channels.tools 0.1.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.
@@ -0,0 +1,132 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { splitGuidance, renderBatch, strictestIntent } from "./envelope.ts";
4
+
5
+ test("splitGuidance separates on the first --- sentinel", () => {
6
+ const r = splitGuidance("line one\n\n---\nGUIDANCE HERE");
7
+ assert.equal(r.body, "line one");
8
+ assert.equal(r.guidance, "GUIDANCE HERE");
9
+ });
10
+
11
+ test("splitGuidance leaves content without a sentinel untouched", () => {
12
+ const r = splitGuidance("just an envelope line");
13
+ assert.equal(r.body, "just an envelope line");
14
+ assert.equal(r.guidance, undefined);
15
+ });
16
+
17
+ test("splitGuidance splits only on the first sentinel so guidance may contain one", () => {
18
+ const r = splitGuidance("body\n\n---\nfirst\n\n---\nsecond");
19
+ assert.equal(r.body, "body");
20
+ assert.equal(r.guidance, "first\n\n---\nsecond");
21
+ });
22
+
23
+ test("a single event renders as a channel block with meta as attributes", () => {
24
+ const out = renderBatch("muster-channel", [{
25
+ source: "muster-channel",
26
+ content: 'muster: reply from pi/pi-setup on thread #305 "x" — call get_thread 305.',
27
+ meta: { kind: "reply", from: "pi/pi-setup", thread_id: "305", intent: "action-requested", count: "1" },
28
+ }]);
29
+ assert.match(out, /^<channel source="muster-channel" /);
30
+ assert.match(out, /kind="reply"/);
31
+ assert.match(out, /from="pi\/pi-setup"/);
32
+ assert.match(out, /thread_id="305"/);
33
+ assert.match(out, /intent="action-requested"/);
34
+ assert.match(out, /call get_thread 305\./);
35
+ assert.match(out, /<\/channel>$/);
36
+ });
37
+
38
+ test("identical guidance across a batch is emitted once", () => {
39
+ const ev = (id: string) => ({
40
+ source: "galley",
41
+ content: `galley: revise on doc-${id}\n\n---\nNEVER REWRITE THE WHOLE FILE`,
42
+ meta: { reason: "revise", doc: `doc-${id}` },
43
+ });
44
+ const out = renderBatch("galley", [ev("a"), ev("b")]);
45
+ assert.equal(out.match(/NEVER REWRITE THE WHOLE FILE/g)?.length, 1);
46
+ assert.match(out, /doc-a/);
47
+ assert.match(out, /doc-b/);
48
+ });
49
+
50
+ test("distinct guidance blocks are all kept, in first-appearance order", () => {
51
+ const out = renderBatch("muster-channel", [
52
+ { source: "muster-channel", content: "a\n\n---\nFYI GUIDANCE", meta: { intent: "fyi" } },
53
+ { source: "muster-channel", content: "b\n\n---\nACTION GUIDANCE", meta: { intent: "action-requested" } },
54
+ ]);
55
+ assert.match(out, /FYI GUIDANCE/);
56
+ assert.match(out, /ACTION GUIDANCE/);
57
+ assert.ok(out.indexOf("FYI GUIDANCE") < out.indexOf("ACTION GUIDANCE"));
58
+ });
59
+
60
+ test("a batch attributes count and the strictest intent present", () => {
61
+ const out = renderBatch("muster-channel", [
62
+ { source: "muster-channel", content: "a", meta: { intent: "fyi" } },
63
+ { source: "muster-channel", content: "b", meta: { intent: "action-requested" } },
64
+ { source: "muster-channel", content: "c", meta: { intent: "reply-requested" } },
65
+ ]);
66
+ assert.match(out, /count="3"/);
67
+ assert.match(out, /intent="action-requested"/);
68
+ });
69
+
70
+ test("attribute values with quotes and angle brackets are escaped", () => {
71
+ const out = renderBatch("muster-channel", [
72
+ { source: "muster-channel", content: "x", meta: { subject: 'he said "hi" <b>' } },
73
+ ]);
74
+ assert.match(out, /subject="he said &quot;hi&quot; &lt;b&gt;"/);
75
+ assert.doesNotMatch(out.split("\n")[0], /<b>/);
76
+ });
77
+
78
+ test("a multi-event batch drops per-event identity attributes, keeping only count and intent (F9)", () => {
79
+ const out = renderBatch("muster-channel", [
80
+ { source: "muster-channel", content: "a", meta: { thread_id: "305", from: "x", kind: "reply", intent: "fyi" } },
81
+ { source: "muster-channel", content: "b", meta: { thread_id: "999", from: "y", kind: "reply", intent: "action-requested" } },
82
+ ]);
83
+ assert.doesNotMatch(out, /thread_id=/);
84
+ assert.doesNotMatch(out, /from=/);
85
+ assert.doesNotMatch(out, /kind=/);
86
+ assert.match(out, /count="2"/);
87
+ assert.match(out, /intent="action-requested"/);
88
+ });
89
+
90
+ test("a meta key literally named 'source' does not produce a duplicate attribute (F10)", () => {
91
+ const out = renderBatch("muster-channel", [
92
+ { source: "muster-channel", content: "x", meta: { source: "evil" } },
93
+ ]);
94
+ const matches = out.match(/source="/g) ?? [];
95
+ assert.equal(matches.length, 1);
96
+ assert.match(out, /source="muster-channel"/);
97
+ });
98
+
99
+ test("strictestIntent orders action-requested over reply-requested over fyi", () => {
100
+ assert.equal(strictestIntent(["fyi", "action-requested", "reply-requested"]), "action-requested");
101
+ assert.equal(strictestIntent(["fyi", "reply-requested"]), "reply-requested");
102
+ assert.equal(strictestIntent(["fyi"]), "fyi");
103
+ assert.equal(strictestIntent([]), undefined);
104
+ assert.equal(strictestIntent(["unknown-thing"]), "unknown-thing");
105
+ });
106
+
107
+ test("a server's own count is preserved, not overwritten by the notification count", () => {
108
+ // muster coalesces server-side: one push can represent many threads, and it
109
+ // says so in meta.count. Ours counts notifications. Overwriting theirs tells
110
+ // the agent one thing arrived when three did.
111
+ const out = renderBatch("muster-channel", [
112
+ { source: "muster-channel", content: "muster: 3 new — …", meta: { count: "3", intent: "action-requested" } },
113
+ ]);
114
+ assert.match(out, /count="3"/);
115
+ });
116
+
117
+ test("coalescing sums the servers' counts rather than counting notifications", () => {
118
+ const out = renderBatch("muster-channel", [
119
+ { source: "muster-channel", content: "a", meta: { count: "3", intent: "fyi" } },
120
+ { source: "muster-channel", content: "b", meta: { count: "2", intent: "action-requested" } },
121
+ ]);
122
+ assert.match(out, /count="5"/);
123
+ assert.match(out, /intent="action-requested"/);
124
+ });
125
+
126
+ test("events without a server count fall back to counting notifications", () => {
127
+ const out = renderBatch("galley", [
128
+ { source: "galley", content: "a", meta: { reason: "revise" } },
129
+ { source: "galley", content: "b", meta: { reason: "revise" } },
130
+ ]);
131
+ assert.match(out, /count="2"/);
132
+ });
@@ -0,0 +1,94 @@
1
+ export type ChannelEvent = {
2
+ source: string;
3
+ content: string;
4
+ meta: Record<string, string>;
5
+ };
6
+
7
+ const GUIDANCE_SENTINEL = "\n\n---\n";
8
+
9
+ // Strictness rises with index. An unknown intent sorts above every known
10
+ // one: a server that grows a new intent must not have it silently ranked
11
+ // as the least urgent thing in the batch.
12
+ const STRICTNESS = ["fyi", "reply-requested", "action-requested"];
13
+
14
+ export function splitGuidance(content: string): { body: string; guidance?: string } {
15
+ const at = content.indexOf(GUIDANCE_SENTINEL);
16
+ if (at < 0) return { body: content };
17
+ return {
18
+ body: content.slice(0, at),
19
+ guidance: content.slice(at + GUIDANCE_SENTINEL.length),
20
+ };
21
+ }
22
+
23
+ export function strictestIntent(intents: string[]): string | undefined {
24
+ let best: string | undefined;
25
+ let bestRank = -1;
26
+ for (const intent of intents) {
27
+ if (intent === "") continue;
28
+ const known = STRICTNESS.indexOf(intent);
29
+ const rank = known < 0 ? STRICTNESS.length : known;
30
+ if (rank > bestRank) {
31
+ bestRank = rank;
32
+ best = intent;
33
+ }
34
+ }
35
+ return best;
36
+ }
37
+
38
+ function escapeAttr(value: string): string {
39
+ return value
40
+ .replace(/&/g, "&amp;")
41
+ .replace(/</g, "&lt;")
42
+ .replace(/>/g, "&gt;")
43
+ .replace(/"/g, "&quot;");
44
+ }
45
+
46
+ export function renderBatch(source: string, events: ChannelEvent[]): string {
47
+ const bodies: string[] = [];
48
+ const guidance: string[] = [];
49
+ const intents: string[] = [];
50
+
51
+ for (const event of events) {
52
+ const split = splitGuidance(event.content);
53
+ if (split.body !== "") bodies.push(split.body);
54
+ if (split.guidance !== undefined && !guidance.includes(split.guidance)) {
55
+ guidance.push(split.guidance);
56
+ }
57
+ if (event.meta.intent) intents.push(event.meta.intent);
58
+ }
59
+
60
+ // Attributes describe the batch. A single event's identity facts
61
+ // (thread_id, from, kind, doc, reason, …) are trustworthy verbatim. Once
62
+ // more than one event is coalesced, those per-event facts no longer
63
+ // describe "the" event — carrying event[0]'s thread_id while count says 3
64
+ // is an actively false attribute an agent could act on — so only the
65
+ // recomputed count and intent survive into a multi-event batch. The
66
+ // body lines still carry each event's own facts.
67
+ const attrs: Record<string, string> = events.length > 1 ? {} : { ...(events[0]?.meta ?? {}) };
68
+ // "source" is reserved for the hardcoded server-name attribute below; a
69
+ // meta key literally named `source` must not produce a duplicate.
70
+ delete attrs.source;
71
+ // A server may coalesce before we do: some servers send one push
72
+ // representing several events and say how many in meta.count. Overwriting that with our
73
+ // notification count tells the agent one thing arrived when three did, so
74
+ // sum the servers' own counts and fall back to counting notifications only
75
+ // for events that carry none.
76
+ attrs.count = String(
77
+ events.reduce((total, event) => {
78
+ const declared = Number(event.meta.count);
79
+ return total + (Number.isFinite(declared) && declared > 0 ? declared : 1);
80
+ }, 0),
81
+ );
82
+ const strictest = strictestIntent(intents);
83
+ if (strictest !== undefined) attrs.intent = strictest;
84
+ else delete attrs.intent;
85
+
86
+ const rendered = Object.entries(attrs)
87
+ .map(([k, v]) => `${k}="${escapeAttr(v)}"`)
88
+ .join(" ");
89
+
90
+ const parts = [bodies.join("\n")];
91
+ if (guidance.length > 0) parts.push(guidance.join("\n\n"));
92
+
93
+ return `<channel source="${escapeAttr(source)}" ${rendered}>\n${parts.join("\n\n---\n")}\n</channel>`;
94
+ }
@@ -0,0 +1,34 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { applyIdentity } from "./identity.ts";
4
+
5
+ test("sets AGENT_SESSION_ID on the given environment", () => {
6
+ const env: NodeJS.ProcessEnv = {};
7
+ applyIdentity("session-abc", env);
8
+ assert.equal(env.AGENT_SESSION_ID, "session-abc");
9
+ });
10
+
11
+ test("never sets CLAUDE_CODE_SESSION_ID — a pi session must not claim to be Claude Code", () => {
12
+ const env: NodeJS.ProcessEnv = {};
13
+ applyIdentity("session-abc", env);
14
+ assert.equal(env.CLAUDE_CODE_SESSION_ID, undefined);
15
+ });
16
+
17
+ test("an inherited CLAUDE_CODE_SESSION_ID is left untouched", () => {
18
+ const env: NodeJS.ProcessEnv = { CLAUDE_CODE_SESSION_ID: "outer" };
19
+ applyIdentity("session-abc", env);
20
+ assert.equal(env.CLAUDE_CODE_SESSION_ID, "outer");
21
+ });
22
+
23
+ test("a later session id replaces an earlier one, as resume and fork require", () => {
24
+ const env: NodeJS.ProcessEnv = {};
25
+ applyIdentity("first", env);
26
+ applyIdentity("second", env);
27
+ assert.equal(env.AGENT_SESSION_ID, "second");
28
+ });
29
+
30
+ test("an undefined session id clears the variable rather than writing 'undefined'", () => {
31
+ const env: NodeJS.ProcessEnv = { AGENT_SESSION_ID: "stale" };
32
+ applyIdentity(undefined, env);
33
+ assert.equal(env.AGENT_SESSION_ID, undefined);
34
+ });
@@ -0,0 +1,13 @@
1
+ export const SESSION_ID_VAR = "AGENT_SESSION_ID";
2
+
3
+ // Deliberately does NOT set CLAUDE_CODE_SESSION_ID. Some channel servers
4
+ // read that variable to decide whether a session is Claude Code, and a pi
5
+ // session must never claim to be one. Servers wanting this session's id
6
+ // should read the neutral AGENT_SESSION_ID.
7
+ export function applyIdentity(sessionId: string | undefined, env: NodeJS.ProcessEnv = process.env): void {
8
+ if (sessionId === undefined || sessionId === "") {
9
+ delete env[SESSION_ID_VAR];
10
+ return;
11
+ }
12
+ env[SESSION_ID_VAR] = sessionId;
13
+ }
@@ -0,0 +1,293 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { mkdtempSync, rmSync } from "node:fs";
6
+ import { createExtension } from "./index.ts";
7
+
8
+ const FAKE = join(import.meta.dirname, "..", "test", "fake-channel-server.ts");
9
+ const FLAKY = join(import.meta.dirname, "..", "test", "flaky-channel-server.ts");
10
+
11
+ type Handler = (event: unknown, ctx: unknown) => unknown;
12
+
13
+ type ToolDef = { name: string; execute(id: string, params: unknown): Promise<unknown> };
14
+
15
+ function fakePi() {
16
+ const handlers = new Map<string, Handler[]>();
17
+ const sent: Array<{ msg: unknown; opts: unknown }> = [];
18
+ const registered: string[] = [];
19
+ const unregistered: string[] = [];
20
+ const tools = new Map<string, ToolDef>();
21
+ const statuses: string[] = [];
22
+ const pi = {
23
+ on(event: string, handler: Handler) {
24
+ const list = handlers.get(event) ?? [];
25
+ list.push(handler);
26
+ handlers.set(event, list);
27
+ },
28
+ sendUserMessage(content: unknown, opts: unknown) { sent.push({ msg: content, opts }); },
29
+ registerTool(def: ToolDef) { registered.push(def.name); tools.set(def.name, def); },
30
+ unregisterTool(name: string) { unregistered.push(name); tools.delete(name); },
31
+ getAllTools() { return [{ name: "bash" }, { name: "read" }]; },
32
+ getActiveTools() { return ["bash", "read"]; },
33
+ setActiveTools(_names: string[]) {},
34
+ };
35
+ const ctx = {
36
+ sessionManager: { getSessionId: () => "session-xyz" },
37
+ ui: { setStatus: (_k: string, v?: string) => { statuses.push(v ?? ""); } },
38
+ hasUI: true,
39
+ cwd: process.cwd(),
40
+ };
41
+ async function fire(event: string, payload: unknown = {}): Promise<unknown[]> {
42
+ const out: unknown[] = [];
43
+ for (const h of handlers.get(event) ?? []) out.push(await h(payload, ctx));
44
+ return out;
45
+ }
46
+ return { pi, ctx, fire, sent, registered, unregistered, tools, statuses };
47
+ }
48
+
49
+ test("session_start sets AGENT_SESSION_ID before any server is spawned", async () => {
50
+ const { pi, fire } = fakePi();
51
+ const spawnOrder: string[] = [];
52
+ const env: NodeJS.ProcessEnv = {};
53
+ createExtension(pi as never, {
54
+ env,
55
+ loadConfig: () => ({ fake: { command: process.execPath, args: [FAKE] } }),
56
+ onBeforeSpawn: () => { spawnOrder.push(`env=${env.AGENT_SESSION_ID ?? "unset"}`); },
57
+ });
58
+ await fire("session_start", { reason: "startup" });
59
+ assert.deepEqual(spawnOrder, ["env=session-xyz"],
60
+ "identity must be set before spawn: galley channel reads it at its own startup");
61
+ // session_start's connectAll() spawns and fully handshakes the fake server;
62
+ // leaving it open here leaks the child process and hangs node --test.
63
+ await fire("session_shutdown");
64
+ });
65
+
66
+ test("an inbound event while idle is delivered as a user message through the full prompt pipeline", async () => {
67
+ const { pi, fire, sent } = fakePi();
68
+ createExtension(pi as never, {
69
+ env: {},
70
+ loadConfig: () => ({ fake: { command: process.execPath, args: [FAKE], env: { FAKE_PUSH_AFTER_MS: "50" } } }),
71
+ });
72
+ await fire("session_start", { reason: "startup" });
73
+ await new Promise((r) => setTimeout(r, 500));
74
+ assert.equal(sent.length, 1);
75
+ const opts = sent[0].opts as { deliverAs?: string };
76
+ assert.equal(opts.deliverAs, "steer");
77
+ const text = sent[0].msg as string;
78
+ assert.match(text, /<channel source="fake"/);
79
+ await fire("session_shutdown");
80
+ });
81
+
82
+ test("a server's tools are registered under their own names", async () => {
83
+ const { pi, fire, registered } = fakePi();
84
+ createExtension(pi as never, {
85
+ env: {},
86
+ loadConfig: () => ({ fake: { command: process.execPath, args: [FAKE] } }),
87
+ });
88
+ await fire("session_start", { reason: "startup" });
89
+ await new Promise((r) => setTimeout(r, 300));
90
+ assert.ok(registered.includes("fake_status"), `registered: ${registered.join(", ")}`);
91
+ await fire("session_shutdown");
92
+ });
93
+
94
+ test("before_agent_start injects the connected server's instructions", async () => {
95
+ const { pi, fire } = fakePi();
96
+ createExtension(pi as never, {
97
+ env: {},
98
+ loadConfig: () => ({ fake: { command: process.execPath, args: [FAKE] } }),
99
+ });
100
+ await fire("session_start", { reason: "startup" });
101
+ await new Promise((r) => setTimeout(r, 300));
102
+ const results = await fire("before_agent_start", { systemPrompt: "BASE PROMPT" });
103
+ const returned = results.find((r): r is { systemPrompt: string } =>
104
+ typeof r === "object" && r !== null && "systemPrompt" in r);
105
+ assert.ok(returned, "before_agent_start must return a modified systemPrompt");
106
+ assert.match(returned.systemPrompt, /BASE PROMPT/);
107
+ assert.match(returned.systemPrompt, /FAKE CORE INSTRUCTIONS/);
108
+ await fire("session_shutdown");
109
+ });
110
+
111
+ test("before_agent_start returns undefined when nothing is connected", async () => {
112
+ const { pi, fire } = fakePi();
113
+ createExtension(pi as never, { env: {}, loadConfig: () => ({}) });
114
+ await fire("session_start", { reason: "startup" });
115
+ const results = await fire("before_agent_start", { systemPrompt: "BASE PROMPT" });
116
+ assert.deepEqual(results, [undefined]);
117
+ await fire("session_shutdown");
118
+ });
119
+
120
+ test("session_shutdown closes connections and clears status", async () => {
121
+ const { pi, fire, statuses } = fakePi();
122
+ createExtension(pi as never, {
123
+ env: {},
124
+ loadConfig: () => ({ fake: { command: process.execPath, args: [FAKE] } }),
125
+ });
126
+ await fire("session_start", { reason: "startup" });
127
+ await new Promise((r) => setTimeout(r, 300));
128
+ await fire("session_shutdown");
129
+ assert.equal(statuses.at(-1), "");
130
+ });
131
+
132
+ test("a tool still works after a resume re-registers it against a fresh connection", async () => {
133
+ const { pi, fire, tools } = fakePi();
134
+ createExtension(pi as never, {
135
+ env: {},
136
+ loadConfig: () => ({ fake: { command: process.execPath, args: [FAKE] } }),
137
+ });
138
+ await fire("session_start", { reason: "startup" });
139
+ await new Promise((r) => setTimeout(r, 300));
140
+ // Simulate a resume: session_start fires again, connectAll() closes the
141
+ // first connection's client and spawns a fresh one. A stale registration
142
+ // guard would leave the tool's execute() bound to the now-closed client.
143
+ await fire("session_start", { reason: "resume" });
144
+ await new Promise((r) => setTimeout(r, 300));
145
+ const tool = tools.get("fake_status");
146
+ assert.ok(tool, "fake_status must still be registered after resume");
147
+ const result = (await tool.execute("call-1", {})) as { content: Array<{ text: string }> };
148
+ assert.deepEqual(result.content, [{ type: "text", text: "fake ok" }]);
149
+ await fire("session_shutdown");
150
+ });
151
+
152
+ test("a tool result with isError:true is signaled by throwing, not returned silently", async () => {
153
+ const { pi, fire, tools } = fakePi();
154
+ createExtension(pi as never, {
155
+ env: {},
156
+ loadConfig: () => ({
157
+ fake: { command: process.execPath, args: [FAKE], env: { FAKE_TOOL_ISERROR: "1" } },
158
+ }),
159
+ });
160
+ await fire("session_start", { reason: "startup" });
161
+ await new Promise((r) => setTimeout(r, 300));
162
+ const tool = tools.get("fake_status");
163
+ assert.ok(tool, "fake_status must be registered");
164
+ await assert.rejects(() => tool!.execute("call-1", {}), /FAKE TOOL ERROR TEXT/);
165
+ await fire("session_shutdown");
166
+ });
167
+
168
+ test("a retry that lands before connectAll resolves does not have its tools dropped as stale", async () => {
169
+ // The race F1's own fix created: `fake` fails, schedules a 50ms retry, and
170
+ // reconnects while `slow` is still handshaking. onConnected registers
171
+ // fake_status. Then session_start's own registerTools() runs — and if it
172
+ // registers from connectAll's returned array (which cannot contain `fake`,
173
+ // whose first attempt failed) instead of manager.connections(), it treats
174
+ // fake_status as stale and unregisters it. The channel is live, its
175
+ // instructions are injected, its events wake the agent, and its tool is gone
176
+ // for the rest of the session with no further retry scheduled.
177
+ const { pi, fire, tools, unregistered } = fakePi();
178
+ const dir = mkdtempSync(join(tmpdir(), "pi-channels-race-"));
179
+ const counterFile = join(dir, "count.txt");
180
+ try {
181
+ createExtension(pi as never, {
182
+ env: {},
183
+ loadConfig: () => ({
184
+ fake: { command: process.execPath, args: [FLAKY], env: { FLAKY_COUNTER_FILE: counterFile } },
185
+ // A DISTINCT tool name is essential: if both servers advertised
186
+ // fake_status, the stale computation would never drop it and this test
187
+ // would pass against the buggy code.
188
+ slow: {
189
+ command: process.execPath,
190
+ args: [FAKE],
191
+ env: { FAKE_INIT_DELAY_MS: "400", FAKE_TOOL_NAME: "slow_status" },
192
+ },
193
+ }),
194
+ retryBaseMs: 50,
195
+ retryMaxMs: 50,
196
+ });
197
+ // session_start does not resolve until `slow` finishes its 400ms handshake,
198
+ // so the 50ms retry lands squarely inside it.
199
+ await fire("session_start", { reason: "startup" });
200
+ assert.ok(
201
+ tools.has("fake_status"),
202
+ `fake_status must survive session_start's registerTools; unregistered: ${unregistered.join(", ")}`,
203
+ );
204
+ assert.ok(
205
+ !unregistered.includes("fake_status"),
206
+ "fake_status must not be treated as stale — the retry had already registered it",
207
+ );
208
+ const tool = tools.get("fake_status");
209
+ const result = (await tool!.execute("call-1", {})) as { content: Array<{ text: string }> };
210
+ assert.deepEqual(result.content, [{ type: "text", text: "fake ok" }], "the surviving tool must still work");
211
+ } finally {
212
+ // In the finally, not after the assertions: a failing assertion would
213
+ // otherwise skip it and leave the channels open, hanging the test runner.
214
+ await fire("session_shutdown");
215
+ rmSync(dir, { recursive: true, force: true });
216
+ }
217
+ });
218
+
219
+ test("a channel that reconnects via retry gets its tools re-registered (F1)", async () => {
220
+ const { pi, fire, tools } = fakePi();
221
+ const dir = mkdtempSync(join(tmpdir(), "pi-channels-flaky-"));
222
+ const counterFile = join(dir, "count.txt");
223
+ try {
224
+ createExtension(pi as never, {
225
+ env: {},
226
+ loadConfig: () => ({
227
+ fake: { command: process.execPath, args: [FLAKY], env: { FLAKY_COUNTER_FILE: counterFile } },
228
+ }),
229
+ retryBaseMs: 50,
230
+ retryMaxMs: 50,
231
+ });
232
+ await fire("session_start", { reason: "startup" });
233
+ // The first attempt dies mid-handshake, so session_start's own
234
+ // registerTools() call runs with zero connections — this is the state
235
+ // pre-fix leaves the channel in forever.
236
+ assert.ok(!tools.has("fake_status"), "the first (failed) attempt must not have registered anything yet");
237
+ // 50ms later the retry connects for real.
238
+ await new Promise((r) => setTimeout(r, 500));
239
+ const tool = tools.get("fake_status");
240
+ assert.ok(tool, "fake_status must be registered once the retry reconnects");
241
+ const result = (await tool!.execute("call-1", {})) as { content: Array<{ text: string }> };
242
+ assert.deepEqual(result.content, [{ type: "text", text: "fake ok" }]);
243
+ await fire("session_shutdown");
244
+ } finally {
245
+ rmSync(dir, { recursive: true, force: true });
246
+ }
247
+ });
248
+
249
+ test("stale tools are dropped from the active set when unregisterTool is unavailable (F5)", async () => {
250
+ const { pi, fire, tools } = fakePi();
251
+ (pi as { unregisterTool?: unknown }).unregisterTool = undefined;
252
+ const activeToolsCalls: string[][] = [];
253
+ pi.getActiveTools = () => ["bash", "read", "fake_status"];
254
+ pi.setActiveTools = (names: string[]) => { activeToolsCalls.push(names); };
255
+ let channelConfigured = true;
256
+ createExtension(pi as never, {
257
+ env: {},
258
+ loadConfig: (): Record<string, import("./config.ts").ChannelServerDef> =>
259
+ channelConfigured ? { fake: { command: process.execPath, args: [FAKE] } } : {},
260
+ });
261
+ await fire("session_start", { reason: "startup" });
262
+ await new Promise((r) => setTimeout(r, 300));
263
+ assert.ok(tools.has("fake_status"));
264
+ // Simulate a resume where the channel is no longer configured: connectAll
265
+ // tears down the old connection and there is nothing left to register, so
266
+ // "fake_status" is now stale.
267
+ channelConfigured = false;
268
+ await fire("session_start", { reason: "resume" });
269
+ await new Promise((r) => setTimeout(r, 100));
270
+ assert.ok(
271
+ activeToolsCalls.some((names) => !names.includes("fake_status")),
272
+ `expected setActiveTools to be called without fake_status, got: ${JSON.stringify(activeToolsCalls)}`,
273
+ );
274
+ await fire("session_shutdown");
275
+ });
276
+
277
+ test("a registerTool failure during session_start does not block the mail gate from opening", async () => {
278
+ const { pi, fire, sent } = fakePi();
279
+ pi.registerTool = () => {
280
+ throw new Error("boom: registerTool exploded");
281
+ };
282
+ createExtension(pi as never, {
283
+ env: {},
284
+ loadConfig: () => ({
285
+ fake: { command: process.execPath, args: [FAKE], env: { FAKE_PUSH_AFTER_MS: "50" } },
286
+ }),
287
+ });
288
+ await fire("session_start", { reason: "startup" });
289
+ await new Promise((r) => setTimeout(r, 500));
290
+ assert.equal(sent.length, 1,
291
+ "mail must still be delivered even though session_start's registerTool step threw");
292
+ await fire("session_shutdown");
293
+ });