feat(h5-session): Session Broker、run SSE replay 与 Finish 竞态修复

落地 H5 Session 架构 Patch 1–5(Broker 收口、Router decision、SSE taxonomy、goosed 边界检查),
并新增可选 MEMIND_RUN_STREAM_REPLAY run 事件回放与 H5 假交付 guard;修复 Finish 先于 agent-run
gate 导致 UI 永久 loading 的竞态,接入 verify:h5-session-patches 回归脚本。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-07-06 14:19:48 +08:00
parent e2ad3bf62b
commit 08feae8bef
41 changed files with 2728 additions and 130 deletions
+10 -3
View File
@@ -2542,12 +2542,16 @@ export function subscribeAgentRunEvents(
): () => void {
let closed = false;
let activeController: AbortController | null = null;
let lastEventId: string | undefined;
const run = async (controller: AbortController) => {
while (!closed && !controller.signal.aborted) {
try {
const headers: Record<string, string> = { Accept: 'text/event-stream' };
if (lastEventId) headers['Last-Event-ID'] = lastEventId;
const res = await fetch(`${API}${AGENT_RUNS_PATH}/${encodeURIComponent(runId)}/events`, {
headers: { Accept: 'text/event-stream' },
headers,
credentials: 'same-origin',
signal: controller.signal,
});
@@ -2567,18 +2571,21 @@ export function subscribeAgentRunEvents(
while (!closed && !controller.signal.aborted) {
const { done, value } = await reader.read();
if (done) return;
if (done) break;
buffer += decoder.decode(value, { stream: true });
const chunks = buffer.split('\n\n');
buffer = chunks.pop() ?? '';
for (const chunk of chunks) {
let eventId: string | undefined;
let eventName = 'message';
let data = '';
for (const line of chunk.split('\n')) {
if (line.startsWith('id:')) eventId = line.slice(3).trim();
if (line.startsWith('event:')) eventName = line.slice(6).trim();
if (line.startsWith('data:')) data = line.slice(5).trim();
}
if (eventId) lastEventId = eventId;
if (!data) continue;
if (eventName === 'run') {
const payload = JSON.parse(data) as { run: AgentRun };
@@ -2594,7 +2601,7 @@ export function subscribeAgentRunEvents(
} catch (err) {
if (controller.signal.aborted || closed) return;
onError(err instanceof Error ? err : new Error(String(err)));
return;
await new Promise((resolve) => setTimeout(resolve, 1500));
}
}
};
+12 -2
View File
@@ -48,6 +48,10 @@ import type {
import { buildContextPrefix } from '../utils/mindspaceChatContext';
import { buildUserAddressPrefix } from '../utils/userAddress';
import { buildAutoChatSkillPrefix } from '../../chat-skills.mjs';
import {
resolvePostAgentRunChatState,
shouldPromoteSessionIdToStreaming,
} from '../../chat-agent-run-gate.mjs';
import { mergeConversationSnapshot } from '../../chat-finish-sync.mjs';
import {
CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES,
@@ -1366,7 +1370,9 @@ export function useTKMindChat(
}
if (!isDirectChatSessionId(sessionId)) {
subscribeToSession(sessionId);
setChatState('streaming');
if (shouldPromoteSessionIdToStreaming(chatStateRef.current)) {
setChatState('streaming');
}
}
},
onMessages: (snapshotMessages) => {
@@ -1443,7 +1449,11 @@ export function useTKMindChat(
// Fall through to agent streaming when the snapshot is not ready yet.
}
subscribeToSession(activeSessionId);
if (finishedViaPortalDirectChat) {
const nextChatState = resolvePostAgentRunChatState({
chatState: chatStateRef.current,
finishedViaPortalDirectChat,
});
if (nextChatState === 'idle') {
clearActiveRequestMissingTimer();
activeRequestId.current = null;
setChatState('idle');