Open History to see the last commit
1// @effect-diagnostics nodeBuiltinImport:off
2import * as NodePath from "node:path";
3import * as NodeOS from "node:os";
4import * as NodeURL from "node:url";
5import * as NodeFS from "node:fs";
6
7import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
8import * as NodeServices from "@effect/platform-node/NodeServices";
9import * as Layer from "effect/Layer";
10import { assert, it } from "@effect/vitest";
11import * as HostProcess from "@t3tools/shared/HostProcess";
12import * as Deferred from "effect/Deferred";
13import * as Effect from "effect/Effect";
14import * as Exit from "effect/Exit";
15import * as Fiber from "effect/Fiber";
16import * as Scope from "effect/Scope";
17import * as Result from "effect/Result";
18import * as Stream from "effect/Stream";
19import { ChildProcessSpawner } from "effect/process";
20import { describe, expect } from "vite-plus/test";
21
22import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";
23import { AgentScope } from "@t3tools/shared/AgentScope";
24import * as EffectAcpErrors from "effect-acp/errors";
25
26const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url));
27const mockAgentPath = NodePath.join(__dirname, "../../../scripts/acp-mock-agent.ts");
28const mockAgentCommand = "node";
29const mockAgentArgs = [mockAgentPath];
30const mockRuntimeOptions = {
31 spawn: { command: mockAgentCommand, args: mockAgentArgs },
32 cwd: process.cwd(),
33 clientInfo: { name: "t3-test", version: "0.0.0" },
34 authMethodId: "test",
35} satisfies AcpSessionRuntime.AcpSessionRuntimeOptions;
36
37// The spawner extends the real process environment, so the test must set it there.
38const setProcessEnv = (name: string, value: string) => {
39 const previous = process.env[name];
40 process.env[name] = value;
41 return () => {
42 if (previous === undefined) delete process.env[name];
43 else process.env[name] = previous;
44 };
45};
46
47describe("AcpSessionRuntime", () => {
48 it.effect.each(["session/new", "session/resume"] as const)(
49 "buffers root metadata while %s startup is still pending",
50 (setupMethod) =>
51 Effect.gen(function* () {
52 const setupReplied = yield* Deferred.make<void>();
53 const allowStartup = yield* Deferred.make<void>();
54 const events: Array<AcpSessionRuntime.AcpSessionRuntimeEvent> = [];
55 const runtime = yield* AcpSessionRuntime.make({
56 ...mockRuntimeOptions,
57 ...(setupMethod === "session/resume"
58 ? {
59 spawn: {
60 ...mockRuntimeOptions.spawn,
61 env: { T3_ACP_SESSION_LIFECYCLE: "1" },
62 },
63 resumeSessionId: "mock-session-1",
64 resumeMethod: "resume" as const,
65 }
66 : {}),
67 requestLogger: (event) =>
68 event.method === setupMethod && event.status === "succeeded"
69 ? Deferred.succeed(setupReplied, undefined).pipe(
70 Effect.andThen(Deferred.await(allowStartup)),
71 )
72 : Effect.void,
73 });
74 yield* runtime.getEvents().pipe(
75 Stream.runForEach((event) => {
76 if (event._tag === "EventStreamBarrier") {
77 return Deferred.succeed(event.acknowledge, undefined);
78 }
79 events.push(event);
80 return Effect.void;
81 }),
82 Effect.forkChild,
83 );
84 const startup = yield* runtime.start().pipe(Effect.forkChild);
85 yield* Deferred.await(setupReplied);
86 yield* runtime.request("_test/startup-metadata", {});
87 yield* Deferred.succeed(allowStartup, undefined);
88 yield* Fiber.join(startup);
89 yield* runtime.drainEvents;
90
91 expect(events.map((event) => event._tag)).toEqual([
92 "AvailableCommandsUpdated",
93 "ModeChanged",
94 "ConfigOptionsUpdated",
95 ]);
96 expect(events[0]).toMatchObject({
97 availableCommands: [{ name: "plan", description: "Native command" }],
98 });
99 expect(yield* runtime.getModeState).toMatchObject({ currentModeId: "code" });
100 expect(events[2]).toMatchObject({
101 configOptions: yield* runtime.getConfigOptions,
102 });
103 expect(
104 (yield* runtime.getConfigOptions).find((option) => option.category === "model"),
105 ).toMatchObject({ currentValue: "gpt-5.4" });
106 }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
107 );
108
109 it.effect("publishes model changes returned by a config request and live notifications", () =>
110 Effect.gen(function* () {
111 const runtime = yield* AcpSessionRuntime.make(mockRuntimeOptions);
112 yield* runtime.start();
113 const updates = yield* Stream.toPull(
114 runtime.getEvents().pipe(Stream.filter((event) => event._tag === "ConfigOptionsUpdated")),
115 );
116 const selected = yield* runtime.setConfigOption("model", "composer-2");
117 expect((yield* updates)[0]?.configOptions).toEqual(selected.configOptions);
118 yield* runtime.request("_test/startup-metadata", {});
119 expect((yield* updates)[0]?.configOptions).toEqual(yield* runtime.getConfigOptions);
120 }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
121 );
122
123 it.effect("waits for native cancellation and drains final updates before another prompt", () =>
124 Effect.gen(function* () {
125 const toolStarted = yield* Deferred.make<void>();
126 const cancelReceived = yield* Deferred.make<void>();
127 const events: Array<AcpSessionRuntime.AcpSessionRuntimeEvent> = [];
128 let promptRequests = 0;
129 const runtime = yield* AcpSessionRuntime.make({
130 ...mockRuntimeOptions,
131 spawn: {
132 ...mockRuntimeOptions.spawn,
133 env: { T3_ACP_COMPLETE_FIRST_PROMPT_ON_CANCEL: "1" },
134 },
135 cancelBehavior: "wait-for-prompt",
136 requestLogger: (event) =>
137 Effect.sync(() => {
138 if (event.method === "session/prompt" && event.status === "started")
139 promptRequests += 1;
140 }),
141 });
142 yield* runtime.getEvents().pipe(
143 Stream.runForEach((event) => {
144 if (event._tag === "EventStreamBarrier") {
145 return Deferred.succeed(event.acknowledge, undefined);
146 }
147 events.push(event);
148 if (event._tag === "ToolCallUpdated" && event.toolCall.status === "inProgress") {
149 return Deferred.succeed(toolStarted, undefined);
150 }
151 if (event._tag === "ThoughtDelta" && event.text === "native-cancel-received") {
152 return Deferred.succeed(cancelReceived, undefined);
153 }
154 return Effect.void;
155 }),
156 Effect.forkChild,
157 );
158 yield* runtime.start();
159 const prompt = yield* runtime
160 .prompt({
161 prompt: [{ type: "text", text: "first" }],
162 })
163 .pipe(Effect.forkChild);
164 yield* Deferred.await(toolStarted);
165 const cancellation = yield* runtime.cancel.pipe(Effect.forkChild);
166 yield* Deferred.await(cancelReceived);
167 const replacement = yield* runtime
168 .prompt({
169 prompt: [{ type: "text", text: "second" }],
170 })
171 .pipe(Effect.forkChild({ startImmediately: true }));
172
173 expect(prompt.pollUnsafe()).toBeUndefined();
174 expect(cancellation.pollUnsafe()).toBeUndefined();
175 expect(promptRequests).toBe(1);
176 yield* runtime.request("_test/finish-cancel", {});
177 yield* Fiber.join(cancellation);
178
179 expect(yield* Fiber.join(prompt)).toEqual({
180 stopReason: "cancelled",
181 _meta: { nativeCancel: true },
182 });
183 expect(
184 events.some(
185 (event) =>
186 event._tag === "ToolCallUpdated" &&
187 event.toolCall.status === "failed" &&
188 event.toolCall.detail === "Cancelled.",
189 ),
190 ).toBe(true);
191 const cancelledDelta = events.find(
192 (event) => event._tag === "ContentDelta" && event.text === "Request cancelled.",
193 );
194 expect(cancelledDelta?._tag).toBe("ContentDelta");
195 if (cancelledDelta?._tag === "ContentDelta") {
196 expect(
197 events.filter(
198 (event) =>
199 event._tag === "AssistantItemCompleted" && event.itemId === cancelledDelta.itemId,
200 ),
201 ).toHaveLength(1);
202 }
203 expect(yield* Fiber.join(replacement)).toMatchObject({ stopReason: "end_turn" });
204 expect(promptRequests).toBe(2);
205 }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
206 );
207
208 it.effect("reports an idle child exit and rejects later prompts", () =>
209 Effect.gen(function* () {
210 const stopped = yield* Deferred.make<string>();
211 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
212 const runtime = yield* AcpSessionRuntime.make(mockRuntimeOptions).pipe(
213 Effect.provideService(
214 ChildProcessSpawner.ChildProcessSpawner,
215 ChildProcessSpawner.make((command) =>
216 spawner.spawn(command).pipe(
217 Effect.map((child) =>
218 ChildProcessSpawner.makeHandle({
219 ...child,
220 // Model an inherited pipe held open until detached work is stopped.
221 stdout: Stream.concat(
222 child.stdout,
223 Stream.fromEffect(Deferred.await(stopped)).pipe(Stream.drain),
224 ),
225 }),
226 ),
227 ),
228 ),
229 ),
230 Effect.provideService(AgentScope, {
231 ...AgentScope.defaultValue(),
232 wrap: ({ command, args }) =>
233 Effect.succeed({ command, args, unit: "t3code-acp-test.scope" }),
234 stop: (unit) => Deferred.succeed(stopped, unit).pipe(Effect.asVoid),
235 }),
236 );
237 yield* runtime.start();
238 expect(yield* Deferred.isDone(stopped)).toBe(false);
239 yield* runtime.notify("_test/exit", {});
240 const events = yield* runtime.getEvents().pipe(Stream.take(1), Stream.runCollect);
241 expect(yield* Deferred.await(stopped)).toBe("t3code-acp-test.scope");
242 const event = events[0];
243 expect(event).toMatchObject({ _tag: "ConnectionTerminated", error: { code: 19 } });
244 if (event?._tag !== "ConnectionTerminated") return;
245 expect(
246 yield* runtime
247 .prompt({
248 prompt: [{ type: "text", text: "must not run" }],
249 })
250 .pipe(Effect.flip),
251 ).toBe(event.error);
252 expect(yield* runtime.start().pipe(Effect.flip)).toBe(event.error);
253 expect(yield* runtime.initialize().pipe(Effect.flip)).toBe(event.error);
254 expect(
255 yield* runtime.request("_test/environment", {}).pipe(
256 Effect.match({
257 onFailure: (error) => error,
258 onSuccess: () => undefined,
259 }),
260 ),
261 ).toBe(event.error);
262 expect(yield* runtime.notify("_test/exit", {}).pipe(Effect.flip)).toBe(event.error);
263 }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
264 );
265
266 it.effect("retires a native runtime when its prompt caller is interrupted", () =>
267 Effect.gen(function* () {
268 const dispatched = yield* Deferred.make<void>();
269 const runtime = yield* AcpSessionRuntime.make({
270 ...mockRuntimeOptions,
271 spawn: {
272 ...mockRuntimeOptions.spawn,
273 env: { T3_ACP_COMPLETE_FIRST_PROMPT_ON_CANCEL: "1" },
274 },
275 cancelBehavior: "wait-for-prompt",
276 });
277 yield* runtime.start();
278 const prompt = yield* runtime
279 .prompt(
280 {
281 prompt: [{ type: "text", text: "first" }],
282 },
283 { dispatched },
284 )
285 .pipe(Effect.forkChild);
286 yield* Deferred.await(dispatched);
287 yield* Fiber.interrupt(prompt);
288 const events = yield* runtime.getEvents().pipe(
289 Stream.filter((event) => event._tag === "ConnectionTerminated"),
290 Stream.take(1),
291 Stream.runCollect,
292 );
293 expect(events[0]).toMatchObject({
294 error: { _tag: "AcpTransportError", method: "session/prompt" },
295 });
296 expect(
297 yield* runtime
298 .prompt({
299 prompt: [{ type: "text", text: "must not run" }],
300 })
301 .pipe(Effect.flip),
302 ).toBe(events[0]?.error);
303 }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
304 );
305
306 it.effect("fails a pending request when the stderr handler rejects the runtime", () =>
307 Effect.gen(function* () {
308 const failure = new EffectAcpErrors.AcpTransportError({
309 detail: "Sign in before continuing.",
310 cause: undefined,
311 });
312 const runtime = yield* AcpSessionRuntime.make({
313 ...mockRuntimeOptions,
314 spawn: { ...mockRuntimeOptions.spawn, env: { T3_ACP_FLOOD_STDERR: "1" } },
315 onStderr: () => Effect.fail(failure),
316 });
317 expect(yield* runtime.start().pipe(Effect.flip)).toBe(failure);
318 const events = yield* runtime.getEvents().pipe(
319 Stream.filter((event) => event._tag === "ConnectionTerminated"),
320 Stream.take(1),
321 Stream.runCollect,
322 );
323 expect(events[0]?.error).toBe(failure);
324 expect(yield* runtime.initialize().pipe(Effect.flip)).toBe(failure);
325 }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
326 );
327
328 it.effect.each([
329 { name: "exit code", terminate: "process.exit(1)", code: 1, signal: undefined },
330 ...(HostProcess.Platform.defaultValue() === "win32"
331 ? []
332 : [
333 {
334 name: "signal",
335 terminate: "process.kill(process.pid, 'SIGTERM')",
336 code: undefined,
337 signal: "SIGTERM",
338 },
339 ]),
340 ])("retains the $name and sanitized stderr when ACP exits before initialize", (testCase) =>
341 Effect.gen(function* () {
342 const runtime = yield* AcpSessionRuntime.make({
343 ...mockRuntimeOptions,
344 spawn: {
345 command: process.execPath,
346 args: [
347 "-e",
348 `process.stderr.write("Invalid project config: Unrecognized key in cli.json\\nAuthorization: Bearer secret-token-value\\n", () => { ${testCase.terminate}; });`,
349 ],
350 },
351 });
352 const error = yield* runtime.start().pipe(Effect.flip);
353 assert.instanceOf(error, EffectAcpErrors.AcpProcessExitedError);
354 expect(error.code).toBe(testCase.code);
355 expect(error.signal).toBe(testCase.signal);
356 expect(error).toMatchObject({
357 pid: expect.any(Number),
358 stderr:
359 "Invalid project config: Unrecognized key in cli.json\nAuthorization: Bearer [redacted]",
360 });
361 expect(error.message).toBe(
362 `${testCase.signal ? "ACP process exited after signal SIGTERM" : "ACP process exited with code 1"}\nInvalid project config: Unrecognized key in cli.json\nAuthorization: Bearer [redacted]`,
363 );
364 if (testCase.signal) {
365 expect(error.cause).toMatchObject({
366 reason: {
367 module: "ChildProcess",
368 method: "exitCode",
369 cause: new Error("Process interrupted due to receipt of signal: 'SIGTERM'"),
370 },
371 });
372 }
373 }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
374 );
375
376 it.effect("drains large stderr output and keeps auth-sized logging chunks", () =>
377 Effect.gen(function* () {
378 const lengths: Array<number> = [];
379 for (const logStderr of [false, true]) {
380 yield* Effect.gen(function* () {
381 const runtime = yield* AcpSessionRuntime.make({
382 ...mockRuntimeOptions,
383 spawn: { ...mockRuntimeOptions.spawn, env: { T3_ACP_FLOOD_STDERR: "1" } },
384 ...(logStderr
385 ? {
386 onStderr: (text: string) =>
387 Effect.sync(() => {
388 lengths.push(text.length);
389 }),
390 }
391 : {}),
392 });
393 expect(yield* runtime.initialize()).toMatchObject({ protocolVersion: 2 });
394 }).pipe(Effect.scoped);
395 }
396 expect(lengths.length).toBeGreaterThan(0);
397 expect(Math.max(...lengths)).toBeGreaterThanOrEqual(16_384);
398 expect(Math.max(...lengths)).toBeLessThanOrEqual(32_768);
399 }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
400 );
401
402 it.effect("releases a queued event drain when its runtime scope closes", () =>
403 Effect.gen(function* () {
404 const scope = yield* Effect.acquireRelease(Scope.make(), (scope) =>
405 Scope.close(scope, Exit.void),
406 );
407 const barrierReceived = yield* Deferred.make<void>();
408 const runtime = yield* AcpSessionRuntime.make(mockRuntimeOptions).pipe(
409 Effect.provideService(Scope.Scope, scope),
410 );
411 yield* runtime.start();
412 yield* runtime.getEvents().pipe(
413 Stream.runForEach((event) =>
414 event._tag === "EventStreamBarrier"
415 ? Deferred.succeed(barrierReceived, undefined).pipe(Effect.andThen(Effect.never))
416 : Effect.void,
417 ),
418 Effect.forkIn(scope),
419 );
420 const drain = yield* runtime.drainEvents.pipe(Effect.forkChild);
421 yield* Deferred.await(barrierReceived);
422 yield* Scope.close(scope, Exit.void);
423 yield* Fiber.join(drain);
424 yield* runtime.drainEvents;
425 expect(yield* runtime.initialize().pipe(Effect.flip)).toMatchObject({
426 _tag: "AcpTransportError",
427 detail: "The ACP session runtime is closed.",
428 });
429 }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
430 );
431
432 it.effect("does not restore ambient variables to a sanitized child environment", () =>
433 Effect.gen(function* () {
434 yield* Effect.acquireRelease(
435 Effect.sync(() => setProcessEnv("T3_ACP_RUNTIME_AMBIENT", "sentinel")),
436 (restore) => Effect.sync(restore),
437 );
438 const runtime = yield* AcpSessionRuntime.make({
439 ...mockRuntimeOptions,
440 spawn: {
441 command: process.execPath,
442 args: mockAgentArgs,
443 extendEnv: false,
444 env: { T3_ACP_RUNTIME_EXPLICIT: "kept" },
445 },
446 });
447 yield* runtime.initialize();
448 expect(yield* runtime.request("_test/environment", {})).toEqual({
449 inherited: false,
450 explicit: true,
451 });
452 }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
453 );
454
455 it("selects explicit or agent-managed authentication without choosing terminal auth", () => {
456 const methods = [
457 { id: "browser", name: "Browser", type: "terminal" as const },
458 { id: "api-key", name: "API key", type: "agent" as const },
459 ];
460
461 expect(AcpSessionRuntime.selectAcpAgentAuthMethod(methods)?.id).toBe("api-key");
462 expect(AcpSessionRuntime.selectAcpAgentAuthMethod(methods, "browser")?.id).toBe("browser");
463 expect(AcpSessionRuntime.selectAcpAgentAuthMethod(methods, "missing")).toBeUndefined();
464 });
465
466 it.effect("prefers MCP-over-ACP only when the agent advertises that transport", () => {
467 const requestEvents: Array<AcpSessionRuntime.AcpSessionRequestLogEvent> = [];
468 return Effect.gen(function* () {
469 const runtime = yield* AcpSessionRuntime.AcpSessionRuntime;
470 yield* runtime.start();
471
472 const created = requestEvents.find(
473 (event) => event.method === "session/new" && event.status === "started",
474 );
475 expect(created?.payload).toMatchObject({
476 mcpServers: [{ type: "acp", name: "t3-code", serverId: "t3-code" }],
477 });
478 }).pipe(
479 Effect.provide(
480 AcpSessionRuntime.layer({
481 spawn: {
482 command: mockAgentCommand,
483 args: mockAgentArgs,
484 env: { T3_ACP_MCP_ACP: "1" },
485 },
486 cwd: process.cwd(),
487 clientInfo: { name: "t3-test", version: "0.0.0" },
488 authMethodId: "test",
489 mcpServers: [{ type: "stdio", name: "t3-code", command: "/usr/bin/node", args: [] }],
490 acpMcpServers: [{ type: "acp", name: "t3-code", serverId: "t3-code" }],
491 requestLogger: (event) =>
492 Effect.sync(() => {
493 requestEvents.push(event);
494 }),
495 }),
496 ),
497 Effect.scoped,
498 Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)),
499 );
500 });
501
502 it.effect("supports v2 session deletion and secret-safe provider management", () => {
503 const requestEvents: Array<AcpSessionRuntime.AcpSessionRequestLogEvent> = [];
504 return Effect.gen(function* () {
505 const runtime = yield* AcpSessionRuntime.AcpSessionRuntime;
506 yield* runtime.start();
507
508 yield* runtime.deleteSession("native-session-1");
509 expect((yield* runtime.listProviders).providers[0]).toMatchObject({
510 providerId: "mock-provider",
511 current: null,
512 });
513 yield* runtime.setProvider({
514 providerId: "mock-provider",
515 apiType: "openai",
516 baseUrl: "https://api.example.test/v1",
517 headers: { Authorization: "Bearer top-secret" },
518 });
519 expect((yield* runtime.listProviders).providers[0]?.current).toEqual({
520 apiType: "openai",
521 baseUrl: "https://api.example.test/v1",
522 });
523 yield* runtime.disableProvider("mock-provider");
524 expect((yield* runtime.listProviders).providers[0]?.current).toBeNull();
525
526 const setRequest = requestEvents.find(
527 (event) => event.method === "providers/set" && event.status === "started",
528 );
529 expect(setRequest?.payload).toMatchObject({
530 headers: { Authorization: "[redacted]" },
531 });
532 }).pipe(
533 Effect.provide(
534 AcpSessionRuntime.layer({
535 spawn: {
536 command: mockAgentCommand,
537 args: mockAgentArgs,
538 env: { T3_ACP_V2_MANAGEMENT: "1" },
539 },
540 cwd: process.cwd(),
541 clientInfo: { name: "t3-test", version: "0.0.0" },
542 authMethodId: "test",
543 requestLogger: (event) =>
544 Effect.sync(() => {
545 requestEvents.push(event);
546 }),
547 }),
548 ),
549 Effect.scoped,
550 Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)),
551 );
552 });
553
554 it.effect("authenticates explicitly typed v2 agent methods and retries session creation", () => {
555 const requestEvents: Array<AcpSessionRuntime.AcpSessionRequestLogEvent> = [];
556 return Effect.gen(function* () {
557 const runtime = yield* AcpSessionRuntime.AcpSessionRuntime;
558 const started = yield* runtime.start();
559 expect(started.sessionId).toBe("mock-session-1");
560 expect(
561 requestEvents.filter((event) => event.status === "started").map((event) => event.method),
562 ).toEqual(["initialize", "session/new", "authenticate", "session/new"]);
563 expect(
564 requestEvents.filter(
565 (event) => event.method === "session/new" && event.status === "failed",
566 ),
567 ).toHaveLength(1);
568 expect(
569 requestEvents.filter(
570 (event) => event.method === "session/new" && event.status === "succeeded",
571 ),
572 ).toHaveLength(1);
573 }).pipe(
574 Effect.provide(
575 AcpSessionRuntime.layer({
576 authMethodId: "test",
577 spawn: {
578 command: mockAgentCommand,
579 args: mockAgentArgs,
580 env: {
581 T3_ACP_AUTH_METHOD_ID: "test",
582 T3_ACP_REQUIRE_AUTH: "1",
583 },
584 },
585 cwd: process.cwd(),
586 clientInfo: { name: "t3-test", version: "0.0.0" },
587 requestLogger: (event) =>
588 Effect.sync(() => {
589 requestEvents.push(event);
590 }),
591 }),
592 ),
593 Effect.scoped,
594 Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)),
595 );
596 });
597
598 it.effect("can surface auth-required without authenticating during discovery", () => {
599 const requestEvents: Array<AcpSessionRuntime.AcpSessionRequestLogEvent> = [];
600 let advertisedAuthMethodName: string | undefined;
601 return Effect.gen(function* () {
602 const runtime = yield* AcpSessionRuntime.AcpSessionRuntime;
603 const result = yield* Effect.result(runtime.start());
604
605 expect(Result.isFailure(result)).toBe(true);
606 expect(advertisedAuthMethodName).toBe("Mock agent authentication");
607 expect(
608 requestEvents.filter((event) => event.status === "started").map((event) => event.method),
609 ).toEqual(["initialize", "session/new"]);
610 }).pipe(
611 Effect.provide(
612 AcpSessionRuntime.layer({
613 authenticateOnAuthRequired: false,
614 authMethodId: "test",
615 spawn: {
616 command: mockAgentCommand,
617 args: mockAgentArgs,
618 env: {
619 T3_ACP_AUTH_METHOD_ID: "test",
620 T3_ACP_REQUIRE_AUTH: "1",
621 },
622 },
623 cwd: process.cwd(),
624 clientInfo: { name: "t3-test", version: "0.0.0" },
625 onInitialized: (initializeResult) =>
626 Effect.sync(() => {
627 advertisedAuthMethodName = initializeResult.authMethods?.[0]?.name;
628 }),
629 requestLogger: (event) =>
630 Effect.sync(() => {
631 requestEvents.push(event);
632 }),
633 }),
634 ),
635 Effect.scoped,
636 Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)),
637 );
638 });
639
640 it.effect("starts a session, prompts, and emits normalized events against the mock agent", () =>
641 Effect.gen(function* () {
642 const runtime = yield* AcpSessionRuntime.AcpSessionRuntime;
643 const started = yield* runtime.start();
644
645 expect(started.initializeResult).toMatchObject({ protocolVersion: 2 });
646 expect(started.sessionId).toBe("mock-session-1");
647
648 const promptResult = yield* runtime.prompt({
649 prompt: [{ type: "text", text: "hi" }],
650 });
651 expect(promptResult).toMatchObject({ stopReason: "end_turn" });
652
653 const notes = Array.from(yield* Stream.runCollect(Stream.take(runtime.getEvents(), 4)));
654 expect(notes).toHaveLength(4);
655 expect(notes.map((note) => note._tag)).toEqual([
656 "PlanUpdated",
657 "AssistantItemStarted",
658 "ContentDelta",
659 "AssistantItemCompleted",
660 ]);
661 const planUpdate = notes.find((note) => note._tag === "PlanUpdated");
662 expect(planUpdate?._tag).toBe("PlanUpdated");
663 if (planUpdate?._tag === "PlanUpdated") {
664 expect(planUpdate.payload).toMatchObject({
665 nativePlanId: "mock-plan",
666 kind: "items",
667 });
668 if (planUpdate.payload.kind === "items") {
669 expect(planUpdate.payload.plan).toHaveLength(2);
670 }
671 }
672 const assistantStart = notes[1];
673 const assistantDelta = notes[2];
674 if (
675 assistantStart?._tag === "AssistantItemStarted" &&
676 assistantDelta?._tag === "ContentDelta"
677 ) {
678 expect(assistantDelta.itemId).toBe(assistantStart.itemId);
679 }
680 }).pipe(
681 Effect.provide(
682 AcpSessionRuntime.layer({
683 spawn: {
684 command: mockAgentCommand,
685 args: mockAgentArgs,
686 },
687 cwd: process.cwd(),
688 clientInfo: { name: "t3-test", version: "0.0.0" },
689 authMethodId: "test",
690 }),
691 ),
692 Effect.scoped,
693 Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)),
694 ),
695 );
696
697 it.effect("keeps assistant item IDs unique when a provider session restarts", () => {
698 const collectFirstAssistantItemId = Effect.gen(function* () {
699 const runtime = yield* AcpSessionRuntime.AcpSessionRuntime;
700 const started = yield* runtime.start();
701 expect(started.sessionId).toBe("mock-session-1");
702
703 yield* runtime.prompt({
704 prompt: [{ type: "text", text: "hi" }],
705 });
706
707 const events = Array.from(yield* Stream.runCollect(Stream.take(runtime.getEvents(), 4)));
708 const assistantStart = events.find((event) => event._tag === "AssistantItemStarted");
709 expect(assistantStart?._tag).toBe("AssistantItemStarted");
710 return assistantStart?._tag === "AssistantItemStarted" ? assistantStart.itemId : "";
711 }).pipe(
712 Effect.provide(
713 AcpSessionRuntime.layer({
714 spawn: {
715 command: mockAgentCommand,
716 args: mockAgentArgs,
717 },
718 cwd: process.cwd(),
719 clientInfo: { name: "t3-test", version: "0.0.0" },
720 authMethodId: "test",
721 }),
722 ),
723 Effect.scoped,
724 );
725
726 return Effect.gen(function* () {
727 const beforeRestart = yield* collectFirstAssistantItemId;
728 const afterRestart = yield* collectFirstAssistantItemId;
729
730 expect(afterRestart).not.toBe(beforeRestart);
731 }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)));
732 });
733
734 it.effect("drops session updates emitted for a child ACP session", () =>
735 Effect.gen(function* () {
736 const runtime = yield* AcpSessionRuntime.AcpSessionRuntime;
737 yield* runtime.start();
738
739 const promptResult = yield* runtime.prompt({
740 prompt: [{ type: "text", text: "hi" }],
741 });
742 expect(promptResult).toMatchObject({ stopReason: "end_turn" });
743
744 const notes = Array.from(yield* Stream.runCollect(Stream.take(runtime.getEvents(), 4)));
745 expect(notes.map((note) => note._tag)).toEqual([
746 "AssistantItemStarted",
747 "ContentDelta",
748 "ContentDelta",
749 "AssistantItemCompleted",
750 ]);
751 expect(
752 notes
753 .filter((note) => note._tag === "ContentDelta")
754 .map((note) => note.text)
755 .join(""),
756 ).toBe("root before child root after child");
757 expect(notes.some((note) => note._tag === "ToolCallUpdated")).toBe(false);
758 }).pipe(
759 Effect.provide(
760 AcpSessionRuntime.layer({
761 spawn: {
762 command: mockAgentCommand,
763 args: mockAgentArgs,
764 env: {
765 T3_ACP_EMIT_FOREIGN_SESSION_UPDATES: "1",
766 },
767 },
768 cwd: process.cwd(),
769 clientInfo: { name: "t3-test", version: "0.0.0" },
770 authMethodId: "test",
771 }),
772 ),
773 Effect.scoped,
774 Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)),
775 ),
776 );
777
778 it.effect("segments assistant text around ACP tool calls", () =>
779 Effect.gen(function* () {
780 const runtime = yield* AcpSessionRuntime.AcpSessionRuntime;
781 yield* runtime.start();
782
783 const promptResult = yield* runtime.prompt({
784 prompt: [{ type: "text", text: "hi" }],
785 });
786 expect(promptResult).toMatchObject({ stopReason: "end_turn" });
787
788 const notes = Array.from(yield* Stream.runCollect(Stream.take(runtime.getEvents(), 7)));
789 expect(notes.map((note) => note._tag)).toEqual([
790 "AssistantItemStarted",
791 "ContentDelta",
792 "AssistantItemCompleted",
793 "ToolCallUpdated",
794 "ToolCallUpdated",
795 "AssistantItemStarted",
796 "ContentDelta",
797 ]);
798
799 const firstStarted = notes[0];
800 const firstDelta = notes[1];
801 const firstCompleted = notes[2];
802 const secondStarted = notes[5];
803 const secondDelta = notes[6];
804 expect(firstStarted?._tag).toBe("AssistantItemStarted");
805 expect(firstCompleted?._tag).toBe("AssistantItemCompleted");
806 expect(secondStarted?._tag).toBe("AssistantItemStarted");
807 if (
808 firstStarted?._tag === "AssistantItemStarted" &&
809 firstDelta?._tag === "ContentDelta" &&
810 firstCompleted?._tag === "AssistantItemCompleted" &&
811 secondStarted?._tag === "AssistantItemStarted" &&
812 secondDelta?._tag === "ContentDelta"
813 ) {
814 expect(firstDelta.itemId).toBe(firstStarted.itemId);
815 expect(firstCompleted.itemId).toBe(firstStarted.itemId);
816 expect(secondStarted.itemId).not.toBe(firstStarted.itemId);
817 expect(secondDelta.itemId).toBe(secondStarted.itemId);
818 }
819 }).pipe(
820 Effect.provide(
821 AcpSessionRuntime.layer({
822 spawn: {
823 command: mockAgentCommand,
824 args: mockAgentArgs,
825 env: {
826 T3_ACP_EMIT_INTERLEAVED_ASSISTANT_TOOL_CALLS: "1",
827 },
828 },
829 cwd: process.cwd(),
830 clientInfo: { name: "t3-test", version: "0.0.0" },
831 authMethodId: "test",
832 }),
833 ),
834 Effect.scoped,
835 Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)),
836 ),
837 );
838
839 it.effect("keeps one answer when an earlier tool reports progress mid-stream", () =>
840 Effect.gen(function* () {
841 const runtime = yield* AcpSessionRuntime.AcpSessionRuntime;
842 yield* runtime.start();
843 yield* runtime.prompt({ prompt: [{ type: "text", text: "hi" }] });
844
845 const notes = Array.from(yield* Stream.runCollect(Stream.take(runtime.getEvents(), 9)));
846 expect(notes.map((note) => note._tag)).toEqual([
847 "ToolCallUpdated",
848 "AssistantItemStarted",
849 "ContentDelta",
850 "ContentDelta",
851 "ToolCallUpdated",
852 "ContentDelta",
853 "ToolCallUpdated",
854 "ContentDelta",
855 "AssistantItemCompleted",
856 ]);
857 const itemIds = new Set(
858 notes.flatMap((note) =>
859 note._tag === "ContentDelta" ||
860 note._tag === "AssistantItemStarted" ||
861 note._tag === "AssistantItemCompleted"
862 ? [note.itemId]
863 : [],
864 ),
865 );
866 expect(itemIds.size).toBe(1);
867 }).pipe(
868 Effect.provide(
869 AcpSessionRuntime.layer({
870 spawn: {
871 command: mockAgentCommand,
872 args: mockAgentArgs,
873 env: { T3_ACP_EMIT_BACKGROUND_TOOL_DURING_ANSWER: "1" },
874 },
875 cwd: process.cwd(),
876 clientInfo: { name: "t3-test", version: "0.0.0" },
877 authMethodId: "test",
878 }),
879 ),
880 Effect.scoped,
881 Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)),
882 ),
883 );
884
885 it.effect("emits status-only tool updates through completion", () =>
886 Effect.gen(function* () {
887 const runtime = yield* AcpSessionRuntime.AcpSessionRuntime;
888 yield* runtime.start();
889
890 const promptResult = yield* runtime.prompt({
891 prompt: [{ type: "text", text: "hi" }],
892 });
893 expect(promptResult).toMatchObject({ stopReason: "end_turn" });
894
895 const notes = Array.from(yield* Stream.runCollect(Stream.take(runtime.getEvents(), 3)));
896 expect(notes.map((note) => note._tag)).toEqual([
897 "ToolCallUpdated",
898 "ToolCallUpdated",
899 "ToolCallUpdated",
900 ]);
901 const toolCalls = notes.flatMap((note) =>
902 note._tag === "ToolCallUpdated" ? [note.toolCall] : [],
903 );
904 expect(toolCalls.map((toolCall) => toolCall.status)).toEqual([
905 "pending",
906 "inProgress",
907 "completed",
908 ]);
909 for (const toolCall of toolCalls) {
910 expect(toolCall.title).toBe("Read file");
911 }
912 }).pipe(
913 Effect.provide(
914 AcpSessionRuntime.layer({
915 spawn: {
916 command: mockAgentCommand,
917 args: mockAgentArgs,
918 env: {
919 T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1",
920 },
921 },
922 cwd: process.cwd(),
923 clientInfo: { name: "t3-test", version: "0.0.0" },
924 authMethodId: "test",
925 }),
926 ),
927 Effect.scoped,
928 Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)),
929 ),
930 );
931
932 it.effect("supports negotiated ACP session list, fork, resume, and close methods", () => {
933 const requestEvents: Array<AcpSessionRuntime.AcpSessionRequestLogEvent> = [];
934 return Effect.gen(function* () {
935 const runtime = yield* AcpSessionRuntime.AcpSessionRuntime;
936 const started = yield* runtime.start();
937 expect(started.initializeResult.agentCapabilities?.sessionCapabilities).toMatchObject({
938 list: {},
939 fork: {},
940 resume: {},
941 close: {},
942 });
943 expect(started.initializeResult.agentCapabilities?.auth).toMatchObject({ logout: {} });
944
945 const listed = yield* runtime.listSessions();
946 expect(listed.sessions.map((session) => session.sessionId)).toEqual(["mock-session-1"]);
947
948 const forked = yield* runtime.forkSession(started.sessionId);
949 expect(forked.sessionId).toBe("mock-session-1-fork");
950 yield* runtime.prompt({ prompt: [{ type: "text", text: "on fork" }] });
951
952 const resumed = yield* runtime.resumeSession(started.sessionId);
953 expect(resumed.sessionId).toBe("mock-session-1");
954 yield* runtime.closeSession();
955 yield* runtime.logout;
956
957 expect(
958 requestEvents.find(
959 (event) => event.method === "session/prompt" && event.status === "started",
960 )?.payload,
961 ).toMatchObject({ sessionId: "mock-session-1-fork" });
962 expect(
963 requestEvents.filter((event) => event.status === "started").map((event) => event.method),
964 ).toEqual(
965 expect.arrayContaining([
966 "session/list",
967 "session/fork",
968 "session/resume",
969 "session/close",
970 "logout",
971 ]),
972 );
973 // Omitted roots clear an agent's stored ones, so every activation resends them.
974 for (const method of ["session/new", "session/fork", "session/resume"]) {
975 expect(
976 requestEvents.find((event) => event.method === method && event.status === "started")
977 ?.payload,
978 ).toMatchObject({
979 additionalDirectories: ["/tmp/t3-attachments"],
980 _meta: { negotiated: "0.0.0" },
981 });
982 }
983 }).pipe(
984 Effect.provide(
985 AcpSessionRuntime.layer({
986 authMethodId: "test",
987 spawn: {
988 command: mockAgentCommand,
989 args: mockAgentArgs,
990 env: {
991 T3_ACP_SESSION_LIFECYCLE: "1",
992 },
993 },
994 additionalDirectories: ["/tmp/t3-attachments"],
995 sessionMeta: (initializeResult) => ({
996 negotiated: initializeResult.agentInfo?.version ?? "none",
997 }),
998 cwd: process.cwd(),
999 clientInfo: { name: "t3-test", version: "0.0.0" },
1000 requestLogger: (event) =>
1001 Effect.sync(() => {
1002 requestEvents.push(event);
1003 }),
1004 }),
1005 ),
1006 Effect.scoped,
1007 Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)),
1008 );
1009 });
1010
1011 it.effect("rejects unauthenticated ACP session lifecycle requests", () =>
1012 Effect.gen(function* () {
1013 const runtime = yield* AcpSessionRuntime.AcpSessionRuntime;
1014 yield* runtime.start();
1015
1016 const listError = yield* runtime.listSessions().pipe(Effect.flip);
1017 expect(listError._tag).toBe("AcpRequestError");
1018 if (listError._tag === "AcpRequestError") {
1019 expect(listError.code).toBe(-32000);
1020 }
1021
1022 const forkError = yield* runtime.forkSession("mock-session-1").pipe(Effect.flip);
1023 expect(forkError._tag).toBe("AcpRequestError");
1024 if (forkError._tag === "AcpRequestError") {
1025 expect(forkError.code).toBe(-32000);
1026 }
1027
1028 const resumeError = yield* runtime.resumeSession("mock-session-1").pipe(Effect.flip);
1029 expect(resumeError._tag).toBe("AcpRequestError");
1030 if (resumeError._tag === "AcpRequestError") {
1031 expect(resumeError.code).toBe(-32000);
1032 }
1033
1034 const closeError = yield* runtime.closeSession().pipe(Effect.flip);
1035 expect(closeError._tag).toBe("AcpRequestError");
1036 if (closeError._tag === "AcpRequestError") {
1037 expect(closeError.code).toBe(-32000);
1038 }
1039 }).pipe(
1040 Effect.provide(
1041 AcpSessionRuntime.layer({
1042 spawn: {
1043 command: mockAgentCommand,
1044 args: mockAgentArgs,
1045 env: {
1046 T3_ACP_REQUIRE_AUTH: "1",
1047 T3_ACP_SESSION_LIFECYCLE: "1",
1048 },
1049 },
1050 cwd: process.cwd(),
1051 resumeSessionId: "mock-session-1",
1052 clientInfo: { name: "t3-test", version: "0.0.0" },
1053 }),
1054 ),
1055 Effect.scoped,
1056 Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)),
1057 ),
1058 );
1059
1060 // regression: #3156 harden ACP resume with replay-idle load readiness
1061 it.effect("fails session startup when session/load returns an error", () =>
1062 Effect.gen(function* () {
1063 const runtime = yield* AcpSessionRuntime.AcpSessionRuntime;
1064 const error = yield* runtime.start().pipe(Effect.flip);
1065
1066 expect(error._tag).toBe("AcpRequestError");
1067 }).pipe(
1068 Effect.provide(
1069 AcpSessionRuntime.layer({
1070 authMethodId: "test",
1071 spawn: {
1072 command: mockAgentCommand,
1073 args: mockAgentArgs,
1074 env: {
1075 T3_ACP_FAIL_LOAD_SESSION: "1",
1076 },
1077 },
1078 cwd: process.cwd(),
1079 resumeSessionId: "stale-session-id",
1080 clientInfo: { name: "t3-test", version: "0.0.0" },
1081 }),
1082 ),
1083 Effect.scoped,
1084 Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)),
1085 ),
1086 );
1087
1088 it.effect("keeps active configuration after a candidate session load fails", () =>
1089 Effect.gen(function* () {
1090 const runtime = yield* AcpSessionRuntime.AcpSessionRuntime;
1091 yield* runtime.start();
1092 const before = yield* runtime.getConfigOptions;
1093
1094 const error = yield* runtime.loadSession("candidate-session").pipe(Effect.flip);
1095
1096 expect(error._tag).toBe("AcpRequestError");
1097 expect(yield* runtime.getConfigOptions).toEqual(before);
1098 expect(before.some((option) => option.id === "candidate-only")).toBe(false);
1099 }).pipe(
1100 Effect.provide(
1101 AcpSessionRuntime.layer({
1102 authMethodId: "test",
1103 spawn: {
1104 command: mockAgentCommand,
1105 args: mockAgentArgs,
1106 env: {
1107 T3_ACP_FAIL_LOAD_SESSION_AFTER_CONFIG_REPLAY: "1",
1108 },
1109 },
1110 cwd: process.cwd(),
1111 clientInfo: { name: "t3-test", version: "0.0.0" },
1112 }),
1113 ),
1114 Effect.scoped,
1115 Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)),
1116 ),
1117 );
1118
1119 it.effect("ignores session/update replay notifications during session/load", () =>
1120 Effect.gen(function* () {
1121 const runtime = yield* AcpSessionRuntime.AcpSessionRuntime;
1122 yield* runtime.start();
1123
1124 yield* runtime.prompt({
1125 prompt: [{ type: "text", text: "hi" }],
1126 });
1127 const notes = Array.from(yield* Stream.runCollect(Stream.take(runtime.getEvents(), 4)));
1128 expect(notes.map((note) => note._tag)).toEqual([
1129 "PlanUpdated",
1130 "AssistantItemStarted",
1131 "ContentDelta",
1132 "AssistantItemCompleted",
1133 ]);
1134 expect(notes.some((note) => note._tag === "ToolCallUpdated")).toBe(false);
1135 }).pipe(
1136 Effect.provide(
1137 AcpSessionRuntime.layer({
1138 authMethodId: "test",
1139 spawn: {
1140 command: mockAgentCommand,
1141 args: mockAgentArgs,
1142 env: {
1143 T3_ACP_EMIT_LOAD_REPLAY: "1",
1144 },
1145 },
1146 cwd: process.cwd(),
1147 resumeSessionId: "mock-session-1",
1148 clientInfo: { name: "t3-test", version: "0.0.0" },
1149 }),
1150 ),
1151 Effect.scoped,
1152 Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)),
1153 ),
1154 );
1155
1156 it.effect("rejects invalid config option values before sending session/set_config_option", () => {
1157 const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "acp-runtime-"));
1158 const requestLogPath = NodePath.join(tempDir, "requests.ndjson");
1159 return Effect.gen(function* () {
1160 const runtime = yield* AcpSessionRuntime.AcpSessionRuntime;
1161 yield* runtime.start();
1162
1163 const error = yield* runtime.setModel("composer-2[fast=false]").pipe(Effect.flip);
1164 expect(error._tag).toBe("AcpRequestError");
1165 if (error._tag === "AcpRequestError") {
1166 expect(error.code).toBe(-32602);
1167 expect(error.message).toContain(
1168 'Invalid value "composer-2[fast=false]" for session config option "model"',
1169 );
1170 expect(error.message).toContain("composer-2[fast=true]");
1171 }
1172
1173 const recordedRequests = NodeFS.readFileSync(requestLogPath, "utf8")
1174 .trim()
1175 .split("\n")
1176 .filter((line) => line.length > 0)
1177 .map((line) => JSON.parse(line) as { method?: string; params?: { value?: unknown } });
1178 expect(
1179 recordedRequests.some(
1180 (message) =>
1181 message.method === "session/set_config_option" &&
1182 message.params?.value === "composer-2[fast=false]",
1183 ),
1184 ).toBe(false);
1185 }).pipe(
1186 Effect.provide(
1187 AcpSessionRuntime.layer({
1188 authMethodId: "test",
1189 spawn: {
1190 command: mockAgentCommand,
1191 args: mockAgentArgs,
1192 env: {
1193 T3_ACP_REQUEST_LOG_PATH: requestLogPath,
1194 },
1195 },
1196 cwd: process.cwd(),
1197 clientInfo: { name: "t3-test", version: "0.0.0" },
1198 }),
1199 ),
1200 Effect.scoped,
1201 Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)),
1202 Effect.ensuring(Effect.sync(() => NodeFS.rmSync(tempDir, { recursive: true, force: true }))),
1203 );
1204 });
1205
1206 it.effect("sends configured custom models the agent does not advertise", () => {
1207 const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "acp-runtime-"));
1208 const requestLogPath = NodePath.join(tempDir, "requests.ndjson");
1209 return Effect.gen(function* () {
1210 const runtime = yield* AcpSessionRuntime.AcpSessionRuntime;
1211 yield* runtime.start();
1212
1213 yield* runtime.setModel("cline-free/custom-model");
1214 const error = yield* runtime.setModel("unconfigured/model").pipe(Effect.flip);
1215 expect(error._tag).toBe("AcpRequestError");
1216
1217 const sentModels = NodeFS.readFileSync(requestLogPath, "utf8")
1218 .trim()
1219 .split("\n")
1220 .filter((line) => line.length > 0)
1221 .map((line) => JSON.parse(line) as { method?: string; params?: { value?: unknown } })
1222 .filter((message) => message.method === "session/set_config_option")
1223 .map((message) => message.params?.value);
1224 expect(sentModels).toEqual(["cline-free/custom-model"]);
1225 }).pipe(
1226 Effect.provide(
1227 AcpSessionRuntime.layer({
1228 ...mockRuntimeOptions,
1229 spawn: {
1230 command: mockAgentCommand,
1231 args: mockAgentArgs,
1232 env: { T3_ACP_REQUEST_LOG_PATH: requestLogPath },
1233 },
1234 customModels: ["cline-free/custom-model"],
1235 }),
1236 ),
1237 Effect.scoped,
1238 Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)),
1239 Effect.ensuring(Effect.sync(() => NodeFS.rmSync(tempDir, { recursive: true, force: true }))),
1240 );
1241 });
1242});