fix: unblock agent events after portal direct chat escalation
When a session starts with LLM direct chat then escalates to goosed, invalidate the portal-direct-chat snapshot and stop replaying stale SSE so page-generation turns stream and finish correctly. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -224,6 +224,7 @@ export function createAgentRunGateway({
|
|||||||
toolGateway = null,
|
toolGateway = null,
|
||||||
directChatService = null,
|
directChatService = null,
|
||||||
chatIntentRouter = null,
|
chatIntentRouter = null,
|
||||||
|
sessionSnapshotService = null,
|
||||||
retryDelaysMs = DEFAULT_RUN_RETRY_DELAYS_MS,
|
retryDelaysMs = DEFAULT_RUN_RETRY_DELAYS_MS,
|
||||||
autoDispatch = envFlag(process.env.MEMIND_AGENT_RUN_AUTODISPATCH, true),
|
autoDispatch = envFlag(process.env.MEMIND_AGENT_RUN_AUTODISPATCH, true),
|
||||||
maxConcurrentRuns = positiveInteger(
|
maxConcurrentRuns = positiveInteger(
|
||||||
@@ -409,6 +410,11 @@ export function createAgentRunGateway({
|
|||||||
return Array.isArray(caps?.grantedSkills) ? caps.grantedSkills : [];
|
return Array.isArray(caps?.grantedSkills) ? caps.grantedSkills : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function invalidatePortalDirectChatSnapshot(sessionId) {
|
||||||
|
if (!sessionId || isDirectChatSessionId(sessionId) || !sessionSnapshotService?.remove) return;
|
||||||
|
await sessionSnapshotService.remove(sessionId).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
async function resolveRunRouting(row, userMessage, runOptions) {
|
async function resolveRunRouting(row, userMessage, runOptions) {
|
||||||
if (!chatIntentRouter?.classify) return null;
|
if (!chatIntentRouter?.classify) return null;
|
||||||
const enabled = chatIntentRouter.isEnabled
|
const enabled = chatIntentRouter.isEnabled
|
||||||
@@ -497,6 +503,7 @@ export function createAgentRunGateway({
|
|||||||
const workingDir = userAuth?.resolveWorkingDir
|
const workingDir = userAuth?.resolveWorkingDir
|
||||||
? await userAuth.resolveWorkingDir(row.user_id)
|
? await userAuth.resolveWorkingDir(row.user_id)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
await invalidatePortalDirectChatSnapshot(row.agent_session_id ?? null);
|
||||||
await appendEvent(runId, 'tool_gateway_dispatch', {
|
await appendEvent(runId, 'tool_gateway_dispatch', {
|
||||||
protocol: toolGatewayStatus.protocol ?? 'agent-run-v1',
|
protocol: toolGatewayStatus.protocol ?? 'agent-run-v1',
|
||||||
taskType: runOptions.taskType,
|
taskType: runOptions.taskType,
|
||||||
@@ -563,6 +570,7 @@ export function createAgentRunGateway({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await invalidatePortalDirectChatSnapshot(sessionId);
|
||||||
await tkmindProxy.submitSessionReplyForUser(
|
await tkmindProxy.submitSessionReplyForUser(
|
||||||
row.user_id,
|
row.user_id,
|
||||||
sessionId,
|
sessionId,
|
||||||
|
|||||||
@@ -383,6 +383,63 @@ test('agent run uses direct chat on regular agent sessions when llm routes direc
|
|||||||
assert.ok(pool.events.some((event) => event.eventType === 'direct_chat_completed'));
|
assert.ok(pool.events.some((event) => event.eventType === 'direct_chat_completed'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('agent run invalidates portal direct chat snapshot before submitting to goosed', async () => {
|
||||||
|
const pool = createFakePool();
|
||||||
|
const submitted = [];
|
||||||
|
const invalidated = [];
|
||||||
|
const gateway = createAgentRunGateway({
|
||||||
|
pool,
|
||||||
|
userAuth: {
|
||||||
|
async getUserCapabilities() {
|
||||||
|
return { grantedSkills: ['static-page-publish'] };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tkmindProxy: {
|
||||||
|
async submitSessionReplyForUser(userId, sessionId, requestId, userMessage) {
|
||||||
|
submitted.push({ userId, sessionId, requestId, userMessage });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
chatIntentRouter: {
|
||||||
|
isEnabled() {
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
async classify() {
|
||||||
|
return {
|
||||||
|
route: 'agent_orchestration',
|
||||||
|
confidence: 1,
|
||||||
|
reason: '用户开启深度推理',
|
||||||
|
source: 'rule',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
applyAgentOrchestration(userMessage) {
|
||||||
|
return userMessage;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
sessionSnapshotService: {
|
||||||
|
async remove(sessionId) {
|
||||||
|
invalidated.push(sessionId);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
retryDelaysMs: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const run = await gateway.createRun('user-1', {
|
||||||
|
sessionId: '20260704_31',
|
||||||
|
requestId: 'req-essay-page',
|
||||||
|
forceDeepReasoning: true,
|
||||||
|
userMessage: {
|
||||||
|
role: 'user',
|
||||||
|
content: [{ type: 'text', text: '帮我写一个 200 字散文,做成页面' }],
|
||||||
|
metadata: { displayText: '帮我写一个 200 字散文,做成页面' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => pool.runs.get(run.id)?.status === 'succeeded');
|
||||||
|
assert.deepEqual(invalidated, ['20260704_31']);
|
||||||
|
assert.equal(submitted.length, 1);
|
||||||
|
assert.equal(submitted[0].sessionId, '20260704_31');
|
||||||
|
});
|
||||||
|
|
||||||
test('agent run records direct_chat_skipped when execution is unavailable', async () => {
|
test('agent run records direct_chat_skipped when execution is unavailable', async () => {
|
||||||
const pool = createFakePool();
|
const pool = createFakePool();
|
||||||
const submitted = [];
|
const submitted = [];
|
||||||
|
|||||||
+35
-1
@@ -198,16 +198,50 @@ function evaluateCanHandle({
|
|||||||
return { ok: true, reason: null };
|
return { ok: true, reason: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isPortalDirectChatSnapshot(snapshot) {
|
export function isPortalDirectChatSnapshot(snapshot, { sessionId = null } = {}) {
|
||||||
|
if (!snapshot) return false;
|
||||||
const messages = Array.isArray(snapshot?.messages) ? snapshot.messages : [];
|
const messages = Array.isArray(snapshot?.messages) ? snapshot.messages : [];
|
||||||
const lastAssistant = [...messages].reverse().find((message) => message?.role === 'assistant');
|
const lastAssistant = [...messages].reverse().find((message) => message?.role === 'assistant');
|
||||||
if (lastAssistant?.metadata?.source !== 'portal-direct-chat') return false;
|
if (lastAssistant?.metadata?.source !== 'portal-direct-chat') return false;
|
||||||
|
// Mixed agent sessions: a user turn after the portal-direct-chat assistant means
|
||||||
|
// execution moved to goosed — never replay the stale direct-chat snapshot.
|
||||||
|
if (sessionId && !isDirectChatSessionId(sessionId)) {
|
||||||
|
const lastAssistantIndex = messages.indexOf(lastAssistant);
|
||||||
|
const hasUserTurnAfterDirectChat = messages
|
||||||
|
.slice(lastAssistantIndex + 1)
|
||||||
|
.some((message) => message?.role === 'user');
|
||||||
|
if (hasUserTurnAfterDirectChat) return false;
|
||||||
|
}
|
||||||
const updatedAtRaw = snapshot?.session?.updated_at ?? snapshot?.meta?.saved_at ?? 0;
|
const updatedAtRaw = snapshot?.session?.updated_at ?? snapshot?.meta?.saved_at ?? 0;
|
||||||
const updatedAt = Date.parse(String(updatedAtRaw)) || Number(updatedAtRaw) || 0;
|
const updatedAt = Date.parse(String(updatedAtRaw)) || Number(updatedAtRaw) || 0;
|
||||||
if (!updatedAt) return true;
|
if (!updatedAt) return true;
|
||||||
return Date.now() - updatedAt <= 5 * 60 * 1000;
|
return Date.now() - updatedAt <= 5 * 60 * 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function shouldExpirePortalDirectChatSnapshot(pool, sessionId, snapshot) {
|
||||||
|
if (!pool || !sessionId || !snapshot) return false;
|
||||||
|
if (isDirectChatSessionId(sessionId)) return false;
|
||||||
|
if (!isPortalDirectChatSnapshot(snapshot, { sessionId })) return false;
|
||||||
|
const syncedAt = Number(snapshot?.meta?.synced_at ?? 0);
|
||||||
|
if (!syncedAt) return false;
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT r.id
|
||||||
|
FROM h5_agent_runs r
|
||||||
|
WHERE r.agent_session_id = ?
|
||||||
|
AND r.status = 'succeeded'
|
||||||
|
AND r.completed_at > ?
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM h5_agent_run_events e
|
||||||
|
WHERE e.run_id = r.id
|
||||||
|
AND e.event_type = 'direct_chat_completed'
|
||||||
|
)
|
||||||
|
LIMIT 1`,
|
||||||
|
[sessionId, syncedAt],
|
||||||
|
);
|
||||||
|
return rows.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeUsageForBilling(usage, previousState) {
|
function normalizeUsageForBilling(usage, previousState) {
|
||||||
if (!usage) return null;
|
if (!usage) return null;
|
||||||
const input = Math.max(0, Number(usage.inputTokens ?? 0) || 0);
|
const input = Math.max(0, Number(usage.inputTokens ?? 0) || 0);
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import test from 'node:test';
|
|||||||
import {
|
import {
|
||||||
createDirectChatService,
|
createDirectChatService,
|
||||||
isDirectChatSessionId,
|
isDirectChatSessionId,
|
||||||
|
isPortalDirectChatSnapshot,
|
||||||
sendDirectChatSessionEvents,
|
sendDirectChatSessionEvents,
|
||||||
|
shouldExpirePortalDirectChatSnapshot,
|
||||||
} from './direct-chat-service.mjs';
|
} from './direct-chat-service.mjs';
|
||||||
|
|
||||||
test('direct chat is enabled by default', () => {
|
test('direct chat is enabled by default', () => {
|
||||||
@@ -285,3 +287,115 @@ test('direct chat run saves portal reply into an existing agent session', async
|
|||||||
assert.equal(saved[1].messages.at(-1)?.metadata?.source, 'portal-direct-chat');
|
assert.equal(saved[1].messages.at(-1)?.metadata?.source, 'portal-direct-chat');
|
||||||
assert.match(saved[1].messages.at(-1)?.content?.[0]?.text ?? '', /太湖/);
|
assert.match(saved[1].messages.at(-1)?.content?.[0]?.text ?? '', /太湖/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('portal direct chat snapshot replay stays enabled for dedicated h5direct sessions', () => {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const snapshot = {
|
||||||
|
session: { updated_at: now },
|
||||||
|
messages: [
|
||||||
|
{ role: 'user', content: [{ type: 'text', text: 'hi' }] },
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
metadata: { source: 'portal-direct-chat' },
|
||||||
|
content: [{ type: 'text', text: '你好' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
assert.equal(
|
||||||
|
isPortalDirectChatSnapshot(snapshot, { sessionId: 'h5direct_abc' }),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('portal direct chat snapshot is disabled after agent escalation on regular sessions', () => {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const snapshot = {
|
||||||
|
session: { updated_at: now },
|
||||||
|
messages: [
|
||||||
|
{ role: 'user', content: [{ type: 'text', text: 'hi' }] },
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
metadata: { source: 'portal-direct-chat' },
|
||||||
|
content: [{ type: 'text', text: '你好' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: [{ type: 'text', text: '帮我写一个 200 字散文,做成页面' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
assert.equal(
|
||||||
|
isPortalDirectChatSnapshot(snapshot, { sessionId: '20260704_31' }),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('portal direct chat snapshot still replays a completed direct turn on regular sessions', () => {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const snapshot = {
|
||||||
|
session: { updated_at: now },
|
||||||
|
messages: [
|
||||||
|
{ role: 'user', content: [{ type: 'text', text: '我想对太湖有更多的了解' }] },
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
metadata: { source: 'portal-direct-chat' },
|
||||||
|
content: [{ type: 'text', text: '太湖是中国第三大淡水湖。' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
assert.equal(
|
||||||
|
isPortalDirectChatSnapshot(snapshot, { sessionId: '20260704_11' }),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shouldExpirePortalDirectChatSnapshot detects agent runs after a portal direct turn', async () => {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const snapshot = {
|
||||||
|
session: { updated_at: now },
|
||||||
|
meta: { synced_at: 1000 },
|
||||||
|
messages: [
|
||||||
|
{ role: 'user', content: [{ type: 'text', text: 'hi' }] },
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
metadata: { source: 'portal-direct-chat' },
|
||||||
|
content: [{ type: 'text', text: '你好' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const pool = {
|
||||||
|
async query(_sql, params) {
|
||||||
|
assert.deepEqual(params, ['20260704_31', 1000]);
|
||||||
|
return [[{ id: 'agent-run-1' }]];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
assert.equal(
|
||||||
|
await shouldExpirePortalDirectChatSnapshot(pool, '20260704_31', snapshot),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shouldExpirePortalDirectChatSnapshot keeps pure direct chat sessions fast', async () => {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const snapshot = {
|
||||||
|
session: { updated_at: now },
|
||||||
|
meta: { synced_at: 1000 },
|
||||||
|
messages: [
|
||||||
|
{ role: 'user', content: [{ type: 'text', text: '太湖' }] },
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
metadata: { source: 'portal-direct-chat' },
|
||||||
|
content: [{ type: 'text', text: '太湖是中国第三大淡水湖。' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const pool = {
|
||||||
|
async query() {
|
||||||
|
return [[]];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
assert.equal(
|
||||||
|
await shouldExpirePortalDirectChatSnapshot(pool, '20260704_11', snapshot),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
+17
-4
@@ -164,7 +164,7 @@ import { createScheduleService } from './schedule-service.mjs';
|
|||||||
import { createFeedbackService } from './user-feedback.mjs';
|
import { createFeedbackService } from './user-feedback.mjs';
|
||||||
import { startScheduleReminderWorker } from './schedule-reminder-worker.mjs';
|
import { startScheduleReminderWorker } from './schedule-reminder-worker.mjs';
|
||||||
import { createLlmProviderService, RELAY_BOOTSTRAP } from './llm-providers.mjs';
|
import { createLlmProviderService, RELAY_BOOTSTRAP } from './llm-providers.mjs';
|
||||||
import { createDirectChatService, isDirectChatSessionId, isPortalDirectChatSnapshot, sendDirectChatSessionEvents } from './direct-chat-service.mjs';
|
import { createDirectChatService, isDirectChatSessionId, isPortalDirectChatSnapshot, sendDirectChatSessionEvents, shouldExpirePortalDirectChatSnapshot } from './direct-chat-service.mjs';
|
||||||
import { createManagedChatIntentRouter } from './chat-intent-router.mjs';
|
import { createManagedChatIntentRouter } from './chat-intent-router.mjs';
|
||||||
import { createSessionSnapshotService } from './session-snapshot.mjs';
|
import { createSessionSnapshotService } from './session-snapshot.mjs';
|
||||||
import { createConversationMemoryService } from './conversation-memory.mjs';
|
import { createConversationMemoryService } from './conversation-memory.mjs';
|
||||||
@@ -580,6 +580,7 @@ async function bootstrapUserAuth() {
|
|||||||
toolGateway,
|
toolGateway,
|
||||||
directChatService,
|
directChatService,
|
||||||
chatIntentRouter,
|
chatIntentRouter,
|
||||||
|
sessionSnapshotService,
|
||||||
autoDispatch: ['1', 'true', 'yes', 'on'].includes(
|
autoDispatch: ['1', 'true', 'yes', 'on'].includes(
|
||||||
String(process.env.MEMIND_AGENT_RUN_AUTODISPATCH ?? '1').trim().toLowerCase(),
|
String(process.env.MEMIND_AGENT_RUN_AUTODISPATCH ?? '1').trim().toLowerCase(),
|
||||||
),
|
),
|
||||||
@@ -4470,7 +4471,13 @@ api.get('/sessions/:sessionId', async (req, res, next) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (sessionSnapshotService?.isEnabled()) {
|
if (sessionSnapshotService?.isEnabled()) {
|
||||||
const snapshot = await sessionSnapshotService.get(sessionId);
|
let snapshot = await sessionSnapshotService.get(sessionId);
|
||||||
|
if (snapshot) {
|
||||||
|
if (authPool && await shouldExpirePortalDirectChatSnapshot(authPool, sessionId, snapshot)) {
|
||||||
|
await sessionSnapshotService.remove(sessionId).catch(() => {});
|
||||||
|
snapshot = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (snapshot) {
|
if (snapshot) {
|
||||||
// REGRESSION GUARD: without both hints, stale snapshot can wipe mid-turn chat.
|
// REGRESSION GUARD: without both hints, stale snapshot can wipe mid-turn chat.
|
||||||
const canUseSnapshotCache = hintMc != null && hintUa != null;
|
const canUseSnapshotCache = hintMc != null && hintUa != null;
|
||||||
@@ -4478,7 +4485,7 @@ api.get('/sessions/:sessionId', async (req, res, next) => {
|
|||||||
const uaMatch = snapshot.meta.source_updated_at === hintUa;
|
const uaMatch = snapshot.meta.source_updated_at === hintUa;
|
||||||
if (
|
if (
|
||||||
isDirectChatSessionId(sessionId) ||
|
isDirectChatSessionId(sessionId) ||
|
||||||
isPortalDirectChatSnapshot(snapshot) ||
|
isPortalDirectChatSnapshot(snapshot, { sessionId }) ||
|
||||||
(canUseSnapshotCache && mcMatch && uaMatch)
|
(canUseSnapshotCache && mcMatch && uaMatch)
|
||||||
) {
|
) {
|
||||||
const sanitizedMessages = sanitizeSessionConversationPublicHtmlLinks(
|
const sanitizedMessages = sanitizeSessionConversationPublicHtmlLinks(
|
||||||
@@ -4581,7 +4588,13 @@ api.get('/sessions/:sessionId/events', async (req, res, next) => {
|
|||||||
return sendDirectChatSessionEvents(req, res, snapshot);
|
return sendDirectChatSessionEvents(req, res, snapshot);
|
||||||
}
|
}
|
||||||
const portalDirectSnapshot = await sessionSnapshotService?.get(sessionId).catch(() => null);
|
const portalDirectSnapshot = await sessionSnapshotService?.get(sessionId).catch(() => null);
|
||||||
if (isPortalDirectChatSnapshot(portalDirectSnapshot)) {
|
if (
|
||||||
|
portalDirectSnapshot &&
|
||||||
|
authPool &&
|
||||||
|
await shouldExpirePortalDirectChatSnapshot(authPool, sessionId, portalDirectSnapshot)
|
||||||
|
) {
|
||||||
|
await sessionSnapshotService?.remove(sessionId).catch(() => {});
|
||||||
|
} else if (portalDirectSnapshot && isPortalDirectChatSnapshot(portalDirectSnapshot, { sessionId })) {
|
||||||
return sendDirectChatSessionEvents(req, res, portalDirectSnapshot);
|
return sendDirectChatSessionEvents(req, res, portalDirectSnapshot);
|
||||||
}
|
}
|
||||||
const publishDir = resolveMindSpaceUserPublishDir(__dirname, { id: req.currentUser.id });
|
const publishDir = resolveMindSpaceUserPublishDir(__dirname, { id: req.currentUser.id });
|
||||||
|
|||||||
Reference in New Issue
Block a user