@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
package/src/client.ts
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { RuntypeClientInitResponse } from "./generated/runtype-openapi-contract";
|
|
2
2
|
import {
|
|
3
3
|
AgentWidgetConfig,
|
|
4
4
|
AgentWidgetMessage,
|
|
5
5
|
AgentWidgetEvent,
|
|
6
6
|
AgentWidgetStreamParser,
|
|
7
|
-
AgentWidgetStreamParserResult,
|
|
8
7
|
AgentWidgetContextProvider,
|
|
9
8
|
AgentWidgetRequestMiddleware,
|
|
10
9
|
AgentWidgetRequestPayload,
|
|
@@ -13,20 +12,15 @@ import {
|
|
|
13
12
|
AgentWidgetSSEEventParser,
|
|
14
13
|
AgentWidgetHeadersFunction,
|
|
15
14
|
AgentWidgetSSEEventResult as _AgentWidgetSSEEventResult,
|
|
16
|
-
StopReasonKind,
|
|
17
15
|
ClientSession,
|
|
18
16
|
ClientChatRequest,
|
|
19
17
|
ClientToolDefinition,
|
|
20
18
|
ClientFeedbackRequest,
|
|
21
19
|
ClientFeedbackType,
|
|
22
|
-
PersonaArtifactKind,
|
|
23
|
-
PersonaArtifactFileMeta,
|
|
24
|
-
ContentPart,
|
|
25
20
|
PreparedClientSession,
|
|
26
21
|
WidgetHistoryInternals,
|
|
27
22
|
HistoryScope,
|
|
28
23
|
HistoryIdentityStatus,
|
|
29
|
-
HistoryConversationSummary,
|
|
30
24
|
HistoryConversationPage,
|
|
31
25
|
HistoryConversationDetail,
|
|
32
26
|
HistoryDisplayProjection,
|
|
@@ -36,28 +30,26 @@ import {
|
|
|
36
30
|
import {
|
|
37
31
|
computeClientToolsFingerprint,
|
|
38
32
|
getWebMcpToolDisplayTitle,
|
|
39
|
-
isWebMcpToolName,
|
|
40
33
|
loadWebMcpPolyfillModule,
|
|
41
34
|
recordWebMcpToolDisplayTitles,
|
|
42
35
|
} from "./webmcp-bridge";
|
|
43
36
|
import { loadWebMcpRuntime } from "./webmcp-runtime-loader";
|
|
44
37
|
import type { WebMcpBridge } from "./webmcp-runtime-entry";
|
|
45
|
-
import {
|
|
46
|
-
buildArtifactRefRawContent,
|
|
47
|
-
resolveArtifactDisplayMode
|
|
48
|
-
} from "./utils/artifact-display";
|
|
49
38
|
import { resolveTarget } from "./utils/target";
|
|
50
39
|
import { generateTurnId } from "./utils/message-id";
|
|
51
40
|
import { builtInClientToolsForDispatch } from "./ask-user-question-tool";
|
|
52
41
|
import { serializeWithToolPairs } from "./utils/tool-pair-replay";
|
|
53
42
|
import {
|
|
54
|
-
extractTextFromJson,
|
|
55
43
|
createPlainTextParser,
|
|
56
44
|
createJsonStreamParser,
|
|
57
45
|
createRegexJsonParser,
|
|
58
46
|
createXmlParser
|
|
59
47
|
} from "./utils/formatting";
|
|
60
48
|
import { VERSION } from "./version";
|
|
49
|
+
import { getClientStreamSync, loadClientStream } from "./client-stream-loader";
|
|
50
|
+
import { loadClientHistory } from "./client-history-loader";
|
|
51
|
+
import type { ClientHistoryHost } from "./client-history";
|
|
52
|
+
import type { ClientResumeHost } from "./client-resume";
|
|
61
53
|
|
|
62
54
|
/** History transcripts stay on the wire shape; `utils/history-messages.ts` maps them. */
|
|
63
55
|
export type { HistoryWireMessage } from "./utils/history-messages";
|
|
@@ -84,10 +76,10 @@ type DispatchOptions = {
|
|
|
84
76
|
interrupt?: boolean;
|
|
85
77
|
};
|
|
86
78
|
|
|
87
|
-
type SSEHandler = (event: AgentWidgetEvent) => void;
|
|
79
|
+
export type SSEHandler = (event: AgentWidgetEvent) => void;
|
|
88
80
|
|
|
89
81
|
const DEFAULT_ENDPOINT = "https://api.runtype.com/v1/dispatch";
|
|
90
|
-
|
|
82
|
+
import { DEFAULT_CLIENT_API_BASE } from "./utils/constants";
|
|
91
83
|
|
|
92
84
|
/** Branch on `code`, never on message text. */
|
|
93
85
|
export type HistoryClientErrorCode =
|
|
@@ -134,68 +126,17 @@ export class HistoryClientError extends Error {
|
|
|
134
126
|
}
|
|
135
127
|
}
|
|
136
128
|
|
|
137
|
-
/** Per-message / per-batch display-projection caps (contract facts #5, #15). */
|
|
138
|
-
const DISPLAY_PROJECTION_MESSAGE_CAP = 32768;
|
|
139
|
-
const DISPLAY_PROJECTION_BATCH_CAP = 49152;
|
|
140
|
-
|
|
141
|
-
/** Normalize current history metadata without leaking wire-only aliases. */
|
|
142
|
-
const normalizeHistorySummary = (raw: unknown): HistoryConversationSummary => {
|
|
143
|
-
const row = (raw ?? {}) as Record<string, unknown>;
|
|
144
|
-
const targetId = typeof row.targetId === "string" ? row.targetId : null;
|
|
145
|
-
return {
|
|
146
|
-
id: typeof row.id === "string" ? row.id : "",
|
|
147
|
-
title: typeof row.title === "string" ? row.title : "",
|
|
148
|
-
targetId,
|
|
149
|
-
preview: typeof row.preview === "string" ? row.preview : null,
|
|
150
|
-
messageCount: typeof row.messageCount === "number" ? row.messageCount : 0,
|
|
151
|
-
createdAt: typeof row.createdAt === "string" ? row.createdAt : "",
|
|
152
|
-
updatedAt: typeof row.updatedAt === "string" ? row.updatedAt : "",
|
|
153
|
-
};
|
|
154
|
-
};
|
|
155
|
-
|
|
156
129
|
const identityReason = (status: HistoryIdentityStatus): string | undefined =>
|
|
157
130
|
"reason" in status ? status.reason : undefined;
|
|
158
131
|
|
|
159
|
-
|
|
160
|
-
"The identity proof was not admitted; account history is unavailable";
|
|
132
|
+
|
|
161
133
|
const PROOF_REJECTED_MESSAGE = "The identity proof was rejected";
|
|
162
|
-
const NO_VISITOR_CREDENTIAL_MESSAGE = "The request carried no visitor credential";
|
|
163
|
-
const IDENTITY_MISMATCH_MESSAGE = "The stored visitor belongs to a different signed-in user";
|
|
164
134
|
|
|
165
135
|
const isHistoryClientError = (
|
|
166
136
|
error: unknown,
|
|
167
137
|
code: HistoryClientErrorCode
|
|
168
138
|
): boolean => error instanceof HistoryClientError && error.code === code;
|
|
169
139
|
|
|
170
|
-
/**
|
|
171
|
-
* Derive a download filename for `agent_media` parts that are delivered
|
|
172
|
-
* without one. Maps a few well-known MIME types to friendly extensions and
|
|
173
|
-
* falls back to `attachment.<subtype>` (or just `attachment` for opaque
|
|
174
|
-
* types like `application/octet-stream`).
|
|
175
|
-
*/
|
|
176
|
-
function filenameFromMediaType(mediaType: string): string {
|
|
177
|
-
// MIME types are case-insensitive (RFC 7231); compare against a lowercased
|
|
178
|
-
// copy so callers that pass mixed casing still hit the friendly extensions.
|
|
179
|
-
const lower = mediaType.toLowerCase();
|
|
180
|
-
const knownExtensions: Record<string, string> = {
|
|
181
|
-
"application/pdf": "pdf",
|
|
182
|
-
"application/json": "json",
|
|
183
|
-
"application/zip": "zip",
|
|
184
|
-
"text/plain": "txt",
|
|
185
|
-
"text/csv": "csv",
|
|
186
|
-
"text/markdown": "md"
|
|
187
|
-
};
|
|
188
|
-
const ext = knownExtensions[lower];
|
|
189
|
-
if (ext) return `attachment.${ext}`;
|
|
190
|
-
const slash = lower.indexOf("/");
|
|
191
|
-
if (slash > 0) {
|
|
192
|
-
const subtype = lower.slice(slash + 1).split(";")[0]?.trim() ?? "";
|
|
193
|
-
if (subtype && subtype !== "octet-stream" && /^[a-z0-9.+-]+$/i.test(subtype)) {
|
|
194
|
-
return `attachment.${subtype}`;
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
return "attachment";
|
|
198
|
-
}
|
|
199
140
|
|
|
200
141
|
/**
|
|
201
142
|
* Check if a message has valid (non-empty) content for sending to the API.
|
|
@@ -203,6 +144,8 @@ function filenameFromMediaType(mediaType: string): string {
|
|
|
203
144
|
*
|
|
204
145
|
*/
|
|
205
146
|
const hasValidContent = (message: AgentWidgetMessage): boolean => {
|
|
147
|
+
// Display-only voice captions are never conversation.
|
|
148
|
+
if (message.voiceCaption) return false;
|
|
206
149
|
// Check contentParts (multi-modal content)
|
|
207
150
|
if (message.contentParts && message.contentParts.length > 0) {
|
|
208
151
|
return true;
|
|
@@ -253,60 +196,27 @@ function getParserFromType(parserType?: "plain" | "json" | "regex-json" | "xml")
|
|
|
253
196
|
|
|
254
197
|
export type SSEEventCallback = (eventType: string, payload: unknown) => void;
|
|
255
198
|
|
|
256
|
-
const looksStructured = (value: string) =>
|
|
257
|
-
value.startsWith("{") || value.startsWith("[") || value.startsWith("<");
|
|
258
|
-
|
|
259
|
-
/**
|
|
260
|
-
* Choose the best content source for sealed-segment reconciliation.
|
|
261
|
-
* Prefers the final structured payload from step_complete when the raw
|
|
262
|
-
* buffer is only a partial/unparseable prefix of the same structured format.
|
|
263
|
-
*/
|
|
264
|
-
export function preferFinalStructuredContent(
|
|
265
|
-
rawBuffer: string | undefined,
|
|
266
|
-
finalString: string
|
|
267
|
-
): string {
|
|
268
|
-
if (!rawBuffer) return finalString;
|
|
269
|
-
|
|
270
|
-
const rawTrimmed = rawBuffer.trim();
|
|
271
|
-
const finalTrimmed = finalString.trim();
|
|
272
|
-
if (rawTrimmed.length === 0) return finalString;
|
|
273
|
-
if (finalTrimmed.length === 0) return rawBuffer;
|
|
274
|
-
|
|
275
|
-
const rawLooksStructured = looksStructured(rawTrimmed);
|
|
276
|
-
const finalLooksStructured = looksStructured(finalTrimmed);
|
|
277
|
-
|
|
278
|
-
if (!finalLooksStructured) return rawBuffer;
|
|
279
|
-
if (!rawLooksStructured) return finalString;
|
|
280
|
-
if (finalTrimmed === rawTrimmed) return finalString;
|
|
281
|
-
if (finalTrimmed.startsWith(rawTrimmed)) return finalString;
|
|
282
|
-
|
|
283
|
-
const rawJsonText = extractTextFromJson(rawBuffer);
|
|
284
|
-
const finalJsonText = extractTextFromJson(finalString);
|
|
285
|
-
if (finalJsonText !== null && rawJsonText === null) return finalString;
|
|
286
|
-
|
|
287
|
-
return rawBuffer;
|
|
288
|
-
}
|
|
289
199
|
|
|
290
200
|
export class AgentWidgetClient {
|
|
291
|
-
|
|
292
|
-
|
|
201
|
+
readonly #apiUrl: string;
|
|
202
|
+
readonly #headers: Record<string, string>;
|
|
293
203
|
private readonly debug: boolean;
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
204
|
+
readonly #createStreamParser: () => AgentWidgetStreamParser;
|
|
205
|
+
readonly #contextProviders: AgentWidgetContextProvider[];
|
|
206
|
+
readonly #requestMiddleware?: AgentWidgetRequestMiddleware;
|
|
207
|
+
readonly #customFetch?: AgentWidgetCustomFetch;
|
|
208
|
+
readonly #parseSSEEvent?: AgentWidgetSSEEventParser;
|
|
209
|
+
readonly #getHeaders?: AgentWidgetHeadersFunction;
|
|
210
|
+
#onSSEEvent?: SSEEventCallback;
|
|
301
211
|
|
|
302
212
|
// Client token mode properties
|
|
303
213
|
/**
|
|
304
214
|
* Turn id of the newest client-token dispatch. A dispatch whose id no longer
|
|
305
215
|
* matches is superseded, and every SSE frame it still receives is dropped
|
|
306
216
|
*/
|
|
307
|
-
|
|
217
|
+
#currentClientTurnId: string | null = null;
|
|
308
218
|
private clientSession: ClientSession | null = null;
|
|
309
|
-
|
|
219
|
+
#sessionInitPromise: Promise<ClientSession> | null = null;
|
|
310
220
|
/**
|
|
311
221
|
* Early-init latch for `warmSession()`. `false` = armed. Otherwise it holds
|
|
312
222
|
* the session value (`null` or an expired session) the one early attempt was
|
|
@@ -314,7 +224,7 @@ export class AgentWidgetClient {
|
|
|
314
224
|
* re-armed when a session is installed or cleared, so each session lifetime
|
|
315
225
|
* gets one early init.
|
|
316
226
|
*/
|
|
317
|
-
|
|
227
|
+
#sessionWarmLatch: ClientSession | null | false = false;
|
|
318
228
|
|
|
319
229
|
// Diff-only / send-once WebMCP tool dispatch (client-token mode ONLY).
|
|
320
230
|
// Fingerprint of the clientTools[] last *sent in full* and confirmed by a
|
|
@@ -330,32 +240,32 @@ export class AgentWidgetClient {
|
|
|
330
240
|
// execution — so a later resume with an empty registry must still send the
|
|
331
241
|
// explicit `clientTools: []` replace. Reset only by an explicit [] replace,
|
|
332
242
|
// a session change, or a conversation reset.
|
|
333
|
-
|
|
243
|
+
#sentNonEmptyClientToolsSessionId: string | null = null;
|
|
334
244
|
|
|
335
245
|
// Visitor history (client-token mode ONLY). `historyUnavailable` latches for
|
|
336
246
|
// the client's lifetime on a 403 `visitor_history_disabled`; `claimInFlight`
|
|
337
247
|
// bounds the immediate first-conversation claim to one extra init.
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
248
|
+
#historyUnavailable = false;
|
|
249
|
+
#historyUnavailableWarned = false;
|
|
250
|
+
#claimInFlight = false;
|
|
341
251
|
// Evidence-based identity state. Null means "never moved off the resting
|
|
342
252
|
// state", which is recomputed from config so `update()` stays honest.
|
|
343
|
-
|
|
253
|
+
#historyIdentityStatus: HistoryIdentityStatus | null = null;
|
|
344
254
|
// Fan-out beside the single internals callback: the Runtype history provider
|
|
345
255
|
// bridges these into the generic seam. Cleared with the client instance.
|
|
346
|
-
|
|
256
|
+
#historyIdentitySubscribers = new Set<
|
|
347
257
|
(status: HistoryIdentityStatus) => void
|
|
348
258
|
>();
|
|
349
|
-
|
|
259
|
+
#historyAvailabilitySubscribers = new Set<(available: boolean) => void>();
|
|
350
260
|
|
|
351
261
|
// WebMCP: page-discovered tool consumption. The bridge runtime ships in the
|
|
352
262
|
// lazy webmcp-runtime chunk; `webMcpBridge` stays null until the chunk is
|
|
353
263
|
// adopted (and forever when `config.webmcp?.enabled !== true`). Dispatch
|
|
354
264
|
// paths await `getWebMcpBridge()`; sync callers read the cached instance.
|
|
355
265
|
private webMcpBridge: WebMcpBridge | null = null;
|
|
356
|
-
|
|
266
|
+
#webMcpBridgePromise: Promise<WebMcpBridge | null> | null = null;
|
|
357
267
|
/** undefined = never set; null = explicitly cleared. Applied at adoption. */
|
|
358
|
-
|
|
268
|
+
#pendingWebMcpConfirmHandler: WebMcpConfirmHandler | null | undefined;
|
|
359
269
|
|
|
360
270
|
constructor(
|
|
361
271
|
private config: AgentWidgetConfig = {},
|
|
@@ -366,29 +276,29 @@ export class AgentWidgetClient {
|
|
|
366
276
|
"[Persona] `target` is mutually exclusive with `agentId`, `flowId`, and `agent`. Set only one routing field.",
|
|
367
277
|
);
|
|
368
278
|
}
|
|
369
|
-
this
|
|
370
|
-
this
|
|
279
|
+
this.#apiUrl = config.apiUrl ?? DEFAULT_ENDPOINT;
|
|
280
|
+
this.#headers = {
|
|
371
281
|
"Content-Type": "application/json",
|
|
372
282
|
"X-Persona-Version": VERSION,
|
|
373
283
|
...config.headers
|
|
374
284
|
};
|
|
375
285
|
this.debug = Boolean(config.debug);
|
|
376
286
|
// Use custom stream parser if provided, otherwise use parserType, or fall back to plain text parser
|
|
377
|
-
this
|
|
378
|
-
this
|
|
379
|
-
this
|
|
380
|
-
this
|
|
381
|
-
this
|
|
382
|
-
this
|
|
287
|
+
this.#createStreamParser = config.streamParser ?? getParserFromType(config.parserType);
|
|
288
|
+
this.#contextProviders = config.contextProviders ?? [];
|
|
289
|
+
this.#requestMiddleware = config.requestMiddleware;
|
|
290
|
+
this.#customFetch = config.customFetch;
|
|
291
|
+
this.#parseSSEEvent = config.parseSSEEvent;
|
|
292
|
+
this.#getHeaders = config.getHeaders;
|
|
383
293
|
if (config.webmcp?.enabled === true) {
|
|
384
294
|
// Kick the runtime chunk fetch now so the bridge is warm before the
|
|
385
295
|
// first dispatch snapshot (which awaits it either way).
|
|
386
|
-
this
|
|
296
|
+
this.#webMcpBridgePromise = this.#createWebMcpBridge(config.webmcp);
|
|
387
297
|
}
|
|
388
298
|
}
|
|
389
299
|
|
|
390
300
|
/** Load the lazy runtime chunk and construct the bridge with core-owned deps. */
|
|
391
|
-
|
|
301
|
+
#createWebMcpBridge(
|
|
392
302
|
webmcpConfig: NonNullable<AgentWidgetConfig["webmcp"]>
|
|
393
303
|
): Promise<WebMcpBridge | null> {
|
|
394
304
|
return loadWebMcpRuntime()
|
|
@@ -398,8 +308,8 @@ export class AgentWidgetClient {
|
|
|
398
308
|
getToolDisplayTitle: getWebMcpToolDisplayTitle,
|
|
399
309
|
loadPolyfill: loadWebMcpPolyfillModule,
|
|
400
310
|
});
|
|
401
|
-
if (this
|
|
402
|
-
bridge.setConfirmHandler(this
|
|
311
|
+
if (this.#pendingWebMcpConfirmHandler !== undefined) {
|
|
312
|
+
bridge.setConfirmHandler(this.#pendingWebMcpConfirmHandler);
|
|
403
313
|
}
|
|
404
314
|
this.webMcpBridge = bridge;
|
|
405
315
|
return bridge;
|
|
@@ -407,7 +317,7 @@ export class AgentWidgetClient {
|
|
|
407
317
|
.catch((err) => {
|
|
408
318
|
// Failed chunk fetch: clear the memoized promise so the next dispatch
|
|
409
319
|
// retries (the chunk loader clears its own rejection too).
|
|
410
|
-
this
|
|
320
|
+
this.#webMcpBridgePromise = null;
|
|
411
321
|
// Always surface this: a silently-absent bridge means empty tool
|
|
412
322
|
// snapshots and failed webmcp resumes with no operator signal.
|
|
413
323
|
console.warn("[Persona] Failed to load the WebMCP runtime chunk", err);
|
|
@@ -416,13 +326,13 @@ export class AgentWidgetClient {
|
|
|
416
326
|
}
|
|
417
327
|
|
|
418
328
|
/** Resolve the bridge, loading the runtime chunk on first use. */
|
|
419
|
-
|
|
329
|
+
#getWebMcpBridge(): Promise<WebMcpBridge | null> {
|
|
420
330
|
if (this.webMcpBridge) return Promise.resolve(this.webMcpBridge);
|
|
421
331
|
if (this.config.webmcp?.enabled !== true) return Promise.resolve(null);
|
|
422
|
-
if (!this
|
|
423
|
-
this
|
|
332
|
+
if (!this.#webMcpBridgePromise) {
|
|
333
|
+
this.#webMcpBridgePromise = this.#createWebMcpBridge(this.config.webmcp);
|
|
424
334
|
}
|
|
425
|
-
return this
|
|
335
|
+
return this.#webMcpBridgePromise;
|
|
426
336
|
}
|
|
427
337
|
|
|
428
338
|
/**
|
|
@@ -456,7 +366,7 @@ export class AgentWidgetClient {
|
|
|
456
366
|
* Set callback for capturing raw SSE events
|
|
457
367
|
*/
|
|
458
368
|
public setSSEEventCallback(callback: SSEEventCallback): void {
|
|
459
|
-
this
|
|
369
|
+
this.#onSSEEvent = callback;
|
|
460
370
|
}
|
|
461
371
|
|
|
462
372
|
/**
|
|
@@ -466,7 +376,7 @@ export class AgentWidgetClient {
|
|
|
466
376
|
*/
|
|
467
377
|
public setWebMcpConfirmHandler(handler: WebMcpConfirmHandler | null): void {
|
|
468
378
|
// Queue for a bridge still in flight; apply immediately once adopted.
|
|
469
|
-
this
|
|
379
|
+
this.#pendingWebMcpConfirmHandler = handler;
|
|
470
380
|
this.webMcpBridge?.setConfirmHandler(handler);
|
|
471
381
|
}
|
|
472
382
|
|
|
@@ -499,7 +409,7 @@ export class AgentWidgetClient {
|
|
|
499
409
|
// have adopted yet, and returning null would misroute the call to the
|
|
500
410
|
// legacy local-tool resume path.
|
|
501
411
|
if (this.config.webmcp?.enabled !== true) return null;
|
|
502
|
-
return this
|
|
412
|
+
return this.#getWebMcpBridge().then((bridge) =>
|
|
503
413
|
bridge
|
|
504
414
|
? bridge.executeToolCall(wireToolName, args, signal)
|
|
505
415
|
: {
|
|
@@ -515,7 +425,7 @@ export class AgentWidgetClient {
|
|
|
515
425
|
* Get the current SSE event callback (used to preserve across client recreation)
|
|
516
426
|
*/
|
|
517
427
|
public getSSEEventCallback(): SSEEventCallback | undefined {
|
|
518
|
-
return this
|
|
428
|
+
return this.#onSSEEvent;
|
|
519
429
|
}
|
|
520
430
|
|
|
521
431
|
/**
|
|
@@ -532,7 +442,7 @@ export class AgentWidgetClient {
|
|
|
532
442
|
* across `update()`; the `target`/explicit-field conflict is rejected in the
|
|
533
443
|
* constructor, so at most one source is set here.
|
|
534
444
|
*/
|
|
535
|
-
|
|
445
|
+
#routing(): {
|
|
536
446
|
agentId?: string;
|
|
537
447
|
flowId?: string;
|
|
538
448
|
targetPayload?: Record<string, unknown>;
|
|
@@ -551,7 +461,7 @@ export class AgentWidgetClient {
|
|
|
551
461
|
* Check if operating in agent execution mode
|
|
552
462
|
*/
|
|
553
463
|
public isAgentMode(): boolean {
|
|
554
|
-
return !!(this.config.agent || this
|
|
464
|
+
return !!(this.config.agent || this.#routing().agentId);
|
|
555
465
|
}
|
|
556
466
|
|
|
557
467
|
/**
|
|
@@ -560,7 +470,7 @@ export class AgentWidgetClient {
|
|
|
560
470
|
* pointed at the dispatch endpoint still resolves its sibling routes. Not
|
|
561
471
|
* used for proxy-mode URLs, whose resume route is `${apiUrl}/resume`.
|
|
562
472
|
*/
|
|
563
|
-
|
|
473
|
+
#clientApiBase(): string {
|
|
564
474
|
return (
|
|
565
475
|
this.config.apiUrl?.replace(/\/+$/, '').replace(/\/v1\/dispatch$/, '') ||
|
|
566
476
|
DEFAULT_CLIENT_API_BASE
|
|
@@ -570,8 +480,8 @@ export class AgentWidgetClient {
|
|
|
570
480
|
/**
|
|
571
481
|
* Get the appropriate API URL based on mode
|
|
572
482
|
*/
|
|
573
|
-
|
|
574
|
-
return `${this
|
|
483
|
+
#getClientApiUrl(endpoint: 'init' | 'chat' | 'resume'): string {
|
|
484
|
+
return `${this.#clientApiBase()}/v1/client/${endpoint}`;
|
|
575
485
|
}
|
|
576
486
|
|
|
577
487
|
/**
|
|
@@ -600,7 +510,7 @@ export class AgentWidgetClient {
|
|
|
600
510
|
);
|
|
601
511
|
}
|
|
602
512
|
const path =
|
|
603
|
-
`${this
|
|
513
|
+
`${this.#clientApiBase()}/v1/client/conversations/` +
|
|
604
514
|
`${encodeURIComponent(conversationId)}/executions/` +
|
|
605
515
|
`${encodeURIComponent(ctx.executionId)}/events`;
|
|
606
516
|
const query = new URLSearchParams({
|
|
@@ -617,14 +527,18 @@ export class AgentWidgetClient {
|
|
|
617
527
|
});
|
|
618
528
|
if (response.status === 401 && !recovered) {
|
|
619
529
|
recovered = true;
|
|
620
|
-
|
|
621
|
-
|
|
530
|
+
const history = await loadClientHistory();
|
|
531
|
+
session = await history.recoverFromUnauthorized(
|
|
532
|
+
this.#historyHost(),
|
|
533
|
+
await history.readErrorCode(this.#historyHost(), response),
|
|
622
534
|
null,
|
|
623
535
|
true
|
|
624
536
|
);
|
|
625
537
|
continue;
|
|
626
538
|
}
|
|
627
|
-
if (!response.ok)
|
|
539
|
+
if (!response.ok) {
|
|
540
|
+
throw await (await loadClientHistory()).historyErrorFor(this.#historyHost(), response, true);
|
|
541
|
+
}
|
|
628
542
|
return response;
|
|
629
543
|
}
|
|
630
544
|
}
|
|
@@ -646,13 +560,13 @@ export class AgentWidgetClient {
|
|
|
646
560
|
const current = this.clientSession;
|
|
647
561
|
if (
|
|
648
562
|
!this.isClientTokenMode() ||
|
|
649
|
-
this
|
|
563
|
+
this.#sessionInitPromise ||
|
|
650
564
|
(current && new Date() < current.expiresAt) ||
|
|
651
|
-
this
|
|
565
|
+
this.#sessionWarmLatch === current
|
|
652
566
|
) {
|
|
653
567
|
return null;
|
|
654
568
|
}
|
|
655
|
-
this
|
|
569
|
+
this.#sessionWarmLatch = current;
|
|
656
570
|
// Swallowed on purpose: the send's own initSession() reports failures.
|
|
657
571
|
return this.initSession().catch(() => null);
|
|
658
572
|
}
|
|
@@ -676,19 +590,19 @@ export class AgentWidgetClient {
|
|
|
676
590
|
}
|
|
677
591
|
|
|
678
592
|
// Deduplicate concurrent init calls
|
|
679
|
-
if (this
|
|
680
|
-
return this
|
|
593
|
+
if (this.#sessionInitPromise) {
|
|
594
|
+
return this.#sessionInitPromise;
|
|
681
595
|
}
|
|
682
596
|
|
|
683
597
|
// Callers that dedupe share this promise, so the stale check below covers
|
|
684
598
|
// them too (the send reusing an early init included).
|
|
685
|
-
const pending: Promise<ClientSession> = (this
|
|
599
|
+
const pending: Promise<ClientSession> = (this.#sessionInitPromise = this.#_doInitSession()
|
|
686
600
|
.then((session) => {
|
|
687
601
|
// A clear or replacement (credential change, start-new, proof re-init)
|
|
688
602
|
// while this was in flight makes the result stale: never install it.
|
|
689
|
-
if (this
|
|
603
|
+
if (this.#sessionInitPromise !== pending) return this.initSession();
|
|
690
604
|
this.clientSession = session;
|
|
691
|
-
this
|
|
605
|
+
this.#sessionWarmLatch = false;
|
|
692
606
|
// A freshly-minted session must resend the full WebMCP tool list on its
|
|
693
607
|
// next turn: drop any diff-only fingerprint cached under a prior session,
|
|
694
608
|
// so we never claim "unchanged" against a session the server didn't store
|
|
@@ -699,17 +613,17 @@ export class AgentWidgetClient {
|
|
|
699
613
|
return session;
|
|
700
614
|
})
|
|
701
615
|
.finally(() => {
|
|
702
|
-
if (this
|
|
616
|
+
if (this.#sessionInitPromise === pending) this.#sessionInitPromise = null;
|
|
703
617
|
}));
|
|
704
618
|
return pending;
|
|
705
619
|
}
|
|
706
620
|
|
|
707
621
|
/** Visitor history rides on client-token init only, and latches off after a 403 degrade. */
|
|
708
|
-
|
|
622
|
+
#isHistoryCapable(): boolean {
|
|
709
623
|
return (
|
|
710
624
|
this.config.features?.history?.enabled === true &&
|
|
711
625
|
this.isClientTokenMode() &&
|
|
712
|
-
!this
|
|
626
|
+
!this.#historyUnavailable
|
|
713
627
|
);
|
|
714
628
|
}
|
|
715
629
|
|
|
@@ -721,36 +635,36 @@ export class AgentWidgetClient {
|
|
|
721
635
|
}
|
|
722
636
|
|
|
723
637
|
/** One-way, client-lifetime latch: chat keeps working without history. */
|
|
724
|
-
|
|
725
|
-
if (this
|
|
638
|
+
#markHistoryUnavailable(): void {
|
|
639
|
+
if (this.#historyUnavailable) return;
|
|
726
640
|
// Announce before the latch flips, or the resting state already matches.
|
|
727
|
-
this
|
|
728
|
-
this
|
|
729
|
-
if (!this
|
|
730
|
-
this
|
|
641
|
+
this.#setHistoryIdentityStatus({ state: 'unavailable', reason: 'history_disabled' });
|
|
642
|
+
this.#historyUnavailable = true;
|
|
643
|
+
if (!this.#historyUnavailableWarned && typeof console !== 'undefined') {
|
|
644
|
+
this.#historyUnavailableWarned = true;
|
|
731
645
|
// eslint-disable-next-line no-console
|
|
732
646
|
console.warn(
|
|
733
647
|
'[Persona] Visitor history is disabled for this surface; continuing without it.'
|
|
734
648
|
);
|
|
735
649
|
}
|
|
736
650
|
this.historyInternals.onHistoryAvailabilityChanged?.(false);
|
|
737
|
-
for (const subscriber of [...this
|
|
651
|
+
for (const subscriber of [...this.#historyAvailabilitySubscribers]) {
|
|
738
652
|
subscriber(false);
|
|
739
653
|
}
|
|
740
654
|
}
|
|
741
655
|
|
|
742
656
|
/** Public, secret-free view of the current identity state. */
|
|
743
657
|
public getHistoryIdentityStatus(): HistoryIdentityStatus {
|
|
744
|
-
return this
|
|
658
|
+
return this.#historyIdentityStatus ?? this.#restingIdentityStatus();
|
|
745
659
|
}
|
|
746
660
|
|
|
747
661
|
/** Deduped status notifications; carries no token, proof, or identity value. */
|
|
748
662
|
public subscribeHistoryIdentityStatus(
|
|
749
663
|
callback: (status: HistoryIdentityStatus) => void
|
|
750
664
|
): () => void {
|
|
751
|
-
this
|
|
665
|
+
this.#historyIdentitySubscribers.add(callback);
|
|
752
666
|
return () => {
|
|
753
|
-
this
|
|
667
|
+
this.#historyIdentitySubscribers.delete(callback);
|
|
754
668
|
};
|
|
755
669
|
}
|
|
756
670
|
|
|
@@ -758,32 +672,32 @@ export class AgentWidgetClient {
|
|
|
758
672
|
public subscribeHistoryAvailability(
|
|
759
673
|
callback: (available: boolean) => void
|
|
760
674
|
): () => void {
|
|
761
|
-
this
|
|
762
|
-
if (this
|
|
675
|
+
this.#historyAvailabilitySubscribers.add(callback);
|
|
676
|
+
if (this.#historyUnavailable) callback(false);
|
|
763
677
|
return () => {
|
|
764
|
-
this
|
|
678
|
+
this.#historyAvailabilitySubscribers.delete(callback);
|
|
765
679
|
};
|
|
766
680
|
}
|
|
767
681
|
|
|
768
682
|
/** Config-derived state before any per-operation evidence exists. */
|
|
769
|
-
|
|
683
|
+
#restingIdentityStatus(): HistoryIdentityStatus {
|
|
770
684
|
if (!this.isClientTokenMode()) {
|
|
771
685
|
return { state: 'unavailable', reason: 'ineligible_mode' };
|
|
772
686
|
}
|
|
773
|
-
if (this.config.features?.history?.enabled !== true || this
|
|
687
|
+
if (this.config.features?.history?.enabled !== true || this.#historyUnavailable) {
|
|
774
688
|
return { state: 'unavailable', reason: 'history_disabled' };
|
|
775
689
|
}
|
|
776
|
-
return this
|
|
690
|
+
return this.#browserOnlyStatus();
|
|
777
691
|
}
|
|
778
692
|
|
|
779
693
|
/** Keeps an already-observed browser_only reason; otherwise derives one. */
|
|
780
|
-
|
|
781
|
-
const current = this
|
|
694
|
+
#browserOnlyStatus(): HistoryIdentityStatus {
|
|
695
|
+
const current = this.#historyIdentityStatus;
|
|
782
696
|
if (current?.state === 'browser_only') return current;
|
|
783
|
-
return this
|
|
697
|
+
return this.#derivedBrowserOnly();
|
|
784
698
|
}
|
|
785
699
|
|
|
786
|
-
|
|
700
|
+
#derivedBrowserOnly(): HistoryIdentityStatus {
|
|
787
701
|
if (this.config.features?.history?.scope === 'browser') {
|
|
788
702
|
return { state: 'browser_only', reason: 'configured_browser_scope' };
|
|
789
703
|
}
|
|
@@ -794,14 +708,14 @@ export class AgentWidgetClient {
|
|
|
794
708
|
}
|
|
795
709
|
|
|
796
710
|
/** Identical consecutive states are not re-announced. */
|
|
797
|
-
|
|
711
|
+
#setHistoryIdentityStatus(next: HistoryIdentityStatus): void {
|
|
798
712
|
const current = this.getHistoryIdentityStatus();
|
|
799
713
|
const unchanged =
|
|
800
714
|
current.state === next.state && identityReason(current) === identityReason(next);
|
|
801
|
-
this
|
|
715
|
+
this.#historyIdentityStatus = next;
|
|
802
716
|
if (unchanged) return;
|
|
803
717
|
this.historyInternals.onHistoryIdentityStatusChanged?.(next);
|
|
804
|
-
for (const subscriber of [...this
|
|
718
|
+
for (const subscriber of [...this.#historyIdentitySubscribers]) subscriber(next);
|
|
805
719
|
}
|
|
806
720
|
|
|
807
721
|
/**
|
|
@@ -809,7 +723,7 @@ export class AgentWidgetClient {
|
|
|
809
723
|
* `conversationId` and `sessionId` are mutually exclusive on the wire (strict
|
|
810
724
|
* server union), so a resume never carries the stored session id.
|
|
811
725
|
*/
|
|
812
|
-
|
|
726
|
+
async #createClientSession(opts: {
|
|
813
727
|
conversationId?: string;
|
|
814
728
|
durableResume?: boolean;
|
|
815
729
|
identityProof?: string | null;
|
|
@@ -817,7 +731,7 @@ export class AgentWidgetClient {
|
|
|
817
731
|
omitVisitorFields?: boolean;
|
|
818
732
|
signal?: AbortSignal;
|
|
819
733
|
}): Promise<ClientSession> {
|
|
820
|
-
const historyCapable = this
|
|
734
|
+
const historyCapable = this.#isHistoryCapable() && !opts.omitVisitorFields;
|
|
821
735
|
// Recovery is negotiated independently from the history UI. New servers
|
|
822
736
|
// return an explicit capability bit; old strict servers reject the
|
|
823
737
|
// additive request field, which the fallback below handles once.
|
|
@@ -829,7 +743,7 @@ export class AgentWidgetClient {
|
|
|
829
743
|
visitorToken = await this.readVisitorToken();
|
|
830
744
|
}
|
|
831
745
|
|
|
832
|
-
const routed = this
|
|
746
|
+
const routed = this.#routing();
|
|
833
747
|
const sessionTargetId = routed.agentId ?? routed.flowId;
|
|
834
748
|
const resumeConversationId =
|
|
835
749
|
historyCapable || opts.durableResume ? opts.conversationId : undefined;
|
|
@@ -854,7 +768,7 @@ export class AgentWidgetClient {
|
|
|
854
768
|
};
|
|
855
769
|
|
|
856
770
|
opts.signal?.throwIfAborted();
|
|
857
|
-
const response = await fetch(this
|
|
771
|
+
const response = await fetch(this.#getClientApiUrl('init'), {
|
|
858
772
|
method: 'POST',
|
|
859
773
|
headers: {
|
|
860
774
|
'Content-Type': 'application/json',
|
|
@@ -867,7 +781,7 @@ export class AgentWidgetClient {
|
|
|
867
781
|
if (!response.ok) {
|
|
868
782
|
const error = await response.json().catch(() => ({ error: 'Session initialization failed' }));
|
|
869
783
|
if (historyCapable && response.status === 403 && error.error === 'visitor_history_disabled') {
|
|
870
|
-
this
|
|
784
|
+
this.#markHistoryUnavailable();
|
|
871
785
|
// A resume must not silently degrade into some other conversation.
|
|
872
786
|
if (resumeConversationId) {
|
|
873
787
|
throw new HistoryClientError(
|
|
@@ -875,7 +789,7 @@ export class AgentWidgetClient {
|
|
|
875
789
|
'Visitor history is disabled for this surface'
|
|
876
790
|
);
|
|
877
791
|
}
|
|
878
|
-
return this
|
|
792
|
+
return this.#createClientSession({ ...opts, omitVisitorFields: true });
|
|
879
793
|
}
|
|
880
794
|
if (response.status === 403 && error.error === 'durable_recovery_disabled') {
|
|
881
795
|
throw new HistoryClientError(
|
|
@@ -929,13 +843,13 @@ export class AgentWidgetClient {
|
|
|
929
843
|
};
|
|
930
844
|
}
|
|
931
845
|
|
|
932
|
-
|
|
846
|
+
async #_doInitSession(): Promise<ClientSession> {
|
|
933
847
|
await this.historyInternals.historyBootstrapReady;
|
|
934
848
|
const previousConversationId = this.config.getStoredConversationId?.() || null;
|
|
935
849
|
const durableResume =
|
|
936
850
|
this.historyInternals.shouldResumeDurableConversation?.() === true;
|
|
937
|
-
if (!this
|
|
938
|
-
return this
|
|
851
|
+
if (!this.#isHistoryCapable() && !durableResume) {
|
|
852
|
+
return this.#ordinaryInit(previousConversationId, false);
|
|
939
853
|
}
|
|
940
854
|
const storedToken = await this.readVisitorToken();
|
|
941
855
|
|
|
@@ -943,14 +857,14 @@ export class AgentWidgetClient {
|
|
|
943
857
|
// session id, so benign expiry never forks or wipes the conversation.
|
|
944
858
|
if (previousConversationId && storedToken) {
|
|
945
859
|
try {
|
|
946
|
-
const resumed = await this
|
|
860
|
+
const resumed = await this.#createClientSession({
|
|
947
861
|
conversationId: previousConversationId,
|
|
948
862
|
durableResume,
|
|
949
863
|
});
|
|
950
864
|
if (durableResume && resumed.durableRecovery?.enabled !== true) {
|
|
951
865
|
this.historyInternals.setStoredResumableHandle?.(null);
|
|
952
866
|
}
|
|
953
|
-
return this
|
|
867
|
+
return this.#finishInit(resumed, previousConversationId, false);
|
|
954
868
|
} catch (error) {
|
|
955
869
|
if (
|
|
956
870
|
!isHistoryClientError(error, 'not_found') &&
|
|
@@ -963,11 +877,11 @@ export class AgentWidgetClient {
|
|
|
963
877
|
this.historyInternals.setStoredResumableHandle?.(null);
|
|
964
878
|
}
|
|
965
879
|
// Record gone or credential dead: exactly one ordinary fallback, never a loop.
|
|
966
|
-
return this
|
|
880
|
+
return this.#ordinaryInit(previousConversationId, true);
|
|
967
881
|
}
|
|
968
882
|
}
|
|
969
883
|
|
|
970
|
-
return this
|
|
884
|
+
return this.#ordinaryInit(previousConversationId, false);
|
|
971
885
|
}
|
|
972
886
|
|
|
973
887
|
/**
|
|
@@ -975,27 +889,27 @@ export class AgentWidgetClient {
|
|
|
975
889
|
* cross-tab first-init lock: a waiter re-reads inside the lock and joins the
|
|
976
890
|
* winner's visitor instead of minting a second one.
|
|
977
891
|
*/
|
|
978
|
-
|
|
892
|
+
async #ordinaryInit(
|
|
979
893
|
previousConversationId: string | null,
|
|
980
894
|
continuityBroken: boolean
|
|
981
895
|
): Promise<ClientSession> {
|
|
982
896
|
const store = this.historyInternals.visitorStore;
|
|
983
897
|
if (store && !(await this.readVisitorToken())) {
|
|
984
898
|
return store.withFirstInitLock(() =>
|
|
985
|
-
this
|
|
899
|
+
this.#doOrdinaryInit(previousConversationId, continuityBroken)
|
|
986
900
|
);
|
|
987
901
|
}
|
|
988
|
-
return this
|
|
902
|
+
return this.#doOrdinaryInit(previousConversationId, continuityBroken);
|
|
989
903
|
}
|
|
990
904
|
|
|
991
|
-
|
|
905
|
+
async #doOrdinaryInit(
|
|
992
906
|
previousConversationId: string | null,
|
|
993
907
|
continuityBroken: boolean
|
|
994
908
|
): Promise<ClientSession> {
|
|
995
909
|
const storedSessionId = this.config.getStoredSessionId?.() || null;
|
|
996
|
-
const first = await this
|
|
997
|
-
const session = await this
|
|
998
|
-
return this
|
|
910
|
+
const first = await this.#createClientSession({ storedSessionId });
|
|
911
|
+
const session = await this.#claimFirstConversation(first);
|
|
912
|
+
return this.#finishInit(session, previousConversationId, continuityBroken);
|
|
999
913
|
}
|
|
1000
914
|
|
|
1001
915
|
/**
|
|
@@ -1003,17 +917,17 @@ export class AgentWidgetClient {
|
|
|
1003
917
|
* persists any replacement token (inside `createClientSession`), and installs
|
|
1004
918
|
* the result as the live session.
|
|
1005
919
|
*/
|
|
1006
|
-
|
|
920
|
+
async #reinitWithProof(proof: string): Promise<ClientSession> {
|
|
1007
921
|
const storedSessionId = this.config.getStoredSessionId?.() || null;
|
|
1008
922
|
const previousConversationId = this.config.getStoredConversationId?.() || null;
|
|
1009
|
-
const session = await this
|
|
923
|
+
const session = await this.#createClientSession({
|
|
1010
924
|
storedSessionId,
|
|
1011
925
|
identityProof: proof,
|
|
1012
926
|
});
|
|
1013
|
-
const installed = this
|
|
927
|
+
const installed = this.#finishInit(session, previousConversationId, false);
|
|
1014
928
|
this.clientSession = installed;
|
|
1015
|
-
this
|
|
1016
|
-
this
|
|
929
|
+
this.#sessionInitPromise = null;
|
|
930
|
+
this.#sessionWarmLatch = false;
|
|
1017
931
|
this.resetClientToolsFingerprint();
|
|
1018
932
|
return installed;
|
|
1019
933
|
}
|
|
@@ -1022,22 +936,22 @@ export class AgentWidgetClient {
|
|
|
1022
936
|
* A minted visitor means the record this session just created is still
|
|
1023
937
|
* unowned; one immediate re-init with `{visitorToken, sessionId}` claims it.
|
|
1024
938
|
*/
|
|
1025
|
-
|
|
939
|
+
async #claimFirstConversation(first: ClientSession): Promise<ClientSession> {
|
|
1026
940
|
if (
|
|
1027
941
|
!first.visitor?.token ||
|
|
1028
|
-
this
|
|
1029
|
-
(!this
|
|
942
|
+
this.#claimInFlight ||
|
|
943
|
+
(!this.#isHistoryCapable() && first.durableRecovery?.enabled !== true)
|
|
1030
944
|
) {
|
|
1031
945
|
return first;
|
|
1032
946
|
}
|
|
1033
|
-
this
|
|
947
|
+
this.#claimInFlight = true;
|
|
1034
948
|
try {
|
|
1035
|
-
return await this
|
|
949
|
+
return await this.#createClientSession({ storedSessionId: first.sessionId });
|
|
1036
950
|
} catch {
|
|
1037
951
|
// Non-fatal: the next page load claims through the normal backend path.
|
|
1038
952
|
return first;
|
|
1039
953
|
} finally {
|
|
1040
|
-
this
|
|
954
|
+
this.#claimInFlight = false;
|
|
1041
955
|
}
|
|
1042
956
|
}
|
|
1043
957
|
|
|
@@ -1045,7 +959,7 @@ export class AgentWidgetClient {
|
|
|
1045
959
|
* Continuity guard + id persistence. A different record than the persisted
|
|
1046
960
|
* one is a privacy transition, announced before the new id is written.
|
|
1047
961
|
*/
|
|
1048
|
-
|
|
962
|
+
#finishInit(
|
|
1049
963
|
session: ClientSession,
|
|
1050
964
|
previousConversationId: string | null,
|
|
1051
965
|
continuityBroken: boolean
|
|
@@ -1072,7 +986,7 @@ export class AgentWidgetClient {
|
|
|
1072
986
|
}
|
|
1073
987
|
|
|
1074
988
|
/** Wrap an uncached init so nothing installs until the winner commits. */
|
|
1075
|
-
|
|
989
|
+
#prepared(session: ClientSession): PreparedClientSession {
|
|
1076
990
|
let settled = false;
|
|
1077
991
|
return {
|
|
1078
992
|
session,
|
|
@@ -1080,7 +994,7 @@ export class AgentWidgetClient {
|
|
|
1080
994
|
if (settled) return;
|
|
1081
995
|
settled = true;
|
|
1082
996
|
this.clientSession = session;
|
|
1083
|
-
this
|
|
997
|
+
this.#sessionWarmLatch = false;
|
|
1084
998
|
this.resetClientToolsFingerprint();
|
|
1085
999
|
},
|
|
1086
1000
|
discard: () => {
|
|
@@ -1101,7 +1015,7 @@ export class AgentWidgetClient {
|
|
|
1101
1015
|
if (!this.isClientTokenMode()) {
|
|
1102
1016
|
throw new Error('prepareConversationSession() only available in client token mode');
|
|
1103
1017
|
}
|
|
1104
|
-
if (!this
|
|
1018
|
+
if (!this.#isHistoryCapable()) {
|
|
1105
1019
|
throw new HistoryClientError(
|
|
1106
1020
|
'history_disabled',
|
|
1107
1021
|
'Visitor history is disabled for this surface'
|
|
@@ -1116,11 +1030,11 @@ export class AgentWidgetClient {
|
|
|
1116
1030
|
'Reopening a conversation requires a stored visitor token or an identity proof'
|
|
1117
1031
|
);
|
|
1118
1032
|
}
|
|
1119
|
-
const session = await this
|
|
1033
|
+
const session = await this.#createClientSession({
|
|
1120
1034
|
conversationId,
|
|
1121
1035
|
identityProof: proof,
|
|
1122
1036
|
});
|
|
1123
|
-
return this
|
|
1037
|
+
return this.#prepared(session);
|
|
1124
1038
|
}
|
|
1125
1039
|
|
|
1126
1040
|
/** Transactional new conversation: no stored session id, no conversation id. */
|
|
@@ -1129,8 +1043,8 @@ export class AgentWidgetClient {
|
|
|
1129
1043
|
throw new Error('prepareNewConversationSession() only available in client token mode');
|
|
1130
1044
|
}
|
|
1131
1045
|
await this.historyInternals.historyBootstrapReady;
|
|
1132
|
-
const session = await this
|
|
1133
|
-
return this
|
|
1046
|
+
const session = await this.#createClientSession({});
|
|
1047
|
+
return this.#prepared(session);
|
|
1134
1048
|
}
|
|
1135
1049
|
|
|
1136
1050
|
/**
|
|
@@ -1140,8 +1054,8 @@ export class AgentWidgetClient {
|
|
|
1140
1054
|
*/
|
|
1141
1055
|
public handleExternalCredentialChange(): void {
|
|
1142
1056
|
this.clearClientSession();
|
|
1143
|
-
if (this
|
|
1144
|
-
this
|
|
1057
|
+
if (this.#isHistoryCapable()) {
|
|
1058
|
+
this.#setHistoryIdentityStatus(this.#derivedBrowserOnly());
|
|
1145
1059
|
}
|
|
1146
1060
|
}
|
|
1147
1061
|
|
|
@@ -1150,8 +1064,8 @@ export class AgentWidgetClient {
|
|
|
1150
1064
|
*/
|
|
1151
1065
|
public clearClientSession(): void {
|
|
1152
1066
|
this.clientSession = null;
|
|
1153
|
-
this
|
|
1154
|
-
this
|
|
1067
|
+
this.#sessionInitPromise = null;
|
|
1068
|
+
this.#sessionWarmLatch = false;
|
|
1155
1069
|
this.resetClientToolsFingerprint();
|
|
1156
1070
|
}
|
|
1157
1071
|
|
|
@@ -1163,14 +1077,14 @@ export class AgentWidgetClient {
|
|
|
1163
1077
|
public resetClientToolsFingerprint(): void {
|
|
1164
1078
|
this.lastSentClientToolsFingerprint = null;
|
|
1165
1079
|
this.clientToolsFingerprintSessionId = null;
|
|
1166
|
-
this
|
|
1080
|
+
this.#sentNonEmptyClientToolsSessionId = null;
|
|
1167
1081
|
}
|
|
1168
1082
|
|
|
1169
1083
|
/**
|
|
1170
1084
|
* Get the feedback API URL
|
|
1171
1085
|
*/
|
|
1172
|
-
|
|
1173
|
-
return `${this
|
|
1086
|
+
#getFeedbackApiUrl(): string {
|
|
1087
|
+
return `${this.#clientApiBase()}/v1/client/feedback`;
|
|
1174
1088
|
}
|
|
1175
1089
|
|
|
1176
1090
|
/**
|
|
@@ -1248,7 +1162,7 @@ export class AgentWidgetClient {
|
|
|
1248
1162
|
...(this.config.clientToken && { token: this.config.clientToken }),
|
|
1249
1163
|
};
|
|
1250
1164
|
|
|
1251
|
-
const response = await fetch(this
|
|
1165
|
+
const response = await fetch(this.#getFeedbackApiUrl(), {
|
|
1252
1166
|
method: 'POST',
|
|
1253
1167
|
headers: {
|
|
1254
1168
|
'Content-Type': 'application/json',
|
|
@@ -1262,7 +1176,7 @@ export class AgentWidgetClient {
|
|
|
1262
1176
|
|
|
1263
1177
|
if (response.status === 401) {
|
|
1264
1178
|
this.clientSession = null;
|
|
1265
|
-
this
|
|
1179
|
+
this.#sessionWarmLatch = false;
|
|
1266
1180
|
this.config.onSessionExpired?.();
|
|
1267
1181
|
throw new Error('Session expired. Please refresh to continue.');
|
|
1268
1182
|
}
|
|
@@ -1330,32 +1244,11 @@ export class AgentWidgetClient {
|
|
|
1330
1244
|
}
|
|
1331
1245
|
|
|
1332
1246
|
// ==========================================================================
|
|
1333
|
-
// Visitor conversation history REST (client token mode only)
|
|
1247
|
+
// Visitor conversation history REST (client token mode only). The bodies
|
|
1248
|
+
// live in `client-history.ts` (lazy `client-history.js` chunk on the CDN).
|
|
1334
1249
|
// ==========================================================================
|
|
1335
1250
|
|
|
1336
|
-
|
|
1337
|
-
private historyUrl(
|
|
1338
|
-
path: string,
|
|
1339
|
-
query: Record<string, string | number | undefined>
|
|
1340
|
-
): string {
|
|
1341
|
-
const params = new URLSearchParams();
|
|
1342
|
-
for (const [key, value] of Object.entries(query)) {
|
|
1343
|
-
if (value === undefined || value === '') continue;
|
|
1344
|
-
params.set(key, String(value));
|
|
1345
|
-
}
|
|
1346
|
-
const search = params.toString();
|
|
1347
|
-
return `${this.clientApiBase()}/v1/client/${path}${search ? `?${search}` : ''}`;
|
|
1348
|
-
}
|
|
1349
|
-
|
|
1350
|
-
/** Explicit per-operation scope wins; otherwise config, then evidence. */
|
|
1351
|
-
private resolveHistoryScope(requested?: HistoryScope): HistoryScope {
|
|
1352
|
-
if (requested) return requested;
|
|
1353
|
-
const configured = this.config.features?.history?.scope;
|
|
1354
|
-
if (configured) return configured;
|
|
1355
|
-
return this.config.getIdentityProof ? 'verified-user' : 'browser';
|
|
1356
|
-
}
|
|
1357
|
-
|
|
1358
|
-
private async resolveChatIdentityProof(signal?: AbortSignal): Promise<ClientChatRequest['identityProof']> {
|
|
1251
|
+
async #resolveChatIdentityProof(signal?: AbortSignal): Promise<ClientChatRequest['identityProof']> {
|
|
1359
1252
|
signal?.throwIfAborted();
|
|
1360
1253
|
const provider = this.config.identityProvider;
|
|
1361
1254
|
if (provider === undefined) return undefined;
|
|
@@ -1389,317 +1282,58 @@ export class AgentWidgetClient {
|
|
|
1389
1282
|
return { provider, token };
|
|
1390
1283
|
}
|
|
1391
1284
|
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
);
|
|
1415
|
-
}
|
|
1416
|
-
if (proof) return proof;
|
|
1417
|
-
if (session.visitor?.endUserId != null) {
|
|
1418
|
-
// Never downgrade a bound visitor into its own browser scope.
|
|
1419
|
-
if (track) {
|
|
1420
|
-
this.setHistoryIdentityStatus({
|
|
1421
|
-
state: 'authentication_required',
|
|
1422
|
-
reason: 'proof_unavailable_after_binding',
|
|
1423
|
-
});
|
|
1424
|
-
}
|
|
1425
|
-
throw new HistoryClientError(
|
|
1426
|
-
'authentication_required',
|
|
1427
|
-
'This browser is bound to a signed-in user and needs a fresh identity proof'
|
|
1428
|
-
);
|
|
1429
|
-
}
|
|
1430
|
-
if (track) {
|
|
1431
|
-
this.setHistoryIdentityStatus({
|
|
1432
|
-
state: 'browser_only',
|
|
1433
|
-
reason: 'proof_unavailable_before_binding',
|
|
1434
|
-
});
|
|
1435
|
-
}
|
|
1436
|
-
return null;
|
|
1437
|
-
}
|
|
1438
|
-
|
|
1439
|
-
/** Bind the visitor before the first verified request; admitted proofs only. */
|
|
1440
|
-
private async bindIdentity(
|
|
1441
|
-
session: ClientSession,
|
|
1442
|
-
proof: string,
|
|
1443
|
-
track: boolean
|
|
1444
|
-
): Promise<ClientSession> {
|
|
1445
|
-
if (session.visitor?.endUserId != null) return session;
|
|
1446
|
-
const bound = await this.reinitWithProof(proof);
|
|
1447
|
-
const admitted =
|
|
1448
|
-
bound.visitor?.identityStatus === 'admitted' && bound.visitor?.endUserId != null;
|
|
1449
|
-
if (!admitted) {
|
|
1450
|
-
// "ignored", or a pre-acknowledgement server that left endUserId null.
|
|
1451
|
-
if (track) {
|
|
1452
|
-
this.setHistoryIdentityStatus({
|
|
1453
|
-
state: 'configuration_error',
|
|
1454
|
-
reason: 'proof_not_admitted',
|
|
1455
|
-
});
|
|
1456
|
-
}
|
|
1457
|
-
throw new HistoryClientError(
|
|
1458
|
-
'proof_not_admitted',
|
|
1459
|
-
PROOF_NOT_ADMITTED_MESSAGE
|
|
1460
|
-
);
|
|
1461
|
-
}
|
|
1462
|
-
return bound;
|
|
1463
|
-
}
|
|
1464
|
-
|
|
1465
|
-
private async readErrorCode(response: Response): Promise<string> {
|
|
1466
|
-
const body = (await response.json().catch(() => ({}))) as Record<string, unknown>;
|
|
1467
|
-
if (typeof body.error === 'string') return body.error;
|
|
1468
|
-
if (typeof body.message === 'string') return body.message;
|
|
1469
|
-
return '';
|
|
1470
|
-
}
|
|
1471
|
-
|
|
1472
|
-
/**
|
|
1473
|
-
* Selective one-shot 401 recovery. Returns the session to retry under;
|
|
1474
|
-
* anything unrecoverable throws typed and is never retried.
|
|
1475
|
-
*/
|
|
1476
|
-
private async recoverFromUnauthorized(
|
|
1477
|
-
reason: string,
|
|
1478
|
-
proof: string | null,
|
|
1479
|
-
track: boolean
|
|
1480
|
-
): Promise<ClientSession> {
|
|
1481
|
-
const text = reason.toLowerCase();
|
|
1482
|
-
if (text.includes('invalid_identity_proof')) {
|
|
1483
|
-
if (track) {
|
|
1484
|
-
this.setHistoryIdentityStatus({
|
|
1485
|
-
state: 'authentication_required',
|
|
1486
|
-
reason: 'invalid_identity_proof',
|
|
1487
|
-
});
|
|
1488
|
-
}
|
|
1489
|
-
throw new HistoryClientError('invalid_identity_proof', PROOF_REJECTED_MESSAGE);
|
|
1490
|
-
}
|
|
1491
|
-
if (text.includes('visitor token required')) {
|
|
1492
|
-
throw new HistoryClientError('visitor_token_missing', NO_VISITOR_CREDENTIAL_MESSAGE);
|
|
1493
|
-
}
|
|
1494
|
-
if (text.includes('visitor_identity_mismatch')) {
|
|
1495
|
-
if (!proof) {
|
|
1496
|
-
if (track) {
|
|
1497
|
-
this.setHistoryIdentityStatus({
|
|
1498
|
-
state: 'authentication_required',
|
|
1499
|
-
reason: 'proof_unavailable_after_binding',
|
|
1500
|
-
});
|
|
1501
|
-
}
|
|
1502
|
-
throw new HistoryClientError('visitor_identity_mismatch', IDENTITY_MISMATCH_MESSAGE);
|
|
1503
|
-
}
|
|
1504
|
-
// Same proof: binds this visitor, or gives a different person a clean one.
|
|
1505
|
-
return this.reinitWithProof(proof);
|
|
1506
|
-
}
|
|
1507
|
-
if (text.includes('expired') || text.includes('not found')) {
|
|
1508
|
-
this.clearClientSession();
|
|
1509
|
-
return this.initSession();
|
|
1510
|
-
}
|
|
1511
|
-
throw new HistoryClientError('unauthorized', reason || 'History request was not authorized');
|
|
1512
|
-
}
|
|
1513
|
-
|
|
1514
|
-
private async historyErrorFor(
|
|
1515
|
-
response: Response,
|
|
1516
|
-
track: boolean
|
|
1517
|
-
): Promise<HistoryClientError> {
|
|
1518
|
-
const code = await this.readErrorCode(response);
|
|
1519
|
-
if (response.status === 404) {
|
|
1520
|
-
return new HistoryClientError('not_found', 'Conversation not found');
|
|
1521
|
-
}
|
|
1522
|
-
if (response.status === 429) {
|
|
1523
|
-
const header = Number.parseInt(response.headers.get('Retry-After') ?? '', 10);
|
|
1524
|
-
return new HistoryClientError('rate_limited', 'Too many history requests', {
|
|
1525
|
-
...(Number.isFinite(header) ? { retryAfterSeconds: header } : {}),
|
|
1526
|
-
});
|
|
1527
|
-
}
|
|
1528
|
-
if (response.status === 503 && code.includes('identity_proof_not_admitted')) {
|
|
1529
|
-
if (track) {
|
|
1530
|
-
this.setHistoryIdentityStatus({
|
|
1531
|
-
state: 'configuration_error',
|
|
1532
|
-
reason: 'proof_not_admitted',
|
|
1533
|
-
});
|
|
1534
|
-
}
|
|
1535
|
-
return new HistoryClientError(
|
|
1536
|
-
'proof_not_admitted',
|
|
1537
|
-
PROOF_NOT_ADMITTED_MESSAGE
|
|
1538
|
-
);
|
|
1539
|
-
}
|
|
1540
|
-
if (response.status === 401) {
|
|
1541
|
-
const text = code.toLowerCase();
|
|
1542
|
-
if (text.includes('invalid_identity_proof')) {
|
|
1543
|
-
if (track) {
|
|
1544
|
-
this.setHistoryIdentityStatus({
|
|
1545
|
-
state: 'authentication_required',
|
|
1546
|
-
reason: 'invalid_identity_proof',
|
|
1547
|
-
});
|
|
1548
|
-
}
|
|
1549
|
-
return new HistoryClientError('invalid_identity_proof', PROOF_REJECTED_MESSAGE);
|
|
1550
|
-
}
|
|
1551
|
-
if (text.includes('visitor token required')) {
|
|
1552
|
-
return new HistoryClientError('visitor_token_missing', NO_VISITOR_CREDENTIAL_MESSAGE);
|
|
1553
|
-
}
|
|
1554
|
-
if (text.includes('visitor_identity_mismatch')) {
|
|
1555
|
-
return new HistoryClientError('visitor_identity_mismatch', IDENTITY_MISMATCH_MESSAGE);
|
|
1556
|
-
}
|
|
1557
|
-
return new HistoryClientError('unauthorized', code || 'History request was not authorized');
|
|
1558
|
-
}
|
|
1559
|
-
return new HistoryClientError(
|
|
1560
|
-
'request_failed',
|
|
1561
|
-
code || `History request failed (${response.status})`
|
|
1562
|
-
);
|
|
1563
|
-
}
|
|
1564
|
-
|
|
1565
|
-
/**
|
|
1566
|
-
* Validate `X-History-Identity-Status` before any body is committed: the
|
|
1567
|
-
* per-operation acknowledgement, not a prior init, is what proves scope.
|
|
1568
|
-
*/
|
|
1569
|
-
private commitIdentityAcknowledgement(
|
|
1570
|
-
header: string | null,
|
|
1571
|
-
proofSent: boolean,
|
|
1572
|
-
track: boolean
|
|
1573
|
-
): void {
|
|
1574
|
-
const value = header?.toLowerCase() ?? null;
|
|
1575
|
-
if (proofSent) {
|
|
1576
|
-
if (value === 'admitted') {
|
|
1577
|
-
if (track) this.setHistoryIdentityStatus({ state: 'verified' });
|
|
1578
|
-
return;
|
|
1579
|
-
}
|
|
1580
|
-
if (track) {
|
|
1581
|
-
this.setHistoryIdentityStatus({
|
|
1582
|
-
state: 'configuration_error',
|
|
1583
|
-
reason: 'proof_not_admitted',
|
|
1584
|
-
});
|
|
1585
|
-
}
|
|
1586
|
-
if (value === 'ignored') {
|
|
1587
|
-
// The gate-off path must fail before I/O; a 2xx here is a broken server.
|
|
1588
|
-
throw new HistoryClientError(
|
|
1589
|
-
'identity_contract_violation',
|
|
1590
|
-
'Server reported an ignored identity proof on a success response'
|
|
1591
|
-
);
|
|
1592
|
-
}
|
|
1593
|
-
throw new HistoryClientError(
|
|
1594
|
-
'proof_not_admitted',
|
|
1595
|
-
'The response did not acknowledge the identity proof this request sent'
|
|
1596
|
-
);
|
|
1597
|
-
}
|
|
1598
|
-
if (value === 'admitted') {
|
|
1599
|
-
throw new HistoryClientError(
|
|
1600
|
-
'identity_contract_violation',
|
|
1601
|
-
'Server admitted an identity proof that was never sent'
|
|
1602
|
-
);
|
|
1603
|
-
}
|
|
1604
|
-
// "not_provided", a missing header (rolling deploy), or an unknown value:
|
|
1605
|
-
// no verified claim was made either way.
|
|
1606
|
-
if (track) this.setHistoryIdentityStatus(this.browserOnlyStatus());
|
|
1607
|
-
}
|
|
1608
|
-
|
|
1609
|
-
/**
|
|
1610
|
-
* Shared history transport: live session, `sessionId` query param,
|
|
1611
|
-
* `X-Visitor-Token` header, one resolved proof, one-shot 401 recovery, and a
|
|
1612
|
-
* credential-revision guard that discards a response the store outran.
|
|
1613
|
-
*/
|
|
1614
|
-
private async historyFetch<T>(
|
|
1615
|
-
path: string,
|
|
1616
|
-
opts: {
|
|
1617
|
-
method: 'GET' | 'POST' | 'PATCH' | 'DELETE';
|
|
1618
|
-
query?: Record<string, string | number | undefined>;
|
|
1619
|
-
scope?: HistoryScope;
|
|
1620
|
-
body?: unknown;
|
|
1621
|
-
/** Reset tolerates a missing credential; every other route requires one. */
|
|
1622
|
-
visitorTokenOptional?: boolean;
|
|
1623
|
-
keepalive?: boolean;
|
|
1624
|
-
/** Transport ops validate the acknowledgement but publish no status. */
|
|
1625
|
-
trackIdentity?: boolean;
|
|
1626
|
-
}
|
|
1627
|
-
): Promise<T> {
|
|
1628
|
-
this.assertHistoryUsable();
|
|
1629
|
-
const track = opts.trackIdentity !== false;
|
|
1630
|
-
let session = await this.initSession();
|
|
1631
|
-
|
|
1632
|
-
// Resolved once per logical request: a recovery retry reuses the same proof
|
|
1633
|
-
// so one action cannot switch identities midway.
|
|
1634
|
-
let proof: string | null = null;
|
|
1635
|
-
if (this.resolveHistoryScope(opts.scope) === 'verified-user') {
|
|
1636
|
-
proof = await this.resolveIdentityProof(session, track);
|
|
1637
|
-
if (proof) session = await this.bindIdentity(session, proof, track);
|
|
1638
|
-
}
|
|
1639
|
-
|
|
1640
|
-
const store = this.historyInternals.visitorStore;
|
|
1641
|
-
let recovered = false;
|
|
1642
|
-
for (;;) {
|
|
1643
|
-
const capturedRevision = store?.revision() ?? 0;
|
|
1644
|
-
const visitorToken = await this.readVisitorToken();
|
|
1645
|
-
if (!visitorToken && !opts.visitorTokenOptional) {
|
|
1646
|
-
throw new HistoryClientError(
|
|
1647
|
-
'visitor_token_missing',
|
|
1648
|
-
'No visitor credential is stored for this browser'
|
|
1649
|
-
);
|
|
1650
|
-
}
|
|
1651
|
-
const response = await fetch(
|
|
1652
|
-
this.historyUrl(path, { ...opts.query, sessionId: session.sessionId }),
|
|
1653
|
-
{
|
|
1654
|
-
method: opts.method,
|
|
1655
|
-
headers: {
|
|
1656
|
-
'X-Persona-Version': VERSION,
|
|
1657
|
-
...(visitorToken ? { 'X-Visitor-Token': visitorToken } : {}),
|
|
1658
|
-
...(proof ? { 'X-Identity-Proof': proof } : {}),
|
|
1659
|
-
...(opts.body !== undefined ? { 'Content-Type': 'application/json' } : {}),
|
|
1660
|
-
},
|
|
1661
|
-
...(opts.body !== undefined ? { body: JSON.stringify(opts.body) } : {}),
|
|
1662
|
-
...(opts.keepalive ? { keepalive: true } : {}),
|
|
1663
|
-
}
|
|
1664
|
-
);
|
|
1665
|
-
|
|
1666
|
-
if (response.status === 401 && !recovered) {
|
|
1667
|
-
recovered = true;
|
|
1668
|
-
session = await this.recoverFromUnauthorized(
|
|
1669
|
-
await this.readErrorCode(response),
|
|
1670
|
-
proof,
|
|
1671
|
-
track
|
|
1672
|
-
);
|
|
1673
|
-
continue;
|
|
1674
|
-
}
|
|
1675
|
-
if (!response.ok) {
|
|
1676
|
-
throw await this.historyErrorFor(response, track);
|
|
1677
|
-
}
|
|
1678
|
-
|
|
1679
|
-
const acknowledgement = response.headers.get('X-History-Identity-Status');
|
|
1680
|
-
const data = (await response.json().catch(() => undefined)) as T;
|
|
1681
|
-
if (store && store.revision() !== capturedRevision) {
|
|
1682
|
-
// Another tab reset or replaced the credential: this body is stale.
|
|
1683
|
-
throw new HistoryClientError(
|
|
1684
|
-
'credential_changed',
|
|
1685
|
-
'The visitor credential changed while the request was in flight'
|
|
1686
|
-
);
|
|
1687
|
-
}
|
|
1688
|
-
this.commitIdentityAcknowledgement(acknowledgement, proof !== null, track);
|
|
1689
|
-
return data;
|
|
1690
|
-
}
|
|
1285
|
+
// Live accessors for the approval / resume requests (`client-resume.ts`,
|
|
1286
|
+
// shipped in the lazy client-stream chunk on the CDN).
|
|
1287
|
+
#resumeHostCache: ClientResumeHost | null = null;
|
|
1288
|
+
#resumeHost(): ClientResumeHost {
|
|
1289
|
+
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
|
1290
|
+
const client = this;
|
|
1291
|
+
return (this.#resumeHostCache ??= {
|
|
1292
|
+
get config() {
|
|
1293
|
+
return client.config;
|
|
1294
|
+
},
|
|
1295
|
+
headers: this.#headers,
|
|
1296
|
+
getHeaders: this.#getHeaders,
|
|
1297
|
+
debug: this.debug,
|
|
1298
|
+
clientApiBase: () => client.#clientApiBase(),
|
|
1299
|
+
getClientApiUrl: (endpoint) => client.#getClientApiUrl(endpoint),
|
|
1300
|
+
getWebMcpBridge: () => client.#getWebMcpBridge(),
|
|
1301
|
+
sendWithClientToolsDiff: (sessionId, tools, doFetch, opts) =>
|
|
1302
|
+
client.#sendWithClientToolsDiff(sessionId, tools, doFetch, opts),
|
|
1303
|
+
initSession: () => client.initSession(),
|
|
1304
|
+
readVisitorToken: () => client.readVisitorToken(),
|
|
1305
|
+
isClientTokenMode: () => client.isClientTokenMode(),
|
|
1306
|
+
});
|
|
1691
1307
|
}
|
|
1692
1308
|
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
)
|
|
1702
|
-
|
|
1309
|
+
// Live accessors handed to the lazily loaded history REST functions
|
|
1310
|
+
// (`client-history.ts`); built once per client.
|
|
1311
|
+
#historyHostCache: ClientHistoryHost | null = null;
|
|
1312
|
+
#historyHost(): ClientHistoryHost {
|
|
1313
|
+
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
|
1314
|
+
const client = this;
|
|
1315
|
+
return (this.#historyHostCache ??= {
|
|
1316
|
+
HistoryClientError,
|
|
1317
|
+
get config() {
|
|
1318
|
+
return client.config;
|
|
1319
|
+
},
|
|
1320
|
+
get historyInternals() {
|
|
1321
|
+
return client.historyInternals;
|
|
1322
|
+
},
|
|
1323
|
+
get clientSession() {
|
|
1324
|
+
return client.clientSession;
|
|
1325
|
+
},
|
|
1326
|
+
clientApiBase: () => client.#clientApiBase(),
|
|
1327
|
+
setHistoryIdentityStatus: (next) => client.#setHistoryIdentityStatus(next),
|
|
1328
|
+
browserOnlyStatus: () => client.#browserOnlyStatus(),
|
|
1329
|
+
derivedBrowserOnly: () => client.#derivedBrowserOnly(),
|
|
1330
|
+
reinitWithProof: (proof) => client.#reinitWithProof(proof),
|
|
1331
|
+
isHistoryCapable: () => client.#isHistoryCapable(),
|
|
1332
|
+
clearClientSession: () => client.clearClientSession(),
|
|
1333
|
+
initSession: () => client.initSession(),
|
|
1334
|
+
readVisitorToken: () => client.readVisitorToken(),
|
|
1335
|
+
isClientTokenMode: () => client.isClientTokenMode(),
|
|
1336
|
+
});
|
|
1703
1337
|
}
|
|
1704
1338
|
|
|
1705
1339
|
/** One page of the visitor's conversations, newest first. */
|
|
@@ -1709,22 +1343,7 @@ export class AgentWidgetClient {
|
|
|
1709
1343
|
targetId?: string;
|
|
1710
1344
|
scope?: HistoryScope;
|
|
1711
1345
|
}): Promise<HistoryConversationPage> {
|
|
1712
|
-
|
|
1713
|
-
data?: unknown[];
|
|
1714
|
-
nextCursor?: string | null;
|
|
1715
|
-
}>('conversations', {
|
|
1716
|
-
method: 'GET',
|
|
1717
|
-
query: {
|
|
1718
|
-
...(opts?.cursor ? { cursor: opts.cursor } : {}),
|
|
1719
|
-
...(opts?.limit !== undefined ? { limit: opts.limit } : {}),
|
|
1720
|
-
...(opts?.targetId ? { targetId: opts.targetId } : {}),
|
|
1721
|
-
},
|
|
1722
|
-
...(opts?.scope ? { scope: opts.scope } : {}),
|
|
1723
|
-
});
|
|
1724
|
-
return {
|
|
1725
|
-
data: (Array.isArray(page?.data) ? page.data : []).map(normalizeHistorySummary),
|
|
1726
|
-
nextCursor: typeof page?.nextCursor === 'string' ? page.nextCursor : null,
|
|
1727
|
-
};
|
|
1346
|
+
return (await loadClientHistory()).listConversations(this.#historyHost(), opts);
|
|
1728
1347
|
}
|
|
1729
1348
|
|
|
1730
1349
|
/**
|
|
@@ -1735,26 +1354,7 @@ export class AgentWidgetClient {
|
|
|
1735
1354
|
conversationId: string,
|
|
1736
1355
|
opts?: { messageCursor?: string; scope?: HistoryScope }
|
|
1737
1356
|
): Promise<HistoryConversationDetail> {
|
|
1738
|
-
|
|
1739
|
-
`conversations/${encodeURIComponent(conversationId)}`,
|
|
1740
|
-
{
|
|
1741
|
-
method: 'GET',
|
|
1742
|
-
query: {
|
|
1743
|
-
...(opts?.messageCursor ? { messageCursor: opts.messageCursor } : {}),
|
|
1744
|
-
},
|
|
1745
|
-
...(opts?.scope ? { scope: opts.scope } : {}),
|
|
1746
|
-
}
|
|
1747
|
-
);
|
|
1748
|
-
return {
|
|
1749
|
-
summary: normalizeHistorySummary(detail),
|
|
1750
|
-
messages: Array.isArray(detail?.messages)
|
|
1751
|
-
? (detail.messages as HistoryConversationDetail['messages'])
|
|
1752
|
-
: [],
|
|
1753
|
-
nextMessageCursor:
|
|
1754
|
-
typeof detail?.nextMessageCursor === 'string' ? detail.nextMessageCursor : null,
|
|
1755
|
-
conversationRevision:
|
|
1756
|
-
typeof detail?.conversationRevision === 'string' ? detail.conversationRevision : null,
|
|
1757
|
-
};
|
|
1357
|
+
return (await loadClientHistory()).getConversation(this.#historyHost(), conversationId, opts);
|
|
1758
1358
|
}
|
|
1759
1359
|
|
|
1760
1360
|
/**
|
|
@@ -1766,63 +1366,14 @@ export class AgentWidgetClient {
|
|
|
1766
1366
|
conversationId: string,
|
|
1767
1367
|
messages: HistoryDisplayProjection[]
|
|
1768
1368
|
): Promise<{ conversationRevision: string | null }> {
|
|
1769
|
-
|
|
1770
|
-
let total = 0;
|
|
1771
|
-
for (const message of messages) {
|
|
1772
|
-
const size = message.displayContent.length;
|
|
1773
|
-
if (size > DISPLAY_PROJECTION_MESSAGE_CAP) {
|
|
1774
|
-
throw new HistoryClientError(
|
|
1775
|
-
'payload_too_large',
|
|
1776
|
-
`displayContent exceeds the ${DISPLAY_PROJECTION_MESSAGE_CAP} character limit`
|
|
1777
|
-
);
|
|
1778
|
-
}
|
|
1779
|
-
total += size;
|
|
1780
|
-
}
|
|
1781
|
-
if (total > DISPLAY_PROJECTION_BATCH_CAP) {
|
|
1782
|
-
throw new HistoryClientError(
|
|
1783
|
-
'payload_too_large',
|
|
1784
|
-
`Display projection batch exceeds the ${DISPLAY_PROJECTION_BATCH_CAP} character limit`
|
|
1785
|
-
);
|
|
1786
|
-
}
|
|
1787
|
-
|
|
1788
|
-
await this.initSession();
|
|
1789
|
-
const store = this.historyInternals.visitorStore;
|
|
1790
|
-
const capturedRevision = store?.revision() ?? 0;
|
|
1791
|
-
const result = await this.historyFetch<{ conversationRevision?: string }>(
|
|
1792
|
-
`conversations/${encodeURIComponent(conversationId)}/display-projections`,
|
|
1793
|
-
{
|
|
1794
|
-
method: 'PATCH',
|
|
1795
|
-
body: { messages },
|
|
1796
|
-
scope: 'browser',
|
|
1797
|
-
keepalive: total <= DISPLAY_PROJECTION_BATCH_CAP,
|
|
1798
|
-
trackIdentity: false,
|
|
1799
|
-
}
|
|
1800
|
-
);
|
|
1801
|
-
const conversationRevision =
|
|
1802
|
-
typeof result?.conversationRevision === 'string' ? result.conversationRevision : null;
|
|
1803
|
-
// Install only while the same record and the same credential are still live.
|
|
1804
|
-
if (
|
|
1805
|
-
conversationRevision &&
|
|
1806
|
-
this.clientSession?.conversationId === conversationId &&
|
|
1807
|
-
(store?.revision() ?? 0) === capturedRevision
|
|
1808
|
-
) {
|
|
1809
|
-
this.historyInternals.setStoredConversationRevision?.(conversationRevision);
|
|
1810
|
-
}
|
|
1811
|
-
return { conversationRevision };
|
|
1369
|
+
return (await loadClientHistory()).finalizeDisplayProjections(this.#historyHost(), conversationId, messages);
|
|
1812
1370
|
}
|
|
1813
1371
|
|
|
1814
1372
|
public async deleteConversation(
|
|
1815
1373
|
conversationId: string,
|
|
1816
1374
|
opts?: { scope?: HistoryScope }
|
|
1817
1375
|
): Promise<{ deleted: number }> {
|
|
1818
|
-
|
|
1819
|
-
`conversations/${encodeURIComponent(conversationId)}`,
|
|
1820
|
-
{
|
|
1821
|
-
method: 'DELETE',
|
|
1822
|
-
...(opts?.scope ? { scope: opts.scope } : {}),
|
|
1823
|
-
}
|
|
1824
|
-
);
|
|
1825
|
-
return { deleted: typeof result?.deleted === 'number' ? result.deleted : 0 };
|
|
1376
|
+
return (await loadClientHistory()).deleteConversation(this.#historyHost(), conversationId, opts);
|
|
1826
1377
|
}
|
|
1827
1378
|
|
|
1828
1379
|
/**
|
|
@@ -1834,14 +1385,7 @@ export class AgentWidgetClient {
|
|
|
1834
1385
|
targetId?: string;
|
|
1835
1386
|
scope?: HistoryScope;
|
|
1836
1387
|
}): Promise<{ deleted: number }> {
|
|
1837
|
-
|
|
1838
|
-
method: 'DELETE',
|
|
1839
|
-
query: {
|
|
1840
|
-
...(opts?.targetId !== undefined ? { targetId: opts.targetId } : {}),
|
|
1841
|
-
},
|
|
1842
|
-
...(opts?.scope ? { scope: opts.scope } : {}),
|
|
1843
|
-
});
|
|
1844
|
-
return { deleted: typeof result?.deleted === 'number' ? result.deleted : 0 };
|
|
1388
|
+
return (await loadClientHistory()).deleteAllConversations(this.#historyHost(), opts);
|
|
1845
1389
|
}
|
|
1846
1390
|
|
|
1847
1391
|
/**
|
|
@@ -1850,20 +1394,7 @@ export class AgentWidgetClient {
|
|
|
1850
1394
|
* revocation still detaches this device.
|
|
1851
1395
|
*/
|
|
1852
1396
|
public async resetVisitor(): Promise<{ reset: true }> {
|
|
1853
|
-
|
|
1854
|
-
this.setHistoryIdentityStatus({ state: 'resetting' });
|
|
1855
|
-
try {
|
|
1856
|
-
await this.historyFetch<{ reset?: boolean }>('visitor/reset', {
|
|
1857
|
-
method: 'POST',
|
|
1858
|
-
scope: 'browser',
|
|
1859
|
-
visitorTokenOptional: true,
|
|
1860
|
-
});
|
|
1861
|
-
} finally {
|
|
1862
|
-
await this.historyInternals.visitorStore?.clear();
|
|
1863
|
-
// Post-reset resting state: this browser is a never-bound visitor again.
|
|
1864
|
-
this.setHistoryIdentityStatus(this.derivedBrowserOnly());
|
|
1865
|
-
}
|
|
1866
|
-
return { reset: true };
|
|
1397
|
+
return (await loadClientHistory()).resetVisitor(this.#historyHost());
|
|
1867
1398
|
}
|
|
1868
1399
|
|
|
1869
1400
|
/**
|
|
@@ -1871,24 +1402,27 @@ export class AgentWidgetClient {
|
|
|
1871
1402
|
*/
|
|
1872
1403
|
public async dispatch(options: DispatchOptions, onEvent: SSEHandler) {
|
|
1873
1404
|
options.signal?.throwIfAborted();
|
|
1405
|
+
// Fetch the stream processor in parallel with the request; a failure here
|
|
1406
|
+
// is retried by the awaited load in #streamResponse.
|
|
1407
|
+
loadClientStream().catch(() => {});
|
|
1874
1408
|
if (this.isClientTokenMode()) {
|
|
1875
|
-
return this
|
|
1409
|
+
return this.#dispatchClientToken(options, onEvent);
|
|
1876
1410
|
}
|
|
1877
1411
|
if (this.isAgentMode()) {
|
|
1878
|
-
return this
|
|
1412
|
+
return this.#dispatchAgent(options, onEvent);
|
|
1879
1413
|
}
|
|
1880
|
-
return this
|
|
1414
|
+
return this.#dispatchProxy(options, onEvent);
|
|
1881
1415
|
}
|
|
1882
1416
|
|
|
1883
1417
|
/**
|
|
1884
1418
|
* Client token mode dispatch
|
|
1885
1419
|
*/
|
|
1886
|
-
|
|
1420
|
+
async #dispatchClientToken(options: DispatchOptions, onEvent: SSEHandler) {
|
|
1887
1421
|
// Claim the turn before any await: a later dispatch that interrupts this one
|
|
1888
1422
|
// takes the claim, and every event this call still receives is then stale.
|
|
1889
1423
|
const turnId = generateTurnId();
|
|
1890
|
-
this
|
|
1891
|
-
const isCurrentTurn = () => this
|
|
1424
|
+
this.#currentClientTurnId = turnId;
|
|
1425
|
+
const isCurrentTurn = () => this.#currentClientTurnId === turnId;
|
|
1892
1426
|
// Terminal frames of a superseded run must not reopen the composer or paint
|
|
1893
1427
|
// into the new turn's bubble; status frames are equally misleading.
|
|
1894
1428
|
const forward: SSEHandler = (event) => {
|
|
@@ -1913,8 +1447,8 @@ export class AgentWidgetClient {
|
|
|
1913
1447
|
throw new Error('Renewing this conversation requires its visitor credential.');
|
|
1914
1448
|
}
|
|
1915
1449
|
assertCurrentTurn();
|
|
1916
|
-
const renewed = await this
|
|
1917
|
-
...(durable || this
|
|
1450
|
+
const renewed = await this.#createClientSession({
|
|
1451
|
+
...(durable || this.#isHistoryCapable()
|
|
1918
1452
|
? { conversationId: previous.conversationId, durableResume: durable }
|
|
1919
1453
|
: { storedSessionId: previous.sessionId }),
|
|
1920
1454
|
signal: options.signal,
|
|
@@ -1926,9 +1460,9 @@ export class AgentWidgetClient {
|
|
|
1926
1460
|
) {
|
|
1927
1461
|
throw new Error('Session renewal did not preserve this conversation.');
|
|
1928
1462
|
}
|
|
1929
|
-
session = this
|
|
1463
|
+
session = this.#finishInit(renewed, previous.conversationId ?? null, false);
|
|
1930
1464
|
this.clientSession = session;
|
|
1931
|
-
this
|
|
1465
|
+
this.#sessionWarmLatch = false;
|
|
1932
1466
|
this.resetClientToolsFingerprint();
|
|
1933
1467
|
this.config.onSessionInit?.(session);
|
|
1934
1468
|
};
|
|
@@ -1937,7 +1471,7 @@ export class AgentWidgetClient {
|
|
|
1937
1471
|
}
|
|
1938
1472
|
|
|
1939
1473
|
// Build the standard payload to get context/metadata from middleware
|
|
1940
|
-
const basePayload = await this
|
|
1474
|
+
const basePayload = await this.#buildPayload(options.messages);
|
|
1941
1475
|
|
|
1942
1476
|
// Build the chat request payload with message IDs for feedback tracking
|
|
1943
1477
|
// Filter out sessionId from metadata if present (it's only for local storage)
|
|
@@ -1948,7 +1482,7 @@ export class AgentWidgetClient {
|
|
|
1948
1482
|
: undefined;
|
|
1949
1483
|
|
|
1950
1484
|
// Common (tools-independent) fields for the chat request.
|
|
1951
|
-
const historyCapable = this
|
|
1485
|
+
const historyCapable = this.#isHistoryCapable();
|
|
1952
1486
|
const baseChatRequest: Omit<ClientChatRequest, 'clientTools' | 'clientToolsFingerprint'> = {
|
|
1953
1487
|
sessionId: session.sessionId,
|
|
1954
1488
|
// Filter out messages with empty content to prevent validation errors
|
|
@@ -1988,9 +1522,9 @@ export class AgentWidgetClient {
|
|
|
1988
1522
|
const recoveryVisitorToken =
|
|
1989
1523
|
session.durableRecovery?.enabled === true ? await this.readVisitorToken() : null;
|
|
1990
1524
|
assertCurrentTurn();
|
|
1991
|
-
return this
|
|
1525
|
+
return this.#sendWithClientToolsDiff(session.sessionId, basePayload.clientTools, async (toolFields) => {
|
|
1992
1526
|
assertCurrentTurn();
|
|
1993
|
-
const identityProof = await this
|
|
1527
|
+
const identityProof = await this.#resolveChatIdentityProof(options.signal);
|
|
1994
1528
|
assertCurrentTurn();
|
|
1995
1529
|
const chatRequest: ClientChatRequest = {
|
|
1996
1530
|
...baseChatRequest,
|
|
@@ -2007,7 +1541,7 @@ export class AgentWidgetClient {
|
|
|
2007
1541
|
});
|
|
2008
1542
|
}
|
|
2009
1543
|
|
|
2010
|
-
return fetch(this
|
|
1544
|
+
return fetch(this.#getClientApiUrl('chat'), {
|
|
2011
1545
|
method: 'POST',
|
|
2012
1546
|
headers: {
|
|
2013
1547
|
'Content-Type': 'application/json',
|
|
@@ -2090,7 +1624,7 @@ export class AgentWidgetClient {
|
|
|
2090
1624
|
|
|
2091
1625
|
// Stream the response (same SSE handling as proxy mode)
|
|
2092
1626
|
try {
|
|
2093
|
-
await this
|
|
1627
|
+
await this.#streamResponse(response.body, forward, options.assistantMessageId);
|
|
2094
1628
|
} finally {
|
|
2095
1629
|
forward({ type: "status", status: "idle" });
|
|
2096
1630
|
}
|
|
@@ -2111,10 +1645,10 @@ export class AgentWidgetClient {
|
|
|
2111
1645
|
/**
|
|
2112
1646
|
* Proxy mode dispatch (original implementation)
|
|
2113
1647
|
*/
|
|
2114
|
-
|
|
1648
|
+
async #dispatchProxy(options: DispatchOptions, onEvent: SSEHandler) {
|
|
2115
1649
|
onEvent({ type: "status", status: "connecting" });
|
|
2116
1650
|
|
|
2117
|
-
const payload = await this
|
|
1651
|
+
const payload = await this.#buildPayload(
|
|
2118
1652
|
options.messages,
|
|
2119
1653
|
options.composerOptions
|
|
2120
1654
|
);
|
|
@@ -2125,10 +1659,10 @@ export class AgentWidgetClient {
|
|
|
2125
1659
|
}
|
|
2126
1660
|
|
|
2127
1661
|
// Build headers - merge static headers with dynamic headers if provided
|
|
2128
|
-
let headers = { ...this
|
|
2129
|
-
if (this
|
|
1662
|
+
let headers = { ...this.#headers };
|
|
1663
|
+
if (this.#getHeaders) {
|
|
2130
1664
|
try {
|
|
2131
|
-
const dynamicHeaders = await this
|
|
1665
|
+
const dynamicHeaders = await this.#getHeaders();
|
|
2132
1666
|
headers = { ...headers, ...dynamicHeaders };
|
|
2133
1667
|
} catch (error) {
|
|
2134
1668
|
if (typeof console !== "undefined") {
|
|
@@ -2140,10 +1674,10 @@ export class AgentWidgetClient {
|
|
|
2140
1674
|
|
|
2141
1675
|
// Use customFetch if provided, otherwise use default fetch
|
|
2142
1676
|
let response: Response;
|
|
2143
|
-
if (this
|
|
1677
|
+
if (this.#customFetch) {
|
|
2144
1678
|
try {
|
|
2145
|
-
response = await this
|
|
2146
|
-
this
|
|
1679
|
+
response = await this.#customFetch(
|
|
1680
|
+
this.#apiUrl,
|
|
2147
1681
|
{
|
|
2148
1682
|
method: "POST",
|
|
2149
1683
|
headers,
|
|
@@ -2158,7 +1692,7 @@ export class AgentWidgetClient {
|
|
|
2158
1692
|
throw err;
|
|
2159
1693
|
}
|
|
2160
1694
|
} else {
|
|
2161
|
-
response = await fetch(this
|
|
1695
|
+
response = await fetch(this.#apiUrl, {
|
|
2162
1696
|
method: "POST",
|
|
2163
1697
|
headers,
|
|
2164
1698
|
body: JSON.stringify(payload),
|
|
@@ -2176,7 +1710,7 @@ export class AgentWidgetClient {
|
|
|
2176
1710
|
|
|
2177
1711
|
onEvent({ type: "status", status: "connected" });
|
|
2178
1712
|
try {
|
|
2179
|
-
await this
|
|
1713
|
+
await this.#streamResponse(response.body, onEvent);
|
|
2180
1714
|
} finally {
|
|
2181
1715
|
onEvent({ type: "status", status: "idle" });
|
|
2182
1716
|
}
|
|
@@ -2185,10 +1719,10 @@ export class AgentWidgetClient {
|
|
|
2185
1719
|
/**
|
|
2186
1720
|
* Agent mode dispatch
|
|
2187
1721
|
*/
|
|
2188
|
-
|
|
1722
|
+
async #dispatchAgent(options: DispatchOptions, onEvent: SSEHandler) {
|
|
2189
1723
|
onEvent({ type: "status", status: "connecting" });
|
|
2190
1724
|
|
|
2191
|
-
const payload = await this
|
|
1725
|
+
const payload = await this.#buildAgentPayload(
|
|
2192
1726
|
options.messages,
|
|
2193
1727
|
options.composerOptions
|
|
2194
1728
|
);
|
|
@@ -2199,10 +1733,10 @@ export class AgentWidgetClient {
|
|
|
2199
1733
|
}
|
|
2200
1734
|
|
|
2201
1735
|
// Build headers - merge static headers with dynamic headers if provided
|
|
2202
|
-
let headers = { ...this
|
|
2203
|
-
if (this
|
|
1736
|
+
let headers = { ...this.#headers };
|
|
1737
|
+
if (this.#getHeaders) {
|
|
2204
1738
|
try {
|
|
2205
|
-
const dynamicHeaders = await this
|
|
1739
|
+
const dynamicHeaders = await this.#getHeaders();
|
|
2206
1740
|
headers = { ...headers, ...dynamicHeaders };
|
|
2207
1741
|
} catch (error) {
|
|
2208
1742
|
if (typeof console !== "undefined") {
|
|
@@ -2214,10 +1748,10 @@ export class AgentWidgetClient {
|
|
|
2214
1748
|
|
|
2215
1749
|
// Use customFetch if provided, otherwise use default fetch
|
|
2216
1750
|
let response: Response;
|
|
2217
|
-
if (this
|
|
1751
|
+
if (this.#customFetch) {
|
|
2218
1752
|
try {
|
|
2219
|
-
response = await this
|
|
2220
|
-
this
|
|
1753
|
+
response = await this.#customFetch(
|
|
1754
|
+
this.#apiUrl,
|
|
2221
1755
|
{
|
|
2222
1756
|
method: "POST",
|
|
2223
1757
|
headers,
|
|
@@ -2232,7 +1766,7 @@ export class AgentWidgetClient {
|
|
|
2232
1766
|
throw err;
|
|
2233
1767
|
}
|
|
2234
1768
|
} else {
|
|
2235
|
-
response = await fetch(this
|
|
1769
|
+
response = await fetch(this.#apiUrl, {
|
|
2236
1770
|
method: "POST",
|
|
2237
1771
|
headers,
|
|
2238
1772
|
body: JSON.stringify(payload),
|
|
@@ -2250,7 +1784,7 @@ export class AgentWidgetClient {
|
|
|
2250
1784
|
|
|
2251
1785
|
onEvent({ type: "status", status: "connected" });
|
|
2252
1786
|
try {
|
|
2253
|
-
await this
|
|
1787
|
+
await this.#streamResponse(response.body, onEvent, options.assistantMessageId);
|
|
2254
1788
|
} finally {
|
|
2255
1789
|
onEvent({ type: "status", status: "idle" });
|
|
2256
1790
|
}
|
|
@@ -2269,7 +1803,7 @@ export class AgentWidgetClient {
|
|
|
2269
1803
|
): Promise<void> {
|
|
2270
1804
|
onEvent({ type: "status", status: "connected" });
|
|
2271
1805
|
try {
|
|
2272
|
-
await this
|
|
1806
|
+
await this.#streamResponse(body, onEvent, assistantMessageId, seedContent);
|
|
2273
1807
|
} finally {
|
|
2274
1808
|
onEvent({ type: "status", status: "idle" });
|
|
2275
1809
|
}
|
|
@@ -2278,31 +1812,20 @@ export class AgentWidgetClient {
|
|
|
2278
1812
|
/**
|
|
2279
1813
|
* Send an approval decision to the API and return the response
|
|
2280
1814
|
* for streaming continuation.
|
|
1815
|
+
*
|
|
1816
|
+
* Routes by mode:
|
|
1817
|
+
* - **client-token mode**: POST `${apiBase}/v1/client/approve` with the
|
|
1818
|
+
* active `sessionId` and no Bearer key (runtypelabs/core#9518). Answers a
|
|
1819
|
+
* gate authored `tools.approval.approver: 'end-user'`; the SSE body has
|
|
1820
|
+
* the same shape as `/v1/client/resume`.
|
|
1821
|
+
* - **dispatch / proxy mode**: POST `${apiBase}/v1/agents/{agentId}/approve`
|
|
1822
|
+
* with the host's headers.
|
|
2281
1823
|
*/
|
|
2282
1824
|
public async resolveApproval(
|
|
2283
1825
|
approval: { agentId: string; executionId: string; approvalId: string },
|
|
2284
1826
|
decision: 'approved' | 'denied'
|
|
2285
1827
|
): Promise<Response> {
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
let headers: Record<string, string> = {
|
|
2289
|
-
'Content-Type': 'application/json',
|
|
2290
|
-
...this.headers
|
|
2291
|
-
};
|
|
2292
|
-
if (this.getHeaders) {
|
|
2293
|
-
Object.assign(headers, await this.getHeaders());
|
|
2294
|
-
}
|
|
2295
|
-
|
|
2296
|
-
return fetch(url, {
|
|
2297
|
-
method: 'POST',
|
|
2298
|
-
headers,
|
|
2299
|
-
body: JSON.stringify({
|
|
2300
|
-
executionId: approval.executionId,
|
|
2301
|
-
approvalId: approval.approvalId,
|
|
2302
|
-
decision,
|
|
2303
|
-
streamResponse: true,
|
|
2304
|
-
}),
|
|
2305
|
-
});
|
|
1828
|
+
return (await loadClientStream()).resolveApproval(this.#resumeHost(), approval, decision);
|
|
2306
1829
|
}
|
|
2307
1830
|
|
|
2308
1831
|
/**
|
|
@@ -2364,7 +1887,7 @@ export class AgentWidgetClient {
|
|
|
2364
1887
|
* tools this turn", whereas on `/resume` absence means "keep the frozen
|
|
2365
1888
|
* dispatch-time set".
|
|
2366
1889
|
*/
|
|
2367
|
-
|
|
1890
|
+
async #sendWithClientToolsDiff(
|
|
2368
1891
|
sessionId: string,
|
|
2369
1892
|
fullClientTools: ClientToolDefinition[] | undefined,
|
|
2370
1893
|
doFetch: (
|
|
@@ -2388,7 +1911,7 @@ export class AgentWidgetClient {
|
|
|
2388
1911
|
const sendEmptyReplace =
|
|
2389
1912
|
!hasClientTools &&
|
|
2390
1913
|
opts?.emptyMeansReplace === true &&
|
|
2391
|
-
this
|
|
1914
|
+
this.#sentNonEmptyClientToolsSessionId === sessionId;
|
|
2392
1915
|
|
|
2393
1916
|
// `forceFull` flips to true after a 409 cache-miss so the single retry
|
|
2394
1917
|
// resends the full list.
|
|
@@ -2427,11 +1950,11 @@ export class AgentWidgetClient {
|
|
|
2427
1950
|
this.lastSentClientToolsFingerprint = clientToolsFingerprint ?? null;
|
|
2428
1951
|
this.clientToolsFingerprintSessionId = sessionId;
|
|
2429
1952
|
if (hasClientTools) {
|
|
2430
|
-
this
|
|
1953
|
+
this.#sentNonEmptyClientToolsSessionId = sessionId;
|
|
2431
1954
|
} else if (sendEmptyReplace) {
|
|
2432
1955
|
// The explicit [] replaced the persisted set server-side; the
|
|
2433
1956
|
// pending clear is done.
|
|
2434
|
-
this
|
|
1957
|
+
this.#sentNonEmptyClientToolsSessionId = null;
|
|
2435
1958
|
}
|
|
2436
1959
|
// Omitted-empty commits (chat with zero tools) leave the flag set:
|
|
2437
1960
|
// they don't touch tools persisted for a paused execution.
|
|
@@ -2444,95 +1967,7 @@ export class AgentWidgetClient {
|
|
|
2444
1967
|
toolOutputs: Record<string, unknown>,
|
|
2445
1968
|
options?: { streamResponse?: boolean; signal?: AbortSignal; after?: string }
|
|
2446
1969
|
): Promise<Response> {
|
|
2447
|
-
|
|
2448
|
-
const url = isClientToken
|
|
2449
|
-
? this.getClientApiUrl('resume')
|
|
2450
|
-
: `${this.config.apiUrl?.replace(/\/+$/, '') || DEFAULT_CLIENT_API_BASE}/resume`;
|
|
2451
|
-
|
|
2452
|
-
// The client-token resume route authenticates the session, not a Bearer
|
|
2453
|
-
// key. A WebMCP approval can sit awaiting user input for a long time, so by
|
|
2454
|
-
// the time we resume the original session may have expired. Re-validate (and
|
|
2455
|
-
// silently re-init if needed) via initSession(): which returns the live
|
|
2456
|
-
// session when `new Date() < expiresAt`, else mints a fresh one: instead of
|
|
2457
|
-
// trusting the possibly-stale `this.clientSession`. (core#3889; BugBot
|
|
2458
|
-
// PR #214 r3367875360.)
|
|
2459
|
-
let resumeSessionId: string | undefined;
|
|
2460
|
-
let resumeVisitorToken: string | null = null;
|
|
2461
|
-
if (isClientToken) {
|
|
2462
|
-
const session = await this.initSession();
|
|
2463
|
-
resumeSessionId = session.sessionId;
|
|
2464
|
-
if (session.durableRecovery?.enabled === true) {
|
|
2465
|
-
resumeVisitorToken = await this.readVisitorToken();
|
|
2466
|
-
}
|
|
2467
|
-
}
|
|
2468
|
-
|
|
2469
|
-
let headers: Record<string, string> = {
|
|
2470
|
-
'Content-Type': 'application/json',
|
|
2471
|
-
...(resumeVisitorToken ? { 'X-Visitor-Token': resumeVisitorToken } : {}),
|
|
2472
|
-
...this.headers
|
|
2473
|
-
};
|
|
2474
|
-
if (this.getHeaders) {
|
|
2475
|
-
Object.assign(headers, await this.getHeaders());
|
|
2476
|
-
}
|
|
2477
|
-
|
|
2478
|
-
const body: Record<string, unknown> = {
|
|
2479
|
-
executionId,
|
|
2480
|
-
toolOutputs,
|
|
2481
|
-
streamResponse: options?.streamResponse ?? true,
|
|
2482
|
-
...(options?.after ? { after: options.after } : {}),
|
|
2483
|
-
};
|
|
2484
|
-
// Thread the (refreshed) sessionId through like `/v1/client/chat` does.
|
|
2485
|
-
if (resumeSessionId) {
|
|
2486
|
-
body.sessionId = resumeSessionId;
|
|
2487
|
-
}
|
|
2488
|
-
|
|
2489
|
-
if (isClientToken && resumeSessionId) {
|
|
2490
|
-
// Mid-run WebMCP tool refresh (runtypelabs/core#5361): the paused tool
|
|
2491
|
-
// may have navigated the page, so the dispatch-time snapshot the server
|
|
2492
|
-
// persisted can be stale. Re-snapshot the registry — the same built-in +
|
|
2493
|
-
// bridge composition as the payload builders, so fingerprints computed
|
|
2494
|
-
// here and on chat turns describe the same tool space — and ship it via
|
|
2495
|
-
// the shared diff-only protocol. `emptyMeansReplace` sends an explicit
|
|
2496
|
-
// `clientTools: []` when the registry vanished after a non-empty send,
|
|
2497
|
-
// so the server replaces the persisted set instead of keeping it frozen.
|
|
2498
|
-
const fullClientTools = [
|
|
2499
|
-
...builtInClientToolsForDispatch(this.config),
|
|
2500
|
-
...((await (await this.getWebMcpBridge())?.snapshotForDispatch()) ?? []),
|
|
2501
|
-
];
|
|
2502
|
-
const { response, commit } = await this.sendWithClientToolsDiff(
|
|
2503
|
-
resumeSessionId,
|
|
2504
|
-
fullClientTools,
|
|
2505
|
-
(toolFields) => {
|
|
2506
|
-
const resumeRequest = { ...body, ...toolFields };
|
|
2507
|
-
if (this.debug) {
|
|
2508
|
-
// eslint-disable-next-line no-console
|
|
2509
|
-
console.debug("[AgentWidgetClient] client token resume", resumeRequest);
|
|
2510
|
-
}
|
|
2511
|
-
return fetch(url, {
|
|
2512
|
-
method: 'POST',
|
|
2513
|
-
headers,
|
|
2514
|
-
body: JSON.stringify(resumeRequest),
|
|
2515
|
-
signal: options?.signal,
|
|
2516
|
-
});
|
|
2517
|
-
},
|
|
2518
|
-
{ emptyMeansReplace: true }
|
|
2519
|
-
);
|
|
2520
|
-
// The server stores the refreshed registry before running the
|
|
2521
|
-
// continuation pipeline, so an OK response means it holds this set under
|
|
2522
|
-
// this fingerprint. Mirror chat's commit-on-success discipline: a failed
|
|
2523
|
-
// resume must not record a fingerprint the server never stored.
|
|
2524
|
-
if (response.ok) {
|
|
2525
|
-
commit();
|
|
2526
|
-
}
|
|
2527
|
-
return response;
|
|
2528
|
-
}
|
|
2529
|
-
|
|
2530
|
-
return fetch(url, {
|
|
2531
|
-
method: 'POST',
|
|
2532
|
-
headers,
|
|
2533
|
-
body: JSON.stringify(body),
|
|
2534
|
-
signal: options?.signal,
|
|
2535
|
-
});
|
|
1970
|
+
return (await loadClientStream()).resumeFlow(this.#resumeHost(), executionId, toolOutputs, options);
|
|
2536
1971
|
}
|
|
2537
1972
|
|
|
2538
1973
|
/**
|
|
@@ -2544,7 +1979,7 @@ export class AgentWidgetClient {
|
|
|
2544
1979
|
* The default model-visible path (`llmAppend`) already rode into the message's
|
|
2545
1980
|
* `llmContent`/`contentParts`, so it needs nothing here.
|
|
2546
1981
|
*/
|
|
2547
|
-
|
|
1982
|
+
#latestMentionContext(
|
|
2548
1983
|
messages: AgentWidgetMessage[]
|
|
2549
1984
|
): Record<string, unknown> | null {
|
|
2550
1985
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
@@ -2566,13 +2001,13 @@ export class AgentWidgetClient {
|
|
|
2566
2001
|
* contributed, so callers can skip setting `payload.context`. Shared by the
|
|
2567
2002
|
* agent and flow payload builders.
|
|
2568
2003
|
*/
|
|
2569
|
-
|
|
2004
|
+
async #buildContextAggregate(
|
|
2570
2005
|
messages: AgentWidgetMessage[]
|
|
2571
2006
|
): Promise<Record<string, unknown> | null> {
|
|
2572
2007
|
const contextAggregate: Record<string, unknown> = {};
|
|
2573
|
-
if (this
|
|
2008
|
+
if (this.#contextProviders.length) {
|
|
2574
2009
|
await Promise.all(
|
|
2575
|
-
this
|
|
2010
|
+
this.#contextProviders.map(async (provider) => {
|
|
2576
2011
|
try {
|
|
2577
2012
|
const result = await provider({
|
|
2578
2013
|
messages,
|
|
@@ -2590,7 +2025,7 @@ export class AgentWidgetClient {
|
|
|
2590
2025
|
})
|
|
2591
2026
|
);
|
|
2592
2027
|
}
|
|
2593
|
-
const mentionContext = this
|
|
2028
|
+
const mentionContext = this.#latestMentionContext(messages);
|
|
2594
2029
|
if (mentionContext) Object.assign(contextAggregate, mentionContext);
|
|
2595
2030
|
return Object.keys(contextAggregate).length ? contextAggregate : null;
|
|
2596
2031
|
}
|
|
@@ -2600,7 +2035,7 @@ export class AgentWidgetClient {
|
|
|
2600
2035
|
* the wire entirely, so a widget with no picker and no modes sends exactly
|
|
2601
2036
|
* what it sent before.
|
|
2602
2037
|
*/
|
|
2603
|
-
|
|
2038
|
+
#normalizeComposerOptions(
|
|
2604
2039
|
options: ComposerOptionsPayload | undefined
|
|
2605
2040
|
): ComposerOptionsPayload | undefined {
|
|
2606
2041
|
if (!options) return undefined;
|
|
@@ -2617,7 +2052,7 @@ export class AgentWidgetClient {
|
|
|
2617
2052
|
* composer, but only for an id the host actually declared in
|
|
2618
2053
|
* `composer.models`. Config is untouched; the mapping is per request.
|
|
2619
2054
|
*/
|
|
2620
|
-
|
|
2055
|
+
#applyInlineAgentModel(
|
|
2621
2056
|
agent: AgentWidgetAgentRequestPayload["agent"],
|
|
2622
2057
|
composerOptions: ComposerOptionsPayload | undefined
|
|
2623
2058
|
): AgentWidgetAgentRequestPayload["agent"] {
|
|
@@ -2629,11 +2064,11 @@ export class AgentWidgetClient {
|
|
|
2629
2064
|
return declared ? { ...agent, model: selected } : agent;
|
|
2630
2065
|
}
|
|
2631
2066
|
|
|
2632
|
-
|
|
2067
|
+
async #buildAgentPayload(
|
|
2633
2068
|
messages: AgentWidgetMessage[],
|
|
2634
2069
|
composerOptions?: ComposerOptionsPayload
|
|
2635
2070
|
): Promise<AgentWidgetAgentRequestPayload> {
|
|
2636
|
-
const routedAgentId = this
|
|
2071
|
+
const routedAgentId = this.#routing().agentId;
|
|
2637
2072
|
if (!this.config.agent && !routedAgentId) {
|
|
2638
2073
|
throw new Error('Agent configuration required for agent mode');
|
|
2639
2074
|
}
|
|
@@ -2650,9 +2085,9 @@ export class AgentWidgetClient {
|
|
|
2650
2085
|
: null
|
|
2651
2086
|
);
|
|
2652
2087
|
|
|
2653
|
-
const composer = this
|
|
2088
|
+
const composer = this.#normalizeComposerOptions(composerOptions);
|
|
2654
2089
|
const payload: AgentWidgetAgentRequestPayload = {
|
|
2655
|
-
agent: this
|
|
2090
|
+
agent: this.#applyInlineAgentModel(
|
|
2656
2091
|
this.config.agent ?? { agentId: routedAgentId! },
|
|
2657
2092
|
composer
|
|
2658
2093
|
),
|
|
@@ -2673,20 +2108,20 @@ export class AgentWidgetClient {
|
|
|
2673
2108
|
// so dispatch microtask timing is unchanged.
|
|
2674
2109
|
const clientTools = [
|
|
2675
2110
|
...builtInClientToolsForDispatch(this.config),
|
|
2676
|
-
...((await (await this
|
|
2111
|
+
...((await (await this.#getWebMcpBridge())?.snapshotForDispatch()) ?? []),
|
|
2677
2112
|
];
|
|
2678
2113
|
if (clientTools.length > 0) {
|
|
2679
2114
|
payload.clientTools = clientTools;
|
|
2680
2115
|
}
|
|
2681
2116
|
|
|
2682
2117
|
// Add context from providers + opt-in mention context.
|
|
2683
|
-
const contextAggregate = await this
|
|
2118
|
+
const contextAggregate = await this.#buildContextAggregate(messages);
|
|
2684
2119
|
if (contextAggregate) payload.context = contextAggregate;
|
|
2685
2120
|
|
|
2686
2121
|
return payload;
|
|
2687
2122
|
}
|
|
2688
2123
|
|
|
2689
|
-
|
|
2124
|
+
async #buildPayload(
|
|
2690
2125
|
messages: AgentWidgetMessage[],
|
|
2691
2126
|
composerOptions?: ComposerOptionsPayload
|
|
2692
2127
|
): Promise<AgentWidgetRequestPayload> {
|
|
@@ -2699,7 +2134,7 @@ export class AgentWidgetClient {
|
|
|
2699
2134
|
(message) => (hasValidContent(message) ? toPayloadMessage(message) : null)
|
|
2700
2135
|
);
|
|
2701
2136
|
|
|
2702
|
-
const routed = this
|
|
2137
|
+
const routed = this.#routing();
|
|
2703
2138
|
const payload: AgentWidgetRequestPayload = {
|
|
2704
2139
|
messages: normalizedMessages,
|
|
2705
2140
|
...(routed.agentId
|
|
@@ -2724,24 +2159,24 @@ export class AgentWidgetClient {
|
|
|
2724
2159
|
// (flow-dispatch path).
|
|
2725
2160
|
const clientTools = [
|
|
2726
2161
|
...builtInClientToolsForDispatch(this.config),
|
|
2727
|
-
...((await (await this
|
|
2162
|
+
...((await (await this.#getWebMcpBridge())?.snapshotForDispatch()) ?? []),
|
|
2728
2163
|
];
|
|
2729
2164
|
if (clientTools.length > 0) {
|
|
2730
2165
|
payload.clientTools = clientTools;
|
|
2731
2166
|
}
|
|
2732
2167
|
|
|
2733
|
-
const contextAggregate = await this
|
|
2168
|
+
const contextAggregate = await this.#buildContextAggregate(messages);
|
|
2734
2169
|
if (contextAggregate) payload.context = contextAggregate;
|
|
2735
2170
|
|
|
2736
2171
|
// Its own field, never folded into `context`: a value in generic context
|
|
2737
2172
|
// does not change inference, and each transport must decide explicitly.
|
|
2738
2173
|
// Set before the middleware runs so hosts can read, rewrite, or drop it.
|
|
2739
|
-
const composer = this
|
|
2174
|
+
const composer = this.#normalizeComposerOptions(composerOptions);
|
|
2740
2175
|
if (composer) payload.composerOptions = composer;
|
|
2741
2176
|
|
|
2742
|
-
if (this
|
|
2177
|
+
if (this.#requestMiddleware) {
|
|
2743
2178
|
try {
|
|
2744
|
-
const result = await this
|
|
2179
|
+
const result = await this.#requestMiddleware({
|
|
2745
2180
|
payload: { ...payload },
|
|
2746
2181
|
config: this.config
|
|
2747
2182
|
});
|
|
@@ -2774,1773 +2209,24 @@ export class AgentWidgetClient {
|
|
|
2774
2209
|
return payload;
|
|
2775
2210
|
}
|
|
2776
2211
|
|
|
2777
|
-
|
|
2778
|
-
* Handle custom SSE event parsing via parseSSEEvent callback
|
|
2779
|
-
* Returns true if event was handled, false otherwise
|
|
2780
|
-
*/
|
|
2781
|
-
private async handleCustomSSEEvent(
|
|
2782
|
-
payload: unknown,
|
|
2783
|
-
onEvent: SSEHandler,
|
|
2784
|
-
assistantMessageRef: { current: AgentWidgetMessage | null },
|
|
2785
|
-
emitMessage: (msg: AgentWidgetMessage) => void,
|
|
2786
|
-
nextSequence: () => number,
|
|
2787
|
-
partIdState: { current: string | null }
|
|
2788
|
-
): Promise<boolean> {
|
|
2789
|
-
if (!this.parseSSEEvent) return false;
|
|
2790
|
-
|
|
2791
|
-
try {
|
|
2792
|
-
const result = await this.parseSSEEvent(payload);
|
|
2793
|
-
if (result === null) return false; // Event should be ignored
|
|
2794
|
-
|
|
2795
|
-
const createNewAssistant = (partId?: string): AgentWidgetMessage => {
|
|
2796
|
-
const msg: AgentWidgetMessage = {
|
|
2797
|
-
id: `assistant-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
|
2798
|
-
role: "assistant",
|
|
2799
|
-
content: "",
|
|
2800
|
-
createdAt: new Date().toISOString(),
|
|
2801
|
-
streaming: true,
|
|
2802
|
-
variant: "assistant",
|
|
2803
|
-
sequence: nextSequence(),
|
|
2804
|
-
...(partId !== undefined && { partId })
|
|
2805
|
-
};
|
|
2806
|
-
assistantMessageRef.current = msg;
|
|
2807
|
-
emitMessage(msg);
|
|
2808
|
-
return msg;
|
|
2809
|
-
};
|
|
2810
|
-
|
|
2811
|
-
const ensureAssistant = (partId?: string) => {
|
|
2812
|
-
if (assistantMessageRef.current) return assistantMessageRef.current;
|
|
2813
|
-
return createNewAssistant(partId);
|
|
2814
|
-
};
|
|
2815
|
-
|
|
2816
|
-
if (result.text !== undefined) {
|
|
2817
|
-
// partId-based message segmentation: when partId changes, seal current
|
|
2818
|
-
// message and start a new one for chronological tool/text interleaving
|
|
2819
|
-
if (result.partId !== undefined && partIdState.current !== null && result.partId !== partIdState.current) {
|
|
2820
|
-
// Seal the current assistant message
|
|
2821
|
-
if (assistantMessageRef.current) {
|
|
2822
|
-
assistantMessageRef.current.streaming = false;
|
|
2823
|
-
emitMessage(assistantMessageRef.current);
|
|
2824
|
-
}
|
|
2825
|
-
// Create a new assistant message for the new text segment
|
|
2826
|
-
createNewAssistant(result.partId);
|
|
2827
|
-
}
|
|
2828
|
-
|
|
2829
|
-
// Update partId tracking (only when partId is provided: backward compatible)
|
|
2830
|
-
if (result.partId !== undefined) {
|
|
2831
|
-
partIdState.current = result.partId;
|
|
2832
|
-
}
|
|
2833
|
-
|
|
2834
|
-
const assistant = ensureAssistant(result.partId);
|
|
2835
|
-
// Tag the message with partId if present and not already set
|
|
2836
|
-
if (result.partId !== undefined && !assistant.partId) {
|
|
2837
|
-
assistant.partId = result.partId;
|
|
2838
|
-
}
|
|
2839
|
-
assistant.content += result.text;
|
|
2840
|
-
emitMessage(assistant);
|
|
2841
|
-
}
|
|
2842
|
-
|
|
2843
|
-
if (result.done) {
|
|
2844
|
-
if (assistantMessageRef.current) {
|
|
2845
|
-
assistantMessageRef.current.streaming = false;
|
|
2846
|
-
emitMessage(assistantMessageRef.current);
|
|
2847
|
-
}
|
|
2848
|
-
partIdState.current = null;
|
|
2849
|
-
onEvent({ type: "status", status: "idle" });
|
|
2850
|
-
}
|
|
2851
|
-
|
|
2852
|
-
if (result.error) {
|
|
2853
|
-
partIdState.current = null;
|
|
2854
|
-
onEvent({
|
|
2855
|
-
type: "error",
|
|
2856
|
-
error: new Error(result.error)
|
|
2857
|
-
});
|
|
2858
|
-
}
|
|
2859
|
-
|
|
2860
|
-
return true; // Event was handled
|
|
2861
|
-
} catch (error) {
|
|
2862
|
-
if (typeof console !== "undefined") {
|
|
2863
|
-
// eslint-disable-next-line no-console
|
|
2864
|
-
console.error("[AgentWidget] parseSSEEvent error:", error);
|
|
2865
|
-
}
|
|
2866
|
-
return false;
|
|
2867
|
-
}
|
|
2868
|
-
}
|
|
2869
|
-
|
|
2870
|
-
private async streamResponse(
|
|
2212
|
+
async #streamResponse(
|
|
2871
2213
|
body: ReadableStream<Uint8Array>,
|
|
2872
2214
|
onEvent: SSEHandler,
|
|
2873
2215
|
assistantMessageId?: string,
|
|
2874
|
-
// Durable reconnect: seed the assistant accumulator with the text already
|
|
2875
|
-
// shown before the drop, so replayed post-cursor deltas APPEND to it
|
|
2876
|
-
// instead of a fresh stream clobbering it (the replay carries only
|
|
2877
|
-
// `seq > after`, i.e. the new deltas, not the full text).
|
|
2878
2216
|
seedContent?: string
|
|
2879
2217
|
) {
|
|
2880
|
-
const
|
|
2881
|
-
|
|
2882
|
-
let buffer = "";
|
|
2883
|
-
|
|
2884
|
-
const baseSequence = Date.now();
|
|
2885
|
-
let sequenceCounter = 0;
|
|
2886
|
-
const nextSequence = () => baseSequence + sequenceCounter++;
|
|
2887
|
-
|
|
2888
|
-
const cloneMessage = (msg: AgentWidgetMessage): AgentWidgetMessage => {
|
|
2889
|
-
const reasoning = msg.reasoning
|
|
2890
|
-
? {
|
|
2891
|
-
...msg.reasoning,
|
|
2892
|
-
chunks: [...msg.reasoning.chunks]
|
|
2893
|
-
}
|
|
2894
|
-
: undefined;
|
|
2895
|
-
const toolCall = msg.toolCall
|
|
2896
|
-
? {
|
|
2897
|
-
...msg.toolCall,
|
|
2898
|
-
chunks: msg.toolCall.chunks ? [...msg.toolCall.chunks] : undefined
|
|
2899
|
-
}
|
|
2900
|
-
: undefined;
|
|
2901
|
-
const tools = msg.tools
|
|
2902
|
-
? msg.tools.map((tool) => ({
|
|
2903
|
-
...tool,
|
|
2904
|
-
chunks: tool.chunks ? [...tool.chunks] : undefined
|
|
2905
|
-
}))
|
|
2906
|
-
: undefined;
|
|
2907
|
-
|
|
2908
|
-
return {
|
|
2909
|
-
...msg,
|
|
2910
|
-
reasoning,
|
|
2911
|
-
toolCall,
|
|
2912
|
-
tools
|
|
2913
|
-
};
|
|
2914
|
-
};
|
|
2915
|
-
|
|
2916
|
-
const shouldEmitMessage = (msg: AgentWidgetMessage): boolean => {
|
|
2917
|
-
if (msg.role !== "assistant" || msg.variant) return true;
|
|
2918
|
-
|
|
2919
|
-
const hasContentParts =
|
|
2920
|
-
Array.isArray(msg.contentParts) && msg.contentParts.length > 0;
|
|
2921
|
-
const hasRawContent =
|
|
2922
|
-
typeof msg.rawContent === "string" && msg.rawContent.trim() !== "";
|
|
2923
|
-
const hasVisibleText =
|
|
2924
|
-
typeof msg.content === "string" && msg.content.trim() !== "";
|
|
2925
|
-
|
|
2926
|
-
// Do not surface assistant text bubbles that only contain whitespace.
|
|
2927
|
-
// Some providers emit newline-only text parts around a leading tool call;
|
|
2928
|
-
// rendering those as normal messages creates an empty bubble above the
|
|
2929
|
-
// tool card. Keep media/component/stop-reason messages renderable.
|
|
2930
|
-
return hasVisibleText || hasContentParts || hasRawContent || Boolean(msg.stopReason);
|
|
2931
|
-
};
|
|
2932
|
-
|
|
2933
|
-
const emitMessage = (msg: AgentWidgetMessage) => {
|
|
2934
|
-
if (!shouldEmitMessage(msg)) return;
|
|
2935
|
-
onEvent({
|
|
2936
|
-
type: "message",
|
|
2937
|
-
message: cloneMessage(msg)
|
|
2938
|
-
});
|
|
2939
|
-
};
|
|
2940
|
-
|
|
2941
|
-
let assistantMessage: AgentWidgetMessage | null = null;
|
|
2942
|
-
// Tracks the most recently touched assistant text message for the
|
|
2943
|
-
// current agent turn so `turn_complete.stopReason` can attach
|
|
2944
|
-
// to the final visible text segment even after `assistantMessage`
|
|
2945
|
-
// has been finalized at a tool-call boundary within the turn.
|
|
2946
|
-
let lastAssistantInTurn: AgentWidgetMessage | null = null;
|
|
2947
|
-
// Reference to track assistant message for custom event handler
|
|
2948
|
-
const assistantMessageRef = { current: null as AgentWidgetMessage | null };
|
|
2949
|
-
// Segmentation state for the `parseSSEEvent` extensibility callback (the
|
|
2950
|
-
// consumer's own `partId` field) — independent of the wire.
|
|
2951
|
-
const customParsePartId = { current: null as string | null };
|
|
2952
|
-
// Unified text-channel block id (from `text_start`/`text_delta` `id`). Drives
|
|
2953
|
-
// bubble-id segmentation on the wire in place of the legacy `partId`:
|
|
2954
|
-
// a new block id means a new bubble, sealed at `text_complete`/tool boundaries.
|
|
2955
|
-
let currentTextBlockId: string | null = null;
|
|
2956
|
-
// Raw text accumulated for the open text block, on both the flow and agent
|
|
2957
|
-
// paths — lets a whitespace-only flow block resolve without a stray bubble,
|
|
2958
|
-
// and gives the structured-content parser the whole block on either path.
|
|
2959
|
-
let pendingTextRaw = "";
|
|
2960
|
-
// Nested flow-as-tool attribution (PR #4602): a text/reasoning block whose
|
|
2961
|
-
// `parentToolCallId` matches a `tool_start.toolCallId` belongs to a flow
|
|
2962
|
-
// running as that tool. Keyed by the wire block id, these route the block's
|
|
2963
|
-
// deltas into a message tagged `agentMetadata.parentToolId` (the parent tool's
|
|
2964
|
-
// row) instead of the top-level assistant/reasoning channel.
|
|
2965
|
-
const nestedBlockParent = new Map<string, string>();
|
|
2966
|
-
const nestedBlockMessages = new Map<string, AgentWidgetMessage>();
|
|
2967
|
-
const nestedBlockRaw = new Map<string, string>();
|
|
2968
|
-
const reasoningMessages = new Map<string, AgentWidgetMessage>();
|
|
2969
|
-
const toolMessages = new Map<string, AgentWidgetMessage>();
|
|
2970
|
-
const baseAssistantId = assistantMessageId;
|
|
2971
|
-
let assistantIdConsumed = false;
|
|
2972
|
-
|
|
2973
|
-
const ensureAssistantMessage = () => {
|
|
2974
|
-
if (assistantMessage) return assistantMessage;
|
|
2975
|
-
let id: string;
|
|
2976
|
-
let initialContent = "";
|
|
2977
|
-
const segment = currentTextBlockId;
|
|
2978
|
-
if (!assistantIdConsumed && baseAssistantId) {
|
|
2979
|
-
id = baseAssistantId;
|
|
2980
|
-
assistantIdConsumed = true;
|
|
2981
|
-
// First (and only) time we reuse the caller-supplied id: this is the
|
|
2982
|
-
// bubble a durable reconnect resumes into, so continue its text.
|
|
2983
|
-
initialContent = seedContent ?? "";
|
|
2984
|
-
} else if (baseAssistantId && segment) {
|
|
2985
|
-
id = `${baseAssistantId}_${segment}`;
|
|
2986
|
-
} else {
|
|
2987
|
-
id = `assistant-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
2988
|
-
}
|
|
2989
|
-
assistantMessage = {
|
|
2990
|
-
id,
|
|
2991
|
-
role: "assistant",
|
|
2992
|
-
content: initialContent,
|
|
2993
|
-
createdAt: new Date().toISOString(),
|
|
2994
|
-
streaming: true,
|
|
2995
|
-
sequence: nextSequence()
|
|
2996
|
-
};
|
|
2997
|
-
emitMessage(assistantMessage);
|
|
2998
|
-
return assistantMessage;
|
|
2999
|
-
};
|
|
3000
|
-
|
|
3001
|
-
const ensureReasoningMessage = (reasoningId: string) => {
|
|
3002
|
-
const existing = reasoningMessages.get(reasoningId);
|
|
3003
|
-
if (existing) {
|
|
3004
|
-
return existing;
|
|
3005
|
-
}
|
|
3006
|
-
|
|
3007
|
-
const message: AgentWidgetMessage = {
|
|
3008
|
-
id: `reason-${reasoningId}`,
|
|
3009
|
-
role: "assistant",
|
|
3010
|
-
content: "",
|
|
3011
|
-
createdAt: new Date().toISOString(),
|
|
3012
|
-
streaming: true,
|
|
3013
|
-
variant: "reasoning",
|
|
3014
|
-
sequence: nextSequence(),
|
|
3015
|
-
reasoning: {
|
|
3016
|
-
id: reasoningId,
|
|
3017
|
-
status: "streaming",
|
|
3018
|
-
chunks: []
|
|
3019
|
-
}
|
|
3020
|
-
};
|
|
3021
|
-
|
|
3022
|
-
reasoningMessages.set(reasoningId, message);
|
|
3023
|
-
emitMessage(message);
|
|
3024
|
-
return message;
|
|
3025
|
-
};
|
|
3026
|
-
|
|
3027
|
-
// Track tool call IDs for artifact emit tools so we can suppress their UI
|
|
3028
|
-
const artifactToolCallIds = new Set<string>();
|
|
3029
|
-
// Track artifact block messages (reference card or inline block) so we can
|
|
3030
|
-
// update them on artifact_complete
|
|
3031
|
-
const artifactCardMessages = new Map<string, AgentWidgetMessage>();
|
|
3032
|
-
// Track artifact IDs that already have a reference card
|
|
3033
|
-
const artifactIdsWithCards = new Set<string>();
|
|
3034
|
-
// Accumulate artifact markdown content (and component props) for embedding
|
|
3035
|
-
// in block props on complete. `props` accumulates across `artifact_update`
|
|
3036
|
-
// events so an inline component block hydrates with its real props after a
|
|
3037
|
-
// refresh (the session artifact registry, which also tracks them live, is
|
|
3038
|
-
// not persisted).
|
|
3039
|
-
const artifactContent = new Map<
|
|
3040
|
-
string,
|
|
2218
|
+
const { streamResponse } = getClientStreamSync() ?? (await loadClientStream());
|
|
2219
|
+
return streamResponse(
|
|
3041
2220
|
{
|
|
3042
|
-
|
|
3043
|
-
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
}
|
|
3047
|
-
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
|
|
3051
|
-
|
|
3052
|
-
};
|
|
3053
|
-
|
|
3054
|
-
const ensureToolMessage = (toolId: string) => {
|
|
3055
|
-
const existing = toolMessages.get(toolId);
|
|
3056
|
-
if (existing) {
|
|
3057
|
-
return existing;
|
|
3058
|
-
}
|
|
3059
|
-
|
|
3060
|
-
const message: AgentWidgetMessage = {
|
|
3061
|
-
id: `tool-${toolId}`,
|
|
3062
|
-
role: "assistant",
|
|
3063
|
-
content: "",
|
|
3064
|
-
createdAt: new Date().toISOString(),
|
|
3065
|
-
streaming: true,
|
|
3066
|
-
variant: "tool",
|
|
3067
|
-
sequence: nextSequence(),
|
|
3068
|
-
toolCall: {
|
|
3069
|
-
id: toolId,
|
|
3070
|
-
status: "pending"
|
|
3071
|
-
}
|
|
3072
|
-
};
|
|
3073
|
-
|
|
3074
|
-
toolMessages.set(toolId, message);
|
|
3075
|
-
emitMessage(message);
|
|
3076
|
-
return message;
|
|
3077
|
-
};
|
|
3078
|
-
|
|
3079
|
-
const resolveTimestamp = (value: unknown) => {
|
|
3080
|
-
if (typeof value === "number" && Number.isFinite(value)) {
|
|
3081
|
-
return value;
|
|
3082
|
-
}
|
|
3083
|
-
if (typeof value === "string") {
|
|
3084
|
-
const parsed = Number(value);
|
|
3085
|
-
if (!Number.isNaN(parsed) && Number.isFinite(parsed)) {
|
|
3086
|
-
return parsed;
|
|
3087
|
-
}
|
|
3088
|
-
const dateParsed = Date.parse(value);
|
|
3089
|
-
if (!Number.isNaN(dateParsed)) {
|
|
3090
|
-
return dateParsed;
|
|
3091
|
-
}
|
|
3092
|
-
}
|
|
3093
|
-
return Date.now();
|
|
3094
|
-
};
|
|
3095
|
-
|
|
3096
|
-
const ensureStringContent = (value: unknown): string => {
|
|
3097
|
-
if (typeof value === "string") {
|
|
3098
|
-
return value;
|
|
3099
|
-
}
|
|
3100
|
-
if (value === null || value === undefined) {
|
|
3101
|
-
return "";
|
|
3102
|
-
}
|
|
3103
|
-
// Convert objects/arrays to JSON string
|
|
3104
|
-
try {
|
|
3105
|
-
return JSON.stringify(value);
|
|
3106
|
-
} catch {
|
|
3107
|
-
return String(value);
|
|
3108
|
-
}
|
|
3109
|
-
};
|
|
3110
|
-
|
|
3111
|
-
// Maintain stateful stream parsers per message for incremental parsing
|
|
3112
|
-
const streamParsers = new Map<string, AgentWidgetStreamParser>();
|
|
3113
|
-
// Track accumulated raw content for structured formats (JSON, XML, etc.)
|
|
3114
|
-
const rawContentBuffers = new Map<string, string>();
|
|
3115
|
-
/** Reconcile a sealed text block with its authoritative structured response. */
|
|
3116
|
-
const reconcileSealedAssistantWithFinalResponse = (
|
|
3117
|
-
msg: AgentWidgetMessage,
|
|
3118
|
-
finalContent: unknown
|
|
3119
|
-
) => {
|
|
3120
|
-
const finalString = ensureStringContent(finalContent);
|
|
3121
|
-
const rawBuffer = rawContentBuffers.get(msg.id);
|
|
3122
|
-
const contentToProcess = preferFinalStructuredContent(rawBuffer, finalString);
|
|
3123
|
-
msg.rawContent = contentToProcess;
|
|
3124
|
-
const parser = streamParsers.get(msg.id);
|
|
3125
|
-
|
|
3126
|
-
const mergeIfBetter = (mergedDisplay: string) => {
|
|
3127
|
-
const cur = msg.content ?? "";
|
|
3128
|
-
if (mergedDisplay.trim() === "") return;
|
|
3129
|
-
// Only replace when empty, or when the stream left a strict prefix of the
|
|
3130
|
-
// authoritative final (truncation). Do not use length alone: multi-segment
|
|
3131
|
-
// flows can have a short last bubble whose content is not a prefix of the
|
|
3132
|
-
// full step response.
|
|
3133
|
-
if (
|
|
3134
|
-
cur.trim().length === 0 ||
|
|
3135
|
-
mergedDisplay.startsWith(cur) ||
|
|
3136
|
-
mergedDisplay.trimStart().startsWith(cur.trim())
|
|
3137
|
-
) {
|
|
3138
|
-
msg.content = mergedDisplay;
|
|
3139
|
-
}
|
|
3140
|
-
};
|
|
3141
|
-
|
|
3142
|
-
const finalizeCleanup = () => {
|
|
3143
|
-
if (parser) {
|
|
3144
|
-
const closeResult = parser.close?.();
|
|
3145
|
-
if (closeResult instanceof Promise) closeResult.catch(() => {});
|
|
3146
|
-
}
|
|
3147
|
-
streamParsers.delete(msg.id);
|
|
3148
|
-
rawContentBuffers.delete(msg.id);
|
|
3149
|
-
msg.streaming = false;
|
|
3150
|
-
emitMessage(msg);
|
|
3151
|
-
};
|
|
3152
|
-
|
|
3153
|
-
if (!parser) {
|
|
3154
|
-
mergeIfBetter(finalString);
|
|
3155
|
-
finalizeCleanup();
|
|
3156
|
-
return;
|
|
3157
|
-
}
|
|
3158
|
-
|
|
3159
|
-
// Prefer JSON fast path when the final payload is JSON-shaped
|
|
3160
|
-
const extractedFromJson = extractTextFromJson(contentToProcess);
|
|
3161
|
-
if (extractedFromJson !== null && extractedFromJson.trim() !== "") {
|
|
3162
|
-
mergeIfBetter(extractedFromJson);
|
|
3163
|
-
finalizeCleanup();
|
|
3164
|
-
return;
|
|
3165
|
-
}
|
|
3166
|
-
|
|
3167
|
-
const bestDisplayText = (
|
|
3168
|
-
result: AgentWidgetStreamParserResult | string | null
|
|
3169
|
-
): string => {
|
|
3170
|
-
const text =
|
|
3171
|
-
typeof result === "string" ? result : result?.text ?? null;
|
|
3172
|
-
if (text !== null && text.trim() !== "") return text;
|
|
3173
|
-
const extracted = parser.getExtractedText();
|
|
3174
|
-
if (extracted !== null && extracted.trim() !== "") return extracted;
|
|
3175
|
-
return finalString;
|
|
3176
|
-
};
|
|
3177
|
-
|
|
3178
|
-
let parsedResult: ReturnType<typeof parser.processChunk>;
|
|
3179
|
-
try {
|
|
3180
|
-
parsedResult = parser.processChunk(contentToProcess);
|
|
3181
|
-
} catch {
|
|
3182
|
-
mergeIfBetter(finalString);
|
|
3183
|
-
finalizeCleanup();
|
|
3184
|
-
return;
|
|
3185
|
-
}
|
|
3186
|
-
|
|
3187
|
-
if (parsedResult instanceof Promise) {
|
|
3188
|
-
parsedResult
|
|
3189
|
-
.then((result) => {
|
|
3190
|
-
mergeIfBetter(bestDisplayText(result));
|
|
3191
|
-
finalizeCleanup();
|
|
3192
|
-
})
|
|
3193
|
-
.catch(() => {
|
|
3194
|
-
mergeIfBetter(finalString);
|
|
3195
|
-
finalizeCleanup();
|
|
3196
|
-
});
|
|
3197
|
-
return;
|
|
3198
|
-
}
|
|
3199
|
-
|
|
3200
|
-
mergeIfBetter(bestDisplayText(parsedResult));
|
|
3201
|
-
finalizeCleanup();
|
|
3202
|
-
};
|
|
3203
|
-
|
|
3204
|
-
// === Unified text channel ===
|
|
3205
|
-
// Prompt-step and agent text both stream as `text_delta` blocks (segmented by
|
|
3206
|
-
// `text_start`/`text_complete`) and can be structured JSON, so each block runs
|
|
3207
|
-
// through the per-bubble structured-content parser. This is the legacy
|
|
3208
|
-
// step_delta parser core, re-keyed from `partId` to the wire block-id bubble.
|
|
3209
|
-
// The caller materializes the bubble lazily (whitespace-only blocks around
|
|
3210
|
-
// tool boundaries never leave a stray bubble) and `step_complete.result.response`
|
|
3211
|
-
// reconciles the authoritative final.
|
|
3212
|
-
let lastSealedFlowBubble: AgentWidgetMessage | null = null;
|
|
3213
|
-
|
|
3214
|
-
// Stream one accumulated chunk of block text through the parser, setting
|
|
3215
|
-
// display `content` (extracted) + `rawContent` (raw) and emitting. Mirrors the
|
|
3216
|
-
// legacy step_delta chunk path; plain text bypasses the structured parser.
|
|
3217
|
-
const applyTextChunk = (
|
|
3218
|
-
assistant: AgentWidgetMessage,
|
|
3219
|
-
accumulatedRaw: string,
|
|
3220
|
-
chunk: string,
|
|
3221
|
-
chunkSeq: number | undefined
|
|
3222
|
-
) => {
|
|
3223
|
-
assistant.rawContent = accumulatedRaw;
|
|
3224
|
-
if (!streamParsers.has(assistant.id)) {
|
|
3225
|
-
streamParsers.set(assistant.id, this.createStreamParser());
|
|
3226
|
-
}
|
|
3227
|
-
const parser = streamParsers.get(assistant.id)!;
|
|
3228
|
-
const looksLikeJson =
|
|
3229
|
-
accumulatedRaw.trim().startsWith("{") || accumulatedRaw.trim().startsWith("[");
|
|
3230
|
-
if (looksLikeJson) {
|
|
3231
|
-
rawContentBuffers.set(assistant.id, accumulatedRaw);
|
|
3232
|
-
}
|
|
3233
|
-
const isPlainTextParser = (parser as any).__isPlainTextParser === true;
|
|
3234
|
-
if (isPlainTextParser) {
|
|
3235
|
-
assistant.content =
|
|
3236
|
-
chunkSeq !== undefined ? accumulatedRaw : assistant.content + chunk;
|
|
3237
|
-
rawContentBuffers.delete(assistant.id);
|
|
3238
|
-
streamParsers.delete(assistant.id);
|
|
3239
|
-
assistant.rawContent = undefined;
|
|
3240
|
-
emitMessage(assistant);
|
|
3241
|
-
return;
|
|
3242
|
-
}
|
|
3243
|
-
const parsedResult = parser.processChunk(accumulatedRaw);
|
|
3244
|
-
if (parsedResult instanceof Promise) {
|
|
3245
|
-
parsedResult
|
|
3246
|
-
.then((result) => {
|
|
3247
|
-
const text = typeof result === "string" ? result : result?.text ?? null;
|
|
3248
|
-
if (text !== null && text.trim() !== "") {
|
|
3249
|
-
assistant.content = text;
|
|
3250
|
-
emitMessage(assistant);
|
|
3251
|
-
} else if (!looksLikeJson && !accumulatedRaw.trim().startsWith("<")) {
|
|
3252
|
-
assistant.content =
|
|
3253
|
-
chunkSeq !== undefined ? accumulatedRaw : assistant.content + chunk;
|
|
3254
|
-
rawContentBuffers.delete(assistant.id);
|
|
3255
|
-
streamParsers.delete(assistant.id);
|
|
3256
|
-
assistant.rawContent = undefined;
|
|
3257
|
-
emitMessage(assistant);
|
|
3258
|
-
}
|
|
3259
|
-
})
|
|
3260
|
-
.catch(() => {
|
|
3261
|
-
assistant.content =
|
|
3262
|
-
chunkSeq !== undefined ? accumulatedRaw : assistant.content + chunk;
|
|
3263
|
-
rawContentBuffers.delete(assistant.id);
|
|
3264
|
-
streamParsers.delete(assistant.id);
|
|
3265
|
-
assistant.rawContent = undefined;
|
|
3266
|
-
emitMessage(assistant);
|
|
3267
|
-
});
|
|
3268
|
-
} else {
|
|
3269
|
-
const text =
|
|
3270
|
-
typeof parsedResult === "string" ? parsedResult : parsedResult?.text ?? null;
|
|
3271
|
-
if (text !== null && text.trim() !== "") {
|
|
3272
|
-
assistant.content = text;
|
|
3273
|
-
emitMessage(assistant);
|
|
3274
|
-
} else if (!looksLikeJson && !accumulatedRaw.trim().startsWith("<")) {
|
|
3275
|
-
assistant.content =
|
|
3276
|
-
chunkSeq !== undefined ? accumulatedRaw : assistant.content + chunk;
|
|
3277
|
-
rawContentBuffers.delete(assistant.id);
|
|
3278
|
-
streamParsers.delete(assistant.id);
|
|
3279
|
-
assistant.rawContent = undefined;
|
|
3280
|
-
emitMessage(assistant);
|
|
3281
|
-
}
|
|
3282
|
-
}
|
|
3283
|
-
};
|
|
3284
|
-
|
|
3285
|
-
// Seal a flow text block at `text_complete`: run final structured extraction
|
|
3286
|
-
// off the accumulated raw buffer (U2: `text_complete.text` mirrors that raw
|
|
3287
|
-
// buffer, so we never double-count), then finalize the bubble. The structured
|
|
3288
|
-
// `step_complete.result.response` reconciles afterward.
|
|
3289
|
-
const finalizeFlowTextBlock = (
|
|
3290
|
-
assistant: AgentWidgetMessage,
|
|
3291
|
-
finalContent?: unknown
|
|
3292
|
-
) => {
|
|
3293
|
-
const effectiveFinal =
|
|
3294
|
-
finalContent !== undefined && finalContent !== null
|
|
3295
|
-
? finalContent
|
|
3296
|
-
: assistant.content;
|
|
3297
|
-
if (
|
|
3298
|
-
effectiveFinal === undefined ||
|
|
3299
|
-
effectiveFinal === null ||
|
|
3300
|
-
effectiveFinal === ""
|
|
3301
|
-
) {
|
|
3302
|
-
assistant.streaming = false;
|
|
3303
|
-
emitMessage(assistant);
|
|
3304
|
-
return;
|
|
3305
|
-
}
|
|
3306
|
-
const rawBuffer = rawContentBuffers.get(assistant.id);
|
|
3307
|
-
const contentToProcess = rawBuffer ?? ensureStringContent(effectiveFinal);
|
|
3308
|
-
assistant.rawContent = contentToProcess;
|
|
3309
|
-
const parser = streamParsers.get(assistant.id);
|
|
3310
|
-
let extractedText: string | null = null;
|
|
3311
|
-
let asyncPending = false;
|
|
3312
|
-
if (parser) {
|
|
3313
|
-
extractedText = parser.getExtractedText();
|
|
3314
|
-
if (extractedText === null) {
|
|
3315
|
-
extractedText = extractTextFromJson(contentToProcess);
|
|
3316
|
-
}
|
|
3317
|
-
if (extractedText === null) {
|
|
3318
|
-
const parsedResult = parser.processChunk(contentToProcess);
|
|
3319
|
-
if (parsedResult instanceof Promise) {
|
|
3320
|
-
asyncPending = true;
|
|
3321
|
-
parsedResult
|
|
3322
|
-
.then((result) => {
|
|
3323
|
-
const text =
|
|
3324
|
-
typeof result === "string" ? result : result?.text ?? null;
|
|
3325
|
-
if (text !== null) {
|
|
3326
|
-
assistant.content = text;
|
|
3327
|
-
assistant.streaming = false;
|
|
3328
|
-
streamParsers.delete(assistant.id);
|
|
3329
|
-
rawContentBuffers.delete(assistant.id);
|
|
3330
|
-
emitMessage(assistant);
|
|
3331
|
-
}
|
|
3332
|
-
})
|
|
3333
|
-
.catch(() => {});
|
|
3334
|
-
} else {
|
|
3335
|
-
extractedText =
|
|
3336
|
-
typeof parsedResult === "string"
|
|
3337
|
-
? parsedResult
|
|
3338
|
-
: parsedResult?.text ?? null;
|
|
3339
|
-
}
|
|
3340
|
-
}
|
|
3341
|
-
}
|
|
3342
|
-
if (!asyncPending) {
|
|
3343
|
-
if (extractedText !== null && extractedText.trim() !== "") {
|
|
3344
|
-
assistant.content = extractedText;
|
|
3345
|
-
} else if (!rawContentBuffers.has(assistant.id)) {
|
|
3346
|
-
assistant.content = ensureStringContent(effectiveFinal);
|
|
3347
|
-
}
|
|
3348
|
-
const parserToClose = streamParsers.get(assistant.id);
|
|
3349
|
-
if (parserToClose) {
|
|
3350
|
-
const closeResult = parserToClose.close?.();
|
|
3351
|
-
if (closeResult instanceof Promise) closeResult.catch(() => {});
|
|
3352
|
-
streamParsers.delete(assistant.id);
|
|
3353
|
-
}
|
|
3354
|
-
rawContentBuffers.delete(assistant.id);
|
|
3355
|
-
assistant.streaming = false;
|
|
3356
|
-
emitMessage(assistant);
|
|
3357
|
-
}
|
|
3358
|
-
};
|
|
3359
|
-
|
|
3360
|
-
// Materialize (lazily) the message for a nested flow-as-tool block, tagged
|
|
3361
|
-
// with the parent tool-call id so the UI renders it in the parent tool's row.
|
|
3362
|
-
const ensureNestedBlockMessage = (
|
|
3363
|
-
blockId: string,
|
|
3364
|
-
parentToolCallId: string,
|
|
3365
|
-
variant?: "reasoning"
|
|
3366
|
-
): AgentWidgetMessage => {
|
|
3367
|
-
const existing = nestedBlockMessages.get(blockId);
|
|
3368
|
-
if (existing) return existing;
|
|
3369
|
-
const message: AgentWidgetMessage = {
|
|
3370
|
-
id: `nested-${parentToolCallId}-${blockId}`,
|
|
3371
|
-
role: "assistant",
|
|
3372
|
-
content: "",
|
|
3373
|
-
createdAt: new Date().toISOString(),
|
|
3374
|
-
streaming: true,
|
|
3375
|
-
sequence: nextSequence(),
|
|
3376
|
-
...(variant ? { variant } : {}),
|
|
3377
|
-
...(variant === "reasoning"
|
|
3378
|
-
? { reasoning: { id: blockId, status: "streaming", chunks: [] } }
|
|
3379
|
-
: {}),
|
|
3380
|
-
agentMetadata: { parentToolId: parentToolCallId },
|
|
3381
|
-
};
|
|
3382
|
-
nestedBlockMessages.set(blockId, message);
|
|
3383
|
-
emitMessage(message);
|
|
3384
|
-
return message;
|
|
3385
|
-
};
|
|
3386
|
-
|
|
3387
|
-
// Ready queue of parsed wire frames awaiting a drain. The API streams the
|
|
3388
|
-
// unified wire vocabulary; each frame is parsed in the SSE loop
|
|
3389
|
-
// below and rendered directly by the handler (no translation bridge), then
|
|
3390
|
-
// pushed here. The wire stream is a single, in-order SSE connection, so
|
|
3391
|
-
// frames drain straight through with no reordering.
|
|
3392
|
-
const eventQueue: RuntypeExecutionStreamEvent[] = [];
|
|
3393
|
-
// Declared here so later closures can reference it; assigned after all
|
|
3394
|
-
// handler-scoped variables are initialised (before the SSE loop).
|
|
3395
|
-
let drainReadyQueue: () => void;
|
|
3396
|
-
// Per-stream media-block buffer: the media triad
|
|
3397
|
-
// (media_start/media_delta/media_complete) is reassembled here into a single
|
|
3398
|
-
// synthetic message at media_complete, keyed by the block id.
|
|
3399
|
-
const mediaBuffers = new Map<
|
|
3400
|
-
string,
|
|
3401
|
-
{ mediaType?: string; toolCallId?: string; parts: string[] }
|
|
3402
|
-
>();
|
|
3403
|
-
// Tracks the last iteration surfaced as a per-iteration message boundary, so
|
|
3404
|
-
// `turn_start` advancing the iteration rotates the bubble in 'separate' mode.
|
|
3405
|
-
let lastIterationSeen = 0;
|
|
3406
|
-
// Execution kind, resolved from the leading `execution_start` frame. Drives
|
|
3407
|
-
// the agent-vs-flow branches that the single wire vocabulary collapses.
|
|
3408
|
-
let executionKind: "agent" | "flow" = "agent";
|
|
3409
|
-
// Whether `executionKind` was set authoritatively by an `execution_start`
|
|
3410
|
-
// frame. Continuation streams (e.g. a tool-driven `/resume`) do NOT re-emit
|
|
3411
|
-
// `execution_start`, so a fresh `streamResponse` for the continuation starts
|
|
3412
|
-
// with the default `"agent"`. For a flow that mis-routes the final
|
|
3413
|
-
// prompt-step finalization and duplicates the last message (the streamed
|
|
3414
|
-
// text block is sealed, then `step_complete.result.response` re-renders it as
|
|
3415
|
-
// a second bubble). When `execution_start` is absent we recover the flow kind
|
|
3416
|
-
// from the first flow `step_*` frame below.
|
|
3417
|
-
let executionKindResolved = false;
|
|
3418
|
-
// Open turn id (from `turn_start`). Unified text/reasoning deltas carry their
|
|
3419
|
-
// own block id, not the turn id, so the turn id is threaded onto agentMetadata
|
|
3420
|
-
// from here.
|
|
3421
|
-
let openTurnId: string | null = null;
|
|
3422
|
-
// Agent execution state tracking
|
|
3423
|
-
let agentExecution: { executionId: string; agentId: string; agentName: string } | null = null;
|
|
3424
|
-
const iterationDisplay = this.config.iterationDisplay ?? 'separate';
|
|
3425
|
-
|
|
3426
|
-
// Drains the queued native events through the main event handler.
|
|
3427
|
-
// Also invoked after the SSE loop exits so any events queued at
|
|
3428
|
-
// end-of-stream are processed.
|
|
3429
|
-
drainReadyQueue = () => {
|
|
3430
|
-
for (let i = 0; i < eventQueue.length; i++) {
|
|
3431
|
-
const payload = eventQueue[i];
|
|
3432
|
-
|
|
3433
|
-
// Recover the execution kind on continuation streams that omit
|
|
3434
|
-
// `execution_start` (e.g. a tool-driven `/resume`). Flow `step_*` frames
|
|
3435
|
-
// carry a `stepType`; agent loops never do (they use `turn_*`). Without
|
|
3436
|
-
// this, the continuation defaults to `"agent"` and a flow's final
|
|
3437
|
-
// prompt-step finalization is duplicated. We only infer when no
|
|
3438
|
-
// `execution_start` resolved the kind, so an explicit `agent` is never
|
|
3439
|
-
// overridden.
|
|
3440
|
-
if (
|
|
3441
|
-
!executionKindResolved &&
|
|
3442
|
-
executionKind !== "flow" &&
|
|
3443
|
-
typeof (payload as { stepType?: unknown }).stepType === "string"
|
|
3444
|
-
) {
|
|
3445
|
-
executionKind = "flow";
|
|
3446
|
-
}
|
|
3447
|
-
|
|
3448
|
-
if (payload.type === "reasoning_start") {
|
|
3449
|
-
// Nested flow-as-tool thinking (PR #4602): route to the parent tool's row.
|
|
3450
|
-
const rStartBlockId = typeof payload.id === "string" ? payload.id : null;
|
|
3451
|
-
const rStartParent =
|
|
3452
|
-
typeof payload.parentToolCallId === "string" && payload.parentToolCallId
|
|
3453
|
-
? payload.parentToolCallId
|
|
3454
|
-
: null;
|
|
3455
|
-
if (rStartBlockId && rStartParent) {
|
|
3456
|
-
nestedBlockParent.set(rStartBlockId, rStartParent);
|
|
3457
|
-
ensureNestedBlockMessage(rStartBlockId, rStartParent, "reasoning");
|
|
3458
|
-
continue;
|
|
3459
|
-
}
|
|
3460
|
-
const reasoningId = payload.id;
|
|
3461
|
-
const reasoningMessage = ensureReasoningMessage(reasoningId);
|
|
3462
|
-
reasoningMessage.reasoning = reasoningMessage.reasoning ?? {
|
|
3463
|
-
id: reasoningId,
|
|
3464
|
-
status: "streaming",
|
|
3465
|
-
chunks: []
|
|
3466
|
-
};
|
|
3467
|
-
reasoningMessage.reasoning.startedAt =
|
|
3468
|
-
reasoningMessage.reasoning.startedAt ??
|
|
3469
|
-
Date.now();
|
|
3470
|
-
reasoningMessage.reasoning.completedAt = undefined;
|
|
3471
|
-
reasoningMessage.reasoning.durationMs = undefined;
|
|
3472
|
-
if (payload.scope === "loop" || payload.scope === "turn") {
|
|
3473
|
-
reasoningMessage.reasoning.scope = payload.scope;
|
|
3474
|
-
}
|
|
3475
|
-
reasoningMessage.streaming = true;
|
|
3476
|
-
reasoningMessage.reasoning.status = "streaming";
|
|
3477
|
-
emitMessage(reasoningMessage);
|
|
3478
|
-
} else if (payload.type === "reasoning_delta") {
|
|
3479
|
-
// Nested flow-as-tool thinking: append to the parent-tool-row message.
|
|
3480
|
-
const rDeltaBlockId = typeof payload.id === "string" ? payload.id : null;
|
|
3481
|
-
if (
|
|
3482
|
-
rDeltaBlockId &&
|
|
3483
|
-
nestedBlockParent.has(rDeltaBlockId) &&
|
|
3484
|
-
nestedBlockMessages.has(rDeltaBlockId)
|
|
3485
|
-
) {
|
|
3486
|
-
const nested = nestedBlockMessages.get(rDeltaBlockId)!;
|
|
3487
|
-
const nestedChunk = payload.delta;
|
|
3488
|
-
if (nestedChunk && nested.reasoning) {
|
|
3489
|
-
nested.reasoning.chunks.push(String(nestedChunk));
|
|
3490
|
-
emitMessage(nested);
|
|
3491
|
-
}
|
|
3492
|
-
continue;
|
|
3493
|
-
}
|
|
3494
|
-
const reasoningId = payload.id;
|
|
3495
|
-
const reasoningMessage = ensureReasoningMessage(reasoningId);
|
|
3496
|
-
reasoningMessage.reasoning = reasoningMessage.reasoning ?? {
|
|
3497
|
-
id: reasoningId,
|
|
3498
|
-
status: "streaming",
|
|
3499
|
-
chunks: []
|
|
3500
|
-
};
|
|
3501
|
-
reasoningMessage.reasoning.startedAt =
|
|
3502
|
-
reasoningMessage.reasoning.startedAt ??
|
|
3503
|
-
Date.now();
|
|
3504
|
-
if (payload.delta) reasoningMessage.reasoning.chunks.push(payload.delta);
|
|
3505
|
-
reasoningMessage.reasoning.status = "streaming";
|
|
3506
|
-
reasoningMessage.streaming = true;
|
|
3507
|
-
emitMessage(reasoningMessage);
|
|
3508
|
-
} else if (payload.type === "reasoning_complete") {
|
|
3509
|
-
// Nested flow-as-tool thinking close: seal the parent-tool-row message.
|
|
3510
|
-
const rCompleteBlockId = typeof payload.id === "string" ? payload.id : null;
|
|
3511
|
-
if (
|
|
3512
|
-
rCompleteBlockId &&
|
|
3513
|
-
nestedBlockParent.has(rCompleteBlockId) &&
|
|
3514
|
-
nestedBlockMessages.has(rCompleteBlockId)
|
|
3515
|
-
) {
|
|
3516
|
-
const nested = nestedBlockMessages.get(rCompleteBlockId)!;
|
|
3517
|
-
if (nested.reasoning) {
|
|
3518
|
-
const nestedReflection =
|
|
3519
|
-
typeof payload.text === "string" ? payload.text : "";
|
|
3520
|
-
if (nestedReflection && nested.reasoning.chunks.length === 0) {
|
|
3521
|
-
nested.reasoning.chunks.push(nestedReflection);
|
|
3522
|
-
}
|
|
3523
|
-
nested.reasoning.status = "complete";
|
|
3524
|
-
nested.streaming = false;
|
|
3525
|
-
emitMessage(nested);
|
|
3526
|
-
}
|
|
3527
|
-
nestedBlockParent.delete(rCompleteBlockId);
|
|
3528
|
-
nestedBlockMessages.delete(rCompleteBlockId);
|
|
3529
|
-
continue;
|
|
3530
|
-
}
|
|
3531
|
-
const reasoningId = payload.id;
|
|
3532
|
-
// A close carrying text (or scope:"loop") is a cross-iteration
|
|
3533
|
-
// reflection fold (merged spec §4 E3): the API streams nothing for the
|
|
3534
|
-
// block, then delivers the whole reflection as `text` on the close.
|
|
3535
|
-
// Materialize a reasoning bubble even if no reasoning_start/delta opened
|
|
3536
|
-
// one, and adopt the close text when the block streamed no chunks (the
|
|
3537
|
-
// common reflection case, where reasoning_start opened an empty bubble).
|
|
3538
|
-
const reflectionText = typeof payload.text === "string" ? payload.text : "";
|
|
3539
|
-
if (!reasoningMessages.get(reasoningId) && (reflectionText || payload.scope === "loop")) {
|
|
3540
|
-
ensureReasoningMessage(reasoningId);
|
|
3541
|
-
}
|
|
3542
|
-
const reasoningMessage = reasoningMessages.get(reasoningId);
|
|
3543
|
-
if (reasoningMessage?.reasoning) {
|
|
3544
|
-
if (payload.scope === "loop" || payload.scope === "turn") {
|
|
3545
|
-
reasoningMessage.reasoning.scope = payload.scope;
|
|
3546
|
-
}
|
|
3547
|
-
if (reflectionText && reasoningMessage.reasoning.chunks.length === 0) {
|
|
3548
|
-
reasoningMessage.reasoning.chunks.push(reflectionText);
|
|
3549
|
-
}
|
|
3550
|
-
reasoningMessage.reasoning.status = "complete";
|
|
3551
|
-
reasoningMessage.reasoning.completedAt = Date.now();
|
|
3552
|
-
const start = reasoningMessage.reasoning.startedAt ?? Date.now();
|
|
3553
|
-
reasoningMessage.reasoning.durationMs = Math.max(
|
|
3554
|
-
0,
|
|
3555
|
-
(reasoningMessage.reasoning.completedAt ?? Date.now()) - start
|
|
3556
|
-
);
|
|
3557
|
-
reasoningMessage.streaming = false;
|
|
3558
|
-
|
|
3559
|
-
emitMessage(reasoningMessage);
|
|
3560
|
-
}
|
|
3561
|
-
} else if (payload.type === "tool_start") {
|
|
3562
|
-
// Unified tool family (agent + flow). Seal any open assistant bubble so
|
|
3563
|
-
// text→tool→text interleaves chronologically (the API also emits a
|
|
3564
|
-
// text_complete here, so this is usually a no-op — kept for safety).
|
|
3565
|
-
if (assistantMessage) {
|
|
3566
|
-
(assistantMessage as AgentWidgetMessage).streaming = false;
|
|
3567
|
-
emitMessage(assistantMessage as AgentWidgetMessage);
|
|
3568
|
-
assistantMessage = null;
|
|
3569
|
-
}
|
|
3570
|
-
// Unified denormalizes `iteration` onto tool frames too (merged spec §2).
|
|
3571
|
-
// Track it so media/reflection blocks — which carry no iteration of their
|
|
3572
|
-
// own — can be stamped with the enclosing iteration even on tool-only
|
|
3573
|
-
// turns that never emit a `turn_start`.
|
|
3574
|
-
if (typeof payload.iteration === "number") lastIterationSeen = payload.iteration;
|
|
3575
|
-
const toolId = payload.toolCallId;
|
|
3576
|
-
const toolName = payload.toolName;
|
|
3577
|
-
// Suppress tool UI for artifact emit tools: artifacts are handled via artifact_* events
|
|
3578
|
-
if (isArtifactEmitToolName(toolName)) {
|
|
3579
|
-
artifactToolCallIds.add(toolId);
|
|
3580
|
-
continue;
|
|
3581
|
-
}
|
|
3582
|
-
const toolMessage = ensureToolMessage(toolId);
|
|
3583
|
-
const tool = toolMessage.toolCall ?? {
|
|
3584
|
-
id: toolId,
|
|
3585
|
-
status: "pending"
|
|
3586
|
-
};
|
|
3587
|
-
tool.name = toolName ?? tool.name;
|
|
3588
|
-
tool.status = "running";
|
|
3589
|
-
tool.success = undefined;
|
|
3590
|
-
tool.error = undefined;
|
|
3591
|
-
tool.duration = undefined;
|
|
3592
|
-
if (payload.parameters !== undefined) {
|
|
3593
|
-
tool.args = payload.parameters;
|
|
3594
|
-
}
|
|
3595
|
-
tool.startedAt =
|
|
3596
|
-
tool.startedAt ??
|
|
3597
|
-
resolveTimestamp(payload.startedAt);
|
|
3598
|
-
tool.completedAt = undefined;
|
|
3599
|
-
tool.durationMs = undefined;
|
|
3600
|
-
toolMessage.toolCall = tool;
|
|
3601
|
-
toolMessage.streaming = true;
|
|
3602
|
-
if (payload.executionId) {
|
|
3603
|
-
toolMessage.agentMetadata = {
|
|
3604
|
-
executionId: payload.executionId,
|
|
3605
|
-
iteration: payload.iteration,
|
|
3606
|
-
};
|
|
3607
|
-
}
|
|
3608
|
-
emitMessage(toolMessage);
|
|
3609
|
-
} else if (payload.type === "tool_output_delta") {
|
|
3610
|
-
const toolId = payload.toolCallId;
|
|
3611
|
-
if (artifactToolCallIds.has(toolId)) continue;
|
|
3612
|
-
const toolMessage = ensureToolMessage(toolId);
|
|
3613
|
-
const tool = toolMessage.toolCall ?? {
|
|
3614
|
-
id: toolId,
|
|
3615
|
-
status: "running"
|
|
3616
|
-
};
|
|
3617
|
-
tool.startedAt =
|
|
3618
|
-
tool.startedAt ??
|
|
3619
|
-
Date.now();
|
|
3620
|
-
const chunkText = payload.delta;
|
|
3621
|
-
if (chunkText) {
|
|
3622
|
-
tool.chunks = tool.chunks ?? [];
|
|
3623
|
-
tool.chunks.push(String(chunkText));
|
|
3624
|
-
}
|
|
3625
|
-
tool.status = "running";
|
|
3626
|
-
toolMessage.toolCall = tool;
|
|
3627
|
-
toolMessage.streaming = true;
|
|
3628
|
-
if (payload.executionId) {
|
|
3629
|
-
toolMessage.agentMetadata = toolMessage.agentMetadata ?? {
|
|
3630
|
-
executionId: payload.executionId,
|
|
3631
|
-
iteration: lastIterationSeen,
|
|
3632
|
-
};
|
|
3633
|
-
}
|
|
3634
|
-
emitMessage(toolMessage);
|
|
3635
|
-
} else if (payload.type === "tool_complete") {
|
|
3636
|
-
const toolId = payload.toolCallId;
|
|
3637
|
-
if (artifactToolCallIds.has(toolId)) {
|
|
3638
|
-
artifactToolCallIds.delete(toolId);
|
|
3639
|
-
continue;
|
|
3640
|
-
}
|
|
3641
|
-
const toolMessage = ensureToolMessage(toolId);
|
|
3642
|
-
const tool = toolMessage.toolCall ?? {
|
|
3643
|
-
id: toolId,
|
|
3644
|
-
status: "running"
|
|
3645
|
-
};
|
|
3646
|
-
tool.status = "complete";
|
|
3647
|
-
if (payload.result !== undefined) {
|
|
3648
|
-
tool.result = payload.result;
|
|
3649
|
-
}
|
|
3650
|
-
tool.name = payload.toolName ?? tool.name;
|
|
3651
|
-
tool.success = payload.success;
|
|
3652
|
-
tool.error = payload.error;
|
|
3653
|
-
tool.completedAt = Date.now();
|
|
3654
|
-
const durationValue = payload.executionTime;
|
|
3655
|
-
if (typeof durationValue === "number") {
|
|
3656
|
-
tool.durationMs = durationValue;
|
|
3657
|
-
tool.duration = durationValue;
|
|
3658
|
-
} else {
|
|
3659
|
-
const start = tool.startedAt ?? Date.now();
|
|
3660
|
-
tool.durationMs = Math.max(
|
|
3661
|
-
0,
|
|
3662
|
-
(tool.completedAt ?? Date.now()) - start
|
|
3663
|
-
);
|
|
3664
|
-
}
|
|
3665
|
-
toolMessage.toolCall = tool;
|
|
3666
|
-
toolMessage.streaming = false;
|
|
3667
|
-
if (payload.executionId) {
|
|
3668
|
-
toolMessage.agentMetadata = toolMessage.agentMetadata ?? {
|
|
3669
|
-
executionId: payload.executionId,
|
|
3670
|
-
iteration: payload.iteration ?? lastIterationSeen,
|
|
3671
|
-
};
|
|
3672
|
-
}
|
|
3673
|
-
emitMessage(toolMessage);
|
|
3674
|
-
} else if (payload.type === "await" && payload.toolName) {
|
|
3675
|
-
// Unified LOCAL tool pause for either dispatch kind. The execution
|
|
3676
|
-
// waits for /resume with toolOutputs. Page tools with origin "webmcp"
|
|
3677
|
-
// may carry a bare name, normalized to the internal webmcp: prefix.
|
|
3678
|
-
//
|
|
3679
|
-
// Upsert a fully-populated tool-variant message so the existing
|
|
3680
|
-
// ask_user_question bubble + sheet paths fire. Mark the message with
|
|
3681
|
-
// `awaitingLocalTool: true` so the UI knows to resolve via
|
|
3682
|
-
// resumeFlow rather than the legacy sendMessage fallback.
|
|
3683
|
-
//
|
|
3684
|
-
// Key the message by the per-call `toolCallId` (provider `toolu_…`;
|
|
3685
|
-
// core#3878) when present. Two PARALLEL calls to the SAME tool in one
|
|
3686
|
-
// turn collapse to an identical `toolId` (`runtime_webmcp:<name>_<ms>`)
|
|
3687
|
-
// and `index: 0`: only `toolCallId` distinguishes them. Keying on it
|
|
3688
|
-
// (a) keeps the two awaits as DISTINCT messages with their own args
|
|
3689
|
-
// instead of the second clobbering the first, and (b) merges each
|
|
3690
|
-
// await into the matching `tool_start` bubble (also keyed by
|
|
3691
|
-
// `toolCallId`). Fall back to the collapsed `toolId` for legacy
|
|
3692
|
-
// servers that don't emit `toolCallId`.
|
|
3693
|
-
const toolCallId: string | undefined =
|
|
3694
|
-
typeof payload.toolCallId === "string" && payload.toolCallId.length > 0
|
|
3695
|
-
? (payload.toolCallId as string)
|
|
3696
|
-
: undefined;
|
|
3697
|
-
const toolId =
|
|
3698
|
-
toolCallId ?? (payload.toolId as string) ?? `local-${nextSequence()}`;
|
|
3699
|
-
const toolMessage = ensureToolMessage(toolId);
|
|
3700
|
-
const rawToolName = payload.toolName as string;
|
|
3701
|
-
// Page tools may arrive with a bare name; synthesize the
|
|
3702
|
-
// `webmcp:` prefix so isWebMcpToolName (and the bridge's prefix-strip on
|
|
3703
|
-
// resume) treat them identically to a flow `await`.
|
|
3704
|
-
const toolName =
|
|
3705
|
-
payload.origin === "webmcp" &&
|
|
3706
|
-
!isWebMcpToolName(rawToolName)
|
|
3707
|
-
? `webmcp:${rawToolName}`
|
|
3708
|
-
: rawToolName;
|
|
3709
|
-
const webMcpTool = isWebMcpToolName(toolName);
|
|
3710
|
-
const tool = toolMessage.toolCall ?? { id: toolId, status: "pending" as const };
|
|
3711
|
-
tool.name = toolName;
|
|
3712
|
-
tool.args = payload.parameters;
|
|
3713
|
-
// WebMCP tools are executed asynchronously by the browser AFTER this
|
|
3714
|
-
// `await` arrives. Keep them running until session.ts resolves
|
|
3715
|
-
// the page tool and records its actual elapsed time. Other local
|
|
3716
|
-
// tools (for example ask_user_question) keep the existing complete
|
|
3717
|
-
// state because they are waiting for a user interaction, not an
|
|
3718
|
-
// automatic page-tool execution.
|
|
3719
|
-
tool.status = webMcpTool ? "running" : "complete";
|
|
3720
|
-
tool.chunks = tool.chunks ?? [];
|
|
3721
|
-
tool.startedAt =
|
|
3722
|
-
tool.startedAt ??
|
|
3723
|
-
resolveTimestamp(payload.awaitedAt);
|
|
3724
|
-
if (webMcpTool) {
|
|
3725
|
-
tool.completedAt = undefined;
|
|
3726
|
-
tool.duration = undefined;
|
|
3727
|
-
tool.durationMs = undefined;
|
|
3728
|
-
} else {
|
|
3729
|
-
tool.completedAt = tool.completedAt ?? tool.startedAt;
|
|
3730
|
-
}
|
|
3731
|
-
toolMessage.toolCall = tool;
|
|
3732
|
-
toolMessage.streaming = false;
|
|
3733
|
-
toolMessage.agentMetadata = {
|
|
3734
|
-
...toolMessage.agentMetadata,
|
|
3735
|
-
executionId: (payload.executionId as string) ?? toolMessage.agentMetadata?.executionId,
|
|
3736
|
-
awaitingLocalTool: true,
|
|
3737
|
-
// Only set when the server emitted a real per-call id; its presence
|
|
3738
|
-
// is what tells session.ts to batch + key `/resume` by id rather
|
|
3739
|
-
// than by tool name (which can't represent two same-tool calls).
|
|
3740
|
-
...(toolCallId ? { webMcpToolCallId: toolCallId } : {}),
|
|
3741
|
-
};
|
|
3742
|
-
emitMessage(toolMessage);
|
|
3743
|
-
} else if (payload.type === "text_start") {
|
|
3744
|
-
// Nested flow-as-tool text (PR #4602): a `parentToolCallId` means this
|
|
3745
|
-
// block belongs to a flow running as that tool — record the mapping and
|
|
3746
|
-
// leave the top-level assistant bubble untouched (the nested deltas route
|
|
3747
|
-
// into the parent tool's row).
|
|
3748
|
-
const startBlockId = typeof payload.id === "string" ? payload.id : null;
|
|
3749
|
-
const startParent =
|
|
3750
|
-
typeof payload.parentToolCallId === "string" && payload.parentToolCallId
|
|
3751
|
-
? payload.parentToolCallId
|
|
3752
|
-
: null;
|
|
3753
|
-
if (startBlockId && startParent) {
|
|
3754
|
-
nestedBlockParent.set(startBlockId, startParent);
|
|
3755
|
-
continue;
|
|
3756
|
-
}
|
|
3757
|
-
// Unified text-channel block open. A new block id means a new bubble, so
|
|
3758
|
-
// seal any open assistant bubble; the next text_delta creates a fresh one
|
|
3759
|
-
// (lazily). The API emits a fresh block at every tool/media/approval/await
|
|
3760
|
-
// boundary, so block-id keying drives segmentation — no partId.
|
|
3761
|
-
const prev = assistantMessage as AgentWidgetMessage | null;
|
|
3762
|
-
if (prev) {
|
|
3763
|
-
// Normally text_complete already sealed the prior block; this is the
|
|
3764
|
-
// defensive path if a producer opens a new block without closing.
|
|
3765
|
-
if (executionKind === "flow") {
|
|
3766
|
-
finalizeFlowTextBlock(prev);
|
|
3767
|
-
lastSealedFlowBubble = prev;
|
|
3768
|
-
} else {
|
|
3769
|
-
prev.streaming = false;
|
|
3770
|
-
emitMessage(prev);
|
|
3771
|
-
}
|
|
3772
|
-
assistantMessage = null;
|
|
3773
|
-
}
|
|
3774
|
-
currentTextBlockId =
|
|
3775
|
-
typeof payload.id === "string" ? payload.id : currentTextBlockId;
|
|
3776
|
-
pendingTextRaw = "";
|
|
3777
|
-
} else if (payload.type === "text_delta") {
|
|
3778
|
-
// Nested flow-as-tool text: route to the parent tool's row, through the
|
|
3779
|
-
// same structured-content parser, never the top-level assistant channel.
|
|
3780
|
-
const deltaBlockId = typeof payload.id === "string" ? payload.id : null;
|
|
3781
|
-
const nestedParent = deltaBlockId
|
|
3782
|
-
? nestedBlockParent.get(deltaBlockId)
|
|
3783
|
-
: undefined;
|
|
3784
|
-
if (deltaBlockId && nestedParent) {
|
|
3785
|
-
const nestedDelta =
|
|
3786
|
-
typeof payload.delta === "string" ? payload.delta : "";
|
|
3787
|
-
const nestedRaw = (nestedBlockRaw.get(deltaBlockId) ?? "") + nestedDelta;
|
|
3788
|
-
nestedBlockRaw.set(deltaBlockId, nestedRaw);
|
|
3789
|
-
if (nestedRaw.trim() === "") continue;
|
|
3790
|
-
const nested = ensureNestedBlockMessage(deltaBlockId, nestedParent);
|
|
3791
|
-
nested.agentMetadata = {
|
|
3792
|
-
...nested.agentMetadata,
|
|
3793
|
-
executionId: payload.executionId,
|
|
3794
|
-
parentToolId: nestedParent,
|
|
3795
|
-
};
|
|
3796
|
-
applyTextChunk(nested, nestedRaw, nestedDelta, undefined);
|
|
3797
|
-
continue;
|
|
3798
|
-
}
|
|
3799
|
-
currentTextBlockId =
|
|
3800
|
-
typeof payload.id === "string" ? payload.id : currentTextBlockId;
|
|
3801
|
-
if (executionKind === "flow") {
|
|
3802
|
-
// Flow prompt-step text can be structured JSON: accumulate the raw
|
|
3803
|
-
// block and run it through the structured-content parser, keyed by the
|
|
3804
|
-
// block-id bubble. Materialize lazily so a whitespace-only block
|
|
3805
|
-
// (newlines around a tool boundary) never leaves a stray bubble.
|
|
3806
|
-
const delta = typeof payload.delta === "string" ? payload.delta : "";
|
|
3807
|
-
pendingTextRaw += delta;
|
|
3808
|
-
if (pendingTextRaw.trim() === "") continue;
|
|
3809
|
-
const assistant = ensureAssistantMessage();
|
|
3810
|
-
assistant.agentMetadata = {
|
|
3811
|
-
executionId: payload.executionId,
|
|
3812
|
-
iteration: lastIterationSeen,
|
|
3813
|
-
};
|
|
3814
|
-
applyTextChunk(assistant, pendingTextRaw, delta, undefined);
|
|
3815
|
-
lastAssistantInTurn = assistant;
|
|
3816
|
-
continue;
|
|
3817
|
-
}
|
|
3818
|
-
// Agent text can be structured JSON too, whenever the host configures a
|
|
3819
|
-
// custom `streamParser` (e.g. the page-context demo's `{"text": ...}` /
|
|
3820
|
-
// `{"action": "add_to_cart", ...}` envelope). Accumulate the open block's
|
|
3821
|
-
// raw text and run it through the same structured-content path the flow
|
|
3822
|
-
// branch uses, so the parser sees the whole block and `rawContent` stays
|
|
3823
|
-
// populated for the action manager. With the default plain-text parser
|
|
3824
|
-
// this is byte-identical to appending the delta, so agent demos that
|
|
3825
|
-
// stream prose are unaffected.
|
|
3826
|
-
const agentDelta = typeof payload.delta === "string" ? payload.delta : "";
|
|
3827
|
-
pendingTextRaw += agentDelta;
|
|
3828
|
-
const assistant = ensureAssistantMessage();
|
|
3829
|
-
assistant.agentMetadata = {
|
|
3830
|
-
executionId: payload.executionId,
|
|
3831
|
-
iteration: lastIterationSeen,
|
|
3832
|
-
turnId: openTurnId ?? undefined,
|
|
3833
|
-
agentName: agentExecution?.agentName
|
|
3834
|
-
};
|
|
3835
|
-
applyTextChunk(assistant, pendingTextRaw, agentDelta, undefined);
|
|
3836
|
-
lastAssistantInTurn = assistant;
|
|
3837
|
-
} else if (payload.type === "text_complete") {
|
|
3838
|
-
// Nested flow-as-tool text block close: seal its parent-tool-row message.
|
|
3839
|
-
const completeBlockId = typeof payload.id === "string" ? payload.id : null;
|
|
3840
|
-
if (completeBlockId && nestedBlockParent.has(completeBlockId)) {
|
|
3841
|
-
const nested = nestedBlockMessages.get(completeBlockId);
|
|
3842
|
-
if (nested) finalizeFlowTextBlock(nested);
|
|
3843
|
-
nestedBlockParent.delete(completeBlockId);
|
|
3844
|
-
nestedBlockRaw.delete(completeBlockId);
|
|
3845
|
-
nestedBlockMessages.delete(completeBlockId);
|
|
3846
|
-
continue;
|
|
3847
|
-
}
|
|
3848
|
-
// Seal the current text block's bubble.
|
|
3849
|
-
const prev = assistantMessage as AgentWidgetMessage | null;
|
|
3850
|
-
if (prev) {
|
|
3851
|
-
if (executionKind === "flow") {
|
|
3852
|
-
// Final structured extraction off the accumulated raw buffer; the
|
|
3853
|
-
// authoritative step_complete.result.response reconciles next.
|
|
3854
|
-
finalizeFlowTextBlock(prev);
|
|
3855
|
-
lastSealedFlowBubble = prev;
|
|
3856
|
-
} else {
|
|
3857
|
-
// U2: text_complete carries the assembled text, but the bubble already
|
|
3858
|
-
// holds it from the deltas — only fall back to payload.text when no
|
|
3859
|
-
// delta content was seen, never double-count.
|
|
3860
|
-
if ((prev.content ?? "") === "" && typeof payload.text === "string") {
|
|
3861
|
-
prev.content = payload.text;
|
|
3862
|
-
}
|
|
3863
|
-
prev.streaming = false;
|
|
3864
|
-
emitMessage(prev);
|
|
3865
|
-
}
|
|
3866
|
-
assistantMessage = null;
|
|
3867
|
-
}
|
|
3868
|
-
currentTextBlockId = null;
|
|
3869
|
-
pendingTextRaw = "";
|
|
3870
|
-
} else if (payload.type === "step_complete") {
|
|
3871
|
-
// Only process completions for prompt steps, not tool/context steps
|
|
3872
|
-
if (payload.stepType === "tool") {
|
|
3873
|
-
// Skip tool-related completions - they're handled by tool_complete
|
|
3874
|
-
continue;
|
|
3875
|
-
}
|
|
3876
|
-
|
|
3877
|
-
// A failed step (`success:false`) — including the legacy `step_error`
|
|
3878
|
-
// event, which the wire encoder folds into a failed `step_complete`
|
|
3879
|
-
// — surfaces as a terminal error and finalizes the stream.
|
|
3880
|
-
if (payload.success === false) {
|
|
3881
|
-
const message = payload.error || "Step failed";
|
|
3882
|
-
onEvent({ type: "error", error: new Error(message) });
|
|
3883
|
-
const finalMsg = assistantMessage as AgentWidgetMessage | null;
|
|
3884
|
-
if (finalMsg && finalMsg.streaming) {
|
|
3885
|
-
finalMsg.streaming = false;
|
|
3886
|
-
emitMessage(finalMsg);
|
|
3887
|
-
}
|
|
3888
|
-
onEvent({ type: "status", status: "idle" });
|
|
3889
|
-
continue;
|
|
3890
|
-
}
|
|
3891
|
-
|
|
3892
|
-
// Unified flow: reconcile the just-sealed text block with the
|
|
3893
|
-
// authoritative structured final (`result.response`). Displayed content
|
|
3894
|
-
// stays as streamed — a multi-segment step keeps each bubble's own text;
|
|
3895
|
-
// reconcile only fills/repairs the last sealed block and sets rawContent.
|
|
3896
|
-
// A pure-tool / text-less step (no sealed flow bubble) completes silently.
|
|
3897
|
-
{
|
|
3898
|
-
const sealed = lastSealedFlowBubble;
|
|
3899
|
-
lastSealedFlowBubble = null;
|
|
3900
|
-
const flowStopReason = payload.stopReason as
|
|
3901
|
-
| StopReasonKind
|
|
3902
|
-
| undefined;
|
|
3903
|
-
const result = payload.result && typeof payload.result === "object"
|
|
3904
|
-
? payload.result as Record<string, unknown>
|
|
3905
|
-
: null;
|
|
3906
|
-
const finalResponse = result?.response;
|
|
3907
|
-
if (sealed) {
|
|
3908
|
-
if (flowStopReason) sealed.stopReason = flowStopReason;
|
|
3909
|
-
if (finalResponse !== undefined && finalResponse !== null) {
|
|
3910
|
-
reconcileSealedAssistantWithFinalResponse(sealed, finalResponse);
|
|
3911
|
-
} else if (sealed.streaming !== false) {
|
|
3912
|
-
streamParsers.delete(sealed.id);
|
|
3913
|
-
rawContentBuffers.delete(sealed.id);
|
|
3914
|
-
sealed.streaming = false;
|
|
3915
|
-
emitMessage(sealed);
|
|
3916
|
-
}
|
|
3917
|
-
} else {
|
|
3918
|
-
// Buffered / dispatch-mode step: no streamed text block, but the step
|
|
3919
|
-
// carries the final response (and/or a stopReason) — render it as the
|
|
3920
|
-
// assistant message. An empty response + stopReason still surfaces a
|
|
3921
|
-
// sealed bubble so the UI can show an affordance.
|
|
3922
|
-
const hasResponse =
|
|
3923
|
-
finalResponse !== undefined &&
|
|
3924
|
-
finalResponse !== null &&
|
|
3925
|
-
finalResponse !== "";
|
|
3926
|
-
if (hasResponse || flowStopReason) {
|
|
3927
|
-
const assistant = ensureAssistantMessage();
|
|
3928
|
-
if (flowStopReason) assistant.stopReason = flowStopReason;
|
|
3929
|
-
if (hasResponse) {
|
|
3930
|
-
finalizeFlowTextBlock(assistant, finalResponse);
|
|
3931
|
-
} else {
|
|
3932
|
-
assistant.streaming = false;
|
|
3933
|
-
emitMessage(assistant);
|
|
3934
|
-
}
|
|
3935
|
-
}
|
|
3936
|
-
}
|
|
3937
|
-
continue;
|
|
3938
|
-
}
|
|
3939
|
-
// ================================================================
|
|
3940
|
-
// Agent Loop Execution Events
|
|
3941
|
-
// ================================================================
|
|
3942
|
-
} else if (payload.type === "execution_start") {
|
|
3943
|
-
executionKind = payload.kind === "flow" ? "flow" : "agent";
|
|
3944
|
-
executionKindResolved = true;
|
|
3945
|
-
if (executionKind === "agent") {
|
|
3946
|
-
agentExecution = {
|
|
3947
|
-
executionId: payload.executionId,
|
|
3948
|
-
agentId: payload.agentId ?? 'virtual',
|
|
3949
|
-
agentName: payload.agentName ?? ''
|
|
3950
|
-
};
|
|
3951
|
-
}
|
|
3952
|
-
} else if (payload.type === "turn_start") {
|
|
3953
|
-
// Unified collapsed `agent_iteration_*` into a denormalized `iteration`
|
|
3954
|
-
// field on the turn (merged spec §2). Reconstruct the per-iteration
|
|
3955
|
-
// message boundary the 'separate' renderer keys off: when the iteration
|
|
3956
|
-
// advances, seal the previous iteration's bubble and rotate to a new one.
|
|
3957
|
-
const iteration =
|
|
3958
|
-
typeof payload.iteration === "number" ? payload.iteration : lastIterationSeen;
|
|
3959
|
-
if (iteration !== lastIterationSeen) {
|
|
3960
|
-
if (iterationDisplay === 'separate' && iteration > 1) {
|
|
3961
|
-
const prevMsg = assistantMessage as AgentWidgetMessage | null;
|
|
3962
|
-
if (prevMsg) {
|
|
3963
|
-
prevMsg.streaming = false;
|
|
3964
|
-
emitMessage(prevMsg);
|
|
3965
|
-
assistantMessage = null;
|
|
3966
|
-
}
|
|
3967
|
-
}
|
|
3968
|
-
lastIterationSeen = iteration;
|
|
3969
|
-
}
|
|
3970
|
-
openTurnId = typeof payload.id === "string" ? payload.id : null;
|
|
3971
|
-
// Reset the per-turn assistant tracker. lastAssistantInTurn is used by
|
|
3972
|
-
// turn_complete to attach stopReason to the final text segment of the
|
|
3973
|
-
// turn even if that segment was sealed by an intervening tool boundary.
|
|
3974
|
-
lastAssistantInTurn = null;
|
|
3975
|
-
} else if (payload.type === "tool_input_delta") {
|
|
3976
|
-
// Streamed tool arguments (display-only; authoritative args ride
|
|
3977
|
-
// tool_input_complete / tool_start).
|
|
3978
|
-
const toolId = payload.toolCallId;
|
|
3979
|
-
if (toolId) {
|
|
3980
|
-
const toolMessage = toolMessages.get(toolId);
|
|
3981
|
-
if (toolMessage?.toolCall) {
|
|
3982
|
-
toolMessage.toolCall.chunks = toolMessage.toolCall.chunks ?? [];
|
|
3983
|
-
toolMessage.toolCall.chunks.push(payload.delta ?? '');
|
|
3984
|
-
emitMessage(toolMessage);
|
|
3985
|
-
}
|
|
3986
|
-
}
|
|
3987
|
-
} else if (payload.type === "tool_input_complete") {
|
|
3988
|
-
if (artifactToolCallIds.has(payload.toolCallId)) continue;
|
|
3989
|
-
const toolMessage = ensureToolMessage(payload.toolCallId);
|
|
3990
|
-
const tool = toolMessage.toolCall!;
|
|
3991
|
-
tool.args = payload.parameters;
|
|
3992
|
-
tool.name = payload.toolName ?? tool.name;
|
|
3993
|
-
emitMessage(toolMessage);
|
|
3994
|
-
} else if (payload.type === "turn_complete") {
|
|
3995
|
-
// Reasoning is sealed by its own reasoning_complete on the wire
|
|
3996
|
-
// vocabulary; this only attaches the turn-level stopReason to the
|
|
3997
|
-
// assistant message produced by this turn. Falls back to
|
|
3998
|
-
// lastAssistantInTurn when the bubble was sealed at a tool boundary
|
|
3999
|
-
// mid-turn, so the notice still attaches to the final visible segment.
|
|
4000
|
-
const turnStopReason = payload.stopReason as
|
|
4001
|
-
| StopReasonKind
|
|
4002
|
-
| undefined;
|
|
4003
|
-
const stopReasonTarget = assistantMessage ?? lastAssistantInTurn;
|
|
4004
|
-
if (turnStopReason && stopReasonTarget !== null) {
|
|
4005
|
-
const turnId = payload.id;
|
|
4006
|
-
const matchesTurn =
|
|
4007
|
-
!turnId || stopReasonTarget.agentMetadata?.turnId === turnId;
|
|
4008
|
-
if (matchesTurn) {
|
|
4009
|
-
stopReasonTarget.stopReason = turnStopReason;
|
|
4010
|
-
emitMessage(stopReasonTarget);
|
|
4011
|
-
}
|
|
4012
|
-
}
|
|
4013
|
-
if (openTurnId === payload.id) openTurnId = null;
|
|
4014
|
-
} else if (payload.type === "media_start") {
|
|
4015
|
-
// Open a media block; buffer fragments until media_complete.
|
|
4016
|
-
const id = String(payload.id);
|
|
4017
|
-
mediaBuffers.set(id, {
|
|
4018
|
-
mediaType: typeof payload.mediaType === "string" ? payload.mediaType : undefined,
|
|
4019
|
-
toolCallId: payload.toolCallId,
|
|
4020
|
-
parts: [],
|
|
4021
|
-
});
|
|
4022
|
-
} else if (payload.type === "media_delta") {
|
|
4023
|
-
const buf = mediaBuffers.get(String(payload.id));
|
|
4024
|
-
if (buf && typeof payload.delta === "string") buf.parts.push(payload.delta);
|
|
4025
|
-
} else if (payload.type === "media_complete") {
|
|
4026
|
-
// Reassemble the buffered media triad into a single AI SDK–aligned
|
|
4027
|
-
// `MediaContentPart`, then render it as a synthetic assistant message
|
|
4028
|
-
// inserted between the tool bubble and the next text turn:
|
|
4029
|
-
// { type: 'media', data, mediaType } // AI SDK v6: base64
|
|
4030
|
-
// { type: 'image-url', url, mediaType? } // AI SDK v3/v4
|
|
4031
|
-
// { type: 'file-url', url, mediaType } // AI SDK v3/v4
|
|
4032
|
-
const mediaBlockId = String(payload.id);
|
|
4033
|
-
const buf = mediaBuffers.get(mediaBlockId);
|
|
4034
|
-
mediaBuffers.delete(mediaBlockId);
|
|
4035
|
-
const completeMediaType =
|
|
4036
|
-
(typeof payload.mediaType === "string" ? payload.mediaType : undefined) ??
|
|
4037
|
-
buf?.mediaType ??
|
|
4038
|
-
"application/octet-stream";
|
|
4039
|
-
const completeData = typeof payload.data === "string" ? payload.data : undefined;
|
|
4040
|
-
const completeUrl =
|
|
4041
|
-
typeof payload.url === "string"
|
|
4042
|
-
? payload.url
|
|
4043
|
-
: buf && buf.parts.length > 0
|
|
4044
|
-
? buf.parts.join("")
|
|
4045
|
-
: undefined;
|
|
4046
|
-
let reconstructed: Record<string, unknown> | null = null;
|
|
4047
|
-
if (completeData) {
|
|
4048
|
-
reconstructed = { type: "media", data: completeData, mediaType: completeMediaType };
|
|
4049
|
-
} else if (completeUrl) {
|
|
4050
|
-
// The wire is mediaType-only; a URL part with no declared MIME
|
|
4051
|
-
// arrives as the bare bucket hint "image" (per the API encoder). Treat
|
|
4052
|
-
// that — and any real `image/*` — as a hosted image so we don't misroute
|
|
4053
|
-
// generated images into the file bucket.
|
|
4054
|
-
const lower = completeMediaType.toLowerCase();
|
|
4055
|
-
const isImage = lower === "image" || lower.startsWith("image/");
|
|
4056
|
-
reconstructed = {
|
|
4057
|
-
type: isImage ? "image-url" : "file-url",
|
|
4058
|
-
url: completeUrl,
|
|
4059
|
-
mediaType: completeMediaType,
|
|
4060
|
-
};
|
|
4061
|
-
}
|
|
4062
|
-
const mediaToolCallId = payload.toolCallId ?? buf?.toolCallId;
|
|
4063
|
-
const rawMedia = reconstructed ? [reconstructed] : [];
|
|
4064
|
-
const mediaContentParts: ContentPart[] = [];
|
|
4065
|
-
for (const part of rawMedia) {
|
|
4066
|
-
if (!part || typeof part !== "object") continue;
|
|
4067
|
-
const rec = part as Record<string, unknown>;
|
|
4068
|
-
const partType = typeof rec.type === "string" ? rec.type : undefined;
|
|
4069
|
-
|
|
4070
|
-
// Resolve `(src, mediaType)` for the part.
|
|
4071
|
-
// RFC 7231 says MIME types are case-insensitive, so we canonicalize
|
|
4072
|
-
// to lowercase once here. That makes the `startsWith("image/")` /
|
|
4073
|
-
// `"audio/"` / `"video/"` bucket checks robust to upstream tools
|
|
4074
|
-
// that emit non-canonical casing like `Image/PNG`.
|
|
4075
|
-
const rawMediaType =
|
|
4076
|
-
typeof rec.mediaType === "string" ? rec.mediaType.toLowerCase() : "";
|
|
4077
|
-
let src: string | null = null;
|
|
4078
|
-
let mediaType = "";
|
|
4079
|
-
if (partType === "media") {
|
|
4080
|
-
const data = typeof rec.data === "string" ? rec.data : undefined;
|
|
4081
|
-
if (!data) continue;
|
|
4082
|
-
// Empty/missing mediaType yields `data:;base64,...` which RFC 2397
|
|
4083
|
-
// resolves to `text/plain`: stamp a default so the data URI is
|
|
4084
|
-
// well-formed and the part lands in the file bucket.
|
|
4085
|
-
mediaType = rawMediaType.length > 0 ? rawMediaType : "application/octet-stream";
|
|
4086
|
-
src = `data:${mediaType};base64,${data}`;
|
|
4087
|
-
} else if (partType === "image-url") {
|
|
4088
|
-
const url = typeof rec.url === "string" ? rec.url : undefined;
|
|
4089
|
-
if (!url) continue;
|
|
4090
|
-
mediaType = rawMediaType;
|
|
4091
|
-
src = url;
|
|
4092
|
-
} else if (partType === "file-url") {
|
|
4093
|
-
const url = typeof rec.url === "string" ? rec.url : undefined;
|
|
4094
|
-
if (!url) continue;
|
|
4095
|
-
mediaType = rawMediaType;
|
|
4096
|
-
src = url;
|
|
4097
|
-
} else {
|
|
4098
|
-
continue;
|
|
4099
|
-
}
|
|
4100
|
-
if (!src) continue;
|
|
4101
|
-
|
|
4102
|
-
// Pick the right rendering bucket based on mediaType.
|
|
4103
|
-
if (partType === "image-url" || mediaType.startsWith("image/")) {
|
|
4104
|
-
mediaContentParts.push({
|
|
4105
|
-
type: "image",
|
|
4106
|
-
image: src,
|
|
4107
|
-
// Only a real MIME (`image/png`) is a usable mimeType; the bare
|
|
4108
|
-
// bucket hint "image" (a hosted URL with no declared type) is not.
|
|
4109
|
-
...(mediaType.includes("/") ? { mimeType: mediaType } : {}),
|
|
4110
|
-
});
|
|
4111
|
-
} else if (mediaType.startsWith("audio/")) {
|
|
4112
|
-
mediaContentParts.push({
|
|
4113
|
-
type: "audio",
|
|
4114
|
-
audio: src,
|
|
4115
|
-
mimeType: mediaType,
|
|
4116
|
-
});
|
|
4117
|
-
} else if (mediaType.startsWith("video/")) {
|
|
4118
|
-
mediaContentParts.push({
|
|
4119
|
-
type: "video",
|
|
4120
|
-
video: src,
|
|
4121
|
-
mimeType: mediaType,
|
|
4122
|
-
});
|
|
4123
|
-
} else {
|
|
4124
|
-
const resolvedMediaType = mediaType || "application/octet-stream";
|
|
4125
|
-
mediaContentParts.push({
|
|
4126
|
-
type: "file",
|
|
4127
|
-
data: src,
|
|
4128
|
-
mimeType: resolvedMediaType,
|
|
4129
|
-
filename: filenameFromMediaType(resolvedMediaType),
|
|
4130
|
-
});
|
|
4131
|
-
}
|
|
4132
|
-
}
|
|
4133
|
-
|
|
4134
|
-
if (mediaContentParts.length > 0) {
|
|
4135
|
-
// Uniquify per emission. A tool may emit multiple `agent_media`
|
|
4136
|
-
// events for the same `toolCallId` (e.g. streamed/batched media);
|
|
4137
|
-
// sharing an id would let `emitMessage` merge them by id and
|
|
4138
|
-
// overwrite the prior `contentParts`.
|
|
4139
|
-
const seq = nextSequence();
|
|
4140
|
-
const toolCallIdRaw = mediaToolCallId;
|
|
4141
|
-
const mediaIdSuffix =
|
|
4142
|
-
typeof toolCallIdRaw === "string" && toolCallIdRaw.length > 0
|
|
4143
|
-
? `${toolCallIdRaw}-${seq}`
|
|
4144
|
-
: String(seq);
|
|
4145
|
-
const mediaMessage: AgentWidgetMessage = {
|
|
4146
|
-
id: `agent-media-${mediaIdSuffix}`,
|
|
4147
|
-
role: "assistant",
|
|
4148
|
-
content: "",
|
|
4149
|
-
contentParts: mediaContentParts,
|
|
4150
|
-
createdAt: new Date().toISOString(),
|
|
4151
|
-
streaming: false,
|
|
4152
|
-
sequence: seq,
|
|
4153
|
-
agentMetadata: {
|
|
4154
|
-
executionId: payload.executionId,
|
|
4155
|
-
// Media blocks carry no iteration of their own; stamp the
|
|
4156
|
-
// enclosing iteration tracked from turn/tool frames.
|
|
4157
|
-
iteration: lastIterationSeen,
|
|
4158
|
-
},
|
|
4159
|
-
};
|
|
4160
|
-
emitMessage(mediaMessage);
|
|
4161
|
-
|
|
4162
|
-
// Seal any in-flight assistant text bubble before splitting the
|
|
4163
|
-
// stream. Without this, an orphan bubble retains `streaming: true`
|
|
4164
|
-
// forever: `execution_complete` only finalizes the latest
|
|
4165
|
-
// `assistantMessage`, so the typing/caret indicator would stay on
|
|
4166
|
-
// the prior bubble even though no more deltas will arrive.
|
|
4167
|
-
const prevAssistant = assistantMessage as AgentWidgetMessage | null;
|
|
4168
|
-
if (prevAssistant) {
|
|
4169
|
-
prevAssistant.streaming = false;
|
|
4170
|
-
emitMessage(prevAssistant);
|
|
4171
|
-
}
|
|
4172
|
-
assistantMessage = null;
|
|
4173
|
-
assistantMessageRef.current = null;
|
|
4174
|
-
}
|
|
4175
|
-
} else if (payload.type === "execution_complete") {
|
|
4176
|
-
// Finalize any still-open assistant message. Per-step reconciliation
|
|
4177
|
-
// (step_complete.result.response) normally sealed the flow blocks
|
|
4178
|
-
// already; this is the defensive close for an unterminated block, and
|
|
4179
|
-
// for flow it runs the final structured extraction off the raw buffer.
|
|
4180
|
-
const finalMsg = assistantMessage as AgentWidgetMessage | null;
|
|
4181
|
-
if (finalMsg) {
|
|
4182
|
-
if (payload.kind === "flow" && finalMsg.streaming !== false) {
|
|
4183
|
-
finalizeFlowTextBlock(finalMsg);
|
|
4184
|
-
} else {
|
|
4185
|
-
finalMsg.streaming = false;
|
|
4186
|
-
emitMessage(finalMsg);
|
|
4187
|
-
}
|
|
4188
|
-
assistantMessage = null;
|
|
4189
|
-
}
|
|
4190
|
-
currentTextBlockId = null;
|
|
4191
|
-
pendingTextRaw = "";
|
|
4192
|
-
lastSealedFlowBubble = null;
|
|
4193
|
-
|
|
4194
|
-
// `terminal: true` marks this as a graceful finish (not a drop). The
|
|
4195
|
-
// session uses it to distinguish the real end-of-turn from the plain
|
|
4196
|
-
// `idle` the dispatch wrappers emit in their `finally` when a durable
|
|
4197
|
-
// connection drops mid-stream (durable-reconnect drop detection).
|
|
4198
|
-
onEvent({ type: "status", status: "idle", terminal: true });
|
|
4199
|
-
} else if (payload.type === "execution_error") {
|
|
4200
|
-
// Terminal failure. The non-terminal `error` is handled
|
|
4201
|
-
// separately (recoverable → warn).
|
|
4202
|
-
const errorMessage = typeof payload.error === 'string'
|
|
4203
|
-
? payload.error
|
|
4204
|
-
: payload.error?.message ?? 'Execution error';
|
|
4205
|
-
onEvent({
|
|
4206
|
-
type: "error",
|
|
4207
|
-
error: new Error(errorMessage)
|
|
4208
|
-
});
|
|
4209
|
-
} else if (payload.type === "ping") {
|
|
4210
|
-
// Keep-alive heartbeat - no action needed
|
|
4211
|
-
// ================================================================
|
|
4212
|
-
// Tool Approval Events
|
|
4213
|
-
// ================================================================
|
|
4214
|
-
} else if (payload.type === "approval_start") {
|
|
4215
|
-
const approvalId = payload.approvalId ?? `approval-${nextSequence()}`;
|
|
4216
|
-
const approvalMessage: AgentWidgetMessage = {
|
|
4217
|
-
id: `approval-${approvalId}`,
|
|
4218
|
-
role: "assistant",
|
|
4219
|
-
content: "",
|
|
4220
|
-
createdAt: new Date().toISOString(),
|
|
4221
|
-
streaming: false,
|
|
4222
|
-
variant: "approval",
|
|
4223
|
-
sequence: nextSequence(),
|
|
4224
|
-
approval: {
|
|
4225
|
-
id: approvalId,
|
|
4226
|
-
status: "pending",
|
|
4227
|
-
agentId: agentExecution?.agentId ?? 'virtual',
|
|
4228
|
-
executionId: payload.executionId ?? agentExecution?.executionId ?? '',
|
|
4229
|
-
toolName: payload.toolName ?? '',
|
|
4230
|
-
toolType: payload.toolType,
|
|
4231
|
-
description: payload.description ?? `Execute ${payload.toolName ?? 'tool'}`,
|
|
4232
|
-
...(typeof payload.reason === "string" && payload.reason
|
|
4233
|
-
? { reason: payload.reason }
|
|
4234
|
-
: {}),
|
|
4235
|
-
parameters: payload.parameters,
|
|
4236
|
-
},
|
|
4237
|
-
};
|
|
4238
|
-
emitMessage(approvalMessage);
|
|
4239
|
-
} else if (payload.type === "approval_complete") {
|
|
4240
|
-
const approvalId = payload.approvalId;
|
|
4241
|
-
if (approvalId) {
|
|
4242
|
-
// Find and update the existing approval message
|
|
4243
|
-
const approvalMessageId = `approval-${approvalId}`;
|
|
4244
|
-
const existingMessage: AgentWidgetMessage = {
|
|
4245
|
-
id: approvalMessageId,
|
|
4246
|
-
role: "assistant",
|
|
4247
|
-
content: "",
|
|
4248
|
-
createdAt: new Date().toISOString(),
|
|
4249
|
-
streaming: false,
|
|
4250
|
-
variant: "approval",
|
|
4251
|
-
sequence: nextSequence(),
|
|
4252
|
-
approval: {
|
|
4253
|
-
id: approvalId,
|
|
4254
|
-
status: (payload.decision as "approved" | "denied") ?? "approved",
|
|
4255
|
-
agentId: agentExecution?.agentId ?? 'virtual',
|
|
4256
|
-
executionId: payload.executionId ?? agentExecution?.executionId ?? '',
|
|
4257
|
-
toolName: '',
|
|
4258
|
-
description: '',
|
|
4259
|
-
resolvedAt: Date.now(),
|
|
4260
|
-
},
|
|
4261
|
-
};
|
|
4262
|
-
emitMessage(existingMessage);
|
|
4263
|
-
}
|
|
4264
|
-
} else if (
|
|
4265
|
-
payload.type === "artifact_start" ||
|
|
4266
|
-
payload.type === "artifact_delta" ||
|
|
4267
|
-
payload.type === "artifact_update" ||
|
|
4268
|
-
payload.type === "artifact_complete"
|
|
4269
|
-
) {
|
|
4270
|
-
if (payload.type === "artifact_start") {
|
|
4271
|
-
const at = payload.artifactType as PersonaArtifactKind;
|
|
4272
|
-
const artId = String(payload.id);
|
|
4273
|
-
const artTitle = typeof payload.title === "string" ? payload.title : undefined;
|
|
4274
|
-
// Additive `file` metadata: validate shape (object with string path +
|
|
4275
|
-
// string mimeType; optional string language). Drop silently if malformed
|
|
4276
|
-
// so old/new backends and unexpected payloads never break the stream.
|
|
4277
|
-
const rawFile = payload.file;
|
|
4278
|
-
let artFile: PersonaArtifactFileMeta | undefined;
|
|
4279
|
-
if (
|
|
4280
|
-
rawFile &&
|
|
4281
|
-
typeof rawFile === "object" &&
|
|
4282
|
-
!Array.isArray(rawFile) &&
|
|
4283
|
-
typeof rawFile.path === "string" &&
|
|
4284
|
-
typeof rawFile.mimeType === "string"
|
|
4285
|
-
) {
|
|
4286
|
-
artFile = {
|
|
4287
|
-
path: rawFile.path,
|
|
4288
|
-
mimeType: rawFile.mimeType,
|
|
4289
|
-
...(typeof rawFile.language === "string" ? { language: rawFile.language } : {}),
|
|
4290
|
-
};
|
|
4291
|
-
}
|
|
4292
|
-
onEvent({
|
|
4293
|
-
type: "artifact_start",
|
|
4294
|
-
id: artId,
|
|
4295
|
-
artifactType: at,
|
|
4296
|
-
title: artTitle,
|
|
4297
|
-
component: typeof payload.component === "string" ? payload.component : undefined,
|
|
4298
|
-
...(artFile ? { file: artFile } : {})
|
|
4299
|
-
});
|
|
4300
|
-
artifactContent.set(artId, {
|
|
4301
|
-
markdown: "",
|
|
4302
|
-
title: artTitle,
|
|
4303
|
-
file: artFile,
|
|
4304
|
-
});
|
|
4305
|
-
// Insert the in-thread artifact block once per ID.
|
|
4306
|
-
// The resolved display mode picks the component:
|
|
4307
|
-
// "card"/"panel" inject the reference card; "inline" injects the
|
|
4308
|
-
// inline preview block. Both share the rawContent JSON-component
|
|
4309
|
-
// shape so transcript persistence and hydration work unchanged.
|
|
4310
|
-
if (!artifactIdsWithCards.has(artId)) {
|
|
4311
|
-
artifactIdsWithCards.add(artId);
|
|
4312
|
-
const displayMode = resolveArtifactDisplayMode(
|
|
4313
|
-
this.config.features?.artifacts,
|
|
4314
|
-
{
|
|
4315
|
-
artifactType: at,
|
|
4316
|
-
...(artFile ? { file: artFile } : {}),
|
|
4317
|
-
}
|
|
4318
|
-
);
|
|
4319
|
-
const artComponent =
|
|
4320
|
-
typeof payload.component === "string" ? payload.component : undefined;
|
|
4321
|
-
const cardMsg: AgentWidgetMessage = {
|
|
4322
|
-
id: `artifact-ref-${artId}`,
|
|
4323
|
-
role: "assistant",
|
|
4324
|
-
content: "",
|
|
4325
|
-
createdAt: new Date().toISOString(),
|
|
4326
|
-
streaming: true,
|
|
4327
|
-
sequence: nextSequence(),
|
|
4328
|
-
rawContent: buildArtifactRefRawContent(displayMode, {
|
|
4329
|
-
artifactId: artId,
|
|
4330
|
-
title: artTitle,
|
|
4331
|
-
artifactType: at,
|
|
4332
|
-
status: "streaming",
|
|
4333
|
-
...(artFile ? { file: artFile } : {}),
|
|
4334
|
-
...(artComponent ? { component: artComponent } : {}),
|
|
4335
|
-
}),
|
|
4336
|
-
};
|
|
4337
|
-
artifactCardMessages.set(artId, cardMsg);
|
|
4338
|
-
emitMessage(cardMsg);
|
|
4339
|
-
}
|
|
4340
|
-
} else if (payload.type === "artifact_delta") {
|
|
4341
|
-
const deltaId = String(payload.id);
|
|
4342
|
-
const deltaText = typeof payload.delta === "string" ? payload.delta : String(payload.delta ?? "");
|
|
4343
|
-
onEvent({
|
|
4344
|
-
type: "artifact_delta",
|
|
4345
|
-
id: deltaId,
|
|
4346
|
-
artDelta: deltaText
|
|
4347
|
-
});
|
|
4348
|
-
const acc = artifactContent.get(deltaId);
|
|
4349
|
-
if (acc) acc.markdown += deltaText;
|
|
4350
|
-
} else if (payload.type === "artifact_update") {
|
|
4351
|
-
const props =
|
|
4352
|
-
payload.props && typeof payload.props === "object" && !Array.isArray(payload.props)
|
|
4353
|
-
? (payload.props as Record<string, unknown>)
|
|
4354
|
-
: {};
|
|
4355
|
-
onEvent({
|
|
4356
|
-
type: "artifact_update",
|
|
4357
|
-
id: String(payload.id),
|
|
4358
|
-
props,
|
|
4359
|
-
component: typeof payload.component === "string" ? payload.component : undefined
|
|
4360
|
-
});
|
|
4361
|
-
// Accumulate for hydration: embedded on artifact_complete so an
|
|
4362
|
-
// inline component block re-renders with its real props after a
|
|
4363
|
-
// refresh.
|
|
4364
|
-
const updateAcc = artifactContent.get(String(payload.id));
|
|
4365
|
-
if (updateAcc) {
|
|
4366
|
-
updateAcc.props = { ...(updateAcc.props ?? {}), ...props };
|
|
4367
|
-
}
|
|
4368
|
-
} else if (payload.type === "artifact_complete") {
|
|
4369
|
-
const artCompleteId = String(payload.id);
|
|
4370
|
-
onEvent({ type: "artifact_complete", id: artCompleteId });
|
|
4371
|
-
// Update the inline card to show completed state
|
|
4372
|
-
const refMsg = artifactCardMessages.get(artCompleteId);
|
|
4373
|
-
if (refMsg) {
|
|
4374
|
-
refMsg.streaming = false;
|
|
4375
|
-
try {
|
|
4376
|
-
const parsed = JSON.parse(refMsg.rawContent ?? "{}");
|
|
4377
|
-
if (parsed.props) {
|
|
4378
|
-
parsed.props.status = "complete";
|
|
4379
|
-
// Store markdown content in card props so download works after page refresh
|
|
4380
|
-
const acc = artifactContent.get(artCompleteId);
|
|
4381
|
-
if (acc?.markdown) {
|
|
4382
|
-
parsed.props.markdown = acc.markdown;
|
|
4383
|
-
}
|
|
4384
|
-
// Persist file metadata too so the download path unfences correctly after refresh.
|
|
4385
|
-
if (acc?.file) {
|
|
4386
|
-
parsed.props.file = acc.file;
|
|
4387
|
-
}
|
|
4388
|
-
// Embed accumulated component props so an inline component
|
|
4389
|
-
// block hydrates with its real props after a refresh. Only
|
|
4390
|
-
// the inline block reads them; the card never does.
|
|
4391
|
-
if (
|
|
4392
|
-
parsed.component === "PersonaArtifactInline" &&
|
|
4393
|
-
acc?.props &&
|
|
4394
|
-
Object.keys(acc.props).length > 0
|
|
4395
|
-
) {
|
|
4396
|
-
parsed.props.componentProps = acc.props;
|
|
4397
|
-
}
|
|
4398
|
-
}
|
|
4399
|
-
refMsg.rawContent = JSON.stringify(parsed);
|
|
4400
|
-
} catch { /* ignore parse errors */ }
|
|
4401
|
-
artifactContent.delete(artCompleteId);
|
|
4402
|
-
emitMessage(refMsg);
|
|
4403
|
-
artifactCardMessages.delete(artCompleteId);
|
|
4404
|
-
}
|
|
4405
|
-
}
|
|
4406
|
-
} else if (payload.type === "error") {
|
|
4407
|
-
// Unified non-terminal error (merged spec). A bare `error` is
|
|
4408
|
-
// recoverable by default — a transient notice such as "rate limited,
|
|
4409
|
-
// retrying" — and the execution continues, so it must NOT surface as a
|
|
4410
|
-
// fatal error or finalize the stream. The API routes terminal failures
|
|
4411
|
-
// through `execution_error`. Only an explicit `recoverable: false`
|
|
4412
|
-
// promotes an `error` to terminal.
|
|
4413
|
-
if (
|
|
4414
|
-
payload.recoverable === false &&
|
|
4415
|
-
payload.error != null &&
|
|
4416
|
-
payload.error !== ""
|
|
4417
|
-
) {
|
|
4418
|
-
const errorMessage =
|
|
4419
|
-
typeof payload.error === "string"
|
|
4420
|
-
? payload.error
|
|
4421
|
-
: (payload.error as { message?: unknown })?.message != null
|
|
4422
|
-
? String((payload.error as { message?: unknown }).message)
|
|
4423
|
-
: "Execution error";
|
|
4424
|
-
onEvent({ type: "error", error: new Error(errorMessage) });
|
|
4425
|
-
const finalMsg = assistantMessage as AgentWidgetMessage | null;
|
|
4426
|
-
if (finalMsg && finalMsg.streaming) {
|
|
4427
|
-
finalMsg.streaming = false;
|
|
4428
|
-
emitMessage(finalMsg);
|
|
4429
|
-
}
|
|
4430
|
-
onEvent({ type: "status", status: "idle" });
|
|
4431
|
-
}
|
|
4432
|
-
|
|
4433
|
-
}
|
|
4434
|
-
}
|
|
4435
|
-
eventQueue.length = 0;
|
|
4436
|
-
};
|
|
4437
|
-
|
|
4438
|
-
// eslint-disable-next-line no-constant-condition
|
|
4439
|
-
while (true) {
|
|
4440
|
-
const { done, value } = await reader.read();
|
|
4441
|
-
if (done) break;
|
|
4442
|
-
|
|
4443
|
-
buffer += decoder.decode(value, { stream: true });
|
|
4444
|
-
const events = buffer.split("\n\n");
|
|
4445
|
-
buffer = events.pop() ?? "";
|
|
4446
|
-
|
|
4447
|
-
for (const event of events) {
|
|
4448
|
-
const lines = event.split("\n");
|
|
4449
|
-
let eventType = "message";
|
|
4450
|
-
let data = "";
|
|
4451
|
-
// Durable-reconnect cursor: the SSE `id:` line (the durable row seq).
|
|
4452
|
-
// Only durable, resumable agent executions stamp these (e.g. Claude
|
|
4453
|
-
// Managed agents, or any async/background run the backend persists and
|
|
4454
|
-
// can replay); other streams carry no cursor. We emit a `cursor`
|
|
4455
|
-
// event AFTER the frame is fully parsed and dispatched, so the session's
|
|
4456
|
-
// `lastEventId` only advances past frames it has actually applied, so the
|
|
4457
|
-
// happy path has no dupes against the server's `seq > after` replay.
|
|
4458
|
-
let frameId: string | null = null;
|
|
4459
|
-
|
|
4460
|
-
for (const line of lines) {
|
|
4461
|
-
if (line.startsWith("event:")) {
|
|
4462
|
-
eventType = line.replace("event:", "").trim();
|
|
4463
|
-
} else if (line.startsWith("data:")) {
|
|
4464
|
-
data += line.replace("data:", "").trim();
|
|
4465
|
-
} else if (line.startsWith("id:")) {
|
|
4466
|
-
frameId = line.slice(3).trim();
|
|
4467
|
-
}
|
|
4468
|
-
}
|
|
4469
|
-
|
|
4470
|
-
const advanceCursor = () => {
|
|
4471
|
-
if (frameId !== null && frameId !== "") {
|
|
4472
|
-
onEvent({ type: "cursor", id: frameId });
|
|
4473
|
-
}
|
|
4474
|
-
};
|
|
4475
|
-
|
|
4476
|
-
// A frame with an `id:` but no `data:` (e.g. a bare keepalive line) is
|
|
4477
|
-
// still a received durable row, so advance the cursor past it.
|
|
4478
|
-
if (!data) {
|
|
4479
|
-
advanceCursor();
|
|
4480
|
-
continue;
|
|
4481
|
-
}
|
|
4482
|
-
let payload: unknown;
|
|
4483
|
-
try {
|
|
4484
|
-
payload = JSON.parse(data);
|
|
4485
|
-
} catch (error) {
|
|
4486
|
-
// Parse failure: the frame was NOT applied. Do NOT advance the cursor
|
|
4487
|
-
// so a reconnect re-fetches this row.
|
|
4488
|
-
onEvent({
|
|
4489
|
-
type: "error",
|
|
4490
|
-
error:
|
|
4491
|
-
error instanceof Error
|
|
4492
|
-
? error
|
|
4493
|
-
: new Error("Failed to parse chat stream payload")
|
|
4494
|
-
});
|
|
4495
|
-
continue;
|
|
4496
|
-
}
|
|
4497
|
-
|
|
4498
|
-
const record = payload !== null && typeof payload === "object" && !Array.isArray(payload)
|
|
4499
|
-
? payload as Record<string, unknown>
|
|
4500
|
-
: null;
|
|
4501
|
-
const payloadType = eventType !== "message"
|
|
4502
|
-
? eventType
|
|
4503
|
-
: typeof record?.type === "string" ? record.type : "message";
|
|
4504
|
-
|
|
4505
|
-
// Tap: capture raw SSE event for event stream inspector
|
|
4506
|
-
this.onSSEEvent?.(payloadType, payload);
|
|
4507
|
-
|
|
4508
|
-
// If custom SSE event parser is provided, try it first
|
|
4509
|
-
if (this.parseSSEEvent) {
|
|
4510
|
-
// Keep assistant message ref in sync
|
|
4511
|
-
assistantMessageRef.current = assistantMessage;
|
|
4512
|
-
const handled = await this.handleCustomSSEEvent(
|
|
4513
|
-
payload,
|
|
4514
|
-
onEvent,
|
|
4515
|
-
assistantMessageRef,
|
|
4516
|
-
emitMessage,
|
|
4517
|
-
nextSequence,
|
|
4518
|
-
customParsePartId
|
|
4519
|
-
);
|
|
4520
|
-
// Update assistantMessage from ref (in case it was created or replaced by partId segmentation)
|
|
4521
|
-
if (assistantMessageRef.current && assistantMessageRef.current !== assistantMessage) {
|
|
4522
|
-
assistantMessage = assistantMessageRef.current;
|
|
4523
|
-
}
|
|
4524
|
-
if (handled) {
|
|
4525
|
-
advanceCursor();
|
|
4526
|
-
continue; // Skip default handling if custom handler processed it
|
|
4527
|
-
}
|
|
4528
|
-
}
|
|
4529
|
-
|
|
4530
|
-
// The wire is the wire vocabulary; the handler consumes it
|
|
4531
|
-
// natively. The stream is single-connection and in order, so each frame
|
|
4532
|
-
// drains straight through.
|
|
4533
|
-
if (record) {
|
|
4534
|
-
// The raw tap and custom parser above retain the original payload.
|
|
4535
|
-
// Known native branches use the generated union; unknown event types
|
|
4536
|
-
// fall through without rejecting a custom backend's stream.
|
|
4537
|
-
eventQueue.push({ ...record, type: payloadType } as RuntypeExecutionStreamEvent);
|
|
4538
|
-
drainReadyQueue();
|
|
4539
|
-
}
|
|
4540
|
-
advanceCursor();
|
|
4541
|
-
}
|
|
4542
|
-
}
|
|
4543
|
-
|
|
4544
|
-
drainReadyQueue();
|
|
2221
|
+
config: () => this.config,
|
|
2222
|
+
createStreamParser: this.#createStreamParser,
|
|
2223
|
+
parseSSEEvent: this.#parseSSEEvent,
|
|
2224
|
+
onSSEEvent: (eventType, payload) => this.#onSSEEvent?.(eventType, payload),
|
|
2225
|
+
},
|
|
2226
|
+
body,
|
|
2227
|
+
onEvent,
|
|
2228
|
+
assistantMessageId,
|
|
2229
|
+
seedContent
|
|
2230
|
+
);
|
|
4545
2231
|
}
|
|
4546
2232
|
}
|