@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.
- package/README.md +142 -0
- package/dist/animations/glyph-cycle.cjs +279 -0
- package/dist/animations/glyph-cycle.d.cts +5 -0
- package/dist/animations/glyph-cycle.d.ts +5 -0
- package/dist/animations/glyph-cycle.js +252 -0
- package/dist/animations/types-cwY5HaFD.d.cts +307 -0
- package/dist/animations/types-cwY5HaFD.d.ts +307 -0
- package/dist/animations/wipe.cjs +107 -0
- package/dist/animations/wipe.d.cts +5 -0
- package/dist/animations/wipe.d.ts +5 -0
- package/dist/animations/wipe.js +80 -0
- package/dist/index.cjs +49 -48
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +504 -1
- package/dist/index.d.ts +504 -1
- package/dist/index.global.js +143 -88
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +49 -48
- package/dist/index.js.map +1 -1
- package/dist/testing.cjs +85 -0
- package/dist/testing.d.cts +39 -0
- package/dist/testing.d.ts +39 -0
- package/dist/testing.js +56 -0
- package/dist/theme-editor.cjs +2095 -207
- package/dist/theme-editor.d.cts +432 -2
- package/dist/theme-editor.d.ts +432 -2
- package/dist/theme-editor.js +2093 -207
- 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 +565 -0
- package/package.json +20 -3
- package/src/animations/glyph-cycle.ts +332 -0
- package/src/animations/wipe.ts +66 -0
- package/src/client.test.ts +275 -0
- package/src/client.ts +99 -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.ts +61 -10
- package/src/components/message-bubble.test.ts +181 -2
- package/src/components/message-bubble.ts +209 -14
- package/src/components/messages.ts +33 -1
- package/src/components/panel.ts +45 -5
- package/src/defaults.ts +37 -0
- package/src/index-global.ts +31 -0
- package/src/index.ts +34 -1
- package/src/plugins/types.ts +57 -0
- package/src/session.test.ts +276 -1
- package/src/session.ts +247 -3
- package/src/styles/widget.css +565 -0
- package/src/testing/index.ts +11 -0
- package/src/testing/mock-stream.test.ts +80 -0
- package/src/testing/mock-stream.ts +94 -0
- package/src/testing.ts +2 -0
- package/src/theme-editor/index.ts +4 -0
- package/src/theme-editor/preview-utils.test.ts +60 -0
- package/src/theme-editor/preview-utils.ts +129 -0
- package/src/theme-editor/sections.test.ts +19 -0
- package/src/theme-editor/sections.ts +84 -1
- package/src/types/theme.ts +15 -0
- package/src/types.ts +360 -0
- package/src/ui.ask-user-question-plugin.test.ts +649 -0
- package/src/ui.stop-button.test.ts +165 -0
- package/src/ui.ts +706 -11
- package/src/utils/message-fingerprint.ts +2 -0
- package/src/utils/morph.ts +7 -0
- package/src/utils/storage.ts +10 -2
- package/src/utils/stream-animation.test.ts +417 -0
- package/src/utils/stream-animation.ts +449 -0
- package/src/utils/theme.test.ts +36 -0
- package/src/utils/tokens.ts +23 -0
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;
|
|
@@ -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
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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";
|
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
|
@@ -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
|
+
});
|