// @effect-diagnostics nodeBuiltinImport:off -- the fake CLI and its leftover child are real processes. import * as ClaudeSdk from "@anthropic-ai/claude-agent-sdk"; import * as NodeChildProcess from "node:child_process"; import * as NodeEvents from "node:events"; import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import * as NodeStream from "node:stream"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import { ProviderSessionId, ThreadId } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Deferred from "effect/Deferred"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import { vi } from "vite-plus/test"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { AgentScope } from "@t3tools/shared/AgentScope"; import { ProviderEventLoggers } from "@t3tools/provider-core/server/ProviderEventLoggers"; import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; import { signalProcessGroup } from "@t3tools/provider-core/server/processGroup"; import * as PortScanner from "../../preview/PortScanner.ts"; import { ClaudeAgentSdkQueryRunner, type ClaudeAgentSdkQueryOpenInput, layerQueryRunner, spawnClaudeProcessGroup, } from "./ClaudeAdapterV2.ts"; vi.mock("@anthropic-ai/claude-agent-sdk", { spy: true }); const FORK_SESSION_ID = "11111111-1111-4111-8111-111111111111"; const sessionEntry = (uuid: string, parentUuid: string | null, message: unknown) => ({ type: parentUuid === null ? "user" : "assistant", uuid, parentUuid, sessionId: FORK_SESSION_ID, cwd: "/repo", isSidechain: false, timestamp: "2026-10-01T00:00:00.000Z", message, }); const writeClaudeSession = Effect.fnUntraced(function* ( configDir: string, entries: ReadonlyArray = [ sessionEntry("aaaaaaaa-0000-4000-8000-000000000001", null, { role: "user", content: "hello" }), sessionEntry("aaaaaaaa-0000-4000-8000-000000000002", "aaaaaaaa-0000-4000-8000-000000000001", { role: "assistant", content: [{ type: "text", text: "hi" }], }), ], ) { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const projectDir = path.join(configDir, "projects", "-repo"); yield* fileSystem.makeDirectory(projectDir, { recursive: true }); yield* fileSystem.writeFileString( path.join(projectDir, `${FORK_SESSION_ID}.jsonl`), entries.map((line) => JSON.stringify(line)).join("\n"), ); return projectDir; }); const forkRunnerLayer = layerQueryRunner.pipe( Layer.provide(PortScanner.layerTest), Layer.provide(Layer.succeed(ProviderEventLoggers, { native: undefined, canonical: undefined })), ); it.effect("forks a session stored under the instance's own CLAUDE_CONFIG_DIR", () => Effect.scoped( Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const configDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-claude-fork-config-", }); const projectDir = yield* writeClaudeSession(configDir); const hostConfigDir = (yield* HostProcess.Environment).CLAUDE_CONFIG_DIR; const inProcessFork = vi.spyOn(ClaudeSdk, "forkSession"); yield* Effect.addFinalizer(() => Effect.sync(() => inProcessFork.mockRestore())); const runner = yield* ClaudeAgentSdkQueryRunner; const forked = yield* runner.forkSession({ sessionId: FORK_SESSION_ID, options: { dir: "/repo" }, environment: { ...process.env, CLAUDE_CONFIG_DIR: configDir }, threadId: ThreadId.make("thread-fork-config-dir"), providerSessionId: ProviderSessionId.make("session-fork-config-dir"), }); assert.notEqual(forked.sessionId, FORK_SESSION_ID); const transcript = yield* fileSystem.readFileString( path.join(projectDir, `${forked.sessionId}.jsonl`), ); assert.include(transcript, '"content":"hello"'); assert.include(transcript, '"text":"hi"'); const inheritedHead = transcript .trim() .split("\n") .findLast((line) => line.includes('"type":"assistant"')) ?.match(/"uuid"\s*:\s*"([^"]+)"/)?.[1]; assert.isString(inheritedHead); assert.equal(forked.headMessageId, inheritedHead); assert.equal((yield* HostProcess.Environment).CLAUDE_CONFIG_DIR, hostConfigDir); assert.equal(inProcessFork.mock.calls.length, 0); }).pipe(Effect.provide(forkRunnerLayer)), ).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("forked Claude sessions keep their compaction chain", () => Effect.scoped( Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const configDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-claude-fork-compact-", }); const uuid = (n: number) => `bbbbbbbb-0000-4000-8000-00000000000${n}`; const turn = (id: number, parent: string | null, role: "user" | "assistant") => ({ ...sessionEntry(uuid(id), parent, { role, content: role === "user" ? `question ${id}` : [{ type: "text", text: `answer ${id}` }], }), type: role, }); const projectDir = yield* writeClaudeSession(configDir, [ turn(1, null, "user"), turn(2, uuid(1), "assistant"), turn(3, uuid(2), "user"), turn(4, uuid(3), "assistant"), { ...sessionEntry(uuid(5), null, undefined), type: "system", subtype: "compact_boundary", content: "Conversation compacted", logicalParentUuid: uuid(4), compactMetadata: { trigger: "manual", preTokens: 19979, preservedSegment: { headUuid: uuid(3), anchorUuid: uuid(6), tailUuid: uuid(4) }, preservedMessages: { anchorUuid: uuid(6), uuids: [uuid(3), uuid(4)], allUuids: [uuid(3), uuid(4)], }, postTokens: 3589, }, }, { ...turn(6, uuid(5), "user"), isCompactSummary: true, isVisibleInTranscriptOnly: true, }, turn(7, uuid(4), "user"), turn(8, uuid(7), "assistant"), ]); const runner = yield* ClaudeAgentSdkQueryRunner; const forked = yield* runner.forkSession({ sessionId: FORK_SESSION_ID, options: { dir: "/repo" }, environment: { ...process.env, CLAUDE_CONFIG_DIR: configDir }, threadId: ThreadId.make("thread-fork-compaction"), providerSessionId: ProviderSessionId.make("session-fork-compaction"), }); const entries: ReadonlyArray<{ readonly uuid?: string; readonly subtype?: string; readonly compactMetadata?: { readonly preservedSegment: Record<"headUuid" | "anchorUuid" | "tailUuid", string>; readonly preservedMessages: { readonly anchorUuid: string; readonly uuids: string[]; readonly allUuids: string[]; }; }; }> = (yield* fileSystem.readFileString(path.join(projectDir, `${forked.sessionId}.jsonl`))) .trim() .split("\n") .map((line) => JSON.parse(line)); const forkedUuids = new Set(entries.map((entry) => entry.uuid)); const boundary = entries.find((entry) => entry.subtype === "compact_boundary"); assert.isDefined(boundary?.compactMetadata); const { preservedSegment, preservedMessages } = boundary!.compactMetadata!; const danglingRefs = [ ...Object.values(preservedSegment), preservedMessages.anchorUuid, ...preservedMessages.uuids, ...preservedMessages.allUuids, ].filter((ref) => !forkedUuids.has(ref)); assert.deepEqual(danglingRefs, [], "compaction refs missing from the forked transcript"); }).pipe(Effect.provide(forkRunnerLayer)), ).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); const realQueryRunner = layerQueryRunner.pipe( Layer.provide(PortScanner.layerTest), Layer.provide( Layer.succeed(ProviderEventLoggers, { native: undefined, canonical: undefined, }), ), Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ); const resumedMcpQueryInput = { threadId: ThreadId.make("thread-restart-registration"), providerSessionId: ProviderSessionId.make("session-restart-registration"), options: { resume: FORK_SESSION_ID, model: "claude-sonnet-4-6", tools: [], permissionMode: "default", mcpServers: { "t3-code": { type: "http", url: "http://localhost:3773/mcp", headers: { Authorization: "Bearer test-mcp-credential" }, }, }, }, } satisfies ClaudeAgentSdkQueryOpenInput; it.effect("reopens a stalled MCP registration before delivering the resumed prompt", () => Effect.scoped( Effect.gen(function* () { const registrationStarted = Promise.withResolvers(); const disposalStarted = Promise.withResolvers(); const finishDisposal = Promise.withResolvers(); const prompts: AsyncIterable[] = []; const firstClose = vi.fn(); const secondClose = vi.fn(); const firstRegistration = vi.fn(() => { registrationStarted.resolve(); return new Promise(() => {}); }); const secondRegistration = vi.fn(async () => ({ added: ["t3-code"], removed: [], errors: {}, })); const querySpy = vi.spyOn(ClaudeSdk, "query").mockImplementation(({ prompt }) => { assert.notTypeOf(prompt, "string"); if (typeof prompt === "string") throw new Error("Expected streaming input"); prompts.push(prompt); return { setMcpServers: prompts.length === 1 ? firstRegistration : secondRegistration, close: prompts.length === 1 ? firstClose : secondClose, [Symbol.asyncDispose]: async () => { firstClose(); disposalStarted.resolve(); await finishDisposal.promise; }, } as unknown as ClaudeSdk.Query; }); yield* Effect.addFinalizer(() => Effect.sync(() => querySpy.mockRestore())); const runner = yield* ClaudeAgentSdkQueryRunner; const opening = yield* runner.open(resumedMcpQueryInput).pipe(Effect.forkChild); yield* Effect.promise(() => registrationStarted.promise); yield* TestClock.adjust("90 seconds"); yield* Effect.promise(() => disposalStarted.promise); assert.equal(querySpy.mock.calls.length, 1); finishDisposal.resolve(); const session = yield* Fiber.join(opening); yield* Effect.addFinalizer(() => session.close.pipe(Effect.orDie)); assert.equal(firstClose.mock.calls.length, 1); assert.equal(secondClose.mock.calls.length, 0); assert.equal(querySpy.mock.calls.length, 2); for (const [call] of querySpy.mock.calls) { assert.equal(call.options?.resume, FORK_SESSION_ID); assert.isUndefined(call.options?.mcpServers); } assert.deepEqual(secondRegistration.mock.calls, [[resumedMcpQueryInput.options.mcpServers]]); const message = { type: "user", message: { role: "user", content: "Continue where you left off." }, parent_tool_use_id: null, session_id: FORK_SESSION_ID, } satisfies ClaudeSdk.SDKUserMessage; yield* session.offer(message); assert.isTrue((yield* Effect.promise(() => prompts[0]![Symbol.asyncIterator]().next())).done); const delivered = yield* Effect.promise(() => prompts[1]![Symbol.asyncIterator]().next()); assert.deepEqual(delivered.value, message); assert.isFalse(delivered.done); }).pipe(Effect.provide(realQueryRunner)), ), ); it.effect.each([ { case: "a readable rule list", errors: undefined, readable: true }, { case: "a settings file Claude skipped", errors: [{ file: "settings.json" }], readable: false }, { case: "a reply without a rule list", response: {}, readable: false }, ])("reads Claude's permission rules only when no settings file was skipped: $case", (testCase) => Effect.scoped( Effect.gen(function* () { const rules = [{ behavior: "deny" as const, rule: "mcp__probe__redeploy" }]; const querySpy = vi.spyOn(ClaudeSdk, "query").mockImplementation( () => ({ close: vi.fn(), request: async () => ({ response: "response" in testCase ? testCase.response : { state: { rules, errors: testCase.errors } }, }), }) as unknown as ClaudeSdk.Query, ); yield* Effect.addFinalizer(() => Effect.sync(() => querySpy.mockRestore())); const runner = yield* ClaudeAgentSdkQueryRunner; const { mcpServers: _mcpServers, ...options } = resumedMcpQueryInput.options; const session = yield* runner.open({ ...resumedMcpQueryInput, options }); yield* Effect.addFinalizer(() => session.close.pipe(Effect.orDie)); const exit = yield* Effect.exit(session.listPermissionRules); if (testCase.readable) assert.deepEqual(exit, Exit.succeed(rules)); else assert.isTrue(Exit.isFailure(exit)); }).pipe(Effect.provide(realQueryRunner)), ), ); it.effect.each([true, false])("only retries after confirmed process exit: %s", (exits) => Effect.scoped( Effect.gen(function* () { const registrationStarted = Promise.withResolvers(); const cleanupProgress = Promise.withResolvers<"kill" | "retry">(); const events = new NodeEvents.EventEmitter(); const stdin = new NodeStream.PassThrough(); const stdout = new NodeStream.PassThrough(); const child = { stdin, stdout, killed: false, exitCode: null, kill: vi.fn(() => { cleanupProgress.resolve("kill"); return true; }), on: events.on.bind(events), once: events.once.bind(events), off: events.off.bind(events), } satisfies ClaudeSdk.SpawnedProcess; const signal = yield* Effect.abortSignal; const disposed = vi.fn(async () => {}); const querySpy = vi.spyOn(ClaudeSdk, "query").mockImplementation(({ options }) => { const first = querySpy.mock.calls.length === 1; if (first) { options?.spawnClaudeCodeProcess?.({ command: "claude", args: [], env: {}, signal }); } else { cleanupProgress.resolve("retry"); } return { setMcpServers: () => { registrationStarted.resolve(); return first ? new Promise(() => {}) : Promise.resolve({ added: ["t3-code"], removed: [], errors: {} }); }, close: vi.fn(), [Symbol.asyncDispose]: disposed, } as unknown as ClaudeSdk.Query; }); yield* Effect.addFinalizer(() => Effect.sync(() => { querySpy.mockRestore(); stdin.destroy(); stdout.destroy(); }), ); const runner = yield* ClaudeAgentSdkQueryRunner; const opening = yield* runner .open({ ...resumedMcpQueryInput, options: { ...resumedMcpQueryInput.options, spawnClaudeCodeProcess: () => child }, }) .pipe(Effect.exit, Effect.forkChild); yield* Effect.promise(() => registrationStarted.promise); yield* TestClock.adjust("90 seconds"); assert.equal(yield* Effect.promise(() => cleanupProgress.promise), "kill"); assert.equal(querySpy.mock.calls.length, 1); assert.deepEqual(child.kill.mock.calls, [["SIGKILL"]]); if (exits) events.emit("exit", null, "SIGKILL"); else yield* TestClock.adjust("10 seconds"); const result = yield* Fiber.join(opening); assert.equal(Exit.isSuccess(result), exits); if (Exit.isSuccess(result)) yield* result.value.close; assert.equal(querySpy.mock.calls.length, exits ? 2 : 1); assert.equal(disposed.mock.calls.length, 1); }).pipe(Effect.provide(realQueryRunner)), ), ); it.effect("stops retrying MCP registration after two timeouts and reports the cause", () => Effect.scoped( Effect.gen(function* () { const firstRegistration = Promise.withResolvers(); const secondRegistration = Promise.withResolvers(); const disposed = vi.fn(async () => {}); const querySpy = vi.spyOn(ClaudeSdk, "query").mockImplementation(() => { const started = querySpy.mock.calls.length === 1 ? firstRegistration : secondRegistration; return { setMcpServers: () => { started.resolve(); return new Promise(() => {}); }, [Symbol.asyncDispose]: disposed, } as unknown as ClaudeSdk.Query; }); yield* Effect.addFinalizer(() => Effect.sync(() => querySpy.mockRestore())); const runner = yield* ClaudeAgentSdkQueryRunner; const opening = yield* runner .open(resumedMcpQueryInput) .pipe(Effect.result, Effect.forkChild); yield* Effect.promise(() => firstRegistration.promise); yield* TestClock.adjust("90 seconds"); yield* Effect.promise(() => secondRegistration.promise); yield* TestClock.adjust("90 seconds"); const result = yield* Fiber.join(opening); assert.equal(result._tag, "Failure"); if (result._tag !== "Failure") return; assert.equal(querySpy.mock.calls.length, 2); assert.equal(disposed.mock.calls.length, 2); assert.equal( makeProviderFailure({ cause: { _tag: "ProviderAdapterTurnStartError", cause: result.failure }, }).message, "Claude timed out while registering its MCP tools. Retry the turn; if it keeps failing, check Claude and the server logs.", ); }).pipe(Effect.provide(realQueryRunner)), ), ); it.effect("cancels a stalled registration without retrying or retaining its prompt queue", () => Effect.scoped( Effect.gen(function* () { const registrationStarted = Promise.withResolvers(); const disposed = vi.fn(async () => {}); const querySpy = vi.spyOn(ClaudeSdk, "query").mockImplementation( () => ({ setMcpServers: () => { registrationStarted.resolve(); return new Promise(() => {}); }, [Symbol.asyncDispose]: disposed, }) as unknown as ClaudeSdk.Query, ); yield* Effect.addFinalizer(() => Effect.sync(() => querySpy.mockRestore())); const runner = yield* ClaudeAgentSdkQueryRunner; const opening = yield* runner.open(resumedMcpQueryInput).pipe(Effect.forkChild); yield* Effect.promise(() => registrationStarted.promise); yield* Fiber.interrupt(opening); assert.equal(querySpy.mock.calls.length, 1); assert.equal(disposed.mock.calls.length, 1); const prompt = querySpy.mock.calls[0]![0].prompt; if (typeof prompt === "string") throw new Error("Expected streaming input"); assert.isTrue((yield* Effect.promise(() => prompt[Symbol.asyncIterator]().next())).done); }).pipe(Effect.provide(realQueryRunner)), ), ); it.effect("does not reuse a fresh session ID after MCP registration times out", () => Effect.scoped( Effect.gen(function* () { const registrationStarted = Promise.withResolvers(); const disposed = vi.fn(async () => {}); const querySpy = vi.spyOn(ClaudeSdk, "query").mockImplementation( () => ({ setMcpServers: () => { registrationStarted.resolve(); return new Promise(() => {}); }, [Symbol.asyncDispose]: disposed, }) as unknown as ClaudeSdk.Query, ); yield* Effect.addFinalizer(() => Effect.sync(() => querySpy.mockRestore())); const runner = yield* ClaudeAgentSdkQueryRunner; const { resume, ...options } = resumedMcpQueryInput.options; const opening = yield* runner .open({ ...resumedMcpQueryInput, options: { ...options, sessionId: resume }, }) .pipe(Effect.result, Effect.forkChild); yield* Effect.promise(() => registrationStarted.promise); yield* TestClock.adjust("90 seconds"); const result = yield* Fiber.join(opening); assert.equal(result._tag, "Failure"); assert.equal(querySpy.mock.calls.length, 1); assert.equal(disposed.mock.calls.length, 1); }).pipe(Effect.provide(realQueryRunner)), ), ); function fakeClaudeCli(script: string): string { const dir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-claude-cli-")); const cli = NodePath.join(dir, "claude"); NodeFS.writeFileSync(cli, `#!/bin/sh\n${script}\n`, { mode: 0o755 }); return cli; } const openFakeCli = (cli: string) => Effect.gen(function* () { const runner = yield* ClaudeAgentSdkQueryRunner; return yield* runner.open({ threadId: ThreadId.make("thread-fake-cli"), providerSessionId: ProviderSessionId.make("session-fake-cli"), options: { sessionId: "native-session-fake-cli", model: "claude-sonnet-4-6", tools: [], permissionMode: "default", pathToClaudeCodeExecutable: cli, }, }); }); describe.skipIf(HostProcess.Platform.defaultValue() === "win32")("Claude CLI process", () => { // The leftover child holds a FIFO open; its death produces EOF without polling. it.effect("stops what the CLI left running when the session closes", () => Effect.gen(function* () { const cli = fakeClaudeCli( [ 'dir=$(dirname "$0")', 'sleep 600 > "$dir/alive" &', 'echo $! > "$dir/leftover.pid"', "cat > /dev/null", ].join("\n"), ); const dir = NodePath.dirname(cli); NodeChildProcess.execFileSync("mkfifo", [NodePath.join(dir, "alive")]); yield* Effect.addFinalizer(() => Effect.sync(() => { try { process.kill(Number(NodeFS.readFileSync(NodePath.join(dir, "leftover.pid"), "utf8"))); } catch {} NodeFS.rmSync(dir, { recursive: true, force: true }); }), ); const alive = NodeFS.createReadStream(NodePath.join(dir, "alive")); const leftoverStarted = new Promise((resolve) => alive.once("open", () => resolve())); const leftoverStopped = new Promise((resolve) => alive.once("close", () => resolve())); alive.resume(); const stopped = yield* Deferred.make(); const session = yield* openFakeCli(cli).pipe( Effect.provide( realQueryRunner.pipe( Layer.provide( Layer.succeed(AgentScope, { ...AgentScope.defaultValue(), wrap: ({ command, args }) => Effect.succeed({ command, args, unit: "t3code-claude-test.scope" }), stop: (unit) => Deferred.succeed(stopped, unit).pipe(Effect.asVoid), }), ), ), ), ); yield* Effect.promise(() => leftoverStarted); yield* session.close; yield* Effect.promise(() => leftoverStopped); assert.strictEqual(yield* Deferred.await(stopped), "t3code-claude-test.scope"); }), ); // The default AgentScope is what macOS gets: no systemd scope to catch a job outside the CLI's group. it.effect("stops a job the CLI started in its own process group when the session closes", () => Effect.gen(function* () { const cli = fakeClaudeCli( [ 'dir=$(dirname "$0")', `perl -e 'setpgrp(0, 0); exec @ARGV' sleep 600 > "$dir/alive" &`, 'echo $! > "$dir/leftover.pid"', "cat > /dev/null", ].join("\n"), ); const dir = NodePath.dirname(cli); NodeChildProcess.execFileSync("mkfifo", [NodePath.join(dir, "alive")]); yield* Effect.addFinalizer(() => Effect.sync(() => { try { process.kill(Number(NodeFS.readFileSync(NodePath.join(dir, "leftover.pid"), "utf8"))); } catch {} NodeFS.rmSync(dir, { recursive: true, force: true }); }), ); const alive = NodeFS.createReadStream(NodePath.join(dir, "alive")); const leftoverStarted = new Promise((resolve) => alive.once("open", () => resolve())); const leftoverStopped = new Promise((resolve) => alive.once("close", () => resolve())); alive.resume(); const session = yield* openFakeCli(cli).pipe(Effect.provide(realQueryRunner)); yield* Effect.promise(() => leftoverStarted); yield* session.close; yield* Effect.promise(() => leftoverStopped); }), ); // Live clock: the job is found by polling while the CLI runs, and the CLI exits on its own. it.live("stops a job the CLI started in its own process group when the CLI exits", () => Effect.gen(function* () { const cli = fakeClaudeCli( [ 'dir=$(dirname "$0")', `perl -e 'setpgrp(0, 0); exec @ARGV' sleep 600 > "$dir/alive" &`, 'echo $! > "$dir/leftover.pid"', "sleep 3", ].join("\n"), ); const dir = NodePath.dirname(cli); NodeChildProcess.execFileSync("mkfifo", [NodePath.join(dir, "alive")]); yield* Effect.addFinalizer(() => Effect.sync(() => { try { process.kill(Number(NodeFS.readFileSync(NodePath.join(dir, "leftover.pid"), "utf8"))); } catch {} NodeFS.rmSync(dir, { recursive: true, force: true }); }), ); const alive = NodeFS.createReadStream(NodePath.join(dir, "alive")); const leftoverStopped = new Promise((resolve) => alive.once("close", () => resolve())); alive.resume(); yield* openFakeCli(cli).pipe(Effect.provide(realQueryRunner)); yield* Effect.promise(() => leftoverStopped); }), ); const rootRefusal = "--dangerously-skip-permissions cannot be used with root/sudo privileges for security reasons"; const startupFailureMessage = (stderr: string) => Effect.gen(function* () { const cli = fakeClaudeCli(`printf '%s\\n' '${stderr}' >&2\nexit 1`); yield* Effect.addFinalizer(() => Effect.sync(() => NodeFS.rmSync(NodePath.dirname(cli), { recursive: true, force: true })), ); const session = yield* openFakeCli(cli); const exit = yield* Stream.runDrain(session.messages).pipe(Effect.exit); assert.isTrue(Exit.isFailure(exit)); return Exit.isFailure(exit) ? makeProviderFailure({ cause: exit.cause, class: "transport_error" }).message : ""; }); // Startup stderr can carry paths, tokens, or settings, so only the known // refusal reaches the failure the client shows. it.effect("keeps other startup stderr out of the turn failure", () => Effect.gen(function* () { const surrounded = yield* startupFailureMessage( `token=sk-private-123\n${rootRefusal}\nat /home/private/settings.json`, ); assert.include(surrounded, rootRefusal); assert.notInclude(surrounded, "sk-private-123"); assert.notInclude(surrounded, "/home/private"); const unknown = yield* startupFailureMessage("token=sk-private-123"); assert.notInclude(unknown, "sk-private-123"); }).pipe(Effect.provide(realQueryRunner)), ); // The CLI keeps running its turn after its pipes close, so a server that // crashed would leave it working next to the session it resumes. it.effect("stops the CLI when the server that started it goes away", () => Effect.gen(function* () { const cli = fakeClaudeCli(['dir=$(dirname "$0")', 'sleep 600 > "$dir/alive"'].join("\n")); const dir = NodePath.dirname(cli); NodeChildProcess.execFileSync("mkfifo", [NodePath.join(dir, "alive")]); const child = spawnClaudeProcessGroup({ command: cli, args: [], cwd: dir, env: process.env, // @effect-diagnostics-next-line abortControllerInEffect:off - This crash fixture closes pipes itself; aborting on test teardown raises an unrelated process error. signal: new AbortController().signal, }) as NodeChildProcess.ChildProcess; yield* Effect.addFinalizer(() => Effect.sync(() => { try { signalProcessGroup(child.pid ?? 0, "SIGKILL"); } catch {} NodeFS.rmSync(dir, { recursive: true, force: true }); }), ); const alive = NodeFS.createReadStream(NodePath.join(dir, "alive")); const turnStarted = new Promise((resolve) => alive.once("open", () => resolve())); const turnStopped = new Promise((resolve) => alive.once("close", () => resolve())); alive.resume(); yield* Effect.promise(() => turnStarted); // A dying server's descriptors close; the kernel would do this for it. for (const pipe of child.stdio) pipe?.destroy(); yield* Effect.promise(() => turnStopped); }), ); it.effect("marks the CLI's jobs with its thread and counts it as an agent until it exits", () => { let runningAgents = 0; let releaseAgent!: () => void; const agentReleased = new Promise((resolve) => (releaseAgent = resolve)); const runner = layerQueryRunner.pipe( Layer.provide( Layer.mock(PortScanner.PortDiscovery)({ trackAgent: () => { runningAgents += 1; return () => { runningAgents -= 1; releaseAgent(); }; }, }), ), Layer.provide( Layer.succeed(ProviderEventLoggers, { native: undefined, canonical: undefined }), ), Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ); return Effect.gen(function* () { const cli = fakeClaudeCli( [ 'dir=$(dirname "$0")', `printf '%s' "$${PortScanner.AGENT_THREAD_ENV}" > "$dir/thread"`, "cat > /dev/null", ].join("\n"), ); const dir = NodePath.dirname(cli); NodeChildProcess.execFileSync("mkfifo", [NodePath.join(dir, "thread")]); yield* Effect.addFinalizer(() => Effect.sync(() => NodeFS.rmSync(dir, { recursive: true, force: true })), ); const threadRead = new Promise((resolve) => { let thread = ""; NodeFS.createReadStream(NodePath.join(dir, "thread"), "utf8") .on("data", (chunk) => (thread += chunk)) .once("end", () => resolve(thread)); }); const session = yield* openFakeCli(cli); assert.equal(yield* Effect.promise(() => threadRead), "thread-fake-cli"); assert.equal(runningAgents, 1); yield* session.close; yield* Effect.promise(() => agentReleased); assert.equal(runningAgents, 0); }).pipe(Effect.provide(runner)); }); });