@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.
- package/README.md +143 -1
- package/dist/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/{types-HPZY7oAI.d.cts → types-cwY5HaFD.d.cts} +25 -0
- package/dist/animations/{types-HPZY7oAI.d.ts → types-cwY5HaFD.d.ts} +25 -0
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/index.cjs +47 -47
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +580 -4
- package/dist/index.d.ts +580 -4
- package/dist/index.global.js +102 -1636
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +45 -45
- package/dist/index.js.map +1 -1
- package/dist/theme-editor.cjs +2844 -752
- package/dist/theme-editor.d.cts +337 -1
- package/dist/theme-editor.d.ts +337 -1
- package/dist/theme-editor.js +2958 -752
- package/dist/theme-reference.cjs +1 -1
- package/dist/theme-reference.d.cts +14 -0
- package/dist/theme-reference.d.ts +14 -0
- package/dist/widget.css +780 -0
- package/package.json +1 -1
- package/src/client.test.ts +134 -0
- package/src/client.ts +71 -0
- package/src/components/ask-user-question-bubble.test.ts +583 -0
- package/src/components/ask-user-question-bubble.ts +924 -0
- package/src/components/composer-builder.test.ts +52 -0
- package/src/components/composer-builder.ts +67 -490
- package/src/components/composer-parts.test.ts +152 -0
- package/src/components/composer-parts.ts +452 -0
- package/src/components/header-builder.ts +22 -299
- package/src/components/header-parts.ts +360 -0
- package/src/components/messages.ts +33 -1
- package/src/components/panel.test.ts +61 -0
- package/src/components/panel.ts +303 -9
- package/src/components/pill-composer-builder.test.ts +85 -0
- package/src/components/pill-composer-builder.ts +183 -0
- package/src/defaults.ts +21 -0
- package/src/index.ts +20 -1
- package/src/plugins/types.ts +57 -0
- package/src/runtime/init.ts +4 -2
- package/src/runtime/persist-state.test.ts +152 -0
- package/src/session.test.ts +183 -0
- package/src/session.ts +242 -3
- package/src/styles/widget.css +780 -0
- package/src/types/theme.ts +15 -0
- package/src/types.ts +271 -1
- package/src/ui.ask-user-question-plugin.test.ts +649 -0
- package/src/ui.component-directive.test.ts +183 -0
- package/src/ui.composer-bar.test.ts +1009 -0
- package/src/ui.ts +1439 -76
- package/src/utils/attachment-manager.ts +1 -1
- package/src/utils/dock.test.ts +45 -0
- package/src/utils/dock.ts +3 -0
- package/src/utils/icons.ts +314 -58
- package/src/utils/storage.ts +10 -2
- package/src/utils/stream-animation.ts +7 -2
- package/src/utils/theme.test.ts +36 -0
- 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 {
|
package/src/plugins/types.ts
CHANGED
|
@@ -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
|
package/src/runtime/init.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/session.test.ts
CHANGED
|
@@ -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
|
+
});
|