pi-task-dispatcher 3.1.0 → 3.2.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.
@@ -1,175 +1,211 @@
1
- /**
2
- * Verify supervised codemode loading and a reusable worker-side boundary after one completed tool batch.
3
- */
1
+ /** Exercise the release extension against a live Pi child, native codemode, and a manager-proxied tool. */
4
2
 
5
- import { spawn } from "node:child_process";
3
+ import assert from "node:assert/strict";
4
+ import { execFile } from "node:child_process";
6
5
  import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
6
+ import { createRequire } from "node:module";
7
7
  import { homedir, tmpdir } from "node:os";
8
8
  import path from "node:path";
9
- import readline from "node:readline";
9
+ import { promisify } from "node:util";
10
10
 
11
- // Resolve the managed Pi release without embedding a machine-specific installation path.
12
- async function resolvePiCli() {
11
+ const executeFile = promisify(execFile);
12
+ const projectRoot = path.resolve(import.meta.dirname, "..");
13
+ const temporaryDirectory = await mkdtemp(path.join(tmpdir(), "pi-task-dispatcher-gate-probe-"));
14
+ const configPath = path.join(temporaryDirectory, "task-dispatcher.json");
15
+ const readProbePath = path.join(temporaryDirectory, "codemode-read-probe.txt");
16
+ const marker = "pi-task-dispatcher-manager-proxy-ready";
17
+ const inheritedWorkerRole = process.env.PI_TASK_DISPATCHER_WORKER;
18
+ let handlers = new Map();
19
+
20
+ /** Resolve the managed Pi package and CLI without embedding a machine-specific installation path. */
21
+ async function resolvePiRuntime() {
13
22
  const installRoot = process.env.PI_MANAGED_INSTALL_ROOT ?? path.join(homedir(), ".pi", "agent", "install");
14
23
  const version = (await readFile(path.join(installRoot, "current-version"), "utf8")).trim();
15
24
  const packageRoot = path.join(installRoot, "releases", version, "node_modules", "@earendil-works", "pi-coding-agent");
16
25
  const packageJson = JSON.parse(await readFile(path.join(packageRoot, "package.json"), "utf8"));
17
26
  const declaredBin = typeof packageJson.bin === "string" ? packageJson.bin : packageJson.bin?.pi;
18
27
  if (!declaredBin) throw new Error("The managed Pi package does not declare its CLI entry point.");
19
- return path.resolve(packageRoot, declaredBin);
28
+ return { packageRoot, cliPath: path.resolve(packageRoot, declaredBin) };
20
29
  }
21
30
 
22
- const cliPath = await resolvePiCli();
23
- const temporaryDirectory = await mkdtemp(path.join(tmpdir(), "pi-task-dispatcher-gate-probe-"));
24
- const gatePath = path.join(temporaryDirectory, "gate.ts");
25
- const codemodeOnlyPath = path.join(temporaryDirectory, "codemode-only.ts");
26
- const readProbePath = path.join(temporaryDirectory, "codemode-read-probe.txt");
27
- let child;
28
-
29
- try {
30
- // Abort the active worker run only after its complete tool batch has been persisted.
31
- await writeFile(
32
- gatePath,
33
- `import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
34
- export default function (pi: ExtensionAPI): void {
35
- pi.on("turn_end", async (event, ctx) => {
36
- if (event.toolResults.length === 0) return;
37
- if (typeof process.send === "function") process.send({
38
- type: "task_dispatcher_boundary",
39
- turnIndex: event.turnIndex,
40
- toolResults: event.toolResults.length,
41
- });
42
- ctx.abort();
43
- });
31
+ /** Parse Pi's human-readable model sizes for the dispatcher's formula-only budget metadata. */
32
+ function parseSize(value) {
33
+ const match = /^(\d+(?:\.\d+)?)([KMG])?$/i.exec(value ?? "");
34
+ if (!match) return undefined;
35
+ const scale = { K: 1_000, M: 1_000_000, G: 1_000_000_000 }[match[2]?.toUpperCase()] ?? 1;
36
+ return Math.round(Number(match[1]) * scale);
44
37
  }
45
- `,
46
- "utf8",
47
- );
48
- // Use Pi's public factory so only codemode is model-visible while selected direct tools remain script-callable.
49
- await writeFile(
50
- codemodeOnlyPath,
51
- `import { createCodemodeExtension, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
52
- const codemodeOnly = createCodemodeExtension({ mode: "only" });
53
- export default function codemodeOnlyExtension(pi: ExtensionAPI): void {
54
- codemodeOnly(pi);
55
- }
56
- `,
57
- "utf8",
58
- );
59
- await writeFile(readProbePath, "native-codemode-only-read-ready\n", "utf8");
60
38
 
61
- // Prefer explicit test overrides, then the active parent Pi model exported in PI_* variables.
62
- const activeModel = process.env.PI_TASK_DISPATCHER_GATE_MODEL
39
+ /** Select the explicit/active model or the first credential-backed model listed by Pi. */
40
+ async function resolveModel(cliPath) {
41
+ const { stdout } = await executeFile(process.execPath, [cliPath, "--list-models"], { maxBuffer: 8 * 1024 * 1024 });
42
+ const rows = stdout.split(/\r?\n/).slice(1).map((line) => line.trim().split(/\s+/)).filter((columns) => columns.length >= 4);
43
+ const requested = process.env.PI_TASK_DISPATCHER_GATE_MODEL
63
44
  ?? (process.env.PI_PROVIDER && process.env.PI_MODEL ? `${process.env.PI_PROVIDER}/${process.env.PI_MODEL}` : undefined);
64
- const activeThinking = process.env.PI_TASK_DISPATCHER_GATE_THINKING ?? process.env.PI_REASONING_LEVEL;
65
- const workerArgs = [
66
- cliPath,
67
- "--mode",
68
- "rpc",
69
- "--no-session",
70
- "--no-extensions",
71
- "--tools",
72
- "codemode,read",
73
- "--extension",
74
- codemodeOnlyPath,
75
- "--extension",
76
- gatePath,
77
- ];
78
- if (activeModel) workerArgs.push("--model", activeModel);
79
- if (activeThinking) workerArgs.push("--thinking", activeThinking);
80
-
81
- child = spawn(
82
- process.execPath,
83
- workerArgs,
84
- {
85
- cwd: path.resolve(import.meta.dirname, ".."),
86
- stdio: ["pipe", "pipe", "pipe", "ipc"],
87
- env: { ...process.env, PI_TASK_DISPATCHER_WORKER: "probe" },
88
- },
89
- );
45
+ const selected = requested
46
+ ? rows.find(([provider, id]) => `${provider}/${id}` === requested)
47
+ : rows[0];
48
+ if (!selected && !requested) throw new Error("Pi did not list an available model for the live gate probe.");
49
+ const [provider, id] = requested ? requested.split(/\/(.+)/) : selected;
50
+ if (!provider || !id) throw new Error(`Invalid gate model: ${requested}`);
51
+ return {
52
+ provider,
53
+ id,
54
+ contextWindow: parseSize(selected?.[2]) ?? 272_000,
55
+ maxTokens: parseSize(selected?.[3]) ?? 128_000,
56
+ };
57
+ }
90
58
 
91
- const events = [];
92
- const boundaries = [];
93
- let stderr = "";
94
- let settledResolve;
95
- let settled = new Promise((resolve) => {
96
- settledResolve = resolve;
97
- });
59
+ try {
60
+ const { packageRoot: piRoot, cliPath } = await resolvePiRuntime();
61
+ const model = await resolveModel(cliPath);
62
+ await writeFile(configPath, "{}\n", "utf8");
63
+ await writeFile(readProbePath, `${marker}\n`, "utf8");
98
64
 
99
- // Collect public RPC events independently from the private boundary signal.
100
- readline.createInterface({ input: child.stdout }).on("line", (line) => {
101
- if (!line.trim()) return;
102
- const event = JSON.parse(line);
103
- events.push(event);
104
- if (event.type === "agent_settled") settledResolve();
105
- });
106
- child.stderr.on("data", (chunk) => {
107
- stderr += chunk.toString();
65
+ // Load the release source while directing only its child-process lookup and configuration to the probe.
66
+ delete process.env.PI_TASK_DISPATCHER_WORKER;
67
+ process.env.PI_TASK_DISPATCHER_TEST_MODE = "1";
68
+ process.env.PI_TASK_DISPATCHER_CONFIG_PATH = configPath;
69
+ process.env.PI_TASK_DISPATCHER_CLI_OVERRIDE = cliPath;
70
+ const requireFromPi = createRequire(path.join(piRoot, "package.json"));
71
+ const { createJiti } = requireFromPi("jiti");
72
+ const jiti = createJiti(import.meta.url, {
73
+ interopDefault: true,
74
+ alias: {
75
+ "@earendil-works/pi-coding-agent": path.join(piRoot, "dist", "index.js"),
76
+ "@earendil-works/pi-tui": requireFromPi.resolve("@earendil-works/pi-tui"),
77
+ typebox: requireFromPi.resolve("typebox"),
78
+ },
108
79
  });
109
- child.on("message", (message) => boundaries.push(message));
110
-
111
- // Run one codemode batch that invokes the selected direct read tool from the script, then require the gate to settle.
112
- child.stdin.write(`${JSON.stringify({
113
- id: "probe-1",
114
- type: "prompt",
115
- message: `Call codemode exactly once. In its JavaScript source, call await tools.read({ path: ${JSON.stringify(readProbePath)} }); and then call text("pi-task-dispatcher-codemode-ready"). Do not call tools directly and do not make any other tool calls.`,
116
- })}\n`);
117
- await Promise.race([
118
- settled,
119
- new Promise((_, reject) => setTimeout(() => reject(new Error("First gated run did not settle.")), 120_000)),
120
- ]);
80
+ const loaded = await jiti.import(path.join(projectRoot, "extensions", "task-dispatcher.ts"));
81
+ const register = loaded.default ?? loaded;
82
+ const registeredTools = new Map();
83
+ handlers = new Map();
84
+ const readTool = {
85
+ name: "read",
86
+ label: "Read",
87
+ description: "Read one UTF-8 text file through the live gate manager.",
88
+ parameters: {
89
+ type: "object",
90
+ additionalProperties: false,
91
+ required: ["path"],
92
+ properties: { path: { type: "string" } },
93
+ },
94
+ executionMode: "parallel",
95
+ };
96
+ const codemodeTool = {
97
+ name: "codemode",
98
+ label: "Codemode",
99
+ description: "Run child-local codemode.",
100
+ parameters: { type: "object", properties: { code: { type: "string" } } },
101
+ executionMode: "sequential",
102
+ };
103
+ const managerTools = [readTool, codemodeTool];
104
+ const pi = {
105
+ registerTool(tool) { registeredTools.set(tool.name, tool); },
106
+ getActiveTools() { return managerTools.map((tool) => tool.name); },
107
+ getAllTools() {
108
+ return managerTools.map((tool) => ({
109
+ name: tool.name,
110
+ description: tool.description,
111
+ parameters: tool.parameters,
112
+ exposure: "direct",
113
+ annotations: { readOnlyHint: tool.name === "read" },
114
+ promptGuidelines: [],
115
+ sourceInfo: { path: `<gate:${tool.name}>`, source: "gate", scope: "temporary", origin: "top-level", baseDir: undefined },
116
+ }));
117
+ },
118
+ on(event, handler) {
119
+ const entries = handlers.get(event) ?? [];
120
+ entries.push(handler);
121
+ handlers.set(event, entries);
122
+ },
123
+ };
124
+ register(pi);
121
125
 
122
- const firstAssistantMessages = events.filter((event) => event.type === "message_end" && event.message?.role === "assistant");
123
- const firstBoundaryCount = boundaries.filter((message) => message?.type === "task_dispatcher_boundary").length;
124
- if (firstBoundaryCount !== 1) throw new Error(`Expected one private boundary, observed ${firstBoundaryCount}.`);
125
- if (!firstAssistantMessages.some((event) => event.message?.stopReason === "toolUse")) {
126
- throw new Error("The first run did not execute the expected tool-use turn.");
127
- }
128
- if (!events.some((event) => event.type === "tool_execution_end" && event.toolName === "codemode" && !event.isError)) {
129
- throw new Error("The isolated worker did not complete the selected native codemode tool.");
130
- }
131
- if (!events.some((event) => event.type === "tool_execution_end" && event.toolName === "read" && event.parentToolCallId)) {
132
- throw new Error("The codemode script could not call the explicitly selected direct read tool.");
133
- }
134
- if (events.some((event) => event.type === "tool_execution_start" && event.toolName === "read" && !event.parentToolCallId)) {
135
- throw new Error("The worker called read directly instead of routing it through codemode-only mode.");
126
+ const managerCalls = [];
127
+ const context = {
128
+ cwd: projectRoot,
129
+ hasUI: false,
130
+ model,
131
+ thinkingLevel: process.env.PI_TASK_DISPATCHER_GATE_THINKING ?? process.env.PI_REASONING_LEVEL ?? "medium",
132
+ get tools() { return managerTools.filter((tool) => tool.name !== "codemode"); },
133
+ ui: { theme: { fg(_color, text) { return text; } }, setWidget() {}, setStatus() {} },
134
+ modelRegistry: {
135
+ find(provider, id) { return provider === model.provider && id === model.id ? model : undefined; },
136
+ hasConfiguredAuth() { return true; },
137
+ },
138
+ async executeTool(name, args, options) {
139
+ managerCalls.push({ name, args });
140
+ if (name !== "read") {
141
+ return { toolCall: { id: "gate-denied", name, arguments: args }, isError: true, result: { content: [{ type: "text", text: `Unexpected manager tool: ${name}` }], details: undefined } };
142
+ }
143
+ options?.onUpdate?.({ content: [{ type: "text", text: "manager read in progress" }], details: undefined });
144
+ try {
145
+ const text = await readFile(args.path, "utf8");
146
+ return { toolCall: { id: "gate-read", name, arguments: args }, isError: false, result: { content: [{ type: "text", text }], details: undefined } };
147
+ } catch (error) {
148
+ return { toolCall: { id: "gate-read", name, arguments: args }, isError: true, result: { content: [{ type: "text", text: String(error) }], details: undefined } };
149
+ }
150
+ },
151
+ };
152
+ const start = registeredTools.get("delegate_engineering");
153
+ const control = registeredTools.get("delegate_control");
154
+ assert(start && control, "The release extension did not register its engineering/control tools.");
155
+ const workload = { investigationUnits: 1, changeUnits: 0, verificationUnits: 1, expectedLongRunningSeconds: 0 };
156
+ const startAbort = new AbortController();
157
+ const startTimer = setTimeout(() => startAbort.abort(), 180_000);
158
+ let result;
159
+ try {
160
+ result = await start.execute(
161
+ "gate-start",
162
+ {
163
+ task: `Call codemode exactly once. In its JavaScript source, call await tools.read({ path: ${JSON.stringify(readProbePath)} }); verify the returned text contains ${marker}, then call text(${JSON.stringify(marker)}). Do not call tools directly or make any other tool calls. After that action batch, report only the marker.`,
164
+ tools: ["read", "codemode"],
165
+ workload,
166
+ },
167
+ startAbort.signal,
168
+ undefined,
169
+ context,
170
+ );
171
+ } finally {
172
+ clearTimeout(startTimer);
136
173
  }
174
+ assert.equal(result.isError, false, result.content?.[0]?.text);
175
+ assert.equal(result.details.status, "paused", result.content?.[0]?.text);
176
+ assert.deepEqual(managerCalls, [{ name: "read", args: { path: readProbePath } }], "The child did not use the exact manager-proxied read grant once.");
177
+ assert(result.details.actionLedger.some((action) => action.toolName === "codemode"), "The live child did not execute native codemode.");
178
+ assert(result.details.actionLedger.some((action) => action.toolName === "read" && action.parentToolCallId), "Codemode did not execute read through the child proxy.");
137
179
 
138
- // Continue in the same in-memory RPC session and require a normal final answer without another tool batch.
139
- events.length = 0;
140
- settled = new Promise((resolve) => {
141
- settledResolve = resolve;
142
- });
143
- child.stdin.write(`${JSON.stringify({
144
- id: "probe-2",
145
- type: "prompt",
146
- message: "Continue from the completed native codemode result. Do not call tools. Report only the exact marker produced by the script.",
147
- })}\n`);
148
- await Promise.race([
149
- settled,
150
- new Promise((_, reject) => setTimeout(() => reject(new Error("Continuation run did not settle.")), 120_000)),
151
- ]);
152
-
153
- const finalText = events
154
- .filter((event) => event.type === "message_end" && event.message?.role === "assistant")
155
- .flatMap((event) => event.message.content ?? [])
156
- .filter((part) => part.type === "text")
157
- .map((part) => part.text)
158
- .join("\n")
159
- .trim();
160
- if (!finalText.includes("pi-task-dispatcher-codemode-ready")) throw new Error(`Unexpected continuation output: ${finalText}`);
161
-
162
- console.log(JSON.stringify({ firstBoundaryCount, firstAssistantMessages: firstAssistantMessages.length, finalText, nativeCodemodeOnly: true }, null, 2));
163
- if (stderr.trim()) console.error(stderr.trim());
164
- } finally {
165
- // Close the reusable child and remove only the runtime-controlled probe directory.
166
- if (child && child.exitCode === null && child.signalCode === null) {
167
- child.stdin?.end();
168
- await Promise.race([
169
- new Promise((resolve) => child.once("close", resolve)),
170
- new Promise((resolve) => setTimeout(resolve, 5_000)),
171
- ]);
172
- if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
180
+ const continueAbort = new AbortController();
181
+ const continueTimer = setTimeout(() => continueAbort.abort(), 180_000);
182
+ try {
183
+ result = await control.execute(
184
+ "gate-control",
185
+ { jobId: result.details.jobId, action: "revise", instructions: `Do not call tools. Report only ${marker}.`, actionBatches: 1 },
186
+ continueAbort.signal,
187
+ undefined,
188
+ context,
189
+ );
190
+ } finally {
191
+ clearTimeout(continueTimer);
173
192
  }
193
+ assert.equal(result.isError, false, result.content?.[0]?.text);
194
+ assert.equal(result.details.status, "completed", result.content?.[0]?.text);
195
+ assert.match(result.details.workerText, new RegExp(marker));
196
+ console.log(JSON.stringify({
197
+ model: `${model.provider}/${model.id}`,
198
+ status: result.details.status,
199
+ managerProxyCalls: managerCalls.length,
200
+ nativeCodemode: true,
201
+ finalText: result.details.workerText,
202
+ }, null, 2));
203
+ } finally {
204
+ for (const handler of handlers.get("session_shutdown") ?? []) await handler();
205
+ delete process.env.PI_TASK_DISPATCHER_TEST_MODE;
206
+ delete process.env.PI_TASK_DISPATCHER_CONFIG_PATH;
207
+ delete process.env.PI_TASK_DISPATCHER_CLI_OVERRIDE;
208
+ if (inheritedWorkerRole === undefined) delete process.env.PI_TASK_DISPATCHER_WORKER;
209
+ else process.env.PI_TASK_DISPATCHER_WORKER = inheritedWorkerRole;
174
210
  await rm(temporaryDirectory, { recursive: true, force: true });
175
211
  }
@@ -5,8 +5,11 @@
5
5
  import { readFile } from "node:fs/promises";
6
6
 
7
7
  const packageJson = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8"));
8
- const source = await readFile(new URL("../extensions/task-dispatcher.ts", import.meta.url), "utf8");
9
- const gateProbe = await readFile(new URL("./gate-probe.mjs", import.meta.url), "utf8");
8
+ const [source, gateProbe, fakeRpcWorker] = await Promise.all([
9
+ readFile(new URL("../extensions/task-dispatcher.ts", import.meta.url), "utf8"),
10
+ readFile(new URL("./gate-probe.mjs", import.meta.url), "utf8"),
11
+ readFile(new URL("./fake-rpc-worker.mjs", import.meta.url), "utf8"),
12
+ ]);
10
13
 
11
14
  // Keep task budgets formula-owned and prevent accidental restoration of the v2 role limits.
12
15
  for (const forbidden of ["checkpointGraceSeconds", "timeoutSeconds", "maxTurns"]) {
@@ -45,13 +48,40 @@ for (const required of [
45
48
  "tool_execution_update",
46
49
  "PRESENTATION_UPDATE_THROTTLE_MS",
47
50
  "context.lastComponent",
51
+ "task_dispatcher_proxy_request",
52
+ "task_dispatcher_proxy_cancel",
53
+ "task_dispatcher_proxy_update",
54
+ "task_dispatcher_proxy_result",
55
+ "buildDelegatedToolManifests",
56
+ "ctx.executeTool",
57
+ "revalidateManifest",
58
+ "openExecutorLease",
59
+ "authorizeNestedToolCall",
60
+ "PROXY_PAYLOAD_LIMIT_BYTES",
61
+ "PROXY_MAX_IN_FLIGHT_REQUESTS",
62
+ "PROXY_MAX_REQUESTS_PER_JOB",
63
+ "REVOKED_EXECUTOR_ROOT_LIMIT",
64
+ "revokedExecutorGuardSaturated",
65
+ "randomBytes",
66
+ "Type.Unsafe",
48
67
  ]) {
49
68
  if (!source.includes(required)) throw new Error(`Missing supervised-protocol marker: ${required}`);
50
69
  }
51
70
 
52
- if (packageJson.version !== "3.1.0") throw new Error(`Expected release version 3.1.0, found ${packageJson.version}`);
71
+ if (packageJson.version !== "3.2.0") throw new Error(`Expected release version 3.2.0, found ${packageJson.version}`);
72
+ // Keep test-side RPC framing independent from readline's broader line-separator behavior.
73
+ for (const [name, reader] of [["fake RPC worker", fakeRpcWorker], ["gate probe", gateProbe]]) {
74
+ if (reader.includes("node:readline")) throw new Error(`${name} still imports node:readline.`);
75
+ }
53
76
  if (source.includes("builtin:codemode")) throw new Error("The dispatcher must not load codemode through a built-in extension selector.");
54
- if (!gateProbe.includes("createCodemodeExtension") || !gateProbe.includes('mode: "only"')) {
55
- throw new Error("The live gate probe does not exercise the native codemode-only extension.");
77
+ for (const forbidden of ["ISOLATED_WORKER_TOOLS", "MUTATING_TOOLS", "Isolated workers do not support these tools"]) {
78
+ if (source.includes(forbidden)) throw new Error(`Legacy tool-name classification remains in source: ${forbidden}`);
79
+ }
80
+ if (!source.includes("ctx.tools")) throw new Error("Delegated tool grants must be built from manager-callable ctx.tools.");
81
+ if (!fakeRpcWorker.includes("security-cross-job") || !fakeRpcWorker.includes("security-paused") || !fakeRpcWorker.includes("proxy-rich") || !fakeRpcWorker.includes("transitive")) {
82
+ throw new Error("The deterministic worker no longer covers proxy security and rich-result transport.");
83
+ }
84
+ for (const required of ['"task-dispatcher.ts"', "managerCalls", 'tools: ["read", "codemode"]', "parentToolCallId"]) {
85
+ if (!gateProbe.includes(required)) throw new Error(`The live gate probe no longer exercises the release proxy/codemode path: ${required}`);
56
86
  }
57
87
  console.log(`Static supervised-protocol checks passed for ${packageJson.name}@${packageJson.version}.`);
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Verify the strict UTF-8 JSONL framing contract used by RPC-oriented test readers.
3
+ */
4
+
5
+ import assert from "node:assert/strict";
6
+ import { Readable } from "node:stream";
7
+ import { readStrictJsonl } from "./strict-jsonl.mjs";
8
+
9
+ const encoder = new TextEncoder();
10
+
11
+ /** Collect records from deliberately controlled byte chunks. */
12
+ async function collectRecords(chunks) {
13
+ const records = [];
14
+ await readStrictJsonl(Readable.from(chunks), async (record) => {
15
+ records.push(record);
16
+ });
17
+ return records;
18
+ }
19
+
20
+ // Keep raw JSON string separators as payload instead of treating them as record boundaries.
21
+ const rawSeparatorValue = `before${String.fromCodePoint(0x2028)}middle${String.fromCodePoint(0x2029)}after`;
22
+ const rawSeparatorRecord = `{"value":"${rawSeparatorValue}"}`;
23
+ const separatorRecords = await collectRecords([encoder.encode(`${rawSeparatorRecord}\n`)]);
24
+ assert.deepEqual(separatorRecords, [rawSeparatorRecord]);
25
+ assert.equal(JSON.parse(separatorRecords[0]).value, rawSeparatorValue);
26
+ assert.doesNotMatch(rawSeparatorRecord, /\\u2028|\\u2029/);
27
+
28
+ // Split a four-byte character mid-sequence to require streaming UTF-8 decoding.
29
+ const multibyteRecord = JSON.stringify({ value: "split 🧪 UTF-8" });
30
+ const multibyteBytes = encoder.encode(`${multibyteRecord}\n`);
31
+ const multibyteStart = multibyteBytes.indexOf(0xf0);
32
+ assert.notEqual(multibyteStart, -1, "Expected the test record to contain a four-byte UTF-8 character.");
33
+ assert.deepEqual(
34
+ await collectRecords([multibyteBytes.subarray(0, multibyteStart + 1), multibyteBytes.subarray(multibyteStart + 1)]),
35
+ [multibyteRecord],
36
+ );
37
+
38
+ // Accept CRLF transport framing while returning the JSON record without its framing CR.
39
+ const crlfRecord = JSON.stringify({ framing: "crlf" });
40
+ assert.deepEqual(await collectRecords([encoder.encode(`${crlfRecord}\r\n`)]), [crlfRecord]);
41
+
42
+ // Reject a final record that has bytes but no LF terminator.
43
+ await assert.rejects(
44
+ collectRecords([encoder.encode('{"unfinished":true}')]),
45
+ /unterminated tail/,
46
+ );
47
+
48
+ console.log("Strict JSONL framing checks passed.");
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Decode UTF-8 JSONL test streams with LF as the only record delimiter.
3
+ */
4
+
5
+ /** Deliver complete LF-framed records while preserving raw Unicode JSON string content. */
6
+ export async function readStrictJsonl(input, onRecord) {
7
+ const decoder = new TextDecoder("utf-8", { fatal: true });
8
+ let tail = "";
9
+
10
+ /** Emit buffered records and remove at most one CR from each LF terminator. */
11
+ const emitRecords = async () => {
12
+ let lineFeed = tail.indexOf("\n");
13
+ // Drain complete LF-framed records before retaining an incomplete tail.
14
+ while (lineFeed !== -1) {
15
+ const record = tail.slice(0, lineFeed);
16
+ tail = tail.slice(lineFeed + 1);
17
+ await onRecord(record.endsWith("\r") ? record.slice(0, -1) : record);
18
+ lineFeed = tail.indexOf("\n");
19
+ }
20
+ };
21
+
22
+ // Decode chunks incrementally so an incomplete multibyte character cannot become replacement text.
23
+ for await (const chunk of input) {
24
+ tail += decoder.decode(chunk, { stream: true });
25
+ await emitRecords();
26
+ }
27
+ // Flush pending decoder bytes before deciding whether the final record was properly framed.
28
+ tail += decoder.decode();
29
+ await emitRecords();
30
+ // Reject a nonempty EOF tail instead of accepting an ambiguous partial JSON record.
31
+ if (tail !== "") throw new Error("Strict JSONL input ended with an unterminated tail.");
32
+ }