authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-08 18:30:58-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-08 18:30:58-07:00
log664bd5e524bb58b23ecab0f2e413fc2ecb4caf55
tree4f41b951c26a296d6f1abba0bd04e00bb2cd7b73
parentb5e4f1e0b0c2c06f183ab6a89072bec21c8334c4
signature Signed by PGP key B5690EEEBB952194

refactor(provider-acp): move the shared ACP adapter into its own package (#17354)

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 @@
3838 "@napi-rs/keyring": "^1.3.0",
3939 "@noble/hashes": "catalog:",
4040 "@opencode-ai/sdk": "^1.3.15",
41 "@t3tools/provider-acp": "workspace:*",
4142 "@t3tools/provider-core": "workspace:*",
4243 "@t3tools/provider-cursor": "workspace:*",
4344 "@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";
2424import { ChildProcessSpawner } from "effect/process";
2525import type * as EffectAcpProtocol from "effect-acp/protocol";
2626
27import * as ServerConfig from "../src/config.ts";
27import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";
28import { layerTestProviderHost } from "@t3tools/provider-testing/host";
2829import {
2930 GROK_DEFAULT_INSTANCE_ID,
3031 GROK_PROVIDER,
3132 grokLaunchRuntimeMode,
3233 makeGrokAdapterV2,
3334} from "../src/orchestration-v2/Adapters/GrokAdapterV2.ts";
34import { ACP_PROTOCOL } from "../src/orchestration-v2/Adapters/AcpAdapterV2.ts";
35import { ACP_PROTOCOL } from "@t3tools/provider-acp/server/adapter";
3536import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
3637import type { ProviderAdapterV2SessionRuntime } from "@t3tools/provider-core/server/ProviderAdapter";
3738import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests";
......@@ -40,7 +41,6 @@ import { provideDeterministicTestRuntime } from "../src/orchestration-v2/testkit
4041import { ORCHESTRATOR_REPLAY_FIXTURES } from "../src/orchestration-v2/testkit/fixtures/index.ts";
4142import { materializeFixtureInput } from "../src/orchestration-v2/testkit/fixtures/shared.ts";
4243import { runOrchestratorV2Scenario } from "../src/orchestration-v2/testkit/OrchestratorScenario.ts";
43import { makeReplayServerConfig } from "../src/orchestration-v2/testkit/ProviderReplayHarness.ts";
4444import * as ProviderReplayHarness from "../src/orchestration-v2/testkit/ProviderReplayHarness.ts";
4545import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace";
4646import { makeGrokAcpRuntime } from "../src/provider/acp/GrokAcpSupport.ts";
......@@ -455,7 +455,7 @@ const recordScenario = Effect.fn("recordGrokScenario")(function* (fixtureName: s
455455 crypto: yield* Crypto.Crypto,
456456 fileSystem: yield* FileSystem.FileSystem,
457457 idAllocator: yield* IdAllocator.IdAllocatorV2,
458 serverConfig: yield* ServerConfig.ServerConfig,
458 host: yield* ProviderHost.ProviderHost,
459459 selfInvocation: yield* resolveSelfInvocation(),
460460 continuationRequests: yield* ProviderContinuationRequests.ProviderContinuationRequests,
461461 // Production's runtime factory, with the protocol logger teeing raw lines.
......@@ -496,10 +496,7 @@ const recordScenario = Effect.fn("recordGrokScenario")(function* (fixtureName: s
496496 ).pipe(
497497 Layer.provide(
498498 Layer.mergeAll(
499 Layer.effect(
500 ServerConfig.ServerConfig,
501 makeReplayServerConfig(`grok-record-${fixtureName}`),
502 ).pipe(Layer.provide(NodeServices.layer)),
499 layerTestProviderHost().pipe(Layer.provide(NodeServices.layer)),
503500 NodeServices.layer,
504501 IdAllocator.layer,
505502 ),
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.
2import { describe, expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Fiber from "effect/Fiber";
5
6import { makeAcpMcpOverAcpBridge } from "./AcpMcpOverAcpBridge.ts";
7
8describe("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
3import * as Effect from "effect/Effect";
4import * as Semaphore from "effect/Semaphore";
5import * as Stream from "effect/Stream";
6import type * as AcpSchema from "effect-acp/compat";
7
8import { responsePayloads } from "./AcpMcpStdioBridge.ts";
9
10const MAX_CONNECTIONS = 16;
11const MAX_MESSAGE_BYTES = 8 * 1024 * 1024;
12
13interface JsonRpcEnvelope {
14 readonly id?: unknown;
15 readonly method?: unknown;
16 readonly error?: unknown;
17 readonly result?: unknown;
18}
19
20interface Connection {
21 readonly mutex: Semaphore.Semaphore;
22 sessionId: string | null;
23 protocolVersion: string | null;
24 nextRequestId: number;
25}
26
27export class AcpMcpOverAcpError extends Error {
28 readonly _tag = "AcpMcpOverAcpError";
29 override readonly name = "AcpMcpOverAcpError";
30}
31
32const bridgeError = (cause: unknown): AcpMcpOverAcpError =>
33 cause instanceof AcpMcpOverAcpError
34 ? cause
35 : new AcpMcpOverAcpError(cause instanceof Error ? cause.message : String(cause), { cause });
36
37const asEnvelope = (value: unknown): JsonRpcEnvelope | null =>
38 typeof value === "object" && value !== null && !Array.isArray(value)
39 ? (value as JsonRpcEnvelope)
40 : null;
41
42function 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
49function 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
59export 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
66export 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. */
83export 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";
77import * as Effect from "effect/Effect";
88import * as Fiber from "effect/Fiber";
99import * as Stream from "effect/Stream";
10import { discardResponseBody, responsePayloads } from "@t3tools/shared/mcpResponsePayloads";
1011
1112/**
1213 * Stdio-to-HTTP bridge for T3's MCP endpoint.
......@@ -63,58 +64,6 @@ function protocolVersionOf(entry: unknown): string | null {
6364 return typeof version === "string" && version.length > 0 ? version : null;
6465}
6566
66async 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
87const 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 */
95export 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
11867export interface AcpMcpToolCallOptions {
11968 readonly endpoint: string;
12069 readonly authorization: string;
......@@ -164,7 +113,7 @@ export function callAcpMcpTool(
164113 new AcpMcpBridgeError(`T3 Code MCP endpoint responded with HTTP ${response.status}.`),
165114 );
166115 }
167 const payloads = yield* Stream.runCollect(responsePayloads(response));
116 const payloads = yield* Stream.runCollect(responsePayloads(response, bridgeError));
168117 for (const payload of payloads) {
169118 protocolVersion = protocolVersionOf(payload) ?? protocolVersion;
170119 }
......@@ -271,7 +220,7 @@ export function runAcpMcpStdioBridge(options: AcpMcpStdioBridgeOptions): Effect.
271220 }
272221 return yield* discardResponseBody(response);
273222 }
274 yield* Stream.runForEach(responsePayloads(response), handleServerPayload);
223 yield* Stream.runForEach(responsePayloads(response, bridgeError), handleServerPayload);
275224 }).pipe(
276225 Effect.catchCause((cause) => {
277226 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";
5454import type * as EffectAcpProtocol from "effect-acp/protocol";
5555import type * as EffectAcpSchema from "effect-acp/compat";
5656
57import * as ServerConfig from "../../config.ts";
57import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";
58import { layerTestProviderHost } from "@t3tools/provider-testing/host";
5859import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession";
59import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts";
60import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";
6061import {
6162 extractXAiAcpSubagentEndNotice,
6263 extractXAiAcpSubagentUpdate,
......@@ -90,7 +91,7 @@ import {
9091 type AcpAdapterV2Flavor,
9192 type AcpAdapterV2RuntimeInput,
9293 type AcpAdapterV2SubagentUpdate,
93} from "./AcpAdapterV2.ts";
94} from "@t3tools/provider-acp/server/adapter";
9495
9596import { makeGrokAdapterV2 } from "./GrokAdapterV2.ts";
9697import {
......@@ -100,11 +101,9 @@ import {
100101
101102const DEFAULT_GROK_SETTINGS = Schema.decodeSync(GrokSettings)({});
102103
103const layerServerConfig = ServerConfig.layerTest(process.cwd(), {
104 prefix: "t3-acp-v2-adapter-",
105}).pipe(Layer.provide(NodeServices.layer));
104const layerHost = layerTestProviderHost().pipe(Layer.provide(NodeServices.layer));
106105
107const layerTest = Layer.mergeAll(NodeServices.layer, IdAllocator.layer, layerServerConfig);
106const layerTest = Layer.mergeAll(NodeServices.layer, IdAllocator.layer, layerHost);
108107const ACP_TEST_DRIVER = ProviderDriverKind.make("acp-test");
109108const decodeUnknownJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown));
110109
......@@ -616,7 +615,7 @@ describe("AcpAdapterV2", () => {
616615 instanceId,
617616 fileSystem: yield* FileSystem.FileSystem,
618617 idAllocator: yield* IdAllocator.IdAllocatorV2,
619 serverConfig: yield* ServerConfig.ServerConfig,
618 host: yield* ProviderHost.ProviderHost,
620619 selfInvocation: yield* resolveSelfInvocation(),
621620 flavor: {
622621 driver: ProviderDriverKind.make("acpRegistry"),
......@@ -713,7 +712,7 @@ describe("AcpAdapterV2", () => {
713712 const fileSystem = yield* FileSystem.FileSystem;
714713 const idAllocator = yield* IdAllocator.IdAllocatorV2;
715714 const path = yield* Path.Path;
716 const serverConfig = yield* ServerConfig.ServerConfig;
715 const host = yield* ProviderHost.ProviderHost;
717716 const selfInvocation = yield* resolveSelfInvocation().pipe(
718717 Effect.provideService(HostProcessIsExecutable, true),
719718 );
......@@ -753,7 +752,7 @@ describe("AcpAdapterV2", () => {
753752 },
754753 fileSystem,
755754 idAllocator,
756 serverConfig,
755 host,
757756 selfInvocation,
758757 });
759758 const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({
......@@ -786,7 +785,7 @@ describe("AcpAdapterV2", () => {
786785 const fileSystem = yield* FileSystem.FileSystem;
787786 const idAllocator = yield* IdAllocator.IdAllocatorV2;
788787 const path = yield* Path.Path;
789 const serverConfig = yield* ServerConfig.ServerConfig;
788 const host = yield* ProviderHost.ProviderHost;
790789 const selfInvocation = yield* resolveSelfInvocation();
791790 const mockAgentPath = yield* path.fromFileUrl(
792791 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -816,7 +815,7 @@ describe("AcpAdapterV2", () => {
816815 },
817816 fileSystem,
818817 idAllocator,
819 serverConfig,
818 host,
820819 selfInvocation,
821820 });
822821 const policy = (interactionMode: "default" | "plan") =>
......@@ -913,7 +912,7 @@ describe("AcpAdapterV2", () => {
913912 const fileSystem = yield* FileSystem.FileSystem;
914913 const idAllocator = yield* IdAllocator.IdAllocatorV2;
915914 const path = yield* Path.Path;
916 const serverConfig = yield* ServerConfig.ServerConfig;
915 const host = yield* ProviderHost.ProviderHost;
917916 const selfInvocation = yield* resolveSelfInvocation();
918917 const mockAgentPath = yield* path.fromFileUrl(
919918 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -941,7 +940,7 @@ describe("AcpAdapterV2", () => {
941940 },
942941 fileSystem,
943942 idAllocator,
944 serverConfig,
943 host,
945944 selfInvocation,
946945 });
947946 const threadId = ThreadId.make("thread-acp-v2-plan-replay-boundary");
......@@ -1049,7 +1048,7 @@ describe("AcpAdapterV2", () => {
10491048 crypto: yield* Crypto.Crypto,
10501049 fileSystem: yield* FileSystem.FileSystem,
10511050 idAllocator: yield* IdAllocator.IdAllocatorV2,
1052 serverConfig: yield* ServerConfig.ServerConfig,
1051 host: yield* ProviderHost.ProviderHost,
10531052 selfInvocation: yield* resolveSelfInvocation(),
10541053 flavor: {
10551054 driver: ACP_TEST_DRIVER,
......@@ -1195,7 +1194,7 @@ describe("AcpAdapterV2", () => {
11951194 crypto: yield* Crypto.Crypto,
11961195 fileSystem: yield* FileSystem.FileSystem,
11971196 idAllocator,
1198 serverConfig: yield* ServerConfig.ServerConfig,
1197 host: yield* ProviderHost.ProviderHost,
11991198 selfInvocation: yield* resolveSelfInvocation(),
12001199 flavor: {
12011200 driver: ACP_TEST_DRIVER,
......@@ -1515,7 +1514,7 @@ describe("AcpAdapterV2", () => {
15151514 const fileSystem = yield* FileSystem.FileSystem;
15161515 const idAllocator = yield* IdAllocator.IdAllocatorV2;
15171516 const path = yield* Path.Path;
1518 const serverConfig = yield* ServerConfig.ServerConfig;
1517 const host = yield* ProviderHost.ProviderHost;
15191518 const selfInvocation = yield* resolveSelfInvocation();
15201519 const mockAgentPath = yield* path.fromFileUrl(
15211520 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -1544,7 +1543,7 @@ describe("AcpAdapterV2", () => {
15441543 },
15451544 fileSystem,
15461545 idAllocator,
1547 serverConfig,
1546 host,
15481547 selfInvocation,
15491548 });
15501549 const threadId = ThreadId.make("thread-acp-v2-fidelity");
......@@ -1709,7 +1708,7 @@ describe("AcpAdapterV2", () => {
17091708 const fileSystem = yield* FileSystem.FileSystem;
17101709 const idAllocator = yield* IdAllocator.IdAllocatorV2;
17111710 const path = yield* Path.Path;
1712 const serverConfig = yield* ServerConfig.ServerConfig;
1711 const host = yield* ProviderHost.ProviderHost;
17131712 const selfInvocation = yield* resolveSelfInvocation();
17141713 const mockAgentPath = yield* path.fromFileUrl(
17151714 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -1730,7 +1729,7 @@ describe("AcpAdapterV2", () => {
17301729 },
17311730 fileSystem,
17321731 idAllocator,
1733 serverConfig,
1732 host,
17341733 selfInvocation,
17351734 });
17361735 const threadId = ThreadId.make("thread-acp-eager-resume");
......@@ -1794,7 +1793,7 @@ describe("AcpAdapterV2", () => {
17941793 const fileSystem = yield* FileSystem.FileSystem;
17951794 const idAllocator = yield* IdAllocator.IdAllocatorV2;
17961795 const path = yield* Path.Path;
1797 const serverConfig = yield* ServerConfig.ServerConfig;
1796 const host = yield* ProviderHost.ProviderHost;
17981797 const selfInvocation = yield* resolveSelfInvocation();
17991798 const mockAgentPath = yield* path.fromFileUrl(
18001799 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -1817,7 +1816,7 @@ describe("AcpAdapterV2", () => {
18171816 },
18181817 fileSystem,
18191818 idAllocator,
1820 serverConfig,
1819 host,
18211820 selfInvocation,
18221821 });
18231822 const threadId = ThreadId.make("thread-acp-stale-eager-resume");
......@@ -1940,7 +1939,7 @@ describe("AcpAdapterV2", () => {
19401939 const fileSystem = yield* FileSystem.FileSystem;
19411940 const idAllocator = yield* IdAllocator.IdAllocatorV2;
19421941 const path = yield* Path.Path;
1943 const serverConfig = yield* ServerConfig.ServerConfig;
1942 const host = yield* ProviderHost.ProviderHost;
19441943 const selfInvocation = yield* resolveSelfInvocation();
19451944 const mockAgentPath = yield* path.fromFileUrl(
19461945 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -1969,7 +1968,7 @@ describe("AcpAdapterV2", () => {
19691968 },
19701969 fileSystem,
19711970 idAllocator,
1972 serverConfig,
1971 host,
19731972 selfInvocation,
19741973 });
19751974 const threadId = ThreadId.make("thread-acp-unexpected-termination");
......@@ -2023,7 +2022,7 @@ describe("AcpAdapterV2", () => {
20232022 const fileSystem = yield* FileSystem.FileSystem;
20242023 const idAllocator = yield* IdAllocator.IdAllocatorV2;
20252024 const path = yield* Path.Path;
2026 const serverConfig = yield* ServerConfig.ServerConfig;
2025 const host = yield* ProviderHost.ProviderHost;
20272026 const selfInvocation = yield* resolveSelfInvocation();
20282027 const mockAgentPath = yield* path.fromFileUrl(
20292028 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -2058,7 +2057,7 @@ describe("AcpAdapterV2", () => {
20582057 },
20592058 fileSystem,
20602059 idAllocator,
2061 serverConfig,
2060 host,
20622061 selfInvocation,
20632062 });
20642063 const threadId = ThreadId.make("thread-acp-provider-exit-running-command");
......@@ -2108,7 +2107,7 @@ describe("AcpAdapterV2", () => {
21082107 const fileSystem = yield* FileSystem.FileSystem;
21092108 const idAllocator = yield* IdAllocator.IdAllocatorV2;
21102109 const path = yield* Path.Path;
2111 const serverConfig = yield* ServerConfig.ServerConfig;
2110 const host = yield* ProviderHost.ProviderHost;
21122111 const selfInvocation = yield* resolveSelfInvocation();
21132112 const mockAgentPath = yield* path.fromFileUrl(
21142113 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -2137,7 +2136,7 @@ describe("AcpAdapterV2", () => {
21372136 },
21382137 fileSystem,
21392138 idAllocator,
2140 serverConfig,
2139 host,
21412140 selfInvocation,
21422141 });
21432142 const threadId = ThreadId.make("thread-acp-cgroup-unavailable");
......@@ -2164,7 +2163,7 @@ describe("AcpAdapterV2", () => {
21642163 const fileSystem = yield* FileSystem.FileSystem;
21652164 const idAllocator = yield* IdAllocator.IdAllocatorV2;
21662165 const path = yield* Path.Path;
2167 const serverConfig = yield* ServerConfig.ServerConfig;
2166 const host = yield* ProviderHost.ProviderHost;
21682167 const selfInvocation = yield* resolveSelfInvocation();
21692168 const mockAgentPath = yield* path.fromFileUrl(
21702169 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -2209,7 +2208,7 @@ describe("AcpAdapterV2", () => {
22092208 },
22102209 fileSystem,
22112210 idAllocator,
2212 serverConfig,
2211 host,
22132212 selfInvocation,
22142213 });
22152214 const threadId = ThreadId.make("thread-acp-cgroup-join-failure");
......@@ -2242,7 +2241,7 @@ describe("AcpAdapterV2", () => {
22422241 const fileSystem = yield* FileSystem.FileSystem;
22432242 const idAllocator = yield* IdAllocator.IdAllocatorV2;
22442243 const path = yield* Path.Path;
2245 const serverConfig = yield* ServerConfig.ServerConfig;
2244 const host = yield* ProviderHost.ProviderHost;
22462245 const selfInvocation = yield* resolveSelfInvocation();
22472246 const mockAgentPath = yield* path.fromFileUrl(
22482247 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -2286,7 +2285,7 @@ describe("AcpAdapterV2", () => {
22862285 },
22872286 fileSystem,
22882287 idAllocator,
2289 serverConfig,
2288 host,
22902289 selfInvocation,
22912290 clientTerminals: { childProcessSpawner },
22922291 });
......@@ -2454,7 +2453,7 @@ describe("AcpAdapterV2", () => {
24542453 const fileSystem = yield* FileSystem.FileSystem;
24552454 const idAllocator = yield* IdAllocator.IdAllocatorV2;
24562455 const path = yield* Path.Path;
2457 const serverConfig = yield* ServerConfig.ServerConfig;
2456 const host = yield* ProviderHost.ProviderHost;
24582457 const selfInvocation = yield* resolveSelfInvocation();
24592458 const mockAgentPath = yield* path.fromFileUrl(
24602459 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -2484,7 +2483,7 @@ describe("AcpAdapterV2", () => {
24842483 },
24852484 fileSystem,
24862485 idAllocator,
2487 serverConfig,
2486 host,
24882487 selfInvocation,
24892488 });
24902489 const threadId = ThreadId.make("thread-acp-no-client-fs");
......@@ -2551,7 +2550,7 @@ describe("AcpAdapterV2", () => {
25512550 const fileSystem = yield* FileSystem.FileSystem;
25522551 const idAllocator = yield* IdAllocator.IdAllocatorV2;
25532552 const path = yield* Path.Path;
2554 const serverConfig = yield* ServerConfig.ServerConfig;
2553 const host = yield* ProviderHost.ProviderHost;
25552554 const selfInvocation = yield* resolveSelfInvocation();
25562555 const mockAgentPath = yield* path.fromFileUrl(
25572556 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -2591,7 +2590,7 @@ describe("AcpAdapterV2", () => {
25912590 },
25922591 fileSystem,
25932592 idAllocator,
2594 serverConfig,
2593 host,
25952594 selfInvocation,
25962595 clientTerminals: { childProcessSpawner },
25972596 });
......@@ -2688,7 +2687,7 @@ describe("AcpAdapterV2", () => {
26882687 const fileSystem = yield* FileSystem.FileSystem;
26892688 const idAllocator = yield* IdAllocator.IdAllocatorV2;
26902689 const path = yield* Path.Path;
2691 const serverConfig = yield* ServerConfig.ServerConfig;
2690 const host = yield* ProviderHost.ProviderHost;
26922691 const selfInvocation = yield* resolveSelfInvocation();
26932692 const mockAgentPath = yield* path.fromFileUrl(
26942693 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -2704,7 +2703,7 @@ describe("AcpAdapterV2", () => {
27042703 },
27052704 fileSystem,
27062705 idAllocator,
2707 serverConfig,
2706 host,
27082707 selfInvocation,
27092708 });
27102709 const threadId = ThreadId.make("thread-acp-missing-native-thread");
......@@ -2750,7 +2749,7 @@ describe("AcpAdapterV2", () => {
27502749 const fileSystem = yield* FileSystem.FileSystem;
27512750 const idAllocator = yield* IdAllocator.IdAllocatorV2;
27522751 const path = yield* Path.Path;
2753 const serverConfig = yield* ServerConfig.ServerConfig;
2752 const host = yield* ProviderHost.ProviderHost;
27542753 const selfInvocation = yield* resolveSelfInvocation();
27552754 const mockAgentPath = yield* path.fromFileUrl(
27562755 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -2788,7 +2787,7 @@ describe("AcpAdapterV2", () => {
27882787 },
27892788 fileSystem,
27902789 idAllocator,
2791 serverConfig,
2790 host,
27922791 selfInvocation,
27932792 });
27942793 const threadId = ThreadId.make("thread-acp-rollback-session");
......@@ -2917,7 +2916,7 @@ describe("AcpAdapterV2", () => {
29172916 const fileSystem = yield* FileSystem.FileSystem;
29182917 const idAllocator = yield* IdAllocator.IdAllocatorV2;
29192918 const path = yield* Path.Path;
2920 const serverConfig = yield* ServerConfig.ServerConfig;
2919 const host = yield* ProviderHost.ProviderHost;
29212920 const selfInvocation = yield* resolveSelfInvocation();
29222921 const mockAgentPath = yield* path.fromFileUrl(
29232922 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -3001,7 +3000,7 @@ describe("AcpAdapterV2", () => {
30013000 },
30023001 fileSystem,
30033002 idAllocator,
3004 serverConfig,
3003 host,
30053004 selfInvocation,
30063005 continuationRequests: { offer: () => Effect.void },
30073006 });
......@@ -3078,7 +3077,7 @@ describe("AcpAdapterV2", () => {
30783077 const fileSystem = yield* FileSystem.FileSystem;
30793078 const idAllocator = yield* IdAllocator.IdAllocatorV2;
30803079 const path = yield* Path.Path;
3081 const serverConfig = yield* ServerConfig.ServerConfig;
3080 const host = yield* ProviderHost.ProviderHost;
30823081 const selfInvocation = yield* resolveSelfInvocation();
30833082 const mockAgentPath = yield* path.fromFileUrl(
30843083 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -3120,7 +3119,7 @@ describe("AcpAdapterV2", () => {
31203119 },
31213120 fileSystem,
31223121 idAllocator,
3123 serverConfig,
3122 host,
31243123 selfInvocation,
31253124 });
31263125 const threadId = ThreadId.make("thread-acp-rollback-failure-compensation");
......@@ -3194,7 +3193,7 @@ describe("AcpAdapterV2", () => {
31943193 const fileSystem = yield* FileSystem.FileSystem;
31953194 const idAllocator = yield* IdAllocator.IdAllocatorV2;
31963195 const path = yield* Path.Path;
3197 const serverConfig = yield* ServerConfig.ServerConfig;
3196 const host = yield* ProviderHost.ProviderHost;
31983197 const selfInvocation = yield* resolveSelfInvocation();
31993198 const mockAgentPath = yield* path.fromFileUrl(
32003199 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -3211,7 +3210,7 @@ describe("AcpAdapterV2", () => {
32113210 },
32123211 fileSystem,
32133212 idAllocator,
3214 serverConfig,
3213 host,
32153214 selfInvocation,
32163215 });
32173216 const threadId = ThreadId.make("thread-acp-idle-finalizer");
......@@ -3245,7 +3244,7 @@ describe("AcpAdapterV2", () => {
32453244 const fileSystem = yield* FileSystem.FileSystem;
32463245 const idAllocator = yield* IdAllocator.IdAllocatorV2;
32473246 const path = yield* Path.Path;
3248 const serverConfig = yield* ServerConfig.ServerConfig;
3247 const host = yield* ProviderHost.ProviderHost;
32493248 const selfInvocation = yield* resolveSelfInvocation();
32503249 const mockAgentPath = yield* path.fromFileUrl(
32513250 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -3261,7 +3260,7 @@ describe("AcpAdapterV2", () => {
32613260 crypto: yield* Crypto.Crypto,
32623261 fileSystem,
32633262 idAllocator,
3264 serverConfig,
3263 host,
32653264 selfInvocation: yield* resolveSelfInvocation(),
32663265 // Production Grok runtimes are wrapped by the x.ai prompt runtime.
32673266 makeRuntime: (input) =>
......@@ -3300,7 +3299,7 @@ describe("AcpAdapterV2", () => {
33003299 const fileSystem = yield* FileSystem.FileSystem;
33013300 const idAllocator = yield* IdAllocator.IdAllocatorV2;
33023301 const path = yield* Path.Path;
3303 const serverConfig = yield* ServerConfig.ServerConfig;
3302 const host = yield* ProviderHost.ProviderHost;
33043303 const selfInvocation = yield* resolveSelfInvocation();
33053304 const mockAgentPath = yield* path.fromFileUrl(
33063305 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -3316,7 +3315,7 @@ describe("AcpAdapterV2", () => {
33163315 crypto: yield* Crypto.Crypto,
33173316 fileSystem,
33183317 idAllocator,
3319 serverConfig,
3318 host,
33203319 selfInvocation: yield* resolveSelfInvocation(),
33213320 // Production Grok runtimes are wrapped by the x.ai prompt runtime.
33223321 makeRuntime: (input) =>
......@@ -3381,7 +3380,7 @@ describe("AcpAdapterV2", () => {
33813380 const fileSystem = yield* FileSystem.FileSystem;
33823381 const idAllocator = yield* IdAllocator.IdAllocatorV2;
33833382 const path = yield* Path.Path;
3384 const serverConfig = yield* ServerConfig.ServerConfig;
3383 const host = yield* ProviderHost.ProviderHost;
33853384 const selfInvocation = yield* resolveSelfInvocation();
33863385 const mockAgentPath = yield* path.fromFileUrl(
33873386 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -3397,7 +3396,7 @@ describe("AcpAdapterV2", () => {
33973396 },
33983397 fileSystem,
33993398 idAllocator,
3400 serverConfig,
3399 host,
34013400 selfInvocation,
34023401 });
34033402 const threadId = ThreadId.make("thread-acp-unsupported-option");
......@@ -3430,7 +3429,7 @@ describe("AcpAdapterV2", () => {
34303429 const fileSystem = yield* FileSystem.FileSystem;
34313430 const idAllocator = yield* IdAllocator.IdAllocatorV2;
34323431 const path = yield* Path.Path;
3433 const serverConfig = yield* ServerConfig.ServerConfig;
3432 const host = yield* ProviderHost.ProviderHost;
34343433 const selfInvocation = yield* resolveSelfInvocation();
34353434 const mockAgentPath = yield* path.fromFileUrl(
34363435 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -3447,7 +3446,7 @@ describe("AcpAdapterV2", () => {
34473446 },
34483447 fileSystem,
34493448 idAllocator,
3450 serverConfig,
3449 host,
34513450 selfInvocation,
34523451 });
34533452 const firstThreadId = ThreadId.make("thread-acp-active-setup:first");
......@@ -3541,7 +3540,7 @@ describe("AcpAdapterV2", () => {
35413540 const fileSystem = yield* FileSystem.FileSystem;
35423541 const idAllocator = yield* IdAllocator.IdAllocatorV2;
35433542 const path = yield* Path.Path;
3544 const serverConfig = yield* ServerConfig.ServerConfig;
3543 const host = yield* ProviderHost.ProviderHost;
35453544 const selfInvocation = yield* resolveSelfInvocation();
35463545 const mockAgentPath = yield* path.fromFileUrl(
35473546 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -3568,7 +3567,7 @@ describe("AcpAdapterV2", () => {
35683567 },
35693568 fileSystem,
35703569 idAllocator,
3571 serverConfig,
3570 host,
35723571 selfInvocation,
35733572 });
35743573 const threadId = ThreadId.make("thread-acp-empty-successful-bash");
......@@ -3688,7 +3687,7 @@ describe("AcpAdapterV2", () => {
36883687 const fileSystem = yield* FileSystem.FileSystem;
36893688 const idAllocator = yield* IdAllocator.IdAllocatorV2;
36903689 const path = yield* Path.Path;
3691 const serverConfig = yield* ServerConfig.ServerConfig;
3690 const host = yield* ProviderHost.ProviderHost;
36923691 const selfInvocation = yield* resolveSelfInvocation();
36933692 const protocolEvents = yield* Queue.unbounded<EffectAcpProtocol.AcpProtocolLogEvent>();
36943693 const native: { current?: AcpSessionRuntime.AcpSessionRuntime["Service"] } = {};
......@@ -3715,7 +3714,7 @@ describe("AcpAdapterV2", () => {
37153714 },
37163715 fileSystem,
37173716 idAllocator,
3718 serverConfig,
3717 host,
37193718 selfInvocation,
37203719 });
37213720 const threadId = ThreadId.make("thread-acp-native-cancel");
......@@ -3802,7 +3801,7 @@ describe("AcpAdapterV2", () => {
38023801 const fileSystem = yield* FileSystem.FileSystem;
38033802 const idAllocator = yield* IdAllocator.IdAllocatorV2;
38043803 const path = yield* Path.Path;
3805 const serverConfig = yield* ServerConfig.ServerConfig;
3804 const host = yield* ProviderHost.ProviderHost;
38063805 const selfInvocation = yield* resolveSelfInvocation();
38073806 const mockAgentPath = yield* path.fromFileUrl(
38083807 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -3824,7 +3823,7 @@ describe("AcpAdapterV2", () => {
38243823 },
38253824 fileSystem,
38263825 idAllocator,
3827 serverConfig,
3826 host,
38283827 selfInvocation,
38293828 });
38303829 const threadId = ThreadId.make("thread-acp-cancel-permission");
......@@ -3903,7 +3902,7 @@ describe("AcpAdapterV2", () => {
39033902 const fileSystem = yield* FileSystem.FileSystem;
39043903 const idAllocator = yield* IdAllocator.IdAllocatorV2;
39053904 const path = yield* Path.Path;
3906 const serverConfig = yield* ServerConfig.ServerConfig;
3905 const host = yield* ProviderHost.ProviderHost;
39073906 const selfInvocation = yield* resolveSelfInvocation();
39083907 const mockAgentPath = yield* path.fromFileUrl(
39093908 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -3930,7 +3929,7 @@ describe("AcpAdapterV2", () => {
39303929 },
39313930 fileSystem,
39323931 idAllocator,
3933 serverConfig,
3932 host,
39343933 selfInvocation,
39353934 });
39363935 const threadId = ThreadId.make("thread-acp-response-wins-permission");
......@@ -4002,7 +4001,7 @@ describe("AcpAdapterV2", () => {
40024001 const fileSystem = yield* FileSystem.FileSystem;
40034002 const idAllocator = yield* IdAllocator.IdAllocatorV2;
40044003 const path = yield* Path.Path;
4005 const serverConfig = yield* ServerConfig.ServerConfig;
4004 const host = yield* ProviderHost.ProviderHost;
40064005 const selfInvocation = yield* resolveSelfInvocation();
40074006 const mockAgentPath = yield* path.fromFileUrl(
40084007 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -4029,7 +4028,7 @@ describe("AcpAdapterV2", () => {
40294028 },
40304029 fileSystem,
40314030 idAllocator,
4032 serverConfig,
4031 host,
40334032 selfInvocation,
40344033 });
40354034 const threadId = ThreadId.make("thread-acp-reordered-elicitation");
......@@ -4091,7 +4090,7 @@ describe("AcpAdapterV2", () => {
40914090 const fileSystem = yield* FileSystem.FileSystem;
40924091 const idAllocator = yield* IdAllocator.IdAllocatorV2;
40934092 const path = yield* Path.Path;
4094 const serverConfig = yield* ServerConfig.ServerConfig;
4093 const host = yield* ProviderHost.ProviderHost;
40954094 const selfInvocation = yield* resolveSelfInvocation();
40964095 const mockAgentPath = yield* path.fromFileUrl(
40974096 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -4111,7 +4110,7 @@ describe("AcpAdapterV2", () => {
41114110 },
41124111 fileSystem,
41134112 idAllocator,
4114 serverConfig,
4113 host,
41154114 selfInvocation,
41164115 });
41174116 const threadId = ThreadId.make("thread-acp-mcp-approval-elicitation");
......@@ -4159,7 +4158,7 @@ describe("AcpAdapterV2", () => {
41594158 const fileSystem = yield* FileSystem.FileSystem;
41604159 const idAllocator = yield* IdAllocator.IdAllocatorV2;
41614160 const path = yield* Path.Path;
4162 const serverConfig = yield* ServerConfig.ServerConfig;
4161 const host = yield* ProviderHost.ProviderHost;
41634162 const selfInvocation = yield* resolveSelfInvocation();
41644163 const mockAgentPath = yield* path.fromFileUrl(
41654164 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -4187,7 +4186,7 @@ describe("AcpAdapterV2", () => {
41874186 },
41884187 fileSystem,
41894188 idAllocator,
4190 serverConfig,
4189 host,
41914190 selfInvocation,
41924191 testHooks: {
41934192 onNativeResponseLifecycle: (event) =>
......@@ -4267,7 +4266,7 @@ describe("AcpAdapterV2", () => {
42674266 const fileSystem = yield* FileSystem.FileSystem;
42684267 const idAllocator = yield* IdAllocator.IdAllocatorV2;
42694268 const path = yield* Path.Path;
4270 const serverConfig = yield* ServerConfig.ServerConfig;
4269 const host = yield* ProviderHost.ProviderHost;
42714270 const selfInvocation = yield* resolveSelfInvocation();
42724271 const mockAgentPath = yield* path.fromFileUrl(
42734272 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -4292,7 +4291,7 @@ describe("AcpAdapterV2", () => {
42924291 },
42934292 fileSystem,
42944293 idAllocator,
4295 serverConfig,
4294 host,
42964295 selfInvocation,
42974296 testHooks: {
42984297 afterNativeResponseTransportClosed: () =>
......@@ -4381,7 +4380,7 @@ describe("AcpAdapterV2", () => {
43814380 const fileSystem = yield* FileSystem.FileSystem;
43824381 const idAllocator = yield* IdAllocator.IdAllocatorV2;
43834382 const path = yield* Path.Path;
4384 const serverConfig = yield* ServerConfig.ServerConfig;
4383 const host = yield* ProviderHost.ProviderHost;
43854384 const selfInvocation = yield* resolveSelfInvocation();
43864385 const mockAgentPath = yield* path.fromFileUrl(
43874386 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -4426,7 +4425,7 @@ describe("AcpAdapterV2", () => {
44264425 },
44274426 fileSystem,
44284427 idAllocator,
4429 serverConfig,
4428 host,
44304429 selfInvocation,
44314430 testHooks: {
44324431 onNativeResponseLifecycle: (event) =>
......@@ -4522,7 +4521,7 @@ describe("AcpAdapterV2", () => {
45224521 const fileSystem = yield* FileSystem.FileSystem;
45234522 const idAllocator = yield* IdAllocator.IdAllocatorV2;
45244523 const path = yield* Path.Path;
4525 const serverConfig = yield* ServerConfig.ServerConfig;
4524 const host = yield* ProviderHost.ProviderHost;
45264525 const selfInvocation = yield* resolveSelfInvocation();
45274526 const mockAgentPath = yield* path.fromFileUrl(
45284527 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -4564,7 +4563,7 @@ describe("AcpAdapterV2", () => {
45644563 },
45654564 fileSystem,
45664565 idAllocator,
4567 serverConfig,
4566 host,
45684567 selfInvocation,
45694568 });
45704569 const threadId = ThreadId.make("thread-acp-pending-response-cancel");
......@@ -4638,7 +4637,7 @@ describe("AcpAdapterV2", () => {
46384637 const fileSystem = yield* FileSystem.FileSystem;
46394638 const idAllocator = yield* IdAllocator.IdAllocatorV2;
46404639 const path = yield* Path.Path;
4641 const serverConfig = yield* ServerConfig.ServerConfig;
4640 const host = yield* ProviderHost.ProviderHost;
46424641 const selfInvocation = yield* resolveSelfInvocation();
46434642 const mockAgentPath = yield* path.fromFileUrl(
46444643 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -4679,7 +4678,7 @@ describe("AcpAdapterV2", () => {
46794678 },
46804679 fileSystem,
46814680 idAllocator,
4682 serverConfig,
4681 host,
46834682 selfInvocation,
46844683 });
46854684 const threadId = ThreadId.make(`thread-acp-immediate-permission-${name}`);
......@@ -4743,7 +4742,7 @@ describe("AcpAdapterV2", () => {
47434742 const fileSystem = yield* FileSystem.FileSystem;
47444743 const idAllocator = yield* IdAllocator.IdAllocatorV2;
47454744 const path = yield* Path.Path;
4746 const serverConfig = yield* ServerConfig.ServerConfig;
4745 const host = yield* ProviderHost.ProviderHost;
47474746 const selfInvocation = yield* resolveSelfInvocation();
47484747 const mockAgentPath = yield* path.fromFileUrl(
47494748 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -4778,7 +4777,7 @@ describe("AcpAdapterV2", () => {
47784777 },
47794778 fileSystem,
47804779 idAllocator,
4781 serverConfig,
4780 host,
47824781 selfInvocation,
47834782 });
47844783 const threadId = ThreadId.make("thread-acp-immediate-url-elicitation");
......@@ -4836,7 +4835,7 @@ describe("AcpAdapterV2", () => {
48364835 const fileSystem = yield* FileSystem.FileSystem;
48374836 const idAllocator = yield* IdAllocator.IdAllocatorV2;
48384837 const path = yield* Path.Path;
4839 const serverConfig = yield* ServerConfig.ServerConfig;
4838 const host = yield* ProviderHost.ProviderHost;
48404839 const selfInvocation = yield* resolveSelfInvocation();
48414840 const mockAgentPath = yield* path.fromFileUrl(
48424841 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -4875,7 +4874,7 @@ describe("AcpAdapterV2", () => {
48754874 },
48764875 fileSystem,
48774876 idAllocator,
4878 serverConfig,
4877 host,
48794878 selfInvocation,
48804879 });
48814880 const threadId = ThreadId.make("thread-acp-missing-response-ack");
......@@ -4929,7 +4928,7 @@ describe("AcpAdapterV2", () => {
49294928 const fileSystem = yield* FileSystem.FileSystem;
49304929 const idAllocator = yield* IdAllocator.IdAllocatorV2;
49314930 const path = yield* Path.Path;
4932 const serverConfig = yield* ServerConfig.ServerConfig;
4931 const host = yield* ProviderHost.ProviderHost;
49334932 const selfInvocation = yield* resolveSelfInvocation();
49344933 const mockAgentPath = yield* path.fromFileUrl(
49354934 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -4964,7 +4963,7 @@ describe("AcpAdapterV2", () => {
49644963 },
49654964 fileSystem,
49664965 idAllocator,
4967 serverConfig,
4966 host,
49684967 selfInvocation,
49694968 });
49704969 const threadId = ThreadId.make("thread-acp-teardown-wins-elicitation");
......@@ -5042,7 +5041,7 @@ describe("AcpAdapterV2", () => {
50425041 const fileSystem = yield* FileSystem.FileSystem;
50435042 const idAllocator = yield* IdAllocator.IdAllocatorV2;
50445043 const path = yield* Path.Path;
5045 const serverConfig = yield* ServerConfig.ServerConfig;
5044 const host = yield* ProviderHost.ProviderHost;
50465045 const selfInvocation = yield* resolveSelfInvocation();
50475046 const mockAgentPath = yield* path.fromFileUrl(
50485047 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -5064,7 +5063,7 @@ describe("AcpAdapterV2", () => {
50645063 },
50655064 fileSystem,
50665065 idAllocator,
5067 serverConfig,
5066 host,
50685067 selfInvocation,
50695068 });
50705069 const threadId = ThreadId.make("thread-acp-cancel-timeout");
......@@ -5145,7 +5144,7 @@ describe("AcpAdapterV2", () => {
51455144 const fileSystem = yield* FileSystem.FileSystem;
51465145 const idAllocator = yield* IdAllocator.IdAllocatorV2;
51475146 const path = yield* Path.Path;
5148 const serverConfig = yield* ServerConfig.ServerConfig;
5147 const host = yield* ProviderHost.ProviderHost;
51495148 const selfInvocation = yield* resolveSelfInvocation();
51505149 const mockAgentPath = yield* path.fromFileUrl(
51515150 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -5171,7 +5170,7 @@ describe("AcpAdapterV2", () => {
51715170 },
51725171 fileSystem,
51735172 idAllocator,
5174 serverConfig,
5173 host,
51755174 selfInvocation,
51765175 });
51775176 const threadId = ThreadId.make("thread-acp-double-stop");
......@@ -5243,7 +5242,7 @@ describe("AcpAdapterV2", () => {
52435242 const fileSystem = yield* FileSystem.FileSystem;
52445243 const idAllocator = yield* IdAllocator.IdAllocatorV2;
52455244 const path = yield* Path.Path;
5246 const serverConfig = yield* ServerConfig.ServerConfig;
5245 const host = yield* ProviderHost.ProviderHost;
52475246 const selfInvocation = yield* resolveSelfInvocation();
52485247 const mockAgentPath = yield* path.fromFileUrl(
52495248 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -5281,7 +5280,7 @@ describe("AcpAdapterV2", () => {
52815280 },
52825281 fileSystem,
52835282 idAllocator,
5284 serverConfig,
5283 host,
52855284 selfInvocation,
52865285 });
52875286 const threadId = ThreadId.make("thread-acp-interrupt-background-hold");
......@@ -5339,7 +5338,7 @@ describe("AcpAdapterV2", () => {
53395338 const fileSystem = yield* FileSystem.FileSystem;
53405339 const idAllocator = yield* IdAllocator.IdAllocatorV2;
53415340 const path = yield* Path.Path;
5342 const serverConfig = yield* ServerConfig.ServerConfig;
5341 const host = yield* ProviderHost.ProviderHost;
53435342 const selfInvocation = yield* resolveSelfInvocation();
53445343 const mockAgentPath = yield* path.fromFileUrl(
53455344 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -5391,7 +5390,7 @@ describe("AcpAdapterV2", () => {
53915390 },
53925391 fileSystem,
53935392 idAllocator,
5394 serverConfig,
5393 host,
53955394 selfInvocation,
53965395 });
53975396 const threadId = ThreadId.make("thread-acp-subagent-carryover");
......@@ -5487,7 +5486,7 @@ describe("AcpAdapterV2", () => {
54875486 const fileSystem = yield* FileSystem.FileSystem;
54885487 const idAllocator = yield* IdAllocator.IdAllocatorV2;
54895488 const path = yield* Path.Path;
5490 const serverConfig = yield* ServerConfig.ServerConfig;
5489 const host = yield* ProviderHost.ProviderHost;
54915490 const selfInvocation = yield* resolveSelfInvocation();
54925491 const mockAgentPath = yield* path.fromFileUrl(
54935492 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -5537,7 +5536,7 @@ describe("AcpAdapterV2", () => {
55375536 },
55385537 fileSystem,
55395538 idAllocator,
5540 serverConfig,
5539 host,
55415540 selfInvocation,
55425541 continuationRequests: { offer: () => Effect.void },
55435542 });
......@@ -5634,7 +5633,7 @@ describe("AcpAdapterV2", () => {
56345633 const fileSystem = yield* FileSystem.FileSystem;
56355634 const idAllocator = yield* IdAllocator.IdAllocatorV2;
56365635 const path = yield* Path.Path;
5637 const serverConfig = yield* ServerConfig.ServerConfig;
5636 const host = yield* ProviderHost.ProviderHost;
56385637 const selfInvocation = yield* resolveSelfInvocation();
56395638 const mockAgentPath = yield* path.fromFileUrl(
56405639 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -5707,7 +5706,7 @@ describe("AcpAdapterV2", () => {
57075706 },
57085707 fileSystem,
57095708 idAllocator,
5710 serverConfig,
5709 host,
57115710 selfInvocation,
57125711 continuationRequests: { offer: () => Effect.void },
57135712 });
......@@ -5871,7 +5870,7 @@ describe("AcpAdapterV2", () => {
58715870 const fileSystem = yield* FileSystem.FileSystem;
58725871 const idAllocator = yield* IdAllocator.IdAllocatorV2;
58735872 const path = yield* Path.Path;
5874 const serverConfig = yield* ServerConfig.ServerConfig;
5873 const host = yield* ProviderHost.ProviderHost;
58755874 const selfInvocation = yield* resolveSelfInvocation();
58765875 const mockAgentPath = yield* path.fromFileUrl(
58775876 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -5958,7 +5957,7 @@ describe("AcpAdapterV2", () => {
59585957 },
59595958 fileSystem,
59605959 idAllocator,
5961 serverConfig,
5960 host,
59625961 selfInvocation,
59635962 continuationRequests: { offer: () => Effect.void },
59645963 });
......@@ -6094,7 +6093,7 @@ describe("AcpAdapterV2", () => {
60946093 const fileSystem = yield* FileSystem.FileSystem;
60956094 const idAllocator = yield* IdAllocator.IdAllocatorV2;
60966095 const path = yield* Path.Path;
6097 const serverConfig = yield* ServerConfig.ServerConfig;
6096 const host = yield* ProviderHost.ProviderHost;
60986097 const selfInvocation = yield* resolveSelfInvocation();
60996098 const mockAgentPath = yield* path.fromFileUrl(
61006099 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -6157,7 +6156,7 @@ describe("AcpAdapterV2", () => {
61576156 },
61586157 fileSystem,
61596158 idAllocator,
6160 serverConfig,
6159 host,
61616160 selfInvocation,
61626161 continuationRequests: {
61636162 offer: (request) =>
......@@ -6313,7 +6312,7 @@ describe("AcpAdapterV2", () => {
63136312 const fileSystem = yield* FileSystem.FileSystem;
63146313 const idAllocator = yield* IdAllocator.IdAllocatorV2;
63156314 const path = yield* Path.Path;
6316 const serverConfig = yield* ServerConfig.ServerConfig;
6315 const host = yield* ProviderHost.ProviderHost;
63176316 const selfInvocation = yield* resolveSelfInvocation();
63186317 const mockAgentPath = yield* path.fromFileUrl(
63196318 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -6355,7 +6354,7 @@ describe("AcpAdapterV2", () => {
63556354 },
63566355 fileSystem,
63576356 idAllocator,
6358 serverConfig,
6357 host,
63596358 selfInvocation,
63606359 continuationRequests: { offer: () => Effect.void },
63616360 });
......@@ -6461,7 +6460,7 @@ describe("AcpAdapterV2", () => {
64616460 const fileSystem = yield* FileSystem.FileSystem;
64626461 const idAllocator = yield* IdAllocator.IdAllocatorV2;
64636462 const path = yield* Path.Path;
6464 const serverConfig = yield* ServerConfig.ServerConfig;
6463 const host = yield* ProviderHost.ProviderHost;
64656464 const selfInvocation = yield* resolveSelfInvocation();
64666465 const mockAgentPath = yield* path.fromFileUrl(
64676466 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -6520,7 +6519,7 @@ describe("AcpAdapterV2", () => {
65206519 },
65216520 fileSystem,
65226521 idAllocator,
6523 serverConfig,
6522 host,
65246523 selfInvocation,
65256524 continuationRequests: {
65266525 offer: (request) =>
......@@ -6713,7 +6712,7 @@ describe("AcpAdapterV2", () => {
67136712 const fileSystem = yield* FileSystem.FileSystem;
67146713 const idAllocator = yield* IdAllocator.IdAllocatorV2;
67156714 const path = yield* Path.Path;
6716 const serverConfig = yield* ServerConfig.ServerConfig;
6715 const host = yield* ProviderHost.ProviderHost;
67176716 const selfInvocation = yield* resolveSelfInvocation();
67186717 const mockAgentPath = yield* path.fromFileUrl(
67196718 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -6774,7 +6773,7 @@ describe("AcpAdapterV2", () => {
67746773 },
67756774 fileSystem,
67766775 idAllocator,
6777 serverConfig,
6776 host,
67786777 selfInvocation,
67796778 continuationRequests: {
67806779 offer: (request) =>
......@@ -7001,7 +7000,7 @@ describe("AcpAdapterV2", () => {
70017000 const fileSystem = yield* FileSystem.FileSystem;
70027001 const idAllocator = yield* IdAllocator.IdAllocatorV2;
70037002 const path = yield* Path.Path;
7004 const serverConfig = yield* ServerConfig.ServerConfig;
7003 const host = yield* ProviderHost.ProviderHost;
70057004 const selfInvocation = yield* resolveSelfInvocation();
70067005 const mockAgentPath = yield* path.fromFileUrl(
70077006 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -7072,7 +7071,7 @@ describe("AcpAdapterV2", () => {
70727071 },
70737072 fileSystem,
70747073 idAllocator,
7075 serverConfig,
7074 host,
70767075 selfInvocation,
70777076 continuationRequests: {
70787077 offer: (request) =>
......@@ -7242,7 +7241,7 @@ describe("AcpAdapterV2", () => {
72427241 const fileSystem = yield* FileSystem.FileSystem;
72437242 const idAllocator = yield* IdAllocator.IdAllocatorV2;
72447243 const path = yield* Path.Path;
7245 const serverConfig = yield* ServerConfig.ServerConfig;
7244 const host = yield* ProviderHost.ProviderHost;
72467245 const selfInvocation = yield* resolveSelfInvocation();
72477246 const mockAgentPath = yield* path.fromFileUrl(
72487247 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -7316,7 +7315,7 @@ describe("AcpAdapterV2", () => {
73167315 },
73177316 fileSystem,
73187317 idAllocator,
7319 serverConfig,
7318 host,
73207319 selfInvocation,
73217320 continuationRequests: {
73227321 offer: (request) =>
......@@ -7471,7 +7470,7 @@ describe("AcpAdapterV2", () => {
74717470 const fileSystem = yield* FileSystem.FileSystem;
74727471 const idAllocator = yield* IdAllocator.IdAllocatorV2;
74737472 const path = yield* Path.Path;
7474 const serverConfig = yield* ServerConfig.ServerConfig;
7473 const host = yield* ProviderHost.ProviderHost;
74757474 const selfInvocation = yield* resolveSelfInvocation();
74767475 const mockAgentPath = yield* path.fromFileUrl(
74777476 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -7533,7 +7532,7 @@ describe("AcpAdapterV2", () => {
75337532 },
75347533 fileSystem,
75357534 idAllocator,
7536 serverConfig,
7535 host,
75377536 selfInvocation,
75387537 continuationRequests: {
75397538 offer: (request) =>
......@@ -7742,7 +7741,7 @@ describe("AcpAdapterV2", () => {
77427741 const fileSystem = yield* FileSystem.FileSystem;
77437742 const idAllocator = yield* IdAllocator.IdAllocatorV2;
77447743 const path = yield* Path.Path;
7745 const serverConfig = yield* ServerConfig.ServerConfig;
7744 const host = yield* ProviderHost.ProviderHost;
77467745 const selfInvocation = yield* resolveSelfInvocation();
77477746 const mockAgentPath = yield* path.fromFileUrl(
77487747 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -7803,7 +7802,7 @@ describe("AcpAdapterV2", () => {
78037802 },
78047803 fileSystem,
78057804 idAllocator,
7806 serverConfig,
7805 host,
78077806 selfInvocation,
78087807 continuationRequests: {
78097808 offer: (request) =>
......@@ -7984,7 +7983,7 @@ describe("AcpAdapterV2", () => {
79847983 const fileSystem = yield* FileSystem.FileSystem;
79857984 const idAllocator = yield* IdAllocator.IdAllocatorV2;
79867985 const path = yield* Path.Path;
7987 const serverConfig = yield* ServerConfig.ServerConfig;
7986 const host = yield* ProviderHost.ProviderHost;
79887987 const selfInvocation = yield* resolveSelfInvocation();
79897988 const mockAgentPath = yield* path.fromFileUrl(
79907989 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -8053,7 +8052,7 @@ describe("AcpAdapterV2", () => {
80538052 },
80548053 fileSystem,
80558054 idAllocator,
8056 serverConfig,
8055 host,
80578056 selfInvocation,
80588057 });
80598058 const threadId = ThreadId.make("thread-acp-settled-soft-steer");
......@@ -8160,7 +8159,7 @@ describe("AcpAdapterV2", () => {
81608159 const fileSystem = yield* FileSystem.FileSystem;
81618160 const idAllocator = yield* IdAllocator.IdAllocatorV2;
81628161 const path = yield* Path.Path;
8163 const serverConfig = yield* ServerConfig.ServerConfig;
8162 const host = yield* ProviderHost.ProviderHost;
81648163 const selfInvocation = yield* resolveSelfInvocation();
81658164 const mockAgentPath = yield* path.fromFileUrl(
81668165 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -8192,7 +8191,7 @@ describe("AcpAdapterV2", () => {
81928191 },
81938192 fileSystem,
81948193 idAllocator,
8195 serverConfig,
8194 host,
81968195 selfInvocation,
81978196 });
81988197 const threadId = ThreadId.make("thread-acp-unsettled-steer-stays-hard");
......@@ -8250,7 +8249,7 @@ describe("AcpAdapterV2", () => {
82508249 const fileSystem = yield* FileSystem.FileSystem;
82518250 const idAllocator = yield* IdAllocator.IdAllocatorV2;
82528251 const path = yield* Path.Path;
8253 const serverConfig = yield* ServerConfig.ServerConfig;
8252 const host = yield* ProviderHost.ProviderHost;
82548253 const selfInvocation = yield* resolveSelfInvocation();
82558254 const mockAgentPath = yield* path.fromFileUrl(
82568255 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -8298,7 +8297,7 @@ describe("AcpAdapterV2", () => {
82988297 },
82998298 fileSystem,
83008299 idAllocator,
8301 serverConfig,
8300 host,
83028301 selfInvocation,
83038302 continuationRequests: {
83048303 offer: (request) =>
......@@ -8444,7 +8443,7 @@ describe("AcpAdapterV2", () => {
84448443 const fileSystem = yield* FileSystem.FileSystem;
84458444 const idAllocator = yield* IdAllocator.IdAllocatorV2;
84468445 const path = yield* Path.Path;
8447 const serverConfig = yield* ServerConfig.ServerConfig;
8446 const host = yield* ProviderHost.ProviderHost;
84488447 const selfInvocation = yield* resolveSelfInvocation();
84498448 const mockAgentPath = yield* path.fromFileUrl(
84508449 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -8484,7 +8483,7 @@ describe("AcpAdapterV2", () => {
84848483 },
84858484 fileSystem,
84868485 idAllocator,
8487 serverConfig,
8486 host,
84888487 selfInvocation,
84898488 continuationRequests: { offer: () => Effect.void },
84908489 });
......@@ -8591,7 +8590,7 @@ describe("AcpAdapterV2", () => {
85918590 const fileSystem = yield* FileSystem.FileSystem;
85928591 const idAllocator = yield* IdAllocator.IdAllocatorV2;
85938592 const path = yield* Path.Path;
8594 const serverConfig = yield* ServerConfig.ServerConfig;
8593 const host = yield* ProviderHost.ProviderHost;
85958594 const selfInvocation = yield* resolveSelfInvocation();
85968595 const mockAgentPath = yield* path.fromFileUrl(
85978596 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -8630,7 +8629,7 @@ describe("AcpAdapterV2", () => {
86308629 },
86318630 fileSystem,
86328631 idAllocator,
8633 serverConfig,
8632 host,
86348633 selfInvocation,
86358634 continuationRequests: { offer: () => Effect.void },
86368635 });
......@@ -8722,7 +8721,7 @@ describe("AcpAdapterV2", () => {
87228721 const fileSystem = yield* FileSystem.FileSystem;
87238722 const idAllocator = yield* IdAllocator.IdAllocatorV2;
87248723 const path = yield* Path.Path;
8725 const serverConfig = yield* ServerConfig.ServerConfig;
8724 const host = yield* ProviderHost.ProviderHost;
87268725 const selfInvocation = yield* resolveSelfInvocation();
87278726 const mockAgentPath = yield* path.fromFileUrl(
87288727 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -8799,7 +8798,7 @@ describe("AcpAdapterV2", () => {
87998798 },
88008799 fileSystem,
88018800 idAllocator,
8802 serverConfig,
8801 host,
88038802 selfInvocation,
88048803 });
88058804 const threadId = ThreadId.make("thread-acp-stop-after-soft-steer-orphan");
......@@ -9026,7 +9025,7 @@ describe("AcpAdapterV2", () => {
90269025 const fileSystem = yield* FileSystem.FileSystem;
90279026 const idAllocator = yield* IdAllocator.IdAllocatorV2;
90289027 const path = yield* Path.Path;
9029 const serverConfig = yield* ServerConfig.ServerConfig;
9028 const host = yield* ProviderHost.ProviderHost;
90309029 const selfInvocation = yield* resolveSelfInvocation();
90319030 const mockAgentPath = yield* path.fromFileUrl(
90329031 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -9087,7 +9086,7 @@ describe("AcpAdapterV2", () => {
90879086 },
90889087 fileSystem,
90899088 idAllocator,
9090 serverConfig,
9089 host,
90919090 selfInvocation,
90929091 continuationRequests: { offer: () => Effect.void },
90939092 });
......@@ -9223,7 +9222,7 @@ describe("AcpAdapterV2", () => {
92239222 const fileSystem = yield* FileSystem.FileSystem;
92249223 const idAllocator = yield* IdAllocator.IdAllocatorV2;
92259224 const path = yield* Path.Path;
9226 const serverConfig = yield* ServerConfig.ServerConfig;
9225 const host = yield* ProviderHost.ProviderHost;
92279226 const selfInvocation = yield* resolveSelfInvocation();
92289227 const mockAgentPath = yield* path.fromFileUrl(
92299228 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -9298,7 +9297,7 @@ describe("AcpAdapterV2", () => {
92989297 },
92999298 fileSystem,
93009299 idAllocator,
9301 serverConfig,
9300 host,
93029301 selfInvocation,
93039302 });
93049303 const threadId = ThreadId.make("thread-acp-settled-soft-admission-race");
......@@ -9393,7 +9392,7 @@ describe("AcpAdapterV2", () => {
93939392 const fileSystem = yield* FileSystem.FileSystem;
93949393 const idAllocator = yield* IdAllocator.IdAllocatorV2;
93959394 const path = yield* Path.Path;
9396 const serverConfig = yield* ServerConfig.ServerConfig;
9395 const host = yield* ProviderHost.ProviderHost;
93979396 const selfInvocation = yield* resolveSelfInvocation();
93989397 const mockAgentPath = yield* path.fromFileUrl(
93999398 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -9431,7 +9430,7 @@ describe("AcpAdapterV2", () => {
94319430 },
94329431 fileSystem,
94339432 idAllocator,
9434 serverConfig,
9433 host,
94359434 selfInvocation,
94369435 continuationRequests: {
94379436 offer: (request) =>
......@@ -9524,7 +9523,7 @@ describe("AcpAdapterV2", () => {
95249523 const fileSystem = yield* FileSystem.FileSystem;
95259524 const idAllocator = yield* IdAllocator.IdAllocatorV2;
95269525 const path = yield* Path.Path;
9527 const serverConfig = yield* ServerConfig.ServerConfig;
9526 const host = yield* ProviderHost.ProviderHost;
95289527 const selfInvocation = yield* resolveSelfInvocation();
95299528 const mockAgentPath = yield* path.fromFileUrl(
95309529 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -9554,7 +9553,7 @@ describe("AcpAdapterV2", () => {
95549553 },
95559554 fileSystem,
95569555 idAllocator,
9557 serverConfig,
9556 host,
95589557 selfInvocation,
95599558 continuationRequests: {
95609559 offer: (request) =>
......@@ -9644,7 +9643,7 @@ describe("AcpAdapterV2", () => {
96449643 const fileSystem = yield* FileSystem.FileSystem;
96459644 const idAllocator = yield* IdAllocator.IdAllocatorV2;
96469645 const path = yield* Path.Path;
9647 const serverConfig = yield* ServerConfig.ServerConfig;
9646 const host = yield* ProviderHost.ProviderHost;
96489647 const selfInvocation = yield* resolveSelfInvocation();
96499648 const mockAgentPath = yield* path.fromFileUrl(
96509649 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -9690,7 +9689,7 @@ describe("AcpAdapterV2", () => {
96909689 },
96919690 fileSystem,
96929691 idAllocator,
9693 serverConfig,
9692 host,
96949693 selfInvocation,
96959694 continuationRequests: {
96969695 offer: (request) =>
......@@ -9853,7 +9852,7 @@ describe("AcpAdapterV2", () => {
98539852 const fileSystem = yield* FileSystem.FileSystem;
98549853 const idAllocator = yield* IdAllocator.IdAllocatorV2;
98559854 const path = yield* Path.Path;
9856 const serverConfig = yield* ServerConfig.ServerConfig;
9855 const host = yield* ProviderHost.ProviderHost;
98579856 const selfInvocation = yield* resolveSelfInvocation();
98589857 const mockAgentPath = yield* path.fromFileUrl(
98599858 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -9897,7 +9896,7 @@ describe("AcpAdapterV2", () => {
98979896 },
98989897 fileSystem,
98999898 idAllocator,
9900 serverConfig,
9899 host,
99019900 selfInvocation,
99029901 });
99039902 const threadId = ThreadId.make("thread-acp-injected-report-hold");
......@@ -10019,7 +10018,7 @@ describe("AcpAdapterV2", () => {
1001910018 const fileSystem = yield* FileSystem.FileSystem;
1002010019 const idAllocator = yield* IdAllocator.IdAllocatorV2;
1002110020 const path = yield* Path.Path;
10022 const serverConfig = yield* ServerConfig.ServerConfig;
10021 const host = yield* ProviderHost.ProviderHost;
1002310022 const selfInvocation = yield* resolveSelfInvocation();
1002410023 const mockAgentPath = yield* path.fromFileUrl(
1002510024 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -10078,7 +10077,7 @@ describe("AcpAdapterV2", () => {
1007810077 },
1007910078 fileSystem,
1008010079 idAllocator,
10081 serverConfig,
10080 host,
1008210081 selfInvocation,
1008310082 continuationRequests: {
1008410083 offer: (request) =>
......@@ -10221,7 +10220,7 @@ describe("AcpAdapterV2", () => {
1022110220 const fileSystem = yield* FileSystem.FileSystem;
1022210221 const idAllocator = yield* IdAllocator.IdAllocatorV2;
1022310222 const path = yield* Path.Path;
10224 const serverConfig = yield* ServerConfig.ServerConfig;
10223 const host = yield* ProviderHost.ProviderHost;
1022510224 const selfInvocation = yield* resolveSelfInvocation();
1022610225 const mockAgentPath = yield* path.fromFileUrl(
1022710226 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -10276,7 +10275,7 @@ describe("AcpAdapterV2", () => {
1027610275 },
1027710276 fileSystem,
1027810277 idAllocator,
10279 serverConfig,
10278 host,
1028010279 selfInvocation,
1028110280 continuationRequests: {
1028210281 offer: (request) =>
......@@ -10461,7 +10460,7 @@ describe("AcpAdapterV2", () => {
1046110460 const fileSystem = yield* FileSystem.FileSystem;
1046210461 const idAllocator = yield* IdAllocator.IdAllocatorV2;
1046310462 const path = yield* Path.Path;
10464 const serverConfig = yield* ServerConfig.ServerConfig;
10463 const host = yield* ProviderHost.ProviderHost;
1046510464 const selfInvocation = yield* resolveSelfInvocation();
1046610465 const mockAgentPath = yield* path.fromFileUrl(
1046710466 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -10523,7 +10522,7 @@ describe("AcpAdapterV2", () => {
1052310522 },
1052410523 fileSystem,
1052510524 idAllocator,
10526 serverConfig,
10525 host,
1052710526 selfInvocation,
1052810527 continuationRequests: {
1052910528 offer: (request) =>
......@@ -10680,7 +10679,7 @@ describe("AcpAdapterV2", () => {
1068010679 const fileSystem = yield* FileSystem.FileSystem;
1068110680 const idAllocator = yield* IdAllocator.IdAllocatorV2;
1068210681 const path = yield* Path.Path;
10683 const serverConfig = yield* ServerConfig.ServerConfig;
10682 const host = yield* ProviderHost.ProviderHost;
1068410683 const selfInvocation = yield* resolveSelfInvocation();
1068510684 const mockAgentPath = yield* path.fromFileUrl(
1068610685 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -10746,7 +10745,7 @@ describe("AcpAdapterV2", () => {
1074610745 },
1074710746 fileSystem,
1074810747 idAllocator,
10749 serverConfig,
10748 host,
1075010749 selfInvocation,
1075110750 continuationRequests: {
1075210751 offer: (request) =>
......@@ -10895,7 +10894,7 @@ describe("AcpAdapterV2", () => {
1089510894 const fileSystem = yield* FileSystem.FileSystem;
1089610895 const idAllocator = yield* IdAllocator.IdAllocatorV2;
1089710896 const path = yield* Path.Path;
10898 const serverConfig = yield* ServerConfig.ServerConfig;
10897 const host = yield* ProviderHost.ProviderHost;
1089910898 const selfInvocation = yield* resolveSelfInvocation();
1090010899 const mockAgentPath = yield* path.fromFileUrl(
1090110900 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -10983,7 +10982,7 @@ describe("AcpAdapterV2", () => {
1098310982 },
1098410983 fileSystem,
1098510984 idAllocator,
10986 serverConfig,
10985 host,
1098710986 selfInvocation,
1098810987 continuationRequests: {
1098910988 offer: (request) =>
......@@ -11267,7 +11266,7 @@ describe("AcpAdapterV2", () => {
1126711266 const fileSystem = yield* FileSystem.FileSystem;
1126811267 const idAllocator = yield* IdAllocator.IdAllocatorV2;
1126911268 const path = yield* Path.Path;
11270 const serverConfig = yield* ServerConfig.ServerConfig;
11269 const host = yield* ProviderHost.ProviderHost;
1127111270 const selfInvocation = yield* resolveSelfInvocation();
1127211271 const mockAgentPath = yield* path.fromFileUrl(
1127311272 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -11306,7 +11305,7 @@ describe("AcpAdapterV2", () => {
1130611305 },
1130711306 fileSystem,
1130811307 idAllocator,
11309 serverConfig,
11308 host,
1131011309 selfInvocation,
1131111310 continuationRequests: {
1131211311 offer: (request) =>
......@@ -11421,7 +11420,7 @@ describe("AcpAdapterV2", () => {
1142111420 const fileSystem = yield* FileSystem.FileSystem;
1142211421 const idAllocator = yield* IdAllocator.IdAllocatorV2;
1142311422 const path = yield* Path.Path;
11424 const serverConfig = yield* ServerConfig.ServerConfig;
11423 const host = yield* ProviderHost.ProviderHost;
1142511424 const selfInvocation = yield* resolveSelfInvocation();
1142611425 const mockAgentPath = yield* path.fromFileUrl(
1142711426 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -11490,7 +11489,7 @@ describe("AcpAdapterV2", () => {
1149011489 },
1149111490 fileSystem,
1149211491 idAllocator,
11493 serverConfig,
11492 host,
1149411493 selfInvocation,
1149511494 continuationRequests: {
1149611495 offer: (request) =>
......@@ -11752,7 +11751,7 @@ describe("AcpAdapterV2", () => {
1175211751 const fileSystem = yield* FileSystem.FileSystem;
1175311752 const idAllocator = yield* IdAllocator.IdAllocatorV2;
1175411753 const path = yield* Path.Path;
11755 const serverConfig = yield* ServerConfig.ServerConfig;
11754 const host = yield* ProviderHost.ProviderHost;
1175611755 const selfInvocation = yield* resolveSelfInvocation();
1175711756 const mockAgentPath = yield* path.fromFileUrl(
1175811757 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -11802,7 +11801,7 @@ describe("AcpAdapterV2", () => {
1180211801 },
1180311802 fileSystem,
1180411803 idAllocator,
11805 serverConfig,
11804 host,
1180611805 selfInvocation,
1180711806 continuationRequests: {
1180811807 offer: (request) =>
......@@ -11939,7 +11938,7 @@ describe("AcpAdapterV2", () => {
1193911938 const fileSystem = yield* FileSystem.FileSystem;
1194011939 const idAllocator = yield* IdAllocator.IdAllocatorV2;
1194111940 const path = yield* Path.Path;
11942 const serverConfig = yield* ServerConfig.ServerConfig;
11941 const host = yield* ProviderHost.ProviderHost;
1194311942 const selfInvocation = yield* resolveSelfInvocation();
1194411943 const mockAgentPath = yield* path.fromFileUrl(
1194511944 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -11993,7 +11992,7 @@ describe("AcpAdapterV2", () => {
1199311992 },
1199411993 fileSystem,
1199511994 idAllocator,
11996 serverConfig,
11995 host,
1199711996 selfInvocation,
1199811997 continuationRequests: {
1199911998 offer: (request) =>
......@@ -12183,7 +12182,7 @@ describe("AcpAdapterV2", () => {
1218312182 const fileSystem = yield* FileSystem.FileSystem;
1218412183 const idAllocator = yield* IdAllocator.IdAllocatorV2;
1218512184 const path = yield* Path.Path;
12186 const serverConfig = yield* ServerConfig.ServerConfig;
12185 const host = yield* ProviderHost.ProviderHost;
1218712186 const selfInvocation = yield* resolveSelfInvocation();
1218812187 const mockAgentPath = yield* path.fromFileUrl(
1218912188 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -12235,7 +12234,7 @@ describe("AcpAdapterV2", () => {
1223512234 },
1223612235 fileSystem,
1223712236 idAllocator,
12238 serverConfig,
12237 host,
1223912238 selfInvocation,
1224012239 continuationRequests: {
1224112240 offer: (request) =>
......@@ -12373,7 +12372,7 @@ describe("AcpAdapterV2", () => {
1237312372 const fileSystem = yield* FileSystem.FileSystem;
1237412373 const idAllocator = yield* IdAllocator.IdAllocatorV2;
1237512374 const path = yield* Path.Path;
12376 const serverConfig = yield* ServerConfig.ServerConfig;
12375 const host = yield* ProviderHost.ProviderHost;
1237712376 const selfInvocation = yield* resolveSelfInvocation();
1237812377 const mockAgentPath = yield* path.fromFileUrl(
1237912378 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -12395,7 +12394,7 @@ describe("AcpAdapterV2", () => {
1239512394 },
1239612395 fileSystem,
1239712396 idAllocator,
12398 serverConfig,
12397 host,
1239912398 selfInvocation,
1240012399 });
1240112400 const threadId = ThreadId.make("thread-acp-restart-after-interrupt");
......@@ -12619,7 +12618,7 @@ describe("AcpAdapterV2", () => {
1261912618 const fileSystem = yield* FileSystem.FileSystem;
1262012619 const idAllocator = yield* IdAllocator.IdAllocatorV2;
1262112620 const path = yield* Path.Path;
12622 const serverConfig = yield* ServerConfig.ServerConfig;
12621 const host = yield* ProviderHost.ProviderHost;
1262312622 const selfInvocation = yield* resolveSelfInvocation();
1262412623 const mockAgentPath = yield* path.fromFileUrl(
1262512624 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -12668,7 +12667,7 @@ describe("AcpAdapterV2", () => {
1266812667 },
1266912668 fileSystem,
1267012669 idAllocator,
12671 serverConfig,
12670 host,
1267212671 selfInvocation,
1267312672 continuationRequests: {
1267412673 offer: (request) =>
......@@ -12837,7 +12836,7 @@ describe("AcpAdapterV2", () => {
1283712836 const fileSystem = yield* FileSystem.FileSystem;
1283812837 const idAllocator = yield* IdAllocator.IdAllocatorV2;
1283912838 const path = yield* Path.Path;
12840 const serverConfig = yield* ServerConfig.ServerConfig;
12839 const host = yield* ProviderHost.ProviderHost;
1284112840 const selfInvocation = yield* resolveSelfInvocation();
1284212841 const mockAgentPath = yield* path.fromFileUrl(
1284312842 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -12861,7 +12860,7 @@ describe("AcpAdapterV2", () => {
1286112860 },
1286212861 fileSystem,
1286312862 idAllocator,
12864 serverConfig,
12863 host,
1286512864 selfInvocation,
1286612865 });
1286712866 const threadId = ThreadId.make("thread-acp-missing-hard-teardown");
......@@ -12926,7 +12925,7 @@ describe("AcpAdapterV2", () => {
1292612925 const fileSystem = yield* FileSystem.FileSystem;
1292712926 const idAllocator = yield* IdAllocator.IdAllocatorV2;
1292812927 const path = yield* Path.Path;
12929 const serverConfig = yield* ServerConfig.ServerConfig;
12928 const host = yield* ProviderHost.ProviderHost;
1293012929 const selfInvocation = yield* resolveSelfInvocation();
1293112930 const mockAgentPath = yield* path.fromFileUrl(
1293212931 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -12963,7 +12962,7 @@ describe("AcpAdapterV2", () => {
1296312962 },
1296412963 fileSystem,
1296512964 idAllocator,
12966 serverConfig,
12965 host,
1296712966 selfInvocation,
1296812967 });
1296912968 const threadId = ThreadId.make("thread-acp-concurrent-hard-teardown");
......@@ -13039,7 +13038,7 @@ describe("AcpAdapterV2", () => {
1303913038 const fileSystem = yield* FileSystem.FileSystem;
1304013039 const idAllocator = yield* IdAllocator.IdAllocatorV2;
1304113040 const path = yield* Path.Path;
13042 const serverConfig = yield* ServerConfig.ServerConfig;
13041 const host = yield* ProviderHost.ProviderHost;
1304313042 const selfInvocation = yield* resolveSelfInvocation();
1304413043 const mockAgentPath = yield* path.fromFileUrl(
1304513044 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -13122,7 +13121,7 @@ describe("AcpAdapterV2", () => {
1312213121 },
1312313122 fileSystem,
1312413123 idAllocator,
13125 serverConfig,
13124 host,
1312613125 selfInvocation,
1312713126 testHooks: {
1312813127 afterHardTeardownTransportDrained: () =>
......@@ -13401,7 +13400,7 @@ describe("AcpAdapterV2", () => {
1340113400 const fileSystem = yield* FileSystem.FileSystem;
1340213401 const idAllocator = yield* IdAllocator.IdAllocatorV2;
1340313402 const path = yield* Path.Path;
13404 const serverConfig = yield* ServerConfig.ServerConfig;
13403 const host = yield* ProviderHost.ProviderHost;
1340513404 const selfInvocation = yield* resolveSelfInvocation();
1340613405 const mockAgentPath = yield* path.fromFileUrl(
1340713406 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -13428,7 +13427,7 @@ describe("AcpAdapterV2", () => {
1342813427 },
1342913428 fileSystem,
1343013429 idAllocator,
13431 serverConfig,
13430 host,
1343213431 selfInvocation,
1343313432 });
1343413433 const threadId = ThreadId.make("thread-acp-stale-deferred-cleanup");
......@@ -13526,7 +13525,7 @@ describe("AcpAdapterV2", () => {
1352613525 const fileSystem = yield* FileSystem.FileSystem;
1352713526 const idAllocator = yield* IdAllocator.IdAllocatorV2;
1352813527 const path = yield* Path.Path;
13529 const serverConfig = yield* ServerConfig.ServerConfig;
13528 const host = yield* ProviderHost.ProviderHost;
1353013529 const selfInvocation = yield* resolveSelfInvocation();
1353113530 const mockAgentPath = yield* path.fromFileUrl(
1353213531 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -13563,7 +13562,7 @@ describe("AcpAdapterV2", () => {
1356313562 },
1356413563 fileSystem,
1356513564 idAllocator,
13566 serverConfig,
13565 host,
1356713566 selfInvocation,
1356813567 });
1356913568 const threadId = ThreadId.make("thread-acp-concurrent-resume-teardown");
......@@ -13660,7 +13659,7 @@ describe("AcpAdapterV2", () => {
1366013659 const fileSystem = yield* FileSystem.FileSystem;
1366113660 const idAllocator = yield* IdAllocator.IdAllocatorV2;
1366213661 const path = yield* Path.Path;
13663 const serverConfig = yield* ServerConfig.ServerConfig;
13662 const host = yield* ProviderHost.ProviderHost;
1366413663 const selfInvocation = yield* resolveSelfInvocation();
1366513664 const mockAgentPath = yield* path.fromFileUrl(
1366613665 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -13714,7 +13713,7 @@ describe("AcpAdapterV2", () => {
1371413713 },
1371513714 fileSystem,
1371613715 idAllocator,
13717 serverConfig,
13716 host,
1371813717 selfInvocation,
1371913718 continuationRequests: { offer: () => Effect.void },
1372013719 });
......@@ -13937,7 +13936,7 @@ describe("AcpAdapterV2", () => {
1393713936 const fileSystem = yield* FileSystem.FileSystem;
1393813937 const idAllocator = yield* IdAllocator.IdAllocatorV2;
1393913938 const path = yield* Path.Path;
13940 const serverConfig = yield* ServerConfig.ServerConfig;
13939 const host = yield* ProviderHost.ProviderHost;
1394113940 const selfInvocation = yield* resolveSelfInvocation();
1394213941 const mockAgentPath = yield* path.fromFileUrl(
1394313942 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -13987,7 +13986,7 @@ describe("AcpAdapterV2", () => {
1398713986 },
1398813987 fileSystem,
1398913988 idAllocator,
13990 serverConfig,
13989 host,
1399113990 selfInvocation,
1399213991 });
1399313992 const threadId = ThreadId.make("thread-acp-direct-stop-subagent-hold");
......@@ -14091,7 +14090,7 @@ describe("AcpAdapterV2", () => {
1409114090 const fileSystem = yield* FileSystem.FileSystem;
1409214091 const idAllocator = yield* IdAllocator.IdAllocatorV2;
1409314092 const path = yield* Path.Path;
14094 const serverConfig = yield* ServerConfig.ServerConfig;
14093 const host = yield* ProviderHost.ProviderHost;
1409514094 const selfInvocation = yield* resolveSelfInvocation();
1409614095 const mockAgentPath = yield* path.fromFileUrl(
1409714096 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -14136,7 +14135,7 @@ describe("AcpAdapterV2", () => {
1413614135 },
1413714136 fileSystem,
1413814137 idAllocator,
14139 serverConfig,
14138 host,
1414014139 selfInvocation,
1414114140 });
1414214141 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";
1313import { ChildProcessSpawner } from "effect/process";
1414import * as EffectAcpErrors from "effect-acp/errors";
1515
16import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts";
16import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";
1717import type { ProviderReplayGate } from "@t3tools/provider-testing/replayGate";
18import { ACP_PROTOCOL, type AcpAdapterV2RuntimeInput } from "./AcpAdapterV2.ts";
18import { ACP_PROTOCOL, type AcpAdapterV2RuntimeInput } from "@t3tools/provider-acp/server/adapter";
1919
2020export const AcpReplayTranscript = Schema.Struct({
2121 provider: ProviderDriverKind,
apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts deleted-8062
......@@ -1,8062 +0,0 @@
1// @effect-diagnostics nodeBuiltinImport:off
2import * as NodePath from "node:path";
3
4import {
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";
35import { modelSelectionsEqual } from "@t3tools/shared/model";
36import { type SelfInvocation, selfInvocationArgs } from "@t3tools/shared/nodeRuntime";
37import { FILE_HEADERS_ONLY, formatPatch, structuredPatch } from "diff";
38import * as Cause from "effect/Cause";
39import * as Crypto from "effect/Crypto";
40import * as DateTime from "effect/DateTime";
41import * as Deferred from "effect/Deferred";
42import type * as Duration from "effect/Duration";
43import * as Effect from "effect/Effect";
44import * as Exit from "effect/Exit";
45import * as FileSystem from "effect/FileSystem";
46import * as Fiber from "effect/Fiber";
47import * as Option from "effect/Option";
48import * as Queue from "effect/Queue";
49import * as Ref from "effect/Ref";
50import * as Result from "effect/Result";
51import * as Scope from "effect/Scope";
52import * as Semaphore from "effect/Semaphore";
53import * as Stream from "effect/Stream";
54import type { ChildProcessSpawner } from "effect/process";
55import * as EffectAcpErrors from "effect-acp/errors";
56import type * as EffectAcpProtocol from "effect-acp/protocol";
57import type * as EffectAcpSchema from "effect-acp/compat";
58
59import { formatReadToolLabel, formatSearchToolLabel } from "@t3tools/shared/toolActivity";
60import { resolveAttachmentPath } from "../../attachmentStore.ts";
61import * as ServerConfig from "../../config.ts";
62import {
63 makeAcpMcpOverAcpBridge,
64 type AcpMcpOverAcpBridge,
65} from "../../mcp/AcpMcpOverAcpBridge.ts";
66import { mcpToolPresentation } from "@t3tools/provider-core/server/mcpToolPresentation";
67import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession";
68import {
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";
82import {
83 acpClientExecuteDisposition,
84 acpMcpToolApprovalElicitationDisposition,
85 acpPermissionDisposition,
86 type AcpPermissionDisposition,
87 makeAcpClientPolicyGrants,
88 unknownRecord,
89} from "../../provider/acp/AcpClientPolicy.ts";
90import {
91 makeAcpClientTerminals,
92 resolveEmbeddedTerminalContent,
93 type AcpClientTerminals,
94} from "../../provider/acp/AcpClientTerminals.ts";
95import { ACP_SESSION_MODE_OPTION_ID } from "../../provider/acp/AcpSessionConfig.ts";
96import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts";
97import {
98 t3AcpPromptWithInstructions,
99 type T3AcpInstructionState,
100} from "@t3tools/provider-core/server/orchestrationInstructions";
101import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions";
102import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
103import { type ProviderContinuationRequest } from "@t3tools/provider-core/server/continuationRequests";
104import {
105 type BackgroundWork,
106 type BackgroundWorkReport,
107 backgroundWorkNotification,
108} from "@t3tools/provider-core/server/notification";
109import {
110 makeProviderFailure,
111 makeProviderRetryTurnItem,
112} from "@t3tools/provider-core/server/failure";
113import { acpSelectionTransition } from "@t3tools/provider-core/server/selectionTransition";
114import {
115 isProviderNativeImageAttachment,
116 providerMessageTextWithAttachmentPaths,
117} from "@t3tools/provider-core/server/attachmentPrompt";
118import {
119 makeSubagentChildThread,
120 makeSubagentConversationArtifacts,
121 subagentThreadTitle,
122} from "@t3tools/provider-core/server/subagentProjection";
123import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
124
125export 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 */
134const ACP_DEFERRED_FINALIZE_DEBOUNCE: Duration.Input = "3000 millis";
135
136export 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
160export type AcpAdapterV2NativeLogging = Pick<
161 AcpSessionRuntime.AcpSessionRuntimeOptions,
162 "requestLogger" | "protocolLogging"
163>;
164
165export interface AcpAdapterV2UserInputRequest {
166 readonly nativeItemId: string;
167 readonly nativeRequestId: string;
168 readonly questions: ReadonlyArray<OrchestrationV2UserInputQuestion>;
169}
170
171export 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
219export 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. */
439export 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. */
447export 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. */
452export 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
462export 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
484export 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
544export 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
643function 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
679interface 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
687function 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
724function acpMcpServers(
725 threadId: ThreadId | null,
726 self: SelfInvocation,
727): ReadonlyArray<EffectAcpSchema.McpServer> {
728 return acpMcpContext(threadId, self).servers;
729}
730
731function acpMcpActivation(threadId: ThreadId | null, self: SelfInvocation) {
732 const context = acpMcpContext(threadId, self);
733 return { mcpServers: context.servers, acpMcpServers: context.acpServers };
734}
735
736function 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
752function 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 */
801function 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
817function nonEmptyText(value: unknown, fallback: string): string {
818 return typeof value === "string" && value.trim().length > 0 ? value.trim() : fallback;
819}
820
821function 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
835function 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
887function 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 */
903export 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
913function 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.
943const 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 */
951export 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
981function 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 */
1007function 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
1041function 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
1058function 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
1075type ProjectedToolStatus = ReturnType<typeof toolStatus> | "interrupted";
1076
1077function nodeStatus(status: ProjectedToolStatus): OrchestrationV2ExecutionNode["status"] {
1078 return status === "pending" ? "running" : status;
1079}
1080
1081function completedAtForStatus(status: ProjectedToolStatus, now: DateTime.Utc): DateTime.Utc | null {
1082 return status === "completed" || status === "failed" || status === "interrupted" ? now : null;
1083}
1084
1085function 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 */
1103function selectAutoApprovedPermissionOption(
1104 request: EffectAcpSchema.RequestPermissionRequest,
1105): string | undefined {
1106 return (
1107 selectPermissionOptionId(request, "accept") ??
1108 selectPermissionOptionId(request, "acceptForSession")
1109 );
1110}
1111
1112function 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
1128interface ActiveTextSegment {
1129 readonly nativeItemId: string;
1130 readonly startedAt: DateTime.Utc;
1131 sourceMessageId: string | null;
1132 text: string;
1133}
1134
1135interface ActiveTextStream {
1136 current: ActiveTextSegment | null;
1137 nextSegment: number;
1138}
1139
1140interface AcpNativeBuildConfiguration {
1141 readonly modeId?: string;
1142 readonly configOptions: ReadonlyArray<{ readonly id: string; readonly value: string }>;
1143}
1144
1145interface 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
1224type 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. */
1233function 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 */
1267export 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 */
1295export 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
1320export 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 */
1338export 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. */
1346function acpIsProviderContinuationMessage(message: {
1347 readonly createdBy: string;
1348 readonly creationSource: string;
1349}): boolean {
1350 return message.createdBy === "agent" && message.creationSource === "provider";
1351}
1352
1353export function acpCarryoverTerminalShouldClearContinuation(input: {
1354 readonly continuationOffered: boolean;
1355 readonly wakeBufferLength: number;
1356}): boolean {
1357 return !input.continuationOffered && input.wakeBufferLength === 0;
1358}
1359
1360export 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
1368export 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
1379export 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
1389interface 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
1414function 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
1424export function acpSubagentStatusBlocksTurnSettlement(
1425 status: OrchestrationV2Subagent["status"],
1426): boolean {
1427 return status === "running" || status === "pending";
1428}
1429
1430function acpSubagentHasPendingBackgroundWork(subagent: ActiveAcpSubagent): boolean {
1431 return (
1432 acpSubagentStatusBlocksTurnSettlement(subagent.task.status) || !subagent.terminalStatusProjected
1433 );
1434}
1435
1436type AcpCarryoverSubagents = {
1437 readonly sessionId: string;
1438 readonly rootTerminalStatus: "completed" | "interrupted" | "failed" | "cancelled";
1439 readonly subagents: ReadonlyArray<ActiveAcpSubagent>;
1440};
1441
1442type 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
1461interface 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
1469const 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 */
1477function 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
1497export 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
8062export 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";
1414import { ChildProcessSpawner } from "effect/process";
1515import * as EffectAcpErrors from "effect-acp/errors";
1616
17import * as ServerConfig from "../../config.ts";
17import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";
18import { layerTestProviderHost } from "@t3tools/provider-testing/host";
1819import * as ServerSettings from "../../serverSettings.ts";
1920import type {
2021 AcpRegistryAvailableCommands,
2122 AcpRegistryLiveConfiguration,
2223} from "../../provider/acp/AcpRegistryProbe.ts";
2324import * as AcpRegistrySupport from "../../provider/acp/AcpRegistrySupport.ts";
24import { ACP_SESSION_MODE_OPTION_ID } from "../../provider/acp/AcpSessionConfig.ts";
25import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts";
25import { ACP_SESSION_MODE_OPTION_ID } from "@t3tools/provider-acp/server/sessionConfig";
26import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";
2627import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
2728import {
2829 decodeAcpReplayTranscript,
......@@ -43,9 +44,7 @@ const decodeAcpRegistryAdapterSettings = Schema.decodeUnknownEffect(
4344 AcpRegistryAdapterV2Driver.configSchema,
4445);
4546
46const layerServerConfig = ServerConfig.layerTest(process.cwd(), {
47 prefix: "t3-acp-registry-v2-adapter-",
48}).pipe(Layer.provide(NodeServices.layer));
47const layerHost = layerTestProviderHost().pipe(Layer.provide(NodeServices.layer));
4948
5049const layerRegistry = Layer.succeed(
5150 HttpClient.HttpClient,
......@@ -86,7 +85,7 @@ const layerRegistry = Layer.succeed(
8685const layerTest = Layer.mergeAll(
8786 NodeServices.layer,
8887 IdAllocator.layer,
89 layerServerConfig,
88 layerHost,
9089 layerRegistry,
9190 ServerSettings.layerTest(),
9291);
......@@ -202,7 +201,7 @@ describe("AcpRegistryAdapterV2", () => {
202201 fileSystem,
203202 idAllocator: yield* IdAllocator.IdAllocatorV2,
204203 resolver: { resolve: () => Effect.die("the runtime is injected") },
205 serverConfig: yield* ServerConfig.ServerConfig,
204 host: yield* ProviderHost.ProviderHost,
206205 makeRuntime: makeAcpReplayRuntime({
207206 transcript,
208207 statusPath,
......@@ -272,7 +271,7 @@ describe("AcpRegistryAdapterV2", () => {
272271 const fileSystem = yield* FileSystem.FileSystem;
273272 const idAllocator = yield* IdAllocator.IdAllocatorV2;
274273 const path = yield* Path.Path;
275 const serverConfig = yield* ServerConfig.ServerConfig;
274 const host = yield* ProviderHost.ProviderHost;
276275 const mockAgentPath = yield* path.fromFileUrl(
277276 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
278277 );
......@@ -291,7 +290,7 @@ describe("AcpRegistryAdapterV2", () => {
291290 fileSystem,
292291 idAllocator,
293292 resolver: { resolve: () => Effect.die("the runtime is injected") },
294 serverConfig,
293 host,
295294 makeRuntime: (input) =>
296295 Effect.gen(function* () {
297296 clientCapabilities = input.clientCapabilities;
......@@ -348,13 +347,13 @@ describe("AcpRegistryAdapterV2", () => {
348347 const fileSystem = yield* FileSystem.FileSystem;
349348 const idAllocator = yield* IdAllocator.IdAllocatorV2;
350349 const path = yield* Path.Path;
351 const serverConfig = yield* ServerConfig.ServerConfig;
350 const host = yield* ProviderHost.ProviderHost;
352351 const mockAgentPath = yield* path.fromFileUrl(
353352 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
354353 );
355354 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
356 cacheDir: serverConfig.providerStatusCacheDir,
357 toolsDir: serverConfig.baseDir + "/tools",
355 cacheDir: host.paths.providerStatusCacheDir,
356 toolsDir: host.paths.baseDir + "/tools",
358357 registryUrl,
359358 });
360359 const settings = yield* decodeAcpRegistryAdapterSettings({
......@@ -429,7 +428,7 @@ describe("AcpRegistryAdapterV2", () => {
429428 })),
430429 ),
431430 },
432 serverConfig,
431 host,
433432 });
434433 const threadId = ThreadId.make("thread-acp-registry-fixture");
435434 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";
99import * as Schema from "effect/Schema";
1010import { ChildProcessSpawner } from "effect/process";
1111
12import * as ServerConfig from "../../config.ts";
12import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";
13import { layerTestProviderHost } from "@t3tools/provider-testing/host";
1314import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
1415import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts";
1516import type { ProviderReplayGate } from "@t3tools/provider-testing/replayGate";
1617import type { OrchestratorV2ProviderReplayHarness } from "../testkit/ProviderReplayHarness.ts";
17import { makeReplayServerConfig } from "../testkit/ProviderReplayHarness.ts";
1818import {
1919 type AcpReplayTranscript,
2020 AcpReplayTranscriptDecodeError,
......@@ -37,10 +37,7 @@ function layerAcpRegistryProviderAdapterRegistryReplay(
3737 transcript: AcpReplayTranscript,
3838 options: { readonly replayGate?: ProviderReplayGate } = {},
3939) {
40 const layerServerConfig = Layer.effect(
41 ServerConfig.ServerConfig,
42 makeReplayServerConfig(`acp-registry-${transcript.scenario}`).pipe(Effect.orDie),
43 ).pipe(Layer.provide(NodeServices.layer));
40 const layerHost = layerTestProviderHost().pipe(Layer.provide(NodeServices.layer));
4441
4542 return ProviderAdapterRegistry.layerFromAdaptersEffect(
4643 Effect.gen(function* () {
......@@ -49,7 +46,7 @@ function layerAcpRegistryProviderAdapterRegistryReplay(
4946 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
5047 const crypto = yield* Crypto.Crypto;
5148 const idAllocator = yield* IdAllocator.IdAllocatorV2;
52 const serverConfig = yield* ServerConfig.ServerConfig;
49 const host = yield* ProviderHost.ProviderHost;
5350 const replayGate = options.replayGate;
5451 const replayDir = yield* fileSystem
5552 .makeTempDirectory({
......@@ -71,7 +68,7 @@ function layerAcpRegistryProviderAdapterRegistryReplay(
7168 resolver: {
7269 resolve: () => Effect.die("ACP registry resolver must not run during replay"),
7370 },
74 serverConfig,
71 host,
7572 selfInvocation: yield* resolveSelfInvocation(),
7673 makeRuntime: makeAcpReplayRuntime({
7774 transcript,
......@@ -86,7 +83,7 @@ function layerAcpRegistryProviderAdapterRegistryReplay(
8683 return [adapter];
8784 }),
8885 ).pipe(
89 Layer.provide(Layer.mergeAll(layerServerConfig, NodeServices.layer, IdAllocator.layer)),
86 Layer.provide(Layer.mergeAll(layerHost, NodeServices.layer, IdAllocator.layer)),
9087 // Held inbound lines must not outlive the scenario and wedge teardown.
9188 Layer.merge(
9289 Layer.effectDiscard(
apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.ts+9-9
......@@ -21,7 +21,7 @@ import type * as Scope from "effect/Scope";
2121import { ChildProcessSpawner } from "effect/process";
2222import * as EffectAcpErrors from "effect-acp/errors";
2323
24import * as ServerConfig from "../../config.ts";
24import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";
2525import {
2626 normalizeAcpRegistryCommands,
2727 normalizeAcpRegistryLiveConfiguration,
......@@ -29,8 +29,8 @@ import {
2929} from "../../provider/acp/AcpRegistryProbe.ts";
3030import * as AcpRegistrySupport from "../../provider/acp/AcpRegistrySupport.ts";
3131import * as AcpRegistryRuntimeCoordinator from "../../provider/acp/AcpRegistryRuntimeCoordinator.ts";
32import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts";
33import { makeAcpNativeLoggerFactory } from "../../provider/acp/AcpNativeLogging.ts";
32import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";
33import { makeAcpNativeLoggerFactory } from "@t3tools/provider-acp/server/nativeLogging";
3434import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts";
3535import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment";
3636import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
......@@ -46,7 +46,7 @@ import {
4646 type AcpAdapterV2ExtensionContext,
4747 type AcpAdapterV2Flavor,
4848 type AcpAdapterV2RuntimeInput,
49} from "./AcpAdapterV2.ts";
49} from "@t3tools/provider-acp/server/adapter";
5050
5151export const ACP_REGISTRY_PROVIDER = ProviderDriverKind.make("acpRegistry");
5252export const ACP_REGISTRY_DEFAULT_INSTANCE_ID = defaultInstanceIdForDriver(ACP_REGISTRY_PROVIDER);
......@@ -65,7 +65,7 @@ export interface AcpRegistryAdapterV2Options {
6565 readonly idAllocator: IdAllocator.IdAllocatorV2["Service"];
6666 readonly resolver: Pick<AcpRegistrySupport.AcpRegistryCatalog["Service"], "resolve">;
6767 readonly runtimeCoordinator?: AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator["Service"];
68 readonly serverConfig: ServerConfig.ServerConfig["Service"];
68 readonly host: ProviderHost.ProviderHostShape;
6969 readonly nativeLogging?: Parameters<typeof makeAcpAdapterV2>[0]["nativeLogging"];
7070 readonly makeRuntime?: (
7171 input: AcpAdapterV2RuntimeInput,
......@@ -243,7 +243,7 @@ export function makeAcpRegistryAdapterV2(options: AcpRegistryAdapterV2Options) {
243243 crypto: options.crypto,
244244 fileSystem: options.fileSystem,
245245 idAllocator: options.idAllocator,
246 serverConfig: options.serverConfig,
246 host: options.host,
247247 selfInvocation: options.selfInvocation,
248248 // Per-agent exception (see the note above registerMistralVibeAcpExtensions):
249249 // Devin runs commands through client terminals and has no ask mode over
......@@ -269,7 +269,7 @@ export type AcpRegistryAdapterV2DriverEnv =
269269 | IdAllocator.IdAllocatorV2
270270 | Path.Path
271271 | ProviderEventLoggers.ProviderEventLoggers
272 | ServerConfig.ServerConfig;
272 | ProviderHost.ProviderHost;
273273
274274export const AcpRegistryAdapterV2Driver: ProviderAdapterDriver<
275275 AcpRegistrySettings,
......@@ -287,7 +287,7 @@ export const AcpRegistryAdapterV2Driver: ProviderAdapterDriver<
287287 const fileSystem = yield* FileSystem.FileSystem;
288288 const idAllocator = yield* IdAllocator.IdAllocatorV2;
289289 const providerEventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers;
290 const serverConfig = yield* ServerConfig.ServerConfig;
290 const host = yield* ProviderHost.ProviderHost;
291291 const makeNativeLogger = yield* makeAcpNativeLoggerFactory();
292292 const resolver = yield* AcpRegistrySupport.AcpRegistryCatalog;
293293 const runtimeCoordinator = yield* Effect.serviceOption(
......@@ -305,7 +305,7 @@ export const AcpRegistryAdapterV2Driver: ProviderAdapterDriver<
305305 ...(Option.isSome(runtimeCoordinator)
306306 ? { runtimeCoordinator: runtimeCoordinator.value }
307307 : {}),
308 serverConfig,
308 host,
309309 selfInvocation,
310310 nativeLogging: (threadId) =>
311311 makeNativeLogger({
apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts+12-13
......@@ -22,8 +22,9 @@ import * as Stream from "effect/Stream";
2222import { ChildProcessSpawner } from "effect/process";
2323import type * as EffectAcpSchema from "effect-acp/compat";
2424
25import * as ServerConfig from "../../config.ts";
26import type * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts";
25import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";
26import { layerTestProviderHost } from "@t3tools/provider-testing/host";
27import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";
2728import { makeAntigravityAcpRuntime } from "../../provider/acp/AntigravityAcpSupport.ts";
2829import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
2930import { ProviderAdapterV2RuntimePolicy } from "@t3tools/provider-core/server/ProviderAdapter";
......@@ -38,7 +39,7 @@ const flavor = makeAntigravityAcpAdapterFlavor({
3839 fileSystem: undefined as never,
3940 path: undefined as never,
4041 idAllocator: undefined as never,
41 serverConfig: undefined as never,
42 host: undefined as never,
4243 selfInvocation: undefined as never,
4344 makeRuntime: () => Effect.die("not spawned in this test"),
4445 withProcess: (_stop, task) => task,
......@@ -129,9 +130,7 @@ describe("AntigravityAdapterV2 flavor", () => {
129130const layerSession = Layer.mergeAll(
130131 NodeServices.layer,
131132 IdAllocator.layer,
132 ServerConfig.layerTest(process.cwd(), { prefix: "t3-antigravity-v2-adapter-" }).pipe(
133 Layer.provide(NodeServices.layer),
134 ),
133 layerTestProviderHost().pipe(Layer.provide(NodeServices.layer)),
135134);
136135
137136describe("AntigravityAdapterV2 client file system", () => {
......@@ -140,7 +139,7 @@ describe("AntigravityAdapterV2 client file system", () => {
140139 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
141140 const fileSystem = yield* FileSystem.FileSystem;
142141 const path = yield* Path.Path;
143 const serverConfig = yield* ServerConfig.ServerConfig;
142 const host = yield* ProviderHost.ProviderHost;
144143 const mockAgentPath = yield* path.fromFileUrl(
145144 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
146145 );
......@@ -157,7 +156,7 @@ describe("AntigravityAdapterV2 client file system", () => {
157156 fileSystem,
158157 path,
159158 idAllocator: yield* IdAllocator.IdAllocatorV2,
160 serverConfig,
159 host,
161160 makeRuntime: (input) =>
162161 makeAntigravityAcpRuntime({
163162 ...input,
......@@ -193,7 +192,7 @@ describe("AntigravityAdapterV2 client file system", () => {
193192 });
194193 const outsideFile = path.join(outside, "secret.txt");
195194 yield* fileSystem.writeFileString(outsideFile, "secret");
196 const attachment = path.join(serverConfig.attachmentsDir, "pasted.txt");
195 const attachment = path.join(host.paths.attachmentsDir, "pasted.txt");
197196 yield* fileSystem.writeFileString(attachment, "pasted");
198197
199198 const threadId = ThreadId.make("thread-antigravity-containment");
......@@ -294,7 +293,7 @@ describe("AntigravityAdapterV2 workspace changes", () => {
294293 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
295294 const fileSystem = yield* FileSystem.FileSystem;
296295 const path = yield* Path.Path;
297 const serverConfig = yield* ServerConfig.ServerConfig;
296 const host = yield* ProviderHost.ProviderHost;
298297 const crypto = yield* Crypto.Crypto;
299298 const mockAgentPath = yield* path.fromFileUrl(
300299 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -309,7 +308,7 @@ describe("AntigravityAdapterV2 workspace changes", () => {
309308 fileSystem,
310309 path,
311310 idAllocator: yield* IdAllocator.IdAllocatorV2,
312 serverConfig,
311 host,
313312 makeRuntime: (input) =>
314313 makeAntigravityAcpRuntime({
315314 ...input,
......@@ -431,7 +430,7 @@ describe("AntigravityAdapterV2 client file system under restrictive policies", (
431430 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
432431 const fileSystem = yield* FileSystem.FileSystem;
433432 const path = yield* Path.Path;
434 const serverConfig = yield* ServerConfig.ServerConfig;
433 const host = yield* ProviderHost.ProviderHost;
435434 const crypto = yield* Crypto.Crypto;
436435 const mockAgentPath = yield* path.fromFileUrl(
437436 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
......@@ -459,7 +458,7 @@ describe("AntigravityAdapterV2 client file system under restrictive policies", (
459458 fileSystem,
460459 path,
461460 idAllocator: yield* IdAllocator.IdAllocatorV2,
462 serverConfig,
461 host,
463462 makeRuntime: (input) =>
464463 makeAntigravityAcpRuntime({
465464 ...input,
apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts+7-9
......@@ -14,9 +14,9 @@ import * as Exit from "effect/Exit";
1414import * as Scope from "effect/Scope";
1515import * as EffectAcpErrors from "effect-acp/errors";
1616
17import type { ServerConfig } from "../../config.ts";
17import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";
1818import type { AntigravityAuth } from "../../provider/AntigravityAuth.ts";
19import type * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts";
19import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";
2020import {
2121 antigravityPermissionMode,
2222 applyAntigravityAcpModelSelection,
......@@ -40,7 +40,7 @@ import {
4040 makeAcpAdapterV2,
4141 type AcpAdapterV2Flavor,
4242 type AcpAdapterV2RuntimeInput,
43} from "./AcpAdapterV2.ts";
43} from "@t3tools/provider-acp/server/adapter";
4444
4545const ANTIGRAVITY_PROVIDER = ProviderDriverKind.make("antigravity");
4646
......@@ -68,7 +68,7 @@ export interface AntigravityAdapterV2Options {
6868 readonly fileSystem: FileSystem.FileSystem;
6969 readonly path: Path.Path;
7070 readonly idAllocator: IdAllocatorV2["Service"];
71 readonly serverConfig: ServerConfig["Service"];
71 readonly host: ProviderHost.ProviderHostShape;
7272 /** Spawns the official agent with the instance's Google profile. */
7373 readonly makeRuntime: (
7474 input: Omit<AntigravityAcpRuntimeInput, "spawn" | "childProcessSpawner">,
......@@ -125,9 +125,7 @@ export function makeAntigravityAcpAdapterFlavor(
125125 // the turn text references. It is a leaf directory of uploads. A session
126126 // without a workspace gets no workspace root rather than the server's cwd.
127127 const antigravityClientFileRoots = (cwd: string | null) =>
128 cwd === null
129 ? [options.serverConfig.attachmentsDir]
130 : [cwd, options.serverConfig.attachmentsDir];
128 cwd === null ? [options.host.paths.attachmentsDir] : [cwd, options.host.paths.attachmentsDir];
131129 const makeRuntime = (input: AcpAdapterV2RuntimeInput) =>
132130 Effect.gen(function* () {
133131 // AcpAdapterV2 owns the runtime scope; sign-in and sign-out stop the
......@@ -139,7 +137,7 @@ export function makeAntigravityAcpAdapterFlavor(
139137 options.makeRuntime({
140138 ...input,
141139 clientFileSystem: true,
142 additionalDirectories: [options.serverConfig.attachmentsDir],
140 additionalDirectories: [options.host.paths.attachmentsDir],
143141 }),
144142 )
145143 .pipe(Effect.provideService(Scope.Scope, scope));
......@@ -233,7 +231,7 @@ export function makeAntigravityAdapterV2(options: AntigravityAdapterV2Options) {
233231 crypto: options.crypto,
234232 fileSystem: options.fileSystem,
235233 idAllocator: options.idAllocator,
236 serverConfig: options.serverConfig,
234 host: options.host,
237235 selfInvocation: options.selfInvocation,
238236 ...(options.nativeLogging === undefined ? {} : { nativeLogging: options.nativeLogging }),
239237 ...(options.continuationRequests === undefined
apps/server/src/orchestration-v2/Adapters/DevinAcp.ts+2-2
......@@ -1,6 +1,6 @@
11import type * as AcpSchema from "effect-acp/compat";
2import type { AcpToolCallState } from "../../provider/acp/AcpRuntimeModel.ts";
3import type { AcpAdapterV2SubagentUpdate } from "./AcpAdapterV2.ts";
2import type { AcpToolCallState } from "@t3tools/provider-acp/server/runtimeModel";
3import type { AcpAdapterV2SubagentUpdate } from "@t3tools/provider-acp/server/adapter";
44
55function record(value: unknown): Record<string, unknown> | undefined {
66 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";
2222import { ChildProcessSpawner } from "effect/process";
2323import type * as EffectAcpSchema from "effect-acp/compat";
2424
25import * as ServerConfig from "../../config.ts";
25import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";
26import { layerTestProviderHost } from "@t3tools/provider-testing/host";
2627import * as ProjectStore from "../ProjectStore.ts";
2728import { buildInitialGrokProviderSnapshot } from "../../provider/GrokProvider.ts";
2829import type { ProviderInstance } from "@t3tools/provider-core/server/driver";
......@@ -30,13 +31,13 @@ import * as ProviderInstanceRegistry from "../../provider/ProviderInstanceRegist
3031import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
3132import { ProviderAdapterV2RuntimePolicy } from "@t3tools/provider-core/server/ProviderAdapter";
3233import * as RuntimePolicy from "../RuntimePolicy.ts";
33import { acpPermissionDisposition } from "../../provider/acp/AcpClientPolicy.ts";
34import { acpPermissionDisposition } from "@t3tools/provider-acp/server/clientPolicy";
3435import {
3536 AcpProviderCapabilitiesV2,
3637 acpCompletedTurnShouldTerminalizeTool,
3738 acpSubagentStatusBlocksTurnSettlement,
3839 acpSupportsImagePrompts,
39} from "./AcpAdapterV2.ts";
40} from "@t3tools/provider-acp/server/adapter";
4041import {
4142 makeGrokAcpAdapterFlavor,
4243 makeGrokAdapterV2,
......@@ -311,10 +312,8 @@ describe("Grok permission prompts", () => {
311312});
312313
313314describe("Grok launch permission mode", () => {
314 const layerServerConfig = ServerConfig.layerTest(process.cwd(), {
315 prefix: "t3-grok-v2-launch-",
316 }).pipe(Layer.provide(NodeServices.layer));
317 const layerTest = Layer.mergeAll(NodeServices.layer, IdAllocator.layer, layerServerConfig);
315 const layerHost = layerTestProviderHost().pipe(Layer.provide(NodeServices.layer));
316 const layerTest = Layer.mergeAll(NodeServices.layer, IdAllocator.layer, layerHost);
318317
319318 // Opens a session through the adapter's own Grok runtime factory and returns
320319 // the argv it tried to launch. The spawn fails after recording, so no
......@@ -342,7 +341,7 @@ describe("Grok launch permission mode", () => {
342341 crypto: yield* Crypto.Crypto,
343342 fileSystem: yield* FileSystem.FileSystem,
344343 idAllocator: yield* IdAllocator.IdAllocatorV2,
345 serverConfig: yield* ServerConfig.ServerConfig,
344 host: yield* ProviderHost.ProviderHost,
346345 selfInvocation: yield* resolveSelfInvocation(),
347346 });
348347 yield* adapter
apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.testkit.ts+6-9
......@@ -10,7 +10,8 @@ import { ChildProcessSpawner } from "effect/process";
1010import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
1111import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime";
1212
13import * as ServerConfig from "../../config.ts";
13import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";
14import { layerTestProviderHost } from "@t3tools/provider-testing/host";
1415import {
1516 GROK_ACP_CANCEL_META,
1617 GROK_ACP_INITIALIZE_META,
......@@ -21,7 +22,6 @@ import * as ProviderContinuationRequests from "@t3tools/provider-core/server/con
2122import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts";
2223import type { ProviderReplayGate } from "@t3tools/provider-testing/replayGate";
2324import type { OrchestratorV2ProviderReplayHarness } from "../testkit/ProviderReplayHarness.ts";
24import { makeReplayServerConfig } from "../testkit/ProviderReplayHarness.ts";
2525import {
2626 type AcpReplayTranscript,
2727 AcpReplayTranscriptDecodeError,
......@@ -37,10 +37,7 @@ function layerGrokProviderAdapterRegistryReplay(
3737 transcript: AcpReplayTranscript,
3838 options: { readonly replayGate?: ProviderReplayGate } = {},
3939) {
40 const layerServerConfig = Layer.effect(
41 ServerConfig.ServerConfig,
42 makeReplayServerConfig(`grok-${transcript.scenario}`).pipe(Effect.orDie),
43 ).pipe(Layer.provide(NodeServices.layer));
40 const layerHost = layerTestProviderHost().pipe(Layer.provide(NodeServices.layer));
4441
4542 return ProviderAdapterRegistry.layerFromAdaptersEffect(
4643 Effect.gen(function* () {
......@@ -50,7 +47,7 @@ function layerGrokProviderAdapterRegistryReplay(
5047 const crypto = yield* Crypto.Crypto;
5148 const hostPlatform = yield* HostProcessPlatform;
5249 const idAllocator = yield* IdAllocator.IdAllocatorV2;
53 const serverConfig = yield* ServerConfig.ServerConfig;
50 const host = yield* ProviderHost.ProviderHost;
5451 // Same queue the continuation worker drains when the fixture runs it.
5552 const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests;
5653 const replayGate = options.replayGate;
......@@ -72,7 +69,7 @@ function layerGrokProviderAdapterRegistryReplay(
7269 crypto,
7370 fileSystem,
7471 idAllocator,
75 serverConfig,
72 host,
7673 selfInvocation: yield* resolveSelfInvocation(),
7774 // Same wrapping as makeGrokAcpRuntime: client type and Ctrl+C cancel
7875 // metadata and the x.ai prompt-completion race, so replay sends what
......@@ -102,7 +99,7 @@ function layerGrokProviderAdapterRegistryReplay(
10299 return [adapter];
103100 }),
104101 ).pipe(
105 Layer.provide(Layer.mergeAll(layerServerConfig, NodeServices.layer, IdAllocator.layer)),
102 Layer.provide(Layer.mergeAll(layerHost, NodeServices.layer, IdAllocator.layer)),
106103 // Held inbound lines must not outlive the scenario and wedge teardown.
107104 Layer.merge(
108105 Layer.effectDiscard(
apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts+13-13
......@@ -23,8 +23,8 @@ import type * as Scope from "effect/Scope";
2323import { ChildProcessSpawner } from "effect/process";
2424import * as EffectAcpErrors from "effect-acp/errors";
2525
26import * as ServerConfig from "../../config.ts";
27import { makeAcpNativeLoggerFactory } from "../../provider/acp/AcpNativeLogging.ts";
26import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";
27import { makeAcpNativeLoggerFactory } from "@t3tools/provider-acp/server/nativeLogging";
2828import {
2929 applyGrokAcpModelSelection,
3030 currentGrokModelIdFromSessionSetup,
......@@ -54,8 +54,8 @@ import {
5454 XAiExitPlanModeRequest,
5555} from "../../provider/acp/XAiAcpExtension.ts";
5656import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment";
57import { acpPermissionDisposition } from "../../provider/acp/AcpClientPolicy.ts";
58import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts";
57import { acpPermissionDisposition } from "@t3tools/provider-acp/server/clientPolicy";
58import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";
5959import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts";
6060import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
6161import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests";
......@@ -71,7 +71,7 @@ import {
7171 type AcpAdapterV2ExtensionContext,
7272 type AcpAdapterV2Flavor,
7373 type AcpAdapterV2RuntimeInput,
74} from "./AcpAdapterV2.ts";
74} from "@t3tools/provider-acp/server/adapter";
7575
7676export const GROK_PROVIDER = ProviderDriverKind.make("grok");
7777const GROK_DRIVER_KIND = GROK_PROVIDER;
......@@ -117,7 +117,7 @@ export interface GrokAdapterV2Options {
117117 readonly selfInvocation: SelfInvocation;
118118 readonly fileSystem: FileSystem.FileSystem;
119119 readonly idAllocator: IdAllocator.IdAllocatorV2["Service"];
120 readonly serverConfig: ServerConfig.ServerConfig["Service"];
120 readonly host: ProviderHost.ProviderHostShape;
121121 readonly nativeLogging?: Parameters<typeof makeAcpAdapterV2>[0]["nativeLogging"];
122122 readonly continuationRequests?: Parameters<typeof makeAcpAdapterV2>[0]["continuationRequests"];
123123 readonly testHooks?: Parameters<typeof makeAcpAdapterV2>[0]["testHooks"];
......@@ -337,7 +337,7 @@ export function makeGrokAdapterV2(options: GrokAdapterV2Options) {
337337 crypto: options.crypto,
338338 fileSystem: options.fileSystem,
339339 idAllocator: options.idAllocator,
340 serverConfig: options.serverConfig,
340 host: options.host,
341341 selfInvocation: options.selfInvocation,
342342 ...(options.nativeLogging === undefined ? {} : { nativeLogging: options.nativeLogging }),
343343 ...(options.continuationRequests === undefined
......@@ -354,7 +354,7 @@ export type GrokAdapterV2DriverEnv =
354354 | IdAllocator.IdAllocatorV2
355355 | Path.Path
356356 | ProviderEventLoggers.ProviderEventLoggers
357 | ServerConfig.ServerConfig;
357 | ProviderHost.ProviderHost;
358358
359359export const GrokAdapterV2Driver: ProviderAdapterDriver<GrokSettings, GrokAdapterV2DriverEnv> = {
360360 driverKind: GROK_DRIVER_KIND,
......@@ -370,7 +370,7 @@ export const GrokAdapterV2Driver: ProviderAdapterDriver<GrokSettings, GrokAdapte
370370 const fileSystem = yield* FileSystem.FileSystem;
371371 const idAllocator = yield* IdAllocator.IdAllocatorV2;
372372 const providerEventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers;
373 const serverConfig = yield* ServerConfig.ServerConfig;
373 const host = yield* ProviderHost.ProviderHost;
374374 const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests;
375375 const makeNativeLogger = yield* makeAcpNativeLoggerFactory();
376376 return makeGrokAdapterV2({
......@@ -382,7 +382,7 @@ export const GrokAdapterV2Driver: ProviderAdapterDriver<GrokSettings, GrokAdapte
382382 crypto,
383383 fileSystem,
384384 idAllocator,
385 serverConfig,
385 host,
386386 selfInvocation,
387387 continuationRequests,
388388 nativeLogging: (threadId) =>
......@@ -417,7 +417,7 @@ const layer: Layer.Layer<
417417 | FileSystem.FileSystem
418418 | IdAllocator.IdAllocatorV2
419419 | ProviderEventLoggers.ProviderEventLoggers
420 | ServerConfig.ServerConfig
420 | ProviderHost.ProviderHost
421421> = Layer.effect(
422422 ProviderAdapter.ProviderAdapterV2,
423423 Effect.gen(function* () {
......@@ -429,7 +429,7 @@ const layer: Layer.Layer<
429429 const fileSystem = yield* FileSystem.FileSystem;
430430 const idAllocator = yield* IdAllocator.IdAllocatorV2;
431431 const providerEventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers;
432 const serverConfig = yield* ServerConfig.ServerConfig;
432 const host = yield* ProviderHost.ProviderHost;
433433 const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests;
434434 const makeNativeLogger = yield* makeAcpNativeLoggerFactory();
435435 return makeGrokAdapterV2({
......@@ -441,7 +441,7 @@ const layer: Layer.Layer<
441441 crypto,
442442 fileSystem,
443443 idAllocator,
444 serverConfig,
444 host,
445445 selfInvocation,
446446 continuationRequests,
447447 nativeLogging: (threadId) =>
apps/server/src/orchestration-v2/ProviderSelectionTransition.test.ts+1-1
......@@ -6,7 +6,7 @@ import {
66 ProviderInstanceId,
77} from "@t3tools/contracts";
88
9import { AcpProviderCapabilitiesV2 } from "./Adapters/AcpAdapterV2.ts";
9import { AcpProviderCapabilitiesV2 } from "@t3tools/provider-acp/server/adapter";
1010import { acpSelectionTransition } from "@t3tools/provider-core/server/selectionTransition";
1111
1212const selection = (model: string, effort = "medium"): ModelSelection => ({
apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts+1-1
......@@ -42,7 +42,7 @@ import {
4242 CodexProviderCapabilitiesV2,
4343 canReuseCodexContextUsage,
4444} from "../Adapters/CodexAdapterV2.ts";
45import { AcpProviderCapabilitiesV2 } from "../Adapters/AcpAdapterV2.ts";
45import { AcpProviderCapabilitiesV2 } from "@t3tools/provider-acp/server/adapter";
4646import { CursorProviderCapabilitiesV2 } from "@t3tools/provider-cursor/testing";
4747import * as EventSink from "../EventSink.ts";
4848import * as EventStore from "../EventStore.ts";
apps/server/src/provider/AntigravityAuth.test.ts+2-2
......@@ -17,7 +17,7 @@ import {
1717 type AntigravityAuth,
1818 type AntigravityAuthRuntime,
1919} from "./AntigravityAuth.ts";
20import type { AcpSessionRuntimeStartResult } from "./acp/AcpSessionRuntime.ts";
20import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";
2121
2222const instanceId = ProviderInstanceId.make("antigravity-auth-test");
2323const owner = "t3-auth-session-owner";
......@@ -31,7 +31,7 @@ const initialized = {
3131 authMethods: [{ id: "oauth-personal", name: "Log in with Google" }],
3232 agentCapabilities: { auth: { logout: {} } },
3333} satisfies AcpSchema.InitializeResponse;
34const started: AcpSessionRuntimeStartResult = {
34const started: AcpSessionRuntime.AcpSessionRuntimeStartResult = {
3535 sessionId: "native-session",
3636 initializeResult: initialized,
3737 sessionSetupResult: {
apps/server/src/provider/AntigravityAuth.ts+4-4
......@@ -19,7 +19,7 @@ import * as Stream from "effect/Stream";
1919import * as SubscriptionRef from "effect/SubscriptionRef";
2020import * as AcpErrors from "effect-acp/errors";
2121
22import type { AcpSessionRuntime, AcpSessionRuntimeStartResult } from "./acp/AcpSessionRuntime.ts";
22import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";
2323import {
2424 parseAntigravityAuthorizationUrl,
2525 type AntigravityAuthorizationUrl,
......@@ -66,19 +66,19 @@ export interface AntigravityAuth {
6666}
6767
6868export type AntigravityAuthRuntime = Pick<
69 AcpSessionRuntime["Service"],
69 AcpSessionRuntime.AcpSessionRuntime["Service"],
7070 "initialize" | "start" | "request"
7171>;
7272
7373export interface AntigravityAuthOptions<
74 Runtime extends AntigravityAuthRuntime = AcpSessionRuntime["Service"],
74 Runtime extends AntigravityAuthRuntime = AcpSessionRuntime.AcpSessionRuntime["Service"],
7575> {
7676 readonly instanceId: ProviderInstanceId;
7777 readonly makeRuntime: (input: {
7878 readonly onAuthorizationUrl?: (url: string) => Effect.Effect<void, AcpErrors.AcpError>;
7979 }) => Effect.Effect<Runtime, AcpErrors.AcpError | ProviderSetupError, Scope.Scope>;
8080 readonly onAuthenticated: (
81 result: AcpSessionRuntimeStartResult,
81 result: AcpSessionRuntime.AcpSessionRuntimeStartResult,
8282 runtime: Runtime,
8383 ) => Effect.Effect<void>;
8484 readonly onSignedOut: Effect.Effect<void>;
apps/server/src/provider/AntigravityProvider.test.ts+2-2
......@@ -23,7 +23,7 @@ import * as ServerConfig from "../config.ts";
2323import * as ServerSettings from "../serverSettings.ts";
2424import * as ProviderHostLive from "./ProviderHostLive.ts";
2525import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
26import type { AcpSessionRuntimeStartResult } from "./acp/AcpSessionRuntime.ts";
26import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";
2727import {
2828 buildAntigravityModelsFromSession,
2929 makeAntigravityProvider,
......@@ -98,7 +98,7 @@ const started = {
9898 initializeResult,
9999 sessionSetupResult,
100100 modelConfigId: "model",
101} satisfies AcpSessionRuntimeStartResult;
101} satisfies AcpSessionRuntime.AcpSessionRuntimeStartResult;
102102
103103const commands = [
104104 { 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";
1818import type * as EffectAcpErrors from "effect-acp/errors";
1919import type * as EffectAcpSchema from "effect-acp/compat";
2020
21import type { AcpSessionRuntimeStartResult } from "./acp/AcpSessionRuntime.ts";
21import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";
2222import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider";
2323import {
2424 makeManualOnlyProviderMaintenanceCapabilities,
......@@ -38,7 +38,7 @@ const AUTH_UNCHECKED_MESSAGE =
3838 "Antigravity is installed. Google account access is not checked yet.";
3939
4040type SessionSetupResult = Pick<
41 AcpSessionRuntimeStartResult["sessionSetupResult"],
41 AcpSessionRuntime.AcpSessionRuntimeStartResult["sessionSetupResult"],
4242 "configOptions" | "models"
4343>;
4444
......@@ -256,7 +256,7 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func
256256 });
257257
258258 const onSessionStarted = Effect.fn("AntigravityProvider.onSessionStarted")(function* (
259 started: AcpSessionRuntimeStartResult,
259 started: AcpSessionRuntime.AcpSessionRuntimeStartResult,
260260 cwd?: string,
261261 ) {
262262 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";
2222import { ChildProcessSpawner } from "effect/process";
2323
2424import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost";
25import * as ServerConfig from "../../config.ts";
2625import {
2726 AcpRegistryAdapterV2Driver,
2827 type AcpRegistryAdapterV2DriverEnv,
......@@ -489,7 +488,6 @@ export const AcpRegistryDriver: ProviderDriver<AcpRegistrySettings, AcpRegistryD
489488 const crypto = yield* Crypto.Crypto;
490489 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
491490 const hostEnvironment = yield* HostProcessEnvironment;
492 const serverConfig = yield* ServerConfig.ServerConfig;
493491 const host = yield* ProviderHost;
494492 const continuationIdentity = defaultProviderContinuationIdentity({
495493 driverKind: DRIVER_KIND,
......@@ -528,7 +526,7 @@ export const AcpRegistryDriver: ProviderDriver<AcpRegistrySettings, AcpRegistryD
528526 };
529527 const confirmedAuthentication =
530528 yield* AcpRegistryAuthenticationState.makeAcpRegistryAuthenticationState({
531 cacheDir: serverConfig.providerStatusCacheDir,
529 cacheDir: host.paths.providerStatusCacheDir,
532530 instanceId,
533531 settings: effectiveConfig,
534532 environment,
......@@ -573,7 +571,7 @@ export const AcpRegistryDriver: ProviderDriver<AcpRegistrySettings, AcpRegistryD
573571 const enrichProvider = checkAcpRegistryProviderStatus(
574572 {
575573 ...readinessInput,
576 cwd: serverConfig.cwd,
574 cwd: host.paths.cwd,
577575 },
578576 probeAcpRegistryConfiguration,
579577 ).pipe(
......@@ -768,7 +766,7 @@ export const AcpRegistryDriver: ProviderDriver<AcpRegistrySettings, AcpRegistryD
768766 const controller = yield* AcpRegistryAuth.makeAcpRegistryAuth({
769767 instanceId,
770768 settings: effectiveConfig,
771 cwd: serverConfig.cwd,
769 cwd: host.paths.cwd,
772770 environment: processEnvironment,
773771 onChanged: (authenticated) =>
774772 confirmedAuthentication
apps/server/src/provider/Drivers/AntigravityDriver.ts+9-11
......@@ -19,7 +19,6 @@ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
1919import type { AcpError } from "effect-acp/errors";
2020
2121import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost";
22import * as ServerConfig from "../../config.ts";
2322import {
2423 isAntigravityTextGenerationAvailable,
2524 makeAntigravityTextGeneration,
......@@ -40,7 +39,7 @@ import {
4039 makeAntigravityAcpRuntime,
4140 type AntigravityAcpRuntimeInput,
4241} from "../acp/AntigravityAcpSupport.ts";
43import type { AcpSessionRuntime, AcpSessionRuntimeStartResult } from "../acp/AcpSessionRuntime.ts";
42import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";
4443import type { ServerProviderDraft } from "@t3tools/provider-core/server/snapshotProbe";
4544import {
4645 removeAntigravityRuntimeTempDirs,
......@@ -49,7 +48,7 @@ import {
4948import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
5049import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests";
5150import { makeAntigravityAdapterV2 } from "../../orchestration-v2/Adapters/AntigravityAdapterV2.ts";
52import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts";
51import { makeAcpNativeLoggerFactory } from "@t3tools/provider-acp/server/nativeLogging";
5352import { ProviderDriverError } from "../Errors.ts";
5453import { makeAntigravityProvider } from "../AntigravityProvider.ts";
5554import * as ProviderEventLoggers from "../ProviderEventLoggers.ts";
......@@ -76,8 +75,7 @@ export type AntigravityDriverEnv =
7675 | IdAllocator.IdAllocatorV2
7776 | ModelManifest.ModelManifest
7877 | Path.Path
79 | ProviderEventLoggers.ProviderEventLoggers
80 | ServerConfig.ServerConfig;
78 | ProviderEventLoggers.ProviderEventLoggers;
8179
8280/** Each instance owns its Google profile. Executable releases are shared by the environment. */
8381export const AntigravityDriver: ProviderDriver<AntigravitySettings, AntigravityDriverEnv> = {
......@@ -91,7 +89,7 @@ export const AntigravityDriver: ProviderDriver<AntigravitySettings, AntigravityD
9189 const fileSystem = yield* FileSystem.FileSystem;
9290 const path = yield* Path.Path;
9391 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
94 const serverConfig = yield* ServerConfig.ServerConfig;
92 const host = yield* ProviderHost;
9593 const selfInvocation = yield* resolveSelfInvocation();
9694 const installation = yield* AntigravityInstallation.AntigravityInstallation;
9795 const loggers = yield* ProviderEventLoggers.ProviderEventLoggers;
......@@ -110,7 +108,7 @@ export const AntigravityDriver: ProviderDriver<AntigravitySettings, AntigravityD
110108 const processEnvironment = mergeProviderInstanceEnvironment(environment);
111109 const userHome = resolveAntigravityUserHome(yield* HostProcessPlatform, processEnvironment);
112110 const directories = yield* resolveAntigravityInstanceDirectories(
113 serverConfig.stateDir,
111 host.paths.stateDir,
114112 instanceId,
115113 ).pipe(
116114 Effect.provideService(Crypto.Crypto, crypto),
......@@ -158,7 +156,7 @@ export const AntigravityDriver: ProviderDriver<AntigravitySettings, AntigravityD
158156 const makeRuntime = Effect.fn("AntigravityDriver.makeRuntime")(function* (
159157 input: Omit<AntigravityAcpRuntimeInput, "spawn" | "childProcessSpawner">,
160158 ): Effect.fn.Return<
161 AcpSessionRuntime["Service"],
159 AcpSessionRuntime.AcpSessionRuntime["Service"],
162160 AcpError | ProviderSetupError,
163161 Scope.Scope
164162 > {
......@@ -303,8 +301,8 @@ export const AntigravityDriver: ProviderDriver<AntigravitySettings, AntigravityD
303301 });
304302
305303 const publishCatalog = (
306 started: AcpSessionRuntimeStartResult,
307 runtime: Pick<AcpSessionRuntime["Service"], "getEvents" | "drainEvents">,
304 started: AcpSessionRuntime.AcpSessionRuntimeStartResult,
305 runtime: Pick<AcpSessionRuntime.AcpSessionRuntime["Service"], "getEvents" | "drainEvents">,
308306 ): Effect.Effect<void> =>
309307 Effect.gen(function* () {
310308 yield* provider.onSessionStarted(started);
......@@ -405,7 +403,7 @@ export const AntigravityDriver: ProviderDriver<AntigravitySettings, AntigravityD
405403 fileSystem,
406404 path,
407405 idAllocator,
408 serverConfig,
406 host,
409407 selfInvocation,
410408 makeRuntime,
411409 withProcess: authFlow.withProcess,
apps/server/src/provider/GrokProvider.ts+1-1
......@@ -42,7 +42,7 @@ import {
4242 makeGrokAcpRuntime,
4343 resolveGrokAcpBaseModelId,
4444} from "./acp/GrokAcpSupport.ts";
45import { sessionModelStateFromInitialize } from "./acp/AcpRuntimeModel.ts";
45import { sessionModelStateFromInitialize } from "@t3tools/provider-acp/server/runtimeModel";
4646import { discoverGrokSkills } from "./Drivers/GrokSkills.ts";
4747
4848const GROK_PRESENTATION = {
apps/server/src/provider/ProviderAuthService.test.ts+1-1
......@@ -22,7 +22,7 @@ import * as SubscriptionRef from "effect/SubscriptionRef";
2222
2323import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts";
2424import * as ProviderSessionManager from "../orchestration-v2/ProviderSessionManager.ts";
25import { AcpProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/AcpAdapterV2.ts";
25import { AcpProviderCapabilitiesV2 } from "@t3tools/provider-acp/server/adapter";
2626import type { ProviderInstance } from "@t3tools/provider-core/server/driver";
2727import * as ProviderAuthService from "./ProviderAuthService.ts";
2828import * as ProviderInstanceRegistry from "./ProviderInstanceRegistry.ts";
apps/server/src/provider/ProviderHostLive.ts+1
......@@ -31,6 +31,7 @@ export const layer = Layer.effect(
3131 baseDir: config.baseDir,
3232 stateDir: config.stateDir,
3333 providerStatusCacheDir: config.providerStatusCacheDir,
34 attachmentsDir: config.attachmentsDir,
3435 },
3536 settings: {
3637 get: serverSettings.getSettings,
apps/server/src/provider/acp/AcpClientPolicy.test.ts deleted-348
......@@ -1,348 +0,0 @@
1// @effect-diagnostics nodeBuiltinImport:off
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { assert, describe, it } from "@effect/vitest";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Path from "effect/Path";
7import type * as EffectAcpSchema from "effect-acp/compat";
8import * as NodePath from "node:path";
9
10import {
11 acpClientExecuteDisposition,
12 acpMcpToolApprovalElicitationDisposition,
13 acpPermissionDisposition,
14 makeAcpClientPolicyGrants,
15 type AcpRuntimePolicy,
16} from "./AcpClientPolicy.ts";
17
18function 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
33describe("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
223describe("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
294describe("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
322describe("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
2import type { ProviderRequestKind, RuntimeMode } from "@t3tools/contracts";
3import type * as EffectAcpSchema from "effect-acp/compat";
4import * as NodeFS from "node:fs";
5import * 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. */
18export interface AcpRuntimePolicy {
19 readonly runtimeMode: RuntimeMode;
20 readonly cwd: string | null;
21 readonly approvalPolicy?: unknown;
22 readonly sandboxPolicy?: unknown;
23}
24
25export type AcpPermissionDisposition = "allow" | "ask" | "deny";
26
27export 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
33function 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
41function 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 */
60function 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. */
96interface AcpPolicyOperation {
97 readonly kind: string | null | undefined;
98 readonly locations: ReadonlyArray<{ readonly path: string }> | null | undefined;
99}
100
101function acpPolicyRequiresApproval(runtimePolicy: AcpRuntimePolicy): boolean {
102 return runtimePolicy.approvalPolicy === undefined
103 ? runtimePolicy.runtimeMode === "approval-required"
104 : runtimePolicy.approvalPolicy !== "never";
105}
106
107function 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 */
118function 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
131function 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 */
178function 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
225function isAcpMutationKind(toolKind: string): boolean {
226 return toolKind === "edit" || toolKind === "delete" || toolKind === "move";
227}
228
229export 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. */
240export 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). */
259export function acpClientExecuteDisposition(
260 runtimePolicy: AcpRuntimePolicy,
261): AcpPermissionDisposition {
262 return acpOperationDisposition(runtimePolicy, { kind: "execute", locations: undefined });
263}
264
265export interface AcpApprovalGrantInput {
266 readonly kind: ProviderRequestKind;
267 readonly scope: "session" | "turn";
268 readonly turnKey: string;
269}
270
271export 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 */
283export 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 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { describe, expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Result from "effect/Result";
5import { ChildProcessSpawner } from "effect/process";
6
7import {
8 makeAcpClientTerminals,
9 resolveEmbeddedTerminalContent,
10 type AcpClientTerminals,
11} from "./AcpClientTerminals.ts";
12
13const 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
23describe("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
283describe("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 @@
1import * as Deferred from "effect/Deferred";
2import * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Exit from "effect/Exit";
5import * as Fiber from "effect/Fiber";
6import * as Scope from "effect/Scope";
7import * as Semaphore from "effect/Semaphore";
8import * as Stream from "effect/Stream";
9import { ChildProcess, ChildProcessSpawner } from "effect/process";
10import * as EffectAcpErrors from "effect-acp/errors";
11import type * as EffectAcpSchema from "effect-acp/compat";
12import * 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
24const MAX_LIVE_TERMINALS = 16;
25const MAX_UNRELEASED_TERMINALS = 64;
26const MAX_RETAINED_TERMINALS = 32;
27const MAX_RETAINED_OUTPUT_BYTES = 16 * 1024 * 1024;
28const DEFAULT_OUTPUT_BYTE_LIMIT = 4 * 1024 * 1024;
29const MAX_OUTPUT_BYTE_LIMIT = 8 * 1024 * 1024;
30
31const terminalRequestError = (message: string, cause?: unknown) =>
32 EffectAcpErrors.AcpRequestError.internalError(
33 message,
34 undefined,
35 cause === undefined ? {} : { cause },
36 );
37
38interface OutputBufferState {
39 chunks: Array<Uint8Array>;
40 bytes: number;
41 truncated: boolean;
42 readonly limit: number;
43}
44
45function 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
62function 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
69function 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
83function 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
90export interface AcpTerminalOutputSnapshot {
91 readonly output: string;
92 readonly truncated: boolean;
93 readonly exitStatus: EffectAcpSchema.TerminalExitStatus | undefined;
94}
95
96interface 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
110export 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
137export 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
149function 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
164export 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 */
435export 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 @@
1import { ProviderDriverKind, RuntimeRequestId, TurnId } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3
4import {
5 makeAcpAssistantItemEvent,
6 makeAcpContentDeltaEvent,
7 makeAcpPlanUpdatedEvent,
8 makeAcpRequestOpenedEvent,
9 makeAcpRequestResolvedEvent,
10 makeAcpToolCallEvent,
11} from "./AcpCoreRuntimeEvents.ts";
12
13describe("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 @@
1import {
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
15import {
16 type AcpPermissionRequest,
17 type AcpPlanUpdate,
18 type AcpToolCallState,
19 canonicalItemTypeFromAcpToolKind,
20} from "./AcpRuntimeModel.ts";
21
22type AcpAdapterRawSource = Extract<
23 RuntimeEventRawSource,
24 "acp.jsonrpc" | `acp.${string}.extension`
25>;
26
27interface AcpEventStamp {
28 readonly eventId: EventId;
29 readonly createdAt: string;
30}
31
32type AcpCanonicalRequestType = Extract<
33 CanonicalRequestType,
34 "exec_command_approval" | "file_read_approval" | "file_change_approval" | "dynamic_tool_call"
35>;
36
37function 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
52function 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
68export 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
103export 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
126export 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
172export 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
206export 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
228export 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";
1717import * as Stream from "effect/Stream";
1818import { describe, expect } from "vite-plus/test";
1919
20import * as AcpSessionRuntime from "./AcpSessionRuntime.ts";
20import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";
2121import type * as EffectAcpProtocol from "effect-acp/protocol";
2222import * as EffectAcpErrors from "effect-acp/errors";
2323
apps/server/src/provider/acp/AcpNativeLogging.test.ts deleted-306
......@@ -1,306 +0,0 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { ProviderDriverKind, ThreadId } from "@t3tools/contracts";
3import { assert, it } from "@effect/vitest";
4import * as Cause from "effect/Cause";
5import * as Effect from "effect/Effect";
6import * as Exit from "effect/Exit";
7import * as Logger from "effect/Logger";
8import * as Schema from "effect/Schema";
9import * as AcpErrors from "effect-acp/errors";
10
11import type { EventNdjsonLogger } from "../EventNdjsonLogger.ts";
12import { makeAcpNativeLoggerFactory } from "./AcpNativeLogging.ts";
13
14const nodeServicesIt = it.layer(NodeServices.layer);
15const encodeUnknownJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
16
17nodeServicesIt("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 @@
1import type { ProviderDriverKind, ThreadId } from "@t3tools/contracts";
2import { causeErrorTag, errorTag } from "@t3tools/shared/observability";
3import * as Cause from "effect/Cause";
4import * as Crypto from "effect/Crypto";
5import * as DateTime from "effect/DateTime";
6import * as Effect from "effect/Effect";
7import type * as EffectAcpProtocol from "effect-acp/protocol";
8
9import type { EventNdjsonLogger } from "../EventNdjsonLogger.ts";
10import {
11 structuralProtocolMethod,
12 summarizeNativeProtocolPayload,
13} from "@t3tools/provider-core/server/nativeProtocolLogging";
14import type * as AcpSessionRuntime from "./AcpSessionRuntime.ts";
15
16const transientProtocolUpdates = new Set(["agent_message_chunk", "agent_thought_chunk"]);
17
18function structuralMethod(value: string): string {
19 return value.length <= 128 && /^[A-Za-z][A-Za-z0-9._:/-]*$/.test(value) ? value : "unknown";
20}
21
22function 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
50function 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
65function formatAcpProtocolLogPayload(event: EffectAcpProtocol.AcpProtocolLogEvent) {
66 return {
67 direction: event.direction,
68 stage: event.stage,
69 payload: summarizeNativeProtocolPayload(event.payload),
70 };
71}
72
73function 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
86function 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
103function 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
121export 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";
1616import * as PtyAdapter from "../../terminal/PtyAdapter.ts";
1717import { makeAcpRegistryAuth } from "./AcpRegistryAuth.ts";
1818import * as AcpRegistrySupport from "./AcpRegistrySupport.ts";
19import type { AcpSessionRuntime } from "./AcpSessionRuntime.ts";
19import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";
2020
2121const decodeSettings = Schema.decodeSync(AcpRegistrySettings);
2222const instanceId = ProviderInstanceId.make("acp-auth-test");
......@@ -101,7 +101,7 @@ const makeHarness = (
101101 authMethods: [{ ...method, name: `${method.name} ${version}` }],
102102 };
103103 let elicitation:
104 | Parameters<AcpSessionRuntime["Service"]["handleElicitation"]>[0]
104 | Parameters<AcpSessionRuntime.AcpSessionRuntime["Service"]["handleElicitation"]>[0]
105105 | undefined;
106106 return {
107107 initialize: () => Effect.succeed(initialized),
apps/server/src/provider/acp/AcpRegistryAuth.ts+1-1
......@@ -20,7 +20,7 @@ import * as ProviderAuthFlow from "../ProviderAuthFlow.ts";
2020import { normalizeAcpRegistryAuthMethods, normalizeAcpRegistryWebUrl } from "./AcpRegistryProbe.ts";
2121import * as AcpRegistrySupport from "./AcpRegistrySupport.ts";
2222import * as AcpRegistryRuntimeCoordinator from "./AcpRegistryRuntimeCoordinator.ts";
23import * as AcpSessionRuntime from "./AcpSessionRuntime.ts";
23import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";
2424
2525type Runtime = Pick<
2626 AcpSessionRuntime.AcpSessionRuntime["Service"],
apps/server/src/provider/acp/AcpRegistryProbe.test.ts+6-6
......@@ -12,7 +12,7 @@ import * as Schema from "effect/Schema";
1212import * as TestClock from "effect/testing/TestClock";
1313import * as EffectAcpErrors from "effect-acp/errors";
1414
15import type { AcpSessionRuntimeStartResult } from "./AcpSessionRuntime.ts";
15import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";
1616import {
1717 acpRegistryProbeFailure,
1818 acpRegistryProbeResult,
......@@ -68,7 +68,7 @@ describe("ACP Registry probe", () => {
6868 ],
6969 },
7070 modelConfigId: "model",
71 } satisfies AcpSessionRuntimeStartResult);
71 } satisfies AcpSessionRuntime.AcpSessionRuntimeStartResult);
7272
7373 expect(result).toEqual({
7474 instanceId,
......@@ -186,7 +186,7 @@ describe("ACP Registry probe", () => {
186186 ],
187187 },
188188 modelConfigId: "model",
189 } satisfies AcpSessionRuntimeStartResult);
189 } satisfies AcpSessionRuntime.AcpSessionRuntimeStartResult);
190190
191191 expect(result.models).toEqual([
192192 { id: "sonnet", name: "Sonnet", description: "Balanced" },
......@@ -216,7 +216,7 @@ describe("ACP Registry probe", () => {
216216 ],
217217 },
218218 modelConfigId: "model",
219 } satisfies AcpSessionRuntimeStartResult);
219 } satisfies AcpSessionRuntime.AcpSessionRuntimeStartResult);
220220
221221 expect(result.models).toEqual([{ id: "valid", name: "Valid", description: null }]);
222222 expect(result.currentModelId).toBeNull();
......@@ -270,7 +270,7 @@ describe("ACP Registry probe", () => {
270270 ],
271271 },
272272 modelConfigId: "model",
273 } satisfies AcpSessionRuntimeStartResult);
273 } satisfies AcpSessionRuntime.AcpSessionRuntimeStartResult);
274274
275275 expect(result.models).toEqual([
276276 { id: codexModel, name: "GPT-6.1 Sol", description: null },
......@@ -312,7 +312,7 @@ describe("ACP Registry probe", () => {
312312 ],
313313 },
314314 modelConfigId: "model",
315 } satisfies AcpSessionRuntimeStartResult);
315 } satisfies AcpSessionRuntime.AcpSessionRuntimeStartResult);
316316
317317 expect(result.models).toHaveLength(256);
318318 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";
2525
2626import * as PtyAdapter from "../../terminal/PtyAdapter.ts";
2727import * as AcpRegistrySupport from "./AcpRegistrySupport.ts";
28import { parseSessionModeState } from "./AcpRuntimeModel.ts";
29import { acpProviderOptionDescriptors } from "./AcpSessionConfig.ts";
28import { parseSessionModeState } from "@t3tools/provider-acp/server/runtimeModel";
29import { acpProviderOptionDescriptors } from "@t3tools/provider-acp/server/sessionConfig";
3030import * as AcpRegistryRuntimeCoordinator from "./AcpRegistryRuntimeCoordinator.ts";
31import * as AcpSessionRuntime from "./AcpSessionRuntime.ts";
31import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";
3232
3333const MAX_AUTH_METHODS = 32;
3434const MAX_MODELS = 256;
apps/server/src/provider/acp/AcpRegistrySupport.ts+2-2
......@@ -43,7 +43,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/process";
4343
4444import { collectUint8StreamText } from "@t3tools/provider-core/server/collectStreamText";
4545import * as ServerSettings from "../../serverSettings.ts";
46import type { AcpSpawnInput } from "./AcpSessionRuntime.ts";
46import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";
4747
4848const ACP_REGISTRY_URL = "https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json";
4949
......@@ -444,7 +444,7 @@ export function resolveAcpRegistryDistribution(input: {
444444export interface ResolvedAcpRegistryAgent {
445445 readonly agent?: AcpRegistryAgent;
446446 readonly distribution: AcpRegistryDistributionKind | "local";
447 readonly spawn: AcpSpawnInput;
447 readonly spawn: AcpSessionRuntime.AcpSpawnInput;
448448}
449449
450450export type AcpRegistryInspection =
apps/server/src/provider/acp/AcpRuntimeModel.test.ts deleted-1724
......@@ -1,1724 +0,0 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import type * as EffectAcpSchema from "effect-acp/compat";
4
5import {
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
24describe("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
1394describe("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
1696describe("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 @@
1import * as Clock from "effect/Clock";
2import * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Option from "effect/Option";
5import * as Ref from "effect/Ref";
6import * as Schema from "effect/Schema";
7import * as EffectAcpSchema from "effect-acp/compat";
8import * as EffectAcpSchemaV1 from "effect-acp/schema-v1";
9import {
10 deriveToolActivityPresentation,
11 mergeToolActivityData,
12} from "@t3tools/shared/toolActivity";
13import { T3_MCP_TOOL_NAMES } from "@t3tools/shared/t3McpToolPresentation";
14import type {
15 OrchestrationV2ProviderThreadNativeMetadata,
16 ThreadTokenUsageSnapshot,
17 ToolLifecycleItemType,
18} from "@t3tools/contracts";
19
20function 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.
26const isSessionModelState = Schema.is(EffectAcpSchema.SessionModelState);
27const isSessionModeState = Schema.is(EffectAcpSchemaV1.SessionModeState);
28
29export interface AcpSessionMode {
30 readonly id: string;
31 readonly name: string;
32 readonly description?: string;
33}
34
35export interface AcpSessionModeState {
36 readonly currentModeId: string;
37 readonly availableModes: ReadonlyArray<AcpSessionMode>;
38}
39
40export 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
50export 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
59type AcpAgentTerminalUpdate = Extract<
60 EffectAcpSchema.SessionUpdate,
61 { readonly sessionUpdate: "terminal_update" | "terminal_output_chunk" }
62>;
63
64const MAX_ACP_TERMINAL_OUTPUT_BYTES = 16 * 1024 * 1024;
65const MAX_ACP_TERMINAL_FRAME_BYTES = MAX_ACP_TERMINAL_OUTPUT_BYTES;
66
67function 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 */
82function 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
103function 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
122function 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
136function 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. */
145export 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
209export 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
224export interface AcpPermissionRequest {
225 readonly kind: string | "unknown";
226 readonly detail?: string;
227 readonly toolCall?: AcpToolCallState;
228}
229
230export 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
291const boundedContentMetadata = (value: string | null | undefined, maximumLength: number): string =>
292 value?.trim().slice(0, maximumLength) ?? "";
293
294const MAX_ACP_CONTENT_TEXT_LENGTH = 65_536;
295
296// Preserve whitespace in streamed model output while keeping one provider frame bounded.
297const boundedContentText = (value: string): string => value.slice(0, MAX_ACP_CONTENT_TEXT_LENGTH);
298
299const 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. */
305export 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
342function 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
381type AcpSessionSetupResponse =
382 | EffectAcpSchema.ForkSessionResponse
383 | EffectAcpSchema.LoadSessionResponse
384 | EffectAcpSchema.NewSessionResponse
385 | EffectAcpSchema.ResumeSessionResponse;
386
387type AcpToolCallUpdate = Extract<
388 EffectAcpSchema.SessionNotification["update"],
389 { readonly sessionUpdate: "tool_call" | "tool_call_update" }
390>;
391
392export 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
403export 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
417export 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
428export 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
476function 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
488function 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
507function 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
526function 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
534function 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
561function 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
569function 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.
630const TOOL_CALL_CONTENT_MAX_CHARS = 8_000;
631const TOOL_CALL_CONTENT_TRUNCATION_MARKER = "[Earlier output truncated]\n\n";
632
633function 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
641const 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.
647function 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
663interface ExtractedToolCallContent {
664 readonly text: string | undefined;
665 readonly content: ReadonlyArray<EffectAcpSchema.ToolCallContent> | undefined;
666}
667
668function 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`.
679function 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
701function 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.
733function 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
785function 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 */
793export 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
811function 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
902function 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
924export 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.
952const TOOL_CALL_UPDATE_MIN_DETAIL_GROWTH_CHARS = 256;
953const TOOL_CALL_UPDATE_COALESCE_LIMIT = 10;
954
955export interface AcpToolCallEmitDecisionInput {
956 readonly previous: AcpToolCallState | undefined;
957 readonly next: AcpToolCallState;
958 readonly lastEmittedDetailLength: number | undefined;
959 readonly skippedSinceEmit: number;
960}
961
962export interface AcpToolCallEmitDecision {
963 readonly emit: boolean;
964 readonly skippedSinceEmit: number;
965}
966
967function 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.
976export 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
1003function 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.
1015export 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
1026export 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
1049export 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. */
1056const ACP_MCP_FALLBACK_CALL = /(?:^|[\s"'=])acp-mcp-call[\s"']+([A-Za-z0-9_.-]+)(?:\s+(.+?))?\s*$/u;
1057
1058function 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 */
1087const 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 */
1095const 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 */
1103const 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 */
1115export 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 */
1217export 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
1230export 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
1259export 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. */
1265export 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
1301export 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
1311export 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 */
1337export 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
1345export 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.
1366function 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
1388export 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 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import { ACP_SESSION_MODE_OPTION_ID, acpProviderOptionDescriptors } from "./AcpSessionConfig.ts";
4
5describe("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 @@
1import type { ProviderOptionChoice, ProviderOptionDescriptor } from "@t3tools/contracts";
2import type * as EffectAcpSchema from "effect-acp/compat";
3
4import 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. */
18export const ACP_SESSION_MODE_OPTION_ID = "_t3/session-mode";
19
20const MAX_OPTION_DESCRIPTORS = 16;
21const MAX_OPTION_CHOICES = 64;
22const MAX_TEXT_LENGTH = 256;
23const MAX_DESCRIPTION_LENGTH = 1_024;
24
25const boundedText = (value: string | null | undefined, maximumLength: number): string =>
26 (value ?? "").trim().slice(0, maximumLength);
27
28const boundedOpaqueValue = (value: string, maximumLength: number): string | undefined =>
29 value.length > 0 && value === value.trim() && value.length <= maximumLength ? value : undefined;
30
31function flattenSelectChoices(
32 options: EffectAcpSchema.SessionConfigSelectOptions,
33): ReadonlyArray<EffectAcpSchema.SessionConfigSelectOption> {
34 return options.flatMap((candidate) => ("value" in candidate ? [candidate] : candidate.options));
35}
36
37function 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
61export 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";
1111import * as Exit from "effect/Exit";
1212import * as Scope from "effect/Scope";
1313
14import {
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";
14import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";
3715
3816const threadSpawnHelperSource = NodeURL.fileURLToPath(
3917 new URL("../../../scripts/acp-thread-spawn-helper.c", import.meta.url),
......@@ -46,7 +24,7 @@ const identity = (
4624 sid: number,
4725 startTime = String(pid),
4826 state = "S",
49): AcpPosixProcessIdentity => ({
27): AcpSessionRuntime.AcpPosixProcessIdentity => ({
5028 executable: `/proc/${pid}/exe`,
5129 pgid,
5230 pid,
......@@ -57,16 +35,16 @@ const identity = (
5735});
5836
5937function makeController(input: {
60 readonly processes: ReadonlyArray<AcpPosixProcessIdentity>;
38 readonly processes: ReadonlyArray<AcpSessionRuntime.AcpPosixProcessIdentity>;
6139 readonly onProcess?: (
62 processes: Map<number, AcpPosixProcessIdentity>,
40 processes: Map<number, AcpSessionRuntime.AcpPosixProcessIdentity>,
6341 pid: number,
6442 signal: NodeJS.Signals,
6543 ) => void;
6644}) {
6745 const processes = new Map(input.processes.map((process) => [process.pid, process]));
6846 const signals: Array<string> = [];
69 const controller: AcpPosixProcessTreeController = {
47 const controller: AcpSessionRuntime.AcpPosixProcessTreeController = {
7048 childPidsOf: (pid) =>
7149 [...processes.values()]
7250 .filter((process) => process.ppid === pid)
......@@ -96,19 +74,23 @@ function processExists(pid: number): boolean {
9674
9775describe("terminatePosixOwnedProcessTree", () => {
9876 it("parses unified cgroup paths and escaped cgroup2 mountinfo", () => {
99 expect(parseUnifiedCgroupPath("0::/user.slice/app.scope\n")).toBe("/user.slice/app.scope");
100 expect(parseUnifiedCgroupPath("0::/one\n0::/two\n")).toBeUndefined();
101 expect(isSafeCgroupPath("/user.slice/app.scope")).toBe(true);
102 expect(isSafeCgroupPath("/user.slice/../escape")).toBe(false);
103 expect(isSafeCgroupPath("/user.slice/app.scope (deleted)")).toBe(false);
77 expect(AcpSessionRuntime.parseUnifiedCgroupPath("0::/user.slice/app.scope\n")).toBe(
78 "/user.slice/app.scope",
79 );
80 expect(AcpSessionRuntime.parseUnifiedCgroupPath("0::/one\n0::/two\n")).toBeUndefined();
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);
10484 expect(
105 parseCgroup2Mount(
85 AcpSessionRuntime.parseCgroup2Mount(
10686 "42 31 0:37 /user.slice /sys/fs/cgroup/user\\040mount rw - cgroup2 cgroup2 rw\n",
10787 ),
10888 ).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();
11089 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(
11294 "41 31 0:36 /other /sys/fs/cgroup/other rw - cgroup2 cgroup2 rw\n" +
11395 "42 31 0:37 /user.slice /sys/fs/cgroup/user rw - cgroup2 cgroup2 rw\n",
11496 ),
......@@ -140,7 +122,7 @@ describe("terminatePosixOwnedProcessTree", () => {
140122 const unrelated = writeSibling("other-lease", "0");
141123 const isProcessAlive = (pid: number) => pid === process.pid || pid === liveForeignPid;
142124 try {
143 sweepStaleLinuxCgroupSiblings(parent, {
125 AcpSessionRuntime.sweepStaleLinuxCgroupSiblings(parent, {
144126 isProcessAlive,
145127 readPopulated: (path) => populatedByPath.get(path),
146128 remove: (path) => {
......@@ -155,7 +137,7 @@ describe("terminatePosixOwnedProcessTree", () => {
155137 expect(NodeFS.existsSync(unrelated)).toBe(true);
156138 // Missing populated state must not remove a sibling.
157139 writeSibling(`t3-acp-${deadPid}-eeee`, null);
158 sweepStaleLinuxCgroupSiblings(parent, {
140 AcpSessionRuntime.sweepStaleLinuxCgroupSiblings(parent, {
159141 isProcessAlive,
160142 readPopulated: (path) => populatedByPath.get(path),
161143 remove: (path) => {
......@@ -174,7 +156,7 @@ describe("terminatePosixOwnedProcessTree", () => {
174156 it.live("preserves exact argv and strips wrapper-only environment before exec", () =>
175157 Effect.gen(function* () {
176158 if ((yield* HostProcessPlatform) !== "linux") return;
177 const lease = makeLinuxCgroupController().create();
159 const lease = AcpSessionRuntime.makeLinuxCgroupController().create();
178160 if (lease === undefined) return;
179161 const scratchRoot = NodePath.join(process.cwd(), "tmp");
180162 NodeFS.mkdirSync(scratchRoot, { recursive: true });
......@@ -192,17 +174,27 @@ describe("terminatePosixOwnedProcessTree", () => {
192174 NodeFS.symlinkSync(process.execPath, bareGrok);
193175 NodeFS.symlinkSync(process.execPath, NodePath.join(relativeBin, "grok"));
194176 NodeFS.symlinkSync(process.execPath, NodePath.join(validBin, "grok"));
195 expect(resolveLinuxCgroupTargetCommand("grok", scratch, { PATH: scratch })).toBe(bareGrok);
196 expect(resolveLinuxCgroupTargetCommand("grok", scratch, { PATH: "" })).toBe(bareGrok);
177 expect(
178 AcpSessionRuntime.resolveLinuxCgroupTargetCommand("grok", scratch, { PATH: scratch }),
179 ).toBe(bareGrok);
180 expect(AcpSessionRuntime.resolveLinuxCgroupTargetCommand("grok", scratch, { PATH: "" })).toBe(
181 bareGrok,
182 );
197183 // Undefined PATH uses Node's default search path (/usr/bin:/bin), not cwd alone
198184 // (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 );
204185 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, {
206198 PATH: `directory-bin${NodePath.delimiter}valid-bin`,
207199 }),
208200 ).toBe(NodePath.join(validBin, "grok"));
......@@ -214,7 +206,7 @@ describe("terminatePosixOwnedProcessTree", () => {
214206 " wrapper: process.env.T3_ACP_CGROUP_WRAPPER,",
215207 "}));",
216208 ].join("\n");
217 const wrapped = wrapCommandForLinuxCgroup(lease, linkedNode, [
209 const wrapped = AcpSessionRuntime.wrapCommandForLinuxCgroup(lease, linkedNode, [
218210 "-e",
219211 target,
220212 outputPath,
......@@ -236,7 +228,7 @@ describe("terminatePosixOwnedProcessTree", () => {
236228 '{"args":["space value","single\'quote","double\\"quote"]}',
237229 );
238230 } finally {
239 yield* terminateLinuxCgroupLease(lease).pipe(Effect.ignore);
231 yield* AcpSessionRuntime.terminateLinuxCgroupLease(lease).pipe(Effect.ignore);
240232 NodeFS.rmSync(scratch, { recursive: true, force: true });
241233 }
242234 }),
......@@ -251,9 +243,11 @@ describe("terminatePosixOwnedProcessTree", () => {
251243 const outputPath = NodePath.join(scratch, "argv output");
252244 NodeFS.mkdirSync(leasePath);
253245 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 );
255249 expect(relativePath).toBeDefined();
256 const lease: AcpLinuxCgroupLease = {
250 const lease: AcpSessionRuntime.AcpLinuxCgroupLease = {
257251 contains: () => false,
258252 exists: () => true,
259253 path: leasePath,
......@@ -264,7 +258,7 @@ describe("terminatePosixOwnedProcessTree", () => {
264258 };
265259 const packagedExecPath = vi.spyOn(process, "execPath", "get").mockReturnValue("/bin/sh");
266260 try {
267 const wrapped = wrapCommandForLinuxCgroup(lease, "/bin/sh", [
261 const wrapped = AcpSessionRuntime.wrapCommandForLinuxCgroup(lease, "/bin/sh", [
268262 "-c",
269263 'printf "%s\\n" "$0" "$1" "$2" "$3" "${ELECTRON_RUN_AS_NODE-}" "${T3_ACP_CGROUP_WRAPPER-}" > "$4"',
270264 "packaged-target",
......@@ -289,7 +283,7 @@ describe("terminatePosixOwnedProcessTree", () => {
289283 /^\d+\n$/,
290284 );
291285
292 const mismatch = wrapCommandForLinuxCgroup(
286 const mismatch = AcpSessionRuntime.wrapCommandForLinuxCgroup(
293287 { ...lease, relativePath: "/not-the-current-cgroup" },
294288 "/bin/sh",
295289 ["-c", 'printf executed > "$0"', outputPath],
......@@ -301,7 +295,11 @@ describe("terminatePosixOwnedProcessTree", () => {
301295 expect(mismatchResult.status, mismatchResult.stderr).toBe(126);
302296 expect(NodeFS.existsSync(outputPath)).toBe(false);
303297
304 const missingTarget = wrapCommandForLinuxCgroup(lease, "/nonexistent-t3-probe", []);
298 const missingTarget = AcpSessionRuntime.wrapCommandForLinuxCgroup(
299 lease,
300 "/nonexistent-t3-probe",
301 [],
302 );
305303 const missingTargetResult = NodeChildProcess.spawnSync(
306304 missingTarget.command,
307305 missingTarget.args,
......@@ -319,12 +317,12 @@ describe("terminatePosixOwnedProcessTree", () => {
319317 it.live("kills a post-TERM detached double fork without touching an unrelated sentinel", () =>
320318 Effect.gen(function* () {
321319 if ((yield* HostProcessPlatform) !== "linux") return;
322 const controller = makeLinuxCgroupController();
320 const controller = AcpSessionRuntime.makeLinuxCgroupController();
323321 const lease = controller.create();
324322 if (lease === undefined) return;
325 expect(parseUnifiedCgroupPath(NodeFS.readFileSync("/proc/self/cgroup", "utf8"))).not.toBe(
326 lease.relativePath,
327 );
323 expect(
324 AcpSessionRuntime.parseUnifiedCgroupPath(NodeFS.readFileSync("/proc/self/cgroup", "utf8")),
325 ).not.toBe(lease.relativePath);
328326 const scratchRoot = NodePath.join(process.cwd(), "tmp");
329327 NodeFS.mkdirSync(scratchRoot, { recursive: true });
330328 const scratch = NodeFS.mkdtempSync(NodePath.join(scratchRoot, "acp-cgroup-kill-"));
......@@ -349,7 +347,7 @@ describe("terminatePosixOwnedProcessTree", () => {
349347 "});",
350348 "setInterval(() => {}, 1000);",
351349 ].join("\n");
352 const wrapped = wrapCommandForLinuxCgroup(lease, process.execPath, [
350 const wrapped = AcpSessionRuntime.wrapCommandForLinuxCgroup(lease, process.execPath, [
353351 "-e",
354352 providerProgram,
355353 readyPath,
......@@ -386,7 +384,7 @@ describe("terminatePosixOwnedProcessTree", () => {
386384 }
387385 }).pipe(Effect.timeout("2 seconds"));
388386 expect(processExists(detachedChildPid)).toBe(true);
389 yield* terminateLinuxCgroupLease(lease);
387 yield* AcpSessionRuntime.terminateLinuxCgroupLease(lease);
390388 expect(processExists(sentinel.pid!)).toBe(true);
391389 yield* Effect.gen(function* () {
392390 while (processExists(detachedChildPid!)) yield* Effect.sleep("10 millis");
......@@ -397,11 +395,11 @@ describe("terminatePosixOwnedProcessTree", () => {
397395 expect(replacement?.path).not.toBe(lease.path);
398396 if (replacement !== undefined) {
399397 NodeFS.mkdirSync(NodePath.join(replacement.path, "nested"));
400 yield* terminateLinuxCgroupLease(replacement);
398 yield* AcpSessionRuntime.terminateLinuxCgroupLease(replacement);
401399 expect(NodeFS.existsSync(replacement.path)).toBe(false);
402400 }
403401 } finally {
404 yield* terminateLinuxCgroupLease(lease).pipe(Effect.ignore);
402 yield* AcpSessionRuntime.terminateLinuxCgroupLease(lease).pipe(Effect.ignore);
405403 try {
406404 process.kill(-provider.pid!, "SIGKILL");
407405 } catch {
......@@ -430,7 +428,7 @@ describe("terminatePosixOwnedProcessTree", () => {
430428 let killCalls = 0;
431429 let populated = false;
432430 let removeCalls = 0;
433 const lease: AcpLinuxCgroupLease = {
431 const lease: AcpSessionRuntime.AcpLinuxCgroupLease = {
434432 contains: () => false,
435433 exists: () => exists,
436434 path: "/test/t3-acp-repopulation",
......@@ -457,11 +455,11 @@ describe("terminatePosixOwnedProcessTree", () => {
457455 },
458456 };
459457
460 yield* terminateLinuxCgroupLease(lease);
458 yield* AcpSessionRuntime.terminateLinuxCgroupLease(lease);
461459 expect(killCalls).toBeGreaterThanOrEqual(3);
462460 expect(removeCalls).toBe(3);
463461 expect(exists).toBe(false);
464 yield* terminateLinuxCgroupLease(lease);
462 yield* AcpSessionRuntime.terminateLinuxCgroupLease(lease);
465463 expect(removeCalls).toBe(3);
466464 }),
467465 );
......@@ -471,7 +469,7 @@ describe("terminatePosixOwnedProcessTree", () => {
471469 const removeError = Object.assign(new Error("cgroup remains busy"), {
472470 code: "EBUSY",
473471 });
474 const lease: AcpLinuxCgroupLease = {
472 const lease: AcpSessionRuntime.AcpLinuxCgroupLease = {
475473 contains: () => false,
476474 exists: () => true,
477475 path: "/test/t3-acp-busy",
......@@ -483,7 +481,7 @@ describe("terminatePosixOwnedProcessTree", () => {
483481 },
484482 };
485483
486 const error = yield* Effect.flip(terminateLinuxCgroupLease(lease));
484 const error = yield* Effect.flip(AcpSessionRuntime.terminateLinuxCgroupLease(lease));
487485 expect(error.detail).toBe("Failed to remove ACP cgroup /test/t3-acp-busy");
488486 expect(error.cause).toBe(removeError);
489487 }),
......@@ -493,13 +491,16 @@ describe("terminatePosixOwnedProcessTree", () => {
493491 const fixture = makeController({
494492 processes: [server(), identity(100, process.pid, 100, 100, "owned")],
495493 });
496 const ownedRoot: AcpOwnedPosixProcess = {
494 const ownedRoot: AcpSessionRuntime.AcpOwnedPosixProcess = {
497495 ...identity(100, process.pid, 100, 100, "owned"),
498496 parentExecutable: server().executable,
499497 parentStartTime: server().startTime,
500498 };
501 const root: AcpPosixOwnershipRoot = { captureAttempted: true, value: ownedRoot };
502 const lease: AcpLinuxCgroupLease = {
499 const root: AcpSessionRuntime.AcpPosixOwnershipRoot = {
500 captureAttempted: true,
501 value: ownedRoot,
502 };
503 const lease: AcpSessionRuntime.AcpLinuxCgroupLease = {
503504 contains: () => true,
504505 exists: () => true,
505506 path: "/test/t3-acp-root",
......@@ -508,19 +509,19 @@ describe("terminatePosixOwnedProcessTree", () => {
508509 populated: () => true,
509510 remove: () => undefined,
510511 };
511 signalLinuxCgroupRootTerm({ controller: fixture.controller, lease, root });
512 AcpSessionRuntime.signalLinuxCgroupRootTerm({ controller: fixture.controller, lease, root });
512513 expect(fixture.signals).toEqual(["process:100:SIGTERM"]);
513514
514515 const reused = makeController({
515516 processes: [server(), identity(100, process.pid, 100, 100, "reused")],
516517 });
517 signalLinuxCgroupRootTerm({ controller: reused.controller, lease, root });
518 AcpSessionRuntime.signalLinuxCgroupRootTerm({ controller: reused.controller, lease, root });
518519 expect(reused.signals).toEqual([]);
519520
520521 const migrated = makeController({
521522 processes: [server(), identity(100, process.pid, 100, 100, "owned")],
522523 });
523 signalLinuxCgroupRootTerm({
524 AcpSessionRuntime.signalLinuxCgroupRootTerm({
524525 controller: migrated.controller,
525526 lease: { ...lease, contains: () => false },
526527 root,
......@@ -565,9 +566,9 @@ describe("terminatePosixOwnedProcessTree", () => {
565566 }).pipe(Effect.timeout("2 seconds"));
566567 const [workerTid, childPid] = published;
567568 expect(workerTid).not.toBe(helper.pid);
568 expect(makePosixProcessTreeController("linux").childrenOf(helper.pid!)).toContainEqual(
569 expect.objectContaining({ pid: childPid, ppid: helper.pid }),
570 );
569 expect(
570 AcpSessionRuntime.makePosixProcessTreeController("linux").childrenOf(helper.pid!),
571 ).toContainEqual(expect.objectContaining({ pid: childPid, ppid: helper.pid }));
571572 } finally {
572573 try {
573574 process.kill(-helper.pid!, "SIGKILL");
......@@ -585,11 +586,11 @@ describe("terminatePosixOwnedProcessTree", () => {
585586 const fixture = makeController({
586587 processes: [server(), identity(100, process.pid, 100, 100), beforeExec],
587588 });
588 const frontier = new Map<number, AcpOwnedPosixProcess>();
589 const frontier = new Map<number, AcpSessionRuntime.AcpOwnedPosixProcess>();
589590 const childQueues = new Map<number, Array<number>>();
590 const ledger = new Map<string, AcpOwnedPosixProcess>();
591 const root: AcpPosixOwnershipRoot = { value: undefined };
592 observePosixOwnershipLedger({
591 const ledger = new Map<string, AcpSessionRuntime.AcpOwnedPosixProcess>();
592 const root: AcpSessionRuntime.AcpPosixOwnershipRoot = { value: undefined };
593 AcpSessionRuntime.observePosixOwnershipLedger({
593594 childQueues,
594595 controller: fixture.controller,
595596 frontier,
......@@ -598,7 +599,7 @@ describe("terminatePosixOwnedProcessTree", () => {
598599 rootPid: 100,
599600 });
600601 fixture.processes.set(110, { ...beforeExec, executable: "/usr/bin/bash" });
601 observePosixOwnershipLedger({
602 AcpSessionRuntime.observePosixOwnershipLedger({
602603 childQueues,
603604 controller: fixture.controller,
604605 frontier,
......@@ -610,7 +611,7 @@ describe("terminatePosixOwnedProcessTree", () => {
610611 expect([...ledger.values()].find((process) => process.pid === 110)?.executable).toBe(
611612 "/usr/bin/bash",
612613 );
613 yield* terminatePosixOwnedProcessTree({
614 yield* AcpSessionRuntime.terminatePosixOwnedProcessTree({
614615 controller: fixture.controller,
615616 grace: 0,
616617 ledger,
......@@ -623,13 +624,13 @@ describe("terminatePosixOwnedProcessTree", () => {
623624
624625 it("never adopts a replacement PID after initial root capture failed", () => {
625626 const fixture = makeController({ processes: [server()] });
626 const frontier = new Map<number, AcpOwnedPosixProcess>();
627 const frontier = new Map<number, AcpSessionRuntime.AcpOwnedPosixProcess>();
627628 const childQueues = new Map<number, Array<number>>();
628 const ledger = new Map<string, AcpOwnedPosixProcess>();
629 const root: AcpPosixOwnershipRoot = { value: undefined };
629 const ledger = new Map<string, AcpSessionRuntime.AcpOwnedPosixProcess>();
630 const root: AcpSessionRuntime.AcpPosixOwnershipRoot = { value: undefined };
630631 const capture = () => {
631632 try {
632 observePosixOwnershipLedger({
633 AcpSessionRuntime.observePosixOwnershipLedger({
633634 childQueues,
634635 controller: fixture.controller,
635636 frontier,
......@@ -663,7 +664,7 @@ describe("terminatePosixOwnedProcessTree", () => {
663664 const fixture = makeController({
664665 processes: [server(), identity(100, process.pid, 100, 100)],
665666 });
666 const controller: AcpPosixProcessTreeController = {
667 const controller: AcpSessionRuntime.AcpPosixProcessTreeController = {
667668 ...fixture.controller,
668669 childPidsOf: (pid) => {
669670 childListReads += 1;
......@@ -678,10 +679,10 @@ describe("terminatePosixOwnedProcessTree", () => {
678679 return fixture.controller.snapshot();
679680 },
680681 };
681 const frontier = new Map<number, AcpOwnedPosixProcess>();
682 const frontier = new Map<number, AcpSessionRuntime.AcpOwnedPosixProcess>();
682683 const childQueues = new Map<number, Array<number>>();
683 const ledger = new Map<string, AcpOwnedPosixProcess>();
684 const root: AcpPosixOwnershipRoot = { value: undefined };
684 const ledger = new Map<string, AcpSessionRuntime.AcpOwnedPosixProcess>();
685 const root: AcpSessionRuntime.AcpPosixOwnershipRoot = { value: undefined };
685686 for (let index = 0; index < 5_000; index += 1) {
686687 const tombstone = identity(100_000 + index, 1, 100_000 + index, 100_000 + index);
687688 ledger.set(`${tombstone.pid}:${tombstone.startTime}`, {
......@@ -690,7 +691,7 @@ describe("terminatePosixOwnedProcessTree", () => {
690691 parentStartTime: "",
691692 });
692693 }
693 observePosixOwnershipLedger({
694 AcpSessionRuntime.observePosixOwnershipLedger({
694695 childQueues,
695696 controller,
696697 frontier,
......@@ -714,7 +715,7 @@ describe("terminatePosixOwnedProcessTree", () => {
714715 ) {
715716 childListReads = 0;
716717 identityCalls = 0;
717 observePosixOwnershipLedger({
718 AcpSessionRuntime.observePosixOwnershipLedger({
718719 childQueues,
719720 controller,
720721 frontier,
......@@ -732,7 +733,7 @@ describe("terminatePosixOwnedProcessTree", () => {
732733 );
733734 expect(missing, `missing after ${passes} passes`).toEqual([]);
734735 expect(passes).toBeGreaterThan(2);
735 yield* terminatePosixOwnedProcessTree({
736 yield* AcpSessionRuntime.terminatePosixOwnedProcessTree({
736737 controller,
737738 grace: 0,
738739 ledger,
......@@ -751,7 +752,7 @@ describe("terminatePosixOwnedProcessTree", () => {
751752 const fixture = makeController({
752753 processes: [server(), identity(100, process.pid, 100, 100), identity(110, 100, 110, 110)],
753754 });
754 const controller: AcpPosixProcessTreeController = {
755 const controller: AcpSessionRuntime.AcpPosixProcessTreeController = {
755756 ...fixture.controller,
756757 childPidsOf: (pid) => {
757758 childrenCalls += 1;
......@@ -767,16 +768,16 @@ describe("terminatePosixOwnedProcessTree", () => {
767768 },
768769 };
769770 const scope = yield* Scope.make();
770 const frontier = new Map<number, AcpOwnedPosixProcess>();
771 const frontier = new Map<number, AcpSessionRuntime.AcpOwnedPosixProcess>();
771772 const childQueues = new Map<number, Array<number>>();
772773 let ledgerValuesCalls = 0;
773 const ledger = new (class extends Map<string, AcpOwnedPosixProcess> {
774 override values(): MapIterator<AcpOwnedPosixProcess> {
774 const ledger = new (class extends Map<string, AcpSessionRuntime.AcpOwnedPosixProcess> {
775 override values(): MapIterator<AcpSessionRuntime.AcpOwnedPosixProcess> {
775776 ledgerValuesCalls += 1;
776777 return super.values();
777778 }
778779 })();
779 const root: AcpPosixOwnershipRoot = { value: undefined };
780 const root: AcpSessionRuntime.AcpPosixOwnershipRoot = { value: undefined };
780781 for (let index = 0; index < 5_000; index += 1) {
781782 const tombstone = identity(10_000 + index, 1, 10_000 + index, 10_000 + index);
782783 ledger.set(`${tombstone.pid}:${tombstone.startTime}`, {
......@@ -785,7 +786,7 @@ describe("terminatePosixOwnedProcessTree", () => {
785786 parentStartTime: "",
786787 });
787788 }
788 yield* observePosixOwnershipLedgerContinuously({
789 yield* AcpSessionRuntime.observePosixOwnershipLedgerContinuously({
789790 childQueues,
790791 controller,
791792 frontier,
......@@ -831,7 +832,7 @@ describe("terminatePosixOwnedProcessTree", () => {
831832 },
832833 });
833834
834 yield* terminatePosixOwnedProcessTree({
835 yield* AcpSessionRuntime.terminatePosixOwnedProcessTree({
835836 controller: fixture.controller,
836837 grace: 0,
837838 rootPid: 100,
......@@ -865,7 +866,7 @@ describe("terminatePosixOwnedProcessTree", () => {
865866 },
866867 });
867868
868 yield* terminatePosixOwnedProcessTree({
869 yield* AcpSessionRuntime.terminatePosixOwnedProcessTree({
869870 controller: fixture.controller,
870871 grace: 0,
871872 rootPid: 100,
......@@ -893,7 +894,7 @@ describe("terminatePosixOwnedProcessTree", () => {
893894 });
894895
895896 const result = yield* Effect.exit(
896 terminatePosixOwnedProcessTree({
897 AcpSessionRuntime.terminatePosixOwnedProcessTree({
897898 controller: fixture.controller,
898899 grace: 0,
899900 rootPid: 100,
......@@ -913,9 +914,13 @@ describe("terminatePosixOwnedProcessTree", () => {
913914
914915 it.live("does not treat zombie residual entries as teardown survivors", () =>
915916 Effect.gen(function* () {
916 expect(posixProcessIsZombie({ ...identity(1, 0, 1, 1), state: "Z" })).toBe(true);
917 expect(posixProcessIsZombie({ ...identity(1, 0, 1, 1), state: "S" })).toBe(false);
918 const withoutState: AcpPosixProcessIdentity = {
917 expect(AcpSessionRuntime.posixProcessIsZombie({ ...identity(1, 0, 1, 1), state: "Z" })).toBe(
918 true,
919 );
920 expect(AcpSessionRuntime.posixProcessIsZombie({ ...identity(1, 0, 1, 1), state: "S" })).toBe(
921 false,
922 );
923 const withoutState: AcpSessionRuntime.AcpPosixProcessIdentity = {
919924 executable: "/proc/1/exe",
920925 pgid: 1,
921926 pid: 1,
......@@ -923,7 +928,7 @@ describe("terminatePosixOwnedProcessTree", () => {
923928 sid: 1,
924929 startTime: "1",
925930 };
926 expect(posixProcessIsZombie(withoutState)).toBe(false);
931 expect(AcpSessionRuntime.posixProcessIsZombie(withoutState)).toBe(false);
927932
928933 const root = identity(100, process.pid, 100, 100);
929934 const zombieChild = identity(110, 100, 110, 110, "110", "Z");
......@@ -936,7 +941,7 @@ describe("terminatePosixOwnedProcessTree", () => {
936941 processes.set(pid, { ...current, state: "Z" });
937942 },
938943 });
939 yield* terminatePosixOwnedProcessTree({
944 yield* AcpSessionRuntime.terminatePosixOwnedProcessTree({
940945 controller: fixture.controller,
941946 grace: 0,
942947 rootPid: 100,
......@@ -948,19 +953,19 @@ describe("terminatePosixOwnedProcessTree", () => {
948953 );
949954
950955 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 = {
952957 ...identity(200, 100, 200, 200, "stale-child"),
953958 parentExecutable: `/proc/100/exe`,
954959 parentStartTime: "100",
955960 };
956961 const liveChild = identity(200, 100, 200, 200, "live-child");
957962 const root = identity(100, process.pid, 100, 100);
958 const ownedRoot: AcpOwnedPosixProcess = {
963 const ownedRoot: AcpSessionRuntime.AcpOwnedPosixProcess = {
959964 ...root,
960965 parentExecutable: `/proc/${process.pid}/exe`,
961966 parentStartTime: "server",
962967 };
963 const ledger = new Map<string, AcpOwnedPosixProcess>([
968 const ledger = new Map<string, AcpSessionRuntime.AcpOwnedPosixProcess>([
964969 [`100:${root.startTime}`, ownedRoot],
965970 [`200:${staleChild.startTime}`, staleChild],
966971 ]);
......@@ -969,7 +974,7 @@ describe("terminatePosixOwnedProcessTree", () => {
969974 }).controller;
970975
971976 // First capture: PID 200 is free, so the dead reservation is dropped.
972 capturePosixOwnershipLedger({
977 AcpSessionRuntime.capturePosixOwnershipLedger({
973978 controller,
974979 ledger,
975980 rootPid: 100,
......@@ -978,7 +983,7 @@ describe("terminatePosixOwnedProcessTree", () => {
978983 expect([...ledger.values()].some((entry) => entry.pid === 200)).toBe(false);
979984
980985 // Second capture: a new process reuses PID 200 under the owned parent.
981 capturePosixOwnershipLedger({
986 AcpSessionRuntime.capturePosixOwnershipLedger({
982987 controller: makeController({ processes: [server(), root, liveChild] }).controller,
983988 ledger,
984989 rootPid: 100,
......@@ -990,24 +995,24 @@ describe("terminatePosixOwnedProcessTree", () => {
990995 });
991996
992997 it("admits a live child when a stale ledger entry still occupies the reused PID", () => {
993 const staleChild: AcpOwnedPosixProcess = {
998 const staleChild: AcpSessionRuntime.AcpOwnedPosixProcess = {
994999 ...identity(200, 100, 200, 200, "stale-child"),
9951000 parentExecutable: `/proc/100/exe`,
9961001 parentStartTime: "100",
9971002 };
9981003 const liveChild = identity(200, 100, 200, 200, "live-child");
9991004 const root = identity(100, process.pid, 100, 100);
1000 const ownedRoot: AcpOwnedPosixProcess = {
1005 const ownedRoot: AcpSessionRuntime.AcpOwnedPosixProcess = {
10011006 ...root,
10021007 parentExecutable: `/proc/${process.pid}/exe`,
10031008 parentStartTime: "server",
10041009 };
1005 const ledger = new Map<string, AcpOwnedPosixProcess>([
1010 const ledger = new Map<string, AcpSessionRuntime.AcpOwnedPosixProcess>([
10061011 [`100:${root.startTime}`, ownedRoot],
10071012 [`200:${staleChild.startTime}`, staleChild],
10081013 ]);
10091014
1010 capturePosixOwnershipLedger({
1015 AcpSessionRuntime.capturePosixOwnershipLedger({
10111016 controller: makeController({ processes: [server(), root, liveChild] }).controller,
10121017 ledger,
10131018 rootPid: 100,
......@@ -1040,7 +1045,7 @@ describe("terminatePosixOwnedProcessTree", () => {
10401045 },
10411046 });
10421047
1043 yield* terminatePosixOwnedProcessTree({
1048 yield* AcpSessionRuntime.terminatePosixOwnedProcessTree({
10441049 controller: fixture.controller,
10451050 grace: 0,
10461051 rootPid: 100,
......@@ -1064,7 +1069,7 @@ describe("terminatePosixOwnedProcessTree", () => {
10641069 },
10651070 });
10661071 let snapshotCalls = 0;
1067 const controller: AcpPosixProcessTreeController = {
1072 const controller: AcpSessionRuntime.AcpPosixProcessTreeController = {
10681073 ...fixture.controller,
10691074 snapshot: () => {
10701075 snapshotCalls += 1;
......@@ -1076,7 +1081,7 @@ describe("terminatePosixOwnedProcessTree", () => {
10761081 };
10771082
10781083 const error = yield* Effect.flip(
1079 terminatePosixOwnedProcessTree({
1084 AcpSessionRuntime.terminatePosixOwnedProcessTree({
10801085 controller,
10811086 discoveryPasses: 2,
10821087 grace: 0,
......@@ -1092,10 +1097,10 @@ describe("terminatePosixOwnedProcessTree", () => {
10921097 it.live("retains the ledger across root exit and an explicit failure for finalizer retry", () =>
10931098 Effect.gen(function* () {
10941099 let ignoreSignals = true;
1095 const ledger = new Map<string, AcpOwnedPosixProcess>();
1096 const frontier = new Map<number, AcpOwnedPosixProcess>();
1100 const ledger = new Map<string, AcpSessionRuntime.AcpOwnedPosixProcess>();
1101 const frontier = new Map<number, AcpSessionRuntime.AcpOwnedPosixProcess>();
10971102 const childQueues = new Map<number, Array<number>>();
1098 const root: AcpPosixOwnershipRoot = { value: undefined };
1103 const root: AcpSessionRuntime.AcpPosixOwnershipRoot = { value: undefined };
10991104 const fixture = makeController({
11001105 processes: [
11011106 server(),
......@@ -1108,7 +1113,7 @@ describe("terminatePosixOwnedProcessTree", () => {
11081113 if (!ignoreSignals) processes.delete(pid);
11091114 },
11101115 });
1111 observePosixOwnershipLedger({
1116 AcpSessionRuntime.observePosixOwnershipLedger({
11121117 childQueues,
11131118 controller: fixture.controller,
11141119 frontier,
......@@ -1118,7 +1123,7 @@ describe("terminatePosixOwnedProcessTree", () => {
11181123 });
11191124
11201125 const explicit = yield* Effect.exit(
1121 terminatePosixOwnedProcessTree({
1126 AcpSessionRuntime.terminatePosixOwnedProcessTree({
11221127 controller: fixture.controller,
11231128 grace: 0,
11241129 ledger,
......@@ -1130,7 +1135,7 @@ describe("terminatePosixOwnedProcessTree", () => {
11301135 expect(fixture.processes.has(111)).toBe(true);
11311136
11321137 fixture.processes.set(112, identity(112, 110, 112, 112));
1133 observePosixOwnershipLedger({
1138 AcpSessionRuntime.observePosixOwnershipLedger({
11341139 childQueues,
11351140 controller: fixture.controller,
11361141 frontier,
......@@ -1139,7 +1144,7 @@ describe("terminatePosixOwnedProcessTree", () => {
11391144 rootPid: 100,
11401145 });
11411146 ignoreSignals = false;
1142 yield* terminatePosixOwnedProcessTree({
1147 yield* AcpSessionRuntime.terminatePosixOwnedProcessTree({
11431148 controller: fixture.controller,
11441149 grace: 0,
11451150 ledger,
......@@ -1153,7 +1158,7 @@ describe("terminatePosixOwnedProcessTree", () => {
11531158
11541159 it("fails closed where a stable POSIX identity provider is unavailable", () => {
11551160 try {
1156 makePosixProcessTreeController("darwin");
1161 AcpSessionRuntime.makePosixProcessTreeController("darwin");
11571162 throw new Error("Expected Darwin process ownership to fail closed");
11581163 } catch (error) {
11591164 expect(error).toMatchObject({
apps/server/src/provider/acp/AcpSessionRuntime.ts deleted-3035
......@@ -1,3035 +0,0 @@
1// @effect-diagnostics nodeBuiltinImport:off
2import * as NodeCrypto from "node:crypto";
3import * as NodeFS from "node:fs";
4import * as NodePath from "node:path";
5
6import * as Cause from "effect/Cause";
7import * as Clock from "effect/Clock";
8import * as Context from "effect/Context";
9import * as Crypto from "effect/Crypto";
10import * as Deferred from "effect/Deferred";
11import * as Duration from "effect/Duration";
12import * as Effect from "effect/Effect";
13import * as Exit from "effect/Exit";
14import * as Fiber from "effect/Fiber";
15import * as Layer from "effect/Layer";
16import * as Option from "effect/Option";
17import * as Queue from "effect/Queue";
18import * as Ref from "effect/Ref";
19import * as Schema from "effect/Schema";
20import * as Scope from "effect/Scope";
21import * as Semaphore from "effect/Semaphore";
22import * as Stream from "effect/Stream";
23import * as ChildProcess from "effect/process/ChildProcess";
24import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
25import * as EffectAcpClient from "effect-acp/client";
26import * as EffectAcpErrors from "effect-acp/errors";
27import type * as EffectAcpSchema from "effect-acp/compat";
28import type * as EffectAcpProtocol from "effect-acp/protocol";
29import { resolveSpawnCommand } from "@t3tools/shared/shell";
30import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
31
32import { signalProcessGroup } from "@t3tools/provider-core/server/processGroup";
33import { appendAcpStderrTail, sanitizeAcpStderrExcerpt } from "./AcpStderr.ts";
34import {
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
52const MAX_SHOWN_TOOL_CALL_IDS = 256;
53
54interface AcpToolCallTrackedState {
55 readonly state: AcpToolCallState;
56 readonly lastEmittedDetailLength: number | undefined;
57 readonly skippedSinceEmit: number;
58}
59
60function formatConfigOptionValue(value: string | boolean): string {
61 return JSON.stringify(value);
62}
63
64export interface AcpSessionEventStreamBarrier {
65 readonly _tag: "EventStreamBarrier";
66 readonly acknowledge: Deferred.Deferred<void>;
67}
68
69export type AcpSessionRuntimeEvent =
70 | AcpParsedSessionEvent
71 | AcpSessionEventStreamBarrier
72 | {
73 readonly _tag: "ConnectionTerminated";
74 readonly error: EffectAcpErrors.AcpError;
75 };
76
77const defaultSessionLoadTimeout = Duration.seconds(90);
78const defaultSessionLoadReplayIdleGap = Duration.seconds(2);
79const defaultCancelTimeout = Duration.seconds(15);
80const maxStartupMetadataUpdates = 32;
81// Antigravity can emit an accepted 16 KiB Google authorization URL on stderr.
82const maxStderrChunkLength = 32_768;
83
84export 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
93export 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
159export 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
167export 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
181const isAcpProcessGroupTerminationError = Schema.is(AcpProcessGroupTerminationError);
182
183export 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
198export 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
206export 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
216export interface AcpLinuxCgroupController {
217 readonly create: () => AcpLinuxCgroupLease | undefined;
218}
219
220export interface AcpPosixOwnershipRoot {
221 captureAttempted?: boolean;
222 value: AcpOwnedPosixProcess | undefined;
223}
224
225function 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
231export function parseUnifiedCgroupPath(contents: string): string | undefined {
232 const matches = [...contents.matchAll(/^0::(\/.*)$/gm)];
233 return matches.length === 1 ? matches[0]![1] : undefined;
234}
235
236export function isSafeCgroupPath(value: string): boolean {
237 return (
238 value.startsWith("/") &&
239 !value.includes("(deleted)") &&
240 NodePath.posix.normalize(value) === value
241 );
242}
243
244export 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
261export function parseCgroup2Mount(
262 contents: string,
263): { readonly mountPoint: string; readonly root: string } | undefined {
264 return parseCgroup2Mounts(contents)[0];
265}
266
267export 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
297export 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
331const STALE_ACP_CGROUP_SIBLING = /^t3-acp-(\d+)-/;
332
333export 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
340function 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
349function 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. */
357export 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
387function 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
458export 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.
490const wallClock = Clock.Clock.defaultValue();
491const withWallClock = <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
492 Effect.provideService(effect, Clock.Clock, wallClock);
493
494export 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
555export 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
571function 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. */
608export function posixProcessIsZombie(process: AcpPosixProcessIdentity): boolean {
609 return process.state === "Z";
610}
611
612function 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
638function 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
657export 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
749export 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
770export 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
779function 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
790export interface AcpOwnedPosixProcess extends AcpPosixProcessIdentity {
791 readonly parentExecutable: string | undefined;
792 readonly parentStartTime: string;
793}
794
795function processIdentityKey(process: AcpPosixProcessIdentity): string {
796 return `${process.pid}:${process.startTime}`;
797}
798
799function 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
816export 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
908export 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
1053export function windowsTaskkillResultIsSuccess(exitCode: number, _output?: string): boolean {
1054 return exitCode === 0;
1055}
1056
1057export 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
1096export 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
1107function isAcpAuthenticationRequired(error: EffectAcpErrors.AcpError): boolean {
1108 return error._tag === "AcpRequestError" && error.code === -32000;
1109}
1110
1111export 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
1122export interface AcpSessionActivationOptions {
1123 readonly mcpServers?: ReadonlyArray<EffectAcpSchema.McpServer>;
1124 readonly acpMcpServers?: ReadonlyArray<EffectAcpSchema.McpServer>;
1125}
1126
1127export 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
1344interface AcpStartedState extends AcpSessionRuntimeStartResult {}
1345
1346type 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
1354type 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
1365interface AcpAssistantSegmentState {
1366 readonly nextSegmentIndex: number;
1367 readonly activeItemId?: string;
1368}
1369
1370interface EnsureActiveAssistantSegmentResult {
1371 readonly itemId: string;
1372 readonly startedEvent?: Extract<AcpParsedSessionEvent, { readonly _tag: "AssistantItemStarted" }>;
1373}
1374
1375interface AcpActivePrompt {
1376 readonly fiber: Fiber.Fiber<EffectAcpSchema.PromptResponse, EffectAcpErrors.AcpError>;
1377 readonly completed: Deferred.Deferred<void>;
1378}
1379
1380export 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
2812export 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
2820function sessionConfigOptionsFromSetup(
2821 response:
2822 | {
2823 readonly configOptions?: ReadonlyArray<EffectAcpSchema.SessionConfigOption> | null;
2824 }
2825 | undefined,
2826): ReadonlyArray<EffectAcpSchema.SessionConfigOption> {
2827 return response?.configOptions ?? [];
2828}
2829
2830function 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
2844function 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
2855const 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
2959function 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
2972const assistantItemId = (sessionId: string, runtimeId: string, segmentIndex: number) =>
2973 `assistant:${sessionId}:runtime:${runtimeId}:segment:${segmentIndex}`;
2974
2975const 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
3015const 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 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 ACP_STDERR_TAIL_MAX_CHARS,
5 appendAcpStderrTail,
6 sanitizeAcpStderrExcerpt,
7} from "./AcpStderr.ts";
8
9describe("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.
2import * as NodeOS from "node:os";
3
4/** Last few KiB of ACP child stderr kept for startup / exit diagnostics. */
5export const ACP_STDERR_TAIL_MAX_CHARS = 4_096;
6
7const PAIRING_URL_PATTERN = /https?:\/\/[^\s]*\/pair#[^\s]*/gi;
8const BEARER_TOKEN_PATTERN = /\bBearer\s+[A-Za-z0-9._\-+=/]+/gi;
9const BASIC_AUTH_PATTERN = /\bAuthorization:\s*Basic\s+\S+/gi;
10const API_KEY_HEADER_PATTERN = /\bx-api-key:\s*\S+/gi;
11const 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
14export 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. */
20export 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 {
2222 makeAntigravityStderrHandler,
2323 makeAntigravityStdoutTransform,
2424} from "../antigravityAuthSupport.ts";
25import * as AcpSessionRuntime from "./AcpSessionRuntime.ts";
25import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";
2626import { normalizeAntigravitySessionUpdate } from "./AntigravityProtocol.ts";
2727
2828export interface AntigravityAcpRuntimeInput extends Omit<
apps/server/src/provider/acp/AntigravityProtocol.test.ts+4-1
......@@ -17,7 +17,10 @@ import {
1717 sanitizeAntigravityToolPayload,
1818 selectAntigravityPermissionOptionId,
1919} from "./AntigravityProtocol.ts";
20import { mergeToolCallState, parseSessionUpdateEvent } from "./AcpRuntimeModel.ts";
20import {
21 mergeToolCallState,
22 parseSessionUpdateEvent,
23} from "@t3tools/provider-acp/server/runtimeModel";
2124
2225const isSessionNotification = Schema.is(AcpWireSchema.SessionNotification);
2326
apps/server/src/provider/acp/AntigravityProtocol.ts+1-1
......@@ -11,7 +11,7 @@ import * as Schema from "effect/Schema";
1111import type * as EffectAcpSchema from "effect-acp/compat";
1212import * as AcpWireSchema from "effect-acp/schema-v1";
1313
14import type { AcpToolCallState } from "./AcpRuntimeModel.ts";
14import type { AcpToolCallState } from "@t3tools/provider-acp/server/runtimeModel";
1515
1616const TOOL_TEXT_LIMIT = 8_000;
1717const 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";
1515import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
1616import { normalizeModelSlug } from "@t3tools/shared/model";
1717
18import * as AcpSessionRuntime from "./AcpSessionRuntime.ts";
18import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";
1919import { makeXAiPromptCompletionRuntime } from "./XAiAcpExtension.ts";
2020
2121const GROK_API_KEY_ENV = "XAI_API_KEY";
apps/server/src/provider/acp/XAiAcpExtension.test.ts+2-2
......@@ -34,12 +34,12 @@ import {
3434 xAiSubagentFinishedNotice,
3535 XAiAskUserQuestionRequest,
3636} from "./XAiAcpExtension.ts";
37import * as AcpSessionRuntime from "./AcpSessionRuntime.ts";
37import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";
3838import {
3939 type AcpToolCallState,
4040 mergeToolCallState,
4141 parseSessionUpdateEvent,
42} from "./AcpRuntimeModel.ts";
42} from "@t3tools/provider-acp/server/runtimeModel";
4343
4444const decodeXAiAskUserQuestionRequest = Schema.decodeUnknownSync(XAiAskUserQuestionRequest);
4545
apps/server/src/provider/acp/XAiAcpExtension.ts+2-2
......@@ -11,8 +11,8 @@ import * as Schema from "effect/Schema";
1111import * as EffectAcpErrors from "effect-acp/errors";
1212import type * as EffectAcpSchema from "effect-acp/compat";
1313
14import type * as AcpSessionRuntime from "./AcpSessionRuntime.ts";
15import type { AcpToolCallState } from "./AcpRuntimeModel.ts";
14import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";
15import type { AcpToolCallState } from "@t3tools/provider-acp/server/runtimeModel";
1616
1717const xAiStopReasonMissingMetaKey = "xAiStopReasonMissing";
1818export const xAiRateLimitedErrorCode = -32003;
apps/server/src/provider/antigravityAuthSupport.ts+2-2
......@@ -17,7 +17,7 @@ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
1717import * as AcpErrors from "effect-acp/errors";
1818
1919import { collectUint8StreamText } from "@t3tools/provider-core/server/collectStreamText";
20import type { AcpSpawnInput } from "./acp/AcpSessionRuntime.ts";
20import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";
2121import {
2222 antigravityUserSkillDirectories,
2323 resolveAntigravityUserHome,
......@@ -440,7 +440,7 @@ export function buildAntigravityAcpSpawnInput(input: {
440440 readonly auth?: AntigravityAuthConfig;
441441 /** Per-process temp directory. Defaults to the profile's shared temp directory. */
442442 readonly runtimeTempDirectory?: string;
443}): AcpSpawnInput {
443}): AcpSessionRuntime.AcpSpawnInput {
444444 return {
445445 command: input.installation.executablePath,
446446 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";
2020import type * as AcpSchema from "effect-acp/compat";
2121import { expect } from "vite-plus/test";
2222
23import type { AcpSessionRuntimeEvent } from "../provider/acp/AcpSessionRuntime.ts";
23import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";
2424import { removeAntigravitySessionFiles } from "../provider/acp/AntigravitySessionFiles.ts";
2525
2626import {
......@@ -121,7 +121,7 @@ const makeFixture = Effect.fn("makeAntigravityTextGenerationFixture")(function*
121121
122122 const makeRuntime: AntigravityTextGenerationOptions["makeRuntime"] = (cwd) =>
123123 Effect.gen(function* () {
124 const events = yield* Queue.unbounded<AcpSessionRuntimeEvent>();
124 const events = yield* Queue.unbounded<AcpSessionRuntime.AcpSessionRuntimeEvent>();
125125 state.workspaces.push(cwd);
126126 expect(yield* fs.readDirectory(cwd).pipe(Effect.orDie)).toEqual([]);
127127 yield* Effect.addFinalizer(() =>
apps/server/src/textGeneration/AntigravityTextGeneration.ts+2-2
......@@ -13,7 +13,7 @@ import { type AcpError, AcpRequestError } from "effect-acp/errors";
1313
1414import { applyAntigravityAcpModelSelection } from "../provider/acp/AntigravityAcpSupport.ts";
1515import { removeAntigravitySessionFiles } from "../provider/acp/AntigravitySessionFiles.ts";
16import type { AcpSessionRuntime } from "../provider/acp/AcpSessionRuntime.ts";
16import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";
1717import * as TextGenerationOperations from "@t3tools/provider-core/server/textGenerationOperations";
1818
1919const ANTIGRAVITY_TIMEOUT_MS = 180_000;
......@@ -25,7 +25,7 @@ const decodeConfiguration = Schema.decodeEffect(Schema.fromJsonString(Configurat
2525const decodeConfigurationObject = Schema.decodeUnknownEffect(Configuration);
2626
2727type AntigravityTextRuntime = Pick<
28 AcpSessionRuntime["Service"],
28 AcpSessionRuntime.AcpSessionRuntime["Service"],
2929 | "start"
3030 | "setMode"
3131 | "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
1010- **ACP agents** start as [ACP Registry](../user/providers-acp.md) entries, which follow the ACP
1111 spec with no per-agent handling. An agent gets a dedicated driver only when it needs behavior the
1212 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
1414 and Antigravity. Never add agent-id checks to the generic registry adapter.
1515- **Other protocols** get a native adapter that implements
1616 [`ProviderAdapterV2`](../../packages/provider-core/src/server/ProviderAdapter.ts), like Codex,
package.json+1-1
......@@ -28,7 +28,7 @@
2828 "tc": "vp run -r --concurrency-limit 2 typecheck",
2929 "lint": "vp lint --report-unused-disable-directives",
3030 "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",
3232 "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts",
3333 "lint:mobile": "node scripts/mobile-native-static-check.ts",
3434 "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
2import * as NodeCrypto from "node:crypto";
3import * as NodeFS from "node:fs";
4import * as NodePath from "node:path";
5
6import * as Cause from "effect/Cause";
7import * as Clock from "effect/Clock";
8import * as Context from "effect/Context";
9import * as Crypto from "effect/Crypto";
10import * as Deferred from "effect/Deferred";
11import * as Duration from "effect/Duration";
12import * as Effect from "effect/Effect";
13import * as Exit from "effect/Exit";
14import * as Fiber from "effect/Fiber";
15import * as Layer from "effect/Layer";
16import * as Option from "effect/Option";
17import * as Queue from "effect/Queue";
18import * as Ref from "effect/Ref";
19import * as Schema from "effect/Schema";
20import * as Scope from "effect/Scope";
21import * as Semaphore from "effect/Semaphore";
22import * as Stream from "effect/Stream";
23import * as ChildProcess from "effect/process/ChildProcess";
24import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
25import * as EffectAcpClient from "effect-acp/client";
26import * as EffectAcpErrors from "effect-acp/errors";
27import type * as EffectAcpSchema from "effect-acp/compat";
28import type * as EffectAcpProtocol from "effect-acp/protocol";
29import { resolveSpawnCommand } from "@t3tools/shared/shell";
30import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
31
32import { signalProcessGroup } from "@t3tools/provider-core/server/processGroup";
33import { appendAcpStderrTail, sanitizeAcpStderrExcerpt } from "./stderr.ts";
34import {
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
52const MAX_SHOWN_TOOL_CALL_IDS = 256;
53
54interface AcpToolCallTrackedState {
55 readonly state: AcpToolCallState;
56 readonly lastEmittedDetailLength: number | undefined;
57 readonly skippedSinceEmit: number;
58}
59
60function formatConfigOptionValue(value: string | boolean): string {
61 return JSON.stringify(value);
62}
63
64export interface AcpSessionEventStreamBarrier {
65 readonly _tag: "EventStreamBarrier";
66 readonly acknowledge: Deferred.Deferred<void>;
67}
68
69export type AcpSessionRuntimeEvent =
70 | AcpParsedSessionEvent
71 | AcpSessionEventStreamBarrier
72 | {
73 readonly _tag: "ConnectionTerminated";
74 readonly error: EffectAcpErrors.AcpError;
75 };
76
77const defaultSessionLoadTimeout = Duration.seconds(90);
78const defaultSessionLoadReplayIdleGap = Duration.seconds(2);
79const defaultCancelTimeout = Duration.seconds(15);
80const maxStartupMetadataUpdates = 32;
81// Antigravity can emit an accepted 16 KiB Google authorization URL on stderr.
82const maxStderrChunkLength = 32_768;
83
84export 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
93export 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
159export 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
167export 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
181const isAcpProcessGroupTerminationError = Schema.is(AcpProcessGroupTerminationError);
182
183export 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
198export 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
206export 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
216export interface AcpLinuxCgroupController {
217 readonly create: () => AcpLinuxCgroupLease | undefined;
218}
219
220export interface AcpPosixOwnershipRoot {
221 captureAttempted?: boolean;
222 value: AcpOwnedPosixProcess | undefined;
223}
224
225function 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
231export function parseUnifiedCgroupPath(contents: string): string | undefined {
232 const matches = [...contents.matchAll(/^0::(\/.*)$/gm)];
233 return matches.length === 1 ? matches[0]![1] : undefined;
234}
235
236export function isSafeCgroupPath(value: string): boolean {
237 return (
238 value.startsWith("/") &&
239 !value.includes("(deleted)") &&
240 NodePath.posix.normalize(value) === value
241 );
242}
243
244export 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
261export function parseCgroup2Mount(
262 contents: string,
263): { readonly mountPoint: string; readonly root: string } | undefined {
264 return parseCgroup2Mounts(contents)[0];
265}
266
267export 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
297export 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
331const STALE_ACP_CGROUP_SIBLING = /^t3-acp-(\d+)-/;
332
333export 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
340function 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
349function 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. */
357export 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
387function 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
458export 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.
490const wallClock = Clock.Clock.defaultValue();
491const withWallClock = <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
492 Effect.provideService(effect, Clock.Clock, wallClock);
493
494export 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
555export 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
571function 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. */
608export function posixProcessIsZombie(process: AcpPosixProcessIdentity): boolean {
609 return process.state === "Z";
610}
611
612function 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
638function 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
657export 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
749export 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
770export 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
779function 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
790export interface AcpOwnedPosixProcess extends AcpPosixProcessIdentity {
791 readonly parentExecutable: string | undefined;
792 readonly parentStartTime: string;
793}
794
795function processIdentityKey(process: AcpPosixProcessIdentity): string {
796 return `${process.pid}:${process.startTime}`;
797}
798
799function 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
816export 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
908export 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
1053export function windowsTaskkillResultIsSuccess(exitCode: number, _output?: string): boolean {
1054 return exitCode === 0;
1055}
1056
1057export 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
1096export 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
1107function isAcpAuthenticationRequired(error: EffectAcpErrors.AcpError): boolean {
1108 return error._tag === "AcpRequestError" && error.code === -32000;
1109}
1110
1111export 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
1122export interface AcpSessionActivationOptions {
1123 readonly mcpServers?: ReadonlyArray<EffectAcpSchema.McpServer>;
1124 readonly acpMcpServers?: ReadonlyArray<EffectAcpSchema.McpServer>;
1125}
1126
1127export 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
1344interface AcpStartedState extends AcpSessionRuntimeStartResult {}
1345
1346type 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
1354type 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
1365interface AcpAssistantSegmentState {
1366 readonly nextSegmentIndex: number;
1367 readonly activeItemId?: string;
1368}
1369
1370interface EnsureActiveAssistantSegmentResult {
1371 readonly itemId: string;
1372 readonly startedEvent?: Extract<AcpParsedSessionEvent, { readonly _tag: "AssistantItemStarted" }>;
1373}
1374
1375interface AcpActivePrompt {
1376 readonly fiber: Fiber.Fiber<EffectAcpSchema.PromptResponse, EffectAcpErrors.AcpError>;
1377 readonly completed: Deferred.Deferred<void>;
1378}
1379
1380export 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
2812export 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
2820function sessionConfigOptionsFromSetup(
2821 response:
2822 | {
2823 readonly configOptions?: ReadonlyArray<EffectAcpSchema.SessionConfigOption> | null;
2824 }
2825 | undefined,
2826): ReadonlyArray<EffectAcpSchema.SessionConfigOption> {
2827 return response?.configOptions ?? [];
2828}
2829
2830function 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
2844function 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
2855const 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
2959function 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
2972const assistantItemId = (sessionId: string, runtimeId: string, segmentIndex: number) =>
2973 `assistant:${sessionId}:runtime:${runtimeId}:segment:${segmentIndex}`;
2974
2975const 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
3015const 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
2import * as NodePath from "node:path";
3
4import {
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";
35import { modelSelectionsEqual } from "@t3tools/shared/model";
36import { type SelfInvocation, selfInvocationArgs } from "@t3tools/shared/nodeRuntime";
37import { FILE_HEADERS_ONLY, formatPatch, structuredPatch } from "diff";
38import * as Cause from "effect/Cause";
39import * as Crypto from "effect/Crypto";
40import * as DateTime from "effect/DateTime";
41import * as Deferred from "effect/Deferred";
42import type * as Duration from "effect/Duration";
43import * as Effect from "effect/Effect";
44import * as Exit from "effect/Exit";
45import * as FileSystem from "effect/FileSystem";
46import * as Fiber from "effect/Fiber";
47import * as Option from "effect/Option";
48import * as Queue from "effect/Queue";
49import * as Ref from "effect/Ref";
50import * as Result from "effect/Result";
51import * as Scope from "effect/Scope";
52import * as Semaphore from "effect/Semaphore";
53import * as Stream from "effect/Stream";
54import type { ChildProcessSpawner } from "effect/process";
55import * as EffectAcpErrors from "effect-acp/errors";
56import type * as EffectAcpProtocol from "effect-acp/protocol";
57import type * as EffectAcpSchema from "effect-acp/compat";
58
59import { formatReadToolLabel, formatSearchToolLabel } from "@t3tools/shared/toolActivity";
60import type * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";
61import { makeAcpMcpOverAcpBridge, type AcpMcpOverAcpBridge } from "./mcpOverAcpBridge.ts";
62import { mcpToolPresentation } from "@t3tools/provider-core/server/mcpToolPresentation";
63import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession";
64import {
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";
78import {
79 acpClientExecuteDisposition,
80 acpMcpToolApprovalElicitationDisposition,
81 acpPermissionDisposition,
82 type AcpPermissionDisposition,
83 makeAcpClientPolicyGrants,
84 unknownRecord,
85} from "./clientPolicy.ts";
86import {
87 makeAcpClientTerminals,
88 resolveEmbeddedTerminalContent,
89 type AcpClientTerminals,
90} from "./clientTerminals.ts";
91import { ACP_SESSION_MODE_OPTION_ID } from "./sessionConfig.ts";
92import * as AcpSessionRuntime from "./AcpSessionRuntime.ts";
93import {
94 t3AcpPromptWithInstructions,
95 type T3AcpInstructionState,
96} from "@t3tools/provider-core/server/orchestrationInstructions";
97import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions";
98import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
99import { type ProviderContinuationRequest } from "@t3tools/provider-core/server/continuationRequests";
100import {
101 type BackgroundWork,
102 type BackgroundWorkReport,
103 backgroundWorkNotification,
104} from "@t3tools/provider-core/server/notification";
105import {
106 makeProviderFailure,
107 makeProviderRetryTurnItem,
108} from "@t3tools/provider-core/server/failure";
109import { acpSelectionTransition } from "@t3tools/provider-core/server/selectionTransition";
110import {
111 isProviderNativeImageAttachment,
112 providerMessageTextWithAttachmentPaths,
113} from "@t3tools/provider-core/server/attachmentPrompt";
114import {
115 makeSubagentChildThread,
116 makeSubagentConversationArtifacts,
117 subagentThreadTitle,
118} from "@t3tools/provider-core/server/subagentProjection";
119import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
120
121export 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 */
130const ACP_DEFERRED_FINALIZE_DEBOUNCE: Duration.Input = "3000 millis";
131
132export 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
156export type AcpAdapterV2NativeLogging = Pick<
157 AcpSessionRuntime.AcpSessionRuntimeOptions,
158 "requestLogger" | "protocolLogging"
159>;
160
161export interface AcpAdapterV2UserInputRequest {
162 readonly nativeItemId: string;
163 readonly nativeRequestId: string;
164 readonly questions: ReadonlyArray<OrchestrationV2UserInputQuestion>;
165}
166
167export 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
215export 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. */
435export 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. */
443export 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. */
448export 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
458export 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
480export 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
540export 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
639function 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
675interface 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
683function 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
720function acpMcpServers(
721 threadId: ThreadId | null,
722 self: SelfInvocation,
723): ReadonlyArray<EffectAcpSchema.McpServer> {
724 return acpMcpContext(threadId, self).servers;
725}
726
727function acpMcpActivation(threadId: ThreadId | null, self: SelfInvocation) {
728 const context = acpMcpContext(threadId, self);
729 return { mcpServers: context.servers, acpMcpServers: context.acpServers };
730}
731
732function 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
748function 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 */
797function 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
813function nonEmptyText(value: unknown, fallback: string): string {
814 return typeof value === "string" && value.trim().length > 0 ? value.trim() : fallback;
815}
816
817function 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
831function 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
883function 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 */
899export 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
909function 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.
939const 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 */
947export 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
977function 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 */
1003function 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
1037function 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
1054function 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
1071type ProjectedToolStatus = ReturnType<typeof toolStatus> | "interrupted";
1072
1073function nodeStatus(status: ProjectedToolStatus): OrchestrationV2ExecutionNode["status"] {
1074 return status === "pending" ? "running" : status;
1075}
1076
1077function completedAtForStatus(status: ProjectedToolStatus, now: DateTime.Utc): DateTime.Utc | null {
1078 return status === "completed" || status === "failed" || status === "interrupted" ? now : null;
1079}
1080
1081function 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 */
1099function selectAutoApprovedPermissionOption(
1100 request: EffectAcpSchema.RequestPermissionRequest,
1101): string | undefined {
1102 return (
1103 selectPermissionOptionId(request, "accept") ??
1104 selectPermissionOptionId(request, "acceptForSession")
1105 );
1106}
1107
1108function 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
1124interface ActiveTextSegment {
1125 readonly nativeItemId: string;
1126 readonly startedAt: DateTime.Utc;
1127 sourceMessageId: string | null;
1128 text: string;
1129}
1130
1131interface ActiveTextStream {
1132 current: ActiveTextSegment | null;
1133 nextSegment: number;
1134}
1135
1136interface AcpNativeBuildConfiguration {
1137 readonly modeId?: string;
1138 readonly configOptions: ReadonlyArray<{ readonly id: string; readonly value: string }>;
1139}
1140
1141interface 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
1220type 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. */
1229function 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 */
1263export 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 */
1291export 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
1316export 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 */
1334export 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. */
1342function acpIsProviderContinuationMessage(message: {
1343 readonly createdBy: string;
1344 readonly creationSource: string;
1345}): boolean {
1346 return message.createdBy === "agent" && message.creationSource === "provider";
1347}
1348
1349export function acpCarryoverTerminalShouldClearContinuation(input: {
1350 readonly continuationOffered: boolean;
1351 readonly wakeBufferLength: number;
1352}): boolean {
1353 return !input.continuationOffered && input.wakeBufferLength === 0;
1354}
1355
1356export 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
1364export 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
1375export 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
1385interface 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
1410function 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
1420export function acpSubagentStatusBlocksTurnSettlement(
1421 status: OrchestrationV2Subagent["status"],
1422): boolean {
1423 return status === "running" || status === "pending";
1424}
1425
1426function acpSubagentHasPendingBackgroundWork(subagent: ActiveAcpSubagent): boolean {
1427 return (
1428 acpSubagentStatusBlocksTurnSettlement(subagent.task.status) || !subagent.terminalStatusProjected
1429 );
1430}
1431
1432type AcpCarryoverSubagents = {
1433 readonly sessionId: string;
1434 readonly rootTerminalStatus: "completed" | "interrupted" | "failed" | "cancelled";
1435 readonly subagents: ReadonlyArray<ActiveAcpSubagent>;
1436};
1437
1438type 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
1457interface 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
1465const 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 */
1473function 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
1493export 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
8054export 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
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { assert, describe, it } from "@effect/vitest";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Path from "effect/Path";
7import type * as EffectAcpSchema from "effect-acp/compat";
8import * as NodePath from "node:path";
9
10import {
11 acpClientExecuteDisposition,
12 acpMcpToolApprovalElicitationDisposition,
13 acpPermissionDisposition,
14 makeAcpClientPolicyGrants,
15 type AcpRuntimePolicy,
16} from "./clientPolicy.ts";
17
18function 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
33describe("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
223describe("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
294describe("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
322describe("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
2import type { ProviderRequestKind, RuntimeMode } from "@t3tools/contracts";
3import type * as EffectAcpSchema from "effect-acp/compat";
4import * as NodeFS from "node:fs";
5import * 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. */
18export interface AcpRuntimePolicy {
19 readonly runtimeMode: RuntimeMode;
20 readonly cwd: string | null;
21 readonly approvalPolicy?: unknown;
22 readonly sandboxPolicy?: unknown;
23}
24
25export type AcpPermissionDisposition = "allow" | "ask" | "deny";
26
27export 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
33function 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
41function 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 */
60function 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. */
96interface AcpPolicyOperation {
97 readonly kind: string | null | undefined;
98 readonly locations: ReadonlyArray<{ readonly path: string }> | null | undefined;
99}
100
101function acpPolicyRequiresApproval(runtimePolicy: AcpRuntimePolicy): boolean {
102 return runtimePolicy.approvalPolicy === undefined
103 ? runtimePolicy.runtimeMode === "approval-required"
104 : runtimePolicy.approvalPolicy !== "never";
105}
106
107function 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 */
118function 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
131function 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 */
178function 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
225function isAcpMutationKind(toolKind: string): boolean {
226 return toolKind === "edit" || toolKind === "delete" || toolKind === "move";
227}
228
229export 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. */
240export 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). */
259export function acpClientExecuteDisposition(
260 runtimePolicy: AcpRuntimePolicy,
261): AcpPermissionDisposition {
262 return acpOperationDisposition(runtimePolicy, { kind: "execute", locations: undefined });
263}
264
265export interface AcpApprovalGrantInput {
266 readonly kind: ProviderRequestKind;
267 readonly scope: "session" | "turn";
268 readonly turnKey: string;
269}
270
271export 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 */
283export 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 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { describe, expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Result from "effect/Result";
5import { ChildProcessSpawner } from "effect/process";
6
7import {
8 makeAcpClientTerminals,
9 resolveEmbeddedTerminalContent,
10 type AcpClientTerminals,
11} from "./clientTerminals.ts";
12
13const 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
23describe("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
283describe("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 @@
1import * as Deferred from "effect/Deferred";
2import * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Exit from "effect/Exit";
5import * as Fiber from "effect/Fiber";
6import * as Scope from "effect/Scope";
7import * as Semaphore from "effect/Semaphore";
8import * as Stream from "effect/Stream";
9import { ChildProcess, ChildProcessSpawner } from "effect/process";
10import * as EffectAcpErrors from "effect-acp/errors";
11import type * as EffectAcpSchema from "effect-acp/compat";
12import * 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
24const MAX_LIVE_TERMINALS = 16;
25const MAX_UNRELEASED_TERMINALS = 64;
26const MAX_RETAINED_TERMINALS = 32;
27const MAX_RETAINED_OUTPUT_BYTES = 16 * 1024 * 1024;
28const DEFAULT_OUTPUT_BYTE_LIMIT = 4 * 1024 * 1024;
29const MAX_OUTPUT_BYTE_LIMIT = 8 * 1024 * 1024;
30
31const terminalRequestError = (message: string, cause?: unknown) =>
32 EffectAcpErrors.AcpRequestError.internalError(
33 message,
34 undefined,
35 cause === undefined ? {} : { cause },
36 );
37
38interface OutputBufferState {
39 chunks: Array<Uint8Array>;
40 bytes: number;
41 truncated: boolean;
42 readonly limit: number;
43}
44
45function 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
62function 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
69function 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
83function 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
90export interface AcpTerminalOutputSnapshot {
91 readonly output: string;
92 readonly truncated: boolean;
93 readonly exitStatus: EffectAcpSchema.TerminalExitStatus | undefined;
94}
95
96interface 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
110export 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
137export 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
149function 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
164export 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 */
435export 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 @@
1import { ProviderDriverKind, RuntimeRequestId, TurnId } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3
4import {
5 makeAcpAssistantItemEvent,
6 makeAcpContentDeltaEvent,
7 makeAcpPlanUpdatedEvent,
8 makeAcpRequestOpenedEvent,
9 makeAcpRequestResolvedEvent,
10 makeAcpToolCallEvent,
11} from "./coreRuntimeEvents.ts";
12
13describe("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 @@
1import {
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
15import {
16 type AcpPermissionRequest,
17 type AcpPlanUpdate,
18 type AcpToolCallState,
19 canonicalItemTypeFromAcpToolKind,
20} from "./runtimeModel.ts";
21
22type AcpAdapterRawSource = Extract<
23 RuntimeEventRawSource,
24 "acp.jsonrpc" | `acp.${string}.extension`
25>;
26
27interface AcpEventStamp {
28 readonly eventId: EventId;
29 readonly createdAt: string;
30}
31
32type AcpCanonicalRequestType = Extract<
33 CanonicalRequestType,
34 "exec_command_approval" | "file_read_approval" | "file_change_approval" | "dynamic_tool_call"
35>;
36
37function 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
52function 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
68export 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
103export 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
126export 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
172export 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
206export 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
228export 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.
2import { describe, expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Fiber from "effect/Fiber";
5
6import { makeAcpMcpOverAcpBridge } from "./mcpOverAcpBridge.ts";
7
8describe("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
3import * as Effect from "effect/Effect";
4import * as Semaphore from "effect/Semaphore";
5import * as Stream from "effect/Stream";
6import type * as AcpSchema from "effect-acp/compat";
7
8import { responsePayloads } from "@t3tools/shared/mcpResponsePayloads";
9
10const MAX_CONNECTIONS = 16;
11const MAX_MESSAGE_BYTES = 8 * 1024 * 1024;
12
13interface JsonRpcEnvelope {
14 readonly id?: unknown;
15 readonly method?: unknown;
16 readonly error?: unknown;
17 readonly result?: unknown;
18}
19
20interface Connection {
21 readonly mutex: Semaphore.Semaphore;
22 sessionId: string | null;
23 protocolVersion: string | null;
24 nextRequestId: number;
25}
26
27export class AcpMcpOverAcpError extends Error {
28 readonly _tag = "AcpMcpOverAcpError";
29 override readonly name = "AcpMcpOverAcpError";
30}
31
32const bridgeError = (cause: unknown): AcpMcpOverAcpError =>
33 cause instanceof AcpMcpOverAcpError
34 ? cause
35 : new AcpMcpOverAcpError(cause instanceof Error ? cause.message : String(cause), { cause });
36
37const asEnvelope = (value: unknown): JsonRpcEnvelope | null =>
38 typeof value === "object" && value !== null && !Array.isArray(value)
39 ? (value as JsonRpcEnvelope)
40 : null;
41
42function 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
49function 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
59export 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
66export 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. */
83export 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 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { ProviderDriverKind, ThreadId } from "@t3tools/contracts";
3import { assert, it } from "@effect/vitest";
4import * as Cause from "effect/Cause";
5import * as Effect from "effect/Effect";
6import * as Exit from "effect/Exit";
7import * as Logger from "effect/Logger";
8import * as Schema from "effect/Schema";
9import * as AcpErrors from "effect-acp/errors";
10
11import type * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers";
12import { makeAcpNativeLoggerFactory } from "./nativeLogging.ts";
13
14const nodeServicesIt = it.layer(NodeServices.layer);
15const encodeUnknownJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
16
17nodeServicesIt("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 @@
1import type { ProviderDriverKind, ThreadId } from "@t3tools/contracts";
2import { causeErrorTag, errorTag } from "@t3tools/shared/observability";
3import * as Cause from "effect/Cause";
4import * as Crypto from "effect/Crypto";
5import * as DateTime from "effect/DateTime";
6import * as Effect from "effect/Effect";
7import type * as EffectAcpProtocol from "effect-acp/protocol";
8
9import type * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers";
10import {
11 structuralProtocolMethod,
12 summarizeNativeProtocolPayload,
13} from "@t3tools/provider-core/server/nativeProtocolLogging";
14import type * as AcpSessionRuntime from "./AcpSessionRuntime.ts";
15
16const transientProtocolUpdates = new Set(["agent_message_chunk", "agent_thought_chunk"]);
17
18function structuralMethod(value: string): string {
19 return value.length <= 128 && /^[A-Za-z][A-Za-z0-9._:/-]*$/.test(value) ? value : "unknown";
20}
21
22function 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
50function 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
65function formatAcpProtocolLogPayload(event: EffectAcpProtocol.AcpProtocolLogEvent) {
66 return {
67 direction: event.direction,
68 stage: event.stage,
69 payload: summarizeNativeProtocolPayload(event.payload),
70 };
71}
72
73function 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
86function 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
103function 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
121export 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 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import type * as EffectAcpSchema from "effect-acp/compat";
4
5import {
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
24describe("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
1394describe("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
1696describe("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 @@
1import * as Clock from "effect/Clock";
2import * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Option from "effect/Option";
5import * as Ref from "effect/Ref";
6import * as Schema from "effect/Schema";
7import * as EffectAcpSchema from "effect-acp/compat";
8import * as EffectAcpSchemaV1 from "effect-acp/schema-v1";
9import {
10 deriveToolActivityPresentation,
11 mergeToolActivityData,
12} from "@t3tools/shared/toolActivity";
13import { T3_MCP_TOOL_NAMES } from "@t3tools/shared/t3McpToolPresentation";
14import type {
15 OrchestrationV2ProviderThreadNativeMetadata,
16 ThreadTokenUsageSnapshot,
17 ToolLifecycleItemType,
18} from "@t3tools/contracts";
19
20function 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.
26const isSessionModelState = Schema.is(EffectAcpSchema.SessionModelState);
27const isSessionModeState = Schema.is(EffectAcpSchemaV1.SessionModeState);
28
29export interface AcpSessionMode {
30 readonly id: string;
31 readonly name: string;
32 readonly description?: string;
33}
34
35export interface AcpSessionModeState {
36 readonly currentModeId: string;
37 readonly availableModes: ReadonlyArray<AcpSessionMode>;
38}
39
40export 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
50export 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
59type AcpAgentTerminalUpdate = Extract<
60 EffectAcpSchema.SessionUpdate,
61 { readonly sessionUpdate: "terminal_update" | "terminal_output_chunk" }
62>;
63
64const MAX_ACP_TERMINAL_OUTPUT_BYTES = 16 * 1024 * 1024;
65const MAX_ACP_TERMINAL_FRAME_BYTES = MAX_ACP_TERMINAL_OUTPUT_BYTES;
66
67function 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 */
82function 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
103function 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
122function 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
136function 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. */
145export 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
209export 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
224export interface AcpPermissionRequest {
225 readonly kind: string | "unknown";
226 readonly detail?: string;
227 readonly toolCall?: AcpToolCallState;
228}
229
230export 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
291const boundedContentMetadata = (value: string | null | undefined, maximumLength: number): string =>
292 value?.trim().slice(0, maximumLength) ?? "";
293
294const MAX_ACP_CONTENT_TEXT_LENGTH = 65_536;
295
296// Preserve whitespace in streamed model output while keeping one provider frame bounded.
297const boundedContentText = (value: string): string => value.slice(0, MAX_ACP_CONTENT_TEXT_LENGTH);
298
299const 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. */
305export 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
342function 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
381type AcpSessionSetupResponse =
382 | EffectAcpSchema.ForkSessionResponse
383 | EffectAcpSchema.LoadSessionResponse
384 | EffectAcpSchema.NewSessionResponse
385 | EffectAcpSchema.ResumeSessionResponse;
386
387type AcpToolCallUpdate = Extract<
388 EffectAcpSchema.SessionNotification["update"],
389 { readonly sessionUpdate: "tool_call" | "tool_call_update" }
390>;
391
392export 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
403export 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
417export 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
428export 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
476function 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
488function 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
507function 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
526function 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
534function 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
561function 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
569function 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.
630const TOOL_CALL_CONTENT_MAX_CHARS = 8_000;
631const TOOL_CALL_CONTENT_TRUNCATION_MARKER = "[Earlier output truncated]\n\n";
632
633function 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
641const 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.
647function 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
663interface ExtractedToolCallContent {
664 readonly text: string | undefined;
665 readonly content: ReadonlyArray<EffectAcpSchema.ToolCallContent> | undefined;
666}
667
668function 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`.
679function 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
701function 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.
733function 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
785function 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 */
793export 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
811function 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
902function 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
924export 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.
952const TOOL_CALL_UPDATE_MIN_DETAIL_GROWTH_CHARS = 256;
953const TOOL_CALL_UPDATE_COALESCE_LIMIT = 10;
954
955export interface AcpToolCallEmitDecisionInput {
956 readonly previous: AcpToolCallState | undefined;
957 readonly next: AcpToolCallState;
958 readonly lastEmittedDetailLength: number | undefined;
959 readonly skippedSinceEmit: number;
960}
961
962export interface AcpToolCallEmitDecision {
963 readonly emit: boolean;
964 readonly skippedSinceEmit: number;
965}
966
967function 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.
976export 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
1003function 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.
1015export 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
1026export 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
1049export 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. */
1056const ACP_MCP_FALLBACK_CALL = /(?:^|[\s"'=])acp-mcp-call[\s"']+([A-Za-z0-9_.-]+)(?:\s+(.+?))?\s*$/u;
1057
1058function 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 */
1087const 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 */
1095const 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 */
1103const 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 */
1115export 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 */
1217export 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
1230export 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
1259export 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. */
1265export 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
1301export 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
1311export 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 */
1337export 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
1345export 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.
1366function 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
1388export 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 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import { ACP_SESSION_MODE_OPTION_ID, acpProviderOptionDescriptors } from "./sessionConfig.ts";
4
5describe("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 @@
1import type { ProviderOptionChoice, ProviderOptionDescriptor } from "@t3tools/contracts";
2import type * as EffectAcpSchema from "effect-acp/compat";
3
4import 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. */
18export const ACP_SESSION_MODE_OPTION_ID = "_t3/session-mode";
19
20const MAX_OPTION_DESCRIPTORS = 16;
21const MAX_OPTION_CHOICES = 64;
22const MAX_TEXT_LENGTH = 256;
23const MAX_DESCRIPTION_LENGTH = 1_024;
24
25const boundedText = (value: string | null | undefined, maximumLength: number): string =>
26 (value ?? "").trim().slice(0, maximumLength);
27
28const boundedOpaqueValue = (value: string, maximumLength: number): string | undefined =>
29 value.length > 0 && value === value.trim() && value.length <= maximumLength ? value : undefined;
30
31function flattenSelectChoices(
32 options: EffectAcpSchema.SessionConfigSelectOptions,
33): ReadonlyArray<EffectAcpSchema.SessionConfigSelectOption> {
34 return options.flatMap((candidate) => ("value" in candidate ? [candidate] : candidate.options));
35}
36
37function 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
61export 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 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 ACP_STDERR_TAIL_MAX_CHARS,
5 appendAcpStderrTail,
6 sanitizeAcpStderrExcerpt,
7} from "./stderr.ts";
8
9describe("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.
2import * as NodeOS from "node:os";
3
4/** Last few KiB of ACP child stderr kept for startup / exit diagnostics. */
5export const ACP_STDERR_TAIL_MAX_CHARS = 4_096;
6
7const PAIRING_URL_PATTERN = /https?:\/\/[^\s]*\/pair#[^\s]*/gi;
8const BEARER_TOKEN_PATTERN = /\bBearer\s+[A-Za-z0-9._\-+=/]+/gi;
9const BASIC_AUTH_PATTERN = /\bAuthorization:\s*Basic\s+\S+/gi;
10const API_KEY_HEADER_PATTERN = /\bx-api-key:\s*\S+/gi;
11const 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
14export 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. */
20export 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 {
3232 readonly stateDir: string;
3333 /** Scratch space for provider probes and generated helper files. */
3434 readonly providerStatusCacheDir: string;
35 /** Where chat attachments are stored; agents that sandbox file reads must be granted it. */
36 readonly attachmentsDir: string;
3537}
3638
3739/** 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: {
109109 baseDir: "/t3",
110110 stateDir: "/t3/userdata",
111111 providerStatusCacheDir: "/t3/caches",
112 attachmentsDir: "/t3/userdata/attachments",
112113 },
113114 settings: input.settings ?? {
114115 get: Effect.succeed(DEFAULT_SERVER_SETTINGS),
packages/provider-testing/src/host.ts+7-1
......@@ -42,7 +42,13 @@ export const layerTestProviderHost = (
4242 const settings = options.settings ?? DEFAULT_SERVER_SETTINGS;
4343 const credentials = new Map<string, Uint8Array>();
4444 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 },
4652 settings: {
4753 get: Effect.succeed(settings),
4854 changes: Stream.empty,
packages/shared/package.json+4
......@@ -466,6 +466,10 @@
466466 "./atomicWrite": {
467467 "types": "./src/atomicWrite.ts",
468468 "import": "./src/atomicWrite.ts"
469 },
470 "./mcpResponsePayloads": {
471 "types": "./src/mcpResponsePayloads.ts",
472 "import": "./src/mcpResponsePayloads.ts"
469473 }
470474 },
471475 "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 */
9import * as Effect from "effect/Effect";
10import * as Stream from "effect/Stream";
11
12async 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
33export 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 */
41export 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:
600600 '@opencode-ai/sdk':
601601 specifier: ^1.3.15
602602 version: 1.15.13
603 '@t3tools/provider-acp':
604 specifier: workspace:*
605 version: link:../../packages/provider-acp
603606 '@t3tools/provider-core':
604607 specifier: workspace:*
605608 version: link:../../packages/provider-core
......@@ -1092,6 +1095,40 @@ importers:
10921095 specifier: 1.0.0
10931096 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)
10941097
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
10951132 packages/provider-core:
10961133 dependencies:
10971134 '@t3tools/contracts':
scripts/release-smoke.ts+1
......@@ -31,6 +31,7 @@ const workspaceFiles = [
3131 "packages/provider-core/package.json",
3232 "packages/provider-testing/package.json",
3333 "packages/provider-pi/package.json",
34 "packages/provider-acp/package.json",
3435 "packages/provider-cursor/package.json",
3536 "packages/provider-muse/package.json",
3637 "packages/provider-opencode/package.json",