@runtypelabs/persona 3.16.0 → 3.17.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/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-HPZY7oAI.d.cts +282 -0
- package/dist/animations/types-HPZY7oAI.d.ts +282 -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 +48 -47
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +205 -1
- package/dist/index.d.ts +205 -1
- package/dist/index.global.js +136 -81
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +48 -47
- 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 +714 -99
- package/dist/theme-editor.d.cts +214 -2
- package/dist/theme-editor.d.ts +214 -2
- package/dist/theme-editor.js +712 -99
- package/dist/widget.css +133 -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 +141 -0
- package/src/client.ts +28 -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/panel.ts +4 -1
- package/src/defaults.ts +16 -0
- package/src/index-global.ts +31 -0
- package/src/index.ts +18 -0
- package/src/session.test.ts +93 -1
- package/src/session.ts +5 -0
- package/src/styles/widget.css +133 -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.ts +210 -0
- package/src/ui.stop-button.test.ts +165 -0
- package/src/ui.ts +75 -6
- package/src/utils/message-fingerprint.ts +2 -0
- package/src/utils/morph.ts +7 -0
- package/src/utils/stream-animation.test.ts +417 -0
- package/src/utils/stream-animation.ts +449 -0
package/src/client.test.ts
CHANGED
|
@@ -2576,3 +2576,144 @@ describe('AgentWidgetClient - Out-of-Order Sequence Reordering', () => {
|
|
|
2576
2576
|
});
|
|
2577
2577
|
});
|
|
2578
2578
|
|
|
2579
|
+
// ============================================================================
|
|
2580
|
+
// stopReason wiring (agent_turn_complete / step_complete)
|
|
2581
|
+
// ============================================================================
|
|
2582
|
+
|
|
2583
|
+
describe('AgentWidgetClient - stopReason propagation', () => {
|
|
2584
|
+
const dispatchModeStream = (stopReason?: string) => {
|
|
2585
|
+
const data: Record<string, unknown> = {
|
|
2586
|
+
type: 'step_complete',
|
|
2587
|
+
id: 'step_1',
|
|
2588
|
+
stepType: 'prompt',
|
|
2589
|
+
result: { response: 'Hello there.' },
|
|
2590
|
+
};
|
|
2591
|
+
if (stopReason) data.stopReason = stopReason;
|
|
2592
|
+
return [
|
|
2593
|
+
`data: ${JSON.stringify(data)}\n\n`,
|
|
2594
|
+
`data: ${JSON.stringify({ type: 'flow_complete', success: true })}\n\n`,
|
|
2595
|
+
];
|
|
2596
|
+
};
|
|
2597
|
+
|
|
2598
|
+
const collectFinalAssistant = (events: AgentWidgetEvent[]): AgentWidgetMessage | null => {
|
|
2599
|
+
const messageEvents = events.filter(e => e.type === 'message');
|
|
2600
|
+
for (let i = messageEvents.length - 1; i >= 0; i--) {
|
|
2601
|
+
const ev = messageEvents[i];
|
|
2602
|
+
if (ev.type === 'message' && ev.message.role === 'assistant' && !ev.message.streaming) {
|
|
2603
|
+
return ev.message;
|
|
2604
|
+
}
|
|
2605
|
+
}
|
|
2606
|
+
return null;
|
|
2607
|
+
};
|
|
2608
|
+
|
|
2609
|
+
const runDispatch = async (chunks: string[]): Promise<AgentWidgetEvent[]> => {
|
|
2610
|
+
global.fetch = vi.fn().mockImplementation(async (_url: string, _options: any) => {
|
|
2611
|
+
const encoder = new TextEncoder();
|
|
2612
|
+
const stream = new ReadableStream({
|
|
2613
|
+
start(controller) {
|
|
2614
|
+
for (const chunk of chunks) controller.enqueue(encoder.encode(chunk));
|
|
2615
|
+
controller.close();
|
|
2616
|
+
}
|
|
2617
|
+
});
|
|
2618
|
+
return { ok: true, body: stream };
|
|
2619
|
+
});
|
|
2620
|
+
const client = new AgentWidgetClient({ apiUrl: 'http://localhost:8000' });
|
|
2621
|
+
const events: AgentWidgetEvent[] = [];
|
|
2622
|
+
await client.dispatch(
|
|
2623
|
+
{ messages: [{ id: 'usr_1', role: 'user', content: 'Hi', createdAt: new Date().toISOString() }] },
|
|
2624
|
+
(e) => events.push(e)
|
|
2625
|
+
);
|
|
2626
|
+
return events;
|
|
2627
|
+
};
|
|
2628
|
+
|
|
2629
|
+
it.each(['end_turn', 'max_tool_calls', 'length', 'content_filter', 'error', 'unknown'] as const)(
|
|
2630
|
+
'attaches stopReason=%s from step_complete (dispatch / flow path)',
|
|
2631
|
+
async (stopReason) => {
|
|
2632
|
+
const events = await runDispatch(dispatchModeStream(stopReason));
|
|
2633
|
+
const final = collectFinalAssistant(events);
|
|
2634
|
+
expect(final).not.toBeNull();
|
|
2635
|
+
expect(final!.stopReason).toBe(stopReason);
|
|
2636
|
+
}
|
|
2637
|
+
);
|
|
2638
|
+
|
|
2639
|
+
it('leaves stopReason undefined when step_complete omits it (backcompat)', async () => {
|
|
2640
|
+
const events = await runDispatch(dispatchModeStream(undefined));
|
|
2641
|
+
const final = collectFinalAssistant(events);
|
|
2642
|
+
expect(final).not.toBeNull();
|
|
2643
|
+
expect(final!.stopReason).toBeUndefined();
|
|
2644
|
+
});
|
|
2645
|
+
|
|
2646
|
+
it('captures the empty-content + max_tool_calls regression case', async () => {
|
|
2647
|
+
// Symptom the upstream fix targets: model emits a tool call then gets cut
|
|
2648
|
+
// off before producing follow-up text. Persona must record stopReason so
|
|
2649
|
+
// the UI can render an affordance instead of an empty bubble.
|
|
2650
|
+
const events = await runDispatch([
|
|
2651
|
+
`data: ${JSON.stringify({
|
|
2652
|
+
type: 'step_complete',
|
|
2653
|
+
id: 'step_1',
|
|
2654
|
+
stepType: 'prompt',
|
|
2655
|
+
result: { response: '' },
|
|
2656
|
+
stopReason: 'max_tool_calls',
|
|
2657
|
+
})}\n\n`,
|
|
2658
|
+
`data: ${JSON.stringify({ type: 'flow_complete', success: true })}\n\n`,
|
|
2659
|
+
]);
|
|
2660
|
+
const final = collectFinalAssistant(events);
|
|
2661
|
+
expect(final).not.toBeNull();
|
|
2662
|
+
expect(final!.content).toBe('');
|
|
2663
|
+
expect(final!.stopReason).toBe('max_tool_calls');
|
|
2664
|
+
});
|
|
2665
|
+
|
|
2666
|
+
it('agent_turn_complete.stopReason overrides any earlier step_complete value (agent-loop path)', async () => {
|
|
2667
|
+
// Build an agent-mode stream that emits both events. agent_turn_complete
|
|
2668
|
+
// arrives last; its stopReason should win.
|
|
2669
|
+
const execId = 'exec_stopreason';
|
|
2670
|
+
global.fetch = createAgentStreamFetch([
|
|
2671
|
+
sseEvent('agent_start', {
|
|
2672
|
+
executionId: execId, agentId: 'virtual', agentName: 'Test',
|
|
2673
|
+
maxTurns: 1, startedAt: new Date().toISOString(), seq: 1,
|
|
2674
|
+
}),
|
|
2675
|
+
sseEvent('agent_iteration_start', {
|
|
2676
|
+
executionId: execId, iteration: 1, maxTurns: 1,
|
|
2677
|
+
startedAt: new Date().toISOString(), seq: 2,
|
|
2678
|
+
}),
|
|
2679
|
+
sseEvent('agent_turn_start', {
|
|
2680
|
+
executionId: execId, iteration: 1, turnIndex: 0,
|
|
2681
|
+
role: 'assistant', turnId: 'turn_1', seq: 3,
|
|
2682
|
+
}),
|
|
2683
|
+
sseEvent('agent_turn_delta', {
|
|
2684
|
+
executionId: execId, iteration: 1, delta: 'partial answer',
|
|
2685
|
+
contentType: 'text', turnId: 'turn_1', seq: 4,
|
|
2686
|
+
}),
|
|
2687
|
+
sseEvent('agent_turn_complete', {
|
|
2688
|
+
executionId: execId, iteration: 1, role: 'assistant',
|
|
2689
|
+
turnId: 'turn_1', completedAt: new Date().toISOString(),
|
|
2690
|
+
stopReason: 'max_tool_calls', seq: 5,
|
|
2691
|
+
}),
|
|
2692
|
+
sseEvent('agent_iteration_complete', {
|
|
2693
|
+
executionId: execId, iteration: 1, toolCallsMade: 0,
|
|
2694
|
+
stopConditionMet: true, completedAt: new Date().toISOString(), seq: 6,
|
|
2695
|
+
}),
|
|
2696
|
+
sseEvent('agent_complete', {
|
|
2697
|
+
executionId: execId, agentId: 'virtual', success: true,
|
|
2698
|
+
iterations: 1, stopReason: 'max_iterations',
|
|
2699
|
+
completedAt: new Date().toISOString(), seq: 7,
|
|
2700
|
+
}),
|
|
2701
|
+
]);
|
|
2702
|
+
|
|
2703
|
+
const client = new AgentWidgetClient({
|
|
2704
|
+
apiUrl: 'http://localhost:8000',
|
|
2705
|
+
agent: { name: 'Test', model: 'openai:gpt-4o-mini', systemPrompt: 'test' },
|
|
2706
|
+
});
|
|
2707
|
+
const events: AgentWidgetEvent[] = [];
|
|
2708
|
+
await client.dispatch(
|
|
2709
|
+
{ messages: [{ id: 'usr_1', role: 'user', content: 'Hi', createdAt: new Date().toISOString() }] },
|
|
2710
|
+
(e) => events.push(e)
|
|
2711
|
+
);
|
|
2712
|
+
|
|
2713
|
+
const final = collectFinalAssistant(events);
|
|
2714
|
+
expect(final).not.toBeNull();
|
|
2715
|
+
expect(final!.stopReason).toBe('max_tool_calls');
|
|
2716
|
+
expect(final!.agentMetadata?.turnId).toBe('turn_1');
|
|
2717
|
+
});
|
|
2718
|
+
});
|
|
2719
|
+
|
package/src/client.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
AgentWidgetHeadersFunction,
|
|
14
14
|
AgentWidgetSSEEventResult as _AgentWidgetSSEEventResult,
|
|
15
15
|
AgentExecutionState,
|
|
16
|
+
StopReasonKind,
|
|
16
17
|
ClientSession,
|
|
17
18
|
ClientInitResponse,
|
|
18
19
|
ClientChatRequest,
|
|
@@ -2094,11 +2095,20 @@ export class AgentWidgetClient {
|
|
|
2094
2095
|
continue;
|
|
2095
2096
|
}
|
|
2096
2097
|
|
|
2098
|
+
// Capture optional per-step stopReason emitted by the runtime
|
|
2099
|
+
// (e.g. `'max_tool_calls'`, `'length'`). This is the dispatch-mode
|
|
2100
|
+
// fallback — `agent_turn_complete` will overwrite it later in
|
|
2101
|
+
// agent-loop streams.
|
|
2102
|
+
const stepStopReason = (payload as any).stopReason as
|
|
2103
|
+
| StopReasonKind
|
|
2104
|
+
| undefined;
|
|
2105
|
+
|
|
2097
2106
|
if (didSplitByPartId) {
|
|
2098
2107
|
// Sealed segment(s) — do not create a second bubble from step_complete.
|
|
2099
2108
|
// Merge authoritative final response into the last sealed segment (fixes async lag).
|
|
2100
2109
|
if (assistantMessage !== null) {
|
|
2101
2110
|
const msg: AgentWidgetMessage = assistantMessage;
|
|
2111
|
+
if (stepStopReason) msg.stopReason = stepStopReason;
|
|
2102
2112
|
streamParsers.delete(msg.id);
|
|
2103
2113
|
rawContentBuffers.delete(msg.id);
|
|
2104
2114
|
if (msg.streaming !== false) {
|
|
@@ -2109,6 +2119,7 @@ export class AgentWidgetClient {
|
|
|
2109
2119
|
const splitFinalContent = payload.result?.response;
|
|
2110
2120
|
const sealedForReconcile = lastSealedTextSegment;
|
|
2111
2121
|
if (sealedForReconcile) {
|
|
2122
|
+
if (stepStopReason) sealedForReconcile.stopReason = stepStopReason;
|
|
2112
2123
|
if (splitFinalContent !== undefined && splitFinalContent !== null) {
|
|
2113
2124
|
reconcileSealedAssistantWithFinalResponse(sealedForReconcile, splitFinalContent);
|
|
2114
2125
|
} else {
|
|
@@ -2121,6 +2132,7 @@ export class AgentWidgetClient {
|
|
|
2121
2132
|
}
|
|
2122
2133
|
const finalContent = payload.result?.response;
|
|
2123
2134
|
const assistant = ensureAssistantMessage();
|
|
2135
|
+
if (stepStopReason) assistant.stopReason = stepStopReason;
|
|
2124
2136
|
if (finalContent !== undefined && finalContent !== null) {
|
|
2125
2137
|
// Check if we already have extracted text from streaming
|
|
2126
2138
|
const parser = streamParsers.get(assistant.id);
|
|
@@ -2408,6 +2420,22 @@ export class AgentWidgetClient {
|
|
|
2408
2420
|
emitMessage(reasoningMessage);
|
|
2409
2421
|
}
|
|
2410
2422
|
}
|
|
2423
|
+
|
|
2424
|
+
// Attach the turn-level stopReason to the assistant message
|
|
2425
|
+
// produced by this turn. Only overwrite the current message —
|
|
2426
|
+
// prior turns already sealed their own stopReason via step_complete.
|
|
2427
|
+
const turnStopReason = (payload as any).stopReason as
|
|
2428
|
+
| StopReasonKind
|
|
2429
|
+
| undefined;
|
|
2430
|
+
if (turnStopReason && assistantMessage !== null) {
|
|
2431
|
+
const turnId = payload.turnId;
|
|
2432
|
+
const matchesTurn =
|
|
2433
|
+
!turnId || assistantMessage.agentMetadata?.turnId === turnId;
|
|
2434
|
+
if (matchesTurn) {
|
|
2435
|
+
assistantMessage.stopReason = turnStopReason;
|
|
2436
|
+
emitMessage(assistantMessage);
|
|
2437
|
+
}
|
|
2438
|
+
}
|
|
2411
2439
|
} else if (payloadType === "agent_tool_start") {
|
|
2412
2440
|
const toolId = payload.toolCallId ?? `agent-tool-${nextSequence()}`;
|
|
2413
2441
|
trackToolId(getToolCallKey(payload), toolId);
|
|
@@ -22,6 +22,13 @@ export interface ComposerElements {
|
|
|
22
22
|
actionsRow: HTMLElement;
|
|
23
23
|
leftActions: HTMLElement;
|
|
24
24
|
rightActions: HTMLElement;
|
|
25
|
+
/**
|
|
26
|
+
* Swap the send button between its idle ("send") appearance and its
|
|
27
|
+
* streaming ("stop") appearance. In icon mode this swaps the SVG; in text
|
|
28
|
+
* mode it swaps the button label. Tooltip text is updated when a tooltip
|
|
29
|
+
* element is present.
|
|
30
|
+
*/
|
|
31
|
+
setSendButtonMode: (mode: "send" | "stop") => void;
|
|
25
32
|
}
|
|
26
33
|
|
|
27
34
|
/**
|
|
@@ -122,7 +129,11 @@ export const buildComposer = (context: ComposerBuildContext): ComposerElements =
|
|
|
122
129
|
const useIcon = sendButtonConfig.useIcon ?? false;
|
|
123
130
|
const iconText = sendButtonConfig.iconText ?? "↑";
|
|
124
131
|
const iconName = sendButtonConfig.iconName;
|
|
132
|
+
const stopIconName = sendButtonConfig.stopIconName ?? "square";
|
|
125
133
|
const tooltipText = sendButtonConfig.tooltipText ?? "Send message";
|
|
134
|
+
const stopTooltipText = sendButtonConfig.stopTooltipText ?? "Stop generating";
|
|
135
|
+
const sendLabel = config?.copy?.sendButtonLabel ?? "Send";
|
|
136
|
+
const stopLabel = config?.copy?.stopButtonLabel ?? "Stop";
|
|
126
137
|
const showTooltip = sendButtonConfig.showTooltip ?? false;
|
|
127
138
|
const buttonSize = sendButtonConfig.size ?? "40px";
|
|
128
139
|
const backgroundColor = sendButtonConfig.backgroundColor;
|
|
@@ -141,6 +152,11 @@ export const buildComposer = (context: ComposerBuildContext): ComposerElements =
|
|
|
141
152
|
sendButton.type = "submit";
|
|
142
153
|
sendButton.setAttribute("data-persona-composer-submit", "");
|
|
143
154
|
|
|
155
|
+
// Icons for both modes are pre-rendered so setSendButtonMode can swap them
|
|
156
|
+
// without having to re-render on every streaming state change.
|
|
157
|
+
let sendIcon: SVGElement | null = null;
|
|
158
|
+
let stopIcon: SVGElement | null = null;
|
|
159
|
+
|
|
144
160
|
if (useIcon) {
|
|
145
161
|
// Icon mode: circular button
|
|
146
162
|
sendButton.style.width = buttonSize;
|
|
@@ -160,13 +176,14 @@ export const buildComposer = (context: ComposerBuildContext): ComposerElements =
|
|
|
160
176
|
sendButton.style.color = "var(--persona-button-primary-fg, #ffffff)";
|
|
161
177
|
}
|
|
162
178
|
|
|
179
|
+
const iconSize = parseFloat(buttonSize) || 24;
|
|
180
|
+
const iconColor = textColor?.trim() || "currentColor";
|
|
181
|
+
|
|
163
182
|
// Use Lucide icon if iconName is provided, otherwise fall back to iconText
|
|
164
183
|
if (iconName) {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
if (iconSvg) {
|
|
169
|
-
sendButton.appendChild(iconSvg);
|
|
184
|
+
sendIcon = renderLucideIcon(iconName, iconSize, iconColor, 2);
|
|
185
|
+
if (sendIcon) {
|
|
186
|
+
sendButton.appendChild(sendIcon);
|
|
170
187
|
} else {
|
|
171
188
|
sendButton.textContent = iconText;
|
|
172
189
|
}
|
|
@@ -174,6 +191,9 @@ export const buildComposer = (context: ComposerBuildContext): ComposerElements =
|
|
|
174
191
|
sendButton.textContent = iconText;
|
|
175
192
|
}
|
|
176
193
|
|
|
194
|
+
// Pre-render the stop icon so mode swaps are cheap; it starts detached.
|
|
195
|
+
stopIcon = renderLucideIcon(stopIconName, iconSize, iconColor, 2);
|
|
196
|
+
|
|
177
197
|
if (backgroundColor) {
|
|
178
198
|
sendButton.style.backgroundColor = backgroundColor;
|
|
179
199
|
} else {
|
|
@@ -181,7 +201,7 @@ export const buildComposer = (context: ComposerBuildContext): ComposerElements =
|
|
|
181
201
|
}
|
|
182
202
|
} else {
|
|
183
203
|
// Text mode: existing behavior
|
|
184
|
-
sendButton.textContent =
|
|
204
|
+
sendButton.textContent = sendLabel;
|
|
185
205
|
if (textColor) {
|
|
186
206
|
sendButton.style.color = textColor;
|
|
187
207
|
} else {
|
|
@@ -215,14 +235,44 @@ export const buildComposer = (context: ComposerBuildContext): ComposerElements =
|
|
|
215
235
|
}
|
|
216
236
|
|
|
217
237
|
// Add tooltip if enabled
|
|
238
|
+
let sendTooltip: HTMLElement | null = null;
|
|
218
239
|
if (showTooltip && tooltipText) {
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
sendButtonWrapper.appendChild(
|
|
240
|
+
sendTooltip = createElement("div", "persona-send-button-tooltip");
|
|
241
|
+
sendTooltip.textContent = tooltipText;
|
|
242
|
+
sendButtonWrapper.appendChild(sendTooltip);
|
|
222
243
|
}
|
|
223
244
|
|
|
245
|
+
sendButton.setAttribute("aria-label", tooltipText);
|
|
246
|
+
|
|
224
247
|
sendButtonWrapper.appendChild(sendButton);
|
|
225
248
|
|
|
249
|
+
let currentMode: "send" | "stop" = "send";
|
|
250
|
+
const setSendButtonMode = (mode: "send" | "stop") => {
|
|
251
|
+
if (mode === currentMode) return;
|
|
252
|
+
currentMode = mode;
|
|
253
|
+
const label = mode === "stop" ? stopTooltipText : tooltipText;
|
|
254
|
+
sendButton.setAttribute("aria-label", label);
|
|
255
|
+
if (sendTooltip) {
|
|
256
|
+
sendTooltip.textContent = label;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (useIcon) {
|
|
260
|
+
// Only swap icons if both were rendered successfully; otherwise the
|
|
261
|
+
// button is using textContent fallback and there's nothing to swap.
|
|
262
|
+
if (sendIcon && stopIcon) {
|
|
263
|
+
const next = mode === "stop" ? stopIcon : sendIcon;
|
|
264
|
+
const prev = mode === "stop" ? sendIcon : stopIcon;
|
|
265
|
+
if (prev.parentNode === sendButton) {
|
|
266
|
+
sendButton.replaceChild(next, prev);
|
|
267
|
+
} else {
|
|
268
|
+
sendButton.appendChild(next);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
} else {
|
|
272
|
+
sendButton.textContent = mode === "stop" ? stopLabel : sendLabel;
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
|
|
226
276
|
// Voice recognition mic button
|
|
227
277
|
const voiceRecognitionConfig = config?.voiceRecognition ?? {};
|
|
228
278
|
const voiceRecognitionEnabled = voiceRecognitionConfig.enabled === true;
|
|
@@ -515,7 +565,8 @@ export const buildComposer = (context: ComposerBuildContext): ComposerElements =
|
|
|
515
565
|
// Actions row layout elements
|
|
516
566
|
actionsRow,
|
|
517
567
|
leftActions,
|
|
518
|
-
rightActions
|
|
568
|
+
rightActions,
|
|
569
|
+
setSendButtonMode
|
|
519
570
|
};
|
|
520
571
|
};
|
|
521
572
|
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
2
|
import { describe, it, expect } from "vitest";
|
|
3
|
-
import {
|
|
4
|
-
|
|
3
|
+
import {
|
|
4
|
+
createStandardBubble,
|
|
5
|
+
isSafeImageSrc,
|
|
6
|
+
resolveStopReasonNoticeText,
|
|
7
|
+
getDefaultStopReasonNoticeCopy,
|
|
8
|
+
} from "./message-bubble";
|
|
9
|
+
import type { AgentWidgetConfig, AgentWidgetMessage } from "../types";
|
|
5
10
|
|
|
6
11
|
const makeMessage = (overrides: Partial<AgentWidgetMessage> = {}): AgentWidgetMessage => ({
|
|
7
12
|
id: "msg-1",
|
|
@@ -95,3 +100,177 @@ describe("createStandardBubble", () => {
|
|
|
95
100
|
expect(previewImages[0]?.getAttribute("alt")).toBe("Safe image");
|
|
96
101
|
});
|
|
97
102
|
});
|
|
103
|
+
|
|
104
|
+
describe("resolveStopReasonNoticeText", () => {
|
|
105
|
+
it("returns null for natural completions", () => {
|
|
106
|
+
expect(resolveStopReasonNoticeText("end_turn")).toBeNull();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("returns null for unknown reasons", () => {
|
|
110
|
+
expect(resolveStopReasonNoticeText("unknown")).toBeNull();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("returns null when stopReason is undefined", () => {
|
|
114
|
+
expect(resolveStopReasonNoticeText(undefined)).toBeNull();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("returns the default copy for actionable reasons", () => {
|
|
118
|
+
expect(resolveStopReasonNoticeText("max_tool_calls")).toBe(
|
|
119
|
+
getDefaultStopReasonNoticeCopy("max_tool_calls")
|
|
120
|
+
);
|
|
121
|
+
expect(resolveStopReasonNoticeText("length")).toBe(
|
|
122
|
+
getDefaultStopReasonNoticeCopy("length")
|
|
123
|
+
);
|
|
124
|
+
expect(resolveStopReasonNoticeText("content_filter")).toBe(
|
|
125
|
+
getDefaultStopReasonNoticeCopy("content_filter")
|
|
126
|
+
);
|
|
127
|
+
expect(resolveStopReasonNoticeText("error")).toBe(
|
|
128
|
+
getDefaultStopReasonNoticeCopy("error")
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("applies overrides on a per-key basis", () => {
|
|
133
|
+
expect(
|
|
134
|
+
resolveStopReasonNoticeText("max_tool_calls", {
|
|
135
|
+
["max_tool_calls" as const]: "Custom override.",
|
|
136
|
+
})
|
|
137
|
+
).toBe("Custom override.");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("falls back to defaults for keys not overridden", () => {
|
|
141
|
+
expect(
|
|
142
|
+
resolveStopReasonNoticeText("length", {
|
|
143
|
+
["max_tool_calls" as const]: "Custom.",
|
|
144
|
+
})
|
|
145
|
+
).toBe(getDefaultStopReasonNoticeCopy("length"));
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("suppresses the notice when override is an empty string", () => {
|
|
149
|
+
expect(
|
|
150
|
+
resolveStopReasonNoticeText("max_tool_calls", {
|
|
151
|
+
["max_tool_calls" as const]: "",
|
|
152
|
+
})
|
|
153
|
+
).toBeNull();
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe("createStandardBubble — stopReason notice", () => {
|
|
158
|
+
const renderWithStopReason = (
|
|
159
|
+
overrides: Partial<AgentWidgetMessage>,
|
|
160
|
+
widgetConfig?: Partial<AgentWidgetConfig>
|
|
161
|
+
) =>
|
|
162
|
+
createStandardBubble(
|
|
163
|
+
makeMessage(overrides),
|
|
164
|
+
({ text }) => text,
|
|
165
|
+
undefined,
|
|
166
|
+
undefined,
|
|
167
|
+
undefined,
|
|
168
|
+
{ widgetConfig: widgetConfig as AgentWidgetConfig | undefined }
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
it("renders no notice for end_turn (natural completion)", () => {
|
|
172
|
+
const bubble = renderWithStopReason({
|
|
173
|
+
content: "All done.",
|
|
174
|
+
stopReason: "end_turn",
|
|
175
|
+
});
|
|
176
|
+
expect(bubble.querySelector(".persona-message-stop-reason")).toBeNull();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("renders no notice when stopReason is absent (backcompat)", () => {
|
|
180
|
+
const bubble = renderWithStopReason({ content: "Hello." });
|
|
181
|
+
expect(bubble.querySelector(".persona-message-stop-reason")).toBeNull();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("renders no notice for unknown reasons", () => {
|
|
185
|
+
const bubble = renderWithStopReason({
|
|
186
|
+
content: "Hello.",
|
|
187
|
+
stopReason: "unknown",
|
|
188
|
+
});
|
|
189
|
+
expect(bubble.querySelector(".persona-message-stop-reason")).toBeNull();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("renders the default notice for max_tool_calls", () => {
|
|
193
|
+
const bubble = renderWithStopReason({
|
|
194
|
+
content: "Used a tool.",
|
|
195
|
+
stopReason: "max_tool_calls",
|
|
196
|
+
});
|
|
197
|
+
const notice = bubble.querySelector(".persona-message-stop-reason");
|
|
198
|
+
expect(notice).not.toBeNull();
|
|
199
|
+
expect(notice?.getAttribute("data-stop-reason")).toBe("max_tool_calls");
|
|
200
|
+
expect(notice?.textContent).toBe(getDefaultStopReasonNoticeCopy("max_tool_calls"));
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("renders the default notice for length", () => {
|
|
204
|
+
const bubble = renderWithStopReason({
|
|
205
|
+
content: "Long answer cut off.",
|
|
206
|
+
stopReason: "length",
|
|
207
|
+
});
|
|
208
|
+
const notice = bubble.querySelector(".persona-message-stop-reason");
|
|
209
|
+
expect(notice?.getAttribute("data-stop-reason")).toBe("length");
|
|
210
|
+
expect(notice?.textContent).toBe(getDefaultStopReasonNoticeCopy("length"));
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("renders the default notice for content_filter", () => {
|
|
214
|
+
const bubble = renderWithStopReason({
|
|
215
|
+
content: "Filtered.",
|
|
216
|
+
stopReason: "content_filter",
|
|
217
|
+
});
|
|
218
|
+
const notice = bubble.querySelector(".persona-message-stop-reason");
|
|
219
|
+
expect(notice?.getAttribute("data-stop-reason")).toBe("content_filter");
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("renders the default notice for error", () => {
|
|
223
|
+
const bubble = renderWithStopReason({
|
|
224
|
+
content: "Provider blew up.",
|
|
225
|
+
stopReason: "error",
|
|
226
|
+
});
|
|
227
|
+
const notice = bubble.querySelector(".persona-message-stop-reason");
|
|
228
|
+
expect(notice?.getAttribute("data-stop-reason")).toBe("error");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("applies copy overrides from widgetConfig.copy.stopReasonNotice", () => {
|
|
232
|
+
const bubble = renderWithStopReason(
|
|
233
|
+
{ content: "x", stopReason: "max_tool_calls" },
|
|
234
|
+
{ copy: { stopReasonNotice: { ["max_tool_calls" as const]: "Custom copy." } } }
|
|
235
|
+
);
|
|
236
|
+
expect(bubble.querySelector(".persona-message-stop-reason")?.textContent).toBe(
|
|
237
|
+
"Custom copy."
|
|
238
|
+
);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("hides the empty content div when content is empty + max_tool_calls", () => {
|
|
242
|
+
// Regression: the empty-bubble symptom the upstream Runtype fix targets.
|
|
243
|
+
// With no content and max_tool_calls, the notice carries the bubble alone;
|
|
244
|
+
// the empty content div must be hidden so we don't render whitespace
|
|
245
|
+
// above the notice.
|
|
246
|
+
const bubble = renderWithStopReason({
|
|
247
|
+
content: "",
|
|
248
|
+
stopReason: "max_tool_calls",
|
|
249
|
+
});
|
|
250
|
+
const contentDiv = bubble.querySelector(".persona-message-content") as HTMLElement | null;
|
|
251
|
+
expect(contentDiv).not.toBeNull();
|
|
252
|
+
expect(contentDiv!.style.display).toBe("none");
|
|
253
|
+
const notice = bubble.querySelector(".persona-message-stop-reason");
|
|
254
|
+
expect(notice).not.toBeNull();
|
|
255
|
+
expect(notice?.getAttribute("data-stop-reason")).toBe("max_tool_calls");
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("does not render notice while message is still streaming", () => {
|
|
259
|
+
const bubble = renderWithStopReason({
|
|
260
|
+
content: "partial",
|
|
261
|
+
stopReason: "max_tool_calls",
|
|
262
|
+
streaming: true,
|
|
263
|
+
});
|
|
264
|
+
expect(bubble.querySelector(".persona-message-stop-reason")).toBeNull();
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("does not render notice on user messages", () => {
|
|
268
|
+
const bubble = renderWithStopReason({
|
|
269
|
+
role: "user",
|
|
270
|
+
content: "user msg",
|
|
271
|
+
// stopReason on a user message is nonsense, but guard against it
|
|
272
|
+
stopReason: "max_tool_calls",
|
|
273
|
+
});
|
|
274
|
+
expect(bubble.querySelector(".persona-message-stop-reason")).toBeNull();
|
|
275
|
+
});
|
|
276
|
+
});
|