08feae8bef
落地 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>
87 lines
2.8 KiB
JavaScript
87 lines
2.8 KiB
JavaScript
export const SSE_EVENT_TAXONOMY = {
|
|
DELTA: 'delta',
|
|
CONTROL: 'control',
|
|
TERMINAL: 'terminal',
|
|
};
|
|
|
|
const TERMINAL_RUN_STATUSES = new Set(['succeeded', 'failed']);
|
|
|
|
function envFlag(value, fallback = false) {
|
|
const raw = String(value ?? '').trim().toLowerCase();
|
|
if (!raw) return fallback;
|
|
return ['1', 'true', 'yes', 'on'].includes(raw);
|
|
}
|
|
|
|
export function isSseEventTaxonomyEnabled(env = process.env) {
|
|
return envFlag(env.MEMIND_SSE_EVENT_TAXONOMY, false);
|
|
}
|
|
|
|
export function classifySessionEventTaxonomy(event) {
|
|
const type = String(event?.type ?? '').trim();
|
|
if (!type) return null;
|
|
switch (type) {
|
|
case 'Message':
|
|
return SSE_EVENT_TAXONOMY.DELTA;
|
|
case 'Finish':
|
|
case 'Error':
|
|
return SSE_EVENT_TAXONOMY.TERMINAL;
|
|
case 'UpdateConversation':
|
|
case 'Ping':
|
|
case 'ActiveRequests':
|
|
return SSE_EVENT_TAXONOMY.CONTROL;
|
|
default:
|
|
break;
|
|
}
|
|
const normalized = type.toLowerCase();
|
|
if (normalized === 'message') return SSE_EVENT_TAXONOMY.DELTA;
|
|
if (normalized === 'finish' || normalized === 'error') return SSE_EVENT_TAXONOMY.TERMINAL;
|
|
if (
|
|
normalized === 'updateconversation' ||
|
|
normalized === 'ping' ||
|
|
normalized === 'activerequests'
|
|
) {
|
|
return SSE_EVENT_TAXONOMY.CONTROL;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function classifyRunStreamTaxonomy(eventName, payload = {}) {
|
|
const name = String(eventName ?? '').trim().toLowerCase();
|
|
if (name === 'error') return SSE_EVENT_TAXONOMY.TERMINAL;
|
|
if (name !== 'run') return null;
|
|
const status = String(payload?.run?.status ?? payload?.status ?? '').trim().toLowerCase();
|
|
if (TERMINAL_RUN_STATUSES.has(status)) return SSE_EVENT_TAXONOMY.TERMINAL;
|
|
if (status) return SSE_EVENT_TAXONOMY.CONTROL;
|
|
return null;
|
|
}
|
|
|
|
export function finalizeSessionStreamEvent(event, env = process.env) {
|
|
if (!event || typeof event !== 'object' || Array.isArray(event)) return event;
|
|
if (!isSseEventTaxonomyEnabled(env)) return event;
|
|
const taxonomy = classifySessionEventTaxonomy(event);
|
|
if (!taxonomy || event.taxonomy === taxonomy) return event;
|
|
return { ...event, taxonomy };
|
|
}
|
|
|
|
export function wrapRunStreamPayload(eventName, data, env = process.env) {
|
|
if (!data || typeof data !== 'object' || Array.isArray(data)) return data;
|
|
if (!isSseEventTaxonomyEnabled(env)) return data;
|
|
const taxonomy = classifyRunStreamTaxonomy(eventName, data);
|
|
if (!taxonomy || data.taxonomy === taxonomy) return data;
|
|
return { ...data, taxonomy };
|
|
}
|
|
|
|
export function writeSseErrorAndEnd(res, message, { status = 502 } = {}) {
|
|
if (res.writableEnded) return;
|
|
if (res.headersSent) {
|
|
try {
|
|
res.write(`event: error\ndata: ${JSON.stringify({ message })}\n\n`);
|
|
} catch {
|
|
// Client may already be gone.
|
|
}
|
|
res.end();
|
|
return;
|
|
}
|
|
res.status(status).json({ message });
|
|
}
|