@runtypelabs/persona 3.16.0 → 3.18.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.
Files changed (71) hide show
  1. package/README.md +142 -0
  2. package/dist/animations/glyph-cycle.cjs +279 -0
  3. package/dist/animations/glyph-cycle.d.cts +5 -0
  4. package/dist/animations/glyph-cycle.d.ts +5 -0
  5. package/dist/animations/glyph-cycle.js +252 -0
  6. package/dist/animations/types-cwY5HaFD.d.cts +307 -0
  7. package/dist/animations/types-cwY5HaFD.d.ts +307 -0
  8. package/dist/animations/wipe.cjs +107 -0
  9. package/dist/animations/wipe.d.cts +5 -0
  10. package/dist/animations/wipe.d.ts +5 -0
  11. package/dist/animations/wipe.js +80 -0
  12. package/dist/index.cjs +49 -48
  13. package/dist/index.cjs.map +1 -1
  14. package/dist/index.d.cts +504 -1
  15. package/dist/index.d.ts +504 -1
  16. package/dist/index.global.js +143 -88
  17. package/dist/index.global.js.map +1 -1
  18. package/dist/index.js +49 -48
  19. package/dist/index.js.map +1 -1
  20. package/dist/testing.cjs +85 -0
  21. package/dist/testing.d.cts +39 -0
  22. package/dist/testing.d.ts +39 -0
  23. package/dist/testing.js +56 -0
  24. package/dist/theme-editor.cjs +2095 -207
  25. package/dist/theme-editor.d.cts +432 -2
  26. package/dist/theme-editor.d.ts +432 -2
  27. package/dist/theme-editor.js +2093 -207
  28. package/dist/theme-reference.cjs +1 -1
  29. package/dist/theme-reference.d.cts +14 -0
  30. package/dist/theme-reference.d.ts +14 -0
  31. package/dist/widget.css +565 -0
  32. package/package.json +20 -3
  33. package/src/animations/glyph-cycle.ts +332 -0
  34. package/src/animations/wipe.ts +66 -0
  35. package/src/client.test.ts +275 -0
  36. package/src/client.ts +99 -0
  37. package/src/components/ask-user-question-bubble.test.ts +583 -0
  38. package/src/components/ask-user-question-bubble.ts +924 -0
  39. package/src/components/composer-builder.ts +61 -10
  40. package/src/components/message-bubble.test.ts +181 -2
  41. package/src/components/message-bubble.ts +209 -14
  42. package/src/components/messages.ts +33 -1
  43. package/src/components/panel.ts +45 -5
  44. package/src/defaults.ts +37 -0
  45. package/src/index-global.ts +31 -0
  46. package/src/index.ts +34 -1
  47. package/src/plugins/types.ts +57 -0
  48. package/src/session.test.ts +276 -1
  49. package/src/session.ts +247 -3
  50. package/src/styles/widget.css +565 -0
  51. package/src/testing/index.ts +11 -0
  52. package/src/testing/mock-stream.test.ts +80 -0
  53. package/src/testing/mock-stream.ts +94 -0
  54. package/src/testing.ts +2 -0
  55. package/src/theme-editor/index.ts +4 -0
  56. package/src/theme-editor/preview-utils.test.ts +60 -0
  57. package/src/theme-editor/preview-utils.ts +129 -0
  58. package/src/theme-editor/sections.test.ts +19 -0
  59. package/src/theme-editor/sections.ts +84 -1
  60. package/src/types/theme.ts +15 -0
  61. package/src/types.ts +360 -0
  62. package/src/ui.ask-user-question-plugin.test.ts +649 -0
  63. package/src/ui.stop-button.test.ts +165 -0
  64. package/src/ui.ts +706 -11
  65. package/src/utils/message-fingerprint.ts +2 -0
  66. package/src/utils/morph.ts +7 -0
  67. package/src/utils/storage.ts +10 -2
  68. package/src/utils/stream-animation.test.ts +417 -0
  69. package/src/utils/stream-animation.ts +449 -0
  70. package/src/utils/theme.test.ts +36 -0
  71. package/src/utils/tokens.ts +23 -0
@@ -81,6 +81,12 @@ export interface PanelElements {
81
81
  container: HTMLElement;
82
82
  body: HTMLElement;
83
83
  messagesWrapper: HTMLElement;
84
+ /**
85
+ * Absolute-positioned slot above the composer footer. Interactive sheets
86
+ * (e.g. the answer-pill sheet for the ask_user_question tool) mount here
87
+ * so they slide in without reflowing the chat transcript.
88
+ */
89
+ composerOverlay: HTMLElement;
84
90
  suggestions: HTMLElement;
85
91
  textarea: HTMLTextAreaElement;
86
92
  sendButton: HTMLButtonElement;
@@ -110,6 +116,8 @@ export interface PanelElements {
110
116
  actionsRow: HTMLElement;
111
117
  leftActions: HTMLElement;
112
118
  rightActions: HTMLElement;
119
+ /** Swap the send button between its send and stop appearances. */
120
+ setSendButtonMode: (mode: "send" | "stop") => void;
113
121
  }
114
122
 
115
123
  export const buildPanel = (config?: AgentWidgetConfig, showClose = true): PanelElements => {
@@ -136,10 +144,17 @@ export const buildPanel = (config?: AgentWidgetConfig, showClose = true): PanelE
136
144
  body.id = "persona-scroll-container";
137
145
  body.setAttribute("data-persona-theme-zone", "messages");
138
146
 
139
- const introCardClasses = isDockedMountMode(config)
140
- ? "persona-rounded-2xl persona-bg-persona-surface persona-p-6"
141
- : "persona-rounded-2xl persona-bg-persona-surface persona-p-6 persona-shadow-sm";
142
- const introCard = createElement("div", introCardClasses);
147
+ const introCard = createElement(
148
+ "div",
149
+ "persona-rounded-2xl persona-bg-persona-surface persona-p-6"
150
+ );
151
+ // Box-shadow flows through the themable `components.introCard.shadow` token
152
+ // (--persona-intro-card-shadow). Docked mode keeps a flat look by default;
153
+ // floating mode falls back to the legacy `persona-shadow-sm` value when no
154
+ // token is set.
155
+ introCard.style.boxShadow = isDockedMountMode(config)
156
+ ? "none"
157
+ : "var(--persona-intro-card-shadow, 0 5px 15px rgba(15, 23, 42, 0.08))";
143
158
  const introTitle = createElement(
144
159
  "h2",
145
160
  "persona-text-lg persona-font-semibold persona-text-persona-primary"
@@ -191,6 +206,26 @@ export const buildPanel = (config?: AgentWidgetConfig, showClose = true): PanelE
191
206
 
192
207
  container.append(body);
193
208
 
209
+ // Composer overlay slot: sits between body and footer, absolutely positioned
210
+ // above the composer so sheets (e.g. the ask_user_question answer-pill sheet)
211
+ // can slide up without reflowing the chat transcript above. Uses inline
212
+ // styles for left/right/bottom because widget.css is hand-authored and
213
+ // doesn't ship `.persona-left-0` / `.persona-right-0` rules — without
214
+ // them the overlay shrink-wraps to content and collapses the sheet width.
215
+ const composerOverlay = createElement(
216
+ "div",
217
+ "persona-composer-overlay persona-pointer-events-none"
218
+ );
219
+ composerOverlay.setAttribute("data-persona-composer-overlay", "");
220
+ composerOverlay.style.position = "absolute";
221
+ composerOverlay.style.left = "0";
222
+ composerOverlay.style.right = "0";
223
+ composerOverlay.style.bottom = "0";
224
+ // Above .persona-scroll-to-bottom-indicator (z-index 10, sibling in the
225
+ // container) so suggestion chips and the ask-user-question sheet are not
226
+ // covered by the "jump to latest" button.
227
+ composerOverlay.style.zIndex = "20";
228
+
194
229
  if (showFooter) {
195
230
  container.append(composerElements.footer);
196
231
  } else {
@@ -199,10 +234,14 @@ export const buildPanel = (config?: AgentWidgetConfig, showClose = true): PanelE
199
234
  container.append(composerElements.footer);
200
235
  }
201
236
 
237
+ // Append overlay last so it stacks above the footer / body content.
238
+ container.append(composerOverlay);
239
+
202
240
  return {
203
241
  container,
204
242
  body,
205
243
  messagesWrapper,
244
+ composerOverlay,
206
245
  suggestions: composerElements.suggestions,
207
246
  textarea: composerElements.textarea,
208
247
  sendButton: composerElements.sendButton,
@@ -230,7 +269,8 @@ export const buildPanel = (config?: AgentWidgetConfig, showClose = true): PanelE
230
269
  // Actions row layout elements
231
270
  actionsRow: composerElements.actionsRow,
232
271
  leftActions: composerElements.leftActions,
233
- rightActions: composerElements.rightActions
272
+ rightActions: composerElements.rightActions,
273
+ setSendButtonMode: composerElements.setSendButtonMode
234
274
  };
235
275
  };
236
276
 
package/src/defaults.ts CHANGED
@@ -137,6 +137,19 @@ export const DEFAULT_WIDGET_CONFIG: Partial<AgentWidgetConfig> = {
137
137
  expandable: true,
138
138
  loadingAnimation: "none",
139
139
  },
140
+ streamAnimation: {
141
+ type: "none",
142
+ placeholder: "none",
143
+ speed: 120,
144
+ duration: 1800,
145
+ },
146
+ askUserQuestion: {
147
+ enabled: true,
148
+ slideInMs: 180,
149
+ freeTextLabel: "Other…",
150
+ freeTextPlaceholder: "Type your answer…",
151
+ submitLabel: "Send",
152
+ },
140
153
  },
141
154
  suggestionChips: [
142
155
  "What can you help me with?",
@@ -251,6 +264,10 @@ export function mergeWithDefaults(
251
264
  const ca = config.features?.artifacts;
252
265
  const dsb = DEFAULT_WIDGET_CONFIG.features?.scrollToBottom;
253
266
  const csb = config.features?.scrollToBottom;
267
+ const dsa = DEFAULT_WIDGET_CONFIG.features?.streamAnimation;
268
+ const csa = config.features?.streamAnimation;
269
+ const dau = DEFAULT_WIDGET_CONFIG.features?.askUserQuestion;
270
+ const cau = config.features?.askUserQuestion;
254
271
  const mergedArtifacts =
255
272
  da === undefined && ca === undefined
256
273
  ? undefined
@@ -269,11 +286,31 @@ export function mergeWithDefaults(
269
286
  ...dsb,
270
287
  ...csb,
271
288
  };
289
+ const mergedStreamAnimation =
290
+ dsa === undefined && csa === undefined
291
+ ? undefined
292
+ : {
293
+ ...dsa,
294
+ ...csa,
295
+ };
296
+ const mergedAskUserQuestion =
297
+ dau === undefined && cau === undefined
298
+ ? undefined
299
+ : {
300
+ ...dau,
301
+ ...cau,
302
+ styles: {
303
+ ...dau?.styles,
304
+ ...cau?.styles,
305
+ },
306
+ };
272
307
  return {
273
308
  ...DEFAULT_WIDGET_CONFIG.features,
274
309
  ...config.features,
275
310
  ...(mergedScrollToBottom !== undefined ? { scrollToBottom: mergedScrollToBottom } : {}),
276
311
  ...(mergedArtifacts !== undefined ? { artifacts: mergedArtifacts } : {}),
312
+ ...(mergedStreamAnimation !== undefined ? { streamAnimation: mergedStreamAnimation } : {}),
313
+ ...(mergedAskUserQuestion !== undefined ? { askUserQuestion: mergedAskUserQuestion } : {}),
277
314
  };
278
315
  })(),
279
316
  suggestionChips: config.suggestionChips ?? DEFAULT_WIDGET_CONFIG.suggestionChips,
@@ -0,0 +1,31 @@
1
+ /**
2
+ * IIFE entry point — bundled for `<script>` tag consumers.
3
+ *
4
+ * This file re-exports everything from the main entry AND side-imports all
5
+ * built-in subpath animations so they register automatically. Script-tag
6
+ * users who include the global build don't need extra script tags or
7
+ * registration calls — setting `features.streamAnimation.type` to any
8
+ * built-in name just works.
9
+ *
10
+ * npm consumers continue to import from the main entry (`import ... from
11
+ * "@runtypelabs/persona"`) — those animations stay in their subpath
12
+ * modules so bundlers can tree-shake them.
13
+ */
14
+
15
+ // Re-export the full public API.
16
+ export * from "./index";
17
+
18
+ // Side-import the remaining subpath animations so they're available to
19
+ // script-tag consumers without an explicit import. (`letter-rise` and
20
+ // `word-fade` are core built-ins and register automatically.)
21
+ import "./animations/wipe";
22
+ import "./animations/glyph-cycle";
23
+
24
+ // Expose plugin-registration helpers on the global so custom animations
25
+ // can be registered from inline `<script>` blocks or third-party CDN scripts.
26
+ export {
27
+ registerStreamAnimationPlugin,
28
+ unregisterStreamAnimationPlugin,
29
+ listRegisteredStreamAnimations,
30
+ } from "./utils/stream-animation";
31
+ export type { StreamAnimationPlugin, StreamAnimationContext } from "./types";
package/src/index.ts CHANGED
@@ -86,9 +86,24 @@ export type {
86
86
  EventStreamToolbarRenderContext,
87
87
  EventStreamPayloadRenderContext,
88
88
  // Controller event map
89
- AgentWidgetControllerEventMap
89
+ AgentWidgetControllerEventMap,
90
+ // Ask-user-question (built-in answer-pill sheet) types
91
+ AskUserQuestionPayload,
92
+ AskUserQuestionPrompt,
93
+ AskUserQuestionOption,
94
+ AgentWidgetAskUserQuestionFeature,
95
+ AgentWidgetAskUserQuestionStyles
90
96
  } from "./types";
91
97
 
98
+ export {
99
+ ASK_USER_QUESTION_TOOL_NAME,
100
+ createAskUserQuestionBubble,
101
+ ensureAskUserQuestionSheet,
102
+ removeAskUserQuestionSheet,
103
+ isAskUserQuestionMessage,
104
+ parseAskUserQuestionPayload
105
+ } from "./components/ask-user-question-bubble";
106
+
92
107
  export { initAgentWidgetFn as initAgentWidget };
93
108
  export {
94
109
  createWidgetHostLayout,
@@ -178,6 +193,24 @@ export type { AgentWidgetInitHandle };
178
193
  export type { AgentWidgetPlugin } from "./plugins/types";
179
194
  export { pluginRegistry } from "./plugins/registry";
180
195
 
196
+ // Stream animation plugin API — lets consumers register custom animations
197
+ // that match the built-in surface (typewriter, pop-bubble) and subpath
198
+ // modules (letter-rise, word-fade, wipe, glyph-cycle).
199
+ export {
200
+ registerStreamAnimationPlugin,
201
+ unregisterStreamAnimationPlugin,
202
+ listRegisteredStreamAnimations,
203
+ } from "./utils/stream-animation";
204
+ export type {
205
+ StreamAnimationPlugin,
206
+ StreamAnimationContext,
207
+ AgentWidgetStreamAnimationBuffer,
208
+ AgentWidgetStreamAnimationBuiltinType,
209
+ AgentWidgetStreamAnimationType,
210
+ AgentWidgetStreamAnimationFeature,
211
+ AgentWidgetStreamAnimationPlaceholder,
212
+ } from "./types";
213
+
181
214
  // Dropdown utility exports
182
215
  export { createDropdownMenu } from "./utils/dropdown";
183
216
  export type { DropdownMenuItem, CreateDropdownOptions, DropdownMenuHandle } from "./utils/dropdown";
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  AgentWidgetMessage,
3
3
  AgentWidgetConfig,
4
+ AskUserQuestionPayload,
4
5
  LoadingIndicatorRenderContext,
5
6
  IdleIndicatorRenderContext,
6
7
  EventStreamViewRenderContext,
@@ -107,6 +108,62 @@ export interface AgentWidgetPlugin {
107
108
  config: AgentWidgetConfig;
108
109
  }) => HTMLElement | null;
109
110
 
111
+ /**
112
+ * Custom renderer for `ask_user_question` tool calls.
113
+ *
114
+ * When a plugin returns an `HTMLElement`, it is inserted into the transcript
115
+ * in place of the default (which is no transcript bubble — the built-in
116
+ * renders a sheet over the composer). The built-in composer-overlay sheet
117
+ * is suppressed so the plugin's UI fully owns the interaction.
118
+ *
119
+ * Return `null` to fall through to the built-in overlay sheet.
120
+ *
121
+ * The context gives you a pre-parsed `payload` (may be partial while the
122
+ * tool call is still streaming — check `complete`) and two callbacks:
123
+ * `resolve(answer)` resumes the paused LOCAL tool with the user's answer,
124
+ * and `dismiss()` cancels with the sentinel `"(dismissed)"`.
125
+ *
126
+ * @example
127
+ * ```typescript
128
+ * renderAskUserQuestion: ({ payload, resolve, dismiss }) => {
129
+ * const prompt = payload.questions?.[0];
130
+ * if (!prompt) return null;
131
+ * const root = document.createElement("div");
132
+ * root.textContent = prompt.question ?? "";
133
+ * (prompt.options ?? []).forEach((option) => {
134
+ * const btn = document.createElement("button");
135
+ * btn.textContent = option.label;
136
+ * btn.addEventListener("click", () => resolve(option.label));
137
+ * root.appendChild(btn);
138
+ * });
139
+ * return root;
140
+ * }
141
+ * ```
142
+ */
143
+ renderAskUserQuestion?: (context: {
144
+ message: AgentWidgetMessage;
145
+ /**
146
+ * Parsed `{ questions: [...] }` payload. May be partial while the tool
147
+ * call is still streaming; see `complete`. `null` when no payload has
148
+ * arrived yet.
149
+ */
150
+ payload: Partial<AskUserQuestionPayload> | null;
151
+ /** `true` once the tool-call args have fully streamed in. */
152
+ complete: boolean;
153
+ /**
154
+ * Resume the paused LOCAL tool with the user's answer. Posts to the
155
+ * resume endpoint, pipes the SSE stream back into the session, and
156
+ * appends a user-visible answer bubble to the transcript.
157
+ */
158
+ resolve: (answer: string) => void;
159
+ /**
160
+ * Cancel the question. Resumes with the sentinel `"(dismissed)"` so the
161
+ * server doesn't sit in `waiting_for_local` forever. Idempotent.
162
+ */
163
+ dismiss: () => void;
164
+ config: AgentWidgetConfig;
165
+ }) => HTMLElement | null;
166
+
110
167
  /**
111
168
  * Custom renderer for approval bubbles
112
169
  * Return null to use default renderer
@@ -1,4 +1,4 @@
1
- import { describe, it, expect, beforeEach } from 'vitest';
1
+ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
2
2
  import { AgentWidgetSession, AgentWidgetSessionStatus } from './session';
3
3
  import { AgentWidgetMessage } from './types';
4
4
 
@@ -245,3 +245,278 @@ describe('AgentWidgetSession - Message Injection', () => {
245
245
  });
246
246
  });
247
247
  });
248
+
249
+ describe('AgentWidgetSession - cancel()', () => {
250
+ const originalFetch = global.fetch;
251
+
252
+ afterEach(() => {
253
+ global.fetch = originalFetch;
254
+ vi.restoreAllMocks();
255
+ });
256
+
257
+ it('aborts the in-flight dispatch and flips streaming/status back to idle', async () => {
258
+ let capturedSignal: AbortSignal | null = null;
259
+ // Fetch returns a promise that only settles when the AbortSignal fires —
260
+ // modeling a dispatch that's still receiving SSE tokens.
261
+ global.fetch = vi.fn().mockImplementation((_url: string, options: any) => {
262
+ capturedSignal = options.signal as AbortSignal;
263
+ return new Promise((_resolve, reject) => {
264
+ options.signal?.addEventListener('abort', () => {
265
+ const err = new Error('aborted');
266
+ err.name = 'AbortError';
267
+ reject(err);
268
+ });
269
+ });
270
+ });
271
+
272
+ let streaming = false;
273
+ let status: AgentWidgetSessionStatus = 'idle';
274
+ const session = new AgentWidgetSession(
275
+ { apiUrl: 'http://example.invalid/chat' },
276
+ {
277
+ onMessagesChanged: () => {},
278
+ onStatusChanged: (s) => { status = s; },
279
+ onStreamingChanged: (s) => { streaming = s; }
280
+ }
281
+ );
282
+
283
+ // Kick off the dispatch but don't await — we want it in-flight when we cancel.
284
+ const dispatchPromise = session.sendMessage('Hello');
285
+ // Let the session set up the AbortController and call fetch.
286
+ await Promise.resolve();
287
+ await Promise.resolve();
288
+
289
+ expect(streaming).toBe(true);
290
+ expect(session.isStreaming()).toBe(true);
291
+ expect(capturedSignal).not.toBeNull();
292
+ expect(capturedSignal!.aborted).toBe(false);
293
+
294
+ session.cancel();
295
+
296
+ expect(session.isStreaming()).toBe(false);
297
+ expect(streaming).toBe(false);
298
+ expect(status).toBe('idle');
299
+ expect(capturedSignal!.aborted).toBe(true);
300
+
301
+ // Drain the dispatch promise so the test doesn't leak a rejection.
302
+ await dispatchPromise;
303
+ });
304
+
305
+ it('is a no-op when not streaming', () => {
306
+ const session = new AgentWidgetSession(
307
+ { apiUrl: 'http://example.invalid/chat' },
308
+ {
309
+ onMessagesChanged: () => {},
310
+ onStatusChanged: () => {},
311
+ onStreamingChanged: () => {}
312
+ }
313
+ );
314
+
315
+ expect(session.isStreaming()).toBe(false);
316
+ expect(() => session.cancel()).not.toThrow();
317
+ expect(session.isStreaming()).toBe(false);
318
+ expect(session.getStatus()).toBe('idle');
319
+ });
320
+
321
+ it('stops in-progress audio playback (TTS + voice provider) on cancel', () => {
322
+ const session = new AgentWidgetSession(
323
+ { apiUrl: 'http://example.invalid/chat' },
324
+ {
325
+ onMessagesChanged: () => {},
326
+ onStatusChanged: () => {},
327
+ onStreamingChanged: () => {}
328
+ }
329
+ );
330
+
331
+ const stopSpeakingSpy = vi.spyOn(session, 'stopSpeaking');
332
+ const stopVoicePlaybackSpy = vi.spyOn(session, 'stopVoicePlayback');
333
+
334
+ session.cancel();
335
+
336
+ expect(stopSpeakingSpy).toHaveBeenCalledTimes(1);
337
+ expect(stopVoicePlaybackSpy).toHaveBeenCalledTimes(1);
338
+ });
339
+ });
340
+
341
+ describe('AgentWidgetSession.resolveAskUserQuestion', () => {
342
+ const makeAwaitingMessage = (): AgentWidgetMessage => ({
343
+ id: 'tool-msg-1',
344
+ role: 'assistant',
345
+ content: '',
346
+ createdAt: new Date().toISOString(),
347
+ variant: 'tool',
348
+ streaming: false,
349
+ toolCall: {
350
+ id: 'runtime_ask_user_question_1',
351
+ name: 'ask_user_question',
352
+ status: 'complete',
353
+ args: { questions: [{ question: 'Who?', options: [{ label: 'A' }] }] },
354
+ chunks: [],
355
+ },
356
+ agentMetadata: {
357
+ executionId: 'exec_abc',
358
+ awaitingLocalTool: true,
359
+ },
360
+ });
361
+
362
+ it('POSTs to /resume, appends a user bubble, and pipes the SSE stream through connectStream', async () => {
363
+ let capturedUrl: string | undefined;
364
+ let capturedBody: Record<string, unknown> | undefined;
365
+ global.fetch = vi.fn().mockImplementation(async (url: string, init: RequestInit) => {
366
+ capturedUrl = url;
367
+ capturedBody = JSON.parse(init.body as string);
368
+ const encoder = new TextEncoder();
369
+ const stream = new ReadableStream({
370
+ start(controller) {
371
+ controller.enqueue(encoder.encode('data: {"type":"flow_complete","success":true}\n\n'));
372
+ controller.close();
373
+ },
374
+ });
375
+ return { ok: true, body: stream };
376
+ });
377
+
378
+ const seen: AgentWidgetMessage[] = [];
379
+ const session = new AgentWidgetSession(
380
+ { apiUrl: 'http://localhost:43111/api/chat/dispatch' },
381
+ {
382
+ onMessagesChanged: (msgs) => { seen.splice(0, seen.length, ...msgs); },
383
+ onStatusChanged: () => {},
384
+ onStreamingChanged: () => {},
385
+ }
386
+ );
387
+
388
+ const connectSpy = vi.spyOn(session, 'connectStream').mockResolvedValue(undefined);
389
+ await session.resolveAskUserQuestion(makeAwaitingMessage(), 'Hobbyists');
390
+
391
+ expect(capturedUrl).toBe('http://localhost:43111/api/chat/dispatch/resume');
392
+ expect(capturedBody).toEqual({
393
+ executionId: 'exec_abc',
394
+ toolOutputs: { ["ask_user_question"]: 'Hobbyists' },
395
+ streamResponse: true,
396
+ });
397
+
398
+ const userBubble = seen.find((m) => m.role === 'user' && m.content === 'Hobbyists');
399
+ expect(userBubble).toBeDefined();
400
+
401
+ expect(connectSpy).toHaveBeenCalledTimes(1);
402
+ });
403
+
404
+ it('flips askUserQuestionAnswered on the tool message before the POST fires', async () => {
405
+ const awaiting = makeAwaitingMessage();
406
+
407
+ const fetchMock = vi.fn().mockImplementation(async () => {
408
+ const encoder = new TextEncoder();
409
+ const stream = new ReadableStream({
410
+ start(controller) {
411
+ controller.enqueue(encoder.encode('data: {"type":"flow_complete","success":true}\n\n'));
412
+ controller.close();
413
+ },
414
+ });
415
+ return { ok: true, body: stream };
416
+ });
417
+ global.fetch = fetchMock;
418
+
419
+ let latest: AgentWidgetMessage[] = [];
420
+ const session = new AgentWidgetSession(
421
+ {
422
+ apiUrl: 'http://localhost:43111/api/chat/dispatch',
423
+ initialMessages: [awaiting],
424
+ },
425
+ {
426
+ onMessagesChanged: (msgs) => { latest = msgs; },
427
+ onStatusChanged: () => {},
428
+ onStreamingChanged: () => {},
429
+ }
430
+ );
431
+
432
+ vi.spyOn(session, 'connectStream').mockResolvedValue(undefined);
433
+
434
+ // Capture the flag state at the exact moment fetch is called — this is
435
+ // the "before the POST fires" assertion. The flag must be flipped BEFORE
436
+ // any network I/O so the subsequent stream-driven renders skip the sheet.
437
+ let flagAtFetch: { awaiting?: boolean; answered?: boolean } | undefined;
438
+ fetchMock.mockImplementationOnce(async () => {
439
+ const toolMsg = session.getMessages().find((m) => m.id === awaiting.id);
440
+ flagAtFetch = {
441
+ awaiting: toolMsg?.agentMetadata?.awaitingLocalTool,
442
+ answered: toolMsg?.agentMetadata?.askUserQuestionAnswered,
443
+ };
444
+ const encoder = new TextEncoder();
445
+ const stream = new ReadableStream({
446
+ start(controller) {
447
+ controller.enqueue(encoder.encode('data: {"type":"flow_complete","success":true}\n\n'));
448
+ controller.close();
449
+ },
450
+ });
451
+ return { ok: true, body: stream };
452
+ });
453
+
454
+ await session.resolveAskUserQuestion(awaiting, 'Hobbyists');
455
+
456
+ expect(flagAtFetch).toEqual({ awaiting: false, answered: true });
457
+
458
+ const finalToolMsg = latest.find((m) => m.id === awaiting.id);
459
+ expect(finalToolMsg?.agentMetadata?.askUserQuestionAnswered).toBe(true);
460
+ expect(finalToolMsg?.agentMetadata?.awaitingLocalTool).toBe(false);
461
+ expect(finalToolMsg?.agentMetadata?.executionId).toBe('exec_abc');
462
+ });
463
+
464
+ it('leaves the answered flag flipped even when resume fails', async () => {
465
+ const awaiting = makeAwaitingMessage();
466
+ global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500, json: async () => ({ error: 'boom' }) });
467
+
468
+ const errors: Error[] = [];
469
+ let latest: AgentWidgetMessage[] = [];
470
+ const session = new AgentWidgetSession(
471
+ {
472
+ apiUrl: 'http://localhost:43111/api/chat/dispatch',
473
+ initialMessages: [awaiting],
474
+ },
475
+ {
476
+ onMessagesChanged: (msgs) => { latest = msgs; },
477
+ onStatusChanged: () => {},
478
+ onStreamingChanged: () => {},
479
+ onError: (e) => errors.push(e),
480
+ }
481
+ );
482
+
483
+ await session.resolveAskUserQuestion(awaiting, 'Hobbyists');
484
+
485
+ expect(errors.length).toBe(1);
486
+ const finalToolMsg = latest.find((m) => m.id === awaiting.id);
487
+ expect(finalToolMsg?.agentMetadata?.askUserQuestionAnswered).toBe(true);
488
+ });
489
+
490
+ it('markAskUserQuestionResolved is idempotent and a no-op when the message is not in state', () => {
491
+ const session = new AgentWidgetSession(
492
+ { apiUrl: 'http://localhost:8000' },
493
+ {
494
+ onMessagesChanged: () => {},
495
+ onStatusChanged: () => {},
496
+ onStreamingChanged: () => {},
497
+ }
498
+ );
499
+ // No throw when the tool message isn't tracked in session state
500
+ expect(() => session.markAskUserQuestionResolved(makeAwaitingMessage())).not.toThrow();
501
+ });
502
+
503
+ it('surfaces errors through onError when the message is missing executionId', async () => {
504
+ const errors: Error[] = [];
505
+ const session = new AgentWidgetSession(
506
+ { apiUrl: 'http://localhost:8000' },
507
+ {
508
+ onMessagesChanged: () => {},
509
+ onStatusChanged: () => {},
510
+ onStreamingChanged: () => {},
511
+ onError: (e) => errors.push(e),
512
+ }
513
+ );
514
+
515
+ const bad = makeAwaitingMessage();
516
+ bad.agentMetadata = { ...bad.agentMetadata, executionId: undefined };
517
+
518
+ await session.resolveAskUserQuestion(bad, 'x');
519
+ expect(errors.length).toBe(1);
520
+ expect(errors[0].message).toMatch(/executionId/);
521
+ });
522
+ });