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 NodeServices from "@effect/platform-node/NodeServices";
8import { it } from "@effect/vitest";
9import * as Deferred from "effect/Deferred";
10import * as Effect from "effect/Effect";
11import * as Exit from "effect/Exit";
12import * as Fiber from "effect/Fiber";
13import * as Option from "effect/Option";
14import * as Scope from "effect/Scope";
15import * as Result from "effect/Result";
16import * as TestClock from "effect/testing/TestClock";
17import * as Stream from "effect/Stream";
18import { describe, expect } from "vite-plus/test";
19
20import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";
21import type * as EffectAcpProtocol from "effect-acp/protocol";
22import * as EffectAcpErrors from "effect-acp/errors";
23
24const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url));
25const mockAgentPath = NodePath.join(__dirname, "../../../scripts/acp-mock-agent.ts");
26const mockAgentCommand = "node";
27const mockAgentArgs = [mockAgentPath];
28const mockRuntimeOptions = {
29 spawn: { command: mockAgentCommand, args: mockAgentArgs },
30 cwd: process.cwd(),
31 clientInfo: { name: "t3-test", version: "0.0.0" },
32 authMethodId: "test",
33} satisfies AcpSessionRuntime.AcpSessionRuntimeOptions;
34
35describe("AcpSessionRuntime", () => {
36 it.effect.each(["session/new", "session/resume"] as const)(
37 "buffers root metadata while %s startup is still pending",
38 (setupMethod) =>
39 Effect.gen(function* () {
40 const setupReplied = yield* Deferred.make<void>();
41 const allowStartup = yield* Deferred.make<void>();
42 const events: Array<AcpSessionRuntime.AcpSessionRuntimeEvent> = [];
43 const runtime = yield* AcpSessionRuntime.make({
44 ...mockRuntimeOptions,
45 ...(setupMethod === "session/resume"
46 ? {
47 spawn: {
48 ...mockRuntimeOptions.spawn,
49 env: { T3_ACP_SESSION_LIFECYCLE: "1" },
50 },
51 resumeSessionId: "mock-session-1",
52 resumeMethod: "resume" as const,
53 }
54 : {}),
55 requestLogger: (event) =>
56 event.method === setupMethod && event.status === "succeeded"
57 ? Deferred.succeed(setupReplied, undefined).pipe(
58 Effect.andThen(Deferred.await(allowStartup)),
59 )
60 : Effect.void,
61 });
62 yield* runtime.getEvents().pipe(
63 Stream.runForEach((event) => {
64 if (event._tag === "EventStreamBarrier") {
65 return Deferred.succeed(event.acknowledge, undefined);
66 }
67 events.push(event);
68 return Effect.void;
69 }),
70 Effect.forkChild,
71 );
72 const startup = yield* runtime.start().pipe(Effect.forkChild);
73 yield* Deferred.await(setupReplied);
74 yield* runtime.request("_test/startup-metadata", {});
75 yield* Deferred.succeed(allowStartup, undefined);
76 yield* Fiber.join(startup);
77 yield* runtime.drainEvents;
78
79 expect(events.map((event) => event._tag)).toEqual([
80 "AvailableCommandsUpdated",
81 "ModeChanged",
82 "ConfigOptionsUpdated",
83 ]);
84 expect(events[0]).toMatchObject({
85 availableCommands: [{ name: "plan", description: "Native command" }],
86 });
87 expect(yield* runtime.getModeState).toMatchObject({ currentModeId: "code" });
88 expect(events[2]).toMatchObject({
89 configOptions: yield* runtime.getConfigOptions,
90 });
91 expect(
92 (yield* runtime.getConfigOptions).find((option) => option.category === "model"),
93 ).toMatchObject({ currentValue: "gpt-5.4" });
94 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
95 );
96
97 it.effect("publishes model changes returned by a config request and live notifications", () =>
98 Effect.gen(function* () {
99 const runtime = yield* AcpSessionRuntime.make(mockRuntimeOptions);
100 yield* runtime.start();
101 const updates = yield* Stream.toPull(
102 runtime.getEvents().pipe(Stream.filter((event) => event._tag === "ConfigOptionsUpdated")),
103 );
104 const selected = yield* runtime.setConfigOption("model", "composer-2");
105 expect((yield* updates)[0]?.configOptions).toEqual(selected.configOptions);
106 yield* runtime.request("_test/startup-metadata", {});
107 expect((yield* updates)[0]?.configOptions).toEqual(yield* runtime.getConfigOptions);
108 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
109 );
110
111 it.effect("awaits native resume instead of using the load replay idle fallback", () =>
112 Effect.gen(function* () {
113 const resumeStarted = yield* Deferred.make<void>();
114 const requestMethods: Array<string> = [];
115 const runtime = yield* AcpSessionRuntime.make({
116 ...mockRuntimeOptions,
117 spawn: {
118 ...mockRuntimeOptions.spawn,
119 env: { T3_ACP_WAIT_FOR_RESUME_RELEASE: "1", T3_ACP_SESSION_LIFECYCLE: "1" },
120 },
121 resumeSessionId: "mock-session-1",
122 resumeMethod: "resume",
123 sessionLoadReplayIdleGap: "1 second",
124 requestLogger: (event) =>
125 Effect.sync(() => {
126 if (event.status === "started") requestMethods.push(event.method);
127 }),
128 });
129 yield* runtime.handleSessionUpdate((notification) =>
130 notification.update.sessionUpdate === "user_message_chunk"
131 ? Deferred.succeed(resumeStarted, undefined).pipe(Effect.asVoid)
132 : Effect.void,
133 );
134 const startup = yield* runtime.start().pipe(Effect.forkChild);
135 yield* Deferred.await(resumeStarted);
136 yield* TestClock.adjust("3 seconds");
137 expect(startup.pollUnsafe()).toBeUndefined();
138 yield* runtime.request("_test/release-resume", {});
139 const started = yield* Fiber.join(startup);
140
141 expect(started.sessionSetupResult._meta).toEqual({ nativeResume: true });
142 expect(requestMethods).toContain("session/resume");
143 expect(requestMethods).not.toContain("session/load");
144 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
145 );
146
147 it.effect("waits for native cancellation and drains final updates before another prompt", () =>
148 Effect.gen(function* () {
149 const toolStarted = yield* Deferred.make<void>();
150 const cancelReceived = yield* Deferred.make<void>();
151 const events: Array<AcpSessionRuntime.AcpSessionRuntimeEvent> = [];
152 let promptRequests = 0;
153 const runtime = yield* AcpSessionRuntime.make({
154 ...mockRuntimeOptions,
155 spawn: {
156 ...mockRuntimeOptions.spawn,
157 env: { T3_ACP_COMPLETE_FIRST_PROMPT_ON_CANCEL: "1" },
158 },
159 cancelBehavior: "wait-for-prompt",
160 requestLogger: (event) =>
161 Effect.sync(() => {
162 if (event.method === "session/prompt" && event.status === "started")
163 promptRequests += 1;
164 }),
165 });
166 yield* runtime.getEvents().pipe(
167 Stream.runForEach((event) => {
168 if (event._tag === "EventStreamBarrier") {
169 return Deferred.succeed(event.acknowledge, undefined);
170 }
171 events.push(event);
172 if (event._tag === "ToolCallUpdated" && event.toolCall.status === "inProgress") {
173 return Deferred.succeed(toolStarted, undefined);
174 }
175 if (event._tag === "ThoughtDelta" && event.text === "native-cancel-received") {
176 return Deferred.succeed(cancelReceived, undefined);
177 }
178 return Effect.void;
179 }),
180 Effect.forkChild,
181 );
182 yield* runtime.start();
183 const prompt = yield* runtime
184 .prompt({
185 prompt: [{ type: "text", text: "first" }],
186 })
187 .pipe(Effect.forkChild);
188 yield* Deferred.await(toolStarted);
189 const cancellation = yield* runtime.cancel.pipe(Effect.forkChild);
190 yield* Deferred.await(cancelReceived);
191 const replacement = yield* runtime
192 .prompt({
193 prompt: [{ type: "text", text: "second" }],
194 })
195 .pipe(Effect.forkChild({ startImmediately: true }));
196
197 expect(prompt.pollUnsafe()).toBeUndefined();
198 expect(cancellation.pollUnsafe()).toBeUndefined();
199 expect(promptRequests).toBe(1);
200 yield* runtime.request("_test/finish-cancel", {});
201 yield* Fiber.join(cancellation);
202
203 expect(yield* Fiber.join(prompt)).toEqual({
204 stopReason: "cancelled",
205 _meta: { nativeCancel: true },
206 });
207 expect(
208 events.some(
209 (event) =>
210 event._tag === "ToolCallUpdated" &&
211 event.toolCall.status === "failed" &&
212 event.toolCall.detail === "Cancelled.",
213 ),
214 ).toBe(true);
215 const cancelledDelta = events.find(
216 (event) => event._tag === "ContentDelta" && event.text === "Request cancelled.",
217 );
218 expect(cancelledDelta?._tag).toBe("ContentDelta");
219 if (cancelledDelta?._tag === "ContentDelta") {
220 expect(
221 events.filter(
222 (event) =>
223 event._tag === "AssistantItemCompleted" && event.itemId === cancelledDelta.itemId,
224 ),
225 ).toHaveLength(1);
226 }
227 expect(yield* Fiber.join(replacement)).toMatchObject({ stopReason: "end_turn" });
228 expect(promptRequests).toBe(2);
229 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
230 );
231
232 it.effect("retires a process when native cancellation times out", () =>
233 Effect.gen(function* () {
234 const toolStarted = yield* Deferred.make<void>();
235 const cancelReceived = yield* Deferred.make<void>();
236 const runtime = yield* AcpSessionRuntime.make({
237 ...mockRuntimeOptions,
238 spawn: {
239 ...mockRuntimeOptions.spawn,
240 env: { T3_ACP_COMPLETE_FIRST_PROMPT_ON_CANCEL: "1" },
241 },
242 cancelBehavior: "wait-for-prompt",
243 cancelTimeout: "1 second",
244 });
245 yield* runtime.getEvents().pipe(
246 Stream.runForEach((event) => {
247 if (event._tag === "EventStreamBarrier") {
248 return Deferred.succeed(event.acknowledge, undefined);
249 }
250 if (event._tag === "ToolCallUpdated") {
251 return Deferred.succeed(toolStarted, undefined);
252 }
253 if (event._tag === "ThoughtDelta") {
254 return Deferred.succeed(cancelReceived, undefined);
255 }
256 return Effect.void;
257 }),
258 Effect.forkChild,
259 );
260 yield* runtime.start();
261 const prompt = yield* runtime
262 .prompt({
263 prompt: [{ type: "text", text: "first" }],
264 })
265 .pipe(Effect.forkChild);
266 yield* Deferred.await(toolStarted);
267 const cancellation = yield* runtime.cancel.pipe(Effect.forkChild);
268 yield* Deferred.await(cancelReceived);
269 yield* TestClock.adjust("2 seconds");
270
271 const error = yield* Fiber.join(cancellation).pipe(Effect.flip);
272 expect(error).toMatchObject({
273 _tag: "AcpTransportError",
274 method: "session/cancel",
275 });
276 expect(Exit.isFailure(yield* Fiber.await(prompt))).toBe(true);
277 expect(
278 yield* runtime
279 .prompt({
280 prompt: [{ type: "text", text: "must not run" }],
281 })
282 .pipe(Effect.flip),
283 ).toBe(error);
284 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
285 );
286
287 it.effect("reports an idle child exit and rejects later prompts", () =>
288 Effect.gen(function* () {
289 const runtime = yield* AcpSessionRuntime.make(mockRuntimeOptions);
290 yield* runtime.start();
291 yield* runtime.notify("_test/exit", {});
292 const events = yield* runtime.getEvents().pipe(Stream.take(1), Stream.runCollect);
293 const event = events[0];
294 expect(event).toMatchObject({ _tag: "ConnectionTerminated", error: { code: 19 } });
295 if (event?._tag !== "ConnectionTerminated") return;
296 expect(
297 yield* runtime
298 .prompt({
299 prompt: [{ type: "text", text: "must not run" }],
300 })
301 .pipe(Effect.flip),
302 ).toBe(event.error);
303 expect(yield* runtime.start().pipe(Effect.flip)).toBe(event.error);
304 expect(yield* runtime.initialize().pipe(Effect.flip)).toBe(event.error);
305 expect(
306 yield* runtime.request("_test/environment", {}).pipe(
307 Effect.match({
308 onFailure: (error) => error,
309 onSuccess: () => undefined,
310 }),
311 ),
312 ).toBe(event.error);
313 expect(yield* runtime.notify("_test/exit", {}).pipe(Effect.flip)).toBe(event.error);
314 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
315 );
316
317 it.effect("retires a native runtime when its prompt caller is interrupted", () =>
318 Effect.gen(function* () {
319 const dispatched = yield* Deferred.make<void>();
320 const runtime = yield* AcpSessionRuntime.make({
321 ...mockRuntimeOptions,
322 spawn: {
323 ...mockRuntimeOptions.spawn,
324 env: { T3_ACP_COMPLETE_FIRST_PROMPT_ON_CANCEL: "1" },
325 },
326 cancelBehavior: "wait-for-prompt",
327 });
328 yield* runtime.start();
329 const prompt = yield* runtime
330 .prompt(
331 {
332 prompt: [{ type: "text", text: "first" }],
333 },
334 { dispatched },
335 )
336 .pipe(Effect.forkChild);
337 yield* Deferred.await(dispatched);
338 yield* Fiber.interrupt(prompt);
339 const events = yield* runtime.getEvents().pipe(
340 Stream.filter((event) => event._tag === "ConnectionTerminated"),
341 Stream.take(1),
342 Stream.runCollect,
343 );
344 expect(events[0]).toMatchObject({
345 error: { _tag: "AcpTransportError", method: "session/prompt" },
346 });
347 expect(
348 yield* runtime
349 .prompt({
350 prompt: [{ type: "text", text: "must not run" }],
351 })
352 .pipe(Effect.flip),
353 ).toBe(events[0]?.error);
354 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
355 );
356
357 it.effect("fails a pending request when the stderr handler rejects the runtime", () =>
358 Effect.gen(function* () {
359 const failure = new EffectAcpErrors.AcpTransportError({
360 detail: "Sign in before continuing.",
361 cause: undefined,
362 });
363 const runtime = yield* AcpSessionRuntime.make({
364 ...mockRuntimeOptions,
365 spawn: { ...mockRuntimeOptions.spawn, env: { T3_ACP_FLOOD_STDERR: "1" } },
366 onStderr: () => Effect.fail(failure),
367 });
368 expect(yield* runtime.start().pipe(Effect.flip)).toBe(failure);
369 const events = yield* runtime.getEvents().pipe(
370 Stream.filter((event) => event._tag === "ConnectionTerminated"),
371 Stream.take(1),
372 Stream.runCollect,
373 );
374 expect(events[0]?.error).toBe(failure);
375 expect(yield* runtime.initialize().pipe(Effect.flip)).toBe(failure);
376 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
377 );
378
379 it.effect("attaches child stderr when the ACP process exits before initialize", () =>
380 Effect.gen(function* () {
381 const runtime = yield* AcpSessionRuntime.make({
382 ...mockRuntimeOptions,
383 spawn: {
384 command: process.execPath,
385 args: [
386 "-e",
387 "process.stderr.write(\"Invalid project config at /tmp/project/.cursor/cli.json: schema validation failed. Unrecognized key(s): 'approvalMode', 'sandbox'\\n\"); process.exit(1);",
388 ],
389 },
390 });
391 const error = yield* runtime.start().pipe(Effect.flip);
392 expect(error._tag).toBe("AcpProcessExitedError");
393 expect(error.message).toContain("cli.json");
394 expect(error.message).toContain("Unrecognized key");
395 expect(error.message).not.toContain("ACP process exited with code 1\nACP process exited");
396 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
397 );
398
399 it.effect("drains large stderr output and keeps auth-sized logging chunks", () =>
400 Effect.gen(function* () {
401 const lengths: Array<number> = [];
402 for (const logStderr of [false, true]) {
403 yield* Effect.gen(function* () {
404 const runtime = yield* AcpSessionRuntime.make({
405 ...mockRuntimeOptions,
406 spawn: { ...mockRuntimeOptions.spawn, env: { T3_ACP_FLOOD_STDERR: "1" } },
407 ...(logStderr
408 ? {
409 onStderr: (text: string) =>
410 Effect.sync(() => {
411 lengths.push(text.length);
412 }),
413 }
414 : {}),
415 });
416 expect(yield* runtime.initialize()).toMatchObject({ protocolVersion: 2 });
417 }).pipe(Effect.scoped);
418 }
419 expect(lengths.length).toBeGreaterThan(0);
420 expect(Math.max(...lengths)).toBeGreaterThanOrEqual(16_384);
421 expect(Math.max(...lengths)).toBeLessThanOrEqual(32_768);
422 }).pipe(Effect.provide(NodeServices.layer)),
423 );
424
425 it.effect("releases a queued event drain when its runtime scope closes", () =>
426 Effect.gen(function* () {
427 const scope = yield* Effect.acquireRelease(Scope.make(), (scope) =>
428 Scope.close(scope, Exit.void),
429 );
430 const barrierReceived = yield* Deferred.make<void>();
431 const runtime = yield* AcpSessionRuntime.make(mockRuntimeOptions).pipe(
432 Effect.provideService(Scope.Scope, scope),
433 );
434 yield* runtime.start();
435 yield* runtime.getEvents().pipe(
436 Stream.runForEach((event) =>
437 event._tag === "EventStreamBarrier"
438 ? Deferred.succeed(barrierReceived, undefined).pipe(Effect.andThen(Effect.never))
439 : Effect.void,
440 ),
441 Effect.forkIn(scope),
442 );
443 const drain = yield* runtime.drainEvents.pipe(Effect.forkChild);
444 yield* Deferred.await(barrierReceived);
445 yield* Scope.close(scope, Exit.void);
446 yield* Fiber.join(drain);
447 yield* runtime.drainEvents;
448 expect(yield* runtime.initialize().pipe(Effect.flip)).toMatchObject({
449 _tag: "AcpTransportError",
450 detail: "The ACP session runtime is closed.",
451 });
452 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
453 );
454
455 it.effect("bounds native cancellation when its event consumer is absent", () =>
456 Effect.gen(function* () {
457 const toolStarted = yield* Deferred.make<void>();
458 const cancelReceived = yield* Deferred.make<void>();
459 const runtime = yield* AcpSessionRuntime.make({
460 ...mockRuntimeOptions,
461 spawn: {
462 ...mockRuntimeOptions.spawn,
463 env: { T3_ACP_COMPLETE_FIRST_PROMPT_ON_CANCEL: "1" },
464 },
465 cancelBehavior: "wait-for-prompt",
466 cancelTimeout: "1 second",
467 });
468 yield* runtime.handleSessionUpdate((notification) => {
469 if (notification.update.sessionUpdate === "tool_call_update") {
470 return Deferred.succeed(toolStarted, undefined).pipe(Effect.asVoid);
471 }
472 if (notification.update.sessionUpdate === "agent_thought_chunk") {
473 return Deferred.succeed(cancelReceived, undefined).pipe(Effect.asVoid);
474 }
475 return Effect.void;
476 });
477 yield* runtime.start();
478 const prompt = yield* runtime
479 .prompt({
480 prompt: [{ type: "text", text: "first" }],
481 })
482 .pipe(Effect.forkChild);
483 yield* Deferred.await(toolStarted);
484 const cancellation = yield* runtime.cancel.pipe(Effect.forkChild);
485 yield* Deferred.await(cancelReceived);
486 yield* runtime.request("_test/finish-cancel", {});
487 expect(yield* Fiber.join(prompt)).toMatchObject({ stopReason: "cancelled" });
488 yield* TestClock.adjust("2 seconds");
489 expect(yield* Fiber.join(cancellation).pipe(Effect.flip)).toMatchObject({
490 _tag: "AcpTransportError",
491 method: "session/cancel",
492 });
493 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
494 );
495
496 it.effect("does not restore ambient variables to a sanitized child environment", () =>
497 Effect.gen(function* () {
498 yield* Effect.acquireRelease(
499 Effect.sync(() => {
500 const previous = process.env.T3_ACP_RUNTIME_AMBIENT;
501 process.env.T3_ACP_RUNTIME_AMBIENT = "sentinel";
502 return previous;
503 }),
504 (previous) =>
505 Effect.sync(() => {
506 if (previous === undefined) delete process.env.T3_ACP_RUNTIME_AMBIENT;
507 else process.env.T3_ACP_RUNTIME_AMBIENT = previous;
508 }),
509 );
510 const runtime = yield* AcpSessionRuntime.make({
511 ...mockRuntimeOptions,
512 spawn: {
513 command: process.execPath,
514 args: mockAgentArgs,
515 extendEnv: false,
516 env: { T3_ACP_RUNTIME_EXPLICIT: "kept" },
517 },
518 });
519 yield* runtime.initialize();
520 expect(yield* runtime.request("_test/environment", {})).toEqual({
521 inherited: false,
522 explicit: true,
523 });
524 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
525 );
526
527 it("selects explicit or agent-managed authentication without choosing terminal auth", () => {
528 const methods = [
529 { id: "browser", name: "Browser", type: "terminal" as const },
530 { id: "api-key", name: "API key", type: "agent" as const },
531 ];
532
533 expect(AcpSessionRuntime.selectAcpAgentAuthMethod(methods)?.id).toBe("api-key");
534 expect(AcpSessionRuntime.selectAcpAgentAuthMethod(methods, "browser")?.id).toBe("browser");
535 expect(AcpSessionRuntime.selectAcpAgentAuthMethod(methods, "missing")).toBeUndefined();
536 });
537
538 it.effect("merges custom initialize client capabilities into the ACP handshake", () => {
539 const requestEvents: Array<AcpSessionRuntime.AcpSessionRequestLogEvent> = [];
540 return Effect.gen(function* () {
541 const runtime = yield* AcpSessionRuntime.AcpSessionRuntime;
542 yield* runtime.start();
543
544 const initializeStarted = requestEvents.find(
545 (event) => event.method === "initialize" && event.status === "started",
546 );
547 expect(initializeStarted?.payload).toMatchObject({
548 protocolVersion: 2,
549 clientCapabilities: {
550 fs: { readTextFile: false, writeTextFile: false },
551 terminal: false,
552 _meta: { parameterizedModelPicker: true },
553 },
554 });
555 }).pipe(
556 Effect.provide(
557 AcpSessionRuntime.layer({
558 spawn: {
559 command: mockAgentCommand,
560 args: mockAgentArgs,
561 },
562 cwd: process.cwd(),
563 clientCapabilities: {
564 _meta: {
565 parameterizedModelPicker: true,
566 },
567 },
568 clientInfo: { name: "t3-test", version: "0.0.0" },
569 authMethodId: "test",
570 requestLogger: (event) =>
571 Effect.sync(() => {
572 requestEvents.push(event);
573 }),
574 }),
575 ),
576 Effect.scoped,
577 Effect.provide(NodeServices.layer),
578 );
579 });
580
581 it.effect("prefers MCP-over-ACP only when the agent advertises that transport", () => {
582 const requestEvents: Array<AcpSessionRuntime.AcpSessionRequestLogEvent> = [];
583 return Effect.gen(function* () {
584 const runtime = yield* AcpSessionRuntime.AcpSessionRuntime;
585 yield* runtime.start();
586
587 const created = requestEvents.find(
588 (event) => event.method === "session/new" && event.status === "started",
589 );
590 expect(created?.payload).toMatchObject({
591 mcpServers: [{ type: "acp", name: "t3-code", serverId: "t3-code" }],
592 });
593 }).pipe(
594 Effect.provide(
595 AcpSessionRuntime.layer({
596 spawn: {
597 command: mockAgentCommand,
598 args: mockAgentArgs,
599 env: { T3_ACP_MCP_ACP: "1" },
600 },
601 cwd: process.cwd(),
602 clientInfo: { name: "t3-test", version: "0.0.0" },
603 authMethodId: "test",
604 mcpServers: [{ type: "stdio", name: "t3-code", command: "/usr/bin/node", args: [] }],
605 acpMcpServers: [{ type: "acp", name: "t3-code", serverId: "t3-code" }],
606 requestLogger: (event) =>
607 Effect.sync(() => {
608 requestEvents.push(event);
609 }),
610 }),
611 ),
612 Effect.scoped,
613 Effect.provide(NodeServices.layer),
614 );
615 });
616
617 it.effect("falls back to stdio MCP when the agent does not advertise MCP-over-ACP", () => {
618 const requestEvents: Array<AcpSessionRuntime.AcpSessionRequestLogEvent> = [];
619 return Effect.gen(function* () {
620 const runtime = yield* AcpSessionRuntime.AcpSessionRuntime;
621 yield* runtime.start();
622
623 const created = requestEvents.find(
624 (event) => event.method === "session/new" && event.status === "started",
625 );
626 expect(created?.payload).toMatchObject({
627 mcpServers: [{ type: "stdio", name: "t3-code", command: "/usr/bin/node", args: [] }],
628 });
629 }).pipe(
630 Effect.provide(
631 AcpSessionRuntime.layer({
632 spawn: {
633 command: mockAgentCommand,
634 args: mockAgentArgs,
635 },
636 cwd: process.cwd(),
637 clientInfo: { name: "t3-test", version: "0.0.0" },
638 authMethodId: "test",
639 mcpServers: [{ type: "stdio", name: "t3-code", command: "/usr/bin/node", args: [] }],
640 acpMcpServers: [{ type: "acp", name: "t3-code", serverId: "t3-code" }],
641 requestLogger: (event) =>
642 Effect.sync(() => {
643 requestEvents.push(event);
644 }),
645 }),
646 ),
647 Effect.scoped,
648 Effect.provide(NodeServices.layer),
649 );
650 });
651
652 it.effect("supports v2 session deletion and secret-safe provider management", () => {
653 const requestEvents: Array<AcpSessionRuntime.AcpSessionRequestLogEvent> = [];
654 return Effect.gen(function* () {
655 const runtime = yield* AcpSessionRuntime.AcpSessionRuntime;
656 yield* runtime.start();
657
658 yield* runtime.deleteSession("native-session-1");
659 expect((yield* runtime.listProviders).providers[0]).toMatchObject({
660 providerId: "mock-provider",
661 current: null,
662 });
663 yield* runtime.setProvider({
664 providerId: "mock-provider",
665 apiType: "openai",
666 baseUrl: "https://api.example.test/v1",
667 headers: { Authorization: "Bearer top-secret" },
668 });
669 expect((yield* runtime.listProviders).providers[0]?.current).toEqual({
670 apiType: "openai",
671 baseUrl: "https://api.example.test/v1",
672 });
673 yield* runtime.disableProvider("mock-provider");
674 expect((yield* runtime.listProviders).providers[0]?.current).toBeNull();
675
676 const setRequest = requestEvents.find(
677 (event) => event.method === "providers/set" && event.status === "started",
678 );
679 expect(setRequest?.payload).toMatchObject({
680 headers: { Authorization: "[redacted]" },
681 });
682 }).pipe(
683 Effect.provide(
684 AcpSessionRuntime.layer({
685 spawn: {
686 command: mockAgentCommand,
687 args: mockAgentArgs,
688 env: { T3_ACP_V2_MANAGEMENT: "1" },
689 },
690 cwd: process.cwd(),
691 clientInfo: { name: "t3-test", version: "0.0.0" },
692 authMethodId: "test",
693 requestLogger: (event) =>
694 Effect.sync(() => {
695 requestEvents.push(event);
696 }),
697 }),
698 ),
699 Effect.scoped,
700 Effect.provide(NodeServices.layer),
701 );
702 });
703
704 it.effect("does not authenticate when an advertised method is not required", () => {
705 const requestEvents: Array<AcpSessionRuntime.AcpSessionRequestLogEvent> = [];
706 return Effect.gen(function* () {
707 const runtime = yield* AcpSessionRuntime.AcpSessionRuntime;
708 yield* runtime.start();
709
710 expect(
711 requestEvents.filter((event) => event.status === "started").map((event) => event.method),
712 ).toEqual(["initialize", "session/new"]);
713 }).pipe(
714 Effect.provide(
715 AcpSessionRuntime.layer({
716 authMethodId: "test",
717 spawn: {
718 command: mockAgentCommand,
719 args: mockAgentArgs,
720 env: { T3_ACP_AUTH_METHOD_ID: "test" },
721 },
722 cwd: process.cwd(),
723 clientInfo: { name: "t3-test", version: "0.0.0" },
724 requestLogger: (event) =>
725 Effect.sync(() => {
726 requestEvents.push(event);
727 }),
728 }),
729 ),
730 Effect.scoped,
731 Effect.provide(NodeServices.layer),
732 );
733 });
734
735 it.effect("authenticates explicitly typed v2 agent methods and retries session creation", () => {
736 const requestEvents: Array<AcpSessionRuntime.AcpSessionRequestLogEvent> = [];
737 return Effect.gen(function* () {
738 const runtime = yield* AcpSessionRuntime.AcpSessionRuntime;
739 const started = yield* runtime.start();
740 expect(started.sessionId).toBe("mock-session-1");
741 expect(
742 requestEvents.filter((event) => event.status === "started").map((event) => event.method),
743 ).toEqual(["initialize", "session/new", "authenticate", "session/new"]);
744 expect(
745 requestEvents.filter(
746 (event) => event.method === "session/new" && event.status === "failed",
747 ),
748 ).toHaveLength(1);
749 expect(
750 requestEvents.filter(
751 (event) => event.method === "session/new" && event.status === "succeeded",
752 ),
753 ).toHaveLength(1);
754 }).pipe(
755 Effect.provide(
756 AcpSessionRuntime.layer({
757 authMethodId: "test",
758 spawn: {
759 command: mockAgentCommand,
760 args: mockAgentArgs,
761 env: {
762 T3_ACP_AUTH_METHOD_ID: "test",
763 T3_ACP_REQUIRE_AUTH: "1",
764 },
765 },
766 cwd: process.cwd(),
767 clientInfo: { name: "t3-test", version: "0.0.0" },
768 requestLogger: (event) =>
769 Effect.sync(() => {
770 requestEvents.push(event);
771 }),
772 }),
773 ),
774 Effect.scoped,
775 Effect.provide(NodeServices.layer),
776 );
777 });
778
779 it.effect("can surface auth-required without authenticating during discovery", () => {
780 const requestEvents: Array<AcpSessionRuntime.AcpSessionRequestLogEvent> = [];
781 let advertisedAuthMethodName: string | undefined;
782 return Effect.gen(function* () {
783 const runtime = yield* AcpSessionRuntime.AcpSessionRuntime;
784 const result = yield* Effect.result(runtime.start());
785
786 expect(Result.isFailure(result)).toBe(true);
787 expect(advertisedAuthMethodName).toBe("Mock agent authentication");
788 expect(
789 requestEvents.filter((event) => event.status === "started").map((event) => event.method),
790 ).toEqual(["initialize", "session/new"]);
791 }).pipe(
792 Effect.provide(
793 AcpSessionRuntime.layer({
794 authenticateOnAuthRequired: false,
795 authMethodId: "test",
796 spawn: {
797 command: mockAgentCommand,
798 args: mockAgentArgs,
799 env: {
800 T3_ACP_AUTH_METHOD_ID: "test",
801 T3_ACP_REQUIRE_AUTH: "1",
802 },
803 },
804 cwd: process.cwd(),
805 clientInfo: { name: "t3-test", version: "0.0.0" },
806 onInitialized: (initializeResult) =>
807 Effect.sync(() => {
808 advertisedAuthMethodName = initializeResult.authMethods?.[0]?.name;
809 }),
810 requestLogger: (event) =>
811 Effect.sync(() => {
812 requestEvents.push(event);
813 }),
814 }),
815 ),
816 Effect.scoped,
817 Effect.provide(NodeServices.layer),
818 );
819 });
820
821 it.effect("starts a session, prompts, and emits normalized events against the mock agent", () =>
822 Effect.gen(function* () {
823 const runtime = yield* AcpSessionRuntime.AcpSessionRuntime;
824 const started = yield* runtime.start();
825
826 expect(started.initializeResult).toMatchObject({ protocolVersion: 2 });
827 expect(started.sessionId).toBe("mock-session-1");
828
829 const promptResult = yield* runtime.prompt({
830 prompt: [{ type: "text", text: "hi" }],
831 });
832 expect(promptResult).toMatchObject({ stopReason: "end_turn" });
833
834 const notes = Array.from(yield* Stream.runCollect(Stream.take(runtime.getEvents(), 4)));
835 expect(notes).toHaveLength(4);
836 expect(notes.map((note) => note._tag)).toEqual([
837 "PlanUpdated",
838 "AssistantItemStarted",
839 "ContentDelta",
840 "AssistantItemCompleted",
841 ]);
842 const planUpdate = notes.find((note) => note._tag === "PlanUpdated");
843 expect(planUpdate?._tag).toBe("PlanUpdated");
844 if (planUpdate?._tag === "PlanUpdated") {
845 expect(planUpdate.payload).toMatchObject({
846 nativePlanId: "mock-plan",
847 kind: "items",
848 });
849 if (planUpdate.payload.kind === "items") {
850 expect(planUpdate.payload.plan).toHaveLength(2);
851 }
852 }
853 const assistantStart = notes[1];
854 const assistantDelta = notes[2];
855 if (
856 assistantStart?._tag === "AssistantItemStarted" &&
857 assistantDelta?._tag === "ContentDelta"
858 ) {
859 expect(assistantDelta.itemId).toBe(assistantStart.itemId);
860 }
861 }).pipe(
862 Effect.provide(
863 AcpSessionRuntime.layer({
864 spawn: {
865 command: mockAgentCommand,
866 args: mockAgentArgs,
867 },
868 cwd: process.cwd(),
869 clientInfo: { name: "t3-test", version: "0.0.0" },
870 authMethodId: "test",
871 }),
872 ),
873 Effect.scoped,
874 Effect.provide(NodeServices.layer),
875 ),
876 );
877
878 it.effect("keeps assistant item IDs unique when a provider session restarts", () => {
879 const collectFirstAssistantItemId = Effect.gen(function* () {
880 const runtime = yield* AcpSessionRuntime.AcpSessionRuntime;
881 const started = yield* runtime.start();
882 expect(started.sessionId).toBe("mock-session-1");
883
884 yield* runtime.prompt({
885 prompt: [{ type: "text", text: "hi" }],
886 });
887
888 const events = Array.from(yield* Stream.runCollect(Stream.take(runtime.getEvents(), 4)));
889 const assistantStart = events.find((event) => event._tag === "AssistantItemStarted");
890 expect(assistantStart?._tag).toBe("AssistantItemStarted");
891 return assistantStart?._tag === "AssistantItemStarted" ? assistantStart.itemId : "";
892 }).pipe(
893 Effect.provide(
894 AcpSessionRuntime.layer({
895 spawn: {
896 command: mockAgentCommand,
897 args: mockAgentArgs,
898 },
899 cwd: process.cwd(),
900 clientInfo: { name: "t3-test", version: "0.0.0" },
901 authMethodId: "test",
902 }),
903 ),
904 Effect.scoped,
905 );
906
907 return Effect.gen(function* () {
908 const beforeRestart = yield* collectFirstAssistantItemId;
909 const afterRestart = yield* collectFirstAssistantItemId;
910
911 expect(afterRestart).not.toBe(beforeRestart);
912 }).pipe(Effect.provide(NodeServices.layer));
913 });
914
915 it.effect("drops session updates emitted for a child ACP session", () =>
916 Effect.gen(function* () {
917 const runtime = yield* AcpSessionRuntime.AcpSessionRuntime;
918 yield* runtime.start();
919
920 const promptResult = yield* runtime.prompt({
921 prompt: [{ type: "text", text: "hi" }],
922 });
923 expect(promptResult).toMatchObject({ stopReason: "end_turn" });
924
925 const notes = Array.from(yield* Stream.runCollect(Stream.take(runtime.getEvents(), 4)));
926 expect(notes.map((note) => note._tag)).toEqual([
927 "AssistantItemStarted",
928 "ContentDelta",
929 "ContentDelta",
930 "AssistantItemCompleted",
931 ]);
932 expect(
933 notes
934 .filter((note) => note._tag === "ContentDelta")
935 .map((note) => note.text)
936 .join(""),
937 ).toBe("root before child root after child");
938 expect(notes.some((note) => note._tag === "ToolCallUpdated")).toBe(false);
939 }).pipe(
940 Effect.provide(
941 AcpSessionRuntime.layer({
942 spawn: {
943 command: mockAgentCommand,
944 args: mockAgentArgs,
945 env: {
946 T3_ACP_EMIT_FOREIGN_SESSION_UPDATES: "1",
947 },
948 },
949 cwd: process.cwd(),
950 clientInfo: { name: "t3-test", version: "0.0.0" },
951 authMethodId: "test",
952 }),
953 ),
954 Effect.scoped,
955 Effect.provide(NodeServices.layer),
956 ),
957 );
958
959 it.effect("supports successive standard ACP prompts", () =>
960 Effect.gen(function* () {
961 const runtime = yield* AcpSessionRuntime.AcpSessionRuntime;
962 yield* runtime.start();
963
964 const firstPromptResult = yield* runtime.prompt({
965 prompt: [{ type: "text", text: "first" }],
966 });
967 const secondPromptResult = yield* runtime.prompt({
968 prompt: [{ type: "text", text: "second" }],
969 });
970
971 expect(firstPromptResult).toMatchObject({ stopReason: "end_turn" });
972 expect(secondPromptResult).toMatchObject({ stopReason: "end_turn" });
973 }).pipe(
974 Effect.provide(
975 AcpSessionRuntime.layer({
976 spawn: {
977 command: mockAgentCommand,
978 args: mockAgentArgs,
979 },
980 cwd: process.cwd(),
981 clientInfo: { name: "t3-test", version: "0.0.0" },
982 authMethodId: "test",
983 }),
984 ),
985 Effect.scoped,
986 Effect.provide(NodeServices.layer),
987 ),
988 );
989
990 it.effect("releases a fully silent prompt and forwards configured cancel metadata", () => {
991 const protocolEvents: Array<EffectAcpProtocol.AcpProtocolLogEvent> = [];
992 return Effect.gen(function* () {
993 const runtime = yield* AcpSessionRuntime.AcpSessionRuntime;
994 yield* runtime.start();
995
996 const promptFiber = yield* runtime
997 .prompt({
998 prompt: [{ type: "text", text: "hang forever" }],
999 })
1000 .pipe(Effect.forkChild({ startImmediately: true }));
1001
1002 yield* TestClock.adjust("500 millis");
1003 yield* runtime.cancel;
1004 yield* Effect.yieldNow;
1005 yield* Effect.yieldNow;
1006
1007 expect(
1008 protocolEvents.some(
1009 (event) =>
1010 event.direction === "outgoing" &&
1011 event.stage === "raw" &&
1012 typeof event.payload === "string" &&
1013 event.payload.includes('"method":"session/cancel"') &&
1014 event.payload.includes('"cancelTrigger":"ctrl_c"'),
1015 ),
1016 ).toBe(true);
1017
1018 const firstPromptResult = yield* Fiber.join(promptFiber);
1019 expect(firstPromptResult).toMatchObject({ stopReason: "cancelled" });
1020
1021 const secondPromptResult = yield* runtime.prompt({
1022 prompt: [{ type: "text", text: "second" }],
1023 });
1024 expect(secondPromptResult).toMatchObject({ stopReason: "end_turn" });
1025 }).pipe(
1026 Effect.provide(
1027 AcpSessionRuntime.layer({
1028 spawn: {
1029 command: mockAgentCommand,
1030 args: mockAgentArgs,
1031 env: {
1032 T3_ACP_HANG_FIRST_PROMPT_FOREVER: "1",
1033 },
1034 },
1035 cwd: process.cwd(),
1036 clientInfo: { name: "t3-test", version: "0.0.0" },
1037 authMethodId: "test",
1038 cancelMeta: { cancelTrigger: "ctrl_c" },
1039 protocolLogging: {
1040 logOutgoing: true,
1041 logger: (event) =>
1042 Effect.sync(() => {
1043 protocolEvents.push(event);
1044 }),
1045 },
1046 }),
1047 ),
1048 Effect.scoped,
1049 Effect.provide(NodeServices.layer),
1050 );
1051 });
1052
1053 it.effect("segments assistant text around ACP tool calls", () =>
1054 Effect.gen(function* () {
1055 const runtime = yield* AcpSessionRuntime.AcpSessionRuntime;
1056 yield* runtime.start();
1057
1058 const promptResult = yield* runtime.prompt({
1059 prompt: [{ type: "text", text: "hi" }],
1060 });
1061 expect(promptResult).toMatchObject({ stopReason: "end_turn" });
1062
1063 const notes = Array.from(yield* Stream.runCollect(Stream.take(runtime.getEvents(), 7)));
1064 expect(notes.map((note) => note._tag)).toEqual([
1065 "AssistantItemStarted",
1066 "ContentDelta",
1067 "AssistantItemCompleted",
1068 "ToolCallUpdated",
1069 "ToolCallUpdated",
1070 "AssistantItemStarted",
1071 "ContentDelta",
1072 ]);
1073
1074 const firstStarted = notes[0];
1075 const firstDelta = notes[1];
1076 const firstCompleted = notes[2];
1077 const secondStarted = notes[5];
1078 const secondDelta = notes[6];
1079 expect(firstStarted?._tag).toBe("AssistantItemStarted");
1080 expect(firstCompleted?._tag).toBe("AssistantItemCompleted");
1081 expect(secondStarted?._tag).toBe("AssistantItemStarted");
1082 if (
1083 firstStarted?._tag === "AssistantItemStarted" &&
1084 firstDelta?._tag === "ContentDelta" &&
1085 firstCompleted?._tag === "AssistantItemCompleted" &&
1086 secondStarted?._tag === "AssistantItemStarted" &&
1087 secondDelta?._tag === "ContentDelta"
1088 ) {
1089 expect(firstDelta.itemId).toBe(firstStarted.itemId);
1090 expect(firstCompleted.itemId).toBe(firstStarted.itemId);
1091 expect(secondStarted.itemId).not.toBe(firstStarted.itemId);
1092 expect(secondDelta.itemId).toBe(secondStarted.itemId);
1093 }
1094 }).pipe(
1095 Effect.provide(
1096 AcpSessionRuntime.layer({
1097 spawn: {
1098 command: mockAgentCommand,
1099 args: mockAgentArgs,
1100 env: {
1101 T3_ACP_EMIT_INTERLEAVED_ASSISTANT_TOOL_CALLS: "1",
1102 },
1103 },
1104 cwd: process.cwd(),
1105 clientInfo: { name: "t3-test", version: "0.0.0" },
1106 authMethodId: "test",
1107 }),
1108 ),
1109 Effect.scoped,
1110 Effect.provide(NodeServices.layer),
1111 ),
1112 );
1113
1114 it.effect("keeps one answer when an earlier tool reports progress mid-stream", () =>
1115 Effect.gen(function* () {
1116 const runtime = yield* AcpSessionRuntime.AcpSessionRuntime;
1117 yield* runtime.start();
1118 yield* runtime.prompt({ prompt: [{ type: "text", text: "hi" }] });
1119
1120 const notes = Array.from(yield* Stream.runCollect(Stream.take(runtime.getEvents(), 9)));
1121 // The coalesced progress tick emits nothing, and neither the completion
1122 // nor a repeated one splits the markdown table across items.
1123 expect(notes.map((note) => note._tag)).toEqual([
1124 "ToolCallUpdated",
1125 "AssistantItemStarted",
1126 "ContentDelta",
1127 "ContentDelta",
1128 "ToolCallUpdated",
1129 "ContentDelta",
1130 "ToolCallUpdated",
1131 "ContentDelta",
1132 "AssistantItemCompleted",
1133 ]);
1134 const itemIds = new Set(
1135 notes.flatMap((note) =>
1136 note._tag === "ContentDelta" ||
1137 note._tag === "AssistantItemStarted" ||
1138 note._tag === "AssistantItemCompleted"
1139 ? [note.itemId]
1140 : [],
1141 ),
1142 );
1143 expect(itemIds.size).toBe(1);
1144 }).pipe(
1145 Effect.provide(
1146 AcpSessionRuntime.layer({
1147 spawn: {
1148 command: mockAgentCommand,
1149 args: mockAgentArgs,
1150 env: { T3_ACP_EMIT_BACKGROUND_TOOL_DURING_ANSWER: "1" },
1151 },
1152 cwd: process.cwd(),
1153 clientInfo: { name: "t3-test", version: "0.0.0" },
1154 authMethodId: "test",
1155 }),
1156 ),
1157 Effect.scoped,
1158 Effect.provide(NodeServices.layer),
1159 ),
1160 );
1161
1162 it.effect("emits status-only tool updates through completion", () =>
1163 Effect.gen(function* () {
1164 const runtime = yield* AcpSessionRuntime.AcpSessionRuntime;
1165 yield* runtime.start();
1166
1167 const promptResult = yield* runtime.prompt({
1168 prompt: [{ type: "text", text: "hi" }],
1169 });
1170 expect(promptResult).toMatchObject({ stopReason: "end_turn" });
1171
1172 const notes = Array.from(yield* Stream.runCollect(Stream.take(runtime.getEvents(), 3)));
1173 expect(notes.map((note) => note._tag)).toEqual([
1174 "ToolCallUpdated",
1175 "ToolCallUpdated",
1176 "ToolCallUpdated",
1177 ]);
1178 const toolCalls = notes.flatMap((note) =>
1179 note._tag === "ToolCallUpdated" ? [note.toolCall] : [],
1180 );
1181 expect(toolCalls.map((toolCall) => toolCall.status)).toEqual([
1182 "pending",
1183 "inProgress",
1184 "completed",
1185 ]);
1186 for (const toolCall of toolCalls) {
1187 expect(toolCall.title).toBe("Read file");
1188 }
1189 }).pipe(
1190 Effect.provide(
1191 AcpSessionRuntime.layer({
1192 spawn: {
1193 command: mockAgentCommand,
1194 args: mockAgentArgs,
1195 env: {
1196 T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1",
1197 },
1198 },
1199 cwd: process.cwd(),
1200 clientInfo: { name: "t3-test", version: "0.0.0" },
1201 authMethodId: "test",
1202 }),
1203 ),
1204 Effect.scoped,
1205 Effect.provide(NodeServices.layer),
1206 ),
1207 );
1208
1209 it.effect("logs ACP requests from the shared runtime", () => {
1210 const requestEvents: Array<AcpSessionRuntime.AcpSessionRequestLogEvent> = [];
1211 return Effect.gen(function* () {
1212 const runtime = yield* AcpSessionRuntime.AcpSessionRuntime;
1213 yield* runtime.start();
1214
1215 yield* runtime.setModel("composer-2");
1216 yield* runtime.prompt({
1217 prompt: [{ type: "text", text: "hi" }],
1218 });
1219
1220 expect(
1221 requestEvents.some(
1222 (event) => event.method === "session/set_config_option" && event.status === "started",
1223 ),
1224 ).toBe(true);
1225 expect(
1226 requestEvents.some(
1227 (event) => event.method === "session/set_config_option" && event.status === "succeeded",
1228 ),
1229 ).toBe(true);
1230 expect(
1231 requestEvents.some(
1232 (event) => event.method === "session/prompt" && event.status === "started",
1233 ),
1234 ).toBe(true);
1235 expect(
1236 requestEvents.some(
1237 (event) => event.method === "session/prompt" && event.status === "succeeded",
1238 ),
1239 ).toBe(true);
1240 }).pipe(
1241 Effect.provide(
1242 AcpSessionRuntime.layer({
1243 authMethodId: "test",
1244 spawn: {
1245 command: mockAgentCommand,
1246 args: mockAgentArgs,
1247 },
1248 cwd: process.cwd(),
1249 clientInfo: { name: "t3-test", version: "0.0.0" },
1250 requestLogger: (event) =>
1251 Effect.sync(() => {
1252 requestEvents.push(event);
1253 }),
1254 }),
1255 ),
1256 Effect.scoped,
1257 Effect.provide(NodeServices.layer),
1258 );
1259 });
1260
1261 it.effect("supports negotiated ACP session list, fork, resume, and close methods", () => {
1262 const requestEvents: Array<AcpSessionRuntime.AcpSessionRequestLogEvent> = [];
1263 return Effect.gen(function* () {
1264 const runtime = yield* AcpSessionRuntime.AcpSessionRuntime;
1265 const started = yield* runtime.start();
1266 expect(started.initializeResult.agentCapabilities?.sessionCapabilities).toMatchObject({
1267 list: {},
1268 fork: {},
1269 resume: {},
1270 close: {},
1271 });
1272 expect(started.initializeResult.agentCapabilities?.auth).toMatchObject({ logout: {} });
1273
1274 const listed = yield* runtime.listSessions();
1275 expect(listed.sessions.map((session) => session.sessionId)).toEqual(["mock-session-1"]);
1276
1277 const forked = yield* runtime.forkSession(started.sessionId);
1278 expect(forked.sessionId).toBe("mock-session-1-fork");
1279 yield* runtime.prompt({ prompt: [{ type: "text", text: "on fork" }] });
1280
1281 const resumed = yield* runtime.resumeSession(started.sessionId);
1282 expect(resumed.sessionId).toBe("mock-session-1");
1283 yield* runtime.closeSession();
1284 yield* runtime.logout;
1285
1286 expect(
1287 requestEvents.find(
1288 (event) => event.method === "session/prompt" && event.status === "started",
1289 )?.payload,
1290 ).toMatchObject({ sessionId: "mock-session-1-fork" });
1291 expect(
1292 requestEvents.filter((event) => event.status === "started").map((event) => event.method),
1293 ).toEqual(
1294 expect.arrayContaining([
1295 "session/list",
1296 "session/fork",
1297 "session/resume",
1298 "session/close",
1299 "logout",
1300 ]),
1301 );
1302 }).pipe(
1303 Effect.provide(
1304 AcpSessionRuntime.layer({
1305 authMethodId: "test",
1306 spawn: {
1307 command: mockAgentCommand,
1308 args: mockAgentArgs,
1309 env: {
1310 T3_ACP_SESSION_LIFECYCLE: "1",
1311 },
1312 },
1313 cwd: process.cwd(),
1314 clientInfo: { name: "t3-test", version: "0.0.0" },
1315 requestLogger: (event) =>
1316 Effect.sync(() => {
1317 requestEvents.push(event);
1318 }),
1319 }),
1320 ),
1321 Effect.scoped,
1322 Effect.provide(NodeServices.layer),
1323 );
1324 });
1325
1326 it.effect("lists sessions without creating a disposable session", () => {
1327 const requestEvents: Array<AcpSessionRuntime.AcpSessionRequestLogEvent> = [];
1328 return Effect.gen(function* () {
1329 const runtime = yield* AcpSessionRuntime.AcpSessionRuntime;
1330 const listed = yield* runtime.listSessions();
1331
1332 expect(listed.sessions).toHaveLength(1);
1333 expect(
1334 requestEvents.filter((event) => event.status === "started").map((event) => event.method),
1335 ).toEqual(["initialize", "session/list"]);
1336 }).pipe(
1337 Effect.provide(
1338 AcpSessionRuntime.layer({
1339 spawn: {
1340 command: mockAgentCommand,
1341 args: mockAgentArgs,
1342 env: { T3_ACP_SESSION_LIFECYCLE: "1" },
1343 },
1344 cwd: process.cwd(),
1345 clientInfo: { name: "t3-test", version: "0.0.0" },
1346 requestLogger: (event) =>
1347 Effect.sync(() => {
1348 requestEvents.push(event);
1349 }),
1350 }),
1351 ),
1352 Effect.scoped,
1353 Effect.provide(NodeServices.layer),
1354 );
1355 });
1356
1357 it.effect("rejects unauthenticated ACP session lifecycle requests", () =>
1358 Effect.gen(function* () {
1359 const runtime = yield* AcpSessionRuntime.AcpSessionRuntime;
1360 yield* runtime.start();
1361
1362 const listError = yield* runtime.listSessions().pipe(Effect.flip);
1363 expect(listError._tag).toBe("AcpRequestError");
1364 if (listError._tag === "AcpRequestError") {
1365 expect(listError.code).toBe(-32000);
1366 }
1367
1368 const forkError = yield* runtime.forkSession("mock-session-1").pipe(Effect.flip);
1369 expect(forkError._tag).toBe("AcpRequestError");
1370 if (forkError._tag === "AcpRequestError") {
1371 expect(forkError.code).toBe(-32000);
1372 }
1373
1374 const resumeError = yield* runtime.resumeSession("mock-session-1").pipe(Effect.flip);
1375 expect(resumeError._tag).toBe("AcpRequestError");
1376 if (resumeError._tag === "AcpRequestError") {
1377 expect(resumeError.code).toBe(-32000);
1378 }
1379
1380 const closeError = yield* runtime.closeSession().pipe(Effect.flip);
1381 expect(closeError._tag).toBe("AcpRequestError");
1382 if (closeError._tag === "AcpRequestError") {
1383 expect(closeError.code).toBe(-32000);
1384 }
1385 }).pipe(
1386 Effect.provide(
1387 AcpSessionRuntime.layer({
1388 spawn: {
1389 command: mockAgentCommand,
1390 args: mockAgentArgs,
1391 env: {
1392 T3_ACP_REQUIRE_AUTH: "1",
1393 T3_ACP_SESSION_LIFECYCLE: "1",
1394 },
1395 },
1396 cwd: process.cwd(),
1397 resumeSessionId: "mock-session-1",
1398 clientInfo: { name: "t3-test", version: "0.0.0" },
1399 }),
1400 ),
1401 Effect.scoped,
1402 Effect.provide(NodeServices.layer),
1403 ),
1404 );
1405
1406 it.effect("skips no-op session config writes when the requested value is already active", () => {
1407 const requestEvents: Array<AcpSessionRuntime.AcpSessionRequestLogEvent> = [];
1408 return Effect.gen(function* () {
1409 const runtime = yield* AcpSessionRuntime.AcpSessionRuntime;
1410 yield* runtime.start();
1411
1412 yield* runtime.setConfigOption("model", "default");
1413 yield* runtime.setMode("ask");
1414
1415 expect(
1416 requestEvents.some(
1417 (event) => event.method === "session/set_config_option" && event.status === "started",
1418 ),
1419 ).toBe(false);
1420 }).pipe(
1421 Effect.provide(
1422 AcpSessionRuntime.layer({
1423 authMethodId: "test",
1424 spawn: {
1425 command: mockAgentCommand,
1426 args: mockAgentArgs,
1427 },
1428 cwd: process.cwd(),
1429 clientInfo: { name: "t3-test", version: "0.0.0" },
1430 requestLogger: (event) =>
1431 Effect.sync(() => {
1432 requestEvents.push(event);
1433 }),
1434 }),
1435 ),
1436 Effect.scoped,
1437 Effect.provide(NodeServices.layer),
1438 );
1439 });
1440
1441 it.effect("emits low-level ACP protocol logs for raw and decoded messages", () => {
1442 const protocolEvents: Array<EffectAcpProtocol.AcpProtocolLogEvent> = [];
1443 return Effect.gen(function* () {
1444 const runtime = yield* AcpSessionRuntime.AcpSessionRuntime;
1445 yield* runtime.start();
1446
1447 yield* runtime.prompt({
1448 prompt: [{ type: "text", text: "hi" }],
1449 });
1450
1451 expect(
1452 protocolEvents.some((event) => event.direction === "outgoing" && event.stage === "raw"),
1453 ).toBe(true);
1454 expect(
1455 protocolEvents.some((event) => event.direction === "outgoing" && event.stage === "decoded"),
1456 ).toBe(true);
1457 expect(
1458 protocolEvents.some((event) => event.direction === "incoming" && event.stage === "raw"),
1459 ).toBe(true);
1460 expect(
1461 protocolEvents.some((event) => event.direction === "incoming" && event.stage === "decoded"),
1462 ).toBe(true);
1463 }).pipe(
1464 Effect.provide(
1465 AcpSessionRuntime.layer({
1466 authMethodId: "test",
1467 spawn: {
1468 command: mockAgentCommand,
1469 args: mockAgentArgs,
1470 },
1471 cwd: process.cwd(),
1472 clientInfo: { name: "t3-test", version: "0.0.0" },
1473 protocolLogging: {
1474 logIncoming: true,
1475 logOutgoing: true,
1476 logger: (event) =>
1477 Effect.sync(() => {
1478 protocolEvents.push(event);
1479 }),
1480 },
1481 }),
1482 ),
1483 Effect.scoped,
1484 Effect.provide(NodeServices.layer),
1485 );
1486 });
1487
1488 it.effect("fails session startup when session/load returns an error", () =>
1489 Effect.gen(function* () {
1490 const runtime = yield* AcpSessionRuntime.AcpSessionRuntime;
1491 const error = yield* runtime.start().pipe(Effect.flip);
1492
1493 expect(error._tag).toBe("AcpRequestError");
1494 }).pipe(
1495 Effect.provide(
1496 AcpSessionRuntime.layer({
1497 authMethodId: "test",
1498 spawn: {
1499 command: mockAgentCommand,
1500 args: mockAgentArgs,
1501 env: {
1502 T3_ACP_FAIL_LOAD_SESSION: "1",
1503 },
1504 },
1505 cwd: process.cwd(),
1506 resumeSessionId: "stale-session-id",
1507 clientInfo: { name: "t3-test", version: "0.0.0" },
1508 }),
1509 ),
1510 Effect.scoped,
1511 Effect.provide(NodeServices.layer),
1512 ),
1513 );
1514
1515 it.effect("keeps active configuration after a candidate session load fails", () =>
1516 Effect.gen(function* () {
1517 const runtime = yield* AcpSessionRuntime.AcpSessionRuntime;
1518 yield* runtime.start();
1519 const before = yield* runtime.getConfigOptions;
1520
1521 const error = yield* runtime.loadSession("candidate-session").pipe(Effect.flip);
1522
1523 expect(error._tag).toBe("AcpRequestError");
1524 expect(yield* runtime.getConfigOptions).toEqual(before);
1525 expect(before.some((option) => option.id === "candidate-only")).toBe(false);
1526 }).pipe(
1527 Effect.provide(
1528 AcpSessionRuntime.layer({
1529 authMethodId: "test",
1530 spawn: {
1531 command: mockAgentCommand,
1532 args: mockAgentArgs,
1533 env: {
1534 T3_ACP_FAIL_LOAD_SESSION_AFTER_CONFIG_REPLAY: "1",
1535 },
1536 },
1537 cwd: process.cwd(),
1538 clientInfo: { name: "t3-test", version: "0.0.0" },
1539 }),
1540 ),
1541 Effect.scoped,
1542 Effect.provide(NodeServices.layer),
1543 ),
1544 );
1545
1546 it.effect("ignores session/update replay notifications during session/load", () =>
1547 Effect.gen(function* () {
1548 const runtime = yield* AcpSessionRuntime.AcpSessionRuntime;
1549 yield* runtime.start();
1550
1551 yield* runtime.prompt({
1552 prompt: [{ type: "text", text: "hi" }],
1553 });
1554 const notes = Array.from(yield* Stream.runCollect(Stream.take(runtime.getEvents(), 4)));
1555 expect(notes.map((note) => note._tag)).toEqual([
1556 "PlanUpdated",
1557 "AssistantItemStarted",
1558 "ContentDelta",
1559 "AssistantItemCompleted",
1560 ]);
1561 expect(notes.some((note) => note._tag === "ToolCallUpdated")).toBe(false);
1562 }).pipe(
1563 Effect.provide(
1564 AcpSessionRuntime.layer({
1565 authMethodId: "test",
1566 spawn: {
1567 command: mockAgentCommand,
1568 args: mockAgentArgs,
1569 env: {
1570 T3_ACP_EMIT_LOAD_REPLAY: "1",
1571 },
1572 },
1573 cwd: process.cwd(),
1574 resumeSessionId: "mock-session-1",
1575 clientInfo: { name: "t3-test", version: "0.0.0" },
1576 }),
1577 ),
1578 Effect.scoped,
1579 Effect.provide(NodeServices.layer),
1580 ),
1581 );
1582
1583 it.effect("keeps active-session updates while loading a different session", () =>
1584 Effect.gen(function* () {
1585 const loadStarted = yield* Deferred.make<void>();
1586 const runtime = yield* AcpSessionRuntime.make({
1587 authMethodId: "test",
1588 spawn: {
1589 command: mockAgentCommand,
1590 args: mockAgentArgs,
1591 env: {
1592 T3_ACP_DELAY_LOAD_SESSION_AFTER_REPLAY: "1",
1593 T3_ACP_LOAD_SESSION_DELAY_MS: "250",
1594 },
1595 },
1596 cwd: process.cwd(),
1597 sessionLoadTimeout: "2 seconds",
1598 clientInfo: { name: "t3-test", version: "0.0.0" },
1599 requestLogger: (event) =>
1600 event.method === "session/load" && event.status === "started"
1601 ? Deferred.succeed(loadStarted, undefined).pipe(Effect.asVoid)
1602 : Effect.void,
1603 });
1604 yield* runtime.start();
1605
1606 const eventsFiber = yield* Stream.runCollect(Stream.take(runtime.getEvents(), 4)).pipe(
1607 Effect.forkChild({ startImmediately: true }),
1608 );
1609 const loadFiber = yield* runtime
1610 .loadSession("mock-session-2")
1611 .pipe(Effect.forkChild({ startImmediately: true }));
1612 yield* Deferred.await(loadStarted);
1613
1614 yield* runtime.prompt({
1615 prompt: [{ type: "text", text: "active session prompt" }],
1616 });
1617 const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("2 seconds")));
1618 expect(events.map((event) => event._tag)).toEqual([
1619 "PlanUpdated",
1620 "AssistantItemStarted",
1621 "ContentDelta",
1622 "AssistantItemCompleted",
1623 ]);
1624 expect(
1625 events.some((event) => event._tag === "ContentDelta" && event.text === "hello from mock"),
1626 ).toBe(true);
1627
1628 const loaded = yield* Fiber.join(loadFiber).pipe(Effect.timeout("2 seconds"));
1629 expect(loaded.sessionId).toBe("mock-session-2");
1630 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer), TestClock.withLive),
1631 );
1632
1633 it.effect("completes session/load after replay becomes idle while its RPC stays pending", () =>
1634 Effect.gen(function* () {
1635 const runtime = yield* AcpSessionRuntime.AcpSessionRuntime;
1636 const started = yield* runtime.start().pipe(Effect.timeout("2 seconds"));
1637
1638 expect(started.sessionId).toBe("mock-session-1");
1639 expect(started.sessionSetupResult._meta).toMatchObject({
1640 t3SessionLoadReady: "replay_idle",
1641 });
1642
1643 const unexpectedReplayEvent = yield* Stream.runHead(runtime.getEvents()).pipe(
1644 Effect.timeoutOption("100 millis"),
1645 );
1646 expect(Option.isNone(unexpectedReplayEvent)).toBe(true);
1647 }).pipe(
1648 Effect.provide(
1649 AcpSessionRuntime.layer({
1650 authMethodId: "test",
1651 spawn: {
1652 command: mockAgentCommand,
1653 args: mockAgentArgs,
1654 env: {
1655 T3_ACP_HANG_LOAD_SESSION_AFTER_REPLAY: "1",
1656 T3_ACP_LOAD_SESSION_DELAY_MS: "10000",
1657 },
1658 },
1659 cwd: process.cwd(),
1660 resumeSessionId: "mock-session-1",
1661 sessionLoadReplayIdleGap: "50 millis",
1662 sessionLoadTimeout: "1 second",
1663 clientInfo: { name: "t3-test", version: "0.0.0" },
1664 }),
1665 ),
1666 Effect.scoped,
1667 Effect.provide(NodeServices.layer),
1668 TestClock.withLive,
1669 ),
1670 );
1671
1672 it.effect("completes ad-hoc loadSession after replay becomes idle while RPC stays pending", () =>
1673 Effect.gen(function* () {
1674 const runtime = yield* AcpSessionRuntime.AcpSessionRuntime;
1675 yield* runtime.start();
1676 const loaded = yield* runtime.loadSession("mock-session-1").pipe(Effect.timeout("2 seconds"));
1677
1678 expect(loaded.sessionId).toBe("mock-session-1");
1679 expect(loaded.sessionSetupResult._meta).toMatchObject({
1680 t3SessionLoadReady: "replay_idle",
1681 });
1682 }).pipe(
1683 Effect.provide(
1684 AcpSessionRuntime.layer({
1685 authMethodId: "test",
1686 spawn: {
1687 command: mockAgentCommand,
1688 args: mockAgentArgs,
1689 env: {
1690 T3_ACP_HANG_LOAD_SESSION_AFTER_REPLAY: "1",
1691 T3_ACP_LOAD_SESSION_DELAY_MS: "10000",
1692 },
1693 },
1694 cwd: process.cwd(),
1695 sessionLoadReplayIdleGap: "50 millis",
1696 sessionLoadTimeout: "1 second",
1697 clientInfo: { name: "t3-test", version: "0.0.0" },
1698 }),
1699 ),
1700 Effect.scoped,
1701 Effect.provide(NodeServices.layer),
1702 TestClock.withLive,
1703 ),
1704 );
1705
1706 it.effect("rejects invalid config option values before sending session/set_config_option", () => {
1707 const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "acp-runtime-"));
1708 const requestLogPath = NodePath.join(tempDir, "requests.ndjson");
1709 return Effect.gen(function* () {
1710 const runtime = yield* AcpSessionRuntime.AcpSessionRuntime;
1711 yield* runtime.start();
1712
1713 const error = yield* runtime.setModel("composer-2[fast=false]").pipe(Effect.flip);
1714 expect(error._tag).toBe("AcpRequestError");
1715 if (error._tag === "AcpRequestError") {
1716 expect(error.code).toBe(-32602);
1717 expect(error.message).toContain(
1718 'Invalid value "composer-2[fast=false]" for session config option "model"',
1719 );
1720 expect(error.message).toContain("composer-2[fast=true]");
1721 }
1722
1723 const recordedRequests = NodeFS.readFileSync(requestLogPath, "utf8")
1724 .trim()
1725 .split("\n")
1726 .filter((line) => line.length > 0)
1727 .map((line) => JSON.parse(line) as { method?: string; params?: { value?: unknown } });
1728 expect(
1729 recordedRequests.some(
1730 (message) =>
1731 message.method === "session/set_config_option" &&
1732 message.params?.value === "composer-2[fast=false]",
1733 ),
1734 ).toBe(false);
1735 }).pipe(
1736 Effect.provide(
1737 AcpSessionRuntime.layer({
1738 authMethodId: "test",
1739 spawn: {
1740 command: mockAgentCommand,
1741 args: mockAgentArgs,
1742 env: {
1743 T3_ACP_REQUEST_LOG_PATH: requestLogPath,
1744 },
1745 },
1746 cwd: process.cwd(),
1747 clientInfo: { name: "t3-test", version: "0.0.0" },
1748 }),
1749 ),
1750 Effect.scoped,
1751 Effect.provide(NodeServices.layer),
1752 Effect.ensuring(Effect.sync(() => NodeFS.rmSync(tempDir, { recursive: true, force: true }))),
1753 );
1754 });
1755
1756 it.effect("sends configured custom models the agent does not advertise", () => {
1757 const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "acp-runtime-"));
1758 const requestLogPath = NodePath.join(tempDir, "requests.ndjson");
1759 return Effect.gen(function* () {
1760 const runtime = yield* AcpSessionRuntime.AcpSessionRuntime;
1761 yield* runtime.start();
1762
1763 yield* runtime.setModel("cline-free/custom-model");
1764 const error = yield* runtime.setModel("unconfigured/model").pipe(Effect.flip);
1765 expect(error._tag).toBe("AcpRequestError");
1766
1767 const sentModels = NodeFS.readFileSync(requestLogPath, "utf8")
1768 .trim()
1769 .split("\n")
1770 .filter((line) => line.length > 0)
1771 .map((line) => JSON.parse(line) as { method?: string; params?: { value?: unknown } })
1772 .filter((message) => message.method === "session/set_config_option")
1773 .map((message) => message.params?.value);
1774 expect(sentModels).toEqual(["cline-free/custom-model"]);
1775 }).pipe(
1776 Effect.provide(
1777 AcpSessionRuntime.layer({
1778 ...mockRuntimeOptions,
1779 spawn: {
1780 command: mockAgentCommand,
1781 args: mockAgentArgs,
1782 env: { T3_ACP_REQUEST_LOG_PATH: requestLogPath },
1783 },
1784 customModels: ["cline-free/custom-model"],
1785 }),
1786 ),
1787 Effect.scoped,
1788 Effect.provide(NodeServices.layer),
1789 Effect.ensuring(Effect.sync(() => NodeFS.rmSync(tempDir, { recursive: true, force: true }))),
1790 );
1791 });
1792});