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,
|
||||
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,
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user