@runtypelabs/persona 4.25.0 → 4.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/{types-Db61aI9J.d.cts → types-BwKTZhg9.d.cts} +14 -0
- package/dist/animations/{types-Db61aI9J.d.ts → types-BwKTZhg9.d.ts} +14 -0
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/animations-extra.d.cts +14 -0
- package/dist/animations-extra.d.ts +14 -0
- package/dist/approval-ui-QAKWNFNM.js +1 -0
- package/dist/approval-ui.cjs +2 -2
- package/dist/approval-ui.d.cts +180 -8
- package/dist/approval-ui.d.ts +180 -8
- package/dist/approval-ui.js +2 -2
- package/dist/{artifacts-ui-IGB5YKLO.js → artifacts-ui-NJGMM27I.js} +1 -1
- package/dist/artifacts-ui.cjs +4 -4
- package/dist/artifacts-ui.d.cts +180 -8
- package/dist/artifacts-ui.d.ts +180 -8
- package/dist/artifacts-ui.js +5 -5
- package/dist/chunk-2ANTRKTV.js +1 -0
- package/dist/chunk-4HPTPUC6.js +1 -0
- package/dist/chunk-6V3MNEKB.js +1 -0
- package/dist/chunk-HGUCGM3K.js +9 -0
- package/dist/chunk-IO5VVUKP.js +3 -0
- package/dist/chunk-J4J3Y2P2.js +1 -0
- package/dist/chunk-K6HKIP5K.js +1 -0
- package/dist/chunk-K7NA7G7J.js +1 -0
- package/dist/chunk-MXUTS3LB.js +1 -0
- package/dist/chunk-PDPVORUF.js +1 -0
- package/dist/chunk-QPPJUSZH.js +1 -0
- package/dist/chunk-QRKGQPU7.js +1 -0
- package/dist/chunk-S6PBWE75.js +1 -0
- package/dist/chunk-T7QM7YMF.js +1 -0
- package/dist/chunk-TR2H475E.js +1 -0
- package/dist/chunk-UPO4GUFC.js +1 -0
- package/dist/chunk-ZC66NAIR.js +1 -0
- package/dist/client-history-YPEG45Q4.js +1 -0
- package/dist/client-history.js +1 -0
- package/dist/client-stream-42FOI3EC.js +4 -0
- package/dist/client-stream.js +5 -0
- package/dist/codegen.cjs +1 -1
- package/dist/codegen.js +1 -1
- package/dist/context-mentions-E7HJYWO6.js +161 -0
- package/dist/{context-mentions-inline-I2RDEBAK.js → context-mentions-inline-QVESLW55.js} +1 -1
- package/dist/context-mentions.cjs +7 -7
- package/dist/context-mentions.d.cts +180 -8
- package/dist/context-mentions.d.ts +180 -8
- package/dist/context-mentions.js +8 -8
- package/dist/event-stream-view-FOIJHFQO.js +1 -0
- package/dist/event-stream-view.cjs +1 -1
- package/dist/event-stream-view.d.cts +183 -31
- package/dist/event-stream-view.d.ts +183 -31
- package/dist/event-stream-view.js +1 -1
- package/dist/forms-ui.d.cts +221 -667
- package/dist/forms-ui.d.ts +221 -667
- package/dist/history-shell-QDQ44PSE.js +3 -0
- package/dist/history-shell.js +3 -0
- package/dist/{history-view-BD6OXLNA.js → history-view-NCFBI5GR.js} +2 -2
- package/dist/history-view.cjs +2 -2
- package/dist/history-view.d.cts +35 -1
- package/dist/history-view.d.ts +35 -1
- package/dist/history-view.js +2 -2
- package/dist/index.cjs +80 -74
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +227 -738
- package/dist/index.d.ts +227 -738
- package/dist/index.global.js +3 -215
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +81 -75
- package/dist/index.js.map +1 -1
- package/dist/install.global.js +1 -1
- package/dist/install.global.js.map +1 -1
- package/dist/launcher.global.js +3 -67
- package/dist/launcher.global.js.map +1 -1
- package/dist/markdown-parsers.js +22 -22
- package/dist/runtype-tts-entry-NFBVWR7N.js +1 -0
- package/dist/runtype-tts.js +1 -1
- package/dist/session-actions-ISSKSI66.js +1 -0
- package/dist/session-actions.js +1 -0
- package/dist/session-reconnect.d.cts +180 -8
- package/dist/session-reconnect.d.ts +180 -8
- package/dist/smart-dom-reader.cjs +13 -13
- package/dist/smart-dom-reader.d.cts +180 -8
- package/dist/smart-dom-reader.d.ts +180 -8
- package/dist/smart-dom-reader.js +11 -11
- package/dist/theme-editor-preview.cjs +116 -98
- package/dist/theme-editor-preview.d.cts +182 -8
- package/dist/theme-editor-preview.d.ts +182 -8
- package/dist/theme-editor-preview.js +49 -54
- package/dist/theme-editor.cjs +2 -2
- package/dist/theme-editor.d.cts +181 -23
- package/dist/theme-editor.d.ts +181 -23
- package/dist/theme-editor.js +2 -2
- package/dist/theme-reference.d.cts +14 -0
- package/dist/theme-reference.d.ts +14 -0
- package/dist/ui-extras-entry-WAYFKVNW.js +3 -0
- package/dist/ui-extras.js +72 -0
- package/dist/voice-runtime-UOQOFAFY.js +17 -0
- package/dist/voice-runtime.cjs +17 -1
- package/dist/voice-runtime.d.cts +11650 -232
- package/dist/voice-runtime.d.ts +11650 -232
- package/dist/voice-runtime.js +17 -1
- package/dist/voice-worklet-player.cjs +19 -4
- package/dist/voice-worklet-player.d.cts +9 -10
- package/dist/voice-worklet-player.d.ts +9 -10
- package/dist/voice-worklet-player.js +19 -4
- package/dist/webmcp-polyfill.js +1 -1
- package/dist/webmcp-runtime-V6ZWW3PI.js +6 -0
- package/dist/webmcp-runtime.cjs +2 -2
- package/dist/webmcp-runtime.d.cts +1 -33
- package/dist/webmcp-runtime.d.ts +1 -33
- package/dist/webmcp-runtime.js +2 -2
- package/dist/widget.css +1 -1
- package/package.json +11 -5
- package/src/approval-paused-tool-settle.test.ts +221 -0
- package/src/client-approve.test.ts +311 -0
- package/src/client-history-eager.ts +9 -0
- package/src/client-history-loader.ts +23 -0
- package/src/client-history.ts +579 -0
- package/src/client-resume.ts +177 -0
- package/src/client-stream-eager.ts +12 -0
- package/src/client-stream-loader.ts +25 -0
- package/src/client-stream.ts +1880 -0
- package/src/client.test.ts +31 -1
- package/src/client.ts +293 -2607
- package/src/components/approval-actions.test.ts +27 -0
- package/src/components/approval-actions.ts +27 -3
- package/src/components/message-bubble.test.ts +19 -0
- package/src/components/message-bubble.ts +2 -0
- package/src/components/registry.ts +6 -6
- package/src/components/tool-bubble.test.ts +9 -0
- package/src/components/tool-bubble.ts +1 -0
- package/src/history-shell-bundle.test.ts +54 -0
- package/src/history-shell-eager.ts +12 -0
- package/src/history-shell-loader.ts +27 -0
- package/src/history-shell.ts +2362 -0
- package/src/history-view-loader.ts +2 -0
- package/src/history-view.ts +3 -0
- package/src/index-core.ts +6 -0
- package/src/index-global.ts +90 -248
- package/src/index.ts +13 -0
- package/src/install.ts +6 -4
- package/src/internal/runtype-history-provider.test.ts +1 -0
- package/src/internal/runtype-history-provider.ts +28 -13
- package/src/plugins/registry.ts +8 -8
- package/src/session-actions-chunk.test.ts +55 -0
- package/src/session-actions-eager.ts +11 -0
- package/src/session-actions-loader.ts +37 -0
- package/src/session-actions.ts +909 -0
- package/src/session-history.test.ts +3 -1
- package/src/session.ts +852 -1536
- package/src/session.voice.test.ts +1170 -5
- package/src/styles/widget.css +58 -0
- package/src/theme-editor/state.ts +91 -91
- package/src/types.ts +198 -5
- package/src/ui-ask-user-sheet.ts +382 -0
- package/src/ui-extras-eager.ts +10 -0
- package/src/ui-extras-entry.ts +9 -0
- package/src/ui-extras-loader.ts +26 -0
- package/src/ui.artifact-rail-resize.test.ts +2 -1
- package/src/ui.ask-user-chunk.test.ts +93 -0
- package/src/ui.history-chunk.test.ts +2 -1
- package/src/ui.history-config-provider.test.ts +2 -1
- package/src/ui.history-confirm-fallback.test.ts +142 -0
- package/src/ui.history-controller.test.ts +2 -1
- package/src/ui.history-lifecycle.test.ts +2 -1
- package/src/ui.history-render-hooks.test.ts +2 -1
- package/src/ui.history-shell.test.ts +2 -1
- package/src/ui.rail-overlay.test.ts +2 -1
- package/src/ui.rail-resize.test.ts +2 -1
- package/src/ui.rail-trigger-pending.test.ts +2 -1
- package/src/ui.ts +340 -2770
- package/src/utils/attachment-manager.ts +67 -67
- package/src/utils/constants.ts +3 -0
- package/src/utils/context-mention-controller.ts +323 -323
- package/src/utils/context-mention-manager.ts +40 -40
- package/src/utils/context-mention-orchestrator.ts +28 -0
- package/src/utils/event-stream-buffer.ts +50 -50
- package/src/utils/event-stream-store.ts +43 -43
- package/src/utils/throughput-tracker.ts +34 -34
- package/src/utils/virtual-scroller.ts +67 -67
- package/src/utils/visitor-store.test.ts +50 -0
- package/src/utils/visitor-store.ts +23 -5
- package/src/voice/__fixtures__/voice-wire/README.md +1 -0
- package/src/voice/__fixtures__/voice-wire/client/cancel.json +3 -0
- package/src/voice/__fixtures__/voice-wire/client/context.json +4 -0
- package/src/voice/__fixtures__/voice-wire/client/delegation_result.json +6 -0
- package/src/voice/__fixtures__/voice-wire/client/delegation_update.json +6 -0
- package/src/voice/__fixtures__/voice-wire/client/ping.json +3 -0
- package/src/voice/__fixtures__/voice-wire/client/playback_progress.json +5 -0
- package/src/voice/__fixtures__/voice-wire/client/start.json +3 -0
- package/src/voice/__fixtures__/voice-wire/server/artifact.json +11 -0
- package/src/voice/__fixtures__/voice-wire/server/attached.json +4 -0
- package/src/voice/__fixtures__/voice-wire/server/audio_clear.json +4 -0
- package/src/voice/__fixtures__/voice-wire/server/audio_end.json +3 -0
- package/src/voice/__fixtures__/voice-wire/server/delegation_cancelled.json +5 -0
- package/src/voice/__fixtures__/voice-wire/server/delegation_completed.json +8 -0
- package/src/voice/__fixtures__/voice-wire/server/delegation_started.json +15 -0
- package/src/voice/__fixtures__/voice-wire/server/error.json +6 -0
- package/src/voice/__fixtures__/voice-wire/server/metrics.json +10 -0
- package/src/voice/__fixtures__/voice-wire/server/pong.json +3 -0
- package/src/voice/__fixtures__/voice-wire/server/session_config.json +16 -0
- package/src/voice/__fixtures__/voice-wire/server/transcript_final.json +7 -0
- package/src/voice/__fixtures__/voice-wire/server/transcript_interim.json +4 -0
- package/src/voice/__fixtures__/voice-wire/server/transcript_partial.json +6 -0
- package/src/voice/__fixtures__/voice-wire/server/transcript_update.json +10 -0
- package/src/voice/__fixtures__/voice-wire/server/warning.json +5 -0
- package/src/voice/audio-playback-manager.test.ts +57 -0
- package/src/voice/audio-playback-manager.ts +120 -98
- package/src/voice/browser-voice-provider.ts +37 -37
- package/src/voice/fallback-speech-engine.ts +22 -16
- package/src/voice/keyed-voice-transcript.ts +314 -0
- package/src/voice/mic-state-styles.ts +182 -0
- package/src/voice/read-aloud-controller.ts +46 -44
- package/src/voice/runtype-speech-engine.ts +35 -33
- package/src/voice/runtype-voice-provider.ts +670 -189
- package/src/voice/session-voice-wiring.ts +266 -0
- package/src/voice/voice-delegation.test.ts +71 -0
- package/src/voice/voice-delegation.ts +364 -0
- package/src/voice/voice-turn-tracker.ts +6 -6
- package/src/voice/voice.test.ts +981 -8
- package/src/voice/worklet-playback-engine.test.ts +81 -0
- package/src/voice/worklet-playback-engine.ts +21 -3
- package/src/voice-runtime-loader.ts +4 -1
- package/src/voice-runtime.ts +7 -0
- package/src/webmcp-runtime-entry.ts +41 -39
- package/dist/approval-ui-3NMYNXBD.js +0 -1
- package/dist/chunk-5EIIHQLQ.js +0 -1
- package/dist/chunk-7CO4T5KB.js +0 -1
- package/dist/chunk-EFJBUH5F.js +0 -1
- package/dist/chunk-R2TMIMDI.js +0 -3
- package/dist/chunk-UTVLOTWB.js +0 -9
- package/dist/context-mentions-45MODWOY.js +0 -161
- package/dist/event-stream-view-GL3ADRY2.js +0 -1
- package/dist/runtype-tts-entry-UJAEF7NZ.js +0 -1
- package/dist/voice-runtime-7DA7MJH4.js +0 -1
- package/dist/webmcp-runtime-JI7T4QYF.js +0 -6
|
@@ -0,0 +1,1880 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSE stream processor for `AgentWidgetClient`: turns a dispatch response
|
|
3
|
+
* body into widget events (messages, reasoning, tools, artifacts, approvals).
|
|
4
|
+
*
|
|
5
|
+
* Split out of `client.ts` so the IIFE/CDN bundle can ship it as the lazy
|
|
6
|
+
* `client-stream.js` sibling chunk (see `client-stream-loader.ts`). It is
|
|
7
|
+
* only needed once a reply starts streaming; the client starts loading it
|
|
8
|
+
* when a dispatch begins, in parallel with the network request.
|
|
9
|
+
*/
|
|
10
|
+
import type { RuntypeExecutionStreamEvent } from "./generated/runtype-openapi-contract";
|
|
11
|
+
import type {
|
|
12
|
+
AgentWidgetConfig,
|
|
13
|
+
AgentWidgetMessage,
|
|
14
|
+
AgentWidgetStreamParser,
|
|
15
|
+
AgentWidgetStreamParserResult,
|
|
16
|
+
AgentWidgetSSEEventParser,
|
|
17
|
+
StopReasonKind,
|
|
18
|
+
ContentPart,
|
|
19
|
+
PersonaArtifactKind,
|
|
20
|
+
PersonaArtifactFileMeta
|
|
21
|
+
} from "./types";
|
|
22
|
+
import type { SSEEventCallback, SSEHandler } from "./client";
|
|
23
|
+
import { isWebMcpToolName } from "./webmcp-bridge";
|
|
24
|
+
import {
|
|
25
|
+
buildArtifactRefRawContent,
|
|
26
|
+
resolveArtifactDisplayMode
|
|
27
|
+
} from "./utils/artifact-display";
|
|
28
|
+
import { extractTextFromJson } from "./utils/formatting";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Derive a download filename for `agent_media` parts that are delivered
|
|
32
|
+
* without one. Maps a few well-known MIME types to friendly extensions and
|
|
33
|
+
* falls back to `attachment.<subtype>` (or just `attachment` for opaque
|
|
34
|
+
* types like `application/octet-stream`).
|
|
35
|
+
*/
|
|
36
|
+
function filenameFromMediaType(mediaType: string): string {
|
|
37
|
+
// MIME types are case-insensitive (RFC 7231); compare against a lowercased
|
|
38
|
+
// copy so callers that pass mixed casing still hit the friendly extensions.
|
|
39
|
+
const lower = mediaType.toLowerCase();
|
|
40
|
+
const knownExtensions: Record<string, string> = {
|
|
41
|
+
"application/pdf": "pdf",
|
|
42
|
+
"application/json": "json",
|
|
43
|
+
"application/zip": "zip",
|
|
44
|
+
"text/plain": "txt",
|
|
45
|
+
"text/csv": "csv",
|
|
46
|
+
"text/markdown": "md"
|
|
47
|
+
};
|
|
48
|
+
const ext = knownExtensions[lower];
|
|
49
|
+
if (ext) return `attachment.${ext}`;
|
|
50
|
+
const slash = lower.indexOf("/");
|
|
51
|
+
if (slash > 0) {
|
|
52
|
+
const subtype = lower.slice(slash + 1).split(";")[0]?.trim() ?? "";
|
|
53
|
+
if (subtype && subtype !== "octet-stream" && /^[a-z0-9.+-]+$/i.test(subtype)) {
|
|
54
|
+
return `attachment.${subtype}`;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return "attachment";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const looksStructured = (value: string) =>
|
|
61
|
+
value.startsWith("{") || value.startsWith("[") || value.startsWith("<");
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Choose the best content source for sealed-segment reconciliation.
|
|
65
|
+
* Prefers the final structured payload from step_complete when the raw
|
|
66
|
+
* buffer is only a partial/unparseable prefix of the same structured format.
|
|
67
|
+
*/
|
|
68
|
+
export function preferFinalStructuredContent(
|
|
69
|
+
rawBuffer: string | undefined,
|
|
70
|
+
finalString: string
|
|
71
|
+
): string {
|
|
72
|
+
if (!rawBuffer) return finalString;
|
|
73
|
+
|
|
74
|
+
const rawTrimmed = rawBuffer.trim();
|
|
75
|
+
const finalTrimmed = finalString.trim();
|
|
76
|
+
if (rawTrimmed.length === 0) return finalString;
|
|
77
|
+
if (finalTrimmed.length === 0) return rawBuffer;
|
|
78
|
+
|
|
79
|
+
const rawLooksStructured = looksStructured(rawTrimmed);
|
|
80
|
+
const finalLooksStructured = looksStructured(finalTrimmed);
|
|
81
|
+
|
|
82
|
+
if (!finalLooksStructured) return rawBuffer;
|
|
83
|
+
if (!rawLooksStructured) return finalString;
|
|
84
|
+
if (finalTrimmed === rawTrimmed) return finalString;
|
|
85
|
+
if (finalTrimmed.startsWith(rawTrimmed)) return finalString;
|
|
86
|
+
|
|
87
|
+
const rawJsonText = extractTextFromJson(rawBuffer);
|
|
88
|
+
const finalJsonText = extractTextFromJson(finalString);
|
|
89
|
+
if (finalJsonText !== null && rawJsonText === null) return finalString;
|
|
90
|
+
|
|
91
|
+
return rawBuffer;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Approval decisions and LOCAL-tool resumes ride the same lazy chunk: they
|
|
95
|
+
// only run after a stream paused, by which time this chunk is loaded.
|
|
96
|
+
export { resolveApproval, resumeFlow } from "./client-resume";
|
|
97
|
+
|
|
98
|
+
export type StreamContext = {
|
|
99
|
+
/** Read live: `updateConfig()` may swap the config mid-stream. */
|
|
100
|
+
config: () => AgentWidgetConfig;
|
|
101
|
+
createStreamParser: () => AgentWidgetStreamParser;
|
|
102
|
+
parseSSEEvent?: AgentWidgetSSEEventParser;
|
|
103
|
+
onSSEEvent?: SSEEventCallback;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Handle custom SSE event parsing via parseSSEEvent callback
|
|
108
|
+
* Returns true if event was handled, false otherwise
|
|
109
|
+
*/
|
|
110
|
+
async function handleCustomSSEEvent(
|
|
111
|
+
ctx: StreamContext,
|
|
112
|
+
payload: unknown,
|
|
113
|
+
onEvent: SSEHandler,
|
|
114
|
+
assistantMessageRef: { current: AgentWidgetMessage | null },
|
|
115
|
+
emitMessage: (msg: AgentWidgetMessage) => void,
|
|
116
|
+
nextSequence: () => number,
|
|
117
|
+
partIdState: { current: string | null }
|
|
118
|
+
): Promise<boolean> {
|
|
119
|
+
if (!ctx.parseSSEEvent) return false;
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
const result = await ctx.parseSSEEvent(payload);
|
|
123
|
+
if (result === null) return false; // Event should be ignored
|
|
124
|
+
|
|
125
|
+
const createNewAssistant = (partId?: string): AgentWidgetMessage => {
|
|
126
|
+
const msg: AgentWidgetMessage = {
|
|
127
|
+
id: `assistant-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
|
128
|
+
role: "assistant",
|
|
129
|
+
content: "",
|
|
130
|
+
createdAt: new Date().toISOString(),
|
|
131
|
+
streaming: true,
|
|
132
|
+
variant: "assistant",
|
|
133
|
+
sequence: nextSequence(),
|
|
134
|
+
...(partId !== undefined && { partId })
|
|
135
|
+
};
|
|
136
|
+
assistantMessageRef.current = msg;
|
|
137
|
+
emitMessage(msg);
|
|
138
|
+
return msg;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const ensureAssistant = (partId?: string) => {
|
|
142
|
+
if (assistantMessageRef.current) return assistantMessageRef.current;
|
|
143
|
+
return createNewAssistant(partId);
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
if (result.text !== undefined) {
|
|
147
|
+
// partId-based message segmentation: when partId changes, seal current
|
|
148
|
+
// message and start a new one for chronological tool/text interleaving
|
|
149
|
+
if (result.partId !== undefined && partIdState.current !== null && result.partId !== partIdState.current) {
|
|
150
|
+
// Seal the current assistant message
|
|
151
|
+
if (assistantMessageRef.current) {
|
|
152
|
+
assistantMessageRef.current.streaming = false;
|
|
153
|
+
emitMessage(assistantMessageRef.current);
|
|
154
|
+
}
|
|
155
|
+
// Create a new assistant message for the new text segment
|
|
156
|
+
createNewAssistant(result.partId);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Update partId tracking (only when partId is provided: backward compatible)
|
|
160
|
+
if (result.partId !== undefined) {
|
|
161
|
+
partIdState.current = result.partId;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const assistant = ensureAssistant(result.partId);
|
|
165
|
+
// Tag the message with partId if present and not already set
|
|
166
|
+
if (result.partId !== undefined && !assistant.partId) {
|
|
167
|
+
assistant.partId = result.partId;
|
|
168
|
+
}
|
|
169
|
+
assistant.content += result.text;
|
|
170
|
+
emitMessage(assistant);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (result.done) {
|
|
174
|
+
if (assistantMessageRef.current) {
|
|
175
|
+
assistantMessageRef.current.streaming = false;
|
|
176
|
+
emitMessage(assistantMessageRef.current);
|
|
177
|
+
}
|
|
178
|
+
partIdState.current = null;
|
|
179
|
+
onEvent({ type: "status", status: "idle" });
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (result.error) {
|
|
183
|
+
partIdState.current = null;
|
|
184
|
+
onEvent({
|
|
185
|
+
type: "error",
|
|
186
|
+
error: new Error(result.error)
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return true; // Event was handled
|
|
191
|
+
} catch (error) {
|
|
192
|
+
if (typeof console !== "undefined") {
|
|
193
|
+
// eslint-disable-next-line no-console
|
|
194
|
+
console.error("[AgentWidget] parseSSEEvent error:", error);
|
|
195
|
+
}
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export async function streamResponse(
|
|
201
|
+
ctx: StreamContext,
|
|
202
|
+
body: ReadableStream<Uint8Array>,
|
|
203
|
+
onEvent: SSEHandler,
|
|
204
|
+
assistantMessageId?: string,
|
|
205
|
+
// Durable reconnect: seed the assistant accumulator with the text already
|
|
206
|
+
// shown before the drop, so replayed post-cursor deltas APPEND to it
|
|
207
|
+
// instead of a fresh stream clobbering it (the replay carries only
|
|
208
|
+
// `seq > after`, i.e. the new deltas, not the full text).
|
|
209
|
+
seedContent?: string
|
|
210
|
+
) {
|
|
211
|
+
const reader = body.getReader();
|
|
212
|
+
const decoder = new TextDecoder();
|
|
213
|
+
let buffer = "";
|
|
214
|
+
|
|
215
|
+
const baseSequence = Date.now();
|
|
216
|
+
let sequenceCounter = 0;
|
|
217
|
+
const nextSequence = () => baseSequence + sequenceCounter++;
|
|
218
|
+
|
|
219
|
+
const cloneMessage = (msg: AgentWidgetMessage): AgentWidgetMessage => {
|
|
220
|
+
const reasoning = msg.reasoning
|
|
221
|
+
? {
|
|
222
|
+
...msg.reasoning,
|
|
223
|
+
chunks: [...msg.reasoning.chunks]
|
|
224
|
+
}
|
|
225
|
+
: undefined;
|
|
226
|
+
const toolCall = msg.toolCall
|
|
227
|
+
? {
|
|
228
|
+
...msg.toolCall,
|
|
229
|
+
chunks: msg.toolCall.chunks ? [...msg.toolCall.chunks] : undefined
|
|
230
|
+
}
|
|
231
|
+
: undefined;
|
|
232
|
+
const tools = msg.tools
|
|
233
|
+
? msg.tools.map((tool) => ({
|
|
234
|
+
...tool,
|
|
235
|
+
chunks: tool.chunks ? [...tool.chunks] : undefined
|
|
236
|
+
}))
|
|
237
|
+
: undefined;
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
...msg,
|
|
241
|
+
reasoning,
|
|
242
|
+
toolCall,
|
|
243
|
+
tools
|
|
244
|
+
};
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
const shouldEmitMessage = (msg: AgentWidgetMessage): boolean => {
|
|
248
|
+
if (msg.role !== "assistant" || msg.variant) return true;
|
|
249
|
+
|
|
250
|
+
const hasContentParts =
|
|
251
|
+
Array.isArray(msg.contentParts) && msg.contentParts.length > 0;
|
|
252
|
+
const hasRawContent =
|
|
253
|
+
typeof msg.rawContent === "string" && msg.rawContent.trim() !== "";
|
|
254
|
+
const hasVisibleText =
|
|
255
|
+
typeof msg.content === "string" && msg.content.trim() !== "";
|
|
256
|
+
|
|
257
|
+
// Do not surface assistant text bubbles that only contain whitespace.
|
|
258
|
+
// Some providers emit newline-only text parts around a leading tool call;
|
|
259
|
+
// rendering those as normal messages creates an empty bubble above the
|
|
260
|
+
// tool card. Keep media/component/stop-reason messages renderable.
|
|
261
|
+
return hasVisibleText || hasContentParts || hasRawContent || Boolean(msg.stopReason);
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const emitMessage = (msg: AgentWidgetMessage) => {
|
|
265
|
+
if (!shouldEmitMessage(msg)) return;
|
|
266
|
+
onEvent({
|
|
267
|
+
type: "message",
|
|
268
|
+
message: cloneMessage(msg)
|
|
269
|
+
});
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
let assistantMessage: AgentWidgetMessage | null = null;
|
|
273
|
+
// Tracks the most recently touched assistant text message for the
|
|
274
|
+
// current agent turn so `turn_complete.stopReason` can attach
|
|
275
|
+
// to the final visible text segment even after `assistantMessage`
|
|
276
|
+
// has been finalized at a tool-call boundary within the turn.
|
|
277
|
+
let lastAssistantInTurn: AgentWidgetMessage | null = null;
|
|
278
|
+
// Reference to track assistant message for custom event handler
|
|
279
|
+
const assistantMessageRef = { current: null as AgentWidgetMessage | null };
|
|
280
|
+
// Segmentation state for the `parseSSEEvent` extensibility callback (the
|
|
281
|
+
// consumer's own `partId` field) — independent of the wire.
|
|
282
|
+
const customParsePartId = { current: null as string | null };
|
|
283
|
+
// Unified text-channel block id (from `text_start`/`text_delta` `id`). Drives
|
|
284
|
+
// bubble-id segmentation on the wire in place of the legacy `partId`:
|
|
285
|
+
// a new block id means a new bubble, sealed at `text_complete`/tool boundaries.
|
|
286
|
+
let currentTextBlockId: string | null = null;
|
|
287
|
+
// Raw text accumulated for the open text block, on both the flow and agent
|
|
288
|
+
// paths — lets a whitespace-only flow block resolve without a stray bubble,
|
|
289
|
+
// and gives the structured-content parser the whole block on either path.
|
|
290
|
+
let pendingTextRaw = "";
|
|
291
|
+
// Nested flow-as-tool attribution (PR #4602): a text/reasoning block whose
|
|
292
|
+
// `parentToolCallId` matches a `tool_start.toolCallId` belongs to a flow
|
|
293
|
+
// running as that tool. Keyed by the wire block id, these route the block's
|
|
294
|
+
// deltas into a message tagged `agentMetadata.parentToolId` (the parent tool's
|
|
295
|
+
// row) instead of the top-level assistant/reasoning channel.
|
|
296
|
+
const nestedBlockParent = new Map<string, string>();
|
|
297
|
+
const nestedBlockMessages = new Map<string, AgentWidgetMessage>();
|
|
298
|
+
const nestedBlockRaw = new Map<string, string>();
|
|
299
|
+
const reasoningMessages = new Map<string, AgentWidgetMessage>();
|
|
300
|
+
const toolMessages = new Map<string, AgentWidgetMessage>();
|
|
301
|
+
const baseAssistantId = assistantMessageId;
|
|
302
|
+
let assistantIdConsumed = false;
|
|
303
|
+
|
|
304
|
+
const ensureAssistantMessage = () => {
|
|
305
|
+
if (assistantMessage) return assistantMessage;
|
|
306
|
+
let id: string;
|
|
307
|
+
let initialContent = "";
|
|
308
|
+
const segment = currentTextBlockId;
|
|
309
|
+
if (!assistantIdConsumed && baseAssistantId) {
|
|
310
|
+
id = baseAssistantId;
|
|
311
|
+
assistantIdConsumed = true;
|
|
312
|
+
// First (and only) time we reuse the caller-supplied id: this is the
|
|
313
|
+
// bubble a durable reconnect resumes into, so continue its text.
|
|
314
|
+
initialContent = seedContent ?? "";
|
|
315
|
+
} else if (baseAssistantId && segment) {
|
|
316
|
+
id = `${baseAssistantId}_${segment}`;
|
|
317
|
+
} else {
|
|
318
|
+
id = `assistant-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
319
|
+
}
|
|
320
|
+
assistantMessage = {
|
|
321
|
+
id,
|
|
322
|
+
role: "assistant",
|
|
323
|
+
content: initialContent,
|
|
324
|
+
createdAt: new Date().toISOString(),
|
|
325
|
+
streaming: true,
|
|
326
|
+
sequence: nextSequence()
|
|
327
|
+
};
|
|
328
|
+
emitMessage(assistantMessage);
|
|
329
|
+
return assistantMessage;
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
const ensureReasoningMessage = (reasoningId: string) => {
|
|
333
|
+
const existing = reasoningMessages.get(reasoningId);
|
|
334
|
+
if (existing) {
|
|
335
|
+
return existing;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const message: AgentWidgetMessage = {
|
|
339
|
+
id: `reason-${reasoningId}`,
|
|
340
|
+
role: "assistant",
|
|
341
|
+
content: "",
|
|
342
|
+
createdAt: new Date().toISOString(),
|
|
343
|
+
streaming: true,
|
|
344
|
+
variant: "reasoning",
|
|
345
|
+
sequence: nextSequence(),
|
|
346
|
+
reasoning: {
|
|
347
|
+
id: reasoningId,
|
|
348
|
+
status: "streaming",
|
|
349
|
+
chunks: []
|
|
350
|
+
}
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
reasoningMessages.set(reasoningId, message);
|
|
354
|
+
emitMessage(message);
|
|
355
|
+
return message;
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
// Track tool call IDs for artifact emit tools so we can suppress their UI
|
|
359
|
+
const artifactToolCallIds = new Set<string>();
|
|
360
|
+
// Track artifact block messages (reference card or inline block) so we can
|
|
361
|
+
// update them on artifact_complete
|
|
362
|
+
const artifactCardMessages = new Map<string, AgentWidgetMessage>();
|
|
363
|
+
// Track artifact IDs that already have a reference card
|
|
364
|
+
const artifactIdsWithCards = new Set<string>();
|
|
365
|
+
// Accumulate artifact markdown content (and component props) for embedding
|
|
366
|
+
// in block props on complete. `props` accumulates across `artifact_update`
|
|
367
|
+
// events so an inline component block hydrates with its real props after a
|
|
368
|
+
// refresh (the session artifact registry, which also tracks them live, is
|
|
369
|
+
// not persisted).
|
|
370
|
+
const artifactContent = new Map<
|
|
371
|
+
string,
|
|
372
|
+
{
|
|
373
|
+
markdown: string;
|
|
374
|
+
title?: string;
|
|
375
|
+
file?: PersonaArtifactFileMeta;
|
|
376
|
+
props?: Record<string, unknown>;
|
|
377
|
+
}
|
|
378
|
+
>();
|
|
379
|
+
const isArtifactEmitToolName = (name: string | undefined): boolean => {
|
|
380
|
+
if (!name) return false;
|
|
381
|
+
const normalized = name.replace(/_+/g, "_").replace(/^_|_$/g, "");
|
|
382
|
+
return normalized === "emit_artifact_markdown" || normalized === "emit_artifact_component";
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
const ensureToolMessage = (toolId: string) => {
|
|
386
|
+
const existing = toolMessages.get(toolId);
|
|
387
|
+
if (existing) {
|
|
388
|
+
return existing;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const message: AgentWidgetMessage = {
|
|
392
|
+
id: `tool-${toolId}`,
|
|
393
|
+
role: "assistant",
|
|
394
|
+
content: "",
|
|
395
|
+
createdAt: new Date().toISOString(),
|
|
396
|
+
streaming: true,
|
|
397
|
+
variant: "tool",
|
|
398
|
+
sequence: nextSequence(),
|
|
399
|
+
toolCall: {
|
|
400
|
+
id: toolId,
|
|
401
|
+
status: "pending"
|
|
402
|
+
}
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
toolMessages.set(toolId, message);
|
|
406
|
+
emitMessage(message);
|
|
407
|
+
return message;
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
const resolveTimestamp = (value: unknown) => {
|
|
411
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
412
|
+
return value;
|
|
413
|
+
}
|
|
414
|
+
if (typeof value === "string") {
|
|
415
|
+
const parsed = Number(value);
|
|
416
|
+
if (!Number.isNaN(parsed) && Number.isFinite(parsed)) {
|
|
417
|
+
return parsed;
|
|
418
|
+
}
|
|
419
|
+
const dateParsed = Date.parse(value);
|
|
420
|
+
if (!Number.isNaN(dateParsed)) {
|
|
421
|
+
return dateParsed;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
return Date.now();
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
const ensureStringContent = (value: unknown): string => {
|
|
428
|
+
if (typeof value === "string") {
|
|
429
|
+
return value;
|
|
430
|
+
}
|
|
431
|
+
if (value === null || value === undefined) {
|
|
432
|
+
return "";
|
|
433
|
+
}
|
|
434
|
+
// Convert objects/arrays to JSON string
|
|
435
|
+
try {
|
|
436
|
+
return JSON.stringify(value);
|
|
437
|
+
} catch {
|
|
438
|
+
return String(value);
|
|
439
|
+
}
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
// Maintain stateful stream parsers per message for incremental parsing
|
|
443
|
+
const streamParsers = new Map<string, AgentWidgetStreamParser>();
|
|
444
|
+
// Track accumulated raw content for structured formats (JSON, XML, etc.)
|
|
445
|
+
const rawContentBuffers = new Map<string, string>();
|
|
446
|
+
/** Reconcile a sealed text block with its authoritative structured response. */
|
|
447
|
+
const reconcileSealedAssistantWithFinalResponse = (
|
|
448
|
+
msg: AgentWidgetMessage,
|
|
449
|
+
finalContent: unknown
|
|
450
|
+
) => {
|
|
451
|
+
const finalString = ensureStringContent(finalContent);
|
|
452
|
+
const rawBuffer = rawContentBuffers.get(msg.id);
|
|
453
|
+
const contentToProcess = preferFinalStructuredContent(rawBuffer, finalString);
|
|
454
|
+
msg.rawContent = contentToProcess;
|
|
455
|
+
const parser = streamParsers.get(msg.id);
|
|
456
|
+
|
|
457
|
+
const mergeIfBetter = (mergedDisplay: string) => {
|
|
458
|
+
const cur = msg.content ?? "";
|
|
459
|
+
if (mergedDisplay.trim() === "") return;
|
|
460
|
+
// Only replace when empty, or when the stream left a strict prefix of the
|
|
461
|
+
// authoritative final (truncation). Do not use length alone: multi-segment
|
|
462
|
+
// flows can have a short last bubble whose content is not a prefix of the
|
|
463
|
+
// full step response.
|
|
464
|
+
if (
|
|
465
|
+
cur.trim().length === 0 ||
|
|
466
|
+
mergedDisplay.startsWith(cur) ||
|
|
467
|
+
mergedDisplay.trimStart().startsWith(cur.trim())
|
|
468
|
+
) {
|
|
469
|
+
msg.content = mergedDisplay;
|
|
470
|
+
}
|
|
471
|
+
};
|
|
472
|
+
|
|
473
|
+
const finalizeCleanup = () => {
|
|
474
|
+
if (parser) {
|
|
475
|
+
const closeResult = parser.close?.();
|
|
476
|
+
if (closeResult instanceof Promise) closeResult.catch(() => {});
|
|
477
|
+
}
|
|
478
|
+
streamParsers.delete(msg.id);
|
|
479
|
+
rawContentBuffers.delete(msg.id);
|
|
480
|
+
msg.streaming = false;
|
|
481
|
+
emitMessage(msg);
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
if (!parser) {
|
|
485
|
+
mergeIfBetter(finalString);
|
|
486
|
+
finalizeCleanup();
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// Prefer JSON fast path when the final payload is JSON-shaped
|
|
491
|
+
const extractedFromJson = extractTextFromJson(contentToProcess);
|
|
492
|
+
if (extractedFromJson !== null && extractedFromJson.trim() !== "") {
|
|
493
|
+
mergeIfBetter(extractedFromJson);
|
|
494
|
+
finalizeCleanup();
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const bestDisplayText = (
|
|
499
|
+
result: AgentWidgetStreamParserResult | string | null
|
|
500
|
+
): string => {
|
|
501
|
+
const text =
|
|
502
|
+
typeof result === "string" ? result : result?.text ?? null;
|
|
503
|
+
if (text !== null && text.trim() !== "") return text;
|
|
504
|
+
const extracted = parser.getExtractedText();
|
|
505
|
+
if (extracted !== null && extracted.trim() !== "") return extracted;
|
|
506
|
+
return finalString;
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
let parsedResult: ReturnType<typeof parser.processChunk>;
|
|
510
|
+
try {
|
|
511
|
+
parsedResult = parser.processChunk(contentToProcess);
|
|
512
|
+
} catch {
|
|
513
|
+
mergeIfBetter(finalString);
|
|
514
|
+
finalizeCleanup();
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
if (parsedResult instanceof Promise) {
|
|
519
|
+
parsedResult
|
|
520
|
+
.then((result) => {
|
|
521
|
+
mergeIfBetter(bestDisplayText(result));
|
|
522
|
+
finalizeCleanup();
|
|
523
|
+
})
|
|
524
|
+
.catch(() => {
|
|
525
|
+
mergeIfBetter(finalString);
|
|
526
|
+
finalizeCleanup();
|
|
527
|
+
});
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
mergeIfBetter(bestDisplayText(parsedResult));
|
|
532
|
+
finalizeCleanup();
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
// === Unified text channel ===
|
|
536
|
+
// Prompt-step and agent text both stream as `text_delta` blocks (segmented by
|
|
537
|
+
// `text_start`/`text_complete`) and can be structured JSON, so each block runs
|
|
538
|
+
// through the per-bubble structured-content parser. This is the legacy
|
|
539
|
+
// step_delta parser core, re-keyed from `partId` to the wire block-id bubble.
|
|
540
|
+
// The caller materializes the bubble lazily (whitespace-only blocks around
|
|
541
|
+
// tool boundaries never leave a stray bubble) and `step_complete.result.response`
|
|
542
|
+
// reconciles the authoritative final.
|
|
543
|
+
let lastSealedFlowBubble: AgentWidgetMessage | null = null;
|
|
544
|
+
|
|
545
|
+
// Stream one accumulated chunk of block text through the parser, setting
|
|
546
|
+
// display `content` (extracted) + `rawContent` (raw) and emitting. Mirrors the
|
|
547
|
+
// legacy step_delta chunk path; plain text bypasses the structured parser.
|
|
548
|
+
const applyTextChunk = (
|
|
549
|
+
assistant: AgentWidgetMessage,
|
|
550
|
+
accumulatedRaw: string,
|
|
551
|
+
chunk: string,
|
|
552
|
+
chunkSeq: number | undefined
|
|
553
|
+
) => {
|
|
554
|
+
assistant.rawContent = accumulatedRaw;
|
|
555
|
+
if (!streamParsers.has(assistant.id)) {
|
|
556
|
+
streamParsers.set(assistant.id, ctx.createStreamParser());
|
|
557
|
+
}
|
|
558
|
+
const parser = streamParsers.get(assistant.id)!;
|
|
559
|
+
const looksLikeJson =
|
|
560
|
+
accumulatedRaw.trim().startsWith("{") || accumulatedRaw.trim().startsWith("[");
|
|
561
|
+
if (looksLikeJson) {
|
|
562
|
+
rawContentBuffers.set(assistant.id, accumulatedRaw);
|
|
563
|
+
}
|
|
564
|
+
const isPlainTextParser = (parser as any).__isPlainTextParser === true;
|
|
565
|
+
if (isPlainTextParser) {
|
|
566
|
+
assistant.content =
|
|
567
|
+
chunkSeq !== undefined ? accumulatedRaw : assistant.content + chunk;
|
|
568
|
+
rawContentBuffers.delete(assistant.id);
|
|
569
|
+
streamParsers.delete(assistant.id);
|
|
570
|
+
assistant.rawContent = undefined;
|
|
571
|
+
emitMessage(assistant);
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
const parsedResult = parser.processChunk(accumulatedRaw);
|
|
575
|
+
if (parsedResult instanceof Promise) {
|
|
576
|
+
parsedResult
|
|
577
|
+
.then((result) => {
|
|
578
|
+
const text = typeof result === "string" ? result : result?.text ?? null;
|
|
579
|
+
if (text !== null && text.trim() !== "") {
|
|
580
|
+
assistant.content = text;
|
|
581
|
+
emitMessage(assistant);
|
|
582
|
+
} else if (!looksLikeJson && !accumulatedRaw.trim().startsWith("<")) {
|
|
583
|
+
assistant.content =
|
|
584
|
+
chunkSeq !== undefined ? accumulatedRaw : assistant.content + chunk;
|
|
585
|
+
rawContentBuffers.delete(assistant.id);
|
|
586
|
+
streamParsers.delete(assistant.id);
|
|
587
|
+
assistant.rawContent = undefined;
|
|
588
|
+
emitMessage(assistant);
|
|
589
|
+
}
|
|
590
|
+
})
|
|
591
|
+
.catch(() => {
|
|
592
|
+
assistant.content =
|
|
593
|
+
chunkSeq !== undefined ? accumulatedRaw : assistant.content + chunk;
|
|
594
|
+
rawContentBuffers.delete(assistant.id);
|
|
595
|
+
streamParsers.delete(assistant.id);
|
|
596
|
+
assistant.rawContent = undefined;
|
|
597
|
+
emitMessage(assistant);
|
|
598
|
+
});
|
|
599
|
+
} else {
|
|
600
|
+
const text =
|
|
601
|
+
typeof parsedResult === "string" ? parsedResult : parsedResult?.text ?? null;
|
|
602
|
+
if (text !== null && text.trim() !== "") {
|
|
603
|
+
assistant.content = text;
|
|
604
|
+
emitMessage(assistant);
|
|
605
|
+
} else if (!looksLikeJson && !accumulatedRaw.trim().startsWith("<")) {
|
|
606
|
+
assistant.content =
|
|
607
|
+
chunkSeq !== undefined ? accumulatedRaw : assistant.content + chunk;
|
|
608
|
+
rawContentBuffers.delete(assistant.id);
|
|
609
|
+
streamParsers.delete(assistant.id);
|
|
610
|
+
assistant.rawContent = undefined;
|
|
611
|
+
emitMessage(assistant);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
};
|
|
615
|
+
|
|
616
|
+
// Seal a flow text block at `text_complete`: run final structured extraction
|
|
617
|
+
// off the accumulated raw buffer (U2: `text_complete.text` mirrors that raw
|
|
618
|
+
// buffer, so we never double-count), then finalize the bubble. The structured
|
|
619
|
+
// `step_complete.result.response` reconciles afterward.
|
|
620
|
+
const finalizeFlowTextBlock = (
|
|
621
|
+
assistant: AgentWidgetMessage,
|
|
622
|
+
finalContent?: unknown
|
|
623
|
+
) => {
|
|
624
|
+
const effectiveFinal =
|
|
625
|
+
finalContent !== undefined && finalContent !== null
|
|
626
|
+
? finalContent
|
|
627
|
+
: assistant.content;
|
|
628
|
+
if (
|
|
629
|
+
effectiveFinal === undefined ||
|
|
630
|
+
effectiveFinal === null ||
|
|
631
|
+
effectiveFinal === ""
|
|
632
|
+
) {
|
|
633
|
+
assistant.streaming = false;
|
|
634
|
+
emitMessage(assistant);
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
const rawBuffer = rawContentBuffers.get(assistant.id);
|
|
638
|
+
const contentToProcess = rawBuffer ?? ensureStringContent(effectiveFinal);
|
|
639
|
+
assistant.rawContent = contentToProcess;
|
|
640
|
+
const parser = streamParsers.get(assistant.id);
|
|
641
|
+
let extractedText: string | null = null;
|
|
642
|
+
let asyncPending = false;
|
|
643
|
+
if (parser) {
|
|
644
|
+
extractedText = parser.getExtractedText();
|
|
645
|
+
if (extractedText === null) {
|
|
646
|
+
extractedText = extractTextFromJson(contentToProcess);
|
|
647
|
+
}
|
|
648
|
+
if (extractedText === null) {
|
|
649
|
+
const parsedResult = parser.processChunk(contentToProcess);
|
|
650
|
+
if (parsedResult instanceof Promise) {
|
|
651
|
+
asyncPending = true;
|
|
652
|
+
parsedResult
|
|
653
|
+
.then((result) => {
|
|
654
|
+
const text =
|
|
655
|
+
typeof result === "string" ? result : result?.text ?? null;
|
|
656
|
+
if (text !== null) {
|
|
657
|
+
assistant.content = text;
|
|
658
|
+
assistant.streaming = false;
|
|
659
|
+
streamParsers.delete(assistant.id);
|
|
660
|
+
rawContentBuffers.delete(assistant.id);
|
|
661
|
+
emitMessage(assistant);
|
|
662
|
+
}
|
|
663
|
+
})
|
|
664
|
+
.catch(() => {});
|
|
665
|
+
} else {
|
|
666
|
+
extractedText =
|
|
667
|
+
typeof parsedResult === "string"
|
|
668
|
+
? parsedResult
|
|
669
|
+
: parsedResult?.text ?? null;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
if (!asyncPending) {
|
|
674
|
+
if (extractedText !== null && extractedText.trim() !== "") {
|
|
675
|
+
assistant.content = extractedText;
|
|
676
|
+
} else if (!rawContentBuffers.has(assistant.id)) {
|
|
677
|
+
assistant.content = ensureStringContent(effectiveFinal);
|
|
678
|
+
}
|
|
679
|
+
const parserToClose = streamParsers.get(assistant.id);
|
|
680
|
+
if (parserToClose) {
|
|
681
|
+
const closeResult = parserToClose.close?.();
|
|
682
|
+
if (closeResult instanceof Promise) closeResult.catch(() => {});
|
|
683
|
+
streamParsers.delete(assistant.id);
|
|
684
|
+
}
|
|
685
|
+
rawContentBuffers.delete(assistant.id);
|
|
686
|
+
assistant.streaming = false;
|
|
687
|
+
emitMessage(assistant);
|
|
688
|
+
}
|
|
689
|
+
};
|
|
690
|
+
|
|
691
|
+
// Materialize (lazily) the message for a nested flow-as-tool block, tagged
|
|
692
|
+
// with the parent tool-call id so the UI renders it in the parent tool's row.
|
|
693
|
+
const ensureNestedBlockMessage = (
|
|
694
|
+
blockId: string,
|
|
695
|
+
parentToolCallId: string,
|
|
696
|
+
variant?: "reasoning"
|
|
697
|
+
): AgentWidgetMessage => {
|
|
698
|
+
const existing = nestedBlockMessages.get(blockId);
|
|
699
|
+
if (existing) return existing;
|
|
700
|
+
const message: AgentWidgetMessage = {
|
|
701
|
+
id: `nested-${parentToolCallId}-${blockId}`,
|
|
702
|
+
role: "assistant",
|
|
703
|
+
content: "",
|
|
704
|
+
createdAt: new Date().toISOString(),
|
|
705
|
+
streaming: true,
|
|
706
|
+
sequence: nextSequence(),
|
|
707
|
+
...(variant ? { variant } : {}),
|
|
708
|
+
...(variant === "reasoning"
|
|
709
|
+
? { reasoning: { id: blockId, status: "streaming", chunks: [] } }
|
|
710
|
+
: {}),
|
|
711
|
+
agentMetadata: { parentToolId: parentToolCallId },
|
|
712
|
+
};
|
|
713
|
+
nestedBlockMessages.set(blockId, message);
|
|
714
|
+
emitMessage(message);
|
|
715
|
+
return message;
|
|
716
|
+
};
|
|
717
|
+
|
|
718
|
+
// Ready queue of parsed wire frames awaiting a drain. The API streams the
|
|
719
|
+
// unified wire vocabulary; each frame is parsed in the SSE loop
|
|
720
|
+
// below and rendered directly by the handler (no translation bridge), then
|
|
721
|
+
// pushed here. The wire stream is a single, in-order SSE connection, so
|
|
722
|
+
// frames drain straight through with no reordering.
|
|
723
|
+
const eventQueue: RuntypeExecutionStreamEvent[] = [];
|
|
724
|
+
// Declared here so later closures can reference it; assigned after all
|
|
725
|
+
// handler-scoped variables are initialised (before the SSE loop).
|
|
726
|
+
let drainReadyQueue: () => void;
|
|
727
|
+
// Per-stream media-block buffer: the media triad
|
|
728
|
+
// (media_start/media_delta/media_complete) is reassembled here into a single
|
|
729
|
+
// synthetic message at media_complete, keyed by the block id.
|
|
730
|
+
const mediaBuffers = new Map<
|
|
731
|
+
string,
|
|
732
|
+
{ mediaType?: string; toolCallId?: string; parts: string[] }
|
|
733
|
+
>();
|
|
734
|
+
// Tracks the last iteration surfaced as a per-iteration message boundary, so
|
|
735
|
+
// `turn_start` advancing the iteration rotates the bubble in 'separate' mode.
|
|
736
|
+
let lastIterationSeen = 0;
|
|
737
|
+
// Execution kind, resolved from the leading `execution_start` frame. Drives
|
|
738
|
+
// the agent-vs-flow branches that the single wire vocabulary collapses.
|
|
739
|
+
let executionKind: "agent" | "flow" = "agent";
|
|
740
|
+
// Whether `executionKind` was set authoritatively by an `execution_start`
|
|
741
|
+
// frame. Continuation streams (e.g. a tool-driven `/resume`) do NOT re-emit
|
|
742
|
+
// `execution_start`, so a fresh `streamResponse` for the continuation starts
|
|
743
|
+
// with the default `"agent"`. For a flow that mis-routes the final
|
|
744
|
+
// prompt-step finalization and duplicates the last message (the streamed
|
|
745
|
+
// text block is sealed, then `step_complete.result.response` re-renders it as
|
|
746
|
+
// a second bubble). When `execution_start` is absent we recover the flow kind
|
|
747
|
+
// from the first flow `step_*` frame below.
|
|
748
|
+
let executionKindResolved = false;
|
|
749
|
+
// Open turn id (from `turn_start`). Unified text/reasoning deltas carry their
|
|
750
|
+
// own block id, not the turn id, so the turn id is threaded onto agentMetadata
|
|
751
|
+
// from here.
|
|
752
|
+
let openTurnId: string | null = null;
|
|
753
|
+
// Agent execution state tracking
|
|
754
|
+
let agentExecution: { executionId: string; agentId: string; agentName: string } | null = null;
|
|
755
|
+
const iterationDisplay = ctx.config().iterationDisplay ?? 'separate';
|
|
756
|
+
|
|
757
|
+
// Drains the queued native events through the main event handler.
|
|
758
|
+
// Also invoked after the SSE loop exits so any events queued at
|
|
759
|
+
// end-of-stream are processed.
|
|
760
|
+
drainReadyQueue = () => {
|
|
761
|
+
for (let i = 0; i < eventQueue.length; i++) {
|
|
762
|
+
const payload = eventQueue[i];
|
|
763
|
+
|
|
764
|
+
// Recover the execution kind on continuation streams that omit
|
|
765
|
+
// `execution_start` (e.g. a tool-driven `/resume`). Flow `step_*` frames
|
|
766
|
+
// carry a `stepType`; agent loops never do (they use `turn_*`). Without
|
|
767
|
+
// this, the continuation defaults to `"agent"` and a flow's final
|
|
768
|
+
// prompt-step finalization is duplicated. We only infer when no
|
|
769
|
+
// `execution_start` resolved the kind, so an explicit `agent` is never
|
|
770
|
+
// overridden.
|
|
771
|
+
if (
|
|
772
|
+
!executionKindResolved &&
|
|
773
|
+
executionKind !== "flow" &&
|
|
774
|
+
typeof (payload as { stepType?: unknown }).stepType === "string"
|
|
775
|
+
) {
|
|
776
|
+
executionKind = "flow";
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
if (payload.type === "reasoning_start") {
|
|
780
|
+
// Nested flow-as-tool thinking (PR #4602): route to the parent tool's row.
|
|
781
|
+
const rStartBlockId = typeof payload.id === "string" ? payload.id : null;
|
|
782
|
+
const rStartParent =
|
|
783
|
+
typeof payload.parentToolCallId === "string" && payload.parentToolCallId
|
|
784
|
+
? payload.parentToolCallId
|
|
785
|
+
: null;
|
|
786
|
+
if (rStartBlockId && rStartParent) {
|
|
787
|
+
nestedBlockParent.set(rStartBlockId, rStartParent);
|
|
788
|
+
ensureNestedBlockMessage(rStartBlockId, rStartParent, "reasoning");
|
|
789
|
+
continue;
|
|
790
|
+
}
|
|
791
|
+
const reasoningId = payload.id;
|
|
792
|
+
const reasoningMessage = ensureReasoningMessage(reasoningId);
|
|
793
|
+
reasoningMessage.reasoning = reasoningMessage.reasoning ?? {
|
|
794
|
+
id: reasoningId,
|
|
795
|
+
status: "streaming",
|
|
796
|
+
chunks: []
|
|
797
|
+
};
|
|
798
|
+
reasoningMessage.reasoning.startedAt =
|
|
799
|
+
reasoningMessage.reasoning.startedAt ??
|
|
800
|
+
Date.now();
|
|
801
|
+
reasoningMessage.reasoning.completedAt = undefined;
|
|
802
|
+
reasoningMessage.reasoning.durationMs = undefined;
|
|
803
|
+
if (payload.scope === "loop" || payload.scope === "turn") {
|
|
804
|
+
reasoningMessage.reasoning.scope = payload.scope;
|
|
805
|
+
}
|
|
806
|
+
reasoningMessage.streaming = true;
|
|
807
|
+
reasoningMessage.reasoning.status = "streaming";
|
|
808
|
+
emitMessage(reasoningMessage);
|
|
809
|
+
} else if (payload.type === "reasoning_delta") {
|
|
810
|
+
// Nested flow-as-tool thinking: append to the parent-tool-row message.
|
|
811
|
+
const rDeltaBlockId = typeof payload.id === "string" ? payload.id : null;
|
|
812
|
+
if (
|
|
813
|
+
rDeltaBlockId &&
|
|
814
|
+
nestedBlockParent.has(rDeltaBlockId) &&
|
|
815
|
+
nestedBlockMessages.has(rDeltaBlockId)
|
|
816
|
+
) {
|
|
817
|
+
const nested = nestedBlockMessages.get(rDeltaBlockId)!;
|
|
818
|
+
const nestedChunk = payload.delta;
|
|
819
|
+
if (nestedChunk && nested.reasoning) {
|
|
820
|
+
nested.reasoning.chunks.push(String(nestedChunk));
|
|
821
|
+
emitMessage(nested);
|
|
822
|
+
}
|
|
823
|
+
continue;
|
|
824
|
+
}
|
|
825
|
+
const reasoningId = payload.id;
|
|
826
|
+
const reasoningMessage = ensureReasoningMessage(reasoningId);
|
|
827
|
+
reasoningMessage.reasoning = reasoningMessage.reasoning ?? {
|
|
828
|
+
id: reasoningId,
|
|
829
|
+
status: "streaming",
|
|
830
|
+
chunks: []
|
|
831
|
+
};
|
|
832
|
+
reasoningMessage.reasoning.startedAt =
|
|
833
|
+
reasoningMessage.reasoning.startedAt ??
|
|
834
|
+
Date.now();
|
|
835
|
+
if (payload.delta) reasoningMessage.reasoning.chunks.push(payload.delta);
|
|
836
|
+
reasoningMessage.reasoning.status = "streaming";
|
|
837
|
+
reasoningMessage.streaming = true;
|
|
838
|
+
emitMessage(reasoningMessage);
|
|
839
|
+
} else if (payload.type === "reasoning_complete") {
|
|
840
|
+
// Nested flow-as-tool thinking close: seal the parent-tool-row message.
|
|
841
|
+
const rCompleteBlockId = typeof payload.id === "string" ? payload.id : null;
|
|
842
|
+
if (
|
|
843
|
+
rCompleteBlockId &&
|
|
844
|
+
nestedBlockParent.has(rCompleteBlockId) &&
|
|
845
|
+
nestedBlockMessages.has(rCompleteBlockId)
|
|
846
|
+
) {
|
|
847
|
+
const nested = nestedBlockMessages.get(rCompleteBlockId)!;
|
|
848
|
+
if (nested.reasoning) {
|
|
849
|
+
const nestedReflection =
|
|
850
|
+
typeof payload.text === "string" ? payload.text : "";
|
|
851
|
+
if (nestedReflection && nested.reasoning.chunks.length === 0) {
|
|
852
|
+
nested.reasoning.chunks.push(nestedReflection);
|
|
853
|
+
}
|
|
854
|
+
nested.reasoning.status = "complete";
|
|
855
|
+
nested.streaming = false;
|
|
856
|
+
emitMessage(nested);
|
|
857
|
+
}
|
|
858
|
+
nestedBlockParent.delete(rCompleteBlockId);
|
|
859
|
+
nestedBlockMessages.delete(rCompleteBlockId);
|
|
860
|
+
continue;
|
|
861
|
+
}
|
|
862
|
+
const reasoningId = payload.id;
|
|
863
|
+
// A close carrying text (or scope:"loop") is a cross-iteration
|
|
864
|
+
// reflection fold (merged spec §4 E3): the API streams nothing for the
|
|
865
|
+
// block, then delivers the whole reflection as `text` on the close.
|
|
866
|
+
// Materialize a reasoning bubble even if no reasoning_start/delta opened
|
|
867
|
+
// one, and adopt the close text when the block streamed no chunks (the
|
|
868
|
+
// common reflection case, where reasoning_start opened an empty bubble).
|
|
869
|
+
const reflectionText = typeof payload.text === "string" ? payload.text : "";
|
|
870
|
+
if (!reasoningMessages.get(reasoningId) && (reflectionText || payload.scope === "loop")) {
|
|
871
|
+
ensureReasoningMessage(reasoningId);
|
|
872
|
+
}
|
|
873
|
+
const reasoningMessage = reasoningMessages.get(reasoningId);
|
|
874
|
+
if (reasoningMessage?.reasoning) {
|
|
875
|
+
if (payload.scope === "loop" || payload.scope === "turn") {
|
|
876
|
+
reasoningMessage.reasoning.scope = payload.scope;
|
|
877
|
+
}
|
|
878
|
+
if (reflectionText && reasoningMessage.reasoning.chunks.length === 0) {
|
|
879
|
+
reasoningMessage.reasoning.chunks.push(reflectionText);
|
|
880
|
+
}
|
|
881
|
+
reasoningMessage.reasoning.status = "complete";
|
|
882
|
+
reasoningMessage.reasoning.completedAt = Date.now();
|
|
883
|
+
const start = reasoningMessage.reasoning.startedAt ?? Date.now();
|
|
884
|
+
reasoningMessage.reasoning.durationMs = Math.max(
|
|
885
|
+
0,
|
|
886
|
+
(reasoningMessage.reasoning.completedAt ?? Date.now()) - start
|
|
887
|
+
);
|
|
888
|
+
reasoningMessage.streaming = false;
|
|
889
|
+
|
|
890
|
+
emitMessage(reasoningMessage);
|
|
891
|
+
}
|
|
892
|
+
} else if (payload.type === "tool_start") {
|
|
893
|
+
// Unified tool family (agent + flow). Seal any open assistant bubble so
|
|
894
|
+
// text→tool→text interleaves chronologically (the API also emits a
|
|
895
|
+
// text_complete here, so this is usually a no-op — kept for safety).
|
|
896
|
+
if (assistantMessage) {
|
|
897
|
+
(assistantMessage as AgentWidgetMessage).streaming = false;
|
|
898
|
+
emitMessage(assistantMessage as AgentWidgetMessage);
|
|
899
|
+
assistantMessage = null;
|
|
900
|
+
}
|
|
901
|
+
// Unified denormalizes `iteration` onto tool frames too (merged spec §2).
|
|
902
|
+
// Track it so media/reflection blocks — which carry no iteration of their
|
|
903
|
+
// own — can be stamped with the enclosing iteration even on tool-only
|
|
904
|
+
// turns that never emit a `turn_start`.
|
|
905
|
+
if (typeof payload.iteration === "number") lastIterationSeen = payload.iteration;
|
|
906
|
+
const toolId = payload.toolCallId;
|
|
907
|
+
const toolName = payload.toolName;
|
|
908
|
+
// Suppress tool UI for artifact emit tools: artifacts are handled via artifact_* events
|
|
909
|
+
if (isArtifactEmitToolName(toolName)) {
|
|
910
|
+
artifactToolCallIds.add(toolId);
|
|
911
|
+
continue;
|
|
912
|
+
}
|
|
913
|
+
const toolMessage = ensureToolMessage(toolId);
|
|
914
|
+
const tool = toolMessage.toolCall ?? {
|
|
915
|
+
id: toolId,
|
|
916
|
+
status: "pending"
|
|
917
|
+
};
|
|
918
|
+
tool.name = toolName ?? tool.name;
|
|
919
|
+
tool.status = "running";
|
|
920
|
+
tool.success = undefined;
|
|
921
|
+
tool.error = undefined;
|
|
922
|
+
tool.duration = undefined;
|
|
923
|
+
if (payload.parameters !== undefined) {
|
|
924
|
+
tool.args = payload.parameters;
|
|
925
|
+
}
|
|
926
|
+
tool.startedAt =
|
|
927
|
+
tool.startedAt ??
|
|
928
|
+
resolveTimestamp(payload.startedAt);
|
|
929
|
+
tool.completedAt = undefined;
|
|
930
|
+
tool.durationMs = undefined;
|
|
931
|
+
toolMessage.toolCall = tool;
|
|
932
|
+
toolMessage.streaming = true;
|
|
933
|
+
if (payload.executionId) {
|
|
934
|
+
toolMessage.agentMetadata = {
|
|
935
|
+
executionId: payload.executionId,
|
|
936
|
+
iteration: payload.iteration,
|
|
937
|
+
};
|
|
938
|
+
}
|
|
939
|
+
emitMessage(toolMessage);
|
|
940
|
+
} else if (payload.type === "tool_output_delta") {
|
|
941
|
+
const toolId = payload.toolCallId;
|
|
942
|
+
if (artifactToolCallIds.has(toolId)) continue;
|
|
943
|
+
const toolMessage = ensureToolMessage(toolId);
|
|
944
|
+
const tool = toolMessage.toolCall ?? {
|
|
945
|
+
id: toolId,
|
|
946
|
+
status: "running"
|
|
947
|
+
};
|
|
948
|
+
tool.startedAt =
|
|
949
|
+
tool.startedAt ??
|
|
950
|
+
Date.now();
|
|
951
|
+
const chunkText = payload.delta;
|
|
952
|
+
if (chunkText) {
|
|
953
|
+
tool.chunks = tool.chunks ?? [];
|
|
954
|
+
tool.chunks.push(String(chunkText));
|
|
955
|
+
}
|
|
956
|
+
tool.status = "running";
|
|
957
|
+
toolMessage.toolCall = tool;
|
|
958
|
+
toolMessage.streaming = true;
|
|
959
|
+
if (payload.executionId) {
|
|
960
|
+
toolMessage.agentMetadata = toolMessage.agentMetadata ?? {
|
|
961
|
+
executionId: payload.executionId,
|
|
962
|
+
iteration: lastIterationSeen,
|
|
963
|
+
};
|
|
964
|
+
}
|
|
965
|
+
emitMessage(toolMessage);
|
|
966
|
+
} else if (payload.type === "tool_complete") {
|
|
967
|
+
const toolId = payload.toolCallId;
|
|
968
|
+
if (artifactToolCallIds.has(toolId)) {
|
|
969
|
+
artifactToolCallIds.delete(toolId);
|
|
970
|
+
continue;
|
|
971
|
+
}
|
|
972
|
+
const toolMessage = ensureToolMessage(toolId);
|
|
973
|
+
const tool = toolMessage.toolCall ?? {
|
|
974
|
+
id: toolId,
|
|
975
|
+
status: "running"
|
|
976
|
+
};
|
|
977
|
+
tool.status = "complete";
|
|
978
|
+
if (payload.result !== undefined) {
|
|
979
|
+
tool.result = payload.result;
|
|
980
|
+
}
|
|
981
|
+
tool.name = payload.toolName ?? tool.name;
|
|
982
|
+
tool.success = payload.success;
|
|
983
|
+
tool.error = payload.error;
|
|
984
|
+
tool.completedAt = Date.now();
|
|
985
|
+
const durationValue = payload.executionTime;
|
|
986
|
+
if (typeof durationValue === "number") {
|
|
987
|
+
tool.durationMs = durationValue;
|
|
988
|
+
tool.duration = durationValue;
|
|
989
|
+
} else {
|
|
990
|
+
const start = tool.startedAt ?? Date.now();
|
|
991
|
+
tool.durationMs = Math.max(
|
|
992
|
+
0,
|
|
993
|
+
(tool.completedAt ?? Date.now()) - start
|
|
994
|
+
);
|
|
995
|
+
}
|
|
996
|
+
toolMessage.toolCall = tool;
|
|
997
|
+
toolMessage.streaming = false;
|
|
998
|
+
if (payload.executionId) {
|
|
999
|
+
toolMessage.agentMetadata = toolMessage.agentMetadata ?? {
|
|
1000
|
+
executionId: payload.executionId,
|
|
1001
|
+
iteration: payload.iteration ?? lastIterationSeen,
|
|
1002
|
+
};
|
|
1003
|
+
}
|
|
1004
|
+
emitMessage(toolMessage);
|
|
1005
|
+
} else if (payload.type === "await" && payload.toolName) {
|
|
1006
|
+
// Unified LOCAL tool pause for either dispatch kind. The execution
|
|
1007
|
+
// waits for /resume with toolOutputs. Page tools with origin "webmcp"
|
|
1008
|
+
// may carry a bare name, normalized to the internal webmcp: prefix.
|
|
1009
|
+
//
|
|
1010
|
+
// Upsert a fully-populated tool-variant message so the existing
|
|
1011
|
+
// ask_user_question bubble + sheet paths fire. Mark the message with
|
|
1012
|
+
// `awaitingLocalTool: true` so the UI knows to resolve via
|
|
1013
|
+
// resumeFlow rather than the legacy sendMessage fallback.
|
|
1014
|
+
//
|
|
1015
|
+
// Key the message by the per-call `toolCallId` (provider `toolu_…`;
|
|
1016
|
+
// core#3878) when present. Two PARALLEL calls to the SAME tool in one
|
|
1017
|
+
// turn collapse to an identical `toolId` (`runtime_webmcp:<name>_<ms>`)
|
|
1018
|
+
// and `index: 0`: only `toolCallId` distinguishes them. Keying on it
|
|
1019
|
+
// (a) keeps the two awaits as DISTINCT messages with their own args
|
|
1020
|
+
// instead of the second clobbering the first, and (b) merges each
|
|
1021
|
+
// await into the matching `tool_start` bubble (also keyed by
|
|
1022
|
+
// `toolCallId`). Fall back to the collapsed `toolId` for legacy
|
|
1023
|
+
// servers that don't emit `toolCallId`.
|
|
1024
|
+
const toolCallId: string | undefined =
|
|
1025
|
+
typeof payload.toolCallId === "string" && payload.toolCallId.length > 0
|
|
1026
|
+
? (payload.toolCallId as string)
|
|
1027
|
+
: undefined;
|
|
1028
|
+
const toolId =
|
|
1029
|
+
toolCallId ?? (payload.toolId as string) ?? `local-${nextSequence()}`;
|
|
1030
|
+
const toolMessage = ensureToolMessage(toolId);
|
|
1031
|
+
const rawToolName = payload.toolName as string;
|
|
1032
|
+
// Page tools may arrive with a bare name; synthesize the
|
|
1033
|
+
// `webmcp:` prefix so isWebMcpToolName (and the bridge's prefix-strip on
|
|
1034
|
+
// resume) treat them identically to a flow `await`.
|
|
1035
|
+
const toolName =
|
|
1036
|
+
payload.origin === "webmcp" &&
|
|
1037
|
+
!isWebMcpToolName(rawToolName)
|
|
1038
|
+
? `webmcp:${rawToolName}`
|
|
1039
|
+
: rawToolName;
|
|
1040
|
+
const webMcpTool = isWebMcpToolName(toolName);
|
|
1041
|
+
const tool = toolMessage.toolCall ?? { id: toolId, status: "pending" as const };
|
|
1042
|
+
tool.name = toolName;
|
|
1043
|
+
tool.args = payload.parameters;
|
|
1044
|
+
// WebMCP tools are executed asynchronously by the browser AFTER this
|
|
1045
|
+
// `await` arrives. Keep them running until session.ts resolves
|
|
1046
|
+
// the page tool and records its actual elapsed time. Other local
|
|
1047
|
+
// tools (for example ask_user_question) keep the existing complete
|
|
1048
|
+
// state because they are waiting for a user interaction, not an
|
|
1049
|
+
// automatic page-tool execution.
|
|
1050
|
+
tool.status = webMcpTool ? "running" : "complete";
|
|
1051
|
+
tool.chunks = tool.chunks ?? [];
|
|
1052
|
+
tool.startedAt =
|
|
1053
|
+
tool.startedAt ??
|
|
1054
|
+
resolveTimestamp(payload.awaitedAt);
|
|
1055
|
+
if (webMcpTool) {
|
|
1056
|
+
tool.completedAt = undefined;
|
|
1057
|
+
tool.duration = undefined;
|
|
1058
|
+
tool.durationMs = undefined;
|
|
1059
|
+
} else {
|
|
1060
|
+
tool.completedAt = tool.completedAt ?? tool.startedAt;
|
|
1061
|
+
}
|
|
1062
|
+
toolMessage.toolCall = tool;
|
|
1063
|
+
toolMessage.streaming = false;
|
|
1064
|
+
toolMessage.agentMetadata = {
|
|
1065
|
+
...toolMessage.agentMetadata,
|
|
1066
|
+
executionId: (payload.executionId as string) ?? toolMessage.agentMetadata?.executionId,
|
|
1067
|
+
awaitingLocalTool: true,
|
|
1068
|
+
// Only set when the server emitted a real per-call id; its presence
|
|
1069
|
+
// is what tells session.ts to batch + key `/resume` by id rather
|
|
1070
|
+
// than by tool name (which can't represent two same-tool calls).
|
|
1071
|
+
...(toolCallId ? { webMcpToolCallId: toolCallId } : {}),
|
|
1072
|
+
};
|
|
1073
|
+
emitMessage(toolMessage);
|
|
1074
|
+
} else if (payload.type === "text_start") {
|
|
1075
|
+
// Nested flow-as-tool text (PR #4602): a `parentToolCallId` means this
|
|
1076
|
+
// block belongs to a flow running as that tool — record the mapping and
|
|
1077
|
+
// leave the top-level assistant bubble untouched (the nested deltas route
|
|
1078
|
+
// into the parent tool's row).
|
|
1079
|
+
const startBlockId = typeof payload.id === "string" ? payload.id : null;
|
|
1080
|
+
const startParent =
|
|
1081
|
+
typeof payload.parentToolCallId === "string" && payload.parentToolCallId
|
|
1082
|
+
? payload.parentToolCallId
|
|
1083
|
+
: null;
|
|
1084
|
+
if (startBlockId && startParent) {
|
|
1085
|
+
nestedBlockParent.set(startBlockId, startParent);
|
|
1086
|
+
continue;
|
|
1087
|
+
}
|
|
1088
|
+
// Unified text-channel block open. A new block id means a new bubble, so
|
|
1089
|
+
// seal any open assistant bubble; the next text_delta creates a fresh one
|
|
1090
|
+
// (lazily). The API emits a fresh block at every tool/media/approval/await
|
|
1091
|
+
// boundary, so block-id keying drives segmentation — no partId.
|
|
1092
|
+
const prev = assistantMessage as AgentWidgetMessage | null;
|
|
1093
|
+
if (prev) {
|
|
1094
|
+
// Normally text_complete already sealed the prior block; this is the
|
|
1095
|
+
// defensive path if a producer opens a new block without closing.
|
|
1096
|
+
if (executionKind === "flow") {
|
|
1097
|
+
finalizeFlowTextBlock(prev);
|
|
1098
|
+
lastSealedFlowBubble = prev;
|
|
1099
|
+
} else {
|
|
1100
|
+
prev.streaming = false;
|
|
1101
|
+
emitMessage(prev);
|
|
1102
|
+
}
|
|
1103
|
+
assistantMessage = null;
|
|
1104
|
+
}
|
|
1105
|
+
currentTextBlockId =
|
|
1106
|
+
typeof payload.id === "string" ? payload.id : currentTextBlockId;
|
|
1107
|
+
pendingTextRaw = "";
|
|
1108
|
+
} else if (payload.type === "text_delta") {
|
|
1109
|
+
// Nested flow-as-tool text: route to the parent tool's row, through the
|
|
1110
|
+
// same structured-content parser, never the top-level assistant channel.
|
|
1111
|
+
const deltaBlockId = typeof payload.id === "string" ? payload.id : null;
|
|
1112
|
+
const nestedParent = deltaBlockId
|
|
1113
|
+
? nestedBlockParent.get(deltaBlockId)
|
|
1114
|
+
: undefined;
|
|
1115
|
+
if (deltaBlockId && nestedParent) {
|
|
1116
|
+
const nestedDelta =
|
|
1117
|
+
typeof payload.delta === "string" ? payload.delta : "";
|
|
1118
|
+
const nestedRaw = (nestedBlockRaw.get(deltaBlockId) ?? "") + nestedDelta;
|
|
1119
|
+
nestedBlockRaw.set(deltaBlockId, nestedRaw);
|
|
1120
|
+
if (nestedRaw.trim() === "") continue;
|
|
1121
|
+
const nested = ensureNestedBlockMessage(deltaBlockId, nestedParent);
|
|
1122
|
+
nested.agentMetadata = {
|
|
1123
|
+
...nested.agentMetadata,
|
|
1124
|
+
executionId: payload.executionId,
|
|
1125
|
+
parentToolId: nestedParent,
|
|
1126
|
+
};
|
|
1127
|
+
applyTextChunk(nested, nestedRaw, nestedDelta, undefined);
|
|
1128
|
+
continue;
|
|
1129
|
+
}
|
|
1130
|
+
currentTextBlockId =
|
|
1131
|
+
typeof payload.id === "string" ? payload.id : currentTextBlockId;
|
|
1132
|
+
if (executionKind === "flow") {
|
|
1133
|
+
// Flow prompt-step text can be structured JSON: accumulate the raw
|
|
1134
|
+
// block and run it through the structured-content parser, keyed by the
|
|
1135
|
+
// block-id bubble. Materialize lazily so a whitespace-only block
|
|
1136
|
+
// (newlines around a tool boundary) never leaves a stray bubble.
|
|
1137
|
+
const delta = typeof payload.delta === "string" ? payload.delta : "";
|
|
1138
|
+
pendingTextRaw += delta;
|
|
1139
|
+
if (pendingTextRaw.trim() === "") continue;
|
|
1140
|
+
const assistant = ensureAssistantMessage();
|
|
1141
|
+
assistant.agentMetadata = {
|
|
1142
|
+
executionId: payload.executionId,
|
|
1143
|
+
iteration: lastIterationSeen,
|
|
1144
|
+
};
|
|
1145
|
+
applyTextChunk(assistant, pendingTextRaw, delta, undefined);
|
|
1146
|
+
lastAssistantInTurn = assistant;
|
|
1147
|
+
continue;
|
|
1148
|
+
}
|
|
1149
|
+
// Agent text can be structured JSON too, whenever the host configures a
|
|
1150
|
+
// custom `streamParser` (e.g. the page-context demo's `{"text": ...}` /
|
|
1151
|
+
// `{"action": "add_to_cart", ...}` envelope). Accumulate the open block's
|
|
1152
|
+
// raw text and run it through the same structured-content path the flow
|
|
1153
|
+
// branch uses, so the parser sees the whole block and `rawContent` stays
|
|
1154
|
+
// populated for the action manager. With the default plain-text parser
|
|
1155
|
+
// this is byte-identical to appending the delta, so agent demos that
|
|
1156
|
+
// stream prose are unaffected.
|
|
1157
|
+
const agentDelta = typeof payload.delta === "string" ? payload.delta : "";
|
|
1158
|
+
pendingTextRaw += agentDelta;
|
|
1159
|
+
const assistant = ensureAssistantMessage();
|
|
1160
|
+
assistant.agentMetadata = {
|
|
1161
|
+
executionId: payload.executionId,
|
|
1162
|
+
iteration: lastIterationSeen,
|
|
1163
|
+
turnId: openTurnId ?? undefined,
|
|
1164
|
+
agentName: agentExecution?.agentName
|
|
1165
|
+
};
|
|
1166
|
+
applyTextChunk(assistant, pendingTextRaw, agentDelta, undefined);
|
|
1167
|
+
lastAssistantInTurn = assistant;
|
|
1168
|
+
} else if (payload.type === "text_complete") {
|
|
1169
|
+
// Nested flow-as-tool text block close: seal its parent-tool-row message.
|
|
1170
|
+
const completeBlockId = typeof payload.id === "string" ? payload.id : null;
|
|
1171
|
+
if (completeBlockId && nestedBlockParent.has(completeBlockId)) {
|
|
1172
|
+
const nested = nestedBlockMessages.get(completeBlockId);
|
|
1173
|
+
if (nested) finalizeFlowTextBlock(nested);
|
|
1174
|
+
nestedBlockParent.delete(completeBlockId);
|
|
1175
|
+
nestedBlockRaw.delete(completeBlockId);
|
|
1176
|
+
nestedBlockMessages.delete(completeBlockId);
|
|
1177
|
+
continue;
|
|
1178
|
+
}
|
|
1179
|
+
// Seal the current text block's bubble.
|
|
1180
|
+
const prev = assistantMessage as AgentWidgetMessage | null;
|
|
1181
|
+
if (prev) {
|
|
1182
|
+
if (executionKind === "flow") {
|
|
1183
|
+
// Final structured extraction off the accumulated raw buffer; the
|
|
1184
|
+
// authoritative step_complete.result.response reconciles next.
|
|
1185
|
+
finalizeFlowTextBlock(prev);
|
|
1186
|
+
lastSealedFlowBubble = prev;
|
|
1187
|
+
} else {
|
|
1188
|
+
// U2: text_complete carries the assembled text, but the bubble already
|
|
1189
|
+
// holds it from the deltas — only fall back to payload.text when no
|
|
1190
|
+
// delta content was seen, never double-count.
|
|
1191
|
+
if ((prev.content ?? "") === "" && typeof payload.text === "string") {
|
|
1192
|
+
prev.content = payload.text;
|
|
1193
|
+
}
|
|
1194
|
+
prev.streaming = false;
|
|
1195
|
+
emitMessage(prev);
|
|
1196
|
+
}
|
|
1197
|
+
assistantMessage = null;
|
|
1198
|
+
}
|
|
1199
|
+
currentTextBlockId = null;
|
|
1200
|
+
pendingTextRaw = "";
|
|
1201
|
+
} else if (payload.type === "step_complete") {
|
|
1202
|
+
// Only process completions for prompt steps, not tool/context steps
|
|
1203
|
+
if (payload.stepType === "tool") {
|
|
1204
|
+
// Skip tool-related completions - they're handled by tool_complete
|
|
1205
|
+
continue;
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
// A failed step (`success:false`) — including the legacy `step_error`
|
|
1209
|
+
// event, which the wire encoder folds into a failed `step_complete`
|
|
1210
|
+
// — surfaces as a terminal error and finalizes the stream.
|
|
1211
|
+
if (payload.success === false) {
|
|
1212
|
+
const message = payload.error || "Step failed";
|
|
1213
|
+
onEvent({ type: "error", error: new Error(message) });
|
|
1214
|
+
const finalMsg = assistantMessage as AgentWidgetMessage | null;
|
|
1215
|
+
if (finalMsg && finalMsg.streaming) {
|
|
1216
|
+
finalMsg.streaming = false;
|
|
1217
|
+
emitMessage(finalMsg);
|
|
1218
|
+
}
|
|
1219
|
+
onEvent({ type: "status", status: "idle" });
|
|
1220
|
+
continue;
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
// Unified flow: reconcile the just-sealed text block with the
|
|
1224
|
+
// authoritative structured final (`result.response`). Displayed content
|
|
1225
|
+
// stays as streamed — a multi-segment step keeps each bubble's own text;
|
|
1226
|
+
// reconcile only fills/repairs the last sealed block and sets rawContent.
|
|
1227
|
+
// A pure-tool / text-less step (no sealed flow bubble) completes silently.
|
|
1228
|
+
{
|
|
1229
|
+
const sealed = lastSealedFlowBubble;
|
|
1230
|
+
lastSealedFlowBubble = null;
|
|
1231
|
+
const flowStopReason = payload.stopReason as
|
|
1232
|
+
| StopReasonKind
|
|
1233
|
+
| undefined;
|
|
1234
|
+
const result = payload.result && typeof payload.result === "object"
|
|
1235
|
+
? payload.result as Record<string, unknown>
|
|
1236
|
+
: null;
|
|
1237
|
+
const finalResponse = result?.response;
|
|
1238
|
+
if (sealed) {
|
|
1239
|
+
if (flowStopReason) sealed.stopReason = flowStopReason;
|
|
1240
|
+
if (finalResponse !== undefined && finalResponse !== null) {
|
|
1241
|
+
reconcileSealedAssistantWithFinalResponse(sealed, finalResponse);
|
|
1242
|
+
} else if (sealed.streaming !== false) {
|
|
1243
|
+
streamParsers.delete(sealed.id);
|
|
1244
|
+
rawContentBuffers.delete(sealed.id);
|
|
1245
|
+
sealed.streaming = false;
|
|
1246
|
+
emitMessage(sealed);
|
|
1247
|
+
}
|
|
1248
|
+
} else {
|
|
1249
|
+
// Buffered / dispatch-mode step: no streamed text block, but the step
|
|
1250
|
+
// carries the final response (and/or a stopReason) — render it as the
|
|
1251
|
+
// assistant message. An empty response + stopReason still surfaces a
|
|
1252
|
+
// sealed bubble so the UI can show an affordance.
|
|
1253
|
+
const hasResponse =
|
|
1254
|
+
finalResponse !== undefined &&
|
|
1255
|
+
finalResponse !== null &&
|
|
1256
|
+
finalResponse !== "";
|
|
1257
|
+
if (hasResponse || flowStopReason) {
|
|
1258
|
+
const assistant = ensureAssistantMessage();
|
|
1259
|
+
if (flowStopReason) assistant.stopReason = flowStopReason;
|
|
1260
|
+
if (hasResponse) {
|
|
1261
|
+
finalizeFlowTextBlock(assistant, finalResponse);
|
|
1262
|
+
} else {
|
|
1263
|
+
assistant.streaming = false;
|
|
1264
|
+
emitMessage(assistant);
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
continue;
|
|
1269
|
+
}
|
|
1270
|
+
// ================================================================
|
|
1271
|
+
// Agent Loop Execution Events
|
|
1272
|
+
// ================================================================
|
|
1273
|
+
} else if (payload.type === "execution_start") {
|
|
1274
|
+
executionKind = payload.kind === "flow" ? "flow" : "agent";
|
|
1275
|
+
executionKindResolved = true;
|
|
1276
|
+
if (executionKind === "agent") {
|
|
1277
|
+
agentExecution = {
|
|
1278
|
+
executionId: payload.executionId,
|
|
1279
|
+
agentId: payload.agentId ?? 'virtual',
|
|
1280
|
+
agentName: payload.agentName ?? ''
|
|
1281
|
+
};
|
|
1282
|
+
}
|
|
1283
|
+
} else if (payload.type === "turn_start") {
|
|
1284
|
+
// Unified collapsed `agent_iteration_*` into a denormalized `iteration`
|
|
1285
|
+
// field on the turn (merged spec §2). Reconstruct the per-iteration
|
|
1286
|
+
// message boundary the 'separate' renderer keys off: when the iteration
|
|
1287
|
+
// advances, seal the previous iteration's bubble and rotate to a new one.
|
|
1288
|
+
const iteration =
|
|
1289
|
+
typeof payload.iteration === "number" ? payload.iteration : lastIterationSeen;
|
|
1290
|
+
if (iteration !== lastIterationSeen) {
|
|
1291
|
+
if (iterationDisplay === 'separate' && iteration > 1) {
|
|
1292
|
+
const prevMsg = assistantMessage as AgentWidgetMessage | null;
|
|
1293
|
+
if (prevMsg) {
|
|
1294
|
+
prevMsg.streaming = false;
|
|
1295
|
+
emitMessage(prevMsg);
|
|
1296
|
+
assistantMessage = null;
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
lastIterationSeen = iteration;
|
|
1300
|
+
}
|
|
1301
|
+
openTurnId = typeof payload.id === "string" ? payload.id : null;
|
|
1302
|
+
// Reset the per-turn assistant tracker. lastAssistantInTurn is used by
|
|
1303
|
+
// turn_complete to attach stopReason to the final text segment of the
|
|
1304
|
+
// turn even if that segment was sealed by an intervening tool boundary.
|
|
1305
|
+
lastAssistantInTurn = null;
|
|
1306
|
+
} else if (payload.type === "tool_input_delta") {
|
|
1307
|
+
// Streamed tool arguments (display-only; authoritative args ride
|
|
1308
|
+
// tool_input_complete / tool_start).
|
|
1309
|
+
const toolId = payload.toolCallId;
|
|
1310
|
+
if (toolId) {
|
|
1311
|
+
const toolMessage = toolMessages.get(toolId);
|
|
1312
|
+
if (toolMessage?.toolCall) {
|
|
1313
|
+
toolMessage.toolCall.chunks = toolMessage.toolCall.chunks ?? [];
|
|
1314
|
+
toolMessage.toolCall.chunks.push(payload.delta ?? '');
|
|
1315
|
+
emitMessage(toolMessage);
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
} else if (payload.type === "tool_input_complete") {
|
|
1319
|
+
if (artifactToolCallIds.has(payload.toolCallId)) continue;
|
|
1320
|
+
const toolMessage = ensureToolMessage(payload.toolCallId);
|
|
1321
|
+
const tool = toolMessage.toolCall!;
|
|
1322
|
+
tool.args = payload.parameters;
|
|
1323
|
+
tool.name = payload.toolName ?? tool.name;
|
|
1324
|
+
emitMessage(toolMessage);
|
|
1325
|
+
} else if (payload.type === "turn_complete") {
|
|
1326
|
+
// Reasoning is sealed by its own reasoning_complete on the wire
|
|
1327
|
+
// vocabulary; this only attaches the turn-level stopReason to the
|
|
1328
|
+
// assistant message produced by this turn. Falls back to
|
|
1329
|
+
// lastAssistantInTurn when the bubble was sealed at a tool boundary
|
|
1330
|
+
// mid-turn, so the notice still attaches to the final visible segment.
|
|
1331
|
+
const turnStopReason = payload.stopReason as
|
|
1332
|
+
| StopReasonKind
|
|
1333
|
+
| undefined;
|
|
1334
|
+
const stopReasonTarget = assistantMessage ?? lastAssistantInTurn;
|
|
1335
|
+
if (turnStopReason && stopReasonTarget !== null) {
|
|
1336
|
+
const turnId = payload.id;
|
|
1337
|
+
const matchesTurn =
|
|
1338
|
+
!turnId || stopReasonTarget.agentMetadata?.turnId === turnId;
|
|
1339
|
+
if (matchesTurn) {
|
|
1340
|
+
stopReasonTarget.stopReason = turnStopReason;
|
|
1341
|
+
emitMessage(stopReasonTarget);
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
if (openTurnId === payload.id) openTurnId = null;
|
|
1345
|
+
} else if (payload.type === "media_start") {
|
|
1346
|
+
// Open a media block; buffer fragments until media_complete.
|
|
1347
|
+
const id = String(payload.id);
|
|
1348
|
+
mediaBuffers.set(id, {
|
|
1349
|
+
mediaType: typeof payload.mediaType === "string" ? payload.mediaType : undefined,
|
|
1350
|
+
toolCallId: payload.toolCallId,
|
|
1351
|
+
parts: [],
|
|
1352
|
+
});
|
|
1353
|
+
} else if (payload.type === "media_delta") {
|
|
1354
|
+
const buf = mediaBuffers.get(String(payload.id));
|
|
1355
|
+
if (buf && typeof payload.delta === "string") buf.parts.push(payload.delta);
|
|
1356
|
+
} else if (payload.type === "media_complete") {
|
|
1357
|
+
// Reassemble the buffered media triad into a single AI SDK–aligned
|
|
1358
|
+
// `MediaContentPart`, then render it as a synthetic assistant message
|
|
1359
|
+
// inserted between the tool bubble and the next text turn:
|
|
1360
|
+
// { type: 'media', data, mediaType } // AI SDK v6: base64
|
|
1361
|
+
// { type: 'image-url', url, mediaType? } // AI SDK v3/v4
|
|
1362
|
+
// { type: 'file-url', url, mediaType } // AI SDK v3/v4
|
|
1363
|
+
const mediaBlockId = String(payload.id);
|
|
1364
|
+
const buf = mediaBuffers.get(mediaBlockId);
|
|
1365
|
+
mediaBuffers.delete(mediaBlockId);
|
|
1366
|
+
const completeMediaType =
|
|
1367
|
+
(typeof payload.mediaType === "string" ? payload.mediaType : undefined) ??
|
|
1368
|
+
buf?.mediaType ??
|
|
1369
|
+
"application/octet-stream";
|
|
1370
|
+
const completeData = typeof payload.data === "string" ? payload.data : undefined;
|
|
1371
|
+
const completeUrl =
|
|
1372
|
+
typeof payload.url === "string"
|
|
1373
|
+
? payload.url
|
|
1374
|
+
: buf && buf.parts.length > 0
|
|
1375
|
+
? buf.parts.join("")
|
|
1376
|
+
: undefined;
|
|
1377
|
+
let reconstructed: Record<string, unknown> | null = null;
|
|
1378
|
+
if (completeData) {
|
|
1379
|
+
reconstructed = { type: "media", data: completeData, mediaType: completeMediaType };
|
|
1380
|
+
} else if (completeUrl) {
|
|
1381
|
+
// The wire is mediaType-only; a URL part with no declared MIME
|
|
1382
|
+
// arrives as the bare bucket hint "image" (per the API encoder). Treat
|
|
1383
|
+
// that — and any real `image/*` — as a hosted image so we don't misroute
|
|
1384
|
+
// generated images into the file bucket.
|
|
1385
|
+
const lower = completeMediaType.toLowerCase();
|
|
1386
|
+
const isImage = lower === "image" || lower.startsWith("image/");
|
|
1387
|
+
reconstructed = {
|
|
1388
|
+
type: isImage ? "image-url" : "file-url",
|
|
1389
|
+
url: completeUrl,
|
|
1390
|
+
mediaType: completeMediaType,
|
|
1391
|
+
};
|
|
1392
|
+
}
|
|
1393
|
+
const mediaToolCallId = payload.toolCallId ?? buf?.toolCallId;
|
|
1394
|
+
const rawMedia = reconstructed ? [reconstructed] : [];
|
|
1395
|
+
const mediaContentParts: ContentPart[] = [];
|
|
1396
|
+
for (const part of rawMedia) {
|
|
1397
|
+
if (!part || typeof part !== "object") continue;
|
|
1398
|
+
const rec = part as Record<string, unknown>;
|
|
1399
|
+
const partType = typeof rec.type === "string" ? rec.type : undefined;
|
|
1400
|
+
|
|
1401
|
+
// Resolve `(src, mediaType)` for the part.
|
|
1402
|
+
// RFC 7231 says MIME types are case-insensitive, so we canonicalize
|
|
1403
|
+
// to lowercase once here. That makes the `startsWith("image/")` /
|
|
1404
|
+
// `"audio/"` / `"video/"` bucket checks robust to upstream tools
|
|
1405
|
+
// that emit non-canonical casing like `Image/PNG`.
|
|
1406
|
+
const rawMediaType =
|
|
1407
|
+
typeof rec.mediaType === "string" ? rec.mediaType.toLowerCase() : "";
|
|
1408
|
+
let src: string | null = null;
|
|
1409
|
+
let mediaType = "";
|
|
1410
|
+
if (partType === "media") {
|
|
1411
|
+
const data = typeof rec.data === "string" ? rec.data : undefined;
|
|
1412
|
+
if (!data) continue;
|
|
1413
|
+
// Empty/missing mediaType yields `data:;base64,...` which RFC 2397
|
|
1414
|
+
// resolves to `text/plain`: stamp a default so the data URI is
|
|
1415
|
+
// well-formed and the part lands in the file bucket.
|
|
1416
|
+
mediaType = rawMediaType.length > 0 ? rawMediaType : "application/octet-stream";
|
|
1417
|
+
src = `data:${mediaType};base64,${data}`;
|
|
1418
|
+
} else if (partType === "image-url") {
|
|
1419
|
+
const url = typeof rec.url === "string" ? rec.url : undefined;
|
|
1420
|
+
if (!url) continue;
|
|
1421
|
+
mediaType = rawMediaType;
|
|
1422
|
+
src = url;
|
|
1423
|
+
} else if (partType === "file-url") {
|
|
1424
|
+
const url = typeof rec.url === "string" ? rec.url : undefined;
|
|
1425
|
+
if (!url) continue;
|
|
1426
|
+
mediaType = rawMediaType;
|
|
1427
|
+
src = url;
|
|
1428
|
+
} else {
|
|
1429
|
+
continue;
|
|
1430
|
+
}
|
|
1431
|
+
if (!src) continue;
|
|
1432
|
+
|
|
1433
|
+
// Pick the right rendering bucket based on mediaType.
|
|
1434
|
+
if (partType === "image-url" || mediaType.startsWith("image/")) {
|
|
1435
|
+
mediaContentParts.push({
|
|
1436
|
+
type: "image",
|
|
1437
|
+
image: src,
|
|
1438
|
+
// Only a real MIME (`image/png`) is a usable mimeType; the bare
|
|
1439
|
+
// bucket hint "image" (a hosted URL with no declared type) is not.
|
|
1440
|
+
...(mediaType.includes("/") ? { mimeType: mediaType } : {}),
|
|
1441
|
+
});
|
|
1442
|
+
} else if (mediaType.startsWith("audio/")) {
|
|
1443
|
+
mediaContentParts.push({
|
|
1444
|
+
type: "audio",
|
|
1445
|
+
audio: src,
|
|
1446
|
+
mimeType: mediaType,
|
|
1447
|
+
});
|
|
1448
|
+
} else if (mediaType.startsWith("video/")) {
|
|
1449
|
+
mediaContentParts.push({
|
|
1450
|
+
type: "video",
|
|
1451
|
+
video: src,
|
|
1452
|
+
mimeType: mediaType,
|
|
1453
|
+
});
|
|
1454
|
+
} else {
|
|
1455
|
+
const resolvedMediaType = mediaType || "application/octet-stream";
|
|
1456
|
+
mediaContentParts.push({
|
|
1457
|
+
type: "file",
|
|
1458
|
+
data: src,
|
|
1459
|
+
mimeType: resolvedMediaType,
|
|
1460
|
+
filename: filenameFromMediaType(resolvedMediaType),
|
|
1461
|
+
});
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
if (mediaContentParts.length > 0) {
|
|
1466
|
+
// Uniquify per emission. A tool may emit multiple `agent_media`
|
|
1467
|
+
// events for the same `toolCallId` (e.g. streamed/batched media);
|
|
1468
|
+
// sharing an id would let `emitMessage` merge them by id and
|
|
1469
|
+
// overwrite the prior `contentParts`.
|
|
1470
|
+
const seq = nextSequence();
|
|
1471
|
+
const toolCallIdRaw = mediaToolCallId;
|
|
1472
|
+
const mediaIdSuffix =
|
|
1473
|
+
typeof toolCallIdRaw === "string" && toolCallIdRaw.length > 0
|
|
1474
|
+
? `${toolCallIdRaw}-${seq}`
|
|
1475
|
+
: String(seq);
|
|
1476
|
+
const mediaMessage: AgentWidgetMessage = {
|
|
1477
|
+
id: `agent-media-${mediaIdSuffix}`,
|
|
1478
|
+
role: "assistant",
|
|
1479
|
+
content: "",
|
|
1480
|
+
contentParts: mediaContentParts,
|
|
1481
|
+
createdAt: new Date().toISOString(),
|
|
1482
|
+
streaming: false,
|
|
1483
|
+
sequence: seq,
|
|
1484
|
+
agentMetadata: {
|
|
1485
|
+
executionId: payload.executionId,
|
|
1486
|
+
// Media blocks carry no iteration of their own; stamp the
|
|
1487
|
+
// enclosing iteration tracked from turn/tool frames.
|
|
1488
|
+
iteration: lastIterationSeen,
|
|
1489
|
+
},
|
|
1490
|
+
};
|
|
1491
|
+
emitMessage(mediaMessage);
|
|
1492
|
+
|
|
1493
|
+
// Seal any in-flight assistant text bubble before splitting the
|
|
1494
|
+
// stream. Without this, an orphan bubble retains `streaming: true`
|
|
1495
|
+
// forever: `execution_complete` only finalizes the latest
|
|
1496
|
+
// `assistantMessage`, so the typing/caret indicator would stay on
|
|
1497
|
+
// the prior bubble even though no more deltas will arrive.
|
|
1498
|
+
const prevAssistant = assistantMessage as AgentWidgetMessage | null;
|
|
1499
|
+
if (prevAssistant) {
|
|
1500
|
+
prevAssistant.streaming = false;
|
|
1501
|
+
emitMessage(prevAssistant);
|
|
1502
|
+
}
|
|
1503
|
+
assistantMessage = null;
|
|
1504
|
+
assistantMessageRef.current = null;
|
|
1505
|
+
}
|
|
1506
|
+
} else if (payload.type === "execution_complete") {
|
|
1507
|
+
// Finalize any still-open assistant message. Per-step reconciliation
|
|
1508
|
+
// (step_complete.result.response) normally sealed the flow blocks
|
|
1509
|
+
// already; this is the defensive close for an unterminated block, and
|
|
1510
|
+
// for flow it runs the final structured extraction off the raw buffer.
|
|
1511
|
+
const finalMsg = assistantMessage as AgentWidgetMessage | null;
|
|
1512
|
+
if (finalMsg) {
|
|
1513
|
+
if (payload.kind === "flow" && finalMsg.streaming !== false) {
|
|
1514
|
+
finalizeFlowTextBlock(finalMsg);
|
|
1515
|
+
} else {
|
|
1516
|
+
finalMsg.streaming = false;
|
|
1517
|
+
emitMessage(finalMsg);
|
|
1518
|
+
}
|
|
1519
|
+
assistantMessage = null;
|
|
1520
|
+
}
|
|
1521
|
+
currentTextBlockId = null;
|
|
1522
|
+
pendingTextRaw = "";
|
|
1523
|
+
lastSealedFlowBubble = null;
|
|
1524
|
+
|
|
1525
|
+
// `terminal: true` marks this as a graceful finish (not a drop). The
|
|
1526
|
+
// session uses it to distinguish the real end-of-turn from the plain
|
|
1527
|
+
// `idle` the dispatch wrappers emit in their `finally` when a durable
|
|
1528
|
+
// connection drops mid-stream (durable-reconnect drop detection).
|
|
1529
|
+
onEvent({ type: "status", status: "idle", terminal: true });
|
|
1530
|
+
} else if (payload.type === "execution_error") {
|
|
1531
|
+
// Terminal failure. The non-terminal `error` is handled
|
|
1532
|
+
// separately (recoverable → warn).
|
|
1533
|
+
const errorMessage = typeof payload.error === 'string'
|
|
1534
|
+
? payload.error
|
|
1535
|
+
: payload.error?.message ?? 'Execution error';
|
|
1536
|
+
onEvent({
|
|
1537
|
+
type: "error",
|
|
1538
|
+
error: new Error(errorMessage)
|
|
1539
|
+
});
|
|
1540
|
+
} else if (payload.type === "ping") {
|
|
1541
|
+
// Keep-alive heartbeat - no action needed
|
|
1542
|
+
// ================================================================
|
|
1543
|
+
// Tool Approval Events
|
|
1544
|
+
// ================================================================
|
|
1545
|
+
} else if (payload.type === "approval_start") {
|
|
1546
|
+
const approvalId = payload.approvalId ?? `approval-${nextSequence()}`;
|
|
1547
|
+
const approvalMessage: AgentWidgetMessage = {
|
|
1548
|
+
id: `approval-${approvalId}`,
|
|
1549
|
+
role: "assistant",
|
|
1550
|
+
content: "",
|
|
1551
|
+
createdAt: new Date().toISOString(),
|
|
1552
|
+
streaming: false,
|
|
1553
|
+
variant: "approval",
|
|
1554
|
+
sequence: nextSequence(),
|
|
1555
|
+
approval: {
|
|
1556
|
+
id: approvalId,
|
|
1557
|
+
status: "pending",
|
|
1558
|
+
agentId: agentExecution?.agentId ?? 'virtual',
|
|
1559
|
+
executionId: payload.executionId ?? agentExecution?.executionId ?? '',
|
|
1560
|
+
toolName: payload.toolName ?? '',
|
|
1561
|
+
toolType: payload.toolType,
|
|
1562
|
+
description: payload.description ?? `Execute ${payload.toolName ?? 'tool'}`,
|
|
1563
|
+
...(typeof payload.reason === "string" && payload.reason
|
|
1564
|
+
? { reason: payload.reason }
|
|
1565
|
+
: {}),
|
|
1566
|
+
parameters: payload.parameters,
|
|
1567
|
+
...(typeof payload.toolCallId === "string" && payload.toolCallId
|
|
1568
|
+
? { toolCallId: payload.toolCallId }
|
|
1569
|
+
: {}),
|
|
1570
|
+
},
|
|
1571
|
+
};
|
|
1572
|
+
emitMessage(approvalMessage);
|
|
1573
|
+
} else if (payload.type === "approval_complete") {
|
|
1574
|
+
const approvalId = payload.approvalId;
|
|
1575
|
+
if (approvalId) {
|
|
1576
|
+
// Find and update the existing approval message
|
|
1577
|
+
const approvalMessageId = `approval-${approvalId}`;
|
|
1578
|
+
const existingMessage: AgentWidgetMessage = {
|
|
1579
|
+
id: approvalMessageId,
|
|
1580
|
+
role: "assistant",
|
|
1581
|
+
content: "",
|
|
1582
|
+
createdAt: new Date().toISOString(),
|
|
1583
|
+
streaming: false,
|
|
1584
|
+
variant: "approval",
|
|
1585
|
+
sequence: nextSequence(),
|
|
1586
|
+
approval: {
|
|
1587
|
+
id: approvalId,
|
|
1588
|
+
status: (payload.decision as "approved" | "denied") ?? "approved",
|
|
1589
|
+
agentId: agentExecution?.agentId ?? 'virtual',
|
|
1590
|
+
executionId: payload.executionId ?? agentExecution?.executionId ?? '',
|
|
1591
|
+
toolName: '',
|
|
1592
|
+
description: '',
|
|
1593
|
+
resolvedAt: Date.now(),
|
|
1594
|
+
},
|
|
1595
|
+
};
|
|
1596
|
+
emitMessage(existingMessage);
|
|
1597
|
+
}
|
|
1598
|
+
} else if (
|
|
1599
|
+
payload.type === "artifact_start" ||
|
|
1600
|
+
payload.type === "artifact_delta" ||
|
|
1601
|
+
payload.type === "artifact_update" ||
|
|
1602
|
+
payload.type === "artifact_complete"
|
|
1603
|
+
) {
|
|
1604
|
+
if (payload.type === "artifact_start") {
|
|
1605
|
+
const at = payload.artifactType as PersonaArtifactKind;
|
|
1606
|
+
const artId = String(payload.id);
|
|
1607
|
+
const artTitle = typeof payload.title === "string" ? payload.title : undefined;
|
|
1608
|
+
// Additive `file` metadata: validate shape (object with string path +
|
|
1609
|
+
// string mimeType; optional string language). Drop silently if malformed
|
|
1610
|
+
// so old/new backends and unexpected payloads never break the stream.
|
|
1611
|
+
const rawFile = payload.file;
|
|
1612
|
+
let artFile: PersonaArtifactFileMeta | undefined;
|
|
1613
|
+
if (
|
|
1614
|
+
rawFile &&
|
|
1615
|
+
typeof rawFile === "object" &&
|
|
1616
|
+
!Array.isArray(rawFile) &&
|
|
1617
|
+
typeof rawFile.path === "string" &&
|
|
1618
|
+
typeof rawFile.mimeType === "string"
|
|
1619
|
+
) {
|
|
1620
|
+
artFile = {
|
|
1621
|
+
path: rawFile.path,
|
|
1622
|
+
mimeType: rawFile.mimeType,
|
|
1623
|
+
...(typeof rawFile.language === "string" ? { language: rawFile.language } : {}),
|
|
1624
|
+
};
|
|
1625
|
+
}
|
|
1626
|
+
onEvent({
|
|
1627
|
+
type: "artifact_start",
|
|
1628
|
+
id: artId,
|
|
1629
|
+
artifactType: at,
|
|
1630
|
+
title: artTitle,
|
|
1631
|
+
component: typeof payload.component === "string" ? payload.component : undefined,
|
|
1632
|
+
...(artFile ? { file: artFile } : {})
|
|
1633
|
+
});
|
|
1634
|
+
artifactContent.set(artId, {
|
|
1635
|
+
markdown: "",
|
|
1636
|
+
title: artTitle,
|
|
1637
|
+
file: artFile,
|
|
1638
|
+
});
|
|
1639
|
+
// Insert the in-thread artifact block once per ID.
|
|
1640
|
+
// The resolved display mode picks the component:
|
|
1641
|
+
// "card"/"panel" inject the reference card; "inline" injects the
|
|
1642
|
+
// inline preview block. Both share the rawContent JSON-component
|
|
1643
|
+
// shape so transcript persistence and hydration work unchanged.
|
|
1644
|
+
if (!artifactIdsWithCards.has(artId)) {
|
|
1645
|
+
artifactIdsWithCards.add(artId);
|
|
1646
|
+
const displayMode = resolveArtifactDisplayMode(
|
|
1647
|
+
ctx.config().features?.artifacts,
|
|
1648
|
+
{
|
|
1649
|
+
artifactType: at,
|
|
1650
|
+
...(artFile ? { file: artFile } : {}),
|
|
1651
|
+
}
|
|
1652
|
+
);
|
|
1653
|
+
const artComponent =
|
|
1654
|
+
typeof payload.component === "string" ? payload.component : undefined;
|
|
1655
|
+
const cardMsg: AgentWidgetMessage = {
|
|
1656
|
+
id: `artifact-ref-${artId}`,
|
|
1657
|
+
role: "assistant",
|
|
1658
|
+
content: "",
|
|
1659
|
+
createdAt: new Date().toISOString(),
|
|
1660
|
+
streaming: true,
|
|
1661
|
+
sequence: nextSequence(),
|
|
1662
|
+
rawContent: buildArtifactRefRawContent(displayMode, {
|
|
1663
|
+
artifactId: artId,
|
|
1664
|
+
title: artTitle,
|
|
1665
|
+
artifactType: at,
|
|
1666
|
+
status: "streaming",
|
|
1667
|
+
...(artFile ? { file: artFile } : {}),
|
|
1668
|
+
...(artComponent ? { component: artComponent } : {}),
|
|
1669
|
+
}),
|
|
1670
|
+
};
|
|
1671
|
+
artifactCardMessages.set(artId, cardMsg);
|
|
1672
|
+
emitMessage(cardMsg);
|
|
1673
|
+
}
|
|
1674
|
+
} else if (payload.type === "artifact_delta") {
|
|
1675
|
+
const deltaId = String(payload.id);
|
|
1676
|
+
const deltaText = typeof payload.delta === "string" ? payload.delta : String(payload.delta ?? "");
|
|
1677
|
+
onEvent({
|
|
1678
|
+
type: "artifact_delta",
|
|
1679
|
+
id: deltaId,
|
|
1680
|
+
artDelta: deltaText
|
|
1681
|
+
});
|
|
1682
|
+
const acc = artifactContent.get(deltaId);
|
|
1683
|
+
if (acc) acc.markdown += deltaText;
|
|
1684
|
+
} else if (payload.type === "artifact_update") {
|
|
1685
|
+
const props =
|
|
1686
|
+
payload.props && typeof payload.props === "object" && !Array.isArray(payload.props)
|
|
1687
|
+
? (payload.props as Record<string, unknown>)
|
|
1688
|
+
: {};
|
|
1689
|
+
onEvent({
|
|
1690
|
+
type: "artifact_update",
|
|
1691
|
+
id: String(payload.id),
|
|
1692
|
+
props,
|
|
1693
|
+
component: typeof payload.component === "string" ? payload.component : undefined
|
|
1694
|
+
});
|
|
1695
|
+
// Accumulate for hydration: embedded on artifact_complete so an
|
|
1696
|
+
// inline component block re-renders with its real props after a
|
|
1697
|
+
// refresh.
|
|
1698
|
+
const updateAcc = artifactContent.get(String(payload.id));
|
|
1699
|
+
if (updateAcc) {
|
|
1700
|
+
updateAcc.props = { ...(updateAcc.props ?? {}), ...props };
|
|
1701
|
+
}
|
|
1702
|
+
} else if (payload.type === "artifact_complete") {
|
|
1703
|
+
const artCompleteId = String(payload.id);
|
|
1704
|
+
onEvent({ type: "artifact_complete", id: artCompleteId });
|
|
1705
|
+
// Update the inline card to show completed state
|
|
1706
|
+
const refMsg = artifactCardMessages.get(artCompleteId);
|
|
1707
|
+
if (refMsg) {
|
|
1708
|
+
refMsg.streaming = false;
|
|
1709
|
+
try {
|
|
1710
|
+
const parsed = JSON.parse(refMsg.rawContent ?? "{}");
|
|
1711
|
+
if (parsed.props) {
|
|
1712
|
+
parsed.props.status = "complete";
|
|
1713
|
+
// Store markdown content in card props so download works after page refresh
|
|
1714
|
+
const acc = artifactContent.get(artCompleteId);
|
|
1715
|
+
if (acc?.markdown) {
|
|
1716
|
+
parsed.props.markdown = acc.markdown;
|
|
1717
|
+
}
|
|
1718
|
+
// Persist file metadata too so the download path unfences correctly after refresh.
|
|
1719
|
+
if (acc?.file) {
|
|
1720
|
+
parsed.props.file = acc.file;
|
|
1721
|
+
}
|
|
1722
|
+
// Embed accumulated component props so an inline component
|
|
1723
|
+
// block hydrates with its real props after a refresh. Only
|
|
1724
|
+
// the inline block reads them; the card never does.
|
|
1725
|
+
if (
|
|
1726
|
+
parsed.component === "PersonaArtifactInline" &&
|
|
1727
|
+
acc?.props &&
|
|
1728
|
+
Object.keys(acc.props).length > 0
|
|
1729
|
+
) {
|
|
1730
|
+
parsed.props.componentProps = acc.props;
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
refMsg.rawContent = JSON.stringify(parsed);
|
|
1734
|
+
} catch { /* ignore parse errors */ }
|
|
1735
|
+
artifactContent.delete(artCompleteId);
|
|
1736
|
+
emitMessage(refMsg);
|
|
1737
|
+
artifactCardMessages.delete(artCompleteId);
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
} else if (payload.type === "error") {
|
|
1741
|
+
// Unified non-terminal error (merged spec). A bare `error` is
|
|
1742
|
+
// recoverable by default — a transient notice such as "rate limited,
|
|
1743
|
+
// retrying" — and the execution continues, so it must NOT surface as a
|
|
1744
|
+
// fatal error or finalize the stream. The API routes terminal failures
|
|
1745
|
+
// through `execution_error`. Only an explicit `recoverable: false`
|
|
1746
|
+
// promotes an `error` to terminal.
|
|
1747
|
+
if (
|
|
1748
|
+
payload.recoverable === false &&
|
|
1749
|
+
payload.error != null &&
|
|
1750
|
+
payload.error !== ""
|
|
1751
|
+
) {
|
|
1752
|
+
const errorMessage =
|
|
1753
|
+
typeof payload.error === "string"
|
|
1754
|
+
? payload.error
|
|
1755
|
+
: (payload.error as { message?: unknown })?.message != null
|
|
1756
|
+
? String((payload.error as { message?: unknown }).message)
|
|
1757
|
+
: "Execution error";
|
|
1758
|
+
onEvent({ type: "error", error: new Error(errorMessage) });
|
|
1759
|
+
const finalMsg = assistantMessage as AgentWidgetMessage | null;
|
|
1760
|
+
if (finalMsg && finalMsg.streaming) {
|
|
1761
|
+
finalMsg.streaming = false;
|
|
1762
|
+
emitMessage(finalMsg);
|
|
1763
|
+
}
|
|
1764
|
+
onEvent({ type: "status", status: "idle" });
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1769
|
+
eventQueue.length = 0;
|
|
1770
|
+
};
|
|
1771
|
+
|
|
1772
|
+
// eslint-disable-next-line no-constant-condition
|
|
1773
|
+
while (true) {
|
|
1774
|
+
const { done, value } = await reader.read();
|
|
1775
|
+
if (done) break;
|
|
1776
|
+
|
|
1777
|
+
buffer += decoder.decode(value, { stream: true });
|
|
1778
|
+
const events = buffer.split("\n\n");
|
|
1779
|
+
buffer = events.pop() ?? "";
|
|
1780
|
+
|
|
1781
|
+
for (const event of events) {
|
|
1782
|
+
const lines = event.split("\n");
|
|
1783
|
+
let eventType = "message";
|
|
1784
|
+
let data = "";
|
|
1785
|
+
// Durable-reconnect cursor: the SSE `id:` line (the durable row seq).
|
|
1786
|
+
// Only durable, resumable agent executions stamp these (e.g. Claude
|
|
1787
|
+
// Managed agents, or any async/background run the backend persists and
|
|
1788
|
+
// can replay); other streams carry no cursor. We emit a `cursor`
|
|
1789
|
+
// event AFTER the frame is fully parsed and dispatched, so the session's
|
|
1790
|
+
// `lastEventId` only advances past frames it has actually applied, so the
|
|
1791
|
+
// happy path has no dupes against the server's `seq > after` replay.
|
|
1792
|
+
let frameId: string | null = null;
|
|
1793
|
+
|
|
1794
|
+
for (const line of lines) {
|
|
1795
|
+
if (line.startsWith("event:")) {
|
|
1796
|
+
eventType = line.replace("event:", "").trim();
|
|
1797
|
+
} else if (line.startsWith("data:")) {
|
|
1798
|
+
data += line.replace("data:", "").trim();
|
|
1799
|
+
} else if (line.startsWith("id:")) {
|
|
1800
|
+
frameId = line.slice(3).trim();
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
const advanceCursor = () => {
|
|
1805
|
+
if (frameId !== null && frameId !== "") {
|
|
1806
|
+
onEvent({ type: "cursor", id: frameId });
|
|
1807
|
+
}
|
|
1808
|
+
};
|
|
1809
|
+
|
|
1810
|
+
// A frame with an `id:` but no `data:` (e.g. a bare keepalive line) is
|
|
1811
|
+
// still a received durable row, so advance the cursor past it.
|
|
1812
|
+
if (!data) {
|
|
1813
|
+
advanceCursor();
|
|
1814
|
+
continue;
|
|
1815
|
+
}
|
|
1816
|
+
let payload: unknown;
|
|
1817
|
+
try {
|
|
1818
|
+
payload = JSON.parse(data);
|
|
1819
|
+
} catch (error) {
|
|
1820
|
+
// Parse failure: the frame was NOT applied. Do NOT advance the cursor
|
|
1821
|
+
// so a reconnect re-fetches this row.
|
|
1822
|
+
onEvent({
|
|
1823
|
+
type: "error",
|
|
1824
|
+
error:
|
|
1825
|
+
error instanceof Error
|
|
1826
|
+
? error
|
|
1827
|
+
: new Error("Failed to parse chat stream payload")
|
|
1828
|
+
});
|
|
1829
|
+
continue;
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
const record = payload !== null && typeof payload === "object" && !Array.isArray(payload)
|
|
1833
|
+
? payload as Record<string, unknown>
|
|
1834
|
+
: null;
|
|
1835
|
+
const payloadType = eventType !== "message"
|
|
1836
|
+
? eventType
|
|
1837
|
+
: typeof record?.type === "string" ? record.type : "message";
|
|
1838
|
+
|
|
1839
|
+
// Tap: capture raw SSE event for event stream inspector
|
|
1840
|
+
ctx.onSSEEvent?.(payloadType, payload);
|
|
1841
|
+
|
|
1842
|
+
// If custom SSE event parser is provided, try it first
|
|
1843
|
+
if (ctx.parseSSEEvent) {
|
|
1844
|
+
// Keep assistant message ref in sync
|
|
1845
|
+
assistantMessageRef.current = assistantMessage;
|
|
1846
|
+
const handled = await handleCustomSSEEvent(
|
|
1847
|
+
ctx,
|
|
1848
|
+
payload,
|
|
1849
|
+
onEvent,
|
|
1850
|
+
assistantMessageRef,
|
|
1851
|
+
emitMessage,
|
|
1852
|
+
nextSequence,
|
|
1853
|
+
customParsePartId
|
|
1854
|
+
);
|
|
1855
|
+
// Update assistantMessage from ref (in case it was created or replaced by partId segmentation)
|
|
1856
|
+
if (assistantMessageRef.current && assistantMessageRef.current !== assistantMessage) {
|
|
1857
|
+
assistantMessage = assistantMessageRef.current;
|
|
1858
|
+
}
|
|
1859
|
+
if (handled) {
|
|
1860
|
+
advanceCursor();
|
|
1861
|
+
continue; // Skip default handling if custom handler processed it
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
// The wire is the wire vocabulary; the handler consumes it
|
|
1866
|
+
// natively. The stream is single-connection and in order, so each frame
|
|
1867
|
+
// drains straight through.
|
|
1868
|
+
if (record) {
|
|
1869
|
+
// The raw tap and custom parser above retain the original payload.
|
|
1870
|
+
// Known native branches use the generated union; unknown event types
|
|
1871
|
+
// fall through without rejecting a custom backend's stream.
|
|
1872
|
+
eventQueue.push({ ...record, type: payloadType } as RuntypeExecutionStreamEvent);
|
|
1873
|
+
drainReadyQueue();
|
|
1874
|
+
}
|
|
1875
|
+
advanceCursor();
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
drainReadyQueue();
|
|
1880
|
+
}
|