@runtypelabs/persona 3.17.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.
- package/README.md +142 -0
- 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 +300 -1
- package/dist/index.d.ts +300 -1
- package/dist/index.global.js +75 -75
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +47 -47
- package/dist/index.js.map +1 -1
- package/dist/theme-editor.cjs +1432 -159
- package/dist/theme-editor.d.cts +218 -0
- package/dist/theme-editor.d.ts +218 -0
- package/dist/theme-editor.js +1432 -159
- 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 +432 -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/messages.ts +33 -1
- package/src/components/panel.ts +41 -4
- package/src/defaults.ts +21 -0
- package/src/index.ts +16 -1
- package/src/plugins/types.ts +57 -0
- package/src/session.test.ts +183 -0
- package/src/session.ts +242 -3
- package/src/styles/widget.css +432 -0
- package/src/types/theme.ts +15 -0
- package/src/types.ts +150 -0
- package/src/ui.ask-user-question-plugin.test.ts +649 -0
- package/src/ui.ts +631 -5
- package/src/utils/storage.ts +10 -2
- package/src/utils/theme.test.ts +36 -0
- package/src/utils/tokens.ts +23 -0
|
@@ -4,6 +4,11 @@ import { MessageTransform, MessageActionCallbacks } from "./message-bubble";
|
|
|
4
4
|
import { createStandardBubble } from "./message-bubble";
|
|
5
5
|
import { createReasoningBubble } from "./reasoning-bubble";
|
|
6
6
|
import { createToolBubble } from "./tool-bubble";
|
|
7
|
+
import {
|
|
8
|
+
ensureAskUserQuestionSheet,
|
|
9
|
+
isAskUserQuestionMessage,
|
|
10
|
+
removeAskUserQuestionSheet,
|
|
11
|
+
} from "./ask-user-question-bubble";
|
|
7
12
|
|
|
8
13
|
export const renderMessages = (
|
|
9
14
|
container: HTMLElement,
|
|
@@ -12,16 +17,29 @@ export const renderMessages = (
|
|
|
12
17
|
showReasoning: boolean,
|
|
13
18
|
showToolCalls: boolean,
|
|
14
19
|
config?: AgentWidgetConfig,
|
|
15
|
-
actionCallbacks?: MessageActionCallbacks
|
|
20
|
+
actionCallbacks?: MessageActionCallbacks,
|
|
21
|
+
composerOverlay?: HTMLElement | null
|
|
16
22
|
) => {
|
|
17
23
|
container.innerHTML = "";
|
|
18
24
|
const fragment = createFragment();
|
|
19
25
|
|
|
26
|
+
// Track which ask_user_question tool-call ids are currently in the message
|
|
27
|
+
// list, so we can prune stale sheets from the overlay afterward.
|
|
28
|
+
const liveAskToolIds = new Set<string>();
|
|
29
|
+
|
|
20
30
|
messages.forEach((message) => {
|
|
21
31
|
let bubble: HTMLElement;
|
|
22
32
|
if (message.variant === "reasoning" && message.reasoning) {
|
|
23
33
|
if (!showReasoning) return;
|
|
24
34
|
bubble = createReasoningBubble(message, config);
|
|
35
|
+
} else if (isAskUserQuestionMessage(message)) {
|
|
36
|
+
// No transcript bubble — the overlay sheet is the only question UI.
|
|
37
|
+
if (config?.features?.askUserQuestion?.enabled === false) return;
|
|
38
|
+
if (!message.agentMetadata?.askUserQuestionAnswered) {
|
|
39
|
+
if (message.toolCall?.id) liveAskToolIds.add(message.toolCall.id);
|
|
40
|
+
ensureAskUserQuestionSheet(message, config, composerOverlay ?? null);
|
|
41
|
+
}
|
|
42
|
+
return;
|
|
25
43
|
} else if (message.variant === "tool" && message.toolCall) {
|
|
26
44
|
if (!showToolCalls) return;
|
|
27
45
|
bubble = createToolBubble(message, config);
|
|
@@ -45,6 +63,20 @@ export const renderMessages = (
|
|
|
45
63
|
|
|
46
64
|
container.appendChild(fragment);
|
|
47
65
|
container.scrollTop = container.scrollHeight;
|
|
66
|
+
|
|
67
|
+
// Clean up any orphaned ask_user_question sheets whose source message is no
|
|
68
|
+
// longer in the list (e.g. after clearChat or a message splice).
|
|
69
|
+
if (composerOverlay) {
|
|
70
|
+
const sheets = composerOverlay.querySelectorAll<HTMLElement>(
|
|
71
|
+
'[data-persona-ask-sheet-for]'
|
|
72
|
+
);
|
|
73
|
+
sheets.forEach((sheet) => {
|
|
74
|
+
const id = sheet.getAttribute('data-persona-ask-sheet-for');
|
|
75
|
+
if (id && !liveAskToolIds.has(id)) {
|
|
76
|
+
removeAskUserQuestionSheet(composerOverlay, id);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
}
|
|
48
80
|
};
|
|
49
81
|
|
|
50
82
|
|
package/src/components/panel.ts
CHANGED
|
@@ -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;
|
|
@@ -138,10 +144,17 @@ export const buildPanel = (config?: AgentWidgetConfig, showClose = true): PanelE
|
|
|
138
144
|
body.id = "persona-scroll-container";
|
|
139
145
|
body.setAttribute("data-persona-theme-zone", "messages");
|
|
140
146
|
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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))";
|
|
145
158
|
const introTitle = createElement(
|
|
146
159
|
"h2",
|
|
147
160
|
"persona-text-lg persona-font-semibold persona-text-persona-primary"
|
|
@@ -193,6 +206,26 @@ export const buildPanel = (config?: AgentWidgetConfig, showClose = true): PanelE
|
|
|
193
206
|
|
|
194
207
|
container.append(body);
|
|
195
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
|
+
|
|
196
229
|
if (showFooter) {
|
|
197
230
|
container.append(composerElements.footer);
|
|
198
231
|
} else {
|
|
@@ -201,10 +234,14 @@ export const buildPanel = (config?: AgentWidgetConfig, showClose = true): PanelE
|
|
|
201
234
|
container.append(composerElements.footer);
|
|
202
235
|
}
|
|
203
236
|
|
|
237
|
+
// Append overlay last so it stacks above the footer / body content.
|
|
238
|
+
container.append(composerOverlay);
|
|
239
|
+
|
|
204
240
|
return {
|
|
205
241
|
container,
|
|
206
242
|
body,
|
|
207
243
|
messagesWrapper,
|
|
244
|
+
composerOverlay,
|
|
208
245
|
suggestions: composerElements.suggestions,
|
|
209
246
|
textarea: composerElements.textarea,
|
|
210
247
|
sendButton: composerElements.sendButton,
|
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,
|
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/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
|
+
});
|