@code-yeongyu/senpi 2026.10.18 → 2026.10.19
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/CHANGELOG.md +47 -0
- package/dist/bundle/chunks/{anthropic-messages-DDBWM4JZ.js → anthropic-messages-EMTBHJMM.js} +2 -2
- package/dist/bundle/chunks/{args-2MYPVFPH.js → args-LWR7VFKS.js} +1 -1
- package/dist/bundle/chunks/{azure-openai-responses-3UG5TQU7.js → azure-openai-responses-SNWKFDXK.js} +1 -1
- package/dist/bundle/chunks/chatgpt-subscription.js +1 -1
- package/dist/bundle/chunks/{chunk-SOF44GFZ.js → chunk-35M5MVUZ.js} +149 -145
- package/dist/bundle/chunks/chunk-3DNYAO3P.js +2 -0
- package/dist/bundle/chunks/{chunk-T2MHSEQ2.js → chunk-5AOKDC6G.js} +1 -1
- package/dist/bundle/chunks/{chunk-MYXNQ7YZ.js → chunk-7QPFFAJT.js} +3 -3
- package/dist/bundle/chunks/{chunk-R36U2DJC.js → chunk-AUZXSLZN.js} +1 -1
- package/dist/bundle/chunks/{chunk-PMTG4YNS.js → chunk-BOCN744P.js} +1 -1
- package/dist/bundle/chunks/{chunk-XTAWER7O.js → chunk-CICJOV6I.js} +1 -1
- package/dist/bundle/chunks/{chunk-4UCLDXEF.js → chunk-FM7NDZR5.js} +1 -1
- package/dist/bundle/chunks/{chunk-TBRWPNA4.js → chunk-FSENE7JK.js} +2 -2
- package/dist/bundle/chunks/{chunk-TUD7XRIS.js → chunk-G7LLAQXI.js} +3 -3
- package/dist/bundle/chunks/{chunk-BGD3MZ47.js → chunk-GOKAWZUO.js} +1 -1
- package/dist/bundle/chunks/{chunk-XC6UG5SI.js → chunk-IM6ZWYTW.js} +2 -2
- package/dist/bundle/chunks/{chunk-KHATAB7P.js → chunk-KM4FMQII.js} +1 -1
- package/dist/bundle/chunks/chunk-LAWS3FYQ.js +11 -0
- package/dist/bundle/chunks/{chunk-W6YQYGE2.js → chunk-MVNVDGYF.js} +1 -1
- package/dist/bundle/chunks/{chunk-W4BO6S7F.js → chunk-PMGEQYEP.js} +2 -2
- package/dist/bundle/chunks/chunk-QGJLZ6BI.js +2 -0
- package/dist/bundle/chunks/{chunk-5RQNCWS2.js → chunk-QJ6MUFXY.js} +1 -1
- package/dist/bundle/chunks/{chunk-57Q7Q627.js → chunk-QRGAMVEE.js} +1 -1
- package/dist/bundle/chunks/{chunk-W2257FYP.js → chunk-QXEWAKXA.js} +1 -1
- package/dist/bundle/chunks/{chunk-UUMKAA7S.js → chunk-RL5QMGQD.js} +9 -9
- package/dist/bundle/chunks/{chunk-D2UQYZVK.js → chunk-SHKXJWK3.js} +1 -1
- package/dist/bundle/chunks/chunk-T36LJ6MW.js +3 -0
- package/dist/bundle/chunks/{chunk-S4V72F76.js → chunk-VCD7FD64.js} +1 -1
- package/dist/bundle/chunks/{chunk-MYPTZCPF.js → chunk-VEKNDMUA.js} +1 -1
- package/dist/bundle/chunks/{chunk-IRBAGET4.js → chunk-VPV7ADF3.js} +4 -4
- package/dist/bundle/chunks/{chunk-6DDAEDY7.js → chunk-WEHIEACU.js} +1 -1
- package/dist/bundle/chunks/{chunk-C2HSPFXN.js → chunk-WQLMQGOW.js} +1 -1
- package/dist/bundle/chunks/{chunk-QZIKKUUP.js → chunk-X6TWPBI6.js} +2 -2
- package/dist/bundle/chunks/{chunk-VYWODOAM.js → chunk-YUMRZIKJ.js} +6 -6
- package/dist/bundle/chunks/chunk-ZPSJQHDK.js +395 -0
- package/dist/bundle/chunks/{cli-main-MVVPTGMS.js → cli-main-EABMHWFV.js} +1 -1
- package/dist/bundle/chunks/{cloudflare-workers-ai-system-one-H4ZPUBX4.js → cloudflare-workers-ai-system-one-PF7FU63D.js} +1 -1
- package/dist/bundle/chunks/{google-generative-ai-YLAYNQLO.js → google-generative-ai-UFCHYLRE.js} +1 -1
- package/dist/bundle/chunks/{google-vertex-THFXM5R2.js → google-vertex-2FWDY7XX.js} +1 -1
- package/dist/bundle/chunks/{help-fast-path-H4ERPCR5.js → help-fast-path-BYWJN6YX.js} +1 -1
- package/dist/bundle/chunks/{host-command-2CMRIKC3.js → host-command-5T34JLBP.js} +1 -1
- package/dist/bundle/chunks/{host-lifecycle-6Y2EKT4U.js → host-lifecycle-GSRIQP6Z.js} +1 -1
- package/dist/bundle/chunks/host-lifecycle.js +5 -5
- package/dist/bundle/chunks/{host-session-command-MVHY4TKA.js → host-session-command-LF6JODPX.js} +1 -1
- package/dist/bundle/chunks/{interactive-mode-2ZQ43N73.js → interactive-mode-GMWHBIUC.js} +1 -1
- package/dist/bundle/chunks/{list-tips-AWDAUHF2.js → list-tips-VSWED3PT.js} +1 -1
- package/dist/bundle/chunks/{mistral-conversations-GUGTVRCL.js → mistral-conversations-QHZLDTSP.js} +1 -1
- package/dist/bundle/chunks/{multi-session-host-NO4PIOVG.js → multi-session-host-3J7STKGD.js} +6 -6
- package/dist/bundle/chunks/openai-codex-responses-7YOBPZ2N.js +10 -0
- package/dist/bundle/chunks/{openai-completions-I5N2UIEK.js → openai-completions-HSZPYXEA.js} +1 -1
- package/dist/bundle/chunks/{openai-responses-SGLUJVZW.js → openai-responses-7W2KF3W7.js} +1 -1
- package/dist/bundle/chunks/openrouter.js +1 -1
- package/dist/bundle/chunks/{package-manager-cli-HTZQXYNI.js → package-manager-cli-LWQZSWKL.js} +1 -1
- package/dist/bundle/chunks/radius.js +1 -1
- package/dist/bundle/chunks/{rotation-stream-TOOIK43C.js → rotation-stream-QRRWL73X.js} +1 -1
- package/dist/bundle/chunks/{rpc-mode-6ZWGPICX.js → rpc-mode-6LSWRKXR.js} +1 -1
- package/dist/bundle/chunks/{schedule-command-2YW4B655.js → schedule-command-7CXMW4WK.js} +1 -1
- package/dist/bundle/chunks/{session-control-endpoint-XC2LPLCD.js → session-control-endpoint-E265WAIQ.js} +1 -1
- package/dist/bundle/chunks/{session-picker-7W7GIRJT.js → session-picker-XRB3LP5W.js} +1 -1
- package/dist/bundle/chunks/session-worker.js +315 -310
- package/dist/bundle/chunks/{typesafe-system-one-VBWWTJTF.js → typesafe-system-one-YA75435G.js} +1 -1
- package/dist/bundle/cli.js +2 -2
- package/dist/bundle/index.js +1 -1
- package/dist/bundle/rpc-entry.js +1 -1
- package/dist/bundle/runtime-manifest.json +1 -1
- package/dist/core/agent-run-continuation.d.ts +24 -0
- package/dist/core/agent-run-continuation.js +57 -0
- package/dist/core/agent-session.d.ts +38 -1
- package/dist/core/agent-session.js +122 -13
- package/dist/core/extension-handler-stall-report.d.ts +11 -0
- package/dist/core/extension-handler-stall-report.js +26 -0
- package/dist/core/extensions/builtin/anthropic-subscription/account-command.js +1 -0
- package/dist/core/extensions/builtin/anthropic-subscription/affinity.d.ts +13 -1
- package/dist/core/extensions/builtin/anthropic-subscription/affinity.js +15 -4
- package/dist/core/extensions/builtin/anthropic-subscription/auth-lane.js +2 -0
- package/dist/core/extensions/builtin/anthropic-subscription/executable.d.ts +5 -0
- package/dist/core/extensions/builtin/anthropic-subscription/executable.js +11 -1
- package/dist/core/extensions/builtin/anthropic-subscription/failover.d.ts +2 -0
- package/dist/core/extensions/builtin/anthropic-subscription/failover.js +3 -2
- package/dist/core/extensions/builtin/anthropic-subscription/guidance.d.ts +8 -0
- package/dist/core/extensions/builtin/anthropic-subscription/guidance.js +29 -0
- package/dist/core/extensions/builtin/anthropic-subscription/settings.d.ts +2 -0
- package/dist/core/extensions/builtin/anthropic-subscription/settings.js +4 -0
- package/dist/core/extensions/builtin/anthropic-subscription/stream-guidance.js +4 -2
- package/dist/core/extensions/handler-budget.d.ts +84 -0
- package/dist/core/extensions/handler-budget.js +136 -0
- package/dist/core/extensions/runner.d.ts +23 -0
- package/dist/core/extensions/runner.js +184 -99
- package/dist/core/footer-data-provider.js +2 -1
- package/dist/core/keybindings.d.ts +1 -1
- package/dist/core/keybindings.js +3 -2
- package/dist/core/model-resolver.js +1 -1
- package/dist/core/pending-input-journal.d.ts +6 -1
- package/dist/core/pending-input-journal.js +90 -34
- package/dist/core/pending-input-public.d.ts +11 -0
- package/dist/core/pending-input-public.js +10 -0
- package/dist/core/pending-input-session-port.d.ts +15 -0
- package/dist/core/pending-input-session-port.js +93 -0
- package/dist/core/pending-input-session.d.ts +108 -0
- package/dist/core/pending-input-session.js +220 -0
- package/dist/core/pending-input-store.d.ts +32 -1
- package/dist/core/pending-input-store.js +74 -26
- package/dist/core/pending-input-types.d.ts +25 -4
- package/dist/core/pending-input.js +36 -18
- package/dist/core/session-entry-file.js +1 -0
- package/dist/core/session-log.js +1 -1
- package/dist/core/session-manager.d.ts +1 -0
- package/dist/core/session-manager.js +5 -1
- package/dist/core/session-move/bundle.d.ts +39 -0
- package/dist/core/session-move/bundle.js +108 -0
- package/dist/core/session-move/export.d.ts +18 -0
- package/dist/core/session-move/export.js +83 -0
- package/dist/core/session-move/finish.d.ts +24 -0
- package/dist/core/session-move/finish.js +81 -0
- package/dist/core/session-move/import.d.ts +32 -0
- package/dist/core/session-move/import.js +124 -0
- package/dist/core/session-move/move-ledger.d.ts +47 -0
- package/dist/core/session-move/move-ledger.js +151 -0
- package/dist/core/session-move/ownership.d.ts +61 -0
- package/dist/core/session-move/ownership.js +89 -0
- package/dist/core/session-move/requeue.d.ts +8 -0
- package/dist/core/session-move/requeue.js +18 -0
- package/dist/core/session-move/write-guard.d.ts +22 -0
- package/dist/core/session-move/write-guard.js +44 -0
- package/dist/core/settings-manager.d.ts +13 -0
- package/dist/core/settings-manager.js +17 -0
- package/dist/core/turn-deadline.d.ts +2 -0
- package/dist/core/turn-deadline.js +10 -3
- package/dist/modes/interactive/components/ask-user-async-widget.d.ts +7 -0
- package/dist/modes/interactive/components/ask-user-async-widget.js +20 -6
- package/dist/modes/rpc/connection-handler.js +3 -0
- package/dist/modes/rpc/effect-commands.js +3 -4
- package/dist/modes/rpc/host-decision.d.ts +1 -1
- package/dist/modes/rpc/host-decision.js +2 -0
- package/dist/modes/rpc/host-ensure-liveness.d.ts +8 -0
- package/dist/modes/rpc/host-ensure-liveness.js +18 -1
- package/dist/modes/rpc/host-ensure-types.d.ts +2 -0
- package/dist/modes/rpc/host-ensure.js +8 -2
- package/dist/modes/rpc/host-lifecycle-proxy.d.ts +9 -2
- package/dist/modes/rpc/host-lifecycle-proxy.js +36 -8
- package/dist/modes/rpc/host-observe-request.js +1 -0
- package/dist/modes/rpc/multi-session-host.js +3 -33
- package/dist/modes/rpc/rpc-client.d.ts +10 -0
- package/dist/modes/rpc/rpc-client.js +11 -0
- package/dist/modes/rpc/rpc-input-validation.js +8 -0
- package/dist/modes/rpc/rpc-types.d.ts +25 -1
- package/dist/modes/rpc/session-command-activity.js +1 -0
- package/dist/modes/rpc/session-control-actions-data.js +17 -8
- package/dist/modes/rpc/session-held.js +1 -0
- package/dist/modes/rpc/session-release.js +16 -1
- package/dist/modes/rpc/socket-ownership.d.ts +17 -0
- package/dist/modes/rpc/socket-ownership.js +52 -0
- package/dist/modes/rpc/tool-child/admission.d.ts +42 -0
- package/dist/modes/rpc/tool-child/admission.js +81 -0
- package/dist/modes/rpc/tool-child/admission.test.d.ts +2 -0
- package/dist/modes/rpc/tool-child/admission.test.js +166 -0
- package/dist/modes/rpc/tool-child/codec.d.ts +21 -0
- package/dist/modes/rpc/tool-child/codec.js +137 -0
- package/dist/modes/rpc/tool-child/codec.test.d.ts +2 -0
- package/dist/modes/rpc/tool-child/codec.test.js +234 -0
- package/dist/modes/rpc/tool-child/context.d.ts +42 -0
- package/dist/modes/rpc/tool-child/context.js +23 -0
- package/dist/modes/rpc/tool-child/flow-control.d.ts +24 -0
- package/dist/modes/rpc/tool-child/flow-control.js +80 -0
- package/dist/modes/rpc/tool-child/payload-ref-paths.d.ts +9 -0
- package/dist/modes/rpc/tool-child/payload-ref-paths.js +45 -0
- package/dist/modes/rpc/tool-child/payload-ref.d.ts +58 -0
- package/dist/modes/rpc/tool-child/payload-ref.js +166 -0
- package/dist/modes/rpc/tool-child/payload-ref.test.d.ts +2 -0
- package/dist/modes/rpc/tool-child/payload-ref.test.js +254 -0
- package/dist/modes/rpc/tool-child/payload-wire.d.ts +13 -0
- package/dist/modes/rpc/tool-child/payload-wire.js +22 -0
- package/dist/modes/rpc/tool-child/protocol-guard.d.ts +9 -0
- package/dist/modes/rpc/tool-child/protocol-guard.js +128 -0
- package/dist/modes/rpc/tool-child/protocol.d.ts +192 -0
- package/dist/modes/rpc/tool-child/protocol.js +54 -0
- package/dist/utils/wsl.d.ts +2 -2
- package/dist/utils/wsl.js +4 -4
- package/docs/providers.md +2 -0
- package/docs/rpc.md +20 -2
- package/docs/settings.md +1 -0
- package/docs/terminal-setup.md +2 -0
- package/package.json +10 -6
- package/dist/bundle/chunks/chunk-COJK5FDH.js +0 -2
- package/dist/bundle/chunks/chunk-ES3GHZE5.js +0 -395
- package/dist/bundle/chunks/chunk-IDXW4FAO.js +0 -2
- package/dist/bundle/chunks/chunk-PFNBY43W.js +0 -2
- package/dist/bundle/chunks/chunk-SA2ZLWBD.js +0 -11
- package/dist/bundle/chunks/openai-codex-responses-5LRKZCBW.js +0 -10
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { TOOL_CHILD_LIMITS } from "./protocol.js";
|
|
2
|
+
/**
|
|
3
|
+
* The one host-call capacity both sides enforce: `HOST_CALL_ACTIVE` running plus `HOST_CALL_WAITING` queued (264).
|
|
4
|
+
* The child admits against it before sending, the server admits against it on receipt; there is no second constant.
|
|
5
|
+
*/
|
|
6
|
+
export function hostCallCapacity() {
|
|
7
|
+
return TOOL_CHILD_LIMITS.HOST_CALL_ACTIVE + TOOL_CHILD_LIMITS.HOST_CALL_WAITING;
|
|
8
|
+
}
|
|
9
|
+
/** Lossless host-call admission: a call is running, queued FIFO, or refused with a frame naming its `callId`. */
|
|
10
|
+
export class HostCallAdmission {
|
|
11
|
+
#active = new Set();
|
|
12
|
+
#waiting = [];
|
|
13
|
+
get active() {
|
|
14
|
+
return this.#active.size;
|
|
15
|
+
}
|
|
16
|
+
get waiting() {
|
|
17
|
+
return this.#waiting.length;
|
|
18
|
+
}
|
|
19
|
+
submit(callId) {
|
|
20
|
+
if (this.#active.has(callId) || this.#waiting.includes(callId))
|
|
21
|
+
return refuse(callId, "host_call_duplicate");
|
|
22
|
+
if (this.#active.size < TOOL_CHILD_LIMITS.HOST_CALL_ACTIVE && this.#waiting.length === 0) {
|
|
23
|
+
this.#active.add(callId);
|
|
24
|
+
return { state: "active" };
|
|
25
|
+
}
|
|
26
|
+
if (this.#active.size + this.#waiting.length >= hostCallCapacity())
|
|
27
|
+
return refuse(callId, "host_call_capacity");
|
|
28
|
+
this.#waiting.push(callId);
|
|
29
|
+
return { state: "waiting" };
|
|
30
|
+
}
|
|
31
|
+
/** Ends a call (or withdraws a queued one) and returns the call promoted into the freed active slot, if any. */
|
|
32
|
+
settle(callId) {
|
|
33
|
+
if (!this.#active.delete(callId)) {
|
|
34
|
+
const index = this.#waiting.indexOf(callId);
|
|
35
|
+
if (index >= 0)
|
|
36
|
+
this.#waiting.splice(index, 1);
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
const next = this.#waiting.shift();
|
|
40
|
+
if (next !== undefined)
|
|
41
|
+
this.#active.add(next);
|
|
42
|
+
return next;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function refuse(callId, code) {
|
|
46
|
+
return { state: "refused", frame: { type: "host_call_refused", callId, code } };
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Per-child job budget. Ordinary jobs are capped at `TOOL_CHILD_LIMITS.jobs`. A nested job (started on behalf of a
|
|
50
|
+
* running job, e.g. a tool an eval cell calls) is admitted past that cap, so a full child cannot deadlock its own
|
|
51
|
+
* callers; nested jobs arrive through host calls and are bounded by `hostCallCapacity()`.
|
|
52
|
+
*/
|
|
53
|
+
export class JobAdmission {
|
|
54
|
+
#ordinary = new Set();
|
|
55
|
+
#nested = new Map();
|
|
56
|
+
admit(jobId, parentJobId) {
|
|
57
|
+
if (this.#ordinary.has(jobId) || this.#nested.has(jobId))
|
|
58
|
+
return refuseJob(jobId, "tool_child_job_duplicate");
|
|
59
|
+
if (parentJobId === undefined) {
|
|
60
|
+
if (this.#ordinary.size >= TOOL_CHILD_LIMITS.jobs)
|
|
61
|
+
return refuseJob(jobId, "tool_child_jobs_full");
|
|
62
|
+
this.#ordinary.add(jobId);
|
|
63
|
+
return { admitted: true };
|
|
64
|
+
}
|
|
65
|
+
if (!this.#ordinary.has(parentJobId) && !this.#nested.has(parentJobId)) {
|
|
66
|
+
return refuseJob(jobId, "tool_child_parent_unknown");
|
|
67
|
+
}
|
|
68
|
+
if (this.#nested.size >= hostCallCapacity())
|
|
69
|
+
return refuseJob(jobId, "tool_child_nested_full");
|
|
70
|
+
this.#nested.set(jobId, parentJobId);
|
|
71
|
+
return { admitted: true };
|
|
72
|
+
}
|
|
73
|
+
finish(jobId) {
|
|
74
|
+
this.#ordinary.delete(jobId);
|
|
75
|
+
this.#nested.delete(jobId);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function refuseJob(jobId, code) {
|
|
79
|
+
return { admitted: false, frame: { type: "job_refused", jobId, code } };
|
|
80
|
+
}
|
|
81
|
+
//# sourceMappingURL=admission.js.map
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { HostCallAdmission, hostCallCapacity, JobAdmission } from "./admission.js";
|
|
3
|
+
import { encodeFrame, FrameDecoder } from "./codec.js";
|
|
4
|
+
import { isLossy, TraceBudget, WakeAdmission, WakeAdmissionError } from "./flow-control.js";
|
|
5
|
+
import { LOSSY_FRAME_TYPES, TOOL_CHILD_LIMITS } from "./protocol.js";
|
|
6
|
+
import { isToolChildMessage } from "./protocol-guard.js";
|
|
7
|
+
const callId = (index) => `call-${index}`;
|
|
8
|
+
function submitMany(admission, count) {
|
|
9
|
+
const states = { active: 0, waiting: 0, refused: [] };
|
|
10
|
+
for (let index = 1; index <= count; index += 1) {
|
|
11
|
+
const decision = admission.submit(callId(index));
|
|
12
|
+
if (decision.state === "refused")
|
|
13
|
+
states.refused.push(decision.frame.callId);
|
|
14
|
+
else
|
|
15
|
+
states[decision.state] += 1;
|
|
16
|
+
}
|
|
17
|
+
return states;
|
|
18
|
+
}
|
|
19
|
+
/** One loop turn: enough for an already-resolved promise to settle, never a timed wait. */
|
|
20
|
+
const turn = () => new Promise((resolve) => setImmediate(resolve));
|
|
21
|
+
function tracked(promise) {
|
|
22
|
+
const state = { settled: false };
|
|
23
|
+
promise.then(() => {
|
|
24
|
+
state.settled = true;
|
|
25
|
+
});
|
|
26
|
+
return state;
|
|
27
|
+
}
|
|
28
|
+
describe("host-call admission", () => {
|
|
29
|
+
it("has one capacity: 8 active + 256 waiting = 264", () => {
|
|
30
|
+
expect(hostCallCapacity()).toBe(264);
|
|
31
|
+
expect(hostCallCapacity()).toBe(TOOL_CHILD_LIMITS.HOST_CALL_ACTIVE + TOOL_CHILD_LIMITS.HOST_CALL_WAITING);
|
|
32
|
+
});
|
|
33
|
+
it("264 concurrent submissions -> 8 active, 256 waiting, 0 refused", () => {
|
|
34
|
+
const admission = new HostCallAdmission();
|
|
35
|
+
expect(submitMany(admission, 264)).toEqual({ active: 8, waiting: 256, refused: [] });
|
|
36
|
+
expect(admission.active).toBe(8);
|
|
37
|
+
expect(admission.waiting).toBe(256);
|
|
38
|
+
});
|
|
39
|
+
it("265 -> exactly the 265th is refused with a correlated host_call_refused frame", () => {
|
|
40
|
+
const admission = new HostCallAdmission();
|
|
41
|
+
expect(submitMany(admission, 265).refused).toEqual(["call-265"]);
|
|
42
|
+
const decision = new HostCallAdmission().submit("x");
|
|
43
|
+
expect(decision.state).toBe("active");
|
|
44
|
+
const full = new HostCallAdmission();
|
|
45
|
+
submitMany(full, 264);
|
|
46
|
+
const refused = full.submit("call-265");
|
|
47
|
+
if (refused.state !== "refused")
|
|
48
|
+
throw new Error("expected a refusal");
|
|
49
|
+
expect(refused.frame).toEqual({ type: "host_call_refused", callId: "call-265", code: "host_call_capacity" });
|
|
50
|
+
expect(isToolChildMessage(refused.frame)).toBe(true);
|
|
51
|
+
expect(new FrameDecoder().push(encodeFrame(refused.frame))).toEqual([refused.frame]);
|
|
52
|
+
});
|
|
53
|
+
it("promotes waiting calls in FIFO order", () => {
|
|
54
|
+
const admission = new HostCallAdmission();
|
|
55
|
+
submitMany(admission, 264);
|
|
56
|
+
const promoted = [];
|
|
57
|
+
for (let index = 1; index <= 264; index += 1)
|
|
58
|
+
promoted.push(admission.settle(callId(index)));
|
|
59
|
+
expect(promoted.slice(0, 256)).toEqual(Array.from({ length: 256 }, (_, offset) => callId(offset + 9)));
|
|
60
|
+
expect(promoted.slice(256)).toEqual(Array(8).fill(undefined));
|
|
61
|
+
expect(admission.active + admission.waiting).toBe(0);
|
|
62
|
+
});
|
|
63
|
+
it("child and server share the capacity: the child never forwards a call the server would refuse", () => {
|
|
64
|
+
const child = new HostCallAdmission();
|
|
65
|
+
const server = new HostCallAdmission();
|
|
66
|
+
const refusedByServer = [];
|
|
67
|
+
for (let index = 1; index <= 300; index += 1) {
|
|
68
|
+
if (child.submit(callId(index)).state === "refused")
|
|
69
|
+
continue;
|
|
70
|
+
const decision = server.submit(callId(index));
|
|
71
|
+
if (decision.state === "refused")
|
|
72
|
+
refusedByServer.push(decision.frame.callId);
|
|
73
|
+
}
|
|
74
|
+
expect(refusedByServer).toEqual([]);
|
|
75
|
+
expect(server.active + server.waiting).toBe(hostCallCapacity());
|
|
76
|
+
});
|
|
77
|
+
it("refuses a duplicate call id", () => {
|
|
78
|
+
const admission = new HostCallAdmission();
|
|
79
|
+
admission.submit("same");
|
|
80
|
+
const duplicate = admission.submit("same");
|
|
81
|
+
expect(duplicate.state === "refused" && duplicate.frame.code).toBe("host_call_duplicate");
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
describe("job admission", () => {
|
|
85
|
+
it("refuses the 33rd ordinary job while a nested job is still admitted", () => {
|
|
86
|
+
const jobs = new JobAdmission();
|
|
87
|
+
for (let index = 1; index <= 32; index += 1)
|
|
88
|
+
expect(jobs.admit(`job-${index}`)).toEqual({ admitted: true });
|
|
89
|
+
expect(jobs.admit("job-33")).toEqual({
|
|
90
|
+
admitted: false,
|
|
91
|
+
frame: { type: "job_refused", jobId: "job-33", code: "tool_child_jobs_full" },
|
|
92
|
+
});
|
|
93
|
+
expect(jobs.admit("nested-1", "job-1")).toEqual({ admitted: true });
|
|
94
|
+
expect(jobs.admit("nested-2", "missing")).toEqual({
|
|
95
|
+
admitted: false,
|
|
96
|
+
frame: { type: "job_refused", jobId: "nested-2", code: "tool_child_parent_unknown" },
|
|
97
|
+
});
|
|
98
|
+
jobs.finish("job-2");
|
|
99
|
+
expect(jobs.admit("job-33")).toEqual({ admitted: true });
|
|
100
|
+
});
|
|
101
|
+
it("bounds nested jobs by the host-call capacity, not by a second constant", () => {
|
|
102
|
+
const jobs = new JobAdmission();
|
|
103
|
+
jobs.admit("parent");
|
|
104
|
+
for (let index = 1; index <= hostCallCapacity(); index += 1) {
|
|
105
|
+
expect(jobs.admit(`nested-${index}`, "parent")).toEqual({ admitted: true });
|
|
106
|
+
}
|
|
107
|
+
const over = jobs.admit("nested-over", "parent");
|
|
108
|
+
expect(over.admitted === false && over.frame.code).toBe("tool_child_nested_full");
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
describe("wake flow control", () => {
|
|
112
|
+
it("the 65th pending wake waits for wake_ack and is never dropped", async () => {
|
|
113
|
+
const wakes = new WakeAdmission();
|
|
114
|
+
for (let index = 1; index <= 64; index += 1)
|
|
115
|
+
await wakes.send(`w-${index}`, 10);
|
|
116
|
+
const sixtyFifth = tracked(wakes.send("w-65", 10));
|
|
117
|
+
const sixtySixth = tracked(wakes.send("w-66", 10));
|
|
118
|
+
await turn();
|
|
119
|
+
expect(sixtyFifth.settled).toBe(false);
|
|
120
|
+
expect(wakes.blocked).toBe(2);
|
|
121
|
+
expect(wakes.pending).toBe(64);
|
|
122
|
+
wakes.ack("w-1");
|
|
123
|
+
await turn();
|
|
124
|
+
expect(sixtyFifth.settled).toBe(true);
|
|
125
|
+
expect(sixtySixth.settled).toBe(false);
|
|
126
|
+
wakes.ack("w-2");
|
|
127
|
+
await turn();
|
|
128
|
+
expect(sixtySixth.settled).toBe(true);
|
|
129
|
+
expect(wakes.pending).toBe(64);
|
|
130
|
+
expect(wakes.blocked).toBe(0);
|
|
131
|
+
});
|
|
132
|
+
it("bounds pending wakes by summed bytes and refuses one larger than the whole budget", async () => {
|
|
133
|
+
const wakes = new WakeAdmission();
|
|
134
|
+
const part = 600 * 1024;
|
|
135
|
+
await wakes.send("a", part);
|
|
136
|
+
const second = tracked(wakes.send("b", part));
|
|
137
|
+
await turn();
|
|
138
|
+
expect(second.settled).toBe(false);
|
|
139
|
+
wakes.ack("a");
|
|
140
|
+
await turn();
|
|
141
|
+
expect(second.settled).toBe(true);
|
|
142
|
+
await expect(wakes.send("huge", TOOL_CHILD_LIMITS.wakePendingBytes + 1)).rejects.toBeInstanceOf(WakeAdmissionError);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
describe("trace budget", () => {
|
|
146
|
+
it("drops and counts the 1,025th trace frame in one second, then admits again in the next", () => {
|
|
147
|
+
const budget = new TraceBudget();
|
|
148
|
+
const frame = { type: "trace", atMs: 0, event: "tick" };
|
|
149
|
+
const admitted = Array.from({ length: 1025 }, () => budget.admit(frame, 5_000));
|
|
150
|
+
expect(admitted.filter(Boolean)).toHaveLength(1024);
|
|
151
|
+
expect(admitted.at(-1)).toBe(false);
|
|
152
|
+
expect(budget.dropped).toBe(1);
|
|
153
|
+
expect(budget.admit(frame, 6_000)).toBe(true);
|
|
154
|
+
expect(budget.dropped).toBe(1);
|
|
155
|
+
});
|
|
156
|
+
it("trace is the only lossy class; results, proofs and ticks are never lossy", () => {
|
|
157
|
+
expect([...LOSSY_FRAME_TYPES]).toEqual(["trace"]);
|
|
158
|
+
expect(isLossy({ type: "trace", atMs: 1, event: "e" })).toBe(true);
|
|
159
|
+
expect(isLossy({ type: "launch_proof", launchId: "l", ackTick: 1, invokeTick: 2 })).toBe(false);
|
|
160
|
+
expect(isLossy({ type: "result", jobId: "j", value: 1 })).toBe(false);
|
|
161
|
+
expect(isLossy({ type: "slot_state", launchId: "l", ackTick: 1, invokeTick: 2, invocations: 1 })).toBe(false);
|
|
162
|
+
expect(isToolChildMessage({ type: "trace", atMs: 1, event: "e", detail: { invokeTick: 2 } })).toBe(false);
|
|
163
|
+
expect(isToolChildMessage({ type: "trace", atMs: 1, event: "e", result: 1 })).toBe(false);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
//# sourceMappingURL=admission.test.js.map
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type ToolChildMessage } from "./protocol.ts";
|
|
2
|
+
export type ToolChildCodecErrorCode = "tool_child_frame_too_large" | "tool_child_truncated" | "tool_child_malformed" | "tool_child_encode_invalid";
|
|
3
|
+
export declare class ToolChildCodecError extends Error {
|
|
4
|
+
readonly code: ToolChildCodecErrorCode;
|
|
5
|
+
constructor(code: ToolChildCodecErrorCode, message: string);
|
|
6
|
+
}
|
|
7
|
+
/** A frame is a 4-byte big-endian body length followed by that many bytes of UTF-8 JSON. No delimiter, no NDJSON. */
|
|
8
|
+
export declare const FRAME_HEADER_BYTES = 4;
|
|
9
|
+
export declare function encodeFrame(message: ToolChildMessage): Uint8Array;
|
|
10
|
+
/**
|
|
11
|
+
* Incremental decoder for one channel direction. A length header above the cap is refused as soon as its four bytes
|
|
12
|
+
* arrive, before any body buffer exists. Any codec error is terminal: the stream is never resynchronized, so nothing
|
|
13
|
+
* after a bad frame can be silently skipped.
|
|
14
|
+
*/
|
|
15
|
+
export declare class FrameDecoder {
|
|
16
|
+
#private;
|
|
17
|
+
get bufferedBytes(): number;
|
|
18
|
+
push(chunk: Uint8Array): ToolChildMessage[];
|
|
19
|
+
end(): void;
|
|
20
|
+
}
|
|
21
|
+
//# sourceMappingURL=codec.d.ts.map
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { TOOL_CHILD_LIMITS } from "./protocol.js";
|
|
2
|
+
import { isToolChildMessage } from "./protocol-guard.js";
|
|
3
|
+
export class ToolChildCodecError extends Error {
|
|
4
|
+
constructor(code, message) {
|
|
5
|
+
super(`${code}: ${message}`);
|
|
6
|
+
this.name = "ToolChildCodecError";
|
|
7
|
+
this.code = code;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
/** A frame is a 4-byte big-endian body length followed by that many bytes of UTF-8 JSON. No delimiter, no NDJSON. */
|
|
11
|
+
export const FRAME_HEADER_BYTES = 4;
|
|
12
|
+
const tooLarge = (bytes) => new ToolChildCodecError("tool_child_frame_too_large", `${bytes} bytes exceeds ${TOOL_CHILD_LIMITS.frameBytes}`);
|
|
13
|
+
export function encodeFrame(message) {
|
|
14
|
+
// JSON.stringify drops undefined-valued properties (what optional fields rely on) and natively serializes
|
|
15
|
+
// NaN/Infinity as null on BOTH the inline and the ref path, so payload contents encode identically either way.
|
|
16
|
+
// The one case JSON cannot represent is a REQUIRED json field that is undefined (today only result.value - a
|
|
17
|
+
// tool returning undefined); it becomes an explicit null the guard takes. The round-trip with the decoder's own
|
|
18
|
+
// predicate refuses anything else unsendable HERE - a typed per-message error, never a channel-killing frame
|
|
19
|
+
// (senpi#3569 delta review: optional undefined must keep stringify's default drop).
|
|
20
|
+
const normalized = message.type === "result" && message.value === undefined ? { ...message, value: null } : message;
|
|
21
|
+
const json = JSON.stringify(normalized);
|
|
22
|
+
const bodyBytes = Buffer.byteLength(json, "utf8");
|
|
23
|
+
if (bodyBytes > TOOL_CHILD_LIMITS.frameBytes)
|
|
24
|
+
throw tooLarge(bodyBytes);
|
|
25
|
+
if (!isToolChildMessage(JSON.parse(json))) {
|
|
26
|
+
throw new ToolChildCodecError("tool_child_encode_invalid", "the message serializes to a frame the peer's guard rejects");
|
|
27
|
+
}
|
|
28
|
+
return frameFromBody(json, bodyBytes);
|
|
29
|
+
}
|
|
30
|
+
function frameFromBody(json, bodyBytes) {
|
|
31
|
+
const frame = Buffer.allocUnsafe(FRAME_HEADER_BYTES + bodyBytes);
|
|
32
|
+
frame.writeUInt32BE(bodyBytes, 0);
|
|
33
|
+
frame.write(json, FRAME_HEADER_BYTES, "utf8");
|
|
34
|
+
return frame;
|
|
35
|
+
}
|
|
36
|
+
function parseBody(body) {
|
|
37
|
+
let value;
|
|
38
|
+
try {
|
|
39
|
+
value = JSON.parse(body.toString("utf8"));
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
throw new ToolChildCodecError("tool_child_malformed", "frame body is not JSON");
|
|
43
|
+
}
|
|
44
|
+
if (!isToolChildMessage(value))
|
|
45
|
+
throw new ToolChildCodecError("tool_child_malformed", "frame is not a protocol message");
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Incremental decoder for one channel direction. A length header above the cap is refused as soon as its four bytes
|
|
50
|
+
* arrive, before any body buffer exists. Any codec error is terminal: the stream is never resynchronized, so nothing
|
|
51
|
+
* after a bad frame can be silently skipped.
|
|
52
|
+
*/
|
|
53
|
+
export class FrameDecoder {
|
|
54
|
+
#chunks = [];
|
|
55
|
+
#buffered = 0;
|
|
56
|
+
#bodyBytes;
|
|
57
|
+
#failure;
|
|
58
|
+
get bufferedBytes() {
|
|
59
|
+
return this.#buffered;
|
|
60
|
+
}
|
|
61
|
+
push(chunk) {
|
|
62
|
+
if (this.#failure)
|
|
63
|
+
throw this.#failure;
|
|
64
|
+
if (chunk.length > 0) {
|
|
65
|
+
this.#chunks.push(Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength));
|
|
66
|
+
this.#buffered += chunk.length;
|
|
67
|
+
}
|
|
68
|
+
const messages = [];
|
|
69
|
+
try {
|
|
70
|
+
for (;;) {
|
|
71
|
+
if (this.#bodyBytes === undefined) {
|
|
72
|
+
if (this.#buffered < FRAME_HEADER_BYTES)
|
|
73
|
+
break;
|
|
74
|
+
const length = this.#take(FRAME_HEADER_BYTES).readUInt32BE(0);
|
|
75
|
+
if (length > TOOL_CHILD_LIMITS.frameBytes)
|
|
76
|
+
throw tooLarge(length);
|
|
77
|
+
this.#bodyBytes = length;
|
|
78
|
+
}
|
|
79
|
+
if (this.#buffered < this.#bodyBytes)
|
|
80
|
+
break;
|
|
81
|
+
const body = this.#take(this.#bodyBytes);
|
|
82
|
+
this.#bodyBytes = undefined;
|
|
83
|
+
messages.push(parseBody(body));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
if (error instanceof ToolChildCodecError)
|
|
88
|
+
this.#fail(error);
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
return messages;
|
|
92
|
+
}
|
|
93
|
+
end() {
|
|
94
|
+
if (this.#failure)
|
|
95
|
+
throw this.#failure;
|
|
96
|
+
if (this.#buffered === 0 && this.#bodyBytes === undefined)
|
|
97
|
+
return;
|
|
98
|
+
const truncated = new ToolChildCodecError("tool_child_truncated", `stream ended inside a frame (${this.#buffered} of ${this.#bodyBytes ?? FRAME_HEADER_BYTES} bytes)`);
|
|
99
|
+
this.#fail(truncated);
|
|
100
|
+
throw truncated;
|
|
101
|
+
}
|
|
102
|
+
#fail(error) {
|
|
103
|
+
this.#failure = error;
|
|
104
|
+
this.#chunks = [];
|
|
105
|
+
this.#buffered = 0;
|
|
106
|
+
this.#bodyBytes = undefined;
|
|
107
|
+
}
|
|
108
|
+
#take(bytes) {
|
|
109
|
+
const first = this.#chunks[0];
|
|
110
|
+
if (first !== undefined && first.length >= bytes) {
|
|
111
|
+
const taken = first.subarray(0, bytes);
|
|
112
|
+
if (first.length === bytes)
|
|
113
|
+
this.#chunks.shift();
|
|
114
|
+
else
|
|
115
|
+
this.#chunks[0] = first.subarray(bytes);
|
|
116
|
+
this.#buffered -= bytes;
|
|
117
|
+
return taken;
|
|
118
|
+
}
|
|
119
|
+
const out = Buffer.allocUnsafe(bytes);
|
|
120
|
+
let filled = 0;
|
|
121
|
+
while (filled < bytes) {
|
|
122
|
+
const chunk = this.#chunks[0];
|
|
123
|
+
if (chunk === undefined)
|
|
124
|
+
break;
|
|
125
|
+
const count = Math.min(chunk.length, bytes - filled);
|
|
126
|
+
chunk.copy(out, filled, 0, count);
|
|
127
|
+
filled += count;
|
|
128
|
+
if (count === chunk.length)
|
|
129
|
+
this.#chunks.shift();
|
|
130
|
+
else
|
|
131
|
+
this.#chunks[0] = chunk.subarray(count);
|
|
132
|
+
}
|
|
133
|
+
this.#buffered -= bytes;
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
//# sourceMappingURL=codec.js.map
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { encodeFrame, FRAME_HEADER_BYTES, FrameDecoder, ToolChildCodecError } from "./codec.js";
|
|
3
|
+
import { narrowContext } from "./context.js";
|
|
4
|
+
import { LAUNCH_CONTAINMENTS, TOOL_CHILD_LIMITS } from "./protocol.js";
|
|
5
|
+
import { TOOL_CHILD_MESSAGE_TYPES } from "./protocol-guard.js";
|
|
6
|
+
const ref = { refId: "r-1", generation: 3, bytes: 10, sha256: "a".repeat(64) };
|
|
7
|
+
const SAMPLES = [
|
|
8
|
+
...LAUNCH_CONTAINMENTS.map((containment) => ({
|
|
9
|
+
type: "launch",
|
|
10
|
+
launchId: `l-${containment}`,
|
|
11
|
+
jobId: "j1",
|
|
12
|
+
origin: "tool",
|
|
13
|
+
api: "bun.spawn",
|
|
14
|
+
containment,
|
|
15
|
+
lifetime: "owned",
|
|
16
|
+
pid: 10,
|
|
17
|
+
pgid: 10,
|
|
18
|
+
})),
|
|
19
|
+
{
|
|
20
|
+
type: "launch",
|
|
21
|
+
launchId: "l-det",
|
|
22
|
+
jobId: "j1",
|
|
23
|
+
origin: "kernel",
|
|
24
|
+
api: "cp.spawn",
|
|
25
|
+
containment: "breakaway",
|
|
26
|
+
lifetime: "detached",
|
|
27
|
+
},
|
|
28
|
+
{ type: "launch_ack", launchId: "l-1" },
|
|
29
|
+
{ type: "launch_proof", launchId: "l-1", ackTick: 4, invokeTick: 5 },
|
|
30
|
+
{ type: "launch_done", launchId: "l-1", exitCode: 0 },
|
|
31
|
+
{ type: "launch_done", launchId: "l-2", signal: "SIGTERM" },
|
|
32
|
+
{ type: "command_started", launchId: "l-1", pid: 77, startedAtMs: 1234, creatorInJob: true, breakaway: false },
|
|
33
|
+
{ type: "slot_query", launchId: "l-1" },
|
|
34
|
+
{ type: "slot_state", launchId: "l-1", ackTick: null, invokeTick: null, invocations: 0 },
|
|
35
|
+
{ type: "slot_state", launchId: "l-1", ackTick: 4, invokeTick: 5, invocations: 1 },
|
|
36
|
+
{ type: "trace", atMs: 1, event: "job_start", detail: { kind: "grep", ok: true, n: 3 } },
|
|
37
|
+
{ type: "trace", atMs: 2, event: "idle" },
|
|
38
|
+
{
|
|
39
|
+
type: "batch",
|
|
40
|
+
jobId: "j-parse",
|
|
41
|
+
done: false,
|
|
42
|
+
entries: [
|
|
43
|
+
{ id: "e1", parentId: null, lineOffset: 0, lineBytes: 120, lineDelta: 0, entry: { type: "message" } },
|
|
44
|
+
{ id: "e2", parentId: "e1", lineOffset: 121, lineBytes: 4096, lineDelta: -3500 },
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
{ type: "hello", generation: 3, pid: 99, protocol: 1 },
|
|
48
|
+
{ type: "hello_ack", generation: 3 },
|
|
49
|
+
{ type: "heartbeat", seq: 7 },
|
|
50
|
+
{ type: "job", jobId: "j1", kind: "read", args: { path: "a\nb.txt" } },
|
|
51
|
+
{ type: "job", jobId: "j2", kind: "write", parentJobId: "j1", argsRef: ref },
|
|
52
|
+
{ type: "cancel", jobId: "j1" },
|
|
53
|
+
{ type: "result", jobId: "j1", value: { content: [{ type: "text", text: "line\n" }] } },
|
|
54
|
+
{ type: "result", jobId: "j3", value: null },
|
|
55
|
+
{ type: "result_ref", jobId: "j1", ref },
|
|
56
|
+
{ type: "job_error", jobId: "j1", code: "tool_failed", message: "boom" },
|
|
57
|
+
{ type: "job_refused", jobId: "j9", code: "tool_child_jobs_full" },
|
|
58
|
+
{ type: "host_call", callId: "c1", jobId: "j1", tool: "read", args: { path: "x" } },
|
|
59
|
+
{ type: "host_call", callId: "c2", jobId: "j1", tool: "write", argsRef: ref },
|
|
60
|
+
{ type: "host_reply", callId: "c1", value: [1, 2] },
|
|
61
|
+
{ type: "host_reply", callId: "c2", valueRef: ref },
|
|
62
|
+
{ type: "host_reply", callId: "c3", error: "denied" },
|
|
63
|
+
{ type: "host_call_refused", callId: "c265", code: "host_call_capacity" },
|
|
64
|
+
{ type: "wake", wakeId: "w1", source: "monitor", bytes: 12 },
|
|
65
|
+
{ type: "wake_ack", wakeId: "w1" },
|
|
66
|
+
{ type: "ref_ack", refId: "r-1" },
|
|
67
|
+
{ type: "ref_nack", refId: "r-1", reason: "cancelled" },
|
|
68
|
+
{ type: "shutdown" },
|
|
69
|
+
];
|
|
70
|
+
function concat(parts) {
|
|
71
|
+
return Buffer.concat(parts.map((part) => Buffer.from(part)));
|
|
72
|
+
}
|
|
73
|
+
function codecError(run) {
|
|
74
|
+
try {
|
|
75
|
+
run();
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
if (error instanceof ToolChildCodecError)
|
|
79
|
+
return error.code;
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
function frameWithBody(body) {
|
|
85
|
+
const bytes = Buffer.from(body, "utf8");
|
|
86
|
+
const header = Buffer.alloc(FRAME_HEADER_BYTES);
|
|
87
|
+
header.writeUInt32BE(bytes.length, 0);
|
|
88
|
+
return concat([header, bytes]);
|
|
89
|
+
}
|
|
90
|
+
afterEach(() => {
|
|
91
|
+
vi.restoreAllMocks();
|
|
92
|
+
});
|
|
93
|
+
describe("tool-child codec", () => {
|
|
94
|
+
it("round-trips every message type through one chunked stream", () => {
|
|
95
|
+
expect(new Set(SAMPLES.map((message) => message.type))).toEqual(new Set(TOOL_CHILD_MESSAGE_TYPES));
|
|
96
|
+
const stream = concat(SAMPLES.map(encodeFrame));
|
|
97
|
+
const decoder = new FrameDecoder();
|
|
98
|
+
const decoded = [];
|
|
99
|
+
for (let offset = 0; offset < stream.length; offset += 7) {
|
|
100
|
+
decoded.push(...decoder.push(stream.subarray(offset, offset + 7)));
|
|
101
|
+
}
|
|
102
|
+
decoder.end();
|
|
103
|
+
expect(decoded).toEqual(SAMPLES);
|
|
104
|
+
});
|
|
105
|
+
it("frames with a big-endian length prefix, not NDJSON", () => {
|
|
106
|
+
const frame = Buffer.from(encodeFrame({ type: "job", jobId: "j1", kind: "read", args: { text: "a\nb" } }));
|
|
107
|
+
const bodyBytes = frame.readUInt32BE(0);
|
|
108
|
+
expect(frame.length).toBe(FRAME_HEADER_BYTES + bodyBytes);
|
|
109
|
+
expect(frame.at(-1)).not.toBe(0x0a);
|
|
110
|
+
});
|
|
111
|
+
it("accepts a body of exactly the cap and refuses one byte more on encode", () => {
|
|
112
|
+
const overhead = JSON.stringify({ type: "result", jobId: "j", value: "" }).length;
|
|
113
|
+
const fits = "x".repeat(TOOL_CHILD_LIMITS.frameBytes - overhead);
|
|
114
|
+
const frame = encodeFrame({ type: "result", jobId: "j", value: fits });
|
|
115
|
+
expect(new FrameDecoder().push(frame)).toEqual([{ type: "result", jobId: "j", value: fits }]);
|
|
116
|
+
expect(codecError(() => encodeFrame({ type: "result", jobId: "j", value: `${fits}x` }))).toBe("tool_child_frame_too_large");
|
|
117
|
+
});
|
|
118
|
+
it("refuses a 16 MiB+1 length header before allocating its body, and stays failed", () => {
|
|
119
|
+
const alloc = vi.spyOn(Buffer, "alloc");
|
|
120
|
+
const allocUnsafe = vi.spyOn(Buffer, "allocUnsafe");
|
|
121
|
+
const header = Buffer.alloc(FRAME_HEADER_BYTES);
|
|
122
|
+
header.writeUInt32BE(TOOL_CHILD_LIMITS.frameBytes + 1, 0);
|
|
123
|
+
alloc.mockClear();
|
|
124
|
+
const decoder = new FrameDecoder();
|
|
125
|
+
expect(codecError(() => decoder.push(header))).toBe("tool_child_frame_too_large");
|
|
126
|
+
const large = (spy) => spy.mock.calls.filter(([size]) => Number(size) > 1024);
|
|
127
|
+
expect(large(alloc)).toEqual([]);
|
|
128
|
+
expect(large(allocUnsafe)).toEqual([]);
|
|
129
|
+
expect(decoder.bufferedBytes).toBe(0);
|
|
130
|
+
expect(codecError(() => decoder.push(encodeFrame({ type: "shutdown" })))).toBe("tool_child_frame_too_large");
|
|
131
|
+
});
|
|
132
|
+
it("reports a truncated stream as tool_child_truncated", () => {
|
|
133
|
+
const frame = encodeFrame({ type: "heartbeat", seq: 1 });
|
|
134
|
+
const partialBody = new FrameDecoder();
|
|
135
|
+
expect(partialBody.push(frame.subarray(0, frame.length - 1))).toEqual([]);
|
|
136
|
+
expect(codecError(() => partialBody.end())).toBe("tool_child_truncated");
|
|
137
|
+
const partialHeader = new FrameDecoder();
|
|
138
|
+
partialHeader.push(frame.subarray(0, 2));
|
|
139
|
+
expect(codecError(() => partialHeader.end())).toBe("tool_child_truncated");
|
|
140
|
+
const clean = new FrameDecoder();
|
|
141
|
+
clean.push(frame);
|
|
142
|
+
expect(codecError(() => clean.end())).toBeUndefined();
|
|
143
|
+
});
|
|
144
|
+
it("refuses malformed frames instead of dropping them", () => {
|
|
145
|
+
const cases = [
|
|
146
|
+
"not json",
|
|
147
|
+
JSON.stringify({ type: "unknown_type" }),
|
|
148
|
+
JSON.stringify({ type: "launch_proof", launchId: "l", ackTick: 1 }),
|
|
149
|
+
JSON.stringify({ type: "trace", atMs: 1, event: "e", value: { result: 1 } }),
|
|
150
|
+
JSON.stringify({ type: "trace", atMs: 1, event: "e", detail: { ackTick: 4 } }),
|
|
151
|
+
JSON.stringify({
|
|
152
|
+
type: "launch",
|
|
153
|
+
launchId: "l",
|
|
154
|
+
jobId: "j",
|
|
155
|
+
origin: "tool",
|
|
156
|
+
api: "bun.spawn",
|
|
157
|
+
containment: "pid",
|
|
158
|
+
lifetime: "owned",
|
|
159
|
+
}),
|
|
160
|
+
];
|
|
161
|
+
for (const body of cases) {
|
|
162
|
+
expect(codecError(() => new FrameDecoder().push(frameWithBody(body)))).toBe("tool_child_malformed");
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
describe("ChildToolContext", () => {
|
|
167
|
+
it("narrowContext keeps plain data only: no functions, no ui, no sessionManager", () => {
|
|
168
|
+
const extensionContext = {
|
|
169
|
+
ui: { notify: () => undefined },
|
|
170
|
+
sessionManager: { getEntries: () => [] },
|
|
171
|
+
cwd: "/work",
|
|
172
|
+
agentDir: "/agent",
|
|
173
|
+
mode: "rpc",
|
|
174
|
+
model: { provider: "anthropic", id: "claude", api: "messages", compat: () => true },
|
|
175
|
+
thinkingLevel: "medium",
|
|
176
|
+
isProjectTrusted: () => true,
|
|
177
|
+
isIdle: () => false,
|
|
178
|
+
goalStoreFile: "/agent/goal.json",
|
|
179
|
+
};
|
|
180
|
+
const narrowed = narrowContext(extensionContext, { sessionId: "s1", toolCallId: "t1" });
|
|
181
|
+
const keys = [];
|
|
182
|
+
const functions = [];
|
|
183
|
+
const walk = (value, path) => {
|
|
184
|
+
if (typeof value === "function")
|
|
185
|
+
functions.push(path);
|
|
186
|
+
if (typeof value !== "object" || value === null)
|
|
187
|
+
return;
|
|
188
|
+
for (const [key, item] of Object.entries(value)) {
|
|
189
|
+
keys.push(key);
|
|
190
|
+
walk(item, `${path}.${key}`);
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
walk(narrowed, "ctx");
|
|
194
|
+
expect(functions).toEqual([]);
|
|
195
|
+
expect(keys).not.toContain("ui");
|
|
196
|
+
expect(keys).not.toContain("sessionManager");
|
|
197
|
+
expect(narrowed).toEqual({
|
|
198
|
+
cwd: "/work",
|
|
199
|
+
agentDir: "/agent",
|
|
200
|
+
mode: "rpc",
|
|
201
|
+
sessionId: "s1",
|
|
202
|
+
toolCallId: "t1",
|
|
203
|
+
projectTrusted: true,
|
|
204
|
+
model: { provider: "anthropic", id: "claude" },
|
|
205
|
+
thinkingLevel: "medium",
|
|
206
|
+
goalStoreFile: "/agent/goal.json",
|
|
207
|
+
});
|
|
208
|
+
const [decoded] = new FrameDecoder().push(encodeFrame({ type: "job", jobId: "j", kind: "read", args: narrowed }));
|
|
209
|
+
expect(decoded).toEqual({ type: "job", jobId: "j", kind: "read", args: narrowed });
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
describe("encode-time normalization (senpi#3569 review)", () => {
|
|
213
|
+
it("a job result of undefined encodes as an explicit null the decoder accepts", () => {
|
|
214
|
+
const [decoded] = new FrameDecoder().push(encodeFrame({ type: "result", jobId: "j", value: undefined }));
|
|
215
|
+
expect(decoded).toMatchObject({ type: "result", jobId: "j", value: null });
|
|
216
|
+
});
|
|
217
|
+
it("a NaN job result encodes as null instead of killing the channel", () => {
|
|
218
|
+
const [decoded] = new FrameDecoder().push(encodeFrame({ type: "result", jobId: "j", value: Number.NaN }));
|
|
219
|
+
expect(decoded).toMatchObject({ type: "result", jobId: "j", value: null });
|
|
220
|
+
});
|
|
221
|
+
it("optional fields set to undefined keep stringify's default drop and still encode (delta review HIGH 1)", () => {
|
|
222
|
+
const [decoded] = new FrameDecoder().push(encodeFrame({ type: "launch_done", launchId: "l1", signal: "SIGTERM" }));
|
|
223
|
+
expect(decoded).toEqual({ type: "launch_done", launchId: "l1", signal: "SIGTERM" });
|
|
224
|
+
expect("exitCode" in decoded).toBe(false);
|
|
225
|
+
});
|
|
226
|
+
it("nested undefined payload keys stay dropped, matching the ref path (delta review MEDIUM 2)", () => {
|
|
227
|
+
const [decoded] = new FrameDecoder().push(encodeFrame({ type: "result", jobId: "j", value: { content: "x", details: undefined } }));
|
|
228
|
+
expect(decoded.value).toEqual({ content: "x" });
|
|
229
|
+
});
|
|
230
|
+
it("a non-finite required number is refused at encode with a typed error, never as a frame", () => {
|
|
231
|
+
expect(() => encodeFrame({ type: "heartbeat", seq: Number.POSITIVE_INFINITY })).toThrow(ToolChildCodecError);
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
//# sourceMappingURL=codec.test.js.map
|