@runtypelabs/persona 3.17.0 → 3.19.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 (61) hide show
  1. package/README.md +143 -1
  2. package/dist/animations/glyph-cycle.d.cts +1 -1
  3. package/dist/animations/glyph-cycle.d.ts +1 -1
  4. package/dist/animations/{types-HPZY7oAI.d.cts → types-cwY5HaFD.d.cts} +25 -0
  5. package/dist/animations/{types-HPZY7oAI.d.ts → types-cwY5HaFD.d.ts} +25 -0
  6. package/dist/animations/wipe.d.cts +1 -1
  7. package/dist/animations/wipe.d.ts +1 -1
  8. package/dist/index.cjs +47 -47
  9. package/dist/index.cjs.map +1 -1
  10. package/dist/index.d.cts +580 -4
  11. package/dist/index.d.ts +580 -4
  12. package/dist/index.global.js +102 -1636
  13. package/dist/index.global.js.map +1 -1
  14. package/dist/index.js +45 -45
  15. package/dist/index.js.map +1 -1
  16. package/dist/theme-editor.cjs +2844 -752
  17. package/dist/theme-editor.d.cts +337 -1
  18. package/dist/theme-editor.d.ts +337 -1
  19. package/dist/theme-editor.js +2958 -752
  20. package/dist/theme-reference.cjs +1 -1
  21. package/dist/theme-reference.d.cts +14 -0
  22. package/dist/theme-reference.d.ts +14 -0
  23. package/dist/widget.css +780 -0
  24. package/package.json +1 -1
  25. package/src/client.test.ts +134 -0
  26. package/src/client.ts +71 -0
  27. package/src/components/ask-user-question-bubble.test.ts +583 -0
  28. package/src/components/ask-user-question-bubble.ts +924 -0
  29. package/src/components/composer-builder.test.ts +52 -0
  30. package/src/components/composer-builder.ts +67 -490
  31. package/src/components/composer-parts.test.ts +152 -0
  32. package/src/components/composer-parts.ts +452 -0
  33. package/src/components/header-builder.ts +22 -299
  34. package/src/components/header-parts.ts +360 -0
  35. package/src/components/messages.ts +33 -1
  36. package/src/components/panel.test.ts +61 -0
  37. package/src/components/panel.ts +303 -9
  38. package/src/components/pill-composer-builder.test.ts +85 -0
  39. package/src/components/pill-composer-builder.ts +183 -0
  40. package/src/defaults.ts +21 -0
  41. package/src/index.ts +20 -1
  42. package/src/plugins/types.ts +57 -0
  43. package/src/runtime/init.ts +4 -2
  44. package/src/runtime/persist-state.test.ts +152 -0
  45. package/src/session.test.ts +183 -0
  46. package/src/session.ts +242 -3
  47. package/src/styles/widget.css +780 -0
  48. package/src/types/theme.ts +15 -0
  49. package/src/types.ts +271 -1
  50. package/src/ui.ask-user-question-plugin.test.ts +649 -0
  51. package/src/ui.component-directive.test.ts +183 -0
  52. package/src/ui.composer-bar.test.ts +1009 -0
  53. package/src/ui.ts +1439 -76
  54. package/src/utils/attachment-manager.ts +1 -1
  55. package/src/utils/dock.test.ts +45 -0
  56. package/src/utils/dock.ts +3 -0
  57. package/src/utils/icons.ts +314 -58
  58. package/src/utils/storage.ts +10 -2
  59. package/src/utils/stream-animation.ts +7 -2
  60. package/src/utils/theme.test.ts +36 -0
  61. package/src/utils/tokens.ts +23 -0
package/src/defaults.ts CHANGED
@@ -143,6 +143,13 @@ export const DEFAULT_WIDGET_CONFIG: Partial<AgentWidgetConfig> = {
143
143
  speed: 120,
144
144
  duration: 1800,
145
145
  },
146
+ askUserQuestion: {
147
+ enabled: true,
148
+ slideInMs: 180,
149
+ freeTextLabel: "Other…",
150
+ freeTextPlaceholder: "Type your answer…",
151
+ submitLabel: "Send",
152
+ },
146
153
  },
147
154
  suggestionChips: [
148
155
  "What can you help me with?",
@@ -259,6 +266,8 @@ export function mergeWithDefaults(
259
266
  const csb = config.features?.scrollToBottom;
260
267
  const dsa = DEFAULT_WIDGET_CONFIG.features?.streamAnimation;
261
268
  const csa = config.features?.streamAnimation;
269
+ const dau = DEFAULT_WIDGET_CONFIG.features?.askUserQuestion;
270
+ const cau = config.features?.askUserQuestion;
262
271
  const mergedArtifacts =
263
272
  da === undefined && ca === undefined
264
273
  ? undefined
@@ -284,12 +293,24 @@ export function mergeWithDefaults(
284
293
  ...dsa,
285
294
  ...csa,
286
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
+ };
287
307
  return {
288
308
  ...DEFAULT_WIDGET_CONFIG.features,
289
309
  ...config.features,
290
310
  ...(mergedScrollToBottom !== undefined ? { scrollToBottom: mergedScrollToBottom } : {}),
291
311
  ...(mergedArtifacts !== undefined ? { artifacts: mergedArtifacts } : {}),
292
312
  ...(mergedStreamAnimation !== undefined ? { streamAnimation: mergedStreamAnimation } : {}),
313
+ ...(mergedAskUserQuestion !== undefined ? { askUserQuestion: mergedAskUserQuestion } : {}),
293
314
  };
294
315
  })(),
295
316
  suggestionChips: config.suggestionChips ?? DEFAULT_WIDGET_CONFIG.suggestionChips,
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,
@@ -200,6 +215,10 @@ export type {
200
215
  export { createDropdownMenu } from "./utils/dropdown";
201
216
  export type { DropdownMenuItem, CreateDropdownOptions, DropdownMenuHandle } from "./utils/dropdown";
202
217
 
218
+ // Icon utility exports
219
+ export { renderLucideIcon } from "./utils/icons";
220
+ export type { IconName } from "./utils/icons";
221
+
203
222
  // Button utility exports
204
223
  export { createIconButton, createLabelButton, createToggleGroup, createComboButton } from "./utils/buttons";
205
224
  export type {
@@ -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,6 +1,6 @@
1
1
  import { createAgentExperience, AgentWidgetController } from "../ui";
2
2
  import { AgentWidgetConfig as _AgentWidgetConfig, AgentWidgetInitOptions, AgentWidgetEvent as _AgentWidgetEvent } from "../types";
3
- import { isDockedMountMode } from "../utils/dock";
3
+ import { isComposerBarMountMode, isDockedMountMode } from "../utils/dock";
4
4
  import { createWidgetHostLayout } from "./host-layout";
5
5
 
6
6
  const ensureTarget = (target: string | HTMLElement): HTMLElement => {
@@ -183,8 +183,10 @@ export const initAgentWidget = (
183
183
  } as _AgentWidgetConfig;
184
184
  const previousDocked = isDockedMountMode(config);
185
185
  const nextDocked = isDockedMountMode(mergedConfig);
186
+ const previousComposerBar = isComposerBarMountMode(config);
187
+ const nextComposerBar = isComposerBarMountMode(mergedConfig);
186
188
 
187
- if (previousDocked !== nextDocked) {
189
+ if (previousDocked !== nextDocked || previousComposerBar !== nextComposerBar) {
188
190
  rebuildLayout(mergedConfig);
189
191
  return;
190
192
  }
@@ -0,0 +1,152 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+
5
+ import { createAgentExperience } from "../ui";
6
+ import { createLocalStorageAdapter } from "../utils/storage";
7
+ import type { AgentWidgetStorageAdapter } from "../types";
8
+
9
+ const DEFAULT_KEY = "persona-state";
10
+
11
+ const baseConfig = () => ({
12
+ apiUrl: "https://api.example.com/chat",
13
+ launcher: { enabled: false } as const,
14
+ });
15
+
16
+ const inject = (controller: ReturnType<typeof createAgentExperience>) =>
17
+ controller.injectAssistantMessage({ content: "hello world" });
18
+
19
+ describe("persistState gates storage adapter", () => {
20
+ afterEach(() => {
21
+ document.body.innerHTML = "";
22
+ try {
23
+ window.localStorage.clear();
24
+ } catch {
25
+ /* jsdom edge cases */
26
+ }
27
+ });
28
+
29
+ it("persistState: false skips the default localStorage adapter", () => {
30
+ const mount = document.createElement("div");
31
+ document.body.appendChild(mount);
32
+
33
+ const controller = createAgentExperience(mount, {
34
+ ...baseConfig(),
35
+ persistState: false,
36
+ });
37
+
38
+ inject(controller);
39
+
40
+ expect(window.localStorage.getItem(DEFAULT_KEY)).toBeNull();
41
+ controller.destroy();
42
+ });
43
+
44
+ it("persistState: false ignores any user-supplied storageAdapter (strict semantic)", () => {
45
+ const mount = document.createElement("div");
46
+ document.body.appendChild(mount);
47
+
48
+ const customAdapter: AgentWidgetStorageAdapter = {
49
+ load: vi.fn(() => null),
50
+ save: vi.fn(),
51
+ clear: vi.fn(),
52
+ };
53
+
54
+ const controller = createAgentExperience(mount, {
55
+ ...baseConfig(),
56
+ persistState: false,
57
+ storageAdapter: customAdapter,
58
+ });
59
+
60
+ inject(controller);
61
+
62
+ expect(customAdapter.load).not.toHaveBeenCalled();
63
+ expect(customAdapter.save).not.toHaveBeenCalled();
64
+ controller.destroy();
65
+ });
66
+
67
+ it("default config (persistState undefined) writes to the default localStorage key", () => {
68
+ const mount = document.createElement("div");
69
+ document.body.appendChild(mount);
70
+
71
+ const controller = createAgentExperience(mount, baseConfig());
72
+
73
+ inject(controller);
74
+
75
+ const stored = window.localStorage.getItem(DEFAULT_KEY);
76
+ expect(stored).not.toBeNull();
77
+ const parsed = JSON.parse(stored!);
78
+ expect(parsed.messages).toBeInstanceOf(Array);
79
+ expect(parsed.messages.length).toBeGreaterThan(0);
80
+ controller.destroy();
81
+ });
82
+
83
+ it("persistState: true keeps using the default localStorage adapter", () => {
84
+ const mount = document.createElement("div");
85
+ document.body.appendChild(mount);
86
+
87
+ const controller = createAgentExperience(mount, {
88
+ ...baseConfig(),
89
+ persistState: true,
90
+ });
91
+
92
+ inject(controller);
93
+
94
+ expect(window.localStorage.getItem(DEFAULT_KEY)).not.toBeNull();
95
+ controller.destroy();
96
+ });
97
+
98
+ it("two widgets with different storageAdapter keys keep their messages isolated", () => {
99
+ const mountA = document.createElement("div");
100
+ const mountB = document.createElement("div");
101
+ document.body.appendChild(mountA);
102
+ document.body.appendChild(mountB);
103
+
104
+ const controllerA = createAgentExperience(mountA, {
105
+ ...baseConfig(),
106
+ storageAdapter: createLocalStorageAdapter("persona-state-test-a"),
107
+ });
108
+ const controllerB = createAgentExperience(mountB, {
109
+ ...baseConfig(),
110
+ storageAdapter: createLocalStorageAdapter("persona-state-test-b"),
111
+ });
112
+
113
+ controllerA.injectAssistantMessage({ content: "message in A" });
114
+ controllerB.injectAssistantMessage({ content: "message in B" });
115
+
116
+ const storedA = JSON.parse(window.localStorage.getItem("persona-state-test-a")!);
117
+ const storedB = JSON.parse(window.localStorage.getItem("persona-state-test-b")!);
118
+
119
+ const aHasA = storedA.messages.some((m: { content?: string }) => m.content === "message in A");
120
+ const aHasB = storedA.messages.some((m: { content?: string }) => m.content === "message in B");
121
+ const bHasA = storedB.messages.some((m: { content?: string }) => m.content === "message in A");
122
+ const bHasB = storedB.messages.some((m: { content?: string }) => m.content === "message in B");
123
+
124
+ expect(aHasA).toBe(true);
125
+ expect(aHasB).toBe(false);
126
+ expect(bHasB).toBe(true);
127
+ expect(bHasA).toBe(false);
128
+ expect(window.localStorage.getItem(DEFAULT_KEY)).toBeNull();
129
+
130
+ controllerA.destroy();
131
+ controllerB.destroy();
132
+ });
133
+
134
+ it("persistState: false does not read from localStorage on init", () => {
135
+ // Pre-seed the default key with a stored message.
136
+ window.localStorage.setItem(
137
+ DEFAULT_KEY,
138
+ JSON.stringify({ messages: [{ id: "stale", role: "assistant", content: "stale" }] })
139
+ );
140
+
141
+ const mount = document.createElement("div");
142
+ document.body.appendChild(mount);
143
+
144
+ const controller = createAgentExperience(mount, {
145
+ ...baseConfig(),
146
+ persistState: false,
147
+ });
148
+
149
+ expect(controller.getMessages()).toEqual([]);
150
+ controller.destroy();
151
+ });
152
+ });
@@ -337,3 +337,186 @@ describe('AgentWidgetSession - cancel()', () => {
337
337
  expect(stopVoicePlaybackSpy).toHaveBeenCalledTimes(1);
338
338
  });
339
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
+ });