@runtypelabs/persona 3.37.0 → 4.0.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 +0 -1
- package/dist/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/{types-DgYsuwXL.d.cts → types-B_xbfvR0.d.cts} +263 -181
- package/dist/animations/{types-DgYsuwXL.d.ts → types-B_xbfvR0.d.ts} +263 -181
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/codegen.cjs +1 -1
- package/dist/codegen.js +1 -1
- package/dist/index.cjs +52 -52
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +327 -838
- package/dist/index.d.ts +327 -838
- package/dist/index.global.js +39 -39
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +52 -52
- package/dist/index.js.map +1 -1
- package/dist/install.global.js +1 -1
- package/dist/install.global.js.map +1 -1
- package/dist/smart-dom-reader.d.cts +268 -191
- package/dist/smart-dom-reader.d.ts +268 -191
- package/dist/theme-editor-preview.cjs +53 -53
- package/dist/theme-editor-preview.d.cts +268 -191
- package/dist/theme-editor-preview.d.ts +268 -191
- package/dist/theme-editor-preview.js +55 -55
- package/dist/theme-editor.d.cts +268 -191
- package/dist/theme-editor.d.ts +268 -191
- package/package.json +1 -1
- package/src/client.test.ts +446 -1041
- package/src/client.ts +684 -967
- package/src/components/tool-bubble.ts +46 -33
- package/src/generated/runtype-openapi-contract.ts +210 -714
- package/src/index-core.ts +2 -3
- package/src/install.test.ts +1 -34
- package/src/install.ts +1 -26
- package/src/runtime/init.test.ts +8 -67
- package/src/runtime/init.ts +2 -17
- package/src/session.test.ts +8 -8
- package/src/session.ts +1 -1
- package/src/types.ts +11 -12
- package/src/ui.postprocess.test.ts +107 -0
- package/src/ui.ts +40 -5
- package/src/utils/__fixtures__/unified-translator.oracle.ts +62 -14
- package/src/utils/copy-selection.test.ts +37 -0
- package/src/utils/copy-selection.ts +19 -0
- package/src/utils/event-stream-capture.test.ts +9 -64
- package/src/utils/sequence-buffer.test.ts +0 -256
- package/src/utils/sequence-buffer.ts +0 -130
- package/src/utils/unified-event-bridge.test.ts +0 -263
- package/src/utils/unified-event-bridge.ts +0 -431
|
@@ -36,7 +36,9 @@ class UnifiedEventTranslator {
|
|
|
36
36
|
private kind: ExecutionKind = "flow";
|
|
37
37
|
private blockCounter = 0;
|
|
38
38
|
private openText: string | null = null;
|
|
39
|
+
private openTextBuffer = "";
|
|
39
40
|
private openReasoning: string | null = null;
|
|
41
|
+
private openReasoningBuffer = "";
|
|
40
42
|
|
|
41
43
|
constructor(
|
|
42
44
|
private readonly sink: (chunk: string) => void,
|
|
@@ -56,40 +58,78 @@ class UnifiedEventTranslator {
|
|
|
56
58
|
this.sink(`event: ${type}\ndata: ${JSON.stringify(frame)}\n\n`);
|
|
57
59
|
}
|
|
58
60
|
|
|
59
|
-
|
|
61
|
+
/**
|
|
62
|
+
* Parent model tool-call id stamped by the `tool_nested` FilteredStream as
|
|
63
|
+
* `toolContext.toolId` (a flow running as a tool enriches its frames this way).
|
|
64
|
+
* Surfaced on the text/reasoning channel as `parentToolCallId` so consumers can
|
|
65
|
+
* route nested streamed output into the parent tool's row (PR #4602). Undefined
|
|
66
|
+
* for top-level output.
|
|
67
|
+
*/
|
|
68
|
+
private parentToolCallId(data: Json): string | undefined {
|
|
69
|
+
const toolContext = data.toolContext;
|
|
70
|
+
if (toolContext && typeof toolContext === "object") {
|
|
71
|
+
const toolId = (toolContext as Json).toolId;
|
|
72
|
+
if (typeof toolId === "string" && toolId) return toolId;
|
|
73
|
+
}
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private emitTextDelta(delta: unknown, parentToolCallId?: string): void {
|
|
60
78
|
if (delta == null || delta === "") return;
|
|
61
79
|
if (!this.openText) {
|
|
62
80
|
this.openText = this.mint("text");
|
|
81
|
+
this.openTextBuffer = "";
|
|
63
82
|
this.out("text_start", {
|
|
64
83
|
id: this.openText,
|
|
65
84
|
...(this.kind === "agent" ? { role: "assistant" } : {}),
|
|
85
|
+
...(parentToolCallId ? { parentToolCallId } : {}),
|
|
66
86
|
});
|
|
67
87
|
}
|
|
68
|
-
|
|
88
|
+
const text = String(delta);
|
|
89
|
+
this.openTextBuffer += text;
|
|
90
|
+
this.out("text_delta", { id: this.openText, delta: text });
|
|
69
91
|
}
|
|
70
92
|
|
|
71
93
|
private closeText(): void {
|
|
72
94
|
if (!this.openText) return;
|
|
73
|
-
|
|
95
|
+
// U2: carry the assembled text so a non-streaming consumer can read the
|
|
96
|
+
// finished message off `text_complete` instead of re-concatenating deltas.
|
|
97
|
+
this.out("text_complete", {
|
|
98
|
+
id: this.openText,
|
|
99
|
+
...(this.openTextBuffer ? { text: this.openTextBuffer } : {}),
|
|
100
|
+
});
|
|
74
101
|
this.openText = null;
|
|
102
|
+
this.openTextBuffer = "";
|
|
75
103
|
}
|
|
76
104
|
|
|
77
|
-
private ensureReasoningOpen(scope?: "turn" | "loop"): void {
|
|
105
|
+
private ensureReasoningOpen(scope?: "turn" | "loop", parentToolCallId?: string): void {
|
|
78
106
|
if (this.openReasoning) return;
|
|
79
107
|
this.openReasoning = this.mint("reason");
|
|
80
|
-
this.
|
|
108
|
+
this.openReasoningBuffer = "";
|
|
109
|
+
this.out("reasoning_start", {
|
|
110
|
+
id: this.openReasoning,
|
|
111
|
+
...(scope ? { scope } : {}),
|
|
112
|
+
...(parentToolCallId ? { parentToolCallId } : {}),
|
|
113
|
+
});
|
|
81
114
|
}
|
|
82
115
|
|
|
83
|
-
private emitReasoningDelta(delta: unknown): void {
|
|
116
|
+
private emitReasoningDelta(delta: unknown, parentToolCallId?: string): void {
|
|
84
117
|
if (delta == null || delta === "") return;
|
|
85
|
-
this.ensureReasoningOpen();
|
|
86
|
-
|
|
118
|
+
this.ensureReasoningOpen(undefined, parentToolCallId);
|
|
119
|
+
const text = String(delta);
|
|
120
|
+
this.openReasoningBuffer += text;
|
|
121
|
+
this.out("reasoning_delta", { id: this.openReasoning, delta: text });
|
|
87
122
|
}
|
|
88
123
|
|
|
89
124
|
private closeReasoning(): void {
|
|
90
125
|
if (!this.openReasoning) return;
|
|
91
|
-
|
|
126
|
+
// U2: carry the assembled reasoning text (parity with text_complete).
|
|
127
|
+
this.out("reasoning_complete", {
|
|
128
|
+
id: this.openReasoning,
|
|
129
|
+
...(this.openReasoningBuffer ? { text: this.openReasoningBuffer } : {}),
|
|
130
|
+
});
|
|
92
131
|
this.openReasoning = null;
|
|
132
|
+
this.openReasoningBuffer = "";
|
|
93
133
|
}
|
|
94
134
|
|
|
95
135
|
private closeChannels(): void {
|
|
@@ -391,7 +431,7 @@ class UnifiedEventTranslator {
|
|
|
391
431
|
});
|
|
392
432
|
break;
|
|
393
433
|
case "step_delta":
|
|
394
|
-
this.emitTextDelta(data.text ?? data.delta);
|
|
434
|
+
this.emitTextDelta(data.text ?? data.delta, this.parentToolCallId(data));
|
|
395
435
|
break;
|
|
396
436
|
case "step_complete":
|
|
397
437
|
this.closeChannels();
|
|
@@ -492,23 +532,31 @@ class UnifiedEventTranslator {
|
|
|
492
532
|
});
|
|
493
533
|
break;
|
|
494
534
|
case "chunk":
|
|
495
|
-
this.emitTextDelta(data.text);
|
|
535
|
+
this.emitTextDelta(data.text, this.parentToolCallId(data));
|
|
496
536
|
break;
|
|
497
537
|
|
|
498
538
|
case "text_start":
|
|
499
539
|
if (!this.openText) {
|
|
500
540
|
this.openText = (data.id as string) ?? this.mint("text");
|
|
501
|
-
this.
|
|
541
|
+
this.openTextBuffer = "";
|
|
542
|
+
const parentToolCallId = this.parentToolCallId(data);
|
|
543
|
+
this.out("text_start", {
|
|
544
|
+
id: this.openText,
|
|
545
|
+
...(parentToolCallId ? { parentToolCallId } : {}),
|
|
546
|
+
});
|
|
502
547
|
}
|
|
503
548
|
break;
|
|
504
549
|
case "text_end":
|
|
505
550
|
this.closeText();
|
|
506
551
|
break;
|
|
507
552
|
case "reason_start":
|
|
508
|
-
this.ensureReasoningOpen();
|
|
553
|
+
this.ensureReasoningOpen(undefined, this.parentToolCallId(data));
|
|
509
554
|
break;
|
|
510
555
|
case "reason_delta":
|
|
511
|
-
this.emitReasoningDelta(
|
|
556
|
+
this.emitReasoningDelta(
|
|
557
|
+
data.reasoningText ?? data.delta ?? data.text,
|
|
558
|
+
this.parentToolCallId(data)
|
|
559
|
+
);
|
|
512
560
|
break;
|
|
513
561
|
case "reason_complete":
|
|
514
562
|
this.closeReasoning();
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { normalizeCopiedSelectionText } from "./copy-selection";
|
|
3
|
+
|
|
4
|
+
describe("normalizeCopiedSelectionText", () => {
|
|
5
|
+
it("strips a single trailing newline left by block-element serialization", () => {
|
|
6
|
+
const raw = "Create a markdown document titled \"Bridge Test Report\".\n";
|
|
7
|
+
expect(normalizeCopiedSelectionText(raw)).toBe(
|
|
8
|
+
"Create a markdown document titled \"Bridge Test Report\"."
|
|
9
|
+
);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("strips multiple trailing blank lines and whitespace", () => {
|
|
13
|
+
expect(normalizeCopiedSelectionText("hello\n\n \n")).toBe("hello");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("strips leading blank lines", () => {
|
|
17
|
+
expect(normalizeCopiedSelectionText("\n\nhello")).toBe("hello");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("preserves interior newlines (multi-paragraph / multi-message selection)", () => {
|
|
21
|
+
expect(normalizeCopiedSelectionText("first\n\nsecond\n")).toBe("first\n\nsecond");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("preserves leading indentation on the first line (copied code)", () => {
|
|
25
|
+
expect(normalizeCopiedSelectionText(" indented line\nnext\n")).toBe(
|
|
26
|
+
" indented line\nnext"
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("returns an unchanged string when there is nothing to trim", () => {
|
|
31
|
+
expect(normalizeCopiedSelectionText("clean text")).toBe("clean text");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("returns an empty string for whitespace-only input", () => {
|
|
35
|
+
expect(normalizeCopiedSelectionText("\n\n \n")).toBe("");
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalize text pulled from a native browser selection before it is written to
|
|
3
|
+
* the clipboard.
|
|
4
|
+
*
|
|
5
|
+
* When a user triple-clicks a chat bubble and presses Ctrl/Cmd-C, the browser
|
|
6
|
+
* serializes the DOM selection itself. Markdown is rendered into block-level
|
|
7
|
+
* elements (`<p>`, `<li>`, `<pre>`, …), and browsers emit surrounding newlines
|
|
8
|
+
* for those blocks — so copying a single message drags along stray leading
|
|
9
|
+
* blank lines and a trailing newline that the user never visually selected.
|
|
10
|
+
*
|
|
11
|
+
* This trims the outer whitespace so the clipboard matches the visible
|
|
12
|
+
* selection, while preserving:
|
|
13
|
+
* - interior newlines (a multi-paragraph or multi-message selection keeps its
|
|
14
|
+
* line breaks), and
|
|
15
|
+
* - leading indentation on the first line (e.g. copied code keeps its indent;
|
|
16
|
+
* only fully-blank leading lines are dropped).
|
|
17
|
+
*/
|
|
18
|
+
export const normalizeCopiedSelectionText = (text: string): string =>
|
|
19
|
+
text.replace(/^\n+/, "").replace(/\s+$/, "");
|
|
@@ -304,28 +304,9 @@ describe("Event Capture Pipeline - no interference with message processing", ()
|
|
|
304
304
|
|
|
305
305
|
it("should still create assistant message correctly when event capture is active", async () => {
|
|
306
306
|
global.fetch = createMockFetch([
|
|
307
|
-
sseData({
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
name: "Prompt 1",
|
|
311
|
-
executionType: "prompt",
|
|
312
|
-
index: 1,
|
|
313
|
-
text: "Hello"
|
|
314
|
-
}),
|
|
315
|
-
sseData({
|
|
316
|
-
type: "step_chunk",
|
|
317
|
-
id: "step_1",
|
|
318
|
-
name: "Prompt 1",
|
|
319
|
-
executionType: "prompt",
|
|
320
|
-
index: 2,
|
|
321
|
-
text: " there"
|
|
322
|
-
}),
|
|
323
|
-
sseData({
|
|
324
|
-
type: "flow_complete",
|
|
325
|
-
flowId: "flow_1",
|
|
326
|
-
success: true,
|
|
327
|
-
duration: 100
|
|
328
|
-
})
|
|
307
|
+
sseData({ type: "text_delta", id: "text_1", delta: "Hello" }),
|
|
308
|
+
sseData({ type: "text_delta", id: "text_1", delta: " there" }),
|
|
309
|
+
sseData({ type: "execution_complete", kind: "flow", success: true })
|
|
329
310
|
]);
|
|
330
311
|
|
|
331
312
|
await client.dispatch(
|
|
@@ -374,20 +355,8 @@ describe("Event Capture Pipeline - no interference with message processing", ()
|
|
|
374
355
|
duration: 250,
|
|
375
356
|
completedAt: "2025-01-01T00:00:01.000Z"
|
|
376
357
|
}),
|
|
377
|
-
sseData({
|
|
378
|
-
|
|
379
|
-
id: "step_1",
|
|
380
|
-
name: "Prompt 1",
|
|
381
|
-
executionType: "prompt",
|
|
382
|
-
index: 1,
|
|
383
|
-
text: "Found results"
|
|
384
|
-
}),
|
|
385
|
-
sseData({
|
|
386
|
-
type: "flow_complete",
|
|
387
|
-
flowId: "flow_1",
|
|
388
|
-
success: true,
|
|
389
|
-
duration: 500
|
|
390
|
-
})
|
|
358
|
+
sseData({ type: "text_delta", id: "text_1", delta: "Found results" }),
|
|
359
|
+
sseData({ type: "execution_complete", kind: "flow", success: true })
|
|
391
360
|
]);
|
|
392
361
|
|
|
393
362
|
await client.dispatch(
|
|
@@ -442,20 +411,8 @@ describe("Event Capture Pipeline - no interference with message processing", ()
|
|
|
442
411
|
});
|
|
443
412
|
|
|
444
413
|
global.fetch = createMockFetch([
|
|
445
|
-
sseData({
|
|
446
|
-
|
|
447
|
-
id: "step_1",
|
|
448
|
-
name: "Prompt 1",
|
|
449
|
-
executionType: "prompt",
|
|
450
|
-
index: 1,
|
|
451
|
-
text: "Works fine"
|
|
452
|
-
}),
|
|
453
|
-
sseData({
|
|
454
|
-
type: "flow_complete",
|
|
455
|
-
flowId: "flow_1",
|
|
456
|
-
success: true,
|
|
457
|
-
duration: 50
|
|
458
|
-
})
|
|
414
|
+
sseData({ type: "text_delta", id: "text_1", delta: "Works fine" }),
|
|
415
|
+
sseData({ type: "execution_complete", kind: "flow", success: true })
|
|
459
416
|
]);
|
|
460
417
|
|
|
461
418
|
const events: AgentWidgetEvent[] = [];
|
|
@@ -496,20 +453,8 @@ describe("Event Capture Pipeline - no interference with message processing", ()
|
|
|
496
453
|
});
|
|
497
454
|
|
|
498
455
|
global.fetch = createMockFetch([
|
|
499
|
-
sseData({
|
|
500
|
-
|
|
501
|
-
id: "step_1",
|
|
502
|
-
name: "Prompt 1",
|
|
503
|
-
executionType: "prompt",
|
|
504
|
-
index: 1,
|
|
505
|
-
text: "Still works"
|
|
506
|
-
}),
|
|
507
|
-
sseData({
|
|
508
|
-
type: "flow_complete",
|
|
509
|
-
flowId: "flow_1",
|
|
510
|
-
success: true,
|
|
511
|
-
duration: 50
|
|
512
|
-
})
|
|
456
|
+
sseData({ type: "text_delta", id: "text_1", delta: "Still works" }),
|
|
457
|
+
sseData({ type: "execution_complete", kind: "flow", success: true })
|
|
513
458
|
]);
|
|
514
459
|
|
|
515
460
|
const events: AgentWidgetEvent[] = [];
|
|
@@ -1,256 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,130 +0,0 @@
|
|
|
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
|
-
}
|