| author | |
| committer | |
| log | 664bd5e524bb58b23ecab0f2e413fc2ecb4caf55 |
| tree | 4f41b951c26a296d6f1abba0bd04e00bb2cd7b73 |
| parent | b5e4f1e0b0c2c06f183ab6a89072bec21c8334c4 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>88 files changed, 18382 insertions(+), 18262 deletions(-)
apps/server/package.json+1| ... | @@ -38,6 +38,7 @@ | ... | @@ -38,6 +38,7 @@ |
| 38 | "@napi-rs/keyring": "^1.3.0", | 38 | "@napi-rs/keyring": "^1.3.0", |
| 39 | "@noble/hashes": "catalog:", | 39 | "@noble/hashes": "catalog:", |
| 40 | "@opencode-ai/sdk": "^1.3.15", | 40 | "@opencode-ai/sdk": "^1.3.15", |
| 41 | "@t3tools/provider-acp": "workspace:*", | ||
| 41 | "@t3tools/provider-core": "workspace:*", | 42 | "@t3tools/provider-core": "workspace:*", |
| 42 | "@t3tools/provider-cursor": "workspace:*", | 43 | "@t3tools/provider-cursor": "workspace:*", |
| 43 | "@t3tools/provider-muse": "workspace:*", | 44 | "@t3tools/provider-muse": "workspace:*", |
apps/server/scripts/record-grok-acp-replay-fixture.ts+5-8| ... | @@ -24,14 +24,15 @@ import * as Schema from "effect/Schema"; | ... | @@ -24,14 +24,15 @@ import * as Schema from "effect/Schema"; |
| 24 | import { ChildProcessSpawner } from "effect/process"; | 24 | import { ChildProcessSpawner } from "effect/process"; |
| 25 | import type * as EffectAcpProtocol from "effect-acp/protocol"; | 25 | import type * as EffectAcpProtocol from "effect-acp/protocol"; |
| 26 | 26 | ||
| 27 | import * as ServerConfig from "../src/config.ts"; | 27 | import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; |
| 28 | import { layerTestProviderHost } from "@t3tools/provider-testing/host"; | ||
| 28 | import { | 29 | import { |
| 29 | GROK_DEFAULT_INSTANCE_ID, | 30 | GROK_DEFAULT_INSTANCE_ID, |
| 30 | GROK_PROVIDER, | 31 | GROK_PROVIDER, |
| 31 | grokLaunchRuntimeMode, | 32 | grokLaunchRuntimeMode, |
| 32 | makeGrokAdapterV2, | 33 | makeGrokAdapterV2, |
| 33 | } from "../src/orchestration-v2/Adapters/GrokAdapterV2.ts"; | 34 | } from "../src/orchestration-v2/Adapters/GrokAdapterV2.ts"; |
| 34 | import { ACP_PROTOCOL } from "../src/orchestration-v2/Adapters/AcpAdapterV2.ts"; | 35 | import { ACP_PROTOCOL } from "@t3tools/provider-acp/server/adapter"; |
| 35 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; | 36 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; |
| 36 | import type { ProviderAdapterV2SessionRuntime } from "@t3tools/provider-core/server/ProviderAdapter"; | 37 | import type { ProviderAdapterV2SessionRuntime } from "@t3tools/provider-core/server/ProviderAdapter"; |
| 37 | import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; | 38 | import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; |
| ... | @@ -40,7 +41,6 @@ import { provideDeterministicTestRuntime } from "../src/orchestration-v2/testkit | ... | @@ -40,7 +41,6 @@ import { provideDeterministicTestRuntime } from "../src/orchestration-v2/testkit |
| 40 | import { ORCHESTRATOR_REPLAY_FIXTURES } from "../src/orchestration-v2/testkit/fixtures/index.ts"; | 41 | import { ORCHESTRATOR_REPLAY_FIXTURES } from "../src/orchestration-v2/testkit/fixtures/index.ts"; |
| 41 | import { materializeFixtureInput } from "../src/orchestration-v2/testkit/fixtures/shared.ts"; | 42 | import { materializeFixtureInput } from "../src/orchestration-v2/testkit/fixtures/shared.ts"; |
| 42 | import { runOrchestratorV2Scenario } from "../src/orchestration-v2/testkit/OrchestratorScenario.ts"; | 43 | import { runOrchestratorV2Scenario } from "../src/orchestration-v2/testkit/OrchestratorScenario.ts"; |
| 43 | import { makeReplayServerConfig } from "../src/orchestration-v2/testkit/ProviderReplayHarness.ts"; | ||
| 44 | import * as ProviderReplayHarness from "../src/orchestration-v2/testkit/ProviderReplayHarness.ts"; | 44 | import * as ProviderReplayHarness from "../src/orchestration-v2/testkit/ProviderReplayHarness.ts"; |
| 45 | import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace"; | 45 | import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace"; |
| 46 | import { makeGrokAcpRuntime } from "../src/provider/acp/GrokAcpSupport.ts"; | 46 | import { makeGrokAcpRuntime } from "../src/provider/acp/GrokAcpSupport.ts"; |
| ... | @@ -455,7 +455,7 @@ const recordScenario = Effect.fn("recordGrokScenario")(function* (fixtureName: s | ... | @@ -455,7 +455,7 @@ const recordScenario = Effect.fn("recordGrokScenario")(function* (fixtureName: s |
| 455 | crypto: yield* Crypto.Crypto, | 455 | crypto: yield* Crypto.Crypto, |
| 456 | fileSystem: yield* FileSystem.FileSystem, | 456 | fileSystem: yield* FileSystem.FileSystem, |
| 457 | idAllocator: yield* IdAllocator.IdAllocatorV2, | 457 | idAllocator: yield* IdAllocator.IdAllocatorV2, |
| 458 | serverConfig: yield* ServerConfig.ServerConfig, | 458 | host: yield* ProviderHost.ProviderHost, |
| 459 | selfInvocation: yield* resolveSelfInvocation(), | 459 | selfInvocation: yield* resolveSelfInvocation(), |
| 460 | continuationRequests: yield* ProviderContinuationRequests.ProviderContinuationRequests, | 460 | continuationRequests: yield* ProviderContinuationRequests.ProviderContinuationRequests, |
| 461 | // Production's runtime factory, with the protocol logger teeing raw lines. | 461 | // Production's runtime factory, with the protocol logger teeing raw lines. |
| ... | @@ -496,10 +496,7 @@ const recordScenario = Effect.fn("recordGrokScenario")(function* (fixtureName: s | ... | @@ -496,10 +496,7 @@ const recordScenario = Effect.fn("recordGrokScenario")(function* (fixtureName: s |
| 496 | ).pipe( | 496 | ).pipe( |
| 497 | Layer.provide( | 497 | Layer.provide( |
| 498 | Layer.mergeAll( | 498 | Layer.mergeAll( |
| 499 | Layer.effect( | 499 | layerTestProviderHost().pipe(Layer.provide(NodeServices.layer)), |
| 500 | ServerConfig.ServerConfig, | ||
| 501 | makeReplayServerConfig(`grok-record-${fixtureName}`), | ||
| 502 | ).pipe(Layer.provide(NodeServices.layer)), | ||
| 503 | NodeServices.layer, | 500 | NodeServices.layer, |
| 504 | IdAllocator.layer, | 501 | IdAllocator.layer, |
| 505 | ), | 502 | ), |
apps/server/src/mcp/AcpMcpOverAcpBridge.test.ts deleted-119| ... | @@ -1,119 +0,0 @@ | ||
| 1 | // The bridge intentionally treats MCP JSON-RPC messages as opaque JSON. | ||
| 2 | import { describe, expect, it } from "@effect/vitest"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Fiber from "effect/Fiber"; | ||
| 5 | |||
| 6 | import { makeAcpMcpOverAcpBridge } from "./AcpMcpOverAcpBridge.ts"; | ||
| 7 | |||
| 8 | describe("AcpMcpOverAcpBridge", () => { | ||
| 9 | it.effect("forwards authenticated MCP requests and closes the negotiated session", () => | ||
| 10 | Effect.gen(function* () { | ||
| 11 | const requests: Array<{ | ||
| 12 | readonly method: string; | ||
| 13 | readonly headers: Headers; | ||
| 14 | readonly body: unknown; | ||
| 15 | }> = []; | ||
| 16 | const bridge = yield* makeAcpMcpOverAcpBridge({ | ||
| 17 | endpoint: "http://127.0.0.1:1/mcp", | ||
| 18 | authorization: "Bearer bridge-test", | ||
| 19 | allocateConnectionId: Effect.succeed("connection-1"), | ||
| 20 | fetchImplementation: async (_url, init) => { | ||
| 21 | const method = init?.method ?? "GET"; | ||
| 22 | const headers = new Headers(init?.headers); | ||
| 23 | const body = init?.body === undefined ? null : JSON.parse(String(init.body)); | ||
| 24 | requests.push({ method, headers, body }); | ||
| 25 | if (method === "DELETE") return new Response(null, { status: 204 }); | ||
| 26 | if (body === null || typeof body !== "object") return new Response(null, { status: 202 }); | ||
| 27 | const request = body as { readonly id?: unknown; readonly method?: unknown }; | ||
| 28 | if (request.method === "notifications/initialized") { | ||
| 29 | return new Response(null, { status: 202 }); | ||
| 30 | } | ||
| 31 | return new Response( | ||
| 32 | JSON.stringify({ | ||
| 33 | jsonrpc: "2.0", | ||
| 34 | id: request.id, | ||
| 35 | result: | ||
| 36 | request.method === "initialize" | ||
| 37 | ? { protocolVersion: "2025-06-18", capabilities: {}, serverInfo: {} } | ||
| 38 | : { tools: [] }, | ||
| 39 | }), | ||
| 40 | { | ||
| 41 | headers: { "content-type": "application/json", "mcp-session-id": "session-42" }, | ||
| 42 | }, | ||
| 43 | ); | ||
| 44 | }, | ||
| 45 | }); | ||
| 46 | |||
| 47 | const connected = yield* bridge.connect({ serverId: "t3-code" }); | ||
| 48 | expect(connected).toEqual({ connectionId: "connection-1" }); | ||
| 49 | expect( | ||
| 50 | yield* bridge.message({ connectionId: connected.connectionId, method: "initialize" }), | ||
| 51 | ).toMatchObject({ protocolVersion: "2025-06-18" }); | ||
| 52 | expect( | ||
| 53 | yield* bridge.message({ connectionId: connected.connectionId, method: "tools/list" }), | ||
| 54 | ).toEqual({ tools: [] }); | ||
| 55 | yield* bridge.notification({ | ||
| 56 | connectionId: connected.connectionId, | ||
| 57 | method: "notifications/initialized", | ||
| 58 | }); | ||
| 59 | yield* bridge.disconnect({ connectionId: connected.connectionId }); | ||
| 60 | |||
| 61 | expect(requests[0]?.headers.get("authorization")).toBe("Bearer bridge-test"); | ||
| 62 | expect(requests[1]?.headers.get("mcp-session-id")).toBe("session-42"); | ||
| 63 | expect(requests[1]?.headers.get("mcp-protocol-version")).toBe("2025-06-18"); | ||
| 64 | expect(requests.at(-1)?.method).toBe("DELETE"); | ||
| 65 | expect(requests.at(-1)?.headers.get("mcp-session-id")).toBe("session-42"); | ||
| 66 | }), | ||
| 67 | ); | ||
| 68 | |||
| 69 | it.effect("aborts an in-flight HTTP request when the ACP call is interrupted", () => | ||
| 70 | Effect.gen(function* () { | ||
| 71 | const started = Promise.withResolvers<AbortSignal>(); | ||
| 72 | const bridge = yield* makeAcpMcpOverAcpBridge({ | ||
| 73 | endpoint: "http://127.0.0.1:1/mcp", | ||
| 74 | authorization: "Bearer bridge-test", | ||
| 75 | allocateConnectionId: Effect.succeed("connection-1"), | ||
| 76 | fetchImplementation: (_url, init) => { | ||
| 77 | if (!init?.signal) throw new Error("Missing cancellation signal"); | ||
| 78 | started.resolve(init.signal); | ||
| 79 | return new Promise<Response>(() => {}); | ||
| 80 | }, | ||
| 81 | }); | ||
| 82 | const connected = yield* bridge.connect({ serverId: "t3-code" }); | ||
| 83 | const request = yield* bridge | ||
| 84 | .message({ connectionId: connected.connectionId, method: "tools/list" }) | ||
| 85 | .pipe(Effect.forkChild); | ||
| 86 | const signal = yield* Effect.promise(() => started.promise); | ||
| 87 | yield* Fiber.interrupt(request); | ||
| 88 | expect(signal.aborted).toBe(true); | ||
| 89 | }).pipe(Effect.scoped), | ||
| 90 | ); | ||
| 91 | |||
| 92 | it.effect("rejects unknown servers, connections, and oversized messages", () => | ||
| 93 | Effect.gen(function* () { | ||
| 94 | const bridge = yield* makeAcpMcpOverAcpBridge({ | ||
| 95 | endpoint: "http://127.0.0.1:1/mcp", | ||
| 96 | authorization: "Bearer bridge-test", | ||
| 97 | allocateConnectionId: Effect.succeed("connection-1"), | ||
| 98 | fetchImplementation: () => Promise.resolve(new Response(null, { status: 202 })), | ||
| 99 | }); | ||
| 100 | |||
| 101 | expect(yield* bridge.connect({ serverId: "other" }).pipe(Effect.flip)).toMatchObject({ | ||
| 102 | _tag: "AcpMcpOverAcpError", | ||
| 103 | }); | ||
| 104 | expect( | ||
| 105 | yield* bridge.message({ connectionId: "missing", method: "tools/list" }).pipe(Effect.flip), | ||
| 106 | ).toMatchObject({ _tag: "AcpMcpOverAcpError" }); | ||
| 107 | |||
| 108 | const connected = yield* bridge.connect({ serverId: "t3-code" }); | ||
| 109 | const failure = yield* bridge | ||
| 110 | .message({ | ||
| 111 | connectionId: connected.connectionId, | ||
| 112 | method: "tools/call", | ||
| 113 | params: { payload: "x".repeat(8 * 1024 * 1024) }, | ||
| 114 | }) | ||
| 115 | .pipe(Effect.flip); | ||
| 116 | expect(failure.message).toContain("exceeds 8 MiB"); | ||
| 117 | }), | ||
| 118 | ); | ||
| 119 | }); | ||
apps/server/src/mcp/AcpMcpOverAcpBridge.ts deleted-241| ... | @@ -1,241 +0,0 @@ | ||
| 1 | // The bridge forwards opaque JSON-RPC values without interpreting their schemas. | ||
| 2 | // @effect-diagnostics preferSchemaOverJson:off | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Semaphore from "effect/Semaphore"; | ||
| 5 | import * as Stream from "effect/Stream"; | ||
| 6 | import type * as AcpSchema from "effect-acp/compat"; | ||
| 7 | |||
| 8 | import { responsePayloads } from "./AcpMcpStdioBridge.ts"; | ||
| 9 | |||
| 10 | const MAX_CONNECTIONS = 16; | ||
| 11 | const MAX_MESSAGE_BYTES = 8 * 1024 * 1024; | ||
| 12 | |||
| 13 | interface JsonRpcEnvelope { | ||
| 14 | readonly id?: unknown; | ||
| 15 | readonly method?: unknown; | ||
| 16 | readonly error?: unknown; | ||
| 17 | readonly result?: unknown; | ||
| 18 | } | ||
| 19 | |||
| 20 | interface Connection { | ||
| 21 | readonly mutex: Semaphore.Semaphore; | ||
| 22 | sessionId: string | null; | ||
| 23 | protocolVersion: string | null; | ||
| 24 | nextRequestId: number; | ||
| 25 | } | ||
| 26 | |||
| 27 | export class AcpMcpOverAcpError extends Error { | ||
| 28 | readonly _tag = "AcpMcpOverAcpError"; | ||
| 29 | override readonly name = "AcpMcpOverAcpError"; | ||
| 30 | } | ||
| 31 | |||
| 32 | const bridgeError = (cause: unknown): AcpMcpOverAcpError => | ||
| 33 | cause instanceof AcpMcpOverAcpError | ||
| 34 | ? cause | ||
| 35 | : new AcpMcpOverAcpError(cause instanceof Error ? cause.message : String(cause), { cause }); | ||
| 36 | |||
| 37 | const asEnvelope = (value: unknown): JsonRpcEnvelope | null => | ||
| 38 | typeof value === "object" && value !== null && !Array.isArray(value) | ||
| 39 | ? (value as JsonRpcEnvelope) | ||
| 40 | : null; | ||
| 41 | |||
| 42 | function protocolVersionOf(payload: unknown): string | null { | ||
| 43 | const result = asEnvelope(payload)?.result; | ||
| 44 | if (typeof result !== "object" || result === null || Array.isArray(result)) return null; | ||
| 45 | const protocolVersion = (result as { readonly protocolVersion?: unknown }).protocolVersion; | ||
| 46 | return typeof protocolVersion === "string" && protocolVersion.length > 0 ? protocolVersion : null; | ||
| 47 | } | ||
| 48 | |||
| 49 | function errorMessage(error: unknown): string { | ||
| 50 | if (typeof error !== "object" || error === null || Array.isArray(error)) { | ||
| 51 | return "The MCP server returned an error."; | ||
| 52 | } | ||
| 53 | const message = (error as { readonly message?: unknown }).message; | ||
| 54 | return typeof message === "string" && message.length > 0 | ||
| 55 | ? message | ||
| 56 | : "The MCP server returned an error."; | ||
| 57 | } | ||
| 58 | |||
| 59 | export interface AcpMcpOverAcpBridgeOptions { | ||
| 60 | readonly endpoint: string; | ||
| 61 | readonly authorization: string; | ||
| 62 | readonly allocateConnectionId: Effect.Effect<string>; | ||
| 63 | readonly fetchImplementation?: (url: string, init?: RequestInit) => Promise<Response>; | ||
| 64 | } | ||
| 65 | |||
| 66 | export interface AcpMcpOverAcpBridge { | ||
| 67 | readonly connect: ( | ||
| 68 | request: AcpSchema.ConnectMcpRequest, | ||
| 69 | ) => Effect.Effect<AcpSchema.ConnectMcpResponse, AcpMcpOverAcpError>; | ||
| 70 | readonly message: ( | ||
| 71 | request: AcpSchema.MessageMcpRequest, | ||
| 72 | ) => Effect.Effect<AcpSchema.MessageMcpResponse, AcpMcpOverAcpError>; | ||
| 73 | readonly notification: ( | ||
| 74 | request: AcpSchema.MessageMcpNotification, | ||
| 75 | ) => Effect.Effect<void, AcpMcpOverAcpError>; | ||
| 76 | readonly disconnect: ( | ||
| 77 | request: AcpSchema.DisconnectMcpRequest, | ||
| 78 | ) => Effect.Effect<AcpSchema.DisconnectMcpResponse, AcpMcpOverAcpError>; | ||
| 79 | readonly dispose: Effect.Effect<void>; | ||
| 80 | } | ||
| 81 | |||
| 82 | /** Bridges the unstable ACP transport to T3's authenticated streamable-HTTP MCP endpoint. */ | ||
| 83 | export const makeAcpMcpOverAcpBridge = Effect.fn("AcpMcpOverAcpBridge.make")(function* ( | ||
| 84 | options: AcpMcpOverAcpBridgeOptions, | ||
| 85 | ): Effect.fn.Return<AcpMcpOverAcpBridge> { | ||
| 86 | const fetchImplementation = options.fetchImplementation ?? fetch; | ||
| 87 | const connections = new Map<string, Connection>(); | ||
| 88 | |||
| 89 | const connectionFor = (connectionId: string): Effect.Effect<Connection, AcpMcpOverAcpError> => | ||
| 90 | Effect.suspend(() => { | ||
| 91 | const connection = connections.get(connectionId); | ||
| 92 | return connection === undefined | ||
| 93 | ? Effect.fail(new AcpMcpOverAcpError(`Unknown MCP-over-ACP connection "${connectionId}".`)) | ||
| 94 | : Effect.succeed(connection); | ||
| 95 | }); | ||
| 96 | |||
| 97 | const send = ( | ||
| 98 | connection: Connection, | ||
| 99 | message: unknown, | ||
| 100 | ): Effect.Effect<ReadonlyArray<unknown>, AcpMcpOverAcpError> => | ||
| 101 | connection.mutex.withPermits(1)( | ||
| 102 | Effect.gen(function* () { | ||
| 103 | const body = JSON.stringify(message); | ||
| 104 | if (Buffer.byteLength(body) > MAX_MESSAGE_BYTES) { | ||
| 105 | return yield* Effect.fail(new AcpMcpOverAcpError("MCP-over-ACP message exceeds 8 MiB.")); | ||
| 106 | } | ||
| 107 | const response = yield* Effect.tryPromise({ | ||
| 108 | try: (signal) => | ||
| 109 | fetchImplementation(options.endpoint, { | ||
| 110 | method: "POST", | ||
| 111 | signal, | ||
| 112 | headers: { | ||
| 113 | "content-type": "application/json", | ||
| 114 | accept: "application/json, text/event-stream", | ||
| 115 | authorization: options.authorization, | ||
| 116 | ...(connection.sessionId === null | ||
| 117 | ? {} | ||
| 118 | : { "mcp-session-id": connection.sessionId }), | ||
| 119 | ...(connection.protocolVersion === null | ||
| 120 | ? {} | ||
| 121 | : { "mcp-protocol-version": connection.protocolVersion }), | ||
| 122 | }, | ||
| 123 | body, | ||
| 124 | }), | ||
| 125 | catch: bridgeError, | ||
| 126 | }); | ||
| 127 | connection.sessionId = response.headers.get("mcp-session-id") ?? connection.sessionId; | ||
| 128 | if (!response.ok) { | ||
| 129 | yield* Effect.promise( | ||
| 130 | () => response.body?.cancel().catch(() => undefined) ?? Promise.resolve(), | ||
| 131 | ); | ||
| 132 | return yield* Effect.fail( | ||
| 133 | new AcpMcpOverAcpError(`T3 Code MCP endpoint responded with HTTP ${response.status}.`), | ||
| 134 | ); | ||
| 135 | } | ||
| 136 | const payloads = [...(yield* Stream.runCollect(responsePayloads(response)))]; | ||
| 137 | for (const payload of payloads) { | ||
| 138 | connection.protocolVersion = protocolVersionOf(payload) ?? connection.protocolVersion; | ||
| 139 | } | ||
| 140 | return payloads; | ||
| 141 | }).pipe(Effect.mapError(bridgeError)), | ||
| 142 | ); | ||
| 143 | |||
| 144 | const disconnect = ( | ||
| 145 | request: AcpSchema.DisconnectMcpRequest, | ||
| 146 | ): Effect.Effect<AcpSchema.DisconnectMcpResponse, AcpMcpOverAcpError> => | ||
| 147 | Effect.gen(function* () { | ||
| 148 | const connection = yield* connectionFor(request.connectionId); | ||
| 149 | connections.delete(request.connectionId); | ||
| 150 | const sessionId = connection.sessionId; | ||
| 151 | if (sessionId !== null) { | ||
| 152 | const response = yield* Effect.tryPromise({ | ||
| 153 | try: (signal) => | ||
| 154 | fetchImplementation(options.endpoint, { | ||
| 155 | method: "DELETE", | ||
| 156 | signal, | ||
| 157 | headers: { | ||
| 158 | authorization: options.authorization, | ||
| 159 | "mcp-session-id": sessionId, | ||
| 160 | ...(connection.protocolVersion === null | ||
| 161 | ? {} | ||
| 162 | : { "mcp-protocol-version": connection.protocolVersion }), | ||
| 163 | }, | ||
| 164 | }), | ||
| 165 | catch: bridgeError, | ||
| 166 | }); | ||
| 167 | if (!response.ok && response.status !== 404) { | ||
| 168 | return yield* Effect.fail( | ||
| 169 | new AcpMcpOverAcpError( | ||
| 170 | `T3 Code MCP endpoint rejected disconnect with HTTP ${response.status}.`, | ||
| 171 | ), | ||
| 172 | ); | ||
| 173 | } | ||
| 174 | yield* Effect.promise( | ||
| 175 | () => response.body?.cancel().catch(() => undefined) ?? Promise.resolve(), | ||
| 176 | ); | ||
| 177 | } | ||
| 178 | return {}; | ||
| 179 | }); | ||
| 180 | |||
| 181 | return { | ||
| 182 | connect: (request) => | ||
| 183 | Effect.gen(function* () { | ||
| 184 | if (request.serverId !== "t3-code") { | ||
| 185 | return yield* Effect.fail( | ||
| 186 | new AcpMcpOverAcpError(`Unknown ACP MCP server "${request.serverId}".`), | ||
| 187 | ); | ||
| 188 | } | ||
| 189 | if (connections.size >= MAX_CONNECTIONS) { | ||
| 190 | return yield* Effect.fail(new AcpMcpOverAcpError("Too many MCP-over-ACP connections.")); | ||
| 191 | } | ||
| 192 | const connectionId = yield* options.allocateConnectionId; | ||
| 193 | connections.set(connectionId, { | ||
| 194 | mutex: yield* Semaphore.make(1), | ||
| 195 | sessionId: null, | ||
| 196 | protocolVersion: null, | ||
| 197 | nextRequestId: 0, | ||
| 198 | }); | ||
| 199 | return { connectionId }; | ||
| 200 | }), | ||
| 201 | message: (request) => | ||
| 202 | Effect.gen(function* () { | ||
| 203 | const connection = yield* connectionFor(request.connectionId); | ||
| 204 | const id = ++connection.nextRequestId; | ||
| 205 | const payloads = yield* send(connection, { | ||
| 206 | jsonrpc: "2.0", | ||
| 207 | id, | ||
| 208 | method: request.method, | ||
| 209 | ...(request.params == null ? {} : { params: request.params }), | ||
| 210 | }); | ||
| 211 | const response = payloads.find((payload) => asEnvelope(payload)?.id === id); | ||
| 212 | const envelope = asEnvelope(response); | ||
| 213 | if (envelope === null) { | ||
| 214 | return yield* Effect.fail( | ||
| 215 | new AcpMcpOverAcpError("MCP server did not return a matching response."), | ||
| 216 | ); | ||
| 217 | } | ||
| 218 | if (envelope.error !== undefined) { | ||
| 219 | return yield* Effect.fail(new AcpMcpOverAcpError(errorMessage(envelope.error))); | ||
| 220 | } | ||
| 221 | return (envelope.result ?? null) as AcpSchema.MessageMcpResponse; | ||
| 222 | }), | ||
| 223 | notification: (request) => | ||
| 224 | Effect.gen(function* () { | ||
| 225 | const connection = yield* connectionFor(request.connectionId); | ||
| 226 | yield* send(connection, { | ||
| 227 | jsonrpc: "2.0", | ||
| 228 | method: request.method, | ||
| 229 | ...(request.params == null ? {} : { params: request.params }), | ||
| 230 | }); | ||
| 231 | }), | ||
| 232 | disconnect, | ||
| 233 | dispose: Effect.suspend(() => | ||
| 234 | Effect.forEach( | ||
| 235 | [...connections.keys()], | ||
| 236 | (connectionId) => disconnect({ connectionId }).pipe(Effect.ignore), | ||
| 237 | { discard: true }, | ||
| 238 | ), | ||
| 239 | ), | ||
| 240 | }; | ||
| 241 | }); | ||
apps/server/src/mcp/AcpMcpStdioBridge.ts+3-54| ... | @@ -7,6 +7,7 @@ import * as Cause from "effect/Cause"; | ... | @@ -7,6 +7,7 @@ import * as Cause from "effect/Cause"; |
| 7 | import * as Effect from "effect/Effect"; | 7 | import * as Effect from "effect/Effect"; |
| 8 | import * as Fiber from "effect/Fiber"; | 8 | import * as Fiber from "effect/Fiber"; |
| 9 | import * as Stream from "effect/Stream"; | 9 | import * as Stream from "effect/Stream"; |
| 10 | import { discardResponseBody, responsePayloads } from "@t3tools/shared/mcpResponsePayloads"; | ||
| 10 | 11 | ||
| 11 | /** | 12 | /** |
| 12 | * Stdio-to-HTTP bridge for T3's MCP endpoint. | 13 | * Stdio-to-HTTP bridge for T3's MCP endpoint. |
| ... | @@ -63,58 +64,6 @@ function protocolVersionOf(entry: unknown): string | null { | ... | @@ -63,58 +64,6 @@ function protocolVersionOf(entry: unknown): string | null { |
| 63 | return typeof version === "string" && version.length > 0 ? version : null; | 64 | return typeof version === "string" && version.length > 0 ? version : null; |
| 64 | } | 65 | } |
| 65 | 66 | ||
| 66 | async function* sseDataLines(response: Response): AsyncGenerator<string> { | ||
| 67 | if (response.body === null) return; | ||
| 68 | const decoder = new TextDecoder(); | ||
| 69 | let buffered = ""; | ||
| 70 | for await (const chunk of response.body) { | ||
| 71 | buffered += decoder.decode(chunk as Uint8Array, { stream: true }); | ||
| 72 | let separatorIndex = buffered.search(/\n\n|\r\n\r\n/u); | ||
| 73 | while (separatorIndex !== -1) { | ||
| 74 | const rawEvent = buffered.slice(0, separatorIndex); | ||
| 75 | buffered = buffered.slice(separatorIndex).replace(/^(?:\r?\n){2}/u, ""); | ||
| 76 | const data = rawEvent | ||
| 77 | .split(/\r?\n/u) | ||
| 78 | .filter((line) => line.startsWith("data:")) | ||
| 79 | .map((line) => line.slice("data:".length).trimStart()) | ||
| 80 | .join("\n"); | ||
| 81 | if (data.length > 0) yield data; | ||
| 82 | separatorIndex = buffered.search(/\n\n|\r\n\r\n/u); | ||
| 83 | } | ||
| 84 | } | ||
| 85 | } | ||
| 86 | |||
| 87 | const discardResponseBody = (response: Response): Effect.Effect<void> => | ||
| 88 | Effect.promise(() => response.body?.cancel().catch(() => undefined) ?? Promise.resolve()); | ||
| 89 | |||
| 90 | /** | ||
| 91 | * Every JSON-RPC payload carried by a response, in arrival order: nothing for | ||
| 92 | * notification acknowledgements, each SSE `data:` event as it streams in, or | ||
| 93 | * the single JSON body. | ||
| 94 | */ | ||
| 95 | export function responsePayloads(response: Response): Stream.Stream<unknown, AcpMcpBridgeError> { | ||
| 96 | if (response.status === 202 || response.status === 204) { | ||
| 97 | return Stream.unwrap(discardResponseBody(response).pipe(Effect.as(Stream.empty))); | ||
| 98 | } | ||
| 99 | const contentType = response.headers.get("content-type") ?? ""; | ||
| 100 | if (contentType.includes("text/event-stream")) { | ||
| 101 | return Stream.fromAsyncIterable(sseDataLines(response), bridgeError).pipe( | ||
| 102 | Stream.mapEffect((data) => Effect.try({ try: () => JSON.parse(data), catch: bridgeError })), | ||
| 103 | ); | ||
| 104 | } | ||
| 105 | return Stream.unwrap( | ||
| 106 | Effect.tryPromise({ try: () => response.text(), catch: bridgeError }).pipe( | ||
| 107 | Effect.flatMap((text) => | ||
| 108 | text.trim().length === 0 | ||
| 109 | ? Effect.succeed(Stream.empty) | ||
| 110 | : Effect.try({ try: () => JSON.parse(text) as unknown, catch: bridgeError }).pipe( | ||
| 111 | Effect.map((payload) => Stream.fromIterable([payload])), | ||
| 112 | ), | ||
| 113 | ), | ||
| 114 | ), | ||
| 115 | ); | ||
| 116 | } | ||
| 117 | |||
| 118 | export interface AcpMcpToolCallOptions { | 67 | export interface AcpMcpToolCallOptions { |
| 119 | readonly endpoint: string; | 68 | readonly endpoint: string; |
| 120 | readonly authorization: string; | 69 | readonly authorization: string; |
| ... | @@ -164,7 +113,7 @@ export function callAcpMcpTool( | ... | @@ -164,7 +113,7 @@ export function callAcpMcpTool( |
| 164 | new AcpMcpBridgeError(`T3 Code MCP endpoint responded with HTTP ${response.status}.`), | 113 | new AcpMcpBridgeError(`T3 Code MCP endpoint responded with HTTP ${response.status}.`), |
| 165 | ); | 114 | ); |
| 166 | } | 115 | } |
| 167 | const payloads = yield* Stream.runCollect(responsePayloads(response)); | 116 | const payloads = yield* Stream.runCollect(responsePayloads(response, bridgeError)); |
| 168 | for (const payload of payloads) { | 117 | for (const payload of payloads) { |
| 169 | protocolVersion = protocolVersionOf(payload) ?? protocolVersion; | 118 | protocolVersion = protocolVersionOf(payload) ?? protocolVersion; |
| 170 | } | 119 | } |
| ... | @@ -271,7 +220,7 @@ export function runAcpMcpStdioBridge(options: AcpMcpStdioBridgeOptions): Effect. | ... | @@ -271,7 +220,7 @@ export function runAcpMcpStdioBridge(options: AcpMcpStdioBridgeOptions): Effect. |
| 271 | } | 220 | } |
| 272 | return yield* discardResponseBody(response); | 221 | return yield* discardResponseBody(response); |
| 273 | } | 222 | } |
| 274 | yield* Stream.runForEach(responsePayloads(response), handleServerPayload); | 223 | yield* Stream.runForEach(responsePayloads(response, bridgeError), handleServerPayload); |
| 275 | }).pipe( | 224 | }).pipe( |
| 276 | Effect.catchCause((cause) => { | 225 | Effect.catchCause((cause) => { |
| 277 | if (envelope.id === undefined) return Effect.void; | 226 | if (envelope.id === undefined) return Effect.void; |
apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts+175-176| ... | @@ -54,9 +54,10 @@ import * as EffectAcpErrors from "effect-acp/errors"; | ... | @@ -54,9 +54,10 @@ import * as EffectAcpErrors from "effect-acp/errors"; |
| 54 | import type * as EffectAcpProtocol from "effect-acp/protocol"; | 54 | import type * as EffectAcpProtocol from "effect-acp/protocol"; |
| 55 | import type * as EffectAcpSchema from "effect-acp/compat"; | 55 | import type * as EffectAcpSchema from "effect-acp/compat"; |
| 56 | 56 | ||
| 57 | import * as ServerConfig from "../../config.ts"; | 57 | import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; |
| 58 | import { layerTestProviderHost } from "@t3tools/provider-testing/host"; | ||
| 58 | import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; | 59 | import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; |
| 59 | import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts"; | 60 | import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; |
| 60 | import { | 61 | import { |
| 61 | extractXAiAcpSubagentEndNotice, | 62 | extractXAiAcpSubagentEndNotice, |
| 62 | extractXAiAcpSubagentUpdate, | 63 | extractXAiAcpSubagentUpdate, |
| ... | @@ -90,7 +91,7 @@ import { | ... | @@ -90,7 +91,7 @@ import { |
| 90 | type AcpAdapterV2Flavor, | 91 | type AcpAdapterV2Flavor, |
| 91 | type AcpAdapterV2RuntimeInput, | 92 | type AcpAdapterV2RuntimeInput, |
| 92 | type AcpAdapterV2SubagentUpdate, | 93 | type AcpAdapterV2SubagentUpdate, |
| 93 | } from "./AcpAdapterV2.ts"; | 94 | } from "@t3tools/provider-acp/server/adapter"; |
| 94 | 95 | ||
| 95 | import { makeGrokAdapterV2 } from "./GrokAdapterV2.ts"; | 96 | import { makeGrokAdapterV2 } from "./GrokAdapterV2.ts"; |
| 96 | import { | 97 | import { |
| ... | @@ -100,11 +101,9 @@ import { | ... | @@ -100,11 +101,9 @@ import { |
| 100 | 101 | ||
| 101 | const DEFAULT_GROK_SETTINGS = Schema.decodeSync(GrokSettings)({}); | 102 | const DEFAULT_GROK_SETTINGS = Schema.decodeSync(GrokSettings)({}); |
| 102 | 103 | ||
| 103 | const layerServerConfig = ServerConfig.layerTest(process.cwd(), { | 104 | const layerHost = layerTestProviderHost().pipe(Layer.provide(NodeServices.layer)); |
| 104 | prefix: "t3-acp-v2-adapter-", | ||
| 105 | }).pipe(Layer.provide(NodeServices.layer)); | ||
| 106 | 105 | ||
| 107 | const layerTest = Layer.mergeAll(NodeServices.layer, IdAllocator.layer, layerServerConfig); | 106 | const layerTest = Layer.mergeAll(NodeServices.layer, IdAllocator.layer, layerHost); |
| 108 | const ACP_TEST_DRIVER = ProviderDriverKind.make("acp-test"); | 107 | const ACP_TEST_DRIVER = ProviderDriverKind.make("acp-test"); |
| 109 | const decodeUnknownJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)); | 108 | const decodeUnknownJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)); |
| 110 | 109 | ||
| ... | @@ -616,7 +615,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -616,7 +615,7 @@ describe("AcpAdapterV2", () => { |
| 616 | instanceId, | 615 | instanceId, |
| 617 | fileSystem: yield* FileSystem.FileSystem, | 616 | fileSystem: yield* FileSystem.FileSystem, |
| 618 | idAllocator: yield* IdAllocator.IdAllocatorV2, | 617 | idAllocator: yield* IdAllocator.IdAllocatorV2, |
| 619 | serverConfig: yield* ServerConfig.ServerConfig, | 618 | host: yield* ProviderHost.ProviderHost, |
| 620 | selfInvocation: yield* resolveSelfInvocation(), | 619 | selfInvocation: yield* resolveSelfInvocation(), |
| 621 | flavor: { | 620 | flavor: { |
| 622 | driver: ProviderDriverKind.make("acpRegistry"), | 621 | driver: ProviderDriverKind.make("acpRegistry"), |
| ... | @@ -713,7 +712,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -713,7 +712,7 @@ describe("AcpAdapterV2", () => { |
| 713 | const fileSystem = yield* FileSystem.FileSystem; | 712 | const fileSystem = yield* FileSystem.FileSystem; |
| 714 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 713 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 715 | const path = yield* Path.Path; | 714 | const path = yield* Path.Path; |
| 716 | const serverConfig = yield* ServerConfig.ServerConfig; | 715 | const host = yield* ProviderHost.ProviderHost; |
| 717 | const selfInvocation = yield* resolveSelfInvocation().pipe( | 716 | const selfInvocation = yield* resolveSelfInvocation().pipe( |
| 718 | Effect.provideService(HostProcessIsExecutable, true), | 717 | Effect.provideService(HostProcessIsExecutable, true), |
| 719 | ); | 718 | ); |
| ... | @@ -753,7 +752,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -753,7 +752,7 @@ describe("AcpAdapterV2", () => { |
| 753 | }, | 752 | }, |
| 754 | fileSystem, | 753 | fileSystem, |
| 755 | idAllocator, | 754 | idAllocator, |
| 756 | serverConfig, | 755 | host, |
| 757 | selfInvocation, | 756 | selfInvocation, |
| 758 | }); | 757 | }); |
| 759 | const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | 758 | const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ |
| ... | @@ -786,7 +785,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -786,7 +785,7 @@ describe("AcpAdapterV2", () => { |
| 786 | const fileSystem = yield* FileSystem.FileSystem; | 785 | const fileSystem = yield* FileSystem.FileSystem; |
| 787 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 786 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 788 | const path = yield* Path.Path; | 787 | const path = yield* Path.Path; |
| 789 | const serverConfig = yield* ServerConfig.ServerConfig; | 788 | const host = yield* ProviderHost.ProviderHost; |
| 790 | const selfInvocation = yield* resolveSelfInvocation(); | 789 | const selfInvocation = yield* resolveSelfInvocation(); |
| 791 | const mockAgentPath = yield* path.fromFileUrl( | 790 | const mockAgentPath = yield* path.fromFileUrl( |
| 792 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 791 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -816,7 +815,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -816,7 +815,7 @@ describe("AcpAdapterV2", () => { |
| 816 | }, | 815 | }, |
| 817 | fileSystem, | 816 | fileSystem, |
| 818 | idAllocator, | 817 | idAllocator, |
| 819 | serverConfig, | 818 | host, |
| 820 | selfInvocation, | 819 | selfInvocation, |
| 821 | }); | 820 | }); |
| 822 | const policy = (interactionMode: "default" | "plan") => | 821 | const policy = (interactionMode: "default" | "plan") => |
| ... | @@ -913,7 +912,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -913,7 +912,7 @@ describe("AcpAdapterV2", () => { |
| 913 | const fileSystem = yield* FileSystem.FileSystem; | 912 | const fileSystem = yield* FileSystem.FileSystem; |
| 914 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 913 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 915 | const path = yield* Path.Path; | 914 | const path = yield* Path.Path; |
| 916 | const serverConfig = yield* ServerConfig.ServerConfig; | 915 | const host = yield* ProviderHost.ProviderHost; |
| 917 | const selfInvocation = yield* resolveSelfInvocation(); | 916 | const selfInvocation = yield* resolveSelfInvocation(); |
| 918 | const mockAgentPath = yield* path.fromFileUrl( | 917 | const mockAgentPath = yield* path.fromFileUrl( |
| 919 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 918 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -941,7 +940,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -941,7 +940,7 @@ describe("AcpAdapterV2", () => { |
| 941 | }, | 940 | }, |
| 942 | fileSystem, | 941 | fileSystem, |
| 943 | idAllocator, | 942 | idAllocator, |
| 944 | serverConfig, | 943 | host, |
| 945 | selfInvocation, | 944 | selfInvocation, |
| 946 | }); | 945 | }); |
| 947 | const threadId = ThreadId.make("thread-acp-v2-plan-replay-boundary"); | 946 | const threadId = ThreadId.make("thread-acp-v2-plan-replay-boundary"); |
| ... | @@ -1049,7 +1048,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -1049,7 +1048,7 @@ describe("AcpAdapterV2", () => { |
| 1049 | crypto: yield* Crypto.Crypto, | 1048 | crypto: yield* Crypto.Crypto, |
| 1050 | fileSystem: yield* FileSystem.FileSystem, | 1049 | fileSystem: yield* FileSystem.FileSystem, |
| 1051 | idAllocator: yield* IdAllocator.IdAllocatorV2, | 1050 | idAllocator: yield* IdAllocator.IdAllocatorV2, |
| 1052 | serverConfig: yield* ServerConfig.ServerConfig, | 1051 | host: yield* ProviderHost.ProviderHost, |
| 1053 | selfInvocation: yield* resolveSelfInvocation(), | 1052 | selfInvocation: yield* resolveSelfInvocation(), |
| 1054 | flavor: { | 1053 | flavor: { |
| 1055 | driver: ACP_TEST_DRIVER, | 1054 | driver: ACP_TEST_DRIVER, |
| ... | @@ -1195,7 +1194,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -1195,7 +1194,7 @@ describe("AcpAdapterV2", () => { |
| 1195 | crypto: yield* Crypto.Crypto, | 1194 | crypto: yield* Crypto.Crypto, |
| 1196 | fileSystem: yield* FileSystem.FileSystem, | 1195 | fileSystem: yield* FileSystem.FileSystem, |
| 1197 | idAllocator, | 1196 | idAllocator, |
| 1198 | serverConfig: yield* ServerConfig.ServerConfig, | 1197 | host: yield* ProviderHost.ProviderHost, |
| 1199 | selfInvocation: yield* resolveSelfInvocation(), | 1198 | selfInvocation: yield* resolveSelfInvocation(), |
| 1200 | flavor: { | 1199 | flavor: { |
| 1201 | driver: ACP_TEST_DRIVER, | 1200 | driver: ACP_TEST_DRIVER, |
| ... | @@ -1515,7 +1514,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -1515,7 +1514,7 @@ describe("AcpAdapterV2", () => { |
| 1515 | const fileSystem = yield* FileSystem.FileSystem; | 1514 | const fileSystem = yield* FileSystem.FileSystem; |
| 1516 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 1515 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 1517 | const path = yield* Path.Path; | 1516 | const path = yield* Path.Path; |
| 1518 | const serverConfig = yield* ServerConfig.ServerConfig; | 1517 | const host = yield* ProviderHost.ProviderHost; |
| 1519 | const selfInvocation = yield* resolveSelfInvocation(); | 1518 | const selfInvocation = yield* resolveSelfInvocation(); |
| 1520 | const mockAgentPath = yield* path.fromFileUrl( | 1519 | const mockAgentPath = yield* path.fromFileUrl( |
| 1521 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 1520 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -1544,7 +1543,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -1544,7 +1543,7 @@ describe("AcpAdapterV2", () => { |
| 1544 | }, | 1543 | }, |
| 1545 | fileSystem, | 1544 | fileSystem, |
| 1546 | idAllocator, | 1545 | idAllocator, |
| 1547 | serverConfig, | 1546 | host, |
| 1548 | selfInvocation, | 1547 | selfInvocation, |
| 1549 | }); | 1548 | }); |
| 1550 | const threadId = ThreadId.make("thread-acp-v2-fidelity"); | 1549 | const threadId = ThreadId.make("thread-acp-v2-fidelity"); |
| ... | @@ -1709,7 +1708,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -1709,7 +1708,7 @@ describe("AcpAdapterV2", () => { |
| 1709 | const fileSystem = yield* FileSystem.FileSystem; | 1708 | const fileSystem = yield* FileSystem.FileSystem; |
| 1710 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 1709 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 1711 | const path = yield* Path.Path; | 1710 | const path = yield* Path.Path; |
| 1712 | const serverConfig = yield* ServerConfig.ServerConfig; | 1711 | const host = yield* ProviderHost.ProviderHost; |
| 1713 | const selfInvocation = yield* resolveSelfInvocation(); | 1712 | const selfInvocation = yield* resolveSelfInvocation(); |
| 1714 | const mockAgentPath = yield* path.fromFileUrl( | 1713 | const mockAgentPath = yield* path.fromFileUrl( |
| 1715 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 1714 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -1730,7 +1729,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -1730,7 +1729,7 @@ describe("AcpAdapterV2", () => { |
| 1730 | }, | 1729 | }, |
| 1731 | fileSystem, | 1730 | fileSystem, |
| 1732 | idAllocator, | 1731 | idAllocator, |
| 1733 | serverConfig, | 1732 | host, |
| 1734 | selfInvocation, | 1733 | selfInvocation, |
| 1735 | }); | 1734 | }); |
| 1736 | const threadId = ThreadId.make("thread-acp-eager-resume"); | 1735 | const threadId = ThreadId.make("thread-acp-eager-resume"); |
| ... | @@ -1794,7 +1793,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -1794,7 +1793,7 @@ describe("AcpAdapterV2", () => { |
| 1794 | const fileSystem = yield* FileSystem.FileSystem; | 1793 | const fileSystem = yield* FileSystem.FileSystem; |
| 1795 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 1794 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 1796 | const path = yield* Path.Path; | 1795 | const path = yield* Path.Path; |
| 1797 | const serverConfig = yield* ServerConfig.ServerConfig; | 1796 | const host = yield* ProviderHost.ProviderHost; |
| 1798 | const selfInvocation = yield* resolveSelfInvocation(); | 1797 | const selfInvocation = yield* resolveSelfInvocation(); |
| 1799 | const mockAgentPath = yield* path.fromFileUrl( | 1798 | const mockAgentPath = yield* path.fromFileUrl( |
| 1800 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 1799 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -1817,7 +1816,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -1817,7 +1816,7 @@ describe("AcpAdapterV2", () => { |
| 1817 | }, | 1816 | }, |
| 1818 | fileSystem, | 1817 | fileSystem, |
| 1819 | idAllocator, | 1818 | idAllocator, |
| 1820 | serverConfig, | 1819 | host, |
| 1821 | selfInvocation, | 1820 | selfInvocation, |
| 1822 | }); | 1821 | }); |
| 1823 | const threadId = ThreadId.make("thread-acp-stale-eager-resume"); | 1822 | const threadId = ThreadId.make("thread-acp-stale-eager-resume"); |
| ... | @@ -1940,7 +1939,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -1940,7 +1939,7 @@ describe("AcpAdapterV2", () => { |
| 1940 | const fileSystem = yield* FileSystem.FileSystem; | 1939 | const fileSystem = yield* FileSystem.FileSystem; |
| 1941 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 1940 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 1942 | const path = yield* Path.Path; | 1941 | const path = yield* Path.Path; |
| 1943 | const serverConfig = yield* ServerConfig.ServerConfig; | 1942 | const host = yield* ProviderHost.ProviderHost; |
| 1944 | const selfInvocation = yield* resolveSelfInvocation(); | 1943 | const selfInvocation = yield* resolveSelfInvocation(); |
| 1945 | const mockAgentPath = yield* path.fromFileUrl( | 1944 | const mockAgentPath = yield* path.fromFileUrl( |
| 1946 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 1945 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -1969,7 +1968,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -1969,7 +1968,7 @@ describe("AcpAdapterV2", () => { |
| 1969 | }, | 1968 | }, |
| 1970 | fileSystem, | 1969 | fileSystem, |
| 1971 | idAllocator, | 1970 | idAllocator, |
| 1972 | serverConfig, | 1971 | host, |
| 1973 | selfInvocation, | 1972 | selfInvocation, |
| 1974 | }); | 1973 | }); |
| 1975 | const threadId = ThreadId.make("thread-acp-unexpected-termination"); | 1974 | const threadId = ThreadId.make("thread-acp-unexpected-termination"); |
| ... | @@ -2023,7 +2022,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -2023,7 +2022,7 @@ describe("AcpAdapterV2", () => { |
| 2023 | const fileSystem = yield* FileSystem.FileSystem; | 2022 | const fileSystem = yield* FileSystem.FileSystem; |
| 2024 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 2023 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 2025 | const path = yield* Path.Path; | 2024 | const path = yield* Path.Path; |
| 2026 | const serverConfig = yield* ServerConfig.ServerConfig; | 2025 | const host = yield* ProviderHost.ProviderHost; |
| 2027 | const selfInvocation = yield* resolveSelfInvocation(); | 2026 | const selfInvocation = yield* resolveSelfInvocation(); |
| 2028 | const mockAgentPath = yield* path.fromFileUrl( | 2027 | const mockAgentPath = yield* path.fromFileUrl( |
| 2029 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 2028 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -2058,7 +2057,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -2058,7 +2057,7 @@ describe("AcpAdapterV2", () => { |
| 2058 | }, | 2057 | }, |
| 2059 | fileSystem, | 2058 | fileSystem, |
| 2060 | idAllocator, | 2059 | idAllocator, |
| 2061 | serverConfig, | 2060 | host, |
| 2062 | selfInvocation, | 2061 | selfInvocation, |
| 2063 | }); | 2062 | }); |
| 2064 | const threadId = ThreadId.make("thread-acp-provider-exit-running-command"); | 2063 | const threadId = ThreadId.make("thread-acp-provider-exit-running-command"); |
| ... | @@ -2108,7 +2107,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -2108,7 +2107,7 @@ describe("AcpAdapterV2", () => { |
| 2108 | const fileSystem = yield* FileSystem.FileSystem; | 2107 | const fileSystem = yield* FileSystem.FileSystem; |
| 2109 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 2108 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 2110 | const path = yield* Path.Path; | 2109 | const path = yield* Path.Path; |
| 2111 | const serverConfig = yield* ServerConfig.ServerConfig; | 2110 | const host = yield* ProviderHost.ProviderHost; |
| 2112 | const selfInvocation = yield* resolveSelfInvocation(); | 2111 | const selfInvocation = yield* resolveSelfInvocation(); |
| 2113 | const mockAgentPath = yield* path.fromFileUrl( | 2112 | const mockAgentPath = yield* path.fromFileUrl( |
| 2114 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 2113 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -2137,7 +2136,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -2137,7 +2136,7 @@ describe("AcpAdapterV2", () => { |
| 2137 | }, | 2136 | }, |
| 2138 | fileSystem, | 2137 | fileSystem, |
| 2139 | idAllocator, | 2138 | idAllocator, |
| 2140 | serverConfig, | 2139 | host, |
| 2141 | selfInvocation, | 2140 | selfInvocation, |
| 2142 | }); | 2141 | }); |
| 2143 | const threadId = ThreadId.make("thread-acp-cgroup-unavailable"); | 2142 | const threadId = ThreadId.make("thread-acp-cgroup-unavailable"); |
| ... | @@ -2164,7 +2163,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -2164,7 +2163,7 @@ describe("AcpAdapterV2", () => { |
| 2164 | const fileSystem = yield* FileSystem.FileSystem; | 2163 | const fileSystem = yield* FileSystem.FileSystem; |
| 2165 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 2164 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 2166 | const path = yield* Path.Path; | 2165 | const path = yield* Path.Path; |
| 2167 | const serverConfig = yield* ServerConfig.ServerConfig; | 2166 | const host = yield* ProviderHost.ProviderHost; |
| 2168 | const selfInvocation = yield* resolveSelfInvocation(); | 2167 | const selfInvocation = yield* resolveSelfInvocation(); |
| 2169 | const mockAgentPath = yield* path.fromFileUrl( | 2168 | const mockAgentPath = yield* path.fromFileUrl( |
| 2170 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 2169 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -2209,7 +2208,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -2209,7 +2208,7 @@ describe("AcpAdapterV2", () => { |
| 2209 | }, | 2208 | }, |
| 2210 | fileSystem, | 2209 | fileSystem, |
| 2211 | idAllocator, | 2210 | idAllocator, |
| 2212 | serverConfig, | 2211 | host, |
| 2213 | selfInvocation, | 2212 | selfInvocation, |
| 2214 | }); | 2213 | }); |
| 2215 | const threadId = ThreadId.make("thread-acp-cgroup-join-failure"); | 2214 | const threadId = ThreadId.make("thread-acp-cgroup-join-failure"); |
| ... | @@ -2242,7 +2241,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -2242,7 +2241,7 @@ describe("AcpAdapterV2", () => { |
| 2242 | const fileSystem = yield* FileSystem.FileSystem; | 2241 | const fileSystem = yield* FileSystem.FileSystem; |
| 2243 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 2242 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 2244 | const path = yield* Path.Path; | 2243 | const path = yield* Path.Path; |
| 2245 | const serverConfig = yield* ServerConfig.ServerConfig; | 2244 | const host = yield* ProviderHost.ProviderHost; |
| 2246 | const selfInvocation = yield* resolveSelfInvocation(); | 2245 | const selfInvocation = yield* resolveSelfInvocation(); |
| 2247 | const mockAgentPath = yield* path.fromFileUrl( | 2246 | const mockAgentPath = yield* path.fromFileUrl( |
| 2248 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 2247 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -2286,7 +2285,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -2286,7 +2285,7 @@ describe("AcpAdapterV2", () => { |
| 2286 | }, | 2285 | }, |
| 2287 | fileSystem, | 2286 | fileSystem, |
| 2288 | idAllocator, | 2287 | idAllocator, |
| 2289 | serverConfig, | 2288 | host, |
| 2290 | selfInvocation, | 2289 | selfInvocation, |
| 2291 | clientTerminals: { childProcessSpawner }, | 2290 | clientTerminals: { childProcessSpawner }, |
| 2292 | }); | 2291 | }); |
| ... | @@ -2454,7 +2453,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -2454,7 +2453,7 @@ describe("AcpAdapterV2", () => { |
| 2454 | const fileSystem = yield* FileSystem.FileSystem; | 2453 | const fileSystem = yield* FileSystem.FileSystem; |
| 2455 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 2454 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 2456 | const path = yield* Path.Path; | 2455 | const path = yield* Path.Path; |
| 2457 | const serverConfig = yield* ServerConfig.ServerConfig; | 2456 | const host = yield* ProviderHost.ProviderHost; |
| 2458 | const selfInvocation = yield* resolveSelfInvocation(); | 2457 | const selfInvocation = yield* resolveSelfInvocation(); |
| 2459 | const mockAgentPath = yield* path.fromFileUrl( | 2458 | const mockAgentPath = yield* path.fromFileUrl( |
| 2460 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 2459 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -2484,7 +2483,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -2484,7 +2483,7 @@ describe("AcpAdapterV2", () => { |
| 2484 | }, | 2483 | }, |
| 2485 | fileSystem, | 2484 | fileSystem, |
| 2486 | idAllocator, | 2485 | idAllocator, |
| 2487 | serverConfig, | 2486 | host, |
| 2488 | selfInvocation, | 2487 | selfInvocation, |
| 2489 | }); | 2488 | }); |
| 2490 | const threadId = ThreadId.make("thread-acp-no-client-fs"); | 2489 | const threadId = ThreadId.make("thread-acp-no-client-fs"); |
| ... | @@ -2551,7 +2550,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -2551,7 +2550,7 @@ describe("AcpAdapterV2", () => { |
| 2551 | const fileSystem = yield* FileSystem.FileSystem; | 2550 | const fileSystem = yield* FileSystem.FileSystem; |
| 2552 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 2551 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 2553 | const path = yield* Path.Path; | 2552 | const path = yield* Path.Path; |
| 2554 | const serverConfig = yield* ServerConfig.ServerConfig; | 2553 | const host = yield* ProviderHost.ProviderHost; |
| 2555 | const selfInvocation = yield* resolveSelfInvocation(); | 2554 | const selfInvocation = yield* resolveSelfInvocation(); |
| 2556 | const mockAgentPath = yield* path.fromFileUrl( | 2555 | const mockAgentPath = yield* path.fromFileUrl( |
| 2557 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 2556 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -2591,7 +2590,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -2591,7 +2590,7 @@ describe("AcpAdapterV2", () => { |
| 2591 | }, | 2590 | }, |
| 2592 | fileSystem, | 2591 | fileSystem, |
| 2593 | idAllocator, | 2592 | idAllocator, |
| 2594 | serverConfig, | 2593 | host, |
| 2595 | selfInvocation, | 2594 | selfInvocation, |
| 2596 | clientTerminals: { childProcessSpawner }, | 2595 | clientTerminals: { childProcessSpawner }, |
| 2597 | }); | 2596 | }); |
| ... | @@ -2688,7 +2687,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -2688,7 +2687,7 @@ describe("AcpAdapterV2", () => { |
| 2688 | const fileSystem = yield* FileSystem.FileSystem; | 2687 | const fileSystem = yield* FileSystem.FileSystem; |
| 2689 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 2688 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 2690 | const path = yield* Path.Path; | 2689 | const path = yield* Path.Path; |
| 2691 | const serverConfig = yield* ServerConfig.ServerConfig; | 2690 | const host = yield* ProviderHost.ProviderHost; |
| 2692 | const selfInvocation = yield* resolveSelfInvocation(); | 2691 | const selfInvocation = yield* resolveSelfInvocation(); |
| 2693 | const mockAgentPath = yield* path.fromFileUrl( | 2692 | const mockAgentPath = yield* path.fromFileUrl( |
| 2694 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 2693 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -2704,7 +2703,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -2704,7 +2703,7 @@ describe("AcpAdapterV2", () => { |
| 2704 | }, | 2703 | }, |
| 2705 | fileSystem, | 2704 | fileSystem, |
| 2706 | idAllocator, | 2705 | idAllocator, |
| 2707 | serverConfig, | 2706 | host, |
| 2708 | selfInvocation, | 2707 | selfInvocation, |
| 2709 | }); | 2708 | }); |
| 2710 | const threadId = ThreadId.make("thread-acp-missing-native-thread"); | 2709 | const threadId = ThreadId.make("thread-acp-missing-native-thread"); |
| ... | @@ -2750,7 +2749,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -2750,7 +2749,7 @@ describe("AcpAdapterV2", () => { |
| 2750 | const fileSystem = yield* FileSystem.FileSystem; | 2749 | const fileSystem = yield* FileSystem.FileSystem; |
| 2751 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 2750 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 2752 | const path = yield* Path.Path; | 2751 | const path = yield* Path.Path; |
| 2753 | const serverConfig = yield* ServerConfig.ServerConfig; | 2752 | const host = yield* ProviderHost.ProviderHost; |
| 2754 | const selfInvocation = yield* resolveSelfInvocation(); | 2753 | const selfInvocation = yield* resolveSelfInvocation(); |
| 2755 | const mockAgentPath = yield* path.fromFileUrl( | 2754 | const mockAgentPath = yield* path.fromFileUrl( |
| 2756 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 2755 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -2788,7 +2787,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -2788,7 +2787,7 @@ describe("AcpAdapterV2", () => { |
| 2788 | }, | 2787 | }, |
| 2789 | fileSystem, | 2788 | fileSystem, |
| 2790 | idAllocator, | 2789 | idAllocator, |
| 2791 | serverConfig, | 2790 | host, |
| 2792 | selfInvocation, | 2791 | selfInvocation, |
| 2793 | }); | 2792 | }); |
| 2794 | const threadId = ThreadId.make("thread-acp-rollback-session"); | 2793 | const threadId = ThreadId.make("thread-acp-rollback-session"); |
| ... | @@ -2917,7 +2916,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -2917,7 +2916,7 @@ describe("AcpAdapterV2", () => { |
| 2917 | const fileSystem = yield* FileSystem.FileSystem; | 2916 | const fileSystem = yield* FileSystem.FileSystem; |
| 2918 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 2917 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 2919 | const path = yield* Path.Path; | 2918 | const path = yield* Path.Path; |
| 2920 | const serverConfig = yield* ServerConfig.ServerConfig; | 2919 | const host = yield* ProviderHost.ProviderHost; |
| 2921 | const selfInvocation = yield* resolveSelfInvocation(); | 2920 | const selfInvocation = yield* resolveSelfInvocation(); |
| 2922 | const mockAgentPath = yield* path.fromFileUrl( | 2921 | const mockAgentPath = yield* path.fromFileUrl( |
| 2923 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 2922 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -3001,7 +3000,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -3001,7 +3000,7 @@ describe("AcpAdapterV2", () => { |
| 3001 | }, | 3000 | }, |
| 3002 | fileSystem, | 3001 | fileSystem, |
| 3003 | idAllocator, | 3002 | idAllocator, |
| 3004 | serverConfig, | 3003 | host, |
| 3005 | selfInvocation, | 3004 | selfInvocation, |
| 3006 | continuationRequests: { offer: () => Effect.void }, | 3005 | continuationRequests: { offer: () => Effect.void }, |
| 3007 | }); | 3006 | }); |
| ... | @@ -3078,7 +3077,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -3078,7 +3077,7 @@ describe("AcpAdapterV2", () => { |
| 3078 | const fileSystem = yield* FileSystem.FileSystem; | 3077 | const fileSystem = yield* FileSystem.FileSystem; |
| 3079 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 3078 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 3080 | const path = yield* Path.Path; | 3079 | const path = yield* Path.Path; |
| 3081 | const serverConfig = yield* ServerConfig.ServerConfig; | 3080 | const host = yield* ProviderHost.ProviderHost; |
| 3082 | const selfInvocation = yield* resolveSelfInvocation(); | 3081 | const selfInvocation = yield* resolveSelfInvocation(); |
| 3083 | const mockAgentPath = yield* path.fromFileUrl( | 3082 | const mockAgentPath = yield* path.fromFileUrl( |
| 3084 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 3083 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -3120,7 +3119,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -3120,7 +3119,7 @@ describe("AcpAdapterV2", () => { |
| 3120 | }, | 3119 | }, |
| 3121 | fileSystem, | 3120 | fileSystem, |
| 3122 | idAllocator, | 3121 | idAllocator, |
| 3123 | serverConfig, | 3122 | host, |
| 3124 | selfInvocation, | 3123 | selfInvocation, |
| 3125 | }); | 3124 | }); |
| 3126 | const threadId = ThreadId.make("thread-acp-rollback-failure-compensation"); | 3125 | const threadId = ThreadId.make("thread-acp-rollback-failure-compensation"); |
| ... | @@ -3194,7 +3193,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -3194,7 +3193,7 @@ describe("AcpAdapterV2", () => { |
| 3194 | const fileSystem = yield* FileSystem.FileSystem; | 3193 | const fileSystem = yield* FileSystem.FileSystem; |
| 3195 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 3194 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 3196 | const path = yield* Path.Path; | 3195 | const path = yield* Path.Path; |
| 3197 | const serverConfig = yield* ServerConfig.ServerConfig; | 3196 | const host = yield* ProviderHost.ProviderHost; |
| 3198 | const selfInvocation = yield* resolveSelfInvocation(); | 3197 | const selfInvocation = yield* resolveSelfInvocation(); |
| 3199 | const mockAgentPath = yield* path.fromFileUrl( | 3198 | const mockAgentPath = yield* path.fromFileUrl( |
| 3200 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 3199 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -3211,7 +3210,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -3211,7 +3210,7 @@ describe("AcpAdapterV2", () => { |
| 3211 | }, | 3210 | }, |
| 3212 | fileSystem, | 3211 | fileSystem, |
| 3213 | idAllocator, | 3212 | idAllocator, |
| 3214 | serverConfig, | 3213 | host, |
| 3215 | selfInvocation, | 3214 | selfInvocation, |
| 3216 | }); | 3215 | }); |
| 3217 | const threadId = ThreadId.make("thread-acp-idle-finalizer"); | 3216 | const threadId = ThreadId.make("thread-acp-idle-finalizer"); |
| ... | @@ -3245,7 +3244,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -3245,7 +3244,7 @@ describe("AcpAdapterV2", () => { |
| 3245 | const fileSystem = yield* FileSystem.FileSystem; | 3244 | const fileSystem = yield* FileSystem.FileSystem; |
| 3246 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 3245 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 3247 | const path = yield* Path.Path; | 3246 | const path = yield* Path.Path; |
| 3248 | const serverConfig = yield* ServerConfig.ServerConfig; | 3247 | const host = yield* ProviderHost.ProviderHost; |
| 3249 | const selfInvocation = yield* resolveSelfInvocation(); | 3248 | const selfInvocation = yield* resolveSelfInvocation(); |
| 3250 | const mockAgentPath = yield* path.fromFileUrl( | 3249 | const mockAgentPath = yield* path.fromFileUrl( |
| 3251 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 3250 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -3261,7 +3260,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -3261,7 +3260,7 @@ describe("AcpAdapterV2", () => { |
| 3261 | crypto: yield* Crypto.Crypto, | 3260 | crypto: yield* Crypto.Crypto, |
| 3262 | fileSystem, | 3261 | fileSystem, |
| 3263 | idAllocator, | 3262 | idAllocator, |
| 3264 | serverConfig, | 3263 | host, |
| 3265 | selfInvocation: yield* resolveSelfInvocation(), | 3264 | selfInvocation: yield* resolveSelfInvocation(), |
| 3266 | // Production Grok runtimes are wrapped by the x.ai prompt runtime. | 3265 | // Production Grok runtimes are wrapped by the x.ai prompt runtime. |
| 3267 | makeRuntime: (input) => | 3266 | makeRuntime: (input) => |
| ... | @@ -3300,7 +3299,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -3300,7 +3299,7 @@ describe("AcpAdapterV2", () => { |
| 3300 | const fileSystem = yield* FileSystem.FileSystem; | 3299 | const fileSystem = yield* FileSystem.FileSystem; |
| 3301 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 3300 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 3302 | const path = yield* Path.Path; | 3301 | const path = yield* Path.Path; |
| 3303 | const serverConfig = yield* ServerConfig.ServerConfig; | 3302 | const host = yield* ProviderHost.ProviderHost; |
| 3304 | const selfInvocation = yield* resolveSelfInvocation(); | 3303 | const selfInvocation = yield* resolveSelfInvocation(); |
| 3305 | const mockAgentPath = yield* path.fromFileUrl( | 3304 | const mockAgentPath = yield* path.fromFileUrl( |
| 3306 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 3305 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -3316,7 +3315,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -3316,7 +3315,7 @@ describe("AcpAdapterV2", () => { |
| 3316 | crypto: yield* Crypto.Crypto, | 3315 | crypto: yield* Crypto.Crypto, |
| 3317 | fileSystem, | 3316 | fileSystem, |
| 3318 | idAllocator, | 3317 | idAllocator, |
| 3319 | serverConfig, | 3318 | host, |
| 3320 | selfInvocation: yield* resolveSelfInvocation(), | 3319 | selfInvocation: yield* resolveSelfInvocation(), |
| 3321 | // Production Grok runtimes are wrapped by the x.ai prompt runtime. | 3320 | // Production Grok runtimes are wrapped by the x.ai prompt runtime. |
| 3322 | makeRuntime: (input) => | 3321 | makeRuntime: (input) => |
| ... | @@ -3381,7 +3380,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -3381,7 +3380,7 @@ describe("AcpAdapterV2", () => { |
| 3381 | const fileSystem = yield* FileSystem.FileSystem; | 3380 | const fileSystem = yield* FileSystem.FileSystem; |
| 3382 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 3381 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 3383 | const path = yield* Path.Path; | 3382 | const path = yield* Path.Path; |
| 3384 | const serverConfig = yield* ServerConfig.ServerConfig; | 3383 | const host = yield* ProviderHost.ProviderHost; |
| 3385 | const selfInvocation = yield* resolveSelfInvocation(); | 3384 | const selfInvocation = yield* resolveSelfInvocation(); |
| 3386 | const mockAgentPath = yield* path.fromFileUrl( | 3385 | const mockAgentPath = yield* path.fromFileUrl( |
| 3387 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 3386 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -3397,7 +3396,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -3397,7 +3396,7 @@ describe("AcpAdapterV2", () => { |
| 3397 | }, | 3396 | }, |
| 3398 | fileSystem, | 3397 | fileSystem, |
| 3399 | idAllocator, | 3398 | idAllocator, |
| 3400 | serverConfig, | 3399 | host, |
| 3401 | selfInvocation, | 3400 | selfInvocation, |
| 3402 | }); | 3401 | }); |
| 3403 | const threadId = ThreadId.make("thread-acp-unsupported-option"); | 3402 | const threadId = ThreadId.make("thread-acp-unsupported-option"); |
| ... | @@ -3430,7 +3429,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -3430,7 +3429,7 @@ describe("AcpAdapterV2", () => { |
| 3430 | const fileSystem = yield* FileSystem.FileSystem; | 3429 | const fileSystem = yield* FileSystem.FileSystem; |
| 3431 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 3430 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 3432 | const path = yield* Path.Path; | 3431 | const path = yield* Path.Path; |
| 3433 | const serverConfig = yield* ServerConfig.ServerConfig; | 3432 | const host = yield* ProviderHost.ProviderHost; |
| 3434 | const selfInvocation = yield* resolveSelfInvocation(); | 3433 | const selfInvocation = yield* resolveSelfInvocation(); |
| 3435 | const mockAgentPath = yield* path.fromFileUrl( | 3434 | const mockAgentPath = yield* path.fromFileUrl( |
| 3436 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 3435 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -3447,7 +3446,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -3447,7 +3446,7 @@ describe("AcpAdapterV2", () => { |
| 3447 | }, | 3446 | }, |
| 3448 | fileSystem, | 3447 | fileSystem, |
| 3449 | idAllocator, | 3448 | idAllocator, |
| 3450 | serverConfig, | 3449 | host, |
| 3451 | selfInvocation, | 3450 | selfInvocation, |
| 3452 | }); | 3451 | }); |
| 3453 | const firstThreadId = ThreadId.make("thread-acp-active-setup:first"); | 3452 | const firstThreadId = ThreadId.make("thread-acp-active-setup:first"); |
| ... | @@ -3541,7 +3540,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -3541,7 +3540,7 @@ describe("AcpAdapterV2", () => { |
| 3541 | const fileSystem = yield* FileSystem.FileSystem; | 3540 | const fileSystem = yield* FileSystem.FileSystem; |
| 3542 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 3541 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 3543 | const path = yield* Path.Path; | 3542 | const path = yield* Path.Path; |
| 3544 | const serverConfig = yield* ServerConfig.ServerConfig; | 3543 | const host = yield* ProviderHost.ProviderHost; |
| 3545 | const selfInvocation = yield* resolveSelfInvocation(); | 3544 | const selfInvocation = yield* resolveSelfInvocation(); |
| 3546 | const mockAgentPath = yield* path.fromFileUrl( | 3545 | const mockAgentPath = yield* path.fromFileUrl( |
| 3547 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 3546 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -3568,7 +3567,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -3568,7 +3567,7 @@ describe("AcpAdapterV2", () => { |
| 3568 | }, | 3567 | }, |
| 3569 | fileSystem, | 3568 | fileSystem, |
| 3570 | idAllocator, | 3569 | idAllocator, |
| 3571 | serverConfig, | 3570 | host, |
| 3572 | selfInvocation, | 3571 | selfInvocation, |
| 3573 | }); | 3572 | }); |
| 3574 | const threadId = ThreadId.make("thread-acp-empty-successful-bash"); | 3573 | const threadId = ThreadId.make("thread-acp-empty-successful-bash"); |
| ... | @@ -3688,7 +3687,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -3688,7 +3687,7 @@ describe("AcpAdapterV2", () => { |
| 3688 | const fileSystem = yield* FileSystem.FileSystem; | 3687 | const fileSystem = yield* FileSystem.FileSystem; |
| 3689 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 3688 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 3690 | const path = yield* Path.Path; | 3689 | const path = yield* Path.Path; |
| 3691 | const serverConfig = yield* ServerConfig.ServerConfig; | 3690 | const host = yield* ProviderHost.ProviderHost; |
| 3692 | const selfInvocation = yield* resolveSelfInvocation(); | 3691 | const selfInvocation = yield* resolveSelfInvocation(); |
| 3693 | const protocolEvents = yield* Queue.unbounded<EffectAcpProtocol.AcpProtocolLogEvent>(); | 3692 | const protocolEvents = yield* Queue.unbounded<EffectAcpProtocol.AcpProtocolLogEvent>(); |
| 3694 | const native: { current?: AcpSessionRuntime.AcpSessionRuntime["Service"] } = {}; | 3693 | const native: { current?: AcpSessionRuntime.AcpSessionRuntime["Service"] } = {}; |
| ... | @@ -3715,7 +3714,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -3715,7 +3714,7 @@ describe("AcpAdapterV2", () => { |
| 3715 | }, | 3714 | }, |
| 3716 | fileSystem, | 3715 | fileSystem, |
| 3717 | idAllocator, | 3716 | idAllocator, |
| 3718 | serverConfig, | 3717 | host, |
| 3719 | selfInvocation, | 3718 | selfInvocation, |
| 3720 | }); | 3719 | }); |
| 3721 | const threadId = ThreadId.make("thread-acp-native-cancel"); | 3720 | const threadId = ThreadId.make("thread-acp-native-cancel"); |
| ... | @@ -3802,7 +3801,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -3802,7 +3801,7 @@ describe("AcpAdapterV2", () => { |
| 3802 | const fileSystem = yield* FileSystem.FileSystem; | 3801 | const fileSystem = yield* FileSystem.FileSystem; |
| 3803 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 3802 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 3804 | const path = yield* Path.Path; | 3803 | const path = yield* Path.Path; |
| 3805 | const serverConfig = yield* ServerConfig.ServerConfig; | 3804 | const host = yield* ProviderHost.ProviderHost; |
| 3806 | const selfInvocation = yield* resolveSelfInvocation(); | 3805 | const selfInvocation = yield* resolveSelfInvocation(); |
| 3807 | const mockAgentPath = yield* path.fromFileUrl( | 3806 | const mockAgentPath = yield* path.fromFileUrl( |
| 3808 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 3807 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -3824,7 +3823,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -3824,7 +3823,7 @@ describe("AcpAdapterV2", () => { |
| 3824 | }, | 3823 | }, |
| 3825 | fileSystem, | 3824 | fileSystem, |
| 3826 | idAllocator, | 3825 | idAllocator, |
| 3827 | serverConfig, | 3826 | host, |
| 3828 | selfInvocation, | 3827 | selfInvocation, |
| 3829 | }); | 3828 | }); |
| 3830 | const threadId = ThreadId.make("thread-acp-cancel-permission"); | 3829 | const threadId = ThreadId.make("thread-acp-cancel-permission"); |
| ... | @@ -3903,7 +3902,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -3903,7 +3902,7 @@ describe("AcpAdapterV2", () => { |
| 3903 | const fileSystem = yield* FileSystem.FileSystem; | 3902 | const fileSystem = yield* FileSystem.FileSystem; |
| 3904 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 3903 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 3905 | const path = yield* Path.Path; | 3904 | const path = yield* Path.Path; |
| 3906 | const serverConfig = yield* ServerConfig.ServerConfig; | 3905 | const host = yield* ProviderHost.ProviderHost; |
| 3907 | const selfInvocation = yield* resolveSelfInvocation(); | 3906 | const selfInvocation = yield* resolveSelfInvocation(); |
| 3908 | const mockAgentPath = yield* path.fromFileUrl( | 3907 | const mockAgentPath = yield* path.fromFileUrl( |
| 3909 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 3908 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -3930,7 +3929,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -3930,7 +3929,7 @@ describe("AcpAdapterV2", () => { |
| 3930 | }, | 3929 | }, |
| 3931 | fileSystem, | 3930 | fileSystem, |
| 3932 | idAllocator, | 3931 | idAllocator, |
| 3933 | serverConfig, | 3932 | host, |
| 3934 | selfInvocation, | 3933 | selfInvocation, |
| 3935 | }); | 3934 | }); |
| 3936 | const threadId = ThreadId.make("thread-acp-response-wins-permission"); | 3935 | const threadId = ThreadId.make("thread-acp-response-wins-permission"); |
| ... | @@ -4002,7 +4001,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -4002,7 +4001,7 @@ describe("AcpAdapterV2", () => { |
| 4002 | const fileSystem = yield* FileSystem.FileSystem; | 4001 | const fileSystem = yield* FileSystem.FileSystem; |
| 4003 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 4002 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 4004 | const path = yield* Path.Path; | 4003 | const path = yield* Path.Path; |
| 4005 | const serverConfig = yield* ServerConfig.ServerConfig; | 4004 | const host = yield* ProviderHost.ProviderHost; |
| 4006 | const selfInvocation = yield* resolveSelfInvocation(); | 4005 | const selfInvocation = yield* resolveSelfInvocation(); |
| 4007 | const mockAgentPath = yield* path.fromFileUrl( | 4006 | const mockAgentPath = yield* path.fromFileUrl( |
| 4008 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 4007 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -4029,7 +4028,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -4029,7 +4028,7 @@ describe("AcpAdapterV2", () => { |
| 4029 | }, | 4028 | }, |
| 4030 | fileSystem, | 4029 | fileSystem, |
| 4031 | idAllocator, | 4030 | idAllocator, |
| 4032 | serverConfig, | 4031 | host, |
| 4033 | selfInvocation, | 4032 | selfInvocation, |
| 4034 | }); | 4033 | }); |
| 4035 | const threadId = ThreadId.make("thread-acp-reordered-elicitation"); | 4034 | const threadId = ThreadId.make("thread-acp-reordered-elicitation"); |
| ... | @@ -4091,7 +4090,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -4091,7 +4090,7 @@ describe("AcpAdapterV2", () => { |
| 4091 | const fileSystem = yield* FileSystem.FileSystem; | 4090 | const fileSystem = yield* FileSystem.FileSystem; |
| 4092 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 4091 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 4093 | const path = yield* Path.Path; | 4092 | const path = yield* Path.Path; |
| 4094 | const serverConfig = yield* ServerConfig.ServerConfig; | 4093 | const host = yield* ProviderHost.ProviderHost; |
| 4095 | const selfInvocation = yield* resolveSelfInvocation(); | 4094 | const selfInvocation = yield* resolveSelfInvocation(); |
| 4096 | const mockAgentPath = yield* path.fromFileUrl( | 4095 | const mockAgentPath = yield* path.fromFileUrl( |
| 4097 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 4096 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -4111,7 +4110,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -4111,7 +4110,7 @@ describe("AcpAdapterV2", () => { |
| 4111 | }, | 4110 | }, |
| 4112 | fileSystem, | 4111 | fileSystem, |
| 4113 | idAllocator, | 4112 | idAllocator, |
| 4114 | serverConfig, | 4113 | host, |
| 4115 | selfInvocation, | 4114 | selfInvocation, |
| 4116 | }); | 4115 | }); |
| 4117 | const threadId = ThreadId.make("thread-acp-mcp-approval-elicitation"); | 4116 | const threadId = ThreadId.make("thread-acp-mcp-approval-elicitation"); |
| ... | @@ -4159,7 +4158,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -4159,7 +4158,7 @@ describe("AcpAdapterV2", () => { |
| 4159 | const fileSystem = yield* FileSystem.FileSystem; | 4158 | const fileSystem = yield* FileSystem.FileSystem; |
| 4160 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 4159 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 4161 | const path = yield* Path.Path; | 4160 | const path = yield* Path.Path; |
| 4162 | const serverConfig = yield* ServerConfig.ServerConfig; | 4161 | const host = yield* ProviderHost.ProviderHost; |
| 4163 | const selfInvocation = yield* resolveSelfInvocation(); | 4162 | const selfInvocation = yield* resolveSelfInvocation(); |
| 4164 | const mockAgentPath = yield* path.fromFileUrl( | 4163 | const mockAgentPath = yield* path.fromFileUrl( |
| 4165 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 4164 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -4187,7 +4186,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -4187,7 +4186,7 @@ describe("AcpAdapterV2", () => { |
| 4187 | }, | 4186 | }, |
| 4188 | fileSystem, | 4187 | fileSystem, |
| 4189 | idAllocator, | 4188 | idAllocator, |
| 4190 | serverConfig, | 4189 | host, |
| 4191 | selfInvocation, | 4190 | selfInvocation, |
| 4192 | testHooks: { | 4191 | testHooks: { |
| 4193 | onNativeResponseLifecycle: (event) => | 4192 | onNativeResponseLifecycle: (event) => |
| ... | @@ -4267,7 +4266,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -4267,7 +4266,7 @@ describe("AcpAdapterV2", () => { |
| 4267 | const fileSystem = yield* FileSystem.FileSystem; | 4266 | const fileSystem = yield* FileSystem.FileSystem; |
| 4268 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 4267 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 4269 | const path = yield* Path.Path; | 4268 | const path = yield* Path.Path; |
| 4270 | const serverConfig = yield* ServerConfig.ServerConfig; | 4269 | const host = yield* ProviderHost.ProviderHost; |
| 4271 | const selfInvocation = yield* resolveSelfInvocation(); | 4270 | const selfInvocation = yield* resolveSelfInvocation(); |
| 4272 | const mockAgentPath = yield* path.fromFileUrl( | 4271 | const mockAgentPath = yield* path.fromFileUrl( |
| 4273 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 4272 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -4292,7 +4291,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -4292,7 +4291,7 @@ describe("AcpAdapterV2", () => { |
| 4292 | }, | 4291 | }, |
| 4293 | fileSystem, | 4292 | fileSystem, |
| 4294 | idAllocator, | 4293 | idAllocator, |
| 4295 | serverConfig, | 4294 | host, |
| 4296 | selfInvocation, | 4295 | selfInvocation, |
| 4297 | testHooks: { | 4296 | testHooks: { |
| 4298 | afterNativeResponseTransportClosed: () => | 4297 | afterNativeResponseTransportClosed: () => |
| ... | @@ -4381,7 +4380,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -4381,7 +4380,7 @@ describe("AcpAdapterV2", () => { |
| 4381 | const fileSystem = yield* FileSystem.FileSystem; | 4380 | const fileSystem = yield* FileSystem.FileSystem; |
| 4382 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 4381 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 4383 | const path = yield* Path.Path; | 4382 | const path = yield* Path.Path; |
| 4384 | const serverConfig = yield* ServerConfig.ServerConfig; | 4383 | const host = yield* ProviderHost.ProviderHost; |
| 4385 | const selfInvocation = yield* resolveSelfInvocation(); | 4384 | const selfInvocation = yield* resolveSelfInvocation(); |
| 4386 | const mockAgentPath = yield* path.fromFileUrl( | 4385 | const mockAgentPath = yield* path.fromFileUrl( |
| 4387 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 4386 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -4426,7 +4425,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -4426,7 +4425,7 @@ describe("AcpAdapterV2", () => { |
| 4426 | }, | 4425 | }, |
| 4427 | fileSystem, | 4426 | fileSystem, |
| 4428 | idAllocator, | 4427 | idAllocator, |
| 4429 | serverConfig, | 4428 | host, |
| 4430 | selfInvocation, | 4429 | selfInvocation, |
| 4431 | testHooks: { | 4430 | testHooks: { |
| 4432 | onNativeResponseLifecycle: (event) => | 4431 | onNativeResponseLifecycle: (event) => |
| ... | @@ -4522,7 +4521,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -4522,7 +4521,7 @@ describe("AcpAdapterV2", () => { |
| 4522 | const fileSystem = yield* FileSystem.FileSystem; | 4521 | const fileSystem = yield* FileSystem.FileSystem; |
| 4523 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 4522 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 4524 | const path = yield* Path.Path; | 4523 | const path = yield* Path.Path; |
| 4525 | const serverConfig = yield* ServerConfig.ServerConfig; | 4524 | const host = yield* ProviderHost.ProviderHost; |
| 4526 | const selfInvocation = yield* resolveSelfInvocation(); | 4525 | const selfInvocation = yield* resolveSelfInvocation(); |
| 4527 | const mockAgentPath = yield* path.fromFileUrl( | 4526 | const mockAgentPath = yield* path.fromFileUrl( |
| 4528 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 4527 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -4564,7 +4563,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -4564,7 +4563,7 @@ describe("AcpAdapterV2", () => { |
| 4564 | }, | 4563 | }, |
| 4565 | fileSystem, | 4564 | fileSystem, |
| 4566 | idAllocator, | 4565 | idAllocator, |
| 4567 | serverConfig, | 4566 | host, |
| 4568 | selfInvocation, | 4567 | selfInvocation, |
| 4569 | }); | 4568 | }); |
| 4570 | const threadId = ThreadId.make("thread-acp-pending-response-cancel"); | 4569 | const threadId = ThreadId.make("thread-acp-pending-response-cancel"); |
| ... | @@ -4638,7 +4637,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -4638,7 +4637,7 @@ describe("AcpAdapterV2", () => { |
| 4638 | const fileSystem = yield* FileSystem.FileSystem; | 4637 | const fileSystem = yield* FileSystem.FileSystem; |
| 4639 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 4638 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 4640 | const path = yield* Path.Path; | 4639 | const path = yield* Path.Path; |
| 4641 | const serverConfig = yield* ServerConfig.ServerConfig; | 4640 | const host = yield* ProviderHost.ProviderHost; |
| 4642 | const selfInvocation = yield* resolveSelfInvocation(); | 4641 | const selfInvocation = yield* resolveSelfInvocation(); |
| 4643 | const mockAgentPath = yield* path.fromFileUrl( | 4642 | const mockAgentPath = yield* path.fromFileUrl( |
| 4644 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 4643 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -4679,7 +4678,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -4679,7 +4678,7 @@ describe("AcpAdapterV2", () => { |
| 4679 | }, | 4678 | }, |
| 4680 | fileSystem, | 4679 | fileSystem, |
| 4681 | idAllocator, | 4680 | idAllocator, |
| 4682 | serverConfig, | 4681 | host, |
| 4683 | selfInvocation, | 4682 | selfInvocation, |
| 4684 | }); | 4683 | }); |
| 4685 | const threadId = ThreadId.make(`thread-acp-immediate-permission-${name}`); | 4684 | const threadId = ThreadId.make(`thread-acp-immediate-permission-${name}`); |
| ... | @@ -4743,7 +4742,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -4743,7 +4742,7 @@ describe("AcpAdapterV2", () => { |
| 4743 | const fileSystem = yield* FileSystem.FileSystem; | 4742 | const fileSystem = yield* FileSystem.FileSystem; |
| 4744 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 4743 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 4745 | const path = yield* Path.Path; | 4744 | const path = yield* Path.Path; |
| 4746 | const serverConfig = yield* ServerConfig.ServerConfig; | 4745 | const host = yield* ProviderHost.ProviderHost; |
| 4747 | const selfInvocation = yield* resolveSelfInvocation(); | 4746 | const selfInvocation = yield* resolveSelfInvocation(); |
| 4748 | const mockAgentPath = yield* path.fromFileUrl( | 4747 | const mockAgentPath = yield* path.fromFileUrl( |
| 4749 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 4748 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -4778,7 +4777,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -4778,7 +4777,7 @@ describe("AcpAdapterV2", () => { |
| 4778 | }, | 4777 | }, |
| 4779 | fileSystem, | 4778 | fileSystem, |
| 4780 | idAllocator, | 4779 | idAllocator, |
| 4781 | serverConfig, | 4780 | host, |
| 4782 | selfInvocation, | 4781 | selfInvocation, |
| 4783 | }); | 4782 | }); |
| 4784 | const threadId = ThreadId.make("thread-acp-immediate-url-elicitation"); | 4783 | const threadId = ThreadId.make("thread-acp-immediate-url-elicitation"); |
| ... | @@ -4836,7 +4835,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -4836,7 +4835,7 @@ describe("AcpAdapterV2", () => { |
| 4836 | const fileSystem = yield* FileSystem.FileSystem; | 4835 | const fileSystem = yield* FileSystem.FileSystem; |
| 4837 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 4836 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 4838 | const path = yield* Path.Path; | 4837 | const path = yield* Path.Path; |
| 4839 | const serverConfig = yield* ServerConfig.ServerConfig; | 4838 | const host = yield* ProviderHost.ProviderHost; |
| 4840 | const selfInvocation = yield* resolveSelfInvocation(); | 4839 | const selfInvocation = yield* resolveSelfInvocation(); |
| 4841 | const mockAgentPath = yield* path.fromFileUrl( | 4840 | const mockAgentPath = yield* path.fromFileUrl( |
| 4842 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 4841 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -4875,7 +4874,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -4875,7 +4874,7 @@ describe("AcpAdapterV2", () => { |
| 4875 | }, | 4874 | }, |
| 4876 | fileSystem, | 4875 | fileSystem, |
| 4877 | idAllocator, | 4876 | idAllocator, |
| 4878 | serverConfig, | 4877 | host, |
| 4879 | selfInvocation, | 4878 | selfInvocation, |
| 4880 | }); | 4879 | }); |
| 4881 | const threadId = ThreadId.make("thread-acp-missing-response-ack"); | 4880 | const threadId = ThreadId.make("thread-acp-missing-response-ack"); |
| ... | @@ -4929,7 +4928,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -4929,7 +4928,7 @@ describe("AcpAdapterV2", () => { |
| 4929 | const fileSystem = yield* FileSystem.FileSystem; | 4928 | const fileSystem = yield* FileSystem.FileSystem; |
| 4930 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 4929 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 4931 | const path = yield* Path.Path; | 4930 | const path = yield* Path.Path; |
| 4932 | const serverConfig = yield* ServerConfig.ServerConfig; | 4931 | const host = yield* ProviderHost.ProviderHost; |
| 4933 | const selfInvocation = yield* resolveSelfInvocation(); | 4932 | const selfInvocation = yield* resolveSelfInvocation(); |
| 4934 | const mockAgentPath = yield* path.fromFileUrl( | 4933 | const mockAgentPath = yield* path.fromFileUrl( |
| 4935 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 4934 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -4964,7 +4963,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -4964,7 +4963,7 @@ describe("AcpAdapterV2", () => { |
| 4964 | }, | 4963 | }, |
| 4965 | fileSystem, | 4964 | fileSystem, |
| 4966 | idAllocator, | 4965 | idAllocator, |
| 4967 | serverConfig, | 4966 | host, |
| 4968 | selfInvocation, | 4967 | selfInvocation, |
| 4969 | }); | 4968 | }); |
| 4970 | const threadId = ThreadId.make("thread-acp-teardown-wins-elicitation"); | 4969 | const threadId = ThreadId.make("thread-acp-teardown-wins-elicitation"); |
| ... | @@ -5042,7 +5041,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -5042,7 +5041,7 @@ describe("AcpAdapterV2", () => { |
| 5042 | const fileSystem = yield* FileSystem.FileSystem; | 5041 | const fileSystem = yield* FileSystem.FileSystem; |
| 5043 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 5042 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 5044 | const path = yield* Path.Path; | 5043 | const path = yield* Path.Path; |
| 5045 | const serverConfig = yield* ServerConfig.ServerConfig; | 5044 | const host = yield* ProviderHost.ProviderHost; |
| 5046 | const selfInvocation = yield* resolveSelfInvocation(); | 5045 | const selfInvocation = yield* resolveSelfInvocation(); |
| 5047 | const mockAgentPath = yield* path.fromFileUrl( | 5046 | const mockAgentPath = yield* path.fromFileUrl( |
| 5048 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 5047 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -5064,7 +5063,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -5064,7 +5063,7 @@ describe("AcpAdapterV2", () => { |
| 5064 | }, | 5063 | }, |
| 5065 | fileSystem, | 5064 | fileSystem, |
| 5066 | idAllocator, | 5065 | idAllocator, |
| 5067 | serverConfig, | 5066 | host, |
| 5068 | selfInvocation, | 5067 | selfInvocation, |
| 5069 | }); | 5068 | }); |
| 5070 | const threadId = ThreadId.make("thread-acp-cancel-timeout"); | 5069 | const threadId = ThreadId.make("thread-acp-cancel-timeout"); |
| ... | @@ -5145,7 +5144,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -5145,7 +5144,7 @@ describe("AcpAdapterV2", () => { |
| 5145 | const fileSystem = yield* FileSystem.FileSystem; | 5144 | const fileSystem = yield* FileSystem.FileSystem; |
| 5146 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 5145 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 5147 | const path = yield* Path.Path; | 5146 | const path = yield* Path.Path; |
| 5148 | const serverConfig = yield* ServerConfig.ServerConfig; | 5147 | const host = yield* ProviderHost.ProviderHost; |
| 5149 | const selfInvocation = yield* resolveSelfInvocation(); | 5148 | const selfInvocation = yield* resolveSelfInvocation(); |
| 5150 | const mockAgentPath = yield* path.fromFileUrl( | 5149 | const mockAgentPath = yield* path.fromFileUrl( |
| 5151 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 5150 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -5171,7 +5170,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -5171,7 +5170,7 @@ describe("AcpAdapterV2", () => { |
| 5171 | }, | 5170 | }, |
| 5172 | fileSystem, | 5171 | fileSystem, |
| 5173 | idAllocator, | 5172 | idAllocator, |
| 5174 | serverConfig, | 5173 | host, |
| 5175 | selfInvocation, | 5174 | selfInvocation, |
| 5176 | }); | 5175 | }); |
| 5177 | const threadId = ThreadId.make("thread-acp-double-stop"); | 5176 | const threadId = ThreadId.make("thread-acp-double-stop"); |
| ... | @@ -5243,7 +5242,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -5243,7 +5242,7 @@ describe("AcpAdapterV2", () => { |
| 5243 | const fileSystem = yield* FileSystem.FileSystem; | 5242 | const fileSystem = yield* FileSystem.FileSystem; |
| 5244 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 5243 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 5245 | const path = yield* Path.Path; | 5244 | const path = yield* Path.Path; |
| 5246 | const serverConfig = yield* ServerConfig.ServerConfig; | 5245 | const host = yield* ProviderHost.ProviderHost; |
| 5247 | const selfInvocation = yield* resolveSelfInvocation(); | 5246 | const selfInvocation = yield* resolveSelfInvocation(); |
| 5248 | const mockAgentPath = yield* path.fromFileUrl( | 5247 | const mockAgentPath = yield* path.fromFileUrl( |
| 5249 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 5248 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -5281,7 +5280,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -5281,7 +5280,7 @@ describe("AcpAdapterV2", () => { |
| 5281 | }, | 5280 | }, |
| 5282 | fileSystem, | 5281 | fileSystem, |
| 5283 | idAllocator, | 5282 | idAllocator, |
| 5284 | serverConfig, | 5283 | host, |
| 5285 | selfInvocation, | 5284 | selfInvocation, |
| 5286 | }); | 5285 | }); |
| 5287 | const threadId = ThreadId.make("thread-acp-interrupt-background-hold"); | 5286 | const threadId = ThreadId.make("thread-acp-interrupt-background-hold"); |
| ... | @@ -5339,7 +5338,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -5339,7 +5338,7 @@ describe("AcpAdapterV2", () => { |
| 5339 | const fileSystem = yield* FileSystem.FileSystem; | 5338 | const fileSystem = yield* FileSystem.FileSystem; |
| 5340 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 5339 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 5341 | const path = yield* Path.Path; | 5340 | const path = yield* Path.Path; |
| 5342 | const serverConfig = yield* ServerConfig.ServerConfig; | 5341 | const host = yield* ProviderHost.ProviderHost; |
| 5343 | const selfInvocation = yield* resolveSelfInvocation(); | 5342 | const selfInvocation = yield* resolveSelfInvocation(); |
| 5344 | const mockAgentPath = yield* path.fromFileUrl( | 5343 | const mockAgentPath = yield* path.fromFileUrl( |
| 5345 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 5344 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -5391,7 +5390,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -5391,7 +5390,7 @@ describe("AcpAdapterV2", () => { |
| 5391 | }, | 5390 | }, |
| 5392 | fileSystem, | 5391 | fileSystem, |
| 5393 | idAllocator, | 5392 | idAllocator, |
| 5394 | serverConfig, | 5393 | host, |
| 5395 | selfInvocation, | 5394 | selfInvocation, |
| 5396 | }); | 5395 | }); |
| 5397 | const threadId = ThreadId.make("thread-acp-subagent-carryover"); | 5396 | const threadId = ThreadId.make("thread-acp-subagent-carryover"); |
| ... | @@ -5487,7 +5486,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -5487,7 +5486,7 @@ describe("AcpAdapterV2", () => { |
| 5487 | const fileSystem = yield* FileSystem.FileSystem; | 5486 | const fileSystem = yield* FileSystem.FileSystem; |
| 5488 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 5487 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 5489 | const path = yield* Path.Path; | 5488 | const path = yield* Path.Path; |
| 5490 | const serverConfig = yield* ServerConfig.ServerConfig; | 5489 | const host = yield* ProviderHost.ProviderHost; |
| 5491 | const selfInvocation = yield* resolveSelfInvocation(); | 5490 | const selfInvocation = yield* resolveSelfInvocation(); |
| 5492 | const mockAgentPath = yield* path.fromFileUrl( | 5491 | const mockAgentPath = yield* path.fromFileUrl( |
| 5493 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 5492 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -5537,7 +5536,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -5537,7 +5536,7 @@ describe("AcpAdapterV2", () => { |
| 5537 | }, | 5536 | }, |
| 5538 | fileSystem, | 5537 | fileSystem, |
| 5539 | idAllocator, | 5538 | idAllocator, |
| 5540 | serverConfig, | 5539 | host, |
| 5541 | selfInvocation, | 5540 | selfInvocation, |
| 5542 | continuationRequests: { offer: () => Effect.void }, | 5541 | continuationRequests: { offer: () => Effect.void }, |
| 5543 | }); | 5542 | }); |
| ... | @@ -5634,7 +5633,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -5634,7 +5633,7 @@ describe("AcpAdapterV2", () => { |
| 5634 | const fileSystem = yield* FileSystem.FileSystem; | 5633 | const fileSystem = yield* FileSystem.FileSystem; |
| 5635 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 5634 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 5636 | const path = yield* Path.Path; | 5635 | const path = yield* Path.Path; |
| 5637 | const serverConfig = yield* ServerConfig.ServerConfig; | 5636 | const host = yield* ProviderHost.ProviderHost; |
| 5638 | const selfInvocation = yield* resolveSelfInvocation(); | 5637 | const selfInvocation = yield* resolveSelfInvocation(); |
| 5639 | const mockAgentPath = yield* path.fromFileUrl( | 5638 | const mockAgentPath = yield* path.fromFileUrl( |
| 5640 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 5639 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -5707,7 +5706,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -5707,7 +5706,7 @@ describe("AcpAdapterV2", () => { |
| 5707 | }, | 5706 | }, |
| 5708 | fileSystem, | 5707 | fileSystem, |
| 5709 | idAllocator, | 5708 | idAllocator, |
| 5710 | serverConfig, | 5709 | host, |
| 5711 | selfInvocation, | 5710 | selfInvocation, |
| 5712 | continuationRequests: { offer: () => Effect.void }, | 5711 | continuationRequests: { offer: () => Effect.void }, |
| 5713 | }); | 5712 | }); |
| ... | @@ -5871,7 +5870,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -5871,7 +5870,7 @@ describe("AcpAdapterV2", () => { |
| 5871 | const fileSystem = yield* FileSystem.FileSystem; | 5870 | const fileSystem = yield* FileSystem.FileSystem; |
| 5872 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 5871 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 5873 | const path = yield* Path.Path; | 5872 | const path = yield* Path.Path; |
| 5874 | const serverConfig = yield* ServerConfig.ServerConfig; | 5873 | const host = yield* ProviderHost.ProviderHost; |
| 5875 | const selfInvocation = yield* resolveSelfInvocation(); | 5874 | const selfInvocation = yield* resolveSelfInvocation(); |
| 5876 | const mockAgentPath = yield* path.fromFileUrl( | 5875 | const mockAgentPath = yield* path.fromFileUrl( |
| 5877 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 5876 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -5958,7 +5957,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -5958,7 +5957,7 @@ describe("AcpAdapterV2", () => { |
| 5958 | }, | 5957 | }, |
| 5959 | fileSystem, | 5958 | fileSystem, |
| 5960 | idAllocator, | 5959 | idAllocator, |
| 5961 | serverConfig, | 5960 | host, |
| 5962 | selfInvocation, | 5961 | selfInvocation, |
| 5963 | continuationRequests: { offer: () => Effect.void }, | 5962 | continuationRequests: { offer: () => Effect.void }, |
| 5964 | }); | 5963 | }); |
| ... | @@ -6094,7 +6093,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -6094,7 +6093,7 @@ describe("AcpAdapterV2", () => { |
| 6094 | const fileSystem = yield* FileSystem.FileSystem; | 6093 | const fileSystem = yield* FileSystem.FileSystem; |
| 6095 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 6094 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 6096 | const path = yield* Path.Path; | 6095 | const path = yield* Path.Path; |
| 6097 | const serverConfig = yield* ServerConfig.ServerConfig; | 6096 | const host = yield* ProviderHost.ProviderHost; |
| 6098 | const selfInvocation = yield* resolveSelfInvocation(); | 6097 | const selfInvocation = yield* resolveSelfInvocation(); |
| 6099 | const mockAgentPath = yield* path.fromFileUrl( | 6098 | const mockAgentPath = yield* path.fromFileUrl( |
| 6100 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 6099 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -6157,7 +6156,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -6157,7 +6156,7 @@ describe("AcpAdapterV2", () => { |
| 6157 | }, | 6156 | }, |
| 6158 | fileSystem, | 6157 | fileSystem, |
| 6159 | idAllocator, | 6158 | idAllocator, |
| 6160 | serverConfig, | 6159 | host, |
| 6161 | selfInvocation, | 6160 | selfInvocation, |
| 6162 | continuationRequests: { | 6161 | continuationRequests: { |
| 6163 | offer: (request) => | 6162 | offer: (request) => |
| ... | @@ -6313,7 +6312,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -6313,7 +6312,7 @@ describe("AcpAdapterV2", () => { |
| 6313 | const fileSystem = yield* FileSystem.FileSystem; | 6312 | const fileSystem = yield* FileSystem.FileSystem; |
| 6314 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 6313 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 6315 | const path = yield* Path.Path; | 6314 | const path = yield* Path.Path; |
| 6316 | const serverConfig = yield* ServerConfig.ServerConfig; | 6315 | const host = yield* ProviderHost.ProviderHost; |
| 6317 | const selfInvocation = yield* resolveSelfInvocation(); | 6316 | const selfInvocation = yield* resolveSelfInvocation(); |
| 6318 | const mockAgentPath = yield* path.fromFileUrl( | 6317 | const mockAgentPath = yield* path.fromFileUrl( |
| 6319 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 6318 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -6355,7 +6354,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -6355,7 +6354,7 @@ describe("AcpAdapterV2", () => { |
| 6355 | }, | 6354 | }, |
| 6356 | fileSystem, | 6355 | fileSystem, |
| 6357 | idAllocator, | 6356 | idAllocator, |
| 6358 | serverConfig, | 6357 | host, |
| 6359 | selfInvocation, | 6358 | selfInvocation, |
| 6360 | continuationRequests: { offer: () => Effect.void }, | 6359 | continuationRequests: { offer: () => Effect.void }, |
| 6361 | }); | 6360 | }); |
| ... | @@ -6461,7 +6460,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -6461,7 +6460,7 @@ describe("AcpAdapterV2", () => { |
| 6461 | const fileSystem = yield* FileSystem.FileSystem; | 6460 | const fileSystem = yield* FileSystem.FileSystem; |
| 6462 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 6461 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 6463 | const path = yield* Path.Path; | 6462 | const path = yield* Path.Path; |
| 6464 | const serverConfig = yield* ServerConfig.ServerConfig; | 6463 | const host = yield* ProviderHost.ProviderHost; |
| 6465 | const selfInvocation = yield* resolveSelfInvocation(); | 6464 | const selfInvocation = yield* resolveSelfInvocation(); |
| 6466 | const mockAgentPath = yield* path.fromFileUrl( | 6465 | const mockAgentPath = yield* path.fromFileUrl( |
| 6467 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 6466 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -6520,7 +6519,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -6520,7 +6519,7 @@ describe("AcpAdapterV2", () => { |
| 6520 | }, | 6519 | }, |
| 6521 | fileSystem, | 6520 | fileSystem, |
| 6522 | idAllocator, | 6521 | idAllocator, |
| 6523 | serverConfig, | 6522 | host, |
| 6524 | selfInvocation, | 6523 | selfInvocation, |
| 6525 | continuationRequests: { | 6524 | continuationRequests: { |
| 6526 | offer: (request) => | 6525 | offer: (request) => |
| ... | @@ -6713,7 +6712,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -6713,7 +6712,7 @@ describe("AcpAdapterV2", () => { |
| 6713 | const fileSystem = yield* FileSystem.FileSystem; | 6712 | const fileSystem = yield* FileSystem.FileSystem; |
| 6714 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 6713 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 6715 | const path = yield* Path.Path; | 6714 | const path = yield* Path.Path; |
| 6716 | const serverConfig = yield* ServerConfig.ServerConfig; | 6715 | const host = yield* ProviderHost.ProviderHost; |
| 6717 | const selfInvocation = yield* resolveSelfInvocation(); | 6716 | const selfInvocation = yield* resolveSelfInvocation(); |
| 6718 | const mockAgentPath = yield* path.fromFileUrl( | 6717 | const mockAgentPath = yield* path.fromFileUrl( |
| 6719 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 6718 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -6774,7 +6773,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -6774,7 +6773,7 @@ describe("AcpAdapterV2", () => { |
| 6774 | }, | 6773 | }, |
| 6775 | fileSystem, | 6774 | fileSystem, |
| 6776 | idAllocator, | 6775 | idAllocator, |
| 6777 | serverConfig, | 6776 | host, |
| 6778 | selfInvocation, | 6777 | selfInvocation, |
| 6779 | continuationRequests: { | 6778 | continuationRequests: { |
| 6780 | offer: (request) => | 6779 | offer: (request) => |
| ... | @@ -7001,7 +7000,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -7001,7 +7000,7 @@ describe("AcpAdapterV2", () => { |
| 7001 | const fileSystem = yield* FileSystem.FileSystem; | 7000 | const fileSystem = yield* FileSystem.FileSystem; |
| 7002 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 7001 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 7003 | const path = yield* Path.Path; | 7002 | const path = yield* Path.Path; |
| 7004 | const serverConfig = yield* ServerConfig.ServerConfig; | 7003 | const host = yield* ProviderHost.ProviderHost; |
| 7005 | const selfInvocation = yield* resolveSelfInvocation(); | 7004 | const selfInvocation = yield* resolveSelfInvocation(); |
| 7006 | const mockAgentPath = yield* path.fromFileUrl( | 7005 | const mockAgentPath = yield* path.fromFileUrl( |
| 7007 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 7006 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -7072,7 +7071,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -7072,7 +7071,7 @@ describe("AcpAdapterV2", () => { |
| 7072 | }, | 7071 | }, |
| 7073 | fileSystem, | 7072 | fileSystem, |
| 7074 | idAllocator, | 7073 | idAllocator, |
| 7075 | serverConfig, | 7074 | host, |
| 7076 | selfInvocation, | 7075 | selfInvocation, |
| 7077 | continuationRequests: { | 7076 | continuationRequests: { |
| 7078 | offer: (request) => | 7077 | offer: (request) => |
| ... | @@ -7242,7 +7241,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -7242,7 +7241,7 @@ describe("AcpAdapterV2", () => { |
| 7242 | const fileSystem = yield* FileSystem.FileSystem; | 7241 | const fileSystem = yield* FileSystem.FileSystem; |
| 7243 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 7242 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 7244 | const path = yield* Path.Path; | 7243 | const path = yield* Path.Path; |
| 7245 | const serverConfig = yield* ServerConfig.ServerConfig; | 7244 | const host = yield* ProviderHost.ProviderHost; |
| 7246 | const selfInvocation = yield* resolveSelfInvocation(); | 7245 | const selfInvocation = yield* resolveSelfInvocation(); |
| 7247 | const mockAgentPath = yield* path.fromFileUrl( | 7246 | const mockAgentPath = yield* path.fromFileUrl( |
| 7248 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 7247 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -7316,7 +7315,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -7316,7 +7315,7 @@ describe("AcpAdapterV2", () => { |
| 7316 | }, | 7315 | }, |
| 7317 | fileSystem, | 7316 | fileSystem, |
| 7318 | idAllocator, | 7317 | idAllocator, |
| 7319 | serverConfig, | 7318 | host, |
| 7320 | selfInvocation, | 7319 | selfInvocation, |
| 7321 | continuationRequests: { | 7320 | continuationRequests: { |
| 7322 | offer: (request) => | 7321 | offer: (request) => |
| ... | @@ -7471,7 +7470,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -7471,7 +7470,7 @@ describe("AcpAdapterV2", () => { |
| 7471 | const fileSystem = yield* FileSystem.FileSystem; | 7470 | const fileSystem = yield* FileSystem.FileSystem; |
| 7472 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 7471 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 7473 | const path = yield* Path.Path; | 7472 | const path = yield* Path.Path; |
| 7474 | const serverConfig = yield* ServerConfig.ServerConfig; | 7473 | const host = yield* ProviderHost.ProviderHost; |
| 7475 | const selfInvocation = yield* resolveSelfInvocation(); | 7474 | const selfInvocation = yield* resolveSelfInvocation(); |
| 7476 | const mockAgentPath = yield* path.fromFileUrl( | 7475 | const mockAgentPath = yield* path.fromFileUrl( |
| 7477 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 7476 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -7533,7 +7532,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -7533,7 +7532,7 @@ describe("AcpAdapterV2", () => { |
| 7533 | }, | 7532 | }, |
| 7534 | fileSystem, | 7533 | fileSystem, |
| 7535 | idAllocator, | 7534 | idAllocator, |
| 7536 | serverConfig, | 7535 | host, |
| 7537 | selfInvocation, | 7536 | selfInvocation, |
| 7538 | continuationRequests: { | 7537 | continuationRequests: { |
| 7539 | offer: (request) => | 7538 | offer: (request) => |
| ... | @@ -7742,7 +7741,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -7742,7 +7741,7 @@ describe("AcpAdapterV2", () => { |
| 7742 | const fileSystem = yield* FileSystem.FileSystem; | 7741 | const fileSystem = yield* FileSystem.FileSystem; |
| 7743 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 7742 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 7744 | const path = yield* Path.Path; | 7743 | const path = yield* Path.Path; |
| 7745 | const serverConfig = yield* ServerConfig.ServerConfig; | 7744 | const host = yield* ProviderHost.ProviderHost; |
| 7746 | const selfInvocation = yield* resolveSelfInvocation(); | 7745 | const selfInvocation = yield* resolveSelfInvocation(); |
| 7747 | const mockAgentPath = yield* path.fromFileUrl( | 7746 | const mockAgentPath = yield* path.fromFileUrl( |
| 7748 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 7747 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -7803,7 +7802,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -7803,7 +7802,7 @@ describe("AcpAdapterV2", () => { |
| 7803 | }, | 7802 | }, |
| 7804 | fileSystem, | 7803 | fileSystem, |
| 7805 | idAllocator, | 7804 | idAllocator, |
| 7806 | serverConfig, | 7805 | host, |
| 7807 | selfInvocation, | 7806 | selfInvocation, |
| 7808 | continuationRequests: { | 7807 | continuationRequests: { |
| 7809 | offer: (request) => | 7808 | offer: (request) => |
| ... | @@ -7984,7 +7983,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -7984,7 +7983,7 @@ describe("AcpAdapterV2", () => { |
| 7984 | const fileSystem = yield* FileSystem.FileSystem; | 7983 | const fileSystem = yield* FileSystem.FileSystem; |
| 7985 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 7984 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 7986 | const path = yield* Path.Path; | 7985 | const path = yield* Path.Path; |
| 7987 | const serverConfig = yield* ServerConfig.ServerConfig; | 7986 | const host = yield* ProviderHost.ProviderHost; |
| 7988 | const selfInvocation = yield* resolveSelfInvocation(); | 7987 | const selfInvocation = yield* resolveSelfInvocation(); |
| 7989 | const mockAgentPath = yield* path.fromFileUrl( | 7988 | const mockAgentPath = yield* path.fromFileUrl( |
| 7990 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 7989 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -8053,7 +8052,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -8053,7 +8052,7 @@ describe("AcpAdapterV2", () => { |
| 8053 | }, | 8052 | }, |
| 8054 | fileSystem, | 8053 | fileSystem, |
| 8055 | idAllocator, | 8054 | idAllocator, |
| 8056 | serverConfig, | 8055 | host, |
| 8057 | selfInvocation, | 8056 | selfInvocation, |
| 8058 | }); | 8057 | }); |
| 8059 | const threadId = ThreadId.make("thread-acp-settled-soft-steer"); | 8058 | const threadId = ThreadId.make("thread-acp-settled-soft-steer"); |
| ... | @@ -8160,7 +8159,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -8160,7 +8159,7 @@ describe("AcpAdapterV2", () => { |
| 8160 | const fileSystem = yield* FileSystem.FileSystem; | 8159 | const fileSystem = yield* FileSystem.FileSystem; |
| 8161 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 8160 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 8162 | const path = yield* Path.Path; | 8161 | const path = yield* Path.Path; |
| 8163 | const serverConfig = yield* ServerConfig.ServerConfig; | 8162 | const host = yield* ProviderHost.ProviderHost; |
| 8164 | const selfInvocation = yield* resolveSelfInvocation(); | 8163 | const selfInvocation = yield* resolveSelfInvocation(); |
| 8165 | const mockAgentPath = yield* path.fromFileUrl( | 8164 | const mockAgentPath = yield* path.fromFileUrl( |
| 8166 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 8165 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -8192,7 +8191,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -8192,7 +8191,7 @@ describe("AcpAdapterV2", () => { |
| 8192 | }, | 8191 | }, |
| 8193 | fileSystem, | 8192 | fileSystem, |
| 8194 | idAllocator, | 8193 | idAllocator, |
| 8195 | serverConfig, | 8194 | host, |
| 8196 | selfInvocation, | 8195 | selfInvocation, |
| 8197 | }); | 8196 | }); |
| 8198 | const threadId = ThreadId.make("thread-acp-unsettled-steer-stays-hard"); | 8197 | const threadId = ThreadId.make("thread-acp-unsettled-steer-stays-hard"); |
| ... | @@ -8250,7 +8249,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -8250,7 +8249,7 @@ describe("AcpAdapterV2", () => { |
| 8250 | const fileSystem = yield* FileSystem.FileSystem; | 8249 | const fileSystem = yield* FileSystem.FileSystem; |
| 8251 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 8250 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 8252 | const path = yield* Path.Path; | 8251 | const path = yield* Path.Path; |
| 8253 | const serverConfig = yield* ServerConfig.ServerConfig; | 8252 | const host = yield* ProviderHost.ProviderHost; |
| 8254 | const selfInvocation = yield* resolveSelfInvocation(); | 8253 | const selfInvocation = yield* resolveSelfInvocation(); |
| 8255 | const mockAgentPath = yield* path.fromFileUrl( | 8254 | const mockAgentPath = yield* path.fromFileUrl( |
| 8256 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 8255 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -8298,7 +8297,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -8298,7 +8297,7 @@ describe("AcpAdapterV2", () => { |
| 8298 | }, | 8297 | }, |
| 8299 | fileSystem, | 8298 | fileSystem, |
| 8300 | idAllocator, | 8299 | idAllocator, |
| 8301 | serverConfig, | 8300 | host, |
| 8302 | selfInvocation, | 8301 | selfInvocation, |
| 8303 | continuationRequests: { | 8302 | continuationRequests: { |
| 8304 | offer: (request) => | 8303 | offer: (request) => |
| ... | @@ -8444,7 +8443,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -8444,7 +8443,7 @@ describe("AcpAdapterV2", () => { |
| 8444 | const fileSystem = yield* FileSystem.FileSystem; | 8443 | const fileSystem = yield* FileSystem.FileSystem; |
| 8445 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 8444 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 8446 | const path = yield* Path.Path; | 8445 | const path = yield* Path.Path; |
| 8447 | const serverConfig = yield* ServerConfig.ServerConfig; | 8446 | const host = yield* ProviderHost.ProviderHost; |
| 8448 | const selfInvocation = yield* resolveSelfInvocation(); | 8447 | const selfInvocation = yield* resolveSelfInvocation(); |
| 8449 | const mockAgentPath = yield* path.fromFileUrl( | 8448 | const mockAgentPath = yield* path.fromFileUrl( |
| 8450 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 8449 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -8484,7 +8483,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -8484,7 +8483,7 @@ describe("AcpAdapterV2", () => { |
| 8484 | }, | 8483 | }, |
| 8485 | fileSystem, | 8484 | fileSystem, |
| 8486 | idAllocator, | 8485 | idAllocator, |
| 8487 | serverConfig, | 8486 | host, |
| 8488 | selfInvocation, | 8487 | selfInvocation, |
| 8489 | continuationRequests: { offer: () => Effect.void }, | 8488 | continuationRequests: { offer: () => Effect.void }, |
| 8490 | }); | 8489 | }); |
| ... | @@ -8591,7 +8590,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -8591,7 +8590,7 @@ describe("AcpAdapterV2", () => { |
| 8591 | const fileSystem = yield* FileSystem.FileSystem; | 8590 | const fileSystem = yield* FileSystem.FileSystem; |
| 8592 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 8591 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 8593 | const path = yield* Path.Path; | 8592 | const path = yield* Path.Path; |
| 8594 | const serverConfig = yield* ServerConfig.ServerConfig; | 8593 | const host = yield* ProviderHost.ProviderHost; |
| 8595 | const selfInvocation = yield* resolveSelfInvocation(); | 8594 | const selfInvocation = yield* resolveSelfInvocation(); |
| 8596 | const mockAgentPath = yield* path.fromFileUrl( | 8595 | const mockAgentPath = yield* path.fromFileUrl( |
| 8597 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 8596 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -8630,7 +8629,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -8630,7 +8629,7 @@ describe("AcpAdapterV2", () => { |
| 8630 | }, | 8629 | }, |
| 8631 | fileSystem, | 8630 | fileSystem, |
| 8632 | idAllocator, | 8631 | idAllocator, |
| 8633 | serverConfig, | 8632 | host, |
| 8634 | selfInvocation, | 8633 | selfInvocation, |
| 8635 | continuationRequests: { offer: () => Effect.void }, | 8634 | continuationRequests: { offer: () => Effect.void }, |
| 8636 | }); | 8635 | }); |
| ... | @@ -8722,7 +8721,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -8722,7 +8721,7 @@ describe("AcpAdapterV2", () => { |
| 8722 | const fileSystem = yield* FileSystem.FileSystem; | 8721 | const fileSystem = yield* FileSystem.FileSystem; |
| 8723 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 8722 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 8724 | const path = yield* Path.Path; | 8723 | const path = yield* Path.Path; |
| 8725 | const serverConfig = yield* ServerConfig.ServerConfig; | 8724 | const host = yield* ProviderHost.ProviderHost; |
| 8726 | const selfInvocation = yield* resolveSelfInvocation(); | 8725 | const selfInvocation = yield* resolveSelfInvocation(); |
| 8727 | const mockAgentPath = yield* path.fromFileUrl( | 8726 | const mockAgentPath = yield* path.fromFileUrl( |
| 8728 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 8727 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -8799,7 +8798,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -8799,7 +8798,7 @@ describe("AcpAdapterV2", () => { |
| 8799 | }, | 8798 | }, |
| 8800 | fileSystem, | 8799 | fileSystem, |
| 8801 | idAllocator, | 8800 | idAllocator, |
| 8802 | serverConfig, | 8801 | host, |
| 8803 | selfInvocation, | 8802 | selfInvocation, |
| 8804 | }); | 8803 | }); |
| 8805 | const threadId = ThreadId.make("thread-acp-stop-after-soft-steer-orphan"); | 8804 | const threadId = ThreadId.make("thread-acp-stop-after-soft-steer-orphan"); |
| ... | @@ -9026,7 +9025,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -9026,7 +9025,7 @@ describe("AcpAdapterV2", () => { |
| 9026 | const fileSystem = yield* FileSystem.FileSystem; | 9025 | const fileSystem = yield* FileSystem.FileSystem; |
| 9027 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 9026 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 9028 | const path = yield* Path.Path; | 9027 | const path = yield* Path.Path; |
| 9029 | const serverConfig = yield* ServerConfig.ServerConfig; | 9028 | const host = yield* ProviderHost.ProviderHost; |
| 9030 | const selfInvocation = yield* resolveSelfInvocation(); | 9029 | const selfInvocation = yield* resolveSelfInvocation(); |
| 9031 | const mockAgentPath = yield* path.fromFileUrl( | 9030 | const mockAgentPath = yield* path.fromFileUrl( |
| 9032 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 9031 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -9087,7 +9086,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -9087,7 +9086,7 @@ describe("AcpAdapterV2", () => { |
| 9087 | }, | 9086 | }, |
| 9088 | fileSystem, | 9087 | fileSystem, |
| 9089 | idAllocator, | 9088 | idAllocator, |
| 9090 | serverConfig, | 9089 | host, |
| 9091 | selfInvocation, | 9090 | selfInvocation, |
| 9092 | continuationRequests: { offer: () => Effect.void }, | 9091 | continuationRequests: { offer: () => Effect.void }, |
| 9093 | }); | 9092 | }); |
| ... | @@ -9223,7 +9222,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -9223,7 +9222,7 @@ describe("AcpAdapterV2", () => { |
| 9223 | const fileSystem = yield* FileSystem.FileSystem; | 9222 | const fileSystem = yield* FileSystem.FileSystem; |
| 9224 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 9223 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 9225 | const path = yield* Path.Path; | 9224 | const path = yield* Path.Path; |
| 9226 | const serverConfig = yield* ServerConfig.ServerConfig; | 9225 | const host = yield* ProviderHost.ProviderHost; |
| 9227 | const selfInvocation = yield* resolveSelfInvocation(); | 9226 | const selfInvocation = yield* resolveSelfInvocation(); |
| 9228 | const mockAgentPath = yield* path.fromFileUrl( | 9227 | const mockAgentPath = yield* path.fromFileUrl( |
| 9229 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 9228 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -9298,7 +9297,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -9298,7 +9297,7 @@ describe("AcpAdapterV2", () => { |
| 9298 | }, | 9297 | }, |
| 9299 | fileSystem, | 9298 | fileSystem, |
| 9300 | idAllocator, | 9299 | idAllocator, |
| 9301 | serverConfig, | 9300 | host, |
| 9302 | selfInvocation, | 9301 | selfInvocation, |
| 9303 | }); | 9302 | }); |
| 9304 | const threadId = ThreadId.make("thread-acp-settled-soft-admission-race"); | 9303 | const threadId = ThreadId.make("thread-acp-settled-soft-admission-race"); |
| ... | @@ -9393,7 +9392,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -9393,7 +9392,7 @@ describe("AcpAdapterV2", () => { |
| 9393 | const fileSystem = yield* FileSystem.FileSystem; | 9392 | const fileSystem = yield* FileSystem.FileSystem; |
| 9394 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 9393 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 9395 | const path = yield* Path.Path; | 9394 | const path = yield* Path.Path; |
| 9396 | const serverConfig = yield* ServerConfig.ServerConfig; | 9395 | const host = yield* ProviderHost.ProviderHost; |
| 9397 | const selfInvocation = yield* resolveSelfInvocation(); | 9396 | const selfInvocation = yield* resolveSelfInvocation(); |
| 9398 | const mockAgentPath = yield* path.fromFileUrl( | 9397 | const mockAgentPath = yield* path.fromFileUrl( |
| 9399 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 9398 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -9431,7 +9430,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -9431,7 +9430,7 @@ describe("AcpAdapterV2", () => { |
| 9431 | }, | 9430 | }, |
| 9432 | fileSystem, | 9431 | fileSystem, |
| 9433 | idAllocator, | 9432 | idAllocator, |
| 9434 | serverConfig, | 9433 | host, |
| 9435 | selfInvocation, | 9434 | selfInvocation, |
| 9436 | continuationRequests: { | 9435 | continuationRequests: { |
| 9437 | offer: (request) => | 9436 | offer: (request) => |
| ... | @@ -9524,7 +9523,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -9524,7 +9523,7 @@ describe("AcpAdapterV2", () => { |
| 9524 | const fileSystem = yield* FileSystem.FileSystem; | 9523 | const fileSystem = yield* FileSystem.FileSystem; |
| 9525 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 9524 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 9526 | const path = yield* Path.Path; | 9525 | const path = yield* Path.Path; |
| 9527 | const serverConfig = yield* ServerConfig.ServerConfig; | 9526 | const host = yield* ProviderHost.ProviderHost; |
| 9528 | const selfInvocation = yield* resolveSelfInvocation(); | 9527 | const selfInvocation = yield* resolveSelfInvocation(); |
| 9529 | const mockAgentPath = yield* path.fromFileUrl( | 9528 | const mockAgentPath = yield* path.fromFileUrl( |
| 9530 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 9529 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -9554,7 +9553,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -9554,7 +9553,7 @@ describe("AcpAdapterV2", () => { |
| 9554 | }, | 9553 | }, |
| 9555 | fileSystem, | 9554 | fileSystem, |
| 9556 | idAllocator, | 9555 | idAllocator, |
| 9557 | serverConfig, | 9556 | host, |
| 9558 | selfInvocation, | 9557 | selfInvocation, |
| 9559 | continuationRequests: { | 9558 | continuationRequests: { |
| 9560 | offer: (request) => | 9559 | offer: (request) => |
| ... | @@ -9644,7 +9643,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -9644,7 +9643,7 @@ describe("AcpAdapterV2", () => { |
| 9644 | const fileSystem = yield* FileSystem.FileSystem; | 9643 | const fileSystem = yield* FileSystem.FileSystem; |
| 9645 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 9644 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 9646 | const path = yield* Path.Path; | 9645 | const path = yield* Path.Path; |
| 9647 | const serverConfig = yield* ServerConfig.ServerConfig; | 9646 | const host = yield* ProviderHost.ProviderHost; |
| 9648 | const selfInvocation = yield* resolveSelfInvocation(); | 9647 | const selfInvocation = yield* resolveSelfInvocation(); |
| 9649 | const mockAgentPath = yield* path.fromFileUrl( | 9648 | const mockAgentPath = yield* path.fromFileUrl( |
| 9650 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 9649 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -9690,7 +9689,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -9690,7 +9689,7 @@ describe("AcpAdapterV2", () => { |
| 9690 | }, | 9689 | }, |
| 9691 | fileSystem, | 9690 | fileSystem, |
| 9692 | idAllocator, | 9691 | idAllocator, |
| 9693 | serverConfig, | 9692 | host, |
| 9694 | selfInvocation, | 9693 | selfInvocation, |
| 9695 | continuationRequests: { | 9694 | continuationRequests: { |
| 9696 | offer: (request) => | 9695 | offer: (request) => |
| ... | @@ -9853,7 +9852,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -9853,7 +9852,7 @@ describe("AcpAdapterV2", () => { |
| 9853 | const fileSystem = yield* FileSystem.FileSystem; | 9852 | const fileSystem = yield* FileSystem.FileSystem; |
| 9854 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 9853 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 9855 | const path = yield* Path.Path; | 9854 | const path = yield* Path.Path; |
| 9856 | const serverConfig = yield* ServerConfig.ServerConfig; | 9855 | const host = yield* ProviderHost.ProviderHost; |
| 9857 | const selfInvocation = yield* resolveSelfInvocation(); | 9856 | const selfInvocation = yield* resolveSelfInvocation(); |
| 9858 | const mockAgentPath = yield* path.fromFileUrl( | 9857 | const mockAgentPath = yield* path.fromFileUrl( |
| 9859 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 9858 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -9897,7 +9896,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -9897,7 +9896,7 @@ describe("AcpAdapterV2", () => { |
| 9897 | }, | 9896 | }, |
| 9898 | fileSystem, | 9897 | fileSystem, |
| 9899 | idAllocator, | 9898 | idAllocator, |
| 9900 | serverConfig, | 9899 | host, |
| 9901 | selfInvocation, | 9900 | selfInvocation, |
| 9902 | }); | 9901 | }); |
| 9903 | const threadId = ThreadId.make("thread-acp-injected-report-hold"); | 9902 | const threadId = ThreadId.make("thread-acp-injected-report-hold"); |
| ... | @@ -10019,7 +10018,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -10019,7 +10018,7 @@ describe("AcpAdapterV2", () => { |
| 10019 | const fileSystem = yield* FileSystem.FileSystem; | 10018 | const fileSystem = yield* FileSystem.FileSystem; |
| 10020 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 10019 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 10021 | const path = yield* Path.Path; | 10020 | const path = yield* Path.Path; |
| 10022 | const serverConfig = yield* ServerConfig.ServerConfig; | 10021 | const host = yield* ProviderHost.ProviderHost; |
| 10023 | const selfInvocation = yield* resolveSelfInvocation(); | 10022 | const selfInvocation = yield* resolveSelfInvocation(); |
| 10024 | const mockAgentPath = yield* path.fromFileUrl( | 10023 | const mockAgentPath = yield* path.fromFileUrl( |
| 10025 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 10024 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -10078,7 +10077,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -10078,7 +10077,7 @@ describe("AcpAdapterV2", () => { |
| 10078 | }, | 10077 | }, |
| 10079 | fileSystem, | 10078 | fileSystem, |
| 10080 | idAllocator, | 10079 | idAllocator, |
| 10081 | serverConfig, | 10080 | host, |
| 10082 | selfInvocation, | 10081 | selfInvocation, |
| 10083 | continuationRequests: { | 10082 | continuationRequests: { |
| 10084 | offer: (request) => | 10083 | offer: (request) => |
| ... | @@ -10221,7 +10220,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -10221,7 +10220,7 @@ describe("AcpAdapterV2", () => { |
| 10221 | const fileSystem = yield* FileSystem.FileSystem; | 10220 | const fileSystem = yield* FileSystem.FileSystem; |
| 10222 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 10221 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 10223 | const path = yield* Path.Path; | 10222 | const path = yield* Path.Path; |
| 10224 | const serverConfig = yield* ServerConfig.ServerConfig; | 10223 | const host = yield* ProviderHost.ProviderHost; |
| 10225 | const selfInvocation = yield* resolveSelfInvocation(); | 10224 | const selfInvocation = yield* resolveSelfInvocation(); |
| 10226 | const mockAgentPath = yield* path.fromFileUrl( | 10225 | const mockAgentPath = yield* path.fromFileUrl( |
| 10227 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 10226 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -10276,7 +10275,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -10276,7 +10275,7 @@ describe("AcpAdapterV2", () => { |
| 10276 | }, | 10275 | }, |
| 10277 | fileSystem, | 10276 | fileSystem, |
| 10278 | idAllocator, | 10277 | idAllocator, |
| 10279 | serverConfig, | 10278 | host, |
| 10280 | selfInvocation, | 10279 | selfInvocation, |
| 10281 | continuationRequests: { | 10280 | continuationRequests: { |
| 10282 | offer: (request) => | 10281 | offer: (request) => |
| ... | @@ -10461,7 +10460,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -10461,7 +10460,7 @@ describe("AcpAdapterV2", () => { |
| 10461 | const fileSystem = yield* FileSystem.FileSystem; | 10460 | const fileSystem = yield* FileSystem.FileSystem; |
| 10462 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 10461 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 10463 | const path = yield* Path.Path; | 10462 | const path = yield* Path.Path; |
| 10464 | const serverConfig = yield* ServerConfig.ServerConfig; | 10463 | const host = yield* ProviderHost.ProviderHost; |
| 10465 | const selfInvocation = yield* resolveSelfInvocation(); | 10464 | const selfInvocation = yield* resolveSelfInvocation(); |
| 10466 | const mockAgentPath = yield* path.fromFileUrl( | 10465 | const mockAgentPath = yield* path.fromFileUrl( |
| 10467 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 10466 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -10523,7 +10522,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -10523,7 +10522,7 @@ describe("AcpAdapterV2", () => { |
| 10523 | }, | 10522 | }, |
| 10524 | fileSystem, | 10523 | fileSystem, |
| 10525 | idAllocator, | 10524 | idAllocator, |
| 10526 | serverConfig, | 10525 | host, |
| 10527 | selfInvocation, | 10526 | selfInvocation, |
| 10528 | continuationRequests: { | 10527 | continuationRequests: { |
| 10529 | offer: (request) => | 10528 | offer: (request) => |
| ... | @@ -10680,7 +10679,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -10680,7 +10679,7 @@ describe("AcpAdapterV2", () => { |
| 10680 | const fileSystem = yield* FileSystem.FileSystem; | 10679 | const fileSystem = yield* FileSystem.FileSystem; |
| 10681 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 10680 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 10682 | const path = yield* Path.Path; | 10681 | const path = yield* Path.Path; |
| 10683 | const serverConfig = yield* ServerConfig.ServerConfig; | 10682 | const host = yield* ProviderHost.ProviderHost; |
| 10684 | const selfInvocation = yield* resolveSelfInvocation(); | 10683 | const selfInvocation = yield* resolveSelfInvocation(); |
| 10685 | const mockAgentPath = yield* path.fromFileUrl( | 10684 | const mockAgentPath = yield* path.fromFileUrl( |
| 10686 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 10685 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -10746,7 +10745,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -10746,7 +10745,7 @@ describe("AcpAdapterV2", () => { |
| 10746 | }, | 10745 | }, |
| 10747 | fileSystem, | 10746 | fileSystem, |
| 10748 | idAllocator, | 10747 | idAllocator, |
| 10749 | serverConfig, | 10748 | host, |
| 10750 | selfInvocation, | 10749 | selfInvocation, |
| 10751 | continuationRequests: { | 10750 | continuationRequests: { |
| 10752 | offer: (request) => | 10751 | offer: (request) => |
| ... | @@ -10895,7 +10894,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -10895,7 +10894,7 @@ describe("AcpAdapterV2", () => { |
| 10895 | const fileSystem = yield* FileSystem.FileSystem; | 10894 | const fileSystem = yield* FileSystem.FileSystem; |
| 10896 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 10895 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 10897 | const path = yield* Path.Path; | 10896 | const path = yield* Path.Path; |
| 10898 | const serverConfig = yield* ServerConfig.ServerConfig; | 10897 | const host = yield* ProviderHost.ProviderHost; |
| 10899 | const selfInvocation = yield* resolveSelfInvocation(); | 10898 | const selfInvocation = yield* resolveSelfInvocation(); |
| 10900 | const mockAgentPath = yield* path.fromFileUrl( | 10899 | const mockAgentPath = yield* path.fromFileUrl( |
| 10901 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 10900 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -10983,7 +10982,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -10983,7 +10982,7 @@ describe("AcpAdapterV2", () => { |
| 10983 | }, | 10982 | }, |
| 10984 | fileSystem, | 10983 | fileSystem, |
| 10985 | idAllocator, | 10984 | idAllocator, |
| 10986 | serverConfig, | 10985 | host, |
| 10987 | selfInvocation, | 10986 | selfInvocation, |
| 10988 | continuationRequests: { | 10987 | continuationRequests: { |
| 10989 | offer: (request) => | 10988 | offer: (request) => |
| ... | @@ -11267,7 +11266,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -11267,7 +11266,7 @@ describe("AcpAdapterV2", () => { |
| 11267 | const fileSystem = yield* FileSystem.FileSystem; | 11266 | const fileSystem = yield* FileSystem.FileSystem; |
| 11268 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 11267 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 11269 | const path = yield* Path.Path; | 11268 | const path = yield* Path.Path; |
| 11270 | const serverConfig = yield* ServerConfig.ServerConfig; | 11269 | const host = yield* ProviderHost.ProviderHost; |
| 11271 | const selfInvocation = yield* resolveSelfInvocation(); | 11270 | const selfInvocation = yield* resolveSelfInvocation(); |
| 11272 | const mockAgentPath = yield* path.fromFileUrl( | 11271 | const mockAgentPath = yield* path.fromFileUrl( |
| 11273 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 11272 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -11306,7 +11305,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -11306,7 +11305,7 @@ describe("AcpAdapterV2", () => { |
| 11306 | }, | 11305 | }, |
| 11307 | fileSystem, | 11306 | fileSystem, |
| 11308 | idAllocator, | 11307 | idAllocator, |
| 11309 | serverConfig, | 11308 | host, |
| 11310 | selfInvocation, | 11309 | selfInvocation, |
| 11311 | continuationRequests: { | 11310 | continuationRequests: { |
| 11312 | offer: (request) => | 11311 | offer: (request) => |
| ... | @@ -11421,7 +11420,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -11421,7 +11420,7 @@ describe("AcpAdapterV2", () => { |
| 11421 | const fileSystem = yield* FileSystem.FileSystem; | 11420 | const fileSystem = yield* FileSystem.FileSystem; |
| 11422 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 11421 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 11423 | const path = yield* Path.Path; | 11422 | const path = yield* Path.Path; |
| 11424 | const serverConfig = yield* ServerConfig.ServerConfig; | 11423 | const host = yield* ProviderHost.ProviderHost; |
| 11425 | const selfInvocation = yield* resolveSelfInvocation(); | 11424 | const selfInvocation = yield* resolveSelfInvocation(); |
| 11426 | const mockAgentPath = yield* path.fromFileUrl( | 11425 | const mockAgentPath = yield* path.fromFileUrl( |
| 11427 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 11426 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -11490,7 +11489,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -11490,7 +11489,7 @@ describe("AcpAdapterV2", () => { |
| 11490 | }, | 11489 | }, |
| 11491 | fileSystem, | 11490 | fileSystem, |
| 11492 | idAllocator, | 11491 | idAllocator, |
| 11493 | serverConfig, | 11492 | host, |
| 11494 | selfInvocation, | 11493 | selfInvocation, |
| 11495 | continuationRequests: { | 11494 | continuationRequests: { |
| 11496 | offer: (request) => | 11495 | offer: (request) => |
| ... | @@ -11752,7 +11751,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -11752,7 +11751,7 @@ describe("AcpAdapterV2", () => { |
| 11752 | const fileSystem = yield* FileSystem.FileSystem; | 11751 | const fileSystem = yield* FileSystem.FileSystem; |
| 11753 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 11752 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 11754 | const path = yield* Path.Path; | 11753 | const path = yield* Path.Path; |
| 11755 | const serverConfig = yield* ServerConfig.ServerConfig; | 11754 | const host = yield* ProviderHost.ProviderHost; |
| 11756 | const selfInvocation = yield* resolveSelfInvocation(); | 11755 | const selfInvocation = yield* resolveSelfInvocation(); |
| 11757 | const mockAgentPath = yield* path.fromFileUrl( | 11756 | const mockAgentPath = yield* path.fromFileUrl( |
| 11758 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 11757 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -11802,7 +11801,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -11802,7 +11801,7 @@ describe("AcpAdapterV2", () => { |
| 11802 | }, | 11801 | }, |
| 11803 | fileSystem, | 11802 | fileSystem, |
| 11804 | idAllocator, | 11803 | idAllocator, |
| 11805 | serverConfig, | 11804 | host, |
| 11806 | selfInvocation, | 11805 | selfInvocation, |
| 11807 | continuationRequests: { | 11806 | continuationRequests: { |
| 11808 | offer: (request) => | 11807 | offer: (request) => |
| ... | @@ -11939,7 +11938,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -11939,7 +11938,7 @@ describe("AcpAdapterV2", () => { |
| 11939 | const fileSystem = yield* FileSystem.FileSystem; | 11938 | const fileSystem = yield* FileSystem.FileSystem; |
| 11940 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 11939 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 11941 | const path = yield* Path.Path; | 11940 | const path = yield* Path.Path; |
| 11942 | const serverConfig = yield* ServerConfig.ServerConfig; | 11941 | const host = yield* ProviderHost.ProviderHost; |
| 11943 | const selfInvocation = yield* resolveSelfInvocation(); | 11942 | const selfInvocation = yield* resolveSelfInvocation(); |
| 11944 | const mockAgentPath = yield* path.fromFileUrl( | 11943 | const mockAgentPath = yield* path.fromFileUrl( |
| 11945 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 11944 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -11993,7 +11992,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -11993,7 +11992,7 @@ describe("AcpAdapterV2", () => { |
| 11993 | }, | 11992 | }, |
| 11994 | fileSystem, | 11993 | fileSystem, |
| 11995 | idAllocator, | 11994 | idAllocator, |
| 11996 | serverConfig, | 11995 | host, |
| 11997 | selfInvocation, | 11996 | selfInvocation, |
| 11998 | continuationRequests: { | 11997 | continuationRequests: { |
| 11999 | offer: (request) => | 11998 | offer: (request) => |
| ... | @@ -12183,7 +12182,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -12183,7 +12182,7 @@ describe("AcpAdapterV2", () => { |
| 12183 | const fileSystem = yield* FileSystem.FileSystem; | 12182 | const fileSystem = yield* FileSystem.FileSystem; |
| 12184 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 12183 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 12185 | const path = yield* Path.Path; | 12184 | const path = yield* Path.Path; |
| 12186 | const serverConfig = yield* ServerConfig.ServerConfig; | 12185 | const host = yield* ProviderHost.ProviderHost; |
| 12187 | const selfInvocation = yield* resolveSelfInvocation(); | 12186 | const selfInvocation = yield* resolveSelfInvocation(); |
| 12188 | const mockAgentPath = yield* path.fromFileUrl( | 12187 | const mockAgentPath = yield* path.fromFileUrl( |
| 12189 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 12188 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -12235,7 +12234,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -12235,7 +12234,7 @@ describe("AcpAdapterV2", () => { |
| 12235 | }, | 12234 | }, |
| 12236 | fileSystem, | 12235 | fileSystem, |
| 12237 | idAllocator, | 12236 | idAllocator, |
| 12238 | serverConfig, | 12237 | host, |
| 12239 | selfInvocation, | 12238 | selfInvocation, |
| 12240 | continuationRequests: { | 12239 | continuationRequests: { |
| 12241 | offer: (request) => | 12240 | offer: (request) => |
| ... | @@ -12373,7 +12372,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -12373,7 +12372,7 @@ describe("AcpAdapterV2", () => { |
| 12373 | const fileSystem = yield* FileSystem.FileSystem; | 12372 | const fileSystem = yield* FileSystem.FileSystem; |
| 12374 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 12373 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 12375 | const path = yield* Path.Path; | 12374 | const path = yield* Path.Path; |
| 12376 | const serverConfig = yield* ServerConfig.ServerConfig; | 12375 | const host = yield* ProviderHost.ProviderHost; |
| 12377 | const selfInvocation = yield* resolveSelfInvocation(); | 12376 | const selfInvocation = yield* resolveSelfInvocation(); |
| 12378 | const mockAgentPath = yield* path.fromFileUrl( | 12377 | const mockAgentPath = yield* path.fromFileUrl( |
| 12379 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 12378 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -12395,7 +12394,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -12395,7 +12394,7 @@ describe("AcpAdapterV2", () => { |
| 12395 | }, | 12394 | }, |
| 12396 | fileSystem, | 12395 | fileSystem, |
| 12397 | idAllocator, | 12396 | idAllocator, |
| 12398 | serverConfig, | 12397 | host, |
| 12399 | selfInvocation, | 12398 | selfInvocation, |
| 12400 | }); | 12399 | }); |
| 12401 | const threadId = ThreadId.make("thread-acp-restart-after-interrupt"); | 12400 | const threadId = ThreadId.make("thread-acp-restart-after-interrupt"); |
| ... | @@ -12619,7 +12618,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -12619,7 +12618,7 @@ describe("AcpAdapterV2", () => { |
| 12619 | const fileSystem = yield* FileSystem.FileSystem; | 12618 | const fileSystem = yield* FileSystem.FileSystem; |
| 12620 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 12619 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 12621 | const path = yield* Path.Path; | 12620 | const path = yield* Path.Path; |
| 12622 | const serverConfig = yield* ServerConfig.ServerConfig; | 12621 | const host = yield* ProviderHost.ProviderHost; |
| 12623 | const selfInvocation = yield* resolveSelfInvocation(); | 12622 | const selfInvocation = yield* resolveSelfInvocation(); |
| 12624 | const mockAgentPath = yield* path.fromFileUrl( | 12623 | const mockAgentPath = yield* path.fromFileUrl( |
| 12625 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 12624 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -12668,7 +12667,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -12668,7 +12667,7 @@ describe("AcpAdapterV2", () => { |
| 12668 | }, | 12667 | }, |
| 12669 | fileSystem, | 12668 | fileSystem, |
| 12670 | idAllocator, | 12669 | idAllocator, |
| 12671 | serverConfig, | 12670 | host, |
| 12672 | selfInvocation, | 12671 | selfInvocation, |
| 12673 | continuationRequests: { | 12672 | continuationRequests: { |
| 12674 | offer: (request) => | 12673 | offer: (request) => |
| ... | @@ -12837,7 +12836,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -12837,7 +12836,7 @@ describe("AcpAdapterV2", () => { |
| 12837 | const fileSystem = yield* FileSystem.FileSystem; | 12836 | const fileSystem = yield* FileSystem.FileSystem; |
| 12838 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 12837 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 12839 | const path = yield* Path.Path; | 12838 | const path = yield* Path.Path; |
| 12840 | const serverConfig = yield* ServerConfig.ServerConfig; | 12839 | const host = yield* ProviderHost.ProviderHost; |
| 12841 | const selfInvocation = yield* resolveSelfInvocation(); | 12840 | const selfInvocation = yield* resolveSelfInvocation(); |
| 12842 | const mockAgentPath = yield* path.fromFileUrl( | 12841 | const mockAgentPath = yield* path.fromFileUrl( |
| 12843 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 12842 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -12861,7 +12860,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -12861,7 +12860,7 @@ describe("AcpAdapterV2", () => { |
| 12861 | }, | 12860 | }, |
| 12862 | fileSystem, | 12861 | fileSystem, |
| 12863 | idAllocator, | 12862 | idAllocator, |
| 12864 | serverConfig, | 12863 | host, |
| 12865 | selfInvocation, | 12864 | selfInvocation, |
| 12866 | }); | 12865 | }); |
| 12867 | const threadId = ThreadId.make("thread-acp-missing-hard-teardown"); | 12866 | const threadId = ThreadId.make("thread-acp-missing-hard-teardown"); |
| ... | @@ -12926,7 +12925,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -12926,7 +12925,7 @@ describe("AcpAdapterV2", () => { |
| 12926 | const fileSystem = yield* FileSystem.FileSystem; | 12925 | const fileSystem = yield* FileSystem.FileSystem; |
| 12927 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 12926 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 12928 | const path = yield* Path.Path; | 12927 | const path = yield* Path.Path; |
| 12929 | const serverConfig = yield* ServerConfig.ServerConfig; | 12928 | const host = yield* ProviderHost.ProviderHost; |
| 12930 | const selfInvocation = yield* resolveSelfInvocation(); | 12929 | const selfInvocation = yield* resolveSelfInvocation(); |
| 12931 | const mockAgentPath = yield* path.fromFileUrl( | 12930 | const mockAgentPath = yield* path.fromFileUrl( |
| 12932 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 12931 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -12963,7 +12962,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -12963,7 +12962,7 @@ describe("AcpAdapterV2", () => { |
| 12963 | }, | 12962 | }, |
| 12964 | fileSystem, | 12963 | fileSystem, |
| 12965 | idAllocator, | 12964 | idAllocator, |
| 12966 | serverConfig, | 12965 | host, |
| 12967 | selfInvocation, | 12966 | selfInvocation, |
| 12968 | }); | 12967 | }); |
| 12969 | const threadId = ThreadId.make("thread-acp-concurrent-hard-teardown"); | 12968 | const threadId = ThreadId.make("thread-acp-concurrent-hard-teardown"); |
| ... | @@ -13039,7 +13038,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -13039,7 +13038,7 @@ describe("AcpAdapterV2", () => { |
| 13039 | const fileSystem = yield* FileSystem.FileSystem; | 13038 | const fileSystem = yield* FileSystem.FileSystem; |
| 13040 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 13039 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 13041 | const path = yield* Path.Path; | 13040 | const path = yield* Path.Path; |
| 13042 | const serverConfig = yield* ServerConfig.ServerConfig; | 13041 | const host = yield* ProviderHost.ProviderHost; |
| 13043 | const selfInvocation = yield* resolveSelfInvocation(); | 13042 | const selfInvocation = yield* resolveSelfInvocation(); |
| 13044 | const mockAgentPath = yield* path.fromFileUrl( | 13043 | const mockAgentPath = yield* path.fromFileUrl( |
| 13045 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 13044 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -13122,7 +13121,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -13122,7 +13121,7 @@ describe("AcpAdapterV2", () => { |
| 13122 | }, | 13121 | }, |
| 13123 | fileSystem, | 13122 | fileSystem, |
| 13124 | idAllocator, | 13123 | idAllocator, |
| 13125 | serverConfig, | 13124 | host, |
| 13126 | selfInvocation, | 13125 | selfInvocation, |
| 13127 | testHooks: { | 13126 | testHooks: { |
| 13128 | afterHardTeardownTransportDrained: () => | 13127 | afterHardTeardownTransportDrained: () => |
| ... | @@ -13401,7 +13400,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -13401,7 +13400,7 @@ describe("AcpAdapterV2", () => { |
| 13401 | const fileSystem = yield* FileSystem.FileSystem; | 13400 | const fileSystem = yield* FileSystem.FileSystem; |
| 13402 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 13401 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 13403 | const path = yield* Path.Path; | 13402 | const path = yield* Path.Path; |
| 13404 | const serverConfig = yield* ServerConfig.ServerConfig; | 13403 | const host = yield* ProviderHost.ProviderHost; |
| 13405 | const selfInvocation = yield* resolveSelfInvocation(); | 13404 | const selfInvocation = yield* resolveSelfInvocation(); |
| 13406 | const mockAgentPath = yield* path.fromFileUrl( | 13405 | const mockAgentPath = yield* path.fromFileUrl( |
| 13407 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 13406 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -13428,7 +13427,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -13428,7 +13427,7 @@ describe("AcpAdapterV2", () => { |
| 13428 | }, | 13427 | }, |
| 13429 | fileSystem, | 13428 | fileSystem, |
| 13430 | idAllocator, | 13429 | idAllocator, |
| 13431 | serverConfig, | 13430 | host, |
| 13432 | selfInvocation, | 13431 | selfInvocation, |
| 13433 | }); | 13432 | }); |
| 13434 | const threadId = ThreadId.make("thread-acp-stale-deferred-cleanup"); | 13433 | const threadId = ThreadId.make("thread-acp-stale-deferred-cleanup"); |
| ... | @@ -13526,7 +13525,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -13526,7 +13525,7 @@ describe("AcpAdapterV2", () => { |
| 13526 | const fileSystem = yield* FileSystem.FileSystem; | 13525 | const fileSystem = yield* FileSystem.FileSystem; |
| 13527 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 13526 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 13528 | const path = yield* Path.Path; | 13527 | const path = yield* Path.Path; |
| 13529 | const serverConfig = yield* ServerConfig.ServerConfig; | 13528 | const host = yield* ProviderHost.ProviderHost; |
| 13530 | const selfInvocation = yield* resolveSelfInvocation(); | 13529 | const selfInvocation = yield* resolveSelfInvocation(); |
| 13531 | const mockAgentPath = yield* path.fromFileUrl( | 13530 | const mockAgentPath = yield* path.fromFileUrl( |
| 13532 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 13531 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -13563,7 +13562,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -13563,7 +13562,7 @@ describe("AcpAdapterV2", () => { |
| 13563 | }, | 13562 | }, |
| 13564 | fileSystem, | 13563 | fileSystem, |
| 13565 | idAllocator, | 13564 | idAllocator, |
| 13566 | serverConfig, | 13565 | host, |
| 13567 | selfInvocation, | 13566 | selfInvocation, |
| 13568 | }); | 13567 | }); |
| 13569 | const threadId = ThreadId.make("thread-acp-concurrent-resume-teardown"); | 13568 | const threadId = ThreadId.make("thread-acp-concurrent-resume-teardown"); |
| ... | @@ -13660,7 +13659,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -13660,7 +13659,7 @@ describe("AcpAdapterV2", () => { |
| 13660 | const fileSystem = yield* FileSystem.FileSystem; | 13659 | const fileSystem = yield* FileSystem.FileSystem; |
| 13661 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 13660 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 13662 | const path = yield* Path.Path; | 13661 | const path = yield* Path.Path; |
| 13663 | const serverConfig = yield* ServerConfig.ServerConfig; | 13662 | const host = yield* ProviderHost.ProviderHost; |
| 13664 | const selfInvocation = yield* resolveSelfInvocation(); | 13663 | const selfInvocation = yield* resolveSelfInvocation(); |
| 13665 | const mockAgentPath = yield* path.fromFileUrl( | 13664 | const mockAgentPath = yield* path.fromFileUrl( |
| 13666 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 13665 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -13714,7 +13713,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -13714,7 +13713,7 @@ describe("AcpAdapterV2", () => { |
| 13714 | }, | 13713 | }, |
| 13715 | fileSystem, | 13714 | fileSystem, |
| 13716 | idAllocator, | 13715 | idAllocator, |
| 13717 | serverConfig, | 13716 | host, |
| 13718 | selfInvocation, | 13717 | selfInvocation, |
| 13719 | continuationRequests: { offer: () => Effect.void }, | 13718 | continuationRequests: { offer: () => Effect.void }, |
| 13720 | }); | 13719 | }); |
| ... | @@ -13937,7 +13936,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -13937,7 +13936,7 @@ describe("AcpAdapterV2", () => { |
| 13937 | const fileSystem = yield* FileSystem.FileSystem; | 13936 | const fileSystem = yield* FileSystem.FileSystem; |
| 13938 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 13937 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 13939 | const path = yield* Path.Path; | 13938 | const path = yield* Path.Path; |
| 13940 | const serverConfig = yield* ServerConfig.ServerConfig; | 13939 | const host = yield* ProviderHost.ProviderHost; |
| 13941 | const selfInvocation = yield* resolveSelfInvocation(); | 13940 | const selfInvocation = yield* resolveSelfInvocation(); |
| 13942 | const mockAgentPath = yield* path.fromFileUrl( | 13941 | const mockAgentPath = yield* path.fromFileUrl( |
| 13943 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 13942 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -13987,7 +13986,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -13987,7 +13986,7 @@ describe("AcpAdapterV2", () => { |
| 13987 | }, | 13986 | }, |
| 13988 | fileSystem, | 13987 | fileSystem, |
| 13989 | idAllocator, | 13988 | idAllocator, |
| 13990 | serverConfig, | 13989 | host, |
| 13991 | selfInvocation, | 13990 | selfInvocation, |
| 13992 | }); | 13991 | }); |
| 13993 | const threadId = ThreadId.make("thread-acp-direct-stop-subagent-hold"); | 13992 | const threadId = ThreadId.make("thread-acp-direct-stop-subagent-hold"); |
| ... | @@ -14091,7 +14090,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -14091,7 +14090,7 @@ describe("AcpAdapterV2", () => { |
| 14091 | const fileSystem = yield* FileSystem.FileSystem; | 14090 | const fileSystem = yield* FileSystem.FileSystem; |
| 14092 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 14091 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 14093 | const path = yield* Path.Path; | 14092 | const path = yield* Path.Path; |
| 14094 | const serverConfig = yield* ServerConfig.ServerConfig; | 14093 | const host = yield* ProviderHost.ProviderHost; |
| 14095 | const selfInvocation = yield* resolveSelfInvocation(); | 14094 | const selfInvocation = yield* resolveSelfInvocation(); |
| 14096 | const mockAgentPath = yield* path.fromFileUrl( | 14095 | const mockAgentPath = yield* path.fromFileUrl( |
| 14097 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 14096 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -14136,7 +14135,7 @@ describe("AcpAdapterV2", () => { | ... | @@ -14136,7 +14135,7 @@ describe("AcpAdapterV2", () => { |
| 14136 | }, | 14135 | }, |
| 14137 | fileSystem, | 14136 | fileSystem, |
| 14138 | idAllocator, | 14137 | idAllocator, |
| 14139 | serverConfig, | 14138 | host, |
| 14140 | selfInvocation, | 14139 | selfInvocation, |
| 14141 | }); | 14140 | }); |
| 14142 | const threadId = ThreadId.make("thread-acp-restart-active-in-process"); | 14141 | const threadId = ThreadId.make("thread-acp-restart-active-in-process"); |
apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.testkit.ts+2-2| ... | @@ -13,9 +13,9 @@ import * as Stream from "effect/Stream"; | ... | @@ -13,9 +13,9 @@ import * as Stream from "effect/Stream"; |
| 13 | import { ChildProcessSpawner } from "effect/process"; | 13 | import { ChildProcessSpawner } from "effect/process"; |
| 14 | import * as EffectAcpErrors from "effect-acp/errors"; | 14 | import * as EffectAcpErrors from "effect-acp/errors"; |
| 15 | 15 | ||
| 16 | import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts"; | 16 | import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; |
| 17 | import type { ProviderReplayGate } from "@t3tools/provider-testing/replayGate"; | 17 | import type { ProviderReplayGate } from "@t3tools/provider-testing/replayGate"; |
| 18 | import { ACP_PROTOCOL, type AcpAdapterV2RuntimeInput } from "./AcpAdapterV2.ts"; | 18 | import { ACP_PROTOCOL, type AcpAdapterV2RuntimeInput } from "@t3tools/provider-acp/server/adapter"; |
| 19 | 19 | ||
| 20 | export const AcpReplayTranscript = Schema.Struct({ | 20 | export const AcpReplayTranscript = Schema.Struct({ |
| 21 | provider: ProviderDriverKind, | 21 | provider: ProviderDriverKind, |
apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts deleted-8062| ... | @@ -1,8062 +0,0 @@ | ||
| 1 | // @effect-diagnostics nodeBuiltinImport:off | ||
| 2 | import * as NodePath from "node:path"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | type ChatAttachment, | ||
| 6 | type ModelSelection, | ||
| 7 | type OrchestrationV2ConversationMessage, | ||
| 8 | type OrchestrationV2ExecutionNode, | ||
| 9 | type OrchestrationV2PlanArtifact, | ||
| 10 | type OrchestrationV2PlanStep, | ||
| 11 | type OrchestrationV2ProviderCapabilities, | ||
| 12 | type OrchestrationV2ProviderFailure, | ||
| 13 | type OrchestrationV2ProviderRetry, | ||
| 14 | type OrchestrationV2ProviderSession, | ||
| 15 | type OrchestrationV2ProviderThread, | ||
| 16 | type OrchestrationV2ProviderThreadNativeMetadata, | ||
| 17 | type OrchestrationV2ProviderTurn, | ||
| 18 | type OrchestrationV2RuntimeRequest, | ||
| 19 | type OrchestrationV2Subagent, | ||
| 20 | type OrchestrationV2TurnItem, | ||
| 21 | type OrchestrationV2UserInputQuestion, | ||
| 22 | type OrchestrationV2WebSearchResult, | ||
| 23 | type ProviderApprovalDecision, | ||
| 24 | type ProviderApprovalOption, | ||
| 25 | type ProviderInstanceId, | ||
| 26 | type ProviderInteractionMode, | ||
| 27 | type ProviderDriverKind, | ||
| 28 | type ProviderRequestKind, | ||
| 29 | type ProviderThreadId, | ||
| 30 | type ProviderUserInputAnswers, | ||
| 31 | type RuntimeRequestId, | ||
| 32 | type ThreadTokenUsageSnapshot, | ||
| 33 | type ThreadId, | ||
| 34 | } from "@t3tools/contracts"; | ||
| 35 | import { modelSelectionsEqual } from "@t3tools/shared/model"; | ||
| 36 | import { type SelfInvocation, selfInvocationArgs } from "@t3tools/shared/nodeRuntime"; | ||
| 37 | import { FILE_HEADERS_ONLY, formatPatch, structuredPatch } from "diff"; | ||
| 38 | import * as Cause from "effect/Cause"; | ||
| 39 | import * as Crypto from "effect/Crypto"; | ||
| 40 | import * as DateTime from "effect/DateTime"; | ||
| 41 | import * as Deferred from "effect/Deferred"; | ||
| 42 | import type * as Duration from "effect/Duration"; | ||
| 43 | import * as Effect from "effect/Effect"; | ||
| 44 | import * as Exit from "effect/Exit"; | ||
| 45 | import * as FileSystem from "effect/FileSystem"; | ||
| 46 | import * as Fiber from "effect/Fiber"; | ||
| 47 | import * as Option from "effect/Option"; | ||
| 48 | import * as Queue from "effect/Queue"; | ||
| 49 | import * as Ref from "effect/Ref"; | ||
| 50 | import * as Result from "effect/Result"; | ||
| 51 | import * as Scope from "effect/Scope"; | ||
| 52 | import * as Semaphore from "effect/Semaphore"; | ||
| 53 | import * as Stream from "effect/Stream"; | ||
| 54 | import type { ChildProcessSpawner } from "effect/process"; | ||
| 55 | import * as EffectAcpErrors from "effect-acp/errors"; | ||
| 56 | import type * as EffectAcpProtocol from "effect-acp/protocol"; | ||
| 57 | import type * as EffectAcpSchema from "effect-acp/compat"; | ||
| 58 | |||
| 59 | import { formatReadToolLabel, formatSearchToolLabel } from "@t3tools/shared/toolActivity"; | ||
| 60 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; | ||
| 61 | import * as ServerConfig from "../../config.ts"; | ||
| 62 | import { | ||
| 63 | makeAcpMcpOverAcpBridge, | ||
| 64 | type AcpMcpOverAcpBridge, | ||
| 65 | } from "../../mcp/AcpMcpOverAcpBridge.ts"; | ||
| 66 | import { mcpToolPresentation } from "@t3tools/provider-core/server/mcpToolPresentation"; | ||
| 67 | import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; | ||
| 68 | import { | ||
| 69 | applyAcpAgentTerminalUpdate, | ||
| 70 | acpContentBlockDisplayText, | ||
| 71 | embeddedTerminalIdsFromSessionUpdate, | ||
| 72 | extractMcpToolCallIdentity, | ||
| 73 | mergeToolCallState, | ||
| 74 | parsePermissionRequest, | ||
| 75 | parseSessionUpdateEvent, | ||
| 76 | toolCallVisibleOutputChanged, | ||
| 77 | type AcpPlanUpdate, | ||
| 78 | type AcpAgentTerminalState, | ||
| 79 | type AcpSessionModeState, | ||
| 80 | type AcpToolCallState, | ||
| 81 | } from "../../provider/acp/AcpRuntimeModel.ts"; | ||
| 82 | import { | ||
| 83 | acpClientExecuteDisposition, | ||
| 84 | acpMcpToolApprovalElicitationDisposition, | ||
| 85 | acpPermissionDisposition, | ||
| 86 | type AcpPermissionDisposition, | ||
| 87 | makeAcpClientPolicyGrants, | ||
| 88 | unknownRecord, | ||
| 89 | } from "../../provider/acp/AcpClientPolicy.ts"; | ||
| 90 | import { | ||
| 91 | makeAcpClientTerminals, | ||
| 92 | resolveEmbeddedTerminalContent, | ||
| 93 | type AcpClientTerminals, | ||
| 94 | } from "../../provider/acp/AcpClientTerminals.ts"; | ||
| 95 | import { ACP_SESSION_MODE_OPTION_ID } from "../../provider/acp/AcpSessionConfig.ts"; | ||
| 96 | import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts"; | ||
| 97 | import { | ||
| 98 | t3AcpPromptWithInstructions, | ||
| 99 | type T3AcpInstructionState, | ||
| 100 | } from "@t3tools/provider-core/server/orchestrationInstructions"; | ||
| 101 | import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions"; | ||
| 102 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; | ||
| 103 | import { type ProviderContinuationRequest } from "@t3tools/provider-core/server/continuationRequests"; | ||
| 104 | import { | ||
| 105 | type BackgroundWork, | ||
| 106 | type BackgroundWorkReport, | ||
| 107 | backgroundWorkNotification, | ||
| 108 | } from "@t3tools/provider-core/server/notification"; | ||
| 109 | import { | ||
| 110 | makeProviderFailure, | ||
| 111 | makeProviderRetryTurnItem, | ||
| 112 | } from "@t3tools/provider-core/server/failure"; | ||
| 113 | import { acpSelectionTransition } from "@t3tools/provider-core/server/selectionTransition"; | ||
| 114 | import { | ||
| 115 | isProviderNativeImageAttachment, | ||
| 116 | providerMessageTextWithAttachmentPaths, | ||
| 117 | } from "@t3tools/provider-core/server/attachmentPrompt"; | ||
| 118 | import { | ||
| 119 | makeSubagentChildThread, | ||
| 120 | makeSubagentConversationArtifacts, | ||
| 121 | subagentThreadTitle, | ||
| 122 | } from "@t3tools/provider-core/server/subagentProjection"; | ||
| 123 | import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; | ||
| 124 | |||
| 125 | export const ACP_PROTOCOL = "acp.ndjson-jsonrpc" as const; | ||
| 126 | |||
| 127 | /** | ||
| 128 | * Quiet window after a settled turn's last background rearm before it | ||
| 129 | * finalizes, so a slightly late post-hydration assistant chunk stays in the | ||
| 130 | * same turn. Grok commonly sends its final summary just over two seconds after | ||
| 131 | * the hydrated tool frame; two seconds split that tail into a second synthetic | ||
| 132 | * wake. Longer floors (4–20s) only prolonged Working. No per-model carveouts. | ||
| 133 | */ | ||
| 134 | const ACP_DEFERRED_FINALIZE_DEBOUNCE: Duration.Input = "3000 millis"; | ||
| 135 | |||
| 136 | export interface AcpAdapterV2RuntimeInput { | ||
| 137 | readonly cwd: string; | ||
| 138 | /** | ||
| 139 | * Policy the session opened with. A runtime-mode change reopens the session, | ||
| 140 | * so flavors that encode permissions in the launch command (Grok) read it here. | ||
| 141 | */ | ||
| 142 | readonly runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy; | ||
| 143 | readonly mcpServers: ReadonlyArray<EffectAcpSchema.McpServer>; | ||
| 144 | readonly acpMcpServers?: ReadonlyArray<EffectAcpSchema.McpServer>; | ||
| 145 | /** Scoped credentials for terminal fallback when an ACP agent drops `mcpServers`. */ | ||
| 146 | readonly processEnvironment?: NodeJS.ProcessEnv; | ||
| 147 | readonly resumeSessionId?: string; | ||
| 148 | readonly interruptPromptOnCancel?: boolean; | ||
| 149 | readonly clientCapabilities: EffectAcpSchema.InitializeRequest["clientCapabilities"]; | ||
| 150 | readonly clientInfo: AcpSessionRuntime.AcpSessionRuntimeOptions["clientInfo"]; | ||
| 151 | readonly requestLogger?: NonNullable<AcpSessionRuntime.AcpSessionRuntimeOptions["requestLogger"]>; | ||
| 152 | readonly protocolLogging: NonNullable< | ||
| 153 | AcpSessionRuntime.AcpSessionRuntimeOptions["protocolLogging"] | ||
| 154 | >; | ||
| 155 | readonly onTermination: NonNullable<AcpSessionRuntime.AcpSessionRuntimeOptions["onTermination"]>; | ||
| 156 | readonly onOutgoingResponseFailure?: AcpSessionRuntime.AcpSessionRuntimeOptions["onOutgoingResponseFailure"]; | ||
| 157 | readonly onOutgoingResponse?: AcpSessionRuntime.AcpSessionRuntimeOptions["onOutgoingResponse"]; | ||
| 158 | } | ||
| 159 | |||
| 160 | export type AcpAdapterV2NativeLogging = Pick< | ||
| 161 | AcpSessionRuntime.AcpSessionRuntimeOptions, | ||
| 162 | "requestLogger" | "protocolLogging" | ||
| 163 | >; | ||
| 164 | |||
| 165 | export interface AcpAdapterV2UserInputRequest { | ||
| 166 | readonly nativeItemId: string; | ||
| 167 | readonly nativeRequestId: string; | ||
| 168 | readonly questions: ReadonlyArray<OrchestrationV2UserInputQuestion>; | ||
| 169 | } | ||
| 170 | |||
| 171 | export interface AcpAdapterV2ExtensionContext { | ||
| 172 | readonly runtime: AcpSessionRuntime.AcpSessionRuntime["Service"]; | ||
| 173 | readonly reportProviderRetry: (input: { | ||
| 174 | readonly sessionId: string; | ||
| 175 | readonly failure: OrchestrationV2ProviderFailure; | ||
| 176 | }) => Effect.Effect<void>; | ||
| 177 | /** | ||
| 178 | * A subagent's structured end on the root session (Grok `subagent_finished`), | ||
| 179 | * keyed by its child session id. Finishes the subagent row, in the turn that | ||
| 180 | * holds it or in the carryover of a settled one. | ||
| 181 | */ | ||
| 182 | readonly finishSubagent: (notice: { | ||
| 183 | readonly sessionId: string; | ||
| 184 | readonly childSessionId: string; | ||
| 185 | readonly status: "completed" | "failed" | "cancelled"; | ||
| 186 | readonly result: string | null; | ||
| 187 | }) => Effect.Effect<void>; | ||
| 188 | /** | ||
| 189 | * Session-scoped background-task lifecycle reported via extension | ||
| 190 | * notifications (e.g. Grok `x.ai/task_backgrounded`; older builds use the | ||
| 191 | * underscore alias). Mutations for non-root sessions are ignored. A terminal | ||
| 192 | * mutation also finishes the tool that registered the task in a settled turn | ||
| 193 | * still held open for it, with `output` as its final text when given. | ||
| 194 | */ | ||
| 195 | readonly applyBackgroundTaskMutation: (mutation: { | ||
| 196 | readonly sessionId: string; | ||
| 197 | readonly taskId: string; | ||
| 198 | readonly status: "running" | "completed" | "failed"; | ||
| 199 | readonly output?: string; | ||
| 200 | /** What the task is, so a wake it causes can name it. */ | ||
| 201 | readonly report?: BackgroundWork; | ||
| 202 | }) => Effect.Effect<void>; | ||
| 203 | readonly requestUserInput: ( | ||
| 204 | input: AcpAdapterV2UserInputRequest, | ||
| 205 | requestContext: EffectAcpProtocol.AcpRequestContext, | ||
| 206 | ) => Effect.Effect< | ||
| 207 | { | ||
| 208 | readonly acknowledgeNativeResponse: Effect.Effect<void, EffectAcpErrors.AcpError>; | ||
| 209 | readonly answers: ProviderUserInputAnswers | null; | ||
| 210 | }, | ||
| 211 | EffectAcpErrors.AcpError | ||
| 212 | >; | ||
| 213 | /** Surfaces plan markdown as this turn's proposed-plan card (#8358). */ | ||
| 214 | readonly captureProposedPlan: (input: { readonly planMarkdown: string }) => Effect.Effect<void>; | ||
| 215 | /** Last markdown captured for the active turn, as the exit-gate fallback. */ | ||
| 216 | readonly lastProposedPlanMarkdown: Effect.Effect<string | undefined>; | ||
| 217 | } | ||
| 218 | |||
| 219 | export interface AcpAdapterV2Flavor { | ||
| 220 | /** Interprets provider-specific prompt errors before they cross into orchestration. */ | ||
| 221 | readonly promptFailure?: (cause: unknown) => OrchestrationV2ProviderFailure; | ||
| 222 | readonly driver: ProviderDriverKind; | ||
| 223 | readonly capabilities: OrchestrationV2ProviderCapabilities; | ||
| 224 | readonly clientCapabilitiesMeta?: Record<string, boolean>; | ||
| 225 | readonly normalizeSessionUpdate?: ( | ||
| 226 | notification: EffectAcpSchema.SessionNotification, | ||
| 227 | ) => EffectAcpSchema.SessionNotification; | ||
| 228 | readonly onAvailableCommandsUpdate?: ( | ||
| 229 | commands: ReadonlyArray<EffectAcpSchema.AvailableCommand>, | ||
| 230 | ) => Effect.Effect<void>; | ||
| 231 | readonly onSessionConfigurationUpdate?: ( | ||
| 232 | configOptions: ReadonlyArray<EffectAcpSchema.SessionConfigOption>, | ||
| 233 | modeState: AcpSessionModeState | undefined, | ||
| 234 | ) => Effect.Effect<void>; | ||
| 235 | readonly onUrlElicitation?: (input: { | ||
| 236 | readonly elicitationId: string; | ||
| 237 | readonly url: string; | ||
| 238 | readonly message: string; | ||
| 239 | }) => Effect.Effect<boolean>; | ||
| 240 | readonly withRuntimeStartup?: <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>; | ||
| 241 | readonly makeRuntime: ( | ||
| 242 | input: AcpAdapterV2RuntimeInput, | ||
| 243 | ) => Effect.Effect< | ||
| 244 | AcpSessionRuntime.AcpSessionRuntime["Service"], | ||
| 245 | EffectAcpErrors.AcpError, | ||
| 246 | Crypto.Crypto | Scope.Scope | ||
| 247 | >; | ||
| 248 | readonly resolveModelId?: (selection: ModelSelection) => string | undefined; | ||
| 249 | /** | ||
| 250 | * Replaces the default model application on session setup. Returns the model | ||
| 251 | * the session now runs on. Antigravity resolves its provider-default alias | ||
| 252 | * against the account's catalog instead of sending it to the agent. | ||
| 253 | */ | ||
| 254 | readonly applyModelSelection?: (input: { | ||
| 255 | readonly runtime: AcpSessionRuntime.AcpSessionRuntime["Service"]; | ||
| 256 | readonly startResult: AcpSessionRuntime.AcpSessionRuntimeStartResult; | ||
| 257 | readonly modelSelection: ModelSelection; | ||
| 258 | }) => Effect.Effect<string | undefined, EffectAcpErrors.AcpError>; | ||
| 259 | /** Native session mode to select for a runtime policy (e.g. Antigravity `yolo`). */ | ||
| 260 | readonly sessionModeForPolicy?: ( | ||
| 261 | policy: ProviderAdapter.ProviderAdapterV2RuntimePolicy, | ||
| 262 | ) => string | undefined; | ||
| 263 | /** | ||
| 264 | * Opts the session into the ACP client `fs` capability. Agents read and write | ||
| 265 | * files themselves under their own permission model unless a flavor sets | ||
| 266 | * this. Requests pass the runtime policy guard, then these handlers, which | ||
| 267 | * receive the cwd of the policy active when the request arrives (null when | ||
| 268 | * the session has no workspace). Antigravity sets it and confines requests | ||
| 269 | * to that workspace. | ||
| 270 | */ | ||
| 271 | readonly clientFileSystem?: { | ||
| 272 | readonly readTextFile: ( | ||
| 273 | request: EffectAcpSchema.ReadTextFileRequest, | ||
| 274 | cwd: string | null, | ||
| 275 | ) => Effect.Effect<EffectAcpSchema.ReadTextFileResponse, EffectAcpErrors.AcpError>; | ||
| 276 | readonly writeTextFile: ( | ||
| 277 | request: EffectAcpSchema.WriteTextFileRequest, | ||
| 278 | cwd: string | null, | ||
| 279 | ) => Effect.Effect<EffectAcpSchema.WriteTextFileResponse, EffectAcpErrors.AcpError>; | ||
| 280 | }; | ||
| 281 | /** | ||
| 282 | * Permission requests that are really questions (Antigravity `interaction_*` | ||
| 283 | * tool calls). Returns the question and a response builder; undefined routes | ||
| 284 | * the request through the normal approval card. | ||
| 285 | */ | ||
| 286 | readonly extractPermissionQuestion?: (request: EffectAcpSchema.RequestPermissionRequest) => | ||
| 287 | | { | ||
| 288 | readonly question: OrchestrationV2UserInputQuestion; | ||
| 289 | readonly respond: ( | ||
| 290 | answers: ProviderUserInputAnswers, | ||
| 291 | ) => EffectAcpSchema.RequestPermissionResponse | undefined; | ||
| 292 | } | ||
| 293 | | undefined; | ||
| 294 | /** | ||
| 295 | * Replaces T3's runtime-policy answer to a permission request. Grok's Auto | ||
| 296 | * mode only asks about what its own classifier refused, so those must reach | ||
| 297 | * the user instead of being approved by T3's policy. | ||
| 298 | */ | ||
| 299 | readonly permissionDisposition?: ( | ||
| 300 | policy: ProviderAdapter.ProviderAdapterV2RuntimePolicy, | ||
| 301 | request: EffectAcpSchema.RequestPermissionRequest, | ||
| 302 | ) => AcpPermissionDisposition; | ||
| 303 | /** Approval choices to advertise on the approval card for a permission request. */ | ||
| 304 | readonly approvalOptions?: ( | ||
| 305 | request: EffectAcpSchema.RequestPermissionRequest, | ||
| 306 | ) => ReadonlyArray<ProviderApprovalOption>; | ||
| 307 | /** | ||
| 308 | * Activate saved sessions with `session/resume` before `session/load` when | ||
| 309 | * the agent supports both. Antigravity's load replays history slowly. | ||
| 310 | */ | ||
| 311 | readonly preferResumeSession?: boolean; | ||
| 312 | readonly onSessionEvent?: ( | ||
| 313 | event: AcpSessionRuntime.AcpSessionRuntimeEvent, | ||
| 314 | ) => Effect.Effect<void>; | ||
| 315 | /** Batch launches without child completion signals become idle when the root turn ends. */ | ||
| 316 | readonly subagentsIdleOnTurnCompletion?: boolean; | ||
| 317 | readonly supportsCompaction?: boolean; | ||
| 318 | readonly runtimeHarness?: string; | ||
| 319 | readonly registerExtensions?: ( | ||
| 320 | context: AcpAdapterV2ExtensionContext, | ||
| 321 | ) => Effect.Effect<void, EffectAcpErrors.AcpError>; | ||
| 322 | readonly extractSubagentUpdate?: ( | ||
| 323 | toolCall: AcpToolCallState, | ||
| 324 | ) => AcpAdapterV2SubagentUpdate | undefined; | ||
| 325 | /** | ||
| 326 | * Optional Grok-style rewrite before tool projection (e.g. keep monitor start | ||
| 327 | * ACKs in the running state until stream end). | ||
| 328 | */ | ||
| 329 | readonly normalizeToolCall?: (toolCall: AcpToolCallState) => AcpToolCallState; | ||
| 330 | /** | ||
| 331 | * Optional plan-file sniffing (#8358): providers that write their proposed | ||
| 332 | * plan to a file mid-turn (Grok plan.md) return its markdown from a tool | ||
| 333 | * call so T3 can show the proposed-plan card while plan mode is active. | ||
| 334 | */ | ||
| 335 | readonly extractProposedPlanMarkdown?: (toolCall: AcpToolCallState) => string | undefined; | ||
| 336 | /** | ||
| 337 | * Optional mapping from a long-lived background tool start ACK to a task id | ||
| 338 | * (e.g. monitor task uuid) so later synthetic text events can update it. | ||
| 339 | */ | ||
| 340 | readonly extractBackgroundTaskId?: (toolCall: AcpToolCallState) => string | undefined; | ||
| 341 | /** | ||
| 342 | * Optional parse of root-session synthetic text (monitor-event lines, monitor | ||
| 343 | * ended reminders). Returns every task mutation in the chunk so coalesced | ||
| 344 | * progress / end notices are not dropped. | ||
| 345 | */ | ||
| 346 | readonly extractBackgroundToolMutation?: (text: string) => ReadonlyArray<{ | ||
| 347 | readonly taskId: string; | ||
| 348 | readonly status: "running" | "completed" | "failed"; | ||
| 349 | readonly appendOutput: string; | ||
| 350 | }>; | ||
| 351 | /** | ||
| 352 | * Optional parse of root-session synthetic text announcing a background | ||
| 353 | * subagent's end ("Background subagent "<uuid>" ... completed successfully"). | ||
| 354 | * Older builds may never hydrate via get_command_or_subagent_output, so this | ||
| 355 | * remains a terminal fallback. Current Grok additionally emits structured | ||
| 356 | * `subagent_finished` session notifications. | ||
| 357 | */ | ||
| 358 | readonly extractSubagentEndNotice?: (text: string) => | ||
| 359 | | { | ||
| 360 | readonly childSessionId: string; | ||
| 361 | readonly status: "completed" | "failed"; | ||
| 362 | } | ||
| 363 | | undefined; | ||
| 364 | /** | ||
| 365 | * Optional hydration when a later tool (e.g. get_command TaskOutput) completes | ||
| 366 | * previously registered background task id(s). | ||
| 367 | */ | ||
| 368 | readonly extractBackgroundTaskCompletion?: (toolCall: AcpToolCallState) => ReadonlyArray<{ | ||
| 369 | readonly taskId: string; | ||
| 370 | readonly status: "running" | "completed" | "failed"; | ||
| 371 | readonly appendOutput: string; | ||
| 372 | }>; | ||
| 373 | /** | ||
| 374 | * Persistent monitors (e.g. Grok `persistent: true`) should not hold root-turn | ||
| 375 | * deferred finalize open forever. Still tracked for post-settle wake. | ||
| 376 | */ | ||
| 377 | readonly isPersistentBackgroundTool?: (toolCall: AcpToolCallState) => boolean; | ||
| 378 | /** | ||
| 379 | * Whether a root-session frame belongs to a turn the agent started itself | ||
| 380 | * after background work ended (Grok `task-completed-*`), not to T3's prompt. | ||
| 381 | * Such frames never project into a root turn held open for that work; they | ||
| 382 | * take the post-settle wake path once the held turn finalizes. | ||
| 383 | */ | ||
| 384 | readonly isProviderWakeNotification?: ( | ||
| 385 | notification: EffectAcpSchema.SessionNotification, | ||
| 386 | ) => boolean; | ||
| 387 | /** | ||
| 388 | * When true, keep the active turn open after session/prompt returns while | ||
| 389 | * background tools/subagents are still running so later monitor/wake traffic | ||
| 390 | * can project (Grok monitors finish after the root prompt settles). | ||
| 391 | */ | ||
| 392 | readonly deferFinalizeForBackgroundWork?: boolean; | ||
| 393 | readonly assertComplete?: Effect.Effect<void, EffectAcpErrors.AcpError>; | ||
| 394 | /** Interrupt the local prompt fiber before `session/cancel` (Grok wedged prompts). */ | ||
| 395 | readonly interruptPromptOnCancel?: boolean; | ||
| 396 | /** | ||
| 397 | * Kill and respawn the ACP child process before the next `session/prompt` after a | ||
| 398 | * user interrupt. Grok can keep `task_already_running` state until the process exits. | ||
| 399 | */ | ||
| 400 | readonly restartRuntimeAfterInterrupt?: boolean; | ||
| 401 | /** | ||
| 402 | * When true, every interrupt restarts the runtime, not just those carrying | ||
| 403 | * `requestRuntimeRestart` (user Stop). Leave unset to keep non-Stop | ||
| 404 | * interrupts (steering, restart_active) soft: `session/cancel` plus session | ||
| 405 | * reuse in the same process. | ||
| 406 | */ | ||
| 407 | readonly restartRuntimeOnEveryInterrupt?: boolean; | ||
| 408 | readonly terminateRuntimeProcessGroupOnInterrupt?: boolean; | ||
| 409 | /** | ||
| 410 | * When true, an interrupt without `requestRuntimeRestart` (steering restart) | ||
| 411 | * on a turn whose native prompt already settled skips the hard process-group | ||
| 412 | * kill, the ACP cancel, and the runtime respawn entirely: the turn | ||
| 413 | * terminalizes locally while background subagents keep running in the same | ||
| 414 | * process and carry over into the replacement turn. Verified against the | ||
| 415 | * real Grok CLI (tmp/grok-acp-experiments E1): a new session/prompt is | ||
| 416 | * accepted concurrently while a fire-and-forget subagent is still running, | ||
| 417 | * with no task_already_running. User Stop (`requestRuntimeRestart: true`) | ||
| 418 | * keeps the hard teardown; mid-prompt non-Stop interrupts go soft | ||
| 419 | * (`session/cancel` plus same-session re-prompt) unless | ||
| 420 | * `restartRuntimeOnEveryInterrupt` is set. | ||
| 421 | */ | ||
| 422 | readonly preserveRuntimeOnSettledInterrupt?: boolean; | ||
| 423 | /** | ||
| 424 | * When true (with continuationRequests), post-settle root session/update traffic | ||
| 425 | * buffers and requests a provider continuation run instead of being dropped or | ||
| 426 | * only appended to loaded history. | ||
| 427 | */ | ||
| 428 | readonly enablePostSettleContinuation?: boolean; | ||
| 429 | /** | ||
| 430 | * When true, send image attachment content blocks even if the ACP agent | ||
| 431 | * advertises `promptCapabilities.image: false`. Grok CLI currently accepts | ||
| 432 | * and vision-processes image blocks while still reporting the capability as | ||
| 433 | * false; without this override, screenshot turns fail before `session/prompt`. | ||
| 434 | */ | ||
| 435 | readonly supportsImagePrompts?: boolean; | ||
| 436 | } | ||
| 437 | |||
| 438 | /** Whether image attachment blocks may be included in session/prompt. */ | ||
| 439 | export function acpSupportsImagePrompts(input: { | ||
| 440 | readonly flavorSupportsImagePrompts?: boolean | undefined; | ||
| 441 | readonly negotiatedImage?: boolean | undefined; | ||
| 442 | }): boolean { | ||
| 443 | return input.flavorSupportsImagePrompts === true || input.negotiatedImage === true; | ||
| 444 | } | ||
| 445 | |||
| 446 | /** Keeps provider-derived ids distinct when two configured ACP instances reuse native ids. */ | ||
| 447 | export function acpScopedNativeId(instanceId: ProviderInstanceId, nativeId: string): string { | ||
| 448 | return `provider-instance:${encodeURIComponent(instanceId)}:${nativeId}`; | ||
| 449 | } | ||
| 450 | |||
| 451 | /** Keeps pre-v2 persisted ids stable while scoping ids for newly created threads. */ | ||
| 452 | export function acpProviderItemNativeId(input: { | ||
| 453 | readonly instanceId: ProviderInstanceId; | ||
| 454 | readonly itemIdentityVersion: 2 | undefined; | ||
| 455 | readonly nativeId: string; | ||
| 456 | }): string { | ||
| 457 | return input.itemIdentityVersion === 2 | ||
| 458 | ? acpScopedNativeId(input.instanceId, input.nativeId) | ||
| 459 | : input.nativeId; | ||
| 460 | } | ||
| 461 | |||
| 462 | export interface AcpAdapterV2SubagentUpdate { | ||
| 463 | readonly nativeTaskId: string; | ||
| 464 | readonly prompt: string; | ||
| 465 | readonly title: string | null; | ||
| 466 | readonly model: string | null; | ||
| 467 | readonly status: | ||
| 468 | | "pending" | ||
| 469 | | "running" | ||
| 470 | | "idle" | ||
| 471 | | "completed" | ||
| 472 | | "failed" | ||
| 473 | | "interrupted" | ||
| 474 | | "cancelled"; | ||
| 475 | readonly childSessionId: string | null; | ||
| 476 | readonly result: string | null; | ||
| 477 | /** | ||
| 478 | * When false, still project a normal tool turn item after the subagent update | ||
| 479 | * (hydration tools like get_command_or_subagent_output). Defaults to true. | ||
| 480 | */ | ||
| 481 | readonly suppressNormalTool?: boolean; | ||
| 482 | } | ||
| 483 | |||
| 484 | export interface AcpAdapterV2Options { | ||
| 485 | readonly instanceId: ProviderInstanceId; | ||
| 486 | readonly flavor: AcpAdapterV2Flavor; | ||
| 487 | readonly crypto: Crypto.Crypto; | ||
| 488 | readonly fileSystem: FileSystem.FileSystem; | ||
| 489 | readonly idAllocator: IdAllocator.IdAllocatorV2Shape; | ||
| 490 | readonly serverConfig: ServerConfig.ServerConfig["Service"]; | ||
| 491 | /** How agents spawn this install's `acp-mcp-bridge`; see `resolveSelfInvocation`. */ | ||
| 492 | readonly selfInvocation: SelfInvocation; | ||
| 493 | /** | ||
| 494 | * Opts the session into the ACP client `terminal` capability. Agents run | ||
| 495 | * commands themselves unless an adapter sets this; with it, sessions | ||
| 496 | * advertise `terminal: true` and run agent-created terminals through this | ||
| 497 | * spawner with the provider instance's environment. Devin sets it. | ||
| 498 | */ | ||
| 499 | readonly clientTerminals?: { | ||
| 500 | readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; | ||
| 501 | readonly environment?: NodeJS.ProcessEnv; | ||
| 502 | readonly shellCommands?: boolean; | ||
| 503 | }; | ||
| 504 | readonly nativeLogging?: (threadId: ThreadId) => AcpAdapterV2NativeLogging; | ||
| 505 | /** | ||
| 506 | * Shared with ProviderContinuationService so post-settle wake traffic can start | ||
| 507 | * a continuation run. Optional: adapters that omit it keep pre-continuation drop | ||
| 508 | * / history-only behavior for null-activeTurn updates. | ||
| 509 | */ | ||
| 510 | readonly continuationRequests?: { | ||
| 511 | readonly offer: (request: ProviderContinuationRequest) => Effect.Effect<void>; | ||
| 512 | }; | ||
| 513 | readonly testHooks?: { | ||
| 514 | /** | ||
| 515 | * A settled turn with no background work left armed its finish debounce | ||
| 516 | * ({@link ACP_DEFERRED_FINALIZE_DEBOUNCE}); replay advances its test clock | ||
| 517 | * by exactly that on this receipt. | ||
| 518 | */ | ||
| 519 | readonly onDeferredFinalizeScheduled?: (debounce: Duration.Input) => Effect.Effect<void>; | ||
| 520 | readonly afterPromptSettledWithBackgroundWork?: () => Effect.Effect<void>; | ||
| 521 | readonly afterNativeResponseTransportClosed?: () => Effect.Effect<void>; | ||
| 522 | readonly afterHardTeardownTransportDrained?: () => Effect.Effect<void>; | ||
| 523 | readonly beforeNativeResponseAdmissionCheck?: ( | ||
| 524 | generation: number, | ||
| 525 | requestId: string, | ||
| 526 | ) => Effect.Effect<void>; | ||
| 527 | readonly onNativeResponseLifecycle?: (event: { | ||
| 528 | readonly generation?: number; | ||
| 529 | readonly requestId?: string; | ||
| 530 | readonly type: | ||
| 531 | | "admission_rejected" | ||
| 532 | | "failed" | ||
| 533 | | "late_noop" | ||
| 534 | | "registered" | ||
| 535 | | "removed" | ||
| 536 | | "timer_exited" | ||
| 537 | | "timer_started" | ||
| 538 | | "watcher_exited" | ||
| 539 | | "watcher_started"; | ||
| 540 | }) => Effect.Effect<void>; | ||
| 541 | }; | ||
| 542 | } | ||
| 543 | |||
| 544 | export const AcpProviderCapabilitiesV2 = { | ||
| 545 | sessions: { | ||
| 546 | supportsMultipleProviderThreadsPerSession: false, | ||
| 547 | supportsModelSwitchInSession: false, | ||
| 548 | supportsProviderSwitchingViaHandoff: true, | ||
| 549 | supportsRuntimeModeSwitchInSession: false, | ||
| 550 | pendingRequestsSurviveRestart: false, | ||
| 551 | }, | ||
| 552 | threads: { | ||
| 553 | canCreateEmptyThread: true, | ||
| 554 | canReadThreadSnapshot: false, | ||
| 555 | canRollbackThread: true, | ||
| 556 | canForkThread: false, | ||
| 557 | canForkFromTurn: false, | ||
| 558 | canForkFromSubagentThread: false, | ||
| 559 | exposesNativeThreadId: true, | ||
| 560 | }, | ||
| 561 | turns: { | ||
| 562 | exposesNativeTurnId: false, | ||
| 563 | emitsTurnStarted: true, | ||
| 564 | emitsTurnCompleted: true, | ||
| 565 | supportsInterrupt: true, | ||
| 566 | supportsActiveSteering: false, | ||
| 567 | supportsSteeringByInterruptRestart: true, | ||
| 568 | supportsQueuedMessages: true, | ||
| 569 | terminalStatusQuality: "strong", | ||
| 570 | }, | ||
| 571 | streaming: { | ||
| 572 | streamsAssistantText: true, | ||
| 573 | streamsReasoning: true, | ||
| 574 | streamsToolOutput: true, | ||
| 575 | streamsPlanText: false, | ||
| 576 | emitsMessageCompleted: true, | ||
| 577 | }, | ||
| 578 | tools: { | ||
| 579 | exposesToolItemIds: true, | ||
| 580 | emitsToolStarted: true, | ||
| 581 | emitsToolCompleted: true, | ||
| 582 | emitsToolOutput: true, | ||
| 583 | supportsMcpTools: false, | ||
| 584 | supportsDynamicToolCallbacks: false, | ||
| 585 | }, | ||
| 586 | approvals: { | ||
| 587 | supportsCommandApproval: true, | ||
| 588 | supportsFileReadApproval: true, | ||
| 589 | supportsFileChangeApproval: true, | ||
| 590 | supportsApplyPatchApproval: false, | ||
| 591 | approvalsHaveNativeRequestIds: false, | ||
| 592 | approvalCallbacksAreLiveOnly: true, | ||
| 593 | approvalsCanOriginateFromSubagents: false, | ||
| 594 | }, | ||
| 595 | planning: { | ||
| 596 | emitsPlanUpdated: true, | ||
| 597 | emitsTodoList: true, | ||
| 598 | emitsProposedPlan: false, | ||
| 599 | supportsStructuredQuestions: true, | ||
| 600 | planDeltasHaveItemIds: false, | ||
| 601 | }, | ||
| 602 | subagents: { | ||
| 603 | supportsSubagents: false, | ||
| 604 | exposesSubagentThreadIds: false, | ||
| 605 | emitsSubagentLifecycle: false, | ||
| 606 | canWaitForSubagents: false, | ||
| 607 | canCloseSubagents: false, | ||
| 608 | canForkSubagentThread: false, | ||
| 609 | }, | ||
| 610 | context: { | ||
| 611 | acceptsSystemContext: false, | ||
| 612 | acceptsDeveloperContext: false, | ||
| 613 | acceptsSyntheticUserContext: true, | ||
| 614 | canGenerateSummaries: true, | ||
| 615 | canConsumeHandoffSummaries: true, | ||
| 616 | supportsDeltaHandoff: true, | ||
| 617 | supportsFullThreadHandoff: true, | ||
| 618 | maxRecommendedHandoffChars: null, | ||
| 619 | }, | ||
| 620 | checkpointing: { | ||
| 621 | appCanCheckpointFilesystem: true, | ||
| 622 | supportsNestedCheckpointScopes: true, | ||
| 623 | // ACP defines no conversation truncation, so rollback resets the provider | ||
| 624 | // conversation: T3 restores checkpointed state and the next turn starts a | ||
| 625 | // fresh agent session without the rolled-back context. | ||
| 626 | providerCanRollbackConversation: true, | ||
| 627 | providerRollbackReturnsSnapshot: true, | ||
| 628 | providerCanReadConversationSnapshot: false, | ||
| 629 | }, | ||
| 630 | identity: { | ||
| 631 | nativeThreadIds: "strong", | ||
| 632 | nativeTurnIds: "weak", | ||
| 633 | nativeItemIds: "weak", | ||
| 634 | nativeRequestIds: "weak", | ||
| 635 | }, | ||
| 636 | runtimePolicy: { | ||
| 637 | // ACP agents run their own tools; T3 only answers their permission | ||
| 638 | // requests by policy. | ||
| 639 | enforcement: "client-boundary", | ||
| 640 | }, | ||
| 641 | } satisfies OrchestrationV2ProviderCapabilities; | ||
| 642 | |||
| 643 | function negotiatedCapabilities( | ||
| 644 | base: OrchestrationV2ProviderCapabilities, | ||
| 645 | started: AcpSessionRuntime.AcpSessionRuntimeStartResult, | ||
| 646 | ): OrchestrationV2ProviderCapabilities { | ||
| 647 | const agent = started.initializeResult.agentCapabilities ?? {}; | ||
| 648 | const session = agent.sessionCapabilities; | ||
| 649 | const setup = started.sessionSetupResult; | ||
| 650 | const hasModelConfig = | ||
| 651 | setup.configOptions?.some((option) => option.category === "model") === true; | ||
| 652 | const canLoad = agent.loadSession === true; | ||
| 653 | const canFork = session?.fork != null; | ||
| 654 | return { | ||
| 655 | ...base, | ||
| 656 | sessions: { | ||
| 657 | ...base.sessions, | ||
| 658 | supportsModelSwitchInSession: hasModelConfig, | ||
| 659 | }, | ||
| 660 | threads: { | ||
| 661 | ...base.threads, | ||
| 662 | canReadThreadSnapshot: canLoad, | ||
| 663 | canForkThread: canFork, | ||
| 664 | canForkFromTurn: false, | ||
| 665 | }, | ||
| 666 | tools: { | ||
| 667 | ...base.tools, | ||
| 668 | // The stdio bridge (`t3 acp-mcp-bridge`) makes the t3-code MCP toolkit | ||
| 669 | // available regardless of the agent's optional http/sse MCP support. | ||
| 670 | supportsMcpTools: true, | ||
| 671 | }, | ||
| 672 | checkpointing: { | ||
| 673 | ...base.checkpointing, | ||
| 674 | providerCanReadConversationSnapshot: canLoad, | ||
| 675 | }, | ||
| 676 | }; | ||
| 677 | } | ||
| 678 | |||
| 679 | interface AcpMcpContext { | ||
| 680 | readonly servers: ReadonlyArray<EffectAcpSchema.McpServer>; | ||
| 681 | readonly acpServers: ReadonlyArray<EffectAcpSchema.McpServer>; | ||
| 682 | readonly processEnvironment?: NodeJS.ProcessEnv; | ||
| 683 | readonly endpoint?: string; | ||
| 684 | readonly authorization?: string; | ||
| 685 | } | ||
| 686 | |||
| 687 | function acpMcpContext(threadId: ThreadId | null, self: SelfInvocation): AcpMcpContext { | ||
| 688 | if (threadId === null) return { servers: [], acpServers: [] }; | ||
| 689 | const session = McpProviderSession.readMcpProviderSession(threadId); | ||
| 690 | if (session === undefined) { | ||
| 691 | return { servers: [], acpServers: [] }; | ||
| 692 | } | ||
| 693 | // Stdio is ACP's required baseline MCP transport. Agents that advertise | ||
| 694 | // optional http support still routinely fail to wire injected http servers | ||
| 695 | // through to their backend (codex-acp 1.2.0 and pi-acp both drop them), so | ||
| 696 | // every ACP session gets the `t3 acp-mcp-bridge` stdio server, which | ||
| 697 | // forwards JSON-RPC to T3's authenticated MCP endpoint. The credential | ||
| 698 | // travels via environment variables, never the command line. | ||
| 699 | return { | ||
| 700 | servers: [ | ||
| 701 | { | ||
| 702 | name: "t3-code", | ||
| 703 | command: self.command, | ||
| 704 | args: [...selfInvocationArgs(self, ["acp-mcp-bridge"])], | ||
| 705 | env: [ | ||
| 706 | { name: "ELECTRON_RUN_AS_NODE", value: "1" }, | ||
| 707 | { name: "T3_ACP_MCP_ENDPOINT", value: session.endpoint }, | ||
| 708 | { name: "T3_ACP_MCP_AUTHORIZATION", value: session.authorizationHeader }, | ||
| 709 | ], | ||
| 710 | }, | ||
| 711 | ], | ||
| 712 | acpServers: [{ type: "acp", name: "t3-code", serverId: "t3-code" }], | ||
| 713 | endpoint: session.endpoint, | ||
| 714 | authorization: session.authorizationHeader, | ||
| 715 | processEnvironment: { | ||
| 716 | T3_ACP_MCP_ENDPOINT: session.endpoint, | ||
| 717 | T3_ACP_MCP_AUTHORIZATION: session.authorizationHeader, | ||
| 718 | T3_ACP_MCP_NODE: self.command, | ||
| 719 | ...(self.entrypoint === undefined ? {} : { T3_ACP_MCP_ENTRYPOINT: self.entrypoint }), | ||
| 720 | }, | ||
| 721 | }; | ||
| 722 | } | ||
| 723 | |||
| 724 | function acpMcpServers( | ||
| 725 | threadId: ThreadId | null, | ||
| 726 | self: SelfInvocation, | ||
| 727 | ): ReadonlyArray<EffectAcpSchema.McpServer> { | ||
| 728 | return acpMcpContext(threadId, self).servers; | ||
| 729 | } | ||
| 730 | |||
| 731 | function acpMcpActivation(threadId: ThreadId | null, self: SelfInvocation) { | ||
| 732 | const context = acpMcpContext(threadId, self); | ||
| 733 | return { mcpServers: context.servers, acpMcpServers: context.acpServers }; | ||
| 734 | } | ||
| 735 | |||
| 736 | function nativeThreadId( | ||
| 737 | driver: ProviderDriverKind, | ||
| 738 | thread: OrchestrationV2ProviderThread, | ||
| 739 | ): Effect.Effect<string, ProviderAdapter.ProviderAdapterProtocolError> { | ||
| 740 | const id = thread.nativeThreadRef?.nativeId; | ||
| 741 | if (id === null || id === undefined || id.trim().length === 0) { | ||
| 742 | return Effect.fail( | ||
| 743 | new ProviderAdapter.ProviderAdapterProtocolError({ | ||
| 744 | driver, | ||
| 745 | detail: `Provider thread ${thread.id} is missing its ACP session id`, | ||
| 746 | }), | ||
| 747 | ); | ||
| 748 | } | ||
| 749 | return Effect.succeed(id); | ||
| 750 | } | ||
| 751 | |||
| 752 | function makeProviderThread(input: { | ||
| 753 | readonly driver: ProviderDriverKind; | ||
| 754 | readonly providerInstanceId: ProviderInstanceId; | ||
| 755 | readonly idAllocator: IdAllocator.IdAllocatorV2Shape; | ||
| 756 | readonly appThreadId: OrchestrationV2ProviderThread["appThreadId"]; | ||
| 757 | readonly providerSessionId: OrchestrationV2ProviderThread["providerSessionId"]; | ||
| 758 | readonly nativeThreadId: string; | ||
| 759 | readonly ownerNodeId?: OrchestrationV2ProviderThread["ownerNodeId"]; | ||
| 760 | readonly forkedFrom?: OrchestrationV2ProviderThread["forkedFrom"]; | ||
| 761 | readonly itemIdentityVersion?: 2; | ||
| 762 | readonly now: DateTime.Utc; | ||
| 763 | }): OrchestrationV2ProviderThread { | ||
| 764 | return { | ||
| 765 | id: input.idAllocator.derive.providerThread({ | ||
| 766 | driver: input.driver, | ||
| 767 | providerInstanceId: input.providerInstanceId, | ||
| 768 | nativeThreadId: input.nativeThreadId, | ||
| 769 | }), | ||
| 770 | driver: input.driver, | ||
| 771 | providerInstanceId: input.providerInstanceId, | ||
| 772 | providerSessionId: input.providerSessionId, | ||
| 773 | appThreadId: input.appThreadId, | ||
| 774 | ownerNodeId: input.ownerNodeId ?? null, | ||
| 775 | nativeThreadRef: { | ||
| 776 | driver: input.driver, | ||
| 777 | nativeId: input.nativeThreadId, | ||
| 778 | strength: "strong", | ||
| 779 | }, | ||
| 780 | nativeConversationHeadRef: null, | ||
| 781 | status: "idle", | ||
| 782 | firstRunOrdinal: null, | ||
| 783 | lastRunOrdinal: null, | ||
| 784 | handoffIds: [], | ||
| 785 | forkedFrom: input.forkedFrom ?? null, | ||
| 786 | contextUsage: null, | ||
| 787 | nativeMetadata: | ||
| 788 | input.itemIdentityVersion === undefined | ||
| 789 | ? null | ||
| 790 | : { itemIdentityVersion: input.itemIdentityVersion }, | ||
| 791 | createdAt: input.now, | ||
| 792 | updatedAt: input.now, | ||
| 793 | }; | ||
| 794 | } | ||
| 795 | |||
| 796 | /** | ||
| 797 | * Unwrap a codex-acp style MCP call result (`{ result, error }` around MCP | ||
| 798 | * `content`/`structuredContent`) the same way the native Codex adapter does, | ||
| 799 | * so recovered MCP items render identical output. Unknown shapes pass through. | ||
| 800 | */ | ||
| 801 | function acpMcpToolCallOutput(rawOutput: unknown): unknown { | ||
| 802 | const record = unknownRecord(rawOutput); | ||
| 803 | if (record === undefined) return rawOutput; | ||
| 804 | if (!("result" in record) && !("error" in record)) return rawOutput; | ||
| 805 | const result = unknownRecord(record.result); | ||
| 806 | const resultOutput = | ||
| 807 | result === undefined ? undefined : (result.structuredContent ?? result.content ?? undefined); | ||
| 808 | const errorMessage = unknownRecord(record.error)?.message; | ||
| 809 | if (typeof errorMessage !== "string") { | ||
| 810 | return resultOutput ?? rawOutput; | ||
| 811 | } | ||
| 812 | return resultOutput === undefined | ||
| 813 | ? { error: errorMessage } | ||
| 814 | : { error: errorMessage, result: resultOutput }; | ||
| 815 | } | ||
| 816 | |||
| 817 | function nonEmptyText(value: unknown, fallback: string): string { | ||
| 818 | return typeof value === "string" && value.trim().length > 0 ? value.trim() : fallback; | ||
| 819 | } | ||
| 820 | |||
| 821 | function decodeByteText(value: unknown): string | undefined { | ||
| 822 | if (!Array.isArray(value) || value.length === 0) return undefined; | ||
| 823 | if (!value.every((entry) => typeof entry === "number" && Number.isInteger(entry))) { | ||
| 824 | return undefined; | ||
| 825 | } | ||
| 826 | try { | ||
| 827 | // Preserve leading/trailing whitespace like the string path in textFromUnknown. | ||
| 828 | const text = new TextDecoder().decode(Uint8Array.from(value as number[])); | ||
| 829 | return text.length > 0 ? text : undefined; | ||
| 830 | } catch { | ||
| 831 | return undefined; | ||
| 832 | } | ||
| 833 | } | ||
| 834 | |||
| 835 | function textFromUnknown(value: unknown): string | undefined { | ||
| 836 | if (typeof value === "string") { | ||
| 837 | return value; | ||
| 838 | } | ||
| 839 | const fromBytes = decodeByteText(value); | ||
| 840 | if (fromBytes !== undefined) { | ||
| 841 | return fromBytes; | ||
| 842 | } | ||
| 843 | if (Array.isArray(value)) { | ||
| 844 | const parts = value.flatMap((entry) => { | ||
| 845 | const text = textFromUnknown(entry); | ||
| 846 | return text === undefined || text.length === 0 ? [] : [text]; | ||
| 847 | }); | ||
| 848 | return parts.length === 0 ? undefined : parts.join("\n"); | ||
| 849 | } | ||
| 850 | const record = unknownRecord(value); | ||
| 851 | if (record === undefined) { | ||
| 852 | return undefined; | ||
| 853 | } | ||
| 854 | // Prefer prompt-facing Grok fields before nested envelopes. | ||
| 855 | // Antigravity reports shell output as combinedOutput. | ||
| 856 | for (const key of [ | ||
| 857 | "output_for_prompt", | ||
| 858 | "stdout", | ||
| 859 | "stderr", | ||
| 860 | "output", | ||
| 861 | "combinedOutput", | ||
| 862 | "combined_output", | ||
| 863 | "content", | ||
| 864 | "text", | ||
| 865 | "message", | ||
| 866 | ]) { | ||
| 867 | const direct = record[key]; | ||
| 868 | if (typeof direct === "string" && direct.length > 0) { | ||
| 869 | return direct; | ||
| 870 | } | ||
| 871 | const decoded = decodeByteText(direct); | ||
| 872 | if (decoded !== undefined) { | ||
| 873 | return decoded; | ||
| 874 | } | ||
| 875 | const text = textFromUnknown(direct); | ||
| 876 | if (text !== undefined && text.length > 0) { | ||
| 877 | return text; | ||
| 878 | } | ||
| 879 | } | ||
| 880 | const result = unknownRecord(record.Result) ?? unknownRecord(record.result); | ||
| 881 | if (result !== undefined) { | ||
| 882 | return textFromUnknown(result); | ||
| 883 | } | ||
| 884 | return undefined; | ||
| 885 | } | ||
| 886 | |||
| 887 | function commandExitCode(value: unknown): number | undefined { | ||
| 888 | const record = unknownRecord(value); | ||
| 889 | for (const key of ["exitCode", "exit_code", "code"]) { | ||
| 890 | const candidate = record?.[key]; | ||
| 891 | if (typeof candidate === "number" && Number.isInteger(candidate)) { | ||
| 892 | return candidate; | ||
| 893 | } | ||
| 894 | } | ||
| 895 | return undefined; | ||
| 896 | } | ||
| 897 | |||
| 898 | /** | ||
| 899 | * Project an exit code only when the tool has a terminal native status. | ||
| 900 | * Mid-stream Grok Bash re-reports carry exit_code 0 while still in progress; | ||
| 901 | * interrupted tools must not retain that stale success code. | ||
| 902 | */ | ||
| 903 | export function acpProjectedCommandExitCode( | ||
| 904 | status: "pending" | "running" | "waiting" | "completed" | "failed" | "interrupted", | ||
| 905 | rawOutput: unknown, | ||
| 906 | ): number | undefined { | ||
| 907 | if (status !== "completed" && status !== "failed") { | ||
| 908 | return undefined; | ||
| 909 | } | ||
| 910 | return commandExitCode(rawOutput); | ||
| 911 | } | ||
| 912 | |||
| 913 | function structuredFileChanges(toolCall: AcpToolCallState) { | ||
| 914 | const content = toolCall.data.content; | ||
| 915 | if (!Array.isArray(content)) return []; | ||
| 916 | return content.flatMap((entry) => { | ||
| 917 | const diff = unknownRecord(entry); | ||
| 918 | if (diff?.type !== "diff" || !Array.isArray(diff.changes)) return []; | ||
| 919 | return diff.changes.flatMap((candidate) => { | ||
| 920 | const change = unknownRecord(candidate); | ||
| 921 | const operation = typeof change?.operation === "string" ? change.operation.trim() : ""; | ||
| 922 | const path = typeof change?.path === "string" ? change.path.trim() : ""; | ||
| 923 | if (operation.length === 0 || path.length === 0) return []; | ||
| 924 | const oldPath = typeof change?.oldPath === "string" ? change.oldPath.trim() : ""; | ||
| 925 | const fileType = typeof change?.fileType === "string" ? change.fileType.trim() : ""; | ||
| 926 | const mimeType = typeof change?.mimeType === "string" ? change.mimeType.trim() : ""; | ||
| 927 | return [ | ||
| 928 | { | ||
| 929 | operation, | ||
| 930 | path, | ||
| 931 | ...(oldPath.length === 0 ? {} : { oldPath }), | ||
| 932 | ...(fileType.length === 0 ? {} : { fileType }), | ||
| 933 | ...(mimeType.length === 0 ? {} : { mimeType }), | ||
| 934 | }, | ||
| 935 | ]; | ||
| 936 | }); | ||
| 937 | }); | ||
| 938 | } | ||
| 939 | |||
| 940 | // Past this edit distance an edit keeps no patch text, so projecting a large | ||
| 941 | // rewrite cannot stall the event loop in the diff search. A created or emptied | ||
| 942 | // file has one empty side and needs no search, so it is never capped. | ||
| 943 | const ACP_V1_DIFF_MAX_EDITS = 1_000; | ||
| 944 | |||
| 945 | /** | ||
| 946 | * Patch text for a tool call's diff content. ACP v2 diffs carry it as | ||
| 947 | * `patch.text`. ACP v1 diffs carry `oldText`/`newText` instead (`oldText` | ||
| 948 | * null or absent for a new file), and agents that negotiate v1 still send | ||
| 949 | * that shape, so the patch is built from the two sides. | ||
| 950 | */ | ||
| 951 | export function acpToolCallDiffPatch(content: unknown): string | undefined { | ||
| 952 | if (!Array.isArray(content)) return undefined; | ||
| 953 | const diffs = content.flatMap((entry) => { | ||
| 954 | const diff = unknownRecord(entry); | ||
| 955 | return diff?.type === "diff" ? [diff] : []; | ||
| 956 | }); | ||
| 957 | for (const diff of diffs) { | ||
| 958 | const patch = unknownRecord(diff.patch); | ||
| 959 | if (typeof patch?.text === "string") return patch.text; | ||
| 960 | } | ||
| 961 | const v1Patches = diffs.flatMap((diff) => { | ||
| 962 | if (typeof diff.path !== "string" || typeof diff.newText !== "string") return []; | ||
| 963 | const oldText = typeof diff.oldText === "string" ? diff.oldText : undefined; | ||
| 964 | const patch = structuredPatch( | ||
| 965 | oldText === undefined ? "/dev/null" : diff.path, | ||
| 966 | diff.path, | ||
| 967 | oldText ?? "", | ||
| 968 | diff.newText, | ||
| 969 | undefined, | ||
| 970 | undefined, | ||
| 971 | { | ||
| 972 | context: 3, | ||
| 973 | maxEditLength: oldText && diff.newText ? ACP_V1_DIFF_MAX_EDITS : Number.POSITIVE_INFINITY, | ||
| 974 | }, | ||
| 975 | ); | ||
| 976 | return patch === undefined || patch.hunks.length === 0 ? [] : [patch]; | ||
| 977 | }); | ||
| 978 | return v1Patches.length === 0 ? undefined : formatPatch(v1Patches, FILE_HEADERS_ONLY); | ||
| 979 | } | ||
| 980 | |||
| 981 | function pathFromToolCall(toolCall: AcpToolCallState): string | undefined { | ||
| 982 | const locations = toolCall.data.locations; | ||
| 983 | if (Array.isArray(locations)) { | ||
| 984 | for (const location of locations) { | ||
| 985 | const path = unknownRecord(location)?.path; | ||
| 986 | if (typeof path === "string" && path.trim().length > 0) { | ||
| 987 | return path.trim(); | ||
| 988 | } | ||
| 989 | } | ||
| 990 | } | ||
| 991 | const rawInput = unknownRecord(toolCall.data.rawInput); | ||
| 992 | for (const key of ["path", "filePath", "file_path", "url", "query", "pattern"]) { | ||
| 993 | const candidate = rawInput?.[key]; | ||
| 994 | if (typeof candidate === "string" && candidate.trim().length > 0) { | ||
| 995 | return candidate.trim(); | ||
| 996 | } | ||
| 997 | } | ||
| 998 | return undefined; | ||
| 999 | } | ||
| 1000 | |||
| 1001 | /** | ||
| 1002 | * Grok runs X and web searches server-side as `search` tools whose rawInput is | ||
| 1003 | * only `{ variant: "XSearch" | "WebSearch", backend: true }`. The query arrives | ||
| 1004 | * with completion: web searches report `action: { query, sources }`, X searches | ||
| 1005 | * the backend call `{ name, input }` with JSON-encoded arguments. | ||
| 1006 | */ | ||
| 1007 | function acpBackendWebSearch( | ||
| 1008 | rawInput: Record<string, unknown> | undefined, | ||
| 1009 | rawOutput: Record<string, unknown> | undefined, | ||
| 1010 | ): | ||
| 1011 | | { readonly query: string | undefined; readonly results: OrchestrationV2WebSearchResult[] } | ||
| 1012 | | undefined { | ||
| 1013 | const variant = typeof rawInput?.variant === "string" ? rawInput.variant.toLowerCase() : ""; | ||
| 1014 | const action = unknownRecord(rawOutput?.action); | ||
| 1015 | if (variant !== "xsearch" && variant !== "websearch" && action?.type !== "search") { | ||
| 1016 | return undefined; | ||
| 1017 | } | ||
| 1018 | let args: Record<string, unknown> | undefined; | ||
| 1019 | if (typeof rawOutput?.input === "string") { | ||
| 1020 | try { | ||
| 1021 | args = unknownRecord(JSON.parse(rawOutput.input)); | ||
| 1022 | } catch { | ||
| 1023 | args = undefined; | ||
| 1024 | } | ||
| 1025 | } | ||
| 1026 | const argsText = Object.entries(args ?? {}) | ||
| 1027 | .filter(([, value]) => typeof value === "string" || typeof value === "number") | ||
| 1028 | .map(([key, value]) => `${key}: ${value}`) | ||
| 1029 | .join(", "); | ||
| 1030 | const query = [action?.query, args?.query, argsText] | ||
| 1031 | .find((value): value is string => typeof value === "string" && value.trim().length > 0) | ||
| 1032 | ?.trim(); | ||
| 1033 | const urls = new Set<string>(); | ||
| 1034 | for (const source of Array.isArray(action?.sources) ? action.sources : []) { | ||
| 1035 | const url = unknownRecord(source)?.url; | ||
| 1036 | if (typeof url === "string" && url.trim().length > 0) urls.add(url.trim()); | ||
| 1037 | } | ||
| 1038 | return { query, results: [...urls].map((url) => ({ url })) }; | ||
| 1039 | } | ||
| 1040 | |||
| 1041 | function providerRequestKind(kind: string | "unknown"): ProviderRequestKind { | ||
| 1042 | switch (kind) { | ||
| 1043 | case "execute": | ||
| 1044 | return "command"; | ||
| 1045 | case "read": | ||
| 1046 | case "search": | ||
| 1047 | case "fetch": | ||
| 1048 | return "file-read"; | ||
| 1049 | case "edit": | ||
| 1050 | case "delete": | ||
| 1051 | case "move": | ||
| 1052 | return "file-change"; | ||
| 1053 | default: | ||
| 1054 | return "command"; | ||
| 1055 | } | ||
| 1056 | } | ||
| 1057 | |||
| 1058 | function toolStatus( | ||
| 1059 | status: AcpToolCallState["status"], | ||
| 1060 | ): "pending" | "running" | "waiting" | "completed" | "failed" { | ||
| 1061 | switch (status) { | ||
| 1062 | case "completed": | ||
| 1063 | return "completed"; | ||
| 1064 | case "failed": | ||
| 1065 | return "failed"; | ||
| 1066 | case "pending": | ||
| 1067 | return "pending"; | ||
| 1068 | case "requiresAction": | ||
| 1069 | return "waiting"; | ||
| 1070 | default: | ||
| 1071 | return "running"; | ||
| 1072 | } | ||
| 1073 | } | ||
| 1074 | |||
| 1075 | type ProjectedToolStatus = ReturnType<typeof toolStatus> | "interrupted"; | ||
| 1076 | |||
| 1077 | function nodeStatus(status: ProjectedToolStatus): OrchestrationV2ExecutionNode["status"] { | ||
| 1078 | return status === "pending" ? "running" : status; | ||
| 1079 | } | ||
| 1080 | |||
| 1081 | function completedAtForStatus(status: ProjectedToolStatus, now: DateTime.Utc): DateTime.Utc | null { | ||
| 1082 | return status === "completed" || status === "failed" || status === "interrupted" ? now : null; | ||
| 1083 | } | ||
| 1084 | |||
| 1085 | function selectPermissionOptionId( | ||
| 1086 | request: EffectAcpSchema.RequestPermissionRequest, | ||
| 1087 | decision: Exclude<ProviderApprovalDecision, "cancel">, | ||
| 1088 | ): string | undefined { | ||
| 1089 | const kind = | ||
| 1090 | decision === "acceptForSession" | ||
| 1091 | ? "allow_always" | ||
| 1092 | : decision === "accept" | ||
| 1093 | ? "allow_once" | ||
| 1094 | : "reject_once"; | ||
| 1095 | return request.options.find((option) => option.kind === kind)?.optionId.trim() || undefined; | ||
| 1096 | } | ||
| 1097 | |||
| 1098 | /** | ||
| 1099 | * The runtime policy approves one request, so answer with the agent's | ||
| 1100 | * allow-once option. Its allow-always option can outlive the session (Grok | ||
| 1101 | * saves it for the whole project); use it only when no allow-once exists. | ||
| 1102 | */ | ||
| 1103 | function selectAutoApprovedPermissionOption( | ||
| 1104 | request: EffectAcpSchema.RequestPermissionRequest, | ||
| 1105 | ): string | undefined { | ||
| 1106 | return ( | ||
| 1107 | selectPermissionOptionId(request, "accept") ?? | ||
| 1108 | selectPermissionOptionId(request, "acceptForSession") | ||
| 1109 | ); | ||
| 1110 | } | ||
| 1111 | |||
| 1112 | function elicitationContent( | ||
| 1113 | answers: ProviderUserInputAnswers, | ||
| 1114 | allowedKeys: ReadonlySet<string>, | ||
| 1115 | ): Record<string, EffectAcpSchema.ElicitationContentValue> { | ||
| 1116 | const content: Record<string, EffectAcpSchema.ElicitationContentValue> = {}; | ||
| 1117 | for (const [key, value] of Object.entries(answers)) { | ||
| 1118 | if (!allowedKeys.has(key)) continue; | ||
| 1119 | if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { | ||
| 1120 | content[key] = value; | ||
| 1121 | } else if (Array.isArray(value)) { | ||
| 1122 | content[key] = value.filter((entry): entry is string => typeof entry === "string"); | ||
| 1123 | } | ||
| 1124 | } | ||
| 1125 | return content; | ||
| 1126 | } | ||
| 1127 | |||
| 1128 | interface ActiveTextSegment { | ||
| 1129 | readonly nativeItemId: string; | ||
| 1130 | readonly startedAt: DateTime.Utc; | ||
| 1131 | sourceMessageId: string | null; | ||
| 1132 | text: string; | ||
| 1133 | } | ||
| 1134 | |||
| 1135 | interface ActiveTextStream { | ||
| 1136 | current: ActiveTextSegment | null; | ||
| 1137 | nextSegment: number; | ||
| 1138 | } | ||
| 1139 | |||
| 1140 | interface AcpNativeBuildConfiguration { | ||
| 1141 | readonly modeId?: string; | ||
| 1142 | readonly configOptions: ReadonlyArray<{ readonly id: string; readonly value: string }>; | ||
| 1143 | } | ||
| 1144 | |||
| 1145 | interface ActiveAcpTurn { | ||
| 1146 | readonly input: ProviderAdapter.ProviderAdapterV2TurnInput; | ||
| 1147 | readonly providerTurnId: OrchestrationV2ProviderTurn["id"]; | ||
| 1148 | readonly nativeThreadId: string; | ||
| 1149 | readonly nativeTurnId: string; | ||
| 1150 | readonly startedAt: DateTime.Utc; | ||
| 1151 | readonly completed: Deferred.Deferred<void, never>; | ||
| 1152 | // Root item ordinals allocated in this turn. Subagent child items keep | ||
| 1153 | // theirs on the subagent, which carries over into later turns. | ||
| 1154 | readonly itemOrdinals: Map<string, number>; | ||
| 1155 | readonly user: ActiveTextStream; | ||
| 1156 | readonly assistant: ActiveTextStream; | ||
| 1157 | readonly reasoning: ActiveTextStream; | ||
| 1158 | providerRetry?: | ||
| 1159 | | { | ||
| 1160 | readonly failure: OrchestrationV2ProviderFailure; | ||
| 1161 | readonly retry: OrchestrationV2ProviderRetry; | ||
| 1162 | readonly startedAt: DateTime.Utc; | ||
| 1163 | readonly itemOrdinal: number; | ||
| 1164 | } | ||
| 1165 | | undefined; | ||
| 1166 | contextUsage: ThreadTokenUsageSnapshot | null; | ||
| 1167 | nativeMetadata: OrchestrationV2ProviderThreadNativeMetadata | null; | ||
| 1168 | readonly tools: Map<string, AcpToolCallState>; | ||
| 1169 | /** Streamed tool updates skipped since the last persisted one; see `shouldPersistToolUpdate`. */ | ||
| 1170 | readonly toolUpdatesSkipped: Map<string, number>; | ||
| 1171 | readonly toolStartedAt: Map<string, DateTime.Utc>; | ||
| 1172 | readonly subagents: Map<string, ActiveAcpSubagent>; | ||
| 1173 | readonly subagentsBySessionId: Map<string, ActiveAcpSubagent>; | ||
| 1174 | readonly pendingSubagentNotifications: Map<string, Array<EffectAcpSchema.SessionNotification>>; | ||
| 1175 | /** Background monitor/task id → toolCallId for synthetic root text updates. */ | ||
| 1176 | readonly toolCallIdsByBackgroundTaskId: Map<string, string>; | ||
| 1177 | /** | ||
| 1178 | * Persistent monitors registered this turn. Excluded from deferred-finalize | ||
| 1179 | * holds so the root turn can settle while they keep streaming post-settle. | ||
| 1180 | */ | ||
| 1181 | readonly persistentBackgroundTaskIds: Set<string>; | ||
| 1182 | /** | ||
| 1183 | * Monitor end events often only say "use get_command…"; keep the turn open | ||
| 1184 | * until TaskOutput hydration arrives (or the safety timeout elapses). | ||
| 1185 | */ | ||
| 1186 | readonly awaitingBackgroundHydration: Set<string>; | ||
| 1187 | /** | ||
| 1188 | * A monitor end notice landed after the prompt settled: the CLI runs an | ||
| 1189 | * injected turn whose report never gets a turn_completed marker, so the | ||
| 1190 | * report chunk races the deferred-finalize debounce (thread a8e8b0a9 run 5 | ||
| 1191 | * dropped the listing this way). Hold finalize until the report streams or | ||
| 1192 | * the safety timeout elapses. | ||
| 1193 | */ | ||
| 1194 | readonly pendingInjectedReport: Set<string>; | ||
| 1195 | /** | ||
| 1196 | * An injected assistant report can race ahead of its monitor end notice. | ||
| 1197 | * Remember that unmatched report so the later notice consumes, rather than | ||
| 1198 | * re-arming, the task's pre-settle completion marker. | ||
| 1199 | */ | ||
| 1200 | earlyInjectedReportObserved: boolean; | ||
| 1201 | readonly plans: Map< | ||
| 1202 | string, | ||
| 1203 | { | ||
| 1204 | readonly id: OrchestrationV2PlanArtifact["id"]; | ||
| 1205 | readonly startedAt: DateTime.Utc; | ||
| 1206 | latest: OrchestrationV2PlanArtifact | null; | ||
| 1207 | } | ||
| 1208 | >; | ||
| 1209 | interrupted: boolean; | ||
| 1210 | finalized: boolean; | ||
| 1211 | finalizedStatus: "completed" | "interrupted" | "failed" | "cancelled" | null; | ||
| 1212 | /** session/prompt already returned; finalize deferred for background work. */ | ||
| 1213 | promptSettled: boolean; | ||
| 1214 | promptSettledStatus: "completed" | "interrupted" | "failed" | "cancelled" | null; | ||
| 1215 | /** | ||
| 1216 | * Completed the moment `runtime.prompt` resolves on the wire, before the | ||
| 1217 | * completion callback requests `runtimeCallbackPermit`. Failure does not | ||
| 1218 | * complete this; settled-soft classification ORs it with `promptSettled`. | ||
| 1219 | */ | ||
| 1220 | readonly promptWireSettled: Deferred.Deferred<void, never>; | ||
| 1221 | backgroundFinalizeGeneration: number; | ||
| 1222 | } | ||
| 1223 | |||
| 1224 | type AcpRuntimeTeardownState = | ||
| 1225 | | { readonly _tag: "Idle" } | ||
| 1226 | | { | ||
| 1227 | readonly _tag: "InProgress"; | ||
| 1228 | readonly completed: Deferred.Deferred<void, ProviderAdapter.ProviderAdapterProtocolError>; | ||
| 1229 | } | ||
| 1230 | | { readonly _tag: "Failed"; readonly error: ProviderAdapter.ProviderAdapterProtocolError }; | ||
| 1231 | |||
| 1232 | /** True when a root session/update carries ingestible turn output, not keepalive noise. */ | ||
| 1233 | function acpRootSessionUpdateIngestsOutput( | ||
| 1234 | notification: EffectAcpSchema.SessionNotification, | ||
| 1235 | ): boolean { | ||
| 1236 | const update = notification.update; | ||
| 1237 | switch (update.sessionUpdate) { | ||
| 1238 | case "agent_message_chunk": | ||
| 1239 | case "agent_thought_chunk": | ||
| 1240 | return (acpContentBlockDisplayText(update.content)?.length ?? 0) > 0; | ||
| 1241 | case "agent_message": | ||
| 1242 | case "agent_thought": | ||
| 1243 | return (update.content ?? []).some( | ||
| 1244 | (content) => (acpContentBlockDisplayText(content)?.length ?? 0) > 0, | ||
| 1245 | ); | ||
| 1246 | case "tool_call": | ||
| 1247 | case "tool_call_update": | ||
| 1248 | case "plan": | ||
| 1249 | case "plan_update": | ||
| 1250 | case "plan_removed": | ||
| 1251 | case "compaction_update": | ||
| 1252 | case "compaction_summary_chunk": | ||
| 1253 | return parseSessionUpdateEvent(notification).events.some( | ||
| 1254 | (event) => event._tag === "ToolCallUpdated" || event._tag === "PlanUpdated", | ||
| 1255 | ); | ||
| 1256 | default: | ||
| 1257 | return false; | ||
| 1258 | } | ||
| 1259 | } | ||
| 1260 | |||
| 1261 | /** | ||
| 1262 | * Post-settle traffic that should be *buffered* for a continuation attach. | ||
| 1263 | * Excludes monitor end/event chatter that must not become ghost history. | ||
| 1264 | * Broader than {@link acpPostSettleContinuationOfferEvidence}: incremental | ||
| 1265 | * tool progress may still need replay once a real completion offers a run. | ||
| 1266 | */ | ||
| 1267 | export function acpPostSettleWakeEvidence( | ||
| 1268 | notification: EffectAcpSchema.SessionNotification, | ||
| 1269 | flavor: Pick<AcpAdapterV2Flavor, "extractBackgroundToolMutation"> = {}, | ||
| 1270 | ): boolean { | ||
| 1271 | if (!acpRootSessionUpdateIngestsOutput(notification)) return false; | ||
| 1272 | const update = notification.update; | ||
| 1273 | if ( | ||
| 1274 | (update.sessionUpdate === "user_message_chunk" || | ||
| 1275 | update.sessionUpdate === "agent_message_chunk") && | ||
| 1276 | update.content.type === "text" | ||
| 1277 | ) { | ||
| 1278 | const text = update.content.text; | ||
| 1279 | if ((flavor.extractBackgroundToolMutation?.(text) ?? []).length > 0) return false; | ||
| 1280 | if (update.sessionUpdate === "agent_message_chunk" && text.trim().length === 0) return false; | ||
| 1281 | if (/<monitor-event\b/i.test(text) || /Monitor\s+["']?[0-9a-f-]{8,}["']?\s+ended/i.test(text)) { | ||
| 1282 | return false; | ||
| 1283 | } | ||
| 1284 | } | ||
| 1285 | return true; | ||
| 1286 | } | ||
| 1287 | |||
| 1288 | /** | ||
| 1289 | * Whether buffered post-settle traffic should *offer* a continuation run now. | ||
| 1290 | * Still-running tool streams often land farther apart than the deferred-finalize | ||
| 1291 | * quiet window; treating every tool_call_update as offer evidence re-opens a | ||
| 1292 | * synthetic "Background task completed." run on each chunk. Only completion-like | ||
| 1293 | * frames (real agent text, or a terminal tool status) should open a new run. | ||
| 1294 | */ | ||
| 1295 | export function acpPostSettleContinuationOfferEvidence( | ||
| 1296 | notification: EffectAcpSchema.SessionNotification, | ||
| 1297 | flavor: Pick<AcpAdapterV2Flavor, "extractBackgroundToolMutation" | "normalizeToolCall"> = {}, | ||
| 1298 | ): boolean { | ||
| 1299 | if (!acpPostSettleWakeEvidence(notification, flavor)) { | ||
| 1300 | return false; | ||
| 1301 | } | ||
| 1302 | const update = notification.update; | ||
| 1303 | // Assistant text only. Thought/reasoning bursts alone must not open synthetic | ||
| 1304 | // "Background task completed." runs (duplicate-run spam after monitors). | ||
| 1305 | if (update.sessionUpdate === "agent_message_chunk") { | ||
| 1306 | return (acpContentBlockDisplayText(update.content)?.trim().length ?? 0) > 0; | ||
| 1307 | } | ||
| 1308 | if (update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update") { | ||
| 1309 | return parseSessionUpdateEvent(notification).events.some((event) => { | ||
| 1310 | if (event._tag !== "ToolCallUpdated") return false; | ||
| 1311 | // Normalize first: a Grok monitor start ACK arrives with raw status | ||
| 1312 | // "completed" but is a still-running background task, not completion. | ||
| 1313 | const toolCall = flavor.normalizeToolCall?.(event.toolCall) ?? event.toolCall; | ||
| 1314 | return toolCall.status === "completed" || toolCall.status === "failed"; | ||
| 1315 | }); | ||
| 1316 | } | ||
| 1317 | return false; | ||
| 1318 | } | ||
| 1319 | |||
| 1320 | export function acpPostSettleWakeShouldBuffer( | ||
| 1321 | notification: EffectAcpSchema.SessionNotification, | ||
| 1322 | backgroundWorkRunning: boolean, | ||
| 1323 | ): boolean { | ||
| 1324 | if (!backgroundWorkRunning) return true; | ||
| 1325 | const update = notification.update; | ||
| 1326 | return ( | ||
| 1327 | update.sessionUpdate !== "agent_message_chunk" && update.sessionUpdate !== "agent_thought_chunk" | ||
| 1328 | ); | ||
| 1329 | } | ||
| 1330 | |||
| 1331 | /** | ||
| 1332 | * An app-owned wake (a delegated child finishing) is injected by the | ||
| 1333 | * orchestrator, not typed by the user. It reports on a sibling child and says | ||
| 1334 | * nothing about this session's own pending wake frames, so it must not discard | ||
| 1335 | * them the way a real user turn does. ClaudeAdapterV2 already leaves its buffer | ||
| 1336 | * alone on non-continuation turns; this keeps ACP consistent. | ||
| 1337 | */ | ||
| 1338 | export function acpIsAppOwnedWakeTurn(message: { | ||
| 1339 | readonly createdBy: string; | ||
| 1340 | readonly creationSource: string; | ||
| 1341 | }): boolean { | ||
| 1342 | return message.createdBy === "agent" && message.creationSource === "server"; | ||
| 1343 | } | ||
| 1344 | |||
| 1345 | /** A continuation run attaches to wake traffic the agent produced on its own. */ | ||
| 1346 | function acpIsProviderContinuationMessage(message: { | ||
| 1347 | readonly createdBy: string; | ||
| 1348 | readonly creationSource: string; | ||
| 1349 | }): boolean { | ||
| 1350 | return message.createdBy === "agent" && message.creationSource === "provider"; | ||
| 1351 | } | ||
| 1352 | |||
| 1353 | export function acpCarryoverTerminalShouldClearContinuation(input: { | ||
| 1354 | readonly continuationOffered: boolean; | ||
| 1355 | readonly wakeBufferLength: number; | ||
| 1356 | }): boolean { | ||
| 1357 | return !input.continuationOffered && input.wakeBufferLength === 0; | ||
| 1358 | } | ||
| 1359 | |||
| 1360 | export function acpTurnStartShouldPreserveContinuation(input: { | ||
| 1361 | readonly continuationRequested: boolean; | ||
| 1362 | readonly isContinuationTurn: boolean; | ||
| 1363 | readonly wakeBufferLength: number; | ||
| 1364 | }): boolean { | ||
| 1365 | return !input.isContinuationTurn && input.continuationRequested && input.wakeBufferLength > 0; | ||
| 1366 | } | ||
| 1367 | |||
| 1368 | export function acpPostSettleMonitorPromptShouldSuppress( | ||
| 1369 | mutation: | ||
| 1370 | | { | ||
| 1371 | readonly taskId: string; | ||
| 1372 | readonly status: "running" | "completed" | "failed"; | ||
| 1373 | } | ||
| 1374 | | undefined, | ||
| 1375 | ): boolean { | ||
| 1376 | return mutation?.status === "running"; | ||
| 1377 | } | ||
| 1378 | |||
| 1379 | export function acpCompletedTurnShouldTerminalizeTool( | ||
| 1380 | tool: AcpToolCallState, | ||
| 1381 | flavor: Pick<AcpAdapterV2Flavor, "extractBackgroundTaskId" | "extractSubagentUpdate">, | ||
| 1382 | ): boolean { | ||
| 1383 | const status = toolStatus(tool.status); | ||
| 1384 | if (status !== "pending" && status !== "running") return false; | ||
| 1385 | if (flavor.extractBackgroundTaskId?.(tool) !== undefined) return false; | ||
| 1386 | return flavor.extractSubagentUpdate?.(tool) === undefined; | ||
| 1387 | } | ||
| 1388 | |||
| 1389 | interface ActiveAcpSubagent { | ||
| 1390 | task: OrchestrationV2Subagent; | ||
| 1391 | readonly childThreadId: ThreadId; | ||
| 1392 | readonly childRootNodeId: OrchestrationV2ExecutionNode["id"]; | ||
| 1393 | readonly turnItemId: OrchestrationV2TurnItem["id"]; | ||
| 1394 | readonly turnItemOrdinal: number; | ||
| 1395 | /** Turn that spawned the subagent; carryover updates keep this lineage. */ | ||
| 1396 | readonly providerTurnId: OrchestrationV2ProviderTurn["id"]; | ||
| 1397 | readonly parentProviderThreadId: ProviderThreadId; | ||
| 1398 | childSessionId: string | null; | ||
| 1399 | assistantText: string; | ||
| 1400 | readonly assistantMessages: Map<string, string>; | ||
| 1401 | readonly childItemOrdinals: Map<string, number>; | ||
| 1402 | nextChildOrdinal: number; | ||
| 1403 | /** | ||
| 1404 | * Whether a terminal carryover status has been projected to events. | ||
| 1405 | * Completed roots project post-settle terminals immediately while their | ||
| 1406 | * subscriber remains observable. Non-completed roots retain terminals in | ||
| 1407 | * memory until the next attach. A later project:true path for the same entry | ||
| 1408 | * must still project once; this flag prevents double emission and pins | ||
| 1409 | * hasPendingBackgroundWork until projection lands. | ||
| 1410 | */ | ||
| 1411 | terminalStatusProjected: boolean; | ||
| 1412 | } | ||
| 1413 | |||
| 1414 | function acpSubagentStatusIsTerminal(status: OrchestrationV2Subagent["status"]): boolean { | ||
| 1415 | return ( | ||
| 1416 | status === "completed" || | ||
| 1417 | status === "failed" || | ||
| 1418 | status === "interrupted" || | ||
| 1419 | status === "cancelled" || | ||
| 1420 | status === "idle" | ||
| 1421 | ); | ||
| 1422 | } | ||
| 1423 | |||
| 1424 | export function acpSubagentStatusBlocksTurnSettlement( | ||
| 1425 | status: OrchestrationV2Subagent["status"], | ||
| 1426 | ): boolean { | ||
| 1427 | return status === "running" || status === "pending"; | ||
| 1428 | } | ||
| 1429 | |||
| 1430 | function acpSubagentHasPendingBackgroundWork(subagent: ActiveAcpSubagent): boolean { | ||
| 1431 | return ( | ||
| 1432 | acpSubagentStatusBlocksTurnSettlement(subagent.task.status) || !subagent.terminalStatusProjected | ||
| 1433 | ); | ||
| 1434 | } | ||
| 1435 | |||
| 1436 | type AcpCarryoverSubagents = { | ||
| 1437 | readonly sessionId: string; | ||
| 1438 | readonly rootTerminalStatus: "completed" | "interrupted" | "failed" | "cancelled"; | ||
| 1439 | readonly subagents: ReadonlyArray<ActiveAcpSubagent>; | ||
| 1440 | }; | ||
| 1441 | |||
| 1442 | type PendingRuntimeRequest = { | ||
| 1443 | readonly generation: number; | ||
| 1444 | readonly nativeResponseAcknowledgement: Deferred.Deferred<void, EffectAcpErrors.AcpError>; | ||
| 1445 | readonly transportRequestId: string; | ||
| 1446 | readonly requestId: RuntimeRequestId; | ||
| 1447 | readonly runtimeRequest: OrchestrationV2RuntimeRequest; | ||
| 1448 | readonly node: OrchestrationV2ExecutionNode; | ||
| 1449 | readonly turnItem: OrchestrationV2TurnItem; | ||
| 1450 | } & ( | ||
| 1451 | | { | ||
| 1452 | readonly type: "approval"; | ||
| 1453 | readonly decision: Deferred.Deferred<ProviderApprovalDecision>; | ||
| 1454 | } | ||
| 1455 | | { | ||
| 1456 | readonly type: "user_input"; | ||
| 1457 | readonly answers: Deferred.Deferred<ProviderUserInputAnswers | null>; | ||
| 1458 | } | ||
| 1459 | ); | ||
| 1460 | |||
| 1461 | interface SnapshotMessageState { | ||
| 1462 | readonly order: Array<string>; | ||
| 1463 | readonly messages: Map<string, OrchestrationV2ConversationMessage>; | ||
| 1464 | loadingRole: "user" | "assistant" | "thought" | null; | ||
| 1465 | loadingMessageId: string | null; | ||
| 1466 | loadingIndex: number; | ||
| 1467 | } | ||
| 1468 | |||
| 1469 | const TOOL_UPDATE_PERSIST_EVERY = 10; | ||
| 1470 | |||
| 1471 | /** | ||
| 1472 | * Some agents stream a tool's arguments (a file write's diff, `rawInput`) and | ||
| 1473 | * resend the whole call each time. Persist every 10th of those. Status, title, | ||
| 1474 | * and output the user watches live always persist, as does the agent's own | ||
| 1475 | * completed/failed when a flavor normalizes it to a non-terminal status. | ||
| 1476 | */ | ||
| 1477 | function shouldPersistToolUpdate( | ||
| 1478 | context: ActiveAcpTurn, | ||
| 1479 | key: string, | ||
| 1480 | previous: AcpToolCallState | undefined, | ||
| 1481 | next: AcpToolCallState, | ||
| 1482 | reportedStatus: AcpToolCallState["status"], | ||
| 1483 | ): boolean { | ||
| 1484 | const skipped = context.toolUpdatesSkipped.get(key) ?? 0; | ||
| 1485 | const persist = | ||
| 1486 | reportedStatus === "completed" || | ||
| 1487 | reportedStatus === "failed" || | ||
| 1488 | previous === undefined || | ||
| 1489 | previous.status !== next.status || | ||
| 1490 | previous.title !== next.title || | ||
| 1491 | toolCallVisibleOutputChanged(previous, next) || | ||
| 1492 | skipped + 1 >= TOOL_UPDATE_PERSIST_EVERY; | ||
| 1493 | context.toolUpdatesSkipped.set(key, persist ? 0 : skipped + 1); | ||
| 1494 | return persist; | ||
| 1495 | } | ||
| 1496 | |||
| 1497 | export function makeAcpAdapterV2( | ||
| 1498 | options: AcpAdapterV2Options, | ||
| 1499 | ): ProviderAdapter.ProviderAdapterV2Shape { | ||
| 1500 | const { flavor, fileSystem, idAllocator, serverConfig, selfInvocation: self } = options; | ||
| 1501 | const driver = flavor.driver; | ||
| 1502 | const continuationRequests = options.continuationRequests; | ||
| 1503 | const postSettleContinuationEnabled = | ||
| 1504 | flavor.enablePostSettleContinuation === true && continuationRequests !== undefined; | ||
| 1505 | |||
| 1506 | return ProviderAdapter.ProviderAdapterV2.of({ | ||
| 1507 | instanceId: options.instanceId, | ||
| 1508 | driver, | ||
| 1509 | getCapabilities: () => Effect.succeed(flavor.capabilities), | ||
| 1510 | planSelectionTransition: (input) => Effect.succeed(acpSelectionTransition(input)), | ||
| 1511 | openSession: Effect.fn("AcpAdapterV2.openSession")( | ||
| 1512 | function* (input: ProviderAdapter.ProviderAdapterV2OpenSessionInput) { | ||
| 1513 | const sessionScope = yield* Effect.scope; | ||
| 1514 | // Persisted ACP threads from before item identity v2 retain their old | ||
| 1515 | // deterministic ids. Fresh threads scope native ids by instance so | ||
| 1516 | // separately configured agents cannot collide. | ||
| 1517 | let itemIdentityVersion: 2 | undefined = | ||
| 1518 | input.initialProviderItemIdentityVersion ?? | ||
| 1519 | (input.initialNativeThreadId === undefined ? 2 : undefined); | ||
| 1520 | const providerNativeId = (nativeId: string) => | ||
| 1521 | acpProviderItemNativeId({ | ||
| 1522 | instanceId: options.instanceId, | ||
| 1523 | itemIdentityVersion, | ||
| 1524 | nativeId, | ||
| 1525 | }); | ||
| 1526 | const providerNodeId = (nativeItemId: string) => | ||
| 1527 | idAllocator.derive.nodeFromProviderItem({ | ||
| 1528 | driver, | ||
| 1529 | nativeItemId: providerNativeId(nativeItemId), | ||
| 1530 | }); | ||
| 1531 | const providerMessageId = (nativeItemId: string) => | ||
| 1532 | idAllocator.derive.messageFromProviderItem({ | ||
| 1533 | driver, | ||
| 1534 | nativeItemId: providerNativeId(nativeItemId), | ||
| 1535 | }); | ||
| 1536 | const providerTurnItemId = (nativeItemId: string) => | ||
| 1537 | idAllocator.derive.turnItemFromProviderItem({ | ||
| 1538 | driver, | ||
| 1539 | nativeItemId: providerNativeId(nativeItemId), | ||
| 1540 | }); | ||
| 1541 | const deriveProviderTurnId = (nativeTurnId: string) => | ||
| 1542 | idAllocator.derive.providerTurn({ | ||
| 1543 | driver, | ||
| 1544 | nativeTurnId: providerNativeId(nativeTurnId), | ||
| 1545 | }); | ||
| 1546 | const useProviderThreadIdentity = (thread: OrchestrationV2ProviderThread): void => { | ||
| 1547 | itemIdentityVersion = thread.nativeMetadata?.itemIdentityVersion; | ||
| 1548 | }; | ||
| 1549 | const terminalEnvironmentBySessionId = new Map<string, NodeJS.ProcessEnv>(); | ||
| 1550 | interface PendingTerminalEnvironment { | ||
| 1551 | readonly environment: NodeJS.ProcessEnv | undefined; | ||
| 1552 | readonly claimUnknownSession: boolean; | ||
| 1553 | readonly sessionId: string | null; | ||
| 1554 | } | ||
| 1555 | let pendingTerminalEnvironment: PendingTerminalEnvironment | null = { | ||
| 1556 | environment: acpMcpContext(input.threadId, self).processEnvironment, | ||
| 1557 | claimUnknownSession: input.initialNativeThreadId === undefined, | ||
| 1558 | sessionId: input.initialNativeThreadId ?? null, | ||
| 1559 | }; | ||
| 1560 | const prepareTerminalEnvironment = ( | ||
| 1561 | threadId: ThreadId | null, | ||
| 1562 | sessionId?: string, | ||
| 1563 | ): void => { | ||
| 1564 | pendingTerminalEnvironment = { | ||
| 1565 | environment: acpMcpContext(threadId, self).processEnvironment, | ||
| 1566 | claimUnknownSession: false, | ||
| 1567 | sessionId: sessionId ?? null, | ||
| 1568 | }; | ||
| 1569 | }; | ||
| 1570 | const prepareClaimableTerminalEnvironment = (threadId: ThreadId | null): void => { | ||
| 1571 | pendingTerminalEnvironment = { | ||
| 1572 | environment: acpMcpContext(threadId, self).processEnvironment, | ||
| 1573 | claimUnknownSession: true, | ||
| 1574 | sessionId: null, | ||
| 1575 | }; | ||
| 1576 | }; | ||
| 1577 | const rememberTerminalEnvironment = ( | ||
| 1578 | sessionId: string, | ||
| 1579 | threadId: ThreadId | null, | ||
| 1580 | ): void => { | ||
| 1581 | const environment = acpMcpContext(threadId, self).processEnvironment; | ||
| 1582 | pendingTerminalEnvironment = null; | ||
| 1583 | if (environment === undefined) { | ||
| 1584 | terminalEnvironmentBySessionId.delete(sessionId); | ||
| 1585 | } else { | ||
| 1586 | terminalEnvironmentBySessionId.set(sessionId, environment); | ||
| 1587 | } | ||
| 1588 | }; | ||
| 1589 | const clientTerminals: AcpClientTerminals | undefined = | ||
| 1590 | options.clientTerminals === undefined | ||
| 1591 | ? undefined | ||
| 1592 | : yield* makeAcpClientTerminals({ | ||
| 1593 | spawner: options.clientTerminals.childProcessSpawner, | ||
| 1594 | defaultCwd: input.runtimePolicy.cwd ?? process.cwd(), | ||
| 1595 | environment: options.clientTerminals.environment, | ||
| 1596 | shellCommands: options.clientTerminals.shellCommands, | ||
| 1597 | environmentForSession: (sessionId) => { | ||
| 1598 | const remembered = terminalEnvironmentBySessionId.get(sessionId); | ||
| 1599 | if (remembered !== undefined) return remembered; | ||
| 1600 | if (pendingTerminalEnvironment === null) return undefined; | ||
| 1601 | if ( | ||
| 1602 | pendingTerminalEnvironment.sessionId === null && | ||
| 1603 | pendingTerminalEnvironment.claimUnknownSession | ||
| 1604 | ) { | ||
| 1605 | pendingTerminalEnvironment = { | ||
| 1606 | ...pendingTerminalEnvironment, | ||
| 1607 | sessionId, | ||
| 1608 | }; | ||
| 1609 | } | ||
| 1610 | return pendingTerminalEnvironment.sessionId === sessionId | ||
| 1611 | ? pendingTerminalEnvironment.environment | ||
| 1612 | : undefined; | ||
| 1613 | }, | ||
| 1614 | }); | ||
| 1615 | if (clientTerminals !== undefined) { | ||
| 1616 | yield* Scope.addFinalizer(sessionScope, clientTerminals.disposeAll); | ||
| 1617 | } | ||
| 1618 | // Terminal ids embedded in raw tool_call updates, remembered before the | ||
| 1619 | // content rewrite so emitTool can recover MCP-fallback command lines. | ||
| 1620 | const sessionScopedId = (sessionId: string, nativeId: string): string => | ||
| 1621 | JSON.stringify([sessionId, nativeId]); | ||
| 1622 | const embeddedTerminalsByToolCallId = new Map< | ||
| 1623 | string, | ||
| 1624 | { | ||
| 1625 | readonly sessionId: string; | ||
| 1626 | readonly terminalIds: ReadonlyArray<string>; | ||
| 1627 | readonly toolCallId: string; | ||
| 1628 | } | ||
| 1629 | >(); | ||
| 1630 | const toolCallIdsByAgentTerminalId = new Map<string, Set<string>>(); | ||
| 1631 | const agentTerminalsById = new Map<string, AcpAgentTerminalState>(); | ||
| 1632 | const rememberEmbeddedTerminals = (input: { | ||
| 1633 | readonly sessionId: string; | ||
| 1634 | readonly toolCallId: string; | ||
| 1635 | readonly terminalIds: ReadonlyArray<string>; | ||
| 1636 | }): void => { | ||
| 1637 | const toolCallKey = sessionScopedId(input.sessionId, input.toolCallId); | ||
| 1638 | for (const terminalId of embeddedTerminalsByToolCallId.get(toolCallKey)?.terminalIds ?? | ||
| 1639 | []) { | ||
| 1640 | const terminalKey = sessionScopedId(input.sessionId, terminalId); | ||
| 1641 | const toolCallIds = toolCallIdsByAgentTerminalId.get(terminalKey); | ||
| 1642 | toolCallIds?.delete(input.toolCallId); | ||
| 1643 | if (toolCallIds?.size === 0) toolCallIdsByAgentTerminalId.delete(terminalKey); | ||
| 1644 | } | ||
| 1645 | embeddedTerminalsByToolCallId.delete(toolCallKey); | ||
| 1646 | embeddedTerminalsByToolCallId.set(toolCallKey, { | ||
| 1647 | sessionId: input.sessionId, | ||
| 1648 | terminalIds: input.terminalIds, | ||
| 1649 | toolCallId: input.toolCallId, | ||
| 1650 | }); | ||
| 1651 | for (const terminalId of input.terminalIds) { | ||
| 1652 | const terminalKey = sessionScopedId(input.sessionId, terminalId); | ||
| 1653 | const toolCallIds = toolCallIdsByAgentTerminalId.get(terminalKey) ?? new Set<string>(); | ||
| 1654 | toolCallIds.add(input.toolCallId); | ||
| 1655 | toolCallIdsByAgentTerminalId.set(terminalKey, toolCallIds); | ||
| 1656 | } | ||
| 1657 | for (const oldest of embeddedTerminalsByToolCallId.keys()) { | ||
| 1658 | if (embeddedTerminalsByToolCallId.size <= 256) break; | ||
| 1659 | const remembered = embeddedTerminalsByToolCallId.get(oldest); | ||
| 1660 | if (remembered === undefined) continue; | ||
| 1661 | for (const terminalId of remembered.terminalIds) { | ||
| 1662 | const terminalKey = sessionScopedId(remembered.sessionId, terminalId); | ||
| 1663 | const toolCallIds = toolCallIdsByAgentTerminalId.get(terminalKey); | ||
| 1664 | toolCallIds?.delete(remembered.toolCallId); | ||
| 1665 | if (toolCallIds?.size === 0) toolCallIdsByAgentTerminalId.delete(terminalKey); | ||
| 1666 | } | ||
| 1667 | embeddedTerminalsByToolCallId.delete(oldest); | ||
| 1668 | } | ||
| 1669 | }; | ||
| 1670 | // Command lines of the terminals embedded in a tool call, so MCP calls | ||
| 1671 | // made through the acp-mcp-call terminal fallback keep their identity. | ||
| 1672 | const embeddedTerminalCommands = ( | ||
| 1673 | sessionId: string, | ||
| 1674 | toolCallId: string, | ||
| 1675 | ): ReadonlyArray<string> => | ||
| 1676 | ( | ||
| 1677 | embeddedTerminalsByToolCallId.get(sessionScopedId(sessionId, toolCallId)) | ||
| 1678 | ?.terminalIds ?? [] | ||
| 1679 | ).flatMap((terminalId) => { | ||
| 1680 | const command = | ||
| 1681 | clientTerminals?.readCommandLine(terminalId) ?? | ||
| 1682 | agentTerminalsById.get(sessionScopedId(sessionId, terminalId))?.command; | ||
| 1683 | return command === undefined ? [] : [command]; | ||
| 1684 | }); | ||
| 1685 | // Client terminals (Devin) run with the T3 server's privileges, so they | ||
| 1686 | // are policy-checked against the active turn policy; a command the user | ||
| 1687 | // already approved satisfies an "ask" disposition. | ||
| 1688 | const clientPolicyGrants = makeAcpClientPolicyGrants(); | ||
| 1689 | let latestRuntimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy = | ||
| 1690 | input.runtimePolicy; | ||
| 1691 | const events = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event>(); | ||
| 1692 | const activeTurn = yield* Ref.make<ActiveAcpTurn | null>(null); | ||
| 1693 | const activeSessionId = yield* Ref.make<string | null>(null); | ||
| 1694 | const contextUsageBySessionId = yield* Ref.make( | ||
| 1695 | new Map<string, ThreadTokenUsageSnapshot>(), | ||
| 1696 | ); | ||
| 1697 | const nativeMetadataBySessionId = yield* Ref.make( | ||
| 1698 | new Map<string, OrchestrationV2ProviderThreadNativeMetadata>(), | ||
| 1699 | ); | ||
| 1700 | const providerThreadByNativeSessionId = yield* Ref.make( | ||
| 1701 | new Map<string, OrchestrationV2ProviderThread>(), | ||
| 1702 | ); | ||
| 1703 | // T3 only owns the temporary Plan override. Remember the agent's | ||
| 1704 | // effective native configuration on entry and restore it on Build. | ||
| 1705 | const nativeBuildConfigurationBySessionId = new Map<string, AcpNativeBuildConfiguration>(); | ||
| 1706 | const initialSessionActivationFailure = yield* Ref.make<{ | ||
| 1707 | readonly sessionId: string; | ||
| 1708 | readonly error: EffectAcpErrors.AcpError; | ||
| 1709 | } | null>(null); | ||
| 1710 | const activeSessionSetup = | ||
| 1711 | yield* Ref.make<AcpSessionRuntime.AcpSessionRuntimeStartResult | null>(null); | ||
| 1712 | const activeSelection = yield* Ref.make<ModelSelection | null>(null); | ||
| 1713 | const activeInteractionMode = yield* Ref.make<ProviderInteractionMode | null>(null); | ||
| 1714 | const promptInstructionStates = yield* Ref.make(new Map<string, T3AcpInstructionState>()); | ||
| 1715 | const runtimeRestartRequired = yield* Ref.make(false); | ||
| 1716 | const runtimeTeardownState = yield* Ref.make<AcpRuntimeTeardownState>({ _tag: "Idle" }); | ||
| 1717 | const runtimeCallbackGeneration = yield* Ref.make(0); | ||
| 1718 | const runtimeCallbackGenerationCounter = yield* Ref.make(0); | ||
| 1719 | const allocateRuntimeCallbackGeneration = Ref.updateAndGet( | ||
| 1720 | runtimeCallbackGenerationCounter, | ||
| 1721 | (generation) => generation + 1, | ||
| 1722 | ); | ||
| 1723 | const advanceRuntimeCallbackGeneration = allocateRuntimeCallbackGeneration.pipe( | ||
| 1724 | Effect.tap((generation) => Ref.set(runtimeCallbackGeneration, generation)), | ||
| 1725 | ); | ||
| 1726 | const runtimeCallbackPermit = yield* Semaphore.make(1); | ||
| 1727 | const runtimeTransitionPermit = yield* Semaphore.make(1); | ||
| 1728 | const nativeResponseAcknowledgements = yield* Ref.make( | ||
| 1729 | new Map< | ||
| 1730 | string, | ||
| 1731 | { | ||
| 1732 | readonly acknowledgement: Deferred.Deferred<void, EffectAcpErrors.AcpError>; | ||
| 1733 | readonly generation: number; | ||
| 1734 | } | ||
| 1735 | >(), | ||
| 1736 | ); | ||
| 1737 | const pendingRuntimeRequests = yield* Ref.make(new Map<string, PendingRuntimeRequest>()); | ||
| 1738 | const emitNativeResponseLifecycle = | ||
| 1739 | options.testHooks?.onNativeResponseLifecycle ?? (() => Effect.void); | ||
| 1740 | const nextElicitationOrdinal = yield* Ref.make(0); | ||
| 1741 | const providerTurns = yield* Ref.make(new Map<string, OrchestrationV2ProviderTurn>()); | ||
| 1742 | const snapshot = yield* Ref.make<SnapshotMessageState>({ | ||
| 1743 | order: [], | ||
| 1744 | messages: new Map(), | ||
| 1745 | loadingRole: null, | ||
| 1746 | loadingMessageId: null, | ||
| 1747 | loadingIndex: 0, | ||
| 1748 | }); | ||
| 1749 | |||
| 1750 | const awaitRuntimeTeardown = Effect.fnUntraced(function* () { | ||
| 1751 | const state = yield* Ref.get(runtimeTeardownState); | ||
| 1752 | if (state._tag === "InProgress") { | ||
| 1753 | yield* Deferred.await(state.completed); | ||
| 1754 | } else if (state._tag === "Failed") { | ||
| 1755 | return yield* state.error; | ||
| 1756 | } | ||
| 1757 | }); | ||
| 1758 | const runRuntimeCallbackAtGeneration = <A, E, R>( | ||
| 1759 | generation: number, | ||
| 1760 | effect: Effect.Effect<A, E, R>, | ||
| 1761 | ) => | ||
| 1762 | runtimeCallbackPermit.withPermit( | ||
| 1763 | Effect.gen(function* () { | ||
| 1764 | if ((yield* Ref.get(runtimeTeardownState))._tag !== "Idle") { | ||
| 1765 | return Option.none<A>(); | ||
| 1766 | } | ||
| 1767 | if ((yield* Ref.get(runtimeCallbackGeneration)) !== generation) { | ||
| 1768 | return Option.none<A>(); | ||
| 1769 | } | ||
| 1770 | return Option.some(yield* effect); | ||
| 1771 | }), | ||
| 1772 | ); | ||
| 1773 | const registerNativeResponseAcknowledgement = ( | ||
| 1774 | generation: number, | ||
| 1775 | transportRequestId: string, | ||
| 1776 | acknowledgement: Deferred.Deferred<void, EffectAcpErrors.AcpError>, | ||
| 1777 | ) => | ||
| 1778 | Effect.gen(function* () { | ||
| 1779 | yield* Ref.update(nativeResponseAcknowledgements, (current) => { | ||
| 1780 | const updated = new Map(current); | ||
| 1781 | updated.set(transportRequestId, { acknowledgement, generation }); | ||
| 1782 | return updated; | ||
| 1783 | }); | ||
| 1784 | yield* emitNativeResponseLifecycle({ | ||
| 1785 | type: "registered", | ||
| 1786 | generation, | ||
| 1787 | requestId: transportRequestId, | ||
| 1788 | }); | ||
| 1789 | if (!(yield* Deferred.isDone(acknowledgement))) return; | ||
| 1790 | yield* Ref.update(nativeResponseAcknowledgements, (current) => { | ||
| 1791 | if (current.get(transportRequestId)?.acknowledgement !== acknowledgement) { | ||
| 1792 | return current; | ||
| 1793 | } | ||
| 1794 | const updated = new Map(current); | ||
| 1795 | updated.delete(transportRequestId); | ||
| 1796 | return updated; | ||
| 1797 | }); | ||
| 1798 | }); | ||
| 1799 | const acknowledgeNativeResponse = (generation: number, transportRequestId: string) => | ||
| 1800 | ( | ||
| 1801 | options.testHooks?.beforeNativeResponseAdmissionCheck?.( | ||
| 1802 | generation, | ||
| 1803 | transportRequestId, | ||
| 1804 | ) ?? Effect.void | ||
| 1805 | ).pipe( | ||
| 1806 | Effect.andThen(runRuntimeCallbackAtGeneration(generation, Effect.void)), | ||
| 1807 | Effect.flatMap((registered) => | ||
| 1808 | Option.isSome(registered) | ||
| 1809 | ? Effect.void | ||
| 1810 | : emitNativeResponseLifecycle({ | ||
| 1811 | type: "admission_rejected", | ||
| 1812 | generation, | ||
| 1813 | requestId: transportRequestId, | ||
| 1814 | }).pipe( | ||
| 1815 | Effect.andThen( | ||
| 1816 | new EffectAcpErrors.AcpTransportError({ | ||
| 1817 | detail: "The ACP runtime closed before its response could be admitted", | ||
| 1818 | cause: "Native response registration rejected during teardown", | ||
| 1819 | }), | ||
| 1820 | ), | ||
| 1821 | ), | ||
| 1822 | ), | ||
| 1823 | ); | ||
| 1824 | const awaitNativeResponseAcknowledgements = Effect.fnUntraced(function* ( | ||
| 1825 | acknowledgements: ReadonlyArray< | ||
| 1826 | readonly [string | undefined, Deferred.Deferred<void, EffectAcpErrors.AcpError>] | ||
| 1827 | >, | ||
| 1828 | ) { | ||
| 1829 | if (acknowledgements.length === 0) return true; | ||
| 1830 | const completed = yield* Deferred.make<"settled" | "timeout">(); | ||
| 1831 | yield* emitNativeResponseLifecycle({ type: "watcher_started" }); | ||
| 1832 | const acknowledgementFiber = yield* Effect.forEach( | ||
| 1833 | acknowledgements, | ||
| 1834 | ([, acknowledgement]) => Deferred.await(acknowledgement).pipe(Effect.exit), | ||
| 1835 | { concurrency: "unbounded", discard: true }, | ||
| 1836 | ).pipe( | ||
| 1837 | Effect.andThen(Deferred.succeed(completed, "settled")), | ||
| 1838 | Effect.interruptible, | ||
| 1839 | Effect.ensuring(emitNativeResponseLifecycle({ type: "watcher_exited" })), | ||
| 1840 | Effect.forkDetach, | ||
| 1841 | ); | ||
| 1842 | yield* emitNativeResponseLifecycle({ type: "timer_started" }); | ||
| 1843 | const timerFiber = yield* Effect.sleep("2 seconds").pipe( | ||
| 1844 | Effect.andThen(Deferred.succeed(completed, "timeout")), | ||
| 1845 | Effect.interruptible, | ||
| 1846 | Effect.ensuring(emitNativeResponseLifecycle({ type: "timer_exited" })), | ||
| 1847 | Effect.forkDetach, | ||
| 1848 | ); | ||
| 1849 | const outcome = yield* Deferred.await(completed); | ||
| 1850 | yield* Fiber.interrupt(acknowledgementFiber); | ||
| 1851 | yield* Fiber.interrupt(timerFiber); | ||
| 1852 | if (outcome === "settled") return true; | ||
| 1853 | |||
| 1854 | yield* Ref.update(nativeResponseAcknowledgements, (current) => { | ||
| 1855 | const updated = new Map(current); | ||
| 1856 | for (const [requestId, acknowledgement] of acknowledgements) { | ||
| 1857 | if ( | ||
| 1858 | requestId !== undefined && | ||
| 1859 | updated.get(requestId)?.acknowledgement === acknowledgement | ||
| 1860 | ) { | ||
| 1861 | updated.delete(requestId); | ||
| 1862 | } | ||
| 1863 | } | ||
| 1864 | return updated; | ||
| 1865 | }); | ||
| 1866 | yield* Effect.forEach( | ||
| 1867 | acknowledgements, | ||
| 1868 | ([requestId]) => | ||
| 1869 | emitNativeResponseLifecycle({ | ||
| 1870 | type: "removed", | ||
| 1871 | ...(requestId === undefined ? {} : { requestId }), | ||
| 1872 | }), | ||
| 1873 | { concurrency: "unbounded", discard: true }, | ||
| 1874 | ); | ||
| 1875 | const timeoutError = new EffectAcpErrors.AcpTransportError({ | ||
| 1876 | detail: "Timed out waiting for an admitted ACP response to reach the transport queue", | ||
| 1877 | cause: "Native response acknowledgement timed out", | ||
| 1878 | }); | ||
| 1879 | yield* Effect.forEach( | ||
| 1880 | acknowledgements, | ||
| 1881 | ([requestId, acknowledgement]) => | ||
| 1882 | Deferred.fail(acknowledgement, timeoutError).pipe( | ||
| 1883 | Effect.andThen( | ||
| 1884 | emitNativeResponseLifecycle({ | ||
| 1885 | type: "failed", | ||
| 1886 | ...(requestId === undefined ? {} : { requestId }), | ||
| 1887 | }), | ||
| 1888 | ), | ||
| 1889 | ), | ||
| 1890 | { concurrency: "unbounded", discard: true }, | ||
| 1891 | ); | ||
| 1892 | return false; | ||
| 1893 | }); | ||
| 1894 | const awaitAdmittedNativeResponses = Ref.get(nativeResponseAcknowledgements).pipe( | ||
| 1895 | Effect.flatMap((current) => | ||
| 1896 | awaitNativeResponseAcknowledgements( | ||
| 1897 | [...current.entries()].map( | ||
| 1898 | ([requestId, entry]) => [requestId, entry.acknowledgement] as const, | ||
| 1899 | ), | ||
| 1900 | ), | ||
| 1901 | ), | ||
| 1902 | ); | ||
| 1903 | const quarantineNativeTransportAtGeneration = Effect.fnUntraced(function* ( | ||
| 1904 | generation: number, | ||
| 1905 | ) { | ||
| 1906 | const quarantined = yield* Ref.modify(nativeResponseAcknowledgements, (current) => { | ||
| 1907 | const updated = new Map(current); | ||
| 1908 | const acknowledgements: Array<Deferred.Deferred<void, EffectAcpErrors.AcpError>> = []; | ||
| 1909 | for (const [requestId, entry] of updated) { | ||
| 1910 | if (entry.generation !== generation) continue; | ||
| 1911 | updated.delete(requestId); | ||
| 1912 | acknowledgements.push(entry.acknowledgement); | ||
| 1913 | } | ||
| 1914 | return [acknowledgements, updated] as const; | ||
| 1915 | }); | ||
| 1916 | const error = new EffectAcpErrors.AcpTransportError({ | ||
| 1917 | detail: "The ACP runtime was replaced before its response reached the transport queue", | ||
| 1918 | cause: "ACP runtime transport was quarantined during teardown", | ||
| 1919 | }); | ||
| 1920 | yield* Effect.forEach( | ||
| 1921 | quarantined, | ||
| 1922 | (acknowledgement) => Deferred.fail(acknowledgement, error), | ||
| 1923 | { concurrency: "unbounded", discard: true }, | ||
| 1924 | ); | ||
| 1925 | }); | ||
| 1926 | const closeNativeTransport = runtimeCallbackPermit.withPermit( | ||
| 1927 | Effect.gen(function* () { | ||
| 1928 | yield* advanceRuntimeCallbackGeneration; | ||
| 1929 | const acknowledgements = yield* Ref.getAndSet( | ||
| 1930 | nativeResponseAcknowledgements, | ||
| 1931 | new Map(), | ||
| 1932 | ); | ||
| 1933 | const error = new EffectAcpErrors.AcpTransportError({ | ||
| 1934 | detail: "The ACP session closed before its admitted response reached the transport", | ||
| 1935 | cause: "ACP session transport closed", | ||
| 1936 | }); | ||
| 1937 | yield* Effect.forEach( | ||
| 1938 | acknowledgements, | ||
| 1939 | ([requestId, entry]) => | ||
| 1940 | Deferred.fail(entry.acknowledgement, error).pipe( | ||
| 1941 | Effect.andThen( | ||
| 1942 | emitNativeResponseLifecycle({ | ||
| 1943 | type: "removed", | ||
| 1944 | generation: entry.generation, | ||
| 1945 | requestId, | ||
| 1946 | }), | ||
| 1947 | ), | ||
| 1948 | Effect.andThen( | ||
| 1949 | emitNativeResponseLifecycle({ | ||
| 1950 | type: "failed", | ||
| 1951 | generation: entry.generation, | ||
| 1952 | requestId, | ||
| 1953 | }), | ||
| 1954 | ), | ||
| 1955 | ), | ||
| 1956 | { concurrency: "unbounded", discard: true }, | ||
| 1957 | ); | ||
| 1958 | return acknowledgements.size > 0; | ||
| 1959 | }), | ||
| 1960 | ); | ||
| 1961 | // Post-settle wake support (Grok async subagent/monitor follow-up). After | ||
| 1962 | // the root turn finalizes, later root session/update traffic buffers here | ||
| 1963 | // until a provider continuation run attaches and drains it. | ||
| 1964 | const lastTurnRoute = yield* Ref.make<{ | ||
| 1965 | readonly threadId: ThreadId; | ||
| 1966 | readonly providerThreadId: ProviderThreadId; | ||
| 1967 | } | null>(null); | ||
| 1968 | const wakeBuffer = yield* Ref.make<Array<EffectAcpSchema.SessionNotification>>([]); | ||
| 1969 | // Background work that ended after the prompt settled, keyed by task or | ||
| 1970 | // child session id. The next continuation offer names it for the user; | ||
| 1971 | // `offered` holds the keys that offer named. Work that ends while the | ||
| 1972 | // offer waits for its turn is named by the offer after it. | ||
| 1973 | const noWakeReports = { | ||
| 1974 | reports: new Map<string, BackgroundWorkReport>(), | ||
| 1975 | offered: new Set<string>(), | ||
| 1976 | }; | ||
| 1977 | const wakeReports = yield* Ref.make<{ | ||
| 1978 | readonly reports: ReadonlyMap<string, BackgroundWorkReport>; | ||
| 1979 | readonly offered: ReadonlySet<string>; | ||
| 1980 | }>(noWakeReports); | ||
| 1981 | const recordWakeReport = Effect.fnUntraced(function* ( | ||
| 1982 | key: string, | ||
| 1983 | report: BackgroundWorkReport, | ||
| 1984 | ) { | ||
| 1985 | const context = yield* Ref.get(activeTurn); | ||
| 1986 | // An open prompt reports the work itself; no continuation follows. | ||
| 1987 | if (context !== null && !context.promptSettled && !context.finalized) return; | ||
| 1988 | yield* Ref.update(wakeReports, ({ reports, offered }) => ({ | ||
| 1989 | reports: new Map(reports).set(key, report), | ||
| 1990 | offered, | ||
| 1991 | })); | ||
| 1992 | }); | ||
| 1993 | const continuationRequested = yield* Ref.make(false); | ||
| 1994 | const continuationGeneration = yield* Ref.make(0); | ||
| 1995 | const continuationPermit = yield* Semaphore.make(1); | ||
| 1996 | const continuationClosed = yield* Ref.make(false); | ||
| 1997 | // Direct Stop (requestRuntimeRestart) quarantines residual events from the | ||
| 1998 | // stopped run so they cannot wake or attach to a later prompt/run. | ||
| 1999 | const stoppedRunQuarantine = yield* Ref.make(false); | ||
| 2000 | // A steering restart (or any interrupt) can finalize a turn while its | ||
| 2001 | // spawned subagents are still running natively. Carry the live | ||
| 2002 | // lineages into the next turn on the same session so their terminal | ||
| 2003 | // signals can still flip the original turn items instead of leaving | ||
| 2004 | // them running forever. | ||
| 2005 | const carryoverSubagents = yield* Ref.make<AcpCarryoverSubagents | null>(null); | ||
| 2006 | const handledBackgroundTaskIdsInActiveTurn = yield* Ref.make<ReadonlySet<string>>( | ||
| 2007 | new Set(), | ||
| 2008 | ); | ||
| 2009 | // Background tasks that reached a genuine terminal mutation while a root | ||
| 2010 | // turn was still streaming and were not yet marked handled in-turn. The | ||
| 2011 | // mid-turn offer is suppressed (active turn owns the work); on finalize | ||
| 2012 | // these ids re-arm a single continuation when the agent never hydrated | ||
| 2013 | // or reported them before settle (regression: mid-turn complete + no | ||
| 2014 | // get_command must still wake after finalize). | ||
| 2015 | const midTurnUnreportedCompletedTaskIds = yield* Ref.make<ReadonlySet<string>>(new Set()); | ||
| 2016 | // A monitor-event can arrive after its task and the user-facing provider | ||
| 2017 | // turn already completed. Grok starts another internal prompt for that | ||
| 2018 | // stale notification; suppress its agent output until a genuine terminal | ||
| 2019 | // mutation or the next app turn so it cannot create a redundant app | ||
| 2020 | // continuation. Tool frames continue through normal hydration. | ||
| 2021 | const suppressPostSettleMonitorPrompt = yield* Ref.make(false); | ||
| 2022 | // Background tasks (Grok monitors) known to still run at session level. | ||
| 2023 | // Turn contexts are too short-lived to carry this: a continuation run | ||
| 2024 | // finalizes between monitor events, and the next commentary burst must | ||
| 2025 | // not reopen a run while the monitor is still streaming. | ||
| 2026 | const runningBackgroundTaskIds = yield* Ref.make<ReadonlySet<string>>(new Set()); | ||
| 2027 | // Task ids with a GENUINE end signal (monitor-ended reminder or | ||
| 2028 | // TaskOutput completion). Normalized tool statuses are not genuine: | ||
| 2029 | // Grok Bash re-reports carry exit_code 0 mid-stream. A straggler | ||
| 2030 | // monitor-event can land after the real end (the CLI keeps streaming | ||
| 2031 | // while the agent already consumed the output via | ||
| 2032 | // get_command_or_subagent_output); without the tombstone it would | ||
| 2033 | // resurrect the running set and pin offers/idle-release forever. | ||
| 2034 | // A tool-level failed get_command tombstones too: failing open to a | ||
| 2035 | // single continuation beats failing closed to a dead thread. | ||
| 2036 | const endedBackgroundTaskIds = yield* Ref.make<ReadonlySet<string>>(new Set()); | ||
| 2037 | const endedBackgroundTaskIdLimit = 128; | ||
| 2038 | |||
| 2039 | const setBackgroundTaskRunning = (taskId: string, running: boolean) => | ||
| 2040 | Effect.gen(function* () { | ||
| 2041 | if (running && (yield* Ref.get(endedBackgroundTaskIds)).has(taskId)) { | ||
| 2042 | return; | ||
| 2043 | } | ||
| 2044 | yield* Ref.update(runningBackgroundTaskIds, (current) => { | ||
| 2045 | if (current.has(taskId) === running) return current; | ||
| 2046 | const next = new Set(current); | ||
| 2047 | if (running) { | ||
| 2048 | next.add(taskId); | ||
| 2049 | } else { | ||
| 2050 | next.delete(taskId); | ||
| 2051 | } | ||
| 2052 | return next; | ||
| 2053 | }); | ||
| 2054 | }); | ||
| 2055 | |||
| 2056 | const markBackgroundTaskEnded = (taskId: string) => | ||
| 2057 | Ref.update(endedBackgroundTaskIds, (current) => { | ||
| 2058 | if (current.has(taskId)) return current; | ||
| 2059 | const next = new Set(current).add(taskId); | ||
| 2060 | for (const oldest of next) { | ||
| 2061 | if (next.size <= endedBackgroundTaskIdLimit) break; | ||
| 2062 | next.delete(oldest); | ||
| 2063 | } | ||
| 2064 | return next; | ||
| 2065 | }).pipe(Effect.andThen(setBackgroundTaskRunning(taskId, false))); | ||
| 2066 | |||
| 2067 | const applyBackgroundTaskMutationRunning = (mutation: { | ||
| 2068 | readonly taskId: string; | ||
| 2069 | readonly status: "running" | "completed" | "failed"; | ||
| 2070 | }) => | ||
| 2071 | mutation.status === "running" | ||
| 2072 | ? setBackgroundTaskRunning(mutation.taskId, true) | ||
| 2073 | : markBackgroundTaskEnded(mutation.taskId); | ||
| 2074 | |||
| 2075 | const trackRunningBackgroundTools = ( | ||
| 2076 | notification: EffectAcpSchema.SessionNotification, | ||
| 2077 | ): Effect.Effect<void> => | ||
| 2078 | Effect.gen(function* () { | ||
| 2079 | if (flavor.extractBackgroundTaskId === undefined) return; | ||
| 2080 | for (const event of parseSessionUpdateEvent(notification).events) { | ||
| 2081 | if (event._tag !== "ToolCallUpdated") continue; | ||
| 2082 | const toolCall = flavor.normalizeToolCall?.(event.toolCall) ?? event.toolCall; | ||
| 2083 | const taskId = flavor.extractBackgroundTaskId(toolCall); | ||
| 2084 | if (taskId === undefined) continue; | ||
| 2085 | const status = toolStatus(toolCall.status); | ||
| 2086 | yield* setBackgroundTaskRunning(taskId, status === "pending" || status === "running"); | ||
| 2087 | } | ||
| 2088 | }); | ||
| 2089 | |||
| 2090 | const emitProviderEvent = (event: ProviderAdapter.ProviderAdapterV2Event) => | ||
| 2091 | Queue.offer(events, event).pipe(Effect.asVoid); | ||
| 2092 | let scheduleDeferredFinalize: (context: ActiveAcpTurn) => Effect.Effect<void> = () => | ||
| 2093 | Effect.void; | ||
| 2094 | |||
| 2095 | const nativeLogging = options.nativeLogging?.(input.threadId); | ||
| 2096 | const handleRuntimeTerminationAtGeneration = (runtimeGeneration: number) => | ||
| 2097 | runRuntimeCallbackAtGeneration( | ||
| 2098 | runtimeGeneration, | ||
| 2099 | Ref.set(runtimeRestartRequired, true), | ||
| 2100 | ).pipe(Effect.asVoid); | ||
| 2101 | const makeRuntimeInput = ( | ||
| 2102 | runtimeGeneration: number, | ||
| 2103 | threadId: ThreadId | null, | ||
| 2104 | resumeSessionId?: string, | ||
| 2105 | onTermination: AcpAdapterV2RuntimeInput["onTermination"] = () => | ||
| 2106 | handleRuntimeTerminationAtGeneration(runtimeGeneration), | ||
| 2107 | ): AcpAdapterV2RuntimeInput => { | ||
| 2108 | const mcpContext = acpMcpContext(threadId, self); | ||
| 2109 | return { | ||
| 2110 | cwd: input.runtimePolicy.cwd ?? process.cwd(), | ||
| 2111 | runtimePolicy: input.runtimePolicy, | ||
| 2112 | mcpServers: mcpContext.servers, | ||
| 2113 | acpMcpServers: mcpContext.acpServers, | ||
| 2114 | ...(mcpContext.processEnvironment === undefined | ||
| 2115 | ? {} | ||
| 2116 | : { processEnvironment: mcpContext.processEnvironment }), | ||
| 2117 | ...(resumeSessionId === undefined ? {} : { resumeSessionId }), | ||
| 2118 | interruptPromptOnCancel: flavor.interruptPromptOnCancel ?? false, | ||
| 2119 | clientCapabilities: { | ||
| 2120 | fs: { | ||
| 2121 | readTextFile: flavor.clientFileSystem !== undefined, | ||
| 2122 | writeTextFile: flavor.clientFileSystem !== undefined, | ||
| 2123 | }, | ||
| 2124 | terminal: clientTerminals !== undefined, | ||
| 2125 | elicitation: { form: {}, ...(flavor.onUrlElicitation ? { url: {} } : {}) }, | ||
| 2126 | ...(flavor.clientCapabilitiesMeta ? { _meta: flavor.clientCapabilitiesMeta } : {}), | ||
| 2127 | }, | ||
| 2128 | clientInfo: { name: "t3-code", version: "0.0.0" }, | ||
| 2129 | onTermination, | ||
| 2130 | onOutgoingResponseFailure: (requestId, error) => | ||
| 2131 | Ref.modify(nativeResponseAcknowledgements, (current) => { | ||
| 2132 | const entry = current.get(requestId); | ||
| 2133 | if (entry === undefined || entry.generation !== runtimeGeneration) { | ||
| 2134 | return [ | ||
| 2135 | emitNativeResponseLifecycle({ | ||
| 2136 | type: "late_noop", | ||
| 2137 | generation: runtimeGeneration, | ||
| 2138 | requestId, | ||
| 2139 | }), | ||
| 2140 | current, | ||
| 2141 | ] as const; | ||
| 2142 | } | ||
| 2143 | const updated = new Map(current); | ||
| 2144 | updated.delete(requestId); | ||
| 2145 | return [ | ||
| 2146 | Deferred.fail(entry.acknowledgement, error).pipe( | ||
| 2147 | Effect.andThen( | ||
| 2148 | emitNativeResponseLifecycle({ | ||
| 2149 | type: "removed", | ||
| 2150 | generation: runtimeGeneration, | ||
| 2151 | requestId, | ||
| 2152 | }), | ||
| 2153 | ), | ||
| 2154 | Effect.asVoid, | ||
| 2155 | ), | ||
| 2156 | updated, | ||
| 2157 | ] as const; | ||
| 2158 | }).pipe(Effect.flatten), | ||
| 2159 | onOutgoingResponse: (requestId) => | ||
| 2160 | Ref.modify(nativeResponseAcknowledgements, (current) => { | ||
| 2161 | const entry = current.get(requestId); | ||
| 2162 | if (entry === undefined || entry.generation !== runtimeGeneration) { | ||
| 2163 | return [ | ||
| 2164 | emitNativeResponseLifecycle({ | ||
| 2165 | type: "late_noop", | ||
| 2166 | generation: runtimeGeneration, | ||
| 2167 | requestId, | ||
| 2168 | }), | ||
| 2169 | current, | ||
| 2170 | ] as const; | ||
| 2171 | } | ||
| 2172 | const updated = new Map(current); | ||
| 2173 | updated.delete(requestId); | ||
| 2174 | return [ | ||
| 2175 | Deferred.succeed(entry.acknowledgement, undefined).pipe( | ||
| 2176 | Effect.andThen( | ||
| 2177 | emitNativeResponseLifecycle({ | ||
| 2178 | type: "removed", | ||
| 2179 | generation: runtimeGeneration, | ||
| 2180 | requestId, | ||
| 2181 | }), | ||
| 2182 | ), | ||
| 2183 | Effect.asVoid, | ||
| 2184 | ), | ||
| 2185 | updated, | ||
| 2186 | ] as const; | ||
| 2187 | }).pipe(Effect.flatten), | ||
| 2188 | ...(nativeLogging?.requestLogger === undefined | ||
| 2189 | ? {} | ||
| 2190 | : { requestLogger: nativeLogging.requestLogger }), | ||
| 2191 | protocolLogging: nativeLogging?.protocolLogging ?? { | ||
| 2192 | logIncoming: true, | ||
| 2193 | logOutgoing: true, | ||
| 2194 | logger: () => Effect.void, | ||
| 2195 | }, | ||
| 2196 | }; | ||
| 2197 | }; | ||
| 2198 | let runtimeScope: Scope.Closeable | undefined; | ||
| 2199 | let runtime!: AcpSessionRuntime.AcpSessionRuntime["Service"]; | ||
| 2200 | let runtimeMcpBridge: AcpMcpOverAcpBridge | undefined; | ||
| 2201 | yield* Effect.addFinalizer(() => | ||
| 2202 | runtimeScope === undefined | ||
| 2203 | ? Effect.void | ||
| 2204 | : Scope.close(runtimeScope, Exit.void).pipe(Effect.ignore), | ||
| 2205 | ); | ||
| 2206 | |||
| 2207 | const resolveItemOrdinal = (context: ActiveAcpTurn, nativeItemId: string) => | ||
| 2208 | Effect.sync(() => { | ||
| 2209 | const existing = context.itemOrdinals.get(nativeItemId); | ||
| 2210 | if (existing !== undefined) return existing; | ||
| 2211 | const ordinal = context.input.providerTurnOrdinal * 100 + context.itemOrdinals.size + 1; | ||
| 2212 | context.itemOrdinals.set(nativeItemId, ordinal); | ||
| 2213 | return ordinal; | ||
| 2214 | }); | ||
| 2215 | |||
| 2216 | const resolveSubagentChildOrdinal = (subagent: ActiveAcpSubagent, nativeItemId: string) => { | ||
| 2217 | const existing = subagent.childItemOrdinals.get(nativeItemId); | ||
| 2218 | if (existing !== undefined) return existing; | ||
| 2219 | const ordinal = subagent.nextChildOrdinal++; | ||
| 2220 | subagent.childItemOrdinals.set(nativeItemId, ordinal); | ||
| 2221 | return ordinal; | ||
| 2222 | }; | ||
| 2223 | |||
| 2224 | const emitProviderRetry = Effect.fnUntraced(function* ( | ||
| 2225 | context: ActiveAcpTurn, | ||
| 2226 | status: "running" | "completed" | "interrupted" | "cancelled", | ||
| 2227 | ) { | ||
| 2228 | const state = context.providerRetry; | ||
| 2229 | if (state === undefined) return; | ||
| 2230 | yield* emitProviderEvent({ | ||
| 2231 | type: "turn_item.updated", | ||
| 2232 | driver, | ||
| 2233 | turnItem: makeProviderRetryTurnItem({ | ||
| 2234 | idAllocator, | ||
| 2235 | driver, | ||
| 2236 | threadId: context.input.threadId, | ||
| 2237 | runId: context.input.runId, | ||
| 2238 | nodeId: context.input.rootNodeId, | ||
| 2239 | providerThreadId: context.input.providerThread.id, | ||
| 2240 | providerTurnId: context.providerTurnId, | ||
| 2241 | ...state, | ||
| 2242 | status, | ||
| 2243 | updatedAt: yield* DateTime.now, | ||
| 2244 | }), | ||
| 2245 | }); | ||
| 2246 | if (status !== "running") context.providerRetry = undefined; | ||
| 2247 | }); | ||
| 2248 | |||
| 2249 | const reportProviderRetry = Effect.fnUntraced(function* (notice: { | ||
| 2250 | readonly sessionId: string; | ||
| 2251 | readonly failure: OrchestrationV2ProviderFailure; | ||
| 2252 | }) { | ||
| 2253 | const context = yield* Ref.get(activeTurn); | ||
| 2254 | if ( | ||
| 2255 | context === null || | ||
| 2256 | context.finalized || | ||
| 2257 | context.interrupted || | ||
| 2258 | context.nativeThreadId !== notice.sessionId || | ||
| 2259 | (yield* Ref.get(stoppedRunQuarantine)) | ||
| 2260 | ) | ||
| 2261 | return; | ||
| 2262 | const previous = context.providerRetry; | ||
| 2263 | context.providerRetry = { | ||
| 2264 | failure: notice.failure, | ||
| 2265 | retry: { | ||
| 2266 | attempt: (previous?.retry.attempt ?? 0) + 1, | ||
| 2267 | maxAttempts: null, | ||
| 2268 | retryDelayMs: null, | ||
| 2269 | }, | ||
| 2270 | startedAt: previous?.startedAt ?? (yield* DateTime.now), | ||
| 2271 | itemOrdinal: | ||
| 2272 | previous?.itemOrdinal ?? | ||
| 2273 | (yield* resolveItemOrdinal(context, `terminal-failure:${context.providerTurnId}`)), | ||
| 2274 | }; | ||
| 2275 | yield* emitProviderRetry(context, "running"); | ||
| 2276 | }); | ||
| 2277 | |||
| 2278 | const lastCapturedProposedPlan = yield* Ref.make<{ | ||
| 2279 | readonly nativeTurnId: string; | ||
| 2280 | readonly markdown: string; | ||
| 2281 | readonly planId: OrchestrationV2PlanArtifact["id"]; | ||
| 2282 | } | null>(null); | ||
| 2283 | /** | ||
| 2284 | * Emits a completed proposed-plan artifact for the active turn. Same | ||
| 2285 | * plan id per turn so repeated captures (plan.md rewrites, the exit | ||
| 2286 | * gate) update one card; identical markdown within a turn is a no-op. | ||
| 2287 | */ | ||
| 2288 | const captureProposedPlan = Effect.fnUntraced(function* (input: { | ||
| 2289 | readonly planMarkdown: string; | ||
| 2290 | }) { | ||
| 2291 | const context = yield* Ref.get(activeTurn); | ||
| 2292 | if (context === null) return; | ||
| 2293 | const markdown = input.planMarkdown.trim(); | ||
| 2294 | if (markdown.length === 0) return; | ||
| 2295 | const previous = yield* Ref.get(lastCapturedProposedPlan); | ||
| 2296 | if ( | ||
| 2297 | previous !== null && | ||
| 2298 | previous.nativeTurnId === context.nativeTurnId && | ||
| 2299 | previous.markdown === markdown | ||
| 2300 | ) { | ||
| 2301 | return; | ||
| 2302 | } | ||
| 2303 | const nativeItemId = `${context.nativeTurnId}:proposed-plan`; | ||
| 2304 | const planId = | ||
| 2305 | previous !== null && previous.nativeTurnId === context.nativeTurnId | ||
| 2306 | ? previous.planId | ||
| 2307 | : yield* idAllocator.allocate | ||
| 2308 | .plan({ | ||
| 2309 | threadId: context.input.threadId, | ||
| 2310 | runId: context.input.runId, | ||
| 2311 | driver, | ||
| 2312 | }) | ||
| 2313 | .pipe(Effect.orDie); | ||
| 2314 | yield* Ref.set(lastCapturedProposedPlan, { | ||
| 2315 | nativeTurnId: context.nativeTurnId, | ||
| 2316 | markdown, | ||
| 2317 | planId, | ||
| 2318 | }); | ||
| 2319 | const now = yield* DateTime.now; | ||
| 2320 | const nodeId = idAllocator.derive.nodeFromProviderItem({ driver, nativeItemId }); | ||
| 2321 | const turnItemId = idAllocator.derive.turnItemFromProviderItem({ driver, nativeItemId }); | ||
| 2322 | const ordinal = yield* resolveItemOrdinal(context, nativeItemId); | ||
| 2323 | const nativeItemRef = { driver, nativeId: nativeItemId, strength: "weak" as const }; | ||
| 2324 | yield* emitProviderEvent({ | ||
| 2325 | type: "node.updated", | ||
| 2326 | driver, | ||
| 2327 | node: { | ||
| 2328 | id: nodeId, | ||
| 2329 | threadId: context.input.threadId, | ||
| 2330 | runId: context.input.runId, | ||
| 2331 | parentNodeId: context.input.rootNodeId, | ||
| 2332 | rootNodeId: context.input.rootNodeId, | ||
| 2333 | kind: "plan", | ||
| 2334 | status: "completed", | ||
| 2335 | countsForRun: false, | ||
| 2336 | providerThreadId: context.input.providerThread.id, | ||
| 2337 | providerTurnId: context.providerTurnId, | ||
| 2338 | nativeItemRef, | ||
| 2339 | runtimeRequestId: null, | ||
| 2340 | checkpointScopeId: null, | ||
| 2341 | startedAt: context.startedAt, | ||
| 2342 | completedAt: now, | ||
| 2343 | }, | ||
| 2344 | }); | ||
| 2345 | yield* emitProviderEvent({ | ||
| 2346 | type: "plan.updated", | ||
| 2347 | driver, | ||
| 2348 | plan: { | ||
| 2349 | id: planId, | ||
| 2350 | threadId: context.input.threadId, | ||
| 2351 | runId: context.input.runId, | ||
| 2352 | nodeId, | ||
| 2353 | kind: "proposed_plan", | ||
| 2354 | status: "completed", | ||
| 2355 | markdown, | ||
| 2356 | }, | ||
| 2357 | }); | ||
| 2358 | yield* emitProviderEvent({ | ||
| 2359 | type: "turn_item.updated", | ||
| 2360 | driver, | ||
| 2361 | turnItem: { | ||
| 2362 | id: turnItemId, | ||
| 2363 | threadId: context.input.threadId, | ||
| 2364 | runId: context.input.runId, | ||
| 2365 | nodeId, | ||
| 2366 | providerThreadId: context.input.providerThread.id, | ||
| 2367 | providerTurnId: context.providerTurnId, | ||
| 2368 | nativeItemRef, | ||
| 2369 | parentItemId: null, | ||
| 2370 | ordinal, | ||
| 2371 | status: "completed", | ||
| 2372 | title: null, | ||
| 2373 | startedAt: context.startedAt, | ||
| 2374 | completedAt: now, | ||
| 2375 | updatedAt: now, | ||
| 2376 | type: "proposed_plan", | ||
| 2377 | planId, | ||
| 2378 | markdown, | ||
| 2379 | streaming: false, | ||
| 2380 | }, | ||
| 2381 | }); | ||
| 2382 | }); | ||
| 2383 | const lastProposedPlanMarkdown = Effect.gen(function* () { | ||
| 2384 | const context = yield* Ref.get(activeTurn); | ||
| 2385 | const previous = yield* Ref.get(lastCapturedProposedPlan); | ||
| 2386 | return context !== null && previous?.nativeTurnId === context.nativeTurnId | ||
| 2387 | ? previous.markdown | ||
| 2388 | : undefined; | ||
| 2389 | }); | ||
| 2390 | |||
| 2391 | const rememberSnapshotMessage = (message: OrchestrationV2ConversationMessage) => | ||
| 2392 | Ref.update(snapshot, (current) => { | ||
| 2393 | const key = String(message.id); | ||
| 2394 | const exists = current.messages.has(key); | ||
| 2395 | const messages = new Map(current.messages); | ||
| 2396 | messages.set(key, message); | ||
| 2397 | return { | ||
| 2398 | ...current, | ||
| 2399 | order: exists ? current.order : [...current.order, key], | ||
| 2400 | messages, | ||
| 2401 | }; | ||
| 2402 | }); | ||
| 2403 | |||
| 2404 | const textStreamFor = ( | ||
| 2405 | context: ActiveAcpTurn, | ||
| 2406 | kind: "user" | "assistant" | "reasoning", | ||
| 2407 | ): ActiveTextStream => | ||
| 2408 | kind === "user" | ||
| 2409 | ? context.user | ||
| 2410 | : kind === "assistant" | ||
| 2411 | ? context.assistant | ||
| 2412 | : context.reasoning; | ||
| 2413 | |||
| 2414 | const emitTextSegment = Effect.fnUntraced(function* ( | ||
| 2415 | context: ActiveAcpTurn, | ||
| 2416 | kind: "user" | "assistant" | "reasoning", | ||
| 2417 | completed: boolean, | ||
| 2418 | ) { | ||
| 2419 | const stream = textStreamFor(context, kind); | ||
| 2420 | const segment = stream.current; | ||
| 2421 | if (segment === null) return; | ||
| 2422 | const now = yield* DateTime.now; | ||
| 2423 | const ordinal = yield* resolveItemOrdinal(context, segment.nativeItemId); | ||
| 2424 | const nodeId = providerNodeId(segment.nativeItemId); | ||
| 2425 | const turnItemId = providerTurnItemId(segment.nativeItemId); | ||
| 2426 | const nativeItemRef = { | ||
| 2427 | driver, | ||
| 2428 | nativeId: segment.nativeItemId, | ||
| 2429 | strength: "weak" as const, | ||
| 2430 | }; | ||
| 2431 | if (kind !== "user") { | ||
| 2432 | yield* emitProviderEvent({ | ||
| 2433 | type: "node.updated", | ||
| 2434 | driver, | ||
| 2435 | node: { | ||
| 2436 | id: nodeId, | ||
| 2437 | threadId: context.input.threadId, | ||
| 2438 | runId: context.input.runId, | ||
| 2439 | parentNodeId: context.input.rootNodeId, | ||
| 2440 | rootNodeId: context.input.rootNodeId, | ||
| 2441 | kind: kind === "assistant" ? "assistant_message" : "reasoning", | ||
| 2442 | status: completed ? "completed" : "running", | ||
| 2443 | countsForRun: false, | ||
| 2444 | providerThreadId: context.input.providerThread.id, | ||
| 2445 | providerTurnId: context.providerTurnId, | ||
| 2446 | nativeItemRef, | ||
| 2447 | runtimeRequestId: null, | ||
| 2448 | checkpointScopeId: null, | ||
| 2449 | startedAt: segment.startedAt, | ||
| 2450 | completedAt: completed ? now : null, | ||
| 2451 | }, | ||
| 2452 | }); | ||
| 2453 | } | ||
| 2454 | if (kind !== "reasoning") { | ||
| 2455 | const messageId = providerMessageId(segment.nativeItemId); | ||
| 2456 | const messageNodeId = kind === "user" ? context.input.rootNodeId : nodeId; | ||
| 2457 | const message: OrchestrationV2ConversationMessage = { | ||
| 2458 | createdBy: kind === "user" ? "user" : "agent", | ||
| 2459 | creationSource: "provider", | ||
| 2460 | id: messageId, | ||
| 2461 | threadId: context.input.threadId, | ||
| 2462 | runId: context.input.runId, | ||
| 2463 | nodeId: messageNodeId, | ||
| 2464 | role: kind, | ||
| 2465 | text: segment.text, | ||
| 2466 | attachments: [], | ||
| 2467 | streaming: !completed, | ||
| 2468 | createdAt: segment.startedAt, | ||
| 2469 | updatedAt: now, | ||
| 2470 | }; | ||
| 2471 | yield* emitProviderEvent({ type: "message.updated", driver, message }); | ||
| 2472 | yield* emitProviderEvent( | ||
| 2473 | kind === "user" | ||
| 2474 | ? { | ||
| 2475 | type: "turn_item.updated", | ||
| 2476 | driver, | ||
| 2477 | turnItem: { | ||
| 2478 | createdBy: "user", | ||
| 2479 | creationSource: "provider", | ||
| 2480 | id: turnItemId, | ||
| 2481 | threadId: context.input.threadId, | ||
| 2482 | runId: context.input.runId, | ||
| 2483 | nodeId: messageNodeId, | ||
| 2484 | providerThreadId: context.input.providerThread.id, | ||
| 2485 | providerTurnId: context.providerTurnId, | ||
| 2486 | nativeItemRef, | ||
| 2487 | parentItemId: null, | ||
| 2488 | ordinal, | ||
| 2489 | status: completed ? "completed" : "running", | ||
| 2490 | title: null, | ||
| 2491 | startedAt: segment.startedAt, | ||
| 2492 | completedAt: completed ? now : null, | ||
| 2493 | updatedAt: now, | ||
| 2494 | type: "user_message", | ||
| 2495 | messageId, | ||
| 2496 | inputIntent: "turn_start", | ||
| 2497 | text: segment.text, | ||
| 2498 | attachments: [], | ||
| 2499 | }, | ||
| 2500 | } | ||
| 2501 | : { | ||
| 2502 | type: "turn_item.updated", | ||
| 2503 | driver, | ||
| 2504 | turnItem: { | ||
| 2505 | id: turnItemId, | ||
| 2506 | threadId: context.input.threadId, | ||
| 2507 | runId: context.input.runId, | ||
| 2508 | nodeId, | ||
| 2509 | providerThreadId: context.input.providerThread.id, | ||
| 2510 | providerTurnId: context.providerTurnId, | ||
| 2511 | nativeItemRef, | ||
| 2512 | parentItemId: null, | ||
| 2513 | ordinal, | ||
| 2514 | status: completed ? "completed" : "running", | ||
| 2515 | title: null, | ||
| 2516 | startedAt: segment.startedAt, | ||
| 2517 | completedAt: completed ? now : null, | ||
| 2518 | updatedAt: now, | ||
| 2519 | type: "assistant_message", | ||
| 2520 | messageId, | ||
| 2521 | text: segment.text, | ||
| 2522 | streaming: !completed, | ||
| 2523 | }, | ||
| 2524 | }, | ||
| 2525 | ); | ||
| 2526 | if (completed) yield* rememberSnapshotMessage(message); | ||
| 2527 | return; | ||
| 2528 | } | ||
| 2529 | yield* emitProviderEvent({ | ||
| 2530 | type: "turn_item.updated", | ||
| 2531 | driver, | ||
| 2532 | turnItem: { | ||
| 2533 | id: turnItemId, | ||
| 2534 | threadId: context.input.threadId, | ||
| 2535 | runId: context.input.runId, | ||
| 2536 | nodeId, | ||
| 2537 | providerThreadId: context.input.providerThread.id, | ||
| 2538 | providerTurnId: context.providerTurnId, | ||
| 2539 | nativeItemRef, | ||
| 2540 | parentItemId: null, | ||
| 2541 | ordinal, | ||
| 2542 | status: completed ? "completed" : "running", | ||
| 2543 | title: null, | ||
| 2544 | startedAt: segment.startedAt, | ||
| 2545 | completedAt: completed ? now : null, | ||
| 2546 | updatedAt: now, | ||
| 2547 | type: "reasoning", | ||
| 2548 | text: segment.text, | ||
| 2549 | streaming: !completed, | ||
| 2550 | }, | ||
| 2551 | }); | ||
| 2552 | }); | ||
| 2553 | |||
| 2554 | const closeTextStream = Effect.fnUntraced(function* ( | ||
| 2555 | context: ActiveAcpTurn, | ||
| 2556 | kind: "user" | "assistant" | "reasoning", | ||
| 2557 | ) { | ||
| 2558 | const stream = textStreamFor(context, kind); | ||
| 2559 | if (stream.current === null) return; | ||
| 2560 | yield* emitTextSegment(context, kind, true); | ||
| 2561 | stream.current = null; | ||
| 2562 | }); | ||
| 2563 | |||
| 2564 | const closeTextStreams = Effect.fnUntraced(function* (context: ActiveAcpTurn) { | ||
| 2565 | yield* closeTextStream(context, "user"); | ||
| 2566 | yield* closeTextStream(context, "reasoning"); | ||
| 2567 | yield* closeTextStream(context, "assistant"); | ||
| 2568 | }); | ||
| 2569 | |||
| 2570 | const appendText = Effect.fnUntraced(function* ( | ||
| 2571 | context: ActiveAcpTurn, | ||
| 2572 | kind: "user" | "assistant" | "reasoning", | ||
| 2573 | text: string, | ||
| 2574 | messageId?: string | null, | ||
| 2575 | ) { | ||
| 2576 | if (text.length === 0) return; | ||
| 2577 | for (const other of ["user", "reasoning", "assistant"] as const) { | ||
| 2578 | if (other !== kind) yield* closeTextStream(context, other); | ||
| 2579 | } | ||
| 2580 | const stream = textStreamFor(context, kind); | ||
| 2581 | const sourceMessageId = messageId?.trim() || null; | ||
| 2582 | if ( | ||
| 2583 | stream.current !== null && | ||
| 2584 | sourceMessageId !== null && | ||
| 2585 | stream.current.sourceMessageId !== null && | ||
| 2586 | stream.current.sourceMessageId !== sourceMessageId | ||
| 2587 | ) { | ||
| 2588 | yield* closeTextStream(context, kind); | ||
| 2589 | } | ||
| 2590 | if (stream.current === null) { | ||
| 2591 | const now = yield* DateTime.now; | ||
| 2592 | stream.current = { | ||
| 2593 | nativeItemId: | ||
| 2594 | sourceMessageId === null | ||
| 2595 | ? `${context.nativeTurnId}:${kind}:${stream.nextSegment}` | ||
| 2596 | : `${context.nativeTurnId}:${kind}:message:${sourceMessageId}`, | ||
| 2597 | startedAt: now, | ||
| 2598 | sourceMessageId, | ||
| 2599 | text: "", | ||
| 2600 | }; | ||
| 2601 | stream.nextSegment += 1; | ||
| 2602 | } else if (stream.current.sourceMessageId === null && sourceMessageId !== null) { | ||
| 2603 | // Some agents omit messageId on the first chunk. Keep the already | ||
| 2604 | // projected identity and use the first later id as its boundary. | ||
| 2605 | stream.current.sourceMessageId = sourceMessageId; | ||
| 2606 | } | ||
| 2607 | stream.current.text += text; | ||
| 2608 | yield* emitTextSegment(context, kind, false); | ||
| 2609 | }); | ||
| 2610 | |||
| 2611 | const replaceText = Effect.fnUntraced(function* ( | ||
| 2612 | context: ActiveAcpTurn, | ||
| 2613 | kind: "user" | "assistant" | "reasoning", | ||
| 2614 | text: string, | ||
| 2615 | messageId: string, | ||
| 2616 | ) { | ||
| 2617 | for (const other of ["user", "reasoning", "assistant"] as const) { | ||
| 2618 | if (other !== kind) yield* closeTextStream(context, other); | ||
| 2619 | } | ||
| 2620 | const stream = textStreamFor(context, kind); | ||
| 2621 | if (stream.current?.sourceMessageId !== messageId) { | ||
| 2622 | yield* closeTextStream(context, kind); | ||
| 2623 | const now = yield* DateTime.now; | ||
| 2624 | stream.current = { | ||
| 2625 | nativeItemId: `${context.nativeTurnId}:${kind}:message:${messageId}`, | ||
| 2626 | startedAt: now, | ||
| 2627 | sourceMessageId: messageId, | ||
| 2628 | text, | ||
| 2629 | }; | ||
| 2630 | stream.nextSegment += 1; | ||
| 2631 | } else { | ||
| 2632 | stream.current.text = text; | ||
| 2633 | } | ||
| 2634 | yield* emitTextSegment(context, kind, false); | ||
| 2635 | }); | ||
| 2636 | |||
| 2637 | const emitSubagentAssistant = Effect.fnUntraced(function* ( | ||
| 2638 | subagent: ActiveAcpSubagent, | ||
| 2639 | text: string, | ||
| 2640 | mode: "append" | "replace" = "append", | ||
| 2641 | messageId?: string | null, | ||
| 2642 | ) { | ||
| 2643 | if (text.length === 0 && mode === "append") return; | ||
| 2644 | const nativeItemId = `${subagent.task.nativeTaskRef?.nativeId ?? subagent.task.id}:message:${messageId ?? "result"}`; | ||
| 2645 | const previous = subagent.assistantMessages.get(nativeItemId) ?? ""; | ||
| 2646 | const messageText = mode === "replace" ? text : `${previous}${text}`; | ||
| 2647 | subagent.assistantMessages.set(nativeItemId, messageText); | ||
| 2648 | subagent.assistantText = messageText; | ||
| 2649 | const now = yield* DateTime.now; | ||
| 2650 | const ordinal = resolveSubagentChildOrdinal(subagent, nativeItemId); | ||
| 2651 | const artifacts = makeSubagentConversationArtifacts({ | ||
| 2652 | messageId: providerMessageId(nativeItemId), | ||
| 2653 | turnItemId: providerTurnItemId(nativeItemId), | ||
| 2654 | threadId: subagent.childThreadId, | ||
| 2655 | rootNodeId: subagent.childRootNodeId, | ||
| 2656 | providerThreadId: subagent.task.providerThreadId, | ||
| 2657 | providerTurnId: null, | ||
| 2658 | nativeItemRef: { driver, nativeId: nativeItemId, strength: "weak" }, | ||
| 2659 | role: "assistant", | ||
| 2660 | text: subagent.assistantText, | ||
| 2661 | ordinal, | ||
| 2662 | now, | ||
| 2663 | }); | ||
| 2664 | yield* emitProviderEvent({ type: "message.updated", driver, message: artifacts.message }); | ||
| 2665 | yield* emitProviderEvent({ | ||
| 2666 | type: "turn_item.updated", | ||
| 2667 | driver, | ||
| 2668 | turnItem: artifacts.turnItem, | ||
| 2669 | }); | ||
| 2670 | }); | ||
| 2671 | |||
| 2672 | const projectSubagentNotification = Effect.fnUntraced(function* ( | ||
| 2673 | subagent: ActiveAcpSubagent, | ||
| 2674 | notification: EffectAcpSchema.SessionNotification, | ||
| 2675 | ) { | ||
| 2676 | const update = notification.update; | ||
| 2677 | if (update.sessionUpdate === "agent_message_chunk") { | ||
| 2678 | const text = acpContentBlockDisplayText(update.content); | ||
| 2679 | if (text !== undefined) { | ||
| 2680 | yield* emitSubagentAssistant(subagent, text, "append", update.messageId); | ||
| 2681 | } | ||
| 2682 | } else if (update.sessionUpdate === "agent_message") { | ||
| 2683 | if (update.content === undefined) return; | ||
| 2684 | const text = (update.content ?? []) | ||
| 2685 | .flatMap((content) => { | ||
| 2686 | const display = acpContentBlockDisplayText(content); | ||
| 2687 | return display === undefined ? [] : [display]; | ||
| 2688 | }) | ||
| 2689 | .join("\n"); | ||
| 2690 | yield* emitSubagentAssistant(subagent, text, "replace", update.messageId); | ||
| 2691 | } | ||
| 2692 | }); | ||
| 2693 | |||
| 2694 | const emitSubagent = Effect.fnUntraced(function* ( | ||
| 2695 | context: ActiveAcpTurn, | ||
| 2696 | update: AcpAdapterV2SubagentUpdate, | ||
| 2697 | ) { | ||
| 2698 | // Hydration tools (get_command_or_subagent_output) use a new toolCallId | ||
| 2699 | // but reference the child via subagent_id / task id. | ||
| 2700 | const existing = | ||
| 2701 | context.subagents.get(update.nativeTaskId) ?? | ||
| 2702 | (update.childSessionId !== null | ||
| 2703 | ? context.subagentsBySessionId.get(update.childSessionId) | ||
| 2704 | : undefined); | ||
| 2705 | const updateIsTerminal = acpSubagentStatusIsTerminal(update.status); | ||
| 2706 | if ( | ||
| 2707 | existing !== undefined && | ||
| 2708 | acpSubagentStatusIsTerminal(existing.task.status) && | ||
| 2709 | !updateIsTerminal | ||
| 2710 | ) { | ||
| 2711 | return; | ||
| 2712 | } | ||
| 2713 | if ( | ||
| 2714 | existing !== undefined && | ||
| 2715 | existing.task.status === update.status && | ||
| 2716 | updateIsTerminal && | ||
| 2717 | existing.terminalStatusProjected | ||
| 2718 | ) { | ||
| 2719 | return; | ||
| 2720 | } | ||
| 2721 | // get_command_or_subagent_output may target monitors/bash tasks. Only | ||
| 2722 | // hydrate when we already have a matching subagent lineage. Spawn ACKs | ||
| 2723 | // (non-empty prompt) may create a new lineage; empty-prompt hydration | ||
| 2724 | // with suppressNormalTool must not invent a phantom subagent. | ||
| 2725 | if (existing === undefined) { | ||
| 2726 | const isHydrationOnly = update.prompt === "" && update.title === null; | ||
| 2727 | if (isHydrationOnly || update.suppressNormalTool === false) { | ||
| 2728 | return; | ||
| 2729 | } | ||
| 2730 | } | ||
| 2731 | const now = yield* DateTime.now; | ||
| 2732 | const nativeTaskId = existing?.task.nativeTaskRef?.nativeId ?? update.nativeTaskId; | ||
| 2733 | const nativeItemRef = { | ||
| 2734 | driver, | ||
| 2735 | nativeId: nativeTaskId, | ||
| 2736 | strength: "strong" as const, | ||
| 2737 | }; | ||
| 2738 | const nodeId = existing?.task.id ?? providerNodeId(nativeTaskId); | ||
| 2739 | const childThreadId = | ||
| 2740 | existing?.childThreadId ?? | ||
| 2741 | idAllocator.derive.threadFromProviderThread({ | ||
| 2742 | driver, | ||
| 2743 | providerInstanceId: context.input.modelSelection.instanceId, | ||
| 2744 | nativeThreadId: `${context.nativeThreadId}:task:${nativeTaskId}`, | ||
| 2745 | }); | ||
| 2746 | const childRootNodeId = | ||
| 2747 | existing?.childRootNodeId ?? providerNodeId(`${nativeTaskId}:child-root`); | ||
| 2748 | const turnItemId = existing?.turnItemId ?? providerTurnItemId(nativeTaskId); | ||
| 2749 | const turnItemOrdinal = | ||
| 2750 | existing?.turnItemOrdinal ?? (yield* resolveItemOrdinal(context, nativeTaskId)); | ||
| 2751 | const taskStatus = update.status; | ||
| 2752 | const task: OrchestrationV2Subagent = { | ||
| 2753 | ...(existing?.task ?? { | ||
| 2754 | id: nodeId, | ||
| 2755 | threadId: context.input.threadId, | ||
| 2756 | runId: context.input.runId, | ||
| 2757 | parentNodeId: context.input.rootNodeId, | ||
| 2758 | origin: "provider_native" as const, | ||
| 2759 | createdBy: "agent" as const, | ||
| 2760 | driver, | ||
| 2761 | providerInstanceId: context.input.modelSelection.instanceId, | ||
| 2762 | providerThreadId: null, | ||
| 2763 | childThreadId, | ||
| 2764 | nativeTaskRef: nativeItemRef, | ||
| 2765 | prompt: update.prompt, | ||
| 2766 | title: update.title, | ||
| 2767 | result: null, | ||
| 2768 | startedAt: now, | ||
| 2769 | }), | ||
| 2770 | model: update.model?.trim() || existing?.task.model || null, | ||
| 2771 | status: taskStatus, | ||
| 2772 | result: update.result ?? existing?.task.result ?? null, | ||
| 2773 | completedAt: acpSubagentStatusIsTerminal(taskStatus) ? now : null, | ||
| 2774 | updatedAt: now, | ||
| 2775 | }; | ||
| 2776 | const subagent: ActiveAcpSubagent = existing ?? { | ||
| 2777 | task, | ||
| 2778 | childThreadId, | ||
| 2779 | childRootNodeId, | ||
| 2780 | turnItemId, | ||
| 2781 | turnItemOrdinal, | ||
| 2782 | providerTurnId: context.providerTurnId, | ||
| 2783 | parentProviderThreadId: context.input.providerThread.id, | ||
| 2784 | childSessionId: null, | ||
| 2785 | assistantText: "", | ||
| 2786 | assistantMessages: new Map(), | ||
| 2787 | childItemOrdinals: new Map(), | ||
| 2788 | nextChildOrdinal: 101, | ||
| 2789 | terminalStatusProjected: false, | ||
| 2790 | }; | ||
| 2791 | subagent.task = task; | ||
| 2792 | context.subagents.set(nativeTaskId, subagent); | ||
| 2793 | |||
| 2794 | if (existing === undefined) { | ||
| 2795 | yield* emitProviderEvent({ | ||
| 2796 | type: "app_thread.created", | ||
| 2797 | driver, | ||
| 2798 | appThread: makeSubagentChildThread({ | ||
| 2799 | parentThread: context.input.appThread, | ||
| 2800 | childThreadId, | ||
| 2801 | parentNodeId: nodeId, | ||
| 2802 | activeProviderThreadId: null, | ||
| 2803 | providerInstanceId: context.input.modelSelection.instanceId, | ||
| 2804 | modelSelection: { | ||
| 2805 | ...context.input.modelSelection, | ||
| 2806 | model: task.model ?? context.input.modelSelection.model, | ||
| 2807 | }, | ||
| 2808 | title: subagentThreadTitle({ | ||
| 2809 | parentTitle: context.input.appThread.title, | ||
| 2810 | title: update.title, | ||
| 2811 | prompt: update.prompt, | ||
| 2812 | ordinal: context.subagents.size, | ||
| 2813 | }), | ||
| 2814 | now, | ||
| 2815 | createdBy: "agent", | ||
| 2816 | creationSource: "provider", | ||
| 2817 | }), | ||
| 2818 | }); | ||
| 2819 | const promptNativeItemId = `${nativeTaskId}:prompt`; | ||
| 2820 | const promptArtifacts = makeSubagentConversationArtifacts({ | ||
| 2821 | senderThreadId: context.input.threadId, | ||
| 2822 | messageId: providerMessageId(promptNativeItemId), | ||
| 2823 | turnItemId: providerTurnItemId(promptNativeItemId), | ||
| 2824 | threadId: childThreadId, | ||
| 2825 | rootNodeId: childRootNodeId, | ||
| 2826 | providerThreadId: null, | ||
| 2827 | providerTurnId: null, | ||
| 2828 | nativeItemRef: { driver, nativeId: promptNativeItemId, strength: "weak" }, | ||
| 2829 | role: "user", | ||
| 2830 | text: update.prompt, | ||
| 2831 | ordinal: 100, | ||
| 2832 | now, | ||
| 2833 | }); | ||
| 2834 | yield* emitProviderEvent({ | ||
| 2835 | type: "message.updated", | ||
| 2836 | driver, | ||
| 2837 | message: promptArtifacts.message, | ||
| 2838 | }); | ||
| 2839 | yield* emitProviderEvent({ | ||
| 2840 | type: "turn_item.updated", | ||
| 2841 | driver, | ||
| 2842 | turnItem: promptArtifacts.turnItem, | ||
| 2843 | }); | ||
| 2844 | } | ||
| 2845 | |||
| 2846 | const childSessionId = update.childSessionId; | ||
| 2847 | if (childSessionId !== null && subagent.childSessionId === null) { | ||
| 2848 | subagent.childSessionId = childSessionId; | ||
| 2849 | context.subagentsBySessionId.set(childSessionId, subagent); | ||
| 2850 | const providerThread = makeProviderThread({ | ||
| 2851 | driver, | ||
| 2852 | providerInstanceId: context.input.modelSelection.instanceId, | ||
| 2853 | idAllocator, | ||
| 2854 | appThreadId: childThreadId, | ||
| 2855 | providerSessionId: input.providerSessionId, | ||
| 2856 | nativeThreadId: childSessionId, | ||
| 2857 | ...(itemIdentityVersion === undefined ? {} : { itemIdentityVersion }), | ||
| 2858 | forkedFrom: { | ||
| 2859 | providerThreadId: context.input.providerThread.id, | ||
| 2860 | providerTurnId: context.providerTurnId, | ||
| 2861 | }, | ||
| 2862 | now, | ||
| 2863 | }); | ||
| 2864 | subagent.task = { ...subagent.task, providerThreadId: providerThread.id }; | ||
| 2865 | yield* Ref.update(providerThreadByNativeSessionId, (current) => | ||
| 2866 | new Map(current).set(childSessionId, providerThread), | ||
| 2867 | ); | ||
| 2868 | yield* emitProviderEvent({ | ||
| 2869 | type: "provider_thread.updated", | ||
| 2870 | driver, | ||
| 2871 | providerThread: { ...providerThread, status: "idle" }, | ||
| 2872 | }); | ||
| 2873 | const buffered = context.pendingSubagentNotifications.get(childSessionId) ?? []; | ||
| 2874 | context.pendingSubagentNotifications.delete(childSessionId); | ||
| 2875 | yield* Effect.forEach( | ||
| 2876 | buffered, | ||
| 2877 | (notification) => projectSubagentNotification(subagent, notification), | ||
| 2878 | { concurrency: 1, discard: true }, | ||
| 2879 | ); | ||
| 2880 | } | ||
| 2881 | |||
| 2882 | if ( | ||
| 2883 | taskStatus !== "running" && | ||
| 2884 | subagent.assistantText.length === 0 && | ||
| 2885 | update.result !== null | ||
| 2886 | ) { | ||
| 2887 | yield* emitSubagentAssistant(subagent, update.result); | ||
| 2888 | } | ||
| 2889 | const result = update.result ?? subagent.task.result ?? (subagent.assistantText || null); | ||
| 2890 | subagent.task = { | ||
| 2891 | ...subagent.task, | ||
| 2892 | status: taskStatus, | ||
| 2893 | result, | ||
| 2894 | completedAt: acpSubagentStatusIsTerminal(taskStatus) ? now : null, | ||
| 2895 | updatedAt: now, | ||
| 2896 | }; | ||
| 2897 | const providerThreadId = subagent.task.providerThreadId; | ||
| 2898 | yield* emitProviderEvent({ | ||
| 2899 | type: "node.updated", | ||
| 2900 | driver, | ||
| 2901 | node: { | ||
| 2902 | id: nodeId, | ||
| 2903 | threadId: context.input.threadId, | ||
| 2904 | runId: subagent.task.runId, | ||
| 2905 | parentNodeId: subagent.task.parentNodeId, | ||
| 2906 | rootNodeId: subagent.task.parentNodeId, | ||
| 2907 | kind: "subagent", | ||
| 2908 | status: taskStatus, | ||
| 2909 | countsForRun: false, | ||
| 2910 | providerThreadId: context.input.providerThread.id, | ||
| 2911 | providerTurnId: subagent.providerTurnId, | ||
| 2912 | nativeItemRef, | ||
| 2913 | runtimeRequestId: null, | ||
| 2914 | checkpointScopeId: null, | ||
| 2915 | startedAt: subagent.task.startedAt, | ||
| 2916 | completedAt: subagent.task.completedAt, | ||
| 2917 | }, | ||
| 2918 | }); | ||
| 2919 | yield* emitProviderEvent({ | ||
| 2920 | type: "node.updated", | ||
| 2921 | driver, | ||
| 2922 | node: { | ||
| 2923 | id: childRootNodeId, | ||
| 2924 | threadId: childThreadId, | ||
| 2925 | runId: null, | ||
| 2926 | parentNodeId: null, | ||
| 2927 | rootNodeId: childRootNodeId, | ||
| 2928 | kind: "root_turn", | ||
| 2929 | status: taskStatus, | ||
| 2930 | countsForRun: false, | ||
| 2931 | providerThreadId, | ||
| 2932 | providerTurnId: null, | ||
| 2933 | nativeItemRef, | ||
| 2934 | runtimeRequestId: null, | ||
| 2935 | checkpointScopeId: null, | ||
| 2936 | startedAt: subagent.task.startedAt, | ||
| 2937 | completedAt: subagent.task.completedAt, | ||
| 2938 | }, | ||
| 2939 | }); | ||
| 2940 | yield* emitProviderEvent({ type: "subagent.updated", driver, subagent: subagent.task }); | ||
| 2941 | yield* emitProviderEvent({ | ||
| 2942 | type: "turn_item.updated", | ||
| 2943 | driver, | ||
| 2944 | turnItem: { | ||
| 2945 | id: turnItemId, | ||
| 2946 | threadId: context.input.threadId, | ||
| 2947 | runId: subagent.task.runId, | ||
| 2948 | nodeId, | ||
| 2949 | providerThreadId: context.input.providerThread.id, | ||
| 2950 | providerTurnId: subagent.providerTurnId, | ||
| 2951 | nativeItemRef, | ||
| 2952 | parentItemId: null, | ||
| 2953 | ordinal: turnItemOrdinal, | ||
| 2954 | status: taskStatus, | ||
| 2955 | title: subagent.task.title, | ||
| 2956 | startedAt: subagent.task.startedAt, | ||
| 2957 | completedAt: subagent.task.completedAt, | ||
| 2958 | updatedAt: now, | ||
| 2959 | type: "subagent", | ||
| 2960 | subagentId: subagent.task.id, | ||
| 2961 | origin: "provider_native", | ||
| 2962 | driver, | ||
| 2963 | providerInstanceId: context.input.modelSelection.instanceId, | ||
| 2964 | childThreadId, | ||
| 2965 | prompt: subagent.task.prompt, | ||
| 2966 | result, | ||
| 2967 | }, | ||
| 2968 | }); | ||
| 2969 | if (acpSubagentStatusIsTerminal(taskStatus)) { | ||
| 2970 | subagent.terminalStatusProjected = true; | ||
| 2971 | } | ||
| 2972 | }); | ||
| 2973 | |||
| 2974 | const toolOutputText = (toolCall: AcpToolCallState): string => { | ||
| 2975 | if (typeof toolCall.data.rawOutput === "string") { | ||
| 2976 | return toolCall.data.rawOutput; | ||
| 2977 | } | ||
| 2978 | if ( | ||
| 2979 | typeof (toolCall.data.rawOutput as { text?: unknown } | undefined)?.text === "string" | ||
| 2980 | ) { | ||
| 2981 | return String((toolCall.data.rawOutput as { text: string }).text); | ||
| 2982 | } | ||
| 2983 | return textFromUnknown(toolCall.data.rawOutput) ?? ""; | ||
| 2984 | }; | ||
| 2985 | |||
| 2986 | const setToolOutputText = (toolCall: AcpToolCallState, text: string): AcpToolCallState => ({ | ||
| 2987 | ...toolCall, | ||
| 2988 | data: { | ||
| 2989 | ...toolCall.data, | ||
| 2990 | rawOutput: { | ||
| 2991 | type: "Text", | ||
| 2992 | text, | ||
| 2993 | }, | ||
| 2994 | }, | ||
| 2995 | }); | ||
| 2996 | |||
| 2997 | const appendToolOutputText = ( | ||
| 2998 | toolCall: AcpToolCallState, | ||
| 2999 | appendOutput: string, | ||
| 3000 | ): AcpToolCallState => { | ||
| 3001 | if (appendOutput.length === 0) return toolCall; | ||
| 3002 | return setToolOutputText(toolCall, `${toolOutputText(toolCall)}${appendOutput}`); | ||
| 3003 | }; | ||
| 3004 | |||
| 3005 | const isMonitorEndNoticeText = (text: string): boolean => | ||
| 3006 | /Monitor\s+["']?[0-9a-f-]{8,}/i.test(text) && /ended/i.test(text); | ||
| 3007 | |||
| 3008 | const hasDeferredBackgroundWork = (context: ActiveAcpTurn): boolean => { | ||
| 3009 | if (context.awaitingBackgroundHydration.size > 0) return true; | ||
| 3010 | if (context.pendingInjectedReport.size > 0) return true; | ||
| 3011 | for (const [taskId, toolCallId] of context.toolCallIdsByBackgroundTaskId) { | ||
| 3012 | // Persistent monitors stream after root settle; do not pin finalize. | ||
| 3013 | if (context.persistentBackgroundTaskIds.has(taskId)) continue; | ||
| 3014 | const tool = context.tools.get(toolCallId); | ||
| 3015 | if (tool === undefined) continue; | ||
| 3016 | const status = toolStatus(tool.status); | ||
| 3017 | if (status === "pending" || status === "running") return true; | ||
| 3018 | } | ||
| 3019 | for (const subagent of context.subagents.values()) { | ||
| 3020 | if (acpSubagentStatusBlocksTurnSettlement(subagent.task.status)) { | ||
| 3021 | return true; | ||
| 3022 | } | ||
| 3023 | } | ||
| 3024 | return false; | ||
| 3025 | }; | ||
| 3026 | |||
| 3027 | // scheduleDeferredFinalize is declared above openSession body start and | ||
| 3028 | // assigned after finalizeTurn so forked finalize Effect typing stays clean. | ||
| 3029 | const rearmDeferredFinalize = (context: ActiveAcpTurn) => | ||
| 3030 | Effect.gen(function* () { | ||
| 3031 | if (!flavor.deferFinalizeForBackgroundWork) return; | ||
| 3032 | if (!context.promptSettled || context.finalized) return; | ||
| 3033 | if (hasDeferredBackgroundWork(context)) return; | ||
| 3034 | yield* scheduleDeferredFinalize(context); | ||
| 3035 | }); | ||
| 3036 | |||
| 3037 | // `let` breaks circular inference from monitor hydration re-entry. | ||
| 3038 | let emitTool: ( | ||
| 3039 | context: ActiveAcpTurn, | ||
| 3040 | incoming: AcpToolCallState, | ||
| 3041 | projectedStatus?: ProjectedToolStatus, | ||
| 3042 | ) => Effect.Effect<void> = () => Effect.void; | ||
| 3043 | |||
| 3044 | const markAwaitingBackgroundHydration = (context: ActiveAcpTurn, taskId: string) => | ||
| 3045 | Effect.gen(function* () { | ||
| 3046 | if (context.awaitingBackgroundHydration.has(taskId)) return; | ||
| 3047 | context.awaitingBackgroundHydration.add(taskId); | ||
| 3048 | // Safety: do not hold the root turn forever if the agent never hydrates. | ||
| 3049 | yield* Effect.gen(function* () { | ||
| 3050 | yield* Effect.sleep("60000 millis"); | ||
| 3051 | if (context.finalized || !context.awaitingBackgroundHydration.has(taskId)) return; | ||
| 3052 | context.awaitingBackgroundHydration.delete(taskId); | ||
| 3053 | // End notices keep the tool running until hydration; force-complete so | ||
| 3054 | // deferred finalize can proceed when get_command never arrives. | ||
| 3055 | const toolCallId = context.toolCallIdsByBackgroundTaskId.get(taskId); | ||
| 3056 | const tool = toolCallId !== undefined ? context.tools.get(toolCallId) : undefined; | ||
| 3057 | if (tool !== undefined) { | ||
| 3058 | const status = toolStatus(tool.status); | ||
| 3059 | if (status === "pending" || status === "running") { | ||
| 3060 | yield* emitTool(context, { ...tool, status: "completed" }); | ||
| 3061 | } | ||
| 3062 | } | ||
| 3063 | yield* rearmDeferredFinalize(context); | ||
| 3064 | }).pipe(Effect.forkIn(sessionScope), Effect.asVoid); | ||
| 3065 | }); | ||
| 3066 | |||
| 3067 | const markPendingInjectedReport = (context: ActiveAcpTurn, taskId: string) => | ||
| 3068 | Effect.gen(function* () { | ||
| 3069 | // Only the settled-and-held window has the race; mid-turn reports | ||
| 3070 | // are protected by the prompt RPC still being open. | ||
| 3071 | if (!context.promptSettled) return; | ||
| 3072 | if (context.pendingInjectedReport.has(taskId)) return; | ||
| 3073 | context.pendingInjectedReport.add(taskId); | ||
| 3074 | // Safety: the injected turn may end without a report chunk. | ||
| 3075 | yield* Effect.gen(function* () { | ||
| 3076 | yield* Effect.sleep("25000 millis"); | ||
| 3077 | if (context.finalized || !context.pendingInjectedReport.has(taskId)) return; | ||
| 3078 | context.pendingInjectedReport.delete(taskId); | ||
| 3079 | yield* rearmDeferredFinalize(context); | ||
| 3080 | }).pipe(Effect.forkIn(sessionScope), Effect.asVoid); | ||
| 3081 | }); | ||
| 3082 | |||
| 3083 | const clearMidTurnUnreportedTaskIds = (taskIds: ReadonlyArray<string>) => | ||
| 3084 | Ref.update(midTurnUnreportedCompletedTaskIds, (current) => { | ||
| 3085 | let next: Set<string> | null = null; | ||
| 3086 | for (const taskId of taskIds) { | ||
| 3087 | if (!current.has(taskId)) continue; | ||
| 3088 | if (next === null) next = new Set(current); | ||
| 3089 | next.delete(taskId); | ||
| 3090 | } | ||
| 3091 | return next ?? current; | ||
| 3092 | }); | ||
| 3093 | |||
| 3094 | emitTool = Effect.fnUntraced(function* ( | ||
| 3095 | context: ActiveAcpTurn, | ||
| 3096 | incoming: AcpToolCallState, | ||
| 3097 | projectedStatus?: ProjectedToolStatus, | ||
| 3098 | ) { | ||
| 3099 | // An identified message can stream concurrently with tool updates. | ||
| 3100 | // Keep its identity until the provider starts another message. | ||
| 3101 | if (context.assistant.current?.sourceMessageId == null) | ||
| 3102 | yield* closeTextStream(context, "assistant"); | ||
| 3103 | if (context.reasoning.current?.sourceMessageId == null) | ||
| 3104 | yield* closeTextStream(context, "reasoning"); | ||
| 3105 | yield* closeTextStream(context, "user"); | ||
| 3106 | const previous = context.tools.get(incoming.toolCallId); | ||
| 3107 | const merged = mergeToolCallState(previous, incoming); | ||
| 3108 | const toolCall = flavor.normalizeToolCall?.(merged) ?? merged; | ||
| 3109 | context.tools.set(toolCall.toolCallId, toolCall); | ||
| 3110 | const backgroundTaskId = flavor.extractBackgroundTaskId?.(toolCall); | ||
| 3111 | if (backgroundTaskId !== undefined) { | ||
| 3112 | context.toolCallIdsByBackgroundTaskId.set(backgroundTaskId, toolCall.toolCallId); | ||
| 3113 | if (flavor.isPersistentBackgroundTool?.(toolCall) === true) { | ||
| 3114 | context.persistentBackgroundTaskIds.add(backgroundTaskId); | ||
| 3115 | } | ||
| 3116 | const backgroundStatus = projectedStatus ?? toolStatus(toolCall.status); | ||
| 3117 | yield* setBackgroundTaskRunning( | ||
| 3118 | backgroundTaskId, | ||
| 3119 | backgroundStatus === "pending" || backgroundStatus === "running", | ||
| 3120 | ); | ||
| 3121 | // Background tool that reaches a terminal status while the root | ||
| 3122 | // prompt is still open (completed, failed, or interrupted) was | ||
| 3123 | // consumed in-turn. Mark handled so late CLI re-reports and residual | ||
| 3124 | // agent chatter do not open synthetic "Background task completed." | ||
| 3125 | // runs. get_command TaskOutput still marks handled below. | ||
| 3126 | // Only before promptSettled: after STARTED the deferred-finalize | ||
| 3127 | // hold can let a monitor finish while the turn is still active; | ||
| 3128 | // marking then would suppress the legitimate post-settle TaskOutput | ||
| 3129 | // continuation (live: grok-post-settle-continuation-poll). | ||
| 3130 | // Terminal statuses only after normalizeToolCall (start ACKs stay | ||
| 3131 | // inProgress/running). | ||
| 3132 | // | ||
| 3133 | // Tradeoff: a pre-settle false "completed" normalization (Bash- | ||
| 3134 | // shaped re-report with exit_code 0, or the hydration safety | ||
| 3135 | // force-complete firing pre-settle) would permanently mark the task | ||
| 3136 | // handled and suppress its injected report chatter. The deleted | ||
| 3137 | // handled-id removal on late monitor-event mutations used to rescue | ||
| 3138 | // that case; likelihood is low because mid-turn monitor ends | ||
| 3139 | // normally arrive as reminder mutations that force inProgress, and | ||
| 3140 | // re-reports are documented post-settle traffic. | ||
| 3141 | if ( | ||
| 3142 | !context.promptSettled && | ||
| 3143 | backgroundStatus !== "pending" && | ||
| 3144 | backgroundStatus !== "running" | ||
| 3145 | ) { | ||
| 3146 | yield* Ref.update(handledBackgroundTaskIdsInActiveTurn, (current) => | ||
| 3147 | new Set(current).add(backgroundTaskId), | ||
| 3148 | ); | ||
| 3149 | yield* Ref.update(midTurnUnreportedCompletedTaskIds, (current) => { | ||
| 3150 | if (!current.has(backgroundTaskId)) return current; | ||
| 3151 | const next = new Set(current); | ||
| 3152 | next.delete(backgroundTaskId); | ||
| 3153 | return next; | ||
| 3154 | }); | ||
| 3155 | } | ||
| 3156 | } | ||
| 3157 | |||
| 3158 | // get_command TaskOutput for registered monitor(s): hydrate those tools. | ||
| 3159 | const backgroundCompletions = | ||
| 3160 | projectedStatus === undefined | ||
| 3161 | ? (flavor.extractBackgroundTaskCompletion?.(toolCall) ?? []) | ||
| 3162 | : []; | ||
| 3163 | let hydratedRegisteredMonitor = false; | ||
| 3164 | for (const backgroundCompletion of backgroundCompletions) { | ||
| 3165 | // Genuine end signal when terminal: tombstone so straggler | ||
| 3166 | // monitor-event chatter cannot resurrect the running set after the | ||
| 3167 | // task truly ended. A still-running poll keeps the id running. | ||
| 3168 | yield* applyBackgroundTaskMutationRunning(backgroundCompletion); | ||
| 3169 | if (backgroundCompletion.status !== "running") { | ||
| 3170 | yield* Ref.update(handledBackgroundTaskIdsInActiveTurn, (current) => | ||
| 3171 | new Set(current).add(backgroundCompletion.taskId), | ||
| 3172 | ); | ||
| 3173 | yield* Ref.update(midTurnUnreportedCompletedTaskIds, (current) => { | ||
| 3174 | if (!current.has(backgroundCompletion.taskId)) return current; | ||
| 3175 | const next = new Set(current); | ||
| 3176 | next.delete(backgroundCompletion.taskId); | ||
| 3177 | return next; | ||
| 3178 | }); | ||
| 3179 | } | ||
| 3180 | // A still-running fetch must keep the hydration hold (and its | ||
| 3181 | // safety timer) alive until output actually lands. | ||
| 3182 | if (backgroundCompletion.status !== "running") { | ||
| 3183 | context.awaitingBackgroundHydration.delete(backgroundCompletion.taskId); | ||
| 3184 | } | ||
| 3185 | const targetToolCallId = context.toolCallIdsByBackgroundTaskId.get( | ||
| 3186 | backgroundCompletion.taskId, | ||
| 3187 | ); | ||
| 3188 | // Known background-task id (monitor registration) — never open a | ||
| 3189 | // phantom subagent for the same get_output poll. | ||
| 3190 | if (targetToolCallId !== undefined) { | ||
| 3191 | hydratedRegisteredMonitor = true; | ||
| 3192 | } | ||
| 3193 | const target = | ||
| 3194 | targetToolCallId !== undefined ? context.tools.get(targetToolCallId) : undefined; | ||
| 3195 | if (target !== undefined && target.toolCallId !== toolCall.toolCallId) { | ||
| 3196 | const nextStatus = | ||
| 3197 | backgroundCompletion.status === "running" | ||
| 3198 | ? ("inProgress" as const) | ||
| 3199 | : backgroundCompletion.status === "failed" | ||
| 3200 | ? ("failed" as const) | ||
| 3201 | : ("completed" as const); | ||
| 3202 | const hydrated = | ||
| 3203 | backgroundCompletion.appendOutput.length > 0 | ||
| 3204 | ? // TaskOutput is the real stdout; replace end-notice boilerplate | ||
| 3205 | // so the timeline shows the listing, not only "Monitor ended…". | ||
| 3206 | isMonitorEndNoticeText(toolOutputText(target)) || | ||
| 3207 | toolOutputText(target).trim().length === 0 | ||
| 3208 | ? setToolOutputText( | ||
| 3209 | { ...target, status: nextStatus }, | ||
| 3210 | backgroundCompletion.appendOutput, | ||
| 3211 | ) | ||
| 3212 | : appendToolOutputText( | ||
| 3213 | { ...target, status: nextStatus }, | ||
| 3214 | backgroundCompletion.appendOutput, | ||
| 3215 | ) | ||
| 3216 | : { ...target, status: nextStatus }; | ||
| 3217 | yield* emitTool(context, hydrated); | ||
| 3218 | } | ||
| 3219 | } | ||
| 3220 | |||
| 3221 | // Monitor TaskOutput shares the get_command tool shape with subagent | ||
| 3222 | // hydration; do not spawn a phantom subagent for a registered monitor. | ||
| 3223 | const subagentUpdate = hydratedRegisteredMonitor | ||
| 3224 | ? undefined | ||
| 3225 | : flavor.extractSubagentUpdate?.(toolCall); | ||
| 3226 | if (subagentUpdate !== undefined) { | ||
| 3227 | yield* emitSubagent(context, subagentUpdate); | ||
| 3228 | if (subagentUpdate.suppressNormalTool !== false) { | ||
| 3229 | yield* rearmDeferredFinalize(context); | ||
| 3230 | return; | ||
| 3231 | } | ||
| 3232 | } | ||
| 3233 | if ( | ||
| 3234 | projectedStatus === undefined && | ||
| 3235 | !shouldPersistToolUpdate( | ||
| 3236 | context, | ||
| 3237 | toolCall.toolCallId, | ||
| 3238 | previous, | ||
| 3239 | toolCall, | ||
| 3240 | merged.status, | ||
| 3241 | ) | ||
| 3242 | ) { | ||
| 3243 | yield* rearmDeferredFinalize(context); | ||
| 3244 | return; | ||
| 3245 | } | ||
| 3246 | const status = projectedStatus ?? toolStatus(toolCall.status); | ||
| 3247 | const now = yield* DateTime.now; | ||
| 3248 | const nativeItemId = `${context.nativeThreadId}:tool:${toolCall.toolCallId}`; | ||
| 3249 | const ordinal = yield* resolveItemOrdinal(context, nativeItemId); | ||
| 3250 | const nodeId = providerNodeId(nativeItemId); | ||
| 3251 | const turnItemId = providerTurnItemId(nativeItemId); | ||
| 3252 | const nativeItemRef = { | ||
| 3253 | driver, | ||
| 3254 | nativeId: toolCall.toolCallId, | ||
| 3255 | strength: "strong" as const, | ||
| 3256 | }; | ||
| 3257 | const startedAt = context.toolStartedAt.get(toolCall.toolCallId) ?? now; | ||
| 3258 | context.toolStartedAt.set(toolCall.toolCallId, startedAt); | ||
| 3259 | const completedAt = completedAtForStatus(status, now); | ||
| 3260 | const title = toolCall.title ?? null; | ||
| 3261 | yield* emitProviderEvent({ | ||
| 3262 | type: "node.updated", | ||
| 3263 | driver, | ||
| 3264 | node: { | ||
| 3265 | id: nodeId, | ||
| 3266 | threadId: context.input.threadId, | ||
| 3267 | runId: context.input.runId, | ||
| 3268 | parentNodeId: context.input.rootNodeId, | ||
| 3269 | rootNodeId: context.input.rootNodeId, | ||
| 3270 | kind: "tool_call", | ||
| 3271 | status: nodeStatus(status), | ||
| 3272 | countsForRun: true, | ||
| 3273 | providerThreadId: context.input.providerThread.id, | ||
| 3274 | providerTurnId: context.providerTurnId, | ||
| 3275 | nativeItemRef, | ||
| 3276 | runtimeRequestId: null, | ||
| 3277 | checkpointScopeId: null, | ||
| 3278 | startedAt, | ||
| 3279 | completedAt, | ||
| 3280 | }, | ||
| 3281 | }); | ||
| 3282 | |||
| 3283 | const base = { | ||
| 3284 | id: turnItemId, | ||
| 3285 | threadId: context.input.threadId, | ||
| 3286 | runId: context.input.runId, | ||
| 3287 | nodeId, | ||
| 3288 | providerThreadId: context.input.providerThread.id, | ||
| 3289 | providerTurnId: context.providerTurnId, | ||
| 3290 | nativeItemRef, | ||
| 3291 | parentItemId: null, | ||
| 3292 | ordinal, | ||
| 3293 | status, | ||
| 3294 | title, | ||
| 3295 | startedAt, | ||
| 3296 | completedAt, | ||
| 3297 | updatedAt: now, | ||
| 3298 | } as const; | ||
| 3299 | const rawInput = toolCall.data.rawInput; | ||
| 3300 | const rawOutput = toolCall.data.rawOutput ?? toolCall.data.content; | ||
| 3301 | const changes = structuredFileChanges(toolCall); | ||
| 3302 | const path = changes[0]?.path ?? pathFromToolCall(toolCall); | ||
| 3303 | const diffText = | ||
| 3304 | acpToolCallDiffPatch(toolCall.data.content) ?? textFromUnknown(rawOutput); | ||
| 3305 | const rawInputRecord = unknownRecord(rawInput); | ||
| 3306 | const inputVariant = | ||
| 3307 | typeof rawInputRecord?.variant === "string" | ||
| 3308 | ? rawInputRecord.variant.trim().toLowerCase() | ||
| 3309 | : ""; | ||
| 3310 | const rawOutputRecord = unknownRecord(rawOutput); | ||
| 3311 | const outputCommand = | ||
| 3312 | typeof rawOutputRecord?.command === "string" && | ||
| 3313 | rawOutputRecord.command.trim().length > 0 | ||
| 3314 | ? rawOutputRecord.command.trim() | ||
| 3315 | : undefined; | ||
| 3316 | const monitorCommand = | ||
| 3317 | (typeof rawInputRecord?.command === "string" && rawInputRecord.command.trim().length > 0 | ||
| 3318 | ? rawInputRecord.command.trim() | ||
| 3319 | : undefined) ?? outputCommand; | ||
| 3320 | const outputIsBashResult = | ||
| 3321 | typeof rawOutputRecord?.type === "string" && | ||
| 3322 | rawOutputRecord.type.trim().toLowerCase() === "bash" && | ||
| 3323 | commandExitCode(rawOutput) !== undefined; | ||
| 3324 | // Grok Monitor tools arrive as generic kind + variant; project like shell | ||
| 3325 | // so stdout is plain text in the timeline (not JSON {type:Text,text:...}). | ||
| 3326 | // Post-settle wake re-reports of a finished monitor carry no rawInput at | ||
| 3327 | // all, only a structured Bash result; project those as commands too. | ||
| 3328 | const projectAsCommandExecution = inputVariant === "monitor" || outputIsBashResult; | ||
| 3329 | // ACP has no typed MCP item, so recover MCP identity from the | ||
| 3330 | // agent-specific shape and project the same branded dynamic_tool | ||
| 3331 | // item native providers produce (e.g. the T3 orchestration tools). | ||
| 3332 | const mcpIdentity = extractMcpToolCallIdentity(toolCall, { | ||
| 3333 | embeddedTerminalCommands: embeddedTerminalCommands( | ||
| 3334 | context.nativeThreadId, | ||
| 3335 | toolCall.toolCallId, | ||
| 3336 | ), | ||
| 3337 | }); | ||
| 3338 | let turnItem: OrchestrationV2TurnItem; | ||
| 3339 | if (toolCall.toolCallId.startsWith("acp-compaction:")) { | ||
| 3340 | const summary = textFromUnknown(rawOutput); | ||
| 3341 | turnItem = { | ||
| 3342 | ...base, | ||
| 3343 | type: "compaction", | ||
| 3344 | driver, | ||
| 3345 | ...(summary === undefined ? {} : { summary }), | ||
| 3346 | ...(context.contextUsage?.usedTokens === undefined | ||
| 3347 | ? {} | ||
| 3348 | : { beforeTokenCount: context.contextUsage.usedTokens }), | ||
| 3349 | }; | ||
| 3350 | if (status === "completed") { | ||
| 3351 | context.contextUsage = null; | ||
| 3352 | yield* Ref.update(contextUsageBySessionId, (current) => { | ||
| 3353 | const updated = new Map(current); | ||
| 3354 | updated.delete(context.nativeThreadId); | ||
| 3355 | return updated; | ||
| 3356 | }); | ||
| 3357 | } | ||
| 3358 | } else if (mcpIdentity !== undefined) { | ||
| 3359 | turnItem = { | ||
| 3360 | ...base, | ||
| 3361 | title: null, | ||
| 3362 | ...mcpToolPresentation({ | ||
| 3363 | serverName: mcpIdentity.server, | ||
| 3364 | toolName: mcpIdentity.tool, | ||
| 3365 | source: unknownRecord( | ||
| 3366 | (unknownRecord(rawOutputRecord?.result) ?? rawOutputRecord)?._meta, | ||
| 3367 | )?.source, | ||
| 3368 | }), | ||
| 3369 | type: "dynamic_tool", | ||
| 3370 | toolName: `${mcpIdentity.server}.${mcpIdentity.tool}`, | ||
| 3371 | input: | ||
| 3372 | mcpIdentity.input ?? | ||
| 3373 | unknownRecord(rawInputRecord?.arguments) ?? | ||
| 3374 | rawInputRecord ?? | ||
| 3375 | {}, | ||
| 3376 | ...(rawOutput === undefined ? {} : { output: acpMcpToolCallOutput(rawOutput) }), | ||
| 3377 | }; | ||
| 3378 | yield* emitProviderEvent({ type: "turn_item.updated", driver, turnItem }); | ||
| 3379 | yield* rearmDeferredFinalize(context); | ||
| 3380 | return; | ||
| 3381 | } else if (changes.length > 0) { | ||
| 3382 | turnItem = { | ||
| 3383 | ...base, | ||
| 3384 | type: "file_change", | ||
| 3385 | fileName: changes[0]!.path, | ||
| 3386 | changes, | ||
| 3387 | ...(diffText === undefined ? {} : { diffStr: diffText }), | ||
| 3388 | }; | ||
| 3389 | } else { | ||
| 3390 | switch (toolCall.kind) { | ||
| 3391 | case "read": | ||
| 3392 | turnItem = { | ||
| 3393 | ...base, | ||
| 3394 | title: path ? formatReadToolLabel(path) : (title ?? "Read file"), | ||
| 3395 | type: "dynamic_tool", | ||
| 3396 | toolName: "Read", | ||
| 3397 | input: | ||
| 3398 | path === undefined || | ||
| 3399 | ["path", "filePath", "file_path"].some((key) => rawInputRecord?.[key] === path) | ||
| 3400 | ? (rawInputRecord ?? {}) | ||
| 3401 | : { ...rawInputRecord, path }, | ||
| 3402 | ...(rawOutput === undefined ? {} : { output: rawOutput }), | ||
| 3403 | }; | ||
| 3404 | break; | ||
| 3405 | case "search": { | ||
| 3406 | const backendSearch = acpBackendWebSearch(rawInputRecord, rawOutputRecord); | ||
| 3407 | if (backendSearch !== undefined) { | ||
| 3408 | // Grok titles these "X search:" / "Web search:" awaiting the query. | ||
| 3409 | const label = nonEmptyText(toolCall.data.title, title ?? "Web search").replace( | ||
| 3410 | /:\s*$/u, | ||
| 3411 | "", | ||
| 3412 | ); | ||
| 3413 | turnItem = { | ||
| 3414 | ...base, | ||
| 3415 | title: | ||
| 3416 | backendSearch.query === undefined | ||
| 3417 | ? label | ||
| 3418 | : `${label}: ${backendSearch.query}`, | ||
| 3419 | type: "web_search", | ||
| 3420 | ...(backendSearch.query === undefined | ||
| 3421 | ? {} | ||
| 3422 | : { patterns: [backendSearch.query] }), | ||
| 3423 | ...(backendSearch.results.length === 0 | ||
| 3424 | ? {} | ||
| 3425 | : { results: backendSearch.results }), | ||
| 3426 | }; | ||
| 3427 | break; | ||
| 3428 | } | ||
| 3429 | turnItem = { | ||
| 3430 | ...base, | ||
| 3431 | title: | ||
| 3432 | formatSearchToolLabel({ | ||
| 3433 | rawInput: rawInputRecord, | ||
| 3434 | input: rawInputRecord, | ||
| 3435 | ...(path === undefined ? {} : { pattern: path }), | ||
| 3436 | }) ?? title, | ||
| 3437 | type: "file_search", | ||
| 3438 | ...(path === undefined ? {} : { pattern: path }), | ||
| 3439 | ...(path === undefined | ||
| 3440 | ? {} | ||
| 3441 | : { | ||
| 3442 | results: [ | ||
| 3443 | { | ||
| 3444 | fileName: path, | ||
| 3445 | ...(textFromUnknown(rawOutput) === undefined | ||
| 3446 | ? {} | ||
| 3447 | : { preview: textFromUnknown(rawOutput) }), | ||
| 3448 | }, | ||
| 3449 | ], | ||
| 3450 | }), | ||
| 3451 | }; | ||
| 3452 | break; | ||
| 3453 | } | ||
| 3454 | case "execute": { | ||
| 3455 | const exitCode = acpProjectedCommandExitCode(status, rawOutput); | ||
| 3456 | turnItem = { | ||
| 3457 | ...base, | ||
| 3458 | type: "command_execution", | ||
| 3459 | input: toolCall.command ?? monitorCommand ?? toolCall.title ?? "Command", | ||
| 3460 | ...(textFromUnknown(rawOutput) === undefined | ||
| 3461 | ? {} | ||
| 3462 | : { output: textFromUnknown(rawOutput) }), | ||
| 3463 | ...(exitCode === undefined ? {} : { exitCode }), | ||
| 3464 | }; | ||
| 3465 | break; | ||
| 3466 | } | ||
| 3467 | case "edit": | ||
| 3468 | case "delete": | ||
| 3469 | case "move": | ||
| 3470 | turnItem = { | ||
| 3471 | ...base, | ||
| 3472 | type: "file_change", | ||
| 3473 | fileName: path ?? toolCall.title ?? "File change", | ||
| 3474 | ...(diffText === undefined ? {} : { diffStr: diffText }), | ||
| 3475 | }; | ||
| 3476 | break; | ||
| 3477 | case "fetch": { | ||
| 3478 | // Grok nests the page under rawOutput.Content, which textFromUnknown | ||
| 3479 | // cannot read; the (bounded) content blocks carry the same text. | ||
| 3480 | const snippet = | ||
| 3481 | textFromUnknown(toolCall.data.content) ?? textFromUnknown(rawOutput); | ||
| 3482 | turnItem = { | ||
| 3483 | ...base, | ||
| 3484 | type: "web_search", | ||
| 3485 | ...(path === undefined ? {} : { patterns: [path] }), | ||
| 3486 | ...(path === undefined | ||
| 3487 | ? {} | ||
| 3488 | : { | ||
| 3489 | results: [ | ||
| 3490 | { | ||
| 3491 | url: path, | ||
| 3492 | ...(snippet === undefined ? {} : { snippet }), | ||
| 3493 | }, | ||
| 3494 | ], | ||
| 3495 | }), | ||
| 3496 | }; | ||
| 3497 | break; | ||
| 3498 | } | ||
| 3499 | default: | ||
| 3500 | if (projectAsCommandExecution) { | ||
| 3501 | const exitCode = acpProjectedCommandExitCode(status, rawOutput); | ||
| 3502 | turnItem = { | ||
| 3503 | ...base, | ||
| 3504 | type: "command_execution", | ||
| 3505 | input: | ||
| 3506 | toolCall.command ?? | ||
| 3507 | monitorCommand ?? | ||
| 3508 | toolCall.title ?? | ||
| 3509 | (inputVariant === "monitor" ? "Monitor" : "Command"), | ||
| 3510 | ...(textFromUnknown(rawOutput) === undefined | ||
| 3511 | ? {} | ||
| 3512 | : { output: textFromUnknown(rawOutput) }), | ||
| 3513 | ...(exitCode === undefined ? {} : { exitCode }), | ||
| 3514 | }; | ||
| 3515 | } else { | ||
| 3516 | turnItem = { | ||
| 3517 | ...base, | ||
| 3518 | type: "dynamic_tool", | ||
| 3519 | toolName: toolCall.title ?? toolCall.kind ?? null, | ||
| 3520 | input: rawInput ?? {}, | ||
| 3521 | ...(rawOutput === undefined ? {} : { output: rawOutput }), | ||
| 3522 | }; | ||
| 3523 | } | ||
| 3524 | } | ||
| 3525 | } | ||
| 3526 | yield* emitProviderEvent({ type: "turn_item.updated", driver, turnItem }); | ||
| 3527 | yield* rearmDeferredFinalize(context); | ||
| 3528 | }); | ||
| 3529 | |||
| 3530 | const emitPlan = Effect.fnUntraced(function* ( | ||
| 3531 | context: ActiveAcpTurn, | ||
| 3532 | update: AcpPlanUpdate, | ||
| 3533 | ) { | ||
| 3534 | yield* closeTextStreams(context); | ||
| 3535 | const existing = context.plans.get(update.nativePlanId); | ||
| 3536 | if (update.kind === "removed" && existing === undefined) return; | ||
| 3537 | const nativeItemId = `${context.nativeTurnId}:plan:${encodeURIComponent(update.nativePlanId)}`; | ||
| 3538 | const ordinal = yield* resolveItemOrdinal(context, nativeItemId); | ||
| 3539 | const now = yield* DateTime.now; | ||
| 3540 | const nodeId = providerNodeId(nativeItemId); | ||
| 3541 | const turnItemId = providerTurnItemId(nativeItemId); | ||
| 3542 | const planState = existing ?? { | ||
| 3543 | id: yield* idAllocator.allocate.plan({ | ||
| 3544 | threadId: context.input.threadId, | ||
| 3545 | runId: context.input.runId, | ||
| 3546 | driver, | ||
| 3547 | }), | ||
| 3548 | startedAt: now, | ||
| 3549 | latest: null, | ||
| 3550 | }; | ||
| 3551 | const planId = planState.id; | ||
| 3552 | const nativeItemRef = { driver, nativeId: nativeItemId, strength: "weak" as const }; | ||
| 3553 | const base = { | ||
| 3554 | id: planId, | ||
| 3555 | threadId: context.input.threadId, | ||
| 3556 | runId: context.input.runId, | ||
| 3557 | nodeId, | ||
| 3558 | } as const; | ||
| 3559 | let plan: OrchestrationV2PlanArtifact; | ||
| 3560 | if (update.kind === "removed") { | ||
| 3561 | if (planState.latest === null) return; | ||
| 3562 | plan = { ...planState.latest, status: "superseded" }; | ||
| 3563 | } else if (update.kind === "items") { | ||
| 3564 | const steps: ReadonlyArray<OrchestrationV2PlanStep> = update.plan.map( | ||
| 3565 | (step, index) => ({ | ||
| 3566 | id: `acp-step-${index + 1}`, | ||
| 3567 | text: nonEmptyText(step.step, `Step ${index + 1}`), | ||
| 3568 | status: | ||
| 3569 | step.status === "inProgress" | ||
| 3570 | ? "running" | ||
| 3571 | : step.status === "completed" | ||
| 3572 | ? "completed" | ||
| 3573 | : "pending", | ||
| 3574 | }), | ||
| 3575 | ); | ||
| 3576 | const completed = | ||
| 3577 | steps.length > 0 && steps.every((step) => step.status === "completed"); | ||
| 3578 | plan = { | ||
| 3579 | ...base, | ||
| 3580 | status: completed ? "completed" : "active", | ||
| 3581 | kind: "todo_list", | ||
| 3582 | steps, | ||
| 3583 | ...(update.explanation == null ? {} : { explanation: update.explanation }), | ||
| 3584 | }; | ||
| 3585 | } else { | ||
| 3586 | const markdown = | ||
| 3587 | update.kind === "markdown" | ||
| 3588 | ? update.markdown | ||
| 3589 | : update.kind === "file" | ||
| 3590 | ? `Plan file: ${update.uri}` | ||
| 3591 | : `[Unsupported ACP plan content: ${update.contentType}]`; | ||
| 3592 | plan = { | ||
| 3593 | ...base, | ||
| 3594 | status: "active", | ||
| 3595 | kind: "proposed_plan", | ||
| 3596 | markdown, | ||
| 3597 | }; | ||
| 3598 | } | ||
| 3599 | planState.latest = plan; | ||
| 3600 | context.plans.set(update.nativePlanId, planState); | ||
| 3601 | const completed = plan.status === "completed" || plan.status === "superseded"; | ||
| 3602 | const nodeKind = plan.kind === "todo_list" ? "todo_list" : "plan"; | ||
| 3603 | yield* emitProviderEvent({ | ||
| 3604 | type: "node.updated", | ||
| 3605 | driver, | ||
| 3606 | node: { | ||
| 3607 | id: nodeId, | ||
| 3608 | threadId: context.input.threadId, | ||
| 3609 | runId: context.input.runId, | ||
| 3610 | parentNodeId: context.input.rootNodeId, | ||
| 3611 | rootNodeId: context.input.rootNodeId, | ||
| 3612 | kind: nodeKind, | ||
| 3613 | status: completed ? "completed" : "running", | ||
| 3614 | countsForRun: false, | ||
| 3615 | providerThreadId: context.input.providerThread.id, | ||
| 3616 | providerTurnId: context.providerTurnId, | ||
| 3617 | nativeItemRef, | ||
| 3618 | runtimeRequestId: null, | ||
| 3619 | checkpointScopeId: null, | ||
| 3620 | startedAt: planState.startedAt, | ||
| 3621 | completedAt: completed ? now : null, | ||
| 3622 | }, | ||
| 3623 | }); | ||
| 3624 | yield* emitProviderEvent({ type: "plan.updated", driver, plan }); | ||
| 3625 | yield* emitProviderEvent({ | ||
| 3626 | type: "turn_item.updated", | ||
| 3627 | driver, | ||
| 3628 | turnItem: { | ||
| 3629 | id: turnItemId, | ||
| 3630 | threadId: context.input.threadId, | ||
| 3631 | runId: context.input.runId, | ||
| 3632 | nodeId, | ||
| 3633 | providerThreadId: context.input.providerThread.id, | ||
| 3634 | providerTurnId: context.providerTurnId, | ||
| 3635 | nativeItemRef, | ||
| 3636 | parentItemId: null, | ||
| 3637 | ordinal, | ||
| 3638 | status: completed ? "completed" : "running", | ||
| 3639 | title: null, | ||
| 3640 | startedAt: planState.startedAt, | ||
| 3641 | completedAt: completed ? now : null, | ||
| 3642 | updatedAt: now, | ||
| 3643 | ...(plan.kind === "todo_list" | ||
| 3644 | ? { | ||
| 3645 | type: "todo_list" as const, | ||
| 3646 | planId, | ||
| 3647 | steps: plan.steps, | ||
| 3648 | ...(plan.explanation === undefined ? {} : { explanation: plan.explanation }), | ||
| 3649 | } | ||
| 3650 | : { | ||
| 3651 | type: "proposed_plan" as const, | ||
| 3652 | planId, | ||
| 3653 | markdown: plan.markdown, | ||
| 3654 | streaming: !completed, | ||
| 3655 | }), | ||
| 3656 | }, | ||
| 3657 | }); | ||
| 3658 | }); | ||
| 3659 | |||
| 3660 | const appendLoadedHistory = ( | ||
| 3661 | notification: EffectAcpSchema.SessionNotification, | ||
| 3662 | role: "user" | "assistant" | "thought", | ||
| 3663 | text: string, | ||
| 3664 | replace = false, | ||
| 3665 | ) => | ||
| 3666 | Effect.gen(function* () { | ||
| 3667 | if (text.length === 0 && !replace) return; | ||
| 3668 | const now = yield* DateTime.now; | ||
| 3669 | yield* Ref.update(snapshot, (current) => { | ||
| 3670 | const update = notification.update; | ||
| 3671 | const sourceMessageId = | ||
| 3672 | (update.sessionUpdate === "user_message_chunk" || | ||
| 3673 | update.sessionUpdate === "agent_message_chunk" || | ||
| 3674 | update.sessionUpdate === "agent_thought_chunk" || | ||
| 3675 | update.sessionUpdate === "user_message" || | ||
| 3676 | update.sessionUpdate === "agent_message" || | ||
| 3677 | update.sessionUpdate === "agent_thought") && | ||
| 3678 | update.messageId | ||
| 3679 | ? update.messageId | ||
| 3680 | : null; | ||
| 3681 | const startsNew = | ||
| 3682 | current.loadingRole !== role || | ||
| 3683 | (sourceMessageId !== null && current.loadingMessageId !== sourceMessageId); | ||
| 3684 | const loadingIndex = startsNew ? current.loadingIndex + 1 : current.loadingIndex; | ||
| 3685 | const nativeItemId = | ||
| 3686 | sourceMessageId === null | ||
| 3687 | ? `${notification.sessionId}:history:${role}:${loadingIndex}` | ||
| 3688 | : `${notification.sessionId}:history:${role}:message:${sourceMessageId}`; | ||
| 3689 | const messageId = providerMessageId(nativeItemId); | ||
| 3690 | const key = String(messageId); | ||
| 3691 | const previous = current.messages.get(key); | ||
| 3692 | const messages = new Map(current.messages); | ||
| 3693 | messages.set(key, { | ||
| 3694 | createdBy: previous?.createdBy ?? (role === "user" ? "user" : "agent"), | ||
| 3695 | creationSource: previous?.creationSource ?? "provider", | ||
| 3696 | id: messageId, | ||
| 3697 | threadId: input.threadId, | ||
| 3698 | runId: null, | ||
| 3699 | nodeId: null, | ||
| 3700 | role: role === "user" ? "user" : "assistant", | ||
| 3701 | text: replace ? text : `${previous?.text ?? ""}${text}`, | ||
| 3702 | attachments: [], | ||
| 3703 | streaming: false, | ||
| 3704 | createdAt: previous?.createdAt ?? now, | ||
| 3705 | updatedAt: now, | ||
| 3706 | }); | ||
| 3707 | return { | ||
| 3708 | order: current.order.includes(key) ? current.order : [...current.order, key], | ||
| 3709 | messages, | ||
| 3710 | loadingRole: role, | ||
| 3711 | loadingMessageId: sourceMessageId ?? (startsNew ? null : current.loadingMessageId), | ||
| 3712 | loadingIndex, | ||
| 3713 | }; | ||
| 3714 | }); | ||
| 3715 | }); | ||
| 3716 | |||
| 3717 | const offerContinuationRun = Effect.fnUntraced(function* (_sessionId: string) { | ||
| 3718 | if (continuationRequests === undefined) { | ||
| 3719 | return false; | ||
| 3720 | } | ||
| 3721 | const pending = yield* continuationPermit.withPermit( | ||
| 3722 | Effect.gen(function* () { | ||
| 3723 | if (yield* Ref.get(continuationClosed)) return Option.none(); | ||
| 3724 | if (yield* Ref.get(stoppedRunQuarantine)) return Option.none(); | ||
| 3725 | if (yield* Ref.get(continuationRequested)) return Option.none(); | ||
| 3726 | const route = yield* Ref.get(lastTurnRoute); | ||
| 3727 | if (route === null) return Option.none(); | ||
| 3728 | yield* Ref.set(continuationRequested, true); | ||
| 3729 | const generation = yield* Ref.updateAndGet( | ||
| 3730 | continuationGeneration, | ||
| 3731 | (value) => value + 1, | ||
| 3732 | ); | ||
| 3733 | const reports = yield* Ref.modify(wakeReports, ({ reports }) => [ | ||
| 3734 | reports, | ||
| 3735 | { reports, offered: new Set(reports.keys()) }, | ||
| 3736 | ]); | ||
| 3737 | return Option.some({ route, generation, reports }); | ||
| 3738 | }), | ||
| 3739 | ); | ||
| 3740 | if (Option.isNone(pending)) return false; | ||
| 3741 | const { route, generation, reports } = pending.value; | ||
| 3742 | const notification = backgroundWorkNotification([...reports.values()]); | ||
| 3743 | yield* Effect.logInfo("orchestration-v2.acp-wake-turn-detected", { | ||
| 3744 | driver, | ||
| 3745 | providerSessionId: input.providerSessionId, | ||
| 3746 | threadId: route.threadId, | ||
| 3747 | providerThreadId: route.providerThreadId, | ||
| 3748 | }); | ||
| 3749 | yield* continuationRequests.offer({ | ||
| 3750 | threadId: route.threadId, | ||
| 3751 | providerThreadId: route.providerThreadId, | ||
| 3752 | driver, | ||
| 3753 | detail: null, | ||
| 3754 | ...(notification === null ? {} : { notification }), | ||
| 3755 | clearIfCurrent: () => | ||
| 3756 | continuationPermit.withPermit( | ||
| 3757 | Effect.gen(function* () { | ||
| 3758 | if ((yield* Ref.get(continuationGeneration)) === generation) { | ||
| 3759 | yield* Ref.set(continuationRequested, false); | ||
| 3760 | } | ||
| 3761 | }), | ||
| 3762 | ), | ||
| 3763 | dispatchIfCurrent: (effect) => | ||
| 3764 | continuationPermit.withPermit( | ||
| 3765 | Effect.gen(function* () { | ||
| 3766 | const clearIfOwner = Effect.gen(function* () { | ||
| 3767 | if ((yield* Ref.get(continuationGeneration)) === generation) { | ||
| 3768 | yield* Ref.set(continuationRequested, false); | ||
| 3769 | } | ||
| 3770 | }); | ||
| 3771 | if (yield* Ref.get(stoppedRunQuarantine)) { | ||
| 3772 | yield* clearIfOwner; | ||
| 3773 | return Option.none(); | ||
| 3774 | } | ||
| 3775 | if ((yield* Ref.get(continuationGeneration)) !== generation) { | ||
| 3776 | // Superseded by a newer offer; do not clear its flag. | ||
| 3777 | return Option.none(); | ||
| 3778 | } | ||
| 3779 | if (!(yield* Ref.get(continuationRequested))) return Option.none(); | ||
| 3780 | // A successful durable dispatch owns the sticky flag until its | ||
| 3781 | // continuation turn starts. Clearing here opens a dispatch-to- | ||
| 3782 | // start race where every late ACP frame can enqueue another | ||
| 3783 | // synthetic continuation. Failures clear immediately because | ||
| 3784 | // no turn will arrive to do so. | ||
| 3785 | const exit = yield* Effect.exit(effect); | ||
| 3786 | if (Exit.isFailure(exit)) { | ||
| 3787 | yield* clearIfOwner; | ||
| 3788 | return yield* Effect.failCause(exit.cause); | ||
| 3789 | } | ||
| 3790 | return Option.some(exit.value); | ||
| 3791 | }), | ||
| 3792 | ), | ||
| 3793 | }); | ||
| 3794 | return true; | ||
| 3795 | }); | ||
| 3796 | |||
| 3797 | const applyLateBackgroundMutation = Effect.fnUntraced(function* ( | ||
| 3798 | sessionId: string, | ||
| 3799 | mutation: { | ||
| 3800 | readonly taskId: string; | ||
| 3801 | readonly status: "running" | "completed" | "failed"; | ||
| 3802 | }, | ||
| 3803 | ) { | ||
| 3804 | const taskAlreadyEnded = (yield* Ref.get(endedBackgroundTaskIds)).has(mutation.taskId); | ||
| 3805 | yield* applyBackgroundTaskMutationRunning(mutation); | ||
| 3806 | if (mutation.status === "running") { | ||
| 3807 | // Straggler running notice after a genuine end: suppress residual | ||
| 3808 | // monitor-prompt agent chatter so it cannot open a synthetic wake. | ||
| 3809 | if (taskAlreadyEnded && acpPostSettleMonitorPromptShouldSuppress(mutation)) { | ||
| 3810 | yield* Ref.set(suppressPostSettleMonitorPrompt, true); | ||
| 3811 | } | ||
| 3812 | return; | ||
| 3813 | } | ||
| 3814 | // First genuine terminal: allow subsequent agent output. Re-delivery | ||
| 3815 | // of an already-ended terminal must not un-suppress residual ack | ||
| 3816 | // chatter from the injected monitor-event turn. | ||
| 3817 | if (!taskAlreadyEnded) { | ||
| 3818 | yield* Ref.set(suppressPostSettleMonitorPrompt, false); | ||
| 3819 | } | ||
| 3820 | // Keep handledBackgroundTaskIdsInActiveTurn intact. Erasing the mark | ||
| 3821 | // on a late monitor-event mutation defeated the in-turn chatter guard | ||
| 3822 | // and let injected-turn acks arm wakeBuffer for a later spurious | ||
| 3823 | // "Background task completed." run (multiturn live repro). Monitors | ||
| 3824 | // the agent settled without reporting never enter that set, so their | ||
| 3825 | // injected report still streams via pendingInjectedReport / hold. | ||
| 3826 | const activeContext = yield* Ref.get(activeTurn); | ||
| 3827 | // Completions that land while a root turn is still streaming are | ||
| 3828 | // owned by in-turn machinery (emitTool handled marks, deferred | ||
| 3829 | // finalize, injected-report hold). Do not open a synthetic wake mid- | ||
| 3830 | // turn; finalizeTurn re-checks wakeBuffer / midTurnUnreported once | ||
| 3831 | // the turn leaves the active slot so legitimate unhandled evidence | ||
| 3832 | // still offers after finalize. | ||
| 3833 | if (activeContext !== null && !activeContext.finalized) { | ||
| 3834 | const handled = yield* Ref.get(handledBackgroundTaskIdsInActiveTurn); | ||
| 3835 | // Only arm deferred wake when this turn registered the task (monitor | ||
| 3836 | // tool started in-turn) and the root prompt is still open. Completions | ||
| 3837 | // that land in the settled-held window (promptSettled, deferred | ||
| 3838 | // finalize holding for injected report) fall back to the pre-existing | ||
| 3839 | // post-finalize injected-turn path; arming here would spuriously | ||
| 3840 | // offer "Background task completed." after the report streams into | ||
| 3841 | // the held turn. Residual cancel-backgrounded completions from a | ||
| 3842 | // prior interrupt must not open a synthetic continuation. | ||
| 3843 | if ( | ||
| 3844 | !activeContext.promptSettled && | ||
| 3845 | !handled.has(mutation.taskId) && | ||
| 3846 | activeContext.toolCallIdsByBackgroundTaskId.has(mutation.taskId) | ||
| 3847 | ) { | ||
| 3848 | yield* Ref.update(midTurnUnreportedCompletedTaskIds, (current) => | ||
| 3849 | new Set(current).add(mutation.taskId), | ||
| 3850 | ); | ||
| 3851 | } | ||
| 3852 | return; | ||
| 3853 | } | ||
| 3854 | if ( | ||
| 3855 | postSettleContinuationEnabled && | ||
| 3856 | (yield* Ref.get(activeSessionId)) === sessionId && | ||
| 3857 | (yield* Ref.get(runningBackgroundTaskIds)).size === 0 && | ||
| 3858 | ((yield* Ref.get(wakeBuffer)).length > 0 || | ||
| 3859 | (yield* Ref.get(midTurnUnreportedCompletedTaskIds)).size > 0) | ||
| 3860 | ) { | ||
| 3861 | yield* offerContinuationRun(sessionId); | ||
| 3862 | } | ||
| 3863 | }); | ||
| 3864 | |||
| 3865 | // A structured task end (Grok `task_completed`) is authoritative for the | ||
| 3866 | // tool that registered the task: its own updates only ever say running. | ||
| 3867 | // Finish the row while deferred finalize holds a settled root turn open | ||
| 3868 | // for it, so the turn can settle. While the prompt is still open the | ||
| 3869 | // agent reports the end itself (TaskOutput hydration), and an unreported | ||
| 3870 | // end must keep its post-finalize continuation offer. | ||
| 3871 | const finishRegisteredBackgroundTool = Effect.fnUntraced(function* (mutation: { | ||
| 3872 | readonly taskId: string; | ||
| 3873 | readonly status: "completed" | "failed"; | ||
| 3874 | readonly output?: string; | ||
| 3875 | }) { | ||
| 3876 | const context = yield* Ref.get(activeTurn); | ||
| 3877 | if (context === null || context.finalized || !context.promptSettled) return; | ||
| 3878 | const toolCallId = context.toolCallIdsByBackgroundTaskId.get(mutation.taskId); | ||
| 3879 | const tool = toolCallId === undefined ? undefined : context.tools.get(toolCallId); | ||
| 3880 | if (tool === undefined) return; | ||
| 3881 | const status = toolStatus(tool.status); | ||
| 3882 | if (status !== "pending" && status !== "running") return; | ||
| 3883 | context.awaitingBackgroundHydration.delete(mutation.taskId); | ||
| 3884 | const finished = { ...tool, status: mutation.status }; | ||
| 3885 | yield* emitTool( | ||
| 3886 | context, | ||
| 3887 | mutation.output === undefined ? finished : setToolOutputText(finished, mutation.output), | ||
| 3888 | mutation.status, | ||
| 3889 | ); | ||
| 3890 | yield* rearmDeferredFinalize(context); | ||
| 3891 | }); | ||
| 3892 | |||
| 3893 | const bufferPostSettleWake = Effect.fnUntraced(function* ( | ||
| 3894 | notification: EffectAcpSchema.SessionNotification, | ||
| 3895 | ) { | ||
| 3896 | if (!postSettleContinuationEnabled || continuationRequests === undefined) { | ||
| 3897 | return { | ||
| 3898 | buffered: false, | ||
| 3899 | offerContinuation: false, | ||
| 3900 | stopProcessing: false, | ||
| 3901 | }; | ||
| 3902 | } | ||
| 3903 | // Direct Stop quarantine: drop residual wake evidence instead of | ||
| 3904 | // buffering it for a later continuation or follow-up run. | ||
| 3905 | if (yield* Ref.get(stoppedRunQuarantine)) { | ||
| 3906 | return { | ||
| 3907 | buffered: false, | ||
| 3908 | offerContinuation: false, | ||
| 3909 | stopProcessing: true, | ||
| 3910 | }; | ||
| 3911 | } | ||
| 3912 | const rootSessionId = yield* Ref.get(activeSessionId); | ||
| 3913 | if (rootSessionId === null || notification.sessionId !== rootSessionId) { | ||
| 3914 | return { | ||
| 3915 | buffered: false, | ||
| 3916 | offerContinuation: false, | ||
| 3917 | stopProcessing: false, | ||
| 3918 | }; | ||
| 3919 | } | ||
| 3920 | if (!acpPostSettleWakeEvidence(notification, flavor)) { | ||
| 3921 | return { | ||
| 3922 | buffered: false, | ||
| 3923 | offerContinuation: false, | ||
| 3924 | stopProcessing: false, | ||
| 3925 | }; | ||
| 3926 | } | ||
| 3927 | const update = notification.update; | ||
| 3928 | let alreadyHandledToolUpdate = false; | ||
| 3929 | if (update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update") { | ||
| 3930 | yield* trackRunningBackgroundTools(notification); | ||
| 3931 | // When the agent consumes the monitor output itself via | ||
| 3932 | // get_command_or_subagent_output (long timeout), the TaskOutput | ||
| 3933 | // completion is the ONLY end signal: no "Monitor ended" reminder | ||
| 3934 | // follows. Without applying it here the running set never clears, | ||
| 3935 | // every offer stays suppressed, and the wake buffer never drains | ||
| 3936 | // (observed live 2026-07-12, thread 8dbe607f). The completion | ||
| 3937 | // frame itself is offer evidence, so clearing the id before the | ||
| 3938 | // gate below lets it open the single continuation run. | ||
| 3939 | for (const event of parseSessionUpdateEvent(notification).events) { | ||
| 3940 | if (event._tag !== "ToolCallUpdated") continue; | ||
| 3941 | const toolCall = flavor.normalizeToolCall?.(event.toolCall) ?? event.toolCall; | ||
| 3942 | const proposedPlanMarkdown = flavor.extractProposedPlanMarkdown?.(toolCall); | ||
| 3943 | if (proposedPlanMarkdown !== undefined && proposedPlanMarkdown.length > 0) { | ||
| 3944 | yield* captureProposedPlan({ planMarkdown: proposedPlanMarkdown }); | ||
| 3945 | } | ||
| 3946 | const toolTaskId = flavor.extractBackgroundTaskId?.(toolCall); | ||
| 3947 | if ( | ||
| 3948 | toolTaskId !== undefined && | ||
| 3949 | (yield* Ref.get(handledBackgroundTaskIdsInActiveTurn)).has(toolTaskId) | ||
| 3950 | ) { | ||
| 3951 | alreadyHandledToolUpdate = true; | ||
| 3952 | } | ||
| 3953 | if (flavor.extractBackgroundTaskCompletion !== undefined) { | ||
| 3954 | for (const completion of flavor.extractBackgroundTaskCompletion(toolCall)) { | ||
| 3955 | alreadyHandledToolUpdate = | ||
| 3956 | alreadyHandledToolUpdate || | ||
| 3957 | (yield* Ref.get(handledBackgroundTaskIdsInActiveTurn)).has(completion.taskId); | ||
| 3958 | yield* applyBackgroundTaskMutationRunning(completion); | ||
| 3959 | } | ||
| 3960 | } | ||
| 3961 | } | ||
| 3962 | } | ||
| 3963 | const backgroundWorkRunning = (yield* Ref.get(runningBackgroundTaskIds)).size > 0; | ||
| 3964 | // Residual Grok agent/thought chatter after in-turn-handled background | ||
| 3965 | // work must not be retained as wake evidence. Tool-path alreadyHandled | ||
| 3966 | // covers re-reports with a task id; this covers agent_message_chunk / | ||
| 3967 | // agent_thought_chunk frames that carry no task id (live: | ||
| 3968 | // grok-in-turn-monitor-no-wake, multiturn stale-buffer arm). Check | ||
| 3969 | // before buffering so the frames cannot dirty wakeBuffer and later | ||
| 3970 | // arm a mid-turn offer when a second monitor completes. | ||
| 3971 | const handledInTurnCount = (yield* Ref.get(handledBackgroundTaskIdsInActiveTurn)).size; | ||
| 3972 | const isInTurnHandledAgentChatter = | ||
| 3973 | handledInTurnCount > 0 && | ||
| 3974 | (update.sessionUpdate === "agent_message_chunk" || | ||
| 3975 | update.sessionUpdate === "agent_thought_chunk"); | ||
| 3976 | // Grok prompts itself for every monitor event after the root turn | ||
| 3977 | // settles. Its assistant/reasoning replies are progress chatter, not | ||
| 3978 | // separate wake results. Retaining them would replay the entire burst | ||
| 3979 | // into the single continuation once the monitor finishes. Keep tool | ||
| 3980 | // state so the final command card still hydrates, then begin retaining | ||
| 3981 | // agent output again after the genuine end signal clears the running | ||
| 3982 | // set. With multiple tasks, text remains best-effort until every task | ||
| 3983 | // ends; their retained tool cards are the authoritative results. | ||
| 3984 | // | ||
| 3985 | // Skip retaining frames for tasks already hydrated in the root turn | ||
| 3986 | // (`handledBackgroundTaskIdsInActiveTurn`). Those re-reports must not | ||
| 3987 | // pin `hasPendingBackgroundWork` via a wake buffer that never drains | ||
| 3988 | // (the already-handled gate below intentionally skips | ||
| 3989 | // `offerContinuationRun` to avoid synthetic "Background task | ||
| 3990 | // completed." spam). | ||
| 3991 | let buffered = false; | ||
| 3992 | if ( | ||
| 3993 | !alreadyHandledToolUpdate && | ||
| 3994 | !isInTurnHandledAgentChatter && | ||
| 3995 | acpPostSettleWakeShouldBuffer(notification, backgroundWorkRunning) | ||
| 3996 | ) { | ||
| 3997 | yield* Ref.update(wakeBuffer, (current) => [...current, notification]); | ||
| 3998 | buffered = true; | ||
| 3999 | } | ||
| 4000 | // Buffer progress without offering; only completion-like frames open a run. | ||
| 4001 | if (!acpPostSettleContinuationOfferEvidence(notification, flavor)) { | ||
| 4002 | return { | ||
| 4003 | buffered, | ||
| 4004 | offerContinuation: false, | ||
| 4005 | stopProcessing: true, | ||
| 4006 | }; | ||
| 4007 | } | ||
| 4008 | // While a monitor is still streaming, tool re-reports buffer without | ||
| 4009 | // offering and per-event agent commentary is consumed without being | ||
| 4010 | // retained. Grok re-reports a running monitor as Bash frames that | ||
| 4011 | // already carry exit_code 0 mid-stream, so a "terminal" normalized | ||
| 4012 | // status is not evidence the task ended; each burst would otherwise | ||
| 4013 | // reopen a synthetic "Background task completed." run. Retained tool | ||
| 4014 | // frames drain into the single continuation offered once the monitor | ||
| 4015 | // actually ends (end-notice mutation below, or the first frame after | ||
| 4016 | // it). | ||
| 4017 | if (backgroundWorkRunning) { | ||
| 4018 | return { | ||
| 4019 | buffered, | ||
| 4020 | offerContinuation: false, | ||
| 4021 | stopProcessing: true, | ||
| 4022 | }; | ||
| 4023 | } | ||
| 4024 | if (alreadyHandledToolUpdate) { | ||
| 4025 | // Drop leftover wake noise for in-turn-handled work so idle release | ||
| 4026 | // is not pinned forever. Leave the buffer alone when a continuation | ||
| 4027 | // is already outstanding: its startTurn will drain legitimate frames | ||
| 4028 | // from other tasks. | ||
| 4029 | if (!(yield* Ref.get(continuationRequested))) { | ||
| 4030 | yield* Ref.set(wakeBuffer, []); | ||
| 4031 | } | ||
| 4032 | return { | ||
| 4033 | buffered, | ||
| 4034 | offerContinuation: false, | ||
| 4035 | stopProcessing: true, | ||
| 4036 | }; | ||
| 4037 | } | ||
| 4038 | // Same in-turn-handled agent chatter: do not open a synthetic wake. | ||
| 4039 | // Do not clear wakeBuffer here: frames for other still-tracked tasks | ||
| 4040 | // must remain drainable when a real (tool) completion later offers. | ||
| 4041 | if (isInTurnHandledAgentChatter) { | ||
| 4042 | return { | ||
| 4043 | buffered, | ||
| 4044 | offerContinuation: false, | ||
| 4045 | stopProcessing: true, | ||
| 4046 | }; | ||
| 4047 | } | ||
| 4048 | return { | ||
| 4049 | buffered, | ||
| 4050 | offerContinuation: true, | ||
| 4051 | stopProcessing: true, | ||
| 4052 | }; | ||
| 4053 | }); | ||
| 4054 | |||
| 4055 | let applyFinalizedActiveTurnSubagentTerminal: ( | ||
| 4056 | context: ActiveAcpTurn, | ||
| 4057 | notification: EffectAcpSchema.SessionNotification, | ||
| 4058 | ) => Effect.Effect<boolean> = () => Effect.succeed(false); | ||
| 4059 | |||
| 4060 | const projectAgentTerminalUpdate = Effect.fnUntraced(function* ( | ||
| 4061 | notification: EffectAcpSchema.SessionNotification, | ||
| 4062 | context: ActiveAcpTurn | null, | ||
| 4063 | ) { | ||
| 4064 | const update = notification.update; | ||
| 4065 | if ( | ||
| 4066 | update.sessionUpdate !== "terminal_update" && | ||
| 4067 | update.sessionUpdate !== "terminal_output_chunk" | ||
| 4068 | ) { | ||
| 4069 | return false; | ||
| 4070 | } | ||
| 4071 | const terminalKey = sessionScopedId(notification.sessionId, update.terminalId); | ||
| 4072 | const next = applyAcpAgentTerminalUpdate(agentTerminalsById.get(terminalKey), update); | ||
| 4073 | agentTerminalsById.set(terminalKey, next); | ||
| 4074 | if (context === null || notification.sessionId !== context.nativeThreadId) return true; | ||
| 4075 | const status = | ||
| 4076 | next.exitStatus === undefined | ||
| 4077 | ? ("inProgress" as const) | ||
| 4078 | : next.exitStatus.exitCode === 0 | ||
| 4079 | ? ("completed" as const) | ||
| 4080 | : ("failed" as const); | ||
| 4081 | const embeddedToolCallIds = toolCallIdsByAgentTerminalId.get(terminalKey); | ||
| 4082 | const projectedToolCallIds = | ||
| 4083 | embeddedToolCallIds === undefined || embeddedToolCallIds.size === 0 | ||
| 4084 | ? [`acp-agent-terminal:${update.terminalId}`] | ||
| 4085 | : [...embeddedToolCallIds]; | ||
| 4086 | for (const toolCallId of projectedToolCallIds) { | ||
| 4087 | const existing = context.tools.get(toolCallId); | ||
| 4088 | const seeded: AcpToolCallState = | ||
| 4089 | existing ?? | ||
| 4090 | ({ | ||
| 4091 | toolCallId, | ||
| 4092 | kind: "execute", | ||
| 4093 | title: next.command ?? "Terminal", | ||
| 4094 | status, | ||
| 4095 | ...(next.command === undefined ? {} : { command: next.command }), | ||
| 4096 | data: { toolCallId }, | ||
| 4097 | } satisfies AcpToolCallState); | ||
| 4098 | yield* emitTool( | ||
| 4099 | context, | ||
| 4100 | setToolOutputText( | ||
| 4101 | { | ||
| 4102 | ...seeded, | ||
| 4103 | status, | ||
| 4104 | ...(next.command === undefined ? {} : { command: next.command }), | ||
| 4105 | }, | ||
| 4106 | next.output, | ||
| 4107 | ), | ||
| 4108 | ); | ||
| 4109 | } | ||
| 4110 | return true; | ||
| 4111 | }); | ||
| 4112 | |||
| 4113 | const handleSessionUpdate = Effect.fnUntraced(function* ( | ||
| 4114 | notification: EffectAcpSchema.SessionNotification, | ||
| 4115 | ) { | ||
| 4116 | const context = yield* Ref.get(activeTurn); | ||
| 4117 | const update = notification.update; | ||
| 4118 | if (yield* projectAgentTerminalUpdate(notification, context)) return; | ||
| 4119 | if ( | ||
| 4120 | update.sessionUpdate === "usage_update" || | ||
| 4121 | update.sessionUpdate === "session_info_update" || | ||
| 4122 | (update.sessionUpdate === "state_update" && | ||
| 4123 | update.state === "idle" && | ||
| 4124 | update.usage != null) | ||
| 4125 | ) { | ||
| 4126 | const stateEvent = parseSessionUpdateEvent(notification).events.find( | ||
| 4127 | (event) => event._tag === "UsageUpdated" || event._tag === "SessionInfoUpdated", | ||
| 4128 | ); | ||
| 4129 | if (stateEvent === undefined) return; | ||
| 4130 | if (stateEvent._tag === "UsageUpdated") { | ||
| 4131 | yield* Ref.update(contextUsageBySessionId, (current) => | ||
| 4132 | new Map(current).set(notification.sessionId, stateEvent.usage), | ||
| 4133 | ); | ||
| 4134 | if (context?.nativeThreadId === notification.sessionId) { | ||
| 4135 | context.contextUsage = stateEvent.usage; | ||
| 4136 | } | ||
| 4137 | } else { | ||
| 4138 | const metadata = yield* Ref.modify(nativeMetadataBySessionId, (current) => { | ||
| 4139 | const merged = { | ||
| 4140 | ...current.get(notification.sessionId), | ||
| 4141 | ...stateEvent.metadata, | ||
| 4142 | }; | ||
| 4143 | return [merged, new Map(current).set(notification.sessionId, merged)] as const; | ||
| 4144 | }); | ||
| 4145 | if (context?.nativeThreadId === notification.sessionId) { | ||
| 4146 | context.nativeMetadata = metadata; | ||
| 4147 | } | ||
| 4148 | } | ||
| 4149 | const knownProviderThread = (yield* Ref.get(providerThreadByNativeSessionId)).get( | ||
| 4150 | notification.sessionId, | ||
| 4151 | ); | ||
| 4152 | if (knownProviderThread !== undefined) { | ||
| 4153 | const now = yield* DateTime.now; | ||
| 4154 | const providerThread: OrchestrationV2ProviderThread = { | ||
| 4155 | ...knownProviderThread, | ||
| 4156 | contextUsage: | ||
| 4157 | (yield* Ref.get(contextUsageBySessionId)).get(notification.sessionId) ?? | ||
| 4158 | knownProviderThread.contextUsage ?? | ||
| 4159 | null, | ||
| 4160 | nativeMetadata: | ||
| 4161 | (yield* Ref.get(nativeMetadataBySessionId)).get(notification.sessionId) ?? | ||
| 4162 | knownProviderThread.nativeMetadata ?? | ||
| 4163 | null, | ||
| 4164 | updatedAt: now, | ||
| 4165 | }; | ||
| 4166 | yield* Ref.update(providerThreadByNativeSessionId, (current) => | ||
| 4167 | new Map(current).set(notification.sessionId, providerThread), | ||
| 4168 | ); | ||
| 4169 | yield* emitProviderEvent({ | ||
| 4170 | type: "provider_thread.updated", | ||
| 4171 | driver, | ||
| 4172 | providerThread, | ||
| 4173 | }); | ||
| 4174 | } | ||
| 4175 | return; | ||
| 4176 | } | ||
| 4177 | if ( | ||
| 4178 | context?.finalized === true && | ||
| 4179 | (yield* applyFinalizedActiveTurnSubagentTerminal(context, notification)) | ||
| 4180 | ) { | ||
| 4181 | return; | ||
| 4182 | } | ||
| 4183 | // Only while a finalized turn is still the active context. When | ||
| 4184 | // activeTurn is null, post-settle agent frames must reach | ||
| 4185 | // bufferPostSettleWake so continuation can attach (context?.finalized | ||
| 4186 | // !== false incorrectly treated null as finalized and dropped them). | ||
| 4187 | if ( | ||
| 4188 | context !== null && | ||
| 4189 | context.finalized && | ||
| 4190 | (yield* Ref.get(handledBackgroundTaskIdsInActiveTurn)).size > 0 && | ||
| 4191 | (update.sessionUpdate === "agent_message_chunk" || | ||
| 4192 | update.sessionUpdate === "agent_thought_chunk") | ||
| 4193 | ) { | ||
| 4194 | yield* Ref.set(suppressPostSettleMonitorPrompt, true); | ||
| 4195 | return; | ||
| 4196 | } | ||
| 4197 | if ( | ||
| 4198 | context !== null && | ||
| 4199 | (yield* Ref.get(suppressPostSettleMonitorPrompt)) && | ||
| 4200 | (update.sessionUpdate === "agent_message_chunk" || | ||
| 4201 | update.sessionUpdate === "agent_thought_chunk") | ||
| 4202 | ) { | ||
| 4203 | return; | ||
| 4204 | } | ||
| 4205 | if ( | ||
| 4206 | context?.finalized === true && | ||
| 4207 | update.sessionUpdate === "user_message_chunk" && | ||
| 4208 | update.content.type === "text" | ||
| 4209 | ) { | ||
| 4210 | const mutations = flavor.extractBackgroundToolMutation?.(update.content.text) ?? []; | ||
| 4211 | for (const mutation of mutations) { | ||
| 4212 | yield* applyLateBackgroundMutation(notification.sessionId, mutation); | ||
| 4213 | } | ||
| 4214 | return; | ||
| 4215 | } | ||
| 4216 | if (context === null) { | ||
| 4217 | // Direct Stop: quarantine residual events from the stopped run so | ||
| 4218 | // they cannot become history, wake buffers, or a later run attach. | ||
| 4219 | if (yield* Ref.get(stoppedRunQuarantine)) { | ||
| 4220 | return; | ||
| 4221 | } | ||
| 4222 | const bufferOutcome = yield* bufferPostSettleWake(notification); | ||
| 4223 | // Post-settle carryover sync: keep in-memory carryover accurate so | ||
| 4224 | // hasPendingBackgroundWork reasons correctly after root settle. | ||
| 4225 | // A completed root keeps its subscriber open while background items | ||
| 4226 | // remain, so project its terminals immediately even when the wake | ||
| 4227 | // frame is buffered for a continuation. Non-completed roots stay | ||
| 4228 | // memory-only: durable ingest requires a subscriber that owns the | ||
| 4229 | // original runId, which the interrupted path does not guarantee. | ||
| 4230 | const carryover = yield* Ref.get(carryoverSubagents); | ||
| 4231 | const rootTerminalCanStillProject = | ||
| 4232 | carryover !== null && | ||
| 4233 | carryover.sessionId === (yield* Ref.get(activeSessionId)) && | ||
| 4234 | carryover.rootTerminalStatus === "completed"; | ||
| 4235 | const projectCarryover = rootTerminalCanStillProject; | ||
| 4236 | let carryoverTerminalized = false; | ||
| 4237 | if ( | ||
| 4238 | flavor.extractSubagentUpdate !== undefined && | ||
| 4239 | (update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update") | ||
| 4240 | ) { | ||
| 4241 | for (const event of parseSessionUpdateEvent(notification).events) { | ||
| 4242 | if (event._tag !== "ToolCallUpdated") continue; | ||
| 4243 | const toolCall = flavor.normalizeToolCall?.(event.toolCall) ?? event.toolCall; | ||
| 4244 | const subagentUpdate = flavor.extractSubagentUpdate(toolCall); | ||
| 4245 | if (subagentUpdate === undefined) continue; | ||
| 4246 | if (!acpSubagentStatusIsTerminal(subagentUpdate.status)) { | ||
| 4247 | continue; | ||
| 4248 | } | ||
| 4249 | if ( | ||
| 4250 | yield* updateCarryoverSubagentStatus( | ||
| 4251 | subagentUpdate.nativeTaskId, | ||
| 4252 | subagentUpdate.status, | ||
| 4253 | subagentUpdate.result, | ||
| 4254 | { project: projectCarryover }, | ||
| 4255 | ) | ||
| 4256 | ) { | ||
| 4257 | carryoverTerminalized = true; | ||
| 4258 | } | ||
| 4259 | if ( | ||
| 4260 | subagentUpdate.childSessionId !== null && | ||
| 4261 | (yield* updateCarryoverSubagentStatus( | ||
| 4262 | subagentUpdate.childSessionId, | ||
| 4263 | subagentUpdate.status, | ||
| 4264 | subagentUpdate.result, | ||
| 4265 | { project: projectCarryover }, | ||
| 4266 | )) | ||
| 4267 | ) { | ||
| 4268 | carryoverTerminalized = true; | ||
| 4269 | } | ||
| 4270 | } | ||
| 4271 | } | ||
| 4272 | if ( | ||
| 4273 | update.sessionUpdate === "user_message_chunk" && | ||
| 4274 | update.content.type === "text" && | ||
| 4275 | flavor.extractSubagentEndNotice !== undefined | ||
| 4276 | ) { | ||
| 4277 | const notice = flavor.extractSubagentEndNotice(update.content.text); | ||
| 4278 | if ( | ||
| 4279 | notice !== undefined && | ||
| 4280 | (yield* updateCarryoverSubagentStatus( | ||
| 4281 | notice.childSessionId, | ||
| 4282 | notice.status, | ||
| 4283 | undefined, | ||
| 4284 | { | ||
| 4285 | project: projectCarryover, | ||
| 4286 | }, | ||
| 4287 | )) | ||
| 4288 | ) { | ||
| 4289 | carryoverTerminalized = true; | ||
| 4290 | } | ||
| 4291 | } | ||
| 4292 | // Synchronize carryover before an eager continuation can attach and | ||
| 4293 | // drain the frame. | ||
| 4294 | const continuationOffered = bufferOutcome.offerContinuation | ||
| 4295 | ? yield* offerContinuationRun(notification.sessionId) | ||
| 4296 | : false; | ||
| 4297 | const wakeOutcome = { ...bufferOutcome, continuationOffered }; | ||
| 4298 | // Frames that will not buffer never reach the continuation drain for | ||
| 4299 | // this traffic. Once carryover is terminalized, skip history append / | ||
| 4300 | // residual handling (and avoid double-projecting on child re-entry). | ||
| 4301 | if (carryoverTerminalized && !wakeOutcome.buffered) { | ||
| 4302 | // Projected above. Never clear a non-empty wakeBuffer. A sticky | ||
| 4303 | // continuationRequested with an empty buffer is safe to drop so | ||
| 4304 | // idle release is not wed on a pin with nothing to deliver. | ||
| 4305 | if ( | ||
| 4306 | acpCarryoverTerminalShouldClearContinuation({ | ||
| 4307 | continuationOffered: wakeOutcome.continuationOffered, | ||
| 4308 | wakeBufferLength: (yield* Ref.get(wakeBuffer)).length, | ||
| 4309 | }) | ||
| 4310 | ) { | ||
| 4311 | yield* Ref.set(continuationRequested, false); | ||
| 4312 | } | ||
| 4313 | return; | ||
| 4314 | } | ||
| 4315 | // Prefer continuation buffering over history append so the same | ||
| 4316 | // frames are not double-counted once a continuation run attaches. | ||
| 4317 | if (wakeOutcome.stopProcessing) { | ||
| 4318 | return; | ||
| 4319 | } | ||
| 4320 | if ( | ||
| 4321 | update.sessionUpdate === "user_message_chunk" || | ||
| 4322 | update.sessionUpdate === "agent_message_chunk" || | ||
| 4323 | update.sessionUpdate === "agent_thought_chunk" | ||
| 4324 | ) { | ||
| 4325 | // Late monitor end/event reminders must not become ghost user/assistant | ||
| 4326 | // history (or OS-facing chatter) after the root turn already finalized. | ||
| 4327 | const text = acpContentBlockDisplayText(update.content); | ||
| 4328 | if (text === undefined) return; | ||
| 4329 | const lateBackgroundMutations = | ||
| 4330 | update.content.type === "text" | ||
| 4331 | ? (flavor.extractBackgroundToolMutation?.(text) ?? []) | ||
| 4332 | : []; | ||
| 4333 | for (const lateBackgroundMutation of lateBackgroundMutations) { | ||
| 4334 | yield* applyLateBackgroundMutation(notification.sessionId, lateBackgroundMutation); | ||
| 4335 | } | ||
| 4336 | const lateMonitorChatter = | ||
| 4337 | /<monitor-event\b/i.test(text) || | ||
| 4338 | /Monitor\s+["']?[0-9a-f-]{8,}["']?\s+ended/i.test(text); | ||
| 4339 | if ( | ||
| 4340 | text.trim().length > 0 && | ||
| 4341 | lateBackgroundMutations.length === 0 && | ||
| 4342 | !lateMonitorChatter | ||
| 4343 | ) { | ||
| 4344 | yield* appendLoadedHistory( | ||
| 4345 | notification, | ||
| 4346 | update.sessionUpdate === "user_message_chunk" | ||
| 4347 | ? "user" | ||
| 4348 | : update.sessionUpdate === "agent_thought_chunk" | ||
| 4349 | ? "thought" | ||
| 4350 | : "assistant", | ||
| 4351 | text, | ||
| 4352 | ); | ||
| 4353 | } | ||
| 4354 | } else if ( | ||
| 4355 | update.sessionUpdate === "user_message" || | ||
| 4356 | update.sessionUpdate === "agent_message" || | ||
| 4357 | update.sessionUpdate === "agent_thought" | ||
| 4358 | ) { | ||
| 4359 | if (update.content === undefined) return; | ||
| 4360 | const text = (update.content ?? []) | ||
| 4361 | .flatMap((content) => { | ||
| 4362 | const display = acpContentBlockDisplayText(content); | ||
| 4363 | return display === undefined ? [] : [display]; | ||
| 4364 | }) | ||
| 4365 | .join("\n"); | ||
| 4366 | yield* appendLoadedHistory( | ||
| 4367 | notification, | ||
| 4368 | update.sessionUpdate === "user_message" | ||
| 4369 | ? "user" | ||
| 4370 | : update.sessionUpdate === "agent_thought" | ||
| 4371 | ? "thought" | ||
| 4372 | : "assistant", | ||
| 4373 | text, | ||
| 4374 | true, | ||
| 4375 | ); | ||
| 4376 | } else if ( | ||
| 4377 | update.sessionUpdate === "tool_call" || | ||
| 4378 | update.sessionUpdate === "tool_call_update" || | ||
| 4379 | update.sessionUpdate === "plan" || | ||
| 4380 | update.sessionUpdate === "plan_update" || | ||
| 4381 | update.sessionUpdate === "plan_removed" | ||
| 4382 | ) { | ||
| 4383 | yield* Ref.update(snapshot, (current) => ({ | ||
| 4384 | ...current, | ||
| 4385 | loadingRole: null, | ||
| 4386 | loadingMessageId: null, | ||
| 4387 | })); | ||
| 4388 | } | ||
| 4389 | return; | ||
| 4390 | } | ||
| 4391 | if (context.finalized) return; | ||
| 4392 | const rootSessionId = yield* Ref.get(activeSessionId); | ||
| 4393 | if (notification.sessionId !== rootSessionId) { | ||
| 4394 | // Finalize may have completed during the activeSessionId yield. | ||
| 4395 | if (context.finalized) return; | ||
| 4396 | if (flavor.extractSubagentUpdate === undefined) return; | ||
| 4397 | const subagent = context.subagentsBySessionId.get(notification.sessionId); | ||
| 4398 | if ( | ||
| 4399 | update.sessionUpdate === "tool_call" || | ||
| 4400 | update.sessionUpdate === "tool_call_update" | ||
| 4401 | ) { | ||
| 4402 | if (subagent === undefined) return; | ||
| 4403 | const nativeTaskId = | ||
| 4404 | subagent.task.nativeTaskRef?.nativeId ?? String(subagent.task.id); | ||
| 4405 | for (const event of parseSessionUpdateEvent(notification).events) { | ||
| 4406 | if (event._tag !== "ToolCallUpdated") continue; | ||
| 4407 | const toolCall = flavor.normalizeToolCall?.(event.toolCall) ?? event.toolCall; | ||
| 4408 | const subagentUpdate = flavor.extractSubagentUpdate(toolCall); | ||
| 4409 | if (subagentUpdate !== undefined) { | ||
| 4410 | if ( | ||
| 4411 | subagentUpdate.nativeTaskId === nativeTaskId || | ||
| 4412 | subagentUpdate.childSessionId === notification.sessionId | ||
| 4413 | ) { | ||
| 4414 | yield* emitSubagent(context, subagentUpdate); | ||
| 4415 | } | ||
| 4416 | continue; | ||
| 4417 | } | ||
| 4418 | const key = `${nativeTaskId}:tool:${toolCall.toolCallId}`; | ||
| 4419 | const previous = context.tools.get(key); | ||
| 4420 | const merged = mergeToolCallState(previous, toolCall); | ||
| 4421 | context.tools.set(key, merged); | ||
| 4422 | if (!shouldPersistToolUpdate(context, key, previous, merged, merged.status)) | ||
| 4423 | continue; | ||
| 4424 | // Terminals are remembered under the raw session id: the child's | ||
| 4425 | // own session, or the root one when the flavor routes child | ||
| 4426 | // updates out of it (Devin). | ||
| 4427 | const mcpIdentity = extractMcpToolCallIdentity(merged, { | ||
| 4428 | embeddedTerminalCommands: [ | ||
| 4429 | ...embeddedTerminalCommands(notification.sessionId, toolCall.toolCallId), | ||
| 4430 | ...(rootSessionId === null | ||
| 4431 | ? [] | ||
| 4432 | : embeddedTerminalCommands(rootSessionId, toolCall.toolCallId)), | ||
| 4433 | ], | ||
| 4434 | }); | ||
| 4435 | const now = yield* DateTime.now; | ||
| 4436 | const status = toolStatus(merged.status); | ||
| 4437 | const startedAt = context.toolStartedAt.get(key) ?? now; | ||
| 4438 | context.toolStartedAt.set(key, startedAt); | ||
| 4439 | const ordinal = resolveSubagentChildOrdinal(subagent, key); | ||
| 4440 | yield* emitProviderEvent({ | ||
| 4441 | type: "turn_item.updated", | ||
| 4442 | driver, | ||
| 4443 | turnItem: { | ||
| 4444 | id: providerTurnItemId(key), | ||
| 4445 | threadId: subagent.childThreadId, | ||
| 4446 | runId: null, | ||
| 4447 | nodeId: subagent.childRootNodeId, | ||
| 4448 | providerThreadId: subagent.task.providerThreadId, | ||
| 4449 | providerTurnId: null, | ||
| 4450 | nativeItemRef: { driver, nativeId: key, strength: "strong" }, | ||
| 4451 | parentItemId: null, | ||
| 4452 | ordinal, | ||
| 4453 | status, | ||
| 4454 | title: merged.title ?? merged.kind ?? "Tool", | ||
| 4455 | startedAt, | ||
| 4456 | completedAt: completedAtForStatus(status, now), | ||
| 4457 | updatedAt: now, | ||
| 4458 | type: "dynamic_tool", | ||
| 4459 | ...(mcpIdentity === undefined | ||
| 4460 | ? {} | ||
| 4461 | : mcpToolPresentation({ | ||
| 4462 | serverName: mcpIdentity.server, | ||
| 4463 | toolName: mcpIdentity.tool, | ||
| 4464 | source: unknownRecord( | ||
| 4465 | ( | ||
| 4466 | unknownRecord(unknownRecord(merged.data.rawOutput)?.result) ?? | ||
| 4467 | unknownRecord(merged.data.rawOutput) | ||
| 4468 | )?._meta, | ||
| 4469 | )?.source, | ||
| 4470 | })), | ||
| 4471 | toolName: | ||
| 4472 | mcpIdentity === undefined | ||
| 4473 | ? (merged.title ?? merged.kind ?? "Tool") | ||
| 4474 | : `${mcpIdentity.server}.${mcpIdentity.tool}`, | ||
| 4475 | input: merged.data.rawInput ?? null, | ||
| 4476 | output: merged.data.rawOutput ?? merged.data.content ?? null, | ||
| 4477 | }, | ||
| 4478 | }); | ||
| 4479 | } | ||
| 4480 | return; | ||
| 4481 | } | ||
| 4482 | const isDisplayableAssistantUpdate = | ||
| 4483 | (update.sessionUpdate === "agent_message_chunk" && | ||
| 4484 | acpContentBlockDisplayText(update.content) !== undefined) || | ||
| 4485 | (update.sessionUpdate === "agent_message" && update.content !== undefined); | ||
| 4486 | if (!isDisplayableAssistantUpdate) { | ||
| 4487 | return; | ||
| 4488 | } | ||
| 4489 | if (subagent !== undefined) { | ||
| 4490 | yield* projectSubagentNotification(subagent, notification); | ||
| 4491 | return; | ||
| 4492 | } | ||
| 4493 | const buffered = context.pendingSubagentNotifications.get(notification.sessionId) ?? []; | ||
| 4494 | buffered.push(notification); | ||
| 4495 | context.pendingSubagentNotifications.set(notification.sessionId, buffered); | ||
| 4496 | return; | ||
| 4497 | } | ||
| 4498 | // Re-check after the activeSessionId yield: idle/prompt settle can | ||
| 4499 | // finalize the same context object while we waited. | ||
| 4500 | if (context.finalized) return; | ||
| 4501 | // The agent started its own turn about finished background work while | ||
| 4502 | // this settled root turn was still held for it. That turn is the | ||
| 4503 | // agent's reply, not more of the root: settle the root first so the | ||
| 4504 | // frame reaches the wake path and opens a continuation run. | ||
| 4505 | if ( | ||
| 4506 | context.promptSettled && | ||
| 4507 | !acpIsProviderContinuationMessage(context.input.message) && | ||
| 4508 | flavor.isProviderWakeNotification?.(notification) === true | ||
| 4509 | ) { | ||
| 4510 | if (hasDeferredBackgroundWork(context)) { | ||
| 4511 | yield* Ref.update(wakeBuffer, (current) => [...current, notification]); | ||
| 4512 | return; | ||
| 4513 | } | ||
| 4514 | yield* finalizeTurn(context, context.promptSettledStatus ?? "completed"); | ||
| 4515 | const wake = yield* bufferPostSettleWake(notification); | ||
| 4516 | if (wake.offerContinuation) { | ||
| 4517 | yield* offerContinuationRun(notification.sessionId); | ||
| 4518 | } | ||
| 4519 | return; | ||
| 4520 | } | ||
| 4521 | // Only fresh model output proves a retry recovered; progress on | ||
| 4522 | // tools and plans that started earlier can arrive mid-retry. | ||
| 4523 | if ( | ||
| 4524 | update.sessionUpdate !== "tool_call_update" && | ||
| 4525 | update.sessionUpdate !== "plan" && | ||
| 4526 | update.sessionUpdate !== "plan_update" && | ||
| 4527 | update.sessionUpdate !== "plan_removed" && | ||
| 4528 | acpRootSessionUpdateIngestsOutput(notification) | ||
| 4529 | ) { | ||
| 4530 | yield* emitProviderRetry(context, "completed"); | ||
| 4531 | } | ||
| 4532 | switch (update.sessionUpdate) { | ||
| 4533 | case "state_update": { | ||
| 4534 | const toolCallId = `${context.nativeTurnId}:requires-action`; | ||
| 4535 | const existing = context.tools.get(toolCallId); | ||
| 4536 | if (update.state === "requires_action") { | ||
| 4537 | yield* emitTool(context, { | ||
| 4538 | toolCallId, | ||
| 4539 | kind: "other", | ||
| 4540 | title: "Action required", | ||
| 4541 | status: "requiresAction", | ||
| 4542 | data: { state: "requires_action" }, | ||
| 4543 | }); | ||
| 4544 | } else if (existing?.status === "requiresAction") { | ||
| 4545 | yield* emitTool(context, { | ||
| 4546 | ...existing, | ||
| 4547 | status: "completed", | ||
| 4548 | data: { ...existing.data, state: update.state }, | ||
| 4549 | }); | ||
| 4550 | } | ||
| 4551 | break; | ||
| 4552 | } | ||
| 4553 | case "agent_message_chunk": { | ||
| 4554 | const text = acpContentBlockDisplayText(update.content); | ||
| 4555 | if (text !== undefined) { | ||
| 4556 | const startsNewAssistantSegment = context.assistant.current === null; | ||
| 4557 | // The injected-turn report is streaming; the normal debounce | ||
| 4558 | // after the last chunk takes over from here. Drop matching | ||
| 4559 | // mid-turn armed ids so finalize does not open a duplicate | ||
| 4560 | // wake after the report already projected into this turn. | ||
| 4561 | if (context.pendingInjectedReport.size > 0) { | ||
| 4562 | const reportedTaskIds = [...context.pendingInjectedReport]; | ||
| 4563 | context.pendingInjectedReport.clear(); | ||
| 4564 | yield* clearMidTurnUnreportedTaskIds(reportedTaskIds); | ||
| 4565 | } else if ( | ||
| 4566 | startsNewAssistantSegment && | ||
| 4567 | context.promptSettled && | ||
| 4568 | (yield* Ref.get(midTurnUnreportedCompletedTaskIds)).size > 0 | ||
| 4569 | ) { | ||
| 4570 | // Some ACP implementations deliver the injected assistant | ||
| 4571 | // report before the synthetic monitor end notice. Correlation | ||
| 4572 | // arrives with that notice, so remember the unmatched report | ||
| 4573 | // and consume its task id when the notice follows. | ||
| 4574 | context.earlyInjectedReportObserved = true; | ||
| 4575 | } | ||
| 4576 | yield* appendText(context, "assistant", text, update.messageId); | ||
| 4577 | } | ||
| 4578 | break; | ||
| 4579 | } | ||
| 4580 | case "agent_thought_chunk": { | ||
| 4581 | const text = acpContentBlockDisplayText(update.content); | ||
| 4582 | if (text !== undefined) { | ||
| 4583 | yield* appendText(context, "reasoning", text, update.messageId); | ||
| 4584 | } | ||
| 4585 | break; | ||
| 4586 | } | ||
| 4587 | case "user_message": | ||
| 4588 | case "agent_message": | ||
| 4589 | case "agent_thought": { | ||
| 4590 | if (update.content === undefined) break; | ||
| 4591 | const text = (update.content ?? []) | ||
| 4592 | .flatMap((content) => { | ||
| 4593 | const display = acpContentBlockDisplayText(content); | ||
| 4594 | return display === undefined ? [] : [display]; | ||
| 4595 | }) | ||
| 4596 | .join("\n"); | ||
| 4597 | yield* replaceText( | ||
| 4598 | context, | ||
| 4599 | update.sessionUpdate === "user_message" | ||
| 4600 | ? "user" | ||
| 4601 | : update.sessionUpdate === "agent_message" | ||
| 4602 | ? "assistant" | ||
| 4603 | : "reasoning", | ||
| 4604 | text, | ||
| 4605 | update.messageId, | ||
| 4606 | ); | ||
| 4607 | break; | ||
| 4608 | } | ||
| 4609 | case "tool_call_content_chunk": { | ||
| 4610 | const text = | ||
| 4611 | update.content.type === "content" | ||
| 4612 | ? acpContentBlockDisplayText(update.content.content) | ||
| 4613 | : update.content.type === "diff" | ||
| 4614 | ? "changes" in update.content | ||
| 4615 | ? update.content.patch?.text | ||
| 4616 | : update.content.newText | ||
| 4617 | : undefined; | ||
| 4618 | if (text !== undefined) { | ||
| 4619 | const previous = context.tools.get(update.toolCallId) ?? { | ||
| 4620 | toolCallId: update.toolCallId, | ||
| 4621 | status: "inProgress" as const, | ||
| 4622 | data: { toolCallId: update.toolCallId }, | ||
| 4623 | }; | ||
| 4624 | yield* emitTool(context, appendToolOutputText(previous, text)); | ||
| 4625 | } | ||
| 4626 | break; | ||
| 4627 | } | ||
| 4628 | case "compaction_summary_chunk": { | ||
| 4629 | const text = acpContentBlockDisplayText(update.content); | ||
| 4630 | if (text !== undefined) { | ||
| 4631 | const toolCallId = `acp-compaction:${update.compactionId}`; | ||
| 4632 | const previous = context.tools.get(toolCallId) ?? { | ||
| 4633 | toolCallId, | ||
| 4634 | kind: "think", | ||
| 4635 | title: "Compact context", | ||
| 4636 | status: "inProgress" as const, | ||
| 4637 | data: { toolCallId }, | ||
| 4638 | }; | ||
| 4639 | yield* emitTool(context, appendToolOutputText(previous, text)); | ||
| 4640 | } | ||
| 4641 | break; | ||
| 4642 | } | ||
| 4643 | case "user_message_chunk": { | ||
| 4644 | if (update.content.type === "text" && flavor.extractBackgroundToolMutation) { | ||
| 4645 | const mutations = flavor.extractBackgroundToolMutation(update.content.text); | ||
| 4646 | const terminalTaskIds = mutations | ||
| 4647 | .filter((mutation) => mutation.status !== "running") | ||
| 4648 | .map((mutation) => mutation.taskId); | ||
| 4649 | const reportArrivedBeforeNotice = | ||
| 4650 | context.earlyInjectedReportObserved && terminalTaskIds.length > 0; | ||
| 4651 | if (reportArrivedBeforeNotice) { | ||
| 4652 | context.earlyInjectedReportObserved = false; | ||
| 4653 | yield* clearMidTurnUnreportedTaskIds(terminalTaskIds); | ||
| 4654 | } | ||
| 4655 | for (const mutation of mutations) { | ||
| 4656 | const toolCallId = context.toolCallIdsByBackgroundTaskId.get(mutation.taskId); | ||
| 4657 | const previous = | ||
| 4658 | toolCallId !== undefined ? context.tools.get(toolCallId) : undefined; | ||
| 4659 | if (previous !== undefined) { | ||
| 4660 | let nextStatus = | ||
| 4661 | mutation.status === "running" | ||
| 4662 | ? ("inProgress" as const) | ||
| 4663 | : mutation.status === "failed" | ||
| 4664 | ? ("failed" as const) | ||
| 4665 | : ("completed" as const); | ||
| 4666 | // End notices typically omit full stdout (no get_command mention | ||
| 4667 | // either). Keep the tool running and hold finalize until | ||
| 4668 | // TaskOutput hydrates, or the safety timer force-completes. | ||
| 4669 | if (nextStatus !== "inProgress") { | ||
| 4670 | yield* markAwaitingBackgroundHydration(context, mutation.taskId); | ||
| 4671 | nextStatus = "inProgress"; | ||
| 4672 | } | ||
| 4673 | yield* emitTool( | ||
| 4674 | context, | ||
| 4675 | appendToolOutputText( | ||
| 4676 | { ...previous, status: nextStatus }, | ||
| 4677 | mutation.appendOutput, | ||
| 4678 | ), | ||
| 4679 | ); | ||
| 4680 | } | ||
| 4681 | // After emitTool: the hydration hold keeps the tool row at | ||
| 4682 | // inProgress past an end notice, but the offer gate must see | ||
| 4683 | // the mutation's semantic status. Also tracks monitors that | ||
| 4684 | // never surfaced a tool_call row at all. | ||
| 4685 | yield* applyBackgroundTaskMutationRunning(mutation); | ||
| 4686 | if (mutation.status !== "running" && !reportArrivedBeforeNotice) { | ||
| 4687 | yield* markPendingInjectedReport(context, mutation.taskId); | ||
| 4688 | } | ||
| 4689 | } | ||
| 4690 | } | ||
| 4691 | if (update.content.type === "text" && flavor.extractSubagentEndNotice) { | ||
| 4692 | const notice = flavor.extractSubagentEndNotice(update.content.text); | ||
| 4693 | const subagent = | ||
| 4694 | notice !== undefined | ||
| 4695 | ? context.subagentsBySessionId.get(notice.childSessionId) | ||
| 4696 | : undefined; | ||
| 4697 | if ( | ||
| 4698 | notice !== undefined && | ||
| 4699 | subagent !== undefined && | ||
| 4700 | (subagent.task.status === "running" || subagent.task.status === "pending") | ||
| 4701 | ) { | ||
| 4702 | // The agent is free to answer with text only and never call | ||
| 4703 | // get_command_or_subagent_output; without this, the subagent | ||
| 4704 | // row stays running and holds deferred finalize open forever. | ||
| 4705 | yield* emitSubagent(context, { | ||
| 4706 | nativeTaskId: subagent.task.nativeTaskRef?.nativeId ?? notice.childSessionId, | ||
| 4707 | prompt: subagent.task.prompt, | ||
| 4708 | title: subagent.task.title, | ||
| 4709 | model: subagent.task.model, | ||
| 4710 | status: notice.status, | ||
| 4711 | childSessionId: notice.childSessionId, | ||
| 4712 | result: null, | ||
| 4713 | suppressNormalTool: true, | ||
| 4714 | }); | ||
| 4715 | } | ||
| 4716 | } | ||
| 4717 | // startTurn already projects the submitted prompt. Active ACP | ||
| 4718 | // user chunks are transport echoes or injected background | ||
| 4719 | // notices, so only their semantic mutations belong in the turn. | ||
| 4720 | break; | ||
| 4721 | } | ||
| 4722 | default: { | ||
| 4723 | const parsed = parseSessionUpdateEvent(notification); | ||
| 4724 | for (const event of parsed.events) { | ||
| 4725 | if (event._tag === "ToolCallUpdated") { | ||
| 4726 | yield* emitTool(context, event.toolCall); | ||
| 4727 | } else if (event._tag === "PlanUpdated") { | ||
| 4728 | yield* emitPlan(context, event.payload); | ||
| 4729 | } else if (event._tag === "UnknownUpdate") { | ||
| 4730 | yield* emitTool(context, { | ||
| 4731 | toolCallId: `${context.nativeTurnId}:unsupported:${event.updateType}`, | ||
| 4732 | kind: "other", | ||
| 4733 | title: `Unsupported ACP update: ${event.updateType}`, | ||
| 4734 | status: "completed", | ||
| 4735 | data: { updateType: event.updateType }, | ||
| 4736 | }); | ||
| 4737 | } | ||
| 4738 | } | ||
| 4739 | } | ||
| 4740 | } | ||
| 4741 | // Keep deferred finalize quiet-window fresh while wake traffic lands. | ||
| 4742 | yield* rearmDeferredFinalize(context); | ||
| 4743 | }); | ||
| 4744 | |||
| 4745 | const activeContext = Effect.gen(function* () { | ||
| 4746 | const context = yield* Ref.get(activeTurn); | ||
| 4747 | if (context === null) { | ||
| 4748 | return yield* new EffectAcpErrors.AcpTransportError({ | ||
| 4749 | detail: "ACP agent requested input without an active turn", | ||
| 4750 | cause: "No active ACP turn", | ||
| 4751 | }); | ||
| 4752 | } | ||
| 4753 | return context; | ||
| 4754 | }); | ||
| 4755 | |||
| 4756 | const beginApprovalRequest = Effect.fnUntraced(function* ( | ||
| 4757 | context: ActiveAcpTurn, | ||
| 4758 | params: EffectAcpSchema.RequestPermissionRequest, | ||
| 4759 | generation: number, | ||
| 4760 | transportRequestId: string, | ||
| 4761 | ) { | ||
| 4762 | yield* closeTextStreams(context); | ||
| 4763 | const parsed = parsePermissionRequest(params); | ||
| 4764 | const approvalOptions = flavor.approvalOptions?.(params); | ||
| 4765 | const nativeRequestId = params.toolCall.toolCallId; | ||
| 4766 | const requestId = yield* idAllocator.allocate.runtimeRequest({ | ||
| 4767 | driver, | ||
| 4768 | providerTurnId: context.providerTurnId, | ||
| 4769 | nativeRequestId, | ||
| 4770 | }); | ||
| 4771 | const decision = yield* Deferred.make<ProviderApprovalDecision>(); | ||
| 4772 | const nativeResponseAcknowledgement = yield* Deferred.make< | ||
| 4773 | void, | ||
| 4774 | EffectAcpErrors.AcpError | ||
| 4775 | >(); | ||
| 4776 | yield* registerNativeResponseAcknowledgement( | ||
| 4777 | generation, | ||
| 4778 | transportRequestId, | ||
| 4779 | nativeResponseAcknowledgement, | ||
| 4780 | ); | ||
| 4781 | const now = yield* DateTime.now; | ||
| 4782 | const nodeId = idAllocator.derive.approvalNode({ requestId }); | ||
| 4783 | const requestKind = providerRequestKind(parsed.kind); | ||
| 4784 | const nativeItemRef = { driver, nativeId: nativeRequestId, strength: "weak" as const }; | ||
| 4785 | const ordinal = yield* resolveItemOrdinal( | ||
| 4786 | context, | ||
| 4787 | `${context.nativeTurnId}:approval:${nativeRequestId}`, | ||
| 4788 | ); | ||
| 4789 | const runtimeRequest: OrchestrationV2RuntimeRequest = { | ||
| 4790 | id: requestId, | ||
| 4791 | nodeId, | ||
| 4792 | providerTurnId: context.providerTurnId, | ||
| 4793 | nativeRequestRef: nativeItemRef, | ||
| 4794 | kind: requestKind, | ||
| 4795 | status: "pending", | ||
| 4796 | responseCapability: { | ||
| 4797 | type: "live", | ||
| 4798 | providerSessionId: input.providerSessionId, | ||
| 4799 | }, | ||
| 4800 | createdAt: now, | ||
| 4801 | resolvedAt: null, | ||
| 4802 | }; | ||
| 4803 | const node: OrchestrationV2ExecutionNode = { | ||
| 4804 | id: nodeId, | ||
| 4805 | threadId: context.input.threadId, | ||
| 4806 | runId: context.input.runId, | ||
| 4807 | parentNodeId: context.input.rootNodeId, | ||
| 4808 | rootNodeId: context.input.rootNodeId, | ||
| 4809 | kind: "approval_request", | ||
| 4810 | status: "waiting", | ||
| 4811 | countsForRun: false, | ||
| 4812 | providerThreadId: context.input.providerThread.id, | ||
| 4813 | providerTurnId: context.providerTurnId, | ||
| 4814 | nativeItemRef, | ||
| 4815 | runtimeRequestId: requestId, | ||
| 4816 | checkpointScopeId: null, | ||
| 4817 | startedAt: now, | ||
| 4818 | completedAt: null, | ||
| 4819 | }; | ||
| 4820 | const turnItem: OrchestrationV2TurnItem = { | ||
| 4821 | id: idAllocator.derive.approvalTurnItem({ requestId }), | ||
| 4822 | threadId: context.input.threadId, | ||
| 4823 | runId: context.input.runId, | ||
| 4824 | nodeId, | ||
| 4825 | providerThreadId: context.input.providerThread.id, | ||
| 4826 | providerTurnId: context.providerTurnId, | ||
| 4827 | nativeItemRef, | ||
| 4828 | parentItemId: null, | ||
| 4829 | ordinal, | ||
| 4830 | status: "waiting", | ||
| 4831 | title: null, | ||
| 4832 | startedAt: now, | ||
| 4833 | completedAt: null, | ||
| 4834 | updatedAt: now, | ||
| 4835 | type: "approval_request", | ||
| 4836 | requestId, | ||
| 4837 | requestKind, | ||
| 4838 | ...(parsed.detail === undefined ? {} : { prompt: parsed.detail }), | ||
| 4839 | ...(approvalOptions === undefined ? {} : { options: approvalOptions }), | ||
| 4840 | }; | ||
| 4841 | yield* Ref.update(pendingRuntimeRequests, (current) => { | ||
| 4842 | const updated = new Map(current); | ||
| 4843 | updated.set(String(requestId), { | ||
| 4844 | type: "approval", | ||
| 4845 | generation, | ||
| 4846 | nativeResponseAcknowledgement, | ||
| 4847 | requestId, | ||
| 4848 | transportRequestId, | ||
| 4849 | decision, | ||
| 4850 | runtimeRequest, | ||
| 4851 | node, | ||
| 4852 | turnItem, | ||
| 4853 | }); | ||
| 4854 | return updated; | ||
| 4855 | }); | ||
| 4856 | yield* emitProviderEvent({ | ||
| 4857 | type: "node.updated", | ||
| 4858 | driver, | ||
| 4859 | node, | ||
| 4860 | }); | ||
| 4861 | yield* emitProviderEvent({ | ||
| 4862 | type: "runtime_request.updated", | ||
| 4863 | driver, | ||
| 4864 | threadId: context.input.threadId, | ||
| 4865 | runtimeRequest, | ||
| 4866 | }); | ||
| 4867 | yield* emitProviderEvent({ | ||
| 4868 | type: "turn_item.updated", | ||
| 4869 | driver, | ||
| 4870 | turnItem, | ||
| 4871 | }); | ||
| 4872 | return { | ||
| 4873 | context, | ||
| 4874 | decision, | ||
| 4875 | nativeResponseAcknowledgement, | ||
| 4876 | requestId, | ||
| 4877 | transportRequestId, | ||
| 4878 | } as const; | ||
| 4879 | }); | ||
| 4880 | |||
| 4881 | const beginUserInputRequest = Effect.fnUntraced(function* ( | ||
| 4882 | request: AcpAdapterV2UserInputRequest, | ||
| 4883 | generation: number, | ||
| 4884 | transportRequestId: string, | ||
| 4885 | ) { | ||
| 4886 | const context = yield* activeContext; | ||
| 4887 | yield* closeTextStreams(context); | ||
| 4888 | const requestId = yield* idAllocator.allocate.runtimeRequest({ | ||
| 4889 | driver, | ||
| 4890 | providerTurnId: context.providerTurnId, | ||
| 4891 | nativeRequestId: request.nativeRequestId, | ||
| 4892 | }); | ||
| 4893 | const answers = yield* Deferred.make<ProviderUserInputAnswers | null>(); | ||
| 4894 | const nativeResponseAcknowledgement = yield* Deferred.make< | ||
| 4895 | void, | ||
| 4896 | EffectAcpErrors.AcpError | ||
| 4897 | >(); | ||
| 4898 | yield* registerNativeResponseAcknowledgement( | ||
| 4899 | generation, | ||
| 4900 | transportRequestId, | ||
| 4901 | nativeResponseAcknowledgement, | ||
| 4902 | ); | ||
| 4903 | const now = yield* DateTime.now; | ||
| 4904 | const nodeId = providerNodeId(request.nativeItemId); | ||
| 4905 | const turnItemId = providerTurnItemId(request.nativeItemId); | ||
| 4906 | const nativeItemRef = { | ||
| 4907 | driver, | ||
| 4908 | nativeId: request.nativeItemId, | ||
| 4909 | strength: "weak" as const, | ||
| 4910 | }; | ||
| 4911 | const ordinal = yield* resolveItemOrdinal(context, request.nativeItemId); | ||
| 4912 | const runtimeRequest: OrchestrationV2RuntimeRequest = { | ||
| 4913 | id: requestId, | ||
| 4914 | nodeId, | ||
| 4915 | providerTurnId: context.providerTurnId, | ||
| 4916 | nativeRequestRef: { | ||
| 4917 | driver, | ||
| 4918 | nativeId: request.nativeRequestId, | ||
| 4919 | strength: "weak", | ||
| 4920 | }, | ||
| 4921 | kind: "user_input", | ||
| 4922 | status: "pending", | ||
| 4923 | responseCapability: { | ||
| 4924 | type: "live", | ||
| 4925 | providerSessionId: input.providerSessionId, | ||
| 4926 | }, | ||
| 4927 | createdAt: now, | ||
| 4928 | resolvedAt: null, | ||
| 4929 | }; | ||
| 4930 | const node: OrchestrationV2ExecutionNode = { | ||
| 4931 | id: nodeId, | ||
| 4932 | threadId: context.input.threadId, | ||
| 4933 | runId: context.input.runId, | ||
| 4934 | parentNodeId: context.input.rootNodeId, | ||
| 4935 | rootNodeId: context.input.rootNodeId, | ||
| 4936 | kind: "user_input_request", | ||
| 4937 | status: "waiting", | ||
| 4938 | countsForRun: false, | ||
| 4939 | providerThreadId: context.input.providerThread.id, | ||
| 4940 | providerTurnId: context.providerTurnId, | ||
| 4941 | nativeItemRef, | ||
| 4942 | runtimeRequestId: requestId, | ||
| 4943 | checkpointScopeId: null, | ||
| 4944 | startedAt: now, | ||
| 4945 | completedAt: null, | ||
| 4946 | }; | ||
| 4947 | const turnItem: OrchestrationV2TurnItem = { | ||
| 4948 | id: turnItemId, | ||
| 4949 | threadId: context.input.threadId, | ||
| 4950 | runId: context.input.runId, | ||
| 4951 | nodeId, | ||
| 4952 | providerThreadId: context.input.providerThread.id, | ||
| 4953 | providerTurnId: context.providerTurnId, | ||
| 4954 | nativeItemRef, | ||
| 4955 | parentItemId: null, | ||
| 4956 | ordinal, | ||
| 4957 | status: "waiting", | ||
| 4958 | title: null, | ||
| 4959 | startedAt: now, | ||
| 4960 | completedAt: null, | ||
| 4961 | updatedAt: now, | ||
| 4962 | type: "user_input_request", | ||
| 4963 | requestId, | ||
| 4964 | questions: [...request.questions], | ||
| 4965 | }; | ||
| 4966 | yield* Ref.update(pendingRuntimeRequests, (current) => { | ||
| 4967 | const updated = new Map(current); | ||
| 4968 | updated.set(String(requestId), { | ||
| 4969 | type: "user_input", | ||
| 4970 | generation, | ||
| 4971 | nativeResponseAcknowledgement, | ||
| 4972 | requestId, | ||
| 4973 | transportRequestId, | ||
| 4974 | answers, | ||
| 4975 | runtimeRequest, | ||
| 4976 | node, | ||
| 4977 | turnItem, | ||
| 4978 | }); | ||
| 4979 | return updated; | ||
| 4980 | }); | ||
| 4981 | yield* emitProviderEvent({ | ||
| 4982 | type: "node.updated", | ||
| 4983 | driver, | ||
| 4984 | node, | ||
| 4985 | }); | ||
| 4986 | yield* emitProviderEvent({ | ||
| 4987 | type: "runtime_request.updated", | ||
| 4988 | driver, | ||
| 4989 | threadId: context.input.threadId, | ||
| 4990 | runtimeRequest, | ||
| 4991 | }); | ||
| 4992 | yield* emitProviderEvent({ | ||
| 4993 | type: "turn_item.updated", | ||
| 4994 | driver, | ||
| 4995 | turnItem, | ||
| 4996 | }); | ||
| 4997 | return { | ||
| 4998 | answers, | ||
| 4999 | context, | ||
| 5000 | nativeResponseAcknowledgement, | ||
| 5001 | requestId, | ||
| 5002 | transportRequestId, | ||
| 5003 | } as const; | ||
| 5004 | }); | ||
| 5005 | |||
| 5006 | const requestUserInputWithAdmission = ( | ||
| 5007 | generation: number, | ||
| 5008 | request: Effect.Effect<AcpAdapterV2UserInputRequest>, | ||
| 5009 | transportRequestId: string, | ||
| 5010 | ) => | ||
| 5011 | runRuntimeCallbackAtGeneration( | ||
| 5012 | generation, | ||
| 5013 | request.pipe( | ||
| 5014 | Effect.flatMap((value) => | ||
| 5015 | beginUserInputRequest(value, generation, transportRequestId), | ||
| 5016 | ), | ||
| 5017 | ), | ||
| 5018 | ).pipe( | ||
| 5019 | Effect.flatMap((pending) => { | ||
| 5020 | if (Option.isNone(pending)) return Effect.never; | ||
| 5021 | const { answers, requestId, transportRequestId } = pending.value; | ||
| 5022 | return Deferred.await(answers).pipe( | ||
| 5023 | Effect.flatMap((result) => | ||
| 5024 | runRuntimeCallbackAtGeneration(generation, Effect.succeed(result)).pipe( | ||
| 5025 | Effect.flatMap((checked) => | ||
| 5026 | Option.isSome(checked) | ||
| 5027 | ? Effect.succeed({ | ||
| 5028 | acknowledgeNativeResponse: acknowledgeNativeResponse( | ||
| 5029 | generation, | ||
| 5030 | transportRequestId, | ||
| 5031 | ), | ||
| 5032 | answers: checked.value, | ||
| 5033 | }) | ||
| 5034 | : Effect.never, | ||
| 5035 | ), | ||
| 5036 | ), | ||
| 5037 | ), | ||
| 5038 | Effect.ensuring( | ||
| 5039 | runRuntimeCallbackAtGeneration( | ||
| 5040 | generation, | ||
| 5041 | Effect.gen(function* () { | ||
| 5042 | yield* Ref.update(pendingRuntimeRequests, (current) => { | ||
| 5043 | const updated = new Map(current); | ||
| 5044 | updated.delete(String(requestId)); | ||
| 5045 | return updated; | ||
| 5046 | }); | ||
| 5047 | }), | ||
| 5048 | ).pipe(Effect.asVoid), | ||
| 5049 | ), | ||
| 5050 | ); | ||
| 5051 | }), | ||
| 5052 | Effect.mapError( | ||
| 5053 | (cause) => | ||
| 5054 | new EffectAcpErrors.AcpTransportError({ | ||
| 5055 | detail: "Failed to handle ACP user input request", | ||
| 5056 | cause, | ||
| 5057 | }), | ||
| 5058 | ), | ||
| 5059 | ); | ||
| 5060 | |||
| 5061 | const cancelPendingRuntimeRequests = Effect.fnUntraced(function* () { | ||
| 5062 | const requests = yield* Ref.modify(pendingRuntimeRequests, (current) => [ | ||
| 5063 | [...current.values()], | ||
| 5064 | new Map<string, PendingRuntimeRequest>(), | ||
| 5065 | ]); | ||
| 5066 | if (requests.length === 0) return; | ||
| 5067 | |||
| 5068 | const now = yield* DateTime.now; | ||
| 5069 | yield* Effect.forEach( | ||
| 5070 | requests, | ||
| 5071 | (request) => | ||
| 5072 | Effect.gen(function* () { | ||
| 5073 | const cancelled = yield* request.type === "approval" | ||
| 5074 | ? Deferred.succeed(request.decision, "cancel") | ||
| 5075 | : Deferred.succeed(request.answers, null); | ||
| 5076 | if (!cancelled) return; | ||
| 5077 | |||
| 5078 | yield* emitProviderEvent({ | ||
| 5079 | type: "runtime_request.updated", | ||
| 5080 | driver, | ||
| 5081 | threadId: request.node.threadId, | ||
| 5082 | runtimeRequest: { | ||
| 5083 | ...request.runtimeRequest, | ||
| 5084 | status: "cancelled", | ||
| 5085 | resolvedAt: now, | ||
| 5086 | }, | ||
| 5087 | }); | ||
| 5088 | yield* emitProviderEvent({ | ||
| 5089 | type: "node.updated", | ||
| 5090 | driver, | ||
| 5091 | node: { | ||
| 5092 | ...request.node, | ||
| 5093 | status: "cancelled", | ||
| 5094 | completedAt: now, | ||
| 5095 | }, | ||
| 5096 | }); | ||
| 5097 | yield* emitProviderEvent({ | ||
| 5098 | type: "turn_item.updated", | ||
| 5099 | driver, | ||
| 5100 | turnItem: { | ||
| 5101 | ...request.turnItem, | ||
| 5102 | status: "cancelled", | ||
| 5103 | completedAt: now, | ||
| 5104 | updatedAt: now, | ||
| 5105 | }, | ||
| 5106 | }); | ||
| 5107 | }), | ||
| 5108 | { concurrency: 1, discard: true }, | ||
| 5109 | ); | ||
| 5110 | }); | ||
| 5111 | |||
| 5112 | /** | ||
| 5113 | * Mutate a carryover subagent's in-memory task status without projecting. | ||
| 5114 | * Used after a non-completed root whose original run subscriber is no | ||
| 5115 | * longer guaranteed to ingest the terminal event. | ||
| 5116 | */ | ||
| 5117 | const mutateCarryoverSubagentStatus = Effect.fnUntraced(function* ( | ||
| 5118 | subagent: ActiveAcpSubagent, | ||
| 5119 | status: OrchestrationV2Subagent["status"], | ||
| 5120 | resultOverride?: string | null, | ||
| 5121 | ) { | ||
| 5122 | const now = yield* DateTime.now; | ||
| 5123 | const result = | ||
| 5124 | resultOverride !== undefined && resultOverride !== null && resultOverride.length > 0 | ||
| 5125 | ? resultOverride | ||
| 5126 | : (subagent.task.result ?? (subagent.assistantText || null)); | ||
| 5127 | const completedAt = acpSubagentStatusIsTerminal(status) ? now : null; | ||
| 5128 | subagent.task = { | ||
| 5129 | ...subagent.task, | ||
| 5130 | status, | ||
| 5131 | result, | ||
| 5132 | completedAt, | ||
| 5133 | updatedAt: now, | ||
| 5134 | }; | ||
| 5135 | }); | ||
| 5136 | |||
| 5137 | /** | ||
| 5138 | * Project a carryover subagent status change without an ActiveAcpTurn. | ||
| 5139 | * Shared by completed-root post-settle completion, deferred attach, and | ||
| 5140 | * Direct Stop terminalization. | ||
| 5141 | */ | ||
| 5142 | const projectCarryoverSubagentStatus = Effect.fnUntraced(function* ( | ||
| 5143 | subagent: ActiveAcpSubagent, | ||
| 5144 | status: OrchestrationV2Subagent["status"], | ||
| 5145 | resultOverride?: string | null, | ||
| 5146 | ) { | ||
| 5147 | yield* mutateCarryoverSubagentStatus(subagent, status, resultOverride); | ||
| 5148 | const now = subagent.task.updatedAt; | ||
| 5149 | const nativeTaskId = subagent.task.nativeTaskRef?.nativeId ?? subagent.task.id; | ||
| 5150 | const nativeItemRef = { | ||
| 5151 | driver, | ||
| 5152 | nativeId: nativeTaskId, | ||
| 5153 | strength: "strong" as const, | ||
| 5154 | }; | ||
| 5155 | const parentProviderThreadId = subagent.parentProviderThreadId; | ||
| 5156 | const result = subagent.task.result; | ||
| 5157 | const completedAt = subagent.task.completedAt; | ||
| 5158 | yield* emitProviderEvent({ | ||
| 5159 | type: "node.updated", | ||
| 5160 | driver, | ||
| 5161 | node: { | ||
| 5162 | id: subagent.task.id, | ||
| 5163 | threadId: subagent.task.threadId, | ||
| 5164 | runId: subagent.task.runId, | ||
| 5165 | parentNodeId: subagent.task.parentNodeId, | ||
| 5166 | rootNodeId: subagent.task.parentNodeId, | ||
| 5167 | kind: "subagent", | ||
| 5168 | status, | ||
| 5169 | countsForRun: false, | ||
| 5170 | providerThreadId: parentProviderThreadId, | ||
| 5171 | providerTurnId: subagent.providerTurnId, | ||
| 5172 | nativeItemRef, | ||
| 5173 | runtimeRequestId: null, | ||
| 5174 | checkpointScopeId: null, | ||
| 5175 | startedAt: subagent.task.startedAt, | ||
| 5176 | completedAt, | ||
| 5177 | }, | ||
| 5178 | }); | ||
| 5179 | yield* emitProviderEvent({ | ||
| 5180 | type: "node.updated", | ||
| 5181 | driver, | ||
| 5182 | node: { | ||
| 5183 | id: subagent.childRootNodeId, | ||
| 5184 | threadId: subagent.childThreadId, | ||
| 5185 | runId: null, | ||
| 5186 | parentNodeId: null, | ||
| 5187 | rootNodeId: subagent.childRootNodeId, | ||
| 5188 | kind: "root_turn", | ||
| 5189 | status, | ||
| 5190 | countsForRun: false, | ||
| 5191 | providerThreadId: subagent.task.providerThreadId, | ||
| 5192 | providerTurnId: null, | ||
| 5193 | nativeItemRef, | ||
| 5194 | runtimeRequestId: null, | ||
| 5195 | checkpointScopeId: null, | ||
| 5196 | startedAt: subagent.task.startedAt, | ||
| 5197 | completedAt, | ||
| 5198 | }, | ||
| 5199 | }); | ||
| 5200 | yield* emitProviderEvent({ | ||
| 5201 | type: "subagent.updated", | ||
| 5202 | driver, | ||
| 5203 | subagent: subagent.task, | ||
| 5204 | }); | ||
| 5205 | yield* emitProviderEvent({ | ||
| 5206 | type: "turn_item.updated", | ||
| 5207 | driver, | ||
| 5208 | turnItem: { | ||
| 5209 | id: subagent.turnItemId, | ||
| 5210 | threadId: subagent.task.threadId, | ||
| 5211 | runId: subagent.task.runId, | ||
| 5212 | nodeId: subagent.task.id, | ||
| 5213 | providerThreadId: parentProviderThreadId, | ||
| 5214 | providerTurnId: subagent.providerTurnId, | ||
| 5215 | nativeItemRef, | ||
| 5216 | parentItemId: null, | ||
| 5217 | ordinal: subagent.turnItemOrdinal, | ||
| 5218 | status, | ||
| 5219 | title: subagent.task.title, | ||
| 5220 | startedAt: subagent.task.startedAt, | ||
| 5221 | completedAt, | ||
| 5222 | updatedAt: now, | ||
| 5223 | type: "subagent", | ||
| 5224 | subagentId: subagent.task.id, | ||
| 5225 | origin: "provider_native", | ||
| 5226 | driver, | ||
| 5227 | providerInstanceId: subagent.task.providerInstanceId, | ||
| 5228 | childThreadId: subagent.childThreadId, | ||
| 5229 | prompt: subagent.task.prompt, | ||
| 5230 | result, | ||
| 5231 | }, | ||
| 5232 | }); | ||
| 5233 | if (acpSubagentStatusIsTerminal(status)) { | ||
| 5234 | subagent.terminalStatusProjected = true; | ||
| 5235 | } | ||
| 5236 | }); | ||
| 5237 | |||
| 5238 | /** | ||
| 5239 | * Update a carryover subagent matched by native task id or child session | ||
| 5240 | * id. Post-settle completions must flip the pin in hasPendingBackgroundWork | ||
| 5241 | * without waiting for a new user turn to consume carryover. | ||
| 5242 | * When `project` is false, only the in-memory carryover status advances | ||
| 5243 | * (the next attach projects the terminal state). | ||
| 5244 | * If status already matches but projection was deferred, a later | ||
| 5245 | * project:true caller still projects once via terminalStatusProjected. | ||
| 5246 | */ | ||
| 5247 | const updateCarryoverSubagentStatus = Effect.fnUntraced(function* ( | ||
| 5248 | nativeIdOrChildSessionId: string, | ||
| 5249 | status: OrchestrationV2Subagent["status"], | ||
| 5250 | result?: string | null, | ||
| 5251 | options?: { readonly project?: boolean }, | ||
| 5252 | ) { | ||
| 5253 | const carryover = yield* Ref.get(carryoverSubagents); | ||
| 5254 | if (carryover === null) return false; | ||
| 5255 | const match = carryover.subagents.find((subagent) => { | ||
| 5256 | const nativeId = subagent.task.nativeTaskRef?.nativeId ?? String(subagent.task.id); | ||
| 5257 | return ( | ||
| 5258 | nativeId === nativeIdOrChildSessionId || | ||
| 5259 | subagent.childSessionId === nativeIdOrChildSessionId | ||
| 5260 | ); | ||
| 5261 | }); | ||
| 5262 | if (match === undefined) return false; | ||
| 5263 | const shouldProject = options?.project !== false; | ||
| 5264 | if (match.task.status === status) { | ||
| 5265 | // Already at this status: only project if requested and not yet done. | ||
| 5266 | if (!shouldProject || match.terminalStatusProjected) { | ||
| 5267 | return true; | ||
| 5268 | } | ||
| 5269 | yield* projectCarryoverSubagentStatus(match, status, result); | ||
| 5270 | return true; | ||
| 5271 | } | ||
| 5272 | // Only advance nonterminal entries; do not resurrect a terminal one. | ||
| 5273 | if (match.task.status !== "running" && match.task.status !== "pending") { | ||
| 5274 | return false; | ||
| 5275 | } | ||
| 5276 | if (!shouldProject) { | ||
| 5277 | yield* mutateCarryoverSubagentStatus(match, status, result); | ||
| 5278 | } else { | ||
| 5279 | yield* projectCarryoverSubagentStatus(match, status, result); | ||
| 5280 | } | ||
| 5281 | return true; | ||
| 5282 | }); | ||
| 5283 | |||
| 5284 | const finishSubagentFromNotice = Effect.fnUntraced(function* (notice: { | ||
| 5285 | readonly childSessionId: string; | ||
| 5286 | readonly status: "completed" | "failed" | "cancelled"; | ||
| 5287 | readonly result: string | null; | ||
| 5288 | }) { | ||
| 5289 | const context = yield* Ref.get(activeTurn); | ||
| 5290 | const subagent = | ||
| 5291 | context === null ? undefined : context.subagentsBySessionId.get(notice.childSessionId); | ||
| 5292 | const known = | ||
| 5293 | subagent ?? | ||
| 5294 | (yield* Ref.get(carryoverSubagents))?.subagents.find( | ||
| 5295 | (candidate) => | ||
| 5296 | candidate.childSessionId === notice.childSessionId || | ||
| 5297 | candidate.task.nativeTaskRef?.nativeId === notice.childSessionId, | ||
| 5298 | ); | ||
| 5299 | if (known !== undefined && acpSubagentStatusBlocksTurnSettlement(known.task.status)) { | ||
| 5300 | yield* recordWakeReport(notice.childSessionId, { | ||
| 5301 | kind: "subagent", | ||
| 5302 | label: known.task.title ?? known.task.prompt, | ||
| 5303 | outcome: notice.status, | ||
| 5304 | childThreadId: known.childThreadId, | ||
| 5305 | }); | ||
| 5306 | } | ||
| 5307 | if (context !== null && subagent !== undefined && !context.finalized) { | ||
| 5308 | if (!acpSubagentStatusBlocksTurnSettlement(subagent.task.status)) return; | ||
| 5309 | yield* emitSubagent(context, { | ||
| 5310 | nativeTaskId: subagent.task.nativeTaskRef?.nativeId ?? notice.childSessionId, | ||
| 5311 | prompt: subagent.task.prompt, | ||
| 5312 | title: subagent.task.title, | ||
| 5313 | model: subagent.task.model, | ||
| 5314 | status: notice.status, | ||
| 5315 | childSessionId: notice.childSessionId, | ||
| 5316 | result: notice.result, | ||
| 5317 | suppressNormalTool: true, | ||
| 5318 | }); | ||
| 5319 | yield* rearmDeferredFinalize(context); | ||
| 5320 | return; | ||
| 5321 | } | ||
| 5322 | // The root turn already settled: the subagent is carryover. Project | ||
| 5323 | // its end while the completed root still owns the run. | ||
| 5324 | const carryover = yield* Ref.get(carryoverSubagents); | ||
| 5325 | yield* updateCarryoverSubagentStatus( | ||
| 5326 | notice.childSessionId, | ||
| 5327 | notice.status, | ||
| 5328 | notice.result, | ||
| 5329 | { | ||
| 5330 | project: | ||
| 5331 | carryover !== null && | ||
| 5332 | carryover.sessionId === (yield* Ref.get(activeSessionId)) && | ||
| 5333 | carryover.rootTerminalStatus === "completed", | ||
| 5334 | }, | ||
| 5335 | ); | ||
| 5336 | }); | ||
| 5337 | |||
| 5338 | applyFinalizedActiveTurnSubagentTerminal = Effect.fnUntraced(function* ( | ||
| 5339 | context: ActiveAcpTurn, | ||
| 5340 | notification: EffectAcpSchema.SessionNotification, | ||
| 5341 | ) { | ||
| 5342 | if (flavor.extractSubagentUpdate === undefined) return false; | ||
| 5343 | |||
| 5344 | const applyTerminal = Effect.fnUntraced(function* ( | ||
| 5345 | subagent: ActiveAcpSubagent, | ||
| 5346 | update: AcpAdapterV2SubagentUpdate, | ||
| 5347 | ) { | ||
| 5348 | if (!acpSubagentStatusIsTerminal(update.status)) return false; | ||
| 5349 | if (acpSubagentStatusIsTerminal(subagent.task.status)) { | ||
| 5350 | if (subagent.terminalStatusProjected || context.finalizedStatus !== "completed") { | ||
| 5351 | return true; | ||
| 5352 | } | ||
| 5353 | } | ||
| 5354 | if (context.finalizedStatus === "completed") { | ||
| 5355 | yield* emitSubagent(context, update); | ||
| 5356 | } else { | ||
| 5357 | yield* mutateCarryoverSubagentStatus(subagent, update.status, update.result); | ||
| 5358 | } | ||
| 5359 | return true; | ||
| 5360 | }); | ||
| 5361 | |||
| 5362 | const sessionUpdate = notification.update; | ||
| 5363 | if ( | ||
| 5364 | sessionUpdate.sessionUpdate === "tool_call" || | ||
| 5365 | sessionUpdate.sessionUpdate === "tool_call_update" | ||
| 5366 | ) { | ||
| 5367 | for (const event of parseSessionUpdateEvent(notification).events) { | ||
| 5368 | if (event._tag !== "ToolCallUpdated") continue; | ||
| 5369 | const toolCall = flavor.normalizeToolCall?.(event.toolCall) ?? event.toolCall; | ||
| 5370 | const subagentUpdate = flavor.extractSubagentUpdate(toolCall); | ||
| 5371 | if ( | ||
| 5372 | subagentUpdate === undefined || | ||
| 5373 | !acpSubagentStatusIsTerminal(subagentUpdate.status) | ||
| 5374 | ) { | ||
| 5375 | continue; | ||
| 5376 | } | ||
| 5377 | const subagent = | ||
| 5378 | context.subagents.get(subagentUpdate.nativeTaskId) ?? | ||
| 5379 | (subagentUpdate.childSessionId === null | ||
| 5380 | ? undefined | ||
| 5381 | : context.subagentsBySessionId.get(subagentUpdate.childSessionId)) ?? | ||
| 5382 | context.subagentsBySessionId.get(notification.sessionId); | ||
| 5383 | if (subagent === undefined) continue; | ||
| 5384 | const nativeTaskId = | ||
| 5385 | subagent.task.nativeTaskRef?.nativeId ?? String(subagent.task.id); | ||
| 5386 | if ( | ||
| 5387 | subagentUpdate.nativeTaskId !== nativeTaskId && | ||
| 5388 | (subagentUpdate.childSessionId === null || | ||
| 5389 | (subagentUpdate.childSessionId !== subagent.childSessionId && | ||
| 5390 | subagentUpdate.childSessionId !== notification.sessionId)) | ||
| 5391 | ) { | ||
| 5392 | continue; | ||
| 5393 | } | ||
| 5394 | if (yield* applyTerminal(subagent, subagentUpdate)) return true; | ||
| 5395 | } | ||
| 5396 | } | ||
| 5397 | |||
| 5398 | if ( | ||
| 5399 | sessionUpdate.sessionUpdate === "user_message_chunk" && | ||
| 5400 | sessionUpdate.content.type === "text" && | ||
| 5401 | flavor.extractSubagentEndNotice !== undefined | ||
| 5402 | ) { | ||
| 5403 | const notice = flavor.extractSubagentEndNotice(sessionUpdate.content.text); | ||
| 5404 | const subagent = | ||
| 5405 | notice === undefined | ||
| 5406 | ? undefined | ||
| 5407 | : context.subagentsBySessionId.get(notice.childSessionId); | ||
| 5408 | if (notice !== undefined && subagent !== undefined) { | ||
| 5409 | return yield* applyTerminal(subagent, { | ||
| 5410 | nativeTaskId: subagent.task.nativeTaskRef?.nativeId ?? String(subagent.task.id), | ||
| 5411 | prompt: subagent.task.prompt, | ||
| 5412 | title: subagent.task.title, | ||
| 5413 | model: subagent.task.model, | ||
| 5414 | status: notice.status, | ||
| 5415 | childSessionId: notice.childSessionId, | ||
| 5416 | result: null, | ||
| 5417 | suppressNormalTool: true, | ||
| 5418 | }); | ||
| 5419 | } | ||
| 5420 | } | ||
| 5421 | |||
| 5422 | return false; | ||
| 5423 | }); | ||
| 5424 | |||
| 5425 | const projectDeferredCarryoverTerminals = Effect.fnUntraced(function* ( | ||
| 5426 | subagents: ReadonlyArray<ActiveAcpSubagent>, | ||
| 5427 | ) { | ||
| 5428 | for (const subagent of subagents) { | ||
| 5429 | if ( | ||
| 5430 | subagent.task.status === "running" || | ||
| 5431 | subagent.task.status === "pending" || | ||
| 5432 | subagent.terminalStatusProjected | ||
| 5433 | ) { | ||
| 5434 | continue; | ||
| 5435 | } | ||
| 5436 | yield* projectCarryoverSubagentStatus( | ||
| 5437 | subagent, | ||
| 5438 | subagent.task.status, | ||
| 5439 | subagent.task.result, | ||
| 5440 | ); | ||
| 5441 | } | ||
| 5442 | }); | ||
| 5443 | |||
| 5444 | /** | ||
| 5445 | * Direct Stop after a soft steer clears carryover without an active turn. | ||
| 5446 | * Emit the same interrupted terminal events terminalizeOpenRunOwnedItems | ||
| 5447 | * would have, context-free (no ActiveAcpTurn). | ||
| 5448 | */ | ||
| 5449 | const terminalizeCarryoverSubagents = Effect.fnUntraced(function* ( | ||
| 5450 | carryover: AcpCarryoverSubagents | null, | ||
| 5451 | ) { | ||
| 5452 | if (carryover === null) return; | ||
| 5453 | for (const subagent of carryover.subagents) { | ||
| 5454 | if (acpSubagentStatusIsTerminal(subagent.task.status)) { | ||
| 5455 | if (!subagent.terminalStatusProjected) { | ||
| 5456 | yield* projectCarryoverSubagentStatus( | ||
| 5457 | subagent, | ||
| 5458 | subagent.task.status, | ||
| 5459 | subagent.task.result, | ||
| 5460 | ); | ||
| 5461 | } | ||
| 5462 | continue; | ||
| 5463 | } | ||
| 5464 | yield* projectCarryoverSubagentStatus(subagent, "interrupted"); | ||
| 5465 | } | ||
| 5466 | }); | ||
| 5467 | |||
| 5468 | const projectAcpRuntimeSessionUpdateEffect = ( | ||
| 5469 | rawNotification: EffectAcpSchema.SessionNotification, | ||
| 5470 | ) => | ||
| 5471 | Effect.gen(function* () { | ||
| 5472 | if (clientTerminals !== undefined) { | ||
| 5473 | const embedded = embeddedTerminalIdsFromSessionUpdate(rawNotification); | ||
| 5474 | if (embedded !== undefined) { | ||
| 5475 | rememberEmbeddedTerminals({ | ||
| 5476 | ...embedded, | ||
| 5477 | sessionId: rawNotification.sessionId, | ||
| 5478 | }); | ||
| 5479 | } | ||
| 5480 | } | ||
| 5481 | const notification = | ||
| 5482 | clientTerminals === undefined | ||
| 5483 | ? rawNotification | ||
| 5484 | : resolveEmbeddedTerminalContent( | ||
| 5485 | rawNotification, | ||
| 5486 | clientTerminals.readOutputSnapshot, | ||
| 5487 | ); | ||
| 5488 | if (notification.update.sessionUpdate === "available_commands_update") { | ||
| 5489 | yield* ( | ||
| 5490 | flavor.onAvailableCommandsUpdate?.(notification.update.availableCommands) ?? | ||
| 5491 | Effect.void | ||
| 5492 | ); | ||
| 5493 | } | ||
| 5494 | if ( | ||
| 5495 | notification.update.sessionUpdate === "config_option_update" || | ||
| 5496 | notification.update.sessionUpdate === "current_mode_update" | ||
| 5497 | ) { | ||
| 5498 | yield* ( | ||
| 5499 | flavor.onSessionConfigurationUpdate?.( | ||
| 5500 | yield* runtime.getConfigOptions, | ||
| 5501 | yield* runtime.getModeState, | ||
| 5502 | ) ?? Effect.void | ||
| 5503 | ); | ||
| 5504 | } | ||
| 5505 | yield* handleSessionUpdate( | ||
| 5506 | flavor.normalizeSessionUpdate?.(notification) ?? notification, | ||
| 5507 | ); | ||
| 5508 | }).pipe( | ||
| 5509 | Effect.mapError( | ||
| 5510 | (cause) => | ||
| 5511 | new EffectAcpErrors.AcpTransportError({ | ||
| 5512 | detail: "Failed to project an ACP session update", | ||
| 5513 | cause, | ||
| 5514 | }), | ||
| 5515 | ), | ||
| 5516 | ); | ||
| 5517 | const projectAcpRuntimeSessionUpdate = ( | ||
| 5518 | handlerGeneration: number, | ||
| 5519 | rawNotification: EffectAcpSchema.SessionNotification, | ||
| 5520 | ) => | ||
| 5521 | runRuntimeCallbackAtGeneration( | ||
| 5522 | handlerGeneration, | ||
| 5523 | projectAcpRuntimeSessionUpdateEffect(rawNotification), | ||
| 5524 | ).pipe(Effect.asVoid); | ||
| 5525 | |||
| 5526 | // Falls back to the latest turn policy when no turn is active so | ||
| 5527 | // post-settle background work stays under the policy it started with. | ||
| 5528 | const clientPolicyContext = Effect.map(Ref.get(activeTurn), (context) => ({ | ||
| 5529 | policy: context?.input.runtimePolicy ?? latestRuntimePolicy, | ||
| 5530 | turnKey: context === null ? null : String(context.providerTurnId), | ||
| 5531 | })); | ||
| 5532 | |||
| 5533 | const denyClientRequest = (operation: string, disposition: "ask" | "deny") => | ||
| 5534 | Effect.logWarning("ACP client policy denied a client-mediated operation", { | ||
| 5535 | operation, | ||
| 5536 | disposition, | ||
| 5537 | }).pipe( | ||
| 5538 | Effect.andThen( | ||
| 5539 | Effect.fail( | ||
| 5540 | EffectAcpErrors.AcpRequestError.internalError( | ||
| 5541 | disposition === "ask" | ||
| 5542 | ? `The active T3 runtime policy requires approval for ${operation}. Request permission with session/request_permission before retrying.` | ||
| 5543 | : `The active T3 runtime policy does not allow ${operation}.`, | ||
| 5544 | ), | ||
| 5545 | ), | ||
| 5546 | ), | ||
| 5547 | ); | ||
| 5548 | |||
| 5549 | const guardClientTerminalCreate = clientPolicyContext.pipe( | ||
| 5550 | Effect.flatMap(({ policy, turnKey }) => { | ||
| 5551 | const disposition = acpClientExecuteDisposition(policy); | ||
| 5552 | if ( | ||
| 5553 | disposition === "allow" || | ||
| 5554 | (disposition === "ask" && clientPolicyGrants.allowsExecute(turnKey)) | ||
| 5555 | ) { | ||
| 5556 | return Effect.void; | ||
| 5557 | } | ||
| 5558 | return denyClientRequest("terminal/create", disposition); | ||
| 5559 | }), | ||
| 5560 | ); | ||
| 5561 | |||
| 5562 | const wireAcpRuntimeTerminalHandlers = Effect.fnUntraced(function* ( | ||
| 5563 | targetRuntime: AcpSessionRuntime.AcpSessionRuntime["Service"], | ||
| 5564 | ) { | ||
| 5565 | if (clientTerminals === undefined) return; | ||
| 5566 | yield* targetRuntime.handleCreateTerminal((request) => | ||
| 5567 | guardClientTerminalCreate.pipe(Effect.andThen(clientTerminals.create(request))), | ||
| 5568 | ); | ||
| 5569 | yield* targetRuntime.handleTerminalOutput(clientTerminals.output); | ||
| 5570 | yield* targetRuntime.handleTerminalWaitForExit(clientTerminals.waitForExit); | ||
| 5571 | yield* targetRuntime.handleTerminalKill(clientTerminals.kill); | ||
| 5572 | yield* targetRuntime.handleTerminalRelease(clientTerminals.release); | ||
| 5573 | }); | ||
| 5574 | |||
| 5575 | const wireAcpRuntimeMcpHandlers = Effect.fnUntraced(function* ( | ||
| 5576 | targetRuntime: AcpSessionRuntime.AcpSessionRuntime["Service"], | ||
| 5577 | mcpBridge: AcpMcpOverAcpBridge | undefined, | ||
| 5578 | ) { | ||
| 5579 | if (mcpBridge === undefined) return; | ||
| 5580 | const mapMcpBridgeError = Effect.mapError( | ||
| 5581 | (error: Error) => | ||
| 5582 | new EffectAcpErrors.AcpRequestError({ | ||
| 5583 | code: -32603, | ||
| 5584 | errorMessage: error.message, | ||
| 5585 | cause: error, | ||
| 5586 | }), | ||
| 5587 | ); | ||
| 5588 | yield* targetRuntime.handleMcpConnect((request) => | ||
| 5589 | mcpBridge.connect(request).pipe(mapMcpBridgeError), | ||
| 5590 | ); | ||
| 5591 | yield* targetRuntime.handleMcpMessage((request) => | ||
| 5592 | mcpBridge.message(request).pipe(mapMcpBridgeError), | ||
| 5593 | ); | ||
| 5594 | yield* targetRuntime.handleMcpNotification((request) => | ||
| 5595 | mcpBridge.notification(request).pipe(mapMcpBridgeError), | ||
| 5596 | ); | ||
| 5597 | yield* targetRuntime.handleMcpDisconnect((request) => | ||
| 5598 | mcpBridge.disconnect(request).pipe(mapMcpBridgeError), | ||
| 5599 | ); | ||
| 5600 | }); | ||
| 5601 | |||
| 5602 | const wireAcpRuntimeHandlers = Effect.fnUntraced(function* ( | ||
| 5603 | targetRuntime: AcpSessionRuntime.AcpSessionRuntime["Service"], | ||
| 5604 | handlerGeneration: number, | ||
| 5605 | handlerOptions: { | ||
| 5606 | readonly sessionUpdates?: boolean; | ||
| 5607 | readonly terminals?: boolean; | ||
| 5608 | readonly mcp?: boolean; | ||
| 5609 | } = {}, | ||
| 5610 | ) { | ||
| 5611 | yield* targetRuntime.getEvents().pipe( | ||
| 5612 | Stream.runForEach((event) => { | ||
| 5613 | if (event._tag === "EventStreamBarrier") { | ||
| 5614 | return Deferred.succeed(event.acknowledge, undefined).pipe(Effect.asVoid); | ||
| 5615 | } | ||
| 5616 | return runRuntimeCallbackAtGeneration( | ||
| 5617 | handlerGeneration, | ||
| 5618 | flavor.onSessionEvent?.(event) ?? Effect.void, | ||
| 5619 | ).pipe(Effect.asVoid); | ||
| 5620 | }), | ||
| 5621 | Effect.forkIn(runtimeScope ?? sessionScope), | ||
| 5622 | ); | ||
| 5623 | const requestUserInput = ( | ||
| 5624 | request: AcpAdapterV2UserInputRequest, | ||
| 5625 | requestContext: EffectAcpProtocol.AcpRequestContext, | ||
| 5626 | ) => | ||
| 5627 | requestUserInputWithAdmission( | ||
| 5628 | handlerGeneration, | ||
| 5629 | Effect.succeed(request), | ||
| 5630 | requestContext.requestId, | ||
| 5631 | ); | ||
| 5632 | // Without the capability no fs handler is registered, so a stray | ||
| 5633 | // request (OpenCode and Kilo send one after approved edits) gets | ||
| 5634 | // method-not-found and cannot touch the disk. A flavor that opts in | ||
| 5635 | // serves requests itself, confined to the workspace of the policy | ||
| 5636 | // active when the request arrives; the agent asks before its edits. | ||
| 5637 | const clientFileSystem = flavor.clientFileSystem; | ||
| 5638 | if (clientFileSystem !== undefined) { | ||
| 5639 | yield* targetRuntime.handleReadTextFile((request) => | ||
| 5640 | clientPolicyContext.pipe( | ||
| 5641 | Effect.flatMap(({ policy }) => clientFileSystem.readTextFile(request, policy.cwd)), | ||
| 5642 | ), | ||
| 5643 | ); | ||
| 5644 | yield* targetRuntime.handleWriteTextFile((request) => | ||
| 5645 | clientPolicyContext.pipe( | ||
| 5646 | Effect.flatMap(({ policy }) => clientFileSystem.writeTextFile(request, policy.cwd)), | ||
| 5647 | ), | ||
| 5648 | ); | ||
| 5649 | } | ||
| 5650 | if (handlerOptions.mcp !== false) { | ||
| 5651 | yield* wireAcpRuntimeMcpHandlers(targetRuntime, runtimeMcpBridge); | ||
| 5652 | } | ||
| 5653 | if (handlerOptions.terminals !== false) { | ||
| 5654 | yield* wireAcpRuntimeTerminalHandlers(targetRuntime); | ||
| 5655 | } | ||
| 5656 | if (handlerOptions.sessionUpdates !== false) { | ||
| 5657 | yield* targetRuntime.handleSessionUpdate((rawNotification) => | ||
| 5658 | projectAcpRuntimeSessionUpdate(handlerGeneration, rawNotification), | ||
| 5659 | ); | ||
| 5660 | } | ||
| 5661 | yield* targetRuntime.handleRequestPermission((params, requestContext) => | ||
| 5662 | Effect.gen(function* () { | ||
| 5663 | const transportRequestId = requestContext.requestId; | ||
| 5664 | const permissionQuestion = flavor.extractPermissionQuestion?.(params); | ||
| 5665 | if (permissionQuestion !== undefined) { | ||
| 5666 | const userInput = yield* requestUserInputWithAdmission( | ||
| 5667 | handlerGeneration, | ||
| 5668 | Effect.succeed({ | ||
| 5669 | nativeItemId: `${params.sessionId}:question:${params.toolCall.toolCallId}`, | ||
| 5670 | nativeMethod: "session/request_permission", | ||
| 5671 | nativeRequestId: params.toolCall.toolCallId, | ||
| 5672 | nativeSessionId: params.sessionId, | ||
| 5673 | questions: [permissionQuestion.question], | ||
| 5674 | }), | ||
| 5675 | transportRequestId, | ||
| 5676 | ); | ||
| 5677 | const response = | ||
| 5678 | userInput.answers === null | ||
| 5679 | ? undefined | ||
| 5680 | : permissionQuestion.respond(userInput.answers); | ||
| 5681 | yield* userInput.acknowledgeNativeResponse; | ||
| 5682 | return response ?? ({ outcome: { outcome: "cancelled" } } as const); | ||
| 5683 | } | ||
| 5684 | const admitted = yield* runRuntimeCallbackAtGeneration( | ||
| 5685 | handlerGeneration, | ||
| 5686 | Effect.gen(function* () { | ||
| 5687 | const context = yield* activeContext; | ||
| 5688 | const disposition = (flavor.permissionDisposition ?? acpPermissionDisposition)( | ||
| 5689 | context.input.runtimePolicy, | ||
| 5690 | params, | ||
| 5691 | ); | ||
| 5692 | if (disposition === "allow") { | ||
| 5693 | const optionId = selectAutoApprovedPermissionOption(params); | ||
| 5694 | return { | ||
| 5695 | _tag: "Immediate" as const, | ||
| 5696 | response: | ||
| 5697 | optionId === undefined | ||
| 5698 | ? ({ outcome: { outcome: "cancelled" } } as const) | ||
| 5699 | : ({ outcome: { outcome: "selected", optionId } } as const), | ||
| 5700 | }; | ||
| 5701 | } | ||
| 5702 | if (disposition === "deny") { | ||
| 5703 | const optionId = selectPermissionOptionId(params, "decline"); | ||
| 5704 | return { | ||
| 5705 | _tag: "Immediate" as const, | ||
| 5706 | response: | ||
| 5707 | optionId === undefined | ||
| 5708 | ? ({ outcome: { outcome: "cancelled" } } as const) | ||
| 5709 | : ({ outcome: { outcome: "selected", optionId } } as const), | ||
| 5710 | }; | ||
| 5711 | } | ||
| 5712 | return { | ||
| 5713 | _tag: "Pending" as const, | ||
| 5714 | pending: yield* beginApprovalRequest( | ||
| 5715 | context, | ||
| 5716 | params, | ||
| 5717 | handlerGeneration, | ||
| 5718 | transportRequestId, | ||
| 5719 | ), | ||
| 5720 | }; | ||
| 5721 | }), | ||
| 5722 | ); | ||
| 5723 | if (Option.isNone(admitted)) { | ||
| 5724 | return yield* Effect.never; | ||
| 5725 | } | ||
| 5726 | if (admitted.value._tag === "Immediate") { | ||
| 5727 | const response = admitted.value.response; | ||
| 5728 | const checked = yield* runRuntimeCallbackAtGeneration( | ||
| 5729 | handlerGeneration, | ||
| 5730 | Effect.gen(function* () { | ||
| 5731 | const nativeResponseAcknowledgement = yield* Deferred.make< | ||
| 5732 | void, | ||
| 5733 | EffectAcpErrors.AcpError | ||
| 5734 | >(); | ||
| 5735 | yield* registerNativeResponseAcknowledgement( | ||
| 5736 | handlerGeneration, | ||
| 5737 | transportRequestId, | ||
| 5738 | nativeResponseAcknowledgement, | ||
| 5739 | ); | ||
| 5740 | return response; | ||
| 5741 | }), | ||
| 5742 | ); | ||
| 5743 | return Option.isSome(checked) ? checked.value : yield* Effect.never; | ||
| 5744 | } | ||
| 5745 | const { | ||
| 5746 | context, | ||
| 5747 | decision: pendingDecision, | ||
| 5748 | requestId, | ||
| 5749 | transportRequestId: pendingTransportRequestId, | ||
| 5750 | } = admitted.value.pending; | ||
| 5751 | const parsedPermission = parsePermissionRequest(params); | ||
| 5752 | const decision = yield* Deferred.await(pendingDecision).pipe( | ||
| 5753 | Effect.ensuring( | ||
| 5754 | runRuntimeCallbackAtGeneration( | ||
| 5755 | handlerGeneration, | ||
| 5756 | Effect.gen(function* () { | ||
| 5757 | yield* Ref.update(pendingRuntimeRequests, (current) => { | ||
| 5758 | const updated = new Map(current); | ||
| 5759 | updated.delete(String(requestId)); | ||
| 5760 | return updated; | ||
| 5761 | }); | ||
| 5762 | }), | ||
| 5763 | ).pipe(Effect.asVoid), | ||
| 5764 | ), | ||
| 5765 | ); | ||
| 5766 | if ( | ||
| 5767 | parsedPermission.kind !== "unknown" && | ||
| 5768 | (decision === "accept" || decision === "acceptForSession") | ||
| 5769 | ) { | ||
| 5770 | clientPolicyGrants.recordApproval({ | ||
| 5771 | kind: providerRequestKind(parsedPermission.kind), | ||
| 5772 | scope: decision === "acceptForSession" ? "session" : "turn", | ||
| 5773 | turnKey: String(context.providerTurnId), | ||
| 5774 | }); | ||
| 5775 | } | ||
| 5776 | const response = (() => { | ||
| 5777 | if (decision === "cancel") { | ||
| 5778 | return { outcome: { outcome: "cancelled" } } as const; | ||
| 5779 | } | ||
| 5780 | const optionId = selectPermissionOptionId(params, decision); | ||
| 5781 | return optionId === undefined | ||
| 5782 | ? ({ outcome: { outcome: "cancelled" } } as const) | ||
| 5783 | : ({ outcome: { outcome: "selected", optionId } } as const); | ||
| 5784 | })(); | ||
| 5785 | const checked = yield* runRuntimeCallbackAtGeneration( | ||
| 5786 | handlerGeneration, | ||
| 5787 | Effect.succeed(response), | ||
| 5788 | ); | ||
| 5789 | if (Option.isNone(checked)) return yield* Effect.never; | ||
| 5790 | yield* acknowledgeNativeResponse(handlerGeneration, pendingTransportRequestId); | ||
| 5791 | return checked.value; | ||
| 5792 | }).pipe( | ||
| 5793 | Effect.mapError( | ||
| 5794 | (cause) => | ||
| 5795 | new EffectAcpErrors.AcpTransportError({ | ||
| 5796 | detail: "Failed to handle an ACP permission request", | ||
| 5797 | cause, | ||
| 5798 | }), | ||
| 5799 | ), | ||
| 5800 | ), | ||
| 5801 | ); | ||
| 5802 | yield* targetRuntime.handleElicitation((params, requestContext) => | ||
| 5803 | Effect.gen(function* () { | ||
| 5804 | const transportRequestId = requestContext.requestId; | ||
| 5805 | if ( | ||
| 5806 | params.mode === "form" && | ||
| 5807 | (unknownRecord(params._meta)?.codex_approval_kind === "mcp_tool_call" || | ||
| 5808 | transportRequestId.startsWith("mcp_tool_call_approval_")) | ||
| 5809 | ) { | ||
| 5810 | const mcpApprovalDisposition = yield* runRuntimeCallbackAtGeneration( | ||
| 5811 | handlerGeneration, | ||
| 5812 | Effect.gen(function* () { | ||
| 5813 | const context = yield* activeContext; | ||
| 5814 | const disposition = acpMcpToolApprovalElicitationDisposition( | ||
| 5815 | context.input.runtimePolicy, | ||
| 5816 | params, | ||
| 5817 | transportRequestId, | ||
| 5818 | ); | ||
| 5819 | if (disposition === undefined || disposition === "ask") { | ||
| 5820 | return disposition; | ||
| 5821 | } | ||
| 5822 | const nativeResponseAcknowledgement = yield* Deferred.make< | ||
| 5823 | void, | ||
| 5824 | EffectAcpErrors.AcpError | ||
| 5825 | >(); | ||
| 5826 | yield* registerNativeResponseAcknowledgement( | ||
| 5827 | handlerGeneration, | ||
| 5828 | transportRequestId, | ||
| 5829 | nativeResponseAcknowledgement, | ||
| 5830 | ); | ||
| 5831 | return disposition; | ||
| 5832 | }), | ||
| 5833 | ); | ||
| 5834 | if (Option.isNone(mcpApprovalDisposition)) return yield* Effect.never; | ||
| 5835 | if (mcpApprovalDisposition.value === "allow") { | ||
| 5836 | return { action: "accept", content: {} } as const; | ||
| 5837 | } | ||
| 5838 | if (mcpApprovalDisposition.value === "deny") { | ||
| 5839 | return { action: "decline" } as const; | ||
| 5840 | } | ||
| 5841 | } | ||
| 5842 | if ( | ||
| 5843 | params.mode === "url" && | ||
| 5844 | "url" in params && | ||
| 5845 | typeof params.url === "string" && | ||
| 5846 | "elicitationId" in params && | ||
| 5847 | typeof params.elicitationId === "string" | ||
| 5848 | ) { | ||
| 5849 | const { elicitationId, url } = params; | ||
| 5850 | const admitted = yield* runRuntimeCallbackAtGeneration( | ||
| 5851 | handlerGeneration, | ||
| 5852 | Effect.gen(function* () { | ||
| 5853 | const nativeResponseAcknowledgement = yield* Deferred.make< | ||
| 5854 | void, | ||
| 5855 | EffectAcpErrors.AcpError | ||
| 5856 | >(); | ||
| 5857 | yield* registerNativeResponseAcknowledgement( | ||
| 5858 | handlerGeneration, | ||
| 5859 | transportRequestId, | ||
| 5860 | nativeResponseAcknowledgement, | ||
| 5861 | ); | ||
| 5862 | const accepted = yield* ( | ||
| 5863 | flavor.onUrlElicitation?.({ | ||
| 5864 | elicitationId, | ||
| 5865 | url, | ||
| 5866 | message: params.message, | ||
| 5867 | }) ?? Effect.succeed(false) | ||
| 5868 | ); | ||
| 5869 | return accepted | ||
| 5870 | ? ({ action: "accept" } as const) | ||
| 5871 | : ({ action: "decline" } as const); | ||
| 5872 | }), | ||
| 5873 | ); | ||
| 5874 | if (Option.isNone(admitted)) return yield* Effect.never; | ||
| 5875 | return admitted.value; | ||
| 5876 | } | ||
| 5877 | if (params.mode !== "form" || !("requestedSchema" in params)) { | ||
| 5878 | // Future elicitation modes beyond form and url decline rather | ||
| 5879 | // than guessing at their semantics. | ||
| 5880 | return { action: "decline" } as const; | ||
| 5881 | } | ||
| 5882 | const requestedSchema = unknownRecord(params.requestedSchema); | ||
| 5883 | const properties = unknownRecord(requestedSchema?.properties) ?? {}; | ||
| 5884 | const elicitationScopeId = | ||
| 5885 | "sessionId" in params ? params.sessionId : `request:${params.requestId}`; | ||
| 5886 | const questions = Object.entries(properties).map( | ||
| 5887 | ([id, property], index): OrchestrationV2UserInputQuestion => { | ||
| 5888 | const record = unknownRecord(property); | ||
| 5889 | const enumValues = Array.isArray(record?.enum) | ||
| 5890 | ? record.enum.filter((value): value is string => typeof value === "string") | ||
| 5891 | : []; | ||
| 5892 | const options = | ||
| 5893 | enumValues.length > 0 | ||
| 5894 | ? enumValues.map((value) => ({ label: value, description: value })) | ||
| 5895 | : record?.type === "boolean" | ||
| 5896 | ? [ | ||
| 5897 | { label: "true", description: "Yes" }, | ||
| 5898 | { label: "false", description: "No" }, | ||
| 5899 | ] | ||
| 5900 | : []; | ||
| 5901 | return { | ||
| 5902 | id, | ||
| 5903 | header: nonEmptyText(record?.title, `Question ${index + 1}`), | ||
| 5904 | question: nonEmptyText(record?.description, params.message), | ||
| 5905 | options, | ||
| 5906 | }; | ||
| 5907 | }, | ||
| 5908 | ); | ||
| 5909 | const userInput = yield* requestUserInputWithAdmission( | ||
| 5910 | handlerGeneration, | ||
| 5911 | Effect.gen(function* () { | ||
| 5912 | const ordinal = yield* Ref.getAndUpdate( | ||
| 5913 | nextElicitationOrdinal, | ||
| 5914 | (current) => current + 1, | ||
| 5915 | ); | ||
| 5916 | const nativeRequestId = `${elicitationScopeId}:elicitation:${ordinal}`; | ||
| 5917 | return { | ||
| 5918 | nativeItemId: nativeRequestId, | ||
| 5919 | nativeRequestId, | ||
| 5920 | questions, | ||
| 5921 | }; | ||
| 5922 | }), | ||
| 5923 | transportRequestId, | ||
| 5924 | ); | ||
| 5925 | const response = | ||
| 5926 | userInput.answers === null | ||
| 5927 | ? ({ action: "cancel" } as const) | ||
| 5928 | : ({ | ||
| 5929 | action: "accept", | ||
| 5930 | content: elicitationContent( | ||
| 5931 | userInput.answers, | ||
| 5932 | new Set(Object.keys(properties)), | ||
| 5933 | ), | ||
| 5934 | } as const); | ||
| 5935 | yield* userInput.acknowledgeNativeResponse; | ||
| 5936 | return response; | ||
| 5937 | }), | ||
| 5938 | ); | ||
| 5939 | if (flavor.registerExtensions !== undefined) { | ||
| 5940 | yield* flavor.registerExtensions({ | ||
| 5941 | runtime: targetRuntime, | ||
| 5942 | reportProviderRetry: (notice) => | ||
| 5943 | runRuntimeCallbackAtGeneration(handlerGeneration, reportProviderRetry(notice)).pipe( | ||
| 5944 | Effect.asVoid, | ||
| 5945 | ), | ||
| 5946 | requestUserInput, | ||
| 5947 | captureProposedPlan, | ||
| 5948 | lastProposedPlanMarkdown, | ||
| 5949 | finishSubagent: (notice) => | ||
| 5950 | runRuntimeCallbackAtGeneration( | ||
| 5951 | handlerGeneration, | ||
| 5952 | Effect.gen(function* () { | ||
| 5953 | if (yield* Ref.get(stoppedRunQuarantine)) return; | ||
| 5954 | // Root-session notices only; nested subagents report to | ||
| 5955 | // their own parent session. | ||
| 5956 | if ((yield* Ref.get(activeSessionId)) !== notice.sessionId) return; | ||
| 5957 | yield* finishSubagentFromNotice(notice); | ||
| 5958 | }), | ||
| 5959 | ).pipe(Effect.asVoid), | ||
| 5960 | applyBackgroundTaskMutation: (mutation) => | ||
| 5961 | runRuntimeCallbackAtGeneration( | ||
| 5962 | handlerGeneration, | ||
| 5963 | Effect.gen(function* () { | ||
| 5964 | // Direct Stop quarantine: drop residual task lifecycle from | ||
| 5965 | // the stopped run instead of mutating wake machinery. | ||
| 5966 | if (yield* Ref.get(stoppedRunQuarantine)) return; | ||
| 5967 | // Root-session tasks only: a cancelled subagent's re-run in | ||
| 5968 | // its child session must not gate root wake machinery. | ||
| 5969 | if ((yield* Ref.get(activeSessionId)) !== mutation.sessionId) return; | ||
| 5970 | if ( | ||
| 5971 | mutation.status !== "running" && | ||
| 5972 | mutation.report !== undefined && | ||
| 5973 | !(yield* Ref.get(handledBackgroundTaskIdsInActiveTurn)).has(mutation.taskId) | ||
| 5974 | ) { | ||
| 5975 | yield* recordWakeReport(mutation.taskId, { | ||
| 5976 | ...mutation.report, | ||
| 5977 | outcome: mutation.status, | ||
| 5978 | }); | ||
| 5979 | } | ||
| 5980 | yield* applyLateBackgroundMutation(mutation.sessionId, mutation); | ||
| 5981 | if (mutation.status !== "running") { | ||
| 5982 | yield* finishRegisteredBackgroundTool({ | ||
| 5983 | taskId: mutation.taskId, | ||
| 5984 | status: mutation.status, | ||
| 5985 | ...(mutation.output === undefined ? {} : { output: mutation.output }), | ||
| 5986 | }); | ||
| 5987 | } | ||
| 5988 | }), | ||
| 5989 | ).pipe(Effect.asVoid), | ||
| 5990 | }); | ||
| 5991 | } | ||
| 5992 | }); | ||
| 5993 | |||
| 5994 | /** Builds the MCP-over-ACP bridge for one runtime, disposed with that runtime's scope. */ | ||
| 5995 | const makeRuntimeMcpBridge = Effect.fnUntraced(function* ( | ||
| 5996 | threadId: ThreadId | null, | ||
| 5997 | scope: Scope.Scope, | ||
| 5998 | ) { | ||
| 5999 | const mcpContext = acpMcpContext(threadId, self); | ||
| 6000 | if (mcpContext.endpoint === undefined || mcpContext.authorization === undefined) { | ||
| 6001 | return undefined; | ||
| 6002 | } | ||
| 6003 | const mcpBridge = yield* makeAcpMcpOverAcpBridge({ | ||
| 6004 | endpoint: mcpContext.endpoint, | ||
| 6005 | authorization: mcpContext.authorization, | ||
| 6006 | allocateConnectionId: options.crypto.randomUUIDv4.pipe(Effect.orDie), | ||
| 6007 | }); | ||
| 6008 | yield* Scope.addFinalizer(scope, mcpBridge.dispose); | ||
| 6009 | return mcpBridge; | ||
| 6010 | }); | ||
| 6011 | |||
| 6012 | const spawnAcpRuntime = Effect.fnUntraced(function* ( | ||
| 6013 | threadId: ThreadId | null, | ||
| 6014 | resumeSessionId?: string, | ||
| 6015 | ) { | ||
| 6016 | if (runtimeScope !== undefined) { | ||
| 6017 | yield* Scope.close(runtimeScope, Exit.void); | ||
| 6018 | } | ||
| 6019 | runtimeScope = yield* Scope.make(); | ||
| 6020 | runtimeMcpBridge = yield* makeRuntimeMcpBridge(threadId, runtimeScope); | ||
| 6021 | const runtimeGeneration = yield* Ref.get(runtimeCallbackGeneration); | ||
| 6022 | runtime = yield* flavor | ||
| 6023 | .makeRuntime(makeRuntimeInput(runtimeGeneration, threadId, resumeSessionId)) | ||
| 6024 | .pipe( | ||
| 6025 | Effect.provideService(Scope.Scope, runtimeScope), | ||
| 6026 | Effect.provideService(Crypto.Crypto, options.crypto), | ||
| 6027 | ); | ||
| 6028 | }); | ||
| 6029 | |||
| 6030 | const startAcpRuntime = Effect.fnUntraced(function* ( | ||
| 6031 | threadId: ThreadId | null, | ||
| 6032 | resumeSessionId?: string, | ||
| 6033 | ) { | ||
| 6034 | const startup = Effect.gen(function* () { | ||
| 6035 | yield* spawnAcpRuntime(threadId, resumeSessionId); | ||
| 6036 | yield* wireAcpRuntimeHandlers(runtime, yield* Ref.get(runtimeCallbackGeneration)); | ||
| 6037 | return yield* runtime.start(); | ||
| 6038 | }); | ||
| 6039 | return yield* flavor.withRuntimeStartup?.(startup) ?? startup; | ||
| 6040 | }); | ||
| 6041 | |||
| 6042 | const restartAcpRuntime = Effect.fnUntraced(function* (threadId: ThreadId | null) { | ||
| 6043 | yield* spawnAcpRuntime(threadId); | ||
| 6044 | yield* wireAcpRuntimeHandlers(runtime, yield* Ref.get(runtimeCallbackGeneration)); | ||
| 6045 | }); | ||
| 6046 | |||
| 6047 | const startReplacementAcpRuntime = Effect.fnUntraced(function* ( | ||
| 6048 | threadId: ThreadId | null, | ||
| 6049 | commitSessionState: ( | ||
| 6050 | replacement: AcpSessionRuntime.AcpSessionRuntimeStartResult, | ||
| 6051 | ) => Effect.Effect<void>, | ||
| 6052 | ) { | ||
| 6053 | const previousScope = runtimeScope; | ||
| 6054 | const previousGeneration = yield* Ref.get(runtimeCallbackGeneration); | ||
| 6055 | yield* runtimeCallbackPermit.withPermit(awaitAdmittedNativeResponses); | ||
| 6056 | |||
| 6057 | // Keep the original generation active until the candidate has | ||
| 6058 | // completed session startup. A failed candidate therefore cannot | ||
| 6059 | // suppress termination or background callbacks from the live session. | ||
| 6060 | const replacementGeneration = yield* allocateRuntimeCallbackGeneration; | ||
| 6061 | const replacementScope = yield* Scope.make(); | ||
| 6062 | type CandidateLifecycle = | ||
| 6063 | | { readonly _tag: "Starting" } | ||
| 6064 | | { readonly _tag: "Terminated"; readonly error: EffectAcpErrors.AcpError } | ||
| 6065 | | { readonly _tag: "Committed" }; | ||
| 6066 | const candidateLifecycle = yield* Ref.make<CandidateLifecycle>({ _tag: "Starting" }); | ||
| 6067 | const handleCandidateTermination: AcpAdapterV2RuntimeInput["onTermination"] = (error) => | ||
| 6068 | Ref.modify( | ||
| 6069 | candidateLifecycle, | ||
| 6070 | (current): readonly [Effect.Effect<void>, CandidateLifecycle] => { | ||
| 6071 | if (current._tag === "Committed") { | ||
| 6072 | return [handleRuntimeTerminationAtGeneration(replacementGeneration), current]; | ||
| 6073 | } | ||
| 6074 | return [ | ||
| 6075 | Effect.void, | ||
| 6076 | current._tag === "Terminated" | ||
| 6077 | ? current | ||
| 6078 | : ({ _tag: "Terminated", error } as const), | ||
| 6079 | ]; | ||
| 6080 | }, | ||
| 6081 | ).pipe(Effect.flatten); | ||
| 6082 | const stagedSessionUpdates: Array<EffectAcpSchema.SessionNotification> = []; | ||
| 6083 | let handleCandidateSessionUpdate: ( | ||
| 6084 | notification: EffectAcpSchema.SessionNotification, | ||
| 6085 | ) => Effect.Effect<void, EffectAcpErrors.AcpError> = (notification) => | ||
| 6086 | Effect.sync(() => { | ||
| 6087 | stagedSessionUpdates.push(notification); | ||
| 6088 | }); | ||
| 6089 | let replacementMcpBridge: AcpMcpOverAcpBridge | undefined; | ||
| 6090 | const startup = Effect.gen(function* () { | ||
| 6091 | replacementMcpBridge = yield* makeRuntimeMcpBridge(threadId, replacementScope); | ||
| 6092 | const replacementRuntime = yield* flavor | ||
| 6093 | .makeRuntime( | ||
| 6094 | makeRuntimeInput( | ||
| 6095 | replacementGeneration, | ||
| 6096 | threadId, | ||
| 6097 | undefined, | ||
| 6098 | handleCandidateTermination, | ||
| 6099 | ), | ||
| 6100 | ) | ||
| 6101 | .pipe( | ||
| 6102 | Effect.provideService(Scope.Scope, replacementScope), | ||
| 6103 | Effect.provideService(Crypto.Crypto, options.crypto), | ||
| 6104 | ); | ||
| 6105 | // Session setup may publish commands before it returns. Buffer those | ||
| 6106 | // notifications, but do not expose request or extension handlers | ||
| 6107 | // until the candidate generation has committed. | ||
| 6108 | yield* replacementRuntime.handleSessionUpdate((notification) => | ||
| 6109 | Effect.suspend(() => handleCandidateSessionUpdate(notification)), | ||
| 6110 | ); | ||
| 6111 | yield* wireAcpRuntimeMcpHandlers(replacementRuntime, replacementMcpBridge); | ||
| 6112 | const started = yield* replacementRuntime.start(); | ||
| 6113 | return { replacementRuntime, started }; | ||
| 6114 | }); | ||
| 6115 | const replacementExit = yield* Effect.exit( | ||
| 6116 | (flavor.withRuntimeStartup?.(startup) ?? startup).pipe( | ||
| 6117 | Effect.onInterrupt(() => | ||
| 6118 | Scope.close(replacementScope, Exit.void).pipe(Effect.ignore), | ||
| 6119 | ), | ||
| 6120 | ), | ||
| 6121 | ); | ||
| 6122 | if (Exit.isFailure(replacementExit)) { | ||
| 6123 | yield* runtimeCallbackPermit.withPermit( | ||
| 6124 | quarantineNativeTransportAtGeneration(replacementGeneration), | ||
| 6125 | ); | ||
| 6126 | yield* Scope.close(replacementScope, Exit.void).pipe(Effect.ignore); | ||
| 6127 | return yield* Effect.failCause(replacementExit.cause); | ||
| 6128 | } | ||
| 6129 | |||
| 6130 | yield* runtimeCallbackPermit | ||
| 6131 | .withPermit( | ||
| 6132 | Effect.uninterruptible( | ||
| 6133 | Effect.gen(function* () { | ||
| 6134 | yield* awaitAdmittedNativeResponses; | ||
| 6135 | // Session updates and MCP were wired during candidate startup | ||
| 6136 | // (against its own bridge); terminals attach after commit below. | ||
| 6137 | yield* wireAcpRuntimeHandlers( | ||
| 6138 | replacementExit.value.replacementRuntime, | ||
| 6139 | replacementGeneration, | ||
| 6140 | { sessionUpdates: false, terminals: false, mcp: false }, | ||
| 6141 | ); | ||
| 6142 | const lifecycle = yield* Ref.modify( | ||
| 6143 | candidateLifecycle, | ||
| 6144 | (current): readonly [CandidateLifecycle, CandidateLifecycle] => | ||
| 6145 | current._tag === "Terminated" | ||
| 6146 | ? [current, current] | ||
| 6147 | : [{ _tag: "Committed" }, { _tag: "Committed" }], | ||
| 6148 | ); | ||
| 6149 | if (lifecycle._tag === "Terminated") { | ||
| 6150 | return yield* lifecycle.error; | ||
| 6151 | } | ||
| 6152 | yield* quarantineNativeTransportAtGeneration(previousGeneration); | ||
| 6153 | runtime = replacementExit.value.replacementRuntime; | ||
| 6154 | runtimeScope = replacementScope; | ||
| 6155 | runtimeMcpBridge = replacementMcpBridge; | ||
| 6156 | yield* Ref.set(runtimeCallbackGeneration, replacementGeneration); | ||
| 6157 | prepareTerminalEnvironment(threadId, replacementExit.value.started.sessionId); | ||
| 6158 | yield* wireAcpRuntimeTerminalHandlers(replacementExit.value.replacementRuntime); | ||
| 6159 | yield* commitSessionState(replacementExit.value.started); | ||
| 6160 | while (true) { | ||
| 6161 | const buffered = stagedSessionUpdates.splice(0, stagedSessionUpdates.length); | ||
| 6162 | if (buffered.length === 0) { | ||
| 6163 | handleCandidateSessionUpdate = (notification) => | ||
| 6164 | projectAcpRuntimeSessionUpdate(replacementGeneration, notification); | ||
| 6165 | break; | ||
| 6166 | } | ||
| 6167 | yield* Effect.forEach( | ||
| 6168 | buffered, | ||
| 6169 | (notification) => | ||
| 6170 | projectAcpRuntimeSessionUpdateEffect(notification).pipe( | ||
| 6171 | Effect.catchCause((cause) => | ||
| 6172 | Effect.logError("failed to replay staged ACP session update", { | ||
| 6173 | driver, | ||
| 6174 | cause, | ||
| 6175 | }), | ||
| 6176 | ), | ||
| 6177 | ), | ||
| 6178 | { discard: true }, | ||
| 6179 | ); | ||
| 6180 | } | ||
| 6181 | yield* cancelPendingRuntimeRequests(); | ||
| 6182 | if (previousScope !== undefined) { | ||
| 6183 | yield* Scope.close(previousScope, Exit.void).pipe( | ||
| 6184 | Effect.catchCause((cause) => | ||
| 6185 | Effect.logError("failed to close replaced ACP runtime scope", { | ||
| 6186 | driver, | ||
| 6187 | cause, | ||
| 6188 | }), | ||
| 6189 | ), | ||
| 6190 | ); | ||
| 6191 | } | ||
| 6192 | }), | ||
| 6193 | ), | ||
| 6194 | ) | ||
| 6195 | .pipe( | ||
| 6196 | Effect.onExit((exit) => | ||
| 6197 | Exit.isFailure(exit) | ||
| 6198 | ? Ref.get(candidateLifecycle).pipe( | ||
| 6199 | Effect.flatMap((lifecycle) => | ||
| 6200 | lifecycle._tag === "Committed" | ||
| 6201 | ? Effect.void | ||
| 6202 | : Scope.close(replacementScope, Exit.void).pipe(Effect.ignore), | ||
| 6203 | ), | ||
| 6204 | ) | ||
| 6205 | : Effect.void, | ||
| 6206 | ), | ||
| 6207 | ); | ||
| 6208 | return replacementExit.value.started; | ||
| 6209 | }); | ||
| 6210 | |||
| 6211 | const initialStart = yield* Effect.result( | ||
| 6212 | startAcpRuntime(input.threadId, input.initialNativeThreadId), | ||
| 6213 | ); | ||
| 6214 | const started = Result.isSuccess(initialStart) | ||
| 6215 | ? initialStart.success | ||
| 6216 | : yield* Effect.gen(function* () { | ||
| 6217 | const failedMethod = | ||
| 6218 | "method" in initialStart.failure ? initialStart.failure.method : undefined; | ||
| 6219 | if ( | ||
| 6220 | input.initialNativeThreadId === undefined || | ||
| 6221 | (failedMethod !== "session/load" && failedMethod !== "session/resume") | ||
| 6222 | ) { | ||
| 6223 | return yield* initialStart.failure; | ||
| 6224 | } | ||
| 6225 | yield* Ref.set(initialSessionActivationFailure, { | ||
| 6226 | sessionId: input.initialNativeThreadId, | ||
| 6227 | error: initialStart.failure, | ||
| 6228 | }); | ||
| 6229 | itemIdentityVersion = 2; | ||
| 6230 | yield* Ref.set(runtimeRestartRequired, false); | ||
| 6231 | prepareClaimableTerminalEnvironment(input.threadId); | ||
| 6232 | return yield* startAcpRuntime(input.threadId); | ||
| 6233 | }); | ||
| 6234 | yield* Ref.set(activeSessionId, started.sessionId); | ||
| 6235 | yield* Ref.set(activeSessionSetup, started); | ||
| 6236 | rememberTerminalEnvironment(started.sessionId, input.threadId); | ||
| 6237 | const capabilities = negotiatedCapabilities(flavor.capabilities, started); | ||
| 6238 | const canLoadSession = started.initializeResult.agentCapabilities?.loadSession === true; | ||
| 6239 | const canResumeSession = | ||
| 6240 | started.initializeResult.agentCapabilities?.sessionCapabilities?.resume != null; | ||
| 6241 | const supportsImagePrompts = acpSupportsImagePrompts({ | ||
| 6242 | flavorSupportsImagePrompts: flavor.supportsImagePrompts, | ||
| 6243 | negotiatedImage: | ||
| 6244 | started.initializeResult.agentCapabilities?.promptCapabilities?.image === true, | ||
| 6245 | }); | ||
| 6246 | |||
| 6247 | const activateSession = Effect.fnUntraced(function* ( | ||
| 6248 | sessionId: string, | ||
| 6249 | threadId: ThreadId | null, | ||
| 6250 | ) { | ||
| 6251 | const initialFailure = yield* Ref.modify(initialSessionActivationFailure, (failure) => | ||
| 6252 | failure?.sessionId === sessionId ? [failure.error, null] : [undefined, failure], | ||
| 6253 | ); | ||
| 6254 | if (initialFailure !== undefined) { | ||
| 6255 | return yield* initialFailure; | ||
| 6256 | } | ||
| 6257 | const activationOptions = acpMcpActivation(threadId, self); | ||
| 6258 | prepareTerminalEnvironment(threadId, sessionId); | ||
| 6259 | const activated = canLoadSession | ||
| 6260 | ? yield* runtime.loadSession(sessionId, activationOptions) | ||
| 6261 | : canResumeSession | ||
| 6262 | ? yield* runtime.resumeSession(sessionId, activationOptions) | ||
| 6263 | : yield* new ProviderAdapter.ProviderAdapterProtocolError({ | ||
| 6264 | driver, | ||
| 6265 | detail: `ACP driver cannot load or resume session ${sessionId}`, | ||
| 6266 | }); | ||
| 6267 | rememberTerminalEnvironment(activated.sessionId, threadId); | ||
| 6268 | return activated; | ||
| 6269 | }); | ||
| 6270 | |||
| 6271 | const configureSession = Effect.fnUntraced(function* ( | ||
| 6272 | startResult: AcpSessionRuntime.AcpSessionRuntimeStartResult, | ||
| 6273 | modelSelection: ModelSelection, | ||
| 6274 | runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy, | ||
| 6275 | ) { | ||
| 6276 | const requestedModel = flavor.resolveModelId?.(modelSelection) ?? modelSelection.model; | ||
| 6277 | let appliedModel: string | undefined; | ||
| 6278 | if (flavor.applyModelSelection !== undefined) { | ||
| 6279 | appliedModel = yield* flavor.applyModelSelection({ | ||
| 6280 | runtime, | ||
| 6281 | startResult, | ||
| 6282 | modelSelection, | ||
| 6283 | }); | ||
| 6284 | } else if ( | ||
| 6285 | requestedModel.length > 0 && | ||
| 6286 | requestedModel !== "auto" && | ||
| 6287 | requestedModel !== "default" | ||
| 6288 | ) { | ||
| 6289 | const hasModelConfig = | ||
| 6290 | startResult.sessionSetupResult.configOptions?.some( | ||
| 6291 | (option) => option.category === "model", | ||
| 6292 | ) === true; | ||
| 6293 | if (hasModelConfig) { | ||
| 6294 | yield* runtime.setModel(requestedModel); | ||
| 6295 | } | ||
| 6296 | } | ||
| 6297 | // Same-runtime switches compare against this stored setup, so keep | ||
| 6298 | // its model metadata in sync with what the session now runs on; | ||
| 6299 | // otherwise switching A -> B -> A would see the stale setup-time A | ||
| 6300 | // and skip the final switch. | ||
| 6301 | if (appliedModel !== undefined) { | ||
| 6302 | const applied = appliedModel; | ||
| 6303 | yield* Ref.update(activeSessionSetup, (setup) => { | ||
| 6304 | if (setup === null) { | ||
| 6305 | return setup; | ||
| 6306 | } | ||
| 6307 | const models = setup.sessionSetupResult.models; | ||
| 6308 | if (models == null || models.currentModelId === applied) { | ||
| 6309 | return setup; | ||
| 6310 | } | ||
| 6311 | return { | ||
| 6312 | ...setup, | ||
| 6313 | sessionSetupResult: { | ||
| 6314 | ...setup.sessionSetupResult, | ||
| 6315 | models: { ...models, currentModelId: applied }, | ||
| 6316 | }, | ||
| 6317 | }; | ||
| 6318 | }); | ||
| 6319 | } | ||
| 6320 | const optionSelections = modelSelection.options ?? []; | ||
| 6321 | const configOptions = yield* runtime.getConfigOptions; | ||
| 6322 | const availableConfigIds = new Set(configOptions.map((option) => option.id)); | ||
| 6323 | const hasNativeConfigWithSyntheticModeId = availableConfigIds.has( | ||
| 6324 | ACP_SESSION_MODE_OPTION_ID, | ||
| 6325 | ); | ||
| 6326 | const modeSelection = hasNativeConfigWithSyntheticModeId | ||
| 6327 | ? undefined | ||
| 6328 | : optionSelections.find((selection) => selection.id === ACP_SESSION_MODE_OPTION_ID); | ||
| 6329 | const configSelections = hasNativeConfigWithSyntheticModeId | ||
| 6330 | ? optionSelections | ||
| 6331 | : optionSelections.filter((selection) => selection.id !== ACP_SESSION_MODE_OPTION_ID); | ||
| 6332 | // Probe-time descriptors are a per-model union, so a stored | ||
| 6333 | // selection can reference an option the live session does not | ||
| 6334 | // expose (Kilo advertises per-model "effort" descriptors while its | ||
| 6335 | // session config omits them). Failing the open here wedges the run | ||
| 6336 | // in a retry loop; skip like the out-of-range values below and let | ||
| 6337 | // the agent default apply. | ||
| 6338 | const unsupportedConfigIds = configSelections | ||
| 6339 | .map((selection) => selection.id) | ||
| 6340 | .filter((id) => !availableConfigIds.has(id)); | ||
| 6341 | if (unsupportedConfigIds.length > 0) { | ||
| 6342 | yield* Effect.logWarning( | ||
| 6343 | "ACP session does not expose requested configuration option(s)", | ||
| 6344 | { | ||
| 6345 | driver, | ||
| 6346 | sessionId: startResult.sessionId, | ||
| 6347 | optionIds: unsupportedConfigIds, | ||
| 6348 | }, | ||
| 6349 | ); | ||
| 6350 | } | ||
| 6351 | for (const selection of configSelections) { | ||
| 6352 | if (!availableConfigIds.has(selection.id)) continue; | ||
| 6353 | // Tuning knobs degrade instead of failing the session open: agents | ||
| 6354 | // advertise the union of values across models but can reject a | ||
| 6355 | // per-model invalid one at set time (codex-acp advertises "ultra" | ||
| 6356 | // reasoning effort and then rejects it for most models). Skip | ||
| 6357 | // values the session does not currently offer and downgrade an | ||
| 6358 | // agent-side set rejection to a warning; the agent's default | ||
| 6359 | // applies for that option. | ||
| 6360 | const option = configOptions.find((candidate) => candidate.id === selection.id); | ||
| 6361 | if ( | ||
| 6362 | option !== undefined && | ||
| 6363 | option.type === "select" && | ||
| 6364 | typeof selection.value === "string" | ||
| 6365 | ) { | ||
| 6366 | const advertisedValues = option.options.flatMap((entry) => | ||
| 6367 | "value" in entry ? [entry.value] : entry.options.map((choice) => choice.value), | ||
| 6368 | ); | ||
| 6369 | if (!advertisedValues.includes(selection.value)) continue; | ||
| 6370 | } | ||
| 6371 | yield* runtime.setConfigOption(selection.id, selection.value).pipe( | ||
| 6372 | Effect.catchTags({ | ||
| 6373 | AcpRequestError: (error) => | ||
| 6374 | Effect.logWarning("ACP session rejected a configuration option value", { | ||
| 6375 | optionId: selection.id, | ||
| 6376 | value: selection.value, | ||
| 6377 | detail: error.message, | ||
| 6378 | }), | ||
| 6379 | }), | ||
| 6380 | ); | ||
| 6381 | } | ||
| 6382 | const policyMode = flavor.sessionModeForPolicy?.(runtimePolicy); | ||
| 6383 | if (policyMode !== undefined) { | ||
| 6384 | yield* runtime.setMode(policyMode); | ||
| 6385 | } | ||
| 6386 | const modeState = yield* runtime.getModeState; | ||
| 6387 | // The synthetic mode selection is skipped rather than failed when the | ||
| 6388 | // agent no longer advertises it: mode sets are volatile across agent | ||
| 6389 | // versions and a stale persisted mode should not block the turn. | ||
| 6390 | if ( | ||
| 6391 | modeSelection !== undefined && | ||
| 6392 | typeof modeSelection.value === "string" && | ||
| 6393 | modeState?.availableModes.some((mode) => mode.id === modeSelection.value) === true && | ||
| 6394 | modeState.currentModeId !== modeSelection.value | ||
| 6395 | ) { | ||
| 6396 | yield* runtime.setMode(modeSelection.value); | ||
| 6397 | } | ||
| 6398 | const effectiveModeState = yield* runtime.getModeState; | ||
| 6399 | const effectiveConfigOptions = yield* runtime.getConfigOptions; | ||
| 6400 | const planSensitiveOptions = effectiveConfigOptions.filter( | ||
| 6401 | ( | ||
| 6402 | option, | ||
| 6403 | ): option is Extract< | ||
| 6404 | EffectAcpSchema.SessionConfigOption, | ||
| 6405 | { readonly type: "select" } | ||
| 6406 | > => | ||
| 6407 | option.type === "select" && | ||
| 6408 | (option.category === "mode" || option.category === "collaboration_mode"), | ||
| 6409 | ); | ||
| 6410 | if (runtimePolicy.interactionMode === "plan") { | ||
| 6411 | if (!nativeBuildConfigurationBySessionId.has(startResult.sessionId)) { | ||
| 6412 | nativeBuildConfigurationBySessionId.set(startResult.sessionId, { | ||
| 6413 | ...(effectiveModeState === undefined | ||
| 6414 | ? {} | ||
| 6415 | : { modeId: effectiveModeState.currentModeId }), | ||
| 6416 | configOptions: planSensitiveOptions.map((option) => ({ | ||
| 6417 | id: option.id, | ||
| 6418 | value: option.currentValue, | ||
| 6419 | })), | ||
| 6420 | }); | ||
| 6421 | } | ||
| 6422 | const planMode = effectiveModeState?.availableModes.find( | ||
| 6423 | (mode) => mode.id === "plan" || mode.id === "architect", | ||
| 6424 | ); | ||
| 6425 | if (planMode !== undefined && effectiveModeState?.currentModeId !== planMode.id) { | ||
| 6426 | yield* runtime.setMode(planMode.id); | ||
| 6427 | } | ||
| 6428 | for (const option of planSensitiveOptions) { | ||
| 6429 | const choices = option.options.flatMap((entry) => | ||
| 6430 | "value" in entry ? [entry.value] : entry.options.map((choice) => choice.value), | ||
| 6431 | ); | ||
| 6432 | const requested = choices.find( | ||
| 6433 | (choice) => choice === "plan" || choice === "architect", | ||
| 6434 | ); | ||
| 6435 | if (requested !== undefined && option.currentValue !== requested) { | ||
| 6436 | yield* runtime.setConfigOption(option.id, requested); | ||
| 6437 | } | ||
| 6438 | } | ||
| 6439 | } else { | ||
| 6440 | const nativeBuild = nativeBuildConfigurationBySessionId.get(startResult.sessionId); | ||
| 6441 | if (nativeBuild !== undefined) { | ||
| 6442 | if ( | ||
| 6443 | nativeBuild.modeId !== undefined && | ||
| 6444 | effectiveModeState?.currentModeId !== nativeBuild.modeId && | ||
| 6445 | effectiveModeState?.availableModes.some( | ||
| 6446 | (mode) => mode.id === nativeBuild.modeId, | ||
| 6447 | ) === true | ||
| 6448 | ) { | ||
| 6449 | yield* runtime.setMode(nativeBuild.modeId); | ||
| 6450 | } | ||
| 6451 | for (const saved of nativeBuild.configOptions) { | ||
| 6452 | const option = effectiveConfigOptions.find( | ||
| 6453 | (candidate) => candidate.type === "select" && candidate.id === saved.id, | ||
| 6454 | ); | ||
| 6455 | if (option === undefined || option.type !== "select") continue; | ||
| 6456 | const choices = option.options.flatMap((entry) => | ||
| 6457 | "value" in entry ? [entry.value] : entry.options.map((choice) => choice.value), | ||
| 6458 | ); | ||
| 6459 | if (option.currentValue !== saved.value && choices.includes(saved.value)) { | ||
| 6460 | yield* runtime.setConfigOption(option.id, saved.value); | ||
| 6461 | } | ||
| 6462 | } | ||
| 6463 | nativeBuildConfigurationBySessionId.delete(startResult.sessionId); | ||
| 6464 | } | ||
| 6465 | } | ||
| 6466 | yield* ( | ||
| 6467 | flavor.onSessionConfigurationUpdate?.( | ||
| 6468 | yield* runtime.getConfigOptions, | ||
| 6469 | yield* runtime.getModeState, | ||
| 6470 | ) ?? Effect.void | ||
| 6471 | ); | ||
| 6472 | }); | ||
| 6473 | |||
| 6474 | yield* configureSession(started, input.modelSelection, input.runtimePolicy); | ||
| 6475 | yield* Ref.set(activeSelection, input.modelSelection); | ||
| 6476 | yield* Ref.set(activeInteractionMode, input.runtimePolicy.interactionMode); | ||
| 6477 | const createdAt = yield* DateTime.now; | ||
| 6478 | const providerSession: OrchestrationV2ProviderSession = { | ||
| 6479 | id: input.providerSessionId, | ||
| 6480 | driver, | ||
| 6481 | providerInstanceId: options.instanceId, | ||
| 6482 | status: "ready", | ||
| 6483 | cwd: input.runtimePolicy.cwd ?? process.cwd(), | ||
| 6484 | model: input.modelSelection.model, | ||
| 6485 | capabilities, | ||
| 6486 | createdAt, | ||
| 6487 | updatedAt: createdAt, | ||
| 6488 | lastError: null, | ||
| 6489 | }; | ||
| 6490 | |||
| 6491 | const providerTurnPayload = ( | ||
| 6492 | context: ActiveAcpTurn, | ||
| 6493 | status: OrchestrationV2ProviderTurn["status"], | ||
| 6494 | completedAt: DateTime.Utc | null, | ||
| 6495 | ): OrchestrationV2ProviderTurn => ({ | ||
| 6496 | id: context.providerTurnId, | ||
| 6497 | providerThreadId: context.input.providerThread.id, | ||
| 6498 | nodeId: context.input.rootNodeId, | ||
| 6499 | runAttemptId: context.input.attemptId, | ||
| 6500 | nativeTurnRef: { | ||
| 6501 | driver, | ||
| 6502 | nativeId: context.nativeTurnId, | ||
| 6503 | strength: "weak", | ||
| 6504 | }, | ||
| 6505 | ordinal: context.input.providerTurnOrdinal, | ||
| 6506 | status, | ||
| 6507 | startedAt: context.startedAt, | ||
| 6508 | completedAt, | ||
| 6509 | }); | ||
| 6510 | |||
| 6511 | const terminalizeOpenRunOwnedItems = Effect.fnUntraced(function* ( | ||
| 6512 | context: ActiveAcpTurn, | ||
| 6513 | options: { readonly terminalizeSubagents: boolean }, | ||
| 6514 | ) { | ||
| 6515 | for (const tool of context.tools.values()) { | ||
| 6516 | const status = toolStatus(tool.status); | ||
| 6517 | if (status === "pending" || status === "running" || status === "waiting") { | ||
| 6518 | yield* emitTool(context, tool, "interrupted"); | ||
| 6519 | } | ||
| 6520 | } | ||
| 6521 | if (!options.terminalizeSubagents) return; | ||
| 6522 | for (const subagent of context.subagents.values()) { | ||
| 6523 | if (subagent.task.status !== "running" && subagent.task.status !== "pending") { | ||
| 6524 | continue; | ||
| 6525 | } | ||
| 6526 | yield* emitSubagent(context, { | ||
| 6527 | nativeTaskId: subagent.task.nativeTaskRef?.nativeId ?? subagent.task.id, | ||
| 6528 | prompt: subagent.task.prompt, | ||
| 6529 | title: subagent.task.title, | ||
| 6530 | model: subagent.task.model, | ||
| 6531 | status: "interrupted", | ||
| 6532 | childSessionId: subagent.childSessionId, | ||
| 6533 | result: subagent.task.result, | ||
| 6534 | suppressNormalTool: true, | ||
| 6535 | }); | ||
| 6536 | } | ||
| 6537 | }); | ||
| 6538 | |||
| 6539 | const terminalizeOpenForegroundTools = Effect.fnUntraced(function* ( | ||
| 6540 | context: ActiveAcpTurn, | ||
| 6541 | ) { | ||
| 6542 | for (const tool of context.tools.values()) { | ||
| 6543 | if (!acpCompletedTurnShouldTerminalizeTool(tool, flavor)) continue; | ||
| 6544 | yield* emitTool(context, tool, "completed"); | ||
| 6545 | } | ||
| 6546 | }); | ||
| 6547 | |||
| 6548 | const quarantineStoppedRun = Effect.fnUntraced(function* () { | ||
| 6549 | yield* continuationPermit.withPermit( | ||
| 6550 | Effect.gen(function* () { | ||
| 6551 | yield* Ref.update(continuationGeneration, (value) => value + 1); | ||
| 6552 | yield* Ref.set(stoppedRunQuarantine, true); | ||
| 6553 | yield* Ref.set(wakeBuffer, []); | ||
| 6554 | yield* Ref.set(wakeReports, noWakeReports); | ||
| 6555 | yield* Ref.set(continuationRequested, false); | ||
| 6556 | yield* Ref.set(runningBackgroundTaskIds, new Set()); | ||
| 6557 | yield* Ref.set(midTurnUnreportedCompletedTaskIds, new Set()); | ||
| 6558 | yield* Ref.set(carryoverSubagents, null); | ||
| 6559 | yield* Ref.set(lastTurnRoute, null); | ||
| 6560 | }), | ||
| 6561 | ); | ||
| 6562 | }); | ||
| 6563 | |||
| 6564 | const finalizeTurn = Effect.fnUntraced(function* ( | ||
| 6565 | context: ActiveAcpTurn, | ||
| 6566 | status: "completed" | "interrupted" | "failed" | "cancelled", | ||
| 6567 | failure?: OrchestrationV2ProviderFailure, | ||
| 6568 | ) { | ||
| 6569 | if (context.finalized) return; | ||
| 6570 | const settledStatus = context.interrupted ? "interrupted" : status; | ||
| 6571 | context.finalizedStatus = settledStatus; | ||
| 6572 | context.finalized = true; | ||
| 6573 | const directStopQuarantine = yield* Ref.get(stoppedRunQuarantine); | ||
| 6574 | if (flavor.subagentsIdleOnTurnCompletion === true) { | ||
| 6575 | for (const subagent of context.subagents.values()) { | ||
| 6576 | if (!acpSubagentStatusBlocksTurnSettlement(subagent.task.status)) continue; | ||
| 6577 | yield* emitSubagent(context, { | ||
| 6578 | nativeTaskId: subagent.task.nativeTaskRef?.nativeId ?? subagent.task.id, | ||
| 6579 | prompt: subagent.task.prompt, | ||
| 6580 | title: subagent.task.title, | ||
| 6581 | model: subagent.task.model, | ||
| 6582 | status: settledStatus === "completed" ? "idle" : settledStatus, | ||
| 6583 | childSessionId: subagent.childSessionId, | ||
| 6584 | result: null, | ||
| 6585 | }); | ||
| 6586 | } | ||
| 6587 | } | ||
| 6588 | if (settledStatus === "completed") { | ||
| 6589 | yield* terminalizeOpenForegroundTools(context); | ||
| 6590 | } else if (settledStatus === "interrupted") { | ||
| 6591 | // Direct Stop terminalizes every visible run-owned item. restart_active | ||
| 6592 | // keeps live subagent lineages for in-process replacement carryover. | ||
| 6593 | yield* terminalizeOpenRunOwnedItems(context, { | ||
| 6594 | terminalizeSubagents: directStopQuarantine, | ||
| 6595 | }); | ||
| 6596 | } | ||
| 6597 | yield* closeTextStreams(context); | ||
| 6598 | if (settledStatus !== "failed") { | ||
| 6599 | yield* emitProviderRetry(context, settledStatus); | ||
| 6600 | } | ||
| 6601 | const now = yield* DateTime.now; | ||
| 6602 | if ( | ||
| 6603 | flavor.supportsCompaction === true && | ||
| 6604 | context.input.message.text.trim() === "/compact" && | ||
| 6605 | context.input.message.attachments.length === 0 && | ||
| 6606 | settledStatus === "completed" | ||
| 6607 | ) { | ||
| 6608 | const nativeItemId = `${context.nativeTurnId}:compaction`; | ||
| 6609 | yield* emitProviderEvent({ | ||
| 6610 | type: "turn_item.updated", | ||
| 6611 | driver, | ||
| 6612 | turnItem: { | ||
| 6613 | id: idAllocator.derive.turnItemFromProviderItem({ driver, nativeItemId }), | ||
| 6614 | threadId: context.input.threadId, | ||
| 6615 | runId: context.input.runId, | ||
| 6616 | nodeId: context.input.rootNodeId, | ||
| 6617 | providerThreadId: context.input.providerThread.id, | ||
| 6618 | providerTurnId: context.providerTurnId, | ||
| 6619 | nativeItemRef: { driver, nativeId: nativeItemId, strength: "weak" }, | ||
| 6620 | parentItemId: null, | ||
| 6621 | ordinal: yield* resolveItemOrdinal(context, nativeItemId), | ||
| 6622 | type: "compaction", | ||
| 6623 | driver, | ||
| 6624 | status: "completed", | ||
| 6625 | title: "Context compacted", | ||
| 6626 | startedAt: context.startedAt, | ||
| 6627 | completedAt: now, | ||
| 6628 | updatedAt: now, | ||
| 6629 | }, | ||
| 6630 | }); | ||
| 6631 | } | ||
| 6632 | const turn = providerTurnPayload(context, settledStatus, now); | ||
| 6633 | yield* Ref.update(providerTurns, (current) => { | ||
| 6634 | const updated = new Map(current); | ||
| 6635 | updated.set(String(turn.id), turn); | ||
| 6636 | return updated; | ||
| 6637 | }); | ||
| 6638 | yield* emitProviderEvent({ | ||
| 6639 | type: "provider_turn.updated", | ||
| 6640 | driver, | ||
| 6641 | threadId: context.input.threadId, | ||
| 6642 | providerTurn: turn, | ||
| 6643 | }); | ||
| 6644 | const updatedProviderThread: OrchestrationV2ProviderThread = { | ||
| 6645 | ...context.input.providerThread, | ||
| 6646 | providerSessionId: input.providerSessionId, | ||
| 6647 | status: "active", | ||
| 6648 | lastRunOrdinal: context.input.runOrdinal, | ||
| 6649 | firstRunOrdinal: | ||
| 6650 | context.input.providerThread.firstRunOrdinal ?? context.input.runOrdinal, | ||
| 6651 | contextUsage: context.contextUsage, | ||
| 6652 | nativeMetadata: context.nativeMetadata, | ||
| 6653 | updatedAt: now, | ||
| 6654 | }; | ||
| 6655 | yield* Ref.update(providerThreadByNativeSessionId, (current) => | ||
| 6656 | new Map(current).set(context.nativeThreadId, updatedProviderThread), | ||
| 6657 | ); | ||
| 6658 | yield* emitProviderEvent({ | ||
| 6659 | type: "provider_thread.updated", | ||
| 6660 | driver, | ||
| 6661 | providerThread: updatedProviderThread, | ||
| 6662 | }); | ||
| 6663 | yield* emitProviderEvent( | ||
| 6664 | settledStatus === "failed" | ||
| 6665 | ? { | ||
| 6666 | type: "turn.terminal", | ||
| 6667 | driver, | ||
| 6668 | providerThreadId: context.input.providerThread.id, | ||
| 6669 | providerTurnId: context.providerTurnId, | ||
| 6670 | runOrdinal: context.input.runOrdinal, | ||
| 6671 | failureItemOrdinal: yield* resolveItemOrdinal( | ||
| 6672 | context, | ||
| 6673 | `terminal-failure:${context.providerTurnId}`, | ||
| 6674 | ), | ||
| 6675 | status: settledStatus, | ||
| 6676 | failure: failure ?? makeProviderFailure({ class: "provider_error" }), | ||
| 6677 | ...(context.providerRetry === undefined | ||
| 6678 | ? {} | ||
| 6679 | : { | ||
| 6680 | retry: context.providerRetry.retry, | ||
| 6681 | retryStartedAt: context.providerRetry.startedAt, | ||
| 6682 | }), | ||
| 6683 | threadDisposition: "reusable", | ||
| 6684 | } | ||
| 6685 | : { | ||
| 6686 | type: "turn.terminal", | ||
| 6687 | driver, | ||
| 6688 | providerThreadId: context.input.providerThread.id, | ||
| 6689 | providerTurnId: context.providerTurnId, | ||
| 6690 | runOrdinal: context.input.runOrdinal, | ||
| 6691 | status: settledStatus, | ||
| 6692 | failure: null, | ||
| 6693 | threadDisposition: "reusable", | ||
| 6694 | }, | ||
| 6695 | ); | ||
| 6696 | const subagentsRequiringCarryover = [...context.subagents.values()].filter( | ||
| 6697 | acpSubagentHasPendingBackgroundWork, | ||
| 6698 | ); | ||
| 6699 | // Direct Stop must not carry residual subagents into a later run. | ||
| 6700 | if (subagentsRequiringCarryover.length > 0 && !directStopQuarantine) { | ||
| 6701 | const sessionId = yield* Ref.get(activeSessionId); | ||
| 6702 | if (sessionId !== null) { | ||
| 6703 | yield* Ref.set(carryoverSubagents, { | ||
| 6704 | sessionId, | ||
| 6705 | rootTerminalStatus: settledStatus, | ||
| 6706 | subagents: subagentsRequiringCarryover, | ||
| 6707 | }); | ||
| 6708 | } | ||
| 6709 | } | ||
| 6710 | yield* Ref.set(activeTurn, null); | ||
| 6711 | // A mid-turn background completion may have deferred its offer while | ||
| 6712 | // this root turn was still streaming. Once the turn leaves the active | ||
| 6713 | // slot, re-check: empty running set + residual wake evidence (or a | ||
| 6714 | // mid-turn unreported completion) means a legitimate unhandled | ||
| 6715 | // completion can open exactly one continuation. Sticky | ||
| 6716 | // continuationRequested prevents double-offer if a later frame also | ||
| 6717 | // races into offerContinuationRun. | ||
| 6718 | if ( | ||
| 6719 | postSettleContinuationEnabled && | ||
| 6720 | settledStatus === "completed" && | ||
| 6721 | !directStopQuarantine && | ||
| 6722 | (yield* Ref.get(runningBackgroundTaskIds)).size === 0 && | ||
| 6723 | ((yield* Ref.get(wakeBuffer)).length > 0 || | ||
| 6724 | (yield* Ref.get(midTurnUnreportedCompletedTaskIds)).size > 0) | ||
| 6725 | ) { | ||
| 6726 | const sessionId = yield* Ref.get(activeSessionId); | ||
| 6727 | if (sessionId !== null) { | ||
| 6728 | yield* offerContinuationRun(sessionId); | ||
| 6729 | } | ||
| 6730 | } | ||
| 6731 | // Clear mid-turn unreported marks unless a completed turn still has | ||
| 6732 | // running background work: keep them so the post-finalize gate can | ||
| 6733 | // offer once when the last task ends. Interrupted/failed turns must | ||
| 6734 | // not leave marks that open a wake after interrupt (quarantine owns | ||
| 6735 | // that path). Non-continuation turn start also clears (~user turn). | ||
| 6736 | if ( | ||
| 6737 | settledStatus !== "completed" || | ||
| 6738 | (yield* Ref.get(runningBackgroundTaskIds)).size === 0 | ||
| 6739 | ) { | ||
| 6740 | yield* Ref.set(midTurnUnreportedCompletedTaskIds, new Set()); | ||
| 6741 | } | ||
| 6742 | yield* Deferred.succeed(context.completed, undefined).pipe(Effect.ignore); | ||
| 6743 | }); | ||
| 6744 | |||
| 6745 | scheduleDeferredFinalize = (context) => | ||
| 6746 | Effect.gen(function* () { | ||
| 6747 | if (!flavor.deferFinalizeForBackgroundWork) return; | ||
| 6748 | if (!context.promptSettled || context.finalized || context.interrupted) return; | ||
| 6749 | if (hasDeferredBackgroundWork(context)) return; | ||
| 6750 | context.backgroundFinalizeGeneration += 1; | ||
| 6751 | const generation = context.backgroundFinalizeGeneration; | ||
| 6752 | yield* Effect.gen(function* () { | ||
| 6753 | yield* Effect.sleep(ACP_DEFERRED_FINALIZE_DEBOUNCE); | ||
| 6754 | if ( | ||
| 6755 | context.finalized || | ||
| 6756 | context.interrupted || | ||
| 6757 | context.backgroundFinalizeGeneration !== generation | ||
| 6758 | ) { | ||
| 6759 | return; | ||
| 6760 | } | ||
| 6761 | if (hasDeferredBackgroundWork(context)) return; | ||
| 6762 | const status = context.promptSettledStatus ?? "completed"; | ||
| 6763 | yield* finalizeTurn(context, status); | ||
| 6764 | }).pipe(Effect.forkIn(sessionScope), Effect.asVoid); | ||
| 6765 | yield* ( | ||
| 6766 | options.testHooks?.onDeferredFinalizeScheduled?.(ACP_DEFERRED_FINALIZE_DEBOUNCE) ?? | ||
| 6767 | Effect.void | ||
| 6768 | ); | ||
| 6769 | }); | ||
| 6770 | |||
| 6771 | const resolvePromptParts = Effect.fnUntraced(function* ( | ||
| 6772 | turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, | ||
| 6773 | sessionId: string, | ||
| 6774 | ) { | ||
| 6775 | const prompt: Array<EffectAcpSchema.ContentBlock> = []; | ||
| 6776 | const instructionState = { | ||
| 6777 | interactionMode: turnInput.runtimePolicy.interactionMode, | ||
| 6778 | hasT3Mcp: acpMcpServers(turnInput.threadId, self).length > 0, | ||
| 6779 | } satisfies T3AcpInstructionState; | ||
| 6780 | const previousInstructionState = (yield* Ref.get(promptInstructionStates)).get(sessionId); | ||
| 6781 | const messageText = providerMessageTextWithAttachmentPaths({ | ||
| 6782 | text: turnInput.message.text, | ||
| 6783 | attachments: turnInput.message.attachments, | ||
| 6784 | resolveAttachmentPath: (attachment) => | ||
| 6785 | resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }), | ||
| 6786 | }); | ||
| 6787 | const text = t3AcpPromptWithInstructions({ | ||
| 6788 | prompt: messageText, | ||
| 6789 | state: instructionState, | ||
| 6790 | ...(previousInstructionState === undefined | ||
| 6791 | ? {} | ||
| 6792 | : { previousState: previousInstructionState }), | ||
| 6793 | }); | ||
| 6794 | if (text.length > 0) { | ||
| 6795 | prompt.push({ type: "text", text }); | ||
| 6796 | } | ||
| 6797 | const imageAttachments = turnInput.message.attachments.filter( | ||
| 6798 | isProviderNativeImageAttachment, | ||
| 6799 | ); | ||
| 6800 | if (imageAttachments.length > 0 && !supportsImagePrompts) { | ||
| 6801 | return yield* new ProviderAdapter.ProviderAdapterProtocolError({ | ||
| 6802 | driver, | ||
| 6803 | detail: "ACP driver did not negotiate image prompt support", | ||
| 6804 | }); | ||
| 6805 | } | ||
| 6806 | for (const attachment of imageAttachments) { | ||
| 6807 | const path = resolveAttachmentPath({ | ||
| 6808 | attachmentsDir: serverConfig.attachmentsDir, | ||
| 6809 | attachment: attachment as ChatAttachment, | ||
| 6810 | }); | ||
| 6811 | if (path === null) { | ||
| 6812 | return yield* new ProviderAdapter.ProviderAdapterProtocolError({ | ||
| 6813 | driver, | ||
| 6814 | detail: `Invalid attachment id '${attachment.id}'`, | ||
| 6815 | }); | ||
| 6816 | } | ||
| 6817 | const bytes = yield* fileSystem.readFile(path).pipe( | ||
| 6818 | Effect.mapError( | ||
| 6819 | (cause) => | ||
| 6820 | new ProviderAdapter.ProviderAdapterProtocolError({ | ||
| 6821 | driver, | ||
| 6822 | detail: `Failed to read attachment '${attachment.id}'`, | ||
| 6823 | payload: cause, | ||
| 6824 | }), | ||
| 6825 | ), | ||
| 6826 | ); | ||
| 6827 | prompt.push({ | ||
| 6828 | type: "image", | ||
| 6829 | data: Buffer.from(bytes).toString("base64"), | ||
| 6830 | mimeType: attachment.mimeType, | ||
| 6831 | }); | ||
| 6832 | } | ||
| 6833 | if (prompt.length === 0) { | ||
| 6834 | return yield* new ProviderAdapter.ProviderAdapterProtocolError({ | ||
| 6835 | driver, | ||
| 6836 | detail: "ACP turn requires non-empty text or attachments", | ||
| 6837 | }); | ||
| 6838 | } | ||
| 6839 | prompt.push({ | ||
| 6840 | type: "text", | ||
| 6841 | text: buildRuntimeInstructions({ | ||
| 6842 | harness: flavor.runtimeHarness ?? driver, | ||
| 6843 | model: turnInput.modelSelection.model, | ||
| 6844 | }), | ||
| 6845 | }); | ||
| 6846 | return { prompt, instructionState: text === messageText ? undefined : instructionState }; | ||
| 6847 | }); | ||
| 6848 | |||
| 6849 | const restartRuntimeAfterTeardownIfRequired = Effect.fnUntraced(function* ( | ||
| 6850 | threadId: ThreadId | null, | ||
| 6851 | ) { | ||
| 6852 | const restartRequired = yield* Ref.get(runtimeRestartRequired); | ||
| 6853 | if (!restartRequired) return false; | ||
| 6854 | yield* restartAcpRuntime(threadId); | ||
| 6855 | yield* Ref.set(runtimeRestartRequired, false); | ||
| 6856 | yield* Ref.set(activeSessionId, null); | ||
| 6857 | yield* Ref.set(activeSessionSetup, null); | ||
| 6858 | yield* Ref.set(activeSelection, null); | ||
| 6859 | yield* Ref.set(activeInteractionMode, null); | ||
| 6860 | yield* Ref.set(snapshot, { | ||
| 6861 | order: [], | ||
| 6862 | messages: new Map(), | ||
| 6863 | loadingRole: null, | ||
| 6864 | loadingMessageId: null, | ||
| 6865 | loadingIndex: 0, | ||
| 6866 | }); | ||
| 6867 | return true; | ||
| 6868 | }); | ||
| 6869 | |||
| 6870 | const startTurnUnlocked = Effect.fn("AcpAdapterV2.startTurn")( | ||
| 6871 | function* (turnInput: ProviderAdapter.ProviderAdapterV2TurnInput) { | ||
| 6872 | yield* awaitRuntimeTeardown(); | ||
| 6873 | const existing = yield* Ref.get(activeTurn); | ||
| 6874 | if (existing !== null) { | ||
| 6875 | return yield* new ProviderAdapter.ProviderAdapterProtocolError({ | ||
| 6876 | driver, | ||
| 6877 | detail: `ACP provider turn ${existing.providerTurnId} is still active`, | ||
| 6878 | }); | ||
| 6879 | } | ||
| 6880 | useProviderThreadIdentity(turnInput.providerThread); | ||
| 6881 | // Session activation can itself invoke client fs/terminal methods. | ||
| 6882 | // Install the incoming thread policy before load/resume so those | ||
| 6883 | // requests can never inherit the previously active thread's policy. | ||
| 6884 | latestRuntimePolicy = turnInput.runtimePolicy; | ||
| 6885 | const requestedSessionId = yield* nativeThreadId(driver, turnInput.providerThread); | ||
| 6886 | const restartAfterInterrupt = yield* restartRuntimeAfterTeardownIfRequired( | ||
| 6887 | turnInput.threadId, | ||
| 6888 | ); | ||
| 6889 | const needsSessionActivation = | ||
| 6890 | (yield* Ref.get(activeSessionId)) !== requestedSessionId || restartAfterInterrupt; | ||
| 6891 | if (needsSessionActivation) { | ||
| 6892 | const activated = yield* activateSession(requestedSessionId, turnInput.threadId); | ||
| 6893 | yield* Ref.set(activeSessionId, activated.sessionId); | ||
| 6894 | yield* Ref.set(activeSessionSetup, activated); | ||
| 6895 | yield* configureSession(activated, turnInput.modelSelection, turnInput.runtimePolicy); | ||
| 6896 | yield* Ref.set(activeSelection, turnInput.modelSelection); | ||
| 6897 | yield* Ref.set(activeInteractionMode, turnInput.runtimePolicy.interactionMode); | ||
| 6898 | } else { | ||
| 6899 | const configuredSelection = yield* Ref.get(activeSelection); | ||
| 6900 | const configuredInteractionMode = yield* Ref.get(activeInteractionMode); | ||
| 6901 | if ( | ||
| 6902 | configuredSelection === null || | ||
| 6903 | !modelSelectionsEqual(configuredSelection, turnInput.modelSelection) || | ||
| 6904 | configuredInteractionMode !== turnInput.runtimePolicy.interactionMode | ||
| 6905 | ) { | ||
| 6906 | const currentSessionSetup = yield* Ref.get(activeSessionSetup); | ||
| 6907 | if (currentSessionSetup === null) { | ||
| 6908 | return yield* new ProviderAdapter.ProviderAdapterProtocolError({ | ||
| 6909 | driver, | ||
| 6910 | detail: `ACP session ${requestedSessionId} has no active setup metadata`, | ||
| 6911 | }); | ||
| 6912 | } | ||
| 6913 | yield* configureSession( | ||
| 6914 | currentSessionSetup, | ||
| 6915 | turnInput.modelSelection, | ||
| 6916 | turnInput.runtimePolicy, | ||
| 6917 | ); | ||
| 6918 | yield* Ref.set(activeSelection, turnInput.modelSelection); | ||
| 6919 | yield* Ref.set(activeInteractionMode, turnInput.runtimePolicy.interactionMode); | ||
| 6920 | } | ||
| 6921 | } | ||
| 6922 | yield* Ref.set(lastTurnRoute, { | ||
| 6923 | threadId: turnInput.threadId, | ||
| 6924 | providerThreadId: turnInput.providerThread.id, | ||
| 6925 | }); | ||
| 6926 | yield* Ref.set(suppressPostSettleMonitorPrompt, false); | ||
| 6927 | yield* Ref.set(handledBackgroundTaskIdsInActiveTurn, new Set()); | ||
| 6928 | // Continuation turns attach to wake traffic the agent already produced | ||
| 6929 | // after the prior root turn settled; do not re-prompt the ACP session. | ||
| 6930 | const isContinuationTurn = | ||
| 6931 | postSettleContinuationEnabled && acpIsProviderContinuationMessage(turnInput.message); | ||
| 6932 | const isAppOwnedWakeTurn = acpIsAppOwnedWakeTurn(turnInput.message); | ||
| 6933 | // An app-owned wake reports on a sibling delegated child and owns | ||
| 6934 | // none of this session's background work, so it must not discard | ||
| 6935 | // pending wake evidence: neither the mid-turn completion marks kept | ||
| 6936 | // across a settle with work still running, nor the wake buffer | ||
| 6937 | // below. Dropping the marks here would lose the offer for a task | ||
| 6938 | // that completed unreported before the settle. | ||
| 6939 | if (!isAppOwnedWakeTurn) { | ||
| 6940 | yield* Ref.set(midTurnUnreportedCompletedTaskIds, new Set()); | ||
| 6941 | } | ||
| 6942 | // A user turn supersedes an empty offer, but not an offer that owns | ||
| 6943 | // buffered wake traffic. ProviderContinuationService queues that | ||
| 6944 | // continuation behind the user run so it can drain afterwards. | ||
| 6945 | const continuationWasRequested = yield* continuationPermit.withPermit( | ||
| 6946 | Effect.gen(function* () { | ||
| 6947 | const wasRequested = yield* Ref.get(continuationRequested); | ||
| 6948 | const preserveBufferedContinuation = acpTurnStartShouldPreserveContinuation({ | ||
| 6949 | continuationRequested: wasRequested, | ||
| 6950 | isContinuationTurn, | ||
| 6951 | wakeBufferLength: (yield* Ref.get(wakeBuffer)).length, | ||
| 6952 | }); | ||
| 6953 | // User turns must not inherit prior-turn wake residue. Stale | ||
| 6954 | // injected-turn ack chatter can otherwise arm a later offer. | ||
| 6955 | // Preserve only a queued continuation that owns buffered wake | ||
| 6956 | // traffic, and exempt app-owned sibling wakes entirely. | ||
| 6957 | if (!isContinuationTurn && !isAppOwnedWakeTurn && !preserveBufferedContinuation) { | ||
| 6958 | yield* Ref.set(wakeBuffer, []); | ||
| 6959 | yield* Ref.set(wakeReports, noWakeReports); | ||
| 6960 | } | ||
| 6961 | if (preserveBufferedContinuation) return wasRequested; | ||
| 6962 | yield* Ref.update(continuationGeneration, (value) => value + 1); | ||
| 6963 | yield* Ref.set(continuationRequested, false); | ||
| 6964 | return wasRequested; | ||
| 6965 | }), | ||
| 6966 | ); | ||
| 6967 | const promptParts = isContinuationTurn | ||
| 6968 | ? null | ||
| 6969 | : yield* resolvePromptParts(turnInput, requestedSessionId); | ||
| 6970 | const startedAt = yield* DateTime.now; | ||
| 6971 | const nativeTurnId = `${requestedSessionId}:turn:${turnInput.providerTurnOrdinal}`; | ||
| 6972 | const providerTurnId = deriveProviderTurnId(nativeTurnId); | ||
| 6973 | const completed = yield* Deferred.make<void, never>(); | ||
| 6974 | const promptWireSettled = yield* Deferred.make<void, never>(); | ||
| 6975 | const rememberedContextUsage = (yield* Ref.get(contextUsageBySessionId)).get( | ||
| 6976 | requestedSessionId, | ||
| 6977 | ); | ||
| 6978 | const rememberedNativeMetadata = (yield* Ref.get(nativeMetadataBySessionId)).get( | ||
| 6979 | requestedSessionId, | ||
| 6980 | ); | ||
| 6981 | const initialNativeMetadata = | ||
| 6982 | rememberedNativeMetadata === undefined | ||
| 6983 | ? (turnInput.providerThread.nativeMetadata ?? null) | ||
| 6984 | : { | ||
| 6985 | ...turnInput.providerThread.nativeMetadata, | ||
| 6986 | ...rememberedNativeMetadata, | ||
| 6987 | }; | ||
| 6988 | if (initialNativeMetadata !== null) { | ||
| 6989 | yield* Ref.update(nativeMetadataBySessionId, (current) => | ||
| 6990 | new Map(current).set(requestedSessionId, initialNativeMetadata), | ||
| 6991 | ); | ||
| 6992 | } | ||
| 6993 | const context: ActiveAcpTurn = { | ||
| 6994 | input: turnInput, | ||
| 6995 | providerTurnId, | ||
| 6996 | nativeThreadId: requestedSessionId, | ||
| 6997 | nativeTurnId, | ||
| 6998 | startedAt, | ||
| 6999 | completed, | ||
| 7000 | itemOrdinals: new Map(), | ||
| 7001 | user: { current: null, nextSegment: 0 }, | ||
| 7002 | assistant: { current: null, nextSegment: 0 }, | ||
| 7003 | reasoning: { current: null, nextSegment: 0 }, | ||
| 7004 | contextUsage: rememberedContextUsage ?? turnInput.providerThread.contextUsage ?? null, | ||
| 7005 | nativeMetadata: initialNativeMetadata, | ||
| 7006 | tools: new Map(), | ||
| 7007 | toolUpdatesSkipped: new Map(), | ||
| 7008 | toolStartedAt: new Map(), | ||
| 7009 | subagents: new Map(), | ||
| 7010 | subagentsBySessionId: new Map(), | ||
| 7011 | pendingSubagentNotifications: new Map(), | ||
| 7012 | toolCallIdsByBackgroundTaskId: new Map(), | ||
| 7013 | persistentBackgroundTaskIds: new Set(), | ||
| 7014 | awaitingBackgroundHydration: new Set(), | ||
| 7015 | pendingInjectedReport: new Set(), | ||
| 7016 | earlyInjectedReportObserved: false, | ||
| 7017 | plans: new Map(), | ||
| 7018 | interrupted: false, | ||
| 7019 | finalized: false, | ||
| 7020 | finalizedStatus: null, | ||
| 7021 | promptSettled: false, | ||
| 7022 | promptSettledStatus: null, | ||
| 7023 | promptWireSettled, | ||
| 7024 | backgroundFinalizeGeneration: 0, | ||
| 7025 | }; | ||
| 7026 | const carryover = yield* Ref.getAndSet(carryoverSubagents, null); | ||
| 7027 | let rehydratedCarryoverSubagents: ReadonlyArray<ActiveAcpSubagent> = []; | ||
| 7028 | if (carryover !== null && carryover.sessionId === requestedSessionId) { | ||
| 7029 | rehydratedCarryoverSubagents = carryover.subagents; | ||
| 7030 | for (const subagent of carryover.subagents) { | ||
| 7031 | const nativeId = subagent.task.nativeTaskRef?.nativeId ?? null; | ||
| 7032 | if (nativeId !== null) { | ||
| 7033 | context.subagents.set(nativeId, subagent); | ||
| 7034 | } | ||
| 7035 | if (subagent.childSessionId !== null) { | ||
| 7036 | context.subagentsBySessionId.set(subagent.childSessionId, subagent); | ||
| 7037 | } | ||
| 7038 | } | ||
| 7039 | } | ||
| 7040 | yield* Ref.set(activeTurn, context); | ||
| 7041 | // Direct Stop closes and recreates the old runtime before reaching | ||
| 7042 | // this reset. The quarantine remains session-scoped by design. | ||
| 7043 | yield* Ref.set(stoppedRunQuarantine, false); | ||
| 7044 | const runningTurn = providerTurnPayload(context, "running", null); | ||
| 7045 | yield* Ref.update(providerTurns, (current) => { | ||
| 7046 | const updated = new Map(current); | ||
| 7047 | updated.set(String(runningTurn.id), runningTurn); | ||
| 7048 | return updated; | ||
| 7049 | }); | ||
| 7050 | yield* emitProviderEvent({ | ||
| 7051 | type: "provider_turn.updated", | ||
| 7052 | driver, | ||
| 7053 | threadId: turnInput.threadId, | ||
| 7054 | providerTurn: runningTurn, | ||
| 7055 | }); | ||
| 7056 | const activeProviderThread: OrchestrationV2ProviderThread = { | ||
| 7057 | ...turnInput.providerThread, | ||
| 7058 | providerSessionId: input.providerSessionId, | ||
| 7059 | status: "active", | ||
| 7060 | contextUsage: context.contextUsage, | ||
| 7061 | nativeMetadata: context.nativeMetadata, | ||
| 7062 | updatedAt: startedAt, | ||
| 7063 | }; | ||
| 7064 | yield* Ref.update(providerThreadByNativeSessionId, (current) => | ||
| 7065 | new Map(current).set(requestedSessionId, activeProviderThread), | ||
| 7066 | ); | ||
| 7067 | yield* emitProviderEvent({ | ||
| 7068 | type: "provider_thread.updated", | ||
| 7069 | driver, | ||
| 7070 | providerThread: activeProviderThread, | ||
| 7071 | }); | ||
| 7072 | yield* rememberSnapshotMessage({ | ||
| 7073 | createdBy: turnInput.message.createdBy, | ||
| 7074 | creationSource: turnInput.message.creationSource, | ||
| 7075 | ...(turnInput.message.scheduledTaskId === undefined | ||
| 7076 | ? {} | ||
| 7077 | : { scheduledTaskId: turnInput.message.scheduledTaskId }), | ||
| 7078 | ...(turnInput.message.senderThreadId === undefined | ||
| 7079 | ? {} | ||
| 7080 | : { senderThreadId: turnInput.message.senderThreadId }), | ||
| 7081 | id: turnInput.message.messageId, | ||
| 7082 | threadId: turnInput.threadId, | ||
| 7083 | runId: turnInput.runId, | ||
| 7084 | nodeId: turnInput.rootNodeId, | ||
| 7085 | role: "user", | ||
| 7086 | text: turnInput.message.text, | ||
| 7087 | attachments: [...turnInput.message.attachments], | ||
| 7088 | streaming: false, | ||
| 7089 | createdAt: startedAt, | ||
| 7090 | updatedAt: startedAt, | ||
| 7091 | }); | ||
| 7092 | // Every attach ends the deferred-terminal contract, but only the | ||
| 7093 | // queued provider continuation owns wake traffic. A user turn may | ||
| 7094 | // start before that continuation and must leave its buffer intact. | ||
| 7095 | yield* projectDeferredCarryoverTerminals(rehydratedCarryoverSubagents); | ||
| 7096 | // Keep projected terminals addressable through the wake drain so | ||
| 7097 | // emitSubagent's monotonic guard can reject replayed spawn frames. | ||
| 7098 | // Finalize carries only entries with pending background work, so a | ||
| 7099 | // terminal-and-projected lineage still expires with this turn. | ||
| 7100 | if (isContinuationTurn) { | ||
| 7101 | yield* Ref.set(continuationRequested, false); | ||
| 7102 | // This turn delivers what its offer named. Work that ended after | ||
| 7103 | // the offer keeps its report for the next one. | ||
| 7104 | yield* Ref.update(wakeReports, ({ reports, offered }) => ({ | ||
| 7105 | reports: new Map([...reports].filter(([key]) => !offered.has(key))), | ||
| 7106 | offered: noWakeReports.offered, | ||
| 7107 | })); | ||
| 7108 | const drainedWakeCount = yield* Ref.modify(wakeBuffer, (current) => { | ||
| 7109 | const next: Array<EffectAcpSchema.SessionNotification> = []; | ||
| 7110 | return [ | ||
| 7111 | current.filter((notification) => notification.sessionId === requestedSessionId), | ||
| 7112 | next, | ||
| 7113 | ] as const; | ||
| 7114 | }).pipe( | ||
| 7115 | Effect.tap((drained) => | ||
| 7116 | Effect.forEach(drained, handleSessionUpdate, { | ||
| 7117 | concurrency: 1, | ||
| 7118 | discard: true, | ||
| 7119 | }), | ||
| 7120 | ), | ||
| 7121 | Effect.map((drained) => drained.length), | ||
| 7122 | ); | ||
| 7123 | // Treat attach mode as prompt-settled so deferred finalize / quiet | ||
| 7124 | // windows can complete the continuation after wake traffic drains. | ||
| 7125 | context.promptSettled = true; | ||
| 7126 | context.promptSettledStatus = "completed"; | ||
| 7127 | if (drainedWakeCount === 0) { | ||
| 7128 | // A requested continuation with only mid-turn evidence has no | ||
| 7129 | // buffered frame yet. Wait the quiet window so late CLI frames | ||
| 7130 | // can attach. A provider-authored attach without a matching | ||
| 7131 | // request only exists to project deferred carryover terminals, | ||
| 7132 | // so it can finish immediately once those terminals are visible. | ||
| 7133 | if (continuationWasRequested && flavor.deferFinalizeForBackgroundWork === true) { | ||
| 7134 | if (hasDeferredBackgroundWork(context)) { | ||
| 7135 | yield* rearmDeferredFinalize(context); | ||
| 7136 | } else { | ||
| 7137 | yield* scheduleDeferredFinalize(context); | ||
| 7138 | } | ||
| 7139 | } else { | ||
| 7140 | yield* finalizeTurn(context, "completed"); | ||
| 7141 | } | ||
| 7142 | return; | ||
| 7143 | } | ||
| 7144 | if (!context.finalized) { | ||
| 7145 | if (hasDeferredBackgroundWork(context)) { | ||
| 7146 | yield* rearmDeferredFinalize(context); | ||
| 7147 | } else { | ||
| 7148 | yield* scheduleDeferredFinalize(context); | ||
| 7149 | } | ||
| 7150 | } | ||
| 7151 | return; | ||
| 7152 | } | ||
| 7153 | const promptGeneration = yield* Ref.get(runtimeCallbackGeneration); | ||
| 7154 | yield* runtime.prompt({ prompt: promptParts!.prompt }).pipe( | ||
| 7155 | Effect.tap(() => | ||
| 7156 | Ref.update(promptInstructionStates, (current) => { | ||
| 7157 | if (promptParts?.instructionState === undefined) return current; | ||
| 7158 | const updated = new Map(current); | ||
| 7159 | updated.set(requestedSessionId, promptParts.instructionState); | ||
| 7160 | return updated; | ||
| 7161 | }), | ||
| 7162 | ), | ||
| 7163 | // Wire settlement precedes the completion callback's permit request so | ||
| 7164 | // settled-soft classification can observe the native return even when | ||
| 7165 | // the completion fiber has not yet set promptSettled under the permit. | ||
| 7166 | Effect.tap(() => | ||
| 7167 | Deferred.succeed(context.promptWireSettled, undefined).pipe(Effect.asVoid), | ||
| 7168 | ), | ||
| 7169 | Effect.flatMap((result) => | ||
| 7170 | runRuntimeCallbackAtGeneration( | ||
| 7171 | promptGeneration, | ||
| 7172 | Effect.gen(function* () { | ||
| 7173 | if (context.finalized) return; | ||
| 7174 | const status = | ||
| 7175 | result.stopReason === "cancelled" | ||
| 7176 | ? context.interrupted | ||
| 7177 | ? "interrupted" | ||
| 7178 | : "cancelled" | ||
| 7179 | : "completed"; | ||
| 7180 | // Grok monitors (and async subagents) keep working after the root | ||
| 7181 | // prompt RPC returns. Defer finalize so their later updates and | ||
| 7182 | // wake-turn traffic still project onto this run. | ||
| 7183 | if ( | ||
| 7184 | flavor.deferFinalizeForBackgroundWork === true && | ||
| 7185 | !context.interrupted && | ||
| 7186 | hasDeferredBackgroundWork(context) | ||
| 7187 | ) { | ||
| 7188 | context.promptSettled = true; | ||
| 7189 | context.promptSettledStatus = status; | ||
| 7190 | // The agent finished this prompt's reply. Background work | ||
| 7191 | // holds the run open, not the text it already sent. | ||
| 7192 | yield* closeTextStreams(context); | ||
| 7193 | yield* ( | ||
| 7194 | options.testHooks?.afterPromptSettledWithBackgroundWork?.() ?? Effect.void | ||
| 7195 | ); | ||
| 7196 | return; | ||
| 7197 | } | ||
| 7198 | yield* finalizeTurn(context, status); | ||
| 7199 | }), | ||
| 7200 | ).pipe(Effect.asVoid), | ||
| 7201 | ), | ||
| 7202 | // Prompt failure is not wire-settled: only a successful resolve marks | ||
| 7203 | // the signal. catchCause must not complete promptWireSettled. | ||
| 7204 | Effect.catchCause((cause) => | ||
| 7205 | runRuntimeCallbackAtGeneration( | ||
| 7206 | promptGeneration, | ||
| 7207 | Effect.gen(function* () { | ||
| 7208 | if (context.finalized) return; | ||
| 7209 | yield* finalizeTurn( | ||
| 7210 | context, | ||
| 7211 | context.interrupted ? "interrupted" : "failed", | ||
| 7212 | flavor.promptFailure?.(Cause.squash(cause)) ?? | ||
| 7213 | makeProviderFailure({ | ||
| 7214 | cause: Cause.squash(cause), | ||
| 7215 | class: "provider_error", | ||
| 7216 | }), | ||
| 7217 | ).pipe( | ||
| 7218 | Effect.andThen( | ||
| 7219 | Effect.logWarning("orchestration-v2.acp-prompt-failed", { | ||
| 7220 | driver, | ||
| 7221 | providerSessionId: input.providerSessionId, | ||
| 7222 | providerThreadId: turnInput.providerThread.id, | ||
| 7223 | providerTurnId, | ||
| 7224 | cause, | ||
| 7225 | }), | ||
| 7226 | ), | ||
| 7227 | ); | ||
| 7228 | }), | ||
| 7229 | ).pipe(Effect.asVoid), | ||
| 7230 | ), | ||
| 7231 | Effect.forkIn(sessionScope), | ||
| 7232 | ); | ||
| 7233 | }, | ||
| 7234 | (effect, turnInput) => | ||
| 7235 | effect.pipe( | ||
| 7236 | Effect.mapError( | ||
| 7237 | (cause) => | ||
| 7238 | new ProviderAdapter.ProviderAdapterTurnStartError({ | ||
| 7239 | driver, | ||
| 7240 | threadId: turnInput.threadId, | ||
| 7241 | providerThreadId: turnInput.providerThread.id, | ||
| 7242 | runId: turnInput.runId, | ||
| 7243 | cause, | ||
| 7244 | }), | ||
| 7245 | ), | ||
| 7246 | ), | ||
| 7247 | ); | ||
| 7248 | |||
| 7249 | const startTurn = Effect.fn("AcpAdapterV2.startTurn.transition")(function* ( | ||
| 7250 | turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, | ||
| 7251 | ) { | ||
| 7252 | return yield* runtimeTransitionPermit.withPermit(startTurnUnlocked(turnInput)); | ||
| 7253 | }); | ||
| 7254 | |||
| 7255 | yield* Effect.addFinalizer(() => | ||
| 7256 | Effect.gen(function* () { | ||
| 7257 | yield* continuationPermit.withPermit( | ||
| 7258 | Effect.gen(function* () { | ||
| 7259 | yield* Ref.set(continuationClosed, true); | ||
| 7260 | yield* Ref.update(continuationGeneration, (value) => value + 1); | ||
| 7261 | }), | ||
| 7262 | ); | ||
| 7263 | const requests = [...(yield* Ref.get(pendingRuntimeRequests)).values()]; | ||
| 7264 | yield* Effect.forEach( | ||
| 7265 | requests, | ||
| 7266 | (request) => | ||
| 7267 | request.type === "approval" | ||
| 7268 | ? Deferred.succeed(request.decision, "cancel").pipe(Effect.ignore) | ||
| 7269 | : Deferred.succeed(request.answers, null).pipe(Effect.ignore), | ||
| 7270 | { discard: true }, | ||
| 7271 | ); | ||
| 7272 | const closingError = new EffectAcpErrors.AcpTransportError({ | ||
| 7273 | detail: "The ACP session closed before its admitted response reached the transport", | ||
| 7274 | cause: "ACP session transport closed", | ||
| 7275 | }); | ||
| 7276 | yield* Effect.forEach( | ||
| 7277 | requests, | ||
| 7278 | (request) => Deferred.fail(request.nativeResponseAcknowledgement, closingError), | ||
| 7279 | { discard: true }, | ||
| 7280 | ); | ||
| 7281 | const transportHadOutstandingResponses = yield* closeNativeTransport; | ||
| 7282 | yield* options.testHooks?.afterNativeResponseTransportClosed?.() ?? Effect.void; | ||
| 7283 | const sessionCapabilities = | ||
| 7284 | started.initializeResult.agentCapabilities?.sessionCapabilities; | ||
| 7285 | if (sessionCapabilities?.close != null) { | ||
| 7286 | yield* runtimeTransitionPermit.withPermitsIfAvailable(1)( | ||
| 7287 | Effect.gen(function* () { | ||
| 7288 | const teardownState = yield* Ref.get(runtimeTeardownState); | ||
| 7289 | const restartRequired = yield* Ref.get(runtimeRestartRequired); | ||
| 7290 | if ( | ||
| 7291 | teardownState._tag === "Idle" && | ||
| 7292 | !restartRequired && | ||
| 7293 | !transportHadOutstandingResponses | ||
| 7294 | ) { | ||
| 7295 | yield* runtime.closeSession().pipe(Effect.ignore); | ||
| 7296 | } | ||
| 7297 | }), | ||
| 7298 | ); | ||
| 7299 | } | ||
| 7300 | if (flavor.assertComplete !== undefined) { | ||
| 7301 | yield* flavor.assertComplete.pipe(Effect.orDie); | ||
| 7302 | } | ||
| 7303 | if (runtimeScope !== undefined) { | ||
| 7304 | yield* Scope.close(runtimeScope, Exit.void).pipe(Effect.ignore); | ||
| 7305 | } | ||
| 7306 | }), | ||
| 7307 | ); | ||
| 7308 | |||
| 7309 | const sessionRuntime: ProviderAdapter.ProviderAdapterV2SessionRuntime = { | ||
| 7310 | instanceId: options.instanceId, | ||
| 7311 | driver, | ||
| 7312 | providerSessionId: input.providerSessionId, | ||
| 7313 | providerSession, | ||
| 7314 | events: Stream.fromEffectRepeat(Queue.take(events)), | ||
| 7315 | ...(postSettleContinuationEnabled | ||
| 7316 | ? { | ||
| 7317 | hasPendingBackgroundWork: Effect.gen(function* () { | ||
| 7318 | if ((yield* Ref.get(wakeBuffer)).length > 0) return true; | ||
| 7319 | if (yield* Ref.get(continuationRequested)) return true; | ||
| 7320 | if ((yield* Ref.get(runningBackgroundTaskIds)).size > 0) return true; | ||
| 7321 | // Projected post-settle Grok subagents can outlive the root | ||
| 7322 | // turn via carryover; keep the ACP process pinned until they | ||
| 7323 | // terminalize or teardown clears the carryover. | ||
| 7324 | // Also pin while a terminal status is held only in memory | ||
| 7325 | // (project:false) so idle release cannot drop the session | ||
| 7326 | // before the continuation drain (or a later project:true path) | ||
| 7327 | // delivers the turn_item terminal. | ||
| 7328 | const active = yield* Ref.get(activeTurn); | ||
| 7329 | if ( | ||
| 7330 | active !== null && | ||
| 7331 | [...active.subagents.values()].some(acpSubagentHasPendingBackgroundWork) | ||
| 7332 | ) { | ||
| 7333 | return true; | ||
| 7334 | } | ||
| 7335 | const carryover = yield* Ref.get(carryoverSubagents); | ||
| 7336 | if ( | ||
| 7337 | carryover !== null && | ||
| 7338 | carryover.subagents.some(acpSubagentHasPendingBackgroundWork) | ||
| 7339 | ) { | ||
| 7340 | return true; | ||
| 7341 | } | ||
| 7342 | return false; | ||
| 7343 | }), | ||
| 7344 | } | ||
| 7345 | : {}), | ||
| 7346 | ensureThread: Effect.fn("AcpAdapterV2.ensureThread")( | ||
| 7347 | function* (threadInput: ProviderAdapter.ProviderAdapterV2EnsureThreadInput) { | ||
| 7348 | const now = yield* DateTime.now; | ||
| 7349 | const sessionId = yield* Ref.get(activeSessionId); | ||
| 7350 | if (sessionId === null) { | ||
| 7351 | return yield* new ProviderAdapter.ProviderAdapterProtocolError({ | ||
| 7352 | driver, | ||
| 7353 | detail: "ACP runtime did not produce a session id", | ||
| 7354 | }); | ||
| 7355 | } | ||
| 7356 | const providerThread = makeProviderThread({ | ||
| 7357 | driver, | ||
| 7358 | providerInstanceId: options.instanceId, | ||
| 7359 | idAllocator, | ||
| 7360 | appThreadId: threadInput.threadId, | ||
| 7361 | providerSessionId: input.providerSessionId, | ||
| 7362 | nativeThreadId: sessionId, | ||
| 7363 | ...(itemIdentityVersion === undefined ? {} : { itemIdentityVersion }), | ||
| 7364 | now, | ||
| 7365 | }); | ||
| 7366 | yield* Ref.update(providerThreadByNativeSessionId, (current) => | ||
| 7367 | new Map(current).set(sessionId, providerThread), | ||
| 7368 | ); | ||
| 7369 | return providerThread; | ||
| 7370 | }, | ||
| 7371 | (effect, threadInput) => | ||
| 7372 | effect.pipe( | ||
| 7373 | Effect.mapError( | ||
| 7374 | (cause) => | ||
| 7375 | new ProviderAdapter.ProviderAdapterEnsureThreadError({ | ||
| 7376 | driver, | ||
| 7377 | threadId: threadInput.threadId, | ||
| 7378 | cause, | ||
| 7379 | }), | ||
| 7380 | ), | ||
| 7381 | ), | ||
| 7382 | ), | ||
| 7383 | resumeThread: Effect.fn("AcpAdapterV2.resumeThread")( | ||
| 7384 | function* (threadInput: { | ||
| 7385 | readonly providerThread: OrchestrationV2ProviderThread; | ||
| 7386 | readonly modelSelection?: ModelSelection; | ||
| 7387 | readonly runtimePolicy?: ProviderAdapter.ProviderAdapterV2RuntimePolicy; | ||
| 7388 | }) { | ||
| 7389 | return yield* runtimeTransitionPermit.withPermit( | ||
| 7390 | Effect.gen(function* () { | ||
| 7391 | yield* awaitRuntimeTeardown(); | ||
| 7392 | const sessionId = yield* nativeThreadId(driver, threadInput.providerThread); | ||
| 7393 | const previousItemIdentityVersion = itemIdentityVersion; | ||
| 7394 | useProviderThreadIdentity(threadInput.providerThread); | ||
| 7395 | const restorePreviousItemIdentity = Effect.sync(() => { | ||
| 7396 | itemIdentityVersion = previousItemIdentityVersion; | ||
| 7397 | }); | ||
| 7398 | const restartAfterInterrupt = yield* restartRuntimeAfterTeardownIfRequired( | ||
| 7399 | threadInput.providerThread.appThreadId, | ||
| 7400 | ).pipe(Effect.tapError(() => restorePreviousItemIdentity)); | ||
| 7401 | if ((yield* Ref.get(activeSessionId)) !== sessionId || restartAfterInterrupt) { | ||
| 7402 | yield* Ref.set(snapshot, { | ||
| 7403 | order: [], | ||
| 7404 | messages: new Map(), | ||
| 7405 | loadingRole: null, | ||
| 7406 | loadingMessageId: null, | ||
| 7407 | loadingIndex: 0, | ||
| 7408 | }); | ||
| 7409 | const activated = yield* activateSession( | ||
| 7410 | sessionId, | ||
| 7411 | threadInput.providerThread.appThreadId, | ||
| 7412 | ).pipe(Effect.tapError(() => restorePreviousItemIdentity)); | ||
| 7413 | yield* Ref.set(activeSessionId, activated.sessionId); | ||
| 7414 | yield* Ref.set(activeSessionSetup, activated); | ||
| 7415 | const nextSelection = threadInput.modelSelection ?? input.modelSelection; | ||
| 7416 | const nextRuntimePolicy = threadInput.runtimePolicy ?? input.runtimePolicy; | ||
| 7417 | yield* configureSession(activated, nextSelection, nextRuntimePolicy); | ||
| 7418 | yield* Ref.set(activeSelection, nextSelection); | ||
| 7419 | yield* Ref.set(activeInteractionMode, nextRuntimePolicy.interactionMode); | ||
| 7420 | } | ||
| 7421 | const now = yield* DateTime.now; | ||
| 7422 | return { | ||
| 7423 | ...threadInput.providerThread, | ||
| 7424 | providerSessionId: input.providerSessionId, | ||
| 7425 | status: "idle" as const, | ||
| 7426 | updatedAt: now, | ||
| 7427 | }; | ||
| 7428 | }), | ||
| 7429 | ); | ||
| 7430 | }, | ||
| 7431 | (effect, threadInput) => | ||
| 7432 | effect.pipe( | ||
| 7433 | Effect.mapError( | ||
| 7434 | (cause) => | ||
| 7435 | new ProviderAdapter.ProviderAdapterResumeThreadError({ | ||
| 7436 | driver, | ||
| 7437 | providerSessionId: input.providerSessionId, | ||
| 7438 | providerThreadId: threadInput.providerThread.id, | ||
| 7439 | cause, | ||
| 7440 | }), | ||
| 7441 | ), | ||
| 7442 | ), | ||
| 7443 | ), | ||
| 7444 | startTurn, | ||
| 7445 | ...(flavor.supportsCompaction === true | ||
| 7446 | ? { | ||
| 7447 | compactThread: (turnInput: ProviderAdapter.ProviderAdapterV2TurnInput) => | ||
| 7448 | startTurn({ | ||
| 7449 | ...turnInput, | ||
| 7450 | message: { ...turnInput.message, text: "/compact" }, | ||
| 7451 | }), | ||
| 7452 | } | ||
| 7453 | : {}), | ||
| 7454 | steerTurn: (turnInput) => | ||
| 7455 | Effect.fail( | ||
| 7456 | new ProviderAdapter.ProviderAdapterSteerRunUnsupportedError({ | ||
| 7457 | driver, | ||
| 7458 | providerThreadId: turnInput.providerThread.id, | ||
| 7459 | }), | ||
| 7460 | ), | ||
| 7461 | interruptTurn: Effect.fn("AcpAdapterV2.interruptTurn")( | ||
| 7462 | function* (turnInput: ProviderAdapter.ProviderAdapterV2InterruptInput) { | ||
| 7463 | return yield* Effect.uninterruptibleMask((restore) => | ||
| 7464 | Effect.gen(function* () { | ||
| 7465 | yield* restore(runtimeTransitionPermit.take(1)); | ||
| 7466 | return yield* Effect.gen(function* () { | ||
| 7467 | const transition = yield* Effect.gen(function* () { | ||
| 7468 | const interruptContext = yield* Ref.get(activeTurn); | ||
| 7469 | // Settled steering restart: the native prompt already | ||
| 7470 | // returned and this is not a user Stop, so keep the | ||
| 7471 | // process (and its background subagents) alive instead | ||
| 7472 | // of hard-killing it. The turn still terminalizes below. | ||
| 7473 | const softSteerCandidate = | ||
| 7474 | flavor.preserveRuntimeOnSettledInterrupt === true && | ||
| 7475 | turnInput.requestRuntimeRestart !== true && | ||
| 7476 | interruptContext?.providerTurnId === turnInput.providerTurnId; | ||
| 7477 | // Settlement is read under the callback permit after | ||
| 7478 | // draining admitted responses: the native prompt can have | ||
| 7479 | // returned on the wire while its completion callback has | ||
| 7480 | // not yet run, and a cancel sent in that window is exactly | ||
| 7481 | // what settled-soft mode exists to avoid. Also treat the | ||
| 7482 | // turn as settled when the wire signal is already done | ||
| 7483 | // (non-blocking; do not await under the permit). | ||
| 7484 | let settledSoftInterrupt = false; | ||
| 7485 | if (softSteerCandidate && interruptContext !== null) { | ||
| 7486 | const promptSettledUnderPermit = yield* runtimeCallbackPermit.withPermit( | ||
| 7487 | awaitAdmittedNativeResponses.pipe( | ||
| 7488 | Effect.andThen( | ||
| 7489 | Effect.sync(() => interruptContext.promptSettled === true), | ||
| 7490 | ), | ||
| 7491 | ), | ||
| 7492 | ); | ||
| 7493 | const wireSettled = yield* Deferred.isDone( | ||
| 7494 | interruptContext.promptWireSettled, | ||
| 7495 | ); | ||
| 7496 | settledSoftInterrupt = promptSettledUnderPermit || wireSettled; | ||
| 7497 | } | ||
| 7498 | const restartRuntime = | ||
| 7499 | !settledSoftInterrupt && | ||
| 7500 | (turnInput.requestRuntimeRestart === true || | ||
| 7501 | flavor.restartRuntimeOnEveryInterrupt === true); | ||
| 7502 | const hardRestart = | ||
| 7503 | restartRuntime && flavor.terminateRuntimeProcessGroupOnInterrupt === true; | ||
| 7504 | if (hardRestart) { | ||
| 7505 | const teardownState = yield* Ref.get(runtimeTeardownState); | ||
| 7506 | if (teardownState._tag === "Failed") { | ||
| 7507 | return yield* teardownState.error; | ||
| 7508 | } | ||
| 7509 | if (teardownState._tag === "InProgress") { | ||
| 7510 | // Concurrent Stop/restart: wait for the in-flight hard | ||
| 7511 | // teardown instead of failing the durable effect. | ||
| 7512 | yield* Effect.logInfo( | ||
| 7513 | "ACP interrupt awaiting in-progress hard teardown", | ||
| 7514 | { | ||
| 7515 | driver, | ||
| 7516 | providerTurnId: turnInput.providerTurnId, | ||
| 7517 | }, | ||
| 7518 | ); | ||
| 7519 | yield* Deferred.await(teardownState.completed); | ||
| 7520 | return undefined; | ||
| 7521 | } | ||
| 7522 | } | ||
| 7523 | const context = interruptContext; | ||
| 7524 | if (context?.providerTurnId !== turnInput.providerTurnId) { | ||
| 7525 | // A soft steering interrupt can clear the turn while | ||
| 7526 | // intentionally leaving the process alive. A queued user | ||
| 7527 | // Stop must still contain that orphan runtime. With a | ||
| 7528 | // different live turn active this stays a pure no-op | ||
| 7529 | // success instead: quarantine and teardown are | ||
| 7530 | // session-global and would maim the replacement turn. | ||
| 7531 | const containOrphanRuntime = | ||
| 7532 | context === null && | ||
| 7533 | hardRestart && | ||
| 7534 | turnInput.requestRuntimeRestart === true; | ||
| 7535 | // Transport death or a prior Stop already cleared the | ||
| 7536 | // turn. Failing here caused effect-worker retries while | ||
| 7537 | // the process was already gone; treat as success. | ||
| 7538 | yield* Effect.logWarning( | ||
| 7539 | containOrphanRuntime | ||
| 7540 | ? "ACP Stop raced a soft interrupt that cleared the turn; containing the orphan runtime" | ||
| 7541 | : "ACP interrupt raced transport teardown or a prior Stop; treating as already interrupted", | ||
| 7542 | { | ||
| 7543 | driver, | ||
| 7544 | requestedProviderTurnId: turnInput.providerTurnId, | ||
| 7545 | activeProviderTurnId: context?.providerTurnId ?? null, | ||
| 7546 | hardRestart, | ||
| 7547 | requestRuntimeRestart: turnInput.requestRuntimeRestart === true, | ||
| 7548 | teardownState: (yield* Ref.get(runtimeTeardownState))._tag, | ||
| 7549 | }, | ||
| 7550 | ); | ||
| 7551 | if (containOrphanRuntime) { | ||
| 7552 | const teardownBarrier = yield* Deferred.make< | ||
| 7553 | void, | ||
| 7554 | ProviderAdapter.ProviderAdapterProtocolError | ||
| 7555 | >(); | ||
| 7556 | yield* runtimeCallbackPermit.withPermit( | ||
| 7557 | Effect.gen(function* () { | ||
| 7558 | yield* awaitAdmittedNativeResponses; | ||
| 7559 | const stoppedGeneration = yield* Ref.get(runtimeCallbackGeneration); | ||
| 7560 | yield* quarantineNativeTransportAtGeneration(stoppedGeneration); | ||
| 7561 | yield* Ref.set(runtimeTeardownState, { | ||
| 7562 | _tag: "InProgress", | ||
| 7563 | completed: teardownBarrier, | ||
| 7564 | }); | ||
| 7565 | yield* advanceRuntimeCallbackGeneration; | ||
| 7566 | }), | ||
| 7567 | ); | ||
| 7568 | // Capture before quarantineStoppedRun clears carryover. | ||
| 7569 | const orphanCarryover = yield* Ref.getAndSet(carryoverSubagents, null); | ||
| 7570 | yield* quarantineStoppedRun(); | ||
| 7571 | // Match the main hard-restart path: cancel pending | ||
| 7572 | // approvals/elicitations after quarantine, before kill. | ||
| 7573 | yield* cancelPendingRuntimeRequests(); | ||
| 7574 | yield* terminalizeCarryoverSubagents(orphanCarryover); | ||
| 7575 | if (runtime.terminateProcessGroup === undefined) { | ||
| 7576 | const error = new ProviderAdapter.ProviderAdapterProtocolError({ | ||
| 7577 | driver, | ||
| 7578 | detail: | ||
| 7579 | "ACP runtime does not expose its required process-group teardown; the session is poisoned", | ||
| 7580 | }); | ||
| 7581 | yield* Ref.set(runtimeTeardownState, { _tag: "Failed", error }); | ||
| 7582 | yield* Deferred.fail(teardownBarrier, error).pipe(Effect.ignore); | ||
| 7583 | return yield* error; | ||
| 7584 | } | ||
| 7585 | const teardownExit = yield* runtime.terminateProcessGroup.pipe( | ||
| 7586 | Effect.exit, | ||
| 7587 | ); | ||
| 7588 | if (Exit.isFailure(teardownExit)) { | ||
| 7589 | const error = new ProviderAdapter.ProviderAdapterProtocolError({ | ||
| 7590 | driver, | ||
| 7591 | detail: | ||
| 7592 | "ACP orphan runtime process-group teardown failed; the session is poisoned", | ||
| 7593 | payload: Cause.squash(teardownExit.cause), | ||
| 7594 | }); | ||
| 7595 | yield* Ref.set(runtimeTeardownState, { _tag: "Failed", error }); | ||
| 7596 | yield* Deferred.fail(teardownBarrier, error).pipe(Effect.ignore); | ||
| 7597 | return yield* error; | ||
| 7598 | } | ||
| 7599 | yield* Ref.set(runtimeRestartRequired, true); | ||
| 7600 | yield* Ref.set(runtimeTeardownState, { _tag: "Idle" }); | ||
| 7601 | yield* Deferred.succeed(teardownBarrier, undefined).pipe(Effect.ignore); | ||
| 7602 | } | ||
| 7603 | return undefined; | ||
| 7604 | } | ||
| 7605 | const teardownBarrier = hardRestart | ||
| 7606 | ? yield* Deferred.make<void, ProviderAdapter.ProviderAdapterProtocolError>() | ||
| 7607 | : null; | ||
| 7608 | if (teardownBarrier !== null) { | ||
| 7609 | yield* runtimeCallbackPermit.withPermit( | ||
| 7610 | Effect.gen(function* () { | ||
| 7611 | yield* awaitAdmittedNativeResponses; | ||
| 7612 | const stoppedGeneration = yield* Ref.get(runtimeCallbackGeneration); | ||
| 7613 | yield* quarantineNativeTransportAtGeneration(stoppedGeneration); | ||
| 7614 | yield* Ref.set(runtimeTeardownState, { | ||
| 7615 | _tag: "InProgress", | ||
| 7616 | completed: teardownBarrier, | ||
| 7617 | }); | ||
| 7618 | yield* advanceRuntimeCallbackGeneration; | ||
| 7619 | yield* ( | ||
| 7620 | options.testHooks?.afterHardTeardownTransportDrained?.() ?? | ||
| 7621 | Effect.void | ||
| 7622 | ); | ||
| 7623 | }), | ||
| 7624 | ); | ||
| 7625 | } | ||
| 7626 | return { | ||
| 7627 | context, | ||
| 7628 | restartRuntime, | ||
| 7629 | hardRestart, | ||
| 7630 | teardownBarrier, | ||
| 7631 | settledSoftInterrupt, | ||
| 7632 | }; | ||
| 7633 | }); | ||
| 7634 | // Concurrent hard teardown already completed above. | ||
| 7635 | if (transition === undefined) return; | ||
| 7636 | const { | ||
| 7637 | context, | ||
| 7638 | restartRuntime, | ||
| 7639 | hardRestart, | ||
| 7640 | teardownBarrier, | ||
| 7641 | settledSoftInterrupt, | ||
| 7642 | } = transition; | ||
| 7643 | const poisonTeardown = Effect.fnUntraced(function* ( | ||
| 7644 | detail: string, | ||
| 7645 | payload?: unknown, | ||
| 7646 | ) { | ||
| 7647 | const error = new ProviderAdapter.ProviderAdapterProtocolError({ | ||
| 7648 | driver, | ||
| 7649 | detail, | ||
| 7650 | ...(payload === undefined ? {} : { payload }), | ||
| 7651 | }); | ||
| 7652 | yield* Ref.set(runtimeTeardownState, { _tag: "Failed", error }); | ||
| 7653 | yield* Deferred.fail(teardownBarrier!, error).pipe(Effect.ignore); | ||
| 7654 | return error; | ||
| 7655 | }); | ||
| 7656 | const runTransition = Effect.gen(function* () { | ||
| 7657 | context.interrupted = true; | ||
| 7658 | // Quarantine only when this run is discarded (user Stop / | ||
| 7659 | // hard process kill). Soft in-process restarts must keep | ||
| 7660 | // carryoverSubagents so a still-running subagent can | ||
| 7661 | // complete into the replacement turn. | ||
| 7662 | if (hardRestart || turnInput.requestRuntimeRestart === true) { | ||
| 7663 | yield* quarantineStoppedRun(); | ||
| 7664 | } | ||
| 7665 | yield* cancelPendingRuntimeRequests(); | ||
| 7666 | // Finalize before process-group kill so projection/UI cannot | ||
| 7667 | // lag a dead transport, and concurrent interrupt effects see | ||
| 7668 | // activeTurn cleared (idempotent success) rather than racing. | ||
| 7669 | if ( | ||
| 7670 | (hardRestart || context.promptSettled || settledSoftInterrupt) && | ||
| 7671 | !context.finalized | ||
| 7672 | ) { | ||
| 7673 | // hardRestart: always terminalize locally. | ||
| 7674 | // promptSettled / settledSoftInterrupt (incl. wire-settled): | ||
| 7675 | // native prompt already returned; only deferred background | ||
| 7676 | // work remains, so session/cancel has nothing to acknowledge. | ||
| 7677 | yield* finalizeTurn(context, "interrupted"); | ||
| 7678 | } | ||
| 7679 | if (hardRestart) { | ||
| 7680 | if (runtime.terminateProcessGroup === undefined) { | ||
| 7681 | return yield* poisonTeardown( | ||
| 7682 | "ACP runtime does not expose its required process-group teardown; the session is poisoned", | ||
| 7683 | ).pipe(Effect.flatMap(Effect.fail)); | ||
| 7684 | } | ||
| 7685 | const teardownExit = yield* runtime.terminateProcessGroup!.pipe( | ||
| 7686 | Effect.exit, | ||
| 7687 | ); | ||
| 7688 | if (Exit.isFailure(teardownExit)) { | ||
| 7689 | return yield* poisonTeardown( | ||
| 7690 | "ACP runtime process-group teardown failed; the session is poisoned", | ||
| 7691 | Cause.squash(teardownExit.cause), | ||
| 7692 | ).pipe(Effect.flatMap(Effect.fail)); | ||
| 7693 | } | ||
| 7694 | // Process group is gone; the next turn must spawn a | ||
| 7695 | // replacement even if the flavor only set hardRestart | ||
| 7696 | // without restartRuntimeAfterInterrupt. | ||
| 7697 | yield* Ref.set(runtimeRestartRequired, true); | ||
| 7698 | yield* Ref.set(runtimeTeardownState, { _tag: "Idle" }); | ||
| 7699 | yield* Deferred.succeed(teardownBarrier!, undefined).pipe(Effect.ignore); | ||
| 7700 | } else { | ||
| 7701 | // Settled soft interrupt: the native prompt already | ||
| 7702 | // returned, so session/cancel has nothing to | ||
| 7703 | // acknowledge and would only threaten still-running | ||
| 7704 | // background subagents. Skip it and leave the runtime | ||
| 7705 | // untouched for the replacement turn. | ||
| 7706 | if (!settledSoftInterrupt) { | ||
| 7707 | yield* runtime.cancel; | ||
| 7708 | } | ||
| 7709 | if (restartRuntime && flavor.restartRuntimeAfterInterrupt === true) { | ||
| 7710 | yield* Ref.set(runtimeRestartRequired, true); | ||
| 7711 | } | ||
| 7712 | } | ||
| 7713 | const stopped = yield* Deferred.await(context.completed).pipe( | ||
| 7714 | Effect.timeoutOption("10 seconds"), | ||
| 7715 | ); | ||
| 7716 | if (Option.isNone(stopped)) { | ||
| 7717 | if (!context.finalized) { | ||
| 7718 | yield* finalizeTurn(context, "interrupted"); | ||
| 7719 | } | ||
| 7720 | return yield* new ProviderAdapter.ProviderAdapterProtocolError({ | ||
| 7721 | driver, | ||
| 7722 | detail: `ACP provider turn ${turnInput.providerTurnId} did not acknowledge cancellation before the interrupt timeout`, | ||
| 7723 | }); | ||
| 7724 | } | ||
| 7725 | }); | ||
| 7726 | if (!hardRestart) { | ||
| 7727 | return yield* restore(runTransition); | ||
| 7728 | } | ||
| 7729 | return yield* runTransition.pipe( | ||
| 7730 | Effect.catchCause((cause) => | ||
| 7731 | Effect.gen(function* () { | ||
| 7732 | const state = yield* Ref.get(runtimeTeardownState); | ||
| 7733 | if (state._tag === "InProgress") { | ||
| 7734 | yield* poisonTeardown( | ||
| 7735 | "ACP hard teardown failed unexpectedly; the session is poisoned", | ||
| 7736 | Cause.squash(cause), | ||
| 7737 | ); | ||
| 7738 | } | ||
| 7739 | return yield* Effect.failCause(cause); | ||
| 7740 | }), | ||
| 7741 | ), | ||
| 7742 | ); | ||
| 7743 | }).pipe(Effect.ensuring(runtimeTransitionPermit.release(1))); | ||
| 7744 | }), | ||
| 7745 | ); | ||
| 7746 | }, | ||
| 7747 | (effect, turnInput) => | ||
| 7748 | effect.pipe( | ||
| 7749 | Effect.mapError( | ||
| 7750 | (cause) => | ||
| 7751 | new ProviderAdapter.ProviderAdapterInterruptError({ | ||
| 7752 | driver, | ||
| 7753 | providerThreadId: turnInput.providerThread.id, | ||
| 7754 | providerTurnId: turnInput.providerTurnId, | ||
| 7755 | cause, | ||
| 7756 | }), | ||
| 7757 | ), | ||
| 7758 | ), | ||
| 7759 | ), | ||
| 7760 | respondToRuntimeRequest: (requestInput) => | ||
| 7761 | Effect.uninterruptibleMask((restore) => | ||
| 7762 | Effect.gen(function* () { | ||
| 7763 | yield* restore(runtimeTransitionPermit.take(1)); | ||
| 7764 | return yield* Effect.gen(function* () { | ||
| 7765 | yield* awaitRuntimeTeardown(); | ||
| 7766 | const generation = yield* Ref.get(runtimeCallbackGeneration); | ||
| 7767 | const pending = (yield* Ref.get(pendingRuntimeRequests)).get( | ||
| 7768 | String(requestInput.requestId), | ||
| 7769 | ); | ||
| 7770 | if (pending === undefined || pending.generation !== generation) { | ||
| 7771 | return yield* new ProviderAdapter.ProviderAdapterProtocolError({ | ||
| 7772 | driver, | ||
| 7773 | detail: `No pending ACP runtime request ${requestInput.requestId}`, | ||
| 7774 | }); | ||
| 7775 | } | ||
| 7776 | const settled = | ||
| 7777 | pending.type === "user_input" | ||
| 7778 | ? yield* Deferred.succeed(pending.answers, requestInput.answers ?? null) | ||
| 7779 | : requestInput.decision === undefined | ||
| 7780 | ? yield* new ProviderAdapter.ProviderAdapterProtocolError({ | ||
| 7781 | driver, | ||
| 7782 | detail: `ACP approval request ${requestInput.requestId} requires a decision`, | ||
| 7783 | }) | ||
| 7784 | : yield* Deferred.succeed(pending.decision, requestInput.decision); | ||
| 7785 | if (!settled) { | ||
| 7786 | return yield* new ProviderAdapter.ProviderAdapterProtocolError({ | ||
| 7787 | driver, | ||
| 7788 | detail: `ACP runtime request ${requestInput.requestId} was already resolved`, | ||
| 7789 | }); | ||
| 7790 | } | ||
| 7791 | yield* awaitNativeResponseAcknowledgements([ | ||
| 7792 | [pending.transportRequestId, pending.nativeResponseAcknowledgement], | ||
| 7793 | ]); | ||
| 7794 | yield* Deferred.await(pending.nativeResponseAcknowledgement); | ||
| 7795 | }).pipe(Effect.ensuring(runtimeTransitionPermit.release(1))); | ||
| 7796 | }), | ||
| 7797 | ).pipe( | ||
| 7798 | Effect.mapError( | ||
| 7799 | (cause) => | ||
| 7800 | new ProviderAdapter.ProviderAdapterRuntimeRequestResponseError({ | ||
| 7801 | driver, | ||
| 7802 | requestId: requestInput.requestId, | ||
| 7803 | cause, | ||
| 7804 | }), | ||
| 7805 | ), | ||
| 7806 | ), | ||
| 7807 | readThreadSnapshot: Effect.fn("AcpAdapterV2.readThreadSnapshot")( | ||
| 7808 | function* (snapshotInput) { | ||
| 7809 | return yield* runtimeTransitionPermit.withPermit( | ||
| 7810 | Effect.gen(function* () { | ||
| 7811 | yield* awaitRuntimeTeardown(); | ||
| 7812 | useProviderThreadIdentity(snapshotInput.providerThread); | ||
| 7813 | yield* restartRuntimeAfterTeardownIfRequired( | ||
| 7814 | snapshotInput.providerThread.appThreadId, | ||
| 7815 | ); | ||
| 7816 | const sessionId = yield* nativeThreadId(driver, snapshotInput.providerThread); | ||
| 7817 | if ((yield* Ref.get(activeSessionId)) !== sessionId) { | ||
| 7818 | if (!capabilities.threads.canReadThreadSnapshot) { | ||
| 7819 | return yield* new ProviderAdapter.ProviderAdapterProtocolError({ | ||
| 7820 | driver, | ||
| 7821 | detail: "ACP driver does not support session/load snapshots", | ||
| 7822 | }); | ||
| 7823 | } | ||
| 7824 | yield* Ref.set(snapshot, { | ||
| 7825 | order: [], | ||
| 7826 | messages: new Map(), | ||
| 7827 | loadingRole: null, | ||
| 7828 | loadingMessageId: null, | ||
| 7829 | loadingIndex: 0, | ||
| 7830 | }); | ||
| 7831 | prepareTerminalEnvironment(snapshotInput.providerThread.appThreadId, sessionId); | ||
| 7832 | const activated = yield* runtime.loadSession( | ||
| 7833 | sessionId, | ||
| 7834 | acpMcpActivation(snapshotInput.providerThread.appThreadId, self), | ||
| 7835 | ); | ||
| 7836 | rememberTerminalEnvironment( | ||
| 7837 | activated.sessionId, | ||
| 7838 | snapshotInput.providerThread.appThreadId, | ||
| 7839 | ); | ||
| 7840 | yield* Ref.set(activeSessionId, activated.sessionId); | ||
| 7841 | yield* Ref.set(activeSessionSetup, activated); | ||
| 7842 | yield* Ref.set(activeSelection, null); | ||
| 7843 | yield* Ref.set(activeInteractionMode, null); | ||
| 7844 | } | ||
| 7845 | const state = yield* Ref.get(snapshot); | ||
| 7846 | const now = yield* DateTime.now; | ||
| 7847 | return { | ||
| 7848 | providerThread: { | ||
| 7849 | ...snapshotInput.providerThread, | ||
| 7850 | providerSessionId: input.providerSessionId, | ||
| 7851 | status: "idle" as const, | ||
| 7852 | updatedAt: now, | ||
| 7853 | }, | ||
| 7854 | providerTurns: [...(yield* Ref.get(providerTurns)).values()], | ||
| 7855 | messages: state.order.flatMap((key) => { | ||
| 7856 | const message = state.messages.get(key); | ||
| 7857 | return message === undefined ? [] : [message]; | ||
| 7858 | }), | ||
| 7859 | runtimeRequests: [], | ||
| 7860 | providerPayload: { protocol: ACP_PROTOCOL, sessionId }, | ||
| 7861 | }; | ||
| 7862 | }), | ||
| 7863 | ); | ||
| 7864 | }, | ||
| 7865 | (effect, snapshotInput) => | ||
| 7866 | effect.pipe( | ||
| 7867 | Effect.mapError( | ||
| 7868 | (cause) => | ||
| 7869 | new ProviderAdapter.ProviderAdapterReadThreadSnapshotError({ | ||
| 7870 | driver, | ||
| 7871 | providerThreadId: snapshotInput.providerThread.id, | ||
| 7872 | cause, | ||
| 7873 | }), | ||
| 7874 | ), | ||
| 7875 | ), | ||
| 7876 | ), | ||
| 7877 | rollbackThread: (rollbackInput) => | ||
| 7878 | runtimeTransitionPermit | ||
| 7879 | .withPermit( | ||
| 7880 | Effect.gen(function* () { | ||
| 7881 | const currentTurn = yield* Ref.get(activeTurn); | ||
| 7882 | if (currentTurn !== null) { | ||
| 7883 | return yield* new ProviderAdapter.ProviderAdapterProtocolError({ | ||
| 7884 | driver, | ||
| 7885 | detail: `Cannot roll back ACP provider thread ${rollbackInput.providerThread.id} while turn ${currentTurn.providerTurnId} is active`, | ||
| 7886 | }); | ||
| 7887 | } | ||
| 7888 | // ACP defines no conversation truncation, so rollback stages | ||
| 7889 | // a fresh native session before retiring the original. | ||
| 7890 | // Returning its binding keeps the next turn runnable without | ||
| 7891 | // loading any of the rolled-back conversation. | ||
| 7892 | yield* awaitRuntimeTeardown(); | ||
| 7893 | itemIdentityVersion = 2; | ||
| 7894 | prepareTerminalEnvironment(rollbackInput.providerThread.appThreadId); | ||
| 7895 | const replacement = yield* startReplacementAcpRuntime( | ||
| 7896 | rollbackInput.providerThread.appThreadId, | ||
| 7897 | (candidate) => | ||
| 7898 | Effect.gen(function* () { | ||
| 7899 | rememberTerminalEnvironment( | ||
| 7900 | candidate.sessionId, | ||
| 7901 | rollbackInput.providerThread.appThreadId, | ||
| 7902 | ); | ||
| 7903 | yield* Ref.set(runtimeRestartRequired, false); | ||
| 7904 | yield* Ref.set(activeSessionId, candidate.sessionId); | ||
| 7905 | yield* Ref.set(activeSessionSetup, candidate); | ||
| 7906 | yield* Ref.set(activeSelection, null); | ||
| 7907 | yield* Ref.set(activeInteractionMode, null); | ||
| 7908 | yield* Ref.set(promptInstructionStates, new Map()); | ||
| 7909 | yield* Ref.set(providerTurns, new Map()); | ||
| 7910 | yield* Ref.set(snapshot, { | ||
| 7911 | order: [], | ||
| 7912 | messages: new Map(), | ||
| 7913 | loadingRole: null, | ||
| 7914 | loadingMessageId: null, | ||
| 7915 | loadingIndex: 0, | ||
| 7916 | }); | ||
| 7917 | yield* continuationPermit.withPermit( | ||
| 7918 | Effect.gen(function* () { | ||
| 7919 | yield* Ref.update(continuationGeneration, (value) => value + 1); | ||
| 7920 | yield* Ref.set(stoppedRunQuarantine, false); | ||
| 7921 | yield* Ref.set(wakeBuffer, []); | ||
| 7922 | yield* Ref.set(wakeReports, noWakeReports); | ||
| 7923 | yield* Ref.set(continuationRequested, false); | ||
| 7924 | yield* Ref.set(runningBackgroundTaskIds, new Set()); | ||
| 7925 | yield* Ref.set(endedBackgroundTaskIds, new Set()); | ||
| 7926 | yield* Ref.set(midTurnUnreportedCompletedTaskIds, new Set()); | ||
| 7927 | yield* Ref.set(handledBackgroundTaskIdsInActiveTurn, new Set()); | ||
| 7928 | yield* Ref.set(carryoverSubagents, null); | ||
| 7929 | yield* Ref.set(suppressPostSettleMonitorPrompt, false); | ||
| 7930 | yield* Ref.set(lastTurnRoute, null); | ||
| 7931 | }), | ||
| 7932 | ); | ||
| 7933 | }), | ||
| 7934 | ).pipe(Effect.retry({ times: 1 })); | ||
| 7935 | const now = yield* DateTime.now; | ||
| 7936 | return { | ||
| 7937 | providerThread: { | ||
| 7938 | ...rollbackInput.providerThread, | ||
| 7939 | nativeThreadRef: { | ||
| 7940 | driver, | ||
| 7941 | nativeId: replacement.sessionId, | ||
| 7942 | strength: "strong" as const, | ||
| 7943 | }, | ||
| 7944 | nativeConversationHeadRef: null, | ||
| 7945 | nativeMetadata: { | ||
| 7946 | ...rollbackInput.providerThread.nativeMetadata, | ||
| 7947 | itemIdentityVersion: 2 as const, | ||
| 7948 | }, | ||
| 7949 | status: "idle" as const, | ||
| 7950 | updatedAt: now, | ||
| 7951 | }, | ||
| 7952 | providerTurns: [], | ||
| 7953 | messages: [], | ||
| 7954 | runtimeRequests: [], | ||
| 7955 | }; | ||
| 7956 | }), | ||
| 7957 | ) | ||
| 7958 | .pipe( | ||
| 7959 | Effect.mapError( | ||
| 7960 | (cause) => | ||
| 7961 | new ProviderAdapter.ProviderAdapterRollbackThreadError({ | ||
| 7962 | driver, | ||
| 7963 | providerThreadId: rollbackInput.providerThread.id, | ||
| 7964 | checkpointId: rollbackInput.target.checkpointId, | ||
| 7965 | cause, | ||
| 7966 | }), | ||
| 7967 | ), | ||
| 7968 | ), | ||
| 7969 | forkThread: Effect.fn("AcpAdapterV2.forkThread")( | ||
| 7970 | function* (forkInput) { | ||
| 7971 | return yield* runtimeTransitionPermit.withPermit( | ||
| 7972 | Effect.gen(function* () { | ||
| 7973 | yield* awaitRuntimeTeardown(); | ||
| 7974 | useProviderThreadIdentity(forkInput.sourceProviderThread); | ||
| 7975 | yield* restartRuntimeAfterTeardownIfRequired( | ||
| 7976 | forkInput.sourceProviderThread.appThreadId, | ||
| 7977 | ); | ||
| 7978 | if (!capabilities.threads.canForkThread) { | ||
| 7979 | return yield* new ProviderAdapter.ProviderAdapterProtocolError({ | ||
| 7980 | driver, | ||
| 7981 | detail: "ACP driver did not negotiate session/fork", | ||
| 7982 | }); | ||
| 7983 | } | ||
| 7984 | if (forkInput.providerTurnId !== undefined) { | ||
| 7985 | return yield* new ProviderAdapter.ProviderAdapterProtocolError({ | ||
| 7986 | driver, | ||
| 7987 | detail: "ACP session/fork can only fork the current session head", | ||
| 7988 | }); | ||
| 7989 | } | ||
| 7990 | const sourceSessionId = yield* nativeThreadId( | ||
| 7991 | driver, | ||
| 7992 | forkInput.sourceProviderThread, | ||
| 7993 | ); | ||
| 7994 | prepareTerminalEnvironment(forkInput.targetThreadId); | ||
| 7995 | const forked = yield* runtime.forkSession( | ||
| 7996 | sourceSessionId, | ||
| 7997 | acpMcpActivation(forkInput.targetThreadId, self), | ||
| 7998 | ); | ||
| 7999 | rememberTerminalEnvironment(forked.sessionId, forkInput.targetThreadId); | ||
| 8000 | yield* Ref.set(activeSessionId, forked.sessionId); | ||
| 8001 | yield* Ref.set(activeSessionSetup, forked); | ||
| 8002 | yield* Ref.set(activeSelection, null); | ||
| 8003 | yield* Ref.set(activeInteractionMode, null); | ||
| 8004 | itemIdentityVersion = 2; | ||
| 8005 | const now = yield* DateTime.now; | ||
| 8006 | const providerThread = makeProviderThread({ | ||
| 8007 | driver, | ||
| 8008 | providerInstanceId: options.instanceId, | ||
| 8009 | idAllocator, | ||
| 8010 | appThreadId: forkInput.targetThreadId, | ||
| 8011 | providerSessionId: input.providerSessionId, | ||
| 8012 | nativeThreadId: forked.sessionId, | ||
| 8013 | itemIdentityVersion: 2, | ||
| 8014 | ...(forkInput.ownerNodeId === undefined | ||
| 8015 | ? {} | ||
| 8016 | : { ownerNodeId: forkInput.ownerNodeId }), | ||
| 8017 | forkedFrom: { | ||
| 8018 | providerThreadId: forkInput.sourceProviderThread.id, | ||
| 8019 | ...(forkInput.providerTurnId === undefined | ||
| 8020 | ? {} | ||
| 8021 | : { providerTurnId: forkInput.providerTurnId }), | ||
| 8022 | }, | ||
| 8023 | now, | ||
| 8024 | }); | ||
| 8025 | yield* Ref.update(providerThreadByNativeSessionId, (current) => | ||
| 8026 | new Map(current).set(forked.sessionId, providerThread), | ||
| 8027 | ); | ||
| 8028 | return providerThread; | ||
| 8029 | }), | ||
| 8030 | ); | ||
| 8031 | }, | ||
| 8032 | (effect, forkInput) => | ||
| 8033 | effect.pipe( | ||
| 8034 | Effect.mapError( | ||
| 8035 | (cause) => | ||
| 8036 | new ProviderAdapter.ProviderAdapterForkThreadError({ | ||
| 8037 | driver, | ||
| 8038 | providerThreadId: forkInput.sourceProviderThread.id, | ||
| 8039 | cause, | ||
| 8040 | }), | ||
| 8041 | ), | ||
| 8042 | ), | ||
| 8043 | ), | ||
| 8044 | }; | ||
| 8045 | return sessionRuntime; | ||
| 8046 | }, | ||
| 8047 | (effect, input) => | ||
| 8048 | effect.pipe( | ||
| 8049 | Effect.mapError( | ||
| 8050 | (cause) => | ||
| 8051 | new ProviderAdapter.ProviderAdapterOpenSessionError({ | ||
| 8052 | driver, | ||
| 8053 | providerSessionId: input.providerSessionId, | ||
| 8054 | cause, | ||
| 8055 | }), | ||
| 8056 | ), | ||
| 8057 | ), | ||
| 8058 | ), | ||
| 8059 | }); | ||
| 8060 | } | ||
| 8061 | |||
| 8062 | export type AcpAdapterV2Env = FileSystem.FileSystem | IdAllocator.IdAllocatorV2; | ||
apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts+13-14| ... | @@ -14,15 +14,16 @@ import { HttpClient, HttpClientResponse } from "effect/http"; | ... | @@ -14,15 +14,16 @@ import { HttpClient, HttpClientResponse } from "effect/http"; |
| 14 | import { ChildProcessSpawner } from "effect/process"; | 14 | import { ChildProcessSpawner } from "effect/process"; |
| 15 | import * as EffectAcpErrors from "effect-acp/errors"; | 15 | import * as EffectAcpErrors from "effect-acp/errors"; |
| 16 | 16 | ||
| 17 | import * as ServerConfig from "../../config.ts"; | 17 | import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; |
| 18 | import { layerTestProviderHost } from "@t3tools/provider-testing/host"; | ||
| 18 | import * as ServerSettings from "../../serverSettings.ts"; | 19 | import * as ServerSettings from "../../serverSettings.ts"; |
| 19 | import type { | 20 | import type { |
| 20 | AcpRegistryAvailableCommands, | 21 | AcpRegistryAvailableCommands, |
| 21 | AcpRegistryLiveConfiguration, | 22 | AcpRegistryLiveConfiguration, |
| 22 | } from "../../provider/acp/AcpRegistryProbe.ts"; | 23 | } from "../../provider/acp/AcpRegistryProbe.ts"; |
| 23 | import * as AcpRegistrySupport from "../../provider/acp/AcpRegistrySupport.ts"; | 24 | import * as AcpRegistrySupport from "../../provider/acp/AcpRegistrySupport.ts"; |
| 24 | import { ACP_SESSION_MODE_OPTION_ID } from "../../provider/acp/AcpSessionConfig.ts"; | 25 | import { ACP_SESSION_MODE_OPTION_ID } from "@t3tools/provider-acp/server/sessionConfig"; |
| 25 | import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts"; | 26 | import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; |
| 26 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; | 27 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; |
| 27 | import { | 28 | import { |
| 28 | decodeAcpReplayTranscript, | 29 | decodeAcpReplayTranscript, |
| ... | @@ -43,9 +44,7 @@ const decodeAcpRegistryAdapterSettings = Schema.decodeUnknownEffect( | ... | @@ -43,9 +44,7 @@ const decodeAcpRegistryAdapterSettings = Schema.decodeUnknownEffect( |
| 43 | AcpRegistryAdapterV2Driver.configSchema, | 44 | AcpRegistryAdapterV2Driver.configSchema, |
| 44 | ); | 45 | ); |
| 45 | 46 | ||
| 46 | const layerServerConfig = ServerConfig.layerTest(process.cwd(), { | 47 | const layerHost = layerTestProviderHost().pipe(Layer.provide(NodeServices.layer)); |
| 47 | prefix: "t3-acp-registry-v2-adapter-", | ||
| 48 | }).pipe(Layer.provide(NodeServices.layer)); | ||
| 49 | 48 | ||
| 50 | const layerRegistry = Layer.succeed( | 49 | const layerRegistry = Layer.succeed( |
| 51 | HttpClient.HttpClient, | 50 | HttpClient.HttpClient, |
| ... | @@ -86,7 +85,7 @@ const layerRegistry = Layer.succeed( | ... | @@ -86,7 +85,7 @@ const layerRegistry = Layer.succeed( |
| 86 | const layerTest = Layer.mergeAll( | 85 | const layerTest = Layer.mergeAll( |
| 87 | NodeServices.layer, | 86 | NodeServices.layer, |
| 88 | IdAllocator.layer, | 87 | IdAllocator.layer, |
| 89 | layerServerConfig, | 88 | layerHost, |
| 90 | layerRegistry, | 89 | layerRegistry, |
| 91 | ServerSettings.layerTest(), | 90 | ServerSettings.layerTest(), |
| 92 | ); | 91 | ); |
| ... | @@ -202,7 +201,7 @@ describe("AcpRegistryAdapterV2", () => { | ... | @@ -202,7 +201,7 @@ describe("AcpRegistryAdapterV2", () => { |
| 202 | fileSystem, | 201 | fileSystem, |
| 203 | idAllocator: yield* IdAllocator.IdAllocatorV2, | 202 | idAllocator: yield* IdAllocator.IdAllocatorV2, |
| 204 | resolver: { resolve: () => Effect.die("the runtime is injected") }, | 203 | resolver: { resolve: () => Effect.die("the runtime is injected") }, |
| 205 | serverConfig: yield* ServerConfig.ServerConfig, | 204 | host: yield* ProviderHost.ProviderHost, |
| 206 | makeRuntime: makeAcpReplayRuntime({ | 205 | makeRuntime: makeAcpReplayRuntime({ |
| 207 | transcript, | 206 | transcript, |
| 208 | statusPath, | 207 | statusPath, |
| ... | @@ -272,7 +271,7 @@ describe("AcpRegistryAdapterV2", () => { | ... | @@ -272,7 +271,7 @@ describe("AcpRegistryAdapterV2", () => { |
| 272 | const fileSystem = yield* FileSystem.FileSystem; | 271 | const fileSystem = yield* FileSystem.FileSystem; |
| 273 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 272 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 274 | const path = yield* Path.Path; | 273 | const path = yield* Path.Path; |
| 275 | const serverConfig = yield* ServerConfig.ServerConfig; | 274 | const host = yield* ProviderHost.ProviderHost; |
| 276 | const mockAgentPath = yield* path.fromFileUrl( | 275 | const mockAgentPath = yield* path.fromFileUrl( |
| 277 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 276 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| 278 | ); | 277 | ); |
| ... | @@ -291,7 +290,7 @@ describe("AcpRegistryAdapterV2", () => { | ... | @@ -291,7 +290,7 @@ describe("AcpRegistryAdapterV2", () => { |
| 291 | fileSystem, | 290 | fileSystem, |
| 292 | idAllocator, | 291 | idAllocator, |
| 293 | resolver: { resolve: () => Effect.die("the runtime is injected") }, | 292 | resolver: { resolve: () => Effect.die("the runtime is injected") }, |
| 294 | serverConfig, | 293 | host, |
| 295 | makeRuntime: (input) => | 294 | makeRuntime: (input) => |
| 296 | Effect.gen(function* () { | 295 | Effect.gen(function* () { |
| 297 | clientCapabilities = input.clientCapabilities; | 296 | clientCapabilities = input.clientCapabilities; |
| ... | @@ -348,13 +347,13 @@ describe("AcpRegistryAdapterV2", () => { | ... | @@ -348,13 +347,13 @@ describe("AcpRegistryAdapterV2", () => { |
| 348 | const fileSystem = yield* FileSystem.FileSystem; | 347 | const fileSystem = yield* FileSystem.FileSystem; |
| 349 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 348 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 350 | const path = yield* Path.Path; | 349 | const path = yield* Path.Path; |
| 351 | const serverConfig = yield* ServerConfig.ServerConfig; | 350 | const host = yield* ProviderHost.ProviderHost; |
| 352 | const mockAgentPath = yield* path.fromFileUrl( | 351 | const mockAgentPath = yield* path.fromFileUrl( |
| 353 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 352 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| 354 | ); | 353 | ); |
| 355 | const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | 354 | const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ |
| 356 | cacheDir: serverConfig.providerStatusCacheDir, | 355 | cacheDir: host.paths.providerStatusCacheDir, |
| 357 | toolsDir: serverConfig.baseDir + "/tools", | 356 | toolsDir: host.paths.baseDir + "/tools", |
| 358 | registryUrl, | 357 | registryUrl, |
| 359 | }); | 358 | }); |
| 360 | const settings = yield* decodeAcpRegistryAdapterSettings({ | 359 | const settings = yield* decodeAcpRegistryAdapterSettings({ |
| ... | @@ -429,7 +428,7 @@ describe("AcpRegistryAdapterV2", () => { | ... | @@ -429,7 +428,7 @@ describe("AcpRegistryAdapterV2", () => { |
| 429 | })), | 428 | })), |
| 430 | ), | 429 | ), |
| 431 | }, | 430 | }, |
| 432 | serverConfig, | 431 | host, |
| 433 | }); | 432 | }); |
| 434 | const threadId = ThreadId.make("thread-acp-registry-fixture"); | 433 | const threadId = ThreadId.make("thread-acp-registry-fixture"); |
| 435 | const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | 434 | const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ |
apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.testkit.ts+6-9| ... | @@ -9,12 +9,12 @@ import * as Path from "effect/Path"; | ... | @@ -9,12 +9,12 @@ import * as Path from "effect/Path"; |
| 9 | import * as Schema from "effect/Schema"; | 9 | import * as Schema from "effect/Schema"; |
| 10 | import { ChildProcessSpawner } from "effect/process"; | 10 | import { ChildProcessSpawner } from "effect/process"; |
| 11 | 11 | ||
| 12 | import * as ServerConfig from "../../config.ts"; | 12 | import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; |
| 13 | import { layerTestProviderHost } from "@t3tools/provider-testing/host"; | ||
| 13 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; | 14 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; |
| 14 | import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts"; | 15 | import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts"; |
| 15 | import type { ProviderReplayGate } from "@t3tools/provider-testing/replayGate"; | 16 | import type { ProviderReplayGate } from "@t3tools/provider-testing/replayGate"; |
| 16 | import type { OrchestratorV2ProviderReplayHarness } from "../testkit/ProviderReplayHarness.ts"; | 17 | import type { OrchestratorV2ProviderReplayHarness } from "../testkit/ProviderReplayHarness.ts"; |
| 17 | import { makeReplayServerConfig } from "../testkit/ProviderReplayHarness.ts"; | ||
| 18 | import { | 18 | import { |
| 19 | type AcpReplayTranscript, | 19 | type AcpReplayTranscript, |
| 20 | AcpReplayTranscriptDecodeError, | 20 | AcpReplayTranscriptDecodeError, |
| ... | @@ -37,10 +37,7 @@ function layerAcpRegistryProviderAdapterRegistryReplay( | ... | @@ -37,10 +37,7 @@ function layerAcpRegistryProviderAdapterRegistryReplay( |
| 37 | transcript: AcpReplayTranscript, | 37 | transcript: AcpReplayTranscript, |
| 38 | options: { readonly replayGate?: ProviderReplayGate } = {}, | 38 | options: { readonly replayGate?: ProviderReplayGate } = {}, |
| 39 | ) { | 39 | ) { |
| 40 | const layerServerConfig = Layer.effect( | 40 | const layerHost = layerTestProviderHost().pipe(Layer.provide(NodeServices.layer)); |
| 41 | ServerConfig.ServerConfig, | ||
| 42 | makeReplayServerConfig(`acp-registry-${transcript.scenario}`).pipe(Effect.orDie), | ||
| 43 | ).pipe(Layer.provide(NodeServices.layer)); | ||
| 44 | 41 | ||
| 45 | return ProviderAdapterRegistry.layerFromAdaptersEffect( | 42 | return ProviderAdapterRegistry.layerFromAdaptersEffect( |
| 46 | Effect.gen(function* () { | 43 | Effect.gen(function* () { |
| ... | @@ -49,7 +46,7 @@ function layerAcpRegistryProviderAdapterRegistryReplay( | ... | @@ -49,7 +46,7 @@ function layerAcpRegistryProviderAdapterRegistryReplay( |
| 49 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | 46 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 50 | const crypto = yield* Crypto.Crypto; | 47 | const crypto = yield* Crypto.Crypto; |
| 51 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 48 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 52 | const serverConfig = yield* ServerConfig.ServerConfig; | 49 | const host = yield* ProviderHost.ProviderHost; |
| 53 | const replayGate = options.replayGate; | 50 | const replayGate = options.replayGate; |
| 54 | const replayDir = yield* fileSystem | 51 | const replayDir = yield* fileSystem |
| 55 | .makeTempDirectory({ | 52 | .makeTempDirectory({ |
| ... | @@ -71,7 +68,7 @@ function layerAcpRegistryProviderAdapterRegistryReplay( | ... | @@ -71,7 +68,7 @@ function layerAcpRegistryProviderAdapterRegistryReplay( |
| 71 | resolver: { | 68 | resolver: { |
| 72 | resolve: () => Effect.die("ACP registry resolver must not run during replay"), | 69 | resolve: () => Effect.die("ACP registry resolver must not run during replay"), |
| 73 | }, | 70 | }, |
| 74 | serverConfig, | 71 | host, |
| 75 | selfInvocation: yield* resolveSelfInvocation(), | 72 | selfInvocation: yield* resolveSelfInvocation(), |
| 76 | makeRuntime: makeAcpReplayRuntime({ | 73 | makeRuntime: makeAcpReplayRuntime({ |
| 77 | transcript, | 74 | transcript, |
| ... | @@ -86,7 +83,7 @@ function layerAcpRegistryProviderAdapterRegistryReplay( | ... | @@ -86,7 +83,7 @@ function layerAcpRegistryProviderAdapterRegistryReplay( |
| 86 | return [adapter]; | 83 | return [adapter]; |
| 87 | }), | 84 | }), |
| 88 | ).pipe( | 85 | ).pipe( |
| 89 | Layer.provide(Layer.mergeAll(layerServerConfig, NodeServices.layer, IdAllocator.layer)), | 86 | Layer.provide(Layer.mergeAll(layerHost, NodeServices.layer, IdAllocator.layer)), |
| 90 | // Held inbound lines must not outlive the scenario and wedge teardown. | 87 | // Held inbound lines must not outlive the scenario and wedge teardown. |
| 91 | Layer.merge( | 88 | Layer.merge( |
| 92 | Layer.effectDiscard( | 89 | Layer.effectDiscard( |
apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.ts+9-9| ... | @@ -21,7 +21,7 @@ import type * as Scope from "effect/Scope"; | ... | @@ -21,7 +21,7 @@ import type * as Scope from "effect/Scope"; |
| 21 | import { ChildProcessSpawner } from "effect/process"; | 21 | import { ChildProcessSpawner } from "effect/process"; |
| 22 | import * as EffectAcpErrors from "effect-acp/errors"; | 22 | import * as EffectAcpErrors from "effect-acp/errors"; |
| 23 | 23 | ||
| 24 | import * as ServerConfig from "../../config.ts"; | 24 | import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; |
| 25 | import { | 25 | import { |
| 26 | normalizeAcpRegistryCommands, | 26 | normalizeAcpRegistryCommands, |
| 27 | normalizeAcpRegistryLiveConfiguration, | 27 | normalizeAcpRegistryLiveConfiguration, |
| ... | @@ -29,8 +29,8 @@ import { | ... | @@ -29,8 +29,8 @@ import { |
| 29 | } from "../../provider/acp/AcpRegistryProbe.ts"; | 29 | } from "../../provider/acp/AcpRegistryProbe.ts"; |
| 30 | import * as AcpRegistrySupport from "../../provider/acp/AcpRegistrySupport.ts"; | 30 | import * as AcpRegistrySupport from "../../provider/acp/AcpRegistrySupport.ts"; |
| 31 | import * as AcpRegistryRuntimeCoordinator from "../../provider/acp/AcpRegistryRuntimeCoordinator.ts"; | 31 | import * as AcpRegistryRuntimeCoordinator from "../../provider/acp/AcpRegistryRuntimeCoordinator.ts"; |
| 32 | import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts"; | 32 | import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; |
| 33 | import { makeAcpNativeLoggerFactory } from "../../provider/acp/AcpNativeLogging.ts"; | 33 | import { makeAcpNativeLoggerFactory } from "@t3tools/provider-acp/server/nativeLogging"; |
| 34 | import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts"; | 34 | import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts"; |
| 35 | import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; | 35 | import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; |
| 36 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; | 36 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; |
| ... | @@ -46,7 +46,7 @@ import { | ... | @@ -46,7 +46,7 @@ import { |
| 46 | type AcpAdapterV2ExtensionContext, | 46 | type AcpAdapterV2ExtensionContext, |
| 47 | type AcpAdapterV2Flavor, | 47 | type AcpAdapterV2Flavor, |
| 48 | type AcpAdapterV2RuntimeInput, | 48 | type AcpAdapterV2RuntimeInput, |
| 49 | } from "./AcpAdapterV2.ts"; | 49 | } from "@t3tools/provider-acp/server/adapter"; |
| 50 | 50 | ||
| 51 | export const ACP_REGISTRY_PROVIDER = ProviderDriverKind.make("acpRegistry"); | 51 | export const ACP_REGISTRY_PROVIDER = ProviderDriverKind.make("acpRegistry"); |
| 52 | export const ACP_REGISTRY_DEFAULT_INSTANCE_ID = defaultInstanceIdForDriver(ACP_REGISTRY_PROVIDER); | 52 | export const ACP_REGISTRY_DEFAULT_INSTANCE_ID = defaultInstanceIdForDriver(ACP_REGISTRY_PROVIDER); |
| ... | @@ -65,7 +65,7 @@ export interface AcpRegistryAdapterV2Options { | ... | @@ -65,7 +65,7 @@ export interface AcpRegistryAdapterV2Options { |
| 65 | readonly idAllocator: IdAllocator.IdAllocatorV2["Service"]; | 65 | readonly idAllocator: IdAllocator.IdAllocatorV2["Service"]; |
| 66 | readonly resolver: Pick<AcpRegistrySupport.AcpRegistryCatalog["Service"], "resolve">; | 66 | readonly resolver: Pick<AcpRegistrySupport.AcpRegistryCatalog["Service"], "resolve">; |
| 67 | readonly runtimeCoordinator?: AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator["Service"]; | 67 | readonly runtimeCoordinator?: AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator["Service"]; |
| 68 | readonly serverConfig: ServerConfig.ServerConfig["Service"]; | 68 | readonly host: ProviderHost.ProviderHostShape; |
| 69 | readonly nativeLogging?: Parameters<typeof makeAcpAdapterV2>[0]["nativeLogging"]; | 69 | readonly nativeLogging?: Parameters<typeof makeAcpAdapterV2>[0]["nativeLogging"]; |
| 70 | readonly makeRuntime?: ( | 70 | readonly makeRuntime?: ( |
| 71 | input: AcpAdapterV2RuntimeInput, | 71 | input: AcpAdapterV2RuntimeInput, |
| ... | @@ -243,7 +243,7 @@ export function makeAcpRegistryAdapterV2(options: AcpRegistryAdapterV2Options) { | ... | @@ -243,7 +243,7 @@ export function makeAcpRegistryAdapterV2(options: AcpRegistryAdapterV2Options) { |
| 243 | crypto: options.crypto, | 243 | crypto: options.crypto, |
| 244 | fileSystem: options.fileSystem, | 244 | fileSystem: options.fileSystem, |
| 245 | idAllocator: options.idAllocator, | 245 | idAllocator: options.idAllocator, |
| 246 | serverConfig: options.serverConfig, | 246 | host: options.host, |
| 247 | selfInvocation: options.selfInvocation, | 247 | selfInvocation: options.selfInvocation, |
| 248 | // Per-agent exception (see the note above registerMistralVibeAcpExtensions): | 248 | // Per-agent exception (see the note above registerMistralVibeAcpExtensions): |
| 249 | // Devin runs commands through client terminals and has no ask mode over | 249 | // Devin runs commands through client terminals and has no ask mode over |
| ... | @@ -269,7 +269,7 @@ export type AcpRegistryAdapterV2DriverEnv = | ... | @@ -269,7 +269,7 @@ export type AcpRegistryAdapterV2DriverEnv = |
| 269 | | IdAllocator.IdAllocatorV2 | 269 | | IdAllocator.IdAllocatorV2 |
| 270 | | Path.Path | 270 | | Path.Path |
| 271 | | ProviderEventLoggers.ProviderEventLoggers | 271 | | ProviderEventLoggers.ProviderEventLoggers |
| 272 | | ServerConfig.ServerConfig; | 272 | | ProviderHost.ProviderHost; |
| 273 | 273 | ||
| 274 | export const AcpRegistryAdapterV2Driver: ProviderAdapterDriver< | 274 | export const AcpRegistryAdapterV2Driver: ProviderAdapterDriver< |
| 275 | AcpRegistrySettings, | 275 | AcpRegistrySettings, |
| ... | @@ -287,7 +287,7 @@ export const AcpRegistryAdapterV2Driver: ProviderAdapterDriver< | ... | @@ -287,7 +287,7 @@ export const AcpRegistryAdapterV2Driver: ProviderAdapterDriver< |
| 287 | const fileSystem = yield* FileSystem.FileSystem; | 287 | const fileSystem = yield* FileSystem.FileSystem; |
| 288 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 288 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 289 | const providerEventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers; | 289 | const providerEventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers; |
| 290 | const serverConfig = yield* ServerConfig.ServerConfig; | 290 | const host = yield* ProviderHost.ProviderHost; |
| 291 | const makeNativeLogger = yield* makeAcpNativeLoggerFactory(); | 291 | const makeNativeLogger = yield* makeAcpNativeLoggerFactory(); |
| 292 | const resolver = yield* AcpRegistrySupport.AcpRegistryCatalog; | 292 | const resolver = yield* AcpRegistrySupport.AcpRegistryCatalog; |
| 293 | const runtimeCoordinator = yield* Effect.serviceOption( | 293 | const runtimeCoordinator = yield* Effect.serviceOption( |
| ... | @@ -305,7 +305,7 @@ export const AcpRegistryAdapterV2Driver: ProviderAdapterDriver< | ... | @@ -305,7 +305,7 @@ export const AcpRegistryAdapterV2Driver: ProviderAdapterDriver< |
| 305 | ...(Option.isSome(runtimeCoordinator) | 305 | ...(Option.isSome(runtimeCoordinator) |
| 306 | ? { runtimeCoordinator: runtimeCoordinator.value } | 306 | ? { runtimeCoordinator: runtimeCoordinator.value } |
| 307 | : {}), | 307 | : {}), |
| 308 | serverConfig, | 308 | host, |
| 309 | selfInvocation, | 309 | selfInvocation, |
| 310 | nativeLogging: (threadId) => | 310 | nativeLogging: (threadId) => |
| 311 | makeNativeLogger({ | 311 | makeNativeLogger({ |
apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts+12-13| ... | @@ -22,8 +22,9 @@ import * as Stream from "effect/Stream"; | ... | @@ -22,8 +22,9 @@ import * as Stream from "effect/Stream"; |
| 22 | import { ChildProcessSpawner } from "effect/process"; | 22 | import { ChildProcessSpawner } from "effect/process"; |
| 23 | import type * as EffectAcpSchema from "effect-acp/compat"; | 23 | import type * as EffectAcpSchema from "effect-acp/compat"; |
| 24 | 24 | ||
| 25 | import * as ServerConfig from "../../config.ts"; | 25 | import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; |
| 26 | import type * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts"; | 26 | import { layerTestProviderHost } from "@t3tools/provider-testing/host"; |
| 27 | import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; | ||
| 27 | import { makeAntigravityAcpRuntime } from "../../provider/acp/AntigravityAcpSupport.ts"; | 28 | import { makeAntigravityAcpRuntime } from "../../provider/acp/AntigravityAcpSupport.ts"; |
| 28 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; | 29 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; |
| 29 | import { ProviderAdapterV2RuntimePolicy } from "@t3tools/provider-core/server/ProviderAdapter"; | 30 | import { ProviderAdapterV2RuntimePolicy } from "@t3tools/provider-core/server/ProviderAdapter"; |
| ... | @@ -38,7 +39,7 @@ const flavor = makeAntigravityAcpAdapterFlavor({ | ... | @@ -38,7 +39,7 @@ const flavor = makeAntigravityAcpAdapterFlavor({ |
| 38 | fileSystem: undefined as never, | 39 | fileSystem: undefined as never, |
| 39 | path: undefined as never, | 40 | path: undefined as never, |
| 40 | idAllocator: undefined as never, | 41 | idAllocator: undefined as never, |
| 41 | serverConfig: undefined as never, | 42 | host: undefined as never, |
| 42 | selfInvocation: undefined as never, | 43 | selfInvocation: undefined as never, |
| 43 | makeRuntime: () => Effect.die("not spawned in this test"), | 44 | makeRuntime: () => Effect.die("not spawned in this test"), |
| 44 | withProcess: (_stop, task) => task, | 45 | withProcess: (_stop, task) => task, |
| ... | @@ -129,9 +130,7 @@ describe("AntigravityAdapterV2 flavor", () => { | ... | @@ -129,9 +130,7 @@ describe("AntigravityAdapterV2 flavor", () => { |
| 129 | const layerSession = Layer.mergeAll( | 130 | const layerSession = Layer.mergeAll( |
| 130 | NodeServices.layer, | 131 | NodeServices.layer, |
| 131 | IdAllocator.layer, | 132 | IdAllocator.layer, |
| 132 | ServerConfig.layerTest(process.cwd(), { prefix: "t3-antigravity-v2-adapter-" }).pipe( | 133 | layerTestProviderHost().pipe(Layer.provide(NodeServices.layer)), |
| 133 | Layer.provide(NodeServices.layer), | ||
| 134 | ), | ||
| 135 | ); | 134 | ); |
| 136 | 135 | ||
| 137 | describe("AntigravityAdapterV2 client file system", () => { | 136 | describe("AntigravityAdapterV2 client file system", () => { |
| ... | @@ -140,7 +139,7 @@ describe("AntigravityAdapterV2 client file system", () => { | ... | @@ -140,7 +139,7 @@ describe("AntigravityAdapterV2 client file system", () => { |
| 140 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | 139 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 141 | const fileSystem = yield* FileSystem.FileSystem; | 140 | const fileSystem = yield* FileSystem.FileSystem; |
| 142 | const path = yield* Path.Path; | 141 | const path = yield* Path.Path; |
| 143 | const serverConfig = yield* ServerConfig.ServerConfig; | 142 | const host = yield* ProviderHost.ProviderHost; |
| 144 | const mockAgentPath = yield* path.fromFileUrl( | 143 | const mockAgentPath = yield* path.fromFileUrl( |
| 145 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 144 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| 146 | ); | 145 | ); |
| ... | @@ -157,7 +156,7 @@ describe("AntigravityAdapterV2 client file system", () => { | ... | @@ -157,7 +156,7 @@ describe("AntigravityAdapterV2 client file system", () => { |
| 157 | fileSystem, | 156 | fileSystem, |
| 158 | path, | 157 | path, |
| 159 | idAllocator: yield* IdAllocator.IdAllocatorV2, | 158 | idAllocator: yield* IdAllocator.IdAllocatorV2, |
| 160 | serverConfig, | 159 | host, |
| 161 | makeRuntime: (input) => | 160 | makeRuntime: (input) => |
| 162 | makeAntigravityAcpRuntime({ | 161 | makeAntigravityAcpRuntime({ |
| 163 | ...input, | 162 | ...input, |
| ... | @@ -193,7 +192,7 @@ describe("AntigravityAdapterV2 client file system", () => { | ... | @@ -193,7 +192,7 @@ describe("AntigravityAdapterV2 client file system", () => { |
| 193 | }); | 192 | }); |
| 194 | const outsideFile = path.join(outside, "secret.txt"); | 193 | const outsideFile = path.join(outside, "secret.txt"); |
| 195 | yield* fileSystem.writeFileString(outsideFile, "secret"); | 194 | yield* fileSystem.writeFileString(outsideFile, "secret"); |
| 196 | const attachment = path.join(serverConfig.attachmentsDir, "pasted.txt"); | 195 | const attachment = path.join(host.paths.attachmentsDir, "pasted.txt"); |
| 197 | yield* fileSystem.writeFileString(attachment, "pasted"); | 196 | yield* fileSystem.writeFileString(attachment, "pasted"); |
| 198 | 197 | ||
| 199 | const threadId = ThreadId.make("thread-antigravity-containment"); | 198 | const threadId = ThreadId.make("thread-antigravity-containment"); |
| ... | @@ -294,7 +293,7 @@ describe("AntigravityAdapterV2 workspace changes", () => { | ... | @@ -294,7 +293,7 @@ describe("AntigravityAdapterV2 workspace changes", () => { |
| 294 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | 293 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 295 | const fileSystem = yield* FileSystem.FileSystem; | 294 | const fileSystem = yield* FileSystem.FileSystem; |
| 296 | const path = yield* Path.Path; | 295 | const path = yield* Path.Path; |
| 297 | const serverConfig = yield* ServerConfig.ServerConfig; | 296 | const host = yield* ProviderHost.ProviderHost; |
| 298 | const crypto = yield* Crypto.Crypto; | 297 | const crypto = yield* Crypto.Crypto; |
| 299 | const mockAgentPath = yield* path.fromFileUrl( | 298 | const mockAgentPath = yield* path.fromFileUrl( |
| 300 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 299 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -309,7 +308,7 @@ describe("AntigravityAdapterV2 workspace changes", () => { | ... | @@ -309,7 +308,7 @@ describe("AntigravityAdapterV2 workspace changes", () => { |
| 309 | fileSystem, | 308 | fileSystem, |
| 310 | path, | 309 | path, |
| 311 | idAllocator: yield* IdAllocator.IdAllocatorV2, | 310 | idAllocator: yield* IdAllocator.IdAllocatorV2, |
| 312 | serverConfig, | 311 | host, |
| 313 | makeRuntime: (input) => | 312 | makeRuntime: (input) => |
| 314 | makeAntigravityAcpRuntime({ | 313 | makeAntigravityAcpRuntime({ |
| 315 | ...input, | 314 | ...input, |
| ... | @@ -431,7 +430,7 @@ describe("AntigravityAdapterV2 client file system under restrictive policies", ( | ... | @@ -431,7 +430,7 @@ describe("AntigravityAdapterV2 client file system under restrictive policies", ( |
| 431 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | 430 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 432 | const fileSystem = yield* FileSystem.FileSystem; | 431 | const fileSystem = yield* FileSystem.FileSystem; |
| 433 | const path = yield* Path.Path; | 432 | const path = yield* Path.Path; |
| 434 | const serverConfig = yield* ServerConfig.ServerConfig; | 433 | const host = yield* ProviderHost.ProviderHost; |
| 435 | const crypto = yield* Crypto.Crypto; | 434 | const crypto = yield* Crypto.Crypto; |
| 436 | const mockAgentPath = yield* path.fromFileUrl( | 435 | const mockAgentPath = yield* path.fromFileUrl( |
| 437 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 436 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| ... | @@ -459,7 +458,7 @@ describe("AntigravityAdapterV2 client file system under restrictive policies", ( | ... | @@ -459,7 +458,7 @@ describe("AntigravityAdapterV2 client file system under restrictive policies", ( |
| 459 | fileSystem, | 458 | fileSystem, |
| 460 | path, | 459 | path, |
| 461 | idAllocator: yield* IdAllocator.IdAllocatorV2, | 460 | idAllocator: yield* IdAllocator.IdAllocatorV2, |
| 462 | serverConfig, | 461 | host, |
| 463 | makeRuntime: (input) => | 462 | makeRuntime: (input) => |
| 464 | makeAntigravityAcpRuntime({ | 463 | makeAntigravityAcpRuntime({ |
| 465 | ...input, | 464 | ...input, |
apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts+7-9| ... | @@ -14,9 +14,9 @@ import * as Exit from "effect/Exit"; | ... | @@ -14,9 +14,9 @@ import * as Exit from "effect/Exit"; |
| 14 | import * as Scope from "effect/Scope"; | 14 | import * as Scope from "effect/Scope"; |
| 15 | import * as EffectAcpErrors from "effect-acp/errors"; | 15 | import * as EffectAcpErrors from "effect-acp/errors"; |
| 16 | 16 | ||
| 17 | import type { ServerConfig } from "../../config.ts"; | 17 | import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; |
| 18 | import type { AntigravityAuth } from "../../provider/AntigravityAuth.ts"; | 18 | import type { AntigravityAuth } from "../../provider/AntigravityAuth.ts"; |
| 19 | import type * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts"; | 19 | import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; |
| 20 | import { | 20 | import { |
| 21 | antigravityPermissionMode, | 21 | antigravityPermissionMode, |
| 22 | applyAntigravityAcpModelSelection, | 22 | applyAntigravityAcpModelSelection, |
| ... | @@ -40,7 +40,7 @@ import { | ... | @@ -40,7 +40,7 @@ import { |
| 40 | makeAcpAdapterV2, | 40 | makeAcpAdapterV2, |
| 41 | type AcpAdapterV2Flavor, | 41 | type AcpAdapterV2Flavor, |
| 42 | type AcpAdapterV2RuntimeInput, | 42 | type AcpAdapterV2RuntimeInput, |
| 43 | } from "./AcpAdapterV2.ts"; | 43 | } from "@t3tools/provider-acp/server/adapter"; |
| 44 | 44 | ||
| 45 | const ANTIGRAVITY_PROVIDER = ProviderDriverKind.make("antigravity"); | 45 | const ANTIGRAVITY_PROVIDER = ProviderDriverKind.make("antigravity"); |
| 46 | 46 | ||
| ... | @@ -68,7 +68,7 @@ export interface AntigravityAdapterV2Options { | ... | @@ -68,7 +68,7 @@ export interface AntigravityAdapterV2Options { |
| 68 | readonly fileSystem: FileSystem.FileSystem; | 68 | readonly fileSystem: FileSystem.FileSystem; |
| 69 | readonly path: Path.Path; | 69 | readonly path: Path.Path; |
| 70 | readonly idAllocator: IdAllocatorV2["Service"]; | 70 | readonly idAllocator: IdAllocatorV2["Service"]; |
| 71 | readonly serverConfig: ServerConfig["Service"]; | 71 | readonly host: ProviderHost.ProviderHostShape; |
| 72 | /** Spawns the official agent with the instance's Google profile. */ | 72 | /** Spawns the official agent with the instance's Google profile. */ |
| 73 | readonly makeRuntime: ( | 73 | readonly makeRuntime: ( |
| 74 | input: Omit<AntigravityAcpRuntimeInput, "spawn" | "childProcessSpawner">, | 74 | input: Omit<AntigravityAcpRuntimeInput, "spawn" | "childProcessSpawner">, |
| ... | @@ -125,9 +125,7 @@ export function makeAntigravityAcpAdapterFlavor( | ... | @@ -125,9 +125,7 @@ export function makeAntigravityAcpAdapterFlavor( |
| 125 | // the turn text references. It is a leaf directory of uploads. A session | 125 | // the turn text references. It is a leaf directory of uploads. A session |
| 126 | // without a workspace gets no workspace root rather than the server's cwd. | 126 | // without a workspace gets no workspace root rather than the server's cwd. |
| 127 | const antigravityClientFileRoots = (cwd: string | null) => | 127 | const antigravityClientFileRoots = (cwd: string | null) => |
| 128 | cwd === null | 128 | cwd === null ? [options.host.paths.attachmentsDir] : [cwd, options.host.paths.attachmentsDir]; |
| 129 | ? [options.serverConfig.attachmentsDir] | ||
| 130 | : [cwd, options.serverConfig.attachmentsDir]; | ||
| 131 | const makeRuntime = (input: AcpAdapterV2RuntimeInput) => | 129 | const makeRuntime = (input: AcpAdapterV2RuntimeInput) => |
| 132 | Effect.gen(function* () { | 130 | Effect.gen(function* () { |
| 133 | // AcpAdapterV2 owns the runtime scope; sign-in and sign-out stop the | 131 | // AcpAdapterV2 owns the runtime scope; sign-in and sign-out stop the |
| ... | @@ -139,7 +137,7 @@ export function makeAntigravityAcpAdapterFlavor( | ... | @@ -139,7 +137,7 @@ export function makeAntigravityAcpAdapterFlavor( |
| 139 | options.makeRuntime({ | 137 | options.makeRuntime({ |
| 140 | ...input, | 138 | ...input, |
| 141 | clientFileSystem: true, | 139 | clientFileSystem: true, |
| 142 | additionalDirectories: [options.serverConfig.attachmentsDir], | 140 | additionalDirectories: [options.host.paths.attachmentsDir], |
| 143 | }), | 141 | }), |
| 144 | ) | 142 | ) |
| 145 | .pipe(Effect.provideService(Scope.Scope, scope)); | 143 | .pipe(Effect.provideService(Scope.Scope, scope)); |
| ... | @@ -233,7 +231,7 @@ export function makeAntigravityAdapterV2(options: AntigravityAdapterV2Options) { | ... | @@ -233,7 +231,7 @@ export function makeAntigravityAdapterV2(options: AntigravityAdapterV2Options) { |
| 233 | crypto: options.crypto, | 231 | crypto: options.crypto, |
| 234 | fileSystem: options.fileSystem, | 232 | fileSystem: options.fileSystem, |
| 235 | idAllocator: options.idAllocator, | 233 | idAllocator: options.idAllocator, |
| 236 | serverConfig: options.serverConfig, | 234 | host: options.host, |
| 237 | selfInvocation: options.selfInvocation, | 235 | selfInvocation: options.selfInvocation, |
| 238 | ...(options.nativeLogging === undefined ? {} : { nativeLogging: options.nativeLogging }), | 236 | ...(options.nativeLogging === undefined ? {} : { nativeLogging: options.nativeLogging }), |
| 239 | ...(options.continuationRequests === undefined | 237 | ...(options.continuationRequests === undefined |
apps/server/src/orchestration-v2/Adapters/DevinAcp.ts+2-2| ... | @@ -1,6 +1,6 @@ | ... | @@ -1,6 +1,6 @@ |
| 1 | import type * as AcpSchema from "effect-acp/compat"; | 1 | import type * as AcpSchema from "effect-acp/compat"; |
| 2 | import type { AcpToolCallState } from "../../provider/acp/AcpRuntimeModel.ts"; | 2 | import type { AcpToolCallState } from "@t3tools/provider-acp/server/runtimeModel"; |
| 3 | import type { AcpAdapterV2SubagentUpdate } from "./AcpAdapterV2.ts"; | 3 | import type { AcpAdapterV2SubagentUpdate } from "@t3tools/provider-acp/server/adapter"; |
| 4 | 4 | ||
| 5 | function record(value: unknown): Record<string, unknown> | undefined { | 5 | function record(value: unknown): Record<string, unknown> | undefined { |
| 6 | return typeof value === "object" && value !== null && !Array.isArray(value) | 6 | return typeof value === "object" && value !== null && !Array.isArray(value) |
apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts+7-8| ... | @@ -22,7 +22,8 @@ import * as Schema from "effect/Schema"; | ... | @@ -22,7 +22,8 @@ import * as Schema from "effect/Schema"; |
| 22 | import { ChildProcessSpawner } from "effect/process"; | 22 | import { ChildProcessSpawner } from "effect/process"; |
| 23 | import type * as EffectAcpSchema from "effect-acp/compat"; | 23 | import type * as EffectAcpSchema from "effect-acp/compat"; |
| 24 | 24 | ||
| 25 | import * as ServerConfig from "../../config.ts"; | 25 | import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; |
| 26 | import { layerTestProviderHost } from "@t3tools/provider-testing/host"; | ||
| 26 | import * as ProjectStore from "../ProjectStore.ts"; | 27 | import * as ProjectStore from "../ProjectStore.ts"; |
| 27 | import { buildInitialGrokProviderSnapshot } from "../../provider/GrokProvider.ts"; | 28 | import { buildInitialGrokProviderSnapshot } from "../../provider/GrokProvider.ts"; |
| 28 | import type { ProviderInstance } from "@t3tools/provider-core/server/driver"; | 29 | import type { ProviderInstance } from "@t3tools/provider-core/server/driver"; |
| ... | @@ -30,13 +31,13 @@ import * as ProviderInstanceRegistry from "../../provider/ProviderInstanceRegist | ... | @@ -30,13 +31,13 @@ import * as ProviderInstanceRegistry from "../../provider/ProviderInstanceRegist |
| 30 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; | 31 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; |
| 31 | import { ProviderAdapterV2RuntimePolicy } from "@t3tools/provider-core/server/ProviderAdapter"; | 32 | import { ProviderAdapterV2RuntimePolicy } from "@t3tools/provider-core/server/ProviderAdapter"; |
| 32 | import * as RuntimePolicy from "../RuntimePolicy.ts"; | 33 | import * as RuntimePolicy from "../RuntimePolicy.ts"; |
| 33 | import { acpPermissionDisposition } from "../../provider/acp/AcpClientPolicy.ts"; | 34 | import { acpPermissionDisposition } from "@t3tools/provider-acp/server/clientPolicy"; |
| 34 | import { | 35 | import { |
| 35 | AcpProviderCapabilitiesV2, | 36 | AcpProviderCapabilitiesV2, |
| 36 | acpCompletedTurnShouldTerminalizeTool, | 37 | acpCompletedTurnShouldTerminalizeTool, |
| 37 | acpSubagentStatusBlocksTurnSettlement, | 38 | acpSubagentStatusBlocksTurnSettlement, |
| 38 | acpSupportsImagePrompts, | 39 | acpSupportsImagePrompts, |
| 39 | } from "./AcpAdapterV2.ts"; | 40 | } from "@t3tools/provider-acp/server/adapter"; |
| 40 | import { | 41 | import { |
| 41 | makeGrokAcpAdapterFlavor, | 42 | makeGrokAcpAdapterFlavor, |
| 42 | makeGrokAdapterV2, | 43 | makeGrokAdapterV2, |
| ... | @@ -311,10 +312,8 @@ describe("Grok permission prompts", () => { | ... | @@ -311,10 +312,8 @@ describe("Grok permission prompts", () => { |
| 311 | }); | 312 | }); |
| 312 | 313 | ||
| 313 | describe("Grok launch permission mode", () => { | 314 | describe("Grok launch permission mode", () => { |
| 314 | const layerServerConfig = ServerConfig.layerTest(process.cwd(), { | 315 | const layerHost = layerTestProviderHost().pipe(Layer.provide(NodeServices.layer)); |
| 315 | prefix: "t3-grok-v2-launch-", | 316 | const layerTest = Layer.mergeAll(NodeServices.layer, IdAllocator.layer, layerHost); |
| 316 | }).pipe(Layer.provide(NodeServices.layer)); | ||
| 317 | const layerTest = Layer.mergeAll(NodeServices.layer, IdAllocator.layer, layerServerConfig); | ||
| 318 | 317 | ||
| 319 | // Opens a session through the adapter's own Grok runtime factory and returns | 318 | // Opens a session through the adapter's own Grok runtime factory and returns |
| 320 | // the argv it tried to launch. The spawn fails after recording, so no | 319 | // the argv it tried to launch. The spawn fails after recording, so no |
| ... | @@ -342,7 +341,7 @@ describe("Grok launch permission mode", () => { | ... | @@ -342,7 +341,7 @@ describe("Grok launch permission mode", () => { |
| 342 | crypto: yield* Crypto.Crypto, | 341 | crypto: yield* Crypto.Crypto, |
| 343 | fileSystem: yield* FileSystem.FileSystem, | 342 | fileSystem: yield* FileSystem.FileSystem, |
| 344 | idAllocator: yield* IdAllocator.IdAllocatorV2, | 343 | idAllocator: yield* IdAllocator.IdAllocatorV2, |
| 345 | serverConfig: yield* ServerConfig.ServerConfig, | 344 | host: yield* ProviderHost.ProviderHost, |
| 346 | selfInvocation: yield* resolveSelfInvocation(), | 345 | selfInvocation: yield* resolveSelfInvocation(), |
| 347 | }); | 346 | }); |
| 348 | yield* adapter | 347 | yield* adapter |
apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.testkit.ts+6-9| ... | @@ -10,7 +10,8 @@ import { ChildProcessSpawner } from "effect/process"; | ... | @@ -10,7 +10,8 @@ import { ChildProcessSpawner } from "effect/process"; |
| 10 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | 10 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| 11 | import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime"; | 11 | import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime"; |
| 12 | 12 | ||
| 13 | import * as ServerConfig from "../../config.ts"; | 13 | import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; |
| 14 | import { layerTestProviderHost } from "@t3tools/provider-testing/host"; | ||
| 14 | import { | 15 | import { |
| 15 | GROK_ACP_CANCEL_META, | 16 | GROK_ACP_CANCEL_META, |
| 16 | GROK_ACP_INITIALIZE_META, | 17 | GROK_ACP_INITIALIZE_META, |
| ... | @@ -21,7 +22,6 @@ import * as ProviderContinuationRequests from "@t3tools/provider-core/server/con | ... | @@ -21,7 +22,6 @@ import * as ProviderContinuationRequests from "@t3tools/provider-core/server/con |
| 21 | import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts"; | 22 | import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts"; |
| 22 | import type { ProviderReplayGate } from "@t3tools/provider-testing/replayGate"; | 23 | import type { ProviderReplayGate } from "@t3tools/provider-testing/replayGate"; |
| 23 | import type { OrchestratorV2ProviderReplayHarness } from "../testkit/ProviderReplayHarness.ts"; | 24 | import type { OrchestratorV2ProviderReplayHarness } from "../testkit/ProviderReplayHarness.ts"; |
| 24 | import { makeReplayServerConfig } from "../testkit/ProviderReplayHarness.ts"; | ||
| 25 | import { | 25 | import { |
| 26 | type AcpReplayTranscript, | 26 | type AcpReplayTranscript, |
| 27 | AcpReplayTranscriptDecodeError, | 27 | AcpReplayTranscriptDecodeError, |
| ... | @@ -37,10 +37,7 @@ function layerGrokProviderAdapterRegistryReplay( | ... | @@ -37,10 +37,7 @@ function layerGrokProviderAdapterRegistryReplay( |
| 37 | transcript: AcpReplayTranscript, | 37 | transcript: AcpReplayTranscript, |
| 38 | options: { readonly replayGate?: ProviderReplayGate } = {}, | 38 | options: { readonly replayGate?: ProviderReplayGate } = {}, |
| 39 | ) { | 39 | ) { |
| 40 | const layerServerConfig = Layer.effect( | 40 | const layerHost = layerTestProviderHost().pipe(Layer.provide(NodeServices.layer)); |
| 41 | ServerConfig.ServerConfig, | ||
| 42 | makeReplayServerConfig(`grok-${transcript.scenario}`).pipe(Effect.orDie), | ||
| 43 | ).pipe(Layer.provide(NodeServices.layer)); | ||
| 44 | 41 | ||
| 45 | return ProviderAdapterRegistry.layerFromAdaptersEffect( | 42 | return ProviderAdapterRegistry.layerFromAdaptersEffect( |
| 46 | Effect.gen(function* () { | 43 | Effect.gen(function* () { |
| ... | @@ -50,7 +47,7 @@ function layerGrokProviderAdapterRegistryReplay( | ... | @@ -50,7 +47,7 @@ function layerGrokProviderAdapterRegistryReplay( |
| 50 | const crypto = yield* Crypto.Crypto; | 47 | const crypto = yield* Crypto.Crypto; |
| 51 | const hostPlatform = yield* HostProcessPlatform; | 48 | const hostPlatform = yield* HostProcessPlatform; |
| 52 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 49 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 53 | const serverConfig = yield* ServerConfig.ServerConfig; | 50 | const host = yield* ProviderHost.ProviderHost; |
| 54 | // Same queue the continuation worker drains when the fixture runs it. | 51 | // Same queue the continuation worker drains when the fixture runs it. |
| 55 | const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests; | 52 | const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests; |
| 56 | const replayGate = options.replayGate; | 53 | const replayGate = options.replayGate; |
| ... | @@ -72,7 +69,7 @@ function layerGrokProviderAdapterRegistryReplay( | ... | @@ -72,7 +69,7 @@ function layerGrokProviderAdapterRegistryReplay( |
| 72 | crypto, | 69 | crypto, |
| 73 | fileSystem, | 70 | fileSystem, |
| 74 | idAllocator, | 71 | idAllocator, |
| 75 | serverConfig, | 72 | host, |
| 76 | selfInvocation: yield* resolveSelfInvocation(), | 73 | selfInvocation: yield* resolveSelfInvocation(), |
| 77 | // Same wrapping as makeGrokAcpRuntime: client type and Ctrl+C cancel | 74 | // Same wrapping as makeGrokAcpRuntime: client type and Ctrl+C cancel |
| 78 | // metadata and the x.ai prompt-completion race, so replay sends what | 75 | // metadata and the x.ai prompt-completion race, so replay sends what |
| ... | @@ -102,7 +99,7 @@ function layerGrokProviderAdapterRegistryReplay( | ... | @@ -102,7 +99,7 @@ function layerGrokProviderAdapterRegistryReplay( |
| 102 | return [adapter]; | 99 | return [adapter]; |
| 103 | }), | 100 | }), |
| 104 | ).pipe( | 101 | ).pipe( |
| 105 | Layer.provide(Layer.mergeAll(layerServerConfig, NodeServices.layer, IdAllocator.layer)), | 102 | Layer.provide(Layer.mergeAll(layerHost, NodeServices.layer, IdAllocator.layer)), |
| 106 | // Held inbound lines must not outlive the scenario and wedge teardown. | 103 | // Held inbound lines must not outlive the scenario and wedge teardown. |
| 107 | Layer.merge( | 104 | Layer.merge( |
| 108 | Layer.effectDiscard( | 105 | Layer.effectDiscard( |
apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts+13-13| ... | @@ -23,8 +23,8 @@ import type * as Scope from "effect/Scope"; | ... | @@ -23,8 +23,8 @@ import type * as Scope from "effect/Scope"; |
| 23 | import { ChildProcessSpawner } from "effect/process"; | 23 | import { ChildProcessSpawner } from "effect/process"; |
| 24 | import * as EffectAcpErrors from "effect-acp/errors"; | 24 | import * as EffectAcpErrors from "effect-acp/errors"; |
| 25 | 25 | ||
| 26 | import * as ServerConfig from "../../config.ts"; | 26 | import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; |
| 27 | import { makeAcpNativeLoggerFactory } from "../../provider/acp/AcpNativeLogging.ts"; | 27 | import { makeAcpNativeLoggerFactory } from "@t3tools/provider-acp/server/nativeLogging"; |
| 28 | import { | 28 | import { |
| 29 | applyGrokAcpModelSelection, | 29 | applyGrokAcpModelSelection, |
| 30 | currentGrokModelIdFromSessionSetup, | 30 | currentGrokModelIdFromSessionSetup, |
| ... | @@ -54,8 +54,8 @@ import { | ... | @@ -54,8 +54,8 @@ import { |
| 54 | XAiExitPlanModeRequest, | 54 | XAiExitPlanModeRequest, |
| 55 | } from "../../provider/acp/XAiAcpExtension.ts"; | 55 | } from "../../provider/acp/XAiAcpExtension.ts"; |
| 56 | import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; | 56 | import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; |
| 57 | import { acpPermissionDisposition } from "../../provider/acp/AcpClientPolicy.ts"; | 57 | import { acpPermissionDisposition } from "@t3tools/provider-acp/server/clientPolicy"; |
| 58 | import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts"; | 58 | import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; |
| 59 | import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts"; | 59 | import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts"; |
| 60 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; | 60 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; |
| 61 | import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; | 61 | import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; |
| ... | @@ -71,7 +71,7 @@ import { | ... | @@ -71,7 +71,7 @@ import { |
| 71 | type AcpAdapterV2ExtensionContext, | 71 | type AcpAdapterV2ExtensionContext, |
| 72 | type AcpAdapterV2Flavor, | 72 | type AcpAdapterV2Flavor, |
| 73 | type AcpAdapterV2RuntimeInput, | 73 | type AcpAdapterV2RuntimeInput, |
| 74 | } from "./AcpAdapterV2.ts"; | 74 | } from "@t3tools/provider-acp/server/adapter"; |
| 75 | 75 | ||
| 76 | export const GROK_PROVIDER = ProviderDriverKind.make("grok"); | 76 | export const GROK_PROVIDER = ProviderDriverKind.make("grok"); |
| 77 | const GROK_DRIVER_KIND = GROK_PROVIDER; | 77 | const GROK_DRIVER_KIND = GROK_PROVIDER; |
| ... | @@ -117,7 +117,7 @@ export interface GrokAdapterV2Options { | ... | @@ -117,7 +117,7 @@ export interface GrokAdapterV2Options { |
| 117 | readonly selfInvocation: SelfInvocation; | 117 | readonly selfInvocation: SelfInvocation; |
| 118 | readonly fileSystem: FileSystem.FileSystem; | 118 | readonly fileSystem: FileSystem.FileSystem; |
| 119 | readonly idAllocator: IdAllocator.IdAllocatorV2["Service"]; | 119 | readonly idAllocator: IdAllocator.IdAllocatorV2["Service"]; |
| 120 | readonly serverConfig: ServerConfig.ServerConfig["Service"]; | 120 | readonly host: ProviderHost.ProviderHostShape; |
| 121 | readonly nativeLogging?: Parameters<typeof makeAcpAdapterV2>[0]["nativeLogging"]; | 121 | readonly nativeLogging?: Parameters<typeof makeAcpAdapterV2>[0]["nativeLogging"]; |
| 122 | readonly continuationRequests?: Parameters<typeof makeAcpAdapterV2>[0]["continuationRequests"]; | 122 | readonly continuationRequests?: Parameters<typeof makeAcpAdapterV2>[0]["continuationRequests"]; |
| 123 | readonly testHooks?: Parameters<typeof makeAcpAdapterV2>[0]["testHooks"]; | 123 | readonly testHooks?: Parameters<typeof makeAcpAdapterV2>[0]["testHooks"]; |
| ... | @@ -337,7 +337,7 @@ export function makeGrokAdapterV2(options: GrokAdapterV2Options) { | ... | @@ -337,7 +337,7 @@ export function makeGrokAdapterV2(options: GrokAdapterV2Options) { |
| 337 | crypto: options.crypto, | 337 | crypto: options.crypto, |
| 338 | fileSystem: options.fileSystem, | 338 | fileSystem: options.fileSystem, |
| 339 | idAllocator: options.idAllocator, | 339 | idAllocator: options.idAllocator, |
| 340 | serverConfig: options.serverConfig, | 340 | host: options.host, |
| 341 | selfInvocation: options.selfInvocation, | 341 | selfInvocation: options.selfInvocation, |
| 342 | ...(options.nativeLogging === undefined ? {} : { nativeLogging: options.nativeLogging }), | 342 | ...(options.nativeLogging === undefined ? {} : { nativeLogging: options.nativeLogging }), |
| 343 | ...(options.continuationRequests === undefined | 343 | ...(options.continuationRequests === undefined |
| ... | @@ -354,7 +354,7 @@ export type GrokAdapterV2DriverEnv = | ... | @@ -354,7 +354,7 @@ export type GrokAdapterV2DriverEnv = |
| 354 | | IdAllocator.IdAllocatorV2 | 354 | | IdAllocator.IdAllocatorV2 |
| 355 | | Path.Path | 355 | | Path.Path |
| 356 | | ProviderEventLoggers.ProviderEventLoggers | 356 | | ProviderEventLoggers.ProviderEventLoggers |
| 357 | | ServerConfig.ServerConfig; | 357 | | ProviderHost.ProviderHost; |
| 358 | 358 | ||
| 359 | export const GrokAdapterV2Driver: ProviderAdapterDriver<GrokSettings, GrokAdapterV2DriverEnv> = { | 359 | export const GrokAdapterV2Driver: ProviderAdapterDriver<GrokSettings, GrokAdapterV2DriverEnv> = { |
| 360 | driverKind: GROK_DRIVER_KIND, | 360 | driverKind: GROK_DRIVER_KIND, |
| ... | @@ -370,7 +370,7 @@ export const GrokAdapterV2Driver: ProviderAdapterDriver<GrokSettings, GrokAdapte | ... | @@ -370,7 +370,7 @@ export const GrokAdapterV2Driver: ProviderAdapterDriver<GrokSettings, GrokAdapte |
| 370 | const fileSystem = yield* FileSystem.FileSystem; | 370 | const fileSystem = yield* FileSystem.FileSystem; |
| 371 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 371 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 372 | const providerEventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers; | 372 | const providerEventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers; |
| 373 | const serverConfig = yield* ServerConfig.ServerConfig; | 373 | const host = yield* ProviderHost.ProviderHost; |
| 374 | const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests; | 374 | const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests; |
| 375 | const makeNativeLogger = yield* makeAcpNativeLoggerFactory(); | 375 | const makeNativeLogger = yield* makeAcpNativeLoggerFactory(); |
| 376 | return makeGrokAdapterV2({ | 376 | return makeGrokAdapterV2({ |
| ... | @@ -382,7 +382,7 @@ export const GrokAdapterV2Driver: ProviderAdapterDriver<GrokSettings, GrokAdapte | ... | @@ -382,7 +382,7 @@ export const GrokAdapterV2Driver: ProviderAdapterDriver<GrokSettings, GrokAdapte |
| 382 | crypto, | 382 | crypto, |
| 383 | fileSystem, | 383 | fileSystem, |
| 384 | idAllocator, | 384 | idAllocator, |
| 385 | serverConfig, | 385 | host, |
| 386 | selfInvocation, | 386 | selfInvocation, |
| 387 | continuationRequests, | 387 | continuationRequests, |
| 388 | nativeLogging: (threadId) => | 388 | nativeLogging: (threadId) => |
| ... | @@ -417,7 +417,7 @@ const layer: Layer.Layer< | ... | @@ -417,7 +417,7 @@ const layer: Layer.Layer< |
| 417 | | FileSystem.FileSystem | 417 | | FileSystem.FileSystem |
| 418 | | IdAllocator.IdAllocatorV2 | 418 | | IdAllocator.IdAllocatorV2 |
| 419 | | ProviderEventLoggers.ProviderEventLoggers | 419 | | ProviderEventLoggers.ProviderEventLoggers |
| 420 | | ServerConfig.ServerConfig | 420 | | ProviderHost.ProviderHost |
| 421 | > = Layer.effect( | 421 | > = Layer.effect( |
| 422 | ProviderAdapter.ProviderAdapterV2, | 422 | ProviderAdapter.ProviderAdapterV2, |
| 423 | Effect.gen(function* () { | 423 | Effect.gen(function* () { |
| ... | @@ -429,7 +429,7 @@ const layer: Layer.Layer< | ... | @@ -429,7 +429,7 @@ const layer: Layer.Layer< |
| 429 | const fileSystem = yield* FileSystem.FileSystem; | 429 | const fileSystem = yield* FileSystem.FileSystem; |
| 430 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 430 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 431 | const providerEventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers; | 431 | const providerEventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers; |
| 432 | const serverConfig = yield* ServerConfig.ServerConfig; | 432 | const host = yield* ProviderHost.ProviderHost; |
| 433 | const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests; | 433 | const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests; |
| 434 | const makeNativeLogger = yield* makeAcpNativeLoggerFactory(); | 434 | const makeNativeLogger = yield* makeAcpNativeLoggerFactory(); |
| 435 | return makeGrokAdapterV2({ | 435 | return makeGrokAdapterV2({ |
| ... | @@ -441,7 +441,7 @@ const layer: Layer.Layer< | ... | @@ -441,7 +441,7 @@ const layer: Layer.Layer< |
| 441 | crypto, | 441 | crypto, |
| 442 | fileSystem, | 442 | fileSystem, |
| 443 | idAllocator, | 443 | idAllocator, |
| 444 | serverConfig, | 444 | host, |
| 445 | selfInvocation, | 445 | selfInvocation, |
| 446 | continuationRequests, | 446 | continuationRequests, |
| 447 | nativeLogging: (threadId) => | 447 | nativeLogging: (threadId) => |
apps/server/src/orchestration-v2/ProviderSelectionTransition.test.ts+1-1| ... | @@ -6,7 +6,7 @@ import { | ... | @@ -6,7 +6,7 @@ import { |
| 6 | ProviderInstanceId, | 6 | ProviderInstanceId, |
| 7 | } from "@t3tools/contracts"; | 7 | } from "@t3tools/contracts"; |
| 8 | 8 | ||
| 9 | import { AcpProviderCapabilitiesV2 } from "./Adapters/AcpAdapterV2.ts"; | 9 | import { AcpProviderCapabilitiesV2 } from "@t3tools/provider-acp/server/adapter"; |
| 10 | import { acpSelectionTransition } from "@t3tools/provider-core/server/selectionTransition"; | 10 | import { acpSelectionTransition } from "@t3tools/provider-core/server/selectionTransition"; |
| 11 | 11 | ||
| 12 | const selection = (model: string, effort = "medium"): ModelSelection => ({ | 12 | const selection = (model: string, effort = "medium"): ModelSelection => ({ |
apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts+1-1| ... | @@ -42,7 +42,7 @@ import { | ... | @@ -42,7 +42,7 @@ import { |
| 42 | CodexProviderCapabilitiesV2, | 42 | CodexProviderCapabilitiesV2, |
| 43 | canReuseCodexContextUsage, | 43 | canReuseCodexContextUsage, |
| 44 | } from "../Adapters/CodexAdapterV2.ts"; | 44 | } from "../Adapters/CodexAdapterV2.ts"; |
| 45 | import { AcpProviderCapabilitiesV2 } from "../Adapters/AcpAdapterV2.ts"; | 45 | import { AcpProviderCapabilitiesV2 } from "@t3tools/provider-acp/server/adapter"; |
| 46 | import { CursorProviderCapabilitiesV2 } from "@t3tools/provider-cursor/testing"; | 46 | import { CursorProviderCapabilitiesV2 } from "@t3tools/provider-cursor/testing"; |
| 47 | import * as EventSink from "../EventSink.ts"; | 47 | import * as EventSink from "../EventSink.ts"; |
| 48 | import * as EventStore from "../EventStore.ts"; | 48 | import * as EventStore from "../EventStore.ts"; |
apps/server/src/provider/AntigravityAuth.test.ts+2-2| ... | @@ -17,7 +17,7 @@ import { | ... | @@ -17,7 +17,7 @@ import { |
| 17 | type AntigravityAuth, | 17 | type AntigravityAuth, |
| 18 | type AntigravityAuthRuntime, | 18 | type AntigravityAuthRuntime, |
| 19 | } from "./AntigravityAuth.ts"; | 19 | } from "./AntigravityAuth.ts"; |
| 20 | import type { AcpSessionRuntimeStartResult } from "./acp/AcpSessionRuntime.ts"; | 20 | import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; |
| 21 | 21 | ||
| 22 | const instanceId = ProviderInstanceId.make("antigravity-auth-test"); | 22 | const instanceId = ProviderInstanceId.make("antigravity-auth-test"); |
| 23 | const owner = "t3-auth-session-owner"; | 23 | const owner = "t3-auth-session-owner"; |
| ... | @@ -31,7 +31,7 @@ const initialized = { | ... | @@ -31,7 +31,7 @@ const initialized = { |
| 31 | authMethods: [{ id: "oauth-personal", name: "Log in with Google" }], | 31 | authMethods: [{ id: "oauth-personal", name: "Log in with Google" }], |
| 32 | agentCapabilities: { auth: { logout: {} } }, | 32 | agentCapabilities: { auth: { logout: {} } }, |
| 33 | } satisfies AcpSchema.InitializeResponse; | 33 | } satisfies AcpSchema.InitializeResponse; |
| 34 | const started: AcpSessionRuntimeStartResult = { | 34 | const started: AcpSessionRuntime.AcpSessionRuntimeStartResult = { |
| 35 | sessionId: "native-session", | 35 | sessionId: "native-session", |
| 36 | initializeResult: initialized, | 36 | initializeResult: initialized, |
| 37 | sessionSetupResult: { | 37 | sessionSetupResult: { |
apps/server/src/provider/AntigravityAuth.ts+4-4| ... | @@ -19,7 +19,7 @@ import * as Stream from "effect/Stream"; | ... | @@ -19,7 +19,7 @@ import * as Stream from "effect/Stream"; |
| 19 | import * as SubscriptionRef from "effect/SubscriptionRef"; | 19 | import * as SubscriptionRef from "effect/SubscriptionRef"; |
| 20 | import * as AcpErrors from "effect-acp/errors"; | 20 | import * as AcpErrors from "effect-acp/errors"; |
| 21 | 21 | ||
| 22 | import type { AcpSessionRuntime, AcpSessionRuntimeStartResult } from "./acp/AcpSessionRuntime.ts"; | 22 | import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; |
| 23 | import { | 23 | import { |
| 24 | parseAntigravityAuthorizationUrl, | 24 | parseAntigravityAuthorizationUrl, |
| 25 | type AntigravityAuthorizationUrl, | 25 | type AntigravityAuthorizationUrl, |
| ... | @@ -66,19 +66,19 @@ export interface AntigravityAuth { | ... | @@ -66,19 +66,19 @@ export interface AntigravityAuth { |
| 66 | } | 66 | } |
| 67 | 67 | ||
| 68 | export type AntigravityAuthRuntime = Pick< | 68 | export type AntigravityAuthRuntime = Pick< |
| 69 | AcpSessionRuntime["Service"], | 69 | AcpSessionRuntime.AcpSessionRuntime["Service"], |
| 70 | "initialize" | "start" | "request" | 70 | "initialize" | "start" | "request" |
| 71 | >; | 71 | >; |
| 72 | 72 | ||
| 73 | export interface AntigravityAuthOptions< | 73 | export interface AntigravityAuthOptions< |
| 74 | Runtime extends AntigravityAuthRuntime = AcpSessionRuntime["Service"], | 74 | Runtime extends AntigravityAuthRuntime = AcpSessionRuntime.AcpSessionRuntime["Service"], |
| 75 | > { | 75 | > { |
| 76 | readonly instanceId: ProviderInstanceId; | 76 | readonly instanceId: ProviderInstanceId; |
| 77 | readonly makeRuntime: (input: { | 77 | readonly makeRuntime: (input: { |
| 78 | readonly onAuthorizationUrl?: (url: string) => Effect.Effect<void, AcpErrors.AcpError>; | 78 | readonly onAuthorizationUrl?: (url: string) => Effect.Effect<void, AcpErrors.AcpError>; |
| 79 | }) => Effect.Effect<Runtime, AcpErrors.AcpError | ProviderSetupError, Scope.Scope>; | 79 | }) => Effect.Effect<Runtime, AcpErrors.AcpError | ProviderSetupError, Scope.Scope>; |
| 80 | readonly onAuthenticated: ( | 80 | readonly onAuthenticated: ( |
| 81 | result: AcpSessionRuntimeStartResult, | 81 | result: AcpSessionRuntime.AcpSessionRuntimeStartResult, |
| 82 | runtime: Runtime, | 82 | runtime: Runtime, |
| 83 | ) => Effect.Effect<void>; | 83 | ) => Effect.Effect<void>; |
| 84 | readonly onSignedOut: Effect.Effect<void>; | 84 | readonly onSignedOut: Effect.Effect<void>; |
apps/server/src/provider/AntigravityProvider.test.ts+2-2| ... | @@ -23,7 +23,7 @@ import * as ServerConfig from "../config.ts"; | ... | @@ -23,7 +23,7 @@ import * as ServerConfig from "../config.ts"; |
| 23 | import * as ServerSettings from "../serverSettings.ts"; | 23 | import * as ServerSettings from "../serverSettings.ts"; |
| 24 | import * as ProviderHostLive from "./ProviderHostLive.ts"; | 24 | import * as ProviderHostLive from "./ProviderHostLive.ts"; |
| 25 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | 25 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; |
| 26 | import type { AcpSessionRuntimeStartResult } from "./acp/AcpSessionRuntime.ts"; | 26 | import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; |
| 27 | import { | 27 | import { |
| 28 | buildAntigravityModelsFromSession, | 28 | buildAntigravityModelsFromSession, |
| 29 | makeAntigravityProvider, | 29 | makeAntigravityProvider, |
| ... | @@ -98,7 +98,7 @@ const started = { | ... | @@ -98,7 +98,7 @@ const started = { |
| 98 | initializeResult, | 98 | initializeResult, |
| 99 | sessionSetupResult, | 99 | sessionSetupResult, |
| 100 | modelConfigId: "model", | 100 | modelConfigId: "model", |
| 101 | } satisfies AcpSessionRuntimeStartResult; | 101 | } satisfies AcpSessionRuntime.AcpSessionRuntimeStartResult; |
| 102 | 102 | ||
| 103 | const commands = [ | 103 | const commands = [ |
| 104 | { name: "plan", description: "Create a plan", input: { type: "text", hint: "What to plan" } }, | 104 | { name: "plan", description: "Create a plan", input: { type: "text", hint: "What to plan" } }, |
apps/server/src/provider/AntigravityProvider.ts+3-3| ... | @@ -18,7 +18,7 @@ import * as SubscriptionRef from "effect/SubscriptionRef"; | ... | @@ -18,7 +18,7 @@ import * as SubscriptionRef from "effect/SubscriptionRef"; |
| 18 | import type * as EffectAcpErrors from "effect-acp/errors"; | 18 | import type * as EffectAcpErrors from "effect-acp/errors"; |
| 19 | import type * as EffectAcpSchema from "effect-acp/compat"; | 19 | import type * as EffectAcpSchema from "effect-acp/compat"; |
| 20 | 20 | ||
| 21 | import type { AcpSessionRuntimeStartResult } from "./acp/AcpSessionRuntime.ts"; | 21 | import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; |
| 22 | import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; | 22 | import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; |
| 23 | import { | 23 | import { |
| 24 | makeManualOnlyProviderMaintenanceCapabilities, | 24 | makeManualOnlyProviderMaintenanceCapabilities, |
| ... | @@ -38,7 +38,7 @@ const AUTH_UNCHECKED_MESSAGE = | ... | @@ -38,7 +38,7 @@ const AUTH_UNCHECKED_MESSAGE = |
| 38 | "Antigravity is installed. Google account access is not checked yet."; | 38 | "Antigravity is installed. Google account access is not checked yet."; |
| 39 | 39 | ||
| 40 | type SessionSetupResult = Pick< | 40 | type SessionSetupResult = Pick< |
| 41 | AcpSessionRuntimeStartResult["sessionSetupResult"], | 41 | AcpSessionRuntime.AcpSessionRuntimeStartResult["sessionSetupResult"], |
| 42 | "configOptions" | "models" | 42 | "configOptions" | "models" |
| 43 | >; | 43 | >; |
| 44 | 44 | ||
| ... | @@ -256,7 +256,7 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func | ... | @@ -256,7 +256,7 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func |
| 256 | }); | 256 | }); |
| 257 | 257 | ||
| 258 | const onSessionStarted = Effect.fn("AntigravityProvider.onSessionStarted")(function* ( | 258 | const onSessionStarted = Effect.fn("AntigravityProvider.onSessionStarted")(function* ( |
| 259 | started: AcpSessionRuntimeStartResult, | 259 | started: AcpSessionRuntime.AcpSessionRuntimeStartResult, |
| 260 | cwd?: string, | 260 | cwd?: string, |
| 261 | ) { | 261 | ) { |
| 262 | const before = yield* SubscriptionRef.get(metadata); | 262 | const before = yield* SubscriptionRef.get(metadata); |
apps/server/src/provider/Drivers/AcpRegistryDriver.ts+3-5| ... | @@ -22,7 +22,6 @@ import * as Semaphore from "effect/Semaphore"; | ... | @@ -22,7 +22,6 @@ import * as Semaphore from "effect/Semaphore"; |
| 22 | import { ChildProcessSpawner } from "effect/process"; | 22 | import { ChildProcessSpawner } from "effect/process"; |
| 23 | 23 | ||
| 24 | import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost"; | 24 | import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost"; |
| 25 | import * as ServerConfig from "../../config.ts"; | ||
| 26 | import { | 25 | import { |
| 27 | AcpRegistryAdapterV2Driver, | 26 | AcpRegistryAdapterV2Driver, |
| 28 | type AcpRegistryAdapterV2DriverEnv, | 27 | type AcpRegistryAdapterV2DriverEnv, |
| ... | @@ -489,7 +488,6 @@ export const AcpRegistryDriver: ProviderDriver<AcpRegistrySettings, AcpRegistryD | ... | @@ -489,7 +488,6 @@ export const AcpRegistryDriver: ProviderDriver<AcpRegistrySettings, AcpRegistryD |
| 489 | const crypto = yield* Crypto.Crypto; | 488 | const crypto = yield* Crypto.Crypto; |
| 490 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | 489 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 491 | const hostEnvironment = yield* HostProcessEnvironment; | 490 | const hostEnvironment = yield* HostProcessEnvironment; |
| 492 | const serverConfig = yield* ServerConfig.ServerConfig; | ||
| 493 | const host = yield* ProviderHost; | 491 | const host = yield* ProviderHost; |
| 494 | const continuationIdentity = defaultProviderContinuationIdentity({ | 492 | const continuationIdentity = defaultProviderContinuationIdentity({ |
| 495 | driverKind: DRIVER_KIND, | 493 | driverKind: DRIVER_KIND, |
| ... | @@ -528,7 +526,7 @@ export const AcpRegistryDriver: ProviderDriver<AcpRegistrySettings, AcpRegistryD | ... | @@ -528,7 +526,7 @@ export const AcpRegistryDriver: ProviderDriver<AcpRegistrySettings, AcpRegistryD |
| 528 | }; | 526 | }; |
| 529 | const confirmedAuthentication = | 527 | const confirmedAuthentication = |
| 530 | yield* AcpRegistryAuthenticationState.makeAcpRegistryAuthenticationState({ | 528 | yield* AcpRegistryAuthenticationState.makeAcpRegistryAuthenticationState({ |
| 531 | cacheDir: serverConfig.providerStatusCacheDir, | 529 | cacheDir: host.paths.providerStatusCacheDir, |
| 532 | instanceId, | 530 | instanceId, |
| 533 | settings: effectiveConfig, | 531 | settings: effectiveConfig, |
| 534 | environment, | 532 | environment, |
| ... | @@ -573,7 +571,7 @@ export const AcpRegistryDriver: ProviderDriver<AcpRegistrySettings, AcpRegistryD | ... | @@ -573,7 +571,7 @@ export const AcpRegistryDriver: ProviderDriver<AcpRegistrySettings, AcpRegistryD |
| 573 | const enrichProvider = checkAcpRegistryProviderStatus( | 571 | const enrichProvider = checkAcpRegistryProviderStatus( |
| 574 | { | 572 | { |
| 575 | ...readinessInput, | 573 | ...readinessInput, |
| 576 | cwd: serverConfig.cwd, | 574 | cwd: host.paths.cwd, |
| 577 | }, | 575 | }, |
| 578 | probeAcpRegistryConfiguration, | 576 | probeAcpRegistryConfiguration, |
| 579 | ).pipe( | 577 | ).pipe( |
| ... | @@ -768,7 +766,7 @@ export const AcpRegistryDriver: ProviderDriver<AcpRegistrySettings, AcpRegistryD | ... | @@ -768,7 +766,7 @@ export const AcpRegistryDriver: ProviderDriver<AcpRegistrySettings, AcpRegistryD |
| 768 | const controller = yield* AcpRegistryAuth.makeAcpRegistryAuth({ | 766 | const controller = yield* AcpRegistryAuth.makeAcpRegistryAuth({ |
| 769 | instanceId, | 767 | instanceId, |
| 770 | settings: effectiveConfig, | 768 | settings: effectiveConfig, |
| 771 | cwd: serverConfig.cwd, | 769 | cwd: host.paths.cwd, |
| 772 | environment: processEnvironment, | 770 | environment: processEnvironment, |
| 773 | onChanged: (authenticated) => | 771 | onChanged: (authenticated) => |
| 774 | confirmedAuthentication | 772 | confirmedAuthentication |
apps/server/src/provider/Drivers/AntigravityDriver.ts+9-11| ... | @@ -19,7 +19,6 @@ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; | ... | @@ -19,7 +19,6 @@ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; |
| 19 | import type { AcpError } from "effect-acp/errors"; | 19 | import type { AcpError } from "effect-acp/errors"; |
| 20 | 20 | ||
| 21 | import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost"; | 21 | import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost"; |
| 22 | import * as ServerConfig from "../../config.ts"; | ||
| 23 | import { | 22 | import { |
| 24 | isAntigravityTextGenerationAvailable, | 23 | isAntigravityTextGenerationAvailable, |
| 25 | makeAntigravityTextGeneration, | 24 | makeAntigravityTextGeneration, |
| ... | @@ -40,7 +39,7 @@ import { | ... | @@ -40,7 +39,7 @@ import { |
| 40 | makeAntigravityAcpRuntime, | 39 | makeAntigravityAcpRuntime, |
| 41 | type AntigravityAcpRuntimeInput, | 40 | type AntigravityAcpRuntimeInput, |
| 42 | } from "../acp/AntigravityAcpSupport.ts"; | 41 | } from "../acp/AntigravityAcpSupport.ts"; |
| 43 | import type { AcpSessionRuntime, AcpSessionRuntimeStartResult } from "../acp/AcpSessionRuntime.ts"; | 42 | import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; |
| 44 | import type { ServerProviderDraft } from "@t3tools/provider-core/server/snapshotProbe"; | 43 | import type { ServerProviderDraft } from "@t3tools/provider-core/server/snapshotProbe"; |
| 45 | import { | 44 | import { |
| 46 | removeAntigravityRuntimeTempDirs, | 45 | removeAntigravityRuntimeTempDirs, |
| ... | @@ -49,7 +48,7 @@ import { | ... | @@ -49,7 +48,7 @@ import { |
| 49 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; | 48 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; |
| 50 | import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; | 49 | import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; |
| 51 | import { makeAntigravityAdapterV2 } from "../../orchestration-v2/Adapters/AntigravityAdapterV2.ts"; | 50 | import { makeAntigravityAdapterV2 } from "../../orchestration-v2/Adapters/AntigravityAdapterV2.ts"; |
| 52 | import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts"; | 51 | import { makeAcpNativeLoggerFactory } from "@t3tools/provider-acp/server/nativeLogging"; |
| 53 | import { ProviderDriverError } from "../Errors.ts"; | 52 | import { ProviderDriverError } from "../Errors.ts"; |
| 54 | import { makeAntigravityProvider } from "../AntigravityProvider.ts"; | 53 | import { makeAntigravityProvider } from "../AntigravityProvider.ts"; |
| 55 | import * as ProviderEventLoggers from "../ProviderEventLoggers.ts"; | 54 | import * as ProviderEventLoggers from "../ProviderEventLoggers.ts"; |
| ... | @@ -76,8 +75,7 @@ export type AntigravityDriverEnv = | ... | @@ -76,8 +75,7 @@ export type AntigravityDriverEnv = |
| 76 | | IdAllocator.IdAllocatorV2 | 75 | | IdAllocator.IdAllocatorV2 |
| 77 | | ModelManifest.ModelManifest | 76 | | ModelManifest.ModelManifest |
| 78 | | Path.Path | 77 | | Path.Path |
| 79 | | ProviderEventLoggers.ProviderEventLoggers | 78 | | ProviderEventLoggers.ProviderEventLoggers; |
| 80 | | ServerConfig.ServerConfig; | ||
| 81 | 79 | ||
| 82 | /** Each instance owns its Google profile. Executable releases are shared by the environment. */ | 80 | /** Each instance owns its Google profile. Executable releases are shared by the environment. */ |
| 83 | export const AntigravityDriver: ProviderDriver<AntigravitySettings, AntigravityDriverEnv> = { | 81 | export const AntigravityDriver: ProviderDriver<AntigravitySettings, AntigravityDriverEnv> = { |
| ... | @@ -91,7 +89,7 @@ export const AntigravityDriver: ProviderDriver<AntigravitySettings, AntigravityD | ... | @@ -91,7 +89,7 @@ export const AntigravityDriver: ProviderDriver<AntigravitySettings, AntigravityD |
| 91 | const fileSystem = yield* FileSystem.FileSystem; | 89 | const fileSystem = yield* FileSystem.FileSystem; |
| 92 | const path = yield* Path.Path; | 90 | const path = yield* Path.Path; |
| 93 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | 91 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 94 | const serverConfig = yield* ServerConfig.ServerConfig; | 92 | const host = yield* ProviderHost; |
| 95 | const selfInvocation = yield* resolveSelfInvocation(); | 93 | const selfInvocation = yield* resolveSelfInvocation(); |
| 96 | const installation = yield* AntigravityInstallation.AntigravityInstallation; | 94 | const installation = yield* AntigravityInstallation.AntigravityInstallation; |
| 97 | const loggers = yield* ProviderEventLoggers.ProviderEventLoggers; | 95 | const loggers = yield* ProviderEventLoggers.ProviderEventLoggers; |
| ... | @@ -110,7 +108,7 @@ export const AntigravityDriver: ProviderDriver<AntigravitySettings, AntigravityD | ... | @@ -110,7 +108,7 @@ export const AntigravityDriver: ProviderDriver<AntigravitySettings, AntigravityD |
| 110 | const processEnvironment = mergeProviderInstanceEnvironment(environment); | 108 | const processEnvironment = mergeProviderInstanceEnvironment(environment); |
| 111 | const userHome = resolveAntigravityUserHome(yield* HostProcessPlatform, processEnvironment); | 109 | const userHome = resolveAntigravityUserHome(yield* HostProcessPlatform, processEnvironment); |
| 112 | const directories = yield* resolveAntigravityInstanceDirectories( | 110 | const directories = yield* resolveAntigravityInstanceDirectories( |
| 113 | serverConfig.stateDir, | 111 | host.paths.stateDir, |
| 114 | instanceId, | 112 | instanceId, |
| 115 | ).pipe( | 113 | ).pipe( |
| 116 | Effect.provideService(Crypto.Crypto, crypto), | 114 | Effect.provideService(Crypto.Crypto, crypto), |
| ... | @@ -158,7 +156,7 @@ export const AntigravityDriver: ProviderDriver<AntigravitySettings, AntigravityD | ... | @@ -158,7 +156,7 @@ export const AntigravityDriver: ProviderDriver<AntigravitySettings, AntigravityD |
| 158 | const makeRuntime = Effect.fn("AntigravityDriver.makeRuntime")(function* ( | 156 | const makeRuntime = Effect.fn("AntigravityDriver.makeRuntime")(function* ( |
| 159 | input: Omit<AntigravityAcpRuntimeInput, "spawn" | "childProcessSpawner">, | 157 | input: Omit<AntigravityAcpRuntimeInput, "spawn" | "childProcessSpawner">, |
| 160 | ): Effect.fn.Return< | 158 | ): Effect.fn.Return< |
| 161 | AcpSessionRuntime["Service"], | 159 | AcpSessionRuntime.AcpSessionRuntime["Service"], |
| 162 | AcpError | ProviderSetupError, | 160 | AcpError | ProviderSetupError, |
| 163 | Scope.Scope | 161 | Scope.Scope |
| 164 | > { | 162 | > { |
| ... | @@ -303,8 +301,8 @@ export const AntigravityDriver: ProviderDriver<AntigravitySettings, AntigravityD | ... | @@ -303,8 +301,8 @@ export const AntigravityDriver: ProviderDriver<AntigravitySettings, AntigravityD |
| 303 | }); | 301 | }); |
| 304 | 302 | ||
| 305 | const publishCatalog = ( | 303 | const publishCatalog = ( |
| 306 | started: AcpSessionRuntimeStartResult, | 304 | started: AcpSessionRuntime.AcpSessionRuntimeStartResult, |
| 307 | runtime: Pick<AcpSessionRuntime["Service"], "getEvents" | "drainEvents">, | 305 | runtime: Pick<AcpSessionRuntime.AcpSessionRuntime["Service"], "getEvents" | "drainEvents">, |
| 308 | ): Effect.Effect<void> => | 306 | ): Effect.Effect<void> => |
| 309 | Effect.gen(function* () { | 307 | Effect.gen(function* () { |
| 310 | yield* provider.onSessionStarted(started); | 308 | yield* provider.onSessionStarted(started); |
| ... | @@ -405,7 +403,7 @@ export const AntigravityDriver: ProviderDriver<AntigravitySettings, AntigravityD | ... | @@ -405,7 +403,7 @@ export const AntigravityDriver: ProviderDriver<AntigravitySettings, AntigravityD |
| 405 | fileSystem, | 403 | fileSystem, |
| 406 | path, | 404 | path, |
| 407 | idAllocator, | 405 | idAllocator, |
| 408 | serverConfig, | 406 | host, |
| 409 | selfInvocation, | 407 | selfInvocation, |
| 410 | makeRuntime, | 408 | makeRuntime, |
| 411 | withProcess: authFlow.withProcess, | 409 | withProcess: authFlow.withProcess, |
apps/server/src/provider/GrokProvider.ts+1-1| ... | @@ -42,7 +42,7 @@ import { | ... | @@ -42,7 +42,7 @@ import { |
| 42 | makeGrokAcpRuntime, | 42 | makeGrokAcpRuntime, |
| 43 | resolveGrokAcpBaseModelId, | 43 | resolveGrokAcpBaseModelId, |
| 44 | } from "./acp/GrokAcpSupport.ts"; | 44 | } from "./acp/GrokAcpSupport.ts"; |
| 45 | import { sessionModelStateFromInitialize } from "./acp/AcpRuntimeModel.ts"; | 45 | import { sessionModelStateFromInitialize } from "@t3tools/provider-acp/server/runtimeModel"; |
| 46 | import { discoverGrokSkills } from "./Drivers/GrokSkills.ts"; | 46 | import { discoverGrokSkills } from "./Drivers/GrokSkills.ts"; |
| 47 | 47 | ||
| 48 | const GROK_PRESENTATION = { | 48 | const GROK_PRESENTATION = { |
apps/server/src/provider/ProviderAuthService.test.ts+1-1| ... | @@ -22,7 +22,7 @@ import * as SubscriptionRef from "effect/SubscriptionRef"; | ... | @@ -22,7 +22,7 @@ import * as SubscriptionRef from "effect/SubscriptionRef"; |
| 22 | 22 | ||
| 23 | import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; | 23 | import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; |
| 24 | import * as ProviderSessionManager from "../orchestration-v2/ProviderSessionManager.ts"; | 24 | import * as ProviderSessionManager from "../orchestration-v2/ProviderSessionManager.ts"; |
| 25 | import { AcpProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/AcpAdapterV2.ts"; | 25 | import { AcpProviderCapabilitiesV2 } from "@t3tools/provider-acp/server/adapter"; |
| 26 | import type { ProviderInstance } from "@t3tools/provider-core/server/driver"; | 26 | import type { ProviderInstance } from "@t3tools/provider-core/server/driver"; |
| 27 | import * as ProviderAuthService from "./ProviderAuthService.ts"; | 27 | import * as ProviderAuthService from "./ProviderAuthService.ts"; |
| 28 | import * as ProviderInstanceRegistry from "./ProviderInstanceRegistry.ts"; | 28 | import * as ProviderInstanceRegistry from "./ProviderInstanceRegistry.ts"; |
apps/server/src/provider/ProviderHostLive.ts+1| ... | @@ -31,6 +31,7 @@ export const layer = Layer.effect( | ... | @@ -31,6 +31,7 @@ export const layer = Layer.effect( |
| 31 | baseDir: config.baseDir, | 31 | baseDir: config.baseDir, |
| 32 | stateDir: config.stateDir, | 32 | stateDir: config.stateDir, |
| 33 | providerStatusCacheDir: config.providerStatusCacheDir, | 33 | providerStatusCacheDir: config.providerStatusCacheDir, |
| 34 | attachmentsDir: config.attachmentsDir, | ||
| 34 | }, | 35 | }, |
| 35 | settings: { | 36 | settings: { |
| 36 | get: serverSettings.getSettings, | 37 | get: serverSettings.getSettings, |
apps/server/src/provider/acp/AcpClientPolicy.test.ts deleted-348| ... | @@ -1,348 +0,0 @@ | ||
| 1 | // @effect-diagnostics nodeBuiltinImport:off | ||
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 3 | import { assert, describe, it } from "@effect/vitest"; | ||
| 4 | import * as Effect from "effect/Effect"; | ||
| 5 | import * as FileSystem from "effect/FileSystem"; | ||
| 6 | import * as Path from "effect/Path"; | ||
| 7 | import type * as EffectAcpSchema from "effect-acp/compat"; | ||
| 8 | import * as NodePath from "node:path"; | ||
| 9 | |||
| 10 | import { | ||
| 11 | acpClientExecuteDisposition, | ||
| 12 | acpMcpToolApprovalElicitationDisposition, | ||
| 13 | acpPermissionDisposition, | ||
| 14 | makeAcpClientPolicyGrants, | ||
| 15 | type AcpRuntimePolicy, | ||
| 16 | } from "./AcpClientPolicy.ts"; | ||
| 17 | |||
| 18 | function permissionRequest( | ||
| 19 | kind: NonNullable<EffectAcpSchema.RequestPermissionRequest["toolCall"]["kind"]>, | ||
| 20 | locations?: ReadonlyArray<EffectAcpSchema.ToolCallLocation>, | ||
| 21 | ): EffectAcpSchema.RequestPermissionRequest { | ||
| 22 | return { | ||
| 23 | options: [], | ||
| 24 | sessionId: "permission-session", | ||
| 25 | toolCall: { | ||
| 26 | kind, | ||
| 27 | ...(locations === undefined ? {} : { locations }), | ||
| 28 | toolCallId: "permission-tool-call", | ||
| 29 | }, | ||
| 30 | }; | ||
| 31 | } | ||
| 32 | |||
| 33 | describe("acpPermissionDisposition", () => { | ||
| 34 | const cwd = NodePath.resolve(process.cwd(), "acp-permission-workspace"); | ||
| 35 | const writableRoot = NodePath.resolve(process.cwd(), "acp-additional-writable-root"); | ||
| 36 | const policy: AcpRuntimePolicy = { | ||
| 37 | runtimeMode: "full-access", | ||
| 38 | cwd, | ||
| 39 | approvalPolicy: "never", | ||
| 40 | sandboxPolicy: { | ||
| 41 | type: "workspaceWrite", | ||
| 42 | writableRoots: [writableRoot], | ||
| 43 | networkAccess: false, | ||
| 44 | }, | ||
| 45 | }; | ||
| 46 | |||
| 47 | it("auto-allows mutations only when every location is in cwd or an additional writable root", () => { | ||
| 48 | assert.equal( | ||
| 49 | acpPermissionDisposition(policy, permissionRequest("edit", [{ path: "src/index.ts" }])), | ||
| 50 | "allow", | ||
| 51 | ); | ||
| 52 | assert.equal( | ||
| 53 | acpPermissionDisposition( | ||
| 54 | policy, | ||
| 55 | permissionRequest("delete", [{ path: NodePath.join(writableRoot, "generated.ts") }]), | ||
| 56 | ), | ||
| 57 | "allow", | ||
| 58 | ); | ||
| 59 | assert.equal( | ||
| 60 | acpPermissionDisposition( | ||
| 61 | policy, | ||
| 62 | permissionRequest("move", [ | ||
| 63 | { path: NodePath.join(cwd, "from.ts") }, | ||
| 64 | { path: NodePath.join(writableRoot, "to.ts") }, | ||
| 65 | ]), | ||
| 66 | ), | ||
| 67 | "allow", | ||
| 68 | ); | ||
| 69 | }); | ||
| 70 | |||
| 71 | it("denies missing or out-of-root mutation locations", () => { | ||
| 72 | const outside = NodePath.resolve(process.cwd(), "outside-acp-permission-workspace", "file.ts"); | ||
| 73 | assert.equal(acpPermissionDisposition(policy, permissionRequest("edit")), "deny"); | ||
| 74 | assert.equal( | ||
| 75 | acpPermissionDisposition(policy, permissionRequest("delete", [{ path: "../escape.ts" }])), | ||
| 76 | "deny", | ||
| 77 | ); | ||
| 78 | assert.equal( | ||
| 79 | acpPermissionDisposition( | ||
| 80 | policy, | ||
| 81 | permissionRequest("move", [{ path: NodePath.join(cwd, "inside.ts") }, { path: outside }]), | ||
| 82 | ), | ||
| 83 | "deny", | ||
| 84 | ); | ||
| 85 | }); | ||
| 86 | |||
| 87 | it("keeps non-mutating workspace permissions and denials unchanged", () => { | ||
| 88 | assert.equal(acpPermissionDisposition(policy, permissionRequest("read")), "allow"); | ||
| 89 | assert.equal(acpPermissionDisposition(policy, permissionRequest("execute")), "deny"); | ||
| 90 | }); | ||
| 91 | |||
| 92 | it("auto-accept-edits approves file changes without locations and asks for the rest", () => { | ||
| 93 | // ACP permission requests need not carry locations. | ||
| 94 | const autoAcceptEdits: AcpRuntimePolicy = { runtimeMode: "auto-accept-edits", cwd }; | ||
| 95 | for (const kind of ["edit", "delete", "move"] as const) { | ||
| 96 | assert.equal(acpPermissionDisposition(autoAcceptEdits, permissionRequest(kind)), "allow"); | ||
| 97 | } | ||
| 98 | for (const kind of ["execute", "fetch", "other"] as const) { | ||
| 99 | assert.equal(acpPermissionDisposition(autoAcceptEdits, permissionRequest(kind)), "ask"); | ||
| 100 | } | ||
| 101 | assert.equal(acpPermissionDisposition(autoAcceptEdits, permissionRequest("read")), "allow"); | ||
| 102 | assert.equal( | ||
| 103 | acpPermissionDisposition( | ||
| 104 | { ...autoAcceptEdits, approvalPolicy: "on-request" }, | ||
| 105 | permissionRequest("edit"), | ||
| 106 | ), | ||
| 107 | "ask", | ||
| 108 | ); | ||
| 109 | }); | ||
| 110 | |||
| 111 | it("auto-allows read-kind permission requests under on-request approval", () => { | ||
| 112 | for (const runtimePolicy of [ | ||
| 113 | { ...policy, approvalPolicy: "on-request" }, | ||
| 114 | { ...policy, approvalPolicy: "on-request", sandboxPolicy: { type: "readOnly" } }, | ||
| 115 | { runtimeMode: "approval-required", cwd }, | ||
| 116 | ] satisfies ReadonlyArray<AcpRuntimePolicy>) { | ||
| 117 | for (const kind of ["read", "search", "think"] as const) { | ||
| 118 | assert.equal(acpPermissionDisposition(runtimePolicy, permissionRequest(kind)), "allow"); | ||
| 119 | } | ||
| 120 | for (const kind of ["edit", "delete", "move", "execute", "fetch", "other"] as const) { | ||
| 121 | assert.equal(acpPermissionDisposition(runtimePolicy, permissionRequest(kind)), "ask"); | ||
| 122 | } | ||
| 123 | } | ||
| 124 | }); | ||
| 125 | |||
| 126 | it.effect("denies mutations through workspace symlinks that escape the writable roots", () => | ||
| 127 | Effect.gen(function* () { | ||
| 128 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 129 | const path = yield* Path.Path; | ||
| 130 | const workspace = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 131 | prefix: "t3-acp-permission-workspace-", | ||
| 132 | }); | ||
| 133 | const outside = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 134 | prefix: "t3-acp-permission-outside-", | ||
| 135 | }); | ||
| 136 | const outsideFile = path.join(outside, "existing.ts"); | ||
| 137 | yield* fileSystem.writeFileString(outsideFile, "outside"); | ||
| 138 | yield* fileSystem.symlink(outsideFile, path.join(workspace, "linked-file.ts")); | ||
| 139 | yield* fileSystem.symlink(outside, path.join(workspace, "linked-directory")); | ||
| 140 | |||
| 141 | const realPolicy: AcpRuntimePolicy = { | ||
| 142 | runtimeMode: "full-access", | ||
| 143 | cwd: workspace, | ||
| 144 | approvalPolicy: "never", | ||
| 145 | sandboxPolicy: { | ||
| 146 | type: "workspaceWrite", | ||
| 147 | writableRoots: [], | ||
| 148 | networkAccess: false, | ||
| 149 | }, | ||
| 150 | }; | ||
| 151 | |||
| 152 | assert.equal( | ||
| 153 | acpPermissionDisposition( | ||
| 154 | realPolicy, | ||
| 155 | permissionRequest("edit", [{ path: "linked-file.ts" }]), | ||
| 156 | ), | ||
| 157 | "deny", | ||
| 158 | ); | ||
| 159 | assert.equal( | ||
| 160 | acpPermissionDisposition( | ||
| 161 | realPolicy, | ||
| 162 | permissionRequest("edit", [{ path: "linked-directory/new-file.ts" }]), | ||
| 163 | ), | ||
| 164 | "deny", | ||
| 165 | "a missing leaf below an escaping directory symlink must not be auto-approved", | ||
| 166 | ); | ||
| 167 | assert.equal( | ||
| 168 | acpPermissionDisposition( | ||
| 169 | realPolicy, | ||
| 170 | permissionRequest("edit", [{ path: "linked-directory/../escaped-file.ts" }]), | ||
| 171 | ), | ||
| 172 | "deny", | ||
| 173 | "physical symlink traversal must be resolved before parent segments", | ||
| 174 | ); | ||
| 175 | }).pipe(Effect.provide(NodeServices.layer)), | ||
| 176 | ); | ||
| 177 | |||
| 178 | it.effect("allows existing and new files beneath canonical writable roots", () => | ||
| 179 | Effect.gen(function* () { | ||
| 180 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 181 | const path = yield* Path.Path; | ||
| 182 | const workspace = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 183 | prefix: "t3-acp-permission-workspace-", | ||
| 184 | }); | ||
| 185 | const workspaceLinkParent = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 186 | prefix: "t3-acp-permission-link-parent-", | ||
| 187 | }); | ||
| 188 | const workspaceLink = path.join(workspaceLinkParent, "workspace-link"); | ||
| 189 | yield* fileSystem.makeDirectory(path.join(workspace, "src"), { recursive: true }); | ||
| 190 | yield* fileSystem.writeFileString(path.join(workspace, "src", "existing.ts"), "existing"); | ||
| 191 | yield* fileSystem.symlink(workspace, workspaceLink); | ||
| 192 | |||
| 193 | const realPolicy: AcpRuntimePolicy = { | ||
| 194 | runtimeMode: "full-access", | ||
| 195 | cwd: workspaceLink, | ||
| 196 | approvalPolicy: "never", | ||
| 197 | sandboxPolicy: { | ||
| 198 | type: "workspaceWrite", | ||
| 199 | writableRoots: [], | ||
| 200 | networkAccess: false, | ||
| 201 | }, | ||
| 202 | }; | ||
| 203 | |||
| 204 | assert.equal( | ||
| 205 | acpPermissionDisposition( | ||
| 206 | realPolicy, | ||
| 207 | permissionRequest("edit", [{ path: "src/existing.ts" }]), | ||
| 208 | ), | ||
| 209 | "allow", | ||
| 210 | ); | ||
| 211 | assert.equal( | ||
| 212 | acpPermissionDisposition( | ||
| 213 | realPolicy, | ||
| 214 | permissionRequest("edit", [{ path: "src/generated/new-file.ts" }]), | ||
| 215 | ), | ||
| 216 | "allow", | ||
| 217 | "non-existent descendants of a real in-root ancestor remain writable", | ||
| 218 | ); | ||
| 219 | }).pipe(Effect.provide(NodeServices.layer)), | ||
| 220 | ); | ||
| 221 | }); | ||
| 222 | |||
| 223 | describe("acpMcpToolApprovalElicitationDisposition", () => { | ||
| 224 | it("applies runtime policy only to explicitly tagged MCP approval elicitations", () => { | ||
| 225 | const fullAccess: AcpRuntimePolicy = { | ||
| 226 | runtimeMode: "full-access", | ||
| 227 | cwd: process.cwd(), | ||
| 228 | }; | ||
| 229 | const approvalRequired: AcpRuntimePolicy = { | ||
| 230 | runtimeMode: "approval-required", | ||
| 231 | cwd: process.cwd(), | ||
| 232 | }; | ||
| 233 | const tagged = { | ||
| 234 | sessionId: "session-1", | ||
| 235 | message: "Approve this request?", | ||
| 236 | mode: "form", | ||
| 237 | requestedSchema: { type: "object", properties: {} }, | ||
| 238 | _meta: { codex_approval_kind: "mcp_tool_call" }, | ||
| 239 | } satisfies EffectAcpSchema.CreateElicitationRequest; | ||
| 240 | |||
| 241 | assert.equal(acpMcpToolApprovalElicitationDisposition(fullAccess, tagged), "allow"); | ||
| 242 | assert.equal(acpMcpToolApprovalElicitationDisposition(approvalRequired, tagged), "ask"); | ||
| 243 | assert.equal( | ||
| 244 | acpMcpToolApprovalElicitationDisposition( | ||
| 245 | { ...fullAccess, approvalPolicy: "on-request" }, | ||
| 246 | tagged, | ||
| 247 | ), | ||
| 248 | "ask", | ||
| 249 | ); | ||
| 250 | assert.equal( | ||
| 251 | acpMcpToolApprovalElicitationDisposition( | ||
| 252 | { | ||
| 253 | runtimeMode: "auto-accept-edits", | ||
| 254 | cwd: process.cwd(), | ||
| 255 | approvalPolicy: "never", | ||
| 256 | sandboxPolicy: { | ||
| 257 | type: "workspaceWrite", | ||
| 258 | writableRoots: [], | ||
| 259 | networkAccess: false, | ||
| 260 | }, | ||
| 261 | }, | ||
| 262 | tagged, | ||
| 263 | ), | ||
| 264 | "allow", | ||
| 265 | ); | ||
| 266 | const { _meta: _tag, ...untagged } = tagged; | ||
| 267 | assert.equal( | ||
| 268 | acpMcpToolApprovalElicitationDisposition( | ||
| 269 | fullAccess, | ||
| 270 | untagged, | ||
| 271 | "mcp_tool_call_approval_exec-123", | ||
| 272 | ), | ||
| 273 | "allow", | ||
| 274 | ); | ||
| 275 | assert.isUndefined( | ||
| 276 | acpMcpToolApprovalElicitationDisposition(fullAccess, { | ||
| 277 | ...tagged, | ||
| 278 | _meta: { codex_approval_kind: "ordinary_form" }, | ||
| 279 | }), | ||
| 280 | ); | ||
| 281 | assert.isUndefined( | ||
| 282 | acpMcpToolApprovalElicitationDisposition(fullAccess, { | ||
| 283 | sessionId: "session-1", | ||
| 284 | message: "Authenticate", | ||
| 285 | mode: "url", | ||
| 286 | elicitationId: "elicitation-1", | ||
| 287 | url: "https://example.com/login", | ||
| 288 | _meta: { codex_approval_kind: "mcp_tool_call" }, | ||
| 289 | }), | ||
| 290 | ); | ||
| 291 | }); | ||
| 292 | }); | ||
| 293 | |||
| 294 | describe("client terminal disposition", () => { | ||
| 295 | const cwd = NodePath.resolve(process.cwd(), "acp-client-policy-workspace"); | ||
| 296 | |||
| 297 | it("asks in approval-required and auto-accept-edits, allows in auto and full access", () => { | ||
| 298 | assert.equal(acpClientExecuteDisposition({ runtimeMode: "approval-required", cwd }), "ask"); | ||
| 299 | assert.equal(acpClientExecuteDisposition({ runtimeMode: "auto-accept-edits", cwd }), "ask"); | ||
| 300 | assert.equal(acpClientExecuteDisposition({ runtimeMode: "auto", cwd }), "allow"); | ||
| 301 | assert.equal(acpClientExecuteDisposition({ runtimeMode: "full-access", cwd }), "allow"); | ||
| 302 | }); | ||
| 303 | |||
| 304 | it("denies terminals under explicit read-only and workspace-write sandboxes", () => { | ||
| 305 | for (const sandboxPolicy of [ | ||
| 306 | { type: "readOnly" }, | ||
| 307 | { type: "workspaceWrite", writableRoots: [], networkAccess: false }, | ||
| 308 | ]) { | ||
| 309 | assert.equal( | ||
| 310 | acpClientExecuteDisposition({ | ||
| 311 | runtimeMode: "full-access", | ||
| 312 | cwd, | ||
| 313 | approvalPolicy: "never", | ||
| 314 | sandboxPolicy, | ||
| 315 | }), | ||
| 316 | "deny", | ||
| 317 | ); | ||
| 318 | } | ||
| 319 | }); | ||
| 320 | }); | ||
| 321 | |||
| 322 | describe("makeAcpClientPolicyGrants", () => { | ||
| 323 | it("grants terminals only from commands, never from an approved read or edit", () => { | ||
| 324 | const grants = makeAcpClientPolicyGrants(); | ||
| 325 | grants.recordApproval({ kind: "file-read", scope: "turn", turnKey: "turn-1" }); | ||
| 326 | grants.recordApproval({ kind: "file-change", scope: "session", turnKey: "turn-1" }); | ||
| 327 | assert.isFalse(grants.allowsExecute("turn-1")); | ||
| 328 | grants.recordApproval({ kind: "command", scope: "turn", turnKey: "turn-1" }); | ||
| 329 | assert.isTrue(grants.allowsExecute("turn-1")); | ||
| 330 | assert.isFalse(grants.allowsExecute("turn-2")); | ||
| 331 | assert.isFalse(grants.allowsExecute(null)); | ||
| 332 | }); | ||
| 333 | |||
| 334 | it("keeps accept-for-session command grants across turns", () => { | ||
| 335 | const grants = makeAcpClientPolicyGrants(); | ||
| 336 | grants.recordApproval({ kind: "command", scope: "session", turnKey: "turn-1" }); | ||
| 337 | assert.isTrue(grants.allowsExecute("turn-9")); | ||
| 338 | assert.isTrue(grants.allowsExecute(null)); | ||
| 339 | }); | ||
| 340 | |||
| 341 | it("drops a turn's command grant when a later turn approves one", () => { | ||
| 342 | const grants = makeAcpClientPolicyGrants(); | ||
| 343 | grants.recordApproval({ kind: "command", scope: "turn", turnKey: "turn-1" }); | ||
| 344 | grants.recordApproval({ kind: "command", scope: "turn", turnKey: "turn-2" }); | ||
| 345 | assert.isFalse(grants.allowsExecute("turn-1")); | ||
| 346 | assert.isTrue(grants.allowsExecute("turn-2")); | ||
| 347 | }); | ||
| 348 | }); | ||
apps/server/src/provider/acp/AcpClientPolicy.ts deleted-297| ... | @@ -1,297 +0,0 @@ | ||
| 1 | // @effect-diagnostics nodeBuiltinImport:off | ||
| 2 | import type { ProviderRequestKind, RuntimeMode } from "@t3tools/contracts"; | ||
| 3 | import type * as EffectAcpSchema from "effect-acp/compat"; | ||
| 4 | import * as NodeFS from "node:fs"; | ||
| 5 | import * as NodePath from "node:path"; | ||
| 6 | |||
| 7 | /** | ||
| 8 | * Runtime-policy decisions for ACP work that T3 mediates. | ||
| 9 | * | ||
| 10 | * ACP agents run their own tools under their own permission model and ask | ||
| 11 | * through `session/request_permission`, which T3 answers by policy. The one | ||
| 12 | * client-mediated path left is Devin's `terminal/*`, which runs with the T3 | ||
| 13 | * server's own privileges; it resolves through {@link acpOperationDisposition} | ||
| 14 | * so a client terminal can never do more than an execute permission request. | ||
| 15 | */ | ||
| 16 | |||
| 17 | /** Structural subset of the adapter runtime policy that decisions read. */ | ||
| 18 | export interface AcpRuntimePolicy { | ||
| 19 | readonly runtimeMode: RuntimeMode; | ||
| 20 | readonly cwd: string | null; | ||
| 21 | readonly approvalPolicy?: unknown; | ||
| 22 | readonly sandboxPolicy?: unknown; | ||
| 23 | } | ||
| 24 | |||
| 25 | export type AcpPermissionDisposition = "allow" | "ask" | "deny"; | ||
| 26 | |||
| 27 | export function unknownRecord(value: unknown): Record<string, unknown> | undefined { | ||
| 28 | return typeof value === "object" && value !== null && !Array.isArray(value) | ||
| 29 | ? (value as Record<string, unknown>) | ||
| 30 | : undefined; | ||
| 31 | } | ||
| 32 | |||
| 33 | function resolveAcpPermissionPath(path: string, cwd: string | null): string | undefined { | ||
| 34 | const trimmed = path.trim(); | ||
| 35 | if (trimmed.length === 0) return undefined; | ||
| 36 | if (NodePath.isAbsolute(trimmed)) return trimmed; | ||
| 37 | if (cwd === null || cwd.trim().length === 0) return undefined; | ||
| 38 | return `${cwd}${cwd.endsWith(NodePath.sep) ? "" : NodePath.sep}${trimmed}`; | ||
| 39 | } | ||
| 40 | |||
| 41 | function acpPathIsWithinRoot(path: string, root: string): boolean { | ||
| 42 | const relative = NodePath.relative(root, path); | ||
| 43 | return ( | ||
| 44 | relative === "" || | ||
| 45 | (relative !== ".." && | ||
| 46 | !relative.startsWith(`..${NodePath.sep}`) && | ||
| 47 | !NodePath.isAbsolute(relative)) | ||
| 48 | ); | ||
| 49 | } | ||
| 50 | |||
| 51 | /** | ||
| 52 | * Canonicalize a path for an authorization containment check. | ||
| 53 | * | ||
| 54 | * `realpath` cannot resolve a file that has not been created yet, so walk up | ||
| 55 | * to the deepest existing ancestor and append the missing suffix to that | ||
| 56 | * ancestor's canonical path. This follows symlinked directories while still | ||
| 57 | * allowing normal writes to new files. If an existing entry cannot be | ||
| 58 | * canonicalized (for example, a broken symlink), fail closed. | ||
| 59 | */ | ||
| 60 | function acpCanonicalPathForContainment(path: string): string | undefined { | ||
| 61 | // Do not lexically normalize before realpath. For a path such as | ||
| 62 | // `workspace/link/../file`, the kernel resolves `link` before `..`; an | ||
| 63 | // eager NodePath.resolve would erase that symlink traversal and could turn | ||
| 64 | // an outside target into an apparently in-workspace path. | ||
| 65 | let candidate = path; | ||
| 66 | const missingSuffix: Array<string> = []; | ||
| 67 | |||
| 68 | while (true) { | ||
| 69 | try { | ||
| 70 | return NodePath.resolve(NodeFS.realpathSync.native(candidate), ...missingSuffix); | ||
| 71 | } catch (cause) { | ||
| 72 | if ((cause as NodeJS.ErrnoException | undefined)?.code !== "ENOENT") { | ||
| 73 | return undefined; | ||
| 74 | } | ||
| 75 | } | ||
| 76 | |||
| 77 | try { | ||
| 78 | NodeFS.lstatSync(candidate); | ||
| 79 | // The entry exists but realpath could not resolve it, as with a broken | ||
| 80 | // symlink. Treat it as untrusted rather than authorizing its lexical path. | ||
| 81 | return undefined; | ||
| 82 | } catch (cause) { | ||
| 83 | if ((cause as NodeJS.ErrnoException | undefined)?.code !== "ENOENT") { | ||
| 84 | return undefined; | ||
| 85 | } | ||
| 86 | } | ||
| 87 | |||
| 88 | const parent = NodePath.dirname(candidate); | ||
| 89 | if (parent === candidate) return undefined; | ||
| 90 | missingSuffix.unshift(NodePath.basename(candidate)); | ||
| 91 | candidate = parent; | ||
| 92 | } | ||
| 93 | } | ||
| 94 | |||
| 95 | /** The parts of a native operation the disposition logic reads. */ | ||
| 96 | interface AcpPolicyOperation { | ||
| 97 | readonly kind: string | null | undefined; | ||
| 98 | readonly locations: ReadonlyArray<{ readonly path: string }> | null | undefined; | ||
| 99 | } | ||
| 100 | |||
| 101 | function acpPolicyRequiresApproval(runtimePolicy: AcpRuntimePolicy): boolean { | ||
| 102 | return runtimePolicy.approvalPolicy === undefined | ||
| 103 | ? runtimePolicy.runtimeMode === "approval-required" | ||
| 104 | : runtimePolicy.approvalPolicy !== "never"; | ||
| 105 | } | ||
| 106 | |||
| 107 | function isAcpReadKind(toolKind: string): boolean { | ||
| 108 | return toolKind === "read" || toolKind === "search" || toolKind === "think"; | ||
| 109 | } | ||
| 110 | |||
| 111 | /** | ||
| 112 | * Reads follow the sandbox alone and never ask. Approval policy governs writes | ||
| 113 | * and commands, as it does for Codex and Claude, and every sandbox T3 knows | ||
| 114 | * lets the agent read. That includes no explicit sandbox, since the strictest | ||
| 115 | * runtime mode (approval-required) implies a read-only one. Unknown sandbox | ||
| 116 | * types still fail closed. | ||
| 117 | */ | ||
| 118 | function acpReadDisposition(runtimePolicy: AcpRuntimePolicy): "allow" | "deny" { | ||
| 119 | switch (unknownRecord(runtimePolicy.sandboxPolicy)?.type) { | ||
| 120 | case undefined: | ||
| 121 | case "readOnly": | ||
| 122 | case "workspaceWrite": | ||
| 123 | case "dangerFullAccess": | ||
| 124 | case "externalSandbox": | ||
| 125 | return "allow"; | ||
| 126 | default: | ||
| 127 | return "deny"; | ||
| 128 | } | ||
| 129 | } | ||
| 130 | |||
| 131 | function acpWorkspaceWriteAllowsMutation( | ||
| 132 | runtimePolicy: AcpRuntimePolicy, | ||
| 133 | sandboxPolicy: Record<string, unknown>, | ||
| 134 | locations: AcpPolicyOperation["locations"], | ||
| 135 | ): boolean { | ||
| 136 | const cwd = | ||
| 137 | typeof runtimePolicy.cwd === "string" && runtimePolicy.cwd.trim().length > 0 | ||
| 138 | ? (resolveAcpPermissionPath(runtimePolicy.cwd, process.cwd()) ?? null) | ||
| 139 | : null; | ||
| 140 | const roots: Array<string> = []; | ||
| 141 | if (cwd !== null) { | ||
| 142 | const canonicalCwd = acpCanonicalPathForContainment(cwd); | ||
| 143 | if (canonicalCwd !== undefined) roots.push(canonicalCwd); | ||
| 144 | } | ||
| 145 | const writableRoots = sandboxPolicy.writableRoots; | ||
| 146 | if (Array.isArray(writableRoots)) { | ||
| 147 | for (const writableRoot of writableRoots) { | ||
| 148 | if (typeof writableRoot !== "string") continue; | ||
| 149 | const resolved = resolveAcpPermissionPath(writableRoot, cwd); | ||
| 150 | if (resolved === undefined) continue; | ||
| 151 | const canonicalRoot = acpCanonicalPathForContainment(resolved); | ||
| 152 | if (canonicalRoot !== undefined) roots.push(canonicalRoot); | ||
| 153 | } | ||
| 154 | } | ||
| 155 | if (roots.length === 0) return false; | ||
| 156 | |||
| 157 | if (locations === undefined || locations === null || locations.length === 0) { | ||
| 158 | return false; | ||
| 159 | } | ||
| 160 | for (const location of locations) { | ||
| 161 | const resolved = resolveAcpPermissionPath(location.path, cwd); | ||
| 162 | const canonicalPath = | ||
| 163 | resolved === undefined ? undefined : acpCanonicalPathForContainment(resolved); | ||
| 164 | if ( | ||
| 165 | canonicalPath === undefined || | ||
| 166 | !roots.some((root) => acpPathIsWithinRoot(canonicalPath, root)) | ||
| 167 | ) { | ||
| 168 | return false; | ||
| 169 | } | ||
| 170 | } | ||
| 171 | return true; | ||
| 172 | } | ||
| 173 | |||
| 174 | /** | ||
| 175 | * Disposition of one native operation under the active runtime policy. Shared | ||
| 176 | * by provider permission requests and the client fs/terminal handlers. | ||
| 177 | */ | ||
| 178 | function acpOperationDisposition( | ||
| 179 | runtimePolicy: AcpRuntimePolicy, | ||
| 180 | operation: AcpPolicyOperation, | ||
| 181 | ): AcpPermissionDisposition { | ||
| 182 | const toolKind = operation.kind ?? "other"; | ||
| 183 | if (isAcpReadKind(toolKind)) { | ||
| 184 | return acpReadDisposition(runtimePolicy); | ||
| 185 | } | ||
| 186 | if (acpPolicyRequiresApproval(runtimePolicy)) { | ||
| 187 | return "ask"; | ||
| 188 | } | ||
| 189 | |||
| 190 | const sandboxPolicy = unknownRecord(runtimePolicy.sandboxPolicy); | ||
| 191 | switch (sandboxPolicy?.type) { | ||
| 192 | case "readOnly": | ||
| 193 | return "deny"; | ||
| 194 | case "workspaceWrite": | ||
| 195 | if (isAcpMutationKind(toolKind)) { | ||
| 196 | return acpWorkspaceWriteAllowsMutation( | ||
| 197 | runtimePolicy, | ||
| 198 | sandboxPolicy ?? {}, | ||
| 199 | operation.locations, | ||
| 200 | ) | ||
| 201 | ? "allow" | ||
| 202 | : "deny"; | ||
| 203 | } | ||
| 204 | return "deny"; | ||
| 205 | case "dangerFullAccess": | ||
| 206 | case "externalSandbox": | ||
| 207 | return "allow"; | ||
| 208 | case undefined: | ||
| 209 | if (runtimePolicy.runtimeMode === "approval-required") return "deny"; | ||
| 210 | // Auto-accept edits approves file changes wherever the agent makes them | ||
| 211 | // (ACP prompts need not carry locations to confine); other actions still ask. | ||
| 212 | if ( | ||
| 213 | runtimePolicy.runtimeMode === "auto-accept-edits" && | ||
| 214 | runtimePolicy.approvalPolicy === undefined && | ||
| 215 | !isAcpMutationKind(toolKind) | ||
| 216 | ) { | ||
| 217 | return "ask"; | ||
| 218 | } | ||
| 219 | return "allow"; | ||
| 220 | default: | ||
| 221 | return "deny"; | ||
| 222 | } | ||
| 223 | } | ||
| 224 | |||
| 225 | function isAcpMutationKind(toolKind: string): boolean { | ||
| 226 | return toolKind === "edit" || toolKind === "delete" || toolKind === "move"; | ||
| 227 | } | ||
| 228 | |||
| 229 | export function acpPermissionDisposition( | ||
| 230 | runtimePolicy: AcpRuntimePolicy, | ||
| 231 | request: EffectAcpSchema.RequestPermissionRequest, | ||
| 232 | ): AcpPermissionDisposition { | ||
| 233 | return acpOperationDisposition(runtimePolicy, { | ||
| 234 | kind: request.toolCall.kind, | ||
| 235 | locations: request.toolCall.locations, | ||
| 236 | }); | ||
| 237 | } | ||
| 238 | |||
| 239 | /** Resolve explicitly tagged MCP approvals through the thread's normal policy. */ | ||
| 240 | export function acpMcpToolApprovalElicitationDisposition( | ||
| 241 | runtimePolicy: AcpRuntimePolicy, | ||
| 242 | request: EffectAcpSchema.CreateElicitationRequest, | ||
| 243 | nativeRequestId?: string, | ||
| 244 | ): AcpPermissionDisposition | undefined { | ||
| 245 | if ( | ||
| 246 | request.mode !== "form" || | ||
| 247 | (unknownRecord(request._meta)?.codex_approval_kind !== "mcp_tool_call" && | ||
| 248 | nativeRequestId?.startsWith("mcp_tool_call_approval_") !== true) | ||
| 249 | ) { | ||
| 250 | return undefined; | ||
| 251 | } | ||
| 252 | // This request comes from T3's authenticated, scope-checked MCP endpoint, | ||
| 253 | // not an arbitrary provider command. Let explicit approval mode surface it | ||
| 254 | // to the user and otherwise allow the endpoint to enforce its own policy. | ||
| 255 | return acpPolicyRequiresApproval(runtimePolicy) ? "ask" : "allow"; | ||
| 256 | } | ||
| 257 | |||
| 258 | /** Disposition of a client-mediated `terminal/create` (Devin's client terminals). */ | ||
| 259 | export function acpClientExecuteDisposition( | ||
| 260 | runtimePolicy: AcpRuntimePolicy, | ||
| 261 | ): AcpPermissionDisposition { | ||
| 262 | return acpOperationDisposition(runtimePolicy, { kind: "execute", locations: undefined }); | ||
| 263 | } | ||
| 264 | |||
| 265 | export interface AcpApprovalGrantInput { | ||
| 266 | readonly kind: ProviderRequestKind; | ||
| 267 | readonly scope: "session" | "turn"; | ||
| 268 | readonly turnKey: string; | ||
| 269 | } | ||
| 270 | |||
| 271 | export interface AcpClientPolicyGrants { | ||
| 272 | readonly recordApproval: (input: AcpApprovalGrantInput) => void; | ||
| 273 | readonly allowsExecute: (turnKey: string | null) => boolean; | ||
| 274 | } | ||
| 275 | |||
| 276 | /** | ||
| 277 | * Command approvals recorded when the user accepts a `session/request_permission`, | ||
| 278 | * so an "ask" disposition at the client terminal boundary honors a command the | ||
| 279 | * user already approved. ACP does not link an approved tool call to the | ||
| 280 | * terminals the agent then creates, so an approved command authorizes client | ||
| 281 | * terminals for the approving turn, or for the session on accept-for-session. | ||
| 282 | */ | ||
| 283 | export function makeAcpClientPolicyGrants(): AcpClientPolicyGrants { | ||
| 284 | let sessionExecute = false; | ||
| 285 | let turnExecute: string | null = null; | ||
| 286 | return { | ||
| 287 | recordApproval: (input) => { | ||
| 288 | if (input.kind !== "command") return; | ||
| 289 | if (input.scope === "session") { | ||
| 290 | sessionExecute = true; | ||
| 291 | } else { | ||
| 292 | turnExecute = input.turnKey; | ||
| 293 | } | ||
| 294 | }, | ||
| 295 | allowsExecute: (turnKey) => sessionExecute || (turnKey !== null && turnExecute === turnKey), | ||
| 296 | }; | ||
| 297 | } | ||
apps/server/src/provider/acp/AcpClientTerminals.test.ts deleted-312| ... | @@ -1,312 +0,0 @@ | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { describe, expect, it } from "@effect/vitest"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Result from "effect/Result"; | ||
| 5 | import { ChildProcessSpawner } from "effect/process"; | ||
| 6 | |||
| 7 | import { | ||
| 8 | makeAcpClientTerminals, | ||
| 9 | resolveEmbeddedTerminalContent, | ||
| 10 | type AcpClientTerminals, | ||
| 11 | } from "./AcpClientTerminals.ts"; | ||
| 12 | |||
| 13 | const withTerminals = <A, E>(use: (terminals: AcpClientTerminals) => Effect.Effect<A, E>) => | ||
| 14 | Effect.gen(function* () { | ||
| 15 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 16 | const terminals = yield* makeAcpClientTerminals({ | ||
| 17 | spawner, | ||
| 18 | defaultCwd: process.cwd(), | ||
| 19 | }); | ||
| 20 | return yield* use(terminals).pipe(Effect.ensuring(terminals.disposeAll)); | ||
| 21 | }).pipe(Effect.provide(NodeServices.layer), Effect.scoped); | ||
| 22 | |||
| 23 | describe("AcpClientTerminals", () => { | ||
| 24 | it.effect("runs a command, buffers output, and reports the exit status", () => | ||
| 25 | withTerminals((terminals) => | ||
| 26 | Effect.gen(function* () { | ||
| 27 | const created = yield* terminals.create({ | ||
| 28 | sessionId: "session", | ||
| 29 | command: process.execPath, | ||
| 30 | args: ["-e", "console.log('hello from acp'); process.exit(3);"], | ||
| 31 | }); | ||
| 32 | |||
| 33 | const exit = yield* terminals.waitForExit({ | ||
| 34 | sessionId: "session", | ||
| 35 | terminalId: created.terminalId, | ||
| 36 | }); | ||
| 37 | expect(exit.exitCode).toBe(3); | ||
| 38 | |||
| 39 | const output = yield* terminals.output({ | ||
| 40 | sessionId: "session", | ||
| 41 | terminalId: created.terminalId, | ||
| 42 | }); | ||
| 43 | expect(output.output).toContain("hello from acp"); | ||
| 44 | expect(output.truncated).toBe(false); | ||
| 45 | expect(output.exitStatus?.exitCode).toBe(3); | ||
| 46 | }), | ||
| 47 | ), | ||
| 48 | ); | ||
| 49 | |||
| 50 | it.effect("resolves terminal environment from the requesting ACP session", () => | ||
| 51 | Effect.gen(function* () { | ||
| 52 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 53 | const terminals = yield* makeAcpClientTerminals({ | ||
| 54 | spawner, | ||
| 55 | defaultCwd: process.cwd(), | ||
| 56 | environmentForSession: (sessionId) => ({ | ||
| 57 | T3_TEST_ACP_SESSION_TOKEN: sessionId === "session-a" ? "token-a" : "token-b", | ||
| 58 | }), | ||
| 59 | }); | ||
| 60 | yield* Effect.addFinalizer(() => terminals.disposeAll); | ||
| 61 | |||
| 62 | for (const [sessionId, expected] of [ | ||
| 63 | ["session-a", "token-a"], | ||
| 64 | ["session-b", "token-b"], | ||
| 65 | ] as const) { | ||
| 66 | const created = yield* terminals.create({ | ||
| 67 | sessionId, | ||
| 68 | command: process.execPath, | ||
| 69 | args: ["-e", "process.stdout.write(process.env.T3_TEST_ACP_SESSION_TOKEN ?? '')"], | ||
| 70 | }); | ||
| 71 | yield* terminals.waitForExit({ sessionId, terminalId: created.terminalId }); | ||
| 72 | const output = yield* terminals.output({ sessionId, terminalId: created.terminalId }); | ||
| 73 | expect(output.output).toBe(expected); | ||
| 74 | } | ||
| 75 | }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), | ||
| 76 | ); | ||
| 77 | |||
| 78 | it.effect("runs Devin shell source with arguments, pipes, and session fallback variables", () => | ||
| 79 | Effect.gen(function* () { | ||
| 80 | const terminals = yield* makeAcpClientTerminals({ | ||
| 81 | spawner: yield* ChildProcessSpawner.ChildProcessSpawner, | ||
| 82 | defaultCwd: process.cwd(), | ||
| 83 | shellCommands: true, | ||
| 84 | environmentForSession: () => ({ T3_ACP_MCP_NODE: "mailbox-probe" }), | ||
| 85 | }); | ||
| 86 | yield* Effect.addFinalizer(() => terminals.disposeAll); | ||
| 87 | // Same command-only shape as the failed production commands and live capture. | ||
| 88 | const terminal = yield* terminals.create({ | ||
| 89 | sessionId: "devin", | ||
| 90 | command: 'printf "%s\\n" "$T3_ACP_MCP_NODE" | tr a-z A-Z', | ||
| 91 | }); | ||
| 92 | const exit = yield* terminals.waitForExit({ sessionId: "devin", ...terminal }); | ||
| 93 | const output = yield* terminals.output({ sessionId: "devin", ...terminal }); | ||
| 94 | expect(exit.exitCode).toBe(0); | ||
| 95 | expect(output.output.trim()).toBe("MAILBOX-PROBE"); | ||
| 96 | }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), | ||
| 97 | ); | ||
| 98 | |||
| 99 | it.effect("truncates buffered output from the beginning at the byte limit", () => | ||
| 100 | withTerminals((terminals) => | ||
| 101 | Effect.gen(function* () { | ||
| 102 | const created = yield* terminals.create({ | ||
| 103 | sessionId: "session", | ||
| 104 | command: process.execPath, | ||
| 105 | args: ["-e", "process.stdout.write('a'.repeat(64) + 'TAIL');"], | ||
| 106 | outputByteLimit: 16, | ||
| 107 | }); | ||
| 108 | yield* terminals.waitForExit({ sessionId: "session", terminalId: created.terminalId }); | ||
| 109 | |||
| 110 | const output = yield* terminals.output({ | ||
| 111 | sessionId: "session", | ||
| 112 | terminalId: created.terminalId, | ||
| 113 | }); | ||
| 114 | expect(output.truncated).toBe(true); | ||
| 115 | expect(output.output.endsWith("TAIL")).toBe(true); | ||
| 116 | expect(output.output.length).toBeLessThanOrEqual(16); | ||
| 117 | }), | ||
| 118 | ), | ||
| 119 | ); | ||
| 120 | |||
| 121 | it.effect("kills long-running commands and rejects released terminal handles", () => | ||
| 122 | withTerminals((terminals) => | ||
| 123 | Effect.gen(function* () { | ||
| 124 | const created = yield* terminals.create({ | ||
| 125 | sessionId: "session", | ||
| 126 | command: process.execPath, | ||
| 127 | args: ["-e", "setInterval(() => {}, 1000);"], | ||
| 128 | }); | ||
| 129 | yield* terminals.kill({ sessionId: "session", terminalId: created.terminalId }); | ||
| 130 | const exit = yield* terminals.waitForExit({ | ||
| 131 | sessionId: "session", | ||
| 132 | terminalId: created.terminalId, | ||
| 133 | }); | ||
| 134 | expect(exit.exitCode === 0 ? null : exit.exitCode).not.toBe(0); | ||
| 135 | expect(exit.signal).toBe("SIGTERM"); | ||
| 136 | |||
| 137 | yield* terminals.release({ sessionId: "session", terminalId: created.terminalId }); | ||
| 138 | const rejected = yield* Effect.flip( | ||
| 139 | terminals.output({ sessionId: "session", terminalId: created.terminalId }), | ||
| 140 | ); | ||
| 141 | expect(rejected.message).toContain("unknown terminal ID"); | ||
| 142 | |||
| 143 | // Embedded tool-call content still renders released terminals. | ||
| 144 | expect(terminals.readOutputSnapshot("session", created.terminalId)).toBeDefined(); | ||
| 145 | }), | ||
| 146 | ), | ||
| 147 | ); | ||
| 148 | |||
| 149 | it.effect("reserves terminal capacity across concurrent creates", () => | ||
| 150 | withTerminals((terminals) => | ||
| 151 | Effect.gen(function* () { | ||
| 152 | const results = yield* Effect.all( | ||
| 153 | Array.from({ length: 17 }, () => | ||
| 154 | terminals | ||
| 155 | .create({ | ||
| 156 | sessionId: "session", | ||
| 157 | command: process.execPath, | ||
| 158 | args: ["-e", "setInterval(() => {}, 1000);"], | ||
| 159 | }) | ||
| 160 | .pipe(Effect.result), | ||
| 161 | ), | ||
| 162 | { concurrency: "unbounded" }, | ||
| 163 | ); | ||
| 164 | |||
| 165 | expect(results.filter(Result.isSuccess)).toHaveLength(16); | ||
| 166 | expect(results.filter(Result.isFailure)).toHaveLength(1); | ||
| 167 | }), | ||
| 168 | ), | ||
| 169 | ); | ||
| 170 | |||
| 171 | it.effect("bounds retained snapshots after terminals are released", () => | ||
| 172 | withTerminals((terminals) => | ||
| 173 | Effect.gen(function* () { | ||
| 174 | const terminalIds: string[] = []; | ||
| 175 | for (let index = 0; index < 33; index += 1) { | ||
| 176 | const created = yield* terminals.create({ | ||
| 177 | sessionId: "session", | ||
| 178 | command: process.execPath, | ||
| 179 | args: ["-e", `process.stdout.write('${index}');`], | ||
| 180 | }); | ||
| 181 | yield* terminals.release({ sessionId: "session", terminalId: created.terminalId }); | ||
| 182 | terminalIds.push(created.terminalId); | ||
| 183 | } | ||
| 184 | |||
| 185 | expect(terminals.readOutputSnapshot("session", terminalIds[0]!)).toBeUndefined(); | ||
| 186 | expect(terminals.readOutputSnapshot("session", terminalIds.at(-1)!)).toBeDefined(); | ||
| 187 | }), | ||
| 188 | ), | ||
| 189 | ); | ||
| 190 | |||
| 191 | it.effect("bounds unreleased terminal handles without invalidating existing ids", () => | ||
| 192 | withTerminals((terminals) => | ||
| 193 | Effect.gen(function* () { | ||
| 194 | const terminalIds: string[] = []; | ||
| 195 | for (let index = 0; index < 64; index += 1) { | ||
| 196 | const created = yield* terminals.create({ | ||
| 197 | sessionId: "session", | ||
| 198 | command: process.execPath, | ||
| 199 | args: ["-e", `process.stdout.write('${index}');`], | ||
| 200 | }); | ||
| 201 | yield* terminals.waitForExit({ | ||
| 202 | sessionId: "session", | ||
| 203 | terminalId: created.terminalId, | ||
| 204 | }); | ||
| 205 | terminalIds.push(created.terminalId); | ||
| 206 | } | ||
| 207 | |||
| 208 | const rejected = yield* terminals | ||
| 209 | .create({ | ||
| 210 | sessionId: "session", | ||
| 211 | command: process.execPath, | ||
| 212 | args: ["-e", ""], | ||
| 213 | }) | ||
| 214 | .pipe(Effect.result); | ||
| 215 | expect(Result.isFailure(rejected)).toBe(true); | ||
| 216 | expect(terminals.readOutputSnapshot("session", terminalIds[0]!)).toBeDefined(); | ||
| 217 | expect(terminals.readOutputSnapshot("session", terminalIds.at(-1)!)).toBeDefined(); | ||
| 218 | }), | ||
| 219 | ), | ||
| 220 | ); | ||
| 221 | |||
| 222 | it.effect("bounds aggregate output retained across unreleased terminals", () => | ||
| 223 | withTerminals((terminals) => | ||
| 224 | Effect.gen(function* () { | ||
| 225 | const terminalIds: string[] = []; | ||
| 226 | for (let index = 0; index < 3; index += 1) { | ||
| 227 | const created = yield* terminals.create({ | ||
| 228 | sessionId: "session", | ||
| 229 | command: process.execPath, | ||
| 230 | args: ["-e", "process.stdout.write('x'.repeat(8 * 1024 * 1024));"], | ||
| 231 | outputByteLimit: 8 * 1024 * 1024, | ||
| 232 | }); | ||
| 233 | yield* terminals.waitForExit({ | ||
| 234 | sessionId: "session", | ||
| 235 | terminalId: created.terminalId, | ||
| 236 | }); | ||
| 237 | terminalIds.push(created.terminalId); | ||
| 238 | } | ||
| 239 | |||
| 240 | const snapshots = terminalIds.map((terminalId) => | ||
| 241 | terminals.readOutputSnapshot("session", terminalId)!, | ||
| 242 | ); | ||
| 243 | const retainedBytes = snapshots.reduce( | ||
| 244 | (total, snapshot) => total + Buffer.byteLength(snapshot.output), | ||
| 245 | 0, | ||
| 246 | ); | ||
| 247 | expect(retainedBytes).toBeLessThanOrEqual(16 * 1024 * 1024); | ||
| 248 | expect(snapshots.some((snapshot) => snapshot.truncated)).toBe(true); | ||
| 249 | }), | ||
| 250 | ), | ||
| 251 | ); | ||
| 252 | |||
| 253 | it.effect("rejects terminal handles from a different ACP session", () => | ||
| 254 | withTerminals((terminals) => | ||
| 255 | Effect.gen(function* () { | ||
| 256 | const created = yield* terminals.create({ | ||
| 257 | sessionId: "owner-session", | ||
| 258 | command: process.execPath, | ||
| 259 | args: ["-e", "process.stdout.write('owned');"], | ||
| 260 | }); | ||
| 261 | const foreignRequest = { | ||
| 262 | sessionId: "foreign-session", | ||
| 263 | terminalId: created.terminalId, | ||
| 264 | }; | ||
| 265 | const results = yield* Effect.all( | ||
| 266 | [ | ||
| 267 | terminals.output(foreignRequest), | ||
| 268 | terminals.waitForExit(foreignRequest), | ||
| 269 | terminals.kill(foreignRequest), | ||
| 270 | terminals.release(foreignRequest), | ||
| 271 | ].map((effect) => effect.pipe(Effect.result)), | ||
| 272 | { concurrency: "unbounded" }, | ||
| 273 | ); | ||
| 274 | |||
| 275 | expect(results.every(Result.isFailure)).toBe(true); | ||
| 276 | expect(terminals.readOutputSnapshot("foreign-session", created.terminalId)).toBeUndefined(); | ||
| 277 | expect(terminals.readOutputSnapshot("owner-session", created.terminalId)).toBeDefined(); | ||
| 278 | }), | ||
| 279 | ), | ||
| 280 | ); | ||
| 281 | }); | ||
| 282 | |||
| 283 | describe("resolveEmbeddedTerminalContent", () => { | ||
| 284 | it("rewrites terminal content into text from the buffered snapshot", () => { | ||
| 285 | const notification = { | ||
| 286 | sessionId: "session", | ||
| 287 | update: { | ||
| 288 | sessionUpdate: "tool_call_update" as const, | ||
| 289 | toolCallId: "call-1", | ||
| 290 | content: [ | ||
| 291 | { type: "content" as const, content: { type: "text" as const, text: "before" } }, | ||
| 292 | { type: "terminal" as const, terminalId: "t3-term-1" }, | ||
| 293 | { type: "terminal" as const, terminalId: "t3-term-unknown" }, | ||
| 294 | ], | ||
| 295 | }, | ||
| 296 | }; | ||
| 297 | |||
| 298 | const resolved = resolveEmbeddedTerminalContent(notification, (sessionId, terminalId) => | ||
| 299 | sessionId === "session" && terminalId === "t3-term-1" | ||
| 300 | ? { output: "compiled 3 files", truncated: false, exitStatus: undefined } | ||
| 301 | : undefined, | ||
| 302 | ); | ||
| 303 | |||
| 304 | expect(resolved.update).toMatchObject({ | ||
| 305 | content: [ | ||
| 306 | { type: "content", content: { type: "text", text: "before" } }, | ||
| 307 | { type: "content", content: { type: "text", text: "compiled 3 files" } }, | ||
| 308 | { type: "content", content: { type: "text", text: "[terminal t3-term-unknown]" } }, | ||
| 309 | ], | ||
| 310 | }); | ||
| 311 | }); | ||
| 312 | }); | ||
apps/server/src/provider/acp/AcpClientTerminals.ts deleted-459| ... | @@ -1,459 +0,0 @@ | ||
| 1 | import * as Deferred from "effect/Deferred"; | ||
| 2 | import * as Duration from "effect/Duration"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Exit from "effect/Exit"; | ||
| 5 | import * as Fiber from "effect/Fiber"; | ||
| 6 | import * as Scope from "effect/Scope"; | ||
| 7 | import * as Semaphore from "effect/Semaphore"; | ||
| 8 | import * as Stream from "effect/Stream"; | ||
| 9 | import { ChildProcess, ChildProcessSpawner } from "effect/process"; | ||
| 10 | import * as EffectAcpErrors from "effect-acp/errors"; | ||
| 11 | import type * as EffectAcpSchema from "effect-acp/compat"; | ||
| 12 | import * as NodeBuffer from "node:buffer"; | ||
| 13 | |||
| 14 | /** | ||
| 15 | * Client-side implementation of the ACP `terminal` capability. | ||
| 16 | * | ||
| 17 | * Agents create terminals to run commands through the client | ||
| 18 | * (`terminal/create`), poll buffered output, await exit, kill, and release | ||
| 19 | * them. Terminals also appear as embedded tool-call content, which | ||
| 20 | * {@link resolveEmbeddedTerminalContent} rewrites into plain text so the | ||
| 21 | * existing tool-call projection renders live command output. | ||
| 22 | */ | ||
| 23 | |||
| 24 | const MAX_LIVE_TERMINALS = 16; | ||
| 25 | const MAX_UNRELEASED_TERMINALS = 64; | ||
| 26 | const MAX_RETAINED_TERMINALS = 32; | ||
| 27 | const MAX_RETAINED_OUTPUT_BYTES = 16 * 1024 * 1024; | ||
| 28 | const DEFAULT_OUTPUT_BYTE_LIMIT = 4 * 1024 * 1024; | ||
| 29 | const MAX_OUTPUT_BYTE_LIMIT = 8 * 1024 * 1024; | ||
| 30 | |||
| 31 | const terminalRequestError = (message: string, cause?: unknown) => | ||
| 32 | EffectAcpErrors.AcpRequestError.internalError( | ||
| 33 | message, | ||
| 34 | undefined, | ||
| 35 | cause === undefined ? {} : { cause }, | ||
| 36 | ); | ||
| 37 | |||
| 38 | interface OutputBufferState { | ||
| 39 | chunks: Array<Uint8Array>; | ||
| 40 | bytes: number; | ||
| 41 | truncated: boolean; | ||
| 42 | readonly limit: number; | ||
| 43 | } | ||
| 44 | |||
| 45 | function trimOutputStart(state: OutputBufferState, bytes: number): void { | ||
| 46 | let remaining = Math.min(bytes, state.bytes); | ||
| 47 | while (remaining > 0) { | ||
| 48 | const head = state.chunks[0]!; | ||
| 49 | if (head.byteLength <= remaining) { | ||
| 50 | state.chunks.shift(); | ||
| 51 | state.bytes -= head.byteLength; | ||
| 52 | remaining -= head.byteLength; | ||
| 53 | } else { | ||
| 54 | state.chunks[0] = head.subarray(remaining); | ||
| 55 | state.bytes -= remaining; | ||
| 56 | remaining = 0; | ||
| 57 | } | ||
| 58 | state.truncated = true; | ||
| 59 | } | ||
| 60 | } | ||
| 61 | |||
| 62 | function appendOutput(state: OutputBufferState, chunk: Uint8Array): void { | ||
| 63 | state.chunks.push(chunk); | ||
| 64 | state.bytes += chunk.byteLength; | ||
| 65 | // The spec requires truncation from the beginning of the output. | ||
| 66 | if (state.bytes > state.limit) trimOutputStart(state, state.bytes - state.limit); | ||
| 67 | } | ||
| 68 | |||
| 69 | function readOutput(state: OutputBufferState): string { | ||
| 70 | let combined: Uint8Array = NodeBuffer.Buffer.concat(state.chunks, state.bytes); | ||
| 71 | if (state.truncated) { | ||
| 72 | // Drop leading UTF-8 continuation bytes so truncation lands on a | ||
| 73 | // character boundary, as the spec requires. | ||
| 74 | let start = 0; | ||
| 75 | while (start < combined.byteLength && (combined[start]! & 0b1100_0000) === 0b1000_0000) { | ||
| 76 | start += 1; | ||
| 77 | } | ||
| 78 | combined = combined.subarray(start); | ||
| 79 | } | ||
| 80 | return NodeBuffer.Buffer.from(combined).toString("utf8"); | ||
| 81 | } | ||
| 82 | |||
| 83 | function terminalExitSignal(error: unknown): string | null { | ||
| 84 | const cause = error instanceof Error ? error.cause : undefined; | ||
| 85 | const message = | ||
| 86 | cause instanceof Error ? cause.message : error instanceof Error ? error.message : ""; | ||
| 87 | return message.match(/signal: '([^']+)'/u)?.[1] ?? null; | ||
| 88 | } | ||
| 89 | |||
| 90 | export interface AcpTerminalOutputSnapshot { | ||
| 91 | readonly output: string; | ||
| 92 | readonly truncated: boolean; | ||
| 93 | readonly exitStatus: EffectAcpSchema.TerminalExitStatus | undefined; | ||
| 94 | } | ||
| 95 | |||
| 96 | interface ManagedAcpTerminal { | ||
| 97 | readonly terminalId: string; | ||
| 98 | readonly sessionId: string; | ||
| 99 | /** The spawned command with args, kept for MCP-fallback identification. */ | ||
| 100 | readonly commandLine: string; | ||
| 101 | readonly buffer: OutputBufferState; | ||
| 102 | readonly exit: Deferred.Deferred<EffectAcpSchema.WaitForTerminalExitResponse>; | ||
| 103 | readonly kill: Effect.Effect<void>; | ||
| 104 | /** Closes the terminal's scope, killing the process if still running. */ | ||
| 105 | readonly dispose: Effect.Effect<void>; | ||
| 106 | exitStatus: EffectAcpSchema.TerminalExitStatus | undefined; | ||
| 107 | released: boolean; | ||
| 108 | } | ||
| 109 | |||
| 110 | export interface AcpClientTerminals { | ||
| 111 | readonly create: ( | ||
| 112 | request: EffectAcpSchema.CreateTerminalRequest, | ||
| 113 | ) => Effect.Effect<EffectAcpSchema.CreateTerminalResponse, EffectAcpErrors.AcpRequestError>; | ||
| 114 | readonly output: ( | ||
| 115 | request: EffectAcpSchema.TerminalOutputRequest, | ||
| 116 | ) => Effect.Effect<EffectAcpSchema.TerminalOutputResponse, EffectAcpErrors.AcpRequestError>; | ||
| 117 | readonly waitForExit: ( | ||
| 118 | request: EffectAcpSchema.WaitForTerminalExitRequest, | ||
| 119 | ) => Effect.Effect<EffectAcpSchema.WaitForTerminalExitResponse, EffectAcpErrors.AcpRequestError>; | ||
| 120 | readonly kill: ( | ||
| 121 | request: EffectAcpSchema.KillTerminalRequest, | ||
| 122 | ) => Effect.Effect<EffectAcpSchema.KillTerminalResponse, EffectAcpErrors.AcpRequestError>; | ||
| 123 | readonly release: ( | ||
| 124 | request: EffectAcpSchema.ReleaseTerminalRequest, | ||
| 125 | ) => Effect.Effect<EffectAcpSchema.ReleaseTerminalResponse, EffectAcpErrors.AcpRequestError>; | ||
| 126 | /** Buffered output for embedded tool-call content; survives release. */ | ||
| 127 | readonly readOutputSnapshot: ( | ||
| 128 | sessionId: string, | ||
| 129 | terminalId: string, | ||
| 130 | ) => AcpTerminalOutputSnapshot | undefined; | ||
| 131 | /** Command line a terminal was created with; presentation-only lookup. */ | ||
| 132 | readonly readCommandLine: (terminalId: string) => string | undefined; | ||
| 133 | /** Kills every terminal that is still running. Safe to call repeatedly. */ | ||
| 134 | readonly disposeAll: Effect.Effect<void>; | ||
| 135 | } | ||
| 136 | |||
| 137 | export interface AcpClientTerminalsOptions { | ||
| 138 | /** Devin sends shell source in command, rather than an executable plus args. */ | ||
| 139 | readonly shellCommands?: boolean | undefined; | ||
| 140 | readonly spawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; | ||
| 141 | readonly defaultCwd: string; | ||
| 142 | readonly environment?: NodeJS.ProcessEnv | undefined; | ||
| 143 | readonly environmentForSession?: | ||
| 144 | | ((sessionId: string) => NodeJS.ProcessEnv | undefined) | ||
| 145 | | undefined; | ||
| 146 | readonly forceKillAfter?: Duration.Input | undefined; | ||
| 147 | } | ||
| 148 | |||
| 149 | function acpTerminalCommand(input: { | ||
| 150 | readonly request: EffectAcpSchema.CreateTerminalRequest; | ||
| 151 | readonly shellCommands?: boolean | undefined; | ||
| 152 | readonly defaultCwd: string; | ||
| 153 | readonly environment?: NodeJS.ProcessEnv | undefined; | ||
| 154 | readonly forceKillAfter?: Duration.Input | undefined; | ||
| 155 | }) { | ||
| 156 | return ChildProcess.make(input.request.command, input.request.args ?? [], { | ||
| 157 | cwd: input.request.cwd ?? input.defaultCwd, | ||
| 158 | ...(input.environment === undefined ? {} : { env: input.environment }), | ||
| 159 | shell: input.shellCommands === true && (input.request.args?.length ?? 0) === 0, | ||
| 160 | forceKillAfter: input.forceKillAfter ?? "5 seconds", | ||
| 161 | }); | ||
| 162 | } | ||
| 163 | |||
| 164 | export const makeAcpClientTerminals = ( | ||
| 165 | options: AcpClientTerminalsOptions, | ||
| 166 | ): Effect.Effect<AcpClientTerminals> => | ||
| 167 | Effect.gen(function* () { | ||
| 168 | const creationPermit = yield* Semaphore.make(1); | ||
| 169 | const terminals = new Map<string, ManagedAcpTerminal>(); | ||
| 170 | let nextTerminalNumber = 1; | ||
| 171 | |||
| 172 | const enforceTotalOutputLimit = (): void => { | ||
| 173 | let excess = | ||
| 174 | Array.from(terminals.values()).reduce( | ||
| 175 | (total, terminal) => total + terminal.buffer.bytes, | ||
| 176 | 0, | ||
| 177 | ) - MAX_RETAINED_OUTPUT_BYTES; | ||
| 178 | if (excess <= 0) return; | ||
| 179 | for (const terminal of terminals.values()) { | ||
| 180 | const before = terminal.buffer.bytes; | ||
| 181 | trimOutputStart(terminal.buffer, excess); | ||
| 182 | excess -= before - terminal.buffer.bytes; | ||
| 183 | if (excess <= 0) return; | ||
| 184 | } | ||
| 185 | }; | ||
| 186 | |||
| 187 | const pruneReleasedSnapshots = (): void => { | ||
| 188 | const released = Array.from(terminals.values()).filter((terminal) => terminal.released); | ||
| 189 | let retainedBytes = released.reduce((total, terminal) => total + terminal.buffer.bytes, 0); | ||
| 190 | while ( | ||
| 191 | released.length > MAX_RETAINED_TERMINALS || | ||
| 192 | retainedBytes > MAX_RETAINED_OUTPUT_BYTES | ||
| 193 | ) { | ||
| 194 | const terminal = released.shift(); | ||
| 195 | if (terminal === undefined) break; | ||
| 196 | retainedBytes -= terminal.buffer.bytes; | ||
| 197 | terminals.delete(terminal.terminalId); | ||
| 198 | } | ||
| 199 | }; | ||
| 200 | |||
| 201 | const requireTerminal = ( | ||
| 202 | sessionId: string, | ||
| 203 | terminalId: string, | ||
| 204 | operation: string, | ||
| 205 | ): Effect.Effect<ManagedAcpTerminal, EffectAcpErrors.AcpRequestError> => { | ||
| 206 | const terminal = terminals.get(terminalId); | ||
| 207 | if (terminal === undefined || terminal.sessionId !== sessionId || terminal.released) { | ||
| 208 | return Effect.fail( | ||
| 209 | terminalRequestError(`ACP ${operation} received an unknown terminal ID '${terminalId}'.`), | ||
| 210 | ); | ||
| 211 | } | ||
| 212 | return Effect.succeed(terminal); | ||
| 213 | }; | ||
| 214 | |||
| 215 | const create: AcpClientTerminals["create"] = (request) => | ||
| 216 | creationPermit.withPermit( | ||
| 217 | Effect.uninterruptibleMask((restore) => | ||
| 218 | Effect.gen(function* () { | ||
| 219 | const liveCount = Array.from(terminals.values()).filter( | ||
| 220 | (terminal) => !terminal.released && terminal.exitStatus === undefined, | ||
| 221 | ).length; | ||
| 222 | const unreleasedCount = Array.from(terminals.values()).filter( | ||
| 223 | (terminal) => !terminal.released, | ||
| 224 | ).length; | ||
| 225 | if (unreleasedCount >= MAX_UNRELEASED_TERMINALS) { | ||
| 226 | return yield* terminalRequestError( | ||
| 227 | `ACP terminal/create exceeded the limit of ${MAX_UNRELEASED_TERMINALS} unreleased terminals.`, | ||
| 228 | ); | ||
| 229 | } | ||
| 230 | if (liveCount >= MAX_LIVE_TERMINALS) { | ||
| 231 | return yield* terminalRequestError( | ||
| 232 | `ACP terminal/create exceeded the limit of ${MAX_LIVE_TERMINALS} concurrent terminals.`, | ||
| 233 | ); | ||
| 234 | } | ||
| 235 | const requestEnvironment = Object.fromEntries( | ||
| 236 | (request.env ?? []).map((variable) => [variable.name, variable.value] as const), | ||
| 237 | ); | ||
| 238 | const sessionEnvironment = options.environmentForSession?.(request.sessionId); | ||
| 239 | const environment = | ||
| 240 | options.environment === undefined && | ||
| 241 | sessionEnvironment === undefined && | ||
| 242 | (request.env?.length ?? 0) === 0 | ||
| 243 | ? undefined | ||
| 244 | : { ...options.environment, ...sessionEnvironment, ...requestEnvironment }; | ||
| 245 | // Each terminal owns a scope so the spawned process is reliably reaped: | ||
| 246 | // closing the scope kills a still-running command and frees the handle. | ||
| 247 | const terminalScope = yield* Scope.make(); | ||
| 248 | const child = yield* restore( | ||
| 249 | options.spawner | ||
| 250 | .spawn( | ||
| 251 | acpTerminalCommand({ | ||
| 252 | request, | ||
| 253 | shellCommands: options.shellCommands, | ||
| 254 | defaultCwd: options.defaultCwd, | ||
| 255 | environment, | ||
| 256 | forceKillAfter: options.forceKillAfter, | ||
| 257 | }), | ||
| 258 | ) | ||
| 259 | .pipe( | ||
| 260 | Effect.provideService(Scope.Scope, terminalScope), | ||
| 261 | Effect.mapError((cause) => | ||
| 262 | terminalRequestError( | ||
| 263 | `Could not start terminal command '${request.command}'.`, | ||
| 264 | cause, | ||
| 265 | ), | ||
| 266 | ), | ||
| 267 | ), | ||
| 268 | ).pipe( | ||
| 269 | Effect.onExit((exit) => | ||
| 270 | Exit.isFailure(exit) ? Scope.close(terminalScope, Exit.void) : Effect.void, | ||
| 271 | ), | ||
| 272 | ); | ||
| 273 | |||
| 274 | const terminalId = `t3-term-${nextTerminalNumber}`; | ||
| 275 | nextTerminalNumber += 1; | ||
| 276 | const record: ManagedAcpTerminal = { | ||
| 277 | terminalId, | ||
| 278 | sessionId: request.sessionId, | ||
| 279 | commandLine: [request.command, ...(request.args ?? [])].join(" "), | ||
| 280 | buffer: { | ||
| 281 | chunks: [], | ||
| 282 | bytes: 0, | ||
| 283 | truncated: false, | ||
| 284 | limit: Math.min( | ||
| 285 | request.outputByteLimit ?? DEFAULT_OUTPUT_BYTE_LIMIT, | ||
| 286 | MAX_OUTPUT_BYTE_LIMIT, | ||
| 287 | ), | ||
| 288 | }, | ||
| 289 | exit: yield* Deferred.make<EffectAcpSchema.WaitForTerminalExitResponse>(), | ||
| 290 | kill: child.kill().pipe(Effect.ignore), | ||
| 291 | dispose: Scope.close(terminalScope, Exit.void).pipe(Effect.ignore), | ||
| 292 | exitStatus: undefined, | ||
| 293 | released: false, | ||
| 294 | }; | ||
| 295 | terminals.set(terminalId, record); | ||
| 296 | |||
| 297 | const pump = (stream: typeof child.stdout) => | ||
| 298 | stream.pipe( | ||
| 299 | Stream.runForEach((chunk) => | ||
| 300 | Effect.sync(() => { | ||
| 301 | appendOutput(record.buffer, chunk); | ||
| 302 | enforceTotalOutputLimit(); | ||
| 303 | }), | ||
| 304 | ), | ||
| 305 | Effect.ignore, | ||
| 306 | ); | ||
| 307 | const stdoutPump = yield* Effect.forkDetach(pump(child.stdout)); | ||
| 308 | const stderrPump = yield* Effect.forkDetach(pump(child.stderr)); | ||
| 309 | yield* Effect.forkDetach( | ||
| 310 | child.exitCode.pipe( | ||
| 311 | Effect.match({ | ||
| 312 | onFailure: (error) => ({ exitCode: null, signal: terminalExitSignal(error) }), | ||
| 313 | onSuccess: (code) => ({ exitCode: Number(code), signal: null }), | ||
| 314 | }), | ||
| 315 | Effect.flatMap((status) => | ||
| 316 | Effect.all([Fiber.join(stdoutPump), Fiber.join(stderrPump)], { | ||
| 317 | discard: true, | ||
| 318 | }).pipe( | ||
| 319 | Effect.andThen( | ||
| 320 | Effect.sync(() => { | ||
| 321 | record.exitStatus = status; | ||
| 322 | }), | ||
| 323 | ), | ||
| 324 | Effect.andThen(Deferred.succeed(record.exit, status)), | ||
| 325 | ), | ||
| 326 | ), | ||
| 327 | ), | ||
| 328 | ); | ||
| 329 | return { terminalId }; | ||
| 330 | }), | ||
| 331 | ), | ||
| 332 | ); | ||
| 333 | |||
| 334 | const output: AcpClientTerminals["output"] = (request) => | ||
| 335 | requireTerminal(request.sessionId, request.terminalId, "terminal/output").pipe( | ||
| 336 | Effect.map((terminal) => ({ | ||
| 337 | output: readOutput(terminal.buffer), | ||
| 338 | truncated: terminal.buffer.truncated, | ||
| 339 | ...(terminal.exitStatus === undefined ? {} : { exitStatus: terminal.exitStatus }), | ||
| 340 | })), | ||
| 341 | ); | ||
| 342 | |||
| 343 | const waitForExit: AcpClientTerminals["waitForExit"] = (request) => | ||
| 344 | requireTerminal(request.sessionId, request.terminalId, "terminal/wait_for_exit").pipe( | ||
| 345 | Effect.flatMap((terminal) => Deferred.await(terminal.exit)), | ||
| 346 | ); | ||
| 347 | |||
| 348 | const kill: AcpClientTerminals["kill"] = (request) => | ||
| 349 | requireTerminal(request.sessionId, request.terminalId, "terminal/kill").pipe( | ||
| 350 | Effect.flatMap((terminal) => terminal.kill), | ||
| 351 | Effect.as({}), | ||
| 352 | ); | ||
| 353 | |||
| 354 | const release: AcpClientTerminals["release"] = (request) => | ||
| 355 | Effect.uninterruptible( | ||
| 356 | requireTerminal(request.sessionId, request.terminalId, "terminal/release").pipe( | ||
| 357 | Effect.flatMap((terminal) => | ||
| 358 | Effect.sync(() => { | ||
| 359 | terminal.released = true; | ||
| 360 | }).pipe( | ||
| 361 | Effect.andThen(terminal.dispose), | ||
| 362 | Effect.andThen(Deferred.await(terminal.exit)), | ||
| 363 | Effect.andThen(Effect.sync(pruneReleasedSnapshots)), | ||
| 364 | ), | ||
| 365 | ), | ||
| 366 | Effect.as({}), | ||
| 367 | ), | ||
| 368 | ); | ||
| 369 | |||
| 370 | const readOutputSnapshot: AcpClientTerminals["readOutputSnapshot"] = ( | ||
| 371 | sessionId, | ||
| 372 | terminalId, | ||
| 373 | ) => { | ||
| 374 | const terminal = terminals.get(terminalId); | ||
| 375 | if (terminal === undefined || terminal.sessionId !== sessionId) return undefined; | ||
| 376 | return { | ||
| 377 | output: readOutput(terminal.buffer), | ||
| 378 | truncated: terminal.buffer.truncated, | ||
| 379 | exitStatus: terminal.exitStatus, | ||
| 380 | }; | ||
| 381 | }; | ||
| 382 | |||
| 383 | const readCommandLine: AcpClientTerminals["readCommandLine"] = (terminalId) => | ||
| 384 | terminals.get(terminalId)?.commandLine; | ||
| 385 | |||
| 386 | const disposeAll: AcpClientTerminals["disposeAll"] = Effect.uninterruptible( | ||
| 387 | Effect.suspend(() => | ||
| 388 | Effect.forEach( | ||
| 389 | Array.from(terminals.values()), | ||
| 390 | (terminal) => | ||
| 391 | Effect.sync(() => { | ||
| 392 | terminal.released = true; | ||
| 393 | }).pipe( | ||
| 394 | Effect.andThen(terminal.dispose), | ||
| 395 | Effect.andThen(Deferred.await(terminal.exit)), | ||
| 396 | ), | ||
| 397 | { concurrency: "unbounded", discard: true }, | ||
| 398 | ).pipe(Effect.andThen(Effect.sync(pruneReleasedSnapshots))), | ||
| 399 | ), | ||
| 400 | ); | ||
| 401 | |||
| 402 | // Terminal failures surface to the agent as JSON-RPC errors and to the | ||
| 403 | // user only as a failed tool item, so log them for server-side diagnosis. | ||
| 404 | const logged = | ||
| 405 | <Request, Response>( | ||
| 406 | operation: string, | ||
| 407 | handler: (request: Request) => Effect.Effect<Response, EffectAcpErrors.AcpRequestError>, | ||
| 408 | ) => | ||
| 409 | (request: Request) => | ||
| 410 | handler(request).pipe( | ||
| 411 | Effect.tapError((error) => | ||
| 412 | Effect.logWarning("ACP client terminal operation failed", { | ||
| 413 | operation, | ||
| 414 | detail: error.message, | ||
| 415 | }), | ||
| 416 | ), | ||
| 417 | ); | ||
| 418 | |||
| 419 | return { | ||
| 420 | create: logged("terminal/create", create), | ||
| 421 | output: logged("terminal/output", output), | ||
| 422 | waitForExit: logged("terminal/wait_for_exit", waitForExit), | ||
| 423 | kill: logged("terminal/kill", kill), | ||
| 424 | release: logged("terminal/release", release), | ||
| 425 | readOutputSnapshot, | ||
| 426 | readCommandLine, | ||
| 427 | disposeAll, | ||
| 428 | }; | ||
| 429 | }); | ||
| 430 | |||
| 431 | /** | ||
| 432 | * Rewrites embedded `terminal` tool-call content into plain text content so | ||
| 433 | * the provider-neutral tool-call projection can display live command output. | ||
| 434 | */ | ||
| 435 | export function resolveEmbeddedTerminalContent( | ||
| 436 | notification: EffectAcpSchema.SessionNotification, | ||
| 437 | readOutputSnapshot: AcpClientTerminals["readOutputSnapshot"], | ||
| 438 | ): EffectAcpSchema.SessionNotification { | ||
| 439 | const update = notification.update; | ||
| 440 | if (update.sessionUpdate !== "tool_call" && update.sessionUpdate !== "tool_call_update") { | ||
| 441 | return notification; | ||
| 442 | } | ||
| 443 | const content = update.content; | ||
| 444 | if (!content || !content.some((entry) => entry.type === "terminal")) { | ||
| 445 | return notification; | ||
| 446 | } | ||
| 447 | const mapped = content.map((entry): EffectAcpSchema.ToolCallContent => { | ||
| 448 | if (entry.type !== "terminal") return entry; | ||
| 449 | const snapshot = readOutputSnapshot(notification.sessionId, entry.terminalId); | ||
| 450 | return { | ||
| 451 | type: "content", | ||
| 452 | content: { | ||
| 453 | type: "text", | ||
| 454 | text: snapshot === undefined ? `[terminal ${entry.terminalId}]` : snapshot.output, | ||
| 455 | }, | ||
| 456 | }; | ||
| 457 | }); | ||
| 458 | return { ...notification, update: { ...update, content: mapped } }; | ||
| 459 | } | ||
apps/server/src/provider/acp/AcpCoreRuntimeEvents.test.ts deleted-247| ... | @@ -1,247 +0,0 @@ | ||
| 1 | import { ProviderDriverKind, RuntimeRequestId, TurnId } from "@t3tools/contracts"; | ||
| 2 | import { describe, expect, it } from "vite-plus/test"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | makeAcpAssistantItemEvent, | ||
| 6 | makeAcpContentDeltaEvent, | ||
| 7 | makeAcpPlanUpdatedEvent, | ||
| 8 | makeAcpRequestOpenedEvent, | ||
| 9 | makeAcpRequestResolvedEvent, | ||
| 10 | makeAcpToolCallEvent, | ||
| 11 | } from "./AcpCoreRuntimeEvents.ts"; | ||
| 12 | |||
| 13 | describe("AcpCoreRuntimeEvents", () => { | ||
| 14 | it("maps ACP permission requests to canonical runtime events", () => { | ||
| 15 | const stamp = { eventId: "event-1" as never, createdAt: "2026-03-27T00:00:00.000Z" }; | ||
| 16 | const turnId = TurnId.make("turn-1"); | ||
| 17 | const permissionRequest = { | ||
| 18 | kind: "execute" as const, | ||
| 19 | detail: "cat package.json", | ||
| 20 | toolCall: { | ||
| 21 | toolCallId: "tool-1", | ||
| 22 | kind: "execute", | ||
| 23 | status: "pending" as const, | ||
| 24 | command: "cat package.json", | ||
| 25 | detail: "cat package.json", | ||
| 26 | data: { toolCallId: "tool-1", kind: "execute" }, | ||
| 27 | }, | ||
| 28 | }; | ||
| 29 | |||
| 30 | const openedEvent = makeAcpRequestOpenedEvent({ | ||
| 31 | stamp, | ||
| 32 | provider: ProviderDriverKind.make("cursor"), | ||
| 33 | threadId: "thread-1" as never, | ||
| 34 | turnId, | ||
| 35 | requestId: RuntimeRequestId.make("request-1"), | ||
| 36 | permissionRequest, | ||
| 37 | detail: "cat package.json", | ||
| 38 | args: { command: ["cat", "package.json"] }, | ||
| 39 | source: "acp.jsonrpc", | ||
| 40 | method: "session/request_permission", | ||
| 41 | rawPayload: { sessionId: "session-1" }, | ||
| 42 | }); | ||
| 43 | expect(openedEvent).toMatchObject({ | ||
| 44 | type: "request.opened", | ||
| 45 | payload: { | ||
| 46 | requestType: "exec_command_approval", | ||
| 47 | detail: "cat package.json", | ||
| 48 | }, | ||
| 49 | }); | ||
| 50 | expect(openedEvent).not.toHaveProperty("payload.options"); | ||
| 51 | |||
| 52 | expect( | ||
| 53 | makeAcpRequestResolvedEvent({ | ||
| 54 | stamp, | ||
| 55 | provider: ProviderDriverKind.make("cursor"), | ||
| 56 | threadId: "thread-1" as never, | ||
| 57 | turnId, | ||
| 58 | requestId: RuntimeRequestId.make("request-1"), | ||
| 59 | permissionRequest, | ||
| 60 | decision: "accept", | ||
| 61 | }), | ||
| 62 | ).toMatchObject({ | ||
| 63 | type: "request.resolved", | ||
| 64 | payload: { | ||
| 65 | requestType: "exec_command_approval", | ||
| 66 | decision: "accept", | ||
| 67 | }, | ||
| 68 | }); | ||
| 69 | }); | ||
| 70 | |||
| 71 | it("preserves a native file approval without a remembered-allow choice", () => { | ||
| 72 | const event = makeAcpRequestOpenedEvent({ | ||
| 73 | stamp: { eventId: "approval-1" as never, createdAt: "2026-09-02T00:00:00.000Z" }, | ||
| 74 | provider: ProviderDriverKind.make("antigravity"), | ||
| 75 | threadId: "thread-1" as never, | ||
| 76 | turnId: TurnId.make("turn-1"), | ||
| 77 | requestId: RuntimeRequestId.make("request-1"), | ||
| 78 | permissionRequest: { kind: "edit" }, | ||
| 79 | approvalOptions: [ | ||
| 80 | { decision: "accept", label: "Allow once" }, | ||
| 81 | { decision: "decline", label: "Reject" }, | ||
| 82 | ], | ||
| 83 | detail: "Edit package.json", | ||
| 84 | args: {}, | ||
| 85 | source: "acp.jsonrpc", | ||
| 86 | method: "session/request_permission", | ||
| 87 | rawPayload: { sessionId: "session-1" }, | ||
| 88 | }); | ||
| 89 | |||
| 90 | expect(event.payload).toEqual({ | ||
| 91 | requestType: "file_change_approval", | ||
| 92 | detail: "Edit package.json", | ||
| 93 | args: {}, | ||
| 94 | options: [ | ||
| 95 | { decision: "accept", label: "Allow once" }, | ||
| 96 | { decision: "decline", label: "Reject" }, | ||
| 97 | ], | ||
| 98 | }); | ||
| 99 | }); | ||
| 100 | |||
| 101 | it("maps generic ACP permission kinds to dynamic tool approvals", () => { | ||
| 102 | const stamp = { eventId: "event-1" as never, createdAt: "2026-03-27T00:00:00.000Z" }; | ||
| 103 | |||
| 104 | for (const kind of ["search", "fetch", "other", "unknown", "future-tool-kind"]) { | ||
| 105 | const permissionRequest = { kind }; | ||
| 106 | const request = { | ||
| 107 | stamp, | ||
| 108 | provider: ProviderDriverKind.make("cursor"), | ||
| 109 | threadId: "thread-1" as never, | ||
| 110 | turnId: TurnId.make("turn-1"), | ||
| 111 | requestId: RuntimeRequestId.make(`request-${kind}`), | ||
| 112 | permissionRequest, | ||
| 113 | }; | ||
| 114 | |||
| 115 | expect( | ||
| 116 | makeAcpRequestOpenedEvent({ | ||
| 117 | ...request, | ||
| 118 | detail: kind, | ||
| 119 | args: {}, | ||
| 120 | source: "acp.jsonrpc", | ||
| 121 | method: "session/request_permission", | ||
| 122 | rawPayload: { sessionId: "session-1" }, | ||
| 123 | }), | ||
| 124 | ).toMatchObject({ | ||
| 125 | type: "request.opened", | ||
| 126 | payload: { requestType: "dynamic_tool_call" }, | ||
| 127 | }); | ||
| 128 | |||
| 129 | expect( | ||
| 130 | makeAcpRequestResolvedEvent({ | ||
| 131 | ...request, | ||
| 132 | decision: "accept", | ||
| 133 | }), | ||
| 134 | ).toMatchObject({ | ||
| 135 | type: "request.resolved", | ||
| 136 | payload: { requestType: "dynamic_tool_call" }, | ||
| 137 | }); | ||
| 138 | } | ||
| 139 | }); | ||
| 140 | |||
| 141 | it("maps ACP core plan, tool-call, and content updates", () => { | ||
| 142 | const stamp = { eventId: "event-1" as never, createdAt: "2026-03-27T00:00:00.000Z" }; | ||
| 143 | const turnId = TurnId.make("turn-1"); | ||
| 144 | |||
| 145 | expect( | ||
| 146 | makeAcpPlanUpdatedEvent({ | ||
| 147 | stamp, | ||
| 148 | provider: ProviderDriverKind.make("cursor"), | ||
| 149 | threadId: "thread-1" as never, | ||
| 150 | turnId, | ||
| 151 | payload: { | ||
| 152 | nativePlanId: "plan-1", | ||
| 153 | kind: "items", | ||
| 154 | plan: [{ step: "Inspect state", status: "inProgress" }], | ||
| 155 | }, | ||
| 156 | source: "acp.cursor.extension", | ||
| 157 | method: "cursor/update_todos", | ||
| 158 | rawPayload: { todos: [] }, | ||
| 159 | }), | ||
| 160 | ).toMatchObject({ | ||
| 161 | type: "turn.plan.updated", | ||
| 162 | raw: { | ||
| 163 | method: "cursor/update_todos", | ||
| 164 | }, | ||
| 165 | }); | ||
| 166 | |||
| 167 | expect( | ||
| 168 | makeAcpToolCallEvent({ | ||
| 169 | stamp, | ||
| 170 | provider: ProviderDriverKind.make("cursor"), | ||
| 171 | threadId: "thread-1" as never, | ||
| 172 | turnId, | ||
| 173 | toolCall: { | ||
| 174 | toolCallId: "tool-1", | ||
| 175 | kind: "execute", | ||
| 176 | status: "completed", | ||
| 177 | title: "Terminal", | ||
| 178 | detail: "bun run test", | ||
| 179 | data: { command: "bun run test" }, | ||
| 180 | }, | ||
| 181 | rawPayload: { sessionId: "session-1" }, | ||
| 182 | }), | ||
| 183 | ).toMatchObject({ | ||
| 184 | type: "item.completed", | ||
| 185 | payload: { | ||
| 186 | itemType: "command_execution", | ||
| 187 | status: "completed", | ||
| 188 | }, | ||
| 189 | }); | ||
| 190 | |||
| 191 | expect( | ||
| 192 | makeAcpContentDeltaEvent({ | ||
| 193 | stamp, | ||
| 194 | provider: ProviderDriverKind.make("cursor"), | ||
| 195 | threadId: "thread-1" as never, | ||
| 196 | turnId, | ||
| 197 | itemId: "assistant:session-1:segment:0", | ||
| 198 | text: "hello", | ||
| 199 | rawPayload: { sessionId: "session-1" }, | ||
| 200 | }), | ||
| 201 | ).toMatchObject({ | ||
| 202 | type: "content.delta", | ||
| 203 | itemId: "assistant:session-1:segment:0", | ||
| 204 | payload: { | ||
| 205 | delta: "hello", | ||
| 206 | }, | ||
| 207 | }); | ||
| 208 | |||
| 209 | expect( | ||
| 210 | makeAcpAssistantItemEvent({ | ||
| 211 | stamp, | ||
| 212 | provider: ProviderDriverKind.make("cursor"), | ||
| 213 | threadId: "thread-1" as never, | ||
| 214 | turnId, | ||
| 215 | itemId: "assistant:session-1:segment:0", | ||
| 216 | lifecycle: "item.started", | ||
| 217 | }), | ||
| 218 | ).toMatchObject({ | ||
| 219 | type: "item.started", | ||
| 220 | itemId: "assistant:session-1:segment:0", | ||
| 221 | payload: { | ||
| 222 | itemType: "assistant_message", | ||
| 223 | status: "inProgress", | ||
| 224 | }, | ||
| 225 | }); | ||
| 226 | }); | ||
| 227 | |||
| 228 | it("maps thoughts to the reasoning stream", () => { | ||
| 229 | expect( | ||
| 230 | makeAcpContentDeltaEvent({ | ||
| 231 | stamp: { eventId: "thought-1" as never, createdAt: "2026-09-02T00:00:00.000Z" }, | ||
| 232 | provider: ProviderDriverKind.make("cursor"), | ||
| 233 | threadId: "thread-1" as never, | ||
| 234 | turnId: TurnId.make("turn-1"), | ||
| 235 | streamKind: "reasoning_text", | ||
| 236 | text: "Inspect the current implementation first.", | ||
| 237 | rawPayload: { sessionId: "session-1" }, | ||
| 238 | }), | ||
| 239 | ).toMatchObject({ | ||
| 240 | type: "content.delta", | ||
| 241 | payload: { | ||
| 242 | streamKind: "reasoning_text", | ||
| 243 | delta: "Inspect the current implementation first.", | ||
| 244 | }, | ||
| 245 | }); | ||
| 246 | }); | ||
| 247 | }); | ||
apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts deleted-255| ... | @@ -1,255 +0,0 @@ | ||
| 1 | import { | ||
| 2 | type RuntimeEventRawSource, | ||
| 3 | RuntimeItemId, | ||
| 4 | type CanonicalRequestType, | ||
| 5 | type EventId, | ||
| 6 | type ProviderApprovalDecision, | ||
| 7 | type ProviderApprovalOption, | ||
| 8 | type ProviderDriverKind, | ||
| 9 | type ProviderRuntimeEvent, | ||
| 10 | type RuntimeRequestId, | ||
| 11 | type ThreadId, | ||
| 12 | type TurnId, | ||
| 13 | } from "@t3tools/contracts"; | ||
| 14 | |||
| 15 | import { | ||
| 16 | type AcpPermissionRequest, | ||
| 17 | type AcpPlanUpdate, | ||
| 18 | type AcpToolCallState, | ||
| 19 | canonicalItemTypeFromAcpToolKind, | ||
| 20 | } from "./AcpRuntimeModel.ts"; | ||
| 21 | |||
| 22 | type AcpAdapterRawSource = Extract< | ||
| 23 | RuntimeEventRawSource, | ||
| 24 | "acp.jsonrpc" | `acp.${string}.extension` | ||
| 25 | >; | ||
| 26 | |||
| 27 | interface AcpEventStamp { | ||
| 28 | readonly eventId: EventId; | ||
| 29 | readonly createdAt: string; | ||
| 30 | } | ||
| 31 | |||
| 32 | type AcpCanonicalRequestType = Extract< | ||
| 33 | CanonicalRequestType, | ||
| 34 | "exec_command_approval" | "file_read_approval" | "file_change_approval" | "dynamic_tool_call" | ||
| 35 | >; | ||
| 36 | |||
| 37 | function canonicalRequestTypeFromAcpKind(kind: string | "unknown"): AcpCanonicalRequestType { | ||
| 38 | switch (kind) { | ||
| 39 | case "execute": | ||
| 40 | return "exec_command_approval"; | ||
| 41 | case "read": | ||
| 42 | return "file_read_approval"; | ||
| 43 | case "edit": | ||
| 44 | case "delete": | ||
| 45 | case "move": | ||
| 46 | return "file_change_approval"; | ||
| 47 | default: | ||
| 48 | return "dynamic_tool_call"; | ||
| 49 | } | ||
| 50 | } | ||
| 51 | |||
| 52 | function runtimeItemStatusFromAcpToolStatus( | ||
| 53 | status: AcpToolCallState["status"], | ||
| 54 | ): "inProgress" | "completed" | "failed" | undefined { | ||
| 55 | switch (status) { | ||
| 56 | case "pending": | ||
| 57 | case "inProgress": | ||
| 58 | return "inProgress"; | ||
| 59 | case "completed": | ||
| 60 | return "completed"; | ||
| 61 | case "failed": | ||
| 62 | return "failed"; | ||
| 63 | default: | ||
| 64 | return undefined; | ||
| 65 | } | ||
| 66 | } | ||
| 67 | |||
| 68 | export function makeAcpRequestOpenedEvent(input: { | ||
| 69 | readonly stamp: AcpEventStamp; | ||
| 70 | readonly provider: ProviderDriverKind; | ||
| 71 | readonly threadId: ThreadId; | ||
| 72 | readonly turnId: TurnId | undefined; | ||
| 73 | readonly requestId: RuntimeRequestId; | ||
| 74 | readonly permissionRequest: AcpPermissionRequest; | ||
| 75 | readonly approvalOptions?: ReadonlyArray<ProviderApprovalOption>; | ||
| 76 | readonly detail: string; | ||
| 77 | readonly args: unknown; | ||
| 78 | readonly source: AcpAdapterRawSource; | ||
| 79 | readonly method: string; | ||
| 80 | readonly rawPayload: unknown; | ||
| 81 | }): ProviderRuntimeEvent { | ||
| 82 | return { | ||
| 83 | type: "request.opened", | ||
| 84 | ...input.stamp, | ||
| 85 | provider: input.provider, | ||
| 86 | threadId: input.threadId, | ||
| 87 | turnId: input.turnId, | ||
| 88 | requestId: input.requestId, | ||
| 89 | payload: { | ||
| 90 | requestType: canonicalRequestTypeFromAcpKind(input.permissionRequest.kind), | ||
| 91 | detail: input.detail, | ||
| 92 | args: input.args, | ||
| 93 | ...(input.approvalOptions !== undefined ? { options: input.approvalOptions } : {}), | ||
| 94 | }, | ||
| 95 | raw: { | ||
| 96 | source: input.source, | ||
| 97 | method: input.method, | ||
| 98 | payload: input.rawPayload, | ||
| 99 | }, | ||
| 100 | }; | ||
| 101 | } | ||
| 102 | |||
| 103 | export function makeAcpRequestResolvedEvent(input: { | ||
| 104 | readonly stamp: AcpEventStamp; | ||
| 105 | readonly provider: ProviderDriverKind; | ||
| 106 | readonly threadId: ThreadId; | ||
| 107 | readonly turnId: TurnId | undefined; | ||
| 108 | readonly requestId: RuntimeRequestId; | ||
| 109 | readonly permissionRequest: AcpPermissionRequest; | ||
| 110 | readonly decision: ProviderApprovalDecision; | ||
| 111 | }): ProviderRuntimeEvent { | ||
| 112 | return { | ||
| 113 | type: "request.resolved", | ||
| 114 | ...input.stamp, | ||
| 115 | provider: input.provider, | ||
| 116 | threadId: input.threadId, | ||
| 117 | turnId: input.turnId, | ||
| 118 | requestId: input.requestId, | ||
| 119 | payload: { | ||
| 120 | requestType: canonicalRequestTypeFromAcpKind(input.permissionRequest.kind), | ||
| 121 | decision: input.decision, | ||
| 122 | }, | ||
| 123 | }; | ||
| 124 | } | ||
| 125 | |||
| 126 | export function makeAcpPlanUpdatedEvent(input: { | ||
| 127 | readonly stamp: AcpEventStamp; | ||
| 128 | readonly provider: ProviderDriverKind; | ||
| 129 | readonly threadId: ThreadId; | ||
| 130 | readonly turnId: TurnId | undefined; | ||
| 131 | readonly payload: AcpPlanUpdate; | ||
| 132 | readonly source: AcpAdapterRawSource; | ||
| 133 | readonly method: string; | ||
| 134 | readonly rawPayload: unknown; | ||
| 135 | }): ProviderRuntimeEvent { | ||
| 136 | const payload = | ||
| 137 | input.payload.kind === "items" | ||
| 138 | ? { | ||
| 139 | plan: input.payload.plan, | ||
| 140 | ...(input.payload.explanation == null ? {} : { explanation: input.payload.explanation }), | ||
| 141 | } | ||
| 142 | : input.payload.kind === "removed" | ||
| 143 | ? { plan: [] } | ||
| 144 | : { | ||
| 145 | plan: [ | ||
| 146 | { | ||
| 147 | step: | ||
| 148 | input.payload.kind === "markdown" | ||
| 149 | ? input.payload.markdown | ||
| 150 | : input.payload.kind === "file" | ||
| 151 | ? `Plan file: ${input.payload.uri}` | ||
| 152 | : `[Unsupported ACP plan content: ${input.payload.contentType}]`, | ||
| 153 | status: "pending" as const, | ||
| 154 | }, | ||
| 155 | ], | ||
| 156 | }; | ||
| 157 | return { | ||
| 158 | type: "turn.plan.updated", | ||
| 159 | ...input.stamp, | ||
| 160 | provider: input.provider, | ||
| 161 | threadId: input.threadId, | ||
| 162 | turnId: input.turnId, | ||
| 163 | payload, | ||
| 164 | raw: { | ||
| 165 | source: input.source, | ||
| 166 | method: input.method, | ||
| 167 | payload: input.rawPayload, | ||
| 168 | }, | ||
| 169 | }; | ||
| 170 | } | ||
| 171 | |||
| 172 | export function makeAcpToolCallEvent(input: { | ||
| 173 | readonly stamp: AcpEventStamp; | ||
| 174 | readonly provider: ProviderDriverKind; | ||
| 175 | readonly threadId: ThreadId; | ||
| 176 | readonly turnId: TurnId | undefined; | ||
| 177 | readonly toolCall: AcpToolCallState; | ||
| 178 | readonly rawPayload: unknown; | ||
| 179 | }): ProviderRuntimeEvent { | ||
| 180 | const runtimeStatus = runtimeItemStatusFromAcpToolStatus(input.toolCall.status); | ||
| 181 | return { | ||
| 182 | type: | ||
| 183 | input.toolCall.status === "completed" || input.toolCall.status === "failed" | ||
| 184 | ? "item.completed" | ||
| 185 | : "item.updated", | ||
| 186 | ...input.stamp, | ||
| 187 | provider: input.provider, | ||
| 188 | threadId: input.threadId, | ||
| 189 | turnId: input.turnId, | ||
| 190 | itemId: RuntimeItemId.make(input.toolCall.toolCallId), | ||
| 191 | payload: { | ||
| 192 | itemType: canonicalItemTypeFromAcpToolKind(input.toolCall.kind), | ||
| 193 | ...(runtimeStatus ? { status: runtimeStatus } : {}), | ||
| 194 | ...(input.toolCall.title ? { title: input.toolCall.title } : {}), | ||
| 195 | ...(input.toolCall.detail ? { detail: input.toolCall.detail } : {}), | ||
| 196 | ...(Object.keys(input.toolCall.data).length > 0 ? { data: input.toolCall.data } : {}), | ||
| 197 | }, | ||
| 198 | raw: { | ||
| 199 | source: "acp.jsonrpc", | ||
| 200 | method: "session/update", | ||
| 201 | payload: input.rawPayload, | ||
| 202 | }, | ||
| 203 | }; | ||
| 204 | } | ||
| 205 | |||
| 206 | export function makeAcpAssistantItemEvent(input: { | ||
| 207 | readonly stamp: AcpEventStamp; | ||
| 208 | readonly provider: ProviderDriverKind; | ||
| 209 | readonly threadId: ThreadId; | ||
| 210 | readonly turnId: TurnId | undefined; | ||
| 211 | readonly itemId: string; | ||
| 212 | readonly lifecycle: "item.started" | "item.completed"; | ||
| 213 | }): ProviderRuntimeEvent { | ||
| 214 | return { | ||
| 215 | type: input.lifecycle, | ||
| 216 | ...input.stamp, | ||
| 217 | provider: input.provider, | ||
| 218 | threadId: input.threadId, | ||
| 219 | turnId: input.turnId, | ||
| 220 | itemId: RuntimeItemId.make(input.itemId), | ||
| 221 | payload: { | ||
| 222 | itemType: "assistant_message", | ||
| 223 | status: input.lifecycle === "item.completed" ? "completed" : "inProgress", | ||
| 224 | }, | ||
| 225 | }; | ||
| 226 | } | ||
| 227 | |||
| 228 | export function makeAcpContentDeltaEvent(input: { | ||
| 229 | readonly stamp: AcpEventStamp; | ||
| 230 | readonly provider: ProviderDriverKind; | ||
| 231 | readonly threadId: ThreadId; | ||
| 232 | readonly turnId: TurnId | undefined; | ||
| 233 | readonly itemId?: string; | ||
| 234 | readonly streamKind?: "assistant_text" | "reasoning_text"; | ||
| 235 | readonly text: string; | ||
| 236 | readonly rawPayload: unknown; | ||
| 237 | }): ProviderRuntimeEvent { | ||
| 238 | return { | ||
| 239 | type: "content.delta", | ||
| 240 | ...input.stamp, | ||
| 241 | provider: input.provider, | ||
| 242 | threadId: input.threadId, | ||
| 243 | turnId: input.turnId, | ||
| 244 | ...(input.itemId ? { itemId: RuntimeItemId.make(input.itemId) } : {}), | ||
| 245 | payload: { | ||
| 246 | streamKind: input.streamKind ?? "assistant_text", | ||
| 247 | delta: input.text, | ||
| 248 | }, | ||
| 249 | raw: { | ||
| 250 | source: "acp.jsonrpc", | ||
| 251 | method: "session/update", | ||
| 252 | payload: input.rawPayload, | ||
| 253 | }, | ||
| 254 | }; | ||
| 255 | } | ||
apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts+1-1| ... | @@ -17,7 +17,7 @@ import * as TestClock from "effect/testing/TestClock"; | ... | @@ -17,7 +17,7 @@ import * as TestClock from "effect/testing/TestClock"; |
| 17 | import * as Stream from "effect/Stream"; | 17 | import * as Stream from "effect/Stream"; |
| 18 | import { describe, expect } from "vite-plus/test"; | 18 | import { describe, expect } from "vite-plus/test"; |
| 19 | 19 | ||
| 20 | import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; | 20 | import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; |
| 21 | import type * as EffectAcpProtocol from "effect-acp/protocol"; | 21 | import type * as EffectAcpProtocol from "effect-acp/protocol"; |
| 22 | import * as EffectAcpErrors from "effect-acp/errors"; | 22 | import * as EffectAcpErrors from "effect-acp/errors"; |
| 23 | 23 |
apps/server/src/provider/acp/AcpNativeLogging.test.ts deleted-306| ... | @@ -1,306 +0,0 @@ | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { ProviderDriverKind, ThreadId } from "@t3tools/contracts"; | ||
| 3 | import { assert, it } from "@effect/vitest"; | ||
| 4 | import * as Cause from "effect/Cause"; | ||
| 5 | import * as Effect from "effect/Effect"; | ||
| 6 | import * as Exit from "effect/Exit"; | ||
| 7 | import * as Logger from "effect/Logger"; | ||
| 8 | import * as Schema from "effect/Schema"; | ||
| 9 | import * as AcpErrors from "effect-acp/errors"; | ||
| 10 | |||
| 11 | import type { EventNdjsonLogger } from "../EventNdjsonLogger.ts"; | ||
| 12 | import { makeAcpNativeLoggerFactory } from "./AcpNativeLogging.ts"; | ||
| 13 | |||
| 14 | const nodeServicesIt = it.layer(NodeServices.layer); | ||
| 15 | const encodeUnknownJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); | ||
| 16 | |||
| 17 | nodeServicesIt("ACP native logging", (it) => { | ||
| 18 | it.effect("records bounded request and protocol diagnostics without raw payloads", () => | ||
| 19 | Effect.gen(function* () { | ||
| 20 | const records: Array<unknown> = []; | ||
| 21 | const nativeEventLogger: EventNdjsonLogger = { | ||
| 22 | filePath: "/tmp/provider-native.ndjson", | ||
| 23 | write: (event) => Effect.sync(() => void records.push(event)), | ||
| 24 | close: () => Effect.void, | ||
| 25 | }; | ||
| 26 | const makeLogger = yield* makeAcpNativeLoggerFactory(); | ||
| 27 | const logger = makeLogger({ | ||
| 28 | nativeEventLogger, | ||
| 29 | provider: ProviderDriverKind.make("cursor"), | ||
| 30 | threadId: ThreadId.make("thread-1"), | ||
| 31 | verboseProtocolLogging: true, | ||
| 32 | }); | ||
| 33 | const secret = "secret-token-value"; | ||
| 34 | const requestLogger = logger.requestLogger; | ||
| 35 | const protocolLogger = logger.protocolLogging?.logger; | ||
| 36 | assert.exists(requestLogger); | ||
| 37 | assert.exists(protocolLogger); | ||
| 38 | if (!requestLogger || !protocolLogger) return; | ||
| 39 | |||
| 40 | yield* requestLogger({ | ||
| 41 | method: "session/prompt", | ||
| 42 | payload: { prompt: secret, sessionId: secret }, | ||
| 43 | status: "failed", | ||
| 44 | cause: Cause.fail(AcpErrors.AcpRequestError.internalError(secret, { token: secret })), | ||
| 45 | }); | ||
| 46 | yield* protocolLogger({ | ||
| 47 | direction: "incoming", | ||
| 48 | stage: "raw", | ||
| 49 | payload: `{"token":"${secret}"}`, | ||
| 50 | }); | ||
| 51 | yield* protocolLogger({ | ||
| 52 | direction: "outgoing", | ||
| 53 | stage: "decoded", | ||
| 54 | payload: { | ||
| 55 | _tag: "Request", | ||
| 56 | tag: "session/prompt", | ||
| 57 | payload: { prompt: secret }, | ||
| 58 | }, | ||
| 59 | }); | ||
| 60 | |||
| 61 | const serialized = encodeUnknownJson(records); | ||
| 62 | assert.notInclude(serialized, secret); | ||
| 63 | assert.include(serialized, '"method":"session/prompt"'); | ||
| 64 | assert.include(serialized, '"errorTag":"AcpRequestError"'); | ||
| 65 | assert.include(serialized, '"reasonCount":1'); | ||
| 66 | assert.include(serialized, '"valueType":"string"'); | ||
| 67 | assert.include(serialized, '"messageTag":"Request"'); | ||
| 68 | }), | ||
| 69 | ); | ||
| 70 | |||
| 71 | it.effect("keeps request diagnostics without enabling full protocol logging", () => | ||
| 72 | Effect.gen(function* () { | ||
| 73 | const records: Array<unknown> = []; | ||
| 74 | const makeLogger = yield* makeAcpNativeLoggerFactory(); | ||
| 75 | const logger = makeLogger({ | ||
| 76 | nativeEventLogger: { | ||
| 77 | filePath: "/tmp/provider-native.ndjson", | ||
| 78 | write: (event) => Effect.sync(() => void records.push(event)), | ||
| 79 | close: () => Effect.void, | ||
| 80 | }, | ||
| 81 | provider: ProviderDriverKind.make("grok"), | ||
| 82 | threadId: ThreadId.make("thread-1"), | ||
| 83 | }); | ||
| 84 | |||
| 85 | assert.isUndefined(logger.protocolLogging); | ||
| 86 | const requestLogger = logger.requestLogger; | ||
| 87 | assert.exists(requestLogger); | ||
| 88 | if (!requestLogger) return; | ||
| 89 | yield* requestLogger({ | ||
| 90 | method: "session/prompt", | ||
| 91 | payload: {}, | ||
| 92 | status: "started", | ||
| 93 | }); | ||
| 94 | assert.lengthOf(records, 1); | ||
| 95 | }), | ||
| 96 | ); | ||
| 97 | |||
| 98 | it.effect("drops transient ACP chunks before formatting verbose protocol logs", () => | ||
| 99 | Effect.gen(function* () { | ||
| 100 | const records: Array<unknown> = []; | ||
| 101 | const makeLogger = yield* makeAcpNativeLoggerFactory(); | ||
| 102 | const logger = makeLogger({ | ||
| 103 | nativeEventLogger: { | ||
| 104 | filePath: "/tmp/provider-native.ndjson", | ||
| 105 | write: (event) => Effect.sync(() => void records.push(event)), | ||
| 106 | close: () => Effect.void, | ||
| 107 | }, | ||
| 108 | provider: ProviderDriverKind.make("cursor"), | ||
| 109 | threadId: ThreadId.make("thread-1"), | ||
| 110 | verboseProtocolLogging: true, | ||
| 111 | }); | ||
| 112 | const protocolLogger = logger.protocolLogging?.logger; | ||
| 113 | assert.exists(protocolLogger); | ||
| 114 | if (!protocolLogger) return; | ||
| 115 | |||
| 116 | for (const updateType of ["agent_message_chunk", "agent_thought_chunk"] as const) { | ||
| 117 | yield* protocolLogger({ | ||
| 118 | direction: "incoming", | ||
| 119 | stage: "raw", | ||
| 120 | payload: `${encodeUnknownJson({ | ||
| 121 | method: "session/update", | ||
| 122 | params: { update: { sessionUpdate: updateType } }, | ||
| 123 | })}\n`, | ||
| 124 | }); | ||
| 125 | yield* protocolLogger({ | ||
| 126 | direction: "incoming", | ||
| 127 | stage: "decoded", | ||
| 128 | payload: [ | ||
| 129 | { | ||
| 130 | _tag: "Request", | ||
| 131 | tag: "session/update", | ||
| 132 | payload: { update: { sessionUpdate: updateType } }, | ||
| 133 | }, | ||
| 134 | ], | ||
| 135 | }); | ||
| 136 | } | ||
| 137 | |||
| 138 | assert.lengthOf(records, 0); | ||
| 139 | |||
| 140 | yield* protocolLogger({ | ||
| 141 | direction: "incoming", | ||
| 142 | stage: "decoded", | ||
| 143 | payload: [ | ||
| 144 | { | ||
| 145 | _tag: "Request", | ||
| 146 | tag: "session/update", | ||
| 147 | payload: { update: { sessionUpdate: "tool_call" } }, | ||
| 148 | }, | ||
| 149 | ], | ||
| 150 | }); | ||
| 151 | assert.lengthOf(records, 1); | ||
| 152 | }), | ||
| 153 | ); | ||
| 154 | |||
| 155 | it.effect("keeps mixed and incomplete raw diagnostics", () => | ||
| 156 | Effect.gen(function* () { | ||
| 157 | const records: Array<unknown> = []; | ||
| 158 | const makeLogger = yield* makeAcpNativeLoggerFactory(); | ||
| 159 | const logger = makeLogger({ | ||
| 160 | nativeEventLogger: { | ||
| 161 | filePath: "/tmp/provider-native.ndjson", | ||
| 162 | write: (event) => Effect.sync(() => void records.push(event)), | ||
| 163 | close: () => Effect.void, | ||
| 164 | }, | ||
| 165 | provider: ProviderDriverKind.make("cursor"), | ||
| 166 | threadId: ThreadId.make("thread-1"), | ||
| 167 | verboseProtocolLogging: true, | ||
| 168 | }); | ||
| 169 | const protocolLogger = logger.protocolLogging?.logger; | ||
| 170 | assert.exists(protocolLogger); | ||
| 171 | if (!protocolLogger) return; | ||
| 172 | |||
| 173 | const transient = encodeUnknownJson({ | ||
| 174 | method: "session/update", | ||
| 175 | params: { update: { sessionUpdate: "agent_message_chunk" } }, | ||
| 176 | }); | ||
| 177 | const lifecycle = encodeUnknownJson({ method: "session/new", params: {} }); | ||
| 178 | |||
| 179 | yield* protocolLogger({ | ||
| 180 | direction: "incoming", | ||
| 181 | stage: "raw", | ||
| 182 | payload: `${transient}\n${lifecycle}\n`, | ||
| 183 | }); | ||
| 184 | yield* protocolLogger({ | ||
| 185 | direction: "incoming", | ||
| 186 | stage: "raw", | ||
| 187 | payload: transient, | ||
| 188 | }); | ||
| 189 | yield* protocolLogger({ | ||
| 190 | direction: "incoming", | ||
| 191 | stage: "raw", | ||
| 192 | payload: `${transient}\n{malformed}\n`, | ||
| 193 | }); | ||
| 194 | |||
| 195 | assert.lengthOf(records, 3); | ||
| 196 | }), | ||
| 197 | ); | ||
| 198 | |||
| 199 | it.effect("filters transient entries from mixed decoded batches", () => | ||
| 200 | Effect.gen(function* () { | ||
| 201 | const records: Array<unknown> = []; | ||
| 202 | const makeLogger = yield* makeAcpNativeLoggerFactory(); | ||
| 203 | const logger = makeLogger({ | ||
| 204 | nativeEventLogger: { | ||
| 205 | filePath: "/tmp/provider-native.ndjson", | ||
| 206 | write: (event) => Effect.sync(() => void records.push(event)), | ||
| 207 | close: () => Effect.void, | ||
| 208 | }, | ||
| 209 | provider: ProviderDriverKind.make("grok"), | ||
| 210 | threadId: ThreadId.make("thread-1"), | ||
| 211 | verboseProtocolLogging: true, | ||
| 212 | }); | ||
| 213 | const protocolLogger = logger.protocolLogging?.logger; | ||
| 214 | assert.exists(protocolLogger); | ||
| 215 | if (!protocolLogger) return; | ||
| 216 | |||
| 217 | yield* protocolLogger({ | ||
| 218 | direction: "incoming", | ||
| 219 | stage: "decoded", | ||
| 220 | payload: [ | ||
| 221 | { | ||
| 222 | _tag: "Request", | ||
| 223 | tag: "session/update", | ||
| 224 | payload: { update: { sessionUpdate: "agent_thought_chunk" } }, | ||
| 225 | }, | ||
| 226 | { | ||
| 227 | _tag: "Request", | ||
| 228 | tag: "session/new", | ||
| 229 | payload: {}, | ||
| 230 | }, | ||
| 231 | ], | ||
| 232 | }); | ||
| 233 | |||
| 234 | assert.lengthOf(records, 1); | ||
| 235 | assert.include(encodeUnknownJson(records), '"itemCount":1'); | ||
| 236 | }), | ||
| 237 | ); | ||
| 238 | |||
| 239 | it.effect("logs a structural tag when the native writer defects", () => { | ||
| 240 | const messages: Array<unknown> = []; | ||
| 241 | const logCapture = Logger.make<unknown, void>(({ message }) => { | ||
| 242 | if (Array.isArray(message)) { | ||
| 243 | messages.push(...message); | ||
| 244 | } else { | ||
| 245 | messages.push(message); | ||
| 246 | } | ||
| 247 | }); | ||
| 248 | const secret = "secret-writer-failure"; | ||
| 249 | |||
| 250 | return Effect.gen(function* () { | ||
| 251 | const makeLogger = yield* makeAcpNativeLoggerFactory(); | ||
| 252 | const logger = makeLogger({ | ||
| 253 | nativeEventLogger: { | ||
| 254 | filePath: "/tmp/provider-native.ndjson", | ||
| 255 | write: () => Effect.die(new Error(secret)), | ||
| 256 | close: () => Effect.void, | ||
| 257 | }, | ||
| 258 | provider: ProviderDriverKind.make("cursor"), | ||
| 259 | threadId: ThreadId.make("thread-1"), | ||
| 260 | }); | ||
| 261 | const requestLogger = logger.requestLogger; | ||
| 262 | assert.exists(requestLogger); | ||
| 263 | if (!requestLogger) return; | ||
| 264 | |||
| 265 | yield* requestLogger({ | ||
| 266 | method: "session/prompt", | ||
| 267 | payload: {}, | ||
| 268 | status: "started", | ||
| 269 | }); | ||
| 270 | |||
| 271 | const serialized = encodeUnknownJson(messages); | ||
| 272 | assert.notInclude(serialized, secret); | ||
| 273 | assert.include(serialized, '"errorTag":"Die"'); | ||
| 274 | assert.include(serialized, '"reasonCount":1'); | ||
| 275 | }).pipe(Effect.provide(Logger.layer([logCapture], { mergeWithExisting: false }))); | ||
| 276 | }); | ||
| 277 | |||
| 278 | it.effect("preserves native writer interruption", () => | ||
| 279 | Effect.gen(function* () { | ||
| 280 | const makeLogger = yield* makeAcpNativeLoggerFactory(); | ||
| 281 | const logger = makeLogger({ | ||
| 282 | nativeEventLogger: { | ||
| 283 | filePath: "/tmp/provider-native.ndjson", | ||
| 284 | write: () => Effect.interrupt, | ||
| 285 | close: () => Effect.void, | ||
| 286 | }, | ||
| 287 | provider: ProviderDriverKind.make("cursor"), | ||
| 288 | threadId: ThreadId.make("thread-1"), | ||
| 289 | }); | ||
| 290 | const requestLogger = logger.requestLogger; | ||
| 291 | assert.exists(requestLogger); | ||
| 292 | if (!requestLogger) return; | ||
| 293 | |||
| 294 | const exit = yield* requestLogger({ | ||
| 295 | method: "session/prompt", | ||
| 296 | payload: {}, | ||
| 297 | status: "started", | ||
| 298 | }).pipe(Effect.exit); | ||
| 299 | |||
| 300 | assert.isTrue(Exit.isFailure(exit)); | ||
| 301 | if (Exit.isFailure(exit)) { | ||
| 302 | assert.isTrue(Cause.hasInterruptsOnly(exit.cause)); | ||
| 303 | } | ||
| 304 | }), | ||
| 305 | ); | ||
| 306 | }); | ||
apps/server/src/provider/acp/AcpNativeLogging.ts deleted-188| ... | @@ -1,188 +0,0 @@ | ||
| 1 | import type { ProviderDriverKind, ThreadId } from "@t3tools/contracts"; | ||
| 2 | import { causeErrorTag, errorTag } from "@t3tools/shared/observability"; | ||
| 3 | import * as Cause from "effect/Cause"; | ||
| 4 | import * as Crypto from "effect/Crypto"; | ||
| 5 | import * as DateTime from "effect/DateTime"; | ||
| 6 | import * as Effect from "effect/Effect"; | ||
| 7 | import type * as EffectAcpProtocol from "effect-acp/protocol"; | ||
| 8 | |||
| 9 | import type { EventNdjsonLogger } from "../EventNdjsonLogger.ts"; | ||
| 10 | import { | ||
| 11 | structuralProtocolMethod, | ||
| 12 | summarizeNativeProtocolPayload, | ||
| 13 | } from "@t3tools/provider-core/server/nativeProtocolLogging"; | ||
| 14 | import type * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; | ||
| 15 | |||
| 16 | const transientProtocolUpdates = new Set(["agent_message_chunk", "agent_thought_chunk"]); | ||
| 17 | |||
| 18 | function structuralMethod(value: string): string { | ||
| 19 | return value.length <= 128 && /^[A-Za-z][A-Za-z0-9._:/-]*$/.test(value) ? value : "unknown"; | ||
| 20 | } | ||
| 21 | |||
| 22 | function summarizePayload(payload: unknown): Readonly<Record<string, unknown>> { | ||
| 23 | if (payload === null) return { valueType: "null" }; | ||
| 24 | if (typeof payload === "string") { | ||
| 25 | return { valueType: "string", byteLength: new TextEncoder().encode(payload).byteLength }; | ||
| 26 | } | ||
| 27 | if (payload instanceof Uint8Array) { | ||
| 28 | return { valueType: "bytes", byteLength: payload.byteLength }; | ||
| 29 | } | ||
| 30 | if (Array.isArray(payload)) { | ||
| 31 | return { valueType: "array", itemCount: payload.length }; | ||
| 32 | } | ||
| 33 | if (typeof payload !== "object") { | ||
| 34 | return { valueType: typeof payload }; | ||
| 35 | } | ||
| 36 | |||
| 37 | try { | ||
| 38 | const record = payload as Record<string, unknown>; | ||
| 39 | return { | ||
| 40 | valueType: "object", | ||
| 41 | fieldCount: Object.keys(record).length, | ||
| 42 | ...(typeof record._tag === "string" ? { messageTag: errorTag(record) } : {}), | ||
| 43 | ...(typeof record.tag === "string" ? { method: structuralMethod(record.tag) } : {}), | ||
| 44 | }; | ||
| 45 | } catch { | ||
| 46 | return { valueType: "object" }; | ||
| 47 | } | ||
| 48 | } | ||
| 49 | |||
| 50 | function formatRequestLogPayload(event: AcpSessionRuntime.AcpSessionRequestLogEvent) { | ||
| 51 | return { | ||
| 52 | method: structuralProtocolMethod(event.method), | ||
| 53 | status: event.status, | ||
| 54 | request: summarizeNativeProtocolPayload(event.payload), | ||
| 55 | ...(event.result !== undefined ? { result: summarizeNativeProtocolPayload(event.result) } : {}), | ||
| 56 | ...(event.cause !== undefined | ||
| 57 | ? { | ||
| 58 | errorTag: causeErrorTag(event.cause), | ||
| 59 | reasonCount: event.cause.reasons.length, | ||
| 60 | } | ||
| 61 | : {}), | ||
| 62 | }; | ||
| 63 | } | ||
| 64 | |||
| 65 | function formatAcpProtocolLogPayload(event: EffectAcpProtocol.AcpProtocolLogEvent) { | ||
| 66 | return { | ||
| 67 | direction: event.direction, | ||
| 68 | stage: event.stage, | ||
| 69 | payload: summarizeNativeProtocolPayload(event.payload), | ||
| 70 | }; | ||
| 71 | } | ||
| 72 | |||
| 73 | function isTransientProtocolMessage(message: unknown): boolean { | ||
| 74 | if (typeof message !== "object" || message === null) return false; | ||
| 75 | const method = Reflect.get(message, "tag") ?? Reflect.get(message, "method"); | ||
| 76 | if (method !== "session/update") return false; | ||
| 77 | |||
| 78 | const payload = Reflect.get(message, "payload") ?? Reflect.get(message, "params"); | ||
| 79 | if (typeof payload !== "object" || payload === null) return false; | ||
| 80 | const update = Reflect.get(payload, "update"); | ||
| 81 | if (typeof update !== "object" || update === null) return false; | ||
| 82 | const updateType = Reflect.get(update, "sessionUpdate"); | ||
| 83 | return typeof updateType === "string" && transientProtocolUpdates.has(updateType); | ||
| 84 | } | ||
| 85 | |||
| 86 | function rawChunkContainsOnlyTransientMessages(payload: string): boolean { | ||
| 87 | const lines = payload.split("\n"); | ||
| 88 | const remainder = lines.pop() ?? ""; | ||
| 89 | if (remainder.trim().length > 0) return false; | ||
| 90 | |||
| 91 | const messages: Array<unknown> = []; | ||
| 92 | for (const line of lines) { | ||
| 93 | if (line.trim().length === 0) continue; | ||
| 94 | try { | ||
| 95 | messages.push(JSON.parse(line)); | ||
| 96 | } catch { | ||
| 97 | return false; | ||
| 98 | } | ||
| 99 | } | ||
| 100 | return messages.length > 0 && messages.every(isTransientProtocolMessage); | ||
| 101 | } | ||
| 102 | |||
| 103 | function filterTransientProtocolLog( | ||
| 104 | event: EffectAcpProtocol.AcpProtocolLogEvent, | ||
| 105 | ): EffectAcpProtocol.AcpProtocolLogEvent | undefined { | ||
| 106 | if (event.direction !== "incoming") return event; | ||
| 107 | |||
| 108 | if (event.stage === "raw" && typeof event.payload === "string") { | ||
| 109 | return rawChunkContainsOnlyTransientMessages(event.payload) ? undefined : event; | ||
| 110 | } | ||
| 111 | |||
| 112 | if (event.stage !== "decoded") return event; | ||
| 113 | if (!Array.isArray(event.payload)) { | ||
| 114 | return isTransientProtocolMessage(event.payload) ? undefined : event; | ||
| 115 | } | ||
| 116 | |||
| 117 | const payload = event.payload.filter((message) => !isTransientProtocolMessage(message)); | ||
| 118 | return payload.length === 0 ? undefined : { ...event, payload }; | ||
| 119 | } | ||
| 120 | |||
| 121 | export const makeAcpNativeLoggerFactory = Effect.fn("makeAcpNativeLoggerFactory")(function* () { | ||
| 122 | const crypto = yield* Crypto.Crypto; | ||
| 123 | return (input: { | ||
| 124 | readonly nativeEventLogger: EventNdjsonLogger | undefined; | ||
| 125 | readonly provider: ProviderDriverKind; | ||
| 126 | readonly threadId: ThreadId; | ||
| 127 | readonly verboseProtocolLogging?: boolean; | ||
| 128 | }): Pick<AcpSessionRuntime.AcpSessionRuntimeOptions, "requestLogger" | "protocolLogging"> => { | ||
| 129 | const writeNativeAcpLog = (logInput: { | ||
| 130 | readonly kind: "request" | "protocol"; | ||
| 131 | readonly payload: unknown; | ||
| 132 | }) => | ||
| 133 | Effect.gen(function* () { | ||
| 134 | if (!input.nativeEventLogger) return; | ||
| 135 | const observedAt = DateTime.formatIso(yield* DateTime.now); | ||
| 136 | yield* input.nativeEventLogger.write( | ||
| 137 | { | ||
| 138 | observedAt, | ||
| 139 | event: { | ||
| 140 | id: yield* crypto.randomUUIDv4, | ||
| 141 | kind: logInput.kind, | ||
| 142 | provider: input.provider, | ||
| 143 | createdAt: observedAt, | ||
| 144 | threadId: input.threadId, | ||
| 145 | payload: logInput.payload, | ||
| 146 | }, | ||
| 147 | }, | ||
| 148 | input.threadId, | ||
| 149 | ); | ||
| 150 | }).pipe( | ||
| 151 | Effect.catchCause((cause) => | ||
| 152 | Cause.hasInterrupts(cause) | ||
| 153 | ? Effect.interrupt | ||
| 154 | : Effect.logWarning("Failed to write native ACP event log.", { | ||
| 155 | errorTag: causeErrorTag(cause), | ||
| 156 | reasonCount: cause.reasons.length, | ||
| 157 | provider: input.provider, | ||
| 158 | threadId: input.threadId, | ||
| 159 | }), | ||
| 160 | ), | ||
| 161 | ); | ||
| 162 | |||
| 163 | return { | ||
| 164 | requestLogger: (event: AcpSessionRuntime.AcpSessionRequestLogEvent) => | ||
| 165 | writeNativeAcpLog({ | ||
| 166 | kind: "request", | ||
| 167 | payload: formatRequestLogPayload(event), | ||
| 168 | }), | ||
| 169 | ...(input.nativeEventLogger && input.verboseProtocolLogging | ||
| 170 | ? { | ||
| 171 | protocolLogging: { | ||
| 172 | logIncoming: true, | ||
| 173 | logOutgoing: true, | ||
| 174 | logger: (event: EffectAcpProtocol.AcpProtocolLogEvent) => { | ||
| 175 | const filtered = filterTransientProtocolLog(event); | ||
| 176 | return filtered | ||
| 177 | ? writeNativeAcpLog({ | ||
| 178 | kind: "protocol", | ||
| 179 | payload: formatAcpProtocolLogPayload(filtered), | ||
| 180 | }) | ||
| 181 | : Effect.void; | ||
| 182 | }, | ||
| 183 | } satisfies NonNullable<AcpSessionRuntime.AcpSessionRuntimeOptions["protocolLogging"]>, | ||
| 184 | } | ||
| 185 | : {}), | ||
| 186 | }; | ||
| 187 | }; | ||
| 188 | }); | ||
apps/server/src/provider/acp/AcpRegistryAuth.test.ts+2-2| ... | @@ -16,7 +16,7 @@ import { AcpRequestError } from "effect-acp/errors"; | ... | @@ -16,7 +16,7 @@ import { AcpRequestError } from "effect-acp/errors"; |
| 16 | import * as PtyAdapter from "../../terminal/PtyAdapter.ts"; | 16 | import * as PtyAdapter from "../../terminal/PtyAdapter.ts"; |
| 17 | import { makeAcpRegistryAuth } from "./AcpRegistryAuth.ts"; | 17 | import { makeAcpRegistryAuth } from "./AcpRegistryAuth.ts"; |
| 18 | import * as AcpRegistrySupport from "./AcpRegistrySupport.ts"; | 18 | import * as AcpRegistrySupport from "./AcpRegistrySupport.ts"; |
| 19 | import type { AcpSessionRuntime } from "./AcpSessionRuntime.ts"; | 19 | import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; |
| 20 | 20 | ||
| 21 | const decodeSettings = Schema.decodeSync(AcpRegistrySettings); | 21 | const decodeSettings = Schema.decodeSync(AcpRegistrySettings); |
| 22 | const instanceId = ProviderInstanceId.make("acp-auth-test"); | 22 | const instanceId = ProviderInstanceId.make("acp-auth-test"); |
| ... | @@ -101,7 +101,7 @@ const makeHarness = ( | ... | @@ -101,7 +101,7 @@ const makeHarness = ( |
| 101 | authMethods: [{ ...method, name: `${method.name} ${version}` }], | 101 | authMethods: [{ ...method, name: `${method.name} ${version}` }], |
| 102 | }; | 102 | }; |
| 103 | let elicitation: | 103 | let elicitation: |
| 104 | | Parameters<AcpSessionRuntime["Service"]["handleElicitation"]>[0] | 104 | | Parameters<AcpSessionRuntime.AcpSessionRuntime["Service"]["handleElicitation"]>[0] |
| 105 | | undefined; | 105 | | undefined; |
| 106 | return { | 106 | return { |
| 107 | initialize: () => Effect.succeed(initialized), | 107 | initialize: () => Effect.succeed(initialized), |
apps/server/src/provider/acp/AcpRegistryAuth.ts+1-1| ... | @@ -20,7 +20,7 @@ import * as ProviderAuthFlow from "../ProviderAuthFlow.ts"; | ... | @@ -20,7 +20,7 @@ import * as ProviderAuthFlow from "../ProviderAuthFlow.ts"; |
| 20 | import { normalizeAcpRegistryAuthMethods, normalizeAcpRegistryWebUrl } from "./AcpRegistryProbe.ts"; | 20 | import { normalizeAcpRegistryAuthMethods, normalizeAcpRegistryWebUrl } from "./AcpRegistryProbe.ts"; |
| 21 | import * as AcpRegistrySupport from "./AcpRegistrySupport.ts"; | 21 | import * as AcpRegistrySupport from "./AcpRegistrySupport.ts"; |
| 22 | import * as AcpRegistryRuntimeCoordinator from "./AcpRegistryRuntimeCoordinator.ts"; | 22 | import * as AcpRegistryRuntimeCoordinator from "./AcpRegistryRuntimeCoordinator.ts"; |
| 23 | import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; | 23 | import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; |
| 24 | 24 | ||
| 25 | type Runtime = Pick< | 25 | type Runtime = Pick< |
| 26 | AcpSessionRuntime.AcpSessionRuntime["Service"], | 26 | AcpSessionRuntime.AcpSessionRuntime["Service"], |
apps/server/src/provider/acp/AcpRegistryProbe.test.ts+6-6| ... | @@ -12,7 +12,7 @@ import * as Schema from "effect/Schema"; | ... | @@ -12,7 +12,7 @@ import * as Schema from "effect/Schema"; |
| 12 | import * as TestClock from "effect/testing/TestClock"; | 12 | import * as TestClock from "effect/testing/TestClock"; |
| 13 | import * as EffectAcpErrors from "effect-acp/errors"; | 13 | import * as EffectAcpErrors from "effect-acp/errors"; |
| 14 | 14 | ||
| 15 | import type { AcpSessionRuntimeStartResult } from "./AcpSessionRuntime.ts"; | 15 | import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; |
| 16 | import { | 16 | import { |
| 17 | acpRegistryProbeFailure, | 17 | acpRegistryProbeFailure, |
| 18 | acpRegistryProbeResult, | 18 | acpRegistryProbeResult, |
| ... | @@ -68,7 +68,7 @@ describe("ACP Registry probe", () => { | ... | @@ -68,7 +68,7 @@ describe("ACP Registry probe", () => { |
| 68 | ], | 68 | ], |
| 69 | }, | 69 | }, |
| 70 | modelConfigId: "model", | 70 | modelConfigId: "model", |
| 71 | } satisfies AcpSessionRuntimeStartResult); | 71 | } satisfies AcpSessionRuntime.AcpSessionRuntimeStartResult); |
| 72 | 72 | ||
| 73 | expect(result).toEqual({ | 73 | expect(result).toEqual({ |
| 74 | instanceId, | 74 | instanceId, |
| ... | @@ -186,7 +186,7 @@ describe("ACP Registry probe", () => { | ... | @@ -186,7 +186,7 @@ describe("ACP Registry probe", () => { |
| 186 | ], | 186 | ], |
| 187 | }, | 187 | }, |
| 188 | modelConfigId: "model", | 188 | modelConfigId: "model", |
| 189 | } satisfies AcpSessionRuntimeStartResult); | 189 | } satisfies AcpSessionRuntime.AcpSessionRuntimeStartResult); |
| 190 | 190 | ||
| 191 | expect(result.models).toEqual([ | 191 | expect(result.models).toEqual([ |
| 192 | { id: "sonnet", name: "Sonnet", description: "Balanced" }, | 192 | { id: "sonnet", name: "Sonnet", description: "Balanced" }, |
| ... | @@ -216,7 +216,7 @@ describe("ACP Registry probe", () => { | ... | @@ -216,7 +216,7 @@ describe("ACP Registry probe", () => { |
| 216 | ], | 216 | ], |
| 217 | }, | 217 | }, |
| 218 | modelConfigId: "model", | 218 | modelConfigId: "model", |
| 219 | } satisfies AcpSessionRuntimeStartResult); | 219 | } satisfies AcpSessionRuntime.AcpSessionRuntimeStartResult); |
| 220 | 220 | ||
| 221 | expect(result.models).toEqual([{ id: "valid", name: "Valid", description: null }]); | 221 | expect(result.models).toEqual([{ id: "valid", name: "Valid", description: null }]); |
| 222 | expect(result.currentModelId).toBeNull(); | 222 | expect(result.currentModelId).toBeNull(); |
| ... | @@ -270,7 +270,7 @@ describe("ACP Registry probe", () => { | ... | @@ -270,7 +270,7 @@ describe("ACP Registry probe", () => { |
| 270 | ], | 270 | ], |
| 271 | }, | 271 | }, |
| 272 | modelConfigId: "model", | 272 | modelConfigId: "model", |
| 273 | } satisfies AcpSessionRuntimeStartResult); | 273 | } satisfies AcpSessionRuntime.AcpSessionRuntimeStartResult); |
| 274 | 274 | ||
| 275 | expect(result.models).toEqual([ | 275 | expect(result.models).toEqual([ |
| 276 | { id: codexModel, name: "GPT-6.1 Sol", description: null }, | 276 | { id: codexModel, name: "GPT-6.1 Sol", description: null }, |
| ... | @@ -312,7 +312,7 @@ describe("ACP Registry probe", () => { | ... | @@ -312,7 +312,7 @@ describe("ACP Registry probe", () => { |
| 312 | ], | 312 | ], |
| 313 | }, | 313 | }, |
| 314 | modelConfigId: "model", | 314 | modelConfigId: "model", |
| 315 | } satisfies AcpSessionRuntimeStartResult); | 315 | } satisfies AcpSessionRuntime.AcpSessionRuntimeStartResult); |
| 316 | 316 | ||
| 317 | expect(result.models).toHaveLength(256); | 317 | expect(result.models).toHaveLength(256); |
| 318 | expect(result.currentModelId).toBeNull(); | 318 | expect(result.currentModelId).toBeNull(); |
apps/server/src/provider/acp/AcpRegistryProbe.ts+3-3| ... | @@ -25,10 +25,10 @@ import type * as EffectAcpSchema from "effect-acp/compat"; | ... | @@ -25,10 +25,10 @@ import type * as EffectAcpSchema from "effect-acp/compat"; |
| 25 | 25 | ||
| 26 | import * as PtyAdapter from "../../terminal/PtyAdapter.ts"; | 26 | import * as PtyAdapter from "../../terminal/PtyAdapter.ts"; |
| 27 | import * as AcpRegistrySupport from "./AcpRegistrySupport.ts"; | 27 | import * as AcpRegistrySupport from "./AcpRegistrySupport.ts"; |
| 28 | import { parseSessionModeState } from "./AcpRuntimeModel.ts"; | 28 | import { parseSessionModeState } from "@t3tools/provider-acp/server/runtimeModel"; |
| 29 | import { acpProviderOptionDescriptors } from "./AcpSessionConfig.ts"; | 29 | import { acpProviderOptionDescriptors } from "@t3tools/provider-acp/server/sessionConfig"; |
| 30 | import * as AcpRegistryRuntimeCoordinator from "./AcpRegistryRuntimeCoordinator.ts"; | 30 | import * as AcpRegistryRuntimeCoordinator from "./AcpRegistryRuntimeCoordinator.ts"; |
| 31 | import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; | 31 | import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; |
| 32 | 32 | ||
| 33 | const MAX_AUTH_METHODS = 32; | 33 | const MAX_AUTH_METHODS = 32; |
| 34 | const MAX_MODELS = 256; | 34 | const MAX_MODELS = 256; |
apps/server/src/provider/acp/AcpRegistrySupport.ts+2-2| ... | @@ -43,7 +43,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/process"; | ... | @@ -43,7 +43,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/process"; |
| 43 | 43 | ||
| 44 | import { collectUint8StreamText } from "@t3tools/provider-core/server/collectStreamText"; | 44 | import { collectUint8StreamText } from "@t3tools/provider-core/server/collectStreamText"; |
| 45 | import * as ServerSettings from "../../serverSettings.ts"; | 45 | import * as ServerSettings from "../../serverSettings.ts"; |
| 46 | import type { AcpSpawnInput } from "./AcpSessionRuntime.ts"; | 46 | import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; |
| 47 | 47 | ||
| 48 | const ACP_REGISTRY_URL = "https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json"; | 48 | const ACP_REGISTRY_URL = "https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json"; |
| 49 | 49 | ||
| ... | @@ -444,7 +444,7 @@ export function resolveAcpRegistryDistribution(input: { | ... | @@ -444,7 +444,7 @@ export function resolveAcpRegistryDistribution(input: { |
| 444 | export interface ResolvedAcpRegistryAgent { | 444 | export interface ResolvedAcpRegistryAgent { |
| 445 | readonly agent?: AcpRegistryAgent; | 445 | readonly agent?: AcpRegistryAgent; |
| 446 | readonly distribution: AcpRegistryDistributionKind | "local"; | 446 | readonly distribution: AcpRegistryDistributionKind | "local"; |
| 447 | readonly spawn: AcpSpawnInput; | 447 | readonly spawn: AcpSessionRuntime.AcpSpawnInput; |
| 448 | } | 448 | } |
| 449 | 449 | ||
| 450 | export type AcpRegistryInspection = | 450 | export type AcpRegistryInspection = |
apps/server/src/provider/acp/AcpRuntimeModel.test.ts deleted-1724| ... | @@ -1,1724 +0,0 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import type * as EffectAcpSchema from "effect-acp/compat"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | decideToolCallUpdateEmission, | ||
| 7 | applyAcpAgentTerminalUpdate, | ||
| 8 | acpContentBlockDisplayText, | ||
| 9 | embeddedTerminalIdsFromSessionUpdate, | ||
| 10 | extractMcpToolCallIdentity, | ||
| 11 | extractModelConfigId, | ||
| 12 | mergeToolCallState, | ||
| 13 | parsePermissionRequest, | ||
| 14 | parseSessionModeState, | ||
| 15 | parseSessionUpdateEvent, | ||
| 16 | sessionModelStateFromInitialize, | ||
| 17 | sessionUpdateCountsAsLoadReplayActivity, | ||
| 18 | sessionUpdateIsReplay, | ||
| 19 | syntheticLoadSessionResponseFromInitialize, | ||
| 20 | toolCallProgressLength, | ||
| 21 | type AcpToolCallState, | ||
| 22 | } from "./AcpRuntimeModel.ts"; | ||
| 23 | |||
| 24 | describe("AcpRuntimeModel", () => { | ||
| 25 | it("parses session mode state from typed ACP session setup responses", () => { | ||
| 26 | const modeState = parseSessionModeState({ | ||
| 27 | sessionId: "session-1", | ||
| 28 | modes: { | ||
| 29 | currentModeId: " code ", | ||
| 30 | availableModes: [ | ||
| 31 | { id: " ask ", name: " Ask ", description: " Request approval " }, | ||
| 32 | { id: " code ", name: " Code " }, | ||
| 33 | ], | ||
| 34 | }, | ||
| 35 | configOptions: [], | ||
| 36 | } satisfies EffectAcpSchema.NewSessionResponse); | ||
| 37 | |||
| 38 | expect(modeState).toEqual({ | ||
| 39 | currentModeId: "code", | ||
| 40 | availableModes: [ | ||
| 41 | { id: "ask", name: "Ask", description: "Request approval" }, | ||
| 42 | { id: "code", name: "Code" }, | ||
| 43 | ], | ||
| 44 | }); | ||
| 45 | }); | ||
| 46 | |||
| 47 | it("extracts the model config id from typed ACP config options", () => { | ||
| 48 | const modelConfigId = extractModelConfigId({ | ||
| 49 | sessionId: "session-1", | ||
| 50 | configOptions: [ | ||
| 51 | { | ||
| 52 | id: "approval", | ||
| 53 | name: "Approval Mode", | ||
| 54 | category: "permission", | ||
| 55 | type: "select", | ||
| 56 | currentValue: "ask", | ||
| 57 | options: [{ value: "ask", name: "Ask" }], | ||
| 58 | }, | ||
| 59 | { | ||
| 60 | id: "model", | ||
| 61 | name: "Model", | ||
| 62 | category: "model", | ||
| 63 | type: "select", | ||
| 64 | currentValue: "default", | ||
| 65 | options: [{ value: "default", name: "Auto" }], | ||
| 66 | }, | ||
| 67 | ], | ||
| 68 | } satisfies EffectAcpSchema.NewSessionResponse); | ||
| 69 | |||
| 70 | expect(modelConfigId).toBe("model"); | ||
| 71 | }); | ||
| 72 | |||
| 73 | it("detects Grok session replay updates from _meta.isReplay", () => { | ||
| 74 | expect( | ||
| 75 | sessionUpdateIsReplay({ | ||
| 76 | _meta: { isReplay: true }, | ||
| 77 | sessionId: "session-1", | ||
| 78 | update: { | ||
| 79 | sessionUpdate: "agent_message_chunk", | ||
| 80 | content: { type: "text", text: "replayed" }, | ||
| 81 | }, | ||
| 82 | } satisfies EffectAcpSchema.SessionNotification), | ||
| 83 | ).toBe(true); | ||
| 84 | expect( | ||
| 85 | sessionUpdateIsReplay({ | ||
| 86 | sessionId: "session-1", | ||
| 87 | update: { | ||
| 88 | sessionUpdate: "agent_message_chunk", | ||
| 89 | content: { type: "text", text: "live" }, | ||
| 90 | }, | ||
| 91 | } satisfies EffectAcpSchema.SessionNotification), | ||
| 92 | ).toBe(false); | ||
| 93 | }); | ||
| 94 | |||
| 95 | it("ignores Grok keepalive chunks when tracking session/load replay activity", () => { | ||
| 96 | expect( | ||
| 97 | sessionUpdateCountsAsLoadReplayActivity({ | ||
| 98 | sessionId: "session-1", | ||
| 99 | update: { | ||
| 100 | sessionUpdate: "agent_message_chunk", | ||
| 101 | content: { type: "text", text: "" }, | ||
| 102 | }, | ||
| 103 | } satisfies EffectAcpSchema.SessionNotification), | ||
| 104 | ).toBe(false); | ||
| 105 | expect( | ||
| 106 | sessionUpdateCountsAsLoadReplayActivity({ | ||
| 107 | _meta: { isReplay: true }, | ||
| 108 | sessionId: "session-1", | ||
| 109 | update: { | ||
| 110 | sessionUpdate: "tool_call", | ||
| 111 | toolCallId: "replay-tool", | ||
| 112 | title: "Replay", | ||
| 113 | kind: "search", | ||
| 114 | status: "completed", | ||
| 115 | }, | ||
| 116 | } satisfies EffectAcpSchema.SessionNotification), | ||
| 117 | ).toBe(true); | ||
| 118 | }); | ||
| 119 | |||
| 120 | it("ignores load-replay activity from other sessions while a load gate is active", () => { | ||
| 121 | expect( | ||
| 122 | sessionUpdateCountsAsLoadReplayActivity( | ||
| 123 | { | ||
| 124 | sessionId: "session-other", | ||
| 125 | update: { | ||
| 126 | sessionUpdate: "agent_message_chunk", | ||
| 127 | content: { type: "text", text: "unrelated" }, | ||
| 128 | }, | ||
| 129 | } satisfies EffectAcpSchema.SessionNotification, | ||
| 130 | "session-loading", | ||
| 131 | ), | ||
| 132 | ).toBe(false); | ||
| 133 | expect( | ||
| 134 | sessionUpdateCountsAsLoadReplayActivity( | ||
| 135 | { | ||
| 136 | sessionId: "session-loading", | ||
| 137 | update: { | ||
| 138 | sessionUpdate: "agent_message_chunk", | ||
| 139 | content: { type: "text", text: "replay" }, | ||
| 140 | }, | ||
| 141 | } satisfies EffectAcpSchema.SessionNotification, | ||
| 142 | "session-loading", | ||
| 143 | ), | ||
| 144 | ).toBe(true); | ||
| 145 | }); | ||
| 146 | |||
| 147 | it("counts mode/config/session/usage updates as load-replay activity", () => { | ||
| 148 | expect( | ||
| 149 | sessionUpdateCountsAsLoadReplayActivity({ | ||
| 150 | sessionId: "session-1", | ||
| 151 | update: { | ||
| 152 | sessionUpdate: "current_mode_update", | ||
| 153 | currentModeId: "code", | ||
| 154 | }, | ||
| 155 | } satisfies EffectAcpSchema.SessionNotification), | ||
| 156 | ).toBe(true); | ||
| 157 | expect( | ||
| 158 | sessionUpdateCountsAsLoadReplayActivity({ | ||
| 159 | sessionId: "session-1", | ||
| 160 | update: { | ||
| 161 | sessionUpdate: "config_option_update", | ||
| 162 | configOptions: [], | ||
| 163 | }, | ||
| 164 | } satisfies EffectAcpSchema.SessionNotification), | ||
| 165 | ).toBe(true); | ||
| 166 | expect( | ||
| 167 | sessionUpdateCountsAsLoadReplayActivity({ | ||
| 168 | sessionId: "session-1", | ||
| 169 | update: { | ||
| 170 | sessionUpdate: "session_info_update", | ||
| 171 | title: "Restored", | ||
| 172 | }, | ||
| 173 | } satisfies EffectAcpSchema.SessionNotification), | ||
| 174 | ).toBe(true); | ||
| 175 | expect( | ||
| 176 | sessionUpdateCountsAsLoadReplayActivity({ | ||
| 177 | sessionId: "session-1", | ||
| 178 | update: { | ||
| 179 | sessionUpdate: "usage_update", | ||
| 180 | used: 1, | ||
| 181 | size: 10, | ||
| 182 | }, | ||
| 183 | } satisfies EffectAcpSchema.SessionNotification), | ||
| 184 | ).toBe(true); | ||
| 185 | }); | ||
| 186 | |||
| 187 | it("ignores malformed initialize mode state in synthetic load responses", () => { | ||
| 188 | const response = syntheticLoadSessionResponseFromInitialize({ | ||
| 189 | protocolVersion: 1, | ||
| 190 | _meta: { | ||
| 191 | modeState: { | ||
| 192 | currentModeId: "code", | ||
| 193 | availableModes: [{ id: "code", name: 12 }], | ||
| 194 | }, | ||
| 195 | }, | ||
| 196 | } as EffectAcpSchema.InitializeResponse); | ||
| 197 | |||
| 198 | expect(response.modes).toBeUndefined(); | ||
| 199 | expect(response._meta).toMatchObject({ t3SessionLoadReady: "replay_idle" }); | ||
| 200 | }); | ||
| 201 | |||
| 202 | it("builds a synthetic load response with initialize mode state", () => { | ||
| 203 | const response = syntheticLoadSessionResponseFromInitialize({ | ||
| 204 | protocolVersion: 1, | ||
| 205 | _meta: { | ||
| 206 | modeState: { | ||
| 207 | currentModeId: "code", | ||
| 208 | availableModes: [ | ||
| 209 | { id: "ask", name: "Ask" }, | ||
| 210 | { id: "code", name: "Code" }, | ||
| 211 | ], | ||
| 212 | }, | ||
| 213 | }, | ||
| 214 | } satisfies EffectAcpSchema.InitializeResponse); | ||
| 215 | |||
| 216 | expect(response.modes?.currentModeId).toBe("code"); | ||
| 217 | expect(response.modes?.availableModes).toHaveLength(2); | ||
| 218 | }); | ||
| 219 | |||
| 220 | it("keeps initialize mode state whose modes have null descriptions", () => { | ||
| 221 | const response = syntheticLoadSessionResponseFromInitialize({ | ||
| 222 | protocolVersion: 1, | ||
| 223 | _meta: { | ||
| 224 | modeState: { | ||
| 225 | currentModeId: "code", | ||
| 226 | availableModes: [ | ||
| 227 | { id: "ask", name: "Ask", description: null }, | ||
| 228 | { id: "code", name: "Code", description: "Edit files", _meta: null }, | ||
| 229 | ], | ||
| 230 | _meta: null, | ||
| 231 | }, | ||
| 232 | }, | ||
| 233 | } satisfies EffectAcpSchema.InitializeResponse); | ||
| 234 | |||
| 235 | expect(parseSessionModeState(response)).toEqual({ | ||
| 236 | currentModeId: "code", | ||
| 237 | availableModes: [ | ||
| 238 | { id: "ask", name: "Ask" }, | ||
| 239 | { id: "code", name: "Code", description: "Edit files" }, | ||
| 240 | ], | ||
| 241 | }); | ||
| 242 | }); | ||
| 243 | |||
| 244 | it("reads initialize model state and ignores malformed model state", () => { | ||
| 245 | const modelState = { | ||
| 246 | currentModelId: "grok-4.7", | ||
| 247 | availableModels: [ | ||
| 248 | { | ||
| 249 | modelId: "grok-4.7", | ||
| 250 | name: "Grok 4.7", | ||
| 251 | description: null, | ||
| 252 | _meta: { reasoningEfforts: [{ id: "high", default: true }] }, | ||
| 253 | }, | ||
| 254 | { modelId: "grok-4.6", name: "Grok 4.6" }, | ||
| 255 | ], | ||
| 256 | }; | ||
| 257 | |||
| 258 | expect(sessionModelStateFromInitialize({ protocolVersion: 1, _meta: { modelState } })).toEqual( | ||
| 259 | modelState, | ||
| 260 | ); | ||
| 261 | expect( | ||
| 262 | sessionModelStateFromInitialize({ | ||
| 263 | protocolVersion: 1, | ||
| 264 | _meta: { | ||
| 265 | modelState: { currentModelId: "grok-4.7", availableModels: [{ modelId: "grok-4.7" }] }, | ||
| 266 | }, | ||
| 267 | }), | ||
| 268 | ).toBeUndefined(); | ||
| 269 | expect( | ||
| 270 | sessionModelStateFromInitialize({ | ||
| 271 | protocolVersion: 1, | ||
| 272 | _meta: { modelState: { ...modelState, _meta: "not an object" } }, | ||
| 273 | }), | ||
| 274 | ).toBeUndefined(); | ||
| 275 | }); | ||
| 276 | |||
| 277 | it("projects typed ACP tool call updates into runtime events", () => { | ||
| 278 | const created = parseSessionUpdateEvent({ | ||
| 279 | sessionId: "session-1", | ||
| 280 | update: { | ||
| 281 | sessionUpdate: "tool_call", | ||
| 282 | toolCallId: "tool-1", | ||
| 283 | title: "Terminal", | ||
| 284 | kind: "execute", | ||
| 285 | status: "pending", | ||
| 286 | rawInput: { | ||
| 287 | executable: "bun", | ||
| 288 | args: ["run", "typecheck"], | ||
| 289 | }, | ||
| 290 | content: [ | ||
| 291 | { | ||
| 292 | type: "content", | ||
| 293 | content: { | ||
| 294 | type: "text", | ||
| 295 | text: "Running checks", | ||
| 296 | }, | ||
| 297 | }, | ||
| 298 | ], | ||
| 299 | }, | ||
| 300 | } satisfies EffectAcpSchema.SessionNotification); | ||
| 301 | |||
| 302 | expect(created.events).toEqual([ | ||
| 303 | { | ||
| 304 | _tag: "ToolCallUpdated", | ||
| 305 | toolCall: { | ||
| 306 | toolCallId: "tool-1", | ||
| 307 | kind: "execute", | ||
| 308 | title: "Ran command", | ||
| 309 | status: "pending", | ||
| 310 | command: "bun run typecheck", | ||
| 311 | detail: "bun run typecheck", | ||
| 312 | data: { | ||
| 313 | toolCallId: "tool-1", | ||
| 314 | kind: "execute", | ||
| 315 | title: "Terminal", | ||
| 316 | command: "bun run typecheck", | ||
| 317 | rawInput: { | ||
| 318 | executable: "bun", | ||
| 319 | args: ["run", "typecheck"], | ||
| 320 | }, | ||
| 321 | content: [ | ||
| 322 | { | ||
| 323 | type: "content", | ||
| 324 | content: { | ||
| 325 | type: "text", | ||
| 326 | text: "Running checks", | ||
| 327 | }, | ||
| 328 | }, | ||
| 329 | ], | ||
| 330 | }, | ||
| 331 | }, | ||
| 332 | rawPayload: { | ||
| 333 | sessionId: "session-1", | ||
| 334 | update: { | ||
| 335 | sessionUpdate: "tool_call", | ||
| 336 | toolCallId: "tool-1", | ||
| 337 | title: "Terminal", | ||
| 338 | kind: "execute", | ||
| 339 | status: "pending", | ||
| 340 | rawInput: { | ||
| 341 | executable: "bun", | ||
| 342 | args: ["run", "typecheck"], | ||
| 343 | }, | ||
| 344 | content: [ | ||
| 345 | { | ||
| 346 | type: "content", | ||
| 347 | content: { | ||
| 348 | type: "text", | ||
| 349 | text: "Running checks", | ||
| 350 | }, | ||
| 351 | }, | ||
| 352 | ], | ||
| 353 | }, | ||
| 354 | }, | ||
| 355 | }, | ||
| 356 | ]); | ||
| 357 | |||
| 358 | const updated = parseSessionUpdateEvent({ | ||
| 359 | sessionId: "session-1", | ||
| 360 | update: { | ||
| 361 | sessionUpdate: "tool_call_update", | ||
| 362 | toolCallId: "tool-1", | ||
| 363 | status: "completed", | ||
| 364 | rawOutput: { exitCode: 0 }, | ||
| 365 | }, | ||
| 366 | } satisfies EffectAcpSchema.SessionNotification); | ||
| 367 | |||
| 368 | expect(updated.events).toHaveLength(1); | ||
| 369 | expect(updated.events[0]?._tag).toBe("ToolCallUpdated"); | ||
| 370 | const createdEvent = created.events[0]; | ||
| 371 | const updatedEvent = updated.events[0]; | ||
| 372 | if (createdEvent?._tag === "ToolCallUpdated" && updatedEvent?._tag === "ToolCallUpdated") { | ||
| 373 | expect(mergeToolCallState(createdEvent.toolCall, updatedEvent.toolCall)).toMatchObject({ | ||
| 374 | toolCallId: "tool-1", | ||
| 375 | status: "completed", | ||
| 376 | title: "Ran command", | ||
| 377 | detail: "bun run typecheck", | ||
| 378 | command: "bun run typecheck", | ||
| 379 | }); | ||
| 380 | } | ||
| 381 | }); | ||
| 382 | |||
| 383 | it("clears prior ACP locations when an update explicitly replaces them", () => { | ||
| 384 | const toolCall = (locations?: EffectAcpSchema.ToolCallLocation[] | null) => { | ||
| 385 | const parsed = parseSessionUpdateEvent({ | ||
| 386 | sessionId: "session-1", | ||
| 387 | update: { | ||
| 388 | sessionUpdate: "tool_call_update", | ||
| 389 | toolCallId: "read-1", | ||
| 390 | kind: "read", | ||
| 391 | ...(locations === undefined ? {} : { locations }), | ||
| 392 | }, | ||
| 393 | } satisfies EffectAcpSchema.SessionNotification); | ||
| 394 | const event = parsed.events.find((candidate) => candidate._tag === "ToolCallUpdated"); | ||
| 395 | if (!event || event._tag !== "ToolCallUpdated") { | ||
| 396 | throw new Error("expected a tool call update"); | ||
| 397 | } | ||
| 398 | return event.toolCall; | ||
| 399 | }; | ||
| 400 | |||
| 401 | const created = toolCall([{ path: "README" }]); | ||
| 402 | expect(mergeToolCallState(created, toolCall()).data.locations).toEqual([{ path: "README" }]); | ||
| 403 | const cleared = mergeToolCallState(created, toolCall([])); | ||
| 404 | expect(cleared.data.locations).toEqual([]); | ||
| 405 | expect(mergeToolCallState(created, toolCall(null)).data.locations).toEqual([]); | ||
| 406 | expect( | ||
| 407 | decideToolCallUpdateEmission({ | ||
| 408 | previous: created, | ||
| 409 | next: cleared, | ||
| 410 | lastEmittedDetailLength: 0, | ||
| 411 | skippedSinceEmit: 0, | ||
| 412 | }).emit, | ||
| 413 | ).toBe(true); | ||
| 414 | }); | ||
| 415 | |||
| 416 | it("trims padded current mode updates before emitting a mode change", () => { | ||
| 417 | const result = parseSessionUpdateEvent({ | ||
| 418 | sessionId: "session-1", | ||
| 419 | update: { | ||
| 420 | sessionUpdate: "current_mode_update", | ||
| 421 | currentModeId: " code ", | ||
| 422 | }, | ||
| 423 | } satisfies EffectAcpSchema.SessionNotification); | ||
| 424 | |||
| 425 | expect(result.modeId).toBe("code"); | ||
| 426 | expect(result.events).toEqual([ | ||
| 427 | { | ||
| 428 | _tag: "ModeChanged", | ||
| 429 | modeId: "code", | ||
| 430 | }, | ||
| 431 | ]); | ||
| 432 | }); | ||
| 433 | |||
| 434 | it("projects typed ACP plan and content updates", () => { | ||
| 435 | const planResult = parseSessionUpdateEvent({ | ||
| 436 | sessionId: "session-1", | ||
| 437 | update: { | ||
| 438 | sessionUpdate: "plan", | ||
| 439 | entries: [ | ||
| 440 | { content: " Inspect state ", priority: "high", status: "completed" }, | ||
| 441 | { content: "", priority: "medium", status: "in_progress" }, | ||
| 442 | ], | ||
| 443 | }, | ||
| 444 | } satisfies EffectAcpSchema.SessionNotification); | ||
| 445 | |||
| 446 | expect(planResult.events).toEqual([ | ||
| 447 | { | ||
| 448 | _tag: "PlanUpdated", | ||
| 449 | payload: { | ||
| 450 | nativePlanId: "legacy", | ||
| 451 | kind: "items", | ||
| 452 | plan: [ | ||
| 453 | { step: "Inspect state", status: "completed" }, | ||
| 454 | { step: "Step 2", status: "inProgress" }, | ||
| 455 | ], | ||
| 456 | }, | ||
| 457 | rawPayload: { | ||
| 458 | sessionId: "session-1", | ||
| 459 | update: { | ||
| 460 | sessionUpdate: "plan", | ||
| 461 | entries: [ | ||
| 462 | { content: " Inspect state ", priority: "high", status: "completed" }, | ||
| 463 | { content: "", priority: "medium", status: "in_progress" }, | ||
| 464 | ], | ||
| 465 | }, | ||
| 466 | }, | ||
| 467 | }, | ||
| 468 | ]); | ||
| 469 | |||
| 470 | const contentResult = parseSessionUpdateEvent({ | ||
| 471 | sessionId: "session-1", | ||
| 472 | update: { | ||
| 473 | sessionUpdate: "agent_message_chunk", | ||
| 474 | content: { | ||
| 475 | type: "text", | ||
| 476 | text: "hello from acp", | ||
| 477 | }, | ||
| 478 | }, | ||
| 479 | } satisfies EffectAcpSchema.SessionNotification); | ||
| 480 | |||
| 481 | expect(contentResult.events).toEqual([ | ||
| 482 | { | ||
| 483 | _tag: "ContentDelta", | ||
| 484 | text: "hello from acp", | ||
| 485 | rawPayload: { | ||
| 486 | sessionId: "session-1", | ||
| 487 | update: { | ||
| 488 | sessionUpdate: "agent_message_chunk", | ||
| 489 | content: { | ||
| 490 | type: "text", | ||
| 491 | text: "hello from acp", | ||
| 492 | }, | ||
| 493 | }, | ||
| 494 | }, | ||
| 495 | }, | ||
| 496 | ]); | ||
| 497 | }); | ||
| 498 | |||
| 499 | it("keeps thought chunks separate from assistant text", () => { | ||
| 500 | const notification = { | ||
| 501 | sessionId: "session-1", | ||
| 502 | update: { | ||
| 503 | sessionUpdate: "agent_thought_chunk", | ||
| 504 | content: { type: "text", text: "Inspect the current implementation first." }, | ||
| 505 | }, | ||
| 506 | } satisfies EffectAcpSchema.SessionNotification; | ||
| 507 | |||
| 508 | expect(parseSessionUpdateEvent(notification).events).toEqual([ | ||
| 509 | { | ||
| 510 | _tag: "ThoughtDelta", | ||
| 511 | text: "Inspect the current implementation first.", | ||
| 512 | rawPayload: notification, | ||
| 513 | }, | ||
| 514 | ]); | ||
| 515 | }); | ||
| 516 | |||
| 517 | it("preserves native command inputs and empty command lists", () => { | ||
| 518 | const availableCommands = [ | ||
| 519 | { name: "plan", description: "Plan a task", input: { type: "text", hint: "task" } }, | ||
| 520 | { name: "logout", description: "Sign out" }, | ||
| 521 | ] satisfies ReadonlyArray<EffectAcpSchema.AvailableCommand>; | ||
| 522 | |||
| 523 | for (const commands of [availableCommands, []]) { | ||
| 524 | const notification = { | ||
| 525 | sessionId: "session-1", | ||
| 526 | update: { sessionUpdate: "available_commands_update", availableCommands: commands }, | ||
| 527 | } satisfies EffectAcpSchema.SessionNotification; | ||
| 528 | expect(parseSessionUpdateEvent(notification).events).toEqual([ | ||
| 529 | { | ||
| 530 | _tag: "AvailableCommandsUpdated", | ||
| 531 | availableCommands: commands, | ||
| 532 | rawPayload: notification, | ||
| 533 | }, | ||
| 534 | ]); | ||
| 535 | } | ||
| 536 | }); | ||
| 537 | |||
| 538 | it("keeps permission request parsing compatible with loose extension payloads", () => { | ||
| 539 | const request = parsePermissionRequest({ | ||
| 540 | sessionId: "session-1", | ||
| 541 | options: [ | ||
| 542 | { | ||
| 543 | optionId: "allow-once", | ||
| 544 | name: "Allow once", | ||
| 545 | kind: "allow_once", | ||
| 546 | }, | ||
| 547 | ], | ||
| 548 | toolCall: { | ||
| 549 | toolCallId: "tool-1", | ||
| 550 | title: "`cat package.json`", | ||
| 551 | kind: "execute", | ||
| 552 | status: "pending", | ||
| 553 | content: [ | ||
| 554 | { | ||
| 555 | type: "content", | ||
| 556 | content: { | ||
| 557 | type: "text", | ||
| 558 | text: "Not in allowlist", | ||
| 559 | }, | ||
| 560 | }, | ||
| 561 | ], | ||
| 562 | }, | ||
| 563 | }); | ||
| 564 | |||
| 565 | expect(request).toMatchObject({ | ||
| 566 | kind: "execute", | ||
| 567 | detail: "cat package.json", | ||
| 568 | toolCall: { | ||
| 569 | toolCallId: "tool-1", | ||
| 570 | kind: "execute", | ||
| 571 | status: "pending", | ||
| 572 | command: "cat package.json", | ||
| 573 | }, | ||
| 574 | }); | ||
| 575 | }); | ||
| 576 | |||
| 577 | it("bounds an oversized cumulative tool_call_update content buffer to a tail window", () => { | ||
| 578 | // Mirrors Grok's ACP CLI resending the ENTIRE accumulated terminal output on every | ||
| 579 | // tool_call_update notification instead of a delta (see upstream #6556). | ||
| 580 | const hugeText = Array.from({ length: 2_000 }, (_, i) => `line ${i}: ${"x".repeat(50)}`).join( | ||
| 581 | "\n", | ||
| 582 | ); | ||
| 583 | expect(hugeText.length).toBeGreaterThan(60_000); | ||
| 584 | |||
| 585 | const result = parseSessionUpdateEvent({ | ||
| 586 | sessionId: "session-1", | ||
| 587 | update: { | ||
| 588 | // Real ACP `tool_call_update` deltas typically omit `title` (already established by | ||
| 589 | // the initial `tool_call`); that is also the shape that surfaces raw content as detail. | ||
| 590 | sessionUpdate: "tool_call_update", | ||
| 591 | toolCallId: "tool-1", | ||
| 592 | kind: "other", | ||
| 593 | status: "in_progress", | ||
| 594 | content: [{ type: "content", content: { type: "text", text: hugeText } }], | ||
| 595 | }, | ||
| 596 | } satisfies EffectAcpSchema.SessionNotification); | ||
| 597 | |||
| 598 | expect(result.events).toHaveLength(1); | ||
| 599 | const event = result.events[0]; | ||
| 600 | if (event?._tag !== "ToolCallUpdated") { | ||
| 601 | throw new Error("expected a ToolCallUpdated event"); | ||
| 602 | } | ||
| 603 | |||
| 604 | expect(event.toolCall.detail).toBeDefined(); | ||
| 605 | const detail = event.toolCall.detail!; | ||
| 606 | // 8000 chars of tail plus the truncation marker, regardless of input size. | ||
| 607 | expect(detail.length).toBe(8_028); | ||
| 608 | expect(detail.startsWith("[Earlier output truncated]")).toBe(true); | ||
| 609 | expect(detail.endsWith(hugeText.slice(-100))).toBe(true); | ||
| 610 | |||
| 611 | // The raw payload threaded through for logging/persistence must not smuggle the full | ||
| 612 | // cumulative buffer back in either. | ||
| 613 | const rawUpdate = ( | ||
| 614 | event.rawPayload as { | ||
| 615 | readonly update: { | ||
| 616 | readonly content: ReadonlyArray<{ readonly content: { text: string } }>; | ||
| 617 | }; | ||
| 618 | } | ||
| 619 | ).update; | ||
| 620 | expect(rawUpdate.content[0]?.content.text.length).toBeLessThan(8_100); | ||
| 621 | expect(JSON.stringify(event).length).toBeLessThan(hugeText.length); | ||
| 622 | }); | ||
| 623 | |||
| 624 | it("coalesces 1000 rapid cumulative tool_call_update notifications for a redrawing progress bar", () => { | ||
| 625 | let previous: AcpToolCallState | undefined; | ||
| 626 | let lastEmittedDetailLength: number | undefined; | ||
| 627 | let skippedSinceEmit = 0; | ||
| 628 | let emittedCount = 0; | ||
| 629 | let emittedBytes = 0; | ||
| 630 | let notificationBytes = 0; | ||
| 631 | let largestEmittedEventBytes = 0; | ||
| 632 | let finalDetail: string | undefined; | ||
| 633 | let cumulativeBuffer = ""; | ||
| 634 | |||
| 635 | for (let i = 0; i < 1_000; i += 1) { | ||
| 636 | // Grok resends the FULL accumulated buffer, not a delta, on every redraw. | ||
| 637 | cumulativeBuffer += `frame ${i}: ${"#".repeat(50)}\n`; | ||
| 638 | const isLast = i === 999; | ||
| 639 | |||
| 640 | const notification = { | ||
| 641 | sessionId: "session-1", | ||
| 642 | update: { | ||
| 643 | sessionUpdate: "tool_call_update", | ||
| 644 | toolCallId: "tool-1", | ||
| 645 | kind: "other", | ||
| 646 | status: isLast ? "completed" : "in_progress", | ||
| 647 | content: [{ type: "content", content: { type: "text", text: cumulativeBuffer } }], | ||
| 648 | }, | ||
| 649 | } satisfies EffectAcpSchema.SessionNotification; | ||
| 650 | notificationBytes += JSON.stringify(notification).length; | ||
| 651 | |||
| 652 | const { events } = parseSessionUpdateEvent(notification); | ||
| 653 | |||
| 654 | const event = events[0]; | ||
| 655 | if (event?._tag !== "ToolCallUpdated") { | ||
| 656 | continue; | ||
| 657 | } | ||
| 658 | |||
| 659 | const merged = mergeToolCallState(previous, event.toolCall); | ||
| 660 | const decision = decideToolCallUpdateEmission({ | ||
| 661 | previous, | ||
| 662 | next: merged, | ||
| 663 | lastEmittedDetailLength, | ||
| 664 | skippedSinceEmit, | ||
| 665 | }); | ||
| 666 | previous = merged; | ||
| 667 | skippedSinceEmit = decision.skippedSinceEmit; | ||
| 668 | if (decision.emit) { | ||
| 669 | emittedCount += 1; | ||
| 670 | const eventBytes = JSON.stringify({ | ||
| 671 | toolCall: merged, | ||
| 672 | rawPayload: event.rawPayload, | ||
| 673 | }).length; | ||
| 674 | emittedBytes += eventBytes; | ||
| 675 | largestEmittedEventBytes = Math.max(largestEmittedEventBytes, eventBytes); | ||
| 676 | lastEmittedDetailLength = merged.detail?.length; | ||
| 677 | finalDetail = merged.detail; | ||
| 678 | } | ||
| 679 | } | ||
| 680 | |||
| 681 | // The flood as the CLI sends it: 1000 cumulative redraws, ~31.6 MB of JSON. | ||
| 682 | expect(notificationBytes).toBeGreaterThan(31_000_000); | ||
| 683 | |||
| 684 | // 1000 cumulative redraws collapse into a fixed, small number of runtime events... | ||
| 685 | expect(emittedCount).toBe(114); | ||
| 686 | // ...each individually bounded, no matter how long the tool call runs... | ||
| 687 | expect(largestEmittedEventBytes).toBeLessThan(25_000); | ||
| 688 | // ...so the whole flooding tool call costs ~2.5 MB of runtime events instead of ~31.6 MB. | ||
| 689 | expect(emittedBytes).toBeLessThan(2_600_000); | ||
| 690 | // ...while the FINAL state (forced by the completed status) still reflects the real, | ||
| 691 | // latest output rather than a stale coalesced value. | ||
| 692 | expect(finalDetail).toBeDefined(); | ||
| 693 | expect(finalDetail?.endsWith(`frame 999: ${"#".repeat(50)}`)).toBe(true); | ||
| 694 | }); | ||
| 695 | |||
| 696 | it("keeps non-text tool call content entries in order when bounding oversized text", () => { | ||
| 697 | const hugePrefix = "x".repeat(25_000); | ||
| 698 | const hugeTail = "y".repeat(25_000); | ||
| 699 | const { events } = parseSessionUpdateEvent({ | ||
| 700 | sessionId: "session-1", | ||
| 701 | update: { | ||
| 702 | sessionUpdate: "tool_call_update", | ||
| 703 | toolCallId: "tool-1", | ||
| 704 | kind: "edit", | ||
| 705 | status: "in_progress", | ||
| 706 | content: [ | ||
| 707 | { type: "content", content: { type: "text", text: hugePrefix } }, | ||
| 708 | { type: "diff", path: "/repo/file.ts", oldText: "before", newText: "after" }, | ||
| 709 | { type: "content", content: { type: "text", text: hugeTail } }, | ||
| 710 | { type: "diff", path: "/repo/other.ts", oldText: "old", newText: "new" }, | ||
| 711 | { type: "content", content: { type: "text", text: " " } }, | ||
| 712 | ], | ||
| 713 | }, | ||
| 714 | } satisfies EffectAcpSchema.SessionNotification); | ||
| 715 | |||
| 716 | const event = events[0]; | ||
| 717 | if (event?._tag !== "ToolCallUpdated") { | ||
| 718 | throw new Error("expected a ToolCallUpdated event"); | ||
| 719 | } | ||
| 720 | const content = event.toolCall.data.content as ReadonlyArray<EffectAcpSchema.ToolCallContent>; | ||
| 721 | expect(content).toHaveLength(3); | ||
| 722 | expect(content[0]).toEqual({ | ||
| 723 | type: "diff", | ||
| 724 | path: "/repo/file.ts", | ||
| 725 | oldText: "before", | ||
| 726 | newText: "after", | ||
| 727 | }); | ||
| 728 | const lastEntry = content[1]; | ||
| 729 | if (lastEntry?.type !== "content" || lastEntry.content.type !== "text") { | ||
| 730 | throw new Error("expected a bounded text entry"); | ||
| 731 | } | ||
| 732 | expect(lastEntry.content.text.length).toBeLessThan(8_100); | ||
| 733 | expect(lastEntry.content.text.endsWith(hugeTail.slice(-100))).toBe(true); | ||
| 734 | expect(content[2]).toEqual({ | ||
| 735 | type: "diff", | ||
| 736 | path: "/repo/other.ts", | ||
| 737 | oldText: "old", | ||
| 738 | newText: "new", | ||
| 739 | }); | ||
| 740 | }); | ||
| 741 | |||
| 742 | it("keeps a retained tail on the original text entries around non-text content", () => { | ||
| 743 | const prefix = "a".repeat(4_000); | ||
| 744 | const suffix = "b".repeat(5_000); | ||
| 745 | const { events } = parseSessionUpdateEvent({ | ||
| 746 | sessionId: "session-1", | ||
| 747 | update: { | ||
| 748 | sessionUpdate: "tool_call_update", | ||
| 749 | toolCallId: "tool-1", | ||
| 750 | kind: "edit", | ||
| 751 | status: "in_progress", | ||
| 752 | content: [ | ||
| 753 | { type: "content", content: { type: "text", text: prefix } }, | ||
| 754 | { type: "diff", path: "/repo/file.ts", oldText: "before", newText: "after" }, | ||
| 755 | { type: "content", content: { type: "text", text: suffix } }, | ||
| 756 | ], | ||
| 757 | }, | ||
| 758 | } satisfies EffectAcpSchema.SessionNotification); | ||
| 759 | |||
| 760 | const event = events[0]; | ||
| 761 | if (event?._tag !== "ToolCallUpdated") { | ||
| 762 | throw new Error("expected a ToolCallUpdated event"); | ||
| 763 | } | ||
| 764 | const content = event.toolCall.data.content as ReadonlyArray<EffectAcpSchema.ToolCallContent>; | ||
| 765 | expect(content).toHaveLength(3); | ||
| 766 | const firstText = content[0]; | ||
| 767 | if (firstText?.type !== "content" || firstText.content.type !== "text") { | ||
| 768 | throw new Error("expected a bounded prefix text entry"); | ||
| 769 | } | ||
| 770 | expect(firstText.content.text.startsWith("[Earlier output truncated]")).toBe(true); | ||
| 771 | expect(firstText.content.text.endsWith("a".repeat(100))).toBe(true); | ||
| 772 | expect(content[1]).toEqual({ | ||
| 773 | type: "diff", | ||
| 774 | path: "/repo/file.ts", | ||
| 775 | oldText: "before", | ||
| 776 | newText: "after", | ||
| 777 | }); | ||
| 778 | expect(content[2]).toEqual({ | ||
| 779 | type: "content", | ||
| 780 | content: { type: "text", text: suffix }, | ||
| 781 | }); | ||
| 782 | }); | ||
| 783 | |||
| 784 | it("bounds oversized whitespace-only tool call content that has no trimmed text", () => { | ||
| 785 | // Whitespace-only entries are skipped when extracting display text (`chunks.length === 0`) | ||
| 786 | // and used to be returned unchanged, which let a redrawing terminal persist unbounded | ||
| 787 | // buffers on `toolCall.data.content` and `rawPayload`. | ||
| 788 | const hugeWhitespace = " \n\t".repeat(30_000); | ||
| 789 | expect(hugeWhitespace.length).toBeGreaterThan(60_000); | ||
| 790 | |||
| 791 | const { events } = parseSessionUpdateEvent({ | ||
| 792 | sessionId: "session-1", | ||
| 793 | update: { | ||
| 794 | sessionUpdate: "tool_call_update", | ||
| 795 | toolCallId: "tool-1", | ||
| 796 | kind: "other", | ||
| 797 | status: "in_progress", | ||
| 798 | content: [{ type: "content", content: { type: "text", text: hugeWhitespace } }], | ||
| 799 | }, | ||
| 800 | } satisfies EffectAcpSchema.SessionNotification); | ||
| 801 | |||
| 802 | const event = events[0]; | ||
| 803 | if (event?._tag !== "ToolCallUpdated") { | ||
| 804 | throw new Error("expected a ToolCallUpdated event"); | ||
| 805 | } | ||
| 806 | |||
| 807 | expect(event.toolCall.detail).toBeUndefined(); | ||
| 808 | const content = event.toolCall.data.content as ReadonlyArray<EffectAcpSchema.ToolCallContent>; | ||
| 809 | const textEntry = content[0]; | ||
| 810 | if (textEntry?.type !== "content" || textEntry.content.type !== "text") { | ||
| 811 | throw new Error("expected a bounded text entry"); | ||
| 812 | } | ||
| 813 | expect(textEntry.content.text.length).toBeLessThan(8_100); | ||
| 814 | expect(textEntry.content.text.startsWith("[Earlier output truncated]")).toBe(true); | ||
| 815 | |||
| 816 | const rawUpdate = ( | ||
| 817 | event.rawPayload as { | ||
| 818 | readonly update: { | ||
| 819 | readonly content: ReadonlyArray<{ readonly content: { text: string } }>; | ||
| 820 | }; | ||
| 821 | } | ||
| 822 | ).update; | ||
| 823 | expect(rawUpdate.content[0]?.content.text.length).toBeLessThan(8_100); | ||
| 824 | expect(JSON.stringify(event).length).toBeLessThan(hugeWhitespace.length); | ||
| 825 | }); | ||
| 826 | |||
| 827 | it("bounds oversized whitespace-padded text entries even when trimmed content fits", () => { | ||
| 828 | const padded = `${" ".repeat(40_000)}ok${" ".repeat(40_000)}`; | ||
| 829 | expect(padded.length).toBeGreaterThan(60_000); | ||
| 830 | |||
| 831 | const { events } = parseSessionUpdateEvent({ | ||
| 832 | sessionId: "session-1", | ||
| 833 | update: { | ||
| 834 | sessionUpdate: "tool_call_update", | ||
| 835 | toolCallId: "tool-1", | ||
| 836 | kind: "other", | ||
| 837 | status: "in_progress", | ||
| 838 | content: [{ type: "content", content: { type: "text", text: padded } }], | ||
| 839 | }, | ||
| 840 | } satisfies EffectAcpSchema.SessionNotification); | ||
| 841 | |||
| 842 | const event = events[0]; | ||
| 843 | if (event?._tag !== "ToolCallUpdated") { | ||
| 844 | throw new Error("expected a ToolCallUpdated event"); | ||
| 845 | } | ||
| 846 | |||
| 847 | expect(event.toolCall.detail).toBe("ok"); | ||
| 848 | const content = event.toolCall.data.content as ReadonlyArray<EffectAcpSchema.ToolCallContent>; | ||
| 849 | const textEntry = content[0]; | ||
| 850 | if (textEntry?.type !== "content" || textEntry.content.type !== "text") { | ||
| 851 | throw new Error("expected a bounded text entry"); | ||
| 852 | } | ||
| 853 | expect(textEntry.content.text).toBe("ok"); | ||
| 854 | |||
| 855 | const rawUpdate = ( | ||
| 856 | event.rawPayload as { | ||
| 857 | readonly update: { | ||
| 858 | readonly content: ReadonlyArray<{ readonly content: { text: string } }>; | ||
| 859 | }; | ||
| 860 | } | ||
| 861 | ).update; | ||
| 862 | expect(rawUpdate.content[0]?.content.text).toBe("ok"); | ||
| 863 | expect(JSON.stringify(event).length).toBeLessThan(padded.length); | ||
| 864 | }); | ||
| 865 | |||
| 866 | describe("decideToolCallUpdateEmission", () => { | ||
| 867 | const toolCall = (detail: string | undefined, status?: AcpToolCallState["status"]) => | ||
| 868 | ({ | ||
| 869 | toolCallId: "tool-1", | ||
| 870 | title: "Grok Tool", | ||
| 871 | ...(status ? { status } : {}), | ||
| 872 | ...(detail ? { detail } : {}), | ||
| 873 | data: {}, | ||
| 874 | }) satisfies AcpToolCallState; | ||
| 875 | |||
| 876 | it("emits the first in-progress tool_call even when it has no detail", () => { | ||
| 877 | expect( | ||
| 878 | decideToolCallUpdateEmission({ | ||
| 879 | previous: undefined, | ||
| 880 | next: { toolCallId: "tool-1", title: "Grok Tool", status: "pending", data: {} }, | ||
| 881 | lastEmittedDetailLength: undefined, | ||
| 882 | skippedSinceEmit: 0, | ||
| 883 | }), | ||
| 884 | ).toEqual({ emit: true, skippedSinceEmit: 0 }); | ||
| 885 | }); | ||
| 886 | |||
| 887 | it("always emits terminal (completed/failed) status updates regardless of growth", () => { | ||
| 888 | expect( | ||
| 889 | decideToolCallUpdateEmission({ | ||
| 890 | previous: toolCall("same", "inProgress"), | ||
| 891 | next: toolCall("same", "completed"), | ||
| 892 | lastEmittedDetailLength: 4, | ||
| 893 | skippedSinceEmit: 0, | ||
| 894 | }), | ||
| 895 | ).toEqual({ emit: true, skippedSinceEmit: 0 }); | ||
| 896 | |||
| 897 | expect( | ||
| 898 | decideToolCallUpdateEmission({ | ||
| 899 | previous: toolCall("same", "inProgress"), | ||
| 900 | next: toolCall("same", "failed"), | ||
| 901 | lastEmittedDetailLength: 4, | ||
| 902 | skippedSinceEmit: 3, | ||
| 903 | }), | ||
| 904 | ).toEqual({ emit: true, skippedSinceEmit: 0 }); | ||
| 905 | }); | ||
| 906 | |||
| 907 | it("skips updates whose bounded detail did not change", () => { | ||
| 908 | const previous = toolCall("frame 1", "inProgress"); | ||
| 909 | expect( | ||
| 910 | decideToolCallUpdateEmission({ | ||
| 911 | previous, | ||
| 912 | next: previous, | ||
| 913 | lastEmittedDetailLength: 7, | ||
| 914 | skippedSinceEmit: 0, | ||
| 915 | }), | ||
| 916 | ).toEqual({ emit: false, skippedSinceEmit: 0 }); | ||
| 917 | }); | ||
| 918 | |||
| 919 | it("coalesces command-tool updates whose content grew while detail stayed the command", () => { | ||
| 920 | const commandCall = (stdout: string): AcpToolCallState => ({ | ||
| 921 | toolCallId: "tool-1", | ||
| 922 | title: "Ran command", | ||
| 923 | status: "inProgress", | ||
| 924 | command: "ls", | ||
| 925 | detail: "ls", | ||
| 926 | data: { | ||
| 927 | command: "ls", | ||
| 928 | content: [{ type: "content", content: { type: "text", text: stdout } }], | ||
| 929 | }, | ||
| 930 | }); | ||
| 931 | |||
| 932 | let previous: AcpToolCallState | undefined; | ||
| 933 | let lastEmittedDetailLength: number | undefined; | ||
| 934 | let skippedSinceEmit = 0; | ||
| 935 | const emissions: Array<boolean> = []; | ||
| 936 | |||
| 937 | for (let i = 1; i <= 12; i += 1) { | ||
| 938 | const next = commandCall("x".repeat(i)); | ||
| 939 | const decision = decideToolCallUpdateEmission({ | ||
| 940 | previous, | ||
| 941 | next, | ||
| 942 | lastEmittedDetailLength, | ||
| 943 | skippedSinceEmit, | ||
| 944 | }); | ||
| 945 | emissions.push(decision.emit); | ||
| 946 | skippedSinceEmit = decision.skippedSinceEmit; | ||
| 947 | if (decision.emit) { | ||
| 948 | lastEmittedDetailLength = toolCallProgressLength(next); | ||
| 949 | } | ||
| 950 | previous = next; | ||
| 951 | } | ||
| 952 | |||
| 953 | const emittedIndices = emissions.flatMap((emitted, index) => (emitted ? [index + 1] : [])); | ||
| 954 | expect(emittedIndices).toEqual([1, 11]); | ||
| 955 | }); | ||
| 956 | |||
| 957 | it("emits pending to inProgress status changes even when detail and output are unchanged", () => { | ||
| 958 | expect( | ||
| 959 | decideToolCallUpdateEmission({ | ||
| 960 | previous: toolCall("same", "pending"), | ||
| 961 | next: toolCall("same", "inProgress"), | ||
| 962 | lastEmittedDetailLength: 4, | ||
| 963 | skippedSinceEmit: 0, | ||
| 964 | }), | ||
| 965 | ).toEqual({ emit: true, skippedSinceEmit: 0 }); | ||
| 966 | }); | ||
| 967 | |||
| 968 | it("emits immediately when the title changes, even with no growth", () => { | ||
| 969 | const decision = decideToolCallUpdateEmission({ | ||
| 970 | previous: { toolCallId: "tool-1", title: "Reading file", detail: "x", data: {} }, | ||
| 971 | next: { toolCallId: "tool-1", title: "Ran command", detail: "x", data: {} }, | ||
| 972 | lastEmittedDetailLength: 1, | ||
| 973 | skippedSinceEmit: 0, | ||
| 974 | }); | ||
| 975 | expect(decision).toEqual({ emit: true, skippedSinceEmit: 0 }); | ||
| 976 | }); | ||
| 977 | |||
| 978 | it("coalesces small deltas but forces an emission after the coalesce limit", () => { | ||
| 979 | let lastEmittedDetailLength: number | undefined = 0; | ||
| 980 | let skippedSinceEmit = 0; | ||
| 981 | const emissions: Array<boolean> = []; | ||
| 982 | let previous: AcpToolCallState | undefined; | ||
| 983 | |||
| 984 | for (let i = 1; i <= 12; i += 1) { | ||
| 985 | // Grows by 1 char per update — well under the 256-char growth threshold, so this | ||
| 986 | // exercises the coalesce-count fallback rather than the growth-based trigger. | ||
| 987 | const next = toolCall("x".repeat(i), "inProgress"); | ||
| 988 | const decision = decideToolCallUpdateEmission({ | ||
| 989 | previous, | ||
| 990 | next, | ||
| 991 | lastEmittedDetailLength, | ||
| 992 | skippedSinceEmit, | ||
| 993 | }); | ||
| 994 | emissions.push(decision.emit); | ||
| 995 | skippedSinceEmit = decision.skippedSinceEmit; | ||
| 996 | if (decision.emit) { | ||
| 997 | lastEmittedDetailLength = next.detail?.length; | ||
| 998 | } | ||
| 999 | previous = next; | ||
| 1000 | } | ||
| 1001 | |||
| 1002 | // First update always emits (no previous state yet); after that, small per-update | ||
| 1003 | // growth should be coalesced until the coalesce limit forces a periodic emission. | ||
| 1004 | const emittedIndices = emissions.flatMap((emitted, index) => (emitted ? [index + 1] : [])); | ||
| 1005 | expect(emittedIndices).toEqual([1, 11]); | ||
| 1006 | }); | ||
| 1007 | |||
| 1008 | it("retains the latest replacement snapshot when equal-length updates are coalesced", () => { | ||
| 1009 | let previous: AcpToolCallState = toolCall("frame-a", "inProgress"); | ||
| 1010 | const lastEmittedDetailLength = previous.detail?.length; | ||
| 1011 | let skippedSinceEmit = 0; | ||
| 1012 | |||
| 1013 | for (const detail of ["frame-b", "frame-c"]) { | ||
| 1014 | const next = mergeToolCallState(previous, toolCall(detail, "inProgress")); | ||
| 1015 | const decision = decideToolCallUpdateEmission({ | ||
| 1016 | previous, | ||
| 1017 | next, | ||
| 1018 | lastEmittedDetailLength, | ||
| 1019 | skippedSinceEmit, | ||
| 1020 | }); | ||
| 1021 | expect(decision.emit).toBe(false); | ||
| 1022 | skippedSinceEmit = decision.skippedSinceEmit; | ||
| 1023 | previous = next; | ||
| 1024 | } | ||
| 1025 | |||
| 1026 | const completed = mergeToolCallState(previous, toolCall(undefined, "completed")); | ||
| 1027 | expect(completed.detail).toBe("frame-c"); | ||
| 1028 | expect( | ||
| 1029 | decideToolCallUpdateEmission({ | ||
| 1030 | previous, | ||
| 1031 | next: completed, | ||
| 1032 | lastEmittedDetailLength, | ||
| 1033 | skippedSinceEmit, | ||
| 1034 | }), | ||
| 1035 | ).toEqual({ emit: true, skippedSinceEmit: 0 }); | ||
| 1036 | }); | ||
| 1037 | }); | ||
| 1038 | |||
| 1039 | it("preserves message identity, usage, metadata, and non-text output", () => { | ||
| 1040 | const message = parseSessionUpdateEvent({ | ||
| 1041 | sessionId: "session-1", | ||
| 1042 | update: { | ||
| 1043 | sessionUpdate: "agent_message_chunk", | ||
| 1044 | messageId: "message-1", | ||
| 1045 | content: { | ||
| 1046 | type: "resource_link", | ||
| 1047 | name: "report.txt", | ||
| 1048 | title: "Report", | ||
| 1049 | description: "Generated artifact", | ||
| 1050 | uri: "file:///workspace/report.txt", | ||
| 1051 | }, | ||
| 1052 | }, | ||
| 1053 | } satisfies EffectAcpSchema.SessionNotification); | ||
| 1054 | const usage = parseSessionUpdateEvent({ | ||
| 1055 | sessionId: "session-1", | ||
| 1056 | update: { | ||
| 1057 | sessionUpdate: "usage_update", | ||
| 1058 | used: 2_500, | ||
| 1059 | size: 10_000, | ||
| 1060 | cost: { amount: 0.42, currency: "USD" }, | ||
| 1061 | }, | ||
| 1062 | } satisfies EffectAcpSchema.SessionNotification); | ||
| 1063 | const metadata = parseSessionUpdateEvent({ | ||
| 1064 | sessionId: "session-1", | ||
| 1065 | update: { | ||
| 1066 | sessionUpdate: "session_info_update", | ||
| 1067 | title: " Native session ", | ||
| 1068 | updatedAt: " 2026-08-23T00:00:00Z ", | ||
| 1069 | }, | ||
| 1070 | } satisfies EffectAcpSchema.SessionNotification); | ||
| 1071 | |||
| 1072 | expect(message.events[0]).toMatchObject({ | ||
| 1073 | _tag: "ContentDelta", | ||
| 1074 | messageId: "message-1", | ||
| 1075 | text: "Report: Generated artifact\nfile:///workspace/report.txt", | ||
| 1076 | }); | ||
| 1077 | expect(usage.events[0]).toMatchObject({ | ||
| 1078 | _tag: "UsageUpdated", | ||
| 1079 | usage: { | ||
| 1080 | usedTokens: 2_500, | ||
| 1081 | maxTokens: 10_000, | ||
| 1082 | cost: { amount: 0.42, currency: "USD" }, | ||
| 1083 | }, | ||
| 1084 | }); | ||
| 1085 | expect(metadata.events[0]).toMatchObject({ | ||
| 1086 | _tag: "SessionInfoUpdated", | ||
| 1087 | metadata: { | ||
| 1088 | title: "Native session", | ||
| 1089 | updatedAt: "2026-08-23T00:00:00Z", | ||
| 1090 | }, | ||
| 1091 | }); | ||
| 1092 | }); | ||
| 1093 | |||
| 1094 | it("projects ACP v2 plan replacement and removal updates", () => { | ||
| 1095 | const replaced = parseSessionUpdateEvent({ | ||
| 1096 | sessionId: "session-1", | ||
| 1097 | update: { | ||
| 1098 | sessionUpdate: "plan_update", | ||
| 1099 | plan: { | ||
| 1100 | type: "items", | ||
| 1101 | planId: "plan-1", | ||
| 1102 | entries: [ | ||
| 1103 | { content: " Inspect schema ", priority: "high", status: "completed" }, | ||
| 1104 | { content: "Ship integration", priority: "high", status: "in_progress" }, | ||
| 1105 | ], | ||
| 1106 | }, | ||
| 1107 | }, | ||
| 1108 | } satisfies EffectAcpSchema.SessionNotification); | ||
| 1109 | const removed = parseSessionUpdateEvent({ | ||
| 1110 | sessionId: "session-1", | ||
| 1111 | update: { sessionUpdate: "plan_removed", planId: "plan-1" }, | ||
| 1112 | } satisfies EffectAcpSchema.SessionNotification); | ||
| 1113 | |||
| 1114 | expect(replaced.events[0]).toMatchObject({ | ||
| 1115 | _tag: "PlanUpdated", | ||
| 1116 | payload: { | ||
| 1117 | nativePlanId: "plan-1", | ||
| 1118 | kind: "items", | ||
| 1119 | plan: [ | ||
| 1120 | { step: "Inspect schema", status: "completed" }, | ||
| 1121 | { step: "Ship integration", status: "inProgress" }, | ||
| 1122 | ], | ||
| 1123 | }, | ||
| 1124 | }); | ||
| 1125 | expect(removed.events[0]).toMatchObject({ | ||
| 1126 | _tag: "PlanUpdated", | ||
| 1127 | payload: { nativePlanId: "plan-1", kind: "removed" }, | ||
| 1128 | }); | ||
| 1129 | }); | ||
| 1130 | |||
| 1131 | it("preserves markdown, file, and future ACP plan variants by plan ID", () => { | ||
| 1132 | const updates = [ | ||
| 1133 | { | ||
| 1134 | sessionId: "session-1", | ||
| 1135 | update: { | ||
| 1136 | sessionUpdate: "plan_update", | ||
| 1137 | plan: { type: "markdown", planId: "plan-markdown", content: "# Ship it" }, | ||
| 1138 | }, | ||
| 1139 | }, | ||
| 1140 | { | ||
| 1141 | sessionId: "session-1", | ||
| 1142 | update: { | ||
| 1143 | sessionUpdate: "plan_update", | ||
| 1144 | plan: { type: "file", planId: "plan-file", uri: "file:///workspace/PLAN.md" }, | ||
| 1145 | }, | ||
| 1146 | }, | ||
| 1147 | { | ||
| 1148 | sessionId: "session-1", | ||
| 1149 | update: { | ||
| 1150 | sessionUpdate: "plan_update", | ||
| 1151 | plan: { type: "diagram", planId: "plan-future", nodes: [] }, | ||
| 1152 | }, | ||
| 1153 | }, | ||
| 1154 | ] as const; | ||
| 1155 | |||
| 1156 | expect(updates.flatMap((update) => parseSessionUpdateEvent(update).events)).toMatchObject([ | ||
| 1157 | { | ||
| 1158 | _tag: "PlanUpdated", | ||
| 1159 | payload: { nativePlanId: "plan-markdown", kind: "markdown", markdown: "# Ship it" }, | ||
| 1160 | }, | ||
| 1161 | { | ||
| 1162 | _tag: "PlanUpdated", | ||
| 1163 | payload: { | ||
| 1164 | nativePlanId: "plan-file", | ||
| 1165 | kind: "file", | ||
| 1166 | uri: "file:///workspace/PLAN.md", | ||
| 1167 | }, | ||
| 1168 | }, | ||
| 1169 | { | ||
| 1170 | _tag: "PlanUpdated", | ||
| 1171 | payload: { nativePlanId: "plan-future", kind: "unknown", contentType: "diagram" }, | ||
| 1172 | }, | ||
| 1173 | ]); | ||
| 1174 | }); | ||
| 1175 | |||
| 1176 | it("turns future ACP content and session updates into explicit placeholders", () => { | ||
| 1177 | expect( | ||
| 1178 | acpContentBlockDisplayText({ | ||
| 1179 | type: "_t3_unknown", | ||
| 1180 | originalType: "chart", | ||
| 1181 | raw: { type: "chart", points: [] }, | ||
| 1182 | }), | ||
| 1183 | ).toBe("[Unsupported ACP content: chart]"); | ||
| 1184 | |||
| 1185 | expect( | ||
| 1186 | parseSessionUpdateEvent({ | ||
| 1187 | sessionId: "session-1", | ||
| 1188 | update: { | ||
| 1189 | sessionUpdate: "_t3_unknown", | ||
| 1190 | originalSessionUpdate: "timeline_update", | ||
| 1191 | raw: { sessionUpdate: "timeline_update", entries: [] }, | ||
| 1192 | }, | ||
| 1193 | }).events, | ||
| 1194 | ).toMatchObject([{ _tag: "UnknownUpdate", updateType: "timeline_update" }]); | ||
| 1195 | }); | ||
| 1196 | |||
| 1197 | it("projects ACP v2 compaction lifecycle and prompt usage", () => { | ||
| 1198 | const compacting = parseSessionUpdateEvent({ | ||
| 1199 | sessionId: "session-1", | ||
| 1200 | update: { | ||
| 1201 | sessionUpdate: "compaction_update", | ||
| 1202 | compactionId: "compact-1", | ||
| 1203 | status: "in_progress", | ||
| 1204 | }, | ||
| 1205 | } satisfies EffectAcpSchema.SessionNotification); | ||
| 1206 | const compacted = parseSessionUpdateEvent({ | ||
| 1207 | sessionId: "session-1", | ||
| 1208 | update: { | ||
| 1209 | sessionUpdate: "compaction_update", | ||
| 1210 | compactionId: "compact-1", | ||
| 1211 | status: "completed", | ||
| 1212 | summary: [{ type: "text", text: "Retained the implementation decisions." }], | ||
| 1213 | }, | ||
| 1214 | } satisfies EffectAcpSchema.SessionNotification); | ||
| 1215 | const idle = parseSessionUpdateEvent({ | ||
| 1216 | sessionId: "session-1", | ||
| 1217 | update: { | ||
| 1218 | sessionUpdate: "state_update", | ||
| 1219 | state: "idle", | ||
| 1220 | stopReason: "end_turn", | ||
| 1221 | usage: { totalTokens: 420, inputTokens: 300, outputTokens: 100, thoughtTokens: 20 }, | ||
| 1222 | }, | ||
| 1223 | } satisfies EffectAcpSchema.SessionNotification); | ||
| 1224 | |||
| 1225 | expect(compacting.events[0]).toMatchObject({ | ||
| 1226 | _tag: "ToolCallUpdated", | ||
| 1227 | toolCall: { | ||
| 1228 | toolCallId: "acp-compaction:compact-1", | ||
| 1229 | kind: "think", | ||
| 1230 | title: "Compact context", | ||
| 1231 | status: "inProgress", | ||
| 1232 | }, | ||
| 1233 | }); | ||
| 1234 | expect(compacted.events[0]).toMatchObject({ | ||
| 1235 | _tag: "ToolCallUpdated", | ||
| 1236 | toolCall: { | ||
| 1237 | toolCallId: "acp-compaction:compact-1", | ||
| 1238 | status: "completed", | ||
| 1239 | data: { rawOutput: "Retained the implementation decisions." }, | ||
| 1240 | }, | ||
| 1241 | }); | ||
| 1242 | expect(idle.events[0]).toMatchObject({ | ||
| 1243 | _tag: "UsageUpdated", | ||
| 1244 | usage: { usedTokens: 420 }, | ||
| 1245 | }); | ||
| 1246 | }); | ||
| 1247 | |||
| 1248 | it("applies ACP v2 agent-owned terminal snapshots and output chunks", () => { | ||
| 1249 | const snapshot = applyAcpAgentTerminalUpdate(undefined, { | ||
| 1250 | sessionUpdate: "terminal_update", | ||
| 1251 | terminalId: "terminal-1", | ||
| 1252 | command: "pnpm test", | ||
| 1253 | cwd: "/workspace", | ||
| 1254 | output: { data: Buffer.from("first line\n").toString("base64") }, | ||
| 1255 | }); | ||
| 1256 | const chunked = applyAcpAgentTerminalUpdate(snapshot, { | ||
| 1257 | sessionUpdate: "terminal_output_chunk", | ||
| 1258 | terminalId: "terminal-1", | ||
| 1259 | data: Buffer.from("second line\n").toString("base64"), | ||
| 1260 | }); | ||
| 1261 | const completed = applyAcpAgentTerminalUpdate(chunked, { | ||
| 1262 | sessionUpdate: "terminal_update", | ||
| 1263 | terminalId: "terminal-1", | ||
| 1264 | exitStatus: { exitCode: 0 }, | ||
| 1265 | }); | ||
| 1266 | |||
| 1267 | expect(completed).toEqual({ | ||
| 1268 | command: "pnpm test", | ||
| 1269 | cwd: "/workspace", | ||
| 1270 | output: "first line\nsecond line\n", | ||
| 1271 | exitStatus: { exitCode: 0 }, | ||
| 1272 | }); | ||
| 1273 | }); | ||
| 1274 | |||
| 1275 | it("preserves UTF-8 characters split across ACP terminal output chunks", () => { | ||
| 1276 | const bytes = Buffer.from("A🙂B", "utf8"); | ||
| 1277 | const first = applyAcpAgentTerminalUpdate(undefined, { | ||
| 1278 | sessionUpdate: "terminal_output_chunk", | ||
| 1279 | terminalId: "terminal-utf8", | ||
| 1280 | data: bytes.subarray(0, 3).toString("base64"), | ||
| 1281 | }); | ||
| 1282 | const second = applyAcpAgentTerminalUpdate(first, { | ||
| 1283 | sessionUpdate: "terminal_output_chunk", | ||
| 1284 | terminalId: "terminal-utf8", | ||
| 1285 | data: bytes.subarray(3).toString("base64"), | ||
| 1286 | }); | ||
| 1287 | |||
| 1288 | expect(first.output).toBe("A"); | ||
| 1289 | expect(first.pendingOutputBytes).toBeDefined(); | ||
| 1290 | expect(second.output).toBe("A🙂B"); | ||
| 1291 | expect(second.pendingOutputBytes).toBeUndefined(); | ||
| 1292 | }); | ||
| 1293 | |||
| 1294 | it("drops malformed and oversized ACP terminal frames without decoding them", () => { | ||
| 1295 | const seeded = applyAcpAgentTerminalUpdate(undefined, { | ||
| 1296 | sessionUpdate: "terminal_output_chunk", | ||
| 1297 | terminalId: "terminal-invalid", | ||
| 1298 | data: Buffer.from("kept").toString("base64"), | ||
| 1299 | }); | ||
| 1300 | |||
| 1301 | expect( | ||
| 1302 | applyAcpAgentTerminalUpdate(seeded, { | ||
| 1303 | sessionUpdate: "terminal_output_chunk", | ||
| 1304 | terminalId: "terminal-invalid", | ||
| 1305 | data: "not base64!", | ||
| 1306 | }), | ||
| 1307 | ).toBe(seeded); | ||
| 1308 | |||
| 1309 | expect( | ||
| 1310 | applyAcpAgentTerminalUpdate(seeded, { | ||
| 1311 | sessionUpdate: "terminal_output_chunk", | ||
| 1312 | terminalId: "terminal-oversized", | ||
| 1313 | data: Buffer.alloc(16 * 1024 * 1024 + 1).toString("base64"), | ||
| 1314 | }), | ||
| 1315 | ).toBe(seeded); | ||
| 1316 | }); | ||
| 1317 | |||
| 1318 | it("projects binary ACP content without retaining encoded payloads", () => { | ||
| 1319 | const encodedPayload = "sensitive-base64-payload"; | ||
| 1320 | const binary = acpContentBlockDisplayText({ | ||
| 1321 | type: "resource", | ||
| 1322 | resource: { | ||
| 1323 | blob: encodedPayload, | ||
| 1324 | mimeType: "application/octet-stream", | ||
| 1325 | uri: "file:///workspace/archive.bin", | ||
| 1326 | }, | ||
| 1327 | }); | ||
| 1328 | const image = acpContentBlockDisplayText({ | ||
| 1329 | type: "image", | ||
| 1330 | data: encodedPayload, | ||
| 1331 | mimeType: "image/png", | ||
| 1332 | uri: "file:///workspace/image.png", | ||
| 1333 | }); | ||
| 1334 | const audio = acpContentBlockDisplayText({ | ||
| 1335 | type: "audio", | ||
| 1336 | data: encodedPayload, | ||
| 1337 | mimeType: "audio/wav", | ||
| 1338 | }); | ||
| 1339 | |||
| 1340 | expect(binary).toBe( | ||
| 1341 | "[ACP binary resource (application/octet-stream): file:///workspace/archive.bin]", | ||
| 1342 | ); | ||
| 1343 | expect(image).toBe("[ACP image (image/png): file:///workspace/image.png]"); | ||
| 1344 | expect(audio).toBe("[ACP audio (audio/wav)]"); | ||
| 1345 | expect(`${binary}${image}${audio}`).not.toContain(encodedPayload); | ||
| 1346 | |||
| 1347 | const toolUpdate = parseSessionUpdateEvent({ | ||
| 1348 | sessionId: "session-1", | ||
| 1349 | update: { | ||
| 1350 | sessionUpdate: "tool_call", | ||
| 1351 | toolCallId: "tool-with-image", | ||
| 1352 | title: "Generated image", | ||
| 1353 | content: [ | ||
| 1354 | { | ||
| 1355 | type: "content", | ||
| 1356 | content: { | ||
| 1357 | type: "image", | ||
| 1358 | data: encodedPayload, | ||
| 1359 | mimeType: "image/png", | ||
| 1360 | uri: `data:image/png;base64,${encodedPayload}`, | ||
| 1361 | }, | ||
| 1362 | }, | ||
| 1363 | ], | ||
| 1364 | }, | ||
| 1365 | } satisfies EffectAcpSchema.SessionNotification); | ||
| 1366 | const toolEvent = toolUpdate.events.find((event) => event._tag === "ToolCallUpdated"); | ||
| 1367 | expect(JSON.stringify(toolEvent?.toolCall)).not.toContain(encodedPayload); | ||
| 1368 | expect(toolUpdate.events[0]).toMatchObject({ | ||
| 1369 | _tag: "ToolCallUpdated", | ||
| 1370 | toolCall: { | ||
| 1371 | data: { | ||
| 1372 | content: [ | ||
| 1373 | { type: "content", content: { type: "text", text: "[ACP image (image/png)]" } }, | ||
| 1374 | ], | ||
| 1375 | }, | ||
| 1376 | }, | ||
| 1377 | }); | ||
| 1378 | }); | ||
| 1379 | |||
| 1380 | it("bounds text-bearing ACP content without changing streamed whitespace", () => { | ||
| 1381 | const text = ` ${"x".repeat(70_000)} `; | ||
| 1382 | const projectedText = acpContentBlockDisplayText({ type: "text", text }); | ||
| 1383 | const projectedResource = acpContentBlockDisplayText({ | ||
| 1384 | type: "resource", | ||
| 1385 | resource: { uri: "file:///workspace/large.txt", text }, | ||
| 1386 | }); | ||
| 1387 | |||
| 1388 | expect(projectedText).toHaveLength(65_536); | ||
| 1389 | expect(projectedText?.startsWith(" ")).toBe(true); | ||
| 1390 | expect(projectedResource).toEqual(projectedText); | ||
| 1391 | }); | ||
| 1392 | }); | ||
| 1393 | |||
| 1394 | describe("extractMcpToolCallIdentity", () => { | ||
| 1395 | function toolCallFromUpdate( | ||
| 1396 | update: EffectAcpSchema.SessionNotification["update"], | ||
| 1397 | ): NonNullable<ReturnType<typeof parsePermissionRequest>["toolCall"]> { | ||
| 1398 | const parsed = parseSessionUpdateEvent({ sessionId: "session-1", update }); | ||
| 1399 | const event = parsed.events.find( | ||
| 1400 | ( | ||
| 1401 | candidate, | ||
| 1402 | ): candidate is Extract<(typeof parsed.events)[number], { _tag: "ToolCallUpdated" }> => | ||
| 1403 | candidate._tag === "ToolCallUpdated", | ||
| 1404 | ); | ||
| 1405 | if (event === undefined) throw new Error("expected a tool call event"); | ||
| 1406 | return event.toolCall; | ||
| 1407 | } | ||
| 1408 | |||
| 1409 | it("recovers server and tool from codex-acp tagged execute calls", () => { | ||
| 1410 | // Captured verbatim from codex-acp 2026-08-14: MCP calls arrive as kind | ||
| 1411 | // "execute" with the identity only in rawInput. | ||
| 1412 | const toolCall = toolCallFromUpdate({ | ||
| 1413 | sessionUpdate: "tool_call", | ||
| 1414 | toolCallId: "exec-f4591587-0754-4bb4-990b-f2767894ba93", | ||
| 1415 | kind: "execute", | ||
| 1416 | title: "mcp.t3-code.orchestrator_capabilities", | ||
| 1417 | status: "in_progress", | ||
| 1418 | rawInput: { server: "t3-code", tool: "orchestrator_capabilities", arguments: {} }, | ||
| 1419 | _meta: { is_mcp_tool_call: true }, | ||
| 1420 | }); | ||
| 1421 | |||
| 1422 | expect(extractMcpToolCallIdentity(toolCall)).toEqual({ | ||
| 1423 | server: "t3-code", | ||
| 1424 | tool: "orchestrator_capabilities", | ||
| 1425 | }); | ||
| 1426 | }); | ||
| 1427 | |||
| 1428 | it("recovers T3 identity from acp-mcp-call fallback commands", () => { | ||
| 1429 | const toolCall = toolCallFromUpdate({ | ||
| 1430 | sessionUpdate: "tool_call", | ||
| 1431 | toolCallId: "exec-1", | ||
| 1432 | kind: "execute", | ||
| 1433 | title: "Ran command", | ||
| 1434 | status: "in_progress", | ||
| 1435 | }); | ||
| 1436 | |||
| 1437 | expect( | ||
| 1438 | extractMcpToolCallIdentity(toolCall, { | ||
| 1439 | embeddedTerminalCommands: [ | ||
| 1440 | '/usr/bin/node /srv/t3/bin.ts acp-mcp-call delegate_task {"task":"x"}', | ||
| 1441 | ], | ||
| 1442 | }), | ||
| 1443 | ).toEqual({ server: "t3-code", tool: "delegate_task", input: { task: "x" } }); | ||
| 1444 | }); | ||
| 1445 | |||
| 1446 | it("recovers T3 identity from pi-acp title-only fallback execs", () => { | ||
| 1447 | // Captured verbatim from pi-acp 0.0.33 2026-08-14: rawInput is null and | ||
| 1448 | // the command line only appears as the verbatim title, which the | ||
| 1449 | // presentation layer summarizes into "Ran command". | ||
| 1450 | const toolCall = toolCallFromUpdate({ | ||
| 1451 | sessionUpdate: "tool_call", | ||
| 1452 | toolCallId: "call_JdxnvzjHHrbvyASTLVekLYWV|fc_08f5a805a7159aa6016a7ec4afad548191", | ||
| 1453 | kind: "execute", | ||
| 1454 | title: | ||
| 1455 | '"$T3_ACP_MCP_NODE" "$T3_ACP_MCP_ENTRYPOINT" acp-mcp-call orchestrator_capabilities \'{}\'', | ||
| 1456 | status: "in_progress", | ||
| 1457 | rawInput: null, | ||
| 1458 | content: [ | ||
| 1459 | { | ||
| 1460 | type: "terminal", | ||
| 1461 | terminalId: "call_JdxnvzjHHrbvyASTLVekLYWV|fc_08f5a805a7159aa6016a7ec4afad548191", | ||
| 1462 | }, | ||
| 1463 | ], | ||
| 1464 | }); | ||
| 1465 | |||
| 1466 | expect(toolCall.title).toBe("Ran command"); | ||
| 1467 | expect(extractMcpToolCallIdentity(toolCall)).toEqual({ | ||
| 1468 | server: "t3-code", | ||
| 1469 | tool: "orchestrator_capabilities", | ||
| 1470 | input: {}, | ||
| 1471 | }); | ||
| 1472 | }); | ||
| 1473 | |||
| 1474 | it("recovers T3 identity from server-namespaced titles across titleless updates", () => { | ||
| 1475 | // Captured verbatim from Kilo 7.4.22 2026-08-15: the initial tool_call | ||
| 1476 | // titles the MCP function "<server>_<tool>" with kind "other", and the | ||
| 1477 | // completed update carries no title at all, so the merged presentation | ||
| 1478 | // title regresses to "Tool" while data.title keeps the wire value. | ||
| 1479 | const created = toolCallFromUpdate({ | ||
| 1480 | sessionUpdate: "tool_call", | ||
| 1481 | toolCallId: "chatcmpl-tool-b2a6142ee1a510a5", | ||
| 1482 | kind: "other", | ||
| 1483 | title: "t3-code_orchestrator_capabilities", | ||
| 1484 | status: "pending", | ||
| 1485 | locations: [], | ||
| 1486 | rawInput: {}, | ||
| 1487 | }); | ||
| 1488 | const completed = toolCallFromUpdate({ | ||
| 1489 | sessionUpdate: "tool_call_update", | ||
| 1490 | toolCallId: "chatcmpl-tool-b2a6142ee1a510a5", | ||
| 1491 | status: "completed", | ||
| 1492 | content: [{ type: "content", content: { type: "text", text: '{"ok":true}' } }], | ||
| 1493 | }); | ||
| 1494 | const merged = mergeToolCallState(created, completed); | ||
| 1495 | |||
| 1496 | expect(extractMcpToolCallIdentity(merged)).toEqual({ | ||
| 1497 | server: "t3-code", | ||
| 1498 | tool: "orchestrator_capabilities", | ||
| 1499 | }); | ||
| 1500 | }); | ||
| 1501 | |||
| 1502 | it("recovers T3 identity from Gemini and qwen MCP-server title templates", () => { | ||
| 1503 | // Gemini CLI 0.55.1: "<tool> (<server> MCP Server)"; qwen-code 0.21.12 | ||
| 1504 | // appends ": <args json>" to the same template. | ||
| 1505 | const gemini = toolCallFromUpdate({ | ||
| 1506 | sessionUpdate: "tool_call", | ||
| 1507 | toolCallId: "gemini-1", | ||
| 1508 | kind: "other", | ||
| 1509 | title: "delegate_task (t3-code MCP Server)", | ||
| 1510 | status: "in_progress", | ||
| 1511 | }); | ||
| 1512 | const qwen = toolCallFromUpdate({ | ||
| 1513 | sessionUpdate: "tool_call", | ||
| 1514 | toolCallId: "qwen-1", | ||
| 1515 | kind: "other", | ||
| 1516 | title: 'task_status (t3-code MCP Server): {"taskId":"node:delegated-task:1"}', | ||
| 1517 | status: "pending", | ||
| 1518 | rawInput: { taskId: "node:delegated-task:1" }, | ||
| 1519 | }); | ||
| 1520 | |||
| 1521 | expect(extractMcpToolCallIdentity(gemini)).toEqual({ | ||
| 1522 | server: "t3-code", | ||
| 1523 | tool: "delegate_task", | ||
| 1524 | }); | ||
| 1525 | expect(extractMcpToolCallIdentity(qwen)).toEqual({ server: "t3-code", tool: "task_status" }); | ||
| 1526 | }); | ||
| 1527 | |||
| 1528 | it("recovers T3 identity across the registry agents' naming conventions", () => { | ||
| 1529 | // One representative per surveyed convention (2026-08 registry builds): | ||
| 1530 | // droid triple underscore, Copilot hyphen, Amp mangled server + detail | ||
| 1531 | // tail, cline args tail, Auggie tool-first suffix. | ||
| 1532 | // One representative per surveyed convention (2026-08 registry builds): | ||
| 1533 | // droid triple underscore, Copilot hyphen, Amp mangled server + detail | ||
| 1534 | // tail, cline args tail, Auggie tool-first suffix, fast-agent slash, | ||
| 1535 | // Kimi bare name with args tail. | ||
| 1536 | for (const title of [ | ||
| 1537 | "t3-code___delegate_task", | ||
| 1538 | "t3-code-delegate_task", | ||
| 1539 | 'mcp__t3_code__delegate_task: {"mode":"async"}', | ||
| 1540 | 't3-code__delegate_task: {"mode":"async"}', | ||
| 1541 | "delegate_task_t3-code", | ||
| 1542 | "t3-code/delegate_task", | ||
| 1543 | 'delegate_task: {"mode":"async"}', | ||
| 1544 | ]) { | ||
| 1545 | const toolCall = toolCallFromUpdate({ | ||
| 1546 | sessionUpdate: "tool_call", | ||
| 1547 | toolCallId: "convention-1", | ||
| 1548 | kind: "other", | ||
| 1549 | title, | ||
| 1550 | status: "pending", | ||
| 1551 | }); | ||
| 1552 | expect(extractMcpToolCallIdentity(toolCall), title).toEqual({ | ||
| 1553 | server: "t3-code", | ||
| 1554 | tool: "delegate_task", | ||
| 1555 | }); | ||
| 1556 | } | ||
| 1557 | }); | ||
| 1558 | |||
| 1559 | it("recovers T3 identity from goose _meta despite LLM-rewritten titles", () => { | ||
| 1560 | // goose enriches titles asynchronously, so only _meta.goose.toolCall is | ||
| 1561 | // stable; shape from crates/goose/src/acp/server/tool_calls/conversion.rs. | ||
| 1562 | const toolCall = toolCallFromUpdate({ | ||
| 1563 | sessionUpdate: "tool_call", | ||
| 1564 | toolCallId: "goose-1", | ||
| 1565 | title: "Checking on the delegated task", | ||
| 1566 | status: "in_progress", | ||
| 1567 | _meta: { | ||
| 1568 | goose: { | ||
| 1569 | toolCall: { toolName: "t3-code__task_status", extensionName: "t3-code" }, | ||
| 1570 | messageId: "message-1", | ||
| 1571 | }, | ||
| 1572 | }, | ||
| 1573 | }); | ||
| 1574 | |||
| 1575 | expect(extractMcpToolCallIdentity(toolCall)).toEqual({ | ||
| 1576 | server: "t3-code", | ||
| 1577 | tool: "task_status", | ||
| 1578 | }); | ||
| 1579 | }); | ||
| 1580 | |||
| 1581 | it("recovers T3 identity from qwen serverId meta regardless of prefix format", () => { | ||
| 1582 | // qwen-code 0.21.12 emits _meta.serverId + _meta.toolName; serverId is an | ||
| 1583 | // explicit origin assertion, so a known tool suffix suffices even if the | ||
| 1584 | // prefix format changes. | ||
| 1585 | const toolCall = toolCallFromUpdate({ | ||
| 1586 | sessionUpdate: "tool_call", | ||
| 1587 | toolCallId: "qwen-meta-1", | ||
| 1588 | kind: "other", | ||
| 1589 | title: "unrelated display title", | ||
| 1590 | status: "pending", | ||
| 1591 | _meta: { toolName: "mcp::t3-code::t3_thread_send", serverId: "t3-code", provenance: "mcp" }, | ||
| 1592 | }); | ||
| 1593 | |||
| 1594 | expect(extractMcpToolCallIdentity(toolCall)).toEqual({ | ||
| 1595 | server: "t3-code", | ||
| 1596 | tool: "t3_thread_send", | ||
| 1597 | }); | ||
| 1598 | }); | ||
| 1599 | |||
| 1600 | it.each([ | ||
| 1601 | { _meta: { claudeCode: { toolName: "mcp__weather__get_weather" } } }, | ||
| 1602 | { _meta: { toolName: "mcp::weather::get_weather", serverId: "weather" } }, | ||
| 1603 | ])("recovers external MCP identity from provider metadata", (metadata) => { | ||
| 1604 | const toolCall = toolCallFromUpdate({ | ||
| 1605 | sessionUpdate: "tool_call", | ||
| 1606 | toolCallId: "weather-call", | ||
| 1607 | title: "Checking the forecast", | ||
| 1608 | status: "in_progress", | ||
| 1609 | ...metadata, | ||
| 1610 | }); | ||
| 1611 | expect(extractMcpToolCallIdentity(toolCall)).toEqual({ | ||
| 1612 | server: "weather", | ||
| 1613 | tool: "get_weather", | ||
| 1614 | }); | ||
| 1615 | }); | ||
| 1616 | |||
| 1617 | it.each([ | ||
| 1618 | { kind: "execute", toolName: "developer__shell", extensionName: "developer" }, | ||
| 1619 | { kind: "edit", toolName: "developer__edit", extensionName: "developer" }, | ||
| 1620 | { kind: "other", toolName: "weather__get_weather", extensionName: "weather" }, | ||
| 1621 | ] as const)( | ||
| 1622 | "leaves goose $toolName unclassified so built-ins keep their projection", | ||
| 1623 | ({ kind, toolName, extensionName }) => { | ||
| 1624 | // goose tags built-in extensions exactly like user MCP servers. | ||
| 1625 | const toolCall = toolCallFromUpdate({ | ||
| 1626 | sessionUpdate: "tool_call", | ||
| 1627 | toolCallId: "goose-builtin", | ||
| 1628 | kind, | ||
| 1629 | title: "developer: shell", | ||
| 1630 | status: "in_progress", | ||
| 1631 | _meta: { goose: { toolCall: { toolName, extensionName } } }, | ||
| 1632 | }); | ||
| 1633 | expect(extractMcpToolCallIdentity(toolCall)).toBeUndefined(); | ||
| 1634 | }, | ||
| 1635 | ); | ||
| 1636 | |||
| 1637 | it("uses the asserted server instead of a misleading T3 title", () => { | ||
| 1638 | const toolCall = toolCallFromUpdate({ | ||
| 1639 | sessionUpdate: "tool_call", | ||
| 1640 | toolCallId: "foreign-1", | ||
| 1641 | kind: "other", | ||
| 1642 | title: "t3-code_delegate_task", | ||
| 1643 | status: "pending", | ||
| 1644 | _meta: { toolName: "delegate_task", serverId: "other-orchestrator" }, | ||
| 1645 | }); | ||
| 1646 | |||
| 1647 | expect(extractMcpToolCallIdentity(toolCall)).toEqual({ | ||
| 1648 | server: "other-orchestrator", | ||
| 1649 | tool: "delegate_task", | ||
| 1650 | }); | ||
| 1651 | }); | ||
| 1652 | |||
| 1653 | it("does not brand path-like or unknown-tool titles", () => { | ||
| 1654 | for (const title of ["t3-code/README.md", "t3-code_not_a_real_tool"]) { | ||
| 1655 | const toolCall = toolCallFromUpdate({ | ||
| 1656 | sessionUpdate: "tool_call", | ||
| 1657 | toolCallId: "path-1", | ||
| 1658 | kind: "other", | ||
| 1659 | title, | ||
| 1660 | status: "pending", | ||
| 1661 | }); | ||
| 1662 | expect(extractMcpToolCallIdentity(toolCall), title).toBeUndefined(); | ||
| 1663 | } | ||
| 1664 | }); | ||
| 1665 | |||
| 1666 | it("leaves ordinary execute calls unidentified", () => { | ||
| 1667 | const toolCall = toolCallFromUpdate({ | ||
| 1668 | sessionUpdate: "tool_call", | ||
| 1669 | toolCallId: "exec-2", | ||
| 1670 | kind: "execute", | ||
| 1671 | title: "cat package.json", | ||
| 1672 | status: "in_progress", | ||
| 1673 | rawInput: { command: "cat package.json" }, | ||
| 1674 | }); | ||
| 1675 | |||
| 1676 | expect(extractMcpToolCallIdentity(toolCall)).toBeUndefined(); | ||
| 1677 | expect( | ||
| 1678 | extractMcpToolCallIdentity(toolCall, { embeddedTerminalCommands: ["bash -lc ls"] }), | ||
| 1679 | ).toBeUndefined(); | ||
| 1680 | }); | ||
| 1681 | |||
| 1682 | it("does not treat generic raw server and tool fields as MCP provenance", () => { | ||
| 1683 | const toolCall = toolCallFromUpdate({ | ||
| 1684 | sessionUpdate: "tool_call", | ||
| 1685 | toolCallId: "generic-1", | ||
| 1686 | kind: "other", | ||
| 1687 | title: "Deploy", | ||
| 1688 | status: "pending", | ||
| 1689 | rawInput: { server: "production", tool: "deploy" }, | ||
| 1690 | }); | ||
| 1691 | |||
| 1692 | expect(extractMcpToolCallIdentity(toolCall)).toBeUndefined(); | ||
| 1693 | }); | ||
| 1694 | }); | ||
| 1695 | |||
| 1696 | describe("embeddedTerminalIdsFromSessionUpdate", () => { | ||
| 1697 | it("collects terminal ids from tool_call content before the text rewrite", () => { | ||
| 1698 | expect( | ||
| 1699 | embeddedTerminalIdsFromSessionUpdate({ | ||
| 1700 | sessionId: "session-1", | ||
| 1701 | update: { | ||
| 1702 | sessionUpdate: "tool_call_update", | ||
| 1703 | toolCallId: "call_1", | ||
| 1704 | status: "in_progress", | ||
| 1705 | content: [ | ||
| 1706 | { type: "terminal", terminalId: "t3-term-9" }, | ||
| 1707 | { type: "content", content: { type: "text", text: "noise" } }, | ||
| 1708 | ], | ||
| 1709 | }, | ||
| 1710 | }), | ||
| 1711 | ).toEqual({ toolCallId: "call_1", terminalIds: ["t3-term-9"] }); | ||
| 1712 | expect( | ||
| 1713 | embeddedTerminalIdsFromSessionUpdate({ | ||
| 1714 | sessionId: "session-1", | ||
| 1715 | update: { | ||
| 1716 | sessionUpdate: "tool_call", | ||
| 1717 | toolCallId: "call_2", | ||
| 1718 | title: "Tool", | ||
| 1719 | status: "pending", | ||
| 1720 | }, | ||
| 1721 | }), | ||
| 1722 | ).toBeUndefined(); | ||
| 1723 | }); | ||
| 1724 | }); | ||
apps/server/src/provider/acp/AcpRuntimeModel.ts deleted-1644| ... | @@ -1,1644 +0,0 @@ | ||
| 1 | import * as Clock from "effect/Clock"; | ||
| 2 | import * as Duration from "effect/Duration"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Option from "effect/Option"; | ||
| 5 | import * as Ref from "effect/Ref"; | ||
| 6 | import * as Schema from "effect/Schema"; | ||
| 7 | import * as EffectAcpSchema from "effect-acp/compat"; | ||
| 8 | import * as EffectAcpSchemaV1 from "effect-acp/schema-v1"; | ||
| 9 | import { | ||
| 10 | deriveToolActivityPresentation, | ||
| 11 | mergeToolActivityData, | ||
| 12 | } from "@t3tools/shared/toolActivity"; | ||
| 13 | import { T3_MCP_TOOL_NAMES } from "@t3tools/shared/t3McpToolPresentation"; | ||
| 14 | import type { | ||
| 15 | OrchestrationV2ProviderThreadNativeMetadata, | ||
| 16 | ThreadTokenUsageSnapshot, | ||
| 17 | ToolLifecycleItemType, | ||
| 18 | } from "@t3tools/contracts"; | ||
| 19 | |||
| 20 | function isRecord(value: unknown): value is Record<string, unknown> { | ||
| 21 | return typeof value === "object" && value !== null && !Array.isArray(value); | ||
| 22 | } | ||
| 23 | |||
| 24 | // Guards for the untyped `initialize._meta` states some agents (Grok) advertise. | ||
| 25 | // Modes were removed from ACP v2, so the v1 wire schema is the source of truth. | ||
| 26 | const isSessionModelState = Schema.is(EffectAcpSchema.SessionModelState); | ||
| 27 | const isSessionModeState = Schema.is(EffectAcpSchemaV1.SessionModeState); | ||
| 28 | |||
| 29 | export interface AcpSessionMode { | ||
| 30 | readonly id: string; | ||
| 31 | readonly name: string; | ||
| 32 | readonly description?: string; | ||
| 33 | } | ||
| 34 | |||
| 35 | export interface AcpSessionModeState { | ||
| 36 | readonly currentModeId: string; | ||
| 37 | readonly availableModes: ReadonlyArray<AcpSessionMode>; | ||
| 38 | } | ||
| 39 | |||
| 40 | export interface AcpToolCallState { | ||
| 41 | readonly toolCallId: string; | ||
| 42 | readonly kind?: string; | ||
| 43 | readonly title?: string; | ||
| 44 | readonly status?: "pending" | "inProgress" | "requiresAction" | "completed" | "failed"; | ||
| 45 | readonly command?: string; | ||
| 46 | readonly detail?: string; | ||
| 47 | readonly data: Record<string, unknown>; | ||
| 48 | } | ||
| 49 | |||
| 50 | export interface AcpAgentTerminalState { | ||
| 51 | readonly command?: string; | ||
| 52 | readonly cwd?: string; | ||
| 53 | readonly output: string; | ||
| 54 | readonly exitStatus?: EffectAcpSchema.TerminalExitStatus; | ||
| 55 | /** Incomplete trailing UTF-8 bytes retained until the next output update. */ | ||
| 56 | readonly pendingOutputBytes?: Uint8Array; | ||
| 57 | } | ||
| 58 | |||
| 59 | type AcpAgentTerminalUpdate = Extract< | ||
| 60 | EffectAcpSchema.SessionUpdate, | ||
| 61 | { readonly sessionUpdate: "terminal_update" | "terminal_output_chunk" } | ||
| 62 | >; | ||
| 63 | |||
| 64 | const MAX_ACP_TERMINAL_OUTPUT_BYTES = 16 * 1024 * 1024; | ||
| 65 | const MAX_ACP_TERMINAL_FRAME_BYTES = MAX_ACP_TERMINAL_OUTPUT_BYTES; | ||
| 66 | |||
| 67 | function isBase64DataCode(code: number): boolean { | ||
| 68 | return ( | ||
| 69 | (code >= 65 && code <= 90) || | ||
| 70 | (code >= 97 && code <= 122) || | ||
| 71 | (code >= 48 && code <= 57) || | ||
| 72 | code === 43 || | ||
| 73 | code === 47 | ||
| 74 | ); | ||
| 75 | } | ||
| 76 | |||
| 77 | /** | ||
| 78 | * Decodes one bounded base64 terminal frame, or `undefined` when the frame is | ||
| 79 | * malformed or oversized. Invalid frames are dropped rather than failed so one | ||
| 80 | * misbehaving agent frame cannot take down session-update handling. | ||
| 81 | */ | ||
| 82 | function decodeBase64TerminalFrame(encoded: string): Uint8Array | undefined { | ||
| 83 | if (encoded.length > Math.ceil(MAX_ACP_TERMINAL_FRAME_BYTES / 3) * 4) { | ||
| 84 | return undefined; | ||
| 85 | } | ||
| 86 | const padding = encoded.endsWith("==") ? 2 : encoded.endsWith("=") ? 1 : 0; | ||
| 87 | const dataLength = encoded.length - padding; | ||
| 88 | if ( | ||
| 89 | encoded.length % 4 === 1 || | ||
| 90 | (padding > 0 && encoded.length % 4 !== 0) || | ||
| 91 | (padding === 1 && dataLength % 4 !== 3) || | ||
| 92 | (padding === 2 && dataLength % 4 !== 2) | ||
| 93 | ) { | ||
| 94 | return undefined; | ||
| 95 | } | ||
| 96 | for (let index = 0; index < dataLength; index += 1) { | ||
| 97 | if (!isBase64DataCode(encoded.charCodeAt(index))) return undefined; | ||
| 98 | } | ||
| 99 | const decoded = Buffer.from(encoded, "base64"); | ||
| 100 | return decoded.byteLength <= MAX_ACP_TERMINAL_FRAME_BYTES ? decoded : undefined; | ||
| 101 | } | ||
| 102 | |||
| 103 | function trailingIncompleteUtf8Start(bytes: Uint8Array): number { | ||
| 104 | if (bytes.length === 0) return bytes.length; | ||
| 105 | let leadIndex = bytes.length - 1; | ||
| 106 | while (leadIndex >= 0 && (bytes[leadIndex]! & 0xc0) === 0x80) { | ||
| 107 | leadIndex -= 1; | ||
| 108 | } | ||
| 109 | if (leadIndex < 0) return bytes.length; | ||
| 110 | const lead = bytes[leadIndex]!; | ||
| 111 | const expectedLength = | ||
| 112 | lead >= 0xc2 && lead <= 0xdf | ||
| 113 | ? 2 | ||
| 114 | : lead >= 0xe0 && lead <= 0xef | ||
| 115 | ? 3 | ||
| 116 | : lead >= 0xf0 && lead <= 0xf4 | ||
| 117 | ? 4 | ||
| 118 | : 1; | ||
| 119 | return bytes.length - leadIndex < expectedLength ? leadIndex : bytes.length; | ||
| 120 | } | ||
| 121 | |||
| 122 | function decodeTerminalOutputChunk( | ||
| 123 | pending: Uint8Array | undefined, | ||
| 124 | encoded: string, | ||
| 125 | ): { readonly text: string; readonly pendingOutputBytes?: Uint8Array } | undefined { | ||
| 126 | const frame = decodeBase64TerminalFrame(encoded); | ||
| 127 | if (frame === undefined) return undefined; | ||
| 128 | const bytes = Buffer.concat([...(pending === undefined ? [] : [Buffer.from(pending)]), frame]); | ||
| 129 | const completeEnd = trailingIncompleteUtf8Start(bytes); | ||
| 130 | const text = bytes.subarray(0, completeEnd).toString("utf8"); | ||
| 131 | return completeEnd === bytes.length | ||
| 132 | ? { text } | ||
| 133 | : { text, pendingOutputBytes: Uint8Array.from(bytes.subarray(completeEnd)) }; | ||
| 134 | } | ||
| 135 | |||
| 136 | function truncateTerminalOutput(output: string): string { | ||
| 137 | const bytes = Buffer.from(output, "utf8"); | ||
| 138 | if (bytes.length <= MAX_ACP_TERMINAL_OUTPUT_BYTES) return output; | ||
| 139 | let start = bytes.length - MAX_ACP_TERMINAL_OUTPUT_BYTES; | ||
| 140 | while (start < bytes.length && (bytes[start]! & 0xc0) === 0x80) start += 1; | ||
| 141 | return bytes.subarray(start).toString("utf8"); | ||
| 142 | } | ||
| 143 | |||
| 144 | /** Applies an ACP v2 agent-owned terminal snapshot or chunk to display state. */ | ||
| 145 | export function applyAcpAgentTerminalUpdate( | ||
| 146 | previous: AcpAgentTerminalState | undefined, | ||
| 147 | update: AcpAgentTerminalUpdate, | ||
| 148 | ): AcpAgentTerminalState { | ||
| 149 | const current = previous ?? { output: "" }; | ||
| 150 | if (update.sessionUpdate === "terminal_output_chunk") { | ||
| 151 | const decoded = decodeTerminalOutputChunk(current.pendingOutputBytes, update.data); | ||
| 152 | if (decoded === undefined) return current; | ||
| 153 | const { pendingOutputBytes: _pendingOutputBytes, ...withoutPendingOutputBytes } = current; | ||
| 154 | return { | ||
| 155 | ...withoutPendingOutputBytes, | ||
| 156 | output: truncateTerminalOutput(`${current.output}${decoded.text}`), | ||
| 157 | ...(decoded.pendingOutputBytes === undefined | ||
| 158 | ? {} | ||
| 159 | : { pendingOutputBytes: decoded.pendingOutputBytes }), | ||
| 160 | }; | ||
| 161 | } | ||
| 162 | let next: AcpAgentTerminalState = current; | ||
| 163 | if (update.command === null) { | ||
| 164 | const { command: _command, ...withoutCommand } = next; | ||
| 165 | next = withoutCommand; | ||
| 166 | } else if (update.command !== undefined) { | ||
| 167 | next = { ...next, command: update.command }; | ||
| 168 | } | ||
| 169 | if (update.cwd === null) { | ||
| 170 | const { cwd: _cwd, ...withoutCwd } = next; | ||
| 171 | next = withoutCwd; | ||
| 172 | } else if (update.cwd !== undefined) { | ||
| 173 | next = { ...next, cwd: update.cwd }; | ||
| 174 | } | ||
| 175 | if (update.output !== undefined) { | ||
| 176 | const decoded = | ||
| 177 | update.output === null | ||
| 178 | ? { text: "" } | ||
| 179 | : decodeTerminalOutputChunk(undefined, update.output.data); | ||
| 180 | if (decoded !== undefined) { | ||
| 181 | const { pendingOutputBytes: _pendingOutputBytes, ...withoutPendingOutputBytes } = next; | ||
| 182 | next = { | ||
| 183 | ...withoutPendingOutputBytes, | ||
| 184 | output: truncateTerminalOutput(decoded.text), | ||
| 185 | ...(decoded.pendingOutputBytes === undefined | ||
| 186 | ? {} | ||
| 187 | : { pendingOutputBytes: decoded.pendingOutputBytes }), | ||
| 188 | }; | ||
| 189 | } | ||
| 190 | } | ||
| 191 | if (update.exitStatus === null) { | ||
| 192 | const { exitStatus: _exitStatus, ...withoutExitStatus } = next; | ||
| 193 | next = withoutExitStatus; | ||
| 194 | } else if (update.exitStatus !== undefined) { | ||
| 195 | const pendingText = | ||
| 196 | next.pendingOutputBytes === undefined | ||
| 197 | ? "" | ||
| 198 | : Buffer.from(next.pendingOutputBytes).toString("utf8"); | ||
| 199 | const { pendingOutputBytes: _pendingOutputBytes, ...withoutPendingOutputBytes } = next; | ||
| 200 | next = { | ||
| 201 | ...withoutPendingOutputBytes, | ||
| 202 | output: truncateTerminalOutput(`${next.output}${pendingText}`), | ||
| 203 | exitStatus: update.exitStatus, | ||
| 204 | }; | ||
| 205 | } | ||
| 206 | return next; | ||
| 207 | } | ||
| 208 | |||
| 209 | export type AcpPlanUpdate = | ||
| 210 | | { | ||
| 211 | readonly nativePlanId: string; | ||
| 212 | readonly kind: "items"; | ||
| 213 | readonly explanation?: string | null; | ||
| 214 | readonly plan: ReadonlyArray<{ | ||
| 215 | readonly step: string; | ||
| 216 | readonly status: "pending" | "inProgress" | "completed"; | ||
| 217 | }>; | ||
| 218 | } | ||
| 219 | | { readonly nativePlanId: string; readonly kind: "markdown"; readonly markdown: string } | ||
| 220 | | { readonly nativePlanId: string; readonly kind: "file"; readonly uri: string } | ||
| 221 | | { readonly nativePlanId: string; readonly kind: "unknown"; readonly contentType: string } | ||
| 222 | | { readonly nativePlanId: string; readonly kind: "removed" }; | ||
| 223 | |||
| 224 | export interface AcpPermissionRequest { | ||
| 225 | readonly kind: string | "unknown"; | ||
| 226 | readonly detail?: string; | ||
| 227 | readonly toolCall?: AcpToolCallState; | ||
| 228 | } | ||
| 229 | |||
| 230 | export type AcpParsedSessionEvent = | ||
| 231 | | { | ||
| 232 | readonly _tag: "ModeChanged"; | ||
| 233 | readonly modeId: string; | ||
| 234 | } | ||
| 235 | | { | ||
| 236 | readonly _tag: "AvailableCommandsUpdated"; | ||
| 237 | readonly availableCommands: ReadonlyArray<EffectAcpSchema.AvailableCommand>; | ||
| 238 | readonly rawPayload: unknown; | ||
| 239 | } | ||
| 240 | | { | ||
| 241 | readonly _tag: "ConfigOptionsUpdated"; | ||
| 242 | readonly configOptions: ReadonlyArray<EffectAcpSchema.SessionConfigOption>; | ||
| 243 | readonly rawPayload: unknown; | ||
| 244 | } | ||
| 245 | | { | ||
| 246 | readonly _tag: "AssistantItemStarted"; | ||
| 247 | readonly itemId: string; | ||
| 248 | } | ||
| 249 | | { | ||
| 250 | readonly _tag: "AssistantItemCompleted"; | ||
| 251 | readonly itemId: string; | ||
| 252 | } | ||
| 253 | | { | ||
| 254 | readonly _tag: "PlanUpdated"; | ||
| 255 | readonly payload: AcpPlanUpdate; | ||
| 256 | readonly rawPayload: unknown; | ||
| 257 | } | ||
| 258 | | { | ||
| 259 | readonly _tag: "ToolCallUpdated"; | ||
| 260 | readonly toolCall: AcpToolCallState; | ||
| 261 | readonly rawPayload: unknown; | ||
| 262 | } | ||
| 263 | | { | ||
| 264 | readonly _tag: "ContentDelta"; | ||
| 265 | readonly itemId?: string; | ||
| 266 | readonly messageId?: string; | ||
| 267 | readonly text: string; | ||
| 268 | readonly rawPayload: unknown; | ||
| 269 | } | ||
| 270 | | { | ||
| 271 | readonly _tag: "ThoughtDelta"; | ||
| 272 | readonly text: string; | ||
| 273 | readonly rawPayload: unknown; | ||
| 274 | } | ||
| 275 | | { | ||
| 276 | readonly _tag: "UsageUpdated"; | ||
| 277 | readonly usage: ThreadTokenUsageSnapshot; | ||
| 278 | readonly rawPayload: unknown; | ||
| 279 | } | ||
| 280 | | { | ||
| 281 | readonly _tag: "SessionInfoUpdated"; | ||
| 282 | readonly metadata: OrchestrationV2ProviderThreadNativeMetadata; | ||
| 283 | readonly rawPayload: unknown; | ||
| 284 | } | ||
| 285 | | { | ||
| 286 | readonly _tag: "UnknownUpdate"; | ||
| 287 | readonly updateType: string; | ||
| 288 | readonly rawPayload: unknown; | ||
| 289 | }; | ||
| 290 | |||
| 291 | const boundedContentMetadata = (value: string | null | undefined, maximumLength: number): string => | ||
| 292 | value?.trim().slice(0, maximumLength) ?? ""; | ||
| 293 | |||
| 294 | const MAX_ACP_CONTENT_TEXT_LENGTH = 65_536; | ||
| 295 | |||
| 296 | // Preserve whitespace in streamed model output while keeping one provider frame bounded. | ||
| 297 | const boundedContentText = (value: string): string => value.slice(0, MAX_ACP_CONTENT_TEXT_LENGTH); | ||
| 298 | |||
| 299 | const boundedContentUri = (value: string | null | undefined): string => { | ||
| 300 | const uri = boundedContentMetadata(value, 4_096); | ||
| 301 | return uri.toLowerCase().startsWith("data:") ? "" : uri; | ||
| 302 | }; | ||
| 303 | |||
| 304 | /** Convert every ACP output block into bounded, displayable text without retaining base64 data. */ | ||
| 305 | export function acpContentBlockDisplayText( | ||
| 306 | content: EffectAcpSchema.ContentBlock, | ||
| 307 | ): string | undefined { | ||
| 308 | switch (content.type) { | ||
| 309 | case "text": | ||
| 310 | return boundedContentText(content.text); | ||
| 311 | case "resource_link": { | ||
| 312 | const label = | ||
| 313 | boundedContentMetadata(content.title, 512) || | ||
| 314 | boundedContentMetadata(content.name, 512) || | ||
| 315 | "resource"; | ||
| 316 | const description = boundedContentMetadata(content.description, 2_048); | ||
| 317 | const uri = boundedContentUri(content.uri); | ||
| 318 | return `${description ? `${label}: ${description}` : label}${uri ? `\n${uri}` : ""}`; | ||
| 319 | } | ||
| 320 | case "resource": { | ||
| 321 | if ("text" in content.resource) { | ||
| 322 | return boundedContentText(content.resource.text); | ||
| 323 | } | ||
| 324 | const mimeType = boundedContentMetadata(content.resource.mimeType, 256); | ||
| 325 | const uri = boundedContentUri(content.resource.uri); | ||
| 326 | return `[ACP binary resource${mimeType ? ` (${mimeType})` : ""}${uri ? `: ${uri}` : ""}]`; | ||
| 327 | } | ||
| 328 | case "image": { | ||
| 329 | const mimeType = boundedContentMetadata(content.mimeType, 256) || "unknown type"; | ||
| 330 | const uri = boundedContentUri(content.uri); | ||
| 331 | return `[ACP image (${mimeType})${uri ? `: ${uri}` : ""}]`; | ||
| 332 | } | ||
| 333 | case "audio": { | ||
| 334 | const mimeType = boundedContentMetadata(content.mimeType, 256) || "unknown type"; | ||
| 335 | return `[ACP audio (${mimeType})]`; | ||
| 336 | } | ||
| 337 | case "_t3_unknown": | ||
| 338 | return `[Unsupported ACP content: ${boundedContentMetadata(content.originalType, 128) || "unknown"}]`; | ||
| 339 | } | ||
| 340 | } | ||
| 341 | |||
| 342 | function sanitizeAcpToolCallContent( | ||
| 343 | content: ReadonlyArray<EffectAcpSchema.ToolCallContent>, | ||
| 344 | ): ReadonlyArray<EffectAcpSchema.ToolCallContent> { | ||
| 345 | return content.map((entry) => { | ||
| 346 | switch (entry.type) { | ||
| 347 | case "content": | ||
| 348 | return { | ||
| 349 | type: "content", | ||
| 350 | content: { | ||
| 351 | type: "text", | ||
| 352 | text: acpContentBlockDisplayText(entry.content) ?? "", | ||
| 353 | }, | ||
| 354 | }; | ||
| 355 | case "diff": | ||
| 356 | if ("changes" in entry) { | ||
| 357 | return { | ||
| 358 | type: "diff", | ||
| 359 | changes: entry.changes, | ||
| 360 | ...(entry.patch === undefined ? {} : { patch: entry.patch }), | ||
| 361 | }; | ||
| 362 | } | ||
| 363 | return { | ||
| 364 | type: "diff", | ||
| 365 | path: entry.path, | ||
| 366 | ...(entry.oldText !== undefined ? { oldText: entry.oldText } : {}), | ||
| 367 | newText: entry.newText, | ||
| 368 | }; | ||
| 369 | case "terminal": | ||
| 370 | return { type: "terminal", terminalId: entry.terminalId }; | ||
| 371 | case "_t3_unknown": | ||
| 372 | return { | ||
| 373 | type: "_t3_unknown", | ||
| 374 | originalType: boundedContentMetadata(entry.originalType, 128) || "unknown", | ||
| 375 | raw: null, | ||
| 376 | }; | ||
| 377 | } | ||
| 378 | }); | ||
| 379 | } | ||
| 380 | |||
| 381 | type AcpSessionSetupResponse = | ||
| 382 | | EffectAcpSchema.ForkSessionResponse | ||
| 383 | | EffectAcpSchema.LoadSessionResponse | ||
| 384 | | EffectAcpSchema.NewSessionResponse | ||
| 385 | | EffectAcpSchema.ResumeSessionResponse; | ||
| 386 | |||
| 387 | type AcpToolCallUpdate = Extract< | ||
| 388 | EffectAcpSchema.SessionNotification["update"], | ||
| 389 | { readonly sessionUpdate: "tool_call" | "tool_call_update" } | ||
| 390 | >; | ||
| 391 | |||
| 392 | export function extractModelConfigId(sessionResponse: AcpSessionSetupResponse): string | undefined { | ||
| 393 | const configOptions = sessionResponse.configOptions; | ||
| 394 | if (!configOptions) return undefined; | ||
| 395 | for (const opt of configOptions) { | ||
| 396 | if (opt.category === "model" && opt.id.trim().length > 0) { | ||
| 397 | return opt.id.trim(); | ||
| 398 | } | ||
| 399 | } | ||
| 400 | return undefined; | ||
| 401 | } | ||
| 402 | |||
| 403 | export function findSessionConfigOption( | ||
| 404 | configOptions: ReadonlyArray<EffectAcpSchema.SessionConfigOption> | null | undefined, | ||
| 405 | configId: string, | ||
| 406 | ): EffectAcpSchema.SessionConfigOption | undefined { | ||
| 407 | if (!configOptions) { | ||
| 408 | return undefined; | ||
| 409 | } | ||
| 410 | const normalizedConfigId = configId.trim(); | ||
| 411 | if (!normalizedConfigId) { | ||
| 412 | return undefined; | ||
| 413 | } | ||
| 414 | return configOptions.find((option) => option.id.trim() === normalizedConfigId); | ||
| 415 | } | ||
| 416 | |||
| 417 | export function collectSessionConfigOptionValues( | ||
| 418 | configOption: EffectAcpSchema.SessionConfigOption, | ||
| 419 | ): ReadonlyArray<string> { | ||
| 420 | if (configOption.type !== "select") { | ||
| 421 | return []; | ||
| 422 | } | ||
| 423 | return configOption.options.flatMap((entry) => | ||
| 424 | "value" in entry ? [entry.value] : entry.options.map((option) => option.value), | ||
| 425 | ); | ||
| 426 | } | ||
| 427 | |||
| 428 | export function parseSessionModeState( | ||
| 429 | sessionResponse: AcpSessionSetupResponse, | ||
| 430 | ): AcpSessionModeState | undefined { | ||
| 431 | const modeConfig = sessionResponse.configOptions?.find( | ||
| 432 | (option) => option.category === "mode" && option.type === "select", | ||
| 433 | ); | ||
| 434 | const modes = | ||
| 435 | sessionResponse.modes ?? | ||
| 436 | (modeConfig?.type === "select" | ||
| 437 | ? { | ||
| 438 | currentModeId: modeConfig.currentValue, | ||
| 439 | availableModes: modeConfig.options | ||
| 440 | .flatMap((option) => ("options" in option ? option.options : [option])) | ||
| 441 | .map((option) => ({ | ||
| 442 | id: option.value, | ||
| 443 | name: option.name, | ||
| 444 | description: option.description, | ||
| 445 | })), | ||
| 446 | } | ||
| 447 | : undefined); | ||
| 448 | if (!modes) return undefined; | ||
| 449 | const currentModeId = modes.currentModeId.trim(); | ||
| 450 | if (!currentModeId) { | ||
| 451 | return undefined; | ||
| 452 | } | ||
| 453 | const availableModes: Array<AcpSessionMode> = []; | ||
| 454 | for (const mode of modes.availableModes) { | ||
| 455 | const id = mode.id.trim(); | ||
| 456 | const name = mode.name.trim(); | ||
| 457 | if (!id || !name) { | ||
| 458 | continue; | ||
| 459 | } | ||
| 460 | const description = mode.description?.trim() || undefined; | ||
| 461 | availableModes.push( | ||
| 462 | description !== undefined | ||
| 463 | ? ({ id, name, description } satisfies AcpSessionMode) | ||
| 464 | : ({ id, name } satisfies AcpSessionMode), | ||
| 465 | ); | ||
| 466 | } | ||
| 467 | if (availableModes.length === 0) { | ||
| 468 | return undefined; | ||
| 469 | } | ||
| 470 | return { | ||
| 471 | currentModeId, | ||
| 472 | availableModes, | ||
| 473 | }; | ||
| 474 | } | ||
| 475 | |||
| 476 | function normalizePlanStepStatus(raw: unknown): "pending" | "inProgress" | "completed" { | ||
| 477 | switch (raw) { | ||
| 478 | case "completed": | ||
| 479 | return "completed"; | ||
| 480 | case "in_progress": | ||
| 481 | case "inProgress": | ||
| 482 | return "inProgress"; | ||
| 483 | default: | ||
| 484 | return "pending"; | ||
| 485 | } | ||
| 486 | } | ||
| 487 | |||
| 488 | function normalizeToolCallStatus( | ||
| 489 | raw: unknown, | ||
| 490 | fallback?: "pending" | "inProgress" | "completed" | "failed", | ||
| 491 | ): "pending" | "inProgress" | "completed" | "failed" | undefined { | ||
| 492 | switch (raw) { | ||
| 493 | case "pending": | ||
| 494 | return "pending"; | ||
| 495 | case "in_progress": | ||
| 496 | case "inProgress": | ||
| 497 | return "inProgress"; | ||
| 498 | case "completed": | ||
| 499 | return "completed"; | ||
| 500 | case "failed": | ||
| 501 | return "failed"; | ||
| 502 | default: | ||
| 503 | return fallback; | ||
| 504 | } | ||
| 505 | } | ||
| 506 | |||
| 507 | function normalizeCommandValue(value: unknown): string | undefined { | ||
| 508 | if (typeof value === "string" && value.trim().length > 0) { | ||
| 509 | return value.trim(); | ||
| 510 | } | ||
| 511 | if (!Array.isArray(value)) { | ||
| 512 | return undefined; | ||
| 513 | } | ||
| 514 | const parts: Array<string> = []; | ||
| 515 | for (const entry of value) { | ||
| 516 | if (typeof entry === "string") { | ||
| 517 | const part = entry.trim(); | ||
| 518 | if (part.length > 0) { | ||
| 519 | parts.push(part); | ||
| 520 | } | ||
| 521 | } | ||
| 522 | } | ||
| 523 | return parts.length > 0 ? parts.join(" ") : undefined; | ||
| 524 | } | ||
| 525 | |||
| 526 | function extractCommandFromTitle(title: string | undefined): string | undefined { | ||
| 527 | if (!title) { | ||
| 528 | return undefined; | ||
| 529 | } | ||
| 530 | const match = /`([^`]+)`/.exec(title); | ||
| 531 | return match?.[1]?.trim() || undefined; | ||
| 532 | } | ||
| 533 | |||
| 534 | function extractToolCallCommand( | ||
| 535 | rawInput: unknown, | ||
| 536 | title: string | undefined, | ||
| 537 | kind: string | undefined, | ||
| 538 | ): string | undefined { | ||
| 539 | if (isRecord(rawInput)) { | ||
| 540 | const directCommand = normalizeCommandValue(rawInput.command); | ||
| 541 | if (directCommand) { | ||
| 542 | return directCommand; | ||
| 543 | } | ||
| 544 | const executable = typeof rawInput.executable === "string" ? rawInput.executable.trim() : ""; | ||
| 545 | const args = normalizeCommandValue(rawInput.args); | ||
| 546 | if (executable && args) { | ||
| 547 | return `${executable} ${args}`; | ||
| 548 | } | ||
| 549 | if (executable) { | ||
| 550 | return executable; | ||
| 551 | } | ||
| 552 | } | ||
| 553 | // Titles like `Read \`src/a.ts\`` are not commands. Only execute tools | ||
| 554 | // fall back to a backtick or bare title. | ||
| 555 | if (kind !== "execute") { | ||
| 556 | return undefined; | ||
| 557 | } | ||
| 558 | return extractCommandFromTitle(title); | ||
| 559 | } | ||
| 560 | |||
| 561 | function filePathFromToolValue(value: unknown): string | undefined { | ||
| 562 | if (typeof value !== "string") { | ||
| 563 | return undefined; | ||
| 564 | } | ||
| 565 | const trimmed = value.trim(); | ||
| 566 | return trimmed.length > 0 ? trimmed : undefined; | ||
| 567 | } | ||
| 568 | |||
| 569 | function locationsFromToolCallInput(input: { | ||
| 570 | readonly locations?: ReadonlyArray<EffectAcpSchema.ToolCallLocation> | null | undefined; | ||
| 571 | readonly content?: ReadonlyArray<EffectAcpSchema.ToolCallContent> | null | undefined; | ||
| 572 | readonly rawInput?: unknown; | ||
| 573 | readonly rawOutput?: unknown; | ||
| 574 | }): ReadonlyArray<EffectAcpSchema.ToolCallLocation> | undefined { | ||
| 575 | if (input.locations === null || input.locations?.length === 0) { | ||
| 576 | return []; | ||
| 577 | } | ||
| 578 | const locations: EffectAcpSchema.ToolCallLocation[] = []; | ||
| 579 | const seen = new Set<string>(); | ||
| 580 | const pushLocation = (location: EffectAcpSchema.ToolCallLocation) => { | ||
| 581 | const path = filePathFromToolValue(location.path); | ||
| 582 | if (!path || seen.has(path)) { | ||
| 583 | return; | ||
| 584 | } | ||
| 585 | seen.add(path); | ||
| 586 | locations.push({ ...location, path }); | ||
| 587 | }; | ||
| 588 | const pushPath = (value: unknown) => { | ||
| 589 | const path = filePathFromToolValue(value); | ||
| 590 | if (!path || seen.has(path)) { | ||
| 591 | return; | ||
| 592 | } | ||
| 593 | seen.add(path); | ||
| 594 | locations.push({ path }); | ||
| 595 | }; | ||
| 596 | |||
| 597 | if (input.locations) { | ||
| 598 | for (const location of input.locations) { | ||
| 599 | pushLocation(location); | ||
| 600 | } | ||
| 601 | } | ||
| 602 | if (input.content) { | ||
| 603 | for (const entry of input.content) { | ||
| 604 | if (entry.type === "diff" && "path" in entry) { | ||
| 605 | pushPath(entry.path); | ||
| 606 | } | ||
| 607 | } | ||
| 608 | } | ||
| 609 | if (isRecord(input.rawInput)) { | ||
| 610 | pushPath(input.rawInput.path); | ||
| 611 | pushPath(input.rawInput.filePath); | ||
| 612 | pushPath(input.rawInput.file_path); | ||
| 613 | } | ||
| 614 | if (isRecord(input.rawOutput)) { | ||
| 615 | pushPath(input.rawOutput.path); | ||
| 616 | pushPath(input.rawOutput.filePath); | ||
| 617 | pushPath(input.rawOutput.file_path); | ||
| 618 | } | ||
| 619 | |||
| 620 | return locations.length > 0 ? locations : undefined; | ||
| 621 | } | ||
| 622 | |||
| 623 | // Some ACP agents (observed with Grok's CLI) resend the ENTIRE accumulated tool-call | ||
| 624 | // output on every `tool_call_update` notification instead of a delta, so a redrawing | ||
| 625 | // terminal progress bar can balloon a single tool call to hundreds of KB per update at | ||
| 626 | // several updates per second. Cap what we retain/emit to a bounded tail so one busy tool | ||
| 627 | // call cannot flood runtime event ingestion. We always keep the tail: `tool_call_update` | ||
| 628 | // deltas routinely omit `kind`, so there is no reliable way to tell a redrawing terminal | ||
| 629 | // from another tool here, and the end is the useful part of any live-growing output. | ||
| 630 | const TOOL_CALL_CONTENT_MAX_CHARS = 8_000; | ||
| 631 | const TOOL_CALL_CONTENT_TRUNCATION_MARKER = "[Earlier output truncated]\n\n"; | ||
| 632 | |||
| 633 | function boundToolCallOutputText(text: string): string { | ||
| 634 | if (text.length <= TOOL_CALL_CONTENT_MAX_CHARS) { | ||
| 635 | return text; | ||
| 636 | } | ||
| 637 | const tail = text.slice(text.length - TOOL_CALL_CONTENT_MAX_CHARS); | ||
| 638 | return `${TOOL_CALL_CONTENT_TRUNCATION_MARKER}${tail}`; | ||
| 639 | } | ||
| 640 | |||
| 641 | const RAW_OUTPUT_TEXT_FIELDS = ["content", "stdout", "stderr", "output"] as const; | ||
| 642 | |||
| 643 | // `rawOutput` is provider-defined and, for terminal-shaped tools, mirrors the same | ||
| 644 | // cumulative text-growth problem as `content` (see the comment above). Bound its known | ||
| 645 | // text-bearing fields the same way so a chatty provider cannot smuggle unbounded output | ||
| 646 | // through this field instead. | ||
| 647 | function boundToolCallRawOutput(rawOutput: unknown): unknown { | ||
| 648 | if (!isRecord(rawOutput)) { | ||
| 649 | return rawOutput; | ||
| 650 | } | ||
| 651 | let changed = false; | ||
| 652 | const bounded: Record<string, unknown> = { ...rawOutput }; | ||
| 653 | for (const field of RAW_OUTPUT_TEXT_FIELDS) { | ||
| 654 | const value = rawOutput[field]; | ||
| 655 | if (typeof value === "string" && value.length > TOOL_CALL_CONTENT_MAX_CHARS) { | ||
| 656 | bounded[field] = boundToolCallOutputText(value); | ||
| 657 | changed = true; | ||
| 658 | } | ||
| 659 | } | ||
| 660 | return changed ? bounded : rawOutput; | ||
| 661 | } | ||
| 662 | |||
| 663 | interface ExtractedToolCallContent { | ||
| 664 | readonly text: string | undefined; | ||
| 665 | readonly content: ReadonlyArray<EffectAcpSchema.ToolCallContent> | undefined; | ||
| 666 | } | ||
| 667 | |||
| 668 | function toolCallContentText(entry: EffectAcpSchema.ToolCallContent): string | undefined { | ||
| 669 | if (entry.type !== "content" || entry.content.type !== "text") { | ||
| 670 | return undefined; | ||
| 671 | } | ||
| 672 | return entry.content.text; | ||
| 673 | } | ||
| 674 | |||
| 675 | // Trim is used for display `text`, so whitespace-only (or whitespace-padded) entries never | ||
| 676 | // contribute to `chunks` and used to take the early returns with the original array. Bound | ||
| 677 | // each text entry independently so those paths cannot persist an unbounded terminal buffer | ||
| 678 | // on `toolCall.data.content` / `rawPayload`. | ||
| 679 | function boundToolCallContentEntries( | ||
| 680 | content: ReadonlyArray<EffectAcpSchema.ToolCallContent>, | ||
| 681 | ): ReadonlyArray<EffectAcpSchema.ToolCallContent> { | ||
| 682 | let changed = false; | ||
| 683 | const bounded = content.map((entry) => { | ||
| 684 | const text = toolCallContentText(entry); | ||
| 685 | if (text === undefined || text.length <= TOOL_CALL_CONTENT_MAX_CHARS) { | ||
| 686 | return entry; | ||
| 687 | } | ||
| 688 | changed = true; | ||
| 689 | const trimmed = text.trim(); | ||
| 690 | return { | ||
| 691 | type: "content", | ||
| 692 | content: { | ||
| 693 | type: "text", | ||
| 694 | text: boundToolCallOutputText(trimmed.length > 0 ? trimmed : text), | ||
| 695 | }, | ||
| 696 | } as const; | ||
| 697 | }); | ||
| 698 | return changed ? bounded : content; | ||
| 699 | } | ||
| 700 | |||
| 701 | function extractTextContentFromToolCallContent( | ||
| 702 | content: ReadonlyArray<EffectAcpSchema.ToolCallContent> | null | undefined, | ||
| 703 | ): ExtractedToolCallContent { | ||
| 704 | if (!content) { | ||
| 705 | return { text: undefined, content: undefined }; | ||
| 706 | } | ||
| 707 | const chunks: Array<string> = []; | ||
| 708 | for (const entry of content) { | ||
| 709 | const text = toolCallContentText(entry)?.trim(); | ||
| 710 | if (text) { | ||
| 711 | chunks.push(text); | ||
| 712 | } | ||
| 713 | } | ||
| 714 | if (chunks.length === 0) { | ||
| 715 | return { text: undefined, content: boundToolCallContentEntries(content) }; | ||
| 716 | } | ||
| 717 | const joined = chunks.join("\n"); | ||
| 718 | if (joined.length <= TOOL_CALL_CONTENT_MAX_CHARS) { | ||
| 719 | return { text: joined, content: boundToolCallContentEntries(content) }; | ||
| 720 | } | ||
| 721 | const bounded = boundToolCallOutputText(joined); | ||
| 722 | const tail = joined.slice(joined.length - TOOL_CALL_CONTENT_MAX_CHARS); | ||
| 723 | return { | ||
| 724 | text: bounded, | ||
| 725 | content: distributeRetainedTailAcrossContent(content, tail), | ||
| 726 | }; | ||
| 727 | } | ||
| 728 | |||
| 729 | // Walk the original text entries from the joined tail window so a retained slice that | ||
| 730 | // spans entries around an image/diff stays on those entries. Non-text kinds keep their | ||
| 731 | // relative order; blank text entries are dropped; the truncation marker is prepended to | ||
| 732 | // the first remaining text entry. | ||
| 733 | function distributeRetainedTailAcrossContent( | ||
| 734 | content: ReadonlyArray<EffectAcpSchema.ToolCallContent>, | ||
| 735 | tail: string, | ||
| 736 | ): ReadonlyArray<EffectAcpSchema.ToolCallContent> { | ||
| 737 | const textRanges: Array< | ||
| 738 | | { | ||
| 739 | readonly start: number; | ||
| 740 | readonly end: number; | ||
| 741 | readonly text: string; | ||
| 742 | } | ||
| 743 | | undefined | ||
| 744 | > = Array.from({ length: content.length }); | ||
| 745 | let offset = 0; | ||
| 746 | let seenText = false; | ||
| 747 | for (const [index, entry] of content.entries()) { | ||
| 748 | const text = toolCallContentText(entry)?.trim(); | ||
| 749 | if (!text) { | ||
| 750 | continue; | ||
| 751 | } | ||
| 752 | if (seenText) { | ||
| 753 | offset += 1; | ||
| 754 | } | ||
| 755 | seenText = true; | ||
| 756 | const start = offset; | ||
| 757 | const end = offset + text.length; | ||
| 758 | textRanges[index] = { start, end, text }; | ||
| 759 | offset = end; | ||
| 760 | } | ||
| 761 | const tailStart = Math.max(0, offset - tail.length); | ||
| 762 | let markerPending = true; | ||
| 763 | return content.flatMap((entry, index) => { | ||
| 764 | if (toolCallContentText(entry) === undefined) { | ||
| 765 | return [entry]; | ||
| 766 | } | ||
| 767 | const range = textRanges[index]; | ||
| 768 | if (range === undefined) { | ||
| 769 | return []; | ||
| 770 | } | ||
| 771 | const overlapStart = Math.max(range.start, tailStart); | ||
| 772 | const overlapEnd = Math.min(range.end, offset); | ||
| 773 | if (overlapEnd <= overlapStart) { | ||
| 774 | return []; | ||
| 775 | } | ||
| 776 | let piece = range.text.slice(overlapStart - range.start, overlapEnd - range.start); | ||
| 777 | if (markerPending) { | ||
| 778 | piece = `${TOOL_CALL_CONTENT_TRUNCATION_MARKER}${piece}`; | ||
| 779 | markerPending = false; | ||
| 780 | } | ||
| 781 | return [{ type: "content", content: { type: "text", text: piece } } as const]; | ||
| 782 | }); | ||
| 783 | } | ||
| 784 | |||
| 785 | function normalizeToolKind(kind: unknown): string | undefined { | ||
| 786 | return typeof kind === "string" && kind.trim().length > 0 ? kind.trim() : undefined; | ||
| 787 | } | ||
| 788 | |||
| 789 | /** | ||
| 790 | * Map an ACP tool kind onto the canonical runtime item type used by the | ||
| 791 | * thread activity model. Unknown kinds fall back to a generic tool call. | ||
| 792 | */ | ||
| 793 | export function canonicalItemTypeFromAcpToolKind(kind: string | undefined): ToolLifecycleItemType { | ||
| 794 | switch (kind) { | ||
| 795 | case "read": | ||
| 796 | return "dynamic_tool_call"; | ||
| 797 | case "execute": | ||
| 798 | return "command_execution"; | ||
| 799 | case "edit": | ||
| 800 | case "delete": | ||
| 801 | case "move": | ||
| 802 | return "file_change"; | ||
| 803 | case "search": | ||
| 804 | case "fetch": | ||
| 805 | return "web_search"; | ||
| 806 | default: | ||
| 807 | return "dynamic_tool_call"; | ||
| 808 | } | ||
| 809 | } | ||
| 810 | |||
| 811 | function makeToolCallState( | ||
| 812 | input: { | ||
| 813 | readonly toolCallId: string; | ||
| 814 | readonly title?: string | null | undefined; | ||
| 815 | readonly kind?: EffectAcpSchema.ToolKind | null | undefined; | ||
| 816 | readonly status?: EffectAcpSchema.ToolCallStatus | null | undefined; | ||
| 817 | readonly rawInput?: unknown; | ||
| 818 | readonly rawOutput?: unknown; | ||
| 819 | readonly content?: ReadonlyArray<EffectAcpSchema.ToolCallContent> | null | undefined; | ||
| 820 | readonly locations?: ReadonlyArray<EffectAcpSchema.ToolCallLocation> | null | undefined; | ||
| 821 | readonly _meta?: unknown; | ||
| 822 | }, | ||
| 823 | options?: { | ||
| 824 | readonly fallbackStatus?: "pending" | "inProgress" | "completed" | "failed"; | ||
| 825 | }, | ||
| 826 | ): AcpToolCallState | undefined { | ||
| 827 | const toolCallId = input.toolCallId.trim(); | ||
| 828 | if (!toolCallId) { | ||
| 829 | return undefined; | ||
| 830 | } | ||
| 831 | const title = input.title?.trim() || undefined; | ||
| 832 | const kind = normalizeToolKind(input.kind); | ||
| 833 | const command = extractToolCallCommand(input.rawInput, title, kind); | ||
| 834 | const extractedContent = extractTextContentFromToolCallContent(input.content); | ||
| 835 | const textContent = extractedContent.text; | ||
| 836 | const normalizedTitle = | ||
| 837 | title && title.toLowerCase() !== "terminal" && title.toLowerCase() !== "tool call" | ||
| 838 | ? title | ||
| 839 | : undefined; | ||
| 840 | const data: Record<string, unknown> = { toolCallId }; | ||
| 841 | if (kind) { | ||
| 842 | data.kind = kind; | ||
| 843 | } | ||
| 844 | if (title) { | ||
| 845 | // The agent's verbatim title. Presentation summarizes `title` on the | ||
| 846 | // state (e.g. a command line becomes "Ran command"), so identity checks | ||
| 847 | // such as MCP-fallback detection need the raw value preserved here. | ||
| 848 | data.title = title; | ||
| 849 | } | ||
| 850 | if (command) { | ||
| 851 | data.command = command; | ||
| 852 | } | ||
| 853 | if (input.rawInput !== undefined) { | ||
| 854 | data.rawInput = input.rawInput; | ||
| 855 | } | ||
| 856 | if (isRecord(input._meta)) { | ||
| 857 | // Some agents identify MCP calls only here (goose `goose.toolCall`, | ||
| 858 | // qwen `toolName`/`serverId`, claude-acp `claudeCode.toolName`). | ||
| 859 | data.meta = input._meta; | ||
| 860 | } | ||
| 861 | if (input.rawOutput !== undefined) { | ||
| 862 | data.rawOutput = boundToolCallRawOutput(input.rawOutput); | ||
| 863 | } | ||
| 864 | if (input.content != null) { | ||
| 865 | data.content = sanitizeAcpToolCallContent(extractedContent.content ?? input.content); | ||
| 866 | } | ||
| 867 | const locations = locationsFromToolCallInput(input); | ||
| 868 | if (locations !== undefined) { | ||
| 869 | data.locations = locations; | ||
| 870 | } | ||
| 871 | if (isRecord(input._meta)) { | ||
| 872 | data.meta = input._meta; | ||
| 873 | } | ||
| 874 | const fallbackDetail = command ?? normalizedTitle ?? textContent; | ||
| 875 | const hasPresentationSeed = | ||
| 876 | title !== undefined || | ||
| 877 | kind !== undefined || | ||
| 878 | command !== undefined || | ||
| 879 | normalizedTitle !== undefined || | ||
| 880 | textContent !== undefined; | ||
| 881 | const presentation = hasPresentationSeed | ||
| 882 | ? deriveToolActivityPresentation({ | ||
| 883 | itemType: canonicalItemTypeFromAcpToolKind(kind), | ||
| 884 | title, | ||
| 885 | detail: fallbackDetail, | ||
| 886 | data, | ||
| 887 | fallbackSummary: title ?? "Tool", | ||
| 888 | }) | ||
| 889 | : undefined; | ||
| 890 | const status = normalizeToolCallStatus(input.status, options?.fallbackStatus); | ||
| 891 | return { | ||
| 892 | toolCallId, | ||
| 893 | ...(kind ? { kind } : {}), | ||
| 894 | ...(presentation?.summary ? { title: presentation.summary } : {}), | ||
| 895 | ...(status ? { status } : {}), | ||
| 896 | ...(command ? { command } : {}), | ||
| 897 | ...(presentation?.detail ? { detail: presentation.detail } : {}), | ||
| 898 | data, | ||
| 899 | }; | ||
| 900 | } | ||
| 901 | |||
| 902 | function parseTypedToolCallState( | ||
| 903 | event: AcpToolCallUpdate, | ||
| 904 | options?: { | ||
| 905 | readonly fallbackStatus?: "pending" | "inProgress" | "completed" | "failed"; | ||
| 906 | }, | ||
| 907 | ): AcpToolCallState | undefined { | ||
| 908 | return makeToolCallState( | ||
| 909 | { | ||
| 910 | toolCallId: event.toolCallId, | ||
| 911 | title: event.title, | ||
| 912 | kind: event.kind, | ||
| 913 | status: event.status, | ||
| 914 | rawInput: event.rawInput, | ||
| 915 | rawOutput: event.rawOutput, | ||
| 916 | content: event.content, | ||
| 917 | locations: event.locations, | ||
| 918 | _meta: event._meta, | ||
| 919 | }, | ||
| 920 | options, | ||
| 921 | ); | ||
| 922 | } | ||
| 923 | |||
| 924 | export function mergeToolCallState( | ||
| 925 | previous: AcpToolCallState | undefined, | ||
| 926 | next: AcpToolCallState, | ||
| 927 | ): AcpToolCallState { | ||
| 928 | const nextKind = next.kind ?? (typeof next.data.kind === "string" ? next.data.kind : undefined); | ||
| 929 | const kind = nextKind ?? previous?.kind; | ||
| 930 | const title = next.title ?? previous?.title; | ||
| 931 | const status = next.status ?? previous?.status; | ||
| 932 | const command = next.command ?? previous?.command; | ||
| 933 | const detail = next.detail ?? previous?.detail; | ||
| 934 | const data = mergeToolActivityData(previous?.data, next.data) ?? next.data; | ||
| 935 | return { | ||
| 936 | toolCallId: next.toolCallId, | ||
| 937 | ...(kind ? { kind } : {}), | ||
| 938 | ...(title ? { title } : {}), | ||
| 939 | ...(status ? { status } : {}), | ||
| 940 | ...(command ? { command } : {}), | ||
| 941 | ...(detail ? { detail } : {}), | ||
| 942 | data, | ||
| 943 | }; | ||
| 944 | } | ||
| 945 | |||
| 946 | // Even with bounded content (see TOOL_CALL_CONTENT_MAX_CHARS above), a redrawing terminal | ||
| 947 | // can still shift its bounded tail window on nearly every notification, which would emit | ||
| 948 | // a runtime event per redraw. Coalesce those: only emit early when the tool call's detail | ||
| 949 | // has grown meaningfully since the last emission, otherwise batch up to a small number of | ||
| 950 | // skipped updates before emitting anyway, so the UI still gets periodic progress and the | ||
| 951 | // final (completed/failed) state is always emitted immediately. | ||
| 952 | const TOOL_CALL_UPDATE_MIN_DETAIL_GROWTH_CHARS = 256; | ||
| 953 | const TOOL_CALL_UPDATE_COALESCE_LIMIT = 10; | ||
| 954 | |||
| 955 | export interface AcpToolCallEmitDecisionInput { | ||
| 956 | readonly previous: AcpToolCallState | undefined; | ||
| 957 | readonly next: AcpToolCallState; | ||
| 958 | readonly lastEmittedDetailLength: number | undefined; | ||
| 959 | readonly skippedSinceEmit: number; | ||
| 960 | } | ||
| 961 | |||
| 962 | export interface AcpToolCallEmitDecision { | ||
| 963 | readonly emit: boolean; | ||
| 964 | readonly skippedSinceEmit: number; | ||
| 965 | } | ||
| 966 | |||
| 967 | function toolCallOutputUnchanged(previous: AcpToolCallState, next: AcpToolCallState): boolean { | ||
| 968 | return ( | ||
| 969 | previous.data.content === next.data.content && previous.data.rawOutput === next.data.rawOutput | ||
| 970 | ); | ||
| 971 | } | ||
| 972 | |||
| 973 | // Command tools keep `detail` equal to the command, so live stdout lives on | ||
| 974 | // `data.content` / `data.rawOutput`. Measure that too, otherwise coalescing never | ||
| 975 | // sees growth and in-progress output is held until completed/failed. | ||
| 976 | export function toolCallProgressLength(state: AcpToolCallState): number { | ||
| 977 | let contentChars = 0; | ||
| 978 | const content = state.data.content; | ||
| 979 | if (Array.isArray(content)) { | ||
| 980 | for (const entry of content) { | ||
| 981 | if (!isRecord(entry)) { | ||
| 982 | continue; | ||
| 983 | } | ||
| 984 | const text = toolCallContentText(entry as EffectAcpSchema.ToolCallContent); | ||
| 985 | if (text) { | ||
| 986 | contentChars += text.length; | ||
| 987 | } | ||
| 988 | } | ||
| 989 | } | ||
| 990 | let rawOutputChars = 0; | ||
| 991 | const rawOutput = state.data.rawOutput; | ||
| 992 | if (isRecord(rawOutput)) { | ||
| 993 | for (const field of RAW_OUTPUT_TEXT_FIELDS) { | ||
| 994 | const value = rawOutput[field]; | ||
| 995 | if (typeof value === "string") { | ||
| 996 | rawOutputChars += value.length; | ||
| 997 | } | ||
| 998 | } | ||
| 999 | } | ||
| 1000 | return Math.max(state.detail?.length ?? 0, contentChars, rawOutputChars); | ||
| 1001 | } | ||
| 1002 | |||
| 1003 | function toolCallContentTexts(state: AcpToolCallState): string { | ||
| 1004 | const content = state.data.content; | ||
| 1005 | if (!Array.isArray(content)) return ""; | ||
| 1006 | return content | ||
| 1007 | .map((entry) => | ||
| 1008 | isRecord(entry) ? (toolCallContentText(entry as EffectAcpSchema.ToolCallContent) ?? "") : "", | ||
| 1009 | ) | ||
| 1010 | .join("\u0000"); | ||
| 1011 | } | ||
| 1012 | |||
| 1013 | // The output a user watches: detail, text content, and any `rawOutput`. A | ||
| 1014 | // streamed diff or `rawInput` is not part of it. | ||
| 1015 | export function toolCallVisibleOutputChanged( | ||
| 1016 | previous: AcpToolCallState, | ||
| 1017 | next: AcpToolCallState, | ||
| 1018 | ): boolean { | ||
| 1019 | return ( | ||
| 1020 | previous.detail !== next.detail || | ||
| 1021 | toolCallContentTexts(previous) !== toolCallContentTexts(next) || | ||
| 1022 | JSON.stringify(previous.data.rawOutput) !== JSON.stringify(next.data.rawOutput) | ||
| 1023 | ); | ||
| 1024 | } | ||
| 1025 | |||
| 1026 | export function decideToolCallUpdateEmission( | ||
| 1027 | input: AcpToolCallEmitDecisionInput, | ||
| 1028 | ): AcpToolCallEmitDecision { | ||
| 1029 | const { previous, next, lastEmittedDetailLength, skippedSinceEmit } = input; | ||
| 1030 | if (next.status === "completed" || next.status === "failed") { | ||
| 1031 | return { emit: true, skippedSinceEmit: 0 }; | ||
| 1032 | } | ||
| 1033 | if (previous === undefined || previous.title !== next.title || previous.status !== next.status) { | ||
| 1034 | return { emit: true, skippedSinceEmit: 0 }; | ||
| 1035 | } | ||
| 1036 | if (previous.detail === next.detail && toolCallOutputUnchanged(previous, next)) { | ||
| 1037 | return { emit: false, skippedSinceEmit }; | ||
| 1038 | } | ||
| 1039 | const progressLength = toolCallProgressLength(next); | ||
| 1040 | const grewMeaningfully = | ||
| 1041 | lastEmittedDetailLength === undefined || | ||
| 1042 | Math.abs(progressLength - lastEmittedDetailLength) >= TOOL_CALL_UPDATE_MIN_DETAIL_GROWTH_CHARS; | ||
| 1043 | if (grewMeaningfully || skippedSinceEmit + 1 >= TOOL_CALL_UPDATE_COALESCE_LIMIT) { | ||
| 1044 | return { emit: true, skippedSinceEmit: 0 }; | ||
| 1045 | } | ||
| 1046 | return { emit: false, skippedSinceEmit: skippedSinceEmit + 1 }; | ||
| 1047 | } | ||
| 1048 | |||
| 1049 | export interface AcpMcpToolCallIdentity { | ||
| 1050 | readonly server: string; | ||
| 1051 | readonly tool: string; | ||
| 1052 | readonly input?: Record<string, unknown>; | ||
| 1053 | } | ||
| 1054 | |||
| 1055 | /** Matches an invocation of T3's `acp-mcp-call` bridge fallback CLI. */ | ||
| 1056 | const ACP_MCP_FALLBACK_CALL = /(?:^|[\s"'=])acp-mcp-call[\s"']+([A-Za-z0-9_.-]+)(?:\s+(.+?))?\s*$/u; | ||
| 1057 | |||
| 1058 | function acpMcpFallbackInput(value: string | undefined): Record<string, unknown> | undefined { | ||
| 1059 | if (value === undefined) return undefined; | ||
| 1060 | const trimmed = value.trim(); | ||
| 1061 | const candidates = [ | ||
| 1062 | trimmed, | ||
| 1063 | ...(trimmed.startsWith("'") && trimmed.endsWith("'") ? [trimmed.slice(1, -1)] : []), | ||
| 1064 | ]; | ||
| 1065 | for (const candidate of candidates) { | ||
| 1066 | try { | ||
| 1067 | const parsed: unknown = JSON.parse(candidate); | ||
| 1068 | const unwrapped = typeof parsed === "string" ? JSON.parse(parsed) : parsed; | ||
| 1069 | if (isRecord(unwrapped)) return unwrapped; | ||
| 1070 | } catch { | ||
| 1071 | // ACP agents choose their own shell quoting. An unrecognized form keeps | ||
| 1072 | // the MCP identity but leaves the input empty instead of misreporting it. | ||
| 1073 | } | ||
| 1074 | } | ||
| 1075 | return undefined; | ||
| 1076 | } | ||
| 1077 | |||
| 1078 | /** | ||
| 1079 | * Agents flatten injected MCP tools into model-facing function names with no | ||
| 1080 | * shared convention (survey of the 2026-08 registry builds): Kilo and | ||
| 1081 | * opencode use `t3-code_<tool>`, claude-acp and qwen `mcp__t3-code__<tool>`, | ||
| 1082 | * Amp `mcp__t3_code__<tool>` (hyphens mangled), droid `t3-code___<tool>`, | ||
| 1083 | * Copilot `t3-code-<tool>`, cline appends `: <args json>`. T3 always injects | ||
| 1084 | * its server as "t3-code", and matches are additionally gated on the known | ||
| 1085 | * T3 tool inventory, so the separator match can stay loose. | ||
| 1086 | */ | ||
| 1087 | const T3_MCP_TITLE_CALL = | ||
| 1088 | /^(?:mcp[-_]{1,2})?t3[-_ ]?code[-_.:/ ]{1,3}(?<tool>[A-Za-z0-9][A-Za-z0-9_.-]*)(?::.*)?$/i; | ||
| 1089 | |||
| 1090 | /** | ||
| 1091 | * Gemini CLI titles injected MCP calls "<tool> (<server> MCP Server)" and | ||
| 1092 | * qwen-code appends ": <args json>" to the same template; Auggie namespaces | ||
| 1093 | * tool-first as "<tool>_t3-code". | ||
| 1094 | */ | ||
| 1095 | const T3_MCP_TITLE_SUFFIX_CALL = | ||
| 1096 | /^(?<tool>[A-Za-z0-9][A-Za-z0-9_.-]*?)(?: \(t3[-_ ]?code MCP Server\)(?::|$)|[-_.]t3[-_ ]?code$)/i; | ||
| 1097 | |||
| 1098 | /** | ||
| 1099 | * glm-acp-agent and Kimi CLI register injected MCP tools under their bare | ||
| 1100 | * names; Kimi additionally appends ": <raw args json>". Safe only because the | ||
| 1101 | * match is gated on the known T3 tool inventory. | ||
| 1102 | */ | ||
| 1103 | const T3_MCP_BARE_TITLE_CALL = /^(?<tool>[A-Za-z0-9_]+)(?::\s|$)/; | ||
| 1104 | |||
| 1105 | /** | ||
| 1106 | * Best-effort recovery of MCP identity from a generic ACP tool call. | ||
| 1107 | * | ||
| 1108 | * ACP has no typed MCP tool-call item, so agents surface MCP calls in | ||
| 1109 | * agent-specific shapes: codex-acp tags execute calls with | ||
| 1110 | * `rawInput.server`/`rawInput.tool`, while agents on T3's terminal fallback | ||
| 1111 | * run the `acp-mcp-call <tool>` CLI through their command or an embedded | ||
| 1112 | * client terminal. Recovered identity lets the projection render the same | ||
| 1113 | * branded MCP item that native providers produce. | ||
| 1114 | */ | ||
| 1115 | export function extractMcpToolCallIdentity( | ||
| 1116 | toolCall: AcpToolCallState, | ||
| 1117 | options?: { | ||
| 1118 | /** Command lines of client terminals embedded in this tool call. */ | ||
| 1119 | readonly embeddedTerminalCommands?: ReadonlyArray<string>; | ||
| 1120 | }, | ||
| 1121 | ): AcpMcpToolCallIdentity | undefined { | ||
| 1122 | const rawInput = isRecord(toolCall.data.rawInput) ? toolCall.data.rawInput : undefined; | ||
| 1123 | const meta = isRecord(toolCall.data.meta) ? toolCall.data.meta : undefined; | ||
| 1124 | const server = typeof rawInput?.server === "string" ? rawInput.server.trim() : ""; | ||
| 1125 | const tool = typeof rawInput?.tool === "string" ? rawInput.tool.trim() : ""; | ||
| 1126 | if (meta?.is_mcp_tool_call === true && server.length > 0 && tool.length > 0) { | ||
| 1127 | return { server, tool }; | ||
| 1128 | } | ||
| 1129 | // Agents without tagged rawInput identify their MCP calls through _meta | ||
| 1130 | // (goose, qwen, claude-acp) or only through the namespaced function name | ||
| 1131 | // in the title. The verbatim wire title survives merges even when a later | ||
| 1132 | // titleless or LLM-enriched update replaces the presentation title, so | ||
| 1133 | // match those rather than the summarized state title. | ||
| 1134 | const claudeCode = isRecord(meta?.claudeCode) ? meta.claudeCode : undefined; | ||
| 1135 | const gooseToolCall = isRecord(meta?.goose) | ||
| 1136 | ? isRecord(meta.goose.toolCall) | ||
| 1137 | ? meta.goose.toolCall | ||
| 1138 | : undefined | ||
| 1139 | : undefined; | ||
| 1140 | // qwen asserts the origin server explicitly, so any known tool suffix in | ||
| 1141 | // its toolName identifies the call even under future prefix formats. | ||
| 1142 | const metaServerId = typeof meta?.serverId === "string" ? meta.serverId.trim() : ""; | ||
| 1143 | const metaToolName = typeof meta?.toolName === "string" ? meta.toolName.trim() : ""; | ||
| 1144 | if (/^t3[-_ ]?code$/i.test(metaServerId) && metaToolName.length > 0) { | ||
| 1145 | for (const knownTool of T3_MCP_TOOL_NAMES) { | ||
| 1146 | const boundary = metaToolName.length - knownTool.length - 1; | ||
| 1147 | if ( | ||
| 1148 | metaToolName === knownTool || | ||
| 1149 | (metaToolName.endsWith(knownTool) && | ||
| 1150 | boundary >= 0 && | ||
| 1151 | !/[A-Za-z0-9]/.test(metaToolName.charAt(boundary))) | ||
| 1152 | ) { | ||
| 1153 | return { server: "t3-code", tool: knownTool }; | ||
| 1154 | } | ||
| 1155 | } | ||
| 1156 | } | ||
| 1157 | const gooseExtension = | ||
| 1158 | typeof gooseToolCall?.extensionName === "string" ? gooseToolCall.extensionName.trim() : ""; | ||
| 1159 | const assertsForeignOrigin = | ||
| 1160 | (metaServerId.length > 0 && !/^t3[-_ ]?code$/i.test(metaServerId)) || | ||
| 1161 | (gooseExtension.length > 0 && !/^t3[-_ ]?code$/i.test(gooseExtension)); | ||
| 1162 | // A foreign origin never brands as T3. qwen's serverId marks a real MCP | ||
| 1163 | // server, but goose reports its built-in extensions (developer__shell, | ||
| 1164 | // edits) the same way as user MCP servers, so goose stays unclassified and | ||
| 1165 | // keeps its command and file-change projections. | ||
| 1166 | if (assertsForeignOrigin) { | ||
| 1167 | if (metaServerId.length === 0 || metaToolName.length === 0) return undefined; | ||
| 1168 | const prefix = [`mcp__${metaServerId}__`, `mcp::${metaServerId}::`].find((prefix) => | ||
| 1169 | metaToolName.startsWith(prefix), | ||
| 1170 | ); | ||
| 1171 | const tool = prefix === undefined ? metaToolName : metaToolName.slice(prefix.length); | ||
| 1172 | return tool ? { server: metaServerId, tool } : undefined; | ||
| 1173 | } | ||
| 1174 | const candidates = [ | ||
| 1175 | meta?.toolName, | ||
| 1176 | claudeCode?.toolName, | ||
| 1177 | gooseToolCall?.toolName, | ||
| 1178 | toolCall.data.title, | ||
| 1179 | ].filter((value): value is string => typeof value === "string"); | ||
| 1180 | for (const candidate of candidates) { | ||
| 1181 | const trimmed = candidate.trim(); | ||
| 1182 | const qualified = /^mcp__(.+?)__(.+)$/i.exec(trimmed); | ||
| 1183 | if (qualified?.[1] && qualified[2] && !/^t3[-_ ]?code$/i.test(qualified[1])) | ||
| 1184 | return { server: qualified[1], tool: qualified[2] }; | ||
| 1185 | const match = | ||
| 1186 | T3_MCP_TITLE_CALL.exec(trimmed) ?? | ||
| 1187 | T3_MCP_TITLE_SUFFIX_CALL.exec(trimmed) ?? | ||
| 1188 | T3_MCP_BARE_TITLE_CALL.exec(trimmed); | ||
| 1189 | const candidateTool = match?.groups?.tool; | ||
| 1190 | if (candidateTool !== undefined && T3_MCP_TOOL_NAMES.has(candidateTool)) { | ||
| 1191 | return { server: "t3-code", tool: candidateTool }; | ||
| 1192 | } | ||
| 1193 | } | ||
| 1194 | const commands = [ | ||
| 1195 | ...(toolCall.command === undefined ? [] : [toolCall.command]), | ||
| 1196 | // pi-acp reports the exec command line only as the verbatim title. | ||
| 1197 | ...(typeof toolCall.data.title === "string" ? [toolCall.data.title] : []), | ||
| 1198 | ...(options?.embeddedTerminalCommands ?? []), | ||
| 1199 | ]; | ||
| 1200 | for (const command of commands) { | ||
| 1201 | const match = ACP_MCP_FALLBACK_CALL.exec(command); | ||
| 1202 | if (match?.[1] !== undefined) { | ||
| 1203 | // The acp-mcp-call CLI exists only as T3's bridge fallback, so the | ||
| 1204 | // server identity is T3's by construction. | ||
| 1205 | const input = acpMcpFallbackInput(match[2]); | ||
| 1206 | return { server: "t3-code", tool: match[1], ...(input === undefined ? {} : { input }) }; | ||
| 1207 | } | ||
| 1208 | } | ||
| 1209 | return undefined; | ||
| 1210 | } | ||
| 1211 | |||
| 1212 | /** | ||
| 1213 | * Terminal ids embedded in a raw tool_call update, captured before | ||
| 1214 | * {@link resolveEmbeddedTerminalContent} rewrites them into plain text, so the | ||
| 1215 | * projection can still resolve the terminal's command line. | ||
| 1216 | */ | ||
| 1217 | export function embeddedTerminalIdsFromSessionUpdate( | ||
| 1218 | notification: EffectAcpSchema.SessionNotification, | ||
| 1219 | ): { readonly toolCallId: string; readonly terminalIds: ReadonlyArray<string> } | undefined { | ||
| 1220 | const update = notification.update; | ||
| 1221 | if (update.sessionUpdate !== "tool_call" && update.sessionUpdate !== "tool_call_update") { | ||
| 1222 | return undefined; | ||
| 1223 | } | ||
| 1224 | const terminalIds = (update.content ?? []) | ||
| 1225 | .filter((entry) => entry.type === "terminal") | ||
| 1226 | .map((entry) => entry.terminalId); | ||
| 1227 | return terminalIds.length === 0 ? undefined : { toolCallId: update.toolCallId, terminalIds }; | ||
| 1228 | } | ||
| 1229 | |||
| 1230 | export function parsePermissionRequest( | ||
| 1231 | params: EffectAcpSchema.RequestPermissionRequest, | ||
| 1232 | ): AcpPermissionRequest { | ||
| 1233 | const toolCall = makeToolCallState( | ||
| 1234 | { | ||
| 1235 | toolCallId: params.toolCall.toolCallId, | ||
| 1236 | title: params.toolCall.title, | ||
| 1237 | kind: params.toolCall.kind, | ||
| 1238 | status: params.toolCall.status, | ||
| 1239 | rawInput: params.toolCall.rawInput, | ||
| 1240 | rawOutput: params.toolCall.rawOutput, | ||
| 1241 | content: params.toolCall.content, | ||
| 1242 | locations: params.toolCall.locations, | ||
| 1243 | }, | ||
| 1244 | { fallbackStatus: "pending" }, | ||
| 1245 | ); | ||
| 1246 | const kind = normalizeToolKind(params.toolCall.kind) ?? "unknown"; | ||
| 1247 | const detail = | ||
| 1248 | toolCall?.command ?? | ||
| 1249 | toolCall?.title ?? | ||
| 1250 | toolCall?.detail ?? | ||
| 1251 | (typeof params.sessionId === "string" ? `Session ${params.sessionId}` : undefined); | ||
| 1252 | return { | ||
| 1253 | kind, | ||
| 1254 | ...(detail ? { detail } : {}), | ||
| 1255 | ...(toolCall ? { toolCall } : {}), | ||
| 1256 | }; | ||
| 1257 | } | ||
| 1258 | |||
| 1259 | export function sessionUpdateIsReplay(params: EffectAcpSchema.SessionNotification): boolean { | ||
| 1260 | const meta = params._meta; | ||
| 1261 | return isRecord(meta) && meta.isReplay === true; | ||
| 1262 | } | ||
| 1263 | |||
| 1264 | /** Replay chunks and substantive updates during session/load; not Grok keepalives. */ | ||
| 1265 | export function sessionUpdateCountsAsLoadReplayActivity( | ||
| 1266 | params: EffectAcpSchema.SessionNotification, | ||
| 1267 | gatedSessionId?: string, | ||
| 1268 | ): boolean { | ||
| 1269 | if (gatedSessionId !== undefined && params.sessionId !== gatedSessionId) { | ||
| 1270 | return false; | ||
| 1271 | } | ||
| 1272 | if (sessionUpdateIsReplay(params)) return true; | ||
| 1273 | const update = params.update; | ||
| 1274 | switch (update.sessionUpdate) { | ||
| 1275 | case "agent_message_chunk": | ||
| 1276 | case "agent_thought_chunk": | ||
| 1277 | return (acpContentBlockDisplayText(update.content)?.length ?? 0) > 0; | ||
| 1278 | case "agent_message": | ||
| 1279 | case "agent_thought": | ||
| 1280 | return (update.content ?? []).some( | ||
| 1281 | (content) => (acpContentBlockDisplayText(content)?.length ?? 0) > 0, | ||
| 1282 | ); | ||
| 1283 | case "tool_call": | ||
| 1284 | case "tool_call_update": | ||
| 1285 | case "plan": | ||
| 1286 | case "plan_update": | ||
| 1287 | case "plan_removed": | ||
| 1288 | case "available_commands_update": | ||
| 1289 | case "current_mode_update": | ||
| 1290 | case "config_option_update": | ||
| 1291 | case "session_info_update": | ||
| 1292 | case "usage_update": | ||
| 1293 | case "compaction_update": | ||
| 1294 | case "compaction_summary_chunk": | ||
| 1295 | return true; | ||
| 1296 | default: | ||
| 1297 | return false; | ||
| 1298 | } | ||
| 1299 | } | ||
| 1300 | |||
| 1301 | export interface SessionLoadGate { | ||
| 1302 | readonly active: boolean; | ||
| 1303 | /** Only notifications for this session refresh load-replay idle activity. */ | ||
| 1304 | readonly sessionId: string; | ||
| 1305 | readonly lastActivityAtMillis: number | undefined; | ||
| 1306 | readonly idleGap: Duration.Duration; | ||
| 1307 | readonly initializeResult: EffectAcpSchema.InitializeResponse; | ||
| 1308 | readonly candidateConfigOptions?: ReadonlyArray<EffectAcpSchema.SessionConfigOption>; | ||
| 1309 | } | ||
| 1310 | |||
| 1311 | export const waitForSessionLoadReplayIdle = (input: { | ||
| 1312 | readonly gateRef: Ref.Ref<Option.Option<SessionLoadGate>>; | ||
| 1313 | }): Effect.Effect<EffectAcpSchema.LoadSessionResponse, never> => | ||
| 1314 | Effect.gen(function* () { | ||
| 1315 | const pollInterval = Duration.millis(25); | ||
| 1316 | while (true) { | ||
| 1317 | const gate = yield* Ref.get(input.gateRef); | ||
| 1318 | if ( | ||
| 1319 | Option.isSome(gate) && | ||
| 1320 | gate.value.active && | ||
| 1321 | gate.value.lastActivityAtMillis !== undefined | ||
| 1322 | ) { | ||
| 1323 | const idleGapMillis = Duration.toMillis(gate.value.idleGap); | ||
| 1324 | const nowMillis = yield* Clock.currentTimeMillis; | ||
| 1325 | if (nowMillis - gate.value.lastActivityAtMillis >= idleGapMillis) { | ||
| 1326 | return syntheticLoadSessionResponseFromInitialize(gate.value.initializeResult); | ||
| 1327 | } | ||
| 1328 | } | ||
| 1329 | yield* Effect.sleep(pollInterval); | ||
| 1330 | } | ||
| 1331 | }); | ||
| 1332 | |||
| 1333 | /** | ||
| 1334 | * Model state some agents (Grok) advertise in `initialize._meta.modelState`, before any | ||
| 1335 | * session exists. Undefined when the agent does not advertise it or the shape is unknown. | ||
| 1336 | */ | ||
| 1337 | export function sessionModelStateFromInitialize( | ||
| 1338 | initializeResult: EffectAcpSchema.InitializeResponse, | ||
| 1339 | ): EffectAcpSchema.SessionModelState | undefined { | ||
| 1340 | const meta = initializeResult._meta; | ||
| 1341 | const modelState = isRecord(meta) ? meta.modelState : undefined; | ||
| 1342 | return isSessionModelState(modelState) ? modelState : undefined; | ||
| 1343 | } | ||
| 1344 | |||
| 1345 | export function syntheticLoadSessionResponseFromInitialize( | ||
| 1346 | initializeResult: EffectAcpSchema.InitializeResponse, | ||
| 1347 | ): EffectAcpSchema.LoadSessionResponse { | ||
| 1348 | const meta = initializeResult._meta; | ||
| 1349 | const modeState = isRecord(meta) ? meta.modeState : undefined; | ||
| 1350 | |||
| 1351 | const modes = isSessionModeState(modeState) ? modeState : undefined; | ||
| 1352 | |||
| 1353 | return { | ||
| 1354 | ...(modes ? { modes } : {}), | ||
| 1355 | _meta: { | ||
| 1356 | t3SessionLoadReady: "replay_idle", | ||
| 1357 | }, | ||
| 1358 | }; | ||
| 1359 | } | ||
| 1360 | |||
| 1361 | // The parsed AcpToolCallState already carries bounded content (see makeToolCallState / | ||
| 1362 | // extractTextContentFromToolCallContent above), but the raw JSON-RPC notification is also | ||
| 1363 | // threaded through as `rawPayload` for logging/debugging and ends up persisted on the | ||
| 1364 | // runtime event. Substitute the same bounded `content`/`rawOutput` there so an oversized | ||
| 1365 | // cumulative update cannot smuggle the unbounded buffer back in through the raw payload. | ||
| 1366 | function boundToolCallRawPayload( | ||
| 1367 | params: EffectAcpSchema.SessionNotification, | ||
| 1368 | update: AcpToolCallUpdate, | ||
| 1369 | toolCall: AcpToolCallState, | ||
| 1370 | ): unknown { | ||
| 1371 | const boundedContent = toolCall.data.content; | ||
| 1372 | const boundedRawOutput = toolCall.data.rawOutput; | ||
| 1373 | const contentBounded = update.content !== undefined && boundedContent !== update.content; | ||
| 1374 | const rawOutputBounded = update.rawOutput !== undefined && boundedRawOutput !== update.rawOutput; | ||
| 1375 | if (!contentBounded && !rawOutputBounded) { | ||
| 1376 | return params; | ||
| 1377 | } | ||
| 1378 | return { | ||
| 1379 | ...params, | ||
| 1380 | update: { | ||
| 1381 | ...update, | ||
| 1382 | ...(contentBounded ? { content: boundedContent } : {}), | ||
| 1383 | ...(rawOutputBounded ? { rawOutput: boundedRawOutput } : {}), | ||
| 1384 | }, | ||
| 1385 | }; | ||
| 1386 | } | ||
| 1387 | |||
| 1388 | export function parseSessionUpdateEvent(params: EffectAcpSchema.SessionNotification): { | ||
| 1389 | readonly modeId?: string; | ||
| 1390 | readonly events: ReadonlyArray<AcpParsedSessionEvent>; | ||
| 1391 | } { | ||
| 1392 | const upd = params.update; | ||
| 1393 | const events: Array<AcpParsedSessionEvent> = []; | ||
| 1394 | let modeId: string | undefined; | ||
| 1395 | |||
| 1396 | switch (upd.sessionUpdate) { | ||
| 1397 | case "config_option_update": { | ||
| 1398 | const modes = parseSessionModeState({ configOptions: upd.configOptions }); | ||
| 1399 | if (modes !== undefined) { | ||
| 1400 | modeId = modes.currentModeId; | ||
| 1401 | events.push({ _tag: "ModeChanged", modeId }); | ||
| 1402 | } | ||
| 1403 | events.push({ | ||
| 1404 | _tag: "ConfigOptionsUpdated", | ||
| 1405 | configOptions: upd.configOptions, | ||
| 1406 | rawPayload: params, | ||
| 1407 | }); | ||
| 1408 | break; | ||
| 1409 | } | ||
| 1410 | case "available_commands_update": { | ||
| 1411 | events.push({ | ||
| 1412 | _tag: "AvailableCommandsUpdated", | ||
| 1413 | availableCommands: upd.availableCommands, | ||
| 1414 | rawPayload: params, | ||
| 1415 | }); | ||
| 1416 | break; | ||
| 1417 | } | ||
| 1418 | case "current_mode_update": { | ||
| 1419 | modeId = upd.currentModeId.trim(); | ||
| 1420 | if (modeId) { | ||
| 1421 | events.push({ | ||
| 1422 | _tag: "ModeChanged", | ||
| 1423 | modeId, | ||
| 1424 | }); | ||
| 1425 | } | ||
| 1426 | break; | ||
| 1427 | } | ||
| 1428 | case "plan": { | ||
| 1429 | const plan = upd.entries.map((entry, index) => ({ | ||
| 1430 | step: entry.content.trim().length > 0 ? entry.content.trim() : `Step ${index + 1}`, | ||
| 1431 | status: normalizePlanStepStatus(entry.status), | ||
| 1432 | })); | ||
| 1433 | if (plan.length > 0) { | ||
| 1434 | events.push({ | ||
| 1435 | _tag: "PlanUpdated", | ||
| 1436 | payload: { | ||
| 1437 | nativePlanId: "legacy", | ||
| 1438 | kind: "items", | ||
| 1439 | plan, | ||
| 1440 | }, | ||
| 1441 | rawPayload: params, | ||
| 1442 | }); | ||
| 1443 | } | ||
| 1444 | break; | ||
| 1445 | } | ||
| 1446 | case "plan_update": { | ||
| 1447 | const nativePlanId = upd.plan.planId.trim(); | ||
| 1448 | if (nativePlanId.length === 0) { | ||
| 1449 | break; | ||
| 1450 | } | ||
| 1451 | if (upd.plan.type === "items" && "entries" in upd.plan && Array.isArray(upd.plan.entries)) { | ||
| 1452 | const entries: ReadonlyArray<unknown> = upd.plan.entries; | ||
| 1453 | const plan = entries.flatMap((entry, index) => { | ||
| 1454 | if (!isRecord(entry) || typeof entry.content !== "string") return []; | ||
| 1455 | const status = typeof entry.status === "string" ? entry.status : "pending"; | ||
| 1456 | return [ | ||
| 1457 | { | ||
| 1458 | step: entry.content.trim().length > 0 ? entry.content.trim() : `Step ${index + 1}`, | ||
| 1459 | status: normalizePlanStepStatus(status), | ||
| 1460 | }, | ||
| 1461 | ]; | ||
| 1462 | }); | ||
| 1463 | events.push({ | ||
| 1464 | _tag: "PlanUpdated", | ||
| 1465 | payload: { nativePlanId, kind: "items", plan }, | ||
| 1466 | rawPayload: params, | ||
| 1467 | }); | ||
| 1468 | } else if ( | ||
| 1469 | upd.plan.type === "markdown" && | ||
| 1470 | "content" in upd.plan && | ||
| 1471 | typeof upd.plan.content === "string" | ||
| 1472 | ) { | ||
| 1473 | events.push({ | ||
| 1474 | _tag: "PlanUpdated", | ||
| 1475 | payload: { nativePlanId, kind: "markdown", markdown: upd.plan.content }, | ||
| 1476 | rawPayload: params, | ||
| 1477 | }); | ||
| 1478 | } else if ( | ||
| 1479 | upd.plan.type === "file" && | ||
| 1480 | "uri" in upd.plan && | ||
| 1481 | typeof upd.plan.uri === "string" | ||
| 1482 | ) { | ||
| 1483 | events.push({ | ||
| 1484 | _tag: "PlanUpdated", | ||
| 1485 | payload: { nativePlanId, kind: "file", uri: upd.plan.uri }, | ||
| 1486 | rawPayload: params, | ||
| 1487 | }); | ||
| 1488 | } else { | ||
| 1489 | events.push({ | ||
| 1490 | _tag: "PlanUpdated", | ||
| 1491 | payload: { nativePlanId, kind: "unknown", contentType: upd.plan.type }, | ||
| 1492 | rawPayload: params, | ||
| 1493 | }); | ||
| 1494 | } | ||
| 1495 | break; | ||
| 1496 | } | ||
| 1497 | case "plan_removed": { | ||
| 1498 | const nativePlanId = upd.planId.trim(); | ||
| 1499 | if (nativePlanId.length > 0) { | ||
| 1500 | events.push({ | ||
| 1501 | _tag: "PlanUpdated", | ||
| 1502 | payload: { nativePlanId, kind: "removed" }, | ||
| 1503 | rawPayload: params, | ||
| 1504 | }); | ||
| 1505 | } | ||
| 1506 | break; | ||
| 1507 | } | ||
| 1508 | case "tool_call": { | ||
| 1509 | const toolCall = parseTypedToolCallState(upd, { | ||
| 1510 | fallbackStatus: "pending", | ||
| 1511 | }); | ||
| 1512 | if (toolCall) { | ||
| 1513 | events.push({ | ||
| 1514 | _tag: "ToolCallUpdated", | ||
| 1515 | toolCall, | ||
| 1516 | rawPayload: boundToolCallRawPayload(params, upd, toolCall), | ||
| 1517 | }); | ||
| 1518 | } | ||
| 1519 | break; | ||
| 1520 | } | ||
| 1521 | case "tool_call_update": { | ||
| 1522 | const toolCall = parseTypedToolCallState(upd); | ||
| 1523 | if (toolCall) { | ||
| 1524 | events.push({ | ||
| 1525 | _tag: "ToolCallUpdated", | ||
| 1526 | toolCall, | ||
| 1527 | rawPayload: boundToolCallRawPayload(params, upd, toolCall), | ||
| 1528 | }); | ||
| 1529 | } | ||
| 1530 | break; | ||
| 1531 | } | ||
| 1532 | case "agent_message_chunk": { | ||
| 1533 | const text = acpContentBlockDisplayText(upd.content); | ||
| 1534 | if (text !== undefined && text.length > 0) { | ||
| 1535 | events.push({ | ||
| 1536 | _tag: "ContentDelta", | ||
| 1537 | ...(upd.messageId ? { messageId: upd.messageId } : {}), | ||
| 1538 | text, | ||
| 1539 | rawPayload: params, | ||
| 1540 | }); | ||
| 1541 | } | ||
| 1542 | break; | ||
| 1543 | } | ||
| 1544 | case "agent_thought_chunk": { | ||
| 1545 | if (upd.content.type === "text" && upd.content.text.length > 0) { | ||
| 1546 | events.push({ | ||
| 1547 | _tag: "ThoughtDelta", | ||
| 1548 | text: upd.content.text, | ||
| 1549 | rawPayload: params, | ||
| 1550 | }); | ||
| 1551 | } | ||
| 1552 | break; | ||
| 1553 | } | ||
| 1554 | case "compaction_update": { | ||
| 1555 | const summary = (upd.summary ?? []) | ||
| 1556 | .flatMap((content) => { | ||
| 1557 | const text = acpContentBlockDisplayText(content); | ||
| 1558 | return text === undefined || text.length === 0 ? [] : [text]; | ||
| 1559 | }) | ||
| 1560 | .join("\n"); | ||
| 1561 | const status = | ||
| 1562 | upd.status === "completed" | ||
| 1563 | ? ("completed" as const) | ||
| 1564 | : upd.status === "failed" || upd.status === "cancelled" | ||
| 1565 | ? ("failed" as const) | ||
| 1566 | : ("inProgress" as const); | ||
| 1567 | const toolCall = makeToolCallState({ | ||
| 1568 | toolCallId: `acp-compaction:${upd.compactionId}`, | ||
| 1569 | title: "Compact context", | ||
| 1570 | kind: "think", | ||
| 1571 | status, | ||
| 1572 | rawOutput: (upd.error ?? summary) || undefined, | ||
| 1573 | _meta: upd._meta, | ||
| 1574 | }); | ||
| 1575 | if (toolCall !== undefined) { | ||
| 1576 | events.push({ _tag: "ToolCallUpdated", toolCall, rawPayload: params }); | ||
| 1577 | } | ||
| 1578 | break; | ||
| 1579 | } | ||
| 1580 | case "compaction_summary_chunk": { | ||
| 1581 | const text = acpContentBlockDisplayText(upd.content); | ||
| 1582 | const toolCall = makeToolCallState({ | ||
| 1583 | toolCallId: `acp-compaction:${upd.compactionId}`, | ||
| 1584 | title: "Compact context", | ||
| 1585 | kind: "think", | ||
| 1586 | status: "inProgress", | ||
| 1587 | ...(text === undefined ? {} : { rawOutput: text }), | ||
| 1588 | _meta: upd._meta, | ||
| 1589 | }); | ||
| 1590 | if (toolCall !== undefined) { | ||
| 1591 | events.push({ _tag: "ToolCallUpdated", toolCall, rawPayload: params }); | ||
| 1592 | } | ||
| 1593 | break; | ||
| 1594 | } | ||
| 1595 | case "state_update": { | ||
| 1596 | if (upd.state === "idle" && upd.usage !== undefined && upd.usage !== null) { | ||
| 1597 | events.push({ | ||
| 1598 | _tag: "UsageUpdated", | ||
| 1599 | usage: { usedTokens: upd.usage.totalTokens }, | ||
| 1600 | rawPayload: params, | ||
| 1601 | }); | ||
| 1602 | } | ||
| 1603 | break; | ||
| 1604 | } | ||
| 1605 | case "usage_update": { | ||
| 1606 | const currency = upd.cost?.currency.trim().slice(0, 32); | ||
| 1607 | events.push({ | ||
| 1608 | _tag: "UsageUpdated", | ||
| 1609 | usage: { | ||
| 1610 | usedTokens: upd.used, | ||
| 1611 | ...(upd.size > 0 ? { maxTokens: upd.size } : {}), | ||
| 1612 | ...(upd.cost && currency ? { cost: { amount: upd.cost.amount, currency } } : {}), | ||
| 1613 | }, | ||
| 1614 | rawPayload: params, | ||
| 1615 | }); | ||
| 1616 | break; | ||
| 1617 | } | ||
| 1618 | case "session_info_update": { | ||
| 1619 | const title = upd.title?.trim().slice(0, 1_024); | ||
| 1620 | const updatedAt = upd.updatedAt?.trim().slice(0, 128); | ||
| 1621 | events.push({ | ||
| 1622 | _tag: "SessionInfoUpdated", | ||
| 1623 | metadata: { | ||
| 1624 | ...(upd.title === null ? { title: null } : title ? { title } : {}), | ||
| 1625 | ...(upd.updatedAt === null ? { updatedAt: null } : updatedAt ? { updatedAt } : {}), | ||
| 1626 | }, | ||
| 1627 | rawPayload: params, | ||
| 1628 | }); | ||
| 1629 | break; | ||
| 1630 | } | ||
| 1631 | case "_t3_unknown": { | ||
| 1632 | events.push({ | ||
| 1633 | _tag: "UnknownUpdate", | ||
| 1634 | updateType: boundedContentMetadata(upd.originalSessionUpdate, 128) || "unknown", | ||
| 1635 | rawPayload: params, | ||
| 1636 | }); | ||
| 1637 | break; | ||
| 1638 | } | ||
| 1639 | default: | ||
| 1640 | break; | ||
| 1641 | } | ||
| 1642 | |||
| 1643 | return { ...(modeId !== undefined ? { modeId } : {}), events }; | ||
| 1644 | } | ||
apps/server/src/provider/acp/AcpSessionConfig.test.ts deleted-263| ... | @@ -1,263 +0,0 @@ | ||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | |||
| 3 | import { ACP_SESSION_MODE_OPTION_ID, acpProviderOptionDescriptors } from "./AcpSessionConfig.ts"; | ||
| 4 | |||
| 5 | describe("acpProviderOptionDescriptors", () => { | ||
| 6 | it("maps non-model select options and excludes model and collaboration categories", () => { | ||
| 7 | const descriptors = acpProviderOptionDescriptors({ | ||
| 8 | configOptions: [ | ||
| 9 | { | ||
| 10 | id: "model", | ||
| 11 | name: "Model", | ||
| 12 | category: "model", | ||
| 13 | type: "select", | ||
| 14 | currentValue: "gpt-5.6-sol", | ||
| 15 | options: [{ value: "gpt-5.6-sol", name: "GPT-5.6-Sol" }], | ||
| 16 | }, | ||
| 17 | { | ||
| 18 | id: "collaboration_mode", | ||
| 19 | name: "Collaboration mode", | ||
| 20 | category: "collaboration_mode", | ||
| 21 | type: "select", | ||
| 22 | currentValue: "default", | ||
| 23 | options: [ | ||
| 24 | { value: "default", name: "Default" }, | ||
| 25 | { value: "plan", name: "Plan" }, | ||
| 26 | ], | ||
| 27 | }, | ||
| 28 | { | ||
| 29 | id: "reasoning_effort", | ||
| 30 | name: "Reasoning effort", | ||
| 31 | category: "thought_level", | ||
| 32 | type: "select", | ||
| 33 | currentValue: "medium", | ||
| 34 | options: [ | ||
| 35 | { value: "low", name: "Low" }, | ||
| 36 | { value: "medium", name: "Medium", description: "Balanced" }, | ||
| 37 | ], | ||
| 38 | }, | ||
| 39 | ], | ||
| 40 | modeState: undefined, | ||
| 41 | }); | ||
| 42 | |||
| 43 | expect(descriptors).toEqual([ | ||
| 44 | { | ||
| 45 | id: "reasoning_effort", | ||
| 46 | label: "Reasoning effort", | ||
| 47 | type: "select", | ||
| 48 | options: [ | ||
| 49 | { id: "low", label: "Low" }, | ||
| 50 | { id: "medium", label: "Medium", description: "Balanced" }, | ||
| 51 | ], | ||
| 52 | currentValue: "medium", | ||
| 53 | }, | ||
| 54 | ]); | ||
| 55 | }); | ||
| 56 | |||
| 57 | it("maps boolean session options", () => { | ||
| 58 | const descriptors = acpProviderOptionDescriptors({ | ||
| 59 | configOptions: [ | ||
| 60 | { | ||
| 61 | id: "fast_mode", | ||
| 62 | name: "Fast mode", | ||
| 63 | description: "Use lower-latency inference.", | ||
| 64 | type: "boolean", | ||
| 65 | currentValue: true, | ||
| 66 | }, | ||
| 67 | ], | ||
| 68 | modeState: undefined, | ||
| 69 | }); | ||
| 70 | |||
| 71 | expect(descriptors).toEqual([ | ||
| 72 | { | ||
| 73 | id: "fast_mode", | ||
| 74 | label: "Fast mode", | ||
| 75 | description: "Use lower-latency inference.", | ||
| 76 | type: "boolean", | ||
| 77 | currentValue: true, | ||
| 78 | }, | ||
| 79 | ]); | ||
| 80 | }); | ||
| 81 | |||
| 82 | it("flattens grouped select choices and drops empty or duplicate values", () => { | ||
| 83 | const descriptors = acpProviderOptionDescriptors({ | ||
| 84 | configOptions: [ | ||
| 85 | { | ||
| 86 | id: "sandbox", | ||
| 87 | name: "Sandbox", | ||
| 88 | type: "select", | ||
| 89 | currentValue: "workspace", | ||
| 90 | options: [ | ||
| 91 | { | ||
| 92 | groupId: "standard", | ||
| 93 | name: "Standard", | ||
| 94 | options: [ | ||
| 95 | { value: "workspace", name: "Workspace" }, | ||
| 96 | { value: " ", name: "Blank" }, | ||
| 97 | ], | ||
| 98 | }, | ||
| 99 | { | ||
| 100 | groupId: "extra", | ||
| 101 | name: "Extra", | ||
| 102 | options: [ | ||
| 103 | { value: "workspace", name: "Duplicate" }, | ||
| 104 | { value: "full", name: "Full" }, | ||
| 105 | ], | ||
| 106 | }, | ||
| 107 | ], | ||
| 108 | }, | ||
| 109 | ], | ||
| 110 | modeState: undefined, | ||
| 111 | }); | ||
| 112 | |||
| 113 | expect(descriptors).toEqual([ | ||
| 114 | { | ||
| 115 | id: "sandbox", | ||
| 116 | label: "Sandbox", | ||
| 117 | type: "select", | ||
| 118 | options: [ | ||
| 119 | { id: "workspace", label: "Workspace" }, | ||
| 120 | { id: "full", label: "Full" }, | ||
| 121 | ], | ||
| 122 | currentValue: "workspace", | ||
| 123 | }, | ||
| 124 | ]); | ||
| 125 | }); | ||
| 126 | |||
| 127 | it("synthesizes a mode descriptor for agents without a mode config option", () => { | ||
| 128 | const descriptors = acpProviderOptionDescriptors({ | ||
| 129 | configOptions: [], | ||
| 130 | modeState: { | ||
| 131 | currentModeId: "safe", | ||
| 132 | availableModes: [ | ||
| 133 | { id: "safe", name: "Safe" }, | ||
| 134 | { id: "yolo", name: "YOLO", description: "No approvals" }, | ||
| 135 | ], | ||
| 136 | }, | ||
| 137 | }); | ||
| 138 | |||
| 139 | expect(descriptors).toEqual([ | ||
| 140 | { | ||
| 141 | id: ACP_SESSION_MODE_OPTION_ID, | ||
| 142 | label: "Mode", | ||
| 143 | description: "Session mode advertised by the ACP agent.", | ||
| 144 | type: "select", | ||
| 145 | options: [ | ||
| 146 | { id: "safe", label: "Safe" }, | ||
| 147 | { id: "yolo", label: "YOLO", description: "No approvals" }, | ||
| 148 | ], | ||
| 149 | currentValue: "safe", | ||
| 150 | }, | ||
| 151 | ]); | ||
| 152 | }); | ||
| 153 | |||
| 154 | it("suppresses the synthetic mode descriptor when modes duplicate a config option", () => { | ||
| 155 | const descriptors = acpProviderOptionDescriptors({ | ||
| 156 | configOptions: [ | ||
| 157 | { | ||
| 158 | id: "thinking", | ||
| 159 | name: "Thinking", | ||
| 160 | category: "thought_level", | ||
| 161 | type: "select", | ||
| 162 | currentValue: "medium", | ||
| 163 | options: [ | ||
| 164 | { value: "off", name: "Thinking: off" }, | ||
| 165 | { value: "medium", name: "Thinking: medium" }, | ||
| 166 | ], | ||
| 167 | }, | ||
| 168 | ], | ||
| 169 | modeState: { | ||
| 170 | currentModeId: "medium", | ||
| 171 | availableModes: [ | ||
| 172 | { id: "off", name: "Thinking: off" }, | ||
| 173 | { id: "medium", name: "Thinking: medium" }, | ||
| 174 | ], | ||
| 175 | }, | ||
| 176 | }); | ||
| 177 | |||
| 178 | expect(descriptors.map((descriptor) => descriptor.id)).toEqual(["thinking"]); | ||
| 179 | }); | ||
| 180 | |||
| 181 | it("does not let an unrelated select option hide the session modes API", () => { | ||
| 182 | const descriptors = acpProviderOptionDescriptors({ | ||
| 183 | configOptions: [ | ||
| 184 | { | ||
| 185 | id: "unrelated", | ||
| 186 | name: "Unrelated", | ||
| 187 | type: "select", | ||
| 188 | currentValue: "safe", | ||
| 189 | options: [ | ||
| 190 | { value: "safe", name: "Safe" }, | ||
| 191 | { value: "yolo", name: "YOLO" }, | ||
| 192 | ], | ||
| 193 | }, | ||
| 194 | ], | ||
| 195 | modeState: { | ||
| 196 | currentModeId: "safe", | ||
| 197 | availableModes: [ | ||
| 198 | { id: "safe", name: "Safe" }, | ||
| 199 | { id: "yolo", name: "YOLO" }, | ||
| 200 | ], | ||
| 201 | }, | ||
| 202 | }); | ||
| 203 | |||
| 204 | expect(descriptors.map((descriptor) => descriptor.id)).toEqual([ | ||
| 205 | "unrelated", | ||
| 206 | ACP_SESSION_MODE_OPTION_ID, | ||
| 207 | ]); | ||
| 208 | }); | ||
| 209 | |||
| 210 | it("preserves canonical opaque option values and omits values the wire would mutate", () => { | ||
| 211 | const exactValue = "value:with:opaque-markers"; | ||
| 212 | const descriptors = acpProviderOptionDescriptors({ | ||
| 213 | configOptions: [ | ||
| 214 | { | ||
| 215 | id: "opaque", | ||
| 216 | name: "Opaque", | ||
| 217 | type: "select", | ||
| 218 | currentValue: exactValue, | ||
| 219 | options: [ | ||
| 220 | { value: exactValue, name: "Exact" }, | ||
| 221 | { value: " value with spaces ", name: "Would be trimmed" }, | ||
| 222 | { value: "x".repeat(257), name: "Too long" }, | ||
| 223 | ], | ||
| 224 | }, | ||
| 225 | ], | ||
| 226 | modeState: undefined, | ||
| 227 | }); | ||
| 228 | |||
| 229 | expect(descriptors[0]).toMatchObject({ | ||
| 230 | id: "opaque", | ||
| 231 | currentValue: exactValue, | ||
| 232 | options: [{ id: exactValue, label: "Exact" }], | ||
| 233 | }); | ||
| 234 | }); | ||
| 235 | |||
| 236 | it("suppresses the synthetic mode descriptor when a mode-category option exists", () => { | ||
| 237 | const descriptors = acpProviderOptionDescriptors({ | ||
| 238 | configOptions: [ | ||
| 239 | { | ||
| 240 | id: "mode", | ||
| 241 | name: "Mode", | ||
| 242 | category: "mode", | ||
| 243 | type: "select", | ||
| 244 | currentValue: "agent", | ||
| 245 | options: [ | ||
| 246 | { value: "read-only", name: "Read-only" }, | ||
| 247 | { value: "agent", name: "Agent" }, | ||
| 248 | ], | ||
| 249 | }, | ||
| 250 | ], | ||
| 251 | modeState: { | ||
| 252 | currentModeId: "agent", | ||
| 253 | availableModes: [ | ||
| 254 | { id: "read-only", name: "Read-only" }, | ||
| 255 | { id: "agent", name: "Agent" }, | ||
| 256 | { id: "agent-full-access", name: "Agent (full access)" }, | ||
| 257 | ], | ||
| 258 | }, | ||
| 259 | }); | ||
| 260 | |||
| 261 | expect(descriptors.map((descriptor) => descriptor.id)).toEqual(["mode"]); | ||
| 262 | }); | ||
| 263 | }); | ||
apps/server/src/provider/acp/AcpSessionConfig.ts deleted-153| ... | @@ -1,153 +0,0 @@ | ||
| 1 | import type { ProviderOptionChoice, ProviderOptionDescriptor } from "@t3tools/contracts"; | ||
| 2 | import type * as EffectAcpSchema from "effect-acp/compat"; | ||
| 3 | |||
| 4 | import type { AcpSessionModeState } from "./AcpRuntimeModel.ts"; | ||
| 5 | |||
| 6 | /** | ||
| 7 | * Maps ACP session configuration and session modes onto T3's provider option | ||
| 8 | * descriptors so the existing model-options UI can drive them. | ||
| 9 | * | ||
| 10 | * Model selection stays on the dedicated model picker: `category: "model"` | ||
| 11 | * options are excluded here because they already surface as models. Session | ||
| 12 | * modes advertised through the modes API (rather than a config option) are | ||
| 13 | * folded into one synthetic descriptor that the ACP adapter routes to | ||
| 14 | * `session/set_mode`. | ||
| 15 | */ | ||
| 16 | |||
| 17 | /** Synthetic descriptor ID for agents that expose modes outside config options. */ | ||
| 18 | export const ACP_SESSION_MODE_OPTION_ID = "_t3/session-mode"; | ||
| 19 | |||
| 20 | const MAX_OPTION_DESCRIPTORS = 16; | ||
| 21 | const MAX_OPTION_CHOICES = 64; | ||
| 22 | const MAX_TEXT_LENGTH = 256; | ||
| 23 | const MAX_DESCRIPTION_LENGTH = 1_024; | ||
| 24 | |||
| 25 | const boundedText = (value: string | null | undefined, maximumLength: number): string => | ||
| 26 | (value ?? "").trim().slice(0, maximumLength); | ||
| 27 | |||
| 28 | const boundedOpaqueValue = (value: string, maximumLength: number): string | undefined => | ||
| 29 | value.length > 0 && value === value.trim() && value.length <= maximumLength ? value : undefined; | ||
| 30 | |||
| 31 | function flattenSelectChoices( | ||
| 32 | options: EffectAcpSchema.SessionConfigSelectOptions, | ||
| 33 | ): ReadonlyArray<EffectAcpSchema.SessionConfigSelectOption> { | ||
| 34 | return options.flatMap((candidate) => ("value" in candidate ? [candidate] : candidate.options)); | ||
| 35 | } | ||
| 36 | |||
| 37 | function selectChoices( | ||
| 38 | candidates: ReadonlyArray<{ | ||
| 39 | readonly value: string; | ||
| 40 | readonly name: string; | ||
| 41 | readonly description?: string | null | undefined; | ||
| 42 | }>, | ||
| 43 | ): ReadonlyArray<ProviderOptionChoice> { | ||
| 44 | const seen = new Set<string>(); | ||
| 45 | const choices: Array<ProviderOptionChoice> = []; | ||
| 46 | for (const candidate of candidates) { | ||
| 47 | const id = boundedOpaqueValue(candidate.value, MAX_TEXT_LENGTH); | ||
| 48 | if (id === undefined || seen.has(id)) continue; | ||
| 49 | seen.add(id); | ||
| 50 | const description = boundedText(candidate.description, MAX_DESCRIPTION_LENGTH); | ||
| 51 | choices.push({ | ||
| 52 | id, | ||
| 53 | label: boundedText(candidate.name, MAX_TEXT_LENGTH) || id, | ||
| 54 | ...(description ? { description } : {}), | ||
| 55 | }); | ||
| 56 | if (choices.length === MAX_OPTION_CHOICES) break; | ||
| 57 | } | ||
| 58 | return choices; | ||
| 59 | } | ||
| 60 | |||
| 61 | export function acpProviderOptionDescriptors(input: { | ||
| 62 | readonly configOptions: ReadonlyArray<EffectAcpSchema.SessionConfigOption> | null | undefined; | ||
| 63 | readonly modeState: AcpSessionModeState | undefined; | ||
| 64 | }): ReadonlyArray<ProviderOptionDescriptor> { | ||
| 65 | const descriptors: Array<ProviderOptionDescriptor> = []; | ||
| 66 | const seen = new Set<string>(); | ||
| 67 | const mirroredModeChoiceSets: Array<ReadonlySet<string>> = []; | ||
| 68 | let hasModeCategory = false; | ||
| 69 | |||
| 70 | for (const option of input.configOptions ?? []) { | ||
| 71 | // "model" options surface as the model list; "collaboration_mode" options | ||
| 72 | // are driven by T3's own plan/build interaction mode in the ACP adapter. | ||
| 73 | if (option.category === "model" || option.category === "collaboration_mode") { | ||
| 74 | continue; | ||
| 75 | } | ||
| 76 | const id = boundedOpaqueValue(option.id, MAX_TEXT_LENGTH); | ||
| 77 | if (id === undefined || seen.has(id)) continue; | ||
| 78 | seen.add(id); | ||
| 79 | const description = boundedText(option.description, MAX_DESCRIPTION_LENGTH); | ||
| 80 | const base = { | ||
| 81 | id, | ||
| 82 | label: boundedText(option.name, MAX_TEXT_LENGTH) || id, | ||
| 83 | ...(description ? { description } : {}), | ||
| 84 | } as const; | ||
| 85 | if (option.type === "boolean") { | ||
| 86 | if (option.category === "mode") hasModeCategory = true; | ||
| 87 | descriptors.push({ | ||
| 88 | ...base, | ||
| 89 | type: "boolean", | ||
| 90 | currentValue: option.currentValue, | ||
| 91 | }); | ||
| 92 | if (descriptors.length === MAX_OPTION_DESCRIPTORS) return descriptors; | ||
| 93 | continue; | ||
| 94 | } | ||
| 95 | const choices = selectChoices(flattenSelectChoices(option.options)); | ||
| 96 | if (choices.length === 0) { | ||
| 97 | seen.delete(id); | ||
| 98 | continue; | ||
| 99 | } | ||
| 100 | if (option.category === "mode") hasModeCategory = true; | ||
| 101 | if (option.category === "thought_level") { | ||
| 102 | mirroredModeChoiceSets.push(new Set(choices.map((choice) => choice.id))); | ||
| 103 | } | ||
| 104 | const currentValue = option.currentValue; | ||
| 105 | descriptors.push({ | ||
| 106 | ...base, | ||
| 107 | type: "select", | ||
| 108 | options: choices, | ||
| 109 | ...(currentValue && choices.some((choice) => choice.id === currentValue) | ||
| 110 | ? { currentValue } | ||
| 111 | : {}), | ||
| 112 | }); | ||
| 113 | if (descriptors.length === MAX_OPTION_DESCRIPTORS) return descriptors; | ||
| 114 | } | ||
| 115 | |||
| 116 | const modeState = input.modeState; | ||
| 117 | if (modeState !== undefined && !hasModeCategory) { | ||
| 118 | const choices = selectChoices( | ||
| 119 | modeState.availableModes.map((mode) => ({ | ||
| 120 | value: mode.id, | ||
| 121 | name: mode.name, | ||
| 122 | description: mode.description, | ||
| 123 | })), | ||
| 124 | ); | ||
| 125 | const currentValue = modeState.currentModeId; | ||
| 126 | // Some agents mirror one knob through both the modes API and a config | ||
| 127 | // option (codex-acp advertises its thinking levels as modes too). Skip | ||
| 128 | // the synthetic descriptor when an existing descriptor already exposes | ||
| 129 | // the same choice set, so the composer shows the knob once. | ||
| 130 | const duplicatesKnownModeDescriptor = mirroredModeChoiceSets.some( | ||
| 131 | (choiceIds) => | ||
| 132 | choiceIds.size === choices.length && choices.every((choice) => choiceIds.has(choice.id)), | ||
| 133 | ); | ||
| 134 | if ( | ||
| 135 | choices.length > 1 && | ||
| 136 | !duplicatesKnownModeDescriptor && | ||
| 137 | !seen.has(ACP_SESSION_MODE_OPTION_ID) | ||
| 138 | ) { | ||
| 139 | descriptors.push({ | ||
| 140 | id: ACP_SESSION_MODE_OPTION_ID, | ||
| 141 | label: "Mode", | ||
| 142 | description: "Session mode advertised by the ACP agent.", | ||
| 143 | type: "select", | ||
| 144 | options: choices, | ||
| 145 | ...(currentValue && choices.some((choice) => choice.id === currentValue) | ||
| 146 | ? { currentValue } | ||
| 147 | : {}), | ||
| 148 | }); | ||
| 149 | } | ||
| 150 | } | ||
| 151 | |||
| 152 | return descriptors.slice(0, MAX_OPTION_DESCRIPTORS); | ||
| 153 | } | ||
apps/server/src/provider/acp/AcpSessionRuntime.processTree.test.ts+131-126| ... | @@ -11,29 +11,7 @@ import * as Effect from "effect/Effect"; | ... | @@ -11,29 +11,7 @@ import * as Effect from "effect/Effect"; |
| 11 | import * as Exit from "effect/Exit"; | 11 | import * as Exit from "effect/Exit"; |
| 12 | import * as Scope from "effect/Scope"; | 12 | import * as Scope from "effect/Scope"; |
| 13 | 13 | ||
| 14 | import { | 14 | import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; |
| 15 | capturePosixOwnershipLedger, | ||
| 16 | isSafeCgroupPath, | ||
| 17 | makeLinuxCgroupController, | ||
| 18 | makePosixProcessTreeController, | ||
| 19 | observePosixOwnershipLedger, | ||
| 20 | observePosixOwnershipLedgerContinuously, | ||
| 21 | parseCgroup2Mount, | ||
| 22 | parseCgroup2Mounts, | ||
| 23 | parseUnifiedCgroupPath, | ||
| 24 | posixProcessIsZombie, | ||
| 25 | resolveLinuxCgroupTargetCommand, | ||
| 26 | signalLinuxCgroupRootTerm, | ||
| 27 | sweepStaleLinuxCgroupSiblings, | ||
| 28 | terminateLinuxCgroupLease, | ||
| 29 | terminatePosixOwnedProcessTree, | ||
| 30 | wrapCommandForLinuxCgroup, | ||
| 31 | type AcpOwnedPosixProcess, | ||
| 32 | type AcpLinuxCgroupLease, | ||
| 33 | type AcpPosixOwnershipRoot, | ||
| 34 | type AcpPosixProcessIdentity, | ||
| 35 | type AcpPosixProcessTreeController, | ||
| 36 | } from "./AcpSessionRuntime.ts"; | ||
| 37 | 15 | ||
| 38 | const threadSpawnHelperSource = NodeURL.fileURLToPath( | 16 | const threadSpawnHelperSource = NodeURL.fileURLToPath( |
| 39 | new URL("../../../scripts/acp-thread-spawn-helper.c", import.meta.url), | 17 | new URL("../../../scripts/acp-thread-spawn-helper.c", import.meta.url), |
| ... | @@ -46,7 +24,7 @@ const identity = ( | ... | @@ -46,7 +24,7 @@ const identity = ( |
| 46 | sid: number, | 24 | sid: number, |
| 47 | startTime = String(pid), | 25 | startTime = String(pid), |
| 48 | state = "S", | 26 | state = "S", |
| 49 | ): AcpPosixProcessIdentity => ({ | 27 | ): AcpSessionRuntime.AcpPosixProcessIdentity => ({ |
| 50 | executable: `/proc/${pid}/exe`, | 28 | executable: `/proc/${pid}/exe`, |
| 51 | pgid, | 29 | pgid, |
| 52 | pid, | 30 | pid, |
| ... | @@ -57,16 +35,16 @@ const identity = ( | ... | @@ -57,16 +35,16 @@ const identity = ( |
| 57 | }); | 35 | }); |
| 58 | 36 | ||
| 59 | function makeController(input: { | 37 | function makeController(input: { |
| 60 | readonly processes: ReadonlyArray<AcpPosixProcessIdentity>; | 38 | readonly processes: ReadonlyArray<AcpSessionRuntime.AcpPosixProcessIdentity>; |
| 61 | readonly onProcess?: ( | 39 | readonly onProcess?: ( |
| 62 | processes: Map<number, AcpPosixProcessIdentity>, | 40 | processes: Map<number, AcpSessionRuntime.AcpPosixProcessIdentity>, |
| 63 | pid: number, | 41 | pid: number, |
| 64 | signal: NodeJS.Signals, | 42 | signal: NodeJS.Signals, |
| 65 | ) => void; | 43 | ) => void; |
| 66 | }) { | 44 | }) { |
| 67 | const processes = new Map(input.processes.map((process) => [process.pid, process])); | 45 | const processes = new Map(input.processes.map((process) => [process.pid, process])); |
| 68 | const signals: Array<string> = []; | 46 | const signals: Array<string> = []; |
| 69 | const controller: AcpPosixProcessTreeController = { | 47 | const controller: AcpSessionRuntime.AcpPosixProcessTreeController = { |
| 70 | childPidsOf: (pid) => | 48 | childPidsOf: (pid) => |
| 71 | [...processes.values()] | 49 | [...processes.values()] |
| 72 | .filter((process) => process.ppid === pid) | 50 | .filter((process) => process.ppid === pid) |
| ... | @@ -96,19 +74,23 @@ function processExists(pid: number): boolean { | ... | @@ -96,19 +74,23 @@ function processExists(pid: number): boolean { |
| 96 | 74 | ||
| 97 | describe("terminatePosixOwnedProcessTree", () => { | 75 | describe("terminatePosixOwnedProcessTree", () => { |
| 98 | it("parses unified cgroup paths and escaped cgroup2 mountinfo", () => { | 76 | it("parses unified cgroup paths and escaped cgroup2 mountinfo", () => { |
| 99 | expect(parseUnifiedCgroupPath("0::/user.slice/app.scope\n")).toBe("/user.slice/app.scope"); | 77 | expect(AcpSessionRuntime.parseUnifiedCgroupPath("0::/user.slice/app.scope\n")).toBe( |
| 100 | expect(parseUnifiedCgroupPath("0::/one\n0::/two\n")).toBeUndefined(); | 78 | "/user.slice/app.scope", |
| 101 | expect(isSafeCgroupPath("/user.slice/app.scope")).toBe(true); | 79 | ); |
| 102 | expect(isSafeCgroupPath("/user.slice/../escape")).toBe(false); | 80 | expect(AcpSessionRuntime.parseUnifiedCgroupPath("0::/one\n0::/two\n")).toBeUndefined(); |
| 103 | expect(isSafeCgroupPath("/user.slice/app.scope (deleted)")).toBe(false); | 81 | expect(AcpSessionRuntime.isSafeCgroupPath("/user.slice/app.scope")).toBe(true); |
| 82 | expect(AcpSessionRuntime.isSafeCgroupPath("/user.slice/../escape")).toBe(false); | ||
| 83 | expect(AcpSessionRuntime.isSafeCgroupPath("/user.slice/app.scope (deleted)")).toBe(false); | ||
| 104 | expect( | 84 | expect( |
| 105 | parseCgroup2Mount( | 85 | AcpSessionRuntime.parseCgroup2Mount( |
| 106 | "42 31 0:37 /user.slice /sys/fs/cgroup/user\\040mount rw - cgroup2 cgroup2 rw\n", | 86 | "42 31 0:37 /user.slice /sys/fs/cgroup/user\\040mount rw - cgroup2 cgroup2 rw\n", |
| 107 | ), | 87 | ), |
| 108 | ).toEqual({ mountPoint: "/sys/fs/cgroup/user mount", root: "/user.slice" }); | 88 | ).toEqual({ mountPoint: "/sys/fs/cgroup/user mount", root: "/user.slice" }); |
| 109 | expect(parseCgroup2Mount("42 31 0:37 / /sys/fs/cgroup rw - tmpfs tmpfs rw\n")).toBeUndefined(); | ||
| 110 | expect( | 89 | expect( |
| 111 | parseCgroup2Mounts( | 90 | AcpSessionRuntime.parseCgroup2Mount("42 31 0:37 / /sys/fs/cgroup rw - tmpfs tmpfs rw\n"), |
| 91 | ).toBeUndefined(); | ||
| 92 | expect( | ||
| 93 | AcpSessionRuntime.parseCgroup2Mounts( | ||
| 112 | "41 31 0:36 /other /sys/fs/cgroup/other rw - cgroup2 cgroup2 rw\n" + | 94 | "41 31 0:36 /other /sys/fs/cgroup/other rw - cgroup2 cgroup2 rw\n" + |
| 113 | "42 31 0:37 /user.slice /sys/fs/cgroup/user rw - cgroup2 cgroup2 rw\n", | 95 | "42 31 0:37 /user.slice /sys/fs/cgroup/user rw - cgroup2 cgroup2 rw\n", |
| 114 | ), | 96 | ), |
| ... | @@ -140,7 +122,7 @@ describe("terminatePosixOwnedProcessTree", () => { | ... | @@ -140,7 +122,7 @@ describe("terminatePosixOwnedProcessTree", () => { |
| 140 | const unrelated = writeSibling("other-lease", "0"); | 122 | const unrelated = writeSibling("other-lease", "0"); |
| 141 | const isProcessAlive = (pid: number) => pid === process.pid || pid === liveForeignPid; | 123 | const isProcessAlive = (pid: number) => pid === process.pid || pid === liveForeignPid; |
| 142 | try { | 124 | try { |
| 143 | sweepStaleLinuxCgroupSiblings(parent, { | 125 | AcpSessionRuntime.sweepStaleLinuxCgroupSiblings(parent, { |
| 144 | isProcessAlive, | 126 | isProcessAlive, |
| 145 | readPopulated: (path) => populatedByPath.get(path), | 127 | readPopulated: (path) => populatedByPath.get(path), |
| 146 | remove: (path) => { | 128 | remove: (path) => { |
| ... | @@ -155,7 +137,7 @@ describe("terminatePosixOwnedProcessTree", () => { | ... | @@ -155,7 +137,7 @@ describe("terminatePosixOwnedProcessTree", () => { |
| 155 | expect(NodeFS.existsSync(unrelated)).toBe(true); | 137 | expect(NodeFS.existsSync(unrelated)).toBe(true); |
| 156 | // Missing populated state must not remove a sibling. | 138 | // Missing populated state must not remove a sibling. |
| 157 | writeSibling(`t3-acp-${deadPid}-eeee`, null); | 139 | writeSibling(`t3-acp-${deadPid}-eeee`, null); |
| 158 | sweepStaleLinuxCgroupSiblings(parent, { | 140 | AcpSessionRuntime.sweepStaleLinuxCgroupSiblings(parent, { |
| 159 | isProcessAlive, | 141 | isProcessAlive, |
| 160 | readPopulated: (path) => populatedByPath.get(path), | 142 | readPopulated: (path) => populatedByPath.get(path), |
| 161 | remove: (path) => { | 143 | remove: (path) => { |
| ... | @@ -174,7 +156,7 @@ describe("terminatePosixOwnedProcessTree", () => { | ... | @@ -174,7 +156,7 @@ describe("terminatePosixOwnedProcessTree", () => { |
| 174 | it.live("preserves exact argv and strips wrapper-only environment before exec", () => | 156 | it.live("preserves exact argv and strips wrapper-only environment before exec", () => |
| 175 | Effect.gen(function* () { | 157 | Effect.gen(function* () { |
| 176 | if ((yield* HostProcessPlatform) !== "linux") return; | 158 | if ((yield* HostProcessPlatform) !== "linux") return; |
| 177 | const lease = makeLinuxCgroupController().create(); | 159 | const lease = AcpSessionRuntime.makeLinuxCgroupController().create(); |
| 178 | if (lease === undefined) return; | 160 | if (lease === undefined) return; |
| 179 | const scratchRoot = NodePath.join(process.cwd(), "tmp"); | 161 | const scratchRoot = NodePath.join(process.cwd(), "tmp"); |
| 180 | NodeFS.mkdirSync(scratchRoot, { recursive: true }); | 162 | NodeFS.mkdirSync(scratchRoot, { recursive: true }); |
| ... | @@ -192,17 +174,27 @@ describe("terminatePosixOwnedProcessTree", () => { | ... | @@ -192,17 +174,27 @@ describe("terminatePosixOwnedProcessTree", () => { |
| 192 | NodeFS.symlinkSync(process.execPath, bareGrok); | 174 | NodeFS.symlinkSync(process.execPath, bareGrok); |
| 193 | NodeFS.symlinkSync(process.execPath, NodePath.join(relativeBin, "grok")); | 175 | NodeFS.symlinkSync(process.execPath, NodePath.join(relativeBin, "grok")); |
| 194 | NodeFS.symlinkSync(process.execPath, NodePath.join(validBin, "grok")); | 176 | NodeFS.symlinkSync(process.execPath, NodePath.join(validBin, "grok")); |
| 195 | expect(resolveLinuxCgroupTargetCommand("grok", scratch, { PATH: scratch })).toBe(bareGrok); | 177 | expect( |
| 196 | expect(resolveLinuxCgroupTargetCommand("grok", scratch, { PATH: "" })).toBe(bareGrok); | 178 | AcpSessionRuntime.resolveLinuxCgroupTargetCommand("grok", scratch, { PATH: scratch }), |
| 179 | ).toBe(bareGrok); | ||
| 180 | expect(AcpSessionRuntime.resolveLinuxCgroupTargetCommand("grok", scratch, { PATH: "" })).toBe( | ||
| 181 | bareGrok, | ||
| 182 | ); | ||
| 197 | // Undefined PATH uses Node's default search path (/usr/bin:/bin), not cwd alone | 183 | // Undefined PATH uses Node's default search path (/usr/bin:/bin), not cwd alone |
| 198 | // (bareGrok lives only in cwd / PATH=scratch). | 184 | // (bareGrok lives only in cwd / PATH=scratch). |
| 199 | expect(resolveLinuxCgroupTargetCommand("grok", scratch, { PATH: undefined })).toBeUndefined(); | ||
| 200 | expect(resolveLinuxCgroupTargetCommand("node", scratch, { PATH: undefined })).toBeDefined(); | ||
| 201 | expect(resolveLinuxCgroupTargetCommand("grok", scratch, { PATH: "relative-bin" })).toBe( | ||
| 202 | NodePath.join(relativeBin, "grok"), | ||
| 203 | ); | ||
| 204 | expect( | 185 | expect( |
| 205 | resolveLinuxCgroupTargetCommand("grok", scratch, { | 186 | AcpSessionRuntime.resolveLinuxCgroupTargetCommand("grok", scratch, { PATH: undefined }), |
| 187 | ).toBeUndefined(); | ||
| 188 | expect( | ||
| 189 | AcpSessionRuntime.resolveLinuxCgroupTargetCommand("node", scratch, { PATH: undefined }), | ||
| 190 | ).toBeDefined(); | ||
| 191 | expect( | ||
| 192 | AcpSessionRuntime.resolveLinuxCgroupTargetCommand("grok", scratch, { | ||
| 193 | PATH: "relative-bin", | ||
| 194 | }), | ||
| 195 | ).toBe(NodePath.join(relativeBin, "grok")); | ||
| 196 | expect( | ||
| 197 | AcpSessionRuntime.resolveLinuxCgroupTargetCommand("grok", scratch, { | ||
| 206 | PATH: `directory-bin${NodePath.delimiter}valid-bin`, | 198 | PATH: `directory-bin${NodePath.delimiter}valid-bin`, |
| 207 | }), | 199 | }), |
| 208 | ).toBe(NodePath.join(validBin, "grok")); | 200 | ).toBe(NodePath.join(validBin, "grok")); |
| ... | @@ -214,7 +206,7 @@ describe("terminatePosixOwnedProcessTree", () => { | ... | @@ -214,7 +206,7 @@ describe("terminatePosixOwnedProcessTree", () => { |
| 214 | " wrapper: process.env.T3_ACP_CGROUP_WRAPPER,", | 206 | " wrapper: process.env.T3_ACP_CGROUP_WRAPPER,", |
| 215 | "}));", | 207 | "}));", |
| 216 | ].join("\n"); | 208 | ].join("\n"); |
| 217 | const wrapped = wrapCommandForLinuxCgroup(lease, linkedNode, [ | 209 | const wrapped = AcpSessionRuntime.wrapCommandForLinuxCgroup(lease, linkedNode, [ |
| 218 | "-e", | 210 | "-e", |
| 219 | target, | 211 | target, |
| 220 | outputPath, | 212 | outputPath, |
| ... | @@ -236,7 +228,7 @@ describe("terminatePosixOwnedProcessTree", () => { | ... | @@ -236,7 +228,7 @@ describe("terminatePosixOwnedProcessTree", () => { |
| 236 | '{"args":["space value","single\'quote","double\\"quote"]}', | 228 | '{"args":["space value","single\'quote","double\\"quote"]}', |
| 237 | ); | 229 | ); |
| 238 | } finally { | 230 | } finally { |
| 239 | yield* terminateLinuxCgroupLease(lease).pipe(Effect.ignore); | 231 | yield* AcpSessionRuntime.terminateLinuxCgroupLease(lease).pipe(Effect.ignore); |
| 240 | NodeFS.rmSync(scratch, { recursive: true, force: true }); | 232 | NodeFS.rmSync(scratch, { recursive: true, force: true }); |
| 241 | } | 233 | } |
| 242 | }), | 234 | }), |
| ... | @@ -251,9 +243,11 @@ describe("terminatePosixOwnedProcessTree", () => { | ... | @@ -251,9 +243,11 @@ describe("terminatePosixOwnedProcessTree", () => { |
| 251 | const outputPath = NodePath.join(scratch, "argv output"); | 243 | const outputPath = NodePath.join(scratch, "argv output"); |
| 252 | NodeFS.mkdirSync(leasePath); | 244 | NodeFS.mkdirSync(leasePath); |
| 253 | NodeFS.writeFileSync(NodePath.join(leasePath, "cgroup.procs"), ""); | 245 | NodeFS.writeFileSync(NodePath.join(leasePath, "cgroup.procs"), ""); |
| 254 | const relativePath = parseUnifiedCgroupPath(NodeFS.readFileSync("/proc/self/cgroup", "utf8")); | 246 | const relativePath = AcpSessionRuntime.parseUnifiedCgroupPath( |
| 247 | NodeFS.readFileSync("/proc/self/cgroup", "utf8"), | ||
| 248 | ); | ||
| 255 | expect(relativePath).toBeDefined(); | 249 | expect(relativePath).toBeDefined(); |
| 256 | const lease: AcpLinuxCgroupLease = { | 250 | const lease: AcpSessionRuntime.AcpLinuxCgroupLease = { |
| 257 | contains: () => false, | 251 | contains: () => false, |
| 258 | exists: () => true, | 252 | exists: () => true, |
| 259 | path: leasePath, | 253 | path: leasePath, |
| ... | @@ -264,7 +258,7 @@ describe("terminatePosixOwnedProcessTree", () => { | ... | @@ -264,7 +258,7 @@ describe("terminatePosixOwnedProcessTree", () => { |
| 264 | }; | 258 | }; |
| 265 | const packagedExecPath = vi.spyOn(process, "execPath", "get").mockReturnValue("/bin/sh"); | 259 | const packagedExecPath = vi.spyOn(process, "execPath", "get").mockReturnValue("/bin/sh"); |
| 266 | try { | 260 | try { |
| 267 | const wrapped = wrapCommandForLinuxCgroup(lease, "/bin/sh", [ | 261 | const wrapped = AcpSessionRuntime.wrapCommandForLinuxCgroup(lease, "/bin/sh", [ |
| 268 | "-c", | 262 | "-c", |
| 269 | 'printf "%s\\n" "$0" "$1" "$2" "$3" "${ELECTRON_RUN_AS_NODE-}" "${T3_ACP_CGROUP_WRAPPER-}" > "$4"', | 263 | 'printf "%s\\n" "$0" "$1" "$2" "$3" "${ELECTRON_RUN_AS_NODE-}" "${T3_ACP_CGROUP_WRAPPER-}" > "$4"', |
| 270 | "packaged-target", | 264 | "packaged-target", |
| ... | @@ -289,7 +283,7 @@ describe("terminatePosixOwnedProcessTree", () => { | ... | @@ -289,7 +283,7 @@ describe("terminatePosixOwnedProcessTree", () => { |
| 289 | /^\d+\n$/, | 283 | /^\d+\n$/, |
| 290 | ); | 284 | ); |
| 291 | 285 | ||
| 292 | const mismatch = wrapCommandForLinuxCgroup( | 286 | const mismatch = AcpSessionRuntime.wrapCommandForLinuxCgroup( |
| 293 | { ...lease, relativePath: "/not-the-current-cgroup" }, | 287 | { ...lease, relativePath: "/not-the-current-cgroup" }, |
| 294 | "/bin/sh", | 288 | "/bin/sh", |
| 295 | ["-c", 'printf executed > "$0"', outputPath], | 289 | ["-c", 'printf executed > "$0"', outputPath], |
| ... | @@ -301,7 +295,11 @@ describe("terminatePosixOwnedProcessTree", () => { | ... | @@ -301,7 +295,11 @@ describe("terminatePosixOwnedProcessTree", () => { |
| 301 | expect(mismatchResult.status, mismatchResult.stderr).toBe(126); | 295 | expect(mismatchResult.status, mismatchResult.stderr).toBe(126); |
| 302 | expect(NodeFS.existsSync(outputPath)).toBe(false); | 296 | expect(NodeFS.existsSync(outputPath)).toBe(false); |
| 303 | 297 | ||
| 304 | const missingTarget = wrapCommandForLinuxCgroup(lease, "/nonexistent-t3-probe", []); | 298 | const missingTarget = AcpSessionRuntime.wrapCommandForLinuxCgroup( |
| 299 | lease, | ||
| 300 | "/nonexistent-t3-probe", | ||
| 301 | [], | ||
| 302 | ); | ||
| 305 | const missingTargetResult = NodeChildProcess.spawnSync( | 303 | const missingTargetResult = NodeChildProcess.spawnSync( |
| 306 | missingTarget.command, | 304 | missingTarget.command, |
| 307 | missingTarget.args, | 305 | missingTarget.args, |
| ... | @@ -319,12 +317,12 @@ describe("terminatePosixOwnedProcessTree", () => { | ... | @@ -319,12 +317,12 @@ describe("terminatePosixOwnedProcessTree", () => { |
| 319 | it.live("kills a post-TERM detached double fork without touching an unrelated sentinel", () => | 317 | it.live("kills a post-TERM detached double fork without touching an unrelated sentinel", () => |
| 320 | Effect.gen(function* () { | 318 | Effect.gen(function* () { |
| 321 | if ((yield* HostProcessPlatform) !== "linux") return; | 319 | if ((yield* HostProcessPlatform) !== "linux") return; |
| 322 | const controller = makeLinuxCgroupController(); | 320 | const controller = AcpSessionRuntime.makeLinuxCgroupController(); |
| 323 | const lease = controller.create(); | 321 | const lease = controller.create(); |
| 324 | if (lease === undefined) return; | 322 | if (lease === undefined) return; |
| 325 | expect(parseUnifiedCgroupPath(NodeFS.readFileSync("/proc/self/cgroup", "utf8"))).not.toBe( | 323 | expect( |
| 326 | lease.relativePath, | 324 | AcpSessionRuntime.parseUnifiedCgroupPath(NodeFS.readFileSync("/proc/self/cgroup", "utf8")), |
| 327 | ); | 325 | ).not.toBe(lease.relativePath); |
| 328 | const scratchRoot = NodePath.join(process.cwd(), "tmp"); | 326 | const scratchRoot = NodePath.join(process.cwd(), "tmp"); |
| 329 | NodeFS.mkdirSync(scratchRoot, { recursive: true }); | 327 | NodeFS.mkdirSync(scratchRoot, { recursive: true }); |
| 330 | const scratch = NodeFS.mkdtempSync(NodePath.join(scratchRoot, "acp-cgroup-kill-")); | 328 | const scratch = NodeFS.mkdtempSync(NodePath.join(scratchRoot, "acp-cgroup-kill-")); |
| ... | @@ -349,7 +347,7 @@ describe("terminatePosixOwnedProcessTree", () => { | ... | @@ -349,7 +347,7 @@ describe("terminatePosixOwnedProcessTree", () => { |
| 349 | "});", | 347 | "});", |
| 350 | "setInterval(() => {}, 1000);", | 348 | "setInterval(() => {}, 1000);", |
| 351 | ].join("\n"); | 349 | ].join("\n"); |
| 352 | const wrapped = wrapCommandForLinuxCgroup(lease, process.execPath, [ | 350 | const wrapped = AcpSessionRuntime.wrapCommandForLinuxCgroup(lease, process.execPath, [ |
| 353 | "-e", | 351 | "-e", |
| 354 | providerProgram, | 352 | providerProgram, |
| 355 | readyPath, | 353 | readyPath, |
| ... | @@ -386,7 +384,7 @@ describe("terminatePosixOwnedProcessTree", () => { | ... | @@ -386,7 +384,7 @@ describe("terminatePosixOwnedProcessTree", () => { |
| 386 | } | 384 | } |
| 387 | }).pipe(Effect.timeout("2 seconds")); | 385 | }).pipe(Effect.timeout("2 seconds")); |
| 388 | expect(processExists(detachedChildPid)).toBe(true); | 386 | expect(processExists(detachedChildPid)).toBe(true); |
| 389 | yield* terminateLinuxCgroupLease(lease); | 387 | yield* AcpSessionRuntime.terminateLinuxCgroupLease(lease); |
| 390 | expect(processExists(sentinel.pid!)).toBe(true); | 388 | expect(processExists(sentinel.pid!)).toBe(true); |
| 391 | yield* Effect.gen(function* () { | 389 | yield* Effect.gen(function* () { |
| 392 | while (processExists(detachedChildPid!)) yield* Effect.sleep("10 millis"); | 390 | while (processExists(detachedChildPid!)) yield* Effect.sleep("10 millis"); |
| ... | @@ -397,11 +395,11 @@ describe("terminatePosixOwnedProcessTree", () => { | ... | @@ -397,11 +395,11 @@ describe("terminatePosixOwnedProcessTree", () => { |
| 397 | expect(replacement?.path).not.toBe(lease.path); | 395 | expect(replacement?.path).not.toBe(lease.path); |
| 398 | if (replacement !== undefined) { | 396 | if (replacement !== undefined) { |
| 399 | NodeFS.mkdirSync(NodePath.join(replacement.path, "nested")); | 397 | NodeFS.mkdirSync(NodePath.join(replacement.path, "nested")); |
| 400 | yield* terminateLinuxCgroupLease(replacement); | 398 | yield* AcpSessionRuntime.terminateLinuxCgroupLease(replacement); |
| 401 | expect(NodeFS.existsSync(replacement.path)).toBe(false); | 399 | expect(NodeFS.existsSync(replacement.path)).toBe(false); |
| 402 | } | 400 | } |
| 403 | } finally { | 401 | } finally { |
| 404 | yield* terminateLinuxCgroupLease(lease).pipe(Effect.ignore); | 402 | yield* AcpSessionRuntime.terminateLinuxCgroupLease(lease).pipe(Effect.ignore); |
| 405 | try { | 403 | try { |
| 406 | process.kill(-provider.pid!, "SIGKILL"); | 404 | process.kill(-provider.pid!, "SIGKILL"); |
| 407 | } catch { | 405 | } catch { |
| ... | @@ -430,7 +428,7 @@ describe("terminatePosixOwnedProcessTree", () => { | ... | @@ -430,7 +428,7 @@ describe("terminatePosixOwnedProcessTree", () => { |
| 430 | let killCalls = 0; | 428 | let killCalls = 0; |
| 431 | let populated = false; | 429 | let populated = false; |
| 432 | let removeCalls = 0; | 430 | let removeCalls = 0; |
| 433 | const lease: AcpLinuxCgroupLease = { | 431 | const lease: AcpSessionRuntime.AcpLinuxCgroupLease = { |
| 434 | contains: () => false, | 432 | contains: () => false, |
| 435 | exists: () => exists, | 433 | exists: () => exists, |
| 436 | path: "/test/t3-acp-repopulation", | 434 | path: "/test/t3-acp-repopulation", |
| ... | @@ -457,11 +455,11 @@ describe("terminatePosixOwnedProcessTree", () => { | ... | @@ -457,11 +455,11 @@ describe("terminatePosixOwnedProcessTree", () => { |
| 457 | }, | 455 | }, |
| 458 | }; | 456 | }; |
| 459 | 457 | ||
| 460 | yield* terminateLinuxCgroupLease(lease); | 458 | yield* AcpSessionRuntime.terminateLinuxCgroupLease(lease); |
| 461 | expect(killCalls).toBeGreaterThanOrEqual(3); | 459 | expect(killCalls).toBeGreaterThanOrEqual(3); |
| 462 | expect(removeCalls).toBe(3); | 460 | expect(removeCalls).toBe(3); |
| 463 | expect(exists).toBe(false); | 461 | expect(exists).toBe(false); |
| 464 | yield* terminateLinuxCgroupLease(lease); | 462 | yield* AcpSessionRuntime.terminateLinuxCgroupLease(lease); |
| 465 | expect(removeCalls).toBe(3); | 463 | expect(removeCalls).toBe(3); |
| 466 | }), | 464 | }), |
| 467 | ); | 465 | ); |
| ... | @@ -471,7 +469,7 @@ describe("terminatePosixOwnedProcessTree", () => { | ... | @@ -471,7 +469,7 @@ describe("terminatePosixOwnedProcessTree", () => { |
| 471 | const removeError = Object.assign(new Error("cgroup remains busy"), { | 469 | const removeError = Object.assign(new Error("cgroup remains busy"), { |
| 472 | code: "EBUSY", | 470 | code: "EBUSY", |
| 473 | }); | 471 | }); |
| 474 | const lease: AcpLinuxCgroupLease = { | 472 | const lease: AcpSessionRuntime.AcpLinuxCgroupLease = { |
| 475 | contains: () => false, | 473 | contains: () => false, |
| 476 | exists: () => true, | 474 | exists: () => true, |
| 477 | path: "/test/t3-acp-busy", | 475 | path: "/test/t3-acp-busy", |
| ... | @@ -483,7 +481,7 @@ describe("terminatePosixOwnedProcessTree", () => { | ... | @@ -483,7 +481,7 @@ describe("terminatePosixOwnedProcessTree", () => { |
| 483 | }, | 481 | }, |
| 484 | }; | 482 | }; |
| 485 | 483 | ||
| 486 | const error = yield* Effect.flip(terminateLinuxCgroupLease(lease)); | 484 | const error = yield* Effect.flip(AcpSessionRuntime.terminateLinuxCgroupLease(lease)); |
| 487 | expect(error.detail).toBe("Failed to remove ACP cgroup /test/t3-acp-busy"); | 485 | expect(error.detail).toBe("Failed to remove ACP cgroup /test/t3-acp-busy"); |
| 488 | expect(error.cause).toBe(removeError); | 486 | expect(error.cause).toBe(removeError); |
| 489 | }), | 487 | }), |
| ... | @@ -493,13 +491,16 @@ describe("terminatePosixOwnedProcessTree", () => { | ... | @@ -493,13 +491,16 @@ describe("terminatePosixOwnedProcessTree", () => { |
| 493 | const fixture = makeController({ | 491 | const fixture = makeController({ |
| 494 | processes: [server(), identity(100, process.pid, 100, 100, "owned")], | 492 | processes: [server(), identity(100, process.pid, 100, 100, "owned")], |
| 495 | }); | 493 | }); |
| 496 | const ownedRoot: AcpOwnedPosixProcess = { | 494 | const ownedRoot: AcpSessionRuntime.AcpOwnedPosixProcess = { |
| 497 | ...identity(100, process.pid, 100, 100, "owned"), | 495 | ...identity(100, process.pid, 100, 100, "owned"), |
| 498 | parentExecutable: server().executable, | 496 | parentExecutable: server().executable, |
| 499 | parentStartTime: server().startTime, | 497 | parentStartTime: server().startTime, |
| 500 | }; | 498 | }; |
| 501 | const root: AcpPosixOwnershipRoot = { captureAttempted: true, value: ownedRoot }; | 499 | const root: AcpSessionRuntime.AcpPosixOwnershipRoot = { |
| 502 | const lease: AcpLinuxCgroupLease = { | 500 | captureAttempted: true, |
| 501 | value: ownedRoot, | ||
| 502 | }; | ||
| 503 | const lease: AcpSessionRuntime.AcpLinuxCgroupLease = { | ||
| 503 | contains: () => true, | 504 | contains: () => true, |
| 504 | exists: () => true, | 505 | exists: () => true, |
| 505 | path: "/test/t3-acp-root", | 506 | path: "/test/t3-acp-root", |
| ... | @@ -508,19 +509,19 @@ describe("terminatePosixOwnedProcessTree", () => { | ... | @@ -508,19 +509,19 @@ describe("terminatePosixOwnedProcessTree", () => { |
| 508 | populated: () => true, | 509 | populated: () => true, |
| 509 | remove: () => undefined, | 510 | remove: () => undefined, |
| 510 | }; | 511 | }; |
| 511 | signalLinuxCgroupRootTerm({ controller: fixture.controller, lease, root }); | 512 | AcpSessionRuntime.signalLinuxCgroupRootTerm({ controller: fixture.controller, lease, root }); |
| 512 | expect(fixture.signals).toEqual(["process:100:SIGTERM"]); | 513 | expect(fixture.signals).toEqual(["process:100:SIGTERM"]); |
| 513 | 514 | ||
| 514 | const reused = makeController({ | 515 | const reused = makeController({ |
| 515 | processes: [server(), identity(100, process.pid, 100, 100, "reused")], | 516 | processes: [server(), identity(100, process.pid, 100, 100, "reused")], |
| 516 | }); | 517 | }); |
| 517 | signalLinuxCgroupRootTerm({ controller: reused.controller, lease, root }); | 518 | AcpSessionRuntime.signalLinuxCgroupRootTerm({ controller: reused.controller, lease, root }); |
| 518 | expect(reused.signals).toEqual([]); | 519 | expect(reused.signals).toEqual([]); |
| 519 | 520 | ||
| 520 | const migrated = makeController({ | 521 | const migrated = makeController({ |
| 521 | processes: [server(), identity(100, process.pid, 100, 100, "owned")], | 522 | processes: [server(), identity(100, process.pid, 100, 100, "owned")], |
| 522 | }); | 523 | }); |
| 523 | signalLinuxCgroupRootTerm({ | 524 | AcpSessionRuntime.signalLinuxCgroupRootTerm({ |
| 524 | controller: migrated.controller, | 525 | controller: migrated.controller, |
| 525 | lease: { ...lease, contains: () => false }, | 526 | lease: { ...lease, contains: () => false }, |
| 526 | root, | 527 | root, |
| ... | @@ -565,9 +566,9 @@ describe("terminatePosixOwnedProcessTree", () => { | ... | @@ -565,9 +566,9 @@ describe("terminatePosixOwnedProcessTree", () => { |
| 565 | }).pipe(Effect.timeout("2 seconds")); | 566 | }).pipe(Effect.timeout("2 seconds")); |
| 566 | const [workerTid, childPid] = published; | 567 | const [workerTid, childPid] = published; |
| 567 | expect(workerTid).not.toBe(helper.pid); | 568 | expect(workerTid).not.toBe(helper.pid); |
| 568 | expect(makePosixProcessTreeController("linux").childrenOf(helper.pid!)).toContainEqual( | 569 | expect( |
| 569 | expect.objectContaining({ pid: childPid, ppid: helper.pid }), | 570 | AcpSessionRuntime.makePosixProcessTreeController("linux").childrenOf(helper.pid!), |
| 570 | ); | 571 | ).toContainEqual(expect.objectContaining({ pid: childPid, ppid: helper.pid })); |
| 571 | } finally { | 572 | } finally { |
| 572 | try { | 573 | try { |
| 573 | process.kill(-helper.pid!, "SIGKILL"); | 574 | process.kill(-helper.pid!, "SIGKILL"); |
| ... | @@ -585,11 +586,11 @@ describe("terminatePosixOwnedProcessTree", () => { | ... | @@ -585,11 +586,11 @@ describe("terminatePosixOwnedProcessTree", () => { |
| 585 | const fixture = makeController({ | 586 | const fixture = makeController({ |
| 586 | processes: [server(), identity(100, process.pid, 100, 100), beforeExec], | 587 | processes: [server(), identity(100, process.pid, 100, 100), beforeExec], |
| 587 | }); | 588 | }); |
| 588 | const frontier = new Map<number, AcpOwnedPosixProcess>(); | 589 | const frontier = new Map<number, AcpSessionRuntime.AcpOwnedPosixProcess>(); |
| 589 | const childQueues = new Map<number, Array<number>>(); | 590 | const childQueues = new Map<number, Array<number>>(); |
| 590 | const ledger = new Map<string, AcpOwnedPosixProcess>(); | 591 | const ledger = new Map<string, AcpSessionRuntime.AcpOwnedPosixProcess>(); |
| 591 | const root: AcpPosixOwnershipRoot = { value: undefined }; | 592 | const root: AcpSessionRuntime.AcpPosixOwnershipRoot = { value: undefined }; |
| 592 | observePosixOwnershipLedger({ | 593 | AcpSessionRuntime.observePosixOwnershipLedger({ |
| 593 | childQueues, | 594 | childQueues, |
| 594 | controller: fixture.controller, | 595 | controller: fixture.controller, |
| 595 | frontier, | 596 | frontier, |
| ... | @@ -598,7 +599,7 @@ describe("terminatePosixOwnedProcessTree", () => { | ... | @@ -598,7 +599,7 @@ describe("terminatePosixOwnedProcessTree", () => { |
| 598 | rootPid: 100, | 599 | rootPid: 100, |
| 599 | }); | 600 | }); |
| 600 | fixture.processes.set(110, { ...beforeExec, executable: "/usr/bin/bash" }); | 601 | fixture.processes.set(110, { ...beforeExec, executable: "/usr/bin/bash" }); |
| 601 | observePosixOwnershipLedger({ | 602 | AcpSessionRuntime.observePosixOwnershipLedger({ |
| 602 | childQueues, | 603 | childQueues, |
| 603 | controller: fixture.controller, | 604 | controller: fixture.controller, |
| 604 | frontier, | 605 | frontier, |
| ... | @@ -610,7 +611,7 @@ describe("terminatePosixOwnedProcessTree", () => { | ... | @@ -610,7 +611,7 @@ describe("terminatePosixOwnedProcessTree", () => { |
| 610 | expect([...ledger.values()].find((process) => process.pid === 110)?.executable).toBe( | 611 | expect([...ledger.values()].find((process) => process.pid === 110)?.executable).toBe( |
| 611 | "/usr/bin/bash", | 612 | "/usr/bin/bash", |
| 612 | ); | 613 | ); |
| 613 | yield* terminatePosixOwnedProcessTree({ | 614 | yield* AcpSessionRuntime.terminatePosixOwnedProcessTree({ |
| 614 | controller: fixture.controller, | 615 | controller: fixture.controller, |
| 615 | grace: 0, | 616 | grace: 0, |
| 616 | ledger, | 617 | ledger, |
| ... | @@ -623,13 +624,13 @@ describe("terminatePosixOwnedProcessTree", () => { | ... | @@ -623,13 +624,13 @@ describe("terminatePosixOwnedProcessTree", () => { |
| 623 | 624 | ||
| 624 | it("never adopts a replacement PID after initial root capture failed", () => { | 625 | it("never adopts a replacement PID after initial root capture failed", () => { |
| 625 | const fixture = makeController({ processes: [server()] }); | 626 | const fixture = makeController({ processes: [server()] }); |
| 626 | const frontier = new Map<number, AcpOwnedPosixProcess>(); | 627 | const frontier = new Map<number, AcpSessionRuntime.AcpOwnedPosixProcess>(); |
| 627 | const childQueues = new Map<number, Array<number>>(); | 628 | const childQueues = new Map<number, Array<number>>(); |
| 628 | const ledger = new Map<string, AcpOwnedPosixProcess>(); | 629 | const ledger = new Map<string, AcpSessionRuntime.AcpOwnedPosixProcess>(); |
| 629 | const root: AcpPosixOwnershipRoot = { value: undefined }; | 630 | const root: AcpSessionRuntime.AcpPosixOwnershipRoot = { value: undefined }; |
| 630 | const capture = () => { | 631 | const capture = () => { |
| 631 | try { | 632 | try { |
| 632 | observePosixOwnershipLedger({ | 633 | AcpSessionRuntime.observePosixOwnershipLedger({ |
| 633 | childQueues, | 634 | childQueues, |
| 634 | controller: fixture.controller, | 635 | controller: fixture.controller, |
| 635 | frontier, | 636 | frontier, |
| ... | @@ -663,7 +664,7 @@ describe("terminatePosixOwnedProcessTree", () => { | ... | @@ -663,7 +664,7 @@ describe("terminatePosixOwnedProcessTree", () => { |
| 663 | const fixture = makeController({ | 664 | const fixture = makeController({ |
| 664 | processes: [server(), identity(100, process.pid, 100, 100)], | 665 | processes: [server(), identity(100, process.pid, 100, 100)], |
| 665 | }); | 666 | }); |
| 666 | const controller: AcpPosixProcessTreeController = { | 667 | const controller: AcpSessionRuntime.AcpPosixProcessTreeController = { |
| 667 | ...fixture.controller, | 668 | ...fixture.controller, |
| 668 | childPidsOf: (pid) => { | 669 | childPidsOf: (pid) => { |
| 669 | childListReads += 1; | 670 | childListReads += 1; |
| ... | @@ -678,10 +679,10 @@ describe("terminatePosixOwnedProcessTree", () => { | ... | @@ -678,10 +679,10 @@ describe("terminatePosixOwnedProcessTree", () => { |
| 678 | return fixture.controller.snapshot(); | 679 | return fixture.controller.snapshot(); |
| 679 | }, | 680 | }, |
| 680 | }; | 681 | }; |
| 681 | const frontier = new Map<number, AcpOwnedPosixProcess>(); | 682 | const frontier = new Map<number, AcpSessionRuntime.AcpOwnedPosixProcess>(); |
| 682 | const childQueues = new Map<number, Array<number>>(); | 683 | const childQueues = new Map<number, Array<number>>(); |
| 683 | const ledger = new Map<string, AcpOwnedPosixProcess>(); | 684 | const ledger = new Map<string, AcpSessionRuntime.AcpOwnedPosixProcess>(); |
| 684 | const root: AcpPosixOwnershipRoot = { value: undefined }; | 685 | const root: AcpSessionRuntime.AcpPosixOwnershipRoot = { value: undefined }; |
| 685 | for (let index = 0; index < 5_000; index += 1) { | 686 | for (let index = 0; index < 5_000; index += 1) { |
| 686 | const tombstone = identity(100_000 + index, 1, 100_000 + index, 100_000 + index); | 687 | const tombstone = identity(100_000 + index, 1, 100_000 + index, 100_000 + index); |
| 687 | ledger.set(`${tombstone.pid}:${tombstone.startTime}`, { | 688 | ledger.set(`${tombstone.pid}:${tombstone.startTime}`, { |
| ... | @@ -690,7 +691,7 @@ describe("terminatePosixOwnedProcessTree", () => { | ... | @@ -690,7 +691,7 @@ describe("terminatePosixOwnedProcessTree", () => { |
| 690 | parentStartTime: "", | 691 | parentStartTime: "", |
| 691 | }); | 692 | }); |
| 692 | } | 693 | } |
| 693 | observePosixOwnershipLedger({ | 694 | AcpSessionRuntime.observePosixOwnershipLedger({ |
| 694 | childQueues, | 695 | childQueues, |
| 695 | controller, | 696 | controller, |
| 696 | frontier, | 697 | frontier, |
| ... | @@ -714,7 +715,7 @@ describe("terminatePosixOwnedProcessTree", () => { | ... | @@ -714,7 +715,7 @@ describe("terminatePosixOwnedProcessTree", () => { |
| 714 | ) { | 715 | ) { |
| 715 | childListReads = 0; | 716 | childListReads = 0; |
| 716 | identityCalls = 0; | 717 | identityCalls = 0; |
| 717 | observePosixOwnershipLedger({ | 718 | AcpSessionRuntime.observePosixOwnershipLedger({ |
| 718 | childQueues, | 719 | childQueues, |
| 719 | controller, | 720 | controller, |
| 720 | frontier, | 721 | frontier, |
| ... | @@ -732,7 +733,7 @@ describe("terminatePosixOwnedProcessTree", () => { | ... | @@ -732,7 +733,7 @@ describe("terminatePosixOwnedProcessTree", () => { |
| 732 | ); | 733 | ); |
| 733 | expect(missing, `missing after ${passes} passes`).toEqual([]); | 734 | expect(missing, `missing after ${passes} passes`).toEqual([]); |
| 734 | expect(passes).toBeGreaterThan(2); | 735 | expect(passes).toBeGreaterThan(2); |
| 735 | yield* terminatePosixOwnedProcessTree({ | 736 | yield* AcpSessionRuntime.terminatePosixOwnedProcessTree({ |
| 736 | controller, | 737 | controller, |
| 737 | grace: 0, | 738 | grace: 0, |
| 738 | ledger, | 739 | ledger, |
| ... | @@ -751,7 +752,7 @@ describe("terminatePosixOwnedProcessTree", () => { | ... | @@ -751,7 +752,7 @@ describe("terminatePosixOwnedProcessTree", () => { |
| 751 | const fixture = makeController({ | 752 | const fixture = makeController({ |
| 752 | processes: [server(), identity(100, process.pid, 100, 100), identity(110, 100, 110, 110)], | 753 | processes: [server(), identity(100, process.pid, 100, 100), identity(110, 100, 110, 110)], |
| 753 | }); | 754 | }); |
| 754 | const controller: AcpPosixProcessTreeController = { | 755 | const controller: AcpSessionRuntime.AcpPosixProcessTreeController = { |
| 755 | ...fixture.controller, | 756 | ...fixture.controller, |
| 756 | childPidsOf: (pid) => { | 757 | childPidsOf: (pid) => { |
| 757 | childrenCalls += 1; | 758 | childrenCalls += 1; |
| ... | @@ -767,16 +768,16 @@ describe("terminatePosixOwnedProcessTree", () => { | ... | @@ -767,16 +768,16 @@ describe("terminatePosixOwnedProcessTree", () => { |
| 767 | }, | 768 | }, |
| 768 | }; | 769 | }; |
| 769 | const scope = yield* Scope.make(); | 770 | const scope = yield* Scope.make(); |
| 770 | const frontier = new Map<number, AcpOwnedPosixProcess>(); | 771 | const frontier = new Map<number, AcpSessionRuntime.AcpOwnedPosixProcess>(); |
| 771 | const childQueues = new Map<number, Array<number>>(); | 772 | const childQueues = new Map<number, Array<number>>(); |
| 772 | let ledgerValuesCalls = 0; | 773 | let ledgerValuesCalls = 0; |
| 773 | const ledger = new (class extends Map<string, AcpOwnedPosixProcess> { | 774 | const ledger = new (class extends Map<string, AcpSessionRuntime.AcpOwnedPosixProcess> { |
| 774 | override values(): MapIterator<AcpOwnedPosixProcess> { | 775 | override values(): MapIterator<AcpSessionRuntime.AcpOwnedPosixProcess> { |
| 775 | ledgerValuesCalls += 1; | 776 | ledgerValuesCalls += 1; |
| 776 | return super.values(); | 777 | return super.values(); |
| 777 | } | 778 | } |
| 778 | })(); | 779 | })(); |
| 779 | const root: AcpPosixOwnershipRoot = { value: undefined }; | 780 | const root: AcpSessionRuntime.AcpPosixOwnershipRoot = { value: undefined }; |
| 780 | for (let index = 0; index < 5_000; index += 1) { | 781 | for (let index = 0; index < 5_000; index += 1) { |
| 781 | const tombstone = identity(10_000 + index, 1, 10_000 + index, 10_000 + index); | 782 | const tombstone = identity(10_000 + index, 1, 10_000 + index, 10_000 + index); |
| 782 | ledger.set(`${tombstone.pid}:${tombstone.startTime}`, { | 783 | ledger.set(`${tombstone.pid}:${tombstone.startTime}`, { |
| ... | @@ -785,7 +786,7 @@ describe("terminatePosixOwnedProcessTree", () => { | ... | @@ -785,7 +786,7 @@ describe("terminatePosixOwnedProcessTree", () => { |
| 785 | parentStartTime: "", | 786 | parentStartTime: "", |
| 786 | }); | 787 | }); |
| 787 | } | 788 | } |
| 788 | yield* observePosixOwnershipLedgerContinuously({ | 789 | yield* AcpSessionRuntime.observePosixOwnershipLedgerContinuously({ |
| 789 | childQueues, | 790 | childQueues, |
| 790 | controller, | 791 | controller, |
| 791 | frontier, | 792 | frontier, |
| ... | @@ -831,7 +832,7 @@ describe("terminatePosixOwnedProcessTree", () => { | ... | @@ -831,7 +832,7 @@ describe("terminatePosixOwnedProcessTree", () => { |
| 831 | }, | 832 | }, |
| 832 | }); | 833 | }); |
| 833 | 834 | ||
| 834 | yield* terminatePosixOwnedProcessTree({ | 835 | yield* AcpSessionRuntime.terminatePosixOwnedProcessTree({ |
| 835 | controller: fixture.controller, | 836 | controller: fixture.controller, |
| 836 | grace: 0, | 837 | grace: 0, |
| 837 | rootPid: 100, | 838 | rootPid: 100, |
| ... | @@ -865,7 +866,7 @@ describe("terminatePosixOwnedProcessTree", () => { | ... | @@ -865,7 +866,7 @@ describe("terminatePosixOwnedProcessTree", () => { |
| 865 | }, | 866 | }, |
| 866 | }); | 867 | }); |
| 867 | 868 | ||
| 868 | yield* terminatePosixOwnedProcessTree({ | 869 | yield* AcpSessionRuntime.terminatePosixOwnedProcessTree({ |
| 869 | controller: fixture.controller, | 870 | controller: fixture.controller, |
| 870 | grace: 0, | 871 | grace: 0, |
| 871 | rootPid: 100, | 872 | rootPid: 100, |
| ... | @@ -893,7 +894,7 @@ describe("terminatePosixOwnedProcessTree", () => { | ... | @@ -893,7 +894,7 @@ describe("terminatePosixOwnedProcessTree", () => { |
| 893 | }); | 894 | }); |
| 894 | 895 | ||
| 895 | const result = yield* Effect.exit( | 896 | const result = yield* Effect.exit( |
| 896 | terminatePosixOwnedProcessTree({ | 897 | AcpSessionRuntime.terminatePosixOwnedProcessTree({ |
| 897 | controller: fixture.controller, | 898 | controller: fixture.controller, |
| 898 | grace: 0, | 899 | grace: 0, |
| 899 | rootPid: 100, | 900 | rootPid: 100, |
| ... | @@ -913,9 +914,13 @@ describe("terminatePosixOwnedProcessTree", () => { | ... | @@ -913,9 +914,13 @@ describe("terminatePosixOwnedProcessTree", () => { |
| 913 | 914 | ||
| 914 | it.live("does not treat zombie residual entries as teardown survivors", () => | 915 | it.live("does not treat zombie residual entries as teardown survivors", () => |
| 915 | Effect.gen(function* () { | 916 | Effect.gen(function* () { |
| 916 | expect(posixProcessIsZombie({ ...identity(1, 0, 1, 1), state: "Z" })).toBe(true); | 917 | expect(AcpSessionRuntime.posixProcessIsZombie({ ...identity(1, 0, 1, 1), state: "Z" })).toBe( |
| 917 | expect(posixProcessIsZombie({ ...identity(1, 0, 1, 1), state: "S" })).toBe(false); | 918 | true, |
| 918 | const withoutState: AcpPosixProcessIdentity = { | 919 | ); |
| 920 | expect(AcpSessionRuntime.posixProcessIsZombie({ ...identity(1, 0, 1, 1), state: "S" })).toBe( | ||
| 921 | false, | ||
| 922 | ); | ||
| 923 | const withoutState: AcpSessionRuntime.AcpPosixProcessIdentity = { | ||
| 919 | executable: "/proc/1/exe", | 924 | executable: "/proc/1/exe", |
| 920 | pgid: 1, | 925 | pgid: 1, |
| 921 | pid: 1, | 926 | pid: 1, |
| ... | @@ -923,7 +928,7 @@ describe("terminatePosixOwnedProcessTree", () => { | ... | @@ -923,7 +928,7 @@ describe("terminatePosixOwnedProcessTree", () => { |
| 923 | sid: 1, | 928 | sid: 1, |
| 924 | startTime: "1", | 929 | startTime: "1", |
| 925 | }; | 930 | }; |
| 926 | expect(posixProcessIsZombie(withoutState)).toBe(false); | 931 | expect(AcpSessionRuntime.posixProcessIsZombie(withoutState)).toBe(false); |
| 927 | 932 | ||
| 928 | const root = identity(100, process.pid, 100, 100); | 933 | const root = identity(100, process.pid, 100, 100); |
| 929 | const zombieChild = identity(110, 100, 110, 110, "110", "Z"); | 934 | const zombieChild = identity(110, 100, 110, 110, "110", "Z"); |
| ... | @@ -936,7 +941,7 @@ describe("terminatePosixOwnedProcessTree", () => { | ... | @@ -936,7 +941,7 @@ describe("terminatePosixOwnedProcessTree", () => { |
| 936 | processes.set(pid, { ...current, state: "Z" }); | 941 | processes.set(pid, { ...current, state: "Z" }); |
| 937 | }, | 942 | }, |
| 938 | }); | 943 | }); |
| 939 | yield* terminatePosixOwnedProcessTree({ | 944 | yield* AcpSessionRuntime.terminatePosixOwnedProcessTree({ |
| 940 | controller: fixture.controller, | 945 | controller: fixture.controller, |
| 941 | grace: 0, | 946 | grace: 0, |
| 942 | rootPid: 100, | 947 | rootPid: 100, |
| ... | @@ -948,19 +953,19 @@ describe("terminatePosixOwnedProcessTree", () => { | ... | @@ -948,19 +953,19 @@ describe("terminatePosixOwnedProcessTree", () => { |
| 948 | ); | 953 | ); |
| 949 | 954 | ||
| 950 | it("admits a live child after a free PID was previously reserved by a dead entry", () => { | 955 | it("admits a live child after a free PID was previously reserved by a dead entry", () => { |
| 951 | const staleChild: AcpOwnedPosixProcess = { | 956 | const staleChild: AcpSessionRuntime.AcpOwnedPosixProcess = { |
| 952 | ...identity(200, 100, 200, 200, "stale-child"), | 957 | ...identity(200, 100, 200, 200, "stale-child"), |
| 953 | parentExecutable: `/proc/100/exe`, | 958 | parentExecutable: `/proc/100/exe`, |
| 954 | parentStartTime: "100", | 959 | parentStartTime: "100", |
| 955 | }; | 960 | }; |
| 956 | const liveChild = identity(200, 100, 200, 200, "live-child"); | 961 | const liveChild = identity(200, 100, 200, 200, "live-child"); |
| 957 | const root = identity(100, process.pid, 100, 100); | 962 | const root = identity(100, process.pid, 100, 100); |
| 958 | const ownedRoot: AcpOwnedPosixProcess = { | 963 | const ownedRoot: AcpSessionRuntime.AcpOwnedPosixProcess = { |
| 959 | ...root, | 964 | ...root, |
| 960 | parentExecutable: `/proc/${process.pid}/exe`, | 965 | parentExecutable: `/proc/${process.pid}/exe`, |
| 961 | parentStartTime: "server", | 966 | parentStartTime: "server", |
| 962 | }; | 967 | }; |
| 963 | const ledger = new Map<string, AcpOwnedPosixProcess>([ | 968 | const ledger = new Map<string, AcpSessionRuntime.AcpOwnedPosixProcess>([ |
| 964 | [`100:${root.startTime}`, ownedRoot], | 969 | [`100:${root.startTime}`, ownedRoot], |
| 965 | [`200:${staleChild.startTime}`, staleChild], | 970 | [`200:${staleChild.startTime}`, staleChild], |
| 966 | ]); | 971 | ]); |
| ... | @@ -969,7 +974,7 @@ describe("terminatePosixOwnedProcessTree", () => { | ... | @@ -969,7 +974,7 @@ describe("terminatePosixOwnedProcessTree", () => { |
| 969 | }).controller; | 974 | }).controller; |
| 970 | 975 | ||
| 971 | // First capture: PID 200 is free, so the dead reservation is dropped. | 976 | // First capture: PID 200 is free, so the dead reservation is dropped. |
| 972 | capturePosixOwnershipLedger({ | 977 | AcpSessionRuntime.capturePosixOwnershipLedger({ |
| 973 | controller, | 978 | controller, |
| 974 | ledger, | 979 | ledger, |
| 975 | rootPid: 100, | 980 | rootPid: 100, |
| ... | @@ -978,7 +983,7 @@ describe("terminatePosixOwnedProcessTree", () => { | ... | @@ -978,7 +983,7 @@ describe("terminatePosixOwnedProcessTree", () => { |
| 978 | expect([...ledger.values()].some((entry) => entry.pid === 200)).toBe(false); | 983 | expect([...ledger.values()].some((entry) => entry.pid === 200)).toBe(false); |
| 979 | 984 | ||
| 980 | // Second capture: a new process reuses PID 200 under the owned parent. | 985 | // Second capture: a new process reuses PID 200 under the owned parent. |
| 981 | capturePosixOwnershipLedger({ | 986 | AcpSessionRuntime.capturePosixOwnershipLedger({ |
| 982 | controller: makeController({ processes: [server(), root, liveChild] }).controller, | 987 | controller: makeController({ processes: [server(), root, liveChild] }).controller, |
| 983 | ledger, | 988 | ledger, |
| 984 | rootPid: 100, | 989 | rootPid: 100, |
| ... | @@ -990,24 +995,24 @@ describe("terminatePosixOwnedProcessTree", () => { | ... | @@ -990,24 +995,24 @@ describe("terminatePosixOwnedProcessTree", () => { |
| 990 | }); | 995 | }); |
| 991 | 996 | ||
| 992 | it("admits a live child when a stale ledger entry still occupies the reused PID", () => { | 997 | it("admits a live child when a stale ledger entry still occupies the reused PID", () => { |
| 993 | const staleChild: AcpOwnedPosixProcess = { | 998 | const staleChild: AcpSessionRuntime.AcpOwnedPosixProcess = { |
| 994 | ...identity(200, 100, 200, 200, "stale-child"), | 999 | ...identity(200, 100, 200, 200, "stale-child"), |
| 995 | parentExecutable: `/proc/100/exe`, | 1000 | parentExecutable: `/proc/100/exe`, |
| 996 | parentStartTime: "100", | 1001 | parentStartTime: "100", |
| 997 | }; | 1002 | }; |
| 998 | const liveChild = identity(200, 100, 200, 200, "live-child"); | 1003 | const liveChild = identity(200, 100, 200, 200, "live-child"); |
| 999 | const root = identity(100, process.pid, 100, 100); | 1004 | const root = identity(100, process.pid, 100, 100); |
| 1000 | const ownedRoot: AcpOwnedPosixProcess = { | 1005 | const ownedRoot: AcpSessionRuntime.AcpOwnedPosixProcess = { |
| 1001 | ...root, | 1006 | ...root, |
| 1002 | parentExecutable: `/proc/${process.pid}/exe`, | 1007 | parentExecutable: `/proc/${process.pid}/exe`, |
| 1003 | parentStartTime: "server", | 1008 | parentStartTime: "server", |
| 1004 | }; | 1009 | }; |
| 1005 | const ledger = new Map<string, AcpOwnedPosixProcess>([ | 1010 | const ledger = new Map<string, AcpSessionRuntime.AcpOwnedPosixProcess>([ |
| 1006 | [`100:${root.startTime}`, ownedRoot], | 1011 | [`100:${root.startTime}`, ownedRoot], |
| 1007 | [`200:${staleChild.startTime}`, staleChild], | 1012 | [`200:${staleChild.startTime}`, staleChild], |
| 1008 | ]); | 1013 | ]); |
| 1009 | 1014 | ||
| 1010 | capturePosixOwnershipLedger({ | 1015 | AcpSessionRuntime.capturePosixOwnershipLedger({ |
| 1011 | controller: makeController({ processes: [server(), root, liveChild] }).controller, | 1016 | controller: makeController({ processes: [server(), root, liveChild] }).controller, |
| 1012 | ledger, | 1017 | ledger, |
| 1013 | rootPid: 100, | 1018 | rootPid: 100, |
| ... | @@ -1040,7 +1045,7 @@ describe("terminatePosixOwnedProcessTree", () => { | ... | @@ -1040,7 +1045,7 @@ describe("terminatePosixOwnedProcessTree", () => { |
| 1040 | }, | 1045 | }, |
| 1041 | }); | 1046 | }); |
| 1042 | 1047 | ||
| 1043 | yield* terminatePosixOwnedProcessTree({ | 1048 | yield* AcpSessionRuntime.terminatePosixOwnedProcessTree({ |
| 1044 | controller: fixture.controller, | 1049 | controller: fixture.controller, |
| 1045 | grace: 0, | 1050 | grace: 0, |
| 1046 | rootPid: 100, | 1051 | rootPid: 100, |
| ... | @@ -1064,7 +1069,7 @@ describe("terminatePosixOwnedProcessTree", () => { | ... | @@ -1064,7 +1069,7 @@ describe("terminatePosixOwnedProcessTree", () => { |
| 1064 | }, | 1069 | }, |
| 1065 | }); | 1070 | }); |
| 1066 | let snapshotCalls = 0; | 1071 | let snapshotCalls = 0; |
| 1067 | const controller: AcpPosixProcessTreeController = { | 1072 | const controller: AcpSessionRuntime.AcpPosixProcessTreeController = { |
| 1068 | ...fixture.controller, | 1073 | ...fixture.controller, |
| 1069 | snapshot: () => { | 1074 | snapshot: () => { |
| 1070 | snapshotCalls += 1; | 1075 | snapshotCalls += 1; |
| ... | @@ -1076,7 +1081,7 @@ describe("terminatePosixOwnedProcessTree", () => { | ... | @@ -1076,7 +1081,7 @@ describe("terminatePosixOwnedProcessTree", () => { |
| 1076 | }; | 1081 | }; |
| 1077 | 1082 | ||
| 1078 | const error = yield* Effect.flip( | 1083 | const error = yield* Effect.flip( |
| 1079 | terminatePosixOwnedProcessTree({ | 1084 | AcpSessionRuntime.terminatePosixOwnedProcessTree({ |
| 1080 | controller, | 1085 | controller, |
| 1081 | discoveryPasses: 2, | 1086 | discoveryPasses: 2, |
| 1082 | grace: 0, | 1087 | grace: 0, |
| ... | @@ -1092,10 +1097,10 @@ describe("terminatePosixOwnedProcessTree", () => { | ... | @@ -1092,10 +1097,10 @@ describe("terminatePosixOwnedProcessTree", () => { |
| 1092 | it.live("retains the ledger across root exit and an explicit failure for finalizer retry", () => | 1097 | it.live("retains the ledger across root exit and an explicit failure for finalizer retry", () => |
| 1093 | Effect.gen(function* () { | 1098 | Effect.gen(function* () { |
| 1094 | let ignoreSignals = true; | 1099 | let ignoreSignals = true; |
| 1095 | const ledger = new Map<string, AcpOwnedPosixProcess>(); | 1100 | const ledger = new Map<string, AcpSessionRuntime.AcpOwnedPosixProcess>(); |
| 1096 | const frontier = new Map<number, AcpOwnedPosixProcess>(); | 1101 | const frontier = new Map<number, AcpSessionRuntime.AcpOwnedPosixProcess>(); |
| 1097 | const childQueues = new Map<number, Array<number>>(); | 1102 | const childQueues = new Map<number, Array<number>>(); |
| 1098 | const root: AcpPosixOwnershipRoot = { value: undefined }; | 1103 | const root: AcpSessionRuntime.AcpPosixOwnershipRoot = { value: undefined }; |
| 1099 | const fixture = makeController({ | 1104 | const fixture = makeController({ |
| 1100 | processes: [ | 1105 | processes: [ |
| 1101 | server(), | 1106 | server(), |
| ... | @@ -1108,7 +1113,7 @@ describe("terminatePosixOwnedProcessTree", () => { | ... | @@ -1108,7 +1113,7 @@ describe("terminatePosixOwnedProcessTree", () => { |
| 1108 | if (!ignoreSignals) processes.delete(pid); | 1113 | if (!ignoreSignals) processes.delete(pid); |
| 1109 | }, | 1114 | }, |
| 1110 | }); | 1115 | }); |
| 1111 | observePosixOwnershipLedger({ | 1116 | AcpSessionRuntime.observePosixOwnershipLedger({ |
| 1112 | childQueues, | 1117 | childQueues, |
| 1113 | controller: fixture.controller, | 1118 | controller: fixture.controller, |
| 1114 | frontier, | 1119 | frontier, |
| ... | @@ -1118,7 +1123,7 @@ describe("terminatePosixOwnedProcessTree", () => { | ... | @@ -1118,7 +1123,7 @@ describe("terminatePosixOwnedProcessTree", () => { |
| 1118 | }); | 1123 | }); |
| 1119 | 1124 | ||
| 1120 | const explicit = yield* Effect.exit( | 1125 | const explicit = yield* Effect.exit( |
| 1121 | terminatePosixOwnedProcessTree({ | 1126 | AcpSessionRuntime.terminatePosixOwnedProcessTree({ |
| 1122 | controller: fixture.controller, | 1127 | controller: fixture.controller, |
| 1123 | grace: 0, | 1128 | grace: 0, |
| 1124 | ledger, | 1129 | ledger, |
| ... | @@ -1130,7 +1135,7 @@ describe("terminatePosixOwnedProcessTree", () => { | ... | @@ -1130,7 +1135,7 @@ describe("terminatePosixOwnedProcessTree", () => { |
| 1130 | expect(fixture.processes.has(111)).toBe(true); | 1135 | expect(fixture.processes.has(111)).toBe(true); |
| 1131 | 1136 | ||
| 1132 | fixture.processes.set(112, identity(112, 110, 112, 112)); | 1137 | fixture.processes.set(112, identity(112, 110, 112, 112)); |
| 1133 | observePosixOwnershipLedger({ | 1138 | AcpSessionRuntime.observePosixOwnershipLedger({ |
| 1134 | childQueues, | 1139 | childQueues, |
| 1135 | controller: fixture.controller, | 1140 | controller: fixture.controller, |
| 1136 | frontier, | 1141 | frontier, |
| ... | @@ -1139,7 +1144,7 @@ describe("terminatePosixOwnedProcessTree", () => { | ... | @@ -1139,7 +1144,7 @@ describe("terminatePosixOwnedProcessTree", () => { |
| 1139 | rootPid: 100, | 1144 | rootPid: 100, |
| 1140 | }); | 1145 | }); |
| 1141 | ignoreSignals = false; | 1146 | ignoreSignals = false; |
| 1142 | yield* terminatePosixOwnedProcessTree({ | 1147 | yield* AcpSessionRuntime.terminatePosixOwnedProcessTree({ |
| 1143 | controller: fixture.controller, | 1148 | controller: fixture.controller, |
| 1144 | grace: 0, | 1149 | grace: 0, |
| 1145 | ledger, | 1150 | ledger, |
| ... | @@ -1153,7 +1158,7 @@ describe("terminatePosixOwnedProcessTree", () => { | ... | @@ -1153,7 +1158,7 @@ describe("terminatePosixOwnedProcessTree", () => { |
| 1153 | 1158 | ||
| 1154 | it("fails closed where a stable POSIX identity provider is unavailable", () => { | 1159 | it("fails closed where a stable POSIX identity provider is unavailable", () => { |
| 1155 | try { | 1160 | try { |
| 1156 | makePosixProcessTreeController("darwin"); | 1161 | AcpSessionRuntime.makePosixProcessTreeController("darwin"); |
| 1157 | throw new Error("Expected Darwin process ownership to fail closed"); | 1162 | throw new Error("Expected Darwin process ownership to fail closed"); |
| 1158 | } catch (error) { | 1163 | } catch (error) { |
| 1159 | expect(error).toMatchObject({ | 1164 | expect(error).toMatchObject({ |
apps/server/src/provider/acp/AcpSessionRuntime.ts deleted-3035| ... | @@ -1,3035 +0,0 @@ | ||
| 1 | // @effect-diagnostics nodeBuiltinImport:off | ||
| 2 | import * as NodeCrypto from "node:crypto"; | ||
| 3 | import * as NodeFS from "node:fs"; | ||
| 4 | import * as NodePath from "node:path"; | ||
| 5 | |||
| 6 | import * as Cause from "effect/Cause"; | ||
| 7 | import * as Clock from "effect/Clock"; | ||
| 8 | import * as Context from "effect/Context"; | ||
| 9 | import * as Crypto from "effect/Crypto"; | ||
| 10 | import * as Deferred from "effect/Deferred"; | ||
| 11 | import * as Duration from "effect/Duration"; | ||
| 12 | import * as Effect from "effect/Effect"; | ||
| 13 | import * as Exit from "effect/Exit"; | ||
| 14 | import * as Fiber from "effect/Fiber"; | ||
| 15 | import * as Layer from "effect/Layer"; | ||
| 16 | import * as Option from "effect/Option"; | ||
| 17 | import * as Queue from "effect/Queue"; | ||
| 18 | import * as Ref from "effect/Ref"; | ||
| 19 | import * as Schema from "effect/Schema"; | ||
| 20 | import * as Scope from "effect/Scope"; | ||
| 21 | import * as Semaphore from "effect/Semaphore"; | ||
| 22 | import * as Stream from "effect/Stream"; | ||
| 23 | import * as ChildProcess from "effect/process/ChildProcess"; | ||
| 24 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; | ||
| 25 | import * as EffectAcpClient from "effect-acp/client"; | ||
| 26 | import * as EffectAcpErrors from "effect-acp/errors"; | ||
| 27 | import type * as EffectAcpSchema from "effect-acp/compat"; | ||
| 28 | import type * as EffectAcpProtocol from "effect-acp/protocol"; | ||
| 29 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; | ||
| 30 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 31 | |||
| 32 | import { signalProcessGroup } from "@t3tools/provider-core/server/processGroup"; | ||
| 33 | import { appendAcpStderrTail, sanitizeAcpStderrExcerpt } from "./AcpStderr.ts"; | ||
| 34 | import { | ||
| 35 | collectSessionConfigOptionValues, | ||
| 36 | decideToolCallUpdateEmission, | ||
| 37 | extractModelConfigId, | ||
| 38 | findSessionConfigOption, | ||
| 39 | mergeToolCallState, | ||
| 40 | toolCallProgressLength, | ||
| 41 | parseSessionModeState, | ||
| 42 | parseSessionUpdateEvent, | ||
| 43 | sessionUpdateCountsAsLoadReplayActivity, | ||
| 44 | sessionUpdateIsReplay, | ||
| 45 | waitForSessionLoadReplayIdle, | ||
| 46 | type SessionLoadGate, | ||
| 47 | type AcpParsedSessionEvent, | ||
| 48 | type AcpSessionModeState, | ||
| 49 | type AcpToolCallState, | ||
| 50 | } from "./AcpRuntimeModel.ts"; | ||
| 51 | |||
| 52 | const MAX_SHOWN_TOOL_CALL_IDS = 256; | ||
| 53 | |||
| 54 | interface AcpToolCallTrackedState { | ||
| 55 | readonly state: AcpToolCallState; | ||
| 56 | readonly lastEmittedDetailLength: number | undefined; | ||
| 57 | readonly skippedSinceEmit: number; | ||
| 58 | } | ||
| 59 | |||
| 60 | function formatConfigOptionValue(value: string | boolean): string { | ||
| 61 | return JSON.stringify(value); | ||
| 62 | } | ||
| 63 | |||
| 64 | export interface AcpSessionEventStreamBarrier { | ||
| 65 | readonly _tag: "EventStreamBarrier"; | ||
| 66 | readonly acknowledge: Deferred.Deferred<void>; | ||
| 67 | } | ||
| 68 | |||
| 69 | export type AcpSessionRuntimeEvent = | ||
| 70 | | AcpParsedSessionEvent | ||
| 71 | | AcpSessionEventStreamBarrier | ||
| 72 | | { | ||
| 73 | readonly _tag: "ConnectionTerminated"; | ||
| 74 | readonly error: EffectAcpErrors.AcpError; | ||
| 75 | }; | ||
| 76 | |||
| 77 | const defaultSessionLoadTimeout = Duration.seconds(90); | ||
| 78 | const defaultSessionLoadReplayIdleGap = Duration.seconds(2); | ||
| 79 | const defaultCancelTimeout = Duration.seconds(15); | ||
| 80 | const maxStartupMetadataUpdates = 32; | ||
| 81 | // Antigravity can emit an accepted 16 KiB Google authorization URL on stderr. | ||
| 82 | const maxStderrChunkLength = 32_768; | ||
| 83 | |||
| 84 | export interface AcpSpawnInput { | ||
| 85 | readonly command: string; | ||
| 86 | readonly args: ReadonlyArray<string>; | ||
| 87 | readonly cwd?: string; | ||
| 88 | readonly env?: NodeJS.ProcessEnv; | ||
| 89 | readonly extendEnv?: boolean; | ||
| 90 | readonly shell?: false; | ||
| 91 | } | ||
| 92 | |||
| 93 | export interface AcpSessionRuntimeOptions { | ||
| 94 | readonly spawn: AcpSpawnInput; | ||
| 95 | readonly cwd: string; | ||
| 96 | readonly resumeSessionId?: string; | ||
| 97 | readonly resumeMethod?: "load" | "resume"; | ||
| 98 | readonly sessionLoadTimeout?: Duration.Input; | ||
| 99 | readonly sessionLoadReplayIdleGap?: Duration.Input; | ||
| 100 | readonly interruptPromptOnCancel?: boolean; | ||
| 101 | /** Optional provider metadata forwarded on `session/cancel`. */ | ||
| 102 | readonly cancelMeta?: EffectAcpSchema.CancelNotification["_meta"]; | ||
| 103 | /** Optional provider metadata forwarded on `initialize`. */ | ||
| 104 | readonly initializeMeta?: EffectAcpSchema.InitializeRequest["_meta"]; | ||
| 105 | readonly ownDetachedProcessGroup?: boolean; | ||
| 106 | readonly ownDescendantProcessGroups?: boolean; | ||
| 107 | readonly processGroupPlatform?: NodeJS.Platform; | ||
| 108 | readonly processGroupTerminationGrace?: Duration.Input; | ||
| 109 | readonly windowsProcessTreeTerminator?: ( | ||
| 110 | pid: number, | ||
| 111 | ) => Effect.Effect<void, AcpProcessGroupTerminationError>; | ||
| 112 | readonly posixProcessTreeController?: AcpPosixProcessTreeController; | ||
| 113 | readonly linuxCgroupController?: AcpLinuxCgroupController | null; | ||
| 114 | /** Native cancellation waits for the prompt response and the getEvents consumer to drain. */ | ||
| 115 | readonly cancelBehavior?: "interrupt" | "wait-for-prompt"; | ||
| 116 | readonly cancelTimeout?: Duration.Input; | ||
| 117 | readonly clientCapabilities?: EffectAcpSchema.InitializeRequest["clientCapabilities"]; | ||
| 118 | readonly clientInfo: { | ||
| 119 | readonly name: string; | ||
| 120 | readonly version: string; | ||
| 121 | }; | ||
| 122 | readonly authMethodId?: string; | ||
| 123 | /** | ||
| 124 | * Call `authenticate` right after `initialize` instead of waiting for an | ||
| 125 | * auth-required error. Antigravity selects its credential source (Google | ||
| 126 | * account vs API key) from the method, so the session must not open first. | ||
| 127 | */ | ||
| 128 | readonly authenticateEagerly?: boolean; | ||
| 129 | readonly mcpServers?: ReadonlyArray<EffectAcpSchema.McpServer>; | ||
| 130 | /** Extra workspace roots the agent may read and write besides `cwd`. */ | ||
| 131 | readonly additionalDirectories?: ReadonlyArray<string>; | ||
| 132 | /** Transforms provider stdout before protocol parsing and protocol logging. */ | ||
| 133 | readonly transformStdout?: EffectAcpClient.AcpClientOptions["transformStdout"]; | ||
| 134 | /** Normalizes provider-specific fields before notification queues or runtime state retain them. */ | ||
| 135 | readonly transformSessionUpdate?: ( | ||
| 136 | notification: EffectAcpSchema.SessionNotification, | ||
| 137 | ) => EffectAcpSchema.SessionNotification; | ||
| 138 | /** Receives bounded stderr chunks. Redact secrets before logging. A failure closes the runtime. */ | ||
| 139 | readonly onStderr?: (text: string) => Effect.Effect<void, EffectAcpErrors.AcpError>; | ||
| 140 | /** Disable only for non-interactive discovery that must surface auth-required immediately. */ | ||
| 141 | readonly authenticateOnAuthRequired?: boolean; | ||
| 142 | /** Observes the normalized handshake before any session setup or authentication retry. */ | ||
| 143 | readonly onInitialized?: ( | ||
| 144 | result: EffectAcpSchema.InitializeResponse, | ||
| 145 | ) => Effect.Effect<void, never>; | ||
| 146 | readonly acpMcpServers?: ReadonlyArray<EffectAcpSchema.McpServer>; | ||
| 147 | readonly requestLogger?: (event: AcpSessionRequestLogEvent) => Effect.Effect<void, never>; | ||
| 148 | readonly protocolLogging?: { | ||
| 149 | readonly logIncoming?: boolean; | ||
| 150 | readonly logOutgoing?: boolean; | ||
| 151 | readonly logger?: (event: EffectAcpProtocol.AcpProtocolLogEvent) => Effect.Effect<void, never>; | ||
| 152 | }; | ||
| 153 | readonly onIncomingRequest?: EffectAcpClient.AcpClientOptions["onIncomingRequest"]; | ||
| 154 | readonly onTermination?: (error: EffectAcpErrors.AcpError) => Effect.Effect<void>; | ||
| 155 | readonly onOutgoingResponseFailure?: EffectAcpClient.AcpClientOptions["onOutgoingResponseFailure"]; | ||
| 156 | readonly onOutgoingResponse?: EffectAcpClient.AcpClientOptions["onOutgoingResponse"]; | ||
| 157 | } | ||
| 158 | |||
| 159 | export interface AcpSessionRequestLogEvent { | ||
| 160 | readonly method: string; | ||
| 161 | readonly payload: unknown; | ||
| 162 | readonly status: "started" | "succeeded" | "failed"; | ||
| 163 | readonly result?: unknown; | ||
| 164 | readonly cause?: Cause.Cause<EffectAcpErrors.AcpError>; | ||
| 165 | } | ||
| 166 | |||
| 167 | export class AcpProcessGroupTerminationError extends Schema.TaggedError<AcpProcessGroupTerminationError>()( | ||
| 168 | "AcpProcessGroupTerminationError", | ||
| 169 | { | ||
| 170 | detail: Schema.String, | ||
| 171 | pid: Schema.optionalKey(Schema.Int), | ||
| 172 | exitCode: Schema.optionalKey(Schema.Number), | ||
| 173 | cause: Schema.optional(Schema.Defect()), | ||
| 174 | }, | ||
| 175 | ) { | ||
| 176 | override get message(): string { | ||
| 177 | return this.detail; | ||
| 178 | } | ||
| 179 | } | ||
| 180 | |||
| 181 | const isAcpProcessGroupTerminationError = Schema.is(AcpProcessGroupTerminationError); | ||
| 182 | |||
| 183 | export interface AcpPosixProcessIdentity { | ||
| 184 | readonly executable: string | undefined; | ||
| 185 | readonly pgid: number; | ||
| 186 | readonly pid: number; | ||
| 187 | readonly ppid: number; | ||
| 188 | readonly sid: number; | ||
| 189 | /** | ||
| 190 | * Linux `/proc/<pid>/stat` state when known (`R`/`S`/`D`/`Z`/…). Omitted on | ||
| 191 | * platforms or fixtures that do not surface it. Zombies (`Z`) are already | ||
| 192 | * dead and must not count as residual teardown survivors. | ||
| 193 | */ | ||
| 194 | readonly state?: string; | ||
| 195 | readonly startTime: string; | ||
| 196 | } | ||
| 197 | |||
| 198 | export interface AcpPosixProcessTreeController { | ||
| 199 | readonly childPidsOf: (pid: number) => ReadonlyArray<number>; | ||
| 200 | readonly childrenOf: (pid: number) => ReadonlyArray<AcpPosixProcessIdentity>; | ||
| 201 | readonly identity: (pid: number) => AcpPosixProcessIdentity | undefined; | ||
| 202 | readonly snapshot: () => ReadonlyArray<AcpPosixProcessIdentity>; | ||
| 203 | readonly signalProcess: (pid: number, signal: NodeJS.Signals) => void; | ||
| 204 | } | ||
| 205 | |||
| 206 | export interface AcpLinuxCgroupLease { | ||
| 207 | readonly contains: (pid: number) => boolean; | ||
| 208 | readonly exists: () => boolean; | ||
| 209 | readonly path: string; | ||
| 210 | readonly relativePath: string; | ||
| 211 | readonly kill: () => void; | ||
| 212 | readonly populated: () => boolean; | ||
| 213 | readonly remove: () => void; | ||
| 214 | } | ||
| 215 | |||
| 216 | export interface AcpLinuxCgroupController { | ||
| 217 | readonly create: () => AcpLinuxCgroupLease | undefined; | ||
| 218 | } | ||
| 219 | |||
| 220 | export interface AcpPosixOwnershipRoot { | ||
| 221 | captureAttempted?: boolean; | ||
| 222 | value: AcpOwnedPosixProcess | undefined; | ||
| 223 | } | ||
| 224 | |||
| 225 | function decodeMountInfoPath(value: string): string { | ||
| 226 | return value.replace(/\\([0-7]{3})/g, (_match, octal: string) => | ||
| 227 | String.fromCharCode(Number.parseInt(octal, 8)), | ||
| 228 | ); | ||
| 229 | } | ||
| 230 | |||
| 231 | export function parseUnifiedCgroupPath(contents: string): string | undefined { | ||
| 232 | const matches = [...contents.matchAll(/^0::(\/.*)$/gm)]; | ||
| 233 | return matches.length === 1 ? matches[0]![1] : undefined; | ||
| 234 | } | ||
| 235 | |||
| 236 | export function isSafeCgroupPath(value: string): boolean { | ||
| 237 | return ( | ||
| 238 | value.startsWith("/") && | ||
| 239 | !value.includes("(deleted)") && | ||
| 240 | NodePath.posix.normalize(value) === value | ||
| 241 | ); | ||
| 242 | } | ||
| 243 | |||
| 244 | export function parseCgroup2Mounts( | ||
| 245 | contents: string, | ||
| 246 | ): ReadonlyArray<{ readonly mountPoint: string; readonly root: string }> { | ||
| 247 | const mounts: Array<{ readonly mountPoint: string; readonly root: string }> = []; | ||
| 248 | for (const line of contents.split("\n")) { | ||
| 249 | const [mount, filesystem] = line.split(" - "); | ||
| 250 | if (mount === undefined || filesystem?.split(" ")[0] !== "cgroup2") continue; | ||
| 251 | const fields = mount.split(" "); | ||
| 252 | if (fields.length < 5) continue; | ||
| 253 | mounts.push({ | ||
| 254 | mountPoint: decodeMountInfoPath(fields[4]!), | ||
| 255 | root: decodeMountInfoPath(fields[3]!), | ||
| 256 | }); | ||
| 257 | } | ||
| 258 | return mounts; | ||
| 259 | } | ||
| 260 | |||
| 261 | export function parseCgroup2Mount( | ||
| 262 | contents: string, | ||
| 263 | ): { readonly mountPoint: string; readonly root: string } | undefined { | ||
| 264 | return parseCgroup2Mounts(contents)[0]; | ||
| 265 | } | ||
| 266 | |||
| 267 | export function wrapCommandForLinuxCgroup( | ||
| 268 | lease: AcpLinuxCgroupLease, | ||
| 269 | command: string, | ||
| 270 | args: ReadonlyArray<string>, | ||
| 271 | ): { readonly command: string; readonly args: ReadonlyArray<string> } { | ||
| 272 | return { | ||
| 273 | command: "/bin/sh", | ||
| 274 | args: [ | ||
| 275 | "-c", | ||
| 276 | [ | ||
| 277 | "lease_path=$1; expected=$2; shift 2", | ||
| 278 | 'printf "%s\\n" "$$" > "$lease_path/cgroup.procs" || exit 125', | ||
| 279 | "actual=", | ||
| 280 | "while IFS= read -r line; do", | ||
| 281 | ' case "$line" in 0::*) [ -z "$actual" ] || exit 126; actual=${line#0::};; esac', | ||
| 282 | "done < /proc/self/cgroup || exit 125", | ||
| 283 | '[ "$actual" = "$expected" ] || exit 126', | ||
| 284 | "unset ELECTRON_RUN_AS_NODE T3_ACP_CGROUP_WRAPPER", | ||
| 285 | "trap 'exit 125' 0", | ||
| 286 | 'exec "$@"', | ||
| 287 | ].join("\n"), | ||
| 288 | "t3-acp-cgroup-wrapper", | ||
| 289 | lease.path, | ||
| 290 | lease.relativePath, | ||
| 291 | command, | ||
| 292 | ...args, | ||
| 293 | ], | ||
| 294 | }; | ||
| 295 | } | ||
| 296 | |||
| 297 | export function resolveLinuxCgroupTargetCommand( | ||
| 298 | command: string, | ||
| 299 | cwd: string, | ||
| 300 | environment: NodeJS.ProcessEnv, | ||
| 301 | ): string | undefined { | ||
| 302 | // Match Node spawn PATH fallback when PATH is undefined. An explicitly empty | ||
| 303 | // PATH stays empty (lookup only via empty-segment → cwd). | ||
| 304 | const pathEnv = environment.PATH === undefined ? "/usr/bin:/bin" : environment.PATH; | ||
| 305 | const candidates = command.includes(NodePath.sep) | ||
| 306 | ? [NodePath.resolve(cwd, command)] | ||
| 307 | : pathEnv | ||
| 308 | .split(NodePath.delimiter) | ||
| 309 | .map((entry) => | ||
| 310 | NodePath.join( | ||
| 311 | entry.length === 0 | ||
| 312 | ? cwd | ||
| 313 | : NodePath.isAbsolute(entry) | ||
| 314 | ? entry | ||
| 315 | : NodePath.resolve(cwd, entry), | ||
| 316 | command, | ||
| 317 | ), | ||
| 318 | ); | ||
| 319 | for (const candidate of candidates) { | ||
| 320 | try { | ||
| 321 | if (!NodeFS.statSync(candidate).isFile()) continue; | ||
| 322 | NodeFS.accessSync(candidate, NodeFS.constants.X_OK); | ||
| 323 | return candidate; | ||
| 324 | } catch { | ||
| 325 | // Try the next PATH entry. | ||
| 326 | } | ||
| 327 | } | ||
| 328 | return undefined; | ||
| 329 | } | ||
| 330 | |||
| 331 | const STALE_ACP_CGROUP_SIBLING = /^t3-acp-(\d+)-/; | ||
| 332 | |||
| 333 | export interface SweepStaleLinuxCgroupSiblingsOptions { | ||
| 334 | readonly currentPid?: number; | ||
| 335 | readonly isProcessAlive?: (pid: number) => boolean; | ||
| 336 | readonly readPopulated?: (siblingPath: string) => "0" | "1" | undefined; | ||
| 337 | readonly remove?: (siblingPath: string) => void; | ||
| 338 | } | ||
| 339 | |||
| 340 | function defaultCgroupOwnerIsAlive(pid: number): boolean { | ||
| 341 | try { | ||
| 342 | process.kill(pid, 0); | ||
| 343 | return true; | ||
| 344 | } catch (cause) { | ||
| 345 | return (cause as NodeJS.ErrnoException | undefined)?.code !== "ESRCH"; | ||
| 346 | } | ||
| 347 | } | ||
| 348 | |||
| 349 | function defaultReadCgroupPopulated(siblingPath: string): "0" | "1" | undefined { | ||
| 350 | const state = /(?:^|\n)populated ([01])(?:\n|$)/.exec( | ||
| 351 | NodeFS.readFileSync(NodePath.join(siblingPath, "cgroup.events"), "utf8"), | ||
| 352 | )?.[1]; | ||
| 353 | return state === "0" || state === "1" ? state : undefined; | ||
| 354 | } | ||
| 355 | |||
| 356 | /** Best-effort removal of empty `t3-acp-<dead-pid>-*` sibling leases under a parent cgroup. */ | ||
| 357 | export function sweepStaleLinuxCgroupSiblings( | ||
| 358 | parentPath: string, | ||
| 359 | options: SweepStaleLinuxCgroupSiblingsOptions = {}, | ||
| 360 | ): void { | ||
| 361 | const currentPid = options.currentPid ?? process.pid; | ||
| 362 | const isProcessAlive = options.isProcessAlive ?? defaultCgroupOwnerIsAlive; | ||
| 363 | const readPopulated = options.readPopulated ?? defaultReadCgroupPopulated; | ||
| 364 | const remove = options.remove ?? ((siblingPath: string) => NodeFS.rmdirSync(siblingPath)); | ||
| 365 | try { | ||
| 366 | for (const entry of NodeFS.readdirSync(parentPath, { withFileTypes: true })) { | ||
| 367 | try { | ||
| 368 | if (!entry.isDirectory()) continue; | ||
| 369 | const match = STALE_ACP_CGROUP_SIBLING.exec(entry.name); | ||
| 370 | if (match === null) continue; | ||
| 371 | const ownerPid = Number(match[1]); | ||
| 372 | if (!Number.isSafeInteger(ownerPid) || ownerPid <= 1 || ownerPid === currentPid) continue; | ||
| 373 | if (isProcessAlive(ownerPid)) continue; | ||
| 374 | const siblingPath = NodePath.join(parentPath, entry.name); | ||
| 375 | if (NodePath.dirname(siblingPath) !== parentPath) continue; | ||
| 376 | if (readPopulated(siblingPath) !== "0") continue; | ||
| 377 | remove(siblingPath); | ||
| 378 | } catch { | ||
| 379 | // Ignore unreadable or busy siblings; never fail lease creation. | ||
| 380 | } | ||
| 381 | } | ||
| 382 | } catch { | ||
| 383 | // Ignore readdir failures on the parent path. | ||
| 384 | } | ||
| 385 | } | ||
| 386 | |||
| 387 | function tryCreateLinuxCgroupLease( | ||
| 388 | currentRelative: string, | ||
| 389 | mount: { readonly mountPoint: string; readonly root: string }, | ||
| 390 | ): AcpLinuxCgroupLease | undefined { | ||
| 391 | if ( | ||
| 392 | !isSafeCgroupPath(mount.root) || | ||
| 393 | !isSafeCgroupPath(mount.mountPoint) || | ||
| 394 | !( | ||
| 395 | mount.root === "/" || | ||
| 396 | currentRelative === mount.root || | ||
| 397 | currentRelative.startsWith(`${mount.root}/`) | ||
| 398 | ) | ||
| 399 | ) { | ||
| 400 | return undefined; | ||
| 401 | } | ||
| 402 | const mountRelative = | ||
| 403 | mount.root === "/" ? currentRelative : currentRelative.slice(mount.root.length); | ||
| 404 | const currentPath = NodePath.resolve(mount.mountPoint, `.${mountRelative}`); | ||
| 405 | const resolvedMount = NodePath.resolve(mount.mountPoint); | ||
| 406 | if (currentPath !== resolvedMount && !currentPath.startsWith(`${resolvedMount}${NodePath.sep}`)) { | ||
| 407 | return undefined; | ||
| 408 | } | ||
| 409 | sweepStaleLinuxCgroupSiblings(currentPath); | ||
| 410 | const childName = `t3-acp-${process.pid}-${NodeCrypto.randomUUID().replaceAll("-", "")}`; | ||
| 411 | const childPath = NodePath.join(currentPath, childName); | ||
| 412 | const childRelative = NodePath.posix.join(currentRelative, childName); | ||
| 413 | if (NodePath.dirname(childPath) !== currentPath) return undefined; | ||
| 414 | try { | ||
| 415 | NodeFS.mkdirSync(childPath, { mode: 0o700 }); | ||
| 416 | NodeFS.accessSync(NodePath.join(childPath, "cgroup.procs"), NodeFS.constants.W_OK); | ||
| 417 | NodeFS.accessSync(NodePath.join(childPath, "cgroup.kill"), NodeFS.constants.W_OK); | ||
| 418 | NodeFS.accessSync(NodePath.join(childPath, "cgroup.events"), NodeFS.constants.R_OK); | ||
| 419 | if (NodeFS.readFileSync(NodePath.join(childPath, "cgroup.type"), "utf8").trim() !== "domain") { | ||
| 420 | throw new Error("ACP cgroup is not a domain cgroup"); | ||
| 421 | } | ||
| 422 | if (typeof process.execve !== "function") throw new Error("process.execve is unavailable"); | ||
| 423 | } catch { | ||
| 424 | try { | ||
| 425 | NodeFS.rmdirSync(childPath); | ||
| 426 | } catch { | ||
| 427 | // The unavailable cgroup was never used for a child process. | ||
| 428 | } | ||
| 429 | return undefined; | ||
| 430 | } | ||
| 431 | return { | ||
| 432 | contains: (pid) => | ||
| 433 | parseUnifiedCgroupPath(NodeFS.readFileSync(`/proc/${pid}/cgroup`, "utf8")) === childRelative, | ||
| 434 | exists: () => NodeFS.existsSync(childPath), | ||
| 435 | path: childPath, | ||
| 436 | relativePath: childRelative, | ||
| 437 | kill: () => NodeFS.writeFileSync(NodePath.join(childPath, "cgroup.kill"), "1\n"), | ||
| 438 | populated: () => { | ||
| 439 | const state = /(?:^|\n)populated ([01])(?:\n|$)/.exec( | ||
| 440 | NodeFS.readFileSync(NodePath.join(childPath, "cgroup.events"), "utf8"), | ||
| 441 | )?.[1]; | ||
| 442 | if (state === undefined) throw new Error("ACP cgroup.events has no populated state"); | ||
| 443 | return state === "1"; | ||
| 444 | }, | ||
| 445 | remove: () => { | ||
| 446 | const directories = (path: string): ReadonlyArray<string> => | ||
| 447 | NodeFS.readdirSync(path, { withFileTypes: true }).flatMap((entry) => | ||
| 448 | entry.isDirectory() | ||
| 449 | ? [...directories(NodePath.join(path, entry.name)), NodePath.join(path, entry.name)] | ||
| 450 | : [], | ||
| 451 | ); | ||
| 452 | for (const directory of directories(childPath)) NodeFS.rmdirSync(directory); | ||
| 453 | NodeFS.rmdirSync(childPath); | ||
| 454 | }, | ||
| 455 | }; | ||
| 456 | } | ||
| 457 | |||
| 458 | export function makeLinuxCgroupController(): AcpLinuxCgroupController { | ||
| 459 | return { | ||
| 460 | create: () => { | ||
| 461 | let currentRelative: string | undefined; | ||
| 462 | let mounts: ReadonlyArray<{ readonly mountPoint: string; readonly root: string }> = []; | ||
| 463 | try { | ||
| 464 | currentRelative = parseUnifiedCgroupPath(NodeFS.readFileSync("/proc/self/cgroup", "utf8")); | ||
| 465 | mounts = parseCgroup2Mounts(NodeFS.readFileSync("/proc/self/mountinfo", "utf8")); | ||
| 466 | } catch { | ||
| 467 | return undefined; | ||
| 468 | } | ||
| 469 | if ( | ||
| 470 | currentRelative === undefined || | ||
| 471 | !currentRelative.startsWith("/") || | ||
| 472 | mounts.length === 0 | ||
| 473 | ) { | ||
| 474 | return undefined; | ||
| 475 | } | ||
| 476 | if (!isSafeCgroupPath(currentRelative)) return undefined; | ||
| 477 | // Prefer the first mount that successfully creates a writable child cgroup. | ||
| 478 | // Multi-mount hosts often list a read-only view before the delegated one. | ||
| 479 | for (const mount of mounts) { | ||
| 480 | const lease = tryCreateLinuxCgroupLease(currentRelative, mount); | ||
| 481 | if (lease !== undefined) return lease; | ||
| 482 | } | ||
| 483 | return undefined; | ||
| 484 | }, | ||
| 485 | }; | ||
| 486 | } | ||
| 487 | |||
| 488 | // OS-state polls (cgroup emptiness, process trees) must use wall time. Under | ||
| 489 | // TestClock, Effect.sleep never resolves unless the test advances the clock. | ||
| 490 | const wallClock = Clock.Clock.defaultValue(); | ||
| 491 | const withWallClock = <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> => | ||
| 492 | Effect.provideService(effect, Clock.Clock, wallClock); | ||
| 493 | |||
| 494 | export function terminateLinuxCgroupLease( | ||
| 495 | lease: AcpLinuxCgroupLease, | ||
| 496 | ): Effect.Effect<void, AcpProcessGroupTerminationError> { | ||
| 497 | const failure = (name: string, cause: unknown) => | ||
| 498 | new AcpProcessGroupTerminationError({ | ||
| 499 | cause, | ||
| 500 | detail: `Failed to ${name} ACP cgroup ${lease.path}`, | ||
| 501 | }); | ||
| 502 | return Effect.gen(function* () { | ||
| 503 | let lastRemovalFailure: { readonly cause: unknown } | null = null; | ||
| 504 | for (let attempt = 0; attempt < 200; attempt += 1) { | ||
| 505 | if (!lease.exists()) return; | ||
| 506 | try { | ||
| 507 | lease.kill(); | ||
| 508 | } catch (cause) { | ||
| 509 | const code = (cause as NodeJS.ErrnoException | undefined)?.code; | ||
| 510 | if (code !== "ENOENT") return yield* failure("kill", cause); | ||
| 511 | if (!lease.exists()) return; | ||
| 512 | yield* Effect.sleep("10 millis"); | ||
| 513 | continue; | ||
| 514 | } | ||
| 515 | let populated: boolean; | ||
| 516 | try { | ||
| 517 | populated = lease.populated(); | ||
| 518 | } catch (cause) { | ||
| 519 | const code = (cause as NodeJS.ErrnoException | undefined)?.code; | ||
| 520 | if (code === "ENOENT" && !lease.exists()) return; | ||
| 521 | if (code === "ENOENT") { | ||
| 522 | yield* Effect.sleep("10 millis"); | ||
| 523 | continue; | ||
| 524 | } | ||
| 525 | return yield* failure("read state for", cause); | ||
| 526 | } | ||
| 527 | if (populated) { | ||
| 528 | lastRemovalFailure = null; | ||
| 529 | yield* Effect.sleep("10 millis"); | ||
| 530 | continue; | ||
| 531 | } | ||
| 532 | try { | ||
| 533 | lease.remove(); | ||
| 534 | lastRemovalFailure = null; | ||
| 535 | } catch (cause) { | ||
| 536 | const code = (cause as NodeJS.ErrnoException | undefined)?.code; | ||
| 537 | if (code === "ENOENT" && !lease.exists()) return; | ||
| 538 | if (code !== "EBUSY" && code !== "ENOTEMPTY" && code !== "ENOENT") { | ||
| 539 | return yield* failure("remove", cause); | ||
| 540 | } | ||
| 541 | lastRemovalFailure = { cause }; | ||
| 542 | } | ||
| 543 | if (!lease.exists()) return; | ||
| 544 | yield* Effect.sleep("10 millis"); | ||
| 545 | } | ||
| 546 | if (lastRemovalFailure !== null) { | ||
| 547 | return yield* failure("remove", lastRemovalFailure.cause); | ||
| 548 | } | ||
| 549 | return yield* new AcpProcessGroupTerminationError({ | ||
| 550 | detail: `ACP cgroup ${lease.path} remained present after cgroup cleanup`, | ||
| 551 | }); | ||
| 552 | }).pipe(withWallClock); | ||
| 553 | } | ||
| 554 | |||
| 555 | export function signalLinuxCgroupRootTerm(input: { | ||
| 556 | readonly controller: AcpPosixProcessTreeController; | ||
| 557 | readonly lease: AcpLinuxCgroupLease; | ||
| 558 | readonly root: AcpPosixOwnershipRoot; | ||
| 559 | }): void { | ||
| 560 | const root = input.root.value; | ||
| 561 | if (root === undefined) return; | ||
| 562 | try { | ||
| 563 | const observed = input.controller.identity(root.pid); | ||
| 564 | if (!samePosixProcessIdentity(root, observed) || !input.lease.contains(root.pid)) return; | ||
| 565 | input.controller.signalProcess(root.pid, "SIGTERM"); | ||
| 566 | } catch { | ||
| 567 | // TERM is optional. cgroup.kill remains the authoritative teardown. | ||
| 568 | } | ||
| 569 | } | ||
| 570 | |||
| 571 | function readLinuxProcessIdentity(pid: number): AcpPosixProcessIdentity | undefined { | ||
| 572 | try { | ||
| 573 | const stat = NodeFS.readFileSync(`/proc/${pid}/stat`, "utf8"); | ||
| 574 | const commandEnd = stat.lastIndexOf(")"); | ||
| 575 | if (commandEnd < 0) return undefined; | ||
| 576 | const fields = stat | ||
| 577 | .slice(commandEnd + 2) | ||
| 578 | .trim() | ||
| 579 | .split(/\s+/); | ||
| 580 | // After comm: state, ppid, pgrp, session, … starttime (field 22 / index 19). | ||
| 581 | const state = fields[0]; | ||
| 582 | const ppid = Number(fields[1]); | ||
| 583 | const pgid = Number(fields[2]); | ||
| 584 | const sid = Number(fields[3]); | ||
| 585 | const startTime = fields[19]; | ||
| 586 | if ( | ||
| 587 | state === undefined || | ||
| 588 | !Number.isSafeInteger(ppid) || | ||
| 589 | !Number.isSafeInteger(pgid) || | ||
| 590 | !Number.isSafeInteger(sid) || | ||
| 591 | startTime === undefined | ||
| 592 | ) { | ||
| 593 | return undefined; | ||
| 594 | } | ||
| 595 | let executable: string | undefined; | ||
| 596 | try { | ||
| 597 | executable = NodeFS.readlinkSync(`/proc/${pid}/exe`); | ||
| 598 | } catch { | ||
| 599 | executable = undefined; | ||
| 600 | } | ||
| 601 | return { executable, pgid, pid, ppid, sid, startTime, state }; | ||
| 602 | } catch { | ||
| 603 | return undefined; | ||
| 604 | } | ||
| 605 | } | ||
| 606 | |||
| 607 | /** Linux zombies keep pid/starttime until reaped; they are not live survivors. */ | ||
| 608 | export function posixProcessIsZombie(process: AcpPosixProcessIdentity): boolean { | ||
| 609 | return process.state === "Z"; | ||
| 610 | } | ||
| 611 | |||
| 612 | function readLinuxChildPids(pid: number): ReadonlyArray<number> { | ||
| 613 | const childPids = new Set<number>(); | ||
| 614 | let taskIds: ReadonlyArray<string> = []; | ||
| 615 | try { | ||
| 616 | taskIds = NodeFS.readdirSync(`/proc/${pid}/task`).filter((entry) => | ||
| 617 | /^[1-9][0-9]*$/.test(entry), | ||
| 618 | ); | ||
| 619 | } catch { | ||
| 620 | return []; | ||
| 621 | } | ||
| 622 | for (const taskId of taskIds) { | ||
| 623 | try { | ||
| 624 | for (const childPid of NodeFS.readFileSync(`/proc/${pid}/task/${taskId}/children`, "utf8") | ||
| 625 | .trim() | ||
| 626 | .split(/\s+/) | ||
| 627 | .filter(Boolean) | ||
| 628 | .map(Number)) { | ||
| 629 | childPids.add(childPid); | ||
| 630 | } | ||
| 631 | } catch { | ||
| 632 | // The task exited while its children were being read. | ||
| 633 | } | ||
| 634 | } | ||
| 635 | return [...childPids]; | ||
| 636 | } | ||
| 637 | |||
| 638 | function makeLinuxProcessTreeController(): AcpPosixProcessTreeController { | ||
| 639 | return { | ||
| 640 | childPidsOf: readLinuxChildPids, | ||
| 641 | childrenOf: (pid) => | ||
| 642 | readLinuxChildPids(pid).flatMap((childPid) => { | ||
| 643 | const identity = readLinuxProcessIdentity(childPid); | ||
| 644 | return identity === undefined ? [] : [identity]; | ||
| 645 | }), | ||
| 646 | identity: readLinuxProcessIdentity, | ||
| 647 | snapshot: () => | ||
| 648 | NodeFS.readdirSync("/proc", { withFileTypes: true }).flatMap((entry) => { | ||
| 649 | if (!entry.isDirectory() || !/^[1-9][0-9]*$/.test(entry.name)) return []; | ||
| 650 | const identity = readLinuxProcessIdentity(Number(entry.name)); | ||
| 651 | return identity === undefined ? [] : [identity]; | ||
| 652 | }), | ||
| 653 | signalProcess: (pid, signal) => process.kill(pid, signal), | ||
| 654 | }; | ||
| 655 | } | ||
| 656 | |||
| 657 | export function observePosixOwnershipLedger(input: { | ||
| 658 | readonly childQueues: Map<number, Array<number>>; | ||
| 659 | readonly controller: AcpPosixProcessTreeController; | ||
| 660 | readonly frontier: Map<number, AcpOwnedPosixProcess>; | ||
| 661 | readonly ledger: Map<string, AcpOwnedPosixProcess>; | ||
| 662 | readonly maxProcesses?: number; | ||
| 663 | readonly root: AcpPosixOwnershipRoot; | ||
| 664 | readonly rootPid: number; | ||
| 665 | }): void { | ||
| 666 | let rootIdentity: AcpPosixProcessIdentity | undefined = input.root.value; | ||
| 667 | if (rootIdentity === undefined) { | ||
| 668 | if (input.root.captureAttempted === true) { | ||
| 669 | throw new AcpProcessGroupTerminationError({ | ||
| 670 | detail: `ACP process ${input.rootPid} exited before its ownership ledger was captured`, | ||
| 671 | }); | ||
| 672 | } | ||
| 673 | input.root.captureAttempted = true; | ||
| 674 | rootIdentity = input.controller.identity(input.rootPid); | ||
| 675 | if (rootIdentity === undefined) { | ||
| 676 | throw new AcpProcessGroupTerminationError({ | ||
| 677 | detail: `ACP process ${input.rootPid} exited before its ownership ledger was captured`, | ||
| 678 | }); | ||
| 679 | } | ||
| 680 | const parent = input.controller.identity(rootIdentity.ppid); | ||
| 681 | const ownedRoot = { | ||
| 682 | ...rootIdentity, | ||
| 683 | parentExecutable: parent?.executable, | ||
| 684 | parentStartTime: parent?.startTime ?? "", | ||
| 685 | }; | ||
| 686 | input.ledger.set(processIdentityKey(rootIdentity), ownedRoot); | ||
| 687 | input.frontier.set(rootIdentity.pid, ownedRoot); | ||
| 688 | input.root.value = ownedRoot; | ||
| 689 | } | ||
| 690 | const maxProcesses = Math.max(1, input.maxProcesses ?? Number.POSITIVE_INFINITY); | ||
| 691 | let remainingBudget = maxProcesses; | ||
| 692 | const pending = [...input.frontier.entries()]; | ||
| 693 | const visited = new Set<number>(); | ||
| 694 | while (pending.length > 0 && remainingBudget > 0) { | ||
| 695 | const entry = pending.shift(); | ||
| 696 | if (entry === undefined) continue; | ||
| 697 | const [parentPid, parent] = entry; | ||
| 698 | input.frontier.delete(parentPid); | ||
| 699 | if (visited.has(parent.pid)) continue; | ||
| 700 | visited.add(parent.pid); | ||
| 701 | remainingBudget -= 1; | ||
| 702 | const observedParent = input.controller.identity(parent.pid); | ||
| 703 | if (!samePosixProcessIdentity(parent, observedParent)) { | ||
| 704 | input.childQueues.delete(parent.pid); | ||
| 705 | continue; | ||
| 706 | } | ||
| 707 | const refreshedParent = { ...parent, ...observedParent }; | ||
| 708 | input.ledger.set(processIdentityKey(refreshedParent), refreshedParent); | ||
| 709 | input.frontier.set(refreshedParent.pid, refreshedParent); | ||
| 710 | let queuedChildren = input.childQueues.get(refreshedParent.pid); | ||
| 711 | if (queuedChildren === undefined) { | ||
| 712 | if (remainingBudget === 0) continue; | ||
| 713 | remainingBudget -= 1; | ||
| 714 | queuedChildren = [...input.controller.childPidsOf(refreshedParent.pid)]; | ||
| 715 | } | ||
| 716 | while (queuedChildren.length > 0 && remainingBudget > 0) { | ||
| 717 | const childPid = queuedChildren.shift(); | ||
| 718 | if (childPid === undefined) continue; | ||
| 719 | remainingBudget -= 1; | ||
| 720 | const child = input.controller.identity(childPid); | ||
| 721 | if (child === undefined) continue; | ||
| 722 | if (child.ppid !== parent.pid) continue; | ||
| 723 | let reservedPid = input.frontier.get(child.pid); | ||
| 724 | if (reservedPid !== undefined && !samePosixProcessIdentity(reservedPid, child)) { | ||
| 725 | // PID reused under an owned parent; drop the stale reservation. | ||
| 726 | input.frontier.delete(child.pid); | ||
| 727 | for (const [key, owned] of input.ledger.entries()) { | ||
| 728 | if (owned.pid === child.pid) input.ledger.delete(key); | ||
| 729 | } | ||
| 730 | reservedPid = undefined; | ||
| 731 | } | ||
| 732 | const childKey = processIdentityKey(child); | ||
| 733 | const owned = | ||
| 734 | reservedPid === undefined | ||
| 735 | ? { | ||
| 736 | ...child, | ||
| 737 | parentExecutable: refreshedParent.executable, | ||
| 738 | parentStartTime: refreshedParent.startTime, | ||
| 739 | } | ||
| 740 | : { ...reservedPid, ...child }; | ||
| 741 | input.ledger.set(childKey, owned); | ||
| 742 | input.frontier.set(child.pid, owned); | ||
| 743 | } | ||
| 744 | if (queuedChildren.length === 0) input.childQueues.delete(refreshedParent.pid); | ||
| 745 | else input.childQueues.set(refreshedParent.pid, queuedChildren); | ||
| 746 | } | ||
| 747 | } | ||
| 748 | |||
| 749 | export function observePosixOwnershipLedgerContinuously(input: { | ||
| 750 | readonly childQueues: Map<number, Array<number>>; | ||
| 751 | readonly controller: AcpPosixProcessTreeController; | ||
| 752 | readonly frontier: Map<number, AcpOwnedPosixProcess>; | ||
| 753 | readonly ledger: Map<string, AcpOwnedPosixProcess>; | ||
| 754 | readonly root: AcpPosixOwnershipRoot; | ||
| 755 | readonly rootPid: number; | ||
| 756 | }): Effect.Effect<never> { | ||
| 757 | return Effect.forever( | ||
| 758 | Effect.gen(function* () { | ||
| 759 | const startedAt = yield* Clock.currentTimeMillis; | ||
| 760 | yield* Effect.sync(() => observePosixOwnershipLedger({ ...input, maxProcesses: 64 })).pipe( | ||
| 761 | Effect.exit, | ||
| 762 | ); | ||
| 763 | const targetInterval = input.frontier.size <= 32 ? 25 : input.frontier.size <= 128 ? 50 : 100; | ||
| 764 | const completedAt = yield* Clock.currentTimeMillis; | ||
| 765 | yield* Effect.sleep(`${Math.max(5, targetInterval - (completedAt - startedAt))} millis`); | ||
| 766 | }), | ||
| 767 | ).pipe(withWallClock); | ||
| 768 | } | ||
| 769 | |||
| 770 | export function makePosixProcessTreeController( | ||
| 771 | platform: NodeJS.Platform, | ||
| 772 | ): AcpPosixProcessTreeController { | ||
| 773 | if (platform === "linux") return makeLinuxProcessTreeController(); | ||
| 774 | throw new AcpProcessGroupTerminationError({ | ||
| 775 | detail: `Detached ACP descendant ownership is unsupported on ${platform}`, | ||
| 776 | }); | ||
| 777 | } | ||
| 778 | |||
| 779 | function samePosixProcessIdentity( | ||
| 780 | expected: AcpPosixProcessIdentity, | ||
| 781 | observed: AcpPosixProcessIdentity | undefined, | ||
| 782 | ): boolean { | ||
| 783 | return ( | ||
| 784 | observed !== undefined && | ||
| 785 | observed.pid === expected.pid && | ||
| 786 | observed.startTime === expected.startTime | ||
| 787 | ); | ||
| 788 | } | ||
| 789 | |||
| 790 | export interface AcpOwnedPosixProcess extends AcpPosixProcessIdentity { | ||
| 791 | readonly parentExecutable: string | undefined; | ||
| 792 | readonly parentStartTime: string; | ||
| 793 | } | ||
| 794 | |||
| 795 | function processIdentityKey(process: AcpPosixProcessIdentity): string { | ||
| 796 | return `${process.pid}:${process.startTime}`; | ||
| 797 | } | ||
| 798 | |||
| 799 | function processDepth( | ||
| 800 | process: AcpPosixProcessIdentity, | ||
| 801 | ledgerByPid: ReadonlyMap<number, AcpOwnedPosixProcess>, | ||
| 802 | ): number { | ||
| 803 | let depth = 0; | ||
| 804 | let current = process; | ||
| 805 | const seen = new Set<number>(); | ||
| 806 | while (!seen.has(current.pid)) { | ||
| 807 | seen.add(current.pid); | ||
| 808 | const parent = ledgerByPid.get(current.ppid); | ||
| 809 | if (parent === undefined) break; | ||
| 810 | depth += 1; | ||
| 811 | current = parent; | ||
| 812 | } | ||
| 813 | return depth; | ||
| 814 | } | ||
| 815 | |||
| 816 | export function capturePosixOwnershipLedger(input: { | ||
| 817 | readonly controller: AcpPosixProcessTreeController; | ||
| 818 | readonly ledger: Map<string, AcpOwnedPosixProcess>; | ||
| 819 | readonly root?: AcpPosixOwnershipRoot; | ||
| 820 | readonly rootPid: number; | ||
| 821 | readonly table?: ReadonlyArray<AcpPosixProcessIdentity>; | ||
| 822 | }): ReadonlyArray<AcpPosixProcessIdentity> { | ||
| 823 | let table: ReadonlyArray<AcpPosixProcessIdentity>; | ||
| 824 | if (input.table === undefined) { | ||
| 825 | try { | ||
| 826 | table = input.controller.snapshot(); | ||
| 827 | } catch (cause) { | ||
| 828 | throw new AcpProcessGroupTerminationError({ | ||
| 829 | cause, | ||
| 830 | detail: `Failed to snapshot ACP process tree ${input.rootPid}`, | ||
| 831 | }); | ||
| 832 | } | ||
| 833 | } else { | ||
| 834 | table = input.table; | ||
| 835 | } | ||
| 836 | const byPid = new Map(table.map((process) => [process.pid, process])); | ||
| 837 | let rootIdentity: AcpPosixProcessIdentity | undefined = | ||
| 838 | input.root?.value ?? | ||
| 839 | [...input.ledger.values()].find((process) => process.pid === input.rootPid); | ||
| 840 | if (rootIdentity === undefined) { | ||
| 841 | rootIdentity = byPid.get(input.rootPid); | ||
| 842 | if (rootIdentity === undefined) { | ||
| 843 | throw new AcpProcessGroupTerminationError({ | ||
| 844 | detail: `ACP process ${input.rootPid} exited before its ownership ledger was captured`, | ||
| 845 | }); | ||
| 846 | } | ||
| 847 | const ownedRoot = { | ||
| 848 | ...rootIdentity, | ||
| 849 | parentExecutable: byPid.get(rootIdentity.ppid)?.executable, | ||
| 850 | parentStartTime: byPid.get(rootIdentity.ppid)?.startTime ?? "", | ||
| 851 | }; | ||
| 852 | input.ledger.set(processIdentityKey(rootIdentity), ownedRoot); | ||
| 853 | if (input.root !== undefined) input.root.value = ownedRoot; | ||
| 854 | } | ||
| 855 | // Drop non-root ledger entries whose PID is free or whose identity no longer | ||
| 856 | // matches the live process at that PID. Either case can block admission of a | ||
| 857 | // legitimate child after PID reuse. Keep the root reservation even if the | ||
| 858 | // root briefly disappears mid-teardown (finalizer retry still needs it). | ||
| 859 | for (const [key, owned] of input.ledger.entries()) { | ||
| 860 | if (owned.pid === input.rootPid) continue; | ||
| 861 | const live = byPid.get(owned.pid); | ||
| 862 | if (live === undefined || !samePosixProcessIdentity(owned, live)) { | ||
| 863 | input.ledger.delete(key); | ||
| 864 | } | ||
| 865 | } | ||
| 866 | const children = new Map<number, Array<AcpPosixProcessIdentity>>(); | ||
| 867 | const retainedByPid = new Map( | ||
| 868 | [...input.ledger.values()] | ||
| 869 | .filter((owned) => samePosixProcessIdentity(owned, byPid.get(owned.pid))) | ||
| 870 | .map((owned) => [owned.pid, owned]), | ||
| 871 | ); | ||
| 872 | for (const process of table) { | ||
| 873 | const siblings = children.get(process.ppid) ?? []; | ||
| 874 | siblings.push(process); | ||
| 875 | children.set(process.ppid, siblings); | ||
| 876 | } | ||
| 877 | const pending = [...input.ledger.values()].filter((owned) => | ||
| 878 | samePosixProcessIdentity(owned, byPid.get(owned.pid)), | ||
| 879 | ); | ||
| 880 | const visited = new Set<number>(); | ||
| 881 | while (pending.length > 0) { | ||
| 882 | const parent = pending.shift(); | ||
| 883 | if (parent === undefined || visited.has(parent.pid)) continue; | ||
| 884 | visited.add(parent.pid); | ||
| 885 | const observedParent = byPid.get(parent.pid); | ||
| 886 | if (!samePosixProcessIdentity(parent, observedParent)) continue; | ||
| 887 | const refreshedParent = { ...parent, ...observedParent }; | ||
| 888 | input.ledger.set(processIdentityKey(parent), refreshedParent); | ||
| 889 | for (const process of children.get(refreshedParent.pid) ?? []) { | ||
| 890 | const reservedPid = retainedByPid.get(process.pid); | ||
| 891 | if (reservedPid !== undefined && !samePosixProcessIdentity(reservedPid, process)) continue; | ||
| 892 | const owned = | ||
| 893 | reservedPid === undefined | ||
| 894 | ? { | ||
| 895 | ...process, | ||
| 896 | parentExecutable: refreshedParent.executable, | ||
| 897 | parentStartTime: refreshedParent.startTime, | ||
| 898 | } | ||
| 899 | : { ...reservedPid, ...process }; | ||
| 900 | input.ledger.set(processIdentityKey(process), owned); | ||
| 901 | retainedByPid.set(process.pid, owned); | ||
| 902 | pending.push(owned); | ||
| 903 | } | ||
| 904 | } | ||
| 905 | return table; | ||
| 906 | } | ||
| 907 | |||
| 908 | export function terminatePosixOwnedProcessTree(input: { | ||
| 909 | readonly controller: AcpPosixProcessTreeController; | ||
| 910 | readonly discoveryPasses?: number; | ||
| 911 | readonly grace?: Duration.Input; | ||
| 912 | readonly ledger?: Map<string, AcpOwnedPosixProcess>; | ||
| 913 | readonly root?: AcpPosixOwnershipRoot; | ||
| 914 | readonly rootPid: number; | ||
| 915 | }): Effect.Effect<void, AcpProcessGroupTerminationError> { | ||
| 916 | // This portable fallback can only admit processes whose exact parent chain is | ||
| 917 | // visible before it breaks. A descendant that double-forks before discovery | ||
| 918 | // requires pre-exec cgroup containment for a hard ownership guarantee. | ||
| 919 | const ledger = input.ledger ?? new Map<string, AcpOwnedPosixProcess>(); | ||
| 920 | |||
| 921 | const fail = (detail: string, cause?: unknown) => | ||
| 922 | new AcpProcessGroupTerminationError({ detail, ...(cause === undefined ? {} : { cause }) }); | ||
| 923 | const snapshot = () => { | ||
| 924 | try { | ||
| 925 | return input.controller.snapshot(); | ||
| 926 | } catch (cause) { | ||
| 927 | throw fail(`Failed to snapshot ACP process tree ${input.rootPid}`, cause); | ||
| 928 | } | ||
| 929 | }; | ||
| 930 | const discover = (table: ReadonlyArray<AcpPosixProcessIdentity>) => | ||
| 931 | capturePosixOwnershipLedger({ | ||
| 932 | controller: input.controller, | ||
| 933 | ledger, | ||
| 934 | ...(input.root === undefined ? {} : { root: input.root }), | ||
| 935 | rootPid: input.rootPid, | ||
| 936 | table, | ||
| 937 | }); | ||
| 938 | const signalPhase = (signal: NodeJS.Signals, includeRoot: boolean) => { | ||
| 939 | const table = snapshot(); | ||
| 940 | discover(table); | ||
| 941 | const byPid = new Map(table.map((entry) => [entry.pid, entry])); | ||
| 942 | const current = input.controller.identity(process.pid); | ||
| 943 | if (current === undefined) throw fail("Cannot identify the current T3 process group"); | ||
| 944 | const ledgerByPid = new Map( | ||
| 945 | [...ledger.values()].map((process) => [process.pid, process] as const), | ||
| 946 | ); | ||
| 947 | const pending = [...ledger.values()].filter( | ||
| 948 | (candidate) => | ||
| 949 | (includeRoot || candidate.pid !== input.rootPid) && | ||
| 950 | samePosixProcessIdentity(candidate, byPid.get(candidate.pid)), | ||
| 951 | ); | ||
| 952 | const retainedByPid = new Map( | ||
| 953 | [...ledger.values()] | ||
| 954 | .filter((owned) => samePosixProcessIdentity(owned, byPid.get(owned.pid))) | ||
| 955 | .map((owned) => [owned.pid, owned]), | ||
| 956 | ); | ||
| 957 | const signalled = new Set<number>(); | ||
| 958 | while (pending.length > 0) { | ||
| 959 | pending.sort( | ||
| 960 | (left, right) => processDepth(right, ledgerByPid) - processDepth(left, ledgerByPid), | ||
| 961 | ); | ||
| 962 | const candidate = pending.shift(); | ||
| 963 | if (candidate === undefined || signalled.has(candidate.pid)) continue; | ||
| 964 | const observed = input.controller.identity(candidate.pid); | ||
| 965 | if ( | ||
| 966 | candidate.pid <= 1 || | ||
| 967 | candidate.pid === process.pid || | ||
| 968 | observed === undefined || | ||
| 969 | observed.pgid === current.pgid || | ||
| 970 | observed.sid === current.sid || | ||
| 971 | !samePosixProcessIdentity(candidate, observed) | ||
| 972 | ) { | ||
| 973 | continue; | ||
| 974 | } | ||
| 975 | for (const child of input.controller.childrenOf(candidate.pid)) { | ||
| 976 | if (child.ppid !== candidate.pid) continue; | ||
| 977 | const reservedPid = retainedByPid.get(child.pid); | ||
| 978 | if (reservedPid !== undefined && !samePosixProcessIdentity(reservedPid, child)) continue; | ||
| 979 | const owned = | ||
| 980 | reservedPid === undefined | ||
| 981 | ? { | ||
| 982 | ...child, | ||
| 983 | parentExecutable: observed.executable, | ||
| 984 | parentStartTime: observed.startTime, | ||
| 985 | } | ||
| 986 | : { ...reservedPid, ...child }; | ||
| 987 | ledger.set(processIdentityKey(child), owned); | ||
| 988 | ledgerByPid.set(child.pid, owned); | ||
| 989 | retainedByPid.set(child.pid, owned); | ||
| 990 | // Signal newly discovered children in this same pass (fork-during-kill). | ||
| 991 | if ( | ||
| 992 | (includeRoot || child.pid !== input.rootPid) && | ||
| 993 | !signalled.has(child.pid) && | ||
| 994 | samePosixProcessIdentity(owned, input.controller.identity(child.pid)) | ||
| 995 | ) { | ||
| 996 | pending.push(owned); | ||
| 997 | } | ||
| 998 | } | ||
| 999 | try { | ||
| 1000 | // Linux starttime prevents PID reuse across observations, but Node does | ||
| 1001 | // not expose pidfd_send_signal. A final read-to-kill race remains. | ||
| 1002 | input.controller.signalProcess(candidate.pid, signal); | ||
| 1003 | signalled.add(candidate.pid); | ||
| 1004 | } catch (cause) { | ||
| 1005 | if (samePosixProcessIdentity(candidate, input.controller.identity(candidate.pid))) { | ||
| 1006 | throw fail(`Failed to signal ACP process ${candidate.pid}`, cause); | ||
| 1007 | } | ||
| 1008 | } | ||
| 1009 | } | ||
| 1010 | }; | ||
| 1011 | |||
| 1012 | return Effect.gen(function* () { | ||
| 1013 | const passes = Math.max(2, Math.min(input.discoveryPasses ?? 4, 8)); | ||
| 1014 | for (let pass = 0; pass < passes; pass += 1) { | ||
| 1015 | yield* Effect.try({ | ||
| 1016 | try: () => signalPhase("SIGTERM", false), | ||
| 1017 | catch: (cause) => cause as AcpProcessGroupTerminationError, | ||
| 1018 | }); | ||
| 1019 | yield* Effect.sleep("10 millis"); | ||
| 1020 | } | ||
| 1021 | yield* Effect.try({ | ||
| 1022 | try: () => signalPhase("SIGTERM", true), | ||
| 1023 | catch: (cause) => cause as AcpProcessGroupTerminationError, | ||
| 1024 | }); | ||
| 1025 | const grace = input.grace ?? "1 second"; | ||
| 1026 | if (grace !== 0) yield* Effect.sleep(grace); | ||
| 1027 | for (let pass = 0; pass < passes; pass += 1) { | ||
| 1028 | yield* Effect.try({ | ||
| 1029 | try: () => signalPhase("SIGKILL", true), | ||
| 1030 | catch: (cause) => cause as AcpProcessGroupTerminationError, | ||
| 1031 | }); | ||
| 1032 | yield* Effect.sleep("10 millis"); | ||
| 1033 | } | ||
| 1034 | const finalTable = snapshot(); | ||
| 1035 | discover(finalTable); | ||
| 1036 | const survivors = new Map(finalTable.map((entry) => [entry.pid, entry])); | ||
| 1037 | const residual = [...ledger.values()].filter((owned) => { | ||
| 1038 | const observed = survivors.get(owned.pid); | ||
| 1039 | if (!samePosixProcessIdentity(owned, observed) || observed === undefined) { | ||
| 1040 | return false; | ||
| 1041 | } | ||
| 1042 | // Already-killed zombies still appear in /proc until reaped; not live. | ||
| 1043 | return !posixProcessIsZombie(observed); | ||
| 1044 | }); | ||
| 1045 | if (residual.length > 0) { | ||
| 1046 | return yield* fail( | ||
| 1047 | `ACP process tree ${input.rootPid} retained owned processes: ${residual.map((entry) => entry.pid).join(", ")}`, | ||
| 1048 | ); | ||
| 1049 | } | ||
| 1050 | }).pipe(withWallClock); | ||
| 1051 | } | ||
| 1052 | |||
| 1053 | export function windowsTaskkillResultIsSuccess(exitCode: number, _output?: string): boolean { | ||
| 1054 | return exitCode === 0; | ||
| 1055 | } | ||
| 1056 | |||
| 1057 | export const terminateWindowsProcessTreeWithTaskkill = ( | ||
| 1058 | spawner: ChildProcessSpawner.ChildProcessSpawner["Service"], | ||
| 1059 | pid: number, | ||
| 1060 | ): Effect.Effect<void, AcpProcessGroupTerminationError> => | ||
| 1061 | Effect.scoped( | ||
| 1062 | Effect.gen(function* () { | ||
| 1063 | const taskkill = yield* spawner.spawn( | ||
| 1064 | ChildProcess.make("taskkill", ["/PID", String(pid), "/T", "/F"]), | ||
| 1065 | ); | ||
| 1066 | const outputFiber = yield* Stream.decodeText(taskkill.all).pipe( | ||
| 1067 | Stream.mkString, | ||
| 1068 | Effect.forkScoped, | ||
| 1069 | ); | ||
| 1070 | const exitCode = Number(yield* taskkill.exitCode); | ||
| 1071 | const output = yield* Fiber.join(outputFiber); | ||
| 1072 | // A missing leader cannot prove that inherited descendants exited. | ||
| 1073 | if (windowsTaskkillResultIsSuccess(exitCode, output)) return; | ||
| 1074 | const trimmedOutput = output.trim(); | ||
| 1075 | return yield* new AcpProcessGroupTerminationError({ | ||
| 1076 | detail: `taskkill exited ${exitCode} for ACP process tree ${pid}`, | ||
| 1077 | pid, | ||
| 1078 | exitCode, | ||
| 1079 | ...(trimmedOutput.length === 0 | ||
| 1080 | ? {} | ||
| 1081 | : { cause: trimmedOutput.length > 500 ? trimmedOutput.slice(0, 500) : trimmedOutput }), | ||
| 1082 | }); | ||
| 1083 | }), | ||
| 1084 | ).pipe( | ||
| 1085 | Effect.mapError((cause) => | ||
| 1086 | isAcpProcessGroupTerminationError(cause) | ||
| 1087 | ? cause | ||
| 1088 | : new AcpProcessGroupTerminationError({ | ||
| 1089 | cause, | ||
| 1090 | detail: `Failed to run taskkill for ACP process tree ${pid}`, | ||
| 1091 | pid, | ||
| 1092 | }), | ||
| 1093 | ), | ||
| 1094 | ); | ||
| 1095 | |||
| 1096 | export function selectAcpAgentAuthMethod( | ||
| 1097 | authMethods: ReadonlyArray<EffectAcpSchema.AuthMethod> | undefined, | ||
| 1098 | preferredMethodId?: string, | ||
| 1099 | ): EffectAcpSchema.AuthMethod | undefined { | ||
| 1100 | const preferred = preferredMethodId?.trim(); | ||
| 1101 | if (preferred) { | ||
| 1102 | return authMethods?.find((method) => method.id === preferred); | ||
| 1103 | } | ||
| 1104 | return authMethods?.find((method) => method.type === undefined || method.type === "agent"); | ||
| 1105 | } | ||
| 1106 | |||
| 1107 | function isAcpAuthenticationRequired(error: EffectAcpErrors.AcpError): boolean { | ||
| 1108 | return error._tag === "AcpRequestError" && error.code === -32000; | ||
| 1109 | } | ||
| 1110 | |||
| 1111 | export interface AcpSessionRuntimeStartResult { | ||
| 1112 | readonly sessionId: string; | ||
| 1113 | readonly initializeResult: EffectAcpSchema.InitializeResponse; | ||
| 1114 | readonly sessionSetupResult: | ||
| 1115 | | EffectAcpSchema.ForkSessionResponse | ||
| 1116 | | EffectAcpSchema.LoadSessionResponse | ||
| 1117 | | EffectAcpSchema.NewSessionResponse | ||
| 1118 | | EffectAcpSchema.ResumeSessionResponse; | ||
| 1119 | readonly modelConfigId: string | undefined; | ||
| 1120 | } | ||
| 1121 | |||
| 1122 | export interface AcpSessionActivationOptions { | ||
| 1123 | readonly mcpServers?: ReadonlyArray<EffectAcpSchema.McpServer>; | ||
| 1124 | readonly acpMcpServers?: ReadonlyArray<EffectAcpSchema.McpServer>; | ||
| 1125 | } | ||
| 1126 | |||
| 1127 | export class AcpSessionRuntime extends Context.Service< | ||
| 1128 | AcpSessionRuntime, | ||
| 1129 | { | ||
| 1130 | /** | ||
| 1131 | * Registers a handler for `session/request_permission`. | ||
| 1132 | * @see https://agentclientprotocol.com/protocol/schema#session/request_permission | ||
| 1133 | */ | ||
| 1134 | readonly handleRequestPermission: EffectAcpClient.AcpClient["Service"]["handleRequestPermission"]; | ||
| 1135 | /** | ||
| 1136 | * Registers a handler for `session/elicitation`. | ||
| 1137 | * @see https://agentclientprotocol.com/protocol/schema#session/elicitation | ||
| 1138 | */ | ||
| 1139 | readonly handleElicitation: EffectAcpClient.AcpClient["Service"]["handleElicitation"]; | ||
| 1140 | readonly handleMcpConnect: EffectAcpClient.AcpClient["Service"]["handleMcpConnect"]; | ||
| 1141 | readonly handleMcpMessage: EffectAcpClient.AcpClient["Service"]["handleMcpMessage"]; | ||
| 1142 | readonly handleMcpDisconnect: EffectAcpClient.AcpClient["Service"]["handleMcpDisconnect"]; | ||
| 1143 | readonly handleMcpNotification: EffectAcpClient.AcpClient["Service"]["handleMcpNotification"]; | ||
| 1144 | /** | ||
| 1145 | * Registers a handler for `fs/read_text_file`. | ||
| 1146 | * @see https://agentclientprotocol.com/protocol/schema#fs/read_text_file | ||
| 1147 | */ | ||
| 1148 | readonly handleReadTextFile: EffectAcpClient.AcpClient["Service"]["handleReadTextFile"]; | ||
| 1149 | /** | ||
| 1150 | * Registers a handler for `fs/write_text_file`. | ||
| 1151 | * @see https://agentclientprotocol.com/protocol/schema#fs/write_text_file | ||
| 1152 | */ | ||
| 1153 | readonly handleWriteTextFile: EffectAcpClient.AcpClient["Service"]["handleWriteTextFile"]; | ||
| 1154 | /** | ||
| 1155 | * Registers a handler for `terminal/create`. | ||
| 1156 | * @see https://agentclientprotocol.com/protocol/schema#terminal/create | ||
| 1157 | */ | ||
| 1158 | readonly handleCreateTerminal: EffectAcpClient.AcpClient["Service"]["handleCreateTerminal"]; | ||
| 1159 | /** | ||
| 1160 | * Registers a handler for `terminal/output`. | ||
| 1161 | * @see https://agentclientprotocol.com/protocol/schema#terminal/output | ||
| 1162 | */ | ||
| 1163 | readonly handleTerminalOutput: EffectAcpClient.AcpClient["Service"]["handleTerminalOutput"]; | ||
| 1164 | /** | ||
| 1165 | * Registers a handler for `terminal/wait_for_exit`. | ||
| 1166 | * @see https://agentclientprotocol.com/protocol/schema#terminal/wait_for_exit | ||
| 1167 | */ | ||
| 1168 | readonly handleTerminalWaitForExit: EffectAcpClient.AcpClient["Service"]["handleTerminalWaitForExit"]; | ||
| 1169 | /** | ||
| 1170 | * Registers a handler for `terminal/kill`. | ||
| 1171 | * @see https://agentclientprotocol.com/protocol/schema#terminal/kill | ||
| 1172 | */ | ||
| 1173 | readonly handleTerminalKill: EffectAcpClient.AcpClient["Service"]["handleTerminalKill"]; | ||
| 1174 | /** | ||
| 1175 | * Registers a handler for `terminal/release`. | ||
| 1176 | * @see https://agentclientprotocol.com/protocol/schema#terminal/release | ||
| 1177 | */ | ||
| 1178 | readonly handleTerminalRelease: EffectAcpClient.AcpClient["Service"]["handleTerminalRelease"]; | ||
| 1179 | /** | ||
| 1180 | * Registers a handler for `session/update`. | ||
| 1181 | * @see https://agentclientprotocol.com/protocol/schema#session/update | ||
| 1182 | */ | ||
| 1183 | readonly handleSessionUpdate: EffectAcpClient.AcpClient["Service"]["handleSessionUpdate"]; | ||
| 1184 | /** | ||
| 1185 | * Registers a handler for `session/elicitation/complete`. | ||
| 1186 | * @see https://agentclientprotocol.com/protocol/schema#session/elicitation/complete | ||
| 1187 | */ | ||
| 1188 | readonly handleElicitationComplete: EffectAcpClient.AcpClient["Service"]["handleElicitationComplete"]; | ||
| 1189 | /** | ||
| 1190 | * Registers a fallback extension request handler. | ||
| 1191 | * @see https://agentclientprotocol.com/protocol/extensibility | ||
| 1192 | */ | ||
| 1193 | readonly handleUnknownExtRequest: EffectAcpClient.AcpClient["Service"]["handleUnknownExtRequest"]; | ||
| 1194 | /** | ||
| 1195 | * Registers a fallback extension notification handler. | ||
| 1196 | * @see https://agentclientprotocol.com/protocol/extensibility | ||
| 1197 | */ | ||
| 1198 | readonly handleUnknownExtNotification: EffectAcpClient.AcpClient["Service"]["handleUnknownExtNotification"]; | ||
| 1199 | /** | ||
| 1200 | * Registers a typed extension request handler. | ||
| 1201 | * @see https://agentclientprotocol.com/protocol/extensibility | ||
| 1202 | */ | ||
| 1203 | readonly handleExtRequest: EffectAcpClient.AcpClient["Service"]["handleExtRequest"]; | ||
| 1204 | /** | ||
| 1205 | * Registers a typed extension notification handler. | ||
| 1206 | * @see https://agentclientprotocol.com/protocol/extensibility | ||
| 1207 | */ | ||
| 1208 | readonly handleExtNotification: EffectAcpClient.AcpClient["Service"]["handleExtNotification"]; | ||
| 1209 | /** | ||
| 1210 | * Sends only `initialize` and returns the agent's response. Health probes use this to read | ||
| 1211 | * advertised capabilities without authenticating or opening a session, so a probe can never | ||
| 1212 | * start an interactive login or boot MCP servers. | ||
| 1213 | * @see https://agentclientprotocol.com/protocol/schema#initialize | ||
| 1214 | */ | ||
| 1215 | readonly initialize: () => Effect.Effect< | ||
| 1216 | EffectAcpSchema.InitializeResponse, | ||
| 1217 | EffectAcpErrors.AcpError | ||
| 1218 | >; | ||
| 1219 | /** Explicit login uses the negotiated v1 authenticate / v2 auth/login operation. */ | ||
| 1220 | readonly authenticate?: (methodId: string) => Effect.Effect<void, EffectAcpErrors.AcpError>; | ||
| 1221 | /** | ||
| 1222 | * Initializes the ACP connection, authenticates, and loads, resumes, or creates the session. | ||
| 1223 | * Concurrent calls share the same in-flight startup and a failed startup may be retried. | ||
| 1224 | */ | ||
| 1225 | readonly start: () => Effect.Effect<AcpSessionRuntimeStartResult, EffectAcpErrors.AcpError>; | ||
| 1226 | /** Stream of parsed root-session events and connection failures. */ | ||
| 1227 | readonly getEvents: () => Stream.Stream<AcpSessionRuntimeEvent, never>; | ||
| 1228 | /** Waits for queued events to be processed, or for the runtime scope to close. */ | ||
| 1229 | readonly drainEvents: Effect.Effect<void>; | ||
| 1230 | /** Latest mode state observed from session setup and `session/update` notifications. */ | ||
| 1231 | readonly getModeState: Effect.Effect<AcpSessionModeState | undefined>; | ||
| 1232 | /** Latest configuration options observed from session setup and configuration writes. */ | ||
| 1233 | readonly getConfigOptions: Effect.Effect<ReadonlyArray<EffectAcpSchema.SessionConfigOption>>; | ||
| 1234 | readonly loadSession: ( | ||
| 1235 | sessionId: string, | ||
| 1236 | options?: AcpSessionActivationOptions, | ||
| 1237 | ) => Effect.Effect<AcpSessionRuntimeStartResult, EffectAcpErrors.AcpError>; | ||
| 1238 | readonly resumeSession: ( | ||
| 1239 | sessionId: string, | ||
| 1240 | options?: AcpSessionActivationOptions, | ||
| 1241 | ) => Effect.Effect<AcpSessionRuntimeStartResult, EffectAcpErrors.AcpError>; | ||
| 1242 | readonly forkSession: ( | ||
| 1243 | sessionId: string, | ||
| 1244 | options?: AcpSessionActivationOptions, | ||
| 1245 | ) => Effect.Effect<AcpSessionRuntimeStartResult, EffectAcpErrors.AcpError>; | ||
| 1246 | readonly listSessions: ( | ||
| 1247 | cursor?: string, | ||
| 1248 | ) => Effect.Effect<EffectAcpSchema.ListSessionsResponse, EffectAcpErrors.AcpError>; | ||
| 1249 | readonly closeSession: ( | ||
| 1250 | sessionId?: string, | ||
| 1251 | ) => Effect.Effect<EffectAcpSchema.CloseSessionResponse, EffectAcpErrors.AcpError>; | ||
| 1252 | readonly deleteSession: ( | ||
| 1253 | sessionId: string, | ||
| 1254 | ) => Effect.Effect<EffectAcpSchema.DeleteSessionResponse, EffectAcpErrors.AcpError>; | ||
| 1255 | readonly listProviders: Effect.Effect< | ||
| 1256 | EffectAcpSchema.ListProvidersResponse, | ||
| 1257 | EffectAcpErrors.AcpError | ||
| 1258 | >; | ||
| 1259 | readonly setProvider: ( | ||
| 1260 | provider: EffectAcpSchema.SetProviderRequest, | ||
| 1261 | ) => Effect.Effect<EffectAcpSchema.SetProviderResponse, EffectAcpErrors.AcpError>; | ||
| 1262 | readonly disableProvider: ( | ||
| 1263 | providerId: string, | ||
| 1264 | ) => Effect.Effect<EffectAcpSchema.DisableProviderResponse, EffectAcpErrors.AcpError>; | ||
| 1265 | /** Logs out the current ACP identity when the agent advertises support. */ | ||
| 1266 | readonly logout: Effect.Effect<EffectAcpSchema.LogoutResponse, EffectAcpErrors.AcpError>; | ||
| 1267 | /** | ||
| 1268 | * Sends a prompt turn to the active session. `options.dispatched` settles once the | ||
| 1269 | * `session/prompt` RPC is registered as the active prompt, so a caller that forks this | ||
| 1270 | * effect knows when a later `cancel` will target this prompt. | ||
| 1271 | * @see https://agentclientprotocol.com/protocol/schema#session/prompt | ||
| 1272 | */ | ||
| 1273 | readonly prompt: ( | ||
| 1274 | payload: Omit<EffectAcpSchema.PromptRequest, "sessionId">, | ||
| 1275 | options?: { readonly dispatched?: Deferred.Deferred<void> }, | ||
| 1276 | ) => Effect.Effect<EffectAcpSchema.PromptResponse, EffectAcpErrors.AcpError>; | ||
| 1277 | /** | ||
| 1278 | * Sends a real ACP `session/cancel` notification for the active session. | ||
| 1279 | * @see https://agentclientprotocol.com/protocol/schema#session/cancel | ||
| 1280 | */ | ||
| 1281 | readonly cancel: Effect.Effect<void, EffectAcpErrors.AcpError>; | ||
| 1282 | readonly processContainment: | ||
| 1283 | | "cgroup-v2" | ||
| 1284 | | "process-ledger-reduced-guarantee" | ||
| 1285 | | "process-group" | ||
| 1286 | | "none"; | ||
| 1287 | /** Terminates the opt-in detached ACP process group and all inherited work. */ | ||
| 1288 | readonly terminateProcessGroup?: Effect.Effect<void, AcpProcessGroupTerminationError>; | ||
| 1289 | /** | ||
| 1290 | * Selects the active mode through the negotiated `mode` configuration option. | ||
| 1291 | * This is a no-op when the requested mode is already active. | ||
| 1292 | * @see https://agentclientprotocol.com/protocol/schema#session/set_config_option | ||
| 1293 | */ | ||
| 1294 | readonly setMode: ( | ||
| 1295 | modeId: string, | ||
| 1296 | ) => Effect.Effect<EffectAcpSchema.SetSessionModeResponse, EffectAcpErrors.AcpError>; | ||
| 1297 | /** | ||
| 1298 | * Updates a session configuration option and the runtime configuration snapshot. | ||
| 1299 | * @see https://agentclientprotocol.com/protocol/schema#session/set_config_option | ||
| 1300 | */ | ||
| 1301 | readonly setSessionModel: ( | ||
| 1302 | modelId: string, | ||
| 1303 | meta?: EffectAcpSchema.SetSessionModelRequest["_meta"], | ||
| 1304 | ) => Effect.Effect<EffectAcpSchema.SetSessionModelResponse, EffectAcpErrors.AcpError>; | ||
| 1305 | readonly setConfigOption: ( | ||
| 1306 | configId: string, | ||
| 1307 | value: string | boolean, | ||
| 1308 | ) => Effect.Effect<EffectAcpSchema.SetSessionConfigOptionResponse, EffectAcpErrors.AcpError>; | ||
| 1309 | /** | ||
| 1310 | * Selects the base model through the negotiated model configuration option. | ||
| 1311 | * @see https://agentclientprotocol.com/protocol/schema#session/set_config_option | ||
| 1312 | */ | ||
| 1313 | readonly setModel: (model: string) => Effect.Effect<void, EffectAcpErrors.AcpError>; | ||
| 1314 | /** | ||
| 1315 | |||
| 1316 | * Sends a generic ACP extension request and records it through the request logger. | ||
| 1317 | * @see https://agentclientprotocol.com/protocol/extensibility | ||
| 1318 | */ | ||
| 1319 | readonly request: ( | ||
| 1320 | method: string, | ||
| 1321 | payload: unknown, | ||
| 1322 | ) => Effect.Effect<unknown, EffectAcpErrors.AcpError>; | ||
| 1323 | /** | ||
| 1324 | * Sends a generic ACP extension notification. | ||
| 1325 | * @see https://agentclientprotocol.com/protocol/extensibility | ||
| 1326 | */ | ||
| 1327 | readonly notify: ( | ||
| 1328 | method: string, | ||
| 1329 | payload: unknown, | ||
| 1330 | ) => Effect.Effect<void, EffectAcpErrors.AcpError>; | ||
| 1331 | } | ||
| 1332 | >()("t3/provider/acp/AcpSessionRuntime") { | ||
| 1333 | static layer( | ||
| 1334 | options: AcpSessionRuntimeOptions, | ||
| 1335 | ): Layer.Layer< | ||
| 1336 | AcpSessionRuntime, | ||
| 1337 | EffectAcpErrors.AcpError, | ||
| 1338 | ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | ||
| 1339 | > { | ||
| 1340 | return Layer.effect(AcpSessionRuntime, make(options)); | ||
| 1341 | } | ||
| 1342 | } | ||
| 1343 | |||
| 1344 | interface AcpStartedState extends AcpSessionRuntimeStartResult {} | ||
| 1345 | |||
| 1346 | type AcpStartState = | ||
| 1347 | | { readonly _tag: "NotStarted" } | ||
| 1348 | | { | ||
| 1349 | readonly _tag: "Starting"; | ||
| 1350 | readonly deferred: Deferred.Deferred<AcpSessionRuntimeStartResult, EffectAcpErrors.AcpError>; | ||
| 1351 | } | ||
| 1352 | | { readonly _tag: "Started"; readonly result: AcpStartedState }; | ||
| 1353 | |||
| 1354 | type AcpInitializeState = | ||
| 1355 | | { readonly _tag: "NotStarted" } | ||
| 1356 | | { | ||
| 1357 | readonly _tag: "Starting"; | ||
| 1358 | readonly deferred: Deferred.Deferred< | ||
| 1359 | EffectAcpSchema.InitializeResponse, | ||
| 1360 | EffectAcpErrors.AcpError | ||
| 1361 | >; | ||
| 1362 | } | ||
| 1363 | | { readonly _tag: "Started"; readonly result: EffectAcpSchema.InitializeResponse }; | ||
| 1364 | |||
| 1365 | interface AcpAssistantSegmentState { | ||
| 1366 | readonly nextSegmentIndex: number; | ||
| 1367 | readonly activeItemId?: string; | ||
| 1368 | } | ||
| 1369 | |||
| 1370 | interface EnsureActiveAssistantSegmentResult { | ||
| 1371 | readonly itemId: string; | ||
| 1372 | readonly startedEvent?: Extract<AcpParsedSessionEvent, { readonly _tag: "AssistantItemStarted" }>; | ||
| 1373 | } | ||
| 1374 | |||
| 1375 | interface AcpActivePrompt { | ||
| 1376 | readonly fiber: Fiber.Fiber<EffectAcpSchema.PromptResponse, EffectAcpErrors.AcpError>; | ||
| 1377 | readonly completed: Deferred.Deferred<void>; | ||
| 1378 | } | ||
| 1379 | |||
| 1380 | export const make = ( | ||
| 1381 | options: AcpSessionRuntimeOptions, | ||
| 1382 | ): Effect.Effect< | ||
| 1383 | AcpSessionRuntime["Service"], | ||
| 1384 | EffectAcpErrors.AcpError, | ||
| 1385 | ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | Scope.Scope | ||
| 1386 | > => | ||
| 1387 | Effect.gen(function* () { | ||
| 1388 | const crypto = yield* Crypto.Crypto; | ||
| 1389 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 1390 | // A child of the caller's scope, so termination can close the runtime without closing the caller's. | ||
| 1391 | const runtimeScope = yield* Scope.fork(yield* Scope.Scope); | ||
| 1392 | const eventQueue = yield* Queue.unbounded<AcpSessionRuntimeEvent>(); | ||
| 1393 | const modeStateRef = yield* Ref.make<AcpSessionModeState | undefined>(undefined); | ||
| 1394 | const toolCallsRef = yield* Ref.make(new Map<string, AcpToolCallTrackedState>()); | ||
| 1395 | // Recently shown tool calls. A late update to a finished call is not a new | ||
| 1396 | // boundary in the answer, although its progress state is gone. | ||
| 1397 | const shownToolCallIds = new Set<string>(); | ||
| 1398 | const assistantItemRuntimeId = yield* crypto.randomUUIDv4.pipe( | ||
| 1399 | Effect.mapError( | ||
| 1400 | (cause) => | ||
| 1401 | new EffectAcpErrors.AcpTransportError({ | ||
| 1402 | detail: "Failed to generate an ACP assistant item runtime identifier.", | ||
| 1403 | cause, | ||
| 1404 | }), | ||
| 1405 | ), | ||
| 1406 | ); | ||
| 1407 | const assistantSegmentRef = yield* Ref.make<AcpAssistantSegmentState>({ nextSegmentIndex: 0 }); | ||
| 1408 | const configOptionsRef = yield* Ref.make(sessionConfigOptionsFromSetup(undefined)); | ||
| 1409 | const initializeStateRef = yield* Ref.make<AcpInitializeState>({ _tag: "NotStarted" }); | ||
| 1410 | const startStateRef = yield* Ref.make<AcpStartState>({ _tag: "NotStarted" }); | ||
| 1411 | const startupMetadataRef = yield* Ref.make<ReadonlyArray<EffectAcpSchema.SessionNotification>>( | ||
| 1412 | [], | ||
| 1413 | ); | ||
| 1414 | const notificationSemaphore = yield* Semaphore.make(1); | ||
| 1415 | const terminationErrorRef = yield* Ref.make<Option.Option<EffectAcpErrors.AcpError>>( | ||
| 1416 | Option.none(), | ||
| 1417 | ); | ||
| 1418 | const stoppingRef = yield* Ref.make(false); | ||
| 1419 | const stderrFailure = yield* Deferred.make<never, EffectAcpErrors.AcpError>(); | ||
| 1420 | const stderrTailRef = yield* Ref.make(""); | ||
| 1421 | const stderrDrained = yield* Deferred.make<void>(); | ||
| 1422 | const runtimeClosed = yield* Deferred.make<void>(); | ||
| 1423 | const promptSerializationSemaphore = yield* Semaphore.make(1); | ||
| 1424 | const sessionLoadSemaphore = yield* Semaphore.make(1); | ||
| 1425 | const promptDispatchSemaphore = yield* Semaphore.make(1); | ||
| 1426 | const activePromptRef = yield* Ref.make<Option.Option<AcpActivePrompt>>(Option.none()); | ||
| 1427 | const assistantUpdatesOpenRef = yield* Ref.make(true); | ||
| 1428 | const sessionLoadGateRef = yield* Ref.make<Option.Option<SessionLoadGate>>(Option.none()); | ||
| 1429 | |||
| 1430 | const ensureConnected = Effect.gen(function* () { | ||
| 1431 | const error = yield* Ref.get(terminationErrorRef); | ||
| 1432 | if (Option.isSome(error)) { | ||
| 1433 | return yield* error.value; | ||
| 1434 | } | ||
| 1435 | if (yield* Ref.get(stoppingRef)) { | ||
| 1436 | return yield* new EffectAcpErrors.AcpTransportError({ | ||
| 1437 | detail: "The ACP session runtime is closed.", | ||
| 1438 | cause: undefined, | ||
| 1439 | }); | ||
| 1440 | } | ||
| 1441 | }); | ||
| 1442 | |||
| 1443 | const enrichProcessExitWithStderr = ( | ||
| 1444 | error: EffectAcpErrors.AcpError, | ||
| 1445 | ): Effect.Effect<EffectAcpErrors.AcpError> => | ||
| 1446 | error._tag !== "AcpProcessExitedError" || (error.stderr?.trim().length ?? 0) > 0 | ||
| 1447 | ? Effect.succeed(error) | ||
| 1448 | : Deferred.await(stderrDrained).pipe( | ||
| 1449 | Effect.timeout("250 millis"), | ||
| 1450 | Effect.ignore, | ||
| 1451 | Effect.andThen(Ref.get(stderrTailRef)), | ||
| 1452 | Effect.map((tail) => { | ||
| 1453 | const stderr = sanitizeAcpStderrExcerpt(tail); | ||
| 1454 | return stderr.length === 0 | ||
| 1455 | ? error | ||
| 1456 | : new EffectAcpErrors.AcpProcessExitedError({ | ||
| 1457 | ...(error.code !== undefined ? { code: error.code } : {}), | ||
| 1458 | ...(error.pid !== undefined ? { pid: error.pid } : {}), | ||
| 1459 | stderr, | ||
| 1460 | ...(error.cause !== undefined ? { cause: error.cause } : {}), | ||
| 1461 | }); | ||
| 1462 | }), | ||
| 1463 | ); | ||
| 1464 | |||
| 1465 | const recordTermination = Effect.fn("AcpSessionRuntime.recordTermination")(function* ( | ||
| 1466 | error: EffectAcpErrors.AcpError, | ||
| 1467 | ) { | ||
| 1468 | if (yield* Ref.get(stoppingRef)) { | ||
| 1469 | return; | ||
| 1470 | } | ||
| 1471 | const enriched = yield* enrichProcessExitWithStderr(error); | ||
| 1472 | const firstTermination = yield* Ref.modify(terminationErrorRef, (current) => | ||
| 1473 | Option.isSome(current) | ||
| 1474 | ? ([false, current] as const) | ||
| 1475 | : ([true, Option.some(enriched)] as const), | ||
| 1476 | ); | ||
| 1477 | if (!firstTermination) { | ||
| 1478 | return; | ||
| 1479 | } | ||
| 1480 | yield* closeActiveAssistantSegment({ queue: eventQueue, assistantSegmentRef }); | ||
| 1481 | yield* Queue.offer(eventQueue, { _tag: "ConnectionTerminated", error: enriched }); | ||
| 1482 | }); | ||
| 1483 | |||
| 1484 | const logRequest = (event: AcpSessionRequestLogEvent) => | ||
| 1485 | options.requestLogger ? options.requestLogger(event) : Effect.void; | ||
| 1486 | |||
| 1487 | const runLoggedRequest = <A>( | ||
| 1488 | method: string, | ||
| 1489 | payload: unknown, | ||
| 1490 | effect: Effect.Effect<A, EffectAcpErrors.AcpError>, | ||
| 1491 | ): Effect.Effect<A, EffectAcpErrors.AcpError> => | ||
| 1492 | logRequest({ method, payload, status: "started" }).pipe( | ||
| 1493 | Effect.flatMap(() => | ||
| 1494 | (options.onStderr | ||
| 1495 | ? Effect.raceFirst(effect, Deferred.await(stderrFailure)) | ||
| 1496 | : effect | ||
| 1497 | ).pipe( | ||
| 1498 | Effect.catch((error) => | ||
| 1499 | enrichProcessExitWithStderr(error).pipe(Effect.flatMap(Effect.fail)), | ||
| 1500 | ), | ||
| 1501 | Effect.tap((result) => | ||
| 1502 | logRequest({ | ||
| 1503 | method, | ||
| 1504 | payload, | ||
| 1505 | status: "succeeded", | ||
| 1506 | result, | ||
| 1507 | }), | ||
| 1508 | ), | ||
| 1509 | Effect.onError((cause) => | ||
| 1510 | logRequest({ | ||
| 1511 | method, | ||
| 1512 | payload, | ||
| 1513 | status: "failed", | ||
| 1514 | cause, | ||
| 1515 | }), | ||
| 1516 | ), | ||
| 1517 | ), | ||
| 1518 | ), | ||
| 1519 | ); | ||
| 1520 | |||
| 1521 | const spawnCommand = | ||
| 1522 | options.spawn.shell === false | ||
| 1523 | ? { command: options.spawn.command, args: options.spawn.args, shell: false } | ||
| 1524 | : yield* resolveSpawnCommand(options.spawn.command, options.spawn.args, { | ||
| 1525 | ...(options.spawn.env ? { env: options.spawn.env } : {}), | ||
| 1526 | extendEnv: options.spawn.extendEnv ?? true, | ||
| 1527 | }); | ||
| 1528 | const linuxCgroupLease = | ||
| 1529 | options.ownDescendantProcessGroups === true && options.processGroupPlatform === "linux" | ||
| 1530 | ? yield* Effect.sync(() => { | ||
| 1531 | try { | ||
| 1532 | if (options.linuxCgroupController === null) return undefined; | ||
| 1533 | return (options.linuxCgroupController ?? makeLinuxCgroupController()).create(); | ||
| 1534 | } catch { | ||
| 1535 | return undefined; | ||
| 1536 | } | ||
| 1537 | }) | ||
| 1538 | : undefined; | ||
| 1539 | // This is lifecycle containment for inherited provider work. It is not a | ||
| 1540 | // security boundary against a provider that can modify its delegated cgroup. | ||
| 1541 | if (linuxCgroupLease !== undefined) { | ||
| 1542 | yield* Scope.addFinalizer( | ||
| 1543 | runtimeScope, | ||
| 1544 | Effect.uninterruptible(terminateLinuxCgroupLease(linuxCgroupLease)).pipe( | ||
| 1545 | Effect.ignoreCause({ log: true }), | ||
| 1546 | ), | ||
| 1547 | ); | ||
| 1548 | } | ||
| 1549 | const containedTargetCommand = | ||
| 1550 | linuxCgroupLease === undefined || NodePath.isAbsolute(spawnCommand.command) | ||
| 1551 | ? spawnCommand.command | ||
| 1552 | : yield* Effect.gen(function* () { | ||
| 1553 | const resolved = resolveLinuxCgroupTargetCommand( | ||
| 1554 | spawnCommand.command, | ||
| 1555 | options.spawn.cwd ?? options.cwd, | ||
| 1556 | { ...process.env, ...options.spawn.env }, | ||
| 1557 | ); | ||
| 1558 | if (resolved !== undefined) return resolved; | ||
| 1559 | return yield* new EffectAcpErrors.AcpSpawnError({ | ||
| 1560 | command: options.spawn.command, | ||
| 1561 | cause: new Error("Contained ACP command was not found on PATH"), | ||
| 1562 | }); | ||
| 1563 | }); | ||
| 1564 | const containedSpawnCommand = | ||
| 1565 | linuxCgroupLease === undefined | ||
| 1566 | ? spawnCommand | ||
| 1567 | : { | ||
| 1568 | ...wrapCommandForLinuxCgroup( | ||
| 1569 | linuxCgroupLease, | ||
| 1570 | containedTargetCommand, | ||
| 1571 | spawnCommand.args, | ||
| 1572 | ), | ||
| 1573 | shell: false, | ||
| 1574 | }; | ||
| 1575 | const spawnEnvironment = | ||
| 1576 | linuxCgroupLease === undefined | ||
| 1577 | ? options.spawn.env | ||
| 1578 | : { | ||
| 1579 | ...options.spawn.env, | ||
| 1580 | ELECTRON_RUN_AS_NODE: "1", | ||
| 1581 | T3_ACP_CGROUP_WRAPPER: "1", | ||
| 1582 | }; | ||
| 1583 | const child = yield* spawner | ||
| 1584 | .spawn( | ||
| 1585 | ChildProcess.make(containedSpawnCommand.command, containedSpawnCommand.args, { | ||
| 1586 | ...(options.spawn.cwd ? { cwd: options.spawn.cwd } : {}), | ||
| 1587 | ...(spawnEnvironment ? { env: spawnEnvironment } : {}), | ||
| 1588 | extendEnv: options.spawn.extendEnv ?? true, | ||
| 1589 | ...(options.ownDetachedProcessGroup === undefined | ||
| 1590 | ? {} | ||
| 1591 | : { detached: options.ownDetachedProcessGroup }), | ||
| 1592 | shell: containedSpawnCommand.shell, | ||
| 1593 | }), | ||
| 1594 | ) | ||
| 1595 | .pipe( | ||
| 1596 | Effect.provideService(Scope.Scope, runtimeScope), | ||
| 1597 | Effect.mapError( | ||
| 1598 | (cause) => | ||
| 1599 | new EffectAcpErrors.AcpSpawnError({ | ||
| 1600 | command: options.spawn.command, | ||
| 1601 | cause, | ||
| 1602 | }), | ||
| 1603 | ), | ||
| 1604 | ); | ||
| 1605 | |||
| 1606 | const posixOwnershipLedger = new Map<string, AcpOwnedPosixProcess>(); | ||
| 1607 | const posixOwnershipFrontier = new Map<number, AcpOwnedPosixProcess>(); | ||
| 1608 | const posixOwnershipChildQueues = new Map<number, Array<number>>(); | ||
| 1609 | const posixOwnershipRoot: AcpPosixOwnershipRoot = { value: undefined }; | ||
| 1610 | const posixController = | ||
| 1611 | options.ownDescendantProcessGroups === true | ||
| 1612 | ? yield* Effect.try({ | ||
| 1613 | try: () => { | ||
| 1614 | if (options.posixProcessTreeController !== undefined) { | ||
| 1615 | return options.posixProcessTreeController; | ||
| 1616 | } | ||
| 1617 | if (options.processGroupPlatform === undefined) { | ||
| 1618 | throw new AcpProcessGroupTerminationError({ | ||
| 1619 | detail: "POSIX ACP descendant ownership requires a supported host platform", | ||
| 1620 | }); | ||
| 1621 | } | ||
| 1622 | return makePosixProcessTreeController(options.processGroupPlatform); | ||
| 1623 | }, | ||
| 1624 | catch: (cause) => | ||
| 1625 | new EffectAcpErrors.AcpSpawnError({ command: options.spawn.command, cause }), | ||
| 1626 | }) | ||
| 1627 | : undefined; | ||
| 1628 | if (posixController !== undefined) { | ||
| 1629 | const observeOwnership = () => | ||
| 1630 | observePosixOwnershipLedger({ | ||
| 1631 | childQueues: posixOwnershipChildQueues, | ||
| 1632 | controller: posixController, | ||
| 1633 | frontier: posixOwnershipFrontier, | ||
| 1634 | ledger: posixOwnershipLedger, | ||
| 1635 | root: posixOwnershipRoot, | ||
| 1636 | rootPid: Number(child.pid), | ||
| 1637 | }); | ||
| 1638 | yield* Effect.try({ | ||
| 1639 | try: observeOwnership, | ||
| 1640 | catch: (cause) => | ||
| 1641 | new EffectAcpErrors.AcpSpawnError({ command: options.spawn.command, cause }), | ||
| 1642 | }); | ||
| 1643 | yield* observePosixOwnershipLedgerContinuously({ | ||
| 1644 | childQueues: posixOwnershipChildQueues, | ||
| 1645 | controller: posixController, | ||
| 1646 | frontier: posixOwnershipFrontier, | ||
| 1647 | ledger: posixOwnershipLedger, | ||
| 1648 | root: posixOwnershipRoot, | ||
| 1649 | rootPid: Number(child.pid), | ||
| 1650 | }).pipe(Effect.forkIn(runtimeScope)); | ||
| 1651 | } | ||
| 1652 | |||
| 1653 | const signalOwnedProcessGroup = (signal: NodeJS.Signals) => | ||
| 1654 | Effect.try({ | ||
| 1655 | try: () => { | ||
| 1656 | signalProcessGroup(Number(child.pid), signal); | ||
| 1657 | return true; | ||
| 1658 | }, | ||
| 1659 | catch: (cause) => | ||
| 1660 | new AcpProcessGroupTerminationError({ | ||
| 1661 | cause, | ||
| 1662 | detail: `Failed to signal ACP process group ${child.pid} with ${signal}`, | ||
| 1663 | }), | ||
| 1664 | }).pipe( | ||
| 1665 | Effect.catch((error) => { | ||
| 1666 | const cause = error.cause as NodeJS.ErrnoException | undefined; | ||
| 1667 | return cause?.code === "ESRCH" ? Effect.succeed(false) : Effect.fail(error); | ||
| 1668 | }), | ||
| 1669 | ); | ||
| 1670 | const terminateWindowsProcessTree = | ||
| 1671 | options.windowsProcessTreeTerminator ?? | ||
| 1672 | ((pid: number) => terminateWindowsProcessTreeWithTaskkill(spawner, pid)); | ||
| 1673 | const terminatePosixProcessTree = (grace: Duration.Input, platformOverride?: NodeJS.Platform) => | ||
| 1674 | Effect.gen(function* () { | ||
| 1675 | const platform = platformOverride ?? options.processGroupPlatform; | ||
| 1676 | if (platform === undefined || platform === "win32") { | ||
| 1677 | return yield* new AcpProcessGroupTerminationError({ | ||
| 1678 | detail: "POSIX ACP descendant ownership requires a supported host platform", | ||
| 1679 | }); | ||
| 1680 | } | ||
| 1681 | const controller = yield* Effect.try({ | ||
| 1682 | try: () => posixController ?? makePosixProcessTreeController(platform), | ||
| 1683 | catch: (cause) => | ||
| 1684 | isAcpProcessGroupTerminationError(cause) | ||
| 1685 | ? cause | ||
| 1686 | : new AcpProcessGroupTerminationError({ | ||
| 1687 | cause, | ||
| 1688 | detail: `Failed to initialize ACP descendant ownership on ${platform}`, | ||
| 1689 | }), | ||
| 1690 | }); | ||
| 1691 | yield* terminatePosixOwnedProcessTree({ | ||
| 1692 | controller, | ||
| 1693 | grace, | ||
| 1694 | ledger: posixOwnershipLedger, | ||
| 1695 | root: posixOwnershipRoot, | ||
| 1696 | rootPid: Number(child.pid), | ||
| 1697 | }); | ||
| 1698 | }); | ||
| 1699 | const terminateOwnedProcessGroupImpl = Effect.gen(function* () { | ||
| 1700 | if (options.ownDetachedProcessGroup !== true) return; | ||
| 1701 | if (options.processGroupPlatform === undefined) { | ||
| 1702 | return yield* new AcpProcessGroupTerminationError({ | ||
| 1703 | detail: "Detached ACP process-group ownership requires a host platform", | ||
| 1704 | }); | ||
| 1705 | } | ||
| 1706 | if (options.processGroupPlatform === "win32") { | ||
| 1707 | return yield* terminateWindowsProcessTree(Number(child.pid)); | ||
| 1708 | } | ||
| 1709 | if (linuxCgroupLease !== undefined) { | ||
| 1710 | if (posixController !== undefined) { | ||
| 1711 | yield* Effect.sync(() => | ||
| 1712 | signalLinuxCgroupRootTerm({ | ||
| 1713 | controller: posixController, | ||
| 1714 | lease: linuxCgroupLease, | ||
| 1715 | root: posixOwnershipRoot, | ||
| 1716 | }), | ||
| 1717 | ); | ||
| 1718 | } | ||
| 1719 | const grace = options.processGroupTerminationGrace ?? "1 second"; | ||
| 1720 | if (grace !== 0) yield* Effect.sleep(grace); | ||
| 1721 | return yield* terminateLinuxCgroupLease(linuxCgroupLease); | ||
| 1722 | } | ||
| 1723 | if (options.ownDescendantProcessGroups === true) { | ||
| 1724 | return yield* terminatePosixProcessTree(options.processGroupTerminationGrace ?? "1 second"); | ||
| 1725 | } | ||
| 1726 | const groupExisted = yield* signalOwnedProcessGroup("SIGTERM"); | ||
| 1727 | if (!groupExisted) return; | ||
| 1728 | const grace = options.processGroupTerminationGrace ?? "1 second"; | ||
| 1729 | if (grace !== 0) { | ||
| 1730 | yield* Effect.sleep(grace); | ||
| 1731 | } | ||
| 1732 | yield* signalOwnedProcessGroup("SIGKILL"); | ||
| 1733 | }).pipe(withWallClock); | ||
| 1734 | const terminateProcessGroup = yield* Effect.cached( | ||
| 1735 | Effect.uninterruptible(terminateOwnedProcessGroupImpl), | ||
| 1736 | ); | ||
| 1737 | if (options.ownDetachedProcessGroup === true) { | ||
| 1738 | const hostPlatform = yield* HostProcessPlatform; | ||
| 1739 | const forceTerminateOwnedProcessGroup = | ||
| 1740 | hostPlatform === "win32" | ||
| 1741 | ? terminateWindowsProcessTreeWithTaskkill(spawner, Number(child.pid)) | ||
| 1742 | : linuxCgroupLease !== undefined | ||
| 1743 | ? terminateLinuxCgroupLease(linuxCgroupLease) | ||
| 1744 | : options.ownDescendantProcessGroups === true | ||
| 1745 | ? terminatePosixProcessTree(0, hostPlatform) | ||
| 1746 | : signalOwnedProcessGroup("SIGKILL").pipe(Effect.asVoid); | ||
| 1747 | yield* Scope.addFinalizer( | ||
| 1748 | runtimeScope, | ||
| 1749 | Effect.uninterruptible(forceTerminateOwnedProcessGroup).pipe( | ||
| 1750 | Effect.ignoreCause({ log: true }), | ||
| 1751 | ), | ||
| 1752 | ); | ||
| 1753 | } | ||
| 1754 | yield* child.stderr.pipe( | ||
| 1755 | Stream.decodeText(), | ||
| 1756 | Stream.runForEach((chunk) => | ||
| 1757 | Ref.update(stderrTailRef, (current) => appendAcpStderrTail(current, chunk)).pipe( | ||
| 1758 | Effect.andThen( | ||
| 1759 | options.onStderr ? options.onStderr(chunk.slice(-maxStderrChunkLength)) : Effect.void, | ||
| 1760 | ), | ||
| 1761 | Effect.catch((error) => | ||
| 1762 | Effect.gen(function* () { | ||
| 1763 | yield* Deferred.fail(stderrFailure, error); | ||
| 1764 | yield* recordTermination(error); | ||
| 1765 | yield* child.kill({ forceKillAfter: "1 second" }).pipe(Effect.ignore); | ||
| 1766 | }), | ||
| 1767 | ), | ||
| 1768 | ), | ||
| 1769 | ), | ||
| 1770 | Effect.ensuring(Deferred.succeed(stderrDrained, undefined)), | ||
| 1771 | Effect.ignore, | ||
| 1772 | Effect.forkIn(runtimeScope), | ||
| 1773 | ); | ||
| 1774 | |||
| 1775 | const acpContext = yield* Layer.build( | ||
| 1776 | EffectAcpClient.layerChildProcess(child, { | ||
| 1777 | ...(options.transformStdout ? { transformStdout: options.transformStdout } : {}), | ||
| 1778 | ...(options.transformSessionUpdate | ||
| 1779 | ? { transformSessionUpdate: options.transformSessionUpdate } | ||
| 1780 | : {}), | ||
| 1781 | ...(options.protocolLogging?.logIncoming !== undefined | ||
| 1782 | ? { logIncoming: options.protocolLogging.logIncoming } | ||
| 1783 | : {}), | ||
| 1784 | ...(options.protocolLogging?.logOutgoing !== undefined | ||
| 1785 | ? { logOutgoing: options.protocolLogging.logOutgoing } | ||
| 1786 | : {}), | ||
| 1787 | ...(options.protocolLogging?.logger ? { logger: options.protocolLogging.logger } : {}), | ||
| 1788 | ...(options.onIncomingRequest ? { onIncomingRequest: options.onIncomingRequest } : {}), | ||
| 1789 | onTermination: (error) => | ||
| 1790 | recordTermination(error).pipe( | ||
| 1791 | Effect.andThen(options.onTermination?.(error) ?? Effect.void), | ||
| 1792 | Effect.ensuring( | ||
| 1793 | Scope.close(runtimeScope, Exit.fail(error)).pipe(Effect.forkDetach, Effect.asVoid), | ||
| 1794 | ), | ||
| 1795 | ), | ||
| 1796 | ...(options.onOutgoingResponseFailure | ||
| 1797 | ? { onOutgoingResponseFailure: options.onOutgoingResponseFailure } | ||
| 1798 | : {}), | ||
| 1799 | ...(options.onOutgoingResponse ? { onOutgoingResponse: options.onOutgoingResponse } : {}), | ||
| 1800 | }), | ||
| 1801 | ).pipe(Effect.provideService(Scope.Scope, runtimeScope)); | ||
| 1802 | |||
| 1803 | const acp = yield* Effect.service(EffectAcpClient.AcpClient).pipe(Effect.provide(acpContext)); | ||
| 1804 | |||
| 1805 | const processSessionUpdate = (notification: EffectAcpSchema.SessionNotification) => | ||
| 1806 | handleSessionUpdate({ | ||
| 1807 | queue: eventQueue, | ||
| 1808 | modeStateRef, | ||
| 1809 | configOptionsRef, | ||
| 1810 | toolCallsRef, | ||
| 1811 | shownToolCallIds, | ||
| 1812 | assistantSegmentRef, | ||
| 1813 | assistantItemRuntimeId, | ||
| 1814 | params: notification, | ||
| 1815 | }); | ||
| 1816 | |||
| 1817 | yield* acp.handleSessionUpdate((notification) => | ||
| 1818 | notificationSemaphore.withPermit( | ||
| 1819 | Effect.gen(function* () { | ||
| 1820 | if (Option.isSome(yield* Ref.get(terminationErrorRef))) { | ||
| 1821 | return; | ||
| 1822 | } | ||
| 1823 | const gate = yield* Ref.get(sessionLoadGateRef); | ||
| 1824 | // A different session can still have an in-flight prompt while this | ||
| 1825 | // load replays history, so quarantine only the loading session. | ||
| 1826 | const loadingThisSession = | ||
| 1827 | Option.isSome(gate) && | ||
| 1828 | gate.value.active && | ||
| 1829 | notification.sessionId === gate.value.sessionId; | ||
| 1830 | if ( | ||
| 1831 | loadingThisSession && | ||
| 1832 | sessionUpdateCountsAsLoadReplayActivity(notification, gate.value.sessionId) | ||
| 1833 | ) { | ||
| 1834 | const lastActivityAtMillis = yield* Clock.currentTimeMillis; | ||
| 1835 | yield* Ref.set( | ||
| 1836 | sessionLoadGateRef, | ||
| 1837 | Option.some({ | ||
| 1838 | ...gate.value, | ||
| 1839 | lastActivityAtMillis, | ||
| 1840 | }), | ||
| 1841 | ); | ||
| 1842 | } | ||
| 1843 | if (sessionUpdateIsReplay(notification)) { | ||
| 1844 | return; | ||
| 1845 | } | ||
| 1846 | const startState = yield* Ref.get(startStateRef); | ||
| 1847 | if (startState._tag === "Starting") { | ||
| 1848 | if (isStartupMetadataUpdate(notification)) { | ||
| 1849 | yield* Ref.update(startupMetadataRef, (current) => | ||
| 1850 | [ | ||
| 1851 | ...current.filter( | ||
| 1852 | (previous) => | ||
| 1853 | previous.sessionId !== notification.sessionId || | ||
| 1854 | previous.update.sessionUpdate !== notification.update.sessionUpdate, | ||
| 1855 | ), | ||
| 1856 | notification, | ||
| 1857 | ].slice(-maxStartupMetadataUpdates), | ||
| 1858 | ); | ||
| 1859 | } | ||
| 1860 | return; | ||
| 1861 | } | ||
| 1862 | if (loadingThisSession) { | ||
| 1863 | if (notification.update.sessionUpdate === "config_option_update") { | ||
| 1864 | const candidateConfigOptions = notification.update.configOptions; | ||
| 1865 | yield* Ref.update(sessionLoadGateRef, (current) => | ||
| 1866 | Option.isSome(current) | ||
| 1867 | ? Option.some({ ...current.value, candidateConfigOptions }) | ||
| 1868 | : current, | ||
| 1869 | ); | ||
| 1870 | } | ||
| 1871 | return; | ||
| 1872 | } | ||
| 1873 | // One runtime projects one root ACP session. Child-session updates need | ||
| 1874 | // explicit lineage routing and must never be flattened into this stream. | ||
| 1875 | if ( | ||
| 1876 | startState._tag !== "Started" || | ||
| 1877 | notification.sessionId !== startState.result.sessionId | ||
| 1878 | ) { | ||
| 1879 | return; | ||
| 1880 | } | ||
| 1881 | if ( | ||
| 1882 | !(yield* Ref.get(assistantUpdatesOpenRef)) && | ||
| 1883 | (notification.update.sessionUpdate === "agent_message_chunk" || | ||
| 1884 | notification.update.sessionUpdate === "agent_thought_chunk") | ||
| 1885 | ) { | ||
| 1886 | return; | ||
| 1887 | } | ||
| 1888 | yield* processSessionUpdate(notification); | ||
| 1889 | }), | ||
| 1890 | ), | ||
| 1891 | ); | ||
| 1892 | yield* Scope.addFinalizer( | ||
| 1893 | runtimeScope, | ||
| 1894 | Ref.set(stoppingRef, true).pipe(Effect.andThen(Deferred.succeed(runtimeClosed, undefined))), | ||
| 1895 | ); | ||
| 1896 | const initializeClientCapabilities = { | ||
| 1897 | fs: { | ||
| 1898 | readTextFile: false, | ||
| 1899 | writeTextFile: false, | ||
| 1900 | ...options.clientCapabilities?.fs, | ||
| 1901 | }, | ||
| 1902 | terminal: options.clientCapabilities?.terminal ?? false, | ||
| 1903 | ...(options.clientCapabilities?.auth ? { auth: options.clientCapabilities.auth } : {}), | ||
| 1904 | ...(options.clientCapabilities?.elicitation | ||
| 1905 | ? { elicitation: options.clientCapabilities.elicitation } | ||
| 1906 | : {}), | ||
| 1907 | ...(options.clientCapabilities?._meta ? { _meta: options.clientCapabilities._meta } : {}), | ||
| 1908 | } satisfies NonNullable<EffectAcpSchema.InitializeRequest["clientCapabilities"]>; | ||
| 1909 | |||
| 1910 | const getStartedState = Effect.gen(function* () { | ||
| 1911 | yield* ensureConnected; | ||
| 1912 | const state = yield* Ref.get(startStateRef); | ||
| 1913 | if (state._tag === "Started") { | ||
| 1914 | return state.result; | ||
| 1915 | } | ||
| 1916 | return yield* new EffectAcpErrors.AcpTransportError({ | ||
| 1917 | detail: "ACP session runtime has not been started", | ||
| 1918 | cause: "ACP session runtime has not been started", | ||
| 1919 | }); | ||
| 1920 | }); | ||
| 1921 | |||
| 1922 | const validateConfigOptionValue = ( | ||
| 1923 | configId: string, | ||
| 1924 | value: string | boolean, | ||
| 1925 | ): Effect.Effect<void, EffectAcpErrors.AcpError> => | ||
| 1926 | Effect.gen(function* () { | ||
| 1927 | const configOption = findSessionConfigOption(yield* Ref.get(configOptionsRef), configId); | ||
| 1928 | if (!configOption) { | ||
| 1929 | return; | ||
| 1930 | } | ||
| 1931 | if (configOption.type === "boolean") { | ||
| 1932 | if (typeof value === "boolean") { | ||
| 1933 | return; | ||
| 1934 | } | ||
| 1935 | return yield* new EffectAcpErrors.AcpRequestError({ | ||
| 1936 | code: -32602, | ||
| 1937 | errorMessage: `Invalid value ${formatConfigOptionValue(value)} for session config option "${configOption.id}": expected boolean`, | ||
| 1938 | data: { | ||
| 1939 | configId: configOption.id, | ||
| 1940 | expectedType: "boolean", | ||
| 1941 | receivedValue: value, | ||
| 1942 | }, | ||
| 1943 | }); | ||
| 1944 | } | ||
| 1945 | if (typeof value !== "string") { | ||
| 1946 | return yield* new EffectAcpErrors.AcpRequestError({ | ||
| 1947 | code: -32602, | ||
| 1948 | errorMessage: `Invalid value ${formatConfigOptionValue(value)} for session config option "${configOption.id}": expected string`, | ||
| 1949 | data: { | ||
| 1950 | configId: configOption.id, | ||
| 1951 | expectedType: "string", | ||
| 1952 | receivedValue: value, | ||
| 1953 | }, | ||
| 1954 | }); | ||
| 1955 | } | ||
| 1956 | const allowedValues = collectSessionConfigOptionValues(configOption); | ||
| 1957 | if (allowedValues.includes(value)) { | ||
| 1958 | return; | ||
| 1959 | } | ||
| 1960 | return yield* new EffectAcpErrors.AcpRequestError({ | ||
| 1961 | code: -32602, | ||
| 1962 | errorMessage: `Invalid value ${formatConfigOptionValue(value)} for session config option "${configOption.id}": expected one of ${allowedValues.join(", ")}`, | ||
| 1963 | data: { | ||
| 1964 | configId: configOption.id, | ||
| 1965 | allowedValues, | ||
| 1966 | receivedValue: value, | ||
| 1967 | }, | ||
| 1968 | }); | ||
| 1969 | }); | ||
| 1970 | |||
| 1971 | const updateConfigOptions = Effect.fn("AcpSessionRuntime.updateConfigOptions")(function* ( | ||
| 1972 | response: | ||
| 1973 | | EffectAcpSchema.SetSessionConfigOptionResponse | ||
| 1974 | | EffectAcpSchema.ForkSessionResponse | ||
| 1975 | | EffectAcpSchema.LoadSessionResponse | ||
| 1976 | | EffectAcpSchema.NewSessionResponse | ||
| 1977 | | EffectAcpSchema.ResumeSessionResponse, | ||
| 1978 | ) { | ||
| 1979 | const configOptions = sessionConfigOptionsFromSetup(response); | ||
| 1980 | yield* Ref.set(configOptionsRef, configOptions); | ||
| 1981 | yield* Queue.offer(eventQueue, { | ||
| 1982 | _tag: "ConfigOptionsUpdated", | ||
| 1983 | configOptions, | ||
| 1984 | rawPayload: response, | ||
| 1985 | }); | ||
| 1986 | }); | ||
| 1987 | |||
| 1988 | const updateCurrentModeId = (modeId: string): Effect.Effect<void> => | ||
| 1989 | Ref.update(modeStateRef, (current) => | ||
| 1990 | current ? { ...current, currentModeId: modeId } : current, | ||
| 1991 | ); | ||
| 1992 | |||
| 1993 | const adoptSession = ( | ||
| 1994 | sessionId: string, | ||
| 1995 | sessionSetupResult: | ||
| 1996 | | EffectAcpSchema.ForkSessionResponse | ||
| 1997 | | EffectAcpSchema.LoadSessionResponse | ||
| 1998 | | EffectAcpSchema.NewSessionResponse | ||
| 1999 | | EffectAcpSchema.ResumeSessionResponse, | ||
| 2000 | ): Effect.Effect<AcpSessionRuntimeStartResult, EffectAcpErrors.AcpError> => | ||
| 2001 | Effect.gen(function* () { | ||
| 2002 | const current = yield* getStartedState; | ||
| 2003 | const meta = sessionSetupResult._meta; | ||
| 2004 | const syntheticReplayIdle = | ||
| 2005 | meta !== null && | ||
| 2006 | typeof meta === "object" && | ||
| 2007 | !Array.isArray(meta) && | ||
| 2008 | (meta as { readonly t3SessionLoadReady?: unknown }).t3SessionLoadReady === "replay_idle"; | ||
| 2009 | const extractedModelConfigId = extractModelConfigId(sessionSetupResult); | ||
| 2010 | const nextModelConfigId = | ||
| 2011 | extractedModelConfigId ?? (syntheticReplayIdle ? current.modelConfigId : undefined); | ||
| 2012 | const nextModeState = parseSessionModeState(sessionSetupResult); | ||
| 2013 | if (nextModeState !== undefined) { | ||
| 2014 | yield* Ref.set(modeStateRef, nextModeState); | ||
| 2015 | } else if (!syntheticReplayIdle) { | ||
| 2016 | yield* Ref.set(modeStateRef, undefined); | ||
| 2017 | } | ||
| 2018 | // Synthetic replay-idle load responses only carry initialize model/mode | ||
| 2019 | // meta; preserve live configOptions so setConfigOption still validates. | ||
| 2020 | if ( | ||
| 2021 | sessionSetupResult.configOptions !== undefined && | ||
| 2022 | sessionSetupResult.configOptions !== null | ||
| 2023 | ) { | ||
| 2024 | yield* Ref.set(configOptionsRef, sessionConfigOptionsFromSetup(sessionSetupResult)); | ||
| 2025 | } else if (!syntheticReplayIdle) { | ||
| 2026 | yield* Ref.set(configOptionsRef, sessionConfigOptionsFromSetup(sessionSetupResult)); | ||
| 2027 | } | ||
| 2028 | const nextState = { | ||
| 2029 | sessionId, | ||
| 2030 | initializeResult: current.initializeResult, | ||
| 2031 | sessionSetupResult, | ||
| 2032 | modelConfigId: nextModelConfigId, | ||
| 2033 | } satisfies AcpStartedState; | ||
| 2034 | yield* Ref.set(toolCallsRef, new Map()); | ||
| 2035 | yield* Ref.set(assistantSegmentRef, { nextSegmentIndex: 0 }); | ||
| 2036 | yield* Ref.set(startStateRef, { _tag: "Started", result: nextState }); | ||
| 2037 | return nextState; | ||
| 2038 | }); | ||
| 2039 | |||
| 2040 | const runLoadSessionWithReplayIdle = ( | ||
| 2041 | loadPayload: EffectAcpSchema.LoadSessionRequest, | ||
| 2042 | initializeResult: EffectAcpSchema.InitializeResponse, | ||
| 2043 | ): Effect.Effect<EffectAcpSchema.LoadSessionResponse, EffectAcpErrors.AcpError> => | ||
| 2044 | sessionLoadSemaphore.withPermits(1)( | ||
| 2045 | Effect.gen(function* () { | ||
| 2046 | const sessionLoadTimeout = Duration.fromInputUnsafe( | ||
| 2047 | options.sessionLoadTimeout ?? defaultSessionLoadTimeout, | ||
| 2048 | ); | ||
| 2049 | const sessionLoadReplayIdleGap = Duration.fromInputUnsafe( | ||
| 2050 | options.sessionLoadReplayIdleGap ?? defaultSessionLoadReplayIdleGap, | ||
| 2051 | ); | ||
| 2052 | |||
| 2053 | yield* Ref.set( | ||
| 2054 | sessionLoadGateRef, | ||
| 2055 | Option.some({ | ||
| 2056 | active: true, | ||
| 2057 | sessionId: loadPayload.sessionId, | ||
| 2058 | lastActivityAtMillis: undefined, | ||
| 2059 | idleGap: sessionLoadReplayIdleGap, | ||
| 2060 | initializeResult, | ||
| 2061 | }), | ||
| 2062 | ); | ||
| 2063 | |||
| 2064 | return yield* Effect.gen(function* () { | ||
| 2065 | yield* logRequest({ | ||
| 2066 | method: "session/load", | ||
| 2067 | payload: loadPayload, | ||
| 2068 | status: "started", | ||
| 2069 | }); | ||
| 2070 | |||
| 2071 | const idleFiber = yield* waitForSessionLoadReplayIdle({ | ||
| 2072 | gateRef: sessionLoadGateRef, | ||
| 2073 | }).pipe(Effect.forkIn(runtimeScope)); | ||
| 2074 | const loaded = yield* Effect.raceFirst( | ||
| 2075 | acp.agent.loadSession(loadPayload), | ||
| 2076 | Fiber.join(idleFiber), | ||
| 2077 | ).pipe( | ||
| 2078 | Effect.ensuring(Fiber.interrupt(idleFiber).pipe(Effect.ignore)), | ||
| 2079 | Effect.timeoutOption(sessionLoadTimeout), | ||
| 2080 | Effect.flatMap((result) => | ||
| 2081 | Option.match(result, { | ||
| 2082 | onNone: () => | ||
| 2083 | Effect.fail( | ||
| 2084 | new EffectAcpErrors.AcpTransportError({ | ||
| 2085 | operation: "call-rpc", | ||
| 2086 | method: "session/load", | ||
| 2087 | detail: | ||
| 2088 | "session/load timed out waiting for RPC response or replay idle gap", | ||
| 2089 | cause: undefined, | ||
| 2090 | }), | ||
| 2091 | ), | ||
| 2092 | onSome: Effect.succeed, | ||
| 2093 | }), | ||
| 2094 | ), | ||
| 2095 | Effect.tap((result) => | ||
| 2096 | logRequest({ | ||
| 2097 | method: "session/load", | ||
| 2098 | payload: loadPayload, | ||
| 2099 | status: "succeeded", | ||
| 2100 | result, | ||
| 2101 | }), | ||
| 2102 | ), | ||
| 2103 | Effect.onError((cause) => | ||
| 2104 | logRequest({ | ||
| 2105 | method: "session/load", | ||
| 2106 | payload: loadPayload, | ||
| 2107 | status: "failed", | ||
| 2108 | cause, | ||
| 2109 | }), | ||
| 2110 | ), | ||
| 2111 | ); | ||
| 2112 | |||
| 2113 | const gate = yield* Ref.get(sessionLoadGateRef); | ||
| 2114 | const candidateConfigOptions = Option.isSome(gate) | ||
| 2115 | ? gate.value.candidateConfigOptions | ||
| 2116 | : undefined; | ||
| 2117 | return loaded.configOptions == null && candidateConfigOptions !== undefined | ||
| 2118 | ? { ...loaded, configOptions: candidateConfigOptions } | ||
| 2119 | : loaded; | ||
| 2120 | }).pipe(Effect.ensuring(Ref.set(sessionLoadGateRef, Option.none()))); | ||
| 2121 | }), | ||
| 2122 | ); | ||
| 2123 | |||
| 2124 | const setConfigOption = ( | ||
| 2125 | configId: string, | ||
| 2126 | value: string | boolean, | ||
| 2127 | ): Effect.Effect<EffectAcpSchema.SetSessionConfigOptionResponse, EffectAcpErrors.AcpError> => | ||
| 2128 | validateConfigOptionValue(configId, value).pipe( | ||
| 2129 | Effect.flatMap(() => getStartedState), | ||
| 2130 | Effect.flatMap((started) => | ||
| 2131 | Ref.get(configOptionsRef).pipe( | ||
| 2132 | Effect.flatMap((configOptions) => { | ||
| 2133 | const existing = findSessionConfigOption(configOptions, configId); | ||
| 2134 | if (existing && configOptionCurrentValueMatches(existing, value)) { | ||
| 2135 | return Effect.succeed({ | ||
| 2136 | configOptions, | ||
| 2137 | } satisfies EffectAcpSchema.SetSessionConfigOptionResponse); | ||
| 2138 | } | ||
| 2139 | const requestPayload = | ||
| 2140 | typeof value === "boolean" | ||
| 2141 | ? ({ | ||
| 2142 | sessionId: started.sessionId, | ||
| 2143 | configId, | ||
| 2144 | type: "boolean", | ||
| 2145 | value, | ||
| 2146 | } satisfies EffectAcpSchema.SetSessionConfigOptionRequest) | ||
| 2147 | : ({ | ||
| 2148 | sessionId: started.sessionId, | ||
| 2149 | configId, | ||
| 2150 | value: String(value), | ||
| 2151 | } satisfies EffectAcpSchema.SetSessionConfigOptionRequest); | ||
| 2152 | return runLoggedRequest( | ||
| 2153 | "session/set_config_option", | ||
| 2154 | requestPayload, | ||
| 2155 | acp.agent.setSessionConfigOption(requestPayload), | ||
| 2156 | ).pipe(Effect.tap((response) => updateConfigOptions(response))); | ||
| 2157 | }), | ||
| 2158 | ), | ||
| 2159 | ), | ||
| 2160 | ); | ||
| 2161 | |||
| 2162 | const initializeOnce = Effect.gen(function* () { | ||
| 2163 | const initializePayload = { | ||
| 2164 | protocolVersion: 2, | ||
| 2165 | clientCapabilities: initializeClientCapabilities, | ||
| 2166 | clientInfo: options.clientInfo, | ||
| 2167 | ...(options.initializeMeta === undefined ? {} : { _meta: options.initializeMeta }), | ||
| 2168 | } satisfies EffectAcpSchema.InitializeRequest; | ||
| 2169 | |||
| 2170 | const initializeResult = yield* runLoggedRequest( | ||
| 2171 | "initialize", | ||
| 2172 | initializePayload, | ||
| 2173 | acp.agent.initialize(initializePayload), | ||
| 2174 | ); | ||
| 2175 | yield* options.onInitialized?.(initializeResult) ?? Effect.void; | ||
| 2176 | return initializeResult; | ||
| 2177 | }); | ||
| 2178 | |||
| 2179 | const initialize = Effect.gen(function* () { | ||
| 2180 | yield* ensureConnected; | ||
| 2181 | const deferred = yield* Deferred.make< | ||
| 2182 | EffectAcpSchema.InitializeResponse, | ||
| 2183 | EffectAcpErrors.AcpError | ||
| 2184 | >(); | ||
| 2185 | const effect = yield* Ref.modify(initializeStateRef, (state) => { | ||
| 2186 | switch (state._tag) { | ||
| 2187 | case "Started": | ||
| 2188 | return [Effect.succeed(state.result), state] as const; | ||
| 2189 | case "Starting": | ||
| 2190 | return [Deferred.await(state.deferred), state] as const; | ||
| 2191 | case "NotStarted": | ||
| 2192 | return [ | ||
| 2193 | initializeOnce.pipe( | ||
| 2194 | Effect.tap((result) => | ||
| 2195 | Ref.set(initializeStateRef, { _tag: "Started", result }).pipe( | ||
| 2196 | Effect.andThen(Deferred.succeed(deferred, result)), | ||
| 2197 | ), | ||
| 2198 | ), | ||
| 2199 | Effect.onError((cause) => | ||
| 2200 | Deferred.failCause(deferred, cause).pipe( | ||
| 2201 | Effect.andThen(Ref.set(initializeStateRef, { _tag: "NotStarted" })), | ||
| 2202 | ), | ||
| 2203 | ), | ||
| 2204 | ), | ||
| 2205 | { _tag: "Starting", deferred } satisfies AcpInitializeState, | ||
| 2206 | ] as const; | ||
| 2207 | } | ||
| 2208 | }); | ||
| 2209 | return yield* effect; | ||
| 2210 | }); | ||
| 2211 | |||
| 2212 | const sessionMcpServers = ( | ||
| 2213 | initializeResult: EffectAcpSchema.InitializeResponse, | ||
| 2214 | activationOptions?: AcpSessionActivationOptions, | ||
| 2215 | ): ReadonlyArray<EffectAcpSchema.McpServer> => { | ||
| 2216 | const acpServers = activationOptions?.acpMcpServers ?? options.acpMcpServers ?? []; | ||
| 2217 | if ( | ||
| 2218 | initializeResult.agentCapabilities?.mcpCapabilities?.acp === true && | ||
| 2219 | acpServers.length > 0 | ||
| 2220 | ) { | ||
| 2221 | return acpServers; | ||
| 2222 | } | ||
| 2223 | return activationOptions?.mcpServers ?? options.mcpServers ?? []; | ||
| 2224 | }; | ||
| 2225 | |||
| 2226 | const startOnce = Effect.gen(function* () { | ||
| 2227 | const initializeResult = yield* initialize; | ||
| 2228 | |||
| 2229 | const authenticateAfterRequired = ( | ||
| 2230 | authRequiredError: EffectAcpErrors.AcpError, | ||
| 2231 | ): Effect.Effect<void, EffectAcpErrors.AcpError> => | ||
| 2232 | Effect.gen(function* () { | ||
| 2233 | const configuredAuthMethodId = options.authMethodId?.trim(); | ||
| 2234 | const authMethod = selectAcpAgentAuthMethod( | ||
| 2235 | initializeResult.authMethods, | ||
| 2236 | configuredAuthMethodId, | ||
| 2237 | ); | ||
| 2238 | if ( | ||
| 2239 | configuredAuthMethodId && | ||
| 2240 | initializeResult.authMethods !== undefined && | ||
| 2241 | authMethod === undefined | ||
| 2242 | ) { | ||
| 2243 | return yield* new EffectAcpErrors.AcpTransportError({ | ||
| 2244 | detail: `ACP agent did not advertise configured authentication method "${configuredAuthMethodId}"`, | ||
| 2245 | cause: { configuredAuthMethodId, authMethods: initializeResult.authMethods }, | ||
| 2246 | }); | ||
| 2247 | } | ||
| 2248 | if ( | ||
| 2249 | authMethod !== undefined && | ||
| 2250 | authMethod.type !== undefined && | ||
| 2251 | authMethod.type !== "agent" | ||
| 2252 | ) { | ||
| 2253 | return yield* new EffectAcpErrors.AcpTransportError({ | ||
| 2254 | detail: `ACP authentication method "${authMethod.id}" requires ${authMethod.type} authentication, which cannot run inside a headless provider session`, | ||
| 2255 | cause: authMethod, | ||
| 2256 | }); | ||
| 2257 | } | ||
| 2258 | |||
| 2259 | const authMethodId = authMethod?.id ?? configuredAuthMethodId; | ||
| 2260 | if (!authMethodId) { | ||
| 2261 | return yield* authRequiredError; | ||
| 2262 | } | ||
| 2263 | |||
| 2264 | const authenticatePayload = { | ||
| 2265 | methodId: authMethodId, | ||
| 2266 | } satisfies EffectAcpSchema.AuthenticateRequest; | ||
| 2267 | yield* runLoggedRequest( | ||
| 2268 | "authenticate", | ||
| 2269 | authenticatePayload, | ||
| 2270 | acp.agent.authenticate(authenticatePayload), | ||
| 2271 | ); | ||
| 2272 | }); | ||
| 2273 | |||
| 2274 | const setupSession = Effect.gen(function* () { | ||
| 2275 | let sessionId: string; | ||
| 2276 | let sessionSetupResult: | ||
| 2277 | | EffectAcpSchema.LoadSessionResponse | ||
| 2278 | | EffectAcpSchema.NewSessionResponse | ||
| 2279 | | EffectAcpSchema.ResumeSessionResponse; | ||
| 2280 | const additionalDirectories = options.additionalDirectories?.length | ||
| 2281 | ? { additionalDirectories: options.additionalDirectories } | ||
| 2282 | : {}; | ||
| 2283 | if (options.resumeSessionId) { | ||
| 2284 | sessionId = options.resumeSessionId; | ||
| 2285 | if ( | ||
| 2286 | initializeResult.agentCapabilities?.loadSession === true && | ||
| 2287 | options.resumeMethod !== "resume" | ||
| 2288 | ) { | ||
| 2289 | const loadPayload = { | ||
| 2290 | sessionId, | ||
| 2291 | cwd: options.cwd, | ||
| 2292 | ...additionalDirectories, | ||
| 2293 | mcpServers: sessionMcpServers(initializeResult), | ||
| 2294 | } satisfies EffectAcpSchema.LoadSessionRequest; | ||
| 2295 | sessionSetupResult = yield* runLoadSessionWithReplayIdle(loadPayload, initializeResult); | ||
| 2296 | } else if (initializeResult.agentCapabilities?.sessionCapabilities?.resume != null) { | ||
| 2297 | const resumePayload = { | ||
| 2298 | sessionId, | ||
| 2299 | cwd: options.cwd, | ||
| 2300 | ...additionalDirectories, | ||
| 2301 | mcpServers: sessionMcpServers(initializeResult), | ||
| 2302 | } satisfies EffectAcpSchema.ResumeSessionRequest; | ||
| 2303 | sessionSetupResult = yield* runLoggedRequest( | ||
| 2304 | "session/resume", | ||
| 2305 | resumePayload, | ||
| 2306 | acp.agent.resumeSession(resumePayload), | ||
| 2307 | ); | ||
| 2308 | } else { | ||
| 2309 | return yield* new EffectAcpErrors.AcpRequestError({ | ||
| 2310 | code: -32601, | ||
| 2311 | errorMessage: "ACP agent does not advertise session/load or session/resume support", | ||
| 2312 | }); | ||
| 2313 | } | ||
| 2314 | } else { | ||
| 2315 | const createPayload = { | ||
| 2316 | cwd: options.cwd, | ||
| 2317 | mcpServers: sessionMcpServers(initializeResult), | ||
| 2318 | ...additionalDirectories, | ||
| 2319 | } satisfies EffectAcpSchema.NewSessionRequest; | ||
| 2320 | const created = yield* runLoggedRequest( | ||
| 2321 | "session/new", | ||
| 2322 | createPayload, | ||
| 2323 | acp.agent.createSession(createPayload), | ||
| 2324 | ); | ||
| 2325 | sessionId = created.sessionId; | ||
| 2326 | sessionSetupResult = created; | ||
| 2327 | } | ||
| 2328 | |||
| 2329 | return { sessionId, sessionSetupResult }; | ||
| 2330 | }); | ||
| 2331 | |||
| 2332 | if (options.authenticateEagerly === true) { | ||
| 2333 | // The configured method is authoritative here: Antigravity accepts | ||
| 2334 | // methods (API key, Vertex) it does not list in `initialize`. | ||
| 2335 | const authenticatePayload = { | ||
| 2336 | methodId: options.authMethodId ?? "", | ||
| 2337 | } satisfies EffectAcpSchema.AuthenticateRequest; | ||
| 2338 | yield* runLoggedRequest( | ||
| 2339 | "authenticate", | ||
| 2340 | authenticatePayload, | ||
| 2341 | acp.agent.authenticate(authenticatePayload), | ||
| 2342 | ); | ||
| 2343 | } | ||
| 2344 | const { sessionId, sessionSetupResult } = yield* setupSession.pipe( | ||
| 2345 | Effect.catch((error) => | ||
| 2346 | !isAcpAuthenticationRequired(error) || options.authenticateOnAuthRequired === false | ||
| 2347 | ? Effect.fail(error) | ||
| 2348 | : authenticateAfterRequired(error).pipe(Effect.andThen(setupSession)), | ||
| 2349 | ), | ||
| 2350 | ); | ||
| 2351 | |||
| 2352 | yield* Ref.set(modeStateRef, parseSessionModeState(sessionSetupResult)); | ||
| 2353 | yield* Ref.set(configOptionsRef, sessionConfigOptionsFromSetup(sessionSetupResult)); | ||
| 2354 | |||
| 2355 | const nextState = { | ||
| 2356 | sessionId, | ||
| 2357 | initializeResult, | ||
| 2358 | sessionSetupResult, | ||
| 2359 | modelConfigId: extractModelConfigId(sessionSetupResult), | ||
| 2360 | } satisfies AcpStartedState; | ||
| 2361 | return nextState; | ||
| 2362 | }); | ||
| 2363 | |||
| 2364 | const start = Effect.gen(function* () { | ||
| 2365 | yield* ensureConnected; | ||
| 2366 | const deferred = yield* Deferred.make< | ||
| 2367 | AcpSessionRuntimeStartResult, | ||
| 2368 | EffectAcpErrors.AcpError | ||
| 2369 | >(); | ||
| 2370 | const effect = yield* Ref.modify(startStateRef, (state) => { | ||
| 2371 | switch (state._tag) { | ||
| 2372 | case "Started": | ||
| 2373 | return [Effect.succeed(state.result), state] as const; | ||
| 2374 | case "Starting": | ||
| 2375 | return [Deferred.await(state.deferred), state] as const; | ||
| 2376 | case "NotStarted": | ||
| 2377 | return [ | ||
| 2378 | startOnce.pipe( | ||
| 2379 | Effect.tap((result) => | ||
| 2380 | notificationSemaphore.withPermit( | ||
| 2381 | Effect.gen(function* () { | ||
| 2382 | const error = yield* Ref.get(terminationErrorRef); | ||
| 2383 | if (Option.isSome(error)) { | ||
| 2384 | return yield* error.value; | ||
| 2385 | } | ||
| 2386 | yield* Ref.set(startStateRef, { _tag: "Started", result }); | ||
| 2387 | const metadata = yield* Ref.getAndSet(startupMetadataRef, []); | ||
| 2388 | for (const notification of metadata) { | ||
| 2389 | if (notification.sessionId === result.sessionId) { | ||
| 2390 | yield* processSessionUpdate(notification); | ||
| 2391 | } | ||
| 2392 | } | ||
| 2393 | yield* Deferred.succeed(deferred, result); | ||
| 2394 | }), | ||
| 2395 | ), | ||
| 2396 | ), | ||
| 2397 | Effect.onError((cause) => | ||
| 2398 | Deferred.failCause(deferred, cause).pipe( | ||
| 2399 | Effect.andThen(Ref.set(startStateRef, { _tag: "NotStarted" })), | ||
| 2400 | Effect.andThen(Ref.set(startupMetadataRef, [])), | ||
| 2401 | ), | ||
| 2402 | ), | ||
| 2403 | ), | ||
| 2404 | { _tag: "Starting", deferred } satisfies AcpStartState, | ||
| 2405 | ] as const; | ||
| 2406 | } | ||
| 2407 | }); | ||
| 2408 | return yield* effect; | ||
| 2409 | }); | ||
| 2410 | |||
| 2411 | const drainEvents = Effect.gen(function* () { | ||
| 2412 | if (yield* Ref.get(stoppingRef)) { | ||
| 2413 | return; | ||
| 2414 | } | ||
| 2415 | const acknowledge = yield* Deferred.make<void>(); | ||
| 2416 | yield* notificationSemaphore.withPermit( | ||
| 2417 | Effect.gen(function* () { | ||
| 2418 | // Keep a provider's final flushed chunks together until the adapter settles the turn. | ||
| 2419 | if (Option.isNone(yield* Ref.get(activePromptRef))) { | ||
| 2420 | yield* Ref.set(assistantUpdatesOpenRef, false); | ||
| 2421 | yield* closeActiveAssistantSegment({ queue: eventQueue, assistantSegmentRef }); | ||
| 2422 | } | ||
| 2423 | yield* Queue.offer(eventQueue, { _tag: "EventStreamBarrier", acknowledge }); | ||
| 2424 | }), | ||
| 2425 | ); | ||
| 2426 | yield* Effect.raceFirst(Deferred.await(acknowledge), Deferred.await(runtimeClosed)); | ||
| 2427 | }); | ||
| 2428 | |||
| 2429 | const retireRuntime = Effect.fn("AcpSessionRuntime.retireRuntime")(function* ( | ||
| 2430 | error: EffectAcpErrors.AcpError, | ||
| 2431 | ) { | ||
| 2432 | yield* recordTermination(error); | ||
| 2433 | yield* child.kill({ forceKillAfter: "1 second" }).pipe(Effect.ignore); | ||
| 2434 | }); | ||
| 2435 | |||
| 2436 | const cancel = Effect.gen(function* () { | ||
| 2437 | const started = yield* getStartedState; | ||
| 2438 | const activePrompt = yield* Ref.get(activePromptRef); | ||
| 2439 | const cancelPayload = { | ||
| 2440 | sessionId: started.sessionId, | ||
| 2441 | ...(options.cancelMeta === undefined ? {} : { _meta: options.cancelMeta }), | ||
| 2442 | } satisfies EffectAcpSchema.CancelNotification; | ||
| 2443 | if (options.cancelBehavior !== "wait-for-prompt") { | ||
| 2444 | if (options.interruptPromptOnCancel === false) { | ||
| 2445 | return yield* acp.agent.cancel(cancelPayload); | ||
| 2446 | } | ||
| 2447 | if (Option.isSome(activePrompt)) { | ||
| 2448 | yield* Fiber.interrupt(activePrompt.value.fiber).pipe(Effect.ignore); | ||
| 2449 | } | ||
| 2450 | // Write cancel before a replacement prompt can reach the agent. | ||
| 2451 | yield* acp.agent.cancel(cancelPayload).pipe(Effect.ignore); | ||
| 2452 | return; | ||
| 2453 | } | ||
| 2454 | |||
| 2455 | yield* acp.agent.cancel(cancelPayload); | ||
| 2456 | if (Option.isNone(activePrompt)) { | ||
| 2457 | return; | ||
| 2458 | } | ||
| 2459 | const completed = yield* Effect.gen(function* () { | ||
| 2460 | const result = yield* Fiber.await(activePrompt.value.fiber); | ||
| 2461 | yield* Deferred.await(activePrompt.value.completed); | ||
| 2462 | if (Option.isNone(yield* Ref.get(terminationErrorRef))) { | ||
| 2463 | yield* drainEvents; | ||
| 2464 | } | ||
| 2465 | return result; | ||
| 2466 | }).pipe(Effect.timeoutOption(options.cancelTimeout ?? defaultCancelTimeout)); | ||
| 2467 | if (Option.isNone(completed)) { | ||
| 2468 | const error = new EffectAcpErrors.AcpTransportError({ | ||
| 2469 | operation: "call-rpc", | ||
| 2470 | method: "session/cancel", | ||
| 2471 | detail: "The ACP agent did not finish cancellation. Its process was stopped.", | ||
| 2472 | cause: undefined, | ||
| 2473 | }); | ||
| 2474 | yield* retireRuntime(error); | ||
| 2475 | return yield* error; | ||
| 2476 | } | ||
| 2477 | if (Exit.isFailure(completed.value)) { | ||
| 2478 | return yield* Effect.failCause(completed.value.cause); | ||
| 2479 | } | ||
| 2480 | }); | ||
| 2481 | |||
| 2482 | return { | ||
| 2483 | processContainment: | ||
| 2484 | linuxCgroupLease !== undefined | ||
| 2485 | ? "cgroup-v2" | ||
| 2486 | : options.ownDescendantProcessGroups === true | ||
| 2487 | ? "process-ledger-reduced-guarantee" | ||
| 2488 | : options.ownDetachedProcessGroup === true | ||
| 2489 | ? "process-group" | ||
| 2490 | : "none", | ||
| 2491 | handleRequestPermission: acp.handleRequestPermission, | ||
| 2492 | handleElicitation: acp.handleElicitation, | ||
| 2493 | handleMcpConnect: acp.handleMcpConnect, | ||
| 2494 | handleMcpMessage: acp.handleMcpMessage, | ||
| 2495 | handleMcpDisconnect: acp.handleMcpDisconnect, | ||
| 2496 | handleMcpNotification: acp.handleMcpNotification, | ||
| 2497 | handleReadTextFile: acp.handleReadTextFile, | ||
| 2498 | handleWriteTextFile: acp.handleWriteTextFile, | ||
| 2499 | handleCreateTerminal: acp.handleCreateTerminal, | ||
| 2500 | handleTerminalOutput: acp.handleTerminalOutput, | ||
| 2501 | handleTerminalWaitForExit: acp.handleTerminalWaitForExit, | ||
| 2502 | handleTerminalKill: acp.handleTerminalKill, | ||
| 2503 | handleTerminalRelease: acp.handleTerminalRelease, | ||
| 2504 | handleSessionUpdate: acp.handleSessionUpdate, | ||
| 2505 | handleElicitationComplete: acp.handleElicitationComplete, | ||
| 2506 | handleUnknownExtRequest: acp.handleUnknownExtRequest, | ||
| 2507 | handleUnknownExtNotification: acp.handleUnknownExtNotification, | ||
| 2508 | handleExtRequest: acp.handleExtRequest, | ||
| 2509 | handleExtNotification: acp.handleExtNotification, | ||
| 2510 | initialize: () => initialize, | ||
| 2511 | authenticate: (methodId) => | ||
| 2512 | initialize.pipe( | ||
| 2513 | Effect.andThen( | ||
| 2514 | runLoggedRequest("authenticate", { methodId }, acp.agent.authenticate({ methodId })), | ||
| 2515 | ), | ||
| 2516 | Effect.asVoid, | ||
| 2517 | ), | ||
| 2518 | start: () => start, | ||
| 2519 | getEvents: () => Stream.fromQueue(eventQueue), | ||
| 2520 | drainEvents, | ||
| 2521 | getModeState: Ref.get(modeStateRef), | ||
| 2522 | getConfigOptions: Ref.get(configOptionsRef), | ||
| 2523 | loadSession: (sessionId, activationOptions) => | ||
| 2524 | start.pipe( | ||
| 2525 | Effect.flatMap((started) => { | ||
| 2526 | const requestPayload = { | ||
| 2527 | sessionId, | ||
| 2528 | cwd: options.cwd, | ||
| 2529 | mcpServers: sessionMcpServers(started.initializeResult, activationOptions), | ||
| 2530 | } satisfies EffectAcpSchema.LoadSessionRequest; | ||
| 2531 | return runLoadSessionWithReplayIdle(requestPayload, started.initializeResult); | ||
| 2532 | }), | ||
| 2533 | Effect.flatMap((response) => adoptSession(sessionId, response)), | ||
| 2534 | ), | ||
| 2535 | resumeSession: (sessionId, activationOptions) => | ||
| 2536 | start.pipe( | ||
| 2537 | Effect.flatMap((started) => { | ||
| 2538 | const requestPayload = { | ||
| 2539 | sessionId, | ||
| 2540 | cwd: options.cwd, | ||
| 2541 | mcpServers: sessionMcpServers(started.initializeResult, activationOptions), | ||
| 2542 | } satisfies EffectAcpSchema.ResumeSessionRequest; | ||
| 2543 | return runLoggedRequest( | ||
| 2544 | "session/resume", | ||
| 2545 | requestPayload, | ||
| 2546 | acp.agent.resumeSession(requestPayload), | ||
| 2547 | ); | ||
| 2548 | }), | ||
| 2549 | Effect.flatMap((response) => adoptSession(sessionId, response)), | ||
| 2550 | ), | ||
| 2551 | forkSession: (sessionId, activationOptions) => | ||
| 2552 | start.pipe( | ||
| 2553 | Effect.flatMap((started) => { | ||
| 2554 | const requestPayload = { | ||
| 2555 | sessionId, | ||
| 2556 | cwd: options.cwd, | ||
| 2557 | mcpServers: sessionMcpServers(started.initializeResult, activationOptions), | ||
| 2558 | } satisfies EffectAcpSchema.ForkSessionRequest; | ||
| 2559 | return runLoggedRequest( | ||
| 2560 | "session/fork", | ||
| 2561 | requestPayload, | ||
| 2562 | acp.agent.forkSession(requestPayload), | ||
| 2563 | ); | ||
| 2564 | }), | ||
| 2565 | Effect.flatMap((response) => adoptSession(response.sessionId, response)), | ||
| 2566 | ), | ||
| 2567 | listSessions: (cursor) => { | ||
| 2568 | const requestPayload = { | ||
| 2569 | cwd: options.cwd, | ||
| 2570 | ...(cursor === undefined ? {} : { cursor }), | ||
| 2571 | } satisfies EffectAcpSchema.ListSessionsRequest; | ||
| 2572 | return initialize.pipe( | ||
| 2573 | Effect.filterOrFail( | ||
| 2574 | (initialized) => initialized.agentCapabilities?.sessionCapabilities?.list != null, | ||
| 2575 | () => | ||
| 2576 | new EffectAcpErrors.AcpRequestError({ | ||
| 2577 | code: -32601, | ||
| 2578 | errorMessage: "ACP agent does not advertise session/list support", | ||
| 2579 | }), | ||
| 2580 | ), | ||
| 2581 | Effect.andThen( | ||
| 2582 | runLoggedRequest( | ||
| 2583 | "session/list", | ||
| 2584 | requestPayload, | ||
| 2585 | acp.agent.listSessions(requestPayload), | ||
| 2586 | ), | ||
| 2587 | ), | ||
| 2588 | ); | ||
| 2589 | }, | ||
| 2590 | closeSession: (sessionId) => | ||
| 2591 | start.pipe( | ||
| 2592 | Effect.flatMap((started) => { | ||
| 2593 | const requestPayload = { | ||
| 2594 | sessionId: sessionId ?? started.sessionId, | ||
| 2595 | } satisfies EffectAcpSchema.CloseSessionRequest; | ||
| 2596 | return runLoggedRequest( | ||
| 2597 | "session/close", | ||
| 2598 | requestPayload, | ||
| 2599 | acp.agent.closeSession(requestPayload), | ||
| 2600 | ); | ||
| 2601 | }), | ||
| 2602 | ), | ||
| 2603 | deleteSession: (sessionId) => | ||
| 2604 | initialize.pipe( | ||
| 2605 | Effect.filterOrFail( | ||
| 2606 | (initialized) => initialized.agentCapabilities?.sessionCapabilities?.delete != null, | ||
| 2607 | () => | ||
| 2608 | new EffectAcpErrors.AcpRequestError({ | ||
| 2609 | code: -32601, | ||
| 2610 | errorMessage: "ACP agent does not advertise session/delete support", | ||
| 2611 | }), | ||
| 2612 | ), | ||
| 2613 | Effect.andThen( | ||
| 2614 | runLoggedRequest( | ||
| 2615 | "session/delete", | ||
| 2616 | { sessionId }, | ||
| 2617 | acp.agent.deleteSession({ sessionId }), | ||
| 2618 | ), | ||
| 2619 | ), | ||
| 2620 | ), | ||
| 2621 | listProviders: initialize.pipe( | ||
| 2622 | Effect.filterOrFail( | ||
| 2623 | (initialized) => initialized.agentCapabilities?.providers != null, | ||
| 2624 | () => | ||
| 2625 | new EffectAcpErrors.AcpRequestError({ | ||
| 2626 | code: -32601, | ||
| 2627 | errorMessage: "ACP agent does not advertise provider configuration support", | ||
| 2628 | }), | ||
| 2629 | ), | ||
| 2630 | Effect.andThen(runLoggedRequest("providers/list", {}, acp.agent.listProviders({}))), | ||
| 2631 | ), | ||
| 2632 | setProvider: (provider) => | ||
| 2633 | initialize.pipe( | ||
| 2634 | Effect.filterOrFail( | ||
| 2635 | (initialized) => initialized.agentCapabilities?.providers != null, | ||
| 2636 | () => | ||
| 2637 | new EffectAcpErrors.AcpRequestError({ | ||
| 2638 | code: -32601, | ||
| 2639 | errorMessage: "ACP agent does not advertise provider configuration support", | ||
| 2640 | }), | ||
| 2641 | ), | ||
| 2642 | Effect.andThen( | ||
| 2643 | runLoggedRequest( | ||
| 2644 | "providers/set", | ||
| 2645 | { | ||
| 2646 | ...provider, | ||
| 2647 | ...(provider.headers === undefined | ||
| 2648 | ? {} | ||
| 2649 | : { | ||
| 2650 | headers: Object.fromEntries( | ||
| 2651 | Object.keys(provider.headers).map((name) => [name, "[redacted]"]), | ||
| 2652 | ), | ||
| 2653 | }), | ||
| 2654 | }, | ||
| 2655 | acp.agent.setProvider(provider), | ||
| 2656 | ), | ||
| 2657 | ), | ||
| 2658 | ), | ||
| 2659 | disableProvider: (providerId) => { | ||
| 2660 | const request = { providerId } satisfies EffectAcpSchema.DisableProviderRequest; | ||
| 2661 | return initialize.pipe( | ||
| 2662 | Effect.filterOrFail( | ||
| 2663 | (initialized) => initialized.agentCapabilities?.providers != null, | ||
| 2664 | () => | ||
| 2665 | new EffectAcpErrors.AcpRequestError({ | ||
| 2666 | code: -32601, | ||
| 2667 | errorMessage: "ACP agent does not advertise provider configuration support", | ||
| 2668 | }), | ||
| 2669 | ), | ||
| 2670 | Effect.andThen( | ||
| 2671 | runLoggedRequest("providers/disable", request, acp.agent.disableProvider(request)), | ||
| 2672 | ), | ||
| 2673 | ); | ||
| 2674 | }, | ||
| 2675 | logout: initialize.pipe( | ||
| 2676 | Effect.filterOrFail( | ||
| 2677 | (initialized) => initialized.agentCapabilities?.auth?.logout != null, | ||
| 2678 | () => | ||
| 2679 | new EffectAcpErrors.AcpRequestError({ | ||
| 2680 | code: -32601, | ||
| 2681 | errorMessage: "ACP agent does not advertise logout support", | ||
| 2682 | }), | ||
| 2683 | ), | ||
| 2684 | Effect.andThen(runLoggedRequest("logout", {}, acp.agent.logout({}))), | ||
| 2685 | ), | ||
| 2686 | prompt: (payload, promptOptions?) => | ||
| 2687 | promptSerializationSemaphore.withPermit( | ||
| 2688 | Effect.acquireUseRelease( | ||
| 2689 | promptDispatchSemaphore.withPermit( | ||
| 2690 | Effect.gen(function* () { | ||
| 2691 | const started = yield* getStartedState; | ||
| 2692 | yield* closeActiveAssistantSegment({ queue: eventQueue, assistantSegmentRef }); | ||
| 2693 | yield* Ref.set(assistantUpdatesOpenRef, true); | ||
| 2694 | const requestPayload = { | ||
| 2695 | sessionId: started.sessionId, | ||
| 2696 | ...payload, | ||
| 2697 | } satisfies EffectAcpSchema.PromptRequest; | ||
| 2698 | const completed = yield* Deferred.make<void>(); | ||
| 2699 | const fiber = yield* runLoggedRequest( | ||
| 2700 | "session/prompt", | ||
| 2701 | requestPayload, | ||
| 2702 | acp.agent.prompt(requestPayload), | ||
| 2703 | ).pipe(Effect.forkIn(runtimeScope)); | ||
| 2704 | const active = { fiber, completed } satisfies AcpActivePrompt; | ||
| 2705 | yield* Ref.set(activePromptRef, Option.some(active)); | ||
| 2706 | if (promptOptions?.dispatched) { | ||
| 2707 | yield* Deferred.succeed(promptOptions.dispatched, undefined); | ||
| 2708 | } | ||
| 2709 | return active; | ||
| 2710 | }).pipe(notificationSemaphore.withPermit), | ||
| 2711 | ), | ||
| 2712 | (activePrompt) => | ||
| 2713 | Fiber.join(activePrompt.fiber).pipe( | ||
| 2714 | Effect.catchCause((cause) => | ||
| 2715 | options.cancelBehavior !== "wait-for-prompt" && Cause.hasInterruptsOnly(cause) | ||
| 2716 | ? Effect.succeed({ | ||
| 2717 | stopReason: "cancelled", | ||
| 2718 | } satisfies EffectAcpSchema.PromptResponse) | ||
| 2719 | : Effect.failCause(cause), | ||
| 2720 | ), | ||
| 2721 | Effect.tap(() => | ||
| 2722 | closeActiveAssistantSegment({ queue: eventQueue, assistantSegmentRef }), | ||
| 2723 | ), | ||
| 2724 | ), | ||
| 2725 | (activePrompt, result) => | ||
| 2726 | Effect.gen(function* () { | ||
| 2727 | if ( | ||
| 2728 | options.cancelBehavior === "wait-for-prompt" && | ||
| 2729 | Exit.isFailure(result) && | ||
| 2730 | Cause.hasInterrupts(result.cause) | ||
| 2731 | ) { | ||
| 2732 | yield* retireRuntime( | ||
| 2733 | new EffectAcpErrors.AcpTransportError({ | ||
| 2734 | method: "session/prompt", | ||
| 2735 | detail: "The ACP prompt stopped before the agent confirmed completion.", | ||
| 2736 | cause: undefined, | ||
| 2737 | }), | ||
| 2738 | ); | ||
| 2739 | } | ||
| 2740 | yield* Fiber.interrupt(activePrompt.fiber).pipe(Effect.ignore); | ||
| 2741 | yield* Ref.set(activePromptRef, Option.none()); | ||
| 2742 | yield* Deferred.succeed(activePrompt.completed, undefined); | ||
| 2743 | }), | ||
| 2744 | ), | ||
| 2745 | ), | ||
| 2746 | cancel: | ||
| 2747 | options.cancelBehavior === "wait-for-prompt" | ||
| 2748 | ? promptDispatchSemaphore.withPermit(cancel) | ||
| 2749 | : cancel, | ||
| 2750 | ...(options.ownDetachedProcessGroup === true ? { terminateProcessGroup } : {}), | ||
| 2751 | // A session's mode is its `category: "mode"` config option. ACP v1 agents | ||
| 2752 | // that only advertise `modes` (gemini-cli) take `session/set_mode` | ||
| 2753 | // instead, which ACP v2 removed. | ||
| 2754 | setMode: (modeId) => | ||
| 2755 | Effect.gen(function* () { | ||
| 2756 | const modeState = yield* Ref.get(modeStateRef); | ||
| 2757 | if (modeState?.currentModeId === modeId) { | ||
| 2758 | return {} satisfies EffectAcpSchema.SetSessionModeResponse; | ||
| 2759 | } | ||
| 2760 | const modeConfigOption = (yield* Ref.get(configOptionsRef))?.find( | ||
| 2761 | (option) => option.category === "mode" && option.type === "select", | ||
| 2762 | ); | ||
| 2763 | if (modeConfigOption === undefined && modeState !== undefined) { | ||
| 2764 | const started = yield* getStartedState; | ||
| 2765 | const payload = { sessionId: started.sessionId, modeId }; | ||
| 2766 | yield* runLoggedRequest("session/set_mode", payload, acp.agent.setSessionMode(payload)); | ||
| 2767 | yield* updateCurrentModeId(modeId); | ||
| 2768 | return {} satisfies EffectAcpSchema.SetSessionModeResponse; | ||
| 2769 | } | ||
| 2770 | const response = yield* setConfigOption(modeConfigOption?.id ?? "mode", modeId); | ||
| 2771 | // The agent answers with its config options, so the mode it reports | ||
| 2772 | // is the mode it runs in, even when it kept another one. | ||
| 2773 | const reported = parseSessionModeState({ configOptions: response.configOptions }); | ||
| 2774 | if (reported === undefined) { | ||
| 2775 | yield* updateCurrentModeId(modeId); | ||
| 2776 | } else { | ||
| 2777 | yield* updateCurrentModeId(reported.currentModeId); | ||
| 2778 | } | ||
| 2779 | return {} satisfies EffectAcpSchema.SetSessionModeResponse; | ||
| 2780 | }), | ||
| 2781 | setSessionModel: (modelId, meta) => | ||
| 2782 | getStartedState.pipe( | ||
| 2783 | Effect.flatMap((started) => { | ||
| 2784 | const payload = { | ||
| 2785 | sessionId: started.sessionId, | ||
| 2786 | modelId, | ||
| 2787 | ...(meta === undefined ? {} : { _meta: meta }), | ||
| 2788 | }; | ||
| 2789 | return runLoggedRequest( | ||
| 2790 | "session/set_model", | ||
| 2791 | payload, | ||
| 2792 | acp.agent.setSessionModel(payload), | ||
| 2793 | ); | ||
| 2794 | }), | ||
| 2795 | ), | ||
| 2796 | setConfigOption, | ||
| 2797 | setModel: (model) => | ||
| 2798 | getStartedState.pipe( | ||
| 2799 | Effect.flatMap((started) => setConfigOption(started.modelConfigId ?? "model", model)), | ||
| 2800 | Effect.asVoid, | ||
| 2801 | ), | ||
| 2802 | |||
| 2803 | request: (method, payload) => | ||
| 2804 | ensureConnected.pipe( | ||
| 2805 | Effect.andThen(runLoggedRequest(method, payload, acp.raw.request(method, payload))), | ||
| 2806 | ), | ||
| 2807 | notify: (method, payload) => | ||
| 2808 | ensureConnected.pipe(Effect.andThen(acp.raw.notify(method, payload))), | ||
| 2809 | } satisfies AcpSessionRuntime["Service"]; | ||
| 2810 | }); | ||
| 2811 | |||
| 2812 | export const layer = ( | ||
| 2813 | options: AcpSessionRuntimeOptions, | ||
| 2814 | ): Layer.Layer< | ||
| 2815 | AcpSessionRuntime, | ||
| 2816 | EffectAcpErrors.AcpError, | ||
| 2817 | ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | ||
| 2818 | > => Layer.effect(AcpSessionRuntime, make(options)); | ||
| 2819 | |||
| 2820 | function sessionConfigOptionsFromSetup( | ||
| 2821 | response: | ||
| 2822 | | { | ||
| 2823 | readonly configOptions?: ReadonlyArray<EffectAcpSchema.SessionConfigOption> | null; | ||
| 2824 | } | ||
| 2825 | | undefined, | ||
| 2826 | ): ReadonlyArray<EffectAcpSchema.SessionConfigOption> { | ||
| 2827 | return response?.configOptions ?? []; | ||
| 2828 | } | ||
| 2829 | |||
| 2830 | function configOptionCurrentValueMatches( | ||
| 2831 | configOption: EffectAcpSchema.SessionConfigOption, | ||
| 2832 | value: string | boolean, | ||
| 2833 | ): boolean { | ||
| 2834 | const currentValue = configOption.currentValue; | ||
| 2835 | if (configOption.type === "boolean") { | ||
| 2836 | return currentValue === value; | ||
| 2837 | } | ||
| 2838 | if (typeof currentValue !== "string") { | ||
| 2839 | return false; | ||
| 2840 | } | ||
| 2841 | return currentValue.trim() === String(value).trim(); | ||
| 2842 | } | ||
| 2843 | |||
| 2844 | function isStartupMetadataUpdate(notification: EffectAcpSchema.SessionNotification): boolean { | ||
| 2845 | switch (notification.update.sessionUpdate) { | ||
| 2846 | case "current_mode_update": | ||
| 2847 | case "config_option_update": | ||
| 2848 | case "available_commands_update": | ||
| 2849 | return true; | ||
| 2850 | default: | ||
| 2851 | return false; | ||
| 2852 | } | ||
| 2853 | } | ||
| 2854 | |||
| 2855 | const handleSessionUpdate = ({ | ||
| 2856 | queue, | ||
| 2857 | modeStateRef, | ||
| 2858 | configOptionsRef, | ||
| 2859 | toolCallsRef, | ||
| 2860 | shownToolCallIds, | ||
| 2861 | assistantSegmentRef, | ||
| 2862 | assistantItemRuntimeId, | ||
| 2863 | params, | ||
| 2864 | }: { | ||
| 2865 | readonly queue: Queue.Queue<AcpSessionRuntimeEvent>; | ||
| 2866 | readonly modeStateRef: Ref.Ref<AcpSessionModeState | undefined>; | ||
| 2867 | readonly configOptionsRef: Ref.Ref<ReadonlyArray<EffectAcpSchema.SessionConfigOption>>; | ||
| 2868 | readonly toolCallsRef: Ref.Ref<Map<string, AcpToolCallTrackedState>>; | ||
| 2869 | readonly shownToolCallIds: Set<string>; | ||
| 2870 | readonly assistantSegmentRef: Ref.Ref<AcpAssistantSegmentState>; | ||
| 2871 | readonly assistantItemRuntimeId: string; | ||
| 2872 | readonly params: EffectAcpSchema.SessionNotification; | ||
| 2873 | }): Effect.Effect<void> => | ||
| 2874 | Effect.gen(function* () { | ||
| 2875 | if (params.update.sessionUpdate === "config_option_update") { | ||
| 2876 | const configOptions = params.update.configOptions; | ||
| 2877 | yield* Ref.set(configOptionsRef, configOptions); | ||
| 2878 | yield* Ref.update( | ||
| 2879 | modeStateRef, | ||
| 2880 | (current) => parseSessionModeState({ configOptions }) ?? current, | ||
| 2881 | ); | ||
| 2882 | } | ||
| 2883 | const parsed = parseSessionUpdateEvent(params); | ||
| 2884 | if (parsed.modeId) { | ||
| 2885 | yield* Ref.update(modeStateRef, (current) => | ||
| 2886 | current === undefined ? current : updateModeState(current, parsed.modeId!), | ||
| 2887 | ); | ||
| 2888 | } | ||
| 2889 | for (const event of parsed.events) { | ||
| 2890 | if (event._tag === "ToolCallUpdated") { | ||
| 2891 | const { merged, decision, active } = yield* Ref.modify(toolCallsRef, (current) => { | ||
| 2892 | const tracked = current.get(event.toolCall.toolCallId); | ||
| 2893 | const previous = tracked?.state; | ||
| 2894 | const nextToolCall = mergeToolCallState(previous, event.toolCall); | ||
| 2895 | const decision = decideToolCallUpdateEmission({ | ||
| 2896 | previous, | ||
| 2897 | next: nextToolCall, | ||
| 2898 | lastEmittedDetailLength: tracked?.lastEmittedDetailLength, | ||
| 2899 | skippedSinceEmit: tracked?.skippedSinceEmit ?? 0, | ||
| 2900 | }); | ||
| 2901 | const next = new Map(current); | ||
| 2902 | if (nextToolCall.status === "completed" || nextToolCall.status === "failed") { | ||
| 2903 | next.delete(nextToolCall.toolCallId); | ||
| 2904 | } else { | ||
| 2905 | next.set(nextToolCall.toolCallId, { | ||
| 2906 | state: nextToolCall, | ||
| 2907 | lastEmittedDetailLength: decision.emit | ||
| 2908 | ? toolCallProgressLength(nextToolCall) | ||
| 2909 | : tracked?.lastEmittedDetailLength, | ||
| 2910 | skippedSinceEmit: decision.skippedSinceEmit, | ||
| 2911 | }); | ||
| 2912 | } | ||
| 2913 | return [{ merged: nextToolCall, decision, active: tracked !== undefined }, next] as const; | ||
| 2914 | }); | ||
| 2915 | if (!decision.emit) { | ||
| 2916 | continue; | ||
| 2917 | } | ||
| 2918 | // A new tool call is a boundary in the prose. Progress on a call that | ||
| 2919 | // is already shown, such as a background command finishing, is not. | ||
| 2920 | if (!shownToolCallIds.has(merged.toolCallId)) { | ||
| 2921 | shownToolCallIds.add(merged.toolCallId); | ||
| 2922 | // Only recent calls get late updates; keep a long session bounded. | ||
| 2923 | if (shownToolCallIds.size > MAX_SHOWN_TOOL_CALL_IDS) { | ||
| 2924 | shownToolCallIds.delete(shownToolCallIds.values().next().value!); | ||
| 2925 | } | ||
| 2926 | // A call still running is already on screen, even if it aged out. | ||
| 2927 | if (!active) yield* closeActiveAssistantSegment({ queue, assistantSegmentRef }); | ||
| 2928 | } | ||
| 2929 | yield* Queue.offer(queue, { | ||
| 2930 | _tag: "ToolCallUpdated", | ||
| 2931 | toolCall: merged, | ||
| 2932 | rawPayload: event.rawPayload, | ||
| 2933 | }); | ||
| 2934 | continue; | ||
| 2935 | } | ||
| 2936 | if (event._tag === "ContentDelta") { | ||
| 2937 | if (event.text.trim().length === 0) { | ||
| 2938 | const assistantSegmentState = yield* Ref.get(assistantSegmentRef); | ||
| 2939 | if (!assistantSegmentState.activeItemId) { | ||
| 2940 | continue; | ||
| 2941 | } | ||
| 2942 | } | ||
| 2943 | const itemId = yield* ensureActiveAssistantSegment({ | ||
| 2944 | queue, | ||
| 2945 | assistantSegmentRef, | ||
| 2946 | sessionId: params.sessionId, | ||
| 2947 | assistantItemRuntimeId, | ||
| 2948 | }); | ||
| 2949 | yield* Queue.offer(queue, { | ||
| 2950 | ...event, | ||
| 2951 | itemId, | ||
| 2952 | }); | ||
| 2953 | continue; | ||
| 2954 | } | ||
| 2955 | yield* Queue.offer(queue, event); | ||
| 2956 | } | ||
| 2957 | }); | ||
| 2958 | |||
| 2959 | function updateModeState(modeState: AcpSessionModeState, nextModeId: string): AcpSessionModeState { | ||
| 2960 | const normalized = nextModeId.trim(); | ||
| 2961 | if (!normalized) { | ||
| 2962 | return modeState; | ||
| 2963 | } | ||
| 2964 | return modeState.availableModes.some((mode) => mode.id === normalized) | ||
| 2965 | ? { | ||
| 2966 | ...modeState, | ||
| 2967 | currentModeId: normalized, | ||
| 2968 | } | ||
| 2969 | : modeState; | ||
| 2970 | } | ||
| 2971 | |||
| 2972 | const assistantItemId = (sessionId: string, runtimeId: string, segmentIndex: number) => | ||
| 2973 | `assistant:${sessionId}:runtime:${runtimeId}:segment:${segmentIndex}`; | ||
| 2974 | |||
| 2975 | const ensureActiveAssistantSegment = ({ | ||
| 2976 | queue, | ||
| 2977 | assistantSegmentRef, | ||
| 2978 | sessionId, | ||
| 2979 | assistantItemRuntimeId, | ||
| 2980 | }: { | ||
| 2981 | readonly queue: Queue.Queue<AcpSessionRuntimeEvent>; | ||
| 2982 | readonly assistantSegmentRef: Ref.Ref<AcpAssistantSegmentState>; | ||
| 2983 | readonly sessionId: string; | ||
| 2984 | readonly assistantItemRuntimeId: string; | ||
| 2985 | }) => | ||
| 2986 | Ref.modify<AcpAssistantSegmentState, EnsureActiveAssistantSegmentResult>( | ||
| 2987 | assistantSegmentRef, | ||
| 2988 | (current) => { | ||
| 2989 | if (current.activeItemId) { | ||
| 2990 | return [{ itemId: current.activeItemId }, current] as const; | ||
| 2991 | } | ||
| 2992 | const itemId = assistantItemId(sessionId, assistantItemRuntimeId, current.nextSegmentIndex); | ||
| 2993 | return [ | ||
| 2994 | { | ||
| 2995 | itemId, | ||
| 2996 | startedEvent: { | ||
| 2997 | _tag: "AssistantItemStarted", | ||
| 2998 | itemId, | ||
| 2999 | } satisfies Extract<AcpParsedSessionEvent, { readonly _tag: "AssistantItemStarted" }>, | ||
| 3000 | }, | ||
| 3001 | { | ||
| 3002 | nextSegmentIndex: current.nextSegmentIndex + 1, | ||
| 3003 | activeItemId: itemId, | ||
| 3004 | } satisfies AcpAssistantSegmentState, | ||
| 3005 | ] as const; | ||
| 3006 | }, | ||
| 3007 | ).pipe( | ||
| 3008 | Effect.flatMap((result) => | ||
| 3009 | result.startedEvent | ||
| 3010 | ? Queue.offer(queue, result.startedEvent).pipe(Effect.as(result.itemId)) | ||
| 3011 | : Effect.succeed(result.itemId), | ||
| 3012 | ), | ||
| 3013 | ); | ||
| 3014 | |||
| 3015 | const closeActiveAssistantSegment = ({ | ||
| 3016 | queue, | ||
| 3017 | assistantSegmentRef, | ||
| 3018 | }: { | ||
| 3019 | readonly queue: Queue.Queue<AcpSessionRuntimeEvent>; | ||
| 3020 | readonly assistantSegmentRef: Ref.Ref<AcpAssistantSegmentState>; | ||
| 3021 | }) => | ||
| 3022 | Ref.modify(assistantSegmentRef, (current) => { | ||
| 3023 | if (!current.activeItemId) { | ||
| 3024 | return [undefined, current] as const; | ||
| 3025 | } | ||
| 3026 | return [ | ||
| 3027 | { | ||
| 3028 | _tag: "AssistantItemCompleted", | ||
| 3029 | itemId: current.activeItemId, | ||
| 3030 | } satisfies AcpParsedSessionEvent, | ||
| 3031 | { | ||
| 3032 | nextSegmentIndex: current.nextSegmentIndex, | ||
| 3033 | } satisfies AcpAssistantSegmentState, | ||
| 3034 | ] as const; | ||
| 3035 | }).pipe(Effect.flatMap((event) => (event ? Queue.offer(queue, event) : Effect.void))); | ||
apps/server/src/provider/acp/AcpStderr.test.ts deleted-56| ... | @@ -1,56 +0,0 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | ACP_STDERR_TAIL_MAX_CHARS, | ||
| 5 | appendAcpStderrTail, | ||
| 6 | sanitizeAcpStderrExcerpt, | ||
| 7 | } from "./AcpStderr.ts"; | ||
| 8 | |||
| 9 | describe("AcpStderr", () => { | ||
| 10 | it("keeps a bounded tail of stderr chunks", () => { | ||
| 11 | const prefix = "x".repeat(ACP_STDERR_TAIL_MAX_CHARS); | ||
| 12 | expect(appendAcpStderrTail(prefix, "abc")).toBe(`${prefix.slice(3)}abc`); | ||
| 13 | }); | ||
| 14 | |||
| 15 | it("redacts home paths, pairing URLs, and tokens from stderr excerpts", () => { | ||
| 16 | const excerpt = sanitizeAcpStderrExcerpt( | ||
| 17 | [ | ||
| 18 | "Invalid project config at /Users/ada/.cursor/cli.json", | ||
| 19 | "Authorization: Bearer secret-token-value", | ||
| 20 | "Visit http://localhost:5733/pair#token=ABCDEF for pairing", | ||
| 21 | "key=sk-abcdefghijklmnopqrstuv", | ||
| 22 | ].join("\n"), | ||
| 23 | { HOME: "/Users/ada" }, | ||
| 24 | ); | ||
| 25 | |||
| 26 | expect(excerpt).toContain("Invalid project config at ~/.cursor/cli.json"); | ||
| 27 | expect(excerpt).toContain("Bearer [redacted]"); | ||
| 28 | expect(excerpt).toContain("[pairing-url]"); | ||
| 29 | expect(excerpt).toContain("[redacted]"); | ||
| 30 | expect(excerpt).not.toContain("secret-token-value"); | ||
| 31 | expect(excerpt).not.toContain("ABCDEF"); | ||
| 32 | expect(excerpt).not.toContain("sk-abcdefghijklmnopqrstuv"); | ||
| 33 | }); | ||
| 34 | |||
| 35 | it("redacts hyphenated OpenAI project keys and header credentials", () => { | ||
| 36 | const excerpt = sanitizeAcpStderrExcerpt( | ||
| 37 | [ | ||
| 38 | "openai=sk-proj-abcdefghijklmnopqrstuvwxyz012345", | ||
| 39 | "svc=sk-svcacct-abcdefghijklmnopqrstuvwxyz012345", | ||
| 40 | "anthropic=sk-ant-api03-abcdefghijklmnopqrstuvwxyz012345", | ||
| 41 | "Authorization: Basic dXNlcjpwYXNz", | ||
| 42 | "x-api-key: ant-api-key-value", | ||
| 43 | ].join("\n"), | ||
| 44 | ); | ||
| 45 | |||
| 46 | expect(excerpt).toContain("[redacted]"); | ||
| 47 | expect(excerpt).toContain("Authorization: Basic [redacted]"); | ||
| 48 | expect(excerpt).toContain("x-api-key: [redacted]"); | ||
| 49 | expect(excerpt).not.toContain("sk-proj-"); | ||
| 50 | expect(excerpt).not.toContain("sk-svcacct-"); | ||
| 51 | expect(excerpt).not.toContain("sk-ant-api03-"); | ||
| 52 | expect(excerpt).not.toContain("abcdefghijklmnopqrstuvwxyz012345"); | ||
| 53 | expect(excerpt).not.toContain("dXNlcjpwYXNz"); | ||
| 54 | expect(excerpt).not.toContain("ant-api-key-value"); | ||
| 55 | }); | ||
| 56 | }); | ||
apps/server/src/provider/acp/AcpStderr.ts deleted-38| ... | @@ -1,38 +0,0 @@ | ||
| 1 | // @effect-diagnostics nodeBuiltinImport:off -- The excerpt sanitizer masks the home directory, which only the Node os module can resolve. | ||
| 2 | import * as NodeOS from "node:os"; | ||
| 3 | |||
| 4 | /** Last few KiB of ACP child stderr kept for startup / exit diagnostics. */ | ||
| 5 | export const ACP_STDERR_TAIL_MAX_CHARS = 4_096; | ||
| 6 | |||
| 7 | const PAIRING_URL_PATTERN = /https?:\/\/[^\s]*\/pair#[^\s]*/gi; | ||
| 8 | const BEARER_TOKEN_PATTERN = /\bBearer\s+[A-Za-z0-9._\-+=/]+/gi; | ||
| 9 | const BASIC_AUTH_PATTERN = /\bAuthorization:\s*Basic\s+\S+/gi; | ||
| 10 | const API_KEY_HEADER_PATTERN = /\bx-api-key:\s*\S+/gi; | ||
| 11 | const SECRET_TOKEN_PATTERN = | ||
| 12 | /\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; | ||
| 13 | |||
| 14 | export function appendAcpStderrTail(current: string, chunk: string): string { | ||
| 15 | const next = `${current}${chunk}`; | ||
| 16 | return next.length <= ACP_STDERR_TAIL_MAX_CHARS ? next : next.slice(-ACP_STDERR_TAIL_MAX_CHARS); | ||
| 17 | } | ||
| 18 | |||
| 19 | /** Bounded, redacted excerpt safe to put on user-facing adapter errors. */ | ||
| 20 | export function sanitizeAcpStderrExcerpt( | ||
| 21 | text: string, | ||
| 22 | environment: NodeJS.ProcessEnv = process.env, | ||
| 23 | ): string { | ||
| 24 | let result = text.replaceAll("\0", ""); | ||
| 25 | const homes = [environment.HOME, environment.USERPROFILE, NodeOS.homedir()].filter( | ||
| 26 | (value): value is string => typeof value === "string" && value.length > 1, | ||
| 27 | ); | ||
| 28 | for (const home of new Set(homes)) { | ||
| 29 | result = result.split(home).join("~"); | ||
| 30 | } | ||
| 31 | result = result | ||
| 32 | .replace(PAIRING_URL_PATTERN, "[pairing-url]") | ||
| 33 | .replace(BEARER_TOKEN_PATTERN, "Bearer [redacted]") | ||
| 34 | .replace(BASIC_AUTH_PATTERN, "Authorization: Basic [redacted]") | ||
| 35 | .replace(API_KEY_HEADER_PATTERN, "x-api-key: [redacted]") | ||
| 36 | .replace(SECRET_TOKEN_PATTERN, "[redacted]"); | ||
| 37 | return result.trim(); | ||
| 38 | } | ||
apps/server/src/provider/acp/AntigravityAcpSupport.ts+1-1| ... | @@ -22,7 +22,7 @@ import { | ... | @@ -22,7 +22,7 @@ import { |
| 22 | makeAntigravityStderrHandler, | 22 | makeAntigravityStderrHandler, |
| 23 | makeAntigravityStdoutTransform, | 23 | makeAntigravityStdoutTransform, |
| 24 | } from "../antigravityAuthSupport.ts"; | 24 | } from "../antigravityAuthSupport.ts"; |
| 25 | import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; | 25 | import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; |
| 26 | import { normalizeAntigravitySessionUpdate } from "./AntigravityProtocol.ts"; | 26 | import { normalizeAntigravitySessionUpdate } from "./AntigravityProtocol.ts"; |
| 27 | 27 | ||
| 28 | export interface AntigravityAcpRuntimeInput extends Omit< | 28 | export interface AntigravityAcpRuntimeInput extends Omit< |
apps/server/src/provider/acp/AntigravityProtocol.test.ts+4-1| ... | @@ -17,7 +17,10 @@ import { | ... | @@ -17,7 +17,10 @@ import { |
| 17 | sanitizeAntigravityToolPayload, | 17 | sanitizeAntigravityToolPayload, |
| 18 | selectAntigravityPermissionOptionId, | 18 | selectAntigravityPermissionOptionId, |
| 19 | } from "./AntigravityProtocol.ts"; | 19 | } from "./AntigravityProtocol.ts"; |
| 20 | import { mergeToolCallState, parseSessionUpdateEvent } from "./AcpRuntimeModel.ts"; | 20 | import { |
| 21 | mergeToolCallState, | ||
| 22 | parseSessionUpdateEvent, | ||
| 23 | } from "@t3tools/provider-acp/server/runtimeModel"; | ||
| 21 | 24 | ||
| 22 | const isSessionNotification = Schema.is(AcpWireSchema.SessionNotification); | 25 | const isSessionNotification = Schema.is(AcpWireSchema.SessionNotification); |
| 23 | 26 |
apps/server/src/provider/acp/AntigravityProtocol.ts+1-1| ... | @@ -11,7 +11,7 @@ import * as Schema from "effect/Schema"; | ... | @@ -11,7 +11,7 @@ import * as Schema from "effect/Schema"; |
| 11 | import type * as EffectAcpSchema from "effect-acp/compat"; | 11 | import type * as EffectAcpSchema from "effect-acp/compat"; |
| 12 | import * as AcpWireSchema from "effect-acp/schema-v1"; | 12 | import * as AcpWireSchema from "effect-acp/schema-v1"; |
| 13 | 13 | ||
| 14 | import type { AcpToolCallState } from "./AcpRuntimeModel.ts"; | 14 | import type { AcpToolCallState } from "@t3tools/provider-acp/server/runtimeModel"; |
| 15 | 15 | ||
| 16 | const TOOL_TEXT_LIMIT = 8_000; | 16 | const TOOL_TEXT_LIMIT = 8_000; |
| 17 | const TOOL_TEXT_TRUNCATED = "[Earlier output truncated]\n\n"; | 17 | const TOOL_TEXT_TRUNCATED = "[Earlier output truncated]\n\n"; |
apps/server/src/provider/acp/GrokAcpSupport.ts+1-1| ... | @@ -15,7 +15,7 @@ import * as EffectAcpErrors from "effect-acp/errors"; | ... | @@ -15,7 +15,7 @@ import * as EffectAcpErrors from "effect-acp/errors"; |
| 15 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | 15 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| 16 | import { normalizeModelSlug } from "@t3tools/shared/model"; | 16 | import { normalizeModelSlug } from "@t3tools/shared/model"; |
| 17 | 17 | ||
| 18 | import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; | 18 | import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; |
| 19 | import { makeXAiPromptCompletionRuntime } from "./XAiAcpExtension.ts"; | 19 | import { makeXAiPromptCompletionRuntime } from "./XAiAcpExtension.ts"; |
| 20 | 20 | ||
| 21 | const GROK_API_KEY_ENV = "XAI_API_KEY"; | 21 | const GROK_API_KEY_ENV = "XAI_API_KEY"; |
apps/server/src/provider/acp/XAiAcpExtension.test.ts+2-2| ... | @@ -34,12 +34,12 @@ import { | ... | @@ -34,12 +34,12 @@ import { |
| 34 | xAiSubagentFinishedNotice, | 34 | xAiSubagentFinishedNotice, |
| 35 | XAiAskUserQuestionRequest, | 35 | XAiAskUserQuestionRequest, |
| 36 | } from "./XAiAcpExtension.ts"; | 36 | } from "./XAiAcpExtension.ts"; |
| 37 | import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; | 37 | import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; |
| 38 | import { | 38 | import { |
| 39 | type AcpToolCallState, | 39 | type AcpToolCallState, |
| 40 | mergeToolCallState, | 40 | mergeToolCallState, |
| 41 | parseSessionUpdateEvent, | 41 | parseSessionUpdateEvent, |
| 42 | } from "./AcpRuntimeModel.ts"; | 42 | } from "@t3tools/provider-acp/server/runtimeModel"; |
| 43 | 43 | ||
| 44 | const decodeXAiAskUserQuestionRequest = Schema.decodeUnknownSync(XAiAskUserQuestionRequest); | 44 | const decodeXAiAskUserQuestionRequest = Schema.decodeUnknownSync(XAiAskUserQuestionRequest); |
| 45 | 45 |
apps/server/src/provider/acp/XAiAcpExtension.ts+2-2| ... | @@ -11,8 +11,8 @@ import * as Schema from "effect/Schema"; | ... | @@ -11,8 +11,8 @@ import * as Schema from "effect/Schema"; |
| 11 | import * as EffectAcpErrors from "effect-acp/errors"; | 11 | import * as EffectAcpErrors from "effect-acp/errors"; |
| 12 | import type * as EffectAcpSchema from "effect-acp/compat"; | 12 | import type * as EffectAcpSchema from "effect-acp/compat"; |
| 13 | 13 | ||
| 14 | import type * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; | 14 | import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; |
| 15 | import type { AcpToolCallState } from "./AcpRuntimeModel.ts"; | 15 | import type { AcpToolCallState } from "@t3tools/provider-acp/server/runtimeModel"; |
| 16 | 16 | ||
| 17 | const xAiStopReasonMissingMetaKey = "xAiStopReasonMissing"; | 17 | const xAiStopReasonMissingMetaKey = "xAiStopReasonMissing"; |
| 18 | export const xAiRateLimitedErrorCode = -32003; | 18 | export const xAiRateLimitedErrorCode = -32003; |
apps/server/src/provider/antigravityAuthSupport.ts+2-2| ... | @@ -17,7 +17,7 @@ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; | ... | @@ -17,7 +17,7 @@ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; |
| 17 | import * as AcpErrors from "effect-acp/errors"; | 17 | import * as AcpErrors from "effect-acp/errors"; |
| 18 | 18 | ||
| 19 | import { collectUint8StreamText } from "@t3tools/provider-core/server/collectStreamText"; | 19 | import { collectUint8StreamText } from "@t3tools/provider-core/server/collectStreamText"; |
| 20 | import type { AcpSpawnInput } from "./acp/AcpSessionRuntime.ts"; | 20 | import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; |
| 21 | import { | 21 | import { |
| 22 | antigravityUserSkillDirectories, | 22 | antigravityUserSkillDirectories, |
| 23 | resolveAntigravityUserHome, | 23 | resolveAntigravityUserHome, |
| ... | @@ -440,7 +440,7 @@ export function buildAntigravityAcpSpawnInput(input: { | ... | @@ -440,7 +440,7 @@ export function buildAntigravityAcpSpawnInput(input: { |
| 440 | readonly auth?: AntigravityAuthConfig; | 440 | readonly auth?: AntigravityAuthConfig; |
| 441 | /** Per-process temp directory. Defaults to the profile's shared temp directory. */ | 441 | /** Per-process temp directory. Defaults to the profile's shared temp directory. */ |
| 442 | readonly runtimeTempDirectory?: string; | 442 | readonly runtimeTempDirectory?: string; |
| 443 | }): AcpSpawnInput { | 443 | }): AcpSessionRuntime.AcpSpawnInput { |
| 444 | return { | 444 | return { |
| 445 | command: input.installation.executablePath, | 445 | command: input.installation.executablePath, |
| 446 | args: input.profile.platform === "linux" ? ["--uid="] : [], | 446 | args: input.profile.platform === "linux" ? ["--uid="] : [], |
apps/server/src/textGeneration/AntigravityTextGeneration.test.ts+2-2| ... | @@ -20,7 +20,7 @@ import { type AcpError, AcpRequestError } from "effect-acp/errors"; | ... | @@ -20,7 +20,7 @@ import { type AcpError, AcpRequestError } from "effect-acp/errors"; |
| 20 | import type * as AcpSchema from "effect-acp/compat"; | 20 | import type * as AcpSchema from "effect-acp/compat"; |
| 21 | import { expect } from "vite-plus/test"; | 21 | import { expect } from "vite-plus/test"; |
| 22 | 22 | ||
| 23 | import type { AcpSessionRuntimeEvent } from "../provider/acp/AcpSessionRuntime.ts"; | 23 | import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; |
| 24 | import { removeAntigravitySessionFiles } from "../provider/acp/AntigravitySessionFiles.ts"; | 24 | import { removeAntigravitySessionFiles } from "../provider/acp/AntigravitySessionFiles.ts"; |
| 25 | 25 | ||
| 26 | import { | 26 | import { |
| ... | @@ -121,7 +121,7 @@ const makeFixture = Effect.fn("makeAntigravityTextGenerationFixture")(function* | ... | @@ -121,7 +121,7 @@ const makeFixture = Effect.fn("makeAntigravityTextGenerationFixture")(function* |
| 121 | 121 | ||
| 122 | const makeRuntime: AntigravityTextGenerationOptions["makeRuntime"] = (cwd) => | 122 | const makeRuntime: AntigravityTextGenerationOptions["makeRuntime"] = (cwd) => |
| 123 | Effect.gen(function* () { | 123 | Effect.gen(function* () { |
| 124 | const events = yield* Queue.unbounded<AcpSessionRuntimeEvent>(); | 124 | const events = yield* Queue.unbounded<AcpSessionRuntime.AcpSessionRuntimeEvent>(); |
| 125 | state.workspaces.push(cwd); | 125 | state.workspaces.push(cwd); |
| 126 | expect(yield* fs.readDirectory(cwd).pipe(Effect.orDie)).toEqual([]); | 126 | expect(yield* fs.readDirectory(cwd).pipe(Effect.orDie)).toEqual([]); |
| 127 | yield* Effect.addFinalizer(() => | 127 | yield* Effect.addFinalizer(() => |
apps/server/src/textGeneration/AntigravityTextGeneration.ts+2-2| ... | @@ -13,7 +13,7 @@ import { type AcpError, AcpRequestError } from "effect-acp/errors"; | ... | @@ -13,7 +13,7 @@ import { type AcpError, AcpRequestError } from "effect-acp/errors"; |
| 13 | 13 | ||
| 14 | import { applyAntigravityAcpModelSelection } from "../provider/acp/AntigravityAcpSupport.ts"; | 14 | import { applyAntigravityAcpModelSelection } from "../provider/acp/AntigravityAcpSupport.ts"; |
| 15 | import { removeAntigravitySessionFiles } from "../provider/acp/AntigravitySessionFiles.ts"; | 15 | import { removeAntigravitySessionFiles } from "../provider/acp/AntigravitySessionFiles.ts"; |
| 16 | import type { AcpSessionRuntime } from "../provider/acp/AcpSessionRuntime.ts"; | 16 | import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; |
| 17 | import * as TextGenerationOperations from "@t3tools/provider-core/server/textGenerationOperations"; | 17 | import * as TextGenerationOperations from "@t3tools/provider-core/server/textGenerationOperations"; |
| 18 | 18 | ||
| 19 | const ANTIGRAVITY_TIMEOUT_MS = 180_000; | 19 | const ANTIGRAVITY_TIMEOUT_MS = 180_000; |
| ... | @@ -25,7 +25,7 @@ const decodeConfiguration = Schema.decodeEffect(Schema.fromJsonString(Configurat | ... | @@ -25,7 +25,7 @@ const decodeConfiguration = Schema.decodeEffect(Schema.fromJsonString(Configurat |
| 25 | const decodeConfigurationObject = Schema.decodeUnknownEffect(Configuration); | 25 | const decodeConfigurationObject = Schema.decodeUnknownEffect(Configuration); |
| 26 | 26 | ||
| 27 | type AntigravityTextRuntime = Pick< | 27 | type AntigravityTextRuntime = Pick< |
| 28 | AcpSessionRuntime["Service"], | 28 | AcpSessionRuntime.AcpSessionRuntime["Service"], |
| 29 | | "start" | 29 | | "start" |
| 30 | | "setMode" | 30 | | "setMode" |
| 31 | | "getConfigOptions" | 31 | | "getConfigOptions" |
docs/internals/adding-a-provider.md+1-1| ... | @@ -10,7 +10,7 @@ whether a turn runs. This page lists the decisions and evidence a new driver nee | ... | @@ -10,7 +10,7 @@ whether a turn runs. This page lists the decisions and evidence a new driver nee |
| 10 | - **ACP agents** start as [ACP Registry](../user/providers-acp.md) entries, which follow the ACP | 10 | - **ACP agents** start as [ACP Registry](../user/providers-acp.md) entries, which follow the ACP |
| 11 | spec with no per-agent handling. An agent gets a dedicated driver only when it needs behavior the | 11 | spec with no per-agent handling. An agent gets a dedicated driver only when it needs behavior the |
| 12 | spec does not cover, and then it is a small flavor over the | 12 | spec does not cover, and then it is a small flavor over the |
| 13 | [shared ACP adapter](../../apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts), like Grok | 13 | [shared ACP adapter](../../packages/provider-acp/src/server/adapter.ts), like Grok |
| 14 | and Antigravity. Never add agent-id checks to the generic registry adapter. | 14 | and Antigravity. Never add agent-id checks to the generic registry adapter. |
| 15 | - **Other protocols** get a native adapter that implements | 15 | - **Other protocols** get a native adapter that implements |
| 16 | [`ProviderAdapterV2`](../../packages/provider-core/src/server/ProviderAdapter.ts), like Codex, | 16 | [`ProviderAdapterV2`](../../packages/provider-core/src/server/ProviderAdapter.ts), like Codex, |
package.json+1-1| ... | @@ -28,7 +28,7 @@ | ... | @@ -28,7 +28,7 @@ |
| 28 | "tc": "vp run -r --concurrency-limit 2 typecheck", | 28 | "tc": "vp run -r --concurrency-limit 2 typecheck", |
| 29 | "lint": "vp lint --report-unused-disable-directives", | 29 | "lint": "vp lint --report-unused-disable-directives", |
| 30 | "knip": "knip --preprocessor ./scripts/knip-schemas.ts", | 30 | "knip": "knip --preprocessor ./scripts/knip-schemas.ts", |
| 31 | "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", | 31 | "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", |
| 32 | "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts", | 32 | "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts", |
| 33 | "lint:mobile": "node scripts/mobile-native-static-check.ts", | 33 | "lint:mobile": "node scripts/mobile-native-static-check.ts", |
| 34 | "test": "vp run -r test", | 34 | "test": "vp run -r test", |
packages/provider-acp/package.json created+65| ... | @@ -0,0 +1,65 @@ | ||
| 1 | { | ||
| 2 | "name": "@t3tools/provider-acp", | ||
| 3 | "private": true, | ||
| 4 | "type": "module", | ||
| 5 | "exports": { | ||
| 6 | "./server/AcpSessionRuntime": { | ||
| 7 | "types": "./src/server/AcpSessionRuntime.ts", | ||
| 8 | "import": "./src/server/AcpSessionRuntime.ts" | ||
| 9 | }, | ||
| 10 | "./server/adapter": { | ||
| 11 | "types": "./src/server/adapter.ts", | ||
| 12 | "import": "./src/server/adapter.ts" | ||
| 13 | }, | ||
| 14 | "./server/clientPolicy": { | ||
| 15 | "types": "./src/server/clientPolicy.ts", | ||
| 16 | "import": "./src/server/clientPolicy.ts" | ||
| 17 | }, | ||
| 18 | "./server/clientTerminals": { | ||
| 19 | "types": "./src/server/clientTerminals.ts", | ||
| 20 | "import": "./src/server/clientTerminals.ts" | ||
| 21 | }, | ||
| 22 | "./server/coreRuntimeEvents": { | ||
| 23 | "types": "./src/server/coreRuntimeEvents.ts", | ||
| 24 | "import": "./src/server/coreRuntimeEvents.ts" | ||
| 25 | }, | ||
| 26 | "./server/mcpOverAcpBridge": { | ||
| 27 | "types": "./src/server/mcpOverAcpBridge.ts", | ||
| 28 | "import": "./src/server/mcpOverAcpBridge.ts" | ||
| 29 | }, | ||
| 30 | "./server/nativeLogging": { | ||
| 31 | "types": "./src/server/nativeLogging.ts", | ||
| 32 | "import": "./src/server/nativeLogging.ts" | ||
| 33 | }, | ||
| 34 | "./server/runtimeModel": { | ||
| 35 | "types": "./src/server/runtimeModel.ts", | ||
| 36 | "import": "./src/server/runtimeModel.ts" | ||
| 37 | }, | ||
| 38 | "./server/sessionConfig": { | ||
| 39 | "types": "./src/server/sessionConfig.ts", | ||
| 40 | "import": "./src/server/sessionConfig.ts" | ||
| 41 | }, | ||
| 42 | "./server/stderr": { | ||
| 43 | "types": "./src/server/stderr.ts", | ||
| 44 | "import": "./src/server/stderr.ts" | ||
| 45 | } | ||
| 46 | }, | ||
| 47 | "scripts": { | ||
| 48 | "typecheck": "tsc --noEmit", | ||
| 49 | "test": "vp test run --config ../../vite.config.ts --dir ." | ||
| 50 | }, | ||
| 51 | "dependencies": { | ||
| 52 | "@t3tools/contracts": "workspace:*", | ||
| 53 | "@t3tools/provider-core": "workspace:*", | ||
| 54 | "@t3tools/shared": "workspace:*", | ||
| 55 | "diff": "8.0.3", | ||
| 56 | "effect": "catalog:" | ||
| 57 | }, | ||
| 58 | "devDependencies": { | ||
| 59 | "@effect/platform-node": "catalog:", | ||
| 60 | "@effect/vitest": "catalog:", | ||
| 61 | "@types/node": "catalog:", | ||
| 62 | "effect-acp": "workspace:*", | ||
| 63 | "vite-plus": "catalog:" | ||
| 64 | } | ||
| 65 | } | ||
packages/provider-acp/src/server/AcpSessionRuntime.ts created+3035| ... | @@ -0,0 +1,3035 @@ | ||
| 1 | // @effect-diagnostics nodeBuiltinImport:off | ||
| 2 | import * as NodeCrypto from "node:crypto"; | ||
| 3 | import * as NodeFS from "node:fs"; | ||
| 4 | import * as NodePath from "node:path"; | ||
| 5 | |||
| 6 | import * as Cause from "effect/Cause"; | ||
| 7 | import * as Clock from "effect/Clock"; | ||
| 8 | import * as Context from "effect/Context"; | ||
| 9 | import * as Crypto from "effect/Crypto"; | ||
| 10 | import * as Deferred from "effect/Deferred"; | ||
| 11 | import * as Duration from "effect/Duration"; | ||
| 12 | import * as Effect from "effect/Effect"; | ||
| 13 | import * as Exit from "effect/Exit"; | ||
| 14 | import * as Fiber from "effect/Fiber"; | ||
| 15 | import * as Layer from "effect/Layer"; | ||
| 16 | import * as Option from "effect/Option"; | ||
| 17 | import * as Queue from "effect/Queue"; | ||
| 18 | import * as Ref from "effect/Ref"; | ||
| 19 | import * as Schema from "effect/Schema"; | ||
| 20 | import * as Scope from "effect/Scope"; | ||
| 21 | import * as Semaphore from "effect/Semaphore"; | ||
| 22 | import * as Stream from "effect/Stream"; | ||
| 23 | import * as ChildProcess from "effect/process/ChildProcess"; | ||
| 24 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; | ||
| 25 | import * as EffectAcpClient from "effect-acp/client"; | ||
| 26 | import * as EffectAcpErrors from "effect-acp/errors"; | ||
| 27 | import type * as EffectAcpSchema from "effect-acp/compat"; | ||
| 28 | import type * as EffectAcpProtocol from "effect-acp/protocol"; | ||
| 29 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; | ||
| 30 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 31 | |||
| 32 | import { signalProcessGroup } from "@t3tools/provider-core/server/processGroup"; | ||
| 33 | import { appendAcpStderrTail, sanitizeAcpStderrExcerpt } from "./stderr.ts"; | ||
| 34 | import { | ||
| 35 | collectSessionConfigOptionValues, | ||
| 36 | decideToolCallUpdateEmission, | ||
| 37 | extractModelConfigId, | ||
| 38 | findSessionConfigOption, | ||
| 39 | mergeToolCallState, | ||
| 40 | toolCallProgressLength, | ||
| 41 | parseSessionModeState, | ||
| 42 | parseSessionUpdateEvent, | ||
| 43 | sessionUpdateCountsAsLoadReplayActivity, | ||
| 44 | sessionUpdateIsReplay, | ||
| 45 | waitForSessionLoadReplayIdle, | ||
| 46 | type SessionLoadGate, | ||
| 47 | type AcpParsedSessionEvent, | ||
| 48 | type AcpSessionModeState, | ||
| 49 | type AcpToolCallState, | ||
| 50 | } from "./runtimeModel.ts"; | ||
| 51 | |||
| 52 | const MAX_SHOWN_TOOL_CALL_IDS = 256; | ||
| 53 | |||
| 54 | interface AcpToolCallTrackedState { | ||
| 55 | readonly state: AcpToolCallState; | ||
| 56 | readonly lastEmittedDetailLength: number | undefined; | ||
| 57 | readonly skippedSinceEmit: number; | ||
| 58 | } | ||
| 59 | |||
| 60 | function formatConfigOptionValue(value: string | boolean): string { | ||
| 61 | return JSON.stringify(value); | ||
| 62 | } | ||
| 63 | |||
| 64 | export interface AcpSessionEventStreamBarrier { | ||
| 65 | readonly _tag: "EventStreamBarrier"; | ||
| 66 | readonly acknowledge: Deferred.Deferred<void>; | ||
| 67 | } | ||
| 68 | |||
| 69 | export type AcpSessionRuntimeEvent = | ||
| 70 | | AcpParsedSessionEvent | ||
| 71 | | AcpSessionEventStreamBarrier | ||
| 72 | | { | ||
| 73 | readonly _tag: "ConnectionTerminated"; | ||
| 74 | readonly error: EffectAcpErrors.AcpError; | ||
| 75 | }; | ||
| 76 | |||
| 77 | const defaultSessionLoadTimeout = Duration.seconds(90); | ||
| 78 | const defaultSessionLoadReplayIdleGap = Duration.seconds(2); | ||
| 79 | const defaultCancelTimeout = Duration.seconds(15); | ||
| 80 | const maxStartupMetadataUpdates = 32; | ||
| 81 | // Antigravity can emit an accepted 16 KiB Google authorization URL on stderr. | ||
| 82 | const maxStderrChunkLength = 32_768; | ||
| 83 | |||
| 84 | export interface AcpSpawnInput { | ||
| 85 | readonly command: string; | ||
| 86 | readonly args: ReadonlyArray<string>; | ||
| 87 | readonly cwd?: string; | ||
| 88 | readonly env?: NodeJS.ProcessEnv; | ||
| 89 | readonly extendEnv?: boolean; | ||
| 90 | readonly shell?: false; | ||
| 91 | } | ||
| 92 | |||
| 93 | export interface AcpSessionRuntimeOptions { | ||
| 94 | readonly spawn: AcpSpawnInput; | ||
| 95 | readonly cwd: string; | ||
| 96 | readonly resumeSessionId?: string; | ||
| 97 | readonly resumeMethod?: "load" | "resume"; | ||
| 98 | readonly sessionLoadTimeout?: Duration.Input; | ||
| 99 | readonly sessionLoadReplayIdleGap?: Duration.Input; | ||
| 100 | readonly interruptPromptOnCancel?: boolean; | ||
| 101 | /** Optional provider metadata forwarded on `session/cancel`. */ | ||
| 102 | readonly cancelMeta?: EffectAcpSchema.CancelNotification["_meta"]; | ||
| 103 | /** Optional provider metadata forwarded on `initialize`. */ | ||
| 104 | readonly initializeMeta?: EffectAcpSchema.InitializeRequest["_meta"]; | ||
| 105 | readonly ownDetachedProcessGroup?: boolean; | ||
| 106 | readonly ownDescendantProcessGroups?: boolean; | ||
| 107 | readonly processGroupPlatform?: NodeJS.Platform; | ||
| 108 | readonly processGroupTerminationGrace?: Duration.Input; | ||
| 109 | readonly windowsProcessTreeTerminator?: ( | ||
| 110 | pid: number, | ||
| 111 | ) => Effect.Effect<void, AcpProcessGroupTerminationError>; | ||
| 112 | readonly posixProcessTreeController?: AcpPosixProcessTreeController; | ||
| 113 | readonly linuxCgroupController?: AcpLinuxCgroupController | null; | ||
| 114 | /** Native cancellation waits for the prompt response and the getEvents consumer to drain. */ | ||
| 115 | readonly cancelBehavior?: "interrupt" | "wait-for-prompt"; | ||
| 116 | readonly cancelTimeout?: Duration.Input; | ||
| 117 | readonly clientCapabilities?: EffectAcpSchema.InitializeRequest["clientCapabilities"]; | ||
| 118 | readonly clientInfo: { | ||
| 119 | readonly name: string; | ||
| 120 | readonly version: string; | ||
| 121 | }; | ||
| 122 | readonly authMethodId?: string; | ||
| 123 | /** | ||
| 124 | * Call `authenticate` right after `initialize` instead of waiting for an | ||
| 125 | * auth-required error. Antigravity selects its credential source (Google | ||
| 126 | * account vs API key) from the method, so the session must not open first. | ||
| 127 | */ | ||
| 128 | readonly authenticateEagerly?: boolean; | ||
| 129 | readonly mcpServers?: ReadonlyArray<EffectAcpSchema.McpServer>; | ||
| 130 | /** Extra workspace roots the agent may read and write besides `cwd`. */ | ||
| 131 | readonly additionalDirectories?: ReadonlyArray<string>; | ||
| 132 | /** Transforms provider stdout before protocol parsing and protocol logging. */ | ||
| 133 | readonly transformStdout?: EffectAcpClient.AcpClientOptions["transformStdout"]; | ||
| 134 | /** Normalizes provider-specific fields before notification queues or runtime state retain them. */ | ||
| 135 | readonly transformSessionUpdate?: ( | ||
| 136 | notification: EffectAcpSchema.SessionNotification, | ||
| 137 | ) => EffectAcpSchema.SessionNotification; | ||
| 138 | /** Receives bounded stderr chunks. Redact secrets before logging. A failure closes the runtime. */ | ||
| 139 | readonly onStderr?: (text: string) => Effect.Effect<void, EffectAcpErrors.AcpError>; | ||
| 140 | /** Disable only for non-interactive discovery that must surface auth-required immediately. */ | ||
| 141 | readonly authenticateOnAuthRequired?: boolean; | ||
| 142 | /** Observes the normalized handshake before any session setup or authentication retry. */ | ||
| 143 | readonly onInitialized?: ( | ||
| 144 | result: EffectAcpSchema.InitializeResponse, | ||
| 145 | ) => Effect.Effect<void, never>; | ||
| 146 | readonly acpMcpServers?: ReadonlyArray<EffectAcpSchema.McpServer>; | ||
| 147 | readonly requestLogger?: (event: AcpSessionRequestLogEvent) => Effect.Effect<void, never>; | ||
| 148 | readonly protocolLogging?: { | ||
| 149 | readonly logIncoming?: boolean; | ||
| 150 | readonly logOutgoing?: boolean; | ||
| 151 | readonly logger?: (event: EffectAcpProtocol.AcpProtocolLogEvent) => Effect.Effect<void, never>; | ||
| 152 | }; | ||
| 153 | readonly onIncomingRequest?: EffectAcpClient.AcpClientOptions["onIncomingRequest"]; | ||
| 154 | readonly onTermination?: (error: EffectAcpErrors.AcpError) => Effect.Effect<void>; | ||
| 155 | readonly onOutgoingResponseFailure?: EffectAcpClient.AcpClientOptions["onOutgoingResponseFailure"]; | ||
| 156 | readonly onOutgoingResponse?: EffectAcpClient.AcpClientOptions["onOutgoingResponse"]; | ||
| 157 | } | ||
| 158 | |||
| 159 | export interface AcpSessionRequestLogEvent { | ||
| 160 | readonly method: string; | ||
| 161 | readonly payload: unknown; | ||
| 162 | readonly status: "started" | "succeeded" | "failed"; | ||
| 163 | readonly result?: unknown; | ||
| 164 | readonly cause?: Cause.Cause<EffectAcpErrors.AcpError>; | ||
| 165 | } | ||
| 166 | |||
| 167 | export class AcpProcessGroupTerminationError extends Schema.TaggedError<AcpProcessGroupTerminationError>()( | ||
| 168 | "AcpProcessGroupTerminationError", | ||
| 169 | { | ||
| 170 | detail: Schema.String, | ||
| 171 | pid: Schema.optionalKey(Schema.Int), | ||
| 172 | exitCode: Schema.optionalKey(Schema.Number), | ||
| 173 | cause: Schema.optional(Schema.Defect()), | ||
| 174 | }, | ||
| 175 | ) { | ||
| 176 | override get message(): string { | ||
| 177 | return this.detail; | ||
| 178 | } | ||
| 179 | } | ||
| 180 | |||
| 181 | const isAcpProcessGroupTerminationError = Schema.is(AcpProcessGroupTerminationError); | ||
| 182 | |||
| 183 | export interface AcpPosixProcessIdentity { | ||
| 184 | readonly executable: string | undefined; | ||
| 185 | readonly pgid: number; | ||
| 186 | readonly pid: number; | ||
| 187 | readonly ppid: number; | ||
| 188 | readonly sid: number; | ||
| 189 | /** | ||
| 190 | * Linux `/proc/<pid>/stat` state when known (`R`/`S`/`D`/`Z`/…). Omitted on | ||
| 191 | * platforms or fixtures that do not surface it. Zombies (`Z`) are already | ||
| 192 | * dead and must not count as residual teardown survivors. | ||
| 193 | */ | ||
| 194 | readonly state?: string; | ||
| 195 | readonly startTime: string; | ||
| 196 | } | ||
| 197 | |||
| 198 | export interface AcpPosixProcessTreeController { | ||
| 199 | readonly childPidsOf: (pid: number) => ReadonlyArray<number>; | ||
| 200 | readonly childrenOf: (pid: number) => ReadonlyArray<AcpPosixProcessIdentity>; | ||
| 201 | readonly identity: (pid: number) => AcpPosixProcessIdentity | undefined; | ||
| 202 | readonly snapshot: () => ReadonlyArray<AcpPosixProcessIdentity>; | ||
| 203 | readonly signalProcess: (pid: number, signal: NodeJS.Signals) => void; | ||
| 204 | } | ||
| 205 | |||
| 206 | export interface AcpLinuxCgroupLease { | ||
| 207 | readonly contains: (pid: number) => boolean; | ||
| 208 | readonly exists: () => boolean; | ||
| 209 | readonly path: string; | ||
| 210 | readonly relativePath: string; | ||
| 211 | readonly kill: () => void; | ||
| 212 | readonly populated: () => boolean; | ||
| 213 | readonly remove: () => void; | ||
| 214 | } | ||
| 215 | |||
| 216 | export interface AcpLinuxCgroupController { | ||
| 217 | readonly create: () => AcpLinuxCgroupLease | undefined; | ||
| 218 | } | ||
| 219 | |||
| 220 | export interface AcpPosixOwnershipRoot { | ||
| 221 | captureAttempted?: boolean; | ||
| 222 | value: AcpOwnedPosixProcess | undefined; | ||
| 223 | } | ||
| 224 | |||
| 225 | function decodeMountInfoPath(value: string): string { | ||
| 226 | return value.replace(/\\([0-7]{3})/g, (_match, octal: string) => | ||
| 227 | String.fromCharCode(Number.parseInt(octal, 8)), | ||
| 228 | ); | ||
| 229 | } | ||
| 230 | |||
| 231 | export function parseUnifiedCgroupPath(contents: string): string | undefined { | ||
| 232 | const matches = [...contents.matchAll(/^0::(\/.*)$/gm)]; | ||
| 233 | return matches.length === 1 ? matches[0]![1] : undefined; | ||
| 234 | } | ||
| 235 | |||
| 236 | export function isSafeCgroupPath(value: string): boolean { | ||
| 237 | return ( | ||
| 238 | value.startsWith("/") && | ||
| 239 | !value.includes("(deleted)") && | ||
| 240 | NodePath.posix.normalize(value) === value | ||
| 241 | ); | ||
| 242 | } | ||
| 243 | |||
| 244 | export function parseCgroup2Mounts( | ||
| 245 | contents: string, | ||
| 246 | ): ReadonlyArray<{ readonly mountPoint: string; readonly root: string }> { | ||
| 247 | const mounts: Array<{ readonly mountPoint: string; readonly root: string }> = []; | ||
| 248 | for (const line of contents.split("\n")) { | ||
| 249 | const [mount, filesystem] = line.split(" - "); | ||
| 250 | if (mount === undefined || filesystem?.split(" ")[0] !== "cgroup2") continue; | ||
| 251 | const fields = mount.split(" "); | ||
| 252 | if (fields.length < 5) continue; | ||
| 253 | mounts.push({ | ||
| 254 | mountPoint: decodeMountInfoPath(fields[4]!), | ||
| 255 | root: decodeMountInfoPath(fields[3]!), | ||
| 256 | }); | ||
| 257 | } | ||
| 258 | return mounts; | ||
| 259 | } | ||
| 260 | |||
| 261 | export function parseCgroup2Mount( | ||
| 262 | contents: string, | ||
| 263 | ): { readonly mountPoint: string; readonly root: string } | undefined { | ||
| 264 | return parseCgroup2Mounts(contents)[0]; | ||
| 265 | } | ||
| 266 | |||
| 267 | export function wrapCommandForLinuxCgroup( | ||
| 268 | lease: AcpLinuxCgroupLease, | ||
| 269 | command: string, | ||
| 270 | args: ReadonlyArray<string>, | ||
| 271 | ): { readonly command: string; readonly args: ReadonlyArray<string> } { | ||
| 272 | return { | ||
| 273 | command: "/bin/sh", | ||
| 274 | args: [ | ||
| 275 | "-c", | ||
| 276 | [ | ||
| 277 | "lease_path=$1; expected=$2; shift 2", | ||
| 278 | 'printf "%s\\n" "$$" > "$lease_path/cgroup.procs" || exit 125', | ||
| 279 | "actual=", | ||
| 280 | "while IFS= read -r line; do", | ||
| 281 | ' case "$line" in 0::*) [ -z "$actual" ] || exit 126; actual=${line#0::};; esac', | ||
| 282 | "done < /proc/self/cgroup || exit 125", | ||
| 283 | '[ "$actual" = "$expected" ] || exit 126', | ||
| 284 | "unset ELECTRON_RUN_AS_NODE T3_ACP_CGROUP_WRAPPER", | ||
| 285 | "trap 'exit 125' 0", | ||
| 286 | 'exec "$@"', | ||
| 287 | ].join("\n"), | ||
| 288 | "t3-acp-cgroup-wrapper", | ||
| 289 | lease.path, | ||
| 290 | lease.relativePath, | ||
| 291 | command, | ||
| 292 | ...args, | ||
| 293 | ], | ||
| 294 | }; | ||
| 295 | } | ||
| 296 | |||
| 297 | export function resolveLinuxCgroupTargetCommand( | ||
| 298 | command: string, | ||
| 299 | cwd: string, | ||
| 300 | environment: NodeJS.ProcessEnv, | ||
| 301 | ): string | undefined { | ||
| 302 | // Match Node spawn PATH fallback when PATH is undefined. An explicitly empty | ||
| 303 | // PATH stays empty (lookup only via empty-segment → cwd). | ||
| 304 | const pathEnv = environment.PATH === undefined ? "/usr/bin:/bin" : environment.PATH; | ||
| 305 | const candidates = command.includes(NodePath.sep) | ||
| 306 | ? [NodePath.resolve(cwd, command)] | ||
| 307 | : pathEnv | ||
| 308 | .split(NodePath.delimiter) | ||
| 309 | .map((entry) => | ||
| 310 | NodePath.join( | ||
| 311 | entry.length === 0 | ||
| 312 | ? cwd | ||
| 313 | : NodePath.isAbsolute(entry) | ||
| 314 | ? entry | ||
| 315 | : NodePath.resolve(cwd, entry), | ||
| 316 | command, | ||
| 317 | ), | ||
| 318 | ); | ||
| 319 | for (const candidate of candidates) { | ||
| 320 | try { | ||
| 321 | if (!NodeFS.statSync(candidate).isFile()) continue; | ||
| 322 | NodeFS.accessSync(candidate, NodeFS.constants.X_OK); | ||
| 323 | return candidate; | ||
| 324 | } catch { | ||
| 325 | // Try the next PATH entry. | ||
| 326 | } | ||
| 327 | } | ||
| 328 | return undefined; | ||
| 329 | } | ||
| 330 | |||
| 331 | const STALE_ACP_CGROUP_SIBLING = /^t3-acp-(\d+)-/; | ||
| 332 | |||
| 333 | export interface SweepStaleLinuxCgroupSiblingsOptions { | ||
| 334 | readonly currentPid?: number; | ||
| 335 | readonly isProcessAlive?: (pid: number) => boolean; | ||
| 336 | readonly readPopulated?: (siblingPath: string) => "0" | "1" | undefined; | ||
| 337 | readonly remove?: (siblingPath: string) => void; | ||
| 338 | } | ||
| 339 | |||
| 340 | function defaultCgroupOwnerIsAlive(pid: number): boolean { | ||
| 341 | try { | ||
| 342 | process.kill(pid, 0); | ||
| 343 | return true; | ||
| 344 | } catch (cause) { | ||
| 345 | return (cause as NodeJS.ErrnoException | undefined)?.code !== "ESRCH"; | ||
| 346 | } | ||
| 347 | } | ||
| 348 | |||
| 349 | function defaultReadCgroupPopulated(siblingPath: string): "0" | "1" | undefined { | ||
| 350 | const state = /(?:^|\n)populated ([01])(?:\n|$)/.exec( | ||
| 351 | NodeFS.readFileSync(NodePath.join(siblingPath, "cgroup.events"), "utf8"), | ||
| 352 | )?.[1]; | ||
| 353 | return state === "0" || state === "1" ? state : undefined; | ||
| 354 | } | ||
| 355 | |||
| 356 | /** Best-effort removal of empty `t3-acp-<dead-pid>-*` sibling leases under a parent cgroup. */ | ||
| 357 | export function sweepStaleLinuxCgroupSiblings( | ||
| 358 | parentPath: string, | ||
| 359 | options: SweepStaleLinuxCgroupSiblingsOptions = {}, | ||
| 360 | ): void { | ||
| 361 | const currentPid = options.currentPid ?? process.pid; | ||
| 362 | const isProcessAlive = options.isProcessAlive ?? defaultCgroupOwnerIsAlive; | ||
| 363 | const readPopulated = options.readPopulated ?? defaultReadCgroupPopulated; | ||
| 364 | const remove = options.remove ?? ((siblingPath: string) => NodeFS.rmdirSync(siblingPath)); | ||
| 365 | try { | ||
| 366 | for (const entry of NodeFS.readdirSync(parentPath, { withFileTypes: true })) { | ||
| 367 | try { | ||
| 368 | if (!entry.isDirectory()) continue; | ||
| 369 | const match = STALE_ACP_CGROUP_SIBLING.exec(entry.name); | ||
| 370 | if (match === null) continue; | ||
| 371 | const ownerPid = Number(match[1]); | ||
| 372 | if (!Number.isSafeInteger(ownerPid) || ownerPid <= 1 || ownerPid === currentPid) continue; | ||
| 373 | if (isProcessAlive(ownerPid)) continue; | ||
| 374 | const siblingPath = NodePath.join(parentPath, entry.name); | ||
| 375 | if (NodePath.dirname(siblingPath) !== parentPath) continue; | ||
| 376 | if (readPopulated(siblingPath) !== "0") continue; | ||
| 377 | remove(siblingPath); | ||
| 378 | } catch { | ||
| 379 | // Ignore unreadable or busy siblings; never fail lease creation. | ||
| 380 | } | ||
| 381 | } | ||
| 382 | } catch { | ||
| 383 | // Ignore readdir failures on the parent path. | ||
| 384 | } | ||
| 385 | } | ||
| 386 | |||
| 387 | function tryCreateLinuxCgroupLease( | ||
| 388 | currentRelative: string, | ||
| 389 | mount: { readonly mountPoint: string; readonly root: string }, | ||
| 390 | ): AcpLinuxCgroupLease | undefined { | ||
| 391 | if ( | ||
| 392 | !isSafeCgroupPath(mount.root) || | ||
| 393 | !isSafeCgroupPath(mount.mountPoint) || | ||
| 394 | !( | ||
| 395 | mount.root === "/" || | ||
| 396 | currentRelative === mount.root || | ||
| 397 | currentRelative.startsWith(`${mount.root}/`) | ||
| 398 | ) | ||
| 399 | ) { | ||
| 400 | return undefined; | ||
| 401 | } | ||
| 402 | const mountRelative = | ||
| 403 | mount.root === "/" ? currentRelative : currentRelative.slice(mount.root.length); | ||
| 404 | const currentPath = NodePath.resolve(mount.mountPoint, `.${mountRelative}`); | ||
| 405 | const resolvedMount = NodePath.resolve(mount.mountPoint); | ||
| 406 | if (currentPath !== resolvedMount && !currentPath.startsWith(`${resolvedMount}${NodePath.sep}`)) { | ||
| 407 | return undefined; | ||
| 408 | } | ||
| 409 | sweepStaleLinuxCgroupSiblings(currentPath); | ||
| 410 | const childName = `t3-acp-${process.pid}-${NodeCrypto.randomUUID().replaceAll("-", "")}`; | ||
| 411 | const childPath = NodePath.join(currentPath, childName); | ||
| 412 | const childRelative = NodePath.posix.join(currentRelative, childName); | ||
| 413 | if (NodePath.dirname(childPath) !== currentPath) return undefined; | ||
| 414 | try { | ||
| 415 | NodeFS.mkdirSync(childPath, { mode: 0o700 }); | ||
| 416 | NodeFS.accessSync(NodePath.join(childPath, "cgroup.procs"), NodeFS.constants.W_OK); | ||
| 417 | NodeFS.accessSync(NodePath.join(childPath, "cgroup.kill"), NodeFS.constants.W_OK); | ||
| 418 | NodeFS.accessSync(NodePath.join(childPath, "cgroup.events"), NodeFS.constants.R_OK); | ||
| 419 | if (NodeFS.readFileSync(NodePath.join(childPath, "cgroup.type"), "utf8").trim() !== "domain") { | ||
| 420 | throw new Error("ACP cgroup is not a domain cgroup"); | ||
| 421 | } | ||
| 422 | if (typeof process.execve !== "function") throw new Error("process.execve is unavailable"); | ||
| 423 | } catch { | ||
| 424 | try { | ||
| 425 | NodeFS.rmdirSync(childPath); | ||
| 426 | } catch { | ||
| 427 | // The unavailable cgroup was never used for a child process. | ||
| 428 | } | ||
| 429 | return undefined; | ||
| 430 | } | ||
| 431 | return { | ||
| 432 | contains: (pid) => | ||
| 433 | parseUnifiedCgroupPath(NodeFS.readFileSync(`/proc/${pid}/cgroup`, "utf8")) === childRelative, | ||
| 434 | exists: () => NodeFS.existsSync(childPath), | ||
| 435 | path: childPath, | ||
| 436 | relativePath: childRelative, | ||
| 437 | kill: () => NodeFS.writeFileSync(NodePath.join(childPath, "cgroup.kill"), "1\n"), | ||
| 438 | populated: () => { | ||
| 439 | const state = /(?:^|\n)populated ([01])(?:\n|$)/.exec( | ||
| 440 | NodeFS.readFileSync(NodePath.join(childPath, "cgroup.events"), "utf8"), | ||
| 441 | )?.[1]; | ||
| 442 | if (state === undefined) throw new Error("ACP cgroup.events has no populated state"); | ||
| 443 | return state === "1"; | ||
| 444 | }, | ||
| 445 | remove: () => { | ||
| 446 | const directories = (path: string): ReadonlyArray<string> => | ||
| 447 | NodeFS.readdirSync(path, { withFileTypes: true }).flatMap((entry) => | ||
| 448 | entry.isDirectory() | ||
| 449 | ? [...directories(NodePath.join(path, entry.name)), NodePath.join(path, entry.name)] | ||
| 450 | : [], | ||
| 451 | ); | ||
| 452 | for (const directory of directories(childPath)) NodeFS.rmdirSync(directory); | ||
| 453 | NodeFS.rmdirSync(childPath); | ||
| 454 | }, | ||
| 455 | }; | ||
| 456 | } | ||
| 457 | |||
| 458 | export function makeLinuxCgroupController(): AcpLinuxCgroupController { | ||
| 459 | return { | ||
| 460 | create: () => { | ||
| 461 | let currentRelative: string | undefined; | ||
| 462 | let mounts: ReadonlyArray<{ readonly mountPoint: string; readonly root: string }> = []; | ||
| 463 | try { | ||
| 464 | currentRelative = parseUnifiedCgroupPath(NodeFS.readFileSync("/proc/self/cgroup", "utf8")); | ||
| 465 | mounts = parseCgroup2Mounts(NodeFS.readFileSync("/proc/self/mountinfo", "utf8")); | ||
| 466 | } catch { | ||
| 467 | return undefined; | ||
| 468 | } | ||
| 469 | if ( | ||
| 470 | currentRelative === undefined || | ||
| 471 | !currentRelative.startsWith("/") || | ||
| 472 | mounts.length === 0 | ||
| 473 | ) { | ||
| 474 | return undefined; | ||
| 475 | } | ||
| 476 | if (!isSafeCgroupPath(currentRelative)) return undefined; | ||
| 477 | // Prefer the first mount that successfully creates a writable child cgroup. | ||
| 478 | // Multi-mount hosts often list a read-only view before the delegated one. | ||
| 479 | for (const mount of mounts) { | ||
| 480 | const lease = tryCreateLinuxCgroupLease(currentRelative, mount); | ||
| 481 | if (lease !== undefined) return lease; | ||
| 482 | } | ||
| 483 | return undefined; | ||
| 484 | }, | ||
| 485 | }; | ||
| 486 | } | ||
| 487 | |||
| 488 | // OS-state polls (cgroup emptiness, process trees) must use wall time. Under | ||
| 489 | // TestClock, Effect.sleep never resolves unless the test advances the clock. | ||
| 490 | const wallClock = Clock.Clock.defaultValue(); | ||
| 491 | const withWallClock = <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> => | ||
| 492 | Effect.provideService(effect, Clock.Clock, wallClock); | ||
| 493 | |||
| 494 | export function terminateLinuxCgroupLease( | ||
| 495 | lease: AcpLinuxCgroupLease, | ||
| 496 | ): Effect.Effect<void, AcpProcessGroupTerminationError> { | ||
| 497 | const failure = (name: string, cause: unknown) => | ||
| 498 | new AcpProcessGroupTerminationError({ | ||
| 499 | cause, | ||
| 500 | detail: `Failed to ${name} ACP cgroup ${lease.path}`, | ||
| 501 | }); | ||
| 502 | return Effect.gen(function* () { | ||
| 503 | let lastRemovalFailure: { readonly cause: unknown } | null = null; | ||
| 504 | for (let attempt = 0; attempt < 200; attempt += 1) { | ||
| 505 | if (!lease.exists()) return; | ||
| 506 | try { | ||
| 507 | lease.kill(); | ||
| 508 | } catch (cause) { | ||
| 509 | const code = (cause as NodeJS.ErrnoException | undefined)?.code; | ||
| 510 | if (code !== "ENOENT") return yield* failure("kill", cause); | ||
| 511 | if (!lease.exists()) return; | ||
| 512 | yield* Effect.sleep("10 millis"); | ||
| 513 | continue; | ||
| 514 | } | ||
| 515 | let populated: boolean; | ||
| 516 | try { | ||
| 517 | populated = lease.populated(); | ||
| 518 | } catch (cause) { | ||
| 519 | const code = (cause as NodeJS.ErrnoException | undefined)?.code; | ||
| 520 | if (code === "ENOENT" && !lease.exists()) return; | ||
| 521 | if (code === "ENOENT") { | ||
| 522 | yield* Effect.sleep("10 millis"); | ||
| 523 | continue; | ||
| 524 | } | ||
| 525 | return yield* failure("read state for", cause); | ||
| 526 | } | ||
| 527 | if (populated) { | ||
| 528 | lastRemovalFailure = null; | ||
| 529 | yield* Effect.sleep("10 millis"); | ||
| 530 | continue; | ||
| 531 | } | ||
| 532 | try { | ||
| 533 | lease.remove(); | ||
| 534 | lastRemovalFailure = null; | ||
| 535 | } catch (cause) { | ||
| 536 | const code = (cause as NodeJS.ErrnoException | undefined)?.code; | ||
| 537 | if (code === "ENOENT" && !lease.exists()) return; | ||
| 538 | if (code !== "EBUSY" && code !== "ENOTEMPTY" && code !== "ENOENT") { | ||
| 539 | return yield* failure("remove", cause); | ||
| 540 | } | ||
| 541 | lastRemovalFailure = { cause }; | ||
| 542 | } | ||
| 543 | if (!lease.exists()) return; | ||
| 544 | yield* Effect.sleep("10 millis"); | ||
| 545 | } | ||
| 546 | if (lastRemovalFailure !== null) { | ||
| 547 | return yield* failure("remove", lastRemovalFailure.cause); | ||
| 548 | } | ||
| 549 | return yield* new AcpProcessGroupTerminationError({ | ||
| 550 | detail: `ACP cgroup ${lease.path} remained present after cgroup cleanup`, | ||
| 551 | }); | ||
| 552 | }).pipe(withWallClock); | ||
| 553 | } | ||
| 554 | |||
| 555 | export function signalLinuxCgroupRootTerm(input: { | ||
| 556 | readonly controller: AcpPosixProcessTreeController; | ||
| 557 | readonly lease: AcpLinuxCgroupLease; | ||
| 558 | readonly root: AcpPosixOwnershipRoot; | ||
| 559 | }): void { | ||
| 560 | const root = input.root.value; | ||
| 561 | if (root === undefined) return; | ||
| 562 | try { | ||
| 563 | const observed = input.controller.identity(root.pid); | ||
| 564 | if (!samePosixProcessIdentity(root, observed) || !input.lease.contains(root.pid)) return; | ||
| 565 | input.controller.signalProcess(root.pid, "SIGTERM"); | ||
| 566 | } catch { | ||
| 567 | // TERM is optional. cgroup.kill remains the authoritative teardown. | ||
| 568 | } | ||
| 569 | } | ||
| 570 | |||
| 571 | function readLinuxProcessIdentity(pid: number): AcpPosixProcessIdentity | undefined { | ||
| 572 | try { | ||
| 573 | const stat = NodeFS.readFileSync(`/proc/${pid}/stat`, "utf8"); | ||
| 574 | const commandEnd = stat.lastIndexOf(")"); | ||
| 575 | if (commandEnd < 0) return undefined; | ||
| 576 | const fields = stat | ||
| 577 | .slice(commandEnd + 2) | ||
| 578 | .trim() | ||
| 579 | .split(/\s+/); | ||
| 580 | // After comm: state, ppid, pgrp, session, … starttime (field 22 / index 19). | ||
| 581 | const state = fields[0]; | ||
| 582 | const ppid = Number(fields[1]); | ||
| 583 | const pgid = Number(fields[2]); | ||
| 584 | const sid = Number(fields[3]); | ||
| 585 | const startTime = fields[19]; | ||
| 586 | if ( | ||
| 587 | state === undefined || | ||
| 588 | !Number.isSafeInteger(ppid) || | ||
| 589 | !Number.isSafeInteger(pgid) || | ||
| 590 | !Number.isSafeInteger(sid) || | ||
| 591 | startTime === undefined | ||
| 592 | ) { | ||
| 593 | return undefined; | ||
| 594 | } | ||
| 595 | let executable: string | undefined; | ||
| 596 | try { | ||
| 597 | executable = NodeFS.readlinkSync(`/proc/${pid}/exe`); | ||
| 598 | } catch { | ||
| 599 | executable = undefined; | ||
| 600 | } | ||
| 601 | return { executable, pgid, pid, ppid, sid, startTime, state }; | ||
| 602 | } catch { | ||
| 603 | return undefined; | ||
| 604 | } | ||
| 605 | } | ||
| 606 | |||
| 607 | /** Linux zombies keep pid/starttime until reaped; they are not live survivors. */ | ||
| 608 | export function posixProcessIsZombie(process: AcpPosixProcessIdentity): boolean { | ||
| 609 | return process.state === "Z"; | ||
| 610 | } | ||
| 611 | |||
| 612 | function readLinuxChildPids(pid: number): ReadonlyArray<number> { | ||
| 613 | const childPids = new Set<number>(); | ||
| 614 | let taskIds: ReadonlyArray<string> = []; | ||
| 615 | try { | ||
| 616 | taskIds = NodeFS.readdirSync(`/proc/${pid}/task`).filter((entry) => | ||
| 617 | /^[1-9][0-9]*$/.test(entry), | ||
| 618 | ); | ||
| 619 | } catch { | ||
| 620 | return []; | ||
| 621 | } | ||
| 622 | for (const taskId of taskIds) { | ||
| 623 | try { | ||
| 624 | for (const childPid of NodeFS.readFileSync(`/proc/${pid}/task/${taskId}/children`, "utf8") | ||
| 625 | .trim() | ||
| 626 | .split(/\s+/) | ||
| 627 | .filter(Boolean) | ||
| 628 | .map(Number)) { | ||
| 629 | childPids.add(childPid); | ||
| 630 | } | ||
| 631 | } catch { | ||
| 632 | // The task exited while its children were being read. | ||
| 633 | } | ||
| 634 | } | ||
| 635 | return [...childPids]; | ||
| 636 | } | ||
| 637 | |||
| 638 | function makeLinuxProcessTreeController(): AcpPosixProcessTreeController { | ||
| 639 | return { | ||
| 640 | childPidsOf: readLinuxChildPids, | ||
| 641 | childrenOf: (pid) => | ||
| 642 | readLinuxChildPids(pid).flatMap((childPid) => { | ||
| 643 | const identity = readLinuxProcessIdentity(childPid); | ||
| 644 | return identity === undefined ? [] : [identity]; | ||
| 645 | }), | ||
| 646 | identity: readLinuxProcessIdentity, | ||
| 647 | snapshot: () => | ||
| 648 | NodeFS.readdirSync("/proc", { withFileTypes: true }).flatMap((entry) => { | ||
| 649 | if (!entry.isDirectory() || !/^[1-9][0-9]*$/.test(entry.name)) return []; | ||
| 650 | const identity = readLinuxProcessIdentity(Number(entry.name)); | ||
| 651 | return identity === undefined ? [] : [identity]; | ||
| 652 | }), | ||
| 653 | signalProcess: (pid, signal) => process.kill(pid, signal), | ||
| 654 | }; | ||
| 655 | } | ||
| 656 | |||
| 657 | export function observePosixOwnershipLedger(input: { | ||
| 658 | readonly childQueues: Map<number, Array<number>>; | ||
| 659 | readonly controller: AcpPosixProcessTreeController; | ||
| 660 | readonly frontier: Map<number, AcpOwnedPosixProcess>; | ||
| 661 | readonly ledger: Map<string, AcpOwnedPosixProcess>; | ||
| 662 | readonly maxProcesses?: number; | ||
| 663 | readonly root: AcpPosixOwnershipRoot; | ||
| 664 | readonly rootPid: number; | ||
| 665 | }): void { | ||
| 666 | let rootIdentity: AcpPosixProcessIdentity | undefined = input.root.value; | ||
| 667 | if (rootIdentity === undefined) { | ||
| 668 | if (input.root.captureAttempted === true) { | ||
| 669 | throw new AcpProcessGroupTerminationError({ | ||
| 670 | detail: `ACP process ${input.rootPid} exited before its ownership ledger was captured`, | ||
| 671 | }); | ||
| 672 | } | ||
| 673 | input.root.captureAttempted = true; | ||
| 674 | rootIdentity = input.controller.identity(input.rootPid); | ||
| 675 | if (rootIdentity === undefined) { | ||
| 676 | throw new AcpProcessGroupTerminationError({ | ||
| 677 | detail: `ACP process ${input.rootPid} exited before its ownership ledger was captured`, | ||
| 678 | }); | ||
| 679 | } | ||
| 680 | const parent = input.controller.identity(rootIdentity.ppid); | ||
| 681 | const ownedRoot = { | ||
| 682 | ...rootIdentity, | ||
| 683 | parentExecutable: parent?.executable, | ||
| 684 | parentStartTime: parent?.startTime ?? "", | ||
| 685 | }; | ||
| 686 | input.ledger.set(processIdentityKey(rootIdentity), ownedRoot); | ||
| 687 | input.frontier.set(rootIdentity.pid, ownedRoot); | ||
| 688 | input.root.value = ownedRoot; | ||
| 689 | } | ||
| 690 | const maxProcesses = Math.max(1, input.maxProcesses ?? Number.POSITIVE_INFINITY); | ||
| 691 | let remainingBudget = maxProcesses; | ||
| 692 | const pending = [...input.frontier.entries()]; | ||
| 693 | const visited = new Set<number>(); | ||
| 694 | while (pending.length > 0 && remainingBudget > 0) { | ||
| 695 | const entry = pending.shift(); | ||
| 696 | if (entry === undefined) continue; | ||
| 697 | const [parentPid, parent] = entry; | ||
| 698 | input.frontier.delete(parentPid); | ||
| 699 | if (visited.has(parent.pid)) continue; | ||
| 700 | visited.add(parent.pid); | ||
| 701 | remainingBudget -= 1; | ||
| 702 | const observedParent = input.controller.identity(parent.pid); | ||
| 703 | if (!samePosixProcessIdentity(parent, observedParent)) { | ||
| 704 | input.childQueues.delete(parent.pid); | ||
| 705 | continue; | ||
| 706 | } | ||
| 707 | const refreshedParent = { ...parent, ...observedParent }; | ||
| 708 | input.ledger.set(processIdentityKey(refreshedParent), refreshedParent); | ||
| 709 | input.frontier.set(refreshedParent.pid, refreshedParent); | ||
| 710 | let queuedChildren = input.childQueues.get(refreshedParent.pid); | ||
| 711 | if (queuedChildren === undefined) { | ||
| 712 | if (remainingBudget === 0) continue; | ||
| 713 | remainingBudget -= 1; | ||
| 714 | queuedChildren = [...input.controller.childPidsOf(refreshedParent.pid)]; | ||
| 715 | } | ||
| 716 | while (queuedChildren.length > 0 && remainingBudget > 0) { | ||
| 717 | const childPid = queuedChildren.shift(); | ||
| 718 | if (childPid === undefined) continue; | ||
| 719 | remainingBudget -= 1; | ||
| 720 | const child = input.controller.identity(childPid); | ||
| 721 | if (child === undefined) continue; | ||
| 722 | if (child.ppid !== parent.pid) continue; | ||
| 723 | let reservedPid = input.frontier.get(child.pid); | ||
| 724 | if (reservedPid !== undefined && !samePosixProcessIdentity(reservedPid, child)) { | ||
| 725 | // PID reused under an owned parent; drop the stale reservation. | ||
| 726 | input.frontier.delete(child.pid); | ||
| 727 | for (const [key, owned] of input.ledger.entries()) { | ||
| 728 | if (owned.pid === child.pid) input.ledger.delete(key); | ||
| 729 | } | ||
| 730 | reservedPid = undefined; | ||
| 731 | } | ||
| 732 | const childKey = processIdentityKey(child); | ||
| 733 | const owned = | ||
| 734 | reservedPid === undefined | ||
| 735 | ? { | ||
| 736 | ...child, | ||
| 737 | parentExecutable: refreshedParent.executable, | ||
| 738 | parentStartTime: refreshedParent.startTime, | ||
| 739 | } | ||
| 740 | : { ...reservedPid, ...child }; | ||
| 741 | input.ledger.set(childKey, owned); | ||
| 742 | input.frontier.set(child.pid, owned); | ||
| 743 | } | ||
| 744 | if (queuedChildren.length === 0) input.childQueues.delete(refreshedParent.pid); | ||
| 745 | else input.childQueues.set(refreshedParent.pid, queuedChildren); | ||
| 746 | } | ||
| 747 | } | ||
| 748 | |||
| 749 | export function observePosixOwnershipLedgerContinuously(input: { | ||
| 750 | readonly childQueues: Map<number, Array<number>>; | ||
| 751 | readonly controller: AcpPosixProcessTreeController; | ||
| 752 | readonly frontier: Map<number, AcpOwnedPosixProcess>; | ||
| 753 | readonly ledger: Map<string, AcpOwnedPosixProcess>; | ||
| 754 | readonly root: AcpPosixOwnershipRoot; | ||
| 755 | readonly rootPid: number; | ||
| 756 | }): Effect.Effect<never> { | ||
| 757 | return Effect.forever( | ||
| 758 | Effect.gen(function* () { | ||
| 759 | const startedAt = yield* Clock.currentTimeMillis; | ||
| 760 | yield* Effect.sync(() => observePosixOwnershipLedger({ ...input, maxProcesses: 64 })).pipe( | ||
| 761 | Effect.exit, | ||
| 762 | ); | ||
| 763 | const targetInterval = input.frontier.size <= 32 ? 25 : input.frontier.size <= 128 ? 50 : 100; | ||
| 764 | const completedAt = yield* Clock.currentTimeMillis; | ||
| 765 | yield* Effect.sleep(`${Math.max(5, targetInterval - (completedAt - startedAt))} millis`); | ||
| 766 | }), | ||
| 767 | ).pipe(withWallClock); | ||
| 768 | } | ||
| 769 | |||
| 770 | export function makePosixProcessTreeController( | ||
| 771 | platform: NodeJS.Platform, | ||
| 772 | ): AcpPosixProcessTreeController { | ||
| 773 | if (platform === "linux") return makeLinuxProcessTreeController(); | ||
| 774 | throw new AcpProcessGroupTerminationError({ | ||
| 775 | detail: `Detached ACP descendant ownership is unsupported on ${platform}`, | ||
| 776 | }); | ||
| 777 | } | ||
| 778 | |||
| 779 | function samePosixProcessIdentity( | ||
| 780 | expected: AcpPosixProcessIdentity, | ||
| 781 | observed: AcpPosixProcessIdentity | undefined, | ||
| 782 | ): boolean { | ||
| 783 | return ( | ||
| 784 | observed !== undefined && | ||
| 785 | observed.pid === expected.pid && | ||
| 786 | observed.startTime === expected.startTime | ||
| 787 | ); | ||
| 788 | } | ||
| 789 | |||
| 790 | export interface AcpOwnedPosixProcess extends AcpPosixProcessIdentity { | ||
| 791 | readonly parentExecutable: string | undefined; | ||
| 792 | readonly parentStartTime: string; | ||
| 793 | } | ||
| 794 | |||
| 795 | function processIdentityKey(process: AcpPosixProcessIdentity): string { | ||
| 796 | return `${process.pid}:${process.startTime}`; | ||
| 797 | } | ||
| 798 | |||
| 799 | function processDepth( | ||
| 800 | process: AcpPosixProcessIdentity, | ||
| 801 | ledgerByPid: ReadonlyMap<number, AcpOwnedPosixProcess>, | ||
| 802 | ): number { | ||
| 803 | let depth = 0; | ||
| 804 | let current = process; | ||
| 805 | const seen = new Set<number>(); | ||
| 806 | while (!seen.has(current.pid)) { | ||
| 807 | seen.add(current.pid); | ||
| 808 | const parent = ledgerByPid.get(current.ppid); | ||
| 809 | if (parent === undefined) break; | ||
| 810 | depth += 1; | ||
| 811 | current = parent; | ||
| 812 | } | ||
| 813 | return depth; | ||
| 814 | } | ||
| 815 | |||
| 816 | export function capturePosixOwnershipLedger(input: { | ||
| 817 | readonly controller: AcpPosixProcessTreeController; | ||
| 818 | readonly ledger: Map<string, AcpOwnedPosixProcess>; | ||
| 819 | readonly root?: AcpPosixOwnershipRoot; | ||
| 820 | readonly rootPid: number; | ||
| 821 | readonly table?: ReadonlyArray<AcpPosixProcessIdentity>; | ||
| 822 | }): ReadonlyArray<AcpPosixProcessIdentity> { | ||
| 823 | let table: ReadonlyArray<AcpPosixProcessIdentity>; | ||
| 824 | if (input.table === undefined) { | ||
| 825 | try { | ||
| 826 | table = input.controller.snapshot(); | ||
| 827 | } catch (cause) { | ||
| 828 | throw new AcpProcessGroupTerminationError({ | ||
| 829 | cause, | ||
| 830 | detail: `Failed to snapshot ACP process tree ${input.rootPid}`, | ||
| 831 | }); | ||
| 832 | } | ||
| 833 | } else { | ||
| 834 | table = input.table; | ||
| 835 | } | ||
| 836 | const byPid = new Map(table.map((process) => [process.pid, process])); | ||
| 837 | let rootIdentity: AcpPosixProcessIdentity | undefined = | ||
| 838 | input.root?.value ?? | ||
| 839 | [...input.ledger.values()].find((process) => process.pid === input.rootPid); | ||
| 840 | if (rootIdentity === undefined) { | ||
| 841 | rootIdentity = byPid.get(input.rootPid); | ||
| 842 | if (rootIdentity === undefined) { | ||
| 843 | throw new AcpProcessGroupTerminationError({ | ||
| 844 | detail: `ACP process ${input.rootPid} exited before its ownership ledger was captured`, | ||
| 845 | }); | ||
| 846 | } | ||
| 847 | const ownedRoot = { | ||
| 848 | ...rootIdentity, | ||
| 849 | parentExecutable: byPid.get(rootIdentity.ppid)?.executable, | ||
| 850 | parentStartTime: byPid.get(rootIdentity.ppid)?.startTime ?? "", | ||
| 851 | }; | ||
| 852 | input.ledger.set(processIdentityKey(rootIdentity), ownedRoot); | ||
| 853 | if (input.root !== undefined) input.root.value = ownedRoot; | ||
| 854 | } | ||
| 855 | // Drop non-root ledger entries whose PID is free or whose identity no longer | ||
| 856 | // matches the live process at that PID. Either case can block admission of a | ||
| 857 | // legitimate child after PID reuse. Keep the root reservation even if the | ||
| 858 | // root briefly disappears mid-teardown (finalizer retry still needs it). | ||
| 859 | for (const [key, owned] of input.ledger.entries()) { | ||
| 860 | if (owned.pid === input.rootPid) continue; | ||
| 861 | const live = byPid.get(owned.pid); | ||
| 862 | if (live === undefined || !samePosixProcessIdentity(owned, live)) { | ||
| 863 | input.ledger.delete(key); | ||
| 864 | } | ||
| 865 | } | ||
| 866 | const children = new Map<number, Array<AcpPosixProcessIdentity>>(); | ||
| 867 | const retainedByPid = new Map( | ||
| 868 | [...input.ledger.values()] | ||
| 869 | .filter((owned) => samePosixProcessIdentity(owned, byPid.get(owned.pid))) | ||
| 870 | .map((owned) => [owned.pid, owned]), | ||
| 871 | ); | ||
| 872 | for (const process of table) { | ||
| 873 | const siblings = children.get(process.ppid) ?? []; | ||
| 874 | siblings.push(process); | ||
| 875 | children.set(process.ppid, siblings); | ||
| 876 | } | ||
| 877 | const pending = [...input.ledger.values()].filter((owned) => | ||
| 878 | samePosixProcessIdentity(owned, byPid.get(owned.pid)), | ||
| 879 | ); | ||
| 880 | const visited = new Set<number>(); | ||
| 881 | while (pending.length > 0) { | ||
| 882 | const parent = pending.shift(); | ||
| 883 | if (parent === undefined || visited.has(parent.pid)) continue; | ||
| 884 | visited.add(parent.pid); | ||
| 885 | const observedParent = byPid.get(parent.pid); | ||
| 886 | if (!samePosixProcessIdentity(parent, observedParent)) continue; | ||
| 887 | const refreshedParent = { ...parent, ...observedParent }; | ||
| 888 | input.ledger.set(processIdentityKey(parent), refreshedParent); | ||
| 889 | for (const process of children.get(refreshedParent.pid) ?? []) { | ||
| 890 | const reservedPid = retainedByPid.get(process.pid); | ||
| 891 | if (reservedPid !== undefined && !samePosixProcessIdentity(reservedPid, process)) continue; | ||
| 892 | const owned = | ||
| 893 | reservedPid === undefined | ||
| 894 | ? { | ||
| 895 | ...process, | ||
| 896 | parentExecutable: refreshedParent.executable, | ||
| 897 | parentStartTime: refreshedParent.startTime, | ||
| 898 | } | ||
| 899 | : { ...reservedPid, ...process }; | ||
| 900 | input.ledger.set(processIdentityKey(process), owned); | ||
| 901 | retainedByPid.set(process.pid, owned); | ||
| 902 | pending.push(owned); | ||
| 903 | } | ||
| 904 | } | ||
| 905 | return table; | ||
| 906 | } | ||
| 907 | |||
| 908 | export function terminatePosixOwnedProcessTree(input: { | ||
| 909 | readonly controller: AcpPosixProcessTreeController; | ||
| 910 | readonly discoveryPasses?: number; | ||
| 911 | readonly grace?: Duration.Input; | ||
| 912 | readonly ledger?: Map<string, AcpOwnedPosixProcess>; | ||
| 913 | readonly root?: AcpPosixOwnershipRoot; | ||
| 914 | readonly rootPid: number; | ||
| 915 | }): Effect.Effect<void, AcpProcessGroupTerminationError> { | ||
| 916 | // This portable fallback can only admit processes whose exact parent chain is | ||
| 917 | // visible before it breaks. A descendant that double-forks before discovery | ||
| 918 | // requires pre-exec cgroup containment for a hard ownership guarantee. | ||
| 919 | const ledger = input.ledger ?? new Map<string, AcpOwnedPosixProcess>(); | ||
| 920 | |||
| 921 | const fail = (detail: string, cause?: unknown) => | ||
| 922 | new AcpProcessGroupTerminationError({ detail, ...(cause === undefined ? {} : { cause }) }); | ||
| 923 | const snapshot = () => { | ||
| 924 | try { | ||
| 925 | return input.controller.snapshot(); | ||
| 926 | } catch (cause) { | ||
| 927 | throw fail(`Failed to snapshot ACP process tree ${input.rootPid}`, cause); | ||
| 928 | } | ||
| 929 | }; | ||
| 930 | const discover = (table: ReadonlyArray<AcpPosixProcessIdentity>) => | ||
| 931 | capturePosixOwnershipLedger({ | ||
| 932 | controller: input.controller, | ||
| 933 | ledger, | ||
| 934 | ...(input.root === undefined ? {} : { root: input.root }), | ||
| 935 | rootPid: input.rootPid, | ||
| 936 | table, | ||
| 937 | }); | ||
| 938 | const signalPhase = (signal: NodeJS.Signals, includeRoot: boolean) => { | ||
| 939 | const table = snapshot(); | ||
| 940 | discover(table); | ||
| 941 | const byPid = new Map(table.map((entry) => [entry.pid, entry])); | ||
| 942 | const current = input.controller.identity(process.pid); | ||
| 943 | if (current === undefined) throw fail("Cannot identify the current T3 process group"); | ||
| 944 | const ledgerByPid = new Map( | ||
| 945 | [...ledger.values()].map((process) => [process.pid, process] as const), | ||
| 946 | ); | ||
| 947 | const pending = [...ledger.values()].filter( | ||
| 948 | (candidate) => | ||
| 949 | (includeRoot || candidate.pid !== input.rootPid) && | ||
| 950 | samePosixProcessIdentity(candidate, byPid.get(candidate.pid)), | ||
| 951 | ); | ||
| 952 | const retainedByPid = new Map( | ||
| 953 | [...ledger.values()] | ||
| 954 | .filter((owned) => samePosixProcessIdentity(owned, byPid.get(owned.pid))) | ||
| 955 | .map((owned) => [owned.pid, owned]), | ||
| 956 | ); | ||
| 957 | const signalled = new Set<number>(); | ||
| 958 | while (pending.length > 0) { | ||
| 959 | pending.sort( | ||
| 960 | (left, right) => processDepth(right, ledgerByPid) - processDepth(left, ledgerByPid), | ||
| 961 | ); | ||
| 962 | const candidate = pending.shift(); | ||
| 963 | if (candidate === undefined || signalled.has(candidate.pid)) continue; | ||
| 964 | const observed = input.controller.identity(candidate.pid); | ||
| 965 | if ( | ||
| 966 | candidate.pid <= 1 || | ||
| 967 | candidate.pid === process.pid || | ||
| 968 | observed === undefined || | ||
| 969 | observed.pgid === current.pgid || | ||
| 970 | observed.sid === current.sid || | ||
| 971 | !samePosixProcessIdentity(candidate, observed) | ||
| 972 | ) { | ||
| 973 | continue; | ||
| 974 | } | ||
| 975 | for (const child of input.controller.childrenOf(candidate.pid)) { | ||
| 976 | if (child.ppid !== candidate.pid) continue; | ||
| 977 | const reservedPid = retainedByPid.get(child.pid); | ||
| 978 | if (reservedPid !== undefined && !samePosixProcessIdentity(reservedPid, child)) continue; | ||
| 979 | const owned = | ||
| 980 | reservedPid === undefined | ||
| 981 | ? { | ||
| 982 | ...child, | ||
| 983 | parentExecutable: observed.executable, | ||
| 984 | parentStartTime: observed.startTime, | ||
| 985 | } | ||
| 986 | : { ...reservedPid, ...child }; | ||
| 987 | ledger.set(processIdentityKey(child), owned); | ||
| 988 | ledgerByPid.set(child.pid, owned); | ||
| 989 | retainedByPid.set(child.pid, owned); | ||
| 990 | // Signal newly discovered children in this same pass (fork-during-kill). | ||
| 991 | if ( | ||
| 992 | (includeRoot || child.pid !== input.rootPid) && | ||
| 993 | !signalled.has(child.pid) && | ||
| 994 | samePosixProcessIdentity(owned, input.controller.identity(child.pid)) | ||
| 995 | ) { | ||
| 996 | pending.push(owned); | ||
| 997 | } | ||
| 998 | } | ||
| 999 | try { | ||
| 1000 | // Linux starttime prevents PID reuse across observations, but Node does | ||
| 1001 | // not expose pidfd_send_signal. A final read-to-kill race remains. | ||
| 1002 | input.controller.signalProcess(candidate.pid, signal); | ||
| 1003 | signalled.add(candidate.pid); | ||
| 1004 | } catch (cause) { | ||
| 1005 | if (samePosixProcessIdentity(candidate, input.controller.identity(candidate.pid))) { | ||
| 1006 | throw fail(`Failed to signal ACP process ${candidate.pid}`, cause); | ||
| 1007 | } | ||
| 1008 | } | ||
| 1009 | } | ||
| 1010 | }; | ||
| 1011 | |||
| 1012 | return Effect.gen(function* () { | ||
| 1013 | const passes = Math.max(2, Math.min(input.discoveryPasses ?? 4, 8)); | ||
| 1014 | for (let pass = 0; pass < passes; pass += 1) { | ||
| 1015 | yield* Effect.try({ | ||
| 1016 | try: () => signalPhase("SIGTERM", false), | ||
| 1017 | catch: (cause) => cause as AcpProcessGroupTerminationError, | ||
| 1018 | }); | ||
| 1019 | yield* Effect.sleep("10 millis"); | ||
| 1020 | } | ||
| 1021 | yield* Effect.try({ | ||
| 1022 | try: () => signalPhase("SIGTERM", true), | ||
| 1023 | catch: (cause) => cause as AcpProcessGroupTerminationError, | ||
| 1024 | }); | ||
| 1025 | const grace = input.grace ?? "1 second"; | ||
| 1026 | if (grace !== 0) yield* Effect.sleep(grace); | ||
| 1027 | for (let pass = 0; pass < passes; pass += 1) { | ||
| 1028 | yield* Effect.try({ | ||
| 1029 | try: () => signalPhase("SIGKILL", true), | ||
| 1030 | catch: (cause) => cause as AcpProcessGroupTerminationError, | ||
| 1031 | }); | ||
| 1032 | yield* Effect.sleep("10 millis"); | ||
| 1033 | } | ||
| 1034 | const finalTable = snapshot(); | ||
| 1035 | discover(finalTable); | ||
| 1036 | const survivors = new Map(finalTable.map((entry) => [entry.pid, entry])); | ||
| 1037 | const residual = [...ledger.values()].filter((owned) => { | ||
| 1038 | const observed = survivors.get(owned.pid); | ||
| 1039 | if (!samePosixProcessIdentity(owned, observed) || observed === undefined) { | ||
| 1040 | return false; | ||
| 1041 | } | ||
| 1042 | // Already-killed zombies still appear in /proc until reaped; not live. | ||
| 1043 | return !posixProcessIsZombie(observed); | ||
| 1044 | }); | ||
| 1045 | if (residual.length > 0) { | ||
| 1046 | return yield* fail( | ||
| 1047 | `ACP process tree ${input.rootPid} retained owned processes: ${residual.map((entry) => entry.pid).join(", ")}`, | ||
| 1048 | ); | ||
| 1049 | } | ||
| 1050 | }).pipe(withWallClock); | ||
| 1051 | } | ||
| 1052 | |||
| 1053 | export function windowsTaskkillResultIsSuccess(exitCode: number, _output?: string): boolean { | ||
| 1054 | return exitCode === 0; | ||
| 1055 | } | ||
| 1056 | |||
| 1057 | export const terminateWindowsProcessTreeWithTaskkill = ( | ||
| 1058 | spawner: ChildProcessSpawner.ChildProcessSpawner["Service"], | ||
| 1059 | pid: number, | ||
| 1060 | ): Effect.Effect<void, AcpProcessGroupTerminationError> => | ||
| 1061 | Effect.scoped( | ||
| 1062 | Effect.gen(function* () { | ||
| 1063 | const taskkill = yield* spawner.spawn( | ||
| 1064 | ChildProcess.make("taskkill", ["/PID", String(pid), "/T", "/F"]), | ||
| 1065 | ); | ||
| 1066 | const outputFiber = yield* Stream.decodeText(taskkill.all).pipe( | ||
| 1067 | Stream.mkString, | ||
| 1068 | Effect.forkScoped, | ||
| 1069 | ); | ||
| 1070 | const exitCode = Number(yield* taskkill.exitCode); | ||
| 1071 | const output = yield* Fiber.join(outputFiber); | ||
| 1072 | // A missing leader cannot prove that inherited descendants exited. | ||
| 1073 | if (windowsTaskkillResultIsSuccess(exitCode, output)) return; | ||
| 1074 | const trimmedOutput = output.trim(); | ||
| 1075 | return yield* new AcpProcessGroupTerminationError({ | ||
| 1076 | detail: `taskkill exited ${exitCode} for ACP process tree ${pid}`, | ||
| 1077 | pid, | ||
| 1078 | exitCode, | ||
| 1079 | ...(trimmedOutput.length === 0 | ||
| 1080 | ? {} | ||
| 1081 | : { cause: trimmedOutput.length > 500 ? trimmedOutput.slice(0, 500) : trimmedOutput }), | ||
| 1082 | }); | ||
| 1083 | }), | ||
| 1084 | ).pipe( | ||
| 1085 | Effect.mapError((cause) => | ||
| 1086 | isAcpProcessGroupTerminationError(cause) | ||
| 1087 | ? cause | ||
| 1088 | : new AcpProcessGroupTerminationError({ | ||
| 1089 | cause, | ||
| 1090 | detail: `Failed to run taskkill for ACP process tree ${pid}`, | ||
| 1091 | pid, | ||
| 1092 | }), | ||
| 1093 | ), | ||
| 1094 | ); | ||
| 1095 | |||
| 1096 | export function selectAcpAgentAuthMethod( | ||
| 1097 | authMethods: ReadonlyArray<EffectAcpSchema.AuthMethod> | undefined, | ||
| 1098 | preferredMethodId?: string, | ||
| 1099 | ): EffectAcpSchema.AuthMethod | undefined { | ||
| 1100 | const preferred = preferredMethodId?.trim(); | ||
| 1101 | if (preferred) { | ||
| 1102 | return authMethods?.find((method) => method.id === preferred); | ||
| 1103 | } | ||
| 1104 | return authMethods?.find((method) => method.type === undefined || method.type === "agent"); | ||
| 1105 | } | ||
| 1106 | |||
| 1107 | function isAcpAuthenticationRequired(error: EffectAcpErrors.AcpError): boolean { | ||
| 1108 | return error._tag === "AcpRequestError" && error.code === -32000; | ||
| 1109 | } | ||
| 1110 | |||
| 1111 | export interface AcpSessionRuntimeStartResult { | ||
| 1112 | readonly sessionId: string; | ||
| 1113 | readonly initializeResult: EffectAcpSchema.InitializeResponse; | ||
| 1114 | readonly sessionSetupResult: | ||
| 1115 | | EffectAcpSchema.ForkSessionResponse | ||
| 1116 | | EffectAcpSchema.LoadSessionResponse | ||
| 1117 | | EffectAcpSchema.NewSessionResponse | ||
| 1118 | | EffectAcpSchema.ResumeSessionResponse; | ||
| 1119 | readonly modelConfigId: string | undefined; | ||
| 1120 | } | ||
| 1121 | |||
| 1122 | export interface AcpSessionActivationOptions { | ||
| 1123 | readonly mcpServers?: ReadonlyArray<EffectAcpSchema.McpServer>; | ||
| 1124 | readonly acpMcpServers?: ReadonlyArray<EffectAcpSchema.McpServer>; | ||
| 1125 | } | ||
| 1126 | |||
| 1127 | export class AcpSessionRuntime extends Context.Service< | ||
| 1128 | AcpSessionRuntime, | ||
| 1129 | { | ||
| 1130 | /** | ||
| 1131 | * Registers a handler for `session/request_permission`. | ||
| 1132 | * @see https://agentclientprotocol.com/protocol/schema#session/request_permission | ||
| 1133 | */ | ||
| 1134 | readonly handleRequestPermission: EffectAcpClient.AcpClient["Service"]["handleRequestPermission"]; | ||
| 1135 | /** | ||
| 1136 | * Registers a handler for `session/elicitation`. | ||
| 1137 | * @see https://agentclientprotocol.com/protocol/schema#session/elicitation | ||
| 1138 | */ | ||
| 1139 | readonly handleElicitation: EffectAcpClient.AcpClient["Service"]["handleElicitation"]; | ||
| 1140 | readonly handleMcpConnect: EffectAcpClient.AcpClient["Service"]["handleMcpConnect"]; | ||
| 1141 | readonly handleMcpMessage: EffectAcpClient.AcpClient["Service"]["handleMcpMessage"]; | ||
| 1142 | readonly handleMcpDisconnect: EffectAcpClient.AcpClient["Service"]["handleMcpDisconnect"]; | ||
| 1143 | readonly handleMcpNotification: EffectAcpClient.AcpClient["Service"]["handleMcpNotification"]; | ||
| 1144 | /** | ||
| 1145 | * Registers a handler for `fs/read_text_file`. | ||
| 1146 | * @see https://agentclientprotocol.com/protocol/schema#fs/read_text_file | ||
| 1147 | */ | ||
| 1148 | readonly handleReadTextFile: EffectAcpClient.AcpClient["Service"]["handleReadTextFile"]; | ||
| 1149 | /** | ||
| 1150 | * Registers a handler for `fs/write_text_file`. | ||
| 1151 | * @see https://agentclientprotocol.com/protocol/schema#fs/write_text_file | ||
| 1152 | */ | ||
| 1153 | readonly handleWriteTextFile: EffectAcpClient.AcpClient["Service"]["handleWriteTextFile"]; | ||
| 1154 | /** | ||
| 1155 | * Registers a handler for `terminal/create`. | ||
| 1156 | * @see https://agentclientprotocol.com/protocol/schema#terminal/create | ||
| 1157 | */ | ||
| 1158 | readonly handleCreateTerminal: EffectAcpClient.AcpClient["Service"]["handleCreateTerminal"]; | ||
| 1159 | /** | ||
| 1160 | * Registers a handler for `terminal/output`. | ||
| 1161 | * @see https://agentclientprotocol.com/protocol/schema#terminal/output | ||
| 1162 | */ | ||
| 1163 | readonly handleTerminalOutput: EffectAcpClient.AcpClient["Service"]["handleTerminalOutput"]; | ||
| 1164 | /** | ||
| 1165 | * Registers a handler for `terminal/wait_for_exit`. | ||
| 1166 | * @see https://agentclientprotocol.com/protocol/schema#terminal/wait_for_exit | ||
| 1167 | */ | ||
| 1168 | readonly handleTerminalWaitForExit: EffectAcpClient.AcpClient["Service"]["handleTerminalWaitForExit"]; | ||
| 1169 | /** | ||
| 1170 | * Registers a handler for `terminal/kill`. | ||
| 1171 | * @see https://agentclientprotocol.com/protocol/schema#terminal/kill | ||
| 1172 | */ | ||
| 1173 | readonly handleTerminalKill: EffectAcpClient.AcpClient["Service"]["handleTerminalKill"]; | ||
| 1174 | /** | ||
| 1175 | * Registers a handler for `terminal/release`. | ||
| 1176 | * @see https://agentclientprotocol.com/protocol/schema#terminal/release | ||
| 1177 | */ | ||
| 1178 | readonly handleTerminalRelease: EffectAcpClient.AcpClient["Service"]["handleTerminalRelease"]; | ||
| 1179 | /** | ||
| 1180 | * Registers a handler for `session/update`. | ||
| 1181 | * @see https://agentclientprotocol.com/protocol/schema#session/update | ||
| 1182 | */ | ||
| 1183 | readonly handleSessionUpdate: EffectAcpClient.AcpClient["Service"]["handleSessionUpdate"]; | ||
| 1184 | /** | ||
| 1185 | * Registers a handler for `session/elicitation/complete`. | ||
| 1186 | * @see https://agentclientprotocol.com/protocol/schema#session/elicitation/complete | ||
| 1187 | */ | ||
| 1188 | readonly handleElicitationComplete: EffectAcpClient.AcpClient["Service"]["handleElicitationComplete"]; | ||
| 1189 | /** | ||
| 1190 | * Registers a fallback extension request handler. | ||
| 1191 | * @see https://agentclientprotocol.com/protocol/extensibility | ||
| 1192 | */ | ||
| 1193 | readonly handleUnknownExtRequest: EffectAcpClient.AcpClient["Service"]["handleUnknownExtRequest"]; | ||
| 1194 | /** | ||
| 1195 | * Registers a fallback extension notification handler. | ||
| 1196 | * @see https://agentclientprotocol.com/protocol/extensibility | ||
| 1197 | */ | ||
| 1198 | readonly handleUnknownExtNotification: EffectAcpClient.AcpClient["Service"]["handleUnknownExtNotification"]; | ||
| 1199 | /** | ||
| 1200 | * Registers a typed extension request handler. | ||
| 1201 | * @see https://agentclientprotocol.com/protocol/extensibility | ||
| 1202 | */ | ||
| 1203 | readonly handleExtRequest: EffectAcpClient.AcpClient["Service"]["handleExtRequest"]; | ||
| 1204 | /** | ||
| 1205 | * Registers a typed extension notification handler. | ||
| 1206 | * @see https://agentclientprotocol.com/protocol/extensibility | ||
| 1207 | */ | ||
| 1208 | readonly handleExtNotification: EffectAcpClient.AcpClient["Service"]["handleExtNotification"]; | ||
| 1209 | /** | ||
| 1210 | * Sends only `initialize` and returns the agent's response. Health probes use this to read | ||
| 1211 | * advertised capabilities without authenticating or opening a session, so a probe can never | ||
| 1212 | * start an interactive login or boot MCP servers. | ||
| 1213 | * @see https://agentclientprotocol.com/protocol/schema#initialize | ||
| 1214 | */ | ||
| 1215 | readonly initialize: () => Effect.Effect< | ||
| 1216 | EffectAcpSchema.InitializeResponse, | ||
| 1217 | EffectAcpErrors.AcpError | ||
| 1218 | >; | ||
| 1219 | /** Explicit login uses the negotiated v1 authenticate / v2 auth/login operation. */ | ||
| 1220 | readonly authenticate?: (methodId: string) => Effect.Effect<void, EffectAcpErrors.AcpError>; | ||
| 1221 | /** | ||
| 1222 | * Initializes the ACP connection, authenticates, and loads, resumes, or creates the session. | ||
| 1223 | * Concurrent calls share the same in-flight startup and a failed startup may be retried. | ||
| 1224 | */ | ||
| 1225 | readonly start: () => Effect.Effect<AcpSessionRuntimeStartResult, EffectAcpErrors.AcpError>; | ||
| 1226 | /** Stream of parsed root-session events and connection failures. */ | ||
| 1227 | readonly getEvents: () => Stream.Stream<AcpSessionRuntimeEvent, never>; | ||
| 1228 | /** Waits for queued events to be processed, or for the runtime scope to close. */ | ||
| 1229 | readonly drainEvents: Effect.Effect<void>; | ||
| 1230 | /** Latest mode state observed from session setup and `session/update` notifications. */ | ||
| 1231 | readonly getModeState: Effect.Effect<AcpSessionModeState | undefined>; | ||
| 1232 | /** Latest configuration options observed from session setup and configuration writes. */ | ||
| 1233 | readonly getConfigOptions: Effect.Effect<ReadonlyArray<EffectAcpSchema.SessionConfigOption>>; | ||
| 1234 | readonly loadSession: ( | ||
| 1235 | sessionId: string, | ||
| 1236 | options?: AcpSessionActivationOptions, | ||
| 1237 | ) => Effect.Effect<AcpSessionRuntimeStartResult, EffectAcpErrors.AcpError>; | ||
| 1238 | readonly resumeSession: ( | ||
| 1239 | sessionId: string, | ||
| 1240 | options?: AcpSessionActivationOptions, | ||
| 1241 | ) => Effect.Effect<AcpSessionRuntimeStartResult, EffectAcpErrors.AcpError>; | ||
| 1242 | readonly forkSession: ( | ||
| 1243 | sessionId: string, | ||
| 1244 | options?: AcpSessionActivationOptions, | ||
| 1245 | ) => Effect.Effect<AcpSessionRuntimeStartResult, EffectAcpErrors.AcpError>; | ||
| 1246 | readonly listSessions: ( | ||
| 1247 | cursor?: string, | ||
| 1248 | ) => Effect.Effect<EffectAcpSchema.ListSessionsResponse, EffectAcpErrors.AcpError>; | ||
| 1249 | readonly closeSession: ( | ||
| 1250 | sessionId?: string, | ||
| 1251 | ) => Effect.Effect<EffectAcpSchema.CloseSessionResponse, EffectAcpErrors.AcpError>; | ||
| 1252 | readonly deleteSession: ( | ||
| 1253 | sessionId: string, | ||
| 1254 | ) => Effect.Effect<EffectAcpSchema.DeleteSessionResponse, EffectAcpErrors.AcpError>; | ||
| 1255 | readonly listProviders: Effect.Effect< | ||
| 1256 | EffectAcpSchema.ListProvidersResponse, | ||
| 1257 | EffectAcpErrors.AcpError | ||
| 1258 | >; | ||
| 1259 | readonly setProvider: ( | ||
| 1260 | provider: EffectAcpSchema.SetProviderRequest, | ||
| 1261 | ) => Effect.Effect<EffectAcpSchema.SetProviderResponse, EffectAcpErrors.AcpError>; | ||
| 1262 | readonly disableProvider: ( | ||
| 1263 | providerId: string, | ||
| 1264 | ) => Effect.Effect<EffectAcpSchema.DisableProviderResponse, EffectAcpErrors.AcpError>; | ||
| 1265 | /** Logs out the current ACP identity when the agent advertises support. */ | ||
| 1266 | readonly logout: Effect.Effect<EffectAcpSchema.LogoutResponse, EffectAcpErrors.AcpError>; | ||
| 1267 | /** | ||
| 1268 | * Sends a prompt turn to the active session. `options.dispatched` settles once the | ||
| 1269 | * `session/prompt` RPC is registered as the active prompt, so a caller that forks this | ||
| 1270 | * effect knows when a later `cancel` will target this prompt. | ||
| 1271 | * @see https://agentclientprotocol.com/protocol/schema#session/prompt | ||
| 1272 | */ | ||
| 1273 | readonly prompt: ( | ||
| 1274 | payload: Omit<EffectAcpSchema.PromptRequest, "sessionId">, | ||
| 1275 | options?: { readonly dispatched?: Deferred.Deferred<void> }, | ||
| 1276 | ) => Effect.Effect<EffectAcpSchema.PromptResponse, EffectAcpErrors.AcpError>; | ||
| 1277 | /** | ||
| 1278 | * Sends a real ACP `session/cancel` notification for the active session. | ||
| 1279 | * @see https://agentclientprotocol.com/protocol/schema#session/cancel | ||
| 1280 | */ | ||
| 1281 | readonly cancel: Effect.Effect<void, EffectAcpErrors.AcpError>; | ||
| 1282 | readonly processContainment: | ||
| 1283 | | "cgroup-v2" | ||
| 1284 | | "process-ledger-reduced-guarantee" | ||
| 1285 | | "process-group" | ||
| 1286 | | "none"; | ||
| 1287 | /** Terminates the opt-in detached ACP process group and all inherited work. */ | ||
| 1288 | readonly terminateProcessGroup?: Effect.Effect<void, AcpProcessGroupTerminationError>; | ||
| 1289 | /** | ||
| 1290 | * Selects the active mode through the negotiated `mode` configuration option. | ||
| 1291 | * This is a no-op when the requested mode is already active. | ||
| 1292 | * @see https://agentclientprotocol.com/protocol/schema#session/set_config_option | ||
| 1293 | */ | ||
| 1294 | readonly setMode: ( | ||
| 1295 | modeId: string, | ||
| 1296 | ) => Effect.Effect<EffectAcpSchema.SetSessionModeResponse, EffectAcpErrors.AcpError>; | ||
| 1297 | /** | ||
| 1298 | * Updates a session configuration option and the runtime configuration snapshot. | ||
| 1299 | * @see https://agentclientprotocol.com/protocol/schema#session/set_config_option | ||
| 1300 | */ | ||
| 1301 | readonly setSessionModel: ( | ||
| 1302 | modelId: string, | ||
| 1303 | meta?: EffectAcpSchema.SetSessionModelRequest["_meta"], | ||
| 1304 | ) => Effect.Effect<EffectAcpSchema.SetSessionModelResponse, EffectAcpErrors.AcpError>; | ||
| 1305 | readonly setConfigOption: ( | ||
| 1306 | configId: string, | ||
| 1307 | value: string | boolean, | ||
| 1308 | ) => Effect.Effect<EffectAcpSchema.SetSessionConfigOptionResponse, EffectAcpErrors.AcpError>; | ||
| 1309 | /** | ||
| 1310 | * Selects the base model through the negotiated model configuration option. | ||
| 1311 | * @see https://agentclientprotocol.com/protocol/schema#session/set_config_option | ||
| 1312 | */ | ||
| 1313 | readonly setModel: (model: string) => Effect.Effect<void, EffectAcpErrors.AcpError>; | ||
| 1314 | /** | ||
| 1315 | |||
| 1316 | * Sends a generic ACP extension request and records it through the request logger. | ||
| 1317 | * @see https://agentclientprotocol.com/protocol/extensibility | ||
| 1318 | */ | ||
| 1319 | readonly request: ( | ||
| 1320 | method: string, | ||
| 1321 | payload: unknown, | ||
| 1322 | ) => Effect.Effect<unknown, EffectAcpErrors.AcpError>; | ||
| 1323 | /** | ||
| 1324 | * Sends a generic ACP extension notification. | ||
| 1325 | * @see https://agentclientprotocol.com/protocol/extensibility | ||
| 1326 | */ | ||
| 1327 | readonly notify: ( | ||
| 1328 | method: string, | ||
| 1329 | payload: unknown, | ||
| 1330 | ) => Effect.Effect<void, EffectAcpErrors.AcpError>; | ||
| 1331 | } | ||
| 1332 | >()("@t3tools/provider-acp/server/AcpSessionRuntime") { | ||
| 1333 | static layer( | ||
| 1334 | options: AcpSessionRuntimeOptions, | ||
| 1335 | ): Layer.Layer< | ||
| 1336 | AcpSessionRuntime, | ||
| 1337 | EffectAcpErrors.AcpError, | ||
| 1338 | ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | ||
| 1339 | > { | ||
| 1340 | return Layer.effect(AcpSessionRuntime, make(options)); | ||
| 1341 | } | ||
| 1342 | } | ||
| 1343 | |||
| 1344 | interface AcpStartedState extends AcpSessionRuntimeStartResult {} | ||
| 1345 | |||
| 1346 | type AcpStartState = | ||
| 1347 | | { readonly _tag: "NotStarted" } | ||
| 1348 | | { | ||
| 1349 | readonly _tag: "Starting"; | ||
| 1350 | readonly deferred: Deferred.Deferred<AcpSessionRuntimeStartResult, EffectAcpErrors.AcpError>; | ||
| 1351 | } | ||
| 1352 | | { readonly _tag: "Started"; readonly result: AcpStartedState }; | ||
| 1353 | |||
| 1354 | type AcpInitializeState = | ||
| 1355 | | { readonly _tag: "NotStarted" } | ||
| 1356 | | { | ||
| 1357 | readonly _tag: "Starting"; | ||
| 1358 | readonly deferred: Deferred.Deferred< | ||
| 1359 | EffectAcpSchema.InitializeResponse, | ||
| 1360 | EffectAcpErrors.AcpError | ||
| 1361 | >; | ||
| 1362 | } | ||
| 1363 | | { readonly _tag: "Started"; readonly result: EffectAcpSchema.InitializeResponse }; | ||
| 1364 | |||
| 1365 | interface AcpAssistantSegmentState { | ||
| 1366 | readonly nextSegmentIndex: number; | ||
| 1367 | readonly activeItemId?: string; | ||
| 1368 | } | ||
| 1369 | |||
| 1370 | interface EnsureActiveAssistantSegmentResult { | ||
| 1371 | readonly itemId: string; | ||
| 1372 | readonly startedEvent?: Extract<AcpParsedSessionEvent, { readonly _tag: "AssistantItemStarted" }>; | ||
| 1373 | } | ||
| 1374 | |||
| 1375 | interface AcpActivePrompt { | ||
| 1376 | readonly fiber: Fiber.Fiber<EffectAcpSchema.PromptResponse, EffectAcpErrors.AcpError>; | ||
| 1377 | readonly completed: Deferred.Deferred<void>; | ||
| 1378 | } | ||
| 1379 | |||
| 1380 | export const make = ( | ||
| 1381 | options: AcpSessionRuntimeOptions, | ||
| 1382 | ): Effect.Effect< | ||
| 1383 | AcpSessionRuntime["Service"], | ||
| 1384 | EffectAcpErrors.AcpError, | ||
| 1385 | ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | Scope.Scope | ||
| 1386 | > => | ||
| 1387 | Effect.gen(function* () { | ||
| 1388 | const crypto = yield* Crypto.Crypto; | ||
| 1389 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 1390 | // A child of the caller's scope, so termination can close the runtime without closing the caller's. | ||
| 1391 | const runtimeScope = yield* Scope.fork(yield* Scope.Scope); | ||
| 1392 | const eventQueue = yield* Queue.unbounded<AcpSessionRuntimeEvent>(); | ||
| 1393 | const modeStateRef = yield* Ref.make<AcpSessionModeState | undefined>(undefined); | ||
| 1394 | const toolCallsRef = yield* Ref.make(new Map<string, AcpToolCallTrackedState>()); | ||
| 1395 | // Recently shown tool calls. A late update to a finished call is not a new | ||
| 1396 | // boundary in the answer, although its progress state is gone. | ||
| 1397 | const shownToolCallIds = new Set<string>(); | ||
| 1398 | const assistantItemRuntimeId = yield* crypto.randomUUIDv4.pipe( | ||
| 1399 | Effect.mapError( | ||
| 1400 | (cause) => | ||
| 1401 | new EffectAcpErrors.AcpTransportError({ | ||
| 1402 | detail: "Failed to generate an ACP assistant item runtime identifier.", | ||
| 1403 | cause, | ||
| 1404 | }), | ||
| 1405 | ), | ||
| 1406 | ); | ||
| 1407 | const assistantSegmentRef = yield* Ref.make<AcpAssistantSegmentState>({ nextSegmentIndex: 0 }); | ||
| 1408 | const configOptionsRef = yield* Ref.make(sessionConfigOptionsFromSetup(undefined)); | ||
| 1409 | const initializeStateRef = yield* Ref.make<AcpInitializeState>({ _tag: "NotStarted" }); | ||
| 1410 | const startStateRef = yield* Ref.make<AcpStartState>({ _tag: "NotStarted" }); | ||
| 1411 | const startupMetadataRef = yield* Ref.make<ReadonlyArray<EffectAcpSchema.SessionNotification>>( | ||
| 1412 | [], | ||
| 1413 | ); | ||
| 1414 | const notificationSemaphore = yield* Semaphore.make(1); | ||
| 1415 | const terminationErrorRef = yield* Ref.make<Option.Option<EffectAcpErrors.AcpError>>( | ||
| 1416 | Option.none(), | ||
| 1417 | ); | ||
| 1418 | const stoppingRef = yield* Ref.make(false); | ||
| 1419 | const stderrFailure = yield* Deferred.make<never, EffectAcpErrors.AcpError>(); | ||
| 1420 | const stderrTailRef = yield* Ref.make(""); | ||
| 1421 | const stderrDrained = yield* Deferred.make<void>(); | ||
| 1422 | const runtimeClosed = yield* Deferred.make<void>(); | ||
| 1423 | const promptSerializationSemaphore = yield* Semaphore.make(1); | ||
| 1424 | const sessionLoadSemaphore = yield* Semaphore.make(1); | ||
| 1425 | const promptDispatchSemaphore = yield* Semaphore.make(1); | ||
| 1426 | const activePromptRef = yield* Ref.make<Option.Option<AcpActivePrompt>>(Option.none()); | ||
| 1427 | const assistantUpdatesOpenRef = yield* Ref.make(true); | ||
| 1428 | const sessionLoadGateRef = yield* Ref.make<Option.Option<SessionLoadGate>>(Option.none()); | ||
| 1429 | |||
| 1430 | const ensureConnected = Effect.gen(function* () { | ||
| 1431 | const error = yield* Ref.get(terminationErrorRef); | ||
| 1432 | if (Option.isSome(error)) { | ||
| 1433 | return yield* error.value; | ||
| 1434 | } | ||
| 1435 | if (yield* Ref.get(stoppingRef)) { | ||
| 1436 | return yield* new EffectAcpErrors.AcpTransportError({ | ||
| 1437 | detail: "The ACP session runtime is closed.", | ||
| 1438 | cause: undefined, | ||
| 1439 | }); | ||
| 1440 | } | ||
| 1441 | }); | ||
| 1442 | |||
| 1443 | const enrichProcessExitWithStderr = ( | ||
| 1444 | error: EffectAcpErrors.AcpError, | ||
| 1445 | ): Effect.Effect<EffectAcpErrors.AcpError> => | ||
| 1446 | error._tag !== "AcpProcessExitedError" || (error.stderr?.trim().length ?? 0) > 0 | ||
| 1447 | ? Effect.succeed(error) | ||
| 1448 | : Deferred.await(stderrDrained).pipe( | ||
| 1449 | Effect.timeout("250 millis"), | ||
| 1450 | Effect.ignore, | ||
| 1451 | Effect.andThen(Ref.get(stderrTailRef)), | ||
| 1452 | Effect.map((tail) => { | ||
| 1453 | const stderr = sanitizeAcpStderrExcerpt(tail); | ||
| 1454 | return stderr.length === 0 | ||
| 1455 | ? error | ||
| 1456 | : new EffectAcpErrors.AcpProcessExitedError({ | ||
| 1457 | ...(error.code !== undefined ? { code: error.code } : {}), | ||
| 1458 | ...(error.pid !== undefined ? { pid: error.pid } : {}), | ||
| 1459 | stderr, | ||
| 1460 | ...(error.cause !== undefined ? { cause: error.cause } : {}), | ||
| 1461 | }); | ||
| 1462 | }), | ||
| 1463 | ); | ||
| 1464 | |||
| 1465 | const recordTermination = Effect.fn("AcpSessionRuntime.recordTermination")(function* ( | ||
| 1466 | error: EffectAcpErrors.AcpError, | ||
| 1467 | ) { | ||
| 1468 | if (yield* Ref.get(stoppingRef)) { | ||
| 1469 | return; | ||
| 1470 | } | ||
| 1471 | const enriched = yield* enrichProcessExitWithStderr(error); | ||
| 1472 | const firstTermination = yield* Ref.modify(terminationErrorRef, (current) => | ||
| 1473 | Option.isSome(current) | ||
| 1474 | ? ([false, current] as const) | ||
| 1475 | : ([true, Option.some(enriched)] as const), | ||
| 1476 | ); | ||
| 1477 | if (!firstTermination) { | ||
| 1478 | return; | ||
| 1479 | } | ||
| 1480 | yield* closeActiveAssistantSegment({ queue: eventQueue, assistantSegmentRef }); | ||
| 1481 | yield* Queue.offer(eventQueue, { _tag: "ConnectionTerminated", error: enriched }); | ||
| 1482 | }); | ||
| 1483 | |||
| 1484 | const logRequest = (event: AcpSessionRequestLogEvent) => | ||
| 1485 | options.requestLogger ? options.requestLogger(event) : Effect.void; | ||
| 1486 | |||
| 1487 | const runLoggedRequest = <A>( | ||
| 1488 | method: string, | ||
| 1489 | payload: unknown, | ||
| 1490 | effect: Effect.Effect<A, EffectAcpErrors.AcpError>, | ||
| 1491 | ): Effect.Effect<A, EffectAcpErrors.AcpError> => | ||
| 1492 | logRequest({ method, payload, status: "started" }).pipe( | ||
| 1493 | Effect.flatMap(() => | ||
| 1494 | (options.onStderr | ||
| 1495 | ? Effect.raceFirst(effect, Deferred.await(stderrFailure)) | ||
| 1496 | : effect | ||
| 1497 | ).pipe( | ||
| 1498 | Effect.catch((error) => | ||
| 1499 | enrichProcessExitWithStderr(error).pipe(Effect.flatMap(Effect.fail)), | ||
| 1500 | ), | ||
| 1501 | Effect.tap((result) => | ||
| 1502 | logRequest({ | ||
| 1503 | method, | ||
| 1504 | payload, | ||
| 1505 | status: "succeeded", | ||
| 1506 | result, | ||
| 1507 | }), | ||
| 1508 | ), | ||
| 1509 | Effect.onError((cause) => | ||
| 1510 | logRequest({ | ||
| 1511 | method, | ||
| 1512 | payload, | ||
| 1513 | status: "failed", | ||
| 1514 | cause, | ||
| 1515 | }), | ||
| 1516 | ), | ||
| 1517 | ), | ||
| 1518 | ), | ||
| 1519 | ); | ||
| 1520 | |||
| 1521 | const spawnCommand = | ||
| 1522 | options.spawn.shell === false | ||
| 1523 | ? { command: options.spawn.command, args: options.spawn.args, shell: false } | ||
| 1524 | : yield* resolveSpawnCommand(options.spawn.command, options.spawn.args, { | ||
| 1525 | ...(options.spawn.env ? { env: options.spawn.env } : {}), | ||
| 1526 | extendEnv: options.spawn.extendEnv ?? true, | ||
| 1527 | }); | ||
| 1528 | const linuxCgroupLease = | ||
| 1529 | options.ownDescendantProcessGroups === true && options.processGroupPlatform === "linux" | ||
| 1530 | ? yield* Effect.sync(() => { | ||
| 1531 | try { | ||
| 1532 | if (options.linuxCgroupController === null) return undefined; | ||
| 1533 | return (options.linuxCgroupController ?? makeLinuxCgroupController()).create(); | ||
| 1534 | } catch { | ||
| 1535 | return undefined; | ||
| 1536 | } | ||
| 1537 | }) | ||
| 1538 | : undefined; | ||
| 1539 | // This is lifecycle containment for inherited provider work. It is not a | ||
| 1540 | // security boundary against a provider that can modify its delegated cgroup. | ||
| 1541 | if (linuxCgroupLease !== undefined) { | ||
| 1542 | yield* Scope.addFinalizer( | ||
| 1543 | runtimeScope, | ||
| 1544 | Effect.uninterruptible(terminateLinuxCgroupLease(linuxCgroupLease)).pipe( | ||
| 1545 | Effect.ignoreCause({ log: true }), | ||
| 1546 | ), | ||
| 1547 | ); | ||
| 1548 | } | ||
| 1549 | const containedTargetCommand = | ||
| 1550 | linuxCgroupLease === undefined || NodePath.isAbsolute(spawnCommand.command) | ||
| 1551 | ? spawnCommand.command | ||
| 1552 | : yield* Effect.gen(function* () { | ||
| 1553 | const resolved = resolveLinuxCgroupTargetCommand( | ||
| 1554 | spawnCommand.command, | ||
| 1555 | options.spawn.cwd ?? options.cwd, | ||
| 1556 | { ...process.env, ...options.spawn.env }, | ||
| 1557 | ); | ||
| 1558 | if (resolved !== undefined) return resolved; | ||
| 1559 | return yield* new EffectAcpErrors.AcpSpawnError({ | ||
| 1560 | command: options.spawn.command, | ||
| 1561 | cause: new Error("Contained ACP command was not found on PATH"), | ||
| 1562 | }); | ||
| 1563 | }); | ||
| 1564 | const containedSpawnCommand = | ||
| 1565 | linuxCgroupLease === undefined | ||
| 1566 | ? spawnCommand | ||
| 1567 | : { | ||
| 1568 | ...wrapCommandForLinuxCgroup( | ||
| 1569 | linuxCgroupLease, | ||
| 1570 | containedTargetCommand, | ||
| 1571 | spawnCommand.args, | ||
| 1572 | ), | ||
| 1573 | shell: false, | ||
| 1574 | }; | ||
| 1575 | const spawnEnvironment = | ||
| 1576 | linuxCgroupLease === undefined | ||
| 1577 | ? options.spawn.env | ||
| 1578 | : { | ||
| 1579 | ...options.spawn.env, | ||
| 1580 | ELECTRON_RUN_AS_NODE: "1", | ||
| 1581 | T3_ACP_CGROUP_WRAPPER: "1", | ||
| 1582 | }; | ||
| 1583 | const child = yield* spawner | ||
| 1584 | .spawn( | ||
| 1585 | ChildProcess.make(containedSpawnCommand.command, containedSpawnCommand.args, { | ||
| 1586 | ...(options.spawn.cwd ? { cwd: options.spawn.cwd } : {}), | ||
| 1587 | ...(spawnEnvironment ? { env: spawnEnvironment } : {}), | ||
| 1588 | extendEnv: options.spawn.extendEnv ?? true, | ||
| 1589 | ...(options.ownDetachedProcessGroup === undefined | ||
| 1590 | ? {} | ||
| 1591 | : { detached: options.ownDetachedProcessGroup }), | ||
| 1592 | shell: containedSpawnCommand.shell, | ||
| 1593 | }), | ||
| 1594 | ) | ||
| 1595 | .pipe( | ||
| 1596 | Effect.provideService(Scope.Scope, runtimeScope), | ||
| 1597 | Effect.mapError( | ||
| 1598 | (cause) => | ||
| 1599 | new EffectAcpErrors.AcpSpawnError({ | ||
| 1600 | command: options.spawn.command, | ||
| 1601 | cause, | ||
| 1602 | }), | ||
| 1603 | ), | ||
| 1604 | ); | ||
| 1605 | |||
| 1606 | const posixOwnershipLedger = new Map<string, AcpOwnedPosixProcess>(); | ||
| 1607 | const posixOwnershipFrontier = new Map<number, AcpOwnedPosixProcess>(); | ||
| 1608 | const posixOwnershipChildQueues = new Map<number, Array<number>>(); | ||
| 1609 | const posixOwnershipRoot: AcpPosixOwnershipRoot = { value: undefined }; | ||
| 1610 | const posixController = | ||
| 1611 | options.ownDescendantProcessGroups === true | ||
| 1612 | ? yield* Effect.try({ | ||
| 1613 | try: () => { | ||
| 1614 | if (options.posixProcessTreeController !== undefined) { | ||
| 1615 | return options.posixProcessTreeController; | ||
| 1616 | } | ||
| 1617 | if (options.processGroupPlatform === undefined) { | ||
| 1618 | throw new AcpProcessGroupTerminationError({ | ||
| 1619 | detail: "POSIX ACP descendant ownership requires a supported host platform", | ||
| 1620 | }); | ||
| 1621 | } | ||
| 1622 | return makePosixProcessTreeController(options.processGroupPlatform); | ||
| 1623 | }, | ||
| 1624 | catch: (cause) => | ||
| 1625 | new EffectAcpErrors.AcpSpawnError({ command: options.spawn.command, cause }), | ||
| 1626 | }) | ||
| 1627 | : undefined; | ||
| 1628 | if (posixController !== undefined) { | ||
| 1629 | const observeOwnership = () => | ||
| 1630 | observePosixOwnershipLedger({ | ||
| 1631 | childQueues: posixOwnershipChildQueues, | ||
| 1632 | controller: posixController, | ||
| 1633 | frontier: posixOwnershipFrontier, | ||
| 1634 | ledger: posixOwnershipLedger, | ||
| 1635 | root: posixOwnershipRoot, | ||
| 1636 | rootPid: Number(child.pid), | ||
| 1637 | }); | ||
| 1638 | yield* Effect.try({ | ||
| 1639 | try: observeOwnership, | ||
| 1640 | catch: (cause) => | ||
| 1641 | new EffectAcpErrors.AcpSpawnError({ command: options.spawn.command, cause }), | ||
| 1642 | }); | ||
| 1643 | yield* observePosixOwnershipLedgerContinuously({ | ||
| 1644 | childQueues: posixOwnershipChildQueues, | ||
| 1645 | controller: posixController, | ||
| 1646 | frontier: posixOwnershipFrontier, | ||
| 1647 | ledger: posixOwnershipLedger, | ||
| 1648 | root: posixOwnershipRoot, | ||
| 1649 | rootPid: Number(child.pid), | ||
| 1650 | }).pipe(Effect.forkIn(runtimeScope)); | ||
| 1651 | } | ||
| 1652 | |||
| 1653 | const signalOwnedProcessGroup = (signal: NodeJS.Signals) => | ||
| 1654 | Effect.try({ | ||
| 1655 | try: () => { | ||
| 1656 | signalProcessGroup(Number(child.pid), signal); | ||
| 1657 | return true; | ||
| 1658 | }, | ||
| 1659 | catch: (cause) => | ||
| 1660 | new AcpProcessGroupTerminationError({ | ||
| 1661 | cause, | ||
| 1662 | detail: `Failed to signal ACP process group ${child.pid} with ${signal}`, | ||
| 1663 | }), | ||
| 1664 | }).pipe( | ||
| 1665 | Effect.catch((error) => { | ||
| 1666 | const cause = error.cause as NodeJS.ErrnoException | undefined; | ||
| 1667 | return cause?.code === "ESRCH" ? Effect.succeed(false) : Effect.fail(error); | ||
| 1668 | }), | ||
| 1669 | ); | ||
| 1670 | const terminateWindowsProcessTree = | ||
| 1671 | options.windowsProcessTreeTerminator ?? | ||
| 1672 | ((pid: number) => terminateWindowsProcessTreeWithTaskkill(spawner, pid)); | ||
| 1673 | const terminatePosixProcessTree = (grace: Duration.Input, platformOverride?: NodeJS.Platform) => | ||
| 1674 | Effect.gen(function* () { | ||
| 1675 | const platform = platformOverride ?? options.processGroupPlatform; | ||
| 1676 | if (platform === undefined || platform === "win32") { | ||
| 1677 | return yield* new AcpProcessGroupTerminationError({ | ||
| 1678 | detail: "POSIX ACP descendant ownership requires a supported host platform", | ||
| 1679 | }); | ||
| 1680 | } | ||
| 1681 | const controller = yield* Effect.try({ | ||
| 1682 | try: () => posixController ?? makePosixProcessTreeController(platform), | ||
| 1683 | catch: (cause) => | ||
| 1684 | isAcpProcessGroupTerminationError(cause) | ||
| 1685 | ? cause | ||
| 1686 | : new AcpProcessGroupTerminationError({ | ||
| 1687 | cause, | ||
| 1688 | detail: `Failed to initialize ACP descendant ownership on ${platform}`, | ||
| 1689 | }), | ||
| 1690 | }); | ||
| 1691 | yield* terminatePosixOwnedProcessTree({ | ||
| 1692 | controller, | ||
| 1693 | grace, | ||
| 1694 | ledger: posixOwnershipLedger, | ||
| 1695 | root: posixOwnershipRoot, | ||
| 1696 | rootPid: Number(child.pid), | ||
| 1697 | }); | ||
| 1698 | }); | ||
| 1699 | const terminateOwnedProcessGroupImpl = Effect.gen(function* () { | ||
| 1700 | if (options.ownDetachedProcessGroup !== true) return; | ||
| 1701 | if (options.processGroupPlatform === undefined) { | ||
| 1702 | return yield* new AcpProcessGroupTerminationError({ | ||
| 1703 | detail: "Detached ACP process-group ownership requires a host platform", | ||
| 1704 | }); | ||
| 1705 | } | ||
| 1706 | if (options.processGroupPlatform === "win32") { | ||
| 1707 | return yield* terminateWindowsProcessTree(Number(child.pid)); | ||
| 1708 | } | ||
| 1709 | if (linuxCgroupLease !== undefined) { | ||
| 1710 | if (posixController !== undefined) { | ||
| 1711 | yield* Effect.sync(() => | ||
| 1712 | signalLinuxCgroupRootTerm({ | ||
| 1713 | controller: posixController, | ||
| 1714 | lease: linuxCgroupLease, | ||
| 1715 | root: posixOwnershipRoot, | ||
| 1716 | }), | ||
| 1717 | ); | ||
| 1718 | } | ||
| 1719 | const grace = options.processGroupTerminationGrace ?? "1 second"; | ||
| 1720 | if (grace !== 0) yield* Effect.sleep(grace); | ||
| 1721 | return yield* terminateLinuxCgroupLease(linuxCgroupLease); | ||
| 1722 | } | ||
| 1723 | if (options.ownDescendantProcessGroups === true) { | ||
| 1724 | return yield* terminatePosixProcessTree(options.processGroupTerminationGrace ?? "1 second"); | ||
| 1725 | } | ||
| 1726 | const groupExisted = yield* signalOwnedProcessGroup("SIGTERM"); | ||
| 1727 | if (!groupExisted) return; | ||
| 1728 | const grace = options.processGroupTerminationGrace ?? "1 second"; | ||
| 1729 | if (grace !== 0) { | ||
| 1730 | yield* Effect.sleep(grace); | ||
| 1731 | } | ||
| 1732 | yield* signalOwnedProcessGroup("SIGKILL"); | ||
| 1733 | }).pipe(withWallClock); | ||
| 1734 | const terminateProcessGroup = yield* Effect.cached( | ||
| 1735 | Effect.uninterruptible(terminateOwnedProcessGroupImpl), | ||
| 1736 | ); | ||
| 1737 | if (options.ownDetachedProcessGroup === true) { | ||
| 1738 | const hostPlatform = yield* HostProcessPlatform; | ||
| 1739 | const forceTerminateOwnedProcessGroup = | ||
| 1740 | hostPlatform === "win32" | ||
| 1741 | ? terminateWindowsProcessTreeWithTaskkill(spawner, Number(child.pid)) | ||
| 1742 | : linuxCgroupLease !== undefined | ||
| 1743 | ? terminateLinuxCgroupLease(linuxCgroupLease) | ||
| 1744 | : options.ownDescendantProcessGroups === true | ||
| 1745 | ? terminatePosixProcessTree(0, hostPlatform) | ||
| 1746 | : signalOwnedProcessGroup("SIGKILL").pipe(Effect.asVoid); | ||
| 1747 | yield* Scope.addFinalizer( | ||
| 1748 | runtimeScope, | ||
| 1749 | Effect.uninterruptible(forceTerminateOwnedProcessGroup).pipe( | ||
| 1750 | Effect.ignoreCause({ log: true }), | ||
| 1751 | ), | ||
| 1752 | ); | ||
| 1753 | } | ||
| 1754 | yield* child.stderr.pipe( | ||
| 1755 | Stream.decodeText(), | ||
| 1756 | Stream.runForEach((chunk) => | ||
| 1757 | Ref.update(stderrTailRef, (current) => appendAcpStderrTail(current, chunk)).pipe( | ||
| 1758 | Effect.andThen( | ||
| 1759 | options.onStderr ? options.onStderr(chunk.slice(-maxStderrChunkLength)) : Effect.void, | ||
| 1760 | ), | ||
| 1761 | Effect.catch((error) => | ||
| 1762 | Effect.gen(function* () { | ||
| 1763 | yield* Deferred.fail(stderrFailure, error); | ||
| 1764 | yield* recordTermination(error); | ||
| 1765 | yield* child.kill({ forceKillAfter: "1 second" }).pipe(Effect.ignore); | ||
| 1766 | }), | ||
| 1767 | ), | ||
| 1768 | ), | ||
| 1769 | ), | ||
| 1770 | Effect.ensuring(Deferred.succeed(stderrDrained, undefined)), | ||
| 1771 | Effect.ignore, | ||
| 1772 | Effect.forkIn(runtimeScope), | ||
| 1773 | ); | ||
| 1774 | |||
| 1775 | const acpContext = yield* Layer.build( | ||
| 1776 | EffectAcpClient.layerChildProcess(child, { | ||
| 1777 | ...(options.transformStdout ? { transformStdout: options.transformStdout } : {}), | ||
| 1778 | ...(options.transformSessionUpdate | ||
| 1779 | ? { transformSessionUpdate: options.transformSessionUpdate } | ||
| 1780 | : {}), | ||
| 1781 | ...(options.protocolLogging?.logIncoming !== undefined | ||
| 1782 | ? { logIncoming: options.protocolLogging.logIncoming } | ||
| 1783 | : {}), | ||
| 1784 | ...(options.protocolLogging?.logOutgoing !== undefined | ||
| 1785 | ? { logOutgoing: options.protocolLogging.logOutgoing } | ||
| 1786 | : {}), | ||
| 1787 | ...(options.protocolLogging?.logger ? { logger: options.protocolLogging.logger } : {}), | ||
| 1788 | ...(options.onIncomingRequest ? { onIncomingRequest: options.onIncomingRequest } : {}), | ||
| 1789 | onTermination: (error) => | ||
| 1790 | recordTermination(error).pipe( | ||
| 1791 | Effect.andThen(options.onTermination?.(error) ?? Effect.void), | ||
| 1792 | Effect.ensuring( | ||
| 1793 | Scope.close(runtimeScope, Exit.fail(error)).pipe(Effect.forkDetach, Effect.asVoid), | ||
| 1794 | ), | ||
| 1795 | ), | ||
| 1796 | ...(options.onOutgoingResponseFailure | ||
| 1797 | ? { onOutgoingResponseFailure: options.onOutgoingResponseFailure } | ||
| 1798 | : {}), | ||
| 1799 | ...(options.onOutgoingResponse ? { onOutgoingResponse: options.onOutgoingResponse } : {}), | ||
| 1800 | }), | ||
| 1801 | ).pipe(Effect.provideService(Scope.Scope, runtimeScope)); | ||
| 1802 | |||
| 1803 | const acp = yield* Effect.service(EffectAcpClient.AcpClient).pipe(Effect.provide(acpContext)); | ||
| 1804 | |||
| 1805 | const processSessionUpdate = (notification: EffectAcpSchema.SessionNotification) => | ||
| 1806 | handleSessionUpdate({ | ||
| 1807 | queue: eventQueue, | ||
| 1808 | modeStateRef, | ||
| 1809 | configOptionsRef, | ||
| 1810 | toolCallsRef, | ||
| 1811 | shownToolCallIds, | ||
| 1812 | assistantSegmentRef, | ||
| 1813 | assistantItemRuntimeId, | ||
| 1814 | params: notification, | ||
| 1815 | }); | ||
| 1816 | |||
| 1817 | yield* acp.handleSessionUpdate((notification) => | ||
| 1818 | notificationSemaphore.withPermit( | ||
| 1819 | Effect.gen(function* () { | ||
| 1820 | if (Option.isSome(yield* Ref.get(terminationErrorRef))) { | ||
| 1821 | return; | ||
| 1822 | } | ||
| 1823 | const gate = yield* Ref.get(sessionLoadGateRef); | ||
| 1824 | // A different session can still have an in-flight prompt while this | ||
| 1825 | // load replays history, so quarantine only the loading session. | ||
| 1826 | const loadingThisSession = | ||
| 1827 | Option.isSome(gate) && | ||
| 1828 | gate.value.active && | ||
| 1829 | notification.sessionId === gate.value.sessionId; | ||
| 1830 | if ( | ||
| 1831 | loadingThisSession && | ||
| 1832 | sessionUpdateCountsAsLoadReplayActivity(notification, gate.value.sessionId) | ||
| 1833 | ) { | ||
| 1834 | const lastActivityAtMillis = yield* Clock.currentTimeMillis; | ||
| 1835 | yield* Ref.set( | ||
| 1836 | sessionLoadGateRef, | ||
| 1837 | Option.some({ | ||
| 1838 | ...gate.value, | ||
| 1839 | lastActivityAtMillis, | ||
| 1840 | }), | ||
| 1841 | ); | ||
| 1842 | } | ||
| 1843 | if (sessionUpdateIsReplay(notification)) { | ||
| 1844 | return; | ||
| 1845 | } | ||
| 1846 | const startState = yield* Ref.get(startStateRef); | ||
| 1847 | if (startState._tag === "Starting") { | ||
| 1848 | if (isStartupMetadataUpdate(notification)) { | ||
| 1849 | yield* Ref.update(startupMetadataRef, (current) => | ||
| 1850 | [ | ||
| 1851 | ...current.filter( | ||
| 1852 | (previous) => | ||
| 1853 | previous.sessionId !== notification.sessionId || | ||
| 1854 | previous.update.sessionUpdate !== notification.update.sessionUpdate, | ||
| 1855 | ), | ||
| 1856 | notification, | ||
| 1857 | ].slice(-maxStartupMetadataUpdates), | ||
| 1858 | ); | ||
| 1859 | } | ||
| 1860 | return; | ||
| 1861 | } | ||
| 1862 | if (loadingThisSession) { | ||
| 1863 | if (notification.update.sessionUpdate === "config_option_update") { | ||
| 1864 | const candidateConfigOptions = notification.update.configOptions; | ||
| 1865 | yield* Ref.update(sessionLoadGateRef, (current) => | ||
| 1866 | Option.isSome(current) | ||
| 1867 | ? Option.some({ ...current.value, candidateConfigOptions }) | ||
| 1868 | : current, | ||
| 1869 | ); | ||
| 1870 | } | ||
| 1871 | return; | ||
| 1872 | } | ||
| 1873 | // One runtime projects one root ACP session. Child-session updates need | ||
| 1874 | // explicit lineage routing and must never be flattened into this stream. | ||
| 1875 | if ( | ||
| 1876 | startState._tag !== "Started" || | ||
| 1877 | notification.sessionId !== startState.result.sessionId | ||
| 1878 | ) { | ||
| 1879 | return; | ||
| 1880 | } | ||
| 1881 | if ( | ||
| 1882 | !(yield* Ref.get(assistantUpdatesOpenRef)) && | ||
| 1883 | (notification.update.sessionUpdate === "agent_message_chunk" || | ||
| 1884 | notification.update.sessionUpdate === "agent_thought_chunk") | ||
| 1885 | ) { | ||
| 1886 | return; | ||
| 1887 | } | ||
| 1888 | yield* processSessionUpdate(notification); | ||
| 1889 | }), | ||
| 1890 | ), | ||
| 1891 | ); | ||
| 1892 | yield* Scope.addFinalizer( | ||
| 1893 | runtimeScope, | ||
| 1894 | Ref.set(stoppingRef, true).pipe(Effect.andThen(Deferred.succeed(runtimeClosed, undefined))), | ||
| 1895 | ); | ||
| 1896 | const initializeClientCapabilities = { | ||
| 1897 | fs: { | ||
| 1898 | readTextFile: false, | ||
| 1899 | writeTextFile: false, | ||
| 1900 | ...options.clientCapabilities?.fs, | ||
| 1901 | }, | ||
| 1902 | terminal: options.clientCapabilities?.terminal ?? false, | ||
| 1903 | ...(options.clientCapabilities?.auth ? { auth: options.clientCapabilities.auth } : {}), | ||
| 1904 | ...(options.clientCapabilities?.elicitation | ||
| 1905 | ? { elicitation: options.clientCapabilities.elicitation } | ||
| 1906 | : {}), | ||
| 1907 | ...(options.clientCapabilities?._meta ? { _meta: options.clientCapabilities._meta } : {}), | ||
| 1908 | } satisfies NonNullable<EffectAcpSchema.InitializeRequest["clientCapabilities"]>; | ||
| 1909 | |||
| 1910 | const getStartedState = Effect.gen(function* () { | ||
| 1911 | yield* ensureConnected; | ||
| 1912 | const state = yield* Ref.get(startStateRef); | ||
| 1913 | if (state._tag === "Started") { | ||
| 1914 | return state.result; | ||
| 1915 | } | ||
| 1916 | return yield* new EffectAcpErrors.AcpTransportError({ | ||
| 1917 | detail: "ACP session runtime has not been started", | ||
| 1918 | cause: "ACP session runtime has not been started", | ||
| 1919 | }); | ||
| 1920 | }); | ||
| 1921 | |||
| 1922 | const validateConfigOptionValue = ( | ||
| 1923 | configId: string, | ||
| 1924 | value: string | boolean, | ||
| 1925 | ): Effect.Effect<void, EffectAcpErrors.AcpError> => | ||
| 1926 | Effect.gen(function* () { | ||
| 1927 | const configOption = findSessionConfigOption(yield* Ref.get(configOptionsRef), configId); | ||
| 1928 | if (!configOption) { | ||
| 1929 | return; | ||
| 1930 | } | ||
| 1931 | if (configOption.type === "boolean") { | ||
| 1932 | if (typeof value === "boolean") { | ||
| 1933 | return; | ||
| 1934 | } | ||
| 1935 | return yield* new EffectAcpErrors.AcpRequestError({ | ||
| 1936 | code: -32602, | ||
| 1937 | errorMessage: `Invalid value ${formatConfigOptionValue(value)} for session config option "${configOption.id}": expected boolean`, | ||
| 1938 | data: { | ||
| 1939 | configId: configOption.id, | ||
| 1940 | expectedType: "boolean", | ||
| 1941 | receivedValue: value, | ||
| 1942 | }, | ||
| 1943 | }); | ||
| 1944 | } | ||
| 1945 | if (typeof value !== "string") { | ||
| 1946 | return yield* new EffectAcpErrors.AcpRequestError({ | ||
| 1947 | code: -32602, | ||
| 1948 | errorMessage: `Invalid value ${formatConfigOptionValue(value)} for session config option "${configOption.id}": expected string`, | ||
| 1949 | data: { | ||
| 1950 | configId: configOption.id, | ||
| 1951 | expectedType: "string", | ||
| 1952 | receivedValue: value, | ||
| 1953 | }, | ||
| 1954 | }); | ||
| 1955 | } | ||
| 1956 | const allowedValues = collectSessionConfigOptionValues(configOption); | ||
| 1957 | if (allowedValues.includes(value)) { | ||
| 1958 | return; | ||
| 1959 | } | ||
| 1960 | return yield* new EffectAcpErrors.AcpRequestError({ | ||
| 1961 | code: -32602, | ||
| 1962 | errorMessage: `Invalid value ${formatConfigOptionValue(value)} for session config option "${configOption.id}": expected one of ${allowedValues.join(", ")}`, | ||
| 1963 | data: { | ||
| 1964 | configId: configOption.id, | ||
| 1965 | allowedValues, | ||
| 1966 | receivedValue: value, | ||
| 1967 | }, | ||
| 1968 | }); | ||
| 1969 | }); | ||
| 1970 | |||
| 1971 | const updateConfigOptions = Effect.fn("AcpSessionRuntime.updateConfigOptions")(function* ( | ||
| 1972 | response: | ||
| 1973 | | EffectAcpSchema.SetSessionConfigOptionResponse | ||
| 1974 | | EffectAcpSchema.ForkSessionResponse | ||
| 1975 | | EffectAcpSchema.LoadSessionResponse | ||
| 1976 | | EffectAcpSchema.NewSessionResponse | ||
| 1977 | | EffectAcpSchema.ResumeSessionResponse, | ||
| 1978 | ) { | ||
| 1979 | const configOptions = sessionConfigOptionsFromSetup(response); | ||
| 1980 | yield* Ref.set(configOptionsRef, configOptions); | ||
| 1981 | yield* Queue.offer(eventQueue, { | ||
| 1982 | _tag: "ConfigOptionsUpdated", | ||
| 1983 | configOptions, | ||
| 1984 | rawPayload: response, | ||
| 1985 | }); | ||
| 1986 | }); | ||
| 1987 | |||
| 1988 | const updateCurrentModeId = (modeId: string): Effect.Effect<void> => | ||
| 1989 | Ref.update(modeStateRef, (current) => | ||
| 1990 | current ? { ...current, currentModeId: modeId } : current, | ||
| 1991 | ); | ||
| 1992 | |||
| 1993 | const adoptSession = ( | ||
| 1994 | sessionId: string, | ||
| 1995 | sessionSetupResult: | ||
| 1996 | | EffectAcpSchema.ForkSessionResponse | ||
| 1997 | | EffectAcpSchema.LoadSessionResponse | ||
| 1998 | | EffectAcpSchema.NewSessionResponse | ||
| 1999 | | EffectAcpSchema.ResumeSessionResponse, | ||
| 2000 | ): Effect.Effect<AcpSessionRuntimeStartResult, EffectAcpErrors.AcpError> => | ||
| 2001 | Effect.gen(function* () { | ||
| 2002 | const current = yield* getStartedState; | ||
| 2003 | const meta = sessionSetupResult._meta; | ||
| 2004 | const syntheticReplayIdle = | ||
| 2005 | meta !== null && | ||
| 2006 | typeof meta === "object" && | ||
| 2007 | !Array.isArray(meta) && | ||
| 2008 | (meta as { readonly t3SessionLoadReady?: unknown }).t3SessionLoadReady === "replay_idle"; | ||
| 2009 | const extractedModelConfigId = extractModelConfigId(sessionSetupResult); | ||
| 2010 | const nextModelConfigId = | ||
| 2011 | extractedModelConfigId ?? (syntheticReplayIdle ? current.modelConfigId : undefined); | ||
| 2012 | const nextModeState = parseSessionModeState(sessionSetupResult); | ||
| 2013 | if (nextModeState !== undefined) { | ||
| 2014 | yield* Ref.set(modeStateRef, nextModeState); | ||
| 2015 | } else if (!syntheticReplayIdle) { | ||
| 2016 | yield* Ref.set(modeStateRef, undefined); | ||
| 2017 | } | ||
| 2018 | // Synthetic replay-idle load responses only carry initialize model/mode | ||
| 2019 | // meta; preserve live configOptions so setConfigOption still validates. | ||
| 2020 | if ( | ||
| 2021 | sessionSetupResult.configOptions !== undefined && | ||
| 2022 | sessionSetupResult.configOptions !== null | ||
| 2023 | ) { | ||
| 2024 | yield* Ref.set(configOptionsRef, sessionConfigOptionsFromSetup(sessionSetupResult)); | ||
| 2025 | } else if (!syntheticReplayIdle) { | ||
| 2026 | yield* Ref.set(configOptionsRef, sessionConfigOptionsFromSetup(sessionSetupResult)); | ||
| 2027 | } | ||
| 2028 | const nextState = { | ||
| 2029 | sessionId, | ||
| 2030 | initializeResult: current.initializeResult, | ||
| 2031 | sessionSetupResult, | ||
| 2032 | modelConfigId: nextModelConfigId, | ||
| 2033 | } satisfies AcpStartedState; | ||
| 2034 | yield* Ref.set(toolCallsRef, new Map()); | ||
| 2035 | yield* Ref.set(assistantSegmentRef, { nextSegmentIndex: 0 }); | ||
| 2036 | yield* Ref.set(startStateRef, { _tag: "Started", result: nextState }); | ||
| 2037 | return nextState; | ||
| 2038 | }); | ||
| 2039 | |||
| 2040 | const runLoadSessionWithReplayIdle = ( | ||
| 2041 | loadPayload: EffectAcpSchema.LoadSessionRequest, | ||
| 2042 | initializeResult: EffectAcpSchema.InitializeResponse, | ||
| 2043 | ): Effect.Effect<EffectAcpSchema.LoadSessionResponse, EffectAcpErrors.AcpError> => | ||
| 2044 | sessionLoadSemaphore.withPermits(1)( | ||
| 2045 | Effect.gen(function* () { | ||
| 2046 | const sessionLoadTimeout = Duration.fromInputUnsafe( | ||
| 2047 | options.sessionLoadTimeout ?? defaultSessionLoadTimeout, | ||
| 2048 | ); | ||
| 2049 | const sessionLoadReplayIdleGap = Duration.fromInputUnsafe( | ||
| 2050 | options.sessionLoadReplayIdleGap ?? defaultSessionLoadReplayIdleGap, | ||
| 2051 | ); | ||
| 2052 | |||
| 2053 | yield* Ref.set( | ||
| 2054 | sessionLoadGateRef, | ||
| 2055 | Option.some({ | ||
| 2056 | active: true, | ||
| 2057 | sessionId: loadPayload.sessionId, | ||
| 2058 | lastActivityAtMillis: undefined, | ||
| 2059 | idleGap: sessionLoadReplayIdleGap, | ||
| 2060 | initializeResult, | ||
| 2061 | }), | ||
| 2062 | ); | ||
| 2063 | |||
| 2064 | return yield* Effect.gen(function* () { | ||
| 2065 | yield* logRequest({ | ||
| 2066 | method: "session/load", | ||
| 2067 | payload: loadPayload, | ||
| 2068 | status: "started", | ||
| 2069 | }); | ||
| 2070 | |||
| 2071 | const idleFiber = yield* waitForSessionLoadReplayIdle({ | ||
| 2072 | gateRef: sessionLoadGateRef, | ||
| 2073 | }).pipe(Effect.forkIn(runtimeScope)); | ||
| 2074 | const loaded = yield* Effect.raceFirst( | ||
| 2075 | acp.agent.loadSession(loadPayload), | ||
| 2076 | Fiber.join(idleFiber), | ||
| 2077 | ).pipe( | ||
| 2078 | Effect.ensuring(Fiber.interrupt(idleFiber).pipe(Effect.ignore)), | ||
| 2079 | Effect.timeoutOption(sessionLoadTimeout), | ||
| 2080 | Effect.flatMap((result) => | ||
| 2081 | Option.match(result, { | ||
| 2082 | onNone: () => | ||
| 2083 | Effect.fail( | ||
| 2084 | new EffectAcpErrors.AcpTransportError({ | ||
| 2085 | operation: "call-rpc", | ||
| 2086 | method: "session/load", | ||
| 2087 | detail: | ||
| 2088 | "session/load timed out waiting for RPC response or replay idle gap", | ||
| 2089 | cause: undefined, | ||
| 2090 | }), | ||
| 2091 | ), | ||
| 2092 | onSome: Effect.succeed, | ||
| 2093 | }), | ||
| 2094 | ), | ||
| 2095 | Effect.tap((result) => | ||
| 2096 | logRequest({ | ||
| 2097 | method: "session/load", | ||
| 2098 | payload: loadPayload, | ||
| 2099 | status: "succeeded", | ||
| 2100 | result, | ||
| 2101 | }), | ||
| 2102 | ), | ||
| 2103 | Effect.onError((cause) => | ||
| 2104 | logRequest({ | ||
| 2105 | method: "session/load", | ||
| 2106 | payload: loadPayload, | ||
| 2107 | status: "failed", | ||
| 2108 | cause, | ||
| 2109 | }), | ||
| 2110 | ), | ||
| 2111 | ); | ||
| 2112 | |||
| 2113 | const gate = yield* Ref.get(sessionLoadGateRef); | ||
| 2114 | const candidateConfigOptions = Option.isSome(gate) | ||
| 2115 | ? gate.value.candidateConfigOptions | ||
| 2116 | : undefined; | ||
| 2117 | return loaded.configOptions == null && candidateConfigOptions !== undefined | ||
| 2118 | ? { ...loaded, configOptions: candidateConfigOptions } | ||
| 2119 | : loaded; | ||
| 2120 | }).pipe(Effect.ensuring(Ref.set(sessionLoadGateRef, Option.none()))); | ||
| 2121 | }), | ||
| 2122 | ); | ||
| 2123 | |||
| 2124 | const setConfigOption = ( | ||
| 2125 | configId: string, | ||
| 2126 | value: string | boolean, | ||
| 2127 | ): Effect.Effect<EffectAcpSchema.SetSessionConfigOptionResponse, EffectAcpErrors.AcpError> => | ||
| 2128 | validateConfigOptionValue(configId, value).pipe( | ||
| 2129 | Effect.flatMap(() => getStartedState), | ||
| 2130 | Effect.flatMap((started) => | ||
| 2131 | Ref.get(configOptionsRef).pipe( | ||
| 2132 | Effect.flatMap((configOptions) => { | ||
| 2133 | const existing = findSessionConfigOption(configOptions, configId); | ||
| 2134 | if (existing && configOptionCurrentValueMatches(existing, value)) { | ||
| 2135 | return Effect.succeed({ | ||
| 2136 | configOptions, | ||
| 2137 | } satisfies EffectAcpSchema.SetSessionConfigOptionResponse); | ||
| 2138 | } | ||
| 2139 | const requestPayload = | ||
| 2140 | typeof value === "boolean" | ||
| 2141 | ? ({ | ||
| 2142 | sessionId: started.sessionId, | ||
| 2143 | configId, | ||
| 2144 | type: "boolean", | ||
| 2145 | value, | ||
| 2146 | } satisfies EffectAcpSchema.SetSessionConfigOptionRequest) | ||
| 2147 | : ({ | ||
| 2148 | sessionId: started.sessionId, | ||
| 2149 | configId, | ||
| 2150 | value: String(value), | ||
| 2151 | } satisfies EffectAcpSchema.SetSessionConfigOptionRequest); | ||
| 2152 | return runLoggedRequest( | ||
| 2153 | "session/set_config_option", | ||
| 2154 | requestPayload, | ||
| 2155 | acp.agent.setSessionConfigOption(requestPayload), | ||
| 2156 | ).pipe(Effect.tap((response) => updateConfigOptions(response))); | ||
| 2157 | }), | ||
| 2158 | ), | ||
| 2159 | ), | ||
| 2160 | ); | ||
| 2161 | |||
| 2162 | const initializeOnce = Effect.gen(function* () { | ||
| 2163 | const initializePayload = { | ||
| 2164 | protocolVersion: 2, | ||
| 2165 | clientCapabilities: initializeClientCapabilities, | ||
| 2166 | clientInfo: options.clientInfo, | ||
| 2167 | ...(options.initializeMeta === undefined ? {} : { _meta: options.initializeMeta }), | ||
| 2168 | } satisfies EffectAcpSchema.InitializeRequest; | ||
| 2169 | |||
| 2170 | const initializeResult = yield* runLoggedRequest( | ||
| 2171 | "initialize", | ||
| 2172 | initializePayload, | ||
| 2173 | acp.agent.initialize(initializePayload), | ||
| 2174 | ); | ||
| 2175 | yield* options.onInitialized?.(initializeResult) ?? Effect.void; | ||
| 2176 | return initializeResult; | ||
| 2177 | }); | ||
| 2178 | |||
| 2179 | const initialize = Effect.gen(function* () { | ||
| 2180 | yield* ensureConnected; | ||
| 2181 | const deferred = yield* Deferred.make< | ||
| 2182 | EffectAcpSchema.InitializeResponse, | ||
| 2183 | EffectAcpErrors.AcpError | ||
| 2184 | >(); | ||
| 2185 | const effect = yield* Ref.modify(initializeStateRef, (state) => { | ||
| 2186 | switch (state._tag) { | ||
| 2187 | case "Started": | ||
| 2188 | return [Effect.succeed(state.result), state] as const; | ||
| 2189 | case "Starting": | ||
| 2190 | return [Deferred.await(state.deferred), state] as const; | ||
| 2191 | case "NotStarted": | ||
| 2192 | return [ | ||
| 2193 | initializeOnce.pipe( | ||
| 2194 | Effect.tap((result) => | ||
| 2195 | Ref.set(initializeStateRef, { _tag: "Started", result }).pipe( | ||
| 2196 | Effect.andThen(Deferred.succeed(deferred, result)), | ||
| 2197 | ), | ||
| 2198 | ), | ||
| 2199 | Effect.onError((cause) => | ||
| 2200 | Deferred.failCause(deferred, cause).pipe( | ||
| 2201 | Effect.andThen(Ref.set(initializeStateRef, { _tag: "NotStarted" })), | ||
| 2202 | ), | ||
| 2203 | ), | ||
| 2204 | ), | ||
| 2205 | { _tag: "Starting", deferred } satisfies AcpInitializeState, | ||
| 2206 | ] as const; | ||
| 2207 | } | ||
| 2208 | }); | ||
| 2209 | return yield* effect; | ||
| 2210 | }); | ||
| 2211 | |||
| 2212 | const sessionMcpServers = ( | ||
| 2213 | initializeResult: EffectAcpSchema.InitializeResponse, | ||
| 2214 | activationOptions?: AcpSessionActivationOptions, | ||
| 2215 | ): ReadonlyArray<EffectAcpSchema.McpServer> => { | ||
| 2216 | const acpServers = activationOptions?.acpMcpServers ?? options.acpMcpServers ?? []; | ||
| 2217 | if ( | ||
| 2218 | initializeResult.agentCapabilities?.mcpCapabilities?.acp === true && | ||
| 2219 | acpServers.length > 0 | ||
| 2220 | ) { | ||
| 2221 | return acpServers; | ||
| 2222 | } | ||
| 2223 | return activationOptions?.mcpServers ?? options.mcpServers ?? []; | ||
| 2224 | }; | ||
| 2225 | |||
| 2226 | const startOnce = Effect.gen(function* () { | ||
| 2227 | const initializeResult = yield* initialize; | ||
| 2228 | |||
| 2229 | const authenticateAfterRequired = ( | ||
| 2230 | authRequiredError: EffectAcpErrors.AcpError, | ||
| 2231 | ): Effect.Effect<void, EffectAcpErrors.AcpError> => | ||
| 2232 | Effect.gen(function* () { | ||
| 2233 | const configuredAuthMethodId = options.authMethodId?.trim(); | ||
| 2234 | const authMethod = selectAcpAgentAuthMethod( | ||
| 2235 | initializeResult.authMethods, | ||
| 2236 | configuredAuthMethodId, | ||
| 2237 | ); | ||
| 2238 | if ( | ||
| 2239 | configuredAuthMethodId && | ||
| 2240 | initializeResult.authMethods !== undefined && | ||
| 2241 | authMethod === undefined | ||
| 2242 | ) { | ||
| 2243 | return yield* new EffectAcpErrors.AcpTransportError({ | ||
| 2244 | detail: `ACP agent did not advertise configured authentication method "${configuredAuthMethodId}"`, | ||
| 2245 | cause: { configuredAuthMethodId, authMethods: initializeResult.authMethods }, | ||
| 2246 | }); | ||
| 2247 | } | ||
| 2248 | if ( | ||
| 2249 | authMethod !== undefined && | ||
| 2250 | authMethod.type !== undefined && | ||
| 2251 | authMethod.type !== "agent" | ||
| 2252 | ) { | ||
| 2253 | return yield* new EffectAcpErrors.AcpTransportError({ | ||
| 2254 | detail: `ACP authentication method "${authMethod.id}" requires ${authMethod.type} authentication, which cannot run inside a headless provider session`, | ||
| 2255 | cause: authMethod, | ||
| 2256 | }); | ||
| 2257 | } | ||
| 2258 | |||
| 2259 | const authMethodId = authMethod?.id ?? configuredAuthMethodId; | ||
| 2260 | if (!authMethodId) { | ||
| 2261 | return yield* authRequiredError; | ||
| 2262 | } | ||
| 2263 | |||
| 2264 | const authenticatePayload = { | ||
| 2265 | methodId: authMethodId, | ||
| 2266 | } satisfies EffectAcpSchema.AuthenticateRequest; | ||
| 2267 | yield* runLoggedRequest( | ||
| 2268 | "authenticate", | ||
| 2269 | authenticatePayload, | ||
| 2270 | acp.agent.authenticate(authenticatePayload), | ||
| 2271 | ); | ||
| 2272 | }); | ||
| 2273 | |||
| 2274 | const setupSession = Effect.gen(function* () { | ||
| 2275 | let sessionId: string; | ||
| 2276 | let sessionSetupResult: | ||
| 2277 | | EffectAcpSchema.LoadSessionResponse | ||
| 2278 | | EffectAcpSchema.NewSessionResponse | ||
| 2279 | | EffectAcpSchema.ResumeSessionResponse; | ||
| 2280 | const additionalDirectories = options.additionalDirectories?.length | ||
| 2281 | ? { additionalDirectories: options.additionalDirectories } | ||
| 2282 | : {}; | ||
| 2283 | if (options.resumeSessionId) { | ||
| 2284 | sessionId = options.resumeSessionId; | ||
| 2285 | if ( | ||
| 2286 | initializeResult.agentCapabilities?.loadSession === true && | ||
| 2287 | options.resumeMethod !== "resume" | ||
| 2288 | ) { | ||
| 2289 | const loadPayload = { | ||
| 2290 | sessionId, | ||
| 2291 | cwd: options.cwd, | ||
| 2292 | ...additionalDirectories, | ||
| 2293 | mcpServers: sessionMcpServers(initializeResult), | ||
| 2294 | } satisfies EffectAcpSchema.LoadSessionRequest; | ||
| 2295 | sessionSetupResult = yield* runLoadSessionWithReplayIdle(loadPayload, initializeResult); | ||
| 2296 | } else if (initializeResult.agentCapabilities?.sessionCapabilities?.resume != null) { | ||
| 2297 | const resumePayload = { | ||
| 2298 | sessionId, | ||
| 2299 | cwd: options.cwd, | ||
| 2300 | ...additionalDirectories, | ||
| 2301 | mcpServers: sessionMcpServers(initializeResult), | ||
| 2302 | } satisfies EffectAcpSchema.ResumeSessionRequest; | ||
| 2303 | sessionSetupResult = yield* runLoggedRequest( | ||
| 2304 | "session/resume", | ||
| 2305 | resumePayload, | ||
| 2306 | acp.agent.resumeSession(resumePayload), | ||
| 2307 | ); | ||
| 2308 | } else { | ||
| 2309 | return yield* new EffectAcpErrors.AcpRequestError({ | ||
| 2310 | code: -32601, | ||
| 2311 | errorMessage: "ACP agent does not advertise session/load or session/resume support", | ||
| 2312 | }); | ||
| 2313 | } | ||
| 2314 | } else { | ||
| 2315 | const createPayload = { | ||
| 2316 | cwd: options.cwd, | ||
| 2317 | mcpServers: sessionMcpServers(initializeResult), | ||
| 2318 | ...additionalDirectories, | ||
| 2319 | } satisfies EffectAcpSchema.NewSessionRequest; | ||
| 2320 | const created = yield* runLoggedRequest( | ||
| 2321 | "session/new", | ||
| 2322 | createPayload, | ||
| 2323 | acp.agent.createSession(createPayload), | ||
| 2324 | ); | ||
| 2325 | sessionId = created.sessionId; | ||
| 2326 | sessionSetupResult = created; | ||
| 2327 | } | ||
| 2328 | |||
| 2329 | return { sessionId, sessionSetupResult }; | ||
| 2330 | }); | ||
| 2331 | |||
| 2332 | if (options.authenticateEagerly === true) { | ||
| 2333 | // The configured method is authoritative here: Antigravity accepts | ||
| 2334 | // methods (API key, Vertex) it does not list in `initialize`. | ||
| 2335 | const authenticatePayload = { | ||
| 2336 | methodId: options.authMethodId ?? "", | ||
| 2337 | } satisfies EffectAcpSchema.AuthenticateRequest; | ||
| 2338 | yield* runLoggedRequest( | ||
| 2339 | "authenticate", | ||
| 2340 | authenticatePayload, | ||
| 2341 | acp.agent.authenticate(authenticatePayload), | ||
| 2342 | ); | ||
| 2343 | } | ||
| 2344 | const { sessionId, sessionSetupResult } = yield* setupSession.pipe( | ||
| 2345 | Effect.catch((error) => | ||
| 2346 | !isAcpAuthenticationRequired(error) || options.authenticateOnAuthRequired === false | ||
| 2347 | ? Effect.fail(error) | ||
| 2348 | : authenticateAfterRequired(error).pipe(Effect.andThen(setupSession)), | ||
| 2349 | ), | ||
| 2350 | ); | ||
| 2351 | |||
| 2352 | yield* Ref.set(modeStateRef, parseSessionModeState(sessionSetupResult)); | ||
| 2353 | yield* Ref.set(configOptionsRef, sessionConfigOptionsFromSetup(sessionSetupResult)); | ||
| 2354 | |||
| 2355 | const nextState = { | ||
| 2356 | sessionId, | ||
| 2357 | initializeResult, | ||
| 2358 | sessionSetupResult, | ||
| 2359 | modelConfigId: extractModelConfigId(sessionSetupResult), | ||
| 2360 | } satisfies AcpStartedState; | ||
| 2361 | return nextState; | ||
| 2362 | }); | ||
| 2363 | |||
| 2364 | const start = Effect.gen(function* () { | ||
| 2365 | yield* ensureConnected; | ||
| 2366 | const deferred = yield* Deferred.make< | ||
| 2367 | AcpSessionRuntimeStartResult, | ||
| 2368 | EffectAcpErrors.AcpError | ||
| 2369 | >(); | ||
| 2370 | const effect = yield* Ref.modify(startStateRef, (state) => { | ||
| 2371 | switch (state._tag) { | ||
| 2372 | case "Started": | ||
| 2373 | return [Effect.succeed(state.result), state] as const; | ||
| 2374 | case "Starting": | ||
| 2375 | return [Deferred.await(state.deferred), state] as const; | ||
| 2376 | case "NotStarted": | ||
| 2377 | return [ | ||
| 2378 | startOnce.pipe( | ||
| 2379 | Effect.tap((result) => | ||
| 2380 | notificationSemaphore.withPermit( | ||
| 2381 | Effect.gen(function* () { | ||
| 2382 | const error = yield* Ref.get(terminationErrorRef); | ||
| 2383 | if (Option.isSome(error)) { | ||
| 2384 | return yield* error.value; | ||
| 2385 | } | ||
| 2386 | yield* Ref.set(startStateRef, { _tag: "Started", result }); | ||
| 2387 | const metadata = yield* Ref.getAndSet(startupMetadataRef, []); | ||
| 2388 | for (const notification of metadata) { | ||
| 2389 | if (notification.sessionId === result.sessionId) { | ||
| 2390 | yield* processSessionUpdate(notification); | ||
| 2391 | } | ||
| 2392 | } | ||
| 2393 | yield* Deferred.succeed(deferred, result); | ||
| 2394 | }), | ||
| 2395 | ), | ||
| 2396 | ), | ||
| 2397 | Effect.onError((cause) => | ||
| 2398 | Deferred.failCause(deferred, cause).pipe( | ||
| 2399 | Effect.andThen(Ref.set(startStateRef, { _tag: "NotStarted" })), | ||
| 2400 | Effect.andThen(Ref.set(startupMetadataRef, [])), | ||
| 2401 | ), | ||
| 2402 | ), | ||
| 2403 | ), | ||
| 2404 | { _tag: "Starting", deferred } satisfies AcpStartState, | ||
| 2405 | ] as const; | ||
| 2406 | } | ||
| 2407 | }); | ||
| 2408 | return yield* effect; | ||
| 2409 | }); | ||
| 2410 | |||
| 2411 | const drainEvents = Effect.gen(function* () { | ||
| 2412 | if (yield* Ref.get(stoppingRef)) { | ||
| 2413 | return; | ||
| 2414 | } | ||
| 2415 | const acknowledge = yield* Deferred.make<void>(); | ||
| 2416 | yield* notificationSemaphore.withPermit( | ||
| 2417 | Effect.gen(function* () { | ||
| 2418 | // Keep a provider's final flushed chunks together until the adapter settles the turn. | ||
| 2419 | if (Option.isNone(yield* Ref.get(activePromptRef))) { | ||
| 2420 | yield* Ref.set(assistantUpdatesOpenRef, false); | ||
| 2421 | yield* closeActiveAssistantSegment({ queue: eventQueue, assistantSegmentRef }); | ||
| 2422 | } | ||
| 2423 | yield* Queue.offer(eventQueue, { _tag: "EventStreamBarrier", acknowledge }); | ||
| 2424 | }), | ||
| 2425 | ); | ||
| 2426 | yield* Effect.raceFirst(Deferred.await(acknowledge), Deferred.await(runtimeClosed)); | ||
| 2427 | }); | ||
| 2428 | |||
| 2429 | const retireRuntime = Effect.fn("AcpSessionRuntime.retireRuntime")(function* ( | ||
| 2430 | error: EffectAcpErrors.AcpError, | ||
| 2431 | ) { | ||
| 2432 | yield* recordTermination(error); | ||
| 2433 | yield* child.kill({ forceKillAfter: "1 second" }).pipe(Effect.ignore); | ||
| 2434 | }); | ||
| 2435 | |||
| 2436 | const cancel = Effect.gen(function* () { | ||
| 2437 | const started = yield* getStartedState; | ||
| 2438 | const activePrompt = yield* Ref.get(activePromptRef); | ||
| 2439 | const cancelPayload = { | ||
| 2440 | sessionId: started.sessionId, | ||
| 2441 | ...(options.cancelMeta === undefined ? {} : { _meta: options.cancelMeta }), | ||
| 2442 | } satisfies EffectAcpSchema.CancelNotification; | ||
| 2443 | if (options.cancelBehavior !== "wait-for-prompt") { | ||
| 2444 | if (options.interruptPromptOnCancel === false) { | ||
| 2445 | return yield* acp.agent.cancel(cancelPayload); | ||
| 2446 | } | ||
| 2447 | if (Option.isSome(activePrompt)) { | ||
| 2448 | yield* Fiber.interrupt(activePrompt.value.fiber).pipe(Effect.ignore); | ||
| 2449 | } | ||
| 2450 | // Write cancel before a replacement prompt can reach the agent. | ||
| 2451 | yield* acp.agent.cancel(cancelPayload).pipe(Effect.ignore); | ||
| 2452 | return; | ||
| 2453 | } | ||
| 2454 | |||
| 2455 | yield* acp.agent.cancel(cancelPayload); | ||
| 2456 | if (Option.isNone(activePrompt)) { | ||
| 2457 | return; | ||
| 2458 | } | ||
| 2459 | const completed = yield* Effect.gen(function* () { | ||
| 2460 | const result = yield* Fiber.await(activePrompt.value.fiber); | ||
| 2461 | yield* Deferred.await(activePrompt.value.completed); | ||
| 2462 | if (Option.isNone(yield* Ref.get(terminationErrorRef))) { | ||
| 2463 | yield* drainEvents; | ||
| 2464 | } | ||
| 2465 | return result; | ||
| 2466 | }).pipe(Effect.timeoutOption(options.cancelTimeout ?? defaultCancelTimeout)); | ||
| 2467 | if (Option.isNone(completed)) { | ||
| 2468 | const error = new EffectAcpErrors.AcpTransportError({ | ||
| 2469 | operation: "call-rpc", | ||
| 2470 | method: "session/cancel", | ||
| 2471 | detail: "The ACP agent did not finish cancellation. Its process was stopped.", | ||
| 2472 | cause: undefined, | ||
| 2473 | }); | ||
| 2474 | yield* retireRuntime(error); | ||
| 2475 | return yield* error; | ||
| 2476 | } | ||
| 2477 | if (Exit.isFailure(completed.value)) { | ||
| 2478 | return yield* Effect.failCause(completed.value.cause); | ||
| 2479 | } | ||
| 2480 | }); | ||
| 2481 | |||
| 2482 | return { | ||
| 2483 | processContainment: | ||
| 2484 | linuxCgroupLease !== undefined | ||
| 2485 | ? "cgroup-v2" | ||
| 2486 | : options.ownDescendantProcessGroups === true | ||
| 2487 | ? "process-ledger-reduced-guarantee" | ||
| 2488 | : options.ownDetachedProcessGroup === true | ||
| 2489 | ? "process-group" | ||
| 2490 | : "none", | ||
| 2491 | handleRequestPermission: acp.handleRequestPermission, | ||
| 2492 | handleElicitation: acp.handleElicitation, | ||
| 2493 | handleMcpConnect: acp.handleMcpConnect, | ||
| 2494 | handleMcpMessage: acp.handleMcpMessage, | ||
| 2495 | handleMcpDisconnect: acp.handleMcpDisconnect, | ||
| 2496 | handleMcpNotification: acp.handleMcpNotification, | ||
| 2497 | handleReadTextFile: acp.handleReadTextFile, | ||
| 2498 | handleWriteTextFile: acp.handleWriteTextFile, | ||
| 2499 | handleCreateTerminal: acp.handleCreateTerminal, | ||
| 2500 | handleTerminalOutput: acp.handleTerminalOutput, | ||
| 2501 | handleTerminalWaitForExit: acp.handleTerminalWaitForExit, | ||
| 2502 | handleTerminalKill: acp.handleTerminalKill, | ||
| 2503 | handleTerminalRelease: acp.handleTerminalRelease, | ||
| 2504 | handleSessionUpdate: acp.handleSessionUpdate, | ||
| 2505 | handleElicitationComplete: acp.handleElicitationComplete, | ||
| 2506 | handleUnknownExtRequest: acp.handleUnknownExtRequest, | ||
| 2507 | handleUnknownExtNotification: acp.handleUnknownExtNotification, | ||
| 2508 | handleExtRequest: acp.handleExtRequest, | ||
| 2509 | handleExtNotification: acp.handleExtNotification, | ||
| 2510 | initialize: () => initialize, | ||
| 2511 | authenticate: (methodId) => | ||
| 2512 | initialize.pipe( | ||
| 2513 | Effect.andThen( | ||
| 2514 | runLoggedRequest("authenticate", { methodId }, acp.agent.authenticate({ methodId })), | ||
| 2515 | ), | ||
| 2516 | Effect.asVoid, | ||
| 2517 | ), | ||
| 2518 | start: () => start, | ||
| 2519 | getEvents: () => Stream.fromQueue(eventQueue), | ||
| 2520 | drainEvents, | ||
| 2521 | getModeState: Ref.get(modeStateRef), | ||
| 2522 | getConfigOptions: Ref.get(configOptionsRef), | ||
| 2523 | loadSession: (sessionId, activationOptions) => | ||
| 2524 | start.pipe( | ||
| 2525 | Effect.flatMap((started) => { | ||
| 2526 | const requestPayload = { | ||
| 2527 | sessionId, | ||
| 2528 | cwd: options.cwd, | ||
| 2529 | mcpServers: sessionMcpServers(started.initializeResult, activationOptions), | ||
| 2530 | } satisfies EffectAcpSchema.LoadSessionRequest; | ||
| 2531 | return runLoadSessionWithReplayIdle(requestPayload, started.initializeResult); | ||
| 2532 | }), | ||
| 2533 | Effect.flatMap((response) => adoptSession(sessionId, response)), | ||
| 2534 | ), | ||
| 2535 | resumeSession: (sessionId, activationOptions) => | ||
| 2536 | start.pipe( | ||
| 2537 | Effect.flatMap((started) => { | ||
| 2538 | const requestPayload = { | ||
| 2539 | sessionId, | ||
| 2540 | cwd: options.cwd, | ||
| 2541 | mcpServers: sessionMcpServers(started.initializeResult, activationOptions), | ||
| 2542 | } satisfies EffectAcpSchema.ResumeSessionRequest; | ||
| 2543 | return runLoggedRequest( | ||
| 2544 | "session/resume", | ||
| 2545 | requestPayload, | ||
| 2546 | acp.agent.resumeSession(requestPayload), | ||
| 2547 | ); | ||
| 2548 | }), | ||
| 2549 | Effect.flatMap((response) => adoptSession(sessionId, response)), | ||
| 2550 | ), | ||
| 2551 | forkSession: (sessionId, activationOptions) => | ||
| 2552 | start.pipe( | ||
| 2553 | Effect.flatMap((started) => { | ||
| 2554 | const requestPayload = { | ||
| 2555 | sessionId, | ||
| 2556 | cwd: options.cwd, | ||
| 2557 | mcpServers: sessionMcpServers(started.initializeResult, activationOptions), | ||
| 2558 | } satisfies EffectAcpSchema.ForkSessionRequest; | ||
| 2559 | return runLoggedRequest( | ||
| 2560 | "session/fork", | ||
| 2561 | requestPayload, | ||
| 2562 | acp.agent.forkSession(requestPayload), | ||
| 2563 | ); | ||
| 2564 | }), | ||
| 2565 | Effect.flatMap((response) => adoptSession(response.sessionId, response)), | ||
| 2566 | ), | ||
| 2567 | listSessions: (cursor) => { | ||
| 2568 | const requestPayload = { | ||
| 2569 | cwd: options.cwd, | ||
| 2570 | ...(cursor === undefined ? {} : { cursor }), | ||
| 2571 | } satisfies EffectAcpSchema.ListSessionsRequest; | ||
| 2572 | return initialize.pipe( | ||
| 2573 | Effect.filterOrFail( | ||
| 2574 | (initialized) => initialized.agentCapabilities?.sessionCapabilities?.list != null, | ||
| 2575 | () => | ||
| 2576 | new EffectAcpErrors.AcpRequestError({ | ||
| 2577 | code: -32601, | ||
| 2578 | errorMessage: "ACP agent does not advertise session/list support", | ||
| 2579 | }), | ||
| 2580 | ), | ||
| 2581 | Effect.andThen( | ||
| 2582 | runLoggedRequest( | ||
| 2583 | "session/list", | ||
| 2584 | requestPayload, | ||
| 2585 | acp.agent.listSessions(requestPayload), | ||
| 2586 | ), | ||
| 2587 | ), | ||
| 2588 | ); | ||
| 2589 | }, | ||
| 2590 | closeSession: (sessionId) => | ||
| 2591 | start.pipe( | ||
| 2592 | Effect.flatMap((started) => { | ||
| 2593 | const requestPayload = { | ||
| 2594 | sessionId: sessionId ?? started.sessionId, | ||
| 2595 | } satisfies EffectAcpSchema.CloseSessionRequest; | ||
| 2596 | return runLoggedRequest( | ||
| 2597 | "session/close", | ||
| 2598 | requestPayload, | ||
| 2599 | acp.agent.closeSession(requestPayload), | ||
| 2600 | ); | ||
| 2601 | }), | ||
| 2602 | ), | ||
| 2603 | deleteSession: (sessionId) => | ||
| 2604 | initialize.pipe( | ||
| 2605 | Effect.filterOrFail( | ||
| 2606 | (initialized) => initialized.agentCapabilities?.sessionCapabilities?.delete != null, | ||
| 2607 | () => | ||
| 2608 | new EffectAcpErrors.AcpRequestError({ | ||
| 2609 | code: -32601, | ||
| 2610 | errorMessage: "ACP agent does not advertise session/delete support", | ||
| 2611 | }), | ||
| 2612 | ), | ||
| 2613 | Effect.andThen( | ||
| 2614 | runLoggedRequest( | ||
| 2615 | "session/delete", | ||
| 2616 | { sessionId }, | ||
| 2617 | acp.agent.deleteSession({ sessionId }), | ||
| 2618 | ), | ||
| 2619 | ), | ||
| 2620 | ), | ||
| 2621 | listProviders: initialize.pipe( | ||
| 2622 | Effect.filterOrFail( | ||
| 2623 | (initialized) => initialized.agentCapabilities?.providers != null, | ||
| 2624 | () => | ||
| 2625 | new EffectAcpErrors.AcpRequestError({ | ||
| 2626 | code: -32601, | ||
| 2627 | errorMessage: "ACP agent does not advertise provider configuration support", | ||
| 2628 | }), | ||
| 2629 | ), | ||
| 2630 | Effect.andThen(runLoggedRequest("providers/list", {}, acp.agent.listProviders({}))), | ||
| 2631 | ), | ||
| 2632 | setProvider: (provider) => | ||
| 2633 | initialize.pipe( | ||
| 2634 | Effect.filterOrFail( | ||
| 2635 | (initialized) => initialized.agentCapabilities?.providers != null, | ||
| 2636 | () => | ||
| 2637 | new EffectAcpErrors.AcpRequestError({ | ||
| 2638 | code: -32601, | ||
| 2639 | errorMessage: "ACP agent does not advertise provider configuration support", | ||
| 2640 | }), | ||
| 2641 | ), | ||
| 2642 | Effect.andThen( | ||
| 2643 | runLoggedRequest( | ||
| 2644 | "providers/set", | ||
| 2645 | { | ||
| 2646 | ...provider, | ||
| 2647 | ...(provider.headers === undefined | ||
| 2648 | ? {} | ||
| 2649 | : { | ||
| 2650 | headers: Object.fromEntries( | ||
| 2651 | Object.keys(provider.headers).map((name) => [name, "[redacted]"]), | ||
| 2652 | ), | ||
| 2653 | }), | ||
| 2654 | }, | ||
| 2655 | acp.agent.setProvider(provider), | ||
| 2656 | ), | ||
| 2657 | ), | ||
| 2658 | ), | ||
| 2659 | disableProvider: (providerId) => { | ||
| 2660 | const request = { providerId } satisfies EffectAcpSchema.DisableProviderRequest; | ||
| 2661 | return initialize.pipe( | ||
| 2662 | Effect.filterOrFail( | ||
| 2663 | (initialized) => initialized.agentCapabilities?.providers != null, | ||
| 2664 | () => | ||
| 2665 | new EffectAcpErrors.AcpRequestError({ | ||
| 2666 | code: -32601, | ||
| 2667 | errorMessage: "ACP agent does not advertise provider configuration support", | ||
| 2668 | }), | ||
| 2669 | ), | ||
| 2670 | Effect.andThen( | ||
| 2671 | runLoggedRequest("providers/disable", request, acp.agent.disableProvider(request)), | ||
| 2672 | ), | ||
| 2673 | ); | ||
| 2674 | }, | ||
| 2675 | logout: initialize.pipe( | ||
| 2676 | Effect.filterOrFail( | ||
| 2677 | (initialized) => initialized.agentCapabilities?.auth?.logout != null, | ||
| 2678 | () => | ||
| 2679 | new EffectAcpErrors.AcpRequestError({ | ||
| 2680 | code: -32601, | ||
| 2681 | errorMessage: "ACP agent does not advertise logout support", | ||
| 2682 | }), | ||
| 2683 | ), | ||
| 2684 | Effect.andThen(runLoggedRequest("logout", {}, acp.agent.logout({}))), | ||
| 2685 | ), | ||
| 2686 | prompt: (payload, promptOptions?) => | ||
| 2687 | promptSerializationSemaphore.withPermit( | ||
| 2688 | Effect.acquireUseRelease( | ||
| 2689 | promptDispatchSemaphore.withPermit( | ||
| 2690 | Effect.gen(function* () { | ||
| 2691 | const started = yield* getStartedState; | ||
| 2692 | yield* closeActiveAssistantSegment({ queue: eventQueue, assistantSegmentRef }); | ||
| 2693 | yield* Ref.set(assistantUpdatesOpenRef, true); | ||
| 2694 | const requestPayload = { | ||
| 2695 | sessionId: started.sessionId, | ||
| 2696 | ...payload, | ||
| 2697 | } satisfies EffectAcpSchema.PromptRequest; | ||
| 2698 | const completed = yield* Deferred.make<void>(); | ||
| 2699 | const fiber = yield* runLoggedRequest( | ||
| 2700 | "session/prompt", | ||
| 2701 | requestPayload, | ||
| 2702 | acp.agent.prompt(requestPayload), | ||
| 2703 | ).pipe(Effect.forkIn(runtimeScope)); | ||
| 2704 | const active = { fiber, completed } satisfies AcpActivePrompt; | ||
| 2705 | yield* Ref.set(activePromptRef, Option.some(active)); | ||
| 2706 | if (promptOptions?.dispatched) { | ||
| 2707 | yield* Deferred.succeed(promptOptions.dispatched, undefined); | ||
| 2708 | } | ||
| 2709 | return active; | ||
| 2710 | }).pipe(notificationSemaphore.withPermit), | ||
| 2711 | ), | ||
| 2712 | (activePrompt) => | ||
| 2713 | Fiber.join(activePrompt.fiber).pipe( | ||
| 2714 | Effect.catchCause((cause) => | ||
| 2715 | options.cancelBehavior !== "wait-for-prompt" && Cause.hasInterruptsOnly(cause) | ||
| 2716 | ? Effect.succeed({ | ||
| 2717 | stopReason: "cancelled", | ||
| 2718 | } satisfies EffectAcpSchema.PromptResponse) | ||
| 2719 | : Effect.failCause(cause), | ||
| 2720 | ), | ||
| 2721 | Effect.tap(() => | ||
| 2722 | closeActiveAssistantSegment({ queue: eventQueue, assistantSegmentRef }), | ||
| 2723 | ), | ||
| 2724 | ), | ||
| 2725 | (activePrompt, result) => | ||
| 2726 | Effect.gen(function* () { | ||
| 2727 | if ( | ||
| 2728 | options.cancelBehavior === "wait-for-prompt" && | ||
| 2729 | Exit.isFailure(result) && | ||
| 2730 | Cause.hasInterrupts(result.cause) | ||
| 2731 | ) { | ||
| 2732 | yield* retireRuntime( | ||
| 2733 | new EffectAcpErrors.AcpTransportError({ | ||
| 2734 | method: "session/prompt", | ||
| 2735 | detail: "The ACP prompt stopped before the agent confirmed completion.", | ||
| 2736 | cause: undefined, | ||
| 2737 | }), | ||
| 2738 | ); | ||
| 2739 | } | ||
| 2740 | yield* Fiber.interrupt(activePrompt.fiber).pipe(Effect.ignore); | ||
| 2741 | yield* Ref.set(activePromptRef, Option.none()); | ||
| 2742 | yield* Deferred.succeed(activePrompt.completed, undefined); | ||
| 2743 | }), | ||
| 2744 | ), | ||
| 2745 | ), | ||
| 2746 | cancel: | ||
| 2747 | options.cancelBehavior === "wait-for-prompt" | ||
| 2748 | ? promptDispatchSemaphore.withPermit(cancel) | ||
| 2749 | : cancel, | ||
| 2750 | ...(options.ownDetachedProcessGroup === true ? { terminateProcessGroup } : {}), | ||
| 2751 | // A session's mode is its `category: "mode"` config option. ACP v1 agents | ||
| 2752 | // that only advertise `modes` (gemini-cli) take `session/set_mode` | ||
| 2753 | // instead, which ACP v2 removed. | ||
| 2754 | setMode: (modeId) => | ||
| 2755 | Effect.gen(function* () { | ||
| 2756 | const modeState = yield* Ref.get(modeStateRef); | ||
| 2757 | if (modeState?.currentModeId === modeId) { | ||
| 2758 | return {} satisfies EffectAcpSchema.SetSessionModeResponse; | ||
| 2759 | } | ||
| 2760 | const modeConfigOption = (yield* Ref.get(configOptionsRef))?.find( | ||
| 2761 | (option) => option.category === "mode" && option.type === "select", | ||
| 2762 | ); | ||
| 2763 | if (modeConfigOption === undefined && modeState !== undefined) { | ||
| 2764 | const started = yield* getStartedState; | ||
| 2765 | const payload = { sessionId: started.sessionId, modeId }; | ||
| 2766 | yield* runLoggedRequest("session/set_mode", payload, acp.agent.setSessionMode(payload)); | ||
| 2767 | yield* updateCurrentModeId(modeId); | ||
| 2768 | return {} satisfies EffectAcpSchema.SetSessionModeResponse; | ||
| 2769 | } | ||
| 2770 | const response = yield* setConfigOption(modeConfigOption?.id ?? "mode", modeId); | ||
| 2771 | // The agent answers with its config options, so the mode it reports | ||
| 2772 | // is the mode it runs in, even when it kept another one. | ||
| 2773 | const reported = parseSessionModeState({ configOptions: response.configOptions }); | ||
| 2774 | if (reported === undefined) { | ||
| 2775 | yield* updateCurrentModeId(modeId); | ||
| 2776 | } else { | ||
| 2777 | yield* updateCurrentModeId(reported.currentModeId); | ||
| 2778 | } | ||
| 2779 | return {} satisfies EffectAcpSchema.SetSessionModeResponse; | ||
| 2780 | }), | ||
| 2781 | setSessionModel: (modelId, meta) => | ||
| 2782 | getStartedState.pipe( | ||
| 2783 | Effect.flatMap((started) => { | ||
| 2784 | const payload = { | ||
| 2785 | sessionId: started.sessionId, | ||
| 2786 | modelId, | ||
| 2787 | ...(meta === undefined ? {} : { _meta: meta }), | ||
| 2788 | }; | ||
| 2789 | return runLoggedRequest( | ||
| 2790 | "session/set_model", | ||
| 2791 | payload, | ||
| 2792 | acp.agent.setSessionModel(payload), | ||
| 2793 | ); | ||
| 2794 | }), | ||
| 2795 | ), | ||
| 2796 | setConfigOption, | ||
| 2797 | setModel: (model) => | ||
| 2798 | getStartedState.pipe( | ||
| 2799 | Effect.flatMap((started) => setConfigOption(started.modelConfigId ?? "model", model)), | ||
| 2800 | Effect.asVoid, | ||
| 2801 | ), | ||
| 2802 | |||
| 2803 | request: (method, payload) => | ||
| 2804 | ensureConnected.pipe( | ||
| 2805 | Effect.andThen(runLoggedRequest(method, payload, acp.raw.request(method, payload))), | ||
| 2806 | ), | ||
| 2807 | notify: (method, payload) => | ||
| 2808 | ensureConnected.pipe(Effect.andThen(acp.raw.notify(method, payload))), | ||
| 2809 | } satisfies AcpSessionRuntime["Service"]; | ||
| 2810 | }); | ||
| 2811 | |||
| 2812 | export const layer = ( | ||
| 2813 | options: AcpSessionRuntimeOptions, | ||
| 2814 | ): Layer.Layer< | ||
| 2815 | AcpSessionRuntime, | ||
| 2816 | EffectAcpErrors.AcpError, | ||
| 2817 | ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | ||
| 2818 | > => Layer.effect(AcpSessionRuntime, make(options)); | ||
| 2819 | |||
| 2820 | function sessionConfigOptionsFromSetup( | ||
| 2821 | response: | ||
| 2822 | | { | ||
| 2823 | readonly configOptions?: ReadonlyArray<EffectAcpSchema.SessionConfigOption> | null; | ||
| 2824 | } | ||
| 2825 | | undefined, | ||
| 2826 | ): ReadonlyArray<EffectAcpSchema.SessionConfigOption> { | ||
| 2827 | return response?.configOptions ?? []; | ||
| 2828 | } | ||
| 2829 | |||
| 2830 | function configOptionCurrentValueMatches( | ||
| 2831 | configOption: EffectAcpSchema.SessionConfigOption, | ||
| 2832 | value: string | boolean, | ||
| 2833 | ): boolean { | ||
| 2834 | const currentValue = configOption.currentValue; | ||
| 2835 | if (configOption.type === "boolean") { | ||
| 2836 | return currentValue === value; | ||
| 2837 | } | ||
| 2838 | if (typeof currentValue !== "string") { | ||
| 2839 | return false; | ||
| 2840 | } | ||
| 2841 | return currentValue.trim() === String(value).trim(); | ||
| 2842 | } | ||
| 2843 | |||
| 2844 | function isStartupMetadataUpdate(notification: EffectAcpSchema.SessionNotification): boolean { | ||
| 2845 | switch (notification.update.sessionUpdate) { | ||
| 2846 | case "current_mode_update": | ||
| 2847 | case "config_option_update": | ||
| 2848 | case "available_commands_update": | ||
| 2849 | return true; | ||
| 2850 | default: | ||
| 2851 | return false; | ||
| 2852 | } | ||
| 2853 | } | ||
| 2854 | |||
| 2855 | const handleSessionUpdate = ({ | ||
| 2856 | queue, | ||
| 2857 | modeStateRef, | ||
| 2858 | configOptionsRef, | ||
| 2859 | toolCallsRef, | ||
| 2860 | shownToolCallIds, | ||
| 2861 | assistantSegmentRef, | ||
| 2862 | assistantItemRuntimeId, | ||
| 2863 | params, | ||
| 2864 | }: { | ||
| 2865 | readonly queue: Queue.Queue<AcpSessionRuntimeEvent>; | ||
| 2866 | readonly modeStateRef: Ref.Ref<AcpSessionModeState | undefined>; | ||
| 2867 | readonly configOptionsRef: Ref.Ref<ReadonlyArray<EffectAcpSchema.SessionConfigOption>>; | ||
| 2868 | readonly toolCallsRef: Ref.Ref<Map<string, AcpToolCallTrackedState>>; | ||
| 2869 | readonly shownToolCallIds: Set<string>; | ||
| 2870 | readonly assistantSegmentRef: Ref.Ref<AcpAssistantSegmentState>; | ||
| 2871 | readonly assistantItemRuntimeId: string; | ||
| 2872 | readonly params: EffectAcpSchema.SessionNotification; | ||
| 2873 | }): Effect.Effect<void> => | ||
| 2874 | Effect.gen(function* () { | ||
| 2875 | if (params.update.sessionUpdate === "config_option_update") { | ||
| 2876 | const configOptions = params.update.configOptions; | ||
| 2877 | yield* Ref.set(configOptionsRef, configOptions); | ||
| 2878 | yield* Ref.update( | ||
| 2879 | modeStateRef, | ||
| 2880 | (current) => parseSessionModeState({ configOptions }) ?? current, | ||
| 2881 | ); | ||
| 2882 | } | ||
| 2883 | const parsed = parseSessionUpdateEvent(params); | ||
| 2884 | if (parsed.modeId) { | ||
| 2885 | yield* Ref.update(modeStateRef, (current) => | ||
| 2886 | current === undefined ? current : updateModeState(current, parsed.modeId!), | ||
| 2887 | ); | ||
| 2888 | } | ||
| 2889 | for (const event of parsed.events) { | ||
| 2890 | if (event._tag === "ToolCallUpdated") { | ||
| 2891 | const { merged, decision, active } = yield* Ref.modify(toolCallsRef, (current) => { | ||
| 2892 | const tracked = current.get(event.toolCall.toolCallId); | ||
| 2893 | const previous = tracked?.state; | ||
| 2894 | const nextToolCall = mergeToolCallState(previous, event.toolCall); | ||
| 2895 | const decision = decideToolCallUpdateEmission({ | ||
| 2896 | previous, | ||
| 2897 | next: nextToolCall, | ||
| 2898 | lastEmittedDetailLength: tracked?.lastEmittedDetailLength, | ||
| 2899 | skippedSinceEmit: tracked?.skippedSinceEmit ?? 0, | ||
| 2900 | }); | ||
| 2901 | const next = new Map(current); | ||
| 2902 | if (nextToolCall.status === "completed" || nextToolCall.status === "failed") { | ||
| 2903 | next.delete(nextToolCall.toolCallId); | ||
| 2904 | } else { | ||
| 2905 | next.set(nextToolCall.toolCallId, { | ||
| 2906 | state: nextToolCall, | ||
| 2907 | lastEmittedDetailLength: decision.emit | ||
| 2908 | ? toolCallProgressLength(nextToolCall) | ||
| 2909 | : tracked?.lastEmittedDetailLength, | ||
| 2910 | skippedSinceEmit: decision.skippedSinceEmit, | ||
| 2911 | }); | ||
| 2912 | } | ||
| 2913 | return [{ merged: nextToolCall, decision, active: tracked !== undefined }, next] as const; | ||
| 2914 | }); | ||
| 2915 | if (!decision.emit) { | ||
| 2916 | continue; | ||
| 2917 | } | ||
| 2918 | // A new tool call is a boundary in the prose. Progress on a call that | ||
| 2919 | // is already shown, such as a background command finishing, is not. | ||
| 2920 | if (!shownToolCallIds.has(merged.toolCallId)) { | ||
| 2921 | shownToolCallIds.add(merged.toolCallId); | ||
| 2922 | // Only recent calls get late updates; keep a long session bounded. | ||
| 2923 | if (shownToolCallIds.size > MAX_SHOWN_TOOL_CALL_IDS) { | ||
| 2924 | shownToolCallIds.delete(shownToolCallIds.values().next().value!); | ||
| 2925 | } | ||
| 2926 | // A call still running is already on screen, even if it aged out. | ||
| 2927 | if (!active) yield* closeActiveAssistantSegment({ queue, assistantSegmentRef }); | ||
| 2928 | } | ||
| 2929 | yield* Queue.offer(queue, { | ||
| 2930 | _tag: "ToolCallUpdated", | ||
| 2931 | toolCall: merged, | ||
| 2932 | rawPayload: event.rawPayload, | ||
| 2933 | }); | ||
| 2934 | continue; | ||
| 2935 | } | ||
| 2936 | if (event._tag === "ContentDelta") { | ||
| 2937 | if (event.text.trim().length === 0) { | ||
| 2938 | const assistantSegmentState = yield* Ref.get(assistantSegmentRef); | ||
| 2939 | if (!assistantSegmentState.activeItemId) { | ||
| 2940 | continue; | ||
| 2941 | } | ||
| 2942 | } | ||
| 2943 | const itemId = yield* ensureActiveAssistantSegment({ | ||
| 2944 | queue, | ||
| 2945 | assistantSegmentRef, | ||
| 2946 | sessionId: params.sessionId, | ||
| 2947 | assistantItemRuntimeId, | ||
| 2948 | }); | ||
| 2949 | yield* Queue.offer(queue, { | ||
| 2950 | ...event, | ||
| 2951 | itemId, | ||
| 2952 | }); | ||
| 2953 | continue; | ||
| 2954 | } | ||
| 2955 | yield* Queue.offer(queue, event); | ||
| 2956 | } | ||
| 2957 | }); | ||
| 2958 | |||
| 2959 | function updateModeState(modeState: AcpSessionModeState, nextModeId: string): AcpSessionModeState { | ||
| 2960 | const normalized = nextModeId.trim(); | ||
| 2961 | if (!normalized) { | ||
| 2962 | return modeState; | ||
| 2963 | } | ||
| 2964 | return modeState.availableModes.some((mode) => mode.id === normalized) | ||
| 2965 | ? { | ||
| 2966 | ...modeState, | ||
| 2967 | currentModeId: normalized, | ||
| 2968 | } | ||
| 2969 | : modeState; | ||
| 2970 | } | ||
| 2971 | |||
| 2972 | const assistantItemId = (sessionId: string, runtimeId: string, segmentIndex: number) => | ||
| 2973 | `assistant:${sessionId}:runtime:${runtimeId}:segment:${segmentIndex}`; | ||
| 2974 | |||
| 2975 | const ensureActiveAssistantSegment = ({ | ||
| 2976 | queue, | ||
| 2977 | assistantSegmentRef, | ||
| 2978 | sessionId, | ||
| 2979 | assistantItemRuntimeId, | ||
| 2980 | }: { | ||
| 2981 | readonly queue: Queue.Queue<AcpSessionRuntimeEvent>; | ||
| 2982 | readonly assistantSegmentRef: Ref.Ref<AcpAssistantSegmentState>; | ||
| 2983 | readonly sessionId: string; | ||
| 2984 | readonly assistantItemRuntimeId: string; | ||
| 2985 | }) => | ||
| 2986 | Ref.modify<AcpAssistantSegmentState, EnsureActiveAssistantSegmentResult>( | ||
| 2987 | assistantSegmentRef, | ||
| 2988 | (current) => { | ||
| 2989 | if (current.activeItemId) { | ||
| 2990 | return [{ itemId: current.activeItemId }, current] as const; | ||
| 2991 | } | ||
| 2992 | const itemId = assistantItemId(sessionId, assistantItemRuntimeId, current.nextSegmentIndex); | ||
| 2993 | return [ | ||
| 2994 | { | ||
| 2995 | itemId, | ||
| 2996 | startedEvent: { | ||
| 2997 | _tag: "AssistantItemStarted", | ||
| 2998 | itemId, | ||
| 2999 | } satisfies Extract<AcpParsedSessionEvent, { readonly _tag: "AssistantItemStarted" }>, | ||
| 3000 | }, | ||
| 3001 | { | ||
| 3002 | nextSegmentIndex: current.nextSegmentIndex + 1, | ||
| 3003 | activeItemId: itemId, | ||
| 3004 | } satisfies AcpAssistantSegmentState, | ||
| 3005 | ] as const; | ||
| 3006 | }, | ||
| 3007 | ).pipe( | ||
| 3008 | Effect.flatMap((result) => | ||
| 3009 | result.startedEvent | ||
| 3010 | ? Queue.offer(queue, result.startedEvent).pipe(Effect.as(result.itemId)) | ||
| 3011 | : Effect.succeed(result.itemId), | ||
| 3012 | ), | ||
| 3013 | ); | ||
| 3014 | |||
| 3015 | const closeActiveAssistantSegment = ({ | ||
| 3016 | queue, | ||
| 3017 | assistantSegmentRef, | ||
| 3018 | }: { | ||
| 3019 | readonly queue: Queue.Queue<AcpSessionRuntimeEvent>; | ||
| 3020 | readonly assistantSegmentRef: Ref.Ref<AcpAssistantSegmentState>; | ||
| 3021 | }) => | ||
| 3022 | Ref.modify(assistantSegmentRef, (current) => { | ||
| 3023 | if (!current.activeItemId) { | ||
| 3024 | return [undefined, current] as const; | ||
| 3025 | } | ||
| 3026 | return [ | ||
| 3027 | { | ||
| 3028 | _tag: "AssistantItemCompleted", | ||
| 3029 | itemId: current.activeItemId, | ||
| 3030 | } satisfies AcpParsedSessionEvent, | ||
| 3031 | { | ||
| 3032 | nextSegmentIndex: current.nextSegmentIndex, | ||
| 3033 | } satisfies AcpAssistantSegmentState, | ||
| 3034 | ] as const; | ||
| 3035 | }).pipe(Effect.flatMap((event) => (event ? Queue.offer(queue, event) : Effect.void))); | ||
packages/provider-acp/src/server/adapter.ts created+8054| ... | @@ -0,0 +1,8054 @@ | ||
| 1 | // @effect-diagnostics nodeBuiltinImport:off | ||
| 2 | import * as NodePath from "node:path"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | type ChatAttachment, | ||
| 6 | type ModelSelection, | ||
| 7 | type OrchestrationV2ConversationMessage, | ||
| 8 | type OrchestrationV2ExecutionNode, | ||
| 9 | type OrchestrationV2PlanArtifact, | ||
| 10 | type OrchestrationV2PlanStep, | ||
| 11 | type OrchestrationV2ProviderCapabilities, | ||
| 12 | type OrchestrationV2ProviderFailure, | ||
| 13 | type OrchestrationV2ProviderRetry, | ||
| 14 | type OrchestrationV2ProviderSession, | ||
| 15 | type OrchestrationV2ProviderThread, | ||
| 16 | type OrchestrationV2ProviderThreadNativeMetadata, | ||
| 17 | type OrchestrationV2ProviderTurn, | ||
| 18 | type OrchestrationV2RuntimeRequest, | ||
| 19 | type OrchestrationV2Subagent, | ||
| 20 | type OrchestrationV2TurnItem, | ||
| 21 | type OrchestrationV2UserInputQuestion, | ||
| 22 | type OrchestrationV2WebSearchResult, | ||
| 23 | type ProviderApprovalDecision, | ||
| 24 | type ProviderApprovalOption, | ||
| 25 | type ProviderInstanceId, | ||
| 26 | type ProviderInteractionMode, | ||
| 27 | type ProviderDriverKind, | ||
| 28 | type ProviderRequestKind, | ||
| 29 | type ProviderThreadId, | ||
| 30 | type ProviderUserInputAnswers, | ||
| 31 | type RuntimeRequestId, | ||
| 32 | type ThreadTokenUsageSnapshot, | ||
| 33 | type ThreadId, | ||
| 34 | } from "@t3tools/contracts"; | ||
| 35 | import { modelSelectionsEqual } from "@t3tools/shared/model"; | ||
| 36 | import { type SelfInvocation, selfInvocationArgs } from "@t3tools/shared/nodeRuntime"; | ||
| 37 | import { FILE_HEADERS_ONLY, formatPatch, structuredPatch } from "diff"; | ||
| 38 | import * as Cause from "effect/Cause"; | ||
| 39 | import * as Crypto from "effect/Crypto"; | ||
| 40 | import * as DateTime from "effect/DateTime"; | ||
| 41 | import * as Deferred from "effect/Deferred"; | ||
| 42 | import type * as Duration from "effect/Duration"; | ||
| 43 | import * as Effect from "effect/Effect"; | ||
| 44 | import * as Exit from "effect/Exit"; | ||
| 45 | import * as FileSystem from "effect/FileSystem"; | ||
| 46 | import * as Fiber from "effect/Fiber"; | ||
| 47 | import * as Option from "effect/Option"; | ||
| 48 | import * as Queue from "effect/Queue"; | ||
| 49 | import * as Ref from "effect/Ref"; | ||
| 50 | import * as Result from "effect/Result"; | ||
| 51 | import * as Scope from "effect/Scope"; | ||
| 52 | import * as Semaphore from "effect/Semaphore"; | ||
| 53 | import * as Stream from "effect/Stream"; | ||
| 54 | import type { ChildProcessSpawner } from "effect/process"; | ||
| 55 | import * as EffectAcpErrors from "effect-acp/errors"; | ||
| 56 | import type * as EffectAcpProtocol from "effect-acp/protocol"; | ||
| 57 | import type * as EffectAcpSchema from "effect-acp/compat"; | ||
| 58 | |||
| 59 | import { formatReadToolLabel, formatSearchToolLabel } from "@t3tools/shared/toolActivity"; | ||
| 60 | import type * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; | ||
| 61 | import { makeAcpMcpOverAcpBridge, type AcpMcpOverAcpBridge } from "./mcpOverAcpBridge.ts"; | ||
| 62 | import { mcpToolPresentation } from "@t3tools/provider-core/server/mcpToolPresentation"; | ||
| 63 | import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; | ||
| 64 | import { | ||
| 65 | applyAcpAgentTerminalUpdate, | ||
| 66 | acpContentBlockDisplayText, | ||
| 67 | embeddedTerminalIdsFromSessionUpdate, | ||
| 68 | extractMcpToolCallIdentity, | ||
| 69 | mergeToolCallState, | ||
| 70 | parsePermissionRequest, | ||
| 71 | parseSessionUpdateEvent, | ||
| 72 | toolCallVisibleOutputChanged, | ||
| 73 | type AcpPlanUpdate, | ||
| 74 | type AcpAgentTerminalState, | ||
| 75 | type AcpSessionModeState, | ||
| 76 | type AcpToolCallState, | ||
| 77 | } from "./runtimeModel.ts"; | ||
| 78 | import { | ||
| 79 | acpClientExecuteDisposition, | ||
| 80 | acpMcpToolApprovalElicitationDisposition, | ||
| 81 | acpPermissionDisposition, | ||
| 82 | type AcpPermissionDisposition, | ||
| 83 | makeAcpClientPolicyGrants, | ||
| 84 | unknownRecord, | ||
| 85 | } from "./clientPolicy.ts"; | ||
| 86 | import { | ||
| 87 | makeAcpClientTerminals, | ||
| 88 | resolveEmbeddedTerminalContent, | ||
| 89 | type AcpClientTerminals, | ||
| 90 | } from "./clientTerminals.ts"; | ||
| 91 | import { ACP_SESSION_MODE_OPTION_ID } from "./sessionConfig.ts"; | ||
| 92 | import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; | ||
| 93 | import { | ||
| 94 | t3AcpPromptWithInstructions, | ||
| 95 | type T3AcpInstructionState, | ||
| 96 | } from "@t3tools/provider-core/server/orchestrationInstructions"; | ||
| 97 | import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions"; | ||
| 98 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; | ||
| 99 | import { type ProviderContinuationRequest } from "@t3tools/provider-core/server/continuationRequests"; | ||
| 100 | import { | ||
| 101 | type BackgroundWork, | ||
| 102 | type BackgroundWorkReport, | ||
| 103 | backgroundWorkNotification, | ||
| 104 | } from "@t3tools/provider-core/server/notification"; | ||
| 105 | import { | ||
| 106 | makeProviderFailure, | ||
| 107 | makeProviderRetryTurnItem, | ||
| 108 | } from "@t3tools/provider-core/server/failure"; | ||
| 109 | import { acpSelectionTransition } from "@t3tools/provider-core/server/selectionTransition"; | ||
| 110 | import { | ||
| 111 | isProviderNativeImageAttachment, | ||
| 112 | providerMessageTextWithAttachmentPaths, | ||
| 113 | } from "@t3tools/provider-core/server/attachmentPrompt"; | ||
| 114 | import { | ||
| 115 | makeSubagentChildThread, | ||
| 116 | makeSubagentConversationArtifacts, | ||
| 117 | subagentThreadTitle, | ||
| 118 | } from "@t3tools/provider-core/server/subagentProjection"; | ||
| 119 | import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; | ||
| 120 | |||
| 121 | export const ACP_PROTOCOL = "acp.ndjson-jsonrpc" as const; | ||
| 122 | |||
| 123 | /** | ||
| 124 | * Quiet window after a settled turn's last background rearm before it | ||
| 125 | * finalizes, so a slightly late post-hydration assistant chunk stays in the | ||
| 126 | * same turn. Grok commonly sends its final summary just over two seconds after | ||
| 127 | * the hydrated tool frame; two seconds split that tail into a second synthetic | ||
| 128 | * wake. Longer floors (4–20s) only prolonged Working. No per-model carveouts. | ||
| 129 | */ | ||
| 130 | const ACP_DEFERRED_FINALIZE_DEBOUNCE: Duration.Input = "3000 millis"; | ||
| 131 | |||
| 132 | export interface AcpAdapterV2RuntimeInput { | ||
| 133 | readonly cwd: string; | ||
| 134 | /** | ||
| 135 | * Policy the session opened with. A runtime-mode change reopens the session, | ||
| 136 | * so flavors that encode permissions in the launch command (Grok) read it here. | ||
| 137 | */ | ||
| 138 | readonly runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy; | ||
| 139 | readonly mcpServers: ReadonlyArray<EffectAcpSchema.McpServer>; | ||
| 140 | readonly acpMcpServers?: ReadonlyArray<EffectAcpSchema.McpServer>; | ||
| 141 | /** Scoped credentials for terminal fallback when an ACP agent drops `mcpServers`. */ | ||
| 142 | readonly processEnvironment?: NodeJS.ProcessEnv; | ||
| 143 | readonly resumeSessionId?: string; | ||
| 144 | readonly interruptPromptOnCancel?: boolean; | ||
| 145 | readonly clientCapabilities: EffectAcpSchema.InitializeRequest["clientCapabilities"]; | ||
| 146 | readonly clientInfo: AcpSessionRuntime.AcpSessionRuntimeOptions["clientInfo"]; | ||
| 147 | readonly requestLogger?: NonNullable<AcpSessionRuntime.AcpSessionRuntimeOptions["requestLogger"]>; | ||
| 148 | readonly protocolLogging: NonNullable< | ||
| 149 | AcpSessionRuntime.AcpSessionRuntimeOptions["protocolLogging"] | ||
| 150 | >; | ||
| 151 | readonly onTermination: NonNullable<AcpSessionRuntime.AcpSessionRuntimeOptions["onTermination"]>; | ||
| 152 | readonly onOutgoingResponseFailure?: AcpSessionRuntime.AcpSessionRuntimeOptions["onOutgoingResponseFailure"]; | ||
| 153 | readonly onOutgoingResponse?: AcpSessionRuntime.AcpSessionRuntimeOptions["onOutgoingResponse"]; | ||
| 154 | } | ||
| 155 | |||
| 156 | export type AcpAdapterV2NativeLogging = Pick< | ||
| 157 | AcpSessionRuntime.AcpSessionRuntimeOptions, | ||
| 158 | "requestLogger" | "protocolLogging" | ||
| 159 | >; | ||
| 160 | |||
| 161 | export interface AcpAdapterV2UserInputRequest { | ||
| 162 | readonly nativeItemId: string; | ||
| 163 | readonly nativeRequestId: string; | ||
| 164 | readonly questions: ReadonlyArray<OrchestrationV2UserInputQuestion>; | ||
| 165 | } | ||
| 166 | |||
| 167 | export interface AcpAdapterV2ExtensionContext { | ||
| 168 | readonly runtime: AcpSessionRuntime.AcpSessionRuntime["Service"]; | ||
| 169 | readonly reportProviderRetry: (input: { | ||
| 170 | readonly sessionId: string; | ||
| 171 | readonly failure: OrchestrationV2ProviderFailure; | ||
| 172 | }) => Effect.Effect<void>; | ||
| 173 | /** | ||
| 174 | * A subagent's structured end on the root session (Grok `subagent_finished`), | ||
| 175 | * keyed by its child session id. Finishes the subagent row, in the turn that | ||
| 176 | * holds it or in the carryover of a settled one. | ||
| 177 | */ | ||
| 178 | readonly finishSubagent: (notice: { | ||
| 179 | readonly sessionId: string; | ||
| 180 | readonly childSessionId: string; | ||
| 181 | readonly status: "completed" | "failed" | "cancelled"; | ||
| 182 | readonly result: string | null; | ||
| 183 | }) => Effect.Effect<void>; | ||
| 184 | /** | ||
| 185 | * Session-scoped background-task lifecycle reported via extension | ||
| 186 | * notifications (e.g. Grok `x.ai/task_backgrounded`; older builds use the | ||
| 187 | * underscore alias). Mutations for non-root sessions are ignored. A terminal | ||
| 188 | * mutation also finishes the tool that registered the task in a settled turn | ||
| 189 | * still held open for it, with `output` as its final text when given. | ||
| 190 | */ | ||
| 191 | readonly applyBackgroundTaskMutation: (mutation: { | ||
| 192 | readonly sessionId: string; | ||
| 193 | readonly taskId: string; | ||
| 194 | readonly status: "running" | "completed" | "failed"; | ||
| 195 | readonly output?: string; | ||
| 196 | /** What the task is, so a wake it causes can name it. */ | ||
| 197 | readonly report?: BackgroundWork; | ||
| 198 | }) => Effect.Effect<void>; | ||
| 199 | readonly requestUserInput: ( | ||
| 200 | input: AcpAdapterV2UserInputRequest, | ||
| 201 | requestContext: EffectAcpProtocol.AcpRequestContext, | ||
| 202 | ) => Effect.Effect< | ||
| 203 | { | ||
| 204 | readonly acknowledgeNativeResponse: Effect.Effect<void, EffectAcpErrors.AcpError>; | ||
| 205 | readonly answers: ProviderUserInputAnswers | null; | ||
| 206 | }, | ||
| 207 | EffectAcpErrors.AcpError | ||
| 208 | >; | ||
| 209 | /** Surfaces plan markdown as this turn's proposed-plan card (#8358). */ | ||
| 210 | readonly captureProposedPlan: (input: { readonly planMarkdown: string }) => Effect.Effect<void>; | ||
| 211 | /** Last markdown captured for the active turn, as the exit-gate fallback. */ | ||
| 212 | readonly lastProposedPlanMarkdown: Effect.Effect<string | undefined>; | ||
| 213 | } | ||
| 214 | |||
| 215 | export interface AcpAdapterV2Flavor { | ||
| 216 | /** Interprets provider-specific prompt errors before they cross into orchestration. */ | ||
| 217 | readonly promptFailure?: (cause: unknown) => OrchestrationV2ProviderFailure; | ||
| 218 | readonly driver: ProviderDriverKind; | ||
| 219 | readonly capabilities: OrchestrationV2ProviderCapabilities; | ||
| 220 | readonly clientCapabilitiesMeta?: Record<string, boolean>; | ||
| 221 | readonly normalizeSessionUpdate?: ( | ||
| 222 | notification: EffectAcpSchema.SessionNotification, | ||
| 223 | ) => EffectAcpSchema.SessionNotification; | ||
| 224 | readonly onAvailableCommandsUpdate?: ( | ||
| 225 | commands: ReadonlyArray<EffectAcpSchema.AvailableCommand>, | ||
| 226 | ) => Effect.Effect<void>; | ||
| 227 | readonly onSessionConfigurationUpdate?: ( | ||
| 228 | configOptions: ReadonlyArray<EffectAcpSchema.SessionConfigOption>, | ||
| 229 | modeState: AcpSessionModeState | undefined, | ||
| 230 | ) => Effect.Effect<void>; | ||
| 231 | readonly onUrlElicitation?: (input: { | ||
| 232 | readonly elicitationId: string; | ||
| 233 | readonly url: string; | ||
| 234 | readonly message: string; | ||
| 235 | }) => Effect.Effect<boolean>; | ||
| 236 | readonly withRuntimeStartup?: <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>; | ||
| 237 | readonly makeRuntime: ( | ||
| 238 | input: AcpAdapterV2RuntimeInput, | ||
| 239 | ) => Effect.Effect< | ||
| 240 | AcpSessionRuntime.AcpSessionRuntime["Service"], | ||
| 241 | EffectAcpErrors.AcpError, | ||
| 242 | Crypto.Crypto | Scope.Scope | ||
| 243 | >; | ||
| 244 | readonly resolveModelId?: (selection: ModelSelection) => string | undefined; | ||
| 245 | /** | ||
| 246 | * Replaces the default model application on session setup. Returns the model | ||
| 247 | * the session now runs on. Antigravity resolves its provider-default alias | ||
| 248 | * against the account's catalog instead of sending it to the agent. | ||
| 249 | */ | ||
| 250 | readonly applyModelSelection?: (input: { | ||
| 251 | readonly runtime: AcpSessionRuntime.AcpSessionRuntime["Service"]; | ||
| 252 | readonly startResult: AcpSessionRuntime.AcpSessionRuntimeStartResult; | ||
| 253 | readonly modelSelection: ModelSelection; | ||
| 254 | }) => Effect.Effect<string | undefined, EffectAcpErrors.AcpError>; | ||
| 255 | /** Native session mode to select for a runtime policy (e.g. Antigravity `yolo`). */ | ||
| 256 | readonly sessionModeForPolicy?: ( | ||
| 257 | policy: ProviderAdapter.ProviderAdapterV2RuntimePolicy, | ||
| 258 | ) => string | undefined; | ||
| 259 | /** | ||
| 260 | * Opts the session into the ACP client `fs` capability. Agents read and write | ||
| 261 | * files themselves under their own permission model unless a flavor sets | ||
| 262 | * this. Requests pass the runtime policy guard, then these handlers, which | ||
| 263 | * receive the cwd of the policy active when the request arrives (null when | ||
| 264 | * the session has no workspace). Antigravity sets it and confines requests | ||
| 265 | * to that workspace. | ||
| 266 | */ | ||
| 267 | readonly clientFileSystem?: { | ||
| 268 | readonly readTextFile: ( | ||
| 269 | request: EffectAcpSchema.ReadTextFileRequest, | ||
| 270 | cwd: string | null, | ||
| 271 | ) => Effect.Effect<EffectAcpSchema.ReadTextFileResponse, EffectAcpErrors.AcpError>; | ||
| 272 | readonly writeTextFile: ( | ||
| 273 | request: EffectAcpSchema.WriteTextFileRequest, | ||
| 274 | cwd: string | null, | ||
| 275 | ) => Effect.Effect<EffectAcpSchema.WriteTextFileResponse, EffectAcpErrors.AcpError>; | ||
| 276 | }; | ||
| 277 | /** | ||
| 278 | * Permission requests that are really questions (Antigravity `interaction_*` | ||
| 279 | * tool calls). Returns the question and a response builder; undefined routes | ||
| 280 | * the request through the normal approval card. | ||
| 281 | */ | ||
| 282 | readonly extractPermissionQuestion?: (request: EffectAcpSchema.RequestPermissionRequest) => | ||
| 283 | | { | ||
| 284 | readonly question: OrchestrationV2UserInputQuestion; | ||
| 285 | readonly respond: ( | ||
| 286 | answers: ProviderUserInputAnswers, | ||
| 287 | ) => EffectAcpSchema.RequestPermissionResponse | undefined; | ||
| 288 | } | ||
| 289 | | undefined; | ||
| 290 | /** | ||
| 291 | * Replaces T3's runtime-policy answer to a permission request. Grok's Auto | ||
| 292 | * mode only asks about what its own classifier refused, so those must reach | ||
| 293 | * the user instead of being approved by T3's policy. | ||
| 294 | */ | ||
| 295 | readonly permissionDisposition?: ( | ||
| 296 | policy: ProviderAdapter.ProviderAdapterV2RuntimePolicy, | ||
| 297 | request: EffectAcpSchema.RequestPermissionRequest, | ||
| 298 | ) => AcpPermissionDisposition; | ||
| 299 | /** Approval choices to advertise on the approval card for a permission request. */ | ||
| 300 | readonly approvalOptions?: ( | ||
| 301 | request: EffectAcpSchema.RequestPermissionRequest, | ||
| 302 | ) => ReadonlyArray<ProviderApprovalOption>; | ||
| 303 | /** | ||
| 304 | * Activate saved sessions with `session/resume` before `session/load` when | ||
| 305 | * the agent supports both. Antigravity's load replays history slowly. | ||
| 306 | */ | ||
| 307 | readonly preferResumeSession?: boolean; | ||
| 308 | readonly onSessionEvent?: ( | ||
| 309 | event: AcpSessionRuntime.AcpSessionRuntimeEvent, | ||
| 310 | ) => Effect.Effect<void>; | ||
| 311 | /** Batch launches without child completion signals become idle when the root turn ends. */ | ||
| 312 | readonly subagentsIdleOnTurnCompletion?: boolean; | ||
| 313 | readonly supportsCompaction?: boolean; | ||
| 314 | readonly runtimeHarness?: string; | ||
| 315 | readonly registerExtensions?: ( | ||
| 316 | context: AcpAdapterV2ExtensionContext, | ||
| 317 | ) => Effect.Effect<void, EffectAcpErrors.AcpError>; | ||
| 318 | readonly extractSubagentUpdate?: ( | ||
| 319 | toolCall: AcpToolCallState, | ||
| 320 | ) => AcpAdapterV2SubagentUpdate | undefined; | ||
| 321 | /** | ||
| 322 | * Optional Grok-style rewrite before tool projection (e.g. keep monitor start | ||
| 323 | * ACKs in the running state until stream end). | ||
| 324 | */ | ||
| 325 | readonly normalizeToolCall?: (toolCall: AcpToolCallState) => AcpToolCallState; | ||
| 326 | /** | ||
| 327 | * Optional plan-file sniffing (#8358): providers that write their proposed | ||
| 328 | * plan to a file mid-turn (Grok plan.md) return its markdown from a tool | ||
| 329 | * call so T3 can show the proposed-plan card while plan mode is active. | ||
| 330 | */ | ||
| 331 | readonly extractProposedPlanMarkdown?: (toolCall: AcpToolCallState) => string | undefined; | ||
| 332 | /** | ||
| 333 | * Optional mapping from a long-lived background tool start ACK to a task id | ||
| 334 | * (e.g. monitor task uuid) so later synthetic text events can update it. | ||
| 335 | */ | ||
| 336 | readonly extractBackgroundTaskId?: (toolCall: AcpToolCallState) => string | undefined; | ||
| 337 | /** | ||
| 338 | * Optional parse of root-session synthetic text (monitor-event lines, monitor | ||
| 339 | * ended reminders). Returns every task mutation in the chunk so coalesced | ||
| 340 | * progress / end notices are not dropped. | ||
| 341 | */ | ||
| 342 | readonly extractBackgroundToolMutation?: (text: string) => ReadonlyArray<{ | ||
| 343 | readonly taskId: string; | ||
| 344 | readonly status: "running" | "completed" | "failed"; | ||
| 345 | readonly appendOutput: string; | ||
| 346 | }>; | ||
| 347 | /** | ||
| 348 | * Optional parse of root-session synthetic text announcing a background | ||
| 349 | * subagent's end ("Background subagent "<uuid>" ... completed successfully"). | ||
| 350 | * Older builds may never hydrate via get_command_or_subagent_output, so this | ||
| 351 | * remains a terminal fallback. Current Grok additionally emits structured | ||
| 352 | * `subagent_finished` session notifications. | ||
| 353 | */ | ||
| 354 | readonly extractSubagentEndNotice?: (text: string) => | ||
| 355 | | { | ||
| 356 | readonly childSessionId: string; | ||
| 357 | readonly status: "completed" | "failed"; | ||
| 358 | } | ||
| 359 | | undefined; | ||
| 360 | /** | ||
| 361 | * Optional hydration when a later tool (e.g. get_command TaskOutput) completes | ||
| 362 | * previously registered background task id(s). | ||
| 363 | */ | ||
| 364 | readonly extractBackgroundTaskCompletion?: (toolCall: AcpToolCallState) => ReadonlyArray<{ | ||
| 365 | readonly taskId: string; | ||
| 366 | readonly status: "running" | "completed" | "failed"; | ||
| 367 | readonly appendOutput: string; | ||
| 368 | }>; | ||
| 369 | /** | ||
| 370 | * Persistent monitors (e.g. Grok `persistent: true`) should not hold root-turn | ||
| 371 | * deferred finalize open forever. Still tracked for post-settle wake. | ||
| 372 | */ | ||
| 373 | readonly isPersistentBackgroundTool?: (toolCall: AcpToolCallState) => boolean; | ||
| 374 | /** | ||
| 375 | * Whether a root-session frame belongs to a turn the agent started itself | ||
| 376 | * after background work ended (Grok `task-completed-*`), not to T3's prompt. | ||
| 377 | * Such frames never project into a root turn held open for that work; they | ||
| 378 | * take the post-settle wake path once the held turn finalizes. | ||
| 379 | */ | ||
| 380 | readonly isProviderWakeNotification?: ( | ||
| 381 | notification: EffectAcpSchema.SessionNotification, | ||
| 382 | ) => boolean; | ||
| 383 | /** | ||
| 384 | * When true, keep the active turn open after session/prompt returns while | ||
| 385 | * background tools/subagents are still running so later monitor/wake traffic | ||
| 386 | * can project (Grok monitors finish after the root prompt settles). | ||
| 387 | */ | ||
| 388 | readonly deferFinalizeForBackgroundWork?: boolean; | ||
| 389 | readonly assertComplete?: Effect.Effect<void, EffectAcpErrors.AcpError>; | ||
| 390 | /** Interrupt the local prompt fiber before `session/cancel` (Grok wedged prompts). */ | ||
| 391 | readonly interruptPromptOnCancel?: boolean; | ||
| 392 | /** | ||
| 393 | * Kill and respawn the ACP child process before the next `session/prompt` after a | ||
| 394 | * user interrupt. Grok can keep `task_already_running` state until the process exits. | ||
| 395 | */ | ||
| 396 | readonly restartRuntimeAfterInterrupt?: boolean; | ||
| 397 | /** | ||
| 398 | * When true, every interrupt restarts the runtime, not just those carrying | ||
| 399 | * `requestRuntimeRestart` (user Stop). Leave unset to keep non-Stop | ||
| 400 | * interrupts (steering, restart_active) soft: `session/cancel` plus session | ||
| 401 | * reuse in the same process. | ||
| 402 | */ | ||
| 403 | readonly restartRuntimeOnEveryInterrupt?: boolean; | ||
| 404 | readonly terminateRuntimeProcessGroupOnInterrupt?: boolean; | ||
| 405 | /** | ||
| 406 | * When true, an interrupt without `requestRuntimeRestart` (steering restart) | ||
| 407 | * on a turn whose native prompt already settled skips the hard process-group | ||
| 408 | * kill, the ACP cancel, and the runtime respawn entirely: the turn | ||
| 409 | * terminalizes locally while background subagents keep running in the same | ||
| 410 | * process and carry over into the replacement turn. Verified against the | ||
| 411 | * real Grok CLI (tmp/grok-acp-experiments E1): a new session/prompt is | ||
| 412 | * accepted concurrently while a fire-and-forget subagent is still running, | ||
| 413 | * with no task_already_running. User Stop (`requestRuntimeRestart: true`) | ||
| 414 | * keeps the hard teardown; mid-prompt non-Stop interrupts go soft | ||
| 415 | * (`session/cancel` plus same-session re-prompt) unless | ||
| 416 | * `restartRuntimeOnEveryInterrupt` is set. | ||
| 417 | */ | ||
| 418 | readonly preserveRuntimeOnSettledInterrupt?: boolean; | ||
| 419 | /** | ||
| 420 | * When true (with continuationRequests), post-settle root session/update traffic | ||
| 421 | * buffers and requests a provider continuation run instead of being dropped or | ||
| 422 | * only appended to loaded history. | ||
| 423 | */ | ||
| 424 | readonly enablePostSettleContinuation?: boolean; | ||
| 425 | /** | ||
| 426 | * When true, send image attachment content blocks even if the ACP agent | ||
| 427 | * advertises `promptCapabilities.image: false`. Grok CLI currently accepts | ||
| 428 | * and vision-processes image blocks while still reporting the capability as | ||
| 429 | * false; without this override, screenshot turns fail before `session/prompt`. | ||
| 430 | */ | ||
| 431 | readonly supportsImagePrompts?: boolean; | ||
| 432 | } | ||
| 433 | |||
| 434 | /** Whether image attachment blocks may be included in session/prompt. */ | ||
| 435 | export function acpSupportsImagePrompts(input: { | ||
| 436 | readonly flavorSupportsImagePrompts?: boolean | undefined; | ||
| 437 | readonly negotiatedImage?: boolean | undefined; | ||
| 438 | }): boolean { | ||
| 439 | return input.flavorSupportsImagePrompts === true || input.negotiatedImage === true; | ||
| 440 | } | ||
| 441 | |||
| 442 | /** Keeps provider-derived ids distinct when two configured ACP instances reuse native ids. */ | ||
| 443 | export function acpScopedNativeId(instanceId: ProviderInstanceId, nativeId: string): string { | ||
| 444 | return `provider-instance:${encodeURIComponent(instanceId)}:${nativeId}`; | ||
| 445 | } | ||
| 446 | |||
| 447 | /** Keeps pre-v2 persisted ids stable while scoping ids for newly created threads. */ | ||
| 448 | export function acpProviderItemNativeId(input: { | ||
| 449 | readonly instanceId: ProviderInstanceId; | ||
| 450 | readonly itemIdentityVersion: 2 | undefined; | ||
| 451 | readonly nativeId: string; | ||
| 452 | }): string { | ||
| 453 | return input.itemIdentityVersion === 2 | ||
| 454 | ? acpScopedNativeId(input.instanceId, input.nativeId) | ||
| 455 | : input.nativeId; | ||
| 456 | } | ||
| 457 | |||
| 458 | export interface AcpAdapterV2SubagentUpdate { | ||
| 459 | readonly nativeTaskId: string; | ||
| 460 | readonly prompt: string; | ||
| 461 | readonly title: string | null; | ||
| 462 | readonly model: string | null; | ||
| 463 | readonly status: | ||
| 464 | | "pending" | ||
| 465 | | "running" | ||
| 466 | | "idle" | ||
| 467 | | "completed" | ||
| 468 | | "failed" | ||
| 469 | | "interrupted" | ||
| 470 | | "cancelled"; | ||
| 471 | readonly childSessionId: string | null; | ||
| 472 | readonly result: string | null; | ||
| 473 | /** | ||
| 474 | * When false, still project a normal tool turn item after the subagent update | ||
| 475 | * (hydration tools like get_command_or_subagent_output). Defaults to true. | ||
| 476 | */ | ||
| 477 | readonly suppressNormalTool?: boolean; | ||
| 478 | } | ||
| 479 | |||
| 480 | export interface AcpAdapterV2Options { | ||
| 481 | readonly instanceId: ProviderInstanceId; | ||
| 482 | readonly flavor: AcpAdapterV2Flavor; | ||
| 483 | readonly crypto: Crypto.Crypto; | ||
| 484 | readonly fileSystem: FileSystem.FileSystem; | ||
| 485 | readonly idAllocator: IdAllocator.IdAllocatorV2Shape; | ||
| 486 | readonly host: ProviderHost.ProviderHostShape; | ||
| 487 | /** How agents spawn this install's `acp-mcp-bridge`; see `resolveSelfInvocation`. */ | ||
| 488 | readonly selfInvocation: SelfInvocation; | ||
| 489 | /** | ||
| 490 | * Opts the session into the ACP client `terminal` capability. Agents run | ||
| 491 | * commands themselves unless an adapter sets this; with it, sessions | ||
| 492 | * advertise `terminal: true` and run agent-created terminals through this | ||
| 493 | * spawner with the provider instance's environment. Devin sets it. | ||
| 494 | */ | ||
| 495 | readonly clientTerminals?: { | ||
| 496 | readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; | ||
| 497 | readonly environment?: NodeJS.ProcessEnv; | ||
| 498 | readonly shellCommands?: boolean; | ||
| 499 | }; | ||
| 500 | readonly nativeLogging?: (threadId: ThreadId) => AcpAdapterV2NativeLogging; | ||
| 501 | /** | ||
| 502 | * Shared with ProviderContinuationService so post-settle wake traffic can start | ||
| 503 | * a continuation run. Optional: adapters that omit it keep pre-continuation drop | ||
| 504 | * / history-only behavior for null-activeTurn updates. | ||
| 505 | */ | ||
| 506 | readonly continuationRequests?: { | ||
| 507 | readonly offer: (request: ProviderContinuationRequest) => Effect.Effect<void>; | ||
| 508 | }; | ||
| 509 | readonly testHooks?: { | ||
| 510 | /** | ||
| 511 | * A settled turn with no background work left armed its finish debounce | ||
| 512 | * ({@link ACP_DEFERRED_FINALIZE_DEBOUNCE}); replay advances its test clock | ||
| 513 | * by exactly that on this receipt. | ||
| 514 | */ | ||
| 515 | readonly onDeferredFinalizeScheduled?: (debounce: Duration.Input) => Effect.Effect<void>; | ||
| 516 | readonly afterPromptSettledWithBackgroundWork?: () => Effect.Effect<void>; | ||
| 517 | readonly afterNativeResponseTransportClosed?: () => Effect.Effect<void>; | ||
| 518 | readonly afterHardTeardownTransportDrained?: () => Effect.Effect<void>; | ||
| 519 | readonly beforeNativeResponseAdmissionCheck?: ( | ||
| 520 | generation: number, | ||
| 521 | requestId: string, | ||
| 522 | ) => Effect.Effect<void>; | ||
| 523 | readonly onNativeResponseLifecycle?: (event: { | ||
| 524 | readonly generation?: number; | ||
| 525 | readonly requestId?: string; | ||
| 526 | readonly type: | ||
| 527 | | "admission_rejected" | ||
| 528 | | "failed" | ||
| 529 | | "late_noop" | ||
| 530 | | "registered" | ||
| 531 | | "removed" | ||
| 532 | | "timer_exited" | ||
| 533 | | "timer_started" | ||
| 534 | | "watcher_exited" | ||
| 535 | | "watcher_started"; | ||
| 536 | }) => Effect.Effect<void>; | ||
| 537 | }; | ||
| 538 | } | ||
| 539 | |||
| 540 | export const AcpProviderCapabilitiesV2 = { | ||
| 541 | sessions: { | ||
| 542 | supportsMultipleProviderThreadsPerSession: false, | ||
| 543 | supportsModelSwitchInSession: false, | ||
| 544 | supportsProviderSwitchingViaHandoff: true, | ||
| 545 | supportsRuntimeModeSwitchInSession: false, | ||
| 546 | pendingRequestsSurviveRestart: false, | ||
| 547 | }, | ||
| 548 | threads: { | ||
| 549 | canCreateEmptyThread: true, | ||
| 550 | canReadThreadSnapshot: false, | ||
| 551 | canRollbackThread: true, | ||
| 552 | canForkThread: false, | ||
| 553 | canForkFromTurn: false, | ||
| 554 | canForkFromSubagentThread: false, | ||
| 555 | exposesNativeThreadId: true, | ||
| 556 | }, | ||
| 557 | turns: { | ||
| 558 | exposesNativeTurnId: false, | ||
| 559 | emitsTurnStarted: true, | ||
| 560 | emitsTurnCompleted: true, | ||
| 561 | supportsInterrupt: true, | ||
| 562 | supportsActiveSteering: false, | ||
| 563 | supportsSteeringByInterruptRestart: true, | ||
| 564 | supportsQueuedMessages: true, | ||
| 565 | terminalStatusQuality: "strong", | ||
| 566 | }, | ||
| 567 | streaming: { | ||
| 568 | streamsAssistantText: true, | ||
| 569 | streamsReasoning: true, | ||
| 570 | streamsToolOutput: true, | ||
| 571 | streamsPlanText: false, | ||
| 572 | emitsMessageCompleted: true, | ||
| 573 | }, | ||
| 574 | tools: { | ||
| 575 | exposesToolItemIds: true, | ||
| 576 | emitsToolStarted: true, | ||
| 577 | emitsToolCompleted: true, | ||
| 578 | emitsToolOutput: true, | ||
| 579 | supportsMcpTools: false, | ||
| 580 | supportsDynamicToolCallbacks: false, | ||
| 581 | }, | ||
| 582 | approvals: { | ||
| 583 | supportsCommandApproval: true, | ||
| 584 | supportsFileReadApproval: true, | ||
| 585 | supportsFileChangeApproval: true, | ||
| 586 | supportsApplyPatchApproval: false, | ||
| 587 | approvalsHaveNativeRequestIds: false, | ||
| 588 | approvalCallbacksAreLiveOnly: true, | ||
| 589 | approvalsCanOriginateFromSubagents: false, | ||
| 590 | }, | ||
| 591 | planning: { | ||
| 592 | emitsPlanUpdated: true, | ||
| 593 | emitsTodoList: true, | ||
| 594 | emitsProposedPlan: false, | ||
| 595 | supportsStructuredQuestions: true, | ||
| 596 | planDeltasHaveItemIds: false, | ||
| 597 | }, | ||
| 598 | subagents: { | ||
| 599 | supportsSubagents: false, | ||
| 600 | exposesSubagentThreadIds: false, | ||
| 601 | emitsSubagentLifecycle: false, | ||
| 602 | canWaitForSubagents: false, | ||
| 603 | canCloseSubagents: false, | ||
| 604 | canForkSubagentThread: false, | ||
| 605 | }, | ||
| 606 | context: { | ||
| 607 | acceptsSystemContext: false, | ||
| 608 | acceptsDeveloperContext: false, | ||
| 609 | acceptsSyntheticUserContext: true, | ||
| 610 | canGenerateSummaries: true, | ||
| 611 | canConsumeHandoffSummaries: true, | ||
| 612 | supportsDeltaHandoff: true, | ||
| 613 | supportsFullThreadHandoff: true, | ||
| 614 | maxRecommendedHandoffChars: null, | ||
| 615 | }, | ||
| 616 | checkpointing: { | ||
| 617 | appCanCheckpointFilesystem: true, | ||
| 618 | supportsNestedCheckpointScopes: true, | ||
| 619 | // ACP defines no conversation truncation, so rollback resets the provider | ||
| 620 | // conversation: T3 restores checkpointed state and the next turn starts a | ||
| 621 | // fresh agent session without the rolled-back context. | ||
| 622 | providerCanRollbackConversation: true, | ||
| 623 | providerRollbackReturnsSnapshot: true, | ||
| 624 | providerCanReadConversationSnapshot: false, | ||
| 625 | }, | ||
| 626 | identity: { | ||
| 627 | nativeThreadIds: "strong", | ||
| 628 | nativeTurnIds: "weak", | ||
| 629 | nativeItemIds: "weak", | ||
| 630 | nativeRequestIds: "weak", | ||
| 631 | }, | ||
| 632 | runtimePolicy: { | ||
| 633 | // ACP agents run their own tools; T3 only answers their permission | ||
| 634 | // requests by policy. | ||
| 635 | enforcement: "client-boundary", | ||
| 636 | }, | ||
| 637 | } satisfies OrchestrationV2ProviderCapabilities; | ||
| 638 | |||
| 639 | function negotiatedCapabilities( | ||
| 640 | base: OrchestrationV2ProviderCapabilities, | ||
| 641 | started: AcpSessionRuntime.AcpSessionRuntimeStartResult, | ||
| 642 | ): OrchestrationV2ProviderCapabilities { | ||
| 643 | const agent = started.initializeResult.agentCapabilities ?? {}; | ||
| 644 | const session = agent.sessionCapabilities; | ||
| 645 | const setup = started.sessionSetupResult; | ||
| 646 | const hasModelConfig = | ||
| 647 | setup.configOptions?.some((option) => option.category === "model") === true; | ||
| 648 | const canLoad = agent.loadSession === true; | ||
| 649 | const canFork = session?.fork != null; | ||
| 650 | return { | ||
| 651 | ...base, | ||
| 652 | sessions: { | ||
| 653 | ...base.sessions, | ||
| 654 | supportsModelSwitchInSession: hasModelConfig, | ||
| 655 | }, | ||
| 656 | threads: { | ||
| 657 | ...base.threads, | ||
| 658 | canReadThreadSnapshot: canLoad, | ||
| 659 | canForkThread: canFork, | ||
| 660 | canForkFromTurn: false, | ||
| 661 | }, | ||
| 662 | tools: { | ||
| 663 | ...base.tools, | ||
| 664 | // The stdio bridge (`t3 acp-mcp-bridge`) makes the t3-code MCP toolkit | ||
| 665 | // available regardless of the agent's optional http/sse MCP support. | ||
| 666 | supportsMcpTools: true, | ||
| 667 | }, | ||
| 668 | checkpointing: { | ||
| 669 | ...base.checkpointing, | ||
| 670 | providerCanReadConversationSnapshot: canLoad, | ||
| 671 | }, | ||
| 672 | }; | ||
| 673 | } | ||
| 674 | |||
| 675 | interface AcpMcpContext { | ||
| 676 | readonly servers: ReadonlyArray<EffectAcpSchema.McpServer>; | ||
| 677 | readonly acpServers: ReadonlyArray<EffectAcpSchema.McpServer>; | ||
| 678 | readonly processEnvironment?: NodeJS.ProcessEnv; | ||
| 679 | readonly endpoint?: string; | ||
| 680 | readonly authorization?: string; | ||
| 681 | } | ||
| 682 | |||
| 683 | function acpMcpContext(threadId: ThreadId | null, self: SelfInvocation): AcpMcpContext { | ||
| 684 | if (threadId === null) return { servers: [], acpServers: [] }; | ||
| 685 | const session = McpProviderSession.readMcpProviderSession(threadId); | ||
| 686 | if (session === undefined) { | ||
| 687 | return { servers: [], acpServers: [] }; | ||
| 688 | } | ||
| 689 | // Stdio is ACP's required baseline MCP transport. Agents that advertise | ||
| 690 | // optional http support still routinely fail to wire injected http servers | ||
| 691 | // through to their backend (codex-acp 1.2.0 and pi-acp both drop them), so | ||
| 692 | // every ACP session gets the `t3 acp-mcp-bridge` stdio server, which | ||
| 693 | // forwards JSON-RPC to T3's authenticated MCP endpoint. The credential | ||
| 694 | // travels via environment variables, never the command line. | ||
| 695 | return { | ||
| 696 | servers: [ | ||
| 697 | { | ||
| 698 | name: "t3-code", | ||
| 699 | command: self.command, | ||
| 700 | args: [...selfInvocationArgs(self, ["acp-mcp-bridge"])], | ||
| 701 | env: [ | ||
| 702 | { name: "ELECTRON_RUN_AS_NODE", value: "1" }, | ||
| 703 | { name: "T3_ACP_MCP_ENDPOINT", value: session.endpoint }, | ||
| 704 | { name: "T3_ACP_MCP_AUTHORIZATION", value: session.authorizationHeader }, | ||
| 705 | ], | ||
| 706 | }, | ||
| 707 | ], | ||
| 708 | acpServers: [{ type: "acp", name: "t3-code", serverId: "t3-code" }], | ||
| 709 | endpoint: session.endpoint, | ||
| 710 | authorization: session.authorizationHeader, | ||
| 711 | processEnvironment: { | ||
| 712 | T3_ACP_MCP_ENDPOINT: session.endpoint, | ||
| 713 | T3_ACP_MCP_AUTHORIZATION: session.authorizationHeader, | ||
| 714 | T3_ACP_MCP_NODE: self.command, | ||
| 715 | ...(self.entrypoint === undefined ? {} : { T3_ACP_MCP_ENTRYPOINT: self.entrypoint }), | ||
| 716 | }, | ||
| 717 | }; | ||
| 718 | } | ||
| 719 | |||
| 720 | function acpMcpServers( | ||
| 721 | threadId: ThreadId | null, | ||
| 722 | self: SelfInvocation, | ||
| 723 | ): ReadonlyArray<EffectAcpSchema.McpServer> { | ||
| 724 | return acpMcpContext(threadId, self).servers; | ||
| 725 | } | ||
| 726 | |||
| 727 | function acpMcpActivation(threadId: ThreadId | null, self: SelfInvocation) { | ||
| 728 | const context = acpMcpContext(threadId, self); | ||
| 729 | return { mcpServers: context.servers, acpMcpServers: context.acpServers }; | ||
| 730 | } | ||
| 731 | |||
| 732 | function nativeThreadId( | ||
| 733 | driver: ProviderDriverKind, | ||
| 734 | thread: OrchestrationV2ProviderThread, | ||
| 735 | ): Effect.Effect<string, ProviderAdapter.ProviderAdapterProtocolError> { | ||
| 736 | const id = thread.nativeThreadRef?.nativeId; | ||
| 737 | if (id === null || id === undefined || id.trim().length === 0) { | ||
| 738 | return Effect.fail( | ||
| 739 | new ProviderAdapter.ProviderAdapterProtocolError({ | ||
| 740 | driver, | ||
| 741 | detail: `Provider thread ${thread.id} is missing its ACP session id`, | ||
| 742 | }), | ||
| 743 | ); | ||
| 744 | } | ||
| 745 | return Effect.succeed(id); | ||
| 746 | } | ||
| 747 | |||
| 748 | function makeProviderThread(input: { | ||
| 749 | readonly driver: ProviderDriverKind; | ||
| 750 | readonly providerInstanceId: ProviderInstanceId; | ||
| 751 | readonly idAllocator: IdAllocator.IdAllocatorV2Shape; | ||
| 752 | readonly appThreadId: OrchestrationV2ProviderThread["appThreadId"]; | ||
| 753 | readonly providerSessionId: OrchestrationV2ProviderThread["providerSessionId"]; | ||
| 754 | readonly nativeThreadId: string; | ||
| 755 | readonly ownerNodeId?: OrchestrationV2ProviderThread["ownerNodeId"]; | ||
| 756 | readonly forkedFrom?: OrchestrationV2ProviderThread["forkedFrom"]; | ||
| 757 | readonly itemIdentityVersion?: 2; | ||
| 758 | readonly now: DateTime.Utc; | ||
| 759 | }): OrchestrationV2ProviderThread { | ||
| 760 | return { | ||
| 761 | id: input.idAllocator.derive.providerThread({ | ||
| 762 | driver: input.driver, | ||
| 763 | providerInstanceId: input.providerInstanceId, | ||
| 764 | nativeThreadId: input.nativeThreadId, | ||
| 765 | }), | ||
| 766 | driver: input.driver, | ||
| 767 | providerInstanceId: input.providerInstanceId, | ||
| 768 | providerSessionId: input.providerSessionId, | ||
| 769 | appThreadId: input.appThreadId, | ||
| 770 | ownerNodeId: input.ownerNodeId ?? null, | ||
| 771 | nativeThreadRef: { | ||
| 772 | driver: input.driver, | ||
| 773 | nativeId: input.nativeThreadId, | ||
| 774 | strength: "strong", | ||
| 775 | }, | ||
| 776 | nativeConversationHeadRef: null, | ||
| 777 | status: "idle", | ||
| 778 | firstRunOrdinal: null, | ||
| 779 | lastRunOrdinal: null, | ||
| 780 | handoffIds: [], | ||
| 781 | forkedFrom: input.forkedFrom ?? null, | ||
| 782 | contextUsage: null, | ||
| 783 | nativeMetadata: | ||
| 784 | input.itemIdentityVersion === undefined | ||
| 785 | ? null | ||
| 786 | : { itemIdentityVersion: input.itemIdentityVersion }, | ||
| 787 | createdAt: input.now, | ||
| 788 | updatedAt: input.now, | ||
| 789 | }; | ||
| 790 | } | ||
| 791 | |||
| 792 | /** | ||
| 793 | * Unwrap a codex-acp style MCP call result (`{ result, error }` around MCP | ||
| 794 | * `content`/`structuredContent`) the same way the native Codex adapter does, | ||
| 795 | * so recovered MCP items render identical output. Unknown shapes pass through. | ||
| 796 | */ | ||
| 797 | function acpMcpToolCallOutput(rawOutput: unknown): unknown { | ||
| 798 | const record = unknownRecord(rawOutput); | ||
| 799 | if (record === undefined) return rawOutput; | ||
| 800 | if (!("result" in record) && !("error" in record)) return rawOutput; | ||
| 801 | const result = unknownRecord(record.result); | ||
| 802 | const resultOutput = | ||
| 803 | result === undefined ? undefined : (result.structuredContent ?? result.content ?? undefined); | ||
| 804 | const errorMessage = unknownRecord(record.error)?.message; | ||
| 805 | if (typeof errorMessage !== "string") { | ||
| 806 | return resultOutput ?? rawOutput; | ||
| 807 | } | ||
| 808 | return resultOutput === undefined | ||
| 809 | ? { error: errorMessage } | ||
| 810 | : { error: errorMessage, result: resultOutput }; | ||
| 811 | } | ||
| 812 | |||
| 813 | function nonEmptyText(value: unknown, fallback: string): string { | ||
| 814 | return typeof value === "string" && value.trim().length > 0 ? value.trim() : fallback; | ||
| 815 | } | ||
| 816 | |||
| 817 | function decodeByteText(value: unknown): string | undefined { | ||
| 818 | if (!Array.isArray(value) || value.length === 0) return undefined; | ||
| 819 | if (!value.every((entry) => typeof entry === "number" && Number.isInteger(entry))) { | ||
| 820 | return undefined; | ||
| 821 | } | ||
| 822 | try { | ||
| 823 | // Preserve leading/trailing whitespace like the string path in textFromUnknown. | ||
| 824 | const text = new TextDecoder().decode(Uint8Array.from(value as number[])); | ||
| 825 | return text.length > 0 ? text : undefined; | ||
| 826 | } catch { | ||
| 827 | return undefined; | ||
| 828 | } | ||
| 829 | } | ||
| 830 | |||
| 831 | function textFromUnknown(value: unknown): string | undefined { | ||
| 832 | if (typeof value === "string") { | ||
| 833 | return value; | ||
| 834 | } | ||
| 835 | const fromBytes = decodeByteText(value); | ||
| 836 | if (fromBytes !== undefined) { | ||
| 837 | return fromBytes; | ||
| 838 | } | ||
| 839 | if (Array.isArray(value)) { | ||
| 840 | const parts = value.flatMap((entry) => { | ||
| 841 | const text = textFromUnknown(entry); | ||
| 842 | return text === undefined || text.length === 0 ? [] : [text]; | ||
| 843 | }); | ||
| 844 | return parts.length === 0 ? undefined : parts.join("\n"); | ||
| 845 | } | ||
| 846 | const record = unknownRecord(value); | ||
| 847 | if (record === undefined) { | ||
| 848 | return undefined; | ||
| 849 | } | ||
| 850 | // Prefer prompt-facing Grok fields before nested envelopes. | ||
| 851 | // Antigravity reports shell output as combinedOutput. | ||
| 852 | for (const key of [ | ||
| 853 | "output_for_prompt", | ||
| 854 | "stdout", | ||
| 855 | "stderr", | ||
| 856 | "output", | ||
| 857 | "combinedOutput", | ||
| 858 | "combined_output", | ||
| 859 | "content", | ||
| 860 | "text", | ||
| 861 | "message", | ||
| 862 | ]) { | ||
| 863 | const direct = record[key]; | ||
| 864 | if (typeof direct === "string" && direct.length > 0) { | ||
| 865 | return direct; | ||
| 866 | } | ||
| 867 | const decoded = decodeByteText(direct); | ||
| 868 | if (decoded !== undefined) { | ||
| 869 | return decoded; | ||
| 870 | } | ||
| 871 | const text = textFromUnknown(direct); | ||
| 872 | if (text !== undefined && text.length > 0) { | ||
| 873 | return text; | ||
| 874 | } | ||
| 875 | } | ||
| 876 | const result = unknownRecord(record.Result) ?? unknownRecord(record.result); | ||
| 877 | if (result !== undefined) { | ||
| 878 | return textFromUnknown(result); | ||
| 879 | } | ||
| 880 | return undefined; | ||
| 881 | } | ||
| 882 | |||
| 883 | function commandExitCode(value: unknown): number | undefined { | ||
| 884 | const record = unknownRecord(value); | ||
| 885 | for (const key of ["exitCode", "exit_code", "code"]) { | ||
| 886 | const candidate = record?.[key]; | ||
| 887 | if (typeof candidate === "number" && Number.isInteger(candidate)) { | ||
| 888 | return candidate; | ||
| 889 | } | ||
| 890 | } | ||
| 891 | return undefined; | ||
| 892 | } | ||
| 893 | |||
| 894 | /** | ||
| 895 | * Project an exit code only when the tool has a terminal native status. | ||
| 896 | * Mid-stream Grok Bash re-reports carry exit_code 0 while still in progress; | ||
| 897 | * interrupted tools must not retain that stale success code. | ||
| 898 | */ | ||
| 899 | export function acpProjectedCommandExitCode( | ||
| 900 | status: "pending" | "running" | "waiting" | "completed" | "failed" | "interrupted", | ||
| 901 | rawOutput: unknown, | ||
| 902 | ): number | undefined { | ||
| 903 | if (status !== "completed" && status !== "failed") { | ||
| 904 | return undefined; | ||
| 905 | } | ||
| 906 | return commandExitCode(rawOutput); | ||
| 907 | } | ||
| 908 | |||
| 909 | function structuredFileChanges(toolCall: AcpToolCallState) { | ||
| 910 | const content = toolCall.data.content; | ||
| 911 | if (!Array.isArray(content)) return []; | ||
| 912 | return content.flatMap((entry) => { | ||
| 913 | const diff = unknownRecord(entry); | ||
| 914 | if (diff?.type !== "diff" || !Array.isArray(diff.changes)) return []; | ||
| 915 | return diff.changes.flatMap((candidate) => { | ||
| 916 | const change = unknownRecord(candidate); | ||
| 917 | const operation = typeof change?.operation === "string" ? change.operation.trim() : ""; | ||
| 918 | const path = typeof change?.path === "string" ? change.path.trim() : ""; | ||
| 919 | if (operation.length === 0 || path.length === 0) return []; | ||
| 920 | const oldPath = typeof change?.oldPath === "string" ? change.oldPath.trim() : ""; | ||
| 921 | const fileType = typeof change?.fileType === "string" ? change.fileType.trim() : ""; | ||
| 922 | const mimeType = typeof change?.mimeType === "string" ? change.mimeType.trim() : ""; | ||
| 923 | return [ | ||
| 924 | { | ||
| 925 | operation, | ||
| 926 | path, | ||
| 927 | ...(oldPath.length === 0 ? {} : { oldPath }), | ||
| 928 | ...(fileType.length === 0 ? {} : { fileType }), | ||
| 929 | ...(mimeType.length === 0 ? {} : { mimeType }), | ||
| 930 | }, | ||
| 931 | ]; | ||
| 932 | }); | ||
| 933 | }); | ||
| 934 | } | ||
| 935 | |||
| 936 | // Past this edit distance an edit keeps no patch text, so projecting a large | ||
| 937 | // rewrite cannot stall the event loop in the diff search. A created or emptied | ||
| 938 | // file has one empty side and needs no search, so it is never capped. | ||
| 939 | const ACP_V1_DIFF_MAX_EDITS = 1_000; | ||
| 940 | |||
| 941 | /** | ||
| 942 | * Patch text for a tool call's diff content. ACP v2 diffs carry it as | ||
| 943 | * `patch.text`. ACP v1 diffs carry `oldText`/`newText` instead (`oldText` | ||
| 944 | * null or absent for a new file), and agents that negotiate v1 still send | ||
| 945 | * that shape, so the patch is built from the two sides. | ||
| 946 | */ | ||
| 947 | export function acpToolCallDiffPatch(content: unknown): string | undefined { | ||
| 948 | if (!Array.isArray(content)) return undefined; | ||
| 949 | const diffs = content.flatMap((entry) => { | ||
| 950 | const diff = unknownRecord(entry); | ||
| 951 | return diff?.type === "diff" ? [diff] : []; | ||
| 952 | }); | ||
| 953 | for (const diff of diffs) { | ||
| 954 | const patch = unknownRecord(diff.patch); | ||
| 955 | if (typeof patch?.text === "string") return patch.text; | ||
| 956 | } | ||
| 957 | const v1Patches = diffs.flatMap((diff) => { | ||
| 958 | if (typeof diff.path !== "string" || typeof diff.newText !== "string") return []; | ||
| 959 | const oldText = typeof diff.oldText === "string" ? diff.oldText : undefined; | ||
| 960 | const patch = structuredPatch( | ||
| 961 | oldText === undefined ? "/dev/null" : diff.path, | ||
| 962 | diff.path, | ||
| 963 | oldText ?? "", | ||
| 964 | diff.newText, | ||
| 965 | undefined, | ||
| 966 | undefined, | ||
| 967 | { | ||
| 968 | context: 3, | ||
| 969 | maxEditLength: oldText && diff.newText ? ACP_V1_DIFF_MAX_EDITS : Number.POSITIVE_INFINITY, | ||
| 970 | }, | ||
| 971 | ); | ||
| 972 | return patch === undefined || patch.hunks.length === 0 ? [] : [patch]; | ||
| 973 | }); | ||
| 974 | return v1Patches.length === 0 ? undefined : formatPatch(v1Patches, FILE_HEADERS_ONLY); | ||
| 975 | } | ||
| 976 | |||
| 977 | function pathFromToolCall(toolCall: AcpToolCallState): string | undefined { | ||
| 978 | const locations = toolCall.data.locations; | ||
| 979 | if (Array.isArray(locations)) { | ||
| 980 | for (const location of locations) { | ||
| 981 | const path = unknownRecord(location)?.path; | ||
| 982 | if (typeof path === "string" && path.trim().length > 0) { | ||
| 983 | return path.trim(); | ||
| 984 | } | ||
| 985 | } | ||
| 986 | } | ||
| 987 | const rawInput = unknownRecord(toolCall.data.rawInput); | ||
| 988 | for (const key of ["path", "filePath", "file_path", "url", "query", "pattern"]) { | ||
| 989 | const candidate = rawInput?.[key]; | ||
| 990 | if (typeof candidate === "string" && candidate.trim().length > 0) { | ||
| 991 | return candidate.trim(); | ||
| 992 | } | ||
| 993 | } | ||
| 994 | return undefined; | ||
| 995 | } | ||
| 996 | |||
| 997 | /** | ||
| 998 | * Grok runs X and web searches server-side as `search` tools whose rawInput is | ||
| 999 | * only `{ variant: "XSearch" | "WebSearch", backend: true }`. The query arrives | ||
| 1000 | * with completion: web searches report `action: { query, sources }`, X searches | ||
| 1001 | * the backend call `{ name, input }` with JSON-encoded arguments. | ||
| 1002 | */ | ||
| 1003 | function acpBackendWebSearch( | ||
| 1004 | rawInput: Record<string, unknown> | undefined, | ||
| 1005 | rawOutput: Record<string, unknown> | undefined, | ||
| 1006 | ): | ||
| 1007 | | { readonly query: string | undefined; readonly results: OrchestrationV2WebSearchResult[] } | ||
| 1008 | | undefined { | ||
| 1009 | const variant = typeof rawInput?.variant === "string" ? rawInput.variant.toLowerCase() : ""; | ||
| 1010 | const action = unknownRecord(rawOutput?.action); | ||
| 1011 | if (variant !== "xsearch" && variant !== "websearch" && action?.type !== "search") { | ||
| 1012 | return undefined; | ||
| 1013 | } | ||
| 1014 | let args: Record<string, unknown> | undefined; | ||
| 1015 | if (typeof rawOutput?.input === "string") { | ||
| 1016 | try { | ||
| 1017 | args = unknownRecord(JSON.parse(rawOutput.input)); | ||
| 1018 | } catch { | ||
| 1019 | args = undefined; | ||
| 1020 | } | ||
| 1021 | } | ||
| 1022 | const argsText = Object.entries(args ?? {}) | ||
| 1023 | .filter(([, value]) => typeof value === "string" || typeof value === "number") | ||
| 1024 | .map(([key, value]) => `${key}: ${value}`) | ||
| 1025 | .join(", "); | ||
| 1026 | const query = [action?.query, args?.query, argsText] | ||
| 1027 | .find((value): value is string => typeof value === "string" && value.trim().length > 0) | ||
| 1028 | ?.trim(); | ||
| 1029 | const urls = new Set<string>(); | ||
| 1030 | for (const source of Array.isArray(action?.sources) ? action.sources : []) { | ||
| 1031 | const url = unknownRecord(source)?.url; | ||
| 1032 | if (typeof url === "string" && url.trim().length > 0) urls.add(url.trim()); | ||
| 1033 | } | ||
| 1034 | return { query, results: [...urls].map((url) => ({ url })) }; | ||
| 1035 | } | ||
| 1036 | |||
| 1037 | function providerRequestKind(kind: string | "unknown"): ProviderRequestKind { | ||
| 1038 | switch (kind) { | ||
| 1039 | case "execute": | ||
| 1040 | return "command"; | ||
| 1041 | case "read": | ||
| 1042 | case "search": | ||
| 1043 | case "fetch": | ||
| 1044 | return "file-read"; | ||
| 1045 | case "edit": | ||
| 1046 | case "delete": | ||
| 1047 | case "move": | ||
| 1048 | return "file-change"; | ||
| 1049 | default: | ||
| 1050 | return "command"; | ||
| 1051 | } | ||
| 1052 | } | ||
| 1053 | |||
| 1054 | function toolStatus( | ||
| 1055 | status: AcpToolCallState["status"], | ||
| 1056 | ): "pending" | "running" | "waiting" | "completed" | "failed" { | ||
| 1057 | switch (status) { | ||
| 1058 | case "completed": | ||
| 1059 | return "completed"; | ||
| 1060 | case "failed": | ||
| 1061 | return "failed"; | ||
| 1062 | case "pending": | ||
| 1063 | return "pending"; | ||
| 1064 | case "requiresAction": | ||
| 1065 | return "waiting"; | ||
| 1066 | default: | ||
| 1067 | return "running"; | ||
| 1068 | } | ||
| 1069 | } | ||
| 1070 | |||
| 1071 | type ProjectedToolStatus = ReturnType<typeof toolStatus> | "interrupted"; | ||
| 1072 | |||
| 1073 | function nodeStatus(status: ProjectedToolStatus): OrchestrationV2ExecutionNode["status"] { | ||
| 1074 | return status === "pending" ? "running" : status; | ||
| 1075 | } | ||
| 1076 | |||
| 1077 | function completedAtForStatus(status: ProjectedToolStatus, now: DateTime.Utc): DateTime.Utc | null { | ||
| 1078 | return status === "completed" || status === "failed" || status === "interrupted" ? now : null; | ||
| 1079 | } | ||
| 1080 | |||
| 1081 | function selectPermissionOptionId( | ||
| 1082 | request: EffectAcpSchema.RequestPermissionRequest, | ||
| 1083 | decision: Exclude<ProviderApprovalDecision, "cancel">, | ||
| 1084 | ): string | undefined { | ||
| 1085 | const kind = | ||
| 1086 | decision === "acceptForSession" | ||
| 1087 | ? "allow_always" | ||
| 1088 | : decision === "accept" | ||
| 1089 | ? "allow_once" | ||
| 1090 | : "reject_once"; | ||
| 1091 | return request.options.find((option) => option.kind === kind)?.optionId.trim() || undefined; | ||
| 1092 | } | ||
| 1093 | |||
| 1094 | /** | ||
| 1095 | * The runtime policy approves one request, so answer with the agent's | ||
| 1096 | * allow-once option. Its allow-always option can outlive the session (Grok | ||
| 1097 | * saves it for the whole project); use it only when no allow-once exists. | ||
| 1098 | */ | ||
| 1099 | function selectAutoApprovedPermissionOption( | ||
| 1100 | request: EffectAcpSchema.RequestPermissionRequest, | ||
| 1101 | ): string | undefined { | ||
| 1102 | return ( | ||
| 1103 | selectPermissionOptionId(request, "accept") ?? | ||
| 1104 | selectPermissionOptionId(request, "acceptForSession") | ||
| 1105 | ); | ||
| 1106 | } | ||
| 1107 | |||
| 1108 | function elicitationContent( | ||
| 1109 | answers: ProviderUserInputAnswers, | ||
| 1110 | allowedKeys: ReadonlySet<string>, | ||
| 1111 | ): Record<string, EffectAcpSchema.ElicitationContentValue> { | ||
| 1112 | const content: Record<string, EffectAcpSchema.ElicitationContentValue> = {}; | ||
| 1113 | for (const [key, value] of Object.entries(answers)) { | ||
| 1114 | if (!allowedKeys.has(key)) continue; | ||
| 1115 | if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { | ||
| 1116 | content[key] = value; | ||
| 1117 | } else if (Array.isArray(value)) { | ||
| 1118 | content[key] = value.filter((entry): entry is string => typeof entry === "string"); | ||
| 1119 | } | ||
| 1120 | } | ||
| 1121 | return content; | ||
| 1122 | } | ||
| 1123 | |||
| 1124 | interface ActiveTextSegment { | ||
| 1125 | readonly nativeItemId: string; | ||
| 1126 | readonly startedAt: DateTime.Utc; | ||
| 1127 | sourceMessageId: string | null; | ||
| 1128 | text: string; | ||
| 1129 | } | ||
| 1130 | |||
| 1131 | interface ActiveTextStream { | ||
| 1132 | current: ActiveTextSegment | null; | ||
| 1133 | nextSegment: number; | ||
| 1134 | } | ||
| 1135 | |||
| 1136 | interface AcpNativeBuildConfiguration { | ||
| 1137 | readonly modeId?: string; | ||
| 1138 | readonly configOptions: ReadonlyArray<{ readonly id: string; readonly value: string }>; | ||
| 1139 | } | ||
| 1140 | |||
| 1141 | interface ActiveAcpTurn { | ||
| 1142 | readonly input: ProviderAdapter.ProviderAdapterV2TurnInput; | ||
| 1143 | readonly providerTurnId: OrchestrationV2ProviderTurn["id"]; | ||
| 1144 | readonly nativeThreadId: string; | ||
| 1145 | readonly nativeTurnId: string; | ||
| 1146 | readonly startedAt: DateTime.Utc; | ||
| 1147 | readonly completed: Deferred.Deferred<void, never>; | ||
| 1148 | // Root item ordinals allocated in this turn. Subagent child items keep | ||
| 1149 | // theirs on the subagent, which carries over into later turns. | ||
| 1150 | readonly itemOrdinals: Map<string, number>; | ||
| 1151 | readonly user: ActiveTextStream; | ||
| 1152 | readonly assistant: ActiveTextStream; | ||
| 1153 | readonly reasoning: ActiveTextStream; | ||
| 1154 | providerRetry?: | ||
| 1155 | | { | ||
| 1156 | readonly failure: OrchestrationV2ProviderFailure; | ||
| 1157 | readonly retry: OrchestrationV2ProviderRetry; | ||
| 1158 | readonly startedAt: DateTime.Utc; | ||
| 1159 | readonly itemOrdinal: number; | ||
| 1160 | } | ||
| 1161 | | undefined; | ||
| 1162 | contextUsage: ThreadTokenUsageSnapshot | null; | ||
| 1163 | nativeMetadata: OrchestrationV2ProviderThreadNativeMetadata | null; | ||
| 1164 | readonly tools: Map<string, AcpToolCallState>; | ||
| 1165 | /** Streamed tool updates skipped since the last persisted one; see `shouldPersistToolUpdate`. */ | ||
| 1166 | readonly toolUpdatesSkipped: Map<string, number>; | ||
| 1167 | readonly toolStartedAt: Map<string, DateTime.Utc>; | ||
| 1168 | readonly subagents: Map<string, ActiveAcpSubagent>; | ||
| 1169 | readonly subagentsBySessionId: Map<string, ActiveAcpSubagent>; | ||
| 1170 | readonly pendingSubagentNotifications: Map<string, Array<EffectAcpSchema.SessionNotification>>; | ||
| 1171 | /** Background monitor/task id → toolCallId for synthetic root text updates. */ | ||
| 1172 | readonly toolCallIdsByBackgroundTaskId: Map<string, string>; | ||
| 1173 | /** | ||
| 1174 | * Persistent monitors registered this turn. Excluded from deferred-finalize | ||
| 1175 | * holds so the root turn can settle while they keep streaming post-settle. | ||
| 1176 | */ | ||
| 1177 | readonly persistentBackgroundTaskIds: Set<string>; | ||
| 1178 | /** | ||
| 1179 | * Monitor end events often only say "use get_command…"; keep the turn open | ||
| 1180 | * until TaskOutput hydration arrives (or the safety timeout elapses). | ||
| 1181 | */ | ||
| 1182 | readonly awaitingBackgroundHydration: Set<string>; | ||
| 1183 | /** | ||
| 1184 | * A monitor end notice landed after the prompt settled: the CLI runs an | ||
| 1185 | * injected turn whose report never gets a turn_completed marker, so the | ||
| 1186 | * report chunk races the deferred-finalize debounce (thread a8e8b0a9 run 5 | ||
| 1187 | * dropped the listing this way). Hold finalize until the report streams or | ||
| 1188 | * the safety timeout elapses. | ||
| 1189 | */ | ||
| 1190 | readonly pendingInjectedReport: Set<string>; | ||
| 1191 | /** | ||
| 1192 | * An injected assistant report can race ahead of its monitor end notice. | ||
| 1193 | * Remember that unmatched report so the later notice consumes, rather than | ||
| 1194 | * re-arming, the task's pre-settle completion marker. | ||
| 1195 | */ | ||
| 1196 | earlyInjectedReportObserved: boolean; | ||
| 1197 | readonly plans: Map< | ||
| 1198 | string, | ||
| 1199 | { | ||
| 1200 | readonly id: OrchestrationV2PlanArtifact["id"]; | ||
| 1201 | readonly startedAt: DateTime.Utc; | ||
| 1202 | latest: OrchestrationV2PlanArtifact | null; | ||
| 1203 | } | ||
| 1204 | >; | ||
| 1205 | interrupted: boolean; | ||
| 1206 | finalized: boolean; | ||
| 1207 | finalizedStatus: "completed" | "interrupted" | "failed" | "cancelled" | null; | ||
| 1208 | /** session/prompt already returned; finalize deferred for background work. */ | ||
| 1209 | promptSettled: boolean; | ||
| 1210 | promptSettledStatus: "completed" | "interrupted" | "failed" | "cancelled" | null; | ||
| 1211 | /** | ||
| 1212 | * Completed the moment `runtime.prompt` resolves on the wire, before the | ||
| 1213 | * completion callback requests `runtimeCallbackPermit`. Failure does not | ||
| 1214 | * complete this; settled-soft classification ORs it with `promptSettled`. | ||
| 1215 | */ | ||
| 1216 | readonly promptWireSettled: Deferred.Deferred<void, never>; | ||
| 1217 | backgroundFinalizeGeneration: number; | ||
| 1218 | } | ||
| 1219 | |||
| 1220 | type AcpRuntimeTeardownState = | ||
| 1221 | | { readonly _tag: "Idle" } | ||
| 1222 | | { | ||
| 1223 | readonly _tag: "InProgress"; | ||
| 1224 | readonly completed: Deferred.Deferred<void, ProviderAdapter.ProviderAdapterProtocolError>; | ||
| 1225 | } | ||
| 1226 | | { readonly _tag: "Failed"; readonly error: ProviderAdapter.ProviderAdapterProtocolError }; | ||
| 1227 | |||
| 1228 | /** True when a root session/update carries ingestible turn output, not keepalive noise. */ | ||
| 1229 | function acpRootSessionUpdateIngestsOutput( | ||
| 1230 | notification: EffectAcpSchema.SessionNotification, | ||
| 1231 | ): boolean { | ||
| 1232 | const update = notification.update; | ||
| 1233 | switch (update.sessionUpdate) { | ||
| 1234 | case "agent_message_chunk": | ||
| 1235 | case "agent_thought_chunk": | ||
| 1236 | return (acpContentBlockDisplayText(update.content)?.length ?? 0) > 0; | ||
| 1237 | case "agent_message": | ||
| 1238 | case "agent_thought": | ||
| 1239 | return (update.content ?? []).some( | ||
| 1240 | (content) => (acpContentBlockDisplayText(content)?.length ?? 0) > 0, | ||
| 1241 | ); | ||
| 1242 | case "tool_call": | ||
| 1243 | case "tool_call_update": | ||
| 1244 | case "plan": | ||
| 1245 | case "plan_update": | ||
| 1246 | case "plan_removed": | ||
| 1247 | case "compaction_update": | ||
| 1248 | case "compaction_summary_chunk": | ||
| 1249 | return parseSessionUpdateEvent(notification).events.some( | ||
| 1250 | (event) => event._tag === "ToolCallUpdated" || event._tag === "PlanUpdated", | ||
| 1251 | ); | ||
| 1252 | default: | ||
| 1253 | return false; | ||
| 1254 | } | ||
| 1255 | } | ||
| 1256 | |||
| 1257 | /** | ||
| 1258 | * Post-settle traffic that should be *buffered* for a continuation attach. | ||
| 1259 | * Excludes monitor end/event chatter that must not become ghost history. | ||
| 1260 | * Broader than {@link acpPostSettleContinuationOfferEvidence}: incremental | ||
| 1261 | * tool progress may still need replay once a real completion offers a run. | ||
| 1262 | */ | ||
| 1263 | export function acpPostSettleWakeEvidence( | ||
| 1264 | notification: EffectAcpSchema.SessionNotification, | ||
| 1265 | flavor: Pick<AcpAdapterV2Flavor, "extractBackgroundToolMutation"> = {}, | ||
| 1266 | ): boolean { | ||
| 1267 | if (!acpRootSessionUpdateIngestsOutput(notification)) return false; | ||
| 1268 | const update = notification.update; | ||
| 1269 | if ( | ||
| 1270 | (update.sessionUpdate === "user_message_chunk" || | ||
| 1271 | update.sessionUpdate === "agent_message_chunk") && | ||
| 1272 | update.content.type === "text" | ||
| 1273 | ) { | ||
| 1274 | const text = update.content.text; | ||
| 1275 | if ((flavor.extractBackgroundToolMutation?.(text) ?? []).length > 0) return false; | ||
| 1276 | if (update.sessionUpdate === "agent_message_chunk" && text.trim().length === 0) return false; | ||
| 1277 | if (/<monitor-event\b/i.test(text) || /Monitor\s+["']?[0-9a-f-]{8,}["']?\s+ended/i.test(text)) { | ||
| 1278 | return false; | ||
| 1279 | } | ||
| 1280 | } | ||
| 1281 | return true; | ||
| 1282 | } | ||
| 1283 | |||
| 1284 | /** | ||
| 1285 | * Whether buffered post-settle traffic should *offer* a continuation run now. | ||
| 1286 | * Still-running tool streams often land farther apart than the deferred-finalize | ||
| 1287 | * quiet window; treating every tool_call_update as offer evidence re-opens a | ||
| 1288 | * synthetic "Background task completed." run on each chunk. Only completion-like | ||
| 1289 | * frames (real agent text, or a terminal tool status) should open a new run. | ||
| 1290 | */ | ||
| 1291 | export function acpPostSettleContinuationOfferEvidence( | ||
| 1292 | notification: EffectAcpSchema.SessionNotification, | ||
| 1293 | flavor: Pick<AcpAdapterV2Flavor, "extractBackgroundToolMutation" | "normalizeToolCall"> = {}, | ||
| 1294 | ): boolean { | ||
| 1295 | if (!acpPostSettleWakeEvidence(notification, flavor)) { | ||
| 1296 | return false; | ||
| 1297 | } | ||
| 1298 | const update = notification.update; | ||
| 1299 | // Assistant text only. Thought/reasoning bursts alone must not open synthetic | ||
| 1300 | // "Background task completed." runs (duplicate-run spam after monitors). | ||
| 1301 | if (update.sessionUpdate === "agent_message_chunk") { | ||
| 1302 | return (acpContentBlockDisplayText(update.content)?.trim().length ?? 0) > 0; | ||
| 1303 | } | ||
| 1304 | if (update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update") { | ||
| 1305 | return parseSessionUpdateEvent(notification).events.some((event) => { | ||
| 1306 | if (event._tag !== "ToolCallUpdated") return false; | ||
| 1307 | // Normalize first: a Grok monitor start ACK arrives with raw status | ||
| 1308 | // "completed" but is a still-running background task, not completion. | ||
| 1309 | const toolCall = flavor.normalizeToolCall?.(event.toolCall) ?? event.toolCall; | ||
| 1310 | return toolCall.status === "completed" || toolCall.status === "failed"; | ||
| 1311 | }); | ||
| 1312 | } | ||
| 1313 | return false; | ||
| 1314 | } | ||
| 1315 | |||
| 1316 | export function acpPostSettleWakeShouldBuffer( | ||
| 1317 | notification: EffectAcpSchema.SessionNotification, | ||
| 1318 | backgroundWorkRunning: boolean, | ||
| 1319 | ): boolean { | ||
| 1320 | if (!backgroundWorkRunning) return true; | ||
| 1321 | const update = notification.update; | ||
| 1322 | return ( | ||
| 1323 | update.sessionUpdate !== "agent_message_chunk" && update.sessionUpdate !== "agent_thought_chunk" | ||
| 1324 | ); | ||
| 1325 | } | ||
| 1326 | |||
| 1327 | /** | ||
| 1328 | * An app-owned wake (a delegated child finishing) is injected by the | ||
| 1329 | * orchestrator, not typed by the user. It reports on a sibling child and says | ||
| 1330 | * nothing about this session's own pending wake frames, so it must not discard | ||
| 1331 | * them the way a real user turn does. ClaudeAdapterV2 already leaves its buffer | ||
| 1332 | * alone on non-continuation turns; this keeps ACP consistent. | ||
| 1333 | */ | ||
| 1334 | export function acpIsAppOwnedWakeTurn(message: { | ||
| 1335 | readonly createdBy: string; | ||
| 1336 | readonly creationSource: string; | ||
| 1337 | }): boolean { | ||
| 1338 | return message.createdBy === "agent" && message.creationSource === "server"; | ||
| 1339 | } | ||
| 1340 | |||
| 1341 | /** A continuation run attaches to wake traffic the agent produced on its own. */ | ||
| 1342 | function acpIsProviderContinuationMessage(message: { | ||
| 1343 | readonly createdBy: string; | ||
| 1344 | readonly creationSource: string; | ||
| 1345 | }): boolean { | ||
| 1346 | return message.createdBy === "agent" && message.creationSource === "provider"; | ||
| 1347 | } | ||
| 1348 | |||
| 1349 | export function acpCarryoverTerminalShouldClearContinuation(input: { | ||
| 1350 | readonly continuationOffered: boolean; | ||
| 1351 | readonly wakeBufferLength: number; | ||
| 1352 | }): boolean { | ||
| 1353 | return !input.continuationOffered && input.wakeBufferLength === 0; | ||
| 1354 | } | ||
| 1355 | |||
| 1356 | export function acpTurnStartShouldPreserveContinuation(input: { | ||
| 1357 | readonly continuationRequested: boolean; | ||
| 1358 | readonly isContinuationTurn: boolean; | ||
| 1359 | readonly wakeBufferLength: number; | ||
| 1360 | }): boolean { | ||
| 1361 | return !input.isContinuationTurn && input.continuationRequested && input.wakeBufferLength > 0; | ||
| 1362 | } | ||
| 1363 | |||
| 1364 | export function acpPostSettleMonitorPromptShouldSuppress( | ||
| 1365 | mutation: | ||
| 1366 | | { | ||
| 1367 | readonly taskId: string; | ||
| 1368 | readonly status: "running" | "completed" | "failed"; | ||
| 1369 | } | ||
| 1370 | | undefined, | ||
| 1371 | ): boolean { | ||
| 1372 | return mutation?.status === "running"; | ||
| 1373 | } | ||
| 1374 | |||
| 1375 | export function acpCompletedTurnShouldTerminalizeTool( | ||
| 1376 | tool: AcpToolCallState, | ||
| 1377 | flavor: Pick<AcpAdapterV2Flavor, "extractBackgroundTaskId" | "extractSubagentUpdate">, | ||
| 1378 | ): boolean { | ||
| 1379 | const status = toolStatus(tool.status); | ||
| 1380 | if (status !== "pending" && status !== "running") return false; | ||
| 1381 | if (flavor.extractBackgroundTaskId?.(tool) !== undefined) return false; | ||
| 1382 | return flavor.extractSubagentUpdate?.(tool) === undefined; | ||
| 1383 | } | ||
| 1384 | |||
| 1385 | interface ActiveAcpSubagent { | ||
| 1386 | task: OrchestrationV2Subagent; | ||
| 1387 | readonly childThreadId: ThreadId; | ||
| 1388 | readonly childRootNodeId: OrchestrationV2ExecutionNode["id"]; | ||
| 1389 | readonly turnItemId: OrchestrationV2TurnItem["id"]; | ||
| 1390 | readonly turnItemOrdinal: number; | ||
| 1391 | /** Turn that spawned the subagent; carryover updates keep this lineage. */ | ||
| 1392 | readonly providerTurnId: OrchestrationV2ProviderTurn["id"]; | ||
| 1393 | readonly parentProviderThreadId: ProviderThreadId; | ||
| 1394 | childSessionId: string | null; | ||
| 1395 | assistantText: string; | ||
| 1396 | readonly assistantMessages: Map<string, string>; | ||
| 1397 | readonly childItemOrdinals: Map<string, number>; | ||
| 1398 | nextChildOrdinal: number; | ||
| 1399 | /** | ||
| 1400 | * Whether a terminal carryover status has been projected to events. | ||
| 1401 | * Completed roots project post-settle terminals immediately while their | ||
| 1402 | * subscriber remains observable. Non-completed roots retain terminals in | ||
| 1403 | * memory until the next attach. A later project:true path for the same entry | ||
| 1404 | * must still project once; this flag prevents double emission and pins | ||
| 1405 | * hasPendingBackgroundWork until projection lands. | ||
| 1406 | */ | ||
| 1407 | terminalStatusProjected: boolean; | ||
| 1408 | } | ||
| 1409 | |||
| 1410 | function acpSubagentStatusIsTerminal(status: OrchestrationV2Subagent["status"]): boolean { | ||
| 1411 | return ( | ||
| 1412 | status === "completed" || | ||
| 1413 | status === "failed" || | ||
| 1414 | status === "interrupted" || | ||
| 1415 | status === "cancelled" || | ||
| 1416 | status === "idle" | ||
| 1417 | ); | ||
| 1418 | } | ||
| 1419 | |||
| 1420 | export function acpSubagentStatusBlocksTurnSettlement( | ||
| 1421 | status: OrchestrationV2Subagent["status"], | ||
| 1422 | ): boolean { | ||
| 1423 | return status === "running" || status === "pending"; | ||
| 1424 | } | ||
| 1425 | |||
| 1426 | function acpSubagentHasPendingBackgroundWork(subagent: ActiveAcpSubagent): boolean { | ||
| 1427 | return ( | ||
| 1428 | acpSubagentStatusBlocksTurnSettlement(subagent.task.status) || !subagent.terminalStatusProjected | ||
| 1429 | ); | ||
| 1430 | } | ||
| 1431 | |||
| 1432 | type AcpCarryoverSubagents = { | ||
| 1433 | readonly sessionId: string; | ||
| 1434 | readonly rootTerminalStatus: "completed" | "interrupted" | "failed" | "cancelled"; | ||
| 1435 | readonly subagents: ReadonlyArray<ActiveAcpSubagent>; | ||
| 1436 | }; | ||
| 1437 | |||
| 1438 | type PendingRuntimeRequest = { | ||
| 1439 | readonly generation: number; | ||
| 1440 | readonly nativeResponseAcknowledgement: Deferred.Deferred<void, EffectAcpErrors.AcpError>; | ||
| 1441 | readonly transportRequestId: string; | ||
| 1442 | readonly requestId: RuntimeRequestId; | ||
| 1443 | readonly runtimeRequest: OrchestrationV2RuntimeRequest; | ||
| 1444 | readonly node: OrchestrationV2ExecutionNode; | ||
| 1445 | readonly turnItem: OrchestrationV2TurnItem; | ||
| 1446 | } & ( | ||
| 1447 | | { | ||
| 1448 | readonly type: "approval"; | ||
| 1449 | readonly decision: Deferred.Deferred<ProviderApprovalDecision>; | ||
| 1450 | } | ||
| 1451 | | { | ||
| 1452 | readonly type: "user_input"; | ||
| 1453 | readonly answers: Deferred.Deferred<ProviderUserInputAnswers | null>; | ||
| 1454 | } | ||
| 1455 | ); | ||
| 1456 | |||
| 1457 | interface SnapshotMessageState { | ||
| 1458 | readonly order: Array<string>; | ||
| 1459 | readonly messages: Map<string, OrchestrationV2ConversationMessage>; | ||
| 1460 | loadingRole: "user" | "assistant" | "thought" | null; | ||
| 1461 | loadingMessageId: string | null; | ||
| 1462 | loadingIndex: number; | ||
| 1463 | } | ||
| 1464 | |||
| 1465 | const TOOL_UPDATE_PERSIST_EVERY = 10; | ||
| 1466 | |||
| 1467 | /** | ||
| 1468 | * Some agents stream a tool's arguments (a file write's diff, `rawInput`) and | ||
| 1469 | * resend the whole call each time. Persist every 10th of those. Status, title, | ||
| 1470 | * and output the user watches live always persist, as does the agent's own | ||
| 1471 | * completed/failed when a flavor normalizes it to a non-terminal status. | ||
| 1472 | */ | ||
| 1473 | function shouldPersistToolUpdate( | ||
| 1474 | context: ActiveAcpTurn, | ||
| 1475 | key: string, | ||
| 1476 | previous: AcpToolCallState | undefined, | ||
| 1477 | next: AcpToolCallState, | ||
| 1478 | reportedStatus: AcpToolCallState["status"], | ||
| 1479 | ): boolean { | ||
| 1480 | const skipped = context.toolUpdatesSkipped.get(key) ?? 0; | ||
| 1481 | const persist = | ||
| 1482 | reportedStatus === "completed" || | ||
| 1483 | reportedStatus === "failed" || | ||
| 1484 | previous === undefined || | ||
| 1485 | previous.status !== next.status || | ||
| 1486 | previous.title !== next.title || | ||
| 1487 | toolCallVisibleOutputChanged(previous, next) || | ||
| 1488 | skipped + 1 >= TOOL_UPDATE_PERSIST_EVERY; | ||
| 1489 | context.toolUpdatesSkipped.set(key, persist ? 0 : skipped + 1); | ||
| 1490 | return persist; | ||
| 1491 | } | ||
| 1492 | |||
| 1493 | export function makeAcpAdapterV2( | ||
| 1494 | options: AcpAdapterV2Options, | ||
| 1495 | ): ProviderAdapter.ProviderAdapterV2Shape { | ||
| 1496 | const { flavor, fileSystem, idAllocator, host, selfInvocation: self } = options; | ||
| 1497 | const driver = flavor.driver; | ||
| 1498 | const continuationRequests = options.continuationRequests; | ||
| 1499 | const postSettleContinuationEnabled = | ||
| 1500 | flavor.enablePostSettleContinuation === true && continuationRequests !== undefined; | ||
| 1501 | |||
| 1502 | return ProviderAdapter.ProviderAdapterV2.of({ | ||
| 1503 | instanceId: options.instanceId, | ||
| 1504 | driver, | ||
| 1505 | getCapabilities: () => Effect.succeed(flavor.capabilities), | ||
| 1506 | planSelectionTransition: (input) => Effect.succeed(acpSelectionTransition(input)), | ||
| 1507 | openSession: Effect.fn("AcpAdapterV2.openSession")( | ||
| 1508 | function* (input: ProviderAdapter.ProviderAdapterV2OpenSessionInput) { | ||
| 1509 | const sessionScope = yield* Effect.scope; | ||
| 1510 | // Persisted ACP threads from before item identity v2 retain their old | ||
| 1511 | // deterministic ids. Fresh threads scope native ids by instance so | ||
| 1512 | // separately configured agents cannot collide. | ||
| 1513 | let itemIdentityVersion: 2 | undefined = | ||
| 1514 | input.initialProviderItemIdentityVersion ?? | ||
| 1515 | (input.initialNativeThreadId === undefined ? 2 : undefined); | ||
| 1516 | const providerNativeId = (nativeId: string) => | ||
| 1517 | acpProviderItemNativeId({ | ||
| 1518 | instanceId: options.instanceId, | ||
| 1519 | itemIdentityVersion, | ||
| 1520 | nativeId, | ||
| 1521 | }); | ||
| 1522 | const providerNodeId = (nativeItemId: string) => | ||
| 1523 | idAllocator.derive.nodeFromProviderItem({ | ||
| 1524 | driver, | ||
| 1525 | nativeItemId: providerNativeId(nativeItemId), | ||
| 1526 | }); | ||
| 1527 | const providerMessageId = (nativeItemId: string) => | ||
| 1528 | idAllocator.derive.messageFromProviderItem({ | ||
| 1529 | driver, | ||
| 1530 | nativeItemId: providerNativeId(nativeItemId), | ||
| 1531 | }); | ||
| 1532 | const providerTurnItemId = (nativeItemId: string) => | ||
| 1533 | idAllocator.derive.turnItemFromProviderItem({ | ||
| 1534 | driver, | ||
| 1535 | nativeItemId: providerNativeId(nativeItemId), | ||
| 1536 | }); | ||
| 1537 | const deriveProviderTurnId = (nativeTurnId: string) => | ||
| 1538 | idAllocator.derive.providerTurn({ | ||
| 1539 | driver, | ||
| 1540 | nativeTurnId: providerNativeId(nativeTurnId), | ||
| 1541 | }); | ||
| 1542 | const useProviderThreadIdentity = (thread: OrchestrationV2ProviderThread): void => { | ||
| 1543 | itemIdentityVersion = thread.nativeMetadata?.itemIdentityVersion; | ||
| 1544 | }; | ||
| 1545 | const terminalEnvironmentBySessionId = new Map<string, NodeJS.ProcessEnv>(); | ||
| 1546 | interface PendingTerminalEnvironment { | ||
| 1547 | readonly environment: NodeJS.ProcessEnv | undefined; | ||
| 1548 | readonly claimUnknownSession: boolean; | ||
| 1549 | readonly sessionId: string | null; | ||
| 1550 | } | ||
| 1551 | let pendingTerminalEnvironment: PendingTerminalEnvironment | null = { | ||
| 1552 | environment: acpMcpContext(input.threadId, self).processEnvironment, | ||
| 1553 | claimUnknownSession: input.initialNativeThreadId === undefined, | ||
| 1554 | sessionId: input.initialNativeThreadId ?? null, | ||
| 1555 | }; | ||
| 1556 | const prepareTerminalEnvironment = ( | ||
| 1557 | threadId: ThreadId | null, | ||
| 1558 | sessionId?: string, | ||
| 1559 | ): void => { | ||
| 1560 | pendingTerminalEnvironment = { | ||
| 1561 | environment: acpMcpContext(threadId, self).processEnvironment, | ||
| 1562 | claimUnknownSession: false, | ||
| 1563 | sessionId: sessionId ?? null, | ||
| 1564 | }; | ||
| 1565 | }; | ||
| 1566 | const prepareClaimableTerminalEnvironment = (threadId: ThreadId | null): void => { | ||
| 1567 | pendingTerminalEnvironment = { | ||
| 1568 | environment: acpMcpContext(threadId, self).processEnvironment, | ||
| 1569 | claimUnknownSession: true, | ||
| 1570 | sessionId: null, | ||
| 1571 | }; | ||
| 1572 | }; | ||
| 1573 | const rememberTerminalEnvironment = ( | ||
| 1574 | sessionId: string, | ||
| 1575 | threadId: ThreadId | null, | ||
| 1576 | ): void => { | ||
| 1577 | const environment = acpMcpContext(threadId, self).processEnvironment; | ||
| 1578 | pendingTerminalEnvironment = null; | ||
| 1579 | if (environment === undefined) { | ||
| 1580 | terminalEnvironmentBySessionId.delete(sessionId); | ||
| 1581 | } else { | ||
| 1582 | terminalEnvironmentBySessionId.set(sessionId, environment); | ||
| 1583 | } | ||
| 1584 | }; | ||
| 1585 | const clientTerminals: AcpClientTerminals | undefined = | ||
| 1586 | options.clientTerminals === undefined | ||
| 1587 | ? undefined | ||
| 1588 | : yield* makeAcpClientTerminals({ | ||
| 1589 | spawner: options.clientTerminals.childProcessSpawner, | ||
| 1590 | defaultCwd: input.runtimePolicy.cwd ?? process.cwd(), | ||
| 1591 | environment: options.clientTerminals.environment, | ||
| 1592 | shellCommands: options.clientTerminals.shellCommands, | ||
| 1593 | environmentForSession: (sessionId) => { | ||
| 1594 | const remembered = terminalEnvironmentBySessionId.get(sessionId); | ||
| 1595 | if (remembered !== undefined) return remembered; | ||
| 1596 | if (pendingTerminalEnvironment === null) return undefined; | ||
| 1597 | if ( | ||
| 1598 | pendingTerminalEnvironment.sessionId === null && | ||
| 1599 | pendingTerminalEnvironment.claimUnknownSession | ||
| 1600 | ) { | ||
| 1601 | pendingTerminalEnvironment = { | ||
| 1602 | ...pendingTerminalEnvironment, | ||
| 1603 | sessionId, | ||
| 1604 | }; | ||
| 1605 | } | ||
| 1606 | return pendingTerminalEnvironment.sessionId === sessionId | ||
| 1607 | ? pendingTerminalEnvironment.environment | ||
| 1608 | : undefined; | ||
| 1609 | }, | ||
| 1610 | }); | ||
| 1611 | if (clientTerminals !== undefined) { | ||
| 1612 | yield* Scope.addFinalizer(sessionScope, clientTerminals.disposeAll); | ||
| 1613 | } | ||
| 1614 | // Terminal ids embedded in raw tool_call updates, remembered before the | ||
| 1615 | // content rewrite so emitTool can recover MCP-fallback command lines. | ||
| 1616 | const sessionScopedId = (sessionId: string, nativeId: string): string => | ||
| 1617 | JSON.stringify([sessionId, nativeId]); | ||
| 1618 | const embeddedTerminalsByToolCallId = new Map< | ||
| 1619 | string, | ||
| 1620 | { | ||
| 1621 | readonly sessionId: string; | ||
| 1622 | readonly terminalIds: ReadonlyArray<string>; | ||
| 1623 | readonly toolCallId: string; | ||
| 1624 | } | ||
| 1625 | >(); | ||
| 1626 | const toolCallIdsByAgentTerminalId = new Map<string, Set<string>>(); | ||
| 1627 | const agentTerminalsById = new Map<string, AcpAgentTerminalState>(); | ||
| 1628 | const rememberEmbeddedTerminals = (input: { | ||
| 1629 | readonly sessionId: string; | ||
| 1630 | readonly toolCallId: string; | ||
| 1631 | readonly terminalIds: ReadonlyArray<string>; | ||
| 1632 | }): void => { | ||
| 1633 | const toolCallKey = sessionScopedId(input.sessionId, input.toolCallId); | ||
| 1634 | for (const terminalId of embeddedTerminalsByToolCallId.get(toolCallKey)?.terminalIds ?? | ||
| 1635 | []) { | ||
| 1636 | const terminalKey = sessionScopedId(input.sessionId, terminalId); | ||
| 1637 | const toolCallIds = toolCallIdsByAgentTerminalId.get(terminalKey); | ||
| 1638 | toolCallIds?.delete(input.toolCallId); | ||
| 1639 | if (toolCallIds?.size === 0) toolCallIdsByAgentTerminalId.delete(terminalKey); | ||
| 1640 | } | ||
| 1641 | embeddedTerminalsByToolCallId.delete(toolCallKey); | ||
| 1642 | embeddedTerminalsByToolCallId.set(toolCallKey, { | ||
| 1643 | sessionId: input.sessionId, | ||
| 1644 | terminalIds: input.terminalIds, | ||
| 1645 | toolCallId: input.toolCallId, | ||
| 1646 | }); | ||
| 1647 | for (const terminalId of input.terminalIds) { | ||
| 1648 | const terminalKey = sessionScopedId(input.sessionId, terminalId); | ||
| 1649 | const toolCallIds = toolCallIdsByAgentTerminalId.get(terminalKey) ?? new Set<string>(); | ||
| 1650 | toolCallIds.add(input.toolCallId); | ||
| 1651 | toolCallIdsByAgentTerminalId.set(terminalKey, toolCallIds); | ||
| 1652 | } | ||
| 1653 | for (const oldest of embeddedTerminalsByToolCallId.keys()) { | ||
| 1654 | if (embeddedTerminalsByToolCallId.size <= 256) break; | ||
| 1655 | const remembered = embeddedTerminalsByToolCallId.get(oldest); | ||
| 1656 | if (remembered === undefined) continue; | ||
| 1657 | for (const terminalId of remembered.terminalIds) { | ||
| 1658 | const terminalKey = sessionScopedId(remembered.sessionId, terminalId); | ||
| 1659 | const toolCallIds = toolCallIdsByAgentTerminalId.get(terminalKey); | ||
| 1660 | toolCallIds?.delete(remembered.toolCallId); | ||
| 1661 | if (toolCallIds?.size === 0) toolCallIdsByAgentTerminalId.delete(terminalKey); | ||
| 1662 | } | ||
| 1663 | embeddedTerminalsByToolCallId.delete(oldest); | ||
| 1664 | } | ||
| 1665 | }; | ||
| 1666 | // Command lines of the terminals embedded in a tool call, so MCP calls | ||
| 1667 | // made through the acp-mcp-call terminal fallback keep their identity. | ||
| 1668 | const embeddedTerminalCommands = ( | ||
| 1669 | sessionId: string, | ||
| 1670 | toolCallId: string, | ||
| 1671 | ): ReadonlyArray<string> => | ||
| 1672 | ( | ||
| 1673 | embeddedTerminalsByToolCallId.get(sessionScopedId(sessionId, toolCallId)) | ||
| 1674 | ?.terminalIds ?? [] | ||
| 1675 | ).flatMap((terminalId) => { | ||
| 1676 | const command = | ||
| 1677 | clientTerminals?.readCommandLine(terminalId) ?? | ||
| 1678 | agentTerminalsById.get(sessionScopedId(sessionId, terminalId))?.command; | ||
| 1679 | return command === undefined ? [] : [command]; | ||
| 1680 | }); | ||
| 1681 | // Client terminals (Devin) run with the T3 server's privileges, so they | ||
| 1682 | // are policy-checked against the active turn policy; a command the user | ||
| 1683 | // already approved satisfies an "ask" disposition. | ||
| 1684 | const clientPolicyGrants = makeAcpClientPolicyGrants(); | ||
| 1685 | let latestRuntimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy = | ||
| 1686 | input.runtimePolicy; | ||
| 1687 | const events = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event>(); | ||
| 1688 | const activeTurn = yield* Ref.make<ActiveAcpTurn | null>(null); | ||
| 1689 | const activeSessionId = yield* Ref.make<string | null>(null); | ||
| 1690 | const contextUsageBySessionId = yield* Ref.make( | ||
| 1691 | new Map<string, ThreadTokenUsageSnapshot>(), | ||
| 1692 | ); | ||
| 1693 | const nativeMetadataBySessionId = yield* Ref.make( | ||
| 1694 | new Map<string, OrchestrationV2ProviderThreadNativeMetadata>(), | ||
| 1695 | ); | ||
| 1696 | const providerThreadByNativeSessionId = yield* Ref.make( | ||
| 1697 | new Map<string, OrchestrationV2ProviderThread>(), | ||
| 1698 | ); | ||
| 1699 | // T3 only owns the temporary Plan override. Remember the agent's | ||
| 1700 | // effective native configuration on entry and restore it on Build. | ||
| 1701 | const nativeBuildConfigurationBySessionId = new Map<string, AcpNativeBuildConfiguration>(); | ||
| 1702 | const initialSessionActivationFailure = yield* Ref.make<{ | ||
| 1703 | readonly sessionId: string; | ||
| 1704 | readonly error: EffectAcpErrors.AcpError; | ||
| 1705 | } | null>(null); | ||
| 1706 | const activeSessionSetup = | ||
| 1707 | yield* Ref.make<AcpSessionRuntime.AcpSessionRuntimeStartResult | null>(null); | ||
| 1708 | const activeSelection = yield* Ref.make<ModelSelection | null>(null); | ||
| 1709 | const activeInteractionMode = yield* Ref.make<ProviderInteractionMode | null>(null); | ||
| 1710 | const promptInstructionStates = yield* Ref.make(new Map<string, T3AcpInstructionState>()); | ||
| 1711 | const runtimeRestartRequired = yield* Ref.make(false); | ||
| 1712 | const runtimeTeardownState = yield* Ref.make<AcpRuntimeTeardownState>({ _tag: "Idle" }); | ||
| 1713 | const runtimeCallbackGeneration = yield* Ref.make(0); | ||
| 1714 | const runtimeCallbackGenerationCounter = yield* Ref.make(0); | ||
| 1715 | const allocateRuntimeCallbackGeneration = Ref.updateAndGet( | ||
| 1716 | runtimeCallbackGenerationCounter, | ||
| 1717 | (generation) => generation + 1, | ||
| 1718 | ); | ||
| 1719 | const advanceRuntimeCallbackGeneration = allocateRuntimeCallbackGeneration.pipe( | ||
| 1720 | Effect.tap((generation) => Ref.set(runtimeCallbackGeneration, generation)), | ||
| 1721 | ); | ||
| 1722 | const runtimeCallbackPermit = yield* Semaphore.make(1); | ||
| 1723 | const runtimeTransitionPermit = yield* Semaphore.make(1); | ||
| 1724 | const nativeResponseAcknowledgements = yield* Ref.make( | ||
| 1725 | new Map< | ||
| 1726 | string, | ||
| 1727 | { | ||
| 1728 | readonly acknowledgement: Deferred.Deferred<void, EffectAcpErrors.AcpError>; | ||
| 1729 | readonly generation: number; | ||
| 1730 | } | ||
| 1731 | >(), | ||
| 1732 | ); | ||
| 1733 | const pendingRuntimeRequests = yield* Ref.make(new Map<string, PendingRuntimeRequest>()); | ||
| 1734 | const emitNativeResponseLifecycle = | ||
| 1735 | options.testHooks?.onNativeResponseLifecycle ?? (() => Effect.void); | ||
| 1736 | const nextElicitationOrdinal = yield* Ref.make(0); | ||
| 1737 | const providerTurns = yield* Ref.make(new Map<string, OrchestrationV2ProviderTurn>()); | ||
| 1738 | const snapshot = yield* Ref.make<SnapshotMessageState>({ | ||
| 1739 | order: [], | ||
| 1740 | messages: new Map(), | ||
| 1741 | loadingRole: null, | ||
| 1742 | loadingMessageId: null, | ||
| 1743 | loadingIndex: 0, | ||
| 1744 | }); | ||
| 1745 | |||
| 1746 | const awaitRuntimeTeardown = Effect.fnUntraced(function* () { | ||
| 1747 | const state = yield* Ref.get(runtimeTeardownState); | ||
| 1748 | if (state._tag === "InProgress") { | ||
| 1749 | yield* Deferred.await(state.completed); | ||
| 1750 | } else if (state._tag === "Failed") { | ||
| 1751 | return yield* state.error; | ||
| 1752 | } | ||
| 1753 | }); | ||
| 1754 | const runRuntimeCallbackAtGeneration = <A, E, R>( | ||
| 1755 | generation: number, | ||
| 1756 | effect: Effect.Effect<A, E, R>, | ||
| 1757 | ) => | ||
| 1758 | runtimeCallbackPermit.withPermit( | ||
| 1759 | Effect.gen(function* () { | ||
| 1760 | if ((yield* Ref.get(runtimeTeardownState))._tag !== "Idle") { | ||
| 1761 | return Option.none<A>(); | ||
| 1762 | } | ||
| 1763 | if ((yield* Ref.get(runtimeCallbackGeneration)) !== generation) { | ||
| 1764 | return Option.none<A>(); | ||
| 1765 | } | ||
| 1766 | return Option.some(yield* effect); | ||
| 1767 | }), | ||
| 1768 | ); | ||
| 1769 | const registerNativeResponseAcknowledgement = ( | ||
| 1770 | generation: number, | ||
| 1771 | transportRequestId: string, | ||
| 1772 | acknowledgement: Deferred.Deferred<void, EffectAcpErrors.AcpError>, | ||
| 1773 | ) => | ||
| 1774 | Effect.gen(function* () { | ||
| 1775 | yield* Ref.update(nativeResponseAcknowledgements, (current) => { | ||
| 1776 | const updated = new Map(current); | ||
| 1777 | updated.set(transportRequestId, { acknowledgement, generation }); | ||
| 1778 | return updated; | ||
| 1779 | }); | ||
| 1780 | yield* emitNativeResponseLifecycle({ | ||
| 1781 | type: "registered", | ||
| 1782 | generation, | ||
| 1783 | requestId: transportRequestId, | ||
| 1784 | }); | ||
| 1785 | if (!(yield* Deferred.isDone(acknowledgement))) return; | ||
| 1786 | yield* Ref.update(nativeResponseAcknowledgements, (current) => { | ||
| 1787 | if (current.get(transportRequestId)?.acknowledgement !== acknowledgement) { | ||
| 1788 | return current; | ||
| 1789 | } | ||
| 1790 | const updated = new Map(current); | ||
| 1791 | updated.delete(transportRequestId); | ||
| 1792 | return updated; | ||
| 1793 | }); | ||
| 1794 | }); | ||
| 1795 | const acknowledgeNativeResponse = (generation: number, transportRequestId: string) => | ||
| 1796 | ( | ||
| 1797 | options.testHooks?.beforeNativeResponseAdmissionCheck?.( | ||
| 1798 | generation, | ||
| 1799 | transportRequestId, | ||
| 1800 | ) ?? Effect.void | ||
| 1801 | ).pipe( | ||
| 1802 | Effect.andThen(runRuntimeCallbackAtGeneration(generation, Effect.void)), | ||
| 1803 | Effect.flatMap((registered) => | ||
| 1804 | Option.isSome(registered) | ||
| 1805 | ? Effect.void | ||
| 1806 | : emitNativeResponseLifecycle({ | ||
| 1807 | type: "admission_rejected", | ||
| 1808 | generation, | ||
| 1809 | requestId: transportRequestId, | ||
| 1810 | }).pipe( | ||
| 1811 | Effect.andThen( | ||
| 1812 | new EffectAcpErrors.AcpTransportError({ | ||
| 1813 | detail: "The ACP runtime closed before its response could be admitted", | ||
| 1814 | cause: "Native response registration rejected during teardown", | ||
| 1815 | }), | ||
| 1816 | ), | ||
| 1817 | ), | ||
| 1818 | ), | ||
| 1819 | ); | ||
| 1820 | const awaitNativeResponseAcknowledgements = Effect.fnUntraced(function* ( | ||
| 1821 | acknowledgements: ReadonlyArray< | ||
| 1822 | readonly [string | undefined, Deferred.Deferred<void, EffectAcpErrors.AcpError>] | ||
| 1823 | >, | ||
| 1824 | ) { | ||
| 1825 | if (acknowledgements.length === 0) return true; | ||
| 1826 | const completed = yield* Deferred.make<"settled" | "timeout">(); | ||
| 1827 | yield* emitNativeResponseLifecycle({ type: "watcher_started" }); | ||
| 1828 | const acknowledgementFiber = yield* Effect.forEach( | ||
| 1829 | acknowledgements, | ||
| 1830 | ([, acknowledgement]) => Deferred.await(acknowledgement).pipe(Effect.exit), | ||
| 1831 | { concurrency: "unbounded", discard: true }, | ||
| 1832 | ).pipe( | ||
| 1833 | Effect.andThen(Deferred.succeed(completed, "settled")), | ||
| 1834 | Effect.interruptible, | ||
| 1835 | Effect.ensuring(emitNativeResponseLifecycle({ type: "watcher_exited" })), | ||
| 1836 | Effect.forkDetach, | ||
| 1837 | ); | ||
| 1838 | yield* emitNativeResponseLifecycle({ type: "timer_started" }); | ||
| 1839 | const timerFiber = yield* Effect.sleep("2 seconds").pipe( | ||
| 1840 | Effect.andThen(Deferred.succeed(completed, "timeout")), | ||
| 1841 | Effect.interruptible, | ||
| 1842 | Effect.ensuring(emitNativeResponseLifecycle({ type: "timer_exited" })), | ||
| 1843 | Effect.forkDetach, | ||
| 1844 | ); | ||
| 1845 | const outcome = yield* Deferred.await(completed); | ||
| 1846 | yield* Fiber.interrupt(acknowledgementFiber); | ||
| 1847 | yield* Fiber.interrupt(timerFiber); | ||
| 1848 | if (outcome === "settled") return true; | ||
| 1849 | |||
| 1850 | yield* Ref.update(nativeResponseAcknowledgements, (current) => { | ||
| 1851 | const updated = new Map(current); | ||
| 1852 | for (const [requestId, acknowledgement] of acknowledgements) { | ||
| 1853 | if ( | ||
| 1854 | requestId !== undefined && | ||
| 1855 | updated.get(requestId)?.acknowledgement === acknowledgement | ||
| 1856 | ) { | ||
| 1857 | updated.delete(requestId); | ||
| 1858 | } | ||
| 1859 | } | ||
| 1860 | return updated; | ||
| 1861 | }); | ||
| 1862 | yield* Effect.forEach( | ||
| 1863 | acknowledgements, | ||
| 1864 | ([requestId]) => | ||
| 1865 | emitNativeResponseLifecycle({ | ||
| 1866 | type: "removed", | ||
| 1867 | ...(requestId === undefined ? {} : { requestId }), | ||
| 1868 | }), | ||
| 1869 | { concurrency: "unbounded", discard: true }, | ||
| 1870 | ); | ||
| 1871 | const timeoutError = new EffectAcpErrors.AcpTransportError({ | ||
| 1872 | detail: "Timed out waiting for an admitted ACP response to reach the transport queue", | ||
| 1873 | cause: "Native response acknowledgement timed out", | ||
| 1874 | }); | ||
| 1875 | yield* Effect.forEach( | ||
| 1876 | acknowledgements, | ||
| 1877 | ([requestId, acknowledgement]) => | ||
| 1878 | Deferred.fail(acknowledgement, timeoutError).pipe( | ||
| 1879 | Effect.andThen( | ||
| 1880 | emitNativeResponseLifecycle({ | ||
| 1881 | type: "failed", | ||
| 1882 | ...(requestId === undefined ? {} : { requestId }), | ||
| 1883 | }), | ||
| 1884 | ), | ||
| 1885 | ), | ||
| 1886 | { concurrency: "unbounded", discard: true }, | ||
| 1887 | ); | ||
| 1888 | return false; | ||
| 1889 | }); | ||
| 1890 | const awaitAdmittedNativeResponses = Ref.get(nativeResponseAcknowledgements).pipe( | ||
| 1891 | Effect.flatMap((current) => | ||
| 1892 | awaitNativeResponseAcknowledgements( | ||
| 1893 | [...current.entries()].map( | ||
| 1894 | ([requestId, entry]) => [requestId, entry.acknowledgement] as const, | ||
| 1895 | ), | ||
| 1896 | ), | ||
| 1897 | ), | ||
| 1898 | ); | ||
| 1899 | const quarantineNativeTransportAtGeneration = Effect.fnUntraced(function* ( | ||
| 1900 | generation: number, | ||
| 1901 | ) { | ||
| 1902 | const quarantined = yield* Ref.modify(nativeResponseAcknowledgements, (current) => { | ||
| 1903 | const updated = new Map(current); | ||
| 1904 | const acknowledgements: Array<Deferred.Deferred<void, EffectAcpErrors.AcpError>> = []; | ||
| 1905 | for (const [requestId, entry] of updated) { | ||
| 1906 | if (entry.generation !== generation) continue; | ||
| 1907 | updated.delete(requestId); | ||
| 1908 | acknowledgements.push(entry.acknowledgement); | ||
| 1909 | } | ||
| 1910 | return [acknowledgements, updated] as const; | ||
| 1911 | }); | ||
| 1912 | const error = new EffectAcpErrors.AcpTransportError({ | ||
| 1913 | detail: "The ACP runtime was replaced before its response reached the transport queue", | ||
| 1914 | cause: "ACP runtime transport was quarantined during teardown", | ||
| 1915 | }); | ||
| 1916 | yield* Effect.forEach( | ||
| 1917 | quarantined, | ||
| 1918 | (acknowledgement) => Deferred.fail(acknowledgement, error), | ||
| 1919 | { concurrency: "unbounded", discard: true }, | ||
| 1920 | ); | ||
| 1921 | }); | ||
| 1922 | const closeNativeTransport = runtimeCallbackPermit.withPermit( | ||
| 1923 | Effect.gen(function* () { | ||
| 1924 | yield* advanceRuntimeCallbackGeneration; | ||
| 1925 | const acknowledgements = yield* Ref.getAndSet( | ||
| 1926 | nativeResponseAcknowledgements, | ||
| 1927 | new Map(), | ||
| 1928 | ); | ||
| 1929 | const error = new EffectAcpErrors.AcpTransportError({ | ||
| 1930 | detail: "The ACP session closed before its admitted response reached the transport", | ||
| 1931 | cause: "ACP session transport closed", | ||
| 1932 | }); | ||
| 1933 | yield* Effect.forEach( | ||
| 1934 | acknowledgements, | ||
| 1935 | ([requestId, entry]) => | ||
| 1936 | Deferred.fail(entry.acknowledgement, error).pipe( | ||
| 1937 | Effect.andThen( | ||
| 1938 | emitNativeResponseLifecycle({ | ||
| 1939 | type: "removed", | ||
| 1940 | generation: entry.generation, | ||
| 1941 | requestId, | ||
| 1942 | }), | ||
| 1943 | ), | ||
| 1944 | Effect.andThen( | ||
| 1945 | emitNativeResponseLifecycle({ | ||
| 1946 | type: "failed", | ||
| 1947 | generation: entry.generation, | ||
| 1948 | requestId, | ||
| 1949 | }), | ||
| 1950 | ), | ||
| 1951 | ), | ||
| 1952 | { concurrency: "unbounded", discard: true }, | ||
| 1953 | ); | ||
| 1954 | return acknowledgements.size > 0; | ||
| 1955 | }), | ||
| 1956 | ); | ||
| 1957 | // Post-settle wake support (Grok async subagent/monitor follow-up). After | ||
| 1958 | // the root turn finalizes, later root session/update traffic buffers here | ||
| 1959 | // until a provider continuation run attaches and drains it. | ||
| 1960 | const lastTurnRoute = yield* Ref.make<{ | ||
| 1961 | readonly threadId: ThreadId; | ||
| 1962 | readonly providerThreadId: ProviderThreadId; | ||
| 1963 | } | null>(null); | ||
| 1964 | const wakeBuffer = yield* Ref.make<Array<EffectAcpSchema.SessionNotification>>([]); | ||
| 1965 | // Background work that ended after the prompt settled, keyed by task or | ||
| 1966 | // child session id. The next continuation offer names it for the user; | ||
| 1967 | // `offered` holds the keys that offer named. Work that ends while the | ||
| 1968 | // offer waits for its turn is named by the offer after it. | ||
| 1969 | const noWakeReports = { | ||
| 1970 | reports: new Map<string, BackgroundWorkReport>(), | ||
| 1971 | offered: new Set<string>(), | ||
| 1972 | }; | ||
| 1973 | const wakeReports = yield* Ref.make<{ | ||
| 1974 | readonly reports: ReadonlyMap<string, BackgroundWorkReport>; | ||
| 1975 | readonly offered: ReadonlySet<string>; | ||
| 1976 | }>(noWakeReports); | ||
| 1977 | const recordWakeReport = Effect.fnUntraced(function* ( | ||
| 1978 | key: string, | ||
| 1979 | report: BackgroundWorkReport, | ||
| 1980 | ) { | ||
| 1981 | const context = yield* Ref.get(activeTurn); | ||
| 1982 | // An open prompt reports the work itself; no continuation follows. | ||
| 1983 | if (context !== null && !context.promptSettled && !context.finalized) return; | ||
| 1984 | yield* Ref.update(wakeReports, ({ reports, offered }) => ({ | ||
| 1985 | reports: new Map(reports).set(key, report), | ||
| 1986 | offered, | ||
| 1987 | })); | ||
| 1988 | }); | ||
| 1989 | const continuationRequested = yield* Ref.make(false); | ||
| 1990 | const continuationGeneration = yield* Ref.make(0); | ||
| 1991 | const continuationPermit = yield* Semaphore.make(1); | ||
| 1992 | const continuationClosed = yield* Ref.make(false); | ||
| 1993 | // Direct Stop (requestRuntimeRestart) quarantines residual events from the | ||
| 1994 | // stopped run so they cannot wake or attach to a later prompt/run. | ||
| 1995 | const stoppedRunQuarantine = yield* Ref.make(false); | ||
| 1996 | // A steering restart (or any interrupt) can finalize a turn while its | ||
| 1997 | // spawned subagents are still running natively. Carry the live | ||
| 1998 | // lineages into the next turn on the same session so their terminal | ||
| 1999 | // signals can still flip the original turn items instead of leaving | ||
| 2000 | // them running forever. | ||
| 2001 | const carryoverSubagents = yield* Ref.make<AcpCarryoverSubagents | null>(null); | ||
| 2002 | const handledBackgroundTaskIdsInActiveTurn = yield* Ref.make<ReadonlySet<string>>( | ||
| 2003 | new Set(), | ||
| 2004 | ); | ||
| 2005 | // Background tasks that reached a genuine terminal mutation while a root | ||
| 2006 | // turn was still streaming and were not yet marked handled in-turn. The | ||
| 2007 | // mid-turn offer is suppressed (active turn owns the work); on finalize | ||
| 2008 | // these ids re-arm a single continuation when the agent never hydrated | ||
| 2009 | // or reported them before settle (regression: mid-turn complete + no | ||
| 2010 | // get_command must still wake after finalize). | ||
| 2011 | const midTurnUnreportedCompletedTaskIds = yield* Ref.make<ReadonlySet<string>>(new Set()); | ||
| 2012 | // A monitor-event can arrive after its task and the user-facing provider | ||
| 2013 | // turn already completed. Grok starts another internal prompt for that | ||
| 2014 | // stale notification; suppress its agent output until a genuine terminal | ||
| 2015 | // mutation or the next app turn so it cannot create a redundant app | ||
| 2016 | // continuation. Tool frames continue through normal hydration. | ||
| 2017 | const suppressPostSettleMonitorPrompt = yield* Ref.make(false); | ||
| 2018 | // Background tasks (Grok monitors) known to still run at session level. | ||
| 2019 | // Turn contexts are too short-lived to carry this: a continuation run | ||
| 2020 | // finalizes between monitor events, and the next commentary burst must | ||
| 2021 | // not reopen a run while the monitor is still streaming. | ||
| 2022 | const runningBackgroundTaskIds = yield* Ref.make<ReadonlySet<string>>(new Set()); | ||
| 2023 | // Task ids with a GENUINE end signal (monitor-ended reminder or | ||
| 2024 | // TaskOutput completion). Normalized tool statuses are not genuine: | ||
| 2025 | // Grok Bash re-reports carry exit_code 0 mid-stream. A straggler | ||
| 2026 | // monitor-event can land after the real end (the CLI keeps streaming | ||
| 2027 | // while the agent already consumed the output via | ||
| 2028 | // get_command_or_subagent_output); without the tombstone it would | ||
| 2029 | // resurrect the running set and pin offers/idle-release forever. | ||
| 2030 | // A tool-level failed get_command tombstones too: failing open to a | ||
| 2031 | // single continuation beats failing closed to a dead thread. | ||
| 2032 | const endedBackgroundTaskIds = yield* Ref.make<ReadonlySet<string>>(new Set()); | ||
| 2033 | const endedBackgroundTaskIdLimit = 128; | ||
| 2034 | |||
| 2035 | const setBackgroundTaskRunning = (taskId: string, running: boolean) => | ||
| 2036 | Effect.gen(function* () { | ||
| 2037 | if (running && (yield* Ref.get(endedBackgroundTaskIds)).has(taskId)) { | ||
| 2038 | return; | ||
| 2039 | } | ||
| 2040 | yield* Ref.update(runningBackgroundTaskIds, (current) => { | ||
| 2041 | if (current.has(taskId) === running) return current; | ||
| 2042 | const next = new Set(current); | ||
| 2043 | if (running) { | ||
| 2044 | next.add(taskId); | ||
| 2045 | } else { | ||
| 2046 | next.delete(taskId); | ||
| 2047 | } | ||
| 2048 | return next; | ||
| 2049 | }); | ||
| 2050 | }); | ||
| 2051 | |||
| 2052 | const markBackgroundTaskEnded = (taskId: string) => | ||
| 2053 | Ref.update(endedBackgroundTaskIds, (current) => { | ||
| 2054 | if (current.has(taskId)) return current; | ||
| 2055 | const next = new Set(current).add(taskId); | ||
| 2056 | for (const oldest of next) { | ||
| 2057 | if (next.size <= endedBackgroundTaskIdLimit) break; | ||
| 2058 | next.delete(oldest); | ||
| 2059 | } | ||
| 2060 | return next; | ||
| 2061 | }).pipe(Effect.andThen(setBackgroundTaskRunning(taskId, false))); | ||
| 2062 | |||
| 2063 | const applyBackgroundTaskMutationRunning = (mutation: { | ||
| 2064 | readonly taskId: string; | ||
| 2065 | readonly status: "running" | "completed" | "failed"; | ||
| 2066 | }) => | ||
| 2067 | mutation.status === "running" | ||
| 2068 | ? setBackgroundTaskRunning(mutation.taskId, true) | ||
| 2069 | : markBackgroundTaskEnded(mutation.taskId); | ||
| 2070 | |||
| 2071 | const trackRunningBackgroundTools = ( | ||
| 2072 | notification: EffectAcpSchema.SessionNotification, | ||
| 2073 | ): Effect.Effect<void> => | ||
| 2074 | Effect.gen(function* () { | ||
| 2075 | if (flavor.extractBackgroundTaskId === undefined) return; | ||
| 2076 | for (const event of parseSessionUpdateEvent(notification).events) { | ||
| 2077 | if (event._tag !== "ToolCallUpdated") continue; | ||
| 2078 | const toolCall = flavor.normalizeToolCall?.(event.toolCall) ?? event.toolCall; | ||
| 2079 | const taskId = flavor.extractBackgroundTaskId(toolCall); | ||
| 2080 | if (taskId === undefined) continue; | ||
| 2081 | const status = toolStatus(toolCall.status); | ||
| 2082 | yield* setBackgroundTaskRunning(taskId, status === "pending" || status === "running"); | ||
| 2083 | } | ||
| 2084 | }); | ||
| 2085 | |||
| 2086 | const emitProviderEvent = (event: ProviderAdapter.ProviderAdapterV2Event) => | ||
| 2087 | Queue.offer(events, event).pipe(Effect.asVoid); | ||
| 2088 | let scheduleDeferredFinalize: (context: ActiveAcpTurn) => Effect.Effect<void> = () => | ||
| 2089 | Effect.void; | ||
| 2090 | |||
| 2091 | const nativeLogging = options.nativeLogging?.(input.threadId); | ||
| 2092 | const handleRuntimeTerminationAtGeneration = (runtimeGeneration: number) => | ||
| 2093 | runRuntimeCallbackAtGeneration( | ||
| 2094 | runtimeGeneration, | ||
| 2095 | Ref.set(runtimeRestartRequired, true), | ||
| 2096 | ).pipe(Effect.asVoid); | ||
| 2097 | const makeRuntimeInput = ( | ||
| 2098 | runtimeGeneration: number, | ||
| 2099 | threadId: ThreadId | null, | ||
| 2100 | resumeSessionId?: string, | ||
| 2101 | onTermination: AcpAdapterV2RuntimeInput["onTermination"] = () => | ||
| 2102 | handleRuntimeTerminationAtGeneration(runtimeGeneration), | ||
| 2103 | ): AcpAdapterV2RuntimeInput => { | ||
| 2104 | const mcpContext = acpMcpContext(threadId, self); | ||
| 2105 | return { | ||
| 2106 | cwd: input.runtimePolicy.cwd ?? process.cwd(), | ||
| 2107 | runtimePolicy: input.runtimePolicy, | ||
| 2108 | mcpServers: mcpContext.servers, | ||
| 2109 | acpMcpServers: mcpContext.acpServers, | ||
| 2110 | ...(mcpContext.processEnvironment === undefined | ||
| 2111 | ? {} | ||
| 2112 | : { processEnvironment: mcpContext.processEnvironment }), | ||
| 2113 | ...(resumeSessionId === undefined ? {} : { resumeSessionId }), | ||
| 2114 | interruptPromptOnCancel: flavor.interruptPromptOnCancel ?? false, | ||
| 2115 | clientCapabilities: { | ||
| 2116 | fs: { | ||
| 2117 | readTextFile: flavor.clientFileSystem !== undefined, | ||
| 2118 | writeTextFile: flavor.clientFileSystem !== undefined, | ||
| 2119 | }, | ||
| 2120 | terminal: clientTerminals !== undefined, | ||
| 2121 | elicitation: { form: {}, ...(flavor.onUrlElicitation ? { url: {} } : {}) }, | ||
| 2122 | ...(flavor.clientCapabilitiesMeta ? { _meta: flavor.clientCapabilitiesMeta } : {}), | ||
| 2123 | }, | ||
| 2124 | clientInfo: { name: "t3-code", version: "0.0.0" }, | ||
| 2125 | onTermination, | ||
| 2126 | onOutgoingResponseFailure: (requestId, error) => | ||
| 2127 | Ref.modify(nativeResponseAcknowledgements, (current) => { | ||
| 2128 | const entry = current.get(requestId); | ||
| 2129 | if (entry === undefined || entry.generation !== runtimeGeneration) { | ||
| 2130 | return [ | ||
| 2131 | emitNativeResponseLifecycle({ | ||
| 2132 | type: "late_noop", | ||
| 2133 | generation: runtimeGeneration, | ||
| 2134 | requestId, | ||
| 2135 | }), | ||
| 2136 | current, | ||
| 2137 | ] as const; | ||
| 2138 | } | ||
| 2139 | const updated = new Map(current); | ||
| 2140 | updated.delete(requestId); | ||
| 2141 | return [ | ||
| 2142 | Deferred.fail(entry.acknowledgement, error).pipe( | ||
| 2143 | Effect.andThen( | ||
| 2144 | emitNativeResponseLifecycle({ | ||
| 2145 | type: "removed", | ||
| 2146 | generation: runtimeGeneration, | ||
| 2147 | requestId, | ||
| 2148 | }), | ||
| 2149 | ), | ||
| 2150 | Effect.asVoid, | ||
| 2151 | ), | ||
| 2152 | updated, | ||
| 2153 | ] as const; | ||
| 2154 | }).pipe(Effect.flatten), | ||
| 2155 | onOutgoingResponse: (requestId) => | ||
| 2156 | Ref.modify(nativeResponseAcknowledgements, (current) => { | ||
| 2157 | const entry = current.get(requestId); | ||
| 2158 | if (entry === undefined || entry.generation !== runtimeGeneration) { | ||
| 2159 | return [ | ||
| 2160 | emitNativeResponseLifecycle({ | ||
| 2161 | type: "late_noop", | ||
| 2162 | generation: runtimeGeneration, | ||
| 2163 | requestId, | ||
| 2164 | }), | ||
| 2165 | current, | ||
| 2166 | ] as const; | ||
| 2167 | } | ||
| 2168 | const updated = new Map(current); | ||
| 2169 | updated.delete(requestId); | ||
| 2170 | return [ | ||
| 2171 | Deferred.succeed(entry.acknowledgement, undefined).pipe( | ||
| 2172 | Effect.andThen( | ||
| 2173 | emitNativeResponseLifecycle({ | ||
| 2174 | type: "removed", | ||
| 2175 | generation: runtimeGeneration, | ||
| 2176 | requestId, | ||
| 2177 | }), | ||
| 2178 | ), | ||
| 2179 | Effect.asVoid, | ||
| 2180 | ), | ||
| 2181 | updated, | ||
| 2182 | ] as const; | ||
| 2183 | }).pipe(Effect.flatten), | ||
| 2184 | ...(nativeLogging?.requestLogger === undefined | ||
| 2185 | ? {} | ||
| 2186 | : { requestLogger: nativeLogging.requestLogger }), | ||
| 2187 | protocolLogging: nativeLogging?.protocolLogging ?? { | ||
| 2188 | logIncoming: true, | ||
| 2189 | logOutgoing: true, | ||
| 2190 | logger: () => Effect.void, | ||
| 2191 | }, | ||
| 2192 | }; | ||
| 2193 | }; | ||
| 2194 | let runtimeScope: Scope.Closeable | undefined; | ||
| 2195 | let runtime!: AcpSessionRuntime.AcpSessionRuntime["Service"]; | ||
| 2196 | let runtimeMcpBridge: AcpMcpOverAcpBridge | undefined; | ||
| 2197 | yield* Effect.addFinalizer(() => | ||
| 2198 | runtimeScope === undefined | ||
| 2199 | ? Effect.void | ||
| 2200 | : Scope.close(runtimeScope, Exit.void).pipe(Effect.ignore), | ||
| 2201 | ); | ||
| 2202 | |||
| 2203 | const resolveItemOrdinal = (context: ActiveAcpTurn, nativeItemId: string) => | ||
| 2204 | Effect.sync(() => { | ||
| 2205 | const existing = context.itemOrdinals.get(nativeItemId); | ||
| 2206 | if (existing !== undefined) return existing; | ||
| 2207 | const ordinal = context.input.providerTurnOrdinal * 100 + context.itemOrdinals.size + 1; | ||
| 2208 | context.itemOrdinals.set(nativeItemId, ordinal); | ||
| 2209 | return ordinal; | ||
| 2210 | }); | ||
| 2211 | |||
| 2212 | const resolveSubagentChildOrdinal = (subagent: ActiveAcpSubagent, nativeItemId: string) => { | ||
| 2213 | const existing = subagent.childItemOrdinals.get(nativeItemId); | ||
| 2214 | if (existing !== undefined) return existing; | ||
| 2215 | const ordinal = subagent.nextChildOrdinal++; | ||
| 2216 | subagent.childItemOrdinals.set(nativeItemId, ordinal); | ||
| 2217 | return ordinal; | ||
| 2218 | }; | ||
| 2219 | |||
| 2220 | const emitProviderRetry = Effect.fnUntraced(function* ( | ||
| 2221 | context: ActiveAcpTurn, | ||
| 2222 | status: "running" | "completed" | "interrupted" | "cancelled", | ||
| 2223 | ) { | ||
| 2224 | const state = context.providerRetry; | ||
| 2225 | if (state === undefined) return; | ||
| 2226 | yield* emitProviderEvent({ | ||
| 2227 | type: "turn_item.updated", | ||
| 2228 | driver, | ||
| 2229 | turnItem: makeProviderRetryTurnItem({ | ||
| 2230 | idAllocator, | ||
| 2231 | driver, | ||
| 2232 | threadId: context.input.threadId, | ||
| 2233 | runId: context.input.runId, | ||
| 2234 | nodeId: context.input.rootNodeId, | ||
| 2235 | providerThreadId: context.input.providerThread.id, | ||
| 2236 | providerTurnId: context.providerTurnId, | ||
| 2237 | ...state, | ||
| 2238 | status, | ||
| 2239 | updatedAt: yield* DateTime.now, | ||
| 2240 | }), | ||
| 2241 | }); | ||
| 2242 | if (status !== "running") context.providerRetry = undefined; | ||
| 2243 | }); | ||
| 2244 | |||
| 2245 | const reportProviderRetry = Effect.fnUntraced(function* (notice: { | ||
| 2246 | readonly sessionId: string; | ||
| 2247 | readonly failure: OrchestrationV2ProviderFailure; | ||
| 2248 | }) { | ||
| 2249 | const context = yield* Ref.get(activeTurn); | ||
| 2250 | if ( | ||
| 2251 | context === null || | ||
| 2252 | context.finalized || | ||
| 2253 | context.interrupted || | ||
| 2254 | context.nativeThreadId !== notice.sessionId || | ||
| 2255 | (yield* Ref.get(stoppedRunQuarantine)) | ||
| 2256 | ) | ||
| 2257 | return; | ||
| 2258 | const previous = context.providerRetry; | ||
| 2259 | context.providerRetry = { | ||
| 2260 | failure: notice.failure, | ||
| 2261 | retry: { | ||
| 2262 | attempt: (previous?.retry.attempt ?? 0) + 1, | ||
| 2263 | maxAttempts: null, | ||
| 2264 | retryDelayMs: null, | ||
| 2265 | }, | ||
| 2266 | startedAt: previous?.startedAt ?? (yield* DateTime.now), | ||
| 2267 | itemOrdinal: | ||
| 2268 | previous?.itemOrdinal ?? | ||
| 2269 | (yield* resolveItemOrdinal(context, `terminal-failure:${context.providerTurnId}`)), | ||
| 2270 | }; | ||
| 2271 | yield* emitProviderRetry(context, "running"); | ||
| 2272 | }); | ||
| 2273 | |||
| 2274 | const lastCapturedProposedPlan = yield* Ref.make<{ | ||
| 2275 | readonly nativeTurnId: string; | ||
| 2276 | readonly markdown: string; | ||
| 2277 | readonly planId: OrchestrationV2PlanArtifact["id"]; | ||
| 2278 | } | null>(null); | ||
| 2279 | /** | ||
| 2280 | * Emits a completed proposed-plan artifact for the active turn. Same | ||
| 2281 | * plan id per turn so repeated captures (plan.md rewrites, the exit | ||
| 2282 | * gate) update one card; identical markdown within a turn is a no-op. | ||
| 2283 | */ | ||
| 2284 | const captureProposedPlan = Effect.fnUntraced(function* (input: { | ||
| 2285 | readonly planMarkdown: string; | ||
| 2286 | }) { | ||
| 2287 | const context = yield* Ref.get(activeTurn); | ||
| 2288 | if (context === null) return; | ||
| 2289 | const markdown = input.planMarkdown.trim(); | ||
| 2290 | if (markdown.length === 0) return; | ||
| 2291 | const previous = yield* Ref.get(lastCapturedProposedPlan); | ||
| 2292 | if ( | ||
| 2293 | previous !== null && | ||
| 2294 | previous.nativeTurnId === context.nativeTurnId && | ||
| 2295 | previous.markdown === markdown | ||
| 2296 | ) { | ||
| 2297 | return; | ||
| 2298 | } | ||
| 2299 | const nativeItemId = `${context.nativeTurnId}:proposed-plan`; | ||
| 2300 | const planId = | ||
| 2301 | previous !== null && previous.nativeTurnId === context.nativeTurnId | ||
| 2302 | ? previous.planId | ||
| 2303 | : yield* idAllocator.allocate | ||
| 2304 | .plan({ | ||
| 2305 | threadId: context.input.threadId, | ||
| 2306 | runId: context.input.runId, | ||
| 2307 | driver, | ||
| 2308 | }) | ||
| 2309 | .pipe(Effect.orDie); | ||
| 2310 | yield* Ref.set(lastCapturedProposedPlan, { | ||
| 2311 | nativeTurnId: context.nativeTurnId, | ||
| 2312 | markdown, | ||
| 2313 | planId, | ||
| 2314 | }); | ||
| 2315 | const now = yield* DateTime.now; | ||
| 2316 | const nodeId = idAllocator.derive.nodeFromProviderItem({ driver, nativeItemId }); | ||
| 2317 | const turnItemId = idAllocator.derive.turnItemFromProviderItem({ driver, nativeItemId }); | ||
| 2318 | const ordinal = yield* resolveItemOrdinal(context, nativeItemId); | ||
| 2319 | const nativeItemRef = { driver, nativeId: nativeItemId, strength: "weak" as const }; | ||
| 2320 | yield* emitProviderEvent({ | ||
| 2321 | type: "node.updated", | ||
| 2322 | driver, | ||
| 2323 | node: { | ||
| 2324 | id: nodeId, | ||
| 2325 | threadId: context.input.threadId, | ||
| 2326 | runId: context.input.runId, | ||
| 2327 | parentNodeId: context.input.rootNodeId, | ||
| 2328 | rootNodeId: context.input.rootNodeId, | ||
| 2329 | kind: "plan", | ||
| 2330 | status: "completed", | ||
| 2331 | countsForRun: false, | ||
| 2332 | providerThreadId: context.input.providerThread.id, | ||
| 2333 | providerTurnId: context.providerTurnId, | ||
| 2334 | nativeItemRef, | ||
| 2335 | runtimeRequestId: null, | ||
| 2336 | checkpointScopeId: null, | ||
| 2337 | startedAt: context.startedAt, | ||
| 2338 | completedAt: now, | ||
| 2339 | }, | ||
| 2340 | }); | ||
| 2341 | yield* emitProviderEvent({ | ||
| 2342 | type: "plan.updated", | ||
| 2343 | driver, | ||
| 2344 | plan: { | ||
| 2345 | id: planId, | ||
| 2346 | threadId: context.input.threadId, | ||
| 2347 | runId: context.input.runId, | ||
| 2348 | nodeId, | ||
| 2349 | kind: "proposed_plan", | ||
| 2350 | status: "completed", | ||
| 2351 | markdown, | ||
| 2352 | }, | ||
| 2353 | }); | ||
| 2354 | yield* emitProviderEvent({ | ||
| 2355 | type: "turn_item.updated", | ||
| 2356 | driver, | ||
| 2357 | turnItem: { | ||
| 2358 | id: turnItemId, | ||
| 2359 | threadId: context.input.threadId, | ||
| 2360 | runId: context.input.runId, | ||
| 2361 | nodeId, | ||
| 2362 | providerThreadId: context.input.providerThread.id, | ||
| 2363 | providerTurnId: context.providerTurnId, | ||
| 2364 | nativeItemRef, | ||
| 2365 | parentItemId: null, | ||
| 2366 | ordinal, | ||
| 2367 | status: "completed", | ||
| 2368 | title: null, | ||
| 2369 | startedAt: context.startedAt, | ||
| 2370 | completedAt: now, | ||
| 2371 | updatedAt: now, | ||
| 2372 | type: "proposed_plan", | ||
| 2373 | planId, | ||
| 2374 | markdown, | ||
| 2375 | streaming: false, | ||
| 2376 | }, | ||
| 2377 | }); | ||
| 2378 | }); | ||
| 2379 | const lastProposedPlanMarkdown = Effect.gen(function* () { | ||
| 2380 | const context = yield* Ref.get(activeTurn); | ||
| 2381 | const previous = yield* Ref.get(lastCapturedProposedPlan); | ||
| 2382 | return context !== null && previous?.nativeTurnId === context.nativeTurnId | ||
| 2383 | ? previous.markdown | ||
| 2384 | : undefined; | ||
| 2385 | }); | ||
| 2386 | |||
| 2387 | const rememberSnapshotMessage = (message: OrchestrationV2ConversationMessage) => | ||
| 2388 | Ref.update(snapshot, (current) => { | ||
| 2389 | const key = String(message.id); | ||
| 2390 | const exists = current.messages.has(key); | ||
| 2391 | const messages = new Map(current.messages); | ||
| 2392 | messages.set(key, message); | ||
| 2393 | return { | ||
| 2394 | ...current, | ||
| 2395 | order: exists ? current.order : [...current.order, key], | ||
| 2396 | messages, | ||
| 2397 | }; | ||
| 2398 | }); | ||
| 2399 | |||
| 2400 | const textStreamFor = ( | ||
| 2401 | context: ActiveAcpTurn, | ||
| 2402 | kind: "user" | "assistant" | "reasoning", | ||
| 2403 | ): ActiveTextStream => | ||
| 2404 | kind === "user" | ||
| 2405 | ? context.user | ||
| 2406 | : kind === "assistant" | ||
| 2407 | ? context.assistant | ||
| 2408 | : context.reasoning; | ||
| 2409 | |||
| 2410 | const emitTextSegment = Effect.fnUntraced(function* ( | ||
| 2411 | context: ActiveAcpTurn, | ||
| 2412 | kind: "user" | "assistant" | "reasoning", | ||
| 2413 | completed: boolean, | ||
| 2414 | ) { | ||
| 2415 | const stream = textStreamFor(context, kind); | ||
| 2416 | const segment = stream.current; | ||
| 2417 | if (segment === null) return; | ||
| 2418 | const now = yield* DateTime.now; | ||
| 2419 | const ordinal = yield* resolveItemOrdinal(context, segment.nativeItemId); | ||
| 2420 | const nodeId = providerNodeId(segment.nativeItemId); | ||
| 2421 | const turnItemId = providerTurnItemId(segment.nativeItemId); | ||
| 2422 | const nativeItemRef = { | ||
| 2423 | driver, | ||
| 2424 | nativeId: segment.nativeItemId, | ||
| 2425 | strength: "weak" as const, | ||
| 2426 | }; | ||
| 2427 | if (kind !== "user") { | ||
| 2428 | yield* emitProviderEvent({ | ||
| 2429 | type: "node.updated", | ||
| 2430 | driver, | ||
| 2431 | node: { | ||
| 2432 | id: nodeId, | ||
| 2433 | threadId: context.input.threadId, | ||
| 2434 | runId: context.input.runId, | ||
| 2435 | parentNodeId: context.input.rootNodeId, | ||
| 2436 | rootNodeId: context.input.rootNodeId, | ||
| 2437 | kind: kind === "assistant" ? "assistant_message" : "reasoning", | ||
| 2438 | status: completed ? "completed" : "running", | ||
| 2439 | countsForRun: false, | ||
| 2440 | providerThreadId: context.input.providerThread.id, | ||
| 2441 | providerTurnId: context.providerTurnId, | ||
| 2442 | nativeItemRef, | ||
| 2443 | runtimeRequestId: null, | ||
| 2444 | checkpointScopeId: null, | ||
| 2445 | startedAt: segment.startedAt, | ||
| 2446 | completedAt: completed ? now : null, | ||
| 2447 | }, | ||
| 2448 | }); | ||
| 2449 | } | ||
| 2450 | if (kind !== "reasoning") { | ||
| 2451 | const messageId = providerMessageId(segment.nativeItemId); | ||
| 2452 | const messageNodeId = kind === "user" ? context.input.rootNodeId : nodeId; | ||
| 2453 | const message: OrchestrationV2ConversationMessage = { | ||
| 2454 | createdBy: kind === "user" ? "user" : "agent", | ||
| 2455 | creationSource: "provider", | ||
| 2456 | id: messageId, | ||
| 2457 | threadId: context.input.threadId, | ||
| 2458 | runId: context.input.runId, | ||
| 2459 | nodeId: messageNodeId, | ||
| 2460 | role: kind, | ||
| 2461 | text: segment.text, | ||
| 2462 | attachments: [], | ||
| 2463 | streaming: !completed, | ||
| 2464 | createdAt: segment.startedAt, | ||
| 2465 | updatedAt: now, | ||
| 2466 | }; | ||
| 2467 | yield* emitProviderEvent({ type: "message.updated", driver, message }); | ||
| 2468 | yield* emitProviderEvent( | ||
| 2469 | kind === "user" | ||
| 2470 | ? { | ||
| 2471 | type: "turn_item.updated", | ||
| 2472 | driver, | ||
| 2473 | turnItem: { | ||
| 2474 | createdBy: "user", | ||
| 2475 | creationSource: "provider", | ||
| 2476 | id: turnItemId, | ||
| 2477 | threadId: context.input.threadId, | ||
| 2478 | runId: context.input.runId, | ||
| 2479 | nodeId: messageNodeId, | ||
| 2480 | providerThreadId: context.input.providerThread.id, | ||
| 2481 | providerTurnId: context.providerTurnId, | ||
| 2482 | nativeItemRef, | ||
| 2483 | parentItemId: null, | ||
| 2484 | ordinal, | ||
| 2485 | status: completed ? "completed" : "running", | ||
| 2486 | title: null, | ||
| 2487 | startedAt: segment.startedAt, | ||
| 2488 | completedAt: completed ? now : null, | ||
| 2489 | updatedAt: now, | ||
| 2490 | type: "user_message", | ||
| 2491 | messageId, | ||
| 2492 | inputIntent: "turn_start", | ||
| 2493 | text: segment.text, | ||
| 2494 | attachments: [], | ||
| 2495 | }, | ||
| 2496 | } | ||
| 2497 | : { | ||
| 2498 | type: "turn_item.updated", | ||
| 2499 | driver, | ||
| 2500 | turnItem: { | ||
| 2501 | id: turnItemId, | ||
| 2502 | threadId: context.input.threadId, | ||
| 2503 | runId: context.input.runId, | ||
| 2504 | nodeId, | ||
| 2505 | providerThreadId: context.input.providerThread.id, | ||
| 2506 | providerTurnId: context.providerTurnId, | ||
| 2507 | nativeItemRef, | ||
| 2508 | parentItemId: null, | ||
| 2509 | ordinal, | ||
| 2510 | status: completed ? "completed" : "running", | ||
| 2511 | title: null, | ||
| 2512 | startedAt: segment.startedAt, | ||
| 2513 | completedAt: completed ? now : null, | ||
| 2514 | updatedAt: now, | ||
| 2515 | type: "assistant_message", | ||
| 2516 | messageId, | ||
| 2517 | text: segment.text, | ||
| 2518 | streaming: !completed, | ||
| 2519 | }, | ||
| 2520 | }, | ||
| 2521 | ); | ||
| 2522 | if (completed) yield* rememberSnapshotMessage(message); | ||
| 2523 | return; | ||
| 2524 | } | ||
| 2525 | yield* emitProviderEvent({ | ||
| 2526 | type: "turn_item.updated", | ||
| 2527 | driver, | ||
| 2528 | turnItem: { | ||
| 2529 | id: turnItemId, | ||
| 2530 | threadId: context.input.threadId, | ||
| 2531 | runId: context.input.runId, | ||
| 2532 | nodeId, | ||
| 2533 | providerThreadId: context.input.providerThread.id, | ||
| 2534 | providerTurnId: context.providerTurnId, | ||
| 2535 | nativeItemRef, | ||
| 2536 | parentItemId: null, | ||
| 2537 | ordinal, | ||
| 2538 | status: completed ? "completed" : "running", | ||
| 2539 | title: null, | ||
| 2540 | startedAt: segment.startedAt, | ||
| 2541 | completedAt: completed ? now : null, | ||
| 2542 | updatedAt: now, | ||
| 2543 | type: "reasoning", | ||
| 2544 | text: segment.text, | ||
| 2545 | streaming: !completed, | ||
| 2546 | }, | ||
| 2547 | }); | ||
| 2548 | }); | ||
| 2549 | |||
| 2550 | const closeTextStream = Effect.fnUntraced(function* ( | ||
| 2551 | context: ActiveAcpTurn, | ||
| 2552 | kind: "user" | "assistant" | "reasoning", | ||
| 2553 | ) { | ||
| 2554 | const stream = textStreamFor(context, kind); | ||
| 2555 | if (stream.current === null) return; | ||
| 2556 | yield* emitTextSegment(context, kind, true); | ||
| 2557 | stream.current = null; | ||
| 2558 | }); | ||
| 2559 | |||
| 2560 | const closeTextStreams = Effect.fnUntraced(function* (context: ActiveAcpTurn) { | ||
| 2561 | yield* closeTextStream(context, "user"); | ||
| 2562 | yield* closeTextStream(context, "reasoning"); | ||
| 2563 | yield* closeTextStream(context, "assistant"); | ||
| 2564 | }); | ||
| 2565 | |||
| 2566 | const appendText = Effect.fnUntraced(function* ( | ||
| 2567 | context: ActiveAcpTurn, | ||
| 2568 | kind: "user" | "assistant" | "reasoning", | ||
| 2569 | text: string, | ||
| 2570 | messageId?: string | null, | ||
| 2571 | ) { | ||
| 2572 | if (text.length === 0) return; | ||
| 2573 | for (const other of ["user", "reasoning", "assistant"] as const) { | ||
| 2574 | if (other !== kind) yield* closeTextStream(context, other); | ||
| 2575 | } | ||
| 2576 | const stream = textStreamFor(context, kind); | ||
| 2577 | const sourceMessageId = messageId?.trim() || null; | ||
| 2578 | if ( | ||
| 2579 | stream.current !== null && | ||
| 2580 | sourceMessageId !== null && | ||
| 2581 | stream.current.sourceMessageId !== null && | ||
| 2582 | stream.current.sourceMessageId !== sourceMessageId | ||
| 2583 | ) { | ||
| 2584 | yield* closeTextStream(context, kind); | ||
| 2585 | } | ||
| 2586 | if (stream.current === null) { | ||
| 2587 | const now = yield* DateTime.now; | ||
| 2588 | stream.current = { | ||
| 2589 | nativeItemId: | ||
| 2590 | sourceMessageId === null | ||
| 2591 | ? `${context.nativeTurnId}:${kind}:${stream.nextSegment}` | ||
| 2592 | : `${context.nativeTurnId}:${kind}:message:${sourceMessageId}`, | ||
| 2593 | startedAt: now, | ||
| 2594 | sourceMessageId, | ||
| 2595 | text: "", | ||
| 2596 | }; | ||
| 2597 | stream.nextSegment += 1; | ||
| 2598 | } else if (stream.current.sourceMessageId === null && sourceMessageId !== null) { | ||
| 2599 | // Some agents omit messageId on the first chunk. Keep the already | ||
| 2600 | // projected identity and use the first later id as its boundary. | ||
| 2601 | stream.current.sourceMessageId = sourceMessageId; | ||
| 2602 | } | ||
| 2603 | stream.current.text += text; | ||
| 2604 | yield* emitTextSegment(context, kind, false); | ||
| 2605 | }); | ||
| 2606 | |||
| 2607 | const replaceText = Effect.fnUntraced(function* ( | ||
| 2608 | context: ActiveAcpTurn, | ||
| 2609 | kind: "user" | "assistant" | "reasoning", | ||
| 2610 | text: string, | ||
| 2611 | messageId: string, | ||
| 2612 | ) { | ||
| 2613 | for (const other of ["user", "reasoning", "assistant"] as const) { | ||
| 2614 | if (other !== kind) yield* closeTextStream(context, other); | ||
| 2615 | } | ||
| 2616 | const stream = textStreamFor(context, kind); | ||
| 2617 | if (stream.current?.sourceMessageId !== messageId) { | ||
| 2618 | yield* closeTextStream(context, kind); | ||
| 2619 | const now = yield* DateTime.now; | ||
| 2620 | stream.current = { | ||
| 2621 | nativeItemId: `${context.nativeTurnId}:${kind}:message:${messageId}`, | ||
| 2622 | startedAt: now, | ||
| 2623 | sourceMessageId: messageId, | ||
| 2624 | text, | ||
| 2625 | }; | ||
| 2626 | stream.nextSegment += 1; | ||
| 2627 | } else { | ||
| 2628 | stream.current.text = text; | ||
| 2629 | } | ||
| 2630 | yield* emitTextSegment(context, kind, false); | ||
| 2631 | }); | ||
| 2632 | |||
| 2633 | const emitSubagentAssistant = Effect.fnUntraced(function* ( | ||
| 2634 | subagent: ActiveAcpSubagent, | ||
| 2635 | text: string, | ||
| 2636 | mode: "append" | "replace" = "append", | ||
| 2637 | messageId?: string | null, | ||
| 2638 | ) { | ||
| 2639 | if (text.length === 0 && mode === "append") return; | ||
| 2640 | const nativeItemId = `${subagent.task.nativeTaskRef?.nativeId ?? subagent.task.id}:message:${messageId ?? "result"}`; | ||
| 2641 | const previous = subagent.assistantMessages.get(nativeItemId) ?? ""; | ||
| 2642 | const messageText = mode === "replace" ? text : `${previous}${text}`; | ||
| 2643 | subagent.assistantMessages.set(nativeItemId, messageText); | ||
| 2644 | subagent.assistantText = messageText; | ||
| 2645 | const now = yield* DateTime.now; | ||
| 2646 | const ordinal = resolveSubagentChildOrdinal(subagent, nativeItemId); | ||
| 2647 | const artifacts = makeSubagentConversationArtifacts({ | ||
| 2648 | messageId: providerMessageId(nativeItemId), | ||
| 2649 | turnItemId: providerTurnItemId(nativeItemId), | ||
| 2650 | threadId: subagent.childThreadId, | ||
| 2651 | rootNodeId: subagent.childRootNodeId, | ||
| 2652 | providerThreadId: subagent.task.providerThreadId, | ||
| 2653 | providerTurnId: null, | ||
| 2654 | nativeItemRef: { driver, nativeId: nativeItemId, strength: "weak" }, | ||
| 2655 | role: "assistant", | ||
| 2656 | text: subagent.assistantText, | ||
| 2657 | ordinal, | ||
| 2658 | now, | ||
| 2659 | }); | ||
| 2660 | yield* emitProviderEvent({ type: "message.updated", driver, message: artifacts.message }); | ||
| 2661 | yield* emitProviderEvent({ | ||
| 2662 | type: "turn_item.updated", | ||
| 2663 | driver, | ||
| 2664 | turnItem: artifacts.turnItem, | ||
| 2665 | }); | ||
| 2666 | }); | ||
| 2667 | |||
| 2668 | const projectSubagentNotification = Effect.fnUntraced(function* ( | ||
| 2669 | subagent: ActiveAcpSubagent, | ||
| 2670 | notification: EffectAcpSchema.SessionNotification, | ||
| 2671 | ) { | ||
| 2672 | const update = notification.update; | ||
| 2673 | if (update.sessionUpdate === "agent_message_chunk") { | ||
| 2674 | const text = acpContentBlockDisplayText(update.content); | ||
| 2675 | if (text !== undefined) { | ||
| 2676 | yield* emitSubagentAssistant(subagent, text, "append", update.messageId); | ||
| 2677 | } | ||
| 2678 | } else if (update.sessionUpdate === "agent_message") { | ||
| 2679 | if (update.content === undefined) return; | ||
| 2680 | const text = (update.content ?? []) | ||
| 2681 | .flatMap((content) => { | ||
| 2682 | const display = acpContentBlockDisplayText(content); | ||
| 2683 | return display === undefined ? [] : [display]; | ||
| 2684 | }) | ||
| 2685 | .join("\n"); | ||
| 2686 | yield* emitSubagentAssistant(subagent, text, "replace", update.messageId); | ||
| 2687 | } | ||
| 2688 | }); | ||
| 2689 | |||
| 2690 | const emitSubagent = Effect.fnUntraced(function* ( | ||
| 2691 | context: ActiveAcpTurn, | ||
| 2692 | update: AcpAdapterV2SubagentUpdate, | ||
| 2693 | ) { | ||
| 2694 | // Hydration tools (get_command_or_subagent_output) use a new toolCallId | ||
| 2695 | // but reference the child via subagent_id / task id. | ||
| 2696 | const existing = | ||
| 2697 | context.subagents.get(update.nativeTaskId) ?? | ||
| 2698 | (update.childSessionId !== null | ||
| 2699 | ? context.subagentsBySessionId.get(update.childSessionId) | ||
| 2700 | : undefined); | ||
| 2701 | const updateIsTerminal = acpSubagentStatusIsTerminal(update.status); | ||
| 2702 | if ( | ||
| 2703 | existing !== undefined && | ||
| 2704 | acpSubagentStatusIsTerminal(existing.task.status) && | ||
| 2705 | !updateIsTerminal | ||
| 2706 | ) { | ||
| 2707 | return; | ||
| 2708 | } | ||
| 2709 | if ( | ||
| 2710 | existing !== undefined && | ||
| 2711 | existing.task.status === update.status && | ||
| 2712 | updateIsTerminal && | ||
| 2713 | existing.terminalStatusProjected | ||
| 2714 | ) { | ||
| 2715 | return; | ||
| 2716 | } | ||
| 2717 | // get_command_or_subagent_output may target monitors/bash tasks. Only | ||
| 2718 | // hydrate when we already have a matching subagent lineage. Spawn ACKs | ||
| 2719 | // (non-empty prompt) may create a new lineage; empty-prompt hydration | ||
| 2720 | // with suppressNormalTool must not invent a phantom subagent. | ||
| 2721 | if (existing === undefined) { | ||
| 2722 | const isHydrationOnly = update.prompt === "" && update.title === null; | ||
| 2723 | if (isHydrationOnly || update.suppressNormalTool === false) { | ||
| 2724 | return; | ||
| 2725 | } | ||
| 2726 | } | ||
| 2727 | const now = yield* DateTime.now; | ||
| 2728 | const nativeTaskId = existing?.task.nativeTaskRef?.nativeId ?? update.nativeTaskId; | ||
| 2729 | const nativeItemRef = { | ||
| 2730 | driver, | ||
| 2731 | nativeId: nativeTaskId, | ||
| 2732 | strength: "strong" as const, | ||
| 2733 | }; | ||
| 2734 | const nodeId = existing?.task.id ?? providerNodeId(nativeTaskId); | ||
| 2735 | const childThreadId = | ||
| 2736 | existing?.childThreadId ?? | ||
| 2737 | idAllocator.derive.threadFromProviderThread({ | ||
| 2738 | driver, | ||
| 2739 | providerInstanceId: context.input.modelSelection.instanceId, | ||
| 2740 | nativeThreadId: `${context.nativeThreadId}:task:${nativeTaskId}`, | ||
| 2741 | }); | ||
| 2742 | const childRootNodeId = | ||
| 2743 | existing?.childRootNodeId ?? providerNodeId(`${nativeTaskId}:child-root`); | ||
| 2744 | const turnItemId = existing?.turnItemId ?? providerTurnItemId(nativeTaskId); | ||
| 2745 | const turnItemOrdinal = | ||
| 2746 | existing?.turnItemOrdinal ?? (yield* resolveItemOrdinal(context, nativeTaskId)); | ||
| 2747 | const taskStatus = update.status; | ||
| 2748 | const task: OrchestrationV2Subagent = { | ||
| 2749 | ...(existing?.task ?? { | ||
| 2750 | id: nodeId, | ||
| 2751 | threadId: context.input.threadId, | ||
| 2752 | runId: context.input.runId, | ||
| 2753 | parentNodeId: context.input.rootNodeId, | ||
| 2754 | origin: "provider_native" as const, | ||
| 2755 | createdBy: "agent" as const, | ||
| 2756 | driver, | ||
| 2757 | providerInstanceId: context.input.modelSelection.instanceId, | ||
| 2758 | providerThreadId: null, | ||
| 2759 | childThreadId, | ||
| 2760 | nativeTaskRef: nativeItemRef, | ||
| 2761 | prompt: update.prompt, | ||
| 2762 | title: update.title, | ||
| 2763 | result: null, | ||
| 2764 | startedAt: now, | ||
| 2765 | }), | ||
| 2766 | model: update.model?.trim() || existing?.task.model || null, | ||
| 2767 | status: taskStatus, | ||
| 2768 | result: update.result ?? existing?.task.result ?? null, | ||
| 2769 | completedAt: acpSubagentStatusIsTerminal(taskStatus) ? now : null, | ||
| 2770 | updatedAt: now, | ||
| 2771 | }; | ||
| 2772 | const subagent: ActiveAcpSubagent = existing ?? { | ||
| 2773 | task, | ||
| 2774 | childThreadId, | ||
| 2775 | childRootNodeId, | ||
| 2776 | turnItemId, | ||
| 2777 | turnItemOrdinal, | ||
| 2778 | providerTurnId: context.providerTurnId, | ||
| 2779 | parentProviderThreadId: context.input.providerThread.id, | ||
| 2780 | childSessionId: null, | ||
| 2781 | assistantText: "", | ||
| 2782 | assistantMessages: new Map(), | ||
| 2783 | childItemOrdinals: new Map(), | ||
| 2784 | nextChildOrdinal: 101, | ||
| 2785 | terminalStatusProjected: false, | ||
| 2786 | }; | ||
| 2787 | subagent.task = task; | ||
| 2788 | context.subagents.set(nativeTaskId, subagent); | ||
| 2789 | |||
| 2790 | if (existing === undefined) { | ||
| 2791 | yield* emitProviderEvent({ | ||
| 2792 | type: "app_thread.created", | ||
| 2793 | driver, | ||
| 2794 | appThread: makeSubagentChildThread({ | ||
| 2795 | parentThread: context.input.appThread, | ||
| 2796 | childThreadId, | ||
| 2797 | parentNodeId: nodeId, | ||
| 2798 | activeProviderThreadId: null, | ||
| 2799 | providerInstanceId: context.input.modelSelection.instanceId, | ||
| 2800 | modelSelection: { | ||
| 2801 | ...context.input.modelSelection, | ||
| 2802 | model: task.model ?? context.input.modelSelection.model, | ||
| 2803 | }, | ||
| 2804 | title: subagentThreadTitle({ | ||
| 2805 | parentTitle: context.input.appThread.title, | ||
| 2806 | title: update.title, | ||
| 2807 | prompt: update.prompt, | ||
| 2808 | ordinal: context.subagents.size, | ||
| 2809 | }), | ||
| 2810 | now, | ||
| 2811 | createdBy: "agent", | ||
| 2812 | creationSource: "provider", | ||
| 2813 | }), | ||
| 2814 | }); | ||
| 2815 | const promptNativeItemId = `${nativeTaskId}:prompt`; | ||
| 2816 | const promptArtifacts = makeSubagentConversationArtifacts({ | ||
| 2817 | senderThreadId: context.input.threadId, | ||
| 2818 | messageId: providerMessageId(promptNativeItemId), | ||
| 2819 | turnItemId: providerTurnItemId(promptNativeItemId), | ||
| 2820 | threadId: childThreadId, | ||
| 2821 | rootNodeId: childRootNodeId, | ||
| 2822 | providerThreadId: null, | ||
| 2823 | providerTurnId: null, | ||
| 2824 | nativeItemRef: { driver, nativeId: promptNativeItemId, strength: "weak" }, | ||
| 2825 | role: "user", | ||
| 2826 | text: update.prompt, | ||
| 2827 | ordinal: 100, | ||
| 2828 | now, | ||
| 2829 | }); | ||
| 2830 | yield* emitProviderEvent({ | ||
| 2831 | type: "message.updated", | ||
| 2832 | driver, | ||
| 2833 | message: promptArtifacts.message, | ||
| 2834 | }); | ||
| 2835 | yield* emitProviderEvent({ | ||
| 2836 | type: "turn_item.updated", | ||
| 2837 | driver, | ||
| 2838 | turnItem: promptArtifacts.turnItem, | ||
| 2839 | }); | ||
| 2840 | } | ||
| 2841 | |||
| 2842 | const childSessionId = update.childSessionId; | ||
| 2843 | if (childSessionId !== null && subagent.childSessionId === null) { | ||
| 2844 | subagent.childSessionId = childSessionId; | ||
| 2845 | context.subagentsBySessionId.set(childSessionId, subagent); | ||
| 2846 | const providerThread = makeProviderThread({ | ||
| 2847 | driver, | ||
| 2848 | providerInstanceId: context.input.modelSelection.instanceId, | ||
| 2849 | idAllocator, | ||
| 2850 | appThreadId: childThreadId, | ||
| 2851 | providerSessionId: input.providerSessionId, | ||
| 2852 | nativeThreadId: childSessionId, | ||
| 2853 | ...(itemIdentityVersion === undefined ? {} : { itemIdentityVersion }), | ||
| 2854 | forkedFrom: { | ||
| 2855 | providerThreadId: context.input.providerThread.id, | ||
| 2856 | providerTurnId: context.providerTurnId, | ||
| 2857 | }, | ||
| 2858 | now, | ||
| 2859 | }); | ||
| 2860 | subagent.task = { ...subagent.task, providerThreadId: providerThread.id }; | ||
| 2861 | yield* Ref.update(providerThreadByNativeSessionId, (current) => | ||
| 2862 | new Map(current).set(childSessionId, providerThread), | ||
| 2863 | ); | ||
| 2864 | yield* emitProviderEvent({ | ||
| 2865 | type: "provider_thread.updated", | ||
| 2866 | driver, | ||
| 2867 | providerThread: { ...providerThread, status: "idle" }, | ||
| 2868 | }); | ||
| 2869 | const buffered = context.pendingSubagentNotifications.get(childSessionId) ?? []; | ||
| 2870 | context.pendingSubagentNotifications.delete(childSessionId); | ||
| 2871 | yield* Effect.forEach( | ||
| 2872 | buffered, | ||
| 2873 | (notification) => projectSubagentNotification(subagent, notification), | ||
| 2874 | { concurrency: 1, discard: true }, | ||
| 2875 | ); | ||
| 2876 | } | ||
| 2877 | |||
| 2878 | if ( | ||
| 2879 | taskStatus !== "running" && | ||
| 2880 | subagent.assistantText.length === 0 && | ||
| 2881 | update.result !== null | ||
| 2882 | ) { | ||
| 2883 | yield* emitSubagentAssistant(subagent, update.result); | ||
| 2884 | } | ||
| 2885 | const result = update.result ?? subagent.task.result ?? (subagent.assistantText || null); | ||
| 2886 | subagent.task = { | ||
| 2887 | ...subagent.task, | ||
| 2888 | status: taskStatus, | ||
| 2889 | result, | ||
| 2890 | completedAt: acpSubagentStatusIsTerminal(taskStatus) ? now : null, | ||
| 2891 | updatedAt: now, | ||
| 2892 | }; | ||
| 2893 | const providerThreadId = subagent.task.providerThreadId; | ||
| 2894 | yield* emitProviderEvent({ | ||
| 2895 | type: "node.updated", | ||
| 2896 | driver, | ||
| 2897 | node: { | ||
| 2898 | id: nodeId, | ||
| 2899 | threadId: context.input.threadId, | ||
| 2900 | runId: subagent.task.runId, | ||
| 2901 | parentNodeId: subagent.task.parentNodeId, | ||
| 2902 | rootNodeId: subagent.task.parentNodeId, | ||
| 2903 | kind: "subagent", | ||
| 2904 | status: taskStatus, | ||
| 2905 | countsForRun: false, | ||
| 2906 | providerThreadId: context.input.providerThread.id, | ||
| 2907 | providerTurnId: subagent.providerTurnId, | ||
| 2908 | nativeItemRef, | ||
| 2909 | runtimeRequestId: null, | ||
| 2910 | checkpointScopeId: null, | ||
| 2911 | startedAt: subagent.task.startedAt, | ||
| 2912 | completedAt: subagent.task.completedAt, | ||
| 2913 | }, | ||
| 2914 | }); | ||
| 2915 | yield* emitProviderEvent({ | ||
| 2916 | type: "node.updated", | ||
| 2917 | driver, | ||
| 2918 | node: { | ||
| 2919 | id: childRootNodeId, | ||
| 2920 | threadId: childThreadId, | ||
| 2921 | runId: null, | ||
| 2922 | parentNodeId: null, | ||
| 2923 | rootNodeId: childRootNodeId, | ||
| 2924 | kind: "root_turn", | ||
| 2925 | status: taskStatus, | ||
| 2926 | countsForRun: false, | ||
| 2927 | providerThreadId, | ||
| 2928 | providerTurnId: null, | ||
| 2929 | nativeItemRef, | ||
| 2930 | runtimeRequestId: null, | ||
| 2931 | checkpointScopeId: null, | ||
| 2932 | startedAt: subagent.task.startedAt, | ||
| 2933 | completedAt: subagent.task.completedAt, | ||
| 2934 | }, | ||
| 2935 | }); | ||
| 2936 | yield* emitProviderEvent({ type: "subagent.updated", driver, subagent: subagent.task }); | ||
| 2937 | yield* emitProviderEvent({ | ||
| 2938 | type: "turn_item.updated", | ||
| 2939 | driver, | ||
| 2940 | turnItem: { | ||
| 2941 | id: turnItemId, | ||
| 2942 | threadId: context.input.threadId, | ||
| 2943 | runId: subagent.task.runId, | ||
| 2944 | nodeId, | ||
| 2945 | providerThreadId: context.input.providerThread.id, | ||
| 2946 | providerTurnId: subagent.providerTurnId, | ||
| 2947 | nativeItemRef, | ||
| 2948 | parentItemId: null, | ||
| 2949 | ordinal: turnItemOrdinal, | ||
| 2950 | status: taskStatus, | ||
| 2951 | title: subagent.task.title, | ||
| 2952 | startedAt: subagent.task.startedAt, | ||
| 2953 | completedAt: subagent.task.completedAt, | ||
| 2954 | updatedAt: now, | ||
| 2955 | type: "subagent", | ||
| 2956 | subagentId: subagent.task.id, | ||
| 2957 | origin: "provider_native", | ||
| 2958 | driver, | ||
| 2959 | providerInstanceId: context.input.modelSelection.instanceId, | ||
| 2960 | childThreadId, | ||
| 2961 | prompt: subagent.task.prompt, | ||
| 2962 | result, | ||
| 2963 | }, | ||
| 2964 | }); | ||
| 2965 | if (acpSubagentStatusIsTerminal(taskStatus)) { | ||
| 2966 | subagent.terminalStatusProjected = true; | ||
| 2967 | } | ||
| 2968 | }); | ||
| 2969 | |||
| 2970 | const toolOutputText = (toolCall: AcpToolCallState): string => { | ||
| 2971 | if (typeof toolCall.data.rawOutput === "string") { | ||
| 2972 | return toolCall.data.rawOutput; | ||
| 2973 | } | ||
| 2974 | if ( | ||
| 2975 | typeof (toolCall.data.rawOutput as { text?: unknown } | undefined)?.text === "string" | ||
| 2976 | ) { | ||
| 2977 | return String((toolCall.data.rawOutput as { text: string }).text); | ||
| 2978 | } | ||
| 2979 | return textFromUnknown(toolCall.data.rawOutput) ?? ""; | ||
| 2980 | }; | ||
| 2981 | |||
| 2982 | const setToolOutputText = (toolCall: AcpToolCallState, text: string): AcpToolCallState => ({ | ||
| 2983 | ...toolCall, | ||
| 2984 | data: { | ||
| 2985 | ...toolCall.data, | ||
| 2986 | rawOutput: { | ||
| 2987 | type: "Text", | ||
| 2988 | text, | ||
| 2989 | }, | ||
| 2990 | }, | ||
| 2991 | }); | ||
| 2992 | |||
| 2993 | const appendToolOutputText = ( | ||
| 2994 | toolCall: AcpToolCallState, | ||
| 2995 | appendOutput: string, | ||
| 2996 | ): AcpToolCallState => { | ||
| 2997 | if (appendOutput.length === 0) return toolCall; | ||
| 2998 | return setToolOutputText(toolCall, `${toolOutputText(toolCall)}${appendOutput}`); | ||
| 2999 | }; | ||
| 3000 | |||
| 3001 | const isMonitorEndNoticeText = (text: string): boolean => | ||
| 3002 | /Monitor\s+["']?[0-9a-f-]{8,}/i.test(text) && /ended/i.test(text); | ||
| 3003 | |||
| 3004 | const hasDeferredBackgroundWork = (context: ActiveAcpTurn): boolean => { | ||
| 3005 | if (context.awaitingBackgroundHydration.size > 0) return true; | ||
| 3006 | if (context.pendingInjectedReport.size > 0) return true; | ||
| 3007 | for (const [taskId, toolCallId] of context.toolCallIdsByBackgroundTaskId) { | ||
| 3008 | // Persistent monitors stream after root settle; do not pin finalize. | ||
| 3009 | if (context.persistentBackgroundTaskIds.has(taskId)) continue; | ||
| 3010 | const tool = context.tools.get(toolCallId); | ||
| 3011 | if (tool === undefined) continue; | ||
| 3012 | const status = toolStatus(tool.status); | ||
| 3013 | if (status === "pending" || status === "running") return true; | ||
| 3014 | } | ||
| 3015 | for (const subagent of context.subagents.values()) { | ||
| 3016 | if (acpSubagentStatusBlocksTurnSettlement(subagent.task.status)) { | ||
| 3017 | return true; | ||
| 3018 | } | ||
| 3019 | } | ||
| 3020 | return false; | ||
| 3021 | }; | ||
| 3022 | |||
| 3023 | // scheduleDeferredFinalize is declared above openSession body start and | ||
| 3024 | // assigned after finalizeTurn so forked finalize Effect typing stays clean. | ||
| 3025 | const rearmDeferredFinalize = (context: ActiveAcpTurn) => | ||
| 3026 | Effect.gen(function* () { | ||
| 3027 | if (!flavor.deferFinalizeForBackgroundWork) return; | ||
| 3028 | if (!context.promptSettled || context.finalized) return; | ||
| 3029 | if (hasDeferredBackgroundWork(context)) return; | ||
| 3030 | yield* scheduleDeferredFinalize(context); | ||
| 3031 | }); | ||
| 3032 | |||
| 3033 | // `let` breaks circular inference from monitor hydration re-entry. | ||
| 3034 | let emitTool: ( | ||
| 3035 | context: ActiveAcpTurn, | ||
| 3036 | incoming: AcpToolCallState, | ||
| 3037 | projectedStatus?: ProjectedToolStatus, | ||
| 3038 | ) => Effect.Effect<void> = () => Effect.void; | ||
| 3039 | |||
| 3040 | const markAwaitingBackgroundHydration = (context: ActiveAcpTurn, taskId: string) => | ||
| 3041 | Effect.gen(function* () { | ||
| 3042 | if (context.awaitingBackgroundHydration.has(taskId)) return; | ||
| 3043 | context.awaitingBackgroundHydration.add(taskId); | ||
| 3044 | // Safety: do not hold the root turn forever if the agent never hydrates. | ||
| 3045 | yield* Effect.gen(function* () { | ||
| 3046 | yield* Effect.sleep("60000 millis"); | ||
| 3047 | if (context.finalized || !context.awaitingBackgroundHydration.has(taskId)) return; | ||
| 3048 | context.awaitingBackgroundHydration.delete(taskId); | ||
| 3049 | // End notices keep the tool running until hydration; force-complete so | ||
| 3050 | // deferred finalize can proceed when get_command never arrives. | ||
| 3051 | const toolCallId = context.toolCallIdsByBackgroundTaskId.get(taskId); | ||
| 3052 | const tool = toolCallId !== undefined ? context.tools.get(toolCallId) : undefined; | ||
| 3053 | if (tool !== undefined) { | ||
| 3054 | const status = toolStatus(tool.status); | ||
| 3055 | if (status === "pending" || status === "running") { | ||
| 3056 | yield* emitTool(context, { ...tool, status: "completed" }); | ||
| 3057 | } | ||
| 3058 | } | ||
| 3059 | yield* rearmDeferredFinalize(context); | ||
| 3060 | }).pipe(Effect.forkIn(sessionScope), Effect.asVoid); | ||
| 3061 | }); | ||
| 3062 | |||
| 3063 | const markPendingInjectedReport = (context: ActiveAcpTurn, taskId: string) => | ||
| 3064 | Effect.gen(function* () { | ||
| 3065 | // Only the settled-and-held window has the race; mid-turn reports | ||
| 3066 | // are protected by the prompt RPC still being open. | ||
| 3067 | if (!context.promptSettled) return; | ||
| 3068 | if (context.pendingInjectedReport.has(taskId)) return; | ||
| 3069 | context.pendingInjectedReport.add(taskId); | ||
| 3070 | // Safety: the injected turn may end without a report chunk. | ||
| 3071 | yield* Effect.gen(function* () { | ||
| 3072 | yield* Effect.sleep("25000 millis"); | ||
| 3073 | if (context.finalized || !context.pendingInjectedReport.has(taskId)) return; | ||
| 3074 | context.pendingInjectedReport.delete(taskId); | ||
| 3075 | yield* rearmDeferredFinalize(context); | ||
| 3076 | }).pipe(Effect.forkIn(sessionScope), Effect.asVoid); | ||
| 3077 | }); | ||
| 3078 | |||
| 3079 | const clearMidTurnUnreportedTaskIds = (taskIds: ReadonlyArray<string>) => | ||
| 3080 | Ref.update(midTurnUnreportedCompletedTaskIds, (current) => { | ||
| 3081 | let next: Set<string> | null = null; | ||
| 3082 | for (const taskId of taskIds) { | ||
| 3083 | if (!current.has(taskId)) continue; | ||
| 3084 | if (next === null) next = new Set(current); | ||
| 3085 | next.delete(taskId); | ||
| 3086 | } | ||
| 3087 | return next ?? current; | ||
| 3088 | }); | ||
| 3089 | |||
| 3090 | emitTool = Effect.fnUntraced(function* ( | ||
| 3091 | context: ActiveAcpTurn, | ||
| 3092 | incoming: AcpToolCallState, | ||
| 3093 | projectedStatus?: ProjectedToolStatus, | ||
| 3094 | ) { | ||
| 3095 | // An identified message can stream concurrently with tool updates. | ||
| 3096 | // Keep its identity until the provider starts another message. | ||
| 3097 | if (context.assistant.current?.sourceMessageId == null) | ||
| 3098 | yield* closeTextStream(context, "assistant"); | ||
| 3099 | if (context.reasoning.current?.sourceMessageId == null) | ||
| 3100 | yield* closeTextStream(context, "reasoning"); | ||
| 3101 | yield* closeTextStream(context, "user"); | ||
| 3102 | const previous = context.tools.get(incoming.toolCallId); | ||
| 3103 | const merged = mergeToolCallState(previous, incoming); | ||
| 3104 | const toolCall = flavor.normalizeToolCall?.(merged) ?? merged; | ||
| 3105 | context.tools.set(toolCall.toolCallId, toolCall); | ||
| 3106 | const backgroundTaskId = flavor.extractBackgroundTaskId?.(toolCall); | ||
| 3107 | if (backgroundTaskId !== undefined) { | ||
| 3108 | context.toolCallIdsByBackgroundTaskId.set(backgroundTaskId, toolCall.toolCallId); | ||
| 3109 | if (flavor.isPersistentBackgroundTool?.(toolCall) === true) { | ||
| 3110 | context.persistentBackgroundTaskIds.add(backgroundTaskId); | ||
| 3111 | } | ||
| 3112 | const backgroundStatus = projectedStatus ?? toolStatus(toolCall.status); | ||
| 3113 | yield* setBackgroundTaskRunning( | ||
| 3114 | backgroundTaskId, | ||
| 3115 | backgroundStatus === "pending" || backgroundStatus === "running", | ||
| 3116 | ); | ||
| 3117 | // Background tool that reaches a terminal status while the root | ||
| 3118 | // prompt is still open (completed, failed, or interrupted) was | ||
| 3119 | // consumed in-turn. Mark handled so late CLI re-reports and residual | ||
| 3120 | // agent chatter do not open synthetic "Background task completed." | ||
| 3121 | // runs. get_command TaskOutput still marks handled below. | ||
| 3122 | // Only before promptSettled: after STARTED the deferred-finalize | ||
| 3123 | // hold can let a monitor finish while the turn is still active; | ||
| 3124 | // marking then would suppress the legitimate post-settle TaskOutput | ||
| 3125 | // continuation (live: grok-post-settle-continuation-poll). | ||
| 3126 | // Terminal statuses only after normalizeToolCall (start ACKs stay | ||
| 3127 | // inProgress/running). | ||
| 3128 | // | ||
| 3129 | // Tradeoff: a pre-settle false "completed" normalization (Bash- | ||
| 3130 | // shaped re-report with exit_code 0, or the hydration safety | ||
| 3131 | // force-complete firing pre-settle) would permanently mark the task | ||
| 3132 | // handled and suppress its injected report chatter. The deleted | ||
| 3133 | // handled-id removal on late monitor-event mutations used to rescue | ||
| 3134 | // that case; likelihood is low because mid-turn monitor ends | ||
| 3135 | // normally arrive as reminder mutations that force inProgress, and | ||
| 3136 | // re-reports are documented post-settle traffic. | ||
| 3137 | if ( | ||
| 3138 | !context.promptSettled && | ||
| 3139 | backgroundStatus !== "pending" && | ||
| 3140 | backgroundStatus !== "running" | ||
| 3141 | ) { | ||
| 3142 | yield* Ref.update(handledBackgroundTaskIdsInActiveTurn, (current) => | ||
| 3143 | new Set(current).add(backgroundTaskId), | ||
| 3144 | ); | ||
| 3145 | yield* Ref.update(midTurnUnreportedCompletedTaskIds, (current) => { | ||
| 3146 | if (!current.has(backgroundTaskId)) return current; | ||
| 3147 | const next = new Set(current); | ||
| 3148 | next.delete(backgroundTaskId); | ||
| 3149 | return next; | ||
| 3150 | }); | ||
| 3151 | } | ||
| 3152 | } | ||
| 3153 | |||
| 3154 | // get_command TaskOutput for registered monitor(s): hydrate those tools. | ||
| 3155 | const backgroundCompletions = | ||
| 3156 | projectedStatus === undefined | ||
| 3157 | ? (flavor.extractBackgroundTaskCompletion?.(toolCall) ?? []) | ||
| 3158 | : []; | ||
| 3159 | let hydratedRegisteredMonitor = false; | ||
| 3160 | for (const backgroundCompletion of backgroundCompletions) { | ||
| 3161 | // Genuine end signal when terminal: tombstone so straggler | ||
| 3162 | // monitor-event chatter cannot resurrect the running set after the | ||
| 3163 | // task truly ended. A still-running poll keeps the id running. | ||
| 3164 | yield* applyBackgroundTaskMutationRunning(backgroundCompletion); | ||
| 3165 | if (backgroundCompletion.status !== "running") { | ||
| 3166 | yield* Ref.update(handledBackgroundTaskIdsInActiveTurn, (current) => | ||
| 3167 | new Set(current).add(backgroundCompletion.taskId), | ||
| 3168 | ); | ||
| 3169 | yield* Ref.update(midTurnUnreportedCompletedTaskIds, (current) => { | ||
| 3170 | if (!current.has(backgroundCompletion.taskId)) return current; | ||
| 3171 | const next = new Set(current); | ||
| 3172 | next.delete(backgroundCompletion.taskId); | ||
| 3173 | return next; | ||
| 3174 | }); | ||
| 3175 | } | ||
| 3176 | // A still-running fetch must keep the hydration hold (and its | ||
| 3177 | // safety timer) alive until output actually lands. | ||
| 3178 | if (backgroundCompletion.status !== "running") { | ||
| 3179 | context.awaitingBackgroundHydration.delete(backgroundCompletion.taskId); | ||
| 3180 | } | ||
| 3181 | const targetToolCallId = context.toolCallIdsByBackgroundTaskId.get( | ||
| 3182 | backgroundCompletion.taskId, | ||
| 3183 | ); | ||
| 3184 | // Known background-task id (monitor registration) — never open a | ||
| 3185 | // phantom subagent for the same get_output poll. | ||
| 3186 | if (targetToolCallId !== undefined) { | ||
| 3187 | hydratedRegisteredMonitor = true; | ||
| 3188 | } | ||
| 3189 | const target = | ||
| 3190 | targetToolCallId !== undefined ? context.tools.get(targetToolCallId) : undefined; | ||
| 3191 | if (target !== undefined && target.toolCallId !== toolCall.toolCallId) { | ||
| 3192 | const nextStatus = | ||
| 3193 | backgroundCompletion.status === "running" | ||
| 3194 | ? ("inProgress" as const) | ||
| 3195 | : backgroundCompletion.status === "failed" | ||
| 3196 | ? ("failed" as const) | ||
| 3197 | : ("completed" as const); | ||
| 3198 | const hydrated = | ||
| 3199 | backgroundCompletion.appendOutput.length > 0 | ||
| 3200 | ? // TaskOutput is the real stdout; replace end-notice boilerplate | ||
| 3201 | // so the timeline shows the listing, not only "Monitor ended…". | ||
| 3202 | isMonitorEndNoticeText(toolOutputText(target)) || | ||
| 3203 | toolOutputText(target).trim().length === 0 | ||
| 3204 | ? setToolOutputText( | ||
| 3205 | { ...target, status: nextStatus }, | ||
| 3206 | backgroundCompletion.appendOutput, | ||
| 3207 | ) | ||
| 3208 | : appendToolOutputText( | ||
| 3209 | { ...target, status: nextStatus }, | ||
| 3210 | backgroundCompletion.appendOutput, | ||
| 3211 | ) | ||
| 3212 | : { ...target, status: nextStatus }; | ||
| 3213 | yield* emitTool(context, hydrated); | ||
| 3214 | } | ||
| 3215 | } | ||
| 3216 | |||
| 3217 | // Monitor TaskOutput shares the get_command tool shape with subagent | ||
| 3218 | // hydration; do not spawn a phantom subagent for a registered monitor. | ||
| 3219 | const subagentUpdate = hydratedRegisteredMonitor | ||
| 3220 | ? undefined | ||
| 3221 | : flavor.extractSubagentUpdate?.(toolCall); | ||
| 3222 | if (subagentUpdate !== undefined) { | ||
| 3223 | yield* emitSubagent(context, subagentUpdate); | ||
| 3224 | if (subagentUpdate.suppressNormalTool !== false) { | ||
| 3225 | yield* rearmDeferredFinalize(context); | ||
| 3226 | return; | ||
| 3227 | } | ||
| 3228 | } | ||
| 3229 | if ( | ||
| 3230 | projectedStatus === undefined && | ||
| 3231 | !shouldPersistToolUpdate( | ||
| 3232 | context, | ||
| 3233 | toolCall.toolCallId, | ||
| 3234 | previous, | ||
| 3235 | toolCall, | ||
| 3236 | merged.status, | ||
| 3237 | ) | ||
| 3238 | ) { | ||
| 3239 | yield* rearmDeferredFinalize(context); | ||
| 3240 | return; | ||
| 3241 | } | ||
| 3242 | const status = projectedStatus ?? toolStatus(toolCall.status); | ||
| 3243 | const now = yield* DateTime.now; | ||
| 3244 | const nativeItemId = `${context.nativeThreadId}:tool:${toolCall.toolCallId}`; | ||
| 3245 | const ordinal = yield* resolveItemOrdinal(context, nativeItemId); | ||
| 3246 | const nodeId = providerNodeId(nativeItemId); | ||
| 3247 | const turnItemId = providerTurnItemId(nativeItemId); | ||
| 3248 | const nativeItemRef = { | ||
| 3249 | driver, | ||
| 3250 | nativeId: toolCall.toolCallId, | ||
| 3251 | strength: "strong" as const, | ||
| 3252 | }; | ||
| 3253 | const startedAt = context.toolStartedAt.get(toolCall.toolCallId) ?? now; | ||
| 3254 | context.toolStartedAt.set(toolCall.toolCallId, startedAt); | ||
| 3255 | const completedAt = completedAtForStatus(status, now); | ||
| 3256 | const title = toolCall.title ?? null; | ||
| 3257 | yield* emitProviderEvent({ | ||
| 3258 | type: "node.updated", | ||
| 3259 | driver, | ||
| 3260 | node: { | ||
| 3261 | id: nodeId, | ||
| 3262 | threadId: context.input.threadId, | ||
| 3263 | runId: context.input.runId, | ||
| 3264 | parentNodeId: context.input.rootNodeId, | ||
| 3265 | rootNodeId: context.input.rootNodeId, | ||
| 3266 | kind: "tool_call", | ||
| 3267 | status: nodeStatus(status), | ||
| 3268 | countsForRun: true, | ||
| 3269 | providerThreadId: context.input.providerThread.id, | ||
| 3270 | providerTurnId: context.providerTurnId, | ||
| 3271 | nativeItemRef, | ||
| 3272 | runtimeRequestId: null, | ||
| 3273 | checkpointScopeId: null, | ||
| 3274 | startedAt, | ||
| 3275 | completedAt, | ||
| 3276 | }, | ||
| 3277 | }); | ||
| 3278 | |||
| 3279 | const base = { | ||
| 3280 | id: turnItemId, | ||
| 3281 | threadId: context.input.threadId, | ||
| 3282 | runId: context.input.runId, | ||
| 3283 | nodeId, | ||
| 3284 | providerThreadId: context.input.providerThread.id, | ||
| 3285 | providerTurnId: context.providerTurnId, | ||
| 3286 | nativeItemRef, | ||
| 3287 | parentItemId: null, | ||
| 3288 | ordinal, | ||
| 3289 | status, | ||
| 3290 | title, | ||
| 3291 | startedAt, | ||
| 3292 | completedAt, | ||
| 3293 | updatedAt: now, | ||
| 3294 | } as const; | ||
| 3295 | const rawInput = toolCall.data.rawInput; | ||
| 3296 | const rawOutput = toolCall.data.rawOutput ?? toolCall.data.content; | ||
| 3297 | const changes = structuredFileChanges(toolCall); | ||
| 3298 | const path = changes[0]?.path ?? pathFromToolCall(toolCall); | ||
| 3299 | const diffText = | ||
| 3300 | acpToolCallDiffPatch(toolCall.data.content) ?? textFromUnknown(rawOutput); | ||
| 3301 | const rawInputRecord = unknownRecord(rawInput); | ||
| 3302 | const inputVariant = | ||
| 3303 | typeof rawInputRecord?.variant === "string" | ||
| 3304 | ? rawInputRecord.variant.trim().toLowerCase() | ||
| 3305 | : ""; | ||
| 3306 | const rawOutputRecord = unknownRecord(rawOutput); | ||
| 3307 | const outputCommand = | ||
| 3308 | typeof rawOutputRecord?.command === "string" && | ||
| 3309 | rawOutputRecord.command.trim().length > 0 | ||
| 3310 | ? rawOutputRecord.command.trim() | ||
| 3311 | : undefined; | ||
| 3312 | const monitorCommand = | ||
| 3313 | (typeof rawInputRecord?.command === "string" && rawInputRecord.command.trim().length > 0 | ||
| 3314 | ? rawInputRecord.command.trim() | ||
| 3315 | : undefined) ?? outputCommand; | ||
| 3316 | const outputIsBashResult = | ||
| 3317 | typeof rawOutputRecord?.type === "string" && | ||
| 3318 | rawOutputRecord.type.trim().toLowerCase() === "bash" && | ||
| 3319 | commandExitCode(rawOutput) !== undefined; | ||
| 3320 | // Grok Monitor tools arrive as generic kind + variant; project like shell | ||
| 3321 | // so stdout is plain text in the timeline (not JSON {type:Text,text:...}). | ||
| 3322 | // Post-settle wake re-reports of a finished monitor carry no rawInput at | ||
| 3323 | // all, only a structured Bash result; project those as commands too. | ||
| 3324 | const projectAsCommandExecution = inputVariant === "monitor" || outputIsBashResult; | ||
| 3325 | // ACP has no typed MCP item, so recover MCP identity from the | ||
| 3326 | // agent-specific shape and project the same branded dynamic_tool | ||
| 3327 | // item native providers produce (e.g. the T3 orchestration tools). | ||
| 3328 | const mcpIdentity = extractMcpToolCallIdentity(toolCall, { | ||
| 3329 | embeddedTerminalCommands: embeddedTerminalCommands( | ||
| 3330 | context.nativeThreadId, | ||
| 3331 | toolCall.toolCallId, | ||
| 3332 | ), | ||
| 3333 | }); | ||
| 3334 | let turnItem: OrchestrationV2TurnItem; | ||
| 3335 | if (toolCall.toolCallId.startsWith("acp-compaction:")) { | ||
| 3336 | const summary = textFromUnknown(rawOutput); | ||
| 3337 | turnItem = { | ||
| 3338 | ...base, | ||
| 3339 | type: "compaction", | ||
| 3340 | driver, | ||
| 3341 | ...(summary === undefined ? {} : { summary }), | ||
| 3342 | ...(context.contextUsage?.usedTokens === undefined | ||
| 3343 | ? {} | ||
| 3344 | : { beforeTokenCount: context.contextUsage.usedTokens }), | ||
| 3345 | }; | ||
| 3346 | if (status === "completed") { | ||
| 3347 | context.contextUsage = null; | ||
| 3348 | yield* Ref.update(contextUsageBySessionId, (current) => { | ||
| 3349 | const updated = new Map(current); | ||
| 3350 | updated.delete(context.nativeThreadId); | ||
| 3351 | return updated; | ||
| 3352 | }); | ||
| 3353 | } | ||
| 3354 | } else if (mcpIdentity !== undefined) { | ||
| 3355 | turnItem = { | ||
| 3356 | ...base, | ||
| 3357 | title: null, | ||
| 3358 | ...mcpToolPresentation({ | ||
| 3359 | serverName: mcpIdentity.server, | ||
| 3360 | toolName: mcpIdentity.tool, | ||
| 3361 | source: unknownRecord( | ||
| 3362 | (unknownRecord(rawOutputRecord?.result) ?? rawOutputRecord)?._meta, | ||
| 3363 | )?.source, | ||
| 3364 | }), | ||
| 3365 | type: "dynamic_tool", | ||
| 3366 | toolName: `${mcpIdentity.server}.${mcpIdentity.tool}`, | ||
| 3367 | input: | ||
| 3368 | mcpIdentity.input ?? | ||
| 3369 | unknownRecord(rawInputRecord?.arguments) ?? | ||
| 3370 | rawInputRecord ?? | ||
| 3371 | {}, | ||
| 3372 | ...(rawOutput === undefined ? {} : { output: acpMcpToolCallOutput(rawOutput) }), | ||
| 3373 | }; | ||
| 3374 | yield* emitProviderEvent({ type: "turn_item.updated", driver, turnItem }); | ||
| 3375 | yield* rearmDeferredFinalize(context); | ||
| 3376 | return; | ||
| 3377 | } else if (changes.length > 0) { | ||
| 3378 | turnItem = { | ||
| 3379 | ...base, | ||
| 3380 | type: "file_change", | ||
| 3381 | fileName: changes[0]!.path, | ||
| 3382 | changes, | ||
| 3383 | ...(diffText === undefined ? {} : { diffStr: diffText }), | ||
| 3384 | }; | ||
| 3385 | } else { | ||
| 3386 | switch (toolCall.kind) { | ||
| 3387 | case "read": | ||
| 3388 | turnItem = { | ||
| 3389 | ...base, | ||
| 3390 | title: path ? formatReadToolLabel(path) : (title ?? "Read file"), | ||
| 3391 | type: "dynamic_tool", | ||
| 3392 | toolName: "Read", | ||
| 3393 | input: | ||
| 3394 | path === undefined || | ||
| 3395 | ["path", "filePath", "file_path"].some((key) => rawInputRecord?.[key] === path) | ||
| 3396 | ? (rawInputRecord ?? {}) | ||
| 3397 | : { ...rawInputRecord, path }, | ||
| 3398 | ...(rawOutput === undefined ? {} : { output: rawOutput }), | ||
| 3399 | }; | ||
| 3400 | break; | ||
| 3401 | case "search": { | ||
| 3402 | const backendSearch = acpBackendWebSearch(rawInputRecord, rawOutputRecord); | ||
| 3403 | if (backendSearch !== undefined) { | ||
| 3404 | // Grok titles these "X search:" / "Web search:" awaiting the query. | ||
| 3405 | const label = nonEmptyText(toolCall.data.title, title ?? "Web search").replace( | ||
| 3406 | /:\s*$/u, | ||
| 3407 | "", | ||
| 3408 | ); | ||
| 3409 | turnItem = { | ||
| 3410 | ...base, | ||
| 3411 | title: | ||
| 3412 | backendSearch.query === undefined | ||
| 3413 | ? label | ||
| 3414 | : `${label}: ${backendSearch.query}`, | ||
| 3415 | type: "web_search", | ||
| 3416 | ...(backendSearch.query === undefined | ||
| 3417 | ? {} | ||
| 3418 | : { patterns: [backendSearch.query] }), | ||
| 3419 | ...(backendSearch.results.length === 0 | ||
| 3420 | ? {} | ||
| 3421 | : { results: backendSearch.results }), | ||
| 3422 | }; | ||
| 3423 | break; | ||
| 3424 | } | ||
| 3425 | turnItem = { | ||
| 3426 | ...base, | ||
| 3427 | title: | ||
| 3428 | formatSearchToolLabel({ | ||
| 3429 | rawInput: rawInputRecord, | ||
| 3430 | input: rawInputRecord, | ||
| 3431 | ...(path === undefined ? {} : { pattern: path }), | ||
| 3432 | }) ?? title, | ||
| 3433 | type: "file_search", | ||
| 3434 | ...(path === undefined ? {} : { pattern: path }), | ||
| 3435 | ...(path === undefined | ||
| 3436 | ? {} | ||
| 3437 | : { | ||
| 3438 | results: [ | ||
| 3439 | { | ||
| 3440 | fileName: path, | ||
| 3441 | ...(textFromUnknown(rawOutput) === undefined | ||
| 3442 | ? {} | ||
| 3443 | : { preview: textFromUnknown(rawOutput) }), | ||
| 3444 | }, | ||
| 3445 | ], | ||
| 3446 | }), | ||
| 3447 | }; | ||
| 3448 | break; | ||
| 3449 | } | ||
| 3450 | case "execute": { | ||
| 3451 | const exitCode = acpProjectedCommandExitCode(status, rawOutput); | ||
| 3452 | turnItem = { | ||
| 3453 | ...base, | ||
| 3454 | type: "command_execution", | ||
| 3455 | input: toolCall.command ?? monitorCommand ?? toolCall.title ?? "Command", | ||
| 3456 | ...(textFromUnknown(rawOutput) === undefined | ||
| 3457 | ? {} | ||
| 3458 | : { output: textFromUnknown(rawOutput) }), | ||
| 3459 | ...(exitCode === undefined ? {} : { exitCode }), | ||
| 3460 | }; | ||
| 3461 | break; | ||
| 3462 | } | ||
| 3463 | case "edit": | ||
| 3464 | case "delete": | ||
| 3465 | case "move": | ||
| 3466 | turnItem = { | ||
| 3467 | ...base, | ||
| 3468 | type: "file_change", | ||
| 3469 | fileName: path ?? toolCall.title ?? "File change", | ||
| 3470 | ...(diffText === undefined ? {} : { diffStr: diffText }), | ||
| 3471 | }; | ||
| 3472 | break; | ||
| 3473 | case "fetch": { | ||
| 3474 | // Grok nests the page under rawOutput.Content, which textFromUnknown | ||
| 3475 | // cannot read; the (bounded) content blocks carry the same text. | ||
| 3476 | const snippet = | ||
| 3477 | textFromUnknown(toolCall.data.content) ?? textFromUnknown(rawOutput); | ||
| 3478 | turnItem = { | ||
| 3479 | ...base, | ||
| 3480 | type: "web_search", | ||
| 3481 | ...(path === undefined ? {} : { patterns: [path] }), | ||
| 3482 | ...(path === undefined | ||
| 3483 | ? {} | ||
| 3484 | : { | ||
| 3485 | results: [ | ||
| 3486 | { | ||
| 3487 | url: path, | ||
| 3488 | ...(snippet === undefined ? {} : { snippet }), | ||
| 3489 | }, | ||
| 3490 | ], | ||
| 3491 | }), | ||
| 3492 | }; | ||
| 3493 | break; | ||
| 3494 | } | ||
| 3495 | default: | ||
| 3496 | if (projectAsCommandExecution) { | ||
| 3497 | const exitCode = acpProjectedCommandExitCode(status, rawOutput); | ||
| 3498 | turnItem = { | ||
| 3499 | ...base, | ||
| 3500 | type: "command_execution", | ||
| 3501 | input: | ||
| 3502 | toolCall.command ?? | ||
| 3503 | monitorCommand ?? | ||
| 3504 | toolCall.title ?? | ||
| 3505 | (inputVariant === "monitor" ? "Monitor" : "Command"), | ||
| 3506 | ...(textFromUnknown(rawOutput) === undefined | ||
| 3507 | ? {} | ||
| 3508 | : { output: textFromUnknown(rawOutput) }), | ||
| 3509 | ...(exitCode === undefined ? {} : { exitCode }), | ||
| 3510 | }; | ||
| 3511 | } else { | ||
| 3512 | turnItem = { | ||
| 3513 | ...base, | ||
| 3514 | type: "dynamic_tool", | ||
| 3515 | toolName: toolCall.title ?? toolCall.kind ?? null, | ||
| 3516 | input: rawInput ?? {}, | ||
| 3517 | ...(rawOutput === undefined ? {} : { output: rawOutput }), | ||
| 3518 | }; | ||
| 3519 | } | ||
| 3520 | } | ||
| 3521 | } | ||
| 3522 | yield* emitProviderEvent({ type: "turn_item.updated", driver, turnItem }); | ||
| 3523 | yield* rearmDeferredFinalize(context); | ||
| 3524 | }); | ||
| 3525 | |||
| 3526 | const emitPlan = Effect.fnUntraced(function* ( | ||
| 3527 | context: ActiveAcpTurn, | ||
| 3528 | update: AcpPlanUpdate, | ||
| 3529 | ) { | ||
| 3530 | yield* closeTextStreams(context); | ||
| 3531 | const existing = context.plans.get(update.nativePlanId); | ||
| 3532 | if (update.kind === "removed" && existing === undefined) return; | ||
| 3533 | const nativeItemId = `${context.nativeTurnId}:plan:${encodeURIComponent(update.nativePlanId)}`; | ||
| 3534 | const ordinal = yield* resolveItemOrdinal(context, nativeItemId); | ||
| 3535 | const now = yield* DateTime.now; | ||
| 3536 | const nodeId = providerNodeId(nativeItemId); | ||
| 3537 | const turnItemId = providerTurnItemId(nativeItemId); | ||
| 3538 | const planState = existing ?? { | ||
| 3539 | id: yield* idAllocator.allocate.plan({ | ||
| 3540 | threadId: context.input.threadId, | ||
| 3541 | runId: context.input.runId, | ||
| 3542 | driver, | ||
| 3543 | }), | ||
| 3544 | startedAt: now, | ||
| 3545 | latest: null, | ||
| 3546 | }; | ||
| 3547 | const planId = planState.id; | ||
| 3548 | const nativeItemRef = { driver, nativeId: nativeItemId, strength: "weak" as const }; | ||
| 3549 | const base = { | ||
| 3550 | id: planId, | ||
| 3551 | threadId: context.input.threadId, | ||
| 3552 | runId: context.input.runId, | ||
| 3553 | nodeId, | ||
| 3554 | } as const; | ||
| 3555 | let plan: OrchestrationV2PlanArtifact; | ||
| 3556 | if (update.kind === "removed") { | ||
| 3557 | if (planState.latest === null) return; | ||
| 3558 | plan = { ...planState.latest, status: "superseded" }; | ||
| 3559 | } else if (update.kind === "items") { | ||
| 3560 | const steps: ReadonlyArray<OrchestrationV2PlanStep> = update.plan.map( | ||
| 3561 | (step, index) => ({ | ||
| 3562 | id: `acp-step-${index + 1}`, | ||
| 3563 | text: nonEmptyText(step.step, `Step ${index + 1}`), | ||
| 3564 | status: | ||
| 3565 | step.status === "inProgress" | ||
| 3566 | ? "running" | ||
| 3567 | : step.status === "completed" | ||
| 3568 | ? "completed" | ||
| 3569 | : "pending", | ||
| 3570 | }), | ||
| 3571 | ); | ||
| 3572 | const completed = | ||
| 3573 | steps.length > 0 && steps.every((step) => step.status === "completed"); | ||
| 3574 | plan = { | ||
| 3575 | ...base, | ||
| 3576 | status: completed ? "completed" : "active", | ||
| 3577 | kind: "todo_list", | ||
| 3578 | steps, | ||
| 3579 | ...(update.explanation == null ? {} : { explanation: update.explanation }), | ||
| 3580 | }; | ||
| 3581 | } else { | ||
| 3582 | const markdown = | ||
| 3583 | update.kind === "markdown" | ||
| 3584 | ? update.markdown | ||
| 3585 | : update.kind === "file" | ||
| 3586 | ? `Plan file: ${update.uri}` | ||
| 3587 | : `[Unsupported ACP plan content: ${update.contentType}]`; | ||
| 3588 | plan = { | ||
| 3589 | ...base, | ||
| 3590 | status: "active", | ||
| 3591 | kind: "proposed_plan", | ||
| 3592 | markdown, | ||
| 3593 | }; | ||
| 3594 | } | ||
| 3595 | planState.latest = plan; | ||
| 3596 | context.plans.set(update.nativePlanId, planState); | ||
| 3597 | const completed = plan.status === "completed" || plan.status === "superseded"; | ||
| 3598 | const nodeKind = plan.kind === "todo_list" ? "todo_list" : "plan"; | ||
| 3599 | yield* emitProviderEvent({ | ||
| 3600 | type: "node.updated", | ||
| 3601 | driver, | ||
| 3602 | node: { | ||
| 3603 | id: nodeId, | ||
| 3604 | threadId: context.input.threadId, | ||
| 3605 | runId: context.input.runId, | ||
| 3606 | parentNodeId: context.input.rootNodeId, | ||
| 3607 | rootNodeId: context.input.rootNodeId, | ||
| 3608 | kind: nodeKind, | ||
| 3609 | status: completed ? "completed" : "running", | ||
| 3610 | countsForRun: false, | ||
| 3611 | providerThreadId: context.input.providerThread.id, | ||
| 3612 | providerTurnId: context.providerTurnId, | ||
| 3613 | nativeItemRef, | ||
| 3614 | runtimeRequestId: null, | ||
| 3615 | checkpointScopeId: null, | ||
| 3616 | startedAt: planState.startedAt, | ||
| 3617 | completedAt: completed ? now : null, | ||
| 3618 | }, | ||
| 3619 | }); | ||
| 3620 | yield* emitProviderEvent({ type: "plan.updated", driver, plan }); | ||
| 3621 | yield* emitProviderEvent({ | ||
| 3622 | type: "turn_item.updated", | ||
| 3623 | driver, | ||
| 3624 | turnItem: { | ||
| 3625 | id: turnItemId, | ||
| 3626 | threadId: context.input.threadId, | ||
| 3627 | runId: context.input.runId, | ||
| 3628 | nodeId, | ||
| 3629 | providerThreadId: context.input.providerThread.id, | ||
| 3630 | providerTurnId: context.providerTurnId, | ||
| 3631 | nativeItemRef, | ||
| 3632 | parentItemId: null, | ||
| 3633 | ordinal, | ||
| 3634 | status: completed ? "completed" : "running", | ||
| 3635 | title: null, | ||
| 3636 | startedAt: planState.startedAt, | ||
| 3637 | completedAt: completed ? now : null, | ||
| 3638 | updatedAt: now, | ||
| 3639 | ...(plan.kind === "todo_list" | ||
| 3640 | ? { | ||
| 3641 | type: "todo_list" as const, | ||
| 3642 | planId, | ||
| 3643 | steps: plan.steps, | ||
| 3644 | ...(plan.explanation === undefined ? {} : { explanation: plan.explanation }), | ||
| 3645 | } | ||
| 3646 | : { | ||
| 3647 | type: "proposed_plan" as const, | ||
| 3648 | planId, | ||
| 3649 | markdown: plan.markdown, | ||
| 3650 | streaming: !completed, | ||
| 3651 | }), | ||
| 3652 | }, | ||
| 3653 | }); | ||
| 3654 | }); | ||
| 3655 | |||
| 3656 | const appendLoadedHistory = ( | ||
| 3657 | notification: EffectAcpSchema.SessionNotification, | ||
| 3658 | role: "user" | "assistant" | "thought", | ||
| 3659 | text: string, | ||
| 3660 | replace = false, | ||
| 3661 | ) => | ||
| 3662 | Effect.gen(function* () { | ||
| 3663 | if (text.length === 0 && !replace) return; | ||
| 3664 | const now = yield* DateTime.now; | ||
| 3665 | yield* Ref.update(snapshot, (current) => { | ||
| 3666 | const update = notification.update; | ||
| 3667 | const sourceMessageId = | ||
| 3668 | (update.sessionUpdate === "user_message_chunk" || | ||
| 3669 | update.sessionUpdate === "agent_message_chunk" || | ||
| 3670 | update.sessionUpdate === "agent_thought_chunk" || | ||
| 3671 | update.sessionUpdate === "user_message" || | ||
| 3672 | update.sessionUpdate === "agent_message" || | ||
| 3673 | update.sessionUpdate === "agent_thought") && | ||
| 3674 | update.messageId | ||
| 3675 | ? update.messageId | ||
| 3676 | : null; | ||
| 3677 | const startsNew = | ||
| 3678 | current.loadingRole !== role || | ||
| 3679 | (sourceMessageId !== null && current.loadingMessageId !== sourceMessageId); | ||
| 3680 | const loadingIndex = startsNew ? current.loadingIndex + 1 : current.loadingIndex; | ||
| 3681 | const nativeItemId = | ||
| 3682 | sourceMessageId === null | ||
| 3683 | ? `${notification.sessionId}:history:${role}:${loadingIndex}` | ||
| 3684 | : `${notification.sessionId}:history:${role}:message:${sourceMessageId}`; | ||
| 3685 | const messageId = providerMessageId(nativeItemId); | ||
| 3686 | const key = String(messageId); | ||
| 3687 | const previous = current.messages.get(key); | ||
| 3688 | const messages = new Map(current.messages); | ||
| 3689 | messages.set(key, { | ||
| 3690 | createdBy: previous?.createdBy ?? (role === "user" ? "user" : "agent"), | ||
| 3691 | creationSource: previous?.creationSource ?? "provider", | ||
| 3692 | id: messageId, | ||
| 3693 | threadId: input.threadId, | ||
| 3694 | runId: null, | ||
| 3695 | nodeId: null, | ||
| 3696 | role: role === "user" ? "user" : "assistant", | ||
| 3697 | text: replace ? text : `${previous?.text ?? ""}${text}`, | ||
| 3698 | attachments: [], | ||
| 3699 | streaming: false, | ||
| 3700 | createdAt: previous?.createdAt ?? now, | ||
| 3701 | updatedAt: now, | ||
| 3702 | }); | ||
| 3703 | return { | ||
| 3704 | order: current.order.includes(key) ? current.order : [...current.order, key], | ||
| 3705 | messages, | ||
| 3706 | loadingRole: role, | ||
| 3707 | loadingMessageId: sourceMessageId ?? (startsNew ? null : current.loadingMessageId), | ||
| 3708 | loadingIndex, | ||
| 3709 | }; | ||
| 3710 | }); | ||
| 3711 | }); | ||
| 3712 | |||
| 3713 | const offerContinuationRun = Effect.fnUntraced(function* (_sessionId: string) { | ||
| 3714 | if (continuationRequests === undefined) { | ||
| 3715 | return false; | ||
| 3716 | } | ||
| 3717 | const pending = yield* continuationPermit.withPermit( | ||
| 3718 | Effect.gen(function* () { | ||
| 3719 | if (yield* Ref.get(continuationClosed)) return Option.none(); | ||
| 3720 | if (yield* Ref.get(stoppedRunQuarantine)) return Option.none(); | ||
| 3721 | if (yield* Ref.get(continuationRequested)) return Option.none(); | ||
| 3722 | const route = yield* Ref.get(lastTurnRoute); | ||
| 3723 | if (route === null) return Option.none(); | ||
| 3724 | yield* Ref.set(continuationRequested, true); | ||
| 3725 | const generation = yield* Ref.updateAndGet( | ||
| 3726 | continuationGeneration, | ||
| 3727 | (value) => value + 1, | ||
| 3728 | ); | ||
| 3729 | const reports = yield* Ref.modify(wakeReports, ({ reports }) => [ | ||
| 3730 | reports, | ||
| 3731 | { reports, offered: new Set(reports.keys()) }, | ||
| 3732 | ]); | ||
| 3733 | return Option.some({ route, generation, reports }); | ||
| 3734 | }), | ||
| 3735 | ); | ||
| 3736 | if (Option.isNone(pending)) return false; | ||
| 3737 | const { route, generation, reports } = pending.value; | ||
| 3738 | const notification = backgroundWorkNotification([...reports.values()]); | ||
| 3739 | yield* Effect.logInfo("orchestration-v2.acp-wake-turn-detected", { | ||
| 3740 | driver, | ||
| 3741 | providerSessionId: input.providerSessionId, | ||
| 3742 | threadId: route.threadId, | ||
| 3743 | providerThreadId: route.providerThreadId, | ||
| 3744 | }); | ||
| 3745 | yield* continuationRequests.offer({ | ||
| 3746 | threadId: route.threadId, | ||
| 3747 | providerThreadId: route.providerThreadId, | ||
| 3748 | driver, | ||
| 3749 | detail: null, | ||
| 3750 | ...(notification === null ? {} : { notification }), | ||
| 3751 | clearIfCurrent: () => | ||
| 3752 | continuationPermit.withPermit( | ||
| 3753 | Effect.gen(function* () { | ||
| 3754 | if ((yield* Ref.get(continuationGeneration)) === generation) { | ||
| 3755 | yield* Ref.set(continuationRequested, false); | ||
| 3756 | } | ||
| 3757 | }), | ||
| 3758 | ), | ||
| 3759 | dispatchIfCurrent: (effect) => | ||
| 3760 | continuationPermit.withPermit( | ||
| 3761 | Effect.gen(function* () { | ||
| 3762 | const clearIfOwner = Effect.gen(function* () { | ||
| 3763 | if ((yield* Ref.get(continuationGeneration)) === generation) { | ||
| 3764 | yield* Ref.set(continuationRequested, false); | ||
| 3765 | } | ||
| 3766 | }); | ||
| 3767 | if (yield* Ref.get(stoppedRunQuarantine)) { | ||
| 3768 | yield* clearIfOwner; | ||
| 3769 | return Option.none(); | ||
| 3770 | } | ||
| 3771 | if ((yield* Ref.get(continuationGeneration)) !== generation) { | ||
| 3772 | // Superseded by a newer offer; do not clear its flag. | ||
| 3773 | return Option.none(); | ||
| 3774 | } | ||
| 3775 | if (!(yield* Ref.get(continuationRequested))) return Option.none(); | ||
| 3776 | // A successful durable dispatch owns the sticky flag until its | ||
| 3777 | // continuation turn starts. Clearing here opens a dispatch-to- | ||
| 3778 | // start race where every late ACP frame can enqueue another | ||
| 3779 | // synthetic continuation. Failures clear immediately because | ||
| 3780 | // no turn will arrive to do so. | ||
| 3781 | const exit = yield* Effect.exit(effect); | ||
| 3782 | if (Exit.isFailure(exit)) { | ||
| 3783 | yield* clearIfOwner; | ||
| 3784 | return yield* Effect.failCause(exit.cause); | ||
| 3785 | } | ||
| 3786 | return Option.some(exit.value); | ||
| 3787 | }), | ||
| 3788 | ), | ||
| 3789 | }); | ||
| 3790 | return true; | ||
| 3791 | }); | ||
| 3792 | |||
| 3793 | const applyLateBackgroundMutation = Effect.fnUntraced(function* ( | ||
| 3794 | sessionId: string, | ||
| 3795 | mutation: { | ||
| 3796 | readonly taskId: string; | ||
| 3797 | readonly status: "running" | "completed" | "failed"; | ||
| 3798 | }, | ||
| 3799 | ) { | ||
| 3800 | const taskAlreadyEnded = (yield* Ref.get(endedBackgroundTaskIds)).has(mutation.taskId); | ||
| 3801 | yield* applyBackgroundTaskMutationRunning(mutation); | ||
| 3802 | if (mutation.status === "running") { | ||
| 3803 | // Straggler running notice after a genuine end: suppress residual | ||
| 3804 | // monitor-prompt agent chatter so it cannot open a synthetic wake. | ||
| 3805 | if (taskAlreadyEnded && acpPostSettleMonitorPromptShouldSuppress(mutation)) { | ||
| 3806 | yield* Ref.set(suppressPostSettleMonitorPrompt, true); | ||
| 3807 | } | ||
| 3808 | return; | ||
| 3809 | } | ||
| 3810 | // First genuine terminal: allow subsequent agent output. Re-delivery | ||
| 3811 | // of an already-ended terminal must not un-suppress residual ack | ||
| 3812 | // chatter from the injected monitor-event turn. | ||
| 3813 | if (!taskAlreadyEnded) { | ||
| 3814 | yield* Ref.set(suppressPostSettleMonitorPrompt, false); | ||
| 3815 | } | ||
| 3816 | // Keep handledBackgroundTaskIdsInActiveTurn intact. Erasing the mark | ||
| 3817 | // on a late monitor-event mutation defeated the in-turn chatter guard | ||
| 3818 | // and let injected-turn acks arm wakeBuffer for a later spurious | ||
| 3819 | // "Background task completed." run (multiturn live repro). Monitors | ||
| 3820 | // the agent settled without reporting never enter that set, so their | ||
| 3821 | // injected report still streams via pendingInjectedReport / hold. | ||
| 3822 | const activeContext = yield* Ref.get(activeTurn); | ||
| 3823 | // Completions that land while a root turn is still streaming are | ||
| 3824 | // owned by in-turn machinery (emitTool handled marks, deferred | ||
| 3825 | // finalize, injected-report hold). Do not open a synthetic wake mid- | ||
| 3826 | // turn; finalizeTurn re-checks wakeBuffer / midTurnUnreported once | ||
| 3827 | // the turn leaves the active slot so legitimate unhandled evidence | ||
| 3828 | // still offers after finalize. | ||
| 3829 | if (activeContext !== null && !activeContext.finalized) { | ||
| 3830 | const handled = yield* Ref.get(handledBackgroundTaskIdsInActiveTurn); | ||
| 3831 | // Only arm deferred wake when this turn registered the task (monitor | ||
| 3832 | // tool started in-turn) and the root prompt is still open. Completions | ||
| 3833 | // that land in the settled-held window (promptSettled, deferred | ||
| 3834 | // finalize holding for injected report) fall back to the pre-existing | ||
| 3835 | // post-finalize injected-turn path; arming here would spuriously | ||
| 3836 | // offer "Background task completed." after the report streams into | ||
| 3837 | // the held turn. Residual cancel-backgrounded completions from a | ||
| 3838 | // prior interrupt must not open a synthetic continuation. | ||
| 3839 | if ( | ||
| 3840 | !activeContext.promptSettled && | ||
| 3841 | !handled.has(mutation.taskId) && | ||
| 3842 | activeContext.toolCallIdsByBackgroundTaskId.has(mutation.taskId) | ||
| 3843 | ) { | ||
| 3844 | yield* Ref.update(midTurnUnreportedCompletedTaskIds, (current) => | ||
| 3845 | new Set(current).add(mutation.taskId), | ||
| 3846 | ); | ||
| 3847 | } | ||
| 3848 | return; | ||
| 3849 | } | ||
| 3850 | if ( | ||
| 3851 | postSettleContinuationEnabled && | ||
| 3852 | (yield* Ref.get(activeSessionId)) === sessionId && | ||
| 3853 | (yield* Ref.get(runningBackgroundTaskIds)).size === 0 && | ||
| 3854 | ((yield* Ref.get(wakeBuffer)).length > 0 || | ||
| 3855 | (yield* Ref.get(midTurnUnreportedCompletedTaskIds)).size > 0) | ||
| 3856 | ) { | ||
| 3857 | yield* offerContinuationRun(sessionId); | ||
| 3858 | } | ||
| 3859 | }); | ||
| 3860 | |||
| 3861 | // A structured task end (Grok `task_completed`) is authoritative for the | ||
| 3862 | // tool that registered the task: its own updates only ever say running. | ||
| 3863 | // Finish the row while deferred finalize holds a settled root turn open | ||
| 3864 | // for it, so the turn can settle. While the prompt is still open the | ||
| 3865 | // agent reports the end itself (TaskOutput hydration), and an unreported | ||
| 3866 | // end must keep its post-finalize continuation offer. | ||
| 3867 | const finishRegisteredBackgroundTool = Effect.fnUntraced(function* (mutation: { | ||
| 3868 | readonly taskId: string; | ||
| 3869 | readonly status: "completed" | "failed"; | ||
| 3870 | readonly output?: string; | ||
| 3871 | }) { | ||
| 3872 | const context = yield* Ref.get(activeTurn); | ||
| 3873 | if (context === null || context.finalized || !context.promptSettled) return; | ||
| 3874 | const toolCallId = context.toolCallIdsByBackgroundTaskId.get(mutation.taskId); | ||
| 3875 | const tool = toolCallId === undefined ? undefined : context.tools.get(toolCallId); | ||
| 3876 | if (tool === undefined) return; | ||
| 3877 | const status = toolStatus(tool.status); | ||
| 3878 | if (status !== "pending" && status !== "running") return; | ||
| 3879 | context.awaitingBackgroundHydration.delete(mutation.taskId); | ||
| 3880 | const finished = { ...tool, status: mutation.status }; | ||
| 3881 | yield* emitTool( | ||
| 3882 | context, | ||
| 3883 | mutation.output === undefined ? finished : setToolOutputText(finished, mutation.output), | ||
| 3884 | mutation.status, | ||
| 3885 | ); | ||
| 3886 | yield* rearmDeferredFinalize(context); | ||
| 3887 | }); | ||
| 3888 | |||
| 3889 | const bufferPostSettleWake = Effect.fnUntraced(function* ( | ||
| 3890 | notification: EffectAcpSchema.SessionNotification, | ||
| 3891 | ) { | ||
| 3892 | if (!postSettleContinuationEnabled || continuationRequests === undefined) { | ||
| 3893 | return { | ||
| 3894 | buffered: false, | ||
| 3895 | offerContinuation: false, | ||
| 3896 | stopProcessing: false, | ||
| 3897 | }; | ||
| 3898 | } | ||
| 3899 | // Direct Stop quarantine: drop residual wake evidence instead of | ||
| 3900 | // buffering it for a later continuation or follow-up run. | ||
| 3901 | if (yield* Ref.get(stoppedRunQuarantine)) { | ||
| 3902 | return { | ||
| 3903 | buffered: false, | ||
| 3904 | offerContinuation: false, | ||
| 3905 | stopProcessing: true, | ||
| 3906 | }; | ||
| 3907 | } | ||
| 3908 | const rootSessionId = yield* Ref.get(activeSessionId); | ||
| 3909 | if (rootSessionId === null || notification.sessionId !== rootSessionId) { | ||
| 3910 | return { | ||
| 3911 | buffered: false, | ||
| 3912 | offerContinuation: false, | ||
| 3913 | stopProcessing: false, | ||
| 3914 | }; | ||
| 3915 | } | ||
| 3916 | if (!acpPostSettleWakeEvidence(notification, flavor)) { | ||
| 3917 | return { | ||
| 3918 | buffered: false, | ||
| 3919 | offerContinuation: false, | ||
| 3920 | stopProcessing: false, | ||
| 3921 | }; | ||
| 3922 | } | ||
| 3923 | const update = notification.update; | ||
| 3924 | let alreadyHandledToolUpdate = false; | ||
| 3925 | if (update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update") { | ||
| 3926 | yield* trackRunningBackgroundTools(notification); | ||
| 3927 | // When the agent consumes the monitor output itself via | ||
| 3928 | // get_command_or_subagent_output (long timeout), the TaskOutput | ||
| 3929 | // completion is the ONLY end signal: no "Monitor ended" reminder | ||
| 3930 | // follows. Without applying it here the running set never clears, | ||
| 3931 | // every offer stays suppressed, and the wake buffer never drains | ||
| 3932 | // (observed live 2026-07-12, thread 8dbe607f). The completion | ||
| 3933 | // frame itself is offer evidence, so clearing the id before the | ||
| 3934 | // gate below lets it open the single continuation run. | ||
| 3935 | for (const event of parseSessionUpdateEvent(notification).events) { | ||
| 3936 | if (event._tag !== "ToolCallUpdated") continue; | ||
| 3937 | const toolCall = flavor.normalizeToolCall?.(event.toolCall) ?? event.toolCall; | ||
| 3938 | const proposedPlanMarkdown = flavor.extractProposedPlanMarkdown?.(toolCall); | ||
| 3939 | if (proposedPlanMarkdown !== undefined && proposedPlanMarkdown.length > 0) { | ||
| 3940 | yield* captureProposedPlan({ planMarkdown: proposedPlanMarkdown }); | ||
| 3941 | } | ||
| 3942 | const toolTaskId = flavor.extractBackgroundTaskId?.(toolCall); | ||
| 3943 | if ( | ||
| 3944 | toolTaskId !== undefined && | ||
| 3945 | (yield* Ref.get(handledBackgroundTaskIdsInActiveTurn)).has(toolTaskId) | ||
| 3946 | ) { | ||
| 3947 | alreadyHandledToolUpdate = true; | ||
| 3948 | } | ||
| 3949 | if (flavor.extractBackgroundTaskCompletion !== undefined) { | ||
| 3950 | for (const completion of flavor.extractBackgroundTaskCompletion(toolCall)) { | ||
| 3951 | alreadyHandledToolUpdate = | ||
| 3952 | alreadyHandledToolUpdate || | ||
| 3953 | (yield* Ref.get(handledBackgroundTaskIdsInActiveTurn)).has(completion.taskId); | ||
| 3954 | yield* applyBackgroundTaskMutationRunning(completion); | ||
| 3955 | } | ||
| 3956 | } | ||
| 3957 | } | ||
| 3958 | } | ||
| 3959 | const backgroundWorkRunning = (yield* Ref.get(runningBackgroundTaskIds)).size > 0; | ||
| 3960 | // Residual Grok agent/thought chatter after in-turn-handled background | ||
| 3961 | // work must not be retained as wake evidence. Tool-path alreadyHandled | ||
| 3962 | // covers re-reports with a task id; this covers agent_message_chunk / | ||
| 3963 | // agent_thought_chunk frames that carry no task id (live: | ||
| 3964 | // grok-in-turn-monitor-no-wake, multiturn stale-buffer arm). Check | ||
| 3965 | // before buffering so the frames cannot dirty wakeBuffer and later | ||
| 3966 | // arm a mid-turn offer when a second monitor completes. | ||
| 3967 | const handledInTurnCount = (yield* Ref.get(handledBackgroundTaskIdsInActiveTurn)).size; | ||
| 3968 | const isInTurnHandledAgentChatter = | ||
| 3969 | handledInTurnCount > 0 && | ||
| 3970 | (update.sessionUpdate === "agent_message_chunk" || | ||
| 3971 | update.sessionUpdate === "agent_thought_chunk"); | ||
| 3972 | // Grok prompts itself for every monitor event after the root turn | ||
| 3973 | // settles. Its assistant/reasoning replies are progress chatter, not | ||
| 3974 | // separate wake results. Retaining them would replay the entire burst | ||
| 3975 | // into the single continuation once the monitor finishes. Keep tool | ||
| 3976 | // state so the final command card still hydrates, then begin retaining | ||
| 3977 | // agent output again after the genuine end signal clears the running | ||
| 3978 | // set. With multiple tasks, text remains best-effort until every task | ||
| 3979 | // ends; their retained tool cards are the authoritative results. | ||
| 3980 | // | ||
| 3981 | // Skip retaining frames for tasks already hydrated in the root turn | ||
| 3982 | // (`handledBackgroundTaskIdsInActiveTurn`). Those re-reports must not | ||
| 3983 | // pin `hasPendingBackgroundWork` via a wake buffer that never drains | ||
| 3984 | // (the already-handled gate below intentionally skips | ||
| 3985 | // `offerContinuationRun` to avoid synthetic "Background task | ||
| 3986 | // completed." spam). | ||
| 3987 | let buffered = false; | ||
| 3988 | if ( | ||
| 3989 | !alreadyHandledToolUpdate && | ||
| 3990 | !isInTurnHandledAgentChatter && | ||
| 3991 | acpPostSettleWakeShouldBuffer(notification, backgroundWorkRunning) | ||
| 3992 | ) { | ||
| 3993 | yield* Ref.update(wakeBuffer, (current) => [...current, notification]); | ||
| 3994 | buffered = true; | ||
| 3995 | } | ||
| 3996 | // Buffer progress without offering; only completion-like frames open a run. | ||
| 3997 | if (!acpPostSettleContinuationOfferEvidence(notification, flavor)) { | ||
| 3998 | return { | ||
| 3999 | buffered, | ||
| 4000 | offerContinuation: false, | ||
| 4001 | stopProcessing: true, | ||
| 4002 | }; | ||
| 4003 | } | ||
| 4004 | // While a monitor is still streaming, tool re-reports buffer without | ||
| 4005 | // offering and per-event agent commentary is consumed without being | ||
| 4006 | // retained. Grok re-reports a running monitor as Bash frames that | ||
| 4007 | // already carry exit_code 0 mid-stream, so a "terminal" normalized | ||
| 4008 | // status is not evidence the task ended; each burst would otherwise | ||
| 4009 | // reopen a synthetic "Background task completed." run. Retained tool | ||
| 4010 | // frames drain into the single continuation offered once the monitor | ||
| 4011 | // actually ends (end-notice mutation below, or the first frame after | ||
| 4012 | // it). | ||
| 4013 | if (backgroundWorkRunning) { | ||
| 4014 | return { | ||
| 4015 | buffered, | ||
| 4016 | offerContinuation: false, | ||
| 4017 | stopProcessing: true, | ||
| 4018 | }; | ||
| 4019 | } | ||
| 4020 | if (alreadyHandledToolUpdate) { | ||
| 4021 | // Drop leftover wake noise for in-turn-handled work so idle release | ||
| 4022 | // is not pinned forever. Leave the buffer alone when a continuation | ||
| 4023 | // is already outstanding: its startTurn will drain legitimate frames | ||
| 4024 | // from other tasks. | ||
| 4025 | if (!(yield* Ref.get(continuationRequested))) { | ||
| 4026 | yield* Ref.set(wakeBuffer, []); | ||
| 4027 | } | ||
| 4028 | return { | ||
| 4029 | buffered, | ||
| 4030 | offerContinuation: false, | ||
| 4031 | stopProcessing: true, | ||
| 4032 | }; | ||
| 4033 | } | ||
| 4034 | // Same in-turn-handled agent chatter: do not open a synthetic wake. | ||
| 4035 | // Do not clear wakeBuffer here: frames for other still-tracked tasks | ||
| 4036 | // must remain drainable when a real (tool) completion later offers. | ||
| 4037 | if (isInTurnHandledAgentChatter) { | ||
| 4038 | return { | ||
| 4039 | buffered, | ||
| 4040 | offerContinuation: false, | ||
| 4041 | stopProcessing: true, | ||
| 4042 | }; | ||
| 4043 | } | ||
| 4044 | return { | ||
| 4045 | buffered, | ||
| 4046 | offerContinuation: true, | ||
| 4047 | stopProcessing: true, | ||
| 4048 | }; | ||
| 4049 | }); | ||
| 4050 | |||
| 4051 | let applyFinalizedActiveTurnSubagentTerminal: ( | ||
| 4052 | context: ActiveAcpTurn, | ||
| 4053 | notification: EffectAcpSchema.SessionNotification, | ||
| 4054 | ) => Effect.Effect<boolean> = () => Effect.succeed(false); | ||
| 4055 | |||
| 4056 | const projectAgentTerminalUpdate = Effect.fnUntraced(function* ( | ||
| 4057 | notification: EffectAcpSchema.SessionNotification, | ||
| 4058 | context: ActiveAcpTurn | null, | ||
| 4059 | ) { | ||
| 4060 | const update = notification.update; | ||
| 4061 | if ( | ||
| 4062 | update.sessionUpdate !== "terminal_update" && | ||
| 4063 | update.sessionUpdate !== "terminal_output_chunk" | ||
| 4064 | ) { | ||
| 4065 | return false; | ||
| 4066 | } | ||
| 4067 | const terminalKey = sessionScopedId(notification.sessionId, update.terminalId); | ||
| 4068 | const next = applyAcpAgentTerminalUpdate(agentTerminalsById.get(terminalKey), update); | ||
| 4069 | agentTerminalsById.set(terminalKey, next); | ||
| 4070 | if (context === null || notification.sessionId !== context.nativeThreadId) return true; | ||
| 4071 | const status = | ||
| 4072 | next.exitStatus === undefined | ||
| 4073 | ? ("inProgress" as const) | ||
| 4074 | : next.exitStatus.exitCode === 0 | ||
| 4075 | ? ("completed" as const) | ||
| 4076 | : ("failed" as const); | ||
| 4077 | const embeddedToolCallIds = toolCallIdsByAgentTerminalId.get(terminalKey); | ||
| 4078 | const projectedToolCallIds = | ||
| 4079 | embeddedToolCallIds === undefined || embeddedToolCallIds.size === 0 | ||
| 4080 | ? [`acp-agent-terminal:${update.terminalId}`] | ||
| 4081 | : [...embeddedToolCallIds]; | ||
| 4082 | for (const toolCallId of projectedToolCallIds) { | ||
| 4083 | const existing = context.tools.get(toolCallId); | ||
| 4084 | const seeded: AcpToolCallState = | ||
| 4085 | existing ?? | ||
| 4086 | ({ | ||
| 4087 | toolCallId, | ||
| 4088 | kind: "execute", | ||
| 4089 | title: next.command ?? "Terminal", | ||
| 4090 | status, | ||
| 4091 | ...(next.command === undefined ? {} : { command: next.command }), | ||
| 4092 | data: { toolCallId }, | ||
| 4093 | } satisfies AcpToolCallState); | ||
| 4094 | yield* emitTool( | ||
| 4095 | context, | ||
| 4096 | setToolOutputText( | ||
| 4097 | { | ||
| 4098 | ...seeded, | ||
| 4099 | status, | ||
| 4100 | ...(next.command === undefined ? {} : { command: next.command }), | ||
| 4101 | }, | ||
| 4102 | next.output, | ||
| 4103 | ), | ||
| 4104 | ); | ||
| 4105 | } | ||
| 4106 | return true; | ||
| 4107 | }); | ||
| 4108 | |||
| 4109 | const handleSessionUpdate = Effect.fnUntraced(function* ( | ||
| 4110 | notification: EffectAcpSchema.SessionNotification, | ||
| 4111 | ) { | ||
| 4112 | const context = yield* Ref.get(activeTurn); | ||
| 4113 | const update = notification.update; | ||
| 4114 | if (yield* projectAgentTerminalUpdate(notification, context)) return; | ||
| 4115 | if ( | ||
| 4116 | update.sessionUpdate === "usage_update" || | ||
| 4117 | update.sessionUpdate === "session_info_update" || | ||
| 4118 | (update.sessionUpdate === "state_update" && | ||
| 4119 | update.state === "idle" && | ||
| 4120 | update.usage != null) | ||
| 4121 | ) { | ||
| 4122 | const stateEvent = parseSessionUpdateEvent(notification).events.find( | ||
| 4123 | (event) => event._tag === "UsageUpdated" || event._tag === "SessionInfoUpdated", | ||
| 4124 | ); | ||
| 4125 | if (stateEvent === undefined) return; | ||
| 4126 | if (stateEvent._tag === "UsageUpdated") { | ||
| 4127 | yield* Ref.update(contextUsageBySessionId, (current) => | ||
| 4128 | new Map(current).set(notification.sessionId, stateEvent.usage), | ||
| 4129 | ); | ||
| 4130 | if (context?.nativeThreadId === notification.sessionId) { | ||
| 4131 | context.contextUsage = stateEvent.usage; | ||
| 4132 | } | ||
| 4133 | } else { | ||
| 4134 | const metadata = yield* Ref.modify(nativeMetadataBySessionId, (current) => { | ||
| 4135 | const merged = { | ||
| 4136 | ...current.get(notification.sessionId), | ||
| 4137 | ...stateEvent.metadata, | ||
| 4138 | }; | ||
| 4139 | return [merged, new Map(current).set(notification.sessionId, merged)] as const; | ||
| 4140 | }); | ||
| 4141 | if (context?.nativeThreadId === notification.sessionId) { | ||
| 4142 | context.nativeMetadata = metadata; | ||
| 4143 | } | ||
| 4144 | } | ||
| 4145 | const knownProviderThread = (yield* Ref.get(providerThreadByNativeSessionId)).get( | ||
| 4146 | notification.sessionId, | ||
| 4147 | ); | ||
| 4148 | if (knownProviderThread !== undefined) { | ||
| 4149 | const now = yield* DateTime.now; | ||
| 4150 | const providerThread: OrchestrationV2ProviderThread = { | ||
| 4151 | ...knownProviderThread, | ||
| 4152 | contextUsage: | ||
| 4153 | (yield* Ref.get(contextUsageBySessionId)).get(notification.sessionId) ?? | ||
| 4154 | knownProviderThread.contextUsage ?? | ||
| 4155 | null, | ||
| 4156 | nativeMetadata: | ||
| 4157 | (yield* Ref.get(nativeMetadataBySessionId)).get(notification.sessionId) ?? | ||
| 4158 | knownProviderThread.nativeMetadata ?? | ||
| 4159 | null, | ||
| 4160 | updatedAt: now, | ||
| 4161 | }; | ||
| 4162 | yield* Ref.update(providerThreadByNativeSessionId, (current) => | ||
| 4163 | new Map(current).set(notification.sessionId, providerThread), | ||
| 4164 | ); | ||
| 4165 | yield* emitProviderEvent({ | ||
| 4166 | type: "provider_thread.updated", | ||
| 4167 | driver, | ||
| 4168 | providerThread, | ||
| 4169 | }); | ||
| 4170 | } | ||
| 4171 | return; | ||
| 4172 | } | ||
| 4173 | if ( | ||
| 4174 | context?.finalized === true && | ||
| 4175 | (yield* applyFinalizedActiveTurnSubagentTerminal(context, notification)) | ||
| 4176 | ) { | ||
| 4177 | return; | ||
| 4178 | } | ||
| 4179 | // Only while a finalized turn is still the active context. When | ||
| 4180 | // activeTurn is null, post-settle agent frames must reach | ||
| 4181 | // bufferPostSettleWake so continuation can attach (context?.finalized | ||
| 4182 | // !== false incorrectly treated null as finalized and dropped them). | ||
| 4183 | if ( | ||
| 4184 | context !== null && | ||
| 4185 | context.finalized && | ||
| 4186 | (yield* Ref.get(handledBackgroundTaskIdsInActiveTurn)).size > 0 && | ||
| 4187 | (update.sessionUpdate === "agent_message_chunk" || | ||
| 4188 | update.sessionUpdate === "agent_thought_chunk") | ||
| 4189 | ) { | ||
| 4190 | yield* Ref.set(suppressPostSettleMonitorPrompt, true); | ||
| 4191 | return; | ||
| 4192 | } | ||
| 4193 | if ( | ||
| 4194 | context !== null && | ||
| 4195 | (yield* Ref.get(suppressPostSettleMonitorPrompt)) && | ||
| 4196 | (update.sessionUpdate === "agent_message_chunk" || | ||
| 4197 | update.sessionUpdate === "agent_thought_chunk") | ||
| 4198 | ) { | ||
| 4199 | return; | ||
| 4200 | } | ||
| 4201 | if ( | ||
| 4202 | context?.finalized === true && | ||
| 4203 | update.sessionUpdate === "user_message_chunk" && | ||
| 4204 | update.content.type === "text" | ||
| 4205 | ) { | ||
| 4206 | const mutations = flavor.extractBackgroundToolMutation?.(update.content.text) ?? []; | ||
| 4207 | for (const mutation of mutations) { | ||
| 4208 | yield* applyLateBackgroundMutation(notification.sessionId, mutation); | ||
| 4209 | } | ||
| 4210 | return; | ||
| 4211 | } | ||
| 4212 | if (context === null) { | ||
| 4213 | // Direct Stop: quarantine residual events from the stopped run so | ||
| 4214 | // they cannot become history, wake buffers, or a later run attach. | ||
| 4215 | if (yield* Ref.get(stoppedRunQuarantine)) { | ||
| 4216 | return; | ||
| 4217 | } | ||
| 4218 | const bufferOutcome = yield* bufferPostSettleWake(notification); | ||
| 4219 | // Post-settle carryover sync: keep in-memory carryover accurate so | ||
| 4220 | // hasPendingBackgroundWork reasons correctly after root settle. | ||
| 4221 | // A completed root keeps its subscriber open while background items | ||
| 4222 | // remain, so project its terminals immediately even when the wake | ||
| 4223 | // frame is buffered for a continuation. Non-completed roots stay | ||
| 4224 | // memory-only: durable ingest requires a subscriber that owns the | ||
| 4225 | // original runId, which the interrupted path does not guarantee. | ||
| 4226 | const carryover = yield* Ref.get(carryoverSubagents); | ||
| 4227 | const rootTerminalCanStillProject = | ||
| 4228 | carryover !== null && | ||
| 4229 | carryover.sessionId === (yield* Ref.get(activeSessionId)) && | ||
| 4230 | carryover.rootTerminalStatus === "completed"; | ||
| 4231 | const projectCarryover = rootTerminalCanStillProject; | ||
| 4232 | let carryoverTerminalized = false; | ||
| 4233 | if ( | ||
| 4234 | flavor.extractSubagentUpdate !== undefined && | ||
| 4235 | (update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update") | ||
| 4236 | ) { | ||
| 4237 | for (const event of parseSessionUpdateEvent(notification).events) { | ||
| 4238 | if (event._tag !== "ToolCallUpdated") continue; | ||
| 4239 | const toolCall = flavor.normalizeToolCall?.(event.toolCall) ?? event.toolCall; | ||
| 4240 | const subagentUpdate = flavor.extractSubagentUpdate(toolCall); | ||
| 4241 | if (subagentUpdate === undefined) continue; | ||
| 4242 | if (!acpSubagentStatusIsTerminal(subagentUpdate.status)) { | ||
| 4243 | continue; | ||
| 4244 | } | ||
| 4245 | if ( | ||
| 4246 | yield* updateCarryoverSubagentStatus( | ||
| 4247 | subagentUpdate.nativeTaskId, | ||
| 4248 | subagentUpdate.status, | ||
| 4249 | subagentUpdate.result, | ||
| 4250 | { project: projectCarryover }, | ||
| 4251 | ) | ||
| 4252 | ) { | ||
| 4253 | carryoverTerminalized = true; | ||
| 4254 | } | ||
| 4255 | if ( | ||
| 4256 | subagentUpdate.childSessionId !== null && | ||
| 4257 | (yield* updateCarryoverSubagentStatus( | ||
| 4258 | subagentUpdate.childSessionId, | ||
| 4259 | subagentUpdate.status, | ||
| 4260 | subagentUpdate.result, | ||
| 4261 | { project: projectCarryover }, | ||
| 4262 | )) | ||
| 4263 | ) { | ||
| 4264 | carryoverTerminalized = true; | ||
| 4265 | } | ||
| 4266 | } | ||
| 4267 | } | ||
| 4268 | if ( | ||
| 4269 | update.sessionUpdate === "user_message_chunk" && | ||
| 4270 | update.content.type === "text" && | ||
| 4271 | flavor.extractSubagentEndNotice !== undefined | ||
| 4272 | ) { | ||
| 4273 | const notice = flavor.extractSubagentEndNotice(update.content.text); | ||
| 4274 | if ( | ||
| 4275 | notice !== undefined && | ||
| 4276 | (yield* updateCarryoverSubagentStatus( | ||
| 4277 | notice.childSessionId, | ||
| 4278 | notice.status, | ||
| 4279 | undefined, | ||
| 4280 | { | ||
| 4281 | project: projectCarryover, | ||
| 4282 | }, | ||
| 4283 | )) | ||
| 4284 | ) { | ||
| 4285 | carryoverTerminalized = true; | ||
| 4286 | } | ||
| 4287 | } | ||
| 4288 | // Synchronize carryover before an eager continuation can attach and | ||
| 4289 | // drain the frame. | ||
| 4290 | const continuationOffered = bufferOutcome.offerContinuation | ||
| 4291 | ? yield* offerContinuationRun(notification.sessionId) | ||
| 4292 | : false; | ||
| 4293 | const wakeOutcome = { ...bufferOutcome, continuationOffered }; | ||
| 4294 | // Frames that will not buffer never reach the continuation drain for | ||
| 4295 | // this traffic. Once carryover is terminalized, skip history append / | ||
| 4296 | // residual handling (and avoid double-projecting on child re-entry). | ||
| 4297 | if (carryoverTerminalized && !wakeOutcome.buffered) { | ||
| 4298 | // Projected above. Never clear a non-empty wakeBuffer. A sticky | ||
| 4299 | // continuationRequested with an empty buffer is safe to drop so | ||
| 4300 | // idle release is not wed on a pin with nothing to deliver. | ||
| 4301 | if ( | ||
| 4302 | acpCarryoverTerminalShouldClearContinuation({ | ||
| 4303 | continuationOffered: wakeOutcome.continuationOffered, | ||
| 4304 | wakeBufferLength: (yield* Ref.get(wakeBuffer)).length, | ||
| 4305 | }) | ||
| 4306 | ) { | ||
| 4307 | yield* Ref.set(continuationRequested, false); | ||
| 4308 | } | ||
| 4309 | return; | ||
| 4310 | } | ||
| 4311 | // Prefer continuation buffering over history append so the same | ||
| 4312 | // frames are not double-counted once a continuation run attaches. | ||
| 4313 | if (wakeOutcome.stopProcessing) { | ||
| 4314 | return; | ||
| 4315 | } | ||
| 4316 | if ( | ||
| 4317 | update.sessionUpdate === "user_message_chunk" || | ||
| 4318 | update.sessionUpdate === "agent_message_chunk" || | ||
| 4319 | update.sessionUpdate === "agent_thought_chunk" | ||
| 4320 | ) { | ||
| 4321 | // Late monitor end/event reminders must not become ghost user/assistant | ||
| 4322 | // history (or OS-facing chatter) after the root turn already finalized. | ||
| 4323 | const text = acpContentBlockDisplayText(update.content); | ||
| 4324 | if (text === undefined) return; | ||
| 4325 | const lateBackgroundMutations = | ||
| 4326 | update.content.type === "text" | ||
| 4327 | ? (flavor.extractBackgroundToolMutation?.(text) ?? []) | ||
| 4328 | : []; | ||
| 4329 | for (const lateBackgroundMutation of lateBackgroundMutations) { | ||
| 4330 | yield* applyLateBackgroundMutation(notification.sessionId, lateBackgroundMutation); | ||
| 4331 | } | ||
| 4332 | const lateMonitorChatter = | ||
| 4333 | /<monitor-event\b/i.test(text) || | ||
| 4334 | /Monitor\s+["']?[0-9a-f-]{8,}["']?\s+ended/i.test(text); | ||
| 4335 | if ( | ||
| 4336 | text.trim().length > 0 && | ||
| 4337 | lateBackgroundMutations.length === 0 && | ||
| 4338 | !lateMonitorChatter | ||
| 4339 | ) { | ||
| 4340 | yield* appendLoadedHistory( | ||
| 4341 | notification, | ||
| 4342 | update.sessionUpdate === "user_message_chunk" | ||
| 4343 | ? "user" | ||
| 4344 | : update.sessionUpdate === "agent_thought_chunk" | ||
| 4345 | ? "thought" | ||
| 4346 | : "assistant", | ||
| 4347 | text, | ||
| 4348 | ); | ||
| 4349 | } | ||
| 4350 | } else if ( | ||
| 4351 | update.sessionUpdate === "user_message" || | ||
| 4352 | update.sessionUpdate === "agent_message" || | ||
| 4353 | update.sessionUpdate === "agent_thought" | ||
| 4354 | ) { | ||
| 4355 | if (update.content === undefined) return; | ||
| 4356 | const text = (update.content ?? []) | ||
| 4357 | .flatMap((content) => { | ||
| 4358 | const display = acpContentBlockDisplayText(content); | ||
| 4359 | return display === undefined ? [] : [display]; | ||
| 4360 | }) | ||
| 4361 | .join("\n"); | ||
| 4362 | yield* appendLoadedHistory( | ||
| 4363 | notification, | ||
| 4364 | update.sessionUpdate === "user_message" | ||
| 4365 | ? "user" | ||
| 4366 | : update.sessionUpdate === "agent_thought" | ||
| 4367 | ? "thought" | ||
| 4368 | : "assistant", | ||
| 4369 | text, | ||
| 4370 | true, | ||
| 4371 | ); | ||
| 4372 | } else if ( | ||
| 4373 | update.sessionUpdate === "tool_call" || | ||
| 4374 | update.sessionUpdate === "tool_call_update" || | ||
| 4375 | update.sessionUpdate === "plan" || | ||
| 4376 | update.sessionUpdate === "plan_update" || | ||
| 4377 | update.sessionUpdate === "plan_removed" | ||
| 4378 | ) { | ||
| 4379 | yield* Ref.update(snapshot, (current) => ({ | ||
| 4380 | ...current, | ||
| 4381 | loadingRole: null, | ||
| 4382 | loadingMessageId: null, | ||
| 4383 | })); | ||
| 4384 | } | ||
| 4385 | return; | ||
| 4386 | } | ||
| 4387 | if (context.finalized) return; | ||
| 4388 | const rootSessionId = yield* Ref.get(activeSessionId); | ||
| 4389 | if (notification.sessionId !== rootSessionId) { | ||
| 4390 | // Finalize may have completed during the activeSessionId yield. | ||
| 4391 | if (context.finalized) return; | ||
| 4392 | if (flavor.extractSubagentUpdate === undefined) return; | ||
| 4393 | const subagent = context.subagentsBySessionId.get(notification.sessionId); | ||
| 4394 | if ( | ||
| 4395 | update.sessionUpdate === "tool_call" || | ||
| 4396 | update.sessionUpdate === "tool_call_update" | ||
| 4397 | ) { | ||
| 4398 | if (subagent === undefined) return; | ||
| 4399 | const nativeTaskId = | ||
| 4400 | subagent.task.nativeTaskRef?.nativeId ?? String(subagent.task.id); | ||
| 4401 | for (const event of parseSessionUpdateEvent(notification).events) { | ||
| 4402 | if (event._tag !== "ToolCallUpdated") continue; | ||
| 4403 | const toolCall = flavor.normalizeToolCall?.(event.toolCall) ?? event.toolCall; | ||
| 4404 | const subagentUpdate = flavor.extractSubagentUpdate(toolCall); | ||
| 4405 | if (subagentUpdate !== undefined) { | ||
| 4406 | if ( | ||
| 4407 | subagentUpdate.nativeTaskId === nativeTaskId || | ||
| 4408 | subagentUpdate.childSessionId === notification.sessionId | ||
| 4409 | ) { | ||
| 4410 | yield* emitSubagent(context, subagentUpdate); | ||
| 4411 | } | ||
| 4412 | continue; | ||
| 4413 | } | ||
| 4414 | const key = `${nativeTaskId}:tool:${toolCall.toolCallId}`; | ||
| 4415 | const previous = context.tools.get(key); | ||
| 4416 | const merged = mergeToolCallState(previous, toolCall); | ||
| 4417 | context.tools.set(key, merged); | ||
| 4418 | if (!shouldPersistToolUpdate(context, key, previous, merged, merged.status)) | ||
| 4419 | continue; | ||
| 4420 | // Terminals are remembered under the raw session id: the child's | ||
| 4421 | // own session, or the root one when the flavor routes child | ||
| 4422 | // updates out of it (Devin). | ||
| 4423 | const mcpIdentity = extractMcpToolCallIdentity(merged, { | ||
| 4424 | embeddedTerminalCommands: [ | ||
| 4425 | ...embeddedTerminalCommands(notification.sessionId, toolCall.toolCallId), | ||
| 4426 | ...(rootSessionId === null | ||
| 4427 | ? [] | ||
| 4428 | : embeddedTerminalCommands(rootSessionId, toolCall.toolCallId)), | ||
| 4429 | ], | ||
| 4430 | }); | ||
| 4431 | const now = yield* DateTime.now; | ||
| 4432 | const status = toolStatus(merged.status); | ||
| 4433 | const startedAt = context.toolStartedAt.get(key) ?? now; | ||
| 4434 | context.toolStartedAt.set(key, startedAt); | ||
| 4435 | const ordinal = resolveSubagentChildOrdinal(subagent, key); | ||
| 4436 | yield* emitProviderEvent({ | ||
| 4437 | type: "turn_item.updated", | ||
| 4438 | driver, | ||
| 4439 | turnItem: { | ||
| 4440 | id: providerTurnItemId(key), | ||
| 4441 | threadId: subagent.childThreadId, | ||
| 4442 | runId: null, | ||
| 4443 | nodeId: subagent.childRootNodeId, | ||
| 4444 | providerThreadId: subagent.task.providerThreadId, | ||
| 4445 | providerTurnId: null, | ||
| 4446 | nativeItemRef: { driver, nativeId: key, strength: "strong" }, | ||
| 4447 | parentItemId: null, | ||
| 4448 | ordinal, | ||
| 4449 | status, | ||
| 4450 | title: merged.title ?? merged.kind ?? "Tool", | ||
| 4451 | startedAt, | ||
| 4452 | completedAt: completedAtForStatus(status, now), | ||
| 4453 | updatedAt: now, | ||
| 4454 | type: "dynamic_tool", | ||
| 4455 | ...(mcpIdentity === undefined | ||
| 4456 | ? {} | ||
| 4457 | : mcpToolPresentation({ | ||
| 4458 | serverName: mcpIdentity.server, | ||
| 4459 | toolName: mcpIdentity.tool, | ||
| 4460 | source: unknownRecord( | ||
| 4461 | ( | ||
| 4462 | unknownRecord(unknownRecord(merged.data.rawOutput)?.result) ?? | ||
| 4463 | unknownRecord(merged.data.rawOutput) | ||
| 4464 | )?._meta, | ||
| 4465 | )?.source, | ||
| 4466 | })), | ||
| 4467 | toolName: | ||
| 4468 | mcpIdentity === undefined | ||
| 4469 | ? (merged.title ?? merged.kind ?? "Tool") | ||
| 4470 | : `${mcpIdentity.server}.${mcpIdentity.tool}`, | ||
| 4471 | input: merged.data.rawInput ?? null, | ||
| 4472 | output: merged.data.rawOutput ?? merged.data.content ?? null, | ||
| 4473 | }, | ||
| 4474 | }); | ||
| 4475 | } | ||
| 4476 | return; | ||
| 4477 | } | ||
| 4478 | const isDisplayableAssistantUpdate = | ||
| 4479 | (update.sessionUpdate === "agent_message_chunk" && | ||
| 4480 | acpContentBlockDisplayText(update.content) !== undefined) || | ||
| 4481 | (update.sessionUpdate === "agent_message" && update.content !== undefined); | ||
| 4482 | if (!isDisplayableAssistantUpdate) { | ||
| 4483 | return; | ||
| 4484 | } | ||
| 4485 | if (subagent !== undefined) { | ||
| 4486 | yield* projectSubagentNotification(subagent, notification); | ||
| 4487 | return; | ||
| 4488 | } | ||
| 4489 | const buffered = context.pendingSubagentNotifications.get(notification.sessionId) ?? []; | ||
| 4490 | buffered.push(notification); | ||
| 4491 | context.pendingSubagentNotifications.set(notification.sessionId, buffered); | ||
| 4492 | return; | ||
| 4493 | } | ||
| 4494 | // Re-check after the activeSessionId yield: idle/prompt settle can | ||
| 4495 | // finalize the same context object while we waited. | ||
| 4496 | if (context.finalized) return; | ||
| 4497 | // The agent started its own turn about finished background work while | ||
| 4498 | // this settled root turn was still held for it. That turn is the | ||
| 4499 | // agent's reply, not more of the root: settle the root first so the | ||
| 4500 | // frame reaches the wake path and opens a continuation run. | ||
| 4501 | if ( | ||
| 4502 | context.promptSettled && | ||
| 4503 | !acpIsProviderContinuationMessage(context.input.message) && | ||
| 4504 | flavor.isProviderWakeNotification?.(notification) === true | ||
| 4505 | ) { | ||
| 4506 | if (hasDeferredBackgroundWork(context)) { | ||
| 4507 | yield* Ref.update(wakeBuffer, (current) => [...current, notification]); | ||
| 4508 | return; | ||
| 4509 | } | ||
| 4510 | yield* finalizeTurn(context, context.promptSettledStatus ?? "completed"); | ||
| 4511 | const wake = yield* bufferPostSettleWake(notification); | ||
| 4512 | if (wake.offerContinuation) { | ||
| 4513 | yield* offerContinuationRun(notification.sessionId); | ||
| 4514 | } | ||
| 4515 | return; | ||
| 4516 | } | ||
| 4517 | // Only fresh model output proves a retry recovered; progress on | ||
| 4518 | // tools and plans that started earlier can arrive mid-retry. | ||
| 4519 | if ( | ||
| 4520 | update.sessionUpdate !== "tool_call_update" && | ||
| 4521 | update.sessionUpdate !== "plan" && | ||
| 4522 | update.sessionUpdate !== "plan_update" && | ||
| 4523 | update.sessionUpdate !== "plan_removed" && | ||
| 4524 | acpRootSessionUpdateIngestsOutput(notification) | ||
| 4525 | ) { | ||
| 4526 | yield* emitProviderRetry(context, "completed"); | ||
| 4527 | } | ||
| 4528 | switch (update.sessionUpdate) { | ||
| 4529 | case "state_update": { | ||
| 4530 | const toolCallId = `${context.nativeTurnId}:requires-action`; | ||
| 4531 | const existing = context.tools.get(toolCallId); | ||
| 4532 | if (update.state === "requires_action") { | ||
| 4533 | yield* emitTool(context, { | ||
| 4534 | toolCallId, | ||
| 4535 | kind: "other", | ||
| 4536 | title: "Action required", | ||
| 4537 | status: "requiresAction", | ||
| 4538 | data: { state: "requires_action" }, | ||
| 4539 | }); | ||
| 4540 | } else if (existing?.status === "requiresAction") { | ||
| 4541 | yield* emitTool(context, { | ||
| 4542 | ...existing, | ||
| 4543 | status: "completed", | ||
| 4544 | data: { ...existing.data, state: update.state }, | ||
| 4545 | }); | ||
| 4546 | } | ||
| 4547 | break; | ||
| 4548 | } | ||
| 4549 | case "agent_message_chunk": { | ||
| 4550 | const text = acpContentBlockDisplayText(update.content); | ||
| 4551 | if (text !== undefined) { | ||
| 4552 | const startsNewAssistantSegment = context.assistant.current === null; | ||
| 4553 | // The injected-turn report is streaming; the normal debounce | ||
| 4554 | // after the last chunk takes over from here. Drop matching | ||
| 4555 | // mid-turn armed ids so finalize does not open a duplicate | ||
| 4556 | // wake after the report already projected into this turn. | ||
| 4557 | if (context.pendingInjectedReport.size > 0) { | ||
| 4558 | const reportedTaskIds = [...context.pendingInjectedReport]; | ||
| 4559 | context.pendingInjectedReport.clear(); | ||
| 4560 | yield* clearMidTurnUnreportedTaskIds(reportedTaskIds); | ||
| 4561 | } else if ( | ||
| 4562 | startsNewAssistantSegment && | ||
| 4563 | context.promptSettled && | ||
| 4564 | (yield* Ref.get(midTurnUnreportedCompletedTaskIds)).size > 0 | ||
| 4565 | ) { | ||
| 4566 | // Some ACP implementations deliver the injected assistant | ||
| 4567 | // report before the synthetic monitor end notice. Correlation | ||
| 4568 | // arrives with that notice, so remember the unmatched report | ||
| 4569 | // and consume its task id when the notice follows. | ||
| 4570 | context.earlyInjectedReportObserved = true; | ||
| 4571 | } | ||
| 4572 | yield* appendText(context, "assistant", text, update.messageId); | ||
| 4573 | } | ||
| 4574 | break; | ||
| 4575 | } | ||
| 4576 | case "agent_thought_chunk": { | ||
| 4577 | const text = acpContentBlockDisplayText(update.content); | ||
| 4578 | if (text !== undefined) { | ||
| 4579 | yield* appendText(context, "reasoning", text, update.messageId); | ||
| 4580 | } | ||
| 4581 | break; | ||
| 4582 | } | ||
| 4583 | case "user_message": | ||
| 4584 | case "agent_message": | ||
| 4585 | case "agent_thought": { | ||
| 4586 | if (update.content === undefined) break; | ||
| 4587 | const text = (update.content ?? []) | ||
| 4588 | .flatMap((content) => { | ||
| 4589 | const display = acpContentBlockDisplayText(content); | ||
| 4590 | return display === undefined ? [] : [display]; | ||
| 4591 | }) | ||
| 4592 | .join("\n"); | ||
| 4593 | yield* replaceText( | ||
| 4594 | context, | ||
| 4595 | update.sessionUpdate === "user_message" | ||
| 4596 | ? "user" | ||
| 4597 | : update.sessionUpdate === "agent_message" | ||
| 4598 | ? "assistant" | ||
| 4599 | : "reasoning", | ||
| 4600 | text, | ||
| 4601 | update.messageId, | ||
| 4602 | ); | ||
| 4603 | break; | ||
| 4604 | } | ||
| 4605 | case "tool_call_content_chunk": { | ||
| 4606 | const text = | ||
| 4607 | update.content.type === "content" | ||
| 4608 | ? acpContentBlockDisplayText(update.content.content) | ||
| 4609 | : update.content.type === "diff" | ||
| 4610 | ? "changes" in update.content | ||
| 4611 | ? update.content.patch?.text | ||
| 4612 | : update.content.newText | ||
| 4613 | : undefined; | ||
| 4614 | if (text !== undefined) { | ||
| 4615 | const previous = context.tools.get(update.toolCallId) ?? { | ||
| 4616 | toolCallId: update.toolCallId, | ||
| 4617 | status: "inProgress" as const, | ||
| 4618 | data: { toolCallId: update.toolCallId }, | ||
| 4619 | }; | ||
| 4620 | yield* emitTool(context, appendToolOutputText(previous, text)); | ||
| 4621 | } | ||
| 4622 | break; | ||
| 4623 | } | ||
| 4624 | case "compaction_summary_chunk": { | ||
| 4625 | const text = acpContentBlockDisplayText(update.content); | ||
| 4626 | if (text !== undefined) { | ||
| 4627 | const toolCallId = `acp-compaction:${update.compactionId}`; | ||
| 4628 | const previous = context.tools.get(toolCallId) ?? { | ||
| 4629 | toolCallId, | ||
| 4630 | kind: "think", | ||
| 4631 | title: "Compact context", | ||
| 4632 | status: "inProgress" as const, | ||
| 4633 | data: { toolCallId }, | ||
| 4634 | }; | ||
| 4635 | yield* emitTool(context, appendToolOutputText(previous, text)); | ||
| 4636 | } | ||
| 4637 | break; | ||
| 4638 | } | ||
| 4639 | case "user_message_chunk": { | ||
| 4640 | if (update.content.type === "text" && flavor.extractBackgroundToolMutation) { | ||
| 4641 | const mutations = flavor.extractBackgroundToolMutation(update.content.text); | ||
| 4642 | const terminalTaskIds = mutations | ||
| 4643 | .filter((mutation) => mutation.status !== "running") | ||
| 4644 | .map((mutation) => mutation.taskId); | ||
| 4645 | const reportArrivedBeforeNotice = | ||
| 4646 | context.earlyInjectedReportObserved && terminalTaskIds.length > 0; | ||
| 4647 | if (reportArrivedBeforeNotice) { | ||
| 4648 | context.earlyInjectedReportObserved = false; | ||
| 4649 | yield* clearMidTurnUnreportedTaskIds(terminalTaskIds); | ||
| 4650 | } | ||
| 4651 | for (const mutation of mutations) { | ||
| 4652 | const toolCallId = context.toolCallIdsByBackgroundTaskId.get(mutation.taskId); | ||
| 4653 | const previous = | ||
| 4654 | toolCallId !== undefined ? context.tools.get(toolCallId) : undefined; | ||
| 4655 | if (previous !== undefined) { | ||
| 4656 | let nextStatus = | ||
| 4657 | mutation.status === "running" | ||
| 4658 | ? ("inProgress" as const) | ||
| 4659 | : mutation.status === "failed" | ||
| 4660 | ? ("failed" as const) | ||
| 4661 | : ("completed" as const); | ||
| 4662 | // End notices typically omit full stdout (no get_command mention | ||
| 4663 | // either). Keep the tool running and hold finalize until | ||
| 4664 | // TaskOutput hydrates, or the safety timer force-completes. | ||
| 4665 | if (nextStatus !== "inProgress") { | ||
| 4666 | yield* markAwaitingBackgroundHydration(context, mutation.taskId); | ||
| 4667 | nextStatus = "inProgress"; | ||
| 4668 | } | ||
| 4669 | yield* emitTool( | ||
| 4670 | context, | ||
| 4671 | appendToolOutputText( | ||
| 4672 | { ...previous, status: nextStatus }, | ||
| 4673 | mutation.appendOutput, | ||
| 4674 | ), | ||
| 4675 | ); | ||
| 4676 | } | ||
| 4677 | // After emitTool: the hydration hold keeps the tool row at | ||
| 4678 | // inProgress past an end notice, but the offer gate must see | ||
| 4679 | // the mutation's semantic status. Also tracks monitors that | ||
| 4680 | // never surfaced a tool_call row at all. | ||
| 4681 | yield* applyBackgroundTaskMutationRunning(mutation); | ||
| 4682 | if (mutation.status !== "running" && !reportArrivedBeforeNotice) { | ||
| 4683 | yield* markPendingInjectedReport(context, mutation.taskId); | ||
| 4684 | } | ||
| 4685 | } | ||
| 4686 | } | ||
| 4687 | if (update.content.type === "text" && flavor.extractSubagentEndNotice) { | ||
| 4688 | const notice = flavor.extractSubagentEndNotice(update.content.text); | ||
| 4689 | const subagent = | ||
| 4690 | notice !== undefined | ||
| 4691 | ? context.subagentsBySessionId.get(notice.childSessionId) | ||
| 4692 | : undefined; | ||
| 4693 | if ( | ||
| 4694 | notice !== undefined && | ||
| 4695 | subagent !== undefined && | ||
| 4696 | (subagent.task.status === "running" || subagent.task.status === "pending") | ||
| 4697 | ) { | ||
| 4698 | // The agent is free to answer with text only and never call | ||
| 4699 | // get_command_or_subagent_output; without this, the subagent | ||
| 4700 | // row stays running and holds deferred finalize open forever. | ||
| 4701 | yield* emitSubagent(context, { | ||
| 4702 | nativeTaskId: subagent.task.nativeTaskRef?.nativeId ?? notice.childSessionId, | ||
| 4703 | prompt: subagent.task.prompt, | ||
| 4704 | title: subagent.task.title, | ||
| 4705 | model: subagent.task.model, | ||
| 4706 | status: notice.status, | ||
| 4707 | childSessionId: notice.childSessionId, | ||
| 4708 | result: null, | ||
| 4709 | suppressNormalTool: true, | ||
| 4710 | }); | ||
| 4711 | } | ||
| 4712 | } | ||
| 4713 | // startTurn already projects the submitted prompt. Active ACP | ||
| 4714 | // user chunks are transport echoes or injected background | ||
| 4715 | // notices, so only their semantic mutations belong in the turn. | ||
| 4716 | break; | ||
| 4717 | } | ||
| 4718 | default: { | ||
| 4719 | const parsed = parseSessionUpdateEvent(notification); | ||
| 4720 | for (const event of parsed.events) { | ||
| 4721 | if (event._tag === "ToolCallUpdated") { | ||
| 4722 | yield* emitTool(context, event.toolCall); | ||
| 4723 | } else if (event._tag === "PlanUpdated") { | ||
| 4724 | yield* emitPlan(context, event.payload); | ||
| 4725 | } else if (event._tag === "UnknownUpdate") { | ||
| 4726 | yield* emitTool(context, { | ||
| 4727 | toolCallId: `${context.nativeTurnId}:unsupported:${event.updateType}`, | ||
| 4728 | kind: "other", | ||
| 4729 | title: `Unsupported ACP update: ${event.updateType}`, | ||
| 4730 | status: "completed", | ||
| 4731 | data: { updateType: event.updateType }, | ||
| 4732 | }); | ||
| 4733 | } | ||
| 4734 | } | ||
| 4735 | } | ||
| 4736 | } | ||
| 4737 | // Keep deferred finalize quiet-window fresh while wake traffic lands. | ||
| 4738 | yield* rearmDeferredFinalize(context); | ||
| 4739 | }); | ||
| 4740 | |||
| 4741 | const activeContext = Effect.gen(function* () { | ||
| 4742 | const context = yield* Ref.get(activeTurn); | ||
| 4743 | if (context === null) { | ||
| 4744 | return yield* new EffectAcpErrors.AcpTransportError({ | ||
| 4745 | detail: "ACP agent requested input without an active turn", | ||
| 4746 | cause: "No active ACP turn", | ||
| 4747 | }); | ||
| 4748 | } | ||
| 4749 | return context; | ||
| 4750 | }); | ||
| 4751 | |||
| 4752 | const beginApprovalRequest = Effect.fnUntraced(function* ( | ||
| 4753 | context: ActiveAcpTurn, | ||
| 4754 | params: EffectAcpSchema.RequestPermissionRequest, | ||
| 4755 | generation: number, | ||
| 4756 | transportRequestId: string, | ||
| 4757 | ) { | ||
| 4758 | yield* closeTextStreams(context); | ||
| 4759 | const parsed = parsePermissionRequest(params); | ||
| 4760 | const approvalOptions = flavor.approvalOptions?.(params); | ||
| 4761 | const nativeRequestId = params.toolCall.toolCallId; | ||
| 4762 | const requestId = yield* idAllocator.allocate.runtimeRequest({ | ||
| 4763 | driver, | ||
| 4764 | providerTurnId: context.providerTurnId, | ||
| 4765 | nativeRequestId, | ||
| 4766 | }); | ||
| 4767 | const decision = yield* Deferred.make<ProviderApprovalDecision>(); | ||
| 4768 | const nativeResponseAcknowledgement = yield* Deferred.make< | ||
| 4769 | void, | ||
| 4770 | EffectAcpErrors.AcpError | ||
| 4771 | >(); | ||
| 4772 | yield* registerNativeResponseAcknowledgement( | ||
| 4773 | generation, | ||
| 4774 | transportRequestId, | ||
| 4775 | nativeResponseAcknowledgement, | ||
| 4776 | ); | ||
| 4777 | const now = yield* DateTime.now; | ||
| 4778 | const nodeId = idAllocator.derive.approvalNode({ requestId }); | ||
| 4779 | const requestKind = providerRequestKind(parsed.kind); | ||
| 4780 | const nativeItemRef = { driver, nativeId: nativeRequestId, strength: "weak" as const }; | ||
| 4781 | const ordinal = yield* resolveItemOrdinal( | ||
| 4782 | context, | ||
| 4783 | `${context.nativeTurnId}:approval:${nativeRequestId}`, | ||
| 4784 | ); | ||
| 4785 | const runtimeRequest: OrchestrationV2RuntimeRequest = { | ||
| 4786 | id: requestId, | ||
| 4787 | nodeId, | ||
| 4788 | providerTurnId: context.providerTurnId, | ||
| 4789 | nativeRequestRef: nativeItemRef, | ||
| 4790 | kind: requestKind, | ||
| 4791 | status: "pending", | ||
| 4792 | responseCapability: { | ||
| 4793 | type: "live", | ||
| 4794 | providerSessionId: input.providerSessionId, | ||
| 4795 | }, | ||
| 4796 | createdAt: now, | ||
| 4797 | resolvedAt: null, | ||
| 4798 | }; | ||
| 4799 | const node: OrchestrationV2ExecutionNode = { | ||
| 4800 | id: nodeId, | ||
| 4801 | threadId: context.input.threadId, | ||
| 4802 | runId: context.input.runId, | ||
| 4803 | parentNodeId: context.input.rootNodeId, | ||
| 4804 | rootNodeId: context.input.rootNodeId, | ||
| 4805 | kind: "approval_request", | ||
| 4806 | status: "waiting", | ||
| 4807 | countsForRun: false, | ||
| 4808 | providerThreadId: context.input.providerThread.id, | ||
| 4809 | providerTurnId: context.providerTurnId, | ||
| 4810 | nativeItemRef, | ||
| 4811 | runtimeRequestId: requestId, | ||
| 4812 | checkpointScopeId: null, | ||
| 4813 | startedAt: now, | ||
| 4814 | completedAt: null, | ||
| 4815 | }; | ||
| 4816 | const turnItem: OrchestrationV2TurnItem = { | ||
| 4817 | id: idAllocator.derive.approvalTurnItem({ requestId }), | ||
| 4818 | threadId: context.input.threadId, | ||
| 4819 | runId: context.input.runId, | ||
| 4820 | nodeId, | ||
| 4821 | providerThreadId: context.input.providerThread.id, | ||
| 4822 | providerTurnId: context.providerTurnId, | ||
| 4823 | nativeItemRef, | ||
| 4824 | parentItemId: null, | ||
| 4825 | ordinal, | ||
| 4826 | status: "waiting", | ||
| 4827 | title: null, | ||
| 4828 | startedAt: now, | ||
| 4829 | completedAt: null, | ||
| 4830 | updatedAt: now, | ||
| 4831 | type: "approval_request", | ||
| 4832 | requestId, | ||
| 4833 | requestKind, | ||
| 4834 | ...(parsed.detail === undefined ? {} : { prompt: parsed.detail }), | ||
| 4835 | ...(approvalOptions === undefined ? {} : { options: approvalOptions }), | ||
| 4836 | }; | ||
| 4837 | yield* Ref.update(pendingRuntimeRequests, (current) => { | ||
| 4838 | const updated = new Map(current); | ||
| 4839 | updated.set(String(requestId), { | ||
| 4840 | type: "approval", | ||
| 4841 | generation, | ||
| 4842 | nativeResponseAcknowledgement, | ||
| 4843 | requestId, | ||
| 4844 | transportRequestId, | ||
| 4845 | decision, | ||
| 4846 | runtimeRequest, | ||
| 4847 | node, | ||
| 4848 | turnItem, | ||
| 4849 | }); | ||
| 4850 | return updated; | ||
| 4851 | }); | ||
| 4852 | yield* emitProviderEvent({ | ||
| 4853 | type: "node.updated", | ||
| 4854 | driver, | ||
| 4855 | node, | ||
| 4856 | }); | ||
| 4857 | yield* emitProviderEvent({ | ||
| 4858 | type: "runtime_request.updated", | ||
| 4859 | driver, | ||
| 4860 | threadId: context.input.threadId, | ||
| 4861 | runtimeRequest, | ||
| 4862 | }); | ||
| 4863 | yield* emitProviderEvent({ | ||
| 4864 | type: "turn_item.updated", | ||
| 4865 | driver, | ||
| 4866 | turnItem, | ||
| 4867 | }); | ||
| 4868 | return { | ||
| 4869 | context, | ||
| 4870 | decision, | ||
| 4871 | nativeResponseAcknowledgement, | ||
| 4872 | requestId, | ||
| 4873 | transportRequestId, | ||
| 4874 | } as const; | ||
| 4875 | }); | ||
| 4876 | |||
| 4877 | const beginUserInputRequest = Effect.fnUntraced(function* ( | ||
| 4878 | request: AcpAdapterV2UserInputRequest, | ||
| 4879 | generation: number, | ||
| 4880 | transportRequestId: string, | ||
| 4881 | ) { | ||
| 4882 | const context = yield* activeContext; | ||
| 4883 | yield* closeTextStreams(context); | ||
| 4884 | const requestId = yield* idAllocator.allocate.runtimeRequest({ | ||
| 4885 | driver, | ||
| 4886 | providerTurnId: context.providerTurnId, | ||
| 4887 | nativeRequestId: request.nativeRequestId, | ||
| 4888 | }); | ||
| 4889 | const answers = yield* Deferred.make<ProviderUserInputAnswers | null>(); | ||
| 4890 | const nativeResponseAcknowledgement = yield* Deferred.make< | ||
| 4891 | void, | ||
| 4892 | EffectAcpErrors.AcpError | ||
| 4893 | >(); | ||
| 4894 | yield* registerNativeResponseAcknowledgement( | ||
| 4895 | generation, | ||
| 4896 | transportRequestId, | ||
| 4897 | nativeResponseAcknowledgement, | ||
| 4898 | ); | ||
| 4899 | const now = yield* DateTime.now; | ||
| 4900 | const nodeId = providerNodeId(request.nativeItemId); | ||
| 4901 | const turnItemId = providerTurnItemId(request.nativeItemId); | ||
| 4902 | const nativeItemRef = { | ||
| 4903 | driver, | ||
| 4904 | nativeId: request.nativeItemId, | ||
| 4905 | strength: "weak" as const, | ||
| 4906 | }; | ||
| 4907 | const ordinal = yield* resolveItemOrdinal(context, request.nativeItemId); | ||
| 4908 | const runtimeRequest: OrchestrationV2RuntimeRequest = { | ||
| 4909 | id: requestId, | ||
| 4910 | nodeId, | ||
| 4911 | providerTurnId: context.providerTurnId, | ||
| 4912 | nativeRequestRef: { | ||
| 4913 | driver, | ||
| 4914 | nativeId: request.nativeRequestId, | ||
| 4915 | strength: "weak", | ||
| 4916 | }, | ||
| 4917 | kind: "user_input", | ||
| 4918 | status: "pending", | ||
| 4919 | responseCapability: { | ||
| 4920 | type: "live", | ||
| 4921 | providerSessionId: input.providerSessionId, | ||
| 4922 | }, | ||
| 4923 | createdAt: now, | ||
| 4924 | resolvedAt: null, | ||
| 4925 | }; | ||
| 4926 | const node: OrchestrationV2ExecutionNode = { | ||
| 4927 | id: nodeId, | ||
| 4928 | threadId: context.input.threadId, | ||
| 4929 | runId: context.input.runId, | ||
| 4930 | parentNodeId: context.input.rootNodeId, | ||
| 4931 | rootNodeId: context.input.rootNodeId, | ||
| 4932 | kind: "user_input_request", | ||
| 4933 | status: "waiting", | ||
| 4934 | countsForRun: false, | ||
| 4935 | providerThreadId: context.input.providerThread.id, | ||
| 4936 | providerTurnId: context.providerTurnId, | ||
| 4937 | nativeItemRef, | ||
| 4938 | runtimeRequestId: requestId, | ||
| 4939 | checkpointScopeId: null, | ||
| 4940 | startedAt: now, | ||
| 4941 | completedAt: null, | ||
| 4942 | }; | ||
| 4943 | const turnItem: OrchestrationV2TurnItem = { | ||
| 4944 | id: turnItemId, | ||
| 4945 | threadId: context.input.threadId, | ||
| 4946 | runId: context.input.runId, | ||
| 4947 | nodeId, | ||
| 4948 | providerThreadId: context.input.providerThread.id, | ||
| 4949 | providerTurnId: context.providerTurnId, | ||
| 4950 | nativeItemRef, | ||
| 4951 | parentItemId: null, | ||
| 4952 | ordinal, | ||
| 4953 | status: "waiting", | ||
| 4954 | title: null, | ||
| 4955 | startedAt: now, | ||
| 4956 | completedAt: null, | ||
| 4957 | updatedAt: now, | ||
| 4958 | type: "user_input_request", | ||
| 4959 | requestId, | ||
| 4960 | questions: [...request.questions], | ||
| 4961 | }; | ||
| 4962 | yield* Ref.update(pendingRuntimeRequests, (current) => { | ||
| 4963 | const updated = new Map(current); | ||
| 4964 | updated.set(String(requestId), { | ||
| 4965 | type: "user_input", | ||
| 4966 | generation, | ||
| 4967 | nativeResponseAcknowledgement, | ||
| 4968 | requestId, | ||
| 4969 | transportRequestId, | ||
| 4970 | answers, | ||
| 4971 | runtimeRequest, | ||
| 4972 | node, | ||
| 4973 | turnItem, | ||
| 4974 | }); | ||
| 4975 | return updated; | ||
| 4976 | }); | ||
| 4977 | yield* emitProviderEvent({ | ||
| 4978 | type: "node.updated", | ||
| 4979 | driver, | ||
| 4980 | node, | ||
| 4981 | }); | ||
| 4982 | yield* emitProviderEvent({ | ||
| 4983 | type: "runtime_request.updated", | ||
| 4984 | driver, | ||
| 4985 | threadId: context.input.threadId, | ||
| 4986 | runtimeRequest, | ||
| 4987 | }); | ||
| 4988 | yield* emitProviderEvent({ | ||
| 4989 | type: "turn_item.updated", | ||
| 4990 | driver, | ||
| 4991 | turnItem, | ||
| 4992 | }); | ||
| 4993 | return { | ||
| 4994 | answers, | ||
| 4995 | context, | ||
| 4996 | nativeResponseAcknowledgement, | ||
| 4997 | requestId, | ||
| 4998 | transportRequestId, | ||
| 4999 | } as const; | ||
| 5000 | }); | ||
| 5001 | |||
| 5002 | const requestUserInputWithAdmission = ( | ||
| 5003 | generation: number, | ||
| 5004 | request: Effect.Effect<AcpAdapterV2UserInputRequest>, | ||
| 5005 | transportRequestId: string, | ||
| 5006 | ) => | ||
| 5007 | runRuntimeCallbackAtGeneration( | ||
| 5008 | generation, | ||
| 5009 | request.pipe( | ||
| 5010 | Effect.flatMap((value) => | ||
| 5011 | beginUserInputRequest(value, generation, transportRequestId), | ||
| 5012 | ), | ||
| 5013 | ), | ||
| 5014 | ).pipe( | ||
| 5015 | Effect.flatMap((pending) => { | ||
| 5016 | if (Option.isNone(pending)) return Effect.never; | ||
| 5017 | const { answers, requestId, transportRequestId } = pending.value; | ||
| 5018 | return Deferred.await(answers).pipe( | ||
| 5019 | Effect.flatMap((result) => | ||
| 5020 | runRuntimeCallbackAtGeneration(generation, Effect.succeed(result)).pipe( | ||
| 5021 | Effect.flatMap((checked) => | ||
| 5022 | Option.isSome(checked) | ||
| 5023 | ? Effect.succeed({ | ||
| 5024 | acknowledgeNativeResponse: acknowledgeNativeResponse( | ||
| 5025 | generation, | ||
| 5026 | transportRequestId, | ||
| 5027 | ), | ||
| 5028 | answers: checked.value, | ||
| 5029 | }) | ||
| 5030 | : Effect.never, | ||
| 5031 | ), | ||
| 5032 | ), | ||
| 5033 | ), | ||
| 5034 | Effect.ensuring( | ||
| 5035 | runRuntimeCallbackAtGeneration( | ||
| 5036 | generation, | ||
| 5037 | Effect.gen(function* () { | ||
| 5038 | yield* Ref.update(pendingRuntimeRequests, (current) => { | ||
| 5039 | const updated = new Map(current); | ||
| 5040 | updated.delete(String(requestId)); | ||
| 5041 | return updated; | ||
| 5042 | }); | ||
| 5043 | }), | ||
| 5044 | ).pipe(Effect.asVoid), | ||
| 5045 | ), | ||
| 5046 | ); | ||
| 5047 | }), | ||
| 5048 | Effect.mapError( | ||
| 5049 | (cause) => | ||
| 5050 | new EffectAcpErrors.AcpTransportError({ | ||
| 5051 | detail: "Failed to handle ACP user input request", | ||
| 5052 | cause, | ||
| 5053 | }), | ||
| 5054 | ), | ||
| 5055 | ); | ||
| 5056 | |||
| 5057 | const cancelPendingRuntimeRequests = Effect.fnUntraced(function* () { | ||
| 5058 | const requests = yield* Ref.modify(pendingRuntimeRequests, (current) => [ | ||
| 5059 | [...current.values()], | ||
| 5060 | new Map<string, PendingRuntimeRequest>(), | ||
| 5061 | ]); | ||
| 5062 | if (requests.length === 0) return; | ||
| 5063 | |||
| 5064 | const now = yield* DateTime.now; | ||
| 5065 | yield* Effect.forEach( | ||
| 5066 | requests, | ||
| 5067 | (request) => | ||
| 5068 | Effect.gen(function* () { | ||
| 5069 | const cancelled = yield* request.type === "approval" | ||
| 5070 | ? Deferred.succeed(request.decision, "cancel") | ||
| 5071 | : Deferred.succeed(request.answers, null); | ||
| 5072 | if (!cancelled) return; | ||
| 5073 | |||
| 5074 | yield* emitProviderEvent({ | ||
| 5075 | type: "runtime_request.updated", | ||
| 5076 | driver, | ||
| 5077 | threadId: request.node.threadId, | ||
| 5078 | runtimeRequest: { | ||
| 5079 | ...request.runtimeRequest, | ||
| 5080 | status: "cancelled", | ||
| 5081 | resolvedAt: now, | ||
| 5082 | }, | ||
| 5083 | }); | ||
| 5084 | yield* emitProviderEvent({ | ||
| 5085 | type: "node.updated", | ||
| 5086 | driver, | ||
| 5087 | node: { | ||
| 5088 | ...request.node, | ||
| 5089 | status: "cancelled", | ||
| 5090 | completedAt: now, | ||
| 5091 | }, | ||
| 5092 | }); | ||
| 5093 | yield* emitProviderEvent({ | ||
| 5094 | type: "turn_item.updated", | ||
| 5095 | driver, | ||
| 5096 | turnItem: { | ||
| 5097 | ...request.turnItem, | ||
| 5098 | status: "cancelled", | ||
| 5099 | completedAt: now, | ||
| 5100 | updatedAt: now, | ||
| 5101 | }, | ||
| 5102 | }); | ||
| 5103 | }), | ||
| 5104 | { concurrency: 1, discard: true }, | ||
| 5105 | ); | ||
| 5106 | }); | ||
| 5107 | |||
| 5108 | /** | ||
| 5109 | * Mutate a carryover subagent's in-memory task status without projecting. | ||
| 5110 | * Used after a non-completed root whose original run subscriber is no | ||
| 5111 | * longer guaranteed to ingest the terminal event. | ||
| 5112 | */ | ||
| 5113 | const mutateCarryoverSubagentStatus = Effect.fnUntraced(function* ( | ||
| 5114 | subagent: ActiveAcpSubagent, | ||
| 5115 | status: OrchestrationV2Subagent["status"], | ||
| 5116 | resultOverride?: string | null, | ||
| 5117 | ) { | ||
| 5118 | const now = yield* DateTime.now; | ||
| 5119 | const result = | ||
| 5120 | resultOverride !== undefined && resultOverride !== null && resultOverride.length > 0 | ||
| 5121 | ? resultOverride | ||
| 5122 | : (subagent.task.result ?? (subagent.assistantText || null)); | ||
| 5123 | const completedAt = acpSubagentStatusIsTerminal(status) ? now : null; | ||
| 5124 | subagent.task = { | ||
| 5125 | ...subagent.task, | ||
| 5126 | status, | ||
| 5127 | result, | ||
| 5128 | completedAt, | ||
| 5129 | updatedAt: now, | ||
| 5130 | }; | ||
| 5131 | }); | ||
| 5132 | |||
| 5133 | /** | ||
| 5134 | * Project a carryover subagent status change without an ActiveAcpTurn. | ||
| 5135 | * Shared by completed-root post-settle completion, deferred attach, and | ||
| 5136 | * Direct Stop terminalization. | ||
| 5137 | */ | ||
| 5138 | const projectCarryoverSubagentStatus = Effect.fnUntraced(function* ( | ||
| 5139 | subagent: ActiveAcpSubagent, | ||
| 5140 | status: OrchestrationV2Subagent["status"], | ||
| 5141 | resultOverride?: string | null, | ||
| 5142 | ) { | ||
| 5143 | yield* mutateCarryoverSubagentStatus(subagent, status, resultOverride); | ||
| 5144 | const now = subagent.task.updatedAt; | ||
| 5145 | const nativeTaskId = subagent.task.nativeTaskRef?.nativeId ?? subagent.task.id; | ||
| 5146 | const nativeItemRef = { | ||
| 5147 | driver, | ||
| 5148 | nativeId: nativeTaskId, | ||
| 5149 | strength: "strong" as const, | ||
| 5150 | }; | ||
| 5151 | const parentProviderThreadId = subagent.parentProviderThreadId; | ||
| 5152 | const result = subagent.task.result; | ||
| 5153 | const completedAt = subagent.task.completedAt; | ||
| 5154 | yield* emitProviderEvent({ | ||
| 5155 | type: "node.updated", | ||
| 5156 | driver, | ||
| 5157 | node: { | ||
| 5158 | id: subagent.task.id, | ||
| 5159 | threadId: subagent.task.threadId, | ||
| 5160 | runId: subagent.task.runId, | ||
| 5161 | parentNodeId: subagent.task.parentNodeId, | ||
| 5162 | rootNodeId: subagent.task.parentNodeId, | ||
| 5163 | kind: "subagent", | ||
| 5164 | status, | ||
| 5165 | countsForRun: false, | ||
| 5166 | providerThreadId: parentProviderThreadId, | ||
| 5167 | providerTurnId: subagent.providerTurnId, | ||
| 5168 | nativeItemRef, | ||
| 5169 | runtimeRequestId: null, | ||
| 5170 | checkpointScopeId: null, | ||
| 5171 | startedAt: subagent.task.startedAt, | ||
| 5172 | completedAt, | ||
| 5173 | }, | ||
| 5174 | }); | ||
| 5175 | yield* emitProviderEvent({ | ||
| 5176 | type: "node.updated", | ||
| 5177 | driver, | ||
| 5178 | node: { | ||
| 5179 | id: subagent.childRootNodeId, | ||
| 5180 | threadId: subagent.childThreadId, | ||
| 5181 | runId: null, | ||
| 5182 | parentNodeId: null, | ||
| 5183 | rootNodeId: subagent.childRootNodeId, | ||
| 5184 | kind: "root_turn", | ||
| 5185 | status, | ||
| 5186 | countsForRun: false, | ||
| 5187 | providerThreadId: subagent.task.providerThreadId, | ||
| 5188 | providerTurnId: null, | ||
| 5189 | nativeItemRef, | ||
| 5190 | runtimeRequestId: null, | ||
| 5191 | checkpointScopeId: null, | ||
| 5192 | startedAt: subagent.task.startedAt, | ||
| 5193 | completedAt, | ||
| 5194 | }, | ||
| 5195 | }); | ||
| 5196 | yield* emitProviderEvent({ | ||
| 5197 | type: "subagent.updated", | ||
| 5198 | driver, | ||
| 5199 | subagent: subagent.task, | ||
| 5200 | }); | ||
| 5201 | yield* emitProviderEvent({ | ||
| 5202 | type: "turn_item.updated", | ||
| 5203 | driver, | ||
| 5204 | turnItem: { | ||
| 5205 | id: subagent.turnItemId, | ||
| 5206 | threadId: subagent.task.threadId, | ||
| 5207 | runId: subagent.task.runId, | ||
| 5208 | nodeId: subagent.task.id, | ||
| 5209 | providerThreadId: parentProviderThreadId, | ||
| 5210 | providerTurnId: subagent.providerTurnId, | ||
| 5211 | nativeItemRef, | ||
| 5212 | parentItemId: null, | ||
| 5213 | ordinal: subagent.turnItemOrdinal, | ||
| 5214 | status, | ||
| 5215 | title: subagent.task.title, | ||
| 5216 | startedAt: subagent.task.startedAt, | ||
| 5217 | completedAt, | ||
| 5218 | updatedAt: now, | ||
| 5219 | type: "subagent", | ||
| 5220 | subagentId: subagent.task.id, | ||
| 5221 | origin: "provider_native", | ||
| 5222 | driver, | ||
| 5223 | providerInstanceId: subagent.task.providerInstanceId, | ||
| 5224 | childThreadId: subagent.childThreadId, | ||
| 5225 | prompt: subagent.task.prompt, | ||
| 5226 | result, | ||
| 5227 | }, | ||
| 5228 | }); | ||
| 5229 | if (acpSubagentStatusIsTerminal(status)) { | ||
| 5230 | subagent.terminalStatusProjected = true; | ||
| 5231 | } | ||
| 5232 | }); | ||
| 5233 | |||
| 5234 | /** | ||
| 5235 | * Update a carryover subagent matched by native task id or child session | ||
| 5236 | * id. Post-settle completions must flip the pin in hasPendingBackgroundWork | ||
| 5237 | * without waiting for a new user turn to consume carryover. | ||
| 5238 | * When `project` is false, only the in-memory carryover status advances | ||
| 5239 | * (the next attach projects the terminal state). | ||
| 5240 | * If status already matches but projection was deferred, a later | ||
| 5241 | * project:true caller still projects once via terminalStatusProjected. | ||
| 5242 | */ | ||
| 5243 | const updateCarryoverSubagentStatus = Effect.fnUntraced(function* ( | ||
| 5244 | nativeIdOrChildSessionId: string, | ||
| 5245 | status: OrchestrationV2Subagent["status"], | ||
| 5246 | result?: string | null, | ||
| 5247 | options?: { readonly project?: boolean }, | ||
| 5248 | ) { | ||
| 5249 | const carryover = yield* Ref.get(carryoverSubagents); | ||
| 5250 | if (carryover === null) return false; | ||
| 5251 | const match = carryover.subagents.find((subagent) => { | ||
| 5252 | const nativeId = subagent.task.nativeTaskRef?.nativeId ?? String(subagent.task.id); | ||
| 5253 | return ( | ||
| 5254 | nativeId === nativeIdOrChildSessionId || | ||
| 5255 | subagent.childSessionId === nativeIdOrChildSessionId | ||
| 5256 | ); | ||
| 5257 | }); | ||
| 5258 | if (match === undefined) return false; | ||
| 5259 | const shouldProject = options?.project !== false; | ||
| 5260 | if (match.task.status === status) { | ||
| 5261 | // Already at this status: only project if requested and not yet done. | ||
| 5262 | if (!shouldProject || match.terminalStatusProjected) { | ||
| 5263 | return true; | ||
| 5264 | } | ||
| 5265 | yield* projectCarryoverSubagentStatus(match, status, result); | ||
| 5266 | return true; | ||
| 5267 | } | ||
| 5268 | // Only advance nonterminal entries; do not resurrect a terminal one. | ||
| 5269 | if (match.task.status !== "running" && match.task.status !== "pending") { | ||
| 5270 | return false; | ||
| 5271 | } | ||
| 5272 | if (!shouldProject) { | ||
| 5273 | yield* mutateCarryoverSubagentStatus(match, status, result); | ||
| 5274 | } else { | ||
| 5275 | yield* projectCarryoverSubagentStatus(match, status, result); | ||
| 5276 | } | ||
| 5277 | return true; | ||
| 5278 | }); | ||
| 5279 | |||
| 5280 | const finishSubagentFromNotice = Effect.fnUntraced(function* (notice: { | ||
| 5281 | readonly childSessionId: string; | ||
| 5282 | readonly status: "completed" | "failed" | "cancelled"; | ||
| 5283 | readonly result: string | null; | ||
| 5284 | }) { | ||
| 5285 | const context = yield* Ref.get(activeTurn); | ||
| 5286 | const subagent = | ||
| 5287 | context === null ? undefined : context.subagentsBySessionId.get(notice.childSessionId); | ||
| 5288 | const known = | ||
| 5289 | subagent ?? | ||
| 5290 | (yield* Ref.get(carryoverSubagents))?.subagents.find( | ||
| 5291 | (candidate) => | ||
| 5292 | candidate.childSessionId === notice.childSessionId || | ||
| 5293 | candidate.task.nativeTaskRef?.nativeId === notice.childSessionId, | ||
| 5294 | ); | ||
| 5295 | if (known !== undefined && acpSubagentStatusBlocksTurnSettlement(known.task.status)) { | ||
| 5296 | yield* recordWakeReport(notice.childSessionId, { | ||
| 5297 | kind: "subagent", | ||
| 5298 | label: known.task.title ?? known.task.prompt, | ||
| 5299 | outcome: notice.status, | ||
| 5300 | childThreadId: known.childThreadId, | ||
| 5301 | }); | ||
| 5302 | } | ||
| 5303 | if (context !== null && subagent !== undefined && !context.finalized) { | ||
| 5304 | if (!acpSubagentStatusBlocksTurnSettlement(subagent.task.status)) return; | ||
| 5305 | yield* emitSubagent(context, { | ||
| 5306 | nativeTaskId: subagent.task.nativeTaskRef?.nativeId ?? notice.childSessionId, | ||
| 5307 | prompt: subagent.task.prompt, | ||
| 5308 | title: subagent.task.title, | ||
| 5309 | model: subagent.task.model, | ||
| 5310 | status: notice.status, | ||
| 5311 | childSessionId: notice.childSessionId, | ||
| 5312 | result: notice.result, | ||
| 5313 | suppressNormalTool: true, | ||
| 5314 | }); | ||
| 5315 | yield* rearmDeferredFinalize(context); | ||
| 5316 | return; | ||
| 5317 | } | ||
| 5318 | // The root turn already settled: the subagent is carryover. Project | ||
| 5319 | // its end while the completed root still owns the run. | ||
| 5320 | const carryover = yield* Ref.get(carryoverSubagents); | ||
| 5321 | yield* updateCarryoverSubagentStatus( | ||
| 5322 | notice.childSessionId, | ||
| 5323 | notice.status, | ||
| 5324 | notice.result, | ||
| 5325 | { | ||
| 5326 | project: | ||
| 5327 | carryover !== null && | ||
| 5328 | carryover.sessionId === (yield* Ref.get(activeSessionId)) && | ||
| 5329 | carryover.rootTerminalStatus === "completed", | ||
| 5330 | }, | ||
| 5331 | ); | ||
| 5332 | }); | ||
| 5333 | |||
| 5334 | applyFinalizedActiveTurnSubagentTerminal = Effect.fnUntraced(function* ( | ||
| 5335 | context: ActiveAcpTurn, | ||
| 5336 | notification: EffectAcpSchema.SessionNotification, | ||
| 5337 | ) { | ||
| 5338 | if (flavor.extractSubagentUpdate === undefined) return false; | ||
| 5339 | |||
| 5340 | const applyTerminal = Effect.fnUntraced(function* ( | ||
| 5341 | subagent: ActiveAcpSubagent, | ||
| 5342 | update: AcpAdapterV2SubagentUpdate, | ||
| 5343 | ) { | ||
| 5344 | if (!acpSubagentStatusIsTerminal(update.status)) return false; | ||
| 5345 | if (acpSubagentStatusIsTerminal(subagent.task.status)) { | ||
| 5346 | if (subagent.terminalStatusProjected || context.finalizedStatus !== "completed") { | ||
| 5347 | return true; | ||
| 5348 | } | ||
| 5349 | } | ||
| 5350 | if (context.finalizedStatus === "completed") { | ||
| 5351 | yield* emitSubagent(context, update); | ||
| 5352 | } else { | ||
| 5353 | yield* mutateCarryoverSubagentStatus(subagent, update.status, update.result); | ||
| 5354 | } | ||
| 5355 | return true; | ||
| 5356 | }); | ||
| 5357 | |||
| 5358 | const sessionUpdate = notification.update; | ||
| 5359 | if ( | ||
| 5360 | sessionUpdate.sessionUpdate === "tool_call" || | ||
| 5361 | sessionUpdate.sessionUpdate === "tool_call_update" | ||
| 5362 | ) { | ||
| 5363 | for (const event of parseSessionUpdateEvent(notification).events) { | ||
| 5364 | if (event._tag !== "ToolCallUpdated") continue; | ||
| 5365 | const toolCall = flavor.normalizeToolCall?.(event.toolCall) ?? event.toolCall; | ||
| 5366 | const subagentUpdate = flavor.extractSubagentUpdate(toolCall); | ||
| 5367 | if ( | ||
| 5368 | subagentUpdate === undefined || | ||
| 5369 | !acpSubagentStatusIsTerminal(subagentUpdate.status) | ||
| 5370 | ) { | ||
| 5371 | continue; | ||
| 5372 | } | ||
| 5373 | const subagent = | ||
| 5374 | context.subagents.get(subagentUpdate.nativeTaskId) ?? | ||
| 5375 | (subagentUpdate.childSessionId === null | ||
| 5376 | ? undefined | ||
| 5377 | : context.subagentsBySessionId.get(subagentUpdate.childSessionId)) ?? | ||
| 5378 | context.subagentsBySessionId.get(notification.sessionId); | ||
| 5379 | if (subagent === undefined) continue; | ||
| 5380 | const nativeTaskId = | ||
| 5381 | subagent.task.nativeTaskRef?.nativeId ?? String(subagent.task.id); | ||
| 5382 | if ( | ||
| 5383 | subagentUpdate.nativeTaskId !== nativeTaskId && | ||
| 5384 | (subagentUpdate.childSessionId === null || | ||
| 5385 | (subagentUpdate.childSessionId !== subagent.childSessionId && | ||
| 5386 | subagentUpdate.childSessionId !== notification.sessionId)) | ||
| 5387 | ) { | ||
| 5388 | continue; | ||
| 5389 | } | ||
| 5390 | if (yield* applyTerminal(subagent, subagentUpdate)) return true; | ||
| 5391 | } | ||
| 5392 | } | ||
| 5393 | |||
| 5394 | if ( | ||
| 5395 | sessionUpdate.sessionUpdate === "user_message_chunk" && | ||
| 5396 | sessionUpdate.content.type === "text" && | ||
| 5397 | flavor.extractSubagentEndNotice !== undefined | ||
| 5398 | ) { | ||
| 5399 | const notice = flavor.extractSubagentEndNotice(sessionUpdate.content.text); | ||
| 5400 | const subagent = | ||
| 5401 | notice === undefined | ||
| 5402 | ? undefined | ||
| 5403 | : context.subagentsBySessionId.get(notice.childSessionId); | ||
| 5404 | if (notice !== undefined && subagent !== undefined) { | ||
| 5405 | return yield* applyTerminal(subagent, { | ||
| 5406 | nativeTaskId: subagent.task.nativeTaskRef?.nativeId ?? String(subagent.task.id), | ||
| 5407 | prompt: subagent.task.prompt, | ||
| 5408 | title: subagent.task.title, | ||
| 5409 | model: subagent.task.model, | ||
| 5410 | status: notice.status, | ||
| 5411 | childSessionId: notice.childSessionId, | ||
| 5412 | result: null, | ||
| 5413 | suppressNormalTool: true, | ||
| 5414 | }); | ||
| 5415 | } | ||
| 5416 | } | ||
| 5417 | |||
| 5418 | return false; | ||
| 5419 | }); | ||
| 5420 | |||
| 5421 | const projectDeferredCarryoverTerminals = Effect.fnUntraced(function* ( | ||
| 5422 | subagents: ReadonlyArray<ActiveAcpSubagent>, | ||
| 5423 | ) { | ||
| 5424 | for (const subagent of subagents) { | ||
| 5425 | if ( | ||
| 5426 | subagent.task.status === "running" || | ||
| 5427 | subagent.task.status === "pending" || | ||
| 5428 | subagent.terminalStatusProjected | ||
| 5429 | ) { | ||
| 5430 | continue; | ||
| 5431 | } | ||
| 5432 | yield* projectCarryoverSubagentStatus( | ||
| 5433 | subagent, | ||
| 5434 | subagent.task.status, | ||
| 5435 | subagent.task.result, | ||
| 5436 | ); | ||
| 5437 | } | ||
| 5438 | }); | ||
| 5439 | |||
| 5440 | /** | ||
| 5441 | * Direct Stop after a soft steer clears carryover without an active turn. | ||
| 5442 | * Emit the same interrupted terminal events terminalizeOpenRunOwnedItems | ||
| 5443 | * would have, context-free (no ActiveAcpTurn). | ||
| 5444 | */ | ||
| 5445 | const terminalizeCarryoverSubagents = Effect.fnUntraced(function* ( | ||
| 5446 | carryover: AcpCarryoverSubagents | null, | ||
| 5447 | ) { | ||
| 5448 | if (carryover === null) return; | ||
| 5449 | for (const subagent of carryover.subagents) { | ||
| 5450 | if (acpSubagentStatusIsTerminal(subagent.task.status)) { | ||
| 5451 | if (!subagent.terminalStatusProjected) { | ||
| 5452 | yield* projectCarryoverSubagentStatus( | ||
| 5453 | subagent, | ||
| 5454 | subagent.task.status, | ||
| 5455 | subagent.task.result, | ||
| 5456 | ); | ||
| 5457 | } | ||
| 5458 | continue; | ||
| 5459 | } | ||
| 5460 | yield* projectCarryoverSubagentStatus(subagent, "interrupted"); | ||
| 5461 | } | ||
| 5462 | }); | ||
| 5463 | |||
| 5464 | const projectAcpRuntimeSessionUpdateEffect = ( | ||
| 5465 | rawNotification: EffectAcpSchema.SessionNotification, | ||
| 5466 | ) => | ||
| 5467 | Effect.gen(function* () { | ||
| 5468 | if (clientTerminals !== undefined) { | ||
| 5469 | const embedded = embeddedTerminalIdsFromSessionUpdate(rawNotification); | ||
| 5470 | if (embedded !== undefined) { | ||
| 5471 | rememberEmbeddedTerminals({ | ||
| 5472 | ...embedded, | ||
| 5473 | sessionId: rawNotification.sessionId, | ||
| 5474 | }); | ||
| 5475 | } | ||
| 5476 | } | ||
| 5477 | const notification = | ||
| 5478 | clientTerminals === undefined | ||
| 5479 | ? rawNotification | ||
| 5480 | : resolveEmbeddedTerminalContent( | ||
| 5481 | rawNotification, | ||
| 5482 | clientTerminals.readOutputSnapshot, | ||
| 5483 | ); | ||
| 5484 | if (notification.update.sessionUpdate === "available_commands_update") { | ||
| 5485 | yield* ( | ||
| 5486 | flavor.onAvailableCommandsUpdate?.(notification.update.availableCommands) ?? | ||
| 5487 | Effect.void | ||
| 5488 | ); | ||
| 5489 | } | ||
| 5490 | if ( | ||
| 5491 | notification.update.sessionUpdate === "config_option_update" || | ||
| 5492 | notification.update.sessionUpdate === "current_mode_update" | ||
| 5493 | ) { | ||
| 5494 | yield* ( | ||
| 5495 | flavor.onSessionConfigurationUpdate?.( | ||
| 5496 | yield* runtime.getConfigOptions, | ||
| 5497 | yield* runtime.getModeState, | ||
| 5498 | ) ?? Effect.void | ||
| 5499 | ); | ||
| 5500 | } | ||
| 5501 | yield* handleSessionUpdate( | ||
| 5502 | flavor.normalizeSessionUpdate?.(notification) ?? notification, | ||
| 5503 | ); | ||
| 5504 | }).pipe( | ||
| 5505 | Effect.mapError( | ||
| 5506 | (cause) => | ||
| 5507 | new EffectAcpErrors.AcpTransportError({ | ||
| 5508 | detail: "Failed to project an ACP session update", | ||
| 5509 | cause, | ||
| 5510 | }), | ||
| 5511 | ), | ||
| 5512 | ); | ||
| 5513 | const projectAcpRuntimeSessionUpdate = ( | ||
| 5514 | handlerGeneration: number, | ||
| 5515 | rawNotification: EffectAcpSchema.SessionNotification, | ||
| 5516 | ) => | ||
| 5517 | runRuntimeCallbackAtGeneration( | ||
| 5518 | handlerGeneration, | ||
| 5519 | projectAcpRuntimeSessionUpdateEffect(rawNotification), | ||
| 5520 | ).pipe(Effect.asVoid); | ||
| 5521 | |||
| 5522 | // Falls back to the latest turn policy when no turn is active so | ||
| 5523 | // post-settle background work stays under the policy it started with. | ||
| 5524 | const clientPolicyContext = Effect.map(Ref.get(activeTurn), (context) => ({ | ||
| 5525 | policy: context?.input.runtimePolicy ?? latestRuntimePolicy, | ||
| 5526 | turnKey: context === null ? null : String(context.providerTurnId), | ||
| 5527 | })); | ||
| 5528 | |||
| 5529 | const denyClientRequest = (operation: string, disposition: "ask" | "deny") => | ||
| 5530 | Effect.logWarning("ACP client policy denied a client-mediated operation", { | ||
| 5531 | operation, | ||
| 5532 | disposition, | ||
| 5533 | }).pipe( | ||
| 5534 | Effect.andThen( | ||
| 5535 | Effect.fail( | ||
| 5536 | EffectAcpErrors.AcpRequestError.internalError( | ||
| 5537 | disposition === "ask" | ||
| 5538 | ? `The active T3 runtime policy requires approval for ${operation}. Request permission with session/request_permission before retrying.` | ||
| 5539 | : `The active T3 runtime policy does not allow ${operation}.`, | ||
| 5540 | ), | ||
| 5541 | ), | ||
| 5542 | ), | ||
| 5543 | ); | ||
| 5544 | |||
| 5545 | const guardClientTerminalCreate = clientPolicyContext.pipe( | ||
| 5546 | Effect.flatMap(({ policy, turnKey }) => { | ||
| 5547 | const disposition = acpClientExecuteDisposition(policy); | ||
| 5548 | if ( | ||
| 5549 | disposition === "allow" || | ||
| 5550 | (disposition === "ask" && clientPolicyGrants.allowsExecute(turnKey)) | ||
| 5551 | ) { | ||
| 5552 | return Effect.void; | ||
| 5553 | } | ||
| 5554 | return denyClientRequest("terminal/create", disposition); | ||
| 5555 | }), | ||
| 5556 | ); | ||
| 5557 | |||
| 5558 | const wireAcpRuntimeTerminalHandlers = Effect.fnUntraced(function* ( | ||
| 5559 | targetRuntime: AcpSessionRuntime.AcpSessionRuntime["Service"], | ||
| 5560 | ) { | ||
| 5561 | if (clientTerminals === undefined) return; | ||
| 5562 | yield* targetRuntime.handleCreateTerminal((request) => | ||
| 5563 | guardClientTerminalCreate.pipe(Effect.andThen(clientTerminals.create(request))), | ||
| 5564 | ); | ||
| 5565 | yield* targetRuntime.handleTerminalOutput(clientTerminals.output); | ||
| 5566 | yield* targetRuntime.handleTerminalWaitForExit(clientTerminals.waitForExit); | ||
| 5567 | yield* targetRuntime.handleTerminalKill(clientTerminals.kill); | ||
| 5568 | yield* targetRuntime.handleTerminalRelease(clientTerminals.release); | ||
| 5569 | }); | ||
| 5570 | |||
| 5571 | const wireAcpRuntimeMcpHandlers = Effect.fnUntraced(function* ( | ||
| 5572 | targetRuntime: AcpSessionRuntime.AcpSessionRuntime["Service"], | ||
| 5573 | mcpBridge: AcpMcpOverAcpBridge | undefined, | ||
| 5574 | ) { | ||
| 5575 | if (mcpBridge === undefined) return; | ||
| 5576 | const mapMcpBridgeError = Effect.mapError( | ||
| 5577 | (error: Error) => | ||
| 5578 | new EffectAcpErrors.AcpRequestError({ | ||
| 5579 | code: -32603, | ||
| 5580 | errorMessage: error.message, | ||
| 5581 | cause: error, | ||
| 5582 | }), | ||
| 5583 | ); | ||
| 5584 | yield* targetRuntime.handleMcpConnect((request) => | ||
| 5585 | mcpBridge.connect(request).pipe(mapMcpBridgeError), | ||
| 5586 | ); | ||
| 5587 | yield* targetRuntime.handleMcpMessage((request) => | ||
| 5588 | mcpBridge.message(request).pipe(mapMcpBridgeError), | ||
| 5589 | ); | ||
| 5590 | yield* targetRuntime.handleMcpNotification((request) => | ||
| 5591 | mcpBridge.notification(request).pipe(mapMcpBridgeError), | ||
| 5592 | ); | ||
| 5593 | yield* targetRuntime.handleMcpDisconnect((request) => | ||
| 5594 | mcpBridge.disconnect(request).pipe(mapMcpBridgeError), | ||
| 5595 | ); | ||
| 5596 | }); | ||
| 5597 | |||
| 5598 | const wireAcpRuntimeHandlers = Effect.fnUntraced(function* ( | ||
| 5599 | targetRuntime: AcpSessionRuntime.AcpSessionRuntime["Service"], | ||
| 5600 | handlerGeneration: number, | ||
| 5601 | handlerOptions: { | ||
| 5602 | readonly sessionUpdates?: boolean; | ||
| 5603 | readonly terminals?: boolean; | ||
| 5604 | readonly mcp?: boolean; | ||
| 5605 | } = {}, | ||
| 5606 | ) { | ||
| 5607 | yield* targetRuntime.getEvents().pipe( | ||
| 5608 | Stream.runForEach((event) => { | ||
| 5609 | if (event._tag === "EventStreamBarrier") { | ||
| 5610 | return Deferred.succeed(event.acknowledge, undefined).pipe(Effect.asVoid); | ||
| 5611 | } | ||
| 5612 | return runRuntimeCallbackAtGeneration( | ||
| 5613 | handlerGeneration, | ||
| 5614 | flavor.onSessionEvent?.(event) ?? Effect.void, | ||
| 5615 | ).pipe(Effect.asVoid); | ||
| 5616 | }), | ||
| 5617 | Effect.forkIn(runtimeScope ?? sessionScope), | ||
| 5618 | ); | ||
| 5619 | const requestUserInput = ( | ||
| 5620 | request: AcpAdapterV2UserInputRequest, | ||
| 5621 | requestContext: EffectAcpProtocol.AcpRequestContext, | ||
| 5622 | ) => | ||
| 5623 | requestUserInputWithAdmission( | ||
| 5624 | handlerGeneration, | ||
| 5625 | Effect.succeed(request), | ||
| 5626 | requestContext.requestId, | ||
| 5627 | ); | ||
| 5628 | // Without the capability no fs handler is registered, so a stray | ||
| 5629 | // request (OpenCode and Kilo send one after approved edits) gets | ||
| 5630 | // method-not-found and cannot touch the disk. A flavor that opts in | ||
| 5631 | // serves requests itself, confined to the workspace of the policy | ||
| 5632 | // active when the request arrives; the agent asks before its edits. | ||
| 5633 | const clientFileSystem = flavor.clientFileSystem; | ||
| 5634 | if (clientFileSystem !== undefined) { | ||
| 5635 | yield* targetRuntime.handleReadTextFile((request) => | ||
| 5636 | clientPolicyContext.pipe( | ||
| 5637 | Effect.flatMap(({ policy }) => clientFileSystem.readTextFile(request, policy.cwd)), | ||
| 5638 | ), | ||
| 5639 | ); | ||
| 5640 | yield* targetRuntime.handleWriteTextFile((request) => | ||
| 5641 | clientPolicyContext.pipe( | ||
| 5642 | Effect.flatMap(({ policy }) => clientFileSystem.writeTextFile(request, policy.cwd)), | ||
| 5643 | ), | ||
| 5644 | ); | ||
| 5645 | } | ||
| 5646 | if (handlerOptions.mcp !== false) { | ||
| 5647 | yield* wireAcpRuntimeMcpHandlers(targetRuntime, runtimeMcpBridge); | ||
| 5648 | } | ||
| 5649 | if (handlerOptions.terminals !== false) { | ||
| 5650 | yield* wireAcpRuntimeTerminalHandlers(targetRuntime); | ||
| 5651 | } | ||
| 5652 | if (handlerOptions.sessionUpdates !== false) { | ||
| 5653 | yield* targetRuntime.handleSessionUpdate((rawNotification) => | ||
| 5654 | projectAcpRuntimeSessionUpdate(handlerGeneration, rawNotification), | ||
| 5655 | ); | ||
| 5656 | } | ||
| 5657 | yield* targetRuntime.handleRequestPermission((params, requestContext) => | ||
| 5658 | Effect.gen(function* () { | ||
| 5659 | const transportRequestId = requestContext.requestId; | ||
| 5660 | const permissionQuestion = flavor.extractPermissionQuestion?.(params); | ||
| 5661 | if (permissionQuestion !== undefined) { | ||
| 5662 | const userInput = yield* requestUserInputWithAdmission( | ||
| 5663 | handlerGeneration, | ||
| 5664 | Effect.succeed({ | ||
| 5665 | nativeItemId: `${params.sessionId}:question:${params.toolCall.toolCallId}`, | ||
| 5666 | nativeMethod: "session/request_permission", | ||
| 5667 | nativeRequestId: params.toolCall.toolCallId, | ||
| 5668 | nativeSessionId: params.sessionId, | ||
| 5669 | questions: [permissionQuestion.question], | ||
| 5670 | }), | ||
| 5671 | transportRequestId, | ||
| 5672 | ); | ||
| 5673 | const response = | ||
| 5674 | userInput.answers === null | ||
| 5675 | ? undefined | ||
| 5676 | : permissionQuestion.respond(userInput.answers); | ||
| 5677 | yield* userInput.acknowledgeNativeResponse; | ||
| 5678 | return response ?? ({ outcome: { outcome: "cancelled" } } as const); | ||
| 5679 | } | ||
| 5680 | const admitted = yield* runRuntimeCallbackAtGeneration( | ||
| 5681 | handlerGeneration, | ||
| 5682 | Effect.gen(function* () { | ||
| 5683 | const context = yield* activeContext; | ||
| 5684 | const disposition = (flavor.permissionDisposition ?? acpPermissionDisposition)( | ||
| 5685 | context.input.runtimePolicy, | ||
| 5686 | params, | ||
| 5687 | ); | ||
| 5688 | if (disposition === "allow") { | ||
| 5689 | const optionId = selectAutoApprovedPermissionOption(params); | ||
| 5690 | return { | ||
| 5691 | _tag: "Immediate" as const, | ||
| 5692 | response: | ||
| 5693 | optionId === undefined | ||
| 5694 | ? ({ outcome: { outcome: "cancelled" } } as const) | ||
| 5695 | : ({ outcome: { outcome: "selected", optionId } } as const), | ||
| 5696 | }; | ||
| 5697 | } | ||
| 5698 | if (disposition === "deny") { | ||
| 5699 | const optionId = selectPermissionOptionId(params, "decline"); | ||
| 5700 | return { | ||
| 5701 | _tag: "Immediate" as const, | ||
| 5702 | response: | ||
| 5703 | optionId === undefined | ||
| 5704 | ? ({ outcome: { outcome: "cancelled" } } as const) | ||
| 5705 | : ({ outcome: { outcome: "selected", optionId } } as const), | ||
| 5706 | }; | ||
| 5707 | } | ||
| 5708 | return { | ||
| 5709 | _tag: "Pending" as const, | ||
| 5710 | pending: yield* beginApprovalRequest( | ||
| 5711 | context, | ||
| 5712 | params, | ||
| 5713 | handlerGeneration, | ||
| 5714 | transportRequestId, | ||
| 5715 | ), | ||
| 5716 | }; | ||
| 5717 | }), | ||
| 5718 | ); | ||
| 5719 | if (Option.isNone(admitted)) { | ||
| 5720 | return yield* Effect.never; | ||
| 5721 | } | ||
| 5722 | if (admitted.value._tag === "Immediate") { | ||
| 5723 | const response = admitted.value.response; | ||
| 5724 | const checked = yield* runRuntimeCallbackAtGeneration( | ||
| 5725 | handlerGeneration, | ||
| 5726 | Effect.gen(function* () { | ||
| 5727 | const nativeResponseAcknowledgement = yield* Deferred.make< | ||
| 5728 | void, | ||
| 5729 | EffectAcpErrors.AcpError | ||
| 5730 | >(); | ||
| 5731 | yield* registerNativeResponseAcknowledgement( | ||
| 5732 | handlerGeneration, | ||
| 5733 | transportRequestId, | ||
| 5734 | nativeResponseAcknowledgement, | ||
| 5735 | ); | ||
| 5736 | return response; | ||
| 5737 | }), | ||
| 5738 | ); | ||
| 5739 | return Option.isSome(checked) ? checked.value : yield* Effect.never; | ||
| 5740 | } | ||
| 5741 | const { | ||
| 5742 | context, | ||
| 5743 | decision: pendingDecision, | ||
| 5744 | requestId, | ||
| 5745 | transportRequestId: pendingTransportRequestId, | ||
| 5746 | } = admitted.value.pending; | ||
| 5747 | const parsedPermission = parsePermissionRequest(params); | ||
| 5748 | const decision = yield* Deferred.await(pendingDecision).pipe( | ||
| 5749 | Effect.ensuring( | ||
| 5750 | runRuntimeCallbackAtGeneration( | ||
| 5751 | handlerGeneration, | ||
| 5752 | Effect.gen(function* () { | ||
| 5753 | yield* Ref.update(pendingRuntimeRequests, (current) => { | ||
| 5754 | const updated = new Map(current); | ||
| 5755 | updated.delete(String(requestId)); | ||
| 5756 | return updated; | ||
| 5757 | }); | ||
| 5758 | }), | ||
| 5759 | ).pipe(Effect.asVoid), | ||
| 5760 | ), | ||
| 5761 | ); | ||
| 5762 | if ( | ||
| 5763 | parsedPermission.kind !== "unknown" && | ||
| 5764 | (decision === "accept" || decision === "acceptForSession") | ||
| 5765 | ) { | ||
| 5766 | clientPolicyGrants.recordApproval({ | ||
| 5767 | kind: providerRequestKind(parsedPermission.kind), | ||
| 5768 | scope: decision === "acceptForSession" ? "session" : "turn", | ||
| 5769 | turnKey: String(context.providerTurnId), | ||
| 5770 | }); | ||
| 5771 | } | ||
| 5772 | const response = (() => { | ||
| 5773 | if (decision === "cancel") { | ||
| 5774 | return { outcome: { outcome: "cancelled" } } as const; | ||
| 5775 | } | ||
| 5776 | const optionId = selectPermissionOptionId(params, decision); | ||
| 5777 | return optionId === undefined | ||
| 5778 | ? ({ outcome: { outcome: "cancelled" } } as const) | ||
| 5779 | : ({ outcome: { outcome: "selected", optionId } } as const); | ||
| 5780 | })(); | ||
| 5781 | const checked = yield* runRuntimeCallbackAtGeneration( | ||
| 5782 | handlerGeneration, | ||
| 5783 | Effect.succeed(response), | ||
| 5784 | ); | ||
| 5785 | if (Option.isNone(checked)) return yield* Effect.never; | ||
| 5786 | yield* acknowledgeNativeResponse(handlerGeneration, pendingTransportRequestId); | ||
| 5787 | return checked.value; | ||
| 5788 | }).pipe( | ||
| 5789 | Effect.mapError( | ||
| 5790 | (cause) => | ||
| 5791 | new EffectAcpErrors.AcpTransportError({ | ||
| 5792 | detail: "Failed to handle an ACP permission request", | ||
| 5793 | cause, | ||
| 5794 | }), | ||
| 5795 | ), | ||
| 5796 | ), | ||
| 5797 | ); | ||
| 5798 | yield* targetRuntime.handleElicitation((params, requestContext) => | ||
| 5799 | Effect.gen(function* () { | ||
| 5800 | const transportRequestId = requestContext.requestId; | ||
| 5801 | if ( | ||
| 5802 | params.mode === "form" && | ||
| 5803 | (unknownRecord(params._meta)?.codex_approval_kind === "mcp_tool_call" || | ||
| 5804 | transportRequestId.startsWith("mcp_tool_call_approval_")) | ||
| 5805 | ) { | ||
| 5806 | const mcpApprovalDisposition = yield* runRuntimeCallbackAtGeneration( | ||
| 5807 | handlerGeneration, | ||
| 5808 | Effect.gen(function* () { | ||
| 5809 | const context = yield* activeContext; | ||
| 5810 | const disposition = acpMcpToolApprovalElicitationDisposition( | ||
| 5811 | context.input.runtimePolicy, | ||
| 5812 | params, | ||
| 5813 | transportRequestId, | ||
| 5814 | ); | ||
| 5815 | if (disposition === undefined || disposition === "ask") { | ||
| 5816 | return disposition; | ||
| 5817 | } | ||
| 5818 | const nativeResponseAcknowledgement = yield* Deferred.make< | ||
| 5819 | void, | ||
| 5820 | EffectAcpErrors.AcpError | ||
| 5821 | >(); | ||
| 5822 | yield* registerNativeResponseAcknowledgement( | ||
| 5823 | handlerGeneration, | ||
| 5824 | transportRequestId, | ||
| 5825 | nativeResponseAcknowledgement, | ||
| 5826 | ); | ||
| 5827 | return disposition; | ||
| 5828 | }), | ||
| 5829 | ); | ||
| 5830 | if (Option.isNone(mcpApprovalDisposition)) return yield* Effect.never; | ||
| 5831 | if (mcpApprovalDisposition.value === "allow") { | ||
| 5832 | return { action: "accept", content: {} } as const; | ||
| 5833 | } | ||
| 5834 | if (mcpApprovalDisposition.value === "deny") { | ||
| 5835 | return { action: "decline" } as const; | ||
| 5836 | } | ||
| 5837 | } | ||
| 5838 | if ( | ||
| 5839 | params.mode === "url" && | ||
| 5840 | "url" in params && | ||
| 5841 | typeof params.url === "string" && | ||
| 5842 | "elicitationId" in params && | ||
| 5843 | typeof params.elicitationId === "string" | ||
| 5844 | ) { | ||
| 5845 | const { elicitationId, url } = params; | ||
| 5846 | const admitted = yield* runRuntimeCallbackAtGeneration( | ||
| 5847 | handlerGeneration, | ||
| 5848 | Effect.gen(function* () { | ||
| 5849 | const nativeResponseAcknowledgement = yield* Deferred.make< | ||
| 5850 | void, | ||
| 5851 | EffectAcpErrors.AcpError | ||
| 5852 | >(); | ||
| 5853 | yield* registerNativeResponseAcknowledgement( | ||
| 5854 | handlerGeneration, | ||
| 5855 | transportRequestId, | ||
| 5856 | nativeResponseAcknowledgement, | ||
| 5857 | ); | ||
| 5858 | const accepted = yield* ( | ||
| 5859 | flavor.onUrlElicitation?.({ | ||
| 5860 | elicitationId, | ||
| 5861 | url, | ||
| 5862 | message: params.message, | ||
| 5863 | }) ?? Effect.succeed(false) | ||
| 5864 | ); | ||
| 5865 | return accepted | ||
| 5866 | ? ({ action: "accept" } as const) | ||
| 5867 | : ({ action: "decline" } as const); | ||
| 5868 | }), | ||
| 5869 | ); | ||
| 5870 | if (Option.isNone(admitted)) return yield* Effect.never; | ||
| 5871 | return admitted.value; | ||
| 5872 | } | ||
| 5873 | if (params.mode !== "form" || !("requestedSchema" in params)) { | ||
| 5874 | // Future elicitation modes beyond form and url decline rather | ||
| 5875 | // than guessing at their semantics. | ||
| 5876 | return { action: "decline" } as const; | ||
| 5877 | } | ||
| 5878 | const requestedSchema = unknownRecord(params.requestedSchema); | ||
| 5879 | const properties = unknownRecord(requestedSchema?.properties) ?? {}; | ||
| 5880 | const elicitationScopeId = | ||
| 5881 | "sessionId" in params ? params.sessionId : `request:${params.requestId}`; | ||
| 5882 | const questions = Object.entries(properties).map( | ||
| 5883 | ([id, property], index): OrchestrationV2UserInputQuestion => { | ||
| 5884 | const record = unknownRecord(property); | ||
| 5885 | const enumValues = Array.isArray(record?.enum) | ||
| 5886 | ? record.enum.filter((value): value is string => typeof value === "string") | ||
| 5887 | : []; | ||
| 5888 | const options = | ||
| 5889 | enumValues.length > 0 | ||
| 5890 | ? enumValues.map((value) => ({ label: value, description: value })) | ||
| 5891 | : record?.type === "boolean" | ||
| 5892 | ? [ | ||
| 5893 | { label: "true", description: "Yes" }, | ||
| 5894 | { label: "false", description: "No" }, | ||
| 5895 | ] | ||
| 5896 | : []; | ||
| 5897 | return { | ||
| 5898 | id, | ||
| 5899 | header: nonEmptyText(record?.title, `Question ${index + 1}`), | ||
| 5900 | question: nonEmptyText(record?.description, params.message), | ||
| 5901 | options, | ||
| 5902 | }; | ||
| 5903 | }, | ||
| 5904 | ); | ||
| 5905 | const userInput = yield* requestUserInputWithAdmission( | ||
| 5906 | handlerGeneration, | ||
| 5907 | Effect.gen(function* () { | ||
| 5908 | const ordinal = yield* Ref.getAndUpdate( | ||
| 5909 | nextElicitationOrdinal, | ||
| 5910 | (current) => current + 1, | ||
| 5911 | ); | ||
| 5912 | const nativeRequestId = `${elicitationScopeId}:elicitation:${ordinal}`; | ||
| 5913 | return { | ||
| 5914 | nativeItemId: nativeRequestId, | ||
| 5915 | nativeRequestId, | ||
| 5916 | questions, | ||
| 5917 | }; | ||
| 5918 | }), | ||
| 5919 | transportRequestId, | ||
| 5920 | ); | ||
| 5921 | const response = | ||
| 5922 | userInput.answers === null | ||
| 5923 | ? ({ action: "cancel" } as const) | ||
| 5924 | : ({ | ||
| 5925 | action: "accept", | ||
| 5926 | content: elicitationContent( | ||
| 5927 | userInput.answers, | ||
| 5928 | new Set(Object.keys(properties)), | ||
| 5929 | ), | ||
| 5930 | } as const); | ||
| 5931 | yield* userInput.acknowledgeNativeResponse; | ||
| 5932 | return response; | ||
| 5933 | }), | ||
| 5934 | ); | ||
| 5935 | if (flavor.registerExtensions !== undefined) { | ||
| 5936 | yield* flavor.registerExtensions({ | ||
| 5937 | runtime: targetRuntime, | ||
| 5938 | reportProviderRetry: (notice) => | ||
| 5939 | runRuntimeCallbackAtGeneration(handlerGeneration, reportProviderRetry(notice)).pipe( | ||
| 5940 | Effect.asVoid, | ||
| 5941 | ), | ||
| 5942 | requestUserInput, | ||
| 5943 | captureProposedPlan, | ||
| 5944 | lastProposedPlanMarkdown, | ||
| 5945 | finishSubagent: (notice) => | ||
| 5946 | runRuntimeCallbackAtGeneration( | ||
| 5947 | handlerGeneration, | ||
| 5948 | Effect.gen(function* () { | ||
| 5949 | if (yield* Ref.get(stoppedRunQuarantine)) return; | ||
| 5950 | // Root-session notices only; nested subagents report to | ||
| 5951 | // their own parent session. | ||
| 5952 | if ((yield* Ref.get(activeSessionId)) !== notice.sessionId) return; | ||
| 5953 | yield* finishSubagentFromNotice(notice); | ||
| 5954 | }), | ||
| 5955 | ).pipe(Effect.asVoid), | ||
| 5956 | applyBackgroundTaskMutation: (mutation) => | ||
| 5957 | runRuntimeCallbackAtGeneration( | ||
| 5958 | handlerGeneration, | ||
| 5959 | Effect.gen(function* () { | ||
| 5960 | // Direct Stop quarantine: drop residual task lifecycle from | ||
| 5961 | // the stopped run instead of mutating wake machinery. | ||
| 5962 | if (yield* Ref.get(stoppedRunQuarantine)) return; | ||
| 5963 | // Root-session tasks only: a cancelled subagent's re-run in | ||
| 5964 | // its child session must not gate root wake machinery. | ||
| 5965 | if ((yield* Ref.get(activeSessionId)) !== mutation.sessionId) return; | ||
| 5966 | if ( | ||
| 5967 | mutation.status !== "running" && | ||
| 5968 | mutation.report !== undefined && | ||
| 5969 | !(yield* Ref.get(handledBackgroundTaskIdsInActiveTurn)).has(mutation.taskId) | ||
| 5970 | ) { | ||
| 5971 | yield* recordWakeReport(mutation.taskId, { | ||
| 5972 | ...mutation.report, | ||
| 5973 | outcome: mutation.status, | ||
| 5974 | }); | ||
| 5975 | } | ||
| 5976 | yield* applyLateBackgroundMutation(mutation.sessionId, mutation); | ||
| 5977 | if (mutation.status !== "running") { | ||
| 5978 | yield* finishRegisteredBackgroundTool({ | ||
| 5979 | taskId: mutation.taskId, | ||
| 5980 | status: mutation.status, | ||
| 5981 | ...(mutation.output === undefined ? {} : { output: mutation.output }), | ||
| 5982 | }); | ||
| 5983 | } | ||
| 5984 | }), | ||
| 5985 | ).pipe(Effect.asVoid), | ||
| 5986 | }); | ||
| 5987 | } | ||
| 5988 | }); | ||
| 5989 | |||
| 5990 | /** Builds the MCP-over-ACP bridge for one runtime, disposed with that runtime's scope. */ | ||
| 5991 | const makeRuntimeMcpBridge = Effect.fnUntraced(function* ( | ||
| 5992 | threadId: ThreadId | null, | ||
| 5993 | scope: Scope.Scope, | ||
| 5994 | ) { | ||
| 5995 | const mcpContext = acpMcpContext(threadId, self); | ||
| 5996 | if (mcpContext.endpoint === undefined || mcpContext.authorization === undefined) { | ||
| 5997 | return undefined; | ||
| 5998 | } | ||
| 5999 | const mcpBridge = yield* makeAcpMcpOverAcpBridge({ | ||
| 6000 | endpoint: mcpContext.endpoint, | ||
| 6001 | authorization: mcpContext.authorization, | ||
| 6002 | allocateConnectionId: options.crypto.randomUUIDv4.pipe(Effect.orDie), | ||
| 6003 | }); | ||
| 6004 | yield* Scope.addFinalizer(scope, mcpBridge.dispose); | ||
| 6005 | return mcpBridge; | ||
| 6006 | }); | ||
| 6007 | |||
| 6008 | const spawnAcpRuntime = Effect.fnUntraced(function* ( | ||
| 6009 | threadId: ThreadId | null, | ||
| 6010 | resumeSessionId?: string, | ||
| 6011 | ) { | ||
| 6012 | if (runtimeScope !== undefined) { | ||
| 6013 | yield* Scope.close(runtimeScope, Exit.void); | ||
| 6014 | } | ||
| 6015 | runtimeScope = yield* Scope.make(); | ||
| 6016 | runtimeMcpBridge = yield* makeRuntimeMcpBridge(threadId, runtimeScope); | ||
| 6017 | const runtimeGeneration = yield* Ref.get(runtimeCallbackGeneration); | ||
| 6018 | runtime = yield* flavor | ||
| 6019 | .makeRuntime(makeRuntimeInput(runtimeGeneration, threadId, resumeSessionId)) | ||
| 6020 | .pipe( | ||
| 6021 | Effect.provideService(Scope.Scope, runtimeScope), | ||
| 6022 | Effect.provideService(Crypto.Crypto, options.crypto), | ||
| 6023 | ); | ||
| 6024 | }); | ||
| 6025 | |||
| 6026 | const startAcpRuntime = Effect.fnUntraced(function* ( | ||
| 6027 | threadId: ThreadId | null, | ||
| 6028 | resumeSessionId?: string, | ||
| 6029 | ) { | ||
| 6030 | const startup = Effect.gen(function* () { | ||
| 6031 | yield* spawnAcpRuntime(threadId, resumeSessionId); | ||
| 6032 | yield* wireAcpRuntimeHandlers(runtime, yield* Ref.get(runtimeCallbackGeneration)); | ||
| 6033 | return yield* runtime.start(); | ||
| 6034 | }); | ||
| 6035 | return yield* flavor.withRuntimeStartup?.(startup) ?? startup; | ||
| 6036 | }); | ||
| 6037 | |||
| 6038 | const restartAcpRuntime = Effect.fnUntraced(function* (threadId: ThreadId | null) { | ||
| 6039 | yield* spawnAcpRuntime(threadId); | ||
| 6040 | yield* wireAcpRuntimeHandlers(runtime, yield* Ref.get(runtimeCallbackGeneration)); | ||
| 6041 | }); | ||
| 6042 | |||
| 6043 | const startReplacementAcpRuntime = Effect.fnUntraced(function* ( | ||
| 6044 | threadId: ThreadId | null, | ||
| 6045 | commitSessionState: ( | ||
| 6046 | replacement: AcpSessionRuntime.AcpSessionRuntimeStartResult, | ||
| 6047 | ) => Effect.Effect<void>, | ||
| 6048 | ) { | ||
| 6049 | const previousScope = runtimeScope; | ||
| 6050 | const previousGeneration = yield* Ref.get(runtimeCallbackGeneration); | ||
| 6051 | yield* runtimeCallbackPermit.withPermit(awaitAdmittedNativeResponses); | ||
| 6052 | |||
| 6053 | // Keep the original generation active until the candidate has | ||
| 6054 | // completed session startup. A failed candidate therefore cannot | ||
| 6055 | // suppress termination or background callbacks from the live session. | ||
| 6056 | const replacementGeneration = yield* allocateRuntimeCallbackGeneration; | ||
| 6057 | const replacementScope = yield* Scope.make(); | ||
| 6058 | type CandidateLifecycle = | ||
| 6059 | | { readonly _tag: "Starting" } | ||
| 6060 | | { readonly _tag: "Terminated"; readonly error: EffectAcpErrors.AcpError } | ||
| 6061 | | { readonly _tag: "Committed" }; | ||
| 6062 | const candidateLifecycle = yield* Ref.make<CandidateLifecycle>({ _tag: "Starting" }); | ||
| 6063 | const handleCandidateTermination: AcpAdapterV2RuntimeInput["onTermination"] = (error) => | ||
| 6064 | Ref.modify( | ||
| 6065 | candidateLifecycle, | ||
| 6066 | (current): readonly [Effect.Effect<void>, CandidateLifecycle] => { | ||
| 6067 | if (current._tag === "Committed") { | ||
| 6068 | return [handleRuntimeTerminationAtGeneration(replacementGeneration), current]; | ||
| 6069 | } | ||
| 6070 | return [ | ||
| 6071 | Effect.void, | ||
| 6072 | current._tag === "Terminated" | ||
| 6073 | ? current | ||
| 6074 | : ({ _tag: "Terminated", error } as const), | ||
| 6075 | ]; | ||
| 6076 | }, | ||
| 6077 | ).pipe(Effect.flatten); | ||
| 6078 | const stagedSessionUpdates: Array<EffectAcpSchema.SessionNotification> = []; | ||
| 6079 | let handleCandidateSessionUpdate: ( | ||
| 6080 | notification: EffectAcpSchema.SessionNotification, | ||
| 6081 | ) => Effect.Effect<void, EffectAcpErrors.AcpError> = (notification) => | ||
| 6082 | Effect.sync(() => { | ||
| 6083 | stagedSessionUpdates.push(notification); | ||
| 6084 | }); | ||
| 6085 | let replacementMcpBridge: AcpMcpOverAcpBridge | undefined; | ||
| 6086 | const startup = Effect.gen(function* () { | ||
| 6087 | replacementMcpBridge = yield* makeRuntimeMcpBridge(threadId, replacementScope); | ||
| 6088 | const replacementRuntime = yield* flavor | ||
| 6089 | .makeRuntime( | ||
| 6090 | makeRuntimeInput( | ||
| 6091 | replacementGeneration, | ||
| 6092 | threadId, | ||
| 6093 | undefined, | ||
| 6094 | handleCandidateTermination, | ||
| 6095 | ), | ||
| 6096 | ) | ||
| 6097 | .pipe( | ||
| 6098 | Effect.provideService(Scope.Scope, replacementScope), | ||
| 6099 | Effect.provideService(Crypto.Crypto, options.crypto), | ||
| 6100 | ); | ||
| 6101 | // Session setup may publish commands before it returns. Buffer those | ||
| 6102 | // notifications, but do not expose request or extension handlers | ||
| 6103 | // until the candidate generation has committed. | ||
| 6104 | yield* replacementRuntime.handleSessionUpdate((notification) => | ||
| 6105 | Effect.suspend(() => handleCandidateSessionUpdate(notification)), | ||
| 6106 | ); | ||
| 6107 | yield* wireAcpRuntimeMcpHandlers(replacementRuntime, replacementMcpBridge); | ||
| 6108 | const started = yield* replacementRuntime.start(); | ||
| 6109 | return { replacementRuntime, started }; | ||
| 6110 | }); | ||
| 6111 | const replacementExit = yield* Effect.exit( | ||
| 6112 | (flavor.withRuntimeStartup?.(startup) ?? startup).pipe( | ||
| 6113 | Effect.onInterrupt(() => | ||
| 6114 | Scope.close(replacementScope, Exit.void).pipe(Effect.ignore), | ||
| 6115 | ), | ||
| 6116 | ), | ||
| 6117 | ); | ||
| 6118 | if (Exit.isFailure(replacementExit)) { | ||
| 6119 | yield* runtimeCallbackPermit.withPermit( | ||
| 6120 | quarantineNativeTransportAtGeneration(replacementGeneration), | ||
| 6121 | ); | ||
| 6122 | yield* Scope.close(replacementScope, Exit.void).pipe(Effect.ignore); | ||
| 6123 | return yield* Effect.failCause(replacementExit.cause); | ||
| 6124 | } | ||
| 6125 | |||
| 6126 | yield* runtimeCallbackPermit | ||
| 6127 | .withPermit( | ||
| 6128 | Effect.uninterruptible( | ||
| 6129 | Effect.gen(function* () { | ||
| 6130 | yield* awaitAdmittedNativeResponses; | ||
| 6131 | // Session updates and MCP were wired during candidate startup | ||
| 6132 | // (against its own bridge); terminals attach after commit below. | ||
| 6133 | yield* wireAcpRuntimeHandlers( | ||
| 6134 | replacementExit.value.replacementRuntime, | ||
| 6135 | replacementGeneration, | ||
| 6136 | { sessionUpdates: false, terminals: false, mcp: false }, | ||
| 6137 | ); | ||
| 6138 | const lifecycle = yield* Ref.modify( | ||
| 6139 | candidateLifecycle, | ||
| 6140 | (current): readonly [CandidateLifecycle, CandidateLifecycle] => | ||
| 6141 | current._tag === "Terminated" | ||
| 6142 | ? [current, current] | ||
| 6143 | : [{ _tag: "Committed" }, { _tag: "Committed" }], | ||
| 6144 | ); | ||
| 6145 | if (lifecycle._tag === "Terminated") { | ||
| 6146 | return yield* lifecycle.error; | ||
| 6147 | } | ||
| 6148 | yield* quarantineNativeTransportAtGeneration(previousGeneration); | ||
| 6149 | runtime = replacementExit.value.replacementRuntime; | ||
| 6150 | runtimeScope = replacementScope; | ||
| 6151 | runtimeMcpBridge = replacementMcpBridge; | ||
| 6152 | yield* Ref.set(runtimeCallbackGeneration, replacementGeneration); | ||
| 6153 | prepareTerminalEnvironment(threadId, replacementExit.value.started.sessionId); | ||
| 6154 | yield* wireAcpRuntimeTerminalHandlers(replacementExit.value.replacementRuntime); | ||
| 6155 | yield* commitSessionState(replacementExit.value.started); | ||
| 6156 | while (true) { | ||
| 6157 | const buffered = stagedSessionUpdates.splice(0, stagedSessionUpdates.length); | ||
| 6158 | if (buffered.length === 0) { | ||
| 6159 | handleCandidateSessionUpdate = (notification) => | ||
| 6160 | projectAcpRuntimeSessionUpdate(replacementGeneration, notification); | ||
| 6161 | break; | ||
| 6162 | } | ||
| 6163 | yield* Effect.forEach( | ||
| 6164 | buffered, | ||
| 6165 | (notification) => | ||
| 6166 | projectAcpRuntimeSessionUpdateEffect(notification).pipe( | ||
| 6167 | Effect.catchCause((cause) => | ||
| 6168 | Effect.logError("failed to replay staged ACP session update", { | ||
| 6169 | driver, | ||
| 6170 | cause, | ||
| 6171 | }), | ||
| 6172 | ), | ||
| 6173 | ), | ||
| 6174 | { discard: true }, | ||
| 6175 | ); | ||
| 6176 | } | ||
| 6177 | yield* cancelPendingRuntimeRequests(); | ||
| 6178 | if (previousScope !== undefined) { | ||
| 6179 | yield* Scope.close(previousScope, Exit.void).pipe( | ||
| 6180 | Effect.catchCause((cause) => | ||
| 6181 | Effect.logError("failed to close replaced ACP runtime scope", { | ||
| 6182 | driver, | ||
| 6183 | cause, | ||
| 6184 | }), | ||
| 6185 | ), | ||
| 6186 | ); | ||
| 6187 | } | ||
| 6188 | }), | ||
| 6189 | ), | ||
| 6190 | ) | ||
| 6191 | .pipe( | ||
| 6192 | Effect.onExit((exit) => | ||
| 6193 | Exit.isFailure(exit) | ||
| 6194 | ? Ref.get(candidateLifecycle).pipe( | ||
| 6195 | Effect.flatMap((lifecycle) => | ||
| 6196 | lifecycle._tag === "Committed" | ||
| 6197 | ? Effect.void | ||
| 6198 | : Scope.close(replacementScope, Exit.void).pipe(Effect.ignore), | ||
| 6199 | ), | ||
| 6200 | ) | ||
| 6201 | : Effect.void, | ||
| 6202 | ), | ||
| 6203 | ); | ||
| 6204 | return replacementExit.value.started; | ||
| 6205 | }); | ||
| 6206 | |||
| 6207 | const initialStart = yield* Effect.result( | ||
| 6208 | startAcpRuntime(input.threadId, input.initialNativeThreadId), | ||
| 6209 | ); | ||
| 6210 | const started = Result.isSuccess(initialStart) | ||
| 6211 | ? initialStart.success | ||
| 6212 | : yield* Effect.gen(function* () { | ||
| 6213 | const failedMethod = | ||
| 6214 | "method" in initialStart.failure ? initialStart.failure.method : undefined; | ||
| 6215 | if ( | ||
| 6216 | input.initialNativeThreadId === undefined || | ||
| 6217 | (failedMethod !== "session/load" && failedMethod !== "session/resume") | ||
| 6218 | ) { | ||
| 6219 | return yield* initialStart.failure; | ||
| 6220 | } | ||
| 6221 | yield* Ref.set(initialSessionActivationFailure, { | ||
| 6222 | sessionId: input.initialNativeThreadId, | ||
| 6223 | error: initialStart.failure, | ||
| 6224 | }); | ||
| 6225 | itemIdentityVersion = 2; | ||
| 6226 | yield* Ref.set(runtimeRestartRequired, false); | ||
| 6227 | prepareClaimableTerminalEnvironment(input.threadId); | ||
| 6228 | return yield* startAcpRuntime(input.threadId); | ||
| 6229 | }); | ||
| 6230 | yield* Ref.set(activeSessionId, started.sessionId); | ||
| 6231 | yield* Ref.set(activeSessionSetup, started); | ||
| 6232 | rememberTerminalEnvironment(started.sessionId, input.threadId); | ||
| 6233 | const capabilities = negotiatedCapabilities(flavor.capabilities, started); | ||
| 6234 | const canLoadSession = started.initializeResult.agentCapabilities?.loadSession === true; | ||
| 6235 | const canResumeSession = | ||
| 6236 | started.initializeResult.agentCapabilities?.sessionCapabilities?.resume != null; | ||
| 6237 | const supportsImagePrompts = acpSupportsImagePrompts({ | ||
| 6238 | flavorSupportsImagePrompts: flavor.supportsImagePrompts, | ||
| 6239 | negotiatedImage: | ||
| 6240 | started.initializeResult.agentCapabilities?.promptCapabilities?.image === true, | ||
| 6241 | }); | ||
| 6242 | |||
| 6243 | const activateSession = Effect.fnUntraced(function* ( | ||
| 6244 | sessionId: string, | ||
| 6245 | threadId: ThreadId | null, | ||
| 6246 | ) { | ||
| 6247 | const initialFailure = yield* Ref.modify(initialSessionActivationFailure, (failure) => | ||
| 6248 | failure?.sessionId === sessionId ? [failure.error, null] : [undefined, failure], | ||
| 6249 | ); | ||
| 6250 | if (initialFailure !== undefined) { | ||
| 6251 | return yield* initialFailure; | ||
| 6252 | } | ||
| 6253 | const activationOptions = acpMcpActivation(threadId, self); | ||
| 6254 | prepareTerminalEnvironment(threadId, sessionId); | ||
| 6255 | const activated = canLoadSession | ||
| 6256 | ? yield* runtime.loadSession(sessionId, activationOptions) | ||
| 6257 | : canResumeSession | ||
| 6258 | ? yield* runtime.resumeSession(sessionId, activationOptions) | ||
| 6259 | : yield* new ProviderAdapter.ProviderAdapterProtocolError({ | ||
| 6260 | driver, | ||
| 6261 | detail: `ACP driver cannot load or resume session ${sessionId}`, | ||
| 6262 | }); | ||
| 6263 | rememberTerminalEnvironment(activated.sessionId, threadId); | ||
| 6264 | return activated; | ||
| 6265 | }); | ||
| 6266 | |||
| 6267 | const configureSession = Effect.fnUntraced(function* ( | ||
| 6268 | startResult: AcpSessionRuntime.AcpSessionRuntimeStartResult, | ||
| 6269 | modelSelection: ModelSelection, | ||
| 6270 | runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy, | ||
| 6271 | ) { | ||
| 6272 | const requestedModel = flavor.resolveModelId?.(modelSelection) ?? modelSelection.model; | ||
| 6273 | let appliedModel: string | undefined; | ||
| 6274 | if (flavor.applyModelSelection !== undefined) { | ||
| 6275 | appliedModel = yield* flavor.applyModelSelection({ | ||
| 6276 | runtime, | ||
| 6277 | startResult, | ||
| 6278 | modelSelection, | ||
| 6279 | }); | ||
| 6280 | } else if ( | ||
| 6281 | requestedModel.length > 0 && | ||
| 6282 | requestedModel !== "auto" && | ||
| 6283 | requestedModel !== "default" | ||
| 6284 | ) { | ||
| 6285 | const hasModelConfig = | ||
| 6286 | startResult.sessionSetupResult.configOptions?.some( | ||
| 6287 | (option) => option.category === "model", | ||
| 6288 | ) === true; | ||
| 6289 | if (hasModelConfig) { | ||
| 6290 | yield* runtime.setModel(requestedModel); | ||
| 6291 | } | ||
| 6292 | } | ||
| 6293 | // Same-runtime switches compare against this stored setup, so keep | ||
| 6294 | // its model metadata in sync with what the session now runs on; | ||
| 6295 | // otherwise switching A -> B -> A would see the stale setup-time A | ||
| 6296 | // and skip the final switch. | ||
| 6297 | if (appliedModel !== undefined) { | ||
| 6298 | const applied = appliedModel; | ||
| 6299 | yield* Ref.update(activeSessionSetup, (setup) => { | ||
| 6300 | if (setup === null) { | ||
| 6301 | return setup; | ||
| 6302 | } | ||
| 6303 | const models = setup.sessionSetupResult.models; | ||
| 6304 | if (models == null || models.currentModelId === applied) { | ||
| 6305 | return setup; | ||
| 6306 | } | ||
| 6307 | return { | ||
| 6308 | ...setup, | ||
| 6309 | sessionSetupResult: { | ||
| 6310 | ...setup.sessionSetupResult, | ||
| 6311 | models: { ...models, currentModelId: applied }, | ||
| 6312 | }, | ||
| 6313 | }; | ||
| 6314 | }); | ||
| 6315 | } | ||
| 6316 | const optionSelections = modelSelection.options ?? []; | ||
| 6317 | const configOptions = yield* runtime.getConfigOptions; | ||
| 6318 | const availableConfigIds = new Set(configOptions.map((option) => option.id)); | ||
| 6319 | const hasNativeConfigWithSyntheticModeId = availableConfigIds.has( | ||
| 6320 | ACP_SESSION_MODE_OPTION_ID, | ||
| 6321 | ); | ||
| 6322 | const modeSelection = hasNativeConfigWithSyntheticModeId | ||
| 6323 | ? undefined | ||
| 6324 | : optionSelections.find((selection) => selection.id === ACP_SESSION_MODE_OPTION_ID); | ||
| 6325 | const configSelections = hasNativeConfigWithSyntheticModeId | ||
| 6326 | ? optionSelections | ||
| 6327 | : optionSelections.filter((selection) => selection.id !== ACP_SESSION_MODE_OPTION_ID); | ||
| 6328 | // Probe-time descriptors are a per-model union, so a stored | ||
| 6329 | // selection can reference an option the live session does not | ||
| 6330 | // expose (Kilo advertises per-model "effort" descriptors while its | ||
| 6331 | // session config omits them). Failing the open here wedges the run | ||
| 6332 | // in a retry loop; skip like the out-of-range values below and let | ||
| 6333 | // the agent default apply. | ||
| 6334 | const unsupportedConfigIds = configSelections | ||
| 6335 | .map((selection) => selection.id) | ||
| 6336 | .filter((id) => !availableConfigIds.has(id)); | ||
| 6337 | if (unsupportedConfigIds.length > 0) { | ||
| 6338 | yield* Effect.logWarning( | ||
| 6339 | "ACP session does not expose requested configuration option(s)", | ||
| 6340 | { | ||
| 6341 | driver, | ||
| 6342 | sessionId: startResult.sessionId, | ||
| 6343 | optionIds: unsupportedConfigIds, | ||
| 6344 | }, | ||
| 6345 | ); | ||
| 6346 | } | ||
| 6347 | for (const selection of configSelections) { | ||
| 6348 | if (!availableConfigIds.has(selection.id)) continue; | ||
| 6349 | // Tuning knobs degrade instead of failing the session open: agents | ||
| 6350 | // advertise the union of values across models but can reject a | ||
| 6351 | // per-model invalid one at set time (codex-acp advertises "ultra" | ||
| 6352 | // reasoning effort and then rejects it for most models). Skip | ||
| 6353 | // values the session does not currently offer and downgrade an | ||
| 6354 | // agent-side set rejection to a warning; the agent's default | ||
| 6355 | // applies for that option. | ||
| 6356 | const option = configOptions.find((candidate) => candidate.id === selection.id); | ||
| 6357 | if ( | ||
| 6358 | option !== undefined && | ||
| 6359 | option.type === "select" && | ||
| 6360 | typeof selection.value === "string" | ||
| 6361 | ) { | ||
| 6362 | const advertisedValues = option.options.flatMap((entry) => | ||
| 6363 | "value" in entry ? [entry.value] : entry.options.map((choice) => choice.value), | ||
| 6364 | ); | ||
| 6365 | if (!advertisedValues.includes(selection.value)) continue; | ||
| 6366 | } | ||
| 6367 | yield* runtime.setConfigOption(selection.id, selection.value).pipe( | ||
| 6368 | Effect.catchTags({ | ||
| 6369 | AcpRequestError: (error) => | ||
| 6370 | Effect.logWarning("ACP session rejected a configuration option value", { | ||
| 6371 | optionId: selection.id, | ||
| 6372 | value: selection.value, | ||
| 6373 | detail: error.message, | ||
| 6374 | }), | ||
| 6375 | }), | ||
| 6376 | ); | ||
| 6377 | } | ||
| 6378 | const policyMode = flavor.sessionModeForPolicy?.(runtimePolicy); | ||
| 6379 | if (policyMode !== undefined) { | ||
| 6380 | yield* runtime.setMode(policyMode); | ||
| 6381 | } | ||
| 6382 | const modeState = yield* runtime.getModeState; | ||
| 6383 | // The synthetic mode selection is skipped rather than failed when the | ||
| 6384 | // agent no longer advertises it: mode sets are volatile across agent | ||
| 6385 | // versions and a stale persisted mode should not block the turn. | ||
| 6386 | if ( | ||
| 6387 | modeSelection !== undefined && | ||
| 6388 | typeof modeSelection.value === "string" && | ||
| 6389 | modeState?.availableModes.some((mode) => mode.id === modeSelection.value) === true && | ||
| 6390 | modeState.currentModeId !== modeSelection.value | ||
| 6391 | ) { | ||
| 6392 | yield* runtime.setMode(modeSelection.value); | ||
| 6393 | } | ||
| 6394 | const effectiveModeState = yield* runtime.getModeState; | ||
| 6395 | const effectiveConfigOptions = yield* runtime.getConfigOptions; | ||
| 6396 | const planSensitiveOptions = effectiveConfigOptions.filter( | ||
| 6397 | ( | ||
| 6398 | option, | ||
| 6399 | ): option is Extract< | ||
| 6400 | EffectAcpSchema.SessionConfigOption, | ||
| 6401 | { readonly type: "select" } | ||
| 6402 | > => | ||
| 6403 | option.type === "select" && | ||
| 6404 | (option.category === "mode" || option.category === "collaboration_mode"), | ||
| 6405 | ); | ||
| 6406 | if (runtimePolicy.interactionMode === "plan") { | ||
| 6407 | if (!nativeBuildConfigurationBySessionId.has(startResult.sessionId)) { | ||
| 6408 | nativeBuildConfigurationBySessionId.set(startResult.sessionId, { | ||
| 6409 | ...(effectiveModeState === undefined | ||
| 6410 | ? {} | ||
| 6411 | : { modeId: effectiveModeState.currentModeId }), | ||
| 6412 | configOptions: planSensitiveOptions.map((option) => ({ | ||
| 6413 | id: option.id, | ||
| 6414 | value: option.currentValue, | ||
| 6415 | })), | ||
| 6416 | }); | ||
| 6417 | } | ||
| 6418 | const planMode = effectiveModeState?.availableModes.find( | ||
| 6419 | (mode) => mode.id === "plan" || mode.id === "architect", | ||
| 6420 | ); | ||
| 6421 | if (planMode !== undefined && effectiveModeState?.currentModeId !== planMode.id) { | ||
| 6422 | yield* runtime.setMode(planMode.id); | ||
| 6423 | } | ||
| 6424 | for (const option of planSensitiveOptions) { | ||
| 6425 | const choices = option.options.flatMap((entry) => | ||
| 6426 | "value" in entry ? [entry.value] : entry.options.map((choice) => choice.value), | ||
| 6427 | ); | ||
| 6428 | const requested = choices.find( | ||
| 6429 | (choice) => choice === "plan" || choice === "architect", | ||
| 6430 | ); | ||
| 6431 | if (requested !== undefined && option.currentValue !== requested) { | ||
| 6432 | yield* runtime.setConfigOption(option.id, requested); | ||
| 6433 | } | ||
| 6434 | } | ||
| 6435 | } else { | ||
| 6436 | const nativeBuild = nativeBuildConfigurationBySessionId.get(startResult.sessionId); | ||
| 6437 | if (nativeBuild !== undefined) { | ||
| 6438 | if ( | ||
| 6439 | nativeBuild.modeId !== undefined && | ||
| 6440 | effectiveModeState?.currentModeId !== nativeBuild.modeId && | ||
| 6441 | effectiveModeState?.availableModes.some( | ||
| 6442 | (mode) => mode.id === nativeBuild.modeId, | ||
| 6443 | ) === true | ||
| 6444 | ) { | ||
| 6445 | yield* runtime.setMode(nativeBuild.modeId); | ||
| 6446 | } | ||
| 6447 | for (const saved of nativeBuild.configOptions) { | ||
| 6448 | const option = effectiveConfigOptions.find( | ||
| 6449 | (candidate) => candidate.type === "select" && candidate.id === saved.id, | ||
| 6450 | ); | ||
| 6451 | if (option === undefined || option.type !== "select") continue; | ||
| 6452 | const choices = option.options.flatMap((entry) => | ||
| 6453 | "value" in entry ? [entry.value] : entry.options.map((choice) => choice.value), | ||
| 6454 | ); | ||
| 6455 | if (option.currentValue !== saved.value && choices.includes(saved.value)) { | ||
| 6456 | yield* runtime.setConfigOption(option.id, saved.value); | ||
| 6457 | } | ||
| 6458 | } | ||
| 6459 | nativeBuildConfigurationBySessionId.delete(startResult.sessionId); | ||
| 6460 | } | ||
| 6461 | } | ||
| 6462 | yield* ( | ||
| 6463 | flavor.onSessionConfigurationUpdate?.( | ||
| 6464 | yield* runtime.getConfigOptions, | ||
| 6465 | yield* runtime.getModeState, | ||
| 6466 | ) ?? Effect.void | ||
| 6467 | ); | ||
| 6468 | }); | ||
| 6469 | |||
| 6470 | yield* configureSession(started, input.modelSelection, input.runtimePolicy); | ||
| 6471 | yield* Ref.set(activeSelection, input.modelSelection); | ||
| 6472 | yield* Ref.set(activeInteractionMode, input.runtimePolicy.interactionMode); | ||
| 6473 | const createdAt = yield* DateTime.now; | ||
| 6474 | const providerSession: OrchestrationV2ProviderSession = { | ||
| 6475 | id: input.providerSessionId, | ||
| 6476 | driver, | ||
| 6477 | providerInstanceId: options.instanceId, | ||
| 6478 | status: "ready", | ||
| 6479 | cwd: input.runtimePolicy.cwd ?? process.cwd(), | ||
| 6480 | model: input.modelSelection.model, | ||
| 6481 | capabilities, | ||
| 6482 | createdAt, | ||
| 6483 | updatedAt: createdAt, | ||
| 6484 | lastError: null, | ||
| 6485 | }; | ||
| 6486 | |||
| 6487 | const providerTurnPayload = ( | ||
| 6488 | context: ActiveAcpTurn, | ||
| 6489 | status: OrchestrationV2ProviderTurn["status"], | ||
| 6490 | completedAt: DateTime.Utc | null, | ||
| 6491 | ): OrchestrationV2ProviderTurn => ({ | ||
| 6492 | id: context.providerTurnId, | ||
| 6493 | providerThreadId: context.input.providerThread.id, | ||
| 6494 | nodeId: context.input.rootNodeId, | ||
| 6495 | runAttemptId: context.input.attemptId, | ||
| 6496 | nativeTurnRef: { | ||
| 6497 | driver, | ||
| 6498 | nativeId: context.nativeTurnId, | ||
| 6499 | strength: "weak", | ||
| 6500 | }, | ||
| 6501 | ordinal: context.input.providerTurnOrdinal, | ||
| 6502 | status, | ||
| 6503 | startedAt: context.startedAt, | ||
| 6504 | completedAt, | ||
| 6505 | }); | ||
| 6506 | |||
| 6507 | const terminalizeOpenRunOwnedItems = Effect.fnUntraced(function* ( | ||
| 6508 | context: ActiveAcpTurn, | ||
| 6509 | options: { readonly terminalizeSubagents: boolean }, | ||
| 6510 | ) { | ||
| 6511 | for (const tool of context.tools.values()) { | ||
| 6512 | const status = toolStatus(tool.status); | ||
| 6513 | if (status === "pending" || status === "running" || status === "waiting") { | ||
| 6514 | yield* emitTool(context, tool, "interrupted"); | ||
| 6515 | } | ||
| 6516 | } | ||
| 6517 | if (!options.terminalizeSubagents) return; | ||
| 6518 | for (const subagent of context.subagents.values()) { | ||
| 6519 | if (subagent.task.status !== "running" && subagent.task.status !== "pending") { | ||
| 6520 | continue; | ||
| 6521 | } | ||
| 6522 | yield* emitSubagent(context, { | ||
| 6523 | nativeTaskId: subagent.task.nativeTaskRef?.nativeId ?? subagent.task.id, | ||
| 6524 | prompt: subagent.task.prompt, | ||
| 6525 | title: subagent.task.title, | ||
| 6526 | model: subagent.task.model, | ||
| 6527 | status: "interrupted", | ||
| 6528 | childSessionId: subagent.childSessionId, | ||
| 6529 | result: subagent.task.result, | ||
| 6530 | suppressNormalTool: true, | ||
| 6531 | }); | ||
| 6532 | } | ||
| 6533 | }); | ||
| 6534 | |||
| 6535 | const terminalizeOpenForegroundTools = Effect.fnUntraced(function* ( | ||
| 6536 | context: ActiveAcpTurn, | ||
| 6537 | ) { | ||
| 6538 | for (const tool of context.tools.values()) { | ||
| 6539 | if (!acpCompletedTurnShouldTerminalizeTool(tool, flavor)) continue; | ||
| 6540 | yield* emitTool(context, tool, "completed"); | ||
| 6541 | } | ||
| 6542 | }); | ||
| 6543 | |||
| 6544 | const quarantineStoppedRun = Effect.fnUntraced(function* () { | ||
| 6545 | yield* continuationPermit.withPermit( | ||
| 6546 | Effect.gen(function* () { | ||
| 6547 | yield* Ref.update(continuationGeneration, (value) => value + 1); | ||
| 6548 | yield* Ref.set(stoppedRunQuarantine, true); | ||
| 6549 | yield* Ref.set(wakeBuffer, []); | ||
| 6550 | yield* Ref.set(wakeReports, noWakeReports); | ||
| 6551 | yield* Ref.set(continuationRequested, false); | ||
| 6552 | yield* Ref.set(runningBackgroundTaskIds, new Set()); | ||
| 6553 | yield* Ref.set(midTurnUnreportedCompletedTaskIds, new Set()); | ||
| 6554 | yield* Ref.set(carryoverSubagents, null); | ||
| 6555 | yield* Ref.set(lastTurnRoute, null); | ||
| 6556 | }), | ||
| 6557 | ); | ||
| 6558 | }); | ||
| 6559 | |||
| 6560 | const finalizeTurn = Effect.fnUntraced(function* ( | ||
| 6561 | context: ActiveAcpTurn, | ||
| 6562 | status: "completed" | "interrupted" | "failed" | "cancelled", | ||
| 6563 | failure?: OrchestrationV2ProviderFailure, | ||
| 6564 | ) { | ||
| 6565 | if (context.finalized) return; | ||
| 6566 | const settledStatus = context.interrupted ? "interrupted" : status; | ||
| 6567 | context.finalizedStatus = settledStatus; | ||
| 6568 | context.finalized = true; | ||
| 6569 | const directStopQuarantine = yield* Ref.get(stoppedRunQuarantine); | ||
| 6570 | if (flavor.subagentsIdleOnTurnCompletion === true) { | ||
| 6571 | for (const subagent of context.subagents.values()) { | ||
| 6572 | if (!acpSubagentStatusBlocksTurnSettlement(subagent.task.status)) continue; | ||
| 6573 | yield* emitSubagent(context, { | ||
| 6574 | nativeTaskId: subagent.task.nativeTaskRef?.nativeId ?? subagent.task.id, | ||
| 6575 | prompt: subagent.task.prompt, | ||
| 6576 | title: subagent.task.title, | ||
| 6577 | model: subagent.task.model, | ||
| 6578 | status: settledStatus === "completed" ? "idle" : settledStatus, | ||
| 6579 | childSessionId: subagent.childSessionId, | ||
| 6580 | result: null, | ||
| 6581 | }); | ||
| 6582 | } | ||
| 6583 | } | ||
| 6584 | if (settledStatus === "completed") { | ||
| 6585 | yield* terminalizeOpenForegroundTools(context); | ||
| 6586 | } else if (settledStatus === "interrupted") { | ||
| 6587 | // Direct Stop terminalizes every visible run-owned item. restart_active | ||
| 6588 | // keeps live subagent lineages for in-process replacement carryover. | ||
| 6589 | yield* terminalizeOpenRunOwnedItems(context, { | ||
| 6590 | terminalizeSubagents: directStopQuarantine, | ||
| 6591 | }); | ||
| 6592 | } | ||
| 6593 | yield* closeTextStreams(context); | ||
| 6594 | if (settledStatus !== "failed") { | ||
| 6595 | yield* emitProviderRetry(context, settledStatus); | ||
| 6596 | } | ||
| 6597 | const now = yield* DateTime.now; | ||
| 6598 | if ( | ||
| 6599 | flavor.supportsCompaction === true && | ||
| 6600 | context.input.message.text.trim() === "/compact" && | ||
| 6601 | context.input.message.attachments.length === 0 && | ||
| 6602 | settledStatus === "completed" | ||
| 6603 | ) { | ||
| 6604 | const nativeItemId = `${context.nativeTurnId}:compaction`; | ||
| 6605 | yield* emitProviderEvent({ | ||
| 6606 | type: "turn_item.updated", | ||
| 6607 | driver, | ||
| 6608 | turnItem: { | ||
| 6609 | id: idAllocator.derive.turnItemFromProviderItem({ driver, nativeItemId }), | ||
| 6610 | threadId: context.input.threadId, | ||
| 6611 | runId: context.input.runId, | ||
| 6612 | nodeId: context.input.rootNodeId, | ||
| 6613 | providerThreadId: context.input.providerThread.id, | ||
| 6614 | providerTurnId: context.providerTurnId, | ||
| 6615 | nativeItemRef: { driver, nativeId: nativeItemId, strength: "weak" }, | ||
| 6616 | parentItemId: null, | ||
| 6617 | ordinal: yield* resolveItemOrdinal(context, nativeItemId), | ||
| 6618 | type: "compaction", | ||
| 6619 | driver, | ||
| 6620 | status: "completed", | ||
| 6621 | title: "Context compacted", | ||
| 6622 | startedAt: context.startedAt, | ||
| 6623 | completedAt: now, | ||
| 6624 | updatedAt: now, | ||
| 6625 | }, | ||
| 6626 | }); | ||
| 6627 | } | ||
| 6628 | const turn = providerTurnPayload(context, settledStatus, now); | ||
| 6629 | yield* Ref.update(providerTurns, (current) => { | ||
| 6630 | const updated = new Map(current); | ||
| 6631 | updated.set(String(turn.id), turn); | ||
| 6632 | return updated; | ||
| 6633 | }); | ||
| 6634 | yield* emitProviderEvent({ | ||
| 6635 | type: "provider_turn.updated", | ||
| 6636 | driver, | ||
| 6637 | threadId: context.input.threadId, | ||
| 6638 | providerTurn: turn, | ||
| 6639 | }); | ||
| 6640 | const updatedProviderThread: OrchestrationV2ProviderThread = { | ||
| 6641 | ...context.input.providerThread, | ||
| 6642 | providerSessionId: input.providerSessionId, | ||
| 6643 | status: "active", | ||
| 6644 | lastRunOrdinal: context.input.runOrdinal, | ||
| 6645 | firstRunOrdinal: | ||
| 6646 | context.input.providerThread.firstRunOrdinal ?? context.input.runOrdinal, | ||
| 6647 | contextUsage: context.contextUsage, | ||
| 6648 | nativeMetadata: context.nativeMetadata, | ||
| 6649 | updatedAt: now, | ||
| 6650 | }; | ||
| 6651 | yield* Ref.update(providerThreadByNativeSessionId, (current) => | ||
| 6652 | new Map(current).set(context.nativeThreadId, updatedProviderThread), | ||
| 6653 | ); | ||
| 6654 | yield* emitProviderEvent({ | ||
| 6655 | type: "provider_thread.updated", | ||
| 6656 | driver, | ||
| 6657 | providerThread: updatedProviderThread, | ||
| 6658 | }); | ||
| 6659 | yield* emitProviderEvent( | ||
| 6660 | settledStatus === "failed" | ||
| 6661 | ? { | ||
| 6662 | type: "turn.terminal", | ||
| 6663 | driver, | ||
| 6664 | providerThreadId: context.input.providerThread.id, | ||
| 6665 | providerTurnId: context.providerTurnId, | ||
| 6666 | runOrdinal: context.input.runOrdinal, | ||
| 6667 | failureItemOrdinal: yield* resolveItemOrdinal( | ||
| 6668 | context, | ||
| 6669 | `terminal-failure:${context.providerTurnId}`, | ||
| 6670 | ), | ||
| 6671 | status: settledStatus, | ||
| 6672 | failure: failure ?? makeProviderFailure({ class: "provider_error" }), | ||
| 6673 | ...(context.providerRetry === undefined | ||
| 6674 | ? {} | ||
| 6675 | : { | ||
| 6676 | retry: context.providerRetry.retry, | ||
| 6677 | retryStartedAt: context.providerRetry.startedAt, | ||
| 6678 | }), | ||
| 6679 | threadDisposition: "reusable", | ||
| 6680 | } | ||
| 6681 | : { | ||
| 6682 | type: "turn.terminal", | ||
| 6683 | driver, | ||
| 6684 | providerThreadId: context.input.providerThread.id, | ||
| 6685 | providerTurnId: context.providerTurnId, | ||
| 6686 | runOrdinal: context.input.runOrdinal, | ||
| 6687 | status: settledStatus, | ||
| 6688 | failure: null, | ||
| 6689 | threadDisposition: "reusable", | ||
| 6690 | }, | ||
| 6691 | ); | ||
| 6692 | const subagentsRequiringCarryover = [...context.subagents.values()].filter( | ||
| 6693 | acpSubagentHasPendingBackgroundWork, | ||
| 6694 | ); | ||
| 6695 | // Direct Stop must not carry residual subagents into a later run. | ||
| 6696 | if (subagentsRequiringCarryover.length > 0 && !directStopQuarantine) { | ||
| 6697 | const sessionId = yield* Ref.get(activeSessionId); | ||
| 6698 | if (sessionId !== null) { | ||
| 6699 | yield* Ref.set(carryoverSubagents, { | ||
| 6700 | sessionId, | ||
| 6701 | rootTerminalStatus: settledStatus, | ||
| 6702 | subagents: subagentsRequiringCarryover, | ||
| 6703 | }); | ||
| 6704 | } | ||
| 6705 | } | ||
| 6706 | yield* Ref.set(activeTurn, null); | ||
| 6707 | // A mid-turn background completion may have deferred its offer while | ||
| 6708 | // this root turn was still streaming. Once the turn leaves the active | ||
| 6709 | // slot, re-check: empty running set + residual wake evidence (or a | ||
| 6710 | // mid-turn unreported completion) means a legitimate unhandled | ||
| 6711 | // completion can open exactly one continuation. Sticky | ||
| 6712 | // continuationRequested prevents double-offer if a later frame also | ||
| 6713 | // races into offerContinuationRun. | ||
| 6714 | if ( | ||
| 6715 | postSettleContinuationEnabled && | ||
| 6716 | settledStatus === "completed" && | ||
| 6717 | !directStopQuarantine && | ||
| 6718 | (yield* Ref.get(runningBackgroundTaskIds)).size === 0 && | ||
| 6719 | ((yield* Ref.get(wakeBuffer)).length > 0 || | ||
| 6720 | (yield* Ref.get(midTurnUnreportedCompletedTaskIds)).size > 0) | ||
| 6721 | ) { | ||
| 6722 | const sessionId = yield* Ref.get(activeSessionId); | ||
| 6723 | if (sessionId !== null) { | ||
| 6724 | yield* offerContinuationRun(sessionId); | ||
| 6725 | } | ||
| 6726 | } | ||
| 6727 | // Clear mid-turn unreported marks unless a completed turn still has | ||
| 6728 | // running background work: keep them so the post-finalize gate can | ||
| 6729 | // offer once when the last task ends. Interrupted/failed turns must | ||
| 6730 | // not leave marks that open a wake after interrupt (quarantine owns | ||
| 6731 | // that path). Non-continuation turn start also clears (~user turn). | ||
| 6732 | if ( | ||
| 6733 | settledStatus !== "completed" || | ||
| 6734 | (yield* Ref.get(runningBackgroundTaskIds)).size === 0 | ||
| 6735 | ) { | ||
| 6736 | yield* Ref.set(midTurnUnreportedCompletedTaskIds, new Set()); | ||
| 6737 | } | ||
| 6738 | yield* Deferred.succeed(context.completed, undefined).pipe(Effect.ignore); | ||
| 6739 | }); | ||
| 6740 | |||
| 6741 | scheduleDeferredFinalize = (context) => | ||
| 6742 | Effect.gen(function* () { | ||
| 6743 | if (!flavor.deferFinalizeForBackgroundWork) return; | ||
| 6744 | if (!context.promptSettled || context.finalized || context.interrupted) return; | ||
| 6745 | if (hasDeferredBackgroundWork(context)) return; | ||
| 6746 | context.backgroundFinalizeGeneration += 1; | ||
| 6747 | const generation = context.backgroundFinalizeGeneration; | ||
| 6748 | yield* Effect.gen(function* () { | ||
| 6749 | yield* Effect.sleep(ACP_DEFERRED_FINALIZE_DEBOUNCE); | ||
| 6750 | if ( | ||
| 6751 | context.finalized || | ||
| 6752 | context.interrupted || | ||
| 6753 | context.backgroundFinalizeGeneration !== generation | ||
| 6754 | ) { | ||
| 6755 | return; | ||
| 6756 | } | ||
| 6757 | if (hasDeferredBackgroundWork(context)) return; | ||
| 6758 | const status = context.promptSettledStatus ?? "completed"; | ||
| 6759 | yield* finalizeTurn(context, status); | ||
| 6760 | }).pipe(Effect.forkIn(sessionScope), Effect.asVoid); | ||
| 6761 | yield* ( | ||
| 6762 | options.testHooks?.onDeferredFinalizeScheduled?.(ACP_DEFERRED_FINALIZE_DEBOUNCE) ?? | ||
| 6763 | Effect.void | ||
| 6764 | ); | ||
| 6765 | }); | ||
| 6766 | |||
| 6767 | const resolvePromptParts = Effect.fnUntraced(function* ( | ||
| 6768 | turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, | ||
| 6769 | sessionId: string, | ||
| 6770 | ) { | ||
| 6771 | const prompt: Array<EffectAcpSchema.ContentBlock> = []; | ||
| 6772 | const instructionState = { | ||
| 6773 | interactionMode: turnInput.runtimePolicy.interactionMode, | ||
| 6774 | hasT3Mcp: acpMcpServers(turnInput.threadId, self).length > 0, | ||
| 6775 | } satisfies T3AcpInstructionState; | ||
| 6776 | const previousInstructionState = (yield* Ref.get(promptInstructionStates)).get(sessionId); | ||
| 6777 | const messageText = providerMessageTextWithAttachmentPaths({ | ||
| 6778 | text: turnInput.message.text, | ||
| 6779 | attachments: turnInput.message.attachments, | ||
| 6780 | resolveAttachmentPath: host.resolveAttachmentPath, | ||
| 6781 | }); | ||
| 6782 | const text = t3AcpPromptWithInstructions({ | ||
| 6783 | prompt: messageText, | ||
| 6784 | state: instructionState, | ||
| 6785 | ...(previousInstructionState === undefined | ||
| 6786 | ? {} | ||
| 6787 | : { previousState: previousInstructionState }), | ||
| 6788 | }); | ||
| 6789 | if (text.length > 0) { | ||
| 6790 | prompt.push({ type: "text", text }); | ||
| 6791 | } | ||
| 6792 | const imageAttachments = turnInput.message.attachments.filter( | ||
| 6793 | isProviderNativeImageAttachment, | ||
| 6794 | ); | ||
| 6795 | if (imageAttachments.length > 0 && !supportsImagePrompts) { | ||
| 6796 | return yield* new ProviderAdapter.ProviderAdapterProtocolError({ | ||
| 6797 | driver, | ||
| 6798 | detail: "ACP driver did not negotiate image prompt support", | ||
| 6799 | }); | ||
| 6800 | } | ||
| 6801 | for (const attachment of imageAttachments) { | ||
| 6802 | const path = host.resolveAttachmentPath(attachment as ChatAttachment); | ||
| 6803 | if (path === null) { | ||
| 6804 | return yield* new ProviderAdapter.ProviderAdapterProtocolError({ | ||
| 6805 | driver, | ||
| 6806 | detail: `Invalid attachment id '${attachment.id}'`, | ||
| 6807 | }); | ||
| 6808 | } | ||
| 6809 | const bytes = yield* fileSystem.readFile(path).pipe( | ||
| 6810 | Effect.mapError( | ||
| 6811 | (cause) => | ||
| 6812 | new ProviderAdapter.ProviderAdapterProtocolError({ | ||
| 6813 | driver, | ||
| 6814 | detail: `Failed to read attachment '${attachment.id}'`, | ||
| 6815 | payload: cause, | ||
| 6816 | }), | ||
| 6817 | ), | ||
| 6818 | ); | ||
| 6819 | prompt.push({ | ||
| 6820 | type: "image", | ||
| 6821 | data: Buffer.from(bytes).toString("base64"), | ||
| 6822 | mimeType: attachment.mimeType, | ||
| 6823 | }); | ||
| 6824 | } | ||
| 6825 | if (prompt.length === 0) { | ||
| 6826 | return yield* new ProviderAdapter.ProviderAdapterProtocolError({ | ||
| 6827 | driver, | ||
| 6828 | detail: "ACP turn requires non-empty text or attachments", | ||
| 6829 | }); | ||
| 6830 | } | ||
| 6831 | prompt.push({ | ||
| 6832 | type: "text", | ||
| 6833 | text: buildRuntimeInstructions({ | ||
| 6834 | harness: flavor.runtimeHarness ?? driver, | ||
| 6835 | model: turnInput.modelSelection.model, | ||
| 6836 | }), | ||
| 6837 | }); | ||
| 6838 | return { prompt, instructionState: text === messageText ? undefined : instructionState }; | ||
| 6839 | }); | ||
| 6840 | |||
| 6841 | const restartRuntimeAfterTeardownIfRequired = Effect.fnUntraced(function* ( | ||
| 6842 | threadId: ThreadId | null, | ||
| 6843 | ) { | ||
| 6844 | const restartRequired = yield* Ref.get(runtimeRestartRequired); | ||
| 6845 | if (!restartRequired) return false; | ||
| 6846 | yield* restartAcpRuntime(threadId); | ||
| 6847 | yield* Ref.set(runtimeRestartRequired, false); | ||
| 6848 | yield* Ref.set(activeSessionId, null); | ||
| 6849 | yield* Ref.set(activeSessionSetup, null); | ||
| 6850 | yield* Ref.set(activeSelection, null); | ||
| 6851 | yield* Ref.set(activeInteractionMode, null); | ||
| 6852 | yield* Ref.set(snapshot, { | ||
| 6853 | order: [], | ||
| 6854 | messages: new Map(), | ||
| 6855 | loadingRole: null, | ||
| 6856 | loadingMessageId: null, | ||
| 6857 | loadingIndex: 0, | ||
| 6858 | }); | ||
| 6859 | return true; | ||
| 6860 | }); | ||
| 6861 | |||
| 6862 | const startTurnUnlocked = Effect.fn("AcpAdapterV2.startTurn")( | ||
| 6863 | function* (turnInput: ProviderAdapter.ProviderAdapterV2TurnInput) { | ||
| 6864 | yield* awaitRuntimeTeardown(); | ||
| 6865 | const existing = yield* Ref.get(activeTurn); | ||
| 6866 | if (existing !== null) { | ||
| 6867 | return yield* new ProviderAdapter.ProviderAdapterProtocolError({ | ||
| 6868 | driver, | ||
| 6869 | detail: `ACP provider turn ${existing.providerTurnId} is still active`, | ||
| 6870 | }); | ||
| 6871 | } | ||
| 6872 | useProviderThreadIdentity(turnInput.providerThread); | ||
| 6873 | // Session activation can itself invoke client fs/terminal methods. | ||
| 6874 | // Install the incoming thread policy before load/resume so those | ||
| 6875 | // requests can never inherit the previously active thread's policy. | ||
| 6876 | latestRuntimePolicy = turnInput.runtimePolicy; | ||
| 6877 | const requestedSessionId = yield* nativeThreadId(driver, turnInput.providerThread); | ||
| 6878 | const restartAfterInterrupt = yield* restartRuntimeAfterTeardownIfRequired( | ||
| 6879 | turnInput.threadId, | ||
| 6880 | ); | ||
| 6881 | const needsSessionActivation = | ||
| 6882 | (yield* Ref.get(activeSessionId)) !== requestedSessionId || restartAfterInterrupt; | ||
| 6883 | if (needsSessionActivation) { | ||
| 6884 | const activated = yield* activateSession(requestedSessionId, turnInput.threadId); | ||
| 6885 | yield* Ref.set(activeSessionId, activated.sessionId); | ||
| 6886 | yield* Ref.set(activeSessionSetup, activated); | ||
| 6887 | yield* configureSession(activated, turnInput.modelSelection, turnInput.runtimePolicy); | ||
| 6888 | yield* Ref.set(activeSelection, turnInput.modelSelection); | ||
| 6889 | yield* Ref.set(activeInteractionMode, turnInput.runtimePolicy.interactionMode); | ||
| 6890 | } else { | ||
| 6891 | const configuredSelection = yield* Ref.get(activeSelection); | ||
| 6892 | const configuredInteractionMode = yield* Ref.get(activeInteractionMode); | ||
| 6893 | if ( | ||
| 6894 | configuredSelection === null || | ||
| 6895 | !modelSelectionsEqual(configuredSelection, turnInput.modelSelection) || | ||
| 6896 | configuredInteractionMode !== turnInput.runtimePolicy.interactionMode | ||
| 6897 | ) { | ||
| 6898 | const currentSessionSetup = yield* Ref.get(activeSessionSetup); | ||
| 6899 | if (currentSessionSetup === null) { | ||
| 6900 | return yield* new ProviderAdapter.ProviderAdapterProtocolError({ | ||
| 6901 | driver, | ||
| 6902 | detail: `ACP session ${requestedSessionId} has no active setup metadata`, | ||
| 6903 | }); | ||
| 6904 | } | ||
| 6905 | yield* configureSession( | ||
| 6906 | currentSessionSetup, | ||
| 6907 | turnInput.modelSelection, | ||
| 6908 | turnInput.runtimePolicy, | ||
| 6909 | ); | ||
| 6910 | yield* Ref.set(activeSelection, turnInput.modelSelection); | ||
| 6911 | yield* Ref.set(activeInteractionMode, turnInput.runtimePolicy.interactionMode); | ||
| 6912 | } | ||
| 6913 | } | ||
| 6914 | yield* Ref.set(lastTurnRoute, { | ||
| 6915 | threadId: turnInput.threadId, | ||
| 6916 | providerThreadId: turnInput.providerThread.id, | ||
| 6917 | }); | ||
| 6918 | yield* Ref.set(suppressPostSettleMonitorPrompt, false); | ||
| 6919 | yield* Ref.set(handledBackgroundTaskIdsInActiveTurn, new Set()); | ||
| 6920 | // Continuation turns attach to wake traffic the agent already produced | ||
| 6921 | // after the prior root turn settled; do not re-prompt the ACP session. | ||
| 6922 | const isContinuationTurn = | ||
| 6923 | postSettleContinuationEnabled && acpIsProviderContinuationMessage(turnInput.message); | ||
| 6924 | const isAppOwnedWakeTurn = acpIsAppOwnedWakeTurn(turnInput.message); | ||
| 6925 | // An app-owned wake reports on a sibling delegated child and owns | ||
| 6926 | // none of this session's background work, so it must not discard | ||
| 6927 | // pending wake evidence: neither the mid-turn completion marks kept | ||
| 6928 | // across a settle with work still running, nor the wake buffer | ||
| 6929 | // below. Dropping the marks here would lose the offer for a task | ||
| 6930 | // that completed unreported before the settle. | ||
| 6931 | if (!isAppOwnedWakeTurn) { | ||
| 6932 | yield* Ref.set(midTurnUnreportedCompletedTaskIds, new Set()); | ||
| 6933 | } | ||
| 6934 | // A user turn supersedes an empty offer, but not an offer that owns | ||
| 6935 | // buffered wake traffic. ProviderContinuationService queues that | ||
| 6936 | // continuation behind the user run so it can drain afterwards. | ||
| 6937 | const continuationWasRequested = yield* continuationPermit.withPermit( | ||
| 6938 | Effect.gen(function* () { | ||
| 6939 | const wasRequested = yield* Ref.get(continuationRequested); | ||
| 6940 | const preserveBufferedContinuation = acpTurnStartShouldPreserveContinuation({ | ||
| 6941 | continuationRequested: wasRequested, | ||
| 6942 | isContinuationTurn, | ||
| 6943 | wakeBufferLength: (yield* Ref.get(wakeBuffer)).length, | ||
| 6944 | }); | ||
| 6945 | // User turns must not inherit prior-turn wake residue. Stale | ||
| 6946 | // injected-turn ack chatter can otherwise arm a later offer. | ||
| 6947 | // Preserve only a queued continuation that owns buffered wake | ||
| 6948 | // traffic, and exempt app-owned sibling wakes entirely. | ||
| 6949 | if (!isContinuationTurn && !isAppOwnedWakeTurn && !preserveBufferedContinuation) { | ||
| 6950 | yield* Ref.set(wakeBuffer, []); | ||
| 6951 | yield* Ref.set(wakeReports, noWakeReports); | ||
| 6952 | } | ||
| 6953 | if (preserveBufferedContinuation) return wasRequested; | ||
| 6954 | yield* Ref.update(continuationGeneration, (value) => value + 1); | ||
| 6955 | yield* Ref.set(continuationRequested, false); | ||
| 6956 | return wasRequested; | ||
| 6957 | }), | ||
| 6958 | ); | ||
| 6959 | const promptParts = isContinuationTurn | ||
| 6960 | ? null | ||
| 6961 | : yield* resolvePromptParts(turnInput, requestedSessionId); | ||
| 6962 | const startedAt = yield* DateTime.now; | ||
| 6963 | const nativeTurnId = `${requestedSessionId}:turn:${turnInput.providerTurnOrdinal}`; | ||
| 6964 | const providerTurnId = deriveProviderTurnId(nativeTurnId); | ||
| 6965 | const completed = yield* Deferred.make<void, never>(); | ||
| 6966 | const promptWireSettled = yield* Deferred.make<void, never>(); | ||
| 6967 | const rememberedContextUsage = (yield* Ref.get(contextUsageBySessionId)).get( | ||
| 6968 | requestedSessionId, | ||
| 6969 | ); | ||
| 6970 | const rememberedNativeMetadata = (yield* Ref.get(nativeMetadataBySessionId)).get( | ||
| 6971 | requestedSessionId, | ||
| 6972 | ); | ||
| 6973 | const initialNativeMetadata = | ||
| 6974 | rememberedNativeMetadata === undefined | ||
| 6975 | ? (turnInput.providerThread.nativeMetadata ?? null) | ||
| 6976 | : { | ||
| 6977 | ...turnInput.providerThread.nativeMetadata, | ||
| 6978 | ...rememberedNativeMetadata, | ||
| 6979 | }; | ||
| 6980 | if (initialNativeMetadata !== null) { | ||
| 6981 | yield* Ref.update(nativeMetadataBySessionId, (current) => | ||
| 6982 | new Map(current).set(requestedSessionId, initialNativeMetadata), | ||
| 6983 | ); | ||
| 6984 | } | ||
| 6985 | const context: ActiveAcpTurn = { | ||
| 6986 | input: turnInput, | ||
| 6987 | providerTurnId, | ||
| 6988 | nativeThreadId: requestedSessionId, | ||
| 6989 | nativeTurnId, | ||
| 6990 | startedAt, | ||
| 6991 | completed, | ||
| 6992 | itemOrdinals: new Map(), | ||
| 6993 | user: { current: null, nextSegment: 0 }, | ||
| 6994 | assistant: { current: null, nextSegment: 0 }, | ||
| 6995 | reasoning: { current: null, nextSegment: 0 }, | ||
| 6996 | contextUsage: rememberedContextUsage ?? turnInput.providerThread.contextUsage ?? null, | ||
| 6997 | nativeMetadata: initialNativeMetadata, | ||
| 6998 | tools: new Map(), | ||
| 6999 | toolUpdatesSkipped: new Map(), | ||
| 7000 | toolStartedAt: new Map(), | ||
| 7001 | subagents: new Map(), | ||
| 7002 | subagentsBySessionId: new Map(), | ||
| 7003 | pendingSubagentNotifications: new Map(), | ||
| 7004 | toolCallIdsByBackgroundTaskId: new Map(), | ||
| 7005 | persistentBackgroundTaskIds: new Set(), | ||
| 7006 | awaitingBackgroundHydration: new Set(), | ||
| 7007 | pendingInjectedReport: new Set(), | ||
| 7008 | earlyInjectedReportObserved: false, | ||
| 7009 | plans: new Map(), | ||
| 7010 | interrupted: false, | ||
| 7011 | finalized: false, | ||
| 7012 | finalizedStatus: null, | ||
| 7013 | promptSettled: false, | ||
| 7014 | promptSettledStatus: null, | ||
| 7015 | promptWireSettled, | ||
| 7016 | backgroundFinalizeGeneration: 0, | ||
| 7017 | }; | ||
| 7018 | const carryover = yield* Ref.getAndSet(carryoverSubagents, null); | ||
| 7019 | let rehydratedCarryoverSubagents: ReadonlyArray<ActiveAcpSubagent> = []; | ||
| 7020 | if (carryover !== null && carryover.sessionId === requestedSessionId) { | ||
| 7021 | rehydratedCarryoverSubagents = carryover.subagents; | ||
| 7022 | for (const subagent of carryover.subagents) { | ||
| 7023 | const nativeId = subagent.task.nativeTaskRef?.nativeId ?? null; | ||
| 7024 | if (nativeId !== null) { | ||
| 7025 | context.subagents.set(nativeId, subagent); | ||
| 7026 | } | ||
| 7027 | if (subagent.childSessionId !== null) { | ||
| 7028 | context.subagentsBySessionId.set(subagent.childSessionId, subagent); | ||
| 7029 | } | ||
| 7030 | } | ||
| 7031 | } | ||
| 7032 | yield* Ref.set(activeTurn, context); | ||
| 7033 | // Direct Stop closes and recreates the old runtime before reaching | ||
| 7034 | // this reset. The quarantine remains session-scoped by design. | ||
| 7035 | yield* Ref.set(stoppedRunQuarantine, false); | ||
| 7036 | const runningTurn = providerTurnPayload(context, "running", null); | ||
| 7037 | yield* Ref.update(providerTurns, (current) => { | ||
| 7038 | const updated = new Map(current); | ||
| 7039 | updated.set(String(runningTurn.id), runningTurn); | ||
| 7040 | return updated; | ||
| 7041 | }); | ||
| 7042 | yield* emitProviderEvent({ | ||
| 7043 | type: "provider_turn.updated", | ||
| 7044 | driver, | ||
| 7045 | threadId: turnInput.threadId, | ||
| 7046 | providerTurn: runningTurn, | ||
| 7047 | }); | ||
| 7048 | const activeProviderThread: OrchestrationV2ProviderThread = { | ||
| 7049 | ...turnInput.providerThread, | ||
| 7050 | providerSessionId: input.providerSessionId, | ||
| 7051 | status: "active", | ||
| 7052 | contextUsage: context.contextUsage, | ||
| 7053 | nativeMetadata: context.nativeMetadata, | ||
| 7054 | updatedAt: startedAt, | ||
| 7055 | }; | ||
| 7056 | yield* Ref.update(providerThreadByNativeSessionId, (current) => | ||
| 7057 | new Map(current).set(requestedSessionId, activeProviderThread), | ||
| 7058 | ); | ||
| 7059 | yield* emitProviderEvent({ | ||
| 7060 | type: "provider_thread.updated", | ||
| 7061 | driver, | ||
| 7062 | providerThread: activeProviderThread, | ||
| 7063 | }); | ||
| 7064 | yield* rememberSnapshotMessage({ | ||
| 7065 | createdBy: turnInput.message.createdBy, | ||
| 7066 | creationSource: turnInput.message.creationSource, | ||
| 7067 | ...(turnInput.message.scheduledTaskId === undefined | ||
| 7068 | ? {} | ||
| 7069 | : { scheduledTaskId: turnInput.message.scheduledTaskId }), | ||
| 7070 | ...(turnInput.message.senderThreadId === undefined | ||
| 7071 | ? {} | ||
| 7072 | : { senderThreadId: turnInput.message.senderThreadId }), | ||
| 7073 | id: turnInput.message.messageId, | ||
| 7074 | threadId: turnInput.threadId, | ||
| 7075 | runId: turnInput.runId, | ||
| 7076 | nodeId: turnInput.rootNodeId, | ||
| 7077 | role: "user", | ||
| 7078 | text: turnInput.message.text, | ||
| 7079 | attachments: [...turnInput.message.attachments], | ||
| 7080 | streaming: false, | ||
| 7081 | createdAt: startedAt, | ||
| 7082 | updatedAt: startedAt, | ||
| 7083 | }); | ||
| 7084 | // Every attach ends the deferred-terminal contract, but only the | ||
| 7085 | // queued provider continuation owns wake traffic. A user turn may | ||
| 7086 | // start before that continuation and must leave its buffer intact. | ||
| 7087 | yield* projectDeferredCarryoverTerminals(rehydratedCarryoverSubagents); | ||
| 7088 | // Keep projected terminals addressable through the wake drain so | ||
| 7089 | // emitSubagent's monotonic guard can reject replayed spawn frames. | ||
| 7090 | // Finalize carries only entries with pending background work, so a | ||
| 7091 | // terminal-and-projected lineage still expires with this turn. | ||
| 7092 | if (isContinuationTurn) { | ||
| 7093 | yield* Ref.set(continuationRequested, false); | ||
| 7094 | // This turn delivers what its offer named. Work that ended after | ||
| 7095 | // the offer keeps its report for the next one. | ||
| 7096 | yield* Ref.update(wakeReports, ({ reports, offered }) => ({ | ||
| 7097 | reports: new Map([...reports].filter(([key]) => !offered.has(key))), | ||
| 7098 | offered: noWakeReports.offered, | ||
| 7099 | })); | ||
| 7100 | const drainedWakeCount = yield* Ref.modify(wakeBuffer, (current) => { | ||
| 7101 | const next: Array<EffectAcpSchema.SessionNotification> = []; | ||
| 7102 | return [ | ||
| 7103 | current.filter((notification) => notification.sessionId === requestedSessionId), | ||
| 7104 | next, | ||
| 7105 | ] as const; | ||
| 7106 | }).pipe( | ||
| 7107 | Effect.tap((drained) => | ||
| 7108 | Effect.forEach(drained, handleSessionUpdate, { | ||
| 7109 | concurrency: 1, | ||
| 7110 | discard: true, | ||
| 7111 | }), | ||
| 7112 | ), | ||
| 7113 | Effect.map((drained) => drained.length), | ||
| 7114 | ); | ||
| 7115 | // Treat attach mode as prompt-settled so deferred finalize / quiet | ||
| 7116 | // windows can complete the continuation after wake traffic drains. | ||
| 7117 | context.promptSettled = true; | ||
| 7118 | context.promptSettledStatus = "completed"; | ||
| 7119 | if (drainedWakeCount === 0) { | ||
| 7120 | // A requested continuation with only mid-turn evidence has no | ||
| 7121 | // buffered frame yet. Wait the quiet window so late CLI frames | ||
| 7122 | // can attach. A provider-authored attach without a matching | ||
| 7123 | // request only exists to project deferred carryover terminals, | ||
| 7124 | // so it can finish immediately once those terminals are visible. | ||
| 7125 | if (continuationWasRequested && flavor.deferFinalizeForBackgroundWork === true) { | ||
| 7126 | if (hasDeferredBackgroundWork(context)) { | ||
| 7127 | yield* rearmDeferredFinalize(context); | ||
| 7128 | } else { | ||
| 7129 | yield* scheduleDeferredFinalize(context); | ||
| 7130 | } | ||
| 7131 | } else { | ||
| 7132 | yield* finalizeTurn(context, "completed"); | ||
| 7133 | } | ||
| 7134 | return; | ||
| 7135 | } | ||
| 7136 | if (!context.finalized) { | ||
| 7137 | if (hasDeferredBackgroundWork(context)) { | ||
| 7138 | yield* rearmDeferredFinalize(context); | ||
| 7139 | } else { | ||
| 7140 | yield* scheduleDeferredFinalize(context); | ||
| 7141 | } | ||
| 7142 | } | ||
| 7143 | return; | ||
| 7144 | } | ||
| 7145 | const promptGeneration = yield* Ref.get(runtimeCallbackGeneration); | ||
| 7146 | yield* runtime.prompt({ prompt: promptParts!.prompt }).pipe( | ||
| 7147 | Effect.tap(() => | ||
| 7148 | Ref.update(promptInstructionStates, (current) => { | ||
| 7149 | if (promptParts?.instructionState === undefined) return current; | ||
| 7150 | const updated = new Map(current); | ||
| 7151 | updated.set(requestedSessionId, promptParts.instructionState); | ||
| 7152 | return updated; | ||
| 7153 | }), | ||
| 7154 | ), | ||
| 7155 | // Wire settlement precedes the completion callback's permit request so | ||
| 7156 | // settled-soft classification can observe the native return even when | ||
| 7157 | // the completion fiber has not yet set promptSettled under the permit. | ||
| 7158 | Effect.tap(() => | ||
| 7159 | Deferred.succeed(context.promptWireSettled, undefined).pipe(Effect.asVoid), | ||
| 7160 | ), | ||
| 7161 | Effect.flatMap((result) => | ||
| 7162 | runRuntimeCallbackAtGeneration( | ||
| 7163 | promptGeneration, | ||
| 7164 | Effect.gen(function* () { | ||
| 7165 | if (context.finalized) return; | ||
| 7166 | const status = | ||
| 7167 | result.stopReason === "cancelled" | ||
| 7168 | ? context.interrupted | ||
| 7169 | ? "interrupted" | ||
| 7170 | : "cancelled" | ||
| 7171 | : "completed"; | ||
| 7172 | // Grok monitors (and async subagents) keep working after the root | ||
| 7173 | // prompt RPC returns. Defer finalize so their later updates and | ||
| 7174 | // wake-turn traffic still project onto this run. | ||
| 7175 | if ( | ||
| 7176 | flavor.deferFinalizeForBackgroundWork === true && | ||
| 7177 | !context.interrupted && | ||
| 7178 | hasDeferredBackgroundWork(context) | ||
| 7179 | ) { | ||
| 7180 | context.promptSettled = true; | ||
| 7181 | context.promptSettledStatus = status; | ||
| 7182 | // The agent finished this prompt's reply. Background work | ||
| 7183 | // holds the run open, not the text it already sent. | ||
| 7184 | yield* closeTextStreams(context); | ||
| 7185 | yield* ( | ||
| 7186 | options.testHooks?.afterPromptSettledWithBackgroundWork?.() ?? Effect.void | ||
| 7187 | ); | ||
| 7188 | return; | ||
| 7189 | } | ||
| 7190 | yield* finalizeTurn(context, status); | ||
| 7191 | }), | ||
| 7192 | ).pipe(Effect.asVoid), | ||
| 7193 | ), | ||
| 7194 | // Prompt failure is not wire-settled: only a successful resolve marks | ||
| 7195 | // the signal. catchCause must not complete promptWireSettled. | ||
| 7196 | Effect.catchCause((cause) => | ||
| 7197 | runRuntimeCallbackAtGeneration( | ||
| 7198 | promptGeneration, | ||
| 7199 | Effect.gen(function* () { | ||
| 7200 | if (context.finalized) return; | ||
| 7201 | yield* finalizeTurn( | ||
| 7202 | context, | ||
| 7203 | context.interrupted ? "interrupted" : "failed", | ||
| 7204 | flavor.promptFailure?.(Cause.squash(cause)) ?? | ||
| 7205 | makeProviderFailure({ | ||
| 7206 | cause: Cause.squash(cause), | ||
| 7207 | class: "provider_error", | ||
| 7208 | }), | ||
| 7209 | ).pipe( | ||
| 7210 | Effect.andThen( | ||
| 7211 | Effect.logWarning("orchestration-v2.acp-prompt-failed", { | ||
| 7212 | driver, | ||
| 7213 | providerSessionId: input.providerSessionId, | ||
| 7214 | providerThreadId: turnInput.providerThread.id, | ||
| 7215 | providerTurnId, | ||
| 7216 | cause, | ||
| 7217 | }), | ||
| 7218 | ), | ||
| 7219 | ); | ||
| 7220 | }), | ||
| 7221 | ).pipe(Effect.asVoid), | ||
| 7222 | ), | ||
| 7223 | Effect.forkIn(sessionScope), | ||
| 7224 | ); | ||
| 7225 | }, | ||
| 7226 | (effect, turnInput) => | ||
| 7227 | effect.pipe( | ||
| 7228 | Effect.mapError( | ||
| 7229 | (cause) => | ||
| 7230 | new ProviderAdapter.ProviderAdapterTurnStartError({ | ||
| 7231 | driver, | ||
| 7232 | threadId: turnInput.threadId, | ||
| 7233 | providerThreadId: turnInput.providerThread.id, | ||
| 7234 | runId: turnInput.runId, | ||
| 7235 | cause, | ||
| 7236 | }), | ||
| 7237 | ), | ||
| 7238 | ), | ||
| 7239 | ); | ||
| 7240 | |||
| 7241 | const startTurn = Effect.fn("AcpAdapterV2.startTurn.transition")(function* ( | ||
| 7242 | turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, | ||
| 7243 | ) { | ||
| 7244 | return yield* runtimeTransitionPermit.withPermit(startTurnUnlocked(turnInput)); | ||
| 7245 | }); | ||
| 7246 | |||
| 7247 | yield* Effect.addFinalizer(() => | ||
| 7248 | Effect.gen(function* () { | ||
| 7249 | yield* continuationPermit.withPermit( | ||
| 7250 | Effect.gen(function* () { | ||
| 7251 | yield* Ref.set(continuationClosed, true); | ||
| 7252 | yield* Ref.update(continuationGeneration, (value) => value + 1); | ||
| 7253 | }), | ||
| 7254 | ); | ||
| 7255 | const requests = [...(yield* Ref.get(pendingRuntimeRequests)).values()]; | ||
| 7256 | yield* Effect.forEach( | ||
| 7257 | requests, | ||
| 7258 | (request) => | ||
| 7259 | request.type === "approval" | ||
| 7260 | ? Deferred.succeed(request.decision, "cancel").pipe(Effect.ignore) | ||
| 7261 | : Deferred.succeed(request.answers, null).pipe(Effect.ignore), | ||
| 7262 | { discard: true }, | ||
| 7263 | ); | ||
| 7264 | const closingError = new EffectAcpErrors.AcpTransportError({ | ||
| 7265 | detail: "The ACP session closed before its admitted response reached the transport", | ||
| 7266 | cause: "ACP session transport closed", | ||
| 7267 | }); | ||
| 7268 | yield* Effect.forEach( | ||
| 7269 | requests, | ||
| 7270 | (request) => Deferred.fail(request.nativeResponseAcknowledgement, closingError), | ||
| 7271 | { discard: true }, | ||
| 7272 | ); | ||
| 7273 | const transportHadOutstandingResponses = yield* closeNativeTransport; | ||
| 7274 | yield* options.testHooks?.afterNativeResponseTransportClosed?.() ?? Effect.void; | ||
| 7275 | const sessionCapabilities = | ||
| 7276 | started.initializeResult.agentCapabilities?.sessionCapabilities; | ||
| 7277 | if (sessionCapabilities?.close != null) { | ||
| 7278 | yield* runtimeTransitionPermit.withPermitsIfAvailable(1)( | ||
| 7279 | Effect.gen(function* () { | ||
| 7280 | const teardownState = yield* Ref.get(runtimeTeardownState); | ||
| 7281 | const restartRequired = yield* Ref.get(runtimeRestartRequired); | ||
| 7282 | if ( | ||
| 7283 | teardownState._tag === "Idle" && | ||
| 7284 | !restartRequired && | ||
| 7285 | !transportHadOutstandingResponses | ||
| 7286 | ) { | ||
| 7287 | yield* runtime.closeSession().pipe(Effect.ignore); | ||
| 7288 | } | ||
| 7289 | }), | ||
| 7290 | ); | ||
| 7291 | } | ||
| 7292 | if (flavor.assertComplete !== undefined) { | ||
| 7293 | yield* flavor.assertComplete.pipe(Effect.orDie); | ||
| 7294 | } | ||
| 7295 | if (runtimeScope !== undefined) { | ||
| 7296 | yield* Scope.close(runtimeScope, Exit.void).pipe(Effect.ignore); | ||
| 7297 | } | ||
| 7298 | }), | ||
| 7299 | ); | ||
| 7300 | |||
| 7301 | const sessionRuntime: ProviderAdapter.ProviderAdapterV2SessionRuntime = { | ||
| 7302 | instanceId: options.instanceId, | ||
| 7303 | driver, | ||
| 7304 | providerSessionId: input.providerSessionId, | ||
| 7305 | providerSession, | ||
| 7306 | events: Stream.fromEffectRepeat(Queue.take(events)), | ||
| 7307 | ...(postSettleContinuationEnabled | ||
| 7308 | ? { | ||
| 7309 | hasPendingBackgroundWork: Effect.gen(function* () { | ||
| 7310 | if ((yield* Ref.get(wakeBuffer)).length > 0) return true; | ||
| 7311 | if (yield* Ref.get(continuationRequested)) return true; | ||
| 7312 | if ((yield* Ref.get(runningBackgroundTaskIds)).size > 0) return true; | ||
| 7313 | // Projected post-settle Grok subagents can outlive the root | ||
| 7314 | // turn via carryover; keep the ACP process pinned until they | ||
| 7315 | // terminalize or teardown clears the carryover. | ||
| 7316 | // Also pin while a terminal status is held only in memory | ||
| 7317 | // (project:false) so idle release cannot drop the session | ||
| 7318 | // before the continuation drain (or a later project:true path) | ||
| 7319 | // delivers the turn_item terminal. | ||
| 7320 | const active = yield* Ref.get(activeTurn); | ||
| 7321 | if ( | ||
| 7322 | active !== null && | ||
| 7323 | [...active.subagents.values()].some(acpSubagentHasPendingBackgroundWork) | ||
| 7324 | ) { | ||
| 7325 | return true; | ||
| 7326 | } | ||
| 7327 | const carryover = yield* Ref.get(carryoverSubagents); | ||
| 7328 | if ( | ||
| 7329 | carryover !== null && | ||
| 7330 | carryover.subagents.some(acpSubagentHasPendingBackgroundWork) | ||
| 7331 | ) { | ||
| 7332 | return true; | ||
| 7333 | } | ||
| 7334 | return false; | ||
| 7335 | }), | ||
| 7336 | } | ||
| 7337 | : {}), | ||
| 7338 | ensureThread: Effect.fn("AcpAdapterV2.ensureThread")( | ||
| 7339 | function* (threadInput: ProviderAdapter.ProviderAdapterV2EnsureThreadInput) { | ||
| 7340 | const now = yield* DateTime.now; | ||
| 7341 | const sessionId = yield* Ref.get(activeSessionId); | ||
| 7342 | if (sessionId === null) { | ||
| 7343 | return yield* new ProviderAdapter.ProviderAdapterProtocolError({ | ||
| 7344 | driver, | ||
| 7345 | detail: "ACP runtime did not produce a session id", | ||
| 7346 | }); | ||
| 7347 | } | ||
| 7348 | const providerThread = makeProviderThread({ | ||
| 7349 | driver, | ||
| 7350 | providerInstanceId: options.instanceId, | ||
| 7351 | idAllocator, | ||
| 7352 | appThreadId: threadInput.threadId, | ||
| 7353 | providerSessionId: input.providerSessionId, | ||
| 7354 | nativeThreadId: sessionId, | ||
| 7355 | ...(itemIdentityVersion === undefined ? {} : { itemIdentityVersion }), | ||
| 7356 | now, | ||
| 7357 | }); | ||
| 7358 | yield* Ref.update(providerThreadByNativeSessionId, (current) => | ||
| 7359 | new Map(current).set(sessionId, providerThread), | ||
| 7360 | ); | ||
| 7361 | return providerThread; | ||
| 7362 | }, | ||
| 7363 | (effect, threadInput) => | ||
| 7364 | effect.pipe( | ||
| 7365 | Effect.mapError( | ||
| 7366 | (cause) => | ||
| 7367 | new ProviderAdapter.ProviderAdapterEnsureThreadError({ | ||
| 7368 | driver, | ||
| 7369 | threadId: threadInput.threadId, | ||
| 7370 | cause, | ||
| 7371 | }), | ||
| 7372 | ), | ||
| 7373 | ), | ||
| 7374 | ), | ||
| 7375 | resumeThread: Effect.fn("AcpAdapterV2.resumeThread")( | ||
| 7376 | function* (threadInput: { | ||
| 7377 | readonly providerThread: OrchestrationV2ProviderThread; | ||
| 7378 | readonly modelSelection?: ModelSelection; | ||
| 7379 | readonly runtimePolicy?: ProviderAdapter.ProviderAdapterV2RuntimePolicy; | ||
| 7380 | }) { | ||
| 7381 | return yield* runtimeTransitionPermit.withPermit( | ||
| 7382 | Effect.gen(function* () { | ||
| 7383 | yield* awaitRuntimeTeardown(); | ||
| 7384 | const sessionId = yield* nativeThreadId(driver, threadInput.providerThread); | ||
| 7385 | const previousItemIdentityVersion = itemIdentityVersion; | ||
| 7386 | useProviderThreadIdentity(threadInput.providerThread); | ||
| 7387 | const restorePreviousItemIdentity = Effect.sync(() => { | ||
| 7388 | itemIdentityVersion = previousItemIdentityVersion; | ||
| 7389 | }); | ||
| 7390 | const restartAfterInterrupt = yield* restartRuntimeAfterTeardownIfRequired( | ||
| 7391 | threadInput.providerThread.appThreadId, | ||
| 7392 | ).pipe(Effect.tapError(() => restorePreviousItemIdentity)); | ||
| 7393 | if ((yield* Ref.get(activeSessionId)) !== sessionId || restartAfterInterrupt) { | ||
| 7394 | yield* Ref.set(snapshot, { | ||
| 7395 | order: [], | ||
| 7396 | messages: new Map(), | ||
| 7397 | loadingRole: null, | ||
| 7398 | loadingMessageId: null, | ||
| 7399 | loadingIndex: 0, | ||
| 7400 | }); | ||
| 7401 | const activated = yield* activateSession( | ||
| 7402 | sessionId, | ||
| 7403 | threadInput.providerThread.appThreadId, | ||
| 7404 | ).pipe(Effect.tapError(() => restorePreviousItemIdentity)); | ||
| 7405 | yield* Ref.set(activeSessionId, activated.sessionId); | ||
| 7406 | yield* Ref.set(activeSessionSetup, activated); | ||
| 7407 | const nextSelection = threadInput.modelSelection ?? input.modelSelection; | ||
| 7408 | const nextRuntimePolicy = threadInput.runtimePolicy ?? input.runtimePolicy; | ||
| 7409 | yield* configureSession(activated, nextSelection, nextRuntimePolicy); | ||
| 7410 | yield* Ref.set(activeSelection, nextSelection); | ||
| 7411 | yield* Ref.set(activeInteractionMode, nextRuntimePolicy.interactionMode); | ||
| 7412 | } | ||
| 7413 | const now = yield* DateTime.now; | ||
| 7414 | return { | ||
| 7415 | ...threadInput.providerThread, | ||
| 7416 | providerSessionId: input.providerSessionId, | ||
| 7417 | status: "idle" as const, | ||
| 7418 | updatedAt: now, | ||
| 7419 | }; | ||
| 7420 | }), | ||
| 7421 | ); | ||
| 7422 | }, | ||
| 7423 | (effect, threadInput) => | ||
| 7424 | effect.pipe( | ||
| 7425 | Effect.mapError( | ||
| 7426 | (cause) => | ||
| 7427 | new ProviderAdapter.ProviderAdapterResumeThreadError({ | ||
| 7428 | driver, | ||
| 7429 | providerSessionId: input.providerSessionId, | ||
| 7430 | providerThreadId: threadInput.providerThread.id, | ||
| 7431 | cause, | ||
| 7432 | }), | ||
| 7433 | ), | ||
| 7434 | ), | ||
| 7435 | ), | ||
| 7436 | startTurn, | ||
| 7437 | ...(flavor.supportsCompaction === true | ||
| 7438 | ? { | ||
| 7439 | compactThread: (turnInput: ProviderAdapter.ProviderAdapterV2TurnInput) => | ||
| 7440 | startTurn({ | ||
| 7441 | ...turnInput, | ||
| 7442 | message: { ...turnInput.message, text: "/compact" }, | ||
| 7443 | }), | ||
| 7444 | } | ||
| 7445 | : {}), | ||
| 7446 | steerTurn: (turnInput) => | ||
| 7447 | Effect.fail( | ||
| 7448 | new ProviderAdapter.ProviderAdapterSteerRunUnsupportedError({ | ||
| 7449 | driver, | ||
| 7450 | providerThreadId: turnInput.providerThread.id, | ||
| 7451 | }), | ||
| 7452 | ), | ||
| 7453 | interruptTurn: Effect.fn("AcpAdapterV2.interruptTurn")( | ||
| 7454 | function* (turnInput: ProviderAdapter.ProviderAdapterV2InterruptInput) { | ||
| 7455 | return yield* Effect.uninterruptibleMask((restore) => | ||
| 7456 | Effect.gen(function* () { | ||
| 7457 | yield* restore(runtimeTransitionPermit.take(1)); | ||
| 7458 | return yield* Effect.gen(function* () { | ||
| 7459 | const transition = yield* Effect.gen(function* () { | ||
| 7460 | const interruptContext = yield* Ref.get(activeTurn); | ||
| 7461 | // Settled steering restart: the native prompt already | ||
| 7462 | // returned and this is not a user Stop, so keep the | ||
| 7463 | // process (and its background subagents) alive instead | ||
| 7464 | // of hard-killing it. The turn still terminalizes below. | ||
| 7465 | const softSteerCandidate = | ||
| 7466 | flavor.preserveRuntimeOnSettledInterrupt === true && | ||
| 7467 | turnInput.requestRuntimeRestart !== true && | ||
| 7468 | interruptContext?.providerTurnId === turnInput.providerTurnId; | ||
| 7469 | // Settlement is read under the callback permit after | ||
| 7470 | // draining admitted responses: the native prompt can have | ||
| 7471 | // returned on the wire while its completion callback has | ||
| 7472 | // not yet run, and a cancel sent in that window is exactly | ||
| 7473 | // what settled-soft mode exists to avoid. Also treat the | ||
| 7474 | // turn as settled when the wire signal is already done | ||
| 7475 | // (non-blocking; do not await under the permit). | ||
| 7476 | let settledSoftInterrupt = false; | ||
| 7477 | if (softSteerCandidate && interruptContext !== null) { | ||
| 7478 | const promptSettledUnderPermit = yield* runtimeCallbackPermit.withPermit( | ||
| 7479 | awaitAdmittedNativeResponses.pipe( | ||
| 7480 | Effect.andThen( | ||
| 7481 | Effect.sync(() => interruptContext.promptSettled === true), | ||
| 7482 | ), | ||
| 7483 | ), | ||
| 7484 | ); | ||
| 7485 | const wireSettled = yield* Deferred.isDone( | ||
| 7486 | interruptContext.promptWireSettled, | ||
| 7487 | ); | ||
| 7488 | settledSoftInterrupt = promptSettledUnderPermit || wireSettled; | ||
| 7489 | } | ||
| 7490 | const restartRuntime = | ||
| 7491 | !settledSoftInterrupt && | ||
| 7492 | (turnInput.requestRuntimeRestart === true || | ||
| 7493 | flavor.restartRuntimeOnEveryInterrupt === true); | ||
| 7494 | const hardRestart = | ||
| 7495 | restartRuntime && flavor.terminateRuntimeProcessGroupOnInterrupt === true; | ||
| 7496 | if (hardRestart) { | ||
| 7497 | const teardownState = yield* Ref.get(runtimeTeardownState); | ||
| 7498 | if (teardownState._tag === "Failed") { | ||
| 7499 | return yield* teardownState.error; | ||
| 7500 | } | ||
| 7501 | if (teardownState._tag === "InProgress") { | ||
| 7502 | // Concurrent Stop/restart: wait for the in-flight hard | ||
| 7503 | // teardown instead of failing the durable effect. | ||
| 7504 | yield* Effect.logInfo( | ||
| 7505 | "ACP interrupt awaiting in-progress hard teardown", | ||
| 7506 | { | ||
| 7507 | driver, | ||
| 7508 | providerTurnId: turnInput.providerTurnId, | ||
| 7509 | }, | ||
| 7510 | ); | ||
| 7511 | yield* Deferred.await(teardownState.completed); | ||
| 7512 | return undefined; | ||
| 7513 | } | ||
| 7514 | } | ||
| 7515 | const context = interruptContext; | ||
| 7516 | if (context?.providerTurnId !== turnInput.providerTurnId) { | ||
| 7517 | // A soft steering interrupt can clear the turn while | ||
| 7518 | // intentionally leaving the process alive. A queued user | ||
| 7519 | // Stop must still contain that orphan runtime. With a | ||
| 7520 | // different live turn active this stays a pure no-op | ||
| 7521 | // success instead: quarantine and teardown are | ||
| 7522 | // session-global and would maim the replacement turn. | ||
| 7523 | const containOrphanRuntime = | ||
| 7524 | context === null && | ||
| 7525 | hardRestart && | ||
| 7526 | turnInput.requestRuntimeRestart === true; | ||
| 7527 | // Transport death or a prior Stop already cleared the | ||
| 7528 | // turn. Failing here caused effect-worker retries while | ||
| 7529 | // the process was already gone; treat as success. | ||
| 7530 | yield* Effect.logWarning( | ||
| 7531 | containOrphanRuntime | ||
| 7532 | ? "ACP Stop raced a soft interrupt that cleared the turn; containing the orphan runtime" | ||
| 7533 | : "ACP interrupt raced transport teardown or a prior Stop; treating as already interrupted", | ||
| 7534 | { | ||
| 7535 | driver, | ||
| 7536 | requestedProviderTurnId: turnInput.providerTurnId, | ||
| 7537 | activeProviderTurnId: context?.providerTurnId ?? null, | ||
| 7538 | hardRestart, | ||
| 7539 | requestRuntimeRestart: turnInput.requestRuntimeRestart === true, | ||
| 7540 | teardownState: (yield* Ref.get(runtimeTeardownState))._tag, | ||
| 7541 | }, | ||
| 7542 | ); | ||
| 7543 | if (containOrphanRuntime) { | ||
| 7544 | const teardownBarrier = yield* Deferred.make< | ||
| 7545 | void, | ||
| 7546 | ProviderAdapter.ProviderAdapterProtocolError | ||
| 7547 | >(); | ||
| 7548 | yield* runtimeCallbackPermit.withPermit( | ||
| 7549 | Effect.gen(function* () { | ||
| 7550 | yield* awaitAdmittedNativeResponses; | ||
| 7551 | const stoppedGeneration = yield* Ref.get(runtimeCallbackGeneration); | ||
| 7552 | yield* quarantineNativeTransportAtGeneration(stoppedGeneration); | ||
| 7553 | yield* Ref.set(runtimeTeardownState, { | ||
| 7554 | _tag: "InProgress", | ||
| 7555 | completed: teardownBarrier, | ||
| 7556 | }); | ||
| 7557 | yield* advanceRuntimeCallbackGeneration; | ||
| 7558 | }), | ||
| 7559 | ); | ||
| 7560 | // Capture before quarantineStoppedRun clears carryover. | ||
| 7561 | const orphanCarryover = yield* Ref.getAndSet(carryoverSubagents, null); | ||
| 7562 | yield* quarantineStoppedRun(); | ||
| 7563 | // Match the main hard-restart path: cancel pending | ||
| 7564 | // approvals/elicitations after quarantine, before kill. | ||
| 7565 | yield* cancelPendingRuntimeRequests(); | ||
| 7566 | yield* terminalizeCarryoverSubagents(orphanCarryover); | ||
| 7567 | if (runtime.terminateProcessGroup === undefined) { | ||
| 7568 | const error = new ProviderAdapter.ProviderAdapterProtocolError({ | ||
| 7569 | driver, | ||
| 7570 | detail: | ||
| 7571 | "ACP runtime does not expose its required process-group teardown; the session is poisoned", | ||
| 7572 | }); | ||
| 7573 | yield* Ref.set(runtimeTeardownState, { _tag: "Failed", error }); | ||
| 7574 | yield* Deferred.fail(teardownBarrier, error).pipe(Effect.ignore); | ||
| 7575 | return yield* error; | ||
| 7576 | } | ||
| 7577 | const teardownExit = yield* runtime.terminateProcessGroup.pipe( | ||
| 7578 | Effect.exit, | ||
| 7579 | ); | ||
| 7580 | if (Exit.isFailure(teardownExit)) { | ||
| 7581 | const error = new ProviderAdapter.ProviderAdapterProtocolError({ | ||
| 7582 | driver, | ||
| 7583 | detail: | ||
| 7584 | "ACP orphan runtime process-group teardown failed; the session is poisoned", | ||
| 7585 | payload: Cause.squash(teardownExit.cause), | ||
| 7586 | }); | ||
| 7587 | yield* Ref.set(runtimeTeardownState, { _tag: "Failed", error }); | ||
| 7588 | yield* Deferred.fail(teardownBarrier, error).pipe(Effect.ignore); | ||
| 7589 | return yield* error; | ||
| 7590 | } | ||
| 7591 | yield* Ref.set(runtimeRestartRequired, true); | ||
| 7592 | yield* Ref.set(runtimeTeardownState, { _tag: "Idle" }); | ||
| 7593 | yield* Deferred.succeed(teardownBarrier, undefined).pipe(Effect.ignore); | ||
| 7594 | } | ||
| 7595 | return undefined; | ||
| 7596 | } | ||
| 7597 | const teardownBarrier = hardRestart | ||
| 7598 | ? yield* Deferred.make<void, ProviderAdapter.ProviderAdapterProtocolError>() | ||
| 7599 | : null; | ||
| 7600 | if (teardownBarrier !== null) { | ||
| 7601 | yield* runtimeCallbackPermit.withPermit( | ||
| 7602 | Effect.gen(function* () { | ||
| 7603 | yield* awaitAdmittedNativeResponses; | ||
| 7604 | const stoppedGeneration = yield* Ref.get(runtimeCallbackGeneration); | ||
| 7605 | yield* quarantineNativeTransportAtGeneration(stoppedGeneration); | ||
| 7606 | yield* Ref.set(runtimeTeardownState, { | ||
| 7607 | _tag: "InProgress", | ||
| 7608 | completed: teardownBarrier, | ||
| 7609 | }); | ||
| 7610 | yield* advanceRuntimeCallbackGeneration; | ||
| 7611 | yield* ( | ||
| 7612 | options.testHooks?.afterHardTeardownTransportDrained?.() ?? | ||
| 7613 | Effect.void | ||
| 7614 | ); | ||
| 7615 | }), | ||
| 7616 | ); | ||
| 7617 | } | ||
| 7618 | return { | ||
| 7619 | context, | ||
| 7620 | restartRuntime, | ||
| 7621 | hardRestart, | ||
| 7622 | teardownBarrier, | ||
| 7623 | settledSoftInterrupt, | ||
| 7624 | }; | ||
| 7625 | }); | ||
| 7626 | // Concurrent hard teardown already completed above. | ||
| 7627 | if (transition === undefined) return; | ||
| 7628 | const { | ||
| 7629 | context, | ||
| 7630 | restartRuntime, | ||
| 7631 | hardRestart, | ||
| 7632 | teardownBarrier, | ||
| 7633 | settledSoftInterrupt, | ||
| 7634 | } = transition; | ||
| 7635 | const poisonTeardown = Effect.fnUntraced(function* ( | ||
| 7636 | detail: string, | ||
| 7637 | payload?: unknown, | ||
| 7638 | ) { | ||
| 7639 | const error = new ProviderAdapter.ProviderAdapterProtocolError({ | ||
| 7640 | driver, | ||
| 7641 | detail, | ||
| 7642 | ...(payload === undefined ? {} : { payload }), | ||
| 7643 | }); | ||
| 7644 | yield* Ref.set(runtimeTeardownState, { _tag: "Failed", error }); | ||
| 7645 | yield* Deferred.fail(teardownBarrier!, error).pipe(Effect.ignore); | ||
| 7646 | return error; | ||
| 7647 | }); | ||
| 7648 | const runTransition = Effect.gen(function* () { | ||
| 7649 | context.interrupted = true; | ||
| 7650 | // Quarantine only when this run is discarded (user Stop / | ||
| 7651 | // hard process kill). Soft in-process restarts must keep | ||
| 7652 | // carryoverSubagents so a still-running subagent can | ||
| 7653 | // complete into the replacement turn. | ||
| 7654 | if (hardRestart || turnInput.requestRuntimeRestart === true) { | ||
| 7655 | yield* quarantineStoppedRun(); | ||
| 7656 | } | ||
| 7657 | yield* cancelPendingRuntimeRequests(); | ||
| 7658 | // Finalize before process-group kill so projection/UI cannot | ||
| 7659 | // lag a dead transport, and concurrent interrupt effects see | ||
| 7660 | // activeTurn cleared (idempotent success) rather than racing. | ||
| 7661 | if ( | ||
| 7662 | (hardRestart || context.promptSettled || settledSoftInterrupt) && | ||
| 7663 | !context.finalized | ||
| 7664 | ) { | ||
| 7665 | // hardRestart: always terminalize locally. | ||
| 7666 | // promptSettled / settledSoftInterrupt (incl. wire-settled): | ||
| 7667 | // native prompt already returned; only deferred background | ||
| 7668 | // work remains, so session/cancel has nothing to acknowledge. | ||
| 7669 | yield* finalizeTurn(context, "interrupted"); | ||
| 7670 | } | ||
| 7671 | if (hardRestart) { | ||
| 7672 | if (runtime.terminateProcessGroup === undefined) { | ||
| 7673 | return yield* poisonTeardown( | ||
| 7674 | "ACP runtime does not expose its required process-group teardown; the session is poisoned", | ||
| 7675 | ).pipe(Effect.flatMap(Effect.fail)); | ||
| 7676 | } | ||
| 7677 | const teardownExit = yield* runtime.terminateProcessGroup!.pipe( | ||
| 7678 | Effect.exit, | ||
| 7679 | ); | ||
| 7680 | if (Exit.isFailure(teardownExit)) { | ||
| 7681 | return yield* poisonTeardown( | ||
| 7682 | "ACP runtime process-group teardown failed; the session is poisoned", | ||
| 7683 | Cause.squash(teardownExit.cause), | ||
| 7684 | ).pipe(Effect.flatMap(Effect.fail)); | ||
| 7685 | } | ||
| 7686 | // Process group is gone; the next turn must spawn a | ||
| 7687 | // replacement even if the flavor only set hardRestart | ||
| 7688 | // without restartRuntimeAfterInterrupt. | ||
| 7689 | yield* Ref.set(runtimeRestartRequired, true); | ||
| 7690 | yield* Ref.set(runtimeTeardownState, { _tag: "Idle" }); | ||
| 7691 | yield* Deferred.succeed(teardownBarrier!, undefined).pipe(Effect.ignore); | ||
| 7692 | } else { | ||
| 7693 | // Settled soft interrupt: the native prompt already | ||
| 7694 | // returned, so session/cancel has nothing to | ||
| 7695 | // acknowledge and would only threaten still-running | ||
| 7696 | // background subagents. Skip it and leave the runtime | ||
| 7697 | // untouched for the replacement turn. | ||
| 7698 | if (!settledSoftInterrupt) { | ||
| 7699 | yield* runtime.cancel; | ||
| 7700 | } | ||
| 7701 | if (restartRuntime && flavor.restartRuntimeAfterInterrupt === true) { | ||
| 7702 | yield* Ref.set(runtimeRestartRequired, true); | ||
| 7703 | } | ||
| 7704 | } | ||
| 7705 | const stopped = yield* Deferred.await(context.completed).pipe( | ||
| 7706 | Effect.timeoutOption("10 seconds"), | ||
| 7707 | ); | ||
| 7708 | if (Option.isNone(stopped)) { | ||
| 7709 | if (!context.finalized) { | ||
| 7710 | yield* finalizeTurn(context, "interrupted"); | ||
| 7711 | } | ||
| 7712 | return yield* new ProviderAdapter.ProviderAdapterProtocolError({ | ||
| 7713 | driver, | ||
| 7714 | detail: `ACP provider turn ${turnInput.providerTurnId} did not acknowledge cancellation before the interrupt timeout`, | ||
| 7715 | }); | ||
| 7716 | } | ||
| 7717 | }); | ||
| 7718 | if (!hardRestart) { | ||
| 7719 | return yield* restore(runTransition); | ||
| 7720 | } | ||
| 7721 | return yield* runTransition.pipe( | ||
| 7722 | Effect.catchCause((cause) => | ||
| 7723 | Effect.gen(function* () { | ||
| 7724 | const state = yield* Ref.get(runtimeTeardownState); | ||
| 7725 | if (state._tag === "InProgress") { | ||
| 7726 | yield* poisonTeardown( | ||
| 7727 | "ACP hard teardown failed unexpectedly; the session is poisoned", | ||
| 7728 | Cause.squash(cause), | ||
| 7729 | ); | ||
| 7730 | } | ||
| 7731 | return yield* Effect.failCause(cause); | ||
| 7732 | }), | ||
| 7733 | ), | ||
| 7734 | ); | ||
| 7735 | }).pipe(Effect.ensuring(runtimeTransitionPermit.release(1))); | ||
| 7736 | }), | ||
| 7737 | ); | ||
| 7738 | }, | ||
| 7739 | (effect, turnInput) => | ||
| 7740 | effect.pipe( | ||
| 7741 | Effect.mapError( | ||
| 7742 | (cause) => | ||
| 7743 | new ProviderAdapter.ProviderAdapterInterruptError({ | ||
| 7744 | driver, | ||
| 7745 | providerThreadId: turnInput.providerThread.id, | ||
| 7746 | providerTurnId: turnInput.providerTurnId, | ||
| 7747 | cause, | ||
| 7748 | }), | ||
| 7749 | ), | ||
| 7750 | ), | ||
| 7751 | ), | ||
| 7752 | respondToRuntimeRequest: (requestInput) => | ||
| 7753 | Effect.uninterruptibleMask((restore) => | ||
| 7754 | Effect.gen(function* () { | ||
| 7755 | yield* restore(runtimeTransitionPermit.take(1)); | ||
| 7756 | return yield* Effect.gen(function* () { | ||
| 7757 | yield* awaitRuntimeTeardown(); | ||
| 7758 | const generation = yield* Ref.get(runtimeCallbackGeneration); | ||
| 7759 | const pending = (yield* Ref.get(pendingRuntimeRequests)).get( | ||
| 7760 | String(requestInput.requestId), | ||
| 7761 | ); | ||
| 7762 | if (pending === undefined || pending.generation !== generation) { | ||
| 7763 | return yield* new ProviderAdapter.ProviderAdapterProtocolError({ | ||
| 7764 | driver, | ||
| 7765 | detail: `No pending ACP runtime request ${requestInput.requestId}`, | ||
| 7766 | }); | ||
| 7767 | } | ||
| 7768 | const settled = | ||
| 7769 | pending.type === "user_input" | ||
| 7770 | ? yield* Deferred.succeed(pending.answers, requestInput.answers ?? null) | ||
| 7771 | : requestInput.decision === undefined | ||
| 7772 | ? yield* new ProviderAdapter.ProviderAdapterProtocolError({ | ||
| 7773 | driver, | ||
| 7774 | detail: `ACP approval request ${requestInput.requestId} requires a decision`, | ||
| 7775 | }) | ||
| 7776 | : yield* Deferred.succeed(pending.decision, requestInput.decision); | ||
| 7777 | if (!settled) { | ||
| 7778 | return yield* new ProviderAdapter.ProviderAdapterProtocolError({ | ||
| 7779 | driver, | ||
| 7780 | detail: `ACP runtime request ${requestInput.requestId} was already resolved`, | ||
| 7781 | }); | ||
| 7782 | } | ||
| 7783 | yield* awaitNativeResponseAcknowledgements([ | ||
| 7784 | [pending.transportRequestId, pending.nativeResponseAcknowledgement], | ||
| 7785 | ]); | ||
| 7786 | yield* Deferred.await(pending.nativeResponseAcknowledgement); | ||
| 7787 | }).pipe(Effect.ensuring(runtimeTransitionPermit.release(1))); | ||
| 7788 | }), | ||
| 7789 | ).pipe( | ||
| 7790 | Effect.mapError( | ||
| 7791 | (cause) => | ||
| 7792 | new ProviderAdapter.ProviderAdapterRuntimeRequestResponseError({ | ||
| 7793 | driver, | ||
| 7794 | requestId: requestInput.requestId, | ||
| 7795 | cause, | ||
| 7796 | }), | ||
| 7797 | ), | ||
| 7798 | ), | ||
| 7799 | readThreadSnapshot: Effect.fn("AcpAdapterV2.readThreadSnapshot")( | ||
| 7800 | function* (snapshotInput) { | ||
| 7801 | return yield* runtimeTransitionPermit.withPermit( | ||
| 7802 | Effect.gen(function* () { | ||
| 7803 | yield* awaitRuntimeTeardown(); | ||
| 7804 | useProviderThreadIdentity(snapshotInput.providerThread); | ||
| 7805 | yield* restartRuntimeAfterTeardownIfRequired( | ||
| 7806 | snapshotInput.providerThread.appThreadId, | ||
| 7807 | ); | ||
| 7808 | const sessionId = yield* nativeThreadId(driver, snapshotInput.providerThread); | ||
| 7809 | if ((yield* Ref.get(activeSessionId)) !== sessionId) { | ||
| 7810 | if (!capabilities.threads.canReadThreadSnapshot) { | ||
| 7811 | return yield* new ProviderAdapter.ProviderAdapterProtocolError({ | ||
| 7812 | driver, | ||
| 7813 | detail: "ACP driver does not support session/load snapshots", | ||
| 7814 | }); | ||
| 7815 | } | ||
| 7816 | yield* Ref.set(snapshot, { | ||
| 7817 | order: [], | ||
| 7818 | messages: new Map(), | ||
| 7819 | loadingRole: null, | ||
| 7820 | loadingMessageId: null, | ||
| 7821 | loadingIndex: 0, | ||
| 7822 | }); | ||
| 7823 | prepareTerminalEnvironment(snapshotInput.providerThread.appThreadId, sessionId); | ||
| 7824 | const activated = yield* runtime.loadSession( | ||
| 7825 | sessionId, | ||
| 7826 | acpMcpActivation(snapshotInput.providerThread.appThreadId, self), | ||
| 7827 | ); | ||
| 7828 | rememberTerminalEnvironment( | ||
| 7829 | activated.sessionId, | ||
| 7830 | snapshotInput.providerThread.appThreadId, | ||
| 7831 | ); | ||
| 7832 | yield* Ref.set(activeSessionId, activated.sessionId); | ||
| 7833 | yield* Ref.set(activeSessionSetup, activated); | ||
| 7834 | yield* Ref.set(activeSelection, null); | ||
| 7835 | yield* Ref.set(activeInteractionMode, null); | ||
| 7836 | } | ||
| 7837 | const state = yield* Ref.get(snapshot); | ||
| 7838 | const now = yield* DateTime.now; | ||
| 7839 | return { | ||
| 7840 | providerThread: { | ||
| 7841 | ...snapshotInput.providerThread, | ||
| 7842 | providerSessionId: input.providerSessionId, | ||
| 7843 | status: "idle" as const, | ||
| 7844 | updatedAt: now, | ||
| 7845 | }, | ||
| 7846 | providerTurns: [...(yield* Ref.get(providerTurns)).values()], | ||
| 7847 | messages: state.order.flatMap((key) => { | ||
| 7848 | const message = state.messages.get(key); | ||
| 7849 | return message === undefined ? [] : [message]; | ||
| 7850 | }), | ||
| 7851 | runtimeRequests: [], | ||
| 7852 | providerPayload: { protocol: ACP_PROTOCOL, sessionId }, | ||
| 7853 | }; | ||
| 7854 | }), | ||
| 7855 | ); | ||
| 7856 | }, | ||
| 7857 | (effect, snapshotInput) => | ||
| 7858 | effect.pipe( | ||
| 7859 | Effect.mapError( | ||
| 7860 | (cause) => | ||
| 7861 | new ProviderAdapter.ProviderAdapterReadThreadSnapshotError({ | ||
| 7862 | driver, | ||
| 7863 | providerThreadId: snapshotInput.providerThread.id, | ||
| 7864 | cause, | ||
| 7865 | }), | ||
| 7866 | ), | ||
| 7867 | ), | ||
| 7868 | ), | ||
| 7869 | rollbackThread: (rollbackInput) => | ||
| 7870 | runtimeTransitionPermit | ||
| 7871 | .withPermit( | ||
| 7872 | Effect.gen(function* () { | ||
| 7873 | const currentTurn = yield* Ref.get(activeTurn); | ||
| 7874 | if (currentTurn !== null) { | ||
| 7875 | return yield* new ProviderAdapter.ProviderAdapterProtocolError({ | ||
| 7876 | driver, | ||
| 7877 | detail: `Cannot roll back ACP provider thread ${rollbackInput.providerThread.id} while turn ${currentTurn.providerTurnId} is active`, | ||
| 7878 | }); | ||
| 7879 | } | ||
| 7880 | // ACP defines no conversation truncation, so rollback stages | ||
| 7881 | // a fresh native session before retiring the original. | ||
| 7882 | // Returning its binding keeps the next turn runnable without | ||
| 7883 | // loading any of the rolled-back conversation. | ||
| 7884 | yield* awaitRuntimeTeardown(); | ||
| 7885 | itemIdentityVersion = 2; | ||
| 7886 | prepareTerminalEnvironment(rollbackInput.providerThread.appThreadId); | ||
| 7887 | const replacement = yield* startReplacementAcpRuntime( | ||
| 7888 | rollbackInput.providerThread.appThreadId, | ||
| 7889 | (candidate) => | ||
| 7890 | Effect.gen(function* () { | ||
| 7891 | rememberTerminalEnvironment( | ||
| 7892 | candidate.sessionId, | ||
| 7893 | rollbackInput.providerThread.appThreadId, | ||
| 7894 | ); | ||
| 7895 | yield* Ref.set(runtimeRestartRequired, false); | ||
| 7896 | yield* Ref.set(activeSessionId, candidate.sessionId); | ||
| 7897 | yield* Ref.set(activeSessionSetup, candidate); | ||
| 7898 | yield* Ref.set(activeSelection, null); | ||
| 7899 | yield* Ref.set(activeInteractionMode, null); | ||
| 7900 | yield* Ref.set(promptInstructionStates, new Map()); | ||
| 7901 | yield* Ref.set(providerTurns, new Map()); | ||
| 7902 | yield* Ref.set(snapshot, { | ||
| 7903 | order: [], | ||
| 7904 | messages: new Map(), | ||
| 7905 | loadingRole: null, | ||
| 7906 | loadingMessageId: null, | ||
| 7907 | loadingIndex: 0, | ||
| 7908 | }); | ||
| 7909 | yield* continuationPermit.withPermit( | ||
| 7910 | Effect.gen(function* () { | ||
| 7911 | yield* Ref.update(continuationGeneration, (value) => value + 1); | ||
| 7912 | yield* Ref.set(stoppedRunQuarantine, false); | ||
| 7913 | yield* Ref.set(wakeBuffer, []); | ||
| 7914 | yield* Ref.set(wakeReports, noWakeReports); | ||
| 7915 | yield* Ref.set(continuationRequested, false); | ||
| 7916 | yield* Ref.set(runningBackgroundTaskIds, new Set()); | ||
| 7917 | yield* Ref.set(endedBackgroundTaskIds, new Set()); | ||
| 7918 | yield* Ref.set(midTurnUnreportedCompletedTaskIds, new Set()); | ||
| 7919 | yield* Ref.set(handledBackgroundTaskIdsInActiveTurn, new Set()); | ||
| 7920 | yield* Ref.set(carryoverSubagents, null); | ||
| 7921 | yield* Ref.set(suppressPostSettleMonitorPrompt, false); | ||
| 7922 | yield* Ref.set(lastTurnRoute, null); | ||
| 7923 | }), | ||
| 7924 | ); | ||
| 7925 | }), | ||
| 7926 | ).pipe(Effect.retry({ times: 1 })); | ||
| 7927 | const now = yield* DateTime.now; | ||
| 7928 | return { | ||
| 7929 | providerThread: { | ||
| 7930 | ...rollbackInput.providerThread, | ||
| 7931 | nativeThreadRef: { | ||
| 7932 | driver, | ||
| 7933 | nativeId: replacement.sessionId, | ||
| 7934 | strength: "strong" as const, | ||
| 7935 | }, | ||
| 7936 | nativeConversationHeadRef: null, | ||
| 7937 | nativeMetadata: { | ||
| 7938 | ...rollbackInput.providerThread.nativeMetadata, | ||
| 7939 | itemIdentityVersion: 2 as const, | ||
| 7940 | }, | ||
| 7941 | status: "idle" as const, | ||
| 7942 | updatedAt: now, | ||
| 7943 | }, | ||
| 7944 | providerTurns: [], | ||
| 7945 | messages: [], | ||
| 7946 | runtimeRequests: [], | ||
| 7947 | }; | ||
| 7948 | }), | ||
| 7949 | ) | ||
| 7950 | .pipe( | ||
| 7951 | Effect.mapError( | ||
| 7952 | (cause) => | ||
| 7953 | new ProviderAdapter.ProviderAdapterRollbackThreadError({ | ||
| 7954 | driver, | ||
| 7955 | providerThreadId: rollbackInput.providerThread.id, | ||
| 7956 | checkpointId: rollbackInput.target.checkpointId, | ||
| 7957 | cause, | ||
| 7958 | }), | ||
| 7959 | ), | ||
| 7960 | ), | ||
| 7961 | forkThread: Effect.fn("AcpAdapterV2.forkThread")( | ||
| 7962 | function* (forkInput) { | ||
| 7963 | return yield* runtimeTransitionPermit.withPermit( | ||
| 7964 | Effect.gen(function* () { | ||
| 7965 | yield* awaitRuntimeTeardown(); | ||
| 7966 | useProviderThreadIdentity(forkInput.sourceProviderThread); | ||
| 7967 | yield* restartRuntimeAfterTeardownIfRequired( | ||
| 7968 | forkInput.sourceProviderThread.appThreadId, | ||
| 7969 | ); | ||
| 7970 | if (!capabilities.threads.canForkThread) { | ||
| 7971 | return yield* new ProviderAdapter.ProviderAdapterProtocolError({ | ||
| 7972 | driver, | ||
| 7973 | detail: "ACP driver did not negotiate session/fork", | ||
| 7974 | }); | ||
| 7975 | } | ||
| 7976 | if (forkInput.providerTurnId !== undefined) { | ||
| 7977 | return yield* new ProviderAdapter.ProviderAdapterProtocolError({ | ||
| 7978 | driver, | ||
| 7979 | detail: "ACP session/fork can only fork the current session head", | ||
| 7980 | }); | ||
| 7981 | } | ||
| 7982 | const sourceSessionId = yield* nativeThreadId( | ||
| 7983 | driver, | ||
| 7984 | forkInput.sourceProviderThread, | ||
| 7985 | ); | ||
| 7986 | prepareTerminalEnvironment(forkInput.targetThreadId); | ||
| 7987 | const forked = yield* runtime.forkSession( | ||
| 7988 | sourceSessionId, | ||
| 7989 | acpMcpActivation(forkInput.targetThreadId, self), | ||
| 7990 | ); | ||
| 7991 | rememberTerminalEnvironment(forked.sessionId, forkInput.targetThreadId); | ||
| 7992 | yield* Ref.set(activeSessionId, forked.sessionId); | ||
| 7993 | yield* Ref.set(activeSessionSetup, forked); | ||
| 7994 | yield* Ref.set(activeSelection, null); | ||
| 7995 | yield* Ref.set(activeInteractionMode, null); | ||
| 7996 | itemIdentityVersion = 2; | ||
| 7997 | const now = yield* DateTime.now; | ||
| 7998 | const providerThread = makeProviderThread({ | ||
| 7999 | driver, | ||
| 8000 | providerInstanceId: options.instanceId, | ||
| 8001 | idAllocator, | ||
| 8002 | appThreadId: forkInput.targetThreadId, | ||
| 8003 | providerSessionId: input.providerSessionId, | ||
| 8004 | nativeThreadId: forked.sessionId, | ||
| 8005 | itemIdentityVersion: 2, | ||
| 8006 | ...(forkInput.ownerNodeId === undefined | ||
| 8007 | ? {} | ||
| 8008 | : { ownerNodeId: forkInput.ownerNodeId }), | ||
| 8009 | forkedFrom: { | ||
| 8010 | providerThreadId: forkInput.sourceProviderThread.id, | ||
| 8011 | ...(forkInput.providerTurnId === undefined | ||
| 8012 | ? {} | ||
| 8013 | : { providerTurnId: forkInput.providerTurnId }), | ||
| 8014 | }, | ||
| 8015 | now, | ||
| 8016 | }); | ||
| 8017 | yield* Ref.update(providerThreadByNativeSessionId, (current) => | ||
| 8018 | new Map(current).set(forked.sessionId, providerThread), | ||
| 8019 | ); | ||
| 8020 | return providerThread; | ||
| 8021 | }), | ||
| 8022 | ); | ||
| 8023 | }, | ||
| 8024 | (effect, forkInput) => | ||
| 8025 | effect.pipe( | ||
| 8026 | Effect.mapError( | ||
| 8027 | (cause) => | ||
| 8028 | new ProviderAdapter.ProviderAdapterForkThreadError({ | ||
| 8029 | driver, | ||
| 8030 | providerThreadId: forkInput.sourceProviderThread.id, | ||
| 8031 | cause, | ||
| 8032 | }), | ||
| 8033 | ), | ||
| 8034 | ), | ||
| 8035 | ), | ||
| 8036 | }; | ||
| 8037 | return sessionRuntime; | ||
| 8038 | }, | ||
| 8039 | (effect, input) => | ||
| 8040 | effect.pipe( | ||
| 8041 | Effect.mapError( | ||
| 8042 | (cause) => | ||
| 8043 | new ProviderAdapter.ProviderAdapterOpenSessionError({ | ||
| 8044 | driver, | ||
| 8045 | providerSessionId: input.providerSessionId, | ||
| 8046 | cause, | ||
| 8047 | }), | ||
| 8048 | ), | ||
| 8049 | ), | ||
| 8050 | ), | ||
| 8051 | }); | ||
| 8052 | } | ||
| 8053 | |||
| 8054 | export type AcpAdapterV2Env = FileSystem.FileSystem | IdAllocator.IdAllocatorV2; | ||
packages/provider-acp/src/server/clientPolicy.test.ts created+348| ... | @@ -0,0 +1,348 @@ | ||
| 1 | // @effect-diagnostics nodeBuiltinImport:off | ||
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 3 | import { assert, describe, it } from "@effect/vitest"; | ||
| 4 | import * as Effect from "effect/Effect"; | ||
| 5 | import * as FileSystem from "effect/FileSystem"; | ||
| 6 | import * as Path from "effect/Path"; | ||
| 7 | import type * as EffectAcpSchema from "effect-acp/compat"; | ||
| 8 | import * as NodePath from "node:path"; | ||
| 9 | |||
| 10 | import { | ||
| 11 | acpClientExecuteDisposition, | ||
| 12 | acpMcpToolApprovalElicitationDisposition, | ||
| 13 | acpPermissionDisposition, | ||
| 14 | makeAcpClientPolicyGrants, | ||
| 15 | type AcpRuntimePolicy, | ||
| 16 | } from "./clientPolicy.ts"; | ||
| 17 | |||
| 18 | function permissionRequest( | ||
| 19 | kind: NonNullable<EffectAcpSchema.RequestPermissionRequest["toolCall"]["kind"]>, | ||
| 20 | locations?: ReadonlyArray<EffectAcpSchema.ToolCallLocation>, | ||
| 21 | ): EffectAcpSchema.RequestPermissionRequest { | ||
| 22 | return { | ||
| 23 | options: [], | ||
| 24 | sessionId: "permission-session", | ||
| 25 | toolCall: { | ||
| 26 | kind, | ||
| 27 | ...(locations === undefined ? {} : { locations }), | ||
| 28 | toolCallId: "permission-tool-call", | ||
| 29 | }, | ||
| 30 | }; | ||
| 31 | } | ||
| 32 | |||
| 33 | describe("acpPermissionDisposition", () => { | ||
| 34 | const cwd = NodePath.resolve(process.cwd(), "acp-permission-workspace"); | ||
| 35 | const writableRoot = NodePath.resolve(process.cwd(), "acp-additional-writable-root"); | ||
| 36 | const policy: AcpRuntimePolicy = { | ||
| 37 | runtimeMode: "full-access", | ||
| 38 | cwd, | ||
| 39 | approvalPolicy: "never", | ||
| 40 | sandboxPolicy: { | ||
| 41 | type: "workspaceWrite", | ||
| 42 | writableRoots: [writableRoot], | ||
| 43 | networkAccess: false, | ||
| 44 | }, | ||
| 45 | }; | ||
| 46 | |||
| 47 | it("auto-allows mutations only when every location is in cwd or an additional writable root", () => { | ||
| 48 | assert.equal( | ||
| 49 | acpPermissionDisposition(policy, permissionRequest("edit", [{ path: "src/index.ts" }])), | ||
| 50 | "allow", | ||
| 51 | ); | ||
| 52 | assert.equal( | ||
| 53 | acpPermissionDisposition( | ||
| 54 | policy, | ||
| 55 | permissionRequest("delete", [{ path: NodePath.join(writableRoot, "generated.ts") }]), | ||
| 56 | ), | ||
| 57 | "allow", | ||
| 58 | ); | ||
| 59 | assert.equal( | ||
| 60 | acpPermissionDisposition( | ||
| 61 | policy, | ||
| 62 | permissionRequest("move", [ | ||
| 63 | { path: NodePath.join(cwd, "from.ts") }, | ||
| 64 | { path: NodePath.join(writableRoot, "to.ts") }, | ||
| 65 | ]), | ||
| 66 | ), | ||
| 67 | "allow", | ||
| 68 | ); | ||
| 69 | }); | ||
| 70 | |||
| 71 | it("denies missing or out-of-root mutation locations", () => { | ||
| 72 | const outside = NodePath.resolve(process.cwd(), "outside-acp-permission-workspace", "file.ts"); | ||
| 73 | assert.equal(acpPermissionDisposition(policy, permissionRequest("edit")), "deny"); | ||
| 74 | assert.equal( | ||
| 75 | acpPermissionDisposition(policy, permissionRequest("delete", [{ path: "../escape.ts" }])), | ||
| 76 | "deny", | ||
| 77 | ); | ||
| 78 | assert.equal( | ||
| 79 | acpPermissionDisposition( | ||
| 80 | policy, | ||
| 81 | permissionRequest("move", [{ path: NodePath.join(cwd, "inside.ts") }, { path: outside }]), | ||
| 82 | ), | ||
| 83 | "deny", | ||
| 84 | ); | ||
| 85 | }); | ||
| 86 | |||
| 87 | it("keeps non-mutating workspace permissions and denials unchanged", () => { | ||
| 88 | assert.equal(acpPermissionDisposition(policy, permissionRequest("read")), "allow"); | ||
| 89 | assert.equal(acpPermissionDisposition(policy, permissionRequest("execute")), "deny"); | ||
| 90 | }); | ||
| 91 | |||
| 92 | it("auto-accept-edits approves file changes without locations and asks for the rest", () => { | ||
| 93 | // ACP permission requests need not carry locations. | ||
| 94 | const autoAcceptEdits: AcpRuntimePolicy = { runtimeMode: "auto-accept-edits", cwd }; | ||
| 95 | for (const kind of ["edit", "delete", "move"] as const) { | ||
| 96 | assert.equal(acpPermissionDisposition(autoAcceptEdits, permissionRequest(kind)), "allow"); | ||
| 97 | } | ||
| 98 | for (const kind of ["execute", "fetch", "other"] as const) { | ||
| 99 | assert.equal(acpPermissionDisposition(autoAcceptEdits, permissionRequest(kind)), "ask"); | ||
| 100 | } | ||
| 101 | assert.equal(acpPermissionDisposition(autoAcceptEdits, permissionRequest("read")), "allow"); | ||
| 102 | assert.equal( | ||
| 103 | acpPermissionDisposition( | ||
| 104 | { ...autoAcceptEdits, approvalPolicy: "on-request" }, | ||
| 105 | permissionRequest("edit"), | ||
| 106 | ), | ||
| 107 | "ask", | ||
| 108 | ); | ||
| 109 | }); | ||
| 110 | |||
| 111 | it("auto-allows read-kind permission requests under on-request approval", () => { | ||
| 112 | for (const runtimePolicy of [ | ||
| 113 | { ...policy, approvalPolicy: "on-request" }, | ||
| 114 | { ...policy, approvalPolicy: "on-request", sandboxPolicy: { type: "readOnly" } }, | ||
| 115 | { runtimeMode: "approval-required", cwd }, | ||
| 116 | ] satisfies ReadonlyArray<AcpRuntimePolicy>) { | ||
| 117 | for (const kind of ["read", "search", "think"] as const) { | ||
| 118 | assert.equal(acpPermissionDisposition(runtimePolicy, permissionRequest(kind)), "allow"); | ||
| 119 | } | ||
| 120 | for (const kind of ["edit", "delete", "move", "execute", "fetch", "other"] as const) { | ||
| 121 | assert.equal(acpPermissionDisposition(runtimePolicy, permissionRequest(kind)), "ask"); | ||
| 122 | } | ||
| 123 | } | ||
| 124 | }); | ||
| 125 | |||
| 126 | it.effect("denies mutations through workspace symlinks that escape the writable roots", () => | ||
| 127 | Effect.gen(function* () { | ||
| 128 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 129 | const path = yield* Path.Path; | ||
| 130 | const workspace = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 131 | prefix: "t3-acp-permission-workspace-", | ||
| 132 | }); | ||
| 133 | const outside = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 134 | prefix: "t3-acp-permission-outside-", | ||
| 135 | }); | ||
| 136 | const outsideFile = path.join(outside, "existing.ts"); | ||
| 137 | yield* fileSystem.writeFileString(outsideFile, "outside"); | ||
| 138 | yield* fileSystem.symlink(outsideFile, path.join(workspace, "linked-file.ts")); | ||
| 139 | yield* fileSystem.symlink(outside, path.join(workspace, "linked-directory")); | ||
| 140 | |||
| 141 | const realPolicy: AcpRuntimePolicy = { | ||
| 142 | runtimeMode: "full-access", | ||
| 143 | cwd: workspace, | ||
| 144 | approvalPolicy: "never", | ||
| 145 | sandboxPolicy: { | ||
| 146 | type: "workspaceWrite", | ||
| 147 | writableRoots: [], | ||
| 148 | networkAccess: false, | ||
| 149 | }, | ||
| 150 | }; | ||
| 151 | |||
| 152 | assert.equal( | ||
| 153 | acpPermissionDisposition( | ||
| 154 | realPolicy, | ||
| 155 | permissionRequest("edit", [{ path: "linked-file.ts" }]), | ||
| 156 | ), | ||
| 157 | "deny", | ||
| 158 | ); | ||
| 159 | assert.equal( | ||
| 160 | acpPermissionDisposition( | ||
| 161 | realPolicy, | ||
| 162 | permissionRequest("edit", [{ path: "linked-directory/new-file.ts" }]), | ||
| 163 | ), | ||
| 164 | "deny", | ||
| 165 | "a missing leaf below an escaping directory symlink must not be auto-approved", | ||
| 166 | ); | ||
| 167 | assert.equal( | ||
| 168 | acpPermissionDisposition( | ||
| 169 | realPolicy, | ||
| 170 | permissionRequest("edit", [{ path: "linked-directory/../escaped-file.ts" }]), | ||
| 171 | ), | ||
| 172 | "deny", | ||
| 173 | "physical symlink traversal must be resolved before parent segments", | ||
| 174 | ); | ||
| 175 | }).pipe(Effect.provide(NodeServices.layer)), | ||
| 176 | ); | ||
| 177 | |||
| 178 | it.effect("allows existing and new files beneath canonical writable roots", () => | ||
| 179 | Effect.gen(function* () { | ||
| 180 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 181 | const path = yield* Path.Path; | ||
| 182 | const workspace = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 183 | prefix: "t3-acp-permission-workspace-", | ||
| 184 | }); | ||
| 185 | const workspaceLinkParent = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 186 | prefix: "t3-acp-permission-link-parent-", | ||
| 187 | }); | ||
| 188 | const workspaceLink = path.join(workspaceLinkParent, "workspace-link"); | ||
| 189 | yield* fileSystem.makeDirectory(path.join(workspace, "src"), { recursive: true }); | ||
| 190 | yield* fileSystem.writeFileString(path.join(workspace, "src", "existing.ts"), "existing"); | ||
| 191 | yield* fileSystem.symlink(workspace, workspaceLink); | ||
| 192 | |||
| 193 | const realPolicy: AcpRuntimePolicy = { | ||
| 194 | runtimeMode: "full-access", | ||
| 195 | cwd: workspaceLink, | ||
| 196 | approvalPolicy: "never", | ||
| 197 | sandboxPolicy: { | ||
| 198 | type: "workspaceWrite", | ||
| 199 | writableRoots: [], | ||
| 200 | networkAccess: false, | ||
| 201 | }, | ||
| 202 | }; | ||
| 203 | |||
| 204 | assert.equal( | ||
| 205 | acpPermissionDisposition( | ||
| 206 | realPolicy, | ||
| 207 | permissionRequest("edit", [{ path: "src/existing.ts" }]), | ||
| 208 | ), | ||
| 209 | "allow", | ||
| 210 | ); | ||
| 211 | assert.equal( | ||
| 212 | acpPermissionDisposition( | ||
| 213 | realPolicy, | ||
| 214 | permissionRequest("edit", [{ path: "src/generated/new-file.ts" }]), | ||
| 215 | ), | ||
| 216 | "allow", | ||
| 217 | "non-existent descendants of a real in-root ancestor remain writable", | ||
| 218 | ); | ||
| 219 | }).pipe(Effect.provide(NodeServices.layer)), | ||
| 220 | ); | ||
| 221 | }); | ||
| 222 | |||
| 223 | describe("acpMcpToolApprovalElicitationDisposition", () => { | ||
| 224 | it("applies runtime policy only to explicitly tagged MCP approval elicitations", () => { | ||
| 225 | const fullAccess: AcpRuntimePolicy = { | ||
| 226 | runtimeMode: "full-access", | ||
| 227 | cwd: process.cwd(), | ||
| 228 | }; | ||
| 229 | const approvalRequired: AcpRuntimePolicy = { | ||
| 230 | runtimeMode: "approval-required", | ||
| 231 | cwd: process.cwd(), | ||
| 232 | }; | ||
| 233 | const tagged = { | ||
| 234 | sessionId: "session-1", | ||
| 235 | message: "Approve this request?", | ||
| 236 | mode: "form", | ||
| 237 | requestedSchema: { type: "object", properties: {} }, | ||
| 238 | _meta: { codex_approval_kind: "mcp_tool_call" }, | ||
| 239 | } satisfies EffectAcpSchema.CreateElicitationRequest; | ||
| 240 | |||
| 241 | assert.equal(acpMcpToolApprovalElicitationDisposition(fullAccess, tagged), "allow"); | ||
| 242 | assert.equal(acpMcpToolApprovalElicitationDisposition(approvalRequired, tagged), "ask"); | ||
| 243 | assert.equal( | ||
| 244 | acpMcpToolApprovalElicitationDisposition( | ||
| 245 | { ...fullAccess, approvalPolicy: "on-request" }, | ||
| 246 | tagged, | ||
| 247 | ), | ||
| 248 | "ask", | ||
| 249 | ); | ||
| 250 | assert.equal( | ||
| 251 | acpMcpToolApprovalElicitationDisposition( | ||
| 252 | { | ||
| 253 | runtimeMode: "auto-accept-edits", | ||
| 254 | cwd: process.cwd(), | ||
| 255 | approvalPolicy: "never", | ||
| 256 | sandboxPolicy: { | ||
| 257 | type: "workspaceWrite", | ||
| 258 | writableRoots: [], | ||
| 259 | networkAccess: false, | ||
| 260 | }, | ||
| 261 | }, | ||
| 262 | tagged, | ||
| 263 | ), | ||
| 264 | "allow", | ||
| 265 | ); | ||
| 266 | const { _meta: _tag, ...untagged } = tagged; | ||
| 267 | assert.equal( | ||
| 268 | acpMcpToolApprovalElicitationDisposition( | ||
| 269 | fullAccess, | ||
| 270 | untagged, | ||
| 271 | "mcp_tool_call_approval_exec-123", | ||
| 272 | ), | ||
| 273 | "allow", | ||
| 274 | ); | ||
| 275 | assert.isUndefined( | ||
| 276 | acpMcpToolApprovalElicitationDisposition(fullAccess, { | ||
| 277 | ...tagged, | ||
| 278 | _meta: { codex_approval_kind: "ordinary_form" }, | ||
| 279 | }), | ||
| 280 | ); | ||
| 281 | assert.isUndefined( | ||
| 282 | acpMcpToolApprovalElicitationDisposition(fullAccess, { | ||
| 283 | sessionId: "session-1", | ||
| 284 | message: "Authenticate", | ||
| 285 | mode: "url", | ||
| 286 | elicitationId: "elicitation-1", | ||
| 287 | url: "https://example.com/login", | ||
| 288 | _meta: { codex_approval_kind: "mcp_tool_call" }, | ||
| 289 | }), | ||
| 290 | ); | ||
| 291 | }); | ||
| 292 | }); | ||
| 293 | |||
| 294 | describe("client terminal disposition", () => { | ||
| 295 | const cwd = NodePath.resolve(process.cwd(), "acp-client-policy-workspace"); | ||
| 296 | |||
| 297 | it("asks in approval-required and auto-accept-edits, allows in auto and full access", () => { | ||
| 298 | assert.equal(acpClientExecuteDisposition({ runtimeMode: "approval-required", cwd }), "ask"); | ||
| 299 | assert.equal(acpClientExecuteDisposition({ runtimeMode: "auto-accept-edits", cwd }), "ask"); | ||
| 300 | assert.equal(acpClientExecuteDisposition({ runtimeMode: "auto", cwd }), "allow"); | ||
| 301 | assert.equal(acpClientExecuteDisposition({ runtimeMode: "full-access", cwd }), "allow"); | ||
| 302 | }); | ||
| 303 | |||
| 304 | it("denies terminals under explicit read-only and workspace-write sandboxes", () => { | ||
| 305 | for (const sandboxPolicy of [ | ||
| 306 | { type: "readOnly" }, | ||
| 307 | { type: "workspaceWrite", writableRoots: [], networkAccess: false }, | ||
| 308 | ]) { | ||
| 309 | assert.equal( | ||
| 310 | acpClientExecuteDisposition({ | ||
| 311 | runtimeMode: "full-access", | ||
| 312 | cwd, | ||
| 313 | approvalPolicy: "never", | ||
| 314 | sandboxPolicy, | ||
| 315 | }), | ||
| 316 | "deny", | ||
| 317 | ); | ||
| 318 | } | ||
| 319 | }); | ||
| 320 | }); | ||
| 321 | |||
| 322 | describe("makeAcpClientPolicyGrants", () => { | ||
| 323 | it("grants terminals only from commands, never from an approved read or edit", () => { | ||
| 324 | const grants = makeAcpClientPolicyGrants(); | ||
| 325 | grants.recordApproval({ kind: "file-read", scope: "turn", turnKey: "turn-1" }); | ||
| 326 | grants.recordApproval({ kind: "file-change", scope: "session", turnKey: "turn-1" }); | ||
| 327 | assert.isFalse(grants.allowsExecute("turn-1")); | ||
| 328 | grants.recordApproval({ kind: "command", scope: "turn", turnKey: "turn-1" }); | ||
| 329 | assert.isTrue(grants.allowsExecute("turn-1")); | ||
| 330 | assert.isFalse(grants.allowsExecute("turn-2")); | ||
| 331 | assert.isFalse(grants.allowsExecute(null)); | ||
| 332 | }); | ||
| 333 | |||
| 334 | it("keeps accept-for-session command grants across turns", () => { | ||
| 335 | const grants = makeAcpClientPolicyGrants(); | ||
| 336 | grants.recordApproval({ kind: "command", scope: "session", turnKey: "turn-1" }); | ||
| 337 | assert.isTrue(grants.allowsExecute("turn-9")); | ||
| 338 | assert.isTrue(grants.allowsExecute(null)); | ||
| 339 | }); | ||
| 340 | |||
| 341 | it("drops a turn's command grant when a later turn approves one", () => { | ||
| 342 | const grants = makeAcpClientPolicyGrants(); | ||
| 343 | grants.recordApproval({ kind: "command", scope: "turn", turnKey: "turn-1" }); | ||
| 344 | grants.recordApproval({ kind: "command", scope: "turn", turnKey: "turn-2" }); | ||
| 345 | assert.isFalse(grants.allowsExecute("turn-1")); | ||
| 346 | assert.isTrue(grants.allowsExecute("turn-2")); | ||
| 347 | }); | ||
| 348 | }); | ||
packages/provider-acp/src/server/clientPolicy.ts created+297| ... | @@ -0,0 +1,297 @@ | ||
| 1 | // @effect-diagnostics nodeBuiltinImport:off | ||
| 2 | import type { ProviderRequestKind, RuntimeMode } from "@t3tools/contracts"; | ||
| 3 | import type * as EffectAcpSchema from "effect-acp/compat"; | ||
| 4 | import * as NodeFS from "node:fs"; | ||
| 5 | import * as NodePath from "node:path"; | ||
| 6 | |||
| 7 | /** | ||
| 8 | * Runtime-policy decisions for ACP work that T3 mediates. | ||
| 9 | * | ||
| 10 | * ACP agents run their own tools under their own permission model and ask | ||
| 11 | * through `session/request_permission`, which T3 answers by policy. The one | ||
| 12 | * client-mediated path left is Devin's `terminal/*`, which runs with the T3 | ||
| 13 | * server's own privileges; it resolves through {@link acpOperationDisposition} | ||
| 14 | * so a client terminal can never do more than an execute permission request. | ||
| 15 | */ | ||
| 16 | |||
| 17 | /** Structural subset of the adapter runtime policy that decisions read. */ | ||
| 18 | export interface AcpRuntimePolicy { | ||
| 19 | readonly runtimeMode: RuntimeMode; | ||
| 20 | readonly cwd: string | null; | ||
| 21 | readonly approvalPolicy?: unknown; | ||
| 22 | readonly sandboxPolicy?: unknown; | ||
| 23 | } | ||
| 24 | |||
| 25 | export type AcpPermissionDisposition = "allow" | "ask" | "deny"; | ||
| 26 | |||
| 27 | export function unknownRecord(value: unknown): Record<string, unknown> | undefined { | ||
| 28 | return typeof value === "object" && value !== null && !Array.isArray(value) | ||
| 29 | ? (value as Record<string, unknown>) | ||
| 30 | : undefined; | ||
| 31 | } | ||
| 32 | |||
| 33 | function resolveAcpPermissionPath(path: string, cwd: string | null): string | undefined { | ||
| 34 | const trimmed = path.trim(); | ||
| 35 | if (trimmed.length === 0) return undefined; | ||
| 36 | if (NodePath.isAbsolute(trimmed)) return trimmed; | ||
| 37 | if (cwd === null || cwd.trim().length === 0) return undefined; | ||
| 38 | return `${cwd}${cwd.endsWith(NodePath.sep) ? "" : NodePath.sep}${trimmed}`; | ||
| 39 | } | ||
| 40 | |||
| 41 | function acpPathIsWithinRoot(path: string, root: string): boolean { | ||
| 42 | const relative = NodePath.relative(root, path); | ||
| 43 | return ( | ||
| 44 | relative === "" || | ||
| 45 | (relative !== ".." && | ||
| 46 | !relative.startsWith(`..${NodePath.sep}`) && | ||
| 47 | !NodePath.isAbsolute(relative)) | ||
| 48 | ); | ||
| 49 | } | ||
| 50 | |||
| 51 | /** | ||
| 52 | * Canonicalize a path for an authorization containment check. | ||
| 53 | * | ||
| 54 | * `realpath` cannot resolve a file that has not been created yet, so walk up | ||
| 55 | * to the deepest existing ancestor and append the missing suffix to that | ||
| 56 | * ancestor's canonical path. This follows symlinked directories while still | ||
| 57 | * allowing normal writes to new files. If an existing entry cannot be | ||
| 58 | * canonicalized (for example, a broken symlink), fail closed. | ||
| 59 | */ | ||
| 60 | function acpCanonicalPathForContainment(path: string): string | undefined { | ||
| 61 | // Do not lexically normalize before realpath. For a path such as | ||
| 62 | // `workspace/link/../file`, the kernel resolves `link` before `..`; an | ||
| 63 | // eager NodePath.resolve would erase that symlink traversal and could turn | ||
| 64 | // an outside target into an apparently in-workspace path. | ||
| 65 | let candidate = path; | ||
| 66 | const missingSuffix: Array<string> = []; | ||
| 67 | |||
| 68 | while (true) { | ||
| 69 | try { | ||
| 70 | return NodePath.resolve(NodeFS.realpathSync.native(candidate), ...missingSuffix); | ||
| 71 | } catch (cause) { | ||
| 72 | if ((cause as NodeJS.ErrnoException | undefined)?.code !== "ENOENT") { | ||
| 73 | return undefined; | ||
| 74 | } | ||
| 75 | } | ||
| 76 | |||
| 77 | try { | ||
| 78 | NodeFS.lstatSync(candidate); | ||
| 79 | // The entry exists but realpath could not resolve it, as with a broken | ||
| 80 | // symlink. Treat it as untrusted rather than authorizing its lexical path. | ||
| 81 | return undefined; | ||
| 82 | } catch (cause) { | ||
| 83 | if ((cause as NodeJS.ErrnoException | undefined)?.code !== "ENOENT") { | ||
| 84 | return undefined; | ||
| 85 | } | ||
| 86 | } | ||
| 87 | |||
| 88 | const parent = NodePath.dirname(candidate); | ||
| 89 | if (parent === candidate) return undefined; | ||
| 90 | missingSuffix.unshift(NodePath.basename(candidate)); | ||
| 91 | candidate = parent; | ||
| 92 | } | ||
| 93 | } | ||
| 94 | |||
| 95 | /** The parts of a native operation the disposition logic reads. */ | ||
| 96 | interface AcpPolicyOperation { | ||
| 97 | readonly kind: string | null | undefined; | ||
| 98 | readonly locations: ReadonlyArray<{ readonly path: string }> | null | undefined; | ||
| 99 | } | ||
| 100 | |||
| 101 | function acpPolicyRequiresApproval(runtimePolicy: AcpRuntimePolicy): boolean { | ||
| 102 | return runtimePolicy.approvalPolicy === undefined | ||
| 103 | ? runtimePolicy.runtimeMode === "approval-required" | ||
| 104 | : runtimePolicy.approvalPolicy !== "never"; | ||
| 105 | } | ||
| 106 | |||
| 107 | function isAcpReadKind(toolKind: string): boolean { | ||
| 108 | return toolKind === "read" || toolKind === "search" || toolKind === "think"; | ||
| 109 | } | ||
| 110 | |||
| 111 | /** | ||
| 112 | * Reads follow the sandbox alone and never ask. Approval policy governs writes | ||
| 113 | * and commands, as it does for Codex and Claude, and every sandbox T3 knows | ||
| 114 | * lets the agent read. That includes no explicit sandbox, since the strictest | ||
| 115 | * runtime mode (approval-required) implies a read-only one. Unknown sandbox | ||
| 116 | * types still fail closed. | ||
| 117 | */ | ||
| 118 | function acpReadDisposition(runtimePolicy: AcpRuntimePolicy): "allow" | "deny" { | ||
| 119 | switch (unknownRecord(runtimePolicy.sandboxPolicy)?.type) { | ||
| 120 | case undefined: | ||
| 121 | case "readOnly": | ||
| 122 | case "workspaceWrite": | ||
| 123 | case "dangerFullAccess": | ||
| 124 | case "externalSandbox": | ||
| 125 | return "allow"; | ||
| 126 | default: | ||
| 127 | return "deny"; | ||
| 128 | } | ||
| 129 | } | ||
| 130 | |||
| 131 | function acpWorkspaceWriteAllowsMutation( | ||
| 132 | runtimePolicy: AcpRuntimePolicy, | ||
| 133 | sandboxPolicy: Record<string, unknown>, | ||
| 134 | locations: AcpPolicyOperation["locations"], | ||
| 135 | ): boolean { | ||
| 136 | const cwd = | ||
| 137 | typeof runtimePolicy.cwd === "string" && runtimePolicy.cwd.trim().length > 0 | ||
| 138 | ? (resolveAcpPermissionPath(runtimePolicy.cwd, process.cwd()) ?? null) | ||
| 139 | : null; | ||
| 140 | const roots: Array<string> = []; | ||
| 141 | if (cwd !== null) { | ||
| 142 | const canonicalCwd = acpCanonicalPathForContainment(cwd); | ||
| 143 | if (canonicalCwd !== undefined) roots.push(canonicalCwd); | ||
| 144 | } | ||
| 145 | const writableRoots = sandboxPolicy.writableRoots; | ||
| 146 | if (Array.isArray(writableRoots)) { | ||
| 147 | for (const writableRoot of writableRoots) { | ||
| 148 | if (typeof writableRoot !== "string") continue; | ||
| 149 | const resolved = resolveAcpPermissionPath(writableRoot, cwd); | ||
| 150 | if (resolved === undefined) continue; | ||
| 151 | const canonicalRoot = acpCanonicalPathForContainment(resolved); | ||
| 152 | if (canonicalRoot !== undefined) roots.push(canonicalRoot); | ||
| 153 | } | ||
| 154 | } | ||
| 155 | if (roots.length === 0) return false; | ||
| 156 | |||
| 157 | if (locations === undefined || locations === null || locations.length === 0) { | ||
| 158 | return false; | ||
| 159 | } | ||
| 160 | for (const location of locations) { | ||
| 161 | const resolved = resolveAcpPermissionPath(location.path, cwd); | ||
| 162 | const canonicalPath = | ||
| 163 | resolved === undefined ? undefined : acpCanonicalPathForContainment(resolved); | ||
| 164 | if ( | ||
| 165 | canonicalPath === undefined || | ||
| 166 | !roots.some((root) => acpPathIsWithinRoot(canonicalPath, root)) | ||
| 167 | ) { | ||
| 168 | return false; | ||
| 169 | } | ||
| 170 | } | ||
| 171 | return true; | ||
| 172 | } | ||
| 173 | |||
| 174 | /** | ||
| 175 | * Disposition of one native operation under the active runtime policy. Shared | ||
| 176 | * by provider permission requests and the client fs/terminal handlers. | ||
| 177 | */ | ||
| 178 | function acpOperationDisposition( | ||
| 179 | runtimePolicy: AcpRuntimePolicy, | ||
| 180 | operation: AcpPolicyOperation, | ||
| 181 | ): AcpPermissionDisposition { | ||
| 182 | const toolKind = operation.kind ?? "other"; | ||
| 183 | if (isAcpReadKind(toolKind)) { | ||
| 184 | return acpReadDisposition(runtimePolicy); | ||
| 185 | } | ||
| 186 | if (acpPolicyRequiresApproval(runtimePolicy)) { | ||
| 187 | return "ask"; | ||
| 188 | } | ||
| 189 | |||
| 190 | const sandboxPolicy = unknownRecord(runtimePolicy.sandboxPolicy); | ||
| 191 | switch (sandboxPolicy?.type) { | ||
| 192 | case "readOnly": | ||
| 193 | return "deny"; | ||
| 194 | case "workspaceWrite": | ||
| 195 | if (isAcpMutationKind(toolKind)) { | ||
| 196 | return acpWorkspaceWriteAllowsMutation( | ||
| 197 | runtimePolicy, | ||
| 198 | sandboxPolicy ?? {}, | ||
| 199 | operation.locations, | ||
| 200 | ) | ||
| 201 | ? "allow" | ||
| 202 | : "deny"; | ||
| 203 | } | ||
| 204 | return "deny"; | ||
| 205 | case "dangerFullAccess": | ||
| 206 | case "externalSandbox": | ||
| 207 | return "allow"; | ||
| 208 | case undefined: | ||
| 209 | if (runtimePolicy.runtimeMode === "approval-required") return "deny"; | ||
| 210 | // Auto-accept edits approves file changes wherever the agent makes them | ||
| 211 | // (ACP prompts need not carry locations to confine); other actions still ask. | ||
| 212 | if ( | ||
| 213 | runtimePolicy.runtimeMode === "auto-accept-edits" && | ||
| 214 | runtimePolicy.approvalPolicy === undefined && | ||
| 215 | !isAcpMutationKind(toolKind) | ||
| 216 | ) { | ||
| 217 | return "ask"; | ||
| 218 | } | ||
| 219 | return "allow"; | ||
| 220 | default: | ||
| 221 | return "deny"; | ||
| 222 | } | ||
| 223 | } | ||
| 224 | |||
| 225 | function isAcpMutationKind(toolKind: string): boolean { | ||
| 226 | return toolKind === "edit" || toolKind === "delete" || toolKind === "move"; | ||
| 227 | } | ||
| 228 | |||
| 229 | export function acpPermissionDisposition( | ||
| 230 | runtimePolicy: AcpRuntimePolicy, | ||
| 231 | request: EffectAcpSchema.RequestPermissionRequest, | ||
| 232 | ): AcpPermissionDisposition { | ||
| 233 | return acpOperationDisposition(runtimePolicy, { | ||
| 234 | kind: request.toolCall.kind, | ||
| 235 | locations: request.toolCall.locations, | ||
| 236 | }); | ||
| 237 | } | ||
| 238 | |||
| 239 | /** Resolve explicitly tagged MCP approvals through the thread's normal policy. */ | ||
| 240 | export function acpMcpToolApprovalElicitationDisposition( | ||
| 241 | runtimePolicy: AcpRuntimePolicy, | ||
| 242 | request: EffectAcpSchema.CreateElicitationRequest, | ||
| 243 | nativeRequestId?: string, | ||
| 244 | ): AcpPermissionDisposition | undefined { | ||
| 245 | if ( | ||
| 246 | request.mode !== "form" || | ||
| 247 | (unknownRecord(request._meta)?.codex_approval_kind !== "mcp_tool_call" && | ||
| 248 | nativeRequestId?.startsWith("mcp_tool_call_approval_") !== true) | ||
| 249 | ) { | ||
| 250 | return undefined; | ||
| 251 | } | ||
| 252 | // This request comes from T3's authenticated, scope-checked MCP endpoint, | ||
| 253 | // not an arbitrary provider command. Let explicit approval mode surface it | ||
| 254 | // to the user and otherwise allow the endpoint to enforce its own policy. | ||
| 255 | return acpPolicyRequiresApproval(runtimePolicy) ? "ask" : "allow"; | ||
| 256 | } | ||
| 257 | |||
| 258 | /** Disposition of a client-mediated `terminal/create` (Devin's client terminals). */ | ||
| 259 | export function acpClientExecuteDisposition( | ||
| 260 | runtimePolicy: AcpRuntimePolicy, | ||
| 261 | ): AcpPermissionDisposition { | ||
| 262 | return acpOperationDisposition(runtimePolicy, { kind: "execute", locations: undefined }); | ||
| 263 | } | ||
| 264 | |||
| 265 | export interface AcpApprovalGrantInput { | ||
| 266 | readonly kind: ProviderRequestKind; | ||
| 267 | readonly scope: "session" | "turn"; | ||
| 268 | readonly turnKey: string; | ||
| 269 | } | ||
| 270 | |||
| 271 | export interface AcpClientPolicyGrants { | ||
| 272 | readonly recordApproval: (input: AcpApprovalGrantInput) => void; | ||
| 273 | readonly allowsExecute: (turnKey: string | null) => boolean; | ||
| 274 | } | ||
| 275 | |||
| 276 | /** | ||
| 277 | * Command approvals recorded when the user accepts a `session/request_permission`, | ||
| 278 | * so an "ask" disposition at the client terminal boundary honors a command the | ||
| 279 | * user already approved. ACP does not link an approved tool call to the | ||
| 280 | * terminals the agent then creates, so an approved command authorizes client | ||
| 281 | * terminals for the approving turn, or for the session on accept-for-session. | ||
| 282 | */ | ||
| 283 | export function makeAcpClientPolicyGrants(): AcpClientPolicyGrants { | ||
| 284 | let sessionExecute = false; | ||
| 285 | let turnExecute: string | null = null; | ||
| 286 | return { | ||
| 287 | recordApproval: (input) => { | ||
| 288 | if (input.kind !== "command") return; | ||
| 289 | if (input.scope === "session") { | ||
| 290 | sessionExecute = true; | ||
| 291 | } else { | ||
| 292 | turnExecute = input.turnKey; | ||
| 293 | } | ||
| 294 | }, | ||
| 295 | allowsExecute: (turnKey) => sessionExecute || (turnKey !== null && turnExecute === turnKey), | ||
| 296 | }; | ||
| 297 | } | ||
packages/provider-acp/src/server/clientTerminals.test.ts created+312| ... | @@ -0,0 +1,312 @@ | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { describe, expect, it } from "@effect/vitest"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Result from "effect/Result"; | ||
| 5 | import { ChildProcessSpawner } from "effect/process"; | ||
| 6 | |||
| 7 | import { | ||
| 8 | makeAcpClientTerminals, | ||
| 9 | resolveEmbeddedTerminalContent, | ||
| 10 | type AcpClientTerminals, | ||
| 11 | } from "./clientTerminals.ts"; | ||
| 12 | |||
| 13 | const withTerminals = <A, E>(use: (terminals: AcpClientTerminals) => Effect.Effect<A, E>) => | ||
| 14 | Effect.gen(function* () { | ||
| 15 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 16 | const terminals = yield* makeAcpClientTerminals({ | ||
| 17 | spawner, | ||
| 18 | defaultCwd: process.cwd(), | ||
| 19 | }); | ||
| 20 | return yield* use(terminals).pipe(Effect.ensuring(terminals.disposeAll)); | ||
| 21 | }).pipe(Effect.provide(NodeServices.layer), Effect.scoped); | ||
| 22 | |||
| 23 | describe("AcpClientTerminals", () => { | ||
| 24 | it.effect("runs a command, buffers output, and reports the exit status", () => | ||
| 25 | withTerminals((terminals) => | ||
| 26 | Effect.gen(function* () { | ||
| 27 | const created = yield* terminals.create({ | ||
| 28 | sessionId: "session", | ||
| 29 | command: process.execPath, | ||
| 30 | args: ["-e", "console.log('hello from acp'); process.exit(3);"], | ||
| 31 | }); | ||
| 32 | |||
| 33 | const exit = yield* terminals.waitForExit({ | ||
| 34 | sessionId: "session", | ||
| 35 | terminalId: created.terminalId, | ||
| 36 | }); | ||
| 37 | expect(exit.exitCode).toBe(3); | ||
| 38 | |||
| 39 | const output = yield* terminals.output({ | ||
| 40 | sessionId: "session", | ||
| 41 | terminalId: created.terminalId, | ||
| 42 | }); | ||
| 43 | expect(output.output).toContain("hello from acp"); | ||
| 44 | expect(output.truncated).toBe(false); | ||
| 45 | expect(output.exitStatus?.exitCode).toBe(3); | ||
| 46 | }), | ||
| 47 | ), | ||
| 48 | ); | ||
| 49 | |||
| 50 | it.effect("resolves terminal environment from the requesting ACP session", () => | ||
| 51 | Effect.gen(function* () { | ||
| 52 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 53 | const terminals = yield* makeAcpClientTerminals({ | ||
| 54 | spawner, | ||
| 55 | defaultCwd: process.cwd(), | ||
| 56 | environmentForSession: (sessionId) => ({ | ||
| 57 | T3_TEST_ACP_SESSION_TOKEN: sessionId === "session-a" ? "token-a" : "token-b", | ||
| 58 | }), | ||
| 59 | }); | ||
| 60 | yield* Effect.addFinalizer(() => terminals.disposeAll); | ||
| 61 | |||
| 62 | for (const [sessionId, expected] of [ | ||
| 63 | ["session-a", "token-a"], | ||
| 64 | ["session-b", "token-b"], | ||
| 65 | ] as const) { | ||
| 66 | const created = yield* terminals.create({ | ||
| 67 | sessionId, | ||
| 68 | command: process.execPath, | ||
| 69 | args: ["-e", "process.stdout.write(process.env.T3_TEST_ACP_SESSION_TOKEN ?? '')"], | ||
| 70 | }); | ||
| 71 | yield* terminals.waitForExit({ sessionId, terminalId: created.terminalId }); | ||
| 72 | const output = yield* terminals.output({ sessionId, terminalId: created.terminalId }); | ||
| 73 | expect(output.output).toBe(expected); | ||
| 74 | } | ||
| 75 | }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), | ||
| 76 | ); | ||
| 77 | |||
| 78 | it.effect("runs Devin shell source with arguments, pipes, and session fallback variables", () => | ||
| 79 | Effect.gen(function* () { | ||
| 80 | const terminals = yield* makeAcpClientTerminals({ | ||
| 81 | spawner: yield* ChildProcessSpawner.ChildProcessSpawner, | ||
| 82 | defaultCwd: process.cwd(), | ||
| 83 | shellCommands: true, | ||
| 84 | environmentForSession: () => ({ T3_ACP_MCP_NODE: "mailbox-probe" }), | ||
| 85 | }); | ||
| 86 | yield* Effect.addFinalizer(() => terminals.disposeAll); | ||
| 87 | // Same command-only shape as the failed production commands and live capture. | ||
| 88 | const terminal = yield* terminals.create({ | ||
| 89 | sessionId: "devin", | ||
| 90 | command: 'printf "%s\\n" "$T3_ACP_MCP_NODE" | tr a-z A-Z', | ||
| 91 | }); | ||
| 92 | const exit = yield* terminals.waitForExit({ sessionId: "devin", ...terminal }); | ||
| 93 | const output = yield* terminals.output({ sessionId: "devin", ...terminal }); | ||
| 94 | expect(exit.exitCode).toBe(0); | ||
| 95 | expect(output.output.trim()).toBe("MAILBOX-PROBE"); | ||
| 96 | }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), | ||
| 97 | ); | ||
| 98 | |||
| 99 | it.effect("truncates buffered output from the beginning at the byte limit", () => | ||
| 100 | withTerminals((terminals) => | ||
| 101 | Effect.gen(function* () { | ||
| 102 | const created = yield* terminals.create({ | ||
| 103 | sessionId: "session", | ||
| 104 | command: process.execPath, | ||
| 105 | args: ["-e", "process.stdout.write('a'.repeat(64) + 'TAIL');"], | ||
| 106 | outputByteLimit: 16, | ||
| 107 | }); | ||
| 108 | yield* terminals.waitForExit({ sessionId: "session", terminalId: created.terminalId }); | ||
| 109 | |||
| 110 | const output = yield* terminals.output({ | ||
| 111 | sessionId: "session", | ||
| 112 | terminalId: created.terminalId, | ||
| 113 | }); | ||
| 114 | expect(output.truncated).toBe(true); | ||
| 115 | expect(output.output.endsWith("TAIL")).toBe(true); | ||
| 116 | expect(output.output.length).toBeLessThanOrEqual(16); | ||
| 117 | }), | ||
| 118 | ), | ||
| 119 | ); | ||
| 120 | |||
| 121 | it.effect("kills long-running commands and rejects released terminal handles", () => | ||
| 122 | withTerminals((terminals) => | ||
| 123 | Effect.gen(function* () { | ||
| 124 | const created = yield* terminals.create({ | ||
| 125 | sessionId: "session", | ||
| 126 | command: process.execPath, | ||
| 127 | args: ["-e", "setInterval(() => {}, 1000);"], | ||
| 128 | }); | ||
| 129 | yield* terminals.kill({ sessionId: "session", terminalId: created.terminalId }); | ||
| 130 | const exit = yield* terminals.waitForExit({ | ||
| 131 | sessionId: "session", | ||
| 132 | terminalId: created.terminalId, | ||
| 133 | }); | ||
| 134 | expect(exit.exitCode === 0 ? null : exit.exitCode).not.toBe(0); | ||
| 135 | expect(exit.signal).toBe("SIGTERM"); | ||
| 136 | |||
| 137 | yield* terminals.release({ sessionId: "session", terminalId: created.terminalId }); | ||
| 138 | const rejected = yield* Effect.flip( | ||
| 139 | terminals.output({ sessionId: "session", terminalId: created.terminalId }), | ||
| 140 | ); | ||
| 141 | expect(rejected.message).toContain("unknown terminal ID"); | ||
| 142 | |||
| 143 | // Embedded tool-call content still renders released terminals. | ||
| 144 | expect(terminals.readOutputSnapshot("session", created.terminalId)).toBeDefined(); | ||
| 145 | }), | ||
| 146 | ), | ||
| 147 | ); | ||
| 148 | |||
| 149 | it.effect("reserves terminal capacity across concurrent creates", () => | ||
| 150 | withTerminals((terminals) => | ||
| 151 | Effect.gen(function* () { | ||
| 152 | const results = yield* Effect.all( | ||
| 153 | Array.from({ length: 17 }, () => | ||
| 154 | terminals | ||
| 155 | .create({ | ||
| 156 | sessionId: "session", | ||
| 157 | command: process.execPath, | ||
| 158 | args: ["-e", "setInterval(() => {}, 1000);"], | ||
| 159 | }) | ||
| 160 | .pipe(Effect.result), | ||
| 161 | ), | ||
| 162 | { concurrency: "unbounded" }, | ||
| 163 | ); | ||
| 164 | |||
| 165 | expect(results.filter(Result.isSuccess)).toHaveLength(16); | ||
| 166 | expect(results.filter(Result.isFailure)).toHaveLength(1); | ||
| 167 | }), | ||
| 168 | ), | ||
| 169 | ); | ||
| 170 | |||
| 171 | it.effect("bounds retained snapshots after terminals are released", () => | ||
| 172 | withTerminals((terminals) => | ||
| 173 | Effect.gen(function* () { | ||
| 174 | const terminalIds: string[] = []; | ||
| 175 | for (let index = 0; index < 33; index += 1) { | ||
| 176 | const created = yield* terminals.create({ | ||
| 177 | sessionId: "session", | ||
| 178 | command: process.execPath, | ||
| 179 | args: ["-e", `process.stdout.write('${index}');`], | ||
| 180 | }); | ||
| 181 | yield* terminals.release({ sessionId: "session", terminalId: created.terminalId }); | ||
| 182 | terminalIds.push(created.terminalId); | ||
| 183 | } | ||
| 184 | |||
| 185 | expect(terminals.readOutputSnapshot("session", terminalIds[0]!)).toBeUndefined(); | ||
| 186 | expect(terminals.readOutputSnapshot("session", terminalIds.at(-1)!)).toBeDefined(); | ||
| 187 | }), | ||
| 188 | ), | ||
| 189 | ); | ||
| 190 | |||
| 191 | it.effect("bounds unreleased terminal handles without invalidating existing ids", () => | ||
| 192 | withTerminals((terminals) => | ||
| 193 | Effect.gen(function* () { | ||
| 194 | const terminalIds: string[] = []; | ||
| 195 | for (let index = 0; index < 64; index += 1) { | ||
| 196 | const created = yield* terminals.create({ | ||
| 197 | sessionId: "session", | ||
| 198 | command: process.execPath, | ||
| 199 | args: ["-e", `process.stdout.write('${index}');`], | ||
| 200 | }); | ||
| 201 | yield* terminals.waitForExit({ | ||
| 202 | sessionId: "session", | ||
| 203 | terminalId: created.terminalId, | ||
| 204 | }); | ||
| 205 | terminalIds.push(created.terminalId); | ||
| 206 | } | ||
| 207 | |||
| 208 | const rejected = yield* terminals | ||
| 209 | .create({ | ||
| 210 | sessionId: "session", | ||
| 211 | command: process.execPath, | ||
| 212 | args: ["-e", ""], | ||
| 213 | }) | ||
| 214 | .pipe(Effect.result); | ||
| 215 | expect(Result.isFailure(rejected)).toBe(true); | ||
| 216 | expect(terminals.readOutputSnapshot("session", terminalIds[0]!)).toBeDefined(); | ||
| 217 | expect(terminals.readOutputSnapshot("session", terminalIds.at(-1)!)).toBeDefined(); | ||
| 218 | }), | ||
| 219 | ), | ||
| 220 | ); | ||
| 221 | |||
| 222 | it.effect("bounds aggregate output retained across unreleased terminals", () => | ||
| 223 | withTerminals((terminals) => | ||
| 224 | Effect.gen(function* () { | ||
| 225 | const terminalIds: string[] = []; | ||
| 226 | for (let index = 0; index < 3; index += 1) { | ||
| 227 | const created = yield* terminals.create({ | ||
| 228 | sessionId: "session", | ||
| 229 | command: process.execPath, | ||
| 230 | args: ["-e", "process.stdout.write('x'.repeat(8 * 1024 * 1024));"], | ||
| 231 | outputByteLimit: 8 * 1024 * 1024, | ||
| 232 | }); | ||
| 233 | yield* terminals.waitForExit({ | ||
| 234 | sessionId: "session", | ||
| 235 | terminalId: created.terminalId, | ||
| 236 | }); | ||
| 237 | terminalIds.push(created.terminalId); | ||
| 238 | } | ||
| 239 | |||
| 240 | const snapshots = terminalIds.map((terminalId) => | ||
| 241 | terminals.readOutputSnapshot("session", terminalId)!, | ||
| 242 | ); | ||
| 243 | const retainedBytes = snapshots.reduce( | ||
| 244 | (total, snapshot) => total + Buffer.byteLength(snapshot.output), | ||
| 245 | 0, | ||
| 246 | ); | ||
| 247 | expect(retainedBytes).toBeLessThanOrEqual(16 * 1024 * 1024); | ||
| 248 | expect(snapshots.some((snapshot) => snapshot.truncated)).toBe(true); | ||
| 249 | }), | ||
| 250 | ), | ||
| 251 | ); | ||
| 252 | |||
| 253 | it.effect("rejects terminal handles from a different ACP session", () => | ||
| 254 | withTerminals((terminals) => | ||
| 255 | Effect.gen(function* () { | ||
| 256 | const created = yield* terminals.create({ | ||
| 257 | sessionId: "owner-session", | ||
| 258 | command: process.execPath, | ||
| 259 | args: ["-e", "process.stdout.write('owned');"], | ||
| 260 | }); | ||
| 261 | const foreignRequest = { | ||
| 262 | sessionId: "foreign-session", | ||
| 263 | terminalId: created.terminalId, | ||
| 264 | }; | ||
| 265 | const results = yield* Effect.all( | ||
| 266 | [ | ||
| 267 | terminals.output(foreignRequest), | ||
| 268 | terminals.waitForExit(foreignRequest), | ||
| 269 | terminals.kill(foreignRequest), | ||
| 270 | terminals.release(foreignRequest), | ||
| 271 | ].map((effect) => effect.pipe(Effect.result)), | ||
| 272 | { concurrency: "unbounded" }, | ||
| 273 | ); | ||
| 274 | |||
| 275 | expect(results.every(Result.isFailure)).toBe(true); | ||
| 276 | expect(terminals.readOutputSnapshot("foreign-session", created.terminalId)).toBeUndefined(); | ||
| 277 | expect(terminals.readOutputSnapshot("owner-session", created.terminalId)).toBeDefined(); | ||
| 278 | }), | ||
| 279 | ), | ||
| 280 | ); | ||
| 281 | }); | ||
| 282 | |||
| 283 | describe("resolveEmbeddedTerminalContent", () => { | ||
| 284 | it("rewrites terminal content into text from the buffered snapshot", () => { | ||
| 285 | const notification = { | ||
| 286 | sessionId: "session", | ||
| 287 | update: { | ||
| 288 | sessionUpdate: "tool_call_update" as const, | ||
| 289 | toolCallId: "call-1", | ||
| 290 | content: [ | ||
| 291 | { type: "content" as const, content: { type: "text" as const, text: "before" } }, | ||
| 292 | { type: "terminal" as const, terminalId: "t3-term-1" }, | ||
| 293 | { type: "terminal" as const, terminalId: "t3-term-unknown" }, | ||
| 294 | ], | ||
| 295 | }, | ||
| 296 | }; | ||
| 297 | |||
| 298 | const resolved = resolveEmbeddedTerminalContent(notification, (sessionId, terminalId) => | ||
| 299 | sessionId === "session" && terminalId === "t3-term-1" | ||
| 300 | ? { output: "compiled 3 files", truncated: false, exitStatus: undefined } | ||
| 301 | : undefined, | ||
| 302 | ); | ||
| 303 | |||
| 304 | expect(resolved.update).toMatchObject({ | ||
| 305 | content: [ | ||
| 306 | { type: "content", content: { type: "text", text: "before" } }, | ||
| 307 | { type: "content", content: { type: "text", text: "compiled 3 files" } }, | ||
| 308 | { type: "content", content: { type: "text", text: "[terminal t3-term-unknown]" } }, | ||
| 309 | ], | ||
| 310 | }); | ||
| 311 | }); | ||
| 312 | }); | ||
packages/provider-acp/src/server/clientTerminals.ts created+459| ... | @@ -0,0 +1,459 @@ | ||
| 1 | import * as Deferred from "effect/Deferred"; | ||
| 2 | import * as Duration from "effect/Duration"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Exit from "effect/Exit"; | ||
| 5 | import * as Fiber from "effect/Fiber"; | ||
| 6 | import * as Scope from "effect/Scope"; | ||
| 7 | import * as Semaphore from "effect/Semaphore"; | ||
| 8 | import * as Stream from "effect/Stream"; | ||
| 9 | import { ChildProcess, ChildProcessSpawner } from "effect/process"; | ||
| 10 | import * as EffectAcpErrors from "effect-acp/errors"; | ||
| 11 | import type * as EffectAcpSchema from "effect-acp/compat"; | ||
| 12 | import * as NodeBuffer from "node:buffer"; | ||
| 13 | |||
| 14 | /** | ||
| 15 | * Client-side implementation of the ACP `terminal` capability. | ||
| 16 | * | ||
| 17 | * Agents create terminals to run commands through the client | ||
| 18 | * (`terminal/create`), poll buffered output, await exit, kill, and release | ||
| 19 | * them. Terminals also appear as embedded tool-call content, which | ||
| 20 | * {@link resolveEmbeddedTerminalContent} rewrites into plain text so the | ||
| 21 | * existing tool-call projection renders live command output. | ||
| 22 | */ | ||
| 23 | |||
| 24 | const MAX_LIVE_TERMINALS = 16; | ||
| 25 | const MAX_UNRELEASED_TERMINALS = 64; | ||
| 26 | const MAX_RETAINED_TERMINALS = 32; | ||
| 27 | const MAX_RETAINED_OUTPUT_BYTES = 16 * 1024 * 1024; | ||
| 28 | const DEFAULT_OUTPUT_BYTE_LIMIT = 4 * 1024 * 1024; | ||
| 29 | const MAX_OUTPUT_BYTE_LIMIT = 8 * 1024 * 1024; | ||
| 30 | |||
| 31 | const terminalRequestError = (message: string, cause?: unknown) => | ||
| 32 | EffectAcpErrors.AcpRequestError.internalError( | ||
| 33 | message, | ||
| 34 | undefined, | ||
| 35 | cause === undefined ? {} : { cause }, | ||
| 36 | ); | ||
| 37 | |||
| 38 | interface OutputBufferState { | ||
| 39 | chunks: Array<Uint8Array>; | ||
| 40 | bytes: number; | ||
| 41 | truncated: boolean; | ||
| 42 | readonly limit: number; | ||
| 43 | } | ||
| 44 | |||
| 45 | function trimOutputStart(state: OutputBufferState, bytes: number): void { | ||
| 46 | let remaining = Math.min(bytes, state.bytes); | ||
| 47 | while (remaining > 0) { | ||
| 48 | const head = state.chunks[0]!; | ||
| 49 | if (head.byteLength <= remaining) { | ||
| 50 | state.chunks.shift(); | ||
| 51 | state.bytes -= head.byteLength; | ||
| 52 | remaining -= head.byteLength; | ||
| 53 | } else { | ||
| 54 | state.chunks[0] = head.subarray(remaining); | ||
| 55 | state.bytes -= remaining; | ||
| 56 | remaining = 0; | ||
| 57 | } | ||
| 58 | state.truncated = true; | ||
| 59 | } | ||
| 60 | } | ||
| 61 | |||
| 62 | function appendOutput(state: OutputBufferState, chunk: Uint8Array): void { | ||
| 63 | state.chunks.push(chunk); | ||
| 64 | state.bytes += chunk.byteLength; | ||
| 65 | // The spec requires truncation from the beginning of the output. | ||
| 66 | if (state.bytes > state.limit) trimOutputStart(state, state.bytes - state.limit); | ||
| 67 | } | ||
| 68 | |||
| 69 | function readOutput(state: OutputBufferState): string { | ||
| 70 | let combined: Uint8Array = NodeBuffer.Buffer.concat(state.chunks, state.bytes); | ||
| 71 | if (state.truncated) { | ||
| 72 | // Drop leading UTF-8 continuation bytes so truncation lands on a | ||
| 73 | // character boundary, as the spec requires. | ||
| 74 | let start = 0; | ||
| 75 | while (start < combined.byteLength && (combined[start]! & 0b1100_0000) === 0b1000_0000) { | ||
| 76 | start += 1; | ||
| 77 | } | ||
| 78 | combined = combined.subarray(start); | ||
| 79 | } | ||
| 80 | return NodeBuffer.Buffer.from(combined).toString("utf8"); | ||
| 81 | } | ||
| 82 | |||
| 83 | function terminalExitSignal(error: unknown): string | null { | ||
| 84 | const cause = error instanceof Error ? error.cause : undefined; | ||
| 85 | const message = | ||
| 86 | cause instanceof Error ? cause.message : error instanceof Error ? error.message : ""; | ||
| 87 | return message.match(/signal: '([^']+)'/u)?.[1] ?? null; | ||
| 88 | } | ||
| 89 | |||
| 90 | export interface AcpTerminalOutputSnapshot { | ||
| 91 | readonly output: string; | ||
| 92 | readonly truncated: boolean; | ||
| 93 | readonly exitStatus: EffectAcpSchema.TerminalExitStatus | undefined; | ||
| 94 | } | ||
| 95 | |||
| 96 | interface ManagedAcpTerminal { | ||
| 97 | readonly terminalId: string; | ||
| 98 | readonly sessionId: string; | ||
| 99 | /** The spawned command with args, kept for MCP-fallback identification. */ | ||
| 100 | readonly commandLine: string; | ||
| 101 | readonly buffer: OutputBufferState; | ||
| 102 | readonly exit: Deferred.Deferred<EffectAcpSchema.WaitForTerminalExitResponse>; | ||
| 103 | readonly kill: Effect.Effect<void>; | ||
| 104 | /** Closes the terminal's scope, killing the process if still running. */ | ||
| 105 | readonly dispose: Effect.Effect<void>; | ||
| 106 | exitStatus: EffectAcpSchema.TerminalExitStatus | undefined; | ||
| 107 | released: boolean; | ||
| 108 | } | ||
| 109 | |||
| 110 | export interface AcpClientTerminals { | ||
| 111 | readonly create: ( | ||
| 112 | request: EffectAcpSchema.CreateTerminalRequest, | ||
| 113 | ) => Effect.Effect<EffectAcpSchema.CreateTerminalResponse, EffectAcpErrors.AcpRequestError>; | ||
| 114 | readonly output: ( | ||
| 115 | request: EffectAcpSchema.TerminalOutputRequest, | ||
| 116 | ) => Effect.Effect<EffectAcpSchema.TerminalOutputResponse, EffectAcpErrors.AcpRequestError>; | ||
| 117 | readonly waitForExit: ( | ||
| 118 | request: EffectAcpSchema.WaitForTerminalExitRequest, | ||
| 119 | ) => Effect.Effect<EffectAcpSchema.WaitForTerminalExitResponse, EffectAcpErrors.AcpRequestError>; | ||
| 120 | readonly kill: ( | ||
| 121 | request: EffectAcpSchema.KillTerminalRequest, | ||
| 122 | ) => Effect.Effect<EffectAcpSchema.KillTerminalResponse, EffectAcpErrors.AcpRequestError>; | ||
| 123 | readonly release: ( | ||
| 124 | request: EffectAcpSchema.ReleaseTerminalRequest, | ||
| 125 | ) => Effect.Effect<EffectAcpSchema.ReleaseTerminalResponse, EffectAcpErrors.AcpRequestError>; | ||
| 126 | /** Buffered output for embedded tool-call content; survives release. */ | ||
| 127 | readonly readOutputSnapshot: ( | ||
| 128 | sessionId: string, | ||
| 129 | terminalId: string, | ||
| 130 | ) => AcpTerminalOutputSnapshot | undefined; | ||
| 131 | /** Command line a terminal was created with; presentation-only lookup. */ | ||
| 132 | readonly readCommandLine: (terminalId: string) => string | undefined; | ||
| 133 | /** Kills every terminal that is still running. Safe to call repeatedly. */ | ||
| 134 | readonly disposeAll: Effect.Effect<void>; | ||
| 135 | } | ||
| 136 | |||
| 137 | export interface AcpClientTerminalsOptions { | ||
| 138 | /** Devin sends shell source in command, rather than an executable plus args. */ | ||
| 139 | readonly shellCommands?: boolean | undefined; | ||
| 140 | readonly spawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; | ||
| 141 | readonly defaultCwd: string; | ||
| 142 | readonly environment?: NodeJS.ProcessEnv | undefined; | ||
| 143 | readonly environmentForSession?: | ||
| 144 | | ((sessionId: string) => NodeJS.ProcessEnv | undefined) | ||
| 145 | | undefined; | ||
| 146 | readonly forceKillAfter?: Duration.Input | undefined; | ||
| 147 | } | ||
| 148 | |||
| 149 | function acpTerminalCommand(input: { | ||
| 150 | readonly request: EffectAcpSchema.CreateTerminalRequest; | ||
| 151 | readonly shellCommands?: boolean | undefined; | ||
| 152 | readonly defaultCwd: string; | ||
| 153 | readonly environment?: NodeJS.ProcessEnv | undefined; | ||
| 154 | readonly forceKillAfter?: Duration.Input | undefined; | ||
| 155 | }) { | ||
| 156 | return ChildProcess.make(input.request.command, input.request.args ?? [], { | ||
| 157 | cwd: input.request.cwd ?? input.defaultCwd, | ||
| 158 | ...(input.environment === undefined ? {} : { env: input.environment }), | ||
| 159 | shell: input.shellCommands === true && (input.request.args?.length ?? 0) === 0, | ||
| 160 | forceKillAfter: input.forceKillAfter ?? "5 seconds", | ||
| 161 | }); | ||
| 162 | } | ||
| 163 | |||
| 164 | export const makeAcpClientTerminals = ( | ||
| 165 | options: AcpClientTerminalsOptions, | ||
| 166 | ): Effect.Effect<AcpClientTerminals> => | ||
| 167 | Effect.gen(function* () { | ||
| 168 | const creationPermit = yield* Semaphore.make(1); | ||
| 169 | const terminals = new Map<string, ManagedAcpTerminal>(); | ||
| 170 | let nextTerminalNumber = 1; | ||
| 171 | |||
| 172 | const enforceTotalOutputLimit = (): void => { | ||
| 173 | let excess = | ||
| 174 | Array.from(terminals.values()).reduce( | ||
| 175 | (total, terminal) => total + terminal.buffer.bytes, | ||
| 176 | 0, | ||
| 177 | ) - MAX_RETAINED_OUTPUT_BYTES; | ||
| 178 | if (excess <= 0) return; | ||
| 179 | for (const terminal of terminals.values()) { | ||
| 180 | const before = terminal.buffer.bytes; | ||
| 181 | trimOutputStart(terminal.buffer, excess); | ||
| 182 | excess -= before - terminal.buffer.bytes; | ||
| 183 | if (excess <= 0) return; | ||
| 184 | } | ||
| 185 | }; | ||
| 186 | |||
| 187 | const pruneReleasedSnapshots = (): void => { | ||
| 188 | const released = Array.from(terminals.values()).filter((terminal) => terminal.released); | ||
| 189 | let retainedBytes = released.reduce((total, terminal) => total + terminal.buffer.bytes, 0); | ||
| 190 | while ( | ||
| 191 | released.length > MAX_RETAINED_TERMINALS || | ||
| 192 | retainedBytes > MAX_RETAINED_OUTPUT_BYTES | ||
| 193 | ) { | ||
| 194 | const terminal = released.shift(); | ||
| 195 | if (terminal === undefined) break; | ||
| 196 | retainedBytes -= terminal.buffer.bytes; | ||
| 197 | terminals.delete(terminal.terminalId); | ||
| 198 | } | ||
| 199 | }; | ||
| 200 | |||
| 201 | const requireTerminal = ( | ||
| 202 | sessionId: string, | ||
| 203 | terminalId: string, | ||
| 204 | operation: string, | ||
| 205 | ): Effect.Effect<ManagedAcpTerminal, EffectAcpErrors.AcpRequestError> => { | ||
| 206 | const terminal = terminals.get(terminalId); | ||
| 207 | if (terminal === undefined || terminal.sessionId !== sessionId || terminal.released) { | ||
| 208 | return Effect.fail( | ||
| 209 | terminalRequestError(`ACP ${operation} received an unknown terminal ID '${terminalId}'.`), | ||
| 210 | ); | ||
| 211 | } | ||
| 212 | return Effect.succeed(terminal); | ||
| 213 | }; | ||
| 214 | |||
| 215 | const create: AcpClientTerminals["create"] = (request) => | ||
| 216 | creationPermit.withPermit( | ||
| 217 | Effect.uninterruptibleMask((restore) => | ||
| 218 | Effect.gen(function* () { | ||
| 219 | const liveCount = Array.from(terminals.values()).filter( | ||
| 220 | (terminal) => !terminal.released && terminal.exitStatus === undefined, | ||
| 221 | ).length; | ||
| 222 | const unreleasedCount = Array.from(terminals.values()).filter( | ||
| 223 | (terminal) => !terminal.released, | ||
| 224 | ).length; | ||
| 225 | if (unreleasedCount >= MAX_UNRELEASED_TERMINALS) { | ||
| 226 | return yield* terminalRequestError( | ||
| 227 | `ACP terminal/create exceeded the limit of ${MAX_UNRELEASED_TERMINALS} unreleased terminals.`, | ||
| 228 | ); | ||
| 229 | } | ||
| 230 | if (liveCount >= MAX_LIVE_TERMINALS) { | ||
| 231 | return yield* terminalRequestError( | ||
| 232 | `ACP terminal/create exceeded the limit of ${MAX_LIVE_TERMINALS} concurrent terminals.`, | ||
| 233 | ); | ||
| 234 | } | ||
| 235 | const requestEnvironment = Object.fromEntries( | ||
| 236 | (request.env ?? []).map((variable) => [variable.name, variable.value] as const), | ||
| 237 | ); | ||
| 238 | const sessionEnvironment = options.environmentForSession?.(request.sessionId); | ||
| 239 | const environment = | ||
| 240 | options.environment === undefined && | ||
| 241 | sessionEnvironment === undefined && | ||
| 242 | (request.env?.length ?? 0) === 0 | ||
| 243 | ? undefined | ||
| 244 | : { ...options.environment, ...sessionEnvironment, ...requestEnvironment }; | ||
| 245 | // Each terminal owns a scope so the spawned process is reliably reaped: | ||
| 246 | // closing the scope kills a still-running command and frees the handle. | ||
| 247 | const terminalScope = yield* Scope.make(); | ||
| 248 | const child = yield* restore( | ||
| 249 | options.spawner | ||
| 250 | .spawn( | ||
| 251 | acpTerminalCommand({ | ||
| 252 | request, | ||
| 253 | shellCommands: options.shellCommands, | ||
| 254 | defaultCwd: options.defaultCwd, | ||
| 255 | environment, | ||
| 256 | forceKillAfter: options.forceKillAfter, | ||
| 257 | }), | ||
| 258 | ) | ||
| 259 | .pipe( | ||
| 260 | Effect.provideService(Scope.Scope, terminalScope), | ||
| 261 | Effect.mapError((cause) => | ||
| 262 | terminalRequestError( | ||
| 263 | `Could not start terminal command '${request.command}'.`, | ||
| 264 | cause, | ||
| 265 | ), | ||
| 266 | ), | ||
| 267 | ), | ||
| 268 | ).pipe( | ||
| 269 | Effect.onExit((exit) => | ||
| 270 | Exit.isFailure(exit) ? Scope.close(terminalScope, Exit.void) : Effect.void, | ||
| 271 | ), | ||
| 272 | ); | ||
| 273 | |||
| 274 | const terminalId = `t3-term-${nextTerminalNumber}`; | ||
| 275 | nextTerminalNumber += 1; | ||
| 276 | const record: ManagedAcpTerminal = { | ||
| 277 | terminalId, | ||
| 278 | sessionId: request.sessionId, | ||
| 279 | commandLine: [request.command, ...(request.args ?? [])].join(" "), | ||
| 280 | buffer: { | ||
| 281 | chunks: [], | ||
| 282 | bytes: 0, | ||
| 283 | truncated: false, | ||
| 284 | limit: Math.min( | ||
| 285 | request.outputByteLimit ?? DEFAULT_OUTPUT_BYTE_LIMIT, | ||
| 286 | MAX_OUTPUT_BYTE_LIMIT, | ||
| 287 | ), | ||
| 288 | }, | ||
| 289 | exit: yield* Deferred.make<EffectAcpSchema.WaitForTerminalExitResponse>(), | ||
| 290 | kill: child.kill().pipe(Effect.ignore), | ||
| 291 | dispose: Scope.close(terminalScope, Exit.void).pipe(Effect.ignore), | ||
| 292 | exitStatus: undefined, | ||
| 293 | released: false, | ||
| 294 | }; | ||
| 295 | terminals.set(terminalId, record); | ||
| 296 | |||
| 297 | const pump = (stream: typeof child.stdout) => | ||
| 298 | stream.pipe( | ||
| 299 | Stream.runForEach((chunk) => | ||
| 300 | Effect.sync(() => { | ||
| 301 | appendOutput(record.buffer, chunk); | ||
| 302 | enforceTotalOutputLimit(); | ||
| 303 | }), | ||
| 304 | ), | ||
| 305 | Effect.ignore, | ||
| 306 | ); | ||
| 307 | const stdoutPump = yield* Effect.forkDetach(pump(child.stdout)); | ||
| 308 | const stderrPump = yield* Effect.forkDetach(pump(child.stderr)); | ||
| 309 | yield* Effect.forkDetach( | ||
| 310 | child.exitCode.pipe( | ||
| 311 | Effect.match({ | ||
| 312 | onFailure: (error) => ({ exitCode: null, signal: terminalExitSignal(error) }), | ||
| 313 | onSuccess: (code) => ({ exitCode: Number(code), signal: null }), | ||
| 314 | }), | ||
| 315 | Effect.flatMap((status) => | ||
| 316 | Effect.all([Fiber.join(stdoutPump), Fiber.join(stderrPump)], { | ||
| 317 | discard: true, | ||
| 318 | }).pipe( | ||
| 319 | Effect.andThen( | ||
| 320 | Effect.sync(() => { | ||
| 321 | record.exitStatus = status; | ||
| 322 | }), | ||
| 323 | ), | ||
| 324 | Effect.andThen(Deferred.succeed(record.exit, status)), | ||
| 325 | ), | ||
| 326 | ), | ||
| 327 | ), | ||
| 328 | ); | ||
| 329 | return { terminalId }; | ||
| 330 | }), | ||
| 331 | ), | ||
| 332 | ); | ||
| 333 | |||
| 334 | const output: AcpClientTerminals["output"] = (request) => | ||
| 335 | requireTerminal(request.sessionId, request.terminalId, "terminal/output").pipe( | ||
| 336 | Effect.map((terminal) => ({ | ||
| 337 | output: readOutput(terminal.buffer), | ||
| 338 | truncated: terminal.buffer.truncated, | ||
| 339 | ...(terminal.exitStatus === undefined ? {} : { exitStatus: terminal.exitStatus }), | ||
| 340 | })), | ||
| 341 | ); | ||
| 342 | |||
| 343 | const waitForExit: AcpClientTerminals["waitForExit"] = (request) => | ||
| 344 | requireTerminal(request.sessionId, request.terminalId, "terminal/wait_for_exit").pipe( | ||
| 345 | Effect.flatMap((terminal) => Deferred.await(terminal.exit)), | ||
| 346 | ); | ||
| 347 | |||
| 348 | const kill: AcpClientTerminals["kill"] = (request) => | ||
| 349 | requireTerminal(request.sessionId, request.terminalId, "terminal/kill").pipe( | ||
| 350 | Effect.flatMap((terminal) => terminal.kill), | ||
| 351 | Effect.as({}), | ||
| 352 | ); | ||
| 353 | |||
| 354 | const release: AcpClientTerminals["release"] = (request) => | ||
| 355 | Effect.uninterruptible( | ||
| 356 | requireTerminal(request.sessionId, request.terminalId, "terminal/release").pipe( | ||
| 357 | Effect.flatMap((terminal) => | ||
| 358 | Effect.sync(() => { | ||
| 359 | terminal.released = true; | ||
| 360 | }).pipe( | ||
| 361 | Effect.andThen(terminal.dispose), | ||
| 362 | Effect.andThen(Deferred.await(terminal.exit)), | ||
| 363 | Effect.andThen(Effect.sync(pruneReleasedSnapshots)), | ||
| 364 | ), | ||
| 365 | ), | ||
| 366 | Effect.as({}), | ||
| 367 | ), | ||
| 368 | ); | ||
| 369 | |||
| 370 | const readOutputSnapshot: AcpClientTerminals["readOutputSnapshot"] = ( | ||
| 371 | sessionId, | ||
| 372 | terminalId, | ||
| 373 | ) => { | ||
| 374 | const terminal = terminals.get(terminalId); | ||
| 375 | if (terminal === undefined || terminal.sessionId !== sessionId) return undefined; | ||
| 376 | return { | ||
| 377 | output: readOutput(terminal.buffer), | ||
| 378 | truncated: terminal.buffer.truncated, | ||
| 379 | exitStatus: terminal.exitStatus, | ||
| 380 | }; | ||
| 381 | }; | ||
| 382 | |||
| 383 | const readCommandLine: AcpClientTerminals["readCommandLine"] = (terminalId) => | ||
| 384 | terminals.get(terminalId)?.commandLine; | ||
| 385 | |||
| 386 | const disposeAll: AcpClientTerminals["disposeAll"] = Effect.uninterruptible( | ||
| 387 | Effect.suspend(() => | ||
| 388 | Effect.forEach( | ||
| 389 | Array.from(terminals.values()), | ||
| 390 | (terminal) => | ||
| 391 | Effect.sync(() => { | ||
| 392 | terminal.released = true; | ||
| 393 | }).pipe( | ||
| 394 | Effect.andThen(terminal.dispose), | ||
| 395 | Effect.andThen(Deferred.await(terminal.exit)), | ||
| 396 | ), | ||
| 397 | { concurrency: "unbounded", discard: true }, | ||
| 398 | ).pipe(Effect.andThen(Effect.sync(pruneReleasedSnapshots))), | ||
| 399 | ), | ||
| 400 | ); | ||
| 401 | |||
| 402 | // Terminal failures surface to the agent as JSON-RPC errors and to the | ||
| 403 | // user only as a failed tool item, so log them for server-side diagnosis. | ||
| 404 | const logged = | ||
| 405 | <Request, Response>( | ||
| 406 | operation: string, | ||
| 407 | handler: (request: Request) => Effect.Effect<Response, EffectAcpErrors.AcpRequestError>, | ||
| 408 | ) => | ||
| 409 | (request: Request) => | ||
| 410 | handler(request).pipe( | ||
| 411 | Effect.tapError((error) => | ||
| 412 | Effect.logWarning("ACP client terminal operation failed", { | ||
| 413 | operation, | ||
| 414 | detail: error.message, | ||
| 415 | }), | ||
| 416 | ), | ||
| 417 | ); | ||
| 418 | |||
| 419 | return { | ||
| 420 | create: logged("terminal/create", create), | ||
| 421 | output: logged("terminal/output", output), | ||
| 422 | waitForExit: logged("terminal/wait_for_exit", waitForExit), | ||
| 423 | kill: logged("terminal/kill", kill), | ||
| 424 | release: logged("terminal/release", release), | ||
| 425 | readOutputSnapshot, | ||
| 426 | readCommandLine, | ||
| 427 | disposeAll, | ||
| 428 | }; | ||
| 429 | }); | ||
| 430 | |||
| 431 | /** | ||
| 432 | * Rewrites embedded `terminal` tool-call content into plain text content so | ||
| 433 | * the provider-neutral tool-call projection can display live command output. | ||
| 434 | */ | ||
| 435 | export function resolveEmbeddedTerminalContent( | ||
| 436 | notification: EffectAcpSchema.SessionNotification, | ||
| 437 | readOutputSnapshot: AcpClientTerminals["readOutputSnapshot"], | ||
| 438 | ): EffectAcpSchema.SessionNotification { | ||
| 439 | const update = notification.update; | ||
| 440 | if (update.sessionUpdate !== "tool_call" && update.sessionUpdate !== "tool_call_update") { | ||
| 441 | return notification; | ||
| 442 | } | ||
| 443 | const content = update.content; | ||
| 444 | if (!content || !content.some((entry) => entry.type === "terminal")) { | ||
| 445 | return notification; | ||
| 446 | } | ||
| 447 | const mapped = content.map((entry): EffectAcpSchema.ToolCallContent => { | ||
| 448 | if (entry.type !== "terminal") return entry; | ||
| 449 | const snapshot = readOutputSnapshot(notification.sessionId, entry.terminalId); | ||
| 450 | return { | ||
| 451 | type: "content", | ||
| 452 | content: { | ||
| 453 | type: "text", | ||
| 454 | text: snapshot === undefined ? `[terminal ${entry.terminalId}]` : snapshot.output, | ||
| 455 | }, | ||
| 456 | }; | ||
| 457 | }); | ||
| 458 | return { ...notification, update: { ...update, content: mapped } }; | ||
| 459 | } | ||
packages/provider-acp/src/server/coreRuntimeEvents.test.ts created+247| ... | @@ -0,0 +1,247 @@ | ||
| 1 | import { ProviderDriverKind, RuntimeRequestId, TurnId } from "@t3tools/contracts"; | ||
| 2 | import { describe, expect, it } from "vite-plus/test"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | makeAcpAssistantItemEvent, | ||
| 6 | makeAcpContentDeltaEvent, | ||
| 7 | makeAcpPlanUpdatedEvent, | ||
| 8 | makeAcpRequestOpenedEvent, | ||
| 9 | makeAcpRequestResolvedEvent, | ||
| 10 | makeAcpToolCallEvent, | ||
| 11 | } from "./coreRuntimeEvents.ts"; | ||
| 12 | |||
| 13 | describe("AcpCoreRuntimeEvents", () => { | ||
| 14 | it("maps ACP permission requests to canonical runtime events", () => { | ||
| 15 | const stamp = { eventId: "event-1" as never, createdAt: "2026-03-27T00:00:00.000Z" }; | ||
| 16 | const turnId = TurnId.make("turn-1"); | ||
| 17 | const permissionRequest = { | ||
| 18 | kind: "execute" as const, | ||
| 19 | detail: "cat package.json", | ||
| 20 | toolCall: { | ||
| 21 | toolCallId: "tool-1", | ||
| 22 | kind: "execute", | ||
| 23 | status: "pending" as const, | ||
| 24 | command: "cat package.json", | ||
| 25 | detail: "cat package.json", | ||
| 26 | data: { toolCallId: "tool-1", kind: "execute" }, | ||
| 27 | }, | ||
| 28 | }; | ||
| 29 | |||
| 30 | const openedEvent = makeAcpRequestOpenedEvent({ | ||
| 31 | stamp, | ||
| 32 | provider: ProviderDriverKind.make("cursor"), | ||
| 33 | threadId: "thread-1" as never, | ||
| 34 | turnId, | ||
| 35 | requestId: RuntimeRequestId.make("request-1"), | ||
| 36 | permissionRequest, | ||
| 37 | detail: "cat package.json", | ||
| 38 | args: { command: ["cat", "package.json"] }, | ||
| 39 | source: "acp.jsonrpc", | ||
| 40 | method: "session/request_permission", | ||
| 41 | rawPayload: { sessionId: "session-1" }, | ||
| 42 | }); | ||
| 43 | expect(openedEvent).toMatchObject({ | ||
| 44 | type: "request.opened", | ||
| 45 | payload: { | ||
| 46 | requestType: "exec_command_approval", | ||
| 47 | detail: "cat package.json", | ||
| 48 | }, | ||
| 49 | }); | ||
| 50 | expect(openedEvent).not.toHaveProperty("payload.options"); | ||
| 51 | |||
| 52 | expect( | ||
| 53 | makeAcpRequestResolvedEvent({ | ||
| 54 | stamp, | ||
| 55 | provider: ProviderDriverKind.make("cursor"), | ||
| 56 | threadId: "thread-1" as never, | ||
| 57 | turnId, | ||
| 58 | requestId: RuntimeRequestId.make("request-1"), | ||
| 59 | permissionRequest, | ||
| 60 | decision: "accept", | ||
| 61 | }), | ||
| 62 | ).toMatchObject({ | ||
| 63 | type: "request.resolved", | ||
| 64 | payload: { | ||
| 65 | requestType: "exec_command_approval", | ||
| 66 | decision: "accept", | ||
| 67 | }, | ||
| 68 | }); | ||
| 69 | }); | ||
| 70 | |||
| 71 | it("preserves a native file approval without a remembered-allow choice", () => { | ||
| 72 | const event = makeAcpRequestOpenedEvent({ | ||
| 73 | stamp: { eventId: "approval-1" as never, createdAt: "2026-09-02T00:00:00.000Z" }, | ||
| 74 | provider: ProviderDriverKind.make("antigravity"), | ||
| 75 | threadId: "thread-1" as never, | ||
| 76 | turnId: TurnId.make("turn-1"), | ||
| 77 | requestId: RuntimeRequestId.make("request-1"), | ||
| 78 | permissionRequest: { kind: "edit" }, | ||
| 79 | approvalOptions: [ | ||
| 80 | { decision: "accept", label: "Allow once" }, | ||
| 81 | { decision: "decline", label: "Reject" }, | ||
| 82 | ], | ||
| 83 | detail: "Edit package.json", | ||
| 84 | args: {}, | ||
| 85 | source: "acp.jsonrpc", | ||
| 86 | method: "session/request_permission", | ||
| 87 | rawPayload: { sessionId: "session-1" }, | ||
| 88 | }); | ||
| 89 | |||
| 90 | expect(event.payload).toEqual({ | ||
| 91 | requestType: "file_change_approval", | ||
| 92 | detail: "Edit package.json", | ||
| 93 | args: {}, | ||
| 94 | options: [ | ||
| 95 | { decision: "accept", label: "Allow once" }, | ||
| 96 | { decision: "decline", label: "Reject" }, | ||
| 97 | ], | ||
| 98 | }); | ||
| 99 | }); | ||
| 100 | |||
| 101 | it("maps generic ACP permission kinds to dynamic tool approvals", () => { | ||
| 102 | const stamp = { eventId: "event-1" as never, createdAt: "2026-03-27T00:00:00.000Z" }; | ||
| 103 | |||
| 104 | for (const kind of ["search", "fetch", "other", "unknown", "future-tool-kind"]) { | ||
| 105 | const permissionRequest = { kind }; | ||
| 106 | const request = { | ||
| 107 | stamp, | ||
| 108 | provider: ProviderDriverKind.make("cursor"), | ||
| 109 | threadId: "thread-1" as never, | ||
| 110 | turnId: TurnId.make("turn-1"), | ||
| 111 | requestId: RuntimeRequestId.make(`request-${kind}`), | ||
| 112 | permissionRequest, | ||
| 113 | }; | ||
| 114 | |||
| 115 | expect( | ||
| 116 | makeAcpRequestOpenedEvent({ | ||
| 117 | ...request, | ||
| 118 | detail: kind, | ||
| 119 | args: {}, | ||
| 120 | source: "acp.jsonrpc", | ||
| 121 | method: "session/request_permission", | ||
| 122 | rawPayload: { sessionId: "session-1" }, | ||
| 123 | }), | ||
| 124 | ).toMatchObject({ | ||
| 125 | type: "request.opened", | ||
| 126 | payload: { requestType: "dynamic_tool_call" }, | ||
| 127 | }); | ||
| 128 | |||
| 129 | expect( | ||
| 130 | makeAcpRequestResolvedEvent({ | ||
| 131 | ...request, | ||
| 132 | decision: "accept", | ||
| 133 | }), | ||
| 134 | ).toMatchObject({ | ||
| 135 | type: "request.resolved", | ||
| 136 | payload: { requestType: "dynamic_tool_call" }, | ||
| 137 | }); | ||
| 138 | } | ||
| 139 | }); | ||
| 140 | |||
| 141 | it("maps ACP core plan, tool-call, and content updates", () => { | ||
| 142 | const stamp = { eventId: "event-1" as never, createdAt: "2026-03-27T00:00:00.000Z" }; | ||
| 143 | const turnId = TurnId.make("turn-1"); | ||
| 144 | |||
| 145 | expect( | ||
| 146 | makeAcpPlanUpdatedEvent({ | ||
| 147 | stamp, | ||
| 148 | provider: ProviderDriverKind.make("cursor"), | ||
| 149 | threadId: "thread-1" as never, | ||
| 150 | turnId, | ||
| 151 | payload: { | ||
| 152 | nativePlanId: "plan-1", | ||
| 153 | kind: "items", | ||
| 154 | plan: [{ step: "Inspect state", status: "inProgress" }], | ||
| 155 | }, | ||
| 156 | source: "acp.cursor.extension", | ||
| 157 | method: "cursor/update_todos", | ||
| 158 | rawPayload: { todos: [] }, | ||
| 159 | }), | ||
| 160 | ).toMatchObject({ | ||
| 161 | type: "turn.plan.updated", | ||
| 162 | raw: { | ||
| 163 | method: "cursor/update_todos", | ||
| 164 | }, | ||
| 165 | }); | ||
| 166 | |||
| 167 | expect( | ||
| 168 | makeAcpToolCallEvent({ | ||
| 169 | stamp, | ||
| 170 | provider: ProviderDriverKind.make("cursor"), | ||
| 171 | threadId: "thread-1" as never, | ||
| 172 | turnId, | ||
| 173 | toolCall: { | ||
| 174 | toolCallId: "tool-1", | ||
| 175 | kind: "execute", | ||
| 176 | status: "completed", | ||
| 177 | title: "Terminal", | ||
| 178 | detail: "bun run test", | ||
| 179 | data: { command: "bun run test" }, | ||
| 180 | }, | ||
| 181 | rawPayload: { sessionId: "session-1" }, | ||
| 182 | }), | ||
| 183 | ).toMatchObject({ | ||
| 184 | type: "item.completed", | ||
| 185 | payload: { | ||
| 186 | itemType: "command_execution", | ||
| 187 | status: "completed", | ||
| 188 | }, | ||
| 189 | }); | ||
| 190 | |||
| 191 | expect( | ||
| 192 | makeAcpContentDeltaEvent({ | ||
| 193 | stamp, | ||
| 194 | provider: ProviderDriverKind.make("cursor"), | ||
| 195 | threadId: "thread-1" as never, | ||
| 196 | turnId, | ||
| 197 | itemId: "assistant:session-1:segment:0", | ||
| 198 | text: "hello", | ||
| 199 | rawPayload: { sessionId: "session-1" }, | ||
| 200 | }), | ||
| 201 | ).toMatchObject({ | ||
| 202 | type: "content.delta", | ||
| 203 | itemId: "assistant:session-1:segment:0", | ||
| 204 | payload: { | ||
| 205 | delta: "hello", | ||
| 206 | }, | ||
| 207 | }); | ||
| 208 | |||
| 209 | expect( | ||
| 210 | makeAcpAssistantItemEvent({ | ||
| 211 | stamp, | ||
| 212 | provider: ProviderDriverKind.make("cursor"), | ||
| 213 | threadId: "thread-1" as never, | ||
| 214 | turnId, | ||
| 215 | itemId: "assistant:session-1:segment:0", | ||
| 216 | lifecycle: "item.started", | ||
| 217 | }), | ||
| 218 | ).toMatchObject({ | ||
| 219 | type: "item.started", | ||
| 220 | itemId: "assistant:session-1:segment:0", | ||
| 221 | payload: { | ||
| 222 | itemType: "assistant_message", | ||
| 223 | status: "inProgress", | ||
| 224 | }, | ||
| 225 | }); | ||
| 226 | }); | ||
| 227 | |||
| 228 | it("maps thoughts to the reasoning stream", () => { | ||
| 229 | expect( | ||
| 230 | makeAcpContentDeltaEvent({ | ||
| 231 | stamp: { eventId: "thought-1" as never, createdAt: "2026-09-02T00:00:00.000Z" }, | ||
| 232 | provider: ProviderDriverKind.make("cursor"), | ||
| 233 | threadId: "thread-1" as never, | ||
| 234 | turnId: TurnId.make("turn-1"), | ||
| 235 | streamKind: "reasoning_text", | ||
| 236 | text: "Inspect the current implementation first.", | ||
| 237 | rawPayload: { sessionId: "session-1" }, | ||
| 238 | }), | ||
| 239 | ).toMatchObject({ | ||
| 240 | type: "content.delta", | ||
| 241 | payload: { | ||
| 242 | streamKind: "reasoning_text", | ||
| 243 | delta: "Inspect the current implementation first.", | ||
| 244 | }, | ||
| 245 | }); | ||
| 246 | }); | ||
| 247 | }); | ||
packages/provider-acp/src/server/coreRuntimeEvents.ts created+255| ... | @@ -0,0 +1,255 @@ | ||
| 1 | import { | ||
| 2 | type RuntimeEventRawSource, | ||
| 3 | RuntimeItemId, | ||
| 4 | type CanonicalRequestType, | ||
| 5 | type EventId, | ||
| 6 | type ProviderApprovalDecision, | ||
| 7 | type ProviderApprovalOption, | ||
| 8 | type ProviderDriverKind, | ||
| 9 | type ProviderRuntimeEvent, | ||
| 10 | type RuntimeRequestId, | ||
| 11 | type ThreadId, | ||
| 12 | type TurnId, | ||
| 13 | } from "@t3tools/contracts"; | ||
| 14 | |||
| 15 | import { | ||
| 16 | type AcpPermissionRequest, | ||
| 17 | type AcpPlanUpdate, | ||
| 18 | type AcpToolCallState, | ||
| 19 | canonicalItemTypeFromAcpToolKind, | ||
| 20 | } from "./runtimeModel.ts"; | ||
| 21 | |||
| 22 | type AcpAdapterRawSource = Extract< | ||
| 23 | RuntimeEventRawSource, | ||
| 24 | "acp.jsonrpc" | `acp.${string}.extension` | ||
| 25 | >; | ||
| 26 | |||
| 27 | interface AcpEventStamp { | ||
| 28 | readonly eventId: EventId; | ||
| 29 | readonly createdAt: string; | ||
| 30 | } | ||
| 31 | |||
| 32 | type AcpCanonicalRequestType = Extract< | ||
| 33 | CanonicalRequestType, | ||
| 34 | "exec_command_approval" | "file_read_approval" | "file_change_approval" | "dynamic_tool_call" | ||
| 35 | >; | ||
| 36 | |||
| 37 | function canonicalRequestTypeFromAcpKind(kind: string | "unknown"): AcpCanonicalRequestType { | ||
| 38 | switch (kind) { | ||
| 39 | case "execute": | ||
| 40 | return "exec_command_approval"; | ||
| 41 | case "read": | ||
| 42 | return "file_read_approval"; | ||
| 43 | case "edit": | ||
| 44 | case "delete": | ||
| 45 | case "move": | ||
| 46 | return "file_change_approval"; | ||
| 47 | default: | ||
| 48 | return "dynamic_tool_call"; | ||
| 49 | } | ||
| 50 | } | ||
| 51 | |||
| 52 | function runtimeItemStatusFromAcpToolStatus( | ||
| 53 | status: AcpToolCallState["status"], | ||
| 54 | ): "inProgress" | "completed" | "failed" | undefined { | ||
| 55 | switch (status) { | ||
| 56 | case "pending": | ||
| 57 | case "inProgress": | ||
| 58 | return "inProgress"; | ||
| 59 | case "completed": | ||
| 60 | return "completed"; | ||
| 61 | case "failed": | ||
| 62 | return "failed"; | ||
| 63 | default: | ||
| 64 | return undefined; | ||
| 65 | } | ||
| 66 | } | ||
| 67 | |||
| 68 | export function makeAcpRequestOpenedEvent(input: { | ||
| 69 | readonly stamp: AcpEventStamp; | ||
| 70 | readonly provider: ProviderDriverKind; | ||
| 71 | readonly threadId: ThreadId; | ||
| 72 | readonly turnId: TurnId | undefined; | ||
| 73 | readonly requestId: RuntimeRequestId; | ||
| 74 | readonly permissionRequest: AcpPermissionRequest; | ||
| 75 | readonly approvalOptions?: ReadonlyArray<ProviderApprovalOption>; | ||
| 76 | readonly detail: string; | ||
| 77 | readonly args: unknown; | ||
| 78 | readonly source: AcpAdapterRawSource; | ||
| 79 | readonly method: string; | ||
| 80 | readonly rawPayload: unknown; | ||
| 81 | }): ProviderRuntimeEvent { | ||
| 82 | return { | ||
| 83 | type: "request.opened", | ||
| 84 | ...input.stamp, | ||
| 85 | provider: input.provider, | ||
| 86 | threadId: input.threadId, | ||
| 87 | turnId: input.turnId, | ||
| 88 | requestId: input.requestId, | ||
| 89 | payload: { | ||
| 90 | requestType: canonicalRequestTypeFromAcpKind(input.permissionRequest.kind), | ||
| 91 | detail: input.detail, | ||
| 92 | args: input.args, | ||
| 93 | ...(input.approvalOptions !== undefined ? { options: input.approvalOptions } : {}), | ||
| 94 | }, | ||
| 95 | raw: { | ||
| 96 | source: input.source, | ||
| 97 | method: input.method, | ||
| 98 | payload: input.rawPayload, | ||
| 99 | }, | ||
| 100 | }; | ||
| 101 | } | ||
| 102 | |||
| 103 | export function makeAcpRequestResolvedEvent(input: { | ||
| 104 | readonly stamp: AcpEventStamp; | ||
| 105 | readonly provider: ProviderDriverKind; | ||
| 106 | readonly threadId: ThreadId; | ||
| 107 | readonly turnId: TurnId | undefined; | ||
| 108 | readonly requestId: RuntimeRequestId; | ||
| 109 | readonly permissionRequest: AcpPermissionRequest; | ||
| 110 | readonly decision: ProviderApprovalDecision; | ||
| 111 | }): ProviderRuntimeEvent { | ||
| 112 | return { | ||
| 113 | type: "request.resolved", | ||
| 114 | ...input.stamp, | ||
| 115 | provider: input.provider, | ||
| 116 | threadId: input.threadId, | ||
| 117 | turnId: input.turnId, | ||
| 118 | requestId: input.requestId, | ||
| 119 | payload: { | ||
| 120 | requestType: canonicalRequestTypeFromAcpKind(input.permissionRequest.kind), | ||
| 121 | decision: input.decision, | ||
| 122 | }, | ||
| 123 | }; | ||
| 124 | } | ||
| 125 | |||
| 126 | export function makeAcpPlanUpdatedEvent(input: { | ||
| 127 | readonly stamp: AcpEventStamp; | ||
| 128 | readonly provider: ProviderDriverKind; | ||
| 129 | readonly threadId: ThreadId; | ||
| 130 | readonly turnId: TurnId | undefined; | ||
| 131 | readonly payload: AcpPlanUpdate; | ||
| 132 | readonly source: AcpAdapterRawSource; | ||
| 133 | readonly method: string; | ||
| 134 | readonly rawPayload: unknown; | ||
| 135 | }): ProviderRuntimeEvent { | ||
| 136 | const payload = | ||
| 137 | input.payload.kind === "items" | ||
| 138 | ? { | ||
| 139 | plan: input.payload.plan, | ||
| 140 | ...(input.payload.explanation == null ? {} : { explanation: input.payload.explanation }), | ||
| 141 | } | ||
| 142 | : input.payload.kind === "removed" | ||
| 143 | ? { plan: [] } | ||
| 144 | : { | ||
| 145 | plan: [ | ||
| 146 | { | ||
| 147 | step: | ||
| 148 | input.payload.kind === "markdown" | ||
| 149 | ? input.payload.markdown | ||
| 150 | : input.payload.kind === "file" | ||
| 151 | ? `Plan file: ${input.payload.uri}` | ||
| 152 | : `[Unsupported ACP plan content: ${input.payload.contentType}]`, | ||
| 153 | status: "pending" as const, | ||
| 154 | }, | ||
| 155 | ], | ||
| 156 | }; | ||
| 157 | return { | ||
| 158 | type: "turn.plan.updated", | ||
| 159 | ...input.stamp, | ||
| 160 | provider: input.provider, | ||
| 161 | threadId: input.threadId, | ||
| 162 | turnId: input.turnId, | ||
| 163 | payload, | ||
| 164 | raw: { | ||
| 165 | source: input.source, | ||
| 166 | method: input.method, | ||
| 167 | payload: input.rawPayload, | ||
| 168 | }, | ||
| 169 | }; | ||
| 170 | } | ||
| 171 | |||
| 172 | export function makeAcpToolCallEvent(input: { | ||
| 173 | readonly stamp: AcpEventStamp; | ||
| 174 | readonly provider: ProviderDriverKind; | ||
| 175 | readonly threadId: ThreadId; | ||
| 176 | readonly turnId: TurnId | undefined; | ||
| 177 | readonly toolCall: AcpToolCallState; | ||
| 178 | readonly rawPayload: unknown; | ||
| 179 | }): ProviderRuntimeEvent { | ||
| 180 | const runtimeStatus = runtimeItemStatusFromAcpToolStatus(input.toolCall.status); | ||
| 181 | return { | ||
| 182 | type: | ||
| 183 | input.toolCall.status === "completed" || input.toolCall.status === "failed" | ||
| 184 | ? "item.completed" | ||
| 185 | : "item.updated", | ||
| 186 | ...input.stamp, | ||
| 187 | provider: input.provider, | ||
| 188 | threadId: input.threadId, | ||
| 189 | turnId: input.turnId, | ||
| 190 | itemId: RuntimeItemId.make(input.toolCall.toolCallId), | ||
| 191 | payload: { | ||
| 192 | itemType: canonicalItemTypeFromAcpToolKind(input.toolCall.kind), | ||
| 193 | ...(runtimeStatus ? { status: runtimeStatus } : {}), | ||
| 194 | ...(input.toolCall.title ? { title: input.toolCall.title } : {}), | ||
| 195 | ...(input.toolCall.detail ? { detail: input.toolCall.detail } : {}), | ||
| 196 | ...(Object.keys(input.toolCall.data).length > 0 ? { data: input.toolCall.data } : {}), | ||
| 197 | }, | ||
| 198 | raw: { | ||
| 199 | source: "acp.jsonrpc", | ||
| 200 | method: "session/update", | ||
| 201 | payload: input.rawPayload, | ||
| 202 | }, | ||
| 203 | }; | ||
| 204 | } | ||
| 205 | |||
| 206 | export function makeAcpAssistantItemEvent(input: { | ||
| 207 | readonly stamp: AcpEventStamp; | ||
| 208 | readonly provider: ProviderDriverKind; | ||
| 209 | readonly threadId: ThreadId; | ||
| 210 | readonly turnId: TurnId | undefined; | ||
| 211 | readonly itemId: string; | ||
| 212 | readonly lifecycle: "item.started" | "item.completed"; | ||
| 213 | }): ProviderRuntimeEvent { | ||
| 214 | return { | ||
| 215 | type: input.lifecycle, | ||
| 216 | ...input.stamp, | ||
| 217 | provider: input.provider, | ||
| 218 | threadId: input.threadId, | ||
| 219 | turnId: input.turnId, | ||
| 220 | itemId: RuntimeItemId.make(input.itemId), | ||
| 221 | payload: { | ||
| 222 | itemType: "assistant_message", | ||
| 223 | status: input.lifecycle === "item.completed" ? "completed" : "inProgress", | ||
| 224 | }, | ||
| 225 | }; | ||
| 226 | } | ||
| 227 | |||
| 228 | export function makeAcpContentDeltaEvent(input: { | ||
| 229 | readonly stamp: AcpEventStamp; | ||
| 230 | readonly provider: ProviderDriverKind; | ||
| 231 | readonly threadId: ThreadId; | ||
| 232 | readonly turnId: TurnId | undefined; | ||
| 233 | readonly itemId?: string; | ||
| 234 | readonly streamKind?: "assistant_text" | "reasoning_text"; | ||
| 235 | readonly text: string; | ||
| 236 | readonly rawPayload: unknown; | ||
| 237 | }): ProviderRuntimeEvent { | ||
| 238 | return { | ||
| 239 | type: "content.delta", | ||
| 240 | ...input.stamp, | ||
| 241 | provider: input.provider, | ||
| 242 | threadId: input.threadId, | ||
| 243 | turnId: input.turnId, | ||
| 244 | ...(input.itemId ? { itemId: RuntimeItemId.make(input.itemId) } : {}), | ||
| 245 | payload: { | ||
| 246 | streamKind: input.streamKind ?? "assistant_text", | ||
| 247 | delta: input.text, | ||
| 248 | }, | ||
| 249 | raw: { | ||
| 250 | source: "acp.jsonrpc", | ||
| 251 | method: "session/update", | ||
| 252 | payload: input.rawPayload, | ||
| 253 | }, | ||
| 254 | }; | ||
| 255 | } | ||
packages/provider-acp/src/server/mcpOverAcpBridge.test.ts created+119| ... | @@ -0,0 +1,119 @@ | ||
| 1 | // The bridge intentionally treats MCP JSON-RPC messages as opaque JSON. | ||
| 2 | import { describe, expect, it } from "@effect/vitest"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Fiber from "effect/Fiber"; | ||
| 5 | |||
| 6 | import { makeAcpMcpOverAcpBridge } from "./mcpOverAcpBridge.ts"; | ||
| 7 | |||
| 8 | describe("AcpMcpOverAcpBridge", () => { | ||
| 9 | it.effect("forwards authenticated MCP requests and closes the negotiated session", () => | ||
| 10 | Effect.gen(function* () { | ||
| 11 | const requests: Array<{ | ||
| 12 | readonly method: string; | ||
| 13 | readonly headers: Headers; | ||
| 14 | readonly body: unknown; | ||
| 15 | }> = []; | ||
| 16 | const bridge = yield* makeAcpMcpOverAcpBridge({ | ||
| 17 | endpoint: "http://127.0.0.1:1/mcp", | ||
| 18 | authorization: "Bearer bridge-test", | ||
| 19 | allocateConnectionId: Effect.succeed("connection-1"), | ||
| 20 | fetchImplementation: async (_url, init) => { | ||
| 21 | const method = init?.method ?? "GET"; | ||
| 22 | const headers = new Headers(init?.headers); | ||
| 23 | const body = init?.body === undefined ? null : JSON.parse(String(init.body)); | ||
| 24 | requests.push({ method, headers, body }); | ||
| 25 | if (method === "DELETE") return new Response(null, { status: 204 }); | ||
| 26 | if (body === null || typeof body !== "object") return new Response(null, { status: 202 }); | ||
| 27 | const request = body as { readonly id?: unknown; readonly method?: unknown }; | ||
| 28 | if (request.method === "notifications/initialized") { | ||
| 29 | return new Response(null, { status: 202 }); | ||
| 30 | } | ||
| 31 | return new Response( | ||
| 32 | JSON.stringify({ | ||
| 33 | jsonrpc: "2.0", | ||
| 34 | id: request.id, | ||
| 35 | result: | ||
| 36 | request.method === "initialize" | ||
| 37 | ? { protocolVersion: "2025-06-18", capabilities: {}, serverInfo: {} } | ||
| 38 | : { tools: [] }, | ||
| 39 | }), | ||
| 40 | { | ||
| 41 | headers: { "content-type": "application/json", "mcp-session-id": "session-42" }, | ||
| 42 | }, | ||
| 43 | ); | ||
| 44 | }, | ||
| 45 | }); | ||
| 46 | |||
| 47 | const connected = yield* bridge.connect({ serverId: "t3-code" }); | ||
| 48 | expect(connected).toEqual({ connectionId: "connection-1" }); | ||
| 49 | expect( | ||
| 50 | yield* bridge.message({ connectionId: connected.connectionId, method: "initialize" }), | ||
| 51 | ).toMatchObject({ protocolVersion: "2025-06-18" }); | ||
| 52 | expect( | ||
| 53 | yield* bridge.message({ connectionId: connected.connectionId, method: "tools/list" }), | ||
| 54 | ).toEqual({ tools: [] }); | ||
| 55 | yield* bridge.notification({ | ||
| 56 | connectionId: connected.connectionId, | ||
| 57 | method: "notifications/initialized", | ||
| 58 | }); | ||
| 59 | yield* bridge.disconnect({ connectionId: connected.connectionId }); | ||
| 60 | |||
| 61 | expect(requests[0]?.headers.get("authorization")).toBe("Bearer bridge-test"); | ||
| 62 | expect(requests[1]?.headers.get("mcp-session-id")).toBe("session-42"); | ||
| 63 | expect(requests[1]?.headers.get("mcp-protocol-version")).toBe("2025-06-18"); | ||
| 64 | expect(requests.at(-1)?.method).toBe("DELETE"); | ||
| 65 | expect(requests.at(-1)?.headers.get("mcp-session-id")).toBe("session-42"); | ||
| 66 | }), | ||
| 67 | ); | ||
| 68 | |||
| 69 | it.effect("aborts an in-flight HTTP request when the ACP call is interrupted", () => | ||
| 70 | Effect.gen(function* () { | ||
| 71 | const started = Promise.withResolvers<AbortSignal>(); | ||
| 72 | const bridge = yield* makeAcpMcpOverAcpBridge({ | ||
| 73 | endpoint: "http://127.0.0.1:1/mcp", | ||
| 74 | authorization: "Bearer bridge-test", | ||
| 75 | allocateConnectionId: Effect.succeed("connection-1"), | ||
| 76 | fetchImplementation: (_url, init) => { | ||
| 77 | if (!init?.signal) throw new Error("Missing cancellation signal"); | ||
| 78 | started.resolve(init.signal); | ||
| 79 | return new Promise<Response>(() => {}); | ||
| 80 | }, | ||
| 81 | }); | ||
| 82 | const connected = yield* bridge.connect({ serverId: "t3-code" }); | ||
| 83 | const request = yield* bridge | ||
| 84 | .message({ connectionId: connected.connectionId, method: "tools/list" }) | ||
| 85 | .pipe(Effect.forkChild); | ||
| 86 | const signal = yield* Effect.promise(() => started.promise); | ||
| 87 | yield* Fiber.interrupt(request); | ||
| 88 | expect(signal.aborted).toBe(true); | ||
| 89 | }).pipe(Effect.scoped), | ||
| 90 | ); | ||
| 91 | |||
| 92 | it.effect("rejects unknown servers, connections, and oversized messages", () => | ||
| 93 | Effect.gen(function* () { | ||
| 94 | const bridge = yield* makeAcpMcpOverAcpBridge({ | ||
| 95 | endpoint: "http://127.0.0.1:1/mcp", | ||
| 96 | authorization: "Bearer bridge-test", | ||
| 97 | allocateConnectionId: Effect.succeed("connection-1"), | ||
| 98 | fetchImplementation: () => Promise.resolve(new Response(null, { status: 202 })), | ||
| 99 | }); | ||
| 100 | |||
| 101 | expect(yield* bridge.connect({ serverId: "other" }).pipe(Effect.flip)).toMatchObject({ | ||
| 102 | _tag: "AcpMcpOverAcpError", | ||
| 103 | }); | ||
| 104 | expect( | ||
| 105 | yield* bridge.message({ connectionId: "missing", method: "tools/list" }).pipe(Effect.flip), | ||
| 106 | ).toMatchObject({ _tag: "AcpMcpOverAcpError" }); | ||
| 107 | |||
| 108 | const connected = yield* bridge.connect({ serverId: "t3-code" }); | ||
| 109 | const failure = yield* bridge | ||
| 110 | .message({ | ||
| 111 | connectionId: connected.connectionId, | ||
| 112 | method: "tools/call", | ||
| 113 | params: { payload: "x".repeat(8 * 1024 * 1024) }, | ||
| 114 | }) | ||
| 115 | .pipe(Effect.flip); | ||
| 116 | expect(failure.message).toContain("exceeds 8 MiB"); | ||
| 117 | }), | ||
| 118 | ); | ||
| 119 | }); | ||
packages/provider-acp/src/server/mcpOverAcpBridge.ts created+241| ... | @@ -0,0 +1,241 @@ | ||
| 1 | // The bridge forwards opaque JSON-RPC values without interpreting their schemas. | ||
| 2 | // @effect-diagnostics preferSchemaOverJson:off | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Semaphore from "effect/Semaphore"; | ||
| 5 | import * as Stream from "effect/Stream"; | ||
| 6 | import type * as AcpSchema from "effect-acp/compat"; | ||
| 7 | |||
| 8 | import { responsePayloads } from "@t3tools/shared/mcpResponsePayloads"; | ||
| 9 | |||
| 10 | const MAX_CONNECTIONS = 16; | ||
| 11 | const MAX_MESSAGE_BYTES = 8 * 1024 * 1024; | ||
| 12 | |||
| 13 | interface JsonRpcEnvelope { | ||
| 14 | readonly id?: unknown; | ||
| 15 | readonly method?: unknown; | ||
| 16 | readonly error?: unknown; | ||
| 17 | readonly result?: unknown; | ||
| 18 | } | ||
| 19 | |||
| 20 | interface Connection { | ||
| 21 | readonly mutex: Semaphore.Semaphore; | ||
| 22 | sessionId: string | null; | ||
| 23 | protocolVersion: string | null; | ||
| 24 | nextRequestId: number; | ||
| 25 | } | ||
| 26 | |||
| 27 | export class AcpMcpOverAcpError extends Error { | ||
| 28 | readonly _tag = "AcpMcpOverAcpError"; | ||
| 29 | override readonly name = "AcpMcpOverAcpError"; | ||
| 30 | } | ||
| 31 | |||
| 32 | const bridgeError = (cause: unknown): AcpMcpOverAcpError => | ||
| 33 | cause instanceof AcpMcpOverAcpError | ||
| 34 | ? cause | ||
| 35 | : new AcpMcpOverAcpError(cause instanceof Error ? cause.message : String(cause), { cause }); | ||
| 36 | |||
| 37 | const asEnvelope = (value: unknown): JsonRpcEnvelope | null => | ||
| 38 | typeof value === "object" && value !== null && !Array.isArray(value) | ||
| 39 | ? (value as JsonRpcEnvelope) | ||
| 40 | : null; | ||
| 41 | |||
| 42 | function protocolVersionOf(payload: unknown): string | null { | ||
| 43 | const result = asEnvelope(payload)?.result; | ||
| 44 | if (typeof result !== "object" || result === null || Array.isArray(result)) return null; | ||
| 45 | const protocolVersion = (result as { readonly protocolVersion?: unknown }).protocolVersion; | ||
| 46 | return typeof protocolVersion === "string" && protocolVersion.length > 0 ? protocolVersion : null; | ||
| 47 | } | ||
| 48 | |||
| 49 | function errorMessage(error: unknown): string { | ||
| 50 | if (typeof error !== "object" || error === null || Array.isArray(error)) { | ||
| 51 | return "The MCP server returned an error."; | ||
| 52 | } | ||
| 53 | const message = (error as { readonly message?: unknown }).message; | ||
| 54 | return typeof message === "string" && message.length > 0 | ||
| 55 | ? message | ||
| 56 | : "The MCP server returned an error."; | ||
| 57 | } | ||
| 58 | |||
| 59 | export interface AcpMcpOverAcpBridgeOptions { | ||
| 60 | readonly endpoint: string; | ||
| 61 | readonly authorization: string; | ||
| 62 | readonly allocateConnectionId: Effect.Effect<string>; | ||
| 63 | readonly fetchImplementation?: (url: string, init?: RequestInit) => Promise<Response>; | ||
| 64 | } | ||
| 65 | |||
| 66 | export interface AcpMcpOverAcpBridge { | ||
| 67 | readonly connect: ( | ||
| 68 | request: AcpSchema.ConnectMcpRequest, | ||
| 69 | ) => Effect.Effect<AcpSchema.ConnectMcpResponse, AcpMcpOverAcpError>; | ||
| 70 | readonly message: ( | ||
| 71 | request: AcpSchema.MessageMcpRequest, | ||
| 72 | ) => Effect.Effect<AcpSchema.MessageMcpResponse, AcpMcpOverAcpError>; | ||
| 73 | readonly notification: ( | ||
| 74 | request: AcpSchema.MessageMcpNotification, | ||
| 75 | ) => Effect.Effect<void, AcpMcpOverAcpError>; | ||
| 76 | readonly disconnect: ( | ||
| 77 | request: AcpSchema.DisconnectMcpRequest, | ||
| 78 | ) => Effect.Effect<AcpSchema.DisconnectMcpResponse, AcpMcpOverAcpError>; | ||
| 79 | readonly dispose: Effect.Effect<void>; | ||
| 80 | } | ||
| 81 | |||
| 82 | /** Bridges the unstable ACP transport to T3's authenticated streamable-HTTP MCP endpoint. */ | ||
| 83 | export const makeAcpMcpOverAcpBridge = Effect.fn("AcpMcpOverAcpBridge.make")(function* ( | ||
| 84 | options: AcpMcpOverAcpBridgeOptions, | ||
| 85 | ): Effect.fn.Return<AcpMcpOverAcpBridge> { | ||
| 86 | const fetchImplementation = options.fetchImplementation ?? fetch; | ||
| 87 | const connections = new Map<string, Connection>(); | ||
| 88 | |||
| 89 | const connectionFor = (connectionId: string): Effect.Effect<Connection, AcpMcpOverAcpError> => | ||
| 90 | Effect.suspend(() => { | ||
| 91 | const connection = connections.get(connectionId); | ||
| 92 | return connection === undefined | ||
| 93 | ? Effect.fail(new AcpMcpOverAcpError(`Unknown MCP-over-ACP connection "${connectionId}".`)) | ||
| 94 | : Effect.succeed(connection); | ||
| 95 | }); | ||
| 96 | |||
| 97 | const send = ( | ||
| 98 | connection: Connection, | ||
| 99 | message: unknown, | ||
| 100 | ): Effect.Effect<ReadonlyArray<unknown>, AcpMcpOverAcpError> => | ||
| 101 | connection.mutex.withPermits(1)( | ||
| 102 | Effect.gen(function* () { | ||
| 103 | const body = JSON.stringify(message); | ||
| 104 | if (Buffer.byteLength(body) > MAX_MESSAGE_BYTES) { | ||
| 105 | return yield* Effect.fail(new AcpMcpOverAcpError("MCP-over-ACP message exceeds 8 MiB.")); | ||
| 106 | } | ||
| 107 | const response = yield* Effect.tryPromise({ | ||
| 108 | try: (signal) => | ||
| 109 | fetchImplementation(options.endpoint, { | ||
| 110 | method: "POST", | ||
| 111 | signal, | ||
| 112 | headers: { | ||
| 113 | "content-type": "application/json", | ||
| 114 | accept: "application/json, text/event-stream", | ||
| 115 | authorization: options.authorization, | ||
| 116 | ...(connection.sessionId === null | ||
| 117 | ? {} | ||
| 118 | : { "mcp-session-id": connection.sessionId }), | ||
| 119 | ...(connection.protocolVersion === null | ||
| 120 | ? {} | ||
| 121 | : { "mcp-protocol-version": connection.protocolVersion }), | ||
| 122 | }, | ||
| 123 | body, | ||
| 124 | }), | ||
| 125 | catch: bridgeError, | ||
| 126 | }); | ||
| 127 | connection.sessionId = response.headers.get("mcp-session-id") ?? connection.sessionId; | ||
| 128 | if (!response.ok) { | ||
| 129 | yield* Effect.promise( | ||
| 130 | () => response.body?.cancel().catch(() => undefined) ?? Promise.resolve(), | ||
| 131 | ); | ||
| 132 | return yield* Effect.fail( | ||
| 133 | new AcpMcpOverAcpError(`T3 Code MCP endpoint responded with HTTP ${response.status}.`), | ||
| 134 | ); | ||
| 135 | } | ||
| 136 | const payloads = [...(yield* Stream.runCollect(responsePayloads(response, bridgeError)))]; | ||
| 137 | for (const payload of payloads) { | ||
| 138 | connection.protocolVersion = protocolVersionOf(payload) ?? connection.protocolVersion; | ||
| 139 | } | ||
| 140 | return payloads; | ||
| 141 | }).pipe(Effect.mapError(bridgeError)), | ||
| 142 | ); | ||
| 143 | |||
| 144 | const disconnect = ( | ||
| 145 | request: AcpSchema.DisconnectMcpRequest, | ||
| 146 | ): Effect.Effect<AcpSchema.DisconnectMcpResponse, AcpMcpOverAcpError> => | ||
| 147 | Effect.gen(function* () { | ||
| 148 | const connection = yield* connectionFor(request.connectionId); | ||
| 149 | connections.delete(request.connectionId); | ||
| 150 | const sessionId = connection.sessionId; | ||
| 151 | if (sessionId !== null) { | ||
| 152 | const response = yield* Effect.tryPromise({ | ||
| 153 | try: (signal) => | ||
| 154 | fetchImplementation(options.endpoint, { | ||
| 155 | method: "DELETE", | ||
| 156 | signal, | ||
| 157 | headers: { | ||
| 158 | authorization: options.authorization, | ||
| 159 | "mcp-session-id": sessionId, | ||
| 160 | ...(connection.protocolVersion === null | ||
| 161 | ? {} | ||
| 162 | : { "mcp-protocol-version": connection.protocolVersion }), | ||
| 163 | }, | ||
| 164 | }), | ||
| 165 | catch: bridgeError, | ||
| 166 | }); | ||
| 167 | if (!response.ok && response.status !== 404) { | ||
| 168 | return yield* Effect.fail( | ||
| 169 | new AcpMcpOverAcpError( | ||
| 170 | `T3 Code MCP endpoint rejected disconnect with HTTP ${response.status}.`, | ||
| 171 | ), | ||
| 172 | ); | ||
| 173 | } | ||
| 174 | yield* Effect.promise( | ||
| 175 | () => response.body?.cancel().catch(() => undefined) ?? Promise.resolve(), | ||
| 176 | ); | ||
| 177 | } | ||
| 178 | return {}; | ||
| 179 | }); | ||
| 180 | |||
| 181 | return { | ||
| 182 | connect: (request) => | ||
| 183 | Effect.gen(function* () { | ||
| 184 | if (request.serverId !== "t3-code") { | ||
| 185 | return yield* Effect.fail( | ||
| 186 | new AcpMcpOverAcpError(`Unknown ACP MCP server "${request.serverId}".`), | ||
| 187 | ); | ||
| 188 | } | ||
| 189 | if (connections.size >= MAX_CONNECTIONS) { | ||
| 190 | return yield* Effect.fail(new AcpMcpOverAcpError("Too many MCP-over-ACP connections.")); | ||
| 191 | } | ||
| 192 | const connectionId = yield* options.allocateConnectionId; | ||
| 193 | connections.set(connectionId, { | ||
| 194 | mutex: yield* Semaphore.make(1), | ||
| 195 | sessionId: null, | ||
| 196 | protocolVersion: null, | ||
| 197 | nextRequestId: 0, | ||
| 198 | }); | ||
| 199 | return { connectionId }; | ||
| 200 | }), | ||
| 201 | message: (request) => | ||
| 202 | Effect.gen(function* () { | ||
| 203 | const connection = yield* connectionFor(request.connectionId); | ||
| 204 | const id = ++connection.nextRequestId; | ||
| 205 | const payloads = yield* send(connection, { | ||
| 206 | jsonrpc: "2.0", | ||
| 207 | id, | ||
| 208 | method: request.method, | ||
| 209 | ...(request.params == null ? {} : { params: request.params }), | ||
| 210 | }); | ||
| 211 | const response = payloads.find((payload) => asEnvelope(payload)?.id === id); | ||
| 212 | const envelope = asEnvelope(response); | ||
| 213 | if (envelope === null) { | ||
| 214 | return yield* Effect.fail( | ||
| 215 | new AcpMcpOverAcpError("MCP server did not return a matching response."), | ||
| 216 | ); | ||
| 217 | } | ||
| 218 | if (envelope.error !== undefined) { | ||
| 219 | return yield* Effect.fail(new AcpMcpOverAcpError(errorMessage(envelope.error))); | ||
| 220 | } | ||
| 221 | return (envelope.result ?? null) as AcpSchema.MessageMcpResponse; | ||
| 222 | }), | ||
| 223 | notification: (request) => | ||
| 224 | Effect.gen(function* () { | ||
| 225 | const connection = yield* connectionFor(request.connectionId); | ||
| 226 | yield* send(connection, { | ||
| 227 | jsonrpc: "2.0", | ||
| 228 | method: request.method, | ||
| 229 | ...(request.params == null ? {} : { params: request.params }), | ||
| 230 | }); | ||
| 231 | }), | ||
| 232 | disconnect, | ||
| 233 | dispose: Effect.suspend(() => | ||
| 234 | Effect.forEach( | ||
| 235 | [...connections.keys()], | ||
| 236 | (connectionId) => disconnect({ connectionId }).pipe(Effect.ignore), | ||
| 237 | { discard: true }, | ||
| 238 | ), | ||
| 239 | ), | ||
| 240 | }; | ||
| 241 | }); | ||
packages/provider-acp/src/server/nativeLogging.test.ts created+306| ... | @@ -0,0 +1,306 @@ | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { ProviderDriverKind, ThreadId } from "@t3tools/contracts"; | ||
| 3 | import { assert, it } from "@effect/vitest"; | ||
| 4 | import * as Cause from "effect/Cause"; | ||
| 5 | import * as Effect from "effect/Effect"; | ||
| 6 | import * as Exit from "effect/Exit"; | ||
| 7 | import * as Logger from "effect/Logger"; | ||
| 8 | import * as Schema from "effect/Schema"; | ||
| 9 | import * as AcpErrors from "effect-acp/errors"; | ||
| 10 | |||
| 11 | import type * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; | ||
| 12 | import { makeAcpNativeLoggerFactory } from "./nativeLogging.ts"; | ||
| 13 | |||
| 14 | const nodeServicesIt = it.layer(NodeServices.layer); | ||
| 15 | const encodeUnknownJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); | ||
| 16 | |||
| 17 | nodeServicesIt("ACP native logging", (it) => { | ||
| 18 | it.effect("records bounded request and protocol diagnostics without raw payloads", () => | ||
| 19 | Effect.gen(function* () { | ||
| 20 | const records: Array<unknown> = []; | ||
| 21 | const nativeEventLogger: ProviderEventLoggers.EventNdjsonLogger = { | ||
| 22 | filePath: "/tmp/provider-native.ndjson", | ||
| 23 | write: (event) => Effect.sync(() => void records.push(event)), | ||
| 24 | close: () => Effect.void, | ||
| 25 | }; | ||
| 26 | const makeLogger = yield* makeAcpNativeLoggerFactory(); | ||
| 27 | const logger = makeLogger({ | ||
| 28 | nativeEventLogger, | ||
| 29 | provider: ProviderDriverKind.make("cursor"), | ||
| 30 | threadId: ThreadId.make("thread-1"), | ||
| 31 | verboseProtocolLogging: true, | ||
| 32 | }); | ||
| 33 | const secret = "secret-token-value"; | ||
| 34 | const requestLogger = logger.requestLogger; | ||
| 35 | const protocolLogger = logger.protocolLogging?.logger; | ||
| 36 | assert.exists(requestLogger); | ||
| 37 | assert.exists(protocolLogger); | ||
| 38 | if (!requestLogger || !protocolLogger) return; | ||
| 39 | |||
| 40 | yield* requestLogger({ | ||
| 41 | method: "session/prompt", | ||
| 42 | payload: { prompt: secret, sessionId: secret }, | ||
| 43 | status: "failed", | ||
| 44 | cause: Cause.fail(AcpErrors.AcpRequestError.internalError(secret, { token: secret })), | ||
| 45 | }); | ||
| 46 | yield* protocolLogger({ | ||
| 47 | direction: "incoming", | ||
| 48 | stage: "raw", | ||
| 49 | payload: `{"token":"${secret}"}`, | ||
| 50 | }); | ||
| 51 | yield* protocolLogger({ | ||
| 52 | direction: "outgoing", | ||
| 53 | stage: "decoded", | ||
| 54 | payload: { | ||
| 55 | _tag: "Request", | ||
| 56 | tag: "session/prompt", | ||
| 57 | payload: { prompt: secret }, | ||
| 58 | }, | ||
| 59 | }); | ||
| 60 | |||
| 61 | const serialized = encodeUnknownJson(records); | ||
| 62 | assert.notInclude(serialized, secret); | ||
| 63 | assert.include(serialized, '"method":"session/prompt"'); | ||
| 64 | assert.include(serialized, '"errorTag":"AcpRequestError"'); | ||
| 65 | assert.include(serialized, '"reasonCount":1'); | ||
| 66 | assert.include(serialized, '"valueType":"string"'); | ||
| 67 | assert.include(serialized, '"messageTag":"Request"'); | ||
| 68 | }), | ||
| 69 | ); | ||
| 70 | |||
| 71 | it.effect("keeps request diagnostics without enabling full protocol logging", () => | ||
| 72 | Effect.gen(function* () { | ||
| 73 | const records: Array<unknown> = []; | ||
| 74 | const makeLogger = yield* makeAcpNativeLoggerFactory(); | ||
| 75 | const logger = makeLogger({ | ||
| 76 | nativeEventLogger: { | ||
| 77 | filePath: "/tmp/provider-native.ndjson", | ||
| 78 | write: (event) => Effect.sync(() => void records.push(event)), | ||
| 79 | close: () => Effect.void, | ||
| 80 | }, | ||
| 81 | provider: ProviderDriverKind.make("grok"), | ||
| 82 | threadId: ThreadId.make("thread-1"), | ||
| 83 | }); | ||
| 84 | |||
| 85 | assert.isUndefined(logger.protocolLogging); | ||
| 86 | const requestLogger = logger.requestLogger; | ||
| 87 | assert.exists(requestLogger); | ||
| 88 | if (!requestLogger) return; | ||
| 89 | yield* requestLogger({ | ||
| 90 | method: "session/prompt", | ||
| 91 | payload: {}, | ||
| 92 | status: "started", | ||
| 93 | }); | ||
| 94 | assert.lengthOf(records, 1); | ||
| 95 | }), | ||
| 96 | ); | ||
| 97 | |||
| 98 | it.effect("drops transient ACP chunks before formatting verbose protocol logs", () => | ||
| 99 | Effect.gen(function* () { | ||
| 100 | const records: Array<unknown> = []; | ||
| 101 | const makeLogger = yield* makeAcpNativeLoggerFactory(); | ||
| 102 | const logger = makeLogger({ | ||
| 103 | nativeEventLogger: { | ||
| 104 | filePath: "/tmp/provider-native.ndjson", | ||
| 105 | write: (event) => Effect.sync(() => void records.push(event)), | ||
| 106 | close: () => Effect.void, | ||
| 107 | }, | ||
| 108 | provider: ProviderDriverKind.make("cursor"), | ||
| 109 | threadId: ThreadId.make("thread-1"), | ||
| 110 | verboseProtocolLogging: true, | ||
| 111 | }); | ||
| 112 | const protocolLogger = logger.protocolLogging?.logger; | ||
| 113 | assert.exists(protocolLogger); | ||
| 114 | if (!protocolLogger) return; | ||
| 115 | |||
| 116 | for (const updateType of ["agent_message_chunk", "agent_thought_chunk"] as const) { | ||
| 117 | yield* protocolLogger({ | ||
| 118 | direction: "incoming", | ||
| 119 | stage: "raw", | ||
| 120 | payload: `${encodeUnknownJson({ | ||
| 121 | method: "session/update", | ||
| 122 | params: { update: { sessionUpdate: updateType } }, | ||
| 123 | })}\n`, | ||
| 124 | }); | ||
| 125 | yield* protocolLogger({ | ||
| 126 | direction: "incoming", | ||
| 127 | stage: "decoded", | ||
| 128 | payload: [ | ||
| 129 | { | ||
| 130 | _tag: "Request", | ||
| 131 | tag: "session/update", | ||
| 132 | payload: { update: { sessionUpdate: updateType } }, | ||
| 133 | }, | ||
| 134 | ], | ||
| 135 | }); | ||
| 136 | } | ||
| 137 | |||
| 138 | assert.lengthOf(records, 0); | ||
| 139 | |||
| 140 | yield* protocolLogger({ | ||
| 141 | direction: "incoming", | ||
| 142 | stage: "decoded", | ||
| 143 | payload: [ | ||
| 144 | { | ||
| 145 | _tag: "Request", | ||
| 146 | tag: "session/update", | ||
| 147 | payload: { update: { sessionUpdate: "tool_call" } }, | ||
| 148 | }, | ||
| 149 | ], | ||
| 150 | }); | ||
| 151 | assert.lengthOf(records, 1); | ||
| 152 | }), | ||
| 153 | ); | ||
| 154 | |||
| 155 | it.effect("keeps mixed and incomplete raw diagnostics", () => | ||
| 156 | Effect.gen(function* () { | ||
| 157 | const records: Array<unknown> = []; | ||
| 158 | const makeLogger = yield* makeAcpNativeLoggerFactory(); | ||
| 159 | const logger = makeLogger({ | ||
| 160 | nativeEventLogger: { | ||
| 161 | filePath: "/tmp/provider-native.ndjson", | ||
| 162 | write: (event) => Effect.sync(() => void records.push(event)), | ||
| 163 | close: () => Effect.void, | ||
| 164 | }, | ||
| 165 | provider: ProviderDriverKind.make("cursor"), | ||
| 166 | threadId: ThreadId.make("thread-1"), | ||
| 167 | verboseProtocolLogging: true, | ||
| 168 | }); | ||
| 169 | const protocolLogger = logger.protocolLogging?.logger; | ||
| 170 | assert.exists(protocolLogger); | ||
| 171 | if (!protocolLogger) return; | ||
| 172 | |||
| 173 | const transient = encodeUnknownJson({ | ||
| 174 | method: "session/update", | ||
| 175 | params: { update: { sessionUpdate: "agent_message_chunk" } }, | ||
| 176 | }); | ||
| 177 | const lifecycle = encodeUnknownJson({ method: "session/new", params: {} }); | ||
| 178 | |||
| 179 | yield* protocolLogger({ | ||
| 180 | direction: "incoming", | ||
| 181 | stage: "raw", | ||
| 182 | payload: `${transient}\n${lifecycle}\n`, | ||
| 183 | }); | ||
| 184 | yield* protocolLogger({ | ||
| 185 | direction: "incoming", | ||
| 186 | stage: "raw", | ||
| 187 | payload: transient, | ||
| 188 | }); | ||
| 189 | yield* protocolLogger({ | ||
| 190 | direction: "incoming", | ||
| 191 | stage: "raw", | ||
| 192 | payload: `${transient}\n{malformed}\n`, | ||
| 193 | }); | ||
| 194 | |||
| 195 | assert.lengthOf(records, 3); | ||
| 196 | }), | ||
| 197 | ); | ||
| 198 | |||
| 199 | it.effect("filters transient entries from mixed decoded batches", () => | ||
| 200 | Effect.gen(function* () { | ||
| 201 | const records: Array<unknown> = []; | ||
| 202 | const makeLogger = yield* makeAcpNativeLoggerFactory(); | ||
| 203 | const logger = makeLogger({ | ||
| 204 | nativeEventLogger: { | ||
| 205 | filePath: "/tmp/provider-native.ndjson", | ||
| 206 | write: (event) => Effect.sync(() => void records.push(event)), | ||
| 207 | close: () => Effect.void, | ||
| 208 | }, | ||
| 209 | provider: ProviderDriverKind.make("grok"), | ||
| 210 | threadId: ThreadId.make("thread-1"), | ||
| 211 | verboseProtocolLogging: true, | ||
| 212 | }); | ||
| 213 | const protocolLogger = logger.protocolLogging?.logger; | ||
| 214 | assert.exists(protocolLogger); | ||
| 215 | if (!protocolLogger) return; | ||
| 216 | |||
| 217 | yield* protocolLogger({ | ||
| 218 | direction: "incoming", | ||
| 219 | stage: "decoded", | ||
| 220 | payload: [ | ||
| 221 | { | ||
| 222 | _tag: "Request", | ||
| 223 | tag: "session/update", | ||
| 224 | payload: { update: { sessionUpdate: "agent_thought_chunk" } }, | ||
| 225 | }, | ||
| 226 | { | ||
| 227 | _tag: "Request", | ||
| 228 | tag: "session/new", | ||
| 229 | payload: {}, | ||
| 230 | }, | ||
| 231 | ], | ||
| 232 | }); | ||
| 233 | |||
| 234 | assert.lengthOf(records, 1); | ||
| 235 | assert.include(encodeUnknownJson(records), '"itemCount":1'); | ||
| 236 | }), | ||
| 237 | ); | ||
| 238 | |||
| 239 | it.effect("logs a structural tag when the native writer defects", () => { | ||
| 240 | const messages: Array<unknown> = []; | ||
| 241 | const logCapture = Logger.make<unknown, void>(({ message }) => { | ||
| 242 | if (Array.isArray(message)) { | ||
| 243 | messages.push(...message); | ||
| 244 | } else { | ||
| 245 | messages.push(message); | ||
| 246 | } | ||
| 247 | }); | ||
| 248 | const secret = "secret-writer-failure"; | ||
| 249 | |||
| 250 | return Effect.gen(function* () { | ||
| 251 | const makeLogger = yield* makeAcpNativeLoggerFactory(); | ||
| 252 | const logger = makeLogger({ | ||
| 253 | nativeEventLogger: { | ||
| 254 | filePath: "/tmp/provider-native.ndjson", | ||
| 255 | write: () => Effect.die(new Error(secret)), | ||
| 256 | close: () => Effect.void, | ||
| 257 | }, | ||
| 258 | provider: ProviderDriverKind.make("cursor"), | ||
| 259 | threadId: ThreadId.make("thread-1"), | ||
| 260 | }); | ||
| 261 | const requestLogger = logger.requestLogger; | ||
| 262 | assert.exists(requestLogger); | ||
| 263 | if (!requestLogger) return; | ||
| 264 | |||
| 265 | yield* requestLogger({ | ||
| 266 | method: "session/prompt", | ||
| 267 | payload: {}, | ||
| 268 | status: "started", | ||
| 269 | }); | ||
| 270 | |||
| 271 | const serialized = encodeUnknownJson(messages); | ||
| 272 | assert.notInclude(serialized, secret); | ||
| 273 | assert.include(serialized, '"errorTag":"Die"'); | ||
| 274 | assert.include(serialized, '"reasonCount":1'); | ||
| 275 | }).pipe(Effect.provide(Logger.layer([logCapture], { mergeWithExisting: false }))); | ||
| 276 | }); | ||
| 277 | |||
| 278 | it.effect("preserves native writer interruption", () => | ||
| 279 | Effect.gen(function* () { | ||
| 280 | const makeLogger = yield* makeAcpNativeLoggerFactory(); | ||
| 281 | const logger = makeLogger({ | ||
| 282 | nativeEventLogger: { | ||
| 283 | filePath: "/tmp/provider-native.ndjson", | ||
| 284 | write: () => Effect.interrupt, | ||
| 285 | close: () => Effect.void, | ||
| 286 | }, | ||
| 287 | provider: ProviderDriverKind.make("cursor"), | ||
| 288 | threadId: ThreadId.make("thread-1"), | ||
| 289 | }); | ||
| 290 | const requestLogger = logger.requestLogger; | ||
| 291 | assert.exists(requestLogger); | ||
| 292 | if (!requestLogger) return; | ||
| 293 | |||
| 294 | const exit = yield* requestLogger({ | ||
| 295 | method: "session/prompt", | ||
| 296 | payload: {}, | ||
| 297 | status: "started", | ||
| 298 | }).pipe(Effect.exit); | ||
| 299 | |||
| 300 | assert.isTrue(Exit.isFailure(exit)); | ||
| 301 | if (Exit.isFailure(exit)) { | ||
| 302 | assert.isTrue(Cause.hasInterruptsOnly(exit.cause)); | ||
| 303 | } | ||
| 304 | }), | ||
| 305 | ); | ||
| 306 | }); | ||
packages/provider-acp/src/server/nativeLogging.ts created+188| ... | @@ -0,0 +1,188 @@ | ||
| 1 | import type { ProviderDriverKind, ThreadId } from "@t3tools/contracts"; | ||
| 2 | import { causeErrorTag, errorTag } from "@t3tools/shared/observability"; | ||
| 3 | import * as Cause from "effect/Cause"; | ||
| 4 | import * as Crypto from "effect/Crypto"; | ||
| 5 | import * as DateTime from "effect/DateTime"; | ||
| 6 | import * as Effect from "effect/Effect"; | ||
| 7 | import type * as EffectAcpProtocol from "effect-acp/protocol"; | ||
| 8 | |||
| 9 | import type * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; | ||
| 10 | import { | ||
| 11 | structuralProtocolMethod, | ||
| 12 | summarizeNativeProtocolPayload, | ||
| 13 | } from "@t3tools/provider-core/server/nativeProtocolLogging"; | ||
| 14 | import type * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; | ||
| 15 | |||
| 16 | const transientProtocolUpdates = new Set(["agent_message_chunk", "agent_thought_chunk"]); | ||
| 17 | |||
| 18 | function structuralMethod(value: string): string { | ||
| 19 | return value.length <= 128 && /^[A-Za-z][A-Za-z0-9._:/-]*$/.test(value) ? value : "unknown"; | ||
| 20 | } | ||
| 21 | |||
| 22 | function summarizePayload(payload: unknown): Readonly<Record<string, unknown>> { | ||
| 23 | if (payload === null) return { valueType: "null" }; | ||
| 24 | if (typeof payload === "string") { | ||
| 25 | return { valueType: "string", byteLength: new TextEncoder().encode(payload).byteLength }; | ||
| 26 | } | ||
| 27 | if (payload instanceof Uint8Array) { | ||
| 28 | return { valueType: "bytes", byteLength: payload.byteLength }; | ||
| 29 | } | ||
| 30 | if (Array.isArray(payload)) { | ||
| 31 | return { valueType: "array", itemCount: payload.length }; | ||
| 32 | } | ||
| 33 | if (typeof payload !== "object") { | ||
| 34 | return { valueType: typeof payload }; | ||
| 35 | } | ||
| 36 | |||
| 37 | try { | ||
| 38 | const record = payload as Record<string, unknown>; | ||
| 39 | return { | ||
| 40 | valueType: "object", | ||
| 41 | fieldCount: Object.keys(record).length, | ||
| 42 | ...(typeof record._tag === "string" ? { messageTag: errorTag(record) } : {}), | ||
| 43 | ...(typeof record.tag === "string" ? { method: structuralMethod(record.tag) } : {}), | ||
| 44 | }; | ||
| 45 | } catch { | ||
| 46 | return { valueType: "object" }; | ||
| 47 | } | ||
| 48 | } | ||
| 49 | |||
| 50 | function formatRequestLogPayload(event: AcpSessionRuntime.AcpSessionRequestLogEvent) { | ||
| 51 | return { | ||
| 52 | method: structuralProtocolMethod(event.method), | ||
| 53 | status: event.status, | ||
| 54 | request: summarizeNativeProtocolPayload(event.payload), | ||
| 55 | ...(event.result !== undefined ? { result: summarizeNativeProtocolPayload(event.result) } : {}), | ||
| 56 | ...(event.cause !== undefined | ||
| 57 | ? { | ||
| 58 | errorTag: causeErrorTag(event.cause), | ||
| 59 | reasonCount: event.cause.reasons.length, | ||
| 60 | } | ||
| 61 | : {}), | ||
| 62 | }; | ||
| 63 | } | ||
| 64 | |||
| 65 | function formatAcpProtocolLogPayload(event: EffectAcpProtocol.AcpProtocolLogEvent) { | ||
| 66 | return { | ||
| 67 | direction: event.direction, | ||
| 68 | stage: event.stage, | ||
| 69 | payload: summarizeNativeProtocolPayload(event.payload), | ||
| 70 | }; | ||
| 71 | } | ||
| 72 | |||
| 73 | function isTransientProtocolMessage(message: unknown): boolean { | ||
| 74 | if (typeof message !== "object" || message === null) return false; | ||
| 75 | const method = Reflect.get(message, "tag") ?? Reflect.get(message, "method"); | ||
| 76 | if (method !== "session/update") return false; | ||
| 77 | |||
| 78 | const payload = Reflect.get(message, "payload") ?? Reflect.get(message, "params"); | ||
| 79 | if (typeof payload !== "object" || payload === null) return false; | ||
| 80 | const update = Reflect.get(payload, "update"); | ||
| 81 | if (typeof update !== "object" || update === null) return false; | ||
| 82 | const updateType = Reflect.get(update, "sessionUpdate"); | ||
| 83 | return typeof updateType === "string" && transientProtocolUpdates.has(updateType); | ||
| 84 | } | ||
| 85 | |||
| 86 | function rawChunkContainsOnlyTransientMessages(payload: string): boolean { | ||
| 87 | const lines = payload.split("\n"); | ||
| 88 | const remainder = lines.pop() ?? ""; | ||
| 89 | if (remainder.trim().length > 0) return false; | ||
| 90 | |||
| 91 | const messages: Array<unknown> = []; | ||
| 92 | for (const line of lines) { | ||
| 93 | if (line.trim().length === 0) continue; | ||
| 94 | try { | ||
| 95 | messages.push(JSON.parse(line)); | ||
| 96 | } catch { | ||
| 97 | return false; | ||
| 98 | } | ||
| 99 | } | ||
| 100 | return messages.length > 0 && messages.every(isTransientProtocolMessage); | ||
| 101 | } | ||
| 102 | |||
| 103 | function filterTransientProtocolLog( | ||
| 104 | event: EffectAcpProtocol.AcpProtocolLogEvent, | ||
| 105 | ): EffectAcpProtocol.AcpProtocolLogEvent | undefined { | ||
| 106 | if (event.direction !== "incoming") return event; | ||
| 107 | |||
| 108 | if (event.stage === "raw" && typeof event.payload === "string") { | ||
| 109 | return rawChunkContainsOnlyTransientMessages(event.payload) ? undefined : event; | ||
| 110 | } | ||
| 111 | |||
| 112 | if (event.stage !== "decoded") return event; | ||
| 113 | if (!Array.isArray(event.payload)) { | ||
| 114 | return isTransientProtocolMessage(event.payload) ? undefined : event; | ||
| 115 | } | ||
| 116 | |||
| 117 | const payload = event.payload.filter((message) => !isTransientProtocolMessage(message)); | ||
| 118 | return payload.length === 0 ? undefined : { ...event, payload }; | ||
| 119 | } | ||
| 120 | |||
| 121 | export const makeAcpNativeLoggerFactory = Effect.fn("makeAcpNativeLoggerFactory")(function* () { | ||
| 122 | const crypto = yield* Crypto.Crypto; | ||
| 123 | return (input: { | ||
| 124 | readonly nativeEventLogger: ProviderEventLoggers.EventNdjsonLogger | undefined; | ||
| 125 | readonly provider: ProviderDriverKind; | ||
| 126 | readonly threadId: ThreadId; | ||
| 127 | readonly verboseProtocolLogging?: boolean; | ||
| 128 | }): Pick<AcpSessionRuntime.AcpSessionRuntimeOptions, "requestLogger" | "protocolLogging"> => { | ||
| 129 | const writeNativeAcpLog = (logInput: { | ||
| 130 | readonly kind: "request" | "protocol"; | ||
| 131 | readonly payload: unknown; | ||
| 132 | }) => | ||
| 133 | Effect.gen(function* () { | ||
| 134 | if (!input.nativeEventLogger) return; | ||
| 135 | const observedAt = DateTime.formatIso(yield* DateTime.now); | ||
| 136 | yield* input.nativeEventLogger.write( | ||
| 137 | { | ||
| 138 | observedAt, | ||
| 139 | event: { | ||
| 140 | id: yield* crypto.randomUUIDv4, | ||
| 141 | kind: logInput.kind, | ||
| 142 | provider: input.provider, | ||
| 143 | createdAt: observedAt, | ||
| 144 | threadId: input.threadId, | ||
| 145 | payload: logInput.payload, | ||
| 146 | }, | ||
| 147 | }, | ||
| 148 | input.threadId, | ||
| 149 | ); | ||
| 150 | }).pipe( | ||
| 151 | Effect.catchCause((cause) => | ||
| 152 | Cause.hasInterrupts(cause) | ||
| 153 | ? Effect.interrupt | ||
| 154 | : Effect.logWarning("Failed to write native ACP event log.", { | ||
| 155 | errorTag: causeErrorTag(cause), | ||
| 156 | reasonCount: cause.reasons.length, | ||
| 157 | provider: input.provider, | ||
| 158 | threadId: input.threadId, | ||
| 159 | }), | ||
| 160 | ), | ||
| 161 | ); | ||
| 162 | |||
| 163 | return { | ||
| 164 | requestLogger: (event: AcpSessionRuntime.AcpSessionRequestLogEvent) => | ||
| 165 | writeNativeAcpLog({ | ||
| 166 | kind: "request", | ||
| 167 | payload: formatRequestLogPayload(event), | ||
| 168 | }), | ||
| 169 | ...(input.nativeEventLogger && input.verboseProtocolLogging | ||
| 170 | ? { | ||
| 171 | protocolLogging: { | ||
| 172 | logIncoming: true, | ||
| 173 | logOutgoing: true, | ||
| 174 | logger: (event: EffectAcpProtocol.AcpProtocolLogEvent) => { | ||
| 175 | const filtered = filterTransientProtocolLog(event); | ||
| 176 | return filtered | ||
| 177 | ? writeNativeAcpLog({ | ||
| 178 | kind: "protocol", | ||
| 179 | payload: formatAcpProtocolLogPayload(filtered), | ||
| 180 | }) | ||
| 181 | : Effect.void; | ||
| 182 | }, | ||
| 183 | } satisfies NonNullable<AcpSessionRuntime.AcpSessionRuntimeOptions["protocolLogging"]>, | ||
| 184 | } | ||
| 185 | : {}), | ||
| 186 | }; | ||
| 187 | }; | ||
| 188 | }); | ||
packages/provider-acp/src/server/runtimeModel.test.ts created+1724| ... | @@ -0,0 +1,1724 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import type * as EffectAcpSchema from "effect-acp/compat"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | decideToolCallUpdateEmission, | ||
| 7 | applyAcpAgentTerminalUpdate, | ||
| 8 | acpContentBlockDisplayText, | ||
| 9 | embeddedTerminalIdsFromSessionUpdate, | ||
| 10 | extractMcpToolCallIdentity, | ||
| 11 | extractModelConfigId, | ||
| 12 | mergeToolCallState, | ||
| 13 | parsePermissionRequest, | ||
| 14 | parseSessionModeState, | ||
| 15 | parseSessionUpdateEvent, | ||
| 16 | sessionModelStateFromInitialize, | ||
| 17 | sessionUpdateCountsAsLoadReplayActivity, | ||
| 18 | sessionUpdateIsReplay, | ||
| 19 | syntheticLoadSessionResponseFromInitialize, | ||
| 20 | toolCallProgressLength, | ||
| 21 | type AcpToolCallState, | ||
| 22 | } from "./runtimeModel.ts"; | ||
| 23 | |||
| 24 | describe("AcpRuntimeModel", () => { | ||
| 25 | it("parses session mode state from typed ACP session setup responses", () => { | ||
| 26 | const modeState = parseSessionModeState({ | ||
| 27 | sessionId: "session-1", | ||
| 28 | modes: { | ||
| 29 | currentModeId: " code ", | ||
| 30 | availableModes: [ | ||
| 31 | { id: " ask ", name: " Ask ", description: " Request approval " }, | ||
| 32 | { id: " code ", name: " Code " }, | ||
| 33 | ], | ||
| 34 | }, | ||
| 35 | configOptions: [], | ||
| 36 | } satisfies EffectAcpSchema.NewSessionResponse); | ||
| 37 | |||
| 38 | expect(modeState).toEqual({ | ||
| 39 | currentModeId: "code", | ||
| 40 | availableModes: [ | ||
| 41 | { id: "ask", name: "Ask", description: "Request approval" }, | ||
| 42 | { id: "code", name: "Code" }, | ||
| 43 | ], | ||
| 44 | }); | ||
| 45 | }); | ||
| 46 | |||
| 47 | it("extracts the model config id from typed ACP config options", () => { | ||
| 48 | const modelConfigId = extractModelConfigId({ | ||
| 49 | sessionId: "session-1", | ||
| 50 | configOptions: [ | ||
| 51 | { | ||
| 52 | id: "approval", | ||
| 53 | name: "Approval Mode", | ||
| 54 | category: "permission", | ||
| 55 | type: "select", | ||
| 56 | currentValue: "ask", | ||
| 57 | options: [{ value: "ask", name: "Ask" }], | ||
| 58 | }, | ||
| 59 | { | ||
| 60 | id: "model", | ||
| 61 | name: "Model", | ||
| 62 | category: "model", | ||
| 63 | type: "select", | ||
| 64 | currentValue: "default", | ||
| 65 | options: [{ value: "default", name: "Auto" }], | ||
| 66 | }, | ||
| 67 | ], | ||
| 68 | } satisfies EffectAcpSchema.NewSessionResponse); | ||
| 69 | |||
| 70 | expect(modelConfigId).toBe("model"); | ||
| 71 | }); | ||
| 72 | |||
| 73 | it("detects Grok session replay updates from _meta.isReplay", () => { | ||
| 74 | expect( | ||
| 75 | sessionUpdateIsReplay({ | ||
| 76 | _meta: { isReplay: true }, | ||
| 77 | sessionId: "session-1", | ||
| 78 | update: { | ||
| 79 | sessionUpdate: "agent_message_chunk", | ||
| 80 | content: { type: "text", text: "replayed" }, | ||
| 81 | }, | ||
| 82 | } satisfies EffectAcpSchema.SessionNotification), | ||
| 83 | ).toBe(true); | ||
| 84 | expect( | ||
| 85 | sessionUpdateIsReplay({ | ||
| 86 | sessionId: "session-1", | ||
| 87 | update: { | ||
| 88 | sessionUpdate: "agent_message_chunk", | ||
| 89 | content: { type: "text", text: "live" }, | ||
| 90 | }, | ||
| 91 | } satisfies EffectAcpSchema.SessionNotification), | ||
| 92 | ).toBe(false); | ||
| 93 | }); | ||
| 94 | |||
| 95 | it("ignores Grok keepalive chunks when tracking session/load replay activity", () => { | ||
| 96 | expect( | ||
| 97 | sessionUpdateCountsAsLoadReplayActivity({ | ||
| 98 | sessionId: "session-1", | ||
| 99 | update: { | ||
| 100 | sessionUpdate: "agent_message_chunk", | ||
| 101 | content: { type: "text", text: "" }, | ||
| 102 | }, | ||
| 103 | } satisfies EffectAcpSchema.SessionNotification), | ||
| 104 | ).toBe(false); | ||
| 105 | expect( | ||
| 106 | sessionUpdateCountsAsLoadReplayActivity({ | ||
| 107 | _meta: { isReplay: true }, | ||
| 108 | sessionId: "session-1", | ||
| 109 | update: { | ||
| 110 | sessionUpdate: "tool_call", | ||
| 111 | toolCallId: "replay-tool", | ||
| 112 | title: "Replay", | ||
| 113 | kind: "search", | ||
| 114 | status: "completed", | ||
| 115 | }, | ||
| 116 | } satisfies EffectAcpSchema.SessionNotification), | ||
| 117 | ).toBe(true); | ||
| 118 | }); | ||
| 119 | |||
| 120 | it("ignores load-replay activity from other sessions while a load gate is active", () => { | ||
| 121 | expect( | ||
| 122 | sessionUpdateCountsAsLoadReplayActivity( | ||
| 123 | { | ||
| 124 | sessionId: "session-other", | ||
| 125 | update: { | ||
| 126 | sessionUpdate: "agent_message_chunk", | ||
| 127 | content: { type: "text", text: "unrelated" }, | ||
| 128 | }, | ||
| 129 | } satisfies EffectAcpSchema.SessionNotification, | ||
| 130 | "session-loading", | ||
| 131 | ), | ||
| 132 | ).toBe(false); | ||
| 133 | expect( | ||
| 134 | sessionUpdateCountsAsLoadReplayActivity( | ||
| 135 | { | ||
| 136 | sessionId: "session-loading", | ||
| 137 | update: { | ||
| 138 | sessionUpdate: "agent_message_chunk", | ||
| 139 | content: { type: "text", text: "replay" }, | ||
| 140 | }, | ||
| 141 | } satisfies EffectAcpSchema.SessionNotification, | ||
| 142 | "session-loading", | ||
| 143 | ), | ||
| 144 | ).toBe(true); | ||
| 145 | }); | ||
| 146 | |||
| 147 | it("counts mode/config/session/usage updates as load-replay activity", () => { | ||
| 148 | expect( | ||
| 149 | sessionUpdateCountsAsLoadReplayActivity({ | ||
| 150 | sessionId: "session-1", | ||
| 151 | update: { | ||
| 152 | sessionUpdate: "current_mode_update", | ||
| 153 | currentModeId: "code", | ||
| 154 | }, | ||
| 155 | } satisfies EffectAcpSchema.SessionNotification), | ||
| 156 | ).toBe(true); | ||
| 157 | expect( | ||
| 158 | sessionUpdateCountsAsLoadReplayActivity({ | ||
| 159 | sessionId: "session-1", | ||
| 160 | update: { | ||
| 161 | sessionUpdate: "config_option_update", | ||
| 162 | configOptions: [], | ||
| 163 | }, | ||
| 164 | } satisfies EffectAcpSchema.SessionNotification), | ||
| 165 | ).toBe(true); | ||
| 166 | expect( | ||
| 167 | sessionUpdateCountsAsLoadReplayActivity({ | ||
| 168 | sessionId: "session-1", | ||
| 169 | update: { | ||
| 170 | sessionUpdate: "session_info_update", | ||
| 171 | title: "Restored", | ||
| 172 | }, | ||
| 173 | } satisfies EffectAcpSchema.SessionNotification), | ||
| 174 | ).toBe(true); | ||
| 175 | expect( | ||
| 176 | sessionUpdateCountsAsLoadReplayActivity({ | ||
| 177 | sessionId: "session-1", | ||
| 178 | update: { | ||
| 179 | sessionUpdate: "usage_update", | ||
| 180 | used: 1, | ||
| 181 | size: 10, | ||
| 182 | }, | ||
| 183 | } satisfies EffectAcpSchema.SessionNotification), | ||
| 184 | ).toBe(true); | ||
| 185 | }); | ||
| 186 | |||
| 187 | it("ignores malformed initialize mode state in synthetic load responses", () => { | ||
| 188 | const response = syntheticLoadSessionResponseFromInitialize({ | ||
| 189 | protocolVersion: 1, | ||
| 190 | _meta: { | ||
| 191 | modeState: { | ||
| 192 | currentModeId: "code", | ||
| 193 | availableModes: [{ id: "code", name: 12 }], | ||
| 194 | }, | ||
| 195 | }, | ||
| 196 | } as EffectAcpSchema.InitializeResponse); | ||
| 197 | |||
| 198 | expect(response.modes).toBeUndefined(); | ||
| 199 | expect(response._meta).toMatchObject({ t3SessionLoadReady: "replay_idle" }); | ||
| 200 | }); | ||
| 201 | |||
| 202 | it("builds a synthetic load response with initialize mode state", () => { | ||
| 203 | const response = syntheticLoadSessionResponseFromInitialize({ | ||
| 204 | protocolVersion: 1, | ||
| 205 | _meta: { | ||
| 206 | modeState: { | ||
| 207 | currentModeId: "code", | ||
| 208 | availableModes: [ | ||
| 209 | { id: "ask", name: "Ask" }, | ||
| 210 | { id: "code", name: "Code" }, | ||
| 211 | ], | ||
| 212 | }, | ||
| 213 | }, | ||
| 214 | } satisfies EffectAcpSchema.InitializeResponse); | ||
| 215 | |||
| 216 | expect(response.modes?.currentModeId).toBe("code"); | ||
| 217 | expect(response.modes?.availableModes).toHaveLength(2); | ||
| 218 | }); | ||
| 219 | |||
| 220 | it("keeps initialize mode state whose modes have null descriptions", () => { | ||
| 221 | const response = syntheticLoadSessionResponseFromInitialize({ | ||
| 222 | protocolVersion: 1, | ||
| 223 | _meta: { | ||
| 224 | modeState: { | ||
| 225 | currentModeId: "code", | ||
| 226 | availableModes: [ | ||
| 227 | { id: "ask", name: "Ask", description: null }, | ||
| 228 | { id: "code", name: "Code", description: "Edit files", _meta: null }, | ||
| 229 | ], | ||
| 230 | _meta: null, | ||
| 231 | }, | ||
| 232 | }, | ||
| 233 | } satisfies EffectAcpSchema.InitializeResponse); | ||
| 234 | |||
| 235 | expect(parseSessionModeState(response)).toEqual({ | ||
| 236 | currentModeId: "code", | ||
| 237 | availableModes: [ | ||
| 238 | { id: "ask", name: "Ask" }, | ||
| 239 | { id: "code", name: "Code", description: "Edit files" }, | ||
| 240 | ], | ||
| 241 | }); | ||
| 242 | }); | ||
| 243 | |||
| 244 | it("reads initialize model state and ignores malformed model state", () => { | ||
| 245 | const modelState = { | ||
| 246 | currentModelId: "grok-4.7", | ||
| 247 | availableModels: [ | ||
| 248 | { | ||
| 249 | modelId: "grok-4.7", | ||
| 250 | name: "Grok 4.7", | ||
| 251 | description: null, | ||
| 252 | _meta: { reasoningEfforts: [{ id: "high", default: true }] }, | ||
| 253 | }, | ||
| 254 | { modelId: "grok-4.6", name: "Grok 4.6" }, | ||
| 255 | ], | ||
| 256 | }; | ||
| 257 | |||
| 258 | expect(sessionModelStateFromInitialize({ protocolVersion: 1, _meta: { modelState } })).toEqual( | ||
| 259 | modelState, | ||
| 260 | ); | ||
| 261 | expect( | ||
| 262 | sessionModelStateFromInitialize({ | ||
| 263 | protocolVersion: 1, | ||
| 264 | _meta: { | ||
| 265 | modelState: { currentModelId: "grok-4.7", availableModels: [{ modelId: "grok-4.7" }] }, | ||
| 266 | }, | ||
| 267 | }), | ||
| 268 | ).toBeUndefined(); | ||
| 269 | expect( | ||
| 270 | sessionModelStateFromInitialize({ | ||
| 271 | protocolVersion: 1, | ||
| 272 | _meta: { modelState: { ...modelState, _meta: "not an object" } }, | ||
| 273 | }), | ||
| 274 | ).toBeUndefined(); | ||
| 275 | }); | ||
| 276 | |||
| 277 | it("projects typed ACP tool call updates into runtime events", () => { | ||
| 278 | const created = parseSessionUpdateEvent({ | ||
| 279 | sessionId: "session-1", | ||
| 280 | update: { | ||
| 281 | sessionUpdate: "tool_call", | ||
| 282 | toolCallId: "tool-1", | ||
| 283 | title: "Terminal", | ||
| 284 | kind: "execute", | ||
| 285 | status: "pending", | ||
| 286 | rawInput: { | ||
| 287 | executable: "bun", | ||
| 288 | args: ["run", "typecheck"], | ||
| 289 | }, | ||
| 290 | content: [ | ||
| 291 | { | ||
| 292 | type: "content", | ||
| 293 | content: { | ||
| 294 | type: "text", | ||
| 295 | text: "Running checks", | ||
| 296 | }, | ||
| 297 | }, | ||
| 298 | ], | ||
| 299 | }, | ||
| 300 | } satisfies EffectAcpSchema.SessionNotification); | ||
| 301 | |||
| 302 | expect(created.events).toEqual([ | ||
| 303 | { | ||
| 304 | _tag: "ToolCallUpdated", | ||
| 305 | toolCall: { | ||
| 306 | toolCallId: "tool-1", | ||
| 307 | kind: "execute", | ||
| 308 | title: "Ran command", | ||
| 309 | status: "pending", | ||
| 310 | command: "bun run typecheck", | ||
| 311 | detail: "bun run typecheck", | ||
| 312 | data: { | ||
| 313 | toolCallId: "tool-1", | ||
| 314 | kind: "execute", | ||
| 315 | title: "Terminal", | ||
| 316 | command: "bun run typecheck", | ||
| 317 | rawInput: { | ||
| 318 | executable: "bun", | ||
| 319 | args: ["run", "typecheck"], | ||
| 320 | }, | ||
| 321 | content: [ | ||
| 322 | { | ||
| 323 | type: "content", | ||
| 324 | content: { | ||
| 325 | type: "text", | ||
| 326 | text: "Running checks", | ||
| 327 | }, | ||
| 328 | }, | ||
| 329 | ], | ||
| 330 | }, | ||
| 331 | }, | ||
| 332 | rawPayload: { | ||
| 333 | sessionId: "session-1", | ||
| 334 | update: { | ||
| 335 | sessionUpdate: "tool_call", | ||
| 336 | toolCallId: "tool-1", | ||
| 337 | title: "Terminal", | ||
| 338 | kind: "execute", | ||
| 339 | status: "pending", | ||
| 340 | rawInput: { | ||
| 341 | executable: "bun", | ||
| 342 | args: ["run", "typecheck"], | ||
| 343 | }, | ||
| 344 | content: [ | ||
| 345 | { | ||
| 346 | type: "content", | ||
| 347 | content: { | ||
| 348 | type: "text", | ||
| 349 | text: "Running checks", | ||
| 350 | }, | ||
| 351 | }, | ||
| 352 | ], | ||
| 353 | }, | ||
| 354 | }, | ||
| 355 | }, | ||
| 356 | ]); | ||
| 357 | |||
| 358 | const updated = parseSessionUpdateEvent({ | ||
| 359 | sessionId: "session-1", | ||
| 360 | update: { | ||
| 361 | sessionUpdate: "tool_call_update", | ||
| 362 | toolCallId: "tool-1", | ||
| 363 | status: "completed", | ||
| 364 | rawOutput: { exitCode: 0 }, | ||
| 365 | }, | ||
| 366 | } satisfies EffectAcpSchema.SessionNotification); | ||
| 367 | |||
| 368 | expect(updated.events).toHaveLength(1); | ||
| 369 | expect(updated.events[0]?._tag).toBe("ToolCallUpdated"); | ||
| 370 | const createdEvent = created.events[0]; | ||
| 371 | const updatedEvent = updated.events[0]; | ||
| 372 | if (createdEvent?._tag === "ToolCallUpdated" && updatedEvent?._tag === "ToolCallUpdated") { | ||
| 373 | expect(mergeToolCallState(createdEvent.toolCall, updatedEvent.toolCall)).toMatchObject({ | ||
| 374 | toolCallId: "tool-1", | ||
| 375 | status: "completed", | ||
| 376 | title: "Ran command", | ||
| 377 | detail: "bun run typecheck", | ||
| 378 | command: "bun run typecheck", | ||
| 379 | }); | ||
| 380 | } | ||
| 381 | }); | ||
| 382 | |||
| 383 | it("clears prior ACP locations when an update explicitly replaces them", () => { | ||
| 384 | const toolCall = (locations?: EffectAcpSchema.ToolCallLocation[] | null) => { | ||
| 385 | const parsed = parseSessionUpdateEvent({ | ||
| 386 | sessionId: "session-1", | ||
| 387 | update: { | ||
| 388 | sessionUpdate: "tool_call_update", | ||
| 389 | toolCallId: "read-1", | ||
| 390 | kind: "read", | ||
| 391 | ...(locations === undefined ? {} : { locations }), | ||
| 392 | }, | ||
| 393 | } satisfies EffectAcpSchema.SessionNotification); | ||
| 394 | const event = parsed.events.find((candidate) => candidate._tag === "ToolCallUpdated"); | ||
| 395 | if (!event || event._tag !== "ToolCallUpdated") { | ||
| 396 | throw new Error("expected a tool call update"); | ||
| 397 | } | ||
| 398 | return event.toolCall; | ||
| 399 | }; | ||
| 400 | |||
| 401 | const created = toolCall([{ path: "README" }]); | ||
| 402 | expect(mergeToolCallState(created, toolCall()).data.locations).toEqual([{ path: "README" }]); | ||
| 403 | const cleared = mergeToolCallState(created, toolCall([])); | ||
| 404 | expect(cleared.data.locations).toEqual([]); | ||
| 405 | expect(mergeToolCallState(created, toolCall(null)).data.locations).toEqual([]); | ||
| 406 | expect( | ||
| 407 | decideToolCallUpdateEmission({ | ||
| 408 | previous: created, | ||
| 409 | next: cleared, | ||
| 410 | lastEmittedDetailLength: 0, | ||
| 411 | skippedSinceEmit: 0, | ||
| 412 | }).emit, | ||
| 413 | ).toBe(true); | ||
| 414 | }); | ||
| 415 | |||
| 416 | it("trims padded current mode updates before emitting a mode change", () => { | ||
| 417 | const result = parseSessionUpdateEvent({ | ||
| 418 | sessionId: "session-1", | ||
| 419 | update: { | ||
| 420 | sessionUpdate: "current_mode_update", | ||
| 421 | currentModeId: " code ", | ||
| 422 | }, | ||
| 423 | } satisfies EffectAcpSchema.SessionNotification); | ||
| 424 | |||
| 425 | expect(result.modeId).toBe("code"); | ||
| 426 | expect(result.events).toEqual([ | ||
| 427 | { | ||
| 428 | _tag: "ModeChanged", | ||
| 429 | modeId: "code", | ||
| 430 | }, | ||
| 431 | ]); | ||
| 432 | }); | ||
| 433 | |||
| 434 | it("projects typed ACP plan and content updates", () => { | ||
| 435 | const planResult = parseSessionUpdateEvent({ | ||
| 436 | sessionId: "session-1", | ||
| 437 | update: { | ||
| 438 | sessionUpdate: "plan", | ||
| 439 | entries: [ | ||
| 440 | { content: " Inspect state ", priority: "high", status: "completed" }, | ||
| 441 | { content: "", priority: "medium", status: "in_progress" }, | ||
| 442 | ], | ||
| 443 | }, | ||
| 444 | } satisfies EffectAcpSchema.SessionNotification); | ||
| 445 | |||
| 446 | expect(planResult.events).toEqual([ | ||
| 447 | { | ||
| 448 | _tag: "PlanUpdated", | ||
| 449 | payload: { | ||
| 450 | nativePlanId: "legacy", | ||
| 451 | kind: "items", | ||
| 452 | plan: [ | ||
| 453 | { step: "Inspect state", status: "completed" }, | ||
| 454 | { step: "Step 2", status: "inProgress" }, | ||
| 455 | ], | ||
| 456 | }, | ||
| 457 | rawPayload: { | ||
| 458 | sessionId: "session-1", | ||
| 459 | update: { | ||
| 460 | sessionUpdate: "plan", | ||
| 461 | entries: [ | ||
| 462 | { content: " Inspect state ", priority: "high", status: "completed" }, | ||
| 463 | { content: "", priority: "medium", status: "in_progress" }, | ||
| 464 | ], | ||
| 465 | }, | ||
| 466 | }, | ||
| 467 | }, | ||
| 468 | ]); | ||
| 469 | |||
| 470 | const contentResult = parseSessionUpdateEvent({ | ||
| 471 | sessionId: "session-1", | ||
| 472 | update: { | ||
| 473 | sessionUpdate: "agent_message_chunk", | ||
| 474 | content: { | ||
| 475 | type: "text", | ||
| 476 | text: "hello from acp", | ||
| 477 | }, | ||
| 478 | }, | ||
| 479 | } satisfies EffectAcpSchema.SessionNotification); | ||
| 480 | |||
| 481 | expect(contentResult.events).toEqual([ | ||
| 482 | { | ||
| 483 | _tag: "ContentDelta", | ||
| 484 | text: "hello from acp", | ||
| 485 | rawPayload: { | ||
| 486 | sessionId: "session-1", | ||
| 487 | update: { | ||
| 488 | sessionUpdate: "agent_message_chunk", | ||
| 489 | content: { | ||
| 490 | type: "text", | ||
| 491 | text: "hello from acp", | ||
| 492 | }, | ||
| 493 | }, | ||
| 494 | }, | ||
| 495 | }, | ||
| 496 | ]); | ||
| 497 | }); | ||
| 498 | |||
| 499 | it("keeps thought chunks separate from assistant text", () => { | ||
| 500 | const notification = { | ||
| 501 | sessionId: "session-1", | ||
| 502 | update: { | ||
| 503 | sessionUpdate: "agent_thought_chunk", | ||
| 504 | content: { type: "text", text: "Inspect the current implementation first." }, | ||
| 505 | }, | ||
| 506 | } satisfies EffectAcpSchema.SessionNotification; | ||
| 507 | |||
| 508 | expect(parseSessionUpdateEvent(notification).events).toEqual([ | ||
| 509 | { | ||
| 510 | _tag: "ThoughtDelta", | ||
| 511 | text: "Inspect the current implementation first.", | ||
| 512 | rawPayload: notification, | ||
| 513 | }, | ||
| 514 | ]); | ||
| 515 | }); | ||
| 516 | |||
| 517 | it("preserves native command inputs and empty command lists", () => { | ||
| 518 | const availableCommands = [ | ||
| 519 | { name: "plan", description: "Plan a task", input: { type: "text", hint: "task" } }, | ||
| 520 | { name: "logout", description: "Sign out" }, | ||
| 521 | ] satisfies ReadonlyArray<EffectAcpSchema.AvailableCommand>; | ||
| 522 | |||
| 523 | for (const commands of [availableCommands, []]) { | ||
| 524 | const notification = { | ||
| 525 | sessionId: "session-1", | ||
| 526 | update: { sessionUpdate: "available_commands_update", availableCommands: commands }, | ||
| 527 | } satisfies EffectAcpSchema.SessionNotification; | ||
| 528 | expect(parseSessionUpdateEvent(notification).events).toEqual([ | ||
| 529 | { | ||
| 530 | _tag: "AvailableCommandsUpdated", | ||
| 531 | availableCommands: commands, | ||
| 532 | rawPayload: notification, | ||
| 533 | }, | ||
| 534 | ]); | ||
| 535 | } | ||
| 536 | }); | ||
| 537 | |||
| 538 | it("keeps permission request parsing compatible with loose extension payloads", () => { | ||
| 539 | const request = parsePermissionRequest({ | ||
| 540 | sessionId: "session-1", | ||
| 541 | options: [ | ||
| 542 | { | ||
| 543 | optionId: "allow-once", | ||
| 544 | name: "Allow once", | ||
| 545 | kind: "allow_once", | ||
| 546 | }, | ||
| 547 | ], | ||
| 548 | toolCall: { | ||
| 549 | toolCallId: "tool-1", | ||
| 550 | title: "`cat package.json`", | ||
| 551 | kind: "execute", | ||
| 552 | status: "pending", | ||
| 553 | content: [ | ||
| 554 | { | ||
| 555 | type: "content", | ||
| 556 | content: { | ||
| 557 | type: "text", | ||
| 558 | text: "Not in allowlist", | ||
| 559 | }, | ||
| 560 | }, | ||
| 561 | ], | ||
| 562 | }, | ||
| 563 | }); | ||
| 564 | |||
| 565 | expect(request).toMatchObject({ | ||
| 566 | kind: "execute", | ||
| 567 | detail: "cat package.json", | ||
| 568 | toolCall: { | ||
| 569 | toolCallId: "tool-1", | ||
| 570 | kind: "execute", | ||
| 571 | status: "pending", | ||
| 572 | command: "cat package.json", | ||
| 573 | }, | ||
| 574 | }); | ||
| 575 | }); | ||
| 576 | |||
| 577 | it("bounds an oversized cumulative tool_call_update content buffer to a tail window", () => { | ||
| 578 | // Mirrors Grok's ACP CLI resending the ENTIRE accumulated terminal output on every | ||
| 579 | // tool_call_update notification instead of a delta (see upstream #6556). | ||
| 580 | const hugeText = Array.from({ length: 2_000 }, (_, i) => `line ${i}: ${"x".repeat(50)}`).join( | ||
| 581 | "\n", | ||
| 582 | ); | ||
| 583 | expect(hugeText.length).toBeGreaterThan(60_000); | ||
| 584 | |||
| 585 | const result = parseSessionUpdateEvent({ | ||
| 586 | sessionId: "session-1", | ||
| 587 | update: { | ||
| 588 | // Real ACP `tool_call_update` deltas typically omit `title` (already established by | ||
| 589 | // the initial `tool_call`); that is also the shape that surfaces raw content as detail. | ||
| 590 | sessionUpdate: "tool_call_update", | ||
| 591 | toolCallId: "tool-1", | ||
| 592 | kind: "other", | ||
| 593 | status: "in_progress", | ||
| 594 | content: [{ type: "content", content: { type: "text", text: hugeText } }], | ||
| 595 | }, | ||
| 596 | } satisfies EffectAcpSchema.SessionNotification); | ||
| 597 | |||
| 598 | expect(result.events).toHaveLength(1); | ||
| 599 | const event = result.events[0]; | ||
| 600 | if (event?._tag !== "ToolCallUpdated") { | ||
| 601 | throw new Error("expected a ToolCallUpdated event"); | ||
| 602 | } | ||
| 603 | |||
| 604 | expect(event.toolCall.detail).toBeDefined(); | ||
| 605 | const detail = event.toolCall.detail!; | ||
| 606 | // 8000 chars of tail plus the truncation marker, regardless of input size. | ||
| 607 | expect(detail.length).toBe(8_028); | ||
| 608 | expect(detail.startsWith("[Earlier output truncated]")).toBe(true); | ||
| 609 | expect(detail.endsWith(hugeText.slice(-100))).toBe(true); | ||
| 610 | |||
| 611 | // The raw payload threaded through for logging/persistence must not smuggle the full | ||
| 612 | // cumulative buffer back in either. | ||
| 613 | const rawUpdate = ( | ||
| 614 | event.rawPayload as { | ||
| 615 | readonly update: { | ||
| 616 | readonly content: ReadonlyArray<{ readonly content: { text: string } }>; | ||
| 617 | }; | ||
| 618 | } | ||
| 619 | ).update; | ||
| 620 | expect(rawUpdate.content[0]?.content.text.length).toBeLessThan(8_100); | ||
| 621 | expect(JSON.stringify(event).length).toBeLessThan(hugeText.length); | ||
| 622 | }); | ||
| 623 | |||
| 624 | it("coalesces 1000 rapid cumulative tool_call_update notifications for a redrawing progress bar", () => { | ||
| 625 | let previous: AcpToolCallState | undefined; | ||
| 626 | let lastEmittedDetailLength: number | undefined; | ||
| 627 | let skippedSinceEmit = 0; | ||
| 628 | let emittedCount = 0; | ||
| 629 | let emittedBytes = 0; | ||
| 630 | let notificationBytes = 0; | ||
| 631 | let largestEmittedEventBytes = 0; | ||
| 632 | let finalDetail: string | undefined; | ||
| 633 | let cumulativeBuffer = ""; | ||
| 634 | |||
| 635 | for (let i = 0; i < 1_000; i += 1) { | ||
| 636 | // Grok resends the FULL accumulated buffer, not a delta, on every redraw. | ||
| 637 | cumulativeBuffer += `frame ${i}: ${"#".repeat(50)}\n`; | ||
| 638 | const isLast = i === 999; | ||
| 639 | |||
| 640 | const notification = { | ||
| 641 | sessionId: "session-1", | ||
| 642 | update: { | ||
| 643 | sessionUpdate: "tool_call_update", | ||
| 644 | toolCallId: "tool-1", | ||
| 645 | kind: "other", | ||
| 646 | status: isLast ? "completed" : "in_progress", | ||
| 647 | content: [{ type: "content", content: { type: "text", text: cumulativeBuffer } }], | ||
| 648 | }, | ||
| 649 | } satisfies EffectAcpSchema.SessionNotification; | ||
| 650 | notificationBytes += JSON.stringify(notification).length; | ||
| 651 | |||
| 652 | const { events } = parseSessionUpdateEvent(notification); | ||
| 653 | |||
| 654 | const event = events[0]; | ||
| 655 | if (event?._tag !== "ToolCallUpdated") { | ||
| 656 | continue; | ||
| 657 | } | ||
| 658 | |||
| 659 | const merged = mergeToolCallState(previous, event.toolCall); | ||
| 660 | const decision = decideToolCallUpdateEmission({ | ||
| 661 | previous, | ||
| 662 | next: merged, | ||
| 663 | lastEmittedDetailLength, | ||
| 664 | skippedSinceEmit, | ||
| 665 | }); | ||
| 666 | previous = merged; | ||
| 667 | skippedSinceEmit = decision.skippedSinceEmit; | ||
| 668 | if (decision.emit) { | ||
| 669 | emittedCount += 1; | ||
| 670 | const eventBytes = JSON.stringify({ | ||
| 671 | toolCall: merged, | ||
| 672 | rawPayload: event.rawPayload, | ||
| 673 | }).length; | ||
| 674 | emittedBytes += eventBytes; | ||
| 675 | largestEmittedEventBytes = Math.max(largestEmittedEventBytes, eventBytes); | ||
| 676 | lastEmittedDetailLength = merged.detail?.length; | ||
| 677 | finalDetail = merged.detail; | ||
| 678 | } | ||
| 679 | } | ||
| 680 | |||
| 681 | // The flood as the CLI sends it: 1000 cumulative redraws, ~31.6 MB of JSON. | ||
| 682 | expect(notificationBytes).toBeGreaterThan(31_000_000); | ||
| 683 | |||
| 684 | // 1000 cumulative redraws collapse into a fixed, small number of runtime events... | ||
| 685 | expect(emittedCount).toBe(114); | ||
| 686 | // ...each individually bounded, no matter how long the tool call runs... | ||
| 687 | expect(largestEmittedEventBytes).toBeLessThan(25_000); | ||
| 688 | // ...so the whole flooding tool call costs ~2.5 MB of runtime events instead of ~31.6 MB. | ||
| 689 | expect(emittedBytes).toBeLessThan(2_600_000); | ||
| 690 | // ...while the FINAL state (forced by the completed status) still reflects the real, | ||
| 691 | // latest output rather than a stale coalesced value. | ||
| 692 | expect(finalDetail).toBeDefined(); | ||
| 693 | expect(finalDetail?.endsWith(`frame 999: ${"#".repeat(50)}`)).toBe(true); | ||
| 694 | }); | ||
| 695 | |||
| 696 | it("keeps non-text tool call content entries in order when bounding oversized text", () => { | ||
| 697 | const hugePrefix = "x".repeat(25_000); | ||
| 698 | const hugeTail = "y".repeat(25_000); | ||
| 699 | const { events } = parseSessionUpdateEvent({ | ||
| 700 | sessionId: "session-1", | ||
| 701 | update: { | ||
| 702 | sessionUpdate: "tool_call_update", | ||
| 703 | toolCallId: "tool-1", | ||
| 704 | kind: "edit", | ||
| 705 | status: "in_progress", | ||
| 706 | content: [ | ||
| 707 | { type: "content", content: { type: "text", text: hugePrefix } }, | ||
| 708 | { type: "diff", path: "/repo/file.ts", oldText: "before", newText: "after" }, | ||
| 709 | { type: "content", content: { type: "text", text: hugeTail } }, | ||
| 710 | { type: "diff", path: "/repo/other.ts", oldText: "old", newText: "new" }, | ||
| 711 | { type: "content", content: { type: "text", text: " " } }, | ||
| 712 | ], | ||
| 713 | }, | ||
| 714 | } satisfies EffectAcpSchema.SessionNotification); | ||
| 715 | |||
| 716 | const event = events[0]; | ||
| 717 | if (event?._tag !== "ToolCallUpdated") { | ||
| 718 | throw new Error("expected a ToolCallUpdated event"); | ||
| 719 | } | ||
| 720 | const content = event.toolCall.data.content as ReadonlyArray<EffectAcpSchema.ToolCallContent>; | ||
| 721 | expect(content).toHaveLength(3); | ||
| 722 | expect(content[0]).toEqual({ | ||
| 723 | type: "diff", | ||
| 724 | path: "/repo/file.ts", | ||
| 725 | oldText: "before", | ||
| 726 | newText: "after", | ||
| 727 | }); | ||
| 728 | const lastEntry = content[1]; | ||
| 729 | if (lastEntry?.type !== "content" || lastEntry.content.type !== "text") { | ||
| 730 | throw new Error("expected a bounded text entry"); | ||
| 731 | } | ||
| 732 | expect(lastEntry.content.text.length).toBeLessThan(8_100); | ||
| 733 | expect(lastEntry.content.text.endsWith(hugeTail.slice(-100))).toBe(true); | ||
| 734 | expect(content[2]).toEqual({ | ||
| 735 | type: "diff", | ||
| 736 | path: "/repo/other.ts", | ||
| 737 | oldText: "old", | ||
| 738 | newText: "new", | ||
| 739 | }); | ||
| 740 | }); | ||
| 741 | |||
| 742 | it("keeps a retained tail on the original text entries around non-text content", () => { | ||
| 743 | const prefix = "a".repeat(4_000); | ||
| 744 | const suffix = "b".repeat(5_000); | ||
| 745 | const { events } = parseSessionUpdateEvent({ | ||
| 746 | sessionId: "session-1", | ||
| 747 | update: { | ||
| 748 | sessionUpdate: "tool_call_update", | ||
| 749 | toolCallId: "tool-1", | ||
| 750 | kind: "edit", | ||
| 751 | status: "in_progress", | ||
| 752 | content: [ | ||
| 753 | { type: "content", content: { type: "text", text: prefix } }, | ||
| 754 | { type: "diff", path: "/repo/file.ts", oldText: "before", newText: "after" }, | ||
| 755 | { type: "content", content: { type: "text", text: suffix } }, | ||
| 756 | ], | ||
| 757 | }, | ||
| 758 | } satisfies EffectAcpSchema.SessionNotification); | ||
| 759 | |||
| 760 | const event = events[0]; | ||
| 761 | if (event?._tag !== "ToolCallUpdated") { | ||
| 762 | throw new Error("expected a ToolCallUpdated event"); | ||
| 763 | } | ||
| 764 | const content = event.toolCall.data.content as ReadonlyArray<EffectAcpSchema.ToolCallContent>; | ||
| 765 | expect(content).toHaveLength(3); | ||
| 766 | const firstText = content[0]; | ||
| 767 | if (firstText?.type !== "content" || firstText.content.type !== "text") { | ||
| 768 | throw new Error("expected a bounded prefix text entry"); | ||
| 769 | } | ||
| 770 | expect(firstText.content.text.startsWith("[Earlier output truncated]")).toBe(true); | ||
| 771 | expect(firstText.content.text.endsWith("a".repeat(100))).toBe(true); | ||
| 772 | expect(content[1]).toEqual({ | ||
| 773 | type: "diff", | ||
| 774 | path: "/repo/file.ts", | ||
| 775 | oldText: "before", | ||
| 776 | newText: "after", | ||
| 777 | }); | ||
| 778 | expect(content[2]).toEqual({ | ||
| 779 | type: "content", | ||
| 780 | content: { type: "text", text: suffix }, | ||
| 781 | }); | ||
| 782 | }); | ||
| 783 | |||
| 784 | it("bounds oversized whitespace-only tool call content that has no trimmed text", () => { | ||
| 785 | // Whitespace-only entries are skipped when extracting display text (`chunks.length === 0`) | ||
| 786 | // and used to be returned unchanged, which let a redrawing terminal persist unbounded | ||
| 787 | // buffers on `toolCall.data.content` and `rawPayload`. | ||
| 788 | const hugeWhitespace = " \n\t".repeat(30_000); | ||
| 789 | expect(hugeWhitespace.length).toBeGreaterThan(60_000); | ||
| 790 | |||
| 791 | const { events } = parseSessionUpdateEvent({ | ||
| 792 | sessionId: "session-1", | ||
| 793 | update: { | ||
| 794 | sessionUpdate: "tool_call_update", | ||
| 795 | toolCallId: "tool-1", | ||
| 796 | kind: "other", | ||
| 797 | status: "in_progress", | ||
| 798 | content: [{ type: "content", content: { type: "text", text: hugeWhitespace } }], | ||
| 799 | }, | ||
| 800 | } satisfies EffectAcpSchema.SessionNotification); | ||
| 801 | |||
| 802 | const event = events[0]; | ||
| 803 | if (event?._tag !== "ToolCallUpdated") { | ||
| 804 | throw new Error("expected a ToolCallUpdated event"); | ||
| 805 | } | ||
| 806 | |||
| 807 | expect(event.toolCall.detail).toBeUndefined(); | ||
| 808 | const content = event.toolCall.data.content as ReadonlyArray<EffectAcpSchema.ToolCallContent>; | ||
| 809 | const textEntry = content[0]; | ||
| 810 | if (textEntry?.type !== "content" || textEntry.content.type !== "text") { | ||
| 811 | throw new Error("expected a bounded text entry"); | ||
| 812 | } | ||
| 813 | expect(textEntry.content.text.length).toBeLessThan(8_100); | ||
| 814 | expect(textEntry.content.text.startsWith("[Earlier output truncated]")).toBe(true); | ||
| 815 | |||
| 816 | const rawUpdate = ( | ||
| 817 | event.rawPayload as { | ||
| 818 | readonly update: { | ||
| 819 | readonly content: ReadonlyArray<{ readonly content: { text: string } }>; | ||
| 820 | }; | ||
| 821 | } | ||
| 822 | ).update; | ||
| 823 | expect(rawUpdate.content[0]?.content.text.length).toBeLessThan(8_100); | ||
| 824 | expect(JSON.stringify(event).length).toBeLessThan(hugeWhitespace.length); | ||
| 825 | }); | ||
| 826 | |||
| 827 | it("bounds oversized whitespace-padded text entries even when trimmed content fits", () => { | ||
| 828 | const padded = `${" ".repeat(40_000)}ok${" ".repeat(40_000)}`; | ||
| 829 | expect(padded.length).toBeGreaterThan(60_000); | ||
| 830 | |||
| 831 | const { events } = parseSessionUpdateEvent({ | ||
| 832 | sessionId: "session-1", | ||
| 833 | update: { | ||
| 834 | sessionUpdate: "tool_call_update", | ||
| 835 | toolCallId: "tool-1", | ||
| 836 | kind: "other", | ||
| 837 | status: "in_progress", | ||
| 838 | content: [{ type: "content", content: { type: "text", text: padded } }], | ||
| 839 | }, | ||
| 840 | } satisfies EffectAcpSchema.SessionNotification); | ||
| 841 | |||
| 842 | const event = events[0]; | ||
| 843 | if (event?._tag !== "ToolCallUpdated") { | ||
| 844 | throw new Error("expected a ToolCallUpdated event"); | ||
| 845 | } | ||
| 846 | |||
| 847 | expect(event.toolCall.detail).toBe("ok"); | ||
| 848 | const content = event.toolCall.data.content as ReadonlyArray<EffectAcpSchema.ToolCallContent>; | ||
| 849 | const textEntry = content[0]; | ||
| 850 | if (textEntry?.type !== "content" || textEntry.content.type !== "text") { | ||
| 851 | throw new Error("expected a bounded text entry"); | ||
| 852 | } | ||
| 853 | expect(textEntry.content.text).toBe("ok"); | ||
| 854 | |||
| 855 | const rawUpdate = ( | ||
| 856 | event.rawPayload as { | ||
| 857 | readonly update: { | ||
| 858 | readonly content: ReadonlyArray<{ readonly content: { text: string } }>; | ||
| 859 | }; | ||
| 860 | } | ||
| 861 | ).update; | ||
| 862 | expect(rawUpdate.content[0]?.content.text).toBe("ok"); | ||
| 863 | expect(JSON.stringify(event).length).toBeLessThan(padded.length); | ||
| 864 | }); | ||
| 865 | |||
| 866 | describe("decideToolCallUpdateEmission", () => { | ||
| 867 | const toolCall = (detail: string | undefined, status?: AcpToolCallState["status"]) => | ||
| 868 | ({ | ||
| 869 | toolCallId: "tool-1", | ||
| 870 | title: "Grok Tool", | ||
| 871 | ...(status ? { status } : {}), | ||
| 872 | ...(detail ? { detail } : {}), | ||
| 873 | data: {}, | ||
| 874 | }) satisfies AcpToolCallState; | ||
| 875 | |||
| 876 | it("emits the first in-progress tool_call even when it has no detail", () => { | ||
| 877 | expect( | ||
| 878 | decideToolCallUpdateEmission({ | ||
| 879 | previous: undefined, | ||
| 880 | next: { toolCallId: "tool-1", title: "Grok Tool", status: "pending", data: {} }, | ||
| 881 | lastEmittedDetailLength: undefined, | ||
| 882 | skippedSinceEmit: 0, | ||
| 883 | }), | ||
| 884 | ).toEqual({ emit: true, skippedSinceEmit: 0 }); | ||
| 885 | }); | ||
| 886 | |||
| 887 | it("always emits terminal (completed/failed) status updates regardless of growth", () => { | ||
| 888 | expect( | ||
| 889 | decideToolCallUpdateEmission({ | ||
| 890 | previous: toolCall("same", "inProgress"), | ||
| 891 | next: toolCall("same", "completed"), | ||
| 892 | lastEmittedDetailLength: 4, | ||
| 893 | skippedSinceEmit: 0, | ||
| 894 | }), | ||
| 895 | ).toEqual({ emit: true, skippedSinceEmit: 0 }); | ||
| 896 | |||
| 897 | expect( | ||
| 898 | decideToolCallUpdateEmission({ | ||
| 899 | previous: toolCall("same", "inProgress"), | ||
| 900 | next: toolCall("same", "failed"), | ||
| 901 | lastEmittedDetailLength: 4, | ||
| 902 | skippedSinceEmit: 3, | ||
| 903 | }), | ||
| 904 | ).toEqual({ emit: true, skippedSinceEmit: 0 }); | ||
| 905 | }); | ||
| 906 | |||
| 907 | it("skips updates whose bounded detail did not change", () => { | ||
| 908 | const previous = toolCall("frame 1", "inProgress"); | ||
| 909 | expect( | ||
| 910 | decideToolCallUpdateEmission({ | ||
| 911 | previous, | ||
| 912 | next: previous, | ||
| 913 | lastEmittedDetailLength: 7, | ||
| 914 | skippedSinceEmit: 0, | ||
| 915 | }), | ||
| 916 | ).toEqual({ emit: false, skippedSinceEmit: 0 }); | ||
| 917 | }); | ||
| 918 | |||
| 919 | it("coalesces command-tool updates whose content grew while detail stayed the command", () => { | ||
| 920 | const commandCall = (stdout: string): AcpToolCallState => ({ | ||
| 921 | toolCallId: "tool-1", | ||
| 922 | title: "Ran command", | ||
| 923 | status: "inProgress", | ||
| 924 | command: "ls", | ||
| 925 | detail: "ls", | ||
| 926 | data: { | ||
| 927 | command: "ls", | ||
| 928 | content: [{ type: "content", content: { type: "text", text: stdout } }], | ||
| 929 | }, | ||
| 930 | }); | ||
| 931 | |||
| 932 | let previous: AcpToolCallState | undefined; | ||
| 933 | let lastEmittedDetailLength: number | undefined; | ||
| 934 | let skippedSinceEmit = 0; | ||
| 935 | const emissions: Array<boolean> = []; | ||
| 936 | |||
| 937 | for (let i = 1; i <= 12; i += 1) { | ||
| 938 | const next = commandCall("x".repeat(i)); | ||
| 939 | const decision = decideToolCallUpdateEmission({ | ||
| 940 | previous, | ||
| 941 | next, | ||
| 942 | lastEmittedDetailLength, | ||
| 943 | skippedSinceEmit, | ||
| 944 | }); | ||
| 945 | emissions.push(decision.emit); | ||
| 946 | skippedSinceEmit = decision.skippedSinceEmit; | ||
| 947 | if (decision.emit) { | ||
| 948 | lastEmittedDetailLength = toolCallProgressLength(next); | ||
| 949 | } | ||
| 950 | previous = next; | ||
| 951 | } | ||
| 952 | |||
| 953 | const emittedIndices = emissions.flatMap((emitted, index) => (emitted ? [index + 1] : [])); | ||
| 954 | expect(emittedIndices).toEqual([1, 11]); | ||
| 955 | }); | ||
| 956 | |||
| 957 | it("emits pending to inProgress status changes even when detail and output are unchanged", () => { | ||
| 958 | expect( | ||
| 959 | decideToolCallUpdateEmission({ | ||
| 960 | previous: toolCall("same", "pending"), | ||
| 961 | next: toolCall("same", "inProgress"), | ||
| 962 | lastEmittedDetailLength: 4, | ||
| 963 | skippedSinceEmit: 0, | ||
| 964 | }), | ||
| 965 | ).toEqual({ emit: true, skippedSinceEmit: 0 }); | ||
| 966 | }); | ||
| 967 | |||
| 968 | it("emits immediately when the title changes, even with no growth", () => { | ||
| 969 | const decision = decideToolCallUpdateEmission({ | ||
| 970 | previous: { toolCallId: "tool-1", title: "Reading file", detail: "x", data: {} }, | ||
| 971 | next: { toolCallId: "tool-1", title: "Ran command", detail: "x", data: {} }, | ||
| 972 | lastEmittedDetailLength: 1, | ||
| 973 | skippedSinceEmit: 0, | ||
| 974 | }); | ||
| 975 | expect(decision).toEqual({ emit: true, skippedSinceEmit: 0 }); | ||
| 976 | }); | ||
| 977 | |||
| 978 | it("coalesces small deltas but forces an emission after the coalesce limit", () => { | ||
| 979 | let lastEmittedDetailLength: number | undefined = 0; | ||
| 980 | let skippedSinceEmit = 0; | ||
| 981 | const emissions: Array<boolean> = []; | ||
| 982 | let previous: AcpToolCallState | undefined; | ||
| 983 | |||
| 984 | for (let i = 1; i <= 12; i += 1) { | ||
| 985 | // Grows by 1 char per update — well under the 256-char growth threshold, so this | ||
| 986 | // exercises the coalesce-count fallback rather than the growth-based trigger. | ||
| 987 | const next = toolCall("x".repeat(i), "inProgress"); | ||
| 988 | const decision = decideToolCallUpdateEmission({ | ||
| 989 | previous, | ||
| 990 | next, | ||
| 991 | lastEmittedDetailLength, | ||
| 992 | skippedSinceEmit, | ||
| 993 | }); | ||
| 994 | emissions.push(decision.emit); | ||
| 995 | skippedSinceEmit = decision.skippedSinceEmit; | ||
| 996 | if (decision.emit) { | ||
| 997 | lastEmittedDetailLength = next.detail?.length; | ||
| 998 | } | ||
| 999 | previous = next; | ||
| 1000 | } | ||
| 1001 | |||
| 1002 | // First update always emits (no previous state yet); after that, small per-update | ||
| 1003 | // growth should be coalesced until the coalesce limit forces a periodic emission. | ||
| 1004 | const emittedIndices = emissions.flatMap((emitted, index) => (emitted ? [index + 1] : [])); | ||
| 1005 | expect(emittedIndices).toEqual([1, 11]); | ||
| 1006 | }); | ||
| 1007 | |||
| 1008 | it("retains the latest replacement snapshot when equal-length updates are coalesced", () => { | ||
| 1009 | let previous: AcpToolCallState = toolCall("frame-a", "inProgress"); | ||
| 1010 | const lastEmittedDetailLength = previous.detail?.length; | ||
| 1011 | let skippedSinceEmit = 0; | ||
| 1012 | |||
| 1013 | for (const detail of ["frame-b", "frame-c"]) { | ||
| 1014 | const next = mergeToolCallState(previous, toolCall(detail, "inProgress")); | ||
| 1015 | const decision = decideToolCallUpdateEmission({ | ||
| 1016 | previous, | ||
| 1017 | next, | ||
| 1018 | lastEmittedDetailLength, | ||
| 1019 | skippedSinceEmit, | ||
| 1020 | }); | ||
| 1021 | expect(decision.emit).toBe(false); | ||
| 1022 | skippedSinceEmit = decision.skippedSinceEmit; | ||
| 1023 | previous = next; | ||
| 1024 | } | ||
| 1025 | |||
| 1026 | const completed = mergeToolCallState(previous, toolCall(undefined, "completed")); | ||
| 1027 | expect(completed.detail).toBe("frame-c"); | ||
| 1028 | expect( | ||
| 1029 | decideToolCallUpdateEmission({ | ||
| 1030 | previous, | ||
| 1031 | next: completed, | ||
| 1032 | lastEmittedDetailLength, | ||
| 1033 | skippedSinceEmit, | ||
| 1034 | }), | ||
| 1035 | ).toEqual({ emit: true, skippedSinceEmit: 0 }); | ||
| 1036 | }); | ||
| 1037 | }); | ||
| 1038 | |||
| 1039 | it("preserves message identity, usage, metadata, and non-text output", () => { | ||
| 1040 | const message = parseSessionUpdateEvent({ | ||
| 1041 | sessionId: "session-1", | ||
| 1042 | update: { | ||
| 1043 | sessionUpdate: "agent_message_chunk", | ||
| 1044 | messageId: "message-1", | ||
| 1045 | content: { | ||
| 1046 | type: "resource_link", | ||
| 1047 | name: "report.txt", | ||
| 1048 | title: "Report", | ||
| 1049 | description: "Generated artifact", | ||
| 1050 | uri: "file:///workspace/report.txt", | ||
| 1051 | }, | ||
| 1052 | }, | ||
| 1053 | } satisfies EffectAcpSchema.SessionNotification); | ||
| 1054 | const usage = parseSessionUpdateEvent({ | ||
| 1055 | sessionId: "session-1", | ||
| 1056 | update: { | ||
| 1057 | sessionUpdate: "usage_update", | ||
| 1058 | used: 2_500, | ||
| 1059 | size: 10_000, | ||
| 1060 | cost: { amount: 0.42, currency: "USD" }, | ||
| 1061 | }, | ||
| 1062 | } satisfies EffectAcpSchema.SessionNotification); | ||
| 1063 | const metadata = parseSessionUpdateEvent({ | ||
| 1064 | sessionId: "session-1", | ||
| 1065 | update: { | ||
| 1066 | sessionUpdate: "session_info_update", | ||
| 1067 | title: " Native session ", | ||
| 1068 | updatedAt: " 2026-08-23T00:00:00Z ", | ||
| 1069 | }, | ||
| 1070 | } satisfies EffectAcpSchema.SessionNotification); | ||
| 1071 | |||
| 1072 | expect(message.events[0]).toMatchObject({ | ||
| 1073 | _tag: "ContentDelta", | ||
| 1074 | messageId: "message-1", | ||
| 1075 | text: "Report: Generated artifact\nfile:///workspace/report.txt", | ||
| 1076 | }); | ||
| 1077 | expect(usage.events[0]).toMatchObject({ | ||
| 1078 | _tag: "UsageUpdated", | ||
| 1079 | usage: { | ||
| 1080 | usedTokens: 2_500, | ||
| 1081 | maxTokens: 10_000, | ||
| 1082 | cost: { amount: 0.42, currency: "USD" }, | ||
| 1083 | }, | ||
| 1084 | }); | ||
| 1085 | expect(metadata.events[0]).toMatchObject({ | ||
| 1086 | _tag: "SessionInfoUpdated", | ||
| 1087 | metadata: { | ||
| 1088 | title: "Native session", | ||
| 1089 | updatedAt: "2026-08-23T00:00:00Z", | ||
| 1090 | }, | ||
| 1091 | }); | ||
| 1092 | }); | ||
| 1093 | |||
| 1094 | it("projects ACP v2 plan replacement and removal updates", () => { | ||
| 1095 | const replaced = parseSessionUpdateEvent({ | ||
| 1096 | sessionId: "session-1", | ||
| 1097 | update: { | ||
| 1098 | sessionUpdate: "plan_update", | ||
| 1099 | plan: { | ||
| 1100 | type: "items", | ||
| 1101 | planId: "plan-1", | ||
| 1102 | entries: [ | ||
| 1103 | { content: " Inspect schema ", priority: "high", status: "completed" }, | ||
| 1104 | { content: "Ship integration", priority: "high", status: "in_progress" }, | ||
| 1105 | ], | ||
| 1106 | }, | ||
| 1107 | }, | ||
| 1108 | } satisfies EffectAcpSchema.SessionNotification); | ||
| 1109 | const removed = parseSessionUpdateEvent({ | ||
| 1110 | sessionId: "session-1", | ||
| 1111 | update: { sessionUpdate: "plan_removed", planId: "plan-1" }, | ||
| 1112 | } satisfies EffectAcpSchema.SessionNotification); | ||
| 1113 | |||
| 1114 | expect(replaced.events[0]).toMatchObject({ | ||
| 1115 | _tag: "PlanUpdated", | ||
| 1116 | payload: { | ||
| 1117 | nativePlanId: "plan-1", | ||
| 1118 | kind: "items", | ||
| 1119 | plan: [ | ||
| 1120 | { step: "Inspect schema", status: "completed" }, | ||
| 1121 | { step: "Ship integration", status: "inProgress" }, | ||
| 1122 | ], | ||
| 1123 | }, | ||
| 1124 | }); | ||
| 1125 | expect(removed.events[0]).toMatchObject({ | ||
| 1126 | _tag: "PlanUpdated", | ||
| 1127 | payload: { nativePlanId: "plan-1", kind: "removed" }, | ||
| 1128 | }); | ||
| 1129 | }); | ||
| 1130 | |||
| 1131 | it("preserves markdown, file, and future ACP plan variants by plan ID", () => { | ||
| 1132 | const updates = [ | ||
| 1133 | { | ||
| 1134 | sessionId: "session-1", | ||
| 1135 | update: { | ||
| 1136 | sessionUpdate: "plan_update", | ||
| 1137 | plan: { type: "markdown", planId: "plan-markdown", content: "# Ship it" }, | ||
| 1138 | }, | ||
| 1139 | }, | ||
| 1140 | { | ||
| 1141 | sessionId: "session-1", | ||
| 1142 | update: { | ||
| 1143 | sessionUpdate: "plan_update", | ||
| 1144 | plan: { type: "file", planId: "plan-file", uri: "file:///workspace/PLAN.md" }, | ||
| 1145 | }, | ||
| 1146 | }, | ||
| 1147 | { | ||
| 1148 | sessionId: "session-1", | ||
| 1149 | update: { | ||
| 1150 | sessionUpdate: "plan_update", | ||
| 1151 | plan: { type: "diagram", planId: "plan-future", nodes: [] }, | ||
| 1152 | }, | ||
| 1153 | }, | ||
| 1154 | ] as const; | ||
| 1155 | |||
| 1156 | expect(updates.flatMap((update) => parseSessionUpdateEvent(update).events)).toMatchObject([ | ||
| 1157 | { | ||
| 1158 | _tag: "PlanUpdated", | ||
| 1159 | payload: { nativePlanId: "plan-markdown", kind: "markdown", markdown: "# Ship it" }, | ||
| 1160 | }, | ||
| 1161 | { | ||
| 1162 | _tag: "PlanUpdated", | ||
| 1163 | payload: { | ||
| 1164 | nativePlanId: "plan-file", | ||
| 1165 | kind: "file", | ||
| 1166 | uri: "file:///workspace/PLAN.md", | ||
| 1167 | }, | ||
| 1168 | }, | ||
| 1169 | { | ||
| 1170 | _tag: "PlanUpdated", | ||
| 1171 | payload: { nativePlanId: "plan-future", kind: "unknown", contentType: "diagram" }, | ||
| 1172 | }, | ||
| 1173 | ]); | ||
| 1174 | }); | ||
| 1175 | |||
| 1176 | it("turns future ACP content and session updates into explicit placeholders", () => { | ||
| 1177 | expect( | ||
| 1178 | acpContentBlockDisplayText({ | ||
| 1179 | type: "_t3_unknown", | ||
| 1180 | originalType: "chart", | ||
| 1181 | raw: { type: "chart", points: [] }, | ||
| 1182 | }), | ||
| 1183 | ).toBe("[Unsupported ACP content: chart]"); | ||
| 1184 | |||
| 1185 | expect( | ||
| 1186 | parseSessionUpdateEvent({ | ||
| 1187 | sessionId: "session-1", | ||
| 1188 | update: { | ||
| 1189 | sessionUpdate: "_t3_unknown", | ||
| 1190 | originalSessionUpdate: "timeline_update", | ||
| 1191 | raw: { sessionUpdate: "timeline_update", entries: [] }, | ||
| 1192 | }, | ||
| 1193 | }).events, | ||
| 1194 | ).toMatchObject([{ _tag: "UnknownUpdate", updateType: "timeline_update" }]); | ||
| 1195 | }); | ||
| 1196 | |||
| 1197 | it("projects ACP v2 compaction lifecycle and prompt usage", () => { | ||
| 1198 | const compacting = parseSessionUpdateEvent({ | ||
| 1199 | sessionId: "session-1", | ||
| 1200 | update: { | ||
| 1201 | sessionUpdate: "compaction_update", | ||
| 1202 | compactionId: "compact-1", | ||
| 1203 | status: "in_progress", | ||
| 1204 | }, | ||
| 1205 | } satisfies EffectAcpSchema.SessionNotification); | ||
| 1206 | const compacted = parseSessionUpdateEvent({ | ||
| 1207 | sessionId: "session-1", | ||
| 1208 | update: { | ||
| 1209 | sessionUpdate: "compaction_update", | ||
| 1210 | compactionId: "compact-1", | ||
| 1211 | status: "completed", | ||
| 1212 | summary: [{ type: "text", text: "Retained the implementation decisions." }], | ||
| 1213 | }, | ||
| 1214 | } satisfies EffectAcpSchema.SessionNotification); | ||
| 1215 | const idle = parseSessionUpdateEvent({ | ||
| 1216 | sessionId: "session-1", | ||
| 1217 | update: { | ||
| 1218 | sessionUpdate: "state_update", | ||
| 1219 | state: "idle", | ||
| 1220 | stopReason: "end_turn", | ||
| 1221 | usage: { totalTokens: 420, inputTokens: 300, outputTokens: 100, thoughtTokens: 20 }, | ||
| 1222 | }, | ||
| 1223 | } satisfies EffectAcpSchema.SessionNotification); | ||
| 1224 | |||
| 1225 | expect(compacting.events[0]).toMatchObject({ | ||
| 1226 | _tag: "ToolCallUpdated", | ||
| 1227 | toolCall: { | ||
| 1228 | toolCallId: "acp-compaction:compact-1", | ||
| 1229 | kind: "think", | ||
| 1230 | title: "Compact context", | ||
| 1231 | status: "inProgress", | ||
| 1232 | }, | ||
| 1233 | }); | ||
| 1234 | expect(compacted.events[0]).toMatchObject({ | ||
| 1235 | _tag: "ToolCallUpdated", | ||
| 1236 | toolCall: { | ||
| 1237 | toolCallId: "acp-compaction:compact-1", | ||
| 1238 | status: "completed", | ||
| 1239 | data: { rawOutput: "Retained the implementation decisions." }, | ||
| 1240 | }, | ||
| 1241 | }); | ||
| 1242 | expect(idle.events[0]).toMatchObject({ | ||
| 1243 | _tag: "UsageUpdated", | ||
| 1244 | usage: { usedTokens: 420 }, | ||
| 1245 | }); | ||
| 1246 | }); | ||
| 1247 | |||
| 1248 | it("applies ACP v2 agent-owned terminal snapshots and output chunks", () => { | ||
| 1249 | const snapshot = applyAcpAgentTerminalUpdate(undefined, { | ||
| 1250 | sessionUpdate: "terminal_update", | ||
| 1251 | terminalId: "terminal-1", | ||
| 1252 | command: "pnpm test", | ||
| 1253 | cwd: "/workspace", | ||
| 1254 | output: { data: Buffer.from("first line\n").toString("base64") }, | ||
| 1255 | }); | ||
| 1256 | const chunked = applyAcpAgentTerminalUpdate(snapshot, { | ||
| 1257 | sessionUpdate: "terminal_output_chunk", | ||
| 1258 | terminalId: "terminal-1", | ||
| 1259 | data: Buffer.from("second line\n").toString("base64"), | ||
| 1260 | }); | ||
| 1261 | const completed = applyAcpAgentTerminalUpdate(chunked, { | ||
| 1262 | sessionUpdate: "terminal_update", | ||
| 1263 | terminalId: "terminal-1", | ||
| 1264 | exitStatus: { exitCode: 0 }, | ||
| 1265 | }); | ||
| 1266 | |||
| 1267 | expect(completed).toEqual({ | ||
| 1268 | command: "pnpm test", | ||
| 1269 | cwd: "/workspace", | ||
| 1270 | output: "first line\nsecond line\n", | ||
| 1271 | exitStatus: { exitCode: 0 }, | ||
| 1272 | }); | ||
| 1273 | }); | ||
| 1274 | |||
| 1275 | it("preserves UTF-8 characters split across ACP terminal output chunks", () => { | ||
| 1276 | const bytes = Buffer.from("A🙂B", "utf8"); | ||
| 1277 | const first = applyAcpAgentTerminalUpdate(undefined, { | ||
| 1278 | sessionUpdate: "terminal_output_chunk", | ||
| 1279 | terminalId: "terminal-utf8", | ||
| 1280 | data: bytes.subarray(0, 3).toString("base64"), | ||
| 1281 | }); | ||
| 1282 | const second = applyAcpAgentTerminalUpdate(first, { | ||
| 1283 | sessionUpdate: "terminal_output_chunk", | ||
| 1284 | terminalId: "terminal-utf8", | ||
| 1285 | data: bytes.subarray(3).toString("base64"), | ||
| 1286 | }); | ||
| 1287 | |||
| 1288 | expect(first.output).toBe("A"); | ||
| 1289 | expect(first.pendingOutputBytes).toBeDefined(); | ||
| 1290 | expect(second.output).toBe("A🙂B"); | ||
| 1291 | expect(second.pendingOutputBytes).toBeUndefined(); | ||
| 1292 | }); | ||
| 1293 | |||
| 1294 | it("drops malformed and oversized ACP terminal frames without decoding them", () => { | ||
| 1295 | const seeded = applyAcpAgentTerminalUpdate(undefined, { | ||
| 1296 | sessionUpdate: "terminal_output_chunk", | ||
| 1297 | terminalId: "terminal-invalid", | ||
| 1298 | data: Buffer.from("kept").toString("base64"), | ||
| 1299 | }); | ||
| 1300 | |||
| 1301 | expect( | ||
| 1302 | applyAcpAgentTerminalUpdate(seeded, { | ||
| 1303 | sessionUpdate: "terminal_output_chunk", | ||
| 1304 | terminalId: "terminal-invalid", | ||
| 1305 | data: "not base64!", | ||
| 1306 | }), | ||
| 1307 | ).toBe(seeded); | ||
| 1308 | |||
| 1309 | expect( | ||
| 1310 | applyAcpAgentTerminalUpdate(seeded, { | ||
| 1311 | sessionUpdate: "terminal_output_chunk", | ||
| 1312 | terminalId: "terminal-oversized", | ||
| 1313 | data: Buffer.alloc(16 * 1024 * 1024 + 1).toString("base64"), | ||
| 1314 | }), | ||
| 1315 | ).toBe(seeded); | ||
| 1316 | }); | ||
| 1317 | |||
| 1318 | it("projects binary ACP content without retaining encoded payloads", () => { | ||
| 1319 | const encodedPayload = "sensitive-base64-payload"; | ||
| 1320 | const binary = acpContentBlockDisplayText({ | ||
| 1321 | type: "resource", | ||
| 1322 | resource: { | ||
| 1323 | blob: encodedPayload, | ||
| 1324 | mimeType: "application/octet-stream", | ||
| 1325 | uri: "file:///workspace/archive.bin", | ||
| 1326 | }, | ||
| 1327 | }); | ||
| 1328 | const image = acpContentBlockDisplayText({ | ||
| 1329 | type: "image", | ||
| 1330 | data: encodedPayload, | ||
| 1331 | mimeType: "image/png", | ||
| 1332 | uri: "file:///workspace/image.png", | ||
| 1333 | }); | ||
| 1334 | const audio = acpContentBlockDisplayText({ | ||
| 1335 | type: "audio", | ||
| 1336 | data: encodedPayload, | ||
| 1337 | mimeType: "audio/wav", | ||
| 1338 | }); | ||
| 1339 | |||
| 1340 | expect(binary).toBe( | ||
| 1341 | "[ACP binary resource (application/octet-stream): file:///workspace/archive.bin]", | ||
| 1342 | ); | ||
| 1343 | expect(image).toBe("[ACP image (image/png): file:///workspace/image.png]"); | ||
| 1344 | expect(audio).toBe("[ACP audio (audio/wav)]"); | ||
| 1345 | expect(`${binary}${image}${audio}`).not.toContain(encodedPayload); | ||
| 1346 | |||
| 1347 | const toolUpdate = parseSessionUpdateEvent({ | ||
| 1348 | sessionId: "session-1", | ||
| 1349 | update: { | ||
| 1350 | sessionUpdate: "tool_call", | ||
| 1351 | toolCallId: "tool-with-image", | ||
| 1352 | title: "Generated image", | ||
| 1353 | content: [ | ||
| 1354 | { | ||
| 1355 | type: "content", | ||
| 1356 | content: { | ||
| 1357 | type: "image", | ||
| 1358 | data: encodedPayload, | ||
| 1359 | mimeType: "image/png", | ||
| 1360 | uri: `data:image/png;base64,${encodedPayload}`, | ||
| 1361 | }, | ||
| 1362 | }, | ||
| 1363 | ], | ||
| 1364 | }, | ||
| 1365 | } satisfies EffectAcpSchema.SessionNotification); | ||
| 1366 | const toolEvent = toolUpdate.events.find((event) => event._tag === "ToolCallUpdated"); | ||
| 1367 | expect(JSON.stringify(toolEvent?.toolCall)).not.toContain(encodedPayload); | ||
| 1368 | expect(toolUpdate.events[0]).toMatchObject({ | ||
| 1369 | _tag: "ToolCallUpdated", | ||
| 1370 | toolCall: { | ||
| 1371 | data: { | ||
| 1372 | content: [ | ||
| 1373 | { type: "content", content: { type: "text", text: "[ACP image (image/png)]" } }, | ||
| 1374 | ], | ||
| 1375 | }, | ||
| 1376 | }, | ||
| 1377 | }); | ||
| 1378 | }); | ||
| 1379 | |||
| 1380 | it("bounds text-bearing ACP content without changing streamed whitespace", () => { | ||
| 1381 | const text = ` ${"x".repeat(70_000)} `; | ||
| 1382 | const projectedText = acpContentBlockDisplayText({ type: "text", text }); | ||
| 1383 | const projectedResource = acpContentBlockDisplayText({ | ||
| 1384 | type: "resource", | ||
| 1385 | resource: { uri: "file:///workspace/large.txt", text }, | ||
| 1386 | }); | ||
| 1387 | |||
| 1388 | expect(projectedText).toHaveLength(65_536); | ||
| 1389 | expect(projectedText?.startsWith(" ")).toBe(true); | ||
| 1390 | expect(projectedResource).toEqual(projectedText); | ||
| 1391 | }); | ||
| 1392 | }); | ||
| 1393 | |||
| 1394 | describe("extractMcpToolCallIdentity", () => { | ||
| 1395 | function toolCallFromUpdate( | ||
| 1396 | update: EffectAcpSchema.SessionNotification["update"], | ||
| 1397 | ): NonNullable<ReturnType<typeof parsePermissionRequest>["toolCall"]> { | ||
| 1398 | const parsed = parseSessionUpdateEvent({ sessionId: "session-1", update }); | ||
| 1399 | const event = parsed.events.find( | ||
| 1400 | ( | ||
| 1401 | candidate, | ||
| 1402 | ): candidate is Extract<(typeof parsed.events)[number], { _tag: "ToolCallUpdated" }> => | ||
| 1403 | candidate._tag === "ToolCallUpdated", | ||
| 1404 | ); | ||
| 1405 | if (event === undefined) throw new Error("expected a tool call event"); | ||
| 1406 | return event.toolCall; | ||
| 1407 | } | ||
| 1408 | |||
| 1409 | it("recovers server and tool from codex-acp tagged execute calls", () => { | ||
| 1410 | // Captured verbatim from codex-acp 2026-08-14: MCP calls arrive as kind | ||
| 1411 | // "execute" with the identity only in rawInput. | ||
| 1412 | const toolCall = toolCallFromUpdate({ | ||
| 1413 | sessionUpdate: "tool_call", | ||
| 1414 | toolCallId: "exec-f4591587-0754-4bb4-990b-f2767894ba93", | ||
| 1415 | kind: "execute", | ||
| 1416 | title: "mcp.t3-code.orchestrator_capabilities", | ||
| 1417 | status: "in_progress", | ||
| 1418 | rawInput: { server: "t3-code", tool: "orchestrator_capabilities", arguments: {} }, | ||
| 1419 | _meta: { is_mcp_tool_call: true }, | ||
| 1420 | }); | ||
| 1421 | |||
| 1422 | expect(extractMcpToolCallIdentity(toolCall)).toEqual({ | ||
| 1423 | server: "t3-code", | ||
| 1424 | tool: "orchestrator_capabilities", | ||
| 1425 | }); | ||
| 1426 | }); | ||
| 1427 | |||
| 1428 | it("recovers T3 identity from acp-mcp-call fallback commands", () => { | ||
| 1429 | const toolCall = toolCallFromUpdate({ | ||
| 1430 | sessionUpdate: "tool_call", | ||
| 1431 | toolCallId: "exec-1", | ||
| 1432 | kind: "execute", | ||
| 1433 | title: "Ran command", | ||
| 1434 | status: "in_progress", | ||
| 1435 | }); | ||
| 1436 | |||
| 1437 | expect( | ||
| 1438 | extractMcpToolCallIdentity(toolCall, { | ||
| 1439 | embeddedTerminalCommands: [ | ||
| 1440 | '/usr/bin/node /srv/t3/bin.ts acp-mcp-call delegate_task {"task":"x"}', | ||
| 1441 | ], | ||
| 1442 | }), | ||
| 1443 | ).toEqual({ server: "t3-code", tool: "delegate_task", input: { task: "x" } }); | ||
| 1444 | }); | ||
| 1445 | |||
| 1446 | it("recovers T3 identity from pi-acp title-only fallback execs", () => { | ||
| 1447 | // Captured verbatim from pi-acp 0.0.33 2026-08-14: rawInput is null and | ||
| 1448 | // the command line only appears as the verbatim title, which the | ||
| 1449 | // presentation layer summarizes into "Ran command". | ||
| 1450 | const toolCall = toolCallFromUpdate({ | ||
| 1451 | sessionUpdate: "tool_call", | ||
| 1452 | toolCallId: "call_JdxnvzjHHrbvyASTLVekLYWV|fc_08f5a805a7159aa6016a7ec4afad548191", | ||
| 1453 | kind: "execute", | ||
| 1454 | title: | ||
| 1455 | '"$T3_ACP_MCP_NODE" "$T3_ACP_MCP_ENTRYPOINT" acp-mcp-call orchestrator_capabilities \'{}\'', | ||
| 1456 | status: "in_progress", | ||
| 1457 | rawInput: null, | ||
| 1458 | content: [ | ||
| 1459 | { | ||
| 1460 | type: "terminal", | ||
| 1461 | terminalId: "call_JdxnvzjHHrbvyASTLVekLYWV|fc_08f5a805a7159aa6016a7ec4afad548191", | ||
| 1462 | }, | ||
| 1463 | ], | ||
| 1464 | }); | ||
| 1465 | |||
| 1466 | expect(toolCall.title).toBe("Ran command"); | ||
| 1467 | expect(extractMcpToolCallIdentity(toolCall)).toEqual({ | ||
| 1468 | server: "t3-code", | ||
| 1469 | tool: "orchestrator_capabilities", | ||
| 1470 | input: {}, | ||
| 1471 | }); | ||
| 1472 | }); | ||
| 1473 | |||
| 1474 | it("recovers T3 identity from server-namespaced titles across titleless updates", () => { | ||
| 1475 | // Captured verbatim from Kilo 7.4.22 2026-08-15: the initial tool_call | ||
| 1476 | // titles the MCP function "<server>_<tool>" with kind "other", and the | ||
| 1477 | // completed update carries no title at all, so the merged presentation | ||
| 1478 | // title regresses to "Tool" while data.title keeps the wire value. | ||
| 1479 | const created = toolCallFromUpdate({ | ||
| 1480 | sessionUpdate: "tool_call", | ||
| 1481 | toolCallId: "chatcmpl-tool-b2a6142ee1a510a5", | ||
| 1482 | kind: "other", | ||
| 1483 | title: "t3-code_orchestrator_capabilities", | ||
| 1484 | status: "pending", | ||
| 1485 | locations: [], | ||
| 1486 | rawInput: {}, | ||
| 1487 | }); | ||
| 1488 | const completed = toolCallFromUpdate({ | ||
| 1489 | sessionUpdate: "tool_call_update", | ||
| 1490 | toolCallId: "chatcmpl-tool-b2a6142ee1a510a5", | ||
| 1491 | status: "completed", | ||
| 1492 | content: [{ type: "content", content: { type: "text", text: '{"ok":true}' } }], | ||
| 1493 | }); | ||
| 1494 | const merged = mergeToolCallState(created, completed); | ||
| 1495 | |||
| 1496 | expect(extractMcpToolCallIdentity(merged)).toEqual({ | ||
| 1497 | server: "t3-code", | ||
| 1498 | tool: "orchestrator_capabilities", | ||
| 1499 | }); | ||
| 1500 | }); | ||
| 1501 | |||
| 1502 | it("recovers T3 identity from Gemini and qwen MCP-server title templates", () => { | ||
| 1503 | // Gemini CLI 0.55.1: "<tool> (<server> MCP Server)"; qwen-code 0.21.12 | ||
| 1504 | // appends ": <args json>" to the same template. | ||
| 1505 | const gemini = toolCallFromUpdate({ | ||
| 1506 | sessionUpdate: "tool_call", | ||
| 1507 | toolCallId: "gemini-1", | ||
| 1508 | kind: "other", | ||
| 1509 | title: "delegate_task (t3-code MCP Server)", | ||
| 1510 | status: "in_progress", | ||
| 1511 | }); | ||
| 1512 | const qwen = toolCallFromUpdate({ | ||
| 1513 | sessionUpdate: "tool_call", | ||
| 1514 | toolCallId: "qwen-1", | ||
| 1515 | kind: "other", | ||
| 1516 | title: 'task_status (t3-code MCP Server): {"taskId":"node:delegated-task:1"}', | ||
| 1517 | status: "pending", | ||
| 1518 | rawInput: { taskId: "node:delegated-task:1" }, | ||
| 1519 | }); | ||
| 1520 | |||
| 1521 | expect(extractMcpToolCallIdentity(gemini)).toEqual({ | ||
| 1522 | server: "t3-code", | ||
| 1523 | tool: "delegate_task", | ||
| 1524 | }); | ||
| 1525 | expect(extractMcpToolCallIdentity(qwen)).toEqual({ server: "t3-code", tool: "task_status" }); | ||
| 1526 | }); | ||
| 1527 | |||
| 1528 | it("recovers T3 identity across the registry agents' naming conventions", () => { | ||
| 1529 | // One representative per surveyed convention (2026-08 registry builds): | ||
| 1530 | // droid triple underscore, Copilot hyphen, Amp mangled server + detail | ||
| 1531 | // tail, cline args tail, Auggie tool-first suffix. | ||
| 1532 | // One representative per surveyed convention (2026-08 registry builds): | ||
| 1533 | // droid triple underscore, Copilot hyphen, Amp mangled server + detail | ||
| 1534 | // tail, cline args tail, Auggie tool-first suffix, fast-agent slash, | ||
| 1535 | // Kimi bare name with args tail. | ||
| 1536 | for (const title of [ | ||
| 1537 | "t3-code___delegate_task", | ||
| 1538 | "t3-code-delegate_task", | ||
| 1539 | 'mcp__t3_code__delegate_task: {"mode":"async"}', | ||
| 1540 | 't3-code__delegate_task: {"mode":"async"}', | ||
| 1541 | "delegate_task_t3-code", | ||
| 1542 | "t3-code/delegate_task", | ||
| 1543 | 'delegate_task: {"mode":"async"}', | ||
| 1544 | ]) { | ||
| 1545 | const toolCall = toolCallFromUpdate({ | ||
| 1546 | sessionUpdate: "tool_call", | ||
| 1547 | toolCallId: "convention-1", | ||
| 1548 | kind: "other", | ||
| 1549 | title, | ||
| 1550 | status: "pending", | ||
| 1551 | }); | ||
| 1552 | expect(extractMcpToolCallIdentity(toolCall), title).toEqual({ | ||
| 1553 | server: "t3-code", | ||
| 1554 | tool: "delegate_task", | ||
| 1555 | }); | ||
| 1556 | } | ||
| 1557 | }); | ||
| 1558 | |||
| 1559 | it("recovers T3 identity from goose _meta despite LLM-rewritten titles", () => { | ||
| 1560 | // goose enriches titles asynchronously, so only _meta.goose.toolCall is | ||
| 1561 | // stable; shape from crates/goose/src/acp/server/tool_calls/conversion.rs. | ||
| 1562 | const toolCall = toolCallFromUpdate({ | ||
| 1563 | sessionUpdate: "tool_call", | ||
| 1564 | toolCallId: "goose-1", | ||
| 1565 | title: "Checking on the delegated task", | ||
| 1566 | status: "in_progress", | ||
| 1567 | _meta: { | ||
| 1568 | goose: { | ||
| 1569 | toolCall: { toolName: "t3-code__task_status", extensionName: "t3-code" }, | ||
| 1570 | messageId: "message-1", | ||
| 1571 | }, | ||
| 1572 | }, | ||
| 1573 | }); | ||
| 1574 | |||
| 1575 | expect(extractMcpToolCallIdentity(toolCall)).toEqual({ | ||
| 1576 | server: "t3-code", | ||
| 1577 | tool: "task_status", | ||
| 1578 | }); | ||
| 1579 | }); | ||
| 1580 | |||
| 1581 | it("recovers T3 identity from qwen serverId meta regardless of prefix format", () => { | ||
| 1582 | // qwen-code 0.21.12 emits _meta.serverId + _meta.toolName; serverId is an | ||
| 1583 | // explicit origin assertion, so a known tool suffix suffices even if the | ||
| 1584 | // prefix format changes. | ||
| 1585 | const toolCall = toolCallFromUpdate({ | ||
| 1586 | sessionUpdate: "tool_call", | ||
| 1587 | toolCallId: "qwen-meta-1", | ||
| 1588 | kind: "other", | ||
| 1589 | title: "unrelated display title", | ||
| 1590 | status: "pending", | ||
| 1591 | _meta: { toolName: "mcp::t3-code::t3_thread_send", serverId: "t3-code", provenance: "mcp" }, | ||
| 1592 | }); | ||
| 1593 | |||
| 1594 | expect(extractMcpToolCallIdentity(toolCall)).toEqual({ | ||
| 1595 | server: "t3-code", | ||
| 1596 | tool: "t3_thread_send", | ||
| 1597 | }); | ||
| 1598 | }); | ||
| 1599 | |||
| 1600 | it.each([ | ||
| 1601 | { _meta: { claudeCode: { toolName: "mcp__weather__get_weather" } } }, | ||
| 1602 | { _meta: { toolName: "mcp::weather::get_weather", serverId: "weather" } }, | ||
| 1603 | ])("recovers external MCP identity from provider metadata", (metadata) => { | ||
| 1604 | const toolCall = toolCallFromUpdate({ | ||
| 1605 | sessionUpdate: "tool_call", | ||
| 1606 | toolCallId: "weather-call", | ||
| 1607 | title: "Checking the forecast", | ||
| 1608 | status: "in_progress", | ||
| 1609 | ...metadata, | ||
| 1610 | }); | ||
| 1611 | expect(extractMcpToolCallIdentity(toolCall)).toEqual({ | ||
| 1612 | server: "weather", | ||
| 1613 | tool: "get_weather", | ||
| 1614 | }); | ||
| 1615 | }); | ||
| 1616 | |||
| 1617 | it.each([ | ||
| 1618 | { kind: "execute", toolName: "developer__shell", extensionName: "developer" }, | ||
| 1619 | { kind: "edit", toolName: "developer__edit", extensionName: "developer" }, | ||
| 1620 | { kind: "other", toolName: "weather__get_weather", extensionName: "weather" }, | ||
| 1621 | ] as const)( | ||
| 1622 | "leaves goose $toolName unclassified so built-ins keep their projection", | ||
| 1623 | ({ kind, toolName, extensionName }) => { | ||
| 1624 | // goose tags built-in extensions exactly like user MCP servers. | ||
| 1625 | const toolCall = toolCallFromUpdate({ | ||
| 1626 | sessionUpdate: "tool_call", | ||
| 1627 | toolCallId: "goose-builtin", | ||
| 1628 | kind, | ||
| 1629 | title: "developer: shell", | ||
| 1630 | status: "in_progress", | ||
| 1631 | _meta: { goose: { toolCall: { toolName, extensionName } } }, | ||
| 1632 | }); | ||
| 1633 | expect(extractMcpToolCallIdentity(toolCall)).toBeUndefined(); | ||
| 1634 | }, | ||
| 1635 | ); | ||
| 1636 | |||
| 1637 | it("uses the asserted server instead of a misleading T3 title", () => { | ||
| 1638 | const toolCall = toolCallFromUpdate({ | ||
| 1639 | sessionUpdate: "tool_call", | ||
| 1640 | toolCallId: "foreign-1", | ||
| 1641 | kind: "other", | ||
| 1642 | title: "t3-code_delegate_task", | ||
| 1643 | status: "pending", | ||
| 1644 | _meta: { toolName: "delegate_task", serverId: "other-orchestrator" }, | ||
| 1645 | }); | ||
| 1646 | |||
| 1647 | expect(extractMcpToolCallIdentity(toolCall)).toEqual({ | ||
| 1648 | server: "other-orchestrator", | ||
| 1649 | tool: "delegate_task", | ||
| 1650 | }); | ||
| 1651 | }); | ||
| 1652 | |||
| 1653 | it("does not brand path-like or unknown-tool titles", () => { | ||
| 1654 | for (const title of ["t3-code/README.md", "t3-code_not_a_real_tool"]) { | ||
| 1655 | const toolCall = toolCallFromUpdate({ | ||
| 1656 | sessionUpdate: "tool_call", | ||
| 1657 | toolCallId: "path-1", | ||
| 1658 | kind: "other", | ||
| 1659 | title, | ||
| 1660 | status: "pending", | ||
| 1661 | }); | ||
| 1662 | expect(extractMcpToolCallIdentity(toolCall), title).toBeUndefined(); | ||
| 1663 | } | ||
| 1664 | }); | ||
| 1665 | |||
| 1666 | it("leaves ordinary execute calls unidentified", () => { | ||
| 1667 | const toolCall = toolCallFromUpdate({ | ||
| 1668 | sessionUpdate: "tool_call", | ||
| 1669 | toolCallId: "exec-2", | ||
| 1670 | kind: "execute", | ||
| 1671 | title: "cat package.json", | ||
| 1672 | status: "in_progress", | ||
| 1673 | rawInput: { command: "cat package.json" }, | ||
| 1674 | }); | ||
| 1675 | |||
| 1676 | expect(extractMcpToolCallIdentity(toolCall)).toBeUndefined(); | ||
| 1677 | expect( | ||
| 1678 | extractMcpToolCallIdentity(toolCall, { embeddedTerminalCommands: ["bash -lc ls"] }), | ||
| 1679 | ).toBeUndefined(); | ||
| 1680 | }); | ||
| 1681 | |||
| 1682 | it("does not treat generic raw server and tool fields as MCP provenance", () => { | ||
| 1683 | const toolCall = toolCallFromUpdate({ | ||
| 1684 | sessionUpdate: "tool_call", | ||
| 1685 | toolCallId: "generic-1", | ||
| 1686 | kind: "other", | ||
| 1687 | title: "Deploy", | ||
| 1688 | status: "pending", | ||
| 1689 | rawInput: { server: "production", tool: "deploy" }, | ||
| 1690 | }); | ||
| 1691 | |||
| 1692 | expect(extractMcpToolCallIdentity(toolCall)).toBeUndefined(); | ||
| 1693 | }); | ||
| 1694 | }); | ||
| 1695 | |||
| 1696 | describe("embeddedTerminalIdsFromSessionUpdate", () => { | ||
| 1697 | it("collects terminal ids from tool_call content before the text rewrite", () => { | ||
| 1698 | expect( | ||
| 1699 | embeddedTerminalIdsFromSessionUpdate({ | ||
| 1700 | sessionId: "session-1", | ||
| 1701 | update: { | ||
| 1702 | sessionUpdate: "tool_call_update", | ||
| 1703 | toolCallId: "call_1", | ||
| 1704 | status: "in_progress", | ||
| 1705 | content: [ | ||
| 1706 | { type: "terminal", terminalId: "t3-term-9" }, | ||
| 1707 | { type: "content", content: { type: "text", text: "noise" } }, | ||
| 1708 | ], | ||
| 1709 | }, | ||
| 1710 | }), | ||
| 1711 | ).toEqual({ toolCallId: "call_1", terminalIds: ["t3-term-9"] }); | ||
| 1712 | expect( | ||
| 1713 | embeddedTerminalIdsFromSessionUpdate({ | ||
| 1714 | sessionId: "session-1", | ||
| 1715 | update: { | ||
| 1716 | sessionUpdate: "tool_call", | ||
| 1717 | toolCallId: "call_2", | ||
| 1718 | title: "Tool", | ||
| 1719 | status: "pending", | ||
| 1720 | }, | ||
| 1721 | }), | ||
| 1722 | ).toBeUndefined(); | ||
| 1723 | }); | ||
| 1724 | }); | ||
packages/provider-acp/src/server/runtimeModel.ts created+1644| ... | @@ -0,0 +1,1644 @@ | ||
| 1 | import * as Clock from "effect/Clock"; | ||
| 2 | import * as Duration from "effect/Duration"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Option from "effect/Option"; | ||
| 5 | import * as Ref from "effect/Ref"; | ||
| 6 | import * as Schema from "effect/Schema"; | ||
| 7 | import * as EffectAcpSchema from "effect-acp/compat"; | ||
| 8 | import * as EffectAcpSchemaV1 from "effect-acp/schema-v1"; | ||
| 9 | import { | ||
| 10 | deriveToolActivityPresentation, | ||
| 11 | mergeToolActivityData, | ||
| 12 | } from "@t3tools/shared/toolActivity"; | ||
| 13 | import { T3_MCP_TOOL_NAMES } from "@t3tools/shared/t3McpToolPresentation"; | ||
| 14 | import type { | ||
| 15 | OrchestrationV2ProviderThreadNativeMetadata, | ||
| 16 | ThreadTokenUsageSnapshot, | ||
| 17 | ToolLifecycleItemType, | ||
| 18 | } from "@t3tools/contracts"; | ||
| 19 | |||
| 20 | function isRecord(value: unknown): value is Record<string, unknown> { | ||
| 21 | return typeof value === "object" && value !== null && !Array.isArray(value); | ||
| 22 | } | ||
| 23 | |||
| 24 | // Guards for the untyped `initialize._meta` states some agents (Grok) advertise. | ||
| 25 | // Modes were removed from ACP v2, so the v1 wire schema is the source of truth. | ||
| 26 | const isSessionModelState = Schema.is(EffectAcpSchema.SessionModelState); | ||
| 27 | const isSessionModeState = Schema.is(EffectAcpSchemaV1.SessionModeState); | ||
| 28 | |||
| 29 | export interface AcpSessionMode { | ||
| 30 | readonly id: string; | ||
| 31 | readonly name: string; | ||
| 32 | readonly description?: string; | ||
| 33 | } | ||
| 34 | |||
| 35 | export interface AcpSessionModeState { | ||
| 36 | readonly currentModeId: string; | ||
| 37 | readonly availableModes: ReadonlyArray<AcpSessionMode>; | ||
| 38 | } | ||
| 39 | |||
| 40 | export interface AcpToolCallState { | ||
| 41 | readonly toolCallId: string; | ||
| 42 | readonly kind?: string; | ||
| 43 | readonly title?: string; | ||
| 44 | readonly status?: "pending" | "inProgress" | "requiresAction" | "completed" | "failed"; | ||
| 45 | readonly command?: string; | ||
| 46 | readonly detail?: string; | ||
| 47 | readonly data: Record<string, unknown>; | ||
| 48 | } | ||
| 49 | |||
| 50 | export interface AcpAgentTerminalState { | ||
| 51 | readonly command?: string; | ||
| 52 | readonly cwd?: string; | ||
| 53 | readonly output: string; | ||
| 54 | readonly exitStatus?: EffectAcpSchema.TerminalExitStatus; | ||
| 55 | /** Incomplete trailing UTF-8 bytes retained until the next output update. */ | ||
| 56 | readonly pendingOutputBytes?: Uint8Array; | ||
| 57 | } | ||
| 58 | |||
| 59 | type AcpAgentTerminalUpdate = Extract< | ||
| 60 | EffectAcpSchema.SessionUpdate, | ||
| 61 | { readonly sessionUpdate: "terminal_update" | "terminal_output_chunk" } | ||
| 62 | >; | ||
| 63 | |||
| 64 | const MAX_ACP_TERMINAL_OUTPUT_BYTES = 16 * 1024 * 1024; | ||
| 65 | const MAX_ACP_TERMINAL_FRAME_BYTES = MAX_ACP_TERMINAL_OUTPUT_BYTES; | ||
| 66 | |||
| 67 | function isBase64DataCode(code: number): boolean { | ||
| 68 | return ( | ||
| 69 | (code >= 65 && code <= 90) || | ||
| 70 | (code >= 97 && code <= 122) || | ||
| 71 | (code >= 48 && code <= 57) || | ||
| 72 | code === 43 || | ||
| 73 | code === 47 | ||
| 74 | ); | ||
| 75 | } | ||
| 76 | |||
| 77 | /** | ||
| 78 | * Decodes one bounded base64 terminal frame, or `undefined` when the frame is | ||
| 79 | * malformed or oversized. Invalid frames are dropped rather than failed so one | ||
| 80 | * misbehaving agent frame cannot take down session-update handling. | ||
| 81 | */ | ||
| 82 | function decodeBase64TerminalFrame(encoded: string): Uint8Array | undefined { | ||
| 83 | if (encoded.length > Math.ceil(MAX_ACP_TERMINAL_FRAME_BYTES / 3) * 4) { | ||
| 84 | return undefined; | ||
| 85 | } | ||
| 86 | const padding = encoded.endsWith("==") ? 2 : encoded.endsWith("=") ? 1 : 0; | ||
| 87 | const dataLength = encoded.length - padding; | ||
| 88 | if ( | ||
| 89 | encoded.length % 4 === 1 || | ||
| 90 | (padding > 0 && encoded.length % 4 !== 0) || | ||
| 91 | (padding === 1 && dataLength % 4 !== 3) || | ||
| 92 | (padding === 2 && dataLength % 4 !== 2) | ||
| 93 | ) { | ||
| 94 | return undefined; | ||
| 95 | } | ||
| 96 | for (let index = 0; index < dataLength; index += 1) { | ||
| 97 | if (!isBase64DataCode(encoded.charCodeAt(index))) return undefined; | ||
| 98 | } | ||
| 99 | const decoded = Buffer.from(encoded, "base64"); | ||
| 100 | return decoded.byteLength <= MAX_ACP_TERMINAL_FRAME_BYTES ? decoded : undefined; | ||
| 101 | } | ||
| 102 | |||
| 103 | function trailingIncompleteUtf8Start(bytes: Uint8Array): number { | ||
| 104 | if (bytes.length === 0) return bytes.length; | ||
| 105 | let leadIndex = bytes.length - 1; | ||
| 106 | while (leadIndex >= 0 && (bytes[leadIndex]! & 0xc0) === 0x80) { | ||
| 107 | leadIndex -= 1; | ||
| 108 | } | ||
| 109 | if (leadIndex < 0) return bytes.length; | ||
| 110 | const lead = bytes[leadIndex]!; | ||
| 111 | const expectedLength = | ||
| 112 | lead >= 0xc2 && lead <= 0xdf | ||
| 113 | ? 2 | ||
| 114 | : lead >= 0xe0 && lead <= 0xef | ||
| 115 | ? 3 | ||
| 116 | : lead >= 0xf0 && lead <= 0xf4 | ||
| 117 | ? 4 | ||
| 118 | : 1; | ||
| 119 | return bytes.length - leadIndex < expectedLength ? leadIndex : bytes.length; | ||
| 120 | } | ||
| 121 | |||
| 122 | function decodeTerminalOutputChunk( | ||
| 123 | pending: Uint8Array | undefined, | ||
| 124 | encoded: string, | ||
| 125 | ): { readonly text: string; readonly pendingOutputBytes?: Uint8Array } | undefined { | ||
| 126 | const frame = decodeBase64TerminalFrame(encoded); | ||
| 127 | if (frame === undefined) return undefined; | ||
| 128 | const bytes = Buffer.concat([...(pending === undefined ? [] : [Buffer.from(pending)]), frame]); | ||
| 129 | const completeEnd = trailingIncompleteUtf8Start(bytes); | ||
| 130 | const text = bytes.subarray(0, completeEnd).toString("utf8"); | ||
| 131 | return completeEnd === bytes.length | ||
| 132 | ? { text } | ||
| 133 | : { text, pendingOutputBytes: Uint8Array.from(bytes.subarray(completeEnd)) }; | ||
| 134 | } | ||
| 135 | |||
| 136 | function truncateTerminalOutput(output: string): string { | ||
| 137 | const bytes = Buffer.from(output, "utf8"); | ||
| 138 | if (bytes.length <= MAX_ACP_TERMINAL_OUTPUT_BYTES) return output; | ||
| 139 | let start = bytes.length - MAX_ACP_TERMINAL_OUTPUT_BYTES; | ||
| 140 | while (start < bytes.length && (bytes[start]! & 0xc0) === 0x80) start += 1; | ||
| 141 | return bytes.subarray(start).toString("utf8"); | ||
| 142 | } | ||
| 143 | |||
| 144 | /** Applies an ACP v2 agent-owned terminal snapshot or chunk to display state. */ | ||
| 145 | export function applyAcpAgentTerminalUpdate( | ||
| 146 | previous: AcpAgentTerminalState | undefined, | ||
| 147 | update: AcpAgentTerminalUpdate, | ||
| 148 | ): AcpAgentTerminalState { | ||
| 149 | const current = previous ?? { output: "" }; | ||
| 150 | if (update.sessionUpdate === "terminal_output_chunk") { | ||
| 151 | const decoded = decodeTerminalOutputChunk(current.pendingOutputBytes, update.data); | ||
| 152 | if (decoded === undefined) return current; | ||
| 153 | const { pendingOutputBytes: _pendingOutputBytes, ...withoutPendingOutputBytes } = current; | ||
| 154 | return { | ||
| 155 | ...withoutPendingOutputBytes, | ||
| 156 | output: truncateTerminalOutput(`${current.output}${decoded.text}`), | ||
| 157 | ...(decoded.pendingOutputBytes === undefined | ||
| 158 | ? {} | ||
| 159 | : { pendingOutputBytes: decoded.pendingOutputBytes }), | ||
| 160 | }; | ||
| 161 | } | ||
| 162 | let next: AcpAgentTerminalState = current; | ||
| 163 | if (update.command === null) { | ||
| 164 | const { command: _command, ...withoutCommand } = next; | ||
| 165 | next = withoutCommand; | ||
| 166 | } else if (update.command !== undefined) { | ||
| 167 | next = { ...next, command: update.command }; | ||
| 168 | } | ||
| 169 | if (update.cwd === null) { | ||
| 170 | const { cwd: _cwd, ...withoutCwd } = next; | ||
| 171 | next = withoutCwd; | ||
| 172 | } else if (update.cwd !== undefined) { | ||
| 173 | next = { ...next, cwd: update.cwd }; | ||
| 174 | } | ||
| 175 | if (update.output !== undefined) { | ||
| 176 | const decoded = | ||
| 177 | update.output === null | ||
| 178 | ? { text: "" } | ||
| 179 | : decodeTerminalOutputChunk(undefined, update.output.data); | ||
| 180 | if (decoded !== undefined) { | ||
| 181 | const { pendingOutputBytes: _pendingOutputBytes, ...withoutPendingOutputBytes } = next; | ||
| 182 | next = { | ||
| 183 | ...withoutPendingOutputBytes, | ||
| 184 | output: truncateTerminalOutput(decoded.text), | ||
| 185 | ...(decoded.pendingOutputBytes === undefined | ||
| 186 | ? {} | ||
| 187 | : { pendingOutputBytes: decoded.pendingOutputBytes }), | ||
| 188 | }; | ||
| 189 | } | ||
| 190 | } | ||
| 191 | if (update.exitStatus === null) { | ||
| 192 | const { exitStatus: _exitStatus, ...withoutExitStatus } = next; | ||
| 193 | next = withoutExitStatus; | ||
| 194 | } else if (update.exitStatus !== undefined) { | ||
| 195 | const pendingText = | ||
| 196 | next.pendingOutputBytes === undefined | ||
| 197 | ? "" | ||
| 198 | : Buffer.from(next.pendingOutputBytes).toString("utf8"); | ||
| 199 | const { pendingOutputBytes: _pendingOutputBytes, ...withoutPendingOutputBytes } = next; | ||
| 200 | next = { | ||
| 201 | ...withoutPendingOutputBytes, | ||
| 202 | output: truncateTerminalOutput(`${next.output}${pendingText}`), | ||
| 203 | exitStatus: update.exitStatus, | ||
| 204 | }; | ||
| 205 | } | ||
| 206 | return next; | ||
| 207 | } | ||
| 208 | |||
| 209 | export type AcpPlanUpdate = | ||
| 210 | | { | ||
| 211 | readonly nativePlanId: string; | ||
| 212 | readonly kind: "items"; | ||
| 213 | readonly explanation?: string | null; | ||
| 214 | readonly plan: ReadonlyArray<{ | ||
| 215 | readonly step: string; | ||
| 216 | readonly status: "pending" | "inProgress" | "completed"; | ||
| 217 | }>; | ||
| 218 | } | ||
| 219 | | { readonly nativePlanId: string; readonly kind: "markdown"; readonly markdown: string } | ||
| 220 | | { readonly nativePlanId: string; readonly kind: "file"; readonly uri: string } | ||
| 221 | | { readonly nativePlanId: string; readonly kind: "unknown"; readonly contentType: string } | ||
| 222 | | { readonly nativePlanId: string; readonly kind: "removed" }; | ||
| 223 | |||
| 224 | export interface AcpPermissionRequest { | ||
| 225 | readonly kind: string | "unknown"; | ||
| 226 | readonly detail?: string; | ||
| 227 | readonly toolCall?: AcpToolCallState; | ||
| 228 | } | ||
| 229 | |||
| 230 | export type AcpParsedSessionEvent = | ||
| 231 | | { | ||
| 232 | readonly _tag: "ModeChanged"; | ||
| 233 | readonly modeId: string; | ||
| 234 | } | ||
| 235 | | { | ||
| 236 | readonly _tag: "AvailableCommandsUpdated"; | ||
| 237 | readonly availableCommands: ReadonlyArray<EffectAcpSchema.AvailableCommand>; | ||
| 238 | readonly rawPayload: unknown; | ||
| 239 | } | ||
| 240 | | { | ||
| 241 | readonly _tag: "ConfigOptionsUpdated"; | ||
| 242 | readonly configOptions: ReadonlyArray<EffectAcpSchema.SessionConfigOption>; | ||
| 243 | readonly rawPayload: unknown; | ||
| 244 | } | ||
| 245 | | { | ||
| 246 | readonly _tag: "AssistantItemStarted"; | ||
| 247 | readonly itemId: string; | ||
| 248 | } | ||
| 249 | | { | ||
| 250 | readonly _tag: "AssistantItemCompleted"; | ||
| 251 | readonly itemId: string; | ||
| 252 | } | ||
| 253 | | { | ||
| 254 | readonly _tag: "PlanUpdated"; | ||
| 255 | readonly payload: AcpPlanUpdate; | ||
| 256 | readonly rawPayload: unknown; | ||
| 257 | } | ||
| 258 | | { | ||
| 259 | readonly _tag: "ToolCallUpdated"; | ||
| 260 | readonly toolCall: AcpToolCallState; | ||
| 261 | readonly rawPayload: unknown; | ||
| 262 | } | ||
| 263 | | { | ||
| 264 | readonly _tag: "ContentDelta"; | ||
| 265 | readonly itemId?: string; | ||
| 266 | readonly messageId?: string; | ||
| 267 | readonly text: string; | ||
| 268 | readonly rawPayload: unknown; | ||
| 269 | } | ||
| 270 | | { | ||
| 271 | readonly _tag: "ThoughtDelta"; | ||
| 272 | readonly text: string; | ||
| 273 | readonly rawPayload: unknown; | ||
| 274 | } | ||
| 275 | | { | ||
| 276 | readonly _tag: "UsageUpdated"; | ||
| 277 | readonly usage: ThreadTokenUsageSnapshot; | ||
| 278 | readonly rawPayload: unknown; | ||
| 279 | } | ||
| 280 | | { | ||
| 281 | readonly _tag: "SessionInfoUpdated"; | ||
| 282 | readonly metadata: OrchestrationV2ProviderThreadNativeMetadata; | ||
| 283 | readonly rawPayload: unknown; | ||
| 284 | } | ||
| 285 | | { | ||
| 286 | readonly _tag: "UnknownUpdate"; | ||
| 287 | readonly updateType: string; | ||
| 288 | readonly rawPayload: unknown; | ||
| 289 | }; | ||
| 290 | |||
| 291 | const boundedContentMetadata = (value: string | null | undefined, maximumLength: number): string => | ||
| 292 | value?.trim().slice(0, maximumLength) ?? ""; | ||
| 293 | |||
| 294 | const MAX_ACP_CONTENT_TEXT_LENGTH = 65_536; | ||
| 295 | |||
| 296 | // Preserve whitespace in streamed model output while keeping one provider frame bounded. | ||
| 297 | const boundedContentText = (value: string): string => value.slice(0, MAX_ACP_CONTENT_TEXT_LENGTH); | ||
| 298 | |||
| 299 | const boundedContentUri = (value: string | null | undefined): string => { | ||
| 300 | const uri = boundedContentMetadata(value, 4_096); | ||
| 301 | return uri.toLowerCase().startsWith("data:") ? "" : uri; | ||
| 302 | }; | ||
| 303 | |||
| 304 | /** Convert every ACP output block into bounded, displayable text without retaining base64 data. */ | ||
| 305 | export function acpContentBlockDisplayText( | ||
| 306 | content: EffectAcpSchema.ContentBlock, | ||
| 307 | ): string | undefined { | ||
| 308 | switch (content.type) { | ||
| 309 | case "text": | ||
| 310 | return boundedContentText(content.text); | ||
| 311 | case "resource_link": { | ||
| 312 | const label = | ||
| 313 | boundedContentMetadata(content.title, 512) || | ||
| 314 | boundedContentMetadata(content.name, 512) || | ||
| 315 | "resource"; | ||
| 316 | const description = boundedContentMetadata(content.description, 2_048); | ||
| 317 | const uri = boundedContentUri(content.uri); | ||
| 318 | return `${description ? `${label}: ${description}` : label}${uri ? `\n${uri}` : ""}`; | ||
| 319 | } | ||
| 320 | case "resource": { | ||
| 321 | if ("text" in content.resource) { | ||
| 322 | return boundedContentText(content.resource.text); | ||
| 323 | } | ||
| 324 | const mimeType = boundedContentMetadata(content.resource.mimeType, 256); | ||
| 325 | const uri = boundedContentUri(content.resource.uri); | ||
| 326 | return `[ACP binary resource${mimeType ? ` (${mimeType})` : ""}${uri ? `: ${uri}` : ""}]`; | ||
| 327 | } | ||
| 328 | case "image": { | ||
| 329 | const mimeType = boundedContentMetadata(content.mimeType, 256) || "unknown type"; | ||
| 330 | const uri = boundedContentUri(content.uri); | ||
| 331 | return `[ACP image (${mimeType})${uri ? `: ${uri}` : ""}]`; | ||
| 332 | } | ||
| 333 | case "audio": { | ||
| 334 | const mimeType = boundedContentMetadata(content.mimeType, 256) || "unknown type"; | ||
| 335 | return `[ACP audio (${mimeType})]`; | ||
| 336 | } | ||
| 337 | case "_t3_unknown": | ||
| 338 | return `[Unsupported ACP content: ${boundedContentMetadata(content.originalType, 128) || "unknown"}]`; | ||
| 339 | } | ||
| 340 | } | ||
| 341 | |||
| 342 | function sanitizeAcpToolCallContent( | ||
| 343 | content: ReadonlyArray<EffectAcpSchema.ToolCallContent>, | ||
| 344 | ): ReadonlyArray<EffectAcpSchema.ToolCallContent> { | ||
| 345 | return content.map((entry) => { | ||
| 346 | switch (entry.type) { | ||
| 347 | case "content": | ||
| 348 | return { | ||
| 349 | type: "content", | ||
| 350 | content: { | ||
| 351 | type: "text", | ||
| 352 | text: acpContentBlockDisplayText(entry.content) ?? "", | ||
| 353 | }, | ||
| 354 | }; | ||
| 355 | case "diff": | ||
| 356 | if ("changes" in entry) { | ||
| 357 | return { | ||
| 358 | type: "diff", | ||
| 359 | changes: entry.changes, | ||
| 360 | ...(entry.patch === undefined ? {} : { patch: entry.patch }), | ||
| 361 | }; | ||
| 362 | } | ||
| 363 | return { | ||
| 364 | type: "diff", | ||
| 365 | path: entry.path, | ||
| 366 | ...(entry.oldText !== undefined ? { oldText: entry.oldText } : {}), | ||
| 367 | newText: entry.newText, | ||
| 368 | }; | ||
| 369 | case "terminal": | ||
| 370 | return { type: "terminal", terminalId: entry.terminalId }; | ||
| 371 | case "_t3_unknown": | ||
| 372 | return { | ||
| 373 | type: "_t3_unknown", | ||
| 374 | originalType: boundedContentMetadata(entry.originalType, 128) || "unknown", | ||
| 375 | raw: null, | ||
| 376 | }; | ||
| 377 | } | ||
| 378 | }); | ||
| 379 | } | ||
| 380 | |||
| 381 | type AcpSessionSetupResponse = | ||
| 382 | | EffectAcpSchema.ForkSessionResponse | ||
| 383 | | EffectAcpSchema.LoadSessionResponse | ||
| 384 | | EffectAcpSchema.NewSessionResponse | ||
| 385 | | EffectAcpSchema.ResumeSessionResponse; | ||
| 386 | |||
| 387 | type AcpToolCallUpdate = Extract< | ||
| 388 | EffectAcpSchema.SessionNotification["update"], | ||
| 389 | { readonly sessionUpdate: "tool_call" | "tool_call_update" } | ||
| 390 | >; | ||
| 391 | |||
| 392 | export function extractModelConfigId(sessionResponse: AcpSessionSetupResponse): string | undefined { | ||
| 393 | const configOptions = sessionResponse.configOptions; | ||
| 394 | if (!configOptions) return undefined; | ||
| 395 | for (const opt of configOptions) { | ||
| 396 | if (opt.category === "model" && opt.id.trim().length > 0) { | ||
| 397 | return opt.id.trim(); | ||
| 398 | } | ||
| 399 | } | ||
| 400 | return undefined; | ||
| 401 | } | ||
| 402 | |||
| 403 | export function findSessionConfigOption( | ||
| 404 | configOptions: ReadonlyArray<EffectAcpSchema.SessionConfigOption> | null | undefined, | ||
| 405 | configId: string, | ||
| 406 | ): EffectAcpSchema.SessionConfigOption | undefined { | ||
| 407 | if (!configOptions) { | ||
| 408 | return undefined; | ||
| 409 | } | ||
| 410 | const normalizedConfigId = configId.trim(); | ||
| 411 | if (!normalizedConfigId) { | ||
| 412 | return undefined; | ||
| 413 | } | ||
| 414 | return configOptions.find((option) => option.id.trim() === normalizedConfigId); | ||
| 415 | } | ||
| 416 | |||
| 417 | export function collectSessionConfigOptionValues( | ||
| 418 | configOption: EffectAcpSchema.SessionConfigOption, | ||
| 419 | ): ReadonlyArray<string> { | ||
| 420 | if (configOption.type !== "select") { | ||
| 421 | return []; | ||
| 422 | } | ||
| 423 | return configOption.options.flatMap((entry) => | ||
| 424 | "value" in entry ? [entry.value] : entry.options.map((option) => option.value), | ||
| 425 | ); | ||
| 426 | } | ||
| 427 | |||
| 428 | export function parseSessionModeState( | ||
| 429 | sessionResponse: AcpSessionSetupResponse, | ||
| 430 | ): AcpSessionModeState | undefined { | ||
| 431 | const modeConfig = sessionResponse.configOptions?.find( | ||
| 432 | (option) => option.category === "mode" && option.type === "select", | ||
| 433 | ); | ||
| 434 | const modes = | ||
| 435 | sessionResponse.modes ?? | ||
| 436 | (modeConfig?.type === "select" | ||
| 437 | ? { | ||
| 438 | currentModeId: modeConfig.currentValue, | ||
| 439 | availableModes: modeConfig.options | ||
| 440 | .flatMap((option) => ("options" in option ? option.options : [option])) | ||
| 441 | .map((option) => ({ | ||
| 442 | id: option.value, | ||
| 443 | name: option.name, | ||
| 444 | description: option.description, | ||
| 445 | })), | ||
| 446 | } | ||
| 447 | : undefined); | ||
| 448 | if (!modes) return undefined; | ||
| 449 | const currentModeId = modes.currentModeId.trim(); | ||
| 450 | if (!currentModeId) { | ||
| 451 | return undefined; | ||
| 452 | } | ||
| 453 | const availableModes: Array<AcpSessionMode> = []; | ||
| 454 | for (const mode of modes.availableModes) { | ||
| 455 | const id = mode.id.trim(); | ||
| 456 | const name = mode.name.trim(); | ||
| 457 | if (!id || !name) { | ||
| 458 | continue; | ||
| 459 | } | ||
| 460 | const description = mode.description?.trim() || undefined; | ||
| 461 | availableModes.push( | ||
| 462 | description !== undefined | ||
| 463 | ? ({ id, name, description } satisfies AcpSessionMode) | ||
| 464 | : ({ id, name } satisfies AcpSessionMode), | ||
| 465 | ); | ||
| 466 | } | ||
| 467 | if (availableModes.length === 0) { | ||
| 468 | return undefined; | ||
| 469 | } | ||
| 470 | return { | ||
| 471 | currentModeId, | ||
| 472 | availableModes, | ||
| 473 | }; | ||
| 474 | } | ||
| 475 | |||
| 476 | function normalizePlanStepStatus(raw: unknown): "pending" | "inProgress" | "completed" { | ||
| 477 | switch (raw) { | ||
| 478 | case "completed": | ||
| 479 | return "completed"; | ||
| 480 | case "in_progress": | ||
| 481 | case "inProgress": | ||
| 482 | return "inProgress"; | ||
| 483 | default: | ||
| 484 | return "pending"; | ||
| 485 | } | ||
| 486 | } | ||
| 487 | |||
| 488 | function normalizeToolCallStatus( | ||
| 489 | raw: unknown, | ||
| 490 | fallback?: "pending" | "inProgress" | "completed" | "failed", | ||
| 491 | ): "pending" | "inProgress" | "completed" | "failed" | undefined { | ||
| 492 | switch (raw) { | ||
| 493 | case "pending": | ||
| 494 | return "pending"; | ||
| 495 | case "in_progress": | ||
| 496 | case "inProgress": | ||
| 497 | return "inProgress"; | ||
| 498 | case "completed": | ||
| 499 | return "completed"; | ||
| 500 | case "failed": | ||
| 501 | return "failed"; | ||
| 502 | default: | ||
| 503 | return fallback; | ||
| 504 | } | ||
| 505 | } | ||
| 506 | |||
| 507 | function normalizeCommandValue(value: unknown): string | undefined { | ||
| 508 | if (typeof value === "string" && value.trim().length > 0) { | ||
| 509 | return value.trim(); | ||
| 510 | } | ||
| 511 | if (!Array.isArray(value)) { | ||
| 512 | return undefined; | ||
| 513 | } | ||
| 514 | const parts: Array<string> = []; | ||
| 515 | for (const entry of value) { | ||
| 516 | if (typeof entry === "string") { | ||
| 517 | const part = entry.trim(); | ||
| 518 | if (part.length > 0) { | ||
| 519 | parts.push(part); | ||
| 520 | } | ||
| 521 | } | ||
| 522 | } | ||
| 523 | return parts.length > 0 ? parts.join(" ") : undefined; | ||
| 524 | } | ||
| 525 | |||
| 526 | function extractCommandFromTitle(title: string | undefined): string | undefined { | ||
| 527 | if (!title) { | ||
| 528 | return undefined; | ||
| 529 | } | ||
| 530 | const match = /`([^`]+)`/.exec(title); | ||
| 531 | return match?.[1]?.trim() || undefined; | ||
| 532 | } | ||
| 533 | |||
| 534 | function extractToolCallCommand( | ||
| 535 | rawInput: unknown, | ||
| 536 | title: string | undefined, | ||
| 537 | kind: string | undefined, | ||
| 538 | ): string | undefined { | ||
| 539 | if (isRecord(rawInput)) { | ||
| 540 | const directCommand = normalizeCommandValue(rawInput.command); | ||
| 541 | if (directCommand) { | ||
| 542 | return directCommand; | ||
| 543 | } | ||
| 544 | const executable = typeof rawInput.executable === "string" ? rawInput.executable.trim() : ""; | ||
| 545 | const args = normalizeCommandValue(rawInput.args); | ||
| 546 | if (executable && args) { | ||
| 547 | return `${executable} ${args}`; | ||
| 548 | } | ||
| 549 | if (executable) { | ||
| 550 | return executable; | ||
| 551 | } | ||
| 552 | } | ||
| 553 | // Titles like `Read \`src/a.ts\`` are not commands. Only execute tools | ||
| 554 | // fall back to a backtick or bare title. | ||
| 555 | if (kind !== "execute") { | ||
| 556 | return undefined; | ||
| 557 | } | ||
| 558 | return extractCommandFromTitle(title); | ||
| 559 | } | ||
| 560 | |||
| 561 | function filePathFromToolValue(value: unknown): string | undefined { | ||
| 562 | if (typeof value !== "string") { | ||
| 563 | return undefined; | ||
| 564 | } | ||
| 565 | const trimmed = value.trim(); | ||
| 566 | return trimmed.length > 0 ? trimmed : undefined; | ||
| 567 | } | ||
| 568 | |||
| 569 | function locationsFromToolCallInput(input: { | ||
| 570 | readonly locations?: ReadonlyArray<EffectAcpSchema.ToolCallLocation> | null | undefined; | ||
| 571 | readonly content?: ReadonlyArray<EffectAcpSchema.ToolCallContent> | null | undefined; | ||
| 572 | readonly rawInput?: unknown; | ||
| 573 | readonly rawOutput?: unknown; | ||
| 574 | }): ReadonlyArray<EffectAcpSchema.ToolCallLocation> | undefined { | ||
| 575 | if (input.locations === null || input.locations?.length === 0) { | ||
| 576 | return []; | ||
| 577 | } | ||
| 578 | const locations: EffectAcpSchema.ToolCallLocation[] = []; | ||
| 579 | const seen = new Set<string>(); | ||
| 580 | const pushLocation = (location: EffectAcpSchema.ToolCallLocation) => { | ||
| 581 | const path = filePathFromToolValue(location.path); | ||
| 582 | if (!path || seen.has(path)) { | ||
| 583 | return; | ||
| 584 | } | ||
| 585 | seen.add(path); | ||
| 586 | locations.push({ ...location, path }); | ||
| 587 | }; | ||
| 588 | const pushPath = (value: unknown) => { | ||
| 589 | const path = filePathFromToolValue(value); | ||
| 590 | if (!path || seen.has(path)) { | ||
| 591 | return; | ||
| 592 | } | ||
| 593 | seen.add(path); | ||
| 594 | locations.push({ path }); | ||
| 595 | }; | ||
| 596 | |||
| 597 | if (input.locations) { | ||
| 598 | for (const location of input.locations) { | ||
| 599 | pushLocation(location); | ||
| 600 | } | ||
| 601 | } | ||
| 602 | if (input.content) { | ||
| 603 | for (const entry of input.content) { | ||
| 604 | if (entry.type === "diff" && "path" in entry) { | ||
| 605 | pushPath(entry.path); | ||
| 606 | } | ||
| 607 | } | ||
| 608 | } | ||
| 609 | if (isRecord(input.rawInput)) { | ||
| 610 | pushPath(input.rawInput.path); | ||
| 611 | pushPath(input.rawInput.filePath); | ||
| 612 | pushPath(input.rawInput.file_path); | ||
| 613 | } | ||
| 614 | if (isRecord(input.rawOutput)) { | ||
| 615 | pushPath(input.rawOutput.path); | ||
| 616 | pushPath(input.rawOutput.filePath); | ||
| 617 | pushPath(input.rawOutput.file_path); | ||
| 618 | } | ||
| 619 | |||
| 620 | return locations.length > 0 ? locations : undefined; | ||
| 621 | } | ||
| 622 | |||
| 623 | // Some ACP agents (observed with Grok's CLI) resend the ENTIRE accumulated tool-call | ||
| 624 | // output on every `tool_call_update` notification instead of a delta, so a redrawing | ||
| 625 | // terminal progress bar can balloon a single tool call to hundreds of KB per update at | ||
| 626 | // several updates per second. Cap what we retain/emit to a bounded tail so one busy tool | ||
| 627 | // call cannot flood runtime event ingestion. We always keep the tail: `tool_call_update` | ||
| 628 | // deltas routinely omit `kind`, so there is no reliable way to tell a redrawing terminal | ||
| 629 | // from another tool here, and the end is the useful part of any live-growing output. | ||
| 630 | const TOOL_CALL_CONTENT_MAX_CHARS = 8_000; | ||
| 631 | const TOOL_CALL_CONTENT_TRUNCATION_MARKER = "[Earlier output truncated]\n\n"; | ||
| 632 | |||
| 633 | function boundToolCallOutputText(text: string): string { | ||
| 634 | if (text.length <= TOOL_CALL_CONTENT_MAX_CHARS) { | ||
| 635 | return text; | ||
| 636 | } | ||
| 637 | const tail = text.slice(text.length - TOOL_CALL_CONTENT_MAX_CHARS); | ||
| 638 | return `${TOOL_CALL_CONTENT_TRUNCATION_MARKER}${tail}`; | ||
| 639 | } | ||
| 640 | |||
| 641 | const RAW_OUTPUT_TEXT_FIELDS = ["content", "stdout", "stderr", "output"] as const; | ||
| 642 | |||
| 643 | // `rawOutput` is provider-defined and, for terminal-shaped tools, mirrors the same | ||
| 644 | // cumulative text-growth problem as `content` (see the comment above). Bound its known | ||
| 645 | // text-bearing fields the same way so a chatty provider cannot smuggle unbounded output | ||
| 646 | // through this field instead. | ||
| 647 | function boundToolCallRawOutput(rawOutput: unknown): unknown { | ||
| 648 | if (!isRecord(rawOutput)) { | ||
| 649 | return rawOutput; | ||
| 650 | } | ||
| 651 | let changed = false; | ||
| 652 | const bounded: Record<string, unknown> = { ...rawOutput }; | ||
| 653 | for (const field of RAW_OUTPUT_TEXT_FIELDS) { | ||
| 654 | const value = rawOutput[field]; | ||
| 655 | if (typeof value === "string" && value.length > TOOL_CALL_CONTENT_MAX_CHARS) { | ||
| 656 | bounded[field] = boundToolCallOutputText(value); | ||
| 657 | changed = true; | ||
| 658 | } | ||
| 659 | } | ||
| 660 | return changed ? bounded : rawOutput; | ||
| 661 | } | ||
| 662 | |||
| 663 | interface ExtractedToolCallContent { | ||
| 664 | readonly text: string | undefined; | ||
| 665 | readonly content: ReadonlyArray<EffectAcpSchema.ToolCallContent> | undefined; | ||
| 666 | } | ||
| 667 | |||
| 668 | function toolCallContentText(entry: EffectAcpSchema.ToolCallContent): string | undefined { | ||
| 669 | if (entry.type !== "content" || entry.content.type !== "text") { | ||
| 670 | return undefined; | ||
| 671 | } | ||
| 672 | return entry.content.text; | ||
| 673 | } | ||
| 674 | |||
| 675 | // Trim is used for display `text`, so whitespace-only (or whitespace-padded) entries never | ||
| 676 | // contribute to `chunks` and used to take the early returns with the original array. Bound | ||
| 677 | // each text entry independently so those paths cannot persist an unbounded terminal buffer | ||
| 678 | // on `toolCall.data.content` / `rawPayload`. | ||
| 679 | function boundToolCallContentEntries( | ||
| 680 | content: ReadonlyArray<EffectAcpSchema.ToolCallContent>, | ||
| 681 | ): ReadonlyArray<EffectAcpSchema.ToolCallContent> { | ||
| 682 | let changed = false; | ||
| 683 | const bounded = content.map((entry) => { | ||
| 684 | const text = toolCallContentText(entry); | ||
| 685 | if (text === undefined || text.length <= TOOL_CALL_CONTENT_MAX_CHARS) { | ||
| 686 | return entry; | ||
| 687 | } | ||
| 688 | changed = true; | ||
| 689 | const trimmed = text.trim(); | ||
| 690 | return { | ||
| 691 | type: "content", | ||
| 692 | content: { | ||
| 693 | type: "text", | ||
| 694 | text: boundToolCallOutputText(trimmed.length > 0 ? trimmed : text), | ||
| 695 | }, | ||
| 696 | } as const; | ||
| 697 | }); | ||
| 698 | return changed ? bounded : content; | ||
| 699 | } | ||
| 700 | |||
| 701 | function extractTextContentFromToolCallContent( | ||
| 702 | content: ReadonlyArray<EffectAcpSchema.ToolCallContent> | null | undefined, | ||
| 703 | ): ExtractedToolCallContent { | ||
| 704 | if (!content) { | ||
| 705 | return { text: undefined, content: undefined }; | ||
| 706 | } | ||
| 707 | const chunks: Array<string> = []; | ||
| 708 | for (const entry of content) { | ||
| 709 | const text = toolCallContentText(entry)?.trim(); | ||
| 710 | if (text) { | ||
| 711 | chunks.push(text); | ||
| 712 | } | ||
| 713 | } | ||
| 714 | if (chunks.length === 0) { | ||
| 715 | return { text: undefined, content: boundToolCallContentEntries(content) }; | ||
| 716 | } | ||
| 717 | const joined = chunks.join("\n"); | ||
| 718 | if (joined.length <= TOOL_CALL_CONTENT_MAX_CHARS) { | ||
| 719 | return { text: joined, content: boundToolCallContentEntries(content) }; | ||
| 720 | } | ||
| 721 | const bounded = boundToolCallOutputText(joined); | ||
| 722 | const tail = joined.slice(joined.length - TOOL_CALL_CONTENT_MAX_CHARS); | ||
| 723 | return { | ||
| 724 | text: bounded, | ||
| 725 | content: distributeRetainedTailAcrossContent(content, tail), | ||
| 726 | }; | ||
| 727 | } | ||
| 728 | |||
| 729 | // Walk the original text entries from the joined tail window so a retained slice that | ||
| 730 | // spans entries around an image/diff stays on those entries. Non-text kinds keep their | ||
| 731 | // relative order; blank text entries are dropped; the truncation marker is prepended to | ||
| 732 | // the first remaining text entry. | ||
| 733 | function distributeRetainedTailAcrossContent( | ||
| 734 | content: ReadonlyArray<EffectAcpSchema.ToolCallContent>, | ||
| 735 | tail: string, | ||
| 736 | ): ReadonlyArray<EffectAcpSchema.ToolCallContent> { | ||
| 737 | const textRanges: Array< | ||
| 738 | | { | ||
| 739 | readonly start: number; | ||
| 740 | readonly end: number; | ||
| 741 | readonly text: string; | ||
| 742 | } | ||
| 743 | | undefined | ||
| 744 | > = Array.from({ length: content.length }); | ||
| 745 | let offset = 0; | ||
| 746 | let seenText = false; | ||
| 747 | for (const [index, entry] of content.entries()) { | ||
| 748 | const text = toolCallContentText(entry)?.trim(); | ||
| 749 | if (!text) { | ||
| 750 | continue; | ||
| 751 | } | ||
| 752 | if (seenText) { | ||
| 753 | offset += 1; | ||
| 754 | } | ||
| 755 | seenText = true; | ||
| 756 | const start = offset; | ||
| 757 | const end = offset + text.length; | ||
| 758 | textRanges[index] = { start, end, text }; | ||
| 759 | offset = end; | ||
| 760 | } | ||
| 761 | const tailStart = Math.max(0, offset - tail.length); | ||
| 762 | let markerPending = true; | ||
| 763 | return content.flatMap((entry, index) => { | ||
| 764 | if (toolCallContentText(entry) === undefined) { | ||
| 765 | return [entry]; | ||
| 766 | } | ||
| 767 | const range = textRanges[index]; | ||
| 768 | if (range === undefined) { | ||
| 769 | return []; | ||
| 770 | } | ||
| 771 | const overlapStart = Math.max(range.start, tailStart); | ||
| 772 | const overlapEnd = Math.min(range.end, offset); | ||
| 773 | if (overlapEnd <= overlapStart) { | ||
| 774 | return []; | ||
| 775 | } | ||
| 776 | let piece = range.text.slice(overlapStart - range.start, overlapEnd - range.start); | ||
| 777 | if (markerPending) { | ||
| 778 | piece = `${TOOL_CALL_CONTENT_TRUNCATION_MARKER}${piece}`; | ||
| 779 | markerPending = false; | ||
| 780 | } | ||
| 781 | return [{ type: "content", content: { type: "text", text: piece } } as const]; | ||
| 782 | }); | ||
| 783 | } | ||
| 784 | |||
| 785 | function normalizeToolKind(kind: unknown): string | undefined { | ||
| 786 | return typeof kind === "string" && kind.trim().length > 0 ? kind.trim() : undefined; | ||
| 787 | } | ||
| 788 | |||
| 789 | /** | ||
| 790 | * Map an ACP tool kind onto the canonical runtime item type used by the | ||
| 791 | * thread activity model. Unknown kinds fall back to a generic tool call. | ||
| 792 | */ | ||
| 793 | export function canonicalItemTypeFromAcpToolKind(kind: string | undefined): ToolLifecycleItemType { | ||
| 794 | switch (kind) { | ||
| 795 | case "read": | ||
| 796 | return "dynamic_tool_call"; | ||
| 797 | case "execute": | ||
| 798 | return "command_execution"; | ||
| 799 | case "edit": | ||
| 800 | case "delete": | ||
| 801 | case "move": | ||
| 802 | return "file_change"; | ||
| 803 | case "search": | ||
| 804 | case "fetch": | ||
| 805 | return "web_search"; | ||
| 806 | default: | ||
| 807 | return "dynamic_tool_call"; | ||
| 808 | } | ||
| 809 | } | ||
| 810 | |||
| 811 | function makeToolCallState( | ||
| 812 | input: { | ||
| 813 | readonly toolCallId: string; | ||
| 814 | readonly title?: string | null | undefined; | ||
| 815 | readonly kind?: EffectAcpSchema.ToolKind | null | undefined; | ||
| 816 | readonly status?: EffectAcpSchema.ToolCallStatus | null | undefined; | ||
| 817 | readonly rawInput?: unknown; | ||
| 818 | readonly rawOutput?: unknown; | ||
| 819 | readonly content?: ReadonlyArray<EffectAcpSchema.ToolCallContent> | null | undefined; | ||
| 820 | readonly locations?: ReadonlyArray<EffectAcpSchema.ToolCallLocation> | null | undefined; | ||
| 821 | readonly _meta?: unknown; | ||
| 822 | }, | ||
| 823 | options?: { | ||
| 824 | readonly fallbackStatus?: "pending" | "inProgress" | "completed" | "failed"; | ||
| 825 | }, | ||
| 826 | ): AcpToolCallState | undefined { | ||
| 827 | const toolCallId = input.toolCallId.trim(); | ||
| 828 | if (!toolCallId) { | ||
| 829 | return undefined; | ||
| 830 | } | ||
| 831 | const title = input.title?.trim() || undefined; | ||
| 832 | const kind = normalizeToolKind(input.kind); | ||
| 833 | const command = extractToolCallCommand(input.rawInput, title, kind); | ||
| 834 | const extractedContent = extractTextContentFromToolCallContent(input.content); | ||
| 835 | const textContent = extractedContent.text; | ||
| 836 | const normalizedTitle = | ||
| 837 | title && title.toLowerCase() !== "terminal" && title.toLowerCase() !== "tool call" | ||
| 838 | ? title | ||
| 839 | : undefined; | ||
| 840 | const data: Record<string, unknown> = { toolCallId }; | ||
| 841 | if (kind) { | ||
| 842 | data.kind = kind; | ||
| 843 | } | ||
| 844 | if (title) { | ||
| 845 | // The agent's verbatim title. Presentation summarizes `title` on the | ||
| 846 | // state (e.g. a command line becomes "Ran command"), so identity checks | ||
| 847 | // such as MCP-fallback detection need the raw value preserved here. | ||
| 848 | data.title = title; | ||
| 849 | } | ||
| 850 | if (command) { | ||
| 851 | data.command = command; | ||
| 852 | } | ||
| 853 | if (input.rawInput !== undefined) { | ||
| 854 | data.rawInput = input.rawInput; | ||
| 855 | } | ||
| 856 | if (isRecord(input._meta)) { | ||
| 857 | // Some agents identify MCP calls only here (goose `goose.toolCall`, | ||
| 858 | // qwen `toolName`/`serverId`, claude-acp `claudeCode.toolName`). | ||
| 859 | data.meta = input._meta; | ||
| 860 | } | ||
| 861 | if (input.rawOutput !== undefined) { | ||
| 862 | data.rawOutput = boundToolCallRawOutput(input.rawOutput); | ||
| 863 | } | ||
| 864 | if (input.content != null) { | ||
| 865 | data.content = sanitizeAcpToolCallContent(extractedContent.content ?? input.content); | ||
| 866 | } | ||
| 867 | const locations = locationsFromToolCallInput(input); | ||
| 868 | if (locations !== undefined) { | ||
| 869 | data.locations = locations; | ||
| 870 | } | ||
| 871 | if (isRecord(input._meta)) { | ||
| 872 | data.meta = input._meta; | ||
| 873 | } | ||
| 874 | const fallbackDetail = command ?? normalizedTitle ?? textContent; | ||
| 875 | const hasPresentationSeed = | ||
| 876 | title !== undefined || | ||
| 877 | kind !== undefined || | ||
| 878 | command !== undefined || | ||
| 879 | normalizedTitle !== undefined || | ||
| 880 | textContent !== undefined; | ||
| 881 | const presentation = hasPresentationSeed | ||
| 882 | ? deriveToolActivityPresentation({ | ||
| 883 | itemType: canonicalItemTypeFromAcpToolKind(kind), | ||
| 884 | title, | ||
| 885 | detail: fallbackDetail, | ||
| 886 | data, | ||
| 887 | fallbackSummary: title ?? "Tool", | ||
| 888 | }) | ||
| 889 | : undefined; | ||
| 890 | const status = normalizeToolCallStatus(input.status, options?.fallbackStatus); | ||
| 891 | return { | ||
| 892 | toolCallId, | ||
| 893 | ...(kind ? { kind } : {}), | ||
| 894 | ...(presentation?.summary ? { title: presentation.summary } : {}), | ||
| 895 | ...(status ? { status } : {}), | ||
| 896 | ...(command ? { command } : {}), | ||
| 897 | ...(presentation?.detail ? { detail: presentation.detail } : {}), | ||
| 898 | data, | ||
| 899 | }; | ||
| 900 | } | ||
| 901 | |||
| 902 | function parseTypedToolCallState( | ||
| 903 | event: AcpToolCallUpdate, | ||
| 904 | options?: { | ||
| 905 | readonly fallbackStatus?: "pending" | "inProgress" | "completed" | "failed"; | ||
| 906 | }, | ||
| 907 | ): AcpToolCallState | undefined { | ||
| 908 | return makeToolCallState( | ||
| 909 | { | ||
| 910 | toolCallId: event.toolCallId, | ||
| 911 | title: event.title, | ||
| 912 | kind: event.kind, | ||
| 913 | status: event.status, | ||
| 914 | rawInput: event.rawInput, | ||
| 915 | rawOutput: event.rawOutput, | ||
| 916 | content: event.content, | ||
| 917 | locations: event.locations, | ||
| 918 | _meta: event._meta, | ||
| 919 | }, | ||
| 920 | options, | ||
| 921 | ); | ||
| 922 | } | ||
| 923 | |||
| 924 | export function mergeToolCallState( | ||
| 925 | previous: AcpToolCallState | undefined, | ||
| 926 | next: AcpToolCallState, | ||
| 927 | ): AcpToolCallState { | ||
| 928 | const nextKind = next.kind ?? (typeof next.data.kind === "string" ? next.data.kind : undefined); | ||
| 929 | const kind = nextKind ?? previous?.kind; | ||
| 930 | const title = next.title ?? previous?.title; | ||
| 931 | const status = next.status ?? previous?.status; | ||
| 932 | const command = next.command ?? previous?.command; | ||
| 933 | const detail = next.detail ?? previous?.detail; | ||
| 934 | const data = mergeToolActivityData(previous?.data, next.data) ?? next.data; | ||
| 935 | return { | ||
| 936 | toolCallId: next.toolCallId, | ||
| 937 | ...(kind ? { kind } : {}), | ||
| 938 | ...(title ? { title } : {}), | ||
| 939 | ...(status ? { status } : {}), | ||
| 940 | ...(command ? { command } : {}), | ||
| 941 | ...(detail ? { detail } : {}), | ||
| 942 | data, | ||
| 943 | }; | ||
| 944 | } | ||
| 945 | |||
| 946 | // Even with bounded content (see TOOL_CALL_CONTENT_MAX_CHARS above), a redrawing terminal | ||
| 947 | // can still shift its bounded tail window on nearly every notification, which would emit | ||
| 948 | // a runtime event per redraw. Coalesce those: only emit early when the tool call's detail | ||
| 949 | // has grown meaningfully since the last emission, otherwise batch up to a small number of | ||
| 950 | // skipped updates before emitting anyway, so the UI still gets periodic progress and the | ||
| 951 | // final (completed/failed) state is always emitted immediately. | ||
| 952 | const TOOL_CALL_UPDATE_MIN_DETAIL_GROWTH_CHARS = 256; | ||
| 953 | const TOOL_CALL_UPDATE_COALESCE_LIMIT = 10; | ||
| 954 | |||
| 955 | export interface AcpToolCallEmitDecisionInput { | ||
| 956 | readonly previous: AcpToolCallState | undefined; | ||
| 957 | readonly next: AcpToolCallState; | ||
| 958 | readonly lastEmittedDetailLength: number | undefined; | ||
| 959 | readonly skippedSinceEmit: number; | ||
| 960 | } | ||
| 961 | |||
| 962 | export interface AcpToolCallEmitDecision { | ||
| 963 | readonly emit: boolean; | ||
| 964 | readonly skippedSinceEmit: number; | ||
| 965 | } | ||
| 966 | |||
| 967 | function toolCallOutputUnchanged(previous: AcpToolCallState, next: AcpToolCallState): boolean { | ||
| 968 | return ( | ||
| 969 | previous.data.content === next.data.content && previous.data.rawOutput === next.data.rawOutput | ||
| 970 | ); | ||
| 971 | } | ||
| 972 | |||
| 973 | // Command tools keep `detail` equal to the command, so live stdout lives on | ||
| 974 | // `data.content` / `data.rawOutput`. Measure that too, otherwise coalescing never | ||
| 975 | // sees growth and in-progress output is held until completed/failed. | ||
| 976 | export function toolCallProgressLength(state: AcpToolCallState): number { | ||
| 977 | let contentChars = 0; | ||
| 978 | const content = state.data.content; | ||
| 979 | if (Array.isArray(content)) { | ||
| 980 | for (const entry of content) { | ||
| 981 | if (!isRecord(entry)) { | ||
| 982 | continue; | ||
| 983 | } | ||
| 984 | const text = toolCallContentText(entry as EffectAcpSchema.ToolCallContent); | ||
| 985 | if (text) { | ||
| 986 | contentChars += text.length; | ||
| 987 | } | ||
| 988 | } | ||
| 989 | } | ||
| 990 | let rawOutputChars = 0; | ||
| 991 | const rawOutput = state.data.rawOutput; | ||
| 992 | if (isRecord(rawOutput)) { | ||
| 993 | for (const field of RAW_OUTPUT_TEXT_FIELDS) { | ||
| 994 | const value = rawOutput[field]; | ||
| 995 | if (typeof value === "string") { | ||
| 996 | rawOutputChars += value.length; | ||
| 997 | } | ||
| 998 | } | ||
| 999 | } | ||
| 1000 | return Math.max(state.detail?.length ?? 0, contentChars, rawOutputChars); | ||
| 1001 | } | ||
| 1002 | |||
| 1003 | function toolCallContentTexts(state: AcpToolCallState): string { | ||
| 1004 | const content = state.data.content; | ||
| 1005 | if (!Array.isArray(content)) return ""; | ||
| 1006 | return content | ||
| 1007 | .map((entry) => | ||
| 1008 | isRecord(entry) ? (toolCallContentText(entry as EffectAcpSchema.ToolCallContent) ?? "") : "", | ||
| 1009 | ) | ||
| 1010 | .join("\u0000"); | ||
| 1011 | } | ||
| 1012 | |||
| 1013 | // The output a user watches: detail, text content, and any `rawOutput`. A | ||
| 1014 | // streamed diff or `rawInput` is not part of it. | ||
| 1015 | export function toolCallVisibleOutputChanged( | ||
| 1016 | previous: AcpToolCallState, | ||
| 1017 | next: AcpToolCallState, | ||
| 1018 | ): boolean { | ||
| 1019 | return ( | ||
| 1020 | previous.detail !== next.detail || | ||
| 1021 | toolCallContentTexts(previous) !== toolCallContentTexts(next) || | ||
| 1022 | JSON.stringify(previous.data.rawOutput) !== JSON.stringify(next.data.rawOutput) | ||
| 1023 | ); | ||
| 1024 | } | ||
| 1025 | |||
| 1026 | export function decideToolCallUpdateEmission( | ||
| 1027 | input: AcpToolCallEmitDecisionInput, | ||
| 1028 | ): AcpToolCallEmitDecision { | ||
| 1029 | const { previous, next, lastEmittedDetailLength, skippedSinceEmit } = input; | ||
| 1030 | if (next.status === "completed" || next.status === "failed") { | ||
| 1031 | return { emit: true, skippedSinceEmit: 0 }; | ||
| 1032 | } | ||
| 1033 | if (previous === undefined || previous.title !== next.title || previous.status !== next.status) { | ||
| 1034 | return { emit: true, skippedSinceEmit: 0 }; | ||
| 1035 | } | ||
| 1036 | if (previous.detail === next.detail && toolCallOutputUnchanged(previous, next)) { | ||
| 1037 | return { emit: false, skippedSinceEmit }; | ||
| 1038 | } | ||
| 1039 | const progressLength = toolCallProgressLength(next); | ||
| 1040 | const grewMeaningfully = | ||
| 1041 | lastEmittedDetailLength === undefined || | ||
| 1042 | Math.abs(progressLength - lastEmittedDetailLength) >= TOOL_CALL_UPDATE_MIN_DETAIL_GROWTH_CHARS; | ||
| 1043 | if (grewMeaningfully || skippedSinceEmit + 1 >= TOOL_CALL_UPDATE_COALESCE_LIMIT) { | ||
| 1044 | return { emit: true, skippedSinceEmit: 0 }; | ||
| 1045 | } | ||
| 1046 | return { emit: false, skippedSinceEmit: skippedSinceEmit + 1 }; | ||
| 1047 | } | ||
| 1048 | |||
| 1049 | export interface AcpMcpToolCallIdentity { | ||
| 1050 | readonly server: string; | ||
| 1051 | readonly tool: string; | ||
| 1052 | readonly input?: Record<string, unknown>; | ||
| 1053 | } | ||
| 1054 | |||
| 1055 | /** Matches an invocation of T3's `acp-mcp-call` bridge fallback CLI. */ | ||
| 1056 | const ACP_MCP_FALLBACK_CALL = /(?:^|[\s"'=])acp-mcp-call[\s"']+([A-Za-z0-9_.-]+)(?:\s+(.+?))?\s*$/u; | ||
| 1057 | |||
| 1058 | function acpMcpFallbackInput(value: string | undefined): Record<string, unknown> | undefined { | ||
| 1059 | if (value === undefined) return undefined; | ||
| 1060 | const trimmed = value.trim(); | ||
| 1061 | const candidates = [ | ||
| 1062 | trimmed, | ||
| 1063 | ...(trimmed.startsWith("'") && trimmed.endsWith("'") ? [trimmed.slice(1, -1)] : []), | ||
| 1064 | ]; | ||
| 1065 | for (const candidate of candidates) { | ||
| 1066 | try { | ||
| 1067 | const parsed: unknown = JSON.parse(candidate); | ||
| 1068 | const unwrapped = typeof parsed === "string" ? JSON.parse(parsed) : parsed; | ||
| 1069 | if (isRecord(unwrapped)) return unwrapped; | ||
| 1070 | } catch { | ||
| 1071 | // ACP agents choose their own shell quoting. An unrecognized form keeps | ||
| 1072 | // the MCP identity but leaves the input empty instead of misreporting it. | ||
| 1073 | } | ||
| 1074 | } | ||
| 1075 | return undefined; | ||
| 1076 | } | ||
| 1077 | |||
| 1078 | /** | ||
| 1079 | * Agents flatten injected MCP tools into model-facing function names with no | ||
| 1080 | * shared convention (survey of the 2026-08 registry builds): Kilo and | ||
| 1081 | * opencode use `t3-code_<tool>`, claude-acp and qwen `mcp__t3-code__<tool>`, | ||
| 1082 | * Amp `mcp__t3_code__<tool>` (hyphens mangled), droid `t3-code___<tool>`, | ||
| 1083 | * Copilot `t3-code-<tool>`, cline appends `: <args json>`. T3 always injects | ||
| 1084 | * its server as "t3-code", and matches are additionally gated on the known | ||
| 1085 | * T3 tool inventory, so the separator match can stay loose. | ||
| 1086 | */ | ||
| 1087 | const T3_MCP_TITLE_CALL = | ||
| 1088 | /^(?:mcp[-_]{1,2})?t3[-_ ]?code[-_.:/ ]{1,3}(?<tool>[A-Za-z0-9][A-Za-z0-9_.-]*)(?::.*)?$/i; | ||
| 1089 | |||
| 1090 | /** | ||
| 1091 | * Gemini CLI titles injected MCP calls "<tool> (<server> MCP Server)" and | ||
| 1092 | * qwen-code appends ": <args json>" to the same template; Auggie namespaces | ||
| 1093 | * tool-first as "<tool>_t3-code". | ||
| 1094 | */ | ||
| 1095 | const T3_MCP_TITLE_SUFFIX_CALL = | ||
| 1096 | /^(?<tool>[A-Za-z0-9][A-Za-z0-9_.-]*?)(?: \(t3[-_ ]?code MCP Server\)(?::|$)|[-_.]t3[-_ ]?code$)/i; | ||
| 1097 | |||
| 1098 | /** | ||
| 1099 | * glm-acp-agent and Kimi CLI register injected MCP tools under their bare | ||
| 1100 | * names; Kimi additionally appends ": <raw args json>". Safe only because the | ||
| 1101 | * match is gated on the known T3 tool inventory. | ||
| 1102 | */ | ||
| 1103 | const T3_MCP_BARE_TITLE_CALL = /^(?<tool>[A-Za-z0-9_]+)(?::\s|$)/; | ||
| 1104 | |||
| 1105 | /** | ||
| 1106 | * Best-effort recovery of MCP identity from a generic ACP tool call. | ||
| 1107 | * | ||
| 1108 | * ACP has no typed MCP tool-call item, so agents surface MCP calls in | ||
| 1109 | * agent-specific shapes: codex-acp tags execute calls with | ||
| 1110 | * `rawInput.server`/`rawInput.tool`, while agents on T3's terminal fallback | ||
| 1111 | * run the `acp-mcp-call <tool>` CLI through their command or an embedded | ||
| 1112 | * client terminal. Recovered identity lets the projection render the same | ||
| 1113 | * branded MCP item that native providers produce. | ||
| 1114 | */ | ||
| 1115 | export function extractMcpToolCallIdentity( | ||
| 1116 | toolCall: AcpToolCallState, | ||
| 1117 | options?: { | ||
| 1118 | /** Command lines of client terminals embedded in this tool call. */ | ||
| 1119 | readonly embeddedTerminalCommands?: ReadonlyArray<string>; | ||
| 1120 | }, | ||
| 1121 | ): AcpMcpToolCallIdentity | undefined { | ||
| 1122 | const rawInput = isRecord(toolCall.data.rawInput) ? toolCall.data.rawInput : undefined; | ||
| 1123 | const meta = isRecord(toolCall.data.meta) ? toolCall.data.meta : undefined; | ||
| 1124 | const server = typeof rawInput?.server === "string" ? rawInput.server.trim() : ""; | ||
| 1125 | const tool = typeof rawInput?.tool === "string" ? rawInput.tool.trim() : ""; | ||
| 1126 | if (meta?.is_mcp_tool_call === true && server.length > 0 && tool.length > 0) { | ||
| 1127 | return { server, tool }; | ||
| 1128 | } | ||
| 1129 | // Agents without tagged rawInput identify their MCP calls through _meta | ||
| 1130 | // (goose, qwen, claude-acp) or only through the namespaced function name | ||
| 1131 | // in the title. The verbatim wire title survives merges even when a later | ||
| 1132 | // titleless or LLM-enriched update replaces the presentation title, so | ||
| 1133 | // match those rather than the summarized state title. | ||
| 1134 | const claudeCode = isRecord(meta?.claudeCode) ? meta.claudeCode : undefined; | ||
| 1135 | const gooseToolCall = isRecord(meta?.goose) | ||
| 1136 | ? isRecord(meta.goose.toolCall) | ||
| 1137 | ? meta.goose.toolCall | ||
| 1138 | : undefined | ||
| 1139 | : undefined; | ||
| 1140 | // qwen asserts the origin server explicitly, so any known tool suffix in | ||
| 1141 | // its toolName identifies the call even under future prefix formats. | ||
| 1142 | const metaServerId = typeof meta?.serverId === "string" ? meta.serverId.trim() : ""; | ||
| 1143 | const metaToolName = typeof meta?.toolName === "string" ? meta.toolName.trim() : ""; | ||
| 1144 | if (/^t3[-_ ]?code$/i.test(metaServerId) && metaToolName.length > 0) { | ||
| 1145 | for (const knownTool of T3_MCP_TOOL_NAMES) { | ||
| 1146 | const boundary = metaToolName.length - knownTool.length - 1; | ||
| 1147 | if ( | ||
| 1148 | metaToolName === knownTool || | ||
| 1149 | (metaToolName.endsWith(knownTool) && | ||
| 1150 | boundary >= 0 && | ||
| 1151 | !/[A-Za-z0-9]/.test(metaToolName.charAt(boundary))) | ||
| 1152 | ) { | ||
| 1153 | return { server: "t3-code", tool: knownTool }; | ||
| 1154 | } | ||
| 1155 | } | ||
| 1156 | } | ||
| 1157 | const gooseExtension = | ||
| 1158 | typeof gooseToolCall?.extensionName === "string" ? gooseToolCall.extensionName.trim() : ""; | ||
| 1159 | const assertsForeignOrigin = | ||
| 1160 | (metaServerId.length > 0 && !/^t3[-_ ]?code$/i.test(metaServerId)) || | ||
| 1161 | (gooseExtension.length > 0 && !/^t3[-_ ]?code$/i.test(gooseExtension)); | ||
| 1162 | // A foreign origin never brands as T3. qwen's serverId marks a real MCP | ||
| 1163 | // server, but goose reports its built-in extensions (developer__shell, | ||
| 1164 | // edits) the same way as user MCP servers, so goose stays unclassified and | ||
| 1165 | // keeps its command and file-change projections. | ||
| 1166 | if (assertsForeignOrigin) { | ||
| 1167 | if (metaServerId.length === 0 || metaToolName.length === 0) return undefined; | ||
| 1168 | const prefix = [`mcp__${metaServerId}__`, `mcp::${metaServerId}::`].find((prefix) => | ||
| 1169 | metaToolName.startsWith(prefix), | ||
| 1170 | ); | ||
| 1171 | const tool = prefix === undefined ? metaToolName : metaToolName.slice(prefix.length); | ||
| 1172 | return tool ? { server: metaServerId, tool } : undefined; | ||
| 1173 | } | ||
| 1174 | const candidates = [ | ||
| 1175 | meta?.toolName, | ||
| 1176 | claudeCode?.toolName, | ||
| 1177 | gooseToolCall?.toolName, | ||
| 1178 | toolCall.data.title, | ||
| 1179 | ].filter((value): value is string => typeof value === "string"); | ||
| 1180 | for (const candidate of candidates) { | ||
| 1181 | const trimmed = candidate.trim(); | ||
| 1182 | const qualified = /^mcp__(.+?)__(.+)$/i.exec(trimmed); | ||
| 1183 | if (qualified?.[1] && qualified[2] && !/^t3[-_ ]?code$/i.test(qualified[1])) | ||
| 1184 | return { server: qualified[1], tool: qualified[2] }; | ||
| 1185 | const match = | ||
| 1186 | T3_MCP_TITLE_CALL.exec(trimmed) ?? | ||
| 1187 | T3_MCP_TITLE_SUFFIX_CALL.exec(trimmed) ?? | ||
| 1188 | T3_MCP_BARE_TITLE_CALL.exec(trimmed); | ||
| 1189 | const candidateTool = match?.groups?.tool; | ||
| 1190 | if (candidateTool !== undefined && T3_MCP_TOOL_NAMES.has(candidateTool)) { | ||
| 1191 | return { server: "t3-code", tool: candidateTool }; | ||
| 1192 | } | ||
| 1193 | } | ||
| 1194 | const commands = [ | ||
| 1195 | ...(toolCall.command === undefined ? [] : [toolCall.command]), | ||
| 1196 | // pi-acp reports the exec command line only as the verbatim title. | ||
| 1197 | ...(typeof toolCall.data.title === "string" ? [toolCall.data.title] : []), | ||
| 1198 | ...(options?.embeddedTerminalCommands ?? []), | ||
| 1199 | ]; | ||
| 1200 | for (const command of commands) { | ||
| 1201 | const match = ACP_MCP_FALLBACK_CALL.exec(command); | ||
| 1202 | if (match?.[1] !== undefined) { | ||
| 1203 | // The acp-mcp-call CLI exists only as T3's bridge fallback, so the | ||
| 1204 | // server identity is T3's by construction. | ||
| 1205 | const input = acpMcpFallbackInput(match[2]); | ||
| 1206 | return { server: "t3-code", tool: match[1], ...(input === undefined ? {} : { input }) }; | ||
| 1207 | } | ||
| 1208 | } | ||
| 1209 | return undefined; | ||
| 1210 | } | ||
| 1211 | |||
| 1212 | /** | ||
| 1213 | * Terminal ids embedded in a raw tool_call update, captured before | ||
| 1214 | * {@link resolveEmbeddedTerminalContent} rewrites them into plain text, so the | ||
| 1215 | * projection can still resolve the terminal's command line. | ||
| 1216 | */ | ||
| 1217 | export function embeddedTerminalIdsFromSessionUpdate( | ||
| 1218 | notification: EffectAcpSchema.SessionNotification, | ||
| 1219 | ): { readonly toolCallId: string; readonly terminalIds: ReadonlyArray<string> } | undefined { | ||
| 1220 | const update = notification.update; | ||
| 1221 | if (update.sessionUpdate !== "tool_call" && update.sessionUpdate !== "tool_call_update") { | ||
| 1222 | return undefined; | ||
| 1223 | } | ||
| 1224 | const terminalIds = (update.content ?? []) | ||
| 1225 | .filter((entry) => entry.type === "terminal") | ||
| 1226 | .map((entry) => entry.terminalId); | ||
| 1227 | return terminalIds.length === 0 ? undefined : { toolCallId: update.toolCallId, terminalIds }; | ||
| 1228 | } | ||
| 1229 | |||
| 1230 | export function parsePermissionRequest( | ||
| 1231 | params: EffectAcpSchema.RequestPermissionRequest, | ||
| 1232 | ): AcpPermissionRequest { | ||
| 1233 | const toolCall = makeToolCallState( | ||
| 1234 | { | ||
| 1235 | toolCallId: params.toolCall.toolCallId, | ||
| 1236 | title: params.toolCall.title, | ||
| 1237 | kind: params.toolCall.kind, | ||
| 1238 | status: params.toolCall.status, | ||
| 1239 | rawInput: params.toolCall.rawInput, | ||
| 1240 | rawOutput: params.toolCall.rawOutput, | ||
| 1241 | content: params.toolCall.content, | ||
| 1242 | locations: params.toolCall.locations, | ||
| 1243 | }, | ||
| 1244 | { fallbackStatus: "pending" }, | ||
| 1245 | ); | ||
| 1246 | const kind = normalizeToolKind(params.toolCall.kind) ?? "unknown"; | ||
| 1247 | const detail = | ||
| 1248 | toolCall?.command ?? | ||
| 1249 | toolCall?.title ?? | ||
| 1250 | toolCall?.detail ?? | ||
| 1251 | (typeof params.sessionId === "string" ? `Session ${params.sessionId}` : undefined); | ||
| 1252 | return { | ||
| 1253 | kind, | ||
| 1254 | ...(detail ? { detail } : {}), | ||
| 1255 | ...(toolCall ? { toolCall } : {}), | ||
| 1256 | }; | ||
| 1257 | } | ||
| 1258 | |||
| 1259 | export function sessionUpdateIsReplay(params: EffectAcpSchema.SessionNotification): boolean { | ||
| 1260 | const meta = params._meta; | ||
| 1261 | return isRecord(meta) && meta.isReplay === true; | ||
| 1262 | } | ||
| 1263 | |||
| 1264 | /** Replay chunks and substantive updates during session/load; not Grok keepalives. */ | ||
| 1265 | export function sessionUpdateCountsAsLoadReplayActivity( | ||
| 1266 | params: EffectAcpSchema.SessionNotification, | ||
| 1267 | gatedSessionId?: string, | ||
| 1268 | ): boolean { | ||
| 1269 | if (gatedSessionId !== undefined && params.sessionId !== gatedSessionId) { | ||
| 1270 | return false; | ||
| 1271 | } | ||
| 1272 | if (sessionUpdateIsReplay(params)) return true; | ||
| 1273 | const update = params.update; | ||
| 1274 | switch (update.sessionUpdate) { | ||
| 1275 | case "agent_message_chunk": | ||
| 1276 | case "agent_thought_chunk": | ||
| 1277 | return (acpContentBlockDisplayText(update.content)?.length ?? 0) > 0; | ||
| 1278 | case "agent_message": | ||
| 1279 | case "agent_thought": | ||
| 1280 | return (update.content ?? []).some( | ||
| 1281 | (content) => (acpContentBlockDisplayText(content)?.length ?? 0) > 0, | ||
| 1282 | ); | ||
| 1283 | case "tool_call": | ||
| 1284 | case "tool_call_update": | ||
| 1285 | case "plan": | ||
| 1286 | case "plan_update": | ||
| 1287 | case "plan_removed": | ||
| 1288 | case "available_commands_update": | ||
| 1289 | case "current_mode_update": | ||
| 1290 | case "config_option_update": | ||
| 1291 | case "session_info_update": | ||
| 1292 | case "usage_update": | ||
| 1293 | case "compaction_update": | ||
| 1294 | case "compaction_summary_chunk": | ||
| 1295 | return true; | ||
| 1296 | default: | ||
| 1297 | return false; | ||
| 1298 | } | ||
| 1299 | } | ||
| 1300 | |||
| 1301 | export interface SessionLoadGate { | ||
| 1302 | readonly active: boolean; | ||
| 1303 | /** Only notifications for this session refresh load-replay idle activity. */ | ||
| 1304 | readonly sessionId: string; | ||
| 1305 | readonly lastActivityAtMillis: number | undefined; | ||
| 1306 | readonly idleGap: Duration.Duration; | ||
| 1307 | readonly initializeResult: EffectAcpSchema.InitializeResponse; | ||
| 1308 | readonly candidateConfigOptions?: ReadonlyArray<EffectAcpSchema.SessionConfigOption>; | ||
| 1309 | } | ||
| 1310 | |||
| 1311 | export const waitForSessionLoadReplayIdle = (input: { | ||
| 1312 | readonly gateRef: Ref.Ref<Option.Option<SessionLoadGate>>; | ||
| 1313 | }): Effect.Effect<EffectAcpSchema.LoadSessionResponse, never> => | ||
| 1314 | Effect.gen(function* () { | ||
| 1315 | const pollInterval = Duration.millis(25); | ||
| 1316 | while (true) { | ||
| 1317 | const gate = yield* Ref.get(input.gateRef); | ||
| 1318 | if ( | ||
| 1319 | Option.isSome(gate) && | ||
| 1320 | gate.value.active && | ||
| 1321 | gate.value.lastActivityAtMillis !== undefined | ||
| 1322 | ) { | ||
| 1323 | const idleGapMillis = Duration.toMillis(gate.value.idleGap); | ||
| 1324 | const nowMillis = yield* Clock.currentTimeMillis; | ||
| 1325 | if (nowMillis - gate.value.lastActivityAtMillis >= idleGapMillis) { | ||
| 1326 | return syntheticLoadSessionResponseFromInitialize(gate.value.initializeResult); | ||
| 1327 | } | ||
| 1328 | } | ||
| 1329 | yield* Effect.sleep(pollInterval); | ||
| 1330 | } | ||
| 1331 | }); | ||
| 1332 | |||
| 1333 | /** | ||
| 1334 | * Model state some agents (Grok) advertise in `initialize._meta.modelState`, before any | ||
| 1335 | * session exists. Undefined when the agent does not advertise it or the shape is unknown. | ||
| 1336 | */ | ||
| 1337 | export function sessionModelStateFromInitialize( | ||
| 1338 | initializeResult: EffectAcpSchema.InitializeResponse, | ||
| 1339 | ): EffectAcpSchema.SessionModelState | undefined { | ||
| 1340 | const meta = initializeResult._meta; | ||
| 1341 | const modelState = isRecord(meta) ? meta.modelState : undefined; | ||
| 1342 | return isSessionModelState(modelState) ? modelState : undefined; | ||
| 1343 | } | ||
| 1344 | |||
| 1345 | export function syntheticLoadSessionResponseFromInitialize( | ||
| 1346 | initializeResult: EffectAcpSchema.InitializeResponse, | ||
| 1347 | ): EffectAcpSchema.LoadSessionResponse { | ||
| 1348 | const meta = initializeResult._meta; | ||
| 1349 | const modeState = isRecord(meta) ? meta.modeState : undefined; | ||
| 1350 | |||
| 1351 | const modes = isSessionModeState(modeState) ? modeState : undefined; | ||
| 1352 | |||
| 1353 | return { | ||
| 1354 | ...(modes ? { modes } : {}), | ||
| 1355 | _meta: { | ||
| 1356 | t3SessionLoadReady: "replay_idle", | ||
| 1357 | }, | ||
| 1358 | }; | ||
| 1359 | } | ||
| 1360 | |||
| 1361 | // The parsed AcpToolCallState already carries bounded content (see makeToolCallState / | ||
| 1362 | // extractTextContentFromToolCallContent above), but the raw JSON-RPC notification is also | ||
| 1363 | // threaded through as `rawPayload` for logging/debugging and ends up persisted on the | ||
| 1364 | // runtime event. Substitute the same bounded `content`/`rawOutput` there so an oversized | ||
| 1365 | // cumulative update cannot smuggle the unbounded buffer back in through the raw payload. | ||
| 1366 | function boundToolCallRawPayload( | ||
| 1367 | params: EffectAcpSchema.SessionNotification, | ||
| 1368 | update: AcpToolCallUpdate, | ||
| 1369 | toolCall: AcpToolCallState, | ||
| 1370 | ): unknown { | ||
| 1371 | const boundedContent = toolCall.data.content; | ||
| 1372 | const boundedRawOutput = toolCall.data.rawOutput; | ||
| 1373 | const contentBounded = update.content !== undefined && boundedContent !== update.content; | ||
| 1374 | const rawOutputBounded = update.rawOutput !== undefined && boundedRawOutput !== update.rawOutput; | ||
| 1375 | if (!contentBounded && !rawOutputBounded) { | ||
| 1376 | return params; | ||
| 1377 | } | ||
| 1378 | return { | ||
| 1379 | ...params, | ||
| 1380 | update: { | ||
| 1381 | ...update, | ||
| 1382 | ...(contentBounded ? { content: boundedContent } : {}), | ||
| 1383 | ...(rawOutputBounded ? { rawOutput: boundedRawOutput } : {}), | ||
| 1384 | }, | ||
| 1385 | }; | ||
| 1386 | } | ||
| 1387 | |||
| 1388 | export function parseSessionUpdateEvent(params: EffectAcpSchema.SessionNotification): { | ||
| 1389 | readonly modeId?: string; | ||
| 1390 | readonly events: ReadonlyArray<AcpParsedSessionEvent>; | ||
| 1391 | } { | ||
| 1392 | const upd = params.update; | ||
| 1393 | const events: Array<AcpParsedSessionEvent> = []; | ||
| 1394 | let modeId: string | undefined; | ||
| 1395 | |||
| 1396 | switch (upd.sessionUpdate) { | ||
| 1397 | case "config_option_update": { | ||
| 1398 | const modes = parseSessionModeState({ configOptions: upd.configOptions }); | ||
| 1399 | if (modes !== undefined) { | ||
| 1400 | modeId = modes.currentModeId; | ||
| 1401 | events.push({ _tag: "ModeChanged", modeId }); | ||
| 1402 | } | ||
| 1403 | events.push({ | ||
| 1404 | _tag: "ConfigOptionsUpdated", | ||
| 1405 | configOptions: upd.configOptions, | ||
| 1406 | rawPayload: params, | ||
| 1407 | }); | ||
| 1408 | break; | ||
| 1409 | } | ||
| 1410 | case "available_commands_update": { | ||
| 1411 | events.push({ | ||
| 1412 | _tag: "AvailableCommandsUpdated", | ||
| 1413 | availableCommands: upd.availableCommands, | ||
| 1414 | rawPayload: params, | ||
| 1415 | }); | ||
| 1416 | break; | ||
| 1417 | } | ||
| 1418 | case "current_mode_update": { | ||
| 1419 | modeId = upd.currentModeId.trim(); | ||
| 1420 | if (modeId) { | ||
| 1421 | events.push({ | ||
| 1422 | _tag: "ModeChanged", | ||
| 1423 | modeId, | ||
| 1424 | }); | ||
| 1425 | } | ||
| 1426 | break; | ||
| 1427 | } | ||
| 1428 | case "plan": { | ||
| 1429 | const plan = upd.entries.map((entry, index) => ({ | ||
| 1430 | step: entry.content.trim().length > 0 ? entry.content.trim() : `Step ${index + 1}`, | ||
| 1431 | status: normalizePlanStepStatus(entry.status), | ||
| 1432 | })); | ||
| 1433 | if (plan.length > 0) { | ||
| 1434 | events.push({ | ||
| 1435 | _tag: "PlanUpdated", | ||
| 1436 | payload: { | ||
| 1437 | nativePlanId: "legacy", | ||
| 1438 | kind: "items", | ||
| 1439 | plan, | ||
| 1440 | }, | ||
| 1441 | rawPayload: params, | ||
| 1442 | }); | ||
| 1443 | } | ||
| 1444 | break; | ||
| 1445 | } | ||
| 1446 | case "plan_update": { | ||
| 1447 | const nativePlanId = upd.plan.planId.trim(); | ||
| 1448 | if (nativePlanId.length === 0) { | ||
| 1449 | break; | ||
| 1450 | } | ||
| 1451 | if (upd.plan.type === "items" && "entries" in upd.plan && Array.isArray(upd.plan.entries)) { | ||
| 1452 | const entries: ReadonlyArray<unknown> = upd.plan.entries; | ||
| 1453 | const plan = entries.flatMap((entry, index) => { | ||
| 1454 | if (!isRecord(entry) || typeof entry.content !== "string") return []; | ||
| 1455 | const status = typeof entry.status === "string" ? entry.status : "pending"; | ||
| 1456 | return [ | ||
| 1457 | { | ||
| 1458 | step: entry.content.trim().length > 0 ? entry.content.trim() : `Step ${index + 1}`, | ||
| 1459 | status: normalizePlanStepStatus(status), | ||
| 1460 | }, | ||
| 1461 | ]; | ||
| 1462 | }); | ||
| 1463 | events.push({ | ||
| 1464 | _tag: "PlanUpdated", | ||
| 1465 | payload: { nativePlanId, kind: "items", plan }, | ||
| 1466 | rawPayload: params, | ||
| 1467 | }); | ||
| 1468 | } else if ( | ||
| 1469 | upd.plan.type === "markdown" && | ||
| 1470 | "content" in upd.plan && | ||
| 1471 | typeof upd.plan.content === "string" | ||
| 1472 | ) { | ||
| 1473 | events.push({ | ||
| 1474 | _tag: "PlanUpdated", | ||
| 1475 | payload: { nativePlanId, kind: "markdown", markdown: upd.plan.content }, | ||
| 1476 | rawPayload: params, | ||
| 1477 | }); | ||
| 1478 | } else if ( | ||
| 1479 | upd.plan.type === "file" && | ||
| 1480 | "uri" in upd.plan && | ||
| 1481 | typeof upd.plan.uri === "string" | ||
| 1482 | ) { | ||
| 1483 | events.push({ | ||
| 1484 | _tag: "PlanUpdated", | ||
| 1485 | payload: { nativePlanId, kind: "file", uri: upd.plan.uri }, | ||
| 1486 | rawPayload: params, | ||
| 1487 | }); | ||
| 1488 | } else { | ||
| 1489 | events.push({ | ||
| 1490 | _tag: "PlanUpdated", | ||
| 1491 | payload: { nativePlanId, kind: "unknown", contentType: upd.plan.type }, | ||
| 1492 | rawPayload: params, | ||
| 1493 | }); | ||
| 1494 | } | ||
| 1495 | break; | ||
| 1496 | } | ||
| 1497 | case "plan_removed": { | ||
| 1498 | const nativePlanId = upd.planId.trim(); | ||
| 1499 | if (nativePlanId.length > 0) { | ||
| 1500 | events.push({ | ||
| 1501 | _tag: "PlanUpdated", | ||
| 1502 | payload: { nativePlanId, kind: "removed" }, | ||
| 1503 | rawPayload: params, | ||
| 1504 | }); | ||
| 1505 | } | ||
| 1506 | break; | ||
| 1507 | } | ||
| 1508 | case "tool_call": { | ||
| 1509 | const toolCall = parseTypedToolCallState(upd, { | ||
| 1510 | fallbackStatus: "pending", | ||
| 1511 | }); | ||
| 1512 | if (toolCall) { | ||
| 1513 | events.push({ | ||
| 1514 | _tag: "ToolCallUpdated", | ||
| 1515 | toolCall, | ||
| 1516 | rawPayload: boundToolCallRawPayload(params, upd, toolCall), | ||
| 1517 | }); | ||
| 1518 | } | ||
| 1519 | break; | ||
| 1520 | } | ||
| 1521 | case "tool_call_update": { | ||
| 1522 | const toolCall = parseTypedToolCallState(upd); | ||
| 1523 | if (toolCall) { | ||
| 1524 | events.push({ | ||
| 1525 | _tag: "ToolCallUpdated", | ||
| 1526 | toolCall, | ||
| 1527 | rawPayload: boundToolCallRawPayload(params, upd, toolCall), | ||
| 1528 | }); | ||
| 1529 | } | ||
| 1530 | break; | ||
| 1531 | } | ||
| 1532 | case "agent_message_chunk": { | ||
| 1533 | const text = acpContentBlockDisplayText(upd.content); | ||
| 1534 | if (text !== undefined && text.length > 0) { | ||
| 1535 | events.push({ | ||
| 1536 | _tag: "ContentDelta", | ||
| 1537 | ...(upd.messageId ? { messageId: upd.messageId } : {}), | ||
| 1538 | text, | ||
| 1539 | rawPayload: params, | ||
| 1540 | }); | ||
| 1541 | } | ||
| 1542 | break; | ||
| 1543 | } | ||
| 1544 | case "agent_thought_chunk": { | ||
| 1545 | if (upd.content.type === "text" && upd.content.text.length > 0) { | ||
| 1546 | events.push({ | ||
| 1547 | _tag: "ThoughtDelta", | ||
| 1548 | text: upd.content.text, | ||
| 1549 | rawPayload: params, | ||
| 1550 | }); | ||
| 1551 | } | ||
| 1552 | break; | ||
| 1553 | } | ||
| 1554 | case "compaction_update": { | ||
| 1555 | const summary = (upd.summary ?? []) | ||
| 1556 | .flatMap((content) => { | ||
| 1557 | const text = acpContentBlockDisplayText(content); | ||
| 1558 | return text === undefined || text.length === 0 ? [] : [text]; | ||
| 1559 | }) | ||
| 1560 | .join("\n"); | ||
| 1561 | const status = | ||
| 1562 | upd.status === "completed" | ||
| 1563 | ? ("completed" as const) | ||
| 1564 | : upd.status === "failed" || upd.status === "cancelled" | ||
| 1565 | ? ("failed" as const) | ||
| 1566 | : ("inProgress" as const); | ||
| 1567 | const toolCall = makeToolCallState({ | ||
| 1568 | toolCallId: `acp-compaction:${upd.compactionId}`, | ||
| 1569 | title: "Compact context", | ||
| 1570 | kind: "think", | ||
| 1571 | status, | ||
| 1572 | rawOutput: (upd.error ?? summary) || undefined, | ||
| 1573 | _meta: upd._meta, | ||
| 1574 | }); | ||
| 1575 | if (toolCall !== undefined) { | ||
| 1576 | events.push({ _tag: "ToolCallUpdated", toolCall, rawPayload: params }); | ||
| 1577 | } | ||
| 1578 | break; | ||
| 1579 | } | ||
| 1580 | case "compaction_summary_chunk": { | ||
| 1581 | const text = acpContentBlockDisplayText(upd.content); | ||
| 1582 | const toolCall = makeToolCallState({ | ||
| 1583 | toolCallId: `acp-compaction:${upd.compactionId}`, | ||
| 1584 | title: "Compact context", | ||
| 1585 | kind: "think", | ||
| 1586 | status: "inProgress", | ||
| 1587 | ...(text === undefined ? {} : { rawOutput: text }), | ||
| 1588 | _meta: upd._meta, | ||
| 1589 | }); | ||
| 1590 | if (toolCall !== undefined) { | ||
| 1591 | events.push({ _tag: "ToolCallUpdated", toolCall, rawPayload: params }); | ||
| 1592 | } | ||
| 1593 | break; | ||
| 1594 | } | ||
| 1595 | case "state_update": { | ||
| 1596 | if (upd.state === "idle" && upd.usage !== undefined && upd.usage !== null) { | ||
| 1597 | events.push({ | ||
| 1598 | _tag: "UsageUpdated", | ||
| 1599 | usage: { usedTokens: upd.usage.totalTokens }, | ||
| 1600 | rawPayload: params, | ||
| 1601 | }); | ||
| 1602 | } | ||
| 1603 | break; | ||
| 1604 | } | ||
| 1605 | case "usage_update": { | ||
| 1606 | const currency = upd.cost?.currency.trim().slice(0, 32); | ||
| 1607 | events.push({ | ||
| 1608 | _tag: "UsageUpdated", | ||
| 1609 | usage: { | ||
| 1610 | usedTokens: upd.used, | ||
| 1611 | ...(upd.size > 0 ? { maxTokens: upd.size } : {}), | ||
| 1612 | ...(upd.cost && currency ? { cost: { amount: upd.cost.amount, currency } } : {}), | ||
| 1613 | }, | ||
| 1614 | rawPayload: params, | ||
| 1615 | }); | ||
| 1616 | break; | ||
| 1617 | } | ||
| 1618 | case "session_info_update": { | ||
| 1619 | const title = upd.title?.trim().slice(0, 1_024); | ||
| 1620 | const updatedAt = upd.updatedAt?.trim().slice(0, 128); | ||
| 1621 | events.push({ | ||
| 1622 | _tag: "SessionInfoUpdated", | ||
| 1623 | metadata: { | ||
| 1624 | ...(upd.title === null ? { title: null } : title ? { title } : {}), | ||
| 1625 | ...(upd.updatedAt === null ? { updatedAt: null } : updatedAt ? { updatedAt } : {}), | ||
| 1626 | }, | ||
| 1627 | rawPayload: params, | ||
| 1628 | }); | ||
| 1629 | break; | ||
| 1630 | } | ||
| 1631 | case "_t3_unknown": { | ||
| 1632 | events.push({ | ||
| 1633 | _tag: "UnknownUpdate", | ||
| 1634 | updateType: boundedContentMetadata(upd.originalSessionUpdate, 128) || "unknown", | ||
| 1635 | rawPayload: params, | ||
| 1636 | }); | ||
| 1637 | break; | ||
| 1638 | } | ||
| 1639 | default: | ||
| 1640 | break; | ||
| 1641 | } | ||
| 1642 | |||
| 1643 | return { ...(modeId !== undefined ? { modeId } : {}), events }; | ||
| 1644 | } | ||
packages/provider-acp/src/server/sessionConfig.test.ts created+263| ... | @@ -0,0 +1,263 @@ | ||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | |||
| 3 | import { ACP_SESSION_MODE_OPTION_ID, acpProviderOptionDescriptors } from "./sessionConfig.ts"; | ||
| 4 | |||
| 5 | describe("acpProviderOptionDescriptors", () => { | ||
| 6 | it("maps non-model select options and excludes model and collaboration categories", () => { | ||
| 7 | const descriptors = acpProviderOptionDescriptors({ | ||
| 8 | configOptions: [ | ||
| 9 | { | ||
| 10 | id: "model", | ||
| 11 | name: "Model", | ||
| 12 | category: "model", | ||
| 13 | type: "select", | ||
| 14 | currentValue: "gpt-5.6-sol", | ||
| 15 | options: [{ value: "gpt-5.6-sol", name: "GPT-5.6-Sol" }], | ||
| 16 | }, | ||
| 17 | { | ||
| 18 | id: "collaboration_mode", | ||
| 19 | name: "Collaboration mode", | ||
| 20 | category: "collaboration_mode", | ||
| 21 | type: "select", | ||
| 22 | currentValue: "default", | ||
| 23 | options: [ | ||
| 24 | { value: "default", name: "Default" }, | ||
| 25 | { value: "plan", name: "Plan" }, | ||
| 26 | ], | ||
| 27 | }, | ||
| 28 | { | ||
| 29 | id: "reasoning_effort", | ||
| 30 | name: "Reasoning effort", | ||
| 31 | category: "thought_level", | ||
| 32 | type: "select", | ||
| 33 | currentValue: "medium", | ||
| 34 | options: [ | ||
| 35 | { value: "low", name: "Low" }, | ||
| 36 | { value: "medium", name: "Medium", description: "Balanced" }, | ||
| 37 | ], | ||
| 38 | }, | ||
| 39 | ], | ||
| 40 | modeState: undefined, | ||
| 41 | }); | ||
| 42 | |||
| 43 | expect(descriptors).toEqual([ | ||
| 44 | { | ||
| 45 | id: "reasoning_effort", | ||
| 46 | label: "Reasoning effort", | ||
| 47 | type: "select", | ||
| 48 | options: [ | ||
| 49 | { id: "low", label: "Low" }, | ||
| 50 | { id: "medium", label: "Medium", description: "Balanced" }, | ||
| 51 | ], | ||
| 52 | currentValue: "medium", | ||
| 53 | }, | ||
| 54 | ]); | ||
| 55 | }); | ||
| 56 | |||
| 57 | it("maps boolean session options", () => { | ||
| 58 | const descriptors = acpProviderOptionDescriptors({ | ||
| 59 | configOptions: [ | ||
| 60 | { | ||
| 61 | id: "fast_mode", | ||
| 62 | name: "Fast mode", | ||
| 63 | description: "Use lower-latency inference.", | ||
| 64 | type: "boolean", | ||
| 65 | currentValue: true, | ||
| 66 | }, | ||
| 67 | ], | ||
| 68 | modeState: undefined, | ||
| 69 | }); | ||
| 70 | |||
| 71 | expect(descriptors).toEqual([ | ||
| 72 | { | ||
| 73 | id: "fast_mode", | ||
| 74 | label: "Fast mode", | ||
| 75 | description: "Use lower-latency inference.", | ||
| 76 | type: "boolean", | ||
| 77 | currentValue: true, | ||
| 78 | }, | ||
| 79 | ]); | ||
| 80 | }); | ||
| 81 | |||
| 82 | it("flattens grouped select choices and drops empty or duplicate values", () => { | ||
| 83 | const descriptors = acpProviderOptionDescriptors({ | ||
| 84 | configOptions: [ | ||
| 85 | { | ||
| 86 | id: "sandbox", | ||
| 87 | name: "Sandbox", | ||
| 88 | type: "select", | ||
| 89 | currentValue: "workspace", | ||
| 90 | options: [ | ||
| 91 | { | ||
| 92 | groupId: "standard", | ||
| 93 | name: "Standard", | ||
| 94 | options: [ | ||
| 95 | { value: "workspace", name: "Workspace" }, | ||
| 96 | { value: " ", name: "Blank" }, | ||
| 97 | ], | ||
| 98 | }, | ||
| 99 | { | ||
| 100 | groupId: "extra", | ||
| 101 | name: "Extra", | ||
| 102 | options: [ | ||
| 103 | { value: "workspace", name: "Duplicate" }, | ||
| 104 | { value: "full", name: "Full" }, | ||
| 105 | ], | ||
| 106 | }, | ||
| 107 | ], | ||
| 108 | }, | ||
| 109 | ], | ||
| 110 | modeState: undefined, | ||
| 111 | }); | ||
| 112 | |||
| 113 | expect(descriptors).toEqual([ | ||
| 114 | { | ||
| 115 | id: "sandbox", | ||
| 116 | label: "Sandbox", | ||
| 117 | type: "select", | ||
| 118 | options: [ | ||
| 119 | { id: "workspace", label: "Workspace" }, | ||
| 120 | { id: "full", label: "Full" }, | ||
| 121 | ], | ||
| 122 | currentValue: "workspace", | ||
| 123 | }, | ||
| 124 | ]); | ||
| 125 | }); | ||
| 126 | |||
| 127 | it("synthesizes a mode descriptor for agents without a mode config option", () => { | ||
| 128 | const descriptors = acpProviderOptionDescriptors({ | ||
| 129 | configOptions: [], | ||
| 130 | modeState: { | ||
| 131 | currentModeId: "safe", | ||
| 132 | availableModes: [ | ||
| 133 | { id: "safe", name: "Safe" }, | ||
| 134 | { id: "yolo", name: "YOLO", description: "No approvals" }, | ||
| 135 | ], | ||
| 136 | }, | ||
| 137 | }); | ||
| 138 | |||
| 139 | expect(descriptors).toEqual([ | ||
| 140 | { | ||
| 141 | id: ACP_SESSION_MODE_OPTION_ID, | ||
| 142 | label: "Mode", | ||
| 143 | description: "Session mode advertised by the ACP agent.", | ||
| 144 | type: "select", | ||
| 145 | options: [ | ||
| 146 | { id: "safe", label: "Safe" }, | ||
| 147 | { id: "yolo", label: "YOLO", description: "No approvals" }, | ||
| 148 | ], | ||
| 149 | currentValue: "safe", | ||
| 150 | }, | ||
| 151 | ]); | ||
| 152 | }); | ||
| 153 | |||
| 154 | it("suppresses the synthetic mode descriptor when modes duplicate a config option", () => { | ||
| 155 | const descriptors = acpProviderOptionDescriptors({ | ||
| 156 | configOptions: [ | ||
| 157 | { | ||
| 158 | id: "thinking", | ||
| 159 | name: "Thinking", | ||
| 160 | category: "thought_level", | ||
| 161 | type: "select", | ||
| 162 | currentValue: "medium", | ||
| 163 | options: [ | ||
| 164 | { value: "off", name: "Thinking: off" }, | ||
| 165 | { value: "medium", name: "Thinking: medium" }, | ||
| 166 | ], | ||
| 167 | }, | ||
| 168 | ], | ||
| 169 | modeState: { | ||
| 170 | currentModeId: "medium", | ||
| 171 | availableModes: [ | ||
| 172 | { id: "off", name: "Thinking: off" }, | ||
| 173 | { id: "medium", name: "Thinking: medium" }, | ||
| 174 | ], | ||
| 175 | }, | ||
| 176 | }); | ||
| 177 | |||
| 178 | expect(descriptors.map((descriptor) => descriptor.id)).toEqual(["thinking"]); | ||
| 179 | }); | ||
| 180 | |||
| 181 | it("does not let an unrelated select option hide the session modes API", () => { | ||
| 182 | const descriptors = acpProviderOptionDescriptors({ | ||
| 183 | configOptions: [ | ||
| 184 | { | ||
| 185 | id: "unrelated", | ||
| 186 | name: "Unrelated", | ||
| 187 | type: "select", | ||
| 188 | currentValue: "safe", | ||
| 189 | options: [ | ||
| 190 | { value: "safe", name: "Safe" }, | ||
| 191 | { value: "yolo", name: "YOLO" }, | ||
| 192 | ], | ||
| 193 | }, | ||
| 194 | ], | ||
| 195 | modeState: { | ||
| 196 | currentModeId: "safe", | ||
| 197 | availableModes: [ | ||
| 198 | { id: "safe", name: "Safe" }, | ||
| 199 | { id: "yolo", name: "YOLO" }, | ||
| 200 | ], | ||
| 201 | }, | ||
| 202 | }); | ||
| 203 | |||
| 204 | expect(descriptors.map((descriptor) => descriptor.id)).toEqual([ | ||
| 205 | "unrelated", | ||
| 206 | ACP_SESSION_MODE_OPTION_ID, | ||
| 207 | ]); | ||
| 208 | }); | ||
| 209 | |||
| 210 | it("preserves canonical opaque option values and omits values the wire would mutate", () => { | ||
| 211 | const exactValue = "value:with:opaque-markers"; | ||
| 212 | const descriptors = acpProviderOptionDescriptors({ | ||
| 213 | configOptions: [ | ||
| 214 | { | ||
| 215 | id: "opaque", | ||
| 216 | name: "Opaque", | ||
| 217 | type: "select", | ||
| 218 | currentValue: exactValue, | ||
| 219 | options: [ | ||
| 220 | { value: exactValue, name: "Exact" }, | ||
| 221 | { value: " value with spaces ", name: "Would be trimmed" }, | ||
| 222 | { value: "x".repeat(257), name: "Too long" }, | ||
| 223 | ], | ||
| 224 | }, | ||
| 225 | ], | ||
| 226 | modeState: undefined, | ||
| 227 | }); | ||
| 228 | |||
| 229 | expect(descriptors[0]).toMatchObject({ | ||
| 230 | id: "opaque", | ||
| 231 | currentValue: exactValue, | ||
| 232 | options: [{ id: exactValue, label: "Exact" }], | ||
| 233 | }); | ||
| 234 | }); | ||
| 235 | |||
| 236 | it("suppresses the synthetic mode descriptor when a mode-category option exists", () => { | ||
| 237 | const descriptors = acpProviderOptionDescriptors({ | ||
| 238 | configOptions: [ | ||
| 239 | { | ||
| 240 | id: "mode", | ||
| 241 | name: "Mode", | ||
| 242 | category: "mode", | ||
| 243 | type: "select", | ||
| 244 | currentValue: "agent", | ||
| 245 | options: [ | ||
| 246 | { value: "read-only", name: "Read-only" }, | ||
| 247 | { value: "agent", name: "Agent" }, | ||
| 248 | ], | ||
| 249 | }, | ||
| 250 | ], | ||
| 251 | modeState: { | ||
| 252 | currentModeId: "agent", | ||
| 253 | availableModes: [ | ||
| 254 | { id: "read-only", name: "Read-only" }, | ||
| 255 | { id: "agent", name: "Agent" }, | ||
| 256 | { id: "agent-full-access", name: "Agent (full access)" }, | ||
| 257 | ], | ||
| 258 | }, | ||
| 259 | }); | ||
| 260 | |||
| 261 | expect(descriptors.map((descriptor) => descriptor.id)).toEqual(["mode"]); | ||
| 262 | }); | ||
| 263 | }); | ||
packages/provider-acp/src/server/sessionConfig.ts created+153| ... | @@ -0,0 +1,153 @@ | ||
| 1 | import type { ProviderOptionChoice, ProviderOptionDescriptor } from "@t3tools/contracts"; | ||
| 2 | import type * as EffectAcpSchema from "effect-acp/compat"; | ||
| 3 | |||
| 4 | import type { AcpSessionModeState } from "./runtimeModel.ts"; | ||
| 5 | |||
| 6 | /** | ||
| 7 | * Maps ACP session configuration and session modes onto T3's provider option | ||
| 8 | * descriptors so the existing model-options UI can drive them. | ||
| 9 | * | ||
| 10 | * Model selection stays on the dedicated model picker: `category: "model"` | ||
| 11 | * options are excluded here because they already surface as models. Session | ||
| 12 | * modes advertised through the modes API (rather than a config option) are | ||
| 13 | * folded into one synthetic descriptor that the ACP adapter routes to | ||
| 14 | * `session/set_mode`. | ||
| 15 | */ | ||
| 16 | |||
| 17 | /** Synthetic descriptor ID for agents that expose modes outside config options. */ | ||
| 18 | export const ACP_SESSION_MODE_OPTION_ID = "_t3/session-mode"; | ||
| 19 | |||
| 20 | const MAX_OPTION_DESCRIPTORS = 16; | ||
| 21 | const MAX_OPTION_CHOICES = 64; | ||
| 22 | const MAX_TEXT_LENGTH = 256; | ||
| 23 | const MAX_DESCRIPTION_LENGTH = 1_024; | ||
| 24 | |||
| 25 | const boundedText = (value: string | null | undefined, maximumLength: number): string => | ||
| 26 | (value ?? "").trim().slice(0, maximumLength); | ||
| 27 | |||
| 28 | const boundedOpaqueValue = (value: string, maximumLength: number): string | undefined => | ||
| 29 | value.length > 0 && value === value.trim() && value.length <= maximumLength ? value : undefined; | ||
| 30 | |||
| 31 | function flattenSelectChoices( | ||
| 32 | options: EffectAcpSchema.SessionConfigSelectOptions, | ||
| 33 | ): ReadonlyArray<EffectAcpSchema.SessionConfigSelectOption> { | ||
| 34 | return options.flatMap((candidate) => ("value" in candidate ? [candidate] : candidate.options)); | ||
| 35 | } | ||
| 36 | |||
| 37 | function selectChoices( | ||
| 38 | candidates: ReadonlyArray<{ | ||
| 39 | readonly value: string; | ||
| 40 | readonly name: string; | ||
| 41 | readonly description?: string | null | undefined; | ||
| 42 | }>, | ||
| 43 | ): ReadonlyArray<ProviderOptionChoice> { | ||
| 44 | const seen = new Set<string>(); | ||
| 45 | const choices: Array<ProviderOptionChoice> = []; | ||
| 46 | for (const candidate of candidates) { | ||
| 47 | const id = boundedOpaqueValue(candidate.value, MAX_TEXT_LENGTH); | ||
| 48 | if (id === undefined || seen.has(id)) continue; | ||
| 49 | seen.add(id); | ||
| 50 | const description = boundedText(candidate.description, MAX_DESCRIPTION_LENGTH); | ||
| 51 | choices.push({ | ||
| 52 | id, | ||
| 53 | label: boundedText(candidate.name, MAX_TEXT_LENGTH) || id, | ||
| 54 | ...(description ? { description } : {}), | ||
| 55 | }); | ||
| 56 | if (choices.length === MAX_OPTION_CHOICES) break; | ||
| 57 | } | ||
| 58 | return choices; | ||
| 59 | } | ||
| 60 | |||
| 61 | export function acpProviderOptionDescriptors(input: { | ||
| 62 | readonly configOptions: ReadonlyArray<EffectAcpSchema.SessionConfigOption> | null | undefined; | ||
| 63 | readonly modeState: AcpSessionModeState | undefined; | ||
| 64 | }): ReadonlyArray<ProviderOptionDescriptor> { | ||
| 65 | const descriptors: Array<ProviderOptionDescriptor> = []; | ||
| 66 | const seen = new Set<string>(); | ||
| 67 | const mirroredModeChoiceSets: Array<ReadonlySet<string>> = []; | ||
| 68 | let hasModeCategory = false; | ||
| 69 | |||
| 70 | for (const option of input.configOptions ?? []) { | ||
| 71 | // "model" options surface as the model list; "collaboration_mode" options | ||
| 72 | // are driven by T3's own plan/build interaction mode in the ACP adapter. | ||
| 73 | if (option.category === "model" || option.category === "collaboration_mode") { | ||
| 74 | continue; | ||
| 75 | } | ||
| 76 | const id = boundedOpaqueValue(option.id, MAX_TEXT_LENGTH); | ||
| 77 | if (id === undefined || seen.has(id)) continue; | ||
| 78 | seen.add(id); | ||
| 79 | const description = boundedText(option.description, MAX_DESCRIPTION_LENGTH); | ||
| 80 | const base = { | ||
| 81 | id, | ||
| 82 | label: boundedText(option.name, MAX_TEXT_LENGTH) || id, | ||
| 83 | ...(description ? { description } : {}), | ||
| 84 | } as const; | ||
| 85 | if (option.type === "boolean") { | ||
| 86 | if (option.category === "mode") hasModeCategory = true; | ||
| 87 | descriptors.push({ | ||
| 88 | ...base, | ||
| 89 | type: "boolean", | ||
| 90 | currentValue: option.currentValue, | ||
| 91 | }); | ||
| 92 | if (descriptors.length === MAX_OPTION_DESCRIPTORS) return descriptors; | ||
| 93 | continue; | ||
| 94 | } | ||
| 95 | const choices = selectChoices(flattenSelectChoices(option.options)); | ||
| 96 | if (choices.length === 0) { | ||
| 97 | seen.delete(id); | ||
| 98 | continue; | ||
| 99 | } | ||
| 100 | if (option.category === "mode") hasModeCategory = true; | ||
| 101 | if (option.category === "thought_level") { | ||
| 102 | mirroredModeChoiceSets.push(new Set(choices.map((choice) => choice.id))); | ||
| 103 | } | ||
| 104 | const currentValue = option.currentValue; | ||
| 105 | descriptors.push({ | ||
| 106 | ...base, | ||
| 107 | type: "select", | ||
| 108 | options: choices, | ||
| 109 | ...(currentValue && choices.some((choice) => choice.id === currentValue) | ||
| 110 | ? { currentValue } | ||
| 111 | : {}), | ||
| 112 | }); | ||
| 113 | if (descriptors.length === MAX_OPTION_DESCRIPTORS) return descriptors; | ||
| 114 | } | ||
| 115 | |||
| 116 | const modeState = input.modeState; | ||
| 117 | if (modeState !== undefined && !hasModeCategory) { | ||
| 118 | const choices = selectChoices( | ||
| 119 | modeState.availableModes.map((mode) => ({ | ||
| 120 | value: mode.id, | ||
| 121 | name: mode.name, | ||
| 122 | description: mode.description, | ||
| 123 | })), | ||
| 124 | ); | ||
| 125 | const currentValue = modeState.currentModeId; | ||
| 126 | // Some agents mirror one knob through both the modes API and a config | ||
| 127 | // option (codex-acp advertises its thinking levels as modes too). Skip | ||
| 128 | // the synthetic descriptor when an existing descriptor already exposes | ||
| 129 | // the same choice set, so the composer shows the knob once. | ||
| 130 | const duplicatesKnownModeDescriptor = mirroredModeChoiceSets.some( | ||
| 131 | (choiceIds) => | ||
| 132 | choiceIds.size === choices.length && choices.every((choice) => choiceIds.has(choice.id)), | ||
| 133 | ); | ||
| 134 | if ( | ||
| 135 | choices.length > 1 && | ||
| 136 | !duplicatesKnownModeDescriptor && | ||
| 137 | !seen.has(ACP_SESSION_MODE_OPTION_ID) | ||
| 138 | ) { | ||
| 139 | descriptors.push({ | ||
| 140 | id: ACP_SESSION_MODE_OPTION_ID, | ||
| 141 | label: "Mode", | ||
| 142 | description: "Session mode advertised by the ACP agent.", | ||
| 143 | type: "select", | ||
| 144 | options: choices, | ||
| 145 | ...(currentValue && choices.some((choice) => choice.id === currentValue) | ||
| 146 | ? { currentValue } | ||
| 147 | : {}), | ||
| 148 | }); | ||
| 149 | } | ||
| 150 | } | ||
| 151 | |||
| 152 | return descriptors.slice(0, MAX_OPTION_DESCRIPTORS); | ||
| 153 | } | ||
packages/provider-acp/src/server/stderr.test.ts created+56| ... | @@ -0,0 +1,56 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | ACP_STDERR_TAIL_MAX_CHARS, | ||
| 5 | appendAcpStderrTail, | ||
| 6 | sanitizeAcpStderrExcerpt, | ||
| 7 | } from "./stderr.ts"; | ||
| 8 | |||
| 9 | describe("AcpStderr", () => { | ||
| 10 | it("keeps a bounded tail of stderr chunks", () => { | ||
| 11 | const prefix = "x".repeat(ACP_STDERR_TAIL_MAX_CHARS); | ||
| 12 | expect(appendAcpStderrTail(prefix, "abc")).toBe(`${prefix.slice(3)}abc`); | ||
| 13 | }); | ||
| 14 | |||
| 15 | it("redacts home paths, pairing URLs, and tokens from stderr excerpts", () => { | ||
| 16 | const excerpt = sanitizeAcpStderrExcerpt( | ||
| 17 | [ | ||
| 18 | "Invalid project config at /Users/ada/.cursor/cli.json", | ||
| 19 | "Authorization: Bearer secret-token-value", | ||
| 20 | "Visit http://localhost:5733/pair#token=ABCDEF for pairing", | ||
| 21 | "key=sk-abcdefghijklmnopqrstuv", | ||
| 22 | ].join("\n"), | ||
| 23 | { HOME: "/Users/ada" }, | ||
| 24 | ); | ||
| 25 | |||
| 26 | expect(excerpt).toContain("Invalid project config at ~/.cursor/cli.json"); | ||
| 27 | expect(excerpt).toContain("Bearer [redacted]"); | ||
| 28 | expect(excerpt).toContain("[pairing-url]"); | ||
| 29 | expect(excerpt).toContain("[redacted]"); | ||
| 30 | expect(excerpt).not.toContain("secret-token-value"); | ||
| 31 | expect(excerpt).not.toContain("ABCDEF"); | ||
| 32 | expect(excerpt).not.toContain("sk-abcdefghijklmnopqrstuv"); | ||
| 33 | }); | ||
| 34 | |||
| 35 | it("redacts hyphenated OpenAI project keys and header credentials", () => { | ||
| 36 | const excerpt = sanitizeAcpStderrExcerpt( | ||
| 37 | [ | ||
| 38 | "openai=sk-proj-abcdefghijklmnopqrstuvwxyz012345", | ||
| 39 | "svc=sk-svcacct-abcdefghijklmnopqrstuvwxyz012345", | ||
| 40 | "anthropic=sk-ant-api03-abcdefghijklmnopqrstuvwxyz012345", | ||
| 41 | "Authorization: Basic dXNlcjpwYXNz", | ||
| 42 | "x-api-key: ant-api-key-value", | ||
| 43 | ].join("\n"), | ||
| 44 | ); | ||
| 45 | |||
| 46 | expect(excerpt).toContain("[redacted]"); | ||
| 47 | expect(excerpt).toContain("Authorization: Basic [redacted]"); | ||
| 48 | expect(excerpt).toContain("x-api-key: [redacted]"); | ||
| 49 | expect(excerpt).not.toContain("sk-proj-"); | ||
| 50 | expect(excerpt).not.toContain("sk-svcacct-"); | ||
| 51 | expect(excerpt).not.toContain("sk-ant-api03-"); | ||
| 52 | expect(excerpt).not.toContain("abcdefghijklmnopqrstuvwxyz012345"); | ||
| 53 | expect(excerpt).not.toContain("dXNlcjpwYXNz"); | ||
| 54 | expect(excerpt).not.toContain("ant-api-key-value"); | ||
| 55 | }); | ||
| 56 | }); | ||
packages/provider-acp/src/server/stderr.ts created+38| ... | @@ -0,0 +1,38 @@ | ||
| 1 | // @effect-diagnostics nodeBuiltinImport:off -- The excerpt sanitizer masks the home directory, which only the Node os module can resolve. | ||
| 2 | import * as NodeOS from "node:os"; | ||
| 3 | |||
| 4 | /** Last few KiB of ACP child stderr kept for startup / exit diagnostics. */ | ||
| 5 | export const ACP_STDERR_TAIL_MAX_CHARS = 4_096; | ||
| 6 | |||
| 7 | const PAIRING_URL_PATTERN = /https?:\/\/[^\s]*\/pair#[^\s]*/gi; | ||
| 8 | const BEARER_TOKEN_PATTERN = /\bBearer\s+[A-Za-z0-9._\-+=/]+/gi; | ||
| 9 | const BASIC_AUTH_PATTERN = /\bAuthorization:\s*Basic\s+\S+/gi; | ||
| 10 | const API_KEY_HEADER_PATTERN = /\bx-api-key:\s*\S+/gi; | ||
| 11 | const SECRET_TOKEN_PATTERN = | ||
| 12 | /\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; | ||
| 13 | |||
| 14 | export function appendAcpStderrTail(current: string, chunk: string): string { | ||
| 15 | const next = `${current}${chunk}`; | ||
| 16 | return next.length <= ACP_STDERR_TAIL_MAX_CHARS ? next : next.slice(-ACP_STDERR_TAIL_MAX_CHARS); | ||
| 17 | } | ||
| 18 | |||
| 19 | /** Bounded, redacted excerpt safe to put on user-facing adapter errors. */ | ||
| 20 | export function sanitizeAcpStderrExcerpt( | ||
| 21 | text: string, | ||
| 22 | environment: NodeJS.ProcessEnv = process.env, | ||
| 23 | ): string { | ||
| 24 | let result = text.replaceAll("\0", ""); | ||
| 25 | const homes = [environment.HOME, environment.USERPROFILE, NodeOS.homedir()].filter( | ||
| 26 | (value): value is string => typeof value === "string" && value.length > 1, | ||
| 27 | ); | ||
| 28 | for (const home of new Set(homes)) { | ||
| 29 | result = result.split(home).join("~"); | ||
| 30 | } | ||
| 31 | result = result | ||
| 32 | .replace(PAIRING_URL_PATTERN, "[pairing-url]") | ||
| 33 | .replace(BEARER_TOKEN_PATTERN, "Bearer [redacted]") | ||
| 34 | .replace(BASIC_AUTH_PATTERN, "Authorization: Basic [redacted]") | ||
| 35 | .replace(API_KEY_HEADER_PATTERN, "x-api-key: [redacted]") | ||
| 36 | .replace(SECRET_TOKEN_PATTERN, "[redacted]"); | ||
| 37 | return result.trim(); | ||
| 38 | } | ||
packages/provider-acp/tsconfig.json created+7| ... | @@ -0,0 +1,7 @@ | ||
| 1 | { | ||
| 2 | "extends": "../../tsconfig.base.json", | ||
| 3 | "compilerOptions": { | ||
| 4 | "types": ["node"] | ||
| 5 | }, | ||
| 6 | "include": ["src"] | ||
| 7 | } | ||
packages/provider-core/src/server/ProviderHost.ts+2| ... | @@ -32,6 +32,8 @@ export interface ProviderHostPaths { | ... | @@ -32,6 +32,8 @@ export interface ProviderHostPaths { |
| 32 | readonly stateDir: string; | 32 | readonly stateDir: string; |
| 33 | /** Scratch space for provider probes and generated helper files. */ | 33 | /** Scratch space for provider probes and generated helper files. */ |
| 34 | readonly providerStatusCacheDir: string; | 34 | readonly providerStatusCacheDir: string; |
| 35 | /** Where chat attachments are stored; agents that sandbox file reads must be granted it. */ | ||
| 36 | readonly attachmentsDir: string; | ||
| 35 | } | 37 | } |
| 36 | 38 | ||
| 37 | /** One opaque credential blob in the server's secret store. Only the provider decodes it. */ | 39 | /** One opaque credential blob in the server's secret store. Only the provider decodes it. */ |
packages/provider-core/src/server/managedProvider.test.ts+1| ... | @@ -109,6 +109,7 @@ function layerProviderHost(input: { | ... | @@ -109,6 +109,7 @@ function layerProviderHost(input: { |
| 109 | baseDir: "/t3", | 109 | baseDir: "/t3", |
| 110 | stateDir: "/t3/userdata", | 110 | stateDir: "/t3/userdata", |
| 111 | providerStatusCacheDir: "/t3/caches", | 111 | providerStatusCacheDir: "/t3/caches", |
| 112 | attachmentsDir: "/t3/userdata/attachments", | ||
| 112 | }, | 113 | }, |
| 113 | settings: input.settings ?? { | 114 | settings: input.settings ?? { |
| 114 | get: Effect.succeed(DEFAULT_SERVER_SETTINGS), | 115 | get: Effect.succeed(DEFAULT_SERVER_SETTINGS), |
packages/provider-testing/src/host.ts+7-1| ... | @@ -42,7 +42,13 @@ export const layerTestProviderHost = ( | ... | @@ -42,7 +42,13 @@ export const layerTestProviderHost = ( |
| 42 | const settings = options.settings ?? DEFAULT_SERVER_SETTINGS; | 42 | const settings = options.settings ?? DEFAULT_SERVER_SETTINGS; |
| 43 | const credentials = new Map<string, Uint8Array>(); | 43 | const credentials = new Map<string, Uint8Array>(); |
| 44 | return ProviderHost.ProviderHost.of({ | 44 | return ProviderHost.ProviderHost.of({ |
| 45 | paths: { cwd: options.cwd ?? process.cwd(), baseDir, stateDir, providerStatusCacheDir }, | 45 | paths: { |
| 46 | cwd: options.cwd ?? process.cwd(), | ||
| 47 | baseDir, | ||
| 48 | stateDir, | ||
| 49 | providerStatusCacheDir, | ||
| 50 | attachmentsDir, | ||
| 51 | }, | ||
| 46 | settings: { | 52 | settings: { |
| 47 | get: Effect.succeed(settings), | 53 | get: Effect.succeed(settings), |
| 48 | changes: Stream.empty, | 54 | changes: Stream.empty, |
packages/shared/package.json+4| ... | @@ -466,6 +466,10 @@ | ... | @@ -466,6 +466,10 @@ |
| 466 | "./atomicWrite": { | 466 | "./atomicWrite": { |
| 467 | "types": "./src/atomicWrite.ts", | 467 | "types": "./src/atomicWrite.ts", |
| 468 | "import": "./src/atomicWrite.ts" | 468 | "import": "./src/atomicWrite.ts" |
| 469 | }, | ||
| 470 | "./mcpResponsePayloads": { | ||
| 471 | "types": "./src/mcpResponsePayloads.ts", | ||
| 472 | "import": "./src/mcpResponsePayloads.ts" | ||
| 469 | } | 473 | } |
| 470 | }, | 474 | }, |
| 471 | "scripts": { | 475 | "scripts": { |
packages/shared/src/mcpResponsePayloads.ts created+65| ... | @@ -0,0 +1,65 @@ | ||
| 1 | // MCP responses carry opaque JSON-RPC that callers relay verbatim. | ||
| 2 | // @effect-diagnostics preferSchemaOverJson:off | ||
| 3 | /** | ||
| 4 | * Reads the JSON-RPC payloads of a response from an MCP streamable-HTTP | ||
| 5 | * endpoint. Used on the ACP bridge fast path, so it imports only effect core. | ||
| 6 | * | ||
| 7 | * @module mcpResponsePayloads | ||
| 8 | */ | ||
| 9 | import * as Effect from "effect/Effect"; | ||
| 10 | import * as Stream from "effect/Stream"; | ||
| 11 | |||
| 12 | async function* sseDataLines(response: Response): AsyncGenerator<string> { | ||
| 13 | if (response.body === null) return; | ||
| 14 | const decoder = new TextDecoder(); | ||
| 15 | let buffered = ""; | ||
| 16 | for await (const chunk of response.body) { | ||
| 17 | buffered += decoder.decode(chunk as Uint8Array, { stream: true }); | ||
| 18 | let separatorIndex = buffered.search(/\n\n|\r\n\r\n/u); | ||
| 19 | while (separatorIndex !== -1) { | ||
| 20 | const rawEvent = buffered.slice(0, separatorIndex); | ||
| 21 | buffered = buffered.slice(separatorIndex).replace(/^(?:\r?\n){2}/u, ""); | ||
| 22 | const data = rawEvent | ||
| 23 | .split(/\r?\n/u) | ||
| 24 | .filter((line) => line.startsWith("data:")) | ||
| 25 | .map((line) => line.slice("data:".length).trimStart()) | ||
| 26 | .join("\n"); | ||
| 27 | if (data.length > 0) yield data; | ||
| 28 | separatorIndex = buffered.search(/\n\n|\r\n\r\n/u); | ||
| 29 | } | ||
| 30 | } | ||
| 31 | } | ||
| 32 | |||
| 33 | export const discardResponseBody = (response: Response): Effect.Effect<void> => | ||
| 34 | Effect.promise(() => response.body?.cancel().catch(() => undefined) ?? Promise.resolve()); | ||
| 35 | |||
| 36 | /** | ||
| 37 | * Every JSON-RPC payload carried by a response, in arrival order: nothing for | ||
| 38 | * notification acknowledgements, each SSE `data:` event as it streams in, or | ||
| 39 | * the single JSON body. | ||
| 40 | */ | ||
| 41 | export function responsePayloads<E>( | ||
| 42 | response: Response, | ||
| 43 | onError: (cause: unknown) => E, | ||
| 44 | ): Stream.Stream<unknown, E> { | ||
| 45 | if (response.status === 202 || response.status === 204) { | ||
| 46 | return Stream.unwrap(discardResponseBody(response).pipe(Effect.as(Stream.empty))); | ||
| 47 | } | ||
| 48 | const contentType = response.headers.get("content-type") ?? ""; | ||
| 49 | if (contentType.includes("text/event-stream")) { | ||
| 50 | return Stream.fromAsyncIterable(sseDataLines(response), onError).pipe( | ||
| 51 | Stream.mapEffect((data) => Effect.try({ try: () => JSON.parse(data), catch: onError })), | ||
| 52 | ); | ||
| 53 | } | ||
| 54 | return Stream.unwrap( | ||
| 55 | Effect.tryPromise({ try: () => response.text(), catch: onError }).pipe( | ||
| 56 | Effect.flatMap((text) => | ||
| 57 | text.trim().length === 0 | ||
| 58 | ? Effect.succeed(Stream.empty) | ||
| 59 | : Effect.try({ try: () => JSON.parse(text) as unknown, catch: onError }).pipe( | ||
| 60 | Effect.map((payload) => Stream.fromIterable([payload])), | ||
| 61 | ), | ||
| 62 | ), | ||
| 63 | ), | ||
| 64 | ); | ||
| 65 | } | ||
pnpm-lock.yaml+37| ... | @@ -600,6 +600,9 @@ importers: | ... | @@ -600,6 +600,9 @@ importers: |
| 600 | '@opencode-ai/sdk': | 600 | '@opencode-ai/sdk': |
| 601 | specifier: ^1.3.15 | 601 | specifier: ^1.3.15 |
| 602 | version: 1.15.13 | 602 | version: 1.15.13 |
| 603 | '@t3tools/provider-acp': | ||
| 604 | specifier: workspace:* | ||
| 605 | version: link:../../packages/provider-acp | ||
| 603 | '@t3tools/provider-core': | 606 | '@t3tools/provider-core': |
| 604 | specifier: workspace:* | 607 | specifier: workspace:* |
| 605 | version: link:../../packages/provider-core | 608 | version: link:../../packages/provider-core |
| ... | @@ -1092,6 +1095,40 @@ importers: | ... | @@ -1092,6 +1095,40 @@ importers: |
| 1092 | specifier: 1.0.0 | 1095 | specifier: 1.0.0 |
| 1093 | 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) | 1096 | 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) |
| 1094 | 1097 | ||
| 1098 | packages/provider-acp: | ||
| 1099 | dependencies: | ||
| 1100 | '@t3tools/contracts': | ||
| 1101 | specifier: workspace:* | ||
| 1102 | version: link:../contracts | ||
| 1103 | '@t3tools/provider-core': | ||
| 1104 | specifier: workspace:* | ||
| 1105 | version: link:../provider-core | ||
| 1106 | '@t3tools/shared': | ||
| 1107 | specifier: workspace:* | ||
| 1108 | version: link:../shared | ||
| 1109 | diff: | ||
| 1110 | specifier: 8.0.3 | ||
| 1111 | version: 8.0.3 | ||
| 1112 | effect: | ||
| 1113 | specifier: 4.0.1 | ||
| 1114 | version: 4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f) | ||
| 1115 | devDependencies: | ||
| 1116 | '@effect/platform-node': | ||
| 1117 | specifier: 4.0.1 | ||
| 1118 | 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) | ||
| 1119 | '@effect/vitest': | ||
| 1120 | specifier: 4.0.1 | ||
| 1121 | 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) | ||
| 1122 | '@types/node': | ||
| 1123 | specifier: 24.12.4 | ||
| 1124 | version: 24.12.4 | ||
| 1125 | effect-acp: | ||
| 1126 | specifier: workspace:* | ||
| 1127 | version: link:../effect-acp | ||
| 1128 | vite-plus: | ||
| 1129 | specifier: 1.0.0 | ||
| 1130 | 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) | ||
| 1131 | |||
| 1095 | packages/provider-core: | 1132 | packages/provider-core: |
| 1096 | dependencies: | 1133 | dependencies: |
| 1097 | '@t3tools/contracts': | 1134 | '@t3tools/contracts': |
scripts/release-smoke.ts+1| ... | @@ -31,6 +31,7 @@ const workspaceFiles = [ | ... | @@ -31,6 +31,7 @@ const workspaceFiles = [ |
| 31 | "packages/provider-core/package.json", | 31 | "packages/provider-core/package.json", |
| 32 | "packages/provider-testing/package.json", | 32 | "packages/provider-testing/package.json", |
| 33 | "packages/provider-pi/package.json", | 33 | "packages/provider-pi/package.json", |
| 34 | "packages/provider-acp/package.json", | ||
| 34 | "packages/provider-cursor/package.json", | 35 | "packages/provider-cursor/package.json", |
| 35 | "packages/provider-muse/package.json", | 36 | "packages/provider-muse/package.json", |
| 36 | "packages/provider-opencode/package.json", | 37 | "packages/provider-opencode/package.json", |