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:
john
2026-07-04 23:15:44 +08:00
parent e45c9300bf
commit a29158d589
5 changed files with 231 additions and 5 deletions
+8
View File
@@ -224,6 +224,7 @@ export function createAgentRunGateway({
toolGateway = null,
directChatService = null,
chatIntentRouter = null,
sessionSnapshotService = null,
retryDelaysMs = DEFAULT_RUN_RETRY_DELAYS_MS,
autoDispatch = envFlag(process.env.MEMIND_AGENT_RUN_AUTODISPATCH, true),
maxConcurrentRuns = positiveInteger(
@@ -409,6 +410,11 @@ export function createAgentRunGateway({
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) {
if (!chatIntentRouter?.classify) return null;
const enabled = chatIntentRouter.isEnabled
@@ -497,6 +503,7 @@ export function createAgentRunGateway({
const workingDir = userAuth?.resolveWorkingDir
? await userAuth.resolveWorkingDir(row.user_id)
: undefined;
await invalidatePortalDirectChatSnapshot(row.agent_session_id ?? null);
await appendEvent(runId, 'tool_gateway_dispatch', {
protocol: toolGatewayStatus.protocol ?? 'agent-run-v1',
taskType: runOptions.taskType,
@@ -563,6 +570,7 @@ export function createAgentRunGateway({
});
}
await invalidatePortalDirectChatSnapshot(sessionId);
await tkmindProxy.submitSessionReplyForUser(
row.user_id,
sessionId,
+57
View File
@@ -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'));
});
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 () => {
const pool = createFakePool();
const submitted = [];
+35 -1
View File
@@ -198,16 +198,50 @@ function evaluateCanHandle({
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 lastAssistant = [...messages].reverse().find((message) => message?.role === 'assistant');
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 updatedAt = Date.parse(String(updatedAtRaw)) || Number(updatedAtRaw) || 0;
if (!updatedAt) return true;
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) {
if (!usage) return null;
const input = Math.max(0, Number(usage.inputTokens ?? 0) || 0);
+114
View File
@@ -3,7 +3,9 @@ import test from 'node:test';
import {
createDirectChatService,
isDirectChatSessionId,
isPortalDirectChatSnapshot,
sendDirectChatSessionEvents,
shouldExpirePortalDirectChatSnapshot,
} from './direct-chat-service.mjs';
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.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
View File
@@ -164,7 +164,7 @@ import { createScheduleService } from './schedule-service.mjs';
import { createFeedbackService } from './user-feedback.mjs';
import { startScheduleReminderWorker } from './schedule-reminder-worker.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 { createSessionSnapshotService } from './session-snapshot.mjs';
import { createConversationMemoryService } from './conversation-memory.mjs';
@@ -580,6 +580,7 @@ async function bootstrapUserAuth() {
toolGateway,
directChatService,
chatIntentRouter,
sessionSnapshotService,
autoDispatch: ['1', 'true', 'yes', 'on'].includes(
String(process.env.MEMIND_AGENT_RUN_AUTODISPATCH ?? '1').trim().toLowerCase(),
),
@@ -4470,7 +4471,13 @@ api.get('/sessions/:sessionId', async (req, res, next) => {
try {
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) {
// REGRESSION GUARD: without both hints, stale snapshot can wipe mid-turn chat.
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;
if (
isDirectChatSessionId(sessionId) ||
isPortalDirectChatSnapshot(snapshot) ||
isPortalDirectChatSnapshot(snapshot, { sessionId }) ||
(canUseSnapshotCache && mcMatch && uaMatch)
) {
const sanitizedMessages = sanitizeSessionConversationPublicHtmlLinks(
@@ -4581,7 +4588,13 @@ api.get('/sessions/:sessionId/events', async (req, res, next) => {
return sendDirectChatSessionEvents(req, res, snapshot);
}
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);
}
const publishDir = resolveMindSpaceUserPublishDir(__dirname, { id: req.currentUser.id });