@runtypelabs/persona 4.29.0 → 4.29.2
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/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/approval-ui.cjs +3 -3
- package/dist/approval-ui.d.cts +1 -1
- package/dist/approval-ui.d.ts +1 -1
- package/dist/approval-ui.js +3 -3
- package/dist/artifacts-ui.d.cts +2 -2
- package/dist/artifacts-ui.d.ts +2 -2
- package/dist/chunk-B1-_pp-Y.js +4 -0
- package/dist/chunk-B2HCRERn.js +9 -0
- package/dist/chunk-Bm2-bemv.js +1 -0
- package/dist/chunk-BvhTpG6k.js +1 -0
- package/dist/{chunk-DjOvgFsn.js → chunk-Car1mORT.js} +1 -1
- package/dist/chunk-D5B629Mj.js +1 -0
- package/dist/chunk-DO9_WSpy.js +1 -0
- package/dist/chunk-DTDHeUsG.js +1 -0
- package/dist/chunk-DjFpGFGA.js +1 -0
- package/dist/chunk-DrpuvY5W.js +1 -0
- package/dist/{chunk-dRBlrLd7.js → chunk-DtmtfIkd.js} +1 -1
- package/dist/client-history.js +1 -1
- package/dist/client-stream.js +1 -1
- package/dist/codegen.cjs +1 -1
- package/dist/codegen.js +1 -1
- package/dist/{composer-input-D3sB8_P_.d.cts → composer-input-C3Xh1Qzj.d.cts} +1 -1
- package/dist/{composer-input-D3sB8_P_.d.ts → composer-input-C3Xh1Qzj.d.ts} +1 -1
- package/dist/{context-mention-manager-D2qxE5op.d.cts → context-mention-manager-BsEiGObk.d.cts} +1 -1
- package/dist/{context-mention-manager-D2qxE5op.d.ts → context-mention-manager-BsEiGObk.d.ts} +1 -1
- package/dist/context-mentions-inline.d.cts +2 -2
- package/dist/context-mentions-inline.d.ts +2 -2
- package/dist/context-mentions.d.cts +3 -3
- package/dist/context-mentions.d.ts +3 -3
- package/dist/{defaults-Dsw66XqE.d.cts → defaults-DN-L67ak.d.cts} +1 -1
- package/dist/{defaults-Dsw66XqE.d.ts → defaults-DN-L67ak.d.ts} +1 -1
- package/dist/event-stream-view.d.cts +1 -1
- package/dist/event-stream-view.d.ts +1 -1
- package/dist/{fallback-speech-engine-DJpJcWAk.d.cts → fallback-speech-engine-Cph8FTux.d.cts} +1 -1
- package/dist/{fallback-speech-engine-DJpJcWAk.d.ts → fallback-speech-engine-Cph8FTux.d.ts} +1 -1
- package/dist/forms-ui.d.cts +2 -2
- package/dist/forms-ui.d.ts +2 -2
- package/dist/history-view.d.cts +1 -1
- package/dist/history-view.d.ts +1 -1
- package/dist/index.cjs +56 -56
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +6 -6
- package/dist/index.d.ts +6 -6
- package/dist/index.global.js +2 -3
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +42 -42
- package/dist/index.js.map +1 -1
- package/dist/{markdown-parsers-loader-CCY7T4Up.d.cts → markdown-parsers-loader-C4Xze-Jw.d.cts} +1 -1
- package/dist/{markdown-parsers-loader-CCY7T4Up.d.ts → markdown-parsers-loader-C4Xze-Jw.d.ts} +1 -1
- package/dist/{preview-DKKpfTo7.d.cts → preview-m9QlqsZi.d.cts} +2 -2
- package/dist/{preview-DKKpfTo7.d.ts → preview-m9QlqsZi.d.ts} +2 -2
- package/dist/{session-DleMr9dZ.d.ts → session-BtvvmWnw.d.cts} +21 -9
- package/dist/{session-DleMr9dZ.d.cts → session-BtvvmWnw.d.ts} +21 -9
- package/dist/session-actions.js +1 -1
- package/dist/session-reconnect.cjs +1 -1
- package/dist/session-reconnect.d.cts +8 -2
- package/dist/session-reconnect.d.ts +8 -2
- package/dist/session-reconnect.js +1 -1
- package/dist/smart-dom-reader.d.cts +1 -1
- package/dist/smart-dom-reader.d.ts +1 -1
- package/dist/theme-editor-preview.cjs +62 -62
- package/dist/theme-editor-preview.d.cts +1 -1
- package/dist/theme-editor-preview.d.ts +1 -1
- package/dist/theme-editor-preview.js +41 -41
- package/dist/theme-editor.d.cts +2 -2
- package/dist/theme-editor.d.ts +2 -2
- package/dist/theme-reference.d.cts +2 -2
- package/dist/theme-reference.d.ts +2 -2
- package/dist/{types-oAZ1VReS.d.cts → types-DL-oBHE1.d.cts} +41 -3
- package/dist/{types-oAZ1VReS.d.ts → types-DL-oBHE1.d.ts} +41 -3
- package/dist/voice-client-tools.d.cts +1 -1
- package/dist/voice-client-tools.d.ts +1 -1
- package/dist/voice-runtime.cjs +3 -3
- package/dist/voice-runtime.d.cts +10 -4
- package/dist/voice-runtime.d.ts +10 -4
- package/dist/voice-runtime.js +5 -5
- package/dist/voice-worklet-player.d.cts +2 -2
- package/dist/voice-worklet-player.d.ts +2 -2
- package/dist/{webmcp-runtime-entry-Deg0NfmB.d.cts → webmcp-runtime-entry-T6SU_TlL.d.cts} +1 -1
- package/dist/{webmcp-runtime-entry-Deg0NfmB.d.ts → webmcp-runtime-entry-T6SU_TlL.d.ts} +1 -1
- package/dist/webmcp-runtime.d.cts +1 -1
- package/dist/webmcp-runtime.d.ts +1 -1
- package/dist/widget.css +1 -1
- package/package.json +1 -1
- package/src/client-approve.test.ts +17 -1
- package/src/client-resume.ts +15 -3
- package/src/client.interrupt.test.ts +21 -0
- package/src/client.ts +24 -4
- package/src/components/activity-row.ts +3 -2
- package/src/components/approval-actions.test.ts +8 -0
- package/src/components/approval-actions.ts +6 -2
- package/src/components/approval-bubble.test.ts +14 -0
- package/src/components/approval-bubble.ts +8 -1
- package/src/components/tool-bubble.ts +8 -5
- package/src/generated/runtype-openapi-contract.ts +7 -1
- package/src/resume-dedupe-local-only.test.ts +273 -0
- package/src/runtime/persist-state.test.ts +86 -0
- package/src/session-actions.ts +20 -6
- package/src/session-reconnect.ts +18 -1
- package/src/session.restored-browser-tools.test.ts +272 -0
- package/src/session.test.ts +23 -0
- package/src/session.ts +211 -46
- package/src/session.voice.test.ts +169 -0
- package/src/styles/widget.css +4 -2
- package/src/types.ts +37 -2
- package/src/ui.restored-browser-tools.test.ts +128 -0
- package/src/ui.scroll.test.ts +7 -2
- package/src/ui.stream-replay.test.ts +86 -0
- package/src/ui.streaming-coalescing.test.ts +183 -0
- package/src/ui.ts +152 -124
- package/src/ui.typing-indicator.test.ts +21 -0
- package/src/utils/formatting.test.ts +9 -1
- package/src/utils/formatting.ts +4 -0
- package/src/utils/local-only-message.ts +15 -0
- package/src/utils/message-fingerprint.test.ts +9 -0
- package/src/utils/message-fingerprint.ts +18 -5
- package/src/utils/morph.ts +10 -0
- package/src/utils/restored-browser-state.ts +50 -0
- package/src/utils/storage.ts +12 -1
- package/src/utils/voice-prior-tool-results.ts +37 -0
- package/src/voice/runtype-voice-provider.ts +13 -8
- package/src/voice/session-voice-wiring.ts +1 -2
- package/src/voice/voice-delegation.ts +10 -3
- package/src/voice/voice.test.ts +38 -2
- package/dist/chunk-B6zZsT6F.js +0 -1
- package/dist/chunk-CIVsNcrk.js +0 -1
- package/dist/chunk-CKdlNyzM.js +0 -9
- package/dist/chunk-DbhxjyOB.js +0 -4
- package/dist/chunk-DsdqLStH.js +0 -1
- package/dist/chunk-GIqbL2Eg.js +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@runtypelabs/persona",
|
|
3
|
-
"version": "4.29.
|
|
3
|
+
"version": "4.29.2",
|
|
4
4
|
"description": "Themeable, pluggable streaming agent widget for websites, in plain JS with support for voice input and reasoning / tool output.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.cjs",
|
|
@@ -176,6 +176,22 @@ describe("AgentWidgetSession.resolveApproval in client-token mode", () => {
|
|
|
176
176
|
expect(errors).toEqual([]);
|
|
177
177
|
});
|
|
178
178
|
|
|
179
|
+
it("sends the continuation bubble's id so the stored reply dedupes by id (core#9961)", async () => {
|
|
180
|
+
const calls = mockFetch(
|
|
181
|
+
sse([
|
|
182
|
+
{ type: "text_start", id: "t1" },
|
|
183
|
+
{ type: "text_delta", id: "t1", delta: "Order placed." },
|
|
184
|
+
{ type: "text_complete", id: "t1" },
|
|
185
|
+
{ type: "execution_complete", kind: "agent", success: true },
|
|
186
|
+
])
|
|
187
|
+
);
|
|
188
|
+
const { approve, last } = setup();
|
|
189
|
+
await approve();
|
|
190
|
+
|
|
191
|
+
expect(calls[0].body.assistantMessageId).toMatch(/^ast_/);
|
|
192
|
+
expect(last()?.id).toBe(calls[0].body.assistantMessageId);
|
|
193
|
+
});
|
|
194
|
+
|
|
179
195
|
it.each([
|
|
180
196
|
[
|
|
181
197
|
403,
|
|
@@ -210,7 +226,7 @@ describe("AgentWidgetSession.resolveApproval in client-token mode", () => {
|
|
|
210
226
|
await approve();
|
|
211
227
|
|
|
212
228
|
expect(bubble()?.approval?.status).toBe(badge);
|
|
213
|
-
expect(last()).toMatchObject({ id: "approval-error-appr_1", content: text });
|
|
229
|
+
expect(last()).toMatchObject({ id: "approval-error-appr_1", content: text, localOnly: true });
|
|
214
230
|
expect(session.isStreaming()).toBe(false);
|
|
215
231
|
expect(errors.map((e) => e.message)).toEqual([text]);
|
|
216
232
|
// A refused request settles too: its token must not outlive it.
|
package/src/client-resume.ts
CHANGED
|
@@ -41,7 +41,8 @@ export type ClientResumeHost = {
|
|
|
41
41
|
export async function resolveApproval(
|
|
42
42
|
h: ClientResumeHost,
|
|
43
43
|
approval: { agentId: string; executionId: string; approvalId: string },
|
|
44
|
-
decision: "approved" | "denied"
|
|
44
|
+
decision: "approved" | "denied",
|
|
45
|
+
options?: { assistantMessageId?: string }
|
|
45
46
|
): Promise<Response> {
|
|
46
47
|
let headers: Record<string, string> = {
|
|
47
48
|
'Content-Type': 'application/json',
|
|
@@ -68,7 +69,10 @@ export async function resolveApproval(
|
|
|
68
69
|
|
|
69
70
|
// Same re-validation as `resumeFlow`: an approval can sit for a long time.
|
|
70
71
|
const { sessionId } = await h.initSession();
|
|
71
|
-
const response = await post('/v1/client/approve', {
|
|
72
|
+
const response = await post('/v1/client/approve', {
|
|
73
|
+
sessionId,
|
|
74
|
+
...(options?.assistantMessageId ? { assistantMessageId: options.assistantMessageId } : {}),
|
|
75
|
+
});
|
|
72
76
|
// A core without the route answers with its generic `Not Found`; an unknown
|
|
73
77
|
// or expired pause 404s with its own error, which the caller surfaces.
|
|
74
78
|
if (response.status === 404) {
|
|
@@ -83,7 +87,13 @@ export async function resumeFlow(
|
|
|
83
87
|
h: ClientResumeHost,
|
|
84
88
|
executionId: string,
|
|
85
89
|
toolOutputs: Record<string, unknown>,
|
|
86
|
-
options?: {
|
|
90
|
+
options?: {
|
|
91
|
+
streamResponse?: boolean;
|
|
92
|
+
signal?: AbortSignal;
|
|
93
|
+
after?: string;
|
|
94
|
+
/** Id of the local bubble the resumed stream renders into (client-token only). */
|
|
95
|
+
assistantMessageId?: string;
|
|
96
|
+
}
|
|
87
97
|
): Promise<Response> {
|
|
88
98
|
const isClientToken = h.isClientTokenMode();
|
|
89
99
|
const url = isClientToken
|
|
@@ -125,6 +135,8 @@ export async function resumeFlow(
|
|
|
125
135
|
// Thread the (refreshed) sessionId through like `/v1/client/chat` does.
|
|
126
136
|
if (resumeSessionId) {
|
|
127
137
|
body.sessionId = resumeSessionId;
|
|
138
|
+
// WHY(runtypelabs/core#9961): the server stores the reply under this id, so a later re-send dedupes by id.
|
|
139
|
+
if (options?.assistantMessageId) body.assistantMessageId = options.assistantMessageId;
|
|
128
140
|
}
|
|
129
141
|
|
|
130
142
|
if (isClientToken && resumeSessionId) {
|
|
@@ -108,6 +108,27 @@ describe("client-token turn identity", () => {
|
|
|
108
108
|
expect(bodies[1]).not.toHaveProperty("voice");
|
|
109
109
|
});
|
|
110
110
|
|
|
111
|
+
it("a voice hand-back with prior tool results sends them under voice; an empty list sends none", async () => {
|
|
112
|
+
const bodies: Record<string, unknown>[] = [];
|
|
113
|
+
global.fetch = vi.fn(async (url: unknown, init: any) => {
|
|
114
|
+
if (String(url).endsWith("/v1/client/init")) return initResponse();
|
|
115
|
+
bodies.push(JSON.parse(init.body));
|
|
116
|
+
const stream = controlledStream();
|
|
117
|
+
stream.close();
|
|
118
|
+
return stream.response;
|
|
119
|
+
}) as unknown as typeof fetch;
|
|
120
|
+
const prior = [
|
|
121
|
+
{ toolCallId: "vtc_1", name: "webmcp:jump_to_slide", arguments: { position: 5 }, result: { slideTitle: "Pricing" } },
|
|
122
|
+
];
|
|
123
|
+
|
|
124
|
+
const client = clientTokenClient();
|
|
125
|
+
await client.dispatch({ messages, voiceSpoken: true, voicePriorToolResults: prior }, () => {});
|
|
126
|
+
await client.dispatch({ messages, voiceSpoken: true, voicePriorToolResults: [] }, () => {});
|
|
127
|
+
|
|
128
|
+
expect(bodies[0].voice).toEqual({ spoken: true, priorToolResults: prior });
|
|
129
|
+
expect(bodies[1].voice).toEqual({ spoken: true });
|
|
130
|
+
});
|
|
131
|
+
|
|
111
132
|
it("drops SSE events belonging to a superseded turn", async () => {
|
|
112
133
|
const first = controlledStream();
|
|
113
134
|
const second = controlledStream();
|
package/src/client.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
AgentWidgetSSEEventResult as _AgentWidgetSSEEventResult,
|
|
15
15
|
ClientSession,
|
|
16
16
|
ClientChatRequest,
|
|
17
|
+
VoicePriorToolResult,
|
|
17
18
|
ClientToolDefinition,
|
|
18
19
|
ClientFeedbackRequest,
|
|
19
20
|
ClientFeedbackType,
|
|
@@ -55,6 +56,7 @@ import type { ClientResumeHost } from "./client-resume";
|
|
|
55
56
|
/** History transcripts stay on the wire shape; `utils/history-messages.ts` maps them. */
|
|
56
57
|
export type { HistoryWireMessage } from "./utils/history-messages";
|
|
57
58
|
import { divergentDisplayProjection } from "./utils/history-messages";
|
|
59
|
+
import { isLocalOnlyMessage } from "./utils/local-only-message";
|
|
58
60
|
// artifactsSidebarEnabled is used in ui.ts to gate the sidebar pane rendering;
|
|
59
61
|
// artifact events are always processed here regardless of config.
|
|
60
62
|
|
|
@@ -81,6 +83,12 @@ type DispatchOptions = {
|
|
|
81
83
|
* answer.
|
|
82
84
|
*/
|
|
83
85
|
voiceSpoken?: boolean;
|
|
86
|
+
/**
|
|
87
|
+
* Client-token only: page tools the voice server already ran for this
|
|
88
|
+
* delegated turn, sent as `voice.priorToolResults` with `voiceSpoken`.
|
|
89
|
+
* Proxy/agent payloads carry them as replayed tool rows instead.
|
|
90
|
+
*/
|
|
91
|
+
voicePriorToolResults?: VoicePriorToolResult[];
|
|
84
92
|
};
|
|
85
93
|
|
|
86
94
|
export type SSEHandler = (event: AgentWidgetEvent) => void;
|
|
@@ -153,6 +161,7 @@ const isHistoryClientError = (
|
|
|
153
161
|
const hasValidContent = (message: AgentWidgetMessage): boolean => {
|
|
154
162
|
// Display-only voice captions are never conversation.
|
|
155
163
|
if (message.voiceCaption) return false;
|
|
164
|
+
if (isLocalOnlyMessage(message)) return false;
|
|
156
165
|
// Check contentParts (multi-modal content)
|
|
157
166
|
if (message.contentParts && message.contentParts.length > 0) {
|
|
158
167
|
return true;
|
|
@@ -1553,7 +1562,12 @@ export class AgentWidgetClient {
|
|
|
1553
1562
|
// superseded run and this client can drop its stale events below.
|
|
1554
1563
|
turnId,
|
|
1555
1564
|
...(options.interrupt && { submitMode: 'interrupt' as const }),
|
|
1556
|
-
...(options.voiceSpoken && {
|
|
1565
|
+
...(options.voiceSpoken && {
|
|
1566
|
+
voice: {
|
|
1567
|
+
spoken: true as const,
|
|
1568
|
+
...(options.voicePriorToolResults?.length && { priorToolResults: options.voicePriorToolResults }),
|
|
1569
|
+
},
|
|
1570
|
+
}),
|
|
1557
1571
|
};
|
|
1558
1572
|
|
|
1559
1573
|
// Diff-only / send-once WebMCP tool dispatch. `buildPayload()` already
|
|
@@ -1867,9 +1881,10 @@ export class AgentWidgetClient {
|
|
|
1867
1881
|
*/
|
|
1868
1882
|
public async resolveApproval(
|
|
1869
1883
|
approval: { agentId: string; executionId: string; approvalId: string },
|
|
1870
|
-
decision: 'approved' | 'denied'
|
|
1884
|
+
decision: 'approved' | 'denied',
|
|
1885
|
+
options?: { assistantMessageId?: string }
|
|
1871
1886
|
): Promise<Response> {
|
|
1872
|
-
return (await loadClientStream()).resolveApproval(this.#resumeHost(), approval, decision);
|
|
1887
|
+
return (await loadClientStream()).resolveApproval(this.#resumeHost(), approval, decision, options);
|
|
1873
1888
|
}
|
|
1874
1889
|
|
|
1875
1890
|
/**
|
|
@@ -2009,7 +2024,12 @@ export class AgentWidgetClient {
|
|
|
2009
2024
|
public async resumeFlow(
|
|
2010
2025
|
executionId: string,
|
|
2011
2026
|
toolOutputs: Record<string, unknown>,
|
|
2012
|
-
options?: {
|
|
2027
|
+
options?: {
|
|
2028
|
+
streamResponse?: boolean;
|
|
2029
|
+
signal?: AbortSignal;
|
|
2030
|
+
after?: string;
|
|
2031
|
+
assistantMessageId?: string;
|
|
2032
|
+
}
|
|
2013
2033
|
): Promise<Response> {
|
|
2014
2034
|
return (await loadClientStream()).resumeFlow(this.#resumeHost(), executionId, toolOutputs, options);
|
|
2015
2035
|
}
|
|
@@ -41,13 +41,14 @@ export function applyActivityRow(bubble: HTMLElement, message: AgentWidgetMessag
|
|
|
41
41
|
const data = kind === "tool" ? message.toolCall : message.reasoning;
|
|
42
42
|
const active = data?.status !== "complete";
|
|
43
43
|
const approval = message.approval?.status ?? message.toolCall?.approvalStatus;
|
|
44
|
-
const state =
|
|
44
|
+
const state = message.toolCall?.interrupted ? "interrupted"
|
|
45
|
+
: approval === "denied" ? "denied" : approval === "pending" ? "awaiting-approval"
|
|
45
46
|
: message.toolCall?.success === false ? "error" : !active ? "done"
|
|
46
47
|
: data?.status === "pending" ? "pending" : "running";
|
|
47
48
|
bubble.dataset.activityState = state;
|
|
48
49
|
const icon = createElement("span", "persona-activity-icon");
|
|
49
50
|
icon.dataset.activityIcon = state;
|
|
50
|
-
const names = { pending: "circle", running: "loader-circle", done: "check", error: "x", denied: "x", "awaiting-approval": "clock" };
|
|
51
|
+
const names = { pending: "circle", running: "loader-circle", done: "check", error: "x", denied: "x", interrupted: "x", "awaiting-approval": "clock" };
|
|
51
52
|
const customIcon = kind === "reasoning" ? config.features?.reasoningDisplay?.iconName : undefined;
|
|
52
53
|
const glyph = customIcon ? renderLucideIcon(customIcon, 16, "currentColor", 2)
|
|
53
54
|
: state === "pending" ? renderIconNode(Circle, 16, "currentColor", 2)
|
|
@@ -346,3 +346,11 @@ describe("built-in approval — resolved states", () => {
|
|
|
346
346
|
expect(el?.textContent).toContain("timed out");
|
|
347
347
|
});
|
|
348
348
|
});
|
|
349
|
+
|
|
350
|
+
it("interrupted approvals explain expiry without action controls", () => {
|
|
351
|
+
const { el } = render({}, makeMessage({ status: "timeout", interrupted: true }));
|
|
352
|
+
expect(el?.textContent).toContain("approval expired");
|
|
353
|
+
expect(el?.textContent).toContain("chat restarted");
|
|
354
|
+
expect(el?.textContent).toContain("fresh proposal");
|
|
355
|
+
expect(el?.querySelector("button, [data-action]")).toBeNull();
|
|
356
|
+
});
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
* tool call takes over the transcript); denied/timeout → a subtle one-line
|
|
19
19
|
* trace.
|
|
20
20
|
*/
|
|
21
|
-
import { createElement } from "../utils/dom";
|
|
21
|
+
import { createElement, createNode } from "../utils/dom";
|
|
22
|
+
import { BROWSER_TOOL_INTERRUPTION } from "../utils/restored-browser-state";
|
|
22
23
|
// The webmcp-title lookup is injected by ui.ts at chunk adoption (see
|
|
23
24
|
// approval-deps): this module ships in the lazy approval-ui chunk. Icons are
|
|
24
25
|
// direct lucide data imports.
|
|
@@ -168,7 +169,10 @@ const buildResolvedTrace = (approval: Approval): HTMLElement => {
|
|
|
168
169
|
if (icon) row.appendChild(icon);
|
|
169
170
|
const name = createElement("span", "persona-approval-resolved-name");
|
|
170
171
|
name.textContent = approval.toolName ? humanizeToolName(approval.toolName) : "Tool";
|
|
171
|
-
|
|
172
|
+
const explanation = approval.status === "timeout" && approval.interrupted
|
|
173
|
+
? ` approval expired. ${BROWSER_TOOL_INTERRUPTION}`
|
|
174
|
+
: approval.status === "timeout" ? " timed out" : " denied";
|
|
175
|
+
row.appendChild(createNode("span", { className: "persona-approval-resolved-text" }, name, explanation));
|
|
172
176
|
return row;
|
|
173
177
|
};
|
|
174
178
|
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
createApprovalBubble,
|
|
6
6
|
humanizeToolName,
|
|
7
7
|
updateApprovalDetailsUI,
|
|
8
|
+
updateApprovalBubbleUI,
|
|
8
9
|
} from "./approval-bubble";
|
|
9
10
|
import {
|
|
10
11
|
getWebMcpToolDisplayTitle,
|
|
@@ -45,6 +46,19 @@ const makeMessage = (
|
|
|
45
46
|
},
|
|
46
47
|
});
|
|
47
48
|
|
|
49
|
+
it("shows interruption metadata while keeping the existing timeout status", () => {
|
|
50
|
+
const bubble = createApprovalBubble(makeMessage({ status: "timeout", interrupted: true }));
|
|
51
|
+
const badge = bubble.querySelector("[data-approval-status]");
|
|
52
|
+
expect(badge?.getAttribute("data-approval-status")).toBe("timeout");
|
|
53
|
+
expect(badge?.textContent).toBe("Interrupted");
|
|
54
|
+
expect(bubble.textContent).toContain("chat restarted");
|
|
55
|
+
expect(bubble.textContent).toContain("fresh proposal");
|
|
56
|
+
expect(bubble.querySelector('[data-action="allow"], [data-action="deny"]')).toBeNull();
|
|
57
|
+
updateApprovalBubbleUI("msg-1", bubble, {}, makeMessage({ status: "timeout", interrupted: true }).approval);
|
|
58
|
+
expect(badge?.textContent).toBe("Interrupted");
|
|
59
|
+
expect(badge?.getAttribute("data-approval-status")).toBe("timeout");
|
|
60
|
+
});
|
|
61
|
+
|
|
48
62
|
describe("createApprovalBubble shadow", () => {
|
|
49
63
|
it("falls back to the themeable --persona-approval-shadow variable when no config.shadow is set", () => {
|
|
50
64
|
const bubble = createApprovalBubble(makeMessage());
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { BROWSER_TOOL_INTERRUPTION } from "../utils/restored-browser-state";
|
|
1
2
|
import { createElement } from "../utils/dom";
|
|
2
3
|
import { AgentWidgetMessage, AgentWidgetConfig } from "../types";
|
|
3
4
|
import { formatUnknownValue } from "../utils/formatting";
|
|
@@ -100,6 +101,7 @@ export const updateApprovalBubbleUI = (
|
|
|
100
101
|
if (statusBadge) {
|
|
101
102
|
statusBadge.textContent = approval.status === "approved" ? "Approved"
|
|
102
103
|
: approval.status === "denied" ? "Denied"
|
|
104
|
+
: (approval.status === "timeout" && approval.interrupted) ? "Interrupted"
|
|
103
105
|
: approval.status === "timeout" ? "Timeout"
|
|
104
106
|
: "Pending";
|
|
105
107
|
|
|
@@ -232,12 +234,17 @@ export const createApprovalBubble = (
|
|
|
232
234
|
} else if (approval.status === "timeout") {
|
|
233
235
|
badge.style.backgroundColor = "var(--persona-palette-colors-warning-100)";
|
|
234
236
|
badge.style.color = "var(--persona-palette-colors-warning-700)";
|
|
235
|
-
badge.textContent = "Timeout";
|
|
237
|
+
badge.textContent = (approval.status === "timeout" && approval.interrupted) ? "Interrupted" : "Timeout";
|
|
236
238
|
}
|
|
237
239
|
titleRow.appendChild(badge);
|
|
238
240
|
}
|
|
239
241
|
|
|
240
242
|
content.appendChild(titleRow);
|
|
243
|
+
if (approval.status === "timeout" && approval.interrupted) {
|
|
244
|
+
const notice = createElement("p", "persona-text-sm");
|
|
245
|
+
notice.textContent = BROWSER_TOOL_INTERRUPTION;
|
|
246
|
+
content.appendChild(notice);
|
|
247
|
+
}
|
|
241
248
|
|
|
242
249
|
// User-facing summary line. The wire `description` is the tool's
|
|
243
250
|
// agent-facing description (prompt prose, usage rules), so it is not shown
|
|
@@ -78,7 +78,8 @@ const getToolSummaryText = (
|
|
|
78
78
|
message,
|
|
79
79
|
toolDisplayConfig?.previewMaxLines ?? DEFAULT_TOOL_CALL_DISPLAY.previewMaxLines
|
|
80
80
|
);
|
|
81
|
-
const defaultSummary = tool ?
|
|
81
|
+
const defaultSummary = tool?.interrupted ? `${tool.name?.trim() || "Tool"} · Interrupted`
|
|
82
|
+
: tool ? (activityVariant(config, "tool") === "row" ? `${tool.status === "complete" ? "Used" : "Using"} ${tool.name?.trim() || "tool"}` : describeToolTitle(tool)) : "";
|
|
82
83
|
|
|
83
84
|
if (!tool) {
|
|
84
85
|
return { summary: defaultSummary, previewText, isActive: false };
|
|
@@ -96,11 +97,13 @@ const getToolSummaryText = (
|
|
|
96
97
|
// Apply text templates if configured
|
|
97
98
|
if (isActive && toolCallConfig.activeTextTemplate) {
|
|
98
99
|
summary = resolveToolHeaderText(tool, toolCallConfig.activeTextTemplate, summary);
|
|
99
|
-
} else if (!isActive && toolCallConfig.completeTextTemplate) {
|
|
100
|
+
} else if (!isActive && !tool.interrupted && toolCallConfig.completeTextTemplate) {
|
|
100
101
|
summary = resolveToolHeaderText(tool, toolCallConfig.completeTextTemplate, summary);
|
|
101
102
|
}
|
|
102
103
|
|
|
103
|
-
if (tool.
|
|
104
|
+
if (tool.interrupted) {
|
|
105
|
+
if (!summary.includes(" · Interrupted")) summary += " · Interrupted";
|
|
106
|
+
} else if (tool.success === false) summary += " · Failed";
|
|
104
107
|
else if (tool.superseded) summary += " · Approved, ran again below";
|
|
105
108
|
return { summary, previewText, isActive };
|
|
106
109
|
};
|
|
@@ -184,7 +187,7 @@ export const createToolBubble = (
|
|
|
184
187
|
// Helper: build a <span data-tool-elapsed> that the global timer in ui.ts updates
|
|
185
188
|
const createElapsedSpan = (): HTMLElement => {
|
|
186
189
|
const span = createElement("span", "");
|
|
187
|
-
span.setAttribute("data-tool-elapsed", startedAt);
|
|
190
|
+
if (isActive) span.setAttribute("data-tool-elapsed", startedAt);
|
|
188
191
|
span.textContent = computeToolElapsed(tool);
|
|
189
192
|
return span;
|
|
190
193
|
};
|
|
@@ -213,7 +216,7 @@ export const createToolBubble = (
|
|
|
213
216
|
// Apply loading animation when tool is active and no custom HTMLElement was provided
|
|
214
217
|
const loadingAnimation = toolDisplayConfig.loadingAnimation ?? DEFAULT_TOOL_CALL_DISPLAY.loadingAnimation;
|
|
215
218
|
const activeTemplate = toolCallConfig.activeTextTemplate;
|
|
216
|
-
const completeTemplate = toolCallConfig.completeTextTemplate;
|
|
219
|
+
const completeTemplate = tool.interrupted ? undefined : toolCallConfig.completeTextTemplate;
|
|
217
220
|
const currentTemplate = isActive ? activeTemplate : completeTemplate;
|
|
218
221
|
const skipCustomElement = customSummary instanceof HTMLElement;
|
|
219
222
|
|
|
@@ -710,9 +710,15 @@ export type RuntypeClientChatRequest = {
|
|
|
710
710
|
}>;
|
|
711
711
|
metadata?: Record<string, unknown>;
|
|
712
712
|
sessionId: string;
|
|
713
|
-
submitMode?: "normal" | "interrupt" | "steer";
|
|
713
|
+
submitMode?: "normal" | "interrupt" | "steer" | "auto";
|
|
714
714
|
turnId?: string;
|
|
715
715
|
voice?: {
|
|
716
|
+
priorToolResults?: Array<{
|
|
717
|
+
arguments: Record<string, unknown>;
|
|
718
|
+
name: string;
|
|
719
|
+
result?: unknown;
|
|
720
|
+
toolCallId: string;
|
|
721
|
+
}>;
|
|
716
722
|
spoken?: boolean;
|
|
717
723
|
};
|
|
718
724
|
};
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { AgentWidgetClient } from "./client";
|
|
3
|
+
import { AgentWidgetSession, type AgentWidgetSessionStatus } from "./session";
|
|
4
|
+
import type { AgentWidgetConfig, AgentWidgetMessage, ResumableHandle } from "./types";
|
|
5
|
+
|
|
6
|
+
// runtypelabs/core#9961: resumed replies must keep the id the server stores
|
|
7
|
+
// them under, and widget failure notices must never become history.
|
|
8
|
+
|
|
9
|
+
const originalFetch = global.fetch;
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
global.fetch = originalFetch;
|
|
12
|
+
vi.restoreAllMocks();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const enc = new TextEncoder();
|
|
16
|
+
const frame = (data: Record<string, unknown>) => `data: ${JSON.stringify(data)}\n\n`;
|
|
17
|
+
const sseStream = (text: string) =>
|
|
18
|
+
new ReadableStream<Uint8Array>({
|
|
19
|
+
start(c) {
|
|
20
|
+
if (text) c.enqueue(enc.encode(text));
|
|
21
|
+
c.close();
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
const replyFrames = (text: string) =>
|
|
25
|
+
frame({ type: "text_start", id: "t1" }) +
|
|
26
|
+
frame({ type: "text_delta", id: "t1", delta: text }) +
|
|
27
|
+
frame({ type: "text_complete", id: "t1" }) +
|
|
28
|
+
frame({ type: "execution_complete", kind: "agent", success: true });
|
|
29
|
+
|
|
30
|
+
type ClientInternals = {
|
|
31
|
+
clientSession: { sessionId: string; expiresAt: Date };
|
|
32
|
+
isWebMcpOperational: () => boolean;
|
|
33
|
+
executeWebMcpToolCall: () => Promise<unknown>;
|
|
34
|
+
resumeFlow: (...args: unknown[]) => Promise<Response>;
|
|
35
|
+
};
|
|
36
|
+
const internals = (session: AgentWidgetSession) =>
|
|
37
|
+
(session as unknown as { client: ClientInternals }).client;
|
|
38
|
+
|
|
39
|
+
const track = (config: AgentWidgetConfig) => {
|
|
40
|
+
const state = {
|
|
41
|
+
messages: [] as AgentWidgetMessage[],
|
|
42
|
+
status: "idle" as AgentWidgetSessionStatus,
|
|
43
|
+
errors: [] as Error[],
|
|
44
|
+
};
|
|
45
|
+
const session = new AgentWidgetSession(config, {
|
|
46
|
+
onMessagesChanged: (m) => {
|
|
47
|
+
state.messages = m;
|
|
48
|
+
},
|
|
49
|
+
onStatusChanged: (s) => {
|
|
50
|
+
state.status = s;
|
|
51
|
+
},
|
|
52
|
+
onStreamingChanged: () => {},
|
|
53
|
+
onError: (e) => state.errors.push(e),
|
|
54
|
+
});
|
|
55
|
+
return { session, state };
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const awaitingWebMcpTool = (): AgentWidgetMessage => ({
|
|
59
|
+
id: "tool-call-1",
|
|
60
|
+
role: "assistant",
|
|
61
|
+
content: "",
|
|
62
|
+
createdAt: "2026-01-01T00:00:01.000Z",
|
|
63
|
+
streaming: true,
|
|
64
|
+
variant: "tool",
|
|
65
|
+
agentMetadata: { executionId: "exec-1", awaitingLocalTool: true, webMcpToolCallId: "call-1" },
|
|
66
|
+
toolCall: { id: "call-1", name: "webmcp:purchase", status: "running", args: { quantity: 1 } },
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("resumed reply ids", () => {
|
|
70
|
+
const tokenClient = () => {
|
|
71
|
+
const client = new AgentWidgetClient({ clientToken: "ct_live_demo", apiUrl: "https://api.runtype.com" });
|
|
72
|
+
(client as unknown as ClientInternals).clientSession = {
|
|
73
|
+
sessionId: "cs_123",
|
|
74
|
+
expiresAt: new Date(Date.now() + 60_000),
|
|
75
|
+
};
|
|
76
|
+
return client;
|
|
77
|
+
};
|
|
78
|
+
const captureBodies = () => {
|
|
79
|
+
const bodies: Record<string, unknown>[] = [];
|
|
80
|
+
global.fetch = vi.fn(async (_url: string, init: RequestInit) => {
|
|
81
|
+
bodies.push(JSON.parse(init.body as string));
|
|
82
|
+
return new Response(sseStream(""));
|
|
83
|
+
}) as unknown as typeof fetch;
|
|
84
|
+
return bodies;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
it("sends the local bubble id as assistantMessageId on a client-token /resume", async () => {
|
|
88
|
+
const bodies = captureBodies();
|
|
89
|
+
await tokenClient().resumeFlow("exec_1", { call_1: "ok" }, { assistantMessageId: "ast_local" });
|
|
90
|
+
expect(bodies[0]).toMatchObject({ executionId: "exec_1", sessionId: "cs_123", assistantMessageId: "ast_local" });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("keeps the proxy /resume body unchanged", async () => {
|
|
94
|
+
const bodies = captureBodies();
|
|
95
|
+
const client = new AgentWidgetClient({ apiUrl: "https://proxy.example.com/chat" });
|
|
96
|
+
await client.resumeFlow("exec_1", { call_1: "ok" }, { assistantMessageId: "ast_local" });
|
|
97
|
+
expect(bodies[0].assistantMessageId).toBeUndefined();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("renders a resumed WebMCP leg into the bubble whose id it sent", async () => {
|
|
101
|
+
const { session, state } = track({ apiUrl: "http://test", webmcp: { enabled: true } });
|
|
102
|
+
const client = internals(session);
|
|
103
|
+
client.isWebMcpOperational = () => true;
|
|
104
|
+
client.executeWebMcpToolCall = vi.fn(async () => ({ content: [{ type: "text", text: "Purchased" }] }));
|
|
105
|
+
const resume = vi.fn(async () => new Response(sseStream(replyFrames("Your order is in."))));
|
|
106
|
+
client.resumeFlow = resume;
|
|
107
|
+
|
|
108
|
+
await session.resolveWebMcpToolCall(awaitingWebMcpTool());
|
|
109
|
+
|
|
110
|
+
expect(resume).toHaveBeenCalledTimes(1);
|
|
111
|
+
const sentId = (resume.mock.calls[0] as unknown[])[2] as { assistantMessageId?: string };
|
|
112
|
+
expect(sentId.assistantMessageId).toMatch(/^ast_/);
|
|
113
|
+
const reply = state.messages.find((m) => m.content === "Your order is in.");
|
|
114
|
+
expect(reply?.id).toBe(sentId.assistantMessageId);
|
|
115
|
+
session.destroy();
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("local-only failure notices", () => {
|
|
120
|
+
it("shows a dispatch failure but never sends it as history", async () => {
|
|
121
|
+
const payloads: Array<{ messages: Array<{ role: string; content: unknown }> }> = [];
|
|
122
|
+
let calls = 0;
|
|
123
|
+
const { session, state } = track({
|
|
124
|
+
apiUrl: "http://test",
|
|
125
|
+
customFetch: async (_url, _init, payload) => {
|
|
126
|
+
payloads.push(payload as (typeof payloads)[number]);
|
|
127
|
+
calls += 1;
|
|
128
|
+
if (calls === 1) throw new Error("upstream unavailable");
|
|
129
|
+
return new Response(sseStream(replyFrames("Back online.")));
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
await session.sendMessage("first");
|
|
134
|
+
const notice = state.messages.at(-1)!;
|
|
135
|
+
expect(notice.role).toBe("assistant");
|
|
136
|
+
expect(notice.localOnly).toBe(true);
|
|
137
|
+
|
|
138
|
+
await session.sendMessage("second");
|
|
139
|
+
expect(payloads[1].messages.map((m) => m.content)).toEqual(["first", "second"]);
|
|
140
|
+
session.destroy();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("drops reconnect-failure bubbles stored before the flag existed", async () => {
|
|
144
|
+
const payloads: Array<{ messages: Array<{ content: unknown }> }> = [];
|
|
145
|
+
const { session } = track({
|
|
146
|
+
apiUrl: "http://test",
|
|
147
|
+
initialMessages: [
|
|
148
|
+
{ id: "u1", role: "user", content: "hi", createdAt: "2026-01-01T00:00:00.000Z" },
|
|
149
|
+
{
|
|
150
|
+
id: "reconnect-failed-exec_1",
|
|
151
|
+
role: "assistant",
|
|
152
|
+
content: "Connection lost and the response could not be resumed.",
|
|
153
|
+
createdAt: "2026-01-01T00:00:01.000Z",
|
|
154
|
+
},
|
|
155
|
+
],
|
|
156
|
+
customFetch: async (_url, _init, payload) => {
|
|
157
|
+
payloads.push(payload as (typeof payloads)[number]);
|
|
158
|
+
return new Response(sseStream(replyFrames("ok")));
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
await session.sendMessage("again");
|
|
163
|
+
expect(payloads[0].messages.map((m) => m.content)).toEqual(["hi", "again"]);
|
|
164
|
+
session.destroy();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("marks the reconnect give-up bubble local-only", async () => {
|
|
168
|
+
const { session, state } = track({
|
|
169
|
+
apiUrl: "http://test",
|
|
170
|
+
reconnectStream: async () => {
|
|
171
|
+
throw new Error("network down");
|
|
172
|
+
},
|
|
173
|
+
reconnect: { backoffMs: [1], maxAttempts: 1 },
|
|
174
|
+
});
|
|
175
|
+
session.hydrateMessages([
|
|
176
|
+
{ id: "u1", role: "user", content: "tell me", createdAt: "2026-01-01T00:00:00.000Z" },
|
|
177
|
+
{ id: "a1", role: "assistant", content: "Once", createdAt: "2026-01-01T00:00:01.000Z" },
|
|
178
|
+
]);
|
|
179
|
+
|
|
180
|
+
session.resumeFromHandle({ executionId: "exec_5", after: "5" });
|
|
181
|
+
await vi.waitFor(() => expect(state.errors).toHaveLength(1));
|
|
182
|
+
|
|
183
|
+
const notice = state.messages.find((m) => m.id === "reconnect-failed-exec_5");
|
|
184
|
+
expect(notice?.localOnly).toBe(true);
|
|
185
|
+
session.destroy();
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe("boot resume of a run parked on an await", () => {
|
|
190
|
+
it("keeps retrying when the reconnect request itself fails", async () => {
|
|
191
|
+
let calls = 0;
|
|
192
|
+
const reconnectStream = vi.fn(async () => {
|
|
193
|
+
calls += 1;
|
|
194
|
+
if (calls === 1) throw new Error("network blip");
|
|
195
|
+
return new Response(sseStream(""));
|
|
196
|
+
});
|
|
197
|
+
const { session, state } = track({
|
|
198
|
+
apiUrl: "http://test",
|
|
199
|
+
webmcp: { enabled: true },
|
|
200
|
+
reconnectStream,
|
|
201
|
+
reconnect: { backoffMs: [1, 1, 1, 1, 1], maxAttempts: 5 },
|
|
202
|
+
});
|
|
203
|
+
session.hydrateMessages([
|
|
204
|
+
{ id: "u1", role: "user", content: "buy one", createdAt: "2026-01-01T00:00:00.000Z" },
|
|
205
|
+
{ id: "a1", role: "assistant", content: "Buying it now.", createdAt: "2026-01-01T00:00:00.500Z" },
|
|
206
|
+
awaitingWebMcpTool(),
|
|
207
|
+
]);
|
|
208
|
+
|
|
209
|
+
session.resumeFromHandle({ executionId: "exec-1", after: "7" });
|
|
210
|
+
await vi.waitFor(() => expect(state.status).toBe("idle"));
|
|
211
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
212
|
+
|
|
213
|
+
expect(reconnectStream).toHaveBeenCalledTimes(2);
|
|
214
|
+
expect(state.errors).toEqual([]);
|
|
215
|
+
expect(session.getResumableHandle()).toBeNull();
|
|
216
|
+
session.destroy();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("ends quietly when the reload interrupted the WebMCP call and nothing replays", async () => {
|
|
220
|
+
const executionStates: Array<ResumableHandle | null> = [];
|
|
221
|
+
const reconnectStream = vi.fn(async () => new Response(sseStream("")));
|
|
222
|
+
const { session, state } = track({
|
|
223
|
+
apiUrl: "http://test",
|
|
224
|
+
webmcp: { enabled: true },
|
|
225
|
+
reconnectStream,
|
|
226
|
+
reconnect: { backoffMs: [1, 1, 1, 1, 1], maxAttempts: 5 },
|
|
227
|
+
onExecutionState: (h) => executionStates.push(h),
|
|
228
|
+
});
|
|
229
|
+
session.hydrateMessages([
|
|
230
|
+
{ id: "u1", role: "user", content: "buy one", createdAt: "2026-01-01T00:00:00.000Z" },
|
|
231
|
+
{ id: "a1", role: "assistant", content: "Buying it now.", createdAt: "2026-01-01T00:00:00.500Z" },
|
|
232
|
+
awaitingWebMcpTool(),
|
|
233
|
+
]);
|
|
234
|
+
|
|
235
|
+
session.resumeFromHandle({ executionId: "exec-1", after: "7" });
|
|
236
|
+
await vi.waitFor(() => expect(state.status).toBe("idle"));
|
|
237
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
238
|
+
|
|
239
|
+
expect(reconnectStream).toHaveBeenCalledTimes(1);
|
|
240
|
+
expect(state.errors).toEqual([]);
|
|
241
|
+
expect(state.messages.some((m) => m.id.startsWith("reconnect-failed-"))).toBe(false);
|
|
242
|
+
expect(state.messages.every((m) => !m.streaming)).toBe(true);
|
|
243
|
+
expect(state.messages.map((m) => m.id)).toEqual(["u1", "a1", "tool-call-1"]);
|
|
244
|
+
expect(session.getResumableHandle()).toBeNull();
|
|
245
|
+
expect(executionStates.at(-1)).toBeNull();
|
|
246
|
+
session.destroy();
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("keeps the cursor for a pending ask_user_question card instead of reconnecting", async () => {
|
|
250
|
+
const reconnectStream = vi.fn(async () => new Response(sseStream("")));
|
|
251
|
+
const { session, state } = track({ apiUrl: "http://test", reconnectStream });
|
|
252
|
+
session.hydrateMessages([
|
|
253
|
+
{ id: "u1", role: "user", content: "plan a trip", createdAt: "2026-01-01T00:00:00.000Z" },
|
|
254
|
+
{
|
|
255
|
+
id: "tool-ask-1",
|
|
256
|
+
role: "assistant",
|
|
257
|
+
content: "",
|
|
258
|
+
createdAt: "2026-01-01T00:00:01.000Z",
|
|
259
|
+
variant: "tool",
|
|
260
|
+
agentMetadata: { executionId: "exec-2", awaitingLocalTool: true },
|
|
261
|
+
toolCall: { id: "ask-1", name: "ask_user_question", status: "running" },
|
|
262
|
+
},
|
|
263
|
+
]);
|
|
264
|
+
|
|
265
|
+
session.resumeFromHandle({ executionId: "exec-2", after: "4" });
|
|
266
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
267
|
+
|
|
268
|
+
expect(reconnectStream).not.toHaveBeenCalled();
|
|
269
|
+
expect(state.status).toBe("idle");
|
|
270
|
+
expect(session.getResumableHandle()).toMatchObject({ executionId: "exec-2", lastEventId: "4" });
|
|
271
|
+
session.destroy();
|
|
272
|
+
});
|
|
273
|
+
});
|