// @effect-diagnostics nodeBuiltinImport:off import * as path from "node:path"; import * as os from "node:os"; import { fileURLToPath } from "node:url"; import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Stream from "effect/Stream"; import { describe, expect } from "vitest"; import { AcpSessionRuntime, type AcpSessionRequestLogEvent } from "./AcpSessionRuntime.ts"; import type * as EffectAcpProtocol from "effect-acp/protocol"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.ts"); const bunExe = "bun"; describe("AcpSessionRuntime", () => { it.effect("merges custom initialize client capabilities into the ACP handshake", () => { const requestEvents: Array = []; return Effect.gen(function* () { const runtime = yield* AcpSessionRuntime; yield* runtime.start(); const initializeStarted = requestEvents.find( (event) => event.method === "initialize" && event.status === "started", ); expect(initializeStarted?.payload).toMatchObject({ protocolVersion: 1, clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false, _meta: { parameterizedModelPicker: true }, }, }); }).pipe( Effect.provide( AcpSessionRuntime.layer({ spawn: { command: bunExe, args: [mockAgentPath], }, cwd: process.cwd(), clientCapabilities: { _meta: { parameterizedModelPicker: true, }, }, clientInfo: { name: "t3-test", version: "0.0.0" }, authMethodId: "test", requestLogger: (event) => Effect.sync(() => { requestEvents.push(event); }), }), ), Effect.scoped, Effect.provide(NodeServices.layer), ); }); it.effect("starts a session, prompts, and emits normalized events against the mock agent", () => Effect.gen(function* () { const runtime = yield* AcpSessionRuntime; const started = yield* runtime.start(); expect(started.initializeResult).toMatchObject({ protocolVersion: 1 }); expect(started.sessionId).toBe("mock-session-1"); const promptResult = yield* runtime.prompt({ prompt: [{ type: "text", text: "hi" }], }); expect(promptResult).toMatchObject({ stopReason: "end_turn" }); const notes = Array.from(yield* Stream.runCollect(Stream.take(runtime.getEvents(), 4))); expect(notes).toHaveLength(4); expect(notes.map((note) => note._tag)).toEqual([ "PlanUpdated", "AssistantItemStarted", "ContentDelta", "AssistantItemCompleted", ]); const planUpdate = notes.find((note) => note._tag === "PlanUpdated"); expect(planUpdate?._tag).toBe("PlanUpdated"); if (planUpdate?._tag === "PlanUpdated") { expect(planUpdate.payload.plan).toHaveLength(2); } const assistantStart = notes[1]; const assistantDelta = notes[2]; if ( assistantStart?._tag === "AssistantItemStarted" && assistantDelta?._tag === "ContentDelta" ) { expect(assistantDelta.itemId).toBe(assistantStart.itemId); } }).pipe( Effect.provide( AcpSessionRuntime.layer({ spawn: { command: bunExe, args: [mockAgentPath], }, cwd: process.cwd(), clientInfo: { name: "t3-test", version: "0.0.0" }, authMethodId: "test", }), ), Effect.scoped, Effect.provide(NodeServices.layer), ), ); it.effect("segments assistant text around ACP tool calls", () => Effect.gen(function* () { const runtime = yield* AcpSessionRuntime; yield* runtime.start(); const promptResult = yield* runtime.prompt({ prompt: [{ type: "text", text: "hi" }], }); expect(promptResult).toMatchObject({ stopReason: "end_turn" }); const notes = Array.from(yield* Stream.runCollect(Stream.take(runtime.getEvents(), 7))); expect(notes.map((note) => note._tag)).toEqual([ "AssistantItemStarted", "ContentDelta", "AssistantItemCompleted", "ToolCallUpdated", "ToolCallUpdated", "AssistantItemStarted", "ContentDelta", ]); const firstStarted = notes[0]; const firstDelta = notes[1]; const firstCompleted = notes[2]; const secondStarted = notes[5]; const secondDelta = notes[6]; expect(firstStarted?._tag).toBe("AssistantItemStarted"); expect(firstCompleted?._tag).toBe("AssistantItemCompleted"); expect(secondStarted?._tag).toBe("AssistantItemStarted"); if ( firstStarted?._tag === "AssistantItemStarted" && firstDelta?._tag === "ContentDelta" && firstCompleted?._tag === "AssistantItemCompleted" && secondStarted?._tag === "AssistantItemStarted" && secondDelta?._tag === "ContentDelta" ) { expect(firstDelta.itemId).toBe(firstStarted.itemId); expect(firstCompleted.itemId).toBe(firstStarted.itemId); expect(secondStarted.itemId).not.toBe(firstStarted.itemId); expect(secondDelta.itemId).toBe(secondStarted.itemId); } }).pipe( Effect.provide( AcpSessionRuntime.layer({ spawn: { command: bunExe, args: [mockAgentPath], env: { T3_ACP_EMIT_INTERLEAVED_ASSISTANT_TOOL_CALLS: "1", }, }, cwd: process.cwd(), clientInfo: { name: "t3-test", version: "0.0.0" }, authMethodId: "test", }), ), Effect.scoped, Effect.provide(NodeServices.layer), ), ); it.effect("suppresses generic placeholder tool updates until completion", () => Effect.gen(function* () { const runtime = yield* AcpSessionRuntime; yield* runtime.start(); const promptResult = yield* runtime.prompt({ prompt: [{ type: "text", text: "hi" }], }); expect(promptResult).toMatchObject({ stopReason: "end_turn" }); const notes = Array.from(yield* Stream.runCollect(Stream.take(runtime.getEvents(), 1))); expect(notes.map((note) => note._tag)).toEqual(["ToolCallUpdated"]); const toolCall = notes[0]; expect(toolCall?._tag).toBe("ToolCallUpdated"); if (toolCall?._tag === "ToolCallUpdated") { expect(toolCall.toolCall.status).toBe("completed"); expect(toolCall.toolCall.title).toBe("Read file"); } }).pipe( Effect.provide( AcpSessionRuntime.layer({ spawn: { command: bunExe, args: [mockAgentPath], env: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1", }, }, cwd: process.cwd(), clientInfo: { name: "t3-test", version: "0.0.0" }, authMethodId: "test", }), ), Effect.scoped, Effect.provide(NodeServices.layer), ), ); it.effect("logs ACP requests from the shared runtime", () => { const requestEvents: Array = []; return Effect.gen(function* () { const runtime = yield* AcpSessionRuntime; yield* runtime.start(); yield* runtime.setModel("composer-2"); yield* runtime.prompt({ prompt: [{ type: "text", text: "hi" }], }); expect( requestEvents.some( (event) => event.method === "session/set_config_option" && event.status === "started", ), ).toBe(true); expect( requestEvents.some( (event) => event.method === "session/set_config_option" && event.status === "succeeded", ), ).toBe(true); expect( requestEvents.some( (event) => event.method === "session/prompt" && event.status === "started", ), ).toBe(true); expect( requestEvents.some( (event) => event.method === "session/prompt" && event.status === "succeeded", ), ).toBe(true); }).pipe( Effect.provide( AcpSessionRuntime.layer({ authMethodId: "test", spawn: { command: bunExe, args: [mockAgentPath], }, cwd: process.cwd(), clientInfo: { name: "t3-test", version: "0.0.0" }, requestLogger: (event) => Effect.sync(() => { requestEvents.push(event); }), }), ), Effect.scoped, Effect.provide(NodeServices.layer), ); }); it.effect("skips no-op session config writes when the requested value is already active", () => { const requestEvents: Array = []; return Effect.gen(function* () { const runtime = yield* AcpSessionRuntime; yield* runtime.start(); yield* runtime.setConfigOption("model", "default"); yield* runtime.setMode("ask"); expect( requestEvents.some( (event) => event.method === "session/set_config_option" && event.status === "started", ), ).toBe(false); }).pipe( Effect.provide( AcpSessionRuntime.layer({ authMethodId: "test", spawn: { command: bunExe, args: [mockAgentPath], }, cwd: process.cwd(), clientInfo: { name: "t3-test", version: "0.0.0" }, requestLogger: (event) => Effect.sync(() => { requestEvents.push(event); }), }), ), Effect.scoped, Effect.provide(NodeServices.layer), ); }); it.effect("emits low-level ACP protocol logs for raw and decoded messages", () => { const protocolEvents: Array = []; return Effect.gen(function* () { const runtime = yield* AcpSessionRuntime; yield* runtime.start(); yield* runtime.prompt({ prompt: [{ type: "text", text: "hi" }], }); expect( protocolEvents.some((event) => event.direction === "outgoing" && event.stage === "raw"), ).toBe(true); expect( protocolEvents.some((event) => event.direction === "outgoing" && event.stage === "decoded"), ).toBe(true); expect( protocolEvents.some((event) => event.direction === "incoming" && event.stage === "raw"), ).toBe(true); expect( protocolEvents.some((event) => event.direction === "incoming" && event.stage === "decoded"), ).toBe(true); }).pipe( Effect.provide( AcpSessionRuntime.layer({ authMethodId: "test", spawn: { command: bunExe, args: [mockAgentPath], }, cwd: process.cwd(), clientInfo: { name: "t3-test", version: "0.0.0" }, protocolLogging: { logIncoming: true, logOutgoing: true, logger: (event) => Effect.sync(() => { protocolEvents.push(event); }), }, }), ), Effect.scoped, Effect.provide(NodeServices.layer), ); }); it.effect("rejects invalid config option values before sending session/set_config_option", () => { const tempDir = mkdtempSync(path.join(os.tmpdir(), "acp-runtime-")); const requestLogPath = path.join(tempDir, "requests.ndjson"); return Effect.gen(function* () { const runtime = yield* AcpSessionRuntime; yield* runtime.start(); const error = yield* runtime.setModel("composer-2[fast=false]").pipe(Effect.flip); expect(error._tag).toBe("AcpRequestError"); if (error._tag === "AcpRequestError") { expect(error.code).toBe(-32602); expect(error.message).toContain( 'Invalid value "composer-2[fast=false]" for session config option "model"', ); expect(error.message).toContain("composer-2[fast=true]"); } const recordedRequests = readFileSync(requestLogPath, "utf8") .trim() .split("\n") .filter((line) => line.length > 0) .map((line) => JSON.parse(line) as { method?: string; params?: { value?: unknown } }); expect( recordedRequests.some( (message) => message.method === "session/set_config_option" && message.params?.value === "composer-2[fast=false]", ), ).toBe(false); }).pipe( Effect.provide( AcpSessionRuntime.layer({ authMethodId: "test", spawn: { command: bunExe, args: [mockAgentPath], env: { T3_ACP_REQUEST_LOG_PATH: requestLogPath, }, }, cwd: process.cwd(), clientInfo: { name: "t3-test", version: "0.0.0" }, }), ), Effect.scoped, Effect.provide(NodeServices.layer), Effect.ensuring(Effect.sync(() => rmSync(tempDir, { recursive: true, force: true }))), ); }); });