mixdog 0.9.151 → 0.9.152
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 +110 -68
- package/package.json +3 -2
- package/scripts/tool-stress.mjs +0 -2
- package/src/defaults/skills/setup/SKILL.md +18 -27
- package/src/headless-exec.mjs +3 -10
- package/src/headless-exec.test.mjs +2 -7
- package/src/runtime/agent/orchestrator/config.mjs +6 -66
- package/src/runtime/agent/orchestrator/context/collect-skills.test.mjs +153 -0
- package/src/runtime/agent/orchestrator/context/collect.mjs +49 -45
- package/src/runtime/agent/orchestrator/mcp/client.mjs +242 -15
- package/src/runtime/agent/orchestrator/mcp/features.test.mjs +66 -0
- package/src/runtime/agent/orchestrator/mcp/security.test.mjs +36 -20
- package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +54 -33
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +1 -0
- package/src/runtime/agent/orchestrator/providers/antigravity-oauth.mjs +7 -0
- package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +7 -0
- package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +3 -8
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +7 -0
- package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +10 -2
- package/src/runtime/agent/orchestrator/providers/lib/provider-replay.mjs +50 -0
- package/src/runtime/agent/orchestrator/providers/lib/sse-framing.mjs +93 -0
- package/src/runtime/agent/orchestrator/providers/model-list-sanitize.mjs +3 -3
- package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +8 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +7 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +25 -2
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +35 -4
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +22 -0
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +0 -2
- package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +13 -0
- package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +4 -7
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +9 -0
- package/src/runtime/agent/orchestrator/providers/provider-replay.test.mjs +235 -0
- package/src/runtime/agent/orchestrator/providers/sse-framing.test.mjs +719 -0
- package/src/runtime/agent/orchestrator/providers/stream-json-pool.mjs +399 -39
- package/src/runtime/agent/orchestrator/providers/stream-json-worker.mjs +26 -0
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +14 -4
- package/src/runtime/agent/orchestrator/session/cache/read-cache.mjs +13 -0
- package/src/runtime/agent/orchestrator/session/compact/runner.mjs +24 -1
- package/src/runtime/agent/orchestrator/session/compaction-read-reset.test.mjs +161 -0
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +14 -4
- package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +2 -2
- package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +12 -1
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +31 -18
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +3 -0
- package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +6 -1
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +1 -3
- package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint-journal.mjs +556 -0
- package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint-journal.test.mjs +457 -0
- package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint.mjs +105 -186
- package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +115 -0
- package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +7 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +23 -1
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool-cwd-env.test.mjs +14 -0
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +19 -10
- package/src/runtime/agent/orchestrator/tools/builtin/find-search-budget.test.mjs +30 -14
- package/src/runtime/agent/orchestrator/tools/builtin/git-command-policy.mjs +15 -4
- package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +73 -15
- package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.test.mjs +76 -2
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +24 -683
- package/src/runtime/agent/orchestrator/tools/builtin/native-search-client.mjs +64 -72
- package/src/runtime/agent/orchestrator/tools/builtin/native-search-runner.mjs +2 -5
- package/src/runtime/agent/orchestrator/tools/builtin/native-search-transport.mjs +75 -0
- package/src/runtime/agent/orchestrator/tools/builtin/native-search-transport.test.mjs +24 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +12 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +4 -1
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +24 -5
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.test.mjs +44 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +12 -2
- package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +35 -21
- package/src/runtime/agent/orchestrator/tools/code-graph/memory-cache.mjs +28 -13
- package/src/runtime/agent/orchestrator/tools/code-graph/memory-cache.test.mjs +54 -0
- package/src/runtime/agent/orchestrator/tools/graph-manifest.json +11 -11
- package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
- package/src/runtime/agent/orchestrator/tools/progress-message.mjs +8 -0
- package/src/runtime/agent/orchestrator/tools/spawn-manifest.json +11 -11
- package/src/runtime/browser-bridge/client.mjs +103 -0
- package/src/runtime/browser-bridge/tool-defs.mjs +42 -0
- package/src/runtime/computer-bridge/client.mjs +101 -0
- package/src/runtime/computer-bridge/tool-defs.mjs +53 -0
- package/src/runtime/media/store.mjs +18 -8
- package/src/runtime/media/store.test.mjs +14 -0
- package/src/runtime/memory/index.mjs +1 -1
- package/src/runtime/memory/lib/transcript-ingest.mjs +8 -6
- package/src/runtime/memory/lib/transcript-ingest.test.mjs +52 -0
- package/src/runtime/shared/child-spawn-gate.mjs +17 -0
- package/src/runtime/shared/child-spawn-remote.mjs +69 -7
- package/src/runtime/shared/child-spawn-remote.test.mjs +124 -0
- package/src/runtime/shared/pristine-execution-contract.json +2 -1
- package/src/runtime/shared/provider-api-key.mjs +1 -0
- package/src/runtime/shared/session-runtime-health.mjs +91 -0
- package/src/runtime/shared/session-runtime-health.test.mjs +53 -0
- package/src/runtime/shared/skill-document.mjs +86 -0
- package/src/runtime/shared/skill-document.test.mjs +81 -0
- package/src/runtime/shared/tool-surface.mjs +18 -0
- package/src/runtime/shared/turn-snapshot-store.mjs +1 -0
- package/src/runtime/shared/turn-snapshot.mjs +32 -9
- package/src/runtime/shared/user-cwd.mjs +33 -14
- package/src/runtime/shared/user-cwd.test.mjs +43 -0
- package/src/session-runtime/cwd-plugins.mjs +26 -26
- package/src/session-runtime/cwd-plugins.test.mjs +75 -0
- package/src/session-runtime/cwd-tool-routing.test.mjs +5 -0
- package/src/session-runtime/global-extensions.mjs +26 -0
- package/src/session-runtime/global-extensions.test.mjs +28 -0
- package/src/session-runtime/goal-runtime.mjs +721 -0
- package/src/session-runtime/goal-runtime.test.mjs +108 -0
- package/src/session-runtime/lifecycle-api.mjs +18 -4
- package/src/session-runtime/lifecycle-api.test.mjs +21 -0
- package/src/session-runtime/mcp-glue.mjs +49 -47
- package/src/session-runtime/plugin-mcp-standard.test.mjs +177 -0
- package/src/session-runtime/plugin-mcp.mjs +96 -22
- package/src/session-runtime/prewarm.mjs +1 -10
- package/src/session-runtime/resource-api-global.test.mjs +69 -0
- package/src/session-runtime/resource-api.mjs +173 -58
- package/src/session-runtime/runtime-core.mjs +148 -20
- package/src/session-runtime/runtime-tunables.mjs +6 -8
- package/src/session-runtime/session-turn-api.mjs +22 -11
- package/src/session-runtime/skills-api.mjs +79 -25
- package/src/session-runtime/skills-api.test.mjs +99 -0
- package/src/session-runtime/tool-catalog-schema.mjs +0 -5
- package/src/session-runtime/tool-catalog.mjs +4 -13
- package/src/session-runtime/tool-policy-surface.test.mjs +33 -11
- package/src/session-runtime/tool-surface.mjs +12 -0
- package/src/standalone/agent-dispatch-broker.mjs +5 -9
- package/src/standalone/agent-tool/notify.mjs +6 -8
- package/src/standalone/agent-tool/notify.test.mjs +37 -0
- package/src/standalone/daemon.mjs +31 -15
- package/src/standalone/hook-bus/config.mjs +1 -0
- package/src/standalone/plugin-admin.mjs +17 -0
- package/src/standalone/plugin-admin.test.mjs +7 -0
- package/src/standalone/provider-admin.mjs +1 -0
- package/src/standalone/session-protocol.mjs +5 -0
- package/src/standalone/session-runtime-agent-control-client.mjs +133 -0
- package/src/standalone/session-runtime-agent-control-client.test.mjs +71 -0
- package/src/standalone/session-runtime-dispatch-cancel.test.mjs +139 -0
- package/src/standalone/session-runtime-host-factory.mjs +17 -0
- package/src/standalone/session-runtime-host-factory.test.mjs +31 -0
- package/src/standalone/session-runtime-host-health.test.mjs +5 -0
- package/src/standalone/session-runtime-host.mjs +696 -58
- package/src/standalone/session-runtime-inline-host.mjs +351 -0
- package/src/standalone/session-runtime-inline-host.test.mjs +83 -0
- package/src/standalone/session-runtime-provider-cooldown.mjs +78 -0
- package/src/standalone/session-runtime-provider-cooldown.test.mjs +80 -0
- package/src/standalone/session-runtime-shard-host.test.mjs +569 -0
- package/src/standalone/session-runtime-shard-router.mjs +149 -0
- package/src/standalone/session-runtime-shard-router.test.mjs +101 -0
- package/src/standalone/session-runtime-worker.mjs +476 -18
- package/src/standalone/session-service.mjs +81 -6
- package/src/tui/app/app-view.jsx +1 -0
- package/src/tui/app/core-memory-picker.mjs +2 -0
- package/src/tui/app/extension-pickers.mjs +1 -1
- package/src/tui/app/maintenance-pickers.mjs +3 -3
- package/src/tui/app/model-options.mjs +2 -0
- package/src/tui/app/model-picker.mjs +14 -4
- package/src/tui/app/panel-handoff.test.mjs +311 -0
- package/src/tui/app/project-picker.mjs +1 -0
- package/src/tui/app/route-pickers.mjs +21 -6
- package/src/tui/app/settings-picker.mjs +26 -3
- package/src/tui/app/slash-commands.mjs +1 -0
- package/src/tui/app/slash-dispatch.mjs +73 -22
- package/src/tui/app/theme-effort-pickers.mjs +7 -2
- package/src/tui/components/Picker.jsx +10 -1
- package/src/tui/dist/index.mjs +163 -44
- package/src/tui/session/context-state.mjs +1 -0
- package/src/tui/session/goal-continuation.mjs +110 -0
- package/src/tui/session/goal-continuation.test.mjs +84 -0
- package/src/tui/session/queue-helpers.mjs +5 -2
- package/src/tui/session/session-action-surface.test.mjs +108 -0
- package/src/tui/session/session-api-ext.mjs +39 -8
- package/src/tui/session/session-api.mjs +58 -0
- package/src/tui/session/session-flow.mjs +6 -0
- package/src/tui/session/turn.mjs +1 -0
- package/src/tui/session-local.mjs +15 -1
- package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +0 -301
|
@@ -0,0 +1,719 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
frameAndParseSse,
|
|
6
|
+
frameSseRegion,
|
|
7
|
+
parseSseFrames,
|
|
8
|
+
splitSseRegion,
|
|
9
|
+
} from './lib/sse-framing.mjs';
|
|
10
|
+
import { createStreamJsonPool, frameProviderSseChunk } from './stream-json-pool.mjs';
|
|
11
|
+
import { parseSSEStream } from './anthropic-sse.mjs';
|
|
12
|
+
|
|
13
|
+
// Reference implementation: the exact line loop the Anthropic reader used
|
|
14
|
+
// before whole-chunk framing. Parity against it is the semantics contract.
|
|
15
|
+
function legacyFrame(text, currentEvent = '') {
|
|
16
|
+
const frames = [];
|
|
17
|
+
let name = currentEvent;
|
|
18
|
+
const lines = text.split('\n');
|
|
19
|
+
lines.pop();
|
|
20
|
+
for (const line of lines) {
|
|
21
|
+
if (line.startsWith(':')) continue;
|
|
22
|
+
if (line === '') continue;
|
|
23
|
+
if (line.startsWith('event: ')) {
|
|
24
|
+
name = line.slice(7).trim();
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (!line.startsWith('data: ')) continue;
|
|
28
|
+
const data = line.slice(6).trim();
|
|
29
|
+
if (!data) continue;
|
|
30
|
+
frames.push({ name, data });
|
|
31
|
+
}
|
|
32
|
+
return { frames, currentEvent: name };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const FIXTURE = [
|
|
36
|
+
': ping\n\n',
|
|
37
|
+
'event: message_start\ndata: {"type":"message_start","message":{"model":"m"}}\n\n',
|
|
38
|
+
':ping\n\n',
|
|
39
|
+
'event: content_block_delta\r\ndata: {"type":"content_block_delta","index":0}\r\n\r\n',
|
|
40
|
+
'data: {"type":"anonymous"}\n\n',
|
|
41
|
+
'data: \n\n',
|
|
42
|
+
'id: 7\n',
|
|
43
|
+
'event: message_stop\ndata: {"type":"message_stop"}\n\n',
|
|
44
|
+
].join('');
|
|
45
|
+
|
|
46
|
+
test('framer matches the legacy line loop, including keepalives and CRLF', () => {
|
|
47
|
+
const region = splitSseRegion(FIXTURE);
|
|
48
|
+
assert.equal(region.rest, '');
|
|
49
|
+
assert.deepEqual(frameSseRegion(region.region, ''), legacyFrame(FIXTURE, ''));
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('framer matches the legacy loop for every chunk boundary', () => {
|
|
53
|
+
for (let cut = 0; cut <= FIXTURE.length; cut += 1) {
|
|
54
|
+
let buffer = '';
|
|
55
|
+
let carry = '';
|
|
56
|
+
const framed = [];
|
|
57
|
+
for (const piece of [FIXTURE.slice(0, cut), FIXTURE.slice(cut)]) {
|
|
58
|
+
buffer += piece;
|
|
59
|
+
const { region, rest } = splitSseRegion(buffer);
|
|
60
|
+
buffer = rest;
|
|
61
|
+
if (!region) continue;
|
|
62
|
+
const result = frameSseRegion(region, carry);
|
|
63
|
+
carry = result.currentEvent;
|
|
64
|
+
framed.push(...result.frames);
|
|
65
|
+
}
|
|
66
|
+
assert.equal(buffer, '', `residual buffer at cut ${cut}`);
|
|
67
|
+
assert.deepEqual(framed, legacyFrame(FIXTURE, '').frames, `frames at cut ${cut}`);
|
|
68
|
+
assert.equal(carry, legacyFrame(FIXTURE, '').currentEvent, `carry at cut ${cut}`);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('splitSseRegion keeps the trailing partial record buffered', () => {
|
|
73
|
+
assert.deepEqual(splitSseRegion('data: {"a":1}\npart'), {
|
|
74
|
+
region: 'data: {"a":1}\n',
|
|
75
|
+
rest: 'part',
|
|
76
|
+
});
|
|
77
|
+
assert.deepEqual(splitSseRegion('no newline yet'), { region: '', rest: 'no newline yet' });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('a malformed record is isolated, never discarding its batch', () => {
|
|
81
|
+
const events = parseSseFrames([
|
|
82
|
+
{ name: 'a', data: '{"ok":1}' },
|
|
83
|
+
{ name: 'b', data: '{oops' },
|
|
84
|
+
{ name: 'c', data: '{"ok":2}' },
|
|
85
|
+
]);
|
|
86
|
+
assert.deepEqual(events[0], { name: 'a', value: { ok: 1 } });
|
|
87
|
+
assert.equal(events[1].value, undefined);
|
|
88
|
+
assert.equal(events[1].error.name, 'SyntaxError');
|
|
89
|
+
assert.deepEqual(events[2], { name: 'c', value: { ok: 2 } });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// --- pool routing ---
|
|
93
|
+
|
|
94
|
+
// Deterministic stand-in for the stream worker: replies only when the test
|
|
95
|
+
// flushes it, so routing and ordering are observable without timing races.
|
|
96
|
+
class FakeWorker {
|
|
97
|
+
constructor() {
|
|
98
|
+
FakeWorker.spawnAttempts += 1;
|
|
99
|
+
if (FakeWorker.spawnLimit !== null && FakeWorker.instances.length >= FakeWorker.spawnLimit) {
|
|
100
|
+
throw new Error('EAGAIN: cannot spawn worker thread');
|
|
101
|
+
}
|
|
102
|
+
this.handlers = new Map();
|
|
103
|
+
this.posted = [];
|
|
104
|
+
this.held = [];
|
|
105
|
+
FakeWorker.instances.push(this);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
on(event, handler) {
|
|
109
|
+
const list = this.handlers.get(event) || [];
|
|
110
|
+
list.push(handler);
|
|
111
|
+
this.handlers.set(event, list);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
emit(event, payload) {
|
|
115
|
+
for (const handler of this.handlers.get(event) || []) handler(payload);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
postMessage(message) {
|
|
119
|
+
this.posted.push(message);
|
|
120
|
+
this.held.push(message);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
reply(message) {
|
|
124
|
+
if (message.kind === 'sse') {
|
|
125
|
+
const framed = frameAndParseSse(String(message.text || ''), String(message.event || ''));
|
|
126
|
+
queueMicrotask(() => this.emit('message', {
|
|
127
|
+
id: message.id,
|
|
128
|
+
ok: true,
|
|
129
|
+
kind: 'sse',
|
|
130
|
+
events: framed.events,
|
|
131
|
+
event: framed.currentEvent,
|
|
132
|
+
}));
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
// Batch protocol (Gemini/OpenAI payload lists) — mirrors the worker.
|
|
136
|
+
let reply;
|
|
137
|
+
try {
|
|
138
|
+
reply = { id: message.id, ok: true, values: message.payloads.map((p) => JSON.parse(String(p))) };
|
|
139
|
+
} catch (error) {
|
|
140
|
+
reply = { id: message.id, ok: false, error: { name: error.name, message: error.message } };
|
|
141
|
+
}
|
|
142
|
+
queueMicrotask(() => this.emit('message', reply));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Reply to held messages; with `filter`, only to the matching ones. */
|
|
146
|
+
flush(filter) {
|
|
147
|
+
const send = [];
|
|
148
|
+
const keep = [];
|
|
149
|
+
for (const message of this.held) {
|
|
150
|
+
if (!filter || filter(message)) send.push(message);
|
|
151
|
+
else keep.push(message);
|
|
152
|
+
}
|
|
153
|
+
this.held = keep;
|
|
154
|
+
for (const message of send) this.reply(message);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
terminate() { return Promise.resolve(); }
|
|
158
|
+
unref() {}
|
|
159
|
+
ref() {}
|
|
160
|
+
}
|
|
161
|
+
FakeWorker.instances = [];
|
|
162
|
+
FakeWorker.spawnLimit = null;
|
|
163
|
+
FakeWorker.spawnAttempts = 0;
|
|
164
|
+
|
|
165
|
+
function sseText(events) {
|
|
166
|
+
return events.map((event) => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`).join('');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
test('small chunks are framed inline and synchronously (no per-event microtask)', () => {
|
|
170
|
+
const pool = createStreamJsonPool({ maxWorkers: 1, minBatchBytes: 64 * 1024, WorkerImpl: FakeWorker });
|
|
171
|
+
const text = sseText([{ type: 'a', i: 1 }, { type: 'b', i: 2 }, { type: 'c', i: 3 }]);
|
|
172
|
+
const framed = pool.frameSse(text, { streamKey: 'inline' });
|
|
173
|
+
assert.equal(typeof framed?.then, 'undefined');
|
|
174
|
+
assert.deepEqual(framed.events.map((event) => event.value.type), ['a', 'b', 'c']);
|
|
175
|
+
assert.equal(pool.snapshot().inlineChunks, 1);
|
|
176
|
+
assert.equal(pool.snapshot().offloadedChunks, 0);
|
|
177
|
+
assert.equal(pool.snapshot().workers, 0);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('large chunks are offloaded and settle in per-stream submission order', async () => {
|
|
181
|
+
FakeWorker.instances.length = 0;
|
|
182
|
+
const pool = createStreamJsonPool({ maxWorkers: 1, minBatchBytes: 256, WorkerImpl: FakeWorker });
|
|
183
|
+
const large = sseText(Array.from({ length: 40 }, (_, i) => ({ type: 'big', i })));
|
|
184
|
+
const small = sseText([{ type: 'small', i: 0 }]);
|
|
185
|
+
assert.ok(Buffer.byteLength(large) >= 256);
|
|
186
|
+
assert.ok(Buffer.byteLength(small) < 256);
|
|
187
|
+
|
|
188
|
+
const settled = [];
|
|
189
|
+
const first = Promise.resolve(pool.frameSse(large, { streamKey: 's1' }))
|
|
190
|
+
.then((value) => { settled.push(['first', value.events.length]); return value; });
|
|
191
|
+
const worker = FakeWorker.instances.at(-1);
|
|
192
|
+
assert.equal(worker.posted.length, 1);
|
|
193
|
+
assert.equal(worker.posted[0].kind, 'sse');
|
|
194
|
+
|
|
195
|
+
const second = Promise.resolve(pool.frameSse(small, { streamKey: 's1' }))
|
|
196
|
+
.then((value) => { settled.push(['second', value.events.length]); return value; });
|
|
197
|
+
|
|
198
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
199
|
+
assert.deepEqual(settled, [], 'inline chunk must not overtake the offloaded one');
|
|
200
|
+
worker.flush();
|
|
201
|
+
await Promise.all([first, second]);
|
|
202
|
+
assert.deepEqual(settled, [['first', 40], ['second', 1]]);
|
|
203
|
+
assert.equal(pool.snapshot().offloadedChunks, 1);
|
|
204
|
+
assert.equal(pool.snapshot().pendingBytes, 0);
|
|
205
|
+
await pool.close();
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test('worker loss and pool close fall back to inline framing instead of failing the stream', async () => {
|
|
209
|
+
FakeWorker.instances.length = 0;
|
|
210
|
+
const pool = createStreamJsonPool({ maxWorkers: 1, minBatchBytes: 128, WorkerImpl: FakeWorker });
|
|
211
|
+
const large = sseText(Array.from({ length: 20 }, (_, i) => ({ type: 'big', i })));
|
|
212
|
+
|
|
213
|
+
const pending = pool.frameSse(large, { streamKey: 'dead' });
|
|
214
|
+
const worker = FakeWorker.instances.at(-1);
|
|
215
|
+
worker.emit('error', new Error('worker died'));
|
|
216
|
+
const recovered = await pending;
|
|
217
|
+
assert.equal(recovered.events.length, 20);
|
|
218
|
+
assert.equal(pool.snapshot().fallbackBatches, 1);
|
|
219
|
+
assert.equal(pool.snapshot().pendingBytes, 0);
|
|
220
|
+
|
|
221
|
+
await pool.close();
|
|
222
|
+
const afterClose = pool.frameSse(large, { streamKey: 'dead' });
|
|
223
|
+
assert.equal(typeof afterClose?.then, 'undefined');
|
|
224
|
+
assert.equal(afterClose.events.length, 20);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test('active stream tails and owner affinity survive thousands of other live streams', async () => {
|
|
228
|
+
// Regression: the metadata bound used to evict the OLDEST entry, which is
|
|
229
|
+
// by definition an ACTIVE stream — s0 lost its tail and its affinity while
|
|
230
|
+
// chunk 1 was still in flight, so chunk 2 settled first (Reviewer repro).
|
|
231
|
+
FakeWorker.instances.length = 0;
|
|
232
|
+
const pool = createStreamJsonPool({
|
|
233
|
+
maxWorkers: 2,
|
|
234
|
+
minBatchBytes: 64,
|
|
235
|
+
maxPendingBytes: 64 * 1024 * 1024,
|
|
236
|
+
WorkerImpl: FakeWorker,
|
|
237
|
+
});
|
|
238
|
+
const chunkOne = sseText([{ type: 's0-1', pad: 'a'.repeat(120) }]);
|
|
239
|
+
const chunkTwo = sseText([{ type: 's0-2' }]); // below the threshold → inline route
|
|
240
|
+
const filler = sseText([{ type: 'filler', pad: 'f'.repeat(120) }]);
|
|
241
|
+
assert.ok(Buffer.byteLength(chunkTwo) < 64);
|
|
242
|
+
|
|
243
|
+
const settled = [];
|
|
244
|
+
const first = Promise.resolve(pool.frameSse(chunkOne, { ownerKey: 'owner-0', streamKey: 's0' }))
|
|
245
|
+
.then((value) => { settled.push('s0#1'); return value; });
|
|
246
|
+
|
|
247
|
+
const others = [];
|
|
248
|
+
const otherStreams = 4600; // past the former 4,096-entry hard bound
|
|
249
|
+
for (let index = 1; index <= otherStreams; index += 1) {
|
|
250
|
+
others.push(Promise.resolve(pool.frameSse(filler, {
|
|
251
|
+
ownerKey: `owner-${index}`,
|
|
252
|
+
streamKey: `s${index}`,
|
|
253
|
+
})));
|
|
254
|
+
}
|
|
255
|
+
const second = Promise.resolve(pool.frameSse(chunkTwo, { ownerKey: 'owner-0', streamKey: 's0' }))
|
|
256
|
+
.then((value) => { settled.push('s0#2'); return value; });
|
|
257
|
+
|
|
258
|
+
// Every live stream still owns its ordering slot and its affinity.
|
|
259
|
+
assert.equal(pool.snapshot().orderedStreams, otherStreams + 1);
|
|
260
|
+
assert.equal(pool.snapshot().ownerAffinities, otherStreams + 1);
|
|
261
|
+
|
|
262
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
263
|
+
assert.deepEqual(settled, [], 'chunk 2 must not settle while chunk 1 is in flight');
|
|
264
|
+
for (const worker of FakeWorker.instances) worker.flush();
|
|
265
|
+
await Promise.all([first, second, ...others]);
|
|
266
|
+
assert.deepEqual(settled, ['s0#1', 's0#2']);
|
|
267
|
+
|
|
268
|
+
// Settled streams release their slots; idle affinity entries are still
|
|
269
|
+
// pruned once the soft cap is reached, so the bound keeps working.
|
|
270
|
+
assert.equal(pool.snapshot().orderedStreams, 0);
|
|
271
|
+
for (let index = 0; index < 8; index += 1) {
|
|
272
|
+
const late = Promise.resolve(pool.frameSse(filler, {
|
|
273
|
+
ownerKey: `late-owner-${index}`,
|
|
274
|
+
streamKey: `late-${index}`,
|
|
275
|
+
}));
|
|
276
|
+
for (const worker of FakeWorker.instances) worker.flush();
|
|
277
|
+
await late;
|
|
278
|
+
}
|
|
279
|
+
assert.ok(pool.snapshot().ownerAffinities <= 4096, 'idle affinity metadata stays bounded');
|
|
280
|
+
await pool.close();
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test('an open transport retains owner affinity while waiting between chunks', async () => {
|
|
284
|
+
FakeWorker.instances.length = 0;
|
|
285
|
+
const pool = createStreamJsonPool({
|
|
286
|
+
maxWorkers: 2,
|
|
287
|
+
minBatchBytes: 64,
|
|
288
|
+
maxIdleOwnerAffinities: 4,
|
|
289
|
+
WorkerImpl: FakeWorker,
|
|
290
|
+
});
|
|
291
|
+
const chunk = sseText([{ type: 'chunk', pad: 'x'.repeat(120) }]);
|
|
292
|
+
const pre = Promise.resolve(pool.frameSse(chunk, { ownerKey: 'pre-owner', streamKey: 'pre' }));
|
|
293
|
+
const workerA = FakeWorker.instances[0];
|
|
294
|
+
|
|
295
|
+
assert.equal(pool.retainStream('open-stream', 'open-owner'), true);
|
|
296
|
+
const first = Promise.resolve(pool.frameSse(chunk, {
|
|
297
|
+
ownerKey: 'open-owner',
|
|
298
|
+
streamKey: 'open-stream',
|
|
299
|
+
}));
|
|
300
|
+
const workerB = FakeWorker.instances[1];
|
|
301
|
+
assert.notEqual(workerA, workerB);
|
|
302
|
+
|
|
303
|
+
workerA.flush();
|
|
304
|
+
workerB.flush();
|
|
305
|
+
await Promise.all([pre, first]);
|
|
306
|
+
assert.equal(pool.snapshot().retainedStreams, 1);
|
|
307
|
+
|
|
308
|
+
for (let index = 0; index < 8; index += 1) {
|
|
309
|
+
const other = Promise.resolve(pool.frameSse(chunk, {
|
|
310
|
+
ownerKey: `idle-owner-${index}`,
|
|
311
|
+
streamKey: `idle-stream-${index}`,
|
|
312
|
+
}));
|
|
313
|
+
for (const worker of FakeWorker.instances) worker.flush();
|
|
314
|
+
await other;
|
|
315
|
+
}
|
|
316
|
+
assert.ok(pool.snapshot().ownerAffinities <= 4);
|
|
317
|
+
|
|
318
|
+
const second = Promise.resolve(pool.frameSse(chunk, {
|
|
319
|
+
ownerKey: 'open-owner',
|
|
320
|
+
streamKey: 'open-stream',
|
|
321
|
+
}));
|
|
322
|
+
assert.equal(workerB.posted.at(-1)?.text, chunk, 'open stream stayed on its original worker');
|
|
323
|
+
workerB.flush();
|
|
324
|
+
await second;
|
|
325
|
+
|
|
326
|
+
pool.releaseStream('open-stream');
|
|
327
|
+
assert.equal(pool.snapshot().retainedStreams, 0);
|
|
328
|
+
await pool.close();
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test('a queued chunk keeps its owner affinity through a prune burst (no migration)', async () => {
|
|
332
|
+
// Regression: the affinity hold used to start at postMessage time, so an
|
|
333
|
+
// owner was "idle" between its settled chunk and its next queued chunk —
|
|
334
|
+
// any new-owner insert in that instant (drainWaiting bursts included)
|
|
335
|
+
// pruned it and the next chunk migrated to another worker.
|
|
336
|
+
FakeWorker.instances.length = 0;
|
|
337
|
+
const pool = createStreamJsonPool({
|
|
338
|
+
maxWorkers: 2,
|
|
339
|
+
minBatchBytes: 64,
|
|
340
|
+
maxPendingBytes: 64 * 1024 * 1024,
|
|
341
|
+
WorkerImpl: FakeWorker,
|
|
342
|
+
});
|
|
343
|
+
const pre = sseText([{ type: 'pre', pad: 'p'.repeat(120) }]);
|
|
344
|
+
const chunkOne = sseText([{ type: 's0-1', pad: 'a'.repeat(120) }]);
|
|
345
|
+
const chunkTwo = sseText([{ type: 's0-2' }]); // inline route, still queued
|
|
346
|
+
const chunkThree = sseText([{ type: 's0-3', pad: 'c'.repeat(120) }]);
|
|
347
|
+
const filler = sseText([{ type: 'filler', pad: 'f'.repeat(120) }]);
|
|
348
|
+
const pending = [];
|
|
349
|
+
|
|
350
|
+
// Another owner creates worker A, so s0 is affine to worker B.
|
|
351
|
+
pending.push(Promise.resolve(pool.frameSse(pre, { ownerKey: 'pre-owner', streamKey: 'pre' })));
|
|
352
|
+
const workerA = FakeWorker.instances[0];
|
|
353
|
+
const first = Promise.resolve(pool.frameSse(chunkOne, { ownerKey: 'owner-0', streamKey: 's0' }));
|
|
354
|
+
const workerB = FakeWorker.instances[1];
|
|
355
|
+
assert.notEqual(workerA, workerB);
|
|
356
|
+
assert.ok(workerB.posted.some((message) => message.text === chunkOne), 's0 starts on worker B');
|
|
357
|
+
|
|
358
|
+
for (let index = 1; index <= 4600; index += 1) {
|
|
359
|
+
pending.push(Promise.resolve(pool.frameSse(filler, {
|
|
360
|
+
ownerKey: `owner-${index}`,
|
|
361
|
+
streamKey: `s${index}`,
|
|
362
|
+
})));
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const settled = [];
|
|
366
|
+
const second = Promise.resolve(pool.frameSse(chunkTwo, { ownerKey: 'owner-0', streamKey: 's0' }))
|
|
367
|
+
.then((value) => { settled.push('s0#2'); return value; });
|
|
368
|
+
const third = Promise.resolve(pool.frameSse(chunkThree, { ownerKey: 'owner-0', streamKey: 's0' }))
|
|
369
|
+
.then((value) => { settled.push('s0#3'); return value; });
|
|
370
|
+
pending.push(first.then((value) => { settled.push('s0#1'); return value; }));
|
|
371
|
+
// Brand-new owners inserted in the instant AFTER chunk 1 settles and
|
|
372
|
+
// BEFORE the queued chunk 3 is posted: the exact prune window.
|
|
373
|
+
pending.push(first.then(() => {
|
|
374
|
+
for (let index = 0; index < 600; index += 1) {
|
|
375
|
+
pending.push(Promise.resolve(pool.frameSse(filler, {
|
|
376
|
+
ownerKey: `burst-${index}`,
|
|
377
|
+
streamKey: `burst-${index}`,
|
|
378
|
+
})));
|
|
379
|
+
}
|
|
380
|
+
}));
|
|
381
|
+
|
|
382
|
+
workerA.flush(); // worker A drains: thousands of owners go idle
|
|
383
|
+
workerB.flush((message) => message.text === chunkOne); // settle s0 chunk 1 only
|
|
384
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
385
|
+
|
|
386
|
+
assert.ok(
|
|
387
|
+
workerB.posted.some((message) => message.text === chunkThree),
|
|
388
|
+
'queued chunk 3 stayed on the affine worker B',
|
|
389
|
+
);
|
|
390
|
+
assert.equal(
|
|
391
|
+
workerA.posted.some((message) => message.text === chunkThree),
|
|
392
|
+
false,
|
|
393
|
+
'chunk 3 must not migrate to the least-loaded worker A',
|
|
394
|
+
);
|
|
395
|
+
assert.deepEqual(settled, ['s0#1', 's0#2']);
|
|
396
|
+
|
|
397
|
+
for (const worker of FakeWorker.instances) worker.flush();
|
|
398
|
+
await Promise.all([...pending, second, third]);
|
|
399
|
+
assert.deepEqual(settled, ['s0#1', 's0#2', 's0#3']);
|
|
400
|
+
assert.equal(pool.snapshot().pendingBytes, 0);
|
|
401
|
+
await pool.close();
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
test('idle affinity metadata is pruned on settlement, with no new owner arriving', async () => {
|
|
405
|
+
// Regression: pruning ran only on insertion, so 4,601 settled owners stayed
|
|
406
|
+
// in the map until some unrelated owner happened to show up.
|
|
407
|
+
FakeWorker.instances.length = 0;
|
|
408
|
+
const pool = createStreamJsonPool({
|
|
409
|
+
maxWorkers: 2,
|
|
410
|
+
minBatchBytes: 64,
|
|
411
|
+
maxPendingBytes: 64 * 1024 * 1024,
|
|
412
|
+
WorkerImpl: FakeWorker,
|
|
413
|
+
});
|
|
414
|
+
const chunk = sseText([{ type: 'chunk', pad: 'z'.repeat(120) }]);
|
|
415
|
+
const pending = [];
|
|
416
|
+
for (let index = 0; index < 4601; index += 1) {
|
|
417
|
+
pending.push(Promise.resolve(pool.frameSse(chunk, {
|
|
418
|
+
ownerKey: `owner-${index}`,
|
|
419
|
+
streamKey: `s${index}`,
|
|
420
|
+
})));
|
|
421
|
+
}
|
|
422
|
+
assert.equal(pool.snapshot().ownerAffinities, 4601, 'live owners are never evicted');
|
|
423
|
+
|
|
424
|
+
for (const worker of FakeWorker.instances) worker.flush();
|
|
425
|
+
await Promise.all(pending);
|
|
426
|
+
const after = pool.snapshot();
|
|
427
|
+
assert.ok(after.ownerAffinities <= 4096, `settled owners pruned (${after.ownerAffinities})`);
|
|
428
|
+
assert.equal(after.orderedStreams, 0);
|
|
429
|
+
assert.equal(after.pendingBytes, 0);
|
|
430
|
+
await pool.close();
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
test('a failed spawn settles that task inline instead of queueing it on a busy worker', async () => {
|
|
434
|
+
FakeWorker.instances.length = 0;
|
|
435
|
+
FakeWorker.spawnLimit = 1;
|
|
436
|
+
try {
|
|
437
|
+
const pool = createStreamJsonPool({ maxWorkers: 4, minBatchBytes: 64, WorkerImpl: FakeWorker });
|
|
438
|
+
const large = sseText(Array.from({ length: 8 }, (_, index) => ({ type: 'big', index })));
|
|
439
|
+
|
|
440
|
+
const held = Promise.resolve(pool.frameSse(large, { ownerKey: 'owner-a', streamKey: 'sa' }));
|
|
441
|
+
const workerA = FakeWorker.instances[0];
|
|
442
|
+
assert.equal(workerA.posted.length, 1);
|
|
443
|
+
|
|
444
|
+
// Worker A is busy, so this task wants its own worker and the spawn
|
|
445
|
+
// fails: it must settle inline right now, not queue behind worker A.
|
|
446
|
+
const inlined = await pool.frameSse(large, { ownerKey: 'owner-b', streamKey: 'sb' });
|
|
447
|
+
assert.equal(inlined.events.length, 8);
|
|
448
|
+
assert.equal(workerA.posted.length, 1, 'failed-spawn task was not queued onto worker A');
|
|
449
|
+
assert.equal(pool.snapshot().workers, 1);
|
|
450
|
+
|
|
451
|
+
const alsoInlined = await pool.frameSse(large, { ownerKey: 'owner-c', streamKey: 'sc' });
|
|
452
|
+
assert.equal(alsoInlined.events.length, 8);
|
|
453
|
+
assert.equal(workerA.posted.length, 1, 'the failure latch never serializes offload work');
|
|
454
|
+
|
|
455
|
+
workerA.flush();
|
|
456
|
+
assert.equal((await held).events.length, 8);
|
|
457
|
+
|
|
458
|
+
// A healthy IDLE worker is still reused: only queueing behind a busy
|
|
459
|
+
// worker the task deliberately avoided is refused.
|
|
460
|
+
const reused = Promise.resolve(pool.frameSse(large, { ownerKey: 'owner-d', streamKey: 'sd' }));
|
|
461
|
+
assert.equal(workerA.posted.length, 2, 'an idle healthy worker is still reused');
|
|
462
|
+
workerA.flush();
|
|
463
|
+
assert.equal((await reused).events.length, 8);
|
|
464
|
+
await pool.close();
|
|
465
|
+
} finally {
|
|
466
|
+
FakeWorker.spawnLimit = null;
|
|
467
|
+
}
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
test('a throwing WorkerImpl falls back to inline work without failing the stream', async () => {
|
|
471
|
+
class ExplodingWorker {
|
|
472
|
+
constructor() { throw new Error('EAGAIN: cannot spawn worker thread'); }
|
|
473
|
+
}
|
|
474
|
+
const pool = createStreamJsonPool({ maxWorkers: 2, minBatchBytes: 64, WorkerImpl: ExplodingWorker });
|
|
475
|
+
const large = sseText(Array.from({ length: 8 }, (_, index) => ({ type: 'big', index })));
|
|
476
|
+
|
|
477
|
+
const framed = await pool.frameSse(large, { ownerKey: 'owner-x', streamKey: 'sx' });
|
|
478
|
+
assert.equal(framed.events.length, 8);
|
|
479
|
+
assert.deepEqual(framed.events[0].value, { type: 'big', index: 0 });
|
|
480
|
+
assert.equal(framed.currentEvent, 'big');
|
|
481
|
+
assert.equal(pool.snapshot().workers, 0);
|
|
482
|
+
assert.ok(pool.snapshot().spawnFailures >= 1);
|
|
483
|
+
|
|
484
|
+
// Batch tasks keep their exact contract on the same fallback path.
|
|
485
|
+
const payload = JSON.stringify({ pad: 'y'.repeat(256) });
|
|
486
|
+
assert.deepEqual(await pool.parseBatch([payload]), [JSON.parse(payload)]);
|
|
487
|
+
await assert.rejects(pool.parseBatch([payload, '{"broken"']), (error) => error instanceof SyntaxError);
|
|
488
|
+
|
|
489
|
+
// Abort semantics are untouched by the fallback.
|
|
490
|
+
const controller = new AbortController();
|
|
491
|
+
controller.abort(new Error('canceled by caller'));
|
|
492
|
+
await assert.rejects(
|
|
493
|
+
pool.parseBatch([payload], { signal: controller.signal }),
|
|
494
|
+
/canceled by caller/,
|
|
495
|
+
);
|
|
496
|
+
|
|
497
|
+
// Repeated failures latch instead of re-throwing a constructor per chunk.
|
|
498
|
+
assert.equal(pool.snapshot().workerSpawnDisabled, true);
|
|
499
|
+
assert.equal(pool.snapshot().spawnFailures, 3);
|
|
500
|
+
const stillFramed = await pool.frameSse(large, { ownerKey: 'owner-x', streamKey: 'sx' });
|
|
501
|
+
assert.equal(stillFramed.events.length, 8);
|
|
502
|
+
assert.equal(pool.snapshot().spawnFailures, 3, 'latched: no further spawn attempts');
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
test('a WorkerImpl that fails listener wiring also degrades to inline work', async () => {
|
|
506
|
+
let terminated = 0;
|
|
507
|
+
class HalfBrokenWorker {
|
|
508
|
+
on() { throw new Error('listener wiring failed'); }
|
|
509
|
+
unref() {}
|
|
510
|
+
ref() {}
|
|
511
|
+
postMessage() { throw new Error('unreachable'); }
|
|
512
|
+
terminate() { terminated += 1; return Promise.resolve(); }
|
|
513
|
+
}
|
|
514
|
+
const pool = createStreamJsonPool({ maxWorkers: 1, minBatchBytes: 64, WorkerImpl: HalfBrokenWorker });
|
|
515
|
+
const large = sseText(Array.from({ length: 6 }, (_, index) => ({ type: 'big', index })));
|
|
516
|
+
const framed = await pool.frameSse(large, { ownerKey: 'owner-y', streamKey: 'sy' });
|
|
517
|
+
assert.equal(framed.events.length, 6);
|
|
518
|
+
assert.equal(terminated, 1, 'a half-built worker is terminated, not leaked');
|
|
519
|
+
assert.equal(pool.snapshot().workers, 0);
|
|
520
|
+
assert.ok(pool.snapshot().spawnFailures >= 1);
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
test('oversized chunks stay inline so pending worker memory stays bounded', () => {
|
|
524
|
+
FakeWorker.instances.length = 0;
|
|
525
|
+
const pool = createStreamJsonPool({
|
|
526
|
+
maxWorkers: 1,
|
|
527
|
+
minBatchBytes: 16,
|
|
528
|
+
maxPendingBytes: 1, // clamped up to the 1 MiB floor by the pool
|
|
529
|
+
WorkerImpl: FakeWorker,
|
|
530
|
+
});
|
|
531
|
+
const oversized = sseText([{ type: 'big', text: 'x'.repeat(1024 * 1024 + 4096) }]);
|
|
532
|
+
assert.ok(Buffer.byteLength(oversized) > pool.snapshot().maxPendingBytes);
|
|
533
|
+
const framed = pool.frameSse(oversized, { streamKey: 'huge' });
|
|
534
|
+
assert.equal(typeof framed?.then, 'undefined');
|
|
535
|
+
assert.equal(framed.events.length, 1);
|
|
536
|
+
assert.equal(pool.snapshot().pendingBytes, 0);
|
|
537
|
+
assert.equal(FakeWorker.instances.length, 0);
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
test('offloaded JSON batches keep their resolve/reject contract', async () => {
|
|
541
|
+
FakeWorker.instances.length = 0;
|
|
542
|
+
const pool = createStreamJsonPool({ maxWorkers: 1, minBatchBytes: 64, WorkerImpl: FakeWorker });
|
|
543
|
+
const payload = JSON.stringify({ pad: 'y'.repeat(256) });
|
|
544
|
+
|
|
545
|
+
const good = pool.parseBatch([payload, payload]);
|
|
546
|
+
const bad = pool.parseBatch([payload, '{"broken"']);
|
|
547
|
+
const worker = FakeWorker.instances.at(-1);
|
|
548
|
+
worker.flush();
|
|
549
|
+
assert.deepEqual(await good, [JSON.parse(payload), JSON.parse(payload)]);
|
|
550
|
+
await assert.rejects(bad, (error) => error instanceof SyntaxError);
|
|
551
|
+
assert.equal(pool.snapshot().pendingBytes, 0);
|
|
552
|
+
await pool.close();
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
// --- Anthropic reader parity ---
|
|
556
|
+
|
|
557
|
+
function rawResponse(chunks) {
|
|
558
|
+
const encoder = new TextEncoder();
|
|
559
|
+
const encoded = chunks.map((chunk) => encoder.encode(chunk));
|
|
560
|
+
let index = 0;
|
|
561
|
+
return {
|
|
562
|
+
body: {
|
|
563
|
+
getReader() {
|
|
564
|
+
return {
|
|
565
|
+
read() {
|
|
566
|
+
return index < encoded.length
|
|
567
|
+
? Promise.resolve({ done: false, value: encoded[index++] })
|
|
568
|
+
: Promise.resolve({ done: true, value: undefined });
|
|
569
|
+
},
|
|
570
|
+
cancel() { return Promise.resolve(); },
|
|
571
|
+
releaseLock() {},
|
|
572
|
+
};
|
|
573
|
+
},
|
|
574
|
+
},
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function freshState() {
|
|
579
|
+
return {
|
|
580
|
+
attemptIndex: 0,
|
|
581
|
+
sawMessageStart: false,
|
|
582
|
+
sawCompleted: false,
|
|
583
|
+
emittedToolCall: false,
|
|
584
|
+
partialToolCall: false,
|
|
585
|
+
emittedThinking: false,
|
|
586
|
+
emittedText: false,
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const TURN_EVENTS = [
|
|
591
|
+
{ type: 'message_start', message: { model: 'claude-test', usage: { input_tokens: 11 } } },
|
|
592
|
+
{ type: 'content_block_start', index: 0, content_block: { type: 'thinking', thinking: '' } },
|
|
593
|
+
{ type: 'content_block_delta', index: 0, delta: { type: 'thinking_delta', thinking: 'plan' } },
|
|
594
|
+
{ type: 'content_block_stop', index: 0 },
|
|
595
|
+
{ type: 'content_block_start', index: 1, content_block: { type: 'text', text: '' } },
|
|
596
|
+
{ type: 'content_block_delta', index: 1, delta: { type: 'text_delta', text: 'hello ' } },
|
|
597
|
+
{ type: 'content_block_delta', index: 1, delta: { type: 'text_delta', text: 'world' } },
|
|
598
|
+
{ type: 'content_block_stop', index: 1 },
|
|
599
|
+
{ type: 'content_block_start', index: 2, content_block: { type: 'tool_use', id: 'toolu_1', name: 'read' } },
|
|
600
|
+
{ type: 'content_block_delta', index: 2, delta: { type: 'input_json_delta', partial_json: '{"path"' } },
|
|
601
|
+
{ type: 'content_block_delta', index: 2, delta: { type: 'input_json_delta', partial_json: ':"a.txt"}' } },
|
|
602
|
+
{ type: 'content_block_stop', index: 2 },
|
|
603
|
+
{ type: 'message_delta', delta: { stop_reason: 'tool_use' }, usage: { output_tokens: 5 } },
|
|
604
|
+
{ type: 'message_stop' },
|
|
605
|
+
];
|
|
606
|
+
|
|
607
|
+
function turnText() {
|
|
608
|
+
return TURN_EVENTS
|
|
609
|
+
.map((event, index) => (index % 3 === 0 ? ': ping\n\n' : '')
|
|
610
|
+
+ `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`)
|
|
611
|
+
.join('');
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
async function runTurn(chunks) {
|
|
615
|
+
const state = freshState();
|
|
616
|
+
const textDeltas = [];
|
|
617
|
+
const toolCalls = [];
|
|
618
|
+
const deltaKinds = [];
|
|
619
|
+
const result = await parseSSEStream(
|
|
620
|
+
rawResponse(chunks),
|
|
621
|
+
null,
|
|
622
|
+
() => {},
|
|
623
|
+
(kind) => deltaKinds.push(kind),
|
|
624
|
+
(call) => toolCalls.push({ call, sawCompleted: state.sawCompleted }),
|
|
625
|
+
state,
|
|
626
|
+
(text) => textDeltas.push(text),
|
|
627
|
+
);
|
|
628
|
+
return { result, state, textDeltas, toolCalls, deltaKinds };
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
test('a whole-chunk batch keeps text, reasoning and eager tool dispatch intact', async () => {
|
|
632
|
+
const batched = await runTurn([turnText()]);
|
|
633
|
+
assert.equal(batched.result.content, 'hello world');
|
|
634
|
+
assert.equal(batched.result.model, 'claude-test');
|
|
635
|
+
assert.equal(batched.result.stopReason, 'tool_use');
|
|
636
|
+
assert.deepEqual(batched.textDeltas, ['hello ', 'world']);
|
|
637
|
+
assert.equal(batched.result.toolCalls.length, 1);
|
|
638
|
+
assert.deepEqual(batched.result.toolCalls[0], {
|
|
639
|
+
id: 'toolu_1',
|
|
640
|
+
name: 'read',
|
|
641
|
+
arguments: { path: 'a.txt' },
|
|
642
|
+
});
|
|
643
|
+
// Eager dispatch: the call fires while the batch is still being replayed,
|
|
644
|
+
// before the terminal frame of the same chunk is handled.
|
|
645
|
+
assert.equal(batched.toolCalls[0].sawCompleted, false);
|
|
646
|
+
assert.equal(batched.state.emittedToolCall, true);
|
|
647
|
+
assert.equal(batched.state.emittedThinking, true);
|
|
648
|
+
assert.deepEqual(batched.result.thinkingBlocks, [{ type: 'thinking', thinking: 'plan', signature: '' }]);
|
|
649
|
+
assert.equal(batched.result.usage.inputTokens, 11);
|
|
650
|
+
assert.equal(batched.result.usage.outputTokens, 5);
|
|
651
|
+
assert.ok(batched.deltaKinds.includes('transport'));
|
|
652
|
+
assert.ok(batched.deltaKinds.includes('text'));
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
test('chunk boundaries do not change the parsed turn', async () => {
|
|
656
|
+
const text = turnText();
|
|
657
|
+
const batched = await runTurn([text]);
|
|
658
|
+
const byteWise = await runTurn(Array.from(text, (character) => character));
|
|
659
|
+
const halved = await runTurn([text.slice(0, 137), text.slice(137)]);
|
|
660
|
+
for (const variant of [byteWise, halved]) {
|
|
661
|
+
assert.equal(variant.result.content, batched.result.content);
|
|
662
|
+
assert.deepEqual(variant.result.toolCalls, batched.result.toolCalls);
|
|
663
|
+
assert.deepEqual(variant.textDeltas, batched.textDeltas);
|
|
664
|
+
assert.deepEqual(variant.result.thinkingBlocks, batched.result.thinkingBlocks);
|
|
665
|
+
assert.equal(variant.state.sawCompleted, true);
|
|
666
|
+
}
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
test('a malformed record is skipped while its batch mates still stream', async () => {
|
|
670
|
+
const events = [
|
|
671
|
+
{ type: 'message_start', message: { model: 'm' } },
|
|
672
|
+
{ type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } },
|
|
673
|
+
];
|
|
674
|
+
const text = events.map((event) => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`).join('')
|
|
675
|
+
+ 'event: content_block_delta\ndata: {"type":"content_block_delta",\n\n'
|
|
676
|
+
+ `event: content_block_delta\ndata: ${JSON.stringify({ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'kept' } })}\n\n`
|
|
677
|
+
+ 'event: message_stop\ndata: {"type":"message_stop"}\n\n';
|
|
678
|
+
const run = await runTurn([text]);
|
|
679
|
+
assert.equal(run.result.content, 'kept');
|
|
680
|
+
assert.equal(run.state.sawCompleted, true);
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
test('a named error frame split across chunks still throws the typed SSE error', async () => {
|
|
684
|
+
await assert.rejects(
|
|
685
|
+
runTurn([
|
|
686
|
+
'event: message_start\ndata: {"type":"message_start","message":{"model":"m"}}\n\nevent: error\n',
|
|
687
|
+
'data: {"type":"overloaded_error","message":"boom"}\n\n',
|
|
688
|
+
]),
|
|
689
|
+
(error) => {
|
|
690
|
+
assert.equal(error.code, 'EANTHROPIC_SSE_ERROR');
|
|
691
|
+
assert.equal(error.providerErrorType, 'overloaded_error');
|
|
692
|
+
assert.equal(error.httpStatus, 503);
|
|
693
|
+
return true;
|
|
694
|
+
},
|
|
695
|
+
);
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
test('an oversized chunk round-trips through the real stream worker', async () => {
|
|
699
|
+
const filler = 'x'.repeat(48 * 1024);
|
|
700
|
+
const events = [
|
|
701
|
+
{ type: 'message_start', message: { model: 'm' } },
|
|
702
|
+
{ type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } },
|
|
703
|
+
{ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: filler } },
|
|
704
|
+
{ type: 'content_block_stop', index: 0 },
|
|
705
|
+
{ type: 'message_delta', delta: { stop_reason: 'end_turn' }, usage: { output_tokens: 3 } },
|
|
706
|
+
{ type: 'message_stop' },
|
|
707
|
+
];
|
|
708
|
+
const text = events.map((event) => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`).join('');
|
|
709
|
+
assert.ok(Buffer.byteLength(text) > 32 * 1024);
|
|
710
|
+
const framed = frameProviderSseChunk(text, { streamKey: 'real-worker' });
|
|
711
|
+
const resolved = typeof framed?.then === 'function' ? await framed : framed;
|
|
712
|
+
assert.equal(resolved.events.length, events.length);
|
|
713
|
+
assert.equal(resolved.events[2].value.delta.text.length, filler.length);
|
|
714
|
+
|
|
715
|
+
const run = await runTurn([text]);
|
|
716
|
+
assert.equal(run.result.content.length, filler.length);
|
|
717
|
+
assert.equal(run.result.stopReason, 'end_turn');
|
|
718
|
+
assert.equal(run.state.sawCompleted, true);
|
|
719
|
+
});
|