@runtypelabs/persona 3.15.0 → 3.16.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/index.cjs +46 -46
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +11 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.global.js +65 -65
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +46 -46
- package/dist/index.js.map +1 -1
- package/dist/theme-editor.cjs +445 -222
- package/dist/theme-editor.d.cts +11 -0
- package/dist/theme-editor.d.ts +11 -0
- package/dist/theme-editor.js +445 -222
- package/dist/widget.css +11 -8
- package/package.json +1 -1
- package/src/client.test.ts +361 -0
- package/src/client.ts +352 -158
- package/src/components/header-builder.ts +18 -7
- package/src/components/header-layouts.ts +3 -1
- package/src/defaults.ts +6 -0
- package/src/styles/widget.css +11 -8
- package/src/types.ts +11 -0
- package/src/ui.ts +31 -4
- package/src/utils/sequence-buffer.test.ts +256 -0
- package/src/utils/sequence-buffer.ts +130 -0
|
@@ -159,9 +159,11 @@ export const buildHeader = (context: HeaderBuildContext): HeaderElements => {
|
|
|
159
159
|
clearChatButton.style.color =
|
|
160
160
|
clearChatIconColor || HEADER_THEME_CSS.actionIconColor;
|
|
161
161
|
|
|
162
|
-
// Add icon
|
|
162
|
+
// Add icon. display:block eliminates inline-baseline spacing that can
|
|
163
|
+
// push the icon a fractional pixel off-center inside the button.
|
|
163
164
|
const iconSvg = renderLucideIcon(clearChatIconName, "20px", "currentColor", 1);
|
|
164
165
|
if (iconSvg) {
|
|
166
|
+
iconSvg.style.display = "block";
|
|
165
167
|
clearChatButton.appendChild(iconSvg);
|
|
166
168
|
}
|
|
167
169
|
|
|
@@ -276,15 +278,17 @@ export const buildHeader = (context: HeaderBuildContext): HeaderElements => {
|
|
|
276
278
|
}
|
|
277
279
|
}
|
|
278
280
|
|
|
279
|
-
// Create close button wrapper for tooltip positioning
|
|
280
|
-
//
|
|
281
|
+
// Create close button wrapper for tooltip positioning.
|
|
282
|
+
// Mirrors the clear-chat wrapper's inline-flex centering so both
|
|
283
|
+
// header action buttons vertically align identically within the
|
|
284
|
+
// header's flex row.
|
|
281
285
|
const closeButtonWrapper = createElement(
|
|
282
286
|
"div",
|
|
283
287
|
closeButtonPlacement === "top-right"
|
|
284
288
|
? "persona-absolute persona-top-4 persona-right-4 persona-z-50"
|
|
285
289
|
: clearChatEnabled && clearChatPlacement === "inline"
|
|
286
|
-
? ""
|
|
287
|
-
: "persona-ml-auto"
|
|
290
|
+
? "persona-relative persona-inline-flex persona-items-center persona-justify-center"
|
|
291
|
+
: "persona-relative persona-ml-auto persona-inline-flex persona-items-center persona-justify-center"
|
|
288
292
|
);
|
|
289
293
|
|
|
290
294
|
// Create close button with base classes
|
|
@@ -309,9 +313,16 @@ export const buildHeader = (context: HeaderBuildContext): HeaderElements => {
|
|
|
309
313
|
closeButton.style.color =
|
|
310
314
|
launcher.closeButtonColor || HEADER_THEME_CSS.actionIconColor;
|
|
311
315
|
|
|
312
|
-
// Try to render Lucide icon, fallback to text if not provided or fails
|
|
313
|
-
|
|
316
|
+
// Try to render Lucide icon, fallback to text if not provided or fails.
|
|
317
|
+
// The X glyph's paths occupy only the middle 50% of its 24x24 viewBox
|
|
318
|
+
// (from 6,6 to 18,18), while other header icons (e.g. refresh-cw) span
|
|
319
|
+
// ~75% of the viewBox. Rendering X at a larger intrinsic size brings
|
|
320
|
+
// its visible extent into parity with sibling icons in the header.
|
|
321
|
+
// display:block eliminates inline-baseline spacing that can push the
|
|
322
|
+
// icon a fractional pixel off-center inside the button.
|
|
323
|
+
const closeIconSvg = renderLucideIcon(closeButtonIconName, "28px", "currentColor", 1);
|
|
314
324
|
if (closeIconSvg) {
|
|
325
|
+
closeIconSvg.style.display = "block";
|
|
315
326
|
closeButton.appendChild(closeIconSvg);
|
|
316
327
|
} else {
|
|
317
328
|
closeButton.textContent = closeButtonIconText;
|
|
@@ -215,7 +215,9 @@ export const buildMinimalHeader: HeaderLayoutRenderer = (context) => {
|
|
|
215
215
|
launcher.closeButtonColor || HEADER_THEME_CSS.actionIconColor;
|
|
216
216
|
|
|
217
217
|
const closeButtonIconName = launcher.closeButtonIconName ?? "x";
|
|
218
|
-
|
|
218
|
+
// Larger intrinsic size compensates for the X glyph's sparse viewBox
|
|
219
|
+
// (paths only occupy the middle 50%). Matches header-builder.ts.
|
|
220
|
+
const closeIconSvg = renderLucideIcon(closeButtonIconName, "28px", "currentColor", 1);
|
|
219
221
|
if (closeIconSvg) {
|
|
220
222
|
closeButton.appendChild(closeIconSvg);
|
|
221
223
|
} else {
|
package/src/defaults.ts
CHANGED
|
@@ -43,6 +43,12 @@ export const DEFAULT_WIDGET_CONFIG: Partial<AgentWidgetConfig> = {
|
|
|
43
43
|
agentIconSize: "40px",
|
|
44
44
|
headerIconSize: "40px",
|
|
45
45
|
closeButtonSize: "32px",
|
|
46
|
+
// Zero out browser-default <button> padding so the icon gets the full
|
|
47
|
+
// 32x32 content box, matching clearChat.paddingX/Y below. Without this,
|
|
48
|
+
// UA stylesheets add ~1-2px vertical and ~6px horizontal padding that
|
|
49
|
+
// eats into the border-box width and shrinks the rendered icon.
|
|
50
|
+
closeButtonPaddingX: "0px",
|
|
51
|
+
closeButtonPaddingY: "0px",
|
|
46
52
|
callToActionIconName: "arrow-up-right",
|
|
47
53
|
callToActionIconText: "",
|
|
48
54
|
callToActionIconSize: "32px",
|
package/src/styles/widget.css
CHANGED
|
@@ -1778,10 +1778,6 @@
|
|
|
1778
1778
|
margin-top: 0.5rem;
|
|
1779
1779
|
padding: 0.25rem 0.5rem;
|
|
1780
1780
|
border-top: none;
|
|
1781
|
-
border-radius: var(--persona-radius-md, 0.75rem);
|
|
1782
|
-
background-color: var(--persona-surface, #ffffff);
|
|
1783
|
-
border: 1px solid var(--persona-divider, #f1f5f9);
|
|
1784
|
-
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
|
1785
1781
|
}
|
|
1786
1782
|
|
|
1787
1783
|
/* Pill alignment in always-visible mode (block flow: use margin to position) */
|
|
@@ -1826,10 +1822,6 @@
|
|
|
1826
1822
|
padding: 0.25rem;
|
|
1827
1823
|
border-top: none;
|
|
1828
1824
|
width: fit-content;
|
|
1829
|
-
background-color: var(--persona-surface, #ffffff);
|
|
1830
|
-
border: 1px solid var(--persona-divider, #f1f5f9);
|
|
1831
|
-
border-radius: var(--persona-radius-md, 0.75rem);
|
|
1832
|
-
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
|
1833
1825
|
}
|
|
1834
1826
|
|
|
1835
1827
|
/* Pill layout - position based on alignment */
|
|
@@ -1932,6 +1924,17 @@
|
|
|
1932
1924
|
opacity: 0.9;
|
|
1933
1925
|
}
|
|
1934
1926
|
|
|
1927
|
+
/* Vote pop animation */
|
|
1928
|
+
@keyframes persona-vote-pop {
|
|
1929
|
+
0% { transform: scale(1); }
|
|
1930
|
+
40% { transform: scale(1.25); }
|
|
1931
|
+
100% { transform: scale(1); }
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
.persona-message-action-btn.persona-message-action-pop {
|
|
1935
|
+
animation: persona-vote-pop 0.3s ease;
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1935
1938
|
/* Success state (after copy) */
|
|
1936
1939
|
.persona-message-action-btn.persona-message-action-success {
|
|
1937
1940
|
background-color: #10b981;
|
package/src/types.ts
CHANGED
|
@@ -194,6 +194,17 @@ export type AgentMessageMetadata = {
|
|
|
194
194
|
iteration?: number;
|
|
195
195
|
turnId?: string;
|
|
196
196
|
agentName?: string;
|
|
197
|
+
/**
|
|
198
|
+
* When this message was produced by a step inside a nested flow executed
|
|
199
|
+
* as a tool, identifies the parent tool call id. Enables renderers to
|
|
200
|
+
* visually group or indent nested-flow output under its parent tool.
|
|
201
|
+
*/
|
|
202
|
+
parentToolId?: string;
|
|
203
|
+
/**
|
|
204
|
+
* Nested flow step id that produced this message (e.g. a `send-stream`
|
|
205
|
+
* or `prompt` step inside the nested flow). Stable key for that step.
|
|
206
|
+
*/
|
|
207
|
+
parentStepId?: string;
|
|
197
208
|
};
|
|
198
209
|
|
|
199
210
|
export type AgentWidgetRequestMiddlewareContext = {
|
package/src/ui.ts
CHANGED
|
@@ -1229,22 +1229,47 @@ export const createAgentExperience = (
|
|
|
1229
1229
|
} else if (action === 'upvote' || action === 'downvote') {
|
|
1230
1230
|
const currentVote = messageVoteState.get(messageId) ?? null;
|
|
1231
1231
|
const wasActive = currentVote === action;
|
|
1232
|
+
const iconName = action === 'upvote' ? 'thumbs-up' : 'thumbs-down';
|
|
1232
1233
|
|
|
1233
1234
|
if (wasActive) {
|
|
1234
|
-
// Toggle off
|
|
1235
|
+
// Toggle off — revert to outline icon
|
|
1235
1236
|
messageVoteState.delete(messageId);
|
|
1236
1237
|
actionBtn.classList.remove("persona-message-action-active");
|
|
1238
|
+
const outlineIcon = renderLucideIcon(iconName, 14, "currentColor", 2);
|
|
1239
|
+
if (outlineIcon) {
|
|
1240
|
+
actionBtn.innerHTML = "";
|
|
1241
|
+
actionBtn.appendChild(outlineIcon);
|
|
1242
|
+
}
|
|
1237
1243
|
} else {
|
|
1238
|
-
// Clear opposite vote button
|
|
1244
|
+
// Clear opposite vote button and revert its icon
|
|
1239
1245
|
const oppositeAction = action === 'upvote' ? 'downvote' : 'upvote';
|
|
1240
1246
|
const oppositeBtn = actionsContainer.querySelector(`[data-action="${oppositeAction}"]`);
|
|
1241
1247
|
if (oppositeBtn) {
|
|
1242
1248
|
oppositeBtn.classList.remove("persona-message-action-active");
|
|
1249
|
+
const oppositeIconName = oppositeAction === 'upvote' ? 'thumbs-up' : 'thumbs-down';
|
|
1250
|
+
const outlineIcon = renderLucideIcon(oppositeIconName, 14, "currentColor", 2);
|
|
1251
|
+
if (outlineIcon) {
|
|
1252
|
+
oppositeBtn.innerHTML = "";
|
|
1253
|
+
oppositeBtn.appendChild(outlineIcon);
|
|
1254
|
+
}
|
|
1243
1255
|
}
|
|
1244
1256
|
|
|
1245
1257
|
messageVoteState.set(messageId, action);
|
|
1246
1258
|
actionBtn.classList.add("persona-message-action-active");
|
|
1247
1259
|
|
|
1260
|
+
// Swap to filled icon
|
|
1261
|
+
const filledIcon = renderLucideIcon(iconName, 14, "currentColor", 2);
|
|
1262
|
+
if (filledIcon) {
|
|
1263
|
+
filledIcon.setAttribute("fill", "currentColor");
|
|
1264
|
+
actionBtn.innerHTML = "";
|
|
1265
|
+
actionBtn.appendChild(filledIcon);
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
// Pop animation
|
|
1269
|
+
actionBtn.classList.remove("persona-message-action-pop");
|
|
1270
|
+
void actionBtn.offsetWidth; // force reflow to restart animation
|
|
1271
|
+
actionBtn.classList.add("persona-message-action-pop");
|
|
1272
|
+
|
|
1248
1273
|
// Trigger feedback
|
|
1249
1274
|
const messages = session.getMessages();
|
|
1250
1275
|
const message = messages.find(m => m.id === messageId);
|
|
@@ -4592,9 +4617,11 @@ export const createAgentExperience = (
|
|
|
4592
4617
|
const closeButtonIconName = launcher.closeButtonIconName ?? "x";
|
|
4593
4618
|
const closeButtonIconText = launcher.closeButtonIconText ?? "×";
|
|
4594
4619
|
|
|
4595
|
-
// Clear existing content and render new icon
|
|
4620
|
+
// Clear existing content and render new icon.
|
|
4621
|
+
// Larger intrinsic size compensates for the X glyph's sparse
|
|
4622
|
+
// viewBox so the close button visually matches sibling icons.
|
|
4596
4623
|
closeButton.innerHTML = "";
|
|
4597
|
-
const iconSvg = renderLucideIcon(closeButtonIconName, "
|
|
4624
|
+
const iconSvg = renderLucideIcon(closeButtonIconName, "28px", "currentColor", 1);
|
|
4598
4625
|
if (iconSvg) {
|
|
4599
4626
|
closeButton.appendChild(iconSvg);
|
|
4600
4627
|
} else {
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { SequenceReorderBuffer } from "./sequence-buffer";
|
|
3
|
+
|
|
4
|
+
describe("SequenceReorderBuffer", () => {
|
|
5
|
+
let emitted: Array<{ payloadType: string; payload: any }>;
|
|
6
|
+
let emitter: (payloadType: string, payload: any) => void;
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
vi.useFakeTimers();
|
|
10
|
+
emitted = [];
|
|
11
|
+
emitter = (payloadType, payload) => {
|
|
12
|
+
emitted.push({ payloadType, payload });
|
|
13
|
+
};
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
vi.useRealTimers();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("passes in-order events through immediately", () => {
|
|
21
|
+
const buf = new SequenceReorderBuffer(emitter);
|
|
22
|
+
buf.push("step_delta", { seq: 1, text: "a" });
|
|
23
|
+
buf.push("step_delta", { seq: 2, text: "b" });
|
|
24
|
+
buf.push("step_delta", { seq: 3, text: "c" });
|
|
25
|
+
|
|
26
|
+
expect(emitted).toHaveLength(3);
|
|
27
|
+
expect(emitted[0].payload.text).toBe("a");
|
|
28
|
+
expect(emitted[1].payload.text).toBe("b");
|
|
29
|
+
expect(emitted[2].payload.text).toBe("c");
|
|
30
|
+
buf.destroy();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("reorders leading out-of-order events (3, 1, 2 → 1, 2, 3)", () => {
|
|
34
|
+
const buf = new SequenceReorderBuffer(emitter);
|
|
35
|
+
// seq=3 arrives first — should be buffered (3 > nextExpected=1)
|
|
36
|
+
buf.push("step_delta", { seq: 3, text: "c" });
|
|
37
|
+
expect(emitted).toHaveLength(0);
|
|
38
|
+
|
|
39
|
+
// seq=1 arrives — matches nextExpected, emits, then drains seq=2 (not present), stops
|
|
40
|
+
buf.push("step_delta", { seq: 1, text: "a" });
|
|
41
|
+
expect(emitted).toHaveLength(1);
|
|
42
|
+
expect(emitted[0].payload.text).toBe("a");
|
|
43
|
+
|
|
44
|
+
// seq=2 arrives — matches nextExpected=2, emits, drains seq=3 from buffer
|
|
45
|
+
buf.push("step_delta", { seq: 2, text: "b" });
|
|
46
|
+
expect(emitted).toHaveLength(3);
|
|
47
|
+
expect(emitted[1].payload.text).toBe("b");
|
|
48
|
+
expect(emitted[2].payload.text).toBe("c");
|
|
49
|
+
buf.destroy();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("reorders mid-stream out-of-order events", () => {
|
|
53
|
+
const buf = new SequenceReorderBuffer(emitter);
|
|
54
|
+
buf.push("step_delta", { seq: 1, text: "a" });
|
|
55
|
+
buf.push("step_delta", { seq: 3, text: "c" }); // buffered
|
|
56
|
+
buf.push("step_delta", { seq: 2, text: "b" }); // emits, drains 3
|
|
57
|
+
|
|
58
|
+
expect(emitted).toHaveLength(3);
|
|
59
|
+
expect(emitted[0].payload.text).toBe("a");
|
|
60
|
+
expect(emitted[1].payload.text).toBe("b");
|
|
61
|
+
expect(emitted[2].payload.text).toBe("c");
|
|
62
|
+
buf.destroy();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("flushes buffered events after gap timeout when a seq is missing", () => {
|
|
66
|
+
const buf = new SequenceReorderBuffer(emitter, 50);
|
|
67
|
+
buf.push("step_delta", { seq: 1, text: "a" }); // emits
|
|
68
|
+
buf.push("step_delta", { seq: 3, text: "c" }); // buffered (waiting for seq 2)
|
|
69
|
+
|
|
70
|
+
expect(emitted).toHaveLength(1);
|
|
71
|
+
|
|
72
|
+
// Advance past gap timeout — seq=2 never arrives, flush seq=3 anyway
|
|
73
|
+
vi.advanceTimersByTime(60);
|
|
74
|
+
|
|
75
|
+
expect(emitted).toHaveLength(2);
|
|
76
|
+
expect(emitted[1].payload.text).toBe("c");
|
|
77
|
+
buf.destroy();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("passes no-seq events through immediately (backward compat)", () => {
|
|
81
|
+
const buf = new SequenceReorderBuffer(emitter);
|
|
82
|
+
buf.push("flow_start", { flowId: "abc" });
|
|
83
|
+
buf.push("step_start", { name: "test" });
|
|
84
|
+
|
|
85
|
+
expect(emitted).toHaveLength(2);
|
|
86
|
+
expect(emitted[0].payload.flowId).toBe("abc");
|
|
87
|
+
expect(emitted[1].payload.name).toBe("test");
|
|
88
|
+
buf.destroy();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("emits late/duplicate events (seq < nextExpected)", () => {
|
|
92
|
+
const buf = new SequenceReorderBuffer(emitter);
|
|
93
|
+
// Process seq 1-3 normally to advance nextExpected to 4
|
|
94
|
+
buf.push("step_delta", { seq: 1, text: "a" });
|
|
95
|
+
buf.push("step_delta", { seq: 2, text: "b" });
|
|
96
|
+
buf.push("step_delta", { seq: 3, text: "c" });
|
|
97
|
+
expect(emitted).toHaveLength(3);
|
|
98
|
+
|
|
99
|
+
// Now seq=1 arrives again — it's a duplicate (1 < nextExpected=4), still emitted
|
|
100
|
+
buf.push("step_delta", { seq: 1, text: "a-dup" });
|
|
101
|
+
expect(emitted).toHaveLength(4);
|
|
102
|
+
expect(emitted[3].payload.text).toBe("a-dup");
|
|
103
|
+
buf.destroy();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("handles mixed seq and no-seq events", () => {
|
|
107
|
+
const buf = new SequenceReorderBuffer(emitter);
|
|
108
|
+
buf.push("step_delta", { seq: 1, text: "a" });
|
|
109
|
+
buf.push("status", { status: "streaming" }); // no seq
|
|
110
|
+
buf.push("step_delta", { seq: 2, text: "b" });
|
|
111
|
+
buf.push("error", { error: "oops" }); // no seq
|
|
112
|
+
|
|
113
|
+
expect(emitted).toHaveLength(4);
|
|
114
|
+
expect(emitted[0].payload.text).toBe("a");
|
|
115
|
+
expect(emitted[1].payload.status).toBe("streaming");
|
|
116
|
+
expect(emitted[2].payload.text).toBe("b");
|
|
117
|
+
expect(emitted[3].payload.error).toBe("oops");
|
|
118
|
+
buf.destroy();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("handles large burst of out-of-order events correctly", () => {
|
|
122
|
+
const buf = new SequenceReorderBuffer(emitter);
|
|
123
|
+
// Send events in reverse order: 10, 9, 8, ..., 1
|
|
124
|
+
// All are buffered until seq=1 arrives (last), then everything drains
|
|
125
|
+
const seqs = [10, 9, 8, 7, 6, 5, 4, 3, 2, 1];
|
|
126
|
+
for (const seq of seqs) {
|
|
127
|
+
buf.push("step_delta", { seq, text: `chunk-${seq}` });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
expect(emitted).toHaveLength(10);
|
|
131
|
+
const emittedTexts = emitted.map(e => e.payload.text);
|
|
132
|
+
expect(emittedTexts).toEqual([
|
|
133
|
+
"chunk-1", "chunk-2", "chunk-3", "chunk-4", "chunk-5",
|
|
134
|
+
"chunk-6", "chunk-7", "chunk-8", "chunk-9", "chunk-10"
|
|
135
|
+
]);
|
|
136
|
+
buf.destroy();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("handles scrambled arrival order", () => {
|
|
140
|
+
const buf = new SequenceReorderBuffer(emitter);
|
|
141
|
+
const scrambled = [1, 5, 3, 2, 4, 8, 6, 7, 10, 9];
|
|
142
|
+
for (const seq of scrambled) {
|
|
143
|
+
buf.push("step_delta", { seq, text: `chunk-${seq}` });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
expect(emitted).toHaveLength(10);
|
|
147
|
+
const emittedTexts = emitted.map(e => e.payload.text);
|
|
148
|
+
expect(emittedTexts).toEqual([
|
|
149
|
+
"chunk-1", "chunk-2", "chunk-3", "chunk-4", "chunk-5",
|
|
150
|
+
"chunk-6", "chunk-7", "chunk-8", "chunk-9", "chunk-10"
|
|
151
|
+
]);
|
|
152
|
+
buf.destroy();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("no-seq event flushes pending buffer and cancels the gap timer", () => {
|
|
156
|
+
const buf = new SequenceReorderBuffer(emitter, 50);
|
|
157
|
+
buf.push("step_delta", { seq: 1, text: "a" });
|
|
158
|
+
buf.push("step_delta", { seq: 3, text: "c" }); // buffered, starts gap timer
|
|
159
|
+
expect(emitted).toHaveLength(1);
|
|
160
|
+
|
|
161
|
+
// A no-seq event triggers flushAll, which drains the buffer in seq order
|
|
162
|
+
// and cancels the gap timer.
|
|
163
|
+
buf.push("flow_complete", { flowId: "done" });
|
|
164
|
+
expect(emitted).toHaveLength(3); // a, c, flow_complete
|
|
165
|
+
|
|
166
|
+
// The gap timer must no longer fire after the flushAll.
|
|
167
|
+
vi.advanceTimersByTime(100);
|
|
168
|
+
expect(emitted).toHaveLength(3);
|
|
169
|
+
buf.destroy();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("supports sequenceIndex as an alternative to seq", () => {
|
|
173
|
+
const buf = new SequenceReorderBuffer(emitter);
|
|
174
|
+
buf.push("reason_delta", { sequenceIndex: 1, text: "a" });
|
|
175
|
+
buf.push("reason_delta", { sequenceIndex: 3, text: "c" });
|
|
176
|
+
buf.push("reason_delta", { sequenceIndex: 2, text: "b" });
|
|
177
|
+
|
|
178
|
+
expect(emitted).toHaveLength(3);
|
|
179
|
+
expect(emitted[0].payload.text).toBe("a");
|
|
180
|
+
expect(emitted[1].payload.text).toBe("b");
|
|
181
|
+
expect(emitted[2].payload.text).toBe("c");
|
|
182
|
+
buf.destroy();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("leading gap flushes via timeout when seq=1 never arrives", () => {
|
|
186
|
+
const buf = new SequenceReorderBuffer(emitter, 50);
|
|
187
|
+
// Only seq=2 and seq=3 arrive — seq=1 is missing
|
|
188
|
+
buf.push("step_delta", { seq: 2, text: "b" });
|
|
189
|
+
buf.push("step_delta", { seq: 3, text: "c" });
|
|
190
|
+
expect(emitted).toHaveLength(0); // both buffered
|
|
191
|
+
|
|
192
|
+
vi.advanceTimersByTime(50);
|
|
193
|
+
// Gap timer flushes in seq order
|
|
194
|
+
expect(emitted).toHaveLength(2);
|
|
195
|
+
expect(emitted[0].payload.text).toBe("b");
|
|
196
|
+
expect(emitted[1].payload.text).toBe("c");
|
|
197
|
+
buf.destroy();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("handles a stream whose first seq is > 1 via the gap timeout (no loss)", () => {
|
|
201
|
+
// Defensive: if the server's counter ever starts above 1 (e.g. a resumed
|
|
202
|
+
// stream), the hardcoded nextExpectedSeq=1 would buffer the first event.
|
|
203
|
+
// The gap timer must still flush it so nothing is lost.
|
|
204
|
+
const buf = new SequenceReorderBuffer(emitter, 50);
|
|
205
|
+
buf.push("step_delta", { seq: 5, text: "first" });
|
|
206
|
+
buf.push("step_delta", { seq: 6, text: "second" });
|
|
207
|
+
expect(emitted).toHaveLength(0);
|
|
208
|
+
|
|
209
|
+
vi.advanceTimersByTime(50);
|
|
210
|
+
|
|
211
|
+
expect(emitted).toHaveLength(2);
|
|
212
|
+
expect(emitted[0].payload.text).toBe("first");
|
|
213
|
+
expect(emitted[1].payload.text).toBe("second");
|
|
214
|
+
|
|
215
|
+
// Subsequent in-order events should pass through immediately.
|
|
216
|
+
buf.push("step_delta", { seq: 7, text: "third" });
|
|
217
|
+
expect(emitted).toHaveLength(3);
|
|
218
|
+
expect(emitted[2].payload.text).toBe("third");
|
|
219
|
+
buf.destroy();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("warns and emits both events on seq collision (does not silently drop)", () => {
|
|
223
|
+
// Server invariant: seq is unique per stream. If it's ever violated
|
|
224
|
+
// (bug, replay, mixed counters), Map.set would silently overwrite. The
|
|
225
|
+
// buffer must detect this, warn, and emit the prior event so nothing is
|
|
226
|
+
// dropped.
|
|
227
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
228
|
+
const buf = new SequenceReorderBuffer(emitter, 50);
|
|
229
|
+
|
|
230
|
+
buf.push("step_delta", { seq: 1, text: "a" });
|
|
231
|
+
// seq=3 buffered, waiting for seq=2
|
|
232
|
+
buf.push("step_delta", { seq: 3, text: "first-at-3" });
|
|
233
|
+
expect(emitted).toHaveLength(1);
|
|
234
|
+
|
|
235
|
+
// Second event with same seq=3 — prior one should be emitted out-of-order
|
|
236
|
+
buf.push("reason_delta", { seq: 3, text: "second-at-3" });
|
|
237
|
+
|
|
238
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
239
|
+
expect(warnSpy.mock.calls[0][0]).toContain("duplicate seq=3");
|
|
240
|
+
expect(warnSpy.mock.calls[0][0]).toContain("step_delta");
|
|
241
|
+
expect(warnSpy.mock.calls[0][0]).toContain("reason_delta");
|
|
242
|
+
|
|
243
|
+
// Prior event flushed immediately (out of seq order), nothing lost
|
|
244
|
+
expect(emitted).toHaveLength(2);
|
|
245
|
+
expect(emitted[1].payload.text).toBe("first-at-3");
|
|
246
|
+
|
|
247
|
+
// seq=2 arrives — advances nextExpected through the buffered second-at-3
|
|
248
|
+
buf.push("step_delta", { seq: 2, text: "b" });
|
|
249
|
+
expect(emitted).toHaveLength(4);
|
|
250
|
+
expect(emitted[2].payload.text).toBe("b");
|
|
251
|
+
expect(emitted[3].payload.text).toBe("second-at-3");
|
|
252
|
+
|
|
253
|
+
warnSpy.mockRestore();
|
|
254
|
+
buf.destroy();
|
|
255
|
+
});
|
|
256
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
type BufferedEvent = { payloadType: string; payload: any; seq: number };
|
|
2
|
+
|
|
3
|
+
export class SequenceReorderBuffer {
|
|
4
|
+
private nextExpectedSeq: number | null = null;
|
|
5
|
+
private buffer: Map<number, BufferedEvent> = new Map();
|
|
6
|
+
private flushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
7
|
+
private emitter: (payloadType: string, payload: any) => void;
|
|
8
|
+
private gapTimeoutMs: number;
|
|
9
|
+
|
|
10
|
+
constructor(emitter: (payloadType: string, payload: any) => void, gapTimeoutMs = 50) {
|
|
11
|
+
this.emitter = emitter;
|
|
12
|
+
this.gapTimeoutMs = gapTimeoutMs;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
push(payloadType: string, payload: any): void {
|
|
16
|
+
// All three fields are sourced from the same FlowExecutionEngine.sequenceCounter:
|
|
17
|
+
// - `seq`: step_delta, text_start, text_end, agent_* events (top-level)
|
|
18
|
+
// - `sequenceIndex`: reason_start, reason_delta, reason_complete, source
|
|
19
|
+
// - `agentContext.seq`: tool_start, tool_delta, tool_complete (agent loop)
|
|
20
|
+
const seq = payload?.seq ?? payload?.sequenceIndex ?? payload?.agentContext?.seq;
|
|
21
|
+
|
|
22
|
+
// No seq field — emit immediately (backward compat).
|
|
23
|
+
// If there are buffered events waiting for a gap to fill, flush them
|
|
24
|
+
// first: the server sending an unsequenced event means it has moved on
|
|
25
|
+
// and the missing seq numbers are not coming.
|
|
26
|
+
if (seq === undefined || seq === null) {
|
|
27
|
+
if (this.buffer.size > 0) {
|
|
28
|
+
this.flushAll();
|
|
29
|
+
}
|
|
30
|
+
this.emitter(payloadType, payload);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Server's sequenceCounter resets to 0 on each execution and pre-increments,
|
|
35
|
+
// so the first sequenced event in any stream is expected to have seq=1.
|
|
36
|
+
// If a server ever starts at a different number (e.g. a resumed stream),
|
|
37
|
+
// the 50ms gap timer below is the safety net: the first event gets
|
|
38
|
+
// buffered, then flushed after the gap elapses. Correctness is preserved;
|
|
39
|
+
// the only cost is a one-time latency on the leading event.
|
|
40
|
+
if (this.nextExpectedSeq === null) {
|
|
41
|
+
this.nextExpectedSeq = 1;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// If this is the expected event, emit it and drain consecutive buffered events
|
|
45
|
+
if (seq === this.nextExpectedSeq) {
|
|
46
|
+
this.emitter(payloadType, payload);
|
|
47
|
+
this.nextExpectedSeq = (seq as number) + 1;
|
|
48
|
+
this.drainConsecutive();
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// If seq < nextExpected, it's a duplicate or late arrival — emit anyway (don't drop)
|
|
53
|
+
if (seq < this.nextExpectedSeq!) {
|
|
54
|
+
this.emitter(payloadType, payload);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// seq > nextExpected — buffer it and start gap timer.
|
|
59
|
+
// If another event with the same seq is already buffered, the server
|
|
60
|
+
// broke its "seq is unique per stream" invariant. Rather than silently
|
|
61
|
+
// overwrite (losing one event) or swallow the new one, emit the prior
|
|
62
|
+
// event immediately — out of order, but better than dropping it — and
|
|
63
|
+
// warn so the issue is visible.
|
|
64
|
+
const existing = this.buffer.get(seq);
|
|
65
|
+
if (existing !== undefined) {
|
|
66
|
+
if (typeof console !== "undefined" && typeof console.warn === "function") {
|
|
67
|
+
console.warn(
|
|
68
|
+
`[persona] SequenceReorderBuffer: duplicate seq=${seq} ` +
|
|
69
|
+
`(${existing.payloadType} vs ${payloadType}); ` +
|
|
70
|
+
`emitting earlier event out-of-order to avoid loss`
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
this.emitter(existing.payloadType, existing.payload);
|
|
74
|
+
}
|
|
75
|
+
this.buffer.set(seq, { payloadType, payload, seq });
|
|
76
|
+
this.startGapTimer();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private drainConsecutive(): void {
|
|
80
|
+
while (this.buffer.has(this.nextExpectedSeq!)) {
|
|
81
|
+
const event = this.buffer.get(this.nextExpectedSeq!)!;
|
|
82
|
+
this.buffer.delete(this.nextExpectedSeq!);
|
|
83
|
+
this.emitter(event.payloadType, event.payload);
|
|
84
|
+
this.nextExpectedSeq!++;
|
|
85
|
+
}
|
|
86
|
+
// If buffer is empty, clear the gap timer
|
|
87
|
+
if (this.buffer.size === 0) {
|
|
88
|
+
this.clearGapTimer();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private startGapTimer(): void {
|
|
93
|
+
if (this.flushTimer !== null) return;
|
|
94
|
+
this.flushTimer = setTimeout(() => {
|
|
95
|
+
this.flushAll();
|
|
96
|
+
}, this.gapTimeoutMs);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private clearGapTimer(): void {
|
|
100
|
+
if (this.flushTimer !== null) {
|
|
101
|
+
clearTimeout(this.flushTimer);
|
|
102
|
+
this.flushTimer = null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private flushAll(): void {
|
|
107
|
+
this.clearGapTimer();
|
|
108
|
+
if (this.buffer.size === 0) return;
|
|
109
|
+
|
|
110
|
+
// Flush all buffered events in seq order
|
|
111
|
+
const sorted = [...this.buffer.entries()].sort((a, b) => a[0] - b[0]);
|
|
112
|
+
for (const [seq, event] of sorted) {
|
|
113
|
+
this.buffer.delete(seq);
|
|
114
|
+
this.emitter(event.payloadType, event.payload);
|
|
115
|
+
}
|
|
116
|
+
// Update nextExpectedSeq to after the last flushed
|
|
117
|
+
if (sorted.length > 0) {
|
|
118
|
+
this.nextExpectedSeq = sorted[sorted.length - 1][0] + 1;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
destroy(): void {
|
|
123
|
+
this.clearGapTimer();
|
|
124
|
+
this.buffer.clear();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
flushPending(): void {
|
|
128
|
+
this.flushAll();
|
|
129
|
+
}
|
|
130
|
+
}
|