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.
- package/README.md +12 -9
- package/extensions/task-dispatcher.ts +670 -36
- package/package.json +2 -2
- package/tests/fake-rpc-check.mjs +316 -122
- package/tests/fake-rpc-worker.mjs +289 -149
- package/tests/gate-probe.mjs +186 -150
- package/tests/static-check.mjs +35 -5
- package/tests/strict-jsonl-check.mjs +48 -0
- package/tests/strict-jsonl.mjs +32 -0
package/tests/gate-probe.mjs
CHANGED
|
@@ -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
|
|
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
|
|
9
|
+
import { promisify } from "node:util";
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
const
|
|
27
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
//
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
}
|
package/tests/static-check.mjs
CHANGED
|
@@ -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
|
|
9
|
-
|
|
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.
|
|
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
|
-
|
|
55
|
-
throw new Error(
|
|
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
|
+
}
|