1// @effect-diagnostics nodeBuiltinImport:off -- the fake CLI and its leftover child are real processes.
2import * as ClaudeSdk from "@anthropic-ai/claude-agent-sdk";
3import * as NodeChildProcess from "node:child_process";
4import * as NodeFS from "node:fs";
5import * as NodeOS from "node:os";
6import * as NodePath from "node:path";
7import * as NodeServices from "@effect/platform-node/NodeServices";
8import { assert, describe, it } from "@effect/vitest";
9import { ProviderSessionId, ThreadId } from "@t3tools/contracts";
10import * as Effect from "effect/Effect";
11import * as Exit from "effect/Exit";
12import * as Layer from "effect/Layer";
13import { vi } from "vite-plus/test";
14
15import { ProviderEventLoggers } from "@t3tools/provider-core/server/ProviderEventLoggers";
16import * as HostProcess from "@t3tools/shared/HostProcess";
17import {
18 CLAUDE_AGENT_SDK_QUERY_PROTOCOL,
19 ClaudeAgentSdkQueryRunner,
20 layerQueryRunner,
21} from "./ClaudeAdapterV2.ts";
22
23vi.mock("@anthropic-ai/claude-agent-sdk", { spy: true });
24
25it.effect("logs successful task stops for replay and omits failed stops", () =>
26 Effect.gen(function* () {
27 const writes: unknown[] = [];
28 const stopTask = vi.fn(async (_taskId: string) => {});
29 const query = vi.spyOn(ClaudeSdk, "query").mockReturnValue({
30 stopTask,
31 } as unknown as ClaudeSdk.Query);
32 yield* Effect.addFinalizer(() => Effect.sync(() => query.mockRestore()));
33 const threadId = ThreadId.make("thread-stop");
34 const providerSessionId = ProviderSessionId.make("session-stop");
35 yield* Effect.gen(function* () {
36 const runner = yield* ClaudeAgentSdkQueryRunner;
37 const session = yield* runner.open({
38 threadId,
39 providerSessionId,
40 options: {
41 sessionId: "native-session-stop",
42 model: "claude-sonnet-4-6",
43 tools: [],
44 permissionMode: "default",
45 },
46 });
47 writes.length = 0;
48 yield* session.stopTask("workflow-task");
49 assert.deepEqual(stopTask.mock.calls, [["workflow-task"]]);
50 assert.deepEqual(writes, [
51 {
52 provider: "claudeAgent",
53 protocol: CLAUDE_AGENT_SDK_QUERY_PROTOCOL,
54 kind: "protocol",
55 providerSessionId,
56 event: {
57 direction: "outgoing",
58 stage: "decoded",
59 payload: { type: "query.stop_task", taskId: "workflow-task" },
60 },
61 },
62 ]);
63 stopTask.mockRejectedValueOnce(new Error("Stop failed"));
64 assert.isTrue(Exit.isFailure(yield* Effect.exit(session.stopTask("failed-task"))));
65 assert.equal(writes.length, 1);
66 }).pipe(
67 Effect.provide(
68 layerQueryRunner.pipe(
69 Layer.provide(
70 Layer.succeed(ProviderEventLoggers, {
71 native: {
72 filePath: "/tmp/events.log",
73 write: (event) =>
74 Effect.sync(() => {
75 writes.push(event);
76 }),
77 close: () => Effect.void,
78 },
79 canonical: undefined,
80 }),
81 ),
82 ),
83 ),
84 );
85 }).pipe(Effect.provide(NodeServices.layer)),
86);
87
88const realQueryRunner = layerQueryRunner.pipe(
89 Layer.provide(
90 Layer.succeed(ProviderEventLoggers, {
91 native: undefined,
92 canonical: undefined,
93 }),
94 ),
95 Layer.provide(NodeServices.layer),
96);
97
98/** Writes an executable stand-in for the Claude CLI into a fresh directory. */
99function fakeClaudeCli(script: string): string {
100 const dir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-claude-cli-"));
101 const cli = NodePath.join(dir, "claude");
102 NodeFS.writeFileSync(cli, `#!/bin/sh\n${script}\n`, { mode: 0o755 });
103 return cli;
104}
105
106const openFakeCli = (cli: string) =>
107 Effect.gen(function* () {
108 const runner = yield* ClaudeAgentSdkQueryRunner;
109 return yield* runner.open({
110 threadId: ThreadId.make("thread-fake-cli"),
111 providerSessionId: ProviderSessionId.make("session-fake-cli"),
112 options: {
113 sessionId: "native-session-fake-cli",
114 model: "claude-sonnet-4-6",
115 tools: [],
116 permissionMode: "default",
117 pathToClaudeCodeExecutable: cli,
118 },
119 });
120 });
121
122describe.skipIf(HostProcess.Platform.defaultValue() === "win32")("Claude CLI process", () => {
123 // The CLI stops the MCP servers it started, but not their children: a
124 // plugin's `bun server.ts` outlives its `bun run` wrapper (#3089). The fake
125 // CLI leaves a child running the same way. The child holds a FIFO open, so
126 // its death reads as end-of-file here, with no polling.
127 it.effect("stops what the CLI left running when the session closes", () =>
128 Effect.gen(function* () {
129 const cli = fakeClaudeCli(
130 [
131 'dir=$(dirname "$0")',
132 'sleep 600 > "$dir/alive" &',
133 'echo $! > "$dir/leftover.pid"',
134 "cat > /dev/null",
135 ].join("\n"),
136 );
137 const dir = NodePath.dirname(cli);
138 NodeChildProcess.execFileSync("mkfifo", [NodePath.join(dir, "alive")]);
139 yield* Effect.addFinalizer(() =>
140 Effect.sync(() => {
141 // Only does anything when the test fails: do not leak its child.
142 try {
143 process.kill(Number(NodeFS.readFileSync(NodePath.join(dir, "leftover.pid"), "utf8")));
144 } catch {}
145 NodeFS.rmSync(dir, { recursive: true, force: true });
146 }),
147 );
148 const alive = NodeFS.createReadStream(NodePath.join(dir, "alive"));
149 const leftoverStarted = new Promise<void>((resolve) => alive.once("open", () => resolve()));
150 const leftoverStopped = new Promise<void>((resolve) => alive.once("close", () => resolve()));
151 alive.resume();
152
153 const session = yield* openFakeCli(cli);
154 yield* Effect.promise(() => leftoverStarted);
155 yield* session.close;
156 yield* Effect.promise(() => leftoverStopped);
157 }).pipe(Effect.provide(realQueryRunner)),
158 );
159});