diff --git a/apps/server/package.json b/apps/server/package.json index de20a01de3b9ce3f30514cded1e4902c132fdd0a..5e731012c583a1be73dc1d890d53368580f7d8bd 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -38,6 +38,7 @@ "@napi-rs/keyring": "^1.3.0", "@noble/hashes": "catalog:", "@opencode-ai/sdk": "^1.3.15", + "@t3tools/provider-acp": "workspace:*", "@t3tools/provider-core": "workspace:*", "@t3tools/provider-cursor": "workspace:*", "@t3tools/provider-muse": "workspace:*", diff --git a/apps/server/scripts/record-grok-acp-replay-fixture.ts b/apps/server/scripts/record-grok-acp-replay-fixture.ts index 23d687eb91309e98a9efab764f43173465ff2f98..30db5e0ef85a7feb95ba5768364bb08bb32d641a 100644 --- a/apps/server/scripts/record-grok-acp-replay-fixture.ts +++ b/apps/server/scripts/record-grok-acp-replay-fixture.ts @@ -24,14 +24,15 @@ import * as Schema from "effect/Schema"; import { ChildProcessSpawner } from "effect/process"; import type * as EffectAcpProtocol from "effect-acp/protocol"; -import * as ServerConfig from "../src/config.ts"; +import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; +import { layerTestProviderHost } from "@t3tools/provider-testing/host"; import { GROK_DEFAULT_INSTANCE_ID, GROK_PROVIDER, grokLaunchRuntimeMode, makeGrokAdapterV2, } from "../src/orchestration-v2/Adapters/GrokAdapterV2.ts"; -import { ACP_PROTOCOL } from "../src/orchestration-v2/Adapters/AcpAdapterV2.ts"; +import { ACP_PROTOCOL } from "@t3tools/provider-acp/server/adapter"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import type { ProviderAdapterV2SessionRuntime } from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; @@ -40,7 +41,6 @@ import { provideDeterministicTestRuntime } from "../src/orchestration-v2/testkit import { ORCHESTRATOR_REPLAY_FIXTURES } from "../src/orchestration-v2/testkit/fixtures/index.ts"; import { materializeFixtureInput } from "../src/orchestration-v2/testkit/fixtures/shared.ts"; import { runOrchestratorV2Scenario } from "../src/orchestration-v2/testkit/OrchestratorScenario.ts"; -import { makeReplayServerConfig } from "../src/orchestration-v2/testkit/ProviderReplayHarness.ts"; import * as ProviderReplayHarness from "../src/orchestration-v2/testkit/ProviderReplayHarness.ts"; import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace"; import { makeGrokAcpRuntime } from "../src/provider/acp/GrokAcpSupport.ts"; @@ -455,7 +455,7 @@ const recordScenario = Effect.fn("recordGrokScenario")(function* (fixtureName: s crypto: yield* Crypto.Crypto, fileSystem: yield* FileSystem.FileSystem, idAllocator: yield* IdAllocator.IdAllocatorV2, - serverConfig: yield* ServerConfig.ServerConfig, + host: yield* ProviderHost.ProviderHost, selfInvocation: yield* resolveSelfInvocation(), continuationRequests: yield* ProviderContinuationRequests.ProviderContinuationRequests, // Production's runtime factory, with the protocol logger teeing raw lines. @@ -496,10 +496,7 @@ const recordScenario = Effect.fn("recordGrokScenario")(function* (fixtureName: s ).pipe( Layer.provide( Layer.mergeAll( - Layer.effect( - ServerConfig.ServerConfig, - makeReplayServerConfig(`grok-record-${fixtureName}`), - ).pipe(Layer.provide(NodeServices.layer)), + layerTestProviderHost().pipe(Layer.provide(NodeServices.layer)), NodeServices.layer, IdAllocator.layer, ), diff --git a/apps/server/src/mcp/AcpMcpOverAcpBridge.test.ts b/apps/server/src/mcp/AcpMcpOverAcpBridge.test.ts deleted file mode 100644 index a564a711a2f638ea247bb9d10790724d4b6984b6..0000000000000000000000000000000000000000 --- a/apps/server/src/mcp/AcpMcpOverAcpBridge.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -// The bridge intentionally treats MCP JSON-RPC messages as opaque JSON. -import { describe, expect, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; - -import { makeAcpMcpOverAcpBridge } from "./AcpMcpOverAcpBridge.ts"; - -describe("AcpMcpOverAcpBridge", () => { - it.effect("forwards authenticated MCP requests and closes the negotiated session", () => - Effect.gen(function* () { - const requests: Array<{ - readonly method: string; - readonly headers: Headers; - readonly body: unknown; - }> = []; - const bridge = yield* makeAcpMcpOverAcpBridge({ - endpoint: "http://127.0.0.1:1/mcp", - authorization: "Bearer bridge-test", - allocateConnectionId: Effect.succeed("connection-1"), - fetchImplementation: async (_url, init) => { - const method = init?.method ?? "GET"; - const headers = new Headers(init?.headers); - const body = init?.body === undefined ? null : JSON.parse(String(init.body)); - requests.push({ method, headers, body }); - if (method === "DELETE") return new Response(null, { status: 204 }); - if (body === null || typeof body !== "object") return new Response(null, { status: 202 }); - const request = body as { readonly id?: unknown; readonly method?: unknown }; - if (request.method === "notifications/initialized") { - return new Response(null, { status: 202 }); - } - return new Response( - JSON.stringify({ - jsonrpc: "2.0", - id: request.id, - result: - request.method === "initialize" - ? { protocolVersion: "2025-06-18", capabilities: {}, serverInfo: {} } - : { tools: [] }, - }), - { - headers: { "content-type": "application/json", "mcp-session-id": "session-42" }, - }, - ); - }, - }); - - const connected = yield* bridge.connect({ serverId: "t3-code" }); - expect(connected).toEqual({ connectionId: "connection-1" }); - expect( - yield* bridge.message({ connectionId: connected.connectionId, method: "initialize" }), - ).toMatchObject({ protocolVersion: "2025-06-18" }); - expect( - yield* bridge.message({ connectionId: connected.connectionId, method: "tools/list" }), - ).toEqual({ tools: [] }); - yield* bridge.notification({ - connectionId: connected.connectionId, - method: "notifications/initialized", - }); - yield* bridge.disconnect({ connectionId: connected.connectionId }); - - expect(requests[0]?.headers.get("authorization")).toBe("Bearer bridge-test"); - expect(requests[1]?.headers.get("mcp-session-id")).toBe("session-42"); - expect(requests[1]?.headers.get("mcp-protocol-version")).toBe("2025-06-18"); - expect(requests.at(-1)?.method).toBe("DELETE"); - expect(requests.at(-1)?.headers.get("mcp-session-id")).toBe("session-42"); - }), - ); - - it.effect("aborts an in-flight HTTP request when the ACP call is interrupted", () => - Effect.gen(function* () { - const started = Promise.withResolvers(); - const bridge = yield* makeAcpMcpOverAcpBridge({ - endpoint: "http://127.0.0.1:1/mcp", - authorization: "Bearer bridge-test", - allocateConnectionId: Effect.succeed("connection-1"), - fetchImplementation: (_url, init) => { - if (!init?.signal) throw new Error("Missing cancellation signal"); - started.resolve(init.signal); - return new Promise(() => {}); - }, - }); - const connected = yield* bridge.connect({ serverId: "t3-code" }); - const request = yield* bridge - .message({ connectionId: connected.connectionId, method: "tools/list" }) - .pipe(Effect.forkChild); - const signal = yield* Effect.promise(() => started.promise); - yield* Fiber.interrupt(request); - expect(signal.aborted).toBe(true); - }).pipe(Effect.scoped), - ); - - it.effect("rejects unknown servers, connections, and oversized messages", () => - Effect.gen(function* () { - const bridge = yield* makeAcpMcpOverAcpBridge({ - endpoint: "http://127.0.0.1:1/mcp", - authorization: "Bearer bridge-test", - allocateConnectionId: Effect.succeed("connection-1"), - fetchImplementation: () => Promise.resolve(new Response(null, { status: 202 })), - }); - - expect(yield* bridge.connect({ serverId: "other" }).pipe(Effect.flip)).toMatchObject({ - _tag: "AcpMcpOverAcpError", - }); - expect( - yield* bridge.message({ connectionId: "missing", method: "tools/list" }).pipe(Effect.flip), - ).toMatchObject({ _tag: "AcpMcpOverAcpError" }); - - const connected = yield* bridge.connect({ serverId: "t3-code" }); - const failure = yield* bridge - .message({ - connectionId: connected.connectionId, - method: "tools/call", - params: { payload: "x".repeat(8 * 1024 * 1024) }, - }) - .pipe(Effect.flip); - expect(failure.message).toContain("exceeds 8 MiB"); - }), - ); -}); diff --git a/apps/server/src/mcp/AcpMcpOverAcpBridge.ts b/apps/server/src/mcp/AcpMcpOverAcpBridge.ts deleted file mode 100644 index a5185e2515ecde86137c6f73e9177af9e5076694..0000000000000000000000000000000000000000 --- a/apps/server/src/mcp/AcpMcpOverAcpBridge.ts +++ /dev/null @@ -1,241 +0,0 @@ -// The bridge forwards opaque JSON-RPC values without interpreting their schemas. -// @effect-diagnostics preferSchemaOverJson:off -import * as Effect from "effect/Effect"; -import * as Semaphore from "effect/Semaphore"; -import * as Stream from "effect/Stream"; -import type * as AcpSchema from "effect-acp/compat"; - -import { responsePayloads } from "./AcpMcpStdioBridge.ts"; - -const MAX_CONNECTIONS = 16; -const MAX_MESSAGE_BYTES = 8 * 1024 * 1024; - -interface JsonRpcEnvelope { - readonly id?: unknown; - readonly method?: unknown; - readonly error?: unknown; - readonly result?: unknown; -} - -interface Connection { - readonly mutex: Semaphore.Semaphore; - sessionId: string | null; - protocolVersion: string | null; - nextRequestId: number; -} - -export class AcpMcpOverAcpError extends Error { - readonly _tag = "AcpMcpOverAcpError"; - override readonly name = "AcpMcpOverAcpError"; -} - -const bridgeError = (cause: unknown): AcpMcpOverAcpError => - cause instanceof AcpMcpOverAcpError - ? cause - : new AcpMcpOverAcpError(cause instanceof Error ? cause.message : String(cause), { cause }); - -const asEnvelope = (value: unknown): JsonRpcEnvelope | null => - typeof value === "object" && value !== null && !Array.isArray(value) - ? (value as JsonRpcEnvelope) - : null; - -function protocolVersionOf(payload: unknown): string | null { - const result = asEnvelope(payload)?.result; - if (typeof result !== "object" || result === null || Array.isArray(result)) return null; - const protocolVersion = (result as { readonly protocolVersion?: unknown }).protocolVersion; - return typeof protocolVersion === "string" && protocolVersion.length > 0 ? protocolVersion : null; -} - -function errorMessage(error: unknown): string { - if (typeof error !== "object" || error === null || Array.isArray(error)) { - return "The MCP server returned an error."; - } - const message = (error as { readonly message?: unknown }).message; - return typeof message === "string" && message.length > 0 - ? message - : "The MCP server returned an error."; -} - -export interface AcpMcpOverAcpBridgeOptions { - readonly endpoint: string; - readonly authorization: string; - readonly allocateConnectionId: Effect.Effect; - readonly fetchImplementation?: (url: string, init?: RequestInit) => Promise; -} - -export interface AcpMcpOverAcpBridge { - readonly connect: ( - request: AcpSchema.ConnectMcpRequest, - ) => Effect.Effect; - readonly message: ( - request: AcpSchema.MessageMcpRequest, - ) => Effect.Effect; - readonly notification: ( - request: AcpSchema.MessageMcpNotification, - ) => Effect.Effect; - readonly disconnect: ( - request: AcpSchema.DisconnectMcpRequest, - ) => Effect.Effect; - readonly dispose: Effect.Effect; -} - -/** Bridges the unstable ACP transport to T3's authenticated streamable-HTTP MCP endpoint. */ -export const makeAcpMcpOverAcpBridge = Effect.fn("AcpMcpOverAcpBridge.make")(function* ( - options: AcpMcpOverAcpBridgeOptions, -): Effect.fn.Return { - const fetchImplementation = options.fetchImplementation ?? fetch; - const connections = new Map(); - - const connectionFor = (connectionId: string): Effect.Effect => - Effect.suspend(() => { - const connection = connections.get(connectionId); - return connection === undefined - ? Effect.fail(new AcpMcpOverAcpError(`Unknown MCP-over-ACP connection "${connectionId}".`)) - : Effect.succeed(connection); - }); - - const send = ( - connection: Connection, - message: unknown, - ): Effect.Effect, AcpMcpOverAcpError> => - connection.mutex.withPermits(1)( - Effect.gen(function* () { - const body = JSON.stringify(message); - if (Buffer.byteLength(body) > MAX_MESSAGE_BYTES) { - return yield* Effect.fail(new AcpMcpOverAcpError("MCP-over-ACP message exceeds 8 MiB.")); - } - const response = yield* Effect.tryPromise({ - try: (signal) => - fetchImplementation(options.endpoint, { - method: "POST", - signal, - headers: { - "content-type": "application/json", - accept: "application/json, text/event-stream", - authorization: options.authorization, - ...(connection.sessionId === null - ? {} - : { "mcp-session-id": connection.sessionId }), - ...(connection.protocolVersion === null - ? {} - : { "mcp-protocol-version": connection.protocolVersion }), - }, - body, - }), - catch: bridgeError, - }); - connection.sessionId = response.headers.get("mcp-session-id") ?? connection.sessionId; - if (!response.ok) { - yield* Effect.promise( - () => response.body?.cancel().catch(() => undefined) ?? Promise.resolve(), - ); - return yield* Effect.fail( - new AcpMcpOverAcpError(`T3 Code MCP endpoint responded with HTTP ${response.status}.`), - ); - } - const payloads = [...(yield* Stream.runCollect(responsePayloads(response)))]; - for (const payload of payloads) { - connection.protocolVersion = protocolVersionOf(payload) ?? connection.protocolVersion; - } - return payloads; - }).pipe(Effect.mapError(bridgeError)), - ); - - const disconnect = ( - request: AcpSchema.DisconnectMcpRequest, - ): Effect.Effect => - Effect.gen(function* () { - const connection = yield* connectionFor(request.connectionId); - connections.delete(request.connectionId); - const sessionId = connection.sessionId; - if (sessionId !== null) { - const response = yield* Effect.tryPromise({ - try: (signal) => - fetchImplementation(options.endpoint, { - method: "DELETE", - signal, - headers: { - authorization: options.authorization, - "mcp-session-id": sessionId, - ...(connection.protocolVersion === null - ? {} - : { "mcp-protocol-version": connection.protocolVersion }), - }, - }), - catch: bridgeError, - }); - if (!response.ok && response.status !== 404) { - return yield* Effect.fail( - new AcpMcpOverAcpError( - `T3 Code MCP endpoint rejected disconnect with HTTP ${response.status}.`, - ), - ); - } - yield* Effect.promise( - () => response.body?.cancel().catch(() => undefined) ?? Promise.resolve(), - ); - } - return {}; - }); - - return { - connect: (request) => - Effect.gen(function* () { - if (request.serverId !== "t3-code") { - return yield* Effect.fail( - new AcpMcpOverAcpError(`Unknown ACP MCP server "${request.serverId}".`), - ); - } - if (connections.size >= MAX_CONNECTIONS) { - return yield* Effect.fail(new AcpMcpOverAcpError("Too many MCP-over-ACP connections.")); - } - const connectionId = yield* options.allocateConnectionId; - connections.set(connectionId, { - mutex: yield* Semaphore.make(1), - sessionId: null, - protocolVersion: null, - nextRequestId: 0, - }); - return { connectionId }; - }), - message: (request) => - Effect.gen(function* () { - const connection = yield* connectionFor(request.connectionId); - const id = ++connection.nextRequestId; - const payloads = yield* send(connection, { - jsonrpc: "2.0", - id, - method: request.method, - ...(request.params == null ? {} : { params: request.params }), - }); - const response = payloads.find((payload) => asEnvelope(payload)?.id === id); - const envelope = asEnvelope(response); - if (envelope === null) { - return yield* Effect.fail( - new AcpMcpOverAcpError("MCP server did not return a matching response."), - ); - } - if (envelope.error !== undefined) { - return yield* Effect.fail(new AcpMcpOverAcpError(errorMessage(envelope.error))); - } - return (envelope.result ?? null) as AcpSchema.MessageMcpResponse; - }), - notification: (request) => - Effect.gen(function* () { - const connection = yield* connectionFor(request.connectionId); - yield* send(connection, { - jsonrpc: "2.0", - method: request.method, - ...(request.params == null ? {} : { params: request.params }), - }); - }), - disconnect, - dispose: Effect.suspend(() => - Effect.forEach( - [...connections.keys()], - (connectionId) => disconnect({ connectionId }).pipe(Effect.ignore), - { discard: true }, - ), - ), - }; -}); diff --git a/apps/server/src/mcp/AcpMcpStdioBridge.ts b/apps/server/src/mcp/AcpMcpStdioBridge.ts index 5c7bfe6889d5545798e0aa6a9b581fef4ac21555..02e6cdeac60799f30d30abb3a54061ce8e85ce43 100644 --- a/apps/server/src/mcp/AcpMcpStdioBridge.ts +++ b/apps/server/src/mcp/AcpMcpStdioBridge.ts @@ -7,6 +7,7 @@ import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Stream from "effect/Stream"; +import { discardResponseBody, responsePayloads } from "@t3tools/shared/mcpResponsePayloads"; /** * Stdio-to-HTTP bridge for T3's MCP endpoint. @@ -63,58 +64,6 @@ function protocolVersionOf(entry: unknown): string | null { return typeof version === "string" && version.length > 0 ? version : null; } -async function* sseDataLines(response: Response): AsyncGenerator { - if (response.body === null) return; - const decoder = new TextDecoder(); - let buffered = ""; - for await (const chunk of response.body) { - buffered += decoder.decode(chunk as Uint8Array, { stream: true }); - let separatorIndex = buffered.search(/\n\n|\r\n\r\n/u); - while (separatorIndex !== -1) { - const rawEvent = buffered.slice(0, separatorIndex); - buffered = buffered.slice(separatorIndex).replace(/^(?:\r?\n){2}/u, ""); - const data = rawEvent - .split(/\r?\n/u) - .filter((line) => line.startsWith("data:")) - .map((line) => line.slice("data:".length).trimStart()) - .join("\n"); - if (data.length > 0) yield data; - separatorIndex = buffered.search(/\n\n|\r\n\r\n/u); - } - } -} - -const discardResponseBody = (response: Response): Effect.Effect => - Effect.promise(() => response.body?.cancel().catch(() => undefined) ?? Promise.resolve()); - -/** - * Every JSON-RPC payload carried by a response, in arrival order: nothing for - * notification acknowledgements, each SSE `data:` event as it streams in, or - * the single JSON body. - */ -export function responsePayloads(response: Response): Stream.Stream { - if (response.status === 202 || response.status === 204) { - return Stream.unwrap(discardResponseBody(response).pipe(Effect.as(Stream.empty))); - } - const contentType = response.headers.get("content-type") ?? ""; - if (contentType.includes("text/event-stream")) { - return Stream.fromAsyncIterable(sseDataLines(response), bridgeError).pipe( - Stream.mapEffect((data) => Effect.try({ try: () => JSON.parse(data), catch: bridgeError })), - ); - } - return Stream.unwrap( - Effect.tryPromise({ try: () => response.text(), catch: bridgeError }).pipe( - Effect.flatMap((text) => - text.trim().length === 0 - ? Effect.succeed(Stream.empty) - : Effect.try({ try: () => JSON.parse(text) as unknown, catch: bridgeError }).pipe( - Effect.map((payload) => Stream.fromIterable([payload])), - ), - ), - ), - ); -} - export interface AcpMcpToolCallOptions { readonly endpoint: string; readonly authorization: string; @@ -164,7 +113,7 @@ export function callAcpMcpTool( new AcpMcpBridgeError(`T3 Code MCP endpoint responded with HTTP ${response.status}.`), ); } - const payloads = yield* Stream.runCollect(responsePayloads(response)); + const payloads = yield* Stream.runCollect(responsePayloads(response, bridgeError)); for (const payload of payloads) { protocolVersion = protocolVersionOf(payload) ?? protocolVersion; } @@ -271,7 +220,7 @@ export function runAcpMcpStdioBridge(options: AcpMcpStdioBridgeOptions): Effect. } return yield* discardResponseBody(response); } - yield* Stream.runForEach(responsePayloads(response), handleServerPayload); + yield* Stream.runForEach(responsePayloads(response, bridgeError), handleServerPayload); }).pipe( Effect.catchCause((cause) => { if (envelope.id === undefined) return Effect.void; diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index c908265271764f7ea0b7d32d7c95c23c5ad05723..5e225d570d7a4f79bcc09f92efbf4d9e2c84faee 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -54,9 +54,10 @@ import * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpProtocol from "effect-acp/protocol"; import type * as EffectAcpSchema from "effect-acp/compat"; -import * as ServerConfig from "../../config.ts"; +import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; +import { layerTestProviderHost } from "@t3tools/provider-testing/host"; import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; -import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts"; +import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; import { extractXAiAcpSubagentEndNotice, extractXAiAcpSubagentUpdate, @@ -90,7 +91,7 @@ import { type AcpAdapterV2Flavor, type AcpAdapterV2RuntimeInput, type AcpAdapterV2SubagentUpdate, -} from "./AcpAdapterV2.ts"; +} from "@t3tools/provider-acp/server/adapter"; import { makeGrokAdapterV2 } from "./GrokAdapterV2.ts"; import { @@ -100,11 +101,9 @@ import { const DEFAULT_GROK_SETTINGS = Schema.decodeSync(GrokSettings)({}); -const layerServerConfig = ServerConfig.layerTest(process.cwd(), { - prefix: "t3-acp-v2-adapter-", -}).pipe(Layer.provide(NodeServices.layer)); +const layerHost = layerTestProviderHost().pipe(Layer.provide(NodeServices.layer)); -const layerTest = Layer.mergeAll(NodeServices.layer, IdAllocator.layer, layerServerConfig); +const layerTest = Layer.mergeAll(NodeServices.layer, IdAllocator.layer, layerHost); const ACP_TEST_DRIVER = ProviderDriverKind.make("acp-test"); const decodeUnknownJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)); @@ -616,7 +615,7 @@ describe("AcpAdapterV2", () => { instanceId, fileSystem: yield* FileSystem.FileSystem, idAllocator: yield* IdAllocator.IdAllocatorV2, - serverConfig: yield* ServerConfig.ServerConfig, + host: yield* ProviderHost.ProviderHost, selfInvocation: yield* resolveSelfInvocation(), flavor: { driver: ProviderDriverKind.make("acpRegistry"), @@ -713,7 +712,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation().pipe( Effect.provideService(HostProcessIsExecutable, true), ); @@ -753,7 +752,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ @@ -786,7 +785,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -816,7 +815,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const policy = (interactionMode: "default" | "plan") => @@ -913,7 +912,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -941,7 +940,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-v2-plan-replay-boundary"); @@ -1049,7 +1048,7 @@ describe("AcpAdapterV2", () => { crypto: yield* Crypto.Crypto, fileSystem: yield* FileSystem.FileSystem, idAllocator: yield* IdAllocator.IdAllocatorV2, - serverConfig: yield* ServerConfig.ServerConfig, + host: yield* ProviderHost.ProviderHost, selfInvocation: yield* resolveSelfInvocation(), flavor: { driver: ACP_TEST_DRIVER, @@ -1195,7 +1194,7 @@ describe("AcpAdapterV2", () => { crypto: yield* Crypto.Crypto, fileSystem: yield* FileSystem.FileSystem, idAllocator, - serverConfig: yield* ServerConfig.ServerConfig, + host: yield* ProviderHost.ProviderHost, selfInvocation: yield* resolveSelfInvocation(), flavor: { driver: ACP_TEST_DRIVER, @@ -1515,7 +1514,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -1544,7 +1543,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-v2-fidelity"); @@ -1709,7 +1708,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -1730,7 +1729,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-eager-resume"); @@ -1794,7 +1793,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -1817,7 +1816,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-stale-eager-resume"); @@ -1940,7 +1939,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -1969,7 +1968,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-unexpected-termination"); @@ -2023,7 +2022,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -2058,7 +2057,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-provider-exit-running-command"); @@ -2108,7 +2107,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -2137,7 +2136,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-cgroup-unavailable"); @@ -2164,7 +2163,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -2209,7 +2208,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-cgroup-join-failure"); @@ -2242,7 +2241,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -2286,7 +2285,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, clientTerminals: { childProcessSpawner }, }); @@ -2454,7 +2453,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -2484,7 +2483,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-no-client-fs"); @@ -2551,7 +2550,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -2591,7 +2590,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, clientTerminals: { childProcessSpawner }, }); @@ -2688,7 +2687,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -2704,7 +2703,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-missing-native-thread"); @@ -2750,7 +2749,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -2788,7 +2787,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-rollback-session"); @@ -2917,7 +2916,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -3001,7 +3000,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, continuationRequests: { offer: () => Effect.void }, }); @@ -3078,7 +3077,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -3120,7 +3119,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-rollback-failure-compensation"); @@ -3194,7 +3193,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -3211,7 +3210,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-idle-finalizer"); @@ -3245,7 +3244,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -3261,7 +3260,7 @@ describe("AcpAdapterV2", () => { crypto: yield* Crypto.Crypto, fileSystem, idAllocator, - serverConfig, + host, selfInvocation: yield* resolveSelfInvocation(), // Production Grok runtimes are wrapped by the x.ai prompt runtime. makeRuntime: (input) => @@ -3300,7 +3299,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -3316,7 +3315,7 @@ describe("AcpAdapterV2", () => { crypto: yield* Crypto.Crypto, fileSystem, idAllocator, - serverConfig, + host, selfInvocation: yield* resolveSelfInvocation(), // Production Grok runtimes are wrapped by the x.ai prompt runtime. makeRuntime: (input) => @@ -3381,7 +3380,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -3397,7 +3396,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-unsupported-option"); @@ -3430,7 +3429,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -3447,7 +3446,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const firstThreadId = ThreadId.make("thread-acp-active-setup:first"); @@ -3541,7 +3540,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -3568,7 +3567,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-empty-successful-bash"); @@ -3688,7 +3687,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const protocolEvents = yield* Queue.unbounded(); const native: { current?: AcpSessionRuntime.AcpSessionRuntime["Service"] } = {}; @@ -3715,7 +3714,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-native-cancel"); @@ -3802,7 +3801,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -3824,7 +3823,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-cancel-permission"); @@ -3903,7 +3902,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -3930,7 +3929,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-response-wins-permission"); @@ -4002,7 +4001,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -4029,7 +4028,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-reordered-elicitation"); @@ -4091,7 +4090,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -4111,7 +4110,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-mcp-approval-elicitation"); @@ -4159,7 +4158,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -4187,7 +4186,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, testHooks: { onNativeResponseLifecycle: (event) => @@ -4267,7 +4266,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -4292,7 +4291,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, testHooks: { afterNativeResponseTransportClosed: () => @@ -4381,7 +4380,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -4426,7 +4425,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, testHooks: { onNativeResponseLifecycle: (event) => @@ -4522,7 +4521,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -4564,7 +4563,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-pending-response-cancel"); @@ -4638,7 +4637,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -4679,7 +4678,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make(`thread-acp-immediate-permission-${name}`); @@ -4743,7 +4742,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -4778,7 +4777,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-immediate-url-elicitation"); @@ -4836,7 +4835,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -4875,7 +4874,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-missing-response-ack"); @@ -4929,7 +4928,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -4964,7 +4963,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-teardown-wins-elicitation"); @@ -5042,7 +5041,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -5064,7 +5063,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-cancel-timeout"); @@ -5145,7 +5144,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -5171,7 +5170,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-double-stop"); @@ -5243,7 +5242,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -5281,7 +5280,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-interrupt-background-hold"); @@ -5339,7 +5338,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -5391,7 +5390,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-subagent-carryover"); @@ -5487,7 +5486,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -5537,7 +5536,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, continuationRequests: { offer: () => Effect.void }, }); @@ -5634,7 +5633,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -5707,7 +5706,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, continuationRequests: { offer: () => Effect.void }, }); @@ -5871,7 +5870,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -5958,7 +5957,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, continuationRequests: { offer: () => Effect.void }, }); @@ -6094,7 +6093,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -6157,7 +6156,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, continuationRequests: { offer: (request) => @@ -6313,7 +6312,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -6355,7 +6354,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, continuationRequests: { offer: () => Effect.void }, }); @@ -6461,7 +6460,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -6520,7 +6519,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, continuationRequests: { offer: (request) => @@ -6713,7 +6712,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -6774,7 +6773,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, continuationRequests: { offer: (request) => @@ -7001,7 +7000,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -7072,7 +7071,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, continuationRequests: { offer: (request) => @@ -7242,7 +7241,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -7316,7 +7315,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, continuationRequests: { offer: (request) => @@ -7471,7 +7470,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -7533,7 +7532,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, continuationRequests: { offer: (request) => @@ -7742,7 +7741,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -7803,7 +7802,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, continuationRequests: { offer: (request) => @@ -7984,7 +7983,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -8053,7 +8052,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-settled-soft-steer"); @@ -8160,7 +8159,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -8192,7 +8191,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-unsettled-steer-stays-hard"); @@ -8250,7 +8249,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -8298,7 +8297,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, continuationRequests: { offer: (request) => @@ -8444,7 +8443,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -8484,7 +8483,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, continuationRequests: { offer: () => Effect.void }, }); @@ -8591,7 +8590,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -8630,7 +8629,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, continuationRequests: { offer: () => Effect.void }, }); @@ -8722,7 +8721,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -8799,7 +8798,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-stop-after-soft-steer-orphan"); @@ -9026,7 +9025,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -9087,7 +9086,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, continuationRequests: { offer: () => Effect.void }, }); @@ -9223,7 +9222,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -9298,7 +9297,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-settled-soft-admission-race"); @@ -9393,7 +9392,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -9431,7 +9430,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, continuationRequests: { offer: (request) => @@ -9524,7 +9523,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -9554,7 +9553,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, continuationRequests: { offer: (request) => @@ -9644,7 +9643,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -9690,7 +9689,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, continuationRequests: { offer: (request) => @@ -9853,7 +9852,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -9897,7 +9896,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-injected-report-hold"); @@ -10019,7 +10018,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -10078,7 +10077,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, continuationRequests: { offer: (request) => @@ -10221,7 +10220,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -10276,7 +10275,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, continuationRequests: { offer: (request) => @@ -10461,7 +10460,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -10523,7 +10522,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, continuationRequests: { offer: (request) => @@ -10680,7 +10679,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -10746,7 +10745,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, continuationRequests: { offer: (request) => @@ -10895,7 +10894,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -10983,7 +10982,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, continuationRequests: { offer: (request) => @@ -11267,7 +11266,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -11306,7 +11305,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, continuationRequests: { offer: (request) => @@ -11421,7 +11420,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -11490,7 +11489,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, continuationRequests: { offer: (request) => @@ -11752,7 +11751,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -11802,7 +11801,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, continuationRequests: { offer: (request) => @@ -11939,7 +11938,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -11993,7 +11992,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, continuationRequests: { offer: (request) => @@ -12183,7 +12182,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -12235,7 +12234,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, continuationRequests: { offer: (request) => @@ -12373,7 +12372,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -12395,7 +12394,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-restart-after-interrupt"); @@ -12619,7 +12618,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -12668,7 +12667,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, continuationRequests: { offer: (request) => @@ -12837,7 +12836,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -12861,7 +12860,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-missing-hard-teardown"); @@ -12926,7 +12925,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -12963,7 +12962,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-concurrent-hard-teardown"); @@ -13039,7 +13038,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -13122,7 +13121,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, testHooks: { afterHardTeardownTransportDrained: () => @@ -13401,7 +13400,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -13428,7 +13427,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-stale-deferred-cleanup"); @@ -13526,7 +13525,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -13563,7 +13562,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-concurrent-resume-teardown"); @@ -13660,7 +13659,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -13714,7 +13713,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, continuationRequests: { offer: () => Effect.void }, }); @@ -13937,7 +13936,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -13987,7 +13986,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-direct-stop-subagent-hold"); @@ -14091,7 +14090,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -14136,7 +14135,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-restart-active-in-process"); diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.testkit.ts index 960ac6f57f011cd369d17f3110d1684e5a5b3d44..8a4310c3d542c478131397950be2df899b4444e8 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.testkit.ts @@ -13,9 +13,9 @@ import * as Stream from "effect/Stream"; import { ChildProcessSpawner } from "effect/process"; import * as EffectAcpErrors from "effect-acp/errors"; -import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts"; +import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; import type { ProviderReplayGate } from "@t3tools/provider-testing/replayGate"; -import { ACP_PROTOCOL, type AcpAdapterV2RuntimeInput } from "./AcpAdapterV2.ts"; +import { ACP_PROTOCOL, type AcpAdapterV2RuntimeInput } from "@t3tools/provider-acp/server/adapter"; export const AcpReplayTranscript = Schema.Struct({ provider: ProviderDriverKind, diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts deleted file mode 100644 index 03a5c629b66302c3dcf3d02971a57fea2de3b6e3..0000000000000000000000000000000000000000 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts +++ /dev/null @@ -1,8062 +0,0 @@ -// @effect-diagnostics nodeBuiltinImport:off -import * as NodePath from "node:path"; - -import { - type ChatAttachment, - type ModelSelection, - type OrchestrationV2ConversationMessage, - type OrchestrationV2ExecutionNode, - type OrchestrationV2PlanArtifact, - type OrchestrationV2PlanStep, - type OrchestrationV2ProviderCapabilities, - type OrchestrationV2ProviderFailure, - type OrchestrationV2ProviderRetry, - type OrchestrationV2ProviderSession, - type OrchestrationV2ProviderThread, - type OrchestrationV2ProviderThreadNativeMetadata, - type OrchestrationV2ProviderTurn, - type OrchestrationV2RuntimeRequest, - type OrchestrationV2Subagent, - type OrchestrationV2TurnItem, - type OrchestrationV2UserInputQuestion, - type OrchestrationV2WebSearchResult, - type ProviderApprovalDecision, - type ProviderApprovalOption, - type ProviderInstanceId, - type ProviderInteractionMode, - type ProviderDriverKind, - type ProviderRequestKind, - type ProviderThreadId, - type ProviderUserInputAnswers, - type RuntimeRequestId, - type ThreadTokenUsageSnapshot, - type ThreadId, -} from "@t3tools/contracts"; -import { modelSelectionsEqual } from "@t3tools/shared/model"; -import { type SelfInvocation, selfInvocationArgs } from "@t3tools/shared/nodeRuntime"; -import { FILE_HEADERS_ONLY, formatPatch, structuredPatch } from "diff"; -import * as Cause from "effect/Cause"; -import * as Crypto from "effect/Crypto"; -import * as DateTime from "effect/DateTime"; -import * as Deferred from "effect/Deferred"; -import type * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; -import * as FileSystem from "effect/FileSystem"; -import * as Fiber from "effect/Fiber"; -import * as Option from "effect/Option"; -import * as Queue from "effect/Queue"; -import * as Ref from "effect/Ref"; -import * as Result from "effect/Result"; -import * as Scope from "effect/Scope"; -import * as Semaphore from "effect/Semaphore"; -import * as Stream from "effect/Stream"; -import type { ChildProcessSpawner } from "effect/process"; -import * as EffectAcpErrors from "effect-acp/errors"; -import type * as EffectAcpProtocol from "effect-acp/protocol"; -import type * as EffectAcpSchema from "effect-acp/compat"; - -import { formatReadToolLabel, formatSearchToolLabel } from "@t3tools/shared/toolActivity"; -import { resolveAttachmentPath } from "../../attachmentStore.ts"; -import * as ServerConfig from "../../config.ts"; -import { - makeAcpMcpOverAcpBridge, - type AcpMcpOverAcpBridge, -} from "../../mcp/AcpMcpOverAcpBridge.ts"; -import { mcpToolPresentation } from "@t3tools/provider-core/server/mcpToolPresentation"; -import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; -import { - applyAcpAgentTerminalUpdate, - acpContentBlockDisplayText, - embeddedTerminalIdsFromSessionUpdate, - extractMcpToolCallIdentity, - mergeToolCallState, - parsePermissionRequest, - parseSessionUpdateEvent, - toolCallVisibleOutputChanged, - type AcpPlanUpdate, - type AcpAgentTerminalState, - type AcpSessionModeState, - type AcpToolCallState, -} from "../../provider/acp/AcpRuntimeModel.ts"; -import { - acpClientExecuteDisposition, - acpMcpToolApprovalElicitationDisposition, - acpPermissionDisposition, - type AcpPermissionDisposition, - makeAcpClientPolicyGrants, - unknownRecord, -} from "../../provider/acp/AcpClientPolicy.ts"; -import { - makeAcpClientTerminals, - resolveEmbeddedTerminalContent, - type AcpClientTerminals, -} from "../../provider/acp/AcpClientTerminals.ts"; -import { ACP_SESSION_MODE_OPTION_ID } from "../../provider/acp/AcpSessionConfig.ts"; -import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts"; -import { - t3AcpPromptWithInstructions, - type T3AcpInstructionState, -} from "@t3tools/provider-core/server/orchestrationInstructions"; -import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions"; -import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; -import { type ProviderContinuationRequest } from "@t3tools/provider-core/server/continuationRequests"; -import { - type BackgroundWork, - type BackgroundWorkReport, - backgroundWorkNotification, -} from "@t3tools/provider-core/server/notification"; -import { - makeProviderFailure, - makeProviderRetryTurnItem, -} from "@t3tools/provider-core/server/failure"; -import { acpSelectionTransition } from "@t3tools/provider-core/server/selectionTransition"; -import { - isProviderNativeImageAttachment, - providerMessageTextWithAttachmentPaths, -} from "@t3tools/provider-core/server/attachmentPrompt"; -import { - makeSubagentChildThread, - makeSubagentConversationArtifacts, - subagentThreadTitle, -} from "@t3tools/provider-core/server/subagentProjection"; -import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; - -export const ACP_PROTOCOL = "acp.ndjson-jsonrpc" as const; - -/** - * Quiet window after a settled turn's last background rearm before it - * finalizes, so a slightly late post-hydration assistant chunk stays in the - * same turn. Grok commonly sends its final summary just over two seconds after - * the hydrated tool frame; two seconds split that tail into a second synthetic - * wake. Longer floors (4–20s) only prolonged Working. No per-model carveouts. - */ -const ACP_DEFERRED_FINALIZE_DEBOUNCE: Duration.Input = "3000 millis"; - -export interface AcpAdapterV2RuntimeInput { - readonly cwd: string; - /** - * Policy the session opened with. A runtime-mode change reopens the session, - * so flavors that encode permissions in the launch command (Grok) read it here. - */ - readonly runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy; - readonly mcpServers: ReadonlyArray; - readonly acpMcpServers?: ReadonlyArray; - /** Scoped credentials for terminal fallback when an ACP agent drops `mcpServers`. */ - readonly processEnvironment?: NodeJS.ProcessEnv; - readonly resumeSessionId?: string; - readonly interruptPromptOnCancel?: boolean; - readonly clientCapabilities: EffectAcpSchema.InitializeRequest["clientCapabilities"]; - readonly clientInfo: AcpSessionRuntime.AcpSessionRuntimeOptions["clientInfo"]; - readonly requestLogger?: NonNullable; - readonly protocolLogging: NonNullable< - AcpSessionRuntime.AcpSessionRuntimeOptions["protocolLogging"] - >; - readonly onTermination: NonNullable; - readonly onOutgoingResponseFailure?: AcpSessionRuntime.AcpSessionRuntimeOptions["onOutgoingResponseFailure"]; - readonly onOutgoingResponse?: AcpSessionRuntime.AcpSessionRuntimeOptions["onOutgoingResponse"]; -} - -export type AcpAdapterV2NativeLogging = Pick< - AcpSessionRuntime.AcpSessionRuntimeOptions, - "requestLogger" | "protocolLogging" ->; - -export interface AcpAdapterV2UserInputRequest { - readonly nativeItemId: string; - readonly nativeRequestId: string; - readonly questions: ReadonlyArray; -} - -export interface AcpAdapterV2ExtensionContext { - readonly runtime: AcpSessionRuntime.AcpSessionRuntime["Service"]; - readonly reportProviderRetry: (input: { - readonly sessionId: string; - readonly failure: OrchestrationV2ProviderFailure; - }) => Effect.Effect; - /** - * A subagent's structured end on the root session (Grok `subagent_finished`), - * keyed by its child session id. Finishes the subagent row, in the turn that - * holds it or in the carryover of a settled one. - */ - readonly finishSubagent: (notice: { - readonly sessionId: string; - readonly childSessionId: string; - readonly status: "completed" | "failed" | "cancelled"; - readonly result: string | null; - }) => Effect.Effect; - /** - * Session-scoped background-task lifecycle reported via extension - * notifications (e.g. Grok `x.ai/task_backgrounded`; older builds use the - * underscore alias). Mutations for non-root sessions are ignored. A terminal - * mutation also finishes the tool that registered the task in a settled turn - * still held open for it, with `output` as its final text when given. - */ - readonly applyBackgroundTaskMutation: (mutation: { - readonly sessionId: string; - readonly taskId: string; - readonly status: "running" | "completed" | "failed"; - readonly output?: string; - /** What the task is, so a wake it causes can name it. */ - readonly report?: BackgroundWork; - }) => Effect.Effect; - readonly requestUserInput: ( - input: AcpAdapterV2UserInputRequest, - requestContext: EffectAcpProtocol.AcpRequestContext, - ) => Effect.Effect< - { - readonly acknowledgeNativeResponse: Effect.Effect; - readonly answers: ProviderUserInputAnswers | null; - }, - EffectAcpErrors.AcpError - >; - /** Surfaces plan markdown as this turn's proposed-plan card (#8358). */ - readonly captureProposedPlan: (input: { readonly planMarkdown: string }) => Effect.Effect; - /** Last markdown captured for the active turn, as the exit-gate fallback. */ - readonly lastProposedPlanMarkdown: Effect.Effect; -} - -export interface AcpAdapterV2Flavor { - /** Interprets provider-specific prompt errors before they cross into orchestration. */ - readonly promptFailure?: (cause: unknown) => OrchestrationV2ProviderFailure; - readonly driver: ProviderDriverKind; - readonly capabilities: OrchestrationV2ProviderCapabilities; - readonly clientCapabilitiesMeta?: Record; - readonly normalizeSessionUpdate?: ( - notification: EffectAcpSchema.SessionNotification, - ) => EffectAcpSchema.SessionNotification; - readonly onAvailableCommandsUpdate?: ( - commands: ReadonlyArray, - ) => Effect.Effect; - readonly onSessionConfigurationUpdate?: ( - configOptions: ReadonlyArray, - modeState: AcpSessionModeState | undefined, - ) => Effect.Effect; - readonly onUrlElicitation?: (input: { - readonly elicitationId: string; - readonly url: string; - readonly message: string; - }) => Effect.Effect; - readonly withRuntimeStartup?: (effect: Effect.Effect) => Effect.Effect; - readonly makeRuntime: ( - input: AcpAdapterV2RuntimeInput, - ) => Effect.Effect< - AcpSessionRuntime.AcpSessionRuntime["Service"], - EffectAcpErrors.AcpError, - Crypto.Crypto | Scope.Scope - >; - readonly resolveModelId?: (selection: ModelSelection) => string | undefined; - /** - * Replaces the default model application on session setup. Returns the model - * the session now runs on. Antigravity resolves its provider-default alias - * against the account's catalog instead of sending it to the agent. - */ - readonly applyModelSelection?: (input: { - readonly runtime: AcpSessionRuntime.AcpSessionRuntime["Service"]; - readonly startResult: AcpSessionRuntime.AcpSessionRuntimeStartResult; - readonly modelSelection: ModelSelection; - }) => Effect.Effect; - /** Native session mode to select for a runtime policy (e.g. Antigravity `yolo`). */ - readonly sessionModeForPolicy?: ( - policy: ProviderAdapter.ProviderAdapterV2RuntimePolicy, - ) => string | undefined; - /** - * Opts the session into the ACP client `fs` capability. Agents read and write - * files themselves under their own permission model unless a flavor sets - * this. Requests pass the runtime policy guard, then these handlers, which - * receive the cwd of the policy active when the request arrives (null when - * the session has no workspace). Antigravity sets it and confines requests - * to that workspace. - */ - readonly clientFileSystem?: { - readonly readTextFile: ( - request: EffectAcpSchema.ReadTextFileRequest, - cwd: string | null, - ) => Effect.Effect; - readonly writeTextFile: ( - request: EffectAcpSchema.WriteTextFileRequest, - cwd: string | null, - ) => Effect.Effect; - }; - /** - * Permission requests that are really questions (Antigravity `interaction_*` - * tool calls). Returns the question and a response builder; undefined routes - * the request through the normal approval card. - */ - readonly extractPermissionQuestion?: (request: EffectAcpSchema.RequestPermissionRequest) => - | { - readonly question: OrchestrationV2UserInputQuestion; - readonly respond: ( - answers: ProviderUserInputAnswers, - ) => EffectAcpSchema.RequestPermissionResponse | undefined; - } - | undefined; - /** - * Replaces T3's runtime-policy answer to a permission request. Grok's Auto - * mode only asks about what its own classifier refused, so those must reach - * the user instead of being approved by T3's policy. - */ - readonly permissionDisposition?: ( - policy: ProviderAdapter.ProviderAdapterV2RuntimePolicy, - request: EffectAcpSchema.RequestPermissionRequest, - ) => AcpPermissionDisposition; - /** Approval choices to advertise on the approval card for a permission request. */ - readonly approvalOptions?: ( - request: EffectAcpSchema.RequestPermissionRequest, - ) => ReadonlyArray; - /** - * Activate saved sessions with `session/resume` before `session/load` when - * the agent supports both. Antigravity's load replays history slowly. - */ - readonly preferResumeSession?: boolean; - readonly onSessionEvent?: ( - event: AcpSessionRuntime.AcpSessionRuntimeEvent, - ) => Effect.Effect; - /** Batch launches without child completion signals become idle when the root turn ends. */ - readonly subagentsIdleOnTurnCompletion?: boolean; - readonly supportsCompaction?: boolean; - readonly runtimeHarness?: string; - readonly registerExtensions?: ( - context: AcpAdapterV2ExtensionContext, - ) => Effect.Effect; - readonly extractSubagentUpdate?: ( - toolCall: AcpToolCallState, - ) => AcpAdapterV2SubagentUpdate | undefined; - /** - * Optional Grok-style rewrite before tool projection (e.g. keep monitor start - * ACKs in the running state until stream end). - */ - readonly normalizeToolCall?: (toolCall: AcpToolCallState) => AcpToolCallState; - /** - * Optional plan-file sniffing (#8358): providers that write their proposed - * plan to a file mid-turn (Grok plan.md) return its markdown from a tool - * call so T3 can show the proposed-plan card while plan mode is active. - */ - readonly extractProposedPlanMarkdown?: (toolCall: AcpToolCallState) => string | undefined; - /** - * Optional mapping from a long-lived background tool start ACK to a task id - * (e.g. monitor task uuid) so later synthetic text events can update it. - */ - readonly extractBackgroundTaskId?: (toolCall: AcpToolCallState) => string | undefined; - /** - * Optional parse of root-session synthetic text (monitor-event lines, monitor - * ended reminders). Returns every task mutation in the chunk so coalesced - * progress / end notices are not dropped. - */ - readonly extractBackgroundToolMutation?: (text: string) => ReadonlyArray<{ - readonly taskId: string; - readonly status: "running" | "completed" | "failed"; - readonly appendOutput: string; - }>; - /** - * Optional parse of root-session synthetic text announcing a background - * subagent's end ("Background subagent "" ... completed successfully"). - * Older builds may never hydrate via get_command_or_subagent_output, so this - * remains a terminal fallback. Current Grok additionally emits structured - * `subagent_finished` session notifications. - */ - readonly extractSubagentEndNotice?: (text: string) => - | { - readonly childSessionId: string; - readonly status: "completed" | "failed"; - } - | undefined; - /** - * Optional hydration when a later tool (e.g. get_command TaskOutput) completes - * previously registered background task id(s). - */ - readonly extractBackgroundTaskCompletion?: (toolCall: AcpToolCallState) => ReadonlyArray<{ - readonly taskId: string; - readonly status: "running" | "completed" | "failed"; - readonly appendOutput: string; - }>; - /** - * Persistent monitors (e.g. Grok `persistent: true`) should not hold root-turn - * deferred finalize open forever. Still tracked for post-settle wake. - */ - readonly isPersistentBackgroundTool?: (toolCall: AcpToolCallState) => boolean; - /** - * Whether a root-session frame belongs to a turn the agent started itself - * after background work ended (Grok `task-completed-*`), not to T3's prompt. - * Such frames never project into a root turn held open for that work; they - * take the post-settle wake path once the held turn finalizes. - */ - readonly isProviderWakeNotification?: ( - notification: EffectAcpSchema.SessionNotification, - ) => boolean; - /** - * When true, keep the active turn open after session/prompt returns while - * background tools/subagents are still running so later monitor/wake traffic - * can project (Grok monitors finish after the root prompt settles). - */ - readonly deferFinalizeForBackgroundWork?: boolean; - readonly assertComplete?: Effect.Effect; - /** Interrupt the local prompt fiber before `session/cancel` (Grok wedged prompts). */ - readonly interruptPromptOnCancel?: boolean; - /** - * Kill and respawn the ACP child process before the next `session/prompt` after a - * user interrupt. Grok can keep `task_already_running` state until the process exits. - */ - readonly restartRuntimeAfterInterrupt?: boolean; - /** - * When true, every interrupt restarts the runtime, not just those carrying - * `requestRuntimeRestart` (user Stop). Leave unset to keep non-Stop - * interrupts (steering, restart_active) soft: `session/cancel` plus session - * reuse in the same process. - */ - readonly restartRuntimeOnEveryInterrupt?: boolean; - readonly terminateRuntimeProcessGroupOnInterrupt?: boolean; - /** - * When true, an interrupt without `requestRuntimeRestart` (steering restart) - * on a turn whose native prompt already settled skips the hard process-group - * kill, the ACP cancel, and the runtime respawn entirely: the turn - * terminalizes locally while background subagents keep running in the same - * process and carry over into the replacement turn. Verified against the - * real Grok CLI (tmp/grok-acp-experiments E1): a new session/prompt is - * accepted concurrently while a fire-and-forget subagent is still running, - * with no task_already_running. User Stop (`requestRuntimeRestart: true`) - * keeps the hard teardown; mid-prompt non-Stop interrupts go soft - * (`session/cancel` plus same-session re-prompt) unless - * `restartRuntimeOnEveryInterrupt` is set. - */ - readonly preserveRuntimeOnSettledInterrupt?: boolean; - /** - * When true (with continuationRequests), post-settle root session/update traffic - * buffers and requests a provider continuation run instead of being dropped or - * only appended to loaded history. - */ - readonly enablePostSettleContinuation?: boolean; - /** - * When true, send image attachment content blocks even if the ACP agent - * advertises `promptCapabilities.image: false`. Grok CLI currently accepts - * and vision-processes image blocks while still reporting the capability as - * false; without this override, screenshot turns fail before `session/prompt`. - */ - readonly supportsImagePrompts?: boolean; -} - -/** Whether image attachment blocks may be included in session/prompt. */ -export function acpSupportsImagePrompts(input: { - readonly flavorSupportsImagePrompts?: boolean | undefined; - readonly negotiatedImage?: boolean | undefined; -}): boolean { - return input.flavorSupportsImagePrompts === true || input.negotiatedImage === true; -} - -/** Keeps provider-derived ids distinct when two configured ACP instances reuse native ids. */ -export function acpScopedNativeId(instanceId: ProviderInstanceId, nativeId: string): string { - return `provider-instance:${encodeURIComponent(instanceId)}:${nativeId}`; -} - -/** Keeps pre-v2 persisted ids stable while scoping ids for newly created threads. */ -export function acpProviderItemNativeId(input: { - readonly instanceId: ProviderInstanceId; - readonly itemIdentityVersion: 2 | undefined; - readonly nativeId: string; -}): string { - return input.itemIdentityVersion === 2 - ? acpScopedNativeId(input.instanceId, input.nativeId) - : input.nativeId; -} - -export interface AcpAdapterV2SubagentUpdate { - readonly nativeTaskId: string; - readonly prompt: string; - readonly title: string | null; - readonly model: string | null; - readonly status: - | "pending" - | "running" - | "idle" - | "completed" - | "failed" - | "interrupted" - | "cancelled"; - readonly childSessionId: string | null; - readonly result: string | null; - /** - * When false, still project a normal tool turn item after the subagent update - * (hydration tools like get_command_or_subagent_output). Defaults to true. - */ - readonly suppressNormalTool?: boolean; -} - -export interface AcpAdapterV2Options { - readonly instanceId: ProviderInstanceId; - readonly flavor: AcpAdapterV2Flavor; - readonly crypto: Crypto.Crypto; - readonly fileSystem: FileSystem.FileSystem; - readonly idAllocator: IdAllocator.IdAllocatorV2Shape; - readonly serverConfig: ServerConfig.ServerConfig["Service"]; - /** How agents spawn this install's `acp-mcp-bridge`; see `resolveSelfInvocation`. */ - readonly selfInvocation: SelfInvocation; - /** - * Opts the session into the ACP client `terminal` capability. Agents run - * commands themselves unless an adapter sets this; with it, sessions - * advertise `terminal: true` and run agent-created terminals through this - * spawner with the provider instance's environment. Devin sets it. - */ - readonly clientTerminals?: { - readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; - readonly environment?: NodeJS.ProcessEnv; - readonly shellCommands?: boolean; - }; - readonly nativeLogging?: (threadId: ThreadId) => AcpAdapterV2NativeLogging; - /** - * Shared with ProviderContinuationService so post-settle wake traffic can start - * a continuation run. Optional: adapters that omit it keep pre-continuation drop - * / history-only behavior for null-activeTurn updates. - */ - readonly continuationRequests?: { - readonly offer: (request: ProviderContinuationRequest) => Effect.Effect; - }; - readonly testHooks?: { - /** - * A settled turn with no background work left armed its finish debounce - * ({@link ACP_DEFERRED_FINALIZE_DEBOUNCE}); replay advances its test clock - * by exactly that on this receipt. - */ - readonly onDeferredFinalizeScheduled?: (debounce: Duration.Input) => Effect.Effect; - readonly afterPromptSettledWithBackgroundWork?: () => Effect.Effect; - readonly afterNativeResponseTransportClosed?: () => Effect.Effect; - readonly afterHardTeardownTransportDrained?: () => Effect.Effect; - readonly beforeNativeResponseAdmissionCheck?: ( - generation: number, - requestId: string, - ) => Effect.Effect; - readonly onNativeResponseLifecycle?: (event: { - readonly generation?: number; - readonly requestId?: string; - readonly type: - | "admission_rejected" - | "failed" - | "late_noop" - | "registered" - | "removed" - | "timer_exited" - | "timer_started" - | "watcher_exited" - | "watcher_started"; - }) => Effect.Effect; - }; -} - -export const AcpProviderCapabilitiesV2 = { - sessions: { - supportsMultipleProviderThreadsPerSession: false, - supportsModelSwitchInSession: false, - supportsProviderSwitchingViaHandoff: true, - supportsRuntimeModeSwitchInSession: false, - pendingRequestsSurviveRestart: false, - }, - threads: { - canCreateEmptyThread: true, - canReadThreadSnapshot: false, - canRollbackThread: true, - canForkThread: false, - canForkFromTurn: false, - canForkFromSubagentThread: false, - exposesNativeThreadId: true, - }, - turns: { - exposesNativeTurnId: false, - emitsTurnStarted: true, - emitsTurnCompleted: true, - supportsInterrupt: true, - supportsActiveSteering: false, - supportsSteeringByInterruptRestart: true, - supportsQueuedMessages: true, - terminalStatusQuality: "strong", - }, - streaming: { - streamsAssistantText: true, - streamsReasoning: true, - streamsToolOutput: true, - streamsPlanText: false, - emitsMessageCompleted: true, - }, - tools: { - exposesToolItemIds: true, - emitsToolStarted: true, - emitsToolCompleted: true, - emitsToolOutput: true, - supportsMcpTools: false, - supportsDynamicToolCallbacks: false, - }, - approvals: { - supportsCommandApproval: true, - supportsFileReadApproval: true, - supportsFileChangeApproval: true, - supportsApplyPatchApproval: false, - approvalsHaveNativeRequestIds: false, - approvalCallbacksAreLiveOnly: true, - approvalsCanOriginateFromSubagents: false, - }, - planning: { - emitsPlanUpdated: true, - emitsTodoList: true, - emitsProposedPlan: false, - supportsStructuredQuestions: true, - planDeltasHaveItemIds: false, - }, - subagents: { - supportsSubagents: false, - exposesSubagentThreadIds: false, - emitsSubagentLifecycle: false, - canWaitForSubagents: false, - canCloseSubagents: false, - canForkSubagentThread: false, - }, - context: { - acceptsSystemContext: false, - acceptsDeveloperContext: false, - acceptsSyntheticUserContext: true, - canGenerateSummaries: true, - canConsumeHandoffSummaries: true, - supportsDeltaHandoff: true, - supportsFullThreadHandoff: true, - maxRecommendedHandoffChars: null, - }, - checkpointing: { - appCanCheckpointFilesystem: true, - supportsNestedCheckpointScopes: true, - // ACP defines no conversation truncation, so rollback resets the provider - // conversation: T3 restores checkpointed state and the next turn starts a - // fresh agent session without the rolled-back context. - providerCanRollbackConversation: true, - providerRollbackReturnsSnapshot: true, - providerCanReadConversationSnapshot: false, - }, - identity: { - nativeThreadIds: "strong", - nativeTurnIds: "weak", - nativeItemIds: "weak", - nativeRequestIds: "weak", - }, - runtimePolicy: { - // ACP agents run their own tools; T3 only answers their permission - // requests by policy. - enforcement: "client-boundary", - }, -} satisfies OrchestrationV2ProviderCapabilities; - -function negotiatedCapabilities( - base: OrchestrationV2ProviderCapabilities, - started: AcpSessionRuntime.AcpSessionRuntimeStartResult, -): OrchestrationV2ProviderCapabilities { - const agent = started.initializeResult.agentCapabilities ?? {}; - const session = agent.sessionCapabilities; - const setup = started.sessionSetupResult; - const hasModelConfig = - setup.configOptions?.some((option) => option.category === "model") === true; - const canLoad = agent.loadSession === true; - const canFork = session?.fork != null; - return { - ...base, - sessions: { - ...base.sessions, - supportsModelSwitchInSession: hasModelConfig, - }, - threads: { - ...base.threads, - canReadThreadSnapshot: canLoad, - canForkThread: canFork, - canForkFromTurn: false, - }, - tools: { - ...base.tools, - // The stdio bridge (`t3 acp-mcp-bridge`) makes the t3-code MCP toolkit - // available regardless of the agent's optional http/sse MCP support. - supportsMcpTools: true, - }, - checkpointing: { - ...base.checkpointing, - providerCanReadConversationSnapshot: canLoad, - }, - }; -} - -interface AcpMcpContext { - readonly servers: ReadonlyArray; - readonly acpServers: ReadonlyArray; - readonly processEnvironment?: NodeJS.ProcessEnv; - readonly endpoint?: string; - readonly authorization?: string; -} - -function acpMcpContext(threadId: ThreadId | null, self: SelfInvocation): AcpMcpContext { - if (threadId === null) return { servers: [], acpServers: [] }; - const session = McpProviderSession.readMcpProviderSession(threadId); - if (session === undefined) { - return { servers: [], acpServers: [] }; - } - // Stdio is ACP's required baseline MCP transport. Agents that advertise - // optional http support still routinely fail to wire injected http servers - // through to their backend (codex-acp 1.2.0 and pi-acp both drop them), so - // every ACP session gets the `t3 acp-mcp-bridge` stdio server, which - // forwards JSON-RPC to T3's authenticated MCP endpoint. The credential - // travels via environment variables, never the command line. - return { - servers: [ - { - name: "t3-code", - command: self.command, - args: [...selfInvocationArgs(self, ["acp-mcp-bridge"])], - env: [ - { name: "ELECTRON_RUN_AS_NODE", value: "1" }, - { name: "T3_ACP_MCP_ENDPOINT", value: session.endpoint }, - { name: "T3_ACP_MCP_AUTHORIZATION", value: session.authorizationHeader }, - ], - }, - ], - acpServers: [{ type: "acp", name: "t3-code", serverId: "t3-code" }], - endpoint: session.endpoint, - authorization: session.authorizationHeader, - processEnvironment: { - T3_ACP_MCP_ENDPOINT: session.endpoint, - T3_ACP_MCP_AUTHORIZATION: session.authorizationHeader, - T3_ACP_MCP_NODE: self.command, - ...(self.entrypoint === undefined ? {} : { T3_ACP_MCP_ENTRYPOINT: self.entrypoint }), - }, - }; -} - -function acpMcpServers( - threadId: ThreadId | null, - self: SelfInvocation, -): ReadonlyArray { - return acpMcpContext(threadId, self).servers; -} - -function acpMcpActivation(threadId: ThreadId | null, self: SelfInvocation) { - const context = acpMcpContext(threadId, self); - return { mcpServers: context.servers, acpMcpServers: context.acpServers }; -} - -function nativeThreadId( - driver: ProviderDriverKind, - thread: OrchestrationV2ProviderThread, -): Effect.Effect { - const id = thread.nativeThreadRef?.nativeId; - if (id === null || id === undefined || id.trim().length === 0) { - return Effect.fail( - new ProviderAdapter.ProviderAdapterProtocolError({ - driver, - detail: `Provider thread ${thread.id} is missing its ACP session id`, - }), - ); - } - return Effect.succeed(id); -} - -function makeProviderThread(input: { - readonly driver: ProviderDriverKind; - readonly providerInstanceId: ProviderInstanceId; - readonly idAllocator: IdAllocator.IdAllocatorV2Shape; - readonly appThreadId: OrchestrationV2ProviderThread["appThreadId"]; - readonly providerSessionId: OrchestrationV2ProviderThread["providerSessionId"]; - readonly nativeThreadId: string; - readonly ownerNodeId?: OrchestrationV2ProviderThread["ownerNodeId"]; - readonly forkedFrom?: OrchestrationV2ProviderThread["forkedFrom"]; - readonly itemIdentityVersion?: 2; - readonly now: DateTime.Utc; -}): OrchestrationV2ProviderThread { - return { - id: input.idAllocator.derive.providerThread({ - driver: input.driver, - providerInstanceId: input.providerInstanceId, - nativeThreadId: input.nativeThreadId, - }), - driver: input.driver, - providerInstanceId: input.providerInstanceId, - providerSessionId: input.providerSessionId, - appThreadId: input.appThreadId, - ownerNodeId: input.ownerNodeId ?? null, - nativeThreadRef: { - driver: input.driver, - nativeId: input.nativeThreadId, - strength: "strong", - }, - nativeConversationHeadRef: null, - status: "idle", - firstRunOrdinal: null, - lastRunOrdinal: null, - handoffIds: [], - forkedFrom: input.forkedFrom ?? null, - contextUsage: null, - nativeMetadata: - input.itemIdentityVersion === undefined - ? null - : { itemIdentityVersion: input.itemIdentityVersion }, - createdAt: input.now, - updatedAt: input.now, - }; -} - -/** - * Unwrap a codex-acp style MCP call result (`{ result, error }` around MCP - * `content`/`structuredContent`) the same way the native Codex adapter does, - * so recovered MCP items render identical output. Unknown shapes pass through. - */ -function acpMcpToolCallOutput(rawOutput: unknown): unknown { - const record = unknownRecord(rawOutput); - if (record === undefined) return rawOutput; - if (!("result" in record) && !("error" in record)) return rawOutput; - const result = unknownRecord(record.result); - const resultOutput = - result === undefined ? undefined : (result.structuredContent ?? result.content ?? undefined); - const errorMessage = unknownRecord(record.error)?.message; - if (typeof errorMessage !== "string") { - return resultOutput ?? rawOutput; - } - return resultOutput === undefined - ? { error: errorMessage } - : { error: errorMessage, result: resultOutput }; -} - -function nonEmptyText(value: unknown, fallback: string): string { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : fallback; -} - -function decodeByteText(value: unknown): string | undefined { - if (!Array.isArray(value) || value.length === 0) return undefined; - if (!value.every((entry) => typeof entry === "number" && Number.isInteger(entry))) { - return undefined; - } - try { - // Preserve leading/trailing whitespace like the string path in textFromUnknown. - const text = new TextDecoder().decode(Uint8Array.from(value as number[])); - return text.length > 0 ? text : undefined; - } catch { - return undefined; - } -} - -function textFromUnknown(value: unknown): string | undefined { - if (typeof value === "string") { - return value; - } - const fromBytes = decodeByteText(value); - if (fromBytes !== undefined) { - return fromBytes; - } - if (Array.isArray(value)) { - const parts = value.flatMap((entry) => { - const text = textFromUnknown(entry); - return text === undefined || text.length === 0 ? [] : [text]; - }); - return parts.length === 0 ? undefined : parts.join("\n"); - } - const record = unknownRecord(value); - if (record === undefined) { - return undefined; - } - // Prefer prompt-facing Grok fields before nested envelopes. - // Antigravity reports shell output as combinedOutput. - for (const key of [ - "output_for_prompt", - "stdout", - "stderr", - "output", - "combinedOutput", - "combined_output", - "content", - "text", - "message", - ]) { - const direct = record[key]; - if (typeof direct === "string" && direct.length > 0) { - return direct; - } - const decoded = decodeByteText(direct); - if (decoded !== undefined) { - return decoded; - } - const text = textFromUnknown(direct); - if (text !== undefined && text.length > 0) { - return text; - } - } - const result = unknownRecord(record.Result) ?? unknownRecord(record.result); - if (result !== undefined) { - return textFromUnknown(result); - } - return undefined; -} - -function commandExitCode(value: unknown): number | undefined { - const record = unknownRecord(value); - for (const key of ["exitCode", "exit_code", "code"]) { - const candidate = record?.[key]; - if (typeof candidate === "number" && Number.isInteger(candidate)) { - return candidate; - } - } - return undefined; -} - -/** - * Project an exit code only when the tool has a terminal native status. - * Mid-stream Grok Bash re-reports carry exit_code 0 while still in progress; - * interrupted tools must not retain that stale success code. - */ -export function acpProjectedCommandExitCode( - status: "pending" | "running" | "waiting" | "completed" | "failed" | "interrupted", - rawOutput: unknown, -): number | undefined { - if (status !== "completed" && status !== "failed") { - return undefined; - } - return commandExitCode(rawOutput); -} - -function structuredFileChanges(toolCall: AcpToolCallState) { - const content = toolCall.data.content; - if (!Array.isArray(content)) return []; - return content.flatMap((entry) => { - const diff = unknownRecord(entry); - if (diff?.type !== "diff" || !Array.isArray(diff.changes)) return []; - return diff.changes.flatMap((candidate) => { - const change = unknownRecord(candidate); - const operation = typeof change?.operation === "string" ? change.operation.trim() : ""; - const path = typeof change?.path === "string" ? change.path.trim() : ""; - if (operation.length === 0 || path.length === 0) return []; - const oldPath = typeof change?.oldPath === "string" ? change.oldPath.trim() : ""; - const fileType = typeof change?.fileType === "string" ? change.fileType.trim() : ""; - const mimeType = typeof change?.mimeType === "string" ? change.mimeType.trim() : ""; - return [ - { - operation, - path, - ...(oldPath.length === 0 ? {} : { oldPath }), - ...(fileType.length === 0 ? {} : { fileType }), - ...(mimeType.length === 0 ? {} : { mimeType }), - }, - ]; - }); - }); -} - -// Past this edit distance an edit keeps no patch text, so projecting a large -// rewrite cannot stall the event loop in the diff search. A created or emptied -// file has one empty side and needs no search, so it is never capped. -const ACP_V1_DIFF_MAX_EDITS = 1_000; - -/** - * Patch text for a tool call's diff content. ACP v2 diffs carry it as - * `patch.text`. ACP v1 diffs carry `oldText`/`newText` instead (`oldText` - * null or absent for a new file), and agents that negotiate v1 still send - * that shape, so the patch is built from the two sides. - */ -export function acpToolCallDiffPatch(content: unknown): string | undefined { - if (!Array.isArray(content)) return undefined; - const diffs = content.flatMap((entry) => { - const diff = unknownRecord(entry); - return diff?.type === "diff" ? [diff] : []; - }); - for (const diff of diffs) { - const patch = unknownRecord(diff.patch); - if (typeof patch?.text === "string") return patch.text; - } - const v1Patches = diffs.flatMap((diff) => { - if (typeof diff.path !== "string" || typeof diff.newText !== "string") return []; - const oldText = typeof diff.oldText === "string" ? diff.oldText : undefined; - const patch = structuredPatch( - oldText === undefined ? "/dev/null" : diff.path, - diff.path, - oldText ?? "", - diff.newText, - undefined, - undefined, - { - context: 3, - maxEditLength: oldText && diff.newText ? ACP_V1_DIFF_MAX_EDITS : Number.POSITIVE_INFINITY, - }, - ); - return patch === undefined || patch.hunks.length === 0 ? [] : [patch]; - }); - return v1Patches.length === 0 ? undefined : formatPatch(v1Patches, FILE_HEADERS_ONLY); -} - -function pathFromToolCall(toolCall: AcpToolCallState): string | undefined { - const locations = toolCall.data.locations; - if (Array.isArray(locations)) { - for (const location of locations) { - const path = unknownRecord(location)?.path; - if (typeof path === "string" && path.trim().length > 0) { - return path.trim(); - } - } - } - const rawInput = unknownRecord(toolCall.data.rawInput); - for (const key of ["path", "filePath", "file_path", "url", "query", "pattern"]) { - const candidate = rawInput?.[key]; - if (typeof candidate === "string" && candidate.trim().length > 0) { - return candidate.trim(); - } - } - return undefined; -} - -/** - * Grok runs X and web searches server-side as `search` tools whose rawInput is - * only `{ variant: "XSearch" | "WebSearch", backend: true }`. The query arrives - * with completion: web searches report `action: { query, sources }`, X searches - * the backend call `{ name, input }` with JSON-encoded arguments. - */ -function acpBackendWebSearch( - rawInput: Record | undefined, - rawOutput: Record | undefined, -): - | { readonly query: string | undefined; readonly results: OrchestrationV2WebSearchResult[] } - | undefined { - const variant = typeof rawInput?.variant === "string" ? rawInput.variant.toLowerCase() : ""; - const action = unknownRecord(rawOutput?.action); - if (variant !== "xsearch" && variant !== "websearch" && action?.type !== "search") { - return undefined; - } - let args: Record | undefined; - if (typeof rawOutput?.input === "string") { - try { - args = unknownRecord(JSON.parse(rawOutput.input)); - } catch { - args = undefined; - } - } - const argsText = Object.entries(args ?? {}) - .filter(([, value]) => typeof value === "string" || typeof value === "number") - .map(([key, value]) => `${key}: ${value}`) - .join(", "); - const query = [action?.query, args?.query, argsText] - .find((value): value is string => typeof value === "string" && value.trim().length > 0) - ?.trim(); - const urls = new Set(); - for (const source of Array.isArray(action?.sources) ? action.sources : []) { - const url = unknownRecord(source)?.url; - if (typeof url === "string" && url.trim().length > 0) urls.add(url.trim()); - } - return { query, results: [...urls].map((url) => ({ url })) }; -} - -function providerRequestKind(kind: string | "unknown"): ProviderRequestKind { - switch (kind) { - case "execute": - return "command"; - case "read": - case "search": - case "fetch": - return "file-read"; - case "edit": - case "delete": - case "move": - return "file-change"; - default: - return "command"; - } -} - -function toolStatus( - status: AcpToolCallState["status"], -): "pending" | "running" | "waiting" | "completed" | "failed" { - switch (status) { - case "completed": - return "completed"; - case "failed": - return "failed"; - case "pending": - return "pending"; - case "requiresAction": - return "waiting"; - default: - return "running"; - } -} - -type ProjectedToolStatus = ReturnType | "interrupted"; - -function nodeStatus(status: ProjectedToolStatus): OrchestrationV2ExecutionNode["status"] { - return status === "pending" ? "running" : status; -} - -function completedAtForStatus(status: ProjectedToolStatus, now: DateTime.Utc): DateTime.Utc | null { - return status === "completed" || status === "failed" || status === "interrupted" ? now : null; -} - -function selectPermissionOptionId( - request: EffectAcpSchema.RequestPermissionRequest, - decision: Exclude, -): string | undefined { - const kind = - decision === "acceptForSession" - ? "allow_always" - : decision === "accept" - ? "allow_once" - : "reject_once"; - return request.options.find((option) => option.kind === kind)?.optionId.trim() || undefined; -} - -/** - * The runtime policy approves one request, so answer with the agent's - * allow-once option. Its allow-always option can outlive the session (Grok - * saves it for the whole project); use it only when no allow-once exists. - */ -function selectAutoApprovedPermissionOption( - request: EffectAcpSchema.RequestPermissionRequest, -): string | undefined { - return ( - selectPermissionOptionId(request, "accept") ?? - selectPermissionOptionId(request, "acceptForSession") - ); -} - -function elicitationContent( - answers: ProviderUserInputAnswers, - allowedKeys: ReadonlySet, -): Record { - const content: Record = {}; - for (const [key, value] of Object.entries(answers)) { - if (!allowedKeys.has(key)) continue; - if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { - content[key] = value; - } else if (Array.isArray(value)) { - content[key] = value.filter((entry): entry is string => typeof entry === "string"); - } - } - return content; -} - -interface ActiveTextSegment { - readonly nativeItemId: string; - readonly startedAt: DateTime.Utc; - sourceMessageId: string | null; - text: string; -} - -interface ActiveTextStream { - current: ActiveTextSegment | null; - nextSegment: number; -} - -interface AcpNativeBuildConfiguration { - readonly modeId?: string; - readonly configOptions: ReadonlyArray<{ readonly id: string; readonly value: string }>; -} - -interface ActiveAcpTurn { - readonly input: ProviderAdapter.ProviderAdapterV2TurnInput; - readonly providerTurnId: OrchestrationV2ProviderTurn["id"]; - readonly nativeThreadId: string; - readonly nativeTurnId: string; - readonly startedAt: DateTime.Utc; - readonly completed: Deferred.Deferred; - // Root item ordinals allocated in this turn. Subagent child items keep - // theirs on the subagent, which carries over into later turns. - readonly itemOrdinals: Map; - readonly user: ActiveTextStream; - readonly assistant: ActiveTextStream; - readonly reasoning: ActiveTextStream; - providerRetry?: - | { - readonly failure: OrchestrationV2ProviderFailure; - readonly retry: OrchestrationV2ProviderRetry; - readonly startedAt: DateTime.Utc; - readonly itemOrdinal: number; - } - | undefined; - contextUsage: ThreadTokenUsageSnapshot | null; - nativeMetadata: OrchestrationV2ProviderThreadNativeMetadata | null; - readonly tools: Map; - /** Streamed tool updates skipped since the last persisted one; see `shouldPersistToolUpdate`. */ - readonly toolUpdatesSkipped: Map; - readonly toolStartedAt: Map; - readonly subagents: Map; - readonly subagentsBySessionId: Map; - readonly pendingSubagentNotifications: Map>; - /** Background monitor/task id → toolCallId for synthetic root text updates. */ - readonly toolCallIdsByBackgroundTaskId: Map; - /** - * Persistent monitors registered this turn. Excluded from deferred-finalize - * holds so the root turn can settle while they keep streaming post-settle. - */ - readonly persistentBackgroundTaskIds: Set; - /** - * Monitor end events often only say "use get_command…"; keep the turn open - * until TaskOutput hydration arrives (or the safety timeout elapses). - */ - readonly awaitingBackgroundHydration: Set; - /** - * A monitor end notice landed after the prompt settled: the CLI runs an - * injected turn whose report never gets a turn_completed marker, so the - * report chunk races the deferred-finalize debounce (thread a8e8b0a9 run 5 - * dropped the listing this way). Hold finalize until the report streams or - * the safety timeout elapses. - */ - readonly pendingInjectedReport: Set; - /** - * An injected assistant report can race ahead of its monitor end notice. - * Remember that unmatched report so the later notice consumes, rather than - * re-arming, the task's pre-settle completion marker. - */ - earlyInjectedReportObserved: boolean; - readonly plans: Map< - string, - { - readonly id: OrchestrationV2PlanArtifact["id"]; - readonly startedAt: DateTime.Utc; - latest: OrchestrationV2PlanArtifact | null; - } - >; - interrupted: boolean; - finalized: boolean; - finalizedStatus: "completed" | "interrupted" | "failed" | "cancelled" | null; - /** session/prompt already returned; finalize deferred for background work. */ - promptSettled: boolean; - promptSettledStatus: "completed" | "interrupted" | "failed" | "cancelled" | null; - /** - * Completed the moment `runtime.prompt` resolves on the wire, before the - * completion callback requests `runtimeCallbackPermit`. Failure does not - * complete this; settled-soft classification ORs it with `promptSettled`. - */ - readonly promptWireSettled: Deferred.Deferred; - backgroundFinalizeGeneration: number; -} - -type AcpRuntimeTeardownState = - | { readonly _tag: "Idle" } - | { - readonly _tag: "InProgress"; - readonly completed: Deferred.Deferred; - } - | { readonly _tag: "Failed"; readonly error: ProviderAdapter.ProviderAdapterProtocolError }; - -/** True when a root session/update carries ingestible turn output, not keepalive noise. */ -function acpRootSessionUpdateIngestsOutput( - notification: EffectAcpSchema.SessionNotification, -): boolean { - const update = notification.update; - switch (update.sessionUpdate) { - case "agent_message_chunk": - case "agent_thought_chunk": - return (acpContentBlockDisplayText(update.content)?.length ?? 0) > 0; - case "agent_message": - case "agent_thought": - return (update.content ?? []).some( - (content) => (acpContentBlockDisplayText(content)?.length ?? 0) > 0, - ); - case "tool_call": - case "tool_call_update": - case "plan": - case "plan_update": - case "plan_removed": - case "compaction_update": - case "compaction_summary_chunk": - return parseSessionUpdateEvent(notification).events.some( - (event) => event._tag === "ToolCallUpdated" || event._tag === "PlanUpdated", - ); - default: - return false; - } -} - -/** - * Post-settle traffic that should be *buffered* for a continuation attach. - * Excludes monitor end/event chatter that must not become ghost history. - * Broader than {@link acpPostSettleContinuationOfferEvidence}: incremental - * tool progress may still need replay once a real completion offers a run. - */ -export function acpPostSettleWakeEvidence( - notification: EffectAcpSchema.SessionNotification, - flavor: Pick = {}, -): boolean { - if (!acpRootSessionUpdateIngestsOutput(notification)) return false; - const update = notification.update; - if ( - (update.sessionUpdate === "user_message_chunk" || - update.sessionUpdate === "agent_message_chunk") && - update.content.type === "text" - ) { - const text = update.content.text; - if ((flavor.extractBackgroundToolMutation?.(text) ?? []).length > 0) return false; - if (update.sessionUpdate === "agent_message_chunk" && text.trim().length === 0) return false; - if (/ = {}, -): boolean { - if (!acpPostSettleWakeEvidence(notification, flavor)) { - return false; - } - const update = notification.update; - // Assistant text only. Thought/reasoning bursts alone must not open synthetic - // "Background task completed." runs (duplicate-run spam after monitors). - if (update.sessionUpdate === "agent_message_chunk") { - return (acpContentBlockDisplayText(update.content)?.trim().length ?? 0) > 0; - } - if (update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update") { - return parseSessionUpdateEvent(notification).events.some((event) => { - if (event._tag !== "ToolCallUpdated") return false; - // Normalize first: a Grok monitor start ACK arrives with raw status - // "completed" but is a still-running background task, not completion. - const toolCall = flavor.normalizeToolCall?.(event.toolCall) ?? event.toolCall; - return toolCall.status === "completed" || toolCall.status === "failed"; - }); - } - return false; -} - -export function acpPostSettleWakeShouldBuffer( - notification: EffectAcpSchema.SessionNotification, - backgroundWorkRunning: boolean, -): boolean { - if (!backgroundWorkRunning) return true; - const update = notification.update; - return ( - update.sessionUpdate !== "agent_message_chunk" && update.sessionUpdate !== "agent_thought_chunk" - ); -} - -/** - * An app-owned wake (a delegated child finishing) is injected by the - * orchestrator, not typed by the user. It reports on a sibling child and says - * nothing about this session's own pending wake frames, so it must not discard - * them the way a real user turn does. ClaudeAdapterV2 already leaves its buffer - * alone on non-continuation turns; this keeps ACP consistent. - */ -export function acpIsAppOwnedWakeTurn(message: { - readonly createdBy: string; - readonly creationSource: string; -}): boolean { - return message.createdBy === "agent" && message.creationSource === "server"; -} - -/** A continuation run attaches to wake traffic the agent produced on its own. */ -function acpIsProviderContinuationMessage(message: { - readonly createdBy: string; - readonly creationSource: string; -}): boolean { - return message.createdBy === "agent" && message.creationSource === "provider"; -} - -export function acpCarryoverTerminalShouldClearContinuation(input: { - readonly continuationOffered: boolean; - readonly wakeBufferLength: number; -}): boolean { - return !input.continuationOffered && input.wakeBufferLength === 0; -} - -export function acpTurnStartShouldPreserveContinuation(input: { - readonly continuationRequested: boolean; - readonly isContinuationTurn: boolean; - readonly wakeBufferLength: number; -}): boolean { - return !input.isContinuationTurn && input.continuationRequested && input.wakeBufferLength > 0; -} - -export function acpPostSettleMonitorPromptShouldSuppress( - mutation: - | { - readonly taskId: string; - readonly status: "running" | "completed" | "failed"; - } - | undefined, -): boolean { - return mutation?.status === "running"; -} - -export function acpCompletedTurnShouldTerminalizeTool( - tool: AcpToolCallState, - flavor: Pick, -): boolean { - const status = toolStatus(tool.status); - if (status !== "pending" && status !== "running") return false; - if (flavor.extractBackgroundTaskId?.(tool) !== undefined) return false; - return flavor.extractSubagentUpdate?.(tool) === undefined; -} - -interface ActiveAcpSubagent { - task: OrchestrationV2Subagent; - readonly childThreadId: ThreadId; - readonly childRootNodeId: OrchestrationV2ExecutionNode["id"]; - readonly turnItemId: OrchestrationV2TurnItem["id"]; - readonly turnItemOrdinal: number; - /** Turn that spawned the subagent; carryover updates keep this lineage. */ - readonly providerTurnId: OrchestrationV2ProviderTurn["id"]; - readonly parentProviderThreadId: ProviderThreadId; - childSessionId: string | null; - assistantText: string; - readonly assistantMessages: Map; - readonly childItemOrdinals: Map; - nextChildOrdinal: number; - /** - * Whether a terminal carryover status has been projected to events. - * Completed roots project post-settle terminals immediately while their - * subscriber remains observable. Non-completed roots retain terminals in - * memory until the next attach. A later project:true path for the same entry - * must still project once; this flag prevents double emission and pins - * hasPendingBackgroundWork until projection lands. - */ - terminalStatusProjected: boolean; -} - -function acpSubagentStatusIsTerminal(status: OrchestrationV2Subagent["status"]): boolean { - return ( - status === "completed" || - status === "failed" || - status === "interrupted" || - status === "cancelled" || - status === "idle" - ); -} - -export function acpSubagentStatusBlocksTurnSettlement( - status: OrchestrationV2Subagent["status"], -): boolean { - return status === "running" || status === "pending"; -} - -function acpSubagentHasPendingBackgroundWork(subagent: ActiveAcpSubagent): boolean { - return ( - acpSubagentStatusBlocksTurnSettlement(subagent.task.status) || !subagent.terminalStatusProjected - ); -} - -type AcpCarryoverSubagents = { - readonly sessionId: string; - readonly rootTerminalStatus: "completed" | "interrupted" | "failed" | "cancelled"; - readonly subagents: ReadonlyArray; -}; - -type PendingRuntimeRequest = { - readonly generation: number; - readonly nativeResponseAcknowledgement: Deferred.Deferred; - readonly transportRequestId: string; - readonly requestId: RuntimeRequestId; - readonly runtimeRequest: OrchestrationV2RuntimeRequest; - readonly node: OrchestrationV2ExecutionNode; - readonly turnItem: OrchestrationV2TurnItem; -} & ( - | { - readonly type: "approval"; - readonly decision: Deferred.Deferred; - } - | { - readonly type: "user_input"; - readonly answers: Deferred.Deferred; - } -); - -interface SnapshotMessageState { - readonly order: Array; - readonly messages: Map; - loadingRole: "user" | "assistant" | "thought" | null; - loadingMessageId: string | null; - loadingIndex: number; -} - -const TOOL_UPDATE_PERSIST_EVERY = 10; - -/** - * Some agents stream a tool's arguments (a file write's diff, `rawInput`) and - * resend the whole call each time. Persist every 10th of those. Status, title, - * and output the user watches live always persist, as does the agent's own - * completed/failed when a flavor normalizes it to a non-terminal status. - */ -function shouldPersistToolUpdate( - context: ActiveAcpTurn, - key: string, - previous: AcpToolCallState | undefined, - next: AcpToolCallState, - reportedStatus: AcpToolCallState["status"], -): boolean { - const skipped = context.toolUpdatesSkipped.get(key) ?? 0; - const persist = - reportedStatus === "completed" || - reportedStatus === "failed" || - previous === undefined || - previous.status !== next.status || - previous.title !== next.title || - toolCallVisibleOutputChanged(previous, next) || - skipped + 1 >= TOOL_UPDATE_PERSIST_EVERY; - context.toolUpdatesSkipped.set(key, persist ? 0 : skipped + 1); - return persist; -} - -export function makeAcpAdapterV2( - options: AcpAdapterV2Options, -): ProviderAdapter.ProviderAdapterV2Shape { - const { flavor, fileSystem, idAllocator, serverConfig, selfInvocation: self } = options; - const driver = flavor.driver; - const continuationRequests = options.continuationRequests; - const postSettleContinuationEnabled = - flavor.enablePostSettleContinuation === true && continuationRequests !== undefined; - - return ProviderAdapter.ProviderAdapterV2.of({ - instanceId: options.instanceId, - driver, - getCapabilities: () => Effect.succeed(flavor.capabilities), - planSelectionTransition: (input) => Effect.succeed(acpSelectionTransition(input)), - openSession: Effect.fn("AcpAdapterV2.openSession")( - function* (input: ProviderAdapter.ProviderAdapterV2OpenSessionInput) { - const sessionScope = yield* Effect.scope; - // Persisted ACP threads from before item identity v2 retain their old - // deterministic ids. Fresh threads scope native ids by instance so - // separately configured agents cannot collide. - let itemIdentityVersion: 2 | undefined = - input.initialProviderItemIdentityVersion ?? - (input.initialNativeThreadId === undefined ? 2 : undefined); - const providerNativeId = (nativeId: string) => - acpProviderItemNativeId({ - instanceId: options.instanceId, - itemIdentityVersion, - nativeId, - }); - const providerNodeId = (nativeItemId: string) => - idAllocator.derive.nodeFromProviderItem({ - driver, - nativeItemId: providerNativeId(nativeItemId), - }); - const providerMessageId = (nativeItemId: string) => - idAllocator.derive.messageFromProviderItem({ - driver, - nativeItemId: providerNativeId(nativeItemId), - }); - const providerTurnItemId = (nativeItemId: string) => - idAllocator.derive.turnItemFromProviderItem({ - driver, - nativeItemId: providerNativeId(nativeItemId), - }); - const deriveProviderTurnId = (nativeTurnId: string) => - idAllocator.derive.providerTurn({ - driver, - nativeTurnId: providerNativeId(nativeTurnId), - }); - const useProviderThreadIdentity = (thread: OrchestrationV2ProviderThread): void => { - itemIdentityVersion = thread.nativeMetadata?.itemIdentityVersion; - }; - const terminalEnvironmentBySessionId = new Map(); - interface PendingTerminalEnvironment { - readonly environment: NodeJS.ProcessEnv | undefined; - readonly claimUnknownSession: boolean; - readonly sessionId: string | null; - } - let pendingTerminalEnvironment: PendingTerminalEnvironment | null = { - environment: acpMcpContext(input.threadId, self).processEnvironment, - claimUnknownSession: input.initialNativeThreadId === undefined, - sessionId: input.initialNativeThreadId ?? null, - }; - const prepareTerminalEnvironment = ( - threadId: ThreadId | null, - sessionId?: string, - ): void => { - pendingTerminalEnvironment = { - environment: acpMcpContext(threadId, self).processEnvironment, - claimUnknownSession: false, - sessionId: sessionId ?? null, - }; - }; - const prepareClaimableTerminalEnvironment = (threadId: ThreadId | null): void => { - pendingTerminalEnvironment = { - environment: acpMcpContext(threadId, self).processEnvironment, - claimUnknownSession: true, - sessionId: null, - }; - }; - const rememberTerminalEnvironment = ( - sessionId: string, - threadId: ThreadId | null, - ): void => { - const environment = acpMcpContext(threadId, self).processEnvironment; - pendingTerminalEnvironment = null; - if (environment === undefined) { - terminalEnvironmentBySessionId.delete(sessionId); - } else { - terminalEnvironmentBySessionId.set(sessionId, environment); - } - }; - const clientTerminals: AcpClientTerminals | undefined = - options.clientTerminals === undefined - ? undefined - : yield* makeAcpClientTerminals({ - spawner: options.clientTerminals.childProcessSpawner, - defaultCwd: input.runtimePolicy.cwd ?? process.cwd(), - environment: options.clientTerminals.environment, - shellCommands: options.clientTerminals.shellCommands, - environmentForSession: (sessionId) => { - const remembered = terminalEnvironmentBySessionId.get(sessionId); - if (remembered !== undefined) return remembered; - if (pendingTerminalEnvironment === null) return undefined; - if ( - pendingTerminalEnvironment.sessionId === null && - pendingTerminalEnvironment.claimUnknownSession - ) { - pendingTerminalEnvironment = { - ...pendingTerminalEnvironment, - sessionId, - }; - } - return pendingTerminalEnvironment.sessionId === sessionId - ? pendingTerminalEnvironment.environment - : undefined; - }, - }); - if (clientTerminals !== undefined) { - yield* Scope.addFinalizer(sessionScope, clientTerminals.disposeAll); - } - // Terminal ids embedded in raw tool_call updates, remembered before the - // content rewrite so emitTool can recover MCP-fallback command lines. - const sessionScopedId = (sessionId: string, nativeId: string): string => - JSON.stringify([sessionId, nativeId]); - const embeddedTerminalsByToolCallId = new Map< - string, - { - readonly sessionId: string; - readonly terminalIds: ReadonlyArray; - readonly toolCallId: string; - } - >(); - const toolCallIdsByAgentTerminalId = new Map>(); - const agentTerminalsById = new Map(); - const rememberEmbeddedTerminals = (input: { - readonly sessionId: string; - readonly toolCallId: string; - readonly terminalIds: ReadonlyArray; - }): void => { - const toolCallKey = sessionScopedId(input.sessionId, input.toolCallId); - for (const terminalId of embeddedTerminalsByToolCallId.get(toolCallKey)?.terminalIds ?? - []) { - const terminalKey = sessionScopedId(input.sessionId, terminalId); - const toolCallIds = toolCallIdsByAgentTerminalId.get(terminalKey); - toolCallIds?.delete(input.toolCallId); - if (toolCallIds?.size === 0) toolCallIdsByAgentTerminalId.delete(terminalKey); - } - embeddedTerminalsByToolCallId.delete(toolCallKey); - embeddedTerminalsByToolCallId.set(toolCallKey, { - sessionId: input.sessionId, - terminalIds: input.terminalIds, - toolCallId: input.toolCallId, - }); - for (const terminalId of input.terminalIds) { - const terminalKey = sessionScopedId(input.sessionId, terminalId); - const toolCallIds = toolCallIdsByAgentTerminalId.get(terminalKey) ?? new Set(); - toolCallIds.add(input.toolCallId); - toolCallIdsByAgentTerminalId.set(terminalKey, toolCallIds); - } - for (const oldest of embeddedTerminalsByToolCallId.keys()) { - if (embeddedTerminalsByToolCallId.size <= 256) break; - const remembered = embeddedTerminalsByToolCallId.get(oldest); - if (remembered === undefined) continue; - for (const terminalId of remembered.terminalIds) { - const terminalKey = sessionScopedId(remembered.sessionId, terminalId); - const toolCallIds = toolCallIdsByAgentTerminalId.get(terminalKey); - toolCallIds?.delete(remembered.toolCallId); - if (toolCallIds?.size === 0) toolCallIdsByAgentTerminalId.delete(terminalKey); - } - embeddedTerminalsByToolCallId.delete(oldest); - } - }; - // Command lines of the terminals embedded in a tool call, so MCP calls - // made through the acp-mcp-call terminal fallback keep their identity. - const embeddedTerminalCommands = ( - sessionId: string, - toolCallId: string, - ): ReadonlyArray => - ( - embeddedTerminalsByToolCallId.get(sessionScopedId(sessionId, toolCallId)) - ?.terminalIds ?? [] - ).flatMap((terminalId) => { - const command = - clientTerminals?.readCommandLine(terminalId) ?? - agentTerminalsById.get(sessionScopedId(sessionId, terminalId))?.command; - return command === undefined ? [] : [command]; - }); - // Client terminals (Devin) run with the T3 server's privileges, so they - // are policy-checked against the active turn policy; a command the user - // already approved satisfies an "ask" disposition. - const clientPolicyGrants = makeAcpClientPolicyGrants(); - let latestRuntimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy = - input.runtimePolicy; - const events = yield* Queue.unbounded(); - const activeTurn = yield* Ref.make(null); - const activeSessionId = yield* Ref.make(null); - const contextUsageBySessionId = yield* Ref.make( - new Map(), - ); - const nativeMetadataBySessionId = yield* Ref.make( - new Map(), - ); - const providerThreadByNativeSessionId = yield* Ref.make( - new Map(), - ); - // T3 only owns the temporary Plan override. Remember the agent's - // effective native configuration on entry and restore it on Build. - const nativeBuildConfigurationBySessionId = new Map(); - const initialSessionActivationFailure = yield* Ref.make<{ - readonly sessionId: string; - readonly error: EffectAcpErrors.AcpError; - } | null>(null); - const activeSessionSetup = - yield* Ref.make(null); - const activeSelection = yield* Ref.make(null); - const activeInteractionMode = yield* Ref.make(null); - const promptInstructionStates = yield* Ref.make(new Map()); - const runtimeRestartRequired = yield* Ref.make(false); - const runtimeTeardownState = yield* Ref.make({ _tag: "Idle" }); - const runtimeCallbackGeneration = yield* Ref.make(0); - const runtimeCallbackGenerationCounter = yield* Ref.make(0); - const allocateRuntimeCallbackGeneration = Ref.updateAndGet( - runtimeCallbackGenerationCounter, - (generation) => generation + 1, - ); - const advanceRuntimeCallbackGeneration = allocateRuntimeCallbackGeneration.pipe( - Effect.tap((generation) => Ref.set(runtimeCallbackGeneration, generation)), - ); - const runtimeCallbackPermit = yield* Semaphore.make(1); - const runtimeTransitionPermit = yield* Semaphore.make(1); - const nativeResponseAcknowledgements = yield* Ref.make( - new Map< - string, - { - readonly acknowledgement: Deferred.Deferred; - readonly generation: number; - } - >(), - ); - const pendingRuntimeRequests = yield* Ref.make(new Map()); - const emitNativeResponseLifecycle = - options.testHooks?.onNativeResponseLifecycle ?? (() => Effect.void); - const nextElicitationOrdinal = yield* Ref.make(0); - const providerTurns = yield* Ref.make(new Map()); - const snapshot = yield* Ref.make({ - order: [], - messages: new Map(), - loadingRole: null, - loadingMessageId: null, - loadingIndex: 0, - }); - - const awaitRuntimeTeardown = Effect.fnUntraced(function* () { - const state = yield* Ref.get(runtimeTeardownState); - if (state._tag === "InProgress") { - yield* Deferred.await(state.completed); - } else if (state._tag === "Failed") { - return yield* state.error; - } - }); - const runRuntimeCallbackAtGeneration = ( - generation: number, - effect: Effect.Effect, - ) => - runtimeCallbackPermit.withPermit( - Effect.gen(function* () { - if ((yield* Ref.get(runtimeTeardownState))._tag !== "Idle") { - return Option.none(); - } - if ((yield* Ref.get(runtimeCallbackGeneration)) !== generation) { - return Option.none(); - } - return Option.some(yield* effect); - }), - ); - const registerNativeResponseAcknowledgement = ( - generation: number, - transportRequestId: string, - acknowledgement: Deferred.Deferred, - ) => - Effect.gen(function* () { - yield* Ref.update(nativeResponseAcknowledgements, (current) => { - const updated = new Map(current); - updated.set(transportRequestId, { acknowledgement, generation }); - return updated; - }); - yield* emitNativeResponseLifecycle({ - type: "registered", - generation, - requestId: transportRequestId, - }); - if (!(yield* Deferred.isDone(acknowledgement))) return; - yield* Ref.update(nativeResponseAcknowledgements, (current) => { - if (current.get(transportRequestId)?.acknowledgement !== acknowledgement) { - return current; - } - const updated = new Map(current); - updated.delete(transportRequestId); - return updated; - }); - }); - const acknowledgeNativeResponse = (generation: number, transportRequestId: string) => - ( - options.testHooks?.beforeNativeResponseAdmissionCheck?.( - generation, - transportRequestId, - ) ?? Effect.void - ).pipe( - Effect.andThen(runRuntimeCallbackAtGeneration(generation, Effect.void)), - Effect.flatMap((registered) => - Option.isSome(registered) - ? Effect.void - : emitNativeResponseLifecycle({ - type: "admission_rejected", - generation, - requestId: transportRequestId, - }).pipe( - Effect.andThen( - new EffectAcpErrors.AcpTransportError({ - detail: "The ACP runtime closed before its response could be admitted", - cause: "Native response registration rejected during teardown", - }), - ), - ), - ), - ); - const awaitNativeResponseAcknowledgements = Effect.fnUntraced(function* ( - acknowledgements: ReadonlyArray< - readonly [string | undefined, Deferred.Deferred] - >, - ) { - if (acknowledgements.length === 0) return true; - const completed = yield* Deferred.make<"settled" | "timeout">(); - yield* emitNativeResponseLifecycle({ type: "watcher_started" }); - const acknowledgementFiber = yield* Effect.forEach( - acknowledgements, - ([, acknowledgement]) => Deferred.await(acknowledgement).pipe(Effect.exit), - { concurrency: "unbounded", discard: true }, - ).pipe( - Effect.andThen(Deferred.succeed(completed, "settled")), - Effect.interruptible, - Effect.ensuring(emitNativeResponseLifecycle({ type: "watcher_exited" })), - Effect.forkDetach, - ); - yield* emitNativeResponseLifecycle({ type: "timer_started" }); - const timerFiber = yield* Effect.sleep("2 seconds").pipe( - Effect.andThen(Deferred.succeed(completed, "timeout")), - Effect.interruptible, - Effect.ensuring(emitNativeResponseLifecycle({ type: "timer_exited" })), - Effect.forkDetach, - ); - const outcome = yield* Deferred.await(completed); - yield* Fiber.interrupt(acknowledgementFiber); - yield* Fiber.interrupt(timerFiber); - if (outcome === "settled") return true; - - yield* Ref.update(nativeResponseAcknowledgements, (current) => { - const updated = new Map(current); - for (const [requestId, acknowledgement] of acknowledgements) { - if ( - requestId !== undefined && - updated.get(requestId)?.acknowledgement === acknowledgement - ) { - updated.delete(requestId); - } - } - return updated; - }); - yield* Effect.forEach( - acknowledgements, - ([requestId]) => - emitNativeResponseLifecycle({ - type: "removed", - ...(requestId === undefined ? {} : { requestId }), - }), - { concurrency: "unbounded", discard: true }, - ); - const timeoutError = new EffectAcpErrors.AcpTransportError({ - detail: "Timed out waiting for an admitted ACP response to reach the transport queue", - cause: "Native response acknowledgement timed out", - }); - yield* Effect.forEach( - acknowledgements, - ([requestId, acknowledgement]) => - Deferred.fail(acknowledgement, timeoutError).pipe( - Effect.andThen( - emitNativeResponseLifecycle({ - type: "failed", - ...(requestId === undefined ? {} : { requestId }), - }), - ), - ), - { concurrency: "unbounded", discard: true }, - ); - return false; - }); - const awaitAdmittedNativeResponses = Ref.get(nativeResponseAcknowledgements).pipe( - Effect.flatMap((current) => - awaitNativeResponseAcknowledgements( - [...current.entries()].map( - ([requestId, entry]) => [requestId, entry.acknowledgement] as const, - ), - ), - ), - ); - const quarantineNativeTransportAtGeneration = Effect.fnUntraced(function* ( - generation: number, - ) { - const quarantined = yield* Ref.modify(nativeResponseAcknowledgements, (current) => { - const updated = new Map(current); - const acknowledgements: Array> = []; - for (const [requestId, entry] of updated) { - if (entry.generation !== generation) continue; - updated.delete(requestId); - acknowledgements.push(entry.acknowledgement); - } - return [acknowledgements, updated] as const; - }); - const error = new EffectAcpErrors.AcpTransportError({ - detail: "The ACP runtime was replaced before its response reached the transport queue", - cause: "ACP runtime transport was quarantined during teardown", - }); - yield* Effect.forEach( - quarantined, - (acknowledgement) => Deferred.fail(acknowledgement, error), - { concurrency: "unbounded", discard: true }, - ); - }); - const closeNativeTransport = runtimeCallbackPermit.withPermit( - Effect.gen(function* () { - yield* advanceRuntimeCallbackGeneration; - const acknowledgements = yield* Ref.getAndSet( - nativeResponseAcknowledgements, - new Map(), - ); - const error = new EffectAcpErrors.AcpTransportError({ - detail: "The ACP session closed before its admitted response reached the transport", - cause: "ACP session transport closed", - }); - yield* Effect.forEach( - acknowledgements, - ([requestId, entry]) => - Deferred.fail(entry.acknowledgement, error).pipe( - Effect.andThen( - emitNativeResponseLifecycle({ - type: "removed", - generation: entry.generation, - requestId, - }), - ), - Effect.andThen( - emitNativeResponseLifecycle({ - type: "failed", - generation: entry.generation, - requestId, - }), - ), - ), - { concurrency: "unbounded", discard: true }, - ); - return acknowledgements.size > 0; - }), - ); - // Post-settle wake support (Grok async subagent/monitor follow-up). After - // the root turn finalizes, later root session/update traffic buffers here - // until a provider continuation run attaches and drains it. - const lastTurnRoute = yield* Ref.make<{ - readonly threadId: ThreadId; - readonly providerThreadId: ProviderThreadId; - } | null>(null); - const wakeBuffer = yield* Ref.make>([]); - // Background work that ended after the prompt settled, keyed by task or - // child session id. The next continuation offer names it for the user; - // `offered` holds the keys that offer named. Work that ends while the - // offer waits for its turn is named by the offer after it. - const noWakeReports = { - reports: new Map(), - offered: new Set(), - }; - const wakeReports = yield* Ref.make<{ - readonly reports: ReadonlyMap; - readonly offered: ReadonlySet; - }>(noWakeReports); - const recordWakeReport = Effect.fnUntraced(function* ( - key: string, - report: BackgroundWorkReport, - ) { - const context = yield* Ref.get(activeTurn); - // An open prompt reports the work itself; no continuation follows. - if (context !== null && !context.promptSettled && !context.finalized) return; - yield* Ref.update(wakeReports, ({ reports, offered }) => ({ - reports: new Map(reports).set(key, report), - offered, - })); - }); - const continuationRequested = yield* Ref.make(false); - const continuationGeneration = yield* Ref.make(0); - const continuationPermit = yield* Semaphore.make(1); - const continuationClosed = yield* Ref.make(false); - // Direct Stop (requestRuntimeRestart) quarantines residual events from the - // stopped run so they cannot wake or attach to a later prompt/run. - const stoppedRunQuarantine = yield* Ref.make(false); - // A steering restart (or any interrupt) can finalize a turn while its - // spawned subagents are still running natively. Carry the live - // lineages into the next turn on the same session so their terminal - // signals can still flip the original turn items instead of leaving - // them running forever. - const carryoverSubagents = yield* Ref.make(null); - const handledBackgroundTaskIdsInActiveTurn = yield* Ref.make>( - new Set(), - ); - // Background tasks that reached a genuine terminal mutation while a root - // turn was still streaming and were not yet marked handled in-turn. The - // mid-turn offer is suppressed (active turn owns the work); on finalize - // these ids re-arm a single continuation when the agent never hydrated - // or reported them before settle (regression: mid-turn complete + no - // get_command must still wake after finalize). - const midTurnUnreportedCompletedTaskIds = yield* Ref.make>(new Set()); - // A monitor-event can arrive after its task and the user-facing provider - // turn already completed. Grok starts another internal prompt for that - // stale notification; suppress its agent output until a genuine terminal - // mutation or the next app turn so it cannot create a redundant app - // continuation. Tool frames continue through normal hydration. - const suppressPostSettleMonitorPrompt = yield* Ref.make(false); - // Background tasks (Grok monitors) known to still run at session level. - // Turn contexts are too short-lived to carry this: a continuation run - // finalizes between monitor events, and the next commentary burst must - // not reopen a run while the monitor is still streaming. - const runningBackgroundTaskIds = yield* Ref.make>(new Set()); - // Task ids with a GENUINE end signal (monitor-ended reminder or - // TaskOutput completion). Normalized tool statuses are not genuine: - // Grok Bash re-reports carry exit_code 0 mid-stream. A straggler - // monitor-event can land after the real end (the CLI keeps streaming - // while the agent already consumed the output via - // get_command_or_subagent_output); without the tombstone it would - // resurrect the running set and pin offers/idle-release forever. - // A tool-level failed get_command tombstones too: failing open to a - // single continuation beats failing closed to a dead thread. - const endedBackgroundTaskIds = yield* Ref.make>(new Set()); - const endedBackgroundTaskIdLimit = 128; - - const setBackgroundTaskRunning = (taskId: string, running: boolean) => - Effect.gen(function* () { - if (running && (yield* Ref.get(endedBackgroundTaskIds)).has(taskId)) { - return; - } - yield* Ref.update(runningBackgroundTaskIds, (current) => { - if (current.has(taskId) === running) return current; - const next = new Set(current); - if (running) { - next.add(taskId); - } else { - next.delete(taskId); - } - return next; - }); - }); - - const markBackgroundTaskEnded = (taskId: string) => - Ref.update(endedBackgroundTaskIds, (current) => { - if (current.has(taskId)) return current; - const next = new Set(current).add(taskId); - for (const oldest of next) { - if (next.size <= endedBackgroundTaskIdLimit) break; - next.delete(oldest); - } - return next; - }).pipe(Effect.andThen(setBackgroundTaskRunning(taskId, false))); - - const applyBackgroundTaskMutationRunning = (mutation: { - readonly taskId: string; - readonly status: "running" | "completed" | "failed"; - }) => - mutation.status === "running" - ? setBackgroundTaskRunning(mutation.taskId, true) - : markBackgroundTaskEnded(mutation.taskId); - - const trackRunningBackgroundTools = ( - notification: EffectAcpSchema.SessionNotification, - ): Effect.Effect => - Effect.gen(function* () { - if (flavor.extractBackgroundTaskId === undefined) return; - for (const event of parseSessionUpdateEvent(notification).events) { - if (event._tag !== "ToolCallUpdated") continue; - const toolCall = flavor.normalizeToolCall?.(event.toolCall) ?? event.toolCall; - const taskId = flavor.extractBackgroundTaskId(toolCall); - if (taskId === undefined) continue; - const status = toolStatus(toolCall.status); - yield* setBackgroundTaskRunning(taskId, status === "pending" || status === "running"); - } - }); - - const emitProviderEvent = (event: ProviderAdapter.ProviderAdapterV2Event) => - Queue.offer(events, event).pipe(Effect.asVoid); - let scheduleDeferredFinalize: (context: ActiveAcpTurn) => Effect.Effect = () => - Effect.void; - - const nativeLogging = options.nativeLogging?.(input.threadId); - const handleRuntimeTerminationAtGeneration = (runtimeGeneration: number) => - runRuntimeCallbackAtGeneration( - runtimeGeneration, - Ref.set(runtimeRestartRequired, true), - ).pipe(Effect.asVoid); - const makeRuntimeInput = ( - runtimeGeneration: number, - threadId: ThreadId | null, - resumeSessionId?: string, - onTermination: AcpAdapterV2RuntimeInput["onTermination"] = () => - handleRuntimeTerminationAtGeneration(runtimeGeneration), - ): AcpAdapterV2RuntimeInput => { - const mcpContext = acpMcpContext(threadId, self); - return { - cwd: input.runtimePolicy.cwd ?? process.cwd(), - runtimePolicy: input.runtimePolicy, - mcpServers: mcpContext.servers, - acpMcpServers: mcpContext.acpServers, - ...(mcpContext.processEnvironment === undefined - ? {} - : { processEnvironment: mcpContext.processEnvironment }), - ...(resumeSessionId === undefined ? {} : { resumeSessionId }), - interruptPromptOnCancel: flavor.interruptPromptOnCancel ?? false, - clientCapabilities: { - fs: { - readTextFile: flavor.clientFileSystem !== undefined, - writeTextFile: flavor.clientFileSystem !== undefined, - }, - terminal: clientTerminals !== undefined, - elicitation: { form: {}, ...(flavor.onUrlElicitation ? { url: {} } : {}) }, - ...(flavor.clientCapabilitiesMeta ? { _meta: flavor.clientCapabilitiesMeta } : {}), - }, - clientInfo: { name: "t3-code", version: "0.0.0" }, - onTermination, - onOutgoingResponseFailure: (requestId, error) => - Ref.modify(nativeResponseAcknowledgements, (current) => { - const entry = current.get(requestId); - if (entry === undefined || entry.generation !== runtimeGeneration) { - return [ - emitNativeResponseLifecycle({ - type: "late_noop", - generation: runtimeGeneration, - requestId, - }), - current, - ] as const; - } - const updated = new Map(current); - updated.delete(requestId); - return [ - Deferred.fail(entry.acknowledgement, error).pipe( - Effect.andThen( - emitNativeResponseLifecycle({ - type: "removed", - generation: runtimeGeneration, - requestId, - }), - ), - Effect.asVoid, - ), - updated, - ] as const; - }).pipe(Effect.flatten), - onOutgoingResponse: (requestId) => - Ref.modify(nativeResponseAcknowledgements, (current) => { - const entry = current.get(requestId); - if (entry === undefined || entry.generation !== runtimeGeneration) { - return [ - emitNativeResponseLifecycle({ - type: "late_noop", - generation: runtimeGeneration, - requestId, - }), - current, - ] as const; - } - const updated = new Map(current); - updated.delete(requestId); - return [ - Deferred.succeed(entry.acknowledgement, undefined).pipe( - Effect.andThen( - emitNativeResponseLifecycle({ - type: "removed", - generation: runtimeGeneration, - requestId, - }), - ), - Effect.asVoid, - ), - updated, - ] as const; - }).pipe(Effect.flatten), - ...(nativeLogging?.requestLogger === undefined - ? {} - : { requestLogger: nativeLogging.requestLogger }), - protocolLogging: nativeLogging?.protocolLogging ?? { - logIncoming: true, - logOutgoing: true, - logger: () => Effect.void, - }, - }; - }; - let runtimeScope: Scope.Closeable | undefined; - let runtime!: AcpSessionRuntime.AcpSessionRuntime["Service"]; - let runtimeMcpBridge: AcpMcpOverAcpBridge | undefined; - yield* Effect.addFinalizer(() => - runtimeScope === undefined - ? Effect.void - : Scope.close(runtimeScope, Exit.void).pipe(Effect.ignore), - ); - - const resolveItemOrdinal = (context: ActiveAcpTurn, nativeItemId: string) => - Effect.sync(() => { - const existing = context.itemOrdinals.get(nativeItemId); - if (existing !== undefined) return existing; - const ordinal = context.input.providerTurnOrdinal * 100 + context.itemOrdinals.size + 1; - context.itemOrdinals.set(nativeItemId, ordinal); - return ordinal; - }); - - const resolveSubagentChildOrdinal = (subagent: ActiveAcpSubagent, nativeItemId: string) => { - const existing = subagent.childItemOrdinals.get(nativeItemId); - if (existing !== undefined) return existing; - const ordinal = subagent.nextChildOrdinal++; - subagent.childItemOrdinals.set(nativeItemId, ordinal); - return ordinal; - }; - - const emitProviderRetry = Effect.fnUntraced(function* ( - context: ActiveAcpTurn, - status: "running" | "completed" | "interrupted" | "cancelled", - ) { - const state = context.providerRetry; - if (state === undefined) return; - yield* emitProviderEvent({ - type: "turn_item.updated", - driver, - turnItem: makeProviderRetryTurnItem({ - idAllocator, - driver, - threadId: context.input.threadId, - runId: context.input.runId, - nodeId: context.input.rootNodeId, - providerThreadId: context.input.providerThread.id, - providerTurnId: context.providerTurnId, - ...state, - status, - updatedAt: yield* DateTime.now, - }), - }); - if (status !== "running") context.providerRetry = undefined; - }); - - const reportProviderRetry = Effect.fnUntraced(function* (notice: { - readonly sessionId: string; - readonly failure: OrchestrationV2ProviderFailure; - }) { - const context = yield* Ref.get(activeTurn); - if ( - context === null || - context.finalized || - context.interrupted || - context.nativeThreadId !== notice.sessionId || - (yield* Ref.get(stoppedRunQuarantine)) - ) - return; - const previous = context.providerRetry; - context.providerRetry = { - failure: notice.failure, - retry: { - attempt: (previous?.retry.attempt ?? 0) + 1, - maxAttempts: null, - retryDelayMs: null, - }, - startedAt: previous?.startedAt ?? (yield* DateTime.now), - itemOrdinal: - previous?.itemOrdinal ?? - (yield* resolveItemOrdinal(context, `terminal-failure:${context.providerTurnId}`)), - }; - yield* emitProviderRetry(context, "running"); - }); - - const lastCapturedProposedPlan = yield* Ref.make<{ - readonly nativeTurnId: string; - readonly markdown: string; - readonly planId: OrchestrationV2PlanArtifact["id"]; - } | null>(null); - /** - * Emits a completed proposed-plan artifact for the active turn. Same - * plan id per turn so repeated captures (plan.md rewrites, the exit - * gate) update one card; identical markdown within a turn is a no-op. - */ - const captureProposedPlan = Effect.fnUntraced(function* (input: { - readonly planMarkdown: string; - }) { - const context = yield* Ref.get(activeTurn); - if (context === null) return; - const markdown = input.planMarkdown.trim(); - if (markdown.length === 0) return; - const previous = yield* Ref.get(lastCapturedProposedPlan); - if ( - previous !== null && - previous.nativeTurnId === context.nativeTurnId && - previous.markdown === markdown - ) { - return; - } - const nativeItemId = `${context.nativeTurnId}:proposed-plan`; - const planId = - previous !== null && previous.nativeTurnId === context.nativeTurnId - ? previous.planId - : yield* idAllocator.allocate - .plan({ - threadId: context.input.threadId, - runId: context.input.runId, - driver, - }) - .pipe(Effect.orDie); - yield* Ref.set(lastCapturedProposedPlan, { - nativeTurnId: context.nativeTurnId, - markdown, - planId, - }); - const now = yield* DateTime.now; - const nodeId = idAllocator.derive.nodeFromProviderItem({ driver, nativeItemId }); - const turnItemId = idAllocator.derive.turnItemFromProviderItem({ driver, nativeItemId }); - const ordinal = yield* resolveItemOrdinal(context, nativeItemId); - const nativeItemRef = { driver, nativeId: nativeItemId, strength: "weak" as const }; - yield* emitProviderEvent({ - type: "node.updated", - driver, - node: { - id: nodeId, - threadId: context.input.threadId, - runId: context.input.runId, - parentNodeId: context.input.rootNodeId, - rootNodeId: context.input.rootNodeId, - kind: "plan", - status: "completed", - countsForRun: false, - providerThreadId: context.input.providerThread.id, - providerTurnId: context.providerTurnId, - nativeItemRef, - runtimeRequestId: null, - checkpointScopeId: null, - startedAt: context.startedAt, - completedAt: now, - }, - }); - yield* emitProviderEvent({ - type: "plan.updated", - driver, - plan: { - id: planId, - threadId: context.input.threadId, - runId: context.input.runId, - nodeId, - kind: "proposed_plan", - status: "completed", - markdown, - }, - }); - yield* emitProviderEvent({ - type: "turn_item.updated", - driver, - turnItem: { - id: turnItemId, - threadId: context.input.threadId, - runId: context.input.runId, - nodeId, - providerThreadId: context.input.providerThread.id, - providerTurnId: context.providerTurnId, - nativeItemRef, - parentItemId: null, - ordinal, - status: "completed", - title: null, - startedAt: context.startedAt, - completedAt: now, - updatedAt: now, - type: "proposed_plan", - planId, - markdown, - streaming: false, - }, - }); - }); - const lastProposedPlanMarkdown = Effect.gen(function* () { - const context = yield* Ref.get(activeTurn); - const previous = yield* Ref.get(lastCapturedProposedPlan); - return context !== null && previous?.nativeTurnId === context.nativeTurnId - ? previous.markdown - : undefined; - }); - - const rememberSnapshotMessage = (message: OrchestrationV2ConversationMessage) => - Ref.update(snapshot, (current) => { - const key = String(message.id); - const exists = current.messages.has(key); - const messages = new Map(current.messages); - messages.set(key, message); - return { - ...current, - order: exists ? current.order : [...current.order, key], - messages, - }; - }); - - const textStreamFor = ( - context: ActiveAcpTurn, - kind: "user" | "assistant" | "reasoning", - ): ActiveTextStream => - kind === "user" - ? context.user - : kind === "assistant" - ? context.assistant - : context.reasoning; - - const emitTextSegment = Effect.fnUntraced(function* ( - context: ActiveAcpTurn, - kind: "user" | "assistant" | "reasoning", - completed: boolean, - ) { - const stream = textStreamFor(context, kind); - const segment = stream.current; - if (segment === null) return; - const now = yield* DateTime.now; - const ordinal = yield* resolveItemOrdinal(context, segment.nativeItemId); - const nodeId = providerNodeId(segment.nativeItemId); - const turnItemId = providerTurnItemId(segment.nativeItemId); - const nativeItemRef = { - driver, - nativeId: segment.nativeItemId, - strength: "weak" as const, - }; - if (kind !== "user") { - yield* emitProviderEvent({ - type: "node.updated", - driver, - node: { - id: nodeId, - threadId: context.input.threadId, - runId: context.input.runId, - parentNodeId: context.input.rootNodeId, - rootNodeId: context.input.rootNodeId, - kind: kind === "assistant" ? "assistant_message" : "reasoning", - status: completed ? "completed" : "running", - countsForRun: false, - providerThreadId: context.input.providerThread.id, - providerTurnId: context.providerTurnId, - nativeItemRef, - runtimeRequestId: null, - checkpointScopeId: null, - startedAt: segment.startedAt, - completedAt: completed ? now : null, - }, - }); - } - if (kind !== "reasoning") { - const messageId = providerMessageId(segment.nativeItemId); - const messageNodeId = kind === "user" ? context.input.rootNodeId : nodeId; - const message: OrchestrationV2ConversationMessage = { - createdBy: kind === "user" ? "user" : "agent", - creationSource: "provider", - id: messageId, - threadId: context.input.threadId, - runId: context.input.runId, - nodeId: messageNodeId, - role: kind, - text: segment.text, - attachments: [], - streaming: !completed, - createdAt: segment.startedAt, - updatedAt: now, - }; - yield* emitProviderEvent({ type: "message.updated", driver, message }); - yield* emitProviderEvent( - kind === "user" - ? { - type: "turn_item.updated", - driver, - turnItem: { - createdBy: "user", - creationSource: "provider", - id: turnItemId, - threadId: context.input.threadId, - runId: context.input.runId, - nodeId: messageNodeId, - providerThreadId: context.input.providerThread.id, - providerTurnId: context.providerTurnId, - nativeItemRef, - parentItemId: null, - ordinal, - status: completed ? "completed" : "running", - title: null, - startedAt: segment.startedAt, - completedAt: completed ? now : null, - updatedAt: now, - type: "user_message", - messageId, - inputIntent: "turn_start", - text: segment.text, - attachments: [], - }, - } - : { - type: "turn_item.updated", - driver, - turnItem: { - id: turnItemId, - threadId: context.input.threadId, - runId: context.input.runId, - nodeId, - providerThreadId: context.input.providerThread.id, - providerTurnId: context.providerTurnId, - nativeItemRef, - parentItemId: null, - ordinal, - status: completed ? "completed" : "running", - title: null, - startedAt: segment.startedAt, - completedAt: completed ? now : null, - updatedAt: now, - type: "assistant_message", - messageId, - text: segment.text, - streaming: !completed, - }, - }, - ); - if (completed) yield* rememberSnapshotMessage(message); - return; - } - yield* emitProviderEvent({ - type: "turn_item.updated", - driver, - turnItem: { - id: turnItemId, - threadId: context.input.threadId, - runId: context.input.runId, - nodeId, - providerThreadId: context.input.providerThread.id, - providerTurnId: context.providerTurnId, - nativeItemRef, - parentItemId: null, - ordinal, - status: completed ? "completed" : "running", - title: null, - startedAt: segment.startedAt, - completedAt: completed ? now : null, - updatedAt: now, - type: "reasoning", - text: segment.text, - streaming: !completed, - }, - }); - }); - - const closeTextStream = Effect.fnUntraced(function* ( - context: ActiveAcpTurn, - kind: "user" | "assistant" | "reasoning", - ) { - const stream = textStreamFor(context, kind); - if (stream.current === null) return; - yield* emitTextSegment(context, kind, true); - stream.current = null; - }); - - const closeTextStreams = Effect.fnUntraced(function* (context: ActiveAcpTurn) { - yield* closeTextStream(context, "user"); - yield* closeTextStream(context, "reasoning"); - yield* closeTextStream(context, "assistant"); - }); - - const appendText = Effect.fnUntraced(function* ( - context: ActiveAcpTurn, - kind: "user" | "assistant" | "reasoning", - text: string, - messageId?: string | null, - ) { - if (text.length === 0) return; - for (const other of ["user", "reasoning", "assistant"] as const) { - if (other !== kind) yield* closeTextStream(context, other); - } - const stream = textStreamFor(context, kind); - const sourceMessageId = messageId?.trim() || null; - if ( - stream.current !== null && - sourceMessageId !== null && - stream.current.sourceMessageId !== null && - stream.current.sourceMessageId !== sourceMessageId - ) { - yield* closeTextStream(context, kind); - } - if (stream.current === null) { - const now = yield* DateTime.now; - stream.current = { - nativeItemId: - sourceMessageId === null - ? `${context.nativeTurnId}:${kind}:${stream.nextSegment}` - : `${context.nativeTurnId}:${kind}:message:${sourceMessageId}`, - startedAt: now, - sourceMessageId, - text: "", - }; - stream.nextSegment += 1; - } else if (stream.current.sourceMessageId === null && sourceMessageId !== null) { - // Some agents omit messageId on the first chunk. Keep the already - // projected identity and use the first later id as its boundary. - stream.current.sourceMessageId = sourceMessageId; - } - stream.current.text += text; - yield* emitTextSegment(context, kind, false); - }); - - const replaceText = Effect.fnUntraced(function* ( - context: ActiveAcpTurn, - kind: "user" | "assistant" | "reasoning", - text: string, - messageId: string, - ) { - for (const other of ["user", "reasoning", "assistant"] as const) { - if (other !== kind) yield* closeTextStream(context, other); - } - const stream = textStreamFor(context, kind); - if (stream.current?.sourceMessageId !== messageId) { - yield* closeTextStream(context, kind); - const now = yield* DateTime.now; - stream.current = { - nativeItemId: `${context.nativeTurnId}:${kind}:message:${messageId}`, - startedAt: now, - sourceMessageId: messageId, - text, - }; - stream.nextSegment += 1; - } else { - stream.current.text = text; - } - yield* emitTextSegment(context, kind, false); - }); - - const emitSubagentAssistant = Effect.fnUntraced(function* ( - subagent: ActiveAcpSubagent, - text: string, - mode: "append" | "replace" = "append", - messageId?: string | null, - ) { - if (text.length === 0 && mode === "append") return; - const nativeItemId = `${subagent.task.nativeTaskRef?.nativeId ?? subagent.task.id}:message:${messageId ?? "result"}`; - const previous = subagent.assistantMessages.get(nativeItemId) ?? ""; - const messageText = mode === "replace" ? text : `${previous}${text}`; - subagent.assistantMessages.set(nativeItemId, messageText); - subagent.assistantText = messageText; - const now = yield* DateTime.now; - const ordinal = resolveSubagentChildOrdinal(subagent, nativeItemId); - const artifacts = makeSubagentConversationArtifacts({ - messageId: providerMessageId(nativeItemId), - turnItemId: providerTurnItemId(nativeItemId), - threadId: subagent.childThreadId, - rootNodeId: subagent.childRootNodeId, - providerThreadId: subagent.task.providerThreadId, - providerTurnId: null, - nativeItemRef: { driver, nativeId: nativeItemId, strength: "weak" }, - role: "assistant", - text: subagent.assistantText, - ordinal, - now, - }); - yield* emitProviderEvent({ type: "message.updated", driver, message: artifacts.message }); - yield* emitProviderEvent({ - type: "turn_item.updated", - driver, - turnItem: artifacts.turnItem, - }); - }); - - const projectSubagentNotification = Effect.fnUntraced(function* ( - subagent: ActiveAcpSubagent, - notification: EffectAcpSchema.SessionNotification, - ) { - const update = notification.update; - if (update.sessionUpdate === "agent_message_chunk") { - const text = acpContentBlockDisplayText(update.content); - if (text !== undefined) { - yield* emitSubagentAssistant(subagent, text, "append", update.messageId); - } - } else if (update.sessionUpdate === "agent_message") { - if (update.content === undefined) return; - const text = (update.content ?? []) - .flatMap((content) => { - const display = acpContentBlockDisplayText(content); - return display === undefined ? [] : [display]; - }) - .join("\n"); - yield* emitSubagentAssistant(subagent, text, "replace", update.messageId); - } - }); - - const emitSubagent = Effect.fnUntraced(function* ( - context: ActiveAcpTurn, - update: AcpAdapterV2SubagentUpdate, - ) { - // Hydration tools (get_command_or_subagent_output) use a new toolCallId - // but reference the child via subagent_id / task id. - const existing = - context.subagents.get(update.nativeTaskId) ?? - (update.childSessionId !== null - ? context.subagentsBySessionId.get(update.childSessionId) - : undefined); - const updateIsTerminal = acpSubagentStatusIsTerminal(update.status); - if ( - existing !== undefined && - acpSubagentStatusIsTerminal(existing.task.status) && - !updateIsTerminal - ) { - return; - } - if ( - existing !== undefined && - existing.task.status === update.status && - updateIsTerminal && - existing.terminalStatusProjected - ) { - return; - } - // get_command_or_subagent_output may target monitors/bash tasks. Only - // hydrate when we already have a matching subagent lineage. Spawn ACKs - // (non-empty prompt) may create a new lineage; empty-prompt hydration - // with suppressNormalTool must not invent a phantom subagent. - if (existing === undefined) { - const isHydrationOnly = update.prompt === "" && update.title === null; - if (isHydrationOnly || update.suppressNormalTool === false) { - return; - } - } - const now = yield* DateTime.now; - const nativeTaskId = existing?.task.nativeTaskRef?.nativeId ?? update.nativeTaskId; - const nativeItemRef = { - driver, - nativeId: nativeTaskId, - strength: "strong" as const, - }; - const nodeId = existing?.task.id ?? providerNodeId(nativeTaskId); - const childThreadId = - existing?.childThreadId ?? - idAllocator.derive.threadFromProviderThread({ - driver, - providerInstanceId: context.input.modelSelection.instanceId, - nativeThreadId: `${context.nativeThreadId}:task:${nativeTaskId}`, - }); - const childRootNodeId = - existing?.childRootNodeId ?? providerNodeId(`${nativeTaskId}:child-root`); - const turnItemId = existing?.turnItemId ?? providerTurnItemId(nativeTaskId); - const turnItemOrdinal = - existing?.turnItemOrdinal ?? (yield* resolveItemOrdinal(context, nativeTaskId)); - const taskStatus = update.status; - const task: OrchestrationV2Subagent = { - ...(existing?.task ?? { - id: nodeId, - threadId: context.input.threadId, - runId: context.input.runId, - parentNodeId: context.input.rootNodeId, - origin: "provider_native" as const, - createdBy: "agent" as const, - driver, - providerInstanceId: context.input.modelSelection.instanceId, - providerThreadId: null, - childThreadId, - nativeTaskRef: nativeItemRef, - prompt: update.prompt, - title: update.title, - result: null, - startedAt: now, - }), - model: update.model?.trim() || existing?.task.model || null, - status: taskStatus, - result: update.result ?? existing?.task.result ?? null, - completedAt: acpSubagentStatusIsTerminal(taskStatus) ? now : null, - updatedAt: now, - }; - const subagent: ActiveAcpSubagent = existing ?? { - task, - childThreadId, - childRootNodeId, - turnItemId, - turnItemOrdinal, - providerTurnId: context.providerTurnId, - parentProviderThreadId: context.input.providerThread.id, - childSessionId: null, - assistantText: "", - assistantMessages: new Map(), - childItemOrdinals: new Map(), - nextChildOrdinal: 101, - terminalStatusProjected: false, - }; - subagent.task = task; - context.subagents.set(nativeTaskId, subagent); - - if (existing === undefined) { - yield* emitProviderEvent({ - type: "app_thread.created", - driver, - appThread: makeSubagentChildThread({ - parentThread: context.input.appThread, - childThreadId, - parentNodeId: nodeId, - activeProviderThreadId: null, - providerInstanceId: context.input.modelSelection.instanceId, - modelSelection: { - ...context.input.modelSelection, - model: task.model ?? context.input.modelSelection.model, - }, - title: subagentThreadTitle({ - parentTitle: context.input.appThread.title, - title: update.title, - prompt: update.prompt, - ordinal: context.subagents.size, - }), - now, - createdBy: "agent", - creationSource: "provider", - }), - }); - const promptNativeItemId = `${nativeTaskId}:prompt`; - const promptArtifacts = makeSubagentConversationArtifacts({ - senderThreadId: context.input.threadId, - messageId: providerMessageId(promptNativeItemId), - turnItemId: providerTurnItemId(promptNativeItemId), - threadId: childThreadId, - rootNodeId: childRootNodeId, - providerThreadId: null, - providerTurnId: null, - nativeItemRef: { driver, nativeId: promptNativeItemId, strength: "weak" }, - role: "user", - text: update.prompt, - ordinal: 100, - now, - }); - yield* emitProviderEvent({ - type: "message.updated", - driver, - message: promptArtifacts.message, - }); - yield* emitProviderEvent({ - type: "turn_item.updated", - driver, - turnItem: promptArtifacts.turnItem, - }); - } - - const childSessionId = update.childSessionId; - if (childSessionId !== null && subagent.childSessionId === null) { - subagent.childSessionId = childSessionId; - context.subagentsBySessionId.set(childSessionId, subagent); - const providerThread = makeProviderThread({ - driver, - providerInstanceId: context.input.modelSelection.instanceId, - idAllocator, - appThreadId: childThreadId, - providerSessionId: input.providerSessionId, - nativeThreadId: childSessionId, - ...(itemIdentityVersion === undefined ? {} : { itemIdentityVersion }), - forkedFrom: { - providerThreadId: context.input.providerThread.id, - providerTurnId: context.providerTurnId, - }, - now, - }); - subagent.task = { ...subagent.task, providerThreadId: providerThread.id }; - yield* Ref.update(providerThreadByNativeSessionId, (current) => - new Map(current).set(childSessionId, providerThread), - ); - yield* emitProviderEvent({ - type: "provider_thread.updated", - driver, - providerThread: { ...providerThread, status: "idle" }, - }); - const buffered = context.pendingSubagentNotifications.get(childSessionId) ?? []; - context.pendingSubagentNotifications.delete(childSessionId); - yield* Effect.forEach( - buffered, - (notification) => projectSubagentNotification(subagent, notification), - { concurrency: 1, discard: true }, - ); - } - - if ( - taskStatus !== "running" && - subagent.assistantText.length === 0 && - update.result !== null - ) { - yield* emitSubagentAssistant(subagent, update.result); - } - const result = update.result ?? subagent.task.result ?? (subagent.assistantText || null); - subagent.task = { - ...subagent.task, - status: taskStatus, - result, - completedAt: acpSubagentStatusIsTerminal(taskStatus) ? now : null, - updatedAt: now, - }; - const providerThreadId = subagent.task.providerThreadId; - yield* emitProviderEvent({ - type: "node.updated", - driver, - node: { - id: nodeId, - threadId: context.input.threadId, - runId: subagent.task.runId, - parentNodeId: subagent.task.parentNodeId, - rootNodeId: subagent.task.parentNodeId, - kind: "subagent", - status: taskStatus, - countsForRun: false, - providerThreadId: context.input.providerThread.id, - providerTurnId: subagent.providerTurnId, - nativeItemRef, - runtimeRequestId: null, - checkpointScopeId: null, - startedAt: subagent.task.startedAt, - completedAt: subagent.task.completedAt, - }, - }); - yield* emitProviderEvent({ - type: "node.updated", - driver, - node: { - id: childRootNodeId, - threadId: childThreadId, - runId: null, - parentNodeId: null, - rootNodeId: childRootNodeId, - kind: "root_turn", - status: taskStatus, - countsForRun: false, - providerThreadId, - providerTurnId: null, - nativeItemRef, - runtimeRequestId: null, - checkpointScopeId: null, - startedAt: subagent.task.startedAt, - completedAt: subagent.task.completedAt, - }, - }); - yield* emitProviderEvent({ type: "subagent.updated", driver, subagent: subagent.task }); - yield* emitProviderEvent({ - type: "turn_item.updated", - driver, - turnItem: { - id: turnItemId, - threadId: context.input.threadId, - runId: subagent.task.runId, - nodeId, - providerThreadId: context.input.providerThread.id, - providerTurnId: subagent.providerTurnId, - nativeItemRef, - parentItemId: null, - ordinal: turnItemOrdinal, - status: taskStatus, - title: subagent.task.title, - startedAt: subagent.task.startedAt, - completedAt: subagent.task.completedAt, - updatedAt: now, - type: "subagent", - subagentId: subagent.task.id, - origin: "provider_native", - driver, - providerInstanceId: context.input.modelSelection.instanceId, - childThreadId, - prompt: subagent.task.prompt, - result, - }, - }); - if (acpSubagentStatusIsTerminal(taskStatus)) { - subagent.terminalStatusProjected = true; - } - }); - - const toolOutputText = (toolCall: AcpToolCallState): string => { - if (typeof toolCall.data.rawOutput === "string") { - return toolCall.data.rawOutput; - } - if ( - typeof (toolCall.data.rawOutput as { text?: unknown } | undefined)?.text === "string" - ) { - return String((toolCall.data.rawOutput as { text: string }).text); - } - return textFromUnknown(toolCall.data.rawOutput) ?? ""; - }; - - const setToolOutputText = (toolCall: AcpToolCallState, text: string): AcpToolCallState => ({ - ...toolCall, - data: { - ...toolCall.data, - rawOutput: { - type: "Text", - text, - }, - }, - }); - - const appendToolOutputText = ( - toolCall: AcpToolCallState, - appendOutput: string, - ): AcpToolCallState => { - if (appendOutput.length === 0) return toolCall; - return setToolOutputText(toolCall, `${toolOutputText(toolCall)}${appendOutput}`); - }; - - const isMonitorEndNoticeText = (text: string): boolean => - /Monitor\s+["']?[0-9a-f-]{8,}/i.test(text) && /ended/i.test(text); - - const hasDeferredBackgroundWork = (context: ActiveAcpTurn): boolean => { - if (context.awaitingBackgroundHydration.size > 0) return true; - if (context.pendingInjectedReport.size > 0) return true; - for (const [taskId, toolCallId] of context.toolCallIdsByBackgroundTaskId) { - // Persistent monitors stream after root settle; do not pin finalize. - if (context.persistentBackgroundTaskIds.has(taskId)) continue; - const tool = context.tools.get(toolCallId); - if (tool === undefined) continue; - const status = toolStatus(tool.status); - if (status === "pending" || status === "running") return true; - } - for (const subagent of context.subagents.values()) { - if (acpSubagentStatusBlocksTurnSettlement(subagent.task.status)) { - return true; - } - } - return false; - }; - - // scheduleDeferredFinalize is declared above openSession body start and - // assigned after finalizeTurn so forked finalize Effect typing stays clean. - const rearmDeferredFinalize = (context: ActiveAcpTurn) => - Effect.gen(function* () { - if (!flavor.deferFinalizeForBackgroundWork) return; - if (!context.promptSettled || context.finalized) return; - if (hasDeferredBackgroundWork(context)) return; - yield* scheduleDeferredFinalize(context); - }); - - // `let` breaks circular inference from monitor hydration re-entry. - let emitTool: ( - context: ActiveAcpTurn, - incoming: AcpToolCallState, - projectedStatus?: ProjectedToolStatus, - ) => Effect.Effect = () => Effect.void; - - const markAwaitingBackgroundHydration = (context: ActiveAcpTurn, taskId: string) => - Effect.gen(function* () { - if (context.awaitingBackgroundHydration.has(taskId)) return; - context.awaitingBackgroundHydration.add(taskId); - // Safety: do not hold the root turn forever if the agent never hydrates. - yield* Effect.gen(function* () { - yield* Effect.sleep("60000 millis"); - if (context.finalized || !context.awaitingBackgroundHydration.has(taskId)) return; - context.awaitingBackgroundHydration.delete(taskId); - // End notices keep the tool running until hydration; force-complete so - // deferred finalize can proceed when get_command never arrives. - const toolCallId = context.toolCallIdsByBackgroundTaskId.get(taskId); - const tool = toolCallId !== undefined ? context.tools.get(toolCallId) : undefined; - if (tool !== undefined) { - const status = toolStatus(tool.status); - if (status === "pending" || status === "running") { - yield* emitTool(context, { ...tool, status: "completed" }); - } - } - yield* rearmDeferredFinalize(context); - }).pipe(Effect.forkIn(sessionScope), Effect.asVoid); - }); - - const markPendingInjectedReport = (context: ActiveAcpTurn, taskId: string) => - Effect.gen(function* () { - // Only the settled-and-held window has the race; mid-turn reports - // are protected by the prompt RPC still being open. - if (!context.promptSettled) return; - if (context.pendingInjectedReport.has(taskId)) return; - context.pendingInjectedReport.add(taskId); - // Safety: the injected turn may end without a report chunk. - yield* Effect.gen(function* () { - yield* Effect.sleep("25000 millis"); - if (context.finalized || !context.pendingInjectedReport.has(taskId)) return; - context.pendingInjectedReport.delete(taskId); - yield* rearmDeferredFinalize(context); - }).pipe(Effect.forkIn(sessionScope), Effect.asVoid); - }); - - const clearMidTurnUnreportedTaskIds = (taskIds: ReadonlyArray) => - Ref.update(midTurnUnreportedCompletedTaskIds, (current) => { - let next: Set | null = null; - for (const taskId of taskIds) { - if (!current.has(taskId)) continue; - if (next === null) next = new Set(current); - next.delete(taskId); - } - return next ?? current; - }); - - emitTool = Effect.fnUntraced(function* ( - context: ActiveAcpTurn, - incoming: AcpToolCallState, - projectedStatus?: ProjectedToolStatus, - ) { - // An identified message can stream concurrently with tool updates. - // Keep its identity until the provider starts another message. - if (context.assistant.current?.sourceMessageId == null) - yield* closeTextStream(context, "assistant"); - if (context.reasoning.current?.sourceMessageId == null) - yield* closeTextStream(context, "reasoning"); - yield* closeTextStream(context, "user"); - const previous = context.tools.get(incoming.toolCallId); - const merged = mergeToolCallState(previous, incoming); - const toolCall = flavor.normalizeToolCall?.(merged) ?? merged; - context.tools.set(toolCall.toolCallId, toolCall); - const backgroundTaskId = flavor.extractBackgroundTaskId?.(toolCall); - if (backgroundTaskId !== undefined) { - context.toolCallIdsByBackgroundTaskId.set(backgroundTaskId, toolCall.toolCallId); - if (flavor.isPersistentBackgroundTool?.(toolCall) === true) { - context.persistentBackgroundTaskIds.add(backgroundTaskId); - } - const backgroundStatus = projectedStatus ?? toolStatus(toolCall.status); - yield* setBackgroundTaskRunning( - backgroundTaskId, - backgroundStatus === "pending" || backgroundStatus === "running", - ); - // Background tool that reaches a terminal status while the root - // prompt is still open (completed, failed, or interrupted) was - // consumed in-turn. Mark handled so late CLI re-reports and residual - // agent chatter do not open synthetic "Background task completed." - // runs. get_command TaskOutput still marks handled below. - // Only before promptSettled: after STARTED the deferred-finalize - // hold can let a monitor finish while the turn is still active; - // marking then would suppress the legitimate post-settle TaskOutput - // continuation (live: grok-post-settle-continuation-poll). - // Terminal statuses only after normalizeToolCall (start ACKs stay - // inProgress/running). - // - // Tradeoff: a pre-settle false "completed" normalization (Bash- - // shaped re-report with exit_code 0, or the hydration safety - // force-complete firing pre-settle) would permanently mark the task - // handled and suppress its injected report chatter. The deleted - // handled-id removal on late monitor-event mutations used to rescue - // that case; likelihood is low because mid-turn monitor ends - // normally arrive as reminder mutations that force inProgress, and - // re-reports are documented post-settle traffic. - if ( - !context.promptSettled && - backgroundStatus !== "pending" && - backgroundStatus !== "running" - ) { - yield* Ref.update(handledBackgroundTaskIdsInActiveTurn, (current) => - new Set(current).add(backgroundTaskId), - ); - yield* Ref.update(midTurnUnreportedCompletedTaskIds, (current) => { - if (!current.has(backgroundTaskId)) return current; - const next = new Set(current); - next.delete(backgroundTaskId); - return next; - }); - } - } - - // get_command TaskOutput for registered monitor(s): hydrate those tools. - const backgroundCompletions = - projectedStatus === undefined - ? (flavor.extractBackgroundTaskCompletion?.(toolCall) ?? []) - : []; - let hydratedRegisteredMonitor = false; - for (const backgroundCompletion of backgroundCompletions) { - // Genuine end signal when terminal: tombstone so straggler - // monitor-event chatter cannot resurrect the running set after the - // task truly ended. A still-running poll keeps the id running. - yield* applyBackgroundTaskMutationRunning(backgroundCompletion); - if (backgroundCompletion.status !== "running") { - yield* Ref.update(handledBackgroundTaskIdsInActiveTurn, (current) => - new Set(current).add(backgroundCompletion.taskId), - ); - yield* Ref.update(midTurnUnreportedCompletedTaskIds, (current) => { - if (!current.has(backgroundCompletion.taskId)) return current; - const next = new Set(current); - next.delete(backgroundCompletion.taskId); - return next; - }); - } - // A still-running fetch must keep the hydration hold (and its - // safety timer) alive until output actually lands. - if (backgroundCompletion.status !== "running") { - context.awaitingBackgroundHydration.delete(backgroundCompletion.taskId); - } - const targetToolCallId = context.toolCallIdsByBackgroundTaskId.get( - backgroundCompletion.taskId, - ); - // Known background-task id (monitor registration) — never open a - // phantom subagent for the same get_output poll. - if (targetToolCallId !== undefined) { - hydratedRegisteredMonitor = true; - } - const target = - targetToolCallId !== undefined ? context.tools.get(targetToolCallId) : undefined; - if (target !== undefined && target.toolCallId !== toolCall.toolCallId) { - const nextStatus = - backgroundCompletion.status === "running" - ? ("inProgress" as const) - : backgroundCompletion.status === "failed" - ? ("failed" as const) - : ("completed" as const); - const hydrated = - backgroundCompletion.appendOutput.length > 0 - ? // TaskOutput is the real stdout; replace end-notice boilerplate - // so the timeline shows the listing, not only "Monitor ended…". - isMonitorEndNoticeText(toolOutputText(target)) || - toolOutputText(target).trim().length === 0 - ? setToolOutputText( - { ...target, status: nextStatus }, - backgroundCompletion.appendOutput, - ) - : appendToolOutputText( - { ...target, status: nextStatus }, - backgroundCompletion.appendOutput, - ) - : { ...target, status: nextStatus }; - yield* emitTool(context, hydrated); - } - } - - // Monitor TaskOutput shares the get_command tool shape with subagent - // hydration; do not spawn a phantom subagent for a registered monitor. - const subagentUpdate = hydratedRegisteredMonitor - ? undefined - : flavor.extractSubagentUpdate?.(toolCall); - if (subagentUpdate !== undefined) { - yield* emitSubagent(context, subagentUpdate); - if (subagentUpdate.suppressNormalTool !== false) { - yield* rearmDeferredFinalize(context); - return; - } - } - if ( - projectedStatus === undefined && - !shouldPersistToolUpdate( - context, - toolCall.toolCallId, - previous, - toolCall, - merged.status, - ) - ) { - yield* rearmDeferredFinalize(context); - return; - } - const status = projectedStatus ?? toolStatus(toolCall.status); - const now = yield* DateTime.now; - const nativeItemId = `${context.nativeThreadId}:tool:${toolCall.toolCallId}`; - const ordinal = yield* resolveItemOrdinal(context, nativeItemId); - const nodeId = providerNodeId(nativeItemId); - const turnItemId = providerTurnItemId(nativeItemId); - const nativeItemRef = { - driver, - nativeId: toolCall.toolCallId, - strength: "strong" as const, - }; - const startedAt = context.toolStartedAt.get(toolCall.toolCallId) ?? now; - context.toolStartedAt.set(toolCall.toolCallId, startedAt); - const completedAt = completedAtForStatus(status, now); - const title = toolCall.title ?? null; - yield* emitProviderEvent({ - type: "node.updated", - driver, - node: { - id: nodeId, - threadId: context.input.threadId, - runId: context.input.runId, - parentNodeId: context.input.rootNodeId, - rootNodeId: context.input.rootNodeId, - kind: "tool_call", - status: nodeStatus(status), - countsForRun: true, - providerThreadId: context.input.providerThread.id, - providerTurnId: context.providerTurnId, - nativeItemRef, - runtimeRequestId: null, - checkpointScopeId: null, - startedAt, - completedAt, - }, - }); - - const base = { - id: turnItemId, - threadId: context.input.threadId, - runId: context.input.runId, - nodeId, - providerThreadId: context.input.providerThread.id, - providerTurnId: context.providerTurnId, - nativeItemRef, - parentItemId: null, - ordinal, - status, - title, - startedAt, - completedAt, - updatedAt: now, - } as const; - const rawInput = toolCall.data.rawInput; - const rawOutput = toolCall.data.rawOutput ?? toolCall.data.content; - const changes = structuredFileChanges(toolCall); - const path = changes[0]?.path ?? pathFromToolCall(toolCall); - const diffText = - acpToolCallDiffPatch(toolCall.data.content) ?? textFromUnknown(rawOutput); - const rawInputRecord = unknownRecord(rawInput); - const inputVariant = - typeof rawInputRecord?.variant === "string" - ? rawInputRecord.variant.trim().toLowerCase() - : ""; - const rawOutputRecord = unknownRecord(rawOutput); - const outputCommand = - typeof rawOutputRecord?.command === "string" && - rawOutputRecord.command.trim().length > 0 - ? rawOutputRecord.command.trim() - : undefined; - const monitorCommand = - (typeof rawInputRecord?.command === "string" && rawInputRecord.command.trim().length > 0 - ? rawInputRecord.command.trim() - : undefined) ?? outputCommand; - const outputIsBashResult = - typeof rawOutputRecord?.type === "string" && - rawOutputRecord.type.trim().toLowerCase() === "bash" && - commandExitCode(rawOutput) !== undefined; - // Grok Monitor tools arrive as generic kind + variant; project like shell - // so stdout is plain text in the timeline (not JSON {type:Text,text:...}). - // Post-settle wake re-reports of a finished monitor carry no rawInput at - // all, only a structured Bash result; project those as commands too. - const projectAsCommandExecution = inputVariant === "monitor" || outputIsBashResult; - // ACP has no typed MCP item, so recover MCP identity from the - // agent-specific shape and project the same branded dynamic_tool - // item native providers produce (e.g. the T3 orchestration tools). - const mcpIdentity = extractMcpToolCallIdentity(toolCall, { - embeddedTerminalCommands: embeddedTerminalCommands( - context.nativeThreadId, - toolCall.toolCallId, - ), - }); - let turnItem: OrchestrationV2TurnItem; - if (toolCall.toolCallId.startsWith("acp-compaction:")) { - const summary = textFromUnknown(rawOutput); - turnItem = { - ...base, - type: "compaction", - driver, - ...(summary === undefined ? {} : { summary }), - ...(context.contextUsage?.usedTokens === undefined - ? {} - : { beforeTokenCount: context.contextUsage.usedTokens }), - }; - if (status === "completed") { - context.contextUsage = null; - yield* Ref.update(contextUsageBySessionId, (current) => { - const updated = new Map(current); - updated.delete(context.nativeThreadId); - return updated; - }); - } - } else if (mcpIdentity !== undefined) { - turnItem = { - ...base, - title: null, - ...mcpToolPresentation({ - serverName: mcpIdentity.server, - toolName: mcpIdentity.tool, - source: unknownRecord( - (unknownRecord(rawOutputRecord?.result) ?? rawOutputRecord)?._meta, - )?.source, - }), - type: "dynamic_tool", - toolName: `${mcpIdentity.server}.${mcpIdentity.tool}`, - input: - mcpIdentity.input ?? - unknownRecord(rawInputRecord?.arguments) ?? - rawInputRecord ?? - {}, - ...(rawOutput === undefined ? {} : { output: acpMcpToolCallOutput(rawOutput) }), - }; - yield* emitProviderEvent({ type: "turn_item.updated", driver, turnItem }); - yield* rearmDeferredFinalize(context); - return; - } else if (changes.length > 0) { - turnItem = { - ...base, - type: "file_change", - fileName: changes[0]!.path, - changes, - ...(diffText === undefined ? {} : { diffStr: diffText }), - }; - } else { - switch (toolCall.kind) { - case "read": - turnItem = { - ...base, - title: path ? formatReadToolLabel(path) : (title ?? "Read file"), - type: "dynamic_tool", - toolName: "Read", - input: - path === undefined || - ["path", "filePath", "file_path"].some((key) => rawInputRecord?.[key] === path) - ? (rawInputRecord ?? {}) - : { ...rawInputRecord, path }, - ...(rawOutput === undefined ? {} : { output: rawOutput }), - }; - break; - case "search": { - const backendSearch = acpBackendWebSearch(rawInputRecord, rawOutputRecord); - if (backendSearch !== undefined) { - // Grok titles these "X search:" / "Web search:" awaiting the query. - const label = nonEmptyText(toolCall.data.title, title ?? "Web search").replace( - /:\s*$/u, - "", - ); - turnItem = { - ...base, - title: - backendSearch.query === undefined - ? label - : `${label}: ${backendSearch.query}`, - type: "web_search", - ...(backendSearch.query === undefined - ? {} - : { patterns: [backendSearch.query] }), - ...(backendSearch.results.length === 0 - ? {} - : { results: backendSearch.results }), - }; - break; - } - turnItem = { - ...base, - title: - formatSearchToolLabel({ - rawInput: rawInputRecord, - input: rawInputRecord, - ...(path === undefined ? {} : { pattern: path }), - }) ?? title, - type: "file_search", - ...(path === undefined ? {} : { pattern: path }), - ...(path === undefined - ? {} - : { - results: [ - { - fileName: path, - ...(textFromUnknown(rawOutput) === undefined - ? {} - : { preview: textFromUnknown(rawOutput) }), - }, - ], - }), - }; - break; - } - case "execute": { - const exitCode = acpProjectedCommandExitCode(status, rawOutput); - turnItem = { - ...base, - type: "command_execution", - input: toolCall.command ?? monitorCommand ?? toolCall.title ?? "Command", - ...(textFromUnknown(rawOutput) === undefined - ? {} - : { output: textFromUnknown(rawOutput) }), - ...(exitCode === undefined ? {} : { exitCode }), - }; - break; - } - case "edit": - case "delete": - case "move": - turnItem = { - ...base, - type: "file_change", - fileName: path ?? toolCall.title ?? "File change", - ...(diffText === undefined ? {} : { diffStr: diffText }), - }; - break; - case "fetch": { - // Grok nests the page under rawOutput.Content, which textFromUnknown - // cannot read; the (bounded) content blocks carry the same text. - const snippet = - textFromUnknown(toolCall.data.content) ?? textFromUnknown(rawOutput); - turnItem = { - ...base, - type: "web_search", - ...(path === undefined ? {} : { patterns: [path] }), - ...(path === undefined - ? {} - : { - results: [ - { - url: path, - ...(snippet === undefined ? {} : { snippet }), - }, - ], - }), - }; - break; - } - default: - if (projectAsCommandExecution) { - const exitCode = acpProjectedCommandExitCode(status, rawOutput); - turnItem = { - ...base, - type: "command_execution", - input: - toolCall.command ?? - monitorCommand ?? - toolCall.title ?? - (inputVariant === "monitor" ? "Monitor" : "Command"), - ...(textFromUnknown(rawOutput) === undefined - ? {} - : { output: textFromUnknown(rawOutput) }), - ...(exitCode === undefined ? {} : { exitCode }), - }; - } else { - turnItem = { - ...base, - type: "dynamic_tool", - toolName: toolCall.title ?? toolCall.kind ?? null, - input: rawInput ?? {}, - ...(rawOutput === undefined ? {} : { output: rawOutput }), - }; - } - } - } - yield* emitProviderEvent({ type: "turn_item.updated", driver, turnItem }); - yield* rearmDeferredFinalize(context); - }); - - const emitPlan = Effect.fnUntraced(function* ( - context: ActiveAcpTurn, - update: AcpPlanUpdate, - ) { - yield* closeTextStreams(context); - const existing = context.plans.get(update.nativePlanId); - if (update.kind === "removed" && existing === undefined) return; - const nativeItemId = `${context.nativeTurnId}:plan:${encodeURIComponent(update.nativePlanId)}`; - const ordinal = yield* resolveItemOrdinal(context, nativeItemId); - const now = yield* DateTime.now; - const nodeId = providerNodeId(nativeItemId); - const turnItemId = providerTurnItemId(nativeItemId); - const planState = existing ?? { - id: yield* idAllocator.allocate.plan({ - threadId: context.input.threadId, - runId: context.input.runId, - driver, - }), - startedAt: now, - latest: null, - }; - const planId = planState.id; - const nativeItemRef = { driver, nativeId: nativeItemId, strength: "weak" as const }; - const base = { - id: planId, - threadId: context.input.threadId, - runId: context.input.runId, - nodeId, - } as const; - let plan: OrchestrationV2PlanArtifact; - if (update.kind === "removed") { - if (planState.latest === null) return; - plan = { ...planState.latest, status: "superseded" }; - } else if (update.kind === "items") { - const steps: ReadonlyArray = update.plan.map( - (step, index) => ({ - id: `acp-step-${index + 1}`, - text: nonEmptyText(step.step, `Step ${index + 1}`), - status: - step.status === "inProgress" - ? "running" - : step.status === "completed" - ? "completed" - : "pending", - }), - ); - const completed = - steps.length > 0 && steps.every((step) => step.status === "completed"); - plan = { - ...base, - status: completed ? "completed" : "active", - kind: "todo_list", - steps, - ...(update.explanation == null ? {} : { explanation: update.explanation }), - }; - } else { - const markdown = - update.kind === "markdown" - ? update.markdown - : update.kind === "file" - ? `Plan file: ${update.uri}` - : `[Unsupported ACP plan content: ${update.contentType}]`; - plan = { - ...base, - status: "active", - kind: "proposed_plan", - markdown, - }; - } - planState.latest = plan; - context.plans.set(update.nativePlanId, planState); - const completed = plan.status === "completed" || plan.status === "superseded"; - const nodeKind = plan.kind === "todo_list" ? "todo_list" : "plan"; - yield* emitProviderEvent({ - type: "node.updated", - driver, - node: { - id: nodeId, - threadId: context.input.threadId, - runId: context.input.runId, - parentNodeId: context.input.rootNodeId, - rootNodeId: context.input.rootNodeId, - kind: nodeKind, - status: completed ? "completed" : "running", - countsForRun: false, - providerThreadId: context.input.providerThread.id, - providerTurnId: context.providerTurnId, - nativeItemRef, - runtimeRequestId: null, - checkpointScopeId: null, - startedAt: planState.startedAt, - completedAt: completed ? now : null, - }, - }); - yield* emitProviderEvent({ type: "plan.updated", driver, plan }); - yield* emitProviderEvent({ - type: "turn_item.updated", - driver, - turnItem: { - id: turnItemId, - threadId: context.input.threadId, - runId: context.input.runId, - nodeId, - providerThreadId: context.input.providerThread.id, - providerTurnId: context.providerTurnId, - nativeItemRef, - parentItemId: null, - ordinal, - status: completed ? "completed" : "running", - title: null, - startedAt: planState.startedAt, - completedAt: completed ? now : null, - updatedAt: now, - ...(plan.kind === "todo_list" - ? { - type: "todo_list" as const, - planId, - steps: plan.steps, - ...(plan.explanation === undefined ? {} : { explanation: plan.explanation }), - } - : { - type: "proposed_plan" as const, - planId, - markdown: plan.markdown, - streaming: !completed, - }), - }, - }); - }); - - const appendLoadedHistory = ( - notification: EffectAcpSchema.SessionNotification, - role: "user" | "assistant" | "thought", - text: string, - replace = false, - ) => - Effect.gen(function* () { - if (text.length === 0 && !replace) return; - const now = yield* DateTime.now; - yield* Ref.update(snapshot, (current) => { - const update = notification.update; - const sourceMessageId = - (update.sessionUpdate === "user_message_chunk" || - update.sessionUpdate === "agent_message_chunk" || - update.sessionUpdate === "agent_thought_chunk" || - update.sessionUpdate === "user_message" || - update.sessionUpdate === "agent_message" || - update.sessionUpdate === "agent_thought") && - update.messageId - ? update.messageId - : null; - const startsNew = - current.loadingRole !== role || - (sourceMessageId !== null && current.loadingMessageId !== sourceMessageId); - const loadingIndex = startsNew ? current.loadingIndex + 1 : current.loadingIndex; - const nativeItemId = - sourceMessageId === null - ? `${notification.sessionId}:history:${role}:${loadingIndex}` - : `${notification.sessionId}:history:${role}:message:${sourceMessageId}`; - const messageId = providerMessageId(nativeItemId); - const key = String(messageId); - const previous = current.messages.get(key); - const messages = new Map(current.messages); - messages.set(key, { - createdBy: previous?.createdBy ?? (role === "user" ? "user" : "agent"), - creationSource: previous?.creationSource ?? "provider", - id: messageId, - threadId: input.threadId, - runId: null, - nodeId: null, - role: role === "user" ? "user" : "assistant", - text: replace ? text : `${previous?.text ?? ""}${text}`, - attachments: [], - streaming: false, - createdAt: previous?.createdAt ?? now, - updatedAt: now, - }); - return { - order: current.order.includes(key) ? current.order : [...current.order, key], - messages, - loadingRole: role, - loadingMessageId: sourceMessageId ?? (startsNew ? null : current.loadingMessageId), - loadingIndex, - }; - }); - }); - - const offerContinuationRun = Effect.fnUntraced(function* (_sessionId: string) { - if (continuationRequests === undefined) { - return false; - } - const pending = yield* continuationPermit.withPermit( - Effect.gen(function* () { - if (yield* Ref.get(continuationClosed)) return Option.none(); - if (yield* Ref.get(stoppedRunQuarantine)) return Option.none(); - if (yield* Ref.get(continuationRequested)) return Option.none(); - const route = yield* Ref.get(lastTurnRoute); - if (route === null) return Option.none(); - yield* Ref.set(continuationRequested, true); - const generation = yield* Ref.updateAndGet( - continuationGeneration, - (value) => value + 1, - ); - const reports = yield* Ref.modify(wakeReports, ({ reports }) => [ - reports, - { reports, offered: new Set(reports.keys()) }, - ]); - return Option.some({ route, generation, reports }); - }), - ); - if (Option.isNone(pending)) return false; - const { route, generation, reports } = pending.value; - const notification = backgroundWorkNotification([...reports.values()]); - yield* Effect.logInfo("orchestration-v2.acp-wake-turn-detected", { - driver, - providerSessionId: input.providerSessionId, - threadId: route.threadId, - providerThreadId: route.providerThreadId, - }); - yield* continuationRequests.offer({ - threadId: route.threadId, - providerThreadId: route.providerThreadId, - driver, - detail: null, - ...(notification === null ? {} : { notification }), - clearIfCurrent: () => - continuationPermit.withPermit( - Effect.gen(function* () { - if ((yield* Ref.get(continuationGeneration)) === generation) { - yield* Ref.set(continuationRequested, false); - } - }), - ), - dispatchIfCurrent: (effect) => - continuationPermit.withPermit( - Effect.gen(function* () { - const clearIfOwner = Effect.gen(function* () { - if ((yield* Ref.get(continuationGeneration)) === generation) { - yield* Ref.set(continuationRequested, false); - } - }); - if (yield* Ref.get(stoppedRunQuarantine)) { - yield* clearIfOwner; - return Option.none(); - } - if ((yield* Ref.get(continuationGeneration)) !== generation) { - // Superseded by a newer offer; do not clear its flag. - return Option.none(); - } - if (!(yield* Ref.get(continuationRequested))) return Option.none(); - // A successful durable dispatch owns the sticky flag until its - // continuation turn starts. Clearing here opens a dispatch-to- - // start race where every late ACP frame can enqueue another - // synthetic continuation. Failures clear immediately because - // no turn will arrive to do so. - const exit = yield* Effect.exit(effect); - if (Exit.isFailure(exit)) { - yield* clearIfOwner; - return yield* Effect.failCause(exit.cause); - } - return Option.some(exit.value); - }), - ), - }); - return true; - }); - - const applyLateBackgroundMutation = Effect.fnUntraced(function* ( - sessionId: string, - mutation: { - readonly taskId: string; - readonly status: "running" | "completed" | "failed"; - }, - ) { - const taskAlreadyEnded = (yield* Ref.get(endedBackgroundTaskIds)).has(mutation.taskId); - yield* applyBackgroundTaskMutationRunning(mutation); - if (mutation.status === "running") { - // Straggler running notice after a genuine end: suppress residual - // monitor-prompt agent chatter so it cannot open a synthetic wake. - if (taskAlreadyEnded && acpPostSettleMonitorPromptShouldSuppress(mutation)) { - yield* Ref.set(suppressPostSettleMonitorPrompt, true); - } - return; - } - // First genuine terminal: allow subsequent agent output. Re-delivery - // of an already-ended terminal must not un-suppress residual ack - // chatter from the injected monitor-event turn. - if (!taskAlreadyEnded) { - yield* Ref.set(suppressPostSettleMonitorPrompt, false); - } - // Keep handledBackgroundTaskIdsInActiveTurn intact. Erasing the mark - // on a late monitor-event mutation defeated the in-turn chatter guard - // and let injected-turn acks arm wakeBuffer for a later spurious - // "Background task completed." run (multiturn live repro). Monitors - // the agent settled without reporting never enter that set, so their - // injected report still streams via pendingInjectedReport / hold. - const activeContext = yield* Ref.get(activeTurn); - // Completions that land while a root turn is still streaming are - // owned by in-turn machinery (emitTool handled marks, deferred - // finalize, injected-report hold). Do not open a synthetic wake mid- - // turn; finalizeTurn re-checks wakeBuffer / midTurnUnreported once - // the turn leaves the active slot so legitimate unhandled evidence - // still offers after finalize. - if (activeContext !== null && !activeContext.finalized) { - const handled = yield* Ref.get(handledBackgroundTaskIdsInActiveTurn); - // Only arm deferred wake when this turn registered the task (monitor - // tool started in-turn) and the root prompt is still open. Completions - // that land in the settled-held window (promptSettled, deferred - // finalize holding for injected report) fall back to the pre-existing - // post-finalize injected-turn path; arming here would spuriously - // offer "Background task completed." after the report streams into - // the held turn. Residual cancel-backgrounded completions from a - // prior interrupt must not open a synthetic continuation. - if ( - !activeContext.promptSettled && - !handled.has(mutation.taskId) && - activeContext.toolCallIdsByBackgroundTaskId.has(mutation.taskId) - ) { - yield* Ref.update(midTurnUnreportedCompletedTaskIds, (current) => - new Set(current).add(mutation.taskId), - ); - } - return; - } - if ( - postSettleContinuationEnabled && - (yield* Ref.get(activeSessionId)) === sessionId && - (yield* Ref.get(runningBackgroundTaskIds)).size === 0 && - ((yield* Ref.get(wakeBuffer)).length > 0 || - (yield* Ref.get(midTurnUnreportedCompletedTaskIds)).size > 0) - ) { - yield* offerContinuationRun(sessionId); - } - }); - - // A structured task end (Grok `task_completed`) is authoritative for the - // tool that registered the task: its own updates only ever say running. - // Finish the row while deferred finalize holds a settled root turn open - // for it, so the turn can settle. While the prompt is still open the - // agent reports the end itself (TaskOutput hydration), and an unreported - // end must keep its post-finalize continuation offer. - const finishRegisteredBackgroundTool = Effect.fnUntraced(function* (mutation: { - readonly taskId: string; - readonly status: "completed" | "failed"; - readonly output?: string; - }) { - const context = yield* Ref.get(activeTurn); - if (context === null || context.finalized || !context.promptSettled) return; - const toolCallId = context.toolCallIdsByBackgroundTaskId.get(mutation.taskId); - const tool = toolCallId === undefined ? undefined : context.tools.get(toolCallId); - if (tool === undefined) return; - const status = toolStatus(tool.status); - if (status !== "pending" && status !== "running") return; - context.awaitingBackgroundHydration.delete(mutation.taskId); - const finished = { ...tool, status: mutation.status }; - yield* emitTool( - context, - mutation.output === undefined ? finished : setToolOutputText(finished, mutation.output), - mutation.status, - ); - yield* rearmDeferredFinalize(context); - }); - - const bufferPostSettleWake = Effect.fnUntraced(function* ( - notification: EffectAcpSchema.SessionNotification, - ) { - if (!postSettleContinuationEnabled || continuationRequests === undefined) { - return { - buffered: false, - offerContinuation: false, - stopProcessing: false, - }; - } - // Direct Stop quarantine: drop residual wake evidence instead of - // buffering it for a later continuation or follow-up run. - if (yield* Ref.get(stoppedRunQuarantine)) { - return { - buffered: false, - offerContinuation: false, - stopProcessing: true, - }; - } - const rootSessionId = yield* Ref.get(activeSessionId); - if (rootSessionId === null || notification.sessionId !== rootSessionId) { - return { - buffered: false, - offerContinuation: false, - stopProcessing: false, - }; - } - if (!acpPostSettleWakeEvidence(notification, flavor)) { - return { - buffered: false, - offerContinuation: false, - stopProcessing: false, - }; - } - const update = notification.update; - let alreadyHandledToolUpdate = false; - if (update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update") { - yield* trackRunningBackgroundTools(notification); - // When the agent consumes the monitor output itself via - // get_command_or_subagent_output (long timeout), the TaskOutput - // completion is the ONLY end signal: no "Monitor ended" reminder - // follows. Without applying it here the running set never clears, - // every offer stays suppressed, and the wake buffer never drains - // (observed live 2026-07-12, thread 8dbe607f). The completion - // frame itself is offer evidence, so clearing the id before the - // gate below lets it open the single continuation run. - for (const event of parseSessionUpdateEvent(notification).events) { - if (event._tag !== "ToolCallUpdated") continue; - const toolCall = flavor.normalizeToolCall?.(event.toolCall) ?? event.toolCall; - const proposedPlanMarkdown = flavor.extractProposedPlanMarkdown?.(toolCall); - if (proposedPlanMarkdown !== undefined && proposedPlanMarkdown.length > 0) { - yield* captureProposedPlan({ planMarkdown: proposedPlanMarkdown }); - } - const toolTaskId = flavor.extractBackgroundTaskId?.(toolCall); - if ( - toolTaskId !== undefined && - (yield* Ref.get(handledBackgroundTaskIdsInActiveTurn)).has(toolTaskId) - ) { - alreadyHandledToolUpdate = true; - } - if (flavor.extractBackgroundTaskCompletion !== undefined) { - for (const completion of flavor.extractBackgroundTaskCompletion(toolCall)) { - alreadyHandledToolUpdate = - alreadyHandledToolUpdate || - (yield* Ref.get(handledBackgroundTaskIdsInActiveTurn)).has(completion.taskId); - yield* applyBackgroundTaskMutationRunning(completion); - } - } - } - } - const backgroundWorkRunning = (yield* Ref.get(runningBackgroundTaskIds)).size > 0; - // Residual Grok agent/thought chatter after in-turn-handled background - // work must not be retained as wake evidence. Tool-path alreadyHandled - // covers re-reports with a task id; this covers agent_message_chunk / - // agent_thought_chunk frames that carry no task id (live: - // grok-in-turn-monitor-no-wake, multiturn stale-buffer arm). Check - // before buffering so the frames cannot dirty wakeBuffer and later - // arm a mid-turn offer when a second monitor completes. - const handledInTurnCount = (yield* Ref.get(handledBackgroundTaskIdsInActiveTurn)).size; - const isInTurnHandledAgentChatter = - handledInTurnCount > 0 && - (update.sessionUpdate === "agent_message_chunk" || - update.sessionUpdate === "agent_thought_chunk"); - // Grok prompts itself for every monitor event after the root turn - // settles. Its assistant/reasoning replies are progress chatter, not - // separate wake results. Retaining them would replay the entire burst - // into the single continuation once the monitor finishes. Keep tool - // state so the final command card still hydrates, then begin retaining - // agent output again after the genuine end signal clears the running - // set. With multiple tasks, text remains best-effort until every task - // ends; their retained tool cards are the authoritative results. - // - // Skip retaining frames for tasks already hydrated in the root turn - // (`handledBackgroundTaskIdsInActiveTurn`). Those re-reports must not - // pin `hasPendingBackgroundWork` via a wake buffer that never drains - // (the already-handled gate below intentionally skips - // `offerContinuationRun` to avoid synthetic "Background task - // completed." spam). - let buffered = false; - if ( - !alreadyHandledToolUpdate && - !isInTurnHandledAgentChatter && - acpPostSettleWakeShouldBuffer(notification, backgroundWorkRunning) - ) { - yield* Ref.update(wakeBuffer, (current) => [...current, notification]); - buffered = true; - } - // Buffer progress without offering; only completion-like frames open a run. - if (!acpPostSettleContinuationOfferEvidence(notification, flavor)) { - return { - buffered, - offerContinuation: false, - stopProcessing: true, - }; - } - // While a monitor is still streaming, tool re-reports buffer without - // offering and per-event agent commentary is consumed without being - // retained. Grok re-reports a running monitor as Bash frames that - // already carry exit_code 0 mid-stream, so a "terminal" normalized - // status is not evidence the task ended; each burst would otherwise - // reopen a synthetic "Background task completed." run. Retained tool - // frames drain into the single continuation offered once the monitor - // actually ends (end-notice mutation below, or the first frame after - // it). - if (backgroundWorkRunning) { - return { - buffered, - offerContinuation: false, - stopProcessing: true, - }; - } - if (alreadyHandledToolUpdate) { - // Drop leftover wake noise for in-turn-handled work so idle release - // is not pinned forever. Leave the buffer alone when a continuation - // is already outstanding: its startTurn will drain legitimate frames - // from other tasks. - if (!(yield* Ref.get(continuationRequested))) { - yield* Ref.set(wakeBuffer, []); - } - return { - buffered, - offerContinuation: false, - stopProcessing: true, - }; - } - // Same in-turn-handled agent chatter: do not open a synthetic wake. - // Do not clear wakeBuffer here: frames for other still-tracked tasks - // must remain drainable when a real (tool) completion later offers. - if (isInTurnHandledAgentChatter) { - return { - buffered, - offerContinuation: false, - stopProcessing: true, - }; - } - return { - buffered, - offerContinuation: true, - stopProcessing: true, - }; - }); - - let applyFinalizedActiveTurnSubagentTerminal: ( - context: ActiveAcpTurn, - notification: EffectAcpSchema.SessionNotification, - ) => Effect.Effect = () => Effect.succeed(false); - - const projectAgentTerminalUpdate = Effect.fnUntraced(function* ( - notification: EffectAcpSchema.SessionNotification, - context: ActiveAcpTurn | null, - ) { - const update = notification.update; - if ( - update.sessionUpdate !== "terminal_update" && - update.sessionUpdate !== "terminal_output_chunk" - ) { - return false; - } - const terminalKey = sessionScopedId(notification.sessionId, update.terminalId); - const next = applyAcpAgentTerminalUpdate(agentTerminalsById.get(terminalKey), update); - agentTerminalsById.set(terminalKey, next); - if (context === null || notification.sessionId !== context.nativeThreadId) return true; - const status = - next.exitStatus === undefined - ? ("inProgress" as const) - : next.exitStatus.exitCode === 0 - ? ("completed" as const) - : ("failed" as const); - const embeddedToolCallIds = toolCallIdsByAgentTerminalId.get(terminalKey); - const projectedToolCallIds = - embeddedToolCallIds === undefined || embeddedToolCallIds.size === 0 - ? [`acp-agent-terminal:${update.terminalId}`] - : [...embeddedToolCallIds]; - for (const toolCallId of projectedToolCallIds) { - const existing = context.tools.get(toolCallId); - const seeded: AcpToolCallState = - existing ?? - ({ - toolCallId, - kind: "execute", - title: next.command ?? "Terminal", - status, - ...(next.command === undefined ? {} : { command: next.command }), - data: { toolCallId }, - } satisfies AcpToolCallState); - yield* emitTool( - context, - setToolOutputText( - { - ...seeded, - status, - ...(next.command === undefined ? {} : { command: next.command }), - }, - next.output, - ), - ); - } - return true; - }); - - const handleSessionUpdate = Effect.fnUntraced(function* ( - notification: EffectAcpSchema.SessionNotification, - ) { - const context = yield* Ref.get(activeTurn); - const update = notification.update; - if (yield* projectAgentTerminalUpdate(notification, context)) return; - if ( - update.sessionUpdate === "usage_update" || - update.sessionUpdate === "session_info_update" || - (update.sessionUpdate === "state_update" && - update.state === "idle" && - update.usage != null) - ) { - const stateEvent = parseSessionUpdateEvent(notification).events.find( - (event) => event._tag === "UsageUpdated" || event._tag === "SessionInfoUpdated", - ); - if (stateEvent === undefined) return; - if (stateEvent._tag === "UsageUpdated") { - yield* Ref.update(contextUsageBySessionId, (current) => - new Map(current).set(notification.sessionId, stateEvent.usage), - ); - if (context?.nativeThreadId === notification.sessionId) { - context.contextUsage = stateEvent.usage; - } - } else { - const metadata = yield* Ref.modify(nativeMetadataBySessionId, (current) => { - const merged = { - ...current.get(notification.sessionId), - ...stateEvent.metadata, - }; - return [merged, new Map(current).set(notification.sessionId, merged)] as const; - }); - if (context?.nativeThreadId === notification.sessionId) { - context.nativeMetadata = metadata; - } - } - const knownProviderThread = (yield* Ref.get(providerThreadByNativeSessionId)).get( - notification.sessionId, - ); - if (knownProviderThread !== undefined) { - const now = yield* DateTime.now; - const providerThread: OrchestrationV2ProviderThread = { - ...knownProviderThread, - contextUsage: - (yield* Ref.get(contextUsageBySessionId)).get(notification.sessionId) ?? - knownProviderThread.contextUsage ?? - null, - nativeMetadata: - (yield* Ref.get(nativeMetadataBySessionId)).get(notification.sessionId) ?? - knownProviderThread.nativeMetadata ?? - null, - updatedAt: now, - }; - yield* Ref.update(providerThreadByNativeSessionId, (current) => - new Map(current).set(notification.sessionId, providerThread), - ); - yield* emitProviderEvent({ - type: "provider_thread.updated", - driver, - providerThread, - }); - } - return; - } - if ( - context?.finalized === true && - (yield* applyFinalizedActiveTurnSubagentTerminal(context, notification)) - ) { - return; - } - // Only while a finalized turn is still the active context. When - // activeTurn is null, post-settle agent frames must reach - // bufferPostSettleWake so continuation can attach (context?.finalized - // !== false incorrectly treated null as finalized and dropped them). - if ( - context !== null && - context.finalized && - (yield* Ref.get(handledBackgroundTaskIdsInActiveTurn)).size > 0 && - (update.sessionUpdate === "agent_message_chunk" || - update.sessionUpdate === "agent_thought_chunk") - ) { - yield* Ref.set(suppressPostSettleMonitorPrompt, true); - return; - } - if ( - context !== null && - (yield* Ref.get(suppressPostSettleMonitorPrompt)) && - (update.sessionUpdate === "agent_message_chunk" || - update.sessionUpdate === "agent_thought_chunk") - ) { - return; - } - if ( - context?.finalized === true && - update.sessionUpdate === "user_message_chunk" && - update.content.type === "text" - ) { - const mutations = flavor.extractBackgroundToolMutation?.(update.content.text) ?? []; - for (const mutation of mutations) { - yield* applyLateBackgroundMutation(notification.sessionId, mutation); - } - return; - } - if (context === null) { - // Direct Stop: quarantine residual events from the stopped run so - // they cannot become history, wake buffers, or a later run attach. - if (yield* Ref.get(stoppedRunQuarantine)) { - return; - } - const bufferOutcome = yield* bufferPostSettleWake(notification); - // Post-settle carryover sync: keep in-memory carryover accurate so - // hasPendingBackgroundWork reasons correctly after root settle. - // A completed root keeps its subscriber open while background items - // remain, so project its terminals immediately even when the wake - // frame is buffered for a continuation. Non-completed roots stay - // memory-only: durable ingest requires a subscriber that owns the - // original runId, which the interrupted path does not guarantee. - const carryover = yield* Ref.get(carryoverSubagents); - const rootTerminalCanStillProject = - carryover !== null && - carryover.sessionId === (yield* Ref.get(activeSessionId)) && - carryover.rootTerminalStatus === "completed"; - const projectCarryover = rootTerminalCanStillProject; - let carryoverTerminalized = false; - if ( - flavor.extractSubagentUpdate !== undefined && - (update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update") - ) { - for (const event of parseSessionUpdateEvent(notification).events) { - if (event._tag !== "ToolCallUpdated") continue; - const toolCall = flavor.normalizeToolCall?.(event.toolCall) ?? event.toolCall; - const subagentUpdate = flavor.extractSubagentUpdate(toolCall); - if (subagentUpdate === undefined) continue; - if (!acpSubagentStatusIsTerminal(subagentUpdate.status)) { - continue; - } - if ( - yield* updateCarryoverSubagentStatus( - subagentUpdate.nativeTaskId, - subagentUpdate.status, - subagentUpdate.result, - { project: projectCarryover }, - ) - ) { - carryoverTerminalized = true; - } - if ( - subagentUpdate.childSessionId !== null && - (yield* updateCarryoverSubagentStatus( - subagentUpdate.childSessionId, - subagentUpdate.status, - subagentUpdate.result, - { project: projectCarryover }, - )) - ) { - carryoverTerminalized = true; - } - } - } - if ( - update.sessionUpdate === "user_message_chunk" && - update.content.type === "text" && - flavor.extractSubagentEndNotice !== undefined - ) { - const notice = flavor.extractSubagentEndNotice(update.content.text); - if ( - notice !== undefined && - (yield* updateCarryoverSubagentStatus( - notice.childSessionId, - notice.status, - undefined, - { - project: projectCarryover, - }, - )) - ) { - carryoverTerminalized = true; - } - } - // Synchronize carryover before an eager continuation can attach and - // drain the frame. - const continuationOffered = bufferOutcome.offerContinuation - ? yield* offerContinuationRun(notification.sessionId) - : false; - const wakeOutcome = { ...bufferOutcome, continuationOffered }; - // Frames that will not buffer never reach the continuation drain for - // this traffic. Once carryover is terminalized, skip history append / - // residual handling (and avoid double-projecting on child re-entry). - if (carryoverTerminalized && !wakeOutcome.buffered) { - // Projected above. Never clear a non-empty wakeBuffer. A sticky - // continuationRequested with an empty buffer is safe to drop so - // idle release is not wed on a pin with nothing to deliver. - if ( - acpCarryoverTerminalShouldClearContinuation({ - continuationOffered: wakeOutcome.continuationOffered, - wakeBufferLength: (yield* Ref.get(wakeBuffer)).length, - }) - ) { - yield* Ref.set(continuationRequested, false); - } - return; - } - // Prefer continuation buffering over history append so the same - // frames are not double-counted once a continuation run attaches. - if (wakeOutcome.stopProcessing) { - return; - } - if ( - update.sessionUpdate === "user_message_chunk" || - update.sessionUpdate === "agent_message_chunk" || - update.sessionUpdate === "agent_thought_chunk" - ) { - // Late monitor end/event reminders must not become ghost user/assistant - // history (or OS-facing chatter) after the root turn already finalized. - const text = acpContentBlockDisplayText(update.content); - if (text === undefined) return; - const lateBackgroundMutations = - update.content.type === "text" - ? (flavor.extractBackgroundToolMutation?.(text) ?? []) - : []; - for (const lateBackgroundMutation of lateBackgroundMutations) { - yield* applyLateBackgroundMutation(notification.sessionId, lateBackgroundMutation); - } - const lateMonitorChatter = - / 0 && - lateBackgroundMutations.length === 0 && - !lateMonitorChatter - ) { - yield* appendLoadedHistory( - notification, - update.sessionUpdate === "user_message_chunk" - ? "user" - : update.sessionUpdate === "agent_thought_chunk" - ? "thought" - : "assistant", - text, - ); - } - } else if ( - update.sessionUpdate === "user_message" || - update.sessionUpdate === "agent_message" || - update.sessionUpdate === "agent_thought" - ) { - if (update.content === undefined) return; - const text = (update.content ?? []) - .flatMap((content) => { - const display = acpContentBlockDisplayText(content); - return display === undefined ? [] : [display]; - }) - .join("\n"); - yield* appendLoadedHistory( - notification, - update.sessionUpdate === "user_message" - ? "user" - : update.sessionUpdate === "agent_thought" - ? "thought" - : "assistant", - text, - true, - ); - } else if ( - update.sessionUpdate === "tool_call" || - update.sessionUpdate === "tool_call_update" || - update.sessionUpdate === "plan" || - update.sessionUpdate === "plan_update" || - update.sessionUpdate === "plan_removed" - ) { - yield* Ref.update(snapshot, (current) => ({ - ...current, - loadingRole: null, - loadingMessageId: null, - })); - } - return; - } - if (context.finalized) return; - const rootSessionId = yield* Ref.get(activeSessionId); - if (notification.sessionId !== rootSessionId) { - // Finalize may have completed during the activeSessionId yield. - if (context.finalized) return; - if (flavor.extractSubagentUpdate === undefined) return; - const subagent = context.subagentsBySessionId.get(notification.sessionId); - if ( - update.sessionUpdate === "tool_call" || - update.sessionUpdate === "tool_call_update" - ) { - if (subagent === undefined) return; - const nativeTaskId = - subagent.task.nativeTaskRef?.nativeId ?? String(subagent.task.id); - for (const event of parseSessionUpdateEvent(notification).events) { - if (event._tag !== "ToolCallUpdated") continue; - const toolCall = flavor.normalizeToolCall?.(event.toolCall) ?? event.toolCall; - const subagentUpdate = flavor.extractSubagentUpdate(toolCall); - if (subagentUpdate !== undefined) { - if ( - subagentUpdate.nativeTaskId === nativeTaskId || - subagentUpdate.childSessionId === notification.sessionId - ) { - yield* emitSubagent(context, subagentUpdate); - } - continue; - } - const key = `${nativeTaskId}:tool:${toolCall.toolCallId}`; - const previous = context.tools.get(key); - const merged = mergeToolCallState(previous, toolCall); - context.tools.set(key, merged); - if (!shouldPersistToolUpdate(context, key, previous, merged, merged.status)) - continue; - // Terminals are remembered under the raw session id: the child's - // own session, or the root one when the flavor routes child - // updates out of it (Devin). - const mcpIdentity = extractMcpToolCallIdentity(merged, { - embeddedTerminalCommands: [ - ...embeddedTerminalCommands(notification.sessionId, toolCall.toolCallId), - ...(rootSessionId === null - ? [] - : embeddedTerminalCommands(rootSessionId, toolCall.toolCallId)), - ], - }); - const now = yield* DateTime.now; - const status = toolStatus(merged.status); - const startedAt = context.toolStartedAt.get(key) ?? now; - context.toolStartedAt.set(key, startedAt); - const ordinal = resolveSubagentChildOrdinal(subagent, key); - yield* emitProviderEvent({ - type: "turn_item.updated", - driver, - turnItem: { - id: providerTurnItemId(key), - threadId: subagent.childThreadId, - runId: null, - nodeId: subagent.childRootNodeId, - providerThreadId: subagent.task.providerThreadId, - providerTurnId: null, - nativeItemRef: { driver, nativeId: key, strength: "strong" }, - parentItemId: null, - ordinal, - status, - title: merged.title ?? merged.kind ?? "Tool", - startedAt, - completedAt: completedAtForStatus(status, now), - updatedAt: now, - type: "dynamic_tool", - ...(mcpIdentity === undefined - ? {} - : mcpToolPresentation({ - serverName: mcpIdentity.server, - toolName: mcpIdentity.tool, - source: unknownRecord( - ( - unknownRecord(unknownRecord(merged.data.rawOutput)?.result) ?? - unknownRecord(merged.data.rawOutput) - )?._meta, - )?.source, - })), - toolName: - mcpIdentity === undefined - ? (merged.title ?? merged.kind ?? "Tool") - : `${mcpIdentity.server}.${mcpIdentity.tool}`, - input: merged.data.rawInput ?? null, - output: merged.data.rawOutput ?? merged.data.content ?? null, - }, - }); - } - return; - } - const isDisplayableAssistantUpdate = - (update.sessionUpdate === "agent_message_chunk" && - acpContentBlockDisplayText(update.content) !== undefined) || - (update.sessionUpdate === "agent_message" && update.content !== undefined); - if (!isDisplayableAssistantUpdate) { - return; - } - if (subagent !== undefined) { - yield* projectSubagentNotification(subagent, notification); - return; - } - const buffered = context.pendingSubagentNotifications.get(notification.sessionId) ?? []; - buffered.push(notification); - context.pendingSubagentNotifications.set(notification.sessionId, buffered); - return; - } - // Re-check after the activeSessionId yield: idle/prompt settle can - // finalize the same context object while we waited. - if (context.finalized) return; - // The agent started its own turn about finished background work while - // this settled root turn was still held for it. That turn is the - // agent's reply, not more of the root: settle the root first so the - // frame reaches the wake path and opens a continuation run. - if ( - context.promptSettled && - !acpIsProviderContinuationMessage(context.input.message) && - flavor.isProviderWakeNotification?.(notification) === true - ) { - if (hasDeferredBackgroundWork(context)) { - yield* Ref.update(wakeBuffer, (current) => [...current, notification]); - return; - } - yield* finalizeTurn(context, context.promptSettledStatus ?? "completed"); - const wake = yield* bufferPostSettleWake(notification); - if (wake.offerContinuation) { - yield* offerContinuationRun(notification.sessionId); - } - return; - } - // Only fresh model output proves a retry recovered; progress on - // tools and plans that started earlier can arrive mid-retry. - if ( - update.sessionUpdate !== "tool_call_update" && - update.sessionUpdate !== "plan" && - update.sessionUpdate !== "plan_update" && - update.sessionUpdate !== "plan_removed" && - acpRootSessionUpdateIngestsOutput(notification) - ) { - yield* emitProviderRetry(context, "completed"); - } - switch (update.sessionUpdate) { - case "state_update": { - const toolCallId = `${context.nativeTurnId}:requires-action`; - const existing = context.tools.get(toolCallId); - if (update.state === "requires_action") { - yield* emitTool(context, { - toolCallId, - kind: "other", - title: "Action required", - status: "requiresAction", - data: { state: "requires_action" }, - }); - } else if (existing?.status === "requiresAction") { - yield* emitTool(context, { - ...existing, - status: "completed", - data: { ...existing.data, state: update.state }, - }); - } - break; - } - case "agent_message_chunk": { - const text = acpContentBlockDisplayText(update.content); - if (text !== undefined) { - const startsNewAssistantSegment = context.assistant.current === null; - // The injected-turn report is streaming; the normal debounce - // after the last chunk takes over from here. Drop matching - // mid-turn armed ids so finalize does not open a duplicate - // wake after the report already projected into this turn. - if (context.pendingInjectedReport.size > 0) { - const reportedTaskIds = [...context.pendingInjectedReport]; - context.pendingInjectedReport.clear(); - yield* clearMidTurnUnreportedTaskIds(reportedTaskIds); - } else if ( - startsNewAssistantSegment && - context.promptSettled && - (yield* Ref.get(midTurnUnreportedCompletedTaskIds)).size > 0 - ) { - // Some ACP implementations deliver the injected assistant - // report before the synthetic monitor end notice. Correlation - // arrives with that notice, so remember the unmatched report - // and consume its task id when the notice follows. - context.earlyInjectedReportObserved = true; - } - yield* appendText(context, "assistant", text, update.messageId); - } - break; - } - case "agent_thought_chunk": { - const text = acpContentBlockDisplayText(update.content); - if (text !== undefined) { - yield* appendText(context, "reasoning", text, update.messageId); - } - break; - } - case "user_message": - case "agent_message": - case "agent_thought": { - if (update.content === undefined) break; - const text = (update.content ?? []) - .flatMap((content) => { - const display = acpContentBlockDisplayText(content); - return display === undefined ? [] : [display]; - }) - .join("\n"); - yield* replaceText( - context, - update.sessionUpdate === "user_message" - ? "user" - : update.sessionUpdate === "agent_message" - ? "assistant" - : "reasoning", - text, - update.messageId, - ); - break; - } - case "tool_call_content_chunk": { - const text = - update.content.type === "content" - ? acpContentBlockDisplayText(update.content.content) - : update.content.type === "diff" - ? "changes" in update.content - ? update.content.patch?.text - : update.content.newText - : undefined; - if (text !== undefined) { - const previous = context.tools.get(update.toolCallId) ?? { - toolCallId: update.toolCallId, - status: "inProgress" as const, - data: { toolCallId: update.toolCallId }, - }; - yield* emitTool(context, appendToolOutputText(previous, text)); - } - break; - } - case "compaction_summary_chunk": { - const text = acpContentBlockDisplayText(update.content); - if (text !== undefined) { - const toolCallId = `acp-compaction:${update.compactionId}`; - const previous = context.tools.get(toolCallId) ?? { - toolCallId, - kind: "think", - title: "Compact context", - status: "inProgress" as const, - data: { toolCallId }, - }; - yield* emitTool(context, appendToolOutputText(previous, text)); - } - break; - } - case "user_message_chunk": { - if (update.content.type === "text" && flavor.extractBackgroundToolMutation) { - const mutations = flavor.extractBackgroundToolMutation(update.content.text); - const terminalTaskIds = mutations - .filter((mutation) => mutation.status !== "running") - .map((mutation) => mutation.taskId); - const reportArrivedBeforeNotice = - context.earlyInjectedReportObserved && terminalTaskIds.length > 0; - if (reportArrivedBeforeNotice) { - context.earlyInjectedReportObserved = false; - yield* clearMidTurnUnreportedTaskIds(terminalTaskIds); - } - for (const mutation of mutations) { - const toolCallId = context.toolCallIdsByBackgroundTaskId.get(mutation.taskId); - const previous = - toolCallId !== undefined ? context.tools.get(toolCallId) : undefined; - if (previous !== undefined) { - let nextStatus = - mutation.status === "running" - ? ("inProgress" as const) - : mutation.status === "failed" - ? ("failed" as const) - : ("completed" as const); - // End notices typically omit full stdout (no get_command mention - // either). Keep the tool running and hold finalize until - // TaskOutput hydrates, or the safety timer force-completes. - if (nextStatus !== "inProgress") { - yield* markAwaitingBackgroundHydration(context, mutation.taskId); - nextStatus = "inProgress"; - } - yield* emitTool( - context, - appendToolOutputText( - { ...previous, status: nextStatus }, - mutation.appendOutput, - ), - ); - } - // After emitTool: the hydration hold keeps the tool row at - // inProgress past an end notice, but the offer gate must see - // the mutation's semantic status. Also tracks monitors that - // never surfaced a tool_call row at all. - yield* applyBackgroundTaskMutationRunning(mutation); - if (mutation.status !== "running" && !reportArrivedBeforeNotice) { - yield* markPendingInjectedReport(context, mutation.taskId); - } - } - } - if (update.content.type === "text" && flavor.extractSubagentEndNotice) { - const notice = flavor.extractSubagentEndNotice(update.content.text); - const subagent = - notice !== undefined - ? context.subagentsBySessionId.get(notice.childSessionId) - : undefined; - if ( - notice !== undefined && - subagent !== undefined && - (subagent.task.status === "running" || subagent.task.status === "pending") - ) { - // The agent is free to answer with text only and never call - // get_command_or_subagent_output; without this, the subagent - // row stays running and holds deferred finalize open forever. - yield* emitSubagent(context, { - nativeTaskId: subagent.task.nativeTaskRef?.nativeId ?? notice.childSessionId, - prompt: subagent.task.prompt, - title: subagent.task.title, - model: subagent.task.model, - status: notice.status, - childSessionId: notice.childSessionId, - result: null, - suppressNormalTool: true, - }); - } - } - // startTurn already projects the submitted prompt. Active ACP - // user chunks are transport echoes or injected background - // notices, so only their semantic mutations belong in the turn. - break; - } - default: { - const parsed = parseSessionUpdateEvent(notification); - for (const event of parsed.events) { - if (event._tag === "ToolCallUpdated") { - yield* emitTool(context, event.toolCall); - } else if (event._tag === "PlanUpdated") { - yield* emitPlan(context, event.payload); - } else if (event._tag === "UnknownUpdate") { - yield* emitTool(context, { - toolCallId: `${context.nativeTurnId}:unsupported:${event.updateType}`, - kind: "other", - title: `Unsupported ACP update: ${event.updateType}`, - status: "completed", - data: { updateType: event.updateType }, - }); - } - } - } - } - // Keep deferred finalize quiet-window fresh while wake traffic lands. - yield* rearmDeferredFinalize(context); - }); - - const activeContext = Effect.gen(function* () { - const context = yield* Ref.get(activeTurn); - if (context === null) { - return yield* new EffectAcpErrors.AcpTransportError({ - detail: "ACP agent requested input without an active turn", - cause: "No active ACP turn", - }); - } - return context; - }); - - const beginApprovalRequest = Effect.fnUntraced(function* ( - context: ActiveAcpTurn, - params: EffectAcpSchema.RequestPermissionRequest, - generation: number, - transportRequestId: string, - ) { - yield* closeTextStreams(context); - const parsed = parsePermissionRequest(params); - const approvalOptions = flavor.approvalOptions?.(params); - const nativeRequestId = params.toolCall.toolCallId; - const requestId = yield* idAllocator.allocate.runtimeRequest({ - driver, - providerTurnId: context.providerTurnId, - nativeRequestId, - }); - const decision = yield* Deferred.make(); - const nativeResponseAcknowledgement = yield* Deferred.make< - void, - EffectAcpErrors.AcpError - >(); - yield* registerNativeResponseAcknowledgement( - generation, - transportRequestId, - nativeResponseAcknowledgement, - ); - const now = yield* DateTime.now; - const nodeId = idAllocator.derive.approvalNode({ requestId }); - const requestKind = providerRequestKind(parsed.kind); - const nativeItemRef = { driver, nativeId: nativeRequestId, strength: "weak" as const }; - const ordinal = yield* resolveItemOrdinal( - context, - `${context.nativeTurnId}:approval:${nativeRequestId}`, - ); - const runtimeRequest: OrchestrationV2RuntimeRequest = { - id: requestId, - nodeId, - providerTurnId: context.providerTurnId, - nativeRequestRef: nativeItemRef, - kind: requestKind, - status: "pending", - responseCapability: { - type: "live", - providerSessionId: input.providerSessionId, - }, - createdAt: now, - resolvedAt: null, - }; - const node: OrchestrationV2ExecutionNode = { - id: nodeId, - threadId: context.input.threadId, - runId: context.input.runId, - parentNodeId: context.input.rootNodeId, - rootNodeId: context.input.rootNodeId, - kind: "approval_request", - status: "waiting", - countsForRun: false, - providerThreadId: context.input.providerThread.id, - providerTurnId: context.providerTurnId, - nativeItemRef, - runtimeRequestId: requestId, - checkpointScopeId: null, - startedAt: now, - completedAt: null, - }; - const turnItem: OrchestrationV2TurnItem = { - id: idAllocator.derive.approvalTurnItem({ requestId }), - threadId: context.input.threadId, - runId: context.input.runId, - nodeId, - providerThreadId: context.input.providerThread.id, - providerTurnId: context.providerTurnId, - nativeItemRef, - parentItemId: null, - ordinal, - status: "waiting", - title: null, - startedAt: now, - completedAt: null, - updatedAt: now, - type: "approval_request", - requestId, - requestKind, - ...(parsed.detail === undefined ? {} : { prompt: parsed.detail }), - ...(approvalOptions === undefined ? {} : { options: approvalOptions }), - }; - yield* Ref.update(pendingRuntimeRequests, (current) => { - const updated = new Map(current); - updated.set(String(requestId), { - type: "approval", - generation, - nativeResponseAcknowledgement, - requestId, - transportRequestId, - decision, - runtimeRequest, - node, - turnItem, - }); - return updated; - }); - yield* emitProviderEvent({ - type: "node.updated", - driver, - node, - }); - yield* emitProviderEvent({ - type: "runtime_request.updated", - driver, - threadId: context.input.threadId, - runtimeRequest, - }); - yield* emitProviderEvent({ - type: "turn_item.updated", - driver, - turnItem, - }); - return { - context, - decision, - nativeResponseAcknowledgement, - requestId, - transportRequestId, - } as const; - }); - - const beginUserInputRequest = Effect.fnUntraced(function* ( - request: AcpAdapterV2UserInputRequest, - generation: number, - transportRequestId: string, - ) { - const context = yield* activeContext; - yield* closeTextStreams(context); - const requestId = yield* idAllocator.allocate.runtimeRequest({ - driver, - providerTurnId: context.providerTurnId, - nativeRequestId: request.nativeRequestId, - }); - const answers = yield* Deferred.make(); - const nativeResponseAcknowledgement = yield* Deferred.make< - void, - EffectAcpErrors.AcpError - >(); - yield* registerNativeResponseAcknowledgement( - generation, - transportRequestId, - nativeResponseAcknowledgement, - ); - const now = yield* DateTime.now; - const nodeId = providerNodeId(request.nativeItemId); - const turnItemId = providerTurnItemId(request.nativeItemId); - const nativeItemRef = { - driver, - nativeId: request.nativeItemId, - strength: "weak" as const, - }; - const ordinal = yield* resolveItemOrdinal(context, request.nativeItemId); - const runtimeRequest: OrchestrationV2RuntimeRequest = { - id: requestId, - nodeId, - providerTurnId: context.providerTurnId, - nativeRequestRef: { - driver, - nativeId: request.nativeRequestId, - strength: "weak", - }, - kind: "user_input", - status: "pending", - responseCapability: { - type: "live", - providerSessionId: input.providerSessionId, - }, - createdAt: now, - resolvedAt: null, - }; - const node: OrchestrationV2ExecutionNode = { - id: nodeId, - threadId: context.input.threadId, - runId: context.input.runId, - parentNodeId: context.input.rootNodeId, - rootNodeId: context.input.rootNodeId, - kind: "user_input_request", - status: "waiting", - countsForRun: false, - providerThreadId: context.input.providerThread.id, - providerTurnId: context.providerTurnId, - nativeItemRef, - runtimeRequestId: requestId, - checkpointScopeId: null, - startedAt: now, - completedAt: null, - }; - const turnItem: OrchestrationV2TurnItem = { - id: turnItemId, - threadId: context.input.threadId, - runId: context.input.runId, - nodeId, - providerThreadId: context.input.providerThread.id, - providerTurnId: context.providerTurnId, - nativeItemRef, - parentItemId: null, - ordinal, - status: "waiting", - title: null, - startedAt: now, - completedAt: null, - updatedAt: now, - type: "user_input_request", - requestId, - questions: [...request.questions], - }; - yield* Ref.update(pendingRuntimeRequests, (current) => { - const updated = new Map(current); - updated.set(String(requestId), { - type: "user_input", - generation, - nativeResponseAcknowledgement, - requestId, - transportRequestId, - answers, - runtimeRequest, - node, - turnItem, - }); - return updated; - }); - yield* emitProviderEvent({ - type: "node.updated", - driver, - node, - }); - yield* emitProviderEvent({ - type: "runtime_request.updated", - driver, - threadId: context.input.threadId, - runtimeRequest, - }); - yield* emitProviderEvent({ - type: "turn_item.updated", - driver, - turnItem, - }); - return { - answers, - context, - nativeResponseAcknowledgement, - requestId, - transportRequestId, - } as const; - }); - - const requestUserInputWithAdmission = ( - generation: number, - request: Effect.Effect, - transportRequestId: string, - ) => - runRuntimeCallbackAtGeneration( - generation, - request.pipe( - Effect.flatMap((value) => - beginUserInputRequest(value, generation, transportRequestId), - ), - ), - ).pipe( - Effect.flatMap((pending) => { - if (Option.isNone(pending)) return Effect.never; - const { answers, requestId, transportRequestId } = pending.value; - return Deferred.await(answers).pipe( - Effect.flatMap((result) => - runRuntimeCallbackAtGeneration(generation, Effect.succeed(result)).pipe( - Effect.flatMap((checked) => - Option.isSome(checked) - ? Effect.succeed({ - acknowledgeNativeResponse: acknowledgeNativeResponse( - generation, - transportRequestId, - ), - answers: checked.value, - }) - : Effect.never, - ), - ), - ), - Effect.ensuring( - runRuntimeCallbackAtGeneration( - generation, - Effect.gen(function* () { - yield* Ref.update(pendingRuntimeRequests, (current) => { - const updated = new Map(current); - updated.delete(String(requestId)); - return updated; - }); - }), - ).pipe(Effect.asVoid), - ), - ); - }), - Effect.mapError( - (cause) => - new EffectAcpErrors.AcpTransportError({ - detail: "Failed to handle ACP user input request", - cause, - }), - ), - ); - - const cancelPendingRuntimeRequests = Effect.fnUntraced(function* () { - const requests = yield* Ref.modify(pendingRuntimeRequests, (current) => [ - [...current.values()], - new Map(), - ]); - if (requests.length === 0) return; - - const now = yield* DateTime.now; - yield* Effect.forEach( - requests, - (request) => - Effect.gen(function* () { - const cancelled = yield* request.type === "approval" - ? Deferred.succeed(request.decision, "cancel") - : Deferred.succeed(request.answers, null); - if (!cancelled) return; - - yield* emitProviderEvent({ - type: "runtime_request.updated", - driver, - threadId: request.node.threadId, - runtimeRequest: { - ...request.runtimeRequest, - status: "cancelled", - resolvedAt: now, - }, - }); - yield* emitProviderEvent({ - type: "node.updated", - driver, - node: { - ...request.node, - status: "cancelled", - completedAt: now, - }, - }); - yield* emitProviderEvent({ - type: "turn_item.updated", - driver, - turnItem: { - ...request.turnItem, - status: "cancelled", - completedAt: now, - updatedAt: now, - }, - }); - }), - { concurrency: 1, discard: true }, - ); - }); - - /** - * Mutate a carryover subagent's in-memory task status without projecting. - * Used after a non-completed root whose original run subscriber is no - * longer guaranteed to ingest the terminal event. - */ - const mutateCarryoverSubagentStatus = Effect.fnUntraced(function* ( - subagent: ActiveAcpSubagent, - status: OrchestrationV2Subagent["status"], - resultOverride?: string | null, - ) { - const now = yield* DateTime.now; - const result = - resultOverride !== undefined && resultOverride !== null && resultOverride.length > 0 - ? resultOverride - : (subagent.task.result ?? (subagent.assistantText || null)); - const completedAt = acpSubagentStatusIsTerminal(status) ? now : null; - subagent.task = { - ...subagent.task, - status, - result, - completedAt, - updatedAt: now, - }; - }); - - /** - * Project a carryover subagent status change without an ActiveAcpTurn. - * Shared by completed-root post-settle completion, deferred attach, and - * Direct Stop terminalization. - */ - const projectCarryoverSubagentStatus = Effect.fnUntraced(function* ( - subagent: ActiveAcpSubagent, - status: OrchestrationV2Subagent["status"], - resultOverride?: string | null, - ) { - yield* mutateCarryoverSubagentStatus(subagent, status, resultOverride); - const now = subagent.task.updatedAt; - const nativeTaskId = subagent.task.nativeTaskRef?.nativeId ?? subagent.task.id; - const nativeItemRef = { - driver, - nativeId: nativeTaskId, - strength: "strong" as const, - }; - const parentProviderThreadId = subagent.parentProviderThreadId; - const result = subagent.task.result; - const completedAt = subagent.task.completedAt; - yield* emitProviderEvent({ - type: "node.updated", - driver, - node: { - id: subagent.task.id, - threadId: subagent.task.threadId, - runId: subagent.task.runId, - parentNodeId: subagent.task.parentNodeId, - rootNodeId: subagent.task.parentNodeId, - kind: "subagent", - status, - countsForRun: false, - providerThreadId: parentProviderThreadId, - providerTurnId: subagent.providerTurnId, - nativeItemRef, - runtimeRequestId: null, - checkpointScopeId: null, - startedAt: subagent.task.startedAt, - completedAt, - }, - }); - yield* emitProviderEvent({ - type: "node.updated", - driver, - node: { - id: subagent.childRootNodeId, - threadId: subagent.childThreadId, - runId: null, - parentNodeId: null, - rootNodeId: subagent.childRootNodeId, - kind: "root_turn", - status, - countsForRun: false, - providerThreadId: subagent.task.providerThreadId, - providerTurnId: null, - nativeItemRef, - runtimeRequestId: null, - checkpointScopeId: null, - startedAt: subagent.task.startedAt, - completedAt, - }, - }); - yield* emitProviderEvent({ - type: "subagent.updated", - driver, - subagent: subagent.task, - }); - yield* emitProviderEvent({ - type: "turn_item.updated", - driver, - turnItem: { - id: subagent.turnItemId, - threadId: subagent.task.threadId, - runId: subagent.task.runId, - nodeId: subagent.task.id, - providerThreadId: parentProviderThreadId, - providerTurnId: subagent.providerTurnId, - nativeItemRef, - parentItemId: null, - ordinal: subagent.turnItemOrdinal, - status, - title: subagent.task.title, - startedAt: subagent.task.startedAt, - completedAt, - updatedAt: now, - type: "subagent", - subagentId: subagent.task.id, - origin: "provider_native", - driver, - providerInstanceId: subagent.task.providerInstanceId, - childThreadId: subagent.childThreadId, - prompt: subagent.task.prompt, - result, - }, - }); - if (acpSubagentStatusIsTerminal(status)) { - subagent.terminalStatusProjected = true; - } - }); - - /** - * Update a carryover subagent matched by native task id or child session - * id. Post-settle completions must flip the pin in hasPendingBackgroundWork - * without waiting for a new user turn to consume carryover. - * When `project` is false, only the in-memory carryover status advances - * (the next attach projects the terminal state). - * If status already matches but projection was deferred, a later - * project:true caller still projects once via terminalStatusProjected. - */ - const updateCarryoverSubagentStatus = Effect.fnUntraced(function* ( - nativeIdOrChildSessionId: string, - status: OrchestrationV2Subagent["status"], - result?: string | null, - options?: { readonly project?: boolean }, - ) { - const carryover = yield* Ref.get(carryoverSubagents); - if (carryover === null) return false; - const match = carryover.subagents.find((subagent) => { - const nativeId = subagent.task.nativeTaskRef?.nativeId ?? String(subagent.task.id); - return ( - nativeId === nativeIdOrChildSessionId || - subagent.childSessionId === nativeIdOrChildSessionId - ); - }); - if (match === undefined) return false; - const shouldProject = options?.project !== false; - if (match.task.status === status) { - // Already at this status: only project if requested and not yet done. - if (!shouldProject || match.terminalStatusProjected) { - return true; - } - yield* projectCarryoverSubagentStatus(match, status, result); - return true; - } - // Only advance nonterminal entries; do not resurrect a terminal one. - if (match.task.status !== "running" && match.task.status !== "pending") { - return false; - } - if (!shouldProject) { - yield* mutateCarryoverSubagentStatus(match, status, result); - } else { - yield* projectCarryoverSubagentStatus(match, status, result); - } - return true; - }); - - const finishSubagentFromNotice = Effect.fnUntraced(function* (notice: { - readonly childSessionId: string; - readonly status: "completed" | "failed" | "cancelled"; - readonly result: string | null; - }) { - const context = yield* Ref.get(activeTurn); - const subagent = - context === null ? undefined : context.subagentsBySessionId.get(notice.childSessionId); - const known = - subagent ?? - (yield* Ref.get(carryoverSubagents))?.subagents.find( - (candidate) => - candidate.childSessionId === notice.childSessionId || - candidate.task.nativeTaskRef?.nativeId === notice.childSessionId, - ); - if (known !== undefined && acpSubagentStatusBlocksTurnSettlement(known.task.status)) { - yield* recordWakeReport(notice.childSessionId, { - kind: "subagent", - label: known.task.title ?? known.task.prompt, - outcome: notice.status, - childThreadId: known.childThreadId, - }); - } - if (context !== null && subagent !== undefined && !context.finalized) { - if (!acpSubagentStatusBlocksTurnSettlement(subagent.task.status)) return; - yield* emitSubagent(context, { - nativeTaskId: subagent.task.nativeTaskRef?.nativeId ?? notice.childSessionId, - prompt: subagent.task.prompt, - title: subagent.task.title, - model: subagent.task.model, - status: notice.status, - childSessionId: notice.childSessionId, - result: notice.result, - suppressNormalTool: true, - }); - yield* rearmDeferredFinalize(context); - return; - } - // The root turn already settled: the subagent is carryover. Project - // its end while the completed root still owns the run. - const carryover = yield* Ref.get(carryoverSubagents); - yield* updateCarryoverSubagentStatus( - notice.childSessionId, - notice.status, - notice.result, - { - project: - carryover !== null && - carryover.sessionId === (yield* Ref.get(activeSessionId)) && - carryover.rootTerminalStatus === "completed", - }, - ); - }); - - applyFinalizedActiveTurnSubagentTerminal = Effect.fnUntraced(function* ( - context: ActiveAcpTurn, - notification: EffectAcpSchema.SessionNotification, - ) { - if (flavor.extractSubagentUpdate === undefined) return false; - - const applyTerminal = Effect.fnUntraced(function* ( - subagent: ActiveAcpSubagent, - update: AcpAdapterV2SubagentUpdate, - ) { - if (!acpSubagentStatusIsTerminal(update.status)) return false; - if (acpSubagentStatusIsTerminal(subagent.task.status)) { - if (subagent.terminalStatusProjected || context.finalizedStatus !== "completed") { - return true; - } - } - if (context.finalizedStatus === "completed") { - yield* emitSubagent(context, update); - } else { - yield* mutateCarryoverSubagentStatus(subagent, update.status, update.result); - } - return true; - }); - - const sessionUpdate = notification.update; - if ( - sessionUpdate.sessionUpdate === "tool_call" || - sessionUpdate.sessionUpdate === "tool_call_update" - ) { - for (const event of parseSessionUpdateEvent(notification).events) { - if (event._tag !== "ToolCallUpdated") continue; - const toolCall = flavor.normalizeToolCall?.(event.toolCall) ?? event.toolCall; - const subagentUpdate = flavor.extractSubagentUpdate(toolCall); - if ( - subagentUpdate === undefined || - !acpSubagentStatusIsTerminal(subagentUpdate.status) - ) { - continue; - } - const subagent = - context.subagents.get(subagentUpdate.nativeTaskId) ?? - (subagentUpdate.childSessionId === null - ? undefined - : context.subagentsBySessionId.get(subagentUpdate.childSessionId)) ?? - context.subagentsBySessionId.get(notification.sessionId); - if (subagent === undefined) continue; - const nativeTaskId = - subagent.task.nativeTaskRef?.nativeId ?? String(subagent.task.id); - if ( - subagentUpdate.nativeTaskId !== nativeTaskId && - (subagentUpdate.childSessionId === null || - (subagentUpdate.childSessionId !== subagent.childSessionId && - subagentUpdate.childSessionId !== notification.sessionId)) - ) { - continue; - } - if (yield* applyTerminal(subagent, subagentUpdate)) return true; - } - } - - if ( - sessionUpdate.sessionUpdate === "user_message_chunk" && - sessionUpdate.content.type === "text" && - flavor.extractSubagentEndNotice !== undefined - ) { - const notice = flavor.extractSubagentEndNotice(sessionUpdate.content.text); - const subagent = - notice === undefined - ? undefined - : context.subagentsBySessionId.get(notice.childSessionId); - if (notice !== undefined && subagent !== undefined) { - return yield* applyTerminal(subagent, { - nativeTaskId: subagent.task.nativeTaskRef?.nativeId ?? String(subagent.task.id), - prompt: subagent.task.prompt, - title: subagent.task.title, - model: subagent.task.model, - status: notice.status, - childSessionId: notice.childSessionId, - result: null, - suppressNormalTool: true, - }); - } - } - - return false; - }); - - const projectDeferredCarryoverTerminals = Effect.fnUntraced(function* ( - subagents: ReadonlyArray, - ) { - for (const subagent of subagents) { - if ( - subagent.task.status === "running" || - subagent.task.status === "pending" || - subagent.terminalStatusProjected - ) { - continue; - } - yield* projectCarryoverSubagentStatus( - subagent, - subagent.task.status, - subagent.task.result, - ); - } - }); - - /** - * Direct Stop after a soft steer clears carryover without an active turn. - * Emit the same interrupted terminal events terminalizeOpenRunOwnedItems - * would have, context-free (no ActiveAcpTurn). - */ - const terminalizeCarryoverSubagents = Effect.fnUntraced(function* ( - carryover: AcpCarryoverSubagents | null, - ) { - if (carryover === null) return; - for (const subagent of carryover.subagents) { - if (acpSubagentStatusIsTerminal(subagent.task.status)) { - if (!subagent.terminalStatusProjected) { - yield* projectCarryoverSubagentStatus( - subagent, - subagent.task.status, - subagent.task.result, - ); - } - continue; - } - yield* projectCarryoverSubagentStatus(subagent, "interrupted"); - } - }); - - const projectAcpRuntimeSessionUpdateEffect = ( - rawNotification: EffectAcpSchema.SessionNotification, - ) => - Effect.gen(function* () { - if (clientTerminals !== undefined) { - const embedded = embeddedTerminalIdsFromSessionUpdate(rawNotification); - if (embedded !== undefined) { - rememberEmbeddedTerminals({ - ...embedded, - sessionId: rawNotification.sessionId, - }); - } - } - const notification = - clientTerminals === undefined - ? rawNotification - : resolveEmbeddedTerminalContent( - rawNotification, - clientTerminals.readOutputSnapshot, - ); - if (notification.update.sessionUpdate === "available_commands_update") { - yield* ( - flavor.onAvailableCommandsUpdate?.(notification.update.availableCommands) ?? - Effect.void - ); - } - if ( - notification.update.sessionUpdate === "config_option_update" || - notification.update.sessionUpdate === "current_mode_update" - ) { - yield* ( - flavor.onSessionConfigurationUpdate?.( - yield* runtime.getConfigOptions, - yield* runtime.getModeState, - ) ?? Effect.void - ); - } - yield* handleSessionUpdate( - flavor.normalizeSessionUpdate?.(notification) ?? notification, - ); - }).pipe( - Effect.mapError( - (cause) => - new EffectAcpErrors.AcpTransportError({ - detail: "Failed to project an ACP session update", - cause, - }), - ), - ); - const projectAcpRuntimeSessionUpdate = ( - handlerGeneration: number, - rawNotification: EffectAcpSchema.SessionNotification, - ) => - runRuntimeCallbackAtGeneration( - handlerGeneration, - projectAcpRuntimeSessionUpdateEffect(rawNotification), - ).pipe(Effect.asVoid); - - // Falls back to the latest turn policy when no turn is active so - // post-settle background work stays under the policy it started with. - const clientPolicyContext = Effect.map(Ref.get(activeTurn), (context) => ({ - policy: context?.input.runtimePolicy ?? latestRuntimePolicy, - turnKey: context === null ? null : String(context.providerTurnId), - })); - - const denyClientRequest = (operation: string, disposition: "ask" | "deny") => - Effect.logWarning("ACP client policy denied a client-mediated operation", { - operation, - disposition, - }).pipe( - Effect.andThen( - Effect.fail( - EffectAcpErrors.AcpRequestError.internalError( - disposition === "ask" - ? `The active T3 runtime policy requires approval for ${operation}. Request permission with session/request_permission before retrying.` - : `The active T3 runtime policy does not allow ${operation}.`, - ), - ), - ), - ); - - const guardClientTerminalCreate = clientPolicyContext.pipe( - Effect.flatMap(({ policy, turnKey }) => { - const disposition = acpClientExecuteDisposition(policy); - if ( - disposition === "allow" || - (disposition === "ask" && clientPolicyGrants.allowsExecute(turnKey)) - ) { - return Effect.void; - } - return denyClientRequest("terminal/create", disposition); - }), - ); - - const wireAcpRuntimeTerminalHandlers = Effect.fnUntraced(function* ( - targetRuntime: AcpSessionRuntime.AcpSessionRuntime["Service"], - ) { - if (clientTerminals === undefined) return; - yield* targetRuntime.handleCreateTerminal((request) => - guardClientTerminalCreate.pipe(Effect.andThen(clientTerminals.create(request))), - ); - yield* targetRuntime.handleTerminalOutput(clientTerminals.output); - yield* targetRuntime.handleTerminalWaitForExit(clientTerminals.waitForExit); - yield* targetRuntime.handleTerminalKill(clientTerminals.kill); - yield* targetRuntime.handleTerminalRelease(clientTerminals.release); - }); - - const wireAcpRuntimeMcpHandlers = Effect.fnUntraced(function* ( - targetRuntime: AcpSessionRuntime.AcpSessionRuntime["Service"], - mcpBridge: AcpMcpOverAcpBridge | undefined, - ) { - if (mcpBridge === undefined) return; - const mapMcpBridgeError = Effect.mapError( - (error: Error) => - new EffectAcpErrors.AcpRequestError({ - code: -32603, - errorMessage: error.message, - cause: error, - }), - ); - yield* targetRuntime.handleMcpConnect((request) => - mcpBridge.connect(request).pipe(mapMcpBridgeError), - ); - yield* targetRuntime.handleMcpMessage((request) => - mcpBridge.message(request).pipe(mapMcpBridgeError), - ); - yield* targetRuntime.handleMcpNotification((request) => - mcpBridge.notification(request).pipe(mapMcpBridgeError), - ); - yield* targetRuntime.handleMcpDisconnect((request) => - mcpBridge.disconnect(request).pipe(mapMcpBridgeError), - ); - }); - - const wireAcpRuntimeHandlers = Effect.fnUntraced(function* ( - targetRuntime: AcpSessionRuntime.AcpSessionRuntime["Service"], - handlerGeneration: number, - handlerOptions: { - readonly sessionUpdates?: boolean; - readonly terminals?: boolean; - readonly mcp?: boolean; - } = {}, - ) { - yield* targetRuntime.getEvents().pipe( - Stream.runForEach((event) => { - if (event._tag === "EventStreamBarrier") { - return Deferred.succeed(event.acknowledge, undefined).pipe(Effect.asVoid); - } - return runRuntimeCallbackAtGeneration( - handlerGeneration, - flavor.onSessionEvent?.(event) ?? Effect.void, - ).pipe(Effect.asVoid); - }), - Effect.forkIn(runtimeScope ?? sessionScope), - ); - const requestUserInput = ( - request: AcpAdapterV2UserInputRequest, - requestContext: EffectAcpProtocol.AcpRequestContext, - ) => - requestUserInputWithAdmission( - handlerGeneration, - Effect.succeed(request), - requestContext.requestId, - ); - // Without the capability no fs handler is registered, so a stray - // request (OpenCode and Kilo send one after approved edits) gets - // method-not-found and cannot touch the disk. A flavor that opts in - // serves requests itself, confined to the workspace of the policy - // active when the request arrives; the agent asks before its edits. - const clientFileSystem = flavor.clientFileSystem; - if (clientFileSystem !== undefined) { - yield* targetRuntime.handleReadTextFile((request) => - clientPolicyContext.pipe( - Effect.flatMap(({ policy }) => clientFileSystem.readTextFile(request, policy.cwd)), - ), - ); - yield* targetRuntime.handleWriteTextFile((request) => - clientPolicyContext.pipe( - Effect.flatMap(({ policy }) => clientFileSystem.writeTextFile(request, policy.cwd)), - ), - ); - } - if (handlerOptions.mcp !== false) { - yield* wireAcpRuntimeMcpHandlers(targetRuntime, runtimeMcpBridge); - } - if (handlerOptions.terminals !== false) { - yield* wireAcpRuntimeTerminalHandlers(targetRuntime); - } - if (handlerOptions.sessionUpdates !== false) { - yield* targetRuntime.handleSessionUpdate((rawNotification) => - projectAcpRuntimeSessionUpdate(handlerGeneration, rawNotification), - ); - } - yield* targetRuntime.handleRequestPermission((params, requestContext) => - Effect.gen(function* () { - const transportRequestId = requestContext.requestId; - const permissionQuestion = flavor.extractPermissionQuestion?.(params); - if (permissionQuestion !== undefined) { - const userInput = yield* requestUserInputWithAdmission( - handlerGeneration, - Effect.succeed({ - nativeItemId: `${params.sessionId}:question:${params.toolCall.toolCallId}`, - nativeMethod: "session/request_permission", - nativeRequestId: params.toolCall.toolCallId, - nativeSessionId: params.sessionId, - questions: [permissionQuestion.question], - }), - transportRequestId, - ); - const response = - userInput.answers === null - ? undefined - : permissionQuestion.respond(userInput.answers); - yield* userInput.acknowledgeNativeResponse; - return response ?? ({ outcome: { outcome: "cancelled" } } as const); - } - const admitted = yield* runRuntimeCallbackAtGeneration( - handlerGeneration, - Effect.gen(function* () { - const context = yield* activeContext; - const disposition = (flavor.permissionDisposition ?? acpPermissionDisposition)( - context.input.runtimePolicy, - params, - ); - if (disposition === "allow") { - const optionId = selectAutoApprovedPermissionOption(params); - return { - _tag: "Immediate" as const, - response: - optionId === undefined - ? ({ outcome: { outcome: "cancelled" } } as const) - : ({ outcome: { outcome: "selected", optionId } } as const), - }; - } - if (disposition === "deny") { - const optionId = selectPermissionOptionId(params, "decline"); - return { - _tag: "Immediate" as const, - response: - optionId === undefined - ? ({ outcome: { outcome: "cancelled" } } as const) - : ({ outcome: { outcome: "selected", optionId } } as const), - }; - } - return { - _tag: "Pending" as const, - pending: yield* beginApprovalRequest( - context, - params, - handlerGeneration, - transportRequestId, - ), - }; - }), - ); - if (Option.isNone(admitted)) { - return yield* Effect.never; - } - if (admitted.value._tag === "Immediate") { - const response = admitted.value.response; - const checked = yield* runRuntimeCallbackAtGeneration( - handlerGeneration, - Effect.gen(function* () { - const nativeResponseAcknowledgement = yield* Deferred.make< - void, - EffectAcpErrors.AcpError - >(); - yield* registerNativeResponseAcknowledgement( - handlerGeneration, - transportRequestId, - nativeResponseAcknowledgement, - ); - return response; - }), - ); - return Option.isSome(checked) ? checked.value : yield* Effect.never; - } - const { - context, - decision: pendingDecision, - requestId, - transportRequestId: pendingTransportRequestId, - } = admitted.value.pending; - const parsedPermission = parsePermissionRequest(params); - const decision = yield* Deferred.await(pendingDecision).pipe( - Effect.ensuring( - runRuntimeCallbackAtGeneration( - handlerGeneration, - Effect.gen(function* () { - yield* Ref.update(pendingRuntimeRequests, (current) => { - const updated = new Map(current); - updated.delete(String(requestId)); - return updated; - }); - }), - ).pipe(Effect.asVoid), - ), - ); - if ( - parsedPermission.kind !== "unknown" && - (decision === "accept" || decision === "acceptForSession") - ) { - clientPolicyGrants.recordApproval({ - kind: providerRequestKind(parsedPermission.kind), - scope: decision === "acceptForSession" ? "session" : "turn", - turnKey: String(context.providerTurnId), - }); - } - const response = (() => { - if (decision === "cancel") { - return { outcome: { outcome: "cancelled" } } as const; - } - const optionId = selectPermissionOptionId(params, decision); - return optionId === undefined - ? ({ outcome: { outcome: "cancelled" } } as const) - : ({ outcome: { outcome: "selected", optionId } } as const); - })(); - const checked = yield* runRuntimeCallbackAtGeneration( - handlerGeneration, - Effect.succeed(response), - ); - if (Option.isNone(checked)) return yield* Effect.never; - yield* acknowledgeNativeResponse(handlerGeneration, pendingTransportRequestId); - return checked.value; - }).pipe( - Effect.mapError( - (cause) => - new EffectAcpErrors.AcpTransportError({ - detail: "Failed to handle an ACP permission request", - cause, - }), - ), - ), - ); - yield* targetRuntime.handleElicitation((params, requestContext) => - Effect.gen(function* () { - const transportRequestId = requestContext.requestId; - if ( - params.mode === "form" && - (unknownRecord(params._meta)?.codex_approval_kind === "mcp_tool_call" || - transportRequestId.startsWith("mcp_tool_call_approval_")) - ) { - const mcpApprovalDisposition = yield* runRuntimeCallbackAtGeneration( - handlerGeneration, - Effect.gen(function* () { - const context = yield* activeContext; - const disposition = acpMcpToolApprovalElicitationDisposition( - context.input.runtimePolicy, - params, - transportRequestId, - ); - if (disposition === undefined || disposition === "ask") { - return disposition; - } - const nativeResponseAcknowledgement = yield* Deferred.make< - void, - EffectAcpErrors.AcpError - >(); - yield* registerNativeResponseAcknowledgement( - handlerGeneration, - transportRequestId, - nativeResponseAcknowledgement, - ); - return disposition; - }), - ); - if (Option.isNone(mcpApprovalDisposition)) return yield* Effect.never; - if (mcpApprovalDisposition.value === "allow") { - return { action: "accept", content: {} } as const; - } - if (mcpApprovalDisposition.value === "deny") { - return { action: "decline" } as const; - } - } - if ( - params.mode === "url" && - "url" in params && - typeof params.url === "string" && - "elicitationId" in params && - typeof params.elicitationId === "string" - ) { - const { elicitationId, url } = params; - const admitted = yield* runRuntimeCallbackAtGeneration( - handlerGeneration, - Effect.gen(function* () { - const nativeResponseAcknowledgement = yield* Deferred.make< - void, - EffectAcpErrors.AcpError - >(); - yield* registerNativeResponseAcknowledgement( - handlerGeneration, - transportRequestId, - nativeResponseAcknowledgement, - ); - const accepted = yield* ( - flavor.onUrlElicitation?.({ - elicitationId, - url, - message: params.message, - }) ?? Effect.succeed(false) - ); - return accepted - ? ({ action: "accept" } as const) - : ({ action: "decline" } as const); - }), - ); - if (Option.isNone(admitted)) return yield* Effect.never; - return admitted.value; - } - if (params.mode !== "form" || !("requestedSchema" in params)) { - // Future elicitation modes beyond form and url decline rather - // than guessing at their semantics. - return { action: "decline" } as const; - } - const requestedSchema = unknownRecord(params.requestedSchema); - const properties = unknownRecord(requestedSchema?.properties) ?? {}; - const elicitationScopeId = - "sessionId" in params ? params.sessionId : `request:${params.requestId}`; - const questions = Object.entries(properties).map( - ([id, property], index): OrchestrationV2UserInputQuestion => { - const record = unknownRecord(property); - const enumValues = Array.isArray(record?.enum) - ? record.enum.filter((value): value is string => typeof value === "string") - : []; - const options = - enumValues.length > 0 - ? enumValues.map((value) => ({ label: value, description: value })) - : record?.type === "boolean" - ? [ - { label: "true", description: "Yes" }, - { label: "false", description: "No" }, - ] - : []; - return { - id, - header: nonEmptyText(record?.title, `Question ${index + 1}`), - question: nonEmptyText(record?.description, params.message), - options, - }; - }, - ); - const userInput = yield* requestUserInputWithAdmission( - handlerGeneration, - Effect.gen(function* () { - const ordinal = yield* Ref.getAndUpdate( - nextElicitationOrdinal, - (current) => current + 1, - ); - const nativeRequestId = `${elicitationScopeId}:elicitation:${ordinal}`; - return { - nativeItemId: nativeRequestId, - nativeRequestId, - questions, - }; - }), - transportRequestId, - ); - const response = - userInput.answers === null - ? ({ action: "cancel" } as const) - : ({ - action: "accept", - content: elicitationContent( - userInput.answers, - new Set(Object.keys(properties)), - ), - } as const); - yield* userInput.acknowledgeNativeResponse; - return response; - }), - ); - if (flavor.registerExtensions !== undefined) { - yield* flavor.registerExtensions({ - runtime: targetRuntime, - reportProviderRetry: (notice) => - runRuntimeCallbackAtGeneration(handlerGeneration, reportProviderRetry(notice)).pipe( - Effect.asVoid, - ), - requestUserInput, - captureProposedPlan, - lastProposedPlanMarkdown, - finishSubagent: (notice) => - runRuntimeCallbackAtGeneration( - handlerGeneration, - Effect.gen(function* () { - if (yield* Ref.get(stoppedRunQuarantine)) return; - // Root-session notices only; nested subagents report to - // their own parent session. - if ((yield* Ref.get(activeSessionId)) !== notice.sessionId) return; - yield* finishSubagentFromNotice(notice); - }), - ).pipe(Effect.asVoid), - applyBackgroundTaskMutation: (mutation) => - runRuntimeCallbackAtGeneration( - handlerGeneration, - Effect.gen(function* () { - // Direct Stop quarantine: drop residual task lifecycle from - // the stopped run instead of mutating wake machinery. - if (yield* Ref.get(stoppedRunQuarantine)) return; - // Root-session tasks only: a cancelled subagent's re-run in - // its child session must not gate root wake machinery. - if ((yield* Ref.get(activeSessionId)) !== mutation.sessionId) return; - if ( - mutation.status !== "running" && - mutation.report !== undefined && - !(yield* Ref.get(handledBackgroundTaskIdsInActiveTurn)).has(mutation.taskId) - ) { - yield* recordWakeReport(mutation.taskId, { - ...mutation.report, - outcome: mutation.status, - }); - } - yield* applyLateBackgroundMutation(mutation.sessionId, mutation); - if (mutation.status !== "running") { - yield* finishRegisteredBackgroundTool({ - taskId: mutation.taskId, - status: mutation.status, - ...(mutation.output === undefined ? {} : { output: mutation.output }), - }); - } - }), - ).pipe(Effect.asVoid), - }); - } - }); - - /** Builds the MCP-over-ACP bridge for one runtime, disposed with that runtime's scope. */ - const makeRuntimeMcpBridge = Effect.fnUntraced(function* ( - threadId: ThreadId | null, - scope: Scope.Scope, - ) { - const mcpContext = acpMcpContext(threadId, self); - if (mcpContext.endpoint === undefined || mcpContext.authorization === undefined) { - return undefined; - } - const mcpBridge = yield* makeAcpMcpOverAcpBridge({ - endpoint: mcpContext.endpoint, - authorization: mcpContext.authorization, - allocateConnectionId: options.crypto.randomUUIDv4.pipe(Effect.orDie), - }); - yield* Scope.addFinalizer(scope, mcpBridge.dispose); - return mcpBridge; - }); - - const spawnAcpRuntime = Effect.fnUntraced(function* ( - threadId: ThreadId | null, - resumeSessionId?: string, - ) { - if (runtimeScope !== undefined) { - yield* Scope.close(runtimeScope, Exit.void); - } - runtimeScope = yield* Scope.make(); - runtimeMcpBridge = yield* makeRuntimeMcpBridge(threadId, runtimeScope); - const runtimeGeneration = yield* Ref.get(runtimeCallbackGeneration); - runtime = yield* flavor - .makeRuntime(makeRuntimeInput(runtimeGeneration, threadId, resumeSessionId)) - .pipe( - Effect.provideService(Scope.Scope, runtimeScope), - Effect.provideService(Crypto.Crypto, options.crypto), - ); - }); - - const startAcpRuntime = Effect.fnUntraced(function* ( - threadId: ThreadId | null, - resumeSessionId?: string, - ) { - const startup = Effect.gen(function* () { - yield* spawnAcpRuntime(threadId, resumeSessionId); - yield* wireAcpRuntimeHandlers(runtime, yield* Ref.get(runtimeCallbackGeneration)); - return yield* runtime.start(); - }); - return yield* flavor.withRuntimeStartup?.(startup) ?? startup; - }); - - const restartAcpRuntime = Effect.fnUntraced(function* (threadId: ThreadId | null) { - yield* spawnAcpRuntime(threadId); - yield* wireAcpRuntimeHandlers(runtime, yield* Ref.get(runtimeCallbackGeneration)); - }); - - const startReplacementAcpRuntime = Effect.fnUntraced(function* ( - threadId: ThreadId | null, - commitSessionState: ( - replacement: AcpSessionRuntime.AcpSessionRuntimeStartResult, - ) => Effect.Effect, - ) { - const previousScope = runtimeScope; - const previousGeneration = yield* Ref.get(runtimeCallbackGeneration); - yield* runtimeCallbackPermit.withPermit(awaitAdmittedNativeResponses); - - // Keep the original generation active until the candidate has - // completed session startup. A failed candidate therefore cannot - // suppress termination or background callbacks from the live session. - const replacementGeneration = yield* allocateRuntimeCallbackGeneration; - const replacementScope = yield* Scope.make(); - type CandidateLifecycle = - | { readonly _tag: "Starting" } - | { readonly _tag: "Terminated"; readonly error: EffectAcpErrors.AcpError } - | { readonly _tag: "Committed" }; - const candidateLifecycle = yield* Ref.make({ _tag: "Starting" }); - const handleCandidateTermination: AcpAdapterV2RuntimeInput["onTermination"] = (error) => - Ref.modify( - candidateLifecycle, - (current): readonly [Effect.Effect, CandidateLifecycle] => { - if (current._tag === "Committed") { - return [handleRuntimeTerminationAtGeneration(replacementGeneration), current]; - } - return [ - Effect.void, - current._tag === "Terminated" - ? current - : ({ _tag: "Terminated", error } as const), - ]; - }, - ).pipe(Effect.flatten); - const stagedSessionUpdates: Array = []; - let handleCandidateSessionUpdate: ( - notification: EffectAcpSchema.SessionNotification, - ) => Effect.Effect = (notification) => - Effect.sync(() => { - stagedSessionUpdates.push(notification); - }); - let replacementMcpBridge: AcpMcpOverAcpBridge | undefined; - const startup = Effect.gen(function* () { - replacementMcpBridge = yield* makeRuntimeMcpBridge(threadId, replacementScope); - const replacementRuntime = yield* flavor - .makeRuntime( - makeRuntimeInput( - replacementGeneration, - threadId, - undefined, - handleCandidateTermination, - ), - ) - .pipe( - Effect.provideService(Scope.Scope, replacementScope), - Effect.provideService(Crypto.Crypto, options.crypto), - ); - // Session setup may publish commands before it returns. Buffer those - // notifications, but do not expose request or extension handlers - // until the candidate generation has committed. - yield* replacementRuntime.handleSessionUpdate((notification) => - Effect.suspend(() => handleCandidateSessionUpdate(notification)), - ); - yield* wireAcpRuntimeMcpHandlers(replacementRuntime, replacementMcpBridge); - const started = yield* replacementRuntime.start(); - return { replacementRuntime, started }; - }); - const replacementExit = yield* Effect.exit( - (flavor.withRuntimeStartup?.(startup) ?? startup).pipe( - Effect.onInterrupt(() => - Scope.close(replacementScope, Exit.void).pipe(Effect.ignore), - ), - ), - ); - if (Exit.isFailure(replacementExit)) { - yield* runtimeCallbackPermit.withPermit( - quarantineNativeTransportAtGeneration(replacementGeneration), - ); - yield* Scope.close(replacementScope, Exit.void).pipe(Effect.ignore); - return yield* Effect.failCause(replacementExit.cause); - } - - yield* runtimeCallbackPermit - .withPermit( - Effect.uninterruptible( - Effect.gen(function* () { - yield* awaitAdmittedNativeResponses; - // Session updates and MCP were wired during candidate startup - // (against its own bridge); terminals attach after commit below. - yield* wireAcpRuntimeHandlers( - replacementExit.value.replacementRuntime, - replacementGeneration, - { sessionUpdates: false, terminals: false, mcp: false }, - ); - const lifecycle = yield* Ref.modify( - candidateLifecycle, - (current): readonly [CandidateLifecycle, CandidateLifecycle] => - current._tag === "Terminated" - ? [current, current] - : [{ _tag: "Committed" }, { _tag: "Committed" }], - ); - if (lifecycle._tag === "Terminated") { - return yield* lifecycle.error; - } - yield* quarantineNativeTransportAtGeneration(previousGeneration); - runtime = replacementExit.value.replacementRuntime; - runtimeScope = replacementScope; - runtimeMcpBridge = replacementMcpBridge; - yield* Ref.set(runtimeCallbackGeneration, replacementGeneration); - prepareTerminalEnvironment(threadId, replacementExit.value.started.sessionId); - yield* wireAcpRuntimeTerminalHandlers(replacementExit.value.replacementRuntime); - yield* commitSessionState(replacementExit.value.started); - while (true) { - const buffered = stagedSessionUpdates.splice(0, stagedSessionUpdates.length); - if (buffered.length === 0) { - handleCandidateSessionUpdate = (notification) => - projectAcpRuntimeSessionUpdate(replacementGeneration, notification); - break; - } - yield* Effect.forEach( - buffered, - (notification) => - projectAcpRuntimeSessionUpdateEffect(notification).pipe( - Effect.catchCause((cause) => - Effect.logError("failed to replay staged ACP session update", { - driver, - cause, - }), - ), - ), - { discard: true }, - ); - } - yield* cancelPendingRuntimeRequests(); - if (previousScope !== undefined) { - yield* Scope.close(previousScope, Exit.void).pipe( - Effect.catchCause((cause) => - Effect.logError("failed to close replaced ACP runtime scope", { - driver, - cause, - }), - ), - ); - } - }), - ), - ) - .pipe( - Effect.onExit((exit) => - Exit.isFailure(exit) - ? Ref.get(candidateLifecycle).pipe( - Effect.flatMap((lifecycle) => - lifecycle._tag === "Committed" - ? Effect.void - : Scope.close(replacementScope, Exit.void).pipe(Effect.ignore), - ), - ) - : Effect.void, - ), - ); - return replacementExit.value.started; - }); - - const initialStart = yield* Effect.result( - startAcpRuntime(input.threadId, input.initialNativeThreadId), - ); - const started = Result.isSuccess(initialStart) - ? initialStart.success - : yield* Effect.gen(function* () { - const failedMethod = - "method" in initialStart.failure ? initialStart.failure.method : undefined; - if ( - input.initialNativeThreadId === undefined || - (failedMethod !== "session/load" && failedMethod !== "session/resume") - ) { - return yield* initialStart.failure; - } - yield* Ref.set(initialSessionActivationFailure, { - sessionId: input.initialNativeThreadId, - error: initialStart.failure, - }); - itemIdentityVersion = 2; - yield* Ref.set(runtimeRestartRequired, false); - prepareClaimableTerminalEnvironment(input.threadId); - return yield* startAcpRuntime(input.threadId); - }); - yield* Ref.set(activeSessionId, started.sessionId); - yield* Ref.set(activeSessionSetup, started); - rememberTerminalEnvironment(started.sessionId, input.threadId); - const capabilities = negotiatedCapabilities(flavor.capabilities, started); - const canLoadSession = started.initializeResult.agentCapabilities?.loadSession === true; - const canResumeSession = - started.initializeResult.agentCapabilities?.sessionCapabilities?.resume != null; - const supportsImagePrompts = acpSupportsImagePrompts({ - flavorSupportsImagePrompts: flavor.supportsImagePrompts, - negotiatedImage: - started.initializeResult.agentCapabilities?.promptCapabilities?.image === true, - }); - - const activateSession = Effect.fnUntraced(function* ( - sessionId: string, - threadId: ThreadId | null, - ) { - const initialFailure = yield* Ref.modify(initialSessionActivationFailure, (failure) => - failure?.sessionId === sessionId ? [failure.error, null] : [undefined, failure], - ); - if (initialFailure !== undefined) { - return yield* initialFailure; - } - const activationOptions = acpMcpActivation(threadId, self); - prepareTerminalEnvironment(threadId, sessionId); - const activated = canLoadSession - ? yield* runtime.loadSession(sessionId, activationOptions) - : canResumeSession - ? yield* runtime.resumeSession(sessionId, activationOptions) - : yield* new ProviderAdapter.ProviderAdapterProtocolError({ - driver, - detail: `ACP driver cannot load or resume session ${sessionId}`, - }); - rememberTerminalEnvironment(activated.sessionId, threadId); - return activated; - }); - - const configureSession = Effect.fnUntraced(function* ( - startResult: AcpSessionRuntime.AcpSessionRuntimeStartResult, - modelSelection: ModelSelection, - runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy, - ) { - const requestedModel = flavor.resolveModelId?.(modelSelection) ?? modelSelection.model; - let appliedModel: string | undefined; - if (flavor.applyModelSelection !== undefined) { - appliedModel = yield* flavor.applyModelSelection({ - runtime, - startResult, - modelSelection, - }); - } else if ( - requestedModel.length > 0 && - requestedModel !== "auto" && - requestedModel !== "default" - ) { - const hasModelConfig = - startResult.sessionSetupResult.configOptions?.some( - (option) => option.category === "model", - ) === true; - if (hasModelConfig) { - yield* runtime.setModel(requestedModel); - } - } - // Same-runtime switches compare against this stored setup, so keep - // its model metadata in sync with what the session now runs on; - // otherwise switching A -> B -> A would see the stale setup-time A - // and skip the final switch. - if (appliedModel !== undefined) { - const applied = appliedModel; - yield* Ref.update(activeSessionSetup, (setup) => { - if (setup === null) { - return setup; - } - const models = setup.sessionSetupResult.models; - if (models == null || models.currentModelId === applied) { - return setup; - } - return { - ...setup, - sessionSetupResult: { - ...setup.sessionSetupResult, - models: { ...models, currentModelId: applied }, - }, - }; - }); - } - const optionSelections = modelSelection.options ?? []; - const configOptions = yield* runtime.getConfigOptions; - const availableConfigIds = new Set(configOptions.map((option) => option.id)); - const hasNativeConfigWithSyntheticModeId = availableConfigIds.has( - ACP_SESSION_MODE_OPTION_ID, - ); - const modeSelection = hasNativeConfigWithSyntheticModeId - ? undefined - : optionSelections.find((selection) => selection.id === ACP_SESSION_MODE_OPTION_ID); - const configSelections = hasNativeConfigWithSyntheticModeId - ? optionSelections - : optionSelections.filter((selection) => selection.id !== ACP_SESSION_MODE_OPTION_ID); - // Probe-time descriptors are a per-model union, so a stored - // selection can reference an option the live session does not - // expose (Kilo advertises per-model "effort" descriptors while its - // session config omits them). Failing the open here wedges the run - // in a retry loop; skip like the out-of-range values below and let - // the agent default apply. - const unsupportedConfigIds = configSelections - .map((selection) => selection.id) - .filter((id) => !availableConfigIds.has(id)); - if (unsupportedConfigIds.length > 0) { - yield* Effect.logWarning( - "ACP session does not expose requested configuration option(s)", - { - driver, - sessionId: startResult.sessionId, - optionIds: unsupportedConfigIds, - }, - ); - } - for (const selection of configSelections) { - if (!availableConfigIds.has(selection.id)) continue; - // Tuning knobs degrade instead of failing the session open: agents - // advertise the union of values across models but can reject a - // per-model invalid one at set time (codex-acp advertises "ultra" - // reasoning effort and then rejects it for most models). Skip - // values the session does not currently offer and downgrade an - // agent-side set rejection to a warning; the agent's default - // applies for that option. - const option = configOptions.find((candidate) => candidate.id === selection.id); - if ( - option !== undefined && - option.type === "select" && - typeof selection.value === "string" - ) { - const advertisedValues = option.options.flatMap((entry) => - "value" in entry ? [entry.value] : entry.options.map((choice) => choice.value), - ); - if (!advertisedValues.includes(selection.value)) continue; - } - yield* runtime.setConfigOption(selection.id, selection.value).pipe( - Effect.catchTags({ - AcpRequestError: (error) => - Effect.logWarning("ACP session rejected a configuration option value", { - optionId: selection.id, - value: selection.value, - detail: error.message, - }), - }), - ); - } - const policyMode = flavor.sessionModeForPolicy?.(runtimePolicy); - if (policyMode !== undefined) { - yield* runtime.setMode(policyMode); - } - const modeState = yield* runtime.getModeState; - // The synthetic mode selection is skipped rather than failed when the - // agent no longer advertises it: mode sets are volatile across agent - // versions and a stale persisted mode should not block the turn. - if ( - modeSelection !== undefined && - typeof modeSelection.value === "string" && - modeState?.availableModes.some((mode) => mode.id === modeSelection.value) === true && - modeState.currentModeId !== modeSelection.value - ) { - yield* runtime.setMode(modeSelection.value); - } - const effectiveModeState = yield* runtime.getModeState; - const effectiveConfigOptions = yield* runtime.getConfigOptions; - const planSensitiveOptions = effectiveConfigOptions.filter( - ( - option, - ): option is Extract< - EffectAcpSchema.SessionConfigOption, - { readonly type: "select" } - > => - option.type === "select" && - (option.category === "mode" || option.category === "collaboration_mode"), - ); - if (runtimePolicy.interactionMode === "plan") { - if (!nativeBuildConfigurationBySessionId.has(startResult.sessionId)) { - nativeBuildConfigurationBySessionId.set(startResult.sessionId, { - ...(effectiveModeState === undefined - ? {} - : { modeId: effectiveModeState.currentModeId }), - configOptions: planSensitiveOptions.map((option) => ({ - id: option.id, - value: option.currentValue, - })), - }); - } - const planMode = effectiveModeState?.availableModes.find( - (mode) => mode.id === "plan" || mode.id === "architect", - ); - if (planMode !== undefined && effectiveModeState?.currentModeId !== planMode.id) { - yield* runtime.setMode(planMode.id); - } - for (const option of planSensitiveOptions) { - const choices = option.options.flatMap((entry) => - "value" in entry ? [entry.value] : entry.options.map((choice) => choice.value), - ); - const requested = choices.find( - (choice) => choice === "plan" || choice === "architect", - ); - if (requested !== undefined && option.currentValue !== requested) { - yield* runtime.setConfigOption(option.id, requested); - } - } - } else { - const nativeBuild = nativeBuildConfigurationBySessionId.get(startResult.sessionId); - if (nativeBuild !== undefined) { - if ( - nativeBuild.modeId !== undefined && - effectiveModeState?.currentModeId !== nativeBuild.modeId && - effectiveModeState?.availableModes.some( - (mode) => mode.id === nativeBuild.modeId, - ) === true - ) { - yield* runtime.setMode(nativeBuild.modeId); - } - for (const saved of nativeBuild.configOptions) { - const option = effectiveConfigOptions.find( - (candidate) => candidate.type === "select" && candidate.id === saved.id, - ); - if (option === undefined || option.type !== "select") continue; - const choices = option.options.flatMap((entry) => - "value" in entry ? [entry.value] : entry.options.map((choice) => choice.value), - ); - if (option.currentValue !== saved.value && choices.includes(saved.value)) { - yield* runtime.setConfigOption(option.id, saved.value); - } - } - nativeBuildConfigurationBySessionId.delete(startResult.sessionId); - } - } - yield* ( - flavor.onSessionConfigurationUpdate?.( - yield* runtime.getConfigOptions, - yield* runtime.getModeState, - ) ?? Effect.void - ); - }); - - yield* configureSession(started, input.modelSelection, input.runtimePolicy); - yield* Ref.set(activeSelection, input.modelSelection); - yield* Ref.set(activeInteractionMode, input.runtimePolicy.interactionMode); - const createdAt = yield* DateTime.now; - const providerSession: OrchestrationV2ProviderSession = { - id: input.providerSessionId, - driver, - providerInstanceId: options.instanceId, - status: "ready", - cwd: input.runtimePolicy.cwd ?? process.cwd(), - model: input.modelSelection.model, - capabilities, - createdAt, - updatedAt: createdAt, - lastError: null, - }; - - const providerTurnPayload = ( - context: ActiveAcpTurn, - status: OrchestrationV2ProviderTurn["status"], - completedAt: DateTime.Utc | null, - ): OrchestrationV2ProviderTurn => ({ - id: context.providerTurnId, - providerThreadId: context.input.providerThread.id, - nodeId: context.input.rootNodeId, - runAttemptId: context.input.attemptId, - nativeTurnRef: { - driver, - nativeId: context.nativeTurnId, - strength: "weak", - }, - ordinal: context.input.providerTurnOrdinal, - status, - startedAt: context.startedAt, - completedAt, - }); - - const terminalizeOpenRunOwnedItems = Effect.fnUntraced(function* ( - context: ActiveAcpTurn, - options: { readonly terminalizeSubagents: boolean }, - ) { - for (const tool of context.tools.values()) { - const status = toolStatus(tool.status); - if (status === "pending" || status === "running" || status === "waiting") { - yield* emitTool(context, tool, "interrupted"); - } - } - if (!options.terminalizeSubagents) return; - for (const subagent of context.subagents.values()) { - if (subagent.task.status !== "running" && subagent.task.status !== "pending") { - continue; - } - yield* emitSubagent(context, { - nativeTaskId: subagent.task.nativeTaskRef?.nativeId ?? subagent.task.id, - prompt: subagent.task.prompt, - title: subagent.task.title, - model: subagent.task.model, - status: "interrupted", - childSessionId: subagent.childSessionId, - result: subagent.task.result, - suppressNormalTool: true, - }); - } - }); - - const terminalizeOpenForegroundTools = Effect.fnUntraced(function* ( - context: ActiveAcpTurn, - ) { - for (const tool of context.tools.values()) { - if (!acpCompletedTurnShouldTerminalizeTool(tool, flavor)) continue; - yield* emitTool(context, tool, "completed"); - } - }); - - const quarantineStoppedRun = Effect.fnUntraced(function* () { - yield* continuationPermit.withPermit( - Effect.gen(function* () { - yield* Ref.update(continuationGeneration, (value) => value + 1); - yield* Ref.set(stoppedRunQuarantine, true); - yield* Ref.set(wakeBuffer, []); - yield* Ref.set(wakeReports, noWakeReports); - yield* Ref.set(continuationRequested, false); - yield* Ref.set(runningBackgroundTaskIds, new Set()); - yield* Ref.set(midTurnUnreportedCompletedTaskIds, new Set()); - yield* Ref.set(carryoverSubagents, null); - yield* Ref.set(lastTurnRoute, null); - }), - ); - }); - - const finalizeTurn = Effect.fnUntraced(function* ( - context: ActiveAcpTurn, - status: "completed" | "interrupted" | "failed" | "cancelled", - failure?: OrchestrationV2ProviderFailure, - ) { - if (context.finalized) return; - const settledStatus = context.interrupted ? "interrupted" : status; - context.finalizedStatus = settledStatus; - context.finalized = true; - const directStopQuarantine = yield* Ref.get(stoppedRunQuarantine); - if (flavor.subagentsIdleOnTurnCompletion === true) { - for (const subagent of context.subagents.values()) { - if (!acpSubagentStatusBlocksTurnSettlement(subagent.task.status)) continue; - yield* emitSubagent(context, { - nativeTaskId: subagent.task.nativeTaskRef?.nativeId ?? subagent.task.id, - prompt: subagent.task.prompt, - title: subagent.task.title, - model: subagent.task.model, - status: settledStatus === "completed" ? "idle" : settledStatus, - childSessionId: subagent.childSessionId, - result: null, - }); - } - } - if (settledStatus === "completed") { - yield* terminalizeOpenForegroundTools(context); - } else if (settledStatus === "interrupted") { - // Direct Stop terminalizes every visible run-owned item. restart_active - // keeps live subagent lineages for in-process replacement carryover. - yield* terminalizeOpenRunOwnedItems(context, { - terminalizeSubagents: directStopQuarantine, - }); - } - yield* closeTextStreams(context); - if (settledStatus !== "failed") { - yield* emitProviderRetry(context, settledStatus); - } - const now = yield* DateTime.now; - if ( - flavor.supportsCompaction === true && - context.input.message.text.trim() === "/compact" && - context.input.message.attachments.length === 0 && - settledStatus === "completed" - ) { - const nativeItemId = `${context.nativeTurnId}:compaction`; - yield* emitProviderEvent({ - type: "turn_item.updated", - driver, - turnItem: { - id: idAllocator.derive.turnItemFromProviderItem({ driver, nativeItemId }), - threadId: context.input.threadId, - runId: context.input.runId, - nodeId: context.input.rootNodeId, - providerThreadId: context.input.providerThread.id, - providerTurnId: context.providerTurnId, - nativeItemRef: { driver, nativeId: nativeItemId, strength: "weak" }, - parentItemId: null, - ordinal: yield* resolveItemOrdinal(context, nativeItemId), - type: "compaction", - driver, - status: "completed", - title: "Context compacted", - startedAt: context.startedAt, - completedAt: now, - updatedAt: now, - }, - }); - } - const turn = providerTurnPayload(context, settledStatus, now); - yield* Ref.update(providerTurns, (current) => { - const updated = new Map(current); - updated.set(String(turn.id), turn); - return updated; - }); - yield* emitProviderEvent({ - type: "provider_turn.updated", - driver, - threadId: context.input.threadId, - providerTurn: turn, - }); - const updatedProviderThread: OrchestrationV2ProviderThread = { - ...context.input.providerThread, - providerSessionId: input.providerSessionId, - status: "active", - lastRunOrdinal: context.input.runOrdinal, - firstRunOrdinal: - context.input.providerThread.firstRunOrdinal ?? context.input.runOrdinal, - contextUsage: context.contextUsage, - nativeMetadata: context.nativeMetadata, - updatedAt: now, - }; - yield* Ref.update(providerThreadByNativeSessionId, (current) => - new Map(current).set(context.nativeThreadId, updatedProviderThread), - ); - yield* emitProviderEvent({ - type: "provider_thread.updated", - driver, - providerThread: updatedProviderThread, - }); - yield* emitProviderEvent( - settledStatus === "failed" - ? { - type: "turn.terminal", - driver, - providerThreadId: context.input.providerThread.id, - providerTurnId: context.providerTurnId, - runOrdinal: context.input.runOrdinal, - failureItemOrdinal: yield* resolveItemOrdinal( - context, - `terminal-failure:${context.providerTurnId}`, - ), - status: settledStatus, - failure: failure ?? makeProviderFailure({ class: "provider_error" }), - ...(context.providerRetry === undefined - ? {} - : { - retry: context.providerRetry.retry, - retryStartedAt: context.providerRetry.startedAt, - }), - threadDisposition: "reusable", - } - : { - type: "turn.terminal", - driver, - providerThreadId: context.input.providerThread.id, - providerTurnId: context.providerTurnId, - runOrdinal: context.input.runOrdinal, - status: settledStatus, - failure: null, - threadDisposition: "reusable", - }, - ); - const subagentsRequiringCarryover = [...context.subagents.values()].filter( - acpSubagentHasPendingBackgroundWork, - ); - // Direct Stop must not carry residual subagents into a later run. - if (subagentsRequiringCarryover.length > 0 && !directStopQuarantine) { - const sessionId = yield* Ref.get(activeSessionId); - if (sessionId !== null) { - yield* Ref.set(carryoverSubagents, { - sessionId, - rootTerminalStatus: settledStatus, - subagents: subagentsRequiringCarryover, - }); - } - } - yield* Ref.set(activeTurn, null); - // A mid-turn background completion may have deferred its offer while - // this root turn was still streaming. Once the turn leaves the active - // slot, re-check: empty running set + residual wake evidence (or a - // mid-turn unreported completion) means a legitimate unhandled - // completion can open exactly one continuation. Sticky - // continuationRequested prevents double-offer if a later frame also - // races into offerContinuationRun. - if ( - postSettleContinuationEnabled && - settledStatus === "completed" && - !directStopQuarantine && - (yield* Ref.get(runningBackgroundTaskIds)).size === 0 && - ((yield* Ref.get(wakeBuffer)).length > 0 || - (yield* Ref.get(midTurnUnreportedCompletedTaskIds)).size > 0) - ) { - const sessionId = yield* Ref.get(activeSessionId); - if (sessionId !== null) { - yield* offerContinuationRun(sessionId); - } - } - // Clear mid-turn unreported marks unless a completed turn still has - // running background work: keep them so the post-finalize gate can - // offer once when the last task ends. Interrupted/failed turns must - // not leave marks that open a wake after interrupt (quarantine owns - // that path). Non-continuation turn start also clears (~user turn). - if ( - settledStatus !== "completed" || - (yield* Ref.get(runningBackgroundTaskIds)).size === 0 - ) { - yield* Ref.set(midTurnUnreportedCompletedTaskIds, new Set()); - } - yield* Deferred.succeed(context.completed, undefined).pipe(Effect.ignore); - }); - - scheduleDeferredFinalize = (context) => - Effect.gen(function* () { - if (!flavor.deferFinalizeForBackgroundWork) return; - if (!context.promptSettled || context.finalized || context.interrupted) return; - if (hasDeferredBackgroundWork(context)) return; - context.backgroundFinalizeGeneration += 1; - const generation = context.backgroundFinalizeGeneration; - yield* Effect.gen(function* () { - yield* Effect.sleep(ACP_DEFERRED_FINALIZE_DEBOUNCE); - if ( - context.finalized || - context.interrupted || - context.backgroundFinalizeGeneration !== generation - ) { - return; - } - if (hasDeferredBackgroundWork(context)) return; - const status = context.promptSettledStatus ?? "completed"; - yield* finalizeTurn(context, status); - }).pipe(Effect.forkIn(sessionScope), Effect.asVoid); - yield* ( - options.testHooks?.onDeferredFinalizeScheduled?.(ACP_DEFERRED_FINALIZE_DEBOUNCE) ?? - Effect.void - ); - }); - - const resolvePromptParts = Effect.fnUntraced(function* ( - turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, - sessionId: string, - ) { - const prompt: Array = []; - const instructionState = { - interactionMode: turnInput.runtimePolicy.interactionMode, - hasT3Mcp: acpMcpServers(turnInput.threadId, self).length > 0, - } satisfies T3AcpInstructionState; - const previousInstructionState = (yield* Ref.get(promptInstructionStates)).get(sessionId); - const messageText = providerMessageTextWithAttachmentPaths({ - text: turnInput.message.text, - attachments: turnInput.message.attachments, - resolveAttachmentPath: (attachment) => - resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }), - }); - const text = t3AcpPromptWithInstructions({ - prompt: messageText, - state: instructionState, - ...(previousInstructionState === undefined - ? {} - : { previousState: previousInstructionState }), - }); - if (text.length > 0) { - prompt.push({ type: "text", text }); - } - const imageAttachments = turnInput.message.attachments.filter( - isProviderNativeImageAttachment, - ); - if (imageAttachments.length > 0 && !supportsImagePrompts) { - return yield* new ProviderAdapter.ProviderAdapterProtocolError({ - driver, - detail: "ACP driver did not negotiate image prompt support", - }); - } - for (const attachment of imageAttachments) { - const path = resolveAttachmentPath({ - attachmentsDir: serverConfig.attachmentsDir, - attachment: attachment as ChatAttachment, - }); - if (path === null) { - return yield* new ProviderAdapter.ProviderAdapterProtocolError({ - driver, - detail: `Invalid attachment id '${attachment.id}'`, - }); - } - const bytes = yield* fileSystem.readFile(path).pipe( - Effect.mapError( - (cause) => - new ProviderAdapter.ProviderAdapterProtocolError({ - driver, - detail: `Failed to read attachment '${attachment.id}'`, - payload: cause, - }), - ), - ); - prompt.push({ - type: "image", - data: Buffer.from(bytes).toString("base64"), - mimeType: attachment.mimeType, - }); - } - if (prompt.length === 0) { - return yield* new ProviderAdapter.ProviderAdapterProtocolError({ - driver, - detail: "ACP turn requires non-empty text or attachments", - }); - } - prompt.push({ - type: "text", - text: buildRuntimeInstructions({ - harness: flavor.runtimeHarness ?? driver, - model: turnInput.modelSelection.model, - }), - }); - return { prompt, instructionState: text === messageText ? undefined : instructionState }; - }); - - const restartRuntimeAfterTeardownIfRequired = Effect.fnUntraced(function* ( - threadId: ThreadId | null, - ) { - const restartRequired = yield* Ref.get(runtimeRestartRequired); - if (!restartRequired) return false; - yield* restartAcpRuntime(threadId); - yield* Ref.set(runtimeRestartRequired, false); - yield* Ref.set(activeSessionId, null); - yield* Ref.set(activeSessionSetup, null); - yield* Ref.set(activeSelection, null); - yield* Ref.set(activeInteractionMode, null); - yield* Ref.set(snapshot, { - order: [], - messages: new Map(), - loadingRole: null, - loadingMessageId: null, - loadingIndex: 0, - }); - return true; - }); - - const startTurnUnlocked = Effect.fn("AcpAdapterV2.startTurn")( - function* (turnInput: ProviderAdapter.ProviderAdapterV2TurnInput) { - yield* awaitRuntimeTeardown(); - const existing = yield* Ref.get(activeTurn); - if (existing !== null) { - return yield* new ProviderAdapter.ProviderAdapterProtocolError({ - driver, - detail: `ACP provider turn ${existing.providerTurnId} is still active`, - }); - } - useProviderThreadIdentity(turnInput.providerThread); - // Session activation can itself invoke client fs/terminal methods. - // Install the incoming thread policy before load/resume so those - // requests can never inherit the previously active thread's policy. - latestRuntimePolicy = turnInput.runtimePolicy; - const requestedSessionId = yield* nativeThreadId(driver, turnInput.providerThread); - const restartAfterInterrupt = yield* restartRuntimeAfterTeardownIfRequired( - turnInput.threadId, - ); - const needsSessionActivation = - (yield* Ref.get(activeSessionId)) !== requestedSessionId || restartAfterInterrupt; - if (needsSessionActivation) { - const activated = yield* activateSession(requestedSessionId, turnInput.threadId); - yield* Ref.set(activeSessionId, activated.sessionId); - yield* Ref.set(activeSessionSetup, activated); - yield* configureSession(activated, turnInput.modelSelection, turnInput.runtimePolicy); - yield* Ref.set(activeSelection, turnInput.modelSelection); - yield* Ref.set(activeInteractionMode, turnInput.runtimePolicy.interactionMode); - } else { - const configuredSelection = yield* Ref.get(activeSelection); - const configuredInteractionMode = yield* Ref.get(activeInteractionMode); - if ( - configuredSelection === null || - !modelSelectionsEqual(configuredSelection, turnInput.modelSelection) || - configuredInteractionMode !== turnInput.runtimePolicy.interactionMode - ) { - const currentSessionSetup = yield* Ref.get(activeSessionSetup); - if (currentSessionSetup === null) { - return yield* new ProviderAdapter.ProviderAdapterProtocolError({ - driver, - detail: `ACP session ${requestedSessionId} has no active setup metadata`, - }); - } - yield* configureSession( - currentSessionSetup, - turnInput.modelSelection, - turnInput.runtimePolicy, - ); - yield* Ref.set(activeSelection, turnInput.modelSelection); - yield* Ref.set(activeInteractionMode, turnInput.runtimePolicy.interactionMode); - } - } - yield* Ref.set(lastTurnRoute, { - threadId: turnInput.threadId, - providerThreadId: turnInput.providerThread.id, - }); - yield* Ref.set(suppressPostSettleMonitorPrompt, false); - yield* Ref.set(handledBackgroundTaskIdsInActiveTurn, new Set()); - // Continuation turns attach to wake traffic the agent already produced - // after the prior root turn settled; do not re-prompt the ACP session. - const isContinuationTurn = - postSettleContinuationEnabled && acpIsProviderContinuationMessage(turnInput.message); - const isAppOwnedWakeTurn = acpIsAppOwnedWakeTurn(turnInput.message); - // An app-owned wake reports on a sibling delegated child and owns - // none of this session's background work, so it must not discard - // pending wake evidence: neither the mid-turn completion marks kept - // across a settle with work still running, nor the wake buffer - // below. Dropping the marks here would lose the offer for a task - // that completed unreported before the settle. - if (!isAppOwnedWakeTurn) { - yield* Ref.set(midTurnUnreportedCompletedTaskIds, new Set()); - } - // A user turn supersedes an empty offer, but not an offer that owns - // buffered wake traffic. ProviderContinuationService queues that - // continuation behind the user run so it can drain afterwards. - const continuationWasRequested = yield* continuationPermit.withPermit( - Effect.gen(function* () { - const wasRequested = yield* Ref.get(continuationRequested); - const preserveBufferedContinuation = acpTurnStartShouldPreserveContinuation({ - continuationRequested: wasRequested, - isContinuationTurn, - wakeBufferLength: (yield* Ref.get(wakeBuffer)).length, - }); - // User turns must not inherit prior-turn wake residue. Stale - // injected-turn ack chatter can otherwise arm a later offer. - // Preserve only a queued continuation that owns buffered wake - // traffic, and exempt app-owned sibling wakes entirely. - if (!isContinuationTurn && !isAppOwnedWakeTurn && !preserveBufferedContinuation) { - yield* Ref.set(wakeBuffer, []); - yield* Ref.set(wakeReports, noWakeReports); - } - if (preserveBufferedContinuation) return wasRequested; - yield* Ref.update(continuationGeneration, (value) => value + 1); - yield* Ref.set(continuationRequested, false); - return wasRequested; - }), - ); - const promptParts = isContinuationTurn - ? null - : yield* resolvePromptParts(turnInput, requestedSessionId); - const startedAt = yield* DateTime.now; - const nativeTurnId = `${requestedSessionId}:turn:${turnInput.providerTurnOrdinal}`; - const providerTurnId = deriveProviderTurnId(nativeTurnId); - const completed = yield* Deferred.make(); - const promptWireSettled = yield* Deferred.make(); - const rememberedContextUsage = (yield* Ref.get(contextUsageBySessionId)).get( - requestedSessionId, - ); - const rememberedNativeMetadata = (yield* Ref.get(nativeMetadataBySessionId)).get( - requestedSessionId, - ); - const initialNativeMetadata = - rememberedNativeMetadata === undefined - ? (turnInput.providerThread.nativeMetadata ?? null) - : { - ...turnInput.providerThread.nativeMetadata, - ...rememberedNativeMetadata, - }; - if (initialNativeMetadata !== null) { - yield* Ref.update(nativeMetadataBySessionId, (current) => - new Map(current).set(requestedSessionId, initialNativeMetadata), - ); - } - const context: ActiveAcpTurn = { - input: turnInput, - providerTurnId, - nativeThreadId: requestedSessionId, - nativeTurnId, - startedAt, - completed, - itemOrdinals: new Map(), - user: { current: null, nextSegment: 0 }, - assistant: { current: null, nextSegment: 0 }, - reasoning: { current: null, nextSegment: 0 }, - contextUsage: rememberedContextUsage ?? turnInput.providerThread.contextUsage ?? null, - nativeMetadata: initialNativeMetadata, - tools: new Map(), - toolUpdatesSkipped: new Map(), - toolStartedAt: new Map(), - subagents: new Map(), - subagentsBySessionId: new Map(), - pendingSubagentNotifications: new Map(), - toolCallIdsByBackgroundTaskId: new Map(), - persistentBackgroundTaskIds: new Set(), - awaitingBackgroundHydration: new Set(), - pendingInjectedReport: new Set(), - earlyInjectedReportObserved: false, - plans: new Map(), - interrupted: false, - finalized: false, - finalizedStatus: null, - promptSettled: false, - promptSettledStatus: null, - promptWireSettled, - backgroundFinalizeGeneration: 0, - }; - const carryover = yield* Ref.getAndSet(carryoverSubagents, null); - let rehydratedCarryoverSubagents: ReadonlyArray = []; - if (carryover !== null && carryover.sessionId === requestedSessionId) { - rehydratedCarryoverSubagents = carryover.subagents; - for (const subagent of carryover.subagents) { - const nativeId = subagent.task.nativeTaskRef?.nativeId ?? null; - if (nativeId !== null) { - context.subagents.set(nativeId, subagent); - } - if (subagent.childSessionId !== null) { - context.subagentsBySessionId.set(subagent.childSessionId, subagent); - } - } - } - yield* Ref.set(activeTurn, context); - // Direct Stop closes and recreates the old runtime before reaching - // this reset. The quarantine remains session-scoped by design. - yield* Ref.set(stoppedRunQuarantine, false); - const runningTurn = providerTurnPayload(context, "running", null); - yield* Ref.update(providerTurns, (current) => { - const updated = new Map(current); - updated.set(String(runningTurn.id), runningTurn); - return updated; - }); - yield* emitProviderEvent({ - type: "provider_turn.updated", - driver, - threadId: turnInput.threadId, - providerTurn: runningTurn, - }); - const activeProviderThread: OrchestrationV2ProviderThread = { - ...turnInput.providerThread, - providerSessionId: input.providerSessionId, - status: "active", - contextUsage: context.contextUsage, - nativeMetadata: context.nativeMetadata, - updatedAt: startedAt, - }; - yield* Ref.update(providerThreadByNativeSessionId, (current) => - new Map(current).set(requestedSessionId, activeProviderThread), - ); - yield* emitProviderEvent({ - type: "provider_thread.updated", - driver, - providerThread: activeProviderThread, - }); - yield* rememberSnapshotMessage({ - createdBy: turnInput.message.createdBy, - creationSource: turnInput.message.creationSource, - ...(turnInput.message.scheduledTaskId === undefined - ? {} - : { scheduledTaskId: turnInput.message.scheduledTaskId }), - ...(turnInput.message.senderThreadId === undefined - ? {} - : { senderThreadId: turnInput.message.senderThreadId }), - id: turnInput.message.messageId, - threadId: turnInput.threadId, - runId: turnInput.runId, - nodeId: turnInput.rootNodeId, - role: "user", - text: turnInput.message.text, - attachments: [...turnInput.message.attachments], - streaming: false, - createdAt: startedAt, - updatedAt: startedAt, - }); - // Every attach ends the deferred-terminal contract, but only the - // queued provider continuation owns wake traffic. A user turn may - // start before that continuation and must leave its buffer intact. - yield* projectDeferredCarryoverTerminals(rehydratedCarryoverSubagents); - // Keep projected terminals addressable through the wake drain so - // emitSubagent's monotonic guard can reject replayed spawn frames. - // Finalize carries only entries with pending background work, so a - // terminal-and-projected lineage still expires with this turn. - if (isContinuationTurn) { - yield* Ref.set(continuationRequested, false); - // This turn delivers what its offer named. Work that ended after - // the offer keeps its report for the next one. - yield* Ref.update(wakeReports, ({ reports, offered }) => ({ - reports: new Map([...reports].filter(([key]) => !offered.has(key))), - offered: noWakeReports.offered, - })); - const drainedWakeCount = yield* Ref.modify(wakeBuffer, (current) => { - const next: Array = []; - return [ - current.filter((notification) => notification.sessionId === requestedSessionId), - next, - ] as const; - }).pipe( - Effect.tap((drained) => - Effect.forEach(drained, handleSessionUpdate, { - concurrency: 1, - discard: true, - }), - ), - Effect.map((drained) => drained.length), - ); - // Treat attach mode as prompt-settled so deferred finalize / quiet - // windows can complete the continuation after wake traffic drains. - context.promptSettled = true; - context.promptSettledStatus = "completed"; - if (drainedWakeCount === 0) { - // A requested continuation with only mid-turn evidence has no - // buffered frame yet. Wait the quiet window so late CLI frames - // can attach. A provider-authored attach without a matching - // request only exists to project deferred carryover terminals, - // so it can finish immediately once those terminals are visible. - if (continuationWasRequested && flavor.deferFinalizeForBackgroundWork === true) { - if (hasDeferredBackgroundWork(context)) { - yield* rearmDeferredFinalize(context); - } else { - yield* scheduleDeferredFinalize(context); - } - } else { - yield* finalizeTurn(context, "completed"); - } - return; - } - if (!context.finalized) { - if (hasDeferredBackgroundWork(context)) { - yield* rearmDeferredFinalize(context); - } else { - yield* scheduleDeferredFinalize(context); - } - } - return; - } - const promptGeneration = yield* Ref.get(runtimeCallbackGeneration); - yield* runtime.prompt({ prompt: promptParts!.prompt }).pipe( - Effect.tap(() => - Ref.update(promptInstructionStates, (current) => { - if (promptParts?.instructionState === undefined) return current; - const updated = new Map(current); - updated.set(requestedSessionId, promptParts.instructionState); - return updated; - }), - ), - // Wire settlement precedes the completion callback's permit request so - // settled-soft classification can observe the native return even when - // the completion fiber has not yet set promptSettled under the permit. - Effect.tap(() => - Deferred.succeed(context.promptWireSettled, undefined).pipe(Effect.asVoid), - ), - Effect.flatMap((result) => - runRuntimeCallbackAtGeneration( - promptGeneration, - Effect.gen(function* () { - if (context.finalized) return; - const status = - result.stopReason === "cancelled" - ? context.interrupted - ? "interrupted" - : "cancelled" - : "completed"; - // Grok monitors (and async subagents) keep working after the root - // prompt RPC returns. Defer finalize so their later updates and - // wake-turn traffic still project onto this run. - if ( - flavor.deferFinalizeForBackgroundWork === true && - !context.interrupted && - hasDeferredBackgroundWork(context) - ) { - context.promptSettled = true; - context.promptSettledStatus = status; - // The agent finished this prompt's reply. Background work - // holds the run open, not the text it already sent. - yield* closeTextStreams(context); - yield* ( - options.testHooks?.afterPromptSettledWithBackgroundWork?.() ?? Effect.void - ); - return; - } - yield* finalizeTurn(context, status); - }), - ).pipe(Effect.asVoid), - ), - // Prompt failure is not wire-settled: only a successful resolve marks - // the signal. catchCause must not complete promptWireSettled. - Effect.catchCause((cause) => - runRuntimeCallbackAtGeneration( - promptGeneration, - Effect.gen(function* () { - if (context.finalized) return; - yield* finalizeTurn( - context, - context.interrupted ? "interrupted" : "failed", - flavor.promptFailure?.(Cause.squash(cause)) ?? - makeProviderFailure({ - cause: Cause.squash(cause), - class: "provider_error", - }), - ).pipe( - Effect.andThen( - Effect.logWarning("orchestration-v2.acp-prompt-failed", { - driver, - providerSessionId: input.providerSessionId, - providerThreadId: turnInput.providerThread.id, - providerTurnId, - cause, - }), - ), - ); - }), - ).pipe(Effect.asVoid), - ), - Effect.forkIn(sessionScope), - ); - }, - (effect, turnInput) => - effect.pipe( - Effect.mapError( - (cause) => - new ProviderAdapter.ProviderAdapterTurnStartError({ - driver, - threadId: turnInput.threadId, - providerThreadId: turnInput.providerThread.id, - runId: turnInput.runId, - cause, - }), - ), - ), - ); - - const startTurn = Effect.fn("AcpAdapterV2.startTurn.transition")(function* ( - turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, - ) { - return yield* runtimeTransitionPermit.withPermit(startTurnUnlocked(turnInput)); - }); - - yield* Effect.addFinalizer(() => - Effect.gen(function* () { - yield* continuationPermit.withPermit( - Effect.gen(function* () { - yield* Ref.set(continuationClosed, true); - yield* Ref.update(continuationGeneration, (value) => value + 1); - }), - ); - const requests = [...(yield* Ref.get(pendingRuntimeRequests)).values()]; - yield* Effect.forEach( - requests, - (request) => - request.type === "approval" - ? Deferred.succeed(request.decision, "cancel").pipe(Effect.ignore) - : Deferred.succeed(request.answers, null).pipe(Effect.ignore), - { discard: true }, - ); - const closingError = new EffectAcpErrors.AcpTransportError({ - detail: "The ACP session closed before its admitted response reached the transport", - cause: "ACP session transport closed", - }); - yield* Effect.forEach( - requests, - (request) => Deferred.fail(request.nativeResponseAcknowledgement, closingError), - { discard: true }, - ); - const transportHadOutstandingResponses = yield* closeNativeTransport; - yield* options.testHooks?.afterNativeResponseTransportClosed?.() ?? Effect.void; - const sessionCapabilities = - started.initializeResult.agentCapabilities?.sessionCapabilities; - if (sessionCapabilities?.close != null) { - yield* runtimeTransitionPermit.withPermitsIfAvailable(1)( - Effect.gen(function* () { - const teardownState = yield* Ref.get(runtimeTeardownState); - const restartRequired = yield* Ref.get(runtimeRestartRequired); - if ( - teardownState._tag === "Idle" && - !restartRequired && - !transportHadOutstandingResponses - ) { - yield* runtime.closeSession().pipe(Effect.ignore); - } - }), - ); - } - if (flavor.assertComplete !== undefined) { - yield* flavor.assertComplete.pipe(Effect.orDie); - } - if (runtimeScope !== undefined) { - yield* Scope.close(runtimeScope, Exit.void).pipe(Effect.ignore); - } - }), - ); - - const sessionRuntime: ProviderAdapter.ProviderAdapterV2SessionRuntime = { - instanceId: options.instanceId, - driver, - providerSessionId: input.providerSessionId, - providerSession, - events: Stream.fromEffectRepeat(Queue.take(events)), - ...(postSettleContinuationEnabled - ? { - hasPendingBackgroundWork: Effect.gen(function* () { - if ((yield* Ref.get(wakeBuffer)).length > 0) return true; - if (yield* Ref.get(continuationRequested)) return true; - if ((yield* Ref.get(runningBackgroundTaskIds)).size > 0) return true; - // Projected post-settle Grok subagents can outlive the root - // turn via carryover; keep the ACP process pinned until they - // terminalize or teardown clears the carryover. - // Also pin while a terminal status is held only in memory - // (project:false) so idle release cannot drop the session - // before the continuation drain (or a later project:true path) - // delivers the turn_item terminal. - const active = yield* Ref.get(activeTurn); - if ( - active !== null && - [...active.subagents.values()].some(acpSubagentHasPendingBackgroundWork) - ) { - return true; - } - const carryover = yield* Ref.get(carryoverSubagents); - if ( - carryover !== null && - carryover.subagents.some(acpSubagentHasPendingBackgroundWork) - ) { - return true; - } - return false; - }), - } - : {}), - ensureThread: Effect.fn("AcpAdapterV2.ensureThread")( - function* (threadInput: ProviderAdapter.ProviderAdapterV2EnsureThreadInput) { - const now = yield* DateTime.now; - const sessionId = yield* Ref.get(activeSessionId); - if (sessionId === null) { - return yield* new ProviderAdapter.ProviderAdapterProtocolError({ - driver, - detail: "ACP runtime did not produce a session id", - }); - } - const providerThread = makeProviderThread({ - driver, - providerInstanceId: options.instanceId, - idAllocator, - appThreadId: threadInput.threadId, - providerSessionId: input.providerSessionId, - nativeThreadId: sessionId, - ...(itemIdentityVersion === undefined ? {} : { itemIdentityVersion }), - now, - }); - yield* Ref.update(providerThreadByNativeSessionId, (current) => - new Map(current).set(sessionId, providerThread), - ); - return providerThread; - }, - (effect, threadInput) => - effect.pipe( - Effect.mapError( - (cause) => - new ProviderAdapter.ProviderAdapterEnsureThreadError({ - driver, - threadId: threadInput.threadId, - cause, - }), - ), - ), - ), - resumeThread: Effect.fn("AcpAdapterV2.resumeThread")( - function* (threadInput: { - readonly providerThread: OrchestrationV2ProviderThread; - readonly modelSelection?: ModelSelection; - readonly runtimePolicy?: ProviderAdapter.ProviderAdapterV2RuntimePolicy; - }) { - return yield* runtimeTransitionPermit.withPermit( - Effect.gen(function* () { - yield* awaitRuntimeTeardown(); - const sessionId = yield* nativeThreadId(driver, threadInput.providerThread); - const previousItemIdentityVersion = itemIdentityVersion; - useProviderThreadIdentity(threadInput.providerThread); - const restorePreviousItemIdentity = Effect.sync(() => { - itemIdentityVersion = previousItemIdentityVersion; - }); - const restartAfterInterrupt = yield* restartRuntimeAfterTeardownIfRequired( - threadInput.providerThread.appThreadId, - ).pipe(Effect.tapError(() => restorePreviousItemIdentity)); - if ((yield* Ref.get(activeSessionId)) !== sessionId || restartAfterInterrupt) { - yield* Ref.set(snapshot, { - order: [], - messages: new Map(), - loadingRole: null, - loadingMessageId: null, - loadingIndex: 0, - }); - const activated = yield* activateSession( - sessionId, - threadInput.providerThread.appThreadId, - ).pipe(Effect.tapError(() => restorePreviousItemIdentity)); - yield* Ref.set(activeSessionId, activated.sessionId); - yield* Ref.set(activeSessionSetup, activated); - const nextSelection = threadInput.modelSelection ?? input.modelSelection; - const nextRuntimePolicy = threadInput.runtimePolicy ?? input.runtimePolicy; - yield* configureSession(activated, nextSelection, nextRuntimePolicy); - yield* Ref.set(activeSelection, nextSelection); - yield* Ref.set(activeInteractionMode, nextRuntimePolicy.interactionMode); - } - const now = yield* DateTime.now; - return { - ...threadInput.providerThread, - providerSessionId: input.providerSessionId, - status: "idle" as const, - updatedAt: now, - }; - }), - ); - }, - (effect, threadInput) => - effect.pipe( - Effect.mapError( - (cause) => - new ProviderAdapter.ProviderAdapterResumeThreadError({ - driver, - providerSessionId: input.providerSessionId, - providerThreadId: threadInput.providerThread.id, - cause, - }), - ), - ), - ), - startTurn, - ...(flavor.supportsCompaction === true - ? { - compactThread: (turnInput: ProviderAdapter.ProviderAdapterV2TurnInput) => - startTurn({ - ...turnInput, - message: { ...turnInput.message, text: "/compact" }, - }), - } - : {}), - steerTurn: (turnInput) => - Effect.fail( - new ProviderAdapter.ProviderAdapterSteerRunUnsupportedError({ - driver, - providerThreadId: turnInput.providerThread.id, - }), - ), - interruptTurn: Effect.fn("AcpAdapterV2.interruptTurn")( - function* (turnInput: ProviderAdapter.ProviderAdapterV2InterruptInput) { - return yield* Effect.uninterruptibleMask((restore) => - Effect.gen(function* () { - yield* restore(runtimeTransitionPermit.take(1)); - return yield* Effect.gen(function* () { - const transition = yield* Effect.gen(function* () { - const interruptContext = yield* Ref.get(activeTurn); - // Settled steering restart: the native prompt already - // returned and this is not a user Stop, so keep the - // process (and its background subagents) alive instead - // of hard-killing it. The turn still terminalizes below. - const softSteerCandidate = - flavor.preserveRuntimeOnSettledInterrupt === true && - turnInput.requestRuntimeRestart !== true && - interruptContext?.providerTurnId === turnInput.providerTurnId; - // Settlement is read under the callback permit after - // draining admitted responses: the native prompt can have - // returned on the wire while its completion callback has - // not yet run, and a cancel sent in that window is exactly - // what settled-soft mode exists to avoid. Also treat the - // turn as settled when the wire signal is already done - // (non-blocking; do not await under the permit). - let settledSoftInterrupt = false; - if (softSteerCandidate && interruptContext !== null) { - const promptSettledUnderPermit = yield* runtimeCallbackPermit.withPermit( - awaitAdmittedNativeResponses.pipe( - Effect.andThen( - Effect.sync(() => interruptContext.promptSettled === true), - ), - ), - ); - const wireSettled = yield* Deferred.isDone( - interruptContext.promptWireSettled, - ); - settledSoftInterrupt = promptSettledUnderPermit || wireSettled; - } - const restartRuntime = - !settledSoftInterrupt && - (turnInput.requestRuntimeRestart === true || - flavor.restartRuntimeOnEveryInterrupt === true); - const hardRestart = - restartRuntime && flavor.terminateRuntimeProcessGroupOnInterrupt === true; - if (hardRestart) { - const teardownState = yield* Ref.get(runtimeTeardownState); - if (teardownState._tag === "Failed") { - return yield* teardownState.error; - } - if (teardownState._tag === "InProgress") { - // Concurrent Stop/restart: wait for the in-flight hard - // teardown instead of failing the durable effect. - yield* Effect.logInfo( - "ACP interrupt awaiting in-progress hard teardown", - { - driver, - providerTurnId: turnInput.providerTurnId, - }, - ); - yield* Deferred.await(teardownState.completed); - return undefined; - } - } - const context = interruptContext; - if (context?.providerTurnId !== turnInput.providerTurnId) { - // A soft steering interrupt can clear the turn while - // intentionally leaving the process alive. A queued user - // Stop must still contain that orphan runtime. With a - // different live turn active this stays a pure no-op - // success instead: quarantine and teardown are - // session-global and would maim the replacement turn. - const containOrphanRuntime = - context === null && - hardRestart && - turnInput.requestRuntimeRestart === true; - // Transport death or a prior Stop already cleared the - // turn. Failing here caused effect-worker retries while - // the process was already gone; treat as success. - yield* Effect.logWarning( - containOrphanRuntime - ? "ACP Stop raced a soft interrupt that cleared the turn; containing the orphan runtime" - : "ACP interrupt raced transport teardown or a prior Stop; treating as already interrupted", - { - driver, - requestedProviderTurnId: turnInput.providerTurnId, - activeProviderTurnId: context?.providerTurnId ?? null, - hardRestart, - requestRuntimeRestart: turnInput.requestRuntimeRestart === true, - teardownState: (yield* Ref.get(runtimeTeardownState))._tag, - }, - ); - if (containOrphanRuntime) { - const teardownBarrier = yield* Deferred.make< - void, - ProviderAdapter.ProviderAdapterProtocolError - >(); - yield* runtimeCallbackPermit.withPermit( - Effect.gen(function* () { - yield* awaitAdmittedNativeResponses; - const stoppedGeneration = yield* Ref.get(runtimeCallbackGeneration); - yield* quarantineNativeTransportAtGeneration(stoppedGeneration); - yield* Ref.set(runtimeTeardownState, { - _tag: "InProgress", - completed: teardownBarrier, - }); - yield* advanceRuntimeCallbackGeneration; - }), - ); - // Capture before quarantineStoppedRun clears carryover. - const orphanCarryover = yield* Ref.getAndSet(carryoverSubagents, null); - yield* quarantineStoppedRun(); - // Match the main hard-restart path: cancel pending - // approvals/elicitations after quarantine, before kill. - yield* cancelPendingRuntimeRequests(); - yield* terminalizeCarryoverSubagents(orphanCarryover); - if (runtime.terminateProcessGroup === undefined) { - const error = new ProviderAdapter.ProviderAdapterProtocolError({ - driver, - detail: - "ACP runtime does not expose its required process-group teardown; the session is poisoned", - }); - yield* Ref.set(runtimeTeardownState, { _tag: "Failed", error }); - yield* Deferred.fail(teardownBarrier, error).pipe(Effect.ignore); - return yield* error; - } - const teardownExit = yield* runtime.terminateProcessGroup.pipe( - Effect.exit, - ); - if (Exit.isFailure(teardownExit)) { - const error = new ProviderAdapter.ProviderAdapterProtocolError({ - driver, - detail: - "ACP orphan runtime process-group teardown failed; the session is poisoned", - payload: Cause.squash(teardownExit.cause), - }); - yield* Ref.set(runtimeTeardownState, { _tag: "Failed", error }); - yield* Deferred.fail(teardownBarrier, error).pipe(Effect.ignore); - return yield* error; - } - yield* Ref.set(runtimeRestartRequired, true); - yield* Ref.set(runtimeTeardownState, { _tag: "Idle" }); - yield* Deferred.succeed(teardownBarrier, undefined).pipe(Effect.ignore); - } - return undefined; - } - const teardownBarrier = hardRestart - ? yield* Deferred.make() - : null; - if (teardownBarrier !== null) { - yield* runtimeCallbackPermit.withPermit( - Effect.gen(function* () { - yield* awaitAdmittedNativeResponses; - const stoppedGeneration = yield* Ref.get(runtimeCallbackGeneration); - yield* quarantineNativeTransportAtGeneration(stoppedGeneration); - yield* Ref.set(runtimeTeardownState, { - _tag: "InProgress", - completed: teardownBarrier, - }); - yield* advanceRuntimeCallbackGeneration; - yield* ( - options.testHooks?.afterHardTeardownTransportDrained?.() ?? - Effect.void - ); - }), - ); - } - return { - context, - restartRuntime, - hardRestart, - teardownBarrier, - settledSoftInterrupt, - }; - }); - // Concurrent hard teardown already completed above. - if (transition === undefined) return; - const { - context, - restartRuntime, - hardRestart, - teardownBarrier, - settledSoftInterrupt, - } = transition; - const poisonTeardown = Effect.fnUntraced(function* ( - detail: string, - payload?: unknown, - ) { - const error = new ProviderAdapter.ProviderAdapterProtocolError({ - driver, - detail, - ...(payload === undefined ? {} : { payload }), - }); - yield* Ref.set(runtimeTeardownState, { _tag: "Failed", error }); - yield* Deferred.fail(teardownBarrier!, error).pipe(Effect.ignore); - return error; - }); - const runTransition = Effect.gen(function* () { - context.interrupted = true; - // Quarantine only when this run is discarded (user Stop / - // hard process kill). Soft in-process restarts must keep - // carryoverSubagents so a still-running subagent can - // complete into the replacement turn. - if (hardRestart || turnInput.requestRuntimeRestart === true) { - yield* quarantineStoppedRun(); - } - yield* cancelPendingRuntimeRequests(); - // Finalize before process-group kill so projection/UI cannot - // lag a dead transport, and concurrent interrupt effects see - // activeTurn cleared (idempotent success) rather than racing. - if ( - (hardRestart || context.promptSettled || settledSoftInterrupt) && - !context.finalized - ) { - // hardRestart: always terminalize locally. - // promptSettled / settledSoftInterrupt (incl. wire-settled): - // native prompt already returned; only deferred background - // work remains, so session/cancel has nothing to acknowledge. - yield* finalizeTurn(context, "interrupted"); - } - if (hardRestart) { - if (runtime.terminateProcessGroup === undefined) { - return yield* poisonTeardown( - "ACP runtime does not expose its required process-group teardown; the session is poisoned", - ).pipe(Effect.flatMap(Effect.fail)); - } - const teardownExit = yield* runtime.terminateProcessGroup!.pipe( - Effect.exit, - ); - if (Exit.isFailure(teardownExit)) { - return yield* poisonTeardown( - "ACP runtime process-group teardown failed; the session is poisoned", - Cause.squash(teardownExit.cause), - ).pipe(Effect.flatMap(Effect.fail)); - } - // Process group is gone; the next turn must spawn a - // replacement even if the flavor only set hardRestart - // without restartRuntimeAfterInterrupt. - yield* Ref.set(runtimeRestartRequired, true); - yield* Ref.set(runtimeTeardownState, { _tag: "Idle" }); - yield* Deferred.succeed(teardownBarrier!, undefined).pipe(Effect.ignore); - } else { - // Settled soft interrupt: the native prompt already - // returned, so session/cancel has nothing to - // acknowledge and would only threaten still-running - // background subagents. Skip it and leave the runtime - // untouched for the replacement turn. - if (!settledSoftInterrupt) { - yield* runtime.cancel; - } - if (restartRuntime && flavor.restartRuntimeAfterInterrupt === true) { - yield* Ref.set(runtimeRestartRequired, true); - } - } - const stopped = yield* Deferred.await(context.completed).pipe( - Effect.timeoutOption("10 seconds"), - ); - if (Option.isNone(stopped)) { - if (!context.finalized) { - yield* finalizeTurn(context, "interrupted"); - } - return yield* new ProviderAdapter.ProviderAdapterProtocolError({ - driver, - detail: `ACP provider turn ${turnInput.providerTurnId} did not acknowledge cancellation before the interrupt timeout`, - }); - } - }); - if (!hardRestart) { - return yield* restore(runTransition); - } - return yield* runTransition.pipe( - Effect.catchCause((cause) => - Effect.gen(function* () { - const state = yield* Ref.get(runtimeTeardownState); - if (state._tag === "InProgress") { - yield* poisonTeardown( - "ACP hard teardown failed unexpectedly; the session is poisoned", - Cause.squash(cause), - ); - } - return yield* Effect.failCause(cause); - }), - ), - ); - }).pipe(Effect.ensuring(runtimeTransitionPermit.release(1))); - }), - ); - }, - (effect, turnInput) => - effect.pipe( - Effect.mapError( - (cause) => - new ProviderAdapter.ProviderAdapterInterruptError({ - driver, - providerThreadId: turnInput.providerThread.id, - providerTurnId: turnInput.providerTurnId, - cause, - }), - ), - ), - ), - respondToRuntimeRequest: (requestInput) => - Effect.uninterruptibleMask((restore) => - Effect.gen(function* () { - yield* restore(runtimeTransitionPermit.take(1)); - return yield* Effect.gen(function* () { - yield* awaitRuntimeTeardown(); - const generation = yield* Ref.get(runtimeCallbackGeneration); - const pending = (yield* Ref.get(pendingRuntimeRequests)).get( - String(requestInput.requestId), - ); - if (pending === undefined || pending.generation !== generation) { - return yield* new ProviderAdapter.ProviderAdapterProtocolError({ - driver, - detail: `No pending ACP runtime request ${requestInput.requestId}`, - }); - } - const settled = - pending.type === "user_input" - ? yield* Deferred.succeed(pending.answers, requestInput.answers ?? null) - : requestInput.decision === undefined - ? yield* new ProviderAdapter.ProviderAdapterProtocolError({ - driver, - detail: `ACP approval request ${requestInput.requestId} requires a decision`, - }) - : yield* Deferred.succeed(pending.decision, requestInput.decision); - if (!settled) { - return yield* new ProviderAdapter.ProviderAdapterProtocolError({ - driver, - detail: `ACP runtime request ${requestInput.requestId} was already resolved`, - }); - } - yield* awaitNativeResponseAcknowledgements([ - [pending.transportRequestId, pending.nativeResponseAcknowledgement], - ]); - yield* Deferred.await(pending.nativeResponseAcknowledgement); - }).pipe(Effect.ensuring(runtimeTransitionPermit.release(1))); - }), - ).pipe( - Effect.mapError( - (cause) => - new ProviderAdapter.ProviderAdapterRuntimeRequestResponseError({ - driver, - requestId: requestInput.requestId, - cause, - }), - ), - ), - readThreadSnapshot: Effect.fn("AcpAdapterV2.readThreadSnapshot")( - function* (snapshotInput) { - return yield* runtimeTransitionPermit.withPermit( - Effect.gen(function* () { - yield* awaitRuntimeTeardown(); - useProviderThreadIdentity(snapshotInput.providerThread); - yield* restartRuntimeAfterTeardownIfRequired( - snapshotInput.providerThread.appThreadId, - ); - const sessionId = yield* nativeThreadId(driver, snapshotInput.providerThread); - if ((yield* Ref.get(activeSessionId)) !== sessionId) { - if (!capabilities.threads.canReadThreadSnapshot) { - return yield* new ProviderAdapter.ProviderAdapterProtocolError({ - driver, - detail: "ACP driver does not support session/load snapshots", - }); - } - yield* Ref.set(snapshot, { - order: [], - messages: new Map(), - loadingRole: null, - loadingMessageId: null, - loadingIndex: 0, - }); - prepareTerminalEnvironment(snapshotInput.providerThread.appThreadId, sessionId); - const activated = yield* runtime.loadSession( - sessionId, - acpMcpActivation(snapshotInput.providerThread.appThreadId, self), - ); - rememberTerminalEnvironment( - activated.sessionId, - snapshotInput.providerThread.appThreadId, - ); - yield* Ref.set(activeSessionId, activated.sessionId); - yield* Ref.set(activeSessionSetup, activated); - yield* Ref.set(activeSelection, null); - yield* Ref.set(activeInteractionMode, null); - } - const state = yield* Ref.get(snapshot); - const now = yield* DateTime.now; - return { - providerThread: { - ...snapshotInput.providerThread, - providerSessionId: input.providerSessionId, - status: "idle" as const, - updatedAt: now, - }, - providerTurns: [...(yield* Ref.get(providerTurns)).values()], - messages: state.order.flatMap((key) => { - const message = state.messages.get(key); - return message === undefined ? [] : [message]; - }), - runtimeRequests: [], - providerPayload: { protocol: ACP_PROTOCOL, sessionId }, - }; - }), - ); - }, - (effect, snapshotInput) => - effect.pipe( - Effect.mapError( - (cause) => - new ProviderAdapter.ProviderAdapterReadThreadSnapshotError({ - driver, - providerThreadId: snapshotInput.providerThread.id, - cause, - }), - ), - ), - ), - rollbackThread: (rollbackInput) => - runtimeTransitionPermit - .withPermit( - Effect.gen(function* () { - const currentTurn = yield* Ref.get(activeTurn); - if (currentTurn !== null) { - return yield* new ProviderAdapter.ProviderAdapterProtocolError({ - driver, - detail: `Cannot roll back ACP provider thread ${rollbackInput.providerThread.id} while turn ${currentTurn.providerTurnId} is active`, - }); - } - // ACP defines no conversation truncation, so rollback stages - // a fresh native session before retiring the original. - // Returning its binding keeps the next turn runnable without - // loading any of the rolled-back conversation. - yield* awaitRuntimeTeardown(); - itemIdentityVersion = 2; - prepareTerminalEnvironment(rollbackInput.providerThread.appThreadId); - const replacement = yield* startReplacementAcpRuntime( - rollbackInput.providerThread.appThreadId, - (candidate) => - Effect.gen(function* () { - rememberTerminalEnvironment( - candidate.sessionId, - rollbackInput.providerThread.appThreadId, - ); - yield* Ref.set(runtimeRestartRequired, false); - yield* Ref.set(activeSessionId, candidate.sessionId); - yield* Ref.set(activeSessionSetup, candidate); - yield* Ref.set(activeSelection, null); - yield* Ref.set(activeInteractionMode, null); - yield* Ref.set(promptInstructionStates, new Map()); - yield* Ref.set(providerTurns, new Map()); - yield* Ref.set(snapshot, { - order: [], - messages: new Map(), - loadingRole: null, - loadingMessageId: null, - loadingIndex: 0, - }); - yield* continuationPermit.withPermit( - Effect.gen(function* () { - yield* Ref.update(continuationGeneration, (value) => value + 1); - yield* Ref.set(stoppedRunQuarantine, false); - yield* Ref.set(wakeBuffer, []); - yield* Ref.set(wakeReports, noWakeReports); - yield* Ref.set(continuationRequested, false); - yield* Ref.set(runningBackgroundTaskIds, new Set()); - yield* Ref.set(endedBackgroundTaskIds, new Set()); - yield* Ref.set(midTurnUnreportedCompletedTaskIds, new Set()); - yield* Ref.set(handledBackgroundTaskIdsInActiveTurn, new Set()); - yield* Ref.set(carryoverSubagents, null); - yield* Ref.set(suppressPostSettleMonitorPrompt, false); - yield* Ref.set(lastTurnRoute, null); - }), - ); - }), - ).pipe(Effect.retry({ times: 1 })); - const now = yield* DateTime.now; - return { - providerThread: { - ...rollbackInput.providerThread, - nativeThreadRef: { - driver, - nativeId: replacement.sessionId, - strength: "strong" as const, - }, - nativeConversationHeadRef: null, - nativeMetadata: { - ...rollbackInput.providerThread.nativeMetadata, - itemIdentityVersion: 2 as const, - }, - status: "idle" as const, - updatedAt: now, - }, - providerTurns: [], - messages: [], - runtimeRequests: [], - }; - }), - ) - .pipe( - Effect.mapError( - (cause) => - new ProviderAdapter.ProviderAdapterRollbackThreadError({ - driver, - providerThreadId: rollbackInput.providerThread.id, - checkpointId: rollbackInput.target.checkpointId, - cause, - }), - ), - ), - forkThread: Effect.fn("AcpAdapterV2.forkThread")( - function* (forkInput) { - return yield* runtimeTransitionPermit.withPermit( - Effect.gen(function* () { - yield* awaitRuntimeTeardown(); - useProviderThreadIdentity(forkInput.sourceProviderThread); - yield* restartRuntimeAfterTeardownIfRequired( - forkInput.sourceProviderThread.appThreadId, - ); - if (!capabilities.threads.canForkThread) { - return yield* new ProviderAdapter.ProviderAdapterProtocolError({ - driver, - detail: "ACP driver did not negotiate session/fork", - }); - } - if (forkInput.providerTurnId !== undefined) { - return yield* new ProviderAdapter.ProviderAdapterProtocolError({ - driver, - detail: "ACP session/fork can only fork the current session head", - }); - } - const sourceSessionId = yield* nativeThreadId( - driver, - forkInput.sourceProviderThread, - ); - prepareTerminalEnvironment(forkInput.targetThreadId); - const forked = yield* runtime.forkSession( - sourceSessionId, - acpMcpActivation(forkInput.targetThreadId, self), - ); - rememberTerminalEnvironment(forked.sessionId, forkInput.targetThreadId); - yield* Ref.set(activeSessionId, forked.sessionId); - yield* Ref.set(activeSessionSetup, forked); - yield* Ref.set(activeSelection, null); - yield* Ref.set(activeInteractionMode, null); - itemIdentityVersion = 2; - const now = yield* DateTime.now; - const providerThread = makeProviderThread({ - driver, - providerInstanceId: options.instanceId, - idAllocator, - appThreadId: forkInput.targetThreadId, - providerSessionId: input.providerSessionId, - nativeThreadId: forked.sessionId, - itemIdentityVersion: 2, - ...(forkInput.ownerNodeId === undefined - ? {} - : { ownerNodeId: forkInput.ownerNodeId }), - forkedFrom: { - providerThreadId: forkInput.sourceProviderThread.id, - ...(forkInput.providerTurnId === undefined - ? {} - : { providerTurnId: forkInput.providerTurnId }), - }, - now, - }); - yield* Ref.update(providerThreadByNativeSessionId, (current) => - new Map(current).set(forked.sessionId, providerThread), - ); - return providerThread; - }), - ); - }, - (effect, forkInput) => - effect.pipe( - Effect.mapError( - (cause) => - new ProviderAdapter.ProviderAdapterForkThreadError({ - driver, - providerThreadId: forkInput.sourceProviderThread.id, - cause, - }), - ), - ), - ), - }; - return sessionRuntime; - }, - (effect, input) => - effect.pipe( - Effect.mapError( - (cause) => - new ProviderAdapter.ProviderAdapterOpenSessionError({ - driver, - providerSessionId: input.providerSessionId, - cause, - }), - ), - ), - ), - }); -} - -export type AcpAdapterV2Env = FileSystem.FileSystem | IdAllocator.IdAllocatorV2; diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts index 85ae7368ee676e7a4cc50c08ceb20888ddcc6238..fa861a22dde77bfff156b3c6271ad4f8c95a3826 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts @@ -14,15 +14,16 @@ import { HttpClient, HttpClientResponse } from "effect/http"; import { ChildProcessSpawner } from "effect/process"; import * as EffectAcpErrors from "effect-acp/errors"; -import * as ServerConfig from "../../config.ts"; +import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; +import { layerTestProviderHost } from "@t3tools/provider-testing/host"; import * as ServerSettings from "../../serverSettings.ts"; import type { AcpRegistryAvailableCommands, AcpRegistryLiveConfiguration, } from "../../provider/acp/AcpRegistryProbe.ts"; import * as AcpRegistrySupport from "../../provider/acp/AcpRegistrySupport.ts"; -import { ACP_SESSION_MODE_OPTION_ID } from "../../provider/acp/AcpSessionConfig.ts"; -import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts"; +import { ACP_SESSION_MODE_OPTION_ID } from "@t3tools/provider-acp/server/sessionConfig"; +import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { decodeAcpReplayTranscript, @@ -43,9 +44,7 @@ const decodeAcpRegistryAdapterSettings = Schema.decodeUnknownEffect( AcpRegistryAdapterV2Driver.configSchema, ); -const layerServerConfig = ServerConfig.layerTest(process.cwd(), { - prefix: "t3-acp-registry-v2-adapter-", -}).pipe(Layer.provide(NodeServices.layer)); +const layerHost = layerTestProviderHost().pipe(Layer.provide(NodeServices.layer)); const layerRegistry = Layer.succeed( HttpClient.HttpClient, @@ -86,7 +85,7 @@ const layerRegistry = Layer.succeed( const layerTest = Layer.mergeAll( NodeServices.layer, IdAllocator.layer, - layerServerConfig, + layerHost, layerRegistry, ServerSettings.layerTest(), ); @@ -202,7 +201,7 @@ describe("AcpRegistryAdapterV2", () => { fileSystem, idAllocator: yield* IdAllocator.IdAllocatorV2, resolver: { resolve: () => Effect.die("the runtime is injected") }, - serverConfig: yield* ServerConfig.ServerConfig, + host: yield* ProviderHost.ProviderHost, makeRuntime: makeAcpReplayRuntime({ transcript, statusPath, @@ -272,7 +271,7 @@ describe("AcpRegistryAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); @@ -291,7 +290,7 @@ describe("AcpRegistryAdapterV2", () => { fileSystem, idAllocator, resolver: { resolve: () => Effect.die("the runtime is injected") }, - serverConfig, + host, makeRuntime: (input) => Effect.gen(function* () { clientCapabilities = input.clientCapabilities; @@ -348,13 +347,13 @@ describe("AcpRegistryAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ - cacheDir: serverConfig.providerStatusCacheDir, - toolsDir: serverConfig.baseDir + "/tools", + cacheDir: host.paths.providerStatusCacheDir, + toolsDir: host.paths.baseDir + "/tools", registryUrl, }); const settings = yield* decodeAcpRegistryAdapterSettings({ @@ -429,7 +428,7 @@ describe("AcpRegistryAdapterV2", () => { })), ), }, - serverConfig, + host, }); const threadId = ThreadId.make("thread-acp-registry-fixture"); const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.testkit.ts index 7d829741862dbe9cae3d6546b4d608a1dda5a860..9ebe08e40ec10def3343846752b4c451baebcc67 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.testkit.ts @@ -9,12 +9,12 @@ import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { ChildProcessSpawner } from "effect/process"; -import * as ServerConfig from "../../config.ts"; +import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; +import { layerTestProviderHost } from "@t3tools/provider-testing/host"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts"; import type { ProviderReplayGate } from "@t3tools/provider-testing/replayGate"; import type { OrchestratorV2ProviderReplayHarness } from "../testkit/ProviderReplayHarness.ts"; -import { makeReplayServerConfig } from "../testkit/ProviderReplayHarness.ts"; import { type AcpReplayTranscript, AcpReplayTranscriptDecodeError, @@ -37,10 +37,7 @@ function layerAcpRegistryProviderAdapterRegistryReplay( transcript: AcpReplayTranscript, options: { readonly replayGate?: ProviderReplayGate } = {}, ) { - const layerServerConfig = Layer.effect( - ServerConfig.ServerConfig, - makeReplayServerConfig(`acp-registry-${transcript.scenario}`).pipe(Effect.orDie), - ).pipe(Layer.provide(NodeServices.layer)); + const layerHost = layerTestProviderHost().pipe(Layer.provide(NodeServices.layer)); return ProviderAdapterRegistry.layerFromAdaptersEffect( Effect.gen(function* () { @@ -49,7 +46,7 @@ function layerAcpRegistryProviderAdapterRegistryReplay( const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const crypto = yield* Crypto.Crypto; const idAllocator = yield* IdAllocator.IdAllocatorV2; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const replayGate = options.replayGate; const replayDir = yield* fileSystem .makeTempDirectory({ @@ -71,7 +68,7 @@ function layerAcpRegistryProviderAdapterRegistryReplay( resolver: { resolve: () => Effect.die("ACP registry resolver must not run during replay"), }, - serverConfig, + host, selfInvocation: yield* resolveSelfInvocation(), makeRuntime: makeAcpReplayRuntime({ transcript, @@ -86,7 +83,7 @@ function layerAcpRegistryProviderAdapterRegistryReplay( return [adapter]; }), ).pipe( - Layer.provide(Layer.mergeAll(layerServerConfig, NodeServices.layer, IdAllocator.layer)), + Layer.provide(Layer.mergeAll(layerHost, NodeServices.layer, IdAllocator.layer)), // Held inbound lines must not outlive the scenario and wedge teardown. Layer.merge( Layer.effectDiscard( diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.ts index f1a1ef3a583f2795e942deb4b65456bd60c6a5ed..d188f9c9266f6b7eeb0aa2f3c44cfbd435e5436b 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.ts @@ -21,7 +21,7 @@ import type * as Scope from "effect/Scope"; import { ChildProcessSpawner } from "effect/process"; import * as EffectAcpErrors from "effect-acp/errors"; -import * as ServerConfig from "../../config.ts"; +import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import { normalizeAcpRegistryCommands, normalizeAcpRegistryLiveConfiguration, @@ -29,8 +29,8 @@ import { } from "../../provider/acp/AcpRegistryProbe.ts"; import * as AcpRegistrySupport from "../../provider/acp/AcpRegistrySupport.ts"; import * as AcpRegistryRuntimeCoordinator from "../../provider/acp/AcpRegistryRuntimeCoordinator.ts"; -import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts"; -import { makeAcpNativeLoggerFactory } from "../../provider/acp/AcpNativeLogging.ts"; +import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; +import { makeAcpNativeLoggerFactory } from "@t3tools/provider-acp/server/nativeLogging"; import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts"; import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; @@ -46,7 +46,7 @@ import { type AcpAdapterV2ExtensionContext, type AcpAdapterV2Flavor, type AcpAdapterV2RuntimeInput, -} from "./AcpAdapterV2.ts"; +} from "@t3tools/provider-acp/server/adapter"; export const ACP_REGISTRY_PROVIDER = ProviderDriverKind.make("acpRegistry"); export const ACP_REGISTRY_DEFAULT_INSTANCE_ID = defaultInstanceIdForDriver(ACP_REGISTRY_PROVIDER); @@ -65,7 +65,7 @@ export interface AcpRegistryAdapterV2Options { readonly idAllocator: IdAllocator.IdAllocatorV2["Service"]; readonly resolver: Pick; readonly runtimeCoordinator?: AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator["Service"]; - readonly serverConfig: ServerConfig.ServerConfig["Service"]; + readonly host: ProviderHost.ProviderHostShape; readonly nativeLogging?: Parameters[0]["nativeLogging"]; readonly makeRuntime?: ( input: AcpAdapterV2RuntimeInput, @@ -243,7 +243,7 @@ export function makeAcpRegistryAdapterV2(options: AcpRegistryAdapterV2Options) { crypto: options.crypto, fileSystem: options.fileSystem, idAllocator: options.idAllocator, - serverConfig: options.serverConfig, + host: options.host, selfInvocation: options.selfInvocation, // Per-agent exception (see the note above registerMistralVibeAcpExtensions): // Devin runs commands through client terminals and has no ask mode over @@ -269,7 +269,7 @@ export type AcpRegistryAdapterV2DriverEnv = | IdAllocator.IdAllocatorV2 | Path.Path | ProviderEventLoggers.ProviderEventLoggers - | ServerConfig.ServerConfig; + | ProviderHost.ProviderHost; export const AcpRegistryAdapterV2Driver: ProviderAdapterDriver< AcpRegistrySettings, @@ -287,7 +287,7 @@ export const AcpRegistryAdapterV2Driver: ProviderAdapterDriver< const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const providerEventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const makeNativeLogger = yield* makeAcpNativeLoggerFactory(); const resolver = yield* AcpRegistrySupport.AcpRegistryCatalog; const runtimeCoordinator = yield* Effect.serviceOption( @@ -305,7 +305,7 @@ export const AcpRegistryAdapterV2Driver: ProviderAdapterDriver< ...(Option.isSome(runtimeCoordinator) ? { runtimeCoordinator: runtimeCoordinator.value } : {}), - serverConfig, + host, selfInvocation, nativeLogging: (threadId) => makeNativeLogger({ diff --git a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts index ed00f33728b7c99dac368810cf834ce14be45d92..4ce55c783307b3b4ee84563d78c8d90dc51abdb0 100644 --- a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts @@ -22,8 +22,9 @@ import * as Stream from "effect/Stream"; import { ChildProcessSpawner } from "effect/process"; import type * as EffectAcpSchema from "effect-acp/compat"; -import * as ServerConfig from "../../config.ts"; -import type * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts"; +import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; +import { layerTestProviderHost } from "@t3tools/provider-testing/host"; +import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; import { makeAntigravityAcpRuntime } from "../../provider/acp/AntigravityAcpSupport.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { ProviderAdapterV2RuntimePolicy } from "@t3tools/provider-core/server/ProviderAdapter"; @@ -38,7 +39,7 @@ const flavor = makeAntigravityAcpAdapterFlavor({ fileSystem: undefined as never, path: undefined as never, idAllocator: undefined as never, - serverConfig: undefined as never, + host: undefined as never, selfInvocation: undefined as never, makeRuntime: () => Effect.die("not spawned in this test"), withProcess: (_stop, task) => task, @@ -129,9 +130,7 @@ describe("AntigravityAdapterV2 flavor", () => { const layerSession = Layer.mergeAll( NodeServices.layer, IdAllocator.layer, - ServerConfig.layerTest(process.cwd(), { prefix: "t3-antigravity-v2-adapter-" }).pipe( - Layer.provide(NodeServices.layer), - ), + layerTestProviderHost().pipe(Layer.provide(NodeServices.layer)), ); describe("AntigravityAdapterV2 client file system", () => { @@ -140,7 +139,7 @@ describe("AntigravityAdapterV2 client file system", () => { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); @@ -157,7 +156,7 @@ describe("AntigravityAdapterV2 client file system", () => { fileSystem, path, idAllocator: yield* IdAllocator.IdAllocatorV2, - serverConfig, + host, makeRuntime: (input) => makeAntigravityAcpRuntime({ ...input, @@ -193,7 +192,7 @@ describe("AntigravityAdapterV2 client file system", () => { }); const outsideFile = path.join(outside, "secret.txt"); yield* fileSystem.writeFileString(outsideFile, "secret"); - const attachment = path.join(serverConfig.attachmentsDir, "pasted.txt"); + const attachment = path.join(host.paths.attachmentsDir, "pasted.txt"); yield* fileSystem.writeFileString(attachment, "pasted"); const threadId = ThreadId.make("thread-antigravity-containment"); @@ -294,7 +293,7 @@ describe("AntigravityAdapterV2 workspace changes", () => { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const crypto = yield* Crypto.Crypto; const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -309,7 +308,7 @@ describe("AntigravityAdapterV2 workspace changes", () => { fileSystem, path, idAllocator: yield* IdAllocator.IdAllocatorV2, - serverConfig, + host, makeRuntime: (input) => makeAntigravityAcpRuntime({ ...input, @@ -431,7 +430,7 @@ describe("AntigravityAdapterV2 client file system under restrictive policies", ( const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const crypto = yield* Crypto.Crypto; const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -459,7 +458,7 @@ describe("AntigravityAdapterV2 client file system under restrictive policies", ( fileSystem, path, idAllocator: yield* IdAllocator.IdAllocatorV2, - serverConfig, + host, makeRuntime: (input) => makeAntigravityAcpRuntime({ ...input, diff --git a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts index 3d8f8f75091a7a62f500b06dc285ca956a434869..4263618cf32c818b4527a7e76b7ea2909dc7c398 100644 --- a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts @@ -14,9 +14,9 @@ import * as Exit from "effect/Exit"; import * as Scope from "effect/Scope"; import * as EffectAcpErrors from "effect-acp/errors"; -import type { ServerConfig } from "../../config.ts"; +import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import type { AntigravityAuth } from "../../provider/AntigravityAuth.ts"; -import type * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts"; +import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; import { antigravityPermissionMode, applyAntigravityAcpModelSelection, @@ -40,7 +40,7 @@ import { makeAcpAdapterV2, type AcpAdapterV2Flavor, type AcpAdapterV2RuntimeInput, -} from "./AcpAdapterV2.ts"; +} from "@t3tools/provider-acp/server/adapter"; const ANTIGRAVITY_PROVIDER = ProviderDriverKind.make("antigravity"); @@ -68,7 +68,7 @@ export interface AntigravityAdapterV2Options { readonly fileSystem: FileSystem.FileSystem; readonly path: Path.Path; readonly idAllocator: IdAllocatorV2["Service"]; - readonly serverConfig: ServerConfig["Service"]; + readonly host: ProviderHost.ProviderHostShape; /** Spawns the official agent with the instance's Google profile. */ readonly makeRuntime: ( input: Omit, @@ -125,9 +125,7 @@ export function makeAntigravityAcpAdapterFlavor( // the turn text references. It is a leaf directory of uploads. A session // without a workspace gets no workspace root rather than the server's cwd. const antigravityClientFileRoots = (cwd: string | null) => - cwd === null - ? [options.serverConfig.attachmentsDir] - : [cwd, options.serverConfig.attachmentsDir]; + cwd === null ? [options.host.paths.attachmentsDir] : [cwd, options.host.paths.attachmentsDir]; const makeRuntime = (input: AcpAdapterV2RuntimeInput) => Effect.gen(function* () { // AcpAdapterV2 owns the runtime scope; sign-in and sign-out stop the @@ -139,7 +137,7 @@ export function makeAntigravityAcpAdapterFlavor( options.makeRuntime({ ...input, clientFileSystem: true, - additionalDirectories: [options.serverConfig.attachmentsDir], + additionalDirectories: [options.host.paths.attachmentsDir], }), ) .pipe(Effect.provideService(Scope.Scope, scope)); @@ -233,7 +231,7 @@ export function makeAntigravityAdapterV2(options: AntigravityAdapterV2Options) { crypto: options.crypto, fileSystem: options.fileSystem, idAllocator: options.idAllocator, - serverConfig: options.serverConfig, + host: options.host, selfInvocation: options.selfInvocation, ...(options.nativeLogging === undefined ? {} : { nativeLogging: options.nativeLogging }), ...(options.continuationRequests === undefined diff --git a/apps/server/src/orchestration-v2/Adapters/DevinAcp.ts b/apps/server/src/orchestration-v2/Adapters/DevinAcp.ts index 23c3b896900ca3d1697198bcb69dcff14b9992e7..6f78d2506adc2539b0291be3de4f9fa384c73471 100644 --- a/apps/server/src/orchestration-v2/Adapters/DevinAcp.ts +++ b/apps/server/src/orchestration-v2/Adapters/DevinAcp.ts @@ -1,6 +1,6 @@ import type * as AcpSchema from "effect-acp/compat"; -import type { AcpToolCallState } from "../../provider/acp/AcpRuntimeModel.ts"; -import type { AcpAdapterV2SubagentUpdate } from "./AcpAdapterV2.ts"; +import type { AcpToolCallState } from "@t3tools/provider-acp/server/runtimeModel"; +import type { AcpAdapterV2SubagentUpdate } from "@t3tools/provider-acp/server/adapter"; function record(value: unknown): Record | undefined { return typeof value === "object" && value !== null && !Array.isArray(value) diff --git a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts index 4284de473a17d22bf8f40f395c92a7550af58e0f..23ebb1b4f2a13258fca847fc126535595a4c760e 100644 --- a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts @@ -22,7 +22,8 @@ import * as Schema from "effect/Schema"; import { ChildProcessSpawner } from "effect/process"; import type * as EffectAcpSchema from "effect-acp/compat"; -import * as ServerConfig from "../../config.ts"; +import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; +import { layerTestProviderHost } from "@t3tools/provider-testing/host"; import * as ProjectStore from "../ProjectStore.ts"; import { buildInitialGrokProviderSnapshot } from "../../provider/GrokProvider.ts"; import type { ProviderInstance } from "@t3tools/provider-core/server/driver"; @@ -30,13 +31,13 @@ import * as ProviderInstanceRegistry from "../../provider/ProviderInstanceRegist import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { ProviderAdapterV2RuntimePolicy } from "@t3tools/provider-core/server/ProviderAdapter"; import * as RuntimePolicy from "../RuntimePolicy.ts"; -import { acpPermissionDisposition } from "../../provider/acp/AcpClientPolicy.ts"; +import { acpPermissionDisposition } from "@t3tools/provider-acp/server/clientPolicy"; import { AcpProviderCapabilitiesV2, acpCompletedTurnShouldTerminalizeTool, acpSubagentStatusBlocksTurnSettlement, acpSupportsImagePrompts, -} from "./AcpAdapterV2.ts"; +} from "@t3tools/provider-acp/server/adapter"; import { makeGrokAcpAdapterFlavor, makeGrokAdapterV2, @@ -311,10 +312,8 @@ describe("Grok permission prompts", () => { }); describe("Grok launch permission mode", () => { - const layerServerConfig = ServerConfig.layerTest(process.cwd(), { - prefix: "t3-grok-v2-launch-", - }).pipe(Layer.provide(NodeServices.layer)); - const layerTest = Layer.mergeAll(NodeServices.layer, IdAllocator.layer, layerServerConfig); + const layerHost = layerTestProviderHost().pipe(Layer.provide(NodeServices.layer)); + const layerTest = Layer.mergeAll(NodeServices.layer, IdAllocator.layer, layerHost); // Opens a session through the adapter's own Grok runtime factory and returns // the argv it tried to launch. The spawn fails after recording, so no @@ -342,7 +341,7 @@ describe("Grok launch permission mode", () => { crypto: yield* Crypto.Crypto, fileSystem: yield* FileSystem.FileSystem, idAllocator: yield* IdAllocator.IdAllocatorV2, - serverConfig: yield* ServerConfig.ServerConfig, + host: yield* ProviderHost.ProviderHost, selfInvocation: yield* resolveSelfInvocation(), }); yield* adapter diff --git a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.testkit.ts index b770efd77136a53db520f47e731f183ba1e8e46c..58446793decd9bd221b7ae4adc27415024a0a352 100644 --- a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.testkit.ts @@ -10,7 +10,8 @@ import { ChildProcessSpawner } from "effect/process"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime"; -import * as ServerConfig from "../../config.ts"; +import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; +import { layerTestProviderHost } from "@t3tools/provider-testing/host"; import { GROK_ACP_CANCEL_META, GROK_ACP_INITIALIZE_META, @@ -21,7 +22,6 @@ import * as ProviderContinuationRequests from "@t3tools/provider-core/server/con import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts"; import type { ProviderReplayGate } from "@t3tools/provider-testing/replayGate"; import type { OrchestratorV2ProviderReplayHarness } from "../testkit/ProviderReplayHarness.ts"; -import { makeReplayServerConfig } from "../testkit/ProviderReplayHarness.ts"; import { type AcpReplayTranscript, AcpReplayTranscriptDecodeError, @@ -37,10 +37,7 @@ function layerGrokProviderAdapterRegistryReplay( transcript: AcpReplayTranscript, options: { readonly replayGate?: ProviderReplayGate } = {}, ) { - const layerServerConfig = Layer.effect( - ServerConfig.ServerConfig, - makeReplayServerConfig(`grok-${transcript.scenario}`).pipe(Effect.orDie), - ).pipe(Layer.provide(NodeServices.layer)); + const layerHost = layerTestProviderHost().pipe(Layer.provide(NodeServices.layer)); return ProviderAdapterRegistry.layerFromAdaptersEffect( Effect.gen(function* () { @@ -50,7 +47,7 @@ function layerGrokProviderAdapterRegistryReplay( const crypto = yield* Crypto.Crypto; const hostPlatform = yield* HostProcessPlatform; const idAllocator = yield* IdAllocator.IdAllocatorV2; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; // Same queue the continuation worker drains when the fixture runs it. const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests; const replayGate = options.replayGate; @@ -72,7 +69,7 @@ function layerGrokProviderAdapterRegistryReplay( crypto, fileSystem, idAllocator, - serverConfig, + host, selfInvocation: yield* resolveSelfInvocation(), // Same wrapping as makeGrokAcpRuntime: client type and Ctrl+C cancel // metadata and the x.ai prompt-completion race, so replay sends what @@ -102,7 +99,7 @@ function layerGrokProviderAdapterRegistryReplay( return [adapter]; }), ).pipe( - Layer.provide(Layer.mergeAll(layerServerConfig, NodeServices.layer, IdAllocator.layer)), + Layer.provide(Layer.mergeAll(layerHost, NodeServices.layer, IdAllocator.layer)), // Held inbound lines must not outlive the scenario and wedge teardown. Layer.merge( Layer.effectDiscard( diff --git a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts index de3c77a696ace12b923ed55e82d481c48c9ae670..e44c2da421a9e39b29d164922be16b033c293180 100644 --- a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts @@ -23,8 +23,8 @@ import type * as Scope from "effect/Scope"; import { ChildProcessSpawner } from "effect/process"; import * as EffectAcpErrors from "effect-acp/errors"; -import * as ServerConfig from "../../config.ts"; -import { makeAcpNativeLoggerFactory } from "../../provider/acp/AcpNativeLogging.ts"; +import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; +import { makeAcpNativeLoggerFactory } from "@t3tools/provider-acp/server/nativeLogging"; import { applyGrokAcpModelSelection, currentGrokModelIdFromSessionSetup, @@ -54,8 +54,8 @@ import { XAiExitPlanModeRequest, } from "../../provider/acp/XAiAcpExtension.ts"; import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; -import { acpPermissionDisposition } from "../../provider/acp/AcpClientPolicy.ts"; -import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts"; +import { acpPermissionDisposition } from "@t3tools/provider-acp/server/clientPolicy"; +import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; @@ -71,7 +71,7 @@ import { type AcpAdapterV2ExtensionContext, type AcpAdapterV2Flavor, type AcpAdapterV2RuntimeInput, -} from "./AcpAdapterV2.ts"; +} from "@t3tools/provider-acp/server/adapter"; export const GROK_PROVIDER = ProviderDriverKind.make("grok"); const GROK_DRIVER_KIND = GROK_PROVIDER; @@ -117,7 +117,7 @@ export interface GrokAdapterV2Options { readonly selfInvocation: SelfInvocation; readonly fileSystem: FileSystem.FileSystem; readonly idAllocator: IdAllocator.IdAllocatorV2["Service"]; - readonly serverConfig: ServerConfig.ServerConfig["Service"]; + readonly host: ProviderHost.ProviderHostShape; readonly nativeLogging?: Parameters[0]["nativeLogging"]; readonly continuationRequests?: Parameters[0]["continuationRequests"]; readonly testHooks?: Parameters[0]["testHooks"]; @@ -337,7 +337,7 @@ export function makeGrokAdapterV2(options: GrokAdapterV2Options) { crypto: options.crypto, fileSystem: options.fileSystem, idAllocator: options.idAllocator, - serverConfig: options.serverConfig, + host: options.host, selfInvocation: options.selfInvocation, ...(options.nativeLogging === undefined ? {} : { nativeLogging: options.nativeLogging }), ...(options.continuationRequests === undefined @@ -354,7 +354,7 @@ export type GrokAdapterV2DriverEnv = | IdAllocator.IdAllocatorV2 | Path.Path | ProviderEventLoggers.ProviderEventLoggers - | ServerConfig.ServerConfig; + | ProviderHost.ProviderHost; export const GrokAdapterV2Driver: ProviderAdapterDriver = { driverKind: GROK_DRIVER_KIND, @@ -370,7 +370,7 @@ export const GrokAdapterV2Driver: ProviderAdapterDriver @@ -417,7 +417,7 @@ const layer: Layer.Layer< | FileSystem.FileSystem | IdAllocator.IdAllocatorV2 | ProviderEventLoggers.ProviderEventLoggers - | ServerConfig.ServerConfig + | ProviderHost.ProviderHost > = Layer.effect( ProviderAdapter.ProviderAdapterV2, Effect.gen(function* () { @@ -429,7 +429,7 @@ const layer: Layer.Layer< const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const providerEventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests; const makeNativeLogger = yield* makeAcpNativeLoggerFactory(); return makeGrokAdapterV2({ @@ -441,7 +441,7 @@ const layer: Layer.Layer< crypto, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, continuationRequests, nativeLogging: (threadId) => diff --git a/apps/server/src/orchestration-v2/ProviderSelectionTransition.test.ts b/apps/server/src/orchestration-v2/ProviderSelectionTransition.test.ts index 4a6d6a6a91d12ddde755bbd3052f7dcb0f23423c..adc4ae1543a47689b440c0e3145e3df882eb9579 100644 --- a/apps/server/src/orchestration-v2/ProviderSelectionTransition.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSelectionTransition.test.ts @@ -6,7 +6,7 @@ import { ProviderInstanceId, } from "@t3tools/contracts"; -import { AcpProviderCapabilitiesV2 } from "./Adapters/AcpAdapterV2.ts"; +import { AcpProviderCapabilitiesV2 } from "@t3tools/provider-acp/server/adapter"; import { acpSelectionTransition } from "@t3tools/provider-core/server/selectionTransition"; const selection = (model: string, effort = "medium"): ModelSelection => ({ diff --git a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts index b71043aabc4e62a0bcb56f02b6c06384a5623c38..e7633781036bf9735d2ec174a1a75f9767f90527 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts @@ -42,7 +42,7 @@ import { CodexProviderCapabilitiesV2, canReuseCodexContextUsage, } from "../Adapters/CodexAdapterV2.ts"; -import { AcpProviderCapabilitiesV2 } from "../Adapters/AcpAdapterV2.ts"; +import { AcpProviderCapabilitiesV2 } from "@t3tools/provider-acp/server/adapter"; import { CursorProviderCapabilitiesV2 } from "@t3tools/provider-cursor/testing"; import * as EventSink from "../EventSink.ts"; import * as EventStore from "../EventStore.ts"; diff --git a/apps/server/src/provider/AntigravityAuth.test.ts b/apps/server/src/provider/AntigravityAuth.test.ts index 39deaa4ecd39b72e0664ba548d5cd007c9d30276..46bc2aeec2800d92eafaac6691b6322e162b7c87 100644 --- a/apps/server/src/provider/AntigravityAuth.test.ts +++ b/apps/server/src/provider/AntigravityAuth.test.ts @@ -17,7 +17,7 @@ import { type AntigravityAuth, type AntigravityAuthRuntime, } from "./AntigravityAuth.ts"; -import type { AcpSessionRuntimeStartResult } from "./acp/AcpSessionRuntime.ts"; +import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; const instanceId = ProviderInstanceId.make("antigravity-auth-test"); const owner = "t3-auth-session-owner"; @@ -31,7 +31,7 @@ const initialized = { authMethods: [{ id: "oauth-personal", name: "Log in with Google" }], agentCapabilities: { auth: { logout: {} } }, } satisfies AcpSchema.InitializeResponse; -const started: AcpSessionRuntimeStartResult = { +const started: AcpSessionRuntime.AcpSessionRuntimeStartResult = { sessionId: "native-session", initializeResult: initialized, sessionSetupResult: { diff --git a/apps/server/src/provider/AntigravityAuth.ts b/apps/server/src/provider/AntigravityAuth.ts index d25ac40f32e751841500f63244ce74a2b7b3a9fe..d711abe7c1dc5118e9fd0dbf170eade9bfa0d4ee 100644 --- a/apps/server/src/provider/AntigravityAuth.ts +++ b/apps/server/src/provider/AntigravityAuth.ts @@ -19,7 +19,7 @@ import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; import * as AcpErrors from "effect-acp/errors"; -import type { AcpSessionRuntime, AcpSessionRuntimeStartResult } from "./acp/AcpSessionRuntime.ts"; +import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; import { parseAntigravityAuthorizationUrl, type AntigravityAuthorizationUrl, @@ -66,19 +66,19 @@ export interface AntigravityAuth { } export type AntigravityAuthRuntime = Pick< - AcpSessionRuntime["Service"], + AcpSessionRuntime.AcpSessionRuntime["Service"], "initialize" | "start" | "request" >; export interface AntigravityAuthOptions< - Runtime extends AntigravityAuthRuntime = AcpSessionRuntime["Service"], + Runtime extends AntigravityAuthRuntime = AcpSessionRuntime.AcpSessionRuntime["Service"], > { readonly instanceId: ProviderInstanceId; readonly makeRuntime: (input: { readonly onAuthorizationUrl?: (url: string) => Effect.Effect; }) => Effect.Effect; readonly onAuthenticated: ( - result: AcpSessionRuntimeStartResult, + result: AcpSessionRuntime.AcpSessionRuntimeStartResult, runtime: Runtime, ) => Effect.Effect; readonly onSignedOut: Effect.Effect; diff --git a/apps/server/src/provider/AntigravityProvider.test.ts b/apps/server/src/provider/AntigravityProvider.test.ts index 69502047f73dcf5e6428290132800fcfec7fb1f0..f1728108dfae619e1a1ffb9ca4551db87fa7108d 100644 --- a/apps/server/src/provider/AntigravityProvider.test.ts +++ b/apps/server/src/provider/AntigravityProvider.test.ts @@ -23,7 +23,7 @@ import * as ServerConfig from "../config.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as ProviderHostLive from "./ProviderHostLive.ts"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; -import type { AcpSessionRuntimeStartResult } from "./acp/AcpSessionRuntime.ts"; +import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; import { buildAntigravityModelsFromSession, makeAntigravityProvider, @@ -98,7 +98,7 @@ const started = { initializeResult, sessionSetupResult, modelConfigId: "model", -} satisfies AcpSessionRuntimeStartResult; +} satisfies AcpSessionRuntime.AcpSessionRuntimeStartResult; const commands = [ { name: "plan", description: "Create a plan", input: { type: "text", hint: "What to plan" } }, diff --git a/apps/server/src/provider/AntigravityProvider.ts b/apps/server/src/provider/AntigravityProvider.ts index 143b6522bd469d2f5b4700f5479e8f1d6a505c4f..ce4663a9be416e7a3a96109403637b23c479fd2c 100644 --- a/apps/server/src/provider/AntigravityProvider.ts +++ b/apps/server/src/provider/AntigravityProvider.ts @@ -18,7 +18,7 @@ import * as SubscriptionRef from "effect/SubscriptionRef"; import type * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpSchema from "effect-acp/compat"; -import type { AcpSessionRuntimeStartResult } from "./acp/AcpSessionRuntime.ts"; +import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; import { makeManualOnlyProviderMaintenanceCapabilities, @@ -38,7 +38,7 @@ const AUTH_UNCHECKED_MESSAGE = "Antigravity is installed. Google account access is not checked yet."; type SessionSetupResult = Pick< - AcpSessionRuntimeStartResult["sessionSetupResult"], + AcpSessionRuntime.AcpSessionRuntimeStartResult["sessionSetupResult"], "configOptions" | "models" >; @@ -256,7 +256,7 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func }); const onSessionStarted = Effect.fn("AntigravityProvider.onSessionStarted")(function* ( - started: AcpSessionRuntimeStartResult, + started: AcpSessionRuntime.AcpSessionRuntimeStartResult, cwd?: string, ) { const before = yield* SubscriptionRef.get(metadata); diff --git a/apps/server/src/provider/Drivers/AcpRegistryDriver.ts b/apps/server/src/provider/Drivers/AcpRegistryDriver.ts index bdd5c0449a2c6961719ebd9b872fbe5a5641a915..be570db9160c6df995d7a57398ae6e68caadebf4 100644 --- a/apps/server/src/provider/Drivers/AcpRegistryDriver.ts +++ b/apps/server/src/provider/Drivers/AcpRegistryDriver.ts @@ -22,7 +22,6 @@ import * as Semaphore from "effect/Semaphore"; import { ChildProcessSpawner } from "effect/process"; import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost"; -import * as ServerConfig from "../../config.ts"; import { AcpRegistryAdapterV2Driver, type AcpRegistryAdapterV2DriverEnv, @@ -489,7 +488,6 @@ export const AcpRegistryDriver: ProviderDriver confirmedAuthentication diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.ts b/apps/server/src/provider/Drivers/AntigravityDriver.ts index f354dc4b4aa74c17b6ff6a7026812ba0c52fe482..fa075fa28dfc44b84dbd8833ff270c098dc12af5 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.ts @@ -19,7 +19,6 @@ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import type { AcpError } from "effect-acp/errors"; import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost"; -import * as ServerConfig from "../../config.ts"; import { isAntigravityTextGenerationAvailable, makeAntigravityTextGeneration, @@ -40,7 +39,7 @@ import { makeAntigravityAcpRuntime, type AntigravityAcpRuntimeInput, } from "../acp/AntigravityAcpSupport.ts"; -import type { AcpSessionRuntime, AcpSessionRuntimeStartResult } from "../acp/AcpSessionRuntime.ts"; +import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; import type { ServerProviderDraft } from "@t3tools/provider-core/server/snapshotProbe"; import { removeAntigravityRuntimeTempDirs, @@ -49,7 +48,7 @@ import { import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; import { makeAntigravityAdapterV2 } from "../../orchestration-v2/Adapters/AntigravityAdapterV2.ts"; -import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts"; +import { makeAcpNativeLoggerFactory } from "@t3tools/provider-acp/server/nativeLogging"; import { ProviderDriverError } from "../Errors.ts"; import { makeAntigravityProvider } from "../AntigravityProvider.ts"; import * as ProviderEventLoggers from "../ProviderEventLoggers.ts"; @@ -76,8 +75,7 @@ export type AntigravityDriverEnv = | IdAllocator.IdAllocatorV2 | ModelManifest.ModelManifest | Path.Path - | ProviderEventLoggers.ProviderEventLoggers - | ServerConfig.ServerConfig; + | ProviderEventLoggers.ProviderEventLoggers; /** Each instance owns its Google profile. Executable releases are shared by the environment. */ export const AntigravityDriver: ProviderDriver = { @@ -91,7 +89,7 @@ export const AntigravityDriver: ProviderDriver, ): Effect.fn.Return< - AcpSessionRuntime["Service"], + AcpSessionRuntime.AcpSessionRuntime["Service"], AcpError | ProviderSetupError, Scope.Scope > { @@ -303,8 +301,8 @@ export const AntigravityDriver: ProviderDriver, + started: AcpSessionRuntime.AcpSessionRuntimeStartResult, + runtime: Pick, ): Effect.Effect => Effect.gen(function* () { yield* provider.onSessionStarted(started); @@ -405,7 +403,7 @@ export const AntigravityDriver: ProviderDriver, - locations?: ReadonlyArray, -): EffectAcpSchema.RequestPermissionRequest { - return { - options: [], - sessionId: "permission-session", - toolCall: { - kind, - ...(locations === undefined ? {} : { locations }), - toolCallId: "permission-tool-call", - }, - }; -} - -describe("acpPermissionDisposition", () => { - const cwd = NodePath.resolve(process.cwd(), "acp-permission-workspace"); - const writableRoot = NodePath.resolve(process.cwd(), "acp-additional-writable-root"); - const policy: AcpRuntimePolicy = { - runtimeMode: "full-access", - cwd, - approvalPolicy: "never", - sandboxPolicy: { - type: "workspaceWrite", - writableRoots: [writableRoot], - networkAccess: false, - }, - }; - - it("auto-allows mutations only when every location is in cwd or an additional writable root", () => { - assert.equal( - acpPermissionDisposition(policy, permissionRequest("edit", [{ path: "src/index.ts" }])), - "allow", - ); - assert.equal( - acpPermissionDisposition( - policy, - permissionRequest("delete", [{ path: NodePath.join(writableRoot, "generated.ts") }]), - ), - "allow", - ); - assert.equal( - acpPermissionDisposition( - policy, - permissionRequest("move", [ - { path: NodePath.join(cwd, "from.ts") }, - { path: NodePath.join(writableRoot, "to.ts") }, - ]), - ), - "allow", - ); - }); - - it("denies missing or out-of-root mutation locations", () => { - const outside = NodePath.resolve(process.cwd(), "outside-acp-permission-workspace", "file.ts"); - assert.equal(acpPermissionDisposition(policy, permissionRequest("edit")), "deny"); - assert.equal( - acpPermissionDisposition(policy, permissionRequest("delete", [{ path: "../escape.ts" }])), - "deny", - ); - assert.equal( - acpPermissionDisposition( - policy, - permissionRequest("move", [{ path: NodePath.join(cwd, "inside.ts") }, { path: outside }]), - ), - "deny", - ); - }); - - it("keeps non-mutating workspace permissions and denials unchanged", () => { - assert.equal(acpPermissionDisposition(policy, permissionRequest("read")), "allow"); - assert.equal(acpPermissionDisposition(policy, permissionRequest("execute")), "deny"); - }); - - it("auto-accept-edits approves file changes without locations and asks for the rest", () => { - // ACP permission requests need not carry locations. - const autoAcceptEdits: AcpRuntimePolicy = { runtimeMode: "auto-accept-edits", cwd }; - for (const kind of ["edit", "delete", "move"] as const) { - assert.equal(acpPermissionDisposition(autoAcceptEdits, permissionRequest(kind)), "allow"); - } - for (const kind of ["execute", "fetch", "other"] as const) { - assert.equal(acpPermissionDisposition(autoAcceptEdits, permissionRequest(kind)), "ask"); - } - assert.equal(acpPermissionDisposition(autoAcceptEdits, permissionRequest("read")), "allow"); - assert.equal( - acpPermissionDisposition( - { ...autoAcceptEdits, approvalPolicy: "on-request" }, - permissionRequest("edit"), - ), - "ask", - ); - }); - - it("auto-allows read-kind permission requests under on-request approval", () => { - for (const runtimePolicy of [ - { ...policy, approvalPolicy: "on-request" }, - { ...policy, approvalPolicy: "on-request", sandboxPolicy: { type: "readOnly" } }, - { runtimeMode: "approval-required", cwd }, - ] satisfies ReadonlyArray) { - for (const kind of ["read", "search", "think"] as const) { - assert.equal(acpPermissionDisposition(runtimePolicy, permissionRequest(kind)), "allow"); - } - for (const kind of ["edit", "delete", "move", "execute", "fetch", "other"] as const) { - assert.equal(acpPermissionDisposition(runtimePolicy, permissionRequest(kind)), "ask"); - } - } - }); - - it.effect("denies mutations through workspace symlinks that escape the writable roots", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const workspace = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-acp-permission-workspace-", - }); - const outside = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-acp-permission-outside-", - }); - const outsideFile = path.join(outside, "existing.ts"); - yield* fileSystem.writeFileString(outsideFile, "outside"); - yield* fileSystem.symlink(outsideFile, path.join(workspace, "linked-file.ts")); - yield* fileSystem.symlink(outside, path.join(workspace, "linked-directory")); - - const realPolicy: AcpRuntimePolicy = { - runtimeMode: "full-access", - cwd: workspace, - approvalPolicy: "never", - sandboxPolicy: { - type: "workspaceWrite", - writableRoots: [], - networkAccess: false, - }, - }; - - assert.equal( - acpPermissionDisposition( - realPolicy, - permissionRequest("edit", [{ path: "linked-file.ts" }]), - ), - "deny", - ); - assert.equal( - acpPermissionDisposition( - realPolicy, - permissionRequest("edit", [{ path: "linked-directory/new-file.ts" }]), - ), - "deny", - "a missing leaf below an escaping directory symlink must not be auto-approved", - ); - assert.equal( - acpPermissionDisposition( - realPolicy, - permissionRequest("edit", [{ path: "linked-directory/../escaped-file.ts" }]), - ), - "deny", - "physical symlink traversal must be resolved before parent segments", - ); - }).pipe(Effect.provide(NodeServices.layer)), - ); - - it.effect("allows existing and new files beneath canonical writable roots", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const workspace = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-acp-permission-workspace-", - }); - const workspaceLinkParent = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-acp-permission-link-parent-", - }); - const workspaceLink = path.join(workspaceLinkParent, "workspace-link"); - yield* fileSystem.makeDirectory(path.join(workspace, "src"), { recursive: true }); - yield* fileSystem.writeFileString(path.join(workspace, "src", "existing.ts"), "existing"); - yield* fileSystem.symlink(workspace, workspaceLink); - - const realPolicy: AcpRuntimePolicy = { - runtimeMode: "full-access", - cwd: workspaceLink, - approvalPolicy: "never", - sandboxPolicy: { - type: "workspaceWrite", - writableRoots: [], - networkAccess: false, - }, - }; - - assert.equal( - acpPermissionDisposition( - realPolicy, - permissionRequest("edit", [{ path: "src/existing.ts" }]), - ), - "allow", - ); - assert.equal( - acpPermissionDisposition( - realPolicy, - permissionRequest("edit", [{ path: "src/generated/new-file.ts" }]), - ), - "allow", - "non-existent descendants of a real in-root ancestor remain writable", - ); - }).pipe(Effect.provide(NodeServices.layer)), - ); -}); - -describe("acpMcpToolApprovalElicitationDisposition", () => { - it("applies runtime policy only to explicitly tagged MCP approval elicitations", () => { - const fullAccess: AcpRuntimePolicy = { - runtimeMode: "full-access", - cwd: process.cwd(), - }; - const approvalRequired: AcpRuntimePolicy = { - runtimeMode: "approval-required", - cwd: process.cwd(), - }; - const tagged = { - sessionId: "session-1", - message: "Approve this request?", - mode: "form", - requestedSchema: { type: "object", properties: {} }, - _meta: { codex_approval_kind: "mcp_tool_call" }, - } satisfies EffectAcpSchema.CreateElicitationRequest; - - assert.equal(acpMcpToolApprovalElicitationDisposition(fullAccess, tagged), "allow"); - assert.equal(acpMcpToolApprovalElicitationDisposition(approvalRequired, tagged), "ask"); - assert.equal( - acpMcpToolApprovalElicitationDisposition( - { ...fullAccess, approvalPolicy: "on-request" }, - tagged, - ), - "ask", - ); - assert.equal( - acpMcpToolApprovalElicitationDisposition( - { - runtimeMode: "auto-accept-edits", - cwd: process.cwd(), - approvalPolicy: "never", - sandboxPolicy: { - type: "workspaceWrite", - writableRoots: [], - networkAccess: false, - }, - }, - tagged, - ), - "allow", - ); - const { _meta: _tag, ...untagged } = tagged; - assert.equal( - acpMcpToolApprovalElicitationDisposition( - fullAccess, - untagged, - "mcp_tool_call_approval_exec-123", - ), - "allow", - ); - assert.isUndefined( - acpMcpToolApprovalElicitationDisposition(fullAccess, { - ...tagged, - _meta: { codex_approval_kind: "ordinary_form" }, - }), - ); - assert.isUndefined( - acpMcpToolApprovalElicitationDisposition(fullAccess, { - sessionId: "session-1", - message: "Authenticate", - mode: "url", - elicitationId: "elicitation-1", - url: "https://example.com/login", - _meta: { codex_approval_kind: "mcp_tool_call" }, - }), - ); - }); -}); - -describe("client terminal disposition", () => { - const cwd = NodePath.resolve(process.cwd(), "acp-client-policy-workspace"); - - it("asks in approval-required and auto-accept-edits, allows in auto and full access", () => { - assert.equal(acpClientExecuteDisposition({ runtimeMode: "approval-required", cwd }), "ask"); - assert.equal(acpClientExecuteDisposition({ runtimeMode: "auto-accept-edits", cwd }), "ask"); - assert.equal(acpClientExecuteDisposition({ runtimeMode: "auto", cwd }), "allow"); - assert.equal(acpClientExecuteDisposition({ runtimeMode: "full-access", cwd }), "allow"); - }); - - it("denies terminals under explicit read-only and workspace-write sandboxes", () => { - for (const sandboxPolicy of [ - { type: "readOnly" }, - { type: "workspaceWrite", writableRoots: [], networkAccess: false }, - ]) { - assert.equal( - acpClientExecuteDisposition({ - runtimeMode: "full-access", - cwd, - approvalPolicy: "never", - sandboxPolicy, - }), - "deny", - ); - } - }); -}); - -describe("makeAcpClientPolicyGrants", () => { - it("grants terminals only from commands, never from an approved read or edit", () => { - const grants = makeAcpClientPolicyGrants(); - grants.recordApproval({ kind: "file-read", scope: "turn", turnKey: "turn-1" }); - grants.recordApproval({ kind: "file-change", scope: "session", turnKey: "turn-1" }); - assert.isFalse(grants.allowsExecute("turn-1")); - grants.recordApproval({ kind: "command", scope: "turn", turnKey: "turn-1" }); - assert.isTrue(grants.allowsExecute("turn-1")); - assert.isFalse(grants.allowsExecute("turn-2")); - assert.isFalse(grants.allowsExecute(null)); - }); - - it("keeps accept-for-session command grants across turns", () => { - const grants = makeAcpClientPolicyGrants(); - grants.recordApproval({ kind: "command", scope: "session", turnKey: "turn-1" }); - assert.isTrue(grants.allowsExecute("turn-9")); - assert.isTrue(grants.allowsExecute(null)); - }); - - it("drops a turn's command grant when a later turn approves one", () => { - const grants = makeAcpClientPolicyGrants(); - grants.recordApproval({ kind: "command", scope: "turn", turnKey: "turn-1" }); - grants.recordApproval({ kind: "command", scope: "turn", turnKey: "turn-2" }); - assert.isFalse(grants.allowsExecute("turn-1")); - assert.isTrue(grants.allowsExecute("turn-2")); - }); -}); diff --git a/apps/server/src/provider/acp/AcpClientPolicy.ts b/apps/server/src/provider/acp/AcpClientPolicy.ts deleted file mode 100644 index 5a0d9355a1c11d852a230a025d46ab6afb29867e..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/acp/AcpClientPolicy.ts +++ /dev/null @@ -1,297 +0,0 @@ -// @effect-diagnostics nodeBuiltinImport:off -import type { ProviderRequestKind, RuntimeMode } from "@t3tools/contracts"; -import type * as EffectAcpSchema from "effect-acp/compat"; -import * as NodeFS from "node:fs"; -import * as NodePath from "node:path"; - -/** - * Runtime-policy decisions for ACP work that T3 mediates. - * - * ACP agents run their own tools under their own permission model and ask - * through `session/request_permission`, which T3 answers by policy. The one - * client-mediated path left is Devin's `terminal/*`, which runs with the T3 - * server's own privileges; it resolves through {@link acpOperationDisposition} - * so a client terminal can never do more than an execute permission request. - */ - -/** Structural subset of the adapter runtime policy that decisions read. */ -export interface AcpRuntimePolicy { - readonly runtimeMode: RuntimeMode; - readonly cwd: string | null; - readonly approvalPolicy?: unknown; - readonly sandboxPolicy?: unknown; -} - -export type AcpPermissionDisposition = "allow" | "ask" | "deny"; - -export function unknownRecord(value: unknown): Record | undefined { - return typeof value === "object" && value !== null && !Array.isArray(value) - ? (value as Record) - : undefined; -} - -function resolveAcpPermissionPath(path: string, cwd: string | null): string | undefined { - const trimmed = path.trim(); - if (trimmed.length === 0) return undefined; - if (NodePath.isAbsolute(trimmed)) return trimmed; - if (cwd === null || cwd.trim().length === 0) return undefined; - return `${cwd}${cwd.endsWith(NodePath.sep) ? "" : NodePath.sep}${trimmed}`; -} - -function acpPathIsWithinRoot(path: string, root: string): boolean { - const relative = NodePath.relative(root, path); - return ( - relative === "" || - (relative !== ".." && - !relative.startsWith(`..${NodePath.sep}`) && - !NodePath.isAbsolute(relative)) - ); -} - -/** - * Canonicalize a path for an authorization containment check. - * - * `realpath` cannot resolve a file that has not been created yet, so walk up - * to the deepest existing ancestor and append the missing suffix to that - * ancestor's canonical path. This follows symlinked directories while still - * allowing normal writes to new files. If an existing entry cannot be - * canonicalized (for example, a broken symlink), fail closed. - */ -function acpCanonicalPathForContainment(path: string): string | undefined { - // Do not lexically normalize before realpath. For a path such as - // `workspace/link/../file`, the kernel resolves `link` before `..`; an - // eager NodePath.resolve would erase that symlink traversal and could turn - // an outside target into an apparently in-workspace path. - let candidate = path; - const missingSuffix: Array = []; - - while (true) { - try { - return NodePath.resolve(NodeFS.realpathSync.native(candidate), ...missingSuffix); - } catch (cause) { - if ((cause as NodeJS.ErrnoException | undefined)?.code !== "ENOENT") { - return undefined; - } - } - - try { - NodeFS.lstatSync(candidate); - // The entry exists but realpath could not resolve it, as with a broken - // symlink. Treat it as untrusted rather than authorizing its lexical path. - return undefined; - } catch (cause) { - if ((cause as NodeJS.ErrnoException | undefined)?.code !== "ENOENT") { - return undefined; - } - } - - const parent = NodePath.dirname(candidate); - if (parent === candidate) return undefined; - missingSuffix.unshift(NodePath.basename(candidate)); - candidate = parent; - } -} - -/** The parts of a native operation the disposition logic reads. */ -interface AcpPolicyOperation { - readonly kind: string | null | undefined; - readonly locations: ReadonlyArray<{ readonly path: string }> | null | undefined; -} - -function acpPolicyRequiresApproval(runtimePolicy: AcpRuntimePolicy): boolean { - return runtimePolicy.approvalPolicy === undefined - ? runtimePolicy.runtimeMode === "approval-required" - : runtimePolicy.approvalPolicy !== "never"; -} - -function isAcpReadKind(toolKind: string): boolean { - return toolKind === "read" || toolKind === "search" || toolKind === "think"; -} - -/** - * Reads follow the sandbox alone and never ask. Approval policy governs writes - * and commands, as it does for Codex and Claude, and every sandbox T3 knows - * lets the agent read. That includes no explicit sandbox, since the strictest - * runtime mode (approval-required) implies a read-only one. Unknown sandbox - * types still fail closed. - */ -function acpReadDisposition(runtimePolicy: AcpRuntimePolicy): "allow" | "deny" { - switch (unknownRecord(runtimePolicy.sandboxPolicy)?.type) { - case undefined: - case "readOnly": - case "workspaceWrite": - case "dangerFullAccess": - case "externalSandbox": - return "allow"; - default: - return "deny"; - } -} - -function acpWorkspaceWriteAllowsMutation( - runtimePolicy: AcpRuntimePolicy, - sandboxPolicy: Record, - locations: AcpPolicyOperation["locations"], -): boolean { - const cwd = - typeof runtimePolicy.cwd === "string" && runtimePolicy.cwd.trim().length > 0 - ? (resolveAcpPermissionPath(runtimePolicy.cwd, process.cwd()) ?? null) - : null; - const roots: Array = []; - if (cwd !== null) { - const canonicalCwd = acpCanonicalPathForContainment(cwd); - if (canonicalCwd !== undefined) roots.push(canonicalCwd); - } - const writableRoots = sandboxPolicy.writableRoots; - if (Array.isArray(writableRoots)) { - for (const writableRoot of writableRoots) { - if (typeof writableRoot !== "string") continue; - const resolved = resolveAcpPermissionPath(writableRoot, cwd); - if (resolved === undefined) continue; - const canonicalRoot = acpCanonicalPathForContainment(resolved); - if (canonicalRoot !== undefined) roots.push(canonicalRoot); - } - } - if (roots.length === 0) return false; - - if (locations === undefined || locations === null || locations.length === 0) { - return false; - } - for (const location of locations) { - const resolved = resolveAcpPermissionPath(location.path, cwd); - const canonicalPath = - resolved === undefined ? undefined : acpCanonicalPathForContainment(resolved); - if ( - canonicalPath === undefined || - !roots.some((root) => acpPathIsWithinRoot(canonicalPath, root)) - ) { - return false; - } - } - return true; -} - -/** - * Disposition of one native operation under the active runtime policy. Shared - * by provider permission requests and the client fs/terminal handlers. - */ -function acpOperationDisposition( - runtimePolicy: AcpRuntimePolicy, - operation: AcpPolicyOperation, -): AcpPermissionDisposition { - const toolKind = operation.kind ?? "other"; - if (isAcpReadKind(toolKind)) { - return acpReadDisposition(runtimePolicy); - } - if (acpPolicyRequiresApproval(runtimePolicy)) { - return "ask"; - } - - const sandboxPolicy = unknownRecord(runtimePolicy.sandboxPolicy); - switch (sandboxPolicy?.type) { - case "readOnly": - return "deny"; - case "workspaceWrite": - if (isAcpMutationKind(toolKind)) { - return acpWorkspaceWriteAllowsMutation( - runtimePolicy, - sandboxPolicy ?? {}, - operation.locations, - ) - ? "allow" - : "deny"; - } - return "deny"; - case "dangerFullAccess": - case "externalSandbox": - return "allow"; - case undefined: - if (runtimePolicy.runtimeMode === "approval-required") return "deny"; - // Auto-accept edits approves file changes wherever the agent makes them - // (ACP prompts need not carry locations to confine); other actions still ask. - if ( - runtimePolicy.runtimeMode === "auto-accept-edits" && - runtimePolicy.approvalPolicy === undefined && - !isAcpMutationKind(toolKind) - ) { - return "ask"; - } - return "allow"; - default: - return "deny"; - } -} - -function isAcpMutationKind(toolKind: string): boolean { - return toolKind === "edit" || toolKind === "delete" || toolKind === "move"; -} - -export function acpPermissionDisposition( - runtimePolicy: AcpRuntimePolicy, - request: EffectAcpSchema.RequestPermissionRequest, -): AcpPermissionDisposition { - return acpOperationDisposition(runtimePolicy, { - kind: request.toolCall.kind, - locations: request.toolCall.locations, - }); -} - -/** Resolve explicitly tagged MCP approvals through the thread's normal policy. */ -export function acpMcpToolApprovalElicitationDisposition( - runtimePolicy: AcpRuntimePolicy, - request: EffectAcpSchema.CreateElicitationRequest, - nativeRequestId?: string, -): AcpPermissionDisposition | undefined { - if ( - request.mode !== "form" || - (unknownRecord(request._meta)?.codex_approval_kind !== "mcp_tool_call" && - nativeRequestId?.startsWith("mcp_tool_call_approval_") !== true) - ) { - return undefined; - } - // This request comes from T3's authenticated, scope-checked MCP endpoint, - // not an arbitrary provider command. Let explicit approval mode surface it - // to the user and otherwise allow the endpoint to enforce its own policy. - return acpPolicyRequiresApproval(runtimePolicy) ? "ask" : "allow"; -} - -/** Disposition of a client-mediated `terminal/create` (Devin's client terminals). */ -export function acpClientExecuteDisposition( - runtimePolicy: AcpRuntimePolicy, -): AcpPermissionDisposition { - return acpOperationDisposition(runtimePolicy, { kind: "execute", locations: undefined }); -} - -export interface AcpApprovalGrantInput { - readonly kind: ProviderRequestKind; - readonly scope: "session" | "turn"; - readonly turnKey: string; -} - -export interface AcpClientPolicyGrants { - readonly recordApproval: (input: AcpApprovalGrantInput) => void; - readonly allowsExecute: (turnKey: string | null) => boolean; -} - -/** - * Command approvals recorded when the user accepts a `session/request_permission`, - * so an "ask" disposition at the client terminal boundary honors a command the - * user already approved. ACP does not link an approved tool call to the - * terminals the agent then creates, so an approved command authorizes client - * terminals for the approving turn, or for the session on accept-for-session. - */ -export function makeAcpClientPolicyGrants(): AcpClientPolicyGrants { - let sessionExecute = false; - let turnExecute: string | null = null; - return { - recordApproval: (input) => { - if (input.kind !== "command") return; - if (input.scope === "session") { - sessionExecute = true; - } else { - turnExecute = input.turnKey; - } - }, - allowsExecute: (turnKey) => sessionExecute || (turnKey !== null && turnExecute === turnKey), - }; -} diff --git a/apps/server/src/provider/acp/AcpClientTerminals.test.ts b/apps/server/src/provider/acp/AcpClientTerminals.test.ts deleted file mode 100644 index c923a996898f779ce2999aca9445ff5f15e267f2..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/acp/AcpClientTerminals.test.ts +++ /dev/null @@ -1,312 +0,0 @@ -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { describe, expect, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as Result from "effect/Result"; -import { ChildProcessSpawner } from "effect/process"; - -import { - makeAcpClientTerminals, - resolveEmbeddedTerminalContent, - type AcpClientTerminals, -} from "./AcpClientTerminals.ts"; - -const withTerminals = (use: (terminals: AcpClientTerminals) => Effect.Effect) => - Effect.gen(function* () { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const terminals = yield* makeAcpClientTerminals({ - spawner, - defaultCwd: process.cwd(), - }); - return yield* use(terminals).pipe(Effect.ensuring(terminals.disposeAll)); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped); - -describe("AcpClientTerminals", () => { - it.effect("runs a command, buffers output, and reports the exit status", () => - withTerminals((terminals) => - Effect.gen(function* () { - const created = yield* terminals.create({ - sessionId: "session", - command: process.execPath, - args: ["-e", "console.log('hello from acp'); process.exit(3);"], - }); - - const exit = yield* terminals.waitForExit({ - sessionId: "session", - terminalId: created.terminalId, - }); - expect(exit.exitCode).toBe(3); - - const output = yield* terminals.output({ - sessionId: "session", - terminalId: created.terminalId, - }); - expect(output.output).toContain("hello from acp"); - expect(output.truncated).toBe(false); - expect(output.exitStatus?.exitCode).toBe(3); - }), - ), - ); - - it.effect("resolves terminal environment from the requesting ACP session", () => - Effect.gen(function* () { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const terminals = yield* makeAcpClientTerminals({ - spawner, - defaultCwd: process.cwd(), - environmentForSession: (sessionId) => ({ - T3_TEST_ACP_SESSION_TOKEN: sessionId === "session-a" ? "token-a" : "token-b", - }), - }); - yield* Effect.addFinalizer(() => terminals.disposeAll); - - for (const [sessionId, expected] of [ - ["session-a", "token-a"], - ["session-b", "token-b"], - ] as const) { - const created = yield* terminals.create({ - sessionId, - command: process.execPath, - args: ["-e", "process.stdout.write(process.env.T3_TEST_ACP_SESSION_TOKEN ?? '')"], - }); - yield* terminals.waitForExit({ sessionId, terminalId: created.terminalId }); - const output = yield* terminals.output({ sessionId, terminalId: created.terminalId }); - expect(output.output).toBe(expected); - } - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), - ); - - it.effect("runs Devin shell source with arguments, pipes, and session fallback variables", () => - Effect.gen(function* () { - const terminals = yield* makeAcpClientTerminals({ - spawner: yield* ChildProcessSpawner.ChildProcessSpawner, - defaultCwd: process.cwd(), - shellCommands: true, - environmentForSession: () => ({ T3_ACP_MCP_NODE: "mailbox-probe" }), - }); - yield* Effect.addFinalizer(() => terminals.disposeAll); - // Same command-only shape as the failed production commands and live capture. - const terminal = yield* terminals.create({ - sessionId: "devin", - command: 'printf "%s\\n" "$T3_ACP_MCP_NODE" | tr a-z A-Z', - }); - const exit = yield* terminals.waitForExit({ sessionId: "devin", ...terminal }); - const output = yield* terminals.output({ sessionId: "devin", ...terminal }); - expect(exit.exitCode).toBe(0); - expect(output.output.trim()).toBe("MAILBOX-PROBE"); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), - ); - - it.effect("truncates buffered output from the beginning at the byte limit", () => - withTerminals((terminals) => - Effect.gen(function* () { - const created = yield* terminals.create({ - sessionId: "session", - command: process.execPath, - args: ["-e", "process.stdout.write('a'.repeat(64) + 'TAIL');"], - outputByteLimit: 16, - }); - yield* terminals.waitForExit({ sessionId: "session", terminalId: created.terminalId }); - - const output = yield* terminals.output({ - sessionId: "session", - terminalId: created.terminalId, - }); - expect(output.truncated).toBe(true); - expect(output.output.endsWith("TAIL")).toBe(true); - expect(output.output.length).toBeLessThanOrEqual(16); - }), - ), - ); - - it.effect("kills long-running commands and rejects released terminal handles", () => - withTerminals((terminals) => - Effect.gen(function* () { - const created = yield* terminals.create({ - sessionId: "session", - command: process.execPath, - args: ["-e", "setInterval(() => {}, 1000);"], - }); - yield* terminals.kill({ sessionId: "session", terminalId: created.terminalId }); - const exit = yield* terminals.waitForExit({ - sessionId: "session", - terminalId: created.terminalId, - }); - expect(exit.exitCode === 0 ? null : exit.exitCode).not.toBe(0); - expect(exit.signal).toBe("SIGTERM"); - - yield* terminals.release({ sessionId: "session", terminalId: created.terminalId }); - const rejected = yield* Effect.flip( - terminals.output({ sessionId: "session", terminalId: created.terminalId }), - ); - expect(rejected.message).toContain("unknown terminal ID"); - - // Embedded tool-call content still renders released terminals. - expect(terminals.readOutputSnapshot("session", created.terminalId)).toBeDefined(); - }), - ), - ); - - it.effect("reserves terminal capacity across concurrent creates", () => - withTerminals((terminals) => - Effect.gen(function* () { - const results = yield* Effect.all( - Array.from({ length: 17 }, () => - terminals - .create({ - sessionId: "session", - command: process.execPath, - args: ["-e", "setInterval(() => {}, 1000);"], - }) - .pipe(Effect.result), - ), - { concurrency: "unbounded" }, - ); - - expect(results.filter(Result.isSuccess)).toHaveLength(16); - expect(results.filter(Result.isFailure)).toHaveLength(1); - }), - ), - ); - - it.effect("bounds retained snapshots after terminals are released", () => - withTerminals((terminals) => - Effect.gen(function* () { - const terminalIds: string[] = []; - for (let index = 0; index < 33; index += 1) { - const created = yield* terminals.create({ - sessionId: "session", - command: process.execPath, - args: ["-e", `process.stdout.write('${index}');`], - }); - yield* terminals.release({ sessionId: "session", terminalId: created.terminalId }); - terminalIds.push(created.terminalId); - } - - expect(terminals.readOutputSnapshot("session", terminalIds[0]!)).toBeUndefined(); - expect(terminals.readOutputSnapshot("session", terminalIds.at(-1)!)).toBeDefined(); - }), - ), - ); - - it.effect("bounds unreleased terminal handles without invalidating existing ids", () => - withTerminals((terminals) => - Effect.gen(function* () { - const terminalIds: string[] = []; - for (let index = 0; index < 64; index += 1) { - const created = yield* terminals.create({ - sessionId: "session", - command: process.execPath, - args: ["-e", `process.stdout.write('${index}');`], - }); - yield* terminals.waitForExit({ - sessionId: "session", - terminalId: created.terminalId, - }); - terminalIds.push(created.terminalId); - } - - const rejected = yield* terminals - .create({ - sessionId: "session", - command: process.execPath, - args: ["-e", ""], - }) - .pipe(Effect.result); - expect(Result.isFailure(rejected)).toBe(true); - expect(terminals.readOutputSnapshot("session", terminalIds[0]!)).toBeDefined(); - expect(terminals.readOutputSnapshot("session", terminalIds.at(-1)!)).toBeDefined(); - }), - ), - ); - - it.effect("bounds aggregate output retained across unreleased terminals", () => - withTerminals((terminals) => - Effect.gen(function* () { - const terminalIds: string[] = []; - for (let index = 0; index < 3; index += 1) { - const created = yield* terminals.create({ - sessionId: "session", - command: process.execPath, - args: ["-e", "process.stdout.write('x'.repeat(8 * 1024 * 1024));"], - outputByteLimit: 8 * 1024 * 1024, - }); - yield* terminals.waitForExit({ - sessionId: "session", - terminalId: created.terminalId, - }); - terminalIds.push(created.terminalId); - } - - const snapshots = terminalIds.map((terminalId) => - terminals.readOutputSnapshot("session", terminalId)!, - ); - const retainedBytes = snapshots.reduce( - (total, snapshot) => total + Buffer.byteLength(snapshot.output), - 0, - ); - expect(retainedBytes).toBeLessThanOrEqual(16 * 1024 * 1024); - expect(snapshots.some((snapshot) => snapshot.truncated)).toBe(true); - }), - ), - ); - - it.effect("rejects terminal handles from a different ACP session", () => - withTerminals((terminals) => - Effect.gen(function* () { - const created = yield* terminals.create({ - sessionId: "owner-session", - command: process.execPath, - args: ["-e", "process.stdout.write('owned');"], - }); - const foreignRequest = { - sessionId: "foreign-session", - terminalId: created.terminalId, - }; - const results = yield* Effect.all( - [ - terminals.output(foreignRequest), - terminals.waitForExit(foreignRequest), - terminals.kill(foreignRequest), - terminals.release(foreignRequest), - ].map((effect) => effect.pipe(Effect.result)), - { concurrency: "unbounded" }, - ); - - expect(results.every(Result.isFailure)).toBe(true); - expect(terminals.readOutputSnapshot("foreign-session", created.terminalId)).toBeUndefined(); - expect(terminals.readOutputSnapshot("owner-session", created.terminalId)).toBeDefined(); - }), - ), - ); -}); - -describe("resolveEmbeddedTerminalContent", () => { - it("rewrites terminal content into text from the buffered snapshot", () => { - const notification = { - sessionId: "session", - update: { - sessionUpdate: "tool_call_update" as const, - toolCallId: "call-1", - content: [ - { type: "content" as const, content: { type: "text" as const, text: "before" } }, - { type: "terminal" as const, terminalId: "t3-term-1" }, - { type: "terminal" as const, terminalId: "t3-term-unknown" }, - ], - }, - }; - - const resolved = resolveEmbeddedTerminalContent(notification, (sessionId, terminalId) => - sessionId === "session" && terminalId === "t3-term-1" - ? { output: "compiled 3 files", truncated: false, exitStatus: undefined } - : undefined, - ); - - expect(resolved.update).toMatchObject({ - content: [ - { type: "content", content: { type: "text", text: "before" } }, - { type: "content", content: { type: "text", text: "compiled 3 files" } }, - { type: "content", content: { type: "text", text: "[terminal t3-term-unknown]" } }, - ], - }); - }); -}); diff --git a/apps/server/src/provider/acp/AcpClientTerminals.ts b/apps/server/src/provider/acp/AcpClientTerminals.ts deleted file mode 100644 index c7743d60daae7739f46f154f03da3c8efd1dbc05..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/acp/AcpClientTerminals.ts +++ /dev/null @@ -1,459 +0,0 @@ -import * as Deferred from "effect/Deferred"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; -import * as Fiber from "effect/Fiber"; -import * as Scope from "effect/Scope"; -import * as Semaphore from "effect/Semaphore"; -import * as Stream from "effect/Stream"; -import { ChildProcess, ChildProcessSpawner } from "effect/process"; -import * as EffectAcpErrors from "effect-acp/errors"; -import type * as EffectAcpSchema from "effect-acp/compat"; -import * as NodeBuffer from "node:buffer"; - -/** - * Client-side implementation of the ACP `terminal` capability. - * - * Agents create terminals to run commands through the client - * (`terminal/create`), poll buffered output, await exit, kill, and release - * them. Terminals also appear as embedded tool-call content, which - * {@link resolveEmbeddedTerminalContent} rewrites into plain text so the - * existing tool-call projection renders live command output. - */ - -const MAX_LIVE_TERMINALS = 16; -const MAX_UNRELEASED_TERMINALS = 64; -const MAX_RETAINED_TERMINALS = 32; -const MAX_RETAINED_OUTPUT_BYTES = 16 * 1024 * 1024; -const DEFAULT_OUTPUT_BYTE_LIMIT = 4 * 1024 * 1024; -const MAX_OUTPUT_BYTE_LIMIT = 8 * 1024 * 1024; - -const terminalRequestError = (message: string, cause?: unknown) => - EffectAcpErrors.AcpRequestError.internalError( - message, - undefined, - cause === undefined ? {} : { cause }, - ); - -interface OutputBufferState { - chunks: Array; - bytes: number; - truncated: boolean; - readonly limit: number; -} - -function trimOutputStart(state: OutputBufferState, bytes: number): void { - let remaining = Math.min(bytes, state.bytes); - while (remaining > 0) { - const head = state.chunks[0]!; - if (head.byteLength <= remaining) { - state.chunks.shift(); - state.bytes -= head.byteLength; - remaining -= head.byteLength; - } else { - state.chunks[0] = head.subarray(remaining); - state.bytes -= remaining; - remaining = 0; - } - state.truncated = true; - } -} - -function appendOutput(state: OutputBufferState, chunk: Uint8Array): void { - state.chunks.push(chunk); - state.bytes += chunk.byteLength; - // The spec requires truncation from the beginning of the output. - if (state.bytes > state.limit) trimOutputStart(state, state.bytes - state.limit); -} - -function readOutput(state: OutputBufferState): string { - let combined: Uint8Array = NodeBuffer.Buffer.concat(state.chunks, state.bytes); - if (state.truncated) { - // Drop leading UTF-8 continuation bytes so truncation lands on a - // character boundary, as the spec requires. - let start = 0; - while (start < combined.byteLength && (combined[start]! & 0b1100_0000) === 0b1000_0000) { - start += 1; - } - combined = combined.subarray(start); - } - return NodeBuffer.Buffer.from(combined).toString("utf8"); -} - -function terminalExitSignal(error: unknown): string | null { - const cause = error instanceof Error ? error.cause : undefined; - const message = - cause instanceof Error ? cause.message : error instanceof Error ? error.message : ""; - return message.match(/signal: '([^']+)'/u)?.[1] ?? null; -} - -export interface AcpTerminalOutputSnapshot { - readonly output: string; - readonly truncated: boolean; - readonly exitStatus: EffectAcpSchema.TerminalExitStatus | undefined; -} - -interface ManagedAcpTerminal { - readonly terminalId: string; - readonly sessionId: string; - /** The spawned command with args, kept for MCP-fallback identification. */ - readonly commandLine: string; - readonly buffer: OutputBufferState; - readonly exit: Deferred.Deferred; - readonly kill: Effect.Effect; - /** Closes the terminal's scope, killing the process if still running. */ - readonly dispose: Effect.Effect; - exitStatus: EffectAcpSchema.TerminalExitStatus | undefined; - released: boolean; -} - -export interface AcpClientTerminals { - readonly create: ( - request: EffectAcpSchema.CreateTerminalRequest, - ) => Effect.Effect; - readonly output: ( - request: EffectAcpSchema.TerminalOutputRequest, - ) => Effect.Effect; - readonly waitForExit: ( - request: EffectAcpSchema.WaitForTerminalExitRequest, - ) => Effect.Effect; - readonly kill: ( - request: EffectAcpSchema.KillTerminalRequest, - ) => Effect.Effect; - readonly release: ( - request: EffectAcpSchema.ReleaseTerminalRequest, - ) => Effect.Effect; - /** Buffered output for embedded tool-call content; survives release. */ - readonly readOutputSnapshot: ( - sessionId: string, - terminalId: string, - ) => AcpTerminalOutputSnapshot | undefined; - /** Command line a terminal was created with; presentation-only lookup. */ - readonly readCommandLine: (terminalId: string) => string | undefined; - /** Kills every terminal that is still running. Safe to call repeatedly. */ - readonly disposeAll: Effect.Effect; -} - -export interface AcpClientTerminalsOptions { - /** Devin sends shell source in command, rather than an executable plus args. */ - readonly shellCommands?: boolean | undefined; - readonly spawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; - readonly defaultCwd: string; - readonly environment?: NodeJS.ProcessEnv | undefined; - readonly environmentForSession?: - | ((sessionId: string) => NodeJS.ProcessEnv | undefined) - | undefined; - readonly forceKillAfter?: Duration.Input | undefined; -} - -function acpTerminalCommand(input: { - readonly request: EffectAcpSchema.CreateTerminalRequest; - readonly shellCommands?: boolean | undefined; - readonly defaultCwd: string; - readonly environment?: NodeJS.ProcessEnv | undefined; - readonly forceKillAfter?: Duration.Input | undefined; -}) { - return ChildProcess.make(input.request.command, input.request.args ?? [], { - cwd: input.request.cwd ?? input.defaultCwd, - ...(input.environment === undefined ? {} : { env: input.environment }), - shell: input.shellCommands === true && (input.request.args?.length ?? 0) === 0, - forceKillAfter: input.forceKillAfter ?? "5 seconds", - }); -} - -export const makeAcpClientTerminals = ( - options: AcpClientTerminalsOptions, -): Effect.Effect => - Effect.gen(function* () { - const creationPermit = yield* Semaphore.make(1); - const terminals = new Map(); - let nextTerminalNumber = 1; - - const enforceTotalOutputLimit = (): void => { - let excess = - Array.from(terminals.values()).reduce( - (total, terminal) => total + terminal.buffer.bytes, - 0, - ) - MAX_RETAINED_OUTPUT_BYTES; - if (excess <= 0) return; - for (const terminal of terminals.values()) { - const before = terminal.buffer.bytes; - trimOutputStart(terminal.buffer, excess); - excess -= before - terminal.buffer.bytes; - if (excess <= 0) return; - } - }; - - const pruneReleasedSnapshots = (): void => { - const released = Array.from(terminals.values()).filter((terminal) => terminal.released); - let retainedBytes = released.reduce((total, terminal) => total + terminal.buffer.bytes, 0); - while ( - released.length > MAX_RETAINED_TERMINALS || - retainedBytes > MAX_RETAINED_OUTPUT_BYTES - ) { - const terminal = released.shift(); - if (terminal === undefined) break; - retainedBytes -= terminal.buffer.bytes; - terminals.delete(terminal.terminalId); - } - }; - - const requireTerminal = ( - sessionId: string, - terminalId: string, - operation: string, - ): Effect.Effect => { - const terminal = terminals.get(terminalId); - if (terminal === undefined || terminal.sessionId !== sessionId || terminal.released) { - return Effect.fail( - terminalRequestError(`ACP ${operation} received an unknown terminal ID '${terminalId}'.`), - ); - } - return Effect.succeed(terminal); - }; - - const create: AcpClientTerminals["create"] = (request) => - creationPermit.withPermit( - Effect.uninterruptibleMask((restore) => - Effect.gen(function* () { - const liveCount = Array.from(terminals.values()).filter( - (terminal) => !terminal.released && terminal.exitStatus === undefined, - ).length; - const unreleasedCount = Array.from(terminals.values()).filter( - (terminal) => !terminal.released, - ).length; - if (unreleasedCount >= MAX_UNRELEASED_TERMINALS) { - return yield* terminalRequestError( - `ACP terminal/create exceeded the limit of ${MAX_UNRELEASED_TERMINALS} unreleased terminals.`, - ); - } - if (liveCount >= MAX_LIVE_TERMINALS) { - return yield* terminalRequestError( - `ACP terminal/create exceeded the limit of ${MAX_LIVE_TERMINALS} concurrent terminals.`, - ); - } - const requestEnvironment = Object.fromEntries( - (request.env ?? []).map((variable) => [variable.name, variable.value] as const), - ); - const sessionEnvironment = options.environmentForSession?.(request.sessionId); - const environment = - options.environment === undefined && - sessionEnvironment === undefined && - (request.env?.length ?? 0) === 0 - ? undefined - : { ...options.environment, ...sessionEnvironment, ...requestEnvironment }; - // Each terminal owns a scope so the spawned process is reliably reaped: - // closing the scope kills a still-running command and frees the handle. - const terminalScope = yield* Scope.make(); - const child = yield* restore( - options.spawner - .spawn( - acpTerminalCommand({ - request, - shellCommands: options.shellCommands, - defaultCwd: options.defaultCwd, - environment, - forceKillAfter: options.forceKillAfter, - }), - ) - .pipe( - Effect.provideService(Scope.Scope, terminalScope), - Effect.mapError((cause) => - terminalRequestError( - `Could not start terminal command '${request.command}'.`, - cause, - ), - ), - ), - ).pipe( - Effect.onExit((exit) => - Exit.isFailure(exit) ? Scope.close(terminalScope, Exit.void) : Effect.void, - ), - ); - - const terminalId = `t3-term-${nextTerminalNumber}`; - nextTerminalNumber += 1; - const record: ManagedAcpTerminal = { - terminalId, - sessionId: request.sessionId, - commandLine: [request.command, ...(request.args ?? [])].join(" "), - buffer: { - chunks: [], - bytes: 0, - truncated: false, - limit: Math.min( - request.outputByteLimit ?? DEFAULT_OUTPUT_BYTE_LIMIT, - MAX_OUTPUT_BYTE_LIMIT, - ), - }, - exit: yield* Deferred.make(), - kill: child.kill().pipe(Effect.ignore), - dispose: Scope.close(terminalScope, Exit.void).pipe(Effect.ignore), - exitStatus: undefined, - released: false, - }; - terminals.set(terminalId, record); - - const pump = (stream: typeof child.stdout) => - stream.pipe( - Stream.runForEach((chunk) => - Effect.sync(() => { - appendOutput(record.buffer, chunk); - enforceTotalOutputLimit(); - }), - ), - Effect.ignore, - ); - const stdoutPump = yield* Effect.forkDetach(pump(child.stdout)); - const stderrPump = yield* Effect.forkDetach(pump(child.stderr)); - yield* Effect.forkDetach( - child.exitCode.pipe( - Effect.match({ - onFailure: (error) => ({ exitCode: null, signal: terminalExitSignal(error) }), - onSuccess: (code) => ({ exitCode: Number(code), signal: null }), - }), - Effect.flatMap((status) => - Effect.all([Fiber.join(stdoutPump), Fiber.join(stderrPump)], { - discard: true, - }).pipe( - Effect.andThen( - Effect.sync(() => { - record.exitStatus = status; - }), - ), - Effect.andThen(Deferred.succeed(record.exit, status)), - ), - ), - ), - ); - return { terminalId }; - }), - ), - ); - - const output: AcpClientTerminals["output"] = (request) => - requireTerminal(request.sessionId, request.terminalId, "terminal/output").pipe( - Effect.map((terminal) => ({ - output: readOutput(terminal.buffer), - truncated: terminal.buffer.truncated, - ...(terminal.exitStatus === undefined ? {} : { exitStatus: terminal.exitStatus }), - })), - ); - - const waitForExit: AcpClientTerminals["waitForExit"] = (request) => - requireTerminal(request.sessionId, request.terminalId, "terminal/wait_for_exit").pipe( - Effect.flatMap((terminal) => Deferred.await(terminal.exit)), - ); - - const kill: AcpClientTerminals["kill"] = (request) => - requireTerminal(request.sessionId, request.terminalId, "terminal/kill").pipe( - Effect.flatMap((terminal) => terminal.kill), - Effect.as({}), - ); - - const release: AcpClientTerminals["release"] = (request) => - Effect.uninterruptible( - requireTerminal(request.sessionId, request.terminalId, "terminal/release").pipe( - Effect.flatMap((terminal) => - Effect.sync(() => { - terminal.released = true; - }).pipe( - Effect.andThen(terminal.dispose), - Effect.andThen(Deferred.await(terminal.exit)), - Effect.andThen(Effect.sync(pruneReleasedSnapshots)), - ), - ), - Effect.as({}), - ), - ); - - const readOutputSnapshot: AcpClientTerminals["readOutputSnapshot"] = ( - sessionId, - terminalId, - ) => { - const terminal = terminals.get(terminalId); - if (terminal === undefined || terminal.sessionId !== sessionId) return undefined; - return { - output: readOutput(terminal.buffer), - truncated: terminal.buffer.truncated, - exitStatus: terminal.exitStatus, - }; - }; - - const readCommandLine: AcpClientTerminals["readCommandLine"] = (terminalId) => - terminals.get(terminalId)?.commandLine; - - const disposeAll: AcpClientTerminals["disposeAll"] = Effect.uninterruptible( - Effect.suspend(() => - Effect.forEach( - Array.from(terminals.values()), - (terminal) => - Effect.sync(() => { - terminal.released = true; - }).pipe( - Effect.andThen(terminal.dispose), - Effect.andThen(Deferred.await(terminal.exit)), - ), - { concurrency: "unbounded", discard: true }, - ).pipe(Effect.andThen(Effect.sync(pruneReleasedSnapshots))), - ), - ); - - // Terminal failures surface to the agent as JSON-RPC errors and to the - // user only as a failed tool item, so log them for server-side diagnosis. - const logged = - ( - operation: string, - handler: (request: Request) => Effect.Effect, - ) => - (request: Request) => - handler(request).pipe( - Effect.tapError((error) => - Effect.logWarning("ACP client terminal operation failed", { - operation, - detail: error.message, - }), - ), - ); - - return { - create: logged("terminal/create", create), - output: logged("terminal/output", output), - waitForExit: logged("terminal/wait_for_exit", waitForExit), - kill: logged("terminal/kill", kill), - release: logged("terminal/release", release), - readOutputSnapshot, - readCommandLine, - disposeAll, - }; - }); - -/** - * Rewrites embedded `terminal` tool-call content into plain text content so - * the provider-neutral tool-call projection can display live command output. - */ -export function resolveEmbeddedTerminalContent( - notification: EffectAcpSchema.SessionNotification, - readOutputSnapshot: AcpClientTerminals["readOutputSnapshot"], -): EffectAcpSchema.SessionNotification { - const update = notification.update; - if (update.sessionUpdate !== "tool_call" && update.sessionUpdate !== "tool_call_update") { - return notification; - } - const content = update.content; - if (!content || !content.some((entry) => entry.type === "terminal")) { - return notification; - } - const mapped = content.map((entry): EffectAcpSchema.ToolCallContent => { - if (entry.type !== "terminal") return entry; - const snapshot = readOutputSnapshot(notification.sessionId, entry.terminalId); - return { - type: "content", - content: { - type: "text", - text: snapshot === undefined ? `[terminal ${entry.terminalId}]` : snapshot.output, - }, - }; - }); - return { ...notification, update: { ...update, content: mapped } }; -} diff --git a/apps/server/src/provider/acp/AcpCoreRuntimeEvents.test.ts b/apps/server/src/provider/acp/AcpCoreRuntimeEvents.test.ts deleted file mode 100644 index 65c133012fb08ddacf7ce3194ff2a7843c9c1ed2..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/acp/AcpCoreRuntimeEvents.test.ts +++ /dev/null @@ -1,247 +0,0 @@ -import { ProviderDriverKind, RuntimeRequestId, TurnId } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; - -import { - makeAcpAssistantItemEvent, - makeAcpContentDeltaEvent, - makeAcpPlanUpdatedEvent, - makeAcpRequestOpenedEvent, - makeAcpRequestResolvedEvent, - makeAcpToolCallEvent, -} from "./AcpCoreRuntimeEvents.ts"; - -describe("AcpCoreRuntimeEvents", () => { - it("maps ACP permission requests to canonical runtime events", () => { - const stamp = { eventId: "event-1" as never, createdAt: "2026-03-27T00:00:00.000Z" }; - const turnId = TurnId.make("turn-1"); - const permissionRequest = { - kind: "execute" as const, - detail: "cat package.json", - toolCall: { - toolCallId: "tool-1", - kind: "execute", - status: "pending" as const, - command: "cat package.json", - detail: "cat package.json", - data: { toolCallId: "tool-1", kind: "execute" }, - }, - }; - - const openedEvent = makeAcpRequestOpenedEvent({ - stamp, - provider: ProviderDriverKind.make("cursor"), - threadId: "thread-1" as never, - turnId, - requestId: RuntimeRequestId.make("request-1"), - permissionRequest, - detail: "cat package.json", - args: { command: ["cat", "package.json"] }, - source: "acp.jsonrpc", - method: "session/request_permission", - rawPayload: { sessionId: "session-1" }, - }); - expect(openedEvent).toMatchObject({ - type: "request.opened", - payload: { - requestType: "exec_command_approval", - detail: "cat package.json", - }, - }); - expect(openedEvent).not.toHaveProperty("payload.options"); - - expect( - makeAcpRequestResolvedEvent({ - stamp, - provider: ProviderDriverKind.make("cursor"), - threadId: "thread-1" as never, - turnId, - requestId: RuntimeRequestId.make("request-1"), - permissionRequest, - decision: "accept", - }), - ).toMatchObject({ - type: "request.resolved", - payload: { - requestType: "exec_command_approval", - decision: "accept", - }, - }); - }); - - it("preserves a native file approval without a remembered-allow choice", () => { - const event = makeAcpRequestOpenedEvent({ - stamp: { eventId: "approval-1" as never, createdAt: "2026-09-02T00:00:00.000Z" }, - provider: ProviderDriverKind.make("antigravity"), - threadId: "thread-1" as never, - turnId: TurnId.make("turn-1"), - requestId: RuntimeRequestId.make("request-1"), - permissionRequest: { kind: "edit" }, - approvalOptions: [ - { decision: "accept", label: "Allow once" }, - { decision: "decline", label: "Reject" }, - ], - detail: "Edit package.json", - args: {}, - source: "acp.jsonrpc", - method: "session/request_permission", - rawPayload: { sessionId: "session-1" }, - }); - - expect(event.payload).toEqual({ - requestType: "file_change_approval", - detail: "Edit package.json", - args: {}, - options: [ - { decision: "accept", label: "Allow once" }, - { decision: "decline", label: "Reject" }, - ], - }); - }); - - it("maps generic ACP permission kinds to dynamic tool approvals", () => { - const stamp = { eventId: "event-1" as never, createdAt: "2026-03-27T00:00:00.000Z" }; - - for (const kind of ["search", "fetch", "other", "unknown", "future-tool-kind"]) { - const permissionRequest = { kind }; - const request = { - stamp, - provider: ProviderDriverKind.make("cursor"), - threadId: "thread-1" as never, - turnId: TurnId.make("turn-1"), - requestId: RuntimeRequestId.make(`request-${kind}`), - permissionRequest, - }; - - expect( - makeAcpRequestOpenedEvent({ - ...request, - detail: kind, - args: {}, - source: "acp.jsonrpc", - method: "session/request_permission", - rawPayload: { sessionId: "session-1" }, - }), - ).toMatchObject({ - type: "request.opened", - payload: { requestType: "dynamic_tool_call" }, - }); - - expect( - makeAcpRequestResolvedEvent({ - ...request, - decision: "accept", - }), - ).toMatchObject({ - type: "request.resolved", - payload: { requestType: "dynamic_tool_call" }, - }); - } - }); - - it("maps ACP core plan, tool-call, and content updates", () => { - const stamp = { eventId: "event-1" as never, createdAt: "2026-03-27T00:00:00.000Z" }; - const turnId = TurnId.make("turn-1"); - - expect( - makeAcpPlanUpdatedEvent({ - stamp, - provider: ProviderDriverKind.make("cursor"), - threadId: "thread-1" as never, - turnId, - payload: { - nativePlanId: "plan-1", - kind: "items", - plan: [{ step: "Inspect state", status: "inProgress" }], - }, - source: "acp.cursor.extension", - method: "cursor/update_todos", - rawPayload: { todos: [] }, - }), - ).toMatchObject({ - type: "turn.plan.updated", - raw: { - method: "cursor/update_todos", - }, - }); - - expect( - makeAcpToolCallEvent({ - stamp, - provider: ProviderDriverKind.make("cursor"), - threadId: "thread-1" as never, - turnId, - toolCall: { - toolCallId: "tool-1", - kind: "execute", - status: "completed", - title: "Terminal", - detail: "bun run test", - data: { command: "bun run test" }, - }, - rawPayload: { sessionId: "session-1" }, - }), - ).toMatchObject({ - type: "item.completed", - payload: { - itemType: "command_execution", - status: "completed", - }, - }); - - expect( - makeAcpContentDeltaEvent({ - stamp, - provider: ProviderDriverKind.make("cursor"), - threadId: "thread-1" as never, - turnId, - itemId: "assistant:session-1:segment:0", - text: "hello", - rawPayload: { sessionId: "session-1" }, - }), - ).toMatchObject({ - type: "content.delta", - itemId: "assistant:session-1:segment:0", - payload: { - delta: "hello", - }, - }); - - expect( - makeAcpAssistantItemEvent({ - stamp, - provider: ProviderDriverKind.make("cursor"), - threadId: "thread-1" as never, - turnId, - itemId: "assistant:session-1:segment:0", - lifecycle: "item.started", - }), - ).toMatchObject({ - type: "item.started", - itemId: "assistant:session-1:segment:0", - payload: { - itemType: "assistant_message", - status: "inProgress", - }, - }); - }); - - it("maps thoughts to the reasoning stream", () => { - expect( - makeAcpContentDeltaEvent({ - stamp: { eventId: "thought-1" as never, createdAt: "2026-09-02T00:00:00.000Z" }, - provider: ProviderDriverKind.make("cursor"), - threadId: "thread-1" as never, - turnId: TurnId.make("turn-1"), - streamKind: "reasoning_text", - text: "Inspect the current implementation first.", - rawPayload: { sessionId: "session-1" }, - }), - ).toMatchObject({ - type: "content.delta", - payload: { - streamKind: "reasoning_text", - delta: "Inspect the current implementation first.", - }, - }); - }); -}); diff --git a/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts b/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts deleted file mode 100644 index c3fa348d2763d7c73e6c24fd953387329bebaca7..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts +++ /dev/null @@ -1,255 +0,0 @@ -import { - type RuntimeEventRawSource, - RuntimeItemId, - type CanonicalRequestType, - type EventId, - type ProviderApprovalDecision, - type ProviderApprovalOption, - type ProviderDriverKind, - type ProviderRuntimeEvent, - type RuntimeRequestId, - type ThreadId, - type TurnId, -} from "@t3tools/contracts"; - -import { - type AcpPermissionRequest, - type AcpPlanUpdate, - type AcpToolCallState, - canonicalItemTypeFromAcpToolKind, -} from "./AcpRuntimeModel.ts"; - -type AcpAdapterRawSource = Extract< - RuntimeEventRawSource, - "acp.jsonrpc" | `acp.${string}.extension` ->; - -interface AcpEventStamp { - readonly eventId: EventId; - readonly createdAt: string; -} - -type AcpCanonicalRequestType = Extract< - CanonicalRequestType, - "exec_command_approval" | "file_read_approval" | "file_change_approval" | "dynamic_tool_call" ->; - -function canonicalRequestTypeFromAcpKind(kind: string | "unknown"): AcpCanonicalRequestType { - switch (kind) { - case "execute": - return "exec_command_approval"; - case "read": - return "file_read_approval"; - case "edit": - case "delete": - case "move": - return "file_change_approval"; - default: - return "dynamic_tool_call"; - } -} - -function runtimeItemStatusFromAcpToolStatus( - status: AcpToolCallState["status"], -): "inProgress" | "completed" | "failed" | undefined { - switch (status) { - case "pending": - case "inProgress": - return "inProgress"; - case "completed": - return "completed"; - case "failed": - return "failed"; - default: - return undefined; - } -} - -export function makeAcpRequestOpenedEvent(input: { - readonly stamp: AcpEventStamp; - readonly provider: ProviderDriverKind; - readonly threadId: ThreadId; - readonly turnId: TurnId | undefined; - readonly requestId: RuntimeRequestId; - readonly permissionRequest: AcpPermissionRequest; - readonly approvalOptions?: ReadonlyArray; - readonly detail: string; - readonly args: unknown; - readonly source: AcpAdapterRawSource; - readonly method: string; - readonly rawPayload: unknown; -}): ProviderRuntimeEvent { - return { - type: "request.opened", - ...input.stamp, - provider: input.provider, - threadId: input.threadId, - turnId: input.turnId, - requestId: input.requestId, - payload: { - requestType: canonicalRequestTypeFromAcpKind(input.permissionRequest.kind), - detail: input.detail, - args: input.args, - ...(input.approvalOptions !== undefined ? { options: input.approvalOptions } : {}), - }, - raw: { - source: input.source, - method: input.method, - payload: input.rawPayload, - }, - }; -} - -export function makeAcpRequestResolvedEvent(input: { - readonly stamp: AcpEventStamp; - readonly provider: ProviderDriverKind; - readonly threadId: ThreadId; - readonly turnId: TurnId | undefined; - readonly requestId: RuntimeRequestId; - readonly permissionRequest: AcpPermissionRequest; - readonly decision: ProviderApprovalDecision; -}): ProviderRuntimeEvent { - return { - type: "request.resolved", - ...input.stamp, - provider: input.provider, - threadId: input.threadId, - turnId: input.turnId, - requestId: input.requestId, - payload: { - requestType: canonicalRequestTypeFromAcpKind(input.permissionRequest.kind), - decision: input.decision, - }, - }; -} - -export function makeAcpPlanUpdatedEvent(input: { - readonly stamp: AcpEventStamp; - readonly provider: ProviderDriverKind; - readonly threadId: ThreadId; - readonly turnId: TurnId | undefined; - readonly payload: AcpPlanUpdate; - readonly source: AcpAdapterRawSource; - readonly method: string; - readonly rawPayload: unknown; -}): ProviderRuntimeEvent { - const payload = - input.payload.kind === "items" - ? { - plan: input.payload.plan, - ...(input.payload.explanation == null ? {} : { explanation: input.payload.explanation }), - } - : input.payload.kind === "removed" - ? { plan: [] } - : { - plan: [ - { - step: - input.payload.kind === "markdown" - ? input.payload.markdown - : input.payload.kind === "file" - ? `Plan file: ${input.payload.uri}` - : `[Unsupported ACP plan content: ${input.payload.contentType}]`, - status: "pending" as const, - }, - ], - }; - return { - type: "turn.plan.updated", - ...input.stamp, - provider: input.provider, - threadId: input.threadId, - turnId: input.turnId, - payload, - raw: { - source: input.source, - method: input.method, - payload: input.rawPayload, - }, - }; -} - -export function makeAcpToolCallEvent(input: { - readonly stamp: AcpEventStamp; - readonly provider: ProviderDriverKind; - readonly threadId: ThreadId; - readonly turnId: TurnId | undefined; - readonly toolCall: AcpToolCallState; - readonly rawPayload: unknown; -}): ProviderRuntimeEvent { - const runtimeStatus = runtimeItemStatusFromAcpToolStatus(input.toolCall.status); - return { - type: - input.toolCall.status === "completed" || input.toolCall.status === "failed" - ? "item.completed" - : "item.updated", - ...input.stamp, - provider: input.provider, - threadId: input.threadId, - turnId: input.turnId, - itemId: RuntimeItemId.make(input.toolCall.toolCallId), - payload: { - itemType: canonicalItemTypeFromAcpToolKind(input.toolCall.kind), - ...(runtimeStatus ? { status: runtimeStatus } : {}), - ...(input.toolCall.title ? { title: input.toolCall.title } : {}), - ...(input.toolCall.detail ? { detail: input.toolCall.detail } : {}), - ...(Object.keys(input.toolCall.data).length > 0 ? { data: input.toolCall.data } : {}), - }, - raw: { - source: "acp.jsonrpc", - method: "session/update", - payload: input.rawPayload, - }, - }; -} - -export function makeAcpAssistantItemEvent(input: { - readonly stamp: AcpEventStamp; - readonly provider: ProviderDriverKind; - readonly threadId: ThreadId; - readonly turnId: TurnId | undefined; - readonly itemId: string; - readonly lifecycle: "item.started" | "item.completed"; -}): ProviderRuntimeEvent { - return { - type: input.lifecycle, - ...input.stamp, - provider: input.provider, - threadId: input.threadId, - turnId: input.turnId, - itemId: RuntimeItemId.make(input.itemId), - payload: { - itemType: "assistant_message", - status: input.lifecycle === "item.completed" ? "completed" : "inProgress", - }, - }; -} - -export function makeAcpContentDeltaEvent(input: { - readonly stamp: AcpEventStamp; - readonly provider: ProviderDriverKind; - readonly threadId: ThreadId; - readonly turnId: TurnId | undefined; - readonly itemId?: string; - readonly streamKind?: "assistant_text" | "reasoning_text"; - readonly text: string; - readonly rawPayload: unknown; -}): ProviderRuntimeEvent { - return { - type: "content.delta", - ...input.stamp, - provider: input.provider, - threadId: input.threadId, - turnId: input.turnId, - ...(input.itemId ? { itemId: RuntimeItemId.make(input.itemId) } : {}), - payload: { - streamKind: input.streamKind ?? "assistant_text", - delta: input.text, - }, - raw: { - source: "acp.jsonrpc", - method: "session/update", - payload: input.rawPayload, - }, - }; -} diff --git a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts index 32f207e2b62bb31505f4033a52066ef077d8d556..b11fb5baec3a0a391c5e9ca33a3c19fb93237f46 100644 --- a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts +++ b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts @@ -17,7 +17,7 @@ import * as TestClock from "effect/testing/TestClock"; import * as Stream from "effect/Stream"; import { describe, expect } from "vite-plus/test"; -import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; +import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; import type * as EffectAcpProtocol from "effect-acp/protocol"; import * as EffectAcpErrors from "effect-acp/errors"; diff --git a/apps/server/src/provider/acp/AcpNativeLogging.test.ts b/apps/server/src/provider/acp/AcpNativeLogging.test.ts deleted file mode 100644 index 54b485613f379d7dd5954ff9764b7140b9d24b2c..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/acp/AcpNativeLogging.test.ts +++ /dev/null @@ -1,306 +0,0 @@ -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { ProviderDriverKind, ThreadId } from "@t3tools/contracts"; -import { assert, it } from "@effect/vitest"; -import * as Cause from "effect/Cause"; -import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; -import * as Logger from "effect/Logger"; -import * as Schema from "effect/Schema"; -import * as AcpErrors from "effect-acp/errors"; - -import type { EventNdjsonLogger } from "../EventNdjsonLogger.ts"; -import { makeAcpNativeLoggerFactory } from "./AcpNativeLogging.ts"; - -const nodeServicesIt = it.layer(NodeServices.layer); -const encodeUnknownJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); - -nodeServicesIt("ACP native logging", (it) => { - it.effect("records bounded request and protocol diagnostics without raw payloads", () => - Effect.gen(function* () { - const records: Array = []; - const nativeEventLogger: EventNdjsonLogger = { - filePath: "/tmp/provider-native.ndjson", - write: (event) => Effect.sync(() => void records.push(event)), - close: () => Effect.void, - }; - const makeLogger = yield* makeAcpNativeLoggerFactory(); - const logger = makeLogger({ - nativeEventLogger, - provider: ProviderDriverKind.make("cursor"), - threadId: ThreadId.make("thread-1"), - verboseProtocolLogging: true, - }); - const secret = "secret-token-value"; - const requestLogger = logger.requestLogger; - const protocolLogger = logger.protocolLogging?.logger; - assert.exists(requestLogger); - assert.exists(protocolLogger); - if (!requestLogger || !protocolLogger) return; - - yield* requestLogger({ - method: "session/prompt", - payload: { prompt: secret, sessionId: secret }, - status: "failed", - cause: Cause.fail(AcpErrors.AcpRequestError.internalError(secret, { token: secret })), - }); - yield* protocolLogger({ - direction: "incoming", - stage: "raw", - payload: `{"token":"${secret}"}`, - }); - yield* protocolLogger({ - direction: "outgoing", - stage: "decoded", - payload: { - _tag: "Request", - tag: "session/prompt", - payload: { prompt: secret }, - }, - }); - - const serialized = encodeUnknownJson(records); - assert.notInclude(serialized, secret); - assert.include(serialized, '"method":"session/prompt"'); - assert.include(serialized, '"errorTag":"AcpRequestError"'); - assert.include(serialized, '"reasonCount":1'); - assert.include(serialized, '"valueType":"string"'); - assert.include(serialized, '"messageTag":"Request"'); - }), - ); - - it.effect("keeps request diagnostics without enabling full protocol logging", () => - Effect.gen(function* () { - const records: Array = []; - const makeLogger = yield* makeAcpNativeLoggerFactory(); - const logger = makeLogger({ - nativeEventLogger: { - filePath: "/tmp/provider-native.ndjson", - write: (event) => Effect.sync(() => void records.push(event)), - close: () => Effect.void, - }, - provider: ProviderDriverKind.make("grok"), - threadId: ThreadId.make("thread-1"), - }); - - assert.isUndefined(logger.protocolLogging); - const requestLogger = logger.requestLogger; - assert.exists(requestLogger); - if (!requestLogger) return; - yield* requestLogger({ - method: "session/prompt", - payload: {}, - status: "started", - }); - assert.lengthOf(records, 1); - }), - ); - - it.effect("drops transient ACP chunks before formatting verbose protocol logs", () => - Effect.gen(function* () { - const records: Array = []; - const makeLogger = yield* makeAcpNativeLoggerFactory(); - const logger = makeLogger({ - nativeEventLogger: { - filePath: "/tmp/provider-native.ndjson", - write: (event) => Effect.sync(() => void records.push(event)), - close: () => Effect.void, - }, - provider: ProviderDriverKind.make("cursor"), - threadId: ThreadId.make("thread-1"), - verboseProtocolLogging: true, - }); - const protocolLogger = logger.protocolLogging?.logger; - assert.exists(protocolLogger); - if (!protocolLogger) return; - - for (const updateType of ["agent_message_chunk", "agent_thought_chunk"] as const) { - yield* protocolLogger({ - direction: "incoming", - stage: "raw", - payload: `${encodeUnknownJson({ - method: "session/update", - params: { update: { sessionUpdate: updateType } }, - })}\n`, - }); - yield* protocolLogger({ - direction: "incoming", - stage: "decoded", - payload: [ - { - _tag: "Request", - tag: "session/update", - payload: { update: { sessionUpdate: updateType } }, - }, - ], - }); - } - - assert.lengthOf(records, 0); - - yield* protocolLogger({ - direction: "incoming", - stage: "decoded", - payload: [ - { - _tag: "Request", - tag: "session/update", - payload: { update: { sessionUpdate: "tool_call" } }, - }, - ], - }); - assert.lengthOf(records, 1); - }), - ); - - it.effect("keeps mixed and incomplete raw diagnostics", () => - Effect.gen(function* () { - const records: Array = []; - const makeLogger = yield* makeAcpNativeLoggerFactory(); - const logger = makeLogger({ - nativeEventLogger: { - filePath: "/tmp/provider-native.ndjson", - write: (event) => Effect.sync(() => void records.push(event)), - close: () => Effect.void, - }, - provider: ProviderDriverKind.make("cursor"), - threadId: ThreadId.make("thread-1"), - verboseProtocolLogging: true, - }); - const protocolLogger = logger.protocolLogging?.logger; - assert.exists(protocolLogger); - if (!protocolLogger) return; - - const transient = encodeUnknownJson({ - method: "session/update", - params: { update: { sessionUpdate: "agent_message_chunk" } }, - }); - const lifecycle = encodeUnknownJson({ method: "session/new", params: {} }); - - yield* protocolLogger({ - direction: "incoming", - stage: "raw", - payload: `${transient}\n${lifecycle}\n`, - }); - yield* protocolLogger({ - direction: "incoming", - stage: "raw", - payload: transient, - }); - yield* protocolLogger({ - direction: "incoming", - stage: "raw", - payload: `${transient}\n{malformed}\n`, - }); - - assert.lengthOf(records, 3); - }), - ); - - it.effect("filters transient entries from mixed decoded batches", () => - Effect.gen(function* () { - const records: Array = []; - const makeLogger = yield* makeAcpNativeLoggerFactory(); - const logger = makeLogger({ - nativeEventLogger: { - filePath: "/tmp/provider-native.ndjson", - write: (event) => Effect.sync(() => void records.push(event)), - close: () => Effect.void, - }, - provider: ProviderDriverKind.make("grok"), - threadId: ThreadId.make("thread-1"), - verboseProtocolLogging: true, - }); - const protocolLogger = logger.protocolLogging?.logger; - assert.exists(protocolLogger); - if (!protocolLogger) return; - - yield* protocolLogger({ - direction: "incoming", - stage: "decoded", - payload: [ - { - _tag: "Request", - tag: "session/update", - payload: { update: { sessionUpdate: "agent_thought_chunk" } }, - }, - { - _tag: "Request", - tag: "session/new", - payload: {}, - }, - ], - }); - - assert.lengthOf(records, 1); - assert.include(encodeUnknownJson(records), '"itemCount":1'); - }), - ); - - it.effect("logs a structural tag when the native writer defects", () => { - const messages: Array = []; - const logCapture = Logger.make(({ message }) => { - if (Array.isArray(message)) { - messages.push(...message); - } else { - messages.push(message); - } - }); - const secret = "secret-writer-failure"; - - return Effect.gen(function* () { - const makeLogger = yield* makeAcpNativeLoggerFactory(); - const logger = makeLogger({ - nativeEventLogger: { - filePath: "/tmp/provider-native.ndjson", - write: () => Effect.die(new Error(secret)), - close: () => Effect.void, - }, - provider: ProviderDriverKind.make("cursor"), - threadId: ThreadId.make("thread-1"), - }); - const requestLogger = logger.requestLogger; - assert.exists(requestLogger); - if (!requestLogger) return; - - yield* requestLogger({ - method: "session/prompt", - payload: {}, - status: "started", - }); - - const serialized = encodeUnknownJson(messages); - assert.notInclude(serialized, secret); - assert.include(serialized, '"errorTag":"Die"'); - assert.include(serialized, '"reasonCount":1'); - }).pipe(Effect.provide(Logger.layer([logCapture], { mergeWithExisting: false }))); - }); - - it.effect("preserves native writer interruption", () => - Effect.gen(function* () { - const makeLogger = yield* makeAcpNativeLoggerFactory(); - const logger = makeLogger({ - nativeEventLogger: { - filePath: "/tmp/provider-native.ndjson", - write: () => Effect.interrupt, - close: () => Effect.void, - }, - provider: ProviderDriverKind.make("cursor"), - threadId: ThreadId.make("thread-1"), - }); - const requestLogger = logger.requestLogger; - assert.exists(requestLogger); - if (!requestLogger) return; - - const exit = yield* requestLogger({ - method: "session/prompt", - payload: {}, - status: "started", - }).pipe(Effect.exit); - - assert.isTrue(Exit.isFailure(exit)); - if (Exit.isFailure(exit)) { - assert.isTrue(Cause.hasInterruptsOnly(exit.cause)); - } - }), - ); -}); diff --git a/apps/server/src/provider/acp/AcpNativeLogging.ts b/apps/server/src/provider/acp/AcpNativeLogging.ts deleted file mode 100644 index a510c5f88b5a3f12ff05a213ca95710c0eec8a6e..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/acp/AcpNativeLogging.ts +++ /dev/null @@ -1,188 +0,0 @@ -import type { ProviderDriverKind, ThreadId } from "@t3tools/contracts"; -import { causeErrorTag, errorTag } from "@t3tools/shared/observability"; -import * as Cause from "effect/Cause"; -import * as Crypto from "effect/Crypto"; -import * as DateTime from "effect/DateTime"; -import * as Effect from "effect/Effect"; -import type * as EffectAcpProtocol from "effect-acp/protocol"; - -import type { EventNdjsonLogger } from "../EventNdjsonLogger.ts"; -import { - structuralProtocolMethod, - summarizeNativeProtocolPayload, -} from "@t3tools/provider-core/server/nativeProtocolLogging"; -import type * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; - -const transientProtocolUpdates = new Set(["agent_message_chunk", "agent_thought_chunk"]); - -function structuralMethod(value: string): string { - return value.length <= 128 && /^[A-Za-z][A-Za-z0-9._:/-]*$/.test(value) ? value : "unknown"; -} - -function summarizePayload(payload: unknown): Readonly> { - if (payload === null) return { valueType: "null" }; - if (typeof payload === "string") { - return { valueType: "string", byteLength: new TextEncoder().encode(payload).byteLength }; - } - if (payload instanceof Uint8Array) { - return { valueType: "bytes", byteLength: payload.byteLength }; - } - if (Array.isArray(payload)) { - return { valueType: "array", itemCount: payload.length }; - } - if (typeof payload !== "object") { - return { valueType: typeof payload }; - } - - try { - const record = payload as Record; - return { - valueType: "object", - fieldCount: Object.keys(record).length, - ...(typeof record._tag === "string" ? { messageTag: errorTag(record) } : {}), - ...(typeof record.tag === "string" ? { method: structuralMethod(record.tag) } : {}), - }; - } catch { - return { valueType: "object" }; - } -} - -function formatRequestLogPayload(event: AcpSessionRuntime.AcpSessionRequestLogEvent) { - return { - method: structuralProtocolMethod(event.method), - status: event.status, - request: summarizeNativeProtocolPayload(event.payload), - ...(event.result !== undefined ? { result: summarizeNativeProtocolPayload(event.result) } : {}), - ...(event.cause !== undefined - ? { - errorTag: causeErrorTag(event.cause), - reasonCount: event.cause.reasons.length, - } - : {}), - }; -} - -function formatAcpProtocolLogPayload(event: EffectAcpProtocol.AcpProtocolLogEvent) { - return { - direction: event.direction, - stage: event.stage, - payload: summarizeNativeProtocolPayload(event.payload), - }; -} - -function isTransientProtocolMessage(message: unknown): boolean { - if (typeof message !== "object" || message === null) return false; - const method = Reflect.get(message, "tag") ?? Reflect.get(message, "method"); - if (method !== "session/update") return false; - - const payload = Reflect.get(message, "payload") ?? Reflect.get(message, "params"); - if (typeof payload !== "object" || payload === null) return false; - const update = Reflect.get(payload, "update"); - if (typeof update !== "object" || update === null) return false; - const updateType = Reflect.get(update, "sessionUpdate"); - return typeof updateType === "string" && transientProtocolUpdates.has(updateType); -} - -function rawChunkContainsOnlyTransientMessages(payload: string): boolean { - const lines = payload.split("\n"); - const remainder = lines.pop() ?? ""; - if (remainder.trim().length > 0) return false; - - const messages: Array = []; - for (const line of lines) { - if (line.trim().length === 0) continue; - try { - messages.push(JSON.parse(line)); - } catch { - return false; - } - } - return messages.length > 0 && messages.every(isTransientProtocolMessage); -} - -function filterTransientProtocolLog( - event: EffectAcpProtocol.AcpProtocolLogEvent, -): EffectAcpProtocol.AcpProtocolLogEvent | undefined { - if (event.direction !== "incoming") return event; - - if (event.stage === "raw" && typeof event.payload === "string") { - return rawChunkContainsOnlyTransientMessages(event.payload) ? undefined : event; - } - - if (event.stage !== "decoded") return event; - if (!Array.isArray(event.payload)) { - return isTransientProtocolMessage(event.payload) ? undefined : event; - } - - const payload = event.payload.filter((message) => !isTransientProtocolMessage(message)); - return payload.length === 0 ? undefined : { ...event, payload }; -} - -export const makeAcpNativeLoggerFactory = Effect.fn("makeAcpNativeLoggerFactory")(function* () { - const crypto = yield* Crypto.Crypto; - return (input: { - readonly nativeEventLogger: EventNdjsonLogger | undefined; - readonly provider: ProviderDriverKind; - readonly threadId: ThreadId; - readonly verboseProtocolLogging?: boolean; - }): Pick => { - const writeNativeAcpLog = (logInput: { - readonly kind: "request" | "protocol"; - readonly payload: unknown; - }) => - Effect.gen(function* () { - if (!input.nativeEventLogger) return; - const observedAt = DateTime.formatIso(yield* DateTime.now); - yield* input.nativeEventLogger.write( - { - observedAt, - event: { - id: yield* crypto.randomUUIDv4, - kind: logInput.kind, - provider: input.provider, - createdAt: observedAt, - threadId: input.threadId, - payload: logInput.payload, - }, - }, - input.threadId, - ); - }).pipe( - Effect.catchCause((cause) => - Cause.hasInterrupts(cause) - ? Effect.interrupt - : Effect.logWarning("Failed to write native ACP event log.", { - errorTag: causeErrorTag(cause), - reasonCount: cause.reasons.length, - provider: input.provider, - threadId: input.threadId, - }), - ), - ); - - return { - requestLogger: (event: AcpSessionRuntime.AcpSessionRequestLogEvent) => - writeNativeAcpLog({ - kind: "request", - payload: formatRequestLogPayload(event), - }), - ...(input.nativeEventLogger && input.verboseProtocolLogging - ? { - protocolLogging: { - logIncoming: true, - logOutgoing: true, - logger: (event: EffectAcpProtocol.AcpProtocolLogEvent) => { - const filtered = filterTransientProtocolLog(event); - return filtered - ? writeNativeAcpLog({ - kind: "protocol", - payload: formatAcpProtocolLogPayload(filtered), - }) - : Effect.void; - }, - } satisfies NonNullable, - } - : {}), - }; - }; -}); diff --git a/apps/server/src/provider/acp/AcpRegistryAuth.test.ts b/apps/server/src/provider/acp/AcpRegistryAuth.test.ts index 5ba96b2fec149e72aa19739d03e3801000e3aa4f..f19be06f26341c31fb8479d605ba17df7974875a 100644 --- a/apps/server/src/provider/acp/AcpRegistryAuth.test.ts +++ b/apps/server/src/provider/acp/AcpRegistryAuth.test.ts @@ -16,7 +16,7 @@ import { AcpRequestError } from "effect-acp/errors"; import * as PtyAdapter from "../../terminal/PtyAdapter.ts"; import { makeAcpRegistryAuth } from "./AcpRegistryAuth.ts"; import * as AcpRegistrySupport from "./AcpRegistrySupport.ts"; -import type { AcpSessionRuntime } from "./AcpSessionRuntime.ts"; +import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; const decodeSettings = Schema.decodeSync(AcpRegistrySettings); const instanceId = ProviderInstanceId.make("acp-auth-test"); @@ -101,7 +101,7 @@ const makeHarness = ( authMethods: [{ ...method, name: `${method.name} ${version}` }], }; let elicitation: - | Parameters[0] + | Parameters[0] | undefined; return { initialize: () => Effect.succeed(initialized), diff --git a/apps/server/src/provider/acp/AcpRegistryAuth.ts b/apps/server/src/provider/acp/AcpRegistryAuth.ts index 6a885b71112069f276e197a9e4ca0dc1e6612789..3232b7b370a20a75d59b27a37c6afda9953cf235 100644 --- a/apps/server/src/provider/acp/AcpRegistryAuth.ts +++ b/apps/server/src/provider/acp/AcpRegistryAuth.ts @@ -20,7 +20,7 @@ import * as ProviderAuthFlow from "../ProviderAuthFlow.ts"; import { normalizeAcpRegistryAuthMethods, normalizeAcpRegistryWebUrl } from "./AcpRegistryProbe.ts"; import * as AcpRegistrySupport from "./AcpRegistrySupport.ts"; import * as AcpRegistryRuntimeCoordinator from "./AcpRegistryRuntimeCoordinator.ts"; -import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; +import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; type Runtime = Pick< AcpSessionRuntime.AcpSessionRuntime["Service"], diff --git a/apps/server/src/provider/acp/AcpRegistryProbe.test.ts b/apps/server/src/provider/acp/AcpRegistryProbe.test.ts index 9d5288c957ee58ba42bffd13bb0d349e80f6aa96..37066157fc0b8ac8d6b356e9fcb08eeb52f665ee 100644 --- a/apps/server/src/provider/acp/AcpRegistryProbe.test.ts +++ b/apps/server/src/provider/acp/AcpRegistryProbe.test.ts @@ -12,7 +12,7 @@ import * as Schema from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; import * as EffectAcpErrors from "effect-acp/errors"; -import type { AcpSessionRuntimeStartResult } from "./AcpSessionRuntime.ts"; +import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; import { acpRegistryProbeFailure, acpRegistryProbeResult, @@ -68,7 +68,7 @@ describe("ACP Registry probe", () => { ], }, modelConfigId: "model", - } satisfies AcpSessionRuntimeStartResult); + } satisfies AcpSessionRuntime.AcpSessionRuntimeStartResult); expect(result).toEqual({ instanceId, @@ -186,7 +186,7 @@ describe("ACP Registry probe", () => { ], }, modelConfigId: "model", - } satisfies AcpSessionRuntimeStartResult); + } satisfies AcpSessionRuntime.AcpSessionRuntimeStartResult); expect(result.models).toEqual([ { id: "sonnet", name: "Sonnet", description: "Balanced" }, @@ -216,7 +216,7 @@ describe("ACP Registry probe", () => { ], }, modelConfigId: "model", - } satisfies AcpSessionRuntimeStartResult); + } satisfies AcpSessionRuntime.AcpSessionRuntimeStartResult); expect(result.models).toEqual([{ id: "valid", name: "Valid", description: null }]); expect(result.currentModelId).toBeNull(); @@ -270,7 +270,7 @@ describe("ACP Registry probe", () => { ], }, modelConfigId: "model", - } satisfies AcpSessionRuntimeStartResult); + } satisfies AcpSessionRuntime.AcpSessionRuntimeStartResult); expect(result.models).toEqual([ { id: codexModel, name: "GPT-6.1 Sol", description: null }, @@ -312,7 +312,7 @@ describe("ACP Registry probe", () => { ], }, modelConfigId: "model", - } satisfies AcpSessionRuntimeStartResult); + } satisfies AcpSessionRuntime.AcpSessionRuntimeStartResult); expect(result.models).toHaveLength(256); expect(result.currentModelId).toBeNull(); diff --git a/apps/server/src/provider/acp/AcpRegistryProbe.ts b/apps/server/src/provider/acp/AcpRegistryProbe.ts index 0ee7ea1a878b0bde99d28d684150fecc06b253de..9fc142c35dc3685ee46c5311cd24c15263b40450 100644 --- a/apps/server/src/provider/acp/AcpRegistryProbe.ts +++ b/apps/server/src/provider/acp/AcpRegistryProbe.ts @@ -25,10 +25,10 @@ import type * as EffectAcpSchema from "effect-acp/compat"; import * as PtyAdapter from "../../terminal/PtyAdapter.ts"; import * as AcpRegistrySupport from "./AcpRegistrySupport.ts"; -import { parseSessionModeState } from "./AcpRuntimeModel.ts"; -import { acpProviderOptionDescriptors } from "./AcpSessionConfig.ts"; +import { parseSessionModeState } from "@t3tools/provider-acp/server/runtimeModel"; +import { acpProviderOptionDescriptors } from "@t3tools/provider-acp/server/sessionConfig"; import * as AcpRegistryRuntimeCoordinator from "./AcpRegistryRuntimeCoordinator.ts"; -import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; +import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; const MAX_AUTH_METHODS = 32; const MAX_MODELS = 256; diff --git a/apps/server/src/provider/acp/AcpRegistrySupport.ts b/apps/server/src/provider/acp/AcpRegistrySupport.ts index c62f93dae4006dc9a5e1162fd2054413f29c627a..dd4ef66f7876d36d151da14f3f3aca2dd31d57b4 100644 --- a/apps/server/src/provider/acp/AcpRegistrySupport.ts +++ b/apps/server/src/provider/acp/AcpRegistrySupport.ts @@ -43,7 +43,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { collectUint8StreamText } from "@t3tools/provider-core/server/collectStreamText"; import * as ServerSettings from "../../serverSettings.ts"; -import type { AcpSpawnInput } from "./AcpSessionRuntime.ts"; +import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; const ACP_REGISTRY_URL = "https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json"; @@ -444,7 +444,7 @@ export function resolveAcpRegistryDistribution(input: { export interface ResolvedAcpRegistryAgent { readonly agent?: AcpRegistryAgent; readonly distribution: AcpRegistryDistributionKind | "local"; - readonly spawn: AcpSpawnInput; + readonly spawn: AcpSessionRuntime.AcpSpawnInput; } export type AcpRegistryInspection = diff --git a/apps/server/src/provider/acp/AcpRuntimeModel.test.ts b/apps/server/src/provider/acp/AcpRuntimeModel.test.ts deleted file mode 100644 index 27cb3234b3752062848c6a1d0a1e8921ec908edf..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/acp/AcpRuntimeModel.test.ts +++ /dev/null @@ -1,1724 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import type * as EffectAcpSchema from "effect-acp/compat"; - -import { - decideToolCallUpdateEmission, - applyAcpAgentTerminalUpdate, - acpContentBlockDisplayText, - embeddedTerminalIdsFromSessionUpdate, - extractMcpToolCallIdentity, - extractModelConfigId, - mergeToolCallState, - parsePermissionRequest, - parseSessionModeState, - parseSessionUpdateEvent, - sessionModelStateFromInitialize, - sessionUpdateCountsAsLoadReplayActivity, - sessionUpdateIsReplay, - syntheticLoadSessionResponseFromInitialize, - toolCallProgressLength, - type AcpToolCallState, -} from "./AcpRuntimeModel.ts"; - -describe("AcpRuntimeModel", () => { - it("parses session mode state from typed ACP session setup responses", () => { - const modeState = parseSessionModeState({ - sessionId: "session-1", - modes: { - currentModeId: " code ", - availableModes: [ - { id: " ask ", name: " Ask ", description: " Request approval " }, - { id: " code ", name: " Code " }, - ], - }, - configOptions: [], - } satisfies EffectAcpSchema.NewSessionResponse); - - expect(modeState).toEqual({ - currentModeId: "code", - availableModes: [ - { id: "ask", name: "Ask", description: "Request approval" }, - { id: "code", name: "Code" }, - ], - }); - }); - - it("extracts the model config id from typed ACP config options", () => { - const modelConfigId = extractModelConfigId({ - sessionId: "session-1", - configOptions: [ - { - id: "approval", - name: "Approval Mode", - category: "permission", - type: "select", - currentValue: "ask", - options: [{ value: "ask", name: "Ask" }], - }, - { - id: "model", - name: "Model", - category: "model", - type: "select", - currentValue: "default", - options: [{ value: "default", name: "Auto" }], - }, - ], - } satisfies EffectAcpSchema.NewSessionResponse); - - expect(modelConfigId).toBe("model"); - }); - - it("detects Grok session replay updates from _meta.isReplay", () => { - expect( - sessionUpdateIsReplay({ - _meta: { isReplay: true }, - sessionId: "session-1", - update: { - sessionUpdate: "agent_message_chunk", - content: { type: "text", text: "replayed" }, - }, - } satisfies EffectAcpSchema.SessionNotification), - ).toBe(true); - expect( - sessionUpdateIsReplay({ - sessionId: "session-1", - update: { - sessionUpdate: "agent_message_chunk", - content: { type: "text", text: "live" }, - }, - } satisfies EffectAcpSchema.SessionNotification), - ).toBe(false); - }); - - it("ignores Grok keepalive chunks when tracking session/load replay activity", () => { - expect( - sessionUpdateCountsAsLoadReplayActivity({ - sessionId: "session-1", - update: { - sessionUpdate: "agent_message_chunk", - content: { type: "text", text: "" }, - }, - } satisfies EffectAcpSchema.SessionNotification), - ).toBe(false); - expect( - sessionUpdateCountsAsLoadReplayActivity({ - _meta: { isReplay: true }, - sessionId: "session-1", - update: { - sessionUpdate: "tool_call", - toolCallId: "replay-tool", - title: "Replay", - kind: "search", - status: "completed", - }, - } satisfies EffectAcpSchema.SessionNotification), - ).toBe(true); - }); - - it("ignores load-replay activity from other sessions while a load gate is active", () => { - expect( - sessionUpdateCountsAsLoadReplayActivity( - { - sessionId: "session-other", - update: { - sessionUpdate: "agent_message_chunk", - content: { type: "text", text: "unrelated" }, - }, - } satisfies EffectAcpSchema.SessionNotification, - "session-loading", - ), - ).toBe(false); - expect( - sessionUpdateCountsAsLoadReplayActivity( - { - sessionId: "session-loading", - update: { - sessionUpdate: "agent_message_chunk", - content: { type: "text", text: "replay" }, - }, - } satisfies EffectAcpSchema.SessionNotification, - "session-loading", - ), - ).toBe(true); - }); - - it("counts mode/config/session/usage updates as load-replay activity", () => { - expect( - sessionUpdateCountsAsLoadReplayActivity({ - sessionId: "session-1", - update: { - sessionUpdate: "current_mode_update", - currentModeId: "code", - }, - } satisfies EffectAcpSchema.SessionNotification), - ).toBe(true); - expect( - sessionUpdateCountsAsLoadReplayActivity({ - sessionId: "session-1", - update: { - sessionUpdate: "config_option_update", - configOptions: [], - }, - } satisfies EffectAcpSchema.SessionNotification), - ).toBe(true); - expect( - sessionUpdateCountsAsLoadReplayActivity({ - sessionId: "session-1", - update: { - sessionUpdate: "session_info_update", - title: "Restored", - }, - } satisfies EffectAcpSchema.SessionNotification), - ).toBe(true); - expect( - sessionUpdateCountsAsLoadReplayActivity({ - sessionId: "session-1", - update: { - sessionUpdate: "usage_update", - used: 1, - size: 10, - }, - } satisfies EffectAcpSchema.SessionNotification), - ).toBe(true); - }); - - it("ignores malformed initialize mode state in synthetic load responses", () => { - const response = syntheticLoadSessionResponseFromInitialize({ - protocolVersion: 1, - _meta: { - modeState: { - currentModeId: "code", - availableModes: [{ id: "code", name: 12 }], - }, - }, - } as EffectAcpSchema.InitializeResponse); - - expect(response.modes).toBeUndefined(); - expect(response._meta).toMatchObject({ t3SessionLoadReady: "replay_idle" }); - }); - - it("builds a synthetic load response with initialize mode state", () => { - const response = syntheticLoadSessionResponseFromInitialize({ - protocolVersion: 1, - _meta: { - modeState: { - currentModeId: "code", - availableModes: [ - { id: "ask", name: "Ask" }, - { id: "code", name: "Code" }, - ], - }, - }, - } satisfies EffectAcpSchema.InitializeResponse); - - expect(response.modes?.currentModeId).toBe("code"); - expect(response.modes?.availableModes).toHaveLength(2); - }); - - it("keeps initialize mode state whose modes have null descriptions", () => { - const response = syntheticLoadSessionResponseFromInitialize({ - protocolVersion: 1, - _meta: { - modeState: { - currentModeId: "code", - availableModes: [ - { id: "ask", name: "Ask", description: null }, - { id: "code", name: "Code", description: "Edit files", _meta: null }, - ], - _meta: null, - }, - }, - } satisfies EffectAcpSchema.InitializeResponse); - - expect(parseSessionModeState(response)).toEqual({ - currentModeId: "code", - availableModes: [ - { id: "ask", name: "Ask" }, - { id: "code", name: "Code", description: "Edit files" }, - ], - }); - }); - - it("reads initialize model state and ignores malformed model state", () => { - const modelState = { - currentModelId: "grok-4.7", - availableModels: [ - { - modelId: "grok-4.7", - name: "Grok 4.7", - description: null, - _meta: { reasoningEfforts: [{ id: "high", default: true }] }, - }, - { modelId: "grok-4.6", name: "Grok 4.6" }, - ], - }; - - expect(sessionModelStateFromInitialize({ protocolVersion: 1, _meta: { modelState } })).toEqual( - modelState, - ); - expect( - sessionModelStateFromInitialize({ - protocolVersion: 1, - _meta: { - modelState: { currentModelId: "grok-4.7", availableModels: [{ modelId: "grok-4.7" }] }, - }, - }), - ).toBeUndefined(); - expect( - sessionModelStateFromInitialize({ - protocolVersion: 1, - _meta: { modelState: { ...modelState, _meta: "not an object" } }, - }), - ).toBeUndefined(); - }); - - it("projects typed ACP tool call updates into runtime events", () => { - const created = parseSessionUpdateEvent({ - sessionId: "session-1", - update: { - sessionUpdate: "tool_call", - toolCallId: "tool-1", - title: "Terminal", - kind: "execute", - status: "pending", - rawInput: { - executable: "bun", - args: ["run", "typecheck"], - }, - content: [ - { - type: "content", - content: { - type: "text", - text: "Running checks", - }, - }, - ], - }, - } satisfies EffectAcpSchema.SessionNotification); - - expect(created.events).toEqual([ - { - _tag: "ToolCallUpdated", - toolCall: { - toolCallId: "tool-1", - kind: "execute", - title: "Ran command", - status: "pending", - command: "bun run typecheck", - detail: "bun run typecheck", - data: { - toolCallId: "tool-1", - kind: "execute", - title: "Terminal", - command: "bun run typecheck", - rawInput: { - executable: "bun", - args: ["run", "typecheck"], - }, - content: [ - { - type: "content", - content: { - type: "text", - text: "Running checks", - }, - }, - ], - }, - }, - rawPayload: { - sessionId: "session-1", - update: { - sessionUpdate: "tool_call", - toolCallId: "tool-1", - title: "Terminal", - kind: "execute", - status: "pending", - rawInput: { - executable: "bun", - args: ["run", "typecheck"], - }, - content: [ - { - type: "content", - content: { - type: "text", - text: "Running checks", - }, - }, - ], - }, - }, - }, - ]); - - const updated = parseSessionUpdateEvent({ - sessionId: "session-1", - update: { - sessionUpdate: "tool_call_update", - toolCallId: "tool-1", - status: "completed", - rawOutput: { exitCode: 0 }, - }, - } satisfies EffectAcpSchema.SessionNotification); - - expect(updated.events).toHaveLength(1); - expect(updated.events[0]?._tag).toBe("ToolCallUpdated"); - const createdEvent = created.events[0]; - const updatedEvent = updated.events[0]; - if (createdEvent?._tag === "ToolCallUpdated" && updatedEvent?._tag === "ToolCallUpdated") { - expect(mergeToolCallState(createdEvent.toolCall, updatedEvent.toolCall)).toMatchObject({ - toolCallId: "tool-1", - status: "completed", - title: "Ran command", - detail: "bun run typecheck", - command: "bun run typecheck", - }); - } - }); - - it("clears prior ACP locations when an update explicitly replaces them", () => { - const toolCall = (locations?: EffectAcpSchema.ToolCallLocation[] | null) => { - const parsed = parseSessionUpdateEvent({ - sessionId: "session-1", - update: { - sessionUpdate: "tool_call_update", - toolCallId: "read-1", - kind: "read", - ...(locations === undefined ? {} : { locations }), - }, - } satisfies EffectAcpSchema.SessionNotification); - const event = parsed.events.find((candidate) => candidate._tag === "ToolCallUpdated"); - if (!event || event._tag !== "ToolCallUpdated") { - throw new Error("expected a tool call update"); - } - return event.toolCall; - }; - - const created = toolCall([{ path: "README" }]); - expect(mergeToolCallState(created, toolCall()).data.locations).toEqual([{ path: "README" }]); - const cleared = mergeToolCallState(created, toolCall([])); - expect(cleared.data.locations).toEqual([]); - expect(mergeToolCallState(created, toolCall(null)).data.locations).toEqual([]); - expect( - decideToolCallUpdateEmission({ - previous: created, - next: cleared, - lastEmittedDetailLength: 0, - skippedSinceEmit: 0, - }).emit, - ).toBe(true); - }); - - it("trims padded current mode updates before emitting a mode change", () => { - const result = parseSessionUpdateEvent({ - sessionId: "session-1", - update: { - sessionUpdate: "current_mode_update", - currentModeId: " code ", - }, - } satisfies EffectAcpSchema.SessionNotification); - - expect(result.modeId).toBe("code"); - expect(result.events).toEqual([ - { - _tag: "ModeChanged", - modeId: "code", - }, - ]); - }); - - it("projects typed ACP plan and content updates", () => { - const planResult = parseSessionUpdateEvent({ - sessionId: "session-1", - update: { - sessionUpdate: "plan", - entries: [ - { content: " Inspect state ", priority: "high", status: "completed" }, - { content: "", priority: "medium", status: "in_progress" }, - ], - }, - } satisfies EffectAcpSchema.SessionNotification); - - expect(planResult.events).toEqual([ - { - _tag: "PlanUpdated", - payload: { - nativePlanId: "legacy", - kind: "items", - plan: [ - { step: "Inspect state", status: "completed" }, - { step: "Step 2", status: "inProgress" }, - ], - }, - rawPayload: { - sessionId: "session-1", - update: { - sessionUpdate: "plan", - entries: [ - { content: " Inspect state ", priority: "high", status: "completed" }, - { content: "", priority: "medium", status: "in_progress" }, - ], - }, - }, - }, - ]); - - const contentResult = parseSessionUpdateEvent({ - sessionId: "session-1", - update: { - sessionUpdate: "agent_message_chunk", - content: { - type: "text", - text: "hello from acp", - }, - }, - } satisfies EffectAcpSchema.SessionNotification); - - expect(contentResult.events).toEqual([ - { - _tag: "ContentDelta", - text: "hello from acp", - rawPayload: { - sessionId: "session-1", - update: { - sessionUpdate: "agent_message_chunk", - content: { - type: "text", - text: "hello from acp", - }, - }, - }, - }, - ]); - }); - - it("keeps thought chunks separate from assistant text", () => { - const notification = { - sessionId: "session-1", - update: { - sessionUpdate: "agent_thought_chunk", - content: { type: "text", text: "Inspect the current implementation first." }, - }, - } satisfies EffectAcpSchema.SessionNotification; - - expect(parseSessionUpdateEvent(notification).events).toEqual([ - { - _tag: "ThoughtDelta", - text: "Inspect the current implementation first.", - rawPayload: notification, - }, - ]); - }); - - it("preserves native command inputs and empty command lists", () => { - const availableCommands = [ - { name: "plan", description: "Plan a task", input: { type: "text", hint: "task" } }, - { name: "logout", description: "Sign out" }, - ] satisfies ReadonlyArray; - - for (const commands of [availableCommands, []]) { - const notification = { - sessionId: "session-1", - update: { sessionUpdate: "available_commands_update", availableCommands: commands }, - } satisfies EffectAcpSchema.SessionNotification; - expect(parseSessionUpdateEvent(notification).events).toEqual([ - { - _tag: "AvailableCommandsUpdated", - availableCommands: commands, - rawPayload: notification, - }, - ]); - } - }); - - it("keeps permission request parsing compatible with loose extension payloads", () => { - const request = parsePermissionRequest({ - sessionId: "session-1", - options: [ - { - optionId: "allow-once", - name: "Allow once", - kind: "allow_once", - }, - ], - toolCall: { - toolCallId: "tool-1", - title: "`cat package.json`", - kind: "execute", - status: "pending", - content: [ - { - type: "content", - content: { - type: "text", - text: "Not in allowlist", - }, - }, - ], - }, - }); - - expect(request).toMatchObject({ - kind: "execute", - detail: "cat package.json", - toolCall: { - toolCallId: "tool-1", - kind: "execute", - status: "pending", - command: "cat package.json", - }, - }); - }); - - it("bounds an oversized cumulative tool_call_update content buffer to a tail window", () => { - // Mirrors Grok's ACP CLI resending the ENTIRE accumulated terminal output on every - // tool_call_update notification instead of a delta (see upstream #6556). - const hugeText = Array.from({ length: 2_000 }, (_, i) => `line ${i}: ${"x".repeat(50)}`).join( - "\n", - ); - expect(hugeText.length).toBeGreaterThan(60_000); - - const result = parseSessionUpdateEvent({ - sessionId: "session-1", - update: { - // Real ACP `tool_call_update` deltas typically omit `title` (already established by - // the initial `tool_call`); that is also the shape that surfaces raw content as detail. - sessionUpdate: "tool_call_update", - toolCallId: "tool-1", - kind: "other", - status: "in_progress", - content: [{ type: "content", content: { type: "text", text: hugeText } }], - }, - } satisfies EffectAcpSchema.SessionNotification); - - expect(result.events).toHaveLength(1); - const event = result.events[0]; - if (event?._tag !== "ToolCallUpdated") { - throw new Error("expected a ToolCallUpdated event"); - } - - expect(event.toolCall.detail).toBeDefined(); - const detail = event.toolCall.detail!; - // 8000 chars of tail plus the truncation marker, regardless of input size. - expect(detail.length).toBe(8_028); - expect(detail.startsWith("[Earlier output truncated]")).toBe(true); - expect(detail.endsWith(hugeText.slice(-100))).toBe(true); - - // The raw payload threaded through for logging/persistence must not smuggle the full - // cumulative buffer back in either. - const rawUpdate = ( - event.rawPayload as { - readonly update: { - readonly content: ReadonlyArray<{ readonly content: { text: string } }>; - }; - } - ).update; - expect(rawUpdate.content[0]?.content.text.length).toBeLessThan(8_100); - expect(JSON.stringify(event).length).toBeLessThan(hugeText.length); - }); - - it("coalesces 1000 rapid cumulative tool_call_update notifications for a redrawing progress bar", () => { - let previous: AcpToolCallState | undefined; - let lastEmittedDetailLength: number | undefined; - let skippedSinceEmit = 0; - let emittedCount = 0; - let emittedBytes = 0; - let notificationBytes = 0; - let largestEmittedEventBytes = 0; - let finalDetail: string | undefined; - let cumulativeBuffer = ""; - - for (let i = 0; i < 1_000; i += 1) { - // Grok resends the FULL accumulated buffer, not a delta, on every redraw. - cumulativeBuffer += `frame ${i}: ${"#".repeat(50)}\n`; - const isLast = i === 999; - - const notification = { - sessionId: "session-1", - update: { - sessionUpdate: "tool_call_update", - toolCallId: "tool-1", - kind: "other", - status: isLast ? "completed" : "in_progress", - content: [{ type: "content", content: { type: "text", text: cumulativeBuffer } }], - }, - } satisfies EffectAcpSchema.SessionNotification; - notificationBytes += JSON.stringify(notification).length; - - const { events } = parseSessionUpdateEvent(notification); - - const event = events[0]; - if (event?._tag !== "ToolCallUpdated") { - continue; - } - - const merged = mergeToolCallState(previous, event.toolCall); - const decision = decideToolCallUpdateEmission({ - previous, - next: merged, - lastEmittedDetailLength, - skippedSinceEmit, - }); - previous = merged; - skippedSinceEmit = decision.skippedSinceEmit; - if (decision.emit) { - emittedCount += 1; - const eventBytes = JSON.stringify({ - toolCall: merged, - rawPayload: event.rawPayload, - }).length; - emittedBytes += eventBytes; - largestEmittedEventBytes = Math.max(largestEmittedEventBytes, eventBytes); - lastEmittedDetailLength = merged.detail?.length; - finalDetail = merged.detail; - } - } - - // The flood as the CLI sends it: 1000 cumulative redraws, ~31.6 MB of JSON. - expect(notificationBytes).toBeGreaterThan(31_000_000); - - // 1000 cumulative redraws collapse into a fixed, small number of runtime events... - expect(emittedCount).toBe(114); - // ...each individually bounded, no matter how long the tool call runs... - expect(largestEmittedEventBytes).toBeLessThan(25_000); - // ...so the whole flooding tool call costs ~2.5 MB of runtime events instead of ~31.6 MB. - expect(emittedBytes).toBeLessThan(2_600_000); - // ...while the FINAL state (forced by the completed status) still reflects the real, - // latest output rather than a stale coalesced value. - expect(finalDetail).toBeDefined(); - expect(finalDetail?.endsWith(`frame 999: ${"#".repeat(50)}`)).toBe(true); - }); - - it("keeps non-text tool call content entries in order when bounding oversized text", () => { - const hugePrefix = "x".repeat(25_000); - const hugeTail = "y".repeat(25_000); - const { events } = parseSessionUpdateEvent({ - sessionId: "session-1", - update: { - sessionUpdate: "tool_call_update", - toolCallId: "tool-1", - kind: "edit", - status: "in_progress", - content: [ - { type: "content", content: { type: "text", text: hugePrefix } }, - { type: "diff", path: "/repo/file.ts", oldText: "before", newText: "after" }, - { type: "content", content: { type: "text", text: hugeTail } }, - { type: "diff", path: "/repo/other.ts", oldText: "old", newText: "new" }, - { type: "content", content: { type: "text", text: " " } }, - ], - }, - } satisfies EffectAcpSchema.SessionNotification); - - const event = events[0]; - if (event?._tag !== "ToolCallUpdated") { - throw new Error("expected a ToolCallUpdated event"); - } - const content = event.toolCall.data.content as ReadonlyArray; - expect(content).toHaveLength(3); - expect(content[0]).toEqual({ - type: "diff", - path: "/repo/file.ts", - oldText: "before", - newText: "after", - }); - const lastEntry = content[1]; - if (lastEntry?.type !== "content" || lastEntry.content.type !== "text") { - throw new Error("expected a bounded text entry"); - } - expect(lastEntry.content.text.length).toBeLessThan(8_100); - expect(lastEntry.content.text.endsWith(hugeTail.slice(-100))).toBe(true); - expect(content[2]).toEqual({ - type: "diff", - path: "/repo/other.ts", - oldText: "old", - newText: "new", - }); - }); - - it("keeps a retained tail on the original text entries around non-text content", () => { - const prefix = "a".repeat(4_000); - const suffix = "b".repeat(5_000); - const { events } = parseSessionUpdateEvent({ - sessionId: "session-1", - update: { - sessionUpdate: "tool_call_update", - toolCallId: "tool-1", - kind: "edit", - status: "in_progress", - content: [ - { type: "content", content: { type: "text", text: prefix } }, - { type: "diff", path: "/repo/file.ts", oldText: "before", newText: "after" }, - { type: "content", content: { type: "text", text: suffix } }, - ], - }, - } satisfies EffectAcpSchema.SessionNotification); - - const event = events[0]; - if (event?._tag !== "ToolCallUpdated") { - throw new Error("expected a ToolCallUpdated event"); - } - const content = event.toolCall.data.content as ReadonlyArray; - expect(content).toHaveLength(3); - const firstText = content[0]; - if (firstText?.type !== "content" || firstText.content.type !== "text") { - throw new Error("expected a bounded prefix text entry"); - } - expect(firstText.content.text.startsWith("[Earlier output truncated]")).toBe(true); - expect(firstText.content.text.endsWith("a".repeat(100))).toBe(true); - expect(content[1]).toEqual({ - type: "diff", - path: "/repo/file.ts", - oldText: "before", - newText: "after", - }); - expect(content[2]).toEqual({ - type: "content", - content: { type: "text", text: suffix }, - }); - }); - - it("bounds oversized whitespace-only tool call content that has no trimmed text", () => { - // Whitespace-only entries are skipped when extracting display text (`chunks.length === 0`) - // and used to be returned unchanged, which let a redrawing terminal persist unbounded - // buffers on `toolCall.data.content` and `rawPayload`. - const hugeWhitespace = " \n\t".repeat(30_000); - expect(hugeWhitespace.length).toBeGreaterThan(60_000); - - const { events } = parseSessionUpdateEvent({ - sessionId: "session-1", - update: { - sessionUpdate: "tool_call_update", - toolCallId: "tool-1", - kind: "other", - status: "in_progress", - content: [{ type: "content", content: { type: "text", text: hugeWhitespace } }], - }, - } satisfies EffectAcpSchema.SessionNotification); - - const event = events[0]; - if (event?._tag !== "ToolCallUpdated") { - throw new Error("expected a ToolCallUpdated event"); - } - - expect(event.toolCall.detail).toBeUndefined(); - const content = event.toolCall.data.content as ReadonlyArray; - const textEntry = content[0]; - if (textEntry?.type !== "content" || textEntry.content.type !== "text") { - throw new Error("expected a bounded text entry"); - } - expect(textEntry.content.text.length).toBeLessThan(8_100); - expect(textEntry.content.text.startsWith("[Earlier output truncated]")).toBe(true); - - const rawUpdate = ( - event.rawPayload as { - readonly update: { - readonly content: ReadonlyArray<{ readonly content: { text: string } }>; - }; - } - ).update; - expect(rawUpdate.content[0]?.content.text.length).toBeLessThan(8_100); - expect(JSON.stringify(event).length).toBeLessThan(hugeWhitespace.length); - }); - - it("bounds oversized whitespace-padded text entries even when trimmed content fits", () => { - const padded = `${" ".repeat(40_000)}ok${" ".repeat(40_000)}`; - expect(padded.length).toBeGreaterThan(60_000); - - const { events } = parseSessionUpdateEvent({ - sessionId: "session-1", - update: { - sessionUpdate: "tool_call_update", - toolCallId: "tool-1", - kind: "other", - status: "in_progress", - content: [{ type: "content", content: { type: "text", text: padded } }], - }, - } satisfies EffectAcpSchema.SessionNotification); - - const event = events[0]; - if (event?._tag !== "ToolCallUpdated") { - throw new Error("expected a ToolCallUpdated event"); - } - - expect(event.toolCall.detail).toBe("ok"); - const content = event.toolCall.data.content as ReadonlyArray; - const textEntry = content[0]; - if (textEntry?.type !== "content" || textEntry.content.type !== "text") { - throw new Error("expected a bounded text entry"); - } - expect(textEntry.content.text).toBe("ok"); - - const rawUpdate = ( - event.rawPayload as { - readonly update: { - readonly content: ReadonlyArray<{ readonly content: { text: string } }>; - }; - } - ).update; - expect(rawUpdate.content[0]?.content.text).toBe("ok"); - expect(JSON.stringify(event).length).toBeLessThan(padded.length); - }); - - describe("decideToolCallUpdateEmission", () => { - const toolCall = (detail: string | undefined, status?: AcpToolCallState["status"]) => - ({ - toolCallId: "tool-1", - title: "Grok Tool", - ...(status ? { status } : {}), - ...(detail ? { detail } : {}), - data: {}, - }) satisfies AcpToolCallState; - - it("emits the first in-progress tool_call even when it has no detail", () => { - expect( - decideToolCallUpdateEmission({ - previous: undefined, - next: { toolCallId: "tool-1", title: "Grok Tool", status: "pending", data: {} }, - lastEmittedDetailLength: undefined, - skippedSinceEmit: 0, - }), - ).toEqual({ emit: true, skippedSinceEmit: 0 }); - }); - - it("always emits terminal (completed/failed) status updates regardless of growth", () => { - expect( - decideToolCallUpdateEmission({ - previous: toolCall("same", "inProgress"), - next: toolCall("same", "completed"), - lastEmittedDetailLength: 4, - skippedSinceEmit: 0, - }), - ).toEqual({ emit: true, skippedSinceEmit: 0 }); - - expect( - decideToolCallUpdateEmission({ - previous: toolCall("same", "inProgress"), - next: toolCall("same", "failed"), - lastEmittedDetailLength: 4, - skippedSinceEmit: 3, - }), - ).toEqual({ emit: true, skippedSinceEmit: 0 }); - }); - - it("skips updates whose bounded detail did not change", () => { - const previous = toolCall("frame 1", "inProgress"); - expect( - decideToolCallUpdateEmission({ - previous, - next: previous, - lastEmittedDetailLength: 7, - skippedSinceEmit: 0, - }), - ).toEqual({ emit: false, skippedSinceEmit: 0 }); - }); - - it("coalesces command-tool updates whose content grew while detail stayed the command", () => { - const commandCall = (stdout: string): AcpToolCallState => ({ - toolCallId: "tool-1", - title: "Ran command", - status: "inProgress", - command: "ls", - detail: "ls", - data: { - command: "ls", - content: [{ type: "content", content: { type: "text", text: stdout } }], - }, - }); - - let previous: AcpToolCallState | undefined; - let lastEmittedDetailLength: number | undefined; - let skippedSinceEmit = 0; - const emissions: Array = []; - - for (let i = 1; i <= 12; i += 1) { - const next = commandCall("x".repeat(i)); - const decision = decideToolCallUpdateEmission({ - previous, - next, - lastEmittedDetailLength, - skippedSinceEmit, - }); - emissions.push(decision.emit); - skippedSinceEmit = decision.skippedSinceEmit; - if (decision.emit) { - lastEmittedDetailLength = toolCallProgressLength(next); - } - previous = next; - } - - const emittedIndices = emissions.flatMap((emitted, index) => (emitted ? [index + 1] : [])); - expect(emittedIndices).toEqual([1, 11]); - }); - - it("emits pending to inProgress status changes even when detail and output are unchanged", () => { - expect( - decideToolCallUpdateEmission({ - previous: toolCall("same", "pending"), - next: toolCall("same", "inProgress"), - lastEmittedDetailLength: 4, - skippedSinceEmit: 0, - }), - ).toEqual({ emit: true, skippedSinceEmit: 0 }); - }); - - it("emits immediately when the title changes, even with no growth", () => { - const decision = decideToolCallUpdateEmission({ - previous: { toolCallId: "tool-1", title: "Reading file", detail: "x", data: {} }, - next: { toolCallId: "tool-1", title: "Ran command", detail: "x", data: {} }, - lastEmittedDetailLength: 1, - skippedSinceEmit: 0, - }); - expect(decision).toEqual({ emit: true, skippedSinceEmit: 0 }); - }); - - it("coalesces small deltas but forces an emission after the coalesce limit", () => { - let lastEmittedDetailLength: number | undefined = 0; - let skippedSinceEmit = 0; - const emissions: Array = []; - let previous: AcpToolCallState | undefined; - - for (let i = 1; i <= 12; i += 1) { - // Grows by 1 char per update — well under the 256-char growth threshold, so this - // exercises the coalesce-count fallback rather than the growth-based trigger. - const next = toolCall("x".repeat(i), "inProgress"); - const decision = decideToolCallUpdateEmission({ - previous, - next, - lastEmittedDetailLength, - skippedSinceEmit, - }); - emissions.push(decision.emit); - skippedSinceEmit = decision.skippedSinceEmit; - if (decision.emit) { - lastEmittedDetailLength = next.detail?.length; - } - previous = next; - } - - // First update always emits (no previous state yet); after that, small per-update - // growth should be coalesced until the coalesce limit forces a periodic emission. - const emittedIndices = emissions.flatMap((emitted, index) => (emitted ? [index + 1] : [])); - expect(emittedIndices).toEqual([1, 11]); - }); - - it("retains the latest replacement snapshot when equal-length updates are coalesced", () => { - let previous: AcpToolCallState = toolCall("frame-a", "inProgress"); - const lastEmittedDetailLength = previous.detail?.length; - let skippedSinceEmit = 0; - - for (const detail of ["frame-b", "frame-c"]) { - const next = mergeToolCallState(previous, toolCall(detail, "inProgress")); - const decision = decideToolCallUpdateEmission({ - previous, - next, - lastEmittedDetailLength, - skippedSinceEmit, - }); - expect(decision.emit).toBe(false); - skippedSinceEmit = decision.skippedSinceEmit; - previous = next; - } - - const completed = mergeToolCallState(previous, toolCall(undefined, "completed")); - expect(completed.detail).toBe("frame-c"); - expect( - decideToolCallUpdateEmission({ - previous, - next: completed, - lastEmittedDetailLength, - skippedSinceEmit, - }), - ).toEqual({ emit: true, skippedSinceEmit: 0 }); - }); - }); - - it("preserves message identity, usage, metadata, and non-text output", () => { - const message = parseSessionUpdateEvent({ - sessionId: "session-1", - update: { - sessionUpdate: "agent_message_chunk", - messageId: "message-1", - content: { - type: "resource_link", - name: "report.txt", - title: "Report", - description: "Generated artifact", - uri: "file:///workspace/report.txt", - }, - }, - } satisfies EffectAcpSchema.SessionNotification); - const usage = parseSessionUpdateEvent({ - sessionId: "session-1", - update: { - sessionUpdate: "usage_update", - used: 2_500, - size: 10_000, - cost: { amount: 0.42, currency: "USD" }, - }, - } satisfies EffectAcpSchema.SessionNotification); - const metadata = parseSessionUpdateEvent({ - sessionId: "session-1", - update: { - sessionUpdate: "session_info_update", - title: " Native session ", - updatedAt: " 2026-08-23T00:00:00Z ", - }, - } satisfies EffectAcpSchema.SessionNotification); - - expect(message.events[0]).toMatchObject({ - _tag: "ContentDelta", - messageId: "message-1", - text: "Report: Generated artifact\nfile:///workspace/report.txt", - }); - expect(usage.events[0]).toMatchObject({ - _tag: "UsageUpdated", - usage: { - usedTokens: 2_500, - maxTokens: 10_000, - cost: { amount: 0.42, currency: "USD" }, - }, - }); - expect(metadata.events[0]).toMatchObject({ - _tag: "SessionInfoUpdated", - metadata: { - title: "Native session", - updatedAt: "2026-08-23T00:00:00Z", - }, - }); - }); - - it("projects ACP v2 plan replacement and removal updates", () => { - const replaced = parseSessionUpdateEvent({ - sessionId: "session-1", - update: { - sessionUpdate: "plan_update", - plan: { - type: "items", - planId: "plan-1", - entries: [ - { content: " Inspect schema ", priority: "high", status: "completed" }, - { content: "Ship integration", priority: "high", status: "in_progress" }, - ], - }, - }, - } satisfies EffectAcpSchema.SessionNotification); - const removed = parseSessionUpdateEvent({ - sessionId: "session-1", - update: { sessionUpdate: "plan_removed", planId: "plan-1" }, - } satisfies EffectAcpSchema.SessionNotification); - - expect(replaced.events[0]).toMatchObject({ - _tag: "PlanUpdated", - payload: { - nativePlanId: "plan-1", - kind: "items", - plan: [ - { step: "Inspect schema", status: "completed" }, - { step: "Ship integration", status: "inProgress" }, - ], - }, - }); - expect(removed.events[0]).toMatchObject({ - _tag: "PlanUpdated", - payload: { nativePlanId: "plan-1", kind: "removed" }, - }); - }); - - it("preserves markdown, file, and future ACP plan variants by plan ID", () => { - const updates = [ - { - sessionId: "session-1", - update: { - sessionUpdate: "plan_update", - plan: { type: "markdown", planId: "plan-markdown", content: "# Ship it" }, - }, - }, - { - sessionId: "session-1", - update: { - sessionUpdate: "plan_update", - plan: { type: "file", planId: "plan-file", uri: "file:///workspace/PLAN.md" }, - }, - }, - { - sessionId: "session-1", - update: { - sessionUpdate: "plan_update", - plan: { type: "diagram", planId: "plan-future", nodes: [] }, - }, - }, - ] as const; - - expect(updates.flatMap((update) => parseSessionUpdateEvent(update).events)).toMatchObject([ - { - _tag: "PlanUpdated", - payload: { nativePlanId: "plan-markdown", kind: "markdown", markdown: "# Ship it" }, - }, - { - _tag: "PlanUpdated", - payload: { - nativePlanId: "plan-file", - kind: "file", - uri: "file:///workspace/PLAN.md", - }, - }, - { - _tag: "PlanUpdated", - payload: { nativePlanId: "plan-future", kind: "unknown", contentType: "diagram" }, - }, - ]); - }); - - it("turns future ACP content and session updates into explicit placeholders", () => { - expect( - acpContentBlockDisplayText({ - type: "_t3_unknown", - originalType: "chart", - raw: { type: "chart", points: [] }, - }), - ).toBe("[Unsupported ACP content: chart]"); - - expect( - parseSessionUpdateEvent({ - sessionId: "session-1", - update: { - sessionUpdate: "_t3_unknown", - originalSessionUpdate: "timeline_update", - raw: { sessionUpdate: "timeline_update", entries: [] }, - }, - }).events, - ).toMatchObject([{ _tag: "UnknownUpdate", updateType: "timeline_update" }]); - }); - - it("projects ACP v2 compaction lifecycle and prompt usage", () => { - const compacting = parseSessionUpdateEvent({ - sessionId: "session-1", - update: { - sessionUpdate: "compaction_update", - compactionId: "compact-1", - status: "in_progress", - }, - } satisfies EffectAcpSchema.SessionNotification); - const compacted = parseSessionUpdateEvent({ - sessionId: "session-1", - update: { - sessionUpdate: "compaction_update", - compactionId: "compact-1", - status: "completed", - summary: [{ type: "text", text: "Retained the implementation decisions." }], - }, - } satisfies EffectAcpSchema.SessionNotification); - const idle = parseSessionUpdateEvent({ - sessionId: "session-1", - update: { - sessionUpdate: "state_update", - state: "idle", - stopReason: "end_turn", - usage: { totalTokens: 420, inputTokens: 300, outputTokens: 100, thoughtTokens: 20 }, - }, - } satisfies EffectAcpSchema.SessionNotification); - - expect(compacting.events[0]).toMatchObject({ - _tag: "ToolCallUpdated", - toolCall: { - toolCallId: "acp-compaction:compact-1", - kind: "think", - title: "Compact context", - status: "inProgress", - }, - }); - expect(compacted.events[0]).toMatchObject({ - _tag: "ToolCallUpdated", - toolCall: { - toolCallId: "acp-compaction:compact-1", - status: "completed", - data: { rawOutput: "Retained the implementation decisions." }, - }, - }); - expect(idle.events[0]).toMatchObject({ - _tag: "UsageUpdated", - usage: { usedTokens: 420 }, - }); - }); - - it("applies ACP v2 agent-owned terminal snapshots and output chunks", () => { - const snapshot = applyAcpAgentTerminalUpdate(undefined, { - sessionUpdate: "terminal_update", - terminalId: "terminal-1", - command: "pnpm test", - cwd: "/workspace", - output: { data: Buffer.from("first line\n").toString("base64") }, - }); - const chunked = applyAcpAgentTerminalUpdate(snapshot, { - sessionUpdate: "terminal_output_chunk", - terminalId: "terminal-1", - data: Buffer.from("second line\n").toString("base64"), - }); - const completed = applyAcpAgentTerminalUpdate(chunked, { - sessionUpdate: "terminal_update", - terminalId: "terminal-1", - exitStatus: { exitCode: 0 }, - }); - - expect(completed).toEqual({ - command: "pnpm test", - cwd: "/workspace", - output: "first line\nsecond line\n", - exitStatus: { exitCode: 0 }, - }); - }); - - it("preserves UTF-8 characters split across ACP terminal output chunks", () => { - const bytes = Buffer.from("A🙂B", "utf8"); - const first = applyAcpAgentTerminalUpdate(undefined, { - sessionUpdate: "terminal_output_chunk", - terminalId: "terminal-utf8", - data: bytes.subarray(0, 3).toString("base64"), - }); - const second = applyAcpAgentTerminalUpdate(first, { - sessionUpdate: "terminal_output_chunk", - terminalId: "terminal-utf8", - data: bytes.subarray(3).toString("base64"), - }); - - expect(first.output).toBe("A"); - expect(first.pendingOutputBytes).toBeDefined(); - expect(second.output).toBe("A🙂B"); - expect(second.pendingOutputBytes).toBeUndefined(); - }); - - it("drops malformed and oversized ACP terminal frames without decoding them", () => { - const seeded = applyAcpAgentTerminalUpdate(undefined, { - sessionUpdate: "terminal_output_chunk", - terminalId: "terminal-invalid", - data: Buffer.from("kept").toString("base64"), - }); - - expect( - applyAcpAgentTerminalUpdate(seeded, { - sessionUpdate: "terminal_output_chunk", - terminalId: "terminal-invalid", - data: "not base64!", - }), - ).toBe(seeded); - - expect( - applyAcpAgentTerminalUpdate(seeded, { - sessionUpdate: "terminal_output_chunk", - terminalId: "terminal-oversized", - data: Buffer.alloc(16 * 1024 * 1024 + 1).toString("base64"), - }), - ).toBe(seeded); - }); - - it("projects binary ACP content without retaining encoded payloads", () => { - const encodedPayload = "sensitive-base64-payload"; - const binary = acpContentBlockDisplayText({ - type: "resource", - resource: { - blob: encodedPayload, - mimeType: "application/octet-stream", - uri: "file:///workspace/archive.bin", - }, - }); - const image = acpContentBlockDisplayText({ - type: "image", - data: encodedPayload, - mimeType: "image/png", - uri: "file:///workspace/image.png", - }); - const audio = acpContentBlockDisplayText({ - type: "audio", - data: encodedPayload, - mimeType: "audio/wav", - }); - - expect(binary).toBe( - "[ACP binary resource (application/octet-stream): file:///workspace/archive.bin]", - ); - expect(image).toBe("[ACP image (image/png): file:///workspace/image.png]"); - expect(audio).toBe("[ACP audio (audio/wav)]"); - expect(`${binary}${image}${audio}`).not.toContain(encodedPayload); - - const toolUpdate = parseSessionUpdateEvent({ - sessionId: "session-1", - update: { - sessionUpdate: "tool_call", - toolCallId: "tool-with-image", - title: "Generated image", - content: [ - { - type: "content", - content: { - type: "image", - data: encodedPayload, - mimeType: "image/png", - uri: `data:image/png;base64,${encodedPayload}`, - }, - }, - ], - }, - } satisfies EffectAcpSchema.SessionNotification); - const toolEvent = toolUpdate.events.find((event) => event._tag === "ToolCallUpdated"); - expect(JSON.stringify(toolEvent?.toolCall)).not.toContain(encodedPayload); - expect(toolUpdate.events[0]).toMatchObject({ - _tag: "ToolCallUpdated", - toolCall: { - data: { - content: [ - { type: "content", content: { type: "text", text: "[ACP image (image/png)]" } }, - ], - }, - }, - }); - }); - - it("bounds text-bearing ACP content without changing streamed whitespace", () => { - const text = ` ${"x".repeat(70_000)} `; - const projectedText = acpContentBlockDisplayText({ type: "text", text }); - const projectedResource = acpContentBlockDisplayText({ - type: "resource", - resource: { uri: "file:///workspace/large.txt", text }, - }); - - expect(projectedText).toHaveLength(65_536); - expect(projectedText?.startsWith(" ")).toBe(true); - expect(projectedResource).toEqual(projectedText); - }); -}); - -describe("extractMcpToolCallIdentity", () => { - function toolCallFromUpdate( - update: EffectAcpSchema.SessionNotification["update"], - ): NonNullable["toolCall"]> { - const parsed = parseSessionUpdateEvent({ sessionId: "session-1", update }); - const event = parsed.events.find( - ( - candidate, - ): candidate is Extract<(typeof parsed.events)[number], { _tag: "ToolCallUpdated" }> => - candidate._tag === "ToolCallUpdated", - ); - if (event === undefined) throw new Error("expected a tool call event"); - return event.toolCall; - } - - it("recovers server and tool from codex-acp tagged execute calls", () => { - // Captured verbatim from codex-acp 2026-08-14: MCP calls arrive as kind - // "execute" with the identity only in rawInput. - const toolCall = toolCallFromUpdate({ - sessionUpdate: "tool_call", - toolCallId: "exec-f4591587-0754-4bb4-990b-f2767894ba93", - kind: "execute", - title: "mcp.t3-code.orchestrator_capabilities", - status: "in_progress", - rawInput: { server: "t3-code", tool: "orchestrator_capabilities", arguments: {} }, - _meta: { is_mcp_tool_call: true }, - }); - - expect(extractMcpToolCallIdentity(toolCall)).toEqual({ - server: "t3-code", - tool: "orchestrator_capabilities", - }); - }); - - it("recovers T3 identity from acp-mcp-call fallback commands", () => { - const toolCall = toolCallFromUpdate({ - sessionUpdate: "tool_call", - toolCallId: "exec-1", - kind: "execute", - title: "Ran command", - status: "in_progress", - }); - - expect( - extractMcpToolCallIdentity(toolCall, { - embeddedTerminalCommands: [ - '/usr/bin/node /srv/t3/bin.ts acp-mcp-call delegate_task {"task":"x"}', - ], - }), - ).toEqual({ server: "t3-code", tool: "delegate_task", input: { task: "x" } }); - }); - - it("recovers T3 identity from pi-acp title-only fallback execs", () => { - // Captured verbatim from pi-acp 0.0.33 2026-08-14: rawInput is null and - // the command line only appears as the verbatim title, which the - // presentation layer summarizes into "Ran command". - const toolCall = toolCallFromUpdate({ - sessionUpdate: "tool_call", - toolCallId: "call_JdxnvzjHHrbvyASTLVekLYWV|fc_08f5a805a7159aa6016a7ec4afad548191", - kind: "execute", - title: - '"$T3_ACP_MCP_NODE" "$T3_ACP_MCP_ENTRYPOINT" acp-mcp-call orchestrator_capabilities \'{}\'', - status: "in_progress", - rawInput: null, - content: [ - { - type: "terminal", - terminalId: "call_JdxnvzjHHrbvyASTLVekLYWV|fc_08f5a805a7159aa6016a7ec4afad548191", - }, - ], - }); - - expect(toolCall.title).toBe("Ran command"); - expect(extractMcpToolCallIdentity(toolCall)).toEqual({ - server: "t3-code", - tool: "orchestrator_capabilities", - input: {}, - }); - }); - - it("recovers T3 identity from server-namespaced titles across titleless updates", () => { - // Captured verbatim from Kilo 7.4.22 2026-08-15: the initial tool_call - // titles the MCP function "_" with kind "other", and the - // completed update carries no title at all, so the merged presentation - // title regresses to "Tool" while data.title keeps the wire value. - const created = toolCallFromUpdate({ - sessionUpdate: "tool_call", - toolCallId: "chatcmpl-tool-b2a6142ee1a510a5", - kind: "other", - title: "t3-code_orchestrator_capabilities", - status: "pending", - locations: [], - rawInput: {}, - }); - const completed = toolCallFromUpdate({ - sessionUpdate: "tool_call_update", - toolCallId: "chatcmpl-tool-b2a6142ee1a510a5", - status: "completed", - content: [{ type: "content", content: { type: "text", text: '{"ok":true}' } }], - }); - const merged = mergeToolCallState(created, completed); - - expect(extractMcpToolCallIdentity(merged)).toEqual({ - server: "t3-code", - tool: "orchestrator_capabilities", - }); - }); - - it("recovers T3 identity from Gemini and qwen MCP-server title templates", () => { - // Gemini CLI 0.55.1: " ( MCP Server)"; qwen-code 0.21.12 - // appends ": " to the same template. - const gemini = toolCallFromUpdate({ - sessionUpdate: "tool_call", - toolCallId: "gemini-1", - kind: "other", - title: "delegate_task (t3-code MCP Server)", - status: "in_progress", - }); - const qwen = toolCallFromUpdate({ - sessionUpdate: "tool_call", - toolCallId: "qwen-1", - kind: "other", - title: 'task_status (t3-code MCP Server): {"taskId":"node:delegated-task:1"}', - status: "pending", - rawInput: { taskId: "node:delegated-task:1" }, - }); - - expect(extractMcpToolCallIdentity(gemini)).toEqual({ - server: "t3-code", - tool: "delegate_task", - }); - expect(extractMcpToolCallIdentity(qwen)).toEqual({ server: "t3-code", tool: "task_status" }); - }); - - it("recovers T3 identity across the registry agents' naming conventions", () => { - // One representative per surveyed convention (2026-08 registry builds): - // droid triple underscore, Copilot hyphen, Amp mangled server + detail - // tail, cline args tail, Auggie tool-first suffix. - // One representative per surveyed convention (2026-08 registry builds): - // droid triple underscore, Copilot hyphen, Amp mangled server + detail - // tail, cline args tail, Auggie tool-first suffix, fast-agent slash, - // Kimi bare name with args tail. - for (const title of [ - "t3-code___delegate_task", - "t3-code-delegate_task", - 'mcp__t3_code__delegate_task: {"mode":"async"}', - 't3-code__delegate_task: {"mode":"async"}', - "delegate_task_t3-code", - "t3-code/delegate_task", - 'delegate_task: {"mode":"async"}', - ]) { - const toolCall = toolCallFromUpdate({ - sessionUpdate: "tool_call", - toolCallId: "convention-1", - kind: "other", - title, - status: "pending", - }); - expect(extractMcpToolCallIdentity(toolCall), title).toEqual({ - server: "t3-code", - tool: "delegate_task", - }); - } - }); - - it("recovers T3 identity from goose _meta despite LLM-rewritten titles", () => { - // goose enriches titles asynchronously, so only _meta.goose.toolCall is - // stable; shape from crates/goose/src/acp/server/tool_calls/conversion.rs. - const toolCall = toolCallFromUpdate({ - sessionUpdate: "tool_call", - toolCallId: "goose-1", - title: "Checking on the delegated task", - status: "in_progress", - _meta: { - goose: { - toolCall: { toolName: "t3-code__task_status", extensionName: "t3-code" }, - messageId: "message-1", - }, - }, - }); - - expect(extractMcpToolCallIdentity(toolCall)).toEqual({ - server: "t3-code", - tool: "task_status", - }); - }); - - it("recovers T3 identity from qwen serverId meta regardless of prefix format", () => { - // qwen-code 0.21.12 emits _meta.serverId + _meta.toolName; serverId is an - // explicit origin assertion, so a known tool suffix suffices even if the - // prefix format changes. - const toolCall = toolCallFromUpdate({ - sessionUpdate: "tool_call", - toolCallId: "qwen-meta-1", - kind: "other", - title: "unrelated display title", - status: "pending", - _meta: { toolName: "mcp::t3-code::t3_thread_send", serverId: "t3-code", provenance: "mcp" }, - }); - - expect(extractMcpToolCallIdentity(toolCall)).toEqual({ - server: "t3-code", - tool: "t3_thread_send", - }); - }); - - it.each([ - { _meta: { claudeCode: { toolName: "mcp__weather__get_weather" } } }, - { _meta: { toolName: "mcp::weather::get_weather", serverId: "weather" } }, - ])("recovers external MCP identity from provider metadata", (metadata) => { - const toolCall = toolCallFromUpdate({ - sessionUpdate: "tool_call", - toolCallId: "weather-call", - title: "Checking the forecast", - status: "in_progress", - ...metadata, - }); - expect(extractMcpToolCallIdentity(toolCall)).toEqual({ - server: "weather", - tool: "get_weather", - }); - }); - - it.each([ - { kind: "execute", toolName: "developer__shell", extensionName: "developer" }, - { kind: "edit", toolName: "developer__edit", extensionName: "developer" }, - { kind: "other", toolName: "weather__get_weather", extensionName: "weather" }, - ] as const)( - "leaves goose $toolName unclassified so built-ins keep their projection", - ({ kind, toolName, extensionName }) => { - // goose tags built-in extensions exactly like user MCP servers. - const toolCall = toolCallFromUpdate({ - sessionUpdate: "tool_call", - toolCallId: "goose-builtin", - kind, - title: "developer: shell", - status: "in_progress", - _meta: { goose: { toolCall: { toolName, extensionName } } }, - }); - expect(extractMcpToolCallIdentity(toolCall)).toBeUndefined(); - }, - ); - - it("uses the asserted server instead of a misleading T3 title", () => { - const toolCall = toolCallFromUpdate({ - sessionUpdate: "tool_call", - toolCallId: "foreign-1", - kind: "other", - title: "t3-code_delegate_task", - status: "pending", - _meta: { toolName: "delegate_task", serverId: "other-orchestrator" }, - }); - - expect(extractMcpToolCallIdentity(toolCall)).toEqual({ - server: "other-orchestrator", - tool: "delegate_task", - }); - }); - - it("does not brand path-like or unknown-tool titles", () => { - for (const title of ["t3-code/README.md", "t3-code_not_a_real_tool"]) { - const toolCall = toolCallFromUpdate({ - sessionUpdate: "tool_call", - toolCallId: "path-1", - kind: "other", - title, - status: "pending", - }); - expect(extractMcpToolCallIdentity(toolCall), title).toBeUndefined(); - } - }); - - it("leaves ordinary execute calls unidentified", () => { - const toolCall = toolCallFromUpdate({ - sessionUpdate: "tool_call", - toolCallId: "exec-2", - kind: "execute", - title: "cat package.json", - status: "in_progress", - rawInput: { command: "cat package.json" }, - }); - - expect(extractMcpToolCallIdentity(toolCall)).toBeUndefined(); - expect( - extractMcpToolCallIdentity(toolCall, { embeddedTerminalCommands: ["bash -lc ls"] }), - ).toBeUndefined(); - }); - - it("does not treat generic raw server and tool fields as MCP provenance", () => { - const toolCall = toolCallFromUpdate({ - sessionUpdate: "tool_call", - toolCallId: "generic-1", - kind: "other", - title: "Deploy", - status: "pending", - rawInput: { server: "production", tool: "deploy" }, - }); - - expect(extractMcpToolCallIdentity(toolCall)).toBeUndefined(); - }); -}); - -describe("embeddedTerminalIdsFromSessionUpdate", () => { - it("collects terminal ids from tool_call content before the text rewrite", () => { - expect( - embeddedTerminalIdsFromSessionUpdate({ - sessionId: "session-1", - update: { - sessionUpdate: "tool_call_update", - toolCallId: "call_1", - status: "in_progress", - content: [ - { type: "terminal", terminalId: "t3-term-9" }, - { type: "content", content: { type: "text", text: "noise" } }, - ], - }, - }), - ).toEqual({ toolCallId: "call_1", terminalIds: ["t3-term-9"] }); - expect( - embeddedTerminalIdsFromSessionUpdate({ - sessionId: "session-1", - update: { - sessionUpdate: "tool_call", - toolCallId: "call_2", - title: "Tool", - status: "pending", - }, - }), - ).toBeUndefined(); - }); -}); diff --git a/apps/server/src/provider/acp/AcpRuntimeModel.ts b/apps/server/src/provider/acp/AcpRuntimeModel.ts deleted file mode 100644 index f4172d17089b1ac66d059bd43c469e684d6adf85..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/acp/AcpRuntimeModel.ts +++ /dev/null @@ -1,1644 +0,0 @@ -import * as Clock from "effect/Clock"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; -import * as Ref from "effect/Ref"; -import * as Schema from "effect/Schema"; -import * as EffectAcpSchema from "effect-acp/compat"; -import * as EffectAcpSchemaV1 from "effect-acp/schema-v1"; -import { - deriveToolActivityPresentation, - mergeToolActivityData, -} from "@t3tools/shared/toolActivity"; -import { T3_MCP_TOOL_NAMES } from "@t3tools/shared/t3McpToolPresentation"; -import type { - OrchestrationV2ProviderThreadNativeMetadata, - ThreadTokenUsageSnapshot, - ToolLifecycleItemType, -} from "@t3tools/contracts"; - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -// Guards for the untyped `initialize._meta` states some agents (Grok) advertise. -// Modes were removed from ACP v2, so the v1 wire schema is the source of truth. -const isSessionModelState = Schema.is(EffectAcpSchema.SessionModelState); -const isSessionModeState = Schema.is(EffectAcpSchemaV1.SessionModeState); - -export interface AcpSessionMode { - readonly id: string; - readonly name: string; - readonly description?: string; -} - -export interface AcpSessionModeState { - readonly currentModeId: string; - readonly availableModes: ReadonlyArray; -} - -export interface AcpToolCallState { - readonly toolCallId: string; - readonly kind?: string; - readonly title?: string; - readonly status?: "pending" | "inProgress" | "requiresAction" | "completed" | "failed"; - readonly command?: string; - readonly detail?: string; - readonly data: Record; -} - -export interface AcpAgentTerminalState { - readonly command?: string; - readonly cwd?: string; - readonly output: string; - readonly exitStatus?: EffectAcpSchema.TerminalExitStatus; - /** Incomplete trailing UTF-8 bytes retained until the next output update. */ - readonly pendingOutputBytes?: Uint8Array; -} - -type AcpAgentTerminalUpdate = Extract< - EffectAcpSchema.SessionUpdate, - { readonly sessionUpdate: "terminal_update" | "terminal_output_chunk" } ->; - -const MAX_ACP_TERMINAL_OUTPUT_BYTES = 16 * 1024 * 1024; -const MAX_ACP_TERMINAL_FRAME_BYTES = MAX_ACP_TERMINAL_OUTPUT_BYTES; - -function isBase64DataCode(code: number): boolean { - return ( - (code >= 65 && code <= 90) || - (code >= 97 && code <= 122) || - (code >= 48 && code <= 57) || - code === 43 || - code === 47 - ); -} - -/** - * Decodes one bounded base64 terminal frame, or `undefined` when the frame is - * malformed or oversized. Invalid frames are dropped rather than failed so one - * misbehaving agent frame cannot take down session-update handling. - */ -function decodeBase64TerminalFrame(encoded: string): Uint8Array | undefined { - if (encoded.length > Math.ceil(MAX_ACP_TERMINAL_FRAME_BYTES / 3) * 4) { - return undefined; - } - const padding = encoded.endsWith("==") ? 2 : encoded.endsWith("=") ? 1 : 0; - const dataLength = encoded.length - padding; - if ( - encoded.length % 4 === 1 || - (padding > 0 && encoded.length % 4 !== 0) || - (padding === 1 && dataLength % 4 !== 3) || - (padding === 2 && dataLength % 4 !== 2) - ) { - return undefined; - } - for (let index = 0; index < dataLength; index += 1) { - if (!isBase64DataCode(encoded.charCodeAt(index))) return undefined; - } - const decoded = Buffer.from(encoded, "base64"); - return decoded.byteLength <= MAX_ACP_TERMINAL_FRAME_BYTES ? decoded : undefined; -} - -function trailingIncompleteUtf8Start(bytes: Uint8Array): number { - if (bytes.length === 0) return bytes.length; - let leadIndex = bytes.length - 1; - while (leadIndex >= 0 && (bytes[leadIndex]! & 0xc0) === 0x80) { - leadIndex -= 1; - } - if (leadIndex < 0) return bytes.length; - const lead = bytes[leadIndex]!; - const expectedLength = - lead >= 0xc2 && lead <= 0xdf - ? 2 - : lead >= 0xe0 && lead <= 0xef - ? 3 - : lead >= 0xf0 && lead <= 0xf4 - ? 4 - : 1; - return bytes.length - leadIndex < expectedLength ? leadIndex : bytes.length; -} - -function decodeTerminalOutputChunk( - pending: Uint8Array | undefined, - encoded: string, -): { readonly text: string; readonly pendingOutputBytes?: Uint8Array } | undefined { - const frame = decodeBase64TerminalFrame(encoded); - if (frame === undefined) return undefined; - const bytes = Buffer.concat([...(pending === undefined ? [] : [Buffer.from(pending)]), frame]); - const completeEnd = trailingIncompleteUtf8Start(bytes); - const text = bytes.subarray(0, completeEnd).toString("utf8"); - return completeEnd === bytes.length - ? { text } - : { text, pendingOutputBytes: Uint8Array.from(bytes.subarray(completeEnd)) }; -} - -function truncateTerminalOutput(output: string): string { - const bytes = Buffer.from(output, "utf8"); - if (bytes.length <= MAX_ACP_TERMINAL_OUTPUT_BYTES) return output; - let start = bytes.length - MAX_ACP_TERMINAL_OUTPUT_BYTES; - while (start < bytes.length && (bytes[start]! & 0xc0) === 0x80) start += 1; - return bytes.subarray(start).toString("utf8"); -} - -/** Applies an ACP v2 agent-owned terminal snapshot or chunk to display state. */ -export function applyAcpAgentTerminalUpdate( - previous: AcpAgentTerminalState | undefined, - update: AcpAgentTerminalUpdate, -): AcpAgentTerminalState { - const current = previous ?? { output: "" }; - if (update.sessionUpdate === "terminal_output_chunk") { - const decoded = decodeTerminalOutputChunk(current.pendingOutputBytes, update.data); - if (decoded === undefined) return current; - const { pendingOutputBytes: _pendingOutputBytes, ...withoutPendingOutputBytes } = current; - return { - ...withoutPendingOutputBytes, - output: truncateTerminalOutput(`${current.output}${decoded.text}`), - ...(decoded.pendingOutputBytes === undefined - ? {} - : { pendingOutputBytes: decoded.pendingOutputBytes }), - }; - } - let next: AcpAgentTerminalState = current; - if (update.command === null) { - const { command: _command, ...withoutCommand } = next; - next = withoutCommand; - } else if (update.command !== undefined) { - next = { ...next, command: update.command }; - } - if (update.cwd === null) { - const { cwd: _cwd, ...withoutCwd } = next; - next = withoutCwd; - } else if (update.cwd !== undefined) { - next = { ...next, cwd: update.cwd }; - } - if (update.output !== undefined) { - const decoded = - update.output === null - ? { text: "" } - : decodeTerminalOutputChunk(undefined, update.output.data); - if (decoded !== undefined) { - const { pendingOutputBytes: _pendingOutputBytes, ...withoutPendingOutputBytes } = next; - next = { - ...withoutPendingOutputBytes, - output: truncateTerminalOutput(decoded.text), - ...(decoded.pendingOutputBytes === undefined - ? {} - : { pendingOutputBytes: decoded.pendingOutputBytes }), - }; - } - } - if (update.exitStatus === null) { - const { exitStatus: _exitStatus, ...withoutExitStatus } = next; - next = withoutExitStatus; - } else if (update.exitStatus !== undefined) { - const pendingText = - next.pendingOutputBytes === undefined - ? "" - : Buffer.from(next.pendingOutputBytes).toString("utf8"); - const { pendingOutputBytes: _pendingOutputBytes, ...withoutPendingOutputBytes } = next; - next = { - ...withoutPendingOutputBytes, - output: truncateTerminalOutput(`${next.output}${pendingText}`), - exitStatus: update.exitStatus, - }; - } - return next; -} - -export type AcpPlanUpdate = - | { - readonly nativePlanId: string; - readonly kind: "items"; - readonly explanation?: string | null; - readonly plan: ReadonlyArray<{ - readonly step: string; - readonly status: "pending" | "inProgress" | "completed"; - }>; - } - | { readonly nativePlanId: string; readonly kind: "markdown"; readonly markdown: string } - | { readonly nativePlanId: string; readonly kind: "file"; readonly uri: string } - | { readonly nativePlanId: string; readonly kind: "unknown"; readonly contentType: string } - | { readonly nativePlanId: string; readonly kind: "removed" }; - -export interface AcpPermissionRequest { - readonly kind: string | "unknown"; - readonly detail?: string; - readonly toolCall?: AcpToolCallState; -} - -export type AcpParsedSessionEvent = - | { - readonly _tag: "ModeChanged"; - readonly modeId: string; - } - | { - readonly _tag: "AvailableCommandsUpdated"; - readonly availableCommands: ReadonlyArray; - readonly rawPayload: unknown; - } - | { - readonly _tag: "ConfigOptionsUpdated"; - readonly configOptions: ReadonlyArray; - readonly rawPayload: unknown; - } - | { - readonly _tag: "AssistantItemStarted"; - readonly itemId: string; - } - | { - readonly _tag: "AssistantItemCompleted"; - readonly itemId: string; - } - | { - readonly _tag: "PlanUpdated"; - readonly payload: AcpPlanUpdate; - readonly rawPayload: unknown; - } - | { - readonly _tag: "ToolCallUpdated"; - readonly toolCall: AcpToolCallState; - readonly rawPayload: unknown; - } - | { - readonly _tag: "ContentDelta"; - readonly itemId?: string; - readonly messageId?: string; - readonly text: string; - readonly rawPayload: unknown; - } - | { - readonly _tag: "ThoughtDelta"; - readonly text: string; - readonly rawPayload: unknown; - } - | { - readonly _tag: "UsageUpdated"; - readonly usage: ThreadTokenUsageSnapshot; - readonly rawPayload: unknown; - } - | { - readonly _tag: "SessionInfoUpdated"; - readonly metadata: OrchestrationV2ProviderThreadNativeMetadata; - readonly rawPayload: unknown; - } - | { - readonly _tag: "UnknownUpdate"; - readonly updateType: string; - readonly rawPayload: unknown; - }; - -const boundedContentMetadata = (value: string | null | undefined, maximumLength: number): string => - value?.trim().slice(0, maximumLength) ?? ""; - -const MAX_ACP_CONTENT_TEXT_LENGTH = 65_536; - -// Preserve whitespace in streamed model output while keeping one provider frame bounded. -const boundedContentText = (value: string): string => value.slice(0, MAX_ACP_CONTENT_TEXT_LENGTH); - -const boundedContentUri = (value: string | null | undefined): string => { - const uri = boundedContentMetadata(value, 4_096); - return uri.toLowerCase().startsWith("data:") ? "" : uri; -}; - -/** Convert every ACP output block into bounded, displayable text without retaining base64 data. */ -export function acpContentBlockDisplayText( - content: EffectAcpSchema.ContentBlock, -): string | undefined { - switch (content.type) { - case "text": - return boundedContentText(content.text); - case "resource_link": { - const label = - boundedContentMetadata(content.title, 512) || - boundedContentMetadata(content.name, 512) || - "resource"; - const description = boundedContentMetadata(content.description, 2_048); - const uri = boundedContentUri(content.uri); - return `${description ? `${label}: ${description}` : label}${uri ? `\n${uri}` : ""}`; - } - case "resource": { - if ("text" in content.resource) { - return boundedContentText(content.resource.text); - } - const mimeType = boundedContentMetadata(content.resource.mimeType, 256); - const uri = boundedContentUri(content.resource.uri); - return `[ACP binary resource${mimeType ? ` (${mimeType})` : ""}${uri ? `: ${uri}` : ""}]`; - } - case "image": { - const mimeType = boundedContentMetadata(content.mimeType, 256) || "unknown type"; - const uri = boundedContentUri(content.uri); - return `[ACP image (${mimeType})${uri ? `: ${uri}` : ""}]`; - } - case "audio": { - const mimeType = boundedContentMetadata(content.mimeType, 256) || "unknown type"; - return `[ACP audio (${mimeType})]`; - } - case "_t3_unknown": - return `[Unsupported ACP content: ${boundedContentMetadata(content.originalType, 128) || "unknown"}]`; - } -} - -function sanitizeAcpToolCallContent( - content: ReadonlyArray, -): ReadonlyArray { - return content.map((entry) => { - switch (entry.type) { - case "content": - return { - type: "content", - content: { - type: "text", - text: acpContentBlockDisplayText(entry.content) ?? "", - }, - }; - case "diff": - if ("changes" in entry) { - return { - type: "diff", - changes: entry.changes, - ...(entry.patch === undefined ? {} : { patch: entry.patch }), - }; - } - return { - type: "diff", - path: entry.path, - ...(entry.oldText !== undefined ? { oldText: entry.oldText } : {}), - newText: entry.newText, - }; - case "terminal": - return { type: "terminal", terminalId: entry.terminalId }; - case "_t3_unknown": - return { - type: "_t3_unknown", - originalType: boundedContentMetadata(entry.originalType, 128) || "unknown", - raw: null, - }; - } - }); -} - -type AcpSessionSetupResponse = - | EffectAcpSchema.ForkSessionResponse - | EffectAcpSchema.LoadSessionResponse - | EffectAcpSchema.NewSessionResponse - | EffectAcpSchema.ResumeSessionResponse; - -type AcpToolCallUpdate = Extract< - EffectAcpSchema.SessionNotification["update"], - { readonly sessionUpdate: "tool_call" | "tool_call_update" } ->; - -export function extractModelConfigId(sessionResponse: AcpSessionSetupResponse): string | undefined { - const configOptions = sessionResponse.configOptions; - if (!configOptions) return undefined; - for (const opt of configOptions) { - if (opt.category === "model" && opt.id.trim().length > 0) { - return opt.id.trim(); - } - } - return undefined; -} - -export function findSessionConfigOption( - configOptions: ReadonlyArray | null | undefined, - configId: string, -): EffectAcpSchema.SessionConfigOption | undefined { - if (!configOptions) { - return undefined; - } - const normalizedConfigId = configId.trim(); - if (!normalizedConfigId) { - return undefined; - } - return configOptions.find((option) => option.id.trim() === normalizedConfigId); -} - -export function collectSessionConfigOptionValues( - configOption: EffectAcpSchema.SessionConfigOption, -): ReadonlyArray { - if (configOption.type !== "select") { - return []; - } - return configOption.options.flatMap((entry) => - "value" in entry ? [entry.value] : entry.options.map((option) => option.value), - ); -} - -export function parseSessionModeState( - sessionResponse: AcpSessionSetupResponse, -): AcpSessionModeState | undefined { - const modeConfig = sessionResponse.configOptions?.find( - (option) => option.category === "mode" && option.type === "select", - ); - const modes = - sessionResponse.modes ?? - (modeConfig?.type === "select" - ? { - currentModeId: modeConfig.currentValue, - availableModes: modeConfig.options - .flatMap((option) => ("options" in option ? option.options : [option])) - .map((option) => ({ - id: option.value, - name: option.name, - description: option.description, - })), - } - : undefined); - if (!modes) return undefined; - const currentModeId = modes.currentModeId.trim(); - if (!currentModeId) { - return undefined; - } - const availableModes: Array = []; - for (const mode of modes.availableModes) { - const id = mode.id.trim(); - const name = mode.name.trim(); - if (!id || !name) { - continue; - } - const description = mode.description?.trim() || undefined; - availableModes.push( - description !== undefined - ? ({ id, name, description } satisfies AcpSessionMode) - : ({ id, name } satisfies AcpSessionMode), - ); - } - if (availableModes.length === 0) { - return undefined; - } - return { - currentModeId, - availableModes, - }; -} - -function normalizePlanStepStatus(raw: unknown): "pending" | "inProgress" | "completed" { - switch (raw) { - case "completed": - return "completed"; - case "in_progress": - case "inProgress": - return "inProgress"; - default: - return "pending"; - } -} - -function normalizeToolCallStatus( - raw: unknown, - fallback?: "pending" | "inProgress" | "completed" | "failed", -): "pending" | "inProgress" | "completed" | "failed" | undefined { - switch (raw) { - case "pending": - return "pending"; - case "in_progress": - case "inProgress": - return "inProgress"; - case "completed": - return "completed"; - case "failed": - return "failed"; - default: - return fallback; - } -} - -function normalizeCommandValue(value: unknown): string | undefined { - if (typeof value === "string" && value.trim().length > 0) { - return value.trim(); - } - if (!Array.isArray(value)) { - return undefined; - } - const parts: Array = []; - for (const entry of value) { - if (typeof entry === "string") { - const part = entry.trim(); - if (part.length > 0) { - parts.push(part); - } - } - } - return parts.length > 0 ? parts.join(" ") : undefined; -} - -function extractCommandFromTitle(title: string | undefined): string | undefined { - if (!title) { - return undefined; - } - const match = /`([^`]+)`/.exec(title); - return match?.[1]?.trim() || undefined; -} - -function extractToolCallCommand( - rawInput: unknown, - title: string | undefined, - kind: string | undefined, -): string | undefined { - if (isRecord(rawInput)) { - const directCommand = normalizeCommandValue(rawInput.command); - if (directCommand) { - return directCommand; - } - const executable = typeof rawInput.executable === "string" ? rawInput.executable.trim() : ""; - const args = normalizeCommandValue(rawInput.args); - if (executable && args) { - return `${executable} ${args}`; - } - if (executable) { - return executable; - } - } - // Titles like `Read \`src/a.ts\`` are not commands. Only execute tools - // fall back to a backtick or bare title. - if (kind !== "execute") { - return undefined; - } - return extractCommandFromTitle(title); -} - -function filePathFromToolValue(value: unknown): string | undefined { - if (typeof value !== "string") { - return undefined; - } - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : undefined; -} - -function locationsFromToolCallInput(input: { - readonly locations?: ReadonlyArray | null | undefined; - readonly content?: ReadonlyArray | null | undefined; - readonly rawInput?: unknown; - readonly rawOutput?: unknown; -}): ReadonlyArray | undefined { - if (input.locations === null || input.locations?.length === 0) { - return []; - } - const locations: EffectAcpSchema.ToolCallLocation[] = []; - const seen = new Set(); - const pushLocation = (location: EffectAcpSchema.ToolCallLocation) => { - const path = filePathFromToolValue(location.path); - if (!path || seen.has(path)) { - return; - } - seen.add(path); - locations.push({ ...location, path }); - }; - const pushPath = (value: unknown) => { - const path = filePathFromToolValue(value); - if (!path || seen.has(path)) { - return; - } - seen.add(path); - locations.push({ path }); - }; - - if (input.locations) { - for (const location of input.locations) { - pushLocation(location); - } - } - if (input.content) { - for (const entry of input.content) { - if (entry.type === "diff" && "path" in entry) { - pushPath(entry.path); - } - } - } - if (isRecord(input.rawInput)) { - pushPath(input.rawInput.path); - pushPath(input.rawInput.filePath); - pushPath(input.rawInput.file_path); - } - if (isRecord(input.rawOutput)) { - pushPath(input.rawOutput.path); - pushPath(input.rawOutput.filePath); - pushPath(input.rawOutput.file_path); - } - - return locations.length > 0 ? locations : undefined; -} - -// Some ACP agents (observed with Grok's CLI) resend the ENTIRE accumulated tool-call -// output on every `tool_call_update` notification instead of a delta, so a redrawing -// terminal progress bar can balloon a single tool call to hundreds of KB per update at -// several updates per second. Cap what we retain/emit to a bounded tail so one busy tool -// call cannot flood runtime event ingestion. We always keep the tail: `tool_call_update` -// deltas routinely omit `kind`, so there is no reliable way to tell a redrawing terminal -// from another tool here, and the end is the useful part of any live-growing output. -const TOOL_CALL_CONTENT_MAX_CHARS = 8_000; -const TOOL_CALL_CONTENT_TRUNCATION_MARKER = "[Earlier output truncated]\n\n"; - -function boundToolCallOutputText(text: string): string { - if (text.length <= TOOL_CALL_CONTENT_MAX_CHARS) { - return text; - } - const tail = text.slice(text.length - TOOL_CALL_CONTENT_MAX_CHARS); - return `${TOOL_CALL_CONTENT_TRUNCATION_MARKER}${tail}`; -} - -const RAW_OUTPUT_TEXT_FIELDS = ["content", "stdout", "stderr", "output"] as const; - -// `rawOutput` is provider-defined and, for terminal-shaped tools, mirrors the same -// cumulative text-growth problem as `content` (see the comment above). Bound its known -// text-bearing fields the same way so a chatty provider cannot smuggle unbounded output -// through this field instead. -function boundToolCallRawOutput(rawOutput: unknown): unknown { - if (!isRecord(rawOutput)) { - return rawOutput; - } - let changed = false; - const bounded: Record = { ...rawOutput }; - for (const field of RAW_OUTPUT_TEXT_FIELDS) { - const value = rawOutput[field]; - if (typeof value === "string" && value.length > TOOL_CALL_CONTENT_MAX_CHARS) { - bounded[field] = boundToolCallOutputText(value); - changed = true; - } - } - return changed ? bounded : rawOutput; -} - -interface ExtractedToolCallContent { - readonly text: string | undefined; - readonly content: ReadonlyArray | undefined; -} - -function toolCallContentText(entry: EffectAcpSchema.ToolCallContent): string | undefined { - if (entry.type !== "content" || entry.content.type !== "text") { - return undefined; - } - return entry.content.text; -} - -// Trim is used for display `text`, so whitespace-only (or whitespace-padded) entries never -// contribute to `chunks` and used to take the early returns with the original array. Bound -// each text entry independently so those paths cannot persist an unbounded terminal buffer -// on `toolCall.data.content` / `rawPayload`. -function boundToolCallContentEntries( - content: ReadonlyArray, -): ReadonlyArray { - let changed = false; - const bounded = content.map((entry) => { - const text = toolCallContentText(entry); - if (text === undefined || text.length <= TOOL_CALL_CONTENT_MAX_CHARS) { - return entry; - } - changed = true; - const trimmed = text.trim(); - return { - type: "content", - content: { - type: "text", - text: boundToolCallOutputText(trimmed.length > 0 ? trimmed : text), - }, - } as const; - }); - return changed ? bounded : content; -} - -function extractTextContentFromToolCallContent( - content: ReadonlyArray | null | undefined, -): ExtractedToolCallContent { - if (!content) { - return { text: undefined, content: undefined }; - } - const chunks: Array = []; - for (const entry of content) { - const text = toolCallContentText(entry)?.trim(); - if (text) { - chunks.push(text); - } - } - if (chunks.length === 0) { - return { text: undefined, content: boundToolCallContentEntries(content) }; - } - const joined = chunks.join("\n"); - if (joined.length <= TOOL_CALL_CONTENT_MAX_CHARS) { - return { text: joined, content: boundToolCallContentEntries(content) }; - } - const bounded = boundToolCallOutputText(joined); - const tail = joined.slice(joined.length - TOOL_CALL_CONTENT_MAX_CHARS); - return { - text: bounded, - content: distributeRetainedTailAcrossContent(content, tail), - }; -} - -// Walk the original text entries from the joined tail window so a retained slice that -// spans entries around an image/diff stays on those entries. Non-text kinds keep their -// relative order; blank text entries are dropped; the truncation marker is prepended to -// the first remaining text entry. -function distributeRetainedTailAcrossContent( - content: ReadonlyArray, - tail: string, -): ReadonlyArray { - const textRanges: Array< - | { - readonly start: number; - readonly end: number; - readonly text: string; - } - | undefined - > = Array.from({ length: content.length }); - let offset = 0; - let seenText = false; - for (const [index, entry] of content.entries()) { - const text = toolCallContentText(entry)?.trim(); - if (!text) { - continue; - } - if (seenText) { - offset += 1; - } - seenText = true; - const start = offset; - const end = offset + text.length; - textRanges[index] = { start, end, text }; - offset = end; - } - const tailStart = Math.max(0, offset - tail.length); - let markerPending = true; - return content.flatMap((entry, index) => { - if (toolCallContentText(entry) === undefined) { - return [entry]; - } - const range = textRanges[index]; - if (range === undefined) { - return []; - } - const overlapStart = Math.max(range.start, tailStart); - const overlapEnd = Math.min(range.end, offset); - if (overlapEnd <= overlapStart) { - return []; - } - let piece = range.text.slice(overlapStart - range.start, overlapEnd - range.start); - if (markerPending) { - piece = `${TOOL_CALL_CONTENT_TRUNCATION_MARKER}${piece}`; - markerPending = false; - } - return [{ type: "content", content: { type: "text", text: piece } } as const]; - }); -} - -function normalizeToolKind(kind: unknown): string | undefined { - return typeof kind === "string" && kind.trim().length > 0 ? kind.trim() : undefined; -} - -/** - * Map an ACP tool kind onto the canonical runtime item type used by the - * thread activity model. Unknown kinds fall back to a generic tool call. - */ -export function canonicalItemTypeFromAcpToolKind(kind: string | undefined): ToolLifecycleItemType { - switch (kind) { - case "read": - return "dynamic_tool_call"; - case "execute": - return "command_execution"; - case "edit": - case "delete": - case "move": - return "file_change"; - case "search": - case "fetch": - return "web_search"; - default: - return "dynamic_tool_call"; - } -} - -function makeToolCallState( - input: { - readonly toolCallId: string; - readonly title?: string | null | undefined; - readonly kind?: EffectAcpSchema.ToolKind | null | undefined; - readonly status?: EffectAcpSchema.ToolCallStatus | null | undefined; - readonly rawInput?: unknown; - readonly rawOutput?: unknown; - readonly content?: ReadonlyArray | null | undefined; - readonly locations?: ReadonlyArray | null | undefined; - readonly _meta?: unknown; - }, - options?: { - readonly fallbackStatus?: "pending" | "inProgress" | "completed" | "failed"; - }, -): AcpToolCallState | undefined { - const toolCallId = input.toolCallId.trim(); - if (!toolCallId) { - return undefined; - } - const title = input.title?.trim() || undefined; - const kind = normalizeToolKind(input.kind); - const command = extractToolCallCommand(input.rawInput, title, kind); - const extractedContent = extractTextContentFromToolCallContent(input.content); - const textContent = extractedContent.text; - const normalizedTitle = - title && title.toLowerCase() !== "terminal" && title.toLowerCase() !== "tool call" - ? title - : undefined; - const data: Record = { toolCallId }; - if (kind) { - data.kind = kind; - } - if (title) { - // The agent's verbatim title. Presentation summarizes `title` on the - // state (e.g. a command line becomes "Ran command"), so identity checks - // such as MCP-fallback detection need the raw value preserved here. - data.title = title; - } - if (command) { - data.command = command; - } - if (input.rawInput !== undefined) { - data.rawInput = input.rawInput; - } - if (isRecord(input._meta)) { - // Some agents identify MCP calls only here (goose `goose.toolCall`, - // qwen `toolName`/`serverId`, claude-acp `claudeCode.toolName`). - data.meta = input._meta; - } - if (input.rawOutput !== undefined) { - data.rawOutput = boundToolCallRawOutput(input.rawOutput); - } - if (input.content != null) { - data.content = sanitizeAcpToolCallContent(extractedContent.content ?? input.content); - } - const locations = locationsFromToolCallInput(input); - if (locations !== undefined) { - data.locations = locations; - } - if (isRecord(input._meta)) { - data.meta = input._meta; - } - const fallbackDetail = command ?? normalizedTitle ?? textContent; - const hasPresentationSeed = - title !== undefined || - kind !== undefined || - command !== undefined || - normalizedTitle !== undefined || - textContent !== undefined; - const presentation = hasPresentationSeed - ? deriveToolActivityPresentation({ - itemType: canonicalItemTypeFromAcpToolKind(kind), - title, - detail: fallbackDetail, - data, - fallbackSummary: title ?? "Tool", - }) - : undefined; - const status = normalizeToolCallStatus(input.status, options?.fallbackStatus); - return { - toolCallId, - ...(kind ? { kind } : {}), - ...(presentation?.summary ? { title: presentation.summary } : {}), - ...(status ? { status } : {}), - ...(command ? { command } : {}), - ...(presentation?.detail ? { detail: presentation.detail } : {}), - data, - }; -} - -function parseTypedToolCallState( - event: AcpToolCallUpdate, - options?: { - readonly fallbackStatus?: "pending" | "inProgress" | "completed" | "failed"; - }, -): AcpToolCallState | undefined { - return makeToolCallState( - { - toolCallId: event.toolCallId, - title: event.title, - kind: event.kind, - status: event.status, - rawInput: event.rawInput, - rawOutput: event.rawOutput, - content: event.content, - locations: event.locations, - _meta: event._meta, - }, - options, - ); -} - -export function mergeToolCallState( - previous: AcpToolCallState | undefined, - next: AcpToolCallState, -): AcpToolCallState { - const nextKind = next.kind ?? (typeof next.data.kind === "string" ? next.data.kind : undefined); - const kind = nextKind ?? previous?.kind; - const title = next.title ?? previous?.title; - const status = next.status ?? previous?.status; - const command = next.command ?? previous?.command; - const detail = next.detail ?? previous?.detail; - const data = mergeToolActivityData(previous?.data, next.data) ?? next.data; - return { - toolCallId: next.toolCallId, - ...(kind ? { kind } : {}), - ...(title ? { title } : {}), - ...(status ? { status } : {}), - ...(command ? { command } : {}), - ...(detail ? { detail } : {}), - data, - }; -} - -// Even with bounded content (see TOOL_CALL_CONTENT_MAX_CHARS above), a redrawing terminal -// can still shift its bounded tail window on nearly every notification, which would emit -// a runtime event per redraw. Coalesce those: only emit early when the tool call's detail -// has grown meaningfully since the last emission, otherwise batch up to a small number of -// skipped updates before emitting anyway, so the UI still gets periodic progress and the -// final (completed/failed) state is always emitted immediately. -const TOOL_CALL_UPDATE_MIN_DETAIL_GROWTH_CHARS = 256; -const TOOL_CALL_UPDATE_COALESCE_LIMIT = 10; - -export interface AcpToolCallEmitDecisionInput { - readonly previous: AcpToolCallState | undefined; - readonly next: AcpToolCallState; - readonly lastEmittedDetailLength: number | undefined; - readonly skippedSinceEmit: number; -} - -export interface AcpToolCallEmitDecision { - readonly emit: boolean; - readonly skippedSinceEmit: number; -} - -function toolCallOutputUnchanged(previous: AcpToolCallState, next: AcpToolCallState): boolean { - return ( - previous.data.content === next.data.content && previous.data.rawOutput === next.data.rawOutput - ); -} - -// Command tools keep `detail` equal to the command, so live stdout lives on -// `data.content` / `data.rawOutput`. Measure that too, otherwise coalescing never -// sees growth and in-progress output is held until completed/failed. -export function toolCallProgressLength(state: AcpToolCallState): number { - let contentChars = 0; - const content = state.data.content; - if (Array.isArray(content)) { - for (const entry of content) { - if (!isRecord(entry)) { - continue; - } - const text = toolCallContentText(entry as EffectAcpSchema.ToolCallContent); - if (text) { - contentChars += text.length; - } - } - } - let rawOutputChars = 0; - const rawOutput = state.data.rawOutput; - if (isRecord(rawOutput)) { - for (const field of RAW_OUTPUT_TEXT_FIELDS) { - const value = rawOutput[field]; - if (typeof value === "string") { - rawOutputChars += value.length; - } - } - } - return Math.max(state.detail?.length ?? 0, contentChars, rawOutputChars); -} - -function toolCallContentTexts(state: AcpToolCallState): string { - const content = state.data.content; - if (!Array.isArray(content)) return ""; - return content - .map((entry) => - isRecord(entry) ? (toolCallContentText(entry as EffectAcpSchema.ToolCallContent) ?? "") : "", - ) - .join("\u0000"); -} - -// The output a user watches: detail, text content, and any `rawOutput`. A -// streamed diff or `rawInput` is not part of it. -export function toolCallVisibleOutputChanged( - previous: AcpToolCallState, - next: AcpToolCallState, -): boolean { - return ( - previous.detail !== next.detail || - toolCallContentTexts(previous) !== toolCallContentTexts(next) || - JSON.stringify(previous.data.rawOutput) !== JSON.stringify(next.data.rawOutput) - ); -} - -export function decideToolCallUpdateEmission( - input: AcpToolCallEmitDecisionInput, -): AcpToolCallEmitDecision { - const { previous, next, lastEmittedDetailLength, skippedSinceEmit } = input; - if (next.status === "completed" || next.status === "failed") { - return { emit: true, skippedSinceEmit: 0 }; - } - if (previous === undefined || previous.title !== next.title || previous.status !== next.status) { - return { emit: true, skippedSinceEmit: 0 }; - } - if (previous.detail === next.detail && toolCallOutputUnchanged(previous, next)) { - return { emit: false, skippedSinceEmit }; - } - const progressLength = toolCallProgressLength(next); - const grewMeaningfully = - lastEmittedDetailLength === undefined || - Math.abs(progressLength - lastEmittedDetailLength) >= TOOL_CALL_UPDATE_MIN_DETAIL_GROWTH_CHARS; - if (grewMeaningfully || skippedSinceEmit + 1 >= TOOL_CALL_UPDATE_COALESCE_LIMIT) { - return { emit: true, skippedSinceEmit: 0 }; - } - return { emit: false, skippedSinceEmit: skippedSinceEmit + 1 }; -} - -export interface AcpMcpToolCallIdentity { - readonly server: string; - readonly tool: string; - readonly input?: Record; -} - -/** Matches an invocation of T3's `acp-mcp-call` bridge fallback CLI. */ -const ACP_MCP_FALLBACK_CALL = /(?:^|[\s"'=])acp-mcp-call[\s"']+([A-Za-z0-9_.-]+)(?:\s+(.+?))?\s*$/u; - -function acpMcpFallbackInput(value: string | undefined): Record | undefined { - if (value === undefined) return undefined; - const trimmed = value.trim(); - const candidates = [ - trimmed, - ...(trimmed.startsWith("'") && trimmed.endsWith("'") ? [trimmed.slice(1, -1)] : []), - ]; - for (const candidate of candidates) { - try { - const parsed: unknown = JSON.parse(candidate); - const unwrapped = typeof parsed === "string" ? JSON.parse(parsed) : parsed; - if (isRecord(unwrapped)) return unwrapped; - } catch { - // ACP agents choose their own shell quoting. An unrecognized form keeps - // the MCP identity but leaves the input empty instead of misreporting it. - } - } - return undefined; -} - -/** - * Agents flatten injected MCP tools into model-facing function names with no - * shared convention (survey of the 2026-08 registry builds): Kilo and - * opencode use `t3-code_`, claude-acp and qwen `mcp__t3-code__`, - * Amp `mcp__t3_code__` (hyphens mangled), droid `t3-code___`, - * Copilot `t3-code-`, cline appends `: `. T3 always injects - * its server as "t3-code", and matches are additionally gated on the known - * T3 tool inventory, so the separator match can stay loose. - */ -const T3_MCP_TITLE_CALL = - /^(?:mcp[-_]{1,2})?t3[-_ ]?code[-_.:/ ]{1,3}(?[A-Za-z0-9][A-Za-z0-9_.-]*)(?::.*)?$/i; - -/** - * Gemini CLI titles injected MCP calls " ( MCP Server)" and - * qwen-code appends ": " to the same template; Auggie namespaces - * tool-first as "_t3-code". - */ -const T3_MCP_TITLE_SUFFIX_CALL = - /^(?[A-Za-z0-9][A-Za-z0-9_.-]*?)(?: \(t3[-_ ]?code MCP Server\)(?::|$)|[-_.]t3[-_ ]?code$)/i; - -/** - * glm-acp-agent and Kimi CLI register injected MCP tools under their bare - * names; Kimi additionally appends ": ". Safe only because the - * match is gated on the known T3 tool inventory. - */ -const T3_MCP_BARE_TITLE_CALL = /^(?[A-Za-z0-9_]+)(?::\s|$)/; - -/** - * Best-effort recovery of MCP identity from a generic ACP tool call. - * - * ACP has no typed MCP tool-call item, so agents surface MCP calls in - * agent-specific shapes: codex-acp tags execute calls with - * `rawInput.server`/`rawInput.tool`, while agents on T3's terminal fallback - * run the `acp-mcp-call ` CLI through their command or an embedded - * client terminal. Recovered identity lets the projection render the same - * branded MCP item that native providers produce. - */ -export function extractMcpToolCallIdentity( - toolCall: AcpToolCallState, - options?: { - /** Command lines of client terminals embedded in this tool call. */ - readonly embeddedTerminalCommands?: ReadonlyArray; - }, -): AcpMcpToolCallIdentity | undefined { - const rawInput = isRecord(toolCall.data.rawInput) ? toolCall.data.rawInput : undefined; - const meta = isRecord(toolCall.data.meta) ? toolCall.data.meta : undefined; - const server = typeof rawInput?.server === "string" ? rawInput.server.trim() : ""; - const tool = typeof rawInput?.tool === "string" ? rawInput.tool.trim() : ""; - if (meta?.is_mcp_tool_call === true && server.length > 0 && tool.length > 0) { - return { server, tool }; - } - // Agents without tagged rawInput identify their MCP calls through _meta - // (goose, qwen, claude-acp) or only through the namespaced function name - // in the title. The verbatim wire title survives merges even when a later - // titleless or LLM-enriched update replaces the presentation title, so - // match those rather than the summarized state title. - const claudeCode = isRecord(meta?.claudeCode) ? meta.claudeCode : undefined; - const gooseToolCall = isRecord(meta?.goose) - ? isRecord(meta.goose.toolCall) - ? meta.goose.toolCall - : undefined - : undefined; - // qwen asserts the origin server explicitly, so any known tool suffix in - // its toolName identifies the call even under future prefix formats. - const metaServerId = typeof meta?.serverId === "string" ? meta.serverId.trim() : ""; - const metaToolName = typeof meta?.toolName === "string" ? meta.toolName.trim() : ""; - if (/^t3[-_ ]?code$/i.test(metaServerId) && metaToolName.length > 0) { - for (const knownTool of T3_MCP_TOOL_NAMES) { - const boundary = metaToolName.length - knownTool.length - 1; - if ( - metaToolName === knownTool || - (metaToolName.endsWith(knownTool) && - boundary >= 0 && - !/[A-Za-z0-9]/.test(metaToolName.charAt(boundary))) - ) { - return { server: "t3-code", tool: knownTool }; - } - } - } - const gooseExtension = - typeof gooseToolCall?.extensionName === "string" ? gooseToolCall.extensionName.trim() : ""; - const assertsForeignOrigin = - (metaServerId.length > 0 && !/^t3[-_ ]?code$/i.test(metaServerId)) || - (gooseExtension.length > 0 && !/^t3[-_ ]?code$/i.test(gooseExtension)); - // A foreign origin never brands as T3. qwen's serverId marks a real MCP - // server, but goose reports its built-in extensions (developer__shell, - // edits) the same way as user MCP servers, so goose stays unclassified and - // keeps its command and file-change projections. - if (assertsForeignOrigin) { - if (metaServerId.length === 0 || metaToolName.length === 0) return undefined; - const prefix = [`mcp__${metaServerId}__`, `mcp::${metaServerId}::`].find((prefix) => - metaToolName.startsWith(prefix), - ); - const tool = prefix === undefined ? metaToolName : metaToolName.slice(prefix.length); - return tool ? { server: metaServerId, tool } : undefined; - } - const candidates = [ - meta?.toolName, - claudeCode?.toolName, - gooseToolCall?.toolName, - toolCall.data.title, - ].filter((value): value is string => typeof value === "string"); - for (const candidate of candidates) { - const trimmed = candidate.trim(); - const qualified = /^mcp__(.+?)__(.+)$/i.exec(trimmed); - if (qualified?.[1] && qualified[2] && !/^t3[-_ ]?code$/i.test(qualified[1])) - return { server: qualified[1], tool: qualified[2] }; - const match = - T3_MCP_TITLE_CALL.exec(trimmed) ?? - T3_MCP_TITLE_SUFFIX_CALL.exec(trimmed) ?? - T3_MCP_BARE_TITLE_CALL.exec(trimmed); - const candidateTool = match?.groups?.tool; - if (candidateTool !== undefined && T3_MCP_TOOL_NAMES.has(candidateTool)) { - return { server: "t3-code", tool: candidateTool }; - } - } - const commands = [ - ...(toolCall.command === undefined ? [] : [toolCall.command]), - // pi-acp reports the exec command line only as the verbatim title. - ...(typeof toolCall.data.title === "string" ? [toolCall.data.title] : []), - ...(options?.embeddedTerminalCommands ?? []), - ]; - for (const command of commands) { - const match = ACP_MCP_FALLBACK_CALL.exec(command); - if (match?.[1] !== undefined) { - // The acp-mcp-call CLI exists only as T3's bridge fallback, so the - // server identity is T3's by construction. - const input = acpMcpFallbackInput(match[2]); - return { server: "t3-code", tool: match[1], ...(input === undefined ? {} : { input }) }; - } - } - return undefined; -} - -/** - * Terminal ids embedded in a raw tool_call update, captured before - * {@link resolveEmbeddedTerminalContent} rewrites them into plain text, so the - * projection can still resolve the terminal's command line. - */ -export function embeddedTerminalIdsFromSessionUpdate( - notification: EffectAcpSchema.SessionNotification, -): { readonly toolCallId: string; readonly terminalIds: ReadonlyArray } | undefined { - const update = notification.update; - if (update.sessionUpdate !== "tool_call" && update.sessionUpdate !== "tool_call_update") { - return undefined; - } - const terminalIds = (update.content ?? []) - .filter((entry) => entry.type === "terminal") - .map((entry) => entry.terminalId); - return terminalIds.length === 0 ? undefined : { toolCallId: update.toolCallId, terminalIds }; -} - -export function parsePermissionRequest( - params: EffectAcpSchema.RequestPermissionRequest, -): AcpPermissionRequest { - const toolCall = makeToolCallState( - { - toolCallId: params.toolCall.toolCallId, - title: params.toolCall.title, - kind: params.toolCall.kind, - status: params.toolCall.status, - rawInput: params.toolCall.rawInput, - rawOutput: params.toolCall.rawOutput, - content: params.toolCall.content, - locations: params.toolCall.locations, - }, - { fallbackStatus: "pending" }, - ); - const kind = normalizeToolKind(params.toolCall.kind) ?? "unknown"; - const detail = - toolCall?.command ?? - toolCall?.title ?? - toolCall?.detail ?? - (typeof params.sessionId === "string" ? `Session ${params.sessionId}` : undefined); - return { - kind, - ...(detail ? { detail } : {}), - ...(toolCall ? { toolCall } : {}), - }; -} - -export function sessionUpdateIsReplay(params: EffectAcpSchema.SessionNotification): boolean { - const meta = params._meta; - return isRecord(meta) && meta.isReplay === true; -} - -/** Replay chunks and substantive updates during session/load; not Grok keepalives. */ -export function sessionUpdateCountsAsLoadReplayActivity( - params: EffectAcpSchema.SessionNotification, - gatedSessionId?: string, -): boolean { - if (gatedSessionId !== undefined && params.sessionId !== gatedSessionId) { - return false; - } - if (sessionUpdateIsReplay(params)) return true; - const update = params.update; - switch (update.sessionUpdate) { - case "agent_message_chunk": - case "agent_thought_chunk": - return (acpContentBlockDisplayText(update.content)?.length ?? 0) > 0; - case "agent_message": - case "agent_thought": - return (update.content ?? []).some( - (content) => (acpContentBlockDisplayText(content)?.length ?? 0) > 0, - ); - case "tool_call": - case "tool_call_update": - case "plan": - case "plan_update": - case "plan_removed": - case "available_commands_update": - case "current_mode_update": - case "config_option_update": - case "session_info_update": - case "usage_update": - case "compaction_update": - case "compaction_summary_chunk": - return true; - default: - return false; - } -} - -export interface SessionLoadGate { - readonly active: boolean; - /** Only notifications for this session refresh load-replay idle activity. */ - readonly sessionId: string; - readonly lastActivityAtMillis: number | undefined; - readonly idleGap: Duration.Duration; - readonly initializeResult: EffectAcpSchema.InitializeResponse; - readonly candidateConfigOptions?: ReadonlyArray; -} - -export const waitForSessionLoadReplayIdle = (input: { - readonly gateRef: Ref.Ref>; -}): Effect.Effect => - Effect.gen(function* () { - const pollInterval = Duration.millis(25); - while (true) { - const gate = yield* Ref.get(input.gateRef); - if ( - Option.isSome(gate) && - gate.value.active && - gate.value.lastActivityAtMillis !== undefined - ) { - const idleGapMillis = Duration.toMillis(gate.value.idleGap); - const nowMillis = yield* Clock.currentTimeMillis; - if (nowMillis - gate.value.lastActivityAtMillis >= idleGapMillis) { - return syntheticLoadSessionResponseFromInitialize(gate.value.initializeResult); - } - } - yield* Effect.sleep(pollInterval); - } - }); - -/** - * Model state some agents (Grok) advertise in `initialize._meta.modelState`, before any - * session exists. Undefined when the agent does not advertise it or the shape is unknown. - */ -export function sessionModelStateFromInitialize( - initializeResult: EffectAcpSchema.InitializeResponse, -): EffectAcpSchema.SessionModelState | undefined { - const meta = initializeResult._meta; - const modelState = isRecord(meta) ? meta.modelState : undefined; - return isSessionModelState(modelState) ? modelState : undefined; -} - -export function syntheticLoadSessionResponseFromInitialize( - initializeResult: EffectAcpSchema.InitializeResponse, -): EffectAcpSchema.LoadSessionResponse { - const meta = initializeResult._meta; - const modeState = isRecord(meta) ? meta.modeState : undefined; - - const modes = isSessionModeState(modeState) ? modeState : undefined; - - return { - ...(modes ? { modes } : {}), - _meta: { - t3SessionLoadReady: "replay_idle", - }, - }; -} - -// The parsed AcpToolCallState already carries bounded content (see makeToolCallState / -// extractTextContentFromToolCallContent above), but the raw JSON-RPC notification is also -// threaded through as `rawPayload` for logging/debugging and ends up persisted on the -// runtime event. Substitute the same bounded `content`/`rawOutput` there so an oversized -// cumulative update cannot smuggle the unbounded buffer back in through the raw payload. -function boundToolCallRawPayload( - params: EffectAcpSchema.SessionNotification, - update: AcpToolCallUpdate, - toolCall: AcpToolCallState, -): unknown { - const boundedContent = toolCall.data.content; - const boundedRawOutput = toolCall.data.rawOutput; - const contentBounded = update.content !== undefined && boundedContent !== update.content; - const rawOutputBounded = update.rawOutput !== undefined && boundedRawOutput !== update.rawOutput; - if (!contentBounded && !rawOutputBounded) { - return params; - } - return { - ...params, - update: { - ...update, - ...(contentBounded ? { content: boundedContent } : {}), - ...(rawOutputBounded ? { rawOutput: boundedRawOutput } : {}), - }, - }; -} - -export function parseSessionUpdateEvent(params: EffectAcpSchema.SessionNotification): { - readonly modeId?: string; - readonly events: ReadonlyArray; -} { - const upd = params.update; - const events: Array = []; - let modeId: string | undefined; - - switch (upd.sessionUpdate) { - case "config_option_update": { - const modes = parseSessionModeState({ configOptions: upd.configOptions }); - if (modes !== undefined) { - modeId = modes.currentModeId; - events.push({ _tag: "ModeChanged", modeId }); - } - events.push({ - _tag: "ConfigOptionsUpdated", - configOptions: upd.configOptions, - rawPayload: params, - }); - break; - } - case "available_commands_update": { - events.push({ - _tag: "AvailableCommandsUpdated", - availableCommands: upd.availableCommands, - rawPayload: params, - }); - break; - } - case "current_mode_update": { - modeId = upd.currentModeId.trim(); - if (modeId) { - events.push({ - _tag: "ModeChanged", - modeId, - }); - } - break; - } - case "plan": { - const plan = upd.entries.map((entry, index) => ({ - step: entry.content.trim().length > 0 ? entry.content.trim() : `Step ${index + 1}`, - status: normalizePlanStepStatus(entry.status), - })); - if (plan.length > 0) { - events.push({ - _tag: "PlanUpdated", - payload: { - nativePlanId: "legacy", - kind: "items", - plan, - }, - rawPayload: params, - }); - } - break; - } - case "plan_update": { - const nativePlanId = upd.plan.planId.trim(); - if (nativePlanId.length === 0) { - break; - } - if (upd.plan.type === "items" && "entries" in upd.plan && Array.isArray(upd.plan.entries)) { - const entries: ReadonlyArray = upd.plan.entries; - const plan = entries.flatMap((entry, index) => { - if (!isRecord(entry) || typeof entry.content !== "string") return []; - const status = typeof entry.status === "string" ? entry.status : "pending"; - return [ - { - step: entry.content.trim().length > 0 ? entry.content.trim() : `Step ${index + 1}`, - status: normalizePlanStepStatus(status), - }, - ]; - }); - events.push({ - _tag: "PlanUpdated", - payload: { nativePlanId, kind: "items", plan }, - rawPayload: params, - }); - } else if ( - upd.plan.type === "markdown" && - "content" in upd.plan && - typeof upd.plan.content === "string" - ) { - events.push({ - _tag: "PlanUpdated", - payload: { nativePlanId, kind: "markdown", markdown: upd.plan.content }, - rawPayload: params, - }); - } else if ( - upd.plan.type === "file" && - "uri" in upd.plan && - typeof upd.plan.uri === "string" - ) { - events.push({ - _tag: "PlanUpdated", - payload: { nativePlanId, kind: "file", uri: upd.plan.uri }, - rawPayload: params, - }); - } else { - events.push({ - _tag: "PlanUpdated", - payload: { nativePlanId, kind: "unknown", contentType: upd.plan.type }, - rawPayload: params, - }); - } - break; - } - case "plan_removed": { - const nativePlanId = upd.planId.trim(); - if (nativePlanId.length > 0) { - events.push({ - _tag: "PlanUpdated", - payload: { nativePlanId, kind: "removed" }, - rawPayload: params, - }); - } - break; - } - case "tool_call": { - const toolCall = parseTypedToolCallState(upd, { - fallbackStatus: "pending", - }); - if (toolCall) { - events.push({ - _tag: "ToolCallUpdated", - toolCall, - rawPayload: boundToolCallRawPayload(params, upd, toolCall), - }); - } - break; - } - case "tool_call_update": { - const toolCall = parseTypedToolCallState(upd); - if (toolCall) { - events.push({ - _tag: "ToolCallUpdated", - toolCall, - rawPayload: boundToolCallRawPayload(params, upd, toolCall), - }); - } - break; - } - case "agent_message_chunk": { - const text = acpContentBlockDisplayText(upd.content); - if (text !== undefined && text.length > 0) { - events.push({ - _tag: "ContentDelta", - ...(upd.messageId ? { messageId: upd.messageId } : {}), - text, - rawPayload: params, - }); - } - break; - } - case "agent_thought_chunk": { - if (upd.content.type === "text" && upd.content.text.length > 0) { - events.push({ - _tag: "ThoughtDelta", - text: upd.content.text, - rawPayload: params, - }); - } - break; - } - case "compaction_update": { - const summary = (upd.summary ?? []) - .flatMap((content) => { - const text = acpContentBlockDisplayText(content); - return text === undefined || text.length === 0 ? [] : [text]; - }) - .join("\n"); - const status = - upd.status === "completed" - ? ("completed" as const) - : upd.status === "failed" || upd.status === "cancelled" - ? ("failed" as const) - : ("inProgress" as const); - const toolCall = makeToolCallState({ - toolCallId: `acp-compaction:${upd.compactionId}`, - title: "Compact context", - kind: "think", - status, - rawOutput: (upd.error ?? summary) || undefined, - _meta: upd._meta, - }); - if (toolCall !== undefined) { - events.push({ _tag: "ToolCallUpdated", toolCall, rawPayload: params }); - } - break; - } - case "compaction_summary_chunk": { - const text = acpContentBlockDisplayText(upd.content); - const toolCall = makeToolCallState({ - toolCallId: `acp-compaction:${upd.compactionId}`, - title: "Compact context", - kind: "think", - status: "inProgress", - ...(text === undefined ? {} : { rawOutput: text }), - _meta: upd._meta, - }); - if (toolCall !== undefined) { - events.push({ _tag: "ToolCallUpdated", toolCall, rawPayload: params }); - } - break; - } - case "state_update": { - if (upd.state === "idle" && upd.usage !== undefined && upd.usage !== null) { - events.push({ - _tag: "UsageUpdated", - usage: { usedTokens: upd.usage.totalTokens }, - rawPayload: params, - }); - } - break; - } - case "usage_update": { - const currency = upd.cost?.currency.trim().slice(0, 32); - events.push({ - _tag: "UsageUpdated", - usage: { - usedTokens: upd.used, - ...(upd.size > 0 ? { maxTokens: upd.size } : {}), - ...(upd.cost && currency ? { cost: { amount: upd.cost.amount, currency } } : {}), - }, - rawPayload: params, - }); - break; - } - case "session_info_update": { - const title = upd.title?.trim().slice(0, 1_024); - const updatedAt = upd.updatedAt?.trim().slice(0, 128); - events.push({ - _tag: "SessionInfoUpdated", - metadata: { - ...(upd.title === null ? { title: null } : title ? { title } : {}), - ...(upd.updatedAt === null ? { updatedAt: null } : updatedAt ? { updatedAt } : {}), - }, - rawPayload: params, - }); - break; - } - case "_t3_unknown": { - events.push({ - _tag: "UnknownUpdate", - updateType: boundedContentMetadata(upd.originalSessionUpdate, 128) || "unknown", - rawPayload: params, - }); - break; - } - default: - break; - } - - return { ...(modeId !== undefined ? { modeId } : {}), events }; -} diff --git a/apps/server/src/provider/acp/AcpSessionConfig.test.ts b/apps/server/src/provider/acp/AcpSessionConfig.test.ts deleted file mode 100644 index 8400605be264bc1b12e17280a9a10ae0933eed37..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/acp/AcpSessionConfig.test.ts +++ /dev/null @@ -1,263 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; - -import { ACP_SESSION_MODE_OPTION_ID, acpProviderOptionDescriptors } from "./AcpSessionConfig.ts"; - -describe("acpProviderOptionDescriptors", () => { - it("maps non-model select options and excludes model and collaboration categories", () => { - const descriptors = acpProviderOptionDescriptors({ - configOptions: [ - { - id: "model", - name: "Model", - category: "model", - type: "select", - currentValue: "gpt-5.6-sol", - options: [{ value: "gpt-5.6-sol", name: "GPT-5.6-Sol" }], - }, - { - id: "collaboration_mode", - name: "Collaboration mode", - category: "collaboration_mode", - type: "select", - currentValue: "default", - options: [ - { value: "default", name: "Default" }, - { value: "plan", name: "Plan" }, - ], - }, - { - id: "reasoning_effort", - name: "Reasoning effort", - category: "thought_level", - type: "select", - currentValue: "medium", - options: [ - { value: "low", name: "Low" }, - { value: "medium", name: "Medium", description: "Balanced" }, - ], - }, - ], - modeState: undefined, - }); - - expect(descriptors).toEqual([ - { - id: "reasoning_effort", - label: "Reasoning effort", - type: "select", - options: [ - { id: "low", label: "Low" }, - { id: "medium", label: "Medium", description: "Balanced" }, - ], - currentValue: "medium", - }, - ]); - }); - - it("maps boolean session options", () => { - const descriptors = acpProviderOptionDescriptors({ - configOptions: [ - { - id: "fast_mode", - name: "Fast mode", - description: "Use lower-latency inference.", - type: "boolean", - currentValue: true, - }, - ], - modeState: undefined, - }); - - expect(descriptors).toEqual([ - { - id: "fast_mode", - label: "Fast mode", - description: "Use lower-latency inference.", - type: "boolean", - currentValue: true, - }, - ]); - }); - - it("flattens grouped select choices and drops empty or duplicate values", () => { - const descriptors = acpProviderOptionDescriptors({ - configOptions: [ - { - id: "sandbox", - name: "Sandbox", - type: "select", - currentValue: "workspace", - options: [ - { - groupId: "standard", - name: "Standard", - options: [ - { value: "workspace", name: "Workspace" }, - { value: " ", name: "Blank" }, - ], - }, - { - groupId: "extra", - name: "Extra", - options: [ - { value: "workspace", name: "Duplicate" }, - { value: "full", name: "Full" }, - ], - }, - ], - }, - ], - modeState: undefined, - }); - - expect(descriptors).toEqual([ - { - id: "sandbox", - label: "Sandbox", - type: "select", - options: [ - { id: "workspace", label: "Workspace" }, - { id: "full", label: "Full" }, - ], - currentValue: "workspace", - }, - ]); - }); - - it("synthesizes a mode descriptor for agents without a mode config option", () => { - const descriptors = acpProviderOptionDescriptors({ - configOptions: [], - modeState: { - currentModeId: "safe", - availableModes: [ - { id: "safe", name: "Safe" }, - { id: "yolo", name: "YOLO", description: "No approvals" }, - ], - }, - }); - - expect(descriptors).toEqual([ - { - id: ACP_SESSION_MODE_OPTION_ID, - label: "Mode", - description: "Session mode advertised by the ACP agent.", - type: "select", - options: [ - { id: "safe", label: "Safe" }, - { id: "yolo", label: "YOLO", description: "No approvals" }, - ], - currentValue: "safe", - }, - ]); - }); - - it("suppresses the synthetic mode descriptor when modes duplicate a config option", () => { - const descriptors = acpProviderOptionDescriptors({ - configOptions: [ - { - id: "thinking", - name: "Thinking", - category: "thought_level", - type: "select", - currentValue: "medium", - options: [ - { value: "off", name: "Thinking: off" }, - { value: "medium", name: "Thinking: medium" }, - ], - }, - ], - modeState: { - currentModeId: "medium", - availableModes: [ - { id: "off", name: "Thinking: off" }, - { id: "medium", name: "Thinking: medium" }, - ], - }, - }); - - expect(descriptors.map((descriptor) => descriptor.id)).toEqual(["thinking"]); - }); - - it("does not let an unrelated select option hide the session modes API", () => { - const descriptors = acpProviderOptionDescriptors({ - configOptions: [ - { - id: "unrelated", - name: "Unrelated", - type: "select", - currentValue: "safe", - options: [ - { value: "safe", name: "Safe" }, - { value: "yolo", name: "YOLO" }, - ], - }, - ], - modeState: { - currentModeId: "safe", - availableModes: [ - { id: "safe", name: "Safe" }, - { id: "yolo", name: "YOLO" }, - ], - }, - }); - - expect(descriptors.map((descriptor) => descriptor.id)).toEqual([ - "unrelated", - ACP_SESSION_MODE_OPTION_ID, - ]); - }); - - it("preserves canonical opaque option values and omits values the wire would mutate", () => { - const exactValue = "value:with:opaque-markers"; - const descriptors = acpProviderOptionDescriptors({ - configOptions: [ - { - id: "opaque", - name: "Opaque", - type: "select", - currentValue: exactValue, - options: [ - { value: exactValue, name: "Exact" }, - { value: " value with spaces ", name: "Would be trimmed" }, - { value: "x".repeat(257), name: "Too long" }, - ], - }, - ], - modeState: undefined, - }); - - expect(descriptors[0]).toMatchObject({ - id: "opaque", - currentValue: exactValue, - options: [{ id: exactValue, label: "Exact" }], - }); - }); - - it("suppresses the synthetic mode descriptor when a mode-category option exists", () => { - const descriptors = acpProviderOptionDescriptors({ - configOptions: [ - { - id: "mode", - name: "Mode", - category: "mode", - type: "select", - currentValue: "agent", - options: [ - { value: "read-only", name: "Read-only" }, - { value: "agent", name: "Agent" }, - ], - }, - ], - modeState: { - currentModeId: "agent", - availableModes: [ - { id: "read-only", name: "Read-only" }, - { id: "agent", name: "Agent" }, - { id: "agent-full-access", name: "Agent (full access)" }, - ], - }, - }); - - expect(descriptors.map((descriptor) => descriptor.id)).toEqual(["mode"]); - }); -}); diff --git a/apps/server/src/provider/acp/AcpSessionConfig.ts b/apps/server/src/provider/acp/AcpSessionConfig.ts deleted file mode 100644 index 6454eb4444c980a8c38633ddc835473c8efe0631..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/acp/AcpSessionConfig.ts +++ /dev/null @@ -1,153 +0,0 @@ -import type { ProviderOptionChoice, ProviderOptionDescriptor } from "@t3tools/contracts"; -import type * as EffectAcpSchema from "effect-acp/compat"; - -import type { AcpSessionModeState } from "./AcpRuntimeModel.ts"; - -/** - * Maps ACP session configuration and session modes onto T3's provider option - * descriptors so the existing model-options UI can drive them. - * - * Model selection stays on the dedicated model picker: `category: "model"` - * options are excluded here because they already surface as models. Session - * modes advertised through the modes API (rather than a config option) are - * folded into one synthetic descriptor that the ACP adapter routes to - * `session/set_mode`. - */ - -/** Synthetic descriptor ID for agents that expose modes outside config options. */ -export const ACP_SESSION_MODE_OPTION_ID = "_t3/session-mode"; - -const MAX_OPTION_DESCRIPTORS = 16; -const MAX_OPTION_CHOICES = 64; -const MAX_TEXT_LENGTH = 256; -const MAX_DESCRIPTION_LENGTH = 1_024; - -const boundedText = (value: string | null | undefined, maximumLength: number): string => - (value ?? "").trim().slice(0, maximumLength); - -const boundedOpaqueValue = (value: string, maximumLength: number): string | undefined => - value.length > 0 && value === value.trim() && value.length <= maximumLength ? value : undefined; - -function flattenSelectChoices( - options: EffectAcpSchema.SessionConfigSelectOptions, -): ReadonlyArray { - return options.flatMap((candidate) => ("value" in candidate ? [candidate] : candidate.options)); -} - -function selectChoices( - candidates: ReadonlyArray<{ - readonly value: string; - readonly name: string; - readonly description?: string | null | undefined; - }>, -): ReadonlyArray { - const seen = new Set(); - const choices: Array = []; - for (const candidate of candidates) { - const id = boundedOpaqueValue(candidate.value, MAX_TEXT_LENGTH); - if (id === undefined || seen.has(id)) continue; - seen.add(id); - const description = boundedText(candidate.description, MAX_DESCRIPTION_LENGTH); - choices.push({ - id, - label: boundedText(candidate.name, MAX_TEXT_LENGTH) || id, - ...(description ? { description } : {}), - }); - if (choices.length === MAX_OPTION_CHOICES) break; - } - return choices; -} - -export function acpProviderOptionDescriptors(input: { - readonly configOptions: ReadonlyArray | null | undefined; - readonly modeState: AcpSessionModeState | undefined; -}): ReadonlyArray { - const descriptors: Array = []; - const seen = new Set(); - const mirroredModeChoiceSets: Array> = []; - let hasModeCategory = false; - - for (const option of input.configOptions ?? []) { - // "model" options surface as the model list; "collaboration_mode" options - // are driven by T3's own plan/build interaction mode in the ACP adapter. - if (option.category === "model" || option.category === "collaboration_mode") { - continue; - } - const id = boundedOpaqueValue(option.id, MAX_TEXT_LENGTH); - if (id === undefined || seen.has(id)) continue; - seen.add(id); - const description = boundedText(option.description, MAX_DESCRIPTION_LENGTH); - const base = { - id, - label: boundedText(option.name, MAX_TEXT_LENGTH) || id, - ...(description ? { description } : {}), - } as const; - if (option.type === "boolean") { - if (option.category === "mode") hasModeCategory = true; - descriptors.push({ - ...base, - type: "boolean", - currentValue: option.currentValue, - }); - if (descriptors.length === MAX_OPTION_DESCRIPTORS) return descriptors; - continue; - } - const choices = selectChoices(flattenSelectChoices(option.options)); - if (choices.length === 0) { - seen.delete(id); - continue; - } - if (option.category === "mode") hasModeCategory = true; - if (option.category === "thought_level") { - mirroredModeChoiceSets.push(new Set(choices.map((choice) => choice.id))); - } - const currentValue = option.currentValue; - descriptors.push({ - ...base, - type: "select", - options: choices, - ...(currentValue && choices.some((choice) => choice.id === currentValue) - ? { currentValue } - : {}), - }); - if (descriptors.length === MAX_OPTION_DESCRIPTORS) return descriptors; - } - - const modeState = input.modeState; - if (modeState !== undefined && !hasModeCategory) { - const choices = selectChoices( - modeState.availableModes.map((mode) => ({ - value: mode.id, - name: mode.name, - description: mode.description, - })), - ); - const currentValue = modeState.currentModeId; - // Some agents mirror one knob through both the modes API and a config - // option (codex-acp advertises its thinking levels as modes too). Skip - // the synthetic descriptor when an existing descriptor already exposes - // the same choice set, so the composer shows the knob once. - const duplicatesKnownModeDescriptor = mirroredModeChoiceSets.some( - (choiceIds) => - choiceIds.size === choices.length && choices.every((choice) => choiceIds.has(choice.id)), - ); - if ( - choices.length > 1 && - !duplicatesKnownModeDescriptor && - !seen.has(ACP_SESSION_MODE_OPTION_ID) - ) { - descriptors.push({ - id: ACP_SESSION_MODE_OPTION_ID, - label: "Mode", - description: "Session mode advertised by the ACP agent.", - type: "select", - options: choices, - ...(currentValue && choices.some((choice) => choice.id === currentValue) - ? { currentValue } - : {}), - }); - } - } - - return descriptors.slice(0, MAX_OPTION_DESCRIPTORS); -} diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.processTree.test.ts b/apps/server/src/provider/acp/AcpSessionRuntime.processTree.test.ts index 9067ee93f096eb97ae7f5ee8c2f15d51550da5f0..13a7dd6c2c81615563d289d915a21c4199a3e55a 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.processTree.test.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.processTree.test.ts @@ -11,29 +11,7 @@ import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Scope from "effect/Scope"; -import { - capturePosixOwnershipLedger, - isSafeCgroupPath, - makeLinuxCgroupController, - makePosixProcessTreeController, - observePosixOwnershipLedger, - observePosixOwnershipLedgerContinuously, - parseCgroup2Mount, - parseCgroup2Mounts, - parseUnifiedCgroupPath, - posixProcessIsZombie, - resolveLinuxCgroupTargetCommand, - signalLinuxCgroupRootTerm, - sweepStaleLinuxCgroupSiblings, - terminateLinuxCgroupLease, - terminatePosixOwnedProcessTree, - wrapCommandForLinuxCgroup, - type AcpOwnedPosixProcess, - type AcpLinuxCgroupLease, - type AcpPosixOwnershipRoot, - type AcpPosixProcessIdentity, - type AcpPosixProcessTreeController, -} from "./AcpSessionRuntime.ts"; +import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; const threadSpawnHelperSource = NodeURL.fileURLToPath( new URL("../../../scripts/acp-thread-spawn-helper.c", import.meta.url), @@ -46,7 +24,7 @@ const identity = ( sid: number, startTime = String(pid), state = "S", -): AcpPosixProcessIdentity => ({ +): AcpSessionRuntime.AcpPosixProcessIdentity => ({ executable: `/proc/${pid}/exe`, pgid, pid, @@ -57,16 +35,16 @@ const identity = ( }); function makeController(input: { - readonly processes: ReadonlyArray; + readonly processes: ReadonlyArray; readonly onProcess?: ( - processes: Map, + processes: Map, pid: number, signal: NodeJS.Signals, ) => void; }) { const processes = new Map(input.processes.map((process) => [process.pid, process])); const signals: Array = []; - const controller: AcpPosixProcessTreeController = { + const controller: AcpSessionRuntime.AcpPosixProcessTreeController = { childPidsOf: (pid) => [...processes.values()] .filter((process) => process.ppid === pid) @@ -96,19 +74,23 @@ function processExists(pid: number): boolean { describe("terminatePosixOwnedProcessTree", () => { it("parses unified cgroup paths and escaped cgroup2 mountinfo", () => { - expect(parseUnifiedCgroupPath("0::/user.slice/app.scope\n")).toBe("/user.slice/app.scope"); - expect(parseUnifiedCgroupPath("0::/one\n0::/two\n")).toBeUndefined(); - expect(isSafeCgroupPath("/user.slice/app.scope")).toBe(true); - expect(isSafeCgroupPath("/user.slice/../escape")).toBe(false); - expect(isSafeCgroupPath("/user.slice/app.scope (deleted)")).toBe(false); + expect(AcpSessionRuntime.parseUnifiedCgroupPath("0::/user.slice/app.scope\n")).toBe( + "/user.slice/app.scope", + ); + expect(AcpSessionRuntime.parseUnifiedCgroupPath("0::/one\n0::/two\n")).toBeUndefined(); + expect(AcpSessionRuntime.isSafeCgroupPath("/user.slice/app.scope")).toBe(true); + expect(AcpSessionRuntime.isSafeCgroupPath("/user.slice/../escape")).toBe(false); + expect(AcpSessionRuntime.isSafeCgroupPath("/user.slice/app.scope (deleted)")).toBe(false); expect( - parseCgroup2Mount( + AcpSessionRuntime.parseCgroup2Mount( "42 31 0:37 /user.slice /sys/fs/cgroup/user\\040mount rw - cgroup2 cgroup2 rw\n", ), ).toEqual({ mountPoint: "/sys/fs/cgroup/user mount", root: "/user.slice" }); - expect(parseCgroup2Mount("42 31 0:37 / /sys/fs/cgroup rw - tmpfs tmpfs rw\n")).toBeUndefined(); expect( - parseCgroup2Mounts( + AcpSessionRuntime.parseCgroup2Mount("42 31 0:37 / /sys/fs/cgroup rw - tmpfs tmpfs rw\n"), + ).toBeUndefined(); + expect( + AcpSessionRuntime.parseCgroup2Mounts( "41 31 0:36 /other /sys/fs/cgroup/other rw - cgroup2 cgroup2 rw\n" + "42 31 0:37 /user.slice /sys/fs/cgroup/user rw - cgroup2 cgroup2 rw\n", ), @@ -140,7 +122,7 @@ describe("terminatePosixOwnedProcessTree", () => { const unrelated = writeSibling("other-lease", "0"); const isProcessAlive = (pid: number) => pid === process.pid || pid === liveForeignPid; try { - sweepStaleLinuxCgroupSiblings(parent, { + AcpSessionRuntime.sweepStaleLinuxCgroupSiblings(parent, { isProcessAlive, readPopulated: (path) => populatedByPath.get(path), remove: (path) => { @@ -155,7 +137,7 @@ describe("terminatePosixOwnedProcessTree", () => { expect(NodeFS.existsSync(unrelated)).toBe(true); // Missing populated state must not remove a sibling. writeSibling(`t3-acp-${deadPid}-eeee`, null); - sweepStaleLinuxCgroupSiblings(parent, { + AcpSessionRuntime.sweepStaleLinuxCgroupSiblings(parent, { isProcessAlive, readPopulated: (path) => populatedByPath.get(path), remove: (path) => { @@ -174,7 +156,7 @@ describe("terminatePosixOwnedProcessTree", () => { it.live("preserves exact argv and strips wrapper-only environment before exec", () => Effect.gen(function* () { if ((yield* HostProcessPlatform) !== "linux") return; - const lease = makeLinuxCgroupController().create(); + const lease = AcpSessionRuntime.makeLinuxCgroupController().create(); if (lease === undefined) return; const scratchRoot = NodePath.join(process.cwd(), "tmp"); NodeFS.mkdirSync(scratchRoot, { recursive: true }); @@ -192,17 +174,27 @@ describe("terminatePosixOwnedProcessTree", () => { NodeFS.symlinkSync(process.execPath, bareGrok); NodeFS.symlinkSync(process.execPath, NodePath.join(relativeBin, "grok")); NodeFS.symlinkSync(process.execPath, NodePath.join(validBin, "grok")); - expect(resolveLinuxCgroupTargetCommand("grok", scratch, { PATH: scratch })).toBe(bareGrok); - expect(resolveLinuxCgroupTargetCommand("grok", scratch, { PATH: "" })).toBe(bareGrok); + expect( + AcpSessionRuntime.resolveLinuxCgroupTargetCommand("grok", scratch, { PATH: scratch }), + ).toBe(bareGrok); + expect(AcpSessionRuntime.resolveLinuxCgroupTargetCommand("grok", scratch, { PATH: "" })).toBe( + bareGrok, + ); // Undefined PATH uses Node's default search path (/usr/bin:/bin), not cwd alone // (bareGrok lives only in cwd / PATH=scratch). - expect(resolveLinuxCgroupTargetCommand("grok", scratch, { PATH: undefined })).toBeUndefined(); - expect(resolveLinuxCgroupTargetCommand("node", scratch, { PATH: undefined })).toBeDefined(); - expect(resolveLinuxCgroupTargetCommand("grok", scratch, { PATH: "relative-bin" })).toBe( - NodePath.join(relativeBin, "grok"), - ); expect( - resolveLinuxCgroupTargetCommand("grok", scratch, { + AcpSessionRuntime.resolveLinuxCgroupTargetCommand("grok", scratch, { PATH: undefined }), + ).toBeUndefined(); + expect( + AcpSessionRuntime.resolveLinuxCgroupTargetCommand("node", scratch, { PATH: undefined }), + ).toBeDefined(); + expect( + AcpSessionRuntime.resolveLinuxCgroupTargetCommand("grok", scratch, { + PATH: "relative-bin", + }), + ).toBe(NodePath.join(relativeBin, "grok")); + expect( + AcpSessionRuntime.resolveLinuxCgroupTargetCommand("grok", scratch, { PATH: `directory-bin${NodePath.delimiter}valid-bin`, }), ).toBe(NodePath.join(validBin, "grok")); @@ -214,7 +206,7 @@ describe("terminatePosixOwnedProcessTree", () => { " wrapper: process.env.T3_ACP_CGROUP_WRAPPER,", "}));", ].join("\n"); - const wrapped = wrapCommandForLinuxCgroup(lease, linkedNode, [ + const wrapped = AcpSessionRuntime.wrapCommandForLinuxCgroup(lease, linkedNode, [ "-e", target, outputPath, @@ -236,7 +228,7 @@ describe("terminatePosixOwnedProcessTree", () => { '{"args":["space value","single\'quote","double\\"quote"]}', ); } finally { - yield* terminateLinuxCgroupLease(lease).pipe(Effect.ignore); + yield* AcpSessionRuntime.terminateLinuxCgroupLease(lease).pipe(Effect.ignore); NodeFS.rmSync(scratch, { recursive: true, force: true }); } }), @@ -251,9 +243,11 @@ describe("terminatePosixOwnedProcessTree", () => { const outputPath = NodePath.join(scratch, "argv output"); NodeFS.mkdirSync(leasePath); NodeFS.writeFileSync(NodePath.join(leasePath, "cgroup.procs"), ""); - const relativePath = parseUnifiedCgroupPath(NodeFS.readFileSync("/proc/self/cgroup", "utf8")); + const relativePath = AcpSessionRuntime.parseUnifiedCgroupPath( + NodeFS.readFileSync("/proc/self/cgroup", "utf8"), + ); expect(relativePath).toBeDefined(); - const lease: AcpLinuxCgroupLease = { + const lease: AcpSessionRuntime.AcpLinuxCgroupLease = { contains: () => false, exists: () => true, path: leasePath, @@ -264,7 +258,7 @@ describe("terminatePosixOwnedProcessTree", () => { }; const packagedExecPath = vi.spyOn(process, "execPath", "get").mockReturnValue("/bin/sh"); try { - const wrapped = wrapCommandForLinuxCgroup(lease, "/bin/sh", [ + const wrapped = AcpSessionRuntime.wrapCommandForLinuxCgroup(lease, "/bin/sh", [ "-c", 'printf "%s\\n" "$0" "$1" "$2" "$3" "${ELECTRON_RUN_AS_NODE-}" "${T3_ACP_CGROUP_WRAPPER-}" > "$4"', "packaged-target", @@ -289,7 +283,7 @@ describe("terminatePosixOwnedProcessTree", () => { /^\d+\n$/, ); - const mismatch = wrapCommandForLinuxCgroup( + const mismatch = AcpSessionRuntime.wrapCommandForLinuxCgroup( { ...lease, relativePath: "/not-the-current-cgroup" }, "/bin/sh", ["-c", 'printf executed > "$0"', outputPath], @@ -301,7 +295,11 @@ describe("terminatePosixOwnedProcessTree", () => { expect(mismatchResult.status, mismatchResult.stderr).toBe(126); expect(NodeFS.existsSync(outputPath)).toBe(false); - const missingTarget = wrapCommandForLinuxCgroup(lease, "/nonexistent-t3-probe", []); + const missingTarget = AcpSessionRuntime.wrapCommandForLinuxCgroup( + lease, + "/nonexistent-t3-probe", + [], + ); const missingTargetResult = NodeChildProcess.spawnSync( missingTarget.command, missingTarget.args, @@ -319,12 +317,12 @@ describe("terminatePosixOwnedProcessTree", () => { it.live("kills a post-TERM detached double fork without touching an unrelated sentinel", () => Effect.gen(function* () { if ((yield* HostProcessPlatform) !== "linux") return; - const controller = makeLinuxCgroupController(); + const controller = AcpSessionRuntime.makeLinuxCgroupController(); const lease = controller.create(); if (lease === undefined) return; - expect(parseUnifiedCgroupPath(NodeFS.readFileSync("/proc/self/cgroup", "utf8"))).not.toBe( - lease.relativePath, - ); + expect( + AcpSessionRuntime.parseUnifiedCgroupPath(NodeFS.readFileSync("/proc/self/cgroup", "utf8")), + ).not.toBe(lease.relativePath); const scratchRoot = NodePath.join(process.cwd(), "tmp"); NodeFS.mkdirSync(scratchRoot, { recursive: true }); const scratch = NodeFS.mkdtempSync(NodePath.join(scratchRoot, "acp-cgroup-kill-")); @@ -349,7 +347,7 @@ describe("terminatePosixOwnedProcessTree", () => { "});", "setInterval(() => {}, 1000);", ].join("\n"); - const wrapped = wrapCommandForLinuxCgroup(lease, process.execPath, [ + const wrapped = AcpSessionRuntime.wrapCommandForLinuxCgroup(lease, process.execPath, [ "-e", providerProgram, readyPath, @@ -386,7 +384,7 @@ describe("terminatePosixOwnedProcessTree", () => { } }).pipe(Effect.timeout("2 seconds")); expect(processExists(detachedChildPid)).toBe(true); - yield* terminateLinuxCgroupLease(lease); + yield* AcpSessionRuntime.terminateLinuxCgroupLease(lease); expect(processExists(sentinel.pid!)).toBe(true); yield* Effect.gen(function* () { while (processExists(detachedChildPid!)) yield* Effect.sleep("10 millis"); @@ -397,11 +395,11 @@ describe("terminatePosixOwnedProcessTree", () => { expect(replacement?.path).not.toBe(lease.path); if (replacement !== undefined) { NodeFS.mkdirSync(NodePath.join(replacement.path, "nested")); - yield* terminateLinuxCgroupLease(replacement); + yield* AcpSessionRuntime.terminateLinuxCgroupLease(replacement); expect(NodeFS.existsSync(replacement.path)).toBe(false); } } finally { - yield* terminateLinuxCgroupLease(lease).pipe(Effect.ignore); + yield* AcpSessionRuntime.terminateLinuxCgroupLease(lease).pipe(Effect.ignore); try { process.kill(-provider.pid!, "SIGKILL"); } catch { @@ -430,7 +428,7 @@ describe("terminatePosixOwnedProcessTree", () => { let killCalls = 0; let populated = false; let removeCalls = 0; - const lease: AcpLinuxCgroupLease = { + const lease: AcpSessionRuntime.AcpLinuxCgroupLease = { contains: () => false, exists: () => exists, path: "/test/t3-acp-repopulation", @@ -457,11 +455,11 @@ describe("terminatePosixOwnedProcessTree", () => { }, }; - yield* terminateLinuxCgroupLease(lease); + yield* AcpSessionRuntime.terminateLinuxCgroupLease(lease); expect(killCalls).toBeGreaterThanOrEqual(3); expect(removeCalls).toBe(3); expect(exists).toBe(false); - yield* terminateLinuxCgroupLease(lease); + yield* AcpSessionRuntime.terminateLinuxCgroupLease(lease); expect(removeCalls).toBe(3); }), ); @@ -471,7 +469,7 @@ describe("terminatePosixOwnedProcessTree", () => { const removeError = Object.assign(new Error("cgroup remains busy"), { code: "EBUSY", }); - const lease: AcpLinuxCgroupLease = { + const lease: AcpSessionRuntime.AcpLinuxCgroupLease = { contains: () => false, exists: () => true, path: "/test/t3-acp-busy", @@ -483,7 +481,7 @@ describe("terminatePosixOwnedProcessTree", () => { }, }; - const error = yield* Effect.flip(terminateLinuxCgroupLease(lease)); + const error = yield* Effect.flip(AcpSessionRuntime.terminateLinuxCgroupLease(lease)); expect(error.detail).toBe("Failed to remove ACP cgroup /test/t3-acp-busy"); expect(error.cause).toBe(removeError); }), @@ -493,13 +491,16 @@ describe("terminatePosixOwnedProcessTree", () => { const fixture = makeController({ processes: [server(), identity(100, process.pid, 100, 100, "owned")], }); - const ownedRoot: AcpOwnedPosixProcess = { + const ownedRoot: AcpSessionRuntime.AcpOwnedPosixProcess = { ...identity(100, process.pid, 100, 100, "owned"), parentExecutable: server().executable, parentStartTime: server().startTime, }; - const root: AcpPosixOwnershipRoot = { captureAttempted: true, value: ownedRoot }; - const lease: AcpLinuxCgroupLease = { + const root: AcpSessionRuntime.AcpPosixOwnershipRoot = { + captureAttempted: true, + value: ownedRoot, + }; + const lease: AcpSessionRuntime.AcpLinuxCgroupLease = { contains: () => true, exists: () => true, path: "/test/t3-acp-root", @@ -508,19 +509,19 @@ describe("terminatePosixOwnedProcessTree", () => { populated: () => true, remove: () => undefined, }; - signalLinuxCgroupRootTerm({ controller: fixture.controller, lease, root }); + AcpSessionRuntime.signalLinuxCgroupRootTerm({ controller: fixture.controller, lease, root }); expect(fixture.signals).toEqual(["process:100:SIGTERM"]); const reused = makeController({ processes: [server(), identity(100, process.pid, 100, 100, "reused")], }); - signalLinuxCgroupRootTerm({ controller: reused.controller, lease, root }); + AcpSessionRuntime.signalLinuxCgroupRootTerm({ controller: reused.controller, lease, root }); expect(reused.signals).toEqual([]); const migrated = makeController({ processes: [server(), identity(100, process.pid, 100, 100, "owned")], }); - signalLinuxCgroupRootTerm({ + AcpSessionRuntime.signalLinuxCgroupRootTerm({ controller: migrated.controller, lease: { ...lease, contains: () => false }, root, @@ -565,9 +566,9 @@ describe("terminatePosixOwnedProcessTree", () => { }).pipe(Effect.timeout("2 seconds")); const [workerTid, childPid] = published; expect(workerTid).not.toBe(helper.pid); - expect(makePosixProcessTreeController("linux").childrenOf(helper.pid!)).toContainEqual( - expect.objectContaining({ pid: childPid, ppid: helper.pid }), - ); + expect( + AcpSessionRuntime.makePosixProcessTreeController("linux").childrenOf(helper.pid!), + ).toContainEqual(expect.objectContaining({ pid: childPid, ppid: helper.pid })); } finally { try { process.kill(-helper.pid!, "SIGKILL"); @@ -585,11 +586,11 @@ describe("terminatePosixOwnedProcessTree", () => { const fixture = makeController({ processes: [server(), identity(100, process.pid, 100, 100), beforeExec], }); - const frontier = new Map(); + const frontier = new Map(); const childQueues = new Map>(); - const ledger = new Map(); - const root: AcpPosixOwnershipRoot = { value: undefined }; - observePosixOwnershipLedger({ + const ledger = new Map(); + const root: AcpSessionRuntime.AcpPosixOwnershipRoot = { value: undefined }; + AcpSessionRuntime.observePosixOwnershipLedger({ childQueues, controller: fixture.controller, frontier, @@ -598,7 +599,7 @@ describe("terminatePosixOwnedProcessTree", () => { rootPid: 100, }); fixture.processes.set(110, { ...beforeExec, executable: "/usr/bin/bash" }); - observePosixOwnershipLedger({ + AcpSessionRuntime.observePosixOwnershipLedger({ childQueues, controller: fixture.controller, frontier, @@ -610,7 +611,7 @@ describe("terminatePosixOwnedProcessTree", () => { expect([...ledger.values()].find((process) => process.pid === 110)?.executable).toBe( "/usr/bin/bash", ); - yield* terminatePosixOwnedProcessTree({ + yield* AcpSessionRuntime.terminatePosixOwnedProcessTree({ controller: fixture.controller, grace: 0, ledger, @@ -623,13 +624,13 @@ describe("terminatePosixOwnedProcessTree", () => { it("never adopts a replacement PID after initial root capture failed", () => { const fixture = makeController({ processes: [server()] }); - const frontier = new Map(); + const frontier = new Map(); const childQueues = new Map>(); - const ledger = new Map(); - const root: AcpPosixOwnershipRoot = { value: undefined }; + const ledger = new Map(); + const root: AcpSessionRuntime.AcpPosixOwnershipRoot = { value: undefined }; const capture = () => { try { - observePosixOwnershipLedger({ + AcpSessionRuntime.observePosixOwnershipLedger({ childQueues, controller: fixture.controller, frontier, @@ -663,7 +664,7 @@ describe("terminatePosixOwnedProcessTree", () => { const fixture = makeController({ processes: [server(), identity(100, process.pid, 100, 100)], }); - const controller: AcpPosixProcessTreeController = { + const controller: AcpSessionRuntime.AcpPosixProcessTreeController = { ...fixture.controller, childPidsOf: (pid) => { childListReads += 1; @@ -678,10 +679,10 @@ describe("terminatePosixOwnedProcessTree", () => { return fixture.controller.snapshot(); }, }; - const frontier = new Map(); + const frontier = new Map(); const childQueues = new Map>(); - const ledger = new Map(); - const root: AcpPosixOwnershipRoot = { value: undefined }; + const ledger = new Map(); + const root: AcpSessionRuntime.AcpPosixOwnershipRoot = { value: undefined }; for (let index = 0; index < 5_000; index += 1) { const tombstone = identity(100_000 + index, 1, 100_000 + index, 100_000 + index); ledger.set(`${tombstone.pid}:${tombstone.startTime}`, { @@ -690,7 +691,7 @@ describe("terminatePosixOwnedProcessTree", () => { parentStartTime: "", }); } - observePosixOwnershipLedger({ + AcpSessionRuntime.observePosixOwnershipLedger({ childQueues, controller, frontier, @@ -714,7 +715,7 @@ describe("terminatePosixOwnedProcessTree", () => { ) { childListReads = 0; identityCalls = 0; - observePosixOwnershipLedger({ + AcpSessionRuntime.observePosixOwnershipLedger({ childQueues, controller, frontier, @@ -732,7 +733,7 @@ describe("terminatePosixOwnedProcessTree", () => { ); expect(missing, `missing after ${passes} passes`).toEqual([]); expect(passes).toBeGreaterThan(2); - yield* terminatePosixOwnedProcessTree({ + yield* AcpSessionRuntime.terminatePosixOwnedProcessTree({ controller, grace: 0, ledger, @@ -751,7 +752,7 @@ describe("terminatePosixOwnedProcessTree", () => { const fixture = makeController({ processes: [server(), identity(100, process.pid, 100, 100), identity(110, 100, 110, 110)], }); - const controller: AcpPosixProcessTreeController = { + const controller: AcpSessionRuntime.AcpPosixProcessTreeController = { ...fixture.controller, childPidsOf: (pid) => { childrenCalls += 1; @@ -767,16 +768,16 @@ describe("terminatePosixOwnedProcessTree", () => { }, }; const scope = yield* Scope.make(); - const frontier = new Map(); + const frontier = new Map(); const childQueues = new Map>(); let ledgerValuesCalls = 0; - const ledger = new (class extends Map { - override values(): MapIterator { + const ledger = new (class extends Map { + override values(): MapIterator { ledgerValuesCalls += 1; return super.values(); } })(); - const root: AcpPosixOwnershipRoot = { value: undefined }; + const root: AcpSessionRuntime.AcpPosixOwnershipRoot = { value: undefined }; for (let index = 0; index < 5_000; index += 1) { const tombstone = identity(10_000 + index, 1, 10_000 + index, 10_000 + index); ledger.set(`${tombstone.pid}:${tombstone.startTime}`, { @@ -785,7 +786,7 @@ describe("terminatePosixOwnedProcessTree", () => { parentStartTime: "", }); } - yield* observePosixOwnershipLedgerContinuously({ + yield* AcpSessionRuntime.observePosixOwnershipLedgerContinuously({ childQueues, controller, frontier, @@ -831,7 +832,7 @@ describe("terminatePosixOwnedProcessTree", () => { }, }); - yield* terminatePosixOwnedProcessTree({ + yield* AcpSessionRuntime.terminatePosixOwnedProcessTree({ controller: fixture.controller, grace: 0, rootPid: 100, @@ -865,7 +866,7 @@ describe("terminatePosixOwnedProcessTree", () => { }, }); - yield* terminatePosixOwnedProcessTree({ + yield* AcpSessionRuntime.terminatePosixOwnedProcessTree({ controller: fixture.controller, grace: 0, rootPid: 100, @@ -893,7 +894,7 @@ describe("terminatePosixOwnedProcessTree", () => { }); const result = yield* Effect.exit( - terminatePosixOwnedProcessTree({ + AcpSessionRuntime.terminatePosixOwnedProcessTree({ controller: fixture.controller, grace: 0, rootPid: 100, @@ -913,9 +914,13 @@ describe("terminatePosixOwnedProcessTree", () => { it.live("does not treat zombie residual entries as teardown survivors", () => Effect.gen(function* () { - expect(posixProcessIsZombie({ ...identity(1, 0, 1, 1), state: "Z" })).toBe(true); - expect(posixProcessIsZombie({ ...identity(1, 0, 1, 1), state: "S" })).toBe(false); - const withoutState: AcpPosixProcessIdentity = { + expect(AcpSessionRuntime.posixProcessIsZombie({ ...identity(1, 0, 1, 1), state: "Z" })).toBe( + true, + ); + expect(AcpSessionRuntime.posixProcessIsZombie({ ...identity(1, 0, 1, 1), state: "S" })).toBe( + false, + ); + const withoutState: AcpSessionRuntime.AcpPosixProcessIdentity = { executable: "/proc/1/exe", pgid: 1, pid: 1, @@ -923,7 +928,7 @@ describe("terminatePosixOwnedProcessTree", () => { sid: 1, startTime: "1", }; - expect(posixProcessIsZombie(withoutState)).toBe(false); + expect(AcpSessionRuntime.posixProcessIsZombie(withoutState)).toBe(false); const root = identity(100, process.pid, 100, 100); const zombieChild = identity(110, 100, 110, 110, "110", "Z"); @@ -936,7 +941,7 @@ describe("terminatePosixOwnedProcessTree", () => { processes.set(pid, { ...current, state: "Z" }); }, }); - yield* terminatePosixOwnedProcessTree({ + yield* AcpSessionRuntime.terminatePosixOwnedProcessTree({ controller: fixture.controller, grace: 0, rootPid: 100, @@ -948,19 +953,19 @@ describe("terminatePosixOwnedProcessTree", () => { ); it("admits a live child after a free PID was previously reserved by a dead entry", () => { - const staleChild: AcpOwnedPosixProcess = { + const staleChild: AcpSessionRuntime.AcpOwnedPosixProcess = { ...identity(200, 100, 200, 200, "stale-child"), parentExecutable: `/proc/100/exe`, parentStartTime: "100", }; const liveChild = identity(200, 100, 200, 200, "live-child"); const root = identity(100, process.pid, 100, 100); - const ownedRoot: AcpOwnedPosixProcess = { + const ownedRoot: AcpSessionRuntime.AcpOwnedPosixProcess = { ...root, parentExecutable: `/proc/${process.pid}/exe`, parentStartTime: "server", }; - const ledger = new Map([ + const ledger = new Map([ [`100:${root.startTime}`, ownedRoot], [`200:${staleChild.startTime}`, staleChild], ]); @@ -969,7 +974,7 @@ describe("terminatePosixOwnedProcessTree", () => { }).controller; // First capture: PID 200 is free, so the dead reservation is dropped. - capturePosixOwnershipLedger({ + AcpSessionRuntime.capturePosixOwnershipLedger({ controller, ledger, rootPid: 100, @@ -978,7 +983,7 @@ describe("terminatePosixOwnedProcessTree", () => { expect([...ledger.values()].some((entry) => entry.pid === 200)).toBe(false); // Second capture: a new process reuses PID 200 under the owned parent. - capturePosixOwnershipLedger({ + AcpSessionRuntime.capturePosixOwnershipLedger({ controller: makeController({ processes: [server(), root, liveChild] }).controller, ledger, rootPid: 100, @@ -990,24 +995,24 @@ describe("terminatePosixOwnedProcessTree", () => { }); it("admits a live child when a stale ledger entry still occupies the reused PID", () => { - const staleChild: AcpOwnedPosixProcess = { + const staleChild: AcpSessionRuntime.AcpOwnedPosixProcess = { ...identity(200, 100, 200, 200, "stale-child"), parentExecutable: `/proc/100/exe`, parentStartTime: "100", }; const liveChild = identity(200, 100, 200, 200, "live-child"); const root = identity(100, process.pid, 100, 100); - const ownedRoot: AcpOwnedPosixProcess = { + const ownedRoot: AcpSessionRuntime.AcpOwnedPosixProcess = { ...root, parentExecutable: `/proc/${process.pid}/exe`, parentStartTime: "server", }; - const ledger = new Map([ + const ledger = new Map([ [`100:${root.startTime}`, ownedRoot], [`200:${staleChild.startTime}`, staleChild], ]); - capturePosixOwnershipLedger({ + AcpSessionRuntime.capturePosixOwnershipLedger({ controller: makeController({ processes: [server(), root, liveChild] }).controller, ledger, rootPid: 100, @@ -1040,7 +1045,7 @@ describe("terminatePosixOwnedProcessTree", () => { }, }); - yield* terminatePosixOwnedProcessTree({ + yield* AcpSessionRuntime.terminatePosixOwnedProcessTree({ controller: fixture.controller, grace: 0, rootPid: 100, @@ -1064,7 +1069,7 @@ describe("terminatePosixOwnedProcessTree", () => { }, }); let snapshotCalls = 0; - const controller: AcpPosixProcessTreeController = { + const controller: AcpSessionRuntime.AcpPosixProcessTreeController = { ...fixture.controller, snapshot: () => { snapshotCalls += 1; @@ -1076,7 +1081,7 @@ describe("terminatePosixOwnedProcessTree", () => { }; const error = yield* Effect.flip( - terminatePosixOwnedProcessTree({ + AcpSessionRuntime.terminatePosixOwnedProcessTree({ controller, discoveryPasses: 2, grace: 0, @@ -1092,10 +1097,10 @@ describe("terminatePosixOwnedProcessTree", () => { it.live("retains the ledger across root exit and an explicit failure for finalizer retry", () => Effect.gen(function* () { let ignoreSignals = true; - const ledger = new Map(); - const frontier = new Map(); + const ledger = new Map(); + const frontier = new Map(); const childQueues = new Map>(); - const root: AcpPosixOwnershipRoot = { value: undefined }; + const root: AcpSessionRuntime.AcpPosixOwnershipRoot = { value: undefined }; const fixture = makeController({ processes: [ server(), @@ -1108,7 +1113,7 @@ describe("terminatePosixOwnedProcessTree", () => { if (!ignoreSignals) processes.delete(pid); }, }); - observePosixOwnershipLedger({ + AcpSessionRuntime.observePosixOwnershipLedger({ childQueues, controller: fixture.controller, frontier, @@ -1118,7 +1123,7 @@ describe("terminatePosixOwnedProcessTree", () => { }); const explicit = yield* Effect.exit( - terminatePosixOwnedProcessTree({ + AcpSessionRuntime.terminatePosixOwnedProcessTree({ controller: fixture.controller, grace: 0, ledger, @@ -1130,7 +1135,7 @@ describe("terminatePosixOwnedProcessTree", () => { expect(fixture.processes.has(111)).toBe(true); fixture.processes.set(112, identity(112, 110, 112, 112)); - observePosixOwnershipLedger({ + AcpSessionRuntime.observePosixOwnershipLedger({ childQueues, controller: fixture.controller, frontier, @@ -1139,7 +1144,7 @@ describe("terminatePosixOwnedProcessTree", () => { rootPid: 100, }); ignoreSignals = false; - yield* terminatePosixOwnedProcessTree({ + yield* AcpSessionRuntime.terminatePosixOwnedProcessTree({ controller: fixture.controller, grace: 0, ledger, @@ -1153,7 +1158,7 @@ describe("terminatePosixOwnedProcessTree", () => { it("fails closed where a stable POSIX identity provider is unavailable", () => { try { - makePosixProcessTreeController("darwin"); + AcpSessionRuntime.makePosixProcessTreeController("darwin"); throw new Error("Expected Darwin process ownership to fail closed"); } catch (error) { expect(error).toMatchObject({ diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts deleted file mode 100644 index 9c2cb395f3074b0e78dd23998702fe261aba53ad..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ /dev/null @@ -1,3035 +0,0 @@ -// @effect-diagnostics nodeBuiltinImport:off -import * as NodeCrypto from "node:crypto"; -import * as NodeFS from "node:fs"; -import * as NodePath from "node:path"; - -import * as Cause from "effect/Cause"; -import * as Clock from "effect/Clock"; -import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; -import * as Deferred from "effect/Deferred"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; -import * as Fiber from "effect/Fiber"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Queue from "effect/Queue"; -import * as Ref from "effect/Ref"; -import * as Schema from "effect/Schema"; -import * as Scope from "effect/Scope"; -import * as Semaphore from "effect/Semaphore"; -import * as Stream from "effect/Stream"; -import * as ChildProcess from "effect/process/ChildProcess"; -import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; -import * as EffectAcpClient from "effect-acp/client"; -import * as EffectAcpErrors from "effect-acp/errors"; -import type * as EffectAcpSchema from "effect-acp/compat"; -import type * as EffectAcpProtocol from "effect-acp/protocol"; -import { resolveSpawnCommand } from "@t3tools/shared/shell"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; - -import { signalProcessGroup } from "@t3tools/provider-core/server/processGroup"; -import { appendAcpStderrTail, sanitizeAcpStderrExcerpt } from "./AcpStderr.ts"; -import { - collectSessionConfigOptionValues, - decideToolCallUpdateEmission, - extractModelConfigId, - findSessionConfigOption, - mergeToolCallState, - toolCallProgressLength, - parseSessionModeState, - parseSessionUpdateEvent, - sessionUpdateCountsAsLoadReplayActivity, - sessionUpdateIsReplay, - waitForSessionLoadReplayIdle, - type SessionLoadGate, - type AcpParsedSessionEvent, - type AcpSessionModeState, - type AcpToolCallState, -} from "./AcpRuntimeModel.ts"; - -const MAX_SHOWN_TOOL_CALL_IDS = 256; - -interface AcpToolCallTrackedState { - readonly state: AcpToolCallState; - readonly lastEmittedDetailLength: number | undefined; - readonly skippedSinceEmit: number; -} - -function formatConfigOptionValue(value: string | boolean): string { - return JSON.stringify(value); -} - -export interface AcpSessionEventStreamBarrier { - readonly _tag: "EventStreamBarrier"; - readonly acknowledge: Deferred.Deferred; -} - -export type AcpSessionRuntimeEvent = - | AcpParsedSessionEvent - | AcpSessionEventStreamBarrier - | { - readonly _tag: "ConnectionTerminated"; - readonly error: EffectAcpErrors.AcpError; - }; - -const defaultSessionLoadTimeout = Duration.seconds(90); -const defaultSessionLoadReplayIdleGap = Duration.seconds(2); -const defaultCancelTimeout = Duration.seconds(15); -const maxStartupMetadataUpdates = 32; -// Antigravity can emit an accepted 16 KiB Google authorization URL on stderr. -const maxStderrChunkLength = 32_768; - -export interface AcpSpawnInput { - readonly command: string; - readonly args: ReadonlyArray; - readonly cwd?: string; - readonly env?: NodeJS.ProcessEnv; - readonly extendEnv?: boolean; - readonly shell?: false; -} - -export interface AcpSessionRuntimeOptions { - readonly spawn: AcpSpawnInput; - readonly cwd: string; - readonly resumeSessionId?: string; - readonly resumeMethod?: "load" | "resume"; - readonly sessionLoadTimeout?: Duration.Input; - readonly sessionLoadReplayIdleGap?: Duration.Input; - readonly interruptPromptOnCancel?: boolean; - /** Optional provider metadata forwarded on `session/cancel`. */ - readonly cancelMeta?: EffectAcpSchema.CancelNotification["_meta"]; - /** Optional provider metadata forwarded on `initialize`. */ - readonly initializeMeta?: EffectAcpSchema.InitializeRequest["_meta"]; - readonly ownDetachedProcessGroup?: boolean; - readonly ownDescendantProcessGroups?: boolean; - readonly processGroupPlatform?: NodeJS.Platform; - readonly processGroupTerminationGrace?: Duration.Input; - readonly windowsProcessTreeTerminator?: ( - pid: number, - ) => Effect.Effect; - readonly posixProcessTreeController?: AcpPosixProcessTreeController; - readonly linuxCgroupController?: AcpLinuxCgroupController | null; - /** Native cancellation waits for the prompt response and the getEvents consumer to drain. */ - readonly cancelBehavior?: "interrupt" | "wait-for-prompt"; - readonly cancelTimeout?: Duration.Input; - readonly clientCapabilities?: EffectAcpSchema.InitializeRequest["clientCapabilities"]; - readonly clientInfo: { - readonly name: string; - readonly version: string; - }; - readonly authMethodId?: string; - /** - * Call `authenticate` right after `initialize` instead of waiting for an - * auth-required error. Antigravity selects its credential source (Google - * account vs API key) from the method, so the session must not open first. - */ - readonly authenticateEagerly?: boolean; - readonly mcpServers?: ReadonlyArray; - /** Extra workspace roots the agent may read and write besides `cwd`. */ - readonly additionalDirectories?: ReadonlyArray; - /** Transforms provider stdout before protocol parsing and protocol logging. */ - readonly transformStdout?: EffectAcpClient.AcpClientOptions["transformStdout"]; - /** Normalizes provider-specific fields before notification queues or runtime state retain them. */ - readonly transformSessionUpdate?: ( - notification: EffectAcpSchema.SessionNotification, - ) => EffectAcpSchema.SessionNotification; - /** Receives bounded stderr chunks. Redact secrets before logging. A failure closes the runtime. */ - readonly onStderr?: (text: string) => Effect.Effect; - /** Disable only for non-interactive discovery that must surface auth-required immediately. */ - readonly authenticateOnAuthRequired?: boolean; - /** Observes the normalized handshake before any session setup or authentication retry. */ - readonly onInitialized?: ( - result: EffectAcpSchema.InitializeResponse, - ) => Effect.Effect; - readonly acpMcpServers?: ReadonlyArray; - readonly requestLogger?: (event: AcpSessionRequestLogEvent) => Effect.Effect; - readonly protocolLogging?: { - readonly logIncoming?: boolean; - readonly logOutgoing?: boolean; - readonly logger?: (event: EffectAcpProtocol.AcpProtocolLogEvent) => Effect.Effect; - }; - readonly onIncomingRequest?: EffectAcpClient.AcpClientOptions["onIncomingRequest"]; - readonly onTermination?: (error: EffectAcpErrors.AcpError) => Effect.Effect; - readonly onOutgoingResponseFailure?: EffectAcpClient.AcpClientOptions["onOutgoingResponseFailure"]; - readonly onOutgoingResponse?: EffectAcpClient.AcpClientOptions["onOutgoingResponse"]; -} - -export interface AcpSessionRequestLogEvent { - readonly method: string; - readonly payload: unknown; - readonly status: "started" | "succeeded" | "failed"; - readonly result?: unknown; - readonly cause?: Cause.Cause; -} - -export class AcpProcessGroupTerminationError extends Schema.TaggedError()( - "AcpProcessGroupTerminationError", - { - detail: Schema.String, - pid: Schema.optionalKey(Schema.Int), - exitCode: Schema.optionalKey(Schema.Number), - cause: Schema.optional(Schema.Defect()), - }, -) { - override get message(): string { - return this.detail; - } -} - -const isAcpProcessGroupTerminationError = Schema.is(AcpProcessGroupTerminationError); - -export interface AcpPosixProcessIdentity { - readonly executable: string | undefined; - readonly pgid: number; - readonly pid: number; - readonly ppid: number; - readonly sid: number; - /** - * Linux `/proc//stat` state when known (`R`/`S`/`D`/`Z`/…). Omitted on - * platforms or fixtures that do not surface it. Zombies (`Z`) are already - * dead and must not count as residual teardown survivors. - */ - readonly state?: string; - readonly startTime: string; -} - -export interface AcpPosixProcessTreeController { - readonly childPidsOf: (pid: number) => ReadonlyArray; - readonly childrenOf: (pid: number) => ReadonlyArray; - readonly identity: (pid: number) => AcpPosixProcessIdentity | undefined; - readonly snapshot: () => ReadonlyArray; - readonly signalProcess: (pid: number, signal: NodeJS.Signals) => void; -} - -export interface AcpLinuxCgroupLease { - readonly contains: (pid: number) => boolean; - readonly exists: () => boolean; - readonly path: string; - readonly relativePath: string; - readonly kill: () => void; - readonly populated: () => boolean; - readonly remove: () => void; -} - -export interface AcpLinuxCgroupController { - readonly create: () => AcpLinuxCgroupLease | undefined; -} - -export interface AcpPosixOwnershipRoot { - captureAttempted?: boolean; - value: AcpOwnedPosixProcess | undefined; -} - -function decodeMountInfoPath(value: string): string { - return value.replace(/\\([0-7]{3})/g, (_match, octal: string) => - String.fromCharCode(Number.parseInt(octal, 8)), - ); -} - -export function parseUnifiedCgroupPath(contents: string): string | undefined { - const matches = [...contents.matchAll(/^0::(\/.*)$/gm)]; - return matches.length === 1 ? matches[0]![1] : undefined; -} - -export function isSafeCgroupPath(value: string): boolean { - return ( - value.startsWith("/") && - !value.includes("(deleted)") && - NodePath.posix.normalize(value) === value - ); -} - -export function parseCgroup2Mounts( - contents: string, -): ReadonlyArray<{ readonly mountPoint: string; readonly root: string }> { - const mounts: Array<{ readonly mountPoint: string; readonly root: string }> = []; - for (const line of contents.split("\n")) { - const [mount, filesystem] = line.split(" - "); - if (mount === undefined || filesystem?.split(" ")[0] !== "cgroup2") continue; - const fields = mount.split(" "); - if (fields.length < 5) continue; - mounts.push({ - mountPoint: decodeMountInfoPath(fields[4]!), - root: decodeMountInfoPath(fields[3]!), - }); - } - return mounts; -} - -export function parseCgroup2Mount( - contents: string, -): { readonly mountPoint: string; readonly root: string } | undefined { - return parseCgroup2Mounts(contents)[0]; -} - -export function wrapCommandForLinuxCgroup( - lease: AcpLinuxCgroupLease, - command: string, - args: ReadonlyArray, -): { readonly command: string; readonly args: ReadonlyArray } { - return { - command: "/bin/sh", - args: [ - "-c", - [ - "lease_path=$1; expected=$2; shift 2", - 'printf "%s\\n" "$$" > "$lease_path/cgroup.procs" || exit 125', - "actual=", - "while IFS= read -r line; do", - ' case "$line" in 0::*) [ -z "$actual" ] || exit 126; actual=${line#0::};; esac', - "done < /proc/self/cgroup || exit 125", - '[ "$actual" = "$expected" ] || exit 126', - "unset ELECTRON_RUN_AS_NODE T3_ACP_CGROUP_WRAPPER", - "trap 'exit 125' 0", - 'exec "$@"', - ].join("\n"), - "t3-acp-cgroup-wrapper", - lease.path, - lease.relativePath, - command, - ...args, - ], - }; -} - -export function resolveLinuxCgroupTargetCommand( - command: string, - cwd: string, - environment: NodeJS.ProcessEnv, -): string | undefined { - // Match Node spawn PATH fallback when PATH is undefined. An explicitly empty - // PATH stays empty (lookup only via empty-segment → cwd). - const pathEnv = environment.PATH === undefined ? "/usr/bin:/bin" : environment.PATH; - const candidates = command.includes(NodePath.sep) - ? [NodePath.resolve(cwd, command)] - : pathEnv - .split(NodePath.delimiter) - .map((entry) => - NodePath.join( - entry.length === 0 - ? cwd - : NodePath.isAbsolute(entry) - ? entry - : NodePath.resolve(cwd, entry), - command, - ), - ); - for (const candidate of candidates) { - try { - if (!NodeFS.statSync(candidate).isFile()) continue; - NodeFS.accessSync(candidate, NodeFS.constants.X_OK); - return candidate; - } catch { - // Try the next PATH entry. - } - } - return undefined; -} - -const STALE_ACP_CGROUP_SIBLING = /^t3-acp-(\d+)-/; - -export interface SweepStaleLinuxCgroupSiblingsOptions { - readonly currentPid?: number; - readonly isProcessAlive?: (pid: number) => boolean; - readonly readPopulated?: (siblingPath: string) => "0" | "1" | undefined; - readonly remove?: (siblingPath: string) => void; -} - -function defaultCgroupOwnerIsAlive(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch (cause) { - return (cause as NodeJS.ErrnoException | undefined)?.code !== "ESRCH"; - } -} - -function defaultReadCgroupPopulated(siblingPath: string): "0" | "1" | undefined { - const state = /(?:^|\n)populated ([01])(?:\n|$)/.exec( - NodeFS.readFileSync(NodePath.join(siblingPath, "cgroup.events"), "utf8"), - )?.[1]; - return state === "0" || state === "1" ? state : undefined; -} - -/** Best-effort removal of empty `t3-acp--*` sibling leases under a parent cgroup. */ -export function sweepStaleLinuxCgroupSiblings( - parentPath: string, - options: SweepStaleLinuxCgroupSiblingsOptions = {}, -): void { - const currentPid = options.currentPid ?? process.pid; - const isProcessAlive = options.isProcessAlive ?? defaultCgroupOwnerIsAlive; - const readPopulated = options.readPopulated ?? defaultReadCgroupPopulated; - const remove = options.remove ?? ((siblingPath: string) => NodeFS.rmdirSync(siblingPath)); - try { - for (const entry of NodeFS.readdirSync(parentPath, { withFileTypes: true })) { - try { - if (!entry.isDirectory()) continue; - const match = STALE_ACP_CGROUP_SIBLING.exec(entry.name); - if (match === null) continue; - const ownerPid = Number(match[1]); - if (!Number.isSafeInteger(ownerPid) || ownerPid <= 1 || ownerPid === currentPid) continue; - if (isProcessAlive(ownerPid)) continue; - const siblingPath = NodePath.join(parentPath, entry.name); - if (NodePath.dirname(siblingPath) !== parentPath) continue; - if (readPopulated(siblingPath) !== "0") continue; - remove(siblingPath); - } catch { - // Ignore unreadable or busy siblings; never fail lease creation. - } - } - } catch { - // Ignore readdir failures on the parent path. - } -} - -function tryCreateLinuxCgroupLease( - currentRelative: string, - mount: { readonly mountPoint: string; readonly root: string }, -): AcpLinuxCgroupLease | undefined { - if ( - !isSafeCgroupPath(mount.root) || - !isSafeCgroupPath(mount.mountPoint) || - !( - mount.root === "/" || - currentRelative === mount.root || - currentRelative.startsWith(`${mount.root}/`) - ) - ) { - return undefined; - } - const mountRelative = - mount.root === "/" ? currentRelative : currentRelative.slice(mount.root.length); - const currentPath = NodePath.resolve(mount.mountPoint, `.${mountRelative}`); - const resolvedMount = NodePath.resolve(mount.mountPoint); - if (currentPath !== resolvedMount && !currentPath.startsWith(`${resolvedMount}${NodePath.sep}`)) { - return undefined; - } - sweepStaleLinuxCgroupSiblings(currentPath); - const childName = `t3-acp-${process.pid}-${NodeCrypto.randomUUID().replaceAll("-", "")}`; - const childPath = NodePath.join(currentPath, childName); - const childRelative = NodePath.posix.join(currentRelative, childName); - if (NodePath.dirname(childPath) !== currentPath) return undefined; - try { - NodeFS.mkdirSync(childPath, { mode: 0o700 }); - NodeFS.accessSync(NodePath.join(childPath, "cgroup.procs"), NodeFS.constants.W_OK); - NodeFS.accessSync(NodePath.join(childPath, "cgroup.kill"), NodeFS.constants.W_OK); - NodeFS.accessSync(NodePath.join(childPath, "cgroup.events"), NodeFS.constants.R_OK); - if (NodeFS.readFileSync(NodePath.join(childPath, "cgroup.type"), "utf8").trim() !== "domain") { - throw new Error("ACP cgroup is not a domain cgroup"); - } - if (typeof process.execve !== "function") throw new Error("process.execve is unavailable"); - } catch { - try { - NodeFS.rmdirSync(childPath); - } catch { - // The unavailable cgroup was never used for a child process. - } - return undefined; - } - return { - contains: (pid) => - parseUnifiedCgroupPath(NodeFS.readFileSync(`/proc/${pid}/cgroup`, "utf8")) === childRelative, - exists: () => NodeFS.existsSync(childPath), - path: childPath, - relativePath: childRelative, - kill: () => NodeFS.writeFileSync(NodePath.join(childPath, "cgroup.kill"), "1\n"), - populated: () => { - const state = /(?:^|\n)populated ([01])(?:\n|$)/.exec( - NodeFS.readFileSync(NodePath.join(childPath, "cgroup.events"), "utf8"), - )?.[1]; - if (state === undefined) throw new Error("ACP cgroup.events has no populated state"); - return state === "1"; - }, - remove: () => { - const directories = (path: string): ReadonlyArray => - NodeFS.readdirSync(path, { withFileTypes: true }).flatMap((entry) => - entry.isDirectory() - ? [...directories(NodePath.join(path, entry.name)), NodePath.join(path, entry.name)] - : [], - ); - for (const directory of directories(childPath)) NodeFS.rmdirSync(directory); - NodeFS.rmdirSync(childPath); - }, - }; -} - -export function makeLinuxCgroupController(): AcpLinuxCgroupController { - return { - create: () => { - let currentRelative: string | undefined; - let mounts: ReadonlyArray<{ readonly mountPoint: string; readonly root: string }> = []; - try { - currentRelative = parseUnifiedCgroupPath(NodeFS.readFileSync("/proc/self/cgroup", "utf8")); - mounts = parseCgroup2Mounts(NodeFS.readFileSync("/proc/self/mountinfo", "utf8")); - } catch { - return undefined; - } - if ( - currentRelative === undefined || - !currentRelative.startsWith("/") || - mounts.length === 0 - ) { - return undefined; - } - if (!isSafeCgroupPath(currentRelative)) return undefined; - // Prefer the first mount that successfully creates a writable child cgroup. - // Multi-mount hosts often list a read-only view before the delegated one. - for (const mount of mounts) { - const lease = tryCreateLinuxCgroupLease(currentRelative, mount); - if (lease !== undefined) return lease; - } - return undefined; - }, - }; -} - -// OS-state polls (cgroup emptiness, process trees) must use wall time. Under -// TestClock, Effect.sleep never resolves unless the test advances the clock. -const wallClock = Clock.Clock.defaultValue(); -const withWallClock = (effect: Effect.Effect): Effect.Effect => - Effect.provideService(effect, Clock.Clock, wallClock); - -export function terminateLinuxCgroupLease( - lease: AcpLinuxCgroupLease, -): Effect.Effect { - const failure = (name: string, cause: unknown) => - new AcpProcessGroupTerminationError({ - cause, - detail: `Failed to ${name} ACP cgroup ${lease.path}`, - }); - return Effect.gen(function* () { - let lastRemovalFailure: { readonly cause: unknown } | null = null; - for (let attempt = 0; attempt < 200; attempt += 1) { - if (!lease.exists()) return; - try { - lease.kill(); - } catch (cause) { - const code = (cause as NodeJS.ErrnoException | undefined)?.code; - if (code !== "ENOENT") return yield* failure("kill", cause); - if (!lease.exists()) return; - yield* Effect.sleep("10 millis"); - continue; - } - let populated: boolean; - try { - populated = lease.populated(); - } catch (cause) { - const code = (cause as NodeJS.ErrnoException | undefined)?.code; - if (code === "ENOENT" && !lease.exists()) return; - if (code === "ENOENT") { - yield* Effect.sleep("10 millis"); - continue; - } - return yield* failure("read state for", cause); - } - if (populated) { - lastRemovalFailure = null; - yield* Effect.sleep("10 millis"); - continue; - } - try { - lease.remove(); - lastRemovalFailure = null; - } catch (cause) { - const code = (cause as NodeJS.ErrnoException | undefined)?.code; - if (code === "ENOENT" && !lease.exists()) return; - if (code !== "EBUSY" && code !== "ENOTEMPTY" && code !== "ENOENT") { - return yield* failure("remove", cause); - } - lastRemovalFailure = { cause }; - } - if (!lease.exists()) return; - yield* Effect.sleep("10 millis"); - } - if (lastRemovalFailure !== null) { - return yield* failure("remove", lastRemovalFailure.cause); - } - return yield* new AcpProcessGroupTerminationError({ - detail: `ACP cgroup ${lease.path} remained present after cgroup cleanup`, - }); - }).pipe(withWallClock); -} - -export function signalLinuxCgroupRootTerm(input: { - readonly controller: AcpPosixProcessTreeController; - readonly lease: AcpLinuxCgroupLease; - readonly root: AcpPosixOwnershipRoot; -}): void { - const root = input.root.value; - if (root === undefined) return; - try { - const observed = input.controller.identity(root.pid); - if (!samePosixProcessIdentity(root, observed) || !input.lease.contains(root.pid)) return; - input.controller.signalProcess(root.pid, "SIGTERM"); - } catch { - // TERM is optional. cgroup.kill remains the authoritative teardown. - } -} - -function readLinuxProcessIdentity(pid: number): AcpPosixProcessIdentity | undefined { - try { - const stat = NodeFS.readFileSync(`/proc/${pid}/stat`, "utf8"); - const commandEnd = stat.lastIndexOf(")"); - if (commandEnd < 0) return undefined; - const fields = stat - .slice(commandEnd + 2) - .trim() - .split(/\s+/); - // After comm: state, ppid, pgrp, session, … starttime (field 22 / index 19). - const state = fields[0]; - const ppid = Number(fields[1]); - const pgid = Number(fields[2]); - const sid = Number(fields[3]); - const startTime = fields[19]; - if ( - state === undefined || - !Number.isSafeInteger(ppid) || - !Number.isSafeInteger(pgid) || - !Number.isSafeInteger(sid) || - startTime === undefined - ) { - return undefined; - } - let executable: string | undefined; - try { - executable = NodeFS.readlinkSync(`/proc/${pid}/exe`); - } catch { - executable = undefined; - } - return { executable, pgid, pid, ppid, sid, startTime, state }; - } catch { - return undefined; - } -} - -/** Linux zombies keep pid/starttime until reaped; they are not live survivors. */ -export function posixProcessIsZombie(process: AcpPosixProcessIdentity): boolean { - return process.state === "Z"; -} - -function readLinuxChildPids(pid: number): ReadonlyArray { - const childPids = new Set(); - let taskIds: ReadonlyArray = []; - try { - taskIds = NodeFS.readdirSync(`/proc/${pid}/task`).filter((entry) => - /^[1-9][0-9]*$/.test(entry), - ); - } catch { - return []; - } - for (const taskId of taskIds) { - try { - for (const childPid of NodeFS.readFileSync(`/proc/${pid}/task/${taskId}/children`, "utf8") - .trim() - .split(/\s+/) - .filter(Boolean) - .map(Number)) { - childPids.add(childPid); - } - } catch { - // The task exited while its children were being read. - } - } - return [...childPids]; -} - -function makeLinuxProcessTreeController(): AcpPosixProcessTreeController { - return { - childPidsOf: readLinuxChildPids, - childrenOf: (pid) => - readLinuxChildPids(pid).flatMap((childPid) => { - const identity = readLinuxProcessIdentity(childPid); - return identity === undefined ? [] : [identity]; - }), - identity: readLinuxProcessIdentity, - snapshot: () => - NodeFS.readdirSync("/proc", { withFileTypes: true }).flatMap((entry) => { - if (!entry.isDirectory() || !/^[1-9][0-9]*$/.test(entry.name)) return []; - const identity = readLinuxProcessIdentity(Number(entry.name)); - return identity === undefined ? [] : [identity]; - }), - signalProcess: (pid, signal) => process.kill(pid, signal), - }; -} - -export function observePosixOwnershipLedger(input: { - readonly childQueues: Map>; - readonly controller: AcpPosixProcessTreeController; - readonly frontier: Map; - readonly ledger: Map; - readonly maxProcesses?: number; - readonly root: AcpPosixOwnershipRoot; - readonly rootPid: number; -}): void { - let rootIdentity: AcpPosixProcessIdentity | undefined = input.root.value; - if (rootIdentity === undefined) { - if (input.root.captureAttempted === true) { - throw new AcpProcessGroupTerminationError({ - detail: `ACP process ${input.rootPid} exited before its ownership ledger was captured`, - }); - } - input.root.captureAttempted = true; - rootIdentity = input.controller.identity(input.rootPid); - if (rootIdentity === undefined) { - throw new AcpProcessGroupTerminationError({ - detail: `ACP process ${input.rootPid} exited before its ownership ledger was captured`, - }); - } - const parent = input.controller.identity(rootIdentity.ppid); - const ownedRoot = { - ...rootIdentity, - parentExecutable: parent?.executable, - parentStartTime: parent?.startTime ?? "", - }; - input.ledger.set(processIdentityKey(rootIdentity), ownedRoot); - input.frontier.set(rootIdentity.pid, ownedRoot); - input.root.value = ownedRoot; - } - const maxProcesses = Math.max(1, input.maxProcesses ?? Number.POSITIVE_INFINITY); - let remainingBudget = maxProcesses; - const pending = [...input.frontier.entries()]; - const visited = new Set(); - while (pending.length > 0 && remainingBudget > 0) { - const entry = pending.shift(); - if (entry === undefined) continue; - const [parentPid, parent] = entry; - input.frontier.delete(parentPid); - if (visited.has(parent.pid)) continue; - visited.add(parent.pid); - remainingBudget -= 1; - const observedParent = input.controller.identity(parent.pid); - if (!samePosixProcessIdentity(parent, observedParent)) { - input.childQueues.delete(parent.pid); - continue; - } - const refreshedParent = { ...parent, ...observedParent }; - input.ledger.set(processIdentityKey(refreshedParent), refreshedParent); - input.frontier.set(refreshedParent.pid, refreshedParent); - let queuedChildren = input.childQueues.get(refreshedParent.pid); - if (queuedChildren === undefined) { - if (remainingBudget === 0) continue; - remainingBudget -= 1; - queuedChildren = [...input.controller.childPidsOf(refreshedParent.pid)]; - } - while (queuedChildren.length > 0 && remainingBudget > 0) { - const childPid = queuedChildren.shift(); - if (childPid === undefined) continue; - remainingBudget -= 1; - const child = input.controller.identity(childPid); - if (child === undefined) continue; - if (child.ppid !== parent.pid) continue; - let reservedPid = input.frontier.get(child.pid); - if (reservedPid !== undefined && !samePosixProcessIdentity(reservedPid, child)) { - // PID reused under an owned parent; drop the stale reservation. - input.frontier.delete(child.pid); - for (const [key, owned] of input.ledger.entries()) { - if (owned.pid === child.pid) input.ledger.delete(key); - } - reservedPid = undefined; - } - const childKey = processIdentityKey(child); - const owned = - reservedPid === undefined - ? { - ...child, - parentExecutable: refreshedParent.executable, - parentStartTime: refreshedParent.startTime, - } - : { ...reservedPid, ...child }; - input.ledger.set(childKey, owned); - input.frontier.set(child.pid, owned); - } - if (queuedChildren.length === 0) input.childQueues.delete(refreshedParent.pid); - else input.childQueues.set(refreshedParent.pid, queuedChildren); - } -} - -export function observePosixOwnershipLedgerContinuously(input: { - readonly childQueues: Map>; - readonly controller: AcpPosixProcessTreeController; - readonly frontier: Map; - readonly ledger: Map; - readonly root: AcpPosixOwnershipRoot; - readonly rootPid: number; -}): Effect.Effect { - return Effect.forever( - Effect.gen(function* () { - const startedAt = yield* Clock.currentTimeMillis; - yield* Effect.sync(() => observePosixOwnershipLedger({ ...input, maxProcesses: 64 })).pipe( - Effect.exit, - ); - const targetInterval = input.frontier.size <= 32 ? 25 : input.frontier.size <= 128 ? 50 : 100; - const completedAt = yield* Clock.currentTimeMillis; - yield* Effect.sleep(`${Math.max(5, targetInterval - (completedAt - startedAt))} millis`); - }), - ).pipe(withWallClock); -} - -export function makePosixProcessTreeController( - platform: NodeJS.Platform, -): AcpPosixProcessTreeController { - if (platform === "linux") return makeLinuxProcessTreeController(); - throw new AcpProcessGroupTerminationError({ - detail: `Detached ACP descendant ownership is unsupported on ${platform}`, - }); -} - -function samePosixProcessIdentity( - expected: AcpPosixProcessIdentity, - observed: AcpPosixProcessIdentity | undefined, -): boolean { - return ( - observed !== undefined && - observed.pid === expected.pid && - observed.startTime === expected.startTime - ); -} - -export interface AcpOwnedPosixProcess extends AcpPosixProcessIdentity { - readonly parentExecutable: string | undefined; - readonly parentStartTime: string; -} - -function processIdentityKey(process: AcpPosixProcessIdentity): string { - return `${process.pid}:${process.startTime}`; -} - -function processDepth( - process: AcpPosixProcessIdentity, - ledgerByPid: ReadonlyMap, -): number { - let depth = 0; - let current = process; - const seen = new Set(); - while (!seen.has(current.pid)) { - seen.add(current.pid); - const parent = ledgerByPid.get(current.ppid); - if (parent === undefined) break; - depth += 1; - current = parent; - } - return depth; -} - -export function capturePosixOwnershipLedger(input: { - readonly controller: AcpPosixProcessTreeController; - readonly ledger: Map; - readonly root?: AcpPosixOwnershipRoot; - readonly rootPid: number; - readonly table?: ReadonlyArray; -}): ReadonlyArray { - let table: ReadonlyArray; - if (input.table === undefined) { - try { - table = input.controller.snapshot(); - } catch (cause) { - throw new AcpProcessGroupTerminationError({ - cause, - detail: `Failed to snapshot ACP process tree ${input.rootPid}`, - }); - } - } else { - table = input.table; - } - const byPid = new Map(table.map((process) => [process.pid, process])); - let rootIdentity: AcpPosixProcessIdentity | undefined = - input.root?.value ?? - [...input.ledger.values()].find((process) => process.pid === input.rootPid); - if (rootIdentity === undefined) { - rootIdentity = byPid.get(input.rootPid); - if (rootIdentity === undefined) { - throw new AcpProcessGroupTerminationError({ - detail: `ACP process ${input.rootPid} exited before its ownership ledger was captured`, - }); - } - const ownedRoot = { - ...rootIdentity, - parentExecutable: byPid.get(rootIdentity.ppid)?.executable, - parentStartTime: byPid.get(rootIdentity.ppid)?.startTime ?? "", - }; - input.ledger.set(processIdentityKey(rootIdentity), ownedRoot); - if (input.root !== undefined) input.root.value = ownedRoot; - } - // Drop non-root ledger entries whose PID is free or whose identity no longer - // matches the live process at that PID. Either case can block admission of a - // legitimate child after PID reuse. Keep the root reservation even if the - // root briefly disappears mid-teardown (finalizer retry still needs it). - for (const [key, owned] of input.ledger.entries()) { - if (owned.pid === input.rootPid) continue; - const live = byPid.get(owned.pid); - if (live === undefined || !samePosixProcessIdentity(owned, live)) { - input.ledger.delete(key); - } - } - const children = new Map>(); - const retainedByPid = new Map( - [...input.ledger.values()] - .filter((owned) => samePosixProcessIdentity(owned, byPid.get(owned.pid))) - .map((owned) => [owned.pid, owned]), - ); - for (const process of table) { - const siblings = children.get(process.ppid) ?? []; - siblings.push(process); - children.set(process.ppid, siblings); - } - const pending = [...input.ledger.values()].filter((owned) => - samePosixProcessIdentity(owned, byPid.get(owned.pid)), - ); - const visited = new Set(); - while (pending.length > 0) { - const parent = pending.shift(); - if (parent === undefined || visited.has(parent.pid)) continue; - visited.add(parent.pid); - const observedParent = byPid.get(parent.pid); - if (!samePosixProcessIdentity(parent, observedParent)) continue; - const refreshedParent = { ...parent, ...observedParent }; - input.ledger.set(processIdentityKey(parent), refreshedParent); - for (const process of children.get(refreshedParent.pid) ?? []) { - const reservedPid = retainedByPid.get(process.pid); - if (reservedPid !== undefined && !samePosixProcessIdentity(reservedPid, process)) continue; - const owned = - reservedPid === undefined - ? { - ...process, - parentExecutable: refreshedParent.executable, - parentStartTime: refreshedParent.startTime, - } - : { ...reservedPid, ...process }; - input.ledger.set(processIdentityKey(process), owned); - retainedByPid.set(process.pid, owned); - pending.push(owned); - } - } - return table; -} - -export function terminatePosixOwnedProcessTree(input: { - readonly controller: AcpPosixProcessTreeController; - readonly discoveryPasses?: number; - readonly grace?: Duration.Input; - readonly ledger?: Map; - readonly root?: AcpPosixOwnershipRoot; - readonly rootPid: number; -}): Effect.Effect { - // This portable fallback can only admit processes whose exact parent chain is - // visible before it breaks. A descendant that double-forks before discovery - // requires pre-exec cgroup containment for a hard ownership guarantee. - const ledger = input.ledger ?? new Map(); - - const fail = (detail: string, cause?: unknown) => - new AcpProcessGroupTerminationError({ detail, ...(cause === undefined ? {} : { cause }) }); - const snapshot = () => { - try { - return input.controller.snapshot(); - } catch (cause) { - throw fail(`Failed to snapshot ACP process tree ${input.rootPid}`, cause); - } - }; - const discover = (table: ReadonlyArray) => - capturePosixOwnershipLedger({ - controller: input.controller, - ledger, - ...(input.root === undefined ? {} : { root: input.root }), - rootPid: input.rootPid, - table, - }); - const signalPhase = (signal: NodeJS.Signals, includeRoot: boolean) => { - const table = snapshot(); - discover(table); - const byPid = new Map(table.map((entry) => [entry.pid, entry])); - const current = input.controller.identity(process.pid); - if (current === undefined) throw fail("Cannot identify the current T3 process group"); - const ledgerByPid = new Map( - [...ledger.values()].map((process) => [process.pid, process] as const), - ); - const pending = [...ledger.values()].filter( - (candidate) => - (includeRoot || candidate.pid !== input.rootPid) && - samePosixProcessIdentity(candidate, byPid.get(candidate.pid)), - ); - const retainedByPid = new Map( - [...ledger.values()] - .filter((owned) => samePosixProcessIdentity(owned, byPid.get(owned.pid))) - .map((owned) => [owned.pid, owned]), - ); - const signalled = new Set(); - while (pending.length > 0) { - pending.sort( - (left, right) => processDepth(right, ledgerByPid) - processDepth(left, ledgerByPid), - ); - const candidate = pending.shift(); - if (candidate === undefined || signalled.has(candidate.pid)) continue; - const observed = input.controller.identity(candidate.pid); - if ( - candidate.pid <= 1 || - candidate.pid === process.pid || - observed === undefined || - observed.pgid === current.pgid || - observed.sid === current.sid || - !samePosixProcessIdentity(candidate, observed) - ) { - continue; - } - for (const child of input.controller.childrenOf(candidate.pid)) { - if (child.ppid !== candidate.pid) continue; - const reservedPid = retainedByPid.get(child.pid); - if (reservedPid !== undefined && !samePosixProcessIdentity(reservedPid, child)) continue; - const owned = - reservedPid === undefined - ? { - ...child, - parentExecutable: observed.executable, - parentStartTime: observed.startTime, - } - : { ...reservedPid, ...child }; - ledger.set(processIdentityKey(child), owned); - ledgerByPid.set(child.pid, owned); - retainedByPid.set(child.pid, owned); - // Signal newly discovered children in this same pass (fork-during-kill). - if ( - (includeRoot || child.pid !== input.rootPid) && - !signalled.has(child.pid) && - samePosixProcessIdentity(owned, input.controller.identity(child.pid)) - ) { - pending.push(owned); - } - } - try { - // Linux starttime prevents PID reuse across observations, but Node does - // not expose pidfd_send_signal. A final read-to-kill race remains. - input.controller.signalProcess(candidate.pid, signal); - signalled.add(candidate.pid); - } catch (cause) { - if (samePosixProcessIdentity(candidate, input.controller.identity(candidate.pid))) { - throw fail(`Failed to signal ACP process ${candidate.pid}`, cause); - } - } - } - }; - - return Effect.gen(function* () { - const passes = Math.max(2, Math.min(input.discoveryPasses ?? 4, 8)); - for (let pass = 0; pass < passes; pass += 1) { - yield* Effect.try({ - try: () => signalPhase("SIGTERM", false), - catch: (cause) => cause as AcpProcessGroupTerminationError, - }); - yield* Effect.sleep("10 millis"); - } - yield* Effect.try({ - try: () => signalPhase("SIGTERM", true), - catch: (cause) => cause as AcpProcessGroupTerminationError, - }); - const grace = input.grace ?? "1 second"; - if (grace !== 0) yield* Effect.sleep(grace); - for (let pass = 0; pass < passes; pass += 1) { - yield* Effect.try({ - try: () => signalPhase("SIGKILL", true), - catch: (cause) => cause as AcpProcessGroupTerminationError, - }); - yield* Effect.sleep("10 millis"); - } - const finalTable = snapshot(); - discover(finalTable); - const survivors = new Map(finalTable.map((entry) => [entry.pid, entry])); - const residual = [...ledger.values()].filter((owned) => { - const observed = survivors.get(owned.pid); - if (!samePosixProcessIdentity(owned, observed) || observed === undefined) { - return false; - } - // Already-killed zombies still appear in /proc until reaped; not live. - return !posixProcessIsZombie(observed); - }); - if (residual.length > 0) { - return yield* fail( - `ACP process tree ${input.rootPid} retained owned processes: ${residual.map((entry) => entry.pid).join(", ")}`, - ); - } - }).pipe(withWallClock); -} - -export function windowsTaskkillResultIsSuccess(exitCode: number, _output?: string): boolean { - return exitCode === 0; -} - -export const terminateWindowsProcessTreeWithTaskkill = ( - spawner: ChildProcessSpawner.ChildProcessSpawner["Service"], - pid: number, -): Effect.Effect => - Effect.scoped( - Effect.gen(function* () { - const taskkill = yield* spawner.spawn( - ChildProcess.make("taskkill", ["/PID", String(pid), "/T", "/F"]), - ); - const outputFiber = yield* Stream.decodeText(taskkill.all).pipe( - Stream.mkString, - Effect.forkScoped, - ); - const exitCode = Number(yield* taskkill.exitCode); - const output = yield* Fiber.join(outputFiber); - // A missing leader cannot prove that inherited descendants exited. - if (windowsTaskkillResultIsSuccess(exitCode, output)) return; - const trimmedOutput = output.trim(); - return yield* new AcpProcessGroupTerminationError({ - detail: `taskkill exited ${exitCode} for ACP process tree ${pid}`, - pid, - exitCode, - ...(trimmedOutput.length === 0 - ? {} - : { cause: trimmedOutput.length > 500 ? trimmedOutput.slice(0, 500) : trimmedOutput }), - }); - }), - ).pipe( - Effect.mapError((cause) => - isAcpProcessGroupTerminationError(cause) - ? cause - : new AcpProcessGroupTerminationError({ - cause, - detail: `Failed to run taskkill for ACP process tree ${pid}`, - pid, - }), - ), - ); - -export function selectAcpAgentAuthMethod( - authMethods: ReadonlyArray | undefined, - preferredMethodId?: string, -): EffectAcpSchema.AuthMethod | undefined { - const preferred = preferredMethodId?.trim(); - if (preferred) { - return authMethods?.find((method) => method.id === preferred); - } - return authMethods?.find((method) => method.type === undefined || method.type === "agent"); -} - -function isAcpAuthenticationRequired(error: EffectAcpErrors.AcpError): boolean { - return error._tag === "AcpRequestError" && error.code === -32000; -} - -export interface AcpSessionRuntimeStartResult { - readonly sessionId: string; - readonly initializeResult: EffectAcpSchema.InitializeResponse; - readonly sessionSetupResult: - | EffectAcpSchema.ForkSessionResponse - | EffectAcpSchema.LoadSessionResponse - | EffectAcpSchema.NewSessionResponse - | EffectAcpSchema.ResumeSessionResponse; - readonly modelConfigId: string | undefined; -} - -export interface AcpSessionActivationOptions { - readonly mcpServers?: ReadonlyArray; - readonly acpMcpServers?: ReadonlyArray; -} - -export class AcpSessionRuntime extends Context.Service< - AcpSessionRuntime, - { - /** - * Registers a handler for `session/request_permission`. - * @see https://agentclientprotocol.com/protocol/schema#session/request_permission - */ - readonly handleRequestPermission: EffectAcpClient.AcpClient["Service"]["handleRequestPermission"]; - /** - * Registers a handler for `session/elicitation`. - * @see https://agentclientprotocol.com/protocol/schema#session/elicitation - */ - readonly handleElicitation: EffectAcpClient.AcpClient["Service"]["handleElicitation"]; - readonly handleMcpConnect: EffectAcpClient.AcpClient["Service"]["handleMcpConnect"]; - readonly handleMcpMessage: EffectAcpClient.AcpClient["Service"]["handleMcpMessage"]; - readonly handleMcpDisconnect: EffectAcpClient.AcpClient["Service"]["handleMcpDisconnect"]; - readonly handleMcpNotification: EffectAcpClient.AcpClient["Service"]["handleMcpNotification"]; - /** - * Registers a handler for `fs/read_text_file`. - * @see https://agentclientprotocol.com/protocol/schema#fs/read_text_file - */ - readonly handleReadTextFile: EffectAcpClient.AcpClient["Service"]["handleReadTextFile"]; - /** - * Registers a handler for `fs/write_text_file`. - * @see https://agentclientprotocol.com/protocol/schema#fs/write_text_file - */ - readonly handleWriteTextFile: EffectAcpClient.AcpClient["Service"]["handleWriteTextFile"]; - /** - * Registers a handler for `terminal/create`. - * @see https://agentclientprotocol.com/protocol/schema#terminal/create - */ - readonly handleCreateTerminal: EffectAcpClient.AcpClient["Service"]["handleCreateTerminal"]; - /** - * Registers a handler for `terminal/output`. - * @see https://agentclientprotocol.com/protocol/schema#terminal/output - */ - readonly handleTerminalOutput: EffectAcpClient.AcpClient["Service"]["handleTerminalOutput"]; - /** - * Registers a handler for `terminal/wait_for_exit`. - * @see https://agentclientprotocol.com/protocol/schema#terminal/wait_for_exit - */ - readonly handleTerminalWaitForExit: EffectAcpClient.AcpClient["Service"]["handleTerminalWaitForExit"]; - /** - * Registers a handler for `terminal/kill`. - * @see https://agentclientprotocol.com/protocol/schema#terminal/kill - */ - readonly handleTerminalKill: EffectAcpClient.AcpClient["Service"]["handleTerminalKill"]; - /** - * Registers a handler for `terminal/release`. - * @see https://agentclientprotocol.com/protocol/schema#terminal/release - */ - readonly handleTerminalRelease: EffectAcpClient.AcpClient["Service"]["handleTerminalRelease"]; - /** - * Registers a handler for `session/update`. - * @see https://agentclientprotocol.com/protocol/schema#session/update - */ - readonly handleSessionUpdate: EffectAcpClient.AcpClient["Service"]["handleSessionUpdate"]; - /** - * Registers a handler for `session/elicitation/complete`. - * @see https://agentclientprotocol.com/protocol/schema#session/elicitation/complete - */ - readonly handleElicitationComplete: EffectAcpClient.AcpClient["Service"]["handleElicitationComplete"]; - /** - * Registers a fallback extension request handler. - * @see https://agentclientprotocol.com/protocol/extensibility - */ - readonly handleUnknownExtRequest: EffectAcpClient.AcpClient["Service"]["handleUnknownExtRequest"]; - /** - * Registers a fallback extension notification handler. - * @see https://agentclientprotocol.com/protocol/extensibility - */ - readonly handleUnknownExtNotification: EffectAcpClient.AcpClient["Service"]["handleUnknownExtNotification"]; - /** - * Registers a typed extension request handler. - * @see https://agentclientprotocol.com/protocol/extensibility - */ - readonly handleExtRequest: EffectAcpClient.AcpClient["Service"]["handleExtRequest"]; - /** - * Registers a typed extension notification handler. - * @see https://agentclientprotocol.com/protocol/extensibility - */ - readonly handleExtNotification: EffectAcpClient.AcpClient["Service"]["handleExtNotification"]; - /** - * Sends only `initialize` and returns the agent's response. Health probes use this to read - * advertised capabilities without authenticating or opening a session, so a probe can never - * start an interactive login or boot MCP servers. - * @see https://agentclientprotocol.com/protocol/schema#initialize - */ - readonly initialize: () => Effect.Effect< - EffectAcpSchema.InitializeResponse, - EffectAcpErrors.AcpError - >; - /** Explicit login uses the negotiated v1 authenticate / v2 auth/login operation. */ - readonly authenticate?: (methodId: string) => Effect.Effect; - /** - * Initializes the ACP connection, authenticates, and loads, resumes, or creates the session. - * Concurrent calls share the same in-flight startup and a failed startup may be retried. - */ - readonly start: () => Effect.Effect; - /** Stream of parsed root-session events and connection failures. */ - readonly getEvents: () => Stream.Stream; - /** Waits for queued events to be processed, or for the runtime scope to close. */ - readonly drainEvents: Effect.Effect; - /** Latest mode state observed from session setup and `session/update` notifications. */ - readonly getModeState: Effect.Effect; - /** Latest configuration options observed from session setup and configuration writes. */ - readonly getConfigOptions: Effect.Effect>; - readonly loadSession: ( - sessionId: string, - options?: AcpSessionActivationOptions, - ) => Effect.Effect; - readonly resumeSession: ( - sessionId: string, - options?: AcpSessionActivationOptions, - ) => Effect.Effect; - readonly forkSession: ( - sessionId: string, - options?: AcpSessionActivationOptions, - ) => Effect.Effect; - readonly listSessions: ( - cursor?: string, - ) => Effect.Effect; - readonly closeSession: ( - sessionId?: string, - ) => Effect.Effect; - readonly deleteSession: ( - sessionId: string, - ) => Effect.Effect; - readonly listProviders: Effect.Effect< - EffectAcpSchema.ListProvidersResponse, - EffectAcpErrors.AcpError - >; - readonly setProvider: ( - provider: EffectAcpSchema.SetProviderRequest, - ) => Effect.Effect; - readonly disableProvider: ( - providerId: string, - ) => Effect.Effect; - /** Logs out the current ACP identity when the agent advertises support. */ - readonly logout: Effect.Effect; - /** - * Sends a prompt turn to the active session. `options.dispatched` settles once the - * `session/prompt` RPC is registered as the active prompt, so a caller that forks this - * effect knows when a later `cancel` will target this prompt. - * @see https://agentclientprotocol.com/protocol/schema#session/prompt - */ - readonly prompt: ( - payload: Omit, - options?: { readonly dispatched?: Deferred.Deferred }, - ) => Effect.Effect; - /** - * Sends a real ACP `session/cancel` notification for the active session. - * @see https://agentclientprotocol.com/protocol/schema#session/cancel - */ - readonly cancel: Effect.Effect; - readonly processContainment: - | "cgroup-v2" - | "process-ledger-reduced-guarantee" - | "process-group" - | "none"; - /** Terminates the opt-in detached ACP process group and all inherited work. */ - readonly terminateProcessGroup?: Effect.Effect; - /** - * Selects the active mode through the negotiated `mode` configuration option. - * This is a no-op when the requested mode is already active. - * @see https://agentclientprotocol.com/protocol/schema#session/set_config_option - */ - readonly setMode: ( - modeId: string, - ) => Effect.Effect; - /** - * Updates a session configuration option and the runtime configuration snapshot. - * @see https://agentclientprotocol.com/protocol/schema#session/set_config_option - */ - readonly setSessionModel: ( - modelId: string, - meta?: EffectAcpSchema.SetSessionModelRequest["_meta"], - ) => Effect.Effect; - readonly setConfigOption: ( - configId: string, - value: string | boolean, - ) => Effect.Effect; - /** - * Selects the base model through the negotiated model configuration option. - * @see https://agentclientprotocol.com/protocol/schema#session/set_config_option - */ - readonly setModel: (model: string) => Effect.Effect; - /** - - * Sends a generic ACP extension request and records it through the request logger. - * @see https://agentclientprotocol.com/protocol/extensibility - */ - readonly request: ( - method: string, - payload: unknown, - ) => Effect.Effect; - /** - * Sends a generic ACP extension notification. - * @see https://agentclientprotocol.com/protocol/extensibility - */ - readonly notify: ( - method: string, - payload: unknown, - ) => Effect.Effect; - } ->()("t3/provider/acp/AcpSessionRuntime") { - static layer( - options: AcpSessionRuntimeOptions, - ): Layer.Layer< - AcpSessionRuntime, - EffectAcpErrors.AcpError, - ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto - > { - return Layer.effect(AcpSessionRuntime, make(options)); - } -} - -interface AcpStartedState extends AcpSessionRuntimeStartResult {} - -type AcpStartState = - | { readonly _tag: "NotStarted" } - | { - readonly _tag: "Starting"; - readonly deferred: Deferred.Deferred; - } - | { readonly _tag: "Started"; readonly result: AcpStartedState }; - -type AcpInitializeState = - | { readonly _tag: "NotStarted" } - | { - readonly _tag: "Starting"; - readonly deferred: Deferred.Deferred< - EffectAcpSchema.InitializeResponse, - EffectAcpErrors.AcpError - >; - } - | { readonly _tag: "Started"; readonly result: EffectAcpSchema.InitializeResponse }; - -interface AcpAssistantSegmentState { - readonly nextSegmentIndex: number; - readonly activeItemId?: string; -} - -interface EnsureActiveAssistantSegmentResult { - readonly itemId: string; - readonly startedEvent?: Extract; -} - -interface AcpActivePrompt { - readonly fiber: Fiber.Fiber; - readonly completed: Deferred.Deferred; -} - -export const make = ( - options: AcpSessionRuntimeOptions, -): Effect.Effect< - AcpSessionRuntime["Service"], - EffectAcpErrors.AcpError, - ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | Scope.Scope -> => - Effect.gen(function* () { - const crypto = yield* Crypto.Crypto; - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - // A child of the caller's scope, so termination can close the runtime without closing the caller's. - const runtimeScope = yield* Scope.fork(yield* Scope.Scope); - const eventQueue = yield* Queue.unbounded(); - const modeStateRef = yield* Ref.make(undefined); - const toolCallsRef = yield* Ref.make(new Map()); - // Recently shown tool calls. A late update to a finished call is not a new - // boundary in the answer, although its progress state is gone. - const shownToolCallIds = new Set(); - const assistantItemRuntimeId = yield* crypto.randomUUIDv4.pipe( - Effect.mapError( - (cause) => - new EffectAcpErrors.AcpTransportError({ - detail: "Failed to generate an ACP assistant item runtime identifier.", - cause, - }), - ), - ); - const assistantSegmentRef = yield* Ref.make({ nextSegmentIndex: 0 }); - const configOptionsRef = yield* Ref.make(sessionConfigOptionsFromSetup(undefined)); - const initializeStateRef = yield* Ref.make({ _tag: "NotStarted" }); - const startStateRef = yield* Ref.make({ _tag: "NotStarted" }); - const startupMetadataRef = yield* Ref.make>( - [], - ); - const notificationSemaphore = yield* Semaphore.make(1); - const terminationErrorRef = yield* Ref.make>( - Option.none(), - ); - const stoppingRef = yield* Ref.make(false); - const stderrFailure = yield* Deferred.make(); - const stderrTailRef = yield* Ref.make(""); - const stderrDrained = yield* Deferred.make(); - const runtimeClosed = yield* Deferred.make(); - const promptSerializationSemaphore = yield* Semaphore.make(1); - const sessionLoadSemaphore = yield* Semaphore.make(1); - const promptDispatchSemaphore = yield* Semaphore.make(1); - const activePromptRef = yield* Ref.make>(Option.none()); - const assistantUpdatesOpenRef = yield* Ref.make(true); - const sessionLoadGateRef = yield* Ref.make>(Option.none()); - - const ensureConnected = Effect.gen(function* () { - const error = yield* Ref.get(terminationErrorRef); - if (Option.isSome(error)) { - return yield* error.value; - } - if (yield* Ref.get(stoppingRef)) { - return yield* new EffectAcpErrors.AcpTransportError({ - detail: "The ACP session runtime is closed.", - cause: undefined, - }); - } - }); - - const enrichProcessExitWithStderr = ( - error: EffectAcpErrors.AcpError, - ): Effect.Effect => - error._tag !== "AcpProcessExitedError" || (error.stderr?.trim().length ?? 0) > 0 - ? Effect.succeed(error) - : Deferred.await(stderrDrained).pipe( - Effect.timeout("250 millis"), - Effect.ignore, - Effect.andThen(Ref.get(stderrTailRef)), - Effect.map((tail) => { - const stderr = sanitizeAcpStderrExcerpt(tail); - return stderr.length === 0 - ? error - : new EffectAcpErrors.AcpProcessExitedError({ - ...(error.code !== undefined ? { code: error.code } : {}), - ...(error.pid !== undefined ? { pid: error.pid } : {}), - stderr, - ...(error.cause !== undefined ? { cause: error.cause } : {}), - }); - }), - ); - - const recordTermination = Effect.fn("AcpSessionRuntime.recordTermination")(function* ( - error: EffectAcpErrors.AcpError, - ) { - if (yield* Ref.get(stoppingRef)) { - return; - } - const enriched = yield* enrichProcessExitWithStderr(error); - const firstTermination = yield* Ref.modify(terminationErrorRef, (current) => - Option.isSome(current) - ? ([false, current] as const) - : ([true, Option.some(enriched)] as const), - ); - if (!firstTermination) { - return; - } - yield* closeActiveAssistantSegment({ queue: eventQueue, assistantSegmentRef }); - yield* Queue.offer(eventQueue, { _tag: "ConnectionTerminated", error: enriched }); - }); - - const logRequest = (event: AcpSessionRequestLogEvent) => - options.requestLogger ? options.requestLogger(event) : Effect.void; - - const runLoggedRequest = ( - method: string, - payload: unknown, - effect: Effect.Effect, - ): Effect.Effect => - logRequest({ method, payload, status: "started" }).pipe( - Effect.flatMap(() => - (options.onStderr - ? Effect.raceFirst(effect, Deferred.await(stderrFailure)) - : effect - ).pipe( - Effect.catch((error) => - enrichProcessExitWithStderr(error).pipe(Effect.flatMap(Effect.fail)), - ), - Effect.tap((result) => - logRequest({ - method, - payload, - status: "succeeded", - result, - }), - ), - Effect.onError((cause) => - logRequest({ - method, - payload, - status: "failed", - cause, - }), - ), - ), - ), - ); - - const spawnCommand = - options.spawn.shell === false - ? { command: options.spawn.command, args: options.spawn.args, shell: false } - : yield* resolveSpawnCommand(options.spawn.command, options.spawn.args, { - ...(options.spawn.env ? { env: options.spawn.env } : {}), - extendEnv: options.spawn.extendEnv ?? true, - }); - const linuxCgroupLease = - options.ownDescendantProcessGroups === true && options.processGroupPlatform === "linux" - ? yield* Effect.sync(() => { - try { - if (options.linuxCgroupController === null) return undefined; - return (options.linuxCgroupController ?? makeLinuxCgroupController()).create(); - } catch { - return undefined; - } - }) - : undefined; - // This is lifecycle containment for inherited provider work. It is not a - // security boundary against a provider that can modify its delegated cgroup. - if (linuxCgroupLease !== undefined) { - yield* Scope.addFinalizer( - runtimeScope, - Effect.uninterruptible(terminateLinuxCgroupLease(linuxCgroupLease)).pipe( - Effect.ignoreCause({ log: true }), - ), - ); - } - const containedTargetCommand = - linuxCgroupLease === undefined || NodePath.isAbsolute(spawnCommand.command) - ? spawnCommand.command - : yield* Effect.gen(function* () { - const resolved = resolveLinuxCgroupTargetCommand( - spawnCommand.command, - options.spawn.cwd ?? options.cwd, - { ...process.env, ...options.spawn.env }, - ); - if (resolved !== undefined) return resolved; - return yield* new EffectAcpErrors.AcpSpawnError({ - command: options.spawn.command, - cause: new Error("Contained ACP command was not found on PATH"), - }); - }); - const containedSpawnCommand = - linuxCgroupLease === undefined - ? spawnCommand - : { - ...wrapCommandForLinuxCgroup( - linuxCgroupLease, - containedTargetCommand, - spawnCommand.args, - ), - shell: false, - }; - const spawnEnvironment = - linuxCgroupLease === undefined - ? options.spawn.env - : { - ...options.spawn.env, - ELECTRON_RUN_AS_NODE: "1", - T3_ACP_CGROUP_WRAPPER: "1", - }; - const child = yield* spawner - .spawn( - ChildProcess.make(containedSpawnCommand.command, containedSpawnCommand.args, { - ...(options.spawn.cwd ? { cwd: options.spawn.cwd } : {}), - ...(spawnEnvironment ? { env: spawnEnvironment } : {}), - extendEnv: options.spawn.extendEnv ?? true, - ...(options.ownDetachedProcessGroup === undefined - ? {} - : { detached: options.ownDetachedProcessGroup }), - shell: containedSpawnCommand.shell, - }), - ) - .pipe( - Effect.provideService(Scope.Scope, runtimeScope), - Effect.mapError( - (cause) => - new EffectAcpErrors.AcpSpawnError({ - command: options.spawn.command, - cause, - }), - ), - ); - - const posixOwnershipLedger = new Map(); - const posixOwnershipFrontier = new Map(); - const posixOwnershipChildQueues = new Map>(); - const posixOwnershipRoot: AcpPosixOwnershipRoot = { value: undefined }; - const posixController = - options.ownDescendantProcessGroups === true - ? yield* Effect.try({ - try: () => { - if (options.posixProcessTreeController !== undefined) { - return options.posixProcessTreeController; - } - if (options.processGroupPlatform === undefined) { - throw new AcpProcessGroupTerminationError({ - detail: "POSIX ACP descendant ownership requires a supported host platform", - }); - } - return makePosixProcessTreeController(options.processGroupPlatform); - }, - catch: (cause) => - new EffectAcpErrors.AcpSpawnError({ command: options.spawn.command, cause }), - }) - : undefined; - if (posixController !== undefined) { - const observeOwnership = () => - observePosixOwnershipLedger({ - childQueues: posixOwnershipChildQueues, - controller: posixController, - frontier: posixOwnershipFrontier, - ledger: posixOwnershipLedger, - root: posixOwnershipRoot, - rootPid: Number(child.pid), - }); - yield* Effect.try({ - try: observeOwnership, - catch: (cause) => - new EffectAcpErrors.AcpSpawnError({ command: options.spawn.command, cause }), - }); - yield* observePosixOwnershipLedgerContinuously({ - childQueues: posixOwnershipChildQueues, - controller: posixController, - frontier: posixOwnershipFrontier, - ledger: posixOwnershipLedger, - root: posixOwnershipRoot, - rootPid: Number(child.pid), - }).pipe(Effect.forkIn(runtimeScope)); - } - - const signalOwnedProcessGroup = (signal: NodeJS.Signals) => - Effect.try({ - try: () => { - signalProcessGroup(Number(child.pid), signal); - return true; - }, - catch: (cause) => - new AcpProcessGroupTerminationError({ - cause, - detail: `Failed to signal ACP process group ${child.pid} with ${signal}`, - }), - }).pipe( - Effect.catch((error) => { - const cause = error.cause as NodeJS.ErrnoException | undefined; - return cause?.code === "ESRCH" ? Effect.succeed(false) : Effect.fail(error); - }), - ); - const terminateWindowsProcessTree = - options.windowsProcessTreeTerminator ?? - ((pid: number) => terminateWindowsProcessTreeWithTaskkill(spawner, pid)); - const terminatePosixProcessTree = (grace: Duration.Input, platformOverride?: NodeJS.Platform) => - Effect.gen(function* () { - const platform = platformOverride ?? options.processGroupPlatform; - if (platform === undefined || platform === "win32") { - return yield* new AcpProcessGroupTerminationError({ - detail: "POSIX ACP descendant ownership requires a supported host platform", - }); - } - const controller = yield* Effect.try({ - try: () => posixController ?? makePosixProcessTreeController(platform), - catch: (cause) => - isAcpProcessGroupTerminationError(cause) - ? cause - : new AcpProcessGroupTerminationError({ - cause, - detail: `Failed to initialize ACP descendant ownership on ${platform}`, - }), - }); - yield* terminatePosixOwnedProcessTree({ - controller, - grace, - ledger: posixOwnershipLedger, - root: posixOwnershipRoot, - rootPid: Number(child.pid), - }); - }); - const terminateOwnedProcessGroupImpl = Effect.gen(function* () { - if (options.ownDetachedProcessGroup !== true) return; - if (options.processGroupPlatform === undefined) { - return yield* new AcpProcessGroupTerminationError({ - detail: "Detached ACP process-group ownership requires a host platform", - }); - } - if (options.processGroupPlatform === "win32") { - return yield* terminateWindowsProcessTree(Number(child.pid)); - } - if (linuxCgroupLease !== undefined) { - if (posixController !== undefined) { - yield* Effect.sync(() => - signalLinuxCgroupRootTerm({ - controller: posixController, - lease: linuxCgroupLease, - root: posixOwnershipRoot, - }), - ); - } - const grace = options.processGroupTerminationGrace ?? "1 second"; - if (grace !== 0) yield* Effect.sleep(grace); - return yield* terminateLinuxCgroupLease(linuxCgroupLease); - } - if (options.ownDescendantProcessGroups === true) { - return yield* terminatePosixProcessTree(options.processGroupTerminationGrace ?? "1 second"); - } - const groupExisted = yield* signalOwnedProcessGroup("SIGTERM"); - if (!groupExisted) return; - const grace = options.processGroupTerminationGrace ?? "1 second"; - if (grace !== 0) { - yield* Effect.sleep(grace); - } - yield* signalOwnedProcessGroup("SIGKILL"); - }).pipe(withWallClock); - const terminateProcessGroup = yield* Effect.cached( - Effect.uninterruptible(terminateOwnedProcessGroupImpl), - ); - if (options.ownDetachedProcessGroup === true) { - const hostPlatform = yield* HostProcessPlatform; - const forceTerminateOwnedProcessGroup = - hostPlatform === "win32" - ? terminateWindowsProcessTreeWithTaskkill(spawner, Number(child.pid)) - : linuxCgroupLease !== undefined - ? terminateLinuxCgroupLease(linuxCgroupLease) - : options.ownDescendantProcessGroups === true - ? terminatePosixProcessTree(0, hostPlatform) - : signalOwnedProcessGroup("SIGKILL").pipe(Effect.asVoid); - yield* Scope.addFinalizer( - runtimeScope, - Effect.uninterruptible(forceTerminateOwnedProcessGroup).pipe( - Effect.ignoreCause({ log: true }), - ), - ); - } - yield* child.stderr.pipe( - Stream.decodeText(), - Stream.runForEach((chunk) => - Ref.update(stderrTailRef, (current) => appendAcpStderrTail(current, chunk)).pipe( - Effect.andThen( - options.onStderr ? options.onStderr(chunk.slice(-maxStderrChunkLength)) : Effect.void, - ), - Effect.catch((error) => - Effect.gen(function* () { - yield* Deferred.fail(stderrFailure, error); - yield* recordTermination(error); - yield* child.kill({ forceKillAfter: "1 second" }).pipe(Effect.ignore); - }), - ), - ), - ), - Effect.ensuring(Deferred.succeed(stderrDrained, undefined)), - Effect.ignore, - Effect.forkIn(runtimeScope), - ); - - const acpContext = yield* Layer.build( - EffectAcpClient.layerChildProcess(child, { - ...(options.transformStdout ? { transformStdout: options.transformStdout } : {}), - ...(options.transformSessionUpdate - ? { transformSessionUpdate: options.transformSessionUpdate } - : {}), - ...(options.protocolLogging?.logIncoming !== undefined - ? { logIncoming: options.protocolLogging.logIncoming } - : {}), - ...(options.protocolLogging?.logOutgoing !== undefined - ? { logOutgoing: options.protocolLogging.logOutgoing } - : {}), - ...(options.protocolLogging?.logger ? { logger: options.protocolLogging.logger } : {}), - ...(options.onIncomingRequest ? { onIncomingRequest: options.onIncomingRequest } : {}), - onTermination: (error) => - recordTermination(error).pipe( - Effect.andThen(options.onTermination?.(error) ?? Effect.void), - Effect.ensuring( - Scope.close(runtimeScope, Exit.fail(error)).pipe(Effect.forkDetach, Effect.asVoid), - ), - ), - ...(options.onOutgoingResponseFailure - ? { onOutgoingResponseFailure: options.onOutgoingResponseFailure } - : {}), - ...(options.onOutgoingResponse ? { onOutgoingResponse: options.onOutgoingResponse } : {}), - }), - ).pipe(Effect.provideService(Scope.Scope, runtimeScope)); - - const acp = yield* Effect.service(EffectAcpClient.AcpClient).pipe(Effect.provide(acpContext)); - - const processSessionUpdate = (notification: EffectAcpSchema.SessionNotification) => - handleSessionUpdate({ - queue: eventQueue, - modeStateRef, - configOptionsRef, - toolCallsRef, - shownToolCallIds, - assistantSegmentRef, - assistantItemRuntimeId, - params: notification, - }); - - yield* acp.handleSessionUpdate((notification) => - notificationSemaphore.withPermit( - Effect.gen(function* () { - if (Option.isSome(yield* Ref.get(terminationErrorRef))) { - return; - } - const gate = yield* Ref.get(sessionLoadGateRef); - // A different session can still have an in-flight prompt while this - // load replays history, so quarantine only the loading session. - const loadingThisSession = - Option.isSome(gate) && - gate.value.active && - notification.sessionId === gate.value.sessionId; - if ( - loadingThisSession && - sessionUpdateCountsAsLoadReplayActivity(notification, gate.value.sessionId) - ) { - const lastActivityAtMillis = yield* Clock.currentTimeMillis; - yield* Ref.set( - sessionLoadGateRef, - Option.some({ - ...gate.value, - lastActivityAtMillis, - }), - ); - } - if (sessionUpdateIsReplay(notification)) { - return; - } - const startState = yield* Ref.get(startStateRef); - if (startState._tag === "Starting") { - if (isStartupMetadataUpdate(notification)) { - yield* Ref.update(startupMetadataRef, (current) => - [ - ...current.filter( - (previous) => - previous.sessionId !== notification.sessionId || - previous.update.sessionUpdate !== notification.update.sessionUpdate, - ), - notification, - ].slice(-maxStartupMetadataUpdates), - ); - } - return; - } - if (loadingThisSession) { - if (notification.update.sessionUpdate === "config_option_update") { - const candidateConfigOptions = notification.update.configOptions; - yield* Ref.update(sessionLoadGateRef, (current) => - Option.isSome(current) - ? Option.some({ ...current.value, candidateConfigOptions }) - : current, - ); - } - return; - } - // One runtime projects one root ACP session. Child-session updates need - // explicit lineage routing and must never be flattened into this stream. - if ( - startState._tag !== "Started" || - notification.sessionId !== startState.result.sessionId - ) { - return; - } - if ( - !(yield* Ref.get(assistantUpdatesOpenRef)) && - (notification.update.sessionUpdate === "agent_message_chunk" || - notification.update.sessionUpdate === "agent_thought_chunk") - ) { - return; - } - yield* processSessionUpdate(notification); - }), - ), - ); - yield* Scope.addFinalizer( - runtimeScope, - Ref.set(stoppingRef, true).pipe(Effect.andThen(Deferred.succeed(runtimeClosed, undefined))), - ); - const initializeClientCapabilities = { - fs: { - readTextFile: false, - writeTextFile: false, - ...options.clientCapabilities?.fs, - }, - terminal: options.clientCapabilities?.terminal ?? false, - ...(options.clientCapabilities?.auth ? { auth: options.clientCapabilities.auth } : {}), - ...(options.clientCapabilities?.elicitation - ? { elicitation: options.clientCapabilities.elicitation } - : {}), - ...(options.clientCapabilities?._meta ? { _meta: options.clientCapabilities._meta } : {}), - } satisfies NonNullable; - - const getStartedState = Effect.gen(function* () { - yield* ensureConnected; - const state = yield* Ref.get(startStateRef); - if (state._tag === "Started") { - return state.result; - } - return yield* new EffectAcpErrors.AcpTransportError({ - detail: "ACP session runtime has not been started", - cause: "ACP session runtime has not been started", - }); - }); - - const validateConfigOptionValue = ( - configId: string, - value: string | boolean, - ): Effect.Effect => - Effect.gen(function* () { - const configOption = findSessionConfigOption(yield* Ref.get(configOptionsRef), configId); - if (!configOption) { - return; - } - if (configOption.type === "boolean") { - if (typeof value === "boolean") { - return; - } - return yield* new EffectAcpErrors.AcpRequestError({ - code: -32602, - errorMessage: `Invalid value ${formatConfigOptionValue(value)} for session config option "${configOption.id}": expected boolean`, - data: { - configId: configOption.id, - expectedType: "boolean", - receivedValue: value, - }, - }); - } - if (typeof value !== "string") { - return yield* new EffectAcpErrors.AcpRequestError({ - code: -32602, - errorMessage: `Invalid value ${formatConfigOptionValue(value)} for session config option "${configOption.id}": expected string`, - data: { - configId: configOption.id, - expectedType: "string", - receivedValue: value, - }, - }); - } - const allowedValues = collectSessionConfigOptionValues(configOption); - if (allowedValues.includes(value)) { - return; - } - return yield* new EffectAcpErrors.AcpRequestError({ - code: -32602, - errorMessage: `Invalid value ${formatConfigOptionValue(value)} for session config option "${configOption.id}": expected one of ${allowedValues.join(", ")}`, - data: { - configId: configOption.id, - allowedValues, - receivedValue: value, - }, - }); - }); - - const updateConfigOptions = Effect.fn("AcpSessionRuntime.updateConfigOptions")(function* ( - response: - | EffectAcpSchema.SetSessionConfigOptionResponse - | EffectAcpSchema.ForkSessionResponse - | EffectAcpSchema.LoadSessionResponse - | EffectAcpSchema.NewSessionResponse - | EffectAcpSchema.ResumeSessionResponse, - ) { - const configOptions = sessionConfigOptionsFromSetup(response); - yield* Ref.set(configOptionsRef, configOptions); - yield* Queue.offer(eventQueue, { - _tag: "ConfigOptionsUpdated", - configOptions, - rawPayload: response, - }); - }); - - const updateCurrentModeId = (modeId: string): Effect.Effect => - Ref.update(modeStateRef, (current) => - current ? { ...current, currentModeId: modeId } : current, - ); - - const adoptSession = ( - sessionId: string, - sessionSetupResult: - | EffectAcpSchema.ForkSessionResponse - | EffectAcpSchema.LoadSessionResponse - | EffectAcpSchema.NewSessionResponse - | EffectAcpSchema.ResumeSessionResponse, - ): Effect.Effect => - Effect.gen(function* () { - const current = yield* getStartedState; - const meta = sessionSetupResult._meta; - const syntheticReplayIdle = - meta !== null && - typeof meta === "object" && - !Array.isArray(meta) && - (meta as { readonly t3SessionLoadReady?: unknown }).t3SessionLoadReady === "replay_idle"; - const extractedModelConfigId = extractModelConfigId(sessionSetupResult); - const nextModelConfigId = - extractedModelConfigId ?? (syntheticReplayIdle ? current.modelConfigId : undefined); - const nextModeState = parseSessionModeState(sessionSetupResult); - if (nextModeState !== undefined) { - yield* Ref.set(modeStateRef, nextModeState); - } else if (!syntheticReplayIdle) { - yield* Ref.set(modeStateRef, undefined); - } - // Synthetic replay-idle load responses only carry initialize model/mode - // meta; preserve live configOptions so setConfigOption still validates. - if ( - sessionSetupResult.configOptions !== undefined && - sessionSetupResult.configOptions !== null - ) { - yield* Ref.set(configOptionsRef, sessionConfigOptionsFromSetup(sessionSetupResult)); - } else if (!syntheticReplayIdle) { - yield* Ref.set(configOptionsRef, sessionConfigOptionsFromSetup(sessionSetupResult)); - } - const nextState = { - sessionId, - initializeResult: current.initializeResult, - sessionSetupResult, - modelConfigId: nextModelConfigId, - } satisfies AcpStartedState; - yield* Ref.set(toolCallsRef, new Map()); - yield* Ref.set(assistantSegmentRef, { nextSegmentIndex: 0 }); - yield* Ref.set(startStateRef, { _tag: "Started", result: nextState }); - return nextState; - }); - - const runLoadSessionWithReplayIdle = ( - loadPayload: EffectAcpSchema.LoadSessionRequest, - initializeResult: EffectAcpSchema.InitializeResponse, - ): Effect.Effect => - sessionLoadSemaphore.withPermits(1)( - Effect.gen(function* () { - const sessionLoadTimeout = Duration.fromInputUnsafe( - options.sessionLoadTimeout ?? defaultSessionLoadTimeout, - ); - const sessionLoadReplayIdleGap = Duration.fromInputUnsafe( - options.sessionLoadReplayIdleGap ?? defaultSessionLoadReplayIdleGap, - ); - - yield* Ref.set( - sessionLoadGateRef, - Option.some({ - active: true, - sessionId: loadPayload.sessionId, - lastActivityAtMillis: undefined, - idleGap: sessionLoadReplayIdleGap, - initializeResult, - }), - ); - - return yield* Effect.gen(function* () { - yield* logRequest({ - method: "session/load", - payload: loadPayload, - status: "started", - }); - - const idleFiber = yield* waitForSessionLoadReplayIdle({ - gateRef: sessionLoadGateRef, - }).pipe(Effect.forkIn(runtimeScope)); - const loaded = yield* Effect.raceFirst( - acp.agent.loadSession(loadPayload), - Fiber.join(idleFiber), - ).pipe( - Effect.ensuring(Fiber.interrupt(idleFiber).pipe(Effect.ignore)), - Effect.timeoutOption(sessionLoadTimeout), - Effect.flatMap((result) => - Option.match(result, { - onNone: () => - Effect.fail( - new EffectAcpErrors.AcpTransportError({ - operation: "call-rpc", - method: "session/load", - detail: - "session/load timed out waiting for RPC response or replay idle gap", - cause: undefined, - }), - ), - onSome: Effect.succeed, - }), - ), - Effect.tap((result) => - logRequest({ - method: "session/load", - payload: loadPayload, - status: "succeeded", - result, - }), - ), - Effect.onError((cause) => - logRequest({ - method: "session/load", - payload: loadPayload, - status: "failed", - cause, - }), - ), - ); - - const gate = yield* Ref.get(sessionLoadGateRef); - const candidateConfigOptions = Option.isSome(gate) - ? gate.value.candidateConfigOptions - : undefined; - return loaded.configOptions == null && candidateConfigOptions !== undefined - ? { ...loaded, configOptions: candidateConfigOptions } - : loaded; - }).pipe(Effect.ensuring(Ref.set(sessionLoadGateRef, Option.none()))); - }), - ); - - const setConfigOption = ( - configId: string, - value: string | boolean, - ): Effect.Effect => - validateConfigOptionValue(configId, value).pipe( - Effect.flatMap(() => getStartedState), - Effect.flatMap((started) => - Ref.get(configOptionsRef).pipe( - Effect.flatMap((configOptions) => { - const existing = findSessionConfigOption(configOptions, configId); - if (existing && configOptionCurrentValueMatches(existing, value)) { - return Effect.succeed({ - configOptions, - } satisfies EffectAcpSchema.SetSessionConfigOptionResponse); - } - const requestPayload = - typeof value === "boolean" - ? ({ - sessionId: started.sessionId, - configId, - type: "boolean", - value, - } satisfies EffectAcpSchema.SetSessionConfigOptionRequest) - : ({ - sessionId: started.sessionId, - configId, - value: String(value), - } satisfies EffectAcpSchema.SetSessionConfigOptionRequest); - return runLoggedRequest( - "session/set_config_option", - requestPayload, - acp.agent.setSessionConfigOption(requestPayload), - ).pipe(Effect.tap((response) => updateConfigOptions(response))); - }), - ), - ), - ); - - const initializeOnce = Effect.gen(function* () { - const initializePayload = { - protocolVersion: 2, - clientCapabilities: initializeClientCapabilities, - clientInfo: options.clientInfo, - ...(options.initializeMeta === undefined ? {} : { _meta: options.initializeMeta }), - } satisfies EffectAcpSchema.InitializeRequest; - - const initializeResult = yield* runLoggedRequest( - "initialize", - initializePayload, - acp.agent.initialize(initializePayload), - ); - yield* options.onInitialized?.(initializeResult) ?? Effect.void; - return initializeResult; - }); - - const initialize = Effect.gen(function* () { - yield* ensureConnected; - const deferred = yield* Deferred.make< - EffectAcpSchema.InitializeResponse, - EffectAcpErrors.AcpError - >(); - const effect = yield* Ref.modify(initializeStateRef, (state) => { - switch (state._tag) { - case "Started": - return [Effect.succeed(state.result), state] as const; - case "Starting": - return [Deferred.await(state.deferred), state] as const; - case "NotStarted": - return [ - initializeOnce.pipe( - Effect.tap((result) => - Ref.set(initializeStateRef, { _tag: "Started", result }).pipe( - Effect.andThen(Deferred.succeed(deferred, result)), - ), - ), - Effect.onError((cause) => - Deferred.failCause(deferred, cause).pipe( - Effect.andThen(Ref.set(initializeStateRef, { _tag: "NotStarted" })), - ), - ), - ), - { _tag: "Starting", deferred } satisfies AcpInitializeState, - ] as const; - } - }); - return yield* effect; - }); - - const sessionMcpServers = ( - initializeResult: EffectAcpSchema.InitializeResponse, - activationOptions?: AcpSessionActivationOptions, - ): ReadonlyArray => { - const acpServers = activationOptions?.acpMcpServers ?? options.acpMcpServers ?? []; - if ( - initializeResult.agentCapabilities?.mcpCapabilities?.acp === true && - acpServers.length > 0 - ) { - return acpServers; - } - return activationOptions?.mcpServers ?? options.mcpServers ?? []; - }; - - const startOnce = Effect.gen(function* () { - const initializeResult = yield* initialize; - - const authenticateAfterRequired = ( - authRequiredError: EffectAcpErrors.AcpError, - ): Effect.Effect => - Effect.gen(function* () { - const configuredAuthMethodId = options.authMethodId?.trim(); - const authMethod = selectAcpAgentAuthMethod( - initializeResult.authMethods, - configuredAuthMethodId, - ); - if ( - configuredAuthMethodId && - initializeResult.authMethods !== undefined && - authMethod === undefined - ) { - return yield* new EffectAcpErrors.AcpTransportError({ - detail: `ACP agent did not advertise configured authentication method "${configuredAuthMethodId}"`, - cause: { configuredAuthMethodId, authMethods: initializeResult.authMethods }, - }); - } - if ( - authMethod !== undefined && - authMethod.type !== undefined && - authMethod.type !== "agent" - ) { - return yield* new EffectAcpErrors.AcpTransportError({ - detail: `ACP authentication method "${authMethod.id}" requires ${authMethod.type} authentication, which cannot run inside a headless provider session`, - cause: authMethod, - }); - } - - const authMethodId = authMethod?.id ?? configuredAuthMethodId; - if (!authMethodId) { - return yield* authRequiredError; - } - - const authenticatePayload = { - methodId: authMethodId, - } satisfies EffectAcpSchema.AuthenticateRequest; - yield* runLoggedRequest( - "authenticate", - authenticatePayload, - acp.agent.authenticate(authenticatePayload), - ); - }); - - const setupSession = Effect.gen(function* () { - let sessionId: string; - let sessionSetupResult: - | EffectAcpSchema.LoadSessionResponse - | EffectAcpSchema.NewSessionResponse - | EffectAcpSchema.ResumeSessionResponse; - const additionalDirectories = options.additionalDirectories?.length - ? { additionalDirectories: options.additionalDirectories } - : {}; - if (options.resumeSessionId) { - sessionId = options.resumeSessionId; - if ( - initializeResult.agentCapabilities?.loadSession === true && - options.resumeMethod !== "resume" - ) { - const loadPayload = { - sessionId, - cwd: options.cwd, - ...additionalDirectories, - mcpServers: sessionMcpServers(initializeResult), - } satisfies EffectAcpSchema.LoadSessionRequest; - sessionSetupResult = yield* runLoadSessionWithReplayIdle(loadPayload, initializeResult); - } else if (initializeResult.agentCapabilities?.sessionCapabilities?.resume != null) { - const resumePayload = { - sessionId, - cwd: options.cwd, - ...additionalDirectories, - mcpServers: sessionMcpServers(initializeResult), - } satisfies EffectAcpSchema.ResumeSessionRequest; - sessionSetupResult = yield* runLoggedRequest( - "session/resume", - resumePayload, - acp.agent.resumeSession(resumePayload), - ); - } else { - return yield* new EffectAcpErrors.AcpRequestError({ - code: -32601, - errorMessage: "ACP agent does not advertise session/load or session/resume support", - }); - } - } else { - const createPayload = { - cwd: options.cwd, - mcpServers: sessionMcpServers(initializeResult), - ...additionalDirectories, - } satisfies EffectAcpSchema.NewSessionRequest; - const created = yield* runLoggedRequest( - "session/new", - createPayload, - acp.agent.createSession(createPayload), - ); - sessionId = created.sessionId; - sessionSetupResult = created; - } - - return { sessionId, sessionSetupResult }; - }); - - if (options.authenticateEagerly === true) { - // The configured method is authoritative here: Antigravity accepts - // methods (API key, Vertex) it does not list in `initialize`. - const authenticatePayload = { - methodId: options.authMethodId ?? "", - } satisfies EffectAcpSchema.AuthenticateRequest; - yield* runLoggedRequest( - "authenticate", - authenticatePayload, - acp.agent.authenticate(authenticatePayload), - ); - } - const { sessionId, sessionSetupResult } = yield* setupSession.pipe( - Effect.catch((error) => - !isAcpAuthenticationRequired(error) || options.authenticateOnAuthRequired === false - ? Effect.fail(error) - : authenticateAfterRequired(error).pipe(Effect.andThen(setupSession)), - ), - ); - - yield* Ref.set(modeStateRef, parseSessionModeState(sessionSetupResult)); - yield* Ref.set(configOptionsRef, sessionConfigOptionsFromSetup(sessionSetupResult)); - - const nextState = { - sessionId, - initializeResult, - sessionSetupResult, - modelConfigId: extractModelConfigId(sessionSetupResult), - } satisfies AcpStartedState; - return nextState; - }); - - const start = Effect.gen(function* () { - yield* ensureConnected; - const deferred = yield* Deferred.make< - AcpSessionRuntimeStartResult, - EffectAcpErrors.AcpError - >(); - const effect = yield* Ref.modify(startStateRef, (state) => { - switch (state._tag) { - case "Started": - return [Effect.succeed(state.result), state] as const; - case "Starting": - return [Deferred.await(state.deferred), state] as const; - case "NotStarted": - return [ - startOnce.pipe( - Effect.tap((result) => - notificationSemaphore.withPermit( - Effect.gen(function* () { - const error = yield* Ref.get(terminationErrorRef); - if (Option.isSome(error)) { - return yield* error.value; - } - yield* Ref.set(startStateRef, { _tag: "Started", result }); - const metadata = yield* Ref.getAndSet(startupMetadataRef, []); - for (const notification of metadata) { - if (notification.sessionId === result.sessionId) { - yield* processSessionUpdate(notification); - } - } - yield* Deferred.succeed(deferred, result); - }), - ), - ), - Effect.onError((cause) => - Deferred.failCause(deferred, cause).pipe( - Effect.andThen(Ref.set(startStateRef, { _tag: "NotStarted" })), - Effect.andThen(Ref.set(startupMetadataRef, [])), - ), - ), - ), - { _tag: "Starting", deferred } satisfies AcpStartState, - ] as const; - } - }); - return yield* effect; - }); - - const drainEvents = Effect.gen(function* () { - if (yield* Ref.get(stoppingRef)) { - return; - } - const acknowledge = yield* Deferred.make(); - yield* notificationSemaphore.withPermit( - Effect.gen(function* () { - // Keep a provider's final flushed chunks together until the adapter settles the turn. - if (Option.isNone(yield* Ref.get(activePromptRef))) { - yield* Ref.set(assistantUpdatesOpenRef, false); - yield* closeActiveAssistantSegment({ queue: eventQueue, assistantSegmentRef }); - } - yield* Queue.offer(eventQueue, { _tag: "EventStreamBarrier", acknowledge }); - }), - ); - yield* Effect.raceFirst(Deferred.await(acknowledge), Deferred.await(runtimeClosed)); - }); - - const retireRuntime = Effect.fn("AcpSessionRuntime.retireRuntime")(function* ( - error: EffectAcpErrors.AcpError, - ) { - yield* recordTermination(error); - yield* child.kill({ forceKillAfter: "1 second" }).pipe(Effect.ignore); - }); - - const cancel = Effect.gen(function* () { - const started = yield* getStartedState; - const activePrompt = yield* Ref.get(activePromptRef); - const cancelPayload = { - sessionId: started.sessionId, - ...(options.cancelMeta === undefined ? {} : { _meta: options.cancelMeta }), - } satisfies EffectAcpSchema.CancelNotification; - if (options.cancelBehavior !== "wait-for-prompt") { - if (options.interruptPromptOnCancel === false) { - return yield* acp.agent.cancel(cancelPayload); - } - if (Option.isSome(activePrompt)) { - yield* Fiber.interrupt(activePrompt.value.fiber).pipe(Effect.ignore); - } - // Write cancel before a replacement prompt can reach the agent. - yield* acp.agent.cancel(cancelPayload).pipe(Effect.ignore); - return; - } - - yield* acp.agent.cancel(cancelPayload); - if (Option.isNone(activePrompt)) { - return; - } - const completed = yield* Effect.gen(function* () { - const result = yield* Fiber.await(activePrompt.value.fiber); - yield* Deferred.await(activePrompt.value.completed); - if (Option.isNone(yield* Ref.get(terminationErrorRef))) { - yield* drainEvents; - } - return result; - }).pipe(Effect.timeoutOption(options.cancelTimeout ?? defaultCancelTimeout)); - if (Option.isNone(completed)) { - const error = new EffectAcpErrors.AcpTransportError({ - operation: "call-rpc", - method: "session/cancel", - detail: "The ACP agent did not finish cancellation. Its process was stopped.", - cause: undefined, - }); - yield* retireRuntime(error); - return yield* error; - } - if (Exit.isFailure(completed.value)) { - return yield* Effect.failCause(completed.value.cause); - } - }); - - return { - processContainment: - linuxCgroupLease !== undefined - ? "cgroup-v2" - : options.ownDescendantProcessGroups === true - ? "process-ledger-reduced-guarantee" - : options.ownDetachedProcessGroup === true - ? "process-group" - : "none", - handleRequestPermission: acp.handleRequestPermission, - handleElicitation: acp.handleElicitation, - handleMcpConnect: acp.handleMcpConnect, - handleMcpMessage: acp.handleMcpMessage, - handleMcpDisconnect: acp.handleMcpDisconnect, - handleMcpNotification: acp.handleMcpNotification, - handleReadTextFile: acp.handleReadTextFile, - handleWriteTextFile: acp.handleWriteTextFile, - handleCreateTerminal: acp.handleCreateTerminal, - handleTerminalOutput: acp.handleTerminalOutput, - handleTerminalWaitForExit: acp.handleTerminalWaitForExit, - handleTerminalKill: acp.handleTerminalKill, - handleTerminalRelease: acp.handleTerminalRelease, - handleSessionUpdate: acp.handleSessionUpdate, - handleElicitationComplete: acp.handleElicitationComplete, - handleUnknownExtRequest: acp.handleUnknownExtRequest, - handleUnknownExtNotification: acp.handleUnknownExtNotification, - handleExtRequest: acp.handleExtRequest, - handleExtNotification: acp.handleExtNotification, - initialize: () => initialize, - authenticate: (methodId) => - initialize.pipe( - Effect.andThen( - runLoggedRequest("authenticate", { methodId }, acp.agent.authenticate({ methodId })), - ), - Effect.asVoid, - ), - start: () => start, - getEvents: () => Stream.fromQueue(eventQueue), - drainEvents, - getModeState: Ref.get(modeStateRef), - getConfigOptions: Ref.get(configOptionsRef), - loadSession: (sessionId, activationOptions) => - start.pipe( - Effect.flatMap((started) => { - const requestPayload = { - sessionId, - cwd: options.cwd, - mcpServers: sessionMcpServers(started.initializeResult, activationOptions), - } satisfies EffectAcpSchema.LoadSessionRequest; - return runLoadSessionWithReplayIdle(requestPayload, started.initializeResult); - }), - Effect.flatMap((response) => adoptSession(sessionId, response)), - ), - resumeSession: (sessionId, activationOptions) => - start.pipe( - Effect.flatMap((started) => { - const requestPayload = { - sessionId, - cwd: options.cwd, - mcpServers: sessionMcpServers(started.initializeResult, activationOptions), - } satisfies EffectAcpSchema.ResumeSessionRequest; - return runLoggedRequest( - "session/resume", - requestPayload, - acp.agent.resumeSession(requestPayload), - ); - }), - Effect.flatMap((response) => adoptSession(sessionId, response)), - ), - forkSession: (sessionId, activationOptions) => - start.pipe( - Effect.flatMap((started) => { - const requestPayload = { - sessionId, - cwd: options.cwd, - mcpServers: sessionMcpServers(started.initializeResult, activationOptions), - } satisfies EffectAcpSchema.ForkSessionRequest; - return runLoggedRequest( - "session/fork", - requestPayload, - acp.agent.forkSession(requestPayload), - ); - }), - Effect.flatMap((response) => adoptSession(response.sessionId, response)), - ), - listSessions: (cursor) => { - const requestPayload = { - cwd: options.cwd, - ...(cursor === undefined ? {} : { cursor }), - } satisfies EffectAcpSchema.ListSessionsRequest; - return initialize.pipe( - Effect.filterOrFail( - (initialized) => initialized.agentCapabilities?.sessionCapabilities?.list != null, - () => - new EffectAcpErrors.AcpRequestError({ - code: -32601, - errorMessage: "ACP agent does not advertise session/list support", - }), - ), - Effect.andThen( - runLoggedRequest( - "session/list", - requestPayload, - acp.agent.listSessions(requestPayload), - ), - ), - ); - }, - closeSession: (sessionId) => - start.pipe( - Effect.flatMap((started) => { - const requestPayload = { - sessionId: sessionId ?? started.sessionId, - } satisfies EffectAcpSchema.CloseSessionRequest; - return runLoggedRequest( - "session/close", - requestPayload, - acp.agent.closeSession(requestPayload), - ); - }), - ), - deleteSession: (sessionId) => - initialize.pipe( - Effect.filterOrFail( - (initialized) => initialized.agentCapabilities?.sessionCapabilities?.delete != null, - () => - new EffectAcpErrors.AcpRequestError({ - code: -32601, - errorMessage: "ACP agent does not advertise session/delete support", - }), - ), - Effect.andThen( - runLoggedRequest( - "session/delete", - { sessionId }, - acp.agent.deleteSession({ sessionId }), - ), - ), - ), - listProviders: initialize.pipe( - Effect.filterOrFail( - (initialized) => initialized.agentCapabilities?.providers != null, - () => - new EffectAcpErrors.AcpRequestError({ - code: -32601, - errorMessage: "ACP agent does not advertise provider configuration support", - }), - ), - Effect.andThen(runLoggedRequest("providers/list", {}, acp.agent.listProviders({}))), - ), - setProvider: (provider) => - initialize.pipe( - Effect.filterOrFail( - (initialized) => initialized.agentCapabilities?.providers != null, - () => - new EffectAcpErrors.AcpRequestError({ - code: -32601, - errorMessage: "ACP agent does not advertise provider configuration support", - }), - ), - Effect.andThen( - runLoggedRequest( - "providers/set", - { - ...provider, - ...(provider.headers === undefined - ? {} - : { - headers: Object.fromEntries( - Object.keys(provider.headers).map((name) => [name, "[redacted]"]), - ), - }), - }, - acp.agent.setProvider(provider), - ), - ), - ), - disableProvider: (providerId) => { - const request = { providerId } satisfies EffectAcpSchema.DisableProviderRequest; - return initialize.pipe( - Effect.filterOrFail( - (initialized) => initialized.agentCapabilities?.providers != null, - () => - new EffectAcpErrors.AcpRequestError({ - code: -32601, - errorMessage: "ACP agent does not advertise provider configuration support", - }), - ), - Effect.andThen( - runLoggedRequest("providers/disable", request, acp.agent.disableProvider(request)), - ), - ); - }, - logout: initialize.pipe( - Effect.filterOrFail( - (initialized) => initialized.agentCapabilities?.auth?.logout != null, - () => - new EffectAcpErrors.AcpRequestError({ - code: -32601, - errorMessage: "ACP agent does not advertise logout support", - }), - ), - Effect.andThen(runLoggedRequest("logout", {}, acp.agent.logout({}))), - ), - prompt: (payload, promptOptions?) => - promptSerializationSemaphore.withPermit( - Effect.acquireUseRelease( - promptDispatchSemaphore.withPermit( - Effect.gen(function* () { - const started = yield* getStartedState; - yield* closeActiveAssistantSegment({ queue: eventQueue, assistantSegmentRef }); - yield* Ref.set(assistantUpdatesOpenRef, true); - const requestPayload = { - sessionId: started.sessionId, - ...payload, - } satisfies EffectAcpSchema.PromptRequest; - const completed = yield* Deferred.make(); - const fiber = yield* runLoggedRequest( - "session/prompt", - requestPayload, - acp.agent.prompt(requestPayload), - ).pipe(Effect.forkIn(runtimeScope)); - const active = { fiber, completed } satisfies AcpActivePrompt; - yield* Ref.set(activePromptRef, Option.some(active)); - if (promptOptions?.dispatched) { - yield* Deferred.succeed(promptOptions.dispatched, undefined); - } - return active; - }).pipe(notificationSemaphore.withPermit), - ), - (activePrompt) => - Fiber.join(activePrompt.fiber).pipe( - Effect.catchCause((cause) => - options.cancelBehavior !== "wait-for-prompt" && Cause.hasInterruptsOnly(cause) - ? Effect.succeed({ - stopReason: "cancelled", - } satisfies EffectAcpSchema.PromptResponse) - : Effect.failCause(cause), - ), - Effect.tap(() => - closeActiveAssistantSegment({ queue: eventQueue, assistantSegmentRef }), - ), - ), - (activePrompt, result) => - Effect.gen(function* () { - if ( - options.cancelBehavior === "wait-for-prompt" && - Exit.isFailure(result) && - Cause.hasInterrupts(result.cause) - ) { - yield* retireRuntime( - new EffectAcpErrors.AcpTransportError({ - method: "session/prompt", - detail: "The ACP prompt stopped before the agent confirmed completion.", - cause: undefined, - }), - ); - } - yield* Fiber.interrupt(activePrompt.fiber).pipe(Effect.ignore); - yield* Ref.set(activePromptRef, Option.none()); - yield* Deferred.succeed(activePrompt.completed, undefined); - }), - ), - ), - cancel: - options.cancelBehavior === "wait-for-prompt" - ? promptDispatchSemaphore.withPermit(cancel) - : cancel, - ...(options.ownDetachedProcessGroup === true ? { terminateProcessGroup } : {}), - // A session's mode is its `category: "mode"` config option. ACP v1 agents - // that only advertise `modes` (gemini-cli) take `session/set_mode` - // instead, which ACP v2 removed. - setMode: (modeId) => - Effect.gen(function* () { - const modeState = yield* Ref.get(modeStateRef); - if (modeState?.currentModeId === modeId) { - return {} satisfies EffectAcpSchema.SetSessionModeResponse; - } - const modeConfigOption = (yield* Ref.get(configOptionsRef))?.find( - (option) => option.category === "mode" && option.type === "select", - ); - if (modeConfigOption === undefined && modeState !== undefined) { - const started = yield* getStartedState; - const payload = { sessionId: started.sessionId, modeId }; - yield* runLoggedRequest("session/set_mode", payload, acp.agent.setSessionMode(payload)); - yield* updateCurrentModeId(modeId); - return {} satisfies EffectAcpSchema.SetSessionModeResponse; - } - const response = yield* setConfigOption(modeConfigOption?.id ?? "mode", modeId); - // The agent answers with its config options, so the mode it reports - // is the mode it runs in, even when it kept another one. - const reported = parseSessionModeState({ configOptions: response.configOptions }); - if (reported === undefined) { - yield* updateCurrentModeId(modeId); - } else { - yield* updateCurrentModeId(reported.currentModeId); - } - return {} satisfies EffectAcpSchema.SetSessionModeResponse; - }), - setSessionModel: (modelId, meta) => - getStartedState.pipe( - Effect.flatMap((started) => { - const payload = { - sessionId: started.sessionId, - modelId, - ...(meta === undefined ? {} : { _meta: meta }), - }; - return runLoggedRequest( - "session/set_model", - payload, - acp.agent.setSessionModel(payload), - ); - }), - ), - setConfigOption, - setModel: (model) => - getStartedState.pipe( - Effect.flatMap((started) => setConfigOption(started.modelConfigId ?? "model", model)), - Effect.asVoid, - ), - - request: (method, payload) => - ensureConnected.pipe( - Effect.andThen(runLoggedRequest(method, payload, acp.raw.request(method, payload))), - ), - notify: (method, payload) => - ensureConnected.pipe(Effect.andThen(acp.raw.notify(method, payload))), - } satisfies AcpSessionRuntime["Service"]; - }); - -export const layer = ( - options: AcpSessionRuntimeOptions, -): Layer.Layer< - AcpSessionRuntime, - EffectAcpErrors.AcpError, - ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto -> => Layer.effect(AcpSessionRuntime, make(options)); - -function sessionConfigOptionsFromSetup( - response: - | { - readonly configOptions?: ReadonlyArray | null; - } - | undefined, -): ReadonlyArray { - return response?.configOptions ?? []; -} - -function configOptionCurrentValueMatches( - configOption: EffectAcpSchema.SessionConfigOption, - value: string | boolean, -): boolean { - const currentValue = configOption.currentValue; - if (configOption.type === "boolean") { - return currentValue === value; - } - if (typeof currentValue !== "string") { - return false; - } - return currentValue.trim() === String(value).trim(); -} - -function isStartupMetadataUpdate(notification: EffectAcpSchema.SessionNotification): boolean { - switch (notification.update.sessionUpdate) { - case "current_mode_update": - case "config_option_update": - case "available_commands_update": - return true; - default: - return false; - } -} - -const handleSessionUpdate = ({ - queue, - modeStateRef, - configOptionsRef, - toolCallsRef, - shownToolCallIds, - assistantSegmentRef, - assistantItemRuntimeId, - params, -}: { - readonly queue: Queue.Queue; - readonly modeStateRef: Ref.Ref; - readonly configOptionsRef: Ref.Ref>; - readonly toolCallsRef: Ref.Ref>; - readonly shownToolCallIds: Set; - readonly assistantSegmentRef: Ref.Ref; - readonly assistantItemRuntimeId: string; - readonly params: EffectAcpSchema.SessionNotification; -}): Effect.Effect => - Effect.gen(function* () { - if (params.update.sessionUpdate === "config_option_update") { - const configOptions = params.update.configOptions; - yield* Ref.set(configOptionsRef, configOptions); - yield* Ref.update( - modeStateRef, - (current) => parseSessionModeState({ configOptions }) ?? current, - ); - } - const parsed = parseSessionUpdateEvent(params); - if (parsed.modeId) { - yield* Ref.update(modeStateRef, (current) => - current === undefined ? current : updateModeState(current, parsed.modeId!), - ); - } - for (const event of parsed.events) { - if (event._tag === "ToolCallUpdated") { - const { merged, decision, active } = yield* Ref.modify(toolCallsRef, (current) => { - const tracked = current.get(event.toolCall.toolCallId); - const previous = tracked?.state; - const nextToolCall = mergeToolCallState(previous, event.toolCall); - const decision = decideToolCallUpdateEmission({ - previous, - next: nextToolCall, - lastEmittedDetailLength: tracked?.lastEmittedDetailLength, - skippedSinceEmit: tracked?.skippedSinceEmit ?? 0, - }); - const next = new Map(current); - if (nextToolCall.status === "completed" || nextToolCall.status === "failed") { - next.delete(nextToolCall.toolCallId); - } else { - next.set(nextToolCall.toolCallId, { - state: nextToolCall, - lastEmittedDetailLength: decision.emit - ? toolCallProgressLength(nextToolCall) - : tracked?.lastEmittedDetailLength, - skippedSinceEmit: decision.skippedSinceEmit, - }); - } - return [{ merged: nextToolCall, decision, active: tracked !== undefined }, next] as const; - }); - if (!decision.emit) { - continue; - } - // A new tool call is a boundary in the prose. Progress on a call that - // is already shown, such as a background command finishing, is not. - if (!shownToolCallIds.has(merged.toolCallId)) { - shownToolCallIds.add(merged.toolCallId); - // Only recent calls get late updates; keep a long session bounded. - if (shownToolCallIds.size > MAX_SHOWN_TOOL_CALL_IDS) { - shownToolCallIds.delete(shownToolCallIds.values().next().value!); - } - // A call still running is already on screen, even if it aged out. - if (!active) yield* closeActiveAssistantSegment({ queue, assistantSegmentRef }); - } - yield* Queue.offer(queue, { - _tag: "ToolCallUpdated", - toolCall: merged, - rawPayload: event.rawPayload, - }); - continue; - } - if (event._tag === "ContentDelta") { - if (event.text.trim().length === 0) { - const assistantSegmentState = yield* Ref.get(assistantSegmentRef); - if (!assistantSegmentState.activeItemId) { - continue; - } - } - const itemId = yield* ensureActiveAssistantSegment({ - queue, - assistantSegmentRef, - sessionId: params.sessionId, - assistantItemRuntimeId, - }); - yield* Queue.offer(queue, { - ...event, - itemId, - }); - continue; - } - yield* Queue.offer(queue, event); - } - }); - -function updateModeState(modeState: AcpSessionModeState, nextModeId: string): AcpSessionModeState { - const normalized = nextModeId.trim(); - if (!normalized) { - return modeState; - } - return modeState.availableModes.some((mode) => mode.id === normalized) - ? { - ...modeState, - currentModeId: normalized, - } - : modeState; -} - -const assistantItemId = (sessionId: string, runtimeId: string, segmentIndex: number) => - `assistant:${sessionId}:runtime:${runtimeId}:segment:${segmentIndex}`; - -const ensureActiveAssistantSegment = ({ - queue, - assistantSegmentRef, - sessionId, - assistantItemRuntimeId, -}: { - readonly queue: Queue.Queue; - readonly assistantSegmentRef: Ref.Ref; - readonly sessionId: string; - readonly assistantItemRuntimeId: string; -}) => - Ref.modify( - assistantSegmentRef, - (current) => { - if (current.activeItemId) { - return [{ itemId: current.activeItemId }, current] as const; - } - const itemId = assistantItemId(sessionId, assistantItemRuntimeId, current.nextSegmentIndex); - return [ - { - itemId, - startedEvent: { - _tag: "AssistantItemStarted", - itemId, - } satisfies Extract, - }, - { - nextSegmentIndex: current.nextSegmentIndex + 1, - activeItemId: itemId, - } satisfies AcpAssistantSegmentState, - ] as const; - }, - ).pipe( - Effect.flatMap((result) => - result.startedEvent - ? Queue.offer(queue, result.startedEvent).pipe(Effect.as(result.itemId)) - : Effect.succeed(result.itemId), - ), - ); - -const closeActiveAssistantSegment = ({ - queue, - assistantSegmentRef, -}: { - readonly queue: Queue.Queue; - readonly assistantSegmentRef: Ref.Ref; -}) => - Ref.modify(assistantSegmentRef, (current) => { - if (!current.activeItemId) { - return [undefined, current] as const; - } - return [ - { - _tag: "AssistantItemCompleted", - itemId: current.activeItemId, - } satisfies AcpParsedSessionEvent, - { - nextSegmentIndex: current.nextSegmentIndex, - } satisfies AcpAssistantSegmentState, - ] as const; - }).pipe(Effect.flatMap((event) => (event ? Queue.offer(queue, event) : Effect.void))); diff --git a/apps/server/src/provider/acp/AcpStderr.test.ts b/apps/server/src/provider/acp/AcpStderr.test.ts deleted file mode 100644 index e88b59bdb0e1453d38224e1c4a7634068c826f2f..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/acp/AcpStderr.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { - ACP_STDERR_TAIL_MAX_CHARS, - appendAcpStderrTail, - sanitizeAcpStderrExcerpt, -} from "./AcpStderr.ts"; - -describe("AcpStderr", () => { - it("keeps a bounded tail of stderr chunks", () => { - const prefix = "x".repeat(ACP_STDERR_TAIL_MAX_CHARS); - expect(appendAcpStderrTail(prefix, "abc")).toBe(`${prefix.slice(3)}abc`); - }); - - it("redacts home paths, pairing URLs, and tokens from stderr excerpts", () => { - const excerpt = sanitizeAcpStderrExcerpt( - [ - "Invalid project config at /Users/ada/.cursor/cli.json", - "Authorization: Bearer secret-token-value", - "Visit http://localhost:5733/pair#token=ABCDEF for pairing", - "key=sk-abcdefghijklmnopqrstuv", - ].join("\n"), - { HOME: "/Users/ada" }, - ); - - expect(excerpt).toContain("Invalid project config at ~/.cursor/cli.json"); - expect(excerpt).toContain("Bearer [redacted]"); - expect(excerpt).toContain("[pairing-url]"); - expect(excerpt).toContain("[redacted]"); - expect(excerpt).not.toContain("secret-token-value"); - expect(excerpt).not.toContain("ABCDEF"); - expect(excerpt).not.toContain("sk-abcdefghijklmnopqrstuv"); - }); - - it("redacts hyphenated OpenAI project keys and header credentials", () => { - const excerpt = sanitizeAcpStderrExcerpt( - [ - "openai=sk-proj-abcdefghijklmnopqrstuvwxyz012345", - "svc=sk-svcacct-abcdefghijklmnopqrstuvwxyz012345", - "anthropic=sk-ant-api03-abcdefghijklmnopqrstuvwxyz012345", - "Authorization: Basic dXNlcjpwYXNz", - "x-api-key: ant-api-key-value", - ].join("\n"), - ); - - expect(excerpt).toContain("[redacted]"); - expect(excerpt).toContain("Authorization: Basic [redacted]"); - expect(excerpt).toContain("x-api-key: [redacted]"); - expect(excerpt).not.toContain("sk-proj-"); - expect(excerpt).not.toContain("sk-svcacct-"); - expect(excerpt).not.toContain("sk-ant-api03-"); - expect(excerpt).not.toContain("abcdefghijklmnopqrstuvwxyz012345"); - expect(excerpt).not.toContain("dXNlcjpwYXNz"); - expect(excerpt).not.toContain("ant-api-key-value"); - }); -}); diff --git a/apps/server/src/provider/acp/AcpStderr.ts b/apps/server/src/provider/acp/AcpStderr.ts deleted file mode 100644 index 52f68bb4c19a389cbd6f7a8f1e00a39dfea42430..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/acp/AcpStderr.ts +++ /dev/null @@ -1,38 +0,0 @@ -// @effect-diagnostics nodeBuiltinImport:off -- The excerpt sanitizer masks the home directory, which only the Node os module can resolve. -import * as NodeOS from "node:os"; - -/** Last few KiB of ACP child stderr kept for startup / exit diagnostics. */ -export const ACP_STDERR_TAIL_MAX_CHARS = 4_096; - -const PAIRING_URL_PATTERN = /https?:\/\/[^\s]*\/pair#[^\s]*/gi; -const BEARER_TOKEN_PATTERN = /\bBearer\s+[A-Za-z0-9._\-+=/]+/gi; -const BASIC_AUTH_PATTERN = /\bAuthorization:\s*Basic\s+\S+/gi; -const API_KEY_HEADER_PATTERN = /\bx-api-key:\s*\S+/gi; -const SECRET_TOKEN_PATTERN = - /\b(?:sk-[A-Za-z0-9][A-Za-z0-9-]{7,}|ghp_[A-Za-z0-9]+|xox[a-zA-Z]-[A-Za-z0-9-]+)\b/g; - -export function appendAcpStderrTail(current: string, chunk: string): string { - const next = `${current}${chunk}`; - return next.length <= ACP_STDERR_TAIL_MAX_CHARS ? next : next.slice(-ACP_STDERR_TAIL_MAX_CHARS); -} - -/** Bounded, redacted excerpt safe to put on user-facing adapter errors. */ -export function sanitizeAcpStderrExcerpt( - text: string, - environment: NodeJS.ProcessEnv = process.env, -): string { - let result = text.replaceAll("\0", ""); - const homes = [environment.HOME, environment.USERPROFILE, NodeOS.homedir()].filter( - (value): value is string => typeof value === "string" && value.length > 1, - ); - for (const home of new Set(homes)) { - result = result.split(home).join("~"); - } - result = result - .replace(PAIRING_URL_PATTERN, "[pairing-url]") - .replace(BEARER_TOKEN_PATTERN, "Bearer [redacted]") - .replace(BASIC_AUTH_PATTERN, "Authorization: Basic [redacted]") - .replace(API_KEY_HEADER_PATTERN, "x-api-key: [redacted]") - .replace(SECRET_TOKEN_PATTERN, "[redacted]"); - return result.trim(); -} diff --git a/apps/server/src/provider/acp/AntigravityAcpSupport.ts b/apps/server/src/provider/acp/AntigravityAcpSupport.ts index a3aa8005cfb13dccfc7ec4c9db3fa8f969a9eda0..d03ce73d09e225b5ba65681fb78de40b656963b8 100644 --- a/apps/server/src/provider/acp/AntigravityAcpSupport.ts +++ b/apps/server/src/provider/acp/AntigravityAcpSupport.ts @@ -22,7 +22,7 @@ import { makeAntigravityStderrHandler, makeAntigravityStdoutTransform, } from "../antigravityAuthSupport.ts"; -import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; +import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; import { normalizeAntigravitySessionUpdate } from "./AntigravityProtocol.ts"; export interface AntigravityAcpRuntimeInput extends Omit< diff --git a/apps/server/src/provider/acp/AntigravityProtocol.test.ts b/apps/server/src/provider/acp/AntigravityProtocol.test.ts index 0a628eb03c565fe7dd9c7f6066b8f9d1c0cdf4c3..cfc7a6adfbe19d75485481df385ac5387285e834 100644 --- a/apps/server/src/provider/acp/AntigravityProtocol.test.ts +++ b/apps/server/src/provider/acp/AntigravityProtocol.test.ts @@ -17,7 +17,10 @@ import { sanitizeAntigravityToolPayload, selectAntigravityPermissionOptionId, } from "./AntigravityProtocol.ts"; -import { mergeToolCallState, parseSessionUpdateEvent } from "./AcpRuntimeModel.ts"; +import { + mergeToolCallState, + parseSessionUpdateEvent, +} from "@t3tools/provider-acp/server/runtimeModel"; const isSessionNotification = Schema.is(AcpWireSchema.SessionNotification); diff --git a/apps/server/src/provider/acp/AntigravityProtocol.ts b/apps/server/src/provider/acp/AntigravityProtocol.ts index 77eff09f7e876db67cd4b60f0a2b108027ffa81d..3f99288328d4a1c8d550228746473aade06400e7 100644 --- a/apps/server/src/provider/acp/AntigravityProtocol.ts +++ b/apps/server/src/provider/acp/AntigravityProtocol.ts @@ -11,7 +11,7 @@ import * as Schema from "effect/Schema"; import type * as EffectAcpSchema from "effect-acp/compat"; import * as AcpWireSchema from "effect-acp/schema-v1"; -import type { AcpToolCallState } from "./AcpRuntimeModel.ts"; +import type { AcpToolCallState } from "@t3tools/provider-acp/server/runtimeModel"; const TOOL_TEXT_LIMIT = 8_000; const TOOL_TEXT_TRUNCATED = "[Earlier output truncated]\n\n"; diff --git a/apps/server/src/provider/acp/GrokAcpSupport.ts b/apps/server/src/provider/acp/GrokAcpSupport.ts index 0039a12683b8c95701590e7f502adabe3b192968..57b4c8fb9d421145613af2f2041ad4f1142960fb 100644 --- a/apps/server/src/provider/acp/GrokAcpSupport.ts +++ b/apps/server/src/provider/acp/GrokAcpSupport.ts @@ -15,7 +15,7 @@ import * as EffectAcpErrors from "effect-acp/errors"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { normalizeModelSlug } from "@t3tools/shared/model"; -import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; +import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; import { makeXAiPromptCompletionRuntime } from "./XAiAcpExtension.ts"; const GROK_API_KEY_ENV = "XAI_API_KEY"; diff --git a/apps/server/src/provider/acp/XAiAcpExtension.test.ts b/apps/server/src/provider/acp/XAiAcpExtension.test.ts index f4282006f98dfa64be0c078b6b84065ebcabd450..84e5511785b40de5c52abd1b1d32fdcfadb174eb 100644 --- a/apps/server/src/provider/acp/XAiAcpExtension.test.ts +++ b/apps/server/src/provider/acp/XAiAcpExtension.test.ts @@ -34,12 +34,12 @@ import { xAiSubagentFinishedNotice, XAiAskUserQuestionRequest, } from "./XAiAcpExtension.ts"; -import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; +import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; import { type AcpToolCallState, mergeToolCallState, parseSessionUpdateEvent, -} from "./AcpRuntimeModel.ts"; +} from "@t3tools/provider-acp/server/runtimeModel"; const decodeXAiAskUserQuestionRequest = Schema.decodeUnknownSync(XAiAskUserQuestionRequest); diff --git a/apps/server/src/provider/acp/XAiAcpExtension.ts b/apps/server/src/provider/acp/XAiAcpExtension.ts index 94d278cfa5a9c0b13554ac9b5ebe6b4a4c4336da..4c424a664d903f54ff19132bccccd0cef55f8f06 100644 --- a/apps/server/src/provider/acp/XAiAcpExtension.ts +++ b/apps/server/src/provider/acp/XAiAcpExtension.ts @@ -11,8 +11,8 @@ import * as Schema from "effect/Schema"; import * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpSchema from "effect-acp/compat"; -import type * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; -import type { AcpToolCallState } from "./AcpRuntimeModel.ts"; +import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; +import type { AcpToolCallState } from "@t3tools/provider-acp/server/runtimeModel"; const xAiStopReasonMissingMetaKey = "xAiStopReasonMissing"; export const xAiRateLimitedErrorCode = -32003; diff --git a/apps/server/src/provider/antigravityAuthSupport.ts b/apps/server/src/provider/antigravityAuthSupport.ts index ea5aff4b4c77468302eaefa85012e15930759595..c4816709a2f67c2ce9fe542812b7109cdd592637 100644 --- a/apps/server/src/provider/antigravityAuthSupport.ts +++ b/apps/server/src/provider/antigravityAuthSupport.ts @@ -17,7 +17,7 @@ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as AcpErrors from "effect-acp/errors"; import { collectUint8StreamText } from "@t3tools/provider-core/server/collectStreamText"; -import type { AcpSpawnInput } from "./acp/AcpSessionRuntime.ts"; +import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; import { antigravityUserSkillDirectories, resolveAntigravityUserHome, @@ -440,7 +440,7 @@ export function buildAntigravityAcpSpawnInput(input: { readonly auth?: AntigravityAuthConfig; /** Per-process temp directory. Defaults to the profile's shared temp directory. */ readonly runtimeTempDirectory?: string; -}): AcpSpawnInput { +}): AcpSessionRuntime.AcpSpawnInput { return { command: input.installation.executablePath, args: input.profile.platform === "linux" ? ["--uid="] : [], diff --git a/apps/server/src/textGeneration/AntigravityTextGeneration.test.ts b/apps/server/src/textGeneration/AntigravityTextGeneration.test.ts index 4fab9e92b9f5e4ebfbc7700d2061d0a94c1c08a1..454c683896d2bfb4873af3888cdc26495ec0b983 100644 --- a/apps/server/src/textGeneration/AntigravityTextGeneration.test.ts +++ b/apps/server/src/textGeneration/AntigravityTextGeneration.test.ts @@ -20,7 +20,7 @@ import { type AcpError, AcpRequestError } from "effect-acp/errors"; import type * as AcpSchema from "effect-acp/compat"; import { expect } from "vite-plus/test"; -import type { AcpSessionRuntimeEvent } from "../provider/acp/AcpSessionRuntime.ts"; +import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; import { removeAntigravitySessionFiles } from "../provider/acp/AntigravitySessionFiles.ts"; import { @@ -121,7 +121,7 @@ const makeFixture = Effect.fn("makeAntigravityTextGenerationFixture")(function* const makeRuntime: AntigravityTextGenerationOptions["makeRuntime"] = (cwd) => Effect.gen(function* () { - const events = yield* Queue.unbounded(); + const events = yield* Queue.unbounded(); state.workspaces.push(cwd); expect(yield* fs.readDirectory(cwd).pipe(Effect.orDie)).toEqual([]); yield* Effect.addFinalizer(() => diff --git a/apps/server/src/textGeneration/AntigravityTextGeneration.ts b/apps/server/src/textGeneration/AntigravityTextGeneration.ts index 2ff9cb659e8b2102e06b9ddee910ecdb627672fc..2c06b082feef44966359ac01ab7bdadb661362ce 100644 --- a/apps/server/src/textGeneration/AntigravityTextGeneration.ts +++ b/apps/server/src/textGeneration/AntigravityTextGeneration.ts @@ -13,7 +13,7 @@ import { type AcpError, AcpRequestError } from "effect-acp/errors"; import { applyAntigravityAcpModelSelection } from "../provider/acp/AntigravityAcpSupport.ts"; import { removeAntigravitySessionFiles } from "../provider/acp/AntigravitySessionFiles.ts"; -import type { AcpSessionRuntime } from "../provider/acp/AcpSessionRuntime.ts"; +import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; import * as TextGenerationOperations from "@t3tools/provider-core/server/textGenerationOperations"; const ANTIGRAVITY_TIMEOUT_MS = 180_000; @@ -25,7 +25,7 @@ const decodeConfiguration = Schema.decodeEffect(Schema.fromJsonString(Configurat const decodeConfigurationObject = Schema.decodeUnknownEffect(Configuration); type AntigravityTextRuntime = Pick< - AcpSessionRuntime["Service"], + AcpSessionRuntime.AcpSessionRuntime["Service"], | "start" | "setMode" | "getConfigOptions" diff --git a/docs/internals/adding-a-provider.md b/docs/internals/adding-a-provider.md index b442053c8001c809e729ff5bebf19a482b79b6be..518e3ea05b23ff6f9124b455927dc5952b45d39c 100644 --- a/docs/internals/adding-a-provider.md +++ b/docs/internals/adding-a-provider.md @@ -10,7 +10,7 @@ whether a turn runs. This page lists the decisions and evidence a new driver nee - **ACP agents** start as [ACP Registry](../user/providers-acp.md) entries, which follow the ACP spec with no per-agent handling. An agent gets a dedicated driver only when it needs behavior the spec does not cover, and then it is a small flavor over the - [shared ACP adapter](../../apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts), like Grok + [shared ACP adapter](../../packages/provider-acp/src/server/adapter.ts), like Grok and Antigravity. Never add agent-id checks to the generic registry adapter. - **Other protocols** get a native adapter that implements [`ProviderAdapterV2`](../../packages/provider-core/src/server/ProviderAdapter.ts), like Codex, diff --git a/package.json b/package.json index 545a4247c098df9650aadf7f163f536f70645b96..f85d50c4a2268dda8ca075b4299b09528be9d9a6 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "tc": "vp run -r --concurrency-limit 2 typecheck", "lint": "vp lint --report-unused-disable-directives", "knip": "knip --preprocessor ./scripts/knip-schemas.ts", - "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --workspace packages/provider-cursor --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", + "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-acp --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --workspace packages/provider-cursor --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts", "lint:mobile": "node scripts/mobile-native-static-check.ts", "test": "vp run -r test", diff --git a/packages/provider-acp/package.json b/packages/provider-acp/package.json new file mode 100644 index 0000000000000000000000000000000000000000..f3bbfa0dad3855e9f7650295f2317ad135e6f69e --- /dev/null +++ b/packages/provider-acp/package.json @@ -0,0 +1,65 @@ +{ + "name": "@t3tools/provider-acp", + "private": true, + "type": "module", + "exports": { + "./server/AcpSessionRuntime": { + "types": "./src/server/AcpSessionRuntime.ts", + "import": "./src/server/AcpSessionRuntime.ts" + }, + "./server/adapter": { + "types": "./src/server/adapter.ts", + "import": "./src/server/adapter.ts" + }, + "./server/clientPolicy": { + "types": "./src/server/clientPolicy.ts", + "import": "./src/server/clientPolicy.ts" + }, + "./server/clientTerminals": { + "types": "./src/server/clientTerminals.ts", + "import": "./src/server/clientTerminals.ts" + }, + "./server/coreRuntimeEvents": { + "types": "./src/server/coreRuntimeEvents.ts", + "import": "./src/server/coreRuntimeEvents.ts" + }, + "./server/mcpOverAcpBridge": { + "types": "./src/server/mcpOverAcpBridge.ts", + "import": "./src/server/mcpOverAcpBridge.ts" + }, + "./server/nativeLogging": { + "types": "./src/server/nativeLogging.ts", + "import": "./src/server/nativeLogging.ts" + }, + "./server/runtimeModel": { + "types": "./src/server/runtimeModel.ts", + "import": "./src/server/runtimeModel.ts" + }, + "./server/sessionConfig": { + "types": "./src/server/sessionConfig.ts", + "import": "./src/server/sessionConfig.ts" + }, + "./server/stderr": { + "types": "./src/server/stderr.ts", + "import": "./src/server/stderr.ts" + } + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vp test run --config ../../vite.config.ts --dir ." + }, + "dependencies": { + "@t3tools/contracts": "workspace:*", + "@t3tools/provider-core": "workspace:*", + "@t3tools/shared": "workspace:*", + "diff": "8.0.3", + "effect": "catalog:" + }, + "devDependencies": { + "@effect/platform-node": "catalog:", + "@effect/vitest": "catalog:", + "@types/node": "catalog:", + "effect-acp": "workspace:*", + "vite-plus": "catalog:" + } +} diff --git a/packages/provider-acp/src/server/AcpSessionRuntime.ts b/packages/provider-acp/src/server/AcpSessionRuntime.ts new file mode 100644 index 0000000000000000000000000000000000000000..8b060291f8b8312b459859d511c0209344bf90b9 --- /dev/null +++ b/packages/provider-acp/src/server/AcpSessionRuntime.ts @@ -0,0 +1,3035 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeCrypto from "node:crypto"; +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; + +import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import * as ChildProcess from "effect/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import * as EffectAcpClient from "effect-acp/client"; +import * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpSchema from "effect-acp/compat"; +import type * as EffectAcpProtocol from "effect-acp/protocol"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; + +import { signalProcessGroup } from "@t3tools/provider-core/server/processGroup"; +import { appendAcpStderrTail, sanitizeAcpStderrExcerpt } from "./stderr.ts"; +import { + collectSessionConfigOptionValues, + decideToolCallUpdateEmission, + extractModelConfigId, + findSessionConfigOption, + mergeToolCallState, + toolCallProgressLength, + parseSessionModeState, + parseSessionUpdateEvent, + sessionUpdateCountsAsLoadReplayActivity, + sessionUpdateIsReplay, + waitForSessionLoadReplayIdle, + type SessionLoadGate, + type AcpParsedSessionEvent, + type AcpSessionModeState, + type AcpToolCallState, +} from "./runtimeModel.ts"; + +const MAX_SHOWN_TOOL_CALL_IDS = 256; + +interface AcpToolCallTrackedState { + readonly state: AcpToolCallState; + readonly lastEmittedDetailLength: number | undefined; + readonly skippedSinceEmit: number; +} + +function formatConfigOptionValue(value: string | boolean): string { + return JSON.stringify(value); +} + +export interface AcpSessionEventStreamBarrier { + readonly _tag: "EventStreamBarrier"; + readonly acknowledge: Deferred.Deferred; +} + +export type AcpSessionRuntimeEvent = + | AcpParsedSessionEvent + | AcpSessionEventStreamBarrier + | { + readonly _tag: "ConnectionTerminated"; + readonly error: EffectAcpErrors.AcpError; + }; + +const defaultSessionLoadTimeout = Duration.seconds(90); +const defaultSessionLoadReplayIdleGap = Duration.seconds(2); +const defaultCancelTimeout = Duration.seconds(15); +const maxStartupMetadataUpdates = 32; +// Antigravity can emit an accepted 16 KiB Google authorization URL on stderr. +const maxStderrChunkLength = 32_768; + +export interface AcpSpawnInput { + readonly command: string; + readonly args: ReadonlyArray; + readonly cwd?: string; + readonly env?: NodeJS.ProcessEnv; + readonly extendEnv?: boolean; + readonly shell?: false; +} + +export interface AcpSessionRuntimeOptions { + readonly spawn: AcpSpawnInput; + readonly cwd: string; + readonly resumeSessionId?: string; + readonly resumeMethod?: "load" | "resume"; + readonly sessionLoadTimeout?: Duration.Input; + readonly sessionLoadReplayIdleGap?: Duration.Input; + readonly interruptPromptOnCancel?: boolean; + /** Optional provider metadata forwarded on `session/cancel`. */ + readonly cancelMeta?: EffectAcpSchema.CancelNotification["_meta"]; + /** Optional provider metadata forwarded on `initialize`. */ + readonly initializeMeta?: EffectAcpSchema.InitializeRequest["_meta"]; + readonly ownDetachedProcessGroup?: boolean; + readonly ownDescendantProcessGroups?: boolean; + readonly processGroupPlatform?: NodeJS.Platform; + readonly processGroupTerminationGrace?: Duration.Input; + readonly windowsProcessTreeTerminator?: ( + pid: number, + ) => Effect.Effect; + readonly posixProcessTreeController?: AcpPosixProcessTreeController; + readonly linuxCgroupController?: AcpLinuxCgroupController | null; + /** Native cancellation waits for the prompt response and the getEvents consumer to drain. */ + readonly cancelBehavior?: "interrupt" | "wait-for-prompt"; + readonly cancelTimeout?: Duration.Input; + readonly clientCapabilities?: EffectAcpSchema.InitializeRequest["clientCapabilities"]; + readonly clientInfo: { + readonly name: string; + readonly version: string; + }; + readonly authMethodId?: string; + /** + * Call `authenticate` right after `initialize` instead of waiting for an + * auth-required error. Antigravity selects its credential source (Google + * account vs API key) from the method, so the session must not open first. + */ + readonly authenticateEagerly?: boolean; + readonly mcpServers?: ReadonlyArray; + /** Extra workspace roots the agent may read and write besides `cwd`. */ + readonly additionalDirectories?: ReadonlyArray; + /** Transforms provider stdout before protocol parsing and protocol logging. */ + readonly transformStdout?: EffectAcpClient.AcpClientOptions["transformStdout"]; + /** Normalizes provider-specific fields before notification queues or runtime state retain them. */ + readonly transformSessionUpdate?: ( + notification: EffectAcpSchema.SessionNotification, + ) => EffectAcpSchema.SessionNotification; + /** Receives bounded stderr chunks. Redact secrets before logging. A failure closes the runtime. */ + readonly onStderr?: (text: string) => Effect.Effect; + /** Disable only for non-interactive discovery that must surface auth-required immediately. */ + readonly authenticateOnAuthRequired?: boolean; + /** Observes the normalized handshake before any session setup or authentication retry. */ + readonly onInitialized?: ( + result: EffectAcpSchema.InitializeResponse, + ) => Effect.Effect; + readonly acpMcpServers?: ReadonlyArray; + readonly requestLogger?: (event: AcpSessionRequestLogEvent) => Effect.Effect; + readonly protocolLogging?: { + readonly logIncoming?: boolean; + readonly logOutgoing?: boolean; + readonly logger?: (event: EffectAcpProtocol.AcpProtocolLogEvent) => Effect.Effect; + }; + readonly onIncomingRequest?: EffectAcpClient.AcpClientOptions["onIncomingRequest"]; + readonly onTermination?: (error: EffectAcpErrors.AcpError) => Effect.Effect; + readonly onOutgoingResponseFailure?: EffectAcpClient.AcpClientOptions["onOutgoingResponseFailure"]; + readonly onOutgoingResponse?: EffectAcpClient.AcpClientOptions["onOutgoingResponse"]; +} + +export interface AcpSessionRequestLogEvent { + readonly method: string; + readonly payload: unknown; + readonly status: "started" | "succeeded" | "failed"; + readonly result?: unknown; + readonly cause?: Cause.Cause; +} + +export class AcpProcessGroupTerminationError extends Schema.TaggedError()( + "AcpProcessGroupTerminationError", + { + detail: Schema.String, + pid: Schema.optionalKey(Schema.Int), + exitCode: Schema.optionalKey(Schema.Number), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return this.detail; + } +} + +const isAcpProcessGroupTerminationError = Schema.is(AcpProcessGroupTerminationError); + +export interface AcpPosixProcessIdentity { + readonly executable: string | undefined; + readonly pgid: number; + readonly pid: number; + readonly ppid: number; + readonly sid: number; + /** + * Linux `/proc//stat` state when known (`R`/`S`/`D`/`Z`/…). Omitted on + * platforms or fixtures that do not surface it. Zombies (`Z`) are already + * dead and must not count as residual teardown survivors. + */ + readonly state?: string; + readonly startTime: string; +} + +export interface AcpPosixProcessTreeController { + readonly childPidsOf: (pid: number) => ReadonlyArray; + readonly childrenOf: (pid: number) => ReadonlyArray; + readonly identity: (pid: number) => AcpPosixProcessIdentity | undefined; + readonly snapshot: () => ReadonlyArray; + readonly signalProcess: (pid: number, signal: NodeJS.Signals) => void; +} + +export interface AcpLinuxCgroupLease { + readonly contains: (pid: number) => boolean; + readonly exists: () => boolean; + readonly path: string; + readonly relativePath: string; + readonly kill: () => void; + readonly populated: () => boolean; + readonly remove: () => void; +} + +export interface AcpLinuxCgroupController { + readonly create: () => AcpLinuxCgroupLease | undefined; +} + +export interface AcpPosixOwnershipRoot { + captureAttempted?: boolean; + value: AcpOwnedPosixProcess | undefined; +} + +function decodeMountInfoPath(value: string): string { + return value.replace(/\\([0-7]{3})/g, (_match, octal: string) => + String.fromCharCode(Number.parseInt(octal, 8)), + ); +} + +export function parseUnifiedCgroupPath(contents: string): string | undefined { + const matches = [...contents.matchAll(/^0::(\/.*)$/gm)]; + return matches.length === 1 ? matches[0]![1] : undefined; +} + +export function isSafeCgroupPath(value: string): boolean { + return ( + value.startsWith("/") && + !value.includes("(deleted)") && + NodePath.posix.normalize(value) === value + ); +} + +export function parseCgroup2Mounts( + contents: string, +): ReadonlyArray<{ readonly mountPoint: string; readonly root: string }> { + const mounts: Array<{ readonly mountPoint: string; readonly root: string }> = []; + for (const line of contents.split("\n")) { + const [mount, filesystem] = line.split(" - "); + if (mount === undefined || filesystem?.split(" ")[0] !== "cgroup2") continue; + const fields = mount.split(" "); + if (fields.length < 5) continue; + mounts.push({ + mountPoint: decodeMountInfoPath(fields[4]!), + root: decodeMountInfoPath(fields[3]!), + }); + } + return mounts; +} + +export function parseCgroup2Mount( + contents: string, +): { readonly mountPoint: string; readonly root: string } | undefined { + return parseCgroup2Mounts(contents)[0]; +} + +export function wrapCommandForLinuxCgroup( + lease: AcpLinuxCgroupLease, + command: string, + args: ReadonlyArray, +): { readonly command: string; readonly args: ReadonlyArray } { + return { + command: "/bin/sh", + args: [ + "-c", + [ + "lease_path=$1; expected=$2; shift 2", + 'printf "%s\\n" "$$" > "$lease_path/cgroup.procs" || exit 125', + "actual=", + "while IFS= read -r line; do", + ' case "$line" in 0::*) [ -z "$actual" ] || exit 126; actual=${line#0::};; esac', + "done < /proc/self/cgroup || exit 125", + '[ "$actual" = "$expected" ] || exit 126', + "unset ELECTRON_RUN_AS_NODE T3_ACP_CGROUP_WRAPPER", + "trap 'exit 125' 0", + 'exec "$@"', + ].join("\n"), + "t3-acp-cgroup-wrapper", + lease.path, + lease.relativePath, + command, + ...args, + ], + }; +} + +export function resolveLinuxCgroupTargetCommand( + command: string, + cwd: string, + environment: NodeJS.ProcessEnv, +): string | undefined { + // Match Node spawn PATH fallback when PATH is undefined. An explicitly empty + // PATH stays empty (lookup only via empty-segment → cwd). + const pathEnv = environment.PATH === undefined ? "/usr/bin:/bin" : environment.PATH; + const candidates = command.includes(NodePath.sep) + ? [NodePath.resolve(cwd, command)] + : pathEnv + .split(NodePath.delimiter) + .map((entry) => + NodePath.join( + entry.length === 0 + ? cwd + : NodePath.isAbsolute(entry) + ? entry + : NodePath.resolve(cwd, entry), + command, + ), + ); + for (const candidate of candidates) { + try { + if (!NodeFS.statSync(candidate).isFile()) continue; + NodeFS.accessSync(candidate, NodeFS.constants.X_OK); + return candidate; + } catch { + // Try the next PATH entry. + } + } + return undefined; +} + +const STALE_ACP_CGROUP_SIBLING = /^t3-acp-(\d+)-/; + +export interface SweepStaleLinuxCgroupSiblingsOptions { + readonly currentPid?: number; + readonly isProcessAlive?: (pid: number) => boolean; + readonly readPopulated?: (siblingPath: string) => "0" | "1" | undefined; + readonly remove?: (siblingPath: string) => void; +} + +function defaultCgroupOwnerIsAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (cause) { + return (cause as NodeJS.ErrnoException | undefined)?.code !== "ESRCH"; + } +} + +function defaultReadCgroupPopulated(siblingPath: string): "0" | "1" | undefined { + const state = /(?:^|\n)populated ([01])(?:\n|$)/.exec( + NodeFS.readFileSync(NodePath.join(siblingPath, "cgroup.events"), "utf8"), + )?.[1]; + return state === "0" || state === "1" ? state : undefined; +} + +/** Best-effort removal of empty `t3-acp--*` sibling leases under a parent cgroup. */ +export function sweepStaleLinuxCgroupSiblings( + parentPath: string, + options: SweepStaleLinuxCgroupSiblingsOptions = {}, +): void { + const currentPid = options.currentPid ?? process.pid; + const isProcessAlive = options.isProcessAlive ?? defaultCgroupOwnerIsAlive; + const readPopulated = options.readPopulated ?? defaultReadCgroupPopulated; + const remove = options.remove ?? ((siblingPath: string) => NodeFS.rmdirSync(siblingPath)); + try { + for (const entry of NodeFS.readdirSync(parentPath, { withFileTypes: true })) { + try { + if (!entry.isDirectory()) continue; + const match = STALE_ACP_CGROUP_SIBLING.exec(entry.name); + if (match === null) continue; + const ownerPid = Number(match[1]); + if (!Number.isSafeInteger(ownerPid) || ownerPid <= 1 || ownerPid === currentPid) continue; + if (isProcessAlive(ownerPid)) continue; + const siblingPath = NodePath.join(parentPath, entry.name); + if (NodePath.dirname(siblingPath) !== parentPath) continue; + if (readPopulated(siblingPath) !== "0") continue; + remove(siblingPath); + } catch { + // Ignore unreadable or busy siblings; never fail lease creation. + } + } + } catch { + // Ignore readdir failures on the parent path. + } +} + +function tryCreateLinuxCgroupLease( + currentRelative: string, + mount: { readonly mountPoint: string; readonly root: string }, +): AcpLinuxCgroupLease | undefined { + if ( + !isSafeCgroupPath(mount.root) || + !isSafeCgroupPath(mount.mountPoint) || + !( + mount.root === "/" || + currentRelative === mount.root || + currentRelative.startsWith(`${mount.root}/`) + ) + ) { + return undefined; + } + const mountRelative = + mount.root === "/" ? currentRelative : currentRelative.slice(mount.root.length); + const currentPath = NodePath.resolve(mount.mountPoint, `.${mountRelative}`); + const resolvedMount = NodePath.resolve(mount.mountPoint); + if (currentPath !== resolvedMount && !currentPath.startsWith(`${resolvedMount}${NodePath.sep}`)) { + return undefined; + } + sweepStaleLinuxCgroupSiblings(currentPath); + const childName = `t3-acp-${process.pid}-${NodeCrypto.randomUUID().replaceAll("-", "")}`; + const childPath = NodePath.join(currentPath, childName); + const childRelative = NodePath.posix.join(currentRelative, childName); + if (NodePath.dirname(childPath) !== currentPath) return undefined; + try { + NodeFS.mkdirSync(childPath, { mode: 0o700 }); + NodeFS.accessSync(NodePath.join(childPath, "cgroup.procs"), NodeFS.constants.W_OK); + NodeFS.accessSync(NodePath.join(childPath, "cgroup.kill"), NodeFS.constants.W_OK); + NodeFS.accessSync(NodePath.join(childPath, "cgroup.events"), NodeFS.constants.R_OK); + if (NodeFS.readFileSync(NodePath.join(childPath, "cgroup.type"), "utf8").trim() !== "domain") { + throw new Error("ACP cgroup is not a domain cgroup"); + } + if (typeof process.execve !== "function") throw new Error("process.execve is unavailable"); + } catch { + try { + NodeFS.rmdirSync(childPath); + } catch { + // The unavailable cgroup was never used for a child process. + } + return undefined; + } + return { + contains: (pid) => + parseUnifiedCgroupPath(NodeFS.readFileSync(`/proc/${pid}/cgroup`, "utf8")) === childRelative, + exists: () => NodeFS.existsSync(childPath), + path: childPath, + relativePath: childRelative, + kill: () => NodeFS.writeFileSync(NodePath.join(childPath, "cgroup.kill"), "1\n"), + populated: () => { + const state = /(?:^|\n)populated ([01])(?:\n|$)/.exec( + NodeFS.readFileSync(NodePath.join(childPath, "cgroup.events"), "utf8"), + )?.[1]; + if (state === undefined) throw new Error("ACP cgroup.events has no populated state"); + return state === "1"; + }, + remove: () => { + const directories = (path: string): ReadonlyArray => + NodeFS.readdirSync(path, { withFileTypes: true }).flatMap((entry) => + entry.isDirectory() + ? [...directories(NodePath.join(path, entry.name)), NodePath.join(path, entry.name)] + : [], + ); + for (const directory of directories(childPath)) NodeFS.rmdirSync(directory); + NodeFS.rmdirSync(childPath); + }, + }; +} + +export function makeLinuxCgroupController(): AcpLinuxCgroupController { + return { + create: () => { + let currentRelative: string | undefined; + let mounts: ReadonlyArray<{ readonly mountPoint: string; readonly root: string }> = []; + try { + currentRelative = parseUnifiedCgroupPath(NodeFS.readFileSync("/proc/self/cgroup", "utf8")); + mounts = parseCgroup2Mounts(NodeFS.readFileSync("/proc/self/mountinfo", "utf8")); + } catch { + return undefined; + } + if ( + currentRelative === undefined || + !currentRelative.startsWith("/") || + mounts.length === 0 + ) { + return undefined; + } + if (!isSafeCgroupPath(currentRelative)) return undefined; + // Prefer the first mount that successfully creates a writable child cgroup. + // Multi-mount hosts often list a read-only view before the delegated one. + for (const mount of mounts) { + const lease = tryCreateLinuxCgroupLease(currentRelative, mount); + if (lease !== undefined) return lease; + } + return undefined; + }, + }; +} + +// OS-state polls (cgroup emptiness, process trees) must use wall time. Under +// TestClock, Effect.sleep never resolves unless the test advances the clock. +const wallClock = Clock.Clock.defaultValue(); +const withWallClock = (effect: Effect.Effect): Effect.Effect => + Effect.provideService(effect, Clock.Clock, wallClock); + +export function terminateLinuxCgroupLease( + lease: AcpLinuxCgroupLease, +): Effect.Effect { + const failure = (name: string, cause: unknown) => + new AcpProcessGroupTerminationError({ + cause, + detail: `Failed to ${name} ACP cgroup ${lease.path}`, + }); + return Effect.gen(function* () { + let lastRemovalFailure: { readonly cause: unknown } | null = null; + for (let attempt = 0; attempt < 200; attempt += 1) { + if (!lease.exists()) return; + try { + lease.kill(); + } catch (cause) { + const code = (cause as NodeJS.ErrnoException | undefined)?.code; + if (code !== "ENOENT") return yield* failure("kill", cause); + if (!lease.exists()) return; + yield* Effect.sleep("10 millis"); + continue; + } + let populated: boolean; + try { + populated = lease.populated(); + } catch (cause) { + const code = (cause as NodeJS.ErrnoException | undefined)?.code; + if (code === "ENOENT" && !lease.exists()) return; + if (code === "ENOENT") { + yield* Effect.sleep("10 millis"); + continue; + } + return yield* failure("read state for", cause); + } + if (populated) { + lastRemovalFailure = null; + yield* Effect.sleep("10 millis"); + continue; + } + try { + lease.remove(); + lastRemovalFailure = null; + } catch (cause) { + const code = (cause as NodeJS.ErrnoException | undefined)?.code; + if (code === "ENOENT" && !lease.exists()) return; + if (code !== "EBUSY" && code !== "ENOTEMPTY" && code !== "ENOENT") { + return yield* failure("remove", cause); + } + lastRemovalFailure = { cause }; + } + if (!lease.exists()) return; + yield* Effect.sleep("10 millis"); + } + if (lastRemovalFailure !== null) { + return yield* failure("remove", lastRemovalFailure.cause); + } + return yield* new AcpProcessGroupTerminationError({ + detail: `ACP cgroup ${lease.path} remained present after cgroup cleanup`, + }); + }).pipe(withWallClock); +} + +export function signalLinuxCgroupRootTerm(input: { + readonly controller: AcpPosixProcessTreeController; + readonly lease: AcpLinuxCgroupLease; + readonly root: AcpPosixOwnershipRoot; +}): void { + const root = input.root.value; + if (root === undefined) return; + try { + const observed = input.controller.identity(root.pid); + if (!samePosixProcessIdentity(root, observed) || !input.lease.contains(root.pid)) return; + input.controller.signalProcess(root.pid, "SIGTERM"); + } catch { + // TERM is optional. cgroup.kill remains the authoritative teardown. + } +} + +function readLinuxProcessIdentity(pid: number): AcpPosixProcessIdentity | undefined { + try { + const stat = NodeFS.readFileSync(`/proc/${pid}/stat`, "utf8"); + const commandEnd = stat.lastIndexOf(")"); + if (commandEnd < 0) return undefined; + const fields = stat + .slice(commandEnd + 2) + .trim() + .split(/\s+/); + // After comm: state, ppid, pgrp, session, … starttime (field 22 / index 19). + const state = fields[0]; + const ppid = Number(fields[1]); + const pgid = Number(fields[2]); + const sid = Number(fields[3]); + const startTime = fields[19]; + if ( + state === undefined || + !Number.isSafeInteger(ppid) || + !Number.isSafeInteger(pgid) || + !Number.isSafeInteger(sid) || + startTime === undefined + ) { + return undefined; + } + let executable: string | undefined; + try { + executable = NodeFS.readlinkSync(`/proc/${pid}/exe`); + } catch { + executable = undefined; + } + return { executable, pgid, pid, ppid, sid, startTime, state }; + } catch { + return undefined; + } +} + +/** Linux zombies keep pid/starttime until reaped; they are not live survivors. */ +export function posixProcessIsZombie(process: AcpPosixProcessIdentity): boolean { + return process.state === "Z"; +} + +function readLinuxChildPids(pid: number): ReadonlyArray { + const childPids = new Set(); + let taskIds: ReadonlyArray = []; + try { + taskIds = NodeFS.readdirSync(`/proc/${pid}/task`).filter((entry) => + /^[1-9][0-9]*$/.test(entry), + ); + } catch { + return []; + } + for (const taskId of taskIds) { + try { + for (const childPid of NodeFS.readFileSync(`/proc/${pid}/task/${taskId}/children`, "utf8") + .trim() + .split(/\s+/) + .filter(Boolean) + .map(Number)) { + childPids.add(childPid); + } + } catch { + // The task exited while its children were being read. + } + } + return [...childPids]; +} + +function makeLinuxProcessTreeController(): AcpPosixProcessTreeController { + return { + childPidsOf: readLinuxChildPids, + childrenOf: (pid) => + readLinuxChildPids(pid).flatMap((childPid) => { + const identity = readLinuxProcessIdentity(childPid); + return identity === undefined ? [] : [identity]; + }), + identity: readLinuxProcessIdentity, + snapshot: () => + NodeFS.readdirSync("/proc", { withFileTypes: true }).flatMap((entry) => { + if (!entry.isDirectory() || !/^[1-9][0-9]*$/.test(entry.name)) return []; + const identity = readLinuxProcessIdentity(Number(entry.name)); + return identity === undefined ? [] : [identity]; + }), + signalProcess: (pid, signal) => process.kill(pid, signal), + }; +} + +export function observePosixOwnershipLedger(input: { + readonly childQueues: Map>; + readonly controller: AcpPosixProcessTreeController; + readonly frontier: Map; + readonly ledger: Map; + readonly maxProcesses?: number; + readonly root: AcpPosixOwnershipRoot; + readonly rootPid: number; +}): void { + let rootIdentity: AcpPosixProcessIdentity | undefined = input.root.value; + if (rootIdentity === undefined) { + if (input.root.captureAttempted === true) { + throw new AcpProcessGroupTerminationError({ + detail: `ACP process ${input.rootPid} exited before its ownership ledger was captured`, + }); + } + input.root.captureAttempted = true; + rootIdentity = input.controller.identity(input.rootPid); + if (rootIdentity === undefined) { + throw new AcpProcessGroupTerminationError({ + detail: `ACP process ${input.rootPid} exited before its ownership ledger was captured`, + }); + } + const parent = input.controller.identity(rootIdentity.ppid); + const ownedRoot = { + ...rootIdentity, + parentExecutable: parent?.executable, + parentStartTime: parent?.startTime ?? "", + }; + input.ledger.set(processIdentityKey(rootIdentity), ownedRoot); + input.frontier.set(rootIdentity.pid, ownedRoot); + input.root.value = ownedRoot; + } + const maxProcesses = Math.max(1, input.maxProcesses ?? Number.POSITIVE_INFINITY); + let remainingBudget = maxProcesses; + const pending = [...input.frontier.entries()]; + const visited = new Set(); + while (pending.length > 0 && remainingBudget > 0) { + const entry = pending.shift(); + if (entry === undefined) continue; + const [parentPid, parent] = entry; + input.frontier.delete(parentPid); + if (visited.has(parent.pid)) continue; + visited.add(parent.pid); + remainingBudget -= 1; + const observedParent = input.controller.identity(parent.pid); + if (!samePosixProcessIdentity(parent, observedParent)) { + input.childQueues.delete(parent.pid); + continue; + } + const refreshedParent = { ...parent, ...observedParent }; + input.ledger.set(processIdentityKey(refreshedParent), refreshedParent); + input.frontier.set(refreshedParent.pid, refreshedParent); + let queuedChildren = input.childQueues.get(refreshedParent.pid); + if (queuedChildren === undefined) { + if (remainingBudget === 0) continue; + remainingBudget -= 1; + queuedChildren = [...input.controller.childPidsOf(refreshedParent.pid)]; + } + while (queuedChildren.length > 0 && remainingBudget > 0) { + const childPid = queuedChildren.shift(); + if (childPid === undefined) continue; + remainingBudget -= 1; + const child = input.controller.identity(childPid); + if (child === undefined) continue; + if (child.ppid !== parent.pid) continue; + let reservedPid = input.frontier.get(child.pid); + if (reservedPid !== undefined && !samePosixProcessIdentity(reservedPid, child)) { + // PID reused under an owned parent; drop the stale reservation. + input.frontier.delete(child.pid); + for (const [key, owned] of input.ledger.entries()) { + if (owned.pid === child.pid) input.ledger.delete(key); + } + reservedPid = undefined; + } + const childKey = processIdentityKey(child); + const owned = + reservedPid === undefined + ? { + ...child, + parentExecutable: refreshedParent.executable, + parentStartTime: refreshedParent.startTime, + } + : { ...reservedPid, ...child }; + input.ledger.set(childKey, owned); + input.frontier.set(child.pid, owned); + } + if (queuedChildren.length === 0) input.childQueues.delete(refreshedParent.pid); + else input.childQueues.set(refreshedParent.pid, queuedChildren); + } +} + +export function observePosixOwnershipLedgerContinuously(input: { + readonly childQueues: Map>; + readonly controller: AcpPosixProcessTreeController; + readonly frontier: Map; + readonly ledger: Map; + readonly root: AcpPosixOwnershipRoot; + readonly rootPid: number; +}): Effect.Effect { + return Effect.forever( + Effect.gen(function* () { + const startedAt = yield* Clock.currentTimeMillis; + yield* Effect.sync(() => observePosixOwnershipLedger({ ...input, maxProcesses: 64 })).pipe( + Effect.exit, + ); + const targetInterval = input.frontier.size <= 32 ? 25 : input.frontier.size <= 128 ? 50 : 100; + const completedAt = yield* Clock.currentTimeMillis; + yield* Effect.sleep(`${Math.max(5, targetInterval - (completedAt - startedAt))} millis`); + }), + ).pipe(withWallClock); +} + +export function makePosixProcessTreeController( + platform: NodeJS.Platform, +): AcpPosixProcessTreeController { + if (platform === "linux") return makeLinuxProcessTreeController(); + throw new AcpProcessGroupTerminationError({ + detail: `Detached ACP descendant ownership is unsupported on ${platform}`, + }); +} + +function samePosixProcessIdentity( + expected: AcpPosixProcessIdentity, + observed: AcpPosixProcessIdentity | undefined, +): boolean { + return ( + observed !== undefined && + observed.pid === expected.pid && + observed.startTime === expected.startTime + ); +} + +export interface AcpOwnedPosixProcess extends AcpPosixProcessIdentity { + readonly parentExecutable: string | undefined; + readonly parentStartTime: string; +} + +function processIdentityKey(process: AcpPosixProcessIdentity): string { + return `${process.pid}:${process.startTime}`; +} + +function processDepth( + process: AcpPosixProcessIdentity, + ledgerByPid: ReadonlyMap, +): number { + let depth = 0; + let current = process; + const seen = new Set(); + while (!seen.has(current.pid)) { + seen.add(current.pid); + const parent = ledgerByPid.get(current.ppid); + if (parent === undefined) break; + depth += 1; + current = parent; + } + return depth; +} + +export function capturePosixOwnershipLedger(input: { + readonly controller: AcpPosixProcessTreeController; + readonly ledger: Map; + readonly root?: AcpPosixOwnershipRoot; + readonly rootPid: number; + readonly table?: ReadonlyArray; +}): ReadonlyArray { + let table: ReadonlyArray; + if (input.table === undefined) { + try { + table = input.controller.snapshot(); + } catch (cause) { + throw new AcpProcessGroupTerminationError({ + cause, + detail: `Failed to snapshot ACP process tree ${input.rootPid}`, + }); + } + } else { + table = input.table; + } + const byPid = new Map(table.map((process) => [process.pid, process])); + let rootIdentity: AcpPosixProcessIdentity | undefined = + input.root?.value ?? + [...input.ledger.values()].find((process) => process.pid === input.rootPid); + if (rootIdentity === undefined) { + rootIdentity = byPid.get(input.rootPid); + if (rootIdentity === undefined) { + throw new AcpProcessGroupTerminationError({ + detail: `ACP process ${input.rootPid} exited before its ownership ledger was captured`, + }); + } + const ownedRoot = { + ...rootIdentity, + parentExecutable: byPid.get(rootIdentity.ppid)?.executable, + parentStartTime: byPid.get(rootIdentity.ppid)?.startTime ?? "", + }; + input.ledger.set(processIdentityKey(rootIdentity), ownedRoot); + if (input.root !== undefined) input.root.value = ownedRoot; + } + // Drop non-root ledger entries whose PID is free or whose identity no longer + // matches the live process at that PID. Either case can block admission of a + // legitimate child after PID reuse. Keep the root reservation even if the + // root briefly disappears mid-teardown (finalizer retry still needs it). + for (const [key, owned] of input.ledger.entries()) { + if (owned.pid === input.rootPid) continue; + const live = byPid.get(owned.pid); + if (live === undefined || !samePosixProcessIdentity(owned, live)) { + input.ledger.delete(key); + } + } + const children = new Map>(); + const retainedByPid = new Map( + [...input.ledger.values()] + .filter((owned) => samePosixProcessIdentity(owned, byPid.get(owned.pid))) + .map((owned) => [owned.pid, owned]), + ); + for (const process of table) { + const siblings = children.get(process.ppid) ?? []; + siblings.push(process); + children.set(process.ppid, siblings); + } + const pending = [...input.ledger.values()].filter((owned) => + samePosixProcessIdentity(owned, byPid.get(owned.pid)), + ); + const visited = new Set(); + while (pending.length > 0) { + const parent = pending.shift(); + if (parent === undefined || visited.has(parent.pid)) continue; + visited.add(parent.pid); + const observedParent = byPid.get(parent.pid); + if (!samePosixProcessIdentity(parent, observedParent)) continue; + const refreshedParent = { ...parent, ...observedParent }; + input.ledger.set(processIdentityKey(parent), refreshedParent); + for (const process of children.get(refreshedParent.pid) ?? []) { + const reservedPid = retainedByPid.get(process.pid); + if (reservedPid !== undefined && !samePosixProcessIdentity(reservedPid, process)) continue; + const owned = + reservedPid === undefined + ? { + ...process, + parentExecutable: refreshedParent.executable, + parentStartTime: refreshedParent.startTime, + } + : { ...reservedPid, ...process }; + input.ledger.set(processIdentityKey(process), owned); + retainedByPid.set(process.pid, owned); + pending.push(owned); + } + } + return table; +} + +export function terminatePosixOwnedProcessTree(input: { + readonly controller: AcpPosixProcessTreeController; + readonly discoveryPasses?: number; + readonly grace?: Duration.Input; + readonly ledger?: Map; + readonly root?: AcpPosixOwnershipRoot; + readonly rootPid: number; +}): Effect.Effect { + // This portable fallback can only admit processes whose exact parent chain is + // visible before it breaks. A descendant that double-forks before discovery + // requires pre-exec cgroup containment for a hard ownership guarantee. + const ledger = input.ledger ?? new Map(); + + const fail = (detail: string, cause?: unknown) => + new AcpProcessGroupTerminationError({ detail, ...(cause === undefined ? {} : { cause }) }); + const snapshot = () => { + try { + return input.controller.snapshot(); + } catch (cause) { + throw fail(`Failed to snapshot ACP process tree ${input.rootPid}`, cause); + } + }; + const discover = (table: ReadonlyArray) => + capturePosixOwnershipLedger({ + controller: input.controller, + ledger, + ...(input.root === undefined ? {} : { root: input.root }), + rootPid: input.rootPid, + table, + }); + const signalPhase = (signal: NodeJS.Signals, includeRoot: boolean) => { + const table = snapshot(); + discover(table); + const byPid = new Map(table.map((entry) => [entry.pid, entry])); + const current = input.controller.identity(process.pid); + if (current === undefined) throw fail("Cannot identify the current T3 process group"); + const ledgerByPid = new Map( + [...ledger.values()].map((process) => [process.pid, process] as const), + ); + const pending = [...ledger.values()].filter( + (candidate) => + (includeRoot || candidate.pid !== input.rootPid) && + samePosixProcessIdentity(candidate, byPid.get(candidate.pid)), + ); + const retainedByPid = new Map( + [...ledger.values()] + .filter((owned) => samePosixProcessIdentity(owned, byPid.get(owned.pid))) + .map((owned) => [owned.pid, owned]), + ); + const signalled = new Set(); + while (pending.length > 0) { + pending.sort( + (left, right) => processDepth(right, ledgerByPid) - processDepth(left, ledgerByPid), + ); + const candidate = pending.shift(); + if (candidate === undefined || signalled.has(candidate.pid)) continue; + const observed = input.controller.identity(candidate.pid); + if ( + candidate.pid <= 1 || + candidate.pid === process.pid || + observed === undefined || + observed.pgid === current.pgid || + observed.sid === current.sid || + !samePosixProcessIdentity(candidate, observed) + ) { + continue; + } + for (const child of input.controller.childrenOf(candidate.pid)) { + if (child.ppid !== candidate.pid) continue; + const reservedPid = retainedByPid.get(child.pid); + if (reservedPid !== undefined && !samePosixProcessIdentity(reservedPid, child)) continue; + const owned = + reservedPid === undefined + ? { + ...child, + parentExecutable: observed.executable, + parentStartTime: observed.startTime, + } + : { ...reservedPid, ...child }; + ledger.set(processIdentityKey(child), owned); + ledgerByPid.set(child.pid, owned); + retainedByPid.set(child.pid, owned); + // Signal newly discovered children in this same pass (fork-during-kill). + if ( + (includeRoot || child.pid !== input.rootPid) && + !signalled.has(child.pid) && + samePosixProcessIdentity(owned, input.controller.identity(child.pid)) + ) { + pending.push(owned); + } + } + try { + // Linux starttime prevents PID reuse across observations, but Node does + // not expose pidfd_send_signal. A final read-to-kill race remains. + input.controller.signalProcess(candidate.pid, signal); + signalled.add(candidate.pid); + } catch (cause) { + if (samePosixProcessIdentity(candidate, input.controller.identity(candidate.pid))) { + throw fail(`Failed to signal ACP process ${candidate.pid}`, cause); + } + } + } + }; + + return Effect.gen(function* () { + const passes = Math.max(2, Math.min(input.discoveryPasses ?? 4, 8)); + for (let pass = 0; pass < passes; pass += 1) { + yield* Effect.try({ + try: () => signalPhase("SIGTERM", false), + catch: (cause) => cause as AcpProcessGroupTerminationError, + }); + yield* Effect.sleep("10 millis"); + } + yield* Effect.try({ + try: () => signalPhase("SIGTERM", true), + catch: (cause) => cause as AcpProcessGroupTerminationError, + }); + const grace = input.grace ?? "1 second"; + if (grace !== 0) yield* Effect.sleep(grace); + for (let pass = 0; pass < passes; pass += 1) { + yield* Effect.try({ + try: () => signalPhase("SIGKILL", true), + catch: (cause) => cause as AcpProcessGroupTerminationError, + }); + yield* Effect.sleep("10 millis"); + } + const finalTable = snapshot(); + discover(finalTable); + const survivors = new Map(finalTable.map((entry) => [entry.pid, entry])); + const residual = [...ledger.values()].filter((owned) => { + const observed = survivors.get(owned.pid); + if (!samePosixProcessIdentity(owned, observed) || observed === undefined) { + return false; + } + // Already-killed zombies still appear in /proc until reaped; not live. + return !posixProcessIsZombie(observed); + }); + if (residual.length > 0) { + return yield* fail( + `ACP process tree ${input.rootPid} retained owned processes: ${residual.map((entry) => entry.pid).join(", ")}`, + ); + } + }).pipe(withWallClock); +} + +export function windowsTaskkillResultIsSuccess(exitCode: number, _output?: string): boolean { + return exitCode === 0; +} + +export const terminateWindowsProcessTreeWithTaskkill = ( + spawner: ChildProcessSpawner.ChildProcessSpawner["Service"], + pid: number, +): Effect.Effect => + Effect.scoped( + Effect.gen(function* () { + const taskkill = yield* spawner.spawn( + ChildProcess.make("taskkill", ["/PID", String(pid), "/T", "/F"]), + ); + const outputFiber = yield* Stream.decodeText(taskkill.all).pipe( + Stream.mkString, + Effect.forkScoped, + ); + const exitCode = Number(yield* taskkill.exitCode); + const output = yield* Fiber.join(outputFiber); + // A missing leader cannot prove that inherited descendants exited. + if (windowsTaskkillResultIsSuccess(exitCode, output)) return; + const trimmedOutput = output.trim(); + return yield* new AcpProcessGroupTerminationError({ + detail: `taskkill exited ${exitCode} for ACP process tree ${pid}`, + pid, + exitCode, + ...(trimmedOutput.length === 0 + ? {} + : { cause: trimmedOutput.length > 500 ? trimmedOutput.slice(0, 500) : trimmedOutput }), + }); + }), + ).pipe( + Effect.mapError((cause) => + isAcpProcessGroupTerminationError(cause) + ? cause + : new AcpProcessGroupTerminationError({ + cause, + detail: `Failed to run taskkill for ACP process tree ${pid}`, + pid, + }), + ), + ); + +export function selectAcpAgentAuthMethod( + authMethods: ReadonlyArray | undefined, + preferredMethodId?: string, +): EffectAcpSchema.AuthMethod | undefined { + const preferred = preferredMethodId?.trim(); + if (preferred) { + return authMethods?.find((method) => method.id === preferred); + } + return authMethods?.find((method) => method.type === undefined || method.type === "agent"); +} + +function isAcpAuthenticationRequired(error: EffectAcpErrors.AcpError): boolean { + return error._tag === "AcpRequestError" && error.code === -32000; +} + +export interface AcpSessionRuntimeStartResult { + readonly sessionId: string; + readonly initializeResult: EffectAcpSchema.InitializeResponse; + readonly sessionSetupResult: + | EffectAcpSchema.ForkSessionResponse + | EffectAcpSchema.LoadSessionResponse + | EffectAcpSchema.NewSessionResponse + | EffectAcpSchema.ResumeSessionResponse; + readonly modelConfigId: string | undefined; +} + +export interface AcpSessionActivationOptions { + readonly mcpServers?: ReadonlyArray; + readonly acpMcpServers?: ReadonlyArray; +} + +export class AcpSessionRuntime extends Context.Service< + AcpSessionRuntime, + { + /** + * Registers a handler for `session/request_permission`. + * @see https://agentclientprotocol.com/protocol/schema#session/request_permission + */ + readonly handleRequestPermission: EffectAcpClient.AcpClient["Service"]["handleRequestPermission"]; + /** + * Registers a handler for `session/elicitation`. + * @see https://agentclientprotocol.com/protocol/schema#session/elicitation + */ + readonly handleElicitation: EffectAcpClient.AcpClient["Service"]["handleElicitation"]; + readonly handleMcpConnect: EffectAcpClient.AcpClient["Service"]["handleMcpConnect"]; + readonly handleMcpMessage: EffectAcpClient.AcpClient["Service"]["handleMcpMessage"]; + readonly handleMcpDisconnect: EffectAcpClient.AcpClient["Service"]["handleMcpDisconnect"]; + readonly handleMcpNotification: EffectAcpClient.AcpClient["Service"]["handleMcpNotification"]; + /** + * Registers a handler for `fs/read_text_file`. + * @see https://agentclientprotocol.com/protocol/schema#fs/read_text_file + */ + readonly handleReadTextFile: EffectAcpClient.AcpClient["Service"]["handleReadTextFile"]; + /** + * Registers a handler for `fs/write_text_file`. + * @see https://agentclientprotocol.com/protocol/schema#fs/write_text_file + */ + readonly handleWriteTextFile: EffectAcpClient.AcpClient["Service"]["handleWriteTextFile"]; + /** + * Registers a handler for `terminal/create`. + * @see https://agentclientprotocol.com/protocol/schema#terminal/create + */ + readonly handleCreateTerminal: EffectAcpClient.AcpClient["Service"]["handleCreateTerminal"]; + /** + * Registers a handler for `terminal/output`. + * @see https://agentclientprotocol.com/protocol/schema#terminal/output + */ + readonly handleTerminalOutput: EffectAcpClient.AcpClient["Service"]["handleTerminalOutput"]; + /** + * Registers a handler for `terminal/wait_for_exit`. + * @see https://agentclientprotocol.com/protocol/schema#terminal/wait_for_exit + */ + readonly handleTerminalWaitForExit: EffectAcpClient.AcpClient["Service"]["handleTerminalWaitForExit"]; + /** + * Registers a handler for `terminal/kill`. + * @see https://agentclientprotocol.com/protocol/schema#terminal/kill + */ + readonly handleTerminalKill: EffectAcpClient.AcpClient["Service"]["handleTerminalKill"]; + /** + * Registers a handler for `terminal/release`. + * @see https://agentclientprotocol.com/protocol/schema#terminal/release + */ + readonly handleTerminalRelease: EffectAcpClient.AcpClient["Service"]["handleTerminalRelease"]; + /** + * Registers a handler for `session/update`. + * @see https://agentclientprotocol.com/protocol/schema#session/update + */ + readonly handleSessionUpdate: EffectAcpClient.AcpClient["Service"]["handleSessionUpdate"]; + /** + * Registers a handler for `session/elicitation/complete`. + * @see https://agentclientprotocol.com/protocol/schema#session/elicitation/complete + */ + readonly handleElicitationComplete: EffectAcpClient.AcpClient["Service"]["handleElicitationComplete"]; + /** + * Registers a fallback extension request handler. + * @see https://agentclientprotocol.com/protocol/extensibility + */ + readonly handleUnknownExtRequest: EffectAcpClient.AcpClient["Service"]["handleUnknownExtRequest"]; + /** + * Registers a fallback extension notification handler. + * @see https://agentclientprotocol.com/protocol/extensibility + */ + readonly handleUnknownExtNotification: EffectAcpClient.AcpClient["Service"]["handleUnknownExtNotification"]; + /** + * Registers a typed extension request handler. + * @see https://agentclientprotocol.com/protocol/extensibility + */ + readonly handleExtRequest: EffectAcpClient.AcpClient["Service"]["handleExtRequest"]; + /** + * Registers a typed extension notification handler. + * @see https://agentclientprotocol.com/protocol/extensibility + */ + readonly handleExtNotification: EffectAcpClient.AcpClient["Service"]["handleExtNotification"]; + /** + * Sends only `initialize` and returns the agent's response. Health probes use this to read + * advertised capabilities without authenticating or opening a session, so a probe can never + * start an interactive login or boot MCP servers. + * @see https://agentclientprotocol.com/protocol/schema#initialize + */ + readonly initialize: () => Effect.Effect< + EffectAcpSchema.InitializeResponse, + EffectAcpErrors.AcpError + >; + /** Explicit login uses the negotiated v1 authenticate / v2 auth/login operation. */ + readonly authenticate?: (methodId: string) => Effect.Effect; + /** + * Initializes the ACP connection, authenticates, and loads, resumes, or creates the session. + * Concurrent calls share the same in-flight startup and a failed startup may be retried. + */ + readonly start: () => Effect.Effect; + /** Stream of parsed root-session events and connection failures. */ + readonly getEvents: () => Stream.Stream; + /** Waits for queued events to be processed, or for the runtime scope to close. */ + readonly drainEvents: Effect.Effect; + /** Latest mode state observed from session setup and `session/update` notifications. */ + readonly getModeState: Effect.Effect; + /** Latest configuration options observed from session setup and configuration writes. */ + readonly getConfigOptions: Effect.Effect>; + readonly loadSession: ( + sessionId: string, + options?: AcpSessionActivationOptions, + ) => Effect.Effect; + readonly resumeSession: ( + sessionId: string, + options?: AcpSessionActivationOptions, + ) => Effect.Effect; + readonly forkSession: ( + sessionId: string, + options?: AcpSessionActivationOptions, + ) => Effect.Effect; + readonly listSessions: ( + cursor?: string, + ) => Effect.Effect; + readonly closeSession: ( + sessionId?: string, + ) => Effect.Effect; + readonly deleteSession: ( + sessionId: string, + ) => Effect.Effect; + readonly listProviders: Effect.Effect< + EffectAcpSchema.ListProvidersResponse, + EffectAcpErrors.AcpError + >; + readonly setProvider: ( + provider: EffectAcpSchema.SetProviderRequest, + ) => Effect.Effect; + readonly disableProvider: ( + providerId: string, + ) => Effect.Effect; + /** Logs out the current ACP identity when the agent advertises support. */ + readonly logout: Effect.Effect; + /** + * Sends a prompt turn to the active session. `options.dispatched` settles once the + * `session/prompt` RPC is registered as the active prompt, so a caller that forks this + * effect knows when a later `cancel` will target this prompt. + * @see https://agentclientprotocol.com/protocol/schema#session/prompt + */ + readonly prompt: ( + payload: Omit, + options?: { readonly dispatched?: Deferred.Deferred }, + ) => Effect.Effect; + /** + * Sends a real ACP `session/cancel` notification for the active session. + * @see https://agentclientprotocol.com/protocol/schema#session/cancel + */ + readonly cancel: Effect.Effect; + readonly processContainment: + | "cgroup-v2" + | "process-ledger-reduced-guarantee" + | "process-group" + | "none"; + /** Terminates the opt-in detached ACP process group and all inherited work. */ + readonly terminateProcessGroup?: Effect.Effect; + /** + * Selects the active mode through the negotiated `mode` configuration option. + * This is a no-op when the requested mode is already active. + * @see https://agentclientprotocol.com/protocol/schema#session/set_config_option + */ + readonly setMode: ( + modeId: string, + ) => Effect.Effect; + /** + * Updates a session configuration option and the runtime configuration snapshot. + * @see https://agentclientprotocol.com/protocol/schema#session/set_config_option + */ + readonly setSessionModel: ( + modelId: string, + meta?: EffectAcpSchema.SetSessionModelRequest["_meta"], + ) => Effect.Effect; + readonly setConfigOption: ( + configId: string, + value: string | boolean, + ) => Effect.Effect; + /** + * Selects the base model through the negotiated model configuration option. + * @see https://agentclientprotocol.com/protocol/schema#session/set_config_option + */ + readonly setModel: (model: string) => Effect.Effect; + /** + + * Sends a generic ACP extension request and records it through the request logger. + * @see https://agentclientprotocol.com/protocol/extensibility + */ + readonly request: ( + method: string, + payload: unknown, + ) => Effect.Effect; + /** + * Sends a generic ACP extension notification. + * @see https://agentclientprotocol.com/protocol/extensibility + */ + readonly notify: ( + method: string, + payload: unknown, + ) => Effect.Effect; + } +>()("@t3tools/provider-acp/server/AcpSessionRuntime") { + static layer( + options: AcpSessionRuntimeOptions, + ): Layer.Layer< + AcpSessionRuntime, + EffectAcpErrors.AcpError, + ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto + > { + return Layer.effect(AcpSessionRuntime, make(options)); + } +} + +interface AcpStartedState extends AcpSessionRuntimeStartResult {} + +type AcpStartState = + | { readonly _tag: "NotStarted" } + | { + readonly _tag: "Starting"; + readonly deferred: Deferred.Deferred; + } + | { readonly _tag: "Started"; readonly result: AcpStartedState }; + +type AcpInitializeState = + | { readonly _tag: "NotStarted" } + | { + readonly _tag: "Starting"; + readonly deferred: Deferred.Deferred< + EffectAcpSchema.InitializeResponse, + EffectAcpErrors.AcpError + >; + } + | { readonly _tag: "Started"; readonly result: EffectAcpSchema.InitializeResponse }; + +interface AcpAssistantSegmentState { + readonly nextSegmentIndex: number; + readonly activeItemId?: string; +} + +interface EnsureActiveAssistantSegmentResult { + readonly itemId: string; + readonly startedEvent?: Extract; +} + +interface AcpActivePrompt { + readonly fiber: Fiber.Fiber; + readonly completed: Deferred.Deferred; +} + +export const make = ( + options: AcpSessionRuntimeOptions, +): Effect.Effect< + AcpSessionRuntime["Service"], + EffectAcpErrors.AcpError, + ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | Scope.Scope +> => + Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + // A child of the caller's scope, so termination can close the runtime without closing the caller's. + const runtimeScope = yield* Scope.fork(yield* Scope.Scope); + const eventQueue = yield* Queue.unbounded(); + const modeStateRef = yield* Ref.make(undefined); + const toolCallsRef = yield* Ref.make(new Map()); + // Recently shown tool calls. A late update to a finished call is not a new + // boundary in the answer, although its progress state is gone. + const shownToolCallIds = new Set(); + const assistantItemRuntimeId = yield* crypto.randomUUIDv4.pipe( + Effect.mapError( + (cause) => + new EffectAcpErrors.AcpTransportError({ + detail: "Failed to generate an ACP assistant item runtime identifier.", + cause, + }), + ), + ); + const assistantSegmentRef = yield* Ref.make({ nextSegmentIndex: 0 }); + const configOptionsRef = yield* Ref.make(sessionConfigOptionsFromSetup(undefined)); + const initializeStateRef = yield* Ref.make({ _tag: "NotStarted" }); + const startStateRef = yield* Ref.make({ _tag: "NotStarted" }); + const startupMetadataRef = yield* Ref.make>( + [], + ); + const notificationSemaphore = yield* Semaphore.make(1); + const terminationErrorRef = yield* Ref.make>( + Option.none(), + ); + const stoppingRef = yield* Ref.make(false); + const stderrFailure = yield* Deferred.make(); + const stderrTailRef = yield* Ref.make(""); + const stderrDrained = yield* Deferred.make(); + const runtimeClosed = yield* Deferred.make(); + const promptSerializationSemaphore = yield* Semaphore.make(1); + const sessionLoadSemaphore = yield* Semaphore.make(1); + const promptDispatchSemaphore = yield* Semaphore.make(1); + const activePromptRef = yield* Ref.make>(Option.none()); + const assistantUpdatesOpenRef = yield* Ref.make(true); + const sessionLoadGateRef = yield* Ref.make>(Option.none()); + + const ensureConnected = Effect.gen(function* () { + const error = yield* Ref.get(terminationErrorRef); + if (Option.isSome(error)) { + return yield* error.value; + } + if (yield* Ref.get(stoppingRef)) { + return yield* new EffectAcpErrors.AcpTransportError({ + detail: "The ACP session runtime is closed.", + cause: undefined, + }); + } + }); + + const enrichProcessExitWithStderr = ( + error: EffectAcpErrors.AcpError, + ): Effect.Effect => + error._tag !== "AcpProcessExitedError" || (error.stderr?.trim().length ?? 0) > 0 + ? Effect.succeed(error) + : Deferred.await(stderrDrained).pipe( + Effect.timeout("250 millis"), + Effect.ignore, + Effect.andThen(Ref.get(stderrTailRef)), + Effect.map((tail) => { + const stderr = sanitizeAcpStderrExcerpt(tail); + return stderr.length === 0 + ? error + : new EffectAcpErrors.AcpProcessExitedError({ + ...(error.code !== undefined ? { code: error.code } : {}), + ...(error.pid !== undefined ? { pid: error.pid } : {}), + stderr, + ...(error.cause !== undefined ? { cause: error.cause } : {}), + }); + }), + ); + + const recordTermination = Effect.fn("AcpSessionRuntime.recordTermination")(function* ( + error: EffectAcpErrors.AcpError, + ) { + if (yield* Ref.get(stoppingRef)) { + return; + } + const enriched = yield* enrichProcessExitWithStderr(error); + const firstTermination = yield* Ref.modify(terminationErrorRef, (current) => + Option.isSome(current) + ? ([false, current] as const) + : ([true, Option.some(enriched)] as const), + ); + if (!firstTermination) { + return; + } + yield* closeActiveAssistantSegment({ queue: eventQueue, assistantSegmentRef }); + yield* Queue.offer(eventQueue, { _tag: "ConnectionTerminated", error: enriched }); + }); + + const logRequest = (event: AcpSessionRequestLogEvent) => + options.requestLogger ? options.requestLogger(event) : Effect.void; + + const runLoggedRequest = ( + method: string, + payload: unknown, + effect: Effect.Effect, + ): Effect.Effect => + logRequest({ method, payload, status: "started" }).pipe( + Effect.flatMap(() => + (options.onStderr + ? Effect.raceFirst(effect, Deferred.await(stderrFailure)) + : effect + ).pipe( + Effect.catch((error) => + enrichProcessExitWithStderr(error).pipe(Effect.flatMap(Effect.fail)), + ), + Effect.tap((result) => + logRequest({ + method, + payload, + status: "succeeded", + result, + }), + ), + Effect.onError((cause) => + logRequest({ + method, + payload, + status: "failed", + cause, + }), + ), + ), + ), + ); + + const spawnCommand = + options.spawn.shell === false + ? { command: options.spawn.command, args: options.spawn.args, shell: false } + : yield* resolveSpawnCommand(options.spawn.command, options.spawn.args, { + ...(options.spawn.env ? { env: options.spawn.env } : {}), + extendEnv: options.spawn.extendEnv ?? true, + }); + const linuxCgroupLease = + options.ownDescendantProcessGroups === true && options.processGroupPlatform === "linux" + ? yield* Effect.sync(() => { + try { + if (options.linuxCgroupController === null) return undefined; + return (options.linuxCgroupController ?? makeLinuxCgroupController()).create(); + } catch { + return undefined; + } + }) + : undefined; + // This is lifecycle containment for inherited provider work. It is not a + // security boundary against a provider that can modify its delegated cgroup. + if (linuxCgroupLease !== undefined) { + yield* Scope.addFinalizer( + runtimeScope, + Effect.uninterruptible(terminateLinuxCgroupLease(linuxCgroupLease)).pipe( + Effect.ignoreCause({ log: true }), + ), + ); + } + const containedTargetCommand = + linuxCgroupLease === undefined || NodePath.isAbsolute(spawnCommand.command) + ? spawnCommand.command + : yield* Effect.gen(function* () { + const resolved = resolveLinuxCgroupTargetCommand( + spawnCommand.command, + options.spawn.cwd ?? options.cwd, + { ...process.env, ...options.spawn.env }, + ); + if (resolved !== undefined) return resolved; + return yield* new EffectAcpErrors.AcpSpawnError({ + command: options.spawn.command, + cause: new Error("Contained ACP command was not found on PATH"), + }); + }); + const containedSpawnCommand = + linuxCgroupLease === undefined + ? spawnCommand + : { + ...wrapCommandForLinuxCgroup( + linuxCgroupLease, + containedTargetCommand, + spawnCommand.args, + ), + shell: false, + }; + const spawnEnvironment = + linuxCgroupLease === undefined + ? options.spawn.env + : { + ...options.spawn.env, + ELECTRON_RUN_AS_NODE: "1", + T3_ACP_CGROUP_WRAPPER: "1", + }; + const child = yield* spawner + .spawn( + ChildProcess.make(containedSpawnCommand.command, containedSpawnCommand.args, { + ...(options.spawn.cwd ? { cwd: options.spawn.cwd } : {}), + ...(spawnEnvironment ? { env: spawnEnvironment } : {}), + extendEnv: options.spawn.extendEnv ?? true, + ...(options.ownDetachedProcessGroup === undefined + ? {} + : { detached: options.ownDetachedProcessGroup }), + shell: containedSpawnCommand.shell, + }), + ) + .pipe( + Effect.provideService(Scope.Scope, runtimeScope), + Effect.mapError( + (cause) => + new EffectAcpErrors.AcpSpawnError({ + command: options.spawn.command, + cause, + }), + ), + ); + + const posixOwnershipLedger = new Map(); + const posixOwnershipFrontier = new Map(); + const posixOwnershipChildQueues = new Map>(); + const posixOwnershipRoot: AcpPosixOwnershipRoot = { value: undefined }; + const posixController = + options.ownDescendantProcessGroups === true + ? yield* Effect.try({ + try: () => { + if (options.posixProcessTreeController !== undefined) { + return options.posixProcessTreeController; + } + if (options.processGroupPlatform === undefined) { + throw new AcpProcessGroupTerminationError({ + detail: "POSIX ACP descendant ownership requires a supported host platform", + }); + } + return makePosixProcessTreeController(options.processGroupPlatform); + }, + catch: (cause) => + new EffectAcpErrors.AcpSpawnError({ command: options.spawn.command, cause }), + }) + : undefined; + if (posixController !== undefined) { + const observeOwnership = () => + observePosixOwnershipLedger({ + childQueues: posixOwnershipChildQueues, + controller: posixController, + frontier: posixOwnershipFrontier, + ledger: posixOwnershipLedger, + root: posixOwnershipRoot, + rootPid: Number(child.pid), + }); + yield* Effect.try({ + try: observeOwnership, + catch: (cause) => + new EffectAcpErrors.AcpSpawnError({ command: options.spawn.command, cause }), + }); + yield* observePosixOwnershipLedgerContinuously({ + childQueues: posixOwnershipChildQueues, + controller: posixController, + frontier: posixOwnershipFrontier, + ledger: posixOwnershipLedger, + root: posixOwnershipRoot, + rootPid: Number(child.pid), + }).pipe(Effect.forkIn(runtimeScope)); + } + + const signalOwnedProcessGroup = (signal: NodeJS.Signals) => + Effect.try({ + try: () => { + signalProcessGroup(Number(child.pid), signal); + return true; + }, + catch: (cause) => + new AcpProcessGroupTerminationError({ + cause, + detail: `Failed to signal ACP process group ${child.pid} with ${signal}`, + }), + }).pipe( + Effect.catch((error) => { + const cause = error.cause as NodeJS.ErrnoException | undefined; + return cause?.code === "ESRCH" ? Effect.succeed(false) : Effect.fail(error); + }), + ); + const terminateWindowsProcessTree = + options.windowsProcessTreeTerminator ?? + ((pid: number) => terminateWindowsProcessTreeWithTaskkill(spawner, pid)); + const terminatePosixProcessTree = (grace: Duration.Input, platformOverride?: NodeJS.Platform) => + Effect.gen(function* () { + const platform = platformOverride ?? options.processGroupPlatform; + if (platform === undefined || platform === "win32") { + return yield* new AcpProcessGroupTerminationError({ + detail: "POSIX ACP descendant ownership requires a supported host platform", + }); + } + const controller = yield* Effect.try({ + try: () => posixController ?? makePosixProcessTreeController(platform), + catch: (cause) => + isAcpProcessGroupTerminationError(cause) + ? cause + : new AcpProcessGroupTerminationError({ + cause, + detail: `Failed to initialize ACP descendant ownership on ${platform}`, + }), + }); + yield* terminatePosixOwnedProcessTree({ + controller, + grace, + ledger: posixOwnershipLedger, + root: posixOwnershipRoot, + rootPid: Number(child.pid), + }); + }); + const terminateOwnedProcessGroupImpl = Effect.gen(function* () { + if (options.ownDetachedProcessGroup !== true) return; + if (options.processGroupPlatform === undefined) { + return yield* new AcpProcessGroupTerminationError({ + detail: "Detached ACP process-group ownership requires a host platform", + }); + } + if (options.processGroupPlatform === "win32") { + return yield* terminateWindowsProcessTree(Number(child.pid)); + } + if (linuxCgroupLease !== undefined) { + if (posixController !== undefined) { + yield* Effect.sync(() => + signalLinuxCgroupRootTerm({ + controller: posixController, + lease: linuxCgroupLease, + root: posixOwnershipRoot, + }), + ); + } + const grace = options.processGroupTerminationGrace ?? "1 second"; + if (grace !== 0) yield* Effect.sleep(grace); + return yield* terminateLinuxCgroupLease(linuxCgroupLease); + } + if (options.ownDescendantProcessGroups === true) { + return yield* terminatePosixProcessTree(options.processGroupTerminationGrace ?? "1 second"); + } + const groupExisted = yield* signalOwnedProcessGroup("SIGTERM"); + if (!groupExisted) return; + const grace = options.processGroupTerminationGrace ?? "1 second"; + if (grace !== 0) { + yield* Effect.sleep(grace); + } + yield* signalOwnedProcessGroup("SIGKILL"); + }).pipe(withWallClock); + const terminateProcessGroup = yield* Effect.cached( + Effect.uninterruptible(terminateOwnedProcessGroupImpl), + ); + if (options.ownDetachedProcessGroup === true) { + const hostPlatform = yield* HostProcessPlatform; + const forceTerminateOwnedProcessGroup = + hostPlatform === "win32" + ? terminateWindowsProcessTreeWithTaskkill(spawner, Number(child.pid)) + : linuxCgroupLease !== undefined + ? terminateLinuxCgroupLease(linuxCgroupLease) + : options.ownDescendantProcessGroups === true + ? terminatePosixProcessTree(0, hostPlatform) + : signalOwnedProcessGroup("SIGKILL").pipe(Effect.asVoid); + yield* Scope.addFinalizer( + runtimeScope, + Effect.uninterruptible(forceTerminateOwnedProcessGroup).pipe( + Effect.ignoreCause({ log: true }), + ), + ); + } + yield* child.stderr.pipe( + Stream.decodeText(), + Stream.runForEach((chunk) => + Ref.update(stderrTailRef, (current) => appendAcpStderrTail(current, chunk)).pipe( + Effect.andThen( + options.onStderr ? options.onStderr(chunk.slice(-maxStderrChunkLength)) : Effect.void, + ), + Effect.catch((error) => + Effect.gen(function* () { + yield* Deferred.fail(stderrFailure, error); + yield* recordTermination(error); + yield* child.kill({ forceKillAfter: "1 second" }).pipe(Effect.ignore); + }), + ), + ), + ), + Effect.ensuring(Deferred.succeed(stderrDrained, undefined)), + Effect.ignore, + Effect.forkIn(runtimeScope), + ); + + const acpContext = yield* Layer.build( + EffectAcpClient.layerChildProcess(child, { + ...(options.transformStdout ? { transformStdout: options.transformStdout } : {}), + ...(options.transformSessionUpdate + ? { transformSessionUpdate: options.transformSessionUpdate } + : {}), + ...(options.protocolLogging?.logIncoming !== undefined + ? { logIncoming: options.protocolLogging.logIncoming } + : {}), + ...(options.protocolLogging?.logOutgoing !== undefined + ? { logOutgoing: options.protocolLogging.logOutgoing } + : {}), + ...(options.protocolLogging?.logger ? { logger: options.protocolLogging.logger } : {}), + ...(options.onIncomingRequest ? { onIncomingRequest: options.onIncomingRequest } : {}), + onTermination: (error) => + recordTermination(error).pipe( + Effect.andThen(options.onTermination?.(error) ?? Effect.void), + Effect.ensuring( + Scope.close(runtimeScope, Exit.fail(error)).pipe(Effect.forkDetach, Effect.asVoid), + ), + ), + ...(options.onOutgoingResponseFailure + ? { onOutgoingResponseFailure: options.onOutgoingResponseFailure } + : {}), + ...(options.onOutgoingResponse ? { onOutgoingResponse: options.onOutgoingResponse } : {}), + }), + ).pipe(Effect.provideService(Scope.Scope, runtimeScope)); + + const acp = yield* Effect.service(EffectAcpClient.AcpClient).pipe(Effect.provide(acpContext)); + + const processSessionUpdate = (notification: EffectAcpSchema.SessionNotification) => + handleSessionUpdate({ + queue: eventQueue, + modeStateRef, + configOptionsRef, + toolCallsRef, + shownToolCallIds, + assistantSegmentRef, + assistantItemRuntimeId, + params: notification, + }); + + yield* acp.handleSessionUpdate((notification) => + notificationSemaphore.withPermit( + Effect.gen(function* () { + if (Option.isSome(yield* Ref.get(terminationErrorRef))) { + return; + } + const gate = yield* Ref.get(sessionLoadGateRef); + // A different session can still have an in-flight prompt while this + // load replays history, so quarantine only the loading session. + const loadingThisSession = + Option.isSome(gate) && + gate.value.active && + notification.sessionId === gate.value.sessionId; + if ( + loadingThisSession && + sessionUpdateCountsAsLoadReplayActivity(notification, gate.value.sessionId) + ) { + const lastActivityAtMillis = yield* Clock.currentTimeMillis; + yield* Ref.set( + sessionLoadGateRef, + Option.some({ + ...gate.value, + lastActivityAtMillis, + }), + ); + } + if (sessionUpdateIsReplay(notification)) { + return; + } + const startState = yield* Ref.get(startStateRef); + if (startState._tag === "Starting") { + if (isStartupMetadataUpdate(notification)) { + yield* Ref.update(startupMetadataRef, (current) => + [ + ...current.filter( + (previous) => + previous.sessionId !== notification.sessionId || + previous.update.sessionUpdate !== notification.update.sessionUpdate, + ), + notification, + ].slice(-maxStartupMetadataUpdates), + ); + } + return; + } + if (loadingThisSession) { + if (notification.update.sessionUpdate === "config_option_update") { + const candidateConfigOptions = notification.update.configOptions; + yield* Ref.update(sessionLoadGateRef, (current) => + Option.isSome(current) + ? Option.some({ ...current.value, candidateConfigOptions }) + : current, + ); + } + return; + } + // One runtime projects one root ACP session. Child-session updates need + // explicit lineage routing and must never be flattened into this stream. + if ( + startState._tag !== "Started" || + notification.sessionId !== startState.result.sessionId + ) { + return; + } + if ( + !(yield* Ref.get(assistantUpdatesOpenRef)) && + (notification.update.sessionUpdate === "agent_message_chunk" || + notification.update.sessionUpdate === "agent_thought_chunk") + ) { + return; + } + yield* processSessionUpdate(notification); + }), + ), + ); + yield* Scope.addFinalizer( + runtimeScope, + Ref.set(stoppingRef, true).pipe(Effect.andThen(Deferred.succeed(runtimeClosed, undefined))), + ); + const initializeClientCapabilities = { + fs: { + readTextFile: false, + writeTextFile: false, + ...options.clientCapabilities?.fs, + }, + terminal: options.clientCapabilities?.terminal ?? false, + ...(options.clientCapabilities?.auth ? { auth: options.clientCapabilities.auth } : {}), + ...(options.clientCapabilities?.elicitation + ? { elicitation: options.clientCapabilities.elicitation } + : {}), + ...(options.clientCapabilities?._meta ? { _meta: options.clientCapabilities._meta } : {}), + } satisfies NonNullable; + + const getStartedState = Effect.gen(function* () { + yield* ensureConnected; + const state = yield* Ref.get(startStateRef); + if (state._tag === "Started") { + return state.result; + } + return yield* new EffectAcpErrors.AcpTransportError({ + detail: "ACP session runtime has not been started", + cause: "ACP session runtime has not been started", + }); + }); + + const validateConfigOptionValue = ( + configId: string, + value: string | boolean, + ): Effect.Effect => + Effect.gen(function* () { + const configOption = findSessionConfigOption(yield* Ref.get(configOptionsRef), configId); + if (!configOption) { + return; + } + if (configOption.type === "boolean") { + if (typeof value === "boolean") { + return; + } + return yield* new EffectAcpErrors.AcpRequestError({ + code: -32602, + errorMessage: `Invalid value ${formatConfigOptionValue(value)} for session config option "${configOption.id}": expected boolean`, + data: { + configId: configOption.id, + expectedType: "boolean", + receivedValue: value, + }, + }); + } + if (typeof value !== "string") { + return yield* new EffectAcpErrors.AcpRequestError({ + code: -32602, + errorMessage: `Invalid value ${formatConfigOptionValue(value)} for session config option "${configOption.id}": expected string`, + data: { + configId: configOption.id, + expectedType: "string", + receivedValue: value, + }, + }); + } + const allowedValues = collectSessionConfigOptionValues(configOption); + if (allowedValues.includes(value)) { + return; + } + return yield* new EffectAcpErrors.AcpRequestError({ + code: -32602, + errorMessage: `Invalid value ${formatConfigOptionValue(value)} for session config option "${configOption.id}": expected one of ${allowedValues.join(", ")}`, + data: { + configId: configOption.id, + allowedValues, + receivedValue: value, + }, + }); + }); + + const updateConfigOptions = Effect.fn("AcpSessionRuntime.updateConfigOptions")(function* ( + response: + | EffectAcpSchema.SetSessionConfigOptionResponse + | EffectAcpSchema.ForkSessionResponse + | EffectAcpSchema.LoadSessionResponse + | EffectAcpSchema.NewSessionResponse + | EffectAcpSchema.ResumeSessionResponse, + ) { + const configOptions = sessionConfigOptionsFromSetup(response); + yield* Ref.set(configOptionsRef, configOptions); + yield* Queue.offer(eventQueue, { + _tag: "ConfigOptionsUpdated", + configOptions, + rawPayload: response, + }); + }); + + const updateCurrentModeId = (modeId: string): Effect.Effect => + Ref.update(modeStateRef, (current) => + current ? { ...current, currentModeId: modeId } : current, + ); + + const adoptSession = ( + sessionId: string, + sessionSetupResult: + | EffectAcpSchema.ForkSessionResponse + | EffectAcpSchema.LoadSessionResponse + | EffectAcpSchema.NewSessionResponse + | EffectAcpSchema.ResumeSessionResponse, + ): Effect.Effect => + Effect.gen(function* () { + const current = yield* getStartedState; + const meta = sessionSetupResult._meta; + const syntheticReplayIdle = + meta !== null && + typeof meta === "object" && + !Array.isArray(meta) && + (meta as { readonly t3SessionLoadReady?: unknown }).t3SessionLoadReady === "replay_idle"; + const extractedModelConfigId = extractModelConfigId(sessionSetupResult); + const nextModelConfigId = + extractedModelConfigId ?? (syntheticReplayIdle ? current.modelConfigId : undefined); + const nextModeState = parseSessionModeState(sessionSetupResult); + if (nextModeState !== undefined) { + yield* Ref.set(modeStateRef, nextModeState); + } else if (!syntheticReplayIdle) { + yield* Ref.set(modeStateRef, undefined); + } + // Synthetic replay-idle load responses only carry initialize model/mode + // meta; preserve live configOptions so setConfigOption still validates. + if ( + sessionSetupResult.configOptions !== undefined && + sessionSetupResult.configOptions !== null + ) { + yield* Ref.set(configOptionsRef, sessionConfigOptionsFromSetup(sessionSetupResult)); + } else if (!syntheticReplayIdle) { + yield* Ref.set(configOptionsRef, sessionConfigOptionsFromSetup(sessionSetupResult)); + } + const nextState = { + sessionId, + initializeResult: current.initializeResult, + sessionSetupResult, + modelConfigId: nextModelConfigId, + } satisfies AcpStartedState; + yield* Ref.set(toolCallsRef, new Map()); + yield* Ref.set(assistantSegmentRef, { nextSegmentIndex: 0 }); + yield* Ref.set(startStateRef, { _tag: "Started", result: nextState }); + return nextState; + }); + + const runLoadSessionWithReplayIdle = ( + loadPayload: EffectAcpSchema.LoadSessionRequest, + initializeResult: EffectAcpSchema.InitializeResponse, + ): Effect.Effect => + sessionLoadSemaphore.withPermits(1)( + Effect.gen(function* () { + const sessionLoadTimeout = Duration.fromInputUnsafe( + options.sessionLoadTimeout ?? defaultSessionLoadTimeout, + ); + const sessionLoadReplayIdleGap = Duration.fromInputUnsafe( + options.sessionLoadReplayIdleGap ?? defaultSessionLoadReplayIdleGap, + ); + + yield* Ref.set( + sessionLoadGateRef, + Option.some({ + active: true, + sessionId: loadPayload.sessionId, + lastActivityAtMillis: undefined, + idleGap: sessionLoadReplayIdleGap, + initializeResult, + }), + ); + + return yield* Effect.gen(function* () { + yield* logRequest({ + method: "session/load", + payload: loadPayload, + status: "started", + }); + + const idleFiber = yield* waitForSessionLoadReplayIdle({ + gateRef: sessionLoadGateRef, + }).pipe(Effect.forkIn(runtimeScope)); + const loaded = yield* Effect.raceFirst( + acp.agent.loadSession(loadPayload), + Fiber.join(idleFiber), + ).pipe( + Effect.ensuring(Fiber.interrupt(idleFiber).pipe(Effect.ignore)), + Effect.timeoutOption(sessionLoadTimeout), + Effect.flatMap((result) => + Option.match(result, { + onNone: () => + Effect.fail( + new EffectAcpErrors.AcpTransportError({ + operation: "call-rpc", + method: "session/load", + detail: + "session/load timed out waiting for RPC response or replay idle gap", + cause: undefined, + }), + ), + onSome: Effect.succeed, + }), + ), + Effect.tap((result) => + logRequest({ + method: "session/load", + payload: loadPayload, + status: "succeeded", + result, + }), + ), + Effect.onError((cause) => + logRequest({ + method: "session/load", + payload: loadPayload, + status: "failed", + cause, + }), + ), + ); + + const gate = yield* Ref.get(sessionLoadGateRef); + const candidateConfigOptions = Option.isSome(gate) + ? gate.value.candidateConfigOptions + : undefined; + return loaded.configOptions == null && candidateConfigOptions !== undefined + ? { ...loaded, configOptions: candidateConfigOptions } + : loaded; + }).pipe(Effect.ensuring(Ref.set(sessionLoadGateRef, Option.none()))); + }), + ); + + const setConfigOption = ( + configId: string, + value: string | boolean, + ): Effect.Effect => + validateConfigOptionValue(configId, value).pipe( + Effect.flatMap(() => getStartedState), + Effect.flatMap((started) => + Ref.get(configOptionsRef).pipe( + Effect.flatMap((configOptions) => { + const existing = findSessionConfigOption(configOptions, configId); + if (existing && configOptionCurrentValueMatches(existing, value)) { + return Effect.succeed({ + configOptions, + } satisfies EffectAcpSchema.SetSessionConfigOptionResponse); + } + const requestPayload = + typeof value === "boolean" + ? ({ + sessionId: started.sessionId, + configId, + type: "boolean", + value, + } satisfies EffectAcpSchema.SetSessionConfigOptionRequest) + : ({ + sessionId: started.sessionId, + configId, + value: String(value), + } satisfies EffectAcpSchema.SetSessionConfigOptionRequest); + return runLoggedRequest( + "session/set_config_option", + requestPayload, + acp.agent.setSessionConfigOption(requestPayload), + ).pipe(Effect.tap((response) => updateConfigOptions(response))); + }), + ), + ), + ); + + const initializeOnce = Effect.gen(function* () { + const initializePayload = { + protocolVersion: 2, + clientCapabilities: initializeClientCapabilities, + clientInfo: options.clientInfo, + ...(options.initializeMeta === undefined ? {} : { _meta: options.initializeMeta }), + } satisfies EffectAcpSchema.InitializeRequest; + + const initializeResult = yield* runLoggedRequest( + "initialize", + initializePayload, + acp.agent.initialize(initializePayload), + ); + yield* options.onInitialized?.(initializeResult) ?? Effect.void; + return initializeResult; + }); + + const initialize = Effect.gen(function* () { + yield* ensureConnected; + const deferred = yield* Deferred.make< + EffectAcpSchema.InitializeResponse, + EffectAcpErrors.AcpError + >(); + const effect = yield* Ref.modify(initializeStateRef, (state) => { + switch (state._tag) { + case "Started": + return [Effect.succeed(state.result), state] as const; + case "Starting": + return [Deferred.await(state.deferred), state] as const; + case "NotStarted": + return [ + initializeOnce.pipe( + Effect.tap((result) => + Ref.set(initializeStateRef, { _tag: "Started", result }).pipe( + Effect.andThen(Deferred.succeed(deferred, result)), + ), + ), + Effect.onError((cause) => + Deferred.failCause(deferred, cause).pipe( + Effect.andThen(Ref.set(initializeStateRef, { _tag: "NotStarted" })), + ), + ), + ), + { _tag: "Starting", deferred } satisfies AcpInitializeState, + ] as const; + } + }); + return yield* effect; + }); + + const sessionMcpServers = ( + initializeResult: EffectAcpSchema.InitializeResponse, + activationOptions?: AcpSessionActivationOptions, + ): ReadonlyArray => { + const acpServers = activationOptions?.acpMcpServers ?? options.acpMcpServers ?? []; + if ( + initializeResult.agentCapabilities?.mcpCapabilities?.acp === true && + acpServers.length > 0 + ) { + return acpServers; + } + return activationOptions?.mcpServers ?? options.mcpServers ?? []; + }; + + const startOnce = Effect.gen(function* () { + const initializeResult = yield* initialize; + + const authenticateAfterRequired = ( + authRequiredError: EffectAcpErrors.AcpError, + ): Effect.Effect => + Effect.gen(function* () { + const configuredAuthMethodId = options.authMethodId?.trim(); + const authMethod = selectAcpAgentAuthMethod( + initializeResult.authMethods, + configuredAuthMethodId, + ); + if ( + configuredAuthMethodId && + initializeResult.authMethods !== undefined && + authMethod === undefined + ) { + return yield* new EffectAcpErrors.AcpTransportError({ + detail: `ACP agent did not advertise configured authentication method "${configuredAuthMethodId}"`, + cause: { configuredAuthMethodId, authMethods: initializeResult.authMethods }, + }); + } + if ( + authMethod !== undefined && + authMethod.type !== undefined && + authMethod.type !== "agent" + ) { + return yield* new EffectAcpErrors.AcpTransportError({ + detail: `ACP authentication method "${authMethod.id}" requires ${authMethod.type} authentication, which cannot run inside a headless provider session`, + cause: authMethod, + }); + } + + const authMethodId = authMethod?.id ?? configuredAuthMethodId; + if (!authMethodId) { + return yield* authRequiredError; + } + + const authenticatePayload = { + methodId: authMethodId, + } satisfies EffectAcpSchema.AuthenticateRequest; + yield* runLoggedRequest( + "authenticate", + authenticatePayload, + acp.agent.authenticate(authenticatePayload), + ); + }); + + const setupSession = Effect.gen(function* () { + let sessionId: string; + let sessionSetupResult: + | EffectAcpSchema.LoadSessionResponse + | EffectAcpSchema.NewSessionResponse + | EffectAcpSchema.ResumeSessionResponse; + const additionalDirectories = options.additionalDirectories?.length + ? { additionalDirectories: options.additionalDirectories } + : {}; + if (options.resumeSessionId) { + sessionId = options.resumeSessionId; + if ( + initializeResult.agentCapabilities?.loadSession === true && + options.resumeMethod !== "resume" + ) { + const loadPayload = { + sessionId, + cwd: options.cwd, + ...additionalDirectories, + mcpServers: sessionMcpServers(initializeResult), + } satisfies EffectAcpSchema.LoadSessionRequest; + sessionSetupResult = yield* runLoadSessionWithReplayIdle(loadPayload, initializeResult); + } else if (initializeResult.agentCapabilities?.sessionCapabilities?.resume != null) { + const resumePayload = { + sessionId, + cwd: options.cwd, + ...additionalDirectories, + mcpServers: sessionMcpServers(initializeResult), + } satisfies EffectAcpSchema.ResumeSessionRequest; + sessionSetupResult = yield* runLoggedRequest( + "session/resume", + resumePayload, + acp.agent.resumeSession(resumePayload), + ); + } else { + return yield* new EffectAcpErrors.AcpRequestError({ + code: -32601, + errorMessage: "ACP agent does not advertise session/load or session/resume support", + }); + } + } else { + const createPayload = { + cwd: options.cwd, + mcpServers: sessionMcpServers(initializeResult), + ...additionalDirectories, + } satisfies EffectAcpSchema.NewSessionRequest; + const created = yield* runLoggedRequest( + "session/new", + createPayload, + acp.agent.createSession(createPayload), + ); + sessionId = created.sessionId; + sessionSetupResult = created; + } + + return { sessionId, sessionSetupResult }; + }); + + if (options.authenticateEagerly === true) { + // The configured method is authoritative here: Antigravity accepts + // methods (API key, Vertex) it does not list in `initialize`. + const authenticatePayload = { + methodId: options.authMethodId ?? "", + } satisfies EffectAcpSchema.AuthenticateRequest; + yield* runLoggedRequest( + "authenticate", + authenticatePayload, + acp.agent.authenticate(authenticatePayload), + ); + } + const { sessionId, sessionSetupResult } = yield* setupSession.pipe( + Effect.catch((error) => + !isAcpAuthenticationRequired(error) || options.authenticateOnAuthRequired === false + ? Effect.fail(error) + : authenticateAfterRequired(error).pipe(Effect.andThen(setupSession)), + ), + ); + + yield* Ref.set(modeStateRef, parseSessionModeState(sessionSetupResult)); + yield* Ref.set(configOptionsRef, sessionConfigOptionsFromSetup(sessionSetupResult)); + + const nextState = { + sessionId, + initializeResult, + sessionSetupResult, + modelConfigId: extractModelConfigId(sessionSetupResult), + } satisfies AcpStartedState; + return nextState; + }); + + const start = Effect.gen(function* () { + yield* ensureConnected; + const deferred = yield* Deferred.make< + AcpSessionRuntimeStartResult, + EffectAcpErrors.AcpError + >(); + const effect = yield* Ref.modify(startStateRef, (state) => { + switch (state._tag) { + case "Started": + return [Effect.succeed(state.result), state] as const; + case "Starting": + return [Deferred.await(state.deferred), state] as const; + case "NotStarted": + return [ + startOnce.pipe( + Effect.tap((result) => + notificationSemaphore.withPermit( + Effect.gen(function* () { + const error = yield* Ref.get(terminationErrorRef); + if (Option.isSome(error)) { + return yield* error.value; + } + yield* Ref.set(startStateRef, { _tag: "Started", result }); + const metadata = yield* Ref.getAndSet(startupMetadataRef, []); + for (const notification of metadata) { + if (notification.sessionId === result.sessionId) { + yield* processSessionUpdate(notification); + } + } + yield* Deferred.succeed(deferred, result); + }), + ), + ), + Effect.onError((cause) => + Deferred.failCause(deferred, cause).pipe( + Effect.andThen(Ref.set(startStateRef, { _tag: "NotStarted" })), + Effect.andThen(Ref.set(startupMetadataRef, [])), + ), + ), + ), + { _tag: "Starting", deferred } satisfies AcpStartState, + ] as const; + } + }); + return yield* effect; + }); + + const drainEvents = Effect.gen(function* () { + if (yield* Ref.get(stoppingRef)) { + return; + } + const acknowledge = yield* Deferred.make(); + yield* notificationSemaphore.withPermit( + Effect.gen(function* () { + // Keep a provider's final flushed chunks together until the adapter settles the turn. + if (Option.isNone(yield* Ref.get(activePromptRef))) { + yield* Ref.set(assistantUpdatesOpenRef, false); + yield* closeActiveAssistantSegment({ queue: eventQueue, assistantSegmentRef }); + } + yield* Queue.offer(eventQueue, { _tag: "EventStreamBarrier", acknowledge }); + }), + ); + yield* Effect.raceFirst(Deferred.await(acknowledge), Deferred.await(runtimeClosed)); + }); + + const retireRuntime = Effect.fn("AcpSessionRuntime.retireRuntime")(function* ( + error: EffectAcpErrors.AcpError, + ) { + yield* recordTermination(error); + yield* child.kill({ forceKillAfter: "1 second" }).pipe(Effect.ignore); + }); + + const cancel = Effect.gen(function* () { + const started = yield* getStartedState; + const activePrompt = yield* Ref.get(activePromptRef); + const cancelPayload = { + sessionId: started.sessionId, + ...(options.cancelMeta === undefined ? {} : { _meta: options.cancelMeta }), + } satisfies EffectAcpSchema.CancelNotification; + if (options.cancelBehavior !== "wait-for-prompt") { + if (options.interruptPromptOnCancel === false) { + return yield* acp.agent.cancel(cancelPayload); + } + if (Option.isSome(activePrompt)) { + yield* Fiber.interrupt(activePrompt.value.fiber).pipe(Effect.ignore); + } + // Write cancel before a replacement prompt can reach the agent. + yield* acp.agent.cancel(cancelPayload).pipe(Effect.ignore); + return; + } + + yield* acp.agent.cancel(cancelPayload); + if (Option.isNone(activePrompt)) { + return; + } + const completed = yield* Effect.gen(function* () { + const result = yield* Fiber.await(activePrompt.value.fiber); + yield* Deferred.await(activePrompt.value.completed); + if (Option.isNone(yield* Ref.get(terminationErrorRef))) { + yield* drainEvents; + } + return result; + }).pipe(Effect.timeoutOption(options.cancelTimeout ?? defaultCancelTimeout)); + if (Option.isNone(completed)) { + const error = new EffectAcpErrors.AcpTransportError({ + operation: "call-rpc", + method: "session/cancel", + detail: "The ACP agent did not finish cancellation. Its process was stopped.", + cause: undefined, + }); + yield* retireRuntime(error); + return yield* error; + } + if (Exit.isFailure(completed.value)) { + return yield* Effect.failCause(completed.value.cause); + } + }); + + return { + processContainment: + linuxCgroupLease !== undefined + ? "cgroup-v2" + : options.ownDescendantProcessGroups === true + ? "process-ledger-reduced-guarantee" + : options.ownDetachedProcessGroup === true + ? "process-group" + : "none", + handleRequestPermission: acp.handleRequestPermission, + handleElicitation: acp.handleElicitation, + handleMcpConnect: acp.handleMcpConnect, + handleMcpMessage: acp.handleMcpMessage, + handleMcpDisconnect: acp.handleMcpDisconnect, + handleMcpNotification: acp.handleMcpNotification, + handleReadTextFile: acp.handleReadTextFile, + handleWriteTextFile: acp.handleWriteTextFile, + handleCreateTerminal: acp.handleCreateTerminal, + handleTerminalOutput: acp.handleTerminalOutput, + handleTerminalWaitForExit: acp.handleTerminalWaitForExit, + handleTerminalKill: acp.handleTerminalKill, + handleTerminalRelease: acp.handleTerminalRelease, + handleSessionUpdate: acp.handleSessionUpdate, + handleElicitationComplete: acp.handleElicitationComplete, + handleUnknownExtRequest: acp.handleUnknownExtRequest, + handleUnknownExtNotification: acp.handleUnknownExtNotification, + handleExtRequest: acp.handleExtRequest, + handleExtNotification: acp.handleExtNotification, + initialize: () => initialize, + authenticate: (methodId) => + initialize.pipe( + Effect.andThen( + runLoggedRequest("authenticate", { methodId }, acp.agent.authenticate({ methodId })), + ), + Effect.asVoid, + ), + start: () => start, + getEvents: () => Stream.fromQueue(eventQueue), + drainEvents, + getModeState: Ref.get(modeStateRef), + getConfigOptions: Ref.get(configOptionsRef), + loadSession: (sessionId, activationOptions) => + start.pipe( + Effect.flatMap((started) => { + const requestPayload = { + sessionId, + cwd: options.cwd, + mcpServers: sessionMcpServers(started.initializeResult, activationOptions), + } satisfies EffectAcpSchema.LoadSessionRequest; + return runLoadSessionWithReplayIdle(requestPayload, started.initializeResult); + }), + Effect.flatMap((response) => adoptSession(sessionId, response)), + ), + resumeSession: (sessionId, activationOptions) => + start.pipe( + Effect.flatMap((started) => { + const requestPayload = { + sessionId, + cwd: options.cwd, + mcpServers: sessionMcpServers(started.initializeResult, activationOptions), + } satisfies EffectAcpSchema.ResumeSessionRequest; + return runLoggedRequest( + "session/resume", + requestPayload, + acp.agent.resumeSession(requestPayload), + ); + }), + Effect.flatMap((response) => adoptSession(sessionId, response)), + ), + forkSession: (sessionId, activationOptions) => + start.pipe( + Effect.flatMap((started) => { + const requestPayload = { + sessionId, + cwd: options.cwd, + mcpServers: sessionMcpServers(started.initializeResult, activationOptions), + } satisfies EffectAcpSchema.ForkSessionRequest; + return runLoggedRequest( + "session/fork", + requestPayload, + acp.agent.forkSession(requestPayload), + ); + }), + Effect.flatMap((response) => adoptSession(response.sessionId, response)), + ), + listSessions: (cursor) => { + const requestPayload = { + cwd: options.cwd, + ...(cursor === undefined ? {} : { cursor }), + } satisfies EffectAcpSchema.ListSessionsRequest; + return initialize.pipe( + Effect.filterOrFail( + (initialized) => initialized.agentCapabilities?.sessionCapabilities?.list != null, + () => + new EffectAcpErrors.AcpRequestError({ + code: -32601, + errorMessage: "ACP agent does not advertise session/list support", + }), + ), + Effect.andThen( + runLoggedRequest( + "session/list", + requestPayload, + acp.agent.listSessions(requestPayload), + ), + ), + ); + }, + closeSession: (sessionId) => + start.pipe( + Effect.flatMap((started) => { + const requestPayload = { + sessionId: sessionId ?? started.sessionId, + } satisfies EffectAcpSchema.CloseSessionRequest; + return runLoggedRequest( + "session/close", + requestPayload, + acp.agent.closeSession(requestPayload), + ); + }), + ), + deleteSession: (sessionId) => + initialize.pipe( + Effect.filterOrFail( + (initialized) => initialized.agentCapabilities?.sessionCapabilities?.delete != null, + () => + new EffectAcpErrors.AcpRequestError({ + code: -32601, + errorMessage: "ACP agent does not advertise session/delete support", + }), + ), + Effect.andThen( + runLoggedRequest( + "session/delete", + { sessionId }, + acp.agent.deleteSession({ sessionId }), + ), + ), + ), + listProviders: initialize.pipe( + Effect.filterOrFail( + (initialized) => initialized.agentCapabilities?.providers != null, + () => + new EffectAcpErrors.AcpRequestError({ + code: -32601, + errorMessage: "ACP agent does not advertise provider configuration support", + }), + ), + Effect.andThen(runLoggedRequest("providers/list", {}, acp.agent.listProviders({}))), + ), + setProvider: (provider) => + initialize.pipe( + Effect.filterOrFail( + (initialized) => initialized.agentCapabilities?.providers != null, + () => + new EffectAcpErrors.AcpRequestError({ + code: -32601, + errorMessage: "ACP agent does not advertise provider configuration support", + }), + ), + Effect.andThen( + runLoggedRequest( + "providers/set", + { + ...provider, + ...(provider.headers === undefined + ? {} + : { + headers: Object.fromEntries( + Object.keys(provider.headers).map((name) => [name, "[redacted]"]), + ), + }), + }, + acp.agent.setProvider(provider), + ), + ), + ), + disableProvider: (providerId) => { + const request = { providerId } satisfies EffectAcpSchema.DisableProviderRequest; + return initialize.pipe( + Effect.filterOrFail( + (initialized) => initialized.agentCapabilities?.providers != null, + () => + new EffectAcpErrors.AcpRequestError({ + code: -32601, + errorMessage: "ACP agent does not advertise provider configuration support", + }), + ), + Effect.andThen( + runLoggedRequest("providers/disable", request, acp.agent.disableProvider(request)), + ), + ); + }, + logout: initialize.pipe( + Effect.filterOrFail( + (initialized) => initialized.agentCapabilities?.auth?.logout != null, + () => + new EffectAcpErrors.AcpRequestError({ + code: -32601, + errorMessage: "ACP agent does not advertise logout support", + }), + ), + Effect.andThen(runLoggedRequest("logout", {}, acp.agent.logout({}))), + ), + prompt: (payload, promptOptions?) => + promptSerializationSemaphore.withPermit( + Effect.acquireUseRelease( + promptDispatchSemaphore.withPermit( + Effect.gen(function* () { + const started = yield* getStartedState; + yield* closeActiveAssistantSegment({ queue: eventQueue, assistantSegmentRef }); + yield* Ref.set(assistantUpdatesOpenRef, true); + const requestPayload = { + sessionId: started.sessionId, + ...payload, + } satisfies EffectAcpSchema.PromptRequest; + const completed = yield* Deferred.make(); + const fiber = yield* runLoggedRequest( + "session/prompt", + requestPayload, + acp.agent.prompt(requestPayload), + ).pipe(Effect.forkIn(runtimeScope)); + const active = { fiber, completed } satisfies AcpActivePrompt; + yield* Ref.set(activePromptRef, Option.some(active)); + if (promptOptions?.dispatched) { + yield* Deferred.succeed(promptOptions.dispatched, undefined); + } + return active; + }).pipe(notificationSemaphore.withPermit), + ), + (activePrompt) => + Fiber.join(activePrompt.fiber).pipe( + Effect.catchCause((cause) => + options.cancelBehavior !== "wait-for-prompt" && Cause.hasInterruptsOnly(cause) + ? Effect.succeed({ + stopReason: "cancelled", + } satisfies EffectAcpSchema.PromptResponse) + : Effect.failCause(cause), + ), + Effect.tap(() => + closeActiveAssistantSegment({ queue: eventQueue, assistantSegmentRef }), + ), + ), + (activePrompt, result) => + Effect.gen(function* () { + if ( + options.cancelBehavior === "wait-for-prompt" && + Exit.isFailure(result) && + Cause.hasInterrupts(result.cause) + ) { + yield* retireRuntime( + new EffectAcpErrors.AcpTransportError({ + method: "session/prompt", + detail: "The ACP prompt stopped before the agent confirmed completion.", + cause: undefined, + }), + ); + } + yield* Fiber.interrupt(activePrompt.fiber).pipe(Effect.ignore); + yield* Ref.set(activePromptRef, Option.none()); + yield* Deferred.succeed(activePrompt.completed, undefined); + }), + ), + ), + cancel: + options.cancelBehavior === "wait-for-prompt" + ? promptDispatchSemaphore.withPermit(cancel) + : cancel, + ...(options.ownDetachedProcessGroup === true ? { terminateProcessGroup } : {}), + // A session's mode is its `category: "mode"` config option. ACP v1 agents + // that only advertise `modes` (gemini-cli) take `session/set_mode` + // instead, which ACP v2 removed. + setMode: (modeId) => + Effect.gen(function* () { + const modeState = yield* Ref.get(modeStateRef); + if (modeState?.currentModeId === modeId) { + return {} satisfies EffectAcpSchema.SetSessionModeResponse; + } + const modeConfigOption = (yield* Ref.get(configOptionsRef))?.find( + (option) => option.category === "mode" && option.type === "select", + ); + if (modeConfigOption === undefined && modeState !== undefined) { + const started = yield* getStartedState; + const payload = { sessionId: started.sessionId, modeId }; + yield* runLoggedRequest("session/set_mode", payload, acp.agent.setSessionMode(payload)); + yield* updateCurrentModeId(modeId); + return {} satisfies EffectAcpSchema.SetSessionModeResponse; + } + const response = yield* setConfigOption(modeConfigOption?.id ?? "mode", modeId); + // The agent answers with its config options, so the mode it reports + // is the mode it runs in, even when it kept another one. + const reported = parseSessionModeState({ configOptions: response.configOptions }); + if (reported === undefined) { + yield* updateCurrentModeId(modeId); + } else { + yield* updateCurrentModeId(reported.currentModeId); + } + return {} satisfies EffectAcpSchema.SetSessionModeResponse; + }), + setSessionModel: (modelId, meta) => + getStartedState.pipe( + Effect.flatMap((started) => { + const payload = { + sessionId: started.sessionId, + modelId, + ...(meta === undefined ? {} : { _meta: meta }), + }; + return runLoggedRequest( + "session/set_model", + payload, + acp.agent.setSessionModel(payload), + ); + }), + ), + setConfigOption, + setModel: (model) => + getStartedState.pipe( + Effect.flatMap((started) => setConfigOption(started.modelConfigId ?? "model", model)), + Effect.asVoid, + ), + + request: (method, payload) => + ensureConnected.pipe( + Effect.andThen(runLoggedRequest(method, payload, acp.raw.request(method, payload))), + ), + notify: (method, payload) => + ensureConnected.pipe(Effect.andThen(acp.raw.notify(method, payload))), + } satisfies AcpSessionRuntime["Service"]; + }); + +export const layer = ( + options: AcpSessionRuntimeOptions, +): Layer.Layer< + AcpSessionRuntime, + EffectAcpErrors.AcpError, + ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto +> => Layer.effect(AcpSessionRuntime, make(options)); + +function sessionConfigOptionsFromSetup( + response: + | { + readonly configOptions?: ReadonlyArray | null; + } + | undefined, +): ReadonlyArray { + return response?.configOptions ?? []; +} + +function configOptionCurrentValueMatches( + configOption: EffectAcpSchema.SessionConfigOption, + value: string | boolean, +): boolean { + const currentValue = configOption.currentValue; + if (configOption.type === "boolean") { + return currentValue === value; + } + if (typeof currentValue !== "string") { + return false; + } + return currentValue.trim() === String(value).trim(); +} + +function isStartupMetadataUpdate(notification: EffectAcpSchema.SessionNotification): boolean { + switch (notification.update.sessionUpdate) { + case "current_mode_update": + case "config_option_update": + case "available_commands_update": + return true; + default: + return false; + } +} + +const handleSessionUpdate = ({ + queue, + modeStateRef, + configOptionsRef, + toolCallsRef, + shownToolCallIds, + assistantSegmentRef, + assistantItemRuntimeId, + params, +}: { + readonly queue: Queue.Queue; + readonly modeStateRef: Ref.Ref; + readonly configOptionsRef: Ref.Ref>; + readonly toolCallsRef: Ref.Ref>; + readonly shownToolCallIds: Set; + readonly assistantSegmentRef: Ref.Ref; + readonly assistantItemRuntimeId: string; + readonly params: EffectAcpSchema.SessionNotification; +}): Effect.Effect => + Effect.gen(function* () { + if (params.update.sessionUpdate === "config_option_update") { + const configOptions = params.update.configOptions; + yield* Ref.set(configOptionsRef, configOptions); + yield* Ref.update( + modeStateRef, + (current) => parseSessionModeState({ configOptions }) ?? current, + ); + } + const parsed = parseSessionUpdateEvent(params); + if (parsed.modeId) { + yield* Ref.update(modeStateRef, (current) => + current === undefined ? current : updateModeState(current, parsed.modeId!), + ); + } + for (const event of parsed.events) { + if (event._tag === "ToolCallUpdated") { + const { merged, decision, active } = yield* Ref.modify(toolCallsRef, (current) => { + const tracked = current.get(event.toolCall.toolCallId); + const previous = tracked?.state; + const nextToolCall = mergeToolCallState(previous, event.toolCall); + const decision = decideToolCallUpdateEmission({ + previous, + next: nextToolCall, + lastEmittedDetailLength: tracked?.lastEmittedDetailLength, + skippedSinceEmit: tracked?.skippedSinceEmit ?? 0, + }); + const next = new Map(current); + if (nextToolCall.status === "completed" || nextToolCall.status === "failed") { + next.delete(nextToolCall.toolCallId); + } else { + next.set(nextToolCall.toolCallId, { + state: nextToolCall, + lastEmittedDetailLength: decision.emit + ? toolCallProgressLength(nextToolCall) + : tracked?.lastEmittedDetailLength, + skippedSinceEmit: decision.skippedSinceEmit, + }); + } + return [{ merged: nextToolCall, decision, active: tracked !== undefined }, next] as const; + }); + if (!decision.emit) { + continue; + } + // A new tool call is a boundary in the prose. Progress on a call that + // is already shown, such as a background command finishing, is not. + if (!shownToolCallIds.has(merged.toolCallId)) { + shownToolCallIds.add(merged.toolCallId); + // Only recent calls get late updates; keep a long session bounded. + if (shownToolCallIds.size > MAX_SHOWN_TOOL_CALL_IDS) { + shownToolCallIds.delete(shownToolCallIds.values().next().value!); + } + // A call still running is already on screen, even if it aged out. + if (!active) yield* closeActiveAssistantSegment({ queue, assistantSegmentRef }); + } + yield* Queue.offer(queue, { + _tag: "ToolCallUpdated", + toolCall: merged, + rawPayload: event.rawPayload, + }); + continue; + } + if (event._tag === "ContentDelta") { + if (event.text.trim().length === 0) { + const assistantSegmentState = yield* Ref.get(assistantSegmentRef); + if (!assistantSegmentState.activeItemId) { + continue; + } + } + const itemId = yield* ensureActiveAssistantSegment({ + queue, + assistantSegmentRef, + sessionId: params.sessionId, + assistantItemRuntimeId, + }); + yield* Queue.offer(queue, { + ...event, + itemId, + }); + continue; + } + yield* Queue.offer(queue, event); + } + }); + +function updateModeState(modeState: AcpSessionModeState, nextModeId: string): AcpSessionModeState { + const normalized = nextModeId.trim(); + if (!normalized) { + return modeState; + } + return modeState.availableModes.some((mode) => mode.id === normalized) + ? { + ...modeState, + currentModeId: normalized, + } + : modeState; +} + +const assistantItemId = (sessionId: string, runtimeId: string, segmentIndex: number) => + `assistant:${sessionId}:runtime:${runtimeId}:segment:${segmentIndex}`; + +const ensureActiveAssistantSegment = ({ + queue, + assistantSegmentRef, + sessionId, + assistantItemRuntimeId, +}: { + readonly queue: Queue.Queue; + readonly assistantSegmentRef: Ref.Ref; + readonly sessionId: string; + readonly assistantItemRuntimeId: string; +}) => + Ref.modify( + assistantSegmentRef, + (current) => { + if (current.activeItemId) { + return [{ itemId: current.activeItemId }, current] as const; + } + const itemId = assistantItemId(sessionId, assistantItemRuntimeId, current.nextSegmentIndex); + return [ + { + itemId, + startedEvent: { + _tag: "AssistantItemStarted", + itemId, + } satisfies Extract, + }, + { + nextSegmentIndex: current.nextSegmentIndex + 1, + activeItemId: itemId, + } satisfies AcpAssistantSegmentState, + ] as const; + }, + ).pipe( + Effect.flatMap((result) => + result.startedEvent + ? Queue.offer(queue, result.startedEvent).pipe(Effect.as(result.itemId)) + : Effect.succeed(result.itemId), + ), + ); + +const closeActiveAssistantSegment = ({ + queue, + assistantSegmentRef, +}: { + readonly queue: Queue.Queue; + readonly assistantSegmentRef: Ref.Ref; +}) => + Ref.modify(assistantSegmentRef, (current) => { + if (!current.activeItemId) { + return [undefined, current] as const; + } + return [ + { + _tag: "AssistantItemCompleted", + itemId: current.activeItemId, + } satisfies AcpParsedSessionEvent, + { + nextSegmentIndex: current.nextSegmentIndex, + } satisfies AcpAssistantSegmentState, + ] as const; + }).pipe(Effect.flatMap((event) => (event ? Queue.offer(queue, event) : Effect.void))); diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts new file mode 100644 index 0000000000000000000000000000000000000000..03d168c04a5be816341204c3d6e8e6c128f95d3f --- /dev/null +++ b/packages/provider-acp/src/server/adapter.ts @@ -0,0 +1,8054 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodePath from "node:path"; + +import { + type ChatAttachment, + type ModelSelection, + type OrchestrationV2ConversationMessage, + type OrchestrationV2ExecutionNode, + type OrchestrationV2PlanArtifact, + type OrchestrationV2PlanStep, + type OrchestrationV2ProviderCapabilities, + type OrchestrationV2ProviderFailure, + type OrchestrationV2ProviderRetry, + type OrchestrationV2ProviderSession, + type OrchestrationV2ProviderThread, + type OrchestrationV2ProviderThreadNativeMetadata, + type OrchestrationV2ProviderTurn, + type OrchestrationV2RuntimeRequest, + type OrchestrationV2Subagent, + type OrchestrationV2TurnItem, + type OrchestrationV2UserInputQuestion, + type OrchestrationV2WebSearchResult, + type ProviderApprovalDecision, + type ProviderApprovalOption, + type ProviderInstanceId, + type ProviderInteractionMode, + type ProviderDriverKind, + type ProviderRequestKind, + type ProviderThreadId, + type ProviderUserInputAnswers, + type RuntimeRequestId, + type ThreadTokenUsageSnapshot, + type ThreadId, +} from "@t3tools/contracts"; +import { modelSelectionsEqual } from "@t3tools/shared/model"; +import { type SelfInvocation, selfInvocationArgs } from "@t3tools/shared/nodeRuntime"; +import { FILE_HEADERS_ONLY, formatPatch, structuredPatch } from "diff"; +import * as Cause from "effect/Cause"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import type * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; +import * as Result from "effect/Result"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import type { ChildProcessSpawner } from "effect/process"; +import * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpProtocol from "effect-acp/protocol"; +import type * as EffectAcpSchema from "effect-acp/compat"; + +import { formatReadToolLabel, formatSearchToolLabel } from "@t3tools/shared/toolActivity"; +import type * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; +import { makeAcpMcpOverAcpBridge, type AcpMcpOverAcpBridge } from "./mcpOverAcpBridge.ts"; +import { mcpToolPresentation } from "@t3tools/provider-core/server/mcpToolPresentation"; +import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; +import { + applyAcpAgentTerminalUpdate, + acpContentBlockDisplayText, + embeddedTerminalIdsFromSessionUpdate, + extractMcpToolCallIdentity, + mergeToolCallState, + parsePermissionRequest, + parseSessionUpdateEvent, + toolCallVisibleOutputChanged, + type AcpPlanUpdate, + type AcpAgentTerminalState, + type AcpSessionModeState, + type AcpToolCallState, +} from "./runtimeModel.ts"; +import { + acpClientExecuteDisposition, + acpMcpToolApprovalElicitationDisposition, + acpPermissionDisposition, + type AcpPermissionDisposition, + makeAcpClientPolicyGrants, + unknownRecord, +} from "./clientPolicy.ts"; +import { + makeAcpClientTerminals, + resolveEmbeddedTerminalContent, + type AcpClientTerminals, +} from "./clientTerminals.ts"; +import { ACP_SESSION_MODE_OPTION_ID } from "./sessionConfig.ts"; +import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; +import { + t3AcpPromptWithInstructions, + type T3AcpInstructionState, +} from "@t3tools/provider-core/server/orchestrationInstructions"; +import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import { type ProviderContinuationRequest } from "@t3tools/provider-core/server/continuationRequests"; +import { + type BackgroundWork, + type BackgroundWorkReport, + backgroundWorkNotification, +} from "@t3tools/provider-core/server/notification"; +import { + makeProviderFailure, + makeProviderRetryTurnItem, +} from "@t3tools/provider-core/server/failure"; +import { acpSelectionTransition } from "@t3tools/provider-core/server/selectionTransition"; +import { + isProviderNativeImageAttachment, + providerMessageTextWithAttachmentPaths, +} from "@t3tools/provider-core/server/attachmentPrompt"; +import { + makeSubagentChildThread, + makeSubagentConversationArtifacts, + subagentThreadTitle, +} from "@t3tools/provider-core/server/subagentProjection"; +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; + +export const ACP_PROTOCOL = "acp.ndjson-jsonrpc" as const; + +/** + * Quiet window after a settled turn's last background rearm before it + * finalizes, so a slightly late post-hydration assistant chunk stays in the + * same turn. Grok commonly sends its final summary just over two seconds after + * the hydrated tool frame; two seconds split that tail into a second synthetic + * wake. Longer floors (4–20s) only prolonged Working. No per-model carveouts. + */ +const ACP_DEFERRED_FINALIZE_DEBOUNCE: Duration.Input = "3000 millis"; + +export interface AcpAdapterV2RuntimeInput { + readonly cwd: string; + /** + * Policy the session opened with. A runtime-mode change reopens the session, + * so flavors that encode permissions in the launch command (Grok) read it here. + */ + readonly runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy; + readonly mcpServers: ReadonlyArray; + readonly acpMcpServers?: ReadonlyArray; + /** Scoped credentials for terminal fallback when an ACP agent drops `mcpServers`. */ + readonly processEnvironment?: NodeJS.ProcessEnv; + readonly resumeSessionId?: string; + readonly interruptPromptOnCancel?: boolean; + readonly clientCapabilities: EffectAcpSchema.InitializeRequest["clientCapabilities"]; + readonly clientInfo: AcpSessionRuntime.AcpSessionRuntimeOptions["clientInfo"]; + readonly requestLogger?: NonNullable; + readonly protocolLogging: NonNullable< + AcpSessionRuntime.AcpSessionRuntimeOptions["protocolLogging"] + >; + readonly onTermination: NonNullable; + readonly onOutgoingResponseFailure?: AcpSessionRuntime.AcpSessionRuntimeOptions["onOutgoingResponseFailure"]; + readonly onOutgoingResponse?: AcpSessionRuntime.AcpSessionRuntimeOptions["onOutgoingResponse"]; +} + +export type AcpAdapterV2NativeLogging = Pick< + AcpSessionRuntime.AcpSessionRuntimeOptions, + "requestLogger" | "protocolLogging" +>; + +export interface AcpAdapterV2UserInputRequest { + readonly nativeItemId: string; + readonly nativeRequestId: string; + readonly questions: ReadonlyArray; +} + +export interface AcpAdapterV2ExtensionContext { + readonly runtime: AcpSessionRuntime.AcpSessionRuntime["Service"]; + readonly reportProviderRetry: (input: { + readonly sessionId: string; + readonly failure: OrchestrationV2ProviderFailure; + }) => Effect.Effect; + /** + * A subagent's structured end on the root session (Grok `subagent_finished`), + * keyed by its child session id. Finishes the subagent row, in the turn that + * holds it or in the carryover of a settled one. + */ + readonly finishSubagent: (notice: { + readonly sessionId: string; + readonly childSessionId: string; + readonly status: "completed" | "failed" | "cancelled"; + readonly result: string | null; + }) => Effect.Effect; + /** + * Session-scoped background-task lifecycle reported via extension + * notifications (e.g. Grok `x.ai/task_backgrounded`; older builds use the + * underscore alias). Mutations for non-root sessions are ignored. A terminal + * mutation also finishes the tool that registered the task in a settled turn + * still held open for it, with `output` as its final text when given. + */ + readonly applyBackgroundTaskMutation: (mutation: { + readonly sessionId: string; + readonly taskId: string; + readonly status: "running" | "completed" | "failed"; + readonly output?: string; + /** What the task is, so a wake it causes can name it. */ + readonly report?: BackgroundWork; + }) => Effect.Effect; + readonly requestUserInput: ( + input: AcpAdapterV2UserInputRequest, + requestContext: EffectAcpProtocol.AcpRequestContext, + ) => Effect.Effect< + { + readonly acknowledgeNativeResponse: Effect.Effect; + readonly answers: ProviderUserInputAnswers | null; + }, + EffectAcpErrors.AcpError + >; + /** Surfaces plan markdown as this turn's proposed-plan card (#8358). */ + readonly captureProposedPlan: (input: { readonly planMarkdown: string }) => Effect.Effect; + /** Last markdown captured for the active turn, as the exit-gate fallback. */ + readonly lastProposedPlanMarkdown: Effect.Effect; +} + +export interface AcpAdapterV2Flavor { + /** Interprets provider-specific prompt errors before they cross into orchestration. */ + readonly promptFailure?: (cause: unknown) => OrchestrationV2ProviderFailure; + readonly driver: ProviderDriverKind; + readonly capabilities: OrchestrationV2ProviderCapabilities; + readonly clientCapabilitiesMeta?: Record; + readonly normalizeSessionUpdate?: ( + notification: EffectAcpSchema.SessionNotification, + ) => EffectAcpSchema.SessionNotification; + readonly onAvailableCommandsUpdate?: ( + commands: ReadonlyArray, + ) => Effect.Effect; + readonly onSessionConfigurationUpdate?: ( + configOptions: ReadonlyArray, + modeState: AcpSessionModeState | undefined, + ) => Effect.Effect; + readonly onUrlElicitation?: (input: { + readonly elicitationId: string; + readonly url: string; + readonly message: string; + }) => Effect.Effect; + readonly withRuntimeStartup?: (effect: Effect.Effect) => Effect.Effect; + readonly makeRuntime: ( + input: AcpAdapterV2RuntimeInput, + ) => Effect.Effect< + AcpSessionRuntime.AcpSessionRuntime["Service"], + EffectAcpErrors.AcpError, + Crypto.Crypto | Scope.Scope + >; + readonly resolveModelId?: (selection: ModelSelection) => string | undefined; + /** + * Replaces the default model application on session setup. Returns the model + * the session now runs on. Antigravity resolves its provider-default alias + * against the account's catalog instead of sending it to the agent. + */ + readonly applyModelSelection?: (input: { + readonly runtime: AcpSessionRuntime.AcpSessionRuntime["Service"]; + readonly startResult: AcpSessionRuntime.AcpSessionRuntimeStartResult; + readonly modelSelection: ModelSelection; + }) => Effect.Effect; + /** Native session mode to select for a runtime policy (e.g. Antigravity `yolo`). */ + readonly sessionModeForPolicy?: ( + policy: ProviderAdapter.ProviderAdapterV2RuntimePolicy, + ) => string | undefined; + /** + * Opts the session into the ACP client `fs` capability. Agents read and write + * files themselves under their own permission model unless a flavor sets + * this. Requests pass the runtime policy guard, then these handlers, which + * receive the cwd of the policy active when the request arrives (null when + * the session has no workspace). Antigravity sets it and confines requests + * to that workspace. + */ + readonly clientFileSystem?: { + readonly readTextFile: ( + request: EffectAcpSchema.ReadTextFileRequest, + cwd: string | null, + ) => Effect.Effect; + readonly writeTextFile: ( + request: EffectAcpSchema.WriteTextFileRequest, + cwd: string | null, + ) => Effect.Effect; + }; + /** + * Permission requests that are really questions (Antigravity `interaction_*` + * tool calls). Returns the question and a response builder; undefined routes + * the request through the normal approval card. + */ + readonly extractPermissionQuestion?: (request: EffectAcpSchema.RequestPermissionRequest) => + | { + readonly question: OrchestrationV2UserInputQuestion; + readonly respond: ( + answers: ProviderUserInputAnswers, + ) => EffectAcpSchema.RequestPermissionResponse | undefined; + } + | undefined; + /** + * Replaces T3's runtime-policy answer to a permission request. Grok's Auto + * mode only asks about what its own classifier refused, so those must reach + * the user instead of being approved by T3's policy. + */ + readonly permissionDisposition?: ( + policy: ProviderAdapter.ProviderAdapterV2RuntimePolicy, + request: EffectAcpSchema.RequestPermissionRequest, + ) => AcpPermissionDisposition; + /** Approval choices to advertise on the approval card for a permission request. */ + readonly approvalOptions?: ( + request: EffectAcpSchema.RequestPermissionRequest, + ) => ReadonlyArray; + /** + * Activate saved sessions with `session/resume` before `session/load` when + * the agent supports both. Antigravity's load replays history slowly. + */ + readonly preferResumeSession?: boolean; + readonly onSessionEvent?: ( + event: AcpSessionRuntime.AcpSessionRuntimeEvent, + ) => Effect.Effect; + /** Batch launches without child completion signals become idle when the root turn ends. */ + readonly subagentsIdleOnTurnCompletion?: boolean; + readonly supportsCompaction?: boolean; + readonly runtimeHarness?: string; + readonly registerExtensions?: ( + context: AcpAdapterV2ExtensionContext, + ) => Effect.Effect; + readonly extractSubagentUpdate?: ( + toolCall: AcpToolCallState, + ) => AcpAdapterV2SubagentUpdate | undefined; + /** + * Optional Grok-style rewrite before tool projection (e.g. keep monitor start + * ACKs in the running state until stream end). + */ + readonly normalizeToolCall?: (toolCall: AcpToolCallState) => AcpToolCallState; + /** + * Optional plan-file sniffing (#8358): providers that write their proposed + * plan to a file mid-turn (Grok plan.md) return its markdown from a tool + * call so T3 can show the proposed-plan card while plan mode is active. + */ + readonly extractProposedPlanMarkdown?: (toolCall: AcpToolCallState) => string | undefined; + /** + * Optional mapping from a long-lived background tool start ACK to a task id + * (e.g. monitor task uuid) so later synthetic text events can update it. + */ + readonly extractBackgroundTaskId?: (toolCall: AcpToolCallState) => string | undefined; + /** + * Optional parse of root-session synthetic text (monitor-event lines, monitor + * ended reminders). Returns every task mutation in the chunk so coalesced + * progress / end notices are not dropped. + */ + readonly extractBackgroundToolMutation?: (text: string) => ReadonlyArray<{ + readonly taskId: string; + readonly status: "running" | "completed" | "failed"; + readonly appendOutput: string; + }>; + /** + * Optional parse of root-session synthetic text announcing a background + * subagent's end ("Background subagent "" ... completed successfully"). + * Older builds may never hydrate via get_command_or_subagent_output, so this + * remains a terminal fallback. Current Grok additionally emits structured + * `subagent_finished` session notifications. + */ + readonly extractSubagentEndNotice?: (text: string) => + | { + readonly childSessionId: string; + readonly status: "completed" | "failed"; + } + | undefined; + /** + * Optional hydration when a later tool (e.g. get_command TaskOutput) completes + * previously registered background task id(s). + */ + readonly extractBackgroundTaskCompletion?: (toolCall: AcpToolCallState) => ReadonlyArray<{ + readonly taskId: string; + readonly status: "running" | "completed" | "failed"; + readonly appendOutput: string; + }>; + /** + * Persistent monitors (e.g. Grok `persistent: true`) should not hold root-turn + * deferred finalize open forever. Still tracked for post-settle wake. + */ + readonly isPersistentBackgroundTool?: (toolCall: AcpToolCallState) => boolean; + /** + * Whether a root-session frame belongs to a turn the agent started itself + * after background work ended (Grok `task-completed-*`), not to T3's prompt. + * Such frames never project into a root turn held open for that work; they + * take the post-settle wake path once the held turn finalizes. + */ + readonly isProviderWakeNotification?: ( + notification: EffectAcpSchema.SessionNotification, + ) => boolean; + /** + * When true, keep the active turn open after session/prompt returns while + * background tools/subagents are still running so later monitor/wake traffic + * can project (Grok monitors finish after the root prompt settles). + */ + readonly deferFinalizeForBackgroundWork?: boolean; + readonly assertComplete?: Effect.Effect; + /** Interrupt the local prompt fiber before `session/cancel` (Grok wedged prompts). */ + readonly interruptPromptOnCancel?: boolean; + /** + * Kill and respawn the ACP child process before the next `session/prompt` after a + * user interrupt. Grok can keep `task_already_running` state until the process exits. + */ + readonly restartRuntimeAfterInterrupt?: boolean; + /** + * When true, every interrupt restarts the runtime, not just those carrying + * `requestRuntimeRestart` (user Stop). Leave unset to keep non-Stop + * interrupts (steering, restart_active) soft: `session/cancel` plus session + * reuse in the same process. + */ + readonly restartRuntimeOnEveryInterrupt?: boolean; + readonly terminateRuntimeProcessGroupOnInterrupt?: boolean; + /** + * When true, an interrupt without `requestRuntimeRestart` (steering restart) + * on a turn whose native prompt already settled skips the hard process-group + * kill, the ACP cancel, and the runtime respawn entirely: the turn + * terminalizes locally while background subagents keep running in the same + * process and carry over into the replacement turn. Verified against the + * real Grok CLI (tmp/grok-acp-experiments E1): a new session/prompt is + * accepted concurrently while a fire-and-forget subagent is still running, + * with no task_already_running. User Stop (`requestRuntimeRestart: true`) + * keeps the hard teardown; mid-prompt non-Stop interrupts go soft + * (`session/cancel` plus same-session re-prompt) unless + * `restartRuntimeOnEveryInterrupt` is set. + */ + readonly preserveRuntimeOnSettledInterrupt?: boolean; + /** + * When true (with continuationRequests), post-settle root session/update traffic + * buffers and requests a provider continuation run instead of being dropped or + * only appended to loaded history. + */ + readonly enablePostSettleContinuation?: boolean; + /** + * When true, send image attachment content blocks even if the ACP agent + * advertises `promptCapabilities.image: false`. Grok CLI currently accepts + * and vision-processes image blocks while still reporting the capability as + * false; without this override, screenshot turns fail before `session/prompt`. + */ + readonly supportsImagePrompts?: boolean; +} + +/** Whether image attachment blocks may be included in session/prompt. */ +export function acpSupportsImagePrompts(input: { + readonly flavorSupportsImagePrompts?: boolean | undefined; + readonly negotiatedImage?: boolean | undefined; +}): boolean { + return input.flavorSupportsImagePrompts === true || input.negotiatedImage === true; +} + +/** Keeps provider-derived ids distinct when two configured ACP instances reuse native ids. */ +export function acpScopedNativeId(instanceId: ProviderInstanceId, nativeId: string): string { + return `provider-instance:${encodeURIComponent(instanceId)}:${nativeId}`; +} + +/** Keeps pre-v2 persisted ids stable while scoping ids for newly created threads. */ +export function acpProviderItemNativeId(input: { + readonly instanceId: ProviderInstanceId; + readonly itemIdentityVersion: 2 | undefined; + readonly nativeId: string; +}): string { + return input.itemIdentityVersion === 2 + ? acpScopedNativeId(input.instanceId, input.nativeId) + : input.nativeId; +} + +export interface AcpAdapterV2SubagentUpdate { + readonly nativeTaskId: string; + readonly prompt: string; + readonly title: string | null; + readonly model: string | null; + readonly status: + | "pending" + | "running" + | "idle" + | "completed" + | "failed" + | "interrupted" + | "cancelled"; + readonly childSessionId: string | null; + readonly result: string | null; + /** + * When false, still project a normal tool turn item after the subagent update + * (hydration tools like get_command_or_subagent_output). Defaults to true. + */ + readonly suppressNormalTool?: boolean; +} + +export interface AcpAdapterV2Options { + readonly instanceId: ProviderInstanceId; + readonly flavor: AcpAdapterV2Flavor; + readonly crypto: Crypto.Crypto; + readonly fileSystem: FileSystem.FileSystem; + readonly idAllocator: IdAllocator.IdAllocatorV2Shape; + readonly host: ProviderHost.ProviderHostShape; + /** How agents spawn this install's `acp-mcp-bridge`; see `resolveSelfInvocation`. */ + readonly selfInvocation: SelfInvocation; + /** + * Opts the session into the ACP client `terminal` capability. Agents run + * commands themselves unless an adapter sets this; with it, sessions + * advertise `terminal: true` and run agent-created terminals through this + * spawner with the provider instance's environment. Devin sets it. + */ + readonly clientTerminals?: { + readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; + readonly environment?: NodeJS.ProcessEnv; + readonly shellCommands?: boolean; + }; + readonly nativeLogging?: (threadId: ThreadId) => AcpAdapterV2NativeLogging; + /** + * Shared with ProviderContinuationService so post-settle wake traffic can start + * a continuation run. Optional: adapters that omit it keep pre-continuation drop + * / history-only behavior for null-activeTurn updates. + */ + readonly continuationRequests?: { + readonly offer: (request: ProviderContinuationRequest) => Effect.Effect; + }; + readonly testHooks?: { + /** + * A settled turn with no background work left armed its finish debounce + * ({@link ACP_DEFERRED_FINALIZE_DEBOUNCE}); replay advances its test clock + * by exactly that on this receipt. + */ + readonly onDeferredFinalizeScheduled?: (debounce: Duration.Input) => Effect.Effect; + readonly afterPromptSettledWithBackgroundWork?: () => Effect.Effect; + readonly afterNativeResponseTransportClosed?: () => Effect.Effect; + readonly afterHardTeardownTransportDrained?: () => Effect.Effect; + readonly beforeNativeResponseAdmissionCheck?: ( + generation: number, + requestId: string, + ) => Effect.Effect; + readonly onNativeResponseLifecycle?: (event: { + readonly generation?: number; + readonly requestId?: string; + readonly type: + | "admission_rejected" + | "failed" + | "late_noop" + | "registered" + | "removed" + | "timer_exited" + | "timer_started" + | "watcher_exited" + | "watcher_started"; + }) => Effect.Effect; + }; +} + +export const AcpProviderCapabilitiesV2 = { + sessions: { + supportsMultipleProviderThreadsPerSession: false, + supportsModelSwitchInSession: false, + supportsProviderSwitchingViaHandoff: true, + supportsRuntimeModeSwitchInSession: false, + pendingRequestsSurviveRestart: false, + }, + threads: { + canCreateEmptyThread: true, + canReadThreadSnapshot: false, + canRollbackThread: true, + canForkThread: false, + canForkFromTurn: false, + canForkFromSubagentThread: false, + exposesNativeThreadId: true, + }, + turns: { + exposesNativeTurnId: false, + emitsTurnStarted: true, + emitsTurnCompleted: true, + supportsInterrupt: true, + supportsActiveSteering: false, + supportsSteeringByInterruptRestart: true, + supportsQueuedMessages: true, + terminalStatusQuality: "strong", + }, + streaming: { + streamsAssistantText: true, + streamsReasoning: true, + streamsToolOutput: true, + streamsPlanText: false, + emitsMessageCompleted: true, + }, + tools: { + exposesToolItemIds: true, + emitsToolStarted: true, + emitsToolCompleted: true, + emitsToolOutput: true, + supportsMcpTools: false, + supportsDynamicToolCallbacks: false, + }, + approvals: { + supportsCommandApproval: true, + supportsFileReadApproval: true, + supportsFileChangeApproval: true, + supportsApplyPatchApproval: false, + approvalsHaveNativeRequestIds: false, + approvalCallbacksAreLiveOnly: true, + approvalsCanOriginateFromSubagents: false, + }, + planning: { + emitsPlanUpdated: true, + emitsTodoList: true, + emitsProposedPlan: false, + supportsStructuredQuestions: true, + planDeltasHaveItemIds: false, + }, + subagents: { + supportsSubagents: false, + exposesSubagentThreadIds: false, + emitsSubagentLifecycle: false, + canWaitForSubagents: false, + canCloseSubagents: false, + canForkSubagentThread: false, + }, + context: { + acceptsSystemContext: false, + acceptsDeveloperContext: false, + acceptsSyntheticUserContext: true, + canGenerateSummaries: true, + canConsumeHandoffSummaries: true, + supportsDeltaHandoff: true, + supportsFullThreadHandoff: true, + maxRecommendedHandoffChars: null, + }, + checkpointing: { + appCanCheckpointFilesystem: true, + supportsNestedCheckpointScopes: true, + // ACP defines no conversation truncation, so rollback resets the provider + // conversation: T3 restores checkpointed state and the next turn starts a + // fresh agent session without the rolled-back context. + providerCanRollbackConversation: true, + providerRollbackReturnsSnapshot: true, + providerCanReadConversationSnapshot: false, + }, + identity: { + nativeThreadIds: "strong", + nativeTurnIds: "weak", + nativeItemIds: "weak", + nativeRequestIds: "weak", + }, + runtimePolicy: { + // ACP agents run their own tools; T3 only answers their permission + // requests by policy. + enforcement: "client-boundary", + }, +} satisfies OrchestrationV2ProviderCapabilities; + +function negotiatedCapabilities( + base: OrchestrationV2ProviderCapabilities, + started: AcpSessionRuntime.AcpSessionRuntimeStartResult, +): OrchestrationV2ProviderCapabilities { + const agent = started.initializeResult.agentCapabilities ?? {}; + const session = agent.sessionCapabilities; + const setup = started.sessionSetupResult; + const hasModelConfig = + setup.configOptions?.some((option) => option.category === "model") === true; + const canLoad = agent.loadSession === true; + const canFork = session?.fork != null; + return { + ...base, + sessions: { + ...base.sessions, + supportsModelSwitchInSession: hasModelConfig, + }, + threads: { + ...base.threads, + canReadThreadSnapshot: canLoad, + canForkThread: canFork, + canForkFromTurn: false, + }, + tools: { + ...base.tools, + // The stdio bridge (`t3 acp-mcp-bridge`) makes the t3-code MCP toolkit + // available regardless of the agent's optional http/sse MCP support. + supportsMcpTools: true, + }, + checkpointing: { + ...base.checkpointing, + providerCanReadConversationSnapshot: canLoad, + }, + }; +} + +interface AcpMcpContext { + readonly servers: ReadonlyArray; + readonly acpServers: ReadonlyArray; + readonly processEnvironment?: NodeJS.ProcessEnv; + readonly endpoint?: string; + readonly authorization?: string; +} + +function acpMcpContext(threadId: ThreadId | null, self: SelfInvocation): AcpMcpContext { + if (threadId === null) return { servers: [], acpServers: [] }; + const session = McpProviderSession.readMcpProviderSession(threadId); + if (session === undefined) { + return { servers: [], acpServers: [] }; + } + // Stdio is ACP's required baseline MCP transport. Agents that advertise + // optional http support still routinely fail to wire injected http servers + // through to their backend (codex-acp 1.2.0 and pi-acp both drop them), so + // every ACP session gets the `t3 acp-mcp-bridge` stdio server, which + // forwards JSON-RPC to T3's authenticated MCP endpoint. The credential + // travels via environment variables, never the command line. + return { + servers: [ + { + name: "t3-code", + command: self.command, + args: [...selfInvocationArgs(self, ["acp-mcp-bridge"])], + env: [ + { name: "ELECTRON_RUN_AS_NODE", value: "1" }, + { name: "T3_ACP_MCP_ENDPOINT", value: session.endpoint }, + { name: "T3_ACP_MCP_AUTHORIZATION", value: session.authorizationHeader }, + ], + }, + ], + acpServers: [{ type: "acp", name: "t3-code", serverId: "t3-code" }], + endpoint: session.endpoint, + authorization: session.authorizationHeader, + processEnvironment: { + T3_ACP_MCP_ENDPOINT: session.endpoint, + T3_ACP_MCP_AUTHORIZATION: session.authorizationHeader, + T3_ACP_MCP_NODE: self.command, + ...(self.entrypoint === undefined ? {} : { T3_ACP_MCP_ENTRYPOINT: self.entrypoint }), + }, + }; +} + +function acpMcpServers( + threadId: ThreadId | null, + self: SelfInvocation, +): ReadonlyArray { + return acpMcpContext(threadId, self).servers; +} + +function acpMcpActivation(threadId: ThreadId | null, self: SelfInvocation) { + const context = acpMcpContext(threadId, self); + return { mcpServers: context.servers, acpMcpServers: context.acpServers }; +} + +function nativeThreadId( + driver: ProviderDriverKind, + thread: OrchestrationV2ProviderThread, +): Effect.Effect { + const id = thread.nativeThreadRef?.nativeId; + if (id === null || id === undefined || id.trim().length === 0) { + return Effect.fail( + new ProviderAdapter.ProviderAdapterProtocolError({ + driver, + detail: `Provider thread ${thread.id} is missing its ACP session id`, + }), + ); + } + return Effect.succeed(id); +} + +function makeProviderThread(input: { + readonly driver: ProviderDriverKind; + readonly providerInstanceId: ProviderInstanceId; + readonly idAllocator: IdAllocator.IdAllocatorV2Shape; + readonly appThreadId: OrchestrationV2ProviderThread["appThreadId"]; + readonly providerSessionId: OrchestrationV2ProviderThread["providerSessionId"]; + readonly nativeThreadId: string; + readonly ownerNodeId?: OrchestrationV2ProviderThread["ownerNodeId"]; + readonly forkedFrom?: OrchestrationV2ProviderThread["forkedFrom"]; + readonly itemIdentityVersion?: 2; + readonly now: DateTime.Utc; +}): OrchestrationV2ProviderThread { + return { + id: input.idAllocator.derive.providerThread({ + driver: input.driver, + providerInstanceId: input.providerInstanceId, + nativeThreadId: input.nativeThreadId, + }), + driver: input.driver, + providerInstanceId: input.providerInstanceId, + providerSessionId: input.providerSessionId, + appThreadId: input.appThreadId, + ownerNodeId: input.ownerNodeId ?? null, + nativeThreadRef: { + driver: input.driver, + nativeId: input.nativeThreadId, + strength: "strong", + }, + nativeConversationHeadRef: null, + status: "idle", + firstRunOrdinal: null, + lastRunOrdinal: null, + handoffIds: [], + forkedFrom: input.forkedFrom ?? null, + contextUsage: null, + nativeMetadata: + input.itemIdentityVersion === undefined + ? null + : { itemIdentityVersion: input.itemIdentityVersion }, + createdAt: input.now, + updatedAt: input.now, + }; +} + +/** + * Unwrap a codex-acp style MCP call result (`{ result, error }` around MCP + * `content`/`structuredContent`) the same way the native Codex adapter does, + * so recovered MCP items render identical output. Unknown shapes pass through. + */ +function acpMcpToolCallOutput(rawOutput: unknown): unknown { + const record = unknownRecord(rawOutput); + if (record === undefined) return rawOutput; + if (!("result" in record) && !("error" in record)) return rawOutput; + const result = unknownRecord(record.result); + const resultOutput = + result === undefined ? undefined : (result.structuredContent ?? result.content ?? undefined); + const errorMessage = unknownRecord(record.error)?.message; + if (typeof errorMessage !== "string") { + return resultOutput ?? rawOutput; + } + return resultOutput === undefined + ? { error: errorMessage } + : { error: errorMessage, result: resultOutput }; +} + +function nonEmptyText(value: unknown, fallback: string): string { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : fallback; +} + +function decodeByteText(value: unknown): string | undefined { + if (!Array.isArray(value) || value.length === 0) return undefined; + if (!value.every((entry) => typeof entry === "number" && Number.isInteger(entry))) { + return undefined; + } + try { + // Preserve leading/trailing whitespace like the string path in textFromUnknown. + const text = new TextDecoder().decode(Uint8Array.from(value as number[])); + return text.length > 0 ? text : undefined; + } catch { + return undefined; + } +} + +function textFromUnknown(value: unknown): string | undefined { + if (typeof value === "string") { + return value; + } + const fromBytes = decodeByteText(value); + if (fromBytes !== undefined) { + return fromBytes; + } + if (Array.isArray(value)) { + const parts = value.flatMap((entry) => { + const text = textFromUnknown(entry); + return text === undefined || text.length === 0 ? [] : [text]; + }); + return parts.length === 0 ? undefined : parts.join("\n"); + } + const record = unknownRecord(value); + if (record === undefined) { + return undefined; + } + // Prefer prompt-facing Grok fields before nested envelopes. + // Antigravity reports shell output as combinedOutput. + for (const key of [ + "output_for_prompt", + "stdout", + "stderr", + "output", + "combinedOutput", + "combined_output", + "content", + "text", + "message", + ]) { + const direct = record[key]; + if (typeof direct === "string" && direct.length > 0) { + return direct; + } + const decoded = decodeByteText(direct); + if (decoded !== undefined) { + return decoded; + } + const text = textFromUnknown(direct); + if (text !== undefined && text.length > 0) { + return text; + } + } + const result = unknownRecord(record.Result) ?? unknownRecord(record.result); + if (result !== undefined) { + return textFromUnknown(result); + } + return undefined; +} + +function commandExitCode(value: unknown): number | undefined { + const record = unknownRecord(value); + for (const key of ["exitCode", "exit_code", "code"]) { + const candidate = record?.[key]; + if (typeof candidate === "number" && Number.isInteger(candidate)) { + return candidate; + } + } + return undefined; +} + +/** + * Project an exit code only when the tool has a terminal native status. + * Mid-stream Grok Bash re-reports carry exit_code 0 while still in progress; + * interrupted tools must not retain that stale success code. + */ +export function acpProjectedCommandExitCode( + status: "pending" | "running" | "waiting" | "completed" | "failed" | "interrupted", + rawOutput: unknown, +): number | undefined { + if (status !== "completed" && status !== "failed") { + return undefined; + } + return commandExitCode(rawOutput); +} + +function structuredFileChanges(toolCall: AcpToolCallState) { + const content = toolCall.data.content; + if (!Array.isArray(content)) return []; + return content.flatMap((entry) => { + const diff = unknownRecord(entry); + if (diff?.type !== "diff" || !Array.isArray(diff.changes)) return []; + return diff.changes.flatMap((candidate) => { + const change = unknownRecord(candidate); + const operation = typeof change?.operation === "string" ? change.operation.trim() : ""; + const path = typeof change?.path === "string" ? change.path.trim() : ""; + if (operation.length === 0 || path.length === 0) return []; + const oldPath = typeof change?.oldPath === "string" ? change.oldPath.trim() : ""; + const fileType = typeof change?.fileType === "string" ? change.fileType.trim() : ""; + const mimeType = typeof change?.mimeType === "string" ? change.mimeType.trim() : ""; + return [ + { + operation, + path, + ...(oldPath.length === 0 ? {} : { oldPath }), + ...(fileType.length === 0 ? {} : { fileType }), + ...(mimeType.length === 0 ? {} : { mimeType }), + }, + ]; + }); + }); +} + +// Past this edit distance an edit keeps no patch text, so projecting a large +// rewrite cannot stall the event loop in the diff search. A created or emptied +// file has one empty side and needs no search, so it is never capped. +const ACP_V1_DIFF_MAX_EDITS = 1_000; + +/** + * Patch text for a tool call's diff content. ACP v2 diffs carry it as + * `patch.text`. ACP v1 diffs carry `oldText`/`newText` instead (`oldText` + * null or absent for a new file), and agents that negotiate v1 still send + * that shape, so the patch is built from the two sides. + */ +export function acpToolCallDiffPatch(content: unknown): string | undefined { + if (!Array.isArray(content)) return undefined; + const diffs = content.flatMap((entry) => { + const diff = unknownRecord(entry); + return diff?.type === "diff" ? [diff] : []; + }); + for (const diff of diffs) { + const patch = unknownRecord(diff.patch); + if (typeof patch?.text === "string") return patch.text; + } + const v1Patches = diffs.flatMap((diff) => { + if (typeof diff.path !== "string" || typeof diff.newText !== "string") return []; + const oldText = typeof diff.oldText === "string" ? diff.oldText : undefined; + const patch = structuredPatch( + oldText === undefined ? "/dev/null" : diff.path, + diff.path, + oldText ?? "", + diff.newText, + undefined, + undefined, + { + context: 3, + maxEditLength: oldText && diff.newText ? ACP_V1_DIFF_MAX_EDITS : Number.POSITIVE_INFINITY, + }, + ); + return patch === undefined || patch.hunks.length === 0 ? [] : [patch]; + }); + return v1Patches.length === 0 ? undefined : formatPatch(v1Patches, FILE_HEADERS_ONLY); +} + +function pathFromToolCall(toolCall: AcpToolCallState): string | undefined { + const locations = toolCall.data.locations; + if (Array.isArray(locations)) { + for (const location of locations) { + const path = unknownRecord(location)?.path; + if (typeof path === "string" && path.trim().length > 0) { + return path.trim(); + } + } + } + const rawInput = unknownRecord(toolCall.data.rawInput); + for (const key of ["path", "filePath", "file_path", "url", "query", "pattern"]) { + const candidate = rawInput?.[key]; + if (typeof candidate === "string" && candidate.trim().length > 0) { + return candidate.trim(); + } + } + return undefined; +} + +/** + * Grok runs X and web searches server-side as `search` tools whose rawInput is + * only `{ variant: "XSearch" | "WebSearch", backend: true }`. The query arrives + * with completion: web searches report `action: { query, sources }`, X searches + * the backend call `{ name, input }` with JSON-encoded arguments. + */ +function acpBackendWebSearch( + rawInput: Record | undefined, + rawOutput: Record | undefined, +): + | { readonly query: string | undefined; readonly results: OrchestrationV2WebSearchResult[] } + | undefined { + const variant = typeof rawInput?.variant === "string" ? rawInput.variant.toLowerCase() : ""; + const action = unknownRecord(rawOutput?.action); + if (variant !== "xsearch" && variant !== "websearch" && action?.type !== "search") { + return undefined; + } + let args: Record | undefined; + if (typeof rawOutput?.input === "string") { + try { + args = unknownRecord(JSON.parse(rawOutput.input)); + } catch { + args = undefined; + } + } + const argsText = Object.entries(args ?? {}) + .filter(([, value]) => typeof value === "string" || typeof value === "number") + .map(([key, value]) => `${key}: ${value}`) + .join(", "); + const query = [action?.query, args?.query, argsText] + .find((value): value is string => typeof value === "string" && value.trim().length > 0) + ?.trim(); + const urls = new Set(); + for (const source of Array.isArray(action?.sources) ? action.sources : []) { + const url = unknownRecord(source)?.url; + if (typeof url === "string" && url.trim().length > 0) urls.add(url.trim()); + } + return { query, results: [...urls].map((url) => ({ url })) }; +} + +function providerRequestKind(kind: string | "unknown"): ProviderRequestKind { + switch (kind) { + case "execute": + return "command"; + case "read": + case "search": + case "fetch": + return "file-read"; + case "edit": + case "delete": + case "move": + return "file-change"; + default: + return "command"; + } +} + +function toolStatus( + status: AcpToolCallState["status"], +): "pending" | "running" | "waiting" | "completed" | "failed" { + switch (status) { + case "completed": + return "completed"; + case "failed": + return "failed"; + case "pending": + return "pending"; + case "requiresAction": + return "waiting"; + default: + return "running"; + } +} + +type ProjectedToolStatus = ReturnType | "interrupted"; + +function nodeStatus(status: ProjectedToolStatus): OrchestrationV2ExecutionNode["status"] { + return status === "pending" ? "running" : status; +} + +function completedAtForStatus(status: ProjectedToolStatus, now: DateTime.Utc): DateTime.Utc | null { + return status === "completed" || status === "failed" || status === "interrupted" ? now : null; +} + +function selectPermissionOptionId( + request: EffectAcpSchema.RequestPermissionRequest, + decision: Exclude, +): string | undefined { + const kind = + decision === "acceptForSession" + ? "allow_always" + : decision === "accept" + ? "allow_once" + : "reject_once"; + return request.options.find((option) => option.kind === kind)?.optionId.trim() || undefined; +} + +/** + * The runtime policy approves one request, so answer with the agent's + * allow-once option. Its allow-always option can outlive the session (Grok + * saves it for the whole project); use it only when no allow-once exists. + */ +function selectAutoApprovedPermissionOption( + request: EffectAcpSchema.RequestPermissionRequest, +): string | undefined { + return ( + selectPermissionOptionId(request, "accept") ?? + selectPermissionOptionId(request, "acceptForSession") + ); +} + +function elicitationContent( + answers: ProviderUserInputAnswers, + allowedKeys: ReadonlySet, +): Record { + const content: Record = {}; + for (const [key, value] of Object.entries(answers)) { + if (!allowedKeys.has(key)) continue; + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { + content[key] = value; + } else if (Array.isArray(value)) { + content[key] = value.filter((entry): entry is string => typeof entry === "string"); + } + } + return content; +} + +interface ActiveTextSegment { + readonly nativeItemId: string; + readonly startedAt: DateTime.Utc; + sourceMessageId: string | null; + text: string; +} + +interface ActiveTextStream { + current: ActiveTextSegment | null; + nextSegment: number; +} + +interface AcpNativeBuildConfiguration { + readonly modeId?: string; + readonly configOptions: ReadonlyArray<{ readonly id: string; readonly value: string }>; +} + +interface ActiveAcpTurn { + readonly input: ProviderAdapter.ProviderAdapterV2TurnInput; + readonly providerTurnId: OrchestrationV2ProviderTurn["id"]; + readonly nativeThreadId: string; + readonly nativeTurnId: string; + readonly startedAt: DateTime.Utc; + readonly completed: Deferred.Deferred; + // Root item ordinals allocated in this turn. Subagent child items keep + // theirs on the subagent, which carries over into later turns. + readonly itemOrdinals: Map; + readonly user: ActiveTextStream; + readonly assistant: ActiveTextStream; + readonly reasoning: ActiveTextStream; + providerRetry?: + | { + readonly failure: OrchestrationV2ProviderFailure; + readonly retry: OrchestrationV2ProviderRetry; + readonly startedAt: DateTime.Utc; + readonly itemOrdinal: number; + } + | undefined; + contextUsage: ThreadTokenUsageSnapshot | null; + nativeMetadata: OrchestrationV2ProviderThreadNativeMetadata | null; + readonly tools: Map; + /** Streamed tool updates skipped since the last persisted one; see `shouldPersistToolUpdate`. */ + readonly toolUpdatesSkipped: Map; + readonly toolStartedAt: Map; + readonly subagents: Map; + readonly subagentsBySessionId: Map; + readonly pendingSubagentNotifications: Map>; + /** Background monitor/task id → toolCallId for synthetic root text updates. */ + readonly toolCallIdsByBackgroundTaskId: Map; + /** + * Persistent monitors registered this turn. Excluded from deferred-finalize + * holds so the root turn can settle while they keep streaming post-settle. + */ + readonly persistentBackgroundTaskIds: Set; + /** + * Monitor end events often only say "use get_command…"; keep the turn open + * until TaskOutput hydration arrives (or the safety timeout elapses). + */ + readonly awaitingBackgroundHydration: Set; + /** + * A monitor end notice landed after the prompt settled: the CLI runs an + * injected turn whose report never gets a turn_completed marker, so the + * report chunk races the deferred-finalize debounce (thread a8e8b0a9 run 5 + * dropped the listing this way). Hold finalize until the report streams or + * the safety timeout elapses. + */ + readonly pendingInjectedReport: Set; + /** + * An injected assistant report can race ahead of its monitor end notice. + * Remember that unmatched report so the later notice consumes, rather than + * re-arming, the task's pre-settle completion marker. + */ + earlyInjectedReportObserved: boolean; + readonly plans: Map< + string, + { + readonly id: OrchestrationV2PlanArtifact["id"]; + readonly startedAt: DateTime.Utc; + latest: OrchestrationV2PlanArtifact | null; + } + >; + interrupted: boolean; + finalized: boolean; + finalizedStatus: "completed" | "interrupted" | "failed" | "cancelled" | null; + /** session/prompt already returned; finalize deferred for background work. */ + promptSettled: boolean; + promptSettledStatus: "completed" | "interrupted" | "failed" | "cancelled" | null; + /** + * Completed the moment `runtime.prompt` resolves on the wire, before the + * completion callback requests `runtimeCallbackPermit`. Failure does not + * complete this; settled-soft classification ORs it with `promptSettled`. + */ + readonly promptWireSettled: Deferred.Deferred; + backgroundFinalizeGeneration: number; +} + +type AcpRuntimeTeardownState = + | { readonly _tag: "Idle" } + | { + readonly _tag: "InProgress"; + readonly completed: Deferred.Deferred; + } + | { readonly _tag: "Failed"; readonly error: ProviderAdapter.ProviderAdapterProtocolError }; + +/** True when a root session/update carries ingestible turn output, not keepalive noise. */ +function acpRootSessionUpdateIngestsOutput( + notification: EffectAcpSchema.SessionNotification, +): boolean { + const update = notification.update; + switch (update.sessionUpdate) { + case "agent_message_chunk": + case "agent_thought_chunk": + return (acpContentBlockDisplayText(update.content)?.length ?? 0) > 0; + case "agent_message": + case "agent_thought": + return (update.content ?? []).some( + (content) => (acpContentBlockDisplayText(content)?.length ?? 0) > 0, + ); + case "tool_call": + case "tool_call_update": + case "plan": + case "plan_update": + case "plan_removed": + case "compaction_update": + case "compaction_summary_chunk": + return parseSessionUpdateEvent(notification).events.some( + (event) => event._tag === "ToolCallUpdated" || event._tag === "PlanUpdated", + ); + default: + return false; + } +} + +/** + * Post-settle traffic that should be *buffered* for a continuation attach. + * Excludes monitor end/event chatter that must not become ghost history. + * Broader than {@link acpPostSettleContinuationOfferEvidence}: incremental + * tool progress may still need replay once a real completion offers a run. + */ +export function acpPostSettleWakeEvidence( + notification: EffectAcpSchema.SessionNotification, + flavor: Pick = {}, +): boolean { + if (!acpRootSessionUpdateIngestsOutput(notification)) return false; + const update = notification.update; + if ( + (update.sessionUpdate === "user_message_chunk" || + update.sessionUpdate === "agent_message_chunk") && + update.content.type === "text" + ) { + const text = update.content.text; + if ((flavor.extractBackgroundToolMutation?.(text) ?? []).length > 0) return false; + if (update.sessionUpdate === "agent_message_chunk" && text.trim().length === 0) return false; + if (/ = {}, +): boolean { + if (!acpPostSettleWakeEvidence(notification, flavor)) { + return false; + } + const update = notification.update; + // Assistant text only. Thought/reasoning bursts alone must not open synthetic + // "Background task completed." runs (duplicate-run spam after monitors). + if (update.sessionUpdate === "agent_message_chunk") { + return (acpContentBlockDisplayText(update.content)?.trim().length ?? 0) > 0; + } + if (update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update") { + return parseSessionUpdateEvent(notification).events.some((event) => { + if (event._tag !== "ToolCallUpdated") return false; + // Normalize first: a Grok monitor start ACK arrives with raw status + // "completed" but is a still-running background task, not completion. + const toolCall = flavor.normalizeToolCall?.(event.toolCall) ?? event.toolCall; + return toolCall.status === "completed" || toolCall.status === "failed"; + }); + } + return false; +} + +export function acpPostSettleWakeShouldBuffer( + notification: EffectAcpSchema.SessionNotification, + backgroundWorkRunning: boolean, +): boolean { + if (!backgroundWorkRunning) return true; + const update = notification.update; + return ( + update.sessionUpdate !== "agent_message_chunk" && update.sessionUpdate !== "agent_thought_chunk" + ); +} + +/** + * An app-owned wake (a delegated child finishing) is injected by the + * orchestrator, not typed by the user. It reports on a sibling child and says + * nothing about this session's own pending wake frames, so it must not discard + * them the way a real user turn does. ClaudeAdapterV2 already leaves its buffer + * alone on non-continuation turns; this keeps ACP consistent. + */ +export function acpIsAppOwnedWakeTurn(message: { + readonly createdBy: string; + readonly creationSource: string; +}): boolean { + return message.createdBy === "agent" && message.creationSource === "server"; +} + +/** A continuation run attaches to wake traffic the agent produced on its own. */ +function acpIsProviderContinuationMessage(message: { + readonly createdBy: string; + readonly creationSource: string; +}): boolean { + return message.createdBy === "agent" && message.creationSource === "provider"; +} + +export function acpCarryoverTerminalShouldClearContinuation(input: { + readonly continuationOffered: boolean; + readonly wakeBufferLength: number; +}): boolean { + return !input.continuationOffered && input.wakeBufferLength === 0; +} + +export function acpTurnStartShouldPreserveContinuation(input: { + readonly continuationRequested: boolean; + readonly isContinuationTurn: boolean; + readonly wakeBufferLength: number; +}): boolean { + return !input.isContinuationTurn && input.continuationRequested && input.wakeBufferLength > 0; +} + +export function acpPostSettleMonitorPromptShouldSuppress( + mutation: + | { + readonly taskId: string; + readonly status: "running" | "completed" | "failed"; + } + | undefined, +): boolean { + return mutation?.status === "running"; +} + +export function acpCompletedTurnShouldTerminalizeTool( + tool: AcpToolCallState, + flavor: Pick, +): boolean { + const status = toolStatus(tool.status); + if (status !== "pending" && status !== "running") return false; + if (flavor.extractBackgroundTaskId?.(tool) !== undefined) return false; + return flavor.extractSubagentUpdate?.(tool) === undefined; +} + +interface ActiveAcpSubagent { + task: OrchestrationV2Subagent; + readonly childThreadId: ThreadId; + readonly childRootNodeId: OrchestrationV2ExecutionNode["id"]; + readonly turnItemId: OrchestrationV2TurnItem["id"]; + readonly turnItemOrdinal: number; + /** Turn that spawned the subagent; carryover updates keep this lineage. */ + readonly providerTurnId: OrchestrationV2ProviderTurn["id"]; + readonly parentProviderThreadId: ProviderThreadId; + childSessionId: string | null; + assistantText: string; + readonly assistantMessages: Map; + readonly childItemOrdinals: Map; + nextChildOrdinal: number; + /** + * Whether a terminal carryover status has been projected to events. + * Completed roots project post-settle terminals immediately while their + * subscriber remains observable. Non-completed roots retain terminals in + * memory until the next attach. A later project:true path for the same entry + * must still project once; this flag prevents double emission and pins + * hasPendingBackgroundWork until projection lands. + */ + terminalStatusProjected: boolean; +} + +function acpSubagentStatusIsTerminal(status: OrchestrationV2Subagent["status"]): boolean { + return ( + status === "completed" || + status === "failed" || + status === "interrupted" || + status === "cancelled" || + status === "idle" + ); +} + +export function acpSubagentStatusBlocksTurnSettlement( + status: OrchestrationV2Subagent["status"], +): boolean { + return status === "running" || status === "pending"; +} + +function acpSubagentHasPendingBackgroundWork(subagent: ActiveAcpSubagent): boolean { + return ( + acpSubagentStatusBlocksTurnSettlement(subagent.task.status) || !subagent.terminalStatusProjected + ); +} + +type AcpCarryoverSubagents = { + readonly sessionId: string; + readonly rootTerminalStatus: "completed" | "interrupted" | "failed" | "cancelled"; + readonly subagents: ReadonlyArray; +}; + +type PendingRuntimeRequest = { + readonly generation: number; + readonly nativeResponseAcknowledgement: Deferred.Deferred; + readonly transportRequestId: string; + readonly requestId: RuntimeRequestId; + readonly runtimeRequest: OrchestrationV2RuntimeRequest; + readonly node: OrchestrationV2ExecutionNode; + readonly turnItem: OrchestrationV2TurnItem; +} & ( + | { + readonly type: "approval"; + readonly decision: Deferred.Deferred; + } + | { + readonly type: "user_input"; + readonly answers: Deferred.Deferred; + } +); + +interface SnapshotMessageState { + readonly order: Array; + readonly messages: Map; + loadingRole: "user" | "assistant" | "thought" | null; + loadingMessageId: string | null; + loadingIndex: number; +} + +const TOOL_UPDATE_PERSIST_EVERY = 10; + +/** + * Some agents stream a tool's arguments (a file write's diff, `rawInput`) and + * resend the whole call each time. Persist every 10th of those. Status, title, + * and output the user watches live always persist, as does the agent's own + * completed/failed when a flavor normalizes it to a non-terminal status. + */ +function shouldPersistToolUpdate( + context: ActiveAcpTurn, + key: string, + previous: AcpToolCallState | undefined, + next: AcpToolCallState, + reportedStatus: AcpToolCallState["status"], +): boolean { + const skipped = context.toolUpdatesSkipped.get(key) ?? 0; + const persist = + reportedStatus === "completed" || + reportedStatus === "failed" || + previous === undefined || + previous.status !== next.status || + previous.title !== next.title || + toolCallVisibleOutputChanged(previous, next) || + skipped + 1 >= TOOL_UPDATE_PERSIST_EVERY; + context.toolUpdatesSkipped.set(key, persist ? 0 : skipped + 1); + return persist; +} + +export function makeAcpAdapterV2( + options: AcpAdapterV2Options, +): ProviderAdapter.ProviderAdapterV2Shape { + const { flavor, fileSystem, idAllocator, host, selfInvocation: self } = options; + const driver = flavor.driver; + const continuationRequests = options.continuationRequests; + const postSettleContinuationEnabled = + flavor.enablePostSettleContinuation === true && continuationRequests !== undefined; + + return ProviderAdapter.ProviderAdapterV2.of({ + instanceId: options.instanceId, + driver, + getCapabilities: () => Effect.succeed(flavor.capabilities), + planSelectionTransition: (input) => Effect.succeed(acpSelectionTransition(input)), + openSession: Effect.fn("AcpAdapterV2.openSession")( + function* (input: ProviderAdapter.ProviderAdapterV2OpenSessionInput) { + const sessionScope = yield* Effect.scope; + // Persisted ACP threads from before item identity v2 retain their old + // deterministic ids. Fresh threads scope native ids by instance so + // separately configured agents cannot collide. + let itemIdentityVersion: 2 | undefined = + input.initialProviderItemIdentityVersion ?? + (input.initialNativeThreadId === undefined ? 2 : undefined); + const providerNativeId = (nativeId: string) => + acpProviderItemNativeId({ + instanceId: options.instanceId, + itemIdentityVersion, + nativeId, + }); + const providerNodeId = (nativeItemId: string) => + idAllocator.derive.nodeFromProviderItem({ + driver, + nativeItemId: providerNativeId(nativeItemId), + }); + const providerMessageId = (nativeItemId: string) => + idAllocator.derive.messageFromProviderItem({ + driver, + nativeItemId: providerNativeId(nativeItemId), + }); + const providerTurnItemId = (nativeItemId: string) => + idAllocator.derive.turnItemFromProviderItem({ + driver, + nativeItemId: providerNativeId(nativeItemId), + }); + const deriveProviderTurnId = (nativeTurnId: string) => + idAllocator.derive.providerTurn({ + driver, + nativeTurnId: providerNativeId(nativeTurnId), + }); + const useProviderThreadIdentity = (thread: OrchestrationV2ProviderThread): void => { + itemIdentityVersion = thread.nativeMetadata?.itemIdentityVersion; + }; + const terminalEnvironmentBySessionId = new Map(); + interface PendingTerminalEnvironment { + readonly environment: NodeJS.ProcessEnv | undefined; + readonly claimUnknownSession: boolean; + readonly sessionId: string | null; + } + let pendingTerminalEnvironment: PendingTerminalEnvironment | null = { + environment: acpMcpContext(input.threadId, self).processEnvironment, + claimUnknownSession: input.initialNativeThreadId === undefined, + sessionId: input.initialNativeThreadId ?? null, + }; + const prepareTerminalEnvironment = ( + threadId: ThreadId | null, + sessionId?: string, + ): void => { + pendingTerminalEnvironment = { + environment: acpMcpContext(threadId, self).processEnvironment, + claimUnknownSession: false, + sessionId: sessionId ?? null, + }; + }; + const prepareClaimableTerminalEnvironment = (threadId: ThreadId | null): void => { + pendingTerminalEnvironment = { + environment: acpMcpContext(threadId, self).processEnvironment, + claimUnknownSession: true, + sessionId: null, + }; + }; + const rememberTerminalEnvironment = ( + sessionId: string, + threadId: ThreadId | null, + ): void => { + const environment = acpMcpContext(threadId, self).processEnvironment; + pendingTerminalEnvironment = null; + if (environment === undefined) { + terminalEnvironmentBySessionId.delete(sessionId); + } else { + terminalEnvironmentBySessionId.set(sessionId, environment); + } + }; + const clientTerminals: AcpClientTerminals | undefined = + options.clientTerminals === undefined + ? undefined + : yield* makeAcpClientTerminals({ + spawner: options.clientTerminals.childProcessSpawner, + defaultCwd: input.runtimePolicy.cwd ?? process.cwd(), + environment: options.clientTerminals.environment, + shellCommands: options.clientTerminals.shellCommands, + environmentForSession: (sessionId) => { + const remembered = terminalEnvironmentBySessionId.get(sessionId); + if (remembered !== undefined) return remembered; + if (pendingTerminalEnvironment === null) return undefined; + if ( + pendingTerminalEnvironment.sessionId === null && + pendingTerminalEnvironment.claimUnknownSession + ) { + pendingTerminalEnvironment = { + ...pendingTerminalEnvironment, + sessionId, + }; + } + return pendingTerminalEnvironment.sessionId === sessionId + ? pendingTerminalEnvironment.environment + : undefined; + }, + }); + if (clientTerminals !== undefined) { + yield* Scope.addFinalizer(sessionScope, clientTerminals.disposeAll); + } + // Terminal ids embedded in raw tool_call updates, remembered before the + // content rewrite so emitTool can recover MCP-fallback command lines. + const sessionScopedId = (sessionId: string, nativeId: string): string => + JSON.stringify([sessionId, nativeId]); + const embeddedTerminalsByToolCallId = new Map< + string, + { + readonly sessionId: string; + readonly terminalIds: ReadonlyArray; + readonly toolCallId: string; + } + >(); + const toolCallIdsByAgentTerminalId = new Map>(); + const agentTerminalsById = new Map(); + const rememberEmbeddedTerminals = (input: { + readonly sessionId: string; + readonly toolCallId: string; + readonly terminalIds: ReadonlyArray; + }): void => { + const toolCallKey = sessionScopedId(input.sessionId, input.toolCallId); + for (const terminalId of embeddedTerminalsByToolCallId.get(toolCallKey)?.terminalIds ?? + []) { + const terminalKey = sessionScopedId(input.sessionId, terminalId); + const toolCallIds = toolCallIdsByAgentTerminalId.get(terminalKey); + toolCallIds?.delete(input.toolCallId); + if (toolCallIds?.size === 0) toolCallIdsByAgentTerminalId.delete(terminalKey); + } + embeddedTerminalsByToolCallId.delete(toolCallKey); + embeddedTerminalsByToolCallId.set(toolCallKey, { + sessionId: input.sessionId, + terminalIds: input.terminalIds, + toolCallId: input.toolCallId, + }); + for (const terminalId of input.terminalIds) { + const terminalKey = sessionScopedId(input.sessionId, terminalId); + const toolCallIds = toolCallIdsByAgentTerminalId.get(terminalKey) ?? new Set(); + toolCallIds.add(input.toolCallId); + toolCallIdsByAgentTerminalId.set(terminalKey, toolCallIds); + } + for (const oldest of embeddedTerminalsByToolCallId.keys()) { + if (embeddedTerminalsByToolCallId.size <= 256) break; + const remembered = embeddedTerminalsByToolCallId.get(oldest); + if (remembered === undefined) continue; + for (const terminalId of remembered.terminalIds) { + const terminalKey = sessionScopedId(remembered.sessionId, terminalId); + const toolCallIds = toolCallIdsByAgentTerminalId.get(terminalKey); + toolCallIds?.delete(remembered.toolCallId); + if (toolCallIds?.size === 0) toolCallIdsByAgentTerminalId.delete(terminalKey); + } + embeddedTerminalsByToolCallId.delete(oldest); + } + }; + // Command lines of the terminals embedded in a tool call, so MCP calls + // made through the acp-mcp-call terminal fallback keep their identity. + const embeddedTerminalCommands = ( + sessionId: string, + toolCallId: string, + ): ReadonlyArray => + ( + embeddedTerminalsByToolCallId.get(sessionScopedId(sessionId, toolCallId)) + ?.terminalIds ?? [] + ).flatMap((terminalId) => { + const command = + clientTerminals?.readCommandLine(terminalId) ?? + agentTerminalsById.get(sessionScopedId(sessionId, terminalId))?.command; + return command === undefined ? [] : [command]; + }); + // Client terminals (Devin) run with the T3 server's privileges, so they + // are policy-checked against the active turn policy; a command the user + // already approved satisfies an "ask" disposition. + const clientPolicyGrants = makeAcpClientPolicyGrants(); + let latestRuntimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy = + input.runtimePolicy; + const events = yield* Queue.unbounded(); + const activeTurn = yield* Ref.make(null); + const activeSessionId = yield* Ref.make(null); + const contextUsageBySessionId = yield* Ref.make( + new Map(), + ); + const nativeMetadataBySessionId = yield* Ref.make( + new Map(), + ); + const providerThreadByNativeSessionId = yield* Ref.make( + new Map(), + ); + // T3 only owns the temporary Plan override. Remember the agent's + // effective native configuration on entry and restore it on Build. + const nativeBuildConfigurationBySessionId = new Map(); + const initialSessionActivationFailure = yield* Ref.make<{ + readonly sessionId: string; + readonly error: EffectAcpErrors.AcpError; + } | null>(null); + const activeSessionSetup = + yield* Ref.make(null); + const activeSelection = yield* Ref.make(null); + const activeInteractionMode = yield* Ref.make(null); + const promptInstructionStates = yield* Ref.make(new Map()); + const runtimeRestartRequired = yield* Ref.make(false); + const runtimeTeardownState = yield* Ref.make({ _tag: "Idle" }); + const runtimeCallbackGeneration = yield* Ref.make(0); + const runtimeCallbackGenerationCounter = yield* Ref.make(0); + const allocateRuntimeCallbackGeneration = Ref.updateAndGet( + runtimeCallbackGenerationCounter, + (generation) => generation + 1, + ); + const advanceRuntimeCallbackGeneration = allocateRuntimeCallbackGeneration.pipe( + Effect.tap((generation) => Ref.set(runtimeCallbackGeneration, generation)), + ); + const runtimeCallbackPermit = yield* Semaphore.make(1); + const runtimeTransitionPermit = yield* Semaphore.make(1); + const nativeResponseAcknowledgements = yield* Ref.make( + new Map< + string, + { + readonly acknowledgement: Deferred.Deferred; + readonly generation: number; + } + >(), + ); + const pendingRuntimeRequests = yield* Ref.make(new Map()); + const emitNativeResponseLifecycle = + options.testHooks?.onNativeResponseLifecycle ?? (() => Effect.void); + const nextElicitationOrdinal = yield* Ref.make(0); + const providerTurns = yield* Ref.make(new Map()); + const snapshot = yield* Ref.make({ + order: [], + messages: new Map(), + loadingRole: null, + loadingMessageId: null, + loadingIndex: 0, + }); + + const awaitRuntimeTeardown = Effect.fnUntraced(function* () { + const state = yield* Ref.get(runtimeTeardownState); + if (state._tag === "InProgress") { + yield* Deferred.await(state.completed); + } else if (state._tag === "Failed") { + return yield* state.error; + } + }); + const runRuntimeCallbackAtGeneration = ( + generation: number, + effect: Effect.Effect, + ) => + runtimeCallbackPermit.withPermit( + Effect.gen(function* () { + if ((yield* Ref.get(runtimeTeardownState))._tag !== "Idle") { + return Option.none(); + } + if ((yield* Ref.get(runtimeCallbackGeneration)) !== generation) { + return Option.none(); + } + return Option.some(yield* effect); + }), + ); + const registerNativeResponseAcknowledgement = ( + generation: number, + transportRequestId: string, + acknowledgement: Deferred.Deferred, + ) => + Effect.gen(function* () { + yield* Ref.update(nativeResponseAcknowledgements, (current) => { + const updated = new Map(current); + updated.set(transportRequestId, { acknowledgement, generation }); + return updated; + }); + yield* emitNativeResponseLifecycle({ + type: "registered", + generation, + requestId: transportRequestId, + }); + if (!(yield* Deferred.isDone(acknowledgement))) return; + yield* Ref.update(nativeResponseAcknowledgements, (current) => { + if (current.get(transportRequestId)?.acknowledgement !== acknowledgement) { + return current; + } + const updated = new Map(current); + updated.delete(transportRequestId); + return updated; + }); + }); + const acknowledgeNativeResponse = (generation: number, transportRequestId: string) => + ( + options.testHooks?.beforeNativeResponseAdmissionCheck?.( + generation, + transportRequestId, + ) ?? Effect.void + ).pipe( + Effect.andThen(runRuntimeCallbackAtGeneration(generation, Effect.void)), + Effect.flatMap((registered) => + Option.isSome(registered) + ? Effect.void + : emitNativeResponseLifecycle({ + type: "admission_rejected", + generation, + requestId: transportRequestId, + }).pipe( + Effect.andThen( + new EffectAcpErrors.AcpTransportError({ + detail: "The ACP runtime closed before its response could be admitted", + cause: "Native response registration rejected during teardown", + }), + ), + ), + ), + ); + const awaitNativeResponseAcknowledgements = Effect.fnUntraced(function* ( + acknowledgements: ReadonlyArray< + readonly [string | undefined, Deferred.Deferred] + >, + ) { + if (acknowledgements.length === 0) return true; + const completed = yield* Deferred.make<"settled" | "timeout">(); + yield* emitNativeResponseLifecycle({ type: "watcher_started" }); + const acknowledgementFiber = yield* Effect.forEach( + acknowledgements, + ([, acknowledgement]) => Deferred.await(acknowledgement).pipe(Effect.exit), + { concurrency: "unbounded", discard: true }, + ).pipe( + Effect.andThen(Deferred.succeed(completed, "settled")), + Effect.interruptible, + Effect.ensuring(emitNativeResponseLifecycle({ type: "watcher_exited" })), + Effect.forkDetach, + ); + yield* emitNativeResponseLifecycle({ type: "timer_started" }); + const timerFiber = yield* Effect.sleep("2 seconds").pipe( + Effect.andThen(Deferred.succeed(completed, "timeout")), + Effect.interruptible, + Effect.ensuring(emitNativeResponseLifecycle({ type: "timer_exited" })), + Effect.forkDetach, + ); + const outcome = yield* Deferred.await(completed); + yield* Fiber.interrupt(acknowledgementFiber); + yield* Fiber.interrupt(timerFiber); + if (outcome === "settled") return true; + + yield* Ref.update(nativeResponseAcknowledgements, (current) => { + const updated = new Map(current); + for (const [requestId, acknowledgement] of acknowledgements) { + if ( + requestId !== undefined && + updated.get(requestId)?.acknowledgement === acknowledgement + ) { + updated.delete(requestId); + } + } + return updated; + }); + yield* Effect.forEach( + acknowledgements, + ([requestId]) => + emitNativeResponseLifecycle({ + type: "removed", + ...(requestId === undefined ? {} : { requestId }), + }), + { concurrency: "unbounded", discard: true }, + ); + const timeoutError = new EffectAcpErrors.AcpTransportError({ + detail: "Timed out waiting for an admitted ACP response to reach the transport queue", + cause: "Native response acknowledgement timed out", + }); + yield* Effect.forEach( + acknowledgements, + ([requestId, acknowledgement]) => + Deferred.fail(acknowledgement, timeoutError).pipe( + Effect.andThen( + emitNativeResponseLifecycle({ + type: "failed", + ...(requestId === undefined ? {} : { requestId }), + }), + ), + ), + { concurrency: "unbounded", discard: true }, + ); + return false; + }); + const awaitAdmittedNativeResponses = Ref.get(nativeResponseAcknowledgements).pipe( + Effect.flatMap((current) => + awaitNativeResponseAcknowledgements( + [...current.entries()].map( + ([requestId, entry]) => [requestId, entry.acknowledgement] as const, + ), + ), + ), + ); + const quarantineNativeTransportAtGeneration = Effect.fnUntraced(function* ( + generation: number, + ) { + const quarantined = yield* Ref.modify(nativeResponseAcknowledgements, (current) => { + const updated = new Map(current); + const acknowledgements: Array> = []; + for (const [requestId, entry] of updated) { + if (entry.generation !== generation) continue; + updated.delete(requestId); + acknowledgements.push(entry.acknowledgement); + } + return [acknowledgements, updated] as const; + }); + const error = new EffectAcpErrors.AcpTransportError({ + detail: "The ACP runtime was replaced before its response reached the transport queue", + cause: "ACP runtime transport was quarantined during teardown", + }); + yield* Effect.forEach( + quarantined, + (acknowledgement) => Deferred.fail(acknowledgement, error), + { concurrency: "unbounded", discard: true }, + ); + }); + const closeNativeTransport = runtimeCallbackPermit.withPermit( + Effect.gen(function* () { + yield* advanceRuntimeCallbackGeneration; + const acknowledgements = yield* Ref.getAndSet( + nativeResponseAcknowledgements, + new Map(), + ); + const error = new EffectAcpErrors.AcpTransportError({ + detail: "The ACP session closed before its admitted response reached the transport", + cause: "ACP session transport closed", + }); + yield* Effect.forEach( + acknowledgements, + ([requestId, entry]) => + Deferred.fail(entry.acknowledgement, error).pipe( + Effect.andThen( + emitNativeResponseLifecycle({ + type: "removed", + generation: entry.generation, + requestId, + }), + ), + Effect.andThen( + emitNativeResponseLifecycle({ + type: "failed", + generation: entry.generation, + requestId, + }), + ), + ), + { concurrency: "unbounded", discard: true }, + ); + return acknowledgements.size > 0; + }), + ); + // Post-settle wake support (Grok async subagent/monitor follow-up). After + // the root turn finalizes, later root session/update traffic buffers here + // until a provider continuation run attaches and drains it. + const lastTurnRoute = yield* Ref.make<{ + readonly threadId: ThreadId; + readonly providerThreadId: ProviderThreadId; + } | null>(null); + const wakeBuffer = yield* Ref.make>([]); + // Background work that ended after the prompt settled, keyed by task or + // child session id. The next continuation offer names it for the user; + // `offered` holds the keys that offer named. Work that ends while the + // offer waits for its turn is named by the offer after it. + const noWakeReports = { + reports: new Map(), + offered: new Set(), + }; + const wakeReports = yield* Ref.make<{ + readonly reports: ReadonlyMap; + readonly offered: ReadonlySet; + }>(noWakeReports); + const recordWakeReport = Effect.fnUntraced(function* ( + key: string, + report: BackgroundWorkReport, + ) { + const context = yield* Ref.get(activeTurn); + // An open prompt reports the work itself; no continuation follows. + if (context !== null && !context.promptSettled && !context.finalized) return; + yield* Ref.update(wakeReports, ({ reports, offered }) => ({ + reports: new Map(reports).set(key, report), + offered, + })); + }); + const continuationRequested = yield* Ref.make(false); + const continuationGeneration = yield* Ref.make(0); + const continuationPermit = yield* Semaphore.make(1); + const continuationClosed = yield* Ref.make(false); + // Direct Stop (requestRuntimeRestart) quarantines residual events from the + // stopped run so they cannot wake or attach to a later prompt/run. + const stoppedRunQuarantine = yield* Ref.make(false); + // A steering restart (or any interrupt) can finalize a turn while its + // spawned subagents are still running natively. Carry the live + // lineages into the next turn on the same session so their terminal + // signals can still flip the original turn items instead of leaving + // them running forever. + const carryoverSubagents = yield* Ref.make(null); + const handledBackgroundTaskIdsInActiveTurn = yield* Ref.make>( + new Set(), + ); + // Background tasks that reached a genuine terminal mutation while a root + // turn was still streaming and were not yet marked handled in-turn. The + // mid-turn offer is suppressed (active turn owns the work); on finalize + // these ids re-arm a single continuation when the agent never hydrated + // or reported them before settle (regression: mid-turn complete + no + // get_command must still wake after finalize). + const midTurnUnreportedCompletedTaskIds = yield* Ref.make>(new Set()); + // A monitor-event can arrive after its task and the user-facing provider + // turn already completed. Grok starts another internal prompt for that + // stale notification; suppress its agent output until a genuine terminal + // mutation or the next app turn so it cannot create a redundant app + // continuation. Tool frames continue through normal hydration. + const suppressPostSettleMonitorPrompt = yield* Ref.make(false); + // Background tasks (Grok monitors) known to still run at session level. + // Turn contexts are too short-lived to carry this: a continuation run + // finalizes between monitor events, and the next commentary burst must + // not reopen a run while the monitor is still streaming. + const runningBackgroundTaskIds = yield* Ref.make>(new Set()); + // Task ids with a GENUINE end signal (monitor-ended reminder or + // TaskOutput completion). Normalized tool statuses are not genuine: + // Grok Bash re-reports carry exit_code 0 mid-stream. A straggler + // monitor-event can land after the real end (the CLI keeps streaming + // while the agent already consumed the output via + // get_command_or_subagent_output); without the tombstone it would + // resurrect the running set and pin offers/idle-release forever. + // A tool-level failed get_command tombstones too: failing open to a + // single continuation beats failing closed to a dead thread. + const endedBackgroundTaskIds = yield* Ref.make>(new Set()); + const endedBackgroundTaskIdLimit = 128; + + const setBackgroundTaskRunning = (taskId: string, running: boolean) => + Effect.gen(function* () { + if (running && (yield* Ref.get(endedBackgroundTaskIds)).has(taskId)) { + return; + } + yield* Ref.update(runningBackgroundTaskIds, (current) => { + if (current.has(taskId) === running) return current; + const next = new Set(current); + if (running) { + next.add(taskId); + } else { + next.delete(taskId); + } + return next; + }); + }); + + const markBackgroundTaskEnded = (taskId: string) => + Ref.update(endedBackgroundTaskIds, (current) => { + if (current.has(taskId)) return current; + const next = new Set(current).add(taskId); + for (const oldest of next) { + if (next.size <= endedBackgroundTaskIdLimit) break; + next.delete(oldest); + } + return next; + }).pipe(Effect.andThen(setBackgroundTaskRunning(taskId, false))); + + const applyBackgroundTaskMutationRunning = (mutation: { + readonly taskId: string; + readonly status: "running" | "completed" | "failed"; + }) => + mutation.status === "running" + ? setBackgroundTaskRunning(mutation.taskId, true) + : markBackgroundTaskEnded(mutation.taskId); + + const trackRunningBackgroundTools = ( + notification: EffectAcpSchema.SessionNotification, + ): Effect.Effect => + Effect.gen(function* () { + if (flavor.extractBackgroundTaskId === undefined) return; + for (const event of parseSessionUpdateEvent(notification).events) { + if (event._tag !== "ToolCallUpdated") continue; + const toolCall = flavor.normalizeToolCall?.(event.toolCall) ?? event.toolCall; + const taskId = flavor.extractBackgroundTaskId(toolCall); + if (taskId === undefined) continue; + const status = toolStatus(toolCall.status); + yield* setBackgroundTaskRunning(taskId, status === "pending" || status === "running"); + } + }); + + const emitProviderEvent = (event: ProviderAdapter.ProviderAdapterV2Event) => + Queue.offer(events, event).pipe(Effect.asVoid); + let scheduleDeferredFinalize: (context: ActiveAcpTurn) => Effect.Effect = () => + Effect.void; + + const nativeLogging = options.nativeLogging?.(input.threadId); + const handleRuntimeTerminationAtGeneration = (runtimeGeneration: number) => + runRuntimeCallbackAtGeneration( + runtimeGeneration, + Ref.set(runtimeRestartRequired, true), + ).pipe(Effect.asVoid); + const makeRuntimeInput = ( + runtimeGeneration: number, + threadId: ThreadId | null, + resumeSessionId?: string, + onTermination: AcpAdapterV2RuntimeInput["onTermination"] = () => + handleRuntimeTerminationAtGeneration(runtimeGeneration), + ): AcpAdapterV2RuntimeInput => { + const mcpContext = acpMcpContext(threadId, self); + return { + cwd: input.runtimePolicy.cwd ?? process.cwd(), + runtimePolicy: input.runtimePolicy, + mcpServers: mcpContext.servers, + acpMcpServers: mcpContext.acpServers, + ...(mcpContext.processEnvironment === undefined + ? {} + : { processEnvironment: mcpContext.processEnvironment }), + ...(resumeSessionId === undefined ? {} : { resumeSessionId }), + interruptPromptOnCancel: flavor.interruptPromptOnCancel ?? false, + clientCapabilities: { + fs: { + readTextFile: flavor.clientFileSystem !== undefined, + writeTextFile: flavor.clientFileSystem !== undefined, + }, + terminal: clientTerminals !== undefined, + elicitation: { form: {}, ...(flavor.onUrlElicitation ? { url: {} } : {}) }, + ...(flavor.clientCapabilitiesMeta ? { _meta: flavor.clientCapabilitiesMeta } : {}), + }, + clientInfo: { name: "t3-code", version: "0.0.0" }, + onTermination, + onOutgoingResponseFailure: (requestId, error) => + Ref.modify(nativeResponseAcknowledgements, (current) => { + const entry = current.get(requestId); + if (entry === undefined || entry.generation !== runtimeGeneration) { + return [ + emitNativeResponseLifecycle({ + type: "late_noop", + generation: runtimeGeneration, + requestId, + }), + current, + ] as const; + } + const updated = new Map(current); + updated.delete(requestId); + return [ + Deferred.fail(entry.acknowledgement, error).pipe( + Effect.andThen( + emitNativeResponseLifecycle({ + type: "removed", + generation: runtimeGeneration, + requestId, + }), + ), + Effect.asVoid, + ), + updated, + ] as const; + }).pipe(Effect.flatten), + onOutgoingResponse: (requestId) => + Ref.modify(nativeResponseAcknowledgements, (current) => { + const entry = current.get(requestId); + if (entry === undefined || entry.generation !== runtimeGeneration) { + return [ + emitNativeResponseLifecycle({ + type: "late_noop", + generation: runtimeGeneration, + requestId, + }), + current, + ] as const; + } + const updated = new Map(current); + updated.delete(requestId); + return [ + Deferred.succeed(entry.acknowledgement, undefined).pipe( + Effect.andThen( + emitNativeResponseLifecycle({ + type: "removed", + generation: runtimeGeneration, + requestId, + }), + ), + Effect.asVoid, + ), + updated, + ] as const; + }).pipe(Effect.flatten), + ...(nativeLogging?.requestLogger === undefined + ? {} + : { requestLogger: nativeLogging.requestLogger }), + protocolLogging: nativeLogging?.protocolLogging ?? { + logIncoming: true, + logOutgoing: true, + logger: () => Effect.void, + }, + }; + }; + let runtimeScope: Scope.Closeable | undefined; + let runtime!: AcpSessionRuntime.AcpSessionRuntime["Service"]; + let runtimeMcpBridge: AcpMcpOverAcpBridge | undefined; + yield* Effect.addFinalizer(() => + runtimeScope === undefined + ? Effect.void + : Scope.close(runtimeScope, Exit.void).pipe(Effect.ignore), + ); + + const resolveItemOrdinal = (context: ActiveAcpTurn, nativeItemId: string) => + Effect.sync(() => { + const existing = context.itemOrdinals.get(nativeItemId); + if (existing !== undefined) return existing; + const ordinal = context.input.providerTurnOrdinal * 100 + context.itemOrdinals.size + 1; + context.itemOrdinals.set(nativeItemId, ordinal); + return ordinal; + }); + + const resolveSubagentChildOrdinal = (subagent: ActiveAcpSubagent, nativeItemId: string) => { + const existing = subagent.childItemOrdinals.get(nativeItemId); + if (existing !== undefined) return existing; + const ordinal = subagent.nextChildOrdinal++; + subagent.childItemOrdinals.set(nativeItemId, ordinal); + return ordinal; + }; + + const emitProviderRetry = Effect.fnUntraced(function* ( + context: ActiveAcpTurn, + status: "running" | "completed" | "interrupted" | "cancelled", + ) { + const state = context.providerRetry; + if (state === undefined) return; + yield* emitProviderEvent({ + type: "turn_item.updated", + driver, + turnItem: makeProviderRetryTurnItem({ + idAllocator, + driver, + threadId: context.input.threadId, + runId: context.input.runId, + nodeId: context.input.rootNodeId, + providerThreadId: context.input.providerThread.id, + providerTurnId: context.providerTurnId, + ...state, + status, + updatedAt: yield* DateTime.now, + }), + }); + if (status !== "running") context.providerRetry = undefined; + }); + + const reportProviderRetry = Effect.fnUntraced(function* (notice: { + readonly sessionId: string; + readonly failure: OrchestrationV2ProviderFailure; + }) { + const context = yield* Ref.get(activeTurn); + if ( + context === null || + context.finalized || + context.interrupted || + context.nativeThreadId !== notice.sessionId || + (yield* Ref.get(stoppedRunQuarantine)) + ) + return; + const previous = context.providerRetry; + context.providerRetry = { + failure: notice.failure, + retry: { + attempt: (previous?.retry.attempt ?? 0) + 1, + maxAttempts: null, + retryDelayMs: null, + }, + startedAt: previous?.startedAt ?? (yield* DateTime.now), + itemOrdinal: + previous?.itemOrdinal ?? + (yield* resolveItemOrdinal(context, `terminal-failure:${context.providerTurnId}`)), + }; + yield* emitProviderRetry(context, "running"); + }); + + const lastCapturedProposedPlan = yield* Ref.make<{ + readonly nativeTurnId: string; + readonly markdown: string; + readonly planId: OrchestrationV2PlanArtifact["id"]; + } | null>(null); + /** + * Emits a completed proposed-plan artifact for the active turn. Same + * plan id per turn so repeated captures (plan.md rewrites, the exit + * gate) update one card; identical markdown within a turn is a no-op. + */ + const captureProposedPlan = Effect.fnUntraced(function* (input: { + readonly planMarkdown: string; + }) { + const context = yield* Ref.get(activeTurn); + if (context === null) return; + const markdown = input.planMarkdown.trim(); + if (markdown.length === 0) return; + const previous = yield* Ref.get(lastCapturedProposedPlan); + if ( + previous !== null && + previous.nativeTurnId === context.nativeTurnId && + previous.markdown === markdown + ) { + return; + } + const nativeItemId = `${context.nativeTurnId}:proposed-plan`; + const planId = + previous !== null && previous.nativeTurnId === context.nativeTurnId + ? previous.planId + : yield* idAllocator.allocate + .plan({ + threadId: context.input.threadId, + runId: context.input.runId, + driver, + }) + .pipe(Effect.orDie); + yield* Ref.set(lastCapturedProposedPlan, { + nativeTurnId: context.nativeTurnId, + markdown, + planId, + }); + const now = yield* DateTime.now; + const nodeId = idAllocator.derive.nodeFromProviderItem({ driver, nativeItemId }); + const turnItemId = idAllocator.derive.turnItemFromProviderItem({ driver, nativeItemId }); + const ordinal = yield* resolveItemOrdinal(context, nativeItemId); + const nativeItemRef = { driver, nativeId: nativeItemId, strength: "weak" as const }; + yield* emitProviderEvent({ + type: "node.updated", + driver, + node: { + id: nodeId, + threadId: context.input.threadId, + runId: context.input.runId, + parentNodeId: context.input.rootNodeId, + rootNodeId: context.input.rootNodeId, + kind: "plan", + status: "completed", + countsForRun: false, + providerThreadId: context.input.providerThread.id, + providerTurnId: context.providerTurnId, + nativeItemRef, + runtimeRequestId: null, + checkpointScopeId: null, + startedAt: context.startedAt, + completedAt: now, + }, + }); + yield* emitProviderEvent({ + type: "plan.updated", + driver, + plan: { + id: planId, + threadId: context.input.threadId, + runId: context.input.runId, + nodeId, + kind: "proposed_plan", + status: "completed", + markdown, + }, + }); + yield* emitProviderEvent({ + type: "turn_item.updated", + driver, + turnItem: { + id: turnItemId, + threadId: context.input.threadId, + runId: context.input.runId, + nodeId, + providerThreadId: context.input.providerThread.id, + providerTurnId: context.providerTurnId, + nativeItemRef, + parentItemId: null, + ordinal, + status: "completed", + title: null, + startedAt: context.startedAt, + completedAt: now, + updatedAt: now, + type: "proposed_plan", + planId, + markdown, + streaming: false, + }, + }); + }); + const lastProposedPlanMarkdown = Effect.gen(function* () { + const context = yield* Ref.get(activeTurn); + const previous = yield* Ref.get(lastCapturedProposedPlan); + return context !== null && previous?.nativeTurnId === context.nativeTurnId + ? previous.markdown + : undefined; + }); + + const rememberSnapshotMessage = (message: OrchestrationV2ConversationMessage) => + Ref.update(snapshot, (current) => { + const key = String(message.id); + const exists = current.messages.has(key); + const messages = new Map(current.messages); + messages.set(key, message); + return { + ...current, + order: exists ? current.order : [...current.order, key], + messages, + }; + }); + + const textStreamFor = ( + context: ActiveAcpTurn, + kind: "user" | "assistant" | "reasoning", + ): ActiveTextStream => + kind === "user" + ? context.user + : kind === "assistant" + ? context.assistant + : context.reasoning; + + const emitTextSegment = Effect.fnUntraced(function* ( + context: ActiveAcpTurn, + kind: "user" | "assistant" | "reasoning", + completed: boolean, + ) { + const stream = textStreamFor(context, kind); + const segment = stream.current; + if (segment === null) return; + const now = yield* DateTime.now; + const ordinal = yield* resolveItemOrdinal(context, segment.nativeItemId); + const nodeId = providerNodeId(segment.nativeItemId); + const turnItemId = providerTurnItemId(segment.nativeItemId); + const nativeItemRef = { + driver, + nativeId: segment.nativeItemId, + strength: "weak" as const, + }; + if (kind !== "user") { + yield* emitProviderEvent({ + type: "node.updated", + driver, + node: { + id: nodeId, + threadId: context.input.threadId, + runId: context.input.runId, + parentNodeId: context.input.rootNodeId, + rootNodeId: context.input.rootNodeId, + kind: kind === "assistant" ? "assistant_message" : "reasoning", + status: completed ? "completed" : "running", + countsForRun: false, + providerThreadId: context.input.providerThread.id, + providerTurnId: context.providerTurnId, + nativeItemRef, + runtimeRequestId: null, + checkpointScopeId: null, + startedAt: segment.startedAt, + completedAt: completed ? now : null, + }, + }); + } + if (kind !== "reasoning") { + const messageId = providerMessageId(segment.nativeItemId); + const messageNodeId = kind === "user" ? context.input.rootNodeId : nodeId; + const message: OrchestrationV2ConversationMessage = { + createdBy: kind === "user" ? "user" : "agent", + creationSource: "provider", + id: messageId, + threadId: context.input.threadId, + runId: context.input.runId, + nodeId: messageNodeId, + role: kind, + text: segment.text, + attachments: [], + streaming: !completed, + createdAt: segment.startedAt, + updatedAt: now, + }; + yield* emitProviderEvent({ type: "message.updated", driver, message }); + yield* emitProviderEvent( + kind === "user" + ? { + type: "turn_item.updated", + driver, + turnItem: { + createdBy: "user", + creationSource: "provider", + id: turnItemId, + threadId: context.input.threadId, + runId: context.input.runId, + nodeId: messageNodeId, + providerThreadId: context.input.providerThread.id, + providerTurnId: context.providerTurnId, + nativeItemRef, + parentItemId: null, + ordinal, + status: completed ? "completed" : "running", + title: null, + startedAt: segment.startedAt, + completedAt: completed ? now : null, + updatedAt: now, + type: "user_message", + messageId, + inputIntent: "turn_start", + text: segment.text, + attachments: [], + }, + } + : { + type: "turn_item.updated", + driver, + turnItem: { + id: turnItemId, + threadId: context.input.threadId, + runId: context.input.runId, + nodeId, + providerThreadId: context.input.providerThread.id, + providerTurnId: context.providerTurnId, + nativeItemRef, + parentItemId: null, + ordinal, + status: completed ? "completed" : "running", + title: null, + startedAt: segment.startedAt, + completedAt: completed ? now : null, + updatedAt: now, + type: "assistant_message", + messageId, + text: segment.text, + streaming: !completed, + }, + }, + ); + if (completed) yield* rememberSnapshotMessage(message); + return; + } + yield* emitProviderEvent({ + type: "turn_item.updated", + driver, + turnItem: { + id: turnItemId, + threadId: context.input.threadId, + runId: context.input.runId, + nodeId, + providerThreadId: context.input.providerThread.id, + providerTurnId: context.providerTurnId, + nativeItemRef, + parentItemId: null, + ordinal, + status: completed ? "completed" : "running", + title: null, + startedAt: segment.startedAt, + completedAt: completed ? now : null, + updatedAt: now, + type: "reasoning", + text: segment.text, + streaming: !completed, + }, + }); + }); + + const closeTextStream = Effect.fnUntraced(function* ( + context: ActiveAcpTurn, + kind: "user" | "assistant" | "reasoning", + ) { + const stream = textStreamFor(context, kind); + if (stream.current === null) return; + yield* emitTextSegment(context, kind, true); + stream.current = null; + }); + + const closeTextStreams = Effect.fnUntraced(function* (context: ActiveAcpTurn) { + yield* closeTextStream(context, "user"); + yield* closeTextStream(context, "reasoning"); + yield* closeTextStream(context, "assistant"); + }); + + const appendText = Effect.fnUntraced(function* ( + context: ActiveAcpTurn, + kind: "user" | "assistant" | "reasoning", + text: string, + messageId?: string | null, + ) { + if (text.length === 0) return; + for (const other of ["user", "reasoning", "assistant"] as const) { + if (other !== kind) yield* closeTextStream(context, other); + } + const stream = textStreamFor(context, kind); + const sourceMessageId = messageId?.trim() || null; + if ( + stream.current !== null && + sourceMessageId !== null && + stream.current.sourceMessageId !== null && + stream.current.sourceMessageId !== sourceMessageId + ) { + yield* closeTextStream(context, kind); + } + if (stream.current === null) { + const now = yield* DateTime.now; + stream.current = { + nativeItemId: + sourceMessageId === null + ? `${context.nativeTurnId}:${kind}:${stream.nextSegment}` + : `${context.nativeTurnId}:${kind}:message:${sourceMessageId}`, + startedAt: now, + sourceMessageId, + text: "", + }; + stream.nextSegment += 1; + } else if (stream.current.sourceMessageId === null && sourceMessageId !== null) { + // Some agents omit messageId on the first chunk. Keep the already + // projected identity and use the first later id as its boundary. + stream.current.sourceMessageId = sourceMessageId; + } + stream.current.text += text; + yield* emitTextSegment(context, kind, false); + }); + + const replaceText = Effect.fnUntraced(function* ( + context: ActiveAcpTurn, + kind: "user" | "assistant" | "reasoning", + text: string, + messageId: string, + ) { + for (const other of ["user", "reasoning", "assistant"] as const) { + if (other !== kind) yield* closeTextStream(context, other); + } + const stream = textStreamFor(context, kind); + if (stream.current?.sourceMessageId !== messageId) { + yield* closeTextStream(context, kind); + const now = yield* DateTime.now; + stream.current = { + nativeItemId: `${context.nativeTurnId}:${kind}:message:${messageId}`, + startedAt: now, + sourceMessageId: messageId, + text, + }; + stream.nextSegment += 1; + } else { + stream.current.text = text; + } + yield* emitTextSegment(context, kind, false); + }); + + const emitSubagentAssistant = Effect.fnUntraced(function* ( + subagent: ActiveAcpSubagent, + text: string, + mode: "append" | "replace" = "append", + messageId?: string | null, + ) { + if (text.length === 0 && mode === "append") return; + const nativeItemId = `${subagent.task.nativeTaskRef?.nativeId ?? subagent.task.id}:message:${messageId ?? "result"}`; + const previous = subagent.assistantMessages.get(nativeItemId) ?? ""; + const messageText = mode === "replace" ? text : `${previous}${text}`; + subagent.assistantMessages.set(nativeItemId, messageText); + subagent.assistantText = messageText; + const now = yield* DateTime.now; + const ordinal = resolveSubagentChildOrdinal(subagent, nativeItemId); + const artifacts = makeSubagentConversationArtifacts({ + messageId: providerMessageId(nativeItemId), + turnItemId: providerTurnItemId(nativeItemId), + threadId: subagent.childThreadId, + rootNodeId: subagent.childRootNodeId, + providerThreadId: subagent.task.providerThreadId, + providerTurnId: null, + nativeItemRef: { driver, nativeId: nativeItemId, strength: "weak" }, + role: "assistant", + text: subagent.assistantText, + ordinal, + now, + }); + yield* emitProviderEvent({ type: "message.updated", driver, message: artifacts.message }); + yield* emitProviderEvent({ + type: "turn_item.updated", + driver, + turnItem: artifacts.turnItem, + }); + }); + + const projectSubagentNotification = Effect.fnUntraced(function* ( + subagent: ActiveAcpSubagent, + notification: EffectAcpSchema.SessionNotification, + ) { + const update = notification.update; + if (update.sessionUpdate === "agent_message_chunk") { + const text = acpContentBlockDisplayText(update.content); + if (text !== undefined) { + yield* emitSubagentAssistant(subagent, text, "append", update.messageId); + } + } else if (update.sessionUpdate === "agent_message") { + if (update.content === undefined) return; + const text = (update.content ?? []) + .flatMap((content) => { + const display = acpContentBlockDisplayText(content); + return display === undefined ? [] : [display]; + }) + .join("\n"); + yield* emitSubagentAssistant(subagent, text, "replace", update.messageId); + } + }); + + const emitSubagent = Effect.fnUntraced(function* ( + context: ActiveAcpTurn, + update: AcpAdapterV2SubagentUpdate, + ) { + // Hydration tools (get_command_or_subagent_output) use a new toolCallId + // but reference the child via subagent_id / task id. + const existing = + context.subagents.get(update.nativeTaskId) ?? + (update.childSessionId !== null + ? context.subagentsBySessionId.get(update.childSessionId) + : undefined); + const updateIsTerminal = acpSubagentStatusIsTerminal(update.status); + if ( + existing !== undefined && + acpSubagentStatusIsTerminal(existing.task.status) && + !updateIsTerminal + ) { + return; + } + if ( + existing !== undefined && + existing.task.status === update.status && + updateIsTerminal && + existing.terminalStatusProjected + ) { + return; + } + // get_command_or_subagent_output may target monitors/bash tasks. Only + // hydrate when we already have a matching subagent lineage. Spawn ACKs + // (non-empty prompt) may create a new lineage; empty-prompt hydration + // with suppressNormalTool must not invent a phantom subagent. + if (existing === undefined) { + const isHydrationOnly = update.prompt === "" && update.title === null; + if (isHydrationOnly || update.suppressNormalTool === false) { + return; + } + } + const now = yield* DateTime.now; + const nativeTaskId = existing?.task.nativeTaskRef?.nativeId ?? update.nativeTaskId; + const nativeItemRef = { + driver, + nativeId: nativeTaskId, + strength: "strong" as const, + }; + const nodeId = existing?.task.id ?? providerNodeId(nativeTaskId); + const childThreadId = + existing?.childThreadId ?? + idAllocator.derive.threadFromProviderThread({ + driver, + providerInstanceId: context.input.modelSelection.instanceId, + nativeThreadId: `${context.nativeThreadId}:task:${nativeTaskId}`, + }); + const childRootNodeId = + existing?.childRootNodeId ?? providerNodeId(`${nativeTaskId}:child-root`); + const turnItemId = existing?.turnItemId ?? providerTurnItemId(nativeTaskId); + const turnItemOrdinal = + existing?.turnItemOrdinal ?? (yield* resolveItemOrdinal(context, nativeTaskId)); + const taskStatus = update.status; + const task: OrchestrationV2Subagent = { + ...(existing?.task ?? { + id: nodeId, + threadId: context.input.threadId, + runId: context.input.runId, + parentNodeId: context.input.rootNodeId, + origin: "provider_native" as const, + createdBy: "agent" as const, + driver, + providerInstanceId: context.input.modelSelection.instanceId, + providerThreadId: null, + childThreadId, + nativeTaskRef: nativeItemRef, + prompt: update.prompt, + title: update.title, + result: null, + startedAt: now, + }), + model: update.model?.trim() || existing?.task.model || null, + status: taskStatus, + result: update.result ?? existing?.task.result ?? null, + completedAt: acpSubagentStatusIsTerminal(taskStatus) ? now : null, + updatedAt: now, + }; + const subagent: ActiveAcpSubagent = existing ?? { + task, + childThreadId, + childRootNodeId, + turnItemId, + turnItemOrdinal, + providerTurnId: context.providerTurnId, + parentProviderThreadId: context.input.providerThread.id, + childSessionId: null, + assistantText: "", + assistantMessages: new Map(), + childItemOrdinals: new Map(), + nextChildOrdinal: 101, + terminalStatusProjected: false, + }; + subagent.task = task; + context.subagents.set(nativeTaskId, subagent); + + if (existing === undefined) { + yield* emitProviderEvent({ + type: "app_thread.created", + driver, + appThread: makeSubagentChildThread({ + parentThread: context.input.appThread, + childThreadId, + parentNodeId: nodeId, + activeProviderThreadId: null, + providerInstanceId: context.input.modelSelection.instanceId, + modelSelection: { + ...context.input.modelSelection, + model: task.model ?? context.input.modelSelection.model, + }, + title: subagentThreadTitle({ + parentTitle: context.input.appThread.title, + title: update.title, + prompt: update.prompt, + ordinal: context.subagents.size, + }), + now, + createdBy: "agent", + creationSource: "provider", + }), + }); + const promptNativeItemId = `${nativeTaskId}:prompt`; + const promptArtifacts = makeSubagentConversationArtifacts({ + senderThreadId: context.input.threadId, + messageId: providerMessageId(promptNativeItemId), + turnItemId: providerTurnItemId(promptNativeItemId), + threadId: childThreadId, + rootNodeId: childRootNodeId, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: { driver, nativeId: promptNativeItemId, strength: "weak" }, + role: "user", + text: update.prompt, + ordinal: 100, + now, + }); + yield* emitProviderEvent({ + type: "message.updated", + driver, + message: promptArtifacts.message, + }); + yield* emitProviderEvent({ + type: "turn_item.updated", + driver, + turnItem: promptArtifacts.turnItem, + }); + } + + const childSessionId = update.childSessionId; + if (childSessionId !== null && subagent.childSessionId === null) { + subagent.childSessionId = childSessionId; + context.subagentsBySessionId.set(childSessionId, subagent); + const providerThread = makeProviderThread({ + driver, + providerInstanceId: context.input.modelSelection.instanceId, + idAllocator, + appThreadId: childThreadId, + providerSessionId: input.providerSessionId, + nativeThreadId: childSessionId, + ...(itemIdentityVersion === undefined ? {} : { itemIdentityVersion }), + forkedFrom: { + providerThreadId: context.input.providerThread.id, + providerTurnId: context.providerTurnId, + }, + now, + }); + subagent.task = { ...subagent.task, providerThreadId: providerThread.id }; + yield* Ref.update(providerThreadByNativeSessionId, (current) => + new Map(current).set(childSessionId, providerThread), + ); + yield* emitProviderEvent({ + type: "provider_thread.updated", + driver, + providerThread: { ...providerThread, status: "idle" }, + }); + const buffered = context.pendingSubagentNotifications.get(childSessionId) ?? []; + context.pendingSubagentNotifications.delete(childSessionId); + yield* Effect.forEach( + buffered, + (notification) => projectSubagentNotification(subagent, notification), + { concurrency: 1, discard: true }, + ); + } + + if ( + taskStatus !== "running" && + subagent.assistantText.length === 0 && + update.result !== null + ) { + yield* emitSubagentAssistant(subagent, update.result); + } + const result = update.result ?? subagent.task.result ?? (subagent.assistantText || null); + subagent.task = { + ...subagent.task, + status: taskStatus, + result, + completedAt: acpSubagentStatusIsTerminal(taskStatus) ? now : null, + updatedAt: now, + }; + const providerThreadId = subagent.task.providerThreadId; + yield* emitProviderEvent({ + type: "node.updated", + driver, + node: { + id: nodeId, + threadId: context.input.threadId, + runId: subagent.task.runId, + parentNodeId: subagent.task.parentNodeId, + rootNodeId: subagent.task.parentNodeId, + kind: "subagent", + status: taskStatus, + countsForRun: false, + providerThreadId: context.input.providerThread.id, + providerTurnId: subagent.providerTurnId, + nativeItemRef, + runtimeRequestId: null, + checkpointScopeId: null, + startedAt: subagent.task.startedAt, + completedAt: subagent.task.completedAt, + }, + }); + yield* emitProviderEvent({ + type: "node.updated", + driver, + node: { + id: childRootNodeId, + threadId: childThreadId, + runId: null, + parentNodeId: null, + rootNodeId: childRootNodeId, + kind: "root_turn", + status: taskStatus, + countsForRun: false, + providerThreadId, + providerTurnId: null, + nativeItemRef, + runtimeRequestId: null, + checkpointScopeId: null, + startedAt: subagent.task.startedAt, + completedAt: subagent.task.completedAt, + }, + }); + yield* emitProviderEvent({ type: "subagent.updated", driver, subagent: subagent.task }); + yield* emitProviderEvent({ + type: "turn_item.updated", + driver, + turnItem: { + id: turnItemId, + threadId: context.input.threadId, + runId: subagent.task.runId, + nodeId, + providerThreadId: context.input.providerThread.id, + providerTurnId: subagent.providerTurnId, + nativeItemRef, + parentItemId: null, + ordinal: turnItemOrdinal, + status: taskStatus, + title: subagent.task.title, + startedAt: subagent.task.startedAt, + completedAt: subagent.task.completedAt, + updatedAt: now, + type: "subagent", + subagentId: subagent.task.id, + origin: "provider_native", + driver, + providerInstanceId: context.input.modelSelection.instanceId, + childThreadId, + prompt: subagent.task.prompt, + result, + }, + }); + if (acpSubagentStatusIsTerminal(taskStatus)) { + subagent.terminalStatusProjected = true; + } + }); + + const toolOutputText = (toolCall: AcpToolCallState): string => { + if (typeof toolCall.data.rawOutput === "string") { + return toolCall.data.rawOutput; + } + if ( + typeof (toolCall.data.rawOutput as { text?: unknown } | undefined)?.text === "string" + ) { + return String((toolCall.data.rawOutput as { text: string }).text); + } + return textFromUnknown(toolCall.data.rawOutput) ?? ""; + }; + + const setToolOutputText = (toolCall: AcpToolCallState, text: string): AcpToolCallState => ({ + ...toolCall, + data: { + ...toolCall.data, + rawOutput: { + type: "Text", + text, + }, + }, + }); + + const appendToolOutputText = ( + toolCall: AcpToolCallState, + appendOutput: string, + ): AcpToolCallState => { + if (appendOutput.length === 0) return toolCall; + return setToolOutputText(toolCall, `${toolOutputText(toolCall)}${appendOutput}`); + }; + + const isMonitorEndNoticeText = (text: string): boolean => + /Monitor\s+["']?[0-9a-f-]{8,}/i.test(text) && /ended/i.test(text); + + const hasDeferredBackgroundWork = (context: ActiveAcpTurn): boolean => { + if (context.awaitingBackgroundHydration.size > 0) return true; + if (context.pendingInjectedReport.size > 0) return true; + for (const [taskId, toolCallId] of context.toolCallIdsByBackgroundTaskId) { + // Persistent monitors stream after root settle; do not pin finalize. + if (context.persistentBackgroundTaskIds.has(taskId)) continue; + const tool = context.tools.get(toolCallId); + if (tool === undefined) continue; + const status = toolStatus(tool.status); + if (status === "pending" || status === "running") return true; + } + for (const subagent of context.subagents.values()) { + if (acpSubagentStatusBlocksTurnSettlement(subagent.task.status)) { + return true; + } + } + return false; + }; + + // scheduleDeferredFinalize is declared above openSession body start and + // assigned after finalizeTurn so forked finalize Effect typing stays clean. + const rearmDeferredFinalize = (context: ActiveAcpTurn) => + Effect.gen(function* () { + if (!flavor.deferFinalizeForBackgroundWork) return; + if (!context.promptSettled || context.finalized) return; + if (hasDeferredBackgroundWork(context)) return; + yield* scheduleDeferredFinalize(context); + }); + + // `let` breaks circular inference from monitor hydration re-entry. + let emitTool: ( + context: ActiveAcpTurn, + incoming: AcpToolCallState, + projectedStatus?: ProjectedToolStatus, + ) => Effect.Effect = () => Effect.void; + + const markAwaitingBackgroundHydration = (context: ActiveAcpTurn, taskId: string) => + Effect.gen(function* () { + if (context.awaitingBackgroundHydration.has(taskId)) return; + context.awaitingBackgroundHydration.add(taskId); + // Safety: do not hold the root turn forever if the agent never hydrates. + yield* Effect.gen(function* () { + yield* Effect.sleep("60000 millis"); + if (context.finalized || !context.awaitingBackgroundHydration.has(taskId)) return; + context.awaitingBackgroundHydration.delete(taskId); + // End notices keep the tool running until hydration; force-complete so + // deferred finalize can proceed when get_command never arrives. + const toolCallId = context.toolCallIdsByBackgroundTaskId.get(taskId); + const tool = toolCallId !== undefined ? context.tools.get(toolCallId) : undefined; + if (tool !== undefined) { + const status = toolStatus(tool.status); + if (status === "pending" || status === "running") { + yield* emitTool(context, { ...tool, status: "completed" }); + } + } + yield* rearmDeferredFinalize(context); + }).pipe(Effect.forkIn(sessionScope), Effect.asVoid); + }); + + const markPendingInjectedReport = (context: ActiveAcpTurn, taskId: string) => + Effect.gen(function* () { + // Only the settled-and-held window has the race; mid-turn reports + // are protected by the prompt RPC still being open. + if (!context.promptSettled) return; + if (context.pendingInjectedReport.has(taskId)) return; + context.pendingInjectedReport.add(taskId); + // Safety: the injected turn may end without a report chunk. + yield* Effect.gen(function* () { + yield* Effect.sleep("25000 millis"); + if (context.finalized || !context.pendingInjectedReport.has(taskId)) return; + context.pendingInjectedReport.delete(taskId); + yield* rearmDeferredFinalize(context); + }).pipe(Effect.forkIn(sessionScope), Effect.asVoid); + }); + + const clearMidTurnUnreportedTaskIds = (taskIds: ReadonlyArray) => + Ref.update(midTurnUnreportedCompletedTaskIds, (current) => { + let next: Set | null = null; + for (const taskId of taskIds) { + if (!current.has(taskId)) continue; + if (next === null) next = new Set(current); + next.delete(taskId); + } + return next ?? current; + }); + + emitTool = Effect.fnUntraced(function* ( + context: ActiveAcpTurn, + incoming: AcpToolCallState, + projectedStatus?: ProjectedToolStatus, + ) { + // An identified message can stream concurrently with tool updates. + // Keep its identity until the provider starts another message. + if (context.assistant.current?.sourceMessageId == null) + yield* closeTextStream(context, "assistant"); + if (context.reasoning.current?.sourceMessageId == null) + yield* closeTextStream(context, "reasoning"); + yield* closeTextStream(context, "user"); + const previous = context.tools.get(incoming.toolCallId); + const merged = mergeToolCallState(previous, incoming); + const toolCall = flavor.normalizeToolCall?.(merged) ?? merged; + context.tools.set(toolCall.toolCallId, toolCall); + const backgroundTaskId = flavor.extractBackgroundTaskId?.(toolCall); + if (backgroundTaskId !== undefined) { + context.toolCallIdsByBackgroundTaskId.set(backgroundTaskId, toolCall.toolCallId); + if (flavor.isPersistentBackgroundTool?.(toolCall) === true) { + context.persistentBackgroundTaskIds.add(backgroundTaskId); + } + const backgroundStatus = projectedStatus ?? toolStatus(toolCall.status); + yield* setBackgroundTaskRunning( + backgroundTaskId, + backgroundStatus === "pending" || backgroundStatus === "running", + ); + // Background tool that reaches a terminal status while the root + // prompt is still open (completed, failed, or interrupted) was + // consumed in-turn. Mark handled so late CLI re-reports and residual + // agent chatter do not open synthetic "Background task completed." + // runs. get_command TaskOutput still marks handled below. + // Only before promptSettled: after STARTED the deferred-finalize + // hold can let a monitor finish while the turn is still active; + // marking then would suppress the legitimate post-settle TaskOutput + // continuation (live: grok-post-settle-continuation-poll). + // Terminal statuses only after normalizeToolCall (start ACKs stay + // inProgress/running). + // + // Tradeoff: a pre-settle false "completed" normalization (Bash- + // shaped re-report with exit_code 0, or the hydration safety + // force-complete firing pre-settle) would permanently mark the task + // handled and suppress its injected report chatter. The deleted + // handled-id removal on late monitor-event mutations used to rescue + // that case; likelihood is low because mid-turn monitor ends + // normally arrive as reminder mutations that force inProgress, and + // re-reports are documented post-settle traffic. + if ( + !context.promptSettled && + backgroundStatus !== "pending" && + backgroundStatus !== "running" + ) { + yield* Ref.update(handledBackgroundTaskIdsInActiveTurn, (current) => + new Set(current).add(backgroundTaskId), + ); + yield* Ref.update(midTurnUnreportedCompletedTaskIds, (current) => { + if (!current.has(backgroundTaskId)) return current; + const next = new Set(current); + next.delete(backgroundTaskId); + return next; + }); + } + } + + // get_command TaskOutput for registered monitor(s): hydrate those tools. + const backgroundCompletions = + projectedStatus === undefined + ? (flavor.extractBackgroundTaskCompletion?.(toolCall) ?? []) + : []; + let hydratedRegisteredMonitor = false; + for (const backgroundCompletion of backgroundCompletions) { + // Genuine end signal when terminal: tombstone so straggler + // monitor-event chatter cannot resurrect the running set after the + // task truly ended. A still-running poll keeps the id running. + yield* applyBackgroundTaskMutationRunning(backgroundCompletion); + if (backgroundCompletion.status !== "running") { + yield* Ref.update(handledBackgroundTaskIdsInActiveTurn, (current) => + new Set(current).add(backgroundCompletion.taskId), + ); + yield* Ref.update(midTurnUnreportedCompletedTaskIds, (current) => { + if (!current.has(backgroundCompletion.taskId)) return current; + const next = new Set(current); + next.delete(backgroundCompletion.taskId); + return next; + }); + } + // A still-running fetch must keep the hydration hold (and its + // safety timer) alive until output actually lands. + if (backgroundCompletion.status !== "running") { + context.awaitingBackgroundHydration.delete(backgroundCompletion.taskId); + } + const targetToolCallId = context.toolCallIdsByBackgroundTaskId.get( + backgroundCompletion.taskId, + ); + // Known background-task id (monitor registration) — never open a + // phantom subagent for the same get_output poll. + if (targetToolCallId !== undefined) { + hydratedRegisteredMonitor = true; + } + const target = + targetToolCallId !== undefined ? context.tools.get(targetToolCallId) : undefined; + if (target !== undefined && target.toolCallId !== toolCall.toolCallId) { + const nextStatus = + backgroundCompletion.status === "running" + ? ("inProgress" as const) + : backgroundCompletion.status === "failed" + ? ("failed" as const) + : ("completed" as const); + const hydrated = + backgroundCompletion.appendOutput.length > 0 + ? // TaskOutput is the real stdout; replace end-notice boilerplate + // so the timeline shows the listing, not only "Monitor ended…". + isMonitorEndNoticeText(toolOutputText(target)) || + toolOutputText(target).trim().length === 0 + ? setToolOutputText( + { ...target, status: nextStatus }, + backgroundCompletion.appendOutput, + ) + : appendToolOutputText( + { ...target, status: nextStatus }, + backgroundCompletion.appendOutput, + ) + : { ...target, status: nextStatus }; + yield* emitTool(context, hydrated); + } + } + + // Monitor TaskOutput shares the get_command tool shape with subagent + // hydration; do not spawn a phantom subagent for a registered monitor. + const subagentUpdate = hydratedRegisteredMonitor + ? undefined + : flavor.extractSubagentUpdate?.(toolCall); + if (subagentUpdate !== undefined) { + yield* emitSubagent(context, subagentUpdate); + if (subagentUpdate.suppressNormalTool !== false) { + yield* rearmDeferredFinalize(context); + return; + } + } + if ( + projectedStatus === undefined && + !shouldPersistToolUpdate( + context, + toolCall.toolCallId, + previous, + toolCall, + merged.status, + ) + ) { + yield* rearmDeferredFinalize(context); + return; + } + const status = projectedStatus ?? toolStatus(toolCall.status); + const now = yield* DateTime.now; + const nativeItemId = `${context.nativeThreadId}:tool:${toolCall.toolCallId}`; + const ordinal = yield* resolveItemOrdinal(context, nativeItemId); + const nodeId = providerNodeId(nativeItemId); + const turnItemId = providerTurnItemId(nativeItemId); + const nativeItemRef = { + driver, + nativeId: toolCall.toolCallId, + strength: "strong" as const, + }; + const startedAt = context.toolStartedAt.get(toolCall.toolCallId) ?? now; + context.toolStartedAt.set(toolCall.toolCallId, startedAt); + const completedAt = completedAtForStatus(status, now); + const title = toolCall.title ?? null; + yield* emitProviderEvent({ + type: "node.updated", + driver, + node: { + id: nodeId, + threadId: context.input.threadId, + runId: context.input.runId, + parentNodeId: context.input.rootNodeId, + rootNodeId: context.input.rootNodeId, + kind: "tool_call", + status: nodeStatus(status), + countsForRun: true, + providerThreadId: context.input.providerThread.id, + providerTurnId: context.providerTurnId, + nativeItemRef, + runtimeRequestId: null, + checkpointScopeId: null, + startedAt, + completedAt, + }, + }); + + const base = { + id: turnItemId, + threadId: context.input.threadId, + runId: context.input.runId, + nodeId, + providerThreadId: context.input.providerThread.id, + providerTurnId: context.providerTurnId, + nativeItemRef, + parentItemId: null, + ordinal, + status, + title, + startedAt, + completedAt, + updatedAt: now, + } as const; + const rawInput = toolCall.data.rawInput; + const rawOutput = toolCall.data.rawOutput ?? toolCall.data.content; + const changes = structuredFileChanges(toolCall); + const path = changes[0]?.path ?? pathFromToolCall(toolCall); + const diffText = + acpToolCallDiffPatch(toolCall.data.content) ?? textFromUnknown(rawOutput); + const rawInputRecord = unknownRecord(rawInput); + const inputVariant = + typeof rawInputRecord?.variant === "string" + ? rawInputRecord.variant.trim().toLowerCase() + : ""; + const rawOutputRecord = unknownRecord(rawOutput); + const outputCommand = + typeof rawOutputRecord?.command === "string" && + rawOutputRecord.command.trim().length > 0 + ? rawOutputRecord.command.trim() + : undefined; + const monitorCommand = + (typeof rawInputRecord?.command === "string" && rawInputRecord.command.trim().length > 0 + ? rawInputRecord.command.trim() + : undefined) ?? outputCommand; + const outputIsBashResult = + typeof rawOutputRecord?.type === "string" && + rawOutputRecord.type.trim().toLowerCase() === "bash" && + commandExitCode(rawOutput) !== undefined; + // Grok Monitor tools arrive as generic kind + variant; project like shell + // so stdout is plain text in the timeline (not JSON {type:Text,text:...}). + // Post-settle wake re-reports of a finished monitor carry no rawInput at + // all, only a structured Bash result; project those as commands too. + const projectAsCommandExecution = inputVariant === "monitor" || outputIsBashResult; + // ACP has no typed MCP item, so recover MCP identity from the + // agent-specific shape and project the same branded dynamic_tool + // item native providers produce (e.g. the T3 orchestration tools). + const mcpIdentity = extractMcpToolCallIdentity(toolCall, { + embeddedTerminalCommands: embeddedTerminalCommands( + context.nativeThreadId, + toolCall.toolCallId, + ), + }); + let turnItem: OrchestrationV2TurnItem; + if (toolCall.toolCallId.startsWith("acp-compaction:")) { + const summary = textFromUnknown(rawOutput); + turnItem = { + ...base, + type: "compaction", + driver, + ...(summary === undefined ? {} : { summary }), + ...(context.contextUsage?.usedTokens === undefined + ? {} + : { beforeTokenCount: context.contextUsage.usedTokens }), + }; + if (status === "completed") { + context.contextUsage = null; + yield* Ref.update(contextUsageBySessionId, (current) => { + const updated = new Map(current); + updated.delete(context.nativeThreadId); + return updated; + }); + } + } else if (mcpIdentity !== undefined) { + turnItem = { + ...base, + title: null, + ...mcpToolPresentation({ + serverName: mcpIdentity.server, + toolName: mcpIdentity.tool, + source: unknownRecord( + (unknownRecord(rawOutputRecord?.result) ?? rawOutputRecord)?._meta, + )?.source, + }), + type: "dynamic_tool", + toolName: `${mcpIdentity.server}.${mcpIdentity.tool}`, + input: + mcpIdentity.input ?? + unknownRecord(rawInputRecord?.arguments) ?? + rawInputRecord ?? + {}, + ...(rawOutput === undefined ? {} : { output: acpMcpToolCallOutput(rawOutput) }), + }; + yield* emitProviderEvent({ type: "turn_item.updated", driver, turnItem }); + yield* rearmDeferredFinalize(context); + return; + } else if (changes.length > 0) { + turnItem = { + ...base, + type: "file_change", + fileName: changes[0]!.path, + changes, + ...(diffText === undefined ? {} : { diffStr: diffText }), + }; + } else { + switch (toolCall.kind) { + case "read": + turnItem = { + ...base, + title: path ? formatReadToolLabel(path) : (title ?? "Read file"), + type: "dynamic_tool", + toolName: "Read", + input: + path === undefined || + ["path", "filePath", "file_path"].some((key) => rawInputRecord?.[key] === path) + ? (rawInputRecord ?? {}) + : { ...rawInputRecord, path }, + ...(rawOutput === undefined ? {} : { output: rawOutput }), + }; + break; + case "search": { + const backendSearch = acpBackendWebSearch(rawInputRecord, rawOutputRecord); + if (backendSearch !== undefined) { + // Grok titles these "X search:" / "Web search:" awaiting the query. + const label = nonEmptyText(toolCall.data.title, title ?? "Web search").replace( + /:\s*$/u, + "", + ); + turnItem = { + ...base, + title: + backendSearch.query === undefined + ? label + : `${label}: ${backendSearch.query}`, + type: "web_search", + ...(backendSearch.query === undefined + ? {} + : { patterns: [backendSearch.query] }), + ...(backendSearch.results.length === 0 + ? {} + : { results: backendSearch.results }), + }; + break; + } + turnItem = { + ...base, + title: + formatSearchToolLabel({ + rawInput: rawInputRecord, + input: rawInputRecord, + ...(path === undefined ? {} : { pattern: path }), + }) ?? title, + type: "file_search", + ...(path === undefined ? {} : { pattern: path }), + ...(path === undefined + ? {} + : { + results: [ + { + fileName: path, + ...(textFromUnknown(rawOutput) === undefined + ? {} + : { preview: textFromUnknown(rawOutput) }), + }, + ], + }), + }; + break; + } + case "execute": { + const exitCode = acpProjectedCommandExitCode(status, rawOutput); + turnItem = { + ...base, + type: "command_execution", + input: toolCall.command ?? monitorCommand ?? toolCall.title ?? "Command", + ...(textFromUnknown(rawOutput) === undefined + ? {} + : { output: textFromUnknown(rawOutput) }), + ...(exitCode === undefined ? {} : { exitCode }), + }; + break; + } + case "edit": + case "delete": + case "move": + turnItem = { + ...base, + type: "file_change", + fileName: path ?? toolCall.title ?? "File change", + ...(diffText === undefined ? {} : { diffStr: diffText }), + }; + break; + case "fetch": { + // Grok nests the page under rawOutput.Content, which textFromUnknown + // cannot read; the (bounded) content blocks carry the same text. + const snippet = + textFromUnknown(toolCall.data.content) ?? textFromUnknown(rawOutput); + turnItem = { + ...base, + type: "web_search", + ...(path === undefined ? {} : { patterns: [path] }), + ...(path === undefined + ? {} + : { + results: [ + { + url: path, + ...(snippet === undefined ? {} : { snippet }), + }, + ], + }), + }; + break; + } + default: + if (projectAsCommandExecution) { + const exitCode = acpProjectedCommandExitCode(status, rawOutput); + turnItem = { + ...base, + type: "command_execution", + input: + toolCall.command ?? + monitorCommand ?? + toolCall.title ?? + (inputVariant === "monitor" ? "Monitor" : "Command"), + ...(textFromUnknown(rawOutput) === undefined + ? {} + : { output: textFromUnknown(rawOutput) }), + ...(exitCode === undefined ? {} : { exitCode }), + }; + } else { + turnItem = { + ...base, + type: "dynamic_tool", + toolName: toolCall.title ?? toolCall.kind ?? null, + input: rawInput ?? {}, + ...(rawOutput === undefined ? {} : { output: rawOutput }), + }; + } + } + } + yield* emitProviderEvent({ type: "turn_item.updated", driver, turnItem }); + yield* rearmDeferredFinalize(context); + }); + + const emitPlan = Effect.fnUntraced(function* ( + context: ActiveAcpTurn, + update: AcpPlanUpdate, + ) { + yield* closeTextStreams(context); + const existing = context.plans.get(update.nativePlanId); + if (update.kind === "removed" && existing === undefined) return; + const nativeItemId = `${context.nativeTurnId}:plan:${encodeURIComponent(update.nativePlanId)}`; + const ordinal = yield* resolveItemOrdinal(context, nativeItemId); + const now = yield* DateTime.now; + const nodeId = providerNodeId(nativeItemId); + const turnItemId = providerTurnItemId(nativeItemId); + const planState = existing ?? { + id: yield* idAllocator.allocate.plan({ + threadId: context.input.threadId, + runId: context.input.runId, + driver, + }), + startedAt: now, + latest: null, + }; + const planId = planState.id; + const nativeItemRef = { driver, nativeId: nativeItemId, strength: "weak" as const }; + const base = { + id: planId, + threadId: context.input.threadId, + runId: context.input.runId, + nodeId, + } as const; + let plan: OrchestrationV2PlanArtifact; + if (update.kind === "removed") { + if (planState.latest === null) return; + plan = { ...planState.latest, status: "superseded" }; + } else if (update.kind === "items") { + const steps: ReadonlyArray = update.plan.map( + (step, index) => ({ + id: `acp-step-${index + 1}`, + text: nonEmptyText(step.step, `Step ${index + 1}`), + status: + step.status === "inProgress" + ? "running" + : step.status === "completed" + ? "completed" + : "pending", + }), + ); + const completed = + steps.length > 0 && steps.every((step) => step.status === "completed"); + plan = { + ...base, + status: completed ? "completed" : "active", + kind: "todo_list", + steps, + ...(update.explanation == null ? {} : { explanation: update.explanation }), + }; + } else { + const markdown = + update.kind === "markdown" + ? update.markdown + : update.kind === "file" + ? `Plan file: ${update.uri}` + : `[Unsupported ACP plan content: ${update.contentType}]`; + plan = { + ...base, + status: "active", + kind: "proposed_plan", + markdown, + }; + } + planState.latest = plan; + context.plans.set(update.nativePlanId, planState); + const completed = plan.status === "completed" || plan.status === "superseded"; + const nodeKind = plan.kind === "todo_list" ? "todo_list" : "plan"; + yield* emitProviderEvent({ + type: "node.updated", + driver, + node: { + id: nodeId, + threadId: context.input.threadId, + runId: context.input.runId, + parentNodeId: context.input.rootNodeId, + rootNodeId: context.input.rootNodeId, + kind: nodeKind, + status: completed ? "completed" : "running", + countsForRun: false, + providerThreadId: context.input.providerThread.id, + providerTurnId: context.providerTurnId, + nativeItemRef, + runtimeRequestId: null, + checkpointScopeId: null, + startedAt: planState.startedAt, + completedAt: completed ? now : null, + }, + }); + yield* emitProviderEvent({ type: "plan.updated", driver, plan }); + yield* emitProviderEvent({ + type: "turn_item.updated", + driver, + turnItem: { + id: turnItemId, + threadId: context.input.threadId, + runId: context.input.runId, + nodeId, + providerThreadId: context.input.providerThread.id, + providerTurnId: context.providerTurnId, + nativeItemRef, + parentItemId: null, + ordinal, + status: completed ? "completed" : "running", + title: null, + startedAt: planState.startedAt, + completedAt: completed ? now : null, + updatedAt: now, + ...(plan.kind === "todo_list" + ? { + type: "todo_list" as const, + planId, + steps: plan.steps, + ...(plan.explanation === undefined ? {} : { explanation: plan.explanation }), + } + : { + type: "proposed_plan" as const, + planId, + markdown: plan.markdown, + streaming: !completed, + }), + }, + }); + }); + + const appendLoadedHistory = ( + notification: EffectAcpSchema.SessionNotification, + role: "user" | "assistant" | "thought", + text: string, + replace = false, + ) => + Effect.gen(function* () { + if (text.length === 0 && !replace) return; + const now = yield* DateTime.now; + yield* Ref.update(snapshot, (current) => { + const update = notification.update; + const sourceMessageId = + (update.sessionUpdate === "user_message_chunk" || + update.sessionUpdate === "agent_message_chunk" || + update.sessionUpdate === "agent_thought_chunk" || + update.sessionUpdate === "user_message" || + update.sessionUpdate === "agent_message" || + update.sessionUpdate === "agent_thought") && + update.messageId + ? update.messageId + : null; + const startsNew = + current.loadingRole !== role || + (sourceMessageId !== null && current.loadingMessageId !== sourceMessageId); + const loadingIndex = startsNew ? current.loadingIndex + 1 : current.loadingIndex; + const nativeItemId = + sourceMessageId === null + ? `${notification.sessionId}:history:${role}:${loadingIndex}` + : `${notification.sessionId}:history:${role}:message:${sourceMessageId}`; + const messageId = providerMessageId(nativeItemId); + const key = String(messageId); + const previous = current.messages.get(key); + const messages = new Map(current.messages); + messages.set(key, { + createdBy: previous?.createdBy ?? (role === "user" ? "user" : "agent"), + creationSource: previous?.creationSource ?? "provider", + id: messageId, + threadId: input.threadId, + runId: null, + nodeId: null, + role: role === "user" ? "user" : "assistant", + text: replace ? text : `${previous?.text ?? ""}${text}`, + attachments: [], + streaming: false, + createdAt: previous?.createdAt ?? now, + updatedAt: now, + }); + return { + order: current.order.includes(key) ? current.order : [...current.order, key], + messages, + loadingRole: role, + loadingMessageId: sourceMessageId ?? (startsNew ? null : current.loadingMessageId), + loadingIndex, + }; + }); + }); + + const offerContinuationRun = Effect.fnUntraced(function* (_sessionId: string) { + if (continuationRequests === undefined) { + return false; + } + const pending = yield* continuationPermit.withPermit( + Effect.gen(function* () { + if (yield* Ref.get(continuationClosed)) return Option.none(); + if (yield* Ref.get(stoppedRunQuarantine)) return Option.none(); + if (yield* Ref.get(continuationRequested)) return Option.none(); + const route = yield* Ref.get(lastTurnRoute); + if (route === null) return Option.none(); + yield* Ref.set(continuationRequested, true); + const generation = yield* Ref.updateAndGet( + continuationGeneration, + (value) => value + 1, + ); + const reports = yield* Ref.modify(wakeReports, ({ reports }) => [ + reports, + { reports, offered: new Set(reports.keys()) }, + ]); + return Option.some({ route, generation, reports }); + }), + ); + if (Option.isNone(pending)) return false; + const { route, generation, reports } = pending.value; + const notification = backgroundWorkNotification([...reports.values()]); + yield* Effect.logInfo("orchestration-v2.acp-wake-turn-detected", { + driver, + providerSessionId: input.providerSessionId, + threadId: route.threadId, + providerThreadId: route.providerThreadId, + }); + yield* continuationRequests.offer({ + threadId: route.threadId, + providerThreadId: route.providerThreadId, + driver, + detail: null, + ...(notification === null ? {} : { notification }), + clearIfCurrent: () => + continuationPermit.withPermit( + Effect.gen(function* () { + if ((yield* Ref.get(continuationGeneration)) === generation) { + yield* Ref.set(continuationRequested, false); + } + }), + ), + dispatchIfCurrent: (effect) => + continuationPermit.withPermit( + Effect.gen(function* () { + const clearIfOwner = Effect.gen(function* () { + if ((yield* Ref.get(continuationGeneration)) === generation) { + yield* Ref.set(continuationRequested, false); + } + }); + if (yield* Ref.get(stoppedRunQuarantine)) { + yield* clearIfOwner; + return Option.none(); + } + if ((yield* Ref.get(continuationGeneration)) !== generation) { + // Superseded by a newer offer; do not clear its flag. + return Option.none(); + } + if (!(yield* Ref.get(continuationRequested))) return Option.none(); + // A successful durable dispatch owns the sticky flag until its + // continuation turn starts. Clearing here opens a dispatch-to- + // start race where every late ACP frame can enqueue another + // synthetic continuation. Failures clear immediately because + // no turn will arrive to do so. + const exit = yield* Effect.exit(effect); + if (Exit.isFailure(exit)) { + yield* clearIfOwner; + return yield* Effect.failCause(exit.cause); + } + return Option.some(exit.value); + }), + ), + }); + return true; + }); + + const applyLateBackgroundMutation = Effect.fnUntraced(function* ( + sessionId: string, + mutation: { + readonly taskId: string; + readonly status: "running" | "completed" | "failed"; + }, + ) { + const taskAlreadyEnded = (yield* Ref.get(endedBackgroundTaskIds)).has(mutation.taskId); + yield* applyBackgroundTaskMutationRunning(mutation); + if (mutation.status === "running") { + // Straggler running notice after a genuine end: suppress residual + // monitor-prompt agent chatter so it cannot open a synthetic wake. + if (taskAlreadyEnded && acpPostSettleMonitorPromptShouldSuppress(mutation)) { + yield* Ref.set(suppressPostSettleMonitorPrompt, true); + } + return; + } + // First genuine terminal: allow subsequent agent output. Re-delivery + // of an already-ended terminal must not un-suppress residual ack + // chatter from the injected monitor-event turn. + if (!taskAlreadyEnded) { + yield* Ref.set(suppressPostSettleMonitorPrompt, false); + } + // Keep handledBackgroundTaskIdsInActiveTurn intact. Erasing the mark + // on a late monitor-event mutation defeated the in-turn chatter guard + // and let injected-turn acks arm wakeBuffer for a later spurious + // "Background task completed." run (multiturn live repro). Monitors + // the agent settled without reporting never enter that set, so their + // injected report still streams via pendingInjectedReport / hold. + const activeContext = yield* Ref.get(activeTurn); + // Completions that land while a root turn is still streaming are + // owned by in-turn machinery (emitTool handled marks, deferred + // finalize, injected-report hold). Do not open a synthetic wake mid- + // turn; finalizeTurn re-checks wakeBuffer / midTurnUnreported once + // the turn leaves the active slot so legitimate unhandled evidence + // still offers after finalize. + if (activeContext !== null && !activeContext.finalized) { + const handled = yield* Ref.get(handledBackgroundTaskIdsInActiveTurn); + // Only arm deferred wake when this turn registered the task (monitor + // tool started in-turn) and the root prompt is still open. Completions + // that land in the settled-held window (promptSettled, deferred + // finalize holding for injected report) fall back to the pre-existing + // post-finalize injected-turn path; arming here would spuriously + // offer "Background task completed." after the report streams into + // the held turn. Residual cancel-backgrounded completions from a + // prior interrupt must not open a synthetic continuation. + if ( + !activeContext.promptSettled && + !handled.has(mutation.taskId) && + activeContext.toolCallIdsByBackgroundTaskId.has(mutation.taskId) + ) { + yield* Ref.update(midTurnUnreportedCompletedTaskIds, (current) => + new Set(current).add(mutation.taskId), + ); + } + return; + } + if ( + postSettleContinuationEnabled && + (yield* Ref.get(activeSessionId)) === sessionId && + (yield* Ref.get(runningBackgroundTaskIds)).size === 0 && + ((yield* Ref.get(wakeBuffer)).length > 0 || + (yield* Ref.get(midTurnUnreportedCompletedTaskIds)).size > 0) + ) { + yield* offerContinuationRun(sessionId); + } + }); + + // A structured task end (Grok `task_completed`) is authoritative for the + // tool that registered the task: its own updates only ever say running. + // Finish the row while deferred finalize holds a settled root turn open + // for it, so the turn can settle. While the prompt is still open the + // agent reports the end itself (TaskOutput hydration), and an unreported + // end must keep its post-finalize continuation offer. + const finishRegisteredBackgroundTool = Effect.fnUntraced(function* (mutation: { + readonly taskId: string; + readonly status: "completed" | "failed"; + readonly output?: string; + }) { + const context = yield* Ref.get(activeTurn); + if (context === null || context.finalized || !context.promptSettled) return; + const toolCallId = context.toolCallIdsByBackgroundTaskId.get(mutation.taskId); + const tool = toolCallId === undefined ? undefined : context.tools.get(toolCallId); + if (tool === undefined) return; + const status = toolStatus(tool.status); + if (status !== "pending" && status !== "running") return; + context.awaitingBackgroundHydration.delete(mutation.taskId); + const finished = { ...tool, status: mutation.status }; + yield* emitTool( + context, + mutation.output === undefined ? finished : setToolOutputText(finished, mutation.output), + mutation.status, + ); + yield* rearmDeferredFinalize(context); + }); + + const bufferPostSettleWake = Effect.fnUntraced(function* ( + notification: EffectAcpSchema.SessionNotification, + ) { + if (!postSettleContinuationEnabled || continuationRequests === undefined) { + return { + buffered: false, + offerContinuation: false, + stopProcessing: false, + }; + } + // Direct Stop quarantine: drop residual wake evidence instead of + // buffering it for a later continuation or follow-up run. + if (yield* Ref.get(stoppedRunQuarantine)) { + return { + buffered: false, + offerContinuation: false, + stopProcessing: true, + }; + } + const rootSessionId = yield* Ref.get(activeSessionId); + if (rootSessionId === null || notification.sessionId !== rootSessionId) { + return { + buffered: false, + offerContinuation: false, + stopProcessing: false, + }; + } + if (!acpPostSettleWakeEvidence(notification, flavor)) { + return { + buffered: false, + offerContinuation: false, + stopProcessing: false, + }; + } + const update = notification.update; + let alreadyHandledToolUpdate = false; + if (update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update") { + yield* trackRunningBackgroundTools(notification); + // When the agent consumes the monitor output itself via + // get_command_or_subagent_output (long timeout), the TaskOutput + // completion is the ONLY end signal: no "Monitor ended" reminder + // follows. Without applying it here the running set never clears, + // every offer stays suppressed, and the wake buffer never drains + // (observed live 2026-07-12, thread 8dbe607f). The completion + // frame itself is offer evidence, so clearing the id before the + // gate below lets it open the single continuation run. + for (const event of parseSessionUpdateEvent(notification).events) { + if (event._tag !== "ToolCallUpdated") continue; + const toolCall = flavor.normalizeToolCall?.(event.toolCall) ?? event.toolCall; + const proposedPlanMarkdown = flavor.extractProposedPlanMarkdown?.(toolCall); + if (proposedPlanMarkdown !== undefined && proposedPlanMarkdown.length > 0) { + yield* captureProposedPlan({ planMarkdown: proposedPlanMarkdown }); + } + const toolTaskId = flavor.extractBackgroundTaskId?.(toolCall); + if ( + toolTaskId !== undefined && + (yield* Ref.get(handledBackgroundTaskIdsInActiveTurn)).has(toolTaskId) + ) { + alreadyHandledToolUpdate = true; + } + if (flavor.extractBackgroundTaskCompletion !== undefined) { + for (const completion of flavor.extractBackgroundTaskCompletion(toolCall)) { + alreadyHandledToolUpdate = + alreadyHandledToolUpdate || + (yield* Ref.get(handledBackgroundTaskIdsInActiveTurn)).has(completion.taskId); + yield* applyBackgroundTaskMutationRunning(completion); + } + } + } + } + const backgroundWorkRunning = (yield* Ref.get(runningBackgroundTaskIds)).size > 0; + // Residual Grok agent/thought chatter after in-turn-handled background + // work must not be retained as wake evidence. Tool-path alreadyHandled + // covers re-reports with a task id; this covers agent_message_chunk / + // agent_thought_chunk frames that carry no task id (live: + // grok-in-turn-monitor-no-wake, multiturn stale-buffer arm). Check + // before buffering so the frames cannot dirty wakeBuffer and later + // arm a mid-turn offer when a second monitor completes. + const handledInTurnCount = (yield* Ref.get(handledBackgroundTaskIdsInActiveTurn)).size; + const isInTurnHandledAgentChatter = + handledInTurnCount > 0 && + (update.sessionUpdate === "agent_message_chunk" || + update.sessionUpdate === "agent_thought_chunk"); + // Grok prompts itself for every monitor event after the root turn + // settles. Its assistant/reasoning replies are progress chatter, not + // separate wake results. Retaining them would replay the entire burst + // into the single continuation once the monitor finishes. Keep tool + // state so the final command card still hydrates, then begin retaining + // agent output again after the genuine end signal clears the running + // set. With multiple tasks, text remains best-effort until every task + // ends; their retained tool cards are the authoritative results. + // + // Skip retaining frames for tasks already hydrated in the root turn + // (`handledBackgroundTaskIdsInActiveTurn`). Those re-reports must not + // pin `hasPendingBackgroundWork` via a wake buffer that never drains + // (the already-handled gate below intentionally skips + // `offerContinuationRun` to avoid synthetic "Background task + // completed." spam). + let buffered = false; + if ( + !alreadyHandledToolUpdate && + !isInTurnHandledAgentChatter && + acpPostSettleWakeShouldBuffer(notification, backgroundWorkRunning) + ) { + yield* Ref.update(wakeBuffer, (current) => [...current, notification]); + buffered = true; + } + // Buffer progress without offering; only completion-like frames open a run. + if (!acpPostSettleContinuationOfferEvidence(notification, flavor)) { + return { + buffered, + offerContinuation: false, + stopProcessing: true, + }; + } + // While a monitor is still streaming, tool re-reports buffer without + // offering and per-event agent commentary is consumed without being + // retained. Grok re-reports a running monitor as Bash frames that + // already carry exit_code 0 mid-stream, so a "terminal" normalized + // status is not evidence the task ended; each burst would otherwise + // reopen a synthetic "Background task completed." run. Retained tool + // frames drain into the single continuation offered once the monitor + // actually ends (end-notice mutation below, or the first frame after + // it). + if (backgroundWorkRunning) { + return { + buffered, + offerContinuation: false, + stopProcessing: true, + }; + } + if (alreadyHandledToolUpdate) { + // Drop leftover wake noise for in-turn-handled work so idle release + // is not pinned forever. Leave the buffer alone when a continuation + // is already outstanding: its startTurn will drain legitimate frames + // from other tasks. + if (!(yield* Ref.get(continuationRequested))) { + yield* Ref.set(wakeBuffer, []); + } + return { + buffered, + offerContinuation: false, + stopProcessing: true, + }; + } + // Same in-turn-handled agent chatter: do not open a synthetic wake. + // Do not clear wakeBuffer here: frames for other still-tracked tasks + // must remain drainable when a real (tool) completion later offers. + if (isInTurnHandledAgentChatter) { + return { + buffered, + offerContinuation: false, + stopProcessing: true, + }; + } + return { + buffered, + offerContinuation: true, + stopProcessing: true, + }; + }); + + let applyFinalizedActiveTurnSubagentTerminal: ( + context: ActiveAcpTurn, + notification: EffectAcpSchema.SessionNotification, + ) => Effect.Effect = () => Effect.succeed(false); + + const projectAgentTerminalUpdate = Effect.fnUntraced(function* ( + notification: EffectAcpSchema.SessionNotification, + context: ActiveAcpTurn | null, + ) { + const update = notification.update; + if ( + update.sessionUpdate !== "terminal_update" && + update.sessionUpdate !== "terminal_output_chunk" + ) { + return false; + } + const terminalKey = sessionScopedId(notification.sessionId, update.terminalId); + const next = applyAcpAgentTerminalUpdate(agentTerminalsById.get(terminalKey), update); + agentTerminalsById.set(terminalKey, next); + if (context === null || notification.sessionId !== context.nativeThreadId) return true; + const status = + next.exitStatus === undefined + ? ("inProgress" as const) + : next.exitStatus.exitCode === 0 + ? ("completed" as const) + : ("failed" as const); + const embeddedToolCallIds = toolCallIdsByAgentTerminalId.get(terminalKey); + const projectedToolCallIds = + embeddedToolCallIds === undefined || embeddedToolCallIds.size === 0 + ? [`acp-agent-terminal:${update.terminalId}`] + : [...embeddedToolCallIds]; + for (const toolCallId of projectedToolCallIds) { + const existing = context.tools.get(toolCallId); + const seeded: AcpToolCallState = + existing ?? + ({ + toolCallId, + kind: "execute", + title: next.command ?? "Terminal", + status, + ...(next.command === undefined ? {} : { command: next.command }), + data: { toolCallId }, + } satisfies AcpToolCallState); + yield* emitTool( + context, + setToolOutputText( + { + ...seeded, + status, + ...(next.command === undefined ? {} : { command: next.command }), + }, + next.output, + ), + ); + } + return true; + }); + + const handleSessionUpdate = Effect.fnUntraced(function* ( + notification: EffectAcpSchema.SessionNotification, + ) { + const context = yield* Ref.get(activeTurn); + const update = notification.update; + if (yield* projectAgentTerminalUpdate(notification, context)) return; + if ( + update.sessionUpdate === "usage_update" || + update.sessionUpdate === "session_info_update" || + (update.sessionUpdate === "state_update" && + update.state === "idle" && + update.usage != null) + ) { + const stateEvent = parseSessionUpdateEvent(notification).events.find( + (event) => event._tag === "UsageUpdated" || event._tag === "SessionInfoUpdated", + ); + if (stateEvent === undefined) return; + if (stateEvent._tag === "UsageUpdated") { + yield* Ref.update(contextUsageBySessionId, (current) => + new Map(current).set(notification.sessionId, stateEvent.usage), + ); + if (context?.nativeThreadId === notification.sessionId) { + context.contextUsage = stateEvent.usage; + } + } else { + const metadata = yield* Ref.modify(nativeMetadataBySessionId, (current) => { + const merged = { + ...current.get(notification.sessionId), + ...stateEvent.metadata, + }; + return [merged, new Map(current).set(notification.sessionId, merged)] as const; + }); + if (context?.nativeThreadId === notification.sessionId) { + context.nativeMetadata = metadata; + } + } + const knownProviderThread = (yield* Ref.get(providerThreadByNativeSessionId)).get( + notification.sessionId, + ); + if (knownProviderThread !== undefined) { + const now = yield* DateTime.now; + const providerThread: OrchestrationV2ProviderThread = { + ...knownProviderThread, + contextUsage: + (yield* Ref.get(contextUsageBySessionId)).get(notification.sessionId) ?? + knownProviderThread.contextUsage ?? + null, + nativeMetadata: + (yield* Ref.get(nativeMetadataBySessionId)).get(notification.sessionId) ?? + knownProviderThread.nativeMetadata ?? + null, + updatedAt: now, + }; + yield* Ref.update(providerThreadByNativeSessionId, (current) => + new Map(current).set(notification.sessionId, providerThread), + ); + yield* emitProviderEvent({ + type: "provider_thread.updated", + driver, + providerThread, + }); + } + return; + } + if ( + context?.finalized === true && + (yield* applyFinalizedActiveTurnSubagentTerminal(context, notification)) + ) { + return; + } + // Only while a finalized turn is still the active context. When + // activeTurn is null, post-settle agent frames must reach + // bufferPostSettleWake so continuation can attach (context?.finalized + // !== false incorrectly treated null as finalized and dropped them). + if ( + context !== null && + context.finalized && + (yield* Ref.get(handledBackgroundTaskIdsInActiveTurn)).size > 0 && + (update.sessionUpdate === "agent_message_chunk" || + update.sessionUpdate === "agent_thought_chunk") + ) { + yield* Ref.set(suppressPostSettleMonitorPrompt, true); + return; + } + if ( + context !== null && + (yield* Ref.get(suppressPostSettleMonitorPrompt)) && + (update.sessionUpdate === "agent_message_chunk" || + update.sessionUpdate === "agent_thought_chunk") + ) { + return; + } + if ( + context?.finalized === true && + update.sessionUpdate === "user_message_chunk" && + update.content.type === "text" + ) { + const mutations = flavor.extractBackgroundToolMutation?.(update.content.text) ?? []; + for (const mutation of mutations) { + yield* applyLateBackgroundMutation(notification.sessionId, mutation); + } + return; + } + if (context === null) { + // Direct Stop: quarantine residual events from the stopped run so + // they cannot become history, wake buffers, or a later run attach. + if (yield* Ref.get(stoppedRunQuarantine)) { + return; + } + const bufferOutcome = yield* bufferPostSettleWake(notification); + // Post-settle carryover sync: keep in-memory carryover accurate so + // hasPendingBackgroundWork reasons correctly after root settle. + // A completed root keeps its subscriber open while background items + // remain, so project its terminals immediately even when the wake + // frame is buffered for a continuation. Non-completed roots stay + // memory-only: durable ingest requires a subscriber that owns the + // original runId, which the interrupted path does not guarantee. + const carryover = yield* Ref.get(carryoverSubagents); + const rootTerminalCanStillProject = + carryover !== null && + carryover.sessionId === (yield* Ref.get(activeSessionId)) && + carryover.rootTerminalStatus === "completed"; + const projectCarryover = rootTerminalCanStillProject; + let carryoverTerminalized = false; + if ( + flavor.extractSubagentUpdate !== undefined && + (update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update") + ) { + for (const event of parseSessionUpdateEvent(notification).events) { + if (event._tag !== "ToolCallUpdated") continue; + const toolCall = flavor.normalizeToolCall?.(event.toolCall) ?? event.toolCall; + const subagentUpdate = flavor.extractSubagentUpdate(toolCall); + if (subagentUpdate === undefined) continue; + if (!acpSubagentStatusIsTerminal(subagentUpdate.status)) { + continue; + } + if ( + yield* updateCarryoverSubagentStatus( + subagentUpdate.nativeTaskId, + subagentUpdate.status, + subagentUpdate.result, + { project: projectCarryover }, + ) + ) { + carryoverTerminalized = true; + } + if ( + subagentUpdate.childSessionId !== null && + (yield* updateCarryoverSubagentStatus( + subagentUpdate.childSessionId, + subagentUpdate.status, + subagentUpdate.result, + { project: projectCarryover }, + )) + ) { + carryoverTerminalized = true; + } + } + } + if ( + update.sessionUpdate === "user_message_chunk" && + update.content.type === "text" && + flavor.extractSubagentEndNotice !== undefined + ) { + const notice = flavor.extractSubagentEndNotice(update.content.text); + if ( + notice !== undefined && + (yield* updateCarryoverSubagentStatus( + notice.childSessionId, + notice.status, + undefined, + { + project: projectCarryover, + }, + )) + ) { + carryoverTerminalized = true; + } + } + // Synchronize carryover before an eager continuation can attach and + // drain the frame. + const continuationOffered = bufferOutcome.offerContinuation + ? yield* offerContinuationRun(notification.sessionId) + : false; + const wakeOutcome = { ...bufferOutcome, continuationOffered }; + // Frames that will not buffer never reach the continuation drain for + // this traffic. Once carryover is terminalized, skip history append / + // residual handling (and avoid double-projecting on child re-entry). + if (carryoverTerminalized && !wakeOutcome.buffered) { + // Projected above. Never clear a non-empty wakeBuffer. A sticky + // continuationRequested with an empty buffer is safe to drop so + // idle release is not wed on a pin with nothing to deliver. + if ( + acpCarryoverTerminalShouldClearContinuation({ + continuationOffered: wakeOutcome.continuationOffered, + wakeBufferLength: (yield* Ref.get(wakeBuffer)).length, + }) + ) { + yield* Ref.set(continuationRequested, false); + } + return; + } + // Prefer continuation buffering over history append so the same + // frames are not double-counted once a continuation run attaches. + if (wakeOutcome.stopProcessing) { + return; + } + if ( + update.sessionUpdate === "user_message_chunk" || + update.sessionUpdate === "agent_message_chunk" || + update.sessionUpdate === "agent_thought_chunk" + ) { + // Late monitor end/event reminders must not become ghost user/assistant + // history (or OS-facing chatter) after the root turn already finalized. + const text = acpContentBlockDisplayText(update.content); + if (text === undefined) return; + const lateBackgroundMutations = + update.content.type === "text" + ? (flavor.extractBackgroundToolMutation?.(text) ?? []) + : []; + for (const lateBackgroundMutation of lateBackgroundMutations) { + yield* applyLateBackgroundMutation(notification.sessionId, lateBackgroundMutation); + } + const lateMonitorChatter = + / 0 && + lateBackgroundMutations.length === 0 && + !lateMonitorChatter + ) { + yield* appendLoadedHistory( + notification, + update.sessionUpdate === "user_message_chunk" + ? "user" + : update.sessionUpdate === "agent_thought_chunk" + ? "thought" + : "assistant", + text, + ); + } + } else if ( + update.sessionUpdate === "user_message" || + update.sessionUpdate === "agent_message" || + update.sessionUpdate === "agent_thought" + ) { + if (update.content === undefined) return; + const text = (update.content ?? []) + .flatMap((content) => { + const display = acpContentBlockDisplayText(content); + return display === undefined ? [] : [display]; + }) + .join("\n"); + yield* appendLoadedHistory( + notification, + update.sessionUpdate === "user_message" + ? "user" + : update.sessionUpdate === "agent_thought" + ? "thought" + : "assistant", + text, + true, + ); + } else if ( + update.sessionUpdate === "tool_call" || + update.sessionUpdate === "tool_call_update" || + update.sessionUpdate === "plan" || + update.sessionUpdate === "plan_update" || + update.sessionUpdate === "plan_removed" + ) { + yield* Ref.update(snapshot, (current) => ({ + ...current, + loadingRole: null, + loadingMessageId: null, + })); + } + return; + } + if (context.finalized) return; + const rootSessionId = yield* Ref.get(activeSessionId); + if (notification.sessionId !== rootSessionId) { + // Finalize may have completed during the activeSessionId yield. + if (context.finalized) return; + if (flavor.extractSubagentUpdate === undefined) return; + const subagent = context.subagentsBySessionId.get(notification.sessionId); + if ( + update.sessionUpdate === "tool_call" || + update.sessionUpdate === "tool_call_update" + ) { + if (subagent === undefined) return; + const nativeTaskId = + subagent.task.nativeTaskRef?.nativeId ?? String(subagent.task.id); + for (const event of parseSessionUpdateEvent(notification).events) { + if (event._tag !== "ToolCallUpdated") continue; + const toolCall = flavor.normalizeToolCall?.(event.toolCall) ?? event.toolCall; + const subagentUpdate = flavor.extractSubagentUpdate(toolCall); + if (subagentUpdate !== undefined) { + if ( + subagentUpdate.nativeTaskId === nativeTaskId || + subagentUpdate.childSessionId === notification.sessionId + ) { + yield* emitSubagent(context, subagentUpdate); + } + continue; + } + const key = `${nativeTaskId}:tool:${toolCall.toolCallId}`; + const previous = context.tools.get(key); + const merged = mergeToolCallState(previous, toolCall); + context.tools.set(key, merged); + if (!shouldPersistToolUpdate(context, key, previous, merged, merged.status)) + continue; + // Terminals are remembered under the raw session id: the child's + // own session, or the root one when the flavor routes child + // updates out of it (Devin). + const mcpIdentity = extractMcpToolCallIdentity(merged, { + embeddedTerminalCommands: [ + ...embeddedTerminalCommands(notification.sessionId, toolCall.toolCallId), + ...(rootSessionId === null + ? [] + : embeddedTerminalCommands(rootSessionId, toolCall.toolCallId)), + ], + }); + const now = yield* DateTime.now; + const status = toolStatus(merged.status); + const startedAt = context.toolStartedAt.get(key) ?? now; + context.toolStartedAt.set(key, startedAt); + const ordinal = resolveSubagentChildOrdinal(subagent, key); + yield* emitProviderEvent({ + type: "turn_item.updated", + driver, + turnItem: { + id: providerTurnItemId(key), + threadId: subagent.childThreadId, + runId: null, + nodeId: subagent.childRootNodeId, + providerThreadId: subagent.task.providerThreadId, + providerTurnId: null, + nativeItemRef: { driver, nativeId: key, strength: "strong" }, + parentItemId: null, + ordinal, + status, + title: merged.title ?? merged.kind ?? "Tool", + startedAt, + completedAt: completedAtForStatus(status, now), + updatedAt: now, + type: "dynamic_tool", + ...(mcpIdentity === undefined + ? {} + : mcpToolPresentation({ + serverName: mcpIdentity.server, + toolName: mcpIdentity.tool, + source: unknownRecord( + ( + unknownRecord(unknownRecord(merged.data.rawOutput)?.result) ?? + unknownRecord(merged.data.rawOutput) + )?._meta, + )?.source, + })), + toolName: + mcpIdentity === undefined + ? (merged.title ?? merged.kind ?? "Tool") + : `${mcpIdentity.server}.${mcpIdentity.tool}`, + input: merged.data.rawInput ?? null, + output: merged.data.rawOutput ?? merged.data.content ?? null, + }, + }); + } + return; + } + const isDisplayableAssistantUpdate = + (update.sessionUpdate === "agent_message_chunk" && + acpContentBlockDisplayText(update.content) !== undefined) || + (update.sessionUpdate === "agent_message" && update.content !== undefined); + if (!isDisplayableAssistantUpdate) { + return; + } + if (subagent !== undefined) { + yield* projectSubagentNotification(subagent, notification); + return; + } + const buffered = context.pendingSubagentNotifications.get(notification.sessionId) ?? []; + buffered.push(notification); + context.pendingSubagentNotifications.set(notification.sessionId, buffered); + return; + } + // Re-check after the activeSessionId yield: idle/prompt settle can + // finalize the same context object while we waited. + if (context.finalized) return; + // The agent started its own turn about finished background work while + // this settled root turn was still held for it. That turn is the + // agent's reply, not more of the root: settle the root first so the + // frame reaches the wake path and opens a continuation run. + if ( + context.promptSettled && + !acpIsProviderContinuationMessage(context.input.message) && + flavor.isProviderWakeNotification?.(notification) === true + ) { + if (hasDeferredBackgroundWork(context)) { + yield* Ref.update(wakeBuffer, (current) => [...current, notification]); + return; + } + yield* finalizeTurn(context, context.promptSettledStatus ?? "completed"); + const wake = yield* bufferPostSettleWake(notification); + if (wake.offerContinuation) { + yield* offerContinuationRun(notification.sessionId); + } + return; + } + // Only fresh model output proves a retry recovered; progress on + // tools and plans that started earlier can arrive mid-retry. + if ( + update.sessionUpdate !== "tool_call_update" && + update.sessionUpdate !== "plan" && + update.sessionUpdate !== "plan_update" && + update.sessionUpdate !== "plan_removed" && + acpRootSessionUpdateIngestsOutput(notification) + ) { + yield* emitProviderRetry(context, "completed"); + } + switch (update.sessionUpdate) { + case "state_update": { + const toolCallId = `${context.nativeTurnId}:requires-action`; + const existing = context.tools.get(toolCallId); + if (update.state === "requires_action") { + yield* emitTool(context, { + toolCallId, + kind: "other", + title: "Action required", + status: "requiresAction", + data: { state: "requires_action" }, + }); + } else if (existing?.status === "requiresAction") { + yield* emitTool(context, { + ...existing, + status: "completed", + data: { ...existing.data, state: update.state }, + }); + } + break; + } + case "agent_message_chunk": { + const text = acpContentBlockDisplayText(update.content); + if (text !== undefined) { + const startsNewAssistantSegment = context.assistant.current === null; + // The injected-turn report is streaming; the normal debounce + // after the last chunk takes over from here. Drop matching + // mid-turn armed ids so finalize does not open a duplicate + // wake after the report already projected into this turn. + if (context.pendingInjectedReport.size > 0) { + const reportedTaskIds = [...context.pendingInjectedReport]; + context.pendingInjectedReport.clear(); + yield* clearMidTurnUnreportedTaskIds(reportedTaskIds); + } else if ( + startsNewAssistantSegment && + context.promptSettled && + (yield* Ref.get(midTurnUnreportedCompletedTaskIds)).size > 0 + ) { + // Some ACP implementations deliver the injected assistant + // report before the synthetic monitor end notice. Correlation + // arrives with that notice, so remember the unmatched report + // and consume its task id when the notice follows. + context.earlyInjectedReportObserved = true; + } + yield* appendText(context, "assistant", text, update.messageId); + } + break; + } + case "agent_thought_chunk": { + const text = acpContentBlockDisplayText(update.content); + if (text !== undefined) { + yield* appendText(context, "reasoning", text, update.messageId); + } + break; + } + case "user_message": + case "agent_message": + case "agent_thought": { + if (update.content === undefined) break; + const text = (update.content ?? []) + .flatMap((content) => { + const display = acpContentBlockDisplayText(content); + return display === undefined ? [] : [display]; + }) + .join("\n"); + yield* replaceText( + context, + update.sessionUpdate === "user_message" + ? "user" + : update.sessionUpdate === "agent_message" + ? "assistant" + : "reasoning", + text, + update.messageId, + ); + break; + } + case "tool_call_content_chunk": { + const text = + update.content.type === "content" + ? acpContentBlockDisplayText(update.content.content) + : update.content.type === "diff" + ? "changes" in update.content + ? update.content.patch?.text + : update.content.newText + : undefined; + if (text !== undefined) { + const previous = context.tools.get(update.toolCallId) ?? { + toolCallId: update.toolCallId, + status: "inProgress" as const, + data: { toolCallId: update.toolCallId }, + }; + yield* emitTool(context, appendToolOutputText(previous, text)); + } + break; + } + case "compaction_summary_chunk": { + const text = acpContentBlockDisplayText(update.content); + if (text !== undefined) { + const toolCallId = `acp-compaction:${update.compactionId}`; + const previous = context.tools.get(toolCallId) ?? { + toolCallId, + kind: "think", + title: "Compact context", + status: "inProgress" as const, + data: { toolCallId }, + }; + yield* emitTool(context, appendToolOutputText(previous, text)); + } + break; + } + case "user_message_chunk": { + if (update.content.type === "text" && flavor.extractBackgroundToolMutation) { + const mutations = flavor.extractBackgroundToolMutation(update.content.text); + const terminalTaskIds = mutations + .filter((mutation) => mutation.status !== "running") + .map((mutation) => mutation.taskId); + const reportArrivedBeforeNotice = + context.earlyInjectedReportObserved && terminalTaskIds.length > 0; + if (reportArrivedBeforeNotice) { + context.earlyInjectedReportObserved = false; + yield* clearMidTurnUnreportedTaskIds(terminalTaskIds); + } + for (const mutation of mutations) { + const toolCallId = context.toolCallIdsByBackgroundTaskId.get(mutation.taskId); + const previous = + toolCallId !== undefined ? context.tools.get(toolCallId) : undefined; + if (previous !== undefined) { + let nextStatus = + mutation.status === "running" + ? ("inProgress" as const) + : mutation.status === "failed" + ? ("failed" as const) + : ("completed" as const); + // End notices typically omit full stdout (no get_command mention + // either). Keep the tool running and hold finalize until + // TaskOutput hydrates, or the safety timer force-completes. + if (nextStatus !== "inProgress") { + yield* markAwaitingBackgroundHydration(context, mutation.taskId); + nextStatus = "inProgress"; + } + yield* emitTool( + context, + appendToolOutputText( + { ...previous, status: nextStatus }, + mutation.appendOutput, + ), + ); + } + // After emitTool: the hydration hold keeps the tool row at + // inProgress past an end notice, but the offer gate must see + // the mutation's semantic status. Also tracks monitors that + // never surfaced a tool_call row at all. + yield* applyBackgroundTaskMutationRunning(mutation); + if (mutation.status !== "running" && !reportArrivedBeforeNotice) { + yield* markPendingInjectedReport(context, mutation.taskId); + } + } + } + if (update.content.type === "text" && flavor.extractSubagentEndNotice) { + const notice = flavor.extractSubagentEndNotice(update.content.text); + const subagent = + notice !== undefined + ? context.subagentsBySessionId.get(notice.childSessionId) + : undefined; + if ( + notice !== undefined && + subagent !== undefined && + (subagent.task.status === "running" || subagent.task.status === "pending") + ) { + // The agent is free to answer with text only and never call + // get_command_or_subagent_output; without this, the subagent + // row stays running and holds deferred finalize open forever. + yield* emitSubagent(context, { + nativeTaskId: subagent.task.nativeTaskRef?.nativeId ?? notice.childSessionId, + prompt: subagent.task.prompt, + title: subagent.task.title, + model: subagent.task.model, + status: notice.status, + childSessionId: notice.childSessionId, + result: null, + suppressNormalTool: true, + }); + } + } + // startTurn already projects the submitted prompt. Active ACP + // user chunks are transport echoes or injected background + // notices, so only their semantic mutations belong in the turn. + break; + } + default: { + const parsed = parseSessionUpdateEvent(notification); + for (const event of parsed.events) { + if (event._tag === "ToolCallUpdated") { + yield* emitTool(context, event.toolCall); + } else if (event._tag === "PlanUpdated") { + yield* emitPlan(context, event.payload); + } else if (event._tag === "UnknownUpdate") { + yield* emitTool(context, { + toolCallId: `${context.nativeTurnId}:unsupported:${event.updateType}`, + kind: "other", + title: `Unsupported ACP update: ${event.updateType}`, + status: "completed", + data: { updateType: event.updateType }, + }); + } + } + } + } + // Keep deferred finalize quiet-window fresh while wake traffic lands. + yield* rearmDeferredFinalize(context); + }); + + const activeContext = Effect.gen(function* () { + const context = yield* Ref.get(activeTurn); + if (context === null) { + return yield* new EffectAcpErrors.AcpTransportError({ + detail: "ACP agent requested input without an active turn", + cause: "No active ACP turn", + }); + } + return context; + }); + + const beginApprovalRequest = Effect.fnUntraced(function* ( + context: ActiveAcpTurn, + params: EffectAcpSchema.RequestPermissionRequest, + generation: number, + transportRequestId: string, + ) { + yield* closeTextStreams(context); + const parsed = parsePermissionRequest(params); + const approvalOptions = flavor.approvalOptions?.(params); + const nativeRequestId = params.toolCall.toolCallId; + const requestId = yield* idAllocator.allocate.runtimeRequest({ + driver, + providerTurnId: context.providerTurnId, + nativeRequestId, + }); + const decision = yield* Deferred.make(); + const nativeResponseAcknowledgement = yield* Deferred.make< + void, + EffectAcpErrors.AcpError + >(); + yield* registerNativeResponseAcknowledgement( + generation, + transportRequestId, + nativeResponseAcknowledgement, + ); + const now = yield* DateTime.now; + const nodeId = idAllocator.derive.approvalNode({ requestId }); + const requestKind = providerRequestKind(parsed.kind); + const nativeItemRef = { driver, nativeId: nativeRequestId, strength: "weak" as const }; + const ordinal = yield* resolveItemOrdinal( + context, + `${context.nativeTurnId}:approval:${nativeRequestId}`, + ); + const runtimeRequest: OrchestrationV2RuntimeRequest = { + id: requestId, + nodeId, + providerTurnId: context.providerTurnId, + nativeRequestRef: nativeItemRef, + kind: requestKind, + status: "pending", + responseCapability: { + type: "live", + providerSessionId: input.providerSessionId, + }, + createdAt: now, + resolvedAt: null, + }; + const node: OrchestrationV2ExecutionNode = { + id: nodeId, + threadId: context.input.threadId, + runId: context.input.runId, + parentNodeId: context.input.rootNodeId, + rootNodeId: context.input.rootNodeId, + kind: "approval_request", + status: "waiting", + countsForRun: false, + providerThreadId: context.input.providerThread.id, + providerTurnId: context.providerTurnId, + nativeItemRef, + runtimeRequestId: requestId, + checkpointScopeId: null, + startedAt: now, + completedAt: null, + }; + const turnItem: OrchestrationV2TurnItem = { + id: idAllocator.derive.approvalTurnItem({ requestId }), + threadId: context.input.threadId, + runId: context.input.runId, + nodeId, + providerThreadId: context.input.providerThread.id, + providerTurnId: context.providerTurnId, + nativeItemRef, + parentItemId: null, + ordinal, + status: "waiting", + title: null, + startedAt: now, + completedAt: null, + updatedAt: now, + type: "approval_request", + requestId, + requestKind, + ...(parsed.detail === undefined ? {} : { prompt: parsed.detail }), + ...(approvalOptions === undefined ? {} : { options: approvalOptions }), + }; + yield* Ref.update(pendingRuntimeRequests, (current) => { + const updated = new Map(current); + updated.set(String(requestId), { + type: "approval", + generation, + nativeResponseAcknowledgement, + requestId, + transportRequestId, + decision, + runtimeRequest, + node, + turnItem, + }); + return updated; + }); + yield* emitProviderEvent({ + type: "node.updated", + driver, + node, + }); + yield* emitProviderEvent({ + type: "runtime_request.updated", + driver, + threadId: context.input.threadId, + runtimeRequest, + }); + yield* emitProviderEvent({ + type: "turn_item.updated", + driver, + turnItem, + }); + return { + context, + decision, + nativeResponseAcknowledgement, + requestId, + transportRequestId, + } as const; + }); + + const beginUserInputRequest = Effect.fnUntraced(function* ( + request: AcpAdapterV2UserInputRequest, + generation: number, + transportRequestId: string, + ) { + const context = yield* activeContext; + yield* closeTextStreams(context); + const requestId = yield* idAllocator.allocate.runtimeRequest({ + driver, + providerTurnId: context.providerTurnId, + nativeRequestId: request.nativeRequestId, + }); + const answers = yield* Deferred.make(); + const nativeResponseAcknowledgement = yield* Deferred.make< + void, + EffectAcpErrors.AcpError + >(); + yield* registerNativeResponseAcknowledgement( + generation, + transportRequestId, + nativeResponseAcknowledgement, + ); + const now = yield* DateTime.now; + const nodeId = providerNodeId(request.nativeItemId); + const turnItemId = providerTurnItemId(request.nativeItemId); + const nativeItemRef = { + driver, + nativeId: request.nativeItemId, + strength: "weak" as const, + }; + const ordinal = yield* resolveItemOrdinal(context, request.nativeItemId); + const runtimeRequest: OrchestrationV2RuntimeRequest = { + id: requestId, + nodeId, + providerTurnId: context.providerTurnId, + nativeRequestRef: { + driver, + nativeId: request.nativeRequestId, + strength: "weak", + }, + kind: "user_input", + status: "pending", + responseCapability: { + type: "live", + providerSessionId: input.providerSessionId, + }, + createdAt: now, + resolvedAt: null, + }; + const node: OrchestrationV2ExecutionNode = { + id: nodeId, + threadId: context.input.threadId, + runId: context.input.runId, + parentNodeId: context.input.rootNodeId, + rootNodeId: context.input.rootNodeId, + kind: "user_input_request", + status: "waiting", + countsForRun: false, + providerThreadId: context.input.providerThread.id, + providerTurnId: context.providerTurnId, + nativeItemRef, + runtimeRequestId: requestId, + checkpointScopeId: null, + startedAt: now, + completedAt: null, + }; + const turnItem: OrchestrationV2TurnItem = { + id: turnItemId, + threadId: context.input.threadId, + runId: context.input.runId, + nodeId, + providerThreadId: context.input.providerThread.id, + providerTurnId: context.providerTurnId, + nativeItemRef, + parentItemId: null, + ordinal, + status: "waiting", + title: null, + startedAt: now, + completedAt: null, + updatedAt: now, + type: "user_input_request", + requestId, + questions: [...request.questions], + }; + yield* Ref.update(pendingRuntimeRequests, (current) => { + const updated = new Map(current); + updated.set(String(requestId), { + type: "user_input", + generation, + nativeResponseAcknowledgement, + requestId, + transportRequestId, + answers, + runtimeRequest, + node, + turnItem, + }); + return updated; + }); + yield* emitProviderEvent({ + type: "node.updated", + driver, + node, + }); + yield* emitProviderEvent({ + type: "runtime_request.updated", + driver, + threadId: context.input.threadId, + runtimeRequest, + }); + yield* emitProviderEvent({ + type: "turn_item.updated", + driver, + turnItem, + }); + return { + answers, + context, + nativeResponseAcknowledgement, + requestId, + transportRequestId, + } as const; + }); + + const requestUserInputWithAdmission = ( + generation: number, + request: Effect.Effect, + transportRequestId: string, + ) => + runRuntimeCallbackAtGeneration( + generation, + request.pipe( + Effect.flatMap((value) => + beginUserInputRequest(value, generation, transportRequestId), + ), + ), + ).pipe( + Effect.flatMap((pending) => { + if (Option.isNone(pending)) return Effect.never; + const { answers, requestId, transportRequestId } = pending.value; + return Deferred.await(answers).pipe( + Effect.flatMap((result) => + runRuntimeCallbackAtGeneration(generation, Effect.succeed(result)).pipe( + Effect.flatMap((checked) => + Option.isSome(checked) + ? Effect.succeed({ + acknowledgeNativeResponse: acknowledgeNativeResponse( + generation, + transportRequestId, + ), + answers: checked.value, + }) + : Effect.never, + ), + ), + ), + Effect.ensuring( + runRuntimeCallbackAtGeneration( + generation, + Effect.gen(function* () { + yield* Ref.update(pendingRuntimeRequests, (current) => { + const updated = new Map(current); + updated.delete(String(requestId)); + return updated; + }); + }), + ).pipe(Effect.asVoid), + ), + ); + }), + Effect.mapError( + (cause) => + new EffectAcpErrors.AcpTransportError({ + detail: "Failed to handle ACP user input request", + cause, + }), + ), + ); + + const cancelPendingRuntimeRequests = Effect.fnUntraced(function* () { + const requests = yield* Ref.modify(pendingRuntimeRequests, (current) => [ + [...current.values()], + new Map(), + ]); + if (requests.length === 0) return; + + const now = yield* DateTime.now; + yield* Effect.forEach( + requests, + (request) => + Effect.gen(function* () { + const cancelled = yield* request.type === "approval" + ? Deferred.succeed(request.decision, "cancel") + : Deferred.succeed(request.answers, null); + if (!cancelled) return; + + yield* emitProviderEvent({ + type: "runtime_request.updated", + driver, + threadId: request.node.threadId, + runtimeRequest: { + ...request.runtimeRequest, + status: "cancelled", + resolvedAt: now, + }, + }); + yield* emitProviderEvent({ + type: "node.updated", + driver, + node: { + ...request.node, + status: "cancelled", + completedAt: now, + }, + }); + yield* emitProviderEvent({ + type: "turn_item.updated", + driver, + turnItem: { + ...request.turnItem, + status: "cancelled", + completedAt: now, + updatedAt: now, + }, + }); + }), + { concurrency: 1, discard: true }, + ); + }); + + /** + * Mutate a carryover subagent's in-memory task status without projecting. + * Used after a non-completed root whose original run subscriber is no + * longer guaranteed to ingest the terminal event. + */ + const mutateCarryoverSubagentStatus = Effect.fnUntraced(function* ( + subagent: ActiveAcpSubagent, + status: OrchestrationV2Subagent["status"], + resultOverride?: string | null, + ) { + const now = yield* DateTime.now; + const result = + resultOverride !== undefined && resultOverride !== null && resultOverride.length > 0 + ? resultOverride + : (subagent.task.result ?? (subagent.assistantText || null)); + const completedAt = acpSubagentStatusIsTerminal(status) ? now : null; + subagent.task = { + ...subagent.task, + status, + result, + completedAt, + updatedAt: now, + }; + }); + + /** + * Project a carryover subagent status change without an ActiveAcpTurn. + * Shared by completed-root post-settle completion, deferred attach, and + * Direct Stop terminalization. + */ + const projectCarryoverSubagentStatus = Effect.fnUntraced(function* ( + subagent: ActiveAcpSubagent, + status: OrchestrationV2Subagent["status"], + resultOverride?: string | null, + ) { + yield* mutateCarryoverSubagentStatus(subagent, status, resultOverride); + const now = subagent.task.updatedAt; + const nativeTaskId = subagent.task.nativeTaskRef?.nativeId ?? subagent.task.id; + const nativeItemRef = { + driver, + nativeId: nativeTaskId, + strength: "strong" as const, + }; + const parentProviderThreadId = subagent.parentProviderThreadId; + const result = subagent.task.result; + const completedAt = subagent.task.completedAt; + yield* emitProviderEvent({ + type: "node.updated", + driver, + node: { + id: subagent.task.id, + threadId: subagent.task.threadId, + runId: subagent.task.runId, + parentNodeId: subagent.task.parentNodeId, + rootNodeId: subagent.task.parentNodeId, + kind: "subagent", + status, + countsForRun: false, + providerThreadId: parentProviderThreadId, + providerTurnId: subagent.providerTurnId, + nativeItemRef, + runtimeRequestId: null, + checkpointScopeId: null, + startedAt: subagent.task.startedAt, + completedAt, + }, + }); + yield* emitProviderEvent({ + type: "node.updated", + driver, + node: { + id: subagent.childRootNodeId, + threadId: subagent.childThreadId, + runId: null, + parentNodeId: null, + rootNodeId: subagent.childRootNodeId, + kind: "root_turn", + status, + countsForRun: false, + providerThreadId: subagent.task.providerThreadId, + providerTurnId: null, + nativeItemRef, + runtimeRequestId: null, + checkpointScopeId: null, + startedAt: subagent.task.startedAt, + completedAt, + }, + }); + yield* emitProviderEvent({ + type: "subagent.updated", + driver, + subagent: subagent.task, + }); + yield* emitProviderEvent({ + type: "turn_item.updated", + driver, + turnItem: { + id: subagent.turnItemId, + threadId: subagent.task.threadId, + runId: subagent.task.runId, + nodeId: subagent.task.id, + providerThreadId: parentProviderThreadId, + providerTurnId: subagent.providerTurnId, + nativeItemRef, + parentItemId: null, + ordinal: subagent.turnItemOrdinal, + status, + title: subagent.task.title, + startedAt: subagent.task.startedAt, + completedAt, + updatedAt: now, + type: "subagent", + subagentId: subagent.task.id, + origin: "provider_native", + driver, + providerInstanceId: subagent.task.providerInstanceId, + childThreadId: subagent.childThreadId, + prompt: subagent.task.prompt, + result, + }, + }); + if (acpSubagentStatusIsTerminal(status)) { + subagent.terminalStatusProjected = true; + } + }); + + /** + * Update a carryover subagent matched by native task id or child session + * id. Post-settle completions must flip the pin in hasPendingBackgroundWork + * without waiting for a new user turn to consume carryover. + * When `project` is false, only the in-memory carryover status advances + * (the next attach projects the terminal state). + * If status already matches but projection was deferred, a later + * project:true caller still projects once via terminalStatusProjected. + */ + const updateCarryoverSubagentStatus = Effect.fnUntraced(function* ( + nativeIdOrChildSessionId: string, + status: OrchestrationV2Subagent["status"], + result?: string | null, + options?: { readonly project?: boolean }, + ) { + const carryover = yield* Ref.get(carryoverSubagents); + if (carryover === null) return false; + const match = carryover.subagents.find((subagent) => { + const nativeId = subagent.task.nativeTaskRef?.nativeId ?? String(subagent.task.id); + return ( + nativeId === nativeIdOrChildSessionId || + subagent.childSessionId === nativeIdOrChildSessionId + ); + }); + if (match === undefined) return false; + const shouldProject = options?.project !== false; + if (match.task.status === status) { + // Already at this status: only project if requested and not yet done. + if (!shouldProject || match.terminalStatusProjected) { + return true; + } + yield* projectCarryoverSubagentStatus(match, status, result); + return true; + } + // Only advance nonterminal entries; do not resurrect a terminal one. + if (match.task.status !== "running" && match.task.status !== "pending") { + return false; + } + if (!shouldProject) { + yield* mutateCarryoverSubagentStatus(match, status, result); + } else { + yield* projectCarryoverSubagentStatus(match, status, result); + } + return true; + }); + + const finishSubagentFromNotice = Effect.fnUntraced(function* (notice: { + readonly childSessionId: string; + readonly status: "completed" | "failed" | "cancelled"; + readonly result: string | null; + }) { + const context = yield* Ref.get(activeTurn); + const subagent = + context === null ? undefined : context.subagentsBySessionId.get(notice.childSessionId); + const known = + subagent ?? + (yield* Ref.get(carryoverSubagents))?.subagents.find( + (candidate) => + candidate.childSessionId === notice.childSessionId || + candidate.task.nativeTaskRef?.nativeId === notice.childSessionId, + ); + if (known !== undefined && acpSubagentStatusBlocksTurnSettlement(known.task.status)) { + yield* recordWakeReport(notice.childSessionId, { + kind: "subagent", + label: known.task.title ?? known.task.prompt, + outcome: notice.status, + childThreadId: known.childThreadId, + }); + } + if (context !== null && subagent !== undefined && !context.finalized) { + if (!acpSubagentStatusBlocksTurnSettlement(subagent.task.status)) return; + yield* emitSubagent(context, { + nativeTaskId: subagent.task.nativeTaskRef?.nativeId ?? notice.childSessionId, + prompt: subagent.task.prompt, + title: subagent.task.title, + model: subagent.task.model, + status: notice.status, + childSessionId: notice.childSessionId, + result: notice.result, + suppressNormalTool: true, + }); + yield* rearmDeferredFinalize(context); + return; + } + // The root turn already settled: the subagent is carryover. Project + // its end while the completed root still owns the run. + const carryover = yield* Ref.get(carryoverSubagents); + yield* updateCarryoverSubagentStatus( + notice.childSessionId, + notice.status, + notice.result, + { + project: + carryover !== null && + carryover.sessionId === (yield* Ref.get(activeSessionId)) && + carryover.rootTerminalStatus === "completed", + }, + ); + }); + + applyFinalizedActiveTurnSubagentTerminal = Effect.fnUntraced(function* ( + context: ActiveAcpTurn, + notification: EffectAcpSchema.SessionNotification, + ) { + if (flavor.extractSubagentUpdate === undefined) return false; + + const applyTerminal = Effect.fnUntraced(function* ( + subagent: ActiveAcpSubagent, + update: AcpAdapterV2SubagentUpdate, + ) { + if (!acpSubagentStatusIsTerminal(update.status)) return false; + if (acpSubagentStatusIsTerminal(subagent.task.status)) { + if (subagent.terminalStatusProjected || context.finalizedStatus !== "completed") { + return true; + } + } + if (context.finalizedStatus === "completed") { + yield* emitSubagent(context, update); + } else { + yield* mutateCarryoverSubagentStatus(subagent, update.status, update.result); + } + return true; + }); + + const sessionUpdate = notification.update; + if ( + sessionUpdate.sessionUpdate === "tool_call" || + sessionUpdate.sessionUpdate === "tool_call_update" + ) { + for (const event of parseSessionUpdateEvent(notification).events) { + if (event._tag !== "ToolCallUpdated") continue; + const toolCall = flavor.normalizeToolCall?.(event.toolCall) ?? event.toolCall; + const subagentUpdate = flavor.extractSubagentUpdate(toolCall); + if ( + subagentUpdate === undefined || + !acpSubagentStatusIsTerminal(subagentUpdate.status) + ) { + continue; + } + const subagent = + context.subagents.get(subagentUpdate.nativeTaskId) ?? + (subagentUpdate.childSessionId === null + ? undefined + : context.subagentsBySessionId.get(subagentUpdate.childSessionId)) ?? + context.subagentsBySessionId.get(notification.sessionId); + if (subagent === undefined) continue; + const nativeTaskId = + subagent.task.nativeTaskRef?.nativeId ?? String(subagent.task.id); + if ( + subagentUpdate.nativeTaskId !== nativeTaskId && + (subagentUpdate.childSessionId === null || + (subagentUpdate.childSessionId !== subagent.childSessionId && + subagentUpdate.childSessionId !== notification.sessionId)) + ) { + continue; + } + if (yield* applyTerminal(subagent, subagentUpdate)) return true; + } + } + + if ( + sessionUpdate.sessionUpdate === "user_message_chunk" && + sessionUpdate.content.type === "text" && + flavor.extractSubagentEndNotice !== undefined + ) { + const notice = flavor.extractSubagentEndNotice(sessionUpdate.content.text); + const subagent = + notice === undefined + ? undefined + : context.subagentsBySessionId.get(notice.childSessionId); + if (notice !== undefined && subagent !== undefined) { + return yield* applyTerminal(subagent, { + nativeTaskId: subagent.task.nativeTaskRef?.nativeId ?? String(subagent.task.id), + prompt: subagent.task.prompt, + title: subagent.task.title, + model: subagent.task.model, + status: notice.status, + childSessionId: notice.childSessionId, + result: null, + suppressNormalTool: true, + }); + } + } + + return false; + }); + + const projectDeferredCarryoverTerminals = Effect.fnUntraced(function* ( + subagents: ReadonlyArray, + ) { + for (const subagent of subagents) { + if ( + subagent.task.status === "running" || + subagent.task.status === "pending" || + subagent.terminalStatusProjected + ) { + continue; + } + yield* projectCarryoverSubagentStatus( + subagent, + subagent.task.status, + subagent.task.result, + ); + } + }); + + /** + * Direct Stop after a soft steer clears carryover without an active turn. + * Emit the same interrupted terminal events terminalizeOpenRunOwnedItems + * would have, context-free (no ActiveAcpTurn). + */ + const terminalizeCarryoverSubagents = Effect.fnUntraced(function* ( + carryover: AcpCarryoverSubagents | null, + ) { + if (carryover === null) return; + for (const subagent of carryover.subagents) { + if (acpSubagentStatusIsTerminal(subagent.task.status)) { + if (!subagent.terminalStatusProjected) { + yield* projectCarryoverSubagentStatus( + subagent, + subagent.task.status, + subagent.task.result, + ); + } + continue; + } + yield* projectCarryoverSubagentStatus(subagent, "interrupted"); + } + }); + + const projectAcpRuntimeSessionUpdateEffect = ( + rawNotification: EffectAcpSchema.SessionNotification, + ) => + Effect.gen(function* () { + if (clientTerminals !== undefined) { + const embedded = embeddedTerminalIdsFromSessionUpdate(rawNotification); + if (embedded !== undefined) { + rememberEmbeddedTerminals({ + ...embedded, + sessionId: rawNotification.sessionId, + }); + } + } + const notification = + clientTerminals === undefined + ? rawNotification + : resolveEmbeddedTerminalContent( + rawNotification, + clientTerminals.readOutputSnapshot, + ); + if (notification.update.sessionUpdate === "available_commands_update") { + yield* ( + flavor.onAvailableCommandsUpdate?.(notification.update.availableCommands) ?? + Effect.void + ); + } + if ( + notification.update.sessionUpdate === "config_option_update" || + notification.update.sessionUpdate === "current_mode_update" + ) { + yield* ( + flavor.onSessionConfigurationUpdate?.( + yield* runtime.getConfigOptions, + yield* runtime.getModeState, + ) ?? Effect.void + ); + } + yield* handleSessionUpdate( + flavor.normalizeSessionUpdate?.(notification) ?? notification, + ); + }).pipe( + Effect.mapError( + (cause) => + new EffectAcpErrors.AcpTransportError({ + detail: "Failed to project an ACP session update", + cause, + }), + ), + ); + const projectAcpRuntimeSessionUpdate = ( + handlerGeneration: number, + rawNotification: EffectAcpSchema.SessionNotification, + ) => + runRuntimeCallbackAtGeneration( + handlerGeneration, + projectAcpRuntimeSessionUpdateEffect(rawNotification), + ).pipe(Effect.asVoid); + + // Falls back to the latest turn policy when no turn is active so + // post-settle background work stays under the policy it started with. + const clientPolicyContext = Effect.map(Ref.get(activeTurn), (context) => ({ + policy: context?.input.runtimePolicy ?? latestRuntimePolicy, + turnKey: context === null ? null : String(context.providerTurnId), + })); + + const denyClientRequest = (operation: string, disposition: "ask" | "deny") => + Effect.logWarning("ACP client policy denied a client-mediated operation", { + operation, + disposition, + }).pipe( + Effect.andThen( + Effect.fail( + EffectAcpErrors.AcpRequestError.internalError( + disposition === "ask" + ? `The active T3 runtime policy requires approval for ${operation}. Request permission with session/request_permission before retrying.` + : `The active T3 runtime policy does not allow ${operation}.`, + ), + ), + ), + ); + + const guardClientTerminalCreate = clientPolicyContext.pipe( + Effect.flatMap(({ policy, turnKey }) => { + const disposition = acpClientExecuteDisposition(policy); + if ( + disposition === "allow" || + (disposition === "ask" && clientPolicyGrants.allowsExecute(turnKey)) + ) { + return Effect.void; + } + return denyClientRequest("terminal/create", disposition); + }), + ); + + const wireAcpRuntimeTerminalHandlers = Effect.fnUntraced(function* ( + targetRuntime: AcpSessionRuntime.AcpSessionRuntime["Service"], + ) { + if (clientTerminals === undefined) return; + yield* targetRuntime.handleCreateTerminal((request) => + guardClientTerminalCreate.pipe(Effect.andThen(clientTerminals.create(request))), + ); + yield* targetRuntime.handleTerminalOutput(clientTerminals.output); + yield* targetRuntime.handleTerminalWaitForExit(clientTerminals.waitForExit); + yield* targetRuntime.handleTerminalKill(clientTerminals.kill); + yield* targetRuntime.handleTerminalRelease(clientTerminals.release); + }); + + const wireAcpRuntimeMcpHandlers = Effect.fnUntraced(function* ( + targetRuntime: AcpSessionRuntime.AcpSessionRuntime["Service"], + mcpBridge: AcpMcpOverAcpBridge | undefined, + ) { + if (mcpBridge === undefined) return; + const mapMcpBridgeError = Effect.mapError( + (error: Error) => + new EffectAcpErrors.AcpRequestError({ + code: -32603, + errorMessage: error.message, + cause: error, + }), + ); + yield* targetRuntime.handleMcpConnect((request) => + mcpBridge.connect(request).pipe(mapMcpBridgeError), + ); + yield* targetRuntime.handleMcpMessage((request) => + mcpBridge.message(request).pipe(mapMcpBridgeError), + ); + yield* targetRuntime.handleMcpNotification((request) => + mcpBridge.notification(request).pipe(mapMcpBridgeError), + ); + yield* targetRuntime.handleMcpDisconnect((request) => + mcpBridge.disconnect(request).pipe(mapMcpBridgeError), + ); + }); + + const wireAcpRuntimeHandlers = Effect.fnUntraced(function* ( + targetRuntime: AcpSessionRuntime.AcpSessionRuntime["Service"], + handlerGeneration: number, + handlerOptions: { + readonly sessionUpdates?: boolean; + readonly terminals?: boolean; + readonly mcp?: boolean; + } = {}, + ) { + yield* targetRuntime.getEvents().pipe( + Stream.runForEach((event) => { + if (event._tag === "EventStreamBarrier") { + return Deferred.succeed(event.acknowledge, undefined).pipe(Effect.asVoid); + } + return runRuntimeCallbackAtGeneration( + handlerGeneration, + flavor.onSessionEvent?.(event) ?? Effect.void, + ).pipe(Effect.asVoid); + }), + Effect.forkIn(runtimeScope ?? sessionScope), + ); + const requestUserInput = ( + request: AcpAdapterV2UserInputRequest, + requestContext: EffectAcpProtocol.AcpRequestContext, + ) => + requestUserInputWithAdmission( + handlerGeneration, + Effect.succeed(request), + requestContext.requestId, + ); + // Without the capability no fs handler is registered, so a stray + // request (OpenCode and Kilo send one after approved edits) gets + // method-not-found and cannot touch the disk. A flavor that opts in + // serves requests itself, confined to the workspace of the policy + // active when the request arrives; the agent asks before its edits. + const clientFileSystem = flavor.clientFileSystem; + if (clientFileSystem !== undefined) { + yield* targetRuntime.handleReadTextFile((request) => + clientPolicyContext.pipe( + Effect.flatMap(({ policy }) => clientFileSystem.readTextFile(request, policy.cwd)), + ), + ); + yield* targetRuntime.handleWriteTextFile((request) => + clientPolicyContext.pipe( + Effect.flatMap(({ policy }) => clientFileSystem.writeTextFile(request, policy.cwd)), + ), + ); + } + if (handlerOptions.mcp !== false) { + yield* wireAcpRuntimeMcpHandlers(targetRuntime, runtimeMcpBridge); + } + if (handlerOptions.terminals !== false) { + yield* wireAcpRuntimeTerminalHandlers(targetRuntime); + } + if (handlerOptions.sessionUpdates !== false) { + yield* targetRuntime.handleSessionUpdate((rawNotification) => + projectAcpRuntimeSessionUpdate(handlerGeneration, rawNotification), + ); + } + yield* targetRuntime.handleRequestPermission((params, requestContext) => + Effect.gen(function* () { + const transportRequestId = requestContext.requestId; + const permissionQuestion = flavor.extractPermissionQuestion?.(params); + if (permissionQuestion !== undefined) { + const userInput = yield* requestUserInputWithAdmission( + handlerGeneration, + Effect.succeed({ + nativeItemId: `${params.sessionId}:question:${params.toolCall.toolCallId}`, + nativeMethod: "session/request_permission", + nativeRequestId: params.toolCall.toolCallId, + nativeSessionId: params.sessionId, + questions: [permissionQuestion.question], + }), + transportRequestId, + ); + const response = + userInput.answers === null + ? undefined + : permissionQuestion.respond(userInput.answers); + yield* userInput.acknowledgeNativeResponse; + return response ?? ({ outcome: { outcome: "cancelled" } } as const); + } + const admitted = yield* runRuntimeCallbackAtGeneration( + handlerGeneration, + Effect.gen(function* () { + const context = yield* activeContext; + const disposition = (flavor.permissionDisposition ?? acpPermissionDisposition)( + context.input.runtimePolicy, + params, + ); + if (disposition === "allow") { + const optionId = selectAutoApprovedPermissionOption(params); + return { + _tag: "Immediate" as const, + response: + optionId === undefined + ? ({ outcome: { outcome: "cancelled" } } as const) + : ({ outcome: { outcome: "selected", optionId } } as const), + }; + } + if (disposition === "deny") { + const optionId = selectPermissionOptionId(params, "decline"); + return { + _tag: "Immediate" as const, + response: + optionId === undefined + ? ({ outcome: { outcome: "cancelled" } } as const) + : ({ outcome: { outcome: "selected", optionId } } as const), + }; + } + return { + _tag: "Pending" as const, + pending: yield* beginApprovalRequest( + context, + params, + handlerGeneration, + transportRequestId, + ), + }; + }), + ); + if (Option.isNone(admitted)) { + return yield* Effect.never; + } + if (admitted.value._tag === "Immediate") { + const response = admitted.value.response; + const checked = yield* runRuntimeCallbackAtGeneration( + handlerGeneration, + Effect.gen(function* () { + const nativeResponseAcknowledgement = yield* Deferred.make< + void, + EffectAcpErrors.AcpError + >(); + yield* registerNativeResponseAcknowledgement( + handlerGeneration, + transportRequestId, + nativeResponseAcknowledgement, + ); + return response; + }), + ); + return Option.isSome(checked) ? checked.value : yield* Effect.never; + } + const { + context, + decision: pendingDecision, + requestId, + transportRequestId: pendingTransportRequestId, + } = admitted.value.pending; + const parsedPermission = parsePermissionRequest(params); + const decision = yield* Deferred.await(pendingDecision).pipe( + Effect.ensuring( + runRuntimeCallbackAtGeneration( + handlerGeneration, + Effect.gen(function* () { + yield* Ref.update(pendingRuntimeRequests, (current) => { + const updated = new Map(current); + updated.delete(String(requestId)); + return updated; + }); + }), + ).pipe(Effect.asVoid), + ), + ); + if ( + parsedPermission.kind !== "unknown" && + (decision === "accept" || decision === "acceptForSession") + ) { + clientPolicyGrants.recordApproval({ + kind: providerRequestKind(parsedPermission.kind), + scope: decision === "acceptForSession" ? "session" : "turn", + turnKey: String(context.providerTurnId), + }); + } + const response = (() => { + if (decision === "cancel") { + return { outcome: { outcome: "cancelled" } } as const; + } + const optionId = selectPermissionOptionId(params, decision); + return optionId === undefined + ? ({ outcome: { outcome: "cancelled" } } as const) + : ({ outcome: { outcome: "selected", optionId } } as const); + })(); + const checked = yield* runRuntimeCallbackAtGeneration( + handlerGeneration, + Effect.succeed(response), + ); + if (Option.isNone(checked)) return yield* Effect.never; + yield* acknowledgeNativeResponse(handlerGeneration, pendingTransportRequestId); + return checked.value; + }).pipe( + Effect.mapError( + (cause) => + new EffectAcpErrors.AcpTransportError({ + detail: "Failed to handle an ACP permission request", + cause, + }), + ), + ), + ); + yield* targetRuntime.handleElicitation((params, requestContext) => + Effect.gen(function* () { + const transportRequestId = requestContext.requestId; + if ( + params.mode === "form" && + (unknownRecord(params._meta)?.codex_approval_kind === "mcp_tool_call" || + transportRequestId.startsWith("mcp_tool_call_approval_")) + ) { + const mcpApprovalDisposition = yield* runRuntimeCallbackAtGeneration( + handlerGeneration, + Effect.gen(function* () { + const context = yield* activeContext; + const disposition = acpMcpToolApprovalElicitationDisposition( + context.input.runtimePolicy, + params, + transportRequestId, + ); + if (disposition === undefined || disposition === "ask") { + return disposition; + } + const nativeResponseAcknowledgement = yield* Deferred.make< + void, + EffectAcpErrors.AcpError + >(); + yield* registerNativeResponseAcknowledgement( + handlerGeneration, + transportRequestId, + nativeResponseAcknowledgement, + ); + return disposition; + }), + ); + if (Option.isNone(mcpApprovalDisposition)) return yield* Effect.never; + if (mcpApprovalDisposition.value === "allow") { + return { action: "accept", content: {} } as const; + } + if (mcpApprovalDisposition.value === "deny") { + return { action: "decline" } as const; + } + } + if ( + params.mode === "url" && + "url" in params && + typeof params.url === "string" && + "elicitationId" in params && + typeof params.elicitationId === "string" + ) { + const { elicitationId, url } = params; + const admitted = yield* runRuntimeCallbackAtGeneration( + handlerGeneration, + Effect.gen(function* () { + const nativeResponseAcknowledgement = yield* Deferred.make< + void, + EffectAcpErrors.AcpError + >(); + yield* registerNativeResponseAcknowledgement( + handlerGeneration, + transportRequestId, + nativeResponseAcknowledgement, + ); + const accepted = yield* ( + flavor.onUrlElicitation?.({ + elicitationId, + url, + message: params.message, + }) ?? Effect.succeed(false) + ); + return accepted + ? ({ action: "accept" } as const) + : ({ action: "decline" } as const); + }), + ); + if (Option.isNone(admitted)) return yield* Effect.never; + return admitted.value; + } + if (params.mode !== "form" || !("requestedSchema" in params)) { + // Future elicitation modes beyond form and url decline rather + // than guessing at their semantics. + return { action: "decline" } as const; + } + const requestedSchema = unknownRecord(params.requestedSchema); + const properties = unknownRecord(requestedSchema?.properties) ?? {}; + const elicitationScopeId = + "sessionId" in params ? params.sessionId : `request:${params.requestId}`; + const questions = Object.entries(properties).map( + ([id, property], index): OrchestrationV2UserInputQuestion => { + const record = unknownRecord(property); + const enumValues = Array.isArray(record?.enum) + ? record.enum.filter((value): value is string => typeof value === "string") + : []; + const options = + enumValues.length > 0 + ? enumValues.map((value) => ({ label: value, description: value })) + : record?.type === "boolean" + ? [ + { label: "true", description: "Yes" }, + { label: "false", description: "No" }, + ] + : []; + return { + id, + header: nonEmptyText(record?.title, `Question ${index + 1}`), + question: nonEmptyText(record?.description, params.message), + options, + }; + }, + ); + const userInput = yield* requestUserInputWithAdmission( + handlerGeneration, + Effect.gen(function* () { + const ordinal = yield* Ref.getAndUpdate( + nextElicitationOrdinal, + (current) => current + 1, + ); + const nativeRequestId = `${elicitationScopeId}:elicitation:${ordinal}`; + return { + nativeItemId: nativeRequestId, + nativeRequestId, + questions, + }; + }), + transportRequestId, + ); + const response = + userInput.answers === null + ? ({ action: "cancel" } as const) + : ({ + action: "accept", + content: elicitationContent( + userInput.answers, + new Set(Object.keys(properties)), + ), + } as const); + yield* userInput.acknowledgeNativeResponse; + return response; + }), + ); + if (flavor.registerExtensions !== undefined) { + yield* flavor.registerExtensions({ + runtime: targetRuntime, + reportProviderRetry: (notice) => + runRuntimeCallbackAtGeneration(handlerGeneration, reportProviderRetry(notice)).pipe( + Effect.asVoid, + ), + requestUserInput, + captureProposedPlan, + lastProposedPlanMarkdown, + finishSubagent: (notice) => + runRuntimeCallbackAtGeneration( + handlerGeneration, + Effect.gen(function* () { + if (yield* Ref.get(stoppedRunQuarantine)) return; + // Root-session notices only; nested subagents report to + // their own parent session. + if ((yield* Ref.get(activeSessionId)) !== notice.sessionId) return; + yield* finishSubagentFromNotice(notice); + }), + ).pipe(Effect.asVoid), + applyBackgroundTaskMutation: (mutation) => + runRuntimeCallbackAtGeneration( + handlerGeneration, + Effect.gen(function* () { + // Direct Stop quarantine: drop residual task lifecycle from + // the stopped run instead of mutating wake machinery. + if (yield* Ref.get(stoppedRunQuarantine)) return; + // Root-session tasks only: a cancelled subagent's re-run in + // its child session must not gate root wake machinery. + if ((yield* Ref.get(activeSessionId)) !== mutation.sessionId) return; + if ( + mutation.status !== "running" && + mutation.report !== undefined && + !(yield* Ref.get(handledBackgroundTaskIdsInActiveTurn)).has(mutation.taskId) + ) { + yield* recordWakeReport(mutation.taskId, { + ...mutation.report, + outcome: mutation.status, + }); + } + yield* applyLateBackgroundMutation(mutation.sessionId, mutation); + if (mutation.status !== "running") { + yield* finishRegisteredBackgroundTool({ + taskId: mutation.taskId, + status: mutation.status, + ...(mutation.output === undefined ? {} : { output: mutation.output }), + }); + } + }), + ).pipe(Effect.asVoid), + }); + } + }); + + /** Builds the MCP-over-ACP bridge for one runtime, disposed with that runtime's scope. */ + const makeRuntimeMcpBridge = Effect.fnUntraced(function* ( + threadId: ThreadId | null, + scope: Scope.Scope, + ) { + const mcpContext = acpMcpContext(threadId, self); + if (mcpContext.endpoint === undefined || mcpContext.authorization === undefined) { + return undefined; + } + const mcpBridge = yield* makeAcpMcpOverAcpBridge({ + endpoint: mcpContext.endpoint, + authorization: mcpContext.authorization, + allocateConnectionId: options.crypto.randomUUIDv4.pipe(Effect.orDie), + }); + yield* Scope.addFinalizer(scope, mcpBridge.dispose); + return mcpBridge; + }); + + const spawnAcpRuntime = Effect.fnUntraced(function* ( + threadId: ThreadId | null, + resumeSessionId?: string, + ) { + if (runtimeScope !== undefined) { + yield* Scope.close(runtimeScope, Exit.void); + } + runtimeScope = yield* Scope.make(); + runtimeMcpBridge = yield* makeRuntimeMcpBridge(threadId, runtimeScope); + const runtimeGeneration = yield* Ref.get(runtimeCallbackGeneration); + runtime = yield* flavor + .makeRuntime(makeRuntimeInput(runtimeGeneration, threadId, resumeSessionId)) + .pipe( + Effect.provideService(Scope.Scope, runtimeScope), + Effect.provideService(Crypto.Crypto, options.crypto), + ); + }); + + const startAcpRuntime = Effect.fnUntraced(function* ( + threadId: ThreadId | null, + resumeSessionId?: string, + ) { + const startup = Effect.gen(function* () { + yield* spawnAcpRuntime(threadId, resumeSessionId); + yield* wireAcpRuntimeHandlers(runtime, yield* Ref.get(runtimeCallbackGeneration)); + return yield* runtime.start(); + }); + return yield* flavor.withRuntimeStartup?.(startup) ?? startup; + }); + + const restartAcpRuntime = Effect.fnUntraced(function* (threadId: ThreadId | null) { + yield* spawnAcpRuntime(threadId); + yield* wireAcpRuntimeHandlers(runtime, yield* Ref.get(runtimeCallbackGeneration)); + }); + + const startReplacementAcpRuntime = Effect.fnUntraced(function* ( + threadId: ThreadId | null, + commitSessionState: ( + replacement: AcpSessionRuntime.AcpSessionRuntimeStartResult, + ) => Effect.Effect, + ) { + const previousScope = runtimeScope; + const previousGeneration = yield* Ref.get(runtimeCallbackGeneration); + yield* runtimeCallbackPermit.withPermit(awaitAdmittedNativeResponses); + + // Keep the original generation active until the candidate has + // completed session startup. A failed candidate therefore cannot + // suppress termination or background callbacks from the live session. + const replacementGeneration = yield* allocateRuntimeCallbackGeneration; + const replacementScope = yield* Scope.make(); + type CandidateLifecycle = + | { readonly _tag: "Starting" } + | { readonly _tag: "Terminated"; readonly error: EffectAcpErrors.AcpError } + | { readonly _tag: "Committed" }; + const candidateLifecycle = yield* Ref.make({ _tag: "Starting" }); + const handleCandidateTermination: AcpAdapterV2RuntimeInput["onTermination"] = (error) => + Ref.modify( + candidateLifecycle, + (current): readonly [Effect.Effect, CandidateLifecycle] => { + if (current._tag === "Committed") { + return [handleRuntimeTerminationAtGeneration(replacementGeneration), current]; + } + return [ + Effect.void, + current._tag === "Terminated" + ? current + : ({ _tag: "Terminated", error } as const), + ]; + }, + ).pipe(Effect.flatten); + const stagedSessionUpdates: Array = []; + let handleCandidateSessionUpdate: ( + notification: EffectAcpSchema.SessionNotification, + ) => Effect.Effect = (notification) => + Effect.sync(() => { + stagedSessionUpdates.push(notification); + }); + let replacementMcpBridge: AcpMcpOverAcpBridge | undefined; + const startup = Effect.gen(function* () { + replacementMcpBridge = yield* makeRuntimeMcpBridge(threadId, replacementScope); + const replacementRuntime = yield* flavor + .makeRuntime( + makeRuntimeInput( + replacementGeneration, + threadId, + undefined, + handleCandidateTermination, + ), + ) + .pipe( + Effect.provideService(Scope.Scope, replacementScope), + Effect.provideService(Crypto.Crypto, options.crypto), + ); + // Session setup may publish commands before it returns. Buffer those + // notifications, but do not expose request or extension handlers + // until the candidate generation has committed. + yield* replacementRuntime.handleSessionUpdate((notification) => + Effect.suspend(() => handleCandidateSessionUpdate(notification)), + ); + yield* wireAcpRuntimeMcpHandlers(replacementRuntime, replacementMcpBridge); + const started = yield* replacementRuntime.start(); + return { replacementRuntime, started }; + }); + const replacementExit = yield* Effect.exit( + (flavor.withRuntimeStartup?.(startup) ?? startup).pipe( + Effect.onInterrupt(() => + Scope.close(replacementScope, Exit.void).pipe(Effect.ignore), + ), + ), + ); + if (Exit.isFailure(replacementExit)) { + yield* runtimeCallbackPermit.withPermit( + quarantineNativeTransportAtGeneration(replacementGeneration), + ); + yield* Scope.close(replacementScope, Exit.void).pipe(Effect.ignore); + return yield* Effect.failCause(replacementExit.cause); + } + + yield* runtimeCallbackPermit + .withPermit( + Effect.uninterruptible( + Effect.gen(function* () { + yield* awaitAdmittedNativeResponses; + // Session updates and MCP were wired during candidate startup + // (against its own bridge); terminals attach after commit below. + yield* wireAcpRuntimeHandlers( + replacementExit.value.replacementRuntime, + replacementGeneration, + { sessionUpdates: false, terminals: false, mcp: false }, + ); + const lifecycle = yield* Ref.modify( + candidateLifecycle, + (current): readonly [CandidateLifecycle, CandidateLifecycle] => + current._tag === "Terminated" + ? [current, current] + : [{ _tag: "Committed" }, { _tag: "Committed" }], + ); + if (lifecycle._tag === "Terminated") { + return yield* lifecycle.error; + } + yield* quarantineNativeTransportAtGeneration(previousGeneration); + runtime = replacementExit.value.replacementRuntime; + runtimeScope = replacementScope; + runtimeMcpBridge = replacementMcpBridge; + yield* Ref.set(runtimeCallbackGeneration, replacementGeneration); + prepareTerminalEnvironment(threadId, replacementExit.value.started.sessionId); + yield* wireAcpRuntimeTerminalHandlers(replacementExit.value.replacementRuntime); + yield* commitSessionState(replacementExit.value.started); + while (true) { + const buffered = stagedSessionUpdates.splice(0, stagedSessionUpdates.length); + if (buffered.length === 0) { + handleCandidateSessionUpdate = (notification) => + projectAcpRuntimeSessionUpdate(replacementGeneration, notification); + break; + } + yield* Effect.forEach( + buffered, + (notification) => + projectAcpRuntimeSessionUpdateEffect(notification).pipe( + Effect.catchCause((cause) => + Effect.logError("failed to replay staged ACP session update", { + driver, + cause, + }), + ), + ), + { discard: true }, + ); + } + yield* cancelPendingRuntimeRequests(); + if (previousScope !== undefined) { + yield* Scope.close(previousScope, Exit.void).pipe( + Effect.catchCause((cause) => + Effect.logError("failed to close replaced ACP runtime scope", { + driver, + cause, + }), + ), + ); + } + }), + ), + ) + .pipe( + Effect.onExit((exit) => + Exit.isFailure(exit) + ? Ref.get(candidateLifecycle).pipe( + Effect.flatMap((lifecycle) => + lifecycle._tag === "Committed" + ? Effect.void + : Scope.close(replacementScope, Exit.void).pipe(Effect.ignore), + ), + ) + : Effect.void, + ), + ); + return replacementExit.value.started; + }); + + const initialStart = yield* Effect.result( + startAcpRuntime(input.threadId, input.initialNativeThreadId), + ); + const started = Result.isSuccess(initialStart) + ? initialStart.success + : yield* Effect.gen(function* () { + const failedMethod = + "method" in initialStart.failure ? initialStart.failure.method : undefined; + if ( + input.initialNativeThreadId === undefined || + (failedMethod !== "session/load" && failedMethod !== "session/resume") + ) { + return yield* initialStart.failure; + } + yield* Ref.set(initialSessionActivationFailure, { + sessionId: input.initialNativeThreadId, + error: initialStart.failure, + }); + itemIdentityVersion = 2; + yield* Ref.set(runtimeRestartRequired, false); + prepareClaimableTerminalEnvironment(input.threadId); + return yield* startAcpRuntime(input.threadId); + }); + yield* Ref.set(activeSessionId, started.sessionId); + yield* Ref.set(activeSessionSetup, started); + rememberTerminalEnvironment(started.sessionId, input.threadId); + const capabilities = negotiatedCapabilities(flavor.capabilities, started); + const canLoadSession = started.initializeResult.agentCapabilities?.loadSession === true; + const canResumeSession = + started.initializeResult.agentCapabilities?.sessionCapabilities?.resume != null; + const supportsImagePrompts = acpSupportsImagePrompts({ + flavorSupportsImagePrompts: flavor.supportsImagePrompts, + negotiatedImage: + started.initializeResult.agentCapabilities?.promptCapabilities?.image === true, + }); + + const activateSession = Effect.fnUntraced(function* ( + sessionId: string, + threadId: ThreadId | null, + ) { + const initialFailure = yield* Ref.modify(initialSessionActivationFailure, (failure) => + failure?.sessionId === sessionId ? [failure.error, null] : [undefined, failure], + ); + if (initialFailure !== undefined) { + return yield* initialFailure; + } + const activationOptions = acpMcpActivation(threadId, self); + prepareTerminalEnvironment(threadId, sessionId); + const activated = canLoadSession + ? yield* runtime.loadSession(sessionId, activationOptions) + : canResumeSession + ? yield* runtime.resumeSession(sessionId, activationOptions) + : yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver, + detail: `ACP driver cannot load or resume session ${sessionId}`, + }); + rememberTerminalEnvironment(activated.sessionId, threadId); + return activated; + }); + + const configureSession = Effect.fnUntraced(function* ( + startResult: AcpSessionRuntime.AcpSessionRuntimeStartResult, + modelSelection: ModelSelection, + runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy, + ) { + const requestedModel = flavor.resolveModelId?.(modelSelection) ?? modelSelection.model; + let appliedModel: string | undefined; + if (flavor.applyModelSelection !== undefined) { + appliedModel = yield* flavor.applyModelSelection({ + runtime, + startResult, + modelSelection, + }); + } else if ( + requestedModel.length > 0 && + requestedModel !== "auto" && + requestedModel !== "default" + ) { + const hasModelConfig = + startResult.sessionSetupResult.configOptions?.some( + (option) => option.category === "model", + ) === true; + if (hasModelConfig) { + yield* runtime.setModel(requestedModel); + } + } + // Same-runtime switches compare against this stored setup, so keep + // its model metadata in sync with what the session now runs on; + // otherwise switching A -> B -> A would see the stale setup-time A + // and skip the final switch. + if (appliedModel !== undefined) { + const applied = appliedModel; + yield* Ref.update(activeSessionSetup, (setup) => { + if (setup === null) { + return setup; + } + const models = setup.sessionSetupResult.models; + if (models == null || models.currentModelId === applied) { + return setup; + } + return { + ...setup, + sessionSetupResult: { + ...setup.sessionSetupResult, + models: { ...models, currentModelId: applied }, + }, + }; + }); + } + const optionSelections = modelSelection.options ?? []; + const configOptions = yield* runtime.getConfigOptions; + const availableConfigIds = new Set(configOptions.map((option) => option.id)); + const hasNativeConfigWithSyntheticModeId = availableConfigIds.has( + ACP_SESSION_MODE_OPTION_ID, + ); + const modeSelection = hasNativeConfigWithSyntheticModeId + ? undefined + : optionSelections.find((selection) => selection.id === ACP_SESSION_MODE_OPTION_ID); + const configSelections = hasNativeConfigWithSyntheticModeId + ? optionSelections + : optionSelections.filter((selection) => selection.id !== ACP_SESSION_MODE_OPTION_ID); + // Probe-time descriptors are a per-model union, so a stored + // selection can reference an option the live session does not + // expose (Kilo advertises per-model "effort" descriptors while its + // session config omits them). Failing the open here wedges the run + // in a retry loop; skip like the out-of-range values below and let + // the agent default apply. + const unsupportedConfigIds = configSelections + .map((selection) => selection.id) + .filter((id) => !availableConfigIds.has(id)); + if (unsupportedConfigIds.length > 0) { + yield* Effect.logWarning( + "ACP session does not expose requested configuration option(s)", + { + driver, + sessionId: startResult.sessionId, + optionIds: unsupportedConfigIds, + }, + ); + } + for (const selection of configSelections) { + if (!availableConfigIds.has(selection.id)) continue; + // Tuning knobs degrade instead of failing the session open: agents + // advertise the union of values across models but can reject a + // per-model invalid one at set time (codex-acp advertises "ultra" + // reasoning effort and then rejects it for most models). Skip + // values the session does not currently offer and downgrade an + // agent-side set rejection to a warning; the agent's default + // applies for that option. + const option = configOptions.find((candidate) => candidate.id === selection.id); + if ( + option !== undefined && + option.type === "select" && + typeof selection.value === "string" + ) { + const advertisedValues = option.options.flatMap((entry) => + "value" in entry ? [entry.value] : entry.options.map((choice) => choice.value), + ); + if (!advertisedValues.includes(selection.value)) continue; + } + yield* runtime.setConfigOption(selection.id, selection.value).pipe( + Effect.catchTags({ + AcpRequestError: (error) => + Effect.logWarning("ACP session rejected a configuration option value", { + optionId: selection.id, + value: selection.value, + detail: error.message, + }), + }), + ); + } + const policyMode = flavor.sessionModeForPolicy?.(runtimePolicy); + if (policyMode !== undefined) { + yield* runtime.setMode(policyMode); + } + const modeState = yield* runtime.getModeState; + // The synthetic mode selection is skipped rather than failed when the + // agent no longer advertises it: mode sets are volatile across agent + // versions and a stale persisted mode should not block the turn. + if ( + modeSelection !== undefined && + typeof modeSelection.value === "string" && + modeState?.availableModes.some((mode) => mode.id === modeSelection.value) === true && + modeState.currentModeId !== modeSelection.value + ) { + yield* runtime.setMode(modeSelection.value); + } + const effectiveModeState = yield* runtime.getModeState; + const effectiveConfigOptions = yield* runtime.getConfigOptions; + const planSensitiveOptions = effectiveConfigOptions.filter( + ( + option, + ): option is Extract< + EffectAcpSchema.SessionConfigOption, + { readonly type: "select" } + > => + option.type === "select" && + (option.category === "mode" || option.category === "collaboration_mode"), + ); + if (runtimePolicy.interactionMode === "plan") { + if (!nativeBuildConfigurationBySessionId.has(startResult.sessionId)) { + nativeBuildConfigurationBySessionId.set(startResult.sessionId, { + ...(effectiveModeState === undefined + ? {} + : { modeId: effectiveModeState.currentModeId }), + configOptions: planSensitiveOptions.map((option) => ({ + id: option.id, + value: option.currentValue, + })), + }); + } + const planMode = effectiveModeState?.availableModes.find( + (mode) => mode.id === "plan" || mode.id === "architect", + ); + if (planMode !== undefined && effectiveModeState?.currentModeId !== planMode.id) { + yield* runtime.setMode(planMode.id); + } + for (const option of planSensitiveOptions) { + const choices = option.options.flatMap((entry) => + "value" in entry ? [entry.value] : entry.options.map((choice) => choice.value), + ); + const requested = choices.find( + (choice) => choice === "plan" || choice === "architect", + ); + if (requested !== undefined && option.currentValue !== requested) { + yield* runtime.setConfigOption(option.id, requested); + } + } + } else { + const nativeBuild = nativeBuildConfigurationBySessionId.get(startResult.sessionId); + if (nativeBuild !== undefined) { + if ( + nativeBuild.modeId !== undefined && + effectiveModeState?.currentModeId !== nativeBuild.modeId && + effectiveModeState?.availableModes.some( + (mode) => mode.id === nativeBuild.modeId, + ) === true + ) { + yield* runtime.setMode(nativeBuild.modeId); + } + for (const saved of nativeBuild.configOptions) { + const option = effectiveConfigOptions.find( + (candidate) => candidate.type === "select" && candidate.id === saved.id, + ); + if (option === undefined || option.type !== "select") continue; + const choices = option.options.flatMap((entry) => + "value" in entry ? [entry.value] : entry.options.map((choice) => choice.value), + ); + if (option.currentValue !== saved.value && choices.includes(saved.value)) { + yield* runtime.setConfigOption(option.id, saved.value); + } + } + nativeBuildConfigurationBySessionId.delete(startResult.sessionId); + } + } + yield* ( + flavor.onSessionConfigurationUpdate?.( + yield* runtime.getConfigOptions, + yield* runtime.getModeState, + ) ?? Effect.void + ); + }); + + yield* configureSession(started, input.modelSelection, input.runtimePolicy); + yield* Ref.set(activeSelection, input.modelSelection); + yield* Ref.set(activeInteractionMode, input.runtimePolicy.interactionMode); + const createdAt = yield* DateTime.now; + const providerSession: OrchestrationV2ProviderSession = { + id: input.providerSessionId, + driver, + providerInstanceId: options.instanceId, + status: "ready", + cwd: input.runtimePolicy.cwd ?? process.cwd(), + model: input.modelSelection.model, + capabilities, + createdAt, + updatedAt: createdAt, + lastError: null, + }; + + const providerTurnPayload = ( + context: ActiveAcpTurn, + status: OrchestrationV2ProviderTurn["status"], + completedAt: DateTime.Utc | null, + ): OrchestrationV2ProviderTurn => ({ + id: context.providerTurnId, + providerThreadId: context.input.providerThread.id, + nodeId: context.input.rootNodeId, + runAttemptId: context.input.attemptId, + nativeTurnRef: { + driver, + nativeId: context.nativeTurnId, + strength: "weak", + }, + ordinal: context.input.providerTurnOrdinal, + status, + startedAt: context.startedAt, + completedAt, + }); + + const terminalizeOpenRunOwnedItems = Effect.fnUntraced(function* ( + context: ActiveAcpTurn, + options: { readonly terminalizeSubagents: boolean }, + ) { + for (const tool of context.tools.values()) { + const status = toolStatus(tool.status); + if (status === "pending" || status === "running" || status === "waiting") { + yield* emitTool(context, tool, "interrupted"); + } + } + if (!options.terminalizeSubagents) return; + for (const subagent of context.subagents.values()) { + if (subagent.task.status !== "running" && subagent.task.status !== "pending") { + continue; + } + yield* emitSubagent(context, { + nativeTaskId: subagent.task.nativeTaskRef?.nativeId ?? subagent.task.id, + prompt: subagent.task.prompt, + title: subagent.task.title, + model: subagent.task.model, + status: "interrupted", + childSessionId: subagent.childSessionId, + result: subagent.task.result, + suppressNormalTool: true, + }); + } + }); + + const terminalizeOpenForegroundTools = Effect.fnUntraced(function* ( + context: ActiveAcpTurn, + ) { + for (const tool of context.tools.values()) { + if (!acpCompletedTurnShouldTerminalizeTool(tool, flavor)) continue; + yield* emitTool(context, tool, "completed"); + } + }); + + const quarantineStoppedRun = Effect.fnUntraced(function* () { + yield* continuationPermit.withPermit( + Effect.gen(function* () { + yield* Ref.update(continuationGeneration, (value) => value + 1); + yield* Ref.set(stoppedRunQuarantine, true); + yield* Ref.set(wakeBuffer, []); + yield* Ref.set(wakeReports, noWakeReports); + yield* Ref.set(continuationRequested, false); + yield* Ref.set(runningBackgroundTaskIds, new Set()); + yield* Ref.set(midTurnUnreportedCompletedTaskIds, new Set()); + yield* Ref.set(carryoverSubagents, null); + yield* Ref.set(lastTurnRoute, null); + }), + ); + }); + + const finalizeTurn = Effect.fnUntraced(function* ( + context: ActiveAcpTurn, + status: "completed" | "interrupted" | "failed" | "cancelled", + failure?: OrchestrationV2ProviderFailure, + ) { + if (context.finalized) return; + const settledStatus = context.interrupted ? "interrupted" : status; + context.finalizedStatus = settledStatus; + context.finalized = true; + const directStopQuarantine = yield* Ref.get(stoppedRunQuarantine); + if (flavor.subagentsIdleOnTurnCompletion === true) { + for (const subagent of context.subagents.values()) { + if (!acpSubagentStatusBlocksTurnSettlement(subagent.task.status)) continue; + yield* emitSubagent(context, { + nativeTaskId: subagent.task.nativeTaskRef?.nativeId ?? subagent.task.id, + prompt: subagent.task.prompt, + title: subagent.task.title, + model: subagent.task.model, + status: settledStatus === "completed" ? "idle" : settledStatus, + childSessionId: subagent.childSessionId, + result: null, + }); + } + } + if (settledStatus === "completed") { + yield* terminalizeOpenForegroundTools(context); + } else if (settledStatus === "interrupted") { + // Direct Stop terminalizes every visible run-owned item. restart_active + // keeps live subagent lineages for in-process replacement carryover. + yield* terminalizeOpenRunOwnedItems(context, { + terminalizeSubagents: directStopQuarantine, + }); + } + yield* closeTextStreams(context); + if (settledStatus !== "failed") { + yield* emitProviderRetry(context, settledStatus); + } + const now = yield* DateTime.now; + if ( + flavor.supportsCompaction === true && + context.input.message.text.trim() === "/compact" && + context.input.message.attachments.length === 0 && + settledStatus === "completed" + ) { + const nativeItemId = `${context.nativeTurnId}:compaction`; + yield* emitProviderEvent({ + type: "turn_item.updated", + driver, + turnItem: { + id: idAllocator.derive.turnItemFromProviderItem({ driver, nativeItemId }), + threadId: context.input.threadId, + runId: context.input.runId, + nodeId: context.input.rootNodeId, + providerThreadId: context.input.providerThread.id, + providerTurnId: context.providerTurnId, + nativeItemRef: { driver, nativeId: nativeItemId, strength: "weak" }, + parentItemId: null, + ordinal: yield* resolveItemOrdinal(context, nativeItemId), + type: "compaction", + driver, + status: "completed", + title: "Context compacted", + startedAt: context.startedAt, + completedAt: now, + updatedAt: now, + }, + }); + } + const turn = providerTurnPayload(context, settledStatus, now); + yield* Ref.update(providerTurns, (current) => { + const updated = new Map(current); + updated.set(String(turn.id), turn); + return updated; + }); + yield* emitProviderEvent({ + type: "provider_turn.updated", + driver, + threadId: context.input.threadId, + providerTurn: turn, + }); + const updatedProviderThread: OrchestrationV2ProviderThread = { + ...context.input.providerThread, + providerSessionId: input.providerSessionId, + status: "active", + lastRunOrdinal: context.input.runOrdinal, + firstRunOrdinal: + context.input.providerThread.firstRunOrdinal ?? context.input.runOrdinal, + contextUsage: context.contextUsage, + nativeMetadata: context.nativeMetadata, + updatedAt: now, + }; + yield* Ref.update(providerThreadByNativeSessionId, (current) => + new Map(current).set(context.nativeThreadId, updatedProviderThread), + ); + yield* emitProviderEvent({ + type: "provider_thread.updated", + driver, + providerThread: updatedProviderThread, + }); + yield* emitProviderEvent( + settledStatus === "failed" + ? { + type: "turn.terminal", + driver, + providerThreadId: context.input.providerThread.id, + providerTurnId: context.providerTurnId, + runOrdinal: context.input.runOrdinal, + failureItemOrdinal: yield* resolveItemOrdinal( + context, + `terminal-failure:${context.providerTurnId}`, + ), + status: settledStatus, + failure: failure ?? makeProviderFailure({ class: "provider_error" }), + ...(context.providerRetry === undefined + ? {} + : { + retry: context.providerRetry.retry, + retryStartedAt: context.providerRetry.startedAt, + }), + threadDisposition: "reusable", + } + : { + type: "turn.terminal", + driver, + providerThreadId: context.input.providerThread.id, + providerTurnId: context.providerTurnId, + runOrdinal: context.input.runOrdinal, + status: settledStatus, + failure: null, + threadDisposition: "reusable", + }, + ); + const subagentsRequiringCarryover = [...context.subagents.values()].filter( + acpSubagentHasPendingBackgroundWork, + ); + // Direct Stop must not carry residual subagents into a later run. + if (subagentsRequiringCarryover.length > 0 && !directStopQuarantine) { + const sessionId = yield* Ref.get(activeSessionId); + if (sessionId !== null) { + yield* Ref.set(carryoverSubagents, { + sessionId, + rootTerminalStatus: settledStatus, + subagents: subagentsRequiringCarryover, + }); + } + } + yield* Ref.set(activeTurn, null); + // A mid-turn background completion may have deferred its offer while + // this root turn was still streaming. Once the turn leaves the active + // slot, re-check: empty running set + residual wake evidence (or a + // mid-turn unreported completion) means a legitimate unhandled + // completion can open exactly one continuation. Sticky + // continuationRequested prevents double-offer if a later frame also + // races into offerContinuationRun. + if ( + postSettleContinuationEnabled && + settledStatus === "completed" && + !directStopQuarantine && + (yield* Ref.get(runningBackgroundTaskIds)).size === 0 && + ((yield* Ref.get(wakeBuffer)).length > 0 || + (yield* Ref.get(midTurnUnreportedCompletedTaskIds)).size > 0) + ) { + const sessionId = yield* Ref.get(activeSessionId); + if (sessionId !== null) { + yield* offerContinuationRun(sessionId); + } + } + // Clear mid-turn unreported marks unless a completed turn still has + // running background work: keep them so the post-finalize gate can + // offer once when the last task ends. Interrupted/failed turns must + // not leave marks that open a wake after interrupt (quarantine owns + // that path). Non-continuation turn start also clears (~user turn). + if ( + settledStatus !== "completed" || + (yield* Ref.get(runningBackgroundTaskIds)).size === 0 + ) { + yield* Ref.set(midTurnUnreportedCompletedTaskIds, new Set()); + } + yield* Deferred.succeed(context.completed, undefined).pipe(Effect.ignore); + }); + + scheduleDeferredFinalize = (context) => + Effect.gen(function* () { + if (!flavor.deferFinalizeForBackgroundWork) return; + if (!context.promptSettled || context.finalized || context.interrupted) return; + if (hasDeferredBackgroundWork(context)) return; + context.backgroundFinalizeGeneration += 1; + const generation = context.backgroundFinalizeGeneration; + yield* Effect.gen(function* () { + yield* Effect.sleep(ACP_DEFERRED_FINALIZE_DEBOUNCE); + if ( + context.finalized || + context.interrupted || + context.backgroundFinalizeGeneration !== generation + ) { + return; + } + if (hasDeferredBackgroundWork(context)) return; + const status = context.promptSettledStatus ?? "completed"; + yield* finalizeTurn(context, status); + }).pipe(Effect.forkIn(sessionScope), Effect.asVoid); + yield* ( + options.testHooks?.onDeferredFinalizeScheduled?.(ACP_DEFERRED_FINALIZE_DEBOUNCE) ?? + Effect.void + ); + }); + + const resolvePromptParts = Effect.fnUntraced(function* ( + turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, + sessionId: string, + ) { + const prompt: Array = []; + const instructionState = { + interactionMode: turnInput.runtimePolicy.interactionMode, + hasT3Mcp: acpMcpServers(turnInput.threadId, self).length > 0, + } satisfies T3AcpInstructionState; + const previousInstructionState = (yield* Ref.get(promptInstructionStates)).get(sessionId); + const messageText = providerMessageTextWithAttachmentPaths({ + text: turnInput.message.text, + attachments: turnInput.message.attachments, + resolveAttachmentPath: host.resolveAttachmentPath, + }); + const text = t3AcpPromptWithInstructions({ + prompt: messageText, + state: instructionState, + ...(previousInstructionState === undefined + ? {} + : { previousState: previousInstructionState }), + }); + if (text.length > 0) { + prompt.push({ type: "text", text }); + } + const imageAttachments = turnInput.message.attachments.filter( + isProviderNativeImageAttachment, + ); + if (imageAttachments.length > 0 && !supportsImagePrompts) { + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver, + detail: "ACP driver did not negotiate image prompt support", + }); + } + for (const attachment of imageAttachments) { + const path = host.resolveAttachmentPath(attachment as ChatAttachment); + if (path === null) { + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver, + detail: `Invalid attachment id '${attachment.id}'`, + }); + } + const bytes = yield* fileSystem.readFile(path).pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterProtocolError({ + driver, + detail: `Failed to read attachment '${attachment.id}'`, + payload: cause, + }), + ), + ); + prompt.push({ + type: "image", + data: Buffer.from(bytes).toString("base64"), + mimeType: attachment.mimeType, + }); + } + if (prompt.length === 0) { + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver, + detail: "ACP turn requires non-empty text or attachments", + }); + } + prompt.push({ + type: "text", + text: buildRuntimeInstructions({ + harness: flavor.runtimeHarness ?? driver, + model: turnInput.modelSelection.model, + }), + }); + return { prompt, instructionState: text === messageText ? undefined : instructionState }; + }); + + const restartRuntimeAfterTeardownIfRequired = Effect.fnUntraced(function* ( + threadId: ThreadId | null, + ) { + const restartRequired = yield* Ref.get(runtimeRestartRequired); + if (!restartRequired) return false; + yield* restartAcpRuntime(threadId); + yield* Ref.set(runtimeRestartRequired, false); + yield* Ref.set(activeSessionId, null); + yield* Ref.set(activeSessionSetup, null); + yield* Ref.set(activeSelection, null); + yield* Ref.set(activeInteractionMode, null); + yield* Ref.set(snapshot, { + order: [], + messages: new Map(), + loadingRole: null, + loadingMessageId: null, + loadingIndex: 0, + }); + return true; + }); + + const startTurnUnlocked = Effect.fn("AcpAdapterV2.startTurn")( + function* (turnInput: ProviderAdapter.ProviderAdapterV2TurnInput) { + yield* awaitRuntimeTeardown(); + const existing = yield* Ref.get(activeTurn); + if (existing !== null) { + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver, + detail: `ACP provider turn ${existing.providerTurnId} is still active`, + }); + } + useProviderThreadIdentity(turnInput.providerThread); + // Session activation can itself invoke client fs/terminal methods. + // Install the incoming thread policy before load/resume so those + // requests can never inherit the previously active thread's policy. + latestRuntimePolicy = turnInput.runtimePolicy; + const requestedSessionId = yield* nativeThreadId(driver, turnInput.providerThread); + const restartAfterInterrupt = yield* restartRuntimeAfterTeardownIfRequired( + turnInput.threadId, + ); + const needsSessionActivation = + (yield* Ref.get(activeSessionId)) !== requestedSessionId || restartAfterInterrupt; + if (needsSessionActivation) { + const activated = yield* activateSession(requestedSessionId, turnInput.threadId); + yield* Ref.set(activeSessionId, activated.sessionId); + yield* Ref.set(activeSessionSetup, activated); + yield* configureSession(activated, turnInput.modelSelection, turnInput.runtimePolicy); + yield* Ref.set(activeSelection, turnInput.modelSelection); + yield* Ref.set(activeInteractionMode, turnInput.runtimePolicy.interactionMode); + } else { + const configuredSelection = yield* Ref.get(activeSelection); + const configuredInteractionMode = yield* Ref.get(activeInteractionMode); + if ( + configuredSelection === null || + !modelSelectionsEqual(configuredSelection, turnInput.modelSelection) || + configuredInteractionMode !== turnInput.runtimePolicy.interactionMode + ) { + const currentSessionSetup = yield* Ref.get(activeSessionSetup); + if (currentSessionSetup === null) { + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver, + detail: `ACP session ${requestedSessionId} has no active setup metadata`, + }); + } + yield* configureSession( + currentSessionSetup, + turnInput.modelSelection, + turnInput.runtimePolicy, + ); + yield* Ref.set(activeSelection, turnInput.modelSelection); + yield* Ref.set(activeInteractionMode, turnInput.runtimePolicy.interactionMode); + } + } + yield* Ref.set(lastTurnRoute, { + threadId: turnInput.threadId, + providerThreadId: turnInput.providerThread.id, + }); + yield* Ref.set(suppressPostSettleMonitorPrompt, false); + yield* Ref.set(handledBackgroundTaskIdsInActiveTurn, new Set()); + // Continuation turns attach to wake traffic the agent already produced + // after the prior root turn settled; do not re-prompt the ACP session. + const isContinuationTurn = + postSettleContinuationEnabled && acpIsProviderContinuationMessage(turnInput.message); + const isAppOwnedWakeTurn = acpIsAppOwnedWakeTurn(turnInput.message); + // An app-owned wake reports on a sibling delegated child and owns + // none of this session's background work, so it must not discard + // pending wake evidence: neither the mid-turn completion marks kept + // across a settle with work still running, nor the wake buffer + // below. Dropping the marks here would lose the offer for a task + // that completed unreported before the settle. + if (!isAppOwnedWakeTurn) { + yield* Ref.set(midTurnUnreportedCompletedTaskIds, new Set()); + } + // A user turn supersedes an empty offer, but not an offer that owns + // buffered wake traffic. ProviderContinuationService queues that + // continuation behind the user run so it can drain afterwards. + const continuationWasRequested = yield* continuationPermit.withPermit( + Effect.gen(function* () { + const wasRequested = yield* Ref.get(continuationRequested); + const preserveBufferedContinuation = acpTurnStartShouldPreserveContinuation({ + continuationRequested: wasRequested, + isContinuationTurn, + wakeBufferLength: (yield* Ref.get(wakeBuffer)).length, + }); + // User turns must not inherit prior-turn wake residue. Stale + // injected-turn ack chatter can otherwise arm a later offer. + // Preserve only a queued continuation that owns buffered wake + // traffic, and exempt app-owned sibling wakes entirely. + if (!isContinuationTurn && !isAppOwnedWakeTurn && !preserveBufferedContinuation) { + yield* Ref.set(wakeBuffer, []); + yield* Ref.set(wakeReports, noWakeReports); + } + if (preserveBufferedContinuation) return wasRequested; + yield* Ref.update(continuationGeneration, (value) => value + 1); + yield* Ref.set(continuationRequested, false); + return wasRequested; + }), + ); + const promptParts = isContinuationTurn + ? null + : yield* resolvePromptParts(turnInput, requestedSessionId); + const startedAt = yield* DateTime.now; + const nativeTurnId = `${requestedSessionId}:turn:${turnInput.providerTurnOrdinal}`; + const providerTurnId = deriveProviderTurnId(nativeTurnId); + const completed = yield* Deferred.make(); + const promptWireSettled = yield* Deferred.make(); + const rememberedContextUsage = (yield* Ref.get(contextUsageBySessionId)).get( + requestedSessionId, + ); + const rememberedNativeMetadata = (yield* Ref.get(nativeMetadataBySessionId)).get( + requestedSessionId, + ); + const initialNativeMetadata = + rememberedNativeMetadata === undefined + ? (turnInput.providerThread.nativeMetadata ?? null) + : { + ...turnInput.providerThread.nativeMetadata, + ...rememberedNativeMetadata, + }; + if (initialNativeMetadata !== null) { + yield* Ref.update(nativeMetadataBySessionId, (current) => + new Map(current).set(requestedSessionId, initialNativeMetadata), + ); + } + const context: ActiveAcpTurn = { + input: turnInput, + providerTurnId, + nativeThreadId: requestedSessionId, + nativeTurnId, + startedAt, + completed, + itemOrdinals: new Map(), + user: { current: null, nextSegment: 0 }, + assistant: { current: null, nextSegment: 0 }, + reasoning: { current: null, nextSegment: 0 }, + contextUsage: rememberedContextUsage ?? turnInput.providerThread.contextUsage ?? null, + nativeMetadata: initialNativeMetadata, + tools: new Map(), + toolUpdatesSkipped: new Map(), + toolStartedAt: new Map(), + subagents: new Map(), + subagentsBySessionId: new Map(), + pendingSubagentNotifications: new Map(), + toolCallIdsByBackgroundTaskId: new Map(), + persistentBackgroundTaskIds: new Set(), + awaitingBackgroundHydration: new Set(), + pendingInjectedReport: new Set(), + earlyInjectedReportObserved: false, + plans: new Map(), + interrupted: false, + finalized: false, + finalizedStatus: null, + promptSettled: false, + promptSettledStatus: null, + promptWireSettled, + backgroundFinalizeGeneration: 0, + }; + const carryover = yield* Ref.getAndSet(carryoverSubagents, null); + let rehydratedCarryoverSubagents: ReadonlyArray = []; + if (carryover !== null && carryover.sessionId === requestedSessionId) { + rehydratedCarryoverSubagents = carryover.subagents; + for (const subagent of carryover.subagents) { + const nativeId = subagent.task.nativeTaskRef?.nativeId ?? null; + if (nativeId !== null) { + context.subagents.set(nativeId, subagent); + } + if (subagent.childSessionId !== null) { + context.subagentsBySessionId.set(subagent.childSessionId, subagent); + } + } + } + yield* Ref.set(activeTurn, context); + // Direct Stop closes and recreates the old runtime before reaching + // this reset. The quarantine remains session-scoped by design. + yield* Ref.set(stoppedRunQuarantine, false); + const runningTurn = providerTurnPayload(context, "running", null); + yield* Ref.update(providerTurns, (current) => { + const updated = new Map(current); + updated.set(String(runningTurn.id), runningTurn); + return updated; + }); + yield* emitProviderEvent({ + type: "provider_turn.updated", + driver, + threadId: turnInput.threadId, + providerTurn: runningTurn, + }); + const activeProviderThread: OrchestrationV2ProviderThread = { + ...turnInput.providerThread, + providerSessionId: input.providerSessionId, + status: "active", + contextUsage: context.contextUsage, + nativeMetadata: context.nativeMetadata, + updatedAt: startedAt, + }; + yield* Ref.update(providerThreadByNativeSessionId, (current) => + new Map(current).set(requestedSessionId, activeProviderThread), + ); + yield* emitProviderEvent({ + type: "provider_thread.updated", + driver, + providerThread: activeProviderThread, + }); + yield* rememberSnapshotMessage({ + createdBy: turnInput.message.createdBy, + creationSource: turnInput.message.creationSource, + ...(turnInput.message.scheduledTaskId === undefined + ? {} + : { scheduledTaskId: turnInput.message.scheduledTaskId }), + ...(turnInput.message.senderThreadId === undefined + ? {} + : { senderThreadId: turnInput.message.senderThreadId }), + id: turnInput.message.messageId, + threadId: turnInput.threadId, + runId: turnInput.runId, + nodeId: turnInput.rootNodeId, + role: "user", + text: turnInput.message.text, + attachments: [...turnInput.message.attachments], + streaming: false, + createdAt: startedAt, + updatedAt: startedAt, + }); + // Every attach ends the deferred-terminal contract, but only the + // queued provider continuation owns wake traffic. A user turn may + // start before that continuation and must leave its buffer intact. + yield* projectDeferredCarryoverTerminals(rehydratedCarryoverSubagents); + // Keep projected terminals addressable through the wake drain so + // emitSubagent's monotonic guard can reject replayed spawn frames. + // Finalize carries only entries with pending background work, so a + // terminal-and-projected lineage still expires with this turn. + if (isContinuationTurn) { + yield* Ref.set(continuationRequested, false); + // This turn delivers what its offer named. Work that ended after + // the offer keeps its report for the next one. + yield* Ref.update(wakeReports, ({ reports, offered }) => ({ + reports: new Map([...reports].filter(([key]) => !offered.has(key))), + offered: noWakeReports.offered, + })); + const drainedWakeCount = yield* Ref.modify(wakeBuffer, (current) => { + const next: Array = []; + return [ + current.filter((notification) => notification.sessionId === requestedSessionId), + next, + ] as const; + }).pipe( + Effect.tap((drained) => + Effect.forEach(drained, handleSessionUpdate, { + concurrency: 1, + discard: true, + }), + ), + Effect.map((drained) => drained.length), + ); + // Treat attach mode as prompt-settled so deferred finalize / quiet + // windows can complete the continuation after wake traffic drains. + context.promptSettled = true; + context.promptSettledStatus = "completed"; + if (drainedWakeCount === 0) { + // A requested continuation with only mid-turn evidence has no + // buffered frame yet. Wait the quiet window so late CLI frames + // can attach. A provider-authored attach without a matching + // request only exists to project deferred carryover terminals, + // so it can finish immediately once those terminals are visible. + if (continuationWasRequested && flavor.deferFinalizeForBackgroundWork === true) { + if (hasDeferredBackgroundWork(context)) { + yield* rearmDeferredFinalize(context); + } else { + yield* scheduleDeferredFinalize(context); + } + } else { + yield* finalizeTurn(context, "completed"); + } + return; + } + if (!context.finalized) { + if (hasDeferredBackgroundWork(context)) { + yield* rearmDeferredFinalize(context); + } else { + yield* scheduleDeferredFinalize(context); + } + } + return; + } + const promptGeneration = yield* Ref.get(runtimeCallbackGeneration); + yield* runtime.prompt({ prompt: promptParts!.prompt }).pipe( + Effect.tap(() => + Ref.update(promptInstructionStates, (current) => { + if (promptParts?.instructionState === undefined) return current; + const updated = new Map(current); + updated.set(requestedSessionId, promptParts.instructionState); + return updated; + }), + ), + // Wire settlement precedes the completion callback's permit request so + // settled-soft classification can observe the native return even when + // the completion fiber has not yet set promptSettled under the permit. + Effect.tap(() => + Deferred.succeed(context.promptWireSettled, undefined).pipe(Effect.asVoid), + ), + Effect.flatMap((result) => + runRuntimeCallbackAtGeneration( + promptGeneration, + Effect.gen(function* () { + if (context.finalized) return; + const status = + result.stopReason === "cancelled" + ? context.interrupted + ? "interrupted" + : "cancelled" + : "completed"; + // Grok monitors (and async subagents) keep working after the root + // prompt RPC returns. Defer finalize so their later updates and + // wake-turn traffic still project onto this run. + if ( + flavor.deferFinalizeForBackgroundWork === true && + !context.interrupted && + hasDeferredBackgroundWork(context) + ) { + context.promptSettled = true; + context.promptSettledStatus = status; + // The agent finished this prompt's reply. Background work + // holds the run open, not the text it already sent. + yield* closeTextStreams(context); + yield* ( + options.testHooks?.afterPromptSettledWithBackgroundWork?.() ?? Effect.void + ); + return; + } + yield* finalizeTurn(context, status); + }), + ).pipe(Effect.asVoid), + ), + // Prompt failure is not wire-settled: only a successful resolve marks + // the signal. catchCause must not complete promptWireSettled. + Effect.catchCause((cause) => + runRuntimeCallbackAtGeneration( + promptGeneration, + Effect.gen(function* () { + if (context.finalized) return; + yield* finalizeTurn( + context, + context.interrupted ? "interrupted" : "failed", + flavor.promptFailure?.(Cause.squash(cause)) ?? + makeProviderFailure({ + cause: Cause.squash(cause), + class: "provider_error", + }), + ).pipe( + Effect.andThen( + Effect.logWarning("orchestration-v2.acp-prompt-failed", { + driver, + providerSessionId: input.providerSessionId, + providerThreadId: turnInput.providerThread.id, + providerTurnId, + cause, + }), + ), + ); + }), + ).pipe(Effect.asVoid), + ), + Effect.forkIn(sessionScope), + ); + }, + (effect, turnInput) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterTurnStartError({ + driver, + threadId: turnInput.threadId, + providerThreadId: turnInput.providerThread.id, + runId: turnInput.runId, + cause, + }), + ), + ), + ); + + const startTurn = Effect.fn("AcpAdapterV2.startTurn.transition")(function* ( + turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, + ) { + return yield* runtimeTransitionPermit.withPermit(startTurnUnlocked(turnInput)); + }); + + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + yield* continuationPermit.withPermit( + Effect.gen(function* () { + yield* Ref.set(continuationClosed, true); + yield* Ref.update(continuationGeneration, (value) => value + 1); + }), + ); + const requests = [...(yield* Ref.get(pendingRuntimeRequests)).values()]; + yield* Effect.forEach( + requests, + (request) => + request.type === "approval" + ? Deferred.succeed(request.decision, "cancel").pipe(Effect.ignore) + : Deferred.succeed(request.answers, null).pipe(Effect.ignore), + { discard: true }, + ); + const closingError = new EffectAcpErrors.AcpTransportError({ + detail: "The ACP session closed before its admitted response reached the transport", + cause: "ACP session transport closed", + }); + yield* Effect.forEach( + requests, + (request) => Deferred.fail(request.nativeResponseAcknowledgement, closingError), + { discard: true }, + ); + const transportHadOutstandingResponses = yield* closeNativeTransport; + yield* options.testHooks?.afterNativeResponseTransportClosed?.() ?? Effect.void; + const sessionCapabilities = + started.initializeResult.agentCapabilities?.sessionCapabilities; + if (sessionCapabilities?.close != null) { + yield* runtimeTransitionPermit.withPermitsIfAvailable(1)( + Effect.gen(function* () { + const teardownState = yield* Ref.get(runtimeTeardownState); + const restartRequired = yield* Ref.get(runtimeRestartRequired); + if ( + teardownState._tag === "Idle" && + !restartRequired && + !transportHadOutstandingResponses + ) { + yield* runtime.closeSession().pipe(Effect.ignore); + } + }), + ); + } + if (flavor.assertComplete !== undefined) { + yield* flavor.assertComplete.pipe(Effect.orDie); + } + if (runtimeScope !== undefined) { + yield* Scope.close(runtimeScope, Exit.void).pipe(Effect.ignore); + } + }), + ); + + const sessionRuntime: ProviderAdapter.ProviderAdapterV2SessionRuntime = { + instanceId: options.instanceId, + driver, + providerSessionId: input.providerSessionId, + providerSession, + events: Stream.fromEffectRepeat(Queue.take(events)), + ...(postSettleContinuationEnabled + ? { + hasPendingBackgroundWork: Effect.gen(function* () { + if ((yield* Ref.get(wakeBuffer)).length > 0) return true; + if (yield* Ref.get(continuationRequested)) return true; + if ((yield* Ref.get(runningBackgroundTaskIds)).size > 0) return true; + // Projected post-settle Grok subagents can outlive the root + // turn via carryover; keep the ACP process pinned until they + // terminalize or teardown clears the carryover. + // Also pin while a terminal status is held only in memory + // (project:false) so idle release cannot drop the session + // before the continuation drain (or a later project:true path) + // delivers the turn_item terminal. + const active = yield* Ref.get(activeTurn); + if ( + active !== null && + [...active.subagents.values()].some(acpSubagentHasPendingBackgroundWork) + ) { + return true; + } + const carryover = yield* Ref.get(carryoverSubagents); + if ( + carryover !== null && + carryover.subagents.some(acpSubagentHasPendingBackgroundWork) + ) { + return true; + } + return false; + }), + } + : {}), + ensureThread: Effect.fn("AcpAdapterV2.ensureThread")( + function* (threadInput: ProviderAdapter.ProviderAdapterV2EnsureThreadInput) { + const now = yield* DateTime.now; + const sessionId = yield* Ref.get(activeSessionId); + if (sessionId === null) { + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver, + detail: "ACP runtime did not produce a session id", + }); + } + const providerThread = makeProviderThread({ + driver, + providerInstanceId: options.instanceId, + idAllocator, + appThreadId: threadInput.threadId, + providerSessionId: input.providerSessionId, + nativeThreadId: sessionId, + ...(itemIdentityVersion === undefined ? {} : { itemIdentityVersion }), + now, + }); + yield* Ref.update(providerThreadByNativeSessionId, (current) => + new Map(current).set(sessionId, providerThread), + ); + return providerThread; + }, + (effect, threadInput) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterEnsureThreadError({ + driver, + threadId: threadInput.threadId, + cause, + }), + ), + ), + ), + resumeThread: Effect.fn("AcpAdapterV2.resumeThread")( + function* (threadInput: { + readonly providerThread: OrchestrationV2ProviderThread; + readonly modelSelection?: ModelSelection; + readonly runtimePolicy?: ProviderAdapter.ProviderAdapterV2RuntimePolicy; + }) { + return yield* runtimeTransitionPermit.withPermit( + Effect.gen(function* () { + yield* awaitRuntimeTeardown(); + const sessionId = yield* nativeThreadId(driver, threadInput.providerThread); + const previousItemIdentityVersion = itemIdentityVersion; + useProviderThreadIdentity(threadInput.providerThread); + const restorePreviousItemIdentity = Effect.sync(() => { + itemIdentityVersion = previousItemIdentityVersion; + }); + const restartAfterInterrupt = yield* restartRuntimeAfterTeardownIfRequired( + threadInput.providerThread.appThreadId, + ).pipe(Effect.tapError(() => restorePreviousItemIdentity)); + if ((yield* Ref.get(activeSessionId)) !== sessionId || restartAfterInterrupt) { + yield* Ref.set(snapshot, { + order: [], + messages: new Map(), + loadingRole: null, + loadingMessageId: null, + loadingIndex: 0, + }); + const activated = yield* activateSession( + sessionId, + threadInput.providerThread.appThreadId, + ).pipe(Effect.tapError(() => restorePreviousItemIdentity)); + yield* Ref.set(activeSessionId, activated.sessionId); + yield* Ref.set(activeSessionSetup, activated); + const nextSelection = threadInput.modelSelection ?? input.modelSelection; + const nextRuntimePolicy = threadInput.runtimePolicy ?? input.runtimePolicy; + yield* configureSession(activated, nextSelection, nextRuntimePolicy); + yield* Ref.set(activeSelection, nextSelection); + yield* Ref.set(activeInteractionMode, nextRuntimePolicy.interactionMode); + } + const now = yield* DateTime.now; + return { + ...threadInput.providerThread, + providerSessionId: input.providerSessionId, + status: "idle" as const, + updatedAt: now, + }; + }), + ); + }, + (effect, threadInput) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterResumeThreadError({ + driver, + providerSessionId: input.providerSessionId, + providerThreadId: threadInput.providerThread.id, + cause, + }), + ), + ), + ), + startTurn, + ...(flavor.supportsCompaction === true + ? { + compactThread: (turnInput: ProviderAdapter.ProviderAdapterV2TurnInput) => + startTurn({ + ...turnInput, + message: { ...turnInput.message, text: "/compact" }, + }), + } + : {}), + steerTurn: (turnInput) => + Effect.fail( + new ProviderAdapter.ProviderAdapterSteerRunUnsupportedError({ + driver, + providerThreadId: turnInput.providerThread.id, + }), + ), + interruptTurn: Effect.fn("AcpAdapterV2.interruptTurn")( + function* (turnInput: ProviderAdapter.ProviderAdapterV2InterruptInput) { + return yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + yield* restore(runtimeTransitionPermit.take(1)); + return yield* Effect.gen(function* () { + const transition = yield* Effect.gen(function* () { + const interruptContext = yield* Ref.get(activeTurn); + // Settled steering restart: the native prompt already + // returned and this is not a user Stop, so keep the + // process (and its background subagents) alive instead + // of hard-killing it. The turn still terminalizes below. + const softSteerCandidate = + flavor.preserveRuntimeOnSettledInterrupt === true && + turnInput.requestRuntimeRestart !== true && + interruptContext?.providerTurnId === turnInput.providerTurnId; + // Settlement is read under the callback permit after + // draining admitted responses: the native prompt can have + // returned on the wire while its completion callback has + // not yet run, and a cancel sent in that window is exactly + // what settled-soft mode exists to avoid. Also treat the + // turn as settled when the wire signal is already done + // (non-blocking; do not await under the permit). + let settledSoftInterrupt = false; + if (softSteerCandidate && interruptContext !== null) { + const promptSettledUnderPermit = yield* runtimeCallbackPermit.withPermit( + awaitAdmittedNativeResponses.pipe( + Effect.andThen( + Effect.sync(() => interruptContext.promptSettled === true), + ), + ), + ); + const wireSettled = yield* Deferred.isDone( + interruptContext.promptWireSettled, + ); + settledSoftInterrupt = promptSettledUnderPermit || wireSettled; + } + const restartRuntime = + !settledSoftInterrupt && + (turnInput.requestRuntimeRestart === true || + flavor.restartRuntimeOnEveryInterrupt === true); + const hardRestart = + restartRuntime && flavor.terminateRuntimeProcessGroupOnInterrupt === true; + if (hardRestart) { + const teardownState = yield* Ref.get(runtimeTeardownState); + if (teardownState._tag === "Failed") { + return yield* teardownState.error; + } + if (teardownState._tag === "InProgress") { + // Concurrent Stop/restart: wait for the in-flight hard + // teardown instead of failing the durable effect. + yield* Effect.logInfo( + "ACP interrupt awaiting in-progress hard teardown", + { + driver, + providerTurnId: turnInput.providerTurnId, + }, + ); + yield* Deferred.await(teardownState.completed); + return undefined; + } + } + const context = interruptContext; + if (context?.providerTurnId !== turnInput.providerTurnId) { + // A soft steering interrupt can clear the turn while + // intentionally leaving the process alive. A queued user + // Stop must still contain that orphan runtime. With a + // different live turn active this stays a pure no-op + // success instead: quarantine and teardown are + // session-global and would maim the replacement turn. + const containOrphanRuntime = + context === null && + hardRestart && + turnInput.requestRuntimeRestart === true; + // Transport death or a prior Stop already cleared the + // turn. Failing here caused effect-worker retries while + // the process was already gone; treat as success. + yield* Effect.logWarning( + containOrphanRuntime + ? "ACP Stop raced a soft interrupt that cleared the turn; containing the orphan runtime" + : "ACP interrupt raced transport teardown or a prior Stop; treating as already interrupted", + { + driver, + requestedProviderTurnId: turnInput.providerTurnId, + activeProviderTurnId: context?.providerTurnId ?? null, + hardRestart, + requestRuntimeRestart: turnInput.requestRuntimeRestart === true, + teardownState: (yield* Ref.get(runtimeTeardownState))._tag, + }, + ); + if (containOrphanRuntime) { + const teardownBarrier = yield* Deferred.make< + void, + ProviderAdapter.ProviderAdapterProtocolError + >(); + yield* runtimeCallbackPermit.withPermit( + Effect.gen(function* () { + yield* awaitAdmittedNativeResponses; + const stoppedGeneration = yield* Ref.get(runtimeCallbackGeneration); + yield* quarantineNativeTransportAtGeneration(stoppedGeneration); + yield* Ref.set(runtimeTeardownState, { + _tag: "InProgress", + completed: teardownBarrier, + }); + yield* advanceRuntimeCallbackGeneration; + }), + ); + // Capture before quarantineStoppedRun clears carryover. + const orphanCarryover = yield* Ref.getAndSet(carryoverSubagents, null); + yield* quarantineStoppedRun(); + // Match the main hard-restart path: cancel pending + // approvals/elicitations after quarantine, before kill. + yield* cancelPendingRuntimeRequests(); + yield* terminalizeCarryoverSubagents(orphanCarryover); + if (runtime.terminateProcessGroup === undefined) { + const error = new ProviderAdapter.ProviderAdapterProtocolError({ + driver, + detail: + "ACP runtime does not expose its required process-group teardown; the session is poisoned", + }); + yield* Ref.set(runtimeTeardownState, { _tag: "Failed", error }); + yield* Deferred.fail(teardownBarrier, error).pipe(Effect.ignore); + return yield* error; + } + const teardownExit = yield* runtime.terminateProcessGroup.pipe( + Effect.exit, + ); + if (Exit.isFailure(teardownExit)) { + const error = new ProviderAdapter.ProviderAdapterProtocolError({ + driver, + detail: + "ACP orphan runtime process-group teardown failed; the session is poisoned", + payload: Cause.squash(teardownExit.cause), + }); + yield* Ref.set(runtimeTeardownState, { _tag: "Failed", error }); + yield* Deferred.fail(teardownBarrier, error).pipe(Effect.ignore); + return yield* error; + } + yield* Ref.set(runtimeRestartRequired, true); + yield* Ref.set(runtimeTeardownState, { _tag: "Idle" }); + yield* Deferred.succeed(teardownBarrier, undefined).pipe(Effect.ignore); + } + return undefined; + } + const teardownBarrier = hardRestart + ? yield* Deferred.make() + : null; + if (teardownBarrier !== null) { + yield* runtimeCallbackPermit.withPermit( + Effect.gen(function* () { + yield* awaitAdmittedNativeResponses; + const stoppedGeneration = yield* Ref.get(runtimeCallbackGeneration); + yield* quarantineNativeTransportAtGeneration(stoppedGeneration); + yield* Ref.set(runtimeTeardownState, { + _tag: "InProgress", + completed: teardownBarrier, + }); + yield* advanceRuntimeCallbackGeneration; + yield* ( + options.testHooks?.afterHardTeardownTransportDrained?.() ?? + Effect.void + ); + }), + ); + } + return { + context, + restartRuntime, + hardRestart, + teardownBarrier, + settledSoftInterrupt, + }; + }); + // Concurrent hard teardown already completed above. + if (transition === undefined) return; + const { + context, + restartRuntime, + hardRestart, + teardownBarrier, + settledSoftInterrupt, + } = transition; + const poisonTeardown = Effect.fnUntraced(function* ( + detail: string, + payload?: unknown, + ) { + const error = new ProviderAdapter.ProviderAdapterProtocolError({ + driver, + detail, + ...(payload === undefined ? {} : { payload }), + }); + yield* Ref.set(runtimeTeardownState, { _tag: "Failed", error }); + yield* Deferred.fail(teardownBarrier!, error).pipe(Effect.ignore); + return error; + }); + const runTransition = Effect.gen(function* () { + context.interrupted = true; + // Quarantine only when this run is discarded (user Stop / + // hard process kill). Soft in-process restarts must keep + // carryoverSubagents so a still-running subagent can + // complete into the replacement turn. + if (hardRestart || turnInput.requestRuntimeRestart === true) { + yield* quarantineStoppedRun(); + } + yield* cancelPendingRuntimeRequests(); + // Finalize before process-group kill so projection/UI cannot + // lag a dead transport, and concurrent interrupt effects see + // activeTurn cleared (idempotent success) rather than racing. + if ( + (hardRestart || context.promptSettled || settledSoftInterrupt) && + !context.finalized + ) { + // hardRestart: always terminalize locally. + // promptSettled / settledSoftInterrupt (incl. wire-settled): + // native prompt already returned; only deferred background + // work remains, so session/cancel has nothing to acknowledge. + yield* finalizeTurn(context, "interrupted"); + } + if (hardRestart) { + if (runtime.terminateProcessGroup === undefined) { + return yield* poisonTeardown( + "ACP runtime does not expose its required process-group teardown; the session is poisoned", + ).pipe(Effect.flatMap(Effect.fail)); + } + const teardownExit = yield* runtime.terminateProcessGroup!.pipe( + Effect.exit, + ); + if (Exit.isFailure(teardownExit)) { + return yield* poisonTeardown( + "ACP runtime process-group teardown failed; the session is poisoned", + Cause.squash(teardownExit.cause), + ).pipe(Effect.flatMap(Effect.fail)); + } + // Process group is gone; the next turn must spawn a + // replacement even if the flavor only set hardRestart + // without restartRuntimeAfterInterrupt. + yield* Ref.set(runtimeRestartRequired, true); + yield* Ref.set(runtimeTeardownState, { _tag: "Idle" }); + yield* Deferred.succeed(teardownBarrier!, undefined).pipe(Effect.ignore); + } else { + // Settled soft interrupt: the native prompt already + // returned, so session/cancel has nothing to + // acknowledge and would only threaten still-running + // background subagents. Skip it and leave the runtime + // untouched for the replacement turn. + if (!settledSoftInterrupt) { + yield* runtime.cancel; + } + if (restartRuntime && flavor.restartRuntimeAfterInterrupt === true) { + yield* Ref.set(runtimeRestartRequired, true); + } + } + const stopped = yield* Deferred.await(context.completed).pipe( + Effect.timeoutOption("10 seconds"), + ); + if (Option.isNone(stopped)) { + if (!context.finalized) { + yield* finalizeTurn(context, "interrupted"); + } + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver, + detail: `ACP provider turn ${turnInput.providerTurnId} did not acknowledge cancellation before the interrupt timeout`, + }); + } + }); + if (!hardRestart) { + return yield* restore(runTransition); + } + return yield* runTransition.pipe( + Effect.catchCause((cause) => + Effect.gen(function* () { + const state = yield* Ref.get(runtimeTeardownState); + if (state._tag === "InProgress") { + yield* poisonTeardown( + "ACP hard teardown failed unexpectedly; the session is poisoned", + Cause.squash(cause), + ); + } + return yield* Effect.failCause(cause); + }), + ), + ); + }).pipe(Effect.ensuring(runtimeTransitionPermit.release(1))); + }), + ); + }, + (effect, turnInput) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterInterruptError({ + driver, + providerThreadId: turnInput.providerThread.id, + providerTurnId: turnInput.providerTurnId, + cause, + }), + ), + ), + ), + respondToRuntimeRequest: (requestInput) => + Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + yield* restore(runtimeTransitionPermit.take(1)); + return yield* Effect.gen(function* () { + yield* awaitRuntimeTeardown(); + const generation = yield* Ref.get(runtimeCallbackGeneration); + const pending = (yield* Ref.get(pendingRuntimeRequests)).get( + String(requestInput.requestId), + ); + if (pending === undefined || pending.generation !== generation) { + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver, + detail: `No pending ACP runtime request ${requestInput.requestId}`, + }); + } + const settled = + pending.type === "user_input" + ? yield* Deferred.succeed(pending.answers, requestInput.answers ?? null) + : requestInput.decision === undefined + ? yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver, + detail: `ACP approval request ${requestInput.requestId} requires a decision`, + }) + : yield* Deferred.succeed(pending.decision, requestInput.decision); + if (!settled) { + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver, + detail: `ACP runtime request ${requestInput.requestId} was already resolved`, + }); + } + yield* awaitNativeResponseAcknowledgements([ + [pending.transportRequestId, pending.nativeResponseAcknowledgement], + ]); + yield* Deferred.await(pending.nativeResponseAcknowledgement); + }).pipe(Effect.ensuring(runtimeTransitionPermit.release(1))); + }), + ).pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterRuntimeRequestResponseError({ + driver, + requestId: requestInput.requestId, + cause, + }), + ), + ), + readThreadSnapshot: Effect.fn("AcpAdapterV2.readThreadSnapshot")( + function* (snapshotInput) { + return yield* runtimeTransitionPermit.withPermit( + Effect.gen(function* () { + yield* awaitRuntimeTeardown(); + useProviderThreadIdentity(snapshotInput.providerThread); + yield* restartRuntimeAfterTeardownIfRequired( + snapshotInput.providerThread.appThreadId, + ); + const sessionId = yield* nativeThreadId(driver, snapshotInput.providerThread); + if ((yield* Ref.get(activeSessionId)) !== sessionId) { + if (!capabilities.threads.canReadThreadSnapshot) { + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver, + detail: "ACP driver does not support session/load snapshots", + }); + } + yield* Ref.set(snapshot, { + order: [], + messages: new Map(), + loadingRole: null, + loadingMessageId: null, + loadingIndex: 0, + }); + prepareTerminalEnvironment(snapshotInput.providerThread.appThreadId, sessionId); + const activated = yield* runtime.loadSession( + sessionId, + acpMcpActivation(snapshotInput.providerThread.appThreadId, self), + ); + rememberTerminalEnvironment( + activated.sessionId, + snapshotInput.providerThread.appThreadId, + ); + yield* Ref.set(activeSessionId, activated.sessionId); + yield* Ref.set(activeSessionSetup, activated); + yield* Ref.set(activeSelection, null); + yield* Ref.set(activeInteractionMode, null); + } + const state = yield* Ref.get(snapshot); + const now = yield* DateTime.now; + return { + providerThread: { + ...snapshotInput.providerThread, + providerSessionId: input.providerSessionId, + status: "idle" as const, + updatedAt: now, + }, + providerTurns: [...(yield* Ref.get(providerTurns)).values()], + messages: state.order.flatMap((key) => { + const message = state.messages.get(key); + return message === undefined ? [] : [message]; + }), + runtimeRequests: [], + providerPayload: { protocol: ACP_PROTOCOL, sessionId }, + }; + }), + ); + }, + (effect, snapshotInput) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterReadThreadSnapshotError({ + driver, + providerThreadId: snapshotInput.providerThread.id, + cause, + }), + ), + ), + ), + rollbackThread: (rollbackInput) => + runtimeTransitionPermit + .withPermit( + Effect.gen(function* () { + const currentTurn = yield* Ref.get(activeTurn); + if (currentTurn !== null) { + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver, + detail: `Cannot roll back ACP provider thread ${rollbackInput.providerThread.id} while turn ${currentTurn.providerTurnId} is active`, + }); + } + // ACP defines no conversation truncation, so rollback stages + // a fresh native session before retiring the original. + // Returning its binding keeps the next turn runnable without + // loading any of the rolled-back conversation. + yield* awaitRuntimeTeardown(); + itemIdentityVersion = 2; + prepareTerminalEnvironment(rollbackInput.providerThread.appThreadId); + const replacement = yield* startReplacementAcpRuntime( + rollbackInput.providerThread.appThreadId, + (candidate) => + Effect.gen(function* () { + rememberTerminalEnvironment( + candidate.sessionId, + rollbackInput.providerThread.appThreadId, + ); + yield* Ref.set(runtimeRestartRequired, false); + yield* Ref.set(activeSessionId, candidate.sessionId); + yield* Ref.set(activeSessionSetup, candidate); + yield* Ref.set(activeSelection, null); + yield* Ref.set(activeInteractionMode, null); + yield* Ref.set(promptInstructionStates, new Map()); + yield* Ref.set(providerTurns, new Map()); + yield* Ref.set(snapshot, { + order: [], + messages: new Map(), + loadingRole: null, + loadingMessageId: null, + loadingIndex: 0, + }); + yield* continuationPermit.withPermit( + Effect.gen(function* () { + yield* Ref.update(continuationGeneration, (value) => value + 1); + yield* Ref.set(stoppedRunQuarantine, false); + yield* Ref.set(wakeBuffer, []); + yield* Ref.set(wakeReports, noWakeReports); + yield* Ref.set(continuationRequested, false); + yield* Ref.set(runningBackgroundTaskIds, new Set()); + yield* Ref.set(endedBackgroundTaskIds, new Set()); + yield* Ref.set(midTurnUnreportedCompletedTaskIds, new Set()); + yield* Ref.set(handledBackgroundTaskIdsInActiveTurn, new Set()); + yield* Ref.set(carryoverSubagents, null); + yield* Ref.set(suppressPostSettleMonitorPrompt, false); + yield* Ref.set(lastTurnRoute, null); + }), + ); + }), + ).pipe(Effect.retry({ times: 1 })); + const now = yield* DateTime.now; + return { + providerThread: { + ...rollbackInput.providerThread, + nativeThreadRef: { + driver, + nativeId: replacement.sessionId, + strength: "strong" as const, + }, + nativeConversationHeadRef: null, + nativeMetadata: { + ...rollbackInput.providerThread.nativeMetadata, + itemIdentityVersion: 2 as const, + }, + status: "idle" as const, + updatedAt: now, + }, + providerTurns: [], + messages: [], + runtimeRequests: [], + }; + }), + ) + .pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterRollbackThreadError({ + driver, + providerThreadId: rollbackInput.providerThread.id, + checkpointId: rollbackInput.target.checkpointId, + cause, + }), + ), + ), + forkThread: Effect.fn("AcpAdapterV2.forkThread")( + function* (forkInput) { + return yield* runtimeTransitionPermit.withPermit( + Effect.gen(function* () { + yield* awaitRuntimeTeardown(); + useProviderThreadIdentity(forkInput.sourceProviderThread); + yield* restartRuntimeAfterTeardownIfRequired( + forkInput.sourceProviderThread.appThreadId, + ); + if (!capabilities.threads.canForkThread) { + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver, + detail: "ACP driver did not negotiate session/fork", + }); + } + if (forkInput.providerTurnId !== undefined) { + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver, + detail: "ACP session/fork can only fork the current session head", + }); + } + const sourceSessionId = yield* nativeThreadId( + driver, + forkInput.sourceProviderThread, + ); + prepareTerminalEnvironment(forkInput.targetThreadId); + const forked = yield* runtime.forkSession( + sourceSessionId, + acpMcpActivation(forkInput.targetThreadId, self), + ); + rememberTerminalEnvironment(forked.sessionId, forkInput.targetThreadId); + yield* Ref.set(activeSessionId, forked.sessionId); + yield* Ref.set(activeSessionSetup, forked); + yield* Ref.set(activeSelection, null); + yield* Ref.set(activeInteractionMode, null); + itemIdentityVersion = 2; + const now = yield* DateTime.now; + const providerThread = makeProviderThread({ + driver, + providerInstanceId: options.instanceId, + idAllocator, + appThreadId: forkInput.targetThreadId, + providerSessionId: input.providerSessionId, + nativeThreadId: forked.sessionId, + itemIdentityVersion: 2, + ...(forkInput.ownerNodeId === undefined + ? {} + : { ownerNodeId: forkInput.ownerNodeId }), + forkedFrom: { + providerThreadId: forkInput.sourceProviderThread.id, + ...(forkInput.providerTurnId === undefined + ? {} + : { providerTurnId: forkInput.providerTurnId }), + }, + now, + }); + yield* Ref.update(providerThreadByNativeSessionId, (current) => + new Map(current).set(forked.sessionId, providerThread), + ); + return providerThread; + }), + ); + }, + (effect, forkInput) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterForkThreadError({ + driver, + providerThreadId: forkInput.sourceProviderThread.id, + cause, + }), + ), + ), + ), + }; + return sessionRuntime; + }, + (effect, input) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterOpenSessionError({ + driver, + providerSessionId: input.providerSessionId, + cause, + }), + ), + ), + ), + }); +} + +export type AcpAdapterV2Env = FileSystem.FileSystem | IdAllocator.IdAllocatorV2; diff --git a/packages/provider-acp/src/server/clientPolicy.test.ts b/packages/provider-acp/src/server/clientPolicy.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..fd2a9714c57cf6e75b20dc9ec0ae5ce593220f7e --- /dev/null +++ b/packages/provider-acp/src/server/clientPolicy.test.ts @@ -0,0 +1,348 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import type * as EffectAcpSchema from "effect-acp/compat"; +import * as NodePath from "node:path"; + +import { + acpClientExecuteDisposition, + acpMcpToolApprovalElicitationDisposition, + acpPermissionDisposition, + makeAcpClientPolicyGrants, + type AcpRuntimePolicy, +} from "./clientPolicy.ts"; + +function permissionRequest( + kind: NonNullable, + locations?: ReadonlyArray, +): EffectAcpSchema.RequestPermissionRequest { + return { + options: [], + sessionId: "permission-session", + toolCall: { + kind, + ...(locations === undefined ? {} : { locations }), + toolCallId: "permission-tool-call", + }, + }; +} + +describe("acpPermissionDisposition", () => { + const cwd = NodePath.resolve(process.cwd(), "acp-permission-workspace"); + const writableRoot = NodePath.resolve(process.cwd(), "acp-additional-writable-root"); + const policy: AcpRuntimePolicy = { + runtimeMode: "full-access", + cwd, + approvalPolicy: "never", + sandboxPolicy: { + type: "workspaceWrite", + writableRoots: [writableRoot], + networkAccess: false, + }, + }; + + it("auto-allows mutations only when every location is in cwd or an additional writable root", () => { + assert.equal( + acpPermissionDisposition(policy, permissionRequest("edit", [{ path: "src/index.ts" }])), + "allow", + ); + assert.equal( + acpPermissionDisposition( + policy, + permissionRequest("delete", [{ path: NodePath.join(writableRoot, "generated.ts") }]), + ), + "allow", + ); + assert.equal( + acpPermissionDisposition( + policy, + permissionRequest("move", [ + { path: NodePath.join(cwd, "from.ts") }, + { path: NodePath.join(writableRoot, "to.ts") }, + ]), + ), + "allow", + ); + }); + + it("denies missing or out-of-root mutation locations", () => { + const outside = NodePath.resolve(process.cwd(), "outside-acp-permission-workspace", "file.ts"); + assert.equal(acpPermissionDisposition(policy, permissionRequest("edit")), "deny"); + assert.equal( + acpPermissionDisposition(policy, permissionRequest("delete", [{ path: "../escape.ts" }])), + "deny", + ); + assert.equal( + acpPermissionDisposition( + policy, + permissionRequest("move", [{ path: NodePath.join(cwd, "inside.ts") }, { path: outside }]), + ), + "deny", + ); + }); + + it("keeps non-mutating workspace permissions and denials unchanged", () => { + assert.equal(acpPermissionDisposition(policy, permissionRequest("read")), "allow"); + assert.equal(acpPermissionDisposition(policy, permissionRequest("execute")), "deny"); + }); + + it("auto-accept-edits approves file changes without locations and asks for the rest", () => { + // ACP permission requests need not carry locations. + const autoAcceptEdits: AcpRuntimePolicy = { runtimeMode: "auto-accept-edits", cwd }; + for (const kind of ["edit", "delete", "move"] as const) { + assert.equal(acpPermissionDisposition(autoAcceptEdits, permissionRequest(kind)), "allow"); + } + for (const kind of ["execute", "fetch", "other"] as const) { + assert.equal(acpPermissionDisposition(autoAcceptEdits, permissionRequest(kind)), "ask"); + } + assert.equal(acpPermissionDisposition(autoAcceptEdits, permissionRequest("read")), "allow"); + assert.equal( + acpPermissionDisposition( + { ...autoAcceptEdits, approvalPolicy: "on-request" }, + permissionRequest("edit"), + ), + "ask", + ); + }); + + it("auto-allows read-kind permission requests under on-request approval", () => { + for (const runtimePolicy of [ + { ...policy, approvalPolicy: "on-request" }, + { ...policy, approvalPolicy: "on-request", sandboxPolicy: { type: "readOnly" } }, + { runtimeMode: "approval-required", cwd }, + ] satisfies ReadonlyArray) { + for (const kind of ["read", "search", "think"] as const) { + assert.equal(acpPermissionDisposition(runtimePolicy, permissionRequest(kind)), "allow"); + } + for (const kind of ["edit", "delete", "move", "execute", "fetch", "other"] as const) { + assert.equal(acpPermissionDisposition(runtimePolicy, permissionRequest(kind)), "ask"); + } + } + }); + + it.effect("denies mutations through workspace symlinks that escape the writable roots", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspace = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-acp-permission-workspace-", + }); + const outside = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-acp-permission-outside-", + }); + const outsideFile = path.join(outside, "existing.ts"); + yield* fileSystem.writeFileString(outsideFile, "outside"); + yield* fileSystem.symlink(outsideFile, path.join(workspace, "linked-file.ts")); + yield* fileSystem.symlink(outside, path.join(workspace, "linked-directory")); + + const realPolicy: AcpRuntimePolicy = { + runtimeMode: "full-access", + cwd: workspace, + approvalPolicy: "never", + sandboxPolicy: { + type: "workspaceWrite", + writableRoots: [], + networkAccess: false, + }, + }; + + assert.equal( + acpPermissionDisposition( + realPolicy, + permissionRequest("edit", [{ path: "linked-file.ts" }]), + ), + "deny", + ); + assert.equal( + acpPermissionDisposition( + realPolicy, + permissionRequest("edit", [{ path: "linked-directory/new-file.ts" }]), + ), + "deny", + "a missing leaf below an escaping directory symlink must not be auto-approved", + ); + assert.equal( + acpPermissionDisposition( + realPolicy, + permissionRequest("edit", [{ path: "linked-directory/../escaped-file.ts" }]), + ), + "deny", + "physical symlink traversal must be resolved before parent segments", + ); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("allows existing and new files beneath canonical writable roots", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspace = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-acp-permission-workspace-", + }); + const workspaceLinkParent = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-acp-permission-link-parent-", + }); + const workspaceLink = path.join(workspaceLinkParent, "workspace-link"); + yield* fileSystem.makeDirectory(path.join(workspace, "src"), { recursive: true }); + yield* fileSystem.writeFileString(path.join(workspace, "src", "existing.ts"), "existing"); + yield* fileSystem.symlink(workspace, workspaceLink); + + const realPolicy: AcpRuntimePolicy = { + runtimeMode: "full-access", + cwd: workspaceLink, + approvalPolicy: "never", + sandboxPolicy: { + type: "workspaceWrite", + writableRoots: [], + networkAccess: false, + }, + }; + + assert.equal( + acpPermissionDisposition( + realPolicy, + permissionRequest("edit", [{ path: "src/existing.ts" }]), + ), + "allow", + ); + assert.equal( + acpPermissionDisposition( + realPolicy, + permissionRequest("edit", [{ path: "src/generated/new-file.ts" }]), + ), + "allow", + "non-existent descendants of a real in-root ancestor remain writable", + ); + }).pipe(Effect.provide(NodeServices.layer)), + ); +}); + +describe("acpMcpToolApprovalElicitationDisposition", () => { + it("applies runtime policy only to explicitly tagged MCP approval elicitations", () => { + const fullAccess: AcpRuntimePolicy = { + runtimeMode: "full-access", + cwd: process.cwd(), + }; + const approvalRequired: AcpRuntimePolicy = { + runtimeMode: "approval-required", + cwd: process.cwd(), + }; + const tagged = { + sessionId: "session-1", + message: "Approve this request?", + mode: "form", + requestedSchema: { type: "object", properties: {} }, + _meta: { codex_approval_kind: "mcp_tool_call" }, + } satisfies EffectAcpSchema.CreateElicitationRequest; + + assert.equal(acpMcpToolApprovalElicitationDisposition(fullAccess, tagged), "allow"); + assert.equal(acpMcpToolApprovalElicitationDisposition(approvalRequired, tagged), "ask"); + assert.equal( + acpMcpToolApprovalElicitationDisposition( + { ...fullAccess, approvalPolicy: "on-request" }, + tagged, + ), + "ask", + ); + assert.equal( + acpMcpToolApprovalElicitationDisposition( + { + runtimeMode: "auto-accept-edits", + cwd: process.cwd(), + approvalPolicy: "never", + sandboxPolicy: { + type: "workspaceWrite", + writableRoots: [], + networkAccess: false, + }, + }, + tagged, + ), + "allow", + ); + const { _meta: _tag, ...untagged } = tagged; + assert.equal( + acpMcpToolApprovalElicitationDisposition( + fullAccess, + untagged, + "mcp_tool_call_approval_exec-123", + ), + "allow", + ); + assert.isUndefined( + acpMcpToolApprovalElicitationDisposition(fullAccess, { + ...tagged, + _meta: { codex_approval_kind: "ordinary_form" }, + }), + ); + assert.isUndefined( + acpMcpToolApprovalElicitationDisposition(fullAccess, { + sessionId: "session-1", + message: "Authenticate", + mode: "url", + elicitationId: "elicitation-1", + url: "https://example.com/login", + _meta: { codex_approval_kind: "mcp_tool_call" }, + }), + ); + }); +}); + +describe("client terminal disposition", () => { + const cwd = NodePath.resolve(process.cwd(), "acp-client-policy-workspace"); + + it("asks in approval-required and auto-accept-edits, allows in auto and full access", () => { + assert.equal(acpClientExecuteDisposition({ runtimeMode: "approval-required", cwd }), "ask"); + assert.equal(acpClientExecuteDisposition({ runtimeMode: "auto-accept-edits", cwd }), "ask"); + assert.equal(acpClientExecuteDisposition({ runtimeMode: "auto", cwd }), "allow"); + assert.equal(acpClientExecuteDisposition({ runtimeMode: "full-access", cwd }), "allow"); + }); + + it("denies terminals under explicit read-only and workspace-write sandboxes", () => { + for (const sandboxPolicy of [ + { type: "readOnly" }, + { type: "workspaceWrite", writableRoots: [], networkAccess: false }, + ]) { + assert.equal( + acpClientExecuteDisposition({ + runtimeMode: "full-access", + cwd, + approvalPolicy: "never", + sandboxPolicy, + }), + "deny", + ); + } + }); +}); + +describe("makeAcpClientPolicyGrants", () => { + it("grants terminals only from commands, never from an approved read or edit", () => { + const grants = makeAcpClientPolicyGrants(); + grants.recordApproval({ kind: "file-read", scope: "turn", turnKey: "turn-1" }); + grants.recordApproval({ kind: "file-change", scope: "session", turnKey: "turn-1" }); + assert.isFalse(grants.allowsExecute("turn-1")); + grants.recordApproval({ kind: "command", scope: "turn", turnKey: "turn-1" }); + assert.isTrue(grants.allowsExecute("turn-1")); + assert.isFalse(grants.allowsExecute("turn-2")); + assert.isFalse(grants.allowsExecute(null)); + }); + + it("keeps accept-for-session command grants across turns", () => { + const grants = makeAcpClientPolicyGrants(); + grants.recordApproval({ kind: "command", scope: "session", turnKey: "turn-1" }); + assert.isTrue(grants.allowsExecute("turn-9")); + assert.isTrue(grants.allowsExecute(null)); + }); + + it("drops a turn's command grant when a later turn approves one", () => { + const grants = makeAcpClientPolicyGrants(); + grants.recordApproval({ kind: "command", scope: "turn", turnKey: "turn-1" }); + grants.recordApproval({ kind: "command", scope: "turn", turnKey: "turn-2" }); + assert.isFalse(grants.allowsExecute("turn-1")); + assert.isTrue(grants.allowsExecute("turn-2")); + }); +}); diff --git a/packages/provider-acp/src/server/clientPolicy.ts b/packages/provider-acp/src/server/clientPolicy.ts new file mode 100644 index 0000000000000000000000000000000000000000..5a0d9355a1c11d852a230a025d46ab6afb29867e --- /dev/null +++ b/packages/provider-acp/src/server/clientPolicy.ts @@ -0,0 +1,297 @@ +// @effect-diagnostics nodeBuiltinImport:off +import type { ProviderRequestKind, RuntimeMode } from "@t3tools/contracts"; +import type * as EffectAcpSchema from "effect-acp/compat"; +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; + +/** + * Runtime-policy decisions for ACP work that T3 mediates. + * + * ACP agents run their own tools under their own permission model and ask + * through `session/request_permission`, which T3 answers by policy. The one + * client-mediated path left is Devin's `terminal/*`, which runs with the T3 + * server's own privileges; it resolves through {@link acpOperationDisposition} + * so a client terminal can never do more than an execute permission request. + */ + +/** Structural subset of the adapter runtime policy that decisions read. */ +export interface AcpRuntimePolicy { + readonly runtimeMode: RuntimeMode; + readonly cwd: string | null; + readonly approvalPolicy?: unknown; + readonly sandboxPolicy?: unknown; +} + +export type AcpPermissionDisposition = "allow" | "ask" | "deny"; + +export function unknownRecord(value: unknown): Record | undefined { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function resolveAcpPermissionPath(path: string, cwd: string | null): string | undefined { + const trimmed = path.trim(); + if (trimmed.length === 0) return undefined; + if (NodePath.isAbsolute(trimmed)) return trimmed; + if (cwd === null || cwd.trim().length === 0) return undefined; + return `${cwd}${cwd.endsWith(NodePath.sep) ? "" : NodePath.sep}${trimmed}`; +} + +function acpPathIsWithinRoot(path: string, root: string): boolean { + const relative = NodePath.relative(root, path); + return ( + relative === "" || + (relative !== ".." && + !relative.startsWith(`..${NodePath.sep}`) && + !NodePath.isAbsolute(relative)) + ); +} + +/** + * Canonicalize a path for an authorization containment check. + * + * `realpath` cannot resolve a file that has not been created yet, so walk up + * to the deepest existing ancestor and append the missing suffix to that + * ancestor's canonical path. This follows symlinked directories while still + * allowing normal writes to new files. If an existing entry cannot be + * canonicalized (for example, a broken symlink), fail closed. + */ +function acpCanonicalPathForContainment(path: string): string | undefined { + // Do not lexically normalize before realpath. For a path such as + // `workspace/link/../file`, the kernel resolves `link` before `..`; an + // eager NodePath.resolve would erase that symlink traversal and could turn + // an outside target into an apparently in-workspace path. + let candidate = path; + const missingSuffix: Array = []; + + while (true) { + try { + return NodePath.resolve(NodeFS.realpathSync.native(candidate), ...missingSuffix); + } catch (cause) { + if ((cause as NodeJS.ErrnoException | undefined)?.code !== "ENOENT") { + return undefined; + } + } + + try { + NodeFS.lstatSync(candidate); + // The entry exists but realpath could not resolve it, as with a broken + // symlink. Treat it as untrusted rather than authorizing its lexical path. + return undefined; + } catch (cause) { + if ((cause as NodeJS.ErrnoException | undefined)?.code !== "ENOENT") { + return undefined; + } + } + + const parent = NodePath.dirname(candidate); + if (parent === candidate) return undefined; + missingSuffix.unshift(NodePath.basename(candidate)); + candidate = parent; + } +} + +/** The parts of a native operation the disposition logic reads. */ +interface AcpPolicyOperation { + readonly kind: string | null | undefined; + readonly locations: ReadonlyArray<{ readonly path: string }> | null | undefined; +} + +function acpPolicyRequiresApproval(runtimePolicy: AcpRuntimePolicy): boolean { + return runtimePolicy.approvalPolicy === undefined + ? runtimePolicy.runtimeMode === "approval-required" + : runtimePolicy.approvalPolicy !== "never"; +} + +function isAcpReadKind(toolKind: string): boolean { + return toolKind === "read" || toolKind === "search" || toolKind === "think"; +} + +/** + * Reads follow the sandbox alone and never ask. Approval policy governs writes + * and commands, as it does for Codex and Claude, and every sandbox T3 knows + * lets the agent read. That includes no explicit sandbox, since the strictest + * runtime mode (approval-required) implies a read-only one. Unknown sandbox + * types still fail closed. + */ +function acpReadDisposition(runtimePolicy: AcpRuntimePolicy): "allow" | "deny" { + switch (unknownRecord(runtimePolicy.sandboxPolicy)?.type) { + case undefined: + case "readOnly": + case "workspaceWrite": + case "dangerFullAccess": + case "externalSandbox": + return "allow"; + default: + return "deny"; + } +} + +function acpWorkspaceWriteAllowsMutation( + runtimePolicy: AcpRuntimePolicy, + sandboxPolicy: Record, + locations: AcpPolicyOperation["locations"], +): boolean { + const cwd = + typeof runtimePolicy.cwd === "string" && runtimePolicy.cwd.trim().length > 0 + ? (resolveAcpPermissionPath(runtimePolicy.cwd, process.cwd()) ?? null) + : null; + const roots: Array = []; + if (cwd !== null) { + const canonicalCwd = acpCanonicalPathForContainment(cwd); + if (canonicalCwd !== undefined) roots.push(canonicalCwd); + } + const writableRoots = sandboxPolicy.writableRoots; + if (Array.isArray(writableRoots)) { + for (const writableRoot of writableRoots) { + if (typeof writableRoot !== "string") continue; + const resolved = resolveAcpPermissionPath(writableRoot, cwd); + if (resolved === undefined) continue; + const canonicalRoot = acpCanonicalPathForContainment(resolved); + if (canonicalRoot !== undefined) roots.push(canonicalRoot); + } + } + if (roots.length === 0) return false; + + if (locations === undefined || locations === null || locations.length === 0) { + return false; + } + for (const location of locations) { + const resolved = resolveAcpPermissionPath(location.path, cwd); + const canonicalPath = + resolved === undefined ? undefined : acpCanonicalPathForContainment(resolved); + if ( + canonicalPath === undefined || + !roots.some((root) => acpPathIsWithinRoot(canonicalPath, root)) + ) { + return false; + } + } + return true; +} + +/** + * Disposition of one native operation under the active runtime policy. Shared + * by provider permission requests and the client fs/terminal handlers. + */ +function acpOperationDisposition( + runtimePolicy: AcpRuntimePolicy, + operation: AcpPolicyOperation, +): AcpPermissionDisposition { + const toolKind = operation.kind ?? "other"; + if (isAcpReadKind(toolKind)) { + return acpReadDisposition(runtimePolicy); + } + if (acpPolicyRequiresApproval(runtimePolicy)) { + return "ask"; + } + + const sandboxPolicy = unknownRecord(runtimePolicy.sandboxPolicy); + switch (sandboxPolicy?.type) { + case "readOnly": + return "deny"; + case "workspaceWrite": + if (isAcpMutationKind(toolKind)) { + return acpWorkspaceWriteAllowsMutation( + runtimePolicy, + sandboxPolicy ?? {}, + operation.locations, + ) + ? "allow" + : "deny"; + } + return "deny"; + case "dangerFullAccess": + case "externalSandbox": + return "allow"; + case undefined: + if (runtimePolicy.runtimeMode === "approval-required") return "deny"; + // Auto-accept edits approves file changes wherever the agent makes them + // (ACP prompts need not carry locations to confine); other actions still ask. + if ( + runtimePolicy.runtimeMode === "auto-accept-edits" && + runtimePolicy.approvalPolicy === undefined && + !isAcpMutationKind(toolKind) + ) { + return "ask"; + } + return "allow"; + default: + return "deny"; + } +} + +function isAcpMutationKind(toolKind: string): boolean { + return toolKind === "edit" || toolKind === "delete" || toolKind === "move"; +} + +export function acpPermissionDisposition( + runtimePolicy: AcpRuntimePolicy, + request: EffectAcpSchema.RequestPermissionRequest, +): AcpPermissionDisposition { + return acpOperationDisposition(runtimePolicy, { + kind: request.toolCall.kind, + locations: request.toolCall.locations, + }); +} + +/** Resolve explicitly tagged MCP approvals through the thread's normal policy. */ +export function acpMcpToolApprovalElicitationDisposition( + runtimePolicy: AcpRuntimePolicy, + request: EffectAcpSchema.CreateElicitationRequest, + nativeRequestId?: string, +): AcpPermissionDisposition | undefined { + if ( + request.mode !== "form" || + (unknownRecord(request._meta)?.codex_approval_kind !== "mcp_tool_call" && + nativeRequestId?.startsWith("mcp_tool_call_approval_") !== true) + ) { + return undefined; + } + // This request comes from T3's authenticated, scope-checked MCP endpoint, + // not an arbitrary provider command. Let explicit approval mode surface it + // to the user and otherwise allow the endpoint to enforce its own policy. + return acpPolicyRequiresApproval(runtimePolicy) ? "ask" : "allow"; +} + +/** Disposition of a client-mediated `terminal/create` (Devin's client terminals). */ +export function acpClientExecuteDisposition( + runtimePolicy: AcpRuntimePolicy, +): AcpPermissionDisposition { + return acpOperationDisposition(runtimePolicy, { kind: "execute", locations: undefined }); +} + +export interface AcpApprovalGrantInput { + readonly kind: ProviderRequestKind; + readonly scope: "session" | "turn"; + readonly turnKey: string; +} + +export interface AcpClientPolicyGrants { + readonly recordApproval: (input: AcpApprovalGrantInput) => void; + readonly allowsExecute: (turnKey: string | null) => boolean; +} + +/** + * Command approvals recorded when the user accepts a `session/request_permission`, + * so an "ask" disposition at the client terminal boundary honors a command the + * user already approved. ACP does not link an approved tool call to the + * terminals the agent then creates, so an approved command authorizes client + * terminals for the approving turn, or for the session on accept-for-session. + */ +export function makeAcpClientPolicyGrants(): AcpClientPolicyGrants { + let sessionExecute = false; + let turnExecute: string | null = null; + return { + recordApproval: (input) => { + if (input.kind !== "command") return; + if (input.scope === "session") { + sessionExecute = true; + } else { + turnExecute = input.turnKey; + } + }, + allowsExecute: (turnKey) => sessionExecute || (turnKey !== null && turnExecute === turnKey), + }; +} diff --git a/packages/provider-acp/src/server/clientTerminals.test.ts b/packages/provider-acp/src/server/clientTerminals.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..83b5273a21add58c97a5e8cd51faabf33a099735 --- /dev/null +++ b/packages/provider-acp/src/server/clientTerminals.test.ts @@ -0,0 +1,312 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Result from "effect/Result"; +import { ChildProcessSpawner } from "effect/process"; + +import { + makeAcpClientTerminals, + resolveEmbeddedTerminalContent, + type AcpClientTerminals, +} from "./clientTerminals.ts"; + +const withTerminals = (use: (terminals: AcpClientTerminals) => Effect.Effect) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const terminals = yield* makeAcpClientTerminals({ + spawner, + defaultCwd: process.cwd(), + }); + return yield* use(terminals).pipe(Effect.ensuring(terminals.disposeAll)); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped); + +describe("AcpClientTerminals", () => { + it.effect("runs a command, buffers output, and reports the exit status", () => + withTerminals((terminals) => + Effect.gen(function* () { + const created = yield* terminals.create({ + sessionId: "session", + command: process.execPath, + args: ["-e", "console.log('hello from acp'); process.exit(3);"], + }); + + const exit = yield* terminals.waitForExit({ + sessionId: "session", + terminalId: created.terminalId, + }); + expect(exit.exitCode).toBe(3); + + const output = yield* terminals.output({ + sessionId: "session", + terminalId: created.terminalId, + }); + expect(output.output).toContain("hello from acp"); + expect(output.truncated).toBe(false); + expect(output.exitStatus?.exitCode).toBe(3); + }), + ), + ); + + it.effect("resolves terminal environment from the requesting ACP session", () => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const terminals = yield* makeAcpClientTerminals({ + spawner, + defaultCwd: process.cwd(), + environmentForSession: (sessionId) => ({ + T3_TEST_ACP_SESSION_TOKEN: sessionId === "session-a" ? "token-a" : "token-b", + }), + }); + yield* Effect.addFinalizer(() => terminals.disposeAll); + + for (const [sessionId, expected] of [ + ["session-a", "token-a"], + ["session-b", "token-b"], + ] as const) { + const created = yield* terminals.create({ + sessionId, + command: process.execPath, + args: ["-e", "process.stdout.write(process.env.T3_TEST_ACP_SESSION_TOKEN ?? '')"], + }); + yield* terminals.waitForExit({ sessionId, terminalId: created.terminalId }); + const output = yield* terminals.output({ sessionId, terminalId: created.terminalId }); + expect(output.output).toBe(expected); + } + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + + it.effect("runs Devin shell source with arguments, pipes, and session fallback variables", () => + Effect.gen(function* () { + const terminals = yield* makeAcpClientTerminals({ + spawner: yield* ChildProcessSpawner.ChildProcessSpawner, + defaultCwd: process.cwd(), + shellCommands: true, + environmentForSession: () => ({ T3_ACP_MCP_NODE: "mailbox-probe" }), + }); + yield* Effect.addFinalizer(() => terminals.disposeAll); + // Same command-only shape as the failed production commands and live capture. + const terminal = yield* terminals.create({ + sessionId: "devin", + command: 'printf "%s\\n" "$T3_ACP_MCP_NODE" | tr a-z A-Z', + }); + const exit = yield* terminals.waitForExit({ sessionId: "devin", ...terminal }); + const output = yield* terminals.output({ sessionId: "devin", ...terminal }); + expect(exit.exitCode).toBe(0); + expect(output.output.trim()).toBe("MAILBOX-PROBE"); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + + it.effect("truncates buffered output from the beginning at the byte limit", () => + withTerminals((terminals) => + Effect.gen(function* () { + const created = yield* terminals.create({ + sessionId: "session", + command: process.execPath, + args: ["-e", "process.stdout.write('a'.repeat(64) + 'TAIL');"], + outputByteLimit: 16, + }); + yield* terminals.waitForExit({ sessionId: "session", terminalId: created.terminalId }); + + const output = yield* terminals.output({ + sessionId: "session", + terminalId: created.terminalId, + }); + expect(output.truncated).toBe(true); + expect(output.output.endsWith("TAIL")).toBe(true); + expect(output.output.length).toBeLessThanOrEqual(16); + }), + ), + ); + + it.effect("kills long-running commands and rejects released terminal handles", () => + withTerminals((terminals) => + Effect.gen(function* () { + const created = yield* terminals.create({ + sessionId: "session", + command: process.execPath, + args: ["-e", "setInterval(() => {}, 1000);"], + }); + yield* terminals.kill({ sessionId: "session", terminalId: created.terminalId }); + const exit = yield* terminals.waitForExit({ + sessionId: "session", + terminalId: created.terminalId, + }); + expect(exit.exitCode === 0 ? null : exit.exitCode).not.toBe(0); + expect(exit.signal).toBe("SIGTERM"); + + yield* terminals.release({ sessionId: "session", terminalId: created.terminalId }); + const rejected = yield* Effect.flip( + terminals.output({ sessionId: "session", terminalId: created.terminalId }), + ); + expect(rejected.message).toContain("unknown terminal ID"); + + // Embedded tool-call content still renders released terminals. + expect(terminals.readOutputSnapshot("session", created.terminalId)).toBeDefined(); + }), + ), + ); + + it.effect("reserves terminal capacity across concurrent creates", () => + withTerminals((terminals) => + Effect.gen(function* () { + const results = yield* Effect.all( + Array.from({ length: 17 }, () => + terminals + .create({ + sessionId: "session", + command: process.execPath, + args: ["-e", "setInterval(() => {}, 1000);"], + }) + .pipe(Effect.result), + ), + { concurrency: "unbounded" }, + ); + + expect(results.filter(Result.isSuccess)).toHaveLength(16); + expect(results.filter(Result.isFailure)).toHaveLength(1); + }), + ), + ); + + it.effect("bounds retained snapshots after terminals are released", () => + withTerminals((terminals) => + Effect.gen(function* () { + const terminalIds: string[] = []; + for (let index = 0; index < 33; index += 1) { + const created = yield* terminals.create({ + sessionId: "session", + command: process.execPath, + args: ["-e", `process.stdout.write('${index}');`], + }); + yield* terminals.release({ sessionId: "session", terminalId: created.terminalId }); + terminalIds.push(created.terminalId); + } + + expect(terminals.readOutputSnapshot("session", terminalIds[0]!)).toBeUndefined(); + expect(terminals.readOutputSnapshot("session", terminalIds.at(-1)!)).toBeDefined(); + }), + ), + ); + + it.effect("bounds unreleased terminal handles without invalidating existing ids", () => + withTerminals((terminals) => + Effect.gen(function* () { + const terminalIds: string[] = []; + for (let index = 0; index < 64; index += 1) { + const created = yield* terminals.create({ + sessionId: "session", + command: process.execPath, + args: ["-e", `process.stdout.write('${index}');`], + }); + yield* terminals.waitForExit({ + sessionId: "session", + terminalId: created.terminalId, + }); + terminalIds.push(created.terminalId); + } + + const rejected = yield* terminals + .create({ + sessionId: "session", + command: process.execPath, + args: ["-e", ""], + }) + .pipe(Effect.result); + expect(Result.isFailure(rejected)).toBe(true); + expect(terminals.readOutputSnapshot("session", terminalIds[0]!)).toBeDefined(); + expect(terminals.readOutputSnapshot("session", terminalIds.at(-1)!)).toBeDefined(); + }), + ), + ); + + it.effect("bounds aggregate output retained across unreleased terminals", () => + withTerminals((terminals) => + Effect.gen(function* () { + const terminalIds: string[] = []; + for (let index = 0; index < 3; index += 1) { + const created = yield* terminals.create({ + sessionId: "session", + command: process.execPath, + args: ["-e", "process.stdout.write('x'.repeat(8 * 1024 * 1024));"], + outputByteLimit: 8 * 1024 * 1024, + }); + yield* terminals.waitForExit({ + sessionId: "session", + terminalId: created.terminalId, + }); + terminalIds.push(created.terminalId); + } + + const snapshots = terminalIds.map((terminalId) => + terminals.readOutputSnapshot("session", terminalId)!, + ); + const retainedBytes = snapshots.reduce( + (total, snapshot) => total + Buffer.byteLength(snapshot.output), + 0, + ); + expect(retainedBytes).toBeLessThanOrEqual(16 * 1024 * 1024); + expect(snapshots.some((snapshot) => snapshot.truncated)).toBe(true); + }), + ), + ); + + it.effect("rejects terminal handles from a different ACP session", () => + withTerminals((terminals) => + Effect.gen(function* () { + const created = yield* terminals.create({ + sessionId: "owner-session", + command: process.execPath, + args: ["-e", "process.stdout.write('owned');"], + }); + const foreignRequest = { + sessionId: "foreign-session", + terminalId: created.terminalId, + }; + const results = yield* Effect.all( + [ + terminals.output(foreignRequest), + terminals.waitForExit(foreignRequest), + terminals.kill(foreignRequest), + terminals.release(foreignRequest), + ].map((effect) => effect.pipe(Effect.result)), + { concurrency: "unbounded" }, + ); + + expect(results.every(Result.isFailure)).toBe(true); + expect(terminals.readOutputSnapshot("foreign-session", created.terminalId)).toBeUndefined(); + expect(terminals.readOutputSnapshot("owner-session", created.terminalId)).toBeDefined(); + }), + ), + ); +}); + +describe("resolveEmbeddedTerminalContent", () => { + it("rewrites terminal content into text from the buffered snapshot", () => { + const notification = { + sessionId: "session", + update: { + sessionUpdate: "tool_call_update" as const, + toolCallId: "call-1", + content: [ + { type: "content" as const, content: { type: "text" as const, text: "before" } }, + { type: "terminal" as const, terminalId: "t3-term-1" }, + { type: "terminal" as const, terminalId: "t3-term-unknown" }, + ], + }, + }; + + const resolved = resolveEmbeddedTerminalContent(notification, (sessionId, terminalId) => + sessionId === "session" && terminalId === "t3-term-1" + ? { output: "compiled 3 files", truncated: false, exitStatus: undefined } + : undefined, + ); + + expect(resolved.update).toMatchObject({ + content: [ + { type: "content", content: { type: "text", text: "before" } }, + { type: "content", content: { type: "text", text: "compiled 3 files" } }, + { type: "content", content: { type: "text", text: "[terminal t3-term-unknown]" } }, + ], + }); + }); +}); diff --git a/packages/provider-acp/src/server/clientTerminals.ts b/packages/provider-acp/src/server/clientTerminals.ts new file mode 100644 index 0000000000000000000000000000000000000000..c7743d60daae7739f46f154f03da3c8efd1dbc05 --- /dev/null +++ b/packages/provider-acp/src/server/clientTerminals.ts @@ -0,0 +1,459 @@ +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; +import * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpSchema from "effect-acp/compat"; +import * as NodeBuffer from "node:buffer"; + +/** + * Client-side implementation of the ACP `terminal` capability. + * + * Agents create terminals to run commands through the client + * (`terminal/create`), poll buffered output, await exit, kill, and release + * them. Terminals also appear as embedded tool-call content, which + * {@link resolveEmbeddedTerminalContent} rewrites into plain text so the + * existing tool-call projection renders live command output. + */ + +const MAX_LIVE_TERMINALS = 16; +const MAX_UNRELEASED_TERMINALS = 64; +const MAX_RETAINED_TERMINALS = 32; +const MAX_RETAINED_OUTPUT_BYTES = 16 * 1024 * 1024; +const DEFAULT_OUTPUT_BYTE_LIMIT = 4 * 1024 * 1024; +const MAX_OUTPUT_BYTE_LIMIT = 8 * 1024 * 1024; + +const terminalRequestError = (message: string, cause?: unknown) => + EffectAcpErrors.AcpRequestError.internalError( + message, + undefined, + cause === undefined ? {} : { cause }, + ); + +interface OutputBufferState { + chunks: Array; + bytes: number; + truncated: boolean; + readonly limit: number; +} + +function trimOutputStart(state: OutputBufferState, bytes: number): void { + let remaining = Math.min(bytes, state.bytes); + while (remaining > 0) { + const head = state.chunks[0]!; + if (head.byteLength <= remaining) { + state.chunks.shift(); + state.bytes -= head.byteLength; + remaining -= head.byteLength; + } else { + state.chunks[0] = head.subarray(remaining); + state.bytes -= remaining; + remaining = 0; + } + state.truncated = true; + } +} + +function appendOutput(state: OutputBufferState, chunk: Uint8Array): void { + state.chunks.push(chunk); + state.bytes += chunk.byteLength; + // The spec requires truncation from the beginning of the output. + if (state.bytes > state.limit) trimOutputStart(state, state.bytes - state.limit); +} + +function readOutput(state: OutputBufferState): string { + let combined: Uint8Array = NodeBuffer.Buffer.concat(state.chunks, state.bytes); + if (state.truncated) { + // Drop leading UTF-8 continuation bytes so truncation lands on a + // character boundary, as the spec requires. + let start = 0; + while (start < combined.byteLength && (combined[start]! & 0b1100_0000) === 0b1000_0000) { + start += 1; + } + combined = combined.subarray(start); + } + return NodeBuffer.Buffer.from(combined).toString("utf8"); +} + +function terminalExitSignal(error: unknown): string | null { + const cause = error instanceof Error ? error.cause : undefined; + const message = + cause instanceof Error ? cause.message : error instanceof Error ? error.message : ""; + return message.match(/signal: '([^']+)'/u)?.[1] ?? null; +} + +export interface AcpTerminalOutputSnapshot { + readonly output: string; + readonly truncated: boolean; + readonly exitStatus: EffectAcpSchema.TerminalExitStatus | undefined; +} + +interface ManagedAcpTerminal { + readonly terminalId: string; + readonly sessionId: string; + /** The spawned command with args, kept for MCP-fallback identification. */ + readonly commandLine: string; + readonly buffer: OutputBufferState; + readonly exit: Deferred.Deferred; + readonly kill: Effect.Effect; + /** Closes the terminal's scope, killing the process if still running. */ + readonly dispose: Effect.Effect; + exitStatus: EffectAcpSchema.TerminalExitStatus | undefined; + released: boolean; +} + +export interface AcpClientTerminals { + readonly create: ( + request: EffectAcpSchema.CreateTerminalRequest, + ) => Effect.Effect; + readonly output: ( + request: EffectAcpSchema.TerminalOutputRequest, + ) => Effect.Effect; + readonly waitForExit: ( + request: EffectAcpSchema.WaitForTerminalExitRequest, + ) => Effect.Effect; + readonly kill: ( + request: EffectAcpSchema.KillTerminalRequest, + ) => Effect.Effect; + readonly release: ( + request: EffectAcpSchema.ReleaseTerminalRequest, + ) => Effect.Effect; + /** Buffered output for embedded tool-call content; survives release. */ + readonly readOutputSnapshot: ( + sessionId: string, + terminalId: string, + ) => AcpTerminalOutputSnapshot | undefined; + /** Command line a terminal was created with; presentation-only lookup. */ + readonly readCommandLine: (terminalId: string) => string | undefined; + /** Kills every terminal that is still running. Safe to call repeatedly. */ + readonly disposeAll: Effect.Effect; +} + +export interface AcpClientTerminalsOptions { + /** Devin sends shell source in command, rather than an executable plus args. */ + readonly shellCommands?: boolean | undefined; + readonly spawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; + readonly defaultCwd: string; + readonly environment?: NodeJS.ProcessEnv | undefined; + readonly environmentForSession?: + | ((sessionId: string) => NodeJS.ProcessEnv | undefined) + | undefined; + readonly forceKillAfter?: Duration.Input | undefined; +} + +function acpTerminalCommand(input: { + readonly request: EffectAcpSchema.CreateTerminalRequest; + readonly shellCommands?: boolean | undefined; + readonly defaultCwd: string; + readonly environment?: NodeJS.ProcessEnv | undefined; + readonly forceKillAfter?: Duration.Input | undefined; +}) { + return ChildProcess.make(input.request.command, input.request.args ?? [], { + cwd: input.request.cwd ?? input.defaultCwd, + ...(input.environment === undefined ? {} : { env: input.environment }), + shell: input.shellCommands === true && (input.request.args?.length ?? 0) === 0, + forceKillAfter: input.forceKillAfter ?? "5 seconds", + }); +} + +export const makeAcpClientTerminals = ( + options: AcpClientTerminalsOptions, +): Effect.Effect => + Effect.gen(function* () { + const creationPermit = yield* Semaphore.make(1); + const terminals = new Map(); + let nextTerminalNumber = 1; + + const enforceTotalOutputLimit = (): void => { + let excess = + Array.from(terminals.values()).reduce( + (total, terminal) => total + terminal.buffer.bytes, + 0, + ) - MAX_RETAINED_OUTPUT_BYTES; + if (excess <= 0) return; + for (const terminal of terminals.values()) { + const before = terminal.buffer.bytes; + trimOutputStart(terminal.buffer, excess); + excess -= before - terminal.buffer.bytes; + if (excess <= 0) return; + } + }; + + const pruneReleasedSnapshots = (): void => { + const released = Array.from(terminals.values()).filter((terminal) => terminal.released); + let retainedBytes = released.reduce((total, terminal) => total + terminal.buffer.bytes, 0); + while ( + released.length > MAX_RETAINED_TERMINALS || + retainedBytes > MAX_RETAINED_OUTPUT_BYTES + ) { + const terminal = released.shift(); + if (terminal === undefined) break; + retainedBytes -= terminal.buffer.bytes; + terminals.delete(terminal.terminalId); + } + }; + + const requireTerminal = ( + sessionId: string, + terminalId: string, + operation: string, + ): Effect.Effect => { + const terminal = terminals.get(terminalId); + if (terminal === undefined || terminal.sessionId !== sessionId || terminal.released) { + return Effect.fail( + terminalRequestError(`ACP ${operation} received an unknown terminal ID '${terminalId}'.`), + ); + } + return Effect.succeed(terminal); + }; + + const create: AcpClientTerminals["create"] = (request) => + creationPermit.withPermit( + Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const liveCount = Array.from(terminals.values()).filter( + (terminal) => !terminal.released && terminal.exitStatus === undefined, + ).length; + const unreleasedCount = Array.from(terminals.values()).filter( + (terminal) => !terminal.released, + ).length; + if (unreleasedCount >= MAX_UNRELEASED_TERMINALS) { + return yield* terminalRequestError( + `ACP terminal/create exceeded the limit of ${MAX_UNRELEASED_TERMINALS} unreleased terminals.`, + ); + } + if (liveCount >= MAX_LIVE_TERMINALS) { + return yield* terminalRequestError( + `ACP terminal/create exceeded the limit of ${MAX_LIVE_TERMINALS} concurrent terminals.`, + ); + } + const requestEnvironment = Object.fromEntries( + (request.env ?? []).map((variable) => [variable.name, variable.value] as const), + ); + const sessionEnvironment = options.environmentForSession?.(request.sessionId); + const environment = + options.environment === undefined && + sessionEnvironment === undefined && + (request.env?.length ?? 0) === 0 + ? undefined + : { ...options.environment, ...sessionEnvironment, ...requestEnvironment }; + // Each terminal owns a scope so the spawned process is reliably reaped: + // closing the scope kills a still-running command and frees the handle. + const terminalScope = yield* Scope.make(); + const child = yield* restore( + options.spawner + .spawn( + acpTerminalCommand({ + request, + shellCommands: options.shellCommands, + defaultCwd: options.defaultCwd, + environment, + forceKillAfter: options.forceKillAfter, + }), + ) + .pipe( + Effect.provideService(Scope.Scope, terminalScope), + Effect.mapError((cause) => + terminalRequestError( + `Could not start terminal command '${request.command}'.`, + cause, + ), + ), + ), + ).pipe( + Effect.onExit((exit) => + Exit.isFailure(exit) ? Scope.close(terminalScope, Exit.void) : Effect.void, + ), + ); + + const terminalId = `t3-term-${nextTerminalNumber}`; + nextTerminalNumber += 1; + const record: ManagedAcpTerminal = { + terminalId, + sessionId: request.sessionId, + commandLine: [request.command, ...(request.args ?? [])].join(" "), + buffer: { + chunks: [], + bytes: 0, + truncated: false, + limit: Math.min( + request.outputByteLimit ?? DEFAULT_OUTPUT_BYTE_LIMIT, + MAX_OUTPUT_BYTE_LIMIT, + ), + }, + exit: yield* Deferred.make(), + kill: child.kill().pipe(Effect.ignore), + dispose: Scope.close(terminalScope, Exit.void).pipe(Effect.ignore), + exitStatus: undefined, + released: false, + }; + terminals.set(terminalId, record); + + const pump = (stream: typeof child.stdout) => + stream.pipe( + Stream.runForEach((chunk) => + Effect.sync(() => { + appendOutput(record.buffer, chunk); + enforceTotalOutputLimit(); + }), + ), + Effect.ignore, + ); + const stdoutPump = yield* Effect.forkDetach(pump(child.stdout)); + const stderrPump = yield* Effect.forkDetach(pump(child.stderr)); + yield* Effect.forkDetach( + child.exitCode.pipe( + Effect.match({ + onFailure: (error) => ({ exitCode: null, signal: terminalExitSignal(error) }), + onSuccess: (code) => ({ exitCode: Number(code), signal: null }), + }), + Effect.flatMap((status) => + Effect.all([Fiber.join(stdoutPump), Fiber.join(stderrPump)], { + discard: true, + }).pipe( + Effect.andThen( + Effect.sync(() => { + record.exitStatus = status; + }), + ), + Effect.andThen(Deferred.succeed(record.exit, status)), + ), + ), + ), + ); + return { terminalId }; + }), + ), + ); + + const output: AcpClientTerminals["output"] = (request) => + requireTerminal(request.sessionId, request.terminalId, "terminal/output").pipe( + Effect.map((terminal) => ({ + output: readOutput(terminal.buffer), + truncated: terminal.buffer.truncated, + ...(terminal.exitStatus === undefined ? {} : { exitStatus: terminal.exitStatus }), + })), + ); + + const waitForExit: AcpClientTerminals["waitForExit"] = (request) => + requireTerminal(request.sessionId, request.terminalId, "terminal/wait_for_exit").pipe( + Effect.flatMap((terminal) => Deferred.await(terminal.exit)), + ); + + const kill: AcpClientTerminals["kill"] = (request) => + requireTerminal(request.sessionId, request.terminalId, "terminal/kill").pipe( + Effect.flatMap((terminal) => terminal.kill), + Effect.as({}), + ); + + const release: AcpClientTerminals["release"] = (request) => + Effect.uninterruptible( + requireTerminal(request.sessionId, request.terminalId, "terminal/release").pipe( + Effect.flatMap((terminal) => + Effect.sync(() => { + terminal.released = true; + }).pipe( + Effect.andThen(terminal.dispose), + Effect.andThen(Deferred.await(terminal.exit)), + Effect.andThen(Effect.sync(pruneReleasedSnapshots)), + ), + ), + Effect.as({}), + ), + ); + + const readOutputSnapshot: AcpClientTerminals["readOutputSnapshot"] = ( + sessionId, + terminalId, + ) => { + const terminal = terminals.get(terminalId); + if (terminal === undefined || terminal.sessionId !== sessionId) return undefined; + return { + output: readOutput(terminal.buffer), + truncated: terminal.buffer.truncated, + exitStatus: terminal.exitStatus, + }; + }; + + const readCommandLine: AcpClientTerminals["readCommandLine"] = (terminalId) => + terminals.get(terminalId)?.commandLine; + + const disposeAll: AcpClientTerminals["disposeAll"] = Effect.uninterruptible( + Effect.suspend(() => + Effect.forEach( + Array.from(terminals.values()), + (terminal) => + Effect.sync(() => { + terminal.released = true; + }).pipe( + Effect.andThen(terminal.dispose), + Effect.andThen(Deferred.await(terminal.exit)), + ), + { concurrency: "unbounded", discard: true }, + ).pipe(Effect.andThen(Effect.sync(pruneReleasedSnapshots))), + ), + ); + + // Terminal failures surface to the agent as JSON-RPC errors and to the + // user only as a failed tool item, so log them for server-side diagnosis. + const logged = + ( + operation: string, + handler: (request: Request) => Effect.Effect, + ) => + (request: Request) => + handler(request).pipe( + Effect.tapError((error) => + Effect.logWarning("ACP client terminal operation failed", { + operation, + detail: error.message, + }), + ), + ); + + return { + create: logged("terminal/create", create), + output: logged("terminal/output", output), + waitForExit: logged("terminal/wait_for_exit", waitForExit), + kill: logged("terminal/kill", kill), + release: logged("terminal/release", release), + readOutputSnapshot, + readCommandLine, + disposeAll, + }; + }); + +/** + * Rewrites embedded `terminal` tool-call content into plain text content so + * the provider-neutral tool-call projection can display live command output. + */ +export function resolveEmbeddedTerminalContent( + notification: EffectAcpSchema.SessionNotification, + readOutputSnapshot: AcpClientTerminals["readOutputSnapshot"], +): EffectAcpSchema.SessionNotification { + const update = notification.update; + if (update.sessionUpdate !== "tool_call" && update.sessionUpdate !== "tool_call_update") { + return notification; + } + const content = update.content; + if (!content || !content.some((entry) => entry.type === "terminal")) { + return notification; + } + const mapped = content.map((entry): EffectAcpSchema.ToolCallContent => { + if (entry.type !== "terminal") return entry; + const snapshot = readOutputSnapshot(notification.sessionId, entry.terminalId); + return { + type: "content", + content: { + type: "text", + text: snapshot === undefined ? `[terminal ${entry.terminalId}]` : snapshot.output, + }, + }; + }); + return { ...notification, update: { ...update, content: mapped } }; +} diff --git a/packages/provider-acp/src/server/coreRuntimeEvents.test.ts b/packages/provider-acp/src/server/coreRuntimeEvents.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..dc4f5ea70605f5ecf8e767caaeb680b92030f835 --- /dev/null +++ b/packages/provider-acp/src/server/coreRuntimeEvents.test.ts @@ -0,0 +1,247 @@ +import { ProviderDriverKind, RuntimeRequestId, TurnId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + makeAcpAssistantItemEvent, + makeAcpContentDeltaEvent, + makeAcpPlanUpdatedEvent, + makeAcpRequestOpenedEvent, + makeAcpRequestResolvedEvent, + makeAcpToolCallEvent, +} from "./coreRuntimeEvents.ts"; + +describe("AcpCoreRuntimeEvents", () => { + it("maps ACP permission requests to canonical runtime events", () => { + const stamp = { eventId: "event-1" as never, createdAt: "2026-03-27T00:00:00.000Z" }; + const turnId = TurnId.make("turn-1"); + const permissionRequest = { + kind: "execute" as const, + detail: "cat package.json", + toolCall: { + toolCallId: "tool-1", + kind: "execute", + status: "pending" as const, + command: "cat package.json", + detail: "cat package.json", + data: { toolCallId: "tool-1", kind: "execute" }, + }, + }; + + const openedEvent = makeAcpRequestOpenedEvent({ + stamp, + provider: ProviderDriverKind.make("cursor"), + threadId: "thread-1" as never, + turnId, + requestId: RuntimeRequestId.make("request-1"), + permissionRequest, + detail: "cat package.json", + args: { command: ["cat", "package.json"] }, + source: "acp.jsonrpc", + method: "session/request_permission", + rawPayload: { sessionId: "session-1" }, + }); + expect(openedEvent).toMatchObject({ + type: "request.opened", + payload: { + requestType: "exec_command_approval", + detail: "cat package.json", + }, + }); + expect(openedEvent).not.toHaveProperty("payload.options"); + + expect( + makeAcpRequestResolvedEvent({ + stamp, + provider: ProviderDriverKind.make("cursor"), + threadId: "thread-1" as never, + turnId, + requestId: RuntimeRequestId.make("request-1"), + permissionRequest, + decision: "accept", + }), + ).toMatchObject({ + type: "request.resolved", + payload: { + requestType: "exec_command_approval", + decision: "accept", + }, + }); + }); + + it("preserves a native file approval without a remembered-allow choice", () => { + const event = makeAcpRequestOpenedEvent({ + stamp: { eventId: "approval-1" as never, createdAt: "2026-09-02T00:00:00.000Z" }, + provider: ProviderDriverKind.make("antigravity"), + threadId: "thread-1" as never, + turnId: TurnId.make("turn-1"), + requestId: RuntimeRequestId.make("request-1"), + permissionRequest: { kind: "edit" }, + approvalOptions: [ + { decision: "accept", label: "Allow once" }, + { decision: "decline", label: "Reject" }, + ], + detail: "Edit package.json", + args: {}, + source: "acp.jsonrpc", + method: "session/request_permission", + rawPayload: { sessionId: "session-1" }, + }); + + expect(event.payload).toEqual({ + requestType: "file_change_approval", + detail: "Edit package.json", + args: {}, + options: [ + { decision: "accept", label: "Allow once" }, + { decision: "decline", label: "Reject" }, + ], + }); + }); + + it("maps generic ACP permission kinds to dynamic tool approvals", () => { + const stamp = { eventId: "event-1" as never, createdAt: "2026-03-27T00:00:00.000Z" }; + + for (const kind of ["search", "fetch", "other", "unknown", "future-tool-kind"]) { + const permissionRequest = { kind }; + const request = { + stamp, + provider: ProviderDriverKind.make("cursor"), + threadId: "thread-1" as never, + turnId: TurnId.make("turn-1"), + requestId: RuntimeRequestId.make(`request-${kind}`), + permissionRequest, + }; + + expect( + makeAcpRequestOpenedEvent({ + ...request, + detail: kind, + args: {}, + source: "acp.jsonrpc", + method: "session/request_permission", + rawPayload: { sessionId: "session-1" }, + }), + ).toMatchObject({ + type: "request.opened", + payload: { requestType: "dynamic_tool_call" }, + }); + + expect( + makeAcpRequestResolvedEvent({ + ...request, + decision: "accept", + }), + ).toMatchObject({ + type: "request.resolved", + payload: { requestType: "dynamic_tool_call" }, + }); + } + }); + + it("maps ACP core plan, tool-call, and content updates", () => { + const stamp = { eventId: "event-1" as never, createdAt: "2026-03-27T00:00:00.000Z" }; + const turnId = TurnId.make("turn-1"); + + expect( + makeAcpPlanUpdatedEvent({ + stamp, + provider: ProviderDriverKind.make("cursor"), + threadId: "thread-1" as never, + turnId, + payload: { + nativePlanId: "plan-1", + kind: "items", + plan: [{ step: "Inspect state", status: "inProgress" }], + }, + source: "acp.cursor.extension", + method: "cursor/update_todos", + rawPayload: { todos: [] }, + }), + ).toMatchObject({ + type: "turn.plan.updated", + raw: { + method: "cursor/update_todos", + }, + }); + + expect( + makeAcpToolCallEvent({ + stamp, + provider: ProviderDriverKind.make("cursor"), + threadId: "thread-1" as never, + turnId, + toolCall: { + toolCallId: "tool-1", + kind: "execute", + status: "completed", + title: "Terminal", + detail: "bun run test", + data: { command: "bun run test" }, + }, + rawPayload: { sessionId: "session-1" }, + }), + ).toMatchObject({ + type: "item.completed", + payload: { + itemType: "command_execution", + status: "completed", + }, + }); + + expect( + makeAcpContentDeltaEvent({ + stamp, + provider: ProviderDriverKind.make("cursor"), + threadId: "thread-1" as never, + turnId, + itemId: "assistant:session-1:segment:0", + text: "hello", + rawPayload: { sessionId: "session-1" }, + }), + ).toMatchObject({ + type: "content.delta", + itemId: "assistant:session-1:segment:0", + payload: { + delta: "hello", + }, + }); + + expect( + makeAcpAssistantItemEvent({ + stamp, + provider: ProviderDriverKind.make("cursor"), + threadId: "thread-1" as never, + turnId, + itemId: "assistant:session-1:segment:0", + lifecycle: "item.started", + }), + ).toMatchObject({ + type: "item.started", + itemId: "assistant:session-1:segment:0", + payload: { + itemType: "assistant_message", + status: "inProgress", + }, + }); + }); + + it("maps thoughts to the reasoning stream", () => { + expect( + makeAcpContentDeltaEvent({ + stamp: { eventId: "thought-1" as never, createdAt: "2026-09-02T00:00:00.000Z" }, + provider: ProviderDriverKind.make("cursor"), + threadId: "thread-1" as never, + turnId: TurnId.make("turn-1"), + streamKind: "reasoning_text", + text: "Inspect the current implementation first.", + rawPayload: { sessionId: "session-1" }, + }), + ).toMatchObject({ + type: "content.delta", + payload: { + streamKind: "reasoning_text", + delta: "Inspect the current implementation first.", + }, + }); + }); +}); diff --git a/packages/provider-acp/src/server/coreRuntimeEvents.ts b/packages/provider-acp/src/server/coreRuntimeEvents.ts new file mode 100644 index 0000000000000000000000000000000000000000..3218e23cd88c3671f856cbad2a906eed9c4e4ddc --- /dev/null +++ b/packages/provider-acp/src/server/coreRuntimeEvents.ts @@ -0,0 +1,255 @@ +import { + type RuntimeEventRawSource, + RuntimeItemId, + type CanonicalRequestType, + type EventId, + type ProviderApprovalDecision, + type ProviderApprovalOption, + type ProviderDriverKind, + type ProviderRuntimeEvent, + type RuntimeRequestId, + type ThreadId, + type TurnId, +} from "@t3tools/contracts"; + +import { + type AcpPermissionRequest, + type AcpPlanUpdate, + type AcpToolCallState, + canonicalItemTypeFromAcpToolKind, +} from "./runtimeModel.ts"; + +type AcpAdapterRawSource = Extract< + RuntimeEventRawSource, + "acp.jsonrpc" | `acp.${string}.extension` +>; + +interface AcpEventStamp { + readonly eventId: EventId; + readonly createdAt: string; +} + +type AcpCanonicalRequestType = Extract< + CanonicalRequestType, + "exec_command_approval" | "file_read_approval" | "file_change_approval" | "dynamic_tool_call" +>; + +function canonicalRequestTypeFromAcpKind(kind: string | "unknown"): AcpCanonicalRequestType { + switch (kind) { + case "execute": + return "exec_command_approval"; + case "read": + return "file_read_approval"; + case "edit": + case "delete": + case "move": + return "file_change_approval"; + default: + return "dynamic_tool_call"; + } +} + +function runtimeItemStatusFromAcpToolStatus( + status: AcpToolCallState["status"], +): "inProgress" | "completed" | "failed" | undefined { + switch (status) { + case "pending": + case "inProgress": + return "inProgress"; + case "completed": + return "completed"; + case "failed": + return "failed"; + default: + return undefined; + } +} + +export function makeAcpRequestOpenedEvent(input: { + readonly stamp: AcpEventStamp; + readonly provider: ProviderDriverKind; + readonly threadId: ThreadId; + readonly turnId: TurnId | undefined; + readonly requestId: RuntimeRequestId; + readonly permissionRequest: AcpPermissionRequest; + readonly approvalOptions?: ReadonlyArray; + readonly detail: string; + readonly args: unknown; + readonly source: AcpAdapterRawSource; + readonly method: string; + readonly rawPayload: unknown; +}): ProviderRuntimeEvent { + return { + type: "request.opened", + ...input.stamp, + provider: input.provider, + threadId: input.threadId, + turnId: input.turnId, + requestId: input.requestId, + payload: { + requestType: canonicalRequestTypeFromAcpKind(input.permissionRequest.kind), + detail: input.detail, + args: input.args, + ...(input.approvalOptions !== undefined ? { options: input.approvalOptions } : {}), + }, + raw: { + source: input.source, + method: input.method, + payload: input.rawPayload, + }, + }; +} + +export function makeAcpRequestResolvedEvent(input: { + readonly stamp: AcpEventStamp; + readonly provider: ProviderDriverKind; + readonly threadId: ThreadId; + readonly turnId: TurnId | undefined; + readonly requestId: RuntimeRequestId; + readonly permissionRequest: AcpPermissionRequest; + readonly decision: ProviderApprovalDecision; +}): ProviderRuntimeEvent { + return { + type: "request.resolved", + ...input.stamp, + provider: input.provider, + threadId: input.threadId, + turnId: input.turnId, + requestId: input.requestId, + payload: { + requestType: canonicalRequestTypeFromAcpKind(input.permissionRequest.kind), + decision: input.decision, + }, + }; +} + +export function makeAcpPlanUpdatedEvent(input: { + readonly stamp: AcpEventStamp; + readonly provider: ProviderDriverKind; + readonly threadId: ThreadId; + readonly turnId: TurnId | undefined; + readonly payload: AcpPlanUpdate; + readonly source: AcpAdapterRawSource; + readonly method: string; + readonly rawPayload: unknown; +}): ProviderRuntimeEvent { + const payload = + input.payload.kind === "items" + ? { + plan: input.payload.plan, + ...(input.payload.explanation == null ? {} : { explanation: input.payload.explanation }), + } + : input.payload.kind === "removed" + ? { plan: [] } + : { + plan: [ + { + step: + input.payload.kind === "markdown" + ? input.payload.markdown + : input.payload.kind === "file" + ? `Plan file: ${input.payload.uri}` + : `[Unsupported ACP plan content: ${input.payload.contentType}]`, + status: "pending" as const, + }, + ], + }; + return { + type: "turn.plan.updated", + ...input.stamp, + provider: input.provider, + threadId: input.threadId, + turnId: input.turnId, + payload, + raw: { + source: input.source, + method: input.method, + payload: input.rawPayload, + }, + }; +} + +export function makeAcpToolCallEvent(input: { + readonly stamp: AcpEventStamp; + readonly provider: ProviderDriverKind; + readonly threadId: ThreadId; + readonly turnId: TurnId | undefined; + readonly toolCall: AcpToolCallState; + readonly rawPayload: unknown; +}): ProviderRuntimeEvent { + const runtimeStatus = runtimeItemStatusFromAcpToolStatus(input.toolCall.status); + return { + type: + input.toolCall.status === "completed" || input.toolCall.status === "failed" + ? "item.completed" + : "item.updated", + ...input.stamp, + provider: input.provider, + threadId: input.threadId, + turnId: input.turnId, + itemId: RuntimeItemId.make(input.toolCall.toolCallId), + payload: { + itemType: canonicalItemTypeFromAcpToolKind(input.toolCall.kind), + ...(runtimeStatus ? { status: runtimeStatus } : {}), + ...(input.toolCall.title ? { title: input.toolCall.title } : {}), + ...(input.toolCall.detail ? { detail: input.toolCall.detail } : {}), + ...(Object.keys(input.toolCall.data).length > 0 ? { data: input.toolCall.data } : {}), + }, + raw: { + source: "acp.jsonrpc", + method: "session/update", + payload: input.rawPayload, + }, + }; +} + +export function makeAcpAssistantItemEvent(input: { + readonly stamp: AcpEventStamp; + readonly provider: ProviderDriverKind; + readonly threadId: ThreadId; + readonly turnId: TurnId | undefined; + readonly itemId: string; + readonly lifecycle: "item.started" | "item.completed"; +}): ProviderRuntimeEvent { + return { + type: input.lifecycle, + ...input.stamp, + provider: input.provider, + threadId: input.threadId, + turnId: input.turnId, + itemId: RuntimeItemId.make(input.itemId), + payload: { + itemType: "assistant_message", + status: input.lifecycle === "item.completed" ? "completed" : "inProgress", + }, + }; +} + +export function makeAcpContentDeltaEvent(input: { + readonly stamp: AcpEventStamp; + readonly provider: ProviderDriverKind; + readonly threadId: ThreadId; + readonly turnId: TurnId | undefined; + readonly itemId?: string; + readonly streamKind?: "assistant_text" | "reasoning_text"; + readonly text: string; + readonly rawPayload: unknown; +}): ProviderRuntimeEvent { + return { + type: "content.delta", + ...input.stamp, + provider: input.provider, + threadId: input.threadId, + turnId: input.turnId, + ...(input.itemId ? { itemId: RuntimeItemId.make(input.itemId) } : {}), + payload: { + streamKind: input.streamKind ?? "assistant_text", + delta: input.text, + }, + raw: { + source: "acp.jsonrpc", + method: "session/update", + payload: input.rawPayload, + }, + }; +} diff --git a/packages/provider-acp/src/server/mcpOverAcpBridge.test.ts b/packages/provider-acp/src/server/mcpOverAcpBridge.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..c9d7b2bbf2344e030353f3b3bdab65cff6f4ef16 --- /dev/null +++ b/packages/provider-acp/src/server/mcpOverAcpBridge.test.ts @@ -0,0 +1,119 @@ +// The bridge intentionally treats MCP JSON-RPC messages as opaque JSON. +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; + +import { makeAcpMcpOverAcpBridge } from "./mcpOverAcpBridge.ts"; + +describe("AcpMcpOverAcpBridge", () => { + it.effect("forwards authenticated MCP requests and closes the negotiated session", () => + Effect.gen(function* () { + const requests: Array<{ + readonly method: string; + readonly headers: Headers; + readonly body: unknown; + }> = []; + const bridge = yield* makeAcpMcpOverAcpBridge({ + endpoint: "http://127.0.0.1:1/mcp", + authorization: "Bearer bridge-test", + allocateConnectionId: Effect.succeed("connection-1"), + fetchImplementation: async (_url, init) => { + const method = init?.method ?? "GET"; + const headers = new Headers(init?.headers); + const body = init?.body === undefined ? null : JSON.parse(String(init.body)); + requests.push({ method, headers, body }); + if (method === "DELETE") return new Response(null, { status: 204 }); + if (body === null || typeof body !== "object") return new Response(null, { status: 202 }); + const request = body as { readonly id?: unknown; readonly method?: unknown }; + if (request.method === "notifications/initialized") { + return new Response(null, { status: 202 }); + } + return new Response( + JSON.stringify({ + jsonrpc: "2.0", + id: request.id, + result: + request.method === "initialize" + ? { protocolVersion: "2025-06-18", capabilities: {}, serverInfo: {} } + : { tools: [] }, + }), + { + headers: { "content-type": "application/json", "mcp-session-id": "session-42" }, + }, + ); + }, + }); + + const connected = yield* bridge.connect({ serverId: "t3-code" }); + expect(connected).toEqual({ connectionId: "connection-1" }); + expect( + yield* bridge.message({ connectionId: connected.connectionId, method: "initialize" }), + ).toMatchObject({ protocolVersion: "2025-06-18" }); + expect( + yield* bridge.message({ connectionId: connected.connectionId, method: "tools/list" }), + ).toEqual({ tools: [] }); + yield* bridge.notification({ + connectionId: connected.connectionId, + method: "notifications/initialized", + }); + yield* bridge.disconnect({ connectionId: connected.connectionId }); + + expect(requests[0]?.headers.get("authorization")).toBe("Bearer bridge-test"); + expect(requests[1]?.headers.get("mcp-session-id")).toBe("session-42"); + expect(requests[1]?.headers.get("mcp-protocol-version")).toBe("2025-06-18"); + expect(requests.at(-1)?.method).toBe("DELETE"); + expect(requests.at(-1)?.headers.get("mcp-session-id")).toBe("session-42"); + }), + ); + + it.effect("aborts an in-flight HTTP request when the ACP call is interrupted", () => + Effect.gen(function* () { + const started = Promise.withResolvers(); + const bridge = yield* makeAcpMcpOverAcpBridge({ + endpoint: "http://127.0.0.1:1/mcp", + authorization: "Bearer bridge-test", + allocateConnectionId: Effect.succeed("connection-1"), + fetchImplementation: (_url, init) => { + if (!init?.signal) throw new Error("Missing cancellation signal"); + started.resolve(init.signal); + return new Promise(() => {}); + }, + }); + const connected = yield* bridge.connect({ serverId: "t3-code" }); + const request = yield* bridge + .message({ connectionId: connected.connectionId, method: "tools/list" }) + .pipe(Effect.forkChild); + const signal = yield* Effect.promise(() => started.promise); + yield* Fiber.interrupt(request); + expect(signal.aborted).toBe(true); + }).pipe(Effect.scoped), + ); + + it.effect("rejects unknown servers, connections, and oversized messages", () => + Effect.gen(function* () { + const bridge = yield* makeAcpMcpOverAcpBridge({ + endpoint: "http://127.0.0.1:1/mcp", + authorization: "Bearer bridge-test", + allocateConnectionId: Effect.succeed("connection-1"), + fetchImplementation: () => Promise.resolve(new Response(null, { status: 202 })), + }); + + expect(yield* bridge.connect({ serverId: "other" }).pipe(Effect.flip)).toMatchObject({ + _tag: "AcpMcpOverAcpError", + }); + expect( + yield* bridge.message({ connectionId: "missing", method: "tools/list" }).pipe(Effect.flip), + ).toMatchObject({ _tag: "AcpMcpOverAcpError" }); + + const connected = yield* bridge.connect({ serverId: "t3-code" }); + const failure = yield* bridge + .message({ + connectionId: connected.connectionId, + method: "tools/call", + params: { payload: "x".repeat(8 * 1024 * 1024) }, + }) + .pipe(Effect.flip); + expect(failure.message).toContain("exceeds 8 MiB"); + }), + ); +}); diff --git a/packages/provider-acp/src/server/mcpOverAcpBridge.ts b/packages/provider-acp/src/server/mcpOverAcpBridge.ts new file mode 100644 index 0000000000000000000000000000000000000000..831bde8756be3b7e8df95b1171eb34689eb70eda --- /dev/null +++ b/packages/provider-acp/src/server/mcpOverAcpBridge.ts @@ -0,0 +1,241 @@ +// The bridge forwards opaque JSON-RPC values without interpreting their schemas. +// @effect-diagnostics preferSchemaOverJson:off +import * as Effect from "effect/Effect"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import type * as AcpSchema from "effect-acp/compat"; + +import { responsePayloads } from "@t3tools/shared/mcpResponsePayloads"; + +const MAX_CONNECTIONS = 16; +const MAX_MESSAGE_BYTES = 8 * 1024 * 1024; + +interface JsonRpcEnvelope { + readonly id?: unknown; + readonly method?: unknown; + readonly error?: unknown; + readonly result?: unknown; +} + +interface Connection { + readonly mutex: Semaphore.Semaphore; + sessionId: string | null; + protocolVersion: string | null; + nextRequestId: number; +} + +export class AcpMcpOverAcpError extends Error { + readonly _tag = "AcpMcpOverAcpError"; + override readonly name = "AcpMcpOverAcpError"; +} + +const bridgeError = (cause: unknown): AcpMcpOverAcpError => + cause instanceof AcpMcpOverAcpError + ? cause + : new AcpMcpOverAcpError(cause instanceof Error ? cause.message : String(cause), { cause }); + +const asEnvelope = (value: unknown): JsonRpcEnvelope | null => + typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as JsonRpcEnvelope) + : null; + +function protocolVersionOf(payload: unknown): string | null { + const result = asEnvelope(payload)?.result; + if (typeof result !== "object" || result === null || Array.isArray(result)) return null; + const protocolVersion = (result as { readonly protocolVersion?: unknown }).protocolVersion; + return typeof protocolVersion === "string" && protocolVersion.length > 0 ? protocolVersion : null; +} + +function errorMessage(error: unknown): string { + if (typeof error !== "object" || error === null || Array.isArray(error)) { + return "The MCP server returned an error."; + } + const message = (error as { readonly message?: unknown }).message; + return typeof message === "string" && message.length > 0 + ? message + : "The MCP server returned an error."; +} + +export interface AcpMcpOverAcpBridgeOptions { + readonly endpoint: string; + readonly authorization: string; + readonly allocateConnectionId: Effect.Effect; + readonly fetchImplementation?: (url: string, init?: RequestInit) => Promise; +} + +export interface AcpMcpOverAcpBridge { + readonly connect: ( + request: AcpSchema.ConnectMcpRequest, + ) => Effect.Effect; + readonly message: ( + request: AcpSchema.MessageMcpRequest, + ) => Effect.Effect; + readonly notification: ( + request: AcpSchema.MessageMcpNotification, + ) => Effect.Effect; + readonly disconnect: ( + request: AcpSchema.DisconnectMcpRequest, + ) => Effect.Effect; + readonly dispose: Effect.Effect; +} + +/** Bridges the unstable ACP transport to T3's authenticated streamable-HTTP MCP endpoint. */ +export const makeAcpMcpOverAcpBridge = Effect.fn("AcpMcpOverAcpBridge.make")(function* ( + options: AcpMcpOverAcpBridgeOptions, +): Effect.fn.Return { + const fetchImplementation = options.fetchImplementation ?? fetch; + const connections = new Map(); + + const connectionFor = (connectionId: string): Effect.Effect => + Effect.suspend(() => { + const connection = connections.get(connectionId); + return connection === undefined + ? Effect.fail(new AcpMcpOverAcpError(`Unknown MCP-over-ACP connection "${connectionId}".`)) + : Effect.succeed(connection); + }); + + const send = ( + connection: Connection, + message: unknown, + ): Effect.Effect, AcpMcpOverAcpError> => + connection.mutex.withPermits(1)( + Effect.gen(function* () { + const body = JSON.stringify(message); + if (Buffer.byteLength(body) > MAX_MESSAGE_BYTES) { + return yield* Effect.fail(new AcpMcpOverAcpError("MCP-over-ACP message exceeds 8 MiB.")); + } + const response = yield* Effect.tryPromise({ + try: (signal) => + fetchImplementation(options.endpoint, { + method: "POST", + signal, + headers: { + "content-type": "application/json", + accept: "application/json, text/event-stream", + authorization: options.authorization, + ...(connection.sessionId === null + ? {} + : { "mcp-session-id": connection.sessionId }), + ...(connection.protocolVersion === null + ? {} + : { "mcp-protocol-version": connection.protocolVersion }), + }, + body, + }), + catch: bridgeError, + }); + connection.sessionId = response.headers.get("mcp-session-id") ?? connection.sessionId; + if (!response.ok) { + yield* Effect.promise( + () => response.body?.cancel().catch(() => undefined) ?? Promise.resolve(), + ); + return yield* Effect.fail( + new AcpMcpOverAcpError(`T3 Code MCP endpoint responded with HTTP ${response.status}.`), + ); + } + const payloads = [...(yield* Stream.runCollect(responsePayloads(response, bridgeError)))]; + for (const payload of payloads) { + connection.protocolVersion = protocolVersionOf(payload) ?? connection.protocolVersion; + } + return payloads; + }).pipe(Effect.mapError(bridgeError)), + ); + + const disconnect = ( + request: AcpSchema.DisconnectMcpRequest, + ): Effect.Effect => + Effect.gen(function* () { + const connection = yield* connectionFor(request.connectionId); + connections.delete(request.connectionId); + const sessionId = connection.sessionId; + if (sessionId !== null) { + const response = yield* Effect.tryPromise({ + try: (signal) => + fetchImplementation(options.endpoint, { + method: "DELETE", + signal, + headers: { + authorization: options.authorization, + "mcp-session-id": sessionId, + ...(connection.protocolVersion === null + ? {} + : { "mcp-protocol-version": connection.protocolVersion }), + }, + }), + catch: bridgeError, + }); + if (!response.ok && response.status !== 404) { + return yield* Effect.fail( + new AcpMcpOverAcpError( + `T3 Code MCP endpoint rejected disconnect with HTTP ${response.status}.`, + ), + ); + } + yield* Effect.promise( + () => response.body?.cancel().catch(() => undefined) ?? Promise.resolve(), + ); + } + return {}; + }); + + return { + connect: (request) => + Effect.gen(function* () { + if (request.serverId !== "t3-code") { + return yield* Effect.fail( + new AcpMcpOverAcpError(`Unknown ACP MCP server "${request.serverId}".`), + ); + } + if (connections.size >= MAX_CONNECTIONS) { + return yield* Effect.fail(new AcpMcpOverAcpError("Too many MCP-over-ACP connections.")); + } + const connectionId = yield* options.allocateConnectionId; + connections.set(connectionId, { + mutex: yield* Semaphore.make(1), + sessionId: null, + protocolVersion: null, + nextRequestId: 0, + }); + return { connectionId }; + }), + message: (request) => + Effect.gen(function* () { + const connection = yield* connectionFor(request.connectionId); + const id = ++connection.nextRequestId; + const payloads = yield* send(connection, { + jsonrpc: "2.0", + id, + method: request.method, + ...(request.params == null ? {} : { params: request.params }), + }); + const response = payloads.find((payload) => asEnvelope(payload)?.id === id); + const envelope = asEnvelope(response); + if (envelope === null) { + return yield* Effect.fail( + new AcpMcpOverAcpError("MCP server did not return a matching response."), + ); + } + if (envelope.error !== undefined) { + return yield* Effect.fail(new AcpMcpOverAcpError(errorMessage(envelope.error))); + } + return (envelope.result ?? null) as AcpSchema.MessageMcpResponse; + }), + notification: (request) => + Effect.gen(function* () { + const connection = yield* connectionFor(request.connectionId); + yield* send(connection, { + jsonrpc: "2.0", + method: request.method, + ...(request.params == null ? {} : { params: request.params }), + }); + }), + disconnect, + dispose: Effect.suspend(() => + Effect.forEach( + [...connections.keys()], + (connectionId) => disconnect({ connectionId }).pipe(Effect.ignore), + { discard: true }, + ), + ), + }; +}); diff --git a/packages/provider-acp/src/server/nativeLogging.test.ts b/packages/provider-acp/src/server/nativeLogging.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..3872121bc6d7ccc7ea0d78a40e0f4feebb5be0d2 --- /dev/null +++ b/packages/provider-acp/src/server/nativeLogging.test.ts @@ -0,0 +1,306 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { ProviderDriverKind, ThreadId } from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Logger from "effect/Logger"; +import * as Schema from "effect/Schema"; +import * as AcpErrors from "effect-acp/errors"; + +import type * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; +import { makeAcpNativeLoggerFactory } from "./nativeLogging.ts"; + +const nodeServicesIt = it.layer(NodeServices.layer); +const encodeUnknownJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); + +nodeServicesIt("ACP native logging", (it) => { + it.effect("records bounded request and protocol diagnostics without raw payloads", () => + Effect.gen(function* () { + const records: Array = []; + const nativeEventLogger: ProviderEventLoggers.EventNdjsonLogger = { + filePath: "/tmp/provider-native.ndjson", + write: (event) => Effect.sync(() => void records.push(event)), + close: () => Effect.void, + }; + const makeLogger = yield* makeAcpNativeLoggerFactory(); + const logger = makeLogger({ + nativeEventLogger, + provider: ProviderDriverKind.make("cursor"), + threadId: ThreadId.make("thread-1"), + verboseProtocolLogging: true, + }); + const secret = "secret-token-value"; + const requestLogger = logger.requestLogger; + const protocolLogger = logger.protocolLogging?.logger; + assert.exists(requestLogger); + assert.exists(protocolLogger); + if (!requestLogger || !protocolLogger) return; + + yield* requestLogger({ + method: "session/prompt", + payload: { prompt: secret, sessionId: secret }, + status: "failed", + cause: Cause.fail(AcpErrors.AcpRequestError.internalError(secret, { token: secret })), + }); + yield* protocolLogger({ + direction: "incoming", + stage: "raw", + payload: `{"token":"${secret}"}`, + }); + yield* protocolLogger({ + direction: "outgoing", + stage: "decoded", + payload: { + _tag: "Request", + tag: "session/prompt", + payload: { prompt: secret }, + }, + }); + + const serialized = encodeUnknownJson(records); + assert.notInclude(serialized, secret); + assert.include(serialized, '"method":"session/prompt"'); + assert.include(serialized, '"errorTag":"AcpRequestError"'); + assert.include(serialized, '"reasonCount":1'); + assert.include(serialized, '"valueType":"string"'); + assert.include(serialized, '"messageTag":"Request"'); + }), + ); + + it.effect("keeps request diagnostics without enabling full protocol logging", () => + Effect.gen(function* () { + const records: Array = []; + const makeLogger = yield* makeAcpNativeLoggerFactory(); + const logger = makeLogger({ + nativeEventLogger: { + filePath: "/tmp/provider-native.ndjson", + write: (event) => Effect.sync(() => void records.push(event)), + close: () => Effect.void, + }, + provider: ProviderDriverKind.make("grok"), + threadId: ThreadId.make("thread-1"), + }); + + assert.isUndefined(logger.protocolLogging); + const requestLogger = logger.requestLogger; + assert.exists(requestLogger); + if (!requestLogger) return; + yield* requestLogger({ + method: "session/prompt", + payload: {}, + status: "started", + }); + assert.lengthOf(records, 1); + }), + ); + + it.effect("drops transient ACP chunks before formatting verbose protocol logs", () => + Effect.gen(function* () { + const records: Array = []; + const makeLogger = yield* makeAcpNativeLoggerFactory(); + const logger = makeLogger({ + nativeEventLogger: { + filePath: "/tmp/provider-native.ndjson", + write: (event) => Effect.sync(() => void records.push(event)), + close: () => Effect.void, + }, + provider: ProviderDriverKind.make("cursor"), + threadId: ThreadId.make("thread-1"), + verboseProtocolLogging: true, + }); + const protocolLogger = logger.protocolLogging?.logger; + assert.exists(protocolLogger); + if (!protocolLogger) return; + + for (const updateType of ["agent_message_chunk", "agent_thought_chunk"] as const) { + yield* protocolLogger({ + direction: "incoming", + stage: "raw", + payload: `${encodeUnknownJson({ + method: "session/update", + params: { update: { sessionUpdate: updateType } }, + })}\n`, + }); + yield* protocolLogger({ + direction: "incoming", + stage: "decoded", + payload: [ + { + _tag: "Request", + tag: "session/update", + payload: { update: { sessionUpdate: updateType } }, + }, + ], + }); + } + + assert.lengthOf(records, 0); + + yield* protocolLogger({ + direction: "incoming", + stage: "decoded", + payload: [ + { + _tag: "Request", + tag: "session/update", + payload: { update: { sessionUpdate: "tool_call" } }, + }, + ], + }); + assert.lengthOf(records, 1); + }), + ); + + it.effect("keeps mixed and incomplete raw diagnostics", () => + Effect.gen(function* () { + const records: Array = []; + const makeLogger = yield* makeAcpNativeLoggerFactory(); + const logger = makeLogger({ + nativeEventLogger: { + filePath: "/tmp/provider-native.ndjson", + write: (event) => Effect.sync(() => void records.push(event)), + close: () => Effect.void, + }, + provider: ProviderDriverKind.make("cursor"), + threadId: ThreadId.make("thread-1"), + verboseProtocolLogging: true, + }); + const protocolLogger = logger.protocolLogging?.logger; + assert.exists(protocolLogger); + if (!protocolLogger) return; + + const transient = encodeUnknownJson({ + method: "session/update", + params: { update: { sessionUpdate: "agent_message_chunk" } }, + }); + const lifecycle = encodeUnknownJson({ method: "session/new", params: {} }); + + yield* protocolLogger({ + direction: "incoming", + stage: "raw", + payload: `${transient}\n${lifecycle}\n`, + }); + yield* protocolLogger({ + direction: "incoming", + stage: "raw", + payload: transient, + }); + yield* protocolLogger({ + direction: "incoming", + stage: "raw", + payload: `${transient}\n{malformed}\n`, + }); + + assert.lengthOf(records, 3); + }), + ); + + it.effect("filters transient entries from mixed decoded batches", () => + Effect.gen(function* () { + const records: Array = []; + const makeLogger = yield* makeAcpNativeLoggerFactory(); + const logger = makeLogger({ + nativeEventLogger: { + filePath: "/tmp/provider-native.ndjson", + write: (event) => Effect.sync(() => void records.push(event)), + close: () => Effect.void, + }, + provider: ProviderDriverKind.make("grok"), + threadId: ThreadId.make("thread-1"), + verboseProtocolLogging: true, + }); + const protocolLogger = logger.protocolLogging?.logger; + assert.exists(protocolLogger); + if (!protocolLogger) return; + + yield* protocolLogger({ + direction: "incoming", + stage: "decoded", + payload: [ + { + _tag: "Request", + tag: "session/update", + payload: { update: { sessionUpdate: "agent_thought_chunk" } }, + }, + { + _tag: "Request", + tag: "session/new", + payload: {}, + }, + ], + }); + + assert.lengthOf(records, 1); + assert.include(encodeUnknownJson(records), '"itemCount":1'); + }), + ); + + it.effect("logs a structural tag when the native writer defects", () => { + const messages: Array = []; + const logCapture = Logger.make(({ message }) => { + if (Array.isArray(message)) { + messages.push(...message); + } else { + messages.push(message); + } + }); + const secret = "secret-writer-failure"; + + return Effect.gen(function* () { + const makeLogger = yield* makeAcpNativeLoggerFactory(); + const logger = makeLogger({ + nativeEventLogger: { + filePath: "/tmp/provider-native.ndjson", + write: () => Effect.die(new Error(secret)), + close: () => Effect.void, + }, + provider: ProviderDriverKind.make("cursor"), + threadId: ThreadId.make("thread-1"), + }); + const requestLogger = logger.requestLogger; + assert.exists(requestLogger); + if (!requestLogger) return; + + yield* requestLogger({ + method: "session/prompt", + payload: {}, + status: "started", + }); + + const serialized = encodeUnknownJson(messages); + assert.notInclude(serialized, secret); + assert.include(serialized, '"errorTag":"Die"'); + assert.include(serialized, '"reasonCount":1'); + }).pipe(Effect.provide(Logger.layer([logCapture], { mergeWithExisting: false }))); + }); + + it.effect("preserves native writer interruption", () => + Effect.gen(function* () { + const makeLogger = yield* makeAcpNativeLoggerFactory(); + const logger = makeLogger({ + nativeEventLogger: { + filePath: "/tmp/provider-native.ndjson", + write: () => Effect.interrupt, + close: () => Effect.void, + }, + provider: ProviderDriverKind.make("cursor"), + threadId: ThreadId.make("thread-1"), + }); + const requestLogger = logger.requestLogger; + assert.exists(requestLogger); + if (!requestLogger) return; + + const exit = yield* requestLogger({ + method: "session/prompt", + payload: {}, + status: "started", + }).pipe(Effect.exit); + + assert.isTrue(Exit.isFailure(exit)); + if (Exit.isFailure(exit)) { + assert.isTrue(Cause.hasInterruptsOnly(exit.cause)); + } + }), + ); +}); diff --git a/packages/provider-acp/src/server/nativeLogging.ts b/packages/provider-acp/src/server/nativeLogging.ts new file mode 100644 index 0000000000000000000000000000000000000000..b869efe47259670d835443da012c11f1eae4b125 --- /dev/null +++ b/packages/provider-acp/src/server/nativeLogging.ts @@ -0,0 +1,188 @@ +import type { ProviderDriverKind, ThreadId } from "@t3tools/contracts"; +import { causeErrorTag, errorTag } from "@t3tools/shared/observability"; +import * as Cause from "effect/Cause"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import type * as EffectAcpProtocol from "effect-acp/protocol"; + +import type * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; +import { + structuralProtocolMethod, + summarizeNativeProtocolPayload, +} from "@t3tools/provider-core/server/nativeProtocolLogging"; +import type * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; + +const transientProtocolUpdates = new Set(["agent_message_chunk", "agent_thought_chunk"]); + +function structuralMethod(value: string): string { + return value.length <= 128 && /^[A-Za-z][A-Za-z0-9._:/-]*$/.test(value) ? value : "unknown"; +} + +function summarizePayload(payload: unknown): Readonly> { + if (payload === null) return { valueType: "null" }; + if (typeof payload === "string") { + return { valueType: "string", byteLength: new TextEncoder().encode(payload).byteLength }; + } + if (payload instanceof Uint8Array) { + return { valueType: "bytes", byteLength: payload.byteLength }; + } + if (Array.isArray(payload)) { + return { valueType: "array", itemCount: payload.length }; + } + if (typeof payload !== "object") { + return { valueType: typeof payload }; + } + + try { + const record = payload as Record; + return { + valueType: "object", + fieldCount: Object.keys(record).length, + ...(typeof record._tag === "string" ? { messageTag: errorTag(record) } : {}), + ...(typeof record.tag === "string" ? { method: structuralMethod(record.tag) } : {}), + }; + } catch { + return { valueType: "object" }; + } +} + +function formatRequestLogPayload(event: AcpSessionRuntime.AcpSessionRequestLogEvent) { + return { + method: structuralProtocolMethod(event.method), + status: event.status, + request: summarizeNativeProtocolPayload(event.payload), + ...(event.result !== undefined ? { result: summarizeNativeProtocolPayload(event.result) } : {}), + ...(event.cause !== undefined + ? { + errorTag: causeErrorTag(event.cause), + reasonCount: event.cause.reasons.length, + } + : {}), + }; +} + +function formatAcpProtocolLogPayload(event: EffectAcpProtocol.AcpProtocolLogEvent) { + return { + direction: event.direction, + stage: event.stage, + payload: summarizeNativeProtocolPayload(event.payload), + }; +} + +function isTransientProtocolMessage(message: unknown): boolean { + if (typeof message !== "object" || message === null) return false; + const method = Reflect.get(message, "tag") ?? Reflect.get(message, "method"); + if (method !== "session/update") return false; + + const payload = Reflect.get(message, "payload") ?? Reflect.get(message, "params"); + if (typeof payload !== "object" || payload === null) return false; + const update = Reflect.get(payload, "update"); + if (typeof update !== "object" || update === null) return false; + const updateType = Reflect.get(update, "sessionUpdate"); + return typeof updateType === "string" && transientProtocolUpdates.has(updateType); +} + +function rawChunkContainsOnlyTransientMessages(payload: string): boolean { + const lines = payload.split("\n"); + const remainder = lines.pop() ?? ""; + if (remainder.trim().length > 0) return false; + + const messages: Array = []; + for (const line of lines) { + if (line.trim().length === 0) continue; + try { + messages.push(JSON.parse(line)); + } catch { + return false; + } + } + return messages.length > 0 && messages.every(isTransientProtocolMessage); +} + +function filterTransientProtocolLog( + event: EffectAcpProtocol.AcpProtocolLogEvent, +): EffectAcpProtocol.AcpProtocolLogEvent | undefined { + if (event.direction !== "incoming") return event; + + if (event.stage === "raw" && typeof event.payload === "string") { + return rawChunkContainsOnlyTransientMessages(event.payload) ? undefined : event; + } + + if (event.stage !== "decoded") return event; + if (!Array.isArray(event.payload)) { + return isTransientProtocolMessage(event.payload) ? undefined : event; + } + + const payload = event.payload.filter((message) => !isTransientProtocolMessage(message)); + return payload.length === 0 ? undefined : { ...event, payload }; +} + +export const makeAcpNativeLoggerFactory = Effect.fn("makeAcpNativeLoggerFactory")(function* () { + const crypto = yield* Crypto.Crypto; + return (input: { + readonly nativeEventLogger: ProviderEventLoggers.EventNdjsonLogger | undefined; + readonly provider: ProviderDriverKind; + readonly threadId: ThreadId; + readonly verboseProtocolLogging?: boolean; + }): Pick => { + const writeNativeAcpLog = (logInput: { + readonly kind: "request" | "protocol"; + readonly payload: unknown; + }) => + Effect.gen(function* () { + if (!input.nativeEventLogger) return; + const observedAt = DateTime.formatIso(yield* DateTime.now); + yield* input.nativeEventLogger.write( + { + observedAt, + event: { + id: yield* crypto.randomUUIDv4, + kind: logInput.kind, + provider: input.provider, + createdAt: observedAt, + threadId: input.threadId, + payload: logInput.payload, + }, + }, + input.threadId, + ); + }).pipe( + Effect.catchCause((cause) => + Cause.hasInterrupts(cause) + ? Effect.interrupt + : Effect.logWarning("Failed to write native ACP event log.", { + errorTag: causeErrorTag(cause), + reasonCount: cause.reasons.length, + provider: input.provider, + threadId: input.threadId, + }), + ), + ); + + return { + requestLogger: (event: AcpSessionRuntime.AcpSessionRequestLogEvent) => + writeNativeAcpLog({ + kind: "request", + payload: formatRequestLogPayload(event), + }), + ...(input.nativeEventLogger && input.verboseProtocolLogging + ? { + protocolLogging: { + logIncoming: true, + logOutgoing: true, + logger: (event: EffectAcpProtocol.AcpProtocolLogEvent) => { + const filtered = filterTransientProtocolLog(event); + return filtered + ? writeNativeAcpLog({ + kind: "protocol", + payload: formatAcpProtocolLogPayload(filtered), + }) + : Effect.void; + }, + } satisfies NonNullable, + } + : {}), + }; + }; +}); diff --git a/packages/provider-acp/src/server/runtimeModel.test.ts b/packages/provider-acp/src/server/runtimeModel.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..dab23a53fead6b190474b29172df694ed3f4a1b4 --- /dev/null +++ b/packages/provider-acp/src/server/runtimeModel.test.ts @@ -0,0 +1,1724 @@ +import { describe, expect, it } from "vite-plus/test"; + +import type * as EffectAcpSchema from "effect-acp/compat"; + +import { + decideToolCallUpdateEmission, + applyAcpAgentTerminalUpdate, + acpContentBlockDisplayText, + embeddedTerminalIdsFromSessionUpdate, + extractMcpToolCallIdentity, + extractModelConfigId, + mergeToolCallState, + parsePermissionRequest, + parseSessionModeState, + parseSessionUpdateEvent, + sessionModelStateFromInitialize, + sessionUpdateCountsAsLoadReplayActivity, + sessionUpdateIsReplay, + syntheticLoadSessionResponseFromInitialize, + toolCallProgressLength, + type AcpToolCallState, +} from "./runtimeModel.ts"; + +describe("AcpRuntimeModel", () => { + it("parses session mode state from typed ACP session setup responses", () => { + const modeState = parseSessionModeState({ + sessionId: "session-1", + modes: { + currentModeId: " code ", + availableModes: [ + { id: " ask ", name: " Ask ", description: " Request approval " }, + { id: " code ", name: " Code " }, + ], + }, + configOptions: [], + } satisfies EffectAcpSchema.NewSessionResponse); + + expect(modeState).toEqual({ + currentModeId: "code", + availableModes: [ + { id: "ask", name: "Ask", description: "Request approval" }, + { id: "code", name: "Code" }, + ], + }); + }); + + it("extracts the model config id from typed ACP config options", () => { + const modelConfigId = extractModelConfigId({ + sessionId: "session-1", + configOptions: [ + { + id: "approval", + name: "Approval Mode", + category: "permission", + type: "select", + currentValue: "ask", + options: [{ value: "ask", name: "Ask" }], + }, + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: "default", + options: [{ value: "default", name: "Auto" }], + }, + ], + } satisfies EffectAcpSchema.NewSessionResponse); + + expect(modelConfigId).toBe("model"); + }); + + it("detects Grok session replay updates from _meta.isReplay", () => { + expect( + sessionUpdateIsReplay({ + _meta: { isReplay: true }, + sessionId: "session-1", + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "replayed" }, + }, + } satisfies EffectAcpSchema.SessionNotification), + ).toBe(true); + expect( + sessionUpdateIsReplay({ + sessionId: "session-1", + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "live" }, + }, + } satisfies EffectAcpSchema.SessionNotification), + ).toBe(false); + }); + + it("ignores Grok keepalive chunks when tracking session/load replay activity", () => { + expect( + sessionUpdateCountsAsLoadReplayActivity({ + sessionId: "session-1", + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "" }, + }, + } satisfies EffectAcpSchema.SessionNotification), + ).toBe(false); + expect( + sessionUpdateCountsAsLoadReplayActivity({ + _meta: { isReplay: true }, + sessionId: "session-1", + update: { + sessionUpdate: "tool_call", + toolCallId: "replay-tool", + title: "Replay", + kind: "search", + status: "completed", + }, + } satisfies EffectAcpSchema.SessionNotification), + ).toBe(true); + }); + + it("ignores load-replay activity from other sessions while a load gate is active", () => { + expect( + sessionUpdateCountsAsLoadReplayActivity( + { + sessionId: "session-other", + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "unrelated" }, + }, + } satisfies EffectAcpSchema.SessionNotification, + "session-loading", + ), + ).toBe(false); + expect( + sessionUpdateCountsAsLoadReplayActivity( + { + sessionId: "session-loading", + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "replay" }, + }, + } satisfies EffectAcpSchema.SessionNotification, + "session-loading", + ), + ).toBe(true); + }); + + it("counts mode/config/session/usage updates as load-replay activity", () => { + expect( + sessionUpdateCountsAsLoadReplayActivity({ + sessionId: "session-1", + update: { + sessionUpdate: "current_mode_update", + currentModeId: "code", + }, + } satisfies EffectAcpSchema.SessionNotification), + ).toBe(true); + expect( + sessionUpdateCountsAsLoadReplayActivity({ + sessionId: "session-1", + update: { + sessionUpdate: "config_option_update", + configOptions: [], + }, + } satisfies EffectAcpSchema.SessionNotification), + ).toBe(true); + expect( + sessionUpdateCountsAsLoadReplayActivity({ + sessionId: "session-1", + update: { + sessionUpdate: "session_info_update", + title: "Restored", + }, + } satisfies EffectAcpSchema.SessionNotification), + ).toBe(true); + expect( + sessionUpdateCountsAsLoadReplayActivity({ + sessionId: "session-1", + update: { + sessionUpdate: "usage_update", + used: 1, + size: 10, + }, + } satisfies EffectAcpSchema.SessionNotification), + ).toBe(true); + }); + + it("ignores malformed initialize mode state in synthetic load responses", () => { + const response = syntheticLoadSessionResponseFromInitialize({ + protocolVersion: 1, + _meta: { + modeState: { + currentModeId: "code", + availableModes: [{ id: "code", name: 12 }], + }, + }, + } as EffectAcpSchema.InitializeResponse); + + expect(response.modes).toBeUndefined(); + expect(response._meta).toMatchObject({ t3SessionLoadReady: "replay_idle" }); + }); + + it("builds a synthetic load response with initialize mode state", () => { + const response = syntheticLoadSessionResponseFromInitialize({ + protocolVersion: 1, + _meta: { + modeState: { + currentModeId: "code", + availableModes: [ + { id: "ask", name: "Ask" }, + { id: "code", name: "Code" }, + ], + }, + }, + } satisfies EffectAcpSchema.InitializeResponse); + + expect(response.modes?.currentModeId).toBe("code"); + expect(response.modes?.availableModes).toHaveLength(2); + }); + + it("keeps initialize mode state whose modes have null descriptions", () => { + const response = syntheticLoadSessionResponseFromInitialize({ + protocolVersion: 1, + _meta: { + modeState: { + currentModeId: "code", + availableModes: [ + { id: "ask", name: "Ask", description: null }, + { id: "code", name: "Code", description: "Edit files", _meta: null }, + ], + _meta: null, + }, + }, + } satisfies EffectAcpSchema.InitializeResponse); + + expect(parseSessionModeState(response)).toEqual({ + currentModeId: "code", + availableModes: [ + { id: "ask", name: "Ask" }, + { id: "code", name: "Code", description: "Edit files" }, + ], + }); + }); + + it("reads initialize model state and ignores malformed model state", () => { + const modelState = { + currentModelId: "grok-4.7", + availableModels: [ + { + modelId: "grok-4.7", + name: "Grok 4.7", + description: null, + _meta: { reasoningEfforts: [{ id: "high", default: true }] }, + }, + { modelId: "grok-4.6", name: "Grok 4.6" }, + ], + }; + + expect(sessionModelStateFromInitialize({ protocolVersion: 1, _meta: { modelState } })).toEqual( + modelState, + ); + expect( + sessionModelStateFromInitialize({ + protocolVersion: 1, + _meta: { + modelState: { currentModelId: "grok-4.7", availableModels: [{ modelId: "grok-4.7" }] }, + }, + }), + ).toBeUndefined(); + expect( + sessionModelStateFromInitialize({ + protocolVersion: 1, + _meta: { modelState: { ...modelState, _meta: "not an object" } }, + }), + ).toBeUndefined(); + }); + + it("projects typed ACP tool call updates into runtime events", () => { + const created = parseSessionUpdateEvent({ + sessionId: "session-1", + update: { + sessionUpdate: "tool_call", + toolCallId: "tool-1", + title: "Terminal", + kind: "execute", + status: "pending", + rawInput: { + executable: "bun", + args: ["run", "typecheck"], + }, + content: [ + { + type: "content", + content: { + type: "text", + text: "Running checks", + }, + }, + ], + }, + } satisfies EffectAcpSchema.SessionNotification); + + expect(created.events).toEqual([ + { + _tag: "ToolCallUpdated", + toolCall: { + toolCallId: "tool-1", + kind: "execute", + title: "Ran command", + status: "pending", + command: "bun run typecheck", + detail: "bun run typecheck", + data: { + toolCallId: "tool-1", + kind: "execute", + title: "Terminal", + command: "bun run typecheck", + rawInput: { + executable: "bun", + args: ["run", "typecheck"], + }, + content: [ + { + type: "content", + content: { + type: "text", + text: "Running checks", + }, + }, + ], + }, + }, + rawPayload: { + sessionId: "session-1", + update: { + sessionUpdate: "tool_call", + toolCallId: "tool-1", + title: "Terminal", + kind: "execute", + status: "pending", + rawInput: { + executable: "bun", + args: ["run", "typecheck"], + }, + content: [ + { + type: "content", + content: { + type: "text", + text: "Running checks", + }, + }, + ], + }, + }, + }, + ]); + + const updated = parseSessionUpdateEvent({ + sessionId: "session-1", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "tool-1", + status: "completed", + rawOutput: { exitCode: 0 }, + }, + } satisfies EffectAcpSchema.SessionNotification); + + expect(updated.events).toHaveLength(1); + expect(updated.events[0]?._tag).toBe("ToolCallUpdated"); + const createdEvent = created.events[0]; + const updatedEvent = updated.events[0]; + if (createdEvent?._tag === "ToolCallUpdated" && updatedEvent?._tag === "ToolCallUpdated") { + expect(mergeToolCallState(createdEvent.toolCall, updatedEvent.toolCall)).toMatchObject({ + toolCallId: "tool-1", + status: "completed", + title: "Ran command", + detail: "bun run typecheck", + command: "bun run typecheck", + }); + } + }); + + it("clears prior ACP locations when an update explicitly replaces them", () => { + const toolCall = (locations?: EffectAcpSchema.ToolCallLocation[] | null) => { + const parsed = parseSessionUpdateEvent({ + sessionId: "session-1", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "read-1", + kind: "read", + ...(locations === undefined ? {} : { locations }), + }, + } satisfies EffectAcpSchema.SessionNotification); + const event = parsed.events.find((candidate) => candidate._tag === "ToolCallUpdated"); + if (!event || event._tag !== "ToolCallUpdated") { + throw new Error("expected a tool call update"); + } + return event.toolCall; + }; + + const created = toolCall([{ path: "README" }]); + expect(mergeToolCallState(created, toolCall()).data.locations).toEqual([{ path: "README" }]); + const cleared = mergeToolCallState(created, toolCall([])); + expect(cleared.data.locations).toEqual([]); + expect(mergeToolCallState(created, toolCall(null)).data.locations).toEqual([]); + expect( + decideToolCallUpdateEmission({ + previous: created, + next: cleared, + lastEmittedDetailLength: 0, + skippedSinceEmit: 0, + }).emit, + ).toBe(true); + }); + + it("trims padded current mode updates before emitting a mode change", () => { + const result = parseSessionUpdateEvent({ + sessionId: "session-1", + update: { + sessionUpdate: "current_mode_update", + currentModeId: " code ", + }, + } satisfies EffectAcpSchema.SessionNotification); + + expect(result.modeId).toBe("code"); + expect(result.events).toEqual([ + { + _tag: "ModeChanged", + modeId: "code", + }, + ]); + }); + + it("projects typed ACP plan and content updates", () => { + const planResult = parseSessionUpdateEvent({ + sessionId: "session-1", + update: { + sessionUpdate: "plan", + entries: [ + { content: " Inspect state ", priority: "high", status: "completed" }, + { content: "", priority: "medium", status: "in_progress" }, + ], + }, + } satisfies EffectAcpSchema.SessionNotification); + + expect(planResult.events).toEqual([ + { + _tag: "PlanUpdated", + payload: { + nativePlanId: "legacy", + kind: "items", + plan: [ + { step: "Inspect state", status: "completed" }, + { step: "Step 2", status: "inProgress" }, + ], + }, + rawPayload: { + sessionId: "session-1", + update: { + sessionUpdate: "plan", + entries: [ + { content: " Inspect state ", priority: "high", status: "completed" }, + { content: "", priority: "medium", status: "in_progress" }, + ], + }, + }, + }, + ]); + + const contentResult = parseSessionUpdateEvent({ + sessionId: "session-1", + update: { + sessionUpdate: "agent_message_chunk", + content: { + type: "text", + text: "hello from acp", + }, + }, + } satisfies EffectAcpSchema.SessionNotification); + + expect(contentResult.events).toEqual([ + { + _tag: "ContentDelta", + text: "hello from acp", + rawPayload: { + sessionId: "session-1", + update: { + sessionUpdate: "agent_message_chunk", + content: { + type: "text", + text: "hello from acp", + }, + }, + }, + }, + ]); + }); + + it("keeps thought chunks separate from assistant text", () => { + const notification = { + sessionId: "session-1", + update: { + sessionUpdate: "agent_thought_chunk", + content: { type: "text", text: "Inspect the current implementation first." }, + }, + } satisfies EffectAcpSchema.SessionNotification; + + expect(parseSessionUpdateEvent(notification).events).toEqual([ + { + _tag: "ThoughtDelta", + text: "Inspect the current implementation first.", + rawPayload: notification, + }, + ]); + }); + + it("preserves native command inputs and empty command lists", () => { + const availableCommands = [ + { name: "plan", description: "Plan a task", input: { type: "text", hint: "task" } }, + { name: "logout", description: "Sign out" }, + ] satisfies ReadonlyArray; + + for (const commands of [availableCommands, []]) { + const notification = { + sessionId: "session-1", + update: { sessionUpdate: "available_commands_update", availableCommands: commands }, + } satisfies EffectAcpSchema.SessionNotification; + expect(parseSessionUpdateEvent(notification).events).toEqual([ + { + _tag: "AvailableCommandsUpdated", + availableCommands: commands, + rawPayload: notification, + }, + ]); + } + }); + + it("keeps permission request parsing compatible with loose extension payloads", () => { + const request = parsePermissionRequest({ + sessionId: "session-1", + options: [ + { + optionId: "allow-once", + name: "Allow once", + kind: "allow_once", + }, + ], + toolCall: { + toolCallId: "tool-1", + title: "`cat package.json`", + kind: "execute", + status: "pending", + content: [ + { + type: "content", + content: { + type: "text", + text: "Not in allowlist", + }, + }, + ], + }, + }); + + expect(request).toMatchObject({ + kind: "execute", + detail: "cat package.json", + toolCall: { + toolCallId: "tool-1", + kind: "execute", + status: "pending", + command: "cat package.json", + }, + }); + }); + + it("bounds an oversized cumulative tool_call_update content buffer to a tail window", () => { + // Mirrors Grok's ACP CLI resending the ENTIRE accumulated terminal output on every + // tool_call_update notification instead of a delta (see upstream #6556). + const hugeText = Array.from({ length: 2_000 }, (_, i) => `line ${i}: ${"x".repeat(50)}`).join( + "\n", + ); + expect(hugeText.length).toBeGreaterThan(60_000); + + const result = parseSessionUpdateEvent({ + sessionId: "session-1", + update: { + // Real ACP `tool_call_update` deltas typically omit `title` (already established by + // the initial `tool_call`); that is also the shape that surfaces raw content as detail. + sessionUpdate: "tool_call_update", + toolCallId: "tool-1", + kind: "other", + status: "in_progress", + content: [{ type: "content", content: { type: "text", text: hugeText } }], + }, + } satisfies EffectAcpSchema.SessionNotification); + + expect(result.events).toHaveLength(1); + const event = result.events[0]; + if (event?._tag !== "ToolCallUpdated") { + throw new Error("expected a ToolCallUpdated event"); + } + + expect(event.toolCall.detail).toBeDefined(); + const detail = event.toolCall.detail!; + // 8000 chars of tail plus the truncation marker, regardless of input size. + expect(detail.length).toBe(8_028); + expect(detail.startsWith("[Earlier output truncated]")).toBe(true); + expect(detail.endsWith(hugeText.slice(-100))).toBe(true); + + // The raw payload threaded through for logging/persistence must not smuggle the full + // cumulative buffer back in either. + const rawUpdate = ( + event.rawPayload as { + readonly update: { + readonly content: ReadonlyArray<{ readonly content: { text: string } }>; + }; + } + ).update; + expect(rawUpdate.content[0]?.content.text.length).toBeLessThan(8_100); + expect(JSON.stringify(event).length).toBeLessThan(hugeText.length); + }); + + it("coalesces 1000 rapid cumulative tool_call_update notifications for a redrawing progress bar", () => { + let previous: AcpToolCallState | undefined; + let lastEmittedDetailLength: number | undefined; + let skippedSinceEmit = 0; + let emittedCount = 0; + let emittedBytes = 0; + let notificationBytes = 0; + let largestEmittedEventBytes = 0; + let finalDetail: string | undefined; + let cumulativeBuffer = ""; + + for (let i = 0; i < 1_000; i += 1) { + // Grok resends the FULL accumulated buffer, not a delta, on every redraw. + cumulativeBuffer += `frame ${i}: ${"#".repeat(50)}\n`; + const isLast = i === 999; + + const notification = { + sessionId: "session-1", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "tool-1", + kind: "other", + status: isLast ? "completed" : "in_progress", + content: [{ type: "content", content: { type: "text", text: cumulativeBuffer } }], + }, + } satisfies EffectAcpSchema.SessionNotification; + notificationBytes += JSON.stringify(notification).length; + + const { events } = parseSessionUpdateEvent(notification); + + const event = events[0]; + if (event?._tag !== "ToolCallUpdated") { + continue; + } + + const merged = mergeToolCallState(previous, event.toolCall); + const decision = decideToolCallUpdateEmission({ + previous, + next: merged, + lastEmittedDetailLength, + skippedSinceEmit, + }); + previous = merged; + skippedSinceEmit = decision.skippedSinceEmit; + if (decision.emit) { + emittedCount += 1; + const eventBytes = JSON.stringify({ + toolCall: merged, + rawPayload: event.rawPayload, + }).length; + emittedBytes += eventBytes; + largestEmittedEventBytes = Math.max(largestEmittedEventBytes, eventBytes); + lastEmittedDetailLength = merged.detail?.length; + finalDetail = merged.detail; + } + } + + // The flood as the CLI sends it: 1000 cumulative redraws, ~31.6 MB of JSON. + expect(notificationBytes).toBeGreaterThan(31_000_000); + + // 1000 cumulative redraws collapse into a fixed, small number of runtime events... + expect(emittedCount).toBe(114); + // ...each individually bounded, no matter how long the tool call runs... + expect(largestEmittedEventBytes).toBeLessThan(25_000); + // ...so the whole flooding tool call costs ~2.5 MB of runtime events instead of ~31.6 MB. + expect(emittedBytes).toBeLessThan(2_600_000); + // ...while the FINAL state (forced by the completed status) still reflects the real, + // latest output rather than a stale coalesced value. + expect(finalDetail).toBeDefined(); + expect(finalDetail?.endsWith(`frame 999: ${"#".repeat(50)}`)).toBe(true); + }); + + it("keeps non-text tool call content entries in order when bounding oversized text", () => { + const hugePrefix = "x".repeat(25_000); + const hugeTail = "y".repeat(25_000); + const { events } = parseSessionUpdateEvent({ + sessionId: "session-1", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "tool-1", + kind: "edit", + status: "in_progress", + content: [ + { type: "content", content: { type: "text", text: hugePrefix } }, + { type: "diff", path: "/repo/file.ts", oldText: "before", newText: "after" }, + { type: "content", content: { type: "text", text: hugeTail } }, + { type: "diff", path: "/repo/other.ts", oldText: "old", newText: "new" }, + { type: "content", content: { type: "text", text: " " } }, + ], + }, + } satisfies EffectAcpSchema.SessionNotification); + + const event = events[0]; + if (event?._tag !== "ToolCallUpdated") { + throw new Error("expected a ToolCallUpdated event"); + } + const content = event.toolCall.data.content as ReadonlyArray; + expect(content).toHaveLength(3); + expect(content[0]).toEqual({ + type: "diff", + path: "/repo/file.ts", + oldText: "before", + newText: "after", + }); + const lastEntry = content[1]; + if (lastEntry?.type !== "content" || lastEntry.content.type !== "text") { + throw new Error("expected a bounded text entry"); + } + expect(lastEntry.content.text.length).toBeLessThan(8_100); + expect(lastEntry.content.text.endsWith(hugeTail.slice(-100))).toBe(true); + expect(content[2]).toEqual({ + type: "diff", + path: "/repo/other.ts", + oldText: "old", + newText: "new", + }); + }); + + it("keeps a retained tail on the original text entries around non-text content", () => { + const prefix = "a".repeat(4_000); + const suffix = "b".repeat(5_000); + const { events } = parseSessionUpdateEvent({ + sessionId: "session-1", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "tool-1", + kind: "edit", + status: "in_progress", + content: [ + { type: "content", content: { type: "text", text: prefix } }, + { type: "diff", path: "/repo/file.ts", oldText: "before", newText: "after" }, + { type: "content", content: { type: "text", text: suffix } }, + ], + }, + } satisfies EffectAcpSchema.SessionNotification); + + const event = events[0]; + if (event?._tag !== "ToolCallUpdated") { + throw new Error("expected a ToolCallUpdated event"); + } + const content = event.toolCall.data.content as ReadonlyArray; + expect(content).toHaveLength(3); + const firstText = content[0]; + if (firstText?.type !== "content" || firstText.content.type !== "text") { + throw new Error("expected a bounded prefix text entry"); + } + expect(firstText.content.text.startsWith("[Earlier output truncated]")).toBe(true); + expect(firstText.content.text.endsWith("a".repeat(100))).toBe(true); + expect(content[1]).toEqual({ + type: "diff", + path: "/repo/file.ts", + oldText: "before", + newText: "after", + }); + expect(content[2]).toEqual({ + type: "content", + content: { type: "text", text: suffix }, + }); + }); + + it("bounds oversized whitespace-only tool call content that has no trimmed text", () => { + // Whitespace-only entries are skipped when extracting display text (`chunks.length === 0`) + // and used to be returned unchanged, which let a redrawing terminal persist unbounded + // buffers on `toolCall.data.content` and `rawPayload`. + const hugeWhitespace = " \n\t".repeat(30_000); + expect(hugeWhitespace.length).toBeGreaterThan(60_000); + + const { events } = parseSessionUpdateEvent({ + sessionId: "session-1", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "tool-1", + kind: "other", + status: "in_progress", + content: [{ type: "content", content: { type: "text", text: hugeWhitespace } }], + }, + } satisfies EffectAcpSchema.SessionNotification); + + const event = events[0]; + if (event?._tag !== "ToolCallUpdated") { + throw new Error("expected a ToolCallUpdated event"); + } + + expect(event.toolCall.detail).toBeUndefined(); + const content = event.toolCall.data.content as ReadonlyArray; + const textEntry = content[0]; + if (textEntry?.type !== "content" || textEntry.content.type !== "text") { + throw new Error("expected a bounded text entry"); + } + expect(textEntry.content.text.length).toBeLessThan(8_100); + expect(textEntry.content.text.startsWith("[Earlier output truncated]")).toBe(true); + + const rawUpdate = ( + event.rawPayload as { + readonly update: { + readonly content: ReadonlyArray<{ readonly content: { text: string } }>; + }; + } + ).update; + expect(rawUpdate.content[0]?.content.text.length).toBeLessThan(8_100); + expect(JSON.stringify(event).length).toBeLessThan(hugeWhitespace.length); + }); + + it("bounds oversized whitespace-padded text entries even when trimmed content fits", () => { + const padded = `${" ".repeat(40_000)}ok${" ".repeat(40_000)}`; + expect(padded.length).toBeGreaterThan(60_000); + + const { events } = parseSessionUpdateEvent({ + sessionId: "session-1", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "tool-1", + kind: "other", + status: "in_progress", + content: [{ type: "content", content: { type: "text", text: padded } }], + }, + } satisfies EffectAcpSchema.SessionNotification); + + const event = events[0]; + if (event?._tag !== "ToolCallUpdated") { + throw new Error("expected a ToolCallUpdated event"); + } + + expect(event.toolCall.detail).toBe("ok"); + const content = event.toolCall.data.content as ReadonlyArray; + const textEntry = content[0]; + if (textEntry?.type !== "content" || textEntry.content.type !== "text") { + throw new Error("expected a bounded text entry"); + } + expect(textEntry.content.text).toBe("ok"); + + const rawUpdate = ( + event.rawPayload as { + readonly update: { + readonly content: ReadonlyArray<{ readonly content: { text: string } }>; + }; + } + ).update; + expect(rawUpdate.content[0]?.content.text).toBe("ok"); + expect(JSON.stringify(event).length).toBeLessThan(padded.length); + }); + + describe("decideToolCallUpdateEmission", () => { + const toolCall = (detail: string | undefined, status?: AcpToolCallState["status"]) => + ({ + toolCallId: "tool-1", + title: "Grok Tool", + ...(status ? { status } : {}), + ...(detail ? { detail } : {}), + data: {}, + }) satisfies AcpToolCallState; + + it("emits the first in-progress tool_call even when it has no detail", () => { + expect( + decideToolCallUpdateEmission({ + previous: undefined, + next: { toolCallId: "tool-1", title: "Grok Tool", status: "pending", data: {} }, + lastEmittedDetailLength: undefined, + skippedSinceEmit: 0, + }), + ).toEqual({ emit: true, skippedSinceEmit: 0 }); + }); + + it("always emits terminal (completed/failed) status updates regardless of growth", () => { + expect( + decideToolCallUpdateEmission({ + previous: toolCall("same", "inProgress"), + next: toolCall("same", "completed"), + lastEmittedDetailLength: 4, + skippedSinceEmit: 0, + }), + ).toEqual({ emit: true, skippedSinceEmit: 0 }); + + expect( + decideToolCallUpdateEmission({ + previous: toolCall("same", "inProgress"), + next: toolCall("same", "failed"), + lastEmittedDetailLength: 4, + skippedSinceEmit: 3, + }), + ).toEqual({ emit: true, skippedSinceEmit: 0 }); + }); + + it("skips updates whose bounded detail did not change", () => { + const previous = toolCall("frame 1", "inProgress"); + expect( + decideToolCallUpdateEmission({ + previous, + next: previous, + lastEmittedDetailLength: 7, + skippedSinceEmit: 0, + }), + ).toEqual({ emit: false, skippedSinceEmit: 0 }); + }); + + it("coalesces command-tool updates whose content grew while detail stayed the command", () => { + const commandCall = (stdout: string): AcpToolCallState => ({ + toolCallId: "tool-1", + title: "Ran command", + status: "inProgress", + command: "ls", + detail: "ls", + data: { + command: "ls", + content: [{ type: "content", content: { type: "text", text: stdout } }], + }, + }); + + let previous: AcpToolCallState | undefined; + let lastEmittedDetailLength: number | undefined; + let skippedSinceEmit = 0; + const emissions: Array = []; + + for (let i = 1; i <= 12; i += 1) { + const next = commandCall("x".repeat(i)); + const decision = decideToolCallUpdateEmission({ + previous, + next, + lastEmittedDetailLength, + skippedSinceEmit, + }); + emissions.push(decision.emit); + skippedSinceEmit = decision.skippedSinceEmit; + if (decision.emit) { + lastEmittedDetailLength = toolCallProgressLength(next); + } + previous = next; + } + + const emittedIndices = emissions.flatMap((emitted, index) => (emitted ? [index + 1] : [])); + expect(emittedIndices).toEqual([1, 11]); + }); + + it("emits pending to inProgress status changes even when detail and output are unchanged", () => { + expect( + decideToolCallUpdateEmission({ + previous: toolCall("same", "pending"), + next: toolCall("same", "inProgress"), + lastEmittedDetailLength: 4, + skippedSinceEmit: 0, + }), + ).toEqual({ emit: true, skippedSinceEmit: 0 }); + }); + + it("emits immediately when the title changes, even with no growth", () => { + const decision = decideToolCallUpdateEmission({ + previous: { toolCallId: "tool-1", title: "Reading file", detail: "x", data: {} }, + next: { toolCallId: "tool-1", title: "Ran command", detail: "x", data: {} }, + lastEmittedDetailLength: 1, + skippedSinceEmit: 0, + }); + expect(decision).toEqual({ emit: true, skippedSinceEmit: 0 }); + }); + + it("coalesces small deltas but forces an emission after the coalesce limit", () => { + let lastEmittedDetailLength: number | undefined = 0; + let skippedSinceEmit = 0; + const emissions: Array = []; + let previous: AcpToolCallState | undefined; + + for (let i = 1; i <= 12; i += 1) { + // Grows by 1 char per update — well under the 256-char growth threshold, so this + // exercises the coalesce-count fallback rather than the growth-based trigger. + const next = toolCall("x".repeat(i), "inProgress"); + const decision = decideToolCallUpdateEmission({ + previous, + next, + lastEmittedDetailLength, + skippedSinceEmit, + }); + emissions.push(decision.emit); + skippedSinceEmit = decision.skippedSinceEmit; + if (decision.emit) { + lastEmittedDetailLength = next.detail?.length; + } + previous = next; + } + + // First update always emits (no previous state yet); after that, small per-update + // growth should be coalesced until the coalesce limit forces a periodic emission. + const emittedIndices = emissions.flatMap((emitted, index) => (emitted ? [index + 1] : [])); + expect(emittedIndices).toEqual([1, 11]); + }); + + it("retains the latest replacement snapshot when equal-length updates are coalesced", () => { + let previous: AcpToolCallState = toolCall("frame-a", "inProgress"); + const lastEmittedDetailLength = previous.detail?.length; + let skippedSinceEmit = 0; + + for (const detail of ["frame-b", "frame-c"]) { + const next = mergeToolCallState(previous, toolCall(detail, "inProgress")); + const decision = decideToolCallUpdateEmission({ + previous, + next, + lastEmittedDetailLength, + skippedSinceEmit, + }); + expect(decision.emit).toBe(false); + skippedSinceEmit = decision.skippedSinceEmit; + previous = next; + } + + const completed = mergeToolCallState(previous, toolCall(undefined, "completed")); + expect(completed.detail).toBe("frame-c"); + expect( + decideToolCallUpdateEmission({ + previous, + next: completed, + lastEmittedDetailLength, + skippedSinceEmit, + }), + ).toEqual({ emit: true, skippedSinceEmit: 0 }); + }); + }); + + it("preserves message identity, usage, metadata, and non-text output", () => { + const message = parseSessionUpdateEvent({ + sessionId: "session-1", + update: { + sessionUpdate: "agent_message_chunk", + messageId: "message-1", + content: { + type: "resource_link", + name: "report.txt", + title: "Report", + description: "Generated artifact", + uri: "file:///workspace/report.txt", + }, + }, + } satisfies EffectAcpSchema.SessionNotification); + const usage = parseSessionUpdateEvent({ + sessionId: "session-1", + update: { + sessionUpdate: "usage_update", + used: 2_500, + size: 10_000, + cost: { amount: 0.42, currency: "USD" }, + }, + } satisfies EffectAcpSchema.SessionNotification); + const metadata = parseSessionUpdateEvent({ + sessionId: "session-1", + update: { + sessionUpdate: "session_info_update", + title: " Native session ", + updatedAt: " 2026-08-23T00:00:00Z ", + }, + } satisfies EffectAcpSchema.SessionNotification); + + expect(message.events[0]).toMatchObject({ + _tag: "ContentDelta", + messageId: "message-1", + text: "Report: Generated artifact\nfile:///workspace/report.txt", + }); + expect(usage.events[0]).toMatchObject({ + _tag: "UsageUpdated", + usage: { + usedTokens: 2_500, + maxTokens: 10_000, + cost: { amount: 0.42, currency: "USD" }, + }, + }); + expect(metadata.events[0]).toMatchObject({ + _tag: "SessionInfoUpdated", + metadata: { + title: "Native session", + updatedAt: "2026-08-23T00:00:00Z", + }, + }); + }); + + it("projects ACP v2 plan replacement and removal updates", () => { + const replaced = parseSessionUpdateEvent({ + sessionId: "session-1", + update: { + sessionUpdate: "plan_update", + plan: { + type: "items", + planId: "plan-1", + entries: [ + { content: " Inspect schema ", priority: "high", status: "completed" }, + { content: "Ship integration", priority: "high", status: "in_progress" }, + ], + }, + }, + } satisfies EffectAcpSchema.SessionNotification); + const removed = parseSessionUpdateEvent({ + sessionId: "session-1", + update: { sessionUpdate: "plan_removed", planId: "plan-1" }, + } satisfies EffectAcpSchema.SessionNotification); + + expect(replaced.events[0]).toMatchObject({ + _tag: "PlanUpdated", + payload: { + nativePlanId: "plan-1", + kind: "items", + plan: [ + { step: "Inspect schema", status: "completed" }, + { step: "Ship integration", status: "inProgress" }, + ], + }, + }); + expect(removed.events[0]).toMatchObject({ + _tag: "PlanUpdated", + payload: { nativePlanId: "plan-1", kind: "removed" }, + }); + }); + + it("preserves markdown, file, and future ACP plan variants by plan ID", () => { + const updates = [ + { + sessionId: "session-1", + update: { + sessionUpdate: "plan_update", + plan: { type: "markdown", planId: "plan-markdown", content: "# Ship it" }, + }, + }, + { + sessionId: "session-1", + update: { + sessionUpdate: "plan_update", + plan: { type: "file", planId: "plan-file", uri: "file:///workspace/PLAN.md" }, + }, + }, + { + sessionId: "session-1", + update: { + sessionUpdate: "plan_update", + plan: { type: "diagram", planId: "plan-future", nodes: [] }, + }, + }, + ] as const; + + expect(updates.flatMap((update) => parseSessionUpdateEvent(update).events)).toMatchObject([ + { + _tag: "PlanUpdated", + payload: { nativePlanId: "plan-markdown", kind: "markdown", markdown: "# Ship it" }, + }, + { + _tag: "PlanUpdated", + payload: { + nativePlanId: "plan-file", + kind: "file", + uri: "file:///workspace/PLAN.md", + }, + }, + { + _tag: "PlanUpdated", + payload: { nativePlanId: "plan-future", kind: "unknown", contentType: "diagram" }, + }, + ]); + }); + + it("turns future ACP content and session updates into explicit placeholders", () => { + expect( + acpContentBlockDisplayText({ + type: "_t3_unknown", + originalType: "chart", + raw: { type: "chart", points: [] }, + }), + ).toBe("[Unsupported ACP content: chart]"); + + expect( + parseSessionUpdateEvent({ + sessionId: "session-1", + update: { + sessionUpdate: "_t3_unknown", + originalSessionUpdate: "timeline_update", + raw: { sessionUpdate: "timeline_update", entries: [] }, + }, + }).events, + ).toMatchObject([{ _tag: "UnknownUpdate", updateType: "timeline_update" }]); + }); + + it("projects ACP v2 compaction lifecycle and prompt usage", () => { + const compacting = parseSessionUpdateEvent({ + sessionId: "session-1", + update: { + sessionUpdate: "compaction_update", + compactionId: "compact-1", + status: "in_progress", + }, + } satisfies EffectAcpSchema.SessionNotification); + const compacted = parseSessionUpdateEvent({ + sessionId: "session-1", + update: { + sessionUpdate: "compaction_update", + compactionId: "compact-1", + status: "completed", + summary: [{ type: "text", text: "Retained the implementation decisions." }], + }, + } satisfies EffectAcpSchema.SessionNotification); + const idle = parseSessionUpdateEvent({ + sessionId: "session-1", + update: { + sessionUpdate: "state_update", + state: "idle", + stopReason: "end_turn", + usage: { totalTokens: 420, inputTokens: 300, outputTokens: 100, thoughtTokens: 20 }, + }, + } satisfies EffectAcpSchema.SessionNotification); + + expect(compacting.events[0]).toMatchObject({ + _tag: "ToolCallUpdated", + toolCall: { + toolCallId: "acp-compaction:compact-1", + kind: "think", + title: "Compact context", + status: "inProgress", + }, + }); + expect(compacted.events[0]).toMatchObject({ + _tag: "ToolCallUpdated", + toolCall: { + toolCallId: "acp-compaction:compact-1", + status: "completed", + data: { rawOutput: "Retained the implementation decisions." }, + }, + }); + expect(idle.events[0]).toMatchObject({ + _tag: "UsageUpdated", + usage: { usedTokens: 420 }, + }); + }); + + it("applies ACP v2 agent-owned terminal snapshots and output chunks", () => { + const snapshot = applyAcpAgentTerminalUpdate(undefined, { + sessionUpdate: "terminal_update", + terminalId: "terminal-1", + command: "pnpm test", + cwd: "/workspace", + output: { data: Buffer.from("first line\n").toString("base64") }, + }); + const chunked = applyAcpAgentTerminalUpdate(snapshot, { + sessionUpdate: "terminal_output_chunk", + terminalId: "terminal-1", + data: Buffer.from("second line\n").toString("base64"), + }); + const completed = applyAcpAgentTerminalUpdate(chunked, { + sessionUpdate: "terminal_update", + terminalId: "terminal-1", + exitStatus: { exitCode: 0 }, + }); + + expect(completed).toEqual({ + command: "pnpm test", + cwd: "/workspace", + output: "first line\nsecond line\n", + exitStatus: { exitCode: 0 }, + }); + }); + + it("preserves UTF-8 characters split across ACP terminal output chunks", () => { + const bytes = Buffer.from("A🙂B", "utf8"); + const first = applyAcpAgentTerminalUpdate(undefined, { + sessionUpdate: "terminal_output_chunk", + terminalId: "terminal-utf8", + data: bytes.subarray(0, 3).toString("base64"), + }); + const second = applyAcpAgentTerminalUpdate(first, { + sessionUpdate: "terminal_output_chunk", + terminalId: "terminal-utf8", + data: bytes.subarray(3).toString("base64"), + }); + + expect(first.output).toBe("A"); + expect(first.pendingOutputBytes).toBeDefined(); + expect(second.output).toBe("A🙂B"); + expect(second.pendingOutputBytes).toBeUndefined(); + }); + + it("drops malformed and oversized ACP terminal frames without decoding them", () => { + const seeded = applyAcpAgentTerminalUpdate(undefined, { + sessionUpdate: "terminal_output_chunk", + terminalId: "terminal-invalid", + data: Buffer.from("kept").toString("base64"), + }); + + expect( + applyAcpAgentTerminalUpdate(seeded, { + sessionUpdate: "terminal_output_chunk", + terminalId: "terminal-invalid", + data: "not base64!", + }), + ).toBe(seeded); + + expect( + applyAcpAgentTerminalUpdate(seeded, { + sessionUpdate: "terminal_output_chunk", + terminalId: "terminal-oversized", + data: Buffer.alloc(16 * 1024 * 1024 + 1).toString("base64"), + }), + ).toBe(seeded); + }); + + it("projects binary ACP content without retaining encoded payloads", () => { + const encodedPayload = "sensitive-base64-payload"; + const binary = acpContentBlockDisplayText({ + type: "resource", + resource: { + blob: encodedPayload, + mimeType: "application/octet-stream", + uri: "file:///workspace/archive.bin", + }, + }); + const image = acpContentBlockDisplayText({ + type: "image", + data: encodedPayload, + mimeType: "image/png", + uri: "file:///workspace/image.png", + }); + const audio = acpContentBlockDisplayText({ + type: "audio", + data: encodedPayload, + mimeType: "audio/wav", + }); + + expect(binary).toBe( + "[ACP binary resource (application/octet-stream): file:///workspace/archive.bin]", + ); + expect(image).toBe("[ACP image (image/png): file:///workspace/image.png]"); + expect(audio).toBe("[ACP audio (audio/wav)]"); + expect(`${binary}${image}${audio}`).not.toContain(encodedPayload); + + const toolUpdate = parseSessionUpdateEvent({ + sessionId: "session-1", + update: { + sessionUpdate: "tool_call", + toolCallId: "tool-with-image", + title: "Generated image", + content: [ + { + type: "content", + content: { + type: "image", + data: encodedPayload, + mimeType: "image/png", + uri: `data:image/png;base64,${encodedPayload}`, + }, + }, + ], + }, + } satisfies EffectAcpSchema.SessionNotification); + const toolEvent = toolUpdate.events.find((event) => event._tag === "ToolCallUpdated"); + expect(JSON.stringify(toolEvent?.toolCall)).not.toContain(encodedPayload); + expect(toolUpdate.events[0]).toMatchObject({ + _tag: "ToolCallUpdated", + toolCall: { + data: { + content: [ + { type: "content", content: { type: "text", text: "[ACP image (image/png)]" } }, + ], + }, + }, + }); + }); + + it("bounds text-bearing ACP content without changing streamed whitespace", () => { + const text = ` ${"x".repeat(70_000)} `; + const projectedText = acpContentBlockDisplayText({ type: "text", text }); + const projectedResource = acpContentBlockDisplayText({ + type: "resource", + resource: { uri: "file:///workspace/large.txt", text }, + }); + + expect(projectedText).toHaveLength(65_536); + expect(projectedText?.startsWith(" ")).toBe(true); + expect(projectedResource).toEqual(projectedText); + }); +}); + +describe("extractMcpToolCallIdentity", () => { + function toolCallFromUpdate( + update: EffectAcpSchema.SessionNotification["update"], + ): NonNullable["toolCall"]> { + const parsed = parseSessionUpdateEvent({ sessionId: "session-1", update }); + const event = parsed.events.find( + ( + candidate, + ): candidate is Extract<(typeof parsed.events)[number], { _tag: "ToolCallUpdated" }> => + candidate._tag === "ToolCallUpdated", + ); + if (event === undefined) throw new Error("expected a tool call event"); + return event.toolCall; + } + + it("recovers server and tool from codex-acp tagged execute calls", () => { + // Captured verbatim from codex-acp 2026-08-14: MCP calls arrive as kind + // "execute" with the identity only in rawInput. + const toolCall = toolCallFromUpdate({ + sessionUpdate: "tool_call", + toolCallId: "exec-f4591587-0754-4bb4-990b-f2767894ba93", + kind: "execute", + title: "mcp.t3-code.orchestrator_capabilities", + status: "in_progress", + rawInput: { server: "t3-code", tool: "orchestrator_capabilities", arguments: {} }, + _meta: { is_mcp_tool_call: true }, + }); + + expect(extractMcpToolCallIdentity(toolCall)).toEqual({ + server: "t3-code", + tool: "orchestrator_capabilities", + }); + }); + + it("recovers T3 identity from acp-mcp-call fallback commands", () => { + const toolCall = toolCallFromUpdate({ + sessionUpdate: "tool_call", + toolCallId: "exec-1", + kind: "execute", + title: "Ran command", + status: "in_progress", + }); + + expect( + extractMcpToolCallIdentity(toolCall, { + embeddedTerminalCommands: [ + '/usr/bin/node /srv/t3/bin.ts acp-mcp-call delegate_task {"task":"x"}', + ], + }), + ).toEqual({ server: "t3-code", tool: "delegate_task", input: { task: "x" } }); + }); + + it("recovers T3 identity from pi-acp title-only fallback execs", () => { + // Captured verbatim from pi-acp 0.0.33 2026-08-14: rawInput is null and + // the command line only appears as the verbatim title, which the + // presentation layer summarizes into "Ran command". + const toolCall = toolCallFromUpdate({ + sessionUpdate: "tool_call", + toolCallId: "call_JdxnvzjHHrbvyASTLVekLYWV|fc_08f5a805a7159aa6016a7ec4afad548191", + kind: "execute", + title: + '"$T3_ACP_MCP_NODE" "$T3_ACP_MCP_ENTRYPOINT" acp-mcp-call orchestrator_capabilities \'{}\'', + status: "in_progress", + rawInput: null, + content: [ + { + type: "terminal", + terminalId: "call_JdxnvzjHHrbvyASTLVekLYWV|fc_08f5a805a7159aa6016a7ec4afad548191", + }, + ], + }); + + expect(toolCall.title).toBe("Ran command"); + expect(extractMcpToolCallIdentity(toolCall)).toEqual({ + server: "t3-code", + tool: "orchestrator_capabilities", + input: {}, + }); + }); + + it("recovers T3 identity from server-namespaced titles across titleless updates", () => { + // Captured verbatim from Kilo 7.4.22 2026-08-15: the initial tool_call + // titles the MCP function "_" with kind "other", and the + // completed update carries no title at all, so the merged presentation + // title regresses to "Tool" while data.title keeps the wire value. + const created = toolCallFromUpdate({ + sessionUpdate: "tool_call", + toolCallId: "chatcmpl-tool-b2a6142ee1a510a5", + kind: "other", + title: "t3-code_orchestrator_capabilities", + status: "pending", + locations: [], + rawInput: {}, + }); + const completed = toolCallFromUpdate({ + sessionUpdate: "tool_call_update", + toolCallId: "chatcmpl-tool-b2a6142ee1a510a5", + status: "completed", + content: [{ type: "content", content: { type: "text", text: '{"ok":true}' } }], + }); + const merged = mergeToolCallState(created, completed); + + expect(extractMcpToolCallIdentity(merged)).toEqual({ + server: "t3-code", + tool: "orchestrator_capabilities", + }); + }); + + it("recovers T3 identity from Gemini and qwen MCP-server title templates", () => { + // Gemini CLI 0.55.1: " ( MCP Server)"; qwen-code 0.21.12 + // appends ": " to the same template. + const gemini = toolCallFromUpdate({ + sessionUpdate: "tool_call", + toolCallId: "gemini-1", + kind: "other", + title: "delegate_task (t3-code MCP Server)", + status: "in_progress", + }); + const qwen = toolCallFromUpdate({ + sessionUpdate: "tool_call", + toolCallId: "qwen-1", + kind: "other", + title: 'task_status (t3-code MCP Server): {"taskId":"node:delegated-task:1"}', + status: "pending", + rawInput: { taskId: "node:delegated-task:1" }, + }); + + expect(extractMcpToolCallIdentity(gemini)).toEqual({ + server: "t3-code", + tool: "delegate_task", + }); + expect(extractMcpToolCallIdentity(qwen)).toEqual({ server: "t3-code", tool: "task_status" }); + }); + + it("recovers T3 identity across the registry agents' naming conventions", () => { + // One representative per surveyed convention (2026-08 registry builds): + // droid triple underscore, Copilot hyphen, Amp mangled server + detail + // tail, cline args tail, Auggie tool-first suffix. + // One representative per surveyed convention (2026-08 registry builds): + // droid triple underscore, Copilot hyphen, Amp mangled server + detail + // tail, cline args tail, Auggie tool-first suffix, fast-agent slash, + // Kimi bare name with args tail. + for (const title of [ + "t3-code___delegate_task", + "t3-code-delegate_task", + 'mcp__t3_code__delegate_task: {"mode":"async"}', + 't3-code__delegate_task: {"mode":"async"}', + "delegate_task_t3-code", + "t3-code/delegate_task", + 'delegate_task: {"mode":"async"}', + ]) { + const toolCall = toolCallFromUpdate({ + sessionUpdate: "tool_call", + toolCallId: "convention-1", + kind: "other", + title, + status: "pending", + }); + expect(extractMcpToolCallIdentity(toolCall), title).toEqual({ + server: "t3-code", + tool: "delegate_task", + }); + } + }); + + it("recovers T3 identity from goose _meta despite LLM-rewritten titles", () => { + // goose enriches titles asynchronously, so only _meta.goose.toolCall is + // stable; shape from crates/goose/src/acp/server/tool_calls/conversion.rs. + const toolCall = toolCallFromUpdate({ + sessionUpdate: "tool_call", + toolCallId: "goose-1", + title: "Checking on the delegated task", + status: "in_progress", + _meta: { + goose: { + toolCall: { toolName: "t3-code__task_status", extensionName: "t3-code" }, + messageId: "message-1", + }, + }, + }); + + expect(extractMcpToolCallIdentity(toolCall)).toEqual({ + server: "t3-code", + tool: "task_status", + }); + }); + + it("recovers T3 identity from qwen serverId meta regardless of prefix format", () => { + // qwen-code 0.21.12 emits _meta.serverId + _meta.toolName; serverId is an + // explicit origin assertion, so a known tool suffix suffices even if the + // prefix format changes. + const toolCall = toolCallFromUpdate({ + sessionUpdate: "tool_call", + toolCallId: "qwen-meta-1", + kind: "other", + title: "unrelated display title", + status: "pending", + _meta: { toolName: "mcp::t3-code::t3_thread_send", serverId: "t3-code", provenance: "mcp" }, + }); + + expect(extractMcpToolCallIdentity(toolCall)).toEqual({ + server: "t3-code", + tool: "t3_thread_send", + }); + }); + + it.each([ + { _meta: { claudeCode: { toolName: "mcp__weather__get_weather" } } }, + { _meta: { toolName: "mcp::weather::get_weather", serverId: "weather" } }, + ])("recovers external MCP identity from provider metadata", (metadata) => { + const toolCall = toolCallFromUpdate({ + sessionUpdate: "tool_call", + toolCallId: "weather-call", + title: "Checking the forecast", + status: "in_progress", + ...metadata, + }); + expect(extractMcpToolCallIdentity(toolCall)).toEqual({ + server: "weather", + tool: "get_weather", + }); + }); + + it.each([ + { kind: "execute", toolName: "developer__shell", extensionName: "developer" }, + { kind: "edit", toolName: "developer__edit", extensionName: "developer" }, + { kind: "other", toolName: "weather__get_weather", extensionName: "weather" }, + ] as const)( + "leaves goose $toolName unclassified so built-ins keep their projection", + ({ kind, toolName, extensionName }) => { + // goose tags built-in extensions exactly like user MCP servers. + const toolCall = toolCallFromUpdate({ + sessionUpdate: "tool_call", + toolCallId: "goose-builtin", + kind, + title: "developer: shell", + status: "in_progress", + _meta: { goose: { toolCall: { toolName, extensionName } } }, + }); + expect(extractMcpToolCallIdentity(toolCall)).toBeUndefined(); + }, + ); + + it("uses the asserted server instead of a misleading T3 title", () => { + const toolCall = toolCallFromUpdate({ + sessionUpdate: "tool_call", + toolCallId: "foreign-1", + kind: "other", + title: "t3-code_delegate_task", + status: "pending", + _meta: { toolName: "delegate_task", serverId: "other-orchestrator" }, + }); + + expect(extractMcpToolCallIdentity(toolCall)).toEqual({ + server: "other-orchestrator", + tool: "delegate_task", + }); + }); + + it("does not brand path-like or unknown-tool titles", () => { + for (const title of ["t3-code/README.md", "t3-code_not_a_real_tool"]) { + const toolCall = toolCallFromUpdate({ + sessionUpdate: "tool_call", + toolCallId: "path-1", + kind: "other", + title, + status: "pending", + }); + expect(extractMcpToolCallIdentity(toolCall), title).toBeUndefined(); + } + }); + + it("leaves ordinary execute calls unidentified", () => { + const toolCall = toolCallFromUpdate({ + sessionUpdate: "tool_call", + toolCallId: "exec-2", + kind: "execute", + title: "cat package.json", + status: "in_progress", + rawInput: { command: "cat package.json" }, + }); + + expect(extractMcpToolCallIdentity(toolCall)).toBeUndefined(); + expect( + extractMcpToolCallIdentity(toolCall, { embeddedTerminalCommands: ["bash -lc ls"] }), + ).toBeUndefined(); + }); + + it("does not treat generic raw server and tool fields as MCP provenance", () => { + const toolCall = toolCallFromUpdate({ + sessionUpdate: "tool_call", + toolCallId: "generic-1", + kind: "other", + title: "Deploy", + status: "pending", + rawInput: { server: "production", tool: "deploy" }, + }); + + expect(extractMcpToolCallIdentity(toolCall)).toBeUndefined(); + }); +}); + +describe("embeddedTerminalIdsFromSessionUpdate", () => { + it("collects terminal ids from tool_call content before the text rewrite", () => { + expect( + embeddedTerminalIdsFromSessionUpdate({ + sessionId: "session-1", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "call_1", + status: "in_progress", + content: [ + { type: "terminal", terminalId: "t3-term-9" }, + { type: "content", content: { type: "text", text: "noise" } }, + ], + }, + }), + ).toEqual({ toolCallId: "call_1", terminalIds: ["t3-term-9"] }); + expect( + embeddedTerminalIdsFromSessionUpdate({ + sessionId: "session-1", + update: { + sessionUpdate: "tool_call", + toolCallId: "call_2", + title: "Tool", + status: "pending", + }, + }), + ).toBeUndefined(); + }); +}); diff --git a/packages/provider-acp/src/server/runtimeModel.ts b/packages/provider-acp/src/server/runtimeModel.ts new file mode 100644 index 0000000000000000000000000000000000000000..f4172d17089b1ac66d059bd43c469e684d6adf85 --- /dev/null +++ b/packages/provider-acp/src/server/runtimeModel.ts @@ -0,0 +1,1644 @@ +import * as Clock from "effect/Clock"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as EffectAcpSchema from "effect-acp/compat"; +import * as EffectAcpSchemaV1 from "effect-acp/schema-v1"; +import { + deriveToolActivityPresentation, + mergeToolActivityData, +} from "@t3tools/shared/toolActivity"; +import { T3_MCP_TOOL_NAMES } from "@t3tools/shared/t3McpToolPresentation"; +import type { + OrchestrationV2ProviderThreadNativeMetadata, + ThreadTokenUsageSnapshot, + ToolLifecycleItemType, +} from "@t3tools/contracts"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +// Guards for the untyped `initialize._meta` states some agents (Grok) advertise. +// Modes were removed from ACP v2, so the v1 wire schema is the source of truth. +const isSessionModelState = Schema.is(EffectAcpSchema.SessionModelState); +const isSessionModeState = Schema.is(EffectAcpSchemaV1.SessionModeState); + +export interface AcpSessionMode { + readonly id: string; + readonly name: string; + readonly description?: string; +} + +export interface AcpSessionModeState { + readonly currentModeId: string; + readonly availableModes: ReadonlyArray; +} + +export interface AcpToolCallState { + readonly toolCallId: string; + readonly kind?: string; + readonly title?: string; + readonly status?: "pending" | "inProgress" | "requiresAction" | "completed" | "failed"; + readonly command?: string; + readonly detail?: string; + readonly data: Record; +} + +export interface AcpAgentTerminalState { + readonly command?: string; + readonly cwd?: string; + readonly output: string; + readonly exitStatus?: EffectAcpSchema.TerminalExitStatus; + /** Incomplete trailing UTF-8 bytes retained until the next output update. */ + readonly pendingOutputBytes?: Uint8Array; +} + +type AcpAgentTerminalUpdate = Extract< + EffectAcpSchema.SessionUpdate, + { readonly sessionUpdate: "terminal_update" | "terminal_output_chunk" } +>; + +const MAX_ACP_TERMINAL_OUTPUT_BYTES = 16 * 1024 * 1024; +const MAX_ACP_TERMINAL_FRAME_BYTES = MAX_ACP_TERMINAL_OUTPUT_BYTES; + +function isBase64DataCode(code: number): boolean { + return ( + (code >= 65 && code <= 90) || + (code >= 97 && code <= 122) || + (code >= 48 && code <= 57) || + code === 43 || + code === 47 + ); +} + +/** + * Decodes one bounded base64 terminal frame, or `undefined` when the frame is + * malformed or oversized. Invalid frames are dropped rather than failed so one + * misbehaving agent frame cannot take down session-update handling. + */ +function decodeBase64TerminalFrame(encoded: string): Uint8Array | undefined { + if (encoded.length > Math.ceil(MAX_ACP_TERMINAL_FRAME_BYTES / 3) * 4) { + return undefined; + } + const padding = encoded.endsWith("==") ? 2 : encoded.endsWith("=") ? 1 : 0; + const dataLength = encoded.length - padding; + if ( + encoded.length % 4 === 1 || + (padding > 0 && encoded.length % 4 !== 0) || + (padding === 1 && dataLength % 4 !== 3) || + (padding === 2 && dataLength % 4 !== 2) + ) { + return undefined; + } + for (let index = 0; index < dataLength; index += 1) { + if (!isBase64DataCode(encoded.charCodeAt(index))) return undefined; + } + const decoded = Buffer.from(encoded, "base64"); + return decoded.byteLength <= MAX_ACP_TERMINAL_FRAME_BYTES ? decoded : undefined; +} + +function trailingIncompleteUtf8Start(bytes: Uint8Array): number { + if (bytes.length === 0) return bytes.length; + let leadIndex = bytes.length - 1; + while (leadIndex >= 0 && (bytes[leadIndex]! & 0xc0) === 0x80) { + leadIndex -= 1; + } + if (leadIndex < 0) return bytes.length; + const lead = bytes[leadIndex]!; + const expectedLength = + lead >= 0xc2 && lead <= 0xdf + ? 2 + : lead >= 0xe0 && lead <= 0xef + ? 3 + : lead >= 0xf0 && lead <= 0xf4 + ? 4 + : 1; + return bytes.length - leadIndex < expectedLength ? leadIndex : bytes.length; +} + +function decodeTerminalOutputChunk( + pending: Uint8Array | undefined, + encoded: string, +): { readonly text: string; readonly pendingOutputBytes?: Uint8Array } | undefined { + const frame = decodeBase64TerminalFrame(encoded); + if (frame === undefined) return undefined; + const bytes = Buffer.concat([...(pending === undefined ? [] : [Buffer.from(pending)]), frame]); + const completeEnd = trailingIncompleteUtf8Start(bytes); + const text = bytes.subarray(0, completeEnd).toString("utf8"); + return completeEnd === bytes.length + ? { text } + : { text, pendingOutputBytes: Uint8Array.from(bytes.subarray(completeEnd)) }; +} + +function truncateTerminalOutput(output: string): string { + const bytes = Buffer.from(output, "utf8"); + if (bytes.length <= MAX_ACP_TERMINAL_OUTPUT_BYTES) return output; + let start = bytes.length - MAX_ACP_TERMINAL_OUTPUT_BYTES; + while (start < bytes.length && (bytes[start]! & 0xc0) === 0x80) start += 1; + return bytes.subarray(start).toString("utf8"); +} + +/** Applies an ACP v2 agent-owned terminal snapshot or chunk to display state. */ +export function applyAcpAgentTerminalUpdate( + previous: AcpAgentTerminalState | undefined, + update: AcpAgentTerminalUpdate, +): AcpAgentTerminalState { + const current = previous ?? { output: "" }; + if (update.sessionUpdate === "terminal_output_chunk") { + const decoded = decodeTerminalOutputChunk(current.pendingOutputBytes, update.data); + if (decoded === undefined) return current; + const { pendingOutputBytes: _pendingOutputBytes, ...withoutPendingOutputBytes } = current; + return { + ...withoutPendingOutputBytes, + output: truncateTerminalOutput(`${current.output}${decoded.text}`), + ...(decoded.pendingOutputBytes === undefined + ? {} + : { pendingOutputBytes: decoded.pendingOutputBytes }), + }; + } + let next: AcpAgentTerminalState = current; + if (update.command === null) { + const { command: _command, ...withoutCommand } = next; + next = withoutCommand; + } else if (update.command !== undefined) { + next = { ...next, command: update.command }; + } + if (update.cwd === null) { + const { cwd: _cwd, ...withoutCwd } = next; + next = withoutCwd; + } else if (update.cwd !== undefined) { + next = { ...next, cwd: update.cwd }; + } + if (update.output !== undefined) { + const decoded = + update.output === null + ? { text: "" } + : decodeTerminalOutputChunk(undefined, update.output.data); + if (decoded !== undefined) { + const { pendingOutputBytes: _pendingOutputBytes, ...withoutPendingOutputBytes } = next; + next = { + ...withoutPendingOutputBytes, + output: truncateTerminalOutput(decoded.text), + ...(decoded.pendingOutputBytes === undefined + ? {} + : { pendingOutputBytes: decoded.pendingOutputBytes }), + }; + } + } + if (update.exitStatus === null) { + const { exitStatus: _exitStatus, ...withoutExitStatus } = next; + next = withoutExitStatus; + } else if (update.exitStatus !== undefined) { + const pendingText = + next.pendingOutputBytes === undefined + ? "" + : Buffer.from(next.pendingOutputBytes).toString("utf8"); + const { pendingOutputBytes: _pendingOutputBytes, ...withoutPendingOutputBytes } = next; + next = { + ...withoutPendingOutputBytes, + output: truncateTerminalOutput(`${next.output}${pendingText}`), + exitStatus: update.exitStatus, + }; + } + return next; +} + +export type AcpPlanUpdate = + | { + readonly nativePlanId: string; + readonly kind: "items"; + readonly explanation?: string | null; + readonly plan: ReadonlyArray<{ + readonly step: string; + readonly status: "pending" | "inProgress" | "completed"; + }>; + } + | { readonly nativePlanId: string; readonly kind: "markdown"; readonly markdown: string } + | { readonly nativePlanId: string; readonly kind: "file"; readonly uri: string } + | { readonly nativePlanId: string; readonly kind: "unknown"; readonly contentType: string } + | { readonly nativePlanId: string; readonly kind: "removed" }; + +export interface AcpPermissionRequest { + readonly kind: string | "unknown"; + readonly detail?: string; + readonly toolCall?: AcpToolCallState; +} + +export type AcpParsedSessionEvent = + | { + readonly _tag: "ModeChanged"; + readonly modeId: string; + } + | { + readonly _tag: "AvailableCommandsUpdated"; + readonly availableCommands: ReadonlyArray; + readonly rawPayload: unknown; + } + | { + readonly _tag: "ConfigOptionsUpdated"; + readonly configOptions: ReadonlyArray; + readonly rawPayload: unknown; + } + | { + readonly _tag: "AssistantItemStarted"; + readonly itemId: string; + } + | { + readonly _tag: "AssistantItemCompleted"; + readonly itemId: string; + } + | { + readonly _tag: "PlanUpdated"; + readonly payload: AcpPlanUpdate; + readonly rawPayload: unknown; + } + | { + readonly _tag: "ToolCallUpdated"; + readonly toolCall: AcpToolCallState; + readonly rawPayload: unknown; + } + | { + readonly _tag: "ContentDelta"; + readonly itemId?: string; + readonly messageId?: string; + readonly text: string; + readonly rawPayload: unknown; + } + | { + readonly _tag: "ThoughtDelta"; + readonly text: string; + readonly rawPayload: unknown; + } + | { + readonly _tag: "UsageUpdated"; + readonly usage: ThreadTokenUsageSnapshot; + readonly rawPayload: unknown; + } + | { + readonly _tag: "SessionInfoUpdated"; + readonly metadata: OrchestrationV2ProviderThreadNativeMetadata; + readonly rawPayload: unknown; + } + | { + readonly _tag: "UnknownUpdate"; + readonly updateType: string; + readonly rawPayload: unknown; + }; + +const boundedContentMetadata = (value: string | null | undefined, maximumLength: number): string => + value?.trim().slice(0, maximumLength) ?? ""; + +const MAX_ACP_CONTENT_TEXT_LENGTH = 65_536; + +// Preserve whitespace in streamed model output while keeping one provider frame bounded. +const boundedContentText = (value: string): string => value.slice(0, MAX_ACP_CONTENT_TEXT_LENGTH); + +const boundedContentUri = (value: string | null | undefined): string => { + const uri = boundedContentMetadata(value, 4_096); + return uri.toLowerCase().startsWith("data:") ? "" : uri; +}; + +/** Convert every ACP output block into bounded, displayable text without retaining base64 data. */ +export function acpContentBlockDisplayText( + content: EffectAcpSchema.ContentBlock, +): string | undefined { + switch (content.type) { + case "text": + return boundedContentText(content.text); + case "resource_link": { + const label = + boundedContentMetadata(content.title, 512) || + boundedContentMetadata(content.name, 512) || + "resource"; + const description = boundedContentMetadata(content.description, 2_048); + const uri = boundedContentUri(content.uri); + return `${description ? `${label}: ${description}` : label}${uri ? `\n${uri}` : ""}`; + } + case "resource": { + if ("text" in content.resource) { + return boundedContentText(content.resource.text); + } + const mimeType = boundedContentMetadata(content.resource.mimeType, 256); + const uri = boundedContentUri(content.resource.uri); + return `[ACP binary resource${mimeType ? ` (${mimeType})` : ""}${uri ? `: ${uri}` : ""}]`; + } + case "image": { + const mimeType = boundedContentMetadata(content.mimeType, 256) || "unknown type"; + const uri = boundedContentUri(content.uri); + return `[ACP image (${mimeType})${uri ? `: ${uri}` : ""}]`; + } + case "audio": { + const mimeType = boundedContentMetadata(content.mimeType, 256) || "unknown type"; + return `[ACP audio (${mimeType})]`; + } + case "_t3_unknown": + return `[Unsupported ACP content: ${boundedContentMetadata(content.originalType, 128) || "unknown"}]`; + } +} + +function sanitizeAcpToolCallContent( + content: ReadonlyArray, +): ReadonlyArray { + return content.map((entry) => { + switch (entry.type) { + case "content": + return { + type: "content", + content: { + type: "text", + text: acpContentBlockDisplayText(entry.content) ?? "", + }, + }; + case "diff": + if ("changes" in entry) { + return { + type: "diff", + changes: entry.changes, + ...(entry.patch === undefined ? {} : { patch: entry.patch }), + }; + } + return { + type: "diff", + path: entry.path, + ...(entry.oldText !== undefined ? { oldText: entry.oldText } : {}), + newText: entry.newText, + }; + case "terminal": + return { type: "terminal", terminalId: entry.terminalId }; + case "_t3_unknown": + return { + type: "_t3_unknown", + originalType: boundedContentMetadata(entry.originalType, 128) || "unknown", + raw: null, + }; + } + }); +} + +type AcpSessionSetupResponse = + | EffectAcpSchema.ForkSessionResponse + | EffectAcpSchema.LoadSessionResponse + | EffectAcpSchema.NewSessionResponse + | EffectAcpSchema.ResumeSessionResponse; + +type AcpToolCallUpdate = Extract< + EffectAcpSchema.SessionNotification["update"], + { readonly sessionUpdate: "tool_call" | "tool_call_update" } +>; + +export function extractModelConfigId(sessionResponse: AcpSessionSetupResponse): string | undefined { + const configOptions = sessionResponse.configOptions; + if (!configOptions) return undefined; + for (const opt of configOptions) { + if (opt.category === "model" && opt.id.trim().length > 0) { + return opt.id.trim(); + } + } + return undefined; +} + +export function findSessionConfigOption( + configOptions: ReadonlyArray | null | undefined, + configId: string, +): EffectAcpSchema.SessionConfigOption | undefined { + if (!configOptions) { + return undefined; + } + const normalizedConfigId = configId.trim(); + if (!normalizedConfigId) { + return undefined; + } + return configOptions.find((option) => option.id.trim() === normalizedConfigId); +} + +export function collectSessionConfigOptionValues( + configOption: EffectAcpSchema.SessionConfigOption, +): ReadonlyArray { + if (configOption.type !== "select") { + return []; + } + return configOption.options.flatMap((entry) => + "value" in entry ? [entry.value] : entry.options.map((option) => option.value), + ); +} + +export function parseSessionModeState( + sessionResponse: AcpSessionSetupResponse, +): AcpSessionModeState | undefined { + const modeConfig = sessionResponse.configOptions?.find( + (option) => option.category === "mode" && option.type === "select", + ); + const modes = + sessionResponse.modes ?? + (modeConfig?.type === "select" + ? { + currentModeId: modeConfig.currentValue, + availableModes: modeConfig.options + .flatMap((option) => ("options" in option ? option.options : [option])) + .map((option) => ({ + id: option.value, + name: option.name, + description: option.description, + })), + } + : undefined); + if (!modes) return undefined; + const currentModeId = modes.currentModeId.trim(); + if (!currentModeId) { + return undefined; + } + const availableModes: Array = []; + for (const mode of modes.availableModes) { + const id = mode.id.trim(); + const name = mode.name.trim(); + if (!id || !name) { + continue; + } + const description = mode.description?.trim() || undefined; + availableModes.push( + description !== undefined + ? ({ id, name, description } satisfies AcpSessionMode) + : ({ id, name } satisfies AcpSessionMode), + ); + } + if (availableModes.length === 0) { + return undefined; + } + return { + currentModeId, + availableModes, + }; +} + +function normalizePlanStepStatus(raw: unknown): "pending" | "inProgress" | "completed" { + switch (raw) { + case "completed": + return "completed"; + case "in_progress": + case "inProgress": + return "inProgress"; + default: + return "pending"; + } +} + +function normalizeToolCallStatus( + raw: unknown, + fallback?: "pending" | "inProgress" | "completed" | "failed", +): "pending" | "inProgress" | "completed" | "failed" | undefined { + switch (raw) { + case "pending": + return "pending"; + case "in_progress": + case "inProgress": + return "inProgress"; + case "completed": + return "completed"; + case "failed": + return "failed"; + default: + return fallback; + } +} + +function normalizeCommandValue(value: unknown): string | undefined { + if (typeof value === "string" && value.trim().length > 0) { + return value.trim(); + } + if (!Array.isArray(value)) { + return undefined; + } + const parts: Array = []; + for (const entry of value) { + if (typeof entry === "string") { + const part = entry.trim(); + if (part.length > 0) { + parts.push(part); + } + } + } + return parts.length > 0 ? parts.join(" ") : undefined; +} + +function extractCommandFromTitle(title: string | undefined): string | undefined { + if (!title) { + return undefined; + } + const match = /`([^`]+)`/.exec(title); + return match?.[1]?.trim() || undefined; +} + +function extractToolCallCommand( + rawInput: unknown, + title: string | undefined, + kind: string | undefined, +): string | undefined { + if (isRecord(rawInput)) { + const directCommand = normalizeCommandValue(rawInput.command); + if (directCommand) { + return directCommand; + } + const executable = typeof rawInput.executable === "string" ? rawInput.executable.trim() : ""; + const args = normalizeCommandValue(rawInput.args); + if (executable && args) { + return `${executable} ${args}`; + } + if (executable) { + return executable; + } + } + // Titles like `Read \`src/a.ts\`` are not commands. Only execute tools + // fall back to a backtick or bare title. + if (kind !== "execute") { + return undefined; + } + return extractCommandFromTitle(title); +} + +function filePathFromToolValue(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function locationsFromToolCallInput(input: { + readonly locations?: ReadonlyArray | null | undefined; + readonly content?: ReadonlyArray | null | undefined; + readonly rawInput?: unknown; + readonly rawOutput?: unknown; +}): ReadonlyArray | undefined { + if (input.locations === null || input.locations?.length === 0) { + return []; + } + const locations: EffectAcpSchema.ToolCallLocation[] = []; + const seen = new Set(); + const pushLocation = (location: EffectAcpSchema.ToolCallLocation) => { + const path = filePathFromToolValue(location.path); + if (!path || seen.has(path)) { + return; + } + seen.add(path); + locations.push({ ...location, path }); + }; + const pushPath = (value: unknown) => { + const path = filePathFromToolValue(value); + if (!path || seen.has(path)) { + return; + } + seen.add(path); + locations.push({ path }); + }; + + if (input.locations) { + for (const location of input.locations) { + pushLocation(location); + } + } + if (input.content) { + for (const entry of input.content) { + if (entry.type === "diff" && "path" in entry) { + pushPath(entry.path); + } + } + } + if (isRecord(input.rawInput)) { + pushPath(input.rawInput.path); + pushPath(input.rawInput.filePath); + pushPath(input.rawInput.file_path); + } + if (isRecord(input.rawOutput)) { + pushPath(input.rawOutput.path); + pushPath(input.rawOutput.filePath); + pushPath(input.rawOutput.file_path); + } + + return locations.length > 0 ? locations : undefined; +} + +// Some ACP agents (observed with Grok's CLI) resend the ENTIRE accumulated tool-call +// output on every `tool_call_update` notification instead of a delta, so a redrawing +// terminal progress bar can balloon a single tool call to hundreds of KB per update at +// several updates per second. Cap what we retain/emit to a bounded tail so one busy tool +// call cannot flood runtime event ingestion. We always keep the tail: `tool_call_update` +// deltas routinely omit `kind`, so there is no reliable way to tell a redrawing terminal +// from another tool here, and the end is the useful part of any live-growing output. +const TOOL_CALL_CONTENT_MAX_CHARS = 8_000; +const TOOL_CALL_CONTENT_TRUNCATION_MARKER = "[Earlier output truncated]\n\n"; + +function boundToolCallOutputText(text: string): string { + if (text.length <= TOOL_CALL_CONTENT_MAX_CHARS) { + return text; + } + const tail = text.slice(text.length - TOOL_CALL_CONTENT_MAX_CHARS); + return `${TOOL_CALL_CONTENT_TRUNCATION_MARKER}${tail}`; +} + +const RAW_OUTPUT_TEXT_FIELDS = ["content", "stdout", "stderr", "output"] as const; + +// `rawOutput` is provider-defined and, for terminal-shaped tools, mirrors the same +// cumulative text-growth problem as `content` (see the comment above). Bound its known +// text-bearing fields the same way so a chatty provider cannot smuggle unbounded output +// through this field instead. +function boundToolCallRawOutput(rawOutput: unknown): unknown { + if (!isRecord(rawOutput)) { + return rawOutput; + } + let changed = false; + const bounded: Record = { ...rawOutput }; + for (const field of RAW_OUTPUT_TEXT_FIELDS) { + const value = rawOutput[field]; + if (typeof value === "string" && value.length > TOOL_CALL_CONTENT_MAX_CHARS) { + bounded[field] = boundToolCallOutputText(value); + changed = true; + } + } + return changed ? bounded : rawOutput; +} + +interface ExtractedToolCallContent { + readonly text: string | undefined; + readonly content: ReadonlyArray | undefined; +} + +function toolCallContentText(entry: EffectAcpSchema.ToolCallContent): string | undefined { + if (entry.type !== "content" || entry.content.type !== "text") { + return undefined; + } + return entry.content.text; +} + +// Trim is used for display `text`, so whitespace-only (or whitespace-padded) entries never +// contribute to `chunks` and used to take the early returns with the original array. Bound +// each text entry independently so those paths cannot persist an unbounded terminal buffer +// on `toolCall.data.content` / `rawPayload`. +function boundToolCallContentEntries( + content: ReadonlyArray, +): ReadonlyArray { + let changed = false; + const bounded = content.map((entry) => { + const text = toolCallContentText(entry); + if (text === undefined || text.length <= TOOL_CALL_CONTENT_MAX_CHARS) { + return entry; + } + changed = true; + const trimmed = text.trim(); + return { + type: "content", + content: { + type: "text", + text: boundToolCallOutputText(trimmed.length > 0 ? trimmed : text), + }, + } as const; + }); + return changed ? bounded : content; +} + +function extractTextContentFromToolCallContent( + content: ReadonlyArray | null | undefined, +): ExtractedToolCallContent { + if (!content) { + return { text: undefined, content: undefined }; + } + const chunks: Array = []; + for (const entry of content) { + const text = toolCallContentText(entry)?.trim(); + if (text) { + chunks.push(text); + } + } + if (chunks.length === 0) { + return { text: undefined, content: boundToolCallContentEntries(content) }; + } + const joined = chunks.join("\n"); + if (joined.length <= TOOL_CALL_CONTENT_MAX_CHARS) { + return { text: joined, content: boundToolCallContentEntries(content) }; + } + const bounded = boundToolCallOutputText(joined); + const tail = joined.slice(joined.length - TOOL_CALL_CONTENT_MAX_CHARS); + return { + text: bounded, + content: distributeRetainedTailAcrossContent(content, tail), + }; +} + +// Walk the original text entries from the joined tail window so a retained slice that +// spans entries around an image/diff stays on those entries. Non-text kinds keep their +// relative order; blank text entries are dropped; the truncation marker is prepended to +// the first remaining text entry. +function distributeRetainedTailAcrossContent( + content: ReadonlyArray, + tail: string, +): ReadonlyArray { + const textRanges: Array< + | { + readonly start: number; + readonly end: number; + readonly text: string; + } + | undefined + > = Array.from({ length: content.length }); + let offset = 0; + let seenText = false; + for (const [index, entry] of content.entries()) { + const text = toolCallContentText(entry)?.trim(); + if (!text) { + continue; + } + if (seenText) { + offset += 1; + } + seenText = true; + const start = offset; + const end = offset + text.length; + textRanges[index] = { start, end, text }; + offset = end; + } + const tailStart = Math.max(0, offset - tail.length); + let markerPending = true; + return content.flatMap((entry, index) => { + if (toolCallContentText(entry) === undefined) { + return [entry]; + } + const range = textRanges[index]; + if (range === undefined) { + return []; + } + const overlapStart = Math.max(range.start, tailStart); + const overlapEnd = Math.min(range.end, offset); + if (overlapEnd <= overlapStart) { + return []; + } + let piece = range.text.slice(overlapStart - range.start, overlapEnd - range.start); + if (markerPending) { + piece = `${TOOL_CALL_CONTENT_TRUNCATION_MARKER}${piece}`; + markerPending = false; + } + return [{ type: "content", content: { type: "text", text: piece } } as const]; + }); +} + +function normalizeToolKind(kind: unknown): string | undefined { + return typeof kind === "string" && kind.trim().length > 0 ? kind.trim() : undefined; +} + +/** + * Map an ACP tool kind onto the canonical runtime item type used by the + * thread activity model. Unknown kinds fall back to a generic tool call. + */ +export function canonicalItemTypeFromAcpToolKind(kind: string | undefined): ToolLifecycleItemType { + switch (kind) { + case "read": + return "dynamic_tool_call"; + case "execute": + return "command_execution"; + case "edit": + case "delete": + case "move": + return "file_change"; + case "search": + case "fetch": + return "web_search"; + default: + return "dynamic_tool_call"; + } +} + +function makeToolCallState( + input: { + readonly toolCallId: string; + readonly title?: string | null | undefined; + readonly kind?: EffectAcpSchema.ToolKind | null | undefined; + readonly status?: EffectAcpSchema.ToolCallStatus | null | undefined; + readonly rawInput?: unknown; + readonly rawOutput?: unknown; + readonly content?: ReadonlyArray | null | undefined; + readonly locations?: ReadonlyArray | null | undefined; + readonly _meta?: unknown; + }, + options?: { + readonly fallbackStatus?: "pending" | "inProgress" | "completed" | "failed"; + }, +): AcpToolCallState | undefined { + const toolCallId = input.toolCallId.trim(); + if (!toolCallId) { + return undefined; + } + const title = input.title?.trim() || undefined; + const kind = normalizeToolKind(input.kind); + const command = extractToolCallCommand(input.rawInput, title, kind); + const extractedContent = extractTextContentFromToolCallContent(input.content); + const textContent = extractedContent.text; + const normalizedTitle = + title && title.toLowerCase() !== "terminal" && title.toLowerCase() !== "tool call" + ? title + : undefined; + const data: Record = { toolCallId }; + if (kind) { + data.kind = kind; + } + if (title) { + // The agent's verbatim title. Presentation summarizes `title` on the + // state (e.g. a command line becomes "Ran command"), so identity checks + // such as MCP-fallback detection need the raw value preserved here. + data.title = title; + } + if (command) { + data.command = command; + } + if (input.rawInput !== undefined) { + data.rawInput = input.rawInput; + } + if (isRecord(input._meta)) { + // Some agents identify MCP calls only here (goose `goose.toolCall`, + // qwen `toolName`/`serverId`, claude-acp `claudeCode.toolName`). + data.meta = input._meta; + } + if (input.rawOutput !== undefined) { + data.rawOutput = boundToolCallRawOutput(input.rawOutput); + } + if (input.content != null) { + data.content = sanitizeAcpToolCallContent(extractedContent.content ?? input.content); + } + const locations = locationsFromToolCallInput(input); + if (locations !== undefined) { + data.locations = locations; + } + if (isRecord(input._meta)) { + data.meta = input._meta; + } + const fallbackDetail = command ?? normalizedTitle ?? textContent; + const hasPresentationSeed = + title !== undefined || + kind !== undefined || + command !== undefined || + normalizedTitle !== undefined || + textContent !== undefined; + const presentation = hasPresentationSeed + ? deriveToolActivityPresentation({ + itemType: canonicalItemTypeFromAcpToolKind(kind), + title, + detail: fallbackDetail, + data, + fallbackSummary: title ?? "Tool", + }) + : undefined; + const status = normalizeToolCallStatus(input.status, options?.fallbackStatus); + return { + toolCallId, + ...(kind ? { kind } : {}), + ...(presentation?.summary ? { title: presentation.summary } : {}), + ...(status ? { status } : {}), + ...(command ? { command } : {}), + ...(presentation?.detail ? { detail: presentation.detail } : {}), + data, + }; +} + +function parseTypedToolCallState( + event: AcpToolCallUpdate, + options?: { + readonly fallbackStatus?: "pending" | "inProgress" | "completed" | "failed"; + }, +): AcpToolCallState | undefined { + return makeToolCallState( + { + toolCallId: event.toolCallId, + title: event.title, + kind: event.kind, + status: event.status, + rawInput: event.rawInput, + rawOutput: event.rawOutput, + content: event.content, + locations: event.locations, + _meta: event._meta, + }, + options, + ); +} + +export function mergeToolCallState( + previous: AcpToolCallState | undefined, + next: AcpToolCallState, +): AcpToolCallState { + const nextKind = next.kind ?? (typeof next.data.kind === "string" ? next.data.kind : undefined); + const kind = nextKind ?? previous?.kind; + const title = next.title ?? previous?.title; + const status = next.status ?? previous?.status; + const command = next.command ?? previous?.command; + const detail = next.detail ?? previous?.detail; + const data = mergeToolActivityData(previous?.data, next.data) ?? next.data; + return { + toolCallId: next.toolCallId, + ...(kind ? { kind } : {}), + ...(title ? { title } : {}), + ...(status ? { status } : {}), + ...(command ? { command } : {}), + ...(detail ? { detail } : {}), + data, + }; +} + +// Even with bounded content (see TOOL_CALL_CONTENT_MAX_CHARS above), a redrawing terminal +// can still shift its bounded tail window on nearly every notification, which would emit +// a runtime event per redraw. Coalesce those: only emit early when the tool call's detail +// has grown meaningfully since the last emission, otherwise batch up to a small number of +// skipped updates before emitting anyway, so the UI still gets periodic progress and the +// final (completed/failed) state is always emitted immediately. +const TOOL_CALL_UPDATE_MIN_DETAIL_GROWTH_CHARS = 256; +const TOOL_CALL_UPDATE_COALESCE_LIMIT = 10; + +export interface AcpToolCallEmitDecisionInput { + readonly previous: AcpToolCallState | undefined; + readonly next: AcpToolCallState; + readonly lastEmittedDetailLength: number | undefined; + readonly skippedSinceEmit: number; +} + +export interface AcpToolCallEmitDecision { + readonly emit: boolean; + readonly skippedSinceEmit: number; +} + +function toolCallOutputUnchanged(previous: AcpToolCallState, next: AcpToolCallState): boolean { + return ( + previous.data.content === next.data.content && previous.data.rawOutput === next.data.rawOutput + ); +} + +// Command tools keep `detail` equal to the command, so live stdout lives on +// `data.content` / `data.rawOutput`. Measure that too, otherwise coalescing never +// sees growth and in-progress output is held until completed/failed. +export function toolCallProgressLength(state: AcpToolCallState): number { + let contentChars = 0; + const content = state.data.content; + if (Array.isArray(content)) { + for (const entry of content) { + if (!isRecord(entry)) { + continue; + } + const text = toolCallContentText(entry as EffectAcpSchema.ToolCallContent); + if (text) { + contentChars += text.length; + } + } + } + let rawOutputChars = 0; + const rawOutput = state.data.rawOutput; + if (isRecord(rawOutput)) { + for (const field of RAW_OUTPUT_TEXT_FIELDS) { + const value = rawOutput[field]; + if (typeof value === "string") { + rawOutputChars += value.length; + } + } + } + return Math.max(state.detail?.length ?? 0, contentChars, rawOutputChars); +} + +function toolCallContentTexts(state: AcpToolCallState): string { + const content = state.data.content; + if (!Array.isArray(content)) return ""; + return content + .map((entry) => + isRecord(entry) ? (toolCallContentText(entry as EffectAcpSchema.ToolCallContent) ?? "") : "", + ) + .join("\u0000"); +} + +// The output a user watches: detail, text content, and any `rawOutput`. A +// streamed diff or `rawInput` is not part of it. +export function toolCallVisibleOutputChanged( + previous: AcpToolCallState, + next: AcpToolCallState, +): boolean { + return ( + previous.detail !== next.detail || + toolCallContentTexts(previous) !== toolCallContentTexts(next) || + JSON.stringify(previous.data.rawOutput) !== JSON.stringify(next.data.rawOutput) + ); +} + +export function decideToolCallUpdateEmission( + input: AcpToolCallEmitDecisionInput, +): AcpToolCallEmitDecision { + const { previous, next, lastEmittedDetailLength, skippedSinceEmit } = input; + if (next.status === "completed" || next.status === "failed") { + return { emit: true, skippedSinceEmit: 0 }; + } + if (previous === undefined || previous.title !== next.title || previous.status !== next.status) { + return { emit: true, skippedSinceEmit: 0 }; + } + if (previous.detail === next.detail && toolCallOutputUnchanged(previous, next)) { + return { emit: false, skippedSinceEmit }; + } + const progressLength = toolCallProgressLength(next); + const grewMeaningfully = + lastEmittedDetailLength === undefined || + Math.abs(progressLength - lastEmittedDetailLength) >= TOOL_CALL_UPDATE_MIN_DETAIL_GROWTH_CHARS; + if (grewMeaningfully || skippedSinceEmit + 1 >= TOOL_CALL_UPDATE_COALESCE_LIMIT) { + return { emit: true, skippedSinceEmit: 0 }; + } + return { emit: false, skippedSinceEmit: skippedSinceEmit + 1 }; +} + +export interface AcpMcpToolCallIdentity { + readonly server: string; + readonly tool: string; + readonly input?: Record; +} + +/** Matches an invocation of T3's `acp-mcp-call` bridge fallback CLI. */ +const ACP_MCP_FALLBACK_CALL = /(?:^|[\s"'=])acp-mcp-call[\s"']+([A-Za-z0-9_.-]+)(?:\s+(.+?))?\s*$/u; + +function acpMcpFallbackInput(value: string | undefined): Record | undefined { + if (value === undefined) return undefined; + const trimmed = value.trim(); + const candidates = [ + trimmed, + ...(trimmed.startsWith("'") && trimmed.endsWith("'") ? [trimmed.slice(1, -1)] : []), + ]; + for (const candidate of candidates) { + try { + const parsed: unknown = JSON.parse(candidate); + const unwrapped = typeof parsed === "string" ? JSON.parse(parsed) : parsed; + if (isRecord(unwrapped)) return unwrapped; + } catch { + // ACP agents choose their own shell quoting. An unrecognized form keeps + // the MCP identity but leaves the input empty instead of misreporting it. + } + } + return undefined; +} + +/** + * Agents flatten injected MCP tools into model-facing function names with no + * shared convention (survey of the 2026-08 registry builds): Kilo and + * opencode use `t3-code_`, claude-acp and qwen `mcp__t3-code__`, + * Amp `mcp__t3_code__` (hyphens mangled), droid `t3-code___`, + * Copilot `t3-code-`, cline appends `: `. T3 always injects + * its server as "t3-code", and matches are additionally gated on the known + * T3 tool inventory, so the separator match can stay loose. + */ +const T3_MCP_TITLE_CALL = + /^(?:mcp[-_]{1,2})?t3[-_ ]?code[-_.:/ ]{1,3}(?[A-Za-z0-9][A-Za-z0-9_.-]*)(?::.*)?$/i; + +/** + * Gemini CLI titles injected MCP calls " ( MCP Server)" and + * qwen-code appends ": " to the same template; Auggie namespaces + * tool-first as "_t3-code". + */ +const T3_MCP_TITLE_SUFFIX_CALL = + /^(?[A-Za-z0-9][A-Za-z0-9_.-]*?)(?: \(t3[-_ ]?code MCP Server\)(?::|$)|[-_.]t3[-_ ]?code$)/i; + +/** + * glm-acp-agent and Kimi CLI register injected MCP tools under their bare + * names; Kimi additionally appends ": ". Safe only because the + * match is gated on the known T3 tool inventory. + */ +const T3_MCP_BARE_TITLE_CALL = /^(?[A-Za-z0-9_]+)(?::\s|$)/; + +/** + * Best-effort recovery of MCP identity from a generic ACP tool call. + * + * ACP has no typed MCP tool-call item, so agents surface MCP calls in + * agent-specific shapes: codex-acp tags execute calls with + * `rawInput.server`/`rawInput.tool`, while agents on T3's terminal fallback + * run the `acp-mcp-call ` CLI through their command or an embedded + * client terminal. Recovered identity lets the projection render the same + * branded MCP item that native providers produce. + */ +export function extractMcpToolCallIdentity( + toolCall: AcpToolCallState, + options?: { + /** Command lines of client terminals embedded in this tool call. */ + readonly embeddedTerminalCommands?: ReadonlyArray; + }, +): AcpMcpToolCallIdentity | undefined { + const rawInput = isRecord(toolCall.data.rawInput) ? toolCall.data.rawInput : undefined; + const meta = isRecord(toolCall.data.meta) ? toolCall.data.meta : undefined; + const server = typeof rawInput?.server === "string" ? rawInput.server.trim() : ""; + const tool = typeof rawInput?.tool === "string" ? rawInput.tool.trim() : ""; + if (meta?.is_mcp_tool_call === true && server.length > 0 && tool.length > 0) { + return { server, tool }; + } + // Agents without tagged rawInput identify their MCP calls through _meta + // (goose, qwen, claude-acp) or only through the namespaced function name + // in the title. The verbatim wire title survives merges even when a later + // titleless or LLM-enriched update replaces the presentation title, so + // match those rather than the summarized state title. + const claudeCode = isRecord(meta?.claudeCode) ? meta.claudeCode : undefined; + const gooseToolCall = isRecord(meta?.goose) + ? isRecord(meta.goose.toolCall) + ? meta.goose.toolCall + : undefined + : undefined; + // qwen asserts the origin server explicitly, so any known tool suffix in + // its toolName identifies the call even under future prefix formats. + const metaServerId = typeof meta?.serverId === "string" ? meta.serverId.trim() : ""; + const metaToolName = typeof meta?.toolName === "string" ? meta.toolName.trim() : ""; + if (/^t3[-_ ]?code$/i.test(metaServerId) && metaToolName.length > 0) { + for (const knownTool of T3_MCP_TOOL_NAMES) { + const boundary = metaToolName.length - knownTool.length - 1; + if ( + metaToolName === knownTool || + (metaToolName.endsWith(knownTool) && + boundary >= 0 && + !/[A-Za-z0-9]/.test(metaToolName.charAt(boundary))) + ) { + return { server: "t3-code", tool: knownTool }; + } + } + } + const gooseExtension = + typeof gooseToolCall?.extensionName === "string" ? gooseToolCall.extensionName.trim() : ""; + const assertsForeignOrigin = + (metaServerId.length > 0 && !/^t3[-_ ]?code$/i.test(metaServerId)) || + (gooseExtension.length > 0 && !/^t3[-_ ]?code$/i.test(gooseExtension)); + // A foreign origin never brands as T3. qwen's serverId marks a real MCP + // server, but goose reports its built-in extensions (developer__shell, + // edits) the same way as user MCP servers, so goose stays unclassified and + // keeps its command and file-change projections. + if (assertsForeignOrigin) { + if (metaServerId.length === 0 || metaToolName.length === 0) return undefined; + const prefix = [`mcp__${metaServerId}__`, `mcp::${metaServerId}::`].find((prefix) => + metaToolName.startsWith(prefix), + ); + const tool = prefix === undefined ? metaToolName : metaToolName.slice(prefix.length); + return tool ? { server: metaServerId, tool } : undefined; + } + const candidates = [ + meta?.toolName, + claudeCode?.toolName, + gooseToolCall?.toolName, + toolCall.data.title, + ].filter((value): value is string => typeof value === "string"); + for (const candidate of candidates) { + const trimmed = candidate.trim(); + const qualified = /^mcp__(.+?)__(.+)$/i.exec(trimmed); + if (qualified?.[1] && qualified[2] && !/^t3[-_ ]?code$/i.test(qualified[1])) + return { server: qualified[1], tool: qualified[2] }; + const match = + T3_MCP_TITLE_CALL.exec(trimmed) ?? + T3_MCP_TITLE_SUFFIX_CALL.exec(trimmed) ?? + T3_MCP_BARE_TITLE_CALL.exec(trimmed); + const candidateTool = match?.groups?.tool; + if (candidateTool !== undefined && T3_MCP_TOOL_NAMES.has(candidateTool)) { + return { server: "t3-code", tool: candidateTool }; + } + } + const commands = [ + ...(toolCall.command === undefined ? [] : [toolCall.command]), + // pi-acp reports the exec command line only as the verbatim title. + ...(typeof toolCall.data.title === "string" ? [toolCall.data.title] : []), + ...(options?.embeddedTerminalCommands ?? []), + ]; + for (const command of commands) { + const match = ACP_MCP_FALLBACK_CALL.exec(command); + if (match?.[1] !== undefined) { + // The acp-mcp-call CLI exists only as T3's bridge fallback, so the + // server identity is T3's by construction. + const input = acpMcpFallbackInput(match[2]); + return { server: "t3-code", tool: match[1], ...(input === undefined ? {} : { input }) }; + } + } + return undefined; +} + +/** + * Terminal ids embedded in a raw tool_call update, captured before + * {@link resolveEmbeddedTerminalContent} rewrites them into plain text, so the + * projection can still resolve the terminal's command line. + */ +export function embeddedTerminalIdsFromSessionUpdate( + notification: EffectAcpSchema.SessionNotification, +): { readonly toolCallId: string; readonly terminalIds: ReadonlyArray } | undefined { + const update = notification.update; + if (update.sessionUpdate !== "tool_call" && update.sessionUpdate !== "tool_call_update") { + return undefined; + } + const terminalIds = (update.content ?? []) + .filter((entry) => entry.type === "terminal") + .map((entry) => entry.terminalId); + return terminalIds.length === 0 ? undefined : { toolCallId: update.toolCallId, terminalIds }; +} + +export function parsePermissionRequest( + params: EffectAcpSchema.RequestPermissionRequest, +): AcpPermissionRequest { + const toolCall = makeToolCallState( + { + toolCallId: params.toolCall.toolCallId, + title: params.toolCall.title, + kind: params.toolCall.kind, + status: params.toolCall.status, + rawInput: params.toolCall.rawInput, + rawOutput: params.toolCall.rawOutput, + content: params.toolCall.content, + locations: params.toolCall.locations, + }, + { fallbackStatus: "pending" }, + ); + const kind = normalizeToolKind(params.toolCall.kind) ?? "unknown"; + const detail = + toolCall?.command ?? + toolCall?.title ?? + toolCall?.detail ?? + (typeof params.sessionId === "string" ? `Session ${params.sessionId}` : undefined); + return { + kind, + ...(detail ? { detail } : {}), + ...(toolCall ? { toolCall } : {}), + }; +} + +export function sessionUpdateIsReplay(params: EffectAcpSchema.SessionNotification): boolean { + const meta = params._meta; + return isRecord(meta) && meta.isReplay === true; +} + +/** Replay chunks and substantive updates during session/load; not Grok keepalives. */ +export function sessionUpdateCountsAsLoadReplayActivity( + params: EffectAcpSchema.SessionNotification, + gatedSessionId?: string, +): boolean { + if (gatedSessionId !== undefined && params.sessionId !== gatedSessionId) { + return false; + } + if (sessionUpdateIsReplay(params)) return true; + const update = params.update; + switch (update.sessionUpdate) { + case "agent_message_chunk": + case "agent_thought_chunk": + return (acpContentBlockDisplayText(update.content)?.length ?? 0) > 0; + case "agent_message": + case "agent_thought": + return (update.content ?? []).some( + (content) => (acpContentBlockDisplayText(content)?.length ?? 0) > 0, + ); + case "tool_call": + case "tool_call_update": + case "plan": + case "plan_update": + case "plan_removed": + case "available_commands_update": + case "current_mode_update": + case "config_option_update": + case "session_info_update": + case "usage_update": + case "compaction_update": + case "compaction_summary_chunk": + return true; + default: + return false; + } +} + +export interface SessionLoadGate { + readonly active: boolean; + /** Only notifications for this session refresh load-replay idle activity. */ + readonly sessionId: string; + readonly lastActivityAtMillis: number | undefined; + readonly idleGap: Duration.Duration; + readonly initializeResult: EffectAcpSchema.InitializeResponse; + readonly candidateConfigOptions?: ReadonlyArray; +} + +export const waitForSessionLoadReplayIdle = (input: { + readonly gateRef: Ref.Ref>; +}): Effect.Effect => + Effect.gen(function* () { + const pollInterval = Duration.millis(25); + while (true) { + const gate = yield* Ref.get(input.gateRef); + if ( + Option.isSome(gate) && + gate.value.active && + gate.value.lastActivityAtMillis !== undefined + ) { + const idleGapMillis = Duration.toMillis(gate.value.idleGap); + const nowMillis = yield* Clock.currentTimeMillis; + if (nowMillis - gate.value.lastActivityAtMillis >= idleGapMillis) { + return syntheticLoadSessionResponseFromInitialize(gate.value.initializeResult); + } + } + yield* Effect.sleep(pollInterval); + } + }); + +/** + * Model state some agents (Grok) advertise in `initialize._meta.modelState`, before any + * session exists. Undefined when the agent does not advertise it or the shape is unknown. + */ +export function sessionModelStateFromInitialize( + initializeResult: EffectAcpSchema.InitializeResponse, +): EffectAcpSchema.SessionModelState | undefined { + const meta = initializeResult._meta; + const modelState = isRecord(meta) ? meta.modelState : undefined; + return isSessionModelState(modelState) ? modelState : undefined; +} + +export function syntheticLoadSessionResponseFromInitialize( + initializeResult: EffectAcpSchema.InitializeResponse, +): EffectAcpSchema.LoadSessionResponse { + const meta = initializeResult._meta; + const modeState = isRecord(meta) ? meta.modeState : undefined; + + const modes = isSessionModeState(modeState) ? modeState : undefined; + + return { + ...(modes ? { modes } : {}), + _meta: { + t3SessionLoadReady: "replay_idle", + }, + }; +} + +// The parsed AcpToolCallState already carries bounded content (see makeToolCallState / +// extractTextContentFromToolCallContent above), but the raw JSON-RPC notification is also +// threaded through as `rawPayload` for logging/debugging and ends up persisted on the +// runtime event. Substitute the same bounded `content`/`rawOutput` there so an oversized +// cumulative update cannot smuggle the unbounded buffer back in through the raw payload. +function boundToolCallRawPayload( + params: EffectAcpSchema.SessionNotification, + update: AcpToolCallUpdate, + toolCall: AcpToolCallState, +): unknown { + const boundedContent = toolCall.data.content; + const boundedRawOutput = toolCall.data.rawOutput; + const contentBounded = update.content !== undefined && boundedContent !== update.content; + const rawOutputBounded = update.rawOutput !== undefined && boundedRawOutput !== update.rawOutput; + if (!contentBounded && !rawOutputBounded) { + return params; + } + return { + ...params, + update: { + ...update, + ...(contentBounded ? { content: boundedContent } : {}), + ...(rawOutputBounded ? { rawOutput: boundedRawOutput } : {}), + }, + }; +} + +export function parseSessionUpdateEvent(params: EffectAcpSchema.SessionNotification): { + readonly modeId?: string; + readonly events: ReadonlyArray; +} { + const upd = params.update; + const events: Array = []; + let modeId: string | undefined; + + switch (upd.sessionUpdate) { + case "config_option_update": { + const modes = parseSessionModeState({ configOptions: upd.configOptions }); + if (modes !== undefined) { + modeId = modes.currentModeId; + events.push({ _tag: "ModeChanged", modeId }); + } + events.push({ + _tag: "ConfigOptionsUpdated", + configOptions: upd.configOptions, + rawPayload: params, + }); + break; + } + case "available_commands_update": { + events.push({ + _tag: "AvailableCommandsUpdated", + availableCommands: upd.availableCommands, + rawPayload: params, + }); + break; + } + case "current_mode_update": { + modeId = upd.currentModeId.trim(); + if (modeId) { + events.push({ + _tag: "ModeChanged", + modeId, + }); + } + break; + } + case "plan": { + const plan = upd.entries.map((entry, index) => ({ + step: entry.content.trim().length > 0 ? entry.content.trim() : `Step ${index + 1}`, + status: normalizePlanStepStatus(entry.status), + })); + if (plan.length > 0) { + events.push({ + _tag: "PlanUpdated", + payload: { + nativePlanId: "legacy", + kind: "items", + plan, + }, + rawPayload: params, + }); + } + break; + } + case "plan_update": { + const nativePlanId = upd.plan.planId.trim(); + if (nativePlanId.length === 0) { + break; + } + if (upd.plan.type === "items" && "entries" in upd.plan && Array.isArray(upd.plan.entries)) { + const entries: ReadonlyArray = upd.plan.entries; + const plan = entries.flatMap((entry, index) => { + if (!isRecord(entry) || typeof entry.content !== "string") return []; + const status = typeof entry.status === "string" ? entry.status : "pending"; + return [ + { + step: entry.content.trim().length > 0 ? entry.content.trim() : `Step ${index + 1}`, + status: normalizePlanStepStatus(status), + }, + ]; + }); + events.push({ + _tag: "PlanUpdated", + payload: { nativePlanId, kind: "items", plan }, + rawPayload: params, + }); + } else if ( + upd.plan.type === "markdown" && + "content" in upd.plan && + typeof upd.plan.content === "string" + ) { + events.push({ + _tag: "PlanUpdated", + payload: { nativePlanId, kind: "markdown", markdown: upd.plan.content }, + rawPayload: params, + }); + } else if ( + upd.plan.type === "file" && + "uri" in upd.plan && + typeof upd.plan.uri === "string" + ) { + events.push({ + _tag: "PlanUpdated", + payload: { nativePlanId, kind: "file", uri: upd.plan.uri }, + rawPayload: params, + }); + } else { + events.push({ + _tag: "PlanUpdated", + payload: { nativePlanId, kind: "unknown", contentType: upd.plan.type }, + rawPayload: params, + }); + } + break; + } + case "plan_removed": { + const nativePlanId = upd.planId.trim(); + if (nativePlanId.length > 0) { + events.push({ + _tag: "PlanUpdated", + payload: { nativePlanId, kind: "removed" }, + rawPayload: params, + }); + } + break; + } + case "tool_call": { + const toolCall = parseTypedToolCallState(upd, { + fallbackStatus: "pending", + }); + if (toolCall) { + events.push({ + _tag: "ToolCallUpdated", + toolCall, + rawPayload: boundToolCallRawPayload(params, upd, toolCall), + }); + } + break; + } + case "tool_call_update": { + const toolCall = parseTypedToolCallState(upd); + if (toolCall) { + events.push({ + _tag: "ToolCallUpdated", + toolCall, + rawPayload: boundToolCallRawPayload(params, upd, toolCall), + }); + } + break; + } + case "agent_message_chunk": { + const text = acpContentBlockDisplayText(upd.content); + if (text !== undefined && text.length > 0) { + events.push({ + _tag: "ContentDelta", + ...(upd.messageId ? { messageId: upd.messageId } : {}), + text, + rawPayload: params, + }); + } + break; + } + case "agent_thought_chunk": { + if (upd.content.type === "text" && upd.content.text.length > 0) { + events.push({ + _tag: "ThoughtDelta", + text: upd.content.text, + rawPayload: params, + }); + } + break; + } + case "compaction_update": { + const summary = (upd.summary ?? []) + .flatMap((content) => { + const text = acpContentBlockDisplayText(content); + return text === undefined || text.length === 0 ? [] : [text]; + }) + .join("\n"); + const status = + upd.status === "completed" + ? ("completed" as const) + : upd.status === "failed" || upd.status === "cancelled" + ? ("failed" as const) + : ("inProgress" as const); + const toolCall = makeToolCallState({ + toolCallId: `acp-compaction:${upd.compactionId}`, + title: "Compact context", + kind: "think", + status, + rawOutput: (upd.error ?? summary) || undefined, + _meta: upd._meta, + }); + if (toolCall !== undefined) { + events.push({ _tag: "ToolCallUpdated", toolCall, rawPayload: params }); + } + break; + } + case "compaction_summary_chunk": { + const text = acpContentBlockDisplayText(upd.content); + const toolCall = makeToolCallState({ + toolCallId: `acp-compaction:${upd.compactionId}`, + title: "Compact context", + kind: "think", + status: "inProgress", + ...(text === undefined ? {} : { rawOutput: text }), + _meta: upd._meta, + }); + if (toolCall !== undefined) { + events.push({ _tag: "ToolCallUpdated", toolCall, rawPayload: params }); + } + break; + } + case "state_update": { + if (upd.state === "idle" && upd.usage !== undefined && upd.usage !== null) { + events.push({ + _tag: "UsageUpdated", + usage: { usedTokens: upd.usage.totalTokens }, + rawPayload: params, + }); + } + break; + } + case "usage_update": { + const currency = upd.cost?.currency.trim().slice(0, 32); + events.push({ + _tag: "UsageUpdated", + usage: { + usedTokens: upd.used, + ...(upd.size > 0 ? { maxTokens: upd.size } : {}), + ...(upd.cost && currency ? { cost: { amount: upd.cost.amount, currency } } : {}), + }, + rawPayload: params, + }); + break; + } + case "session_info_update": { + const title = upd.title?.trim().slice(0, 1_024); + const updatedAt = upd.updatedAt?.trim().slice(0, 128); + events.push({ + _tag: "SessionInfoUpdated", + metadata: { + ...(upd.title === null ? { title: null } : title ? { title } : {}), + ...(upd.updatedAt === null ? { updatedAt: null } : updatedAt ? { updatedAt } : {}), + }, + rawPayload: params, + }); + break; + } + case "_t3_unknown": { + events.push({ + _tag: "UnknownUpdate", + updateType: boundedContentMetadata(upd.originalSessionUpdate, 128) || "unknown", + rawPayload: params, + }); + break; + } + default: + break; + } + + return { ...(modeId !== undefined ? { modeId } : {}), events }; +} diff --git a/packages/provider-acp/src/server/sessionConfig.test.ts b/packages/provider-acp/src/server/sessionConfig.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..8e208e88aa5cc19118f6dd82a1ae11b213f833a4 --- /dev/null +++ b/packages/provider-acp/src/server/sessionConfig.test.ts @@ -0,0 +1,263 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { ACP_SESSION_MODE_OPTION_ID, acpProviderOptionDescriptors } from "./sessionConfig.ts"; + +describe("acpProviderOptionDescriptors", () => { + it("maps non-model select options and excludes model and collaboration categories", () => { + const descriptors = acpProviderOptionDescriptors({ + configOptions: [ + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: "gpt-5.6-sol", + options: [{ value: "gpt-5.6-sol", name: "GPT-5.6-Sol" }], + }, + { + id: "collaboration_mode", + name: "Collaboration mode", + category: "collaboration_mode", + type: "select", + currentValue: "default", + options: [ + { value: "default", name: "Default" }, + { value: "plan", name: "Plan" }, + ], + }, + { + id: "reasoning_effort", + name: "Reasoning effort", + category: "thought_level", + type: "select", + currentValue: "medium", + options: [ + { value: "low", name: "Low" }, + { value: "medium", name: "Medium", description: "Balanced" }, + ], + }, + ], + modeState: undefined, + }); + + expect(descriptors).toEqual([ + { + id: "reasoning_effort", + label: "Reasoning effort", + type: "select", + options: [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium", description: "Balanced" }, + ], + currentValue: "medium", + }, + ]); + }); + + it("maps boolean session options", () => { + const descriptors = acpProviderOptionDescriptors({ + configOptions: [ + { + id: "fast_mode", + name: "Fast mode", + description: "Use lower-latency inference.", + type: "boolean", + currentValue: true, + }, + ], + modeState: undefined, + }); + + expect(descriptors).toEqual([ + { + id: "fast_mode", + label: "Fast mode", + description: "Use lower-latency inference.", + type: "boolean", + currentValue: true, + }, + ]); + }); + + it("flattens grouped select choices and drops empty or duplicate values", () => { + const descriptors = acpProviderOptionDescriptors({ + configOptions: [ + { + id: "sandbox", + name: "Sandbox", + type: "select", + currentValue: "workspace", + options: [ + { + groupId: "standard", + name: "Standard", + options: [ + { value: "workspace", name: "Workspace" }, + { value: " ", name: "Blank" }, + ], + }, + { + groupId: "extra", + name: "Extra", + options: [ + { value: "workspace", name: "Duplicate" }, + { value: "full", name: "Full" }, + ], + }, + ], + }, + ], + modeState: undefined, + }); + + expect(descriptors).toEqual([ + { + id: "sandbox", + label: "Sandbox", + type: "select", + options: [ + { id: "workspace", label: "Workspace" }, + { id: "full", label: "Full" }, + ], + currentValue: "workspace", + }, + ]); + }); + + it("synthesizes a mode descriptor for agents without a mode config option", () => { + const descriptors = acpProviderOptionDescriptors({ + configOptions: [], + modeState: { + currentModeId: "safe", + availableModes: [ + { id: "safe", name: "Safe" }, + { id: "yolo", name: "YOLO", description: "No approvals" }, + ], + }, + }); + + expect(descriptors).toEqual([ + { + id: ACP_SESSION_MODE_OPTION_ID, + label: "Mode", + description: "Session mode advertised by the ACP agent.", + type: "select", + options: [ + { id: "safe", label: "Safe" }, + { id: "yolo", label: "YOLO", description: "No approvals" }, + ], + currentValue: "safe", + }, + ]); + }); + + it("suppresses the synthetic mode descriptor when modes duplicate a config option", () => { + const descriptors = acpProviderOptionDescriptors({ + configOptions: [ + { + id: "thinking", + name: "Thinking", + category: "thought_level", + type: "select", + currentValue: "medium", + options: [ + { value: "off", name: "Thinking: off" }, + { value: "medium", name: "Thinking: medium" }, + ], + }, + ], + modeState: { + currentModeId: "medium", + availableModes: [ + { id: "off", name: "Thinking: off" }, + { id: "medium", name: "Thinking: medium" }, + ], + }, + }); + + expect(descriptors.map((descriptor) => descriptor.id)).toEqual(["thinking"]); + }); + + it("does not let an unrelated select option hide the session modes API", () => { + const descriptors = acpProviderOptionDescriptors({ + configOptions: [ + { + id: "unrelated", + name: "Unrelated", + type: "select", + currentValue: "safe", + options: [ + { value: "safe", name: "Safe" }, + { value: "yolo", name: "YOLO" }, + ], + }, + ], + modeState: { + currentModeId: "safe", + availableModes: [ + { id: "safe", name: "Safe" }, + { id: "yolo", name: "YOLO" }, + ], + }, + }); + + expect(descriptors.map((descriptor) => descriptor.id)).toEqual([ + "unrelated", + ACP_SESSION_MODE_OPTION_ID, + ]); + }); + + it("preserves canonical opaque option values and omits values the wire would mutate", () => { + const exactValue = "value:with:opaque-markers"; + const descriptors = acpProviderOptionDescriptors({ + configOptions: [ + { + id: "opaque", + name: "Opaque", + type: "select", + currentValue: exactValue, + options: [ + { value: exactValue, name: "Exact" }, + { value: " value with spaces ", name: "Would be trimmed" }, + { value: "x".repeat(257), name: "Too long" }, + ], + }, + ], + modeState: undefined, + }); + + expect(descriptors[0]).toMatchObject({ + id: "opaque", + currentValue: exactValue, + options: [{ id: exactValue, label: "Exact" }], + }); + }); + + it("suppresses the synthetic mode descriptor when a mode-category option exists", () => { + const descriptors = acpProviderOptionDescriptors({ + configOptions: [ + { + id: "mode", + name: "Mode", + category: "mode", + type: "select", + currentValue: "agent", + options: [ + { value: "read-only", name: "Read-only" }, + { value: "agent", name: "Agent" }, + ], + }, + ], + modeState: { + currentModeId: "agent", + availableModes: [ + { id: "read-only", name: "Read-only" }, + { id: "agent", name: "Agent" }, + { id: "agent-full-access", name: "Agent (full access)" }, + ], + }, + }); + + expect(descriptors.map((descriptor) => descriptor.id)).toEqual(["mode"]); + }); +}); diff --git a/packages/provider-acp/src/server/sessionConfig.ts b/packages/provider-acp/src/server/sessionConfig.ts new file mode 100644 index 0000000000000000000000000000000000000000..0d2a821762e511c385736fba149b7366a53bdce3 --- /dev/null +++ b/packages/provider-acp/src/server/sessionConfig.ts @@ -0,0 +1,153 @@ +import type { ProviderOptionChoice, ProviderOptionDescriptor } from "@t3tools/contracts"; +import type * as EffectAcpSchema from "effect-acp/compat"; + +import type { AcpSessionModeState } from "./runtimeModel.ts"; + +/** + * Maps ACP session configuration and session modes onto T3's provider option + * descriptors so the existing model-options UI can drive them. + * + * Model selection stays on the dedicated model picker: `category: "model"` + * options are excluded here because they already surface as models. Session + * modes advertised through the modes API (rather than a config option) are + * folded into one synthetic descriptor that the ACP adapter routes to + * `session/set_mode`. + */ + +/** Synthetic descriptor ID for agents that expose modes outside config options. */ +export const ACP_SESSION_MODE_OPTION_ID = "_t3/session-mode"; + +const MAX_OPTION_DESCRIPTORS = 16; +const MAX_OPTION_CHOICES = 64; +const MAX_TEXT_LENGTH = 256; +const MAX_DESCRIPTION_LENGTH = 1_024; + +const boundedText = (value: string | null | undefined, maximumLength: number): string => + (value ?? "").trim().slice(0, maximumLength); + +const boundedOpaqueValue = (value: string, maximumLength: number): string | undefined => + value.length > 0 && value === value.trim() && value.length <= maximumLength ? value : undefined; + +function flattenSelectChoices( + options: EffectAcpSchema.SessionConfigSelectOptions, +): ReadonlyArray { + return options.flatMap((candidate) => ("value" in candidate ? [candidate] : candidate.options)); +} + +function selectChoices( + candidates: ReadonlyArray<{ + readonly value: string; + readonly name: string; + readonly description?: string | null | undefined; + }>, +): ReadonlyArray { + const seen = new Set(); + const choices: Array = []; + for (const candidate of candidates) { + const id = boundedOpaqueValue(candidate.value, MAX_TEXT_LENGTH); + if (id === undefined || seen.has(id)) continue; + seen.add(id); + const description = boundedText(candidate.description, MAX_DESCRIPTION_LENGTH); + choices.push({ + id, + label: boundedText(candidate.name, MAX_TEXT_LENGTH) || id, + ...(description ? { description } : {}), + }); + if (choices.length === MAX_OPTION_CHOICES) break; + } + return choices; +} + +export function acpProviderOptionDescriptors(input: { + readonly configOptions: ReadonlyArray | null | undefined; + readonly modeState: AcpSessionModeState | undefined; +}): ReadonlyArray { + const descriptors: Array = []; + const seen = new Set(); + const mirroredModeChoiceSets: Array> = []; + let hasModeCategory = false; + + for (const option of input.configOptions ?? []) { + // "model" options surface as the model list; "collaboration_mode" options + // are driven by T3's own plan/build interaction mode in the ACP adapter. + if (option.category === "model" || option.category === "collaboration_mode") { + continue; + } + const id = boundedOpaqueValue(option.id, MAX_TEXT_LENGTH); + if (id === undefined || seen.has(id)) continue; + seen.add(id); + const description = boundedText(option.description, MAX_DESCRIPTION_LENGTH); + const base = { + id, + label: boundedText(option.name, MAX_TEXT_LENGTH) || id, + ...(description ? { description } : {}), + } as const; + if (option.type === "boolean") { + if (option.category === "mode") hasModeCategory = true; + descriptors.push({ + ...base, + type: "boolean", + currentValue: option.currentValue, + }); + if (descriptors.length === MAX_OPTION_DESCRIPTORS) return descriptors; + continue; + } + const choices = selectChoices(flattenSelectChoices(option.options)); + if (choices.length === 0) { + seen.delete(id); + continue; + } + if (option.category === "mode") hasModeCategory = true; + if (option.category === "thought_level") { + mirroredModeChoiceSets.push(new Set(choices.map((choice) => choice.id))); + } + const currentValue = option.currentValue; + descriptors.push({ + ...base, + type: "select", + options: choices, + ...(currentValue && choices.some((choice) => choice.id === currentValue) + ? { currentValue } + : {}), + }); + if (descriptors.length === MAX_OPTION_DESCRIPTORS) return descriptors; + } + + const modeState = input.modeState; + if (modeState !== undefined && !hasModeCategory) { + const choices = selectChoices( + modeState.availableModes.map((mode) => ({ + value: mode.id, + name: mode.name, + description: mode.description, + })), + ); + const currentValue = modeState.currentModeId; + // Some agents mirror one knob through both the modes API and a config + // option (codex-acp advertises its thinking levels as modes too). Skip + // the synthetic descriptor when an existing descriptor already exposes + // the same choice set, so the composer shows the knob once. + const duplicatesKnownModeDescriptor = mirroredModeChoiceSets.some( + (choiceIds) => + choiceIds.size === choices.length && choices.every((choice) => choiceIds.has(choice.id)), + ); + if ( + choices.length > 1 && + !duplicatesKnownModeDescriptor && + !seen.has(ACP_SESSION_MODE_OPTION_ID) + ) { + descriptors.push({ + id: ACP_SESSION_MODE_OPTION_ID, + label: "Mode", + description: "Session mode advertised by the ACP agent.", + type: "select", + options: choices, + ...(currentValue && choices.some((choice) => choice.id === currentValue) + ? { currentValue } + : {}), + }); + } + } + + return descriptors.slice(0, MAX_OPTION_DESCRIPTORS); +} diff --git a/packages/provider-acp/src/server/stderr.test.ts b/packages/provider-acp/src/server/stderr.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..f5f704f049967d774ee633b92e733b64bfb00f1a --- /dev/null +++ b/packages/provider-acp/src/server/stderr.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + ACP_STDERR_TAIL_MAX_CHARS, + appendAcpStderrTail, + sanitizeAcpStderrExcerpt, +} from "./stderr.ts"; + +describe("AcpStderr", () => { + it("keeps a bounded tail of stderr chunks", () => { + const prefix = "x".repeat(ACP_STDERR_TAIL_MAX_CHARS); + expect(appendAcpStderrTail(prefix, "abc")).toBe(`${prefix.slice(3)}abc`); + }); + + it("redacts home paths, pairing URLs, and tokens from stderr excerpts", () => { + const excerpt = sanitizeAcpStderrExcerpt( + [ + "Invalid project config at /Users/ada/.cursor/cli.json", + "Authorization: Bearer secret-token-value", + "Visit http://localhost:5733/pair#token=ABCDEF for pairing", + "key=sk-abcdefghijklmnopqrstuv", + ].join("\n"), + { HOME: "/Users/ada" }, + ); + + expect(excerpt).toContain("Invalid project config at ~/.cursor/cli.json"); + expect(excerpt).toContain("Bearer [redacted]"); + expect(excerpt).toContain("[pairing-url]"); + expect(excerpt).toContain("[redacted]"); + expect(excerpt).not.toContain("secret-token-value"); + expect(excerpt).not.toContain("ABCDEF"); + expect(excerpt).not.toContain("sk-abcdefghijklmnopqrstuv"); + }); + + it("redacts hyphenated OpenAI project keys and header credentials", () => { + const excerpt = sanitizeAcpStderrExcerpt( + [ + "openai=sk-proj-abcdefghijklmnopqrstuvwxyz012345", + "svc=sk-svcacct-abcdefghijklmnopqrstuvwxyz012345", + "anthropic=sk-ant-api03-abcdefghijklmnopqrstuvwxyz012345", + "Authorization: Basic dXNlcjpwYXNz", + "x-api-key: ant-api-key-value", + ].join("\n"), + ); + + expect(excerpt).toContain("[redacted]"); + expect(excerpt).toContain("Authorization: Basic [redacted]"); + expect(excerpt).toContain("x-api-key: [redacted]"); + expect(excerpt).not.toContain("sk-proj-"); + expect(excerpt).not.toContain("sk-svcacct-"); + expect(excerpt).not.toContain("sk-ant-api03-"); + expect(excerpt).not.toContain("abcdefghijklmnopqrstuvwxyz012345"); + expect(excerpt).not.toContain("dXNlcjpwYXNz"); + expect(excerpt).not.toContain("ant-api-key-value"); + }); +}); diff --git a/packages/provider-acp/src/server/stderr.ts b/packages/provider-acp/src/server/stderr.ts new file mode 100644 index 0000000000000000000000000000000000000000..52f68bb4c19a389cbd6f7a8f1e00a39dfea42430 --- /dev/null +++ b/packages/provider-acp/src/server/stderr.ts @@ -0,0 +1,38 @@ +// @effect-diagnostics nodeBuiltinImport:off -- The excerpt sanitizer masks the home directory, which only the Node os module can resolve. +import * as NodeOS from "node:os"; + +/** Last few KiB of ACP child stderr kept for startup / exit diagnostics. */ +export const ACP_STDERR_TAIL_MAX_CHARS = 4_096; + +const PAIRING_URL_PATTERN = /https?:\/\/[^\s]*\/pair#[^\s]*/gi; +const BEARER_TOKEN_PATTERN = /\bBearer\s+[A-Za-z0-9._\-+=/]+/gi; +const BASIC_AUTH_PATTERN = /\bAuthorization:\s*Basic\s+\S+/gi; +const API_KEY_HEADER_PATTERN = /\bx-api-key:\s*\S+/gi; +const SECRET_TOKEN_PATTERN = + /\b(?:sk-[A-Za-z0-9][A-Za-z0-9-]{7,}|ghp_[A-Za-z0-9]+|xox[a-zA-Z]-[A-Za-z0-9-]+)\b/g; + +export function appendAcpStderrTail(current: string, chunk: string): string { + const next = `${current}${chunk}`; + return next.length <= ACP_STDERR_TAIL_MAX_CHARS ? next : next.slice(-ACP_STDERR_TAIL_MAX_CHARS); +} + +/** Bounded, redacted excerpt safe to put on user-facing adapter errors. */ +export function sanitizeAcpStderrExcerpt( + text: string, + environment: NodeJS.ProcessEnv = process.env, +): string { + let result = text.replaceAll("\0", ""); + const homes = [environment.HOME, environment.USERPROFILE, NodeOS.homedir()].filter( + (value): value is string => typeof value === "string" && value.length > 1, + ); + for (const home of new Set(homes)) { + result = result.split(home).join("~"); + } + result = result + .replace(PAIRING_URL_PATTERN, "[pairing-url]") + .replace(BEARER_TOKEN_PATTERN, "Bearer [redacted]") + .replace(BASIC_AUTH_PATTERN, "Authorization: Basic [redacted]") + .replace(API_KEY_HEADER_PATTERN, "x-api-key: [redacted]") + .replace(SECRET_TOKEN_PATTERN, "[redacted]"); + return result.trim(); +} diff --git a/packages/provider-acp/tsconfig.json b/packages/provider-acp/tsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..374bac55202ddcad695ede1c567ec219a61e9c48 --- /dev/null +++ b/packages/provider-acp/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src"] +} diff --git a/packages/provider-core/src/server/ProviderHost.ts b/packages/provider-core/src/server/ProviderHost.ts index 318037cdcb3bf0b5e92bb65e9ff480e9c1fd4177..a8509b01e1309a3df6dd0a40c4a30f8870019155 100644 --- a/packages/provider-core/src/server/ProviderHost.ts +++ b/packages/provider-core/src/server/ProviderHost.ts @@ -32,6 +32,8 @@ export interface ProviderHostPaths { readonly stateDir: string; /** Scratch space for provider probes and generated helper files. */ readonly providerStatusCacheDir: string; + /** Where chat attachments are stored; agents that sandbox file reads must be granted it. */ + readonly attachmentsDir: string; } /** One opaque credential blob in the server's secret store. Only the provider decodes it. */ diff --git a/packages/provider-core/src/server/managedProvider.test.ts b/packages/provider-core/src/server/managedProvider.test.ts index 6d9e3827531bd58f600bcbda8028c16d6fcbd865..76f7fda46954106fb38c79396e6f52ee2a05d45e 100644 --- a/packages/provider-core/src/server/managedProvider.test.ts +++ b/packages/provider-core/src/server/managedProvider.test.ts @@ -109,6 +109,7 @@ function layerProviderHost(input: { baseDir: "/t3", stateDir: "/t3/userdata", providerStatusCacheDir: "/t3/caches", + attachmentsDir: "/t3/userdata/attachments", }, settings: input.settings ?? { get: Effect.succeed(DEFAULT_SERVER_SETTINGS), diff --git a/packages/provider-testing/src/host.ts b/packages/provider-testing/src/host.ts index eb1d07af6c8615b6a62c5831d0c629b3768cb16e..2436648579107c5786a4de4d2e584250789af239 100644 --- a/packages/provider-testing/src/host.ts +++ b/packages/provider-testing/src/host.ts @@ -42,7 +42,13 @@ export const layerTestProviderHost = ( const settings = options.settings ?? DEFAULT_SERVER_SETTINGS; const credentials = new Map(); return ProviderHost.ProviderHost.of({ - paths: { cwd: options.cwd ?? process.cwd(), baseDir, stateDir, providerStatusCacheDir }, + paths: { + cwd: options.cwd ?? process.cwd(), + baseDir, + stateDir, + providerStatusCacheDir, + attachmentsDir, + }, settings: { get: Effect.succeed(settings), changes: Stream.empty, diff --git a/packages/shared/package.json b/packages/shared/package.json index c5bca30542902a24907e24e49323dd2699275094..4942d700caddb3a58ad41b4084b56253375370db 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -466,6 +466,10 @@ "./atomicWrite": { "types": "./src/atomicWrite.ts", "import": "./src/atomicWrite.ts" + }, + "./mcpResponsePayloads": { + "types": "./src/mcpResponsePayloads.ts", + "import": "./src/mcpResponsePayloads.ts" } }, "scripts": { diff --git a/packages/shared/src/mcpResponsePayloads.ts b/packages/shared/src/mcpResponsePayloads.ts new file mode 100644 index 0000000000000000000000000000000000000000..d46ef8e73fe94ccdaa1956f8a015b23d4b118154 --- /dev/null +++ b/packages/shared/src/mcpResponsePayloads.ts @@ -0,0 +1,65 @@ +// MCP responses carry opaque JSON-RPC that callers relay verbatim. +// @effect-diagnostics preferSchemaOverJson:off +/** + * Reads the JSON-RPC payloads of a response from an MCP streamable-HTTP + * endpoint. Used on the ACP bridge fast path, so it imports only effect core. + * + * @module mcpResponsePayloads + */ +import * as Effect from "effect/Effect"; +import * as Stream from "effect/Stream"; + +async function* sseDataLines(response: Response): AsyncGenerator { + if (response.body === null) return; + const decoder = new TextDecoder(); + let buffered = ""; + for await (const chunk of response.body) { + buffered += decoder.decode(chunk as Uint8Array, { stream: true }); + let separatorIndex = buffered.search(/\n\n|\r\n\r\n/u); + while (separatorIndex !== -1) { + const rawEvent = buffered.slice(0, separatorIndex); + buffered = buffered.slice(separatorIndex).replace(/^(?:\r?\n){2}/u, ""); + const data = rawEvent + .split(/\r?\n/u) + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice("data:".length).trimStart()) + .join("\n"); + if (data.length > 0) yield data; + separatorIndex = buffered.search(/\n\n|\r\n\r\n/u); + } + } +} + +export const discardResponseBody = (response: Response): Effect.Effect => + Effect.promise(() => response.body?.cancel().catch(() => undefined) ?? Promise.resolve()); + +/** + * Every JSON-RPC payload carried by a response, in arrival order: nothing for + * notification acknowledgements, each SSE `data:` event as it streams in, or + * the single JSON body. + */ +export function responsePayloads( + response: Response, + onError: (cause: unknown) => E, +): Stream.Stream { + if (response.status === 202 || response.status === 204) { + return Stream.unwrap(discardResponseBody(response).pipe(Effect.as(Stream.empty))); + } + const contentType = response.headers.get("content-type") ?? ""; + if (contentType.includes("text/event-stream")) { + return Stream.fromAsyncIterable(sseDataLines(response), onError).pipe( + Stream.mapEffect((data) => Effect.try({ try: () => JSON.parse(data), catch: onError })), + ); + } + return Stream.unwrap( + Effect.tryPromise({ try: () => response.text(), catch: onError }).pipe( + Effect.flatMap((text) => + text.trim().length === 0 + ? Effect.succeed(Stream.empty) + : Effect.try({ try: () => JSON.parse(text) as unknown, catch: onError }).pipe( + Effect.map((payload) => Stream.fromIterable([payload])), + ), + ), + ), + ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 85e33ad5cabe2b5820fe500be6c861ff18574586..d0b0d970c12bd65948f79eb89f21724c066c975b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -600,6 +600,9 @@ importers: '@opencode-ai/sdk': specifier: ^1.3.15 version: 1.15.13 + '@t3tools/provider-acp': + specifier: workspace:* + version: link:../../packages/provider-acp '@t3tools/provider-core': specifier: workspace:* version: link:../../packages/provider-core @@ -1092,6 +1095,40 @@ importers: specifier: 1.0.0 version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + packages/provider-acp: + dependencies: + '@t3tools/contracts': + specifier: workspace:* + version: link:../contracts + '@t3tools/provider-core': + specifier: workspace:* + version: link:../provider-core + '@t3tools/shared': + specifier: workspace:* + version: link:../shared + diff: + specifier: 8.0.3 + version: 8.0.3 + effect: + specifier: 4.0.1 + version: 4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f) + devDependencies: + '@effect/platform-node': + specifier: 4.0.1 + version: 4.0.1(bufferutil@4.1.0)(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f))(redis@6.2.1)(utf-8-validate@6.0.6) + '@effect/vitest': + specifier: 4.0.1 + version: 4.0.1(patch_hash=359f6fb2f7b3ec145bb72208edb9034f02489791aa2491a55cdbd69bd56ee0d2)(@types/node@24.12.4)(@vitest/ui@5.0.1)(bufferutil@4.1.0)(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f))(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + '@types/node': + specifier: 24.12.4 + version: 24.12.4 + effect-acp: + specifier: workspace:* + version: link:../effect-acp + vite-plus: + specifier: 1.0.0 + version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + packages/provider-core: dependencies: '@t3tools/contracts': diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index 6c5a1707854f31218b41c55d6ce76f49c50f938e..40dfbc7c8d3cf62b23403640f992a3c9a3299083 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -31,6 +31,7 @@ const workspaceFiles = [ "packages/provider-core/package.json", "packages/provider-testing/package.json", "packages/provider-pi/package.json", + "packages/provider-acp/package.json", "packages/provider-cursor/package.json", "packages/provider-muse/package.json", "packages/provider-opencode/package.json",