History

Add ACP replay harness and session lifecycle support

- Add replay agent and fixture-driven ACP transcript testing - Support session lifecycle, auth methods, and session resume/fork flows - Expand orchestration V2 adapter coverage and debug tooling

Julius Marminge committed 4 months ago

Commit 11615291 parent 9d38de865 files · +9914 −74Browse files

Unified · Split

65 files
  1. apps/server/scripts/acp-mock-agent.ts +80 −7
  2. apps/server/scripts/acp-replay-agent.ts +285 −0
  3. apps/server/src/orchestration-v2/AcpRegistryOrchestratorV2.live.test.ts +170 −0
  4. apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +358 −0
  5. apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.testkit.ts +187 −0
  6. apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts +2203 −0
  7. apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts +157 −0
  8. apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.testkit.ts +89 −0
  9. apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.ts +158 −0
  10. apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts +111 −0
  11. apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.testkit.ts +76 −0
  12. apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts +210 −0
  13. apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts +184 −0
  14. apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.testkit.ts +385 −0
  15. apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts +2743 −0
  16. apps/server/src/orchestration-v2/CommandPolicy.test.ts +34 −0
  17. apps/server/src/orchestration-v2/GrokOrchestratorV2.live.test.ts +158 −0
  18. apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts +21 −2
  19. apps/server/src/orchestration-v2/runtimeLayer.ts +4 −0
  20. apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.contract.test.ts +9 −1
  21. apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.integration.test.ts +18 −0
  22. apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts +1 −1
  23. apps/server/src/orchestration-v2/testkit/fixtures/acp_elicitation/grok_transcript.ndjson +11 −0
  24. apps/server/src/orchestration-v2/testkit/fixtures/index.ts +175 −0
  25. apps/server/src/orchestration-v2/testkit/fixtures/message_steering/grok_output.ts +54 −0
  26. apps/server/src/orchestration-v2/testkit/fixtures/message_steering/grok_transcript.ndjson +14 −0
  27. apps/server/src/orchestration-v2/testkit/fixtures/multi_turn/grok_transcript.ndjson +12 −0
  28. apps/server/src/orchestration-v2/testkit/fixtures/opencode_subagent/input.ts +7 −0
  29. apps/server/src/orchestration-v2/testkit/fixtures/opencode_subagent/opencode_transcript.ndjson +30 −0
  30. apps/server/src/orchestration-v2/testkit/fixtures/opencode_subagent/output.ts +71 −0
  31. apps/server/src/orchestration-v2/testkit/fixtures/plan_questions/codex_output.ts +9 −1
  32. apps/server/src/orchestration-v2/testkit/fixtures/plan_questions/grok_transcript.ndjson +11 −0
  33. apps/server/src/orchestration-v2/testkit/fixtures/plan_questions/input.ts +1 −0
  34. apps/server/src/orchestration-v2/testkit/fixtures/plan_questions/opencode_output.ts +26 −0
  35. apps/server/src/orchestration-v2/testkit/fixtures/plan_questions/opencode_transcript.ndjson +23 −0
  36. apps/server/src/orchestration-v2/testkit/fixtures/queued_turn/grok_transcript.ndjson +12 −0
  37. apps/server/src/orchestration-v2/testkit/fixtures/shared.ts +18 −0
  38. apps/server/src/orchestration-v2/testkit/fixtures/simple/grok_transcript.ndjson +12 −0
  39. apps/server/src/orchestration-v2/testkit/fixtures/simple/opencode_transcript.ndjson +19 −0
  40. apps/server/src/orchestration-v2/testkit/fixtures/todo_list/grok_output.ts +49 −0
  41. apps/server/src/orchestration-v2/testkit/fixtures/todo_list/grok_transcript.ndjson +16 −0
  42. apps/server/src/orchestration-v2/testkit/fixtures/tool_call_read_only/grok_transcript.ndjson +15 −0
  43. apps/server/src/orchestration-v2/testkit/fixtures/tool_call_read_only_on_request/grok_transcript.ndjson +13 −0
  44. apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt/grok_transcript.ndjson +9 −0
  45. apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt/opencode_transcript.ndjson +14 −0
  46. apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts +149 −1
  47. apps/server/src/provider/acp/AcpRegistrySupport.test.ts +263 −0
  48. apps/server/src/provider/acp/AcpRegistrySupport.ts +633 −0
  49. apps/server/src/provider/acp/AcpRuntimeModel.ts +1 −0
  50. apps/server/src/provider/acp/AcpSessionRuntime.ts +216 −41
  51. apps/server/src/provider/acp/GrokAcpCliProbe.test.ts +19 −0
  52. apps/server/src/provider/acp/GrokAcpSupport.ts +1 −4
  53. apps/server/src/provider/acp/XAiAcpExtension.ts +11 −0
  54. apps/web/src/components/settings/AddProviderInstanceDialog.tsx +1 −6
  55. apps/web/src/components/settings/ProviderSettingsForm.test.ts +12 −0
  56. apps/web/src/components/settings/SettingsPanels.browser.tsx +29 −0
  57. apps/web/src/components/settings/SettingsPanels.tsx +10 −3
  58. apps/web/src/components/settings/providerDriverMeta.ts +20 −1
  59. apps/web/src/orchestrationV2DebugProviders.test.ts +82 −0
  60. apps/web/src/orchestrationV2DebugProviders.ts +88 −0
  61. apps/web/src/routes/debug.orchestration-v2.tsx +9 −1
  62. docs/orchestration-v2/orchestrator-mcp-server.md +35 −4
  63. docs/orchestration-v2/testing-strategy.md +20 −1
  64. packages/contracts/src/model.ts +3 −0
  65. packages/contracts/src/settings.ts +50 −0
apps/server/scripts/acp-mock-agent.ts+80−7
@@ -23,6 +23,9 @@ const failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1"; · 22 unmodified lines
2323 const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1";
2424 const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT;
2525 const promptDelayMs = Number(process.env.T3_ACP_PROMPT_DELAY_MS ?? "0");
26+const supportsSessionLifecycle = process.env.T3_ACP_SESSION_LIFECYCLE === "1";
27+const advertisedAuthMethodId = process.env.T3_ACP_AUTH_METHOD_ID?.trim();
28+const requiresAuthentication = process.env.T3_ACP_REQUIRE_AUTH === "1";
2629 const permissionOptionIds = {
2730 allowOnce: process.env.T3_ACP_ALLOW_ONCE_OPTION_ID ?? "allow-once",
2831 allowAlways: process.env.T3_ACP_ALLOW_ALWAYS_OPTION_ID ?? "allow-always",
@@ -36,6 +39,7 @@ let parameterizedModelPicker = false; · 7 unmodified lines
3639 let currentReasoning = "medium";
3740 let currentContext = "272k";
3841 let currentFast = false;
42+let authenticated = !requiresAuthentication;
3943 const cancelledSessions = new Set<string>();
4044
4145 function logExit(reason: string): void {
@@ -268,19 +272,56 @@ const program = Effect.gen(function* () { · 226 unmodified lines
268272 request.clientCapabilities?._meta?.parameterizedModelPicker === true;
269273 return {
270274 protocolVersion: 1,
271− agentCapabilities: { loadSession: true },
275+ agentCapabilities: {
276+ loadSession: true,
277+ ...(supportsSessionLifecycle
278+ ? {
279+ sessionCapabilities: {
280+ list: {},
281+ fork: {},
282+ resume: {},
283+ close: {},
284+ },
285+ }
286+ : {}),
287+ },
288+ ...(advertisedAuthMethodId
289+ ? {
290+ authMethods: [
291+ {
292+ id: advertisedAuthMethodId,
293+ name: "Mock agent authentication",
294+ },
295+ ],
296+ }
297+ : {}),
272298 };
273299 }),
274300 );
275301
276− yield* agent.handleAuthenticate(() => Effect.succeed({}));
302+ yield* agent.handleAuthenticate((request) =>
303+ Effect.gen(function* () {
304+ if (advertisedAuthMethodId && request.methodId !== advertisedAuthMethodId) {
305+ return yield* AcpError.AcpRequestError.invalidParams(
306+ `Unknown mock authentication method: ${request.methodId}`,
307+ );
308+ }
309+ authenticated = true;
310+ return {};
311+ }),
312+ );
277313
278314 yield* agent.handleCreateSession(() =>
279− Effect.succeed({
280− sessionId,
281− modes: modeState(),
282− models: modelState(),
283− configOptions: configOptions(),
315+ Effect.gen(function* () {
316+ if (!authenticated) {
317+ return yield* AcpError.AcpRequestError.authRequired();
318+ }
319+ return {
320+ sessionId,
321+ modes: modeState(),
322+ models: modelState(),
323+ configOptions: configOptions(),
324+ };
284325 }),
285326 );
286327
@@ -302,6 +343,38 @@ const program = Effect.gen(function* () { · 15 unmodified lines
302343 ),
303344 );
304345
346+ yield* agent.handleListSessions((request) =>
347+ Effect.succeed({
348+ sessions: [
349+ {
350+ sessionId,
351+ cwd: request.cwd ?? process.cwd(),
352+ title: "Mock session",
353+ updatedAt: "1970-01-01T00:00:00.000Z",
354+ },
355+ ],
356+ }),
357+ );
358+
359+ yield* agent.handleForkSession((request) =>
360+ Effect.succeed({
361+ sessionId: `${request.sessionId}-fork`,
362+ modes: modeState(),
363+ models: modelState(),
364+ configOptions: configOptions(),
365+ }),
366+ );
367+
368+ yield* agent.handleResumeSession(() =>
369+ Effect.succeed({
370+ modes: modeState(),
371+ models: modelState(),
372+ configOptions: configOptions(),
373+ }),
374+ );
375+
376+ yield* agent.handleCloseSession(() => Effect.succeed({}));
377+
305378 yield* agent.handleSetSessionModel((request) =>
306379 Effect.gen(function* () {
307380 if (!grokAcpModels.some((model) => model.modelId === request.modelId)) {
apps/server/scripts/acp-replay-agent.ts+285−0
@@ -0,0 +1,285 @@
1+#!/usr/bin/env node
2+// @effect-diagnostics nodeBuiltinImport:off
3+import { writeFileSync } from "node:fs";
4+import { createInterface } from "node:readline";
5+
6+interface ReplayEntry {
7+ readonly type: "emit_inbound" | "expect_outbound" | "runtime_exit";
8+ readonly label?: string;
9+ readonly frame?: unknown;
10+ readonly status?: "success" | "error" | "cancelled";
11+ readonly error?: unknown;
12+}
13+
14+interface ReplayTranscript {
15+ readonly scenario: string;
16+ readonly entries: ReadonlyArray<ReplayEntry>;
17+}
18+
19+interface LogicalFrame {
20+ readonly kind: "notification" | "request" | "response";
21+ readonly method: string;
22+ readonly params?: unknown;
23+ readonly result?: unknown;
24+ readonly error?: unknown;
25+}
26+
27+interface JsonRpcMessage {
28+ readonly jsonrpc?: string;
29+ readonly id?: string | number | null;
30+ readonly method?: string;
31+ readonly params?: unknown;
32+ readonly result?: unknown;
33+ readonly error?: unknown;
34+ readonly headers?: ReadonlyArray<unknown>;
35+}
36+
37+const encodedTranscript = process.env.T3_ACP_REPLAY_TRANSCRIPT;
38+const statusPath = process.env.T3_ACP_REPLAY_STATUS_PATH;
39+const replayWorkspace = process.env.T3_ACP_REPLAY_WORKSPACE ?? process.cwd();
40+
41+if (encodedTranscript === undefined || statusPath === undefined) {
42+ process.stderr.write("ACP replay requires transcript and status environment variables.\n");
43+ process.exit(2);
44+}
45+
46+const replayStatusPath = statusPath;
47+const transcript = JSON.parse(
48+ Buffer.from(encodedTranscript, "base64").toString("utf8"),
49+) as ReplayTranscript;
50+let cursor = 0;
51+let stopped = false;
52+let nextAgentRequestId = 1;
53+const pendingClientRequestIds = new Map<string, string | number>();
54+const pendingAgentRequestMethods = new Map<string, string>();
55+
56+function writeStatus(failure?: unknown): void {
57+ writeFileSync(
58+ replayStatusPath,
59+ JSON.stringify({
60+ scenario: transcript.scenario,
61+ cursor,
62+ total: transcript.entries.length,
63+ ...(failure === undefined ? {} : { failure }),
64+ }),
65+ "utf8",
66+ );
67+}
68+
69+function stableStringify(value: unknown): string {
70+ if (Array.isArray(value)) {
71+ return `[${value.map(stableStringify).join(",")}]`;
72+ }
73+ if (typeof value === "object" && value !== null) {
74+ const record = value as Record<string, unknown>;
75+ return `{${Object.keys(record)
76+ .toSorted()
77+ .map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`)
78+ .join(",")}}`;
79+ }
80+ return JSON.stringify(value);
81+}
82+
83+function expandExpectedString(value: string): string {
84+ return value.replaceAll("<workspace>", replayWorkspace);
85+}
86+
87+function matchesExpected(expected: unknown, actual: unknown): boolean {
88+ if (expected === "<any>") return true;
89+ if (typeof expected === "string" && typeof actual === "string") {
90+ const expanded = expandExpectedString(expected);
91+ if (!expanded.includes("<any>")) return expanded === actual;
92+ const parts = expanded.split("<any>");
93+ let offset = 0;
94+ for (const part of parts) {
95+ const index = actual.indexOf(part, offset);
96+ if (index === -1) return false;
97+ offset = index + part.length;
98+ }
99+ return true;
100+ }
101+ if (Array.isArray(expected)) {
102+ return (
103+ Array.isArray(actual) &&
104+ expected.length === actual.length &&
105+ expected.every((entry, index) => matchesExpected(entry, actual[index]))
106+ );
107+ }
108+ if (typeof expected === "object" && expected !== null) {
109+ if (typeof actual !== "object" || actual === null || Array.isArray(actual)) return false;
110+ const expectedRecord = expected as Record<string, unknown>;
111+ const actualRecord = actual as Record<string, unknown>;
112+ const expectedKeys = Object.keys(expectedRecord).toSorted();
113+ const actualKeys = Object.keys(actualRecord).toSorted();
114+ return (
115+ stableStringify(expectedKeys) === stableStringify(actualKeys) &&
116+ expectedKeys.every((key) => matchesExpected(expectedRecord[key], actualRecord[key]))
117+ );
118+ }
119+ return Object.is(expected, actual);
120+}
121+
122+function stopWithFailure(detail: string, actual?: unknown): void {
123+ if (stopped) return;
124+ stopped = true;
125+ const entry = transcript.entries[cursor];
126+ const failure = {
127+ detail,
128+ cursor,
129+ expected: entry,
130+ ...(actual === undefined ? {} : { actual }),
131+ };
132+ writeStatus(failure);
133+ process.stderr.write(`ACP replay mismatch: ${JSON.stringify(failure)}\n`);
134+ process.exitCode = 1;
135+ process.stdin.pause();
136+}
137+
138+function advance(): void {
139+ cursor += 1;
140+ writeStatus();
141+}
142+
143+function send(message: JsonRpcMessage): void {
144+ process.stdout.write(`${JSON.stringify(message)}\n`);
145+}
146+
147+function pendingClientRequestId(method: string): string | number | undefined {
148+ return pendingClientRequestIds.get(method);
149+}
150+
151+function logicalIncoming(message: JsonRpcMessage): LogicalFrame | undefined {
152+ if (typeof message.method === "string") {
153+ return {
154+ kind:
155+ message.id === undefined || message.id === null || message.id === ""
156+ ? "notification"
157+ : "request",
158+ method: message.method,
159+ ...(message.params === undefined ? {} : { params: message.params }),
160+ };
161+ }
162+ if (message.id === undefined || message.id === null) return undefined;
163+ const method = pendingAgentRequestMethods.get(String(message.id));
164+ if (method === undefined) return undefined;
165+ return {
166+ kind: "response",
167+ method,
168+ ...(message.result === undefined ? {} : { result: message.result }),
169+ ...(message.error === undefined ? {} : { error: message.error }),
170+ };
171+}
172+
173+function emitInbound(frame: LogicalFrame): void {
174+ switch (frame.kind) {
175+ case "notification":
176+ send({
177+ jsonrpc: "2.0",
178+ method: frame.method,
179+ ...(frame.params === undefined ? {} : { params: frame.params }),
180+ });
181+ return;
182+ case "request": {
183+ const id = nextAgentRequestId;
184+ nextAgentRequestId += 1;
185+ pendingAgentRequestMethods.set(String(id), frame.method);
186+ send({
187+ jsonrpc: "2.0",
188+ id,
189+ method: frame.method,
190+ ...(frame.params === undefined ? {} : { params: frame.params }),
191+ headers: [],
192+ });
193+ return;
194+ }
195+ case "response": {
196+ const id = pendingClientRequestId(frame.method);
197+ if (id === undefined) {
198+ stopWithFailure(`No pending client request for ${frame.method}`, frame);
199+ return;
200+ }
201+ pendingClientRequestIds.delete(frame.method);
202+ send({
203+ jsonrpc: "2.0",
204+ id,
205+ ...(frame.result === undefined ? {} : { result: frame.result }),
206+ ...(frame.error === undefined ? {} : { error: frame.error }),
207+ });
208+ }
209+ }
210+}
211+
212+function flushInbound(): void {
213+ while (!stopped) {
214+ const entry = transcript.entries[cursor];
215+ if (entry === undefined || entry.type === "expect_outbound") return;
216+ if (entry.type === "runtime_exit") {
217+ if (entry.status !== "success") {
218+ stopWithFailure(`Recorded runtime exit was ${entry.status ?? "unknown"}`, entry.error);
219+ return;
220+ }
221+ advance();
222+ continue;
223+ }
224+ const frame = entry.frame as LogicalFrame;
225+ if (
226+ typeof frame !== "object" ||
227+ frame === null ||
228+ !["notification", "request", "response"].includes(frame.kind) ||
229+ typeof frame.method !== "string"
230+ ) {
231+ stopWithFailure("Invalid emit_inbound logical ACP frame", entry.frame);
232+ return;
233+ }
234+ emitInbound(frame);
235+ if (stopped) return;
236+ advance();
237+ }
238+}
239+
240+function handleMessage(message: JsonRpcMessage): void {
241+ if (stopped) return;
242+ const actual = logicalIncoming(message);
243+ if (actual === undefined) {
244+ stopWithFailure("Could not identify outbound ACP frame", message);
245+ return;
246+ }
247+ const entry = transcript.entries[cursor];
248+ if (entry?.type !== "expect_outbound" || !matchesExpected(entry.frame, actual)) {
249+ if (actual.kind === "request" && message.id !== undefined && message.id !== null) {
250+ send({
251+ jsonrpc: "2.0",
252+ id: message.id,
253+ error: { code: -32603, message: "ACP replay frame mismatch" },
254+ });
255+ }
256+ stopWithFailure("Unexpected outbound ACP frame", actual);
257+ return;
258+ }
259+ if (actual.kind === "request" && message.id !== undefined && message.id !== null) {
260+ pendingClientRequestIds.set(actual.method, message.id);
261+ } else if (actual.kind === "response" && message.id !== undefined && message.id !== null) {
262+ pendingAgentRequestMethods.delete(String(message.id));
263+ }
264+ advance();
265+ flushInbound();
266+}
267+
268+writeStatus();
269+flushInbound();
270+
271+const input = createInterface({ input: process.stdin, crlfDelay: Infinity });
272+input.on("line", (line) => {
273+ if (stopped || line.trim().length === 0) return;
274+ try {
275+ handleMessage(JSON.parse(line) as JsonRpcMessage);
276+ } catch (cause) {
277+ stopWithFailure("Failed to decode outbound ACP JSON-RPC", String(cause));
278+ }
279+});
280+
281+input.on("close", () => {
282+ if (!stopped && cursor !== transcript.entries.length) {
283+ stopWithFailure("ACP replay input closed before transcript completion");
284+ }
285+});
apps/server/src/orchestration-v2/AcpRegistryOrchestratorV2.live.test.ts+170−0
@@ -0,0 +1,170 @@
1+import * as NodeServices from "@effect/platform-node/NodeServices";
2+import { assert, it } from "@effect/vitest";
3+import {
4+ CommandId,
5+ type ModelSelection,
6+ MessageId,
7+ ProjectId,
8+ ProviderDriverKind,
9+ ProviderInstanceId,
10+ ThreadId,
11+} from "@t3tools/contracts";
12+import * as Console from "effect/Console";
13+import * as Effect from "effect/Effect";
14+import * as Layer from "effect/Layer";
15+import { describe } from "vite-plus/test";
16+
17+import { CheckpointStoreLive } from "../checkpointing/Layers/CheckpointStore.ts";
18+import { ServerConfig } from "../config.ts";
19+import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";
20+import { ServerSettingsService } from "../serverSettings.ts";
21+import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
22+import * as VcsProcess from "../vcs/VcsProcess.ts";
23+import { OrchestratorV2 } from "./Orchestrator.ts";
24+import { OrchestrationV2LayerLive } from "./runtimeLayer.ts";
25+
26+const liveAgentId = process.env.T3_ACP_REGISTRY_LIVE_AGENT_ID?.trim() || "devin";
27+const liveCommandPath = process.env.T3_ACP_REGISTRY_LIVE_COMMAND?.trim();
28+const liveInstanceId = ProviderInstanceId.make("acpRegistry_live");
29+const liveModelSelection = {
30+ instanceId: liveInstanceId,
31+ model: "default",
32+} satisfies ModelSelection;
33+
34+const serverConfigLayer = ServerConfig.layerTest(process.cwd(), {
35+ prefix: "t3-acp-registry-v2-live-",
36+});
37+
38+const vcsDriverRegistryLayer = VcsDriverRegistry.layer.pipe(
39+ Layer.provide(VcsProcess.layer),
40+ Layer.provide(serverConfigLayer),
41+ Layer.provide(NodeServices.layer),
42+);
43+
44+const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(vcsDriverRegistryLayer));
45+
46+const liveLayer = OrchestrationV2LayerLive.pipe(
47+ Layer.provide(SqlitePersistenceMemory),
48+ Layer.provide(checkpointStoreLayer),
49+ Layer.provide(serverConfigLayer),
50+ Layer.provide(
51+ ServerSettingsService.layerTest({
52+ providerInstances: {
53+ [liveInstanceId]: {
54+ driver: ProviderDriverKind.make("acpRegistry"),
55+ displayName: `ACP Registry: ${liveAgentId}`,
56+ enabled: true,
57+ config: {
58+ agentId: liveAgentId,
59+ ...(liveCommandPath ? { commandPath: liveCommandPath } : {}),
60+ },
61+ },
62+ },
63+ }),
64+ ),
65+ Layer.provide(NodeServices.layer),
66+);
67+
68+const waitForIdle = Effect.fn("AcpRegistryOrchestratorV2Live.waitForIdle")(function* (
69+ threadId: ThreadId,
70+ expectedRunCount: number,
71+) {
72+ const orchestrator = yield* OrchestratorV2;
73+ for (let attempt = 0; attempt < 900; attempt += 1) {
74+ const projection = yield* orchestrator.getThreadProjection(threadId);
75+ if (
76+ projection.runs.length >= expectedRunCount &&
77+ projection.runs.every(
78+ (run) => !["queued", "starting", "running", "waiting"].includes(run.status),
79+ )
80+ ) {
81+ return projection;
82+ }
83+ yield* Effect.sleep("500 millis");
84+ }
85+ return yield* Effect.die(new Error(`Timed out waiting for ACP Registry thread ${threadId}.`));
86+});
87+
88+describe.runIf(process.env.T3_ACP_REGISTRY_LIVE_ORCHESTRATOR === "1")(
89+ "ACP Registry V2 live orchestrator",
90+ () => {
91+ it.live(
92+ "runs and resumes a real registry agent through the production V2 harness",
93+ () =>
94+ Effect.gen(function* () {
95+ const orchestrator = yield* OrchestratorV2;
96+ const projectId = ProjectId.make("project:acp-registry-live");
97+ const threadId = ThreadId.make("thread:acp-registry-live");
98+ const marker = "ACP_REGISTRY_LIVE_7H3Q";
99+
100+ yield* orchestrator.dispatch({
101+ type: "thread.create",
102+ commandId: CommandId.make("command:acp-registry-live:create"),
103+ threadId,
104+ projectId,
105+ title: `ACP Registry live: ${liveAgentId}`,
106+ modelSelection: liveModelSelection,
107+ runtimeMode: "full-access",
108+ interactionMode: "default",
109+ branch: null,
110+ worktreePath: null,
111+ });
112+ yield* Console.log(
113+ `ACP Registry thread created for '${liveAgentId}'; dispatching first prompt.`,
114+ );
115+ yield* orchestrator.dispatch({
116+ type: "message.dispatch",
117+ commandId: CommandId.make("command:acp-registry-live:first"),
118+ threadId,
119+ messageId: MessageId.make("message:acp-registry-live:first"),
120+ text: `Remember this opaque marker. Respond with exactly: ${marker}`,
121+ attachments: [],
122+ modelSelection: liveModelSelection,
123+ dispatchMode: { type: "start_immediately" },
124+ });
125+ const firstProjection = yield* waitForIdle(threadId, 1);
126+ const firstAssistant = firstProjection.messages.findLast(
127+ (message) => message.role === "assistant",
128+ )?.text;
129+
130+ assert.deepEqual(
131+ firstProjection.runs.map((run) => [run.provider, run.status]),
132+ [[liveInstanceId, "completed"]],
133+ );
134+ assert.include(firstAssistant ?? "", marker);
135+
136+ yield* Console.log("First ACP turn completed; dispatching continuation prompt.");
137+ yield* orchestrator.dispatch({
138+ type: "message.dispatch",
139+ commandId: CommandId.make("command:acp-registry-live:second"),
140+ threadId,
141+ messageId: MessageId.make("message:acp-registry-live:second"),
142+ text: "Return the opaque marker from the previous turn. Respond with only the marker.",
143+ attachments: [],
144+ modelSelection: liveModelSelection,
145+ dispatchMode: { type: "start_immediately" },
146+ });
147+ const finalProjection = yield* waitForIdle(threadId, 2);
148+ const finalAssistant = finalProjection.messages.findLast(
149+ (message) => message.role === "assistant",
150+ )?.text;
151+
152+ assert.deepEqual(
153+ finalProjection.runs.map((run) => [run.provider, run.status]),
154+ [
155+ [liveInstanceId, "completed"],
156+ [liveInstanceId, "completed"],
157+ ],
158+ );
159+ assert.include(finalAssistant ?? "", marker);
160+ assert.deepEqual(finalProjection.providerSessions.length, 2);
161+ assert.isAtLeast(finalProjection.providerThreads.length, 1);
162+ assert.deepEqual(
163+ finalProjection.providerTurns.map((turn) => turn.status),
164+ ["completed", "completed"],
165+ );
166+ }).pipe(Effect.provide(liveLayer), Effect.scoped),
167+ 480_000,
168+ );
169+ },
170+);
apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts+358−0
@@ -0,0 +1,358 @@
1+import * as NodeServices from "@effect/platform-node/NodeServices";
2+import { assert, describe, it } from "@effect/vitest";
3+import {
4+ MessageId,
5+ NodeId,
6+ ProjectId,
7+ ProviderInstanceId,
8+ ProviderSessionId,
9+ RunAttemptId,
10+ RunId,
11+ ThreadId,
12+ type OrchestrationV2ProviderThread,
13+} from "@t3tools/contracts";
14+import * as DateTime from "effect/DateTime";
15+import * as Effect from "effect/Effect";
16+import * as FileSystem from "effect/FileSystem";
17+import * as Fiber from "effect/Fiber";
18+import * as Layer from "effect/Layer";
19+import * as Option from "effect/Option";
20+import * as Path from "effect/Path";
21+import * as Stream from "effect/Stream";
22+import * as TestClock from "effect/testing/TestClock";
23+import { ChildProcessSpawner } from "effect/unstable/process";
24+
25+import { ServerConfig } from "../../config.ts";
26+import { AcpSessionRuntime } from "../../provider/acp/AcpSessionRuntime.ts";
27+import { layer as idAllocatorLayer, IdAllocatorV2 } from "../IdAllocator.ts";
28+import {
29+ ProviderAdapterV2RuntimePolicy,
30+ type ProviderAdapterV2TurnInput,
31+} from "../ProviderAdapter.ts";
32+import {
33+ AcpProviderCapabilitiesV2,
34+ makeAcpAdapterV2,
35+ type AcpAdapterV2Flavor,
36+} from "./AcpAdapterV2.ts";
37+
38+const serverConfigLayer = ServerConfig.layerTest(process.cwd(), {
39+ prefix: "t3-acp-v2-adapter-",
40+}).pipe(Layer.provide(NodeServices.layer));
41+
42+const testLayer = Layer.mergeAll(NodeServices.layer, idAllocatorLayer, serverConfigLayer);
43+
44+function makeMockRuntime(input: {
45+ readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"];
46+ readonly mockAgentPath: string;
47+ readonly environment?: Readonly<Record<string, string>>;
48+}): AcpAdapterV2Flavor["makeRuntime"] {
49+ return (runtimeInput) =>
50+ Effect.gen(function* () {
51+ const context = yield* Layer.build(
52+ AcpSessionRuntime.layer({
53+ ...runtimeInput,
54+ spawn: {
55+ command: process.execPath,
56+ args: [input.mockAgentPath],
57+ cwd: runtimeInput.cwd,
58+ env: { T3_ACP_SESSION_LIFECYCLE: "1", ...input.environment },
59+ },
60+ authMethodId: "test",
61+ }).pipe(
62+ Layer.provide(
63+ Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner),
64+ ),
65+ ),
66+ );
67+ return yield* Effect.service(AcpSessionRuntime).pipe(Effect.provide(context));
68+ });
69+}
70+
71+function makeTurnInput(input: {
72+ readonly threadId: ThreadId;
73+ readonly providerThread: OrchestrationV2ProviderThread;
74+ readonly instanceId: ProviderInstanceId;
75+ readonly runtimePolicy: ProviderAdapterV2RuntimePolicy;
76+ readonly now: DateTime.Utc;
77+ readonly ordinal?: number;
78+}): ProviderAdapterV2TurnInput {
79+ const ordinal = input.ordinal ?? 1;
80+ const suffix = `${input.threadId}:${ordinal}`;
81+ const modelSelection = { instanceId: input.instanceId, model: "default" } as const;
82+ return {
83+ appThread: {
84+ id: input.threadId,
85+ projectId: ProjectId.make(`project:${input.threadId}`),
86+ title: "ACP adapter test",
87+ defaultProvider: "acp-test",
88+ modelSelection,
89+ runtimeMode: "approval-required",
90+ interactionMode: "default",
91+ branch: null,
92+ worktreePath: null,
93+ activeProviderThreadId: input.providerThread.id,
94+ lineage: {
95+ parentThreadId: null,
96+ relationshipToParent: null,
97+ rootThreadId: input.threadId,
98+ },
99+ forkedFrom: null,
100+ createdAt: input.now,
101+ updatedAt: input.now,
102+ archivedAt: null,
103+ deletedAt: null,
104+ },
105+ threadId: input.threadId,
106+ runId: RunId.make(`run:${suffix}`),
107+ runOrdinal: ordinal,
108+ providerTurnOrdinal: ordinal,
109+ attemptId: RunAttemptId.make(`attempt:${suffix}`),
110+ rootNodeId: NodeId.make(`node:${suffix}`),
111+ providerThread: input.providerThread,
112+ message: {
113+ messageId: MessageId.make(`message:${suffix}`),
114+ text: "test prompt",
115+ attachments: [],
116+ },
117+ modelSelection,
118+ runtimePolicy: input.runtimePolicy,
119+ };
120+}
121+
122+describe("AcpAdapterV2", () => {
123+ it.effect("negotiates and executes optional native session forks through the ACP runtime", () =>
124+ Effect.gen(function* () {
125+ const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
126+ const fileSystem = yield* FileSystem.FileSystem;
127+ const idAllocator = yield* IdAllocatorV2;
128+ const path = yield* Path.Path;
129+ const serverConfig = yield* ServerConfig;
130+ const mockAgentPath = yield* path.fromFileUrl(
131+ new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
132+ );
133+ const makeRuntime = makeMockRuntime({ childProcessSpawner, mockAgentPath });
134+
135+ const instanceId = ProviderInstanceId.make("acp-test");
136+ const adapter = makeAcpAdapterV2({
137+ instanceId,
138+ flavor: {
139+ provider: "acp-test",
140+ capabilities: AcpProviderCapabilitiesV2,
141+ makeRuntime,
142+ },
143+ fileSystem,
144+ idAllocator,
145+ serverConfig,
146+ });
147+ const sourceThreadId = ThreadId.make("thread-acp-native-fork-source");
148+ const targetThreadId = ThreadId.make("thread-acp-native-fork-target");
149+ const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({
150+ runtimeMode: "full-access",
151+ interactionMode: "default",
152+ cwd: process.cwd(),
153+ });
154+ const modelSelection = { instanceId, model: "default" } as const;
155+ const runtime = yield* adapter.openSession({
156+ threadId: sourceThreadId,
157+ providerSessionId: ProviderSessionId.make("provider-session-acp-native-fork"),
158+ modelSelection,
159+ runtimePolicy,
160+ });
161+
162+ assert.isTrue(runtime.providerSession.capabilities.threads.canForkThread);
163+ assert.isTrue(runtime.providerSession.capabilities.threads.canReadThreadSnapshot);
164+
165+ const sourceProviderThread = yield* runtime.ensureThread({
166+ threadId: sourceThreadId,
167+ modelSelection,
168+ runtimePolicy,
169+ });
170+ const forkedProviderThread = yield* runtime.forkThread({
171+ sourceProviderThread,
172+ targetThreadId,
173+ });
174+
175+ assert.equal(sourceProviderThread.nativeThreadRef?.nativeId, "mock-session-1");
176+ assert.equal(forkedProviderThread.nativeThreadRef?.nativeId, "mock-session-1-fork");
177+ assert.equal(forkedProviderThread.appThreadId, targetThreadId);
178+ assert.equal(forkedProviderThread.forkedFrom?.providerThreadId, sourceProviderThread.id);
179+ }).pipe(Effect.provide(testLayer), Effect.scoped),
180+ );
181+
182+ it.effect("cancels pending permission requests while interrupting an ACP turn", () =>
183+ Effect.gen(function* () {
184+ const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
185+ const fileSystem = yield* FileSystem.FileSystem;
186+ const idAllocator = yield* IdAllocatorV2;
187+ const path = yield* Path.Path;
188+ const serverConfig = yield* ServerConfig;
189+ const mockAgentPath = yield* path.fromFileUrl(
190+ new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
191+ );
192+ const instanceId = ProviderInstanceId.make("acp-test");
193+ const adapter = makeAcpAdapterV2({
194+ instanceId,
195+ flavor: {
196+ provider: "acp-test",
197+ capabilities: AcpProviderCapabilitiesV2,
198+ makeRuntime: makeMockRuntime({
199+ childProcessSpawner,
200+ mockAgentPath,
201+ environment: { T3_ACP_EMIT_TOOL_CALLS: "1" },
202+ }),
203+ },
204+ fileSystem,
205+ idAllocator,
206+ serverConfig,
207+ });
208+ const threadId = ThreadId.make("thread-acp-cancel-permission");
209+ const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({
210+ runtimeMode: "approval-required",
211+ interactionMode: "default",
212+ cwd: process.cwd(),
213+ });
214+ const modelSelection = { instanceId, model: "default" } as const;
215+ const runtime = yield* adapter.openSession({
216+ threadId,
217+ providerSessionId: ProviderSessionId.make("provider-session-acp-cancel-permission"),
218+ modelSelection,
219+ runtimePolicy,
220+ });
221+ const providerThread = yield* runtime.ensureThread({
222+ threadId,
223+ modelSelection,
224+ runtimePolicy,
225+ });
226+ const now = yield* DateTime.now;
227+ yield* runtime.startTurn(
228+ makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }),
229+ );
230+
231+ const pendingRequest = Option.getOrThrow(
232+ yield* runtime.events.pipe(
233+ Stream.filter(
234+ (event) =>
235+ event.type === "runtime_request.updated" && event.runtimeRequest.status === "pending",
236+ ),
237+ Stream.runHead,
238+ ),
239+ );
240+ if (
241+ pendingRequest.type !== "runtime_request.updated" ||
242+ pendingRequest.runtimeRequest.providerTurnId === null
243+ ) {
244+ return yield* Effect.die("Expected a pending ACP permission request with a provider turn");
245+ }
246+
247+ yield* runtime.interruptTurn({
248+ providerThread,
249+ providerTurnId: pendingRequest.runtimeRequest.providerTurnId,
250+ });
251+
252+ const cancelledRequest = Option.getOrThrow(
253+ yield* runtime.events.pipe(
254+ Stream.filter(
255+ (event) =>
256+ event.type === "runtime_request.updated" &&
257+ event.runtimeRequest.id === pendingRequest.runtimeRequest.id &&
258+ event.runtimeRequest.status === "cancelled",
259+ ),
260+ Stream.runHead,
261+ ),
262+ );
263+ assert.equal(cancelledRequest.type, "runtime_request.updated");
264+ }).pipe(Effect.provide(testLayer), Effect.scoped),
265+ );
266+
267+ it.effect("does not release an ACP turn when cancellation is not acknowledged", () =>
268+ Effect.gen(function* () {
269+ const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
270+ const fileSystem = yield* FileSystem.FileSystem;
271+ const idAllocator = yield* IdAllocatorV2;
272+ const path = yield* Path.Path;
273+ const serverConfig = yield* ServerConfig;
274+ const mockAgentPath = yield* path.fromFileUrl(
275+ new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
276+ );
277+ const instanceId = ProviderInstanceId.make("acp-test");
278+ const adapter = makeAcpAdapterV2({
279+ instanceId,
280+ flavor: {
281+ provider: "acp-test",
282+ capabilities: AcpProviderCapabilitiesV2,
283+ makeRuntime: makeMockRuntime({
284+ childProcessSpawner,
285+ mockAgentPath,
286+ environment: { T3_ACP_PROMPT_DELAY_MS: "5000" },
287+ }),
288+ },
289+ fileSystem,
290+ idAllocator,
291+ serverConfig,
292+ });
293+ const threadId = ThreadId.make("thread-acp-cancel-timeout");
294+ const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({
295+ runtimeMode: "full-access",
296+ interactionMode: "default",
297+ cwd: process.cwd(),
298+ });
299+ const modelSelection = { instanceId, model: "default" } as const;
300+ const runtime = yield* adapter.openSession({
301+ threadId,
302+ providerSessionId: ProviderSessionId.make("provider-session-acp-cancel-timeout"),
303+ modelSelection,
304+ runtimePolicy,
305+ });
306+ const providerThread = yield* runtime.ensureThread({
307+ threadId,
308+ modelSelection,
309+ runtimePolicy,
310+ });
311+ const now = yield* DateTime.now;
312+ const firstTurn = makeTurnInput({
313+ threadId,
314+ providerThread,
315+ instanceId,
316+ runtimePolicy,
317+ now,
318+ });
319+ yield* runtime.startTurn(firstTurn);
320+ yield* runtime.rawEvents.pipe(
321+ Stream.filter(
322+ (event) => event.direction === "outgoing" && event.method === "session/prompt",
323+ ),
324+ Stream.runHead,
325+ );
326+ const providerTurnId = idAllocator.derive.providerTurn({
327+ provider: "acp-test",
328+ nativeTurnId: "mock-session-1:turn:1",
329+ });
330+ const interruptFiber = yield* runtime
331+ .interruptTurn({ providerThread, providerTurnId })
332+ .pipe(Effect.flip, Effect.forkScoped);
333+ yield* runtime.rawEvents.pipe(
334+ Stream.filter(
335+ (event) => event.direction === "outgoing" && event.method === "session/cancel",
336+ ),
337+ Stream.runHead,
338+ );
339+ yield* TestClock.adjust("10 seconds");
340+ const interruptError = yield* Fiber.join(interruptFiber);
341+ assert.equal(interruptError._tag, "ProviderAdapterInterruptError");
342+
343+ const secondTurnError = yield* runtime
344+ .startTurn(
345+ makeTurnInput({
346+ threadId,
347+ providerThread,
348+ instanceId,
349+ runtimePolicy,
350+ now,
351+ ordinal: 2,
352+ }),
353+ )
354+ .pipe(Effect.flip);
355+ assert.equal(secondTurnError._tag, "ProviderAdapterTurnStartError");
356+ }).pipe(Effect.provide(testLayer), Effect.scoped),
357+ );
358+});
apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.testkit.ts+187−0
@@ -0,0 +1,187 @@
1+import {
2+ ProviderKind,
3+ ProviderReplayEntry,
4+ type ProviderReplayTranscript,
5+} from "@t3tools/contracts";
6+import * as Effect from "effect/Effect";
7+import * as FileSystem from "effect/FileSystem";
8+import * as Layer from "effect/Layer";
9+import * as Schema from "effect/Schema";
10+import type * as Scope from "effect/Scope";
11+import { ChildProcessSpawner } from "effect/unstable/process";
12+import * as EffectAcpErrors from "effect-acp/errors";
13+
14+import {
15+ AcpSessionRuntime,
16+ type AcpSessionRuntimeShape,
17+} from "../../provider/acp/AcpSessionRuntime.ts";
18+import { ACP_PROTOCOL, type AcpAdapterV2RuntimeInput } from "./AcpAdapterV2.ts";
19+
20+export const AcpReplayTranscript = Schema.Struct({
21+ provider: ProviderKind,
22+ protocol: Schema.Literal(ACP_PROTOCOL),
23+ version: Schema.String,
24+ scenario: Schema.String,
25+ metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
26+ entries: Schema.Array(ProviderReplayEntry),
27+});
28+export type AcpReplayTranscript = typeof AcpReplayTranscript.Type;
29+
30+const decodeAcpReplayTranscriptSchema = Schema.decodeUnknownEffect(AcpReplayTranscript);
31+
32+export class AcpReplayTranscriptDecodeError extends Schema.TaggedErrorClass<AcpReplayTranscriptDecodeError>()(
33+ "AcpReplayTranscriptDecodeError",
34+ {
35+ expectedProvider: ProviderKind,
36+ provider: Schema.optional(Schema.String),
37+ protocol: Schema.optional(Schema.String),
38+ scenario: Schema.optional(Schema.String),
39+ cause: Schema.Defect(),
40+ },
41+) {
42+ override get message(): string {
43+ return `Failed to decode ${this.expectedProvider} ACP replay transcript for scenario ${this.scenario ?? "<unknown>"}.`;
44+ }
45+}
46+
47+const isAcpReplayTranscriptDecodeError = Schema.is(AcpReplayTranscriptDecodeError);
48+
49+function metadataFromTranscript(transcript: ProviderReplayTranscript): {
50+ readonly provider?: string;
51+ readonly protocol?: string;
52+ readonly scenario?: string;
53+} {
54+ return {
55+ provider: transcript.provider,
56+ protocol: transcript.protocol,
57+ scenario: transcript.scenario,
58+ };
59+}
60+
61+export function decodeAcpReplayTranscript(
62+ transcript: ProviderReplayTranscript,
63+ expectedProvider: ProviderKind,
64+ options: { readonly retargetProvider?: boolean } = {},
65+): Effect.Effect<AcpReplayTranscript, AcpReplayTranscriptDecodeError> {
66+ const candidate = options.retargetProvider
67+ ? { ...transcript, provider: expectedProvider }
68+ : transcript;
69+ return decodeAcpReplayTranscriptSchema(candidate).pipe(
70+ Effect.filterOrFail(
71+ (decoded) => decoded.provider === expectedProvider,
72+ () =>
73+ new AcpReplayTranscriptDecodeError({
74+ expectedProvider,
75+ ...metadataFromTranscript(transcript),
76+ cause: `Expected provider ${expectedProvider}, received ${transcript.provider}`,
77+ }),
78+ ),
79+ Effect.mapError((cause) =>
80+ isAcpReplayTranscriptDecodeError(cause)
81+ ? cause
82+ : new AcpReplayTranscriptDecodeError({
83+ expectedProvider,
84+ ...metadataFromTranscript(transcript),
85+ cause,
86+ }),
87+ ),
88+ );
89+}
90+
91+interface ReplayStatus {
92+ readonly cursor: number;
93+ readonly total: number;
94+ readonly failure?: unknown;
95+}
96+
97+function decodeReplayStatus(raw: string): ReplayStatus {
98+ const value: unknown = JSON.parse(raw);
99+ if (
100+ typeof value !== "object" ||
101+ value === null ||
102+ typeof Reflect.get(value, "cursor") !== "number" ||
103+ typeof Reflect.get(value, "total") !== "number"
104+ ) {
105+ throw new Error("ACP replay status is malformed.");
106+ }
107+ return value as ReplayStatus;
108+}
109+
110+export function makeAcpReplayCompletenessAssertion(
111+ fileSystem: FileSystem.FileSystem,
112+ statusPath: string,
113+ transcript: AcpReplayTranscript,
114+): Effect.Effect<void, EffectAcpErrors.AcpError> {
115+ return fileSystem.readFileString(statusPath).pipe(
116+ Effect.mapError(
117+ (cause) =>
118+ new EffectAcpErrors.AcpTransportError({
119+ detail: `Failed to read ACP replay status for ${transcript.scenario}`,
120+ cause,
121+ }),
122+ ),
123+ Effect.flatMap((raw) =>
124+ Effect.try({
125+ try: () => decodeReplayStatus(raw),
126+ catch: (cause) =>
127+ new EffectAcpErrors.AcpTransportError({
128+ detail: `Failed to decode ACP replay status for ${transcript.scenario}`,
129+ cause,
130+ }),
131+ }),
132+ ),
133+ Effect.flatMap((status) => {
134+ if (
135+ status.failure === undefined &&
136+ status.cursor === transcript.entries.length &&
137+ status.total === transcript.entries.length
138+ ) {
139+ return Effect.void;
140+ }
141+ return Effect.fail(
142+ new EffectAcpErrors.AcpTransportError({
143+ detail: `ACP replay did not consume all frames for ${transcript.scenario}`,
144+ cause: status,
145+ }),
146+ );
147+ }),
148+ );
149+}
150+
151+export function makeAcpReplayRuntime(input: {
152+ readonly transcript: AcpReplayTranscript;
153+ readonly statusPath: string;
154+ readonly scriptPath: string;
155+ readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"];
156+}): (
157+ runtimeInput: AcpAdapterV2RuntimeInput,
158+) => Effect.Effect<AcpSessionRuntimeShape, EffectAcpErrors.AcpError, Scope.Scope> {
159+ const encodedTranscript = Buffer.from(JSON.stringify(input.transcript), "utf8").toString(
160+ "base64",
161+ );
162+ return (runtimeInput) =>
163+ Effect.gen(function* () {
164+ const context = yield* Layer.build(
165+ AcpSessionRuntime.layer({
166+ ...runtimeInput,
167+ spawn: {
168+ command: process.execPath,
169+ args: [input.scriptPath],
170+ cwd: runtimeInput.cwd,
171+ env: {
172+ ...process.env,
173+ T3_ACP_REPLAY_TRANSCRIPT: encodedTranscript,
174+ T3_ACP_REPLAY_STATUS_PATH: input.statusPath,
175+ T3_ACP_REPLAY_WORKSPACE: runtimeInput.cwd,
176+ },
177+ },
178+ authMethodId: "replay",
179+ }).pipe(
180+ Layer.provide(
181+ Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner),
182+ ),
183+ ),
184+ );
185+ return yield* Effect.service(AcpSessionRuntime).pipe(Effect.provide(context));
186+ });
187+}
apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts+2203−0
@@ -0,0 +1,2203 @@
1+import {
2+ type ChatAttachment,
3+ type ModelSelection,
4+ type OrchestrationV2ConversationMessage,
5+ type OrchestrationV2ExecutionNode,
6+ type OrchestrationV2PlanArtifact,
7+ type OrchestrationV2PlanStep,
8+ type OrchestrationV2ProviderCapabilities,
9+ type OrchestrationV2ProviderSession,
10+ type OrchestrationV2ProviderThread,
11+ type OrchestrationV2ProviderTurn,
12+ type OrchestrationV2RawProviderEvent,
13+ type OrchestrationV2RuntimeRequest,
14+ type OrchestrationV2TurnItem,
15+ type OrchestrationV2UserInputQuestion,
16+ type ProviderApprovalDecision,
17+ type ProviderInstanceId,
18+ type ProviderKind,
19+ type ProviderRequestKind,
20+ type ProviderUserInputAnswers,
21+ type RuntimeRequestId,
22+ type ThreadId,
23+} from "@t3tools/contracts";
24+import * as DateTime from "effect/DateTime";
25+import * as Deferred from "effect/Deferred";
26+import * as Effect from "effect/Effect";
27+import * as FileSystem from "effect/FileSystem";
28+import * as Option from "effect/Option";
29+import * as Queue from "effect/Queue";
30+import * as Ref from "effect/Ref";
31+import type * as Scope from "effect/Scope";
32+import * as Stream from "effect/Stream";
33+import * as EffectAcpErrors from "effect-acp/errors";
34+import type * as EffectAcpProtocol from "effect-acp/protocol";
35+import type * as EffectAcpSchema from "effect-acp/schema";
36+
37+import { resolveAttachmentPath } from "../../attachmentStore.ts";
38+import { type ServerConfigShape } from "../../config.ts";
39+import * as McpProviderSession from "../../mcp/McpProviderSession.ts";
40+import {
41+ mergeToolCallState,
42+ parsePermissionRequest,
43+ parseSessionUpdateEvent,
44+ type AcpPlanUpdate,
45+ type AcpToolCallState,
46+} from "../../provider/acp/AcpRuntimeModel.ts";
47+import type {
48+ AcpSessionRuntimeOptions,
49+ AcpSessionRuntimeShape,
50+ AcpSessionRuntimeStartResult,
51+} from "../../provider/acp/AcpSessionRuntime.ts";
52+import { IdAllocatorV2, type IdAllocatorV2Shape } from "../IdAllocator.ts";
53+import {
54+ ProviderAdapterEnsureThreadError,
55+ ProviderAdapterForkThreadError,
56+ ProviderAdapterInterruptError,
57+ ProviderAdapterOpenSessionError,
58+ ProviderAdapterProtocolError,
59+ ProviderAdapterReadThreadSnapshotError,
60+ ProviderAdapterResumeThreadError,
61+ ProviderAdapterRollbackThreadError,
62+ ProviderAdapterRuntimeRequestResponseError,
63+ ProviderAdapterSteerRunUnsupportedError,
64+ ProviderAdapterTurnStartError,
65+ ProviderAdapterV2,
66+ type ProviderAdapterV2EnsureThreadInput,
67+ type ProviderAdapterV2Event,
68+ type ProviderAdapterV2InterruptInput,
69+ type ProviderAdapterV2OpenSessionInput,
70+ type ProviderAdapterV2RuntimePolicy,
71+ type ProviderAdapterV2SessionRuntime,
72+ type ProviderAdapterV2Shape,
73+ type ProviderAdapterV2TurnInput,
74+} from "../ProviderAdapter.ts";
75+
76+export const ACP_PROTOCOL = "acp.ndjson-jsonrpc" as const;
77+
78+export interface AcpAdapterV2RuntimeInput {
79+ readonly cwd: string;
80+ readonly mcpServers: ReadonlyArray<EffectAcpSchema.McpServer>;
81+ readonly clientCapabilities: EffectAcpSchema.InitializeRequest["clientCapabilities"];
82+ readonly clientInfo: AcpSessionRuntimeOptions["clientInfo"];
83+ readonly protocolLogging: NonNullable<AcpSessionRuntimeOptions["protocolLogging"]>;
84+}
85+
86+export interface AcpAdapterV2UserInputRequest {
87+ readonly nativeItemId: string;
88+ readonly nativeRequestId: string;
89+ readonly questions: ReadonlyArray<OrchestrationV2UserInputQuestion>;
90+}
91+
92+export interface AcpAdapterV2ExtensionContext {
93+ readonly runtime: AcpSessionRuntimeShape;
94+ readonly requestUserInput: (
95+ input: AcpAdapterV2UserInputRequest,
96+ ) => Effect.Effect<ProviderUserInputAnswers | null, EffectAcpErrors.AcpError>;
97+}
98+
99+export interface AcpAdapterV2Flavor {
100+ readonly provider: ProviderKind;
101+ readonly capabilities: OrchestrationV2ProviderCapabilities;
102+ readonly makeRuntime: (
103+ input: AcpAdapterV2RuntimeInput,
104+ ) => Effect.Effect<AcpSessionRuntimeShape, EffectAcpErrors.AcpError, Scope.Scope>;
105+ readonly resolveModelId?: (selection: ModelSelection) => string | undefined;
106+ readonly registerExtensions?: (
107+ context: AcpAdapterV2ExtensionContext,
108+ ) => Effect.Effect<void, EffectAcpErrors.AcpError>;
109+ readonly assertComplete?: Effect.Effect<void, EffectAcpErrors.AcpError>;
110+}
111+
112+export interface AcpAdapterV2Options {
113+ readonly instanceId: ProviderInstanceId;
114+ readonly flavor: AcpAdapterV2Flavor;
115+ readonly fileSystem: FileSystem.FileSystem;
116+ readonly idAllocator: IdAllocatorV2Shape;
117+ readonly serverConfig: ServerConfigShape;
118+}
119+
120+export const AcpProviderCapabilitiesV2 = {
121+ sessions: {
122+ supportsMultipleProviderThreadsPerSession: false,
123+ supportsModelSwitchInSession: false,
124+ supportsProviderSwitchingViaHandoff: true,
125+ supportsRuntimeModeSwitchInSession: false,
126+ pendingRequestsSurviveRestart: false,
127+ },
128+ threads: {
129+ canCreateEmptyThread: true,
130+ canReadThreadSnapshot: false,
131+ canRollbackThread: false,
132+ canForkThread: false,
133+ canForkFromTurn: false,
134+ canForkFromSubagentThread: false,
135+ exposesNativeThreadId: true,
136+ },
137+ turns: {
138+ exposesNativeTurnId: false,
139+ emitsTurnStarted: true,
140+ emitsTurnCompleted: true,
141+ supportsInterrupt: true,
142+ supportsActiveSteering: false,
143+ supportsSteeringByInterruptRestart: true,
144+ supportsQueuedMessages: true,
145+ terminalStatusQuality: "strong",
146+ },
147+ streaming: {
148+ streamsAssistantText: true,
149+ streamsReasoning: true,
150+ streamsToolOutput: true,
151+ streamsPlanText: false,
152+ emitsMessageCompleted: true,
153+ },
154+ tools: {
155+ exposesToolItemIds: true,
156+ emitsToolStarted: true,
157+ emitsToolCompleted: true,
158+ emitsToolOutput: true,
159+ supportsMcpTools: false,
160+ supportsDynamicToolCallbacks: false,
161+ },
162+ approvals: {
163+ supportsCommandApproval: true,
164+ supportsFileReadApproval: true,
165+ supportsFileChangeApproval: true,
166+ supportsApplyPatchApproval: false,
167+ approvalsHaveNativeRequestIds: false,
168+ approvalCallbacksAreLiveOnly: true,
169+ approvalsCanOriginateFromSubagents: false,
170+ },
171+ planning: {
172+ emitsPlanUpdated: true,
173+ emitsTodoList: true,
174+ emitsProposedPlan: false,
175+ supportsStructuredQuestions: true,
176+ planDeltasHaveItemIds: false,
177+ },
178+ subagents: {
179+ supportsSubagents: false,
180+ exposesSubagentThreadIds: false,
181+ emitsSubagentLifecycle: false,
182+ canWaitForSubagents: false,
183+ canCloseSubagents: false,
184+ canForkSubagentThread: false,
185+ },
186+ context: {
187+ acceptsSystemContext: false,
188+ acceptsDeveloperContext: false,
189+ acceptsSyntheticUserContext: true,
190+ canGenerateSummaries: true,
191+ canConsumeHandoffSummaries: true,
192+ supportsDeltaHandoff: true,
193+ supportsFullThreadHandoff: true,
194+ maxRecommendedHandoffChars: null,
195+ },
196+ checkpointing: {
197+ appCanCheckpointFilesystem: true,
198+ supportsNestedCheckpointScopes: true,
199+ providerCanRollbackConversation: false,
200+ providerRollbackReturnsSnapshot: false,
201+ providerCanReadConversationSnapshot: false,
202+ },
203+ identity: {
204+ nativeThreadIds: "strong",
205+ nativeTurnIds: "weak",
206+ nativeItemIds: "weak",
207+ nativeRequestIds: "weak",
208+ },
209+} satisfies OrchestrationV2ProviderCapabilities;
210+
211+function negotiatedCapabilities(
212+ base: OrchestrationV2ProviderCapabilities,
213+ started: AcpSessionRuntimeStartResult,
214+): OrchestrationV2ProviderCapabilities {
215+ const agent = started.initializeResult.agentCapabilities ?? {};
216+ const session = agent.sessionCapabilities;
217+ const setup = started.sessionSetupResult;
218+ const hasModelConfig =
219+ setup.configOptions?.some((option) => option.category === "model") === true;
220+ const hasModeConfig = setup.configOptions?.some((option) => option.category === "mode") === true;
221+ const supportsMcp = agent.mcpCapabilities?.http === true || agent.mcpCapabilities?.sse === true;
222+ const canLoad = agent.loadSession === true;
223+ const canFork = session?.fork != null;
224+ return {
225+ ...base,
226+ sessions: {
227+ ...base.sessions,
228+ supportsModelSwitchInSession: setup.models != null || hasModelConfig,
229+ supportsRuntimeModeSwitchInSession: setup.modes != null || hasModeConfig,
230+ },
231+ threads: {
232+ ...base.threads,
233+ canReadThreadSnapshot: canLoad,
234+ canForkThread: canFork,
235+ canForkFromTurn: false,
236+ },
237+ tools: {
238+ ...base.tools,
239+ supportsMcpTools: supportsMcp,
240+ },
241+ checkpointing: {
242+ ...base.checkpointing,
243+ providerCanReadConversationSnapshot: canLoad,
244+ },
245+ };
246+}
247+
248+function acpMcpServers(threadId: ThreadId): ReadonlyArray<EffectAcpSchema.McpServer> {
249+ const session = McpProviderSession.readMcpProviderSession(threadId);
250+ if (session === undefined) {
251+ return [];
252+ }
253+ return [
254+ {
255+ type: "http",
256+ name: "t3-code",
257+ url: session.endpoint,
258+ headers: [
259+ {
260+ name: "Authorization",
261+ value: session.authorizationHeader,
262+ },
263+ ],
264+ },
265+ ];
266+}
267+
268+function nativeThreadId(provider: ProviderKind, thread: OrchestrationV2ProviderThread): string {
269+ const id = thread.nativeThreadRef?.nativeId;
270+ if (id === null || id === undefined || id.trim().length === 0) {
271+ throw new ProviderAdapterProtocolError({
272+ provider,
273+ detail: `Provider thread ${thread.id} is missing its ACP session id`,
274+ });
275+ }
276+ return id;
277+}
278+
279+function makeProviderThread(input: {
280+ readonly provider: ProviderKind;
281+ readonly idAllocator: IdAllocatorV2Shape;
282+ readonly appThreadId: OrchestrationV2ProviderThread["appThreadId"];
283+ readonly providerSessionId: OrchestrationV2ProviderThread["providerSessionId"];
284+ readonly nativeThreadId: string;
285+ readonly ownerNodeId?: OrchestrationV2ProviderThread["ownerNodeId"];
286+ readonly forkedFrom?: OrchestrationV2ProviderThread["forkedFrom"];
287+ readonly now: DateTime.Utc;
288+}): OrchestrationV2ProviderThread {
289+ return {
290+ id: input.idAllocator.derive.providerThread({
291+ provider: input.provider,
292+ nativeThreadId: input.nativeThreadId,
293+ }),
294+ provider: input.provider,
295+ providerSessionId: input.providerSessionId,
296+ appThreadId: input.appThreadId,
297+ ownerNodeId: input.ownerNodeId ?? null,
298+ nativeThreadRef: {
299+ provider: input.provider,
300+ nativeId: input.nativeThreadId,
301+ strength: "strong",
302+ },
303+ nativeConversationHeadRef: null,
304+ status: "idle",
305+ firstRunOrdinal: null,
306+ lastRunOrdinal: null,
307+ handoffIds: [],
308+ forkedFrom: input.forkedFrom ?? null,
309+ createdAt: input.now,
310+ updatedAt: input.now,
311+ };
312+}
313+
314+function unknownRecord(value: unknown): Record<string, unknown> | undefined {
315+ return typeof value === "object" && value !== null && !Array.isArray(value)
316+ ? (value as Record<string, unknown>)
317+ : undefined;
318+}
319+
320+function nonEmptyText(value: unknown, fallback: string): string {
321+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : fallback;
322+}
323+
324+function textFromUnknown(value: unknown): string | undefined {
325+ if (typeof value === "string") {
326+ return value;
327+ }
328+ if (Array.isArray(value)) {
329+ const parts = value.flatMap((entry) => {
330+ const text = textFromUnknown(entry);
331+ return text === undefined || text.length === 0 ? [] : [text];
332+ });
333+ return parts.length === 0 ? undefined : parts.join("\n");
334+ }
335+ const record = unknownRecord(value);
336+ if (record === undefined) {
337+ return undefined;
338+ }
339+ for (const key of ["stdout", "stderr", "output", "content", "text", "message"]) {
340+ const text = textFromUnknown(record[key]);
341+ if (text !== undefined && text.length > 0) {
342+ return text;
343+ }
344+ }
345+ return undefined;
346+}
347+
348+function commandExitCode(value: unknown): number | undefined {
349+ const record = unknownRecord(value);
350+ for (const key of ["exitCode", "exit_code", "code"]) {
351+ const candidate = record?.[key];
352+ if (typeof candidate === "number" && Number.isInteger(candidate)) {
353+ return candidate;
354+ }
355+ }
356+ return undefined;
357+}
358+
359+function pathFromToolCall(toolCall: AcpToolCallState): string | undefined {
360+ const locations = toolCall.data.locations;
361+ if (Array.isArray(locations)) {
362+ for (const location of locations) {
363+ const path = unknownRecord(location)?.path;
364+ if (typeof path === "string" && path.trim().length > 0) {
365+ return path.trim();
366+ }
367+ }
368+ }
369+ const rawInput = unknownRecord(toolCall.data.rawInput);
370+ for (const key of ["path", "filePath", "file_path", "url", "query", "pattern"]) {
371+ const candidate = rawInput?.[key];
372+ if (typeof candidate === "string" && candidate.trim().length > 0) {
373+ return candidate.trim();
374+ }
375+ }
376+ return undefined;
377+}
378+
379+function providerRequestKind(kind: string | "unknown"): ProviderRequestKind {
380+ switch (kind) {
381+ case "execute":
382+ return "command";
383+ case "read":
384+ case "search":
385+ case "fetch":
386+ return "file-read";
387+ case "edit":
388+ case "delete":
389+ case "move":
390+ return "file-change";
391+ default:
392+ return "command";
393+ }
394+}
395+
396+function toolStatus(
397+ status: AcpToolCallState["status"],
398+): "pending" | "running" | "completed" | "failed" {
399+ switch (status) {
400+ case "completed":
401+ return "completed";
402+ case "failed":
403+ return "failed";
404+ case "pending":
405+ return "pending";
406+ default:
407+ return "running";
408+ }
409+}
410+
411+function nodeStatus(status: ReturnType<typeof toolStatus>): OrchestrationV2ExecutionNode["status"] {
412+ return status === "pending" ? "running" : status;
413+}
414+
415+function completedAtForStatus(
416+ status: ReturnType<typeof toolStatus>,
417+ now: DateTime.Utc,
418+): DateTime.Utc | null {
419+ return status === "completed" || status === "failed" ? now : null;
420+}
421+
422+function selectPermissionOptionId(
423+ request: EffectAcpSchema.RequestPermissionRequest,
424+ decision: Exclude<ProviderApprovalDecision, "cancel">,
425+): string | undefined {
426+ const kind =
427+ decision === "acceptForSession"
428+ ? "allow_always"
429+ : decision === "accept"
430+ ? "allow_once"
431+ : "reject_once";
432+ return request.options.find((option) => option.kind === kind)?.optionId.trim() || undefined;
433+}
434+
435+function selectAutoApprovedPermissionOption(
436+ request: EffectAcpSchema.RequestPermissionRequest,
437+): string | undefined {
438+ return (
439+ selectPermissionOptionId(request, "acceptForSession") ??
440+ selectPermissionOptionId(request, "accept")
441+ );
442+}
443+
444+export type AcpPermissionDisposition = "allow" | "ask" | "deny";
445+
446+export function acpPermissionDisposition(
447+ runtimePolicy: ProviderAdapterV2RuntimePolicy,
448+ request: EffectAcpSchema.RequestPermissionRequest,
449+): AcpPermissionDisposition {
450+ const approvalPolicy = runtimePolicy.approvalPolicy;
451+ const requiresApproval =
452+ approvalPolicy === undefined
453+ ? runtimePolicy.runtimeMode === "approval-required"
454+ : approvalPolicy !== "never";
455+ if (requiresApproval) {
456+ return "ask";
457+ }
458+
459+ const sandboxPolicy = unknownRecord(runtimePolicy.sandboxPolicy);
460+ const sandboxType = sandboxPolicy?.type;
461+ const toolKind = request.toolCall.kind ?? "other";
462+ switch (sandboxType) {
463+ case "readOnly":
464+ return toolKind === "read" || toolKind === "search" || toolKind === "think"
465+ ? "allow"
466+ : "deny";
467+ case "workspaceWrite":
468+ return toolKind === "read" ||
469+ toolKind === "search" ||
470+ toolKind === "think" ||
471+ toolKind === "edit" ||
472+ toolKind === "delete" ||
473+ toolKind === "move"
474+ ? "allow"
475+ : "deny";
476+ case "dangerFullAccess":
477+ case "externalSandbox":
478+ return "allow";
479+ case undefined:
480+ return runtimePolicy.runtimeMode === "approval-required" ? "deny" : "allow";
481+ default:
482+ return "deny";
483+ }
484+}
485+
486+function jsonRpcId(value: unknown): string | number | null {
487+ return (typeof value === "string" && value.length > 0) || typeof value === "number"
488+ ? value
489+ : null;
490+}
491+
492+function elicitationContent(
493+ answers: ProviderUserInputAnswers,
494+): Record<string, EffectAcpSchema.ElicitationContentValue> {
495+ const content: Record<string, EffectAcpSchema.ElicitationContentValue> = {};
496+ for (const [key, value] of Object.entries(answers)) {
497+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
498+ content[key] = value;
499+ } else if (Array.isArray(value)) {
500+ content[key] = value.filter((entry): entry is string => typeof entry === "string");
501+ }
502+ }
503+ return content;
504+}
505+
506+function rawJsonRpcMessages(payload: unknown): ReadonlyArray<Record<string, unknown>> {
507+ if (typeof payload !== "string") {
508+ return [];
509+ }
510+ const messages: Array<Record<string, unknown>> = [];
511+ for (const line of payload.split("\n")) {
512+ const trimmed = line.trim();
513+ if (trimmed.length === 0) continue;
514+ try {
515+ const decoded: unknown = JSON.parse(trimmed);
516+ if (Array.isArray(decoded)) {
517+ for (const entry of decoded) {
518+ const record = unknownRecord(entry);
519+ if (record !== undefined) messages.push(record);
520+ }
521+ } else {
522+ const record = unknownRecord(decoded);
523+ if (record !== undefined) messages.push(record);
524+ }
525+ } catch {
526+ messages.push({ raw: trimmed });
527+ }
528+ }
529+ return messages;
530+}
531+
532+interface ActiveTextSegment {
533+ readonly nativeItemId: string;
534+ readonly startedAt: DateTime.Utc;
535+ text: string;
536+}
537+
538+interface ActiveTextStream {
539+ current: ActiveTextSegment | null;
540+ nextSegment: number;
541+}
542+
543+interface ActiveAcpTurn {
544+ readonly input: ProviderAdapterV2TurnInput;
545+ readonly providerTurnId: OrchestrationV2ProviderTurn["id"];
546+ readonly nativeTurnId: string;
547+ readonly startedAt: DateTime.Utc;
548+ readonly completed: Deferred.Deferred<void, never>;
549+ readonly assistant: ActiveTextStream;
550+ readonly reasoning: ActiveTextStream;
551+ readonly tools: Map<string, AcpToolCallState>;
552+ readonly toolStartedAt: Map<string, DateTime.Utc>;
553+ plan: {
554+ readonly id: OrchestrationV2PlanArtifact["id"];
555+ readonly startedAt: DateTime.Utc;
556+ } | null;
557+ interrupted: boolean;
558+ finalized: boolean;
559+}
560+
561+type PendingRuntimeRequest = {
562+ readonly requestId: RuntimeRequestId;
563+ readonly runtimeRequest: OrchestrationV2RuntimeRequest;
564+ readonly node: OrchestrationV2ExecutionNode;
565+ readonly turnItem: OrchestrationV2TurnItem;
566+} & (
567+ | {
568+ readonly type: "approval";
569+ readonly decision: Deferred.Deferred<ProviderApprovalDecision>;
570+ }
571+ | {
572+ readonly type: "user_input";
573+ readonly answers: Deferred.Deferred<ProviderUserInputAnswers | null>;
574+ }
575+);
576+
577+interface SnapshotMessageState {
578+ readonly order: Array<string>;
579+ readonly messages: Map<string, OrchestrationV2ConversationMessage>;
580+ loadingRole: "user" | "assistant" | null;
581+ loadingIndex: number;
582+}
583+
584+interface RawProtocolState {
585+ readonly pendingMethods: Map<string, string>;
586+ sequence: number;
587+}
588+
589+function requestKey(direction: "incoming" | "outgoing", id: string | number): string {
590+ return `${direction}:${String(id)}`;
591+}
592+
593+function oppositeDirection(direction: "incoming" | "outgoing"): "incoming" | "outgoing" {
594+ return direction === "incoming" ? "outgoing" : "incoming";
595+}
596+
597+export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV2Shape {
598+ const { flavor, fileSystem, idAllocator, serverConfig } = options;
599+ const provider = flavor.provider;
600+
601+ return ProviderAdapterV2.of({
602+ instanceId: options.instanceId,
603+ provider,
604+ getCapabilities: () => Effect.succeed(flavor.capabilities),
605+ openSession: Effect.fn("AcpAdapterV2.openSession")(
606+ function* (input: ProviderAdapterV2OpenSessionInput) {
607+ const sessionScope = yield* Effect.scope;
608+ const events = yield* Queue.unbounded<ProviderAdapterV2Event>();
609+ const rawEvents = yield* Queue.unbounded<OrchestrationV2RawProviderEvent>();
610+ const rawState = yield* Ref.make<RawProtocolState>({
611+ pendingMethods: new Map(),
612+ sequence: 0,
613+ });
614+ const activeTurn = yield* Ref.make<ActiveAcpTurn | null>(null);
615+ const activeSessionId = yield* Ref.make<string | null>(null);
616+ const pendingRuntimeRequests = yield* Ref.make(new Map<string, PendingRuntimeRequest>());
617+ const nextElicitationOrdinal = yield* Ref.make(0);
618+ const itemOrdinals = yield* Ref.make(new Map<string, number>());
619+ const nextItemOrdinalsByTurn = yield* Ref.make(new Map<string, number>());
620+ const providerTurns = yield* Ref.make(new Map<string, OrchestrationV2ProviderTurn>());
621+ const snapshot = yield* Ref.make<SnapshotMessageState>({
622+ order: [],
623+ messages: new Map(),
624+ loadingRole: null,
625+ loadingIndex: 0,
626+ });
627+
628+ const emitProviderEvent = (event: ProviderAdapterV2Event) =>
629+ Queue.offer(events, event).pipe(Effect.asVoid);
630+
631+ const protocolLogger = (
632+ event: EffectAcpProtocol.AcpProtocolLogEvent,
633+ ): Effect.Effect<void, never> =>
634+ Effect.gen(function* () {
635+ if (event.stage !== "raw") return;
636+ for (const message of rawJsonRpcMessages(event.payload)) {
637+ const methodValue = message.method;
638+ const id = jsonRpcId(message.id);
639+ let method = typeof methodValue === "string" ? methodValue : null;
640+ let messageKind: OrchestrationV2RawProviderEvent["messageKind"];
641+ if (method !== null) {
642+ messageKind = id === null ? "notification" : "request";
643+ } else if ("error" in message) {
644+ messageKind = "error";
645+ } else {
646+ messageKind = "response";
647+ }
648+ const state = yield* Ref.get(rawState);
649+ if (method !== null && id !== null) {
650+ state.pendingMethods.set(requestKey(event.direction, id), method);
651+ } else if (id !== null) {
652+ method =
653+ state.pendingMethods.get(requestKey(oppositeDirection(event.direction), id)) ??
654+ null;
655+ state.pendingMethods.delete(requestKey(oppositeDirection(event.direction), id));
656+ }
657+ state.sequence += 1;
658+ const now = yield* DateTime.now;
659+ const rawEventId = yield* idAllocator.allocate.rawEvent({
660+ providerSessionId: input.providerSessionId,
661+ method,
662+ });
663+ yield* Queue.offer(rawEvents, {
664+ id: rawEventId,
665+ provider,
666+ providerSessionId: input.providerSessionId,
667+ sequence: state.sequence,
668+ direction: event.direction,
669+ messageKind,
670+ method,
671+ jsonRpcId: id,
672+ payload: message,
673+ observedAt: now,
674+ });
675+ }
676+ }).pipe(
677+ Effect.catchCause((cause) =>
678+ Effect.logWarning("orchestration-v2.acp-raw-event-failed", {
679+ provider,
680+ providerSessionId: input.providerSessionId,
681+ cause,
682+ }),
683+ ),
684+ );
685+
686+ const runtime = yield* flavor.makeRuntime({
687+ cwd: input.runtimePolicy.cwd ?? process.cwd(),
688+ mcpServers: acpMcpServers(input.threadId),
689+ clientCapabilities: {
690+ fs: { readTextFile: false, writeTextFile: false },
691+ terminal: false,
692+ elicitation: { form: {} },
693+ },
694+ clientInfo: { name: "t3-code", version: "0.0.0" },
695+ protocolLogging: {
696+ logIncoming: true,
697+ logOutgoing: true,
698+ logger: protocolLogger,
699+ },
700+ });
701+
702+ const resolveItemOrdinal = Effect.fnUntraced(function* (
703+ context: ActiveAcpTurn,
704+ nativeItemId: string,
705+ ) {
706+ const existing = (yield* Ref.get(itemOrdinals)).get(nativeItemId);
707+ if (existing !== undefined) return existing;
708+ const nextWithinTurn = yield* Ref.modify(nextItemOrdinalsByTurn, (current) => {
709+ const next = (current.get(context.nativeTurnId) ?? 0) + 1;
710+ const updated = new Map(current);
711+ updated.set(context.nativeTurnId, next);
712+ return [next, updated] as const;
713+ });
714+ const ordinal = context.input.runOrdinal * 100 + nextWithinTurn;
715+ yield* Ref.update(itemOrdinals, (current) => {
716+ const updated = new Map(current);
717+ updated.set(nativeItemId, ordinal);
718+ return updated;
719+ });
720+ return ordinal;
721+ });
722+
723+ const rememberSnapshotMessage = (message: OrchestrationV2ConversationMessage) =>
724+ Ref.update(snapshot, (current) => {
725+ const key = String(message.id);
726+ const exists = current.messages.has(key);
727+ const messages = new Map(current.messages);
728+ messages.set(key, message);
729+ return {
730+ ...current,
731+ order: exists ? current.order : [...current.order, key],
732+ messages,
733+ };
734+ });
735+
736+ const emitTextSegment = Effect.fnUntraced(function* (
737+ context: ActiveAcpTurn,
738+ kind: "assistant" | "reasoning",
739+ completed: boolean,
740+ ) {
741+ const stream = kind === "assistant" ? context.assistant : context.reasoning;
742+ const segment = stream.current;
743+ if (segment === null || segment.text.length === 0) return;
744+ const now = yield* DateTime.now;
745+ const ordinal = yield* resolveItemOrdinal(context, segment.nativeItemId);
746+ const nodeId = idAllocator.derive.nodeFromProviderItem({
747+ provider,
748+ nativeItemId: segment.nativeItemId,
749+ });
750+ const turnItemId = idAllocator.derive.turnItemFromProviderItem({
751+ provider,
752+ nativeItemId: segment.nativeItemId,
753+ });
754+ const nativeItemRef = {
755+ provider,
756+ nativeId: segment.nativeItemId,
757+ strength: "weak" as const,
758+ };
759+ yield* emitProviderEvent({
760+ type: "node.updated",
761+ provider,
762+ node: {
763+ id: nodeId,
764+ threadId: context.input.threadId,
765+ runId: context.input.runId,
766+ parentNodeId: context.input.rootNodeId,
767+ rootNodeId: context.input.rootNodeId,
768+ kind: kind === "assistant" ? "assistant_message" : "reasoning",
769+ status: completed ? "completed" : "running",
770+ countsForRun: false,
771+ providerThreadId: context.input.providerThread.id,
772+ providerTurnId: context.providerTurnId,
773+ nativeItemRef,
774+ runtimeRequestId: null,
775+ checkpointScopeId: null,
776+ startedAt: segment.startedAt,
777+ completedAt: completed ? now : null,
778+ },
779+ });
780+ if (kind === "assistant") {
781+ const messageId = idAllocator.derive.messageFromProviderItem({
782+ provider,
783+ nativeItemId: segment.nativeItemId,
784+ });
785+ const message: OrchestrationV2ConversationMessage = {
786+ id: messageId,
787+ threadId: context.input.threadId,
788+ runId: context.input.runId,
789+ nodeId,
790+ role: "assistant",
791+ text: segment.text,
792+ attachments: [],
793+ streaming: !completed,
794+ createdAt: segment.startedAt,
795+ updatedAt: now,
796+ };
797+ yield* emitProviderEvent({ type: "message.updated", provider, message });
798+ yield* emitProviderEvent({
799+ type: "turn_item.updated",
800+ provider,
801+ turnItem: {
802+ id: turnItemId,
803+ threadId: context.input.threadId,
804+ runId: context.input.runId,
805+ nodeId,
806+ providerThreadId: context.input.providerThread.id,
807+ providerTurnId: context.providerTurnId,
808+ nativeItemRef,
809+ parentItemId: null,
810+ ordinal,
811+ status: completed ? "completed" : "running",
812+ title: null,
813+ startedAt: segment.startedAt,
814+ completedAt: completed ? now : null,
815+ updatedAt: now,
816+ type: "assistant_message",
817+ messageId,
818+ text: segment.text,
819+ streaming: !completed,
820+ },
821+ });
822+ if (completed) yield* rememberSnapshotMessage(message);
823+ return;
824+ }
825+ yield* emitProviderEvent({
826+ type: "turn_item.updated",
827+ provider,
828+ turnItem: {
829+ id: turnItemId,
830+ threadId: context.input.threadId,
831+ runId: context.input.runId,
832+ nodeId,
833+ providerThreadId: context.input.providerThread.id,
834+ providerTurnId: context.providerTurnId,
835+ nativeItemRef,
836+ parentItemId: null,
837+ ordinal,
838+ status: completed ? "completed" : "running",
839+ title: null,
840+ startedAt: segment.startedAt,
841+ completedAt: completed ? now : null,
842+ updatedAt: now,
843+ type: "reasoning",
844+ text: segment.text,
845+ streaming: !completed,
846+ },
847+ });
848+ });
849+
850+ const closeTextStream = Effect.fnUntraced(function* (
851+ context: ActiveAcpTurn,
852+ kind: "assistant" | "reasoning",
853+ ) {
854+ const stream = kind === "assistant" ? context.assistant : context.reasoning;
855+ if (stream.current === null) return;
856+ yield* emitTextSegment(context, kind, true);
857+ stream.current = null;
858+ });
859+
860+ const closeTextStreams = Effect.fnUntraced(function* (context: ActiveAcpTurn) {
861+ yield* closeTextStream(context, "reasoning");
862+ yield* closeTextStream(context, "assistant");
863+ });
864+
865+ const appendText = Effect.fnUntraced(function* (
866+ context: ActiveAcpTurn,
867+ kind: "assistant" | "reasoning",
868+ text: string,
869+ ) {
870+ if (text.length === 0) return;
871+ const other = kind === "assistant" ? "reasoning" : "assistant";
872+ yield* closeTextStream(context, other);
873+ const stream = kind === "assistant" ? context.assistant : context.reasoning;
874+ if (stream.current === null) {
875+ const now = yield* DateTime.now;
876+ stream.current = {
877+ nativeItemId: `${context.nativeTurnId}:${kind}:${stream.nextSegment}`,
878+ startedAt: now,
879+ text: "",
880+ };
881+ stream.nextSegment += 1;
882+ }
883+ stream.current.text += text;
884+ yield* emitTextSegment(context, kind, false);
885+ });
886+
887+ const emitTool = Effect.fnUntraced(function* (
888+ context: ActiveAcpTurn,
889+ incoming: AcpToolCallState,
890+ ) {
891+ yield* closeTextStreams(context);
892+ const previous = context.tools.get(incoming.toolCallId);
893+ const toolCall = mergeToolCallState(previous, incoming);
894+ context.tools.set(toolCall.toolCallId, toolCall);
895+ const status = toolStatus(toolCall.status);
896+ const now = yield* DateTime.now;
897+ const nativeItemId = `${nativeThreadId(provider, context.input.providerThread)}:tool:${toolCall.toolCallId}`;
898+ const ordinal = yield* resolveItemOrdinal(context, nativeItemId);
899+ const nodeId = idAllocator.derive.nodeFromProviderItem({ provider, nativeItemId });
900+ const turnItemId = idAllocator.derive.turnItemFromProviderItem({
901+ provider,
902+ nativeItemId,
903+ });
904+ const nativeItemRef = {
905+ provider,
906+ nativeId: toolCall.toolCallId,
907+ strength: "strong" as const,
908+ };
909+ const startedAt = context.toolStartedAt.get(toolCall.toolCallId) ?? now;
910+ context.toolStartedAt.set(toolCall.toolCallId, startedAt);
911+ const completedAt = completedAtForStatus(status, now);
912+ const title = toolCall.title ?? null;
913+ yield* emitProviderEvent({
914+ type: "node.updated",
915+ provider,
916+ node: {
917+ id: nodeId,
918+ threadId: context.input.threadId,
919+ runId: context.input.runId,
920+ parentNodeId: context.input.rootNodeId,
921+ rootNodeId: context.input.rootNodeId,
922+ kind: "tool_call",
923+ status: nodeStatus(status),
924+ countsForRun: true,
925+ providerThreadId: context.input.providerThread.id,
926+ providerTurnId: context.providerTurnId,
927+ nativeItemRef,
928+ runtimeRequestId: null,
929+ checkpointScopeId: null,
930+ startedAt,
931+ completedAt,
932+ },
933+ });
934+
935+ const base = {
936+ id: turnItemId,
937+ threadId: context.input.threadId,
938+ runId: context.input.runId,
939+ nodeId,
940+ providerThreadId: context.input.providerThread.id,
941+ providerTurnId: context.providerTurnId,
942+ nativeItemRef,
943+ parentItemId: null,
944+ ordinal,
945+ status,
946+ title,
947+ startedAt,
948+ completedAt,
949+ updatedAt: now,
950+ } as const;
951+ const rawInput = toolCall.data.rawInput;
952+ const rawOutput = toolCall.data.rawOutput ?? toolCall.data.content;
953+ const path = pathFromToolCall(toolCall);
954+ let turnItem: OrchestrationV2TurnItem;
955+ switch (toolCall.kind) {
956+ case "read":
957+ case "search":
958+ turnItem = {
959+ ...base,
960+ type: "file_search",
961+ ...(path === undefined ? {} : { pattern: path }),
962+ ...(path === undefined
963+ ? {}
964+ : {
965+ results: [
966+ {
967+ fileName: path,
968+ ...(textFromUnknown(rawOutput) === undefined
969+ ? {}
970+ : { preview: textFromUnknown(rawOutput) }),
971+ },
972+ ],
973+ }),
974+ };
975+ break;
976+ case "execute":
977+ turnItem = {
978+ ...base,
979+ type: "command_execution",
980+ input: toolCall.command ?? toolCall.title ?? "Command",
981+ ...(textFromUnknown(rawOutput) === undefined
982+ ? {}
983+ : { output: textFromUnknown(rawOutput) }),
984+ ...(commandExitCode(rawOutput) === undefined
985+ ? {}
986+ : { exitCode: commandExitCode(rawOutput) }),
987+ };
988+ break;
989+ case "edit":
990+ case "delete":
991+ case "move":
992+ turnItem = {
993+ ...base,
994+ type: "file_change",
995+ fileName: path ?? toolCall.title ?? "File change",
996+ ...(textFromUnknown(rawOutput) === undefined
997+ ? {}
998+ : { diffStr: textFromUnknown(rawOutput) }),
999+ };
1000+ break;
1001+ case "fetch":
1002+ turnItem = {
1003+ ...base,
1004+ type: "web_search",
1005+ ...(path === undefined ? {} : { patterns: [path] }),
1006+ ...(path === undefined
1007+ ? {}
1008+ : {
1009+ results: [
1010+ {
1011+ url: path,
1012+ ...(textFromUnknown(rawOutput) === undefined
1013+ ? {}
1014+ : { snippet: textFromUnknown(rawOutput) }),
1015+ },
1016+ ],
1017+ }),
1018+ };
1019+ break;
1020+ default:
1021+ turnItem = {
1022+ ...base,
1023+ type: "dynamic_tool",
1024+ toolName: toolCall.title ?? toolCall.kind ?? null,
1025+ input: rawInput ?? {},
1026+ ...(rawOutput === undefined ? {} : { output: rawOutput }),
1027+ };
1028+ }
1029+ yield* emitProviderEvent({ type: "turn_item.updated", provider, turnItem });
1030+ });
1031+
1032+ const emitPlan = Effect.fnUntraced(function* (
1033+ context: ActiveAcpTurn,
1034+ update: AcpPlanUpdate,
1035+ ) {
1036+ yield* closeTextStreams(context);
1037+ const nativeItemId = `${context.nativeTurnId}:plan`;
1038+ const ordinal = yield* resolveItemOrdinal(context, nativeItemId);
1039+ const now = yield* DateTime.now;
1040+ const nodeId = idAllocator.derive.nodeFromProviderItem({ provider, nativeItemId });
1041+ const turnItemId = idAllocator.derive.turnItemFromProviderItem({
1042+ provider,
1043+ nativeItemId,
1044+ });
1045+ if (context.plan === null) {
1046+ context.plan = {
1047+ id: yield* idAllocator.allocate.plan({
1048+ threadId: context.input.threadId,
1049+ runId: context.input.runId,
1050+ provider,
1051+ }),
1052+ startedAt: now,
1053+ };
1054+ }
1055+ const planId = context.plan.id;
1056+ const steps: ReadonlyArray<OrchestrationV2PlanStep> = update.plan.map((step, index) => ({
1057+ id: `acp-step-${index + 1}`,
1058+ text: nonEmptyText(step.step, `Step ${index + 1}`),
1059+ status:
1060+ step.status === "inProgress"
1061+ ? "running"
1062+ : step.status === "completed"
1063+ ? "completed"
1064+ : "pending",
1065+ }));
1066+ const completed = steps.length > 0 && steps.every((step) => step.status === "completed");
1067+ const nativeItemRef = { provider, nativeId: nativeItemId, strength: "weak" as const };
1068+ const plan: OrchestrationV2PlanArtifact = {
1069+ id: planId,
1070+ threadId: context.input.threadId,
1071+ runId: context.input.runId,
1072+ nodeId,
1073+ status: completed ? "completed" : "active",
1074+ kind: "todo_list",
1075+ steps,
1076+ ...(update.explanation == null ? {} : { explanation: update.explanation }),
1077+ };
1078+ yield* emitProviderEvent({
1079+ type: "node.updated",
1080+ provider,
1081+ node: {
1082+ id: nodeId,
1083+ threadId: context.input.threadId,
1084+ runId: context.input.runId,
1085+ parentNodeId: context.input.rootNodeId,
1086+ rootNodeId: context.input.rootNodeId,
1087+ kind: "todo_list",
1088+ status: completed ? "completed" : "running",
1089+ countsForRun: false,
1090+ providerThreadId: context.input.providerThread.id,
1091+ providerTurnId: context.providerTurnId,
1092+ nativeItemRef,
1093+ runtimeRequestId: null,
1094+ checkpointScopeId: null,
1095+ startedAt: context.plan.startedAt,
1096+ completedAt: completed ? now : null,
1097+ },
1098+ });
1099+ yield* emitProviderEvent({ type: "plan.updated", provider, plan });
1100+ yield* emitProviderEvent({
1101+ type: "turn_item.updated",
1102+ provider,
1103+ turnItem: {
1104+ id: turnItemId,
1105+ threadId: context.input.threadId,
1106+ runId: context.input.runId,
1107+ nodeId,
1108+ providerThreadId: context.input.providerThread.id,
1109+ providerTurnId: context.providerTurnId,
1110+ nativeItemRef,
1111+ parentItemId: null,
1112+ ordinal,
1113+ status: completed ? "completed" : "running",
1114+ title: null,
1115+ startedAt: context.plan.startedAt,
1116+ completedAt: completed ? now : null,
1117+ updatedAt: now,
1118+ type: "todo_list",
1119+ planId,
1120+ steps,
1121+ ...(update.explanation == null ? {} : { explanation: update.explanation }),
1122+ },
1123+ });
1124+ });
1125+
1126+ const appendLoadedHistory = (
1127+ notification: EffectAcpSchema.SessionNotification,
1128+ role: "user" | "assistant",
1129+ text: string,
1130+ ) =>
1131+ Effect.gen(function* () {
1132+ if (text.length === 0) return;
1133+ const now = yield* DateTime.now;
1134+ yield* Ref.update(snapshot, (current) => {
1135+ const startsNew = current.loadingRole !== role;
1136+ const loadingIndex = startsNew ? current.loadingIndex + 1 : current.loadingIndex;
1137+ const nativeItemId = `${notification.sessionId}:history:${role}:${loadingIndex}`;
1138+ const messageId = idAllocator.derive.messageFromProviderItem({
1139+ provider,
1140+ nativeItemId,
1141+ });
1142+ const key = String(messageId);
1143+ const previous = current.messages.get(key);
1144+ const messages = new Map(current.messages);
1145+ messages.set(key, {
1146+ id: messageId,
1147+ threadId: input.threadId,
1148+ runId: null,
1149+ nodeId: null,
1150+ role,
1151+ text: `${previous?.text ?? ""}${text}`,
1152+ attachments: [],
1153+ streaming: false,
1154+ createdAt: previous?.createdAt ?? now,
1155+ updatedAt: now,
1156+ });
1157+ return {
1158+ order: current.order.includes(key) ? current.order : [...current.order, key],
1159+ messages,
1160+ loadingRole: role,
1161+ loadingIndex,
1162+ };
1163+ });
1164+ });
1165+
1166+ const handleSessionUpdate = Effect.fnUntraced(function* (
1167+ notification: EffectAcpSchema.SessionNotification,
1168+ ) {
1169+ const context = yield* Ref.get(activeTurn);
1170+ const update = notification.update;
1171+ if (context === null) {
1172+ if (
1173+ (update.sessionUpdate === "user_message_chunk" ||
1174+ update.sessionUpdate === "agent_message_chunk") &&
1175+ update.content.type === "text"
1176+ ) {
1177+ yield* appendLoadedHistory(
1178+ notification,
1179+ update.sessionUpdate === "user_message_chunk" ? "user" : "assistant",
1180+ update.content.text,
1181+ );
1182+ } else if (
1183+ update.sessionUpdate === "tool_call" ||
1184+ update.sessionUpdate === "tool_call_update" ||
1185+ update.sessionUpdate === "plan"
1186+ ) {
1187+ yield* Ref.update(snapshot, (current) => ({ ...current, loadingRole: null }));
1188+ }
1189+ return;
1190+ }
1191+ if (context.finalized) return;
1192+ if (notification.sessionId !== (yield* Ref.get(activeSessionId))) return;
1193+ switch (update.sessionUpdate) {
1194+ case "agent_message_chunk":
1195+ if (update.content.type === "text") {
1196+ yield* appendText(context, "assistant", update.content.text);
1197+ }
1198+ return;
1199+ case "agent_thought_chunk":
1200+ if (update.content.type === "text") {
1201+ yield* appendText(context, "reasoning", update.content.text);
1202+ }
1203+ return;
1204+ default: {
1205+ const parsed = parseSessionUpdateEvent(notification);
1206+ for (const event of parsed.events) {
1207+ if (event._tag === "ToolCallUpdated") {
1208+ yield* emitTool(context, event.toolCall);
1209+ } else if (event._tag === "PlanUpdated") {
1210+ yield* emitPlan(context, event.payload);
1211+ }
1212+ }
1213+ }
1214+ }
1215+ });
1216+
1217+ yield* runtime.handleSessionUpdate((notification) =>
1218+ handleSessionUpdate(notification).pipe(
1219+ Effect.mapError(
1220+ (cause) =>
1221+ new EffectAcpErrors.AcpTransportError({
1222+ detail: "Failed to project an ACP session update",
1223+ cause,
1224+ }),
1225+ ),
1226+ ),
1227+ );
1228+
1229+ const activeContext = Effect.gen(function* () {
1230+ const context = yield* Ref.get(activeTurn);
1231+ if (context === null) {
1232+ return yield* new EffectAcpErrors.AcpTransportError({
1233+ detail: "ACP agent requested input without an active turn",
1234+ cause: "No active ACP turn",
1235+ });
1236+ }
1237+ return context;
1238+ });
1239+
1240+ const emitApprovalRequest = Effect.fnUntraced(function* (
1241+ context: ActiveAcpTurn,
1242+ params: EffectAcpSchema.RequestPermissionRequest,
1243+ ) {
1244+ yield* closeTextStreams(context);
1245+ const parsed = parsePermissionRequest(params);
1246+ const nativeRequestId = params.toolCall.toolCallId;
1247+ const requestId = yield* idAllocator.allocate.runtimeRequest({
1248+ provider,
1249+ providerTurnId: context.providerTurnId,
1250+ nativeRequestId,
1251+ });
1252+ const decision = yield* Deferred.make<ProviderApprovalDecision>();
1253+ const now = yield* DateTime.now;
1254+ const nodeId = idAllocator.derive.approvalNode({ requestId });
1255+ const requestKind = providerRequestKind(parsed.kind);
1256+ const nativeItemRef = { provider, nativeId: nativeRequestId, strength: "weak" as const };
1257+ const ordinal = yield* resolveItemOrdinal(
1258+ context,
1259+ `${context.nativeTurnId}:approval:${nativeRequestId}`,
1260+ );
1261+ const runtimeRequest: OrchestrationV2RuntimeRequest = {
1262+ id: requestId,
1263+ nodeId,
1264+ providerTurnId: context.providerTurnId,
1265+ nativeRequestRef: nativeItemRef,
1266+ kind: requestKind,
1267+ status: "pending",
1268+ responseCapability: {
1269+ type: "live",
1270+ providerSessionId: input.providerSessionId,
1271+ },
1272+ createdAt: now,
1273+ resolvedAt: null,
1274+ };
1275+ const node: OrchestrationV2ExecutionNode = {
1276+ id: nodeId,
1277+ threadId: context.input.threadId,
1278+ runId: context.input.runId,
1279+ parentNodeId: context.input.rootNodeId,
1280+ rootNodeId: context.input.rootNodeId,
1281+ kind: "approval_request",
1282+ status: "waiting",
1283+ countsForRun: false,
1284+ providerThreadId: context.input.providerThread.id,
1285+ providerTurnId: context.providerTurnId,
1286+ nativeItemRef,
1287+ runtimeRequestId: requestId,
1288+ checkpointScopeId: null,
1289+ startedAt: now,
1290+ completedAt: null,
1291+ };
1292+ const turnItem: OrchestrationV2TurnItem = {
1293+ id: idAllocator.derive.approvalTurnItem({ requestId }),
1294+ threadId: context.input.threadId,
1295+ runId: context.input.runId,
1296+ nodeId,
1297+ providerThreadId: context.input.providerThread.id,
1298+ providerTurnId: context.providerTurnId,
1299+ nativeItemRef,
1300+ parentItemId: null,
1301+ ordinal,
1302+ status: "waiting",
1303+ title: null,
1304+ startedAt: now,
1305+ completedAt: null,
1306+ updatedAt: now,
1307+ type: "approval_request",
1308+ requestId,
1309+ requestKind,
1310+ ...(parsed.detail === undefined ? {} : { prompt: parsed.detail }),
1311+ };
1312+ yield* Ref.update(pendingRuntimeRequests, (current) => {
1313+ const updated = new Map(current);
1314+ updated.set(String(requestId), {
1315+ type: "approval",
1316+ requestId,
1317+ decision,
1318+ runtimeRequest,
1319+ node,
1320+ turnItem,
1321+ });
1322+ return updated;
1323+ });
1324+ yield* emitProviderEvent({
1325+ type: "node.updated",
1326+ provider,
1327+ node,
1328+ });
1329+ yield* emitProviderEvent({
1330+ type: "runtime_request.updated",
1331+ provider,
1332+ threadId: context.input.threadId,
1333+ runtimeRequest,
1334+ });
1335+ yield* emitProviderEvent({
1336+ type: "turn_item.updated",
1337+ provider,
1338+ turnItem,
1339+ });
1340+ const resolved = yield* Deferred.await(decision).pipe(
1341+ Effect.ensuring(
1342+ Ref.update(pendingRuntimeRequests, (current) => {
1343+ const updated = new Map(current);
1344+ updated.delete(String(requestId));
1345+ return updated;
1346+ }),
1347+ ),
1348+ );
1349+ return resolved;
1350+ });
1351+
1352+ yield* runtime.handleRequestPermission((params) =>
1353+ Effect.gen(function* () {
1354+ const context = yield* activeContext;
1355+ const disposition = acpPermissionDisposition(context.input.runtimePolicy, params);
1356+ if (disposition === "allow") {
1357+ const optionId = selectAutoApprovedPermissionOption(params);
1358+ return optionId === undefined
1359+ ? ({ outcome: { outcome: "cancelled" } } as const)
1360+ : ({ outcome: { outcome: "selected", optionId } } as const);
1361+ }
1362+ if (disposition === "deny") {
1363+ const optionId = selectPermissionOptionId(params, "decline");
1364+ return optionId === undefined
1365+ ? ({ outcome: { outcome: "cancelled" } } as const)
1366+ : ({ outcome: { outcome: "selected", optionId } } as const);
1367+ }
1368+ const decision = yield* emitApprovalRequest(context, params);
1369+ if (decision === "cancel") {
1370+ return { outcome: { outcome: "cancelled" } } as const;
1371+ }
1372+ const optionId = selectPermissionOptionId(params, decision);
1373+ return optionId === undefined
1374+ ? ({ outcome: { outcome: "cancelled" } } as const)
1375+ : ({ outcome: { outcome: "selected", optionId } } as const);
1376+ }).pipe(
1377+ Effect.mapError(
1378+ (cause) =>
1379+ new EffectAcpErrors.AcpTransportError({
1380+ detail: "Failed to handle an ACP permission request",
1381+ cause,
1382+ }),
1383+ ),
1384+ ),
1385+ );
1386+
1387+ const requestUserInputInternal = Effect.fnUntraced(function* (
1388+ request: AcpAdapterV2UserInputRequest,
1389+ ) {
1390+ const context = yield* activeContext;
1391+ yield* closeTextStreams(context);
1392+ const requestId = yield* idAllocator.allocate.runtimeRequest({
1393+ provider,
1394+ providerTurnId: context.providerTurnId,
1395+ nativeRequestId: request.nativeRequestId,
1396+ });
1397+ const answers = yield* Deferred.make<ProviderUserInputAnswers | null>();
1398+ const now = yield* DateTime.now;
1399+ const nodeId = idAllocator.derive.nodeFromProviderItem({
1400+ provider,
1401+ nativeItemId: request.nativeItemId,
1402+ });
1403+ const turnItemId = idAllocator.derive.turnItemFromProviderItem({
1404+ provider,
1405+ nativeItemId: request.nativeItemId,
1406+ });
1407+ const nativeItemRef = {
1408+ provider,
1409+ nativeId: request.nativeItemId,
1410+ strength: "weak" as const,
1411+ };
1412+ const ordinal = yield* resolveItemOrdinal(context, request.nativeItemId);
1413+ const runtimeRequest: OrchestrationV2RuntimeRequest = {
1414+ id: requestId,
1415+ nodeId,
1416+ providerTurnId: context.providerTurnId,
1417+ nativeRequestRef: {
1418+ provider,
1419+ nativeId: request.nativeRequestId,
1420+ strength: "weak",
1421+ },
1422+ kind: "user_input",
1423+ status: "pending",
1424+ responseCapability: {
1425+ type: "live",
1426+ providerSessionId: input.providerSessionId,
1427+ },
1428+ createdAt: now,
1429+ resolvedAt: null,
1430+ };
1431+ const node: OrchestrationV2ExecutionNode = {
1432+ id: nodeId,
1433+ threadId: context.input.threadId,
1434+ runId: context.input.runId,
1435+ parentNodeId: context.input.rootNodeId,
1436+ rootNodeId: context.input.rootNodeId,
1437+ kind: "user_input_request",
1438+ status: "waiting",
1439+ countsForRun: false,
1440+ providerThreadId: context.input.providerThread.id,
1441+ providerTurnId: context.providerTurnId,
1442+ nativeItemRef,
1443+ runtimeRequestId: requestId,
1444+ checkpointScopeId: null,
1445+ startedAt: now,
1446+ completedAt: null,
1447+ };
1448+ const turnItem: OrchestrationV2TurnItem = {
1449+ id: turnItemId,
1450+ threadId: context.input.threadId,
1451+ runId: context.input.runId,
1452+ nodeId,
1453+ providerThreadId: context.input.providerThread.id,
1454+ providerTurnId: context.providerTurnId,
1455+ nativeItemRef,
1456+ parentItemId: null,
1457+ ordinal,
1458+ status: "waiting",
1459+ title: null,
1460+ startedAt: now,
1461+ completedAt: null,
1462+ updatedAt: now,
1463+ type: "user_input_request",
1464+ requestId,
1465+ questions: [...request.questions],
1466+ };
1467+ yield* Ref.update(pendingRuntimeRequests, (current) => {
1468+ const updated = new Map(current);
1469+ updated.set(String(requestId), {
1470+ type: "user_input",
1471+ requestId,
1472+ answers,
1473+ runtimeRequest,
1474+ node,
1475+ turnItem,
1476+ });
1477+ return updated;
1478+ });
1479+ yield* emitProviderEvent({
1480+ type: "node.updated",
1481+ provider,
1482+ node,
1483+ });
1484+ yield* emitProviderEvent({
1485+ type: "runtime_request.updated",
1486+ provider,
1487+ threadId: context.input.threadId,
1488+ runtimeRequest,
1489+ });
1490+ yield* emitProviderEvent({
1491+ type: "turn_item.updated",
1492+ provider,
1493+ turnItem,
1494+ });
1495+ return yield* Deferred.await(answers).pipe(
1496+ Effect.ensuring(
1497+ Ref.update(pendingRuntimeRequests, (current) => {
1498+ const updated = new Map(current);
1499+ updated.delete(String(requestId));
1500+ return updated;
1501+ }),
1502+ ),
1503+ );
1504+ });
1505+
1506+ const requestUserInput = (request: AcpAdapterV2UserInputRequest) =>
1507+ requestUserInputInternal(request).pipe(
1508+ Effect.mapError(
1509+ (cause) =>
1510+ new EffectAcpErrors.AcpTransportError({
1511+ detail: "Failed to handle ACP user input request",
1512+ cause,
1513+ }),
1514+ ),
1515+ );
1516+
1517+ const cancelPendingRuntimeRequests = Effect.fnUntraced(function* () {
1518+ const requests = [...(yield* Ref.get(pendingRuntimeRequests)).values()];
1519+ if (requests.length === 0) return;
1520+
1521+ const now = yield* DateTime.now;
1522+ yield* Effect.forEach(
1523+ requests,
1524+ (request) =>
1525+ Effect.gen(function* () {
1526+ const cancelled = yield* request.type === "approval"
1527+ ? Deferred.succeed(request.decision, "cancel")
1528+ : Deferred.succeed(request.answers, null);
1529+ if (!cancelled) return;
1530+
1531+ yield* emitProviderEvent({
1532+ type: "runtime_request.updated",
1533+ provider,
1534+ threadId: request.node.threadId,
1535+ runtimeRequest: {
1536+ ...request.runtimeRequest,
1537+ status: "cancelled",
1538+ resolvedAt: now,
1539+ },
1540+ });
1541+ yield* emitProviderEvent({
1542+ type: "node.updated",
1543+ provider,
1544+ node: {
1545+ ...request.node,
1546+ status: "cancelled",
1547+ completedAt: now,
1548+ },
1549+ });
1550+ yield* emitProviderEvent({
1551+ type: "turn_item.updated",
1552+ provider,
1553+ turnItem: {
1554+ ...request.turnItem,
1555+ status: "cancelled",
1556+ completedAt: now,
1557+ updatedAt: now,
1558+ },
1559+ });
1560+ }),
1561+ { concurrency: 1, discard: true },
1562+ );
1563+ });
1564+
1565+ yield* runtime.handleElicitation((params) =>
1566+ Effect.gen(function* () {
1567+ if (params.mode === "url") {
1568+ return { action: { action: "decline" } } as const;
1569+ }
1570+ const questions = Object.entries(params.requestedSchema.properties ?? {}).map(
1571+ ([id, property], index): OrchestrationV2UserInputQuestion => {
1572+ const record = unknownRecord(property);
1573+ const enumValues = Array.isArray(record?.enum)
1574+ ? record.enum.filter((value): value is string => typeof value === "string")
1575+ : [];
1576+ const options =
1577+ enumValues.length > 0
1578+ ? enumValues.map((value) => ({ label: value, description: value }))
1579+ : record?.type === "boolean"
1580+ ? [
1581+ { label: "true", description: "Yes" },
1582+ { label: "false", description: "No" },
1583+ ]
1584+ : [];
1585+ return {
1586+ id,
1587+ header: nonEmptyText(record?.title, `Question ${index + 1}`),
1588+ question: nonEmptyText(record?.description, params.message),
1589+ options,
1590+ };
1591+ },
1592+ );
1593+ const ordinal = yield* Ref.getAndUpdate(
1594+ nextElicitationOrdinal,
1595+ (current) => current + 1,
1596+ );
1597+ const nativeRequestId = `${params.sessionId}:elicitation:${ordinal}`;
1598+ const answers = yield* requestUserInput({
1599+ nativeItemId: nativeRequestId,
1600+ nativeRequestId,
1601+ questions,
1602+ });
1603+ return answers === null
1604+ ? ({ action: { action: "cancel" } } as const)
1605+ : ({ action: { action: "accept", content: elicitationContent(answers) } } as const);
1606+ }),
1607+ );
1608+
1609+ if (flavor.registerExtensions !== undefined) {
1610+ yield* flavor.registerExtensions({ runtime, requestUserInput });
1611+ }
1612+
1613+ const started = yield* runtime.start();
1614+ yield* Ref.set(activeSessionId, started.sessionId);
1615+ const capabilities = negotiatedCapabilities(flavor.capabilities, started);
1616+ const canLoadSession = started.initializeResult.agentCapabilities?.loadSession === true;
1617+ const canResumeSession =
1618+ started.initializeResult.agentCapabilities?.sessionCapabilities?.resume != null;
1619+ const supportsImagePrompts =
1620+ started.initializeResult.agentCapabilities?.promptCapabilities?.image === true;
1621+
1622+ const activateSession = Effect.fnUntraced(function* (sessionId: string) {
1623+ if (canLoadSession) {
1624+ return yield* runtime.loadSession(sessionId);
1625+ }
1626+ if (canResumeSession) {
1627+ return yield* runtime.resumeSession(sessionId);
1628+ }
1629+ return yield* new ProviderAdapterProtocolError({
1630+ provider,
1631+ detail: `ACP driver cannot load or resume session ${sessionId}`,
1632+ });
1633+ });
1634+
1635+ const configureSession = Effect.fnUntraced(function* (
1636+ startResult: AcpSessionRuntimeStartResult,
1637+ modelSelection: ModelSelection,
1638+ runtimePolicy: ProviderAdapterV2RuntimePolicy,
1639+ ) {
1640+ const requestedModel = flavor.resolveModelId?.(modelSelection) ?? modelSelection.model;
1641+ if (
1642+ requestedModel.length > 0 &&
1643+ requestedModel !== "auto" &&
1644+ requestedModel !== "default"
1645+ ) {
1646+ const currentModel = startResult.sessionSetupResult.models?.currentModelId;
1647+ if (currentModel !== requestedModel) {
1648+ if (startResult.sessionSetupResult.models != null) {
1649+ yield* runtime.setSessionModel(requestedModel);
1650+ } else if (
1651+ startResult.sessionSetupResult.configOptions?.some(
1652+ (option) => option.category === "model",
1653+ ) === true
1654+ ) {
1655+ yield* runtime.setModel(requestedModel);
1656+ }
1657+ }
1658+ }
1659+ const configOptions = yield* runtime.getConfigOptions;
1660+ for (const selection of modelSelection.options ?? []) {
1661+ if (configOptions.some((option) => option.id === selection.id)) {
1662+ yield* runtime.setConfigOption(selection.id, selection.value);
1663+ }
1664+ }
1665+ const modeState = yield* runtime.getModeState;
1666+ if (runtimePolicy.interactionMode === "plan" && modeState !== undefined) {
1667+ const planMode = modeState.availableModes.find(
1668+ (mode) => mode.id === "plan" || mode.id === "architect",
1669+ );
1670+ if (planMode !== undefined) yield* runtime.setMode(planMode.id);
1671+ }
1672+ });
1673+
1674+ yield* configureSession(started, input.modelSelection, input.runtimePolicy);
1675+ const createdAt = yield* DateTime.now;
1676+ const providerSession: OrchestrationV2ProviderSession = {
1677+ id: input.providerSessionId,
1678+ provider,
1679+ status: "ready",
1680+ cwd: input.runtimePolicy.cwd ?? process.cwd(),
1681+ model: input.modelSelection.model,
1682+ capabilities,
1683+ createdAt,
1684+ updatedAt: createdAt,
1685+ lastError: null,
1686+ };
1687+
1688+ const providerTurnPayload = (
1689+ context: ActiveAcpTurn,
1690+ status: OrchestrationV2ProviderTurn["status"],
1691+ completedAt: DateTime.Utc | null,
1692+ ): OrchestrationV2ProviderTurn => ({
1693+ id: context.providerTurnId,
1694+ providerThreadId: context.input.providerThread.id,
1695+ nodeId: context.input.rootNodeId,
1696+ runAttemptId: context.input.attemptId,
1697+ nativeTurnRef: {
1698+ provider,
1699+ nativeId: context.nativeTurnId,
1700+ strength: "weak",
1701+ },
1702+ ordinal: context.input.providerTurnOrdinal,
1703+ status,
1704+ startedAt: context.startedAt,
1705+ completedAt,
1706+ });
1707+
1708+ const finalizeTurn = Effect.fnUntraced(function* (
1709+ context: ActiveAcpTurn,
1710+ status: "completed" | "interrupted" | "failed" | "cancelled",
1711+ ) {
1712+ if (context.finalized) return;
1713+ context.finalized = true;
1714+ yield* closeTextStreams(context);
1715+ const now = yield* DateTime.now;
1716+ const turn = providerTurnPayload(context, status, now);
1717+ yield* Ref.update(providerTurns, (current) => {
1718+ const updated = new Map(current);
1719+ updated.set(String(turn.id), turn);
1720+ return updated;
1721+ });
1722+ yield* emitProviderEvent({
1723+ type: "provider_turn.updated",
1724+ provider,
1725+ threadId: context.input.threadId,
1726+ providerTurn: turn,
1727+ });
1728+ yield* emitProviderEvent({
1729+ type: "provider_thread.updated",
1730+ provider,
1731+ providerThread: {
1732+ ...context.input.providerThread,
1733+ providerSessionId: input.providerSessionId,
1734+ status: "idle",
1735+ lastRunOrdinal: context.input.runOrdinal,
1736+ firstRunOrdinal:
1737+ context.input.providerThread.firstRunOrdinal ?? context.input.runOrdinal,
1738+ updatedAt: now,
1739+ },
1740+ });
1741+ yield* emitProviderEvent({
1742+ type: "turn.terminal",
1743+ provider,
1744+ providerTurnId: context.providerTurnId,
1745+ status,
1746+ });
1747+ yield* Ref.set(activeTurn, null);
1748+ yield* Deferred.succeed(context.completed, undefined).pipe(Effect.ignore);
1749+ });
1750+
1751+ const resolvePromptParts = Effect.fnUntraced(function* (
1752+ turnInput: ProviderAdapterV2TurnInput,
1753+ ) {
1754+ const prompt: Array<EffectAcpSchema.ContentBlock> = [];
1755+ if (turnInput.message.text.length > 0) {
1756+ prompt.push({ type: "text", text: turnInput.message.text });
1757+ }
1758+ if (turnInput.message.attachments.length > 0 && !supportsImagePrompts) {
1759+ return yield* new ProviderAdapterProtocolError({
1760+ provider,
1761+ detail: "ACP driver did not negotiate image prompt support",
1762+ });
1763+ }
1764+ for (const attachment of turnInput.message.attachments) {
1765+ const path = resolveAttachmentPath({
1766+ attachmentsDir: serverConfig.attachmentsDir,
1767+ attachment: attachment as ChatAttachment,
1768+ });
1769+ if (path === null) {
1770+ return yield* new ProviderAdapterProtocolError({
1771+ provider,
1772+ detail: `Invalid attachment id '${attachment.id}'`,
1773+ });
1774+ }
1775+ const bytes = yield* fileSystem.readFile(path).pipe(
1776+ Effect.mapError(
1777+ (cause) =>
1778+ new ProviderAdapterProtocolError({
1779+ provider,
1780+ detail: `Failed to read attachment '${attachment.id}'`,
1781+ payload: cause,
1782+ }),
1783+ ),
1784+ );
1785+ prompt.push({
1786+ type: "image",
1787+ data: Buffer.from(bytes).toString("base64"),
1788+ mimeType: attachment.mimeType,
1789+ });
1790+ }
1791+ if (prompt.length === 0) {
1792+ return yield* new ProviderAdapterProtocolError({
1793+ provider,
1794+ detail: "ACP turn requires non-empty text or attachments",
1795+ });
1796+ }
1797+ return prompt;
1798+ });
1799+
1800+ const startTurn = Effect.fn("AcpAdapterV2.startTurn")(
1801+ function* (turnInput: ProviderAdapterV2TurnInput) {
1802+ const existing = yield* Ref.get(activeTurn);
1803+ if (existing !== null) {
1804+ return yield* new ProviderAdapterProtocolError({
1805+ provider,
1806+ detail: `ACP provider turn ${existing.providerTurnId} is still active`,
1807+ });
1808+ }
1809+ const requestedSessionId = nativeThreadId(provider, turnInput.providerThread);
1810+ if ((yield* Ref.get(activeSessionId)) !== requestedSessionId) {
1811+ const activated = yield* activateSession(requestedSessionId);
1812+ yield* Ref.set(activeSessionId, activated.sessionId);
1813+ yield* configureSession(activated, turnInput.modelSelection, turnInput.runtimePolicy);
1814+ }
1815+ const prompt = yield* resolvePromptParts(turnInput);
1816+ const startedAt = yield* DateTime.now;
1817+ const nativeTurnId = `${requestedSessionId}:turn:${turnInput.providerTurnOrdinal}`;
1818+ const providerTurnId = idAllocator.derive.providerTurn({ provider, nativeTurnId });
1819+ const completed = yield* Deferred.make<void, never>();
1820+ const context: ActiveAcpTurn = {
1821+ input: turnInput,
1822+ providerTurnId,
1823+ nativeTurnId,
1824+ startedAt,
1825+ completed,
1826+ assistant: { current: null, nextSegment: 0 },
1827+ reasoning: { current: null, nextSegment: 0 },
1828+ tools: new Map(),
1829+ toolStartedAt: new Map(),
1830+ plan: null,
1831+ interrupted: false,
1832+ finalized: false,
1833+ };
1834+ yield* Ref.set(activeTurn, context);
1835+ const runningTurn = providerTurnPayload(context, "running", null);
1836+ yield* Ref.update(providerTurns, (current) => {
1837+ const updated = new Map(current);
1838+ updated.set(String(runningTurn.id), runningTurn);
1839+ return updated;
1840+ });
1841+ yield* emitProviderEvent({
1842+ type: "provider_turn.updated",
1843+ provider,
1844+ threadId: turnInput.threadId,
1845+ providerTurn: runningTurn,
1846+ });
1847+ yield* emitProviderEvent({
1848+ type: "provider_thread.updated",
1849+ provider,
1850+ providerThread: {
1851+ ...turnInput.providerThread,
1852+ providerSessionId: input.providerSessionId,
1853+ status: "active",
1854+ updatedAt: startedAt,
1855+ },
1856+ });
1857+ yield* rememberSnapshotMessage({
1858+ id: turnInput.message.messageId,
1859+ threadId: turnInput.threadId,
1860+ runId: turnInput.runId,
1861+ nodeId: turnInput.rootNodeId,
1862+ role: "user",
1863+ text: turnInput.message.text,
1864+ attachments: [...turnInput.message.attachments],
1865+ streaming: false,
1866+ createdAt: startedAt,
1867+ updatedAt: startedAt,
1868+ });
1869+ yield* runtime.prompt({ prompt }).pipe(
1870+ Effect.flatMap((result) => {
1871+ const status =
1872+ result.stopReason === "cancelled"
1873+ ? context.interrupted
1874+ ? "interrupted"
1875+ : "cancelled"
1876+ : "completed";
1877+ return finalizeTurn(context, status);
1878+ }),
1879+ Effect.catchCause((cause) =>
1880+ finalizeTurn(context, context.interrupted ? "interrupted" : "failed").pipe(
1881+ Effect.andThen(
1882+ Effect.logWarning("orchestration-v2.acp-prompt-failed", {
1883+ provider,
1884+ providerSessionId: input.providerSessionId,
1885+ providerThreadId: turnInput.providerThread.id,
1886+ providerTurnId,
1887+ cause,
1888+ }),
1889+ ),
1890+ ),
1891+ ),
1892+ Effect.forkIn(sessionScope),
1893+ );
1894+ },
1895+ (effect, turnInput) =>
1896+ effect.pipe(
1897+ Effect.mapError(
1898+ (cause) =>
1899+ new ProviderAdapterTurnStartError({
1900+ provider,
1901+ threadId: turnInput.threadId,
1902+ providerThreadId: turnInput.providerThread.id,
1903+ runId: turnInput.runId,
1904+ cause,
1905+ }),
1906+ ),
1907+ ),
1908+ );
1909+
1910+ yield* Effect.addFinalizer(() =>
1911+ Effect.gen(function* () {
1912+ const requests = [...(yield* Ref.get(pendingRuntimeRequests)).values()];
1913+ yield* Effect.forEach(
1914+ requests,
1915+ (request) =>
1916+ request.type === "approval"
1917+ ? Deferred.succeed(request.decision, "cancel").pipe(Effect.ignore)
1918+ : Deferred.succeed(request.answers, null).pipe(Effect.ignore),
1919+ { discard: true },
1920+ );
1921+ const sessionCapabilities =
1922+ started.initializeResult.agentCapabilities?.sessionCapabilities;
1923+ if (sessionCapabilities?.close != null) {
1924+ yield* runtime.closeSession().pipe(Effect.ignore);
1925+ }
1926+ if (flavor.assertComplete !== undefined) {
1927+ yield* flavor.assertComplete.pipe(Effect.orDie);
1928+ }
1929+ }),
1930+ );
1931+
1932+ const sessionRuntime: ProviderAdapterV2SessionRuntime = {
1933+ instanceId: options.instanceId,
1934+ provider,
1935+ providerSessionId: input.providerSessionId,
1936+ providerSession,
1937+ rawEvents: Stream.fromEffectRepeat(Queue.take(rawEvents)),
1938+ events: Stream.fromEffectRepeat(Queue.take(events)),
1939+ ensureThread: Effect.fn("AcpAdapterV2.ensureThread")(
1940+ function* (threadInput: ProviderAdapterV2EnsureThreadInput) {
1941+ const now = yield* DateTime.now;
1942+ const sessionId = yield* Ref.get(activeSessionId);
1943+ if (sessionId === null) {
1944+ return yield* new ProviderAdapterProtocolError({
1945+ provider,
1946+ detail: "ACP runtime did not produce a session id",
1947+ });
1948+ }
1949+ return makeProviderThread({
1950+ provider,
1951+ idAllocator,
1952+ appThreadId: threadInput.threadId,
1953+ providerSessionId: input.providerSessionId,
1954+ nativeThreadId: sessionId,
1955+ now,
1956+ });
1957+ },
1958+ (effect, threadInput) =>
1959+ effect.pipe(
1960+ Effect.mapError(
1961+ (cause) =>
1962+ new ProviderAdapterEnsureThreadError({
1963+ provider,
1964+ threadId: threadInput.threadId,
1965+ cause,
1966+ }),
1967+ ),
1968+ ),
1969+ ),
1970+ resumeThread: Effect.fn("AcpAdapterV2.resumeThread")(
1971+ function* (threadInput: { readonly providerThread: OrchestrationV2ProviderThread }) {
1972+ const sessionId = nativeThreadId(provider, threadInput.providerThread);
1973+ if ((yield* Ref.get(activeSessionId)) !== sessionId) {
1974+ yield* Ref.set(snapshot, {
1975+ order: [],
1976+ messages: new Map(),
1977+ loadingRole: null,
1978+ loadingIndex: 0,
1979+ });
1980+ const activated = yield* activateSession(sessionId);
1981+ yield* Ref.set(activeSessionId, activated.sessionId);
1982+ yield* configureSession(activated, input.modelSelection, input.runtimePolicy);
1983+ }
1984+ const now = yield* DateTime.now;
1985+ return {
1986+ ...threadInput.providerThread,
1987+ providerSessionId: input.providerSessionId,
1988+ status: "idle" as const,
1989+ updatedAt: now,
1990+ };
1991+ },
1992+ (effect, threadInput) =>
1993+ effect.pipe(
1994+ Effect.mapError(
1995+ (cause) =>
1996+ new ProviderAdapterResumeThreadError({
1997+ provider,
1998+ providerSessionId: input.providerSessionId,
1999+ providerThreadId: threadInput.providerThread.id,
2000+ cause,
2001+ }),
2002+ ),
2003+ ),
2004+ ),
2005+ startTurn,
2006+ steerTurn: (turnInput) =>
2007+ Effect.fail(
2008+ new ProviderAdapterSteerRunUnsupportedError({
2009+ provider,
2010+ providerThreadId: turnInput.providerThread.id,
2011+ }),
2012+ ),
2013+ interruptTurn: Effect.fn("AcpAdapterV2.interruptTurn")(
2014+ function* (turnInput: ProviderAdapterV2InterruptInput) {
2015+ const context = yield* Ref.get(activeTurn);
2016+ if (context?.providerTurnId !== turnInput.providerTurnId) {
2017+ return yield* new ProviderAdapterProtocolError({
2018+ provider,
2019+ detail: `ACP provider turn ${turnInput.providerTurnId} is not active`,
2020+ });
2021+ }
2022+ context.interrupted = true;
2023+ yield* runtime.cancel.pipe(Effect.ensuring(cancelPendingRuntimeRequests()));
2024+ const stopped = yield* Deferred.await(context.completed).pipe(
2025+ Effect.timeoutOption("10 seconds"),
2026+ );
2027+ if (Option.isNone(stopped)) {
2028+ return yield* new ProviderAdapterProtocolError({
2029+ provider,
2030+ detail: `ACP provider turn ${turnInput.providerTurnId} did not acknowledge cancellation before the interrupt timeout`,
2031+ });
2032+ }
2033+ },
2034+ (effect, turnInput) =>
2035+ effect.pipe(
2036+ Effect.mapError(
2037+ (cause) =>
2038+ new ProviderAdapterInterruptError({
2039+ provider,
2040+ providerThreadId: turnInput.providerThread.id,
2041+ providerTurnId: turnInput.providerTurnId,
2042+ cause,
2043+ }),
2044+ ),
2045+ ),
2046+ ),
2047+ respondToRuntimeRequest: (requestInput) =>
2048+ Effect.gen(function* () {
2049+ const pending = (yield* Ref.get(pendingRuntimeRequests)).get(
2050+ String(requestInput.requestId),
2051+ );
2052+ if (pending === undefined) {
2053+ return yield* new ProviderAdapterProtocolError({
2054+ provider,
2055+ detail: `No pending ACP runtime request ${requestInput.requestId}`,
2056+ });
2057+ }
2058+ if (pending.type === "user_input") {
2059+ yield* Deferred.succeed(pending.answers, requestInput.answers ?? null);
2060+ return;
2061+ }
2062+ if (requestInput.decision === undefined) {
2063+ return yield* new ProviderAdapterProtocolError({
2064+ provider,
2065+ detail: `ACP approval request ${requestInput.requestId} requires a decision`,
2066+ });
2067+ }
2068+ yield* Deferred.succeed(pending.decision, requestInput.decision);
2069+ }).pipe(
2070+ Effect.mapError(
2071+ (cause) =>
2072+ new ProviderAdapterRuntimeRequestResponseError({
2073+ provider,
2074+ requestId: requestInput.requestId,
2075+ cause,
2076+ }),
2077+ ),
2078+ ),
2079+ readThreadSnapshot: Effect.fn("AcpAdapterV2.readThreadSnapshot")(
2080+ function* (snapshotInput) {
2081+ const sessionId = nativeThreadId(provider, snapshotInput.providerThread);
2082+ if ((yield* Ref.get(activeSessionId)) !== sessionId) {
2083+ if (!capabilities.threads.canReadThreadSnapshot) {
2084+ return yield* new ProviderAdapterProtocolError({
2085+ provider,
2086+ detail: "ACP driver does not support session/load snapshots",
2087+ });
2088+ }
2089+ yield* Ref.set(snapshot, {
2090+ order: [],
2091+ messages: new Map(),
2092+ loadingRole: null,
2093+ loadingIndex: 0,
2094+ });
2095+ const activated = yield* runtime.loadSession(sessionId);
2096+ yield* Ref.set(activeSessionId, activated.sessionId);
2097+ }
2098+ const state = yield* Ref.get(snapshot);
2099+ const now = yield* DateTime.now;
2100+ return {
2101+ providerThread: {
2102+ ...snapshotInput.providerThread,
2103+ providerSessionId: input.providerSessionId,
2104+ status: "idle" as const,
2105+ updatedAt: now,
2106+ },
2107+ providerTurns: [...(yield* Ref.get(providerTurns)).values()],
2108+ messages: state.order.flatMap((key) => {
2109+ const message = state.messages.get(key);
2110+ return message === undefined ? [] : [message];
2111+ }),
2112+ runtimeRequests: [],
2113+ providerPayload: { protocol: ACP_PROTOCOL, sessionId },
2114+ };
2115+ },
2116+ (effect, snapshotInput) =>
2117+ effect.pipe(
2118+ Effect.mapError(
2119+ (cause) =>
2120+ new ProviderAdapterReadThreadSnapshotError({
2121+ provider,
2122+ providerThreadId: snapshotInput.providerThread.id,
2123+ cause,
2124+ }),
2125+ ),
2126+ ),
2127+ ),
2128+ rollbackThread: (rollbackInput) =>
2129+ Effect.fail(
2130+ new ProviderAdapterRollbackThreadError({
2131+ provider,
2132+ providerThreadId: rollbackInput.providerThread.id,
2133+ checkpointId: rollbackInput.target.checkpointId,
2134+ cause: "ACP does not define conversation rollback.",
2135+ }),
2136+ ),
2137+ forkThread: Effect.fn("AcpAdapterV2.forkThread")(
2138+ function* (forkInput) {
2139+ if (!capabilities.threads.canForkThread) {
2140+ return yield* new ProviderAdapterProtocolError({
2141+ provider,
2142+ detail: "ACP driver did not negotiate session/fork",
2143+ });
2144+ }
2145+ if (forkInput.providerTurnId !== undefined) {
2146+ return yield* new ProviderAdapterProtocolError({
2147+ provider,
2148+ detail: "ACP session/fork can only fork the current session head",
2149+ });
2150+ }
2151+ const sourceSessionId = nativeThreadId(provider, forkInput.sourceProviderThread);
2152+ const forked = yield* runtime.forkSession(sourceSessionId);
2153+ yield* Ref.set(activeSessionId, forked.sessionId);
2154+ const now = yield* DateTime.now;
2155+ return makeProviderThread({
2156+ provider,
2157+ idAllocator,
2158+ appThreadId: forkInput.targetThreadId,
2159+ providerSessionId: input.providerSessionId,
2160+ nativeThreadId: forked.sessionId,
2161+ ...(forkInput.ownerNodeId === undefined
2162+ ? {}
2163+ : { ownerNodeId: forkInput.ownerNodeId }),
2164+ forkedFrom: {
2165+ providerThreadId: forkInput.sourceProviderThread.id,
2166+ ...(forkInput.providerTurnId === undefined
2167+ ? {}
2168+ : { providerTurnId: forkInput.providerTurnId }),
2169+ },
2170+ now,
2171+ });
2172+ },
2173+ (effect, forkInput) =>
2174+ effect.pipe(
2175+ Effect.mapError(
2176+ (cause) =>
2177+ new ProviderAdapterForkThreadError({
2178+ provider,
2179+ providerThreadId: forkInput.sourceProviderThread.id,
2180+ cause,
2181+ }),
2182+ ),
2183+ ),
2184+ ),
2185+ };
2186+ return sessionRuntime;
2187+ },
2188+ (effect, input) =>
2189+ effect.pipe(
2190+ Effect.mapError(
2191+ (cause) =>
2192+ new ProviderAdapterOpenSessionError({
2193+ provider,
2194+ providerSessionId: input.providerSessionId,
2195+ cause,
2196+ }),
2197+ ),
2198+ ),
2199+ ),
2200+ });
2201+}
2202+
2203+export type AcpAdapterV2Env = FileSystem.FileSystem | IdAllocatorV2;
apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts+157−0
@@ -0,0 +1,157 @@
1+import * as NodeServices from "@effect/platform-node/NodeServices";
2+import { assert, describe, it } from "@effect/vitest";
3+import { ProviderInstanceId, ProviderSessionId, ThreadId } from "@t3tools/contracts";
4+import * as Effect from "effect/Effect";
5+import * as FileSystem from "effect/FileSystem";
6+import * as Layer from "effect/Layer";
7+import * as Path from "effect/Path";
8+import * as Schema from "effect/Schema";
9+import { HttpClient, HttpClientResponse } from "effect/unstable/http";
10+import { ChildProcessSpawner } from "effect/unstable/process";
11+
12+import { ServerConfig } from "../../config.ts";
13+import { makeAcpRegistryResolver } from "../../provider/acp/AcpRegistrySupport.ts";
14+import { layer as idAllocatorLayer, IdAllocatorV2 } from "../IdAllocator.ts";
15+import { ProviderAdapterV2RuntimePolicy } from "../ProviderAdapter.ts";
16+import { BUILT_IN_PROVIDER_ADAPTER_DRIVER_KINDS_V2 } from "../builtInProviderAdapterDrivers.ts";
17+import {
18+ ACP_REGISTRY_DRIVER_KIND,
19+ AcpRegistryAdapterV2Driver,
20+ makeAcpRegistryAdapterV2,
21+} from "./AcpRegistryAdapterV2.ts";
22+
23+const registryUrl = "https://registry.test/registry.json";
24+const decodeAcpRegistryAdapterSettings = Schema.decodeUnknownEffect(
25+ AcpRegistryAdapterV2Driver.configSchema,
26+);
27+
28+const serverConfigLayer = ServerConfig.layerTest(process.cwd(), {
29+ prefix: "t3-acp-registry-v2-adapter-",
30+}).pipe(Layer.provide(NodeServices.layer));
31+
32+const registryLayer = Layer.succeed(
33+ HttpClient.HttpClient,
34+ HttpClient.make((request) =>
35+ Effect.succeed(
36+ HttpClientResponse.fromWeb(
37+ request,
38+ Response.json({
39+ version: "1.0.0",
40+ agents: [
41+ {
42+ id: "fixture-agent",
43+ name: "Fixture Agent",
44+ version: "1.0.0",
45+ description: "ACP V2 adapter fixture",
46+ distribution: {
47+ binary: {
48+ "darwin-aarch64": {
49+ archive: "https://registry.test/unused",
50+ cmd: "fixture-agent",
51+ args: [],
52+ },
53+ "linux-x86_64": {
54+ archive: "https://registry.test/unused",
55+ cmd: "fixture-agent",
56+ args: [],
57+ },
58+ },
59+ },
60+ },
61+ ],
62+ }),
63+ ),
64+ ),
65+ ),
66+);
67+
68+const testLayer = Layer.mergeAll(
69+ NodeServices.layer,
70+ idAllocatorLayer,
71+ serverConfigLayer,
72+ registryLayer,
73+);
74+
75+describe("AcpRegistryAdapterV2", () => {
76+ it("is registered as a generic provider driver with schema defaults", () => {
77+ assert.isTrue(BUILT_IN_PROVIDER_ADAPTER_DRIVER_KINDS_V2.has(ACP_REGISTRY_DRIVER_KIND));
78+ assert.equal(AcpRegistryAdapterV2Driver.driverKind, ACP_REGISTRY_DRIVER_KIND);
79+ assert.deepEqual(AcpRegistryAdapterV2Driver.defaultConfig(), {
80+ enabled: true,
81+ agentId: "",
82+ commandPath: "",
83+ authMethodId: "",
84+ distribution: "auto",
85+ customModels: [],
86+ });
87+ });
88+
89+ it.effect("opens a real ACP child process resolved from registry configuration", () =>
90+ Effect.gen(function* () {
91+ const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
92+ const fileSystem = yield* FileSystem.FileSystem;
93+ const idAllocator = yield* IdAllocatorV2;
94+ const path = yield* Path.Path;
95+ const serverConfig = yield* ServerConfig;
96+ const mockAgentPath = yield* path.fromFileUrl(
97+ new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
98+ );
99+ const resolver = yield* makeAcpRegistryResolver({
100+ cacheDir: serverConfig.providerStatusCacheDir,
101+ registryUrl,
102+ });
103+ const settings = yield* decodeAcpRegistryAdapterSettings({
104+ agentId: "fixture-agent",
105+ commandPath: process.execPath,
106+ authMethodId: "test",
107+ });
108+ const instanceId = ProviderInstanceId.make("acp-registry-fixture");
109+ const adapter = makeAcpRegistryAdapterV2({
110+ instanceId,
111+ settings,
112+ environment: {
113+ T3_ACP_SESSION_LIFECYCLE: "1",
114+ },
115+ childProcessSpawner,
116+ fileSystem,
117+ idAllocator,
118+ resolver: {
119+ resolve: (configuredSettings, cwd, environment) =>
120+ resolver.resolve(configuredSettings, cwd, environment).pipe(
121+ Effect.map((resolved) => ({
122+ ...resolved,
123+ spawn: {
124+ ...resolved.spawn,
125+ args: [mockAgentPath],
126+ },
127+ })),
128+ ),
129+ },
130+ serverConfig,
131+ });
132+ const threadId = ThreadId.make("thread-acp-registry-fixture");
133+ const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({
134+ runtimeMode: "full-access",
135+ interactionMode: "default",
136+ cwd: process.cwd(),
137+ });
138+ const modelSelection = { instanceId, model: "default" } as const;
139+ const runtime = yield* adapter.openSession({
140+ threadId,
141+ providerSessionId: ProviderSessionId.make("provider-session-acp-registry-fixture"),
142+ modelSelection,
143+ runtimePolicy,
144+ });
145+ const providerThread = yield* runtime.ensureThread({
146+ threadId,
147+ modelSelection,
148+ runtimePolicy,
149+ });
150+
151+ assert.equal(runtime.providerSession.provider, "acpRegistry");
152+ assert.equal(providerThread.nativeThreadRef?.nativeId, "mock-session-1");
153+ assert.isTrue(runtime.providerSession.capabilities.threads.canReadThreadSnapshot);
154+ assert.isTrue(runtime.providerSession.capabilities.threads.canForkThread);
155+ }).pipe(Effect.provide(testLayer), Effect.scoped),
156+ );
157+});
apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.testkit.ts+89−0
@@ -0,0 +1,89 @@
1+import * as NodeServices from "@effect/platform-node/NodeServices";
2+import { AcpRegistrySettings } from "@t3tools/contracts";
3+import * as Effect from "effect/Effect";
4+import * as FileSystem from "effect/FileSystem";
5+import * as Layer from "effect/Layer";
6+import * as Path from "effect/Path";
7+import * as Schema from "effect/Schema";
8+import { ChildProcessSpawner } from "effect/unstable/process";
9+
10+import { ServerConfig } from "../../config.ts";
11+import { layer as idAllocatorLayer, IdAllocatorV2 } from "../IdAllocator.ts";
12+import { makeLayerEffect as makeProviderAdapterRegistryLayerEffect } from "../ProviderAdapterRegistry.ts";
13+import type { OrchestratorV2ProviderReplayHarness } from "../testkit/ProviderReplayHarness.ts";
14+import { makeReplayServerConfig } from "../testkit/ProviderReplayHarness.ts";
15+import {
16+ type AcpReplayTranscript,
17+ AcpReplayTranscriptDecodeError,
18+ decodeAcpReplayTranscript,
19+ makeAcpReplayCompletenessAssertion,
20+ makeAcpReplayRuntime,
21+} from "./AcpAdapterV2.testkit.ts";
22+import {
23+ ACP_REGISTRY_DEFAULT_INSTANCE_ID,
24+ ACP_REGISTRY_PROVIDER,
25+ makeAcpRegistryAdapterV2,
26+} from "./AcpRegistryAdapterV2.ts";
27+
28+const REPLAY_SETTINGS = Schema.decodeUnknownSync(AcpRegistrySettings)({
29+ agentId: "replay-agent",
30+ authMethodId: "replay",
31+});
32+
33+export function makeAcpRegistryProviderAdapterRegistryReplayLayer(transcript: AcpReplayTranscript) {
34+ const serverConfigLayer = Layer.effect(
35+ ServerConfig,
36+ makeReplayServerConfig(`acp-registry-${transcript.scenario}`).pipe(Effect.orDie),
37+ ).pipe(Layer.provide(NodeServices.layer));
38+
39+ return makeProviderAdapterRegistryLayerEffect(
40+ Effect.gen(function* () {
41+ const fileSystem = yield* FileSystem.FileSystem;
42+ const path = yield* Path.Path;
43+ const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
44+ const idAllocator = yield* IdAllocatorV2;
45+ const serverConfig = yield* ServerConfig;
46+ const replayDir = yield* fileSystem
47+ .makeTempDirectory({
48+ prefix: `t3-orchestration-v2-acp-registry-replay-${transcript.scenario}-`,
49+ })
50+ .pipe(Effect.orDie);
51+ const statusPath = path.join(replayDir, "status.json");
52+ const scriptPath = yield* path
53+ .fromFileUrl(new URL("../../../scripts/acp-replay-agent.ts", import.meta.url))
54+ .pipe(Effect.orDie);
55+ const adapter = makeAcpRegistryAdapterV2({
56+ instanceId: ACP_REGISTRY_DEFAULT_INSTANCE_ID,
57+ settings: REPLAY_SETTINGS,
58+ environment: {},
59+ childProcessSpawner,
60+ fileSystem,
61+ idAllocator,
62+ resolver: {
63+ resolve: () => Effect.die("ACP registry resolver must not run during replay"),
64+ },
65+ serverConfig,
66+ makeRuntime: makeAcpReplayRuntime({
67+ transcript,
68+ statusPath,
69+ scriptPath,
70+ childProcessSpawner,
71+ }),
72+ assertComplete: makeAcpReplayCompletenessAssertion(fileSystem, statusPath, transcript),
73+ });
74+ return [adapter];
75+ }),
76+ ).pipe(Layer.provide(Layer.mergeAll(serverConfigLayer, NodeServices.layer, idAllocatorLayer)));
77+}
78+
79+export const AcpRegistryOrchestratorReplayHarness: OrchestratorV2ProviderReplayHarness<
80+ AcpReplayTranscript,
81+ AcpReplayTranscriptDecodeError
82+> = {
83+ provider: ACP_REGISTRY_PROVIDER,
84+ decodeTranscript: (transcript) =>
85+ decodeAcpReplayTranscript(transcript, ACP_REGISTRY_PROVIDER, {
86+ retargetProvider: true,
87+ }),
88+ makeProviderAdapterRegistryLayer: makeAcpRegistryProviderAdapterRegistryReplayLayer,
89+};
apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.ts+158−0
@@ -0,0 +1,158 @@
1+import {
2+ AcpRegistrySettings,
3+ defaultInstanceIdForDriver,
4+ ProviderDriverKind,
5+} from "@t3tools/contracts";
6+import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
7+import * as Effect from "effect/Effect";
8+import * as FileSystem from "effect/FileSystem";
9+import * as Layer from "effect/Layer";
10+import * as Path from "effect/Path";
11+import * as Schema from "effect/Schema";
12+import type * as Scope from "effect/Scope";
13+import { HttpClient } from "effect/unstable/http";
14+import { ChildProcessSpawner } from "effect/unstable/process";
15+import * as EffectAcpErrors from "effect-acp/errors";
16+
17+import { ServerConfig } from "../../config.ts";
18+import {
19+ makeAcpRegistryResolver,
20+ type AcpRegistryResolverShape,
21+} from "../../provider/acp/AcpRegistrySupport.ts";
22+import {
23+ AcpSessionRuntime,
24+ type AcpSessionRuntimeShape,
25+} from "../../provider/acp/AcpSessionRuntime.ts";
26+import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts";
27+import { IdAllocatorV2 } from "../IdAllocator.ts";
28+import {
29+ ProviderAdapterDriverCreateError,
30+ type ProviderAdapterDriver,
31+ type ProviderAdapterDriverCreateInput,
32+} from "../ProviderAdapterDriver.ts";
33+import {
34+ AcpProviderCapabilitiesV2,
35+ makeAcpAdapterV2,
36+ type AcpAdapterV2Flavor,
37+ type AcpAdapterV2RuntimeInput,
38+} from "./AcpAdapterV2.ts";
39+
40+export const ACP_REGISTRY_PROVIDER = "acpRegistry" as const;
41+export const ACP_REGISTRY_DRIVER_KIND = ProviderDriverKind.make(ACP_REGISTRY_PROVIDER);
42+export const ACP_REGISTRY_DEFAULT_INSTANCE_ID =
43+ defaultInstanceIdForDriver(ACP_REGISTRY_DRIVER_KIND);
44+
45+const DEFAULT_ACP_REGISTRY_SETTINGS = Schema.decodeSync(AcpRegistrySettings)({});
46+
47+export interface AcpRegistryAdapterV2Options {
48+ readonly instanceId: Parameters<typeof makeAcpAdapterV2>[0]["instanceId"];
49+ readonly settings: AcpRegistrySettings;
50+ readonly environment: NodeJS.ProcessEnv;
51+ readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"];
52+ readonly fileSystem: FileSystem.FileSystem;
53+ readonly idAllocator: IdAllocatorV2["Service"];
54+ readonly resolver: AcpRegistryResolverShape;
55+ readonly serverConfig: ServerConfig["Service"];
56+ readonly makeRuntime?: (
57+ input: AcpAdapterV2RuntimeInput,
58+ ) => Effect.Effect<AcpSessionRuntimeShape, EffectAcpErrors.AcpError, Scope.Scope>;
59+ readonly assertComplete?: Effect.Effect<void, EffectAcpErrors.AcpError>;
60+}
61+
62+function makeAcpRegistryRuntime(options: AcpRegistryAdapterV2Options) {
63+ return (
64+ input: AcpAdapterV2RuntimeInput,
65+ ): Effect.Effect<AcpSessionRuntimeShape, EffectAcpErrors.AcpError, Scope.Scope> =>
66+ Effect.gen(function* () {
67+ const resolved = yield* options.resolver
68+ .resolve(options.settings, input.cwd, options.environment)
69+ .pipe(
70+ Effect.mapError(
71+ (cause) =>
72+ new EffectAcpErrors.AcpSpawnError({
73+ command: options.settings.agentId || ACP_REGISTRY_PROVIDER,
74+ cause,
75+ }),
76+ ),
77+ );
78+ const context = yield* Layer.build(
79+ AcpSessionRuntime.layer({
80+ ...input,
81+ spawn: resolved.spawn,
82+ ...(options.settings.authMethodId ? { authMethodId: options.settings.authMethodId } : {}),
83+ }).pipe(
84+ Layer.provide(
85+ Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, options.childProcessSpawner),
86+ ),
87+ ),
88+ );
89+ return yield* Effect.service(AcpSessionRuntime).pipe(Effect.provide(context));
90+ });
91+}
92+
93+export function makeAcpRegistryAdapterV2(options: AcpRegistryAdapterV2Options) {
94+ const flavor: AcpAdapterV2Flavor = {
95+ provider: ACP_REGISTRY_PROVIDER,
96+ capabilities: AcpProviderCapabilitiesV2,
97+ makeRuntime: options.makeRuntime ?? makeAcpRegistryRuntime(options),
98+ ...(options.assertComplete === undefined ? {} : { assertComplete: options.assertComplete }),
99+ };
100+ return makeAcpAdapterV2({
101+ instanceId: options.instanceId,
102+ flavor,
103+ fileSystem: options.fileSystem,
104+ idAllocator: options.idAllocator,
105+ serverConfig: options.serverConfig,
106+ });
107+}
108+
109+export type AcpRegistryAdapterV2DriverEnv =
110+ | ChildProcessSpawner.ChildProcessSpawner
111+ | FileSystem.FileSystem
112+ | HttpClient.HttpClient
113+ | IdAllocatorV2
114+ | Path.Path
115+ | ServerConfig;
116+
117+export const AcpRegistryAdapterV2Driver: ProviderAdapterDriver<
118+ AcpRegistrySettings,
119+ AcpRegistryAdapterV2DriverEnv
120+> = {
121+ driverKind: ACP_REGISTRY_DRIVER_KIND,
122+ configSchema: AcpRegistrySettings,
123+ defaultConfig: (): AcpRegistrySettings => DEFAULT_ACP_REGISTRY_SETTINGS,
124+ create: Effect.fn("AcpRegistryAdapterV2Driver.create")(
125+ function* (input: ProviderAdapterDriverCreateInput<AcpRegistrySettings>) {
126+ const hostEnvironment = yield* HostProcessEnvironment;
127+ const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
128+ const fileSystem = yield* FileSystem.FileSystem;
129+ const idAllocator = yield* IdAllocatorV2;
130+ const serverConfig = yield* ServerConfig;
131+ const resolver = yield* makeAcpRegistryResolver({
132+ cacheDir: serverConfig.providerStatusCacheDir,
133+ });
134+ return makeAcpRegistryAdapterV2({
135+ instanceId: input.instanceId,
136+ settings: { ...input.config, enabled: input.enabled },
137+ environment: mergeProviderInstanceEnvironment(input.environment, hostEnvironment),
138+ childProcessSpawner,
139+ fileSystem,
140+ idAllocator,
141+ resolver,
142+ serverConfig,
143+ });
144+ },
145+ (effect, input) =>
146+ effect.pipe(
147+ Effect.mapError(
148+ (cause) =>
149+ new ProviderAdapterDriverCreateError({
150+ driver: ACP_REGISTRY_DRIVER_KIND,
151+ instanceId: input.instanceId,
152+ detail: "Failed to create ACP Registry adapter.",
153+ cause,
154+ }),
155+ ),
156+ ),
157+ ),
158+};
apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts+111−0
@@ -0,0 +1,111 @@
1+import { assert, describe, it } from "@effect/vitest";
2+import type * as EffectAcpSchema from "effect-acp/schema";
3+
4+import { ProviderAdapterV2RuntimePolicy } from "../ProviderAdapter.ts";
5+import { AcpProviderCapabilitiesV2, acpPermissionDisposition } from "./AcpAdapterV2.ts";
6+import { GrokProviderCapabilitiesV2 } from "./GrokAdapterV2.ts";
7+
8+function permissionRequest(
9+ kind: EffectAcpSchema.ToolKind,
10+): EffectAcpSchema.RequestPermissionRequest {
11+ return {
12+ sessionId: "session-1",
13+ options: [
14+ { optionId: "allow-once", name: "Allow once", kind: "allow_once" },
15+ { optionId: "allow-always", name: "Allow always", kind: "allow_always" },
16+ { optionId: "reject-once", name: "Reject", kind: "reject_once" },
17+ ],
18+ toolCall: {
19+ toolCallId: "tool-1",
20+ title: "Test tool",
21+ kind,
22+ },
23+ };
24+}
25+
26+function runtimePolicy(input: {
27+ readonly runtimeMode: "approval-required" | "auto-accept-edits" | "full-access";
28+ readonly approvalPolicy?: unknown;
29+ readonly sandboxPolicy?: unknown;
30+}) {
31+ return ProviderAdapterV2RuntimePolicy.make({
32+ runtimeMode: input.runtimeMode,
33+ interactionMode: "default",
34+ cwd: "/workspace",
35+ ...(input.approvalPolicy === undefined ? {} : { approvalPolicy: input.approvalPolicy }),
36+ ...(input.sandboxPolicy === undefined ? {} : { sandboxPolicy: input.sandboxPolicy }),
37+ });
38+}
39+
40+describe("GrokAdapterV2 capabilities", () => {
41+ it("keeps optional protocol features conservative until a flavor or handshake confirms them", () => {
42+ assert.isFalse(AcpProviderCapabilitiesV2.sessions.supportsModelSwitchInSession);
43+ assert.isFalse(AcpProviderCapabilitiesV2.sessions.supportsRuntimeModeSwitchInSession);
44+ assert.isFalse(AcpProviderCapabilitiesV2.threads.canReadThreadSnapshot);
45+ assert.isFalse(AcpProviderCapabilitiesV2.tools.supportsMcpTools);
46+ });
47+
48+ it("leaves non-native features available to orchestrator fallbacks", () => {
49+ assert.isFalse(GrokProviderCapabilitiesV2.threads.canForkThread);
50+ assert.isFalse(GrokProviderCapabilitiesV2.subagents.supportsSubagents);
51+ assert.isFalse(GrokProviderCapabilitiesV2.turns.supportsActiveSteering);
52+ assert.isTrue(GrokProviderCapabilitiesV2.turns.supportsInterrupt);
53+ assert.isTrue(GrokProviderCapabilitiesV2.turns.supportsSteeringByInterruptRestart);
54+ assert.isTrue(GrokProviderCapabilitiesV2.context.supportsFullThreadHandoff);
55+ });
56+
57+ it("declares the optional ACP features verified by the Grok handshake", () => {
58+ assert.isTrue(GrokProviderCapabilitiesV2.sessions.supportsModelSwitchInSession);
59+ assert.isTrue(GrokProviderCapabilitiesV2.threads.canReadThreadSnapshot);
60+ assert.isTrue(GrokProviderCapabilitiesV2.tools.supportsMcpTools);
61+ assert.isTrue(GrokProviderCapabilitiesV2.checkpointing.providerCanReadConversationSnapshot);
62+ });
63+});
64+
65+describe("ACP permission policy", () => {
66+ it("honors explicit on-request approval over full-access runtime mode", () => {
67+ assert.equal(
68+ acpPermissionDisposition(
69+ runtimePolicy({
70+ runtimeMode: "full-access",
71+ approvalPolicy: "on-request",
72+ sandboxPolicy: { type: "readOnly" },
73+ }),
74+ permissionRequest("execute"),
75+ ),
76+ "ask",
77+ );
78+ });
79+
80+ it("rejects mutating escalation under a non-interactive read-only policy", () => {
81+ const policy = runtimePolicy({
82+ runtimeMode: "full-access",
83+ approvalPolicy: "never",
84+ sandboxPolicy: { type: "readOnly" },
85+ });
86+ assert.equal(acpPermissionDisposition(policy, permissionRequest("execute")), "deny");
87+ assert.equal(acpPermissionDisposition(policy, permissionRequest("edit")), "deny");
88+ assert.equal(acpPermissionDisposition(policy, permissionRequest("read")), "allow");
89+ });
90+
91+ it("auto-approves requests only when the resolved policy permits them", () => {
92+ assert.equal(
93+ acpPermissionDisposition(
94+ runtimePolicy({
95+ runtimeMode: "full-access",
96+ approvalPolicy: "never",
97+ sandboxPolicy: { type: "dangerFullAccess" },
98+ }),
99+ permissionRequest("execute"),
100+ ),
101+ "allow",
102+ );
103+ assert.equal(
104+ acpPermissionDisposition(
105+ runtimePolicy({ runtimeMode: "approval-required" }),
106+ permissionRequest("read"),
107+ ),
108+ "ask",
109+ );
110+ });
111+});
apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.testkit.ts+76−0
@@ -0,0 +1,76 @@
1+import * as NodeServices from "@effect/platform-node/NodeServices";
2+import { GrokSettings } from "@t3tools/contracts";
3+import * as Effect from "effect/Effect";
4+import * as FileSystem from "effect/FileSystem";
5+import * as Layer from "effect/Layer";
6+import * as Path from "effect/Path";
7+import * as Schema from "effect/Schema";
8+import { ChildProcessSpawner } from "effect/unstable/process";
9+
10+import { ServerConfig } from "../../config.ts";
11+import { layer as idAllocatorLayer, IdAllocatorV2 } from "../IdAllocator.ts";
12+import { makeLayerEffect as makeProviderAdapterRegistryLayerEffect } from "../ProviderAdapterRegistry.ts";
13+import type { OrchestratorV2ProviderReplayHarness } from "../testkit/ProviderReplayHarness.ts";
14+import { makeReplayServerConfig } from "../testkit/ProviderReplayHarness.ts";
15+import {
16+ type AcpReplayTranscript,
17+ AcpReplayTranscriptDecodeError,
18+ decodeAcpReplayTranscript,
19+ makeAcpReplayCompletenessAssertion,
20+ makeAcpReplayRuntime,
21+} from "./AcpAdapterV2.testkit.ts";
22+import { GROK_DEFAULT_INSTANCE_ID, GROK_PROVIDER, makeGrokAdapterV2 } from "./GrokAdapterV2.ts";
23+
24+const DEFAULT_GROK_SETTINGS = Schema.decodeUnknownSync(GrokSettings)({});
25+
26+export function makeGrokProviderAdapterRegistryReplayLayer(transcript: AcpReplayTranscript) {
27+ const serverConfigLayer = Layer.effect(
28+ ServerConfig,
29+ makeReplayServerConfig(`grok-${transcript.scenario}`).pipe(Effect.orDie),
30+ ).pipe(Layer.provide(NodeServices.layer));
31+
32+ return makeProviderAdapterRegistryLayerEffect(
33+ Effect.gen(function* () {
34+ const fileSystem = yield* FileSystem.FileSystem;
35+ const path = yield* Path.Path;
36+ const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
37+ const idAllocator = yield* IdAllocatorV2;
38+ const serverConfig = yield* ServerConfig;
39+ const replayDir = yield* fileSystem
40+ .makeTempDirectory({
41+ prefix: `t3-orchestration-v2-grok-replay-${transcript.scenario}-`,
42+ })
43+ .pipe(Effect.orDie);
44+ const statusPath = path.join(replayDir, "status.json");
45+ const scriptPath = yield* path
46+ .fromFileUrl(new URL("../../../scripts/acp-replay-agent.ts", import.meta.url))
47+ .pipe(Effect.orDie);
48+ const adapter = makeGrokAdapterV2({
49+ instanceId: GROK_DEFAULT_INSTANCE_ID,
50+ settings: DEFAULT_GROK_SETTINGS,
51+ environment: {},
52+ childProcessSpawner,
53+ fileSystem,
54+ idAllocator,
55+ serverConfig,
56+ makeRuntime: makeAcpReplayRuntime({
57+ transcript,
58+ statusPath,
59+ scriptPath,
60+ childProcessSpawner,
61+ }),
62+ assertComplete: makeAcpReplayCompletenessAssertion(fileSystem, statusPath, transcript),
63+ });
64+ return [adapter];
65+ }),
66+ ).pipe(Layer.provide(Layer.mergeAll(serverConfigLayer, NodeServices.layer, idAllocatorLayer)));
67+}
68+
69+export const GrokOrchestratorReplayHarness: OrchestratorV2ProviderReplayHarness<
70+ AcpReplayTranscript,
71+ AcpReplayTranscriptDecodeError
72+> = {
73+ provider: GROK_PROVIDER,
74+ decodeTranscript: (transcript) => decodeAcpReplayTranscript(transcript, GROK_PROVIDER),
75+ makeProviderAdapterRegistryLayer: makeGrokProviderAdapterRegistryReplayLayer,
76+};
apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts+210−0
@@ -0,0 +1,210 @@
1+import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
2+import {
3+ defaultInstanceIdForDriver,
4+ GrokSettings,
5+ ProviderDriverKind,
6+ type OrchestrationV2ProviderCapabilities,
7+} from "@t3tools/contracts";
8+import * as Effect from "effect/Effect";
9+import * as FileSystem from "effect/FileSystem";
10+import * as Layer from "effect/Layer";
11+import * as Schema from "effect/Schema";
12+import type * as Scope from "effect/Scope";
13+import { ChildProcessSpawner } from "effect/unstable/process";
14+import type * as EffectAcpErrors from "effect-acp/errors";
15+
16+import { ServerConfig } from "../../config.ts";
17+import {
18+ makeGrokAcpRuntime,
19+ resolveGrokAcpBaseModelId,
20+} from "../../provider/acp/GrokAcpSupport.ts";
21+import {
22+ extractXAiAskUserQuestionIdentity,
23+ extractXAiAskUserQuestions,
24+ makeXAiAskUserQuestionCancelledResponse,
25+ makeXAiAskUserQuestionResponse,
26+ XAiAskUserQuestionRequest,
27+} from "../../provider/acp/XAiAcpExtension.ts";
28+import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts";
29+import type { AcpSessionRuntimeShape } from "../../provider/acp/AcpSessionRuntime.ts";
30+import { IdAllocatorV2 } from "../IdAllocator.ts";
31+import { ProviderAdapterV2 } from "../ProviderAdapter.ts";
32+import {
33+ ProviderAdapterDriverCreateError,
34+ type ProviderAdapterDriver,
35+ type ProviderAdapterDriverCreateInput,
36+} from "../ProviderAdapterDriver.ts";
37+import {
38+ AcpProviderCapabilitiesV2,
39+ makeAcpAdapterV2,
40+ type AcpAdapterV2Flavor,
41+ type AcpAdapterV2RuntimeInput,
42+} from "./AcpAdapterV2.ts";
43+
44+export const GROK_PROVIDER = "grok" as const;
45+export const GROK_DRIVER_KIND = ProviderDriverKind.make(GROK_PROVIDER);
46+export const GROK_DEFAULT_INSTANCE_ID = defaultInstanceIdForDriver(GROK_DRIVER_KIND);
47+const DEFAULT_GROK_SETTINGS = Schema.decodeSync(GrokSettings)({});
48+
49+export const GrokProviderCapabilitiesV2 = {
50+ ...AcpProviderCapabilitiesV2,
51+ sessions: {
52+ ...AcpProviderCapabilitiesV2.sessions,
53+ supportsModelSwitchInSession: true,
54+ supportsRuntimeModeSwitchInSession: false,
55+ },
56+ threads: {
57+ ...AcpProviderCapabilitiesV2.threads,
58+ canReadThreadSnapshot: true,
59+ canForkThread: false,
60+ canForkFromTurn: false,
61+ },
62+ subagents: {
63+ ...AcpProviderCapabilitiesV2.subagents,
64+ supportsSubagents: false,
65+ },
66+ tools: {
67+ ...AcpProviderCapabilitiesV2.tools,
68+ supportsMcpTools: true,
69+ },
70+ checkpointing: {
71+ ...AcpProviderCapabilitiesV2.checkpointing,
72+ providerCanReadConversationSnapshot: true,
73+ },
74+} satisfies OrchestrationV2ProviderCapabilities;
75+
76+export interface GrokAdapterV2Options {
77+ readonly instanceId: Parameters<typeof makeAcpAdapterV2>[0]["instanceId"];
78+ readonly settings: GrokSettings;
79+ readonly environment: NodeJS.ProcessEnv;
80+ readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"];
81+ readonly fileSystem: FileSystem.FileSystem;
82+ readonly idAllocator: IdAllocatorV2["Service"];
83+ readonly serverConfig: ServerConfig["Service"];
84+ readonly makeRuntime?: (
85+ input: AcpAdapterV2RuntimeInput,
86+ ) => Effect.Effect<AcpSessionRuntimeShape, EffectAcpErrors.AcpError, Scope.Scope>;
87+ readonly assertComplete?: Effect.Effect<void, EffectAcpErrors.AcpError>;
88+}
89+
90+export const registerGrokAcpExtensions: NonNullable<AcpAdapterV2Flavor["registerExtensions"]> = ({
91+ runtime,
92+ requestUserInput,
93+}) =>
94+ Effect.forEach(
95+ ["x.ai/ask_user_question", "_x.ai/ask_user_question"] as const,
96+ (method) =>
97+ runtime.handleExtRequest(method, XAiAskUserQuestionRequest, (params) => {
98+ const identity = extractXAiAskUserQuestionIdentity(params);
99+ const questions = extractXAiAskUserQuestions(params).map((question) => ({
100+ id: question.id,
101+ header: question.header,
102+ question: question.question,
103+ options: [...question.options],
104+ }));
105+ return requestUserInput({
106+ nativeItemId: `${identity.sessionId}:xai-question:${identity.toolCallId}`,
107+ nativeRequestId: identity.toolCallId,
108+ questions,
109+ }).pipe(
110+ Effect.map((answers) =>
111+ answers === null
112+ ? makeXAiAskUserQuestionCancelledResponse()
113+ : makeXAiAskUserQuestionResponse(params, answers),
114+ ),
115+ );
116+ }),
117+ { discard: true },
118+ );
119+
120+export function makeGrokAdapterV2(options: GrokAdapterV2Options) {
121+ const flavor: AcpAdapterV2Flavor = {
122+ provider: GROK_PROVIDER,
123+ capabilities: GrokProviderCapabilitiesV2,
124+ resolveModelId: (selection) => resolveGrokAcpBaseModelId(selection.model),
125+ makeRuntime:
126+ options.makeRuntime ??
127+ ((input) =>
128+ makeGrokAcpRuntime({
129+ ...input,
130+ grokSettings: options.settings,
131+ environment: options.environment,
132+ childProcessSpawner: options.childProcessSpawner,
133+ })),
134+ registerExtensions: registerGrokAcpExtensions,
135+ ...(options.assertComplete === undefined ? {} : { assertComplete: options.assertComplete }),
136+ };
137+ return makeAcpAdapterV2({
138+ instanceId: options.instanceId,
139+ flavor,
140+ fileSystem: options.fileSystem,
141+ idAllocator: options.idAllocator,
142+ serverConfig: options.serverConfig,
143+ });
144+}
145+
146+export type GrokAdapterV2DriverEnv =
147+ | ChildProcessSpawner.ChildProcessSpawner
148+ | FileSystem.FileSystem
149+ | IdAllocatorV2
150+ | ServerConfig;
151+
152+export const GrokAdapterV2Driver: ProviderAdapterDriver<GrokSettings, GrokAdapterV2DriverEnv> = {
153+ driverKind: GROK_DRIVER_KIND,
154+ configSchema: GrokSettings,
155+ defaultConfig: (): GrokSettings => DEFAULT_GROK_SETTINGS,
156+ create: Effect.fn("GrokAdapterV2Driver.create")(
157+ function* (input: ProviderAdapterDriverCreateInput<GrokSettings>) {
158+ const hostEnvironment = yield* HostProcessEnvironment;
159+ const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
160+ const fileSystem = yield* FileSystem.FileSystem;
161+ const idAllocator = yield* IdAllocatorV2;
162+ const serverConfig = yield* ServerConfig;
163+ return makeGrokAdapterV2({
164+ instanceId: input.instanceId,
165+ settings: { ...input.config, enabled: input.enabled },
166+ environment: mergeProviderInstanceEnvironment(input.environment, hostEnvironment),
167+ childProcessSpawner,
168+ fileSystem,
169+ idAllocator,
170+ serverConfig,
171+ });
172+ },
173+ (effect, input) =>
174+ effect.pipe(
175+ Effect.mapError(
176+ (cause) =>
177+ new ProviderAdapterDriverCreateError({
178+ driver: GROK_DRIVER_KIND,
179+ instanceId: input.instanceId,
180+ detail: "Failed to create Grok ACP adapter.",
181+ cause,
182+ }),
183+ ),
184+ ),
185+ ),
186+};
187+
188+export const layer: Layer.Layer<
189+ ProviderAdapterV2,
190+ never,
191+ ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | IdAllocatorV2 | ServerConfig
192+> = Layer.effect(
193+ ProviderAdapterV2,
194+ Effect.gen(function* () {
195+ const hostEnvironment = yield* HostProcessEnvironment;
196+ const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
197+ const fileSystem = yield* FileSystem.FileSystem;
198+ const idAllocator = yield* IdAllocatorV2;
199+ const serverConfig = yield* ServerConfig;
200+ return makeGrokAdapterV2({
201+ instanceId: GROK_DEFAULT_INSTANCE_ID,
202+ settings: DEFAULT_GROK_SETTINGS,
203+ environment: hostEnvironment,
204+ childProcessSpawner,
205+ fileSystem,
206+ idAllocator,
207+ serverConfig,
208+ });
209+ }),
210+);
apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts+184−0
@@ -0,0 +1,184 @@
1+import { assert, describe, it } from "@effect/vitest";
2+import {
3+ NodeId,
4+ ProviderThreadId,
5+ ProviderTurnId,
6+ type OrchestrationV2ProviderTurn,
7+} from "@t3tools/contracts";
8+
9+import {
10+ openCodeBoundaryAfterProviderTurn,
11+ openCodeChildPermissionRules,
12+ openCodePermissionRules,
13+ openCodePermissionRequestKind,
14+ openCodeToolProjectionKind,
15+ OpenCodeProviderCapabilitiesV2,
16+} from "./OpenCodeAdapterV2.ts";
17+import { ProviderAdapterV2RuntimePolicy } from "../ProviderAdapter.ts";
18+
19+function runtimePolicy(
20+ runtimeMode: ProviderAdapterV2RuntimePolicy["runtimeMode"],
21+ override: Partial<ProviderAdapterV2RuntimePolicy> = {},
22+): ProviderAdapterV2RuntimePolicy {
23+ return ProviderAdapterV2RuntimePolicy.make({
24+ runtimeMode,
25+ interactionMode: "default",
26+ cwd: null,
27+ ...override,
28+ });
29+}
30+
31+function permissionAction(rules: ReturnType<typeof openCodePermissionRules>, permission: string) {
32+ return rules.findLast((rule) => rule.permission === "*" || rule.permission === permission)
33+ ?.action;
34+}
35+
36+function providerTurn(input: {
37+ readonly id: string;
38+ readonly ordinal: number;
39+ readonly nativeId: string | null;
40+}): OrchestrationV2ProviderTurn {
41+ return {
42+ id: ProviderTurnId.make(input.id),
43+ providerThreadId: ProviderThreadId.make("provider-thread:opencode-test"),
44+ nodeId: NodeId.make(`node:${input.id}`),
45+ runAttemptId: null,
46+ nativeTurnRef:
47+ input.nativeId === null
48+ ? null
49+ : { provider: "opencode", nativeId: input.nativeId, strength: "weak" },
50+ ordinal: input.ordinal,
51+ status: "completed",
52+ startedAt: null,
53+ completedAt: null,
54+ };
55+}
56+
57+describe("OpenCodeAdapterV2", () => {
58+ it("advertises the identity strengths exposed by the SDK boundary", () => {
59+ assert.equal(OpenCodeProviderCapabilitiesV2.identity.nativeThreadIds, "strong");
60+ assert.equal(OpenCodeProviderCapabilitiesV2.identity.nativeTurnIds, "weak");
61+ assert.equal(OpenCodeProviderCapabilitiesV2.identity.nativeItemIds, "strong");
62+ assert.equal(OpenCodeProviderCapabilitiesV2.identity.nativeRequestIds, "strong");
63+ assert.isTrue(OpenCodeProviderCapabilitiesV2.threads.canForkFromTurn);
64+ assert.isTrue(OpenCodeProviderCapabilitiesV2.turns.supportsActiveSteering);
65+ assert.equal(OpenCodeProviderCapabilitiesV2.turns.terminalStatusQuality, "strong");
66+ assert.isFalse(OpenCodeProviderCapabilitiesV2.subagents.canCloseSubagents);
67+ });
68+
69+ it("maps native permission families to orchestration request kinds", () => {
70+ assert.equal(openCodePermissionRequestKind("bash"), "command");
71+ assert.equal(openCodePermissionRequestKind("read"), "file-read");
72+ assert.equal(openCodePermissionRequestKind("grep"), "file-read");
73+ assert.equal(openCodePermissionRequestKind("external_directory"), "file-read");
74+ assert.equal(openCodePermissionRequestKind("external_directory", "edit"), "file-change");
75+ assert.equal(openCodePermissionRequestKind("edit"), "file-change");
76+ assert.equal(openCodePermissionRequestKind("apply_patch"), "file-change");
77+ });
78+
79+ it("maps OpenCode tools to semantic turn-item families", () => {
80+ assert.equal(openCodeToolProjectionKind("bash"), "command_execution");
81+ assert.equal(openCodeToolProjectionKind("edit"), "file_change");
82+ assert.equal(openCodeToolProjectionKind("read"), "file_search");
83+ assert.equal(openCodeToolProjectionKind("lsp"), "file_search");
84+ assert.equal(openCodeToolProjectionKind("websearch"), "web_search");
85+ assert.equal(openCodeToolProjectionKind("codesearch"), "web_search");
86+ assert.equal(openCodeToolProjectionKind("custom_tool"), "dynamic_tool");
87+ });
88+
89+ it("maps runtime modes to safe OpenCode permission rules", () => {
90+ const approvalRequired = openCodePermissionRules(runtimePolicy("approval-required"));
91+ assert.equal(permissionAction(approvalRequired, "read"), "allow");
92+ assert.equal(permissionAction(approvalRequired, "edit"), "ask");
93+ assert.equal(permissionAction(approvalRequired, "bash"), "ask");
94+ assert.equal(permissionAction(approvalRequired, "doom_loop"), "ask");
95+ assert.equal(permissionAction(approvalRequired, "unknown_plugin_tool"), "ask");
96+ assert.equal(permissionAction(approvalRequired, "question"), "allow");
97+
98+ const autoAcceptEdits = openCodePermissionRules(runtimePolicy("auto-accept-edits"));
99+ assert.equal(permissionAction(autoAcceptEdits, "edit"), "allow");
100+ assert.equal(permissionAction(autoAcceptEdits, "bash"), "ask");
101+
102+ const fullAccess = openCodePermissionRules(runtimePolicy("full-access"));
103+ assert.equal(permissionAction(fullAccess, "bash"), "allow");
104+ assert.equal(permissionAction(fullAccess, "edit"), "allow");
105+
106+ const granularApproval = openCodePermissionRules(
107+ runtimePolicy("full-access", {
108+ approvalPolicy: { granular: { request_permissions: true } },
109+ }),
110+ );
111+ assert.equal(permissionAction(granularApproval, "bash"), "ask");
112+ assert.equal(permissionAction(granularApproval, "read"), "allow");
113+ });
114+
115+ it("enforces non-interactive sandbox policy through OpenCode permissions", () => {
116+ const readOnly = openCodePermissionRules(
117+ runtimePolicy("full-access", {
118+ approvalPolicy: "never",
119+ sandboxPolicy: {
120+ type: "readOnly",
121+ access: { type: "fullAccess" },
122+ networkAccess: false,
123+ },
124+ }),
125+ );
126+ assert.equal(permissionAction(readOnly, "read"), "allow");
127+ assert.equal(permissionAction(readOnly, "edit"), "deny");
128+ assert.equal(permissionAction(readOnly, "bash"), "deny");
129+ assert.equal(permissionAction(readOnly, "webfetch"), "deny");
130+ assert.equal(permissionAction(readOnly, "doom_loop"), "deny");
131+ assert.equal(permissionAction(readOnly, "unknown_plugin_tool"), "deny");
132+ assert.equal(permissionAction(readOnly, "external_directory"), "allow");
133+
134+ const workspaceWrite = openCodePermissionRules(
135+ runtimePolicy("auto-accept-edits", {
136+ approvalPolicy: "never",
137+ sandboxPolicy: {
138+ type: "workspaceWrite",
139+ writableRoots: ["/tmp/opencode-workspace"],
140+ networkAccess: true,
141+ },
142+ }),
143+ );
144+ assert.equal(permissionAction(workspaceWrite, "edit"), "allow");
145+ assert.equal(permissionAction(workspaceWrite, "bash"), "deny");
146+ assert.equal(permissionAction(workspaceWrite, "webfetch"), "allow");
147+ assert.deepInclude(workspaceWrite, {
148+ permission: "external_directory",
149+ pattern: "/tmp/opencode-workspace/*",
150+ action: "allow",
151+ });
152+ });
153+
154+ it("preserves OpenCode's recursion guard on task-created child sessions", () => {
155+ const childRules = openCodeChildPermissionRules(runtimePolicy("full-access"), [
156+ { permission: "task", pattern: "*", action: "deny" },
157+ ]);
158+
159+ assert.equal(permissionAction(childRules, "read"), "allow");
160+ assert.equal(permissionAction(childRules, "bash"), "allow");
161+ assert.equal(permissionAction(childRules, "task"), "deny");
162+
163+ const approvalRequiredPolicy = runtimePolicy("approval-required");
164+ const parentRules = openCodePermissionRules(approvalRequiredPolicy);
165+ const childApprovalRules = openCodeChildPermissionRules(approvalRequiredPolicy, [
166+ ...parentRules.filter((rule) => rule.action === "deny"),
167+ { permission: "task", pattern: "*", action: "deny" },
168+ ]);
169+ assert.equal(permissionAction(childApprovalRules, "bash"), "ask");
170+ assert.equal(permissionAction(childApprovalRules, "task"), "deny");
171+ });
172+
173+ it("uses the next native user message as the exclusive fork and revert boundary", () => {
174+ const first = providerTurn({ id: "turn:first", ordinal: 1, nativeId: "msg-user-1" });
175+ const synthetic = providerTurn({ id: "turn:synthetic", ordinal: 2, nativeId: null });
176+ const third = providerTurn({ id: "turn:third", ordinal: 3, nativeId: "msg-user-3" });
177+
178+ assert.equal(
179+ openCodeBoundaryAfterProviderTurn([third, first, synthetic], first.id),
180+ "msg-user-3",
181+ );
182+ assert.isUndefined(openCodeBoundaryAfterProviderTurn([first, synthetic, third], third.id));
183+ });
184+});
apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.testkit.ts+385−0
@@ -0,0 +1,385 @@
1+import type { Event as OpenCodeEvent, OpencodeClient } from "@opencode-ai/sdk/v2";
2+import * as NodeServices from "@effect/platform-node/NodeServices";
3+import { ProviderReplayEntry, type ProviderReplayTranscript } from "@t3tools/contracts";
4+import * as Effect from "effect/Effect";
5+import * as Duration from "effect/Duration";
6+import * as Layer from "effect/Layer";
7+import * as Schema from "effect/Schema";
8+
9+import { ServerConfig } from "../../config.ts";
10+import {
11+ OpenCodeRuntime,
12+ OpenCodeRuntimeError,
13+ type OpenCodeRuntimeShape,
14+} from "../../provider/opencodeRuntime.ts";
15+import { layer as idAllocatorLayer } from "../IdAllocator.ts";
16+import { ProviderAdapterDriverCreateError } from "../ProviderAdapterDriver.ts";
17+import { makeDriverLayer as makeProviderAdapterRegistryDriverLayer } from "../ProviderAdapterRegistry.ts";
18+import {
19+ makeReplayServerConfig,
20+ type OrchestratorV2ProviderReplayHarness,
21+} from "../testkit/ProviderReplayHarness.ts";
22+import {
23+ OPENCODE_DEFAULT_INSTANCE_ID,
24+ OPENCODE_DRIVER_KIND,
25+ OPENCODE_PROVIDER,
26+ OpenCodeAdapterV2Driver,
27+} from "./OpenCodeAdapterV2.ts";
28+
29+export const OPENCODE_SDK_REPLAY_PROTOCOL = "opencode-sdk.sse" as const;
30+
31+const OpenCodeSdkReplayTranscript = Schema.Struct({
32+ provider: Schema.Literal(OPENCODE_PROVIDER),
33+ protocol: Schema.Literal(OPENCODE_SDK_REPLAY_PROTOCOL),
34+ version: Schema.String,
35+ scenario: Schema.String,
36+ metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
37+ entries: Schema.Array(ProviderReplayEntry),
38+});
39+export type OpenCodeSdkReplayTranscript = typeof OpenCodeSdkReplayTranscript.Type;
40+const decodeOpenCodeSdkReplayTranscript = Schema.decodeUnknownEffect(OpenCodeSdkReplayTranscript);
41+
42+export class OpenCodeReplayTranscriptDecodeError extends Schema.TaggedErrorClass<OpenCodeReplayTranscriptDecodeError>()(
43+ "OpenCodeReplayTranscriptDecodeError",
44+ {
45+ provider: Schema.optional(Schema.String),
46+ protocol: Schema.optional(Schema.String),
47+ scenario: Schema.optional(Schema.String),
48+ cause: Schema.Defect(),
49+ },
50+) {
51+ override get message(): string {
52+ return `Failed to decode OpenCode replay transcript for scenario ${this.scenario ?? "<unknown>"}.`;
53+ }
54+}
55+
56+export class OpenCodeReplayMismatchError extends Schema.TaggedErrorClass<OpenCodeReplayMismatchError>()(
57+ "OpenCodeReplayMismatchError",
58+ {
59+ scenario: Schema.String,
60+ cursor: Schema.Number,
61+ expected: Schema.Unknown,
62+ actual: Schema.Unknown,
63+ },
64+) {
65+ override get message(): string {
66+ return `OpenCode replay frame mismatch at cursor ${this.cursor} in scenario ${this.scenario}.`;
67+ }
68+}
69+
70+export class OpenCodeReplayIncompleteError extends Schema.TaggedErrorClass<OpenCodeReplayIncompleteError>()(
71+ "OpenCodeReplayIncompleteError",
72+ {
73+ scenario: Schema.String,
74+ cursor: Schema.Number,
75+ remaining: Schema.Number,
76+ },
77+) {
78+ override get message(): string {
79+ return `OpenCode replay ended with ${this.remaining} unconsumed entries in scenario ${this.scenario}.`;
80+ }
81+}
82+
83+export const OpenCodeReplayError = Schema.Union([
84+ OpenCodeReplayTranscriptDecodeError,
85+ OpenCodeReplayMismatchError,
86+ OpenCodeReplayIncompleteError,
87+]);
88+export type OpenCodeReplayError = typeof OpenCodeReplayError.Type;
89+export const OpenCodeOrchestratorReplayHarnessError = Schema.Union([
90+ OpenCodeReplayError,
91+ ProviderAdapterDriverCreateError,
92+]);
93+export type OpenCodeOrchestratorReplayHarnessError =
94+ typeof OpenCodeOrchestratorReplayHarnessError.Type;
95+
96+function replayValueMatches(expected: unknown, actual: unknown): boolean {
97+ if (expected === "<any>" || expected === "<workspace>") return true;
98+ if (Array.isArray(expected)) {
99+ return (
100+ Array.isArray(actual) &&
101+ expected.length === actual.length &&
102+ expected.every((entry, index) => replayValueMatches(entry, actual[index]))
103+ );
104+ }
105+ if (typeof expected === "object" && expected !== null) {
106+ if (typeof actual !== "object" || actual === null) return false;
107+ return Object.entries(expected).every(([key, value]) =>
108+ replayValueMatches(value, (actual as Record<string, unknown>)[key]),
109+ );
110+ }
111+ return Object.is(expected, actual);
112+}
113+
114+function frameRecord(frame: unknown): Record<string, unknown> | null {
115+ return typeof frame === "object" && frame !== null ? (frame as Record<string, unknown>) : null;
116+}
117+
118+class OpenCodeReplayController {
119+ private cursor = 0;
120+ private readonly waiters = new Set<() => void>();
121+ private failure: unknown = null;
122+ private readonly transcript: OpenCodeSdkReplayTranscript;
123+
124+ constructor(transcript: OpenCodeSdkReplayTranscript) {
125+ this.transcript = transcript;
126+ }
127+
128+ async expectOutbound(actual: unknown): Promise<void> {
129+ try {
130+ const entry = this.transcript.entries[this.cursor];
131+ if (entry?.type !== "expect_outbound" || !replayValueMatches(entry.frame, actual)) {
132+ throw new OpenCodeReplayMismatchError({
133+ scenario: this.transcript.scenario,
134+ cursor: this.cursor,
135+ expected: entry?.type === "expect_outbound" ? entry.frame : (entry ?? null),
136+ actual,
137+ });
138+ }
139+ this.advance();
140+ } catch (cause) {
141+ this.fail(cause);
142+ throw cause;
143+ }
144+ }
145+
146+ async response(operation: string): Promise<unknown> {
147+ while (true) {
148+ this.throwFailure();
149+ const entry = this.transcript.entries[this.cursor];
150+ if (entry?.type === "emit_inbound") {
151+ const frame = frameRecord(entry.frame);
152+ if (frame?.type === "sdk.response" && frame.operation === operation) {
153+ if (entry.afterMs !== undefined && entry.afterMs > 0) {
154+ await Effect.runPromise(Effect.sleep(Duration.millis(entry.afterMs)));
155+ }
156+ const data = frame.data;
157+ this.advance();
158+ return data;
159+ }
160+ }
161+ if (entry?.type === "runtime_exit") {
162+ const mismatch = new OpenCodeReplayMismatchError({
163+ scenario: this.transcript.scenario,
164+ cursor: this.cursor,
165+ expected: { type: "sdk.response", operation },
166+ actual: entry,
167+ });
168+ this.fail(mismatch);
169+ throw mismatch;
170+ }
171+ await this.changed();
172+ }
173+ }
174+
175+ async *events(signal?: AbortSignal): AsyncIterable<OpenCodeEvent> {
176+ while (true) {
177+ if (signal?.aborted === true) return;
178+ this.throwFailure();
179+ const entry = this.transcript.entries[this.cursor];
180+ if (entry?.type === "emit_inbound") {
181+ const frame = frameRecord(entry.frame);
182+ if (frame?.type === "sdk.event") {
183+ if (entry.afterMs !== undefined && entry.afterMs > 0) {
184+ await Effect.runPromise(Effect.sleep(Duration.millis(entry.afterMs)));
185+ }
186+ const event = frame.event as OpenCodeEvent;
187+ this.advance();
188+ yield event;
189+ continue;
190+ }
191+ }
192+ if (entry?.type === "runtime_exit") {
193+ this.advance();
194+ if (entry.status === "success") return;
195+ const mismatch = new OpenCodeReplayMismatchError({
196+ scenario: this.transcript.scenario,
197+ cursor: this.cursor - 1,
198+ expected: { status: "success" },
199+ actual: entry,
200+ });
201+ this.fail(mismatch);
202+ throw mismatch;
203+ }
204+ await this.changed(signal);
205+ }
206+ }
207+
208+ assertComplete(): void {
209+ while (this.transcript.entries[this.cursor]?.type === "runtime_exit") {
210+ const exit = this.transcript.entries[this.cursor];
211+ if (exit?.type !== "runtime_exit" || exit.status !== "success") break;
212+ this.cursor += 1;
213+ }
214+ this.throwFailure();
215+ if (this.cursor !== this.transcript.entries.length) {
216+ throw new OpenCodeReplayIncompleteError({
217+ scenario: this.transcript.scenario,
218+ cursor: this.cursor,
219+ remaining: this.transcript.entries.length - this.cursor,
220+ });
221+ }
222+ }
223+
224+ private advance(): void {
225+ this.cursor += 1;
226+ for (const waiter of this.waiters) waiter();
227+ this.waiters.clear();
228+ }
229+
230+ private fail(cause: unknown): void {
231+ this.failure = cause;
232+ for (const waiter of this.waiters) waiter();
233+ this.waiters.clear();
234+ }
235+
236+ private throwFailure(): void {
237+ if (this.failure !== null) throw this.failure;
238+ }
239+
240+ private changed(signal?: AbortSignal): Promise<void> {
241+ if (signal?.aborted === true) return Promise.resolve();
242+ return new Promise((resolve) => {
243+ const done = () => {
244+ signal?.removeEventListener("abort", done);
245+ this.waiters.delete(done);
246+ resolve();
247+ };
248+ this.waiters.add(done);
249+ signal?.addEventListener("abort", done, { once: true });
250+ });
251+ }
252+}
253+
254+function makeReplayClient(controller: OpenCodeReplayController): OpencodeClient {
255+ const request = async (operation: string, input: unknown) => {
256+ await controller.expectOutbound({ type: operation, input });
257+ return { data: await controller.response(operation) };
258+ };
259+ return {
260+ event: {
261+ subscribe: async (_input?: unknown, options?: { readonly signal?: AbortSignal }) => {
262+ await controller.expectOutbound({ type: "event.subscribe" });
263+ return { stream: controller.events(options?.signal) };
264+ },
265+ },
266+ session: {
267+ create: (input: unknown) => request("session.create", input),
268+ get: (input: unknown) => request("session.get", input),
269+ update: (input: unknown) => request("session.update", input),
270+ messages: (input: unknown) => request("session.messages", input),
271+ promptAsync: (input: unknown) => request("session.promptAsync", input),
272+ abort: (input: unknown) => request("session.abort", input),
273+ revert: (input: unknown) => request("session.revert", input),
274+ unrevert: (input: unknown) => request("session.unrevert", input),
275+ fork: (input: unknown) => request("session.fork", input),
276+ },
277+ permission: {
278+ reply: (input: unknown) => request("permission.reply", input),
279+ },
280+ question: {
281+ reply: (input: unknown) => request("question.reply", input),
282+ },
283+ mcp: {
284+ add: (input: unknown) => request("mcp.add", input),
285+ },
286+ } as unknown as OpencodeClient;
287+}
288+
289+function makeOpenCodeReplayRuntimeLayer(transcript: OpenCodeSdkReplayTranscript) {
290+ return Layer.effect(
291+ OpenCodeRuntime,
292+ Effect.gen(function* () {
293+ const controller = new OpenCodeReplayController(transcript);
294+ yield* Effect.addFinalizer(() =>
295+ Effect.sync(() => {
296+ controller.assertComplete();
297+ }),
298+ );
299+ const client = makeReplayClient(controller);
300+ return OpenCodeRuntime.of({
301+ startOpenCodeServerProcess: () =>
302+ Effect.fail(
303+ new OpenCodeRuntimeError({
304+ operation: "startOpenCodeServerProcess",
305+ detail: "OpenCode replay uses an external in-memory SDK boundary.",
306+ }),
307+ ),
308+ connectToOpenCodeServer: () =>
309+ Effect.succeed({
310+ url: "replay://opencode",
311+ exitCode: null,
312+ external: true,
313+ }),
314+ runOpenCodeCommand: () =>
315+ Effect.fail(
316+ new OpenCodeRuntimeError({
317+ operation: "runOpenCodeCommand",
318+ detail: "OpenCode replay does not execute commands.",
319+ }),
320+ ),
321+ createOpenCodeSdkClient: () => client,
322+ loadOpenCodeInventory: () =>
323+ Effect.fail(
324+ new OpenCodeRuntimeError({
325+ operation: "loadOpenCodeInventory",
326+ detail: "OpenCode replay does not load inventory.",
327+ }),
328+ ),
329+ } satisfies OpenCodeRuntimeShape);
330+ }),
331+ );
332+}
333+
334+export function makeOpenCodeProviderAdapterRegistryReplayLayer(
335+ transcript: OpenCodeSdkReplayTranscript,
336+) {
337+ const serverConfigLayer = Layer.effect(
338+ ServerConfig,
339+ makeReplayServerConfig(transcript.scenario).pipe(Effect.orDie),
340+ ).pipe(Layer.provide(NodeServices.layer));
341+ return makeProviderAdapterRegistryDriverLayer({
342+ drivers: [OpenCodeAdapterV2Driver],
343+ configMap: {
344+ [OPENCODE_DEFAULT_INSTANCE_ID]: {
345+ driver: OPENCODE_DRIVER_KIND,
346+ config: { serverUrl: "replay://opencode" },
347+ },
348+ },
349+ }).pipe(
350+ Layer.provide(
351+ Layer.mergeAll(
352+ makeOpenCodeReplayRuntimeLayer(transcript),
353+ serverConfigLayer,
354+ NodeServices.layer,
355+ idAllocatorLayer,
356+ ),
357+ ),
358+ );
359+}
360+
361+function transcriptMetadata(transcript: ProviderReplayTranscript) {
362+ return {
363+ provider: transcript.provider,
364+ protocol: transcript.protocol,
365+ scenario: transcript.scenario,
366+ };
367+}
368+
369+export const OpenCodeOrchestratorReplayHarness: OrchestratorV2ProviderReplayHarness<
370+ OpenCodeSdkReplayTranscript,
371+ OpenCodeOrchestratorReplayHarnessError
372+> = {
373+ provider: OPENCODE_PROVIDER,
374+ decodeTranscript: (transcript) =>
375+ decodeOpenCodeSdkReplayTranscript(transcript).pipe(
376+ Effect.mapError(
377+ (cause) =>
378+ new OpenCodeReplayTranscriptDecodeError({
379+ ...transcriptMetadata(transcript),
380+ cause,
381+ }),
382+ ),
383+ ),
384+ makeProviderAdapterRegistryLayer: makeOpenCodeProviderAdapterRegistryReplayLayer,
385+};
apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts+2743−0
@@ -0,0 +1,2743 @@
1+import type {
2+ Event as OpenCodeEvent,
3+ Message as OpenCodeMessage,
4+ Part as OpenCodePart,
5+ PermissionRequest,
6+ PermissionRuleset,
7+ QuestionRequest,
8+ Session as OpenCodeSession,
9+ Todo as OpenCodeTodo,
10+ ToolPart,
11+} from "@opencode-ai/sdk/v2";
12+import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
13+import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";
14+import {
15+ defaultInstanceIdForDriver,
16+ type ModelSelection,
17+ type OpenCodeSettings,
18+ type OrchestrationV2AppThread,
19+ type OrchestrationV2ConversationMessage,
20+ type OrchestrationV2ExecutionNode,
21+ type OrchestrationV2PlanStep,
22+ type OrchestrationV2ProviderCapabilities,
23+ type OrchestrationV2ProviderRef,
24+ type OrchestrationV2ProviderSession,
25+ type OrchestrationV2ProviderThread,
26+ type OrchestrationV2ProviderTurn,
27+ type OrchestrationV2RawProviderEvent,
28+ type OrchestrationV2RuntimeRequest,
29+ type OrchestrationV2Subagent,
30+ type OrchestrationV2TurnItem,
31+ OpenCodeSettings as OpenCodeSettingsSchema,
32+ type PlanId,
33+ ProviderDriverKind,
34+ type ProviderInstanceId,
35+ type ProviderRequestKind,
36+ type RuntimeRequestId,
37+ type ThreadId,
38+} from "@t3tools/contracts";
39+import * as Cause from "effect/Cause";
40+import * as DateTime from "effect/DateTime";
41+import * as Effect from "effect/Effect";
42+import * as Exit from "effect/Exit";
43+import * as Layer from "effect/Layer";
44+import * as Option from "effect/Option";
45+import * as Queue from "effect/Queue";
46+import * as Ref from "effect/Ref";
47+import * as Schema from "effect/Schema";
48+import * as Scope from "effect/Scope";
49+import * as Stream from "effect/Stream";
50+
51+import { resolveAttachmentPath } from "../../attachmentStore.ts";
52+import { ServerConfig, type ServerConfigShape } from "../../config.ts";
53+import * as McpProviderSession from "../../mcp/McpProviderSession.ts";
54+import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts";
55+import {
56+ OpenCodeRuntime,
57+ OpenCodeRuntimeError,
58+ openCodeQuestionId,
59+ openCodeRuntimeErrorDetail,
60+ parseOpenCodeModelSlug,
61+ runOpenCodeSdk,
62+ toOpenCodeFileParts,
63+ toOpenCodePermissionReply,
64+ toOpenCodeQuestionAnswers,
65+ type OpenCodeRuntimeShape,
66+} from "../../provider/opencodeRuntime.ts";
67+import { IdAllocatorV2, type IdAllocatorV2Shape } from "../IdAllocator.ts";
68+import {
69+ ProviderAdapterEnsureThreadError,
70+ ProviderAdapterForkThreadError,
71+ ProviderAdapterInterruptError,
72+ ProviderAdapterOpenSessionError,
73+ ProviderAdapterProtocolError,
74+ ProviderAdapterReadThreadSnapshotError,
75+ ProviderAdapterResumeThreadError,
76+ ProviderAdapterRollbackThreadError,
77+ ProviderAdapterRuntimeRequestResponseError,
78+ ProviderAdapterSteerRunError,
79+ ProviderAdapterTurnStartError,
80+ ProviderAdapterV2,
81+ type ProviderAdapterV2Event,
82+ type ProviderAdapterV2OpenSessionInput,
83+ type ProviderAdapterV2SessionRuntime,
84+ type ProviderAdapterV2Shape,
85+ type ProviderAdapterV2RuntimePolicy,
86+ type ProviderAdapterV2TurnInput,
87+} from "../ProviderAdapter.ts";
88+import {
89+ ProviderAdapterDriverCreateError,
90+ type ProviderAdapterDriver,
91+ type ProviderAdapterDriverCreateInput,
92+} from "../ProviderAdapterDriver.ts";
93+import { makeSubagentChildThread, subagentThreadTitle } from "../SubagentProjection.ts";
94+
95+export const OPENCODE_PROVIDER = "opencode" as const;
96+export const OPENCODE_DRIVER_KIND = ProviderDriverKind.make(OPENCODE_PROVIDER);
97+export const OPENCODE_DEFAULT_INSTANCE_ID = defaultInstanceIdForDriver(OPENCODE_DRIVER_KIND);
98+const DEFAULT_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettingsSchema)({});
99+
100+/**
101+ * OpenCode's session, message, part, and interaction-request identifiers are
102+ * durable. It does not expose a first-class turn object: the initiating user
103+ * message is the best native turn correlation point, and session idle is the
104+ * authoritative terminal signal.
105+ */
106+export const OpenCodeProviderCapabilitiesV2 = {
107+ sessions: {
108+ supportsMultipleProviderThreadsPerSession: true,
109+ supportsModelSwitchInSession: true,
110+ supportsProviderSwitchingViaHandoff: true,
111+ supportsRuntimeModeSwitchInSession: false,
112+ pendingRequestsSurviveRestart: false,
113+ },
114+ threads: {
115+ canCreateEmptyThread: true,
116+ canReadThreadSnapshot: true,
117+ canRollbackThread: true,
118+ canForkThread: true,
119+ canForkFromTurn: true,
120+ canForkFromSubagentThread: true,
121+ exposesNativeThreadId: true,
122+ },
123+ turns: {
124+ exposesNativeTurnId: false,
125+ emitsTurnStarted: true,
126+ emitsTurnCompleted: true,
127+ supportsInterrupt: true,
128+ supportsActiveSteering: true,
129+ supportsSteeringByInterruptRestart: true,
130+ supportsQueuedMessages: true,
131+ terminalStatusQuality: "strong",
132+ },
133+ streaming: {
134+ streamsAssistantText: true,
135+ streamsReasoning: true,
136+ streamsToolOutput: true,
137+ streamsPlanText: false,
138+ emitsMessageCompleted: true,
139+ },
140+ tools: {
141+ exposesToolItemIds: true,
142+ emitsToolStarted: true,
143+ emitsToolCompleted: true,
144+ emitsToolOutput: true,
145+ supportsMcpTools: true,
146+ supportsDynamicToolCallbacks: false,
147+ },
148+ approvals: {
149+ supportsCommandApproval: true,
150+ supportsFileReadApproval: true,
151+ supportsFileChangeApproval: true,
152+ supportsApplyPatchApproval: true,
153+ approvalsHaveNativeRequestIds: true,
154+ approvalCallbacksAreLiveOnly: true,
155+ approvalsCanOriginateFromSubagents: true,
156+ },
157+ planning: {
158+ emitsPlanUpdated: true,
159+ emitsTodoList: true,
160+ emitsProposedPlan: false,
161+ supportsStructuredQuestions: true,
162+ planDeltasHaveItemIds: false,
163+ },
164+ subagents: {
165+ supportsSubagents: true,
166+ exposesSubagentThreadIds: true,
167+ emitsSubagentLifecycle: true,
168+ canWaitForSubagents: true,
169+ canCloseSubagents: false,
170+ canForkSubagentThread: true,
171+ },
172+ context: {
173+ acceptsSystemContext: false,
174+ acceptsDeveloperContext: false,
175+ acceptsSyntheticUserContext: true,
176+ canGenerateSummaries: true,
177+ canConsumeHandoffSummaries: true,
178+ supportsDeltaHandoff: true,
179+ supportsFullThreadHandoff: true,
180+ maxRecommendedHandoffChars: null,
181+ },
182+ checkpointing: {
183+ appCanCheckpointFilesystem: true,
184+ supportsNestedCheckpointScopes: true,
185+ providerCanRollbackConversation: true,
186+ providerRollbackReturnsSnapshot: true,
187+ providerCanReadConversationSnapshot: true,
188+ },
189+ identity: {
190+ nativeThreadIds: "strong",
191+ nativeTurnIds: "weak",
192+ nativeItemIds: "strong",
193+ nativeRequestIds: "strong",
194+ },
195+} satisfies OrchestrationV2ProviderCapabilities;
196+
197+type TerminalTurnStatus = Extract<
198+ OrchestrationV2ProviderTurn["status"],
199+ "completed" | "interrupted" | "failed" | "cancelled"
200+>;
201+
202+interface ActiveOpenCodeTurn {
203+ readonly isRoot: boolean;
204+ readonly threadId: ThreadId;
205+ readonly runId: ProviderAdapterV2TurnInput["runId"] | null;
206+ readonly rootNodeId: ProviderAdapterV2TurnInput["rootNodeId"];
207+ readonly appThread: OrchestrationV2AppThread;
208+ readonly modelSelection: ModelSelection;
209+ readonly runtimePolicy: ProviderAdapterV2RuntimePolicy;
210+ readonly providerTurnId: OrchestrationV2ProviderTurn["id"];
211+ readonly providerTurnOrdinal: number;
212+ readonly runAttemptId: OrchestrationV2ProviderTurn["runAttemptId"];
213+ readonly startedAt: DateTime.Utc;
214+ readonly itemOrdinals: Map<string, number>;
215+ readonly parts: Map<string, OpenCodePart>;
216+ readonly partIdsByMessage: Map<string, Set<string>>;
217+ readonly providerTurn: OrchestrationV2ProviderTurn;
218+ nextItemOrdinal: number;
219+ nativeUserMessageId: string | null;
220+ interrupted: boolean;
221+ finalized: boolean;
222+ planId: PlanId | null;
223+}
224+
225+interface OpenCodeSubagentContext {
226+ readonly nativeItemId: string;
227+ readonly nodeId: OrchestrationV2Subagent["id"];
228+ readonly parentTurn: ActiveOpenCodeTurn;
229+ readonly prompt: string;
230+ readonly title: string | null;
231+ readonly startedAt: DateTime.Utc;
232+ childSessionId: string | null;
233+ childThreadId: ThreadId | null;
234+ childProviderThreadId: OrchestrationV2ProviderThread["id"] | null;
235+ model: string | null;
236+ result: string | null;
237+}
238+
239+interface OpenCodeThreadState {
240+ readonly nativeSessionId: string;
241+ providerThread: OrchestrationV2ProviderThread;
242+ appThread: OrchestrationV2AppThread | null;
243+ activeTurn: ActiveOpenCodeTurn | null;
244+ readonly providerTurns: Map<string, OrchestrationV2ProviderTurn>;
245+ readonly messages: Map<string, OrchestrationV2ConversationMessage>;
246+ readonly runtimeRequests: Map<string, OrchestrationV2RuntimeRequest>;
247+ readonly messageRoles: Map<string, "user" | "assistant">;
248+ readonly userMessageIds: Array<string>;
249+ parentSubagent: OpenCodeSubagentContext | null;
250+ nextChildTurnOrdinal: number;
251+}
252+
253+interface PendingOpenCodeRequest {
254+ readonly requestId: RuntimeRequestId;
255+ readonly nativeRequestId: string;
256+ readonly turn: ActiveOpenCodeTurn;
257+ readonly state: OpenCodeThreadState;
258+ readonly nodeId: OrchestrationV2ExecutionNode["id"];
259+ readonly turnItemId: OrchestrationV2TurnItem["id"];
260+ readonly requestKind: OpenCodePermissionRequestKind | "user_input";
261+ readonly createdAt: DateTime.Utc;
262+ readonly permission?: PermissionRequest;
263+ readonly question?: QuestionRequest;
264+}
265+
266+export interface OpenCodeAdapterV2Options {
267+ readonly instanceId: ProviderInstanceId;
268+ readonly settings: OpenCodeSettings;
269+ readonly environment: NodeJS.ProcessEnv;
270+ readonly runtime: OpenCodeRuntimeShape;
271+ readonly idAllocator: IdAllocatorV2Shape;
272+ readonly serverConfig: ServerConfigShape;
273+}
274+
275+function protocolError(detail: string, payload?: unknown): ProviderAdapterProtocolError {
276+ return new ProviderAdapterProtocolError({
277+ provider: OPENCODE_PROVIDER,
278+ detail,
279+ ...(payload === undefined ? {} : { payload }),
280+ });
281+}
282+
283+function nativeThreadId(providerThread: OrchestrationV2ProviderThread): string {
284+ const nativeId = providerThread.nativeThreadRef?.nativeId;
285+ if (nativeId === null || nativeId === undefined) {
286+ throw protocolError(`Provider thread ${providerThread.id} has no OpenCode session id`);
287+ }
288+ return nativeId;
289+}
290+
291+function dateTimeFromEpoch(value: number | undefined, fallback: DateTime.Utc): DateTime.Utc {
292+ if (value === undefined) return fallback;
293+ return Option.getOrElse(DateTime.make(value), () => fallback);
294+}
295+
296+function nonEmptyString(value: unknown): string | undefined {
297+ return typeof value === "string" && value.trim().length > 0 ? value : undefined;
298+}
299+
300+function recordValue(input: unknown, key: string): unknown {
301+ return typeof input === "object" && input !== null && key in input
302+ ? (input as Record<string, unknown>)[key]
303+ : undefined;
304+}
305+
306+function recordString(input: unknown, ...keys: ReadonlyArray<string>): string | undefined {
307+ for (const key of keys) {
308+ const value = nonEmptyString(recordValue(input, key));
309+ if (value !== undefined) return value;
310+ }
311+ return undefined;
312+}
313+
314+function recordNumber(input: unknown, ...keys: ReadonlyArray<string>): number | undefined {
315+ for (const key of keys) {
316+ const value = recordValue(input, key);
317+ if (typeof value === "number" && Number.isFinite(value)) return value;
318+ }
319+ return undefined;
320+}
321+
322+function stableJson(value: unknown): string {
323+ if (typeof value === "string") return value;
324+ try {
325+ return JSON.stringify(value, null, 2);
326+ } catch {
327+ return String(value);
328+ }
329+}
330+
331+function sdkResponseForRawLog(value: unknown): unknown {
332+ if (typeof value !== "object" || value === null) return value;
333+ if ("data" in value) return { data: (value as { readonly data?: unknown }).data ?? null };
334+ if ("stream" in value) return { subscribed: true };
335+ return value;
336+}
337+
338+type OpenCodePermissionRequestKind = Extract<
339+ ProviderRequestKind,
340+ "command" | "file-read" | "file-change"
341+>;
342+
343+export function openCodePermissionRequestKind(
344+ permission: string,
345+ toolName?: string,
346+): OpenCodePermissionRequestKind {
347+ const normalized = permission.toLowerCase();
348+ const normalizedTool = toolName?.toLowerCase() ?? "";
349+ if (
350+ normalized === "edit" ||
351+ normalized.includes("write") ||
352+ normalized.includes("patch") ||
353+ normalizedTool.includes("edit") ||
354+ normalizedTool.includes("write") ||
355+ normalizedTool.includes("patch")
356+ ) {
357+ return "file-change";
358+ }
359+ if (
360+ normalized === "read" ||
361+ normalized === "glob" ||
362+ normalized === "grep" ||
363+ normalized === "lsp" ||
364+ normalized === "external_directory" ||
365+ normalizedTool === "read" ||
366+ normalizedTool.includes("glob") ||
367+ normalizedTool.includes("grep") ||
368+ normalizedTool.includes("search")
369+ ) {
370+ return "file-read";
371+ }
372+ return "command";
373+}
374+
375+export function openCodeToolProjectionKind(
376+ toolName: string,
377+): "command_execution" | "file_change" | "file_search" | "web_search" | "dynamic_tool" {
378+ const normalized = toolName.toLowerCase();
379+ if (normalized.includes("bash") || normalized.includes("shell")) {
380+ return "command_execution";
381+ }
382+ if (normalized.includes("edit") || normalized.includes("write") || normalized.includes("patch")) {
383+ return "file_change";
384+ }
385+ if (normalized.includes("web") || normalized === "codesearch" || normalized === "code_search") {
386+ return "web_search";
387+ }
388+ if (
389+ normalized === "read" ||
390+ normalized.includes("glob") ||
391+ normalized.includes("grep") ||
392+ normalized.includes("search") ||
393+ normalized.includes("lsp")
394+ ) {
395+ return "file_search";
396+ }
397+ return "dynamic_tool";
398+}
399+
400+const OPENCODE_ALWAYS_ALLOWED_PERMISSIONS = [
401+ "question",
402+ "read",
403+ "glob",
404+ "grep",
405+ "lsp",
406+ "todowrite",
407+ "task",
408+ "skill",
409+] as const;
410+
411+const OPENCODE_RESTRICTED_PERMISSIONS = [
412+ "bash",
413+ "edit",
414+ "webfetch",
415+ "websearch",
416+ "codesearch",
417+ "external_directory",
418+ "doom_loop",
419+] as const;
420+
421+/**
422+ * OpenCode does not provide an OS sandbox, so permission rules are also the
423+ * enforcement boundary for non-interactive policies. Read/planning tools are
424+ * safe by default; edits are auto-approved only for workspace-write modes,
425+ * while shell/network/external access remains gated unless policy explicitly
426+ * allows it.
427+ */
428+export function openCodePermissionRules(
429+ runtimePolicy: ProviderAdapterV2RuntimePolicy,
430+): PermissionRuleset {
431+ const sandboxPolicy = recordValue(runtimePolicy, "sandboxPolicy");
432+ const sandboxType = recordString(sandboxPolicy, "type");
433+ const rawApprovalPolicy = runtimePolicy.approvalPolicy;
434+ const approvalPolicy = nonEmptyString(rawApprovalPolicy);
435+ const requiresApproval =
436+ approvalPolicy === undefined
437+ ? (typeof rawApprovalPolicy === "object" && rawApprovalPolicy !== null) ||
438+ runtimePolicy.runtimeMode !== "full-access"
439+ : approvalPolicy !== "never";
440+ const externallySandboxed = sandboxType === "externalSandbox";
441+ const dangerFullAccess = sandboxType === "dangerFullAccess";
442+ const implicitFullAccess =
443+ sandboxType === undefined && runtimePolicy.runtimeMode === "full-access";
444+
445+ if (!requiresApproval && (externallySandboxed || dangerFullAccess || implicitFullAccess)) {
446+ return [{ permission: "*", pattern: "*", action: "allow" }];
447+ }
448+
449+ // Task sessions initially inherit only parent deny rules. Seed explicit
450+ // denies before the effective ask/allow overrides so a child is safe during
451+ // the short interval before emitSubagent installs its complete policy.
452+ const rules: PermissionRuleset = [
453+ { permission: "*", pattern: "*", action: "deny" },
454+ ...OPENCODE_RESTRICTED_PERMISSIONS.map((permission) => ({
455+ permission,
456+ pattern: "*",
457+ action: "deny" as const,
458+ })),
459+ ];
460+
461+ if (requiresApproval) {
462+ rules.push({ permission: "*", pattern: "*", action: "ask" });
463+ for (const permission of OPENCODE_RESTRICTED_PERMISSIONS) {
464+ rules.push({ permission, pattern: "*", action: "ask" });
465+ }
466+ }
467+
468+ rules.push(
469+ ...OPENCODE_ALWAYS_ALLOWED_PERMISSIONS.map((permission) => ({
470+ permission,
471+ pattern: "*",
472+ action: "allow" as const,
473+ })),
474+ );
475+
476+ if (runtimePolicy.runtimeMode === "auto-accept-edits" || sandboxType === "workspaceWrite") {
477+ rules.push({ permission: "edit", pattern: "*", action: "allow" });
478+ }
479+
480+ if (!requiresApproval && recordValue(sandboxPolicy, "networkAccess") === true) {
481+ for (const permission of ["webfetch", "websearch", "codesearch"] as const) {
482+ rules.push({ permission, pattern: "*", action: "allow" });
483+ }
484+ }
485+
486+ if (!requiresApproval && sandboxType === "readOnly") {
487+ const access = recordValue(sandboxPolicy, "access");
488+ if (recordString(access, "type") === "fullAccess") {
489+ rules.push({ permission: "external_directory", pattern: "*", action: "allow" });
490+ }
491+ }
492+
493+ if (!requiresApproval && sandboxType === "workspaceWrite") {
494+ const writableRoots = recordValue(sandboxPolicy, "writableRoots");
495+ if (Array.isArray(writableRoots)) {
496+ for (const root of writableRoots) {
497+ if (typeof root === "string" && root.trim().length > 0) {
498+ rules.push({
499+ permission: "external_directory",
500+ pattern: `${root.replace(/\/$/, "")}/*`,
501+ action: "allow",
502+ });
503+ }
504+ }
505+ }
506+ }
507+
508+ return rules;
509+}
510+
511+function permissionRuleEquals(
512+ left: PermissionRuleset[number],
513+ right: PermissionRuleset[number],
514+): boolean {
515+ return (
516+ left.permission === right.permission &&
517+ left.pattern === right.pattern &&
518+ left.action === right.action
519+ );
520+}
521+
522+/**
523+ * OpenCode task sessions inherit only the parent's deny/external-directory
524+ * rules and then add agent-specific restrictions such as disabling nested
525+ * tasks. Install the complete parent policy while retaining only rules that
526+ * were added specifically for the selected child agent.
527+ */
528+export function openCodeChildPermissionRules(
529+ runtimePolicy: ProviderAdapterV2RuntimePolicy,
530+ nativeChildRules: PermissionRuleset,
531+): PermissionRuleset {
532+ const parentRules = openCodePermissionRules(runtimePolicy);
533+ const inheritedRules = parentRules.filter(
534+ (rule) => rule.permission === "external_directory" || rule.action === "deny",
535+ );
536+ const childSpecificRules = nativeChildRules.filter(
537+ (childRule) =>
538+ !inheritedRules.some((inheritedRule) => permissionRuleEquals(childRule, inheritedRule)),
539+ );
540+ return [...parentRules, ...childSpecificRules];
541+}
542+
543+/**
544+ * OpenCode's fork/revert boundary is exclusive. To retain the selected app
545+ * turn, address the next native user message; omitting a boundary retains the
546+ * current head when the selected turn is already last.
547+ */
548+export function openCodeBoundaryAfterProviderTurn(
549+ providerTurns: ReadonlyArray<OrchestrationV2ProviderTurn>,
550+ selectedProviderTurnId: OrchestrationV2ProviderTurn["id"],
551+): string | undefined {
552+ const selected = providerTurns.find((turn) => turn.id === selectedProviderTurnId);
553+ if (selected === undefined) return undefined;
554+ return providerTurns
555+ .filter((turn) => turn.ordinal > selected.ordinal)
556+ .toSorted((left, right) => left.ordinal - right.ordinal)
557+ .map((turn) => turn.nativeTurnRef?.nativeId)
558+ .find((nativeId): nativeId is string => nativeId !== null && nativeId !== undefined);
559+}
560+
561+function toolStatus(part: ToolPart): {
562+ readonly node: OrchestrationV2ExecutionNode["status"];
563+ readonly item: OrchestrationV2TurnItem["status"];
564+} {
565+ switch (part.state.status) {
566+ case "pending":
567+ return { node: "pending", item: "pending" };
568+ case "running":
569+ return { node: "running", item: "running" };
570+ case "completed":
571+ return { node: "completed", item: "completed" };
572+ case "error":
573+ return { node: "failed", item: "failed" };
574+ }
575+}
576+
577+function toolInput(part: ToolPart): Record<string, unknown> {
578+ return part.state.input;
579+}
580+
581+function toolOutput(part: ToolPart): string | undefined {
582+ if (part.state.status === "completed") return part.state.output;
583+ if (part.state.status === "error") return part.state.error;
584+ return undefined;
585+}
586+
587+function toolStartedAt(part: ToolPart, now: DateTime.Utc): DateTime.Utc {
588+ return dateTimeFromEpoch(
589+ part.state.status === "pending" ? undefined : part.state.time.start,
590+ now,
591+ );
592+}
593+
594+function toolCompletedAt(part: ToolPart, now: DateTime.Utc): DateTime.Utc | null {
595+ return part.state.status === "completed" || part.state.status === "error"
596+ ? dateTimeFromEpoch(part.state.time.end, now)
597+ : null;
598+}
599+
600+function toolTitle(part: ToolPart): string | null {
601+ return part.state.status === "running" || part.state.status === "completed"
602+ ? (part.state.title ?? null)
603+ : null;
604+}
605+
606+function toolModel(part: ToolPart): string | null {
607+ const metadata =
608+ part.state.status === "running" ||
609+ part.state.status === "completed" ||
610+ part.state.status === "error"
611+ ? part.state.metadata
612+ : undefined;
613+ const model = recordValue(metadata, "model");
614+ const providerId = recordString(model, "providerID", "providerId");
615+ const modelId = recordString(model, "modelID", "modelId", "id");
616+ return providerId !== undefined && modelId !== undefined ? `${providerId}/${modelId}` : null;
617+}
618+
619+function taskSessionId(part: ToolPart): string | null {
620+ const metadata =
621+ part.state.status === "running" ||
622+ part.state.status === "completed" ||
623+ part.state.status === "error"
624+ ? part.state.metadata
625+ : undefined;
626+ return recordString(metadata, "sessionId", "sessionID") ?? null;
627+}
628+
629+function makeProviderThread(input: {
630+ readonly idAllocator: IdAllocatorV2Shape;
631+ readonly providerSessionId: OrchestrationV2ProviderThread["providerSessionId"];
632+ readonly appThreadId: OrchestrationV2ProviderThread["appThreadId"];
633+ readonly ownerNodeId?: OrchestrationV2ProviderThread["ownerNodeId"];
634+ readonly nativeSession: OpenCodeSession;
635+ readonly forkedFrom?: OrchestrationV2ProviderThread["forkedFrom"];
636+ readonly now: DateTime.Utc;
637+}): OrchestrationV2ProviderThread {
638+ const createdAt = dateTimeFromEpoch(input.nativeSession.time.created, input.now);
639+ return {
640+ id: input.idAllocator.derive.providerThread({
641+ provider: OPENCODE_PROVIDER,
642+ nativeThreadId: input.nativeSession.id,
643+ }),
644+ provider: OPENCODE_PROVIDER,
645+ providerSessionId: input.providerSessionId,
646+ appThreadId: input.appThreadId,
647+ ownerNodeId: input.ownerNodeId ?? null,
648+ nativeThreadRef: {
649+ provider: OPENCODE_PROVIDER,
650+ nativeId: input.nativeSession.id,
651+ strength: "strong",
652+ },
653+ nativeConversationHeadRef: null,
654+ status: "idle",
655+ firstRunOrdinal: null,
656+ lastRunOrdinal: null,
657+ handoffIds: [],
658+ forkedFrom: input.forkedFrom ?? null,
659+ createdAt,
660+ updatedAt: dateTimeFromEpoch(input.nativeSession.time.updated, input.now),
661+ };
662+}
663+
664+function providerRef(nativeId: string, strength: "strong" | "weak" = "strong") {
665+ return {
666+ provider: OPENCODE_PROVIDER,
667+ nativeId,
668+ strength,
669+ } satisfies OrchestrationV2ProviderRef;
670+}
671+
672+function openCodeErrorMessage(event: Extract<OpenCodeEvent, { type: "session.error" }>): string {
673+ const error = event.properties.error;
674+ if (error === undefined) return "OpenCode session failed without an error payload.";
675+ return recordString(error.data, "message") ?? error.name;
676+}
677+
678+function terminalStatusForError(
679+ event: Extract<OpenCodeEvent, { type: "session.error" }>,
680+ turn: ActiveOpenCodeTurn,
681+): TerminalTurnStatus {
682+ return turn.interrupted || isMessageAbortedError(event) ? "interrupted" : "failed";
683+}
684+
685+function isMessageAbortedError(event: Extract<OpenCodeEvent, { type: "session.error" }>): boolean {
686+ return event.properties.error?.name === "MessageAbortedError";
687+}
688+
689+function unwrapData<A>(operation: string, result: { readonly data?: A }): NonNullable<A> {
690+ if (result.data === undefined) {
691+ throw new OpenCodeRuntimeError({
692+ operation,
693+ detail: `OpenCode ${operation} returned no response payload.`,
694+ });
695+ }
696+ return result.data as NonNullable<A>;
697+}
698+
699+export function makeOpenCodeAdapterV2(options: OpenCodeAdapterV2Options): ProviderAdapterV2Shape {
700+ const { idAllocator, runtime, serverConfig } = options;
701+
702+ return ProviderAdapterV2.of({
703+ instanceId: options.instanceId,
704+ provider: OPENCODE_PROVIDER,
705+ getCapabilities: () => Effect.succeed(OpenCodeProviderCapabilitiesV2),
706+ openSession: Effect.fn("OpenCodeAdapterV2.openSession")(
707+ function* (input: ProviderAdapterV2OpenSessionInput) {
708+ const scope = yield* Effect.scope;
709+ const cwd = input.runtimePolicy.cwd ?? serverConfig.cwd;
710+ const connection = yield* runtime.connectToOpenCodeServer({
711+ binaryPath: options.settings.binaryPath,
712+ serverUrl: options.settings.serverUrl,
713+ environment: options.environment,
714+ });
715+ const client = runtime.createOpenCodeSdkClient({
716+ baseUrl: connection.url,
717+ directory: cwd,
718+ ...(connection.external && options.settings.serverPassword
719+ ? { serverPassword: options.settings.serverPassword }
720+ : {}),
721+ });
722+
723+ const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId);
724+ if (mcpSession !== undefined && !connection.external) {
725+ yield* runOpenCodeSdk("mcp.add", () =>
726+ client.mcp.add({
727+ name: "t3-code",
728+ config: {
729+ type: "remote",
730+ url: mcpSession.endpoint,
731+ headers: { Authorization: mcpSession.authorizationHeader },
732+ oauth: false,
733+ },
734+ }),
735+ );
736+ }
737+
738+ const now = yield* DateTime.now;
739+ let sessionEntity: OrchestrationV2ProviderSession = {
740+ id: input.providerSessionId,
741+ provider: OPENCODE_PROVIDER,
742+ status: "ready",
743+ cwd,
744+ model: input.modelSelection.model,
745+ capabilities: OpenCodeProviderCapabilitiesV2,
746+ createdAt: now,
747+ updatedAt: now,
748+ lastError: null,
749+ };
750+ const events = yield* Queue.unbounded<ProviderAdapterV2Event>();
751+ const rawEvents = yield* Queue.unbounded<OrchestrationV2RawProviderEvent>();
752+ const rawSequence = yield* Ref.make(0);
753+ const threads = new Map<string, OpenCodeThreadState>();
754+ const pendingRequests = new Map<string, PendingOpenCodeRequest>();
755+ const pendingRequestsByNativeId = new Map<string, PendingOpenCodeRequest>();
756+ const subagentsByNativeItemId = new Map<string, OpenCodeSubagentContext>();
757+ const subagentsByChildSessionId = new Map<string, OpenCodeSubagentContext>();
758+ const abortController = new AbortController();
759+
760+ const emitProviderEvent = (event: ProviderAdapterV2Event) =>
761+ Queue.offer(events, event).pipe(Effect.asVoid);
762+
763+ const emitRawEvent = (raw: {
764+ readonly direction: "incoming" | "outgoing";
765+ readonly messageKind: OrchestrationV2RawProviderEvent["messageKind"];
766+ readonly method: string;
767+ readonly payload: unknown;
768+ }): Effect.Effect<void, never> =>
769+ Effect.gen(function* () {
770+ const sequence = yield* Ref.updateAndGet(rawSequence, (value) => value + 1);
771+ const observedAt = yield* DateTime.now;
772+ const rawEventId = yield* idAllocator.allocate.rawEvent({
773+ providerSessionId: input.providerSessionId,
774+ method: raw.method,
775+ });
776+ yield* Queue.offer(rawEvents, {
777+ id: rawEventId,
778+ provider: OPENCODE_PROVIDER,
779+ providerSessionId: input.providerSessionId,
780+ sequence,
781+ direction: raw.direction,
782+ messageKind: raw.messageKind,
783+ method: raw.method,
784+ jsonRpcId: null,
785+ payload: raw.payload,
786+ observedAt,
787+ });
788+ }).pipe(
789+ Effect.catchCause((cause) =>
790+ Effect.logWarning("orchestration-v2.opencode-raw-event-failed", {
791+ providerSessionId: input.providerSessionId,
792+ cause,
793+ }),
794+ ),
795+ );
796+
797+ const sdkCall = <A>(
798+ method: string,
799+ payload: unknown,
800+ call: () => Promise<A>,
801+ ): Effect.Effect<A, OpenCodeRuntimeError> =>
802+ emitRawEvent({
803+ direction: "outgoing",
804+ messageKind: "request",
805+ method,
806+ payload,
807+ }).pipe(
808+ Effect.andThen(runOpenCodeSdk(method, call)),
809+ Effect.tap((response) =>
810+ emitRawEvent({
811+ direction: "incoming",
812+ messageKind: "response",
813+ method,
814+ payload: sdkResponseForRawLog(response),
815+ }),
816+ ),
817+ );
818+
819+ const updateProviderSession = (
820+ status: OrchestrationV2ProviderSession["status"],
821+ lastError: string | null = sessionEntity.lastError,
822+ ) =>
823+ Effect.gen(function* () {
824+ const updatedAt = yield* DateTime.now;
825+ sessionEntity = { ...sessionEntity, status, lastError, updatedAt };
826+ yield* emitProviderEvent({
827+ type: "provider_session.updated",
828+ provider: OPENCODE_PROVIDER,
829+ providerSession: sessionEntity,
830+ });
831+ });
832+
833+ const updateProviderThread = (
834+ state: OpenCodeThreadState,
835+ patch: Partial<OrchestrationV2ProviderThread>,
836+ ) =>
837+ Effect.gen(function* () {
838+ const updatedAt = yield* DateTime.now;
839+ state.providerThread = { ...state.providerThread, ...patch, updatedAt };
840+ yield* emitProviderEvent({
841+ type: "provider_thread.updated",
842+ provider: OPENCODE_PROVIDER,
843+ providerThread: state.providerThread,
844+ });
845+ });
846+
847+ const itemOrdinal = (turn: ActiveOpenCodeTurn, nativeItemId: string): number => {
848+ const existing = turn.itemOrdinals.get(nativeItemId);
849+ if (existing !== undefined) return existing;
850+ const ordinal = turn.nextItemOrdinal++;
851+ turn.itemOrdinals.set(nativeItemId, ordinal);
852+ return ordinal;
853+ };
854+
855+ const emitProviderTurn = (
856+ state: OpenCodeThreadState,
857+ turn: ActiveOpenCodeTurn,
858+ status: OrchestrationV2ProviderTurn["status"],
859+ completedAt: DateTime.Utc | null,
860+ ) => {
861+ const providerTurn: OrchestrationV2ProviderTurn = {
862+ ...turn.providerTurn,
863+ nativeTurnRef:
864+ turn.nativeUserMessageId === null
865+ ? turn.providerTurn.nativeTurnRef
866+ : providerRef(turn.nativeUserMessageId, "weak"),
867+ status,
868+ completedAt,
869+ };
870+ Object.assign(turn.providerTurn, providerTurn);
871+ state.providerTurns.set(String(providerTurn.id), providerTurn);
872+ return emitProviderEvent({
873+ type: "provider_turn.updated",
874+ provider: OPENCODE_PROVIDER,
875+ threadId: turn.threadId,
876+ providerTurn,
877+ });
878+ };
879+
880+ const emitTextPart = Effect.fnUntraced(function* (
881+ state: OpenCodeThreadState,
882+ turn: ActiveOpenCodeTurn,
883+ part: Extract<OpenCodePart, { type: "text" | "reasoning" }>,
884+ forceCompleted = false,
885+ ) {
886+ if (part.type === "text" && (part.ignored === true || part.synthetic === true)) return;
887+ if (part.text.length === 0) return;
888+ const emittedAt = yield* DateTime.now;
889+ const isCompleted = forceCompleted || part.time?.end !== undefined;
890+ const startedAt = dateTimeFromEpoch(part.time?.start, emittedAt);
891+ const completedAt = isCompleted ? dateTimeFromEpoch(part.time?.end, emittedAt) : null;
892+ const nativeItemRef = providerRef(part.id);
893+ const nodeId = idAllocator.derive.nodeFromProviderItem({
894+ provider: OPENCODE_PROVIDER,
895+ nativeItemId: part.id,
896+ });
897+ const turnItemId = idAllocator.derive.turnItemFromProviderItem({
898+ provider: OPENCODE_PROVIDER,
899+ nativeItemId: part.id,
900+ });
901+ const ordinal = itemOrdinal(turn, part.id);
902+ yield* emitProviderEvent({
903+ type: "node.updated",
904+ provider: OPENCODE_PROVIDER,
905+ node: {
906+ id: nodeId,
907+ threadId: turn.threadId,
908+ runId: turn.runId,
909+ parentNodeId: turn.rootNodeId,
910+ rootNodeId: turn.rootNodeId,
911+ kind: part.type === "text" ? "assistant_message" : "reasoning",
912+ status: isCompleted ? "completed" : "running",
913+ countsForRun: false,
914+ providerThreadId: state.providerThread.id,
915+ providerTurnId: turn.providerTurnId,
916+ nativeItemRef,
917+ runtimeRequestId: null,
918+ checkpointScopeId: null,
919+ startedAt,
920+ completedAt,
921+ },
922+ });
923+ if (part.type === "text") {
924+ const messageId = idAllocator.derive.messageFromProviderItem({
925+ provider: OPENCODE_PROVIDER,
926+ nativeItemId: part.id,
927+ });
928+ const message: OrchestrationV2ConversationMessage = {
929+ id: messageId,
930+ threadId: turn.threadId,
931+ runId: turn.runId,
932+ nodeId,
933+ role: "assistant",
934+ text: part.text,
935+ attachments: [],
936+ streaming: !isCompleted,
937+ createdAt: startedAt,
938+ updatedAt: emittedAt,
939+ };
940+ state.messages.set(String(message.id), message);
941+ yield* emitProviderEvent({
942+ type: "message.updated",
943+ provider: OPENCODE_PROVIDER,
944+ message,
945+ });
946+ yield* emitProviderEvent({
947+ type: "turn_item.updated",
948+ provider: OPENCODE_PROVIDER,
949+ turnItem: {
950+ id: turnItemId,
951+ threadId: turn.threadId,
952+ runId: turn.runId,
953+ nodeId,
954+ providerThreadId: state.providerThread.id,
955+ providerTurnId: turn.providerTurnId,
956+ nativeItemRef,
957+ parentItemId: null,
958+ ordinal,
959+ status: isCompleted ? "completed" : "running",
960+ title: null,
961+ startedAt,
962+ completedAt,
963+ updatedAt: emittedAt,
964+ type: "assistant_message",
965+ messageId,
966+ text: part.text,
967+ streaming: !isCompleted,
968+ },
969+ });
970+ return;
971+ }
972+ yield* emitProviderEvent({
973+ type: "turn_item.updated",
974+ provider: OPENCODE_PROVIDER,
975+ turnItem: {
976+ id: turnItemId,
977+ threadId: turn.threadId,
978+ runId: turn.runId,
979+ nodeId,
980+ providerThreadId: state.providerThread.id,
981+ providerTurnId: turn.providerTurnId,
982+ nativeItemRef,
983+ parentItemId: null,
984+ ordinal,
985+ status: isCompleted ? "completed" : "running",
986+ title: null,
987+ startedAt,
988+ completedAt,
989+ updatedAt: emittedAt,
990+ type: "reasoning",
991+ text: part.text,
992+ streaming: !isCompleted,
993+ },
994+ });
995+ });
996+
997+ const emitSubagent = Effect.fnUntraced(function* (
998+ state: OpenCodeThreadState,
999+ turn: ActiveOpenCodeTurn,
1000+ part: ToolPart,
1001+ ) {
1002+ const now = yield* DateTime.now;
1003+ const nativeItemRef = providerRef(part.id);
1004+ const nodeId = idAllocator.derive.nodeFromProviderItem({
1005+ provider: OPENCODE_PROVIDER,
1006+ nativeItemId: part.id,
1007+ });
1008+ const turnItemId = idAllocator.derive.turnItemFromProviderItem({
1009+ provider: OPENCODE_PROVIDER,
1010+ nativeItemId: part.id,
1011+ });
1012+ const input = toolInput(part);
1013+ const prompt = recordString(input, "prompt") ?? "";
1014+ const title = toolTitle(part) ?? recordString(input, "description") ?? null;
1015+ let context = subagentsByNativeItemId.get(part.id);
1016+ if (context === undefined) {
1017+ context = {
1018+ nativeItemId: part.id,
1019+ nodeId,
1020+ parentTurn: turn,
1021+ prompt,
1022+ title,
1023+ startedAt: toolStartedAt(part, now),
1024+ childSessionId: null,
1025+ childThreadId: null,
1026+ childProviderThreadId: null,
1027+ model: null,
1028+ result: null,
1029+ };
1030+ subagentsByNativeItemId.set(part.id, context);
1031+ }
1032+ context.model = toolModel(part) ?? context.model;
1033+ const childSessionId = taskSessionId(part);
1034+ if (childSessionId !== null && context.childSessionId === null) {
1035+ context.childSessionId = childSessionId;
1036+ context.childThreadId = idAllocator.derive.threadFromProviderThread({
1037+ provider: OPENCODE_PROVIDER,
1038+ nativeThreadId: childSessionId,
1039+ });
1040+ context.childProviderThreadId = idAllocator.derive.providerThread({
1041+ provider: OPENCODE_PROVIDER,
1042+ nativeThreadId: childSessionId,
1043+ });
1044+ subagentsByChildSessionId.set(childSessionId, context);
1045+ const childModelSelection: ModelSelection = {
1046+ instanceId: options.instanceId,
1047+ model: context.model ?? turn.modelSelection.model,
1048+ };
1049+ const childThread = makeSubagentChildThread({
1050+ parentThread: turn.appThread,
1051+ childThreadId: context.childThreadId,
1052+ parentNodeId: nodeId,
1053+ activeProviderThreadId: context.childProviderThreadId,
1054+ providerInstanceId: options.instanceId,
1055+ modelSelection: childModelSelection,
1056+ title: subagentThreadTitle({
1057+ parentTitle: turn.appThread.title,
1058+ title,
1059+ prompt,
1060+ ordinal: itemOrdinal(turn, part.id),
1061+ }),
1062+ now,
1063+ });
1064+ const childProviderThread: OrchestrationV2ProviderThread = {
1065+ id: context.childProviderThreadId,
1066+ provider: OPENCODE_PROVIDER,
1067+ providerSessionId: inputProviderSessionId,
1068+ appThreadId: context.childThreadId,
1069+ ownerNodeId: nodeId,
1070+ nativeThreadRef: providerRef(childSessionId),
1071+ nativeConversationHeadRef: null,
1072+ status: "active",
1073+ firstRunOrdinal: null,
1074+ lastRunOrdinal: null,
1075+ handoffIds: [],
1076+ forkedFrom: null,
1077+ createdAt: now,
1078+ updatedAt: now,
1079+ };
1080+ const childSessionResponse = yield* sdkCall(
1081+ "session.get",
1082+ { sessionID: childSessionId },
1083+ () => client.session.get({ sessionID: childSessionId }),
1084+ );
1085+ const nativeChildSession = unwrapData("session.get", childSessionResponse);
1086+ const childPermission = openCodeChildPermissionRules(
1087+ turn.runtimePolicy,
1088+ nativeChildSession.permission ?? [],
1089+ );
1090+ yield* sdkCall(
1091+ "session.update",
1092+ { sessionID: childSessionId, permission: childPermission },
1093+ () =>
1094+ client.session.update({
1095+ sessionID: childSessionId,
1096+ permission: childPermission,
1097+ }),
1098+ );
1099+ threads.set(childSessionId, {
1100+ nativeSessionId: childSessionId,
1101+ providerThread: childProviderThread,
1102+ appThread: childThread,
1103+ activeTurn: null,
1104+ providerTurns: new Map(),
1105+ messages: new Map(),
1106+ runtimeRequests: new Map(),
1107+ messageRoles: new Map(),
1108+ userMessageIds: [],
1109+ parentSubagent: context,
1110+ nextChildTurnOrdinal: 1,
1111+ });
1112+ yield* emitProviderEvent({
1113+ type: "app_thread.created",
1114+ provider: OPENCODE_PROVIDER,
1115+ appThread: childThread,
1116+ });
1117+ yield* emitProviderEvent({
1118+ type: "provider_thread.updated",
1119+ provider: OPENCODE_PROVIDER,
1120+ providerThread: childProviderThread,
1121+ });
1122+ }
1123+ const output = toolOutput(part);
1124+ if (part.state.status === "completed" && output !== undefined) context.result = output;
1125+ const status = toolStatus(part);
1126+ const completedAt = toolCompletedAt(part, now);
1127+ const subagentStatus: OrchestrationV2Subagent["status"] =
1128+ status.item === "failed"
1129+ ? "failed"
1130+ : status.item === "completed"
1131+ ? "completed"
1132+ : status.item === "pending"
1133+ ? "pending"
1134+ : "running";
1135+ const subagent: OrchestrationV2Subagent = {
1136+ id: nodeId,
1137+ threadId: turn.threadId,
1138+ runId: turn.runId,
1139+ parentNodeId: turn.rootNodeId,
1140+ origin: "provider_native",
1141+ createdBy: "agent",
1142+ provider: OPENCODE_PROVIDER,
1143+ providerThreadId: context.childProviderThreadId,
1144+ childThreadId: context.childThreadId,
1145+ nativeTaskRef: nativeItemRef,
1146+ prompt,
1147+ title,
1148+ model: context.model,
1149+ status: subagentStatus,
1150+ result: context.result,
1151+ startedAt: context.startedAt,
1152+ completedAt,
1153+ updatedAt: now,
1154+ };
1155+ yield* emitProviderEvent({
1156+ type: "node.updated",
1157+ provider: OPENCODE_PROVIDER,
1158+ node: {
1159+ id: nodeId,
1160+ threadId: turn.threadId,
1161+ runId: turn.runId,
1162+ parentNodeId: turn.rootNodeId,
1163+ rootNodeId: turn.rootNodeId,
1164+ kind: "subagent",
1165+ status: status.node,
1166+ countsForRun: false,
1167+ providerThreadId: context.childProviderThreadId ?? state.providerThread.id,
1168+ providerTurnId: turn.providerTurnId,
1169+ nativeItemRef,
1170+ runtimeRequestId: null,
1171+ checkpointScopeId: null,
1172+ startedAt: context.startedAt,
1173+ completedAt,
1174+ },
1175+ });
1176+ yield* emitProviderEvent({
1177+ type: "subagent.updated",
1178+ provider: OPENCODE_PROVIDER,
1179+ subagent,
1180+ });
1181+ yield* emitProviderEvent({
1182+ type: "turn_item.updated",
1183+ provider: OPENCODE_PROVIDER,
1184+ turnItem: {
1185+ id: turnItemId,
1186+ threadId: turn.threadId,
1187+ runId: turn.runId,
1188+ nodeId,
1189+ providerThreadId: state.providerThread.id,
1190+ providerTurnId: turn.providerTurnId,
1191+ nativeItemRef,
1192+ parentItemId: null,
1193+ ordinal: itemOrdinal(turn, part.id),
1194+ status: status.item,
1195+ title,
1196+ startedAt: context.startedAt,
1197+ completedAt,
1198+ updatedAt: now,
1199+ type: "subagent",
1200+ subagentId: nodeId,
1201+ origin: "provider_native",
1202+ provider: OPENCODE_PROVIDER,
1203+ childThreadId: context.childThreadId,
1204+ prompt,
1205+ result: context.result,
1206+ },
1207+ });
1208+ });
1209+
1210+ const emitToolPart = Effect.fnUntraced(function* (
1211+ state: OpenCodeThreadState,
1212+ turn: ActiveOpenCodeTurn,
1213+ part: ToolPart,
1214+ ) {
1215+ const normalizedTool = part.tool.toLowerCase();
1216+ if (normalizedTool === "task") {
1217+ yield* emitSubagent(state, turn, part);
1218+ return;
1219+ }
1220+ // question.asked carries the respondable semantic item. Projecting
1221+ // the implementation tool as well would duplicate it in the UI.
1222+ if (normalizedTool === "question") return;
1223+ const now = yield* DateTime.now;
1224+ const status = toolStatus(part);
1225+ const startedAt = toolStartedAt(part, now);
1226+ const completedAt = toolCompletedAt(part, now);
1227+ const nativeItemRef = providerRef(part.id);
1228+ const nodeId = idAllocator.derive.nodeFromProviderItem({
1229+ provider: OPENCODE_PROVIDER,
1230+ nativeItemId: part.id,
1231+ });
1232+ const turnItemId = idAllocator.derive.turnItemFromProviderItem({
1233+ provider: OPENCODE_PROVIDER,
1234+ nativeItemId: part.id,
1235+ });
1236+ const base = {
1237+ id: turnItemId,
1238+ threadId: turn.threadId,
1239+ runId: turn.runId,
1240+ nodeId,
1241+ providerThreadId: state.providerThread.id,
1242+ providerTurnId: turn.providerTurnId,
1243+ nativeItemRef,
1244+ parentItemId: null,
1245+ ordinal: itemOrdinal(turn, part.id),
1246+ status: status.item,
1247+ title: toolTitle(part),
1248+ startedAt,
1249+ completedAt,
1250+ updatedAt: now,
1251+ } satisfies Pick<
1252+ OrchestrationV2TurnItem,
1253+ | "id"
1254+ | "threadId"
1255+ | "runId"
1256+ | "nodeId"
1257+ | "providerThreadId"
1258+ | "providerTurnId"
1259+ | "nativeItemRef"
1260+ | "parentItemId"
1261+ | "ordinal"
1262+ | "status"
1263+ | "title"
1264+ | "startedAt"
1265+ | "completedAt"
1266+ | "updatedAt"
1267+ >;
1268+ const input = toolInput(part);
1269+ const output = toolOutput(part);
1270+ const projectionKind = openCodeToolProjectionKind(part.tool);
1271+ let turnItem: OrchestrationV2TurnItem;
1272+ if (projectionKind === "command_execution") {
1273+ turnItem = {
1274+ ...base,
1275+ type: "command_execution",
1276+ input: recordString(input, "command", "cmd") ?? stableJson(input),
1277+ ...(output === undefined ? {} : { output }),
1278+ ...(recordNumber(
1279+ part.state.status === "completed" ? part.state.metadata : undefined,
1280+ "exit",
1281+ "exitCode",
1282+ ) === undefined
1283+ ? {}
1284+ : {
1285+ exitCode: recordNumber(
1286+ part.state.status === "completed" ? part.state.metadata : undefined,
1287+ "exit",
1288+ "exitCode",
1289+ )!,
1290+ }),
1291+ };
1292+ } else if (projectionKind === "file_change") {
1293+ turnItem = {
1294+ ...base,
1295+ type: "file_change",
1296+ fileName: recordString(input, "filePath", "path", "file") ?? part.tool,
1297+ ...(recordString(input, "oldString", "oldText") === undefined
1298+ ? {}
1299+ : { oldStr: recordString(input, "oldString", "oldText")! }),
1300+ ...(recordString(input, "newString", "content", "newText") === undefined
1301+ ? {}
1302+ : { newStr: recordString(input, "newString", "content", "newText")! }),
1303+ ...(recordString(
1304+ part.state.status === "completed" ? part.state.metadata : undefined,
1305+ "diff",
1306+ "patch",
1307+ ) === undefined
1308+ ? {}
1309+ : {
1310+ diffStr: recordString(
1311+ part.state.status === "completed" ? part.state.metadata : undefined,
1312+ "diff",
1313+ "patch",
1314+ )!,
1315+ }),
1316+ };
1317+ } else if (projectionKind === "file_search") {
1318+ turnItem = {
1319+ ...base,
1320+ type: "file_search",
1321+ ...(recordString(input, "pattern", "query", "path", "filePath") === undefined
1322+ ? {}
1323+ : { pattern: recordString(input, "pattern", "query", "path", "filePath")! }),
1324+ };
1325+ } else if (projectionKind === "web_search") {
1326+ const pattern = recordString(input, "query", "url", "pattern");
1327+ turnItem = {
1328+ ...base,
1329+ type: "web_search",
1330+ ...(pattern === undefined ? {} : { patterns: [pattern] }),
1331+ };
1332+ } else {
1333+ turnItem = {
1334+ ...base,
1335+ type: "dynamic_tool",
1336+ toolName: part.tool,
1337+ input,
1338+ ...(output === undefined ? {} : { output }),
1339+ };
1340+ }
1341+ yield* emitProviderEvent({
1342+ type: "node.updated",
1343+ provider: OPENCODE_PROVIDER,
1344+ node: {
1345+ id: nodeId,
1346+ threadId: turn.threadId,
1347+ runId: turn.runId,
1348+ parentNodeId: turn.rootNodeId,
1349+ rootNodeId: turn.rootNodeId,
1350+ kind: "tool_call",
1351+ status: status.node,
1352+ countsForRun: false,
1353+ providerThreadId: state.providerThread.id,
1354+ providerTurnId: turn.providerTurnId,
1355+ nativeItemRef,
1356+ runtimeRequestId: null,
1357+ checkpointScopeId: null,
1358+ startedAt,
1359+ completedAt,
1360+ },
1361+ });
1362+ yield* emitProviderEvent({
1363+ type: "turn_item.updated",
1364+ provider: OPENCODE_PROVIDER,
1365+ turnItem,
1366+ });
1367+ });
1368+
1369+ const emitTodo = Effect.fnUntraced(function* (
1370+ state: OpenCodeThreadState,
1371+ turn: ActiveOpenCodeTurn,
1372+ todos: ReadonlyArray<OpenCodeTodo>,
1373+ ) {
1374+ const now = yield* DateTime.now;
1375+ if (turn.planId === null) {
1376+ turn.planId = yield* idAllocator.allocate.plan({
1377+ threadId: turn.threadId,
1378+ ...(turn.runId === null ? {} : { runId: turn.runId }),
1379+ provider: OPENCODE_PROVIDER,
1380+ });
1381+ }
1382+ const planId = turn.planId;
1383+ const nativeItemId = `${state.nativeSessionId}:todo:${turn.providerTurnId}`;
1384+ const nodeId = idAllocator.derive.nodeFromProviderItem({
1385+ provider: OPENCODE_PROVIDER,
1386+ nativeItemId,
1387+ });
1388+ const turnItemId = idAllocator.derive.turnItemFromProviderItem({
1389+ provider: OPENCODE_PROVIDER,
1390+ nativeItemId,
1391+ });
1392+ const steps: Array<OrchestrationV2PlanStep> = todos.map((todo, index) => ({
1393+ id: `${nativeItemId}:${index + 1}`,
1394+ text: todo.content.trim() || `Todo ${index + 1}`,
1395+ status:
1396+ todo.status === "completed"
1397+ ? "completed"
1398+ : todo.status === "in_progress"
1399+ ? "running"
1400+ : "pending",
1401+ }));
1402+ const completed = steps.length > 0 && steps.every((step) => step.status === "completed");
1403+ yield* emitProviderEvent({
1404+ type: "node.updated",
1405+ provider: OPENCODE_PROVIDER,
1406+ node: {
1407+ id: nodeId,
1408+ threadId: turn.threadId,
1409+ runId: turn.runId,
1410+ parentNodeId: turn.rootNodeId,
1411+ rootNodeId: turn.rootNodeId,
1412+ kind: "todo_list",
1413+ status: completed ? "completed" : "running",
1414+ countsForRun: false,
1415+ providerThreadId: state.providerThread.id,
1416+ providerTurnId: turn.providerTurnId,
1417+ nativeItemRef: providerRef(nativeItemId, "weak"),
1418+ runtimeRequestId: null,
1419+ checkpointScopeId: null,
1420+ startedAt: turn.startedAt,
1421+ completedAt: completed ? now : null,
1422+ },
1423+ });
1424+ yield* emitProviderEvent({
1425+ type: "plan.updated",
1426+ provider: OPENCODE_PROVIDER,
1427+ plan: {
1428+ id: planId,
1429+ threadId: turn.threadId,
1430+ runId: turn.runId,
1431+ nodeId,
1432+ status: completed ? "completed" : "active",
1433+ kind: "todo_list",
1434+ steps,
1435+ },
1436+ });
1437+ yield* emitProviderEvent({
1438+ type: "turn_item.updated",
1439+ provider: OPENCODE_PROVIDER,
1440+ turnItem: {
1441+ id: turnItemId,
1442+ threadId: turn.threadId,
1443+ runId: turn.runId,
1444+ nodeId,
1445+ providerThreadId: state.providerThread.id,
1446+ providerTurnId: turn.providerTurnId,
1447+ nativeItemRef: providerRef(nativeItemId, "weak"),
1448+ parentItemId: null,
1449+ ordinal: itemOrdinal(turn, nativeItemId),
1450+ status: completed ? "completed" : "running",
1451+ title: "Todo list",
1452+ startedAt: turn.startedAt,
1453+ completedAt: completed ? now : null,
1454+ updatedAt: now,
1455+ type: "todo_list",
1456+ planId,
1457+ steps,
1458+ },
1459+ });
1460+ });
1461+
1462+ const requestQuestions = (request: QuestionRequest) =>
1463+ request.questions.map((question, index) => ({
1464+ id: openCodeQuestionId(index, question),
1465+ header: question.header.trim() || `Question ${index + 1}`,
1466+ question: question.question.trim() || question.header.trim() || `Question ${index + 1}`,
1467+ options: question.options.map((option) => ({
1468+ label: option.label.trim() || "Option",
1469+ description: option.description.trim() || option.label.trim() || "Option",
1470+ })),
1471+ }));
1472+
1473+ const runtimeRequestTurnItem = (
1474+ pending: PendingOpenCodeRequest,
1475+ status: OrchestrationV2TurnItem["status"],
1476+ completedAt: DateTime.Utc | null,
1477+ updatedAt: DateTime.Utc,
1478+ ): OrchestrationV2TurnItem => {
1479+ const base = {
1480+ id: pending.turnItemId,
1481+ threadId: pending.turn.threadId,
1482+ runId: pending.turn.runId,
1483+ nodeId: pending.nodeId,
1484+ providerThreadId: pending.state.providerThread.id,
1485+ providerTurnId: pending.turn.providerTurnId,
1486+ nativeItemRef: providerRef(pending.nativeRequestId),
1487+ parentItemId: null,
1488+ ordinal: itemOrdinal(pending.turn, pending.nativeRequestId),
1489+ status,
1490+ startedAt: pending.createdAt,
1491+ completedAt,
1492+ updatedAt,
1493+ };
1494+ if (pending.question !== undefined) {
1495+ return {
1496+ ...base,
1497+ title: "User input",
1498+ type: "user_input_request",
1499+ requestId: pending.requestId,
1500+ questions: requestQuestions(pending.question),
1501+ };
1502+ }
1503+ const permission = pending.permission;
1504+ if (permission === undefined) {
1505+ throw protocolError(`OpenCode request ${pending.requestId} has no native payload`);
1506+ }
1507+ return {
1508+ ...base,
1509+ title: permission.permission,
1510+ type: "approval_request",
1511+ requestId: pending.requestId,
1512+ requestKind: pending.requestKind === "user_input" ? "command" : pending.requestKind,
1513+ prompt:
1514+ permission.patterns.length === 0
1515+ ? permission.permission
1516+ : permission.patterns.join("\n"),
1517+ };
1518+ };
1519+
1520+ const emitRuntimeRequest = Effect.fnUntraced(function* (
1521+ state: OpenCodeThreadState,
1522+ turn: ActiveOpenCodeTurn,
1523+ nativeRequestId: string,
1524+ request:
1525+ | { readonly type: "permission"; readonly value: PermissionRequest }
1526+ | { readonly type: "question"; readonly value: QuestionRequest },
1527+ ) {
1528+ if (pendingRequestsByNativeId.has(nativeRequestId)) return;
1529+ const now = yield* DateTime.now;
1530+ const requestId = yield* idAllocator.allocate.runtimeRequest({
1531+ provider: OPENCODE_PROVIDER,
1532+ providerTurnId: turn.providerTurnId,
1533+ nativeRequestId,
1534+ });
1535+ const nodeId = idAllocator.derive.approvalNode({ requestId });
1536+ const turnItemId = idAllocator.derive.approvalTurnItem({ requestId });
1537+ const permissionToolName =
1538+ request.type === "permission" && request.value.tool !== undefined
1539+ ? Array.from(turn.parts.values()).find(
1540+ (part): part is ToolPart =>
1541+ part.type === "tool" && part.callID === request.value.tool?.callID,
1542+ )?.tool
1543+ : undefined;
1544+ const permissionRequestKind =
1545+ request.type === "permission"
1546+ ? openCodePermissionRequestKind(request.value.permission, permissionToolName)
1547+ : undefined;
1548+ const requestKind: OrchestrationV2RuntimeRequest["kind"] =
1549+ permissionRequestKind ?? "user_input";
1550+ const pending: PendingOpenCodeRequest = {
1551+ requestId,
1552+ nativeRequestId,
1553+ turn,
1554+ state,
1555+ nodeId,
1556+ turnItemId,
1557+ requestKind,
1558+ createdAt: now,
1559+ ...(request.type === "permission"
1560+ ? { permission: request.value }
1561+ : { question: request.value }),
1562+ };
1563+ pendingRequests.set(String(requestId), pending);
1564+ pendingRequestsByNativeId.set(nativeRequestId, pending);
1565+ const runtimeRequest: OrchestrationV2RuntimeRequest = {
1566+ id: requestId,
1567+ nodeId,
1568+ providerTurnId: turn.providerTurnId,
1569+ nativeRequestRef: providerRef(nativeRequestId),
1570+ kind: requestKind,
1571+ status: "pending",
1572+ responseCapability: {
1573+ type: "live",
1574+ providerSessionId: inputProviderSessionId,
1575+ },
1576+ createdAt: now,
1577+ resolvedAt: null,
1578+ };
1579+ state.runtimeRequests.set(String(requestId), runtimeRequest);
1580+ yield* emitProviderEvent({
1581+ type: "node.updated",
1582+ provider: OPENCODE_PROVIDER,
1583+ node: {
1584+ id: nodeId,
1585+ threadId: turn.threadId,
1586+ runId: turn.runId,
1587+ parentNodeId: turn.rootNodeId,
1588+ rootNodeId: turn.rootNodeId,
1589+ kind: request.type === "question" ? "user_input_request" : "approval_request",
1590+ status: "waiting",
1591+ countsForRun: false,
1592+ providerThreadId: state.providerThread.id,
1593+ providerTurnId: turn.providerTurnId,
1594+ nativeItemRef: providerRef(nativeRequestId),
1595+ runtimeRequestId: requestId,
1596+ checkpointScopeId: null,
1597+ startedAt: now,
1598+ completedAt: null,
1599+ },
1600+ });
1601+ yield* emitProviderEvent({
1602+ type: "runtime_request.updated",
1603+ provider: OPENCODE_PROVIDER,
1604+ threadId: turn.threadId,
1605+ runtimeRequest,
1606+ });
1607+ yield* emitProviderEvent({
1608+ type: "turn_item.updated",
1609+ provider: OPENCODE_PROVIDER,
1610+ turnItem: runtimeRequestTurnItem(pending, "waiting", null, now),
1611+ });
1612+ yield* updateProviderSession("waiting", null);
1613+ });
1614+
1615+ const resolveRuntimeRequest = Effect.fnUntraced(function* (
1616+ nativeRequestId: string,
1617+ status: "resolved" | "cancelled",
1618+ ) {
1619+ const pending = pendingRequestsByNativeId.get(nativeRequestId);
1620+ if (pending === undefined) return;
1621+ const now = yield* DateTime.now;
1622+ const current = pending.state.runtimeRequests.get(String(pending.requestId));
1623+ if (current !== undefined) {
1624+ const resolved: OrchestrationV2RuntimeRequest = {
1625+ ...current,
1626+ status,
1627+ resolvedAt: now,
1628+ };
1629+ pending.state.runtimeRequests.set(String(pending.requestId), resolved);
1630+ yield* emitProviderEvent({
1631+ type: "runtime_request.updated",
1632+ provider: OPENCODE_PROVIDER,
1633+ threadId: pending.turn.threadId,
1634+ runtimeRequest: resolved,
1635+ });
1636+ }
1637+ yield* emitProviderEvent({
1638+ type: "node.updated",
1639+ provider: OPENCODE_PROVIDER,
1640+ node: {
1641+ id: pending.nodeId,
1642+ threadId: pending.turn.threadId,
1643+ runId: pending.turn.runId,
1644+ parentNodeId: pending.turn.rootNodeId,
1645+ rootNodeId: pending.turn.rootNodeId,
1646+ kind: pending.question === undefined ? "approval_request" : "user_input_request",
1647+ status: status === "resolved" ? "completed" : "cancelled",
1648+ countsForRun: false,
1649+ providerThreadId: pending.state.providerThread.id,
1650+ providerTurnId: pending.turn.providerTurnId,
1651+ nativeItemRef: providerRef(nativeRequestId),
1652+ runtimeRequestId: pending.requestId,
1653+ checkpointScopeId: null,
1654+ startedAt: pending.createdAt,
1655+ completedAt: now,
1656+ },
1657+ });
1658+ yield* emitProviderEvent({
1659+ type: "turn_item.updated",
1660+ provider: OPENCODE_PROVIDER,
1661+ turnItem: runtimeRequestTurnItem(
1662+ pending,
1663+ status === "resolved" ? "completed" : "cancelled",
1664+ now,
1665+ now,
1666+ ),
1667+ });
1668+ pendingRequests.delete(String(pending.requestId));
1669+ pendingRequestsByNativeId.delete(nativeRequestId);
1670+ const hasOtherPending = Array.from(pendingRequests.values()).some(
1671+ (candidate) => candidate.turn.isRoot,
1672+ );
1673+ if (!hasOtherPending) yield* updateProviderSession("running", null);
1674+ });
1675+
1676+ const finalizeTurn = Effect.fnUntraced(function* (
1677+ state: OpenCodeThreadState,
1678+ turn: ActiveOpenCodeTurn,
1679+ status: TerminalTurnStatus,
1680+ ) {
1681+ if (turn.finalized) return;
1682+ turn.finalized = true;
1683+ const completedAt = yield* DateTime.now;
1684+ for (const part of turn.parts.values()) {
1685+ if (part.type === "text" || part.type === "reasoning") {
1686+ yield* emitTextPart(state, turn, part, true);
1687+ }
1688+ }
1689+ for (const pending of Array.from(pendingRequests.values())) {
1690+ if (pending.turn.providerTurnId === turn.providerTurnId) {
1691+ yield* resolveRuntimeRequest(pending.nativeRequestId, "cancelled");
1692+ }
1693+ }
1694+ yield* emitProviderTurn(state, turn, status, completedAt);
1695+ yield* updateProviderThread(state, {
1696+ status: status === "failed" ? "error" : "idle",
1697+ nativeConversationHeadRef:
1698+ turn.nativeUserMessageId === null
1699+ ? state.providerThread.nativeConversationHeadRef
1700+ : providerRef(turn.nativeUserMessageId, "weak"),
1701+ });
1702+ state.activeTurn = null;
1703+ if (!turn.isRoot) {
1704+ yield* emitProviderEvent({
1705+ type: "node.updated",
1706+ provider: OPENCODE_PROVIDER,
1707+ node: {
1708+ id: turn.rootNodeId,
1709+ threadId: turn.threadId,
1710+ runId: null,
1711+ parentNodeId: null,
1712+ rootNodeId: turn.rootNodeId,
1713+ kind: "root_turn",
1714+ status,
1715+ countsForRun: false,
1716+ providerThreadId: state.providerThread.id,
1717+ providerTurnId: turn.providerTurnId,
1718+ nativeItemRef: providerRef(state.nativeSessionId),
1719+ runtimeRequestId: null,
1720+ checkpointScopeId: null,
1721+ startedAt: turn.startedAt,
1722+ completedAt,
1723+ },
1724+ });
1725+ return;
1726+ }
1727+ const anotherTurnIsActive = Array.from(threads.values()).some(
1728+ (candidate) => candidate.activeTurn?.isRoot === true,
1729+ );
1730+ yield* updateProviderSession(
1731+ anotherTurnIsActive ? "running" : status === "failed" ? "error" : "ready",
1732+ status === "failed" ? sessionEntity.lastError : null,
1733+ );
1734+ yield* emitProviderEvent({
1735+ type: "turn.terminal",
1736+ provider: OPENCODE_PROVIDER,
1737+ providerTurnId: turn.providerTurnId,
1738+ status,
1739+ });
1740+ });
1741+
1742+ const createChildTurn = Effect.fnUntraced(function* (
1743+ state: OpenCodeThreadState,
1744+ message: Extract<OpenCodeMessage, { role: "user" }>,
1745+ ) {
1746+ if (state.appThread === null || state.parentSubagent === null) return null;
1747+ const now = yield* DateTime.now;
1748+ const startedAt = dateTimeFromEpoch(message.time.created, now);
1749+ const rootNodeId = idAllocator.derive.nodeFromProviderItem({
1750+ provider: OPENCODE_PROVIDER,
1751+ nativeItemId: `${state.nativeSessionId}:root:${message.id}`,
1752+ });
1753+ const providerTurnId = idAllocator.derive.providerTurn({
1754+ provider: OPENCODE_PROVIDER,
1755+ nativeTurnId: message.id,
1756+ });
1757+ const providerTurn: OrchestrationV2ProviderTurn = {
1758+ id: providerTurnId,
1759+ providerThreadId: state.providerThread.id,
1760+ nodeId: rootNodeId,
1761+ runAttemptId: null,
1762+ nativeTurnRef: providerRef(message.id, "weak"),
1763+ ordinal: state.nextChildTurnOrdinal++,
1764+ status: "running",
1765+ startedAt,
1766+ completedAt: null,
1767+ };
1768+ const turn: ActiveOpenCodeTurn = {
1769+ isRoot: false,
1770+ threadId: state.appThread.id,
1771+ runId: null,
1772+ rootNodeId,
1773+ appThread: state.appThread,
1774+ modelSelection: state.appThread.modelSelection,
1775+ runtimePolicy: state.parentSubagent.parentTurn.runtimePolicy,
1776+ providerTurnId,
1777+ providerTurnOrdinal: providerTurn.ordinal,
1778+ runAttemptId: null,
1779+ startedAt,
1780+ itemOrdinals: new Map(),
1781+ parts: new Map(),
1782+ partIdsByMessage: new Map(),
1783+ providerTurn,
1784+ nextItemOrdinal: 1,
1785+ nativeUserMessageId: message.id,
1786+ interrupted: false,
1787+ finalized: false,
1788+ planId: null,
1789+ };
1790+ state.activeTurn = turn;
1791+ state.providerTurns.set(String(providerTurnId), providerTurn);
1792+ yield* emitProviderEvent({
1793+ type: "node.updated",
1794+ provider: OPENCODE_PROVIDER,
1795+ node: {
1796+ id: rootNodeId,
1797+ threadId: turn.threadId,
1798+ runId: null,
1799+ parentNodeId: null,
1800+ rootNodeId,
1801+ kind: "root_turn",
1802+ status: "running",
1803+ countsForRun: false,
1804+ providerThreadId: state.providerThread.id,
1805+ providerTurnId,
1806+ nativeItemRef: providerRef(message.id, "weak"),
1807+ runtimeRequestId: null,
1808+ checkpointScopeId: null,
1809+ startedAt,
1810+ completedAt: null,
1811+ },
1812+ });
1813+ yield* emitProviderTurn(state, turn, "running", null);
1814+ return turn;
1815+ });
1816+
1817+ const projectChildUserPart = Effect.fnUntraced(function* (
1818+ state: OpenCodeThreadState,
1819+ turn: ActiveOpenCodeTurn,
1820+ part: Extract<OpenCodePart, { type: "text" }>,
1821+ ) {
1822+ const now = yield* DateTime.now;
1823+ const messageId = idAllocator.derive.messageFromProviderItem({
1824+ provider: OPENCODE_PROVIDER,
1825+ nativeItemId: part.messageID,
1826+ });
1827+ const turnItemId = idAllocator.derive.turnItemFromProviderItem({
1828+ provider: OPENCODE_PROVIDER,
1829+ nativeItemId: part.messageID,
1830+ });
1831+ const projected: OrchestrationV2ConversationMessage = {
1832+ id: messageId,
1833+ threadId: turn.threadId,
1834+ runId: null,
1835+ nodeId: turn.rootNodeId,
1836+ role: "user",
1837+ text: part.text,
1838+ attachments: [],
1839+ streaming: false,
1840+ createdAt: turn.startedAt,
1841+ updatedAt: now,
1842+ };
1843+ state.messages.set(String(messageId), projected);
1844+ yield* emitProviderEvent({
1845+ type: "message.updated",
1846+ provider: OPENCODE_PROVIDER,
1847+ message: projected,
1848+ });
1849+ yield* emitProviderEvent({
1850+ type: "turn_item.updated",
1851+ provider: OPENCODE_PROVIDER,
1852+ turnItem: {
1853+ id: turnItemId,
1854+ threadId: turn.threadId,
1855+ runId: null,
1856+ nodeId: turn.rootNodeId,
1857+ providerThreadId: state.providerThread.id,
1858+ providerTurnId: turn.providerTurnId,
1859+ nativeItemRef: providerRef(part.messageID),
1860+ parentItemId: null,
1861+ ordinal: itemOrdinal(turn, part.messageID),
1862+ status: "completed",
1863+ title: null,
1864+ startedAt: projected.createdAt,
1865+ completedAt: now,
1866+ updatedAt: now,
1867+ type: "user_message",
1868+ messageId,
1869+ inputIntent: "turn_start",
1870+ text: part.text,
1871+ attachments: [],
1872+ },
1873+ });
1874+ });
1875+
1876+ const handleMessageUpdated = Effect.fnUntraced(function* (
1877+ event: Extract<OpenCodeEvent, { type: "message.updated" }>,
1878+ ) {
1879+ const state = threads.get(event.properties.sessionID);
1880+ if (state === undefined) return;
1881+ const message = event.properties.info;
1882+ state.messageRoles.set(message.id, message.role);
1883+ if (message.role !== "user") return;
1884+ const isNewUserMessage = !state.userMessageIds.includes(message.id);
1885+ if (isNewUserMessage) state.userMessageIds.push(message.id);
1886+ let turn = state.activeTurn;
1887+ if (turn === null && state.parentSubagent !== null && isNewUserMessage) {
1888+ turn = yield* createChildTurn(state, message);
1889+ }
1890+ if (turn !== null && turn.nativeUserMessageId === null) {
1891+ turn.nativeUserMessageId = message.id;
1892+ yield* emitProviderTurn(state, turn, "running", null);
1893+ }
1894+ });
1895+
1896+ const handlePartUpdated = Effect.fnUntraced(function* (
1897+ event: Extract<OpenCodeEvent, { type: "message.part.updated" }>,
1898+ ) {
1899+ const part = event.properties.part;
1900+ const state = threads.get(part.sessionID);
1901+ const turn = state?.activeTurn;
1902+ if (state === undefined || turn === null || turn === undefined || turn.finalized) return;
1903+ if (part.type === "text" && state.messageRoles.get(part.messageID) === "user") {
1904+ if (!turn.isRoot) yield* projectChildUserPart(state, turn, part);
1905+ return;
1906+ }
1907+ turn.parts.set(part.id, part);
1908+ const ids = turn.partIdsByMessage.get(part.messageID) ?? new Set<string>();
1909+ ids.add(part.id);
1910+ turn.partIdsByMessage.set(part.messageID, ids);
1911+ switch (part.type) {
1912+ case "text":
1913+ case "reasoning":
1914+ yield* emitTextPart(state, turn, part);
1915+ return;
1916+ case "tool":
1917+ yield* emitToolPart(state, turn, part);
1918+ return;
1919+ default:
1920+ return;
1921+ }
1922+ });
1923+
1924+ const handlePartDelta = Effect.fnUntraced(function* (
1925+ event: Extract<OpenCodeEvent, { type: "message.part.delta" }>,
1926+ ) {
1927+ if (event.properties.field !== "text") return;
1928+ const state = threads.get(event.properties.sessionID);
1929+ const turn = state?.activeTurn;
1930+ const current = turn?.parts.get(event.properties.partID);
1931+ if (
1932+ state === undefined ||
1933+ turn === null ||
1934+ turn === undefined ||
1935+ current === undefined ||
1936+ (current.type !== "text" && current.type !== "reasoning")
1937+ ) {
1938+ return;
1939+ }
1940+ const updated = { ...current, text: current.text + event.properties.delta };
1941+ turn.parts.set(updated.id, updated);
1942+ yield* emitTextPart(state, turn, updated);
1943+ });
1944+
1945+ const handleAssistantCompleted = Effect.fnUntraced(function* (
1946+ event: Extract<OpenCodeEvent, { type: "message.updated" }>,
1947+ ) {
1948+ const message = event.properties.info;
1949+ if (message.role !== "assistant" || message.time.completed === undefined) return;
1950+ const state = threads.get(message.sessionID);
1951+ const turn = state?.activeTurn;
1952+ if (state === undefined || turn === null || turn === undefined) return;
1953+ for (const partId of turn.partIdsByMessage.get(message.id) ?? []) {
1954+ const part = turn.parts.get(partId);
1955+ if (part?.type === "text" || part?.type === "reasoning") {
1956+ yield* emitTextPart(state, turn, part, true);
1957+ }
1958+ }
1959+ });
1960+
1961+ const handleEvent = Effect.fnUntraced(function* (event: OpenCodeEvent) {
1962+ yield* emitRawEvent({
1963+ direction: "incoming",
1964+ messageKind: "notification",
1965+ method: event.type,
1966+ payload: event,
1967+ });
1968+ switch (event.type) {
1969+ case "message.updated":
1970+ yield* handleMessageUpdated(event);
1971+ yield* handleAssistantCompleted(event);
1972+ return;
1973+ case "message.part.updated":
1974+ yield* handlePartUpdated(event);
1975+ return;
1976+ case "message.part.delta":
1977+ yield* handlePartDelta(event);
1978+ return;
1979+ case "todo.updated": {
1980+ const state = threads.get(event.properties.sessionID);
1981+ if (state?.activeTurn !== null && state?.activeTurn !== undefined) {
1982+ yield* emitTodo(state, state.activeTurn, event.properties.todos);
1983+ }
1984+ return;
1985+ }
1986+ case "permission.asked": {
1987+ const state = threads.get(event.properties.sessionID);
1988+ if (state?.activeTurn !== null && state?.activeTurn !== undefined) {
1989+ yield* emitRuntimeRequest(state, state.activeTurn, event.properties.id, {
1990+ type: "permission",
1991+ value: event.properties,
1992+ });
1993+ }
1994+ return;
1995+ }
1996+ case "question.asked": {
1997+ const state = threads.get(event.properties.sessionID);
1998+ if (state?.activeTurn !== null && state?.activeTurn !== undefined) {
1999+ yield* emitRuntimeRequest(state, state.activeTurn, event.properties.id, {
2000+ type: "question",
2001+ value: event.properties,
2002+ });
2003+ }
2004+ return;
2005+ }
2006+ case "permission.replied":
2007+ yield* resolveRuntimeRequest(event.properties.requestID, "resolved");
2008+ return;
2009+ case "question.replied":
2010+ yield* resolveRuntimeRequest(event.properties.requestID, "resolved");
2011+ return;
2012+ case "question.rejected":
2013+ yield* resolveRuntimeRequest(event.properties.requestID, "cancelled");
2014+ return;
2015+ case "session.status": {
2016+ const state = threads.get(event.properties.sessionID);
2017+ if (state === undefined) return;
2018+ if (event.properties.status.type === "busy") {
2019+ yield* updateProviderThread(state, { status: "active" });
2020+ return;
2021+ }
2022+ if (event.properties.status.type === "idle" && state.activeTurn !== null) {
2023+ yield* finalizeTurn(
2024+ state,
2025+ state.activeTurn,
2026+ state.activeTurn.interrupted ? "interrupted" : "completed",
2027+ );
2028+ }
2029+ return;
2030+ }
2031+ case "session.idle": {
2032+ const state = threads.get(event.properties.sessionID);
2033+ if (state?.activeTurn !== null && state?.activeTurn !== undefined) {
2034+ yield* finalizeTurn(
2035+ state,
2036+ state.activeTurn,
2037+ state.activeTurn.interrupted ? "interrupted" : "completed",
2038+ );
2039+ }
2040+ return;
2041+ }
2042+ case "session.error": {
2043+ const states =
2044+ event.properties.sessionID === undefined
2045+ ? Array.from(threads.values()).filter((state) => state.activeTurn !== null)
2046+ : [threads.get(event.properties.sessionID)].filter(
2047+ (state): state is OpenCodeThreadState => state !== undefined,
2048+ );
2049+ const message = openCodeErrorMessage(event);
2050+ if (
2051+ !isMessageAbortedError(event) &&
2052+ (event.properties.sessionID === undefined ||
2053+ states.some((state) => state.parentSubagent === null))
2054+ ) {
2055+ yield* updateProviderSession("error", message);
2056+ }
2057+ for (const state of states) {
2058+ if (state.activeTurn !== null) {
2059+ yield* finalizeTurn(
2060+ state,
2061+ state.activeTurn,
2062+ terminalStatusForError(event, state.activeTurn),
2063+ );
2064+ }
2065+ }
2066+ return;
2067+ }
2068+ default:
2069+ return;
2070+ }
2071+ });
2072+
2073+ const inputProviderSessionId = input.providerSessionId;
2074+
2075+ const subscription = yield* sdkCall("event.subscribe", {}, () =>
2076+ client.event.subscribe(undefined, { signal: abortController.signal }),
2077+ );
2078+ yield* Scope.addFinalizer(
2079+ scope,
2080+ Effect.sync(() => abortController.abort()),
2081+ );
2082+ yield* Stream.fromAsyncIterable(
2083+ subscription.stream,
2084+ (cause) =>
2085+ new OpenCodeRuntimeError({
2086+ operation: "event.subscribe",
2087+ detail: openCodeRuntimeErrorDetail(cause),
2088+ cause,
2089+ }),
2090+ ).pipe(
2091+ Stream.runForEach(handleEvent),
2092+ Effect.exit,
2093+ Effect.flatMap((exit) =>
2094+ Effect.gen(function* () {
2095+ if (abortController.signal.aborted || Exit.isSuccess(exit)) return;
2096+ const detail = openCodeRuntimeErrorDetail(Cause.squash(exit.cause));
2097+ yield* updateProviderSession("error", detail);
2098+ for (const state of threads.values()) {
2099+ if (state.activeTurn !== null)
2100+ yield* finalizeTurn(state, state.activeTurn, "failed");
2101+ }
2102+ }),
2103+ ),
2104+ Effect.forkIn(scope),
2105+ );
2106+
2107+ if (!connection.external && connection.exitCode !== null) {
2108+ yield* connection.exitCode.pipe(
2109+ Effect.flatMap((code) =>
2110+ abortController.signal.aborted
2111+ ? Effect.void
2112+ : Effect.gen(function* () {
2113+ const detail = `OpenCode server exited unexpectedly (${code}).`;
2114+ yield* updateProviderSession("error", detail);
2115+ for (const state of threads.values()) {
2116+ if (state.activeTurn !== null) {
2117+ yield* finalizeTurn(state, state.activeTurn, "failed");
2118+ }
2119+ }
2120+ }),
2121+ ),
2122+ Effect.forkIn(scope),
2123+ );
2124+ }
2125+
2126+ const registerThread = (
2127+ nativeSession: OpenCodeSession,
2128+ providerThread: OrchestrationV2ProviderThread,
2129+ appThread: OrchestrationV2AppThread | null,
2130+ ): OpenCodeThreadState => {
2131+ const existing = threads.get(nativeSession.id);
2132+ if (existing !== undefined) {
2133+ existing.providerThread = providerThread;
2134+ if (appThread !== null) existing.appThread = appThread;
2135+ return existing;
2136+ }
2137+ const state: OpenCodeThreadState = {
2138+ nativeSessionId: nativeSession.id,
2139+ providerThread,
2140+ appThread,
2141+ activeTurn: null,
2142+ providerTurns: new Map(),
2143+ messages: new Map(),
2144+ runtimeRequests: new Map(),
2145+ messageRoles: new Map(),
2146+ userMessageIds: [],
2147+ parentSubagent: subagentsByChildSessionId.get(nativeSession.id) ?? null,
2148+ nextChildTurnOrdinal: 1,
2149+ };
2150+ threads.set(nativeSession.id, state);
2151+ return state;
2152+ };
2153+
2154+ const resolvePromptParts = (turnInput: ProviderAdapterV2TurnInput) => {
2155+ const text = turnInput.message.text.trim();
2156+ const files = toOpenCodeFileParts({
2157+ attachments: turnInput.message.attachments,
2158+ resolveAttachmentPath: (attachment) =>
2159+ resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }),
2160+ });
2161+ if (text.length === 0 && files.length === 0) {
2162+ throw protocolError("OpenCode turns require text or at least one valid attachment");
2163+ }
2164+ return [...(text.length === 0 ? [] : [{ type: "text" as const, text }]), ...files];
2165+ };
2166+
2167+ const readSnapshot = Effect.fnUntraced(function* (
2168+ providerThread: OrchestrationV2ProviderThread,
2169+ ) {
2170+ const sessionId = nativeThreadId(providerThread);
2171+ const response = yield* sdkCall("session.messages", { sessionID: sessionId }, () =>
2172+ client.session.messages({ sessionID: sessionId }),
2173+ );
2174+ const nativeMessages = unwrapData("session.messages", response);
2175+ const state = threads.get(sessionId);
2176+ const snapshotNow = yield* DateTime.now;
2177+ const messages: Array<OrchestrationV2ConversationMessage> = nativeMessages.flatMap(
2178+ ({ info, parts }) => {
2179+ const text = parts
2180+ .filter(
2181+ (part): part is Extract<OpenCodePart, { type: "text" }> => part.type === "text",
2182+ )
2183+ .filter((part) => part.ignored !== true && part.synthetic !== true)
2184+ .map((part) => part.text)
2185+ .join("\n");
2186+ if (text.length === 0) return [];
2187+ const createdAt = dateTimeFromEpoch(info.time.created, snapshotNow);
2188+ return [
2189+ {
2190+ id: idAllocator.derive.messageFromProviderItem({
2191+ provider: OPENCODE_PROVIDER,
2192+ nativeItemId: info.id,
2193+ }),
2194+ threadId: providerThread.appThreadId ?? input.threadId,
2195+ runId: null,
2196+ nodeId: null,
2197+ role: info.role,
2198+ text,
2199+ attachments: [],
2200+ streaming: false,
2201+ createdAt,
2202+ updatedAt:
2203+ info.role === "assistant"
2204+ ? dateTimeFromEpoch(info.time.completed, createdAt)
2205+ : createdAt,
2206+ },
2207+ ];
2208+ },
2209+ );
2210+ const lastUser = nativeMessages.findLast(({ info }) => info.role === "user")?.info.id;
2211+ return {
2212+ providerThread: {
2213+ ...providerThread,
2214+ providerSessionId: input.providerSessionId,
2215+ nativeConversationHeadRef:
2216+ lastUser === undefined ? null : providerRef(lastUser, "weak"),
2217+ status: "idle" as const,
2218+ updatedAt: snapshotNow,
2219+ },
2220+ providerTurns: state === undefined ? [] : [...state.providerTurns.values()],
2221+ messages,
2222+ runtimeRequests: state === undefined ? [] : [...state.runtimeRequests.values()],
2223+ providerPayload: nativeMessages,
2224+ };
2225+ });
2226+
2227+ const runtimeSession: ProviderAdapterV2SessionRuntime = {
2228+ instanceId: options.instanceId,
2229+ provider: OPENCODE_PROVIDER,
2230+ providerSessionId: input.providerSessionId,
2231+ providerSession: sessionEntity,
2232+ rawEvents: Stream.fromEffectRepeat(Queue.take(rawEvents)),
2233+ events: Stream.fromEffectRepeat(Queue.take(events)),
2234+ ensureThread: (threadInput) =>
2235+ Effect.gen(function* () {
2236+ if (threadInput.existingProviderThread !== undefined) {
2237+ return yield* runtimeSession.resumeThread({
2238+ providerThread: threadInput.existingProviderThread,
2239+ });
2240+ }
2241+ const response = yield* sdkCall(
2242+ "session.create",
2243+ {
2244+ title: `T3 Code ${threadInput.threadId}`,
2245+ permission: openCodePermissionRules(threadInput.runtimePolicy),
2246+ },
2247+ () =>
2248+ client.session.create({
2249+ title: `T3 Code ${threadInput.threadId}`,
2250+ permission: openCodePermissionRules(threadInput.runtimePolicy),
2251+ }),
2252+ );
2253+ const nativeSession = unwrapData("session.create", response);
2254+ const createdAt = yield* DateTime.now;
2255+ const providerThread = makeProviderThread({
2256+ idAllocator,
2257+ providerSessionId: input.providerSessionId,
2258+ appThreadId: threadInput.threadId,
2259+ nativeSession,
2260+ now: createdAt,
2261+ });
2262+ registerThread(nativeSession, providerThread, null);
2263+ return providerThread;
2264+ }).pipe(
2265+ Effect.mapError(
2266+ (cause) =>
2267+ new ProviderAdapterEnsureThreadError({
2268+ provider: OPENCODE_PROVIDER,
2269+ threadId: threadInput.threadId,
2270+ cause,
2271+ }),
2272+ ),
2273+ ),
2274+ resumeThread: (threadInput) =>
2275+ Effect.gen(function* () {
2276+ const sessionId = nativeThreadId(threadInput.providerThread);
2277+ const response = yield* sdkCall("session.get", { sessionID: sessionId }, () =>
2278+ client.session.get({ sessionID: sessionId }),
2279+ );
2280+ const nativeSession = unwrapData("session.get", response);
2281+ const resumedAt = yield* DateTime.now;
2282+ const providerThread = {
2283+ ...threadInput.providerThread,
2284+ providerSessionId: input.providerSessionId,
2285+ status: "idle" as const,
2286+ updatedAt: dateTimeFromEpoch(nativeSession.time.updated, resumedAt),
2287+ };
2288+ registerThread(nativeSession, providerThread, null);
2289+ return providerThread;
2290+ }).pipe(
2291+ Effect.mapError(
2292+ (cause) =>
2293+ new ProviderAdapterResumeThreadError({
2294+ provider: OPENCODE_PROVIDER,
2295+ providerSessionId: input.providerSessionId,
2296+ providerThreadId: threadInput.providerThread.id,
2297+ cause,
2298+ }),
2299+ ),
2300+ ),
2301+ startTurn: (turnInput) =>
2302+ Effect.gen(function* () {
2303+ const sessionId = nativeThreadId(turnInput.providerThread);
2304+ const state = threads.get(sessionId);
2305+ if (state === undefined) {
2306+ return yield* protocolError(`OpenCode session ${sessionId} is not registered`);
2307+ }
2308+ if (state.activeTurn !== null) {
2309+ return yield* protocolError(
2310+ `OpenCode provider thread ${turnInput.providerThread.id} already has an active turn`,
2311+ );
2312+ }
2313+ const parsedModel = parseOpenCodeModelSlug(turnInput.modelSelection.model);
2314+ if (parsedModel === null) {
2315+ return yield* protocolError(
2316+ `OpenCode model '${turnInput.modelSelection.model}' must use provider/model format`,
2317+ );
2318+ }
2319+ const parts = resolvePromptParts(turnInput);
2320+ const startedAt = yield* DateTime.now;
2321+ const syntheticNativeTurnId = `${sessionId}:attempt:${turnInput.attemptId}`;
2322+ const providerTurnId = idAllocator.derive.providerTurn({
2323+ provider: OPENCODE_PROVIDER,
2324+ nativeTurnId: syntheticNativeTurnId,
2325+ });
2326+ const providerTurn: OrchestrationV2ProviderTurn = {
2327+ id: providerTurnId,
2328+ providerThreadId: turnInput.providerThread.id,
2329+ nodeId: turnInput.rootNodeId,
2330+ runAttemptId: turnInput.attemptId,
2331+ nativeTurnRef: providerRef(syntheticNativeTurnId, "weak"),
2332+ ordinal: turnInput.providerTurnOrdinal,
2333+ status: "running",
2334+ startedAt,
2335+ completedAt: null,
2336+ };
2337+ const turn: ActiveOpenCodeTurn = {
2338+ isRoot: true,
2339+ threadId: turnInput.threadId,
2340+ runId: turnInput.runId,
2341+ rootNodeId: turnInput.rootNodeId,
2342+ appThread: turnInput.appThread,
2343+ modelSelection: turnInput.modelSelection,
2344+ runtimePolicy: turnInput.runtimePolicy,
2345+ providerTurnId,
2346+ providerTurnOrdinal: turnInput.providerTurnOrdinal,
2347+ runAttemptId: turnInput.attemptId,
2348+ startedAt,
2349+ itemOrdinals: new Map(),
2350+ parts: new Map(),
2351+ partIdsByMessage: new Map(),
2352+ providerTurn,
2353+ nextItemOrdinal: 100,
2354+ nativeUserMessageId: null,
2355+ interrupted: false,
2356+ finalized: false,
2357+ planId: null,
2358+ };
2359+ state.appThread = turnInput.appThread;
2360+ state.activeTurn = turn;
2361+ state.providerTurns.set(String(providerTurnId), providerTurn);
2362+ yield* emitProviderTurn(state, turn, "running", null);
2363+ yield* updateProviderThread(state, {
2364+ status: "active",
2365+ firstRunOrdinal: state.providerThread.firstRunOrdinal ?? turnInput.runOrdinal,
2366+ lastRunOrdinal: turnInput.runOrdinal,
2367+ });
2368+ yield* updateProviderSession("running", null);
2369+ const agent =
2370+ getModelSelectionStringOptionValue(turnInput.modelSelection, "agent") ??
2371+ (turnInput.runtimePolicy.interactionMode === "plan" ? "plan" : undefined);
2372+ const variant = getModelSelectionStringOptionValue(
2373+ turnInput.modelSelection,
2374+ "variant",
2375+ );
2376+ yield* sdkCall(
2377+ "session.promptAsync",
2378+ {
2379+ sessionID: sessionId,
2380+ model: parsedModel,
2381+ ...(agent === undefined ? {} : { agent }),
2382+ ...(variant === undefined ? {} : { variant }),
2383+ parts,
2384+ },
2385+ () =>
2386+ client.session.promptAsync({
2387+ sessionID: sessionId,
2388+ model: parsedModel,
2389+ ...(agent === undefined ? {} : { agent }),
2390+ ...(variant === undefined ? {} : { variant }),
2391+ parts,
2392+ }),
2393+ ).pipe(Effect.tapError(() => finalizeTurn(state, turn, "failed")));
2394+ }).pipe(
2395+ Effect.mapError(
2396+ (cause) =>
2397+ new ProviderAdapterTurnStartError({
2398+ provider: OPENCODE_PROVIDER,
2399+ threadId: turnInput.threadId,
2400+ providerThreadId: turnInput.providerThread.id,
2401+ runId: turnInput.runId,
2402+ cause,
2403+ }),
2404+ ),
2405+ ),
2406+ steerTurn: (steerInput) =>
2407+ Effect.gen(function* () {
2408+ const sessionId = nativeThreadId(steerInput.providerThread);
2409+ const state = threads.get(sessionId);
2410+ const turn = state?.activeTurn;
2411+ if (
2412+ turn === undefined ||
2413+ turn === null ||
2414+ turn.providerTurnId !== steerInput.providerTurnId
2415+ ) {
2416+ return yield* protocolError(
2417+ `OpenCode turn ${steerInput.providerTurnId} is not active`,
2418+ );
2419+ }
2420+ const parsedModel = parseOpenCodeModelSlug(turn.modelSelection.model);
2421+ if (parsedModel === null) {
2422+ return yield* protocolError(
2423+ `OpenCode model '${turn.modelSelection.model}' must use provider/model format`,
2424+ );
2425+ }
2426+ const text = steerInput.message.text.trim();
2427+ const files = toOpenCodeFileParts({
2428+ attachments: steerInput.message.attachments,
2429+ resolveAttachmentPath: (attachment) =>
2430+ resolveAttachmentPath({
2431+ attachmentsDir: serverConfig.attachmentsDir,
2432+ attachment,
2433+ }),
2434+ });
2435+ if (text.length === 0 && files.length === 0) {
2436+ return yield* protocolError("OpenCode steering requires text or an attachment");
2437+ }
2438+ const parts = [
2439+ ...(text.length === 0 ? [] : [{ type: "text" as const, text }]),
2440+ ...files,
2441+ ];
2442+ yield* sdkCall(
2443+ "session.promptAsync",
2444+ { sessionID: sessionId, model: parsedModel, parts },
2445+ () =>
2446+ client.session.promptAsync({
2447+ sessionID: sessionId,
2448+ model: parsedModel,
2449+ parts,
2450+ }),
2451+ );
2452+ }).pipe(
2453+ Effect.mapError(
2454+ (cause) =>
2455+ new ProviderAdapterSteerRunError({
2456+ provider: OPENCODE_PROVIDER,
2457+ providerThreadId: steerInput.providerThread.id,
2458+ providerTurnId: steerInput.providerTurnId,
2459+ cause,
2460+ }),
2461+ ),
2462+ ),
2463+ interruptTurn: (interruptInput) =>
2464+ Effect.gen(function* () {
2465+ const sessionId = nativeThreadId(interruptInput.providerThread);
2466+ const state = threads.get(sessionId);
2467+ const turn = state?.activeTurn;
2468+ if (
2469+ turn === undefined ||
2470+ turn === null ||
2471+ turn.providerTurnId !== interruptInput.providerTurnId
2472+ ) {
2473+ return yield* protocolError(
2474+ `OpenCode turn ${interruptInput.providerTurnId} is not active`,
2475+ );
2476+ }
2477+ turn.interrupted = true;
2478+ yield* sdkCall("session.abort", { sessionID: sessionId }, () =>
2479+ client.session.abort({ sessionID: sessionId }),
2480+ ).pipe(Effect.tapError(() => Effect.sync(() => (turn.interrupted = false))));
2481+ }).pipe(
2482+ Effect.mapError(
2483+ (cause) =>
2484+ new ProviderAdapterInterruptError({
2485+ provider: OPENCODE_PROVIDER,
2486+ providerThreadId: interruptInput.providerThread.id,
2487+ providerTurnId: interruptInput.providerTurnId,
2488+ cause,
2489+ }),
2490+ ),
2491+ ),
2492+ respondToRuntimeRequest: (requestInput) =>
2493+ Effect.gen(function* () {
2494+ const pending = pendingRequests.get(String(requestInput.requestId));
2495+ if (pending === undefined) {
2496+ return yield* protocolError(
2497+ `No pending OpenCode request ${requestInput.requestId}`,
2498+ );
2499+ }
2500+ if (pending.question !== undefined) {
2501+ if (requestInput.answers === undefined) {
2502+ return yield* protocolError(
2503+ `OpenCode question request ${requestInput.requestId} requires answers`,
2504+ );
2505+ }
2506+ const answers = toOpenCodeQuestionAnswers(pending.question, requestInput.answers);
2507+ yield* sdkCall(
2508+ "question.reply",
2509+ { requestID: pending.nativeRequestId, answers },
2510+ () =>
2511+ client.question.reply({
2512+ requestID: pending.nativeRequestId,
2513+ answers,
2514+ }),
2515+ );
2516+ return;
2517+ }
2518+ if (requestInput.decision === undefined) {
2519+ return yield* protocolError(
2520+ `OpenCode approval request ${requestInput.requestId} requires a decision`,
2521+ );
2522+ }
2523+ const reply = toOpenCodePermissionReply(requestInput.decision);
2524+ yield* sdkCall(
2525+ "permission.reply",
2526+ { requestID: pending.nativeRequestId, reply },
2527+ () =>
2528+ client.permission.reply({
2529+ requestID: pending.nativeRequestId,
2530+ reply,
2531+ }),
2532+ );
2533+ }).pipe(
2534+ Effect.mapError(
2535+ (cause) =>
2536+ new ProviderAdapterRuntimeRequestResponseError({
2537+ provider: OPENCODE_PROVIDER,
2538+ requestId: requestInput.requestId,
2539+ cause,
2540+ }),
2541+ ),
2542+ ),
2543+ readThreadSnapshot: (snapshotInput) =>
2544+ readSnapshot(snapshotInput.providerThread).pipe(
2545+ Effect.mapError(
2546+ (cause) =>
2547+ new ProviderAdapterReadThreadSnapshotError({
2548+ provider: OPENCODE_PROVIDER,
2549+ providerThreadId: snapshotInput.providerThread.id,
2550+ cause,
2551+ }),
2552+ ),
2553+ ),
2554+ rollbackThread: (rollbackInput) =>
2555+ Effect.gen(function* () {
2556+ const sessionId = nativeThreadId(rollbackInput.providerThread);
2557+ const state = threads.get(sessionId);
2558+ if (state?.activeTurn !== null && state?.activeTurn !== undefined) {
2559+ return yield* protocolError(
2560+ `Cannot roll back OpenCode thread ${rollbackInput.providerThread.id} while a turn is active`,
2561+ );
2562+ }
2563+ const response = yield* sdkCall("session.messages", { sessionID: sessionId }, () =>
2564+ client.session.messages({ sessionID: sessionId }),
2565+ );
2566+ const messages = unwrapData("session.messages", response);
2567+ let boundaryMessageId: string | undefined;
2568+ if (rollbackInput.target.type === "thread_start") {
2569+ boundaryMessageId = messages.find(({ info }) => info.role === "user")?.info.id;
2570+ } else {
2571+ boundaryMessageId = openCodeBoundaryAfterProviderTurn(
2572+ rollbackInput.providerThreadTurns,
2573+ rollbackInput.target.providerTurn.id,
2574+ );
2575+ }
2576+ if (boundaryMessageId !== undefined) {
2577+ yield* sdkCall(
2578+ "session.revert",
2579+ { sessionID: sessionId, messageID: boundaryMessageId },
2580+ () =>
2581+ client.session.revert({ sessionID: sessionId, messageID: boundaryMessageId }),
2582+ );
2583+ }
2584+ const snapshot = yield* readSnapshot(rollbackInput.providerThread);
2585+ return {
2586+ ...snapshot,
2587+ providerThread: {
2588+ ...snapshot.providerThread,
2589+ nativeConversationHeadRef:
2590+ rollbackInput.target.type === "provider_turn"
2591+ ? rollbackInput.target.providerTurn.nativeTurnRef
2592+ : null,
2593+ },
2594+ };
2595+ }).pipe(
2596+ Effect.mapError(
2597+ (cause) =>
2598+ new ProviderAdapterRollbackThreadError({
2599+ provider: OPENCODE_PROVIDER,
2600+ providerThreadId: rollbackInput.providerThread.id,
2601+ checkpointId: rollbackInput.target.checkpointId,
2602+ cause,
2603+ }),
2604+ ),
2605+ ),
2606+ forkThread: (forkInput) =>
2607+ Effect.gen(function* () {
2608+ const sourceSessionId = nativeThreadId(forkInput.sourceProviderThread);
2609+ const sourceState = threads.get(sourceSessionId);
2610+ if (sourceState?.activeTurn !== null && sourceState?.activeTurn !== undefined) {
2611+ return yield* protocolError(
2612+ `Cannot fork OpenCode thread ${forkInput.sourceProviderThread.id} while a turn is active`,
2613+ );
2614+ }
2615+ let boundaryMessageId: string | undefined;
2616+ if (forkInput.providerTurnId !== undefined) {
2617+ const sourceTurns = forkInput.sourceProviderTurns ?? [];
2618+ const selected = sourceTurns.find((turn) => turn.id === forkInput.providerTurnId);
2619+ if (selected === undefined) {
2620+ return yield* protocolError(
2621+ `OpenCode fork boundary turn ${forkInput.providerTurnId} was not found`,
2622+ );
2623+ }
2624+ boundaryMessageId = openCodeBoundaryAfterProviderTurn(sourceTurns, selected.id);
2625+ }
2626+ const response = yield* sdkCall(
2627+ "session.fork",
2628+ {
2629+ sessionID: sourceSessionId,
2630+ ...(boundaryMessageId === undefined ? {} : { messageID: boundaryMessageId }),
2631+ },
2632+ () =>
2633+ client.session.fork({
2634+ sessionID: sourceSessionId,
2635+ ...(boundaryMessageId === undefined ? {} : { messageID: boundaryMessageId }),
2636+ }),
2637+ );
2638+ const nativeSession = unwrapData("session.fork", response);
2639+ const forkedAt = yield* DateTime.now;
2640+ const providerThread = makeProviderThread({
2641+ idAllocator,
2642+ providerSessionId: input.providerSessionId,
2643+ appThreadId: forkInput.targetThreadId,
2644+ ...(forkInput.ownerNodeId === undefined
2645+ ? {}
2646+ : { ownerNodeId: forkInput.ownerNodeId }),
2647+ nativeSession,
2648+ forkedFrom: {
2649+ providerThreadId: forkInput.sourceProviderThread.id,
2650+ ...(forkInput.providerTurnId === undefined
2651+ ? {}
2652+ : { providerTurnId: forkInput.providerTurnId }),
2653+ },
2654+ now: forkedAt,
2655+ });
2656+ registerThread(nativeSession, providerThread, null);
2657+ return providerThread;
2658+ }).pipe(
2659+ Effect.mapError(
2660+ (cause) =>
2661+ new ProviderAdapterForkThreadError({
2662+ provider: OPENCODE_PROVIDER,
2663+ providerThreadId: forkInput.sourceProviderThread.id,
2664+ cause,
2665+ }),
2666+ ),
2667+ ),
2668+ };
2669+
2670+ return runtimeSession;
2671+ },
2672+ (effect, input) =>
2673+ effect.pipe(
2674+ Effect.mapError(
2675+ (cause) =>
2676+ new ProviderAdapterOpenSessionError({
2677+ provider: OPENCODE_PROVIDER,
2678+ providerSessionId: input.providerSessionId,
2679+ cause,
2680+ }),
2681+ ),
2682+ ),
2683+ ),
2684+ });
2685+}
2686+
2687+export type OpenCodeAdapterV2DriverEnv = OpenCodeRuntime | IdAllocatorV2 | ServerConfig;
2688+
2689+export const OpenCodeAdapterV2Driver: ProviderAdapterDriver<
2690+ OpenCodeSettings,
2691+ OpenCodeAdapterV2DriverEnv
2692+> = {
2693+ driverKind: OPENCODE_DRIVER_KIND,
2694+ configSchema: OpenCodeSettingsSchema,
2695+ defaultConfig: (): OpenCodeSettings => DEFAULT_OPENCODE_SETTINGS,
2696+ create: Effect.fn("OpenCodeAdapterV2Driver.create")(
2697+ function* (input: ProviderAdapterDriverCreateInput<OpenCodeSettings>) {
2698+ const hostEnvironment = yield* HostProcessEnvironment;
2699+ const openCodeRuntime = yield* OpenCodeRuntime;
2700+ const idAllocator = yield* IdAllocatorV2;
2701+ const serverConfig = yield* ServerConfig;
2702+ return makeOpenCodeAdapterV2({
2703+ instanceId: input.instanceId,
2704+ settings: { ...input.config, enabled: input.enabled },
2705+ environment: mergeProviderInstanceEnvironment(input.environment, hostEnvironment),
2706+ runtime: openCodeRuntime,
2707+ idAllocator,
2708+ serverConfig,
2709+ });
2710+ },
2711+ (effect, input) =>
2712+ effect.pipe(
2713+ Effect.mapError(
2714+ (cause) =>
2715+ new ProviderAdapterDriverCreateError({
2716+ driver: OPENCODE_DRIVER_KIND,
2717+ instanceId: input.instanceId,
2718+ detail: "Failed to create OpenCode v2 adapter.",
2719+ cause,
2720+ }),
2721+ ),
2722+ ),
2723+ ),
2724+};
2725+
2726+export const layer: Layer.Layer<ProviderAdapterV2, never, OpenCodeAdapterV2DriverEnv> =
2727+ Layer.effect(
2728+ ProviderAdapterV2,
2729+ Effect.gen(function* () {
2730+ const hostEnvironment = yield* HostProcessEnvironment;
2731+ const openCodeRuntime = yield* OpenCodeRuntime;
2732+ const idAllocator = yield* IdAllocatorV2;
2733+ const serverConfig = yield* ServerConfig;
2734+ return makeOpenCodeAdapterV2({
2735+ instanceId: OPENCODE_DEFAULT_INSTANCE_ID,
2736+ settings: DEFAULT_OPENCODE_SETTINGS,
2737+ environment: hostEnvironment,
2738+ runtime: openCodeRuntime,
2739+ idAllocator,
2740+ serverConfig,
2741+ });
2742+ }),
2743+ );
apps/server/src/orchestration-v2/CommandPolicy.test.ts+34−0
@@ -4,6 +4,7 @@ import * as Effect from "effect/Effect"; · 3 unmodified lines
44
55 import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts";
66 import { CursorProviderCapabilitiesV2 } from "./Adapters/CursorAdapterV2.ts";
7+import { GrokProviderCapabilitiesV2 } from "./Adapters/GrokAdapterV2.ts";
78 import {
89 CommandPolicyCapabilityUnsupportedError,
910 CommandPolicyV2,
@@ -62,6 +63,21 @@ layer("CommandPolicyV2", (it) => { · 52 unmodified lines
6263 }),
6364 );
6465
66+ it.effect("uses interrupt-and-restart steering for Grok ACP", () =>
67+ Effect.gen(function* () {
68+ const policy = yield* CommandPolicyV2;
69+
70+ const result = yield* policy.decideSteeringExecution({
71+ commandId,
72+ threadId,
73+ provider: "grok",
74+ capabilities: GrokProviderCapabilitiesV2,
75+ });
76+
77+ assert.equal(result, "interrupt_restart");
78+ }),
79+ );
80+
6581 it.effect("honors an explicit interrupt-and-restart request", () =>
6682 Effect.gen(function* () {
6783 const policy = yield* CommandPolicyV2;
@@ -165,6 +181,24 @@ layer("CommandPolicyV2", (it) => { · 97 unmodified lines
165181 }),
166182 );
167183
184+ it.effect("falls back to portable context when Grok ACP cannot fork natively", () =>
185+ Effect.gen(function* () {
186+ const policy = yield* CommandPolicyV2;
187+
188+ const result = yield* policy.decideForkExecution({
189+ commandId,
190+ threadId,
191+ provider: "grok",
192+ capabilities: GrokProviderCapabilitiesV2,
193+ sameProvider: true,
194+ hasStrongNativeSource: true,
195+ fromSpecificTurn: true,
196+ });
197+
198+ assert.equal(result, "portable_context");
199+ }),
200+ );
201+
168202 it.effect("returns a typed error when neither native nor portable fork is available", () =>
169203 Effect.gen(function* () {
170204 const policy = yield* CommandPolicyV2;
apps/server/src/orchestration-v2/GrokOrchestratorV2.live.test.ts+158−0
@@ -0,0 +1,158 @@
1+import * as NodeServices from "@effect/platform-node/NodeServices";
2+import { assert, it } from "@effect/vitest";
3+import {
4+ CommandId,
5+ MessageId,
6+ type OrchestrationV2ThreadProjection,
7+ ProjectId,
8+ ThreadId,
9+} from "@t3tools/contracts";
10+import * as Console from "effect/Console";
11+import * as Effect from "effect/Effect";
12+import * as Layer from "effect/Layer";
13+import { describe } from "vite-plus/test";
14+
15+import { CheckpointStoreLive } from "../checkpointing/Layers/CheckpointStore.ts";
16+import { ServerConfig } from "../config.ts";
17+import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";
18+import { ServerSettingsService } from "../serverSettings.ts";
19+import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
20+import * as VcsProcess from "../vcs/VcsProcess.ts";
21+import { OrchestratorV2 } from "./Orchestrator.ts";
22+import { OrchestrationV2LayerLive } from "./runtimeLayer.ts";
23+import { GROK_MODEL_SELECTION } from "./testkit/fixtures/shared.ts";
24+
25+const serverConfigLayer = ServerConfig.layerTest(process.cwd(), {
26+ prefix: "t3-grok-v2-live-",
27+});
28+
29+const vcsDriverRegistryLayer = VcsDriverRegistry.layer.pipe(
30+ Layer.provide(VcsProcess.layer),
31+ Layer.provide(serverConfigLayer),
32+ Layer.provide(NodeServices.layer),
33+);
34+
35+const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(vcsDriverRegistryLayer));
36+
37+const liveLayer = OrchestrationV2LayerLive.pipe(
38+ Layer.provide(SqlitePersistenceMemory),
39+ Layer.provide(checkpointStoreLayer),
40+ Layer.provide(serverConfigLayer),
41+ Layer.provide(
42+ ServerSettingsService.layerTest({
43+ providers: {
44+ grok: { enabled: true },
45+ },
46+ }),
47+ ),
48+ Layer.provide(NodeServices.layer),
49+);
50+
51+const waitForIdle = Effect.fn("GrokOrchestratorV2Live.waitForIdle")(function* (threadId: ThreadId) {
52+ const orchestrator = yield* OrchestratorV2;
53+ for (let attempt = 0; attempt < 600; attempt += 1) {
54+ const projection = yield* orchestrator.getThreadProjection(threadId);
55+ if (
56+ projection.runs.length > 0 &&
57+ projection.runs.every(
58+ (run) => !["queued", "starting", "running", "waiting"].includes(run.status),
59+ )
60+ ) {
61+ return projection;
62+ }
63+ yield* Effect.sleep("500 millis");
64+ }
65+ return yield* Effect.die(new Error(`Timed out waiting for Grok thread ${threadId}.`));
66+});
67+
68+describe.runIf(process.env.T3_GROK_LIVE_ORCHESTRATOR === "1")("Grok V2 live orchestrator", () => {
69+ it.live(
70+ "forks through portable context using real Grok ACP agents",
71+ () =>
72+ Effect.gen(function* () {
73+ const orchestrator = yield* OrchestratorV2;
74+ const projectId = ProjectId.make("project:grok-live-portable-fork");
75+ const sourceThreadId = ThreadId.make("thread:grok-live-portable-fork:source");
76+ const targetThreadId = ThreadId.make("thread:grok-live-portable-fork:target");
77+ const marker = "GROK_LIVE_PORTABLE_FORK_7H3Q";
78+
79+ yield* orchestrator.dispatch({
80+ type: "thread.create",
81+ commandId: CommandId.make("command:grok-live-portable-fork:create"),
82+ threadId: sourceThreadId,
83+ projectId,
84+ title: "Grok live portable fork source",
85+ modelSelection: GROK_MODEL_SELECTION,
86+ runtimeMode: "full-access",
87+ interactionMode: "default",
88+ branch: null,
89+ worktreePath: null,
90+ });
91+ yield* Console.log("Grok live source thread created; dispatching source prompt.");
92+ yield* orchestrator.dispatch({
93+ type: "message.dispatch",
94+ commandId: CommandId.make("command:grok-live-portable-fork:source"),
95+ threadId: sourceThreadId,
96+ messageId: MessageId.make("message:grok-live-portable-fork:source"),
97+ text: `Remember this opaque marker. Respond with exactly: ${marker}`,
98+ attachments: [],
99+ modelSelection: GROK_MODEL_SELECTION,
100+ dispatchMode: { type: "start_immediately" },
101+ });
102+ yield* Console.log("Grok live source prompt dispatched; waiting for completion.");
103+ const sourceProjection = yield* waitForIdle(sourceThreadId);
104+ yield* Console.log("Grok live source turn completed; dispatching portable fork.");
105+
106+ yield* orchestrator.dispatch({
107+ type: "thread.fork",
108+ commandId: CommandId.make("command:grok-live-portable-fork:fork"),
109+ sourceThreadId,
110+ targetThreadId,
111+ sourcePoint: { type: "latest_stable" },
112+ title: "Grok live portable fork target",
113+ });
114+ yield* orchestrator.dispatch({
115+ type: "message.dispatch",
116+ commandId: CommandId.make("command:grok-live-portable-fork:target"),
117+ threadId: targetThreadId,
118+ messageId: MessageId.make("message:grok-live-portable-fork:target"),
119+ text: "Return the opaque marker from the transferred conversation. Respond with only the marker.",
120+ attachments: [],
121+ modelSelection: GROK_MODEL_SELECTION,
122+ dispatchMode: { type: "start_immediately" },
123+ });
124+ const targetProjection = yield* waitForIdle(targetThreadId);
125+ yield* Console.log("Grok live portable fork target completed.");
126+
127+ const assistantText = (projection: OrchestrationV2ThreadProjection) =>
128+ projection.messages
129+ .filter((message) => message.role === "assistant")
130+ .map((message) => message.text)
131+ .join("\n");
132+
133+ assert.deepEqual(
134+ sourceProjection.runs.map((run) => [run.provider, run.status]),
135+ [["grok", "completed"]],
136+ );
137+ assert.deepEqual(
138+ targetProjection.runs.map((run) => [run.provider, run.status]),
139+ [["grok", "completed"]],
140+ );
141+ assert.deepEqual(
142+ targetProjection.contextTransfers.map((transfer) => [
143+ transfer.type,
144+ transfer.status,
145+ transfer.resolution?.strategy,
146+ ]),
147+ [["fork", "consumed", "portable_context"]],
148+ );
149+ assert.deepEqual(
150+ targetProjection.contextHandoffs.map((handoff) => handoff.strategy),
151+ ["full_thread_summary"],
152+ );
153+ assert.include(targetProjection.contextHandoffs[0]?.summaryText ?? "", marker);
154+ assert.include(assistantText(targetProjection), marker);
155+ }).pipe(Effect.provide(liveLayer), Effect.scoped),
156+ 360_000,
157+ );
158+});
apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts+21−2
@@ -1,5 +1,9 @@
11 import type { ProviderDriverKind } from "@t3tools/contracts";
22
3+import {
4+ AcpRegistryAdapterV2Driver,
5+ type AcpRegistryAdapterV2DriverEnv,
6+} from "./Adapters/AcpRegistryAdapterV2.ts";
37 import {
48 ClaudeAdapterV2Driver,
59 type ClaudeAdapterV2DriverEnv,
@@ -9,16 +13,31 @@ import { · 3 unmodified lines
913 CursorAdapterV2Driver,
1014 type CursorAdapterV2DriverEnv,
1115 } from "./Adapters/CursorAdapterV2.ts";
16+import { GrokAdapterV2Driver, type GrokAdapterV2DriverEnv } from "./Adapters/GrokAdapterV2.ts";
17+import {
18+ OpenCodeAdapterV2Driver,
19+ type OpenCodeAdapterV2DriverEnv,
20+} from "./Adapters/OpenCodeAdapterV2.ts";
1221 import type { AnyProviderAdapterDriver } from "./ProviderAdapterDriver.ts";
1322
1423 export type BuiltInProviderAdapterDriversV2Env =
24+ | AcpRegistryAdapterV2DriverEnv
1525 | ClaudeAdapterV2DriverEnv
1626 | CodexAdapterV2DriverEnv
17− | CursorAdapterV2DriverEnv;
27+ | CursorAdapterV2DriverEnv
28+ | GrokAdapterV2DriverEnv
29+ | OpenCodeAdapterV2DriverEnv;
1830
1931 export const BUILT_IN_PROVIDER_ADAPTER_DRIVERS_V2: ReadonlyArray<
2032 AnyProviderAdapterDriver<BuiltInProviderAdapterDriversV2Env>
21−> = [CodexAdapterV2Driver, ClaudeAdapterV2Driver, CursorAdapterV2Driver];
33+> = [
34+ CodexAdapterV2Driver,
35+ ClaudeAdapterV2Driver,
36+ CursorAdapterV2Driver,
37+ OpenCodeAdapterV2Driver,
38+ GrokAdapterV2Driver,
39+ AcpRegistryAdapterV2Driver,
40+];
2241
2342 export const BUILT_IN_PROVIDER_ADAPTER_DRIVER_KINDS_V2: ReadonlySet<ProviderDriverKind> = new Set(
2443 BUILT_IN_PROVIDER_ADAPTER_DRIVERS_V2.map((driver) => driver.driverKind),
apps/server/src/orchestration-v2/runtimeLayer.ts+4−0
@@ -1,5 +1,7 @@
11 import * as Layer from "effect/Layer";
2+import { FetchHttpClient } from "effect/unstable/http";
23
4+import { OpenCodeRuntimeLive } from "../provider/opencodeRuntime.ts";
35 import { claudeAgentSdkQueryRunnerLiveLayer } from "./Adapters/ClaudeAdapterV2.ts";
46 import { codexAppServerClientFactoryFromSettingsLayer } from "./Adapters/CodexAdapterV2.ts";
57 import { cursorAgentSdkRunnerLiveLayer } from "./Adapters/CursorAgentSdk.ts";
@@ -40,6 +42,8 @@ const providerAdapterRegistryProvided = providerAdapterRegistryLayerFromSettings · 34 unmodified lines
4042 Layer.provide(codexAppServerClientFactoryFromSettingsLayer),
4143 Layer.provide(claudeAgentSdkQueryRunnerLiveLayer),
4244 Layer.provide(cursorAgentSdkRunnerLiveLayer),
45+ Layer.provide(OpenCodeRuntimeLive),
46+ Layer.provide(FetchHttpClient.layer),
4347 Layer.provide(idAllocatorLayer),
4448 );
4549
apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.contract.test.ts+9−1
@@ -49,7 +49,15 @@ describe("orchestrator replay fixture contract", () => { · 48 unmodified lines
4949 const firstCommand = materialized.commands[0];
5050
5151 assert.equal(transcript.scenario, fixture.name);
52− assert.equal(transcript.provider, provider.provider);
52+ if (provider.provider === "acpRegistry") {
53+ assert.include(
54+ ["acpRegistry", "grok"],
55+ transcript.provider,
56+ "ACP Registry may retarget protocol-standard Grok ACP evidence",
57+ );
58+ } else {
59+ assert.equal(transcript.provider, provider.provider);
60+ }
5361 assert.equal(provider.modelSelection.instanceId, provider.provider);
5462 assert.isDefined(materialized.projectionThreadIds[0]);
5563 assert.equal(firstCommand?.type, "thread.create");
apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.integration.test.ts+18−0
@@ -7,6 +7,9 @@ import * as FileSystem from "effect/FileSystem"; · 6 unmodified lines
77 import { ClaudeOrchestratorReplayHarness } from "../Adapters/ClaudeAdapterV2.testkit.ts";
88 import { CodexOrchestratorReplayHarness } from "../Adapters/CodexAdapterV2.testkit.ts";
99 import { CursorOrchestratorReplayHarness } from "../Adapters/CursorAdapterV2.testkit.ts";
10+import { AcpRegistryOrchestratorReplayHarness } from "../Adapters/AcpRegistryAdapterV2.testkit.ts";
11+import { GrokOrchestratorReplayHarness } from "../Adapters/GrokAdapterV2.testkit.ts";
12+import { OpenCodeOrchestratorReplayHarness } from "../Adapters/OpenCodeAdapterV2.testkit.ts";
1013 import { layer as idAllocatorLayer } from "../IdAllocator.ts";
1114 import { provideDeterministicTestRuntime } from "./DeterministicRuntime.ts";
1215 import { ORCHESTRATOR_REPLAY_FIXTURES } from "./fixtures/index.ts";
@@ -118,6 +121,21 @@ function runFixtureProviderWithRegisteredHarness(input: { · 105 unmodified lines
118121 ...input,
119122 harness: CursorOrchestratorReplayHarness,
120123 }).pipe(Effect.mapError(normalizeTestError), Effect.scoped);
124+ case "grok":
125+ return runFixtureProvider({
126+ ...input,
127+ harness: GrokOrchestratorReplayHarness,
128+ }).pipe(Effect.mapError(normalizeTestError), Effect.scoped);
129+ case "acpRegistry":
130+ return runFixtureProvider({
131+ ...input,
132+ harness: AcpRegistryOrchestratorReplayHarness,
133+ }).pipe(Effect.mapError(normalizeTestError), Effect.scoped);
134+ case "opencode":
135+ return runFixtureProvider({
136+ ...input,
137+ harness: OpenCodeOrchestratorReplayHarness,
138+ }).pipe(Effect.mapError(normalizeTestError), Effect.scoped);
121139 default:
122140 return Effect.die(
123141 new Error(`No replay harness registered for provider ${input.provider.provider}.`),
apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts+1−1
@@ -41,7 +41,7 @@ import { · 40 unmodified lines
4141 type OrchestratorV2ScenarioResult,
4242 } from "./OrchestratorScenario.ts";
4343
44−function makeReplayServerConfig(
44+export function makeReplayServerConfig(
4545 scenario: string,
4646 ): Effect.Effect<
4747 ServerConfigShape,
apps/server/src/orchestration-v2/testkit/fixtures/acp_elicitation/grok_transcript.ndjson+11−0
@@ -0,0 +1,11 @@
1+{"type":"transcript_start","provider":"grok","protocol":"acp.ndjson-jsonrpc","version":"1","scenario":"acp_elicitation","metadata":{"generatedBy":"protocol-semantic-fixture","nativeSessionId":"grok-replay-session-1"}}
2+{"type":"expect_outbound","label":"initialize","frame":{"kind":"request","method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{"fs":{"readTextFile":false,"writeTextFile":false},"terminal":false,"elicitation":{"form":{}}},"clientInfo":{"name":"t3-code","version":"0.0.0"}}}}
3+{"type":"emit_inbound","label":"initialized","frame":{"kind":"response","method":"initialize","result":{"protocolVersion":1,"agentCapabilities":{"loadSession":true,"mcpCapabilities":{"http":true,"sse":true},"promptCapabilities":{"audio":false,"embeddedContext":true,"image":false}},"authMethods":[{"id":"replay","name":"Replay"}],"agentInfo":{"name":"generic-acp-replay","version":"1"}}}}
4+{"type":"expect_outbound","label":"session.new","frame":{"kind":"request","method":"session/new","params":{"cwd":"<workspace>","mcpServers":"<any>"}}}
5+{"type":"emit_inbound","label":"session.created","frame":{"kind":"response","method":"session/new","result":{"sessionId":"grok-replay-session-1","models":{"currentModelId":"grok-build","availableModels":[{"modelId":"grok-build","name":"Grok Build"}]}}}}
6+{"type":"expect_outbound","label":"session.prompt","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"grok-replay-session-1","prompt":[{"type":"text","text":"Use request_user_input to ask one multiple-choice clarifying question about whether this fixture should prefer strict schemas or UI flexibility. After receiving the answer, respond exactly: plan questions fixture complete"}]}}}
7+{"type":"emit_inbound","label":"elicitation.request","frame":{"kind":"request","method":"session/elicitation","params":{"sessionId":"grok-replay-session-1","mode":"form","message":"Choose the fixture preference.","requestedSchema":{"type":"object","title":"Fixture preference","properties":{"schema_vs_ui_flexibility":{"type":"string","title":"Fixture","description":"Should this fixture prefer strict schemas or UI flexibility?","enum":["Strict schemas (Recommended)","UI flexibility"]}},"required":["schema_vs_ui_flexibility"]}}}}
8+{"type":"expect_outbound","label":"elicitation.response","frame":{"kind":"response","method":"session/elicitation","result":{"action":{"action":"accept","content":{"schema_vs_ui_flexibility":"Strict schemas (Recommended)"}}}}}
9+{"type":"emit_inbound","label":"assistant.final","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"grok-replay-session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"plan questions fixture complete"}}}}}
10+{"type":"emit_inbound","label":"prompt.completed","frame":{"kind":"response","method":"session/prompt","result":{"stopReason":"end_turn"}}}
11+{"type":"runtime_exit","status":"success"}
apps/server/src/orchestration-v2/testkit/fixtures/index.ts+175−0
@@ -1,11 +1,15 @@
11 import { assertClaudeMessageSteeringOutput } from "./message_steering/claude_output.ts";
22 import { assertMessageSteeringOutput } from "./message_steering/codex_output.ts";
33 import { assertCursorMessageSteeringOutput } from "./message_steering/cursor_output.ts";
4+import { assertGrokMessageSteeringOutput } from "./message_steering/grok_output.ts";
45 import { messageSteeringInput } from "./message_steering/input.ts";
56 import { assertMultiTurnClaudeOutput } from "./multi_turn/claude_output.ts";
67 import { assertMultiTurnOutput } from "./multi_turn/codex_output.ts";
78 import { multiTurnInput } from "./multi_turn/input.ts";
9+import { openCodeSubagentInput } from "./opencode_subagent/input.ts";
10+import { assertOpenCodeSubagentOutput } from "./opencode_subagent/output.ts";
811 import { assertPlanQuestionsOutput } from "./plan_questions/codex_output.ts";
12+import { assertOpenCodePlanQuestionsOutput } from "./plan_questions/opencode_output.ts";
913 import { planQuestionsInput } from "./plan_questions/input.ts";
1014 import { assertProposedPlanOutput } from "./proposed_plan/codex_output.ts";
1115 import { assertProposedPlanCursorOutput } from "./proposed_plan/cursor_output.ts";
@@ -26,6 +30,7 @@ import { assertThreadRollbackOutput } from "./thread_rollback/codex_output.ts"; · 14 unmodified lines
2630 import { threadRollbackInput } from "./thread_rollback/input.ts";
2731 import { assertTodoListOutput } from "./todo_list/codex_output.ts";
2832 import { assertTodoListCursorOutput } from "./todo_list/cursor_output.ts";
33+import { assertTodoListGrokOutput } from "./todo_list/grok_output.ts";
2934 import { todoListInput } from "./todo_list/input.ts";
3035 import { assertToolCallReadOnlyClaudeOutput } from "./tool_call_read_only/claude_output.ts";
3136 import { assertToolCallReadOnlyCursorOutput } from "./tool_call_read_only/cursor_output.ts";
@@ -52,9 +57,12 @@ import { assertClaudeWebSearchOutput } from "./web_search/claude_output.ts"; · 20 unmodified lines
5257 import { assertWebSearchOutput } from "./web_search/codex_output.ts";
5358 import { webSearchInput } from "./web_search/input.ts";
5459 import {
60+ ACP_REGISTRY_MODEL_SELECTION,
5561 CLAUDE_MODEL_SELECTION,
5662 CODEX_MODEL_SELECTION,
5763 CURSOR_MODEL_SELECTION,
64+ GROK_MODEL_SELECTION,
65+ OPENCODE_MODEL_SELECTION,
5866 READ_ONLY_NEVER_POLICY,
5967 READ_ONLY_ON_REQUEST_POLICY,
6068 RESTRICTED_GRANULAR_POLICY,
@@ -63,6 +71,26 @@ import { · 2 unmodified lines
6371 } from "./shared.ts";
6472
6573 export const ORCHESTRATOR_REPLAY_FIXTURES = [
74+ {
75+ name: "acp_elicitation",
76+ buildInput: planQuestionsInput,
77+ providers: [
78+ {
79+ provider: "grok",
80+ transcriptFile: new URL("./acp_elicitation/grok_transcript.ndjson", import.meta.url),
81+ modelSelection: GROK_MODEL_SELECTION,
82+ runtimePolicyOverride: READ_ONLY_NEVER_POLICY,
83+ assertOutput: assertPlanQuestionsOutput,
84+ },
85+ {
86+ provider: "acpRegistry",
87+ transcriptFile: new URL("./acp_elicitation/grok_transcript.ndjson", import.meta.url),
88+ modelSelection: ACP_REGISTRY_MODEL_SELECTION,
89+ runtimePolicyOverride: READ_ONLY_NEVER_POLICY,
90+ assertOutput: assertPlanQuestionsOutput,
91+ },
92+ ],
93+ },
6694 {
6795 name: "simple",
6896 buildInput: simpleInput,
@@ -85,6 +113,24 @@ export const ORCHESTRATOR_REPLAY_FIXTURES = [ · 16 unmodified lines
85113 modelSelection: CURSOR_MODEL_SELECTION,
86114 assertOutput: assertSimpleOutput,
87115 },
116+ {
117+ provider: "grok",
118+ transcriptFile: new URL("./simple/grok_transcript.ndjson", import.meta.url),
119+ modelSelection: GROK_MODEL_SELECTION,
120+ assertOutput: assertSimpleOutput,
121+ },
122+ {
123+ provider: "acpRegistry",
124+ transcriptFile: new URL("./simple/grok_transcript.ndjson", import.meta.url),
125+ modelSelection: ACP_REGISTRY_MODEL_SELECTION,
126+ assertOutput: assertSimpleOutput,
127+ },
128+ {
129+ provider: "opencode",
130+ transcriptFile: new URL("./simple/opencode_transcript.ndjson", import.meta.url),
131+ modelSelection: OPENCODE_MODEL_SELECTION,
132+ assertOutput: assertSimpleOutput,
133+ },
88134 ],
89135 },
90136 {
@@ -105,6 +151,20 @@ export const ORCHESTRATOR_REPLAY_FIXTURES = [ · 14 unmodified lines
105151 runtimePolicyOverride: READ_ONLY_NEVER_POLICY,
106152 assertOutput: assertToolCallReadOnlyCursorOutput,
107153 },
154+ {
155+ provider: "grok",
156+ transcriptFile: new URL("./tool_call_read_only/grok_transcript.ndjson", import.meta.url),
157+ modelSelection: GROK_MODEL_SELECTION,
158+ runtimePolicyOverride: READ_ONLY_NEVER_POLICY,
159+ assertOutput: assertToolCallReadOnlyCursorOutput,
160+ },
161+ {
162+ provider: "acpRegistry",
163+ transcriptFile: new URL("./tool_call_read_only/grok_transcript.ndjson", import.meta.url),
164+ modelSelection: ACP_REGISTRY_MODEL_SELECTION,
165+ runtimePolicyOverride: READ_ONLY_NEVER_POLICY,
166+ assertOutput: assertToolCallReadOnlyCursorOutput,
167+ },
108168 ],
109169 },
110170 {
@@ -131,6 +191,26 @@ export const ORCHESTRATOR_REPLAY_FIXTURES = [ · 20 unmodified lines
131191 runtimePolicyOverride: READ_ONLY_ON_REQUEST_POLICY,
132192 assertOutput: assertToolCallReadOnlyOnRequestClaudeOutput,
133193 },
194+ {
195+ provider: "grok",
196+ transcriptFile: new URL(
197+ "./tool_call_read_only_on_request/grok_transcript.ndjson",
198+ import.meta.url,
199+ ),
200+ modelSelection: GROK_MODEL_SELECTION,
201+ runtimePolicyOverride: READ_ONLY_ON_REQUEST_POLICY,
202+ assertOutput: assertToolCallReadOnlyOnRequestOutput,
203+ },
204+ {
205+ provider: "acpRegistry",
206+ transcriptFile: new URL(
207+ "./tool_call_read_only_on_request/grok_transcript.ndjson",
208+ import.meta.url,
209+ ),
210+ modelSelection: ACP_REGISTRY_MODEL_SELECTION,
211+ runtimePolicyOverride: READ_ONLY_ON_REQUEST_POLICY,
212+ assertOutput: assertToolCallReadOnlyOnRequestOutput,
213+ },
134214 ],
135215 },
136216 {
@@ -223,6 +303,18 @@ export const ORCHESTRATOR_REPLAY_FIXTURES = [ · 86 unmodified lines
223303 },
224304 ],
225305 },
306+ {
307+ name: "opencode_subagent",
308+ buildInput: openCodeSubagentInput,
309+ providers: [
310+ {
311+ provider: "opencode",
312+ transcriptFile: new URL("./opencode_subagent/opencode_transcript.ndjson", import.meta.url),
313+ modelSelection: OPENCODE_MODEL_SELECTION,
314+ assertOutput: assertOpenCodeSubagentOutput,
315+ },
316+ ],
317+ },
226318 {
227319 name: "multi_turn",
228320 buildInput: multiTurnInput,
@@ -245,6 +337,18 @@ export const ORCHESTRATOR_REPLAY_FIXTURES = [ · 16 unmodified lines
245337 modelSelection: CURSOR_MODEL_SELECTION,
246338 assertOutput: assertMultiTurnOutput,
247339 },
340+ {
341+ provider: "grok",
342+ transcriptFile: new URL("./multi_turn/grok_transcript.ndjson", import.meta.url),
343+ modelSelection: GROK_MODEL_SELECTION,
344+ assertOutput: assertMultiTurnOutput,
345+ },
346+ {
347+ provider: "acpRegistry",
348+ transcriptFile: new URL("./multi_turn/grok_transcript.ndjson", import.meta.url),
349+ modelSelection: ACP_REGISTRY_MODEL_SELECTION,
350+ assertOutput: assertMultiTurnOutput,
351+ },
248352 ],
249353 },
250354 {
@@ -281,6 +385,18 @@ export const ORCHESTRATOR_REPLAY_FIXTURES = [ · 30 unmodified lines
281385 modelSelection: CURSOR_MODEL_SELECTION,
282386 assertOutput: assertQueuedTurnOutput,
283387 },
388+ {
389+ provider: "grok",
390+ transcriptFile: new URL("./queued_turn/grok_transcript.ndjson", import.meta.url),
391+ modelSelection: GROK_MODEL_SELECTION,
392+ assertOutput: assertQueuedTurnOutput,
393+ },
394+ {
395+ provider: "acpRegistry",
396+ transcriptFile: new URL("./queued_turn/grok_transcript.ndjson", import.meta.url),
397+ modelSelection: ACP_REGISTRY_MODEL_SELECTION,
398+ assertOutput: assertQueuedTurnOutput,
399+ },
284400 ],
285401 },
286402 {
@@ -301,6 +417,18 @@ export const ORCHESTRATOR_REPLAY_FIXTURES = [ · 14 unmodified lines
301417 runtimePolicyOverride: READ_ONLY_NEVER_POLICY,
302418 assertOutput: assertTodoListCursorOutput,
303419 },
420+ {
421+ provider: "grok",
422+ transcriptFile: new URL("./todo_list/grok_transcript.ndjson", import.meta.url),
423+ modelSelection: GROK_MODEL_SELECTION,
424+ assertOutput: assertTodoListGrokOutput,
425+ },
426+ {
427+ provider: "acpRegistry",
428+ transcriptFile: new URL("./todo_list/grok_transcript.ndjson", import.meta.url),
429+ modelSelection: ACP_REGISTRY_MODEL_SELECTION,
430+ assertOutput: assertTodoListGrokOutput,
431+ },
304432 ],
305433 },
306434 {
@@ -332,6 +460,20 @@ export const ORCHESTRATOR_REPLAY_FIXTURES = [ · 25 unmodified lines
332460 runtimePolicyOverride: READ_ONLY_NEVER_POLICY,
333461 assertOutput: assertPlanQuestionsOutput,
334462 },
463+ {
464+ provider: "grok",
465+ transcriptFile: new URL("./plan_questions/grok_transcript.ndjson", import.meta.url),
466+ modelSelection: GROK_MODEL_SELECTION,
467+ runtimePolicyOverride: READ_ONLY_NEVER_POLICY,
468+ assertOutput: assertPlanQuestionsOutput,
469+ },
470+ {
471+ provider: "opencode",
472+ transcriptFile: new URL("./plan_questions/opencode_transcript.ndjson", import.meta.url),
473+ modelSelection: OPENCODE_MODEL_SELECTION,
474+ runtimePolicyOverride: READ_ONLY_NEVER_POLICY,
475+ assertOutput: assertOpenCodePlanQuestionsOutput,
476+ },
335477 ],
336478 },
337479 {
@@ -376,6 +518,18 @@ export const ORCHESTRATOR_REPLAY_FIXTURES = [ · 38 unmodified lines
376518 modelSelection: CURSOR_MODEL_SELECTION,
377519 assertOutput: assertCursorMessageSteeringOutput,
378520 },
521+ {
522+ provider: "grok",
523+ transcriptFile: new URL("./message_steering/grok_transcript.ndjson", import.meta.url),
524+ modelSelection: GROK_MODEL_SELECTION,
525+ assertOutput: assertGrokMessageSteeringOutput,
526+ },
527+ {
528+ provider: "acpRegistry",
529+ transcriptFile: new URL("./message_steering/grok_transcript.ndjson", import.meta.url),
530+ modelSelection: ACP_REGISTRY_MODEL_SELECTION,
531+ assertOutput: assertGrokMessageSteeringOutput,
532+ },
379533 ],
380534 },
381535 {
@@ -396,6 +550,27 @@ export const ORCHESTRATOR_REPLAY_FIXTURES = [ · 14 unmodified lines
396550 runtimePolicyOverride: WORKSPACE_NEVER_POLICY,
397551 assertOutput: assertTurnInterruptClaudeOutput,
398552 },
553+ {
554+ provider: "grok",
555+ transcriptFile: new URL("./turn_interrupt/grok_transcript.ndjson", import.meta.url),
556+ modelSelection: GROK_MODEL_SELECTION,
557+ runtimePolicyOverride: WORKSPACE_NEVER_POLICY,
558+ assertOutput: assertTurnInterruptOutput,
559+ },
560+ {
561+ provider: "acpRegistry",
562+ transcriptFile: new URL("./turn_interrupt/grok_transcript.ndjson", import.meta.url),
563+ modelSelection: ACP_REGISTRY_MODEL_SELECTION,
564+ runtimePolicyOverride: WORKSPACE_NEVER_POLICY,
565+ assertOutput: assertTurnInterruptOutput,
566+ },
567+ {
568+ provider: "opencode",
569+ transcriptFile: new URL("./turn_interrupt/opencode_transcript.ndjson", import.meta.url),
570+ modelSelection: OPENCODE_MODEL_SELECTION,
571+ runtimePolicyOverride: WORKSPACE_NEVER_POLICY,
572+ assertOutput: assertTurnInterruptOutput,
573+ },
399574 ],
400575 },
401576 {
apps/server/src/orchestration-v2/testkit/fixtures/message_steering/grok_output.ts+54−0
@@ -0,0 +1,54 @@
1+import { assert } from "@effect/vitest";
2+import type { ProviderReplayTranscript } from "@t3tools/contracts";
3+
4+import type { OrchestratorV2ScenarioResult } from "../../OrchestratorScenario.ts";
5+import {
6+ assertAssistantTextIncludes,
7+ assertBaseProjection,
8+ assertRuntimeRequestCounts,
9+ assertSemanticProjectionIntegrity,
10+ assertTurnItemTypes,
11+ assertUserMessageInputIntents,
12+ assertUserMessagesInclude,
13+ MESSAGE_STEERING_INITIAL_PROMPT,
14+ MESSAGE_STEERING_STEER_PROMPT,
15+ projectionFor,
16+} from "../shared.ts";
17+
18+export function assertGrokMessageSteeringOutput(
19+ result: OrchestratorV2ScenarioResult,
20+ transcript: ProviderReplayTranscript,
21+) {
22+ assertBaseProjection({ result, transcript, runCount: 1, runStatuses: ["completed"] });
23+
24+ const projection = projectionFor(result, transcript.scenario);
25+ assertSemanticProjectionIntegrity(projection);
26+ assertTurnItemTypes(projection, ["user_message", "run_interrupt_result", "assistant_message"]);
27+ assertRuntimeRequestCounts(projection, { total: 0 });
28+ assertUserMessagesInclude(projection, [
29+ MESSAGE_STEERING_INITIAL_PROMPT,
30+ MESSAGE_STEERING_STEER_PROMPT,
31+ ]);
32+ assertUserMessageInputIntents(projection, ["turn_start", "steer"]);
33+ assertAssistantTextIncludes(projection, "steering fixture observed");
34+
35+ assert.lengthOf(projection.runs, 1, "steering must preserve the app run");
36+ assert.deepEqual(
37+ projection.attempts.map((attempt) => [attempt.reason, attempt.status]),
38+ [
39+ ["initial", "superseded"],
40+ ["steering_restart", "completed"],
41+ ],
42+ );
43+ assert.deepEqual(
44+ projection.providerTurns.map((turn) => turn.status),
45+ ["interrupted", "completed"],
46+ );
47+ assert.equal(projection.runs[0]?.activeAttemptId, projection.attempts[1]?.id);
48+ assert.equal(projection.runs[0]?.rootNodeId, projection.attempts[1]?.rootNodeId);
49+ assert.notInclude(
50+ projection.visibleTurnItems.map((row) => row.item.type),
51+ "run_interrupt_result",
52+ "restart steering must keep its internal interruption out of visible chat history",
53+ );
54+}
apps/server/src/orchestration-v2/testkit/fixtures/message_steering/grok_transcript.ndjson+14−0
@@ -0,0 +1,14 @@
1+{"type":"transcript_start","provider":"grok","protocol":"acp.ndjson-jsonrpc","version":"1","scenario":"message_steering","metadata":{"generatedBy":"live-grok-shape-probe","nativeSessionId":"grok-replay-session-1"}}
2+{"type":"expect_outbound","label":"initialize","frame":{"kind":"request","method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{"fs":{"readTextFile":false,"writeTextFile":false},"terminal":false,"elicitation":{"form":{}}},"clientInfo":{"name":"t3-code","version":"0.0.0"}}}}
3+{"type":"emit_inbound","label":"initialized","frame":{"kind":"response","method":"initialize","result":{"protocolVersion":1,"agentCapabilities":{"loadSession":true,"mcpCapabilities":{"http":true,"sse":true},"promptCapabilities":{"audio":false,"embeddedContext":true,"image":false}},"authMethods":[{"id":"replay","name":"Replay"}],"agentInfo":{"name":"grok-replay","version":"1"}}}}
4+{"type":"expect_outbound","label":"session.new","frame":{"kind":"request","method":"session/new","params":{"cwd":"<workspace>","mcpServers":"<any>"}}}
5+{"type":"emit_inbound","label":"session.created","frame":{"kind":"response","method":"session/new","result":{"sessionId":"grok-replay-session-1","models":{"currentModelId":"grok-build","availableModels":[{"modelId":"grok-build","name":"Grok Build"}]}}}}
6+{"type":"expect_outbound","label":"session.prompt.1","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"grok-replay-session-1","prompt":[{"type":"text","text":"Respond with exactly: steering fixture initial response"}]}}}
7+{"type":"emit_inbound","label":"assistant.partial","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"grok-replay-session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ste"}}}}}
8+{"type":"expect_outbound","label":"session.cancel","frame":{"kind":"notification","method":"session/cancel","params":{"sessionId":"grok-replay-session-1"}}}
9+{"type":"emit_inbound","label":"prompt.cancelled","frame":{"kind":"response","method":"session/prompt","result":{"stopReason":"cancelled"}}}
10+{"type":"expect_outbound","label":"session.prompt.2","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"grok-replay-session-1","prompt":[{"type":"text","text":"Actually, respond with exactly: steering fixture observed"}]}}}
11+{"type":"emit_inbound","label":"assistant.1","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"grok-replay-session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"steering fixture "}}}}}
12+{"type":"emit_inbound","label":"assistant.2","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"grok-replay-session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"observed"}}}}}
13+{"type":"emit_inbound","label":"prompt.completed","frame":{"kind":"response","method":"session/prompt","result":{"stopReason":"end_turn"}}}
14+{"type":"runtime_exit","status":"success"}
apps/server/src/orchestration-v2/testkit/fixtures/multi_turn/grok_transcript.ndjson+12−0
@@ -0,0 +1,12 @@
1+{"type":"transcript_start","provider":"grok","protocol":"acp.ndjson-jsonrpc","version":"1","scenario":"multi_turn","metadata":{"generatedBy":"protocol-semantic-fixture","nativeSessionId":"grok-replay-session-1"}}
2+{"type":"expect_outbound","label":"initialize","frame":{"kind":"request","method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{"fs":{"readTextFile":false,"writeTextFile":false},"terminal":false,"elicitation":{"form":{}}},"clientInfo":{"name":"t3-code","version":"0.0.0"}}}}
3+{"type":"emit_inbound","label":"initialized","frame":{"kind":"response","method":"initialize","result":{"protocolVersion":1,"agentCapabilities":{"loadSession":true,"mcpCapabilities":{"http":true,"sse":true},"promptCapabilities":{"audio":false,"embeddedContext":true,"image":false}},"authMethods":[{"id":"replay","name":"Replay"}],"agentInfo":{"name":"grok-replay","version":"1"}}}}
4+{"type":"expect_outbound","label":"session.new","frame":{"kind":"request","method":"session/new","params":{"cwd":"<workspace>","mcpServers":"<any>"}}}
5+{"type":"emit_inbound","label":"session.created","frame":{"kind":"response","method":"session/new","result":{"sessionId":"grok-replay-session-1","models":{"currentModelId":"grok-build","availableModels":[{"modelId":"grok-build","name":"Grok Build"}]}}}}
6+{"type":"expect_outbound","label":"first.prompt","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"grok-replay-session-1","prompt":[{"type":"text","text":"Respond with exactly: first fixture turn complete"}]}}}
7+{"type":"emit_inbound","label":"first.assistant","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"grok-replay-session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"first fixture turn complete"}}}}}
8+{"type":"emit_inbound","label":"first.completed","frame":{"kind":"response","method":"session/prompt","result":{"stopReason":"end_turn"}}}
9+{"type":"expect_outbound","label":"second.prompt","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"grok-replay-session-1","prompt":[{"type":"text","text":"Respond with exactly: second fixture turn complete"}]}}}
10+{"type":"emit_inbound","label":"second.assistant","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"grok-replay-session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"second fixture turn complete"}}}}}
11+{"type":"emit_inbound","label":"second.completed","frame":{"kind":"response","method":"session/prompt","result":{"stopReason":"end_turn"}}}
12+{"type":"runtime_exit","status":"success"}
apps/server/src/orchestration-v2/testkit/fixtures/opencode_subagent/input.ts+7−0
@@ -0,0 +1,7 @@
1+import { OPENCODE_SUBAGENT_PROMPT, type OrchestratorFixtureInput } from "../shared.ts";
2+
3+export function openCodeSubagentInput(): OrchestratorFixtureInput {
4+ return {
5+ steps: [{ type: "message", text: OPENCODE_SUBAGENT_PROMPT }],
6+ };
7+}
apps/server/src/orchestration-v2/testkit/fixtures/opencode_subagent/opencode_transcript.ndjson+30−0
@@ -0,0 +1,30 @@
1+{"type":"transcript_start","provider":"opencode","protocol":"opencode-sdk.sse","version":"1.14.39","scenario":"opencode_subagent","metadata":{"source":"authenticated-opencode-sdk-probe","capturedAt":"2026-06-18","nativeSessionId":"ses_1235df8f8ffekByXP7gVnnXA3P","nativeChildSessionId":"ses_1235debc9ffe9MNk00UheV2j0q","model":"openai/gpt-5.4-mini","description":"One real OpenCode task-tool delegation. The session.update boundary records the adapter policy propagation required before projecting buffered child events."}}
2+{"type":"expect_outbound","label":"event.subscribe","frame":{"type":"event.subscribe"}}
3+{"type":"expect_outbound","label":"session.create","frame":{"type":"session.create","input":{"title":"<any>","permission":"<any>"}}}
4+{"type":"emit_inbound","label":"session.created","frame":{"type":"sdk.response","operation":"session.create","data":{"id":"ses_1235df8f8ffekByXP7gVnnXA3P","slug":"kind-tiger","version":"1.14.39","projectID":"global","directory":"/private/tmp/t3-opencode-probe","path":"private/tmp/t3-opencode-probe","title":"T3 OpenCode v2 probe","time":{"created":1781818066695,"updated":1781818066695}}}}
5+{"type":"expect_outbound","label":"session.promptAsync","frame":{"type":"session.promptAsync","input":{"sessionID":"ses_1235df8f8ffekByXP7gVnnXA3P","model":{"providerID":"openai","modelID":"gpt-5.4-mini"},"agent":"build","parts":[{"type":"text","text":"Use the task tool exactly once. Delegate to the general subagent with this prompt: Respond exactly CHILD_OK. After the task completes, respond exactly PARENT_OK."}]}}}
6+{"type":"emit_inbound","label":"session.promptAsync.response","frame":{"type":"sdk.response","operation":"session.promptAsync","data":null}}
7+{"type":"emit_inbound","label":"root.user","frame":{"type":"sdk.event","event":{"id":"evt_edca20735003a32yj1UtyUpHkU","type":"message.updated","properties":{"sessionID":"ses_1235df8f8ffekByXP7gVnnXA3P","info":{"id":"msg_edca20735001Esh39V762TEx7f","role":"user","sessionID":"ses_1235df8f8ffekByXP7gVnnXA3P","time":{"created":1781818066741},"agent":"build","model":{"providerID":"openai","modelID":"gpt-5.4-mini"}}}}}}
8+{"type":"emit_inbound","label":"root.user.text","frame":{"type":"sdk.event","event":{"id":"evt_edca207360015UIWeYXmKllHo3","type":"message.part.updated","properties":{"sessionID":"ses_1235df8f8ffekByXP7gVnnXA3P","part":{"type":"text","text":"Use the task tool exactly once. Delegate to the general subagent with this prompt: Respond exactly CHILD_OK. After the task completes, respond exactly PARENT_OK.","messageID":"msg_edca20735001Esh39V762TEx7f","sessionID":"ses_1235df8f8ffekByXP7gVnnXA3P","id":"prt_edca20735002U603TIFRMOYd9M"},"time":1781818066742}}}}
9+{"type":"emit_inbound","label":"root.busy","frame":{"type":"sdk.event","event":{"id":"evt_edca207370013ecC1B3n7HWHF0","type":"session.status","properties":{"sessionID":"ses_1235df8f8ffekByXP7gVnnXA3P","status":{"type":"busy"}}}}}
10+{"type":"emit_inbound","label":"root.assistant.tool-step","frame":{"type":"sdk.event","event":{"id":"evt_edca207380024rQ5C0LdF5jkgv","type":"message.updated","properties":{"sessionID":"ses_1235df8f8ffekByXP7gVnnXA3P","info":{"id":"msg_edca20738001L0AkDDqxC8ipdK","parentID":"msg_edca20735001Esh39V762TEx7f","role":"assistant","mode":"build","agent":"build","path":{"cwd":"/private/tmp/t3-opencode-probe","root":"/"},"cost":0,"tokens":{"input":0,"output":0,"reasoning":0,"cache":{"read":0,"write":0}},"modelID":"gpt-5.4-mini","providerID":"openai","time":{"created":1781818066744},"sessionID":"ses_1235df8f8ffekByXP7gVnnXA3P"}}}}}
11+{"type":"emit_inbound","label":"task.pending","frame":{"type":"sdk.event","event":{"id":"evt_edca213fb002A7172u3WxPY2Jy","type":"message.part.updated","properties":{"sessionID":"ses_1235df8f8ffekByXP7gVnnXA3P","part":{"id":"prt_edca213fb001pDRIA33EBmsHOT","messageID":"msg_edca20738001L0AkDDqxC8ipdK","sessionID":"ses_1235df8f8ffekByXP7gVnnXA3P","type":"tool","tool":"task","callID":"call_MMPbMUvLDj12T5aimkDZC8Sl","state":{"status":"pending","input":{},"raw":""}},"time":1781818070011}}}}
12+{"type":"emit_inbound","label":"task.running","frame":{"type":"sdk.event","event":{"id":"evt_edca21438001pcNOWgyXdKFyaI","type":"message.part.updated","properties":{"sessionID":"ses_1235df8f8ffekByXP7gVnnXA3P","part":{"type":"tool","tool":"task","callID":"call_MMPbMUvLDj12T5aimkDZC8Sl","state":{"title":"Return CHILD_OK","metadata":{"sessionId":"ses_1235debc9ffe9MNk00UheV2j0q","model":{"modelID":"gpt-5.4-mini","providerID":"openai"}},"status":"running","input":{"description":"Return CHILD_OK","prompt":"Respond exactly CHILD_OK.","subagent_type":"general","task_id":"","command":"delegate exact response"},"time":{"start":1781818070072}},"id":"prt_edca213fb001pDRIA33EBmsHOT","sessionID":"ses_1235df8f8ffekByXP7gVnnXA3P","messageID":"msg_edca20738001L0AkDDqxC8ipdK"},"time":1781818070072}}}}
13+{"type":"expect_outbound","label":"session.get.child-policy","frame":{"type":"session.get","input":{"sessionID":"ses_1235debc9ffe9MNk00UheV2j0q"}}}
14+{"type":"emit_inbound","label":"session.get.child-policy.response","frame":{"type":"sdk.response","operation":"session.get","data":{"id":"ses_1235debc9ffe9MNk00UheV2j0q","slug":"tidy-engine","projectID":"global","directory":"/private/tmp/t3-opencode-probe","path":"private/tmp/t3-opencode-probe","parentID":"ses_1235df8f8ffekByXP7gVnnXA3P","title":"Return CHILD_OK (@general subagent)","version":"1.14.39","permission":[{"permission":"task","action":"deny","pattern":"*"}],"time":{"created":1781818070070,"updated":1781818070072}}}}
15+{"type":"expect_outbound","label":"session.update.child-policy","frame":{"type":"session.update","input":{"sessionID":"ses_1235debc9ffe9MNk00UheV2j0q","permission":"<any>"}}}
16+{"type":"emit_inbound","label":"session.update.child-policy.response","frame":{"type":"sdk.response","operation":"session.update","data":{"id":"ses_1235debc9ffe9MNk00UheV2j0q","permission":[{"permission":"*","pattern":"*","action":"allow"},{"permission":"task","pattern":"*","action":"deny"}]}}}
17+{"type":"emit_inbound","label":"child.user","frame":{"type":"sdk.event","event":{"id":"evt_edca21439002NEQt3QTkDp4ENI","type":"message.updated","properties":{"sessionID":"ses_1235debc9ffe9MNk00UheV2j0q","info":{"id":"msg_edca21438002r6NiMUSoqNKyyO","role":"user","sessionID":"ses_1235debc9ffe9MNk00UheV2j0q","time":{"created":1781818070073},"tools":{"task":false},"agent":"general","model":{"providerID":"openai","modelID":"gpt-5.4-mini"}}}}}}
18+{"type":"emit_inbound","label":"child.user.text","frame":{"type":"sdk.event","event":{"id":"evt_edca21439003pseKePjnoGiTMW","type":"message.part.updated","properties":{"sessionID":"ses_1235debc9ffe9MNk00UheV2j0q","part":{"type":"text","text":"Respond exactly CHILD_OK.","messageID":"msg_edca21438002r6NiMUSoqNKyyO","sessionID":"ses_1235debc9ffe9MNk00UheV2j0q","id":"prt_edca214390014Naiz0G5HjlDbr"},"time":1781818070073}}}}
19+{"type":"emit_inbound","label":"child.busy","frame":{"type":"sdk.event","event":{"id":"evt_edca2143c0013lzfo6Odo5FMzE","type":"session.status","properties":{"sessionID":"ses_1235debc9ffe9MNk00UheV2j0q","status":{"type":"busy"}}}}}
20+{"type":"emit_inbound","label":"child.assistant","frame":{"type":"sdk.event","event":{"id":"evt_edca2143c0033666Mhyl3cAlhK","type":"message.updated","properties":{"sessionID":"ses_1235debc9ffe9MNk00UheV2j0q","info":{"id":"msg_edca2143c002Eu46Ey4Vg2BbuM","parentID":"msg_edca21438002r6NiMUSoqNKyyO","role":"assistant","mode":"general","agent":"general","path":{"cwd":"/private/tmp/t3-opencode-probe","root":"/"},"cost":0,"tokens":{"input":0,"output":0,"reasoning":0,"cache":{"read":0,"write":0}},"modelID":"gpt-5.4-mini","providerID":"openai","time":{"created":1781818070076},"sessionID":"ses_1235debc9ffe9MNk00UheV2j0q"}}}}}
21+{"type":"emit_inbound","label":"child.assistant.text","frame":{"type":"sdk.event","event":{"id":"evt_edca21a3f001VbjReH1JvfruIc","type":"message.part.updated","properties":{"sessionID":"ses_1235debc9ffe9MNk00UheV2j0q","part":{"id":"prt_edca21a22001xAMVOfMPeu3Pe5","messageID":"msg_edca2143c002Eu46Ey4Vg2BbuM","sessionID":"ses_1235debc9ffe9MNk00UheV2j0q","type":"text","text":"CHILD_OK","time":{"start":1781818071586,"end":1781818071615},"metadata":{"openai":{"itemId":"msg_08dabd7d4c704130016a3462d793f48196a3764bab4f871b8d","phase":"final_answer"}}},"time":1781818071615}}}}
22+{"type":"emit_inbound","label":"child.assistant.completed","frame":{"type":"sdk.event","event":{"id":"evt_edca21a5d001RVnmfbE88crcg3","type":"message.updated","properties":{"sessionID":"ses_1235debc9ffe9MNk00UheV2j0q","info":{"id":"msg_edca2143c002Eu46Ey4Vg2BbuM","parentID":"msg_edca21438002r6NiMUSoqNKyyO","role":"assistant","mode":"general","agent":"general","path":{"cwd":"/private/tmp/t3-opencode-probe","root":"/"},"cost":0,"tokens":{"total":7538,"input":7515,"output":9,"reasoning":14,"cache":{"write":0,"read":0}},"modelID":"gpt-5.4-mini","providerID":"openai","time":{"created":1781818070076,"completed":1781818071645},"sessionID":"ses_1235debc9ffe9MNk00UheV2j0q","finish":"stop"}}}}}
23+{"type":"emit_inbound","label":"child.idle","frame":{"type":"sdk.event","event":{"id":"evt_edca21a5d0033WtPpZQdeaACpg","type":"session.status","properties":{"sessionID":"ses_1235debc9ffe9MNk00UheV2j0q","status":{"type":"idle"}}}}}
24+{"type":"emit_inbound","label":"task.completed","frame":{"type":"sdk.event","event":{"id":"evt_edca21a5e001nGDHspvGlDIOu3","type":"message.part.updated","properties":{"sessionID":"ses_1235df8f8ffekByXP7gVnnXA3P","part":{"type":"tool","tool":"task","callID":"call_MMPbMUvLDj12T5aimkDZC8Sl","state":{"status":"completed","input":{"description":"Return CHILD_OK","prompt":"Respond exactly CHILD_OK.","subagent_type":"general","task_id":"","command":"delegate exact response"},"output":"task_id: ses_1235debc9ffe9MNk00UheV2j0q (for resuming to continue this task if needed)\n\n<task_result>\nCHILD_OK\n</task_result>","metadata":{"sessionId":"ses_1235debc9ffe9MNk00UheV2j0q","model":{"modelID":"gpt-5.4-mini","providerID":"openai"},"truncated":false},"title":"Return CHILD_OK","time":{"start":1781818070075,"end":1781818071646}},"metadata":{"openai":{"itemId":"fc_0f59e88787b4bf7e016a3462d5f5d08196893b4fa241992c81"}},"id":"prt_edca213fb001pDRIA33EBmsHOT","sessionID":"ses_1235df8f8ffekByXP7gVnnXA3P","messageID":"msg_edca20738001L0AkDDqxC8ipdK"},"time":1781818071646}}}}
25+{"type":"emit_inbound","label":"root.assistant.tool-step.completed","frame":{"type":"sdk.event","event":{"id":"evt_edca21a5f001I6X2xOrOgrkGi4","type":"message.updated","properties":{"sessionID":"ses_1235df8f8ffekByXP7gVnnXA3P","info":{"id":"msg_edca20738001L0AkDDqxC8ipdK","parentID":"msg_edca20735001Esh39V762TEx7f","role":"assistant","mode":"build","agent":"build","path":{"cwd":"/private/tmp/t3-opencode-probe","root":"/"},"cost":0,"tokens":{"total":10945,"input":10835,"output":43,"reasoning":67,"cache":{"write":0,"read":0}},"modelID":"gpt-5.4-mini","providerID":"openai","time":{"created":1781818066744,"completed":1781818071647},"sessionID":"ses_1235df8f8ffekByXP7gVnnXA3P","finish":"tool-calls"}}}}}
26+{"type":"emit_inbound","label":"root.assistant.final-step","frame":{"type":"sdk.event","event":{"id":"evt_edca21a5f004jTKIwlcQchMbZY","type":"message.updated","properties":{"sessionID":"ses_1235df8f8ffekByXP7gVnnXA3P","info":{"id":"msg_edca21a5f003lreMfrQqG0qWIe","parentID":"msg_edca20735001Esh39V762TEx7f","role":"assistant","mode":"build","agent":"build","path":{"cwd":"/private/tmp/t3-opencode-probe","root":"/"},"cost":0,"tokens":{"input":0,"output":0,"reasoning":0,"cache":{"read":0,"write":0}},"modelID":"gpt-5.4-mini","providerID":"openai","time":{"created":1781818071647},"sessionID":"ses_1235df8f8ffekByXP7gVnnXA3P"}}}}}
27+{"type":"emit_inbound","label":"root.assistant.text","frame":{"type":"sdk.event","event":{"id":"evt_edca21e36001X0l8t5UPa1gOe3","type":"message.part.updated","properties":{"sessionID":"ses_1235df8f8ffekByXP7gVnnXA3P","part":{"id":"prt_edca21d9e001I40GbbzvlzhQ6Z","messageID":"msg_edca21a5f003lreMfrQqG0qWIe","sessionID":"ses_1235df8f8ffekByXP7gVnnXA3P","type":"text","text":"PARENT_OK","time":{"start":1781818072478,"end":1781818072630},"metadata":{"openai":{"itemId":"msg_06e59296cb53fc7c016a3462d879288196aead5e9838c741df","phase":"final_answer"}}},"time":1781818072630}}}}
28+{"type":"emit_inbound","label":"root.assistant.completed","frame":{"type":"sdk.event","event":{"id":"evt_edca21e3800181OyYIBTDTTK3r","type":"message.updated","properties":{"sessionID":"ses_1235df8f8ffekByXP7gVnnXA3P","info":{"id":"msg_edca21a5f003lreMfrQqG0qWIe","parentID":"msg_edca20735001Esh39V762TEx7f","role":"assistant","mode":"build","agent":"build","path":{"cwd":"/private/tmp/t3-opencode-probe","root":"/"},"cost":0,"tokens":{"total":11007,"input":248,"output":7,"reasoning":0,"cache":{"write":0,"read":10752}},"modelID":"gpt-5.4-mini","providerID":"openai","time":{"created":1781818071647,"completed":1781818072632},"sessionID":"ses_1235df8f8ffekByXP7gVnnXA3P","finish":"stop"}}}}}
29+{"type":"emit_inbound","label":"root.idle","frame":{"type":"sdk.event","event":{"id":"evt_edca21e39001lo71BB2Ym4WFC2","type":"session.status","properties":{"sessionID":"ses_1235df8f8ffekByXP7gVnnXA3P","status":{"type":"idle"}}}}}
30+{"type":"runtime_exit","status":"success"}
apps/server/src/orchestration-v2/testkit/fixtures/opencode_subagent/output.ts+71−0
@@ -0,0 +1,71 @@
1+import { assert } from "@effect/vitest";
2+import type { ProviderReplayTranscript } from "@t3tools/contracts";
3+
4+import type { OrchestratorV2ScenarioResult } from "../../OrchestratorScenario.ts";
5+import {
6+ assertAssistantTextIncludes,
7+ assertBaseProjection,
8+ assertExecutionNodeKinds,
9+ assertNoExtraAppRunsForProviderChildren,
10+ assertRunProviderTurnCardinality,
11+ assertSemanticProjectionIntegrity,
12+ assertTurnItemTypes,
13+ assertUserMessagesInclude,
14+ OPENCODE_SUBAGENT_PROMPT,
15+ projectionFor,
16+} from "../shared.ts";
17+
18+export function assertOpenCodeSubagentOutput(
19+ result: OrchestratorV2ScenarioResult,
20+ transcript: ProviderReplayTranscript,
21+) {
22+ assertBaseProjection({ result, transcript, runCount: 1, runStatuses: ["completed"] });
23+
24+ const projection = projectionFor(result, transcript.scenario);
25+ assertSemanticProjectionIntegrity(projection);
26+ assertExecutionNodeKinds(projection, ["root_turn", "subagent", "assistant_message"]);
27+ assertTurnItemTypes(projection, ["user_message", "subagent", "assistant_message"]);
28+ assertRunProviderTurnCardinality({ projection, rootRunCount: 1 });
29+ assertNoExtraAppRunsForProviderChildren({ projection, expectedAppRuns: 1 });
30+ assertUserMessagesInclude(projection, [OPENCODE_SUBAGENT_PROMPT]);
31+ assertAssistantTextIncludes(projection, "PARENT_OK");
32+
33+ assert.lengthOf(projection.subagents, 1);
34+ assert.lengthOf(result.shellSnapshot.threads, 2);
35+ const subagent = projection.subagents[0];
36+ assert.isDefined(subagent);
37+ assert.equal(subagent.origin, "provider_native");
38+ assert.equal(subagent.createdBy, "agent");
39+ assert.equal(subagent.provider, "opencode");
40+ assert.equal(subagent.status, "completed");
41+ assert.equal(subagent.prompt, "Respond exactly CHILD_OK.");
42+ assert.include(subagent.result ?? "", "CHILD_OK");
43+ assert.isNotNull(subagent.childThreadId);
44+ assert.isNotNull(subagent.providerThreadId);
45+ assert.isNotNull(subagent.nativeTaskRef);
46+ assert.isNotNull(subagent.completedAt);
47+ if (subagent.childThreadId === null || subagent.providerThreadId === null) {
48+ throw new Error("OpenCode subagent is missing its child thread identity");
49+ }
50+
51+ const providerThread = projection.providerThreads.find(
52+ (thread) => thread.id === subagent.providerThreadId,
53+ );
54+ assert.isDefined(providerThread);
55+ assert.equal(providerThread.appThreadId, subagent.childThreadId);
56+ assert.equal(providerThread.ownerNodeId, subagent.id);
57+
58+ const childProjection = result.projections.get(subagent.childThreadId);
59+ assert.isDefined(childProjection);
60+ assert.equal(childProjection.thread.lineage.parentThreadId, projection.thread.id);
61+ assert.equal(childProjection.thread.lineage.relationshipToParent, "subagent");
62+ assert.equal(childProjection.thread.activeProviderThreadId, providerThread.id);
63+ assert.lengthOf(childProjection.runs, 0);
64+ assert.lengthOf(childProjection.providerThreads, 1);
65+ assert.lengthOf(childProjection.providerTurns, 1);
66+ assert.equal(childProjection.providerTurns[0]?.status, "completed");
67+ assertExecutionNodeKinds(childProjection, ["root_turn", "assistant_message"]);
68+ assertTurnItemTypes(childProjection, ["user_message", "assistant_message"]);
69+ assertUserMessagesInclude(childProjection, ["Respond exactly CHILD_OK."]);
70+ assertAssistantTextIncludes(childProjection, "CHILD_OK");
71+}
apps/server/src/orchestration-v2/testkit/fixtures/plan_questions/codex_output.ts+9−1
@@ -18,7 +18,7 @@ import { · 17 unmodified lines
1818 projectionFor,
1919 } from "../shared.ts";
2020
21−export function assertPlanQuestionsOutput(
21+export function assertPlanQuestionsOutputBase(
2222 result: OrchestratorV2ScenarioResult,
2323 transcript: ProviderReplayTranscript,
2424 ) {
@@ -35,7 +35,15 @@ export function assertPlanQuestionsOutput( · 10 unmodified lines
3535 assertTurnItemTypes(projection, ["user_message", "user_input_request", "assistant_message"]);
3636 assertUserMessagesInclude(projection, [PLAN_QUESTIONS_PROMPT]);
3737 assertAssistantTextIncludes(projection, "plan questions fixture complete");
38+}
39+
40+export function assertPlanQuestionsOutput(
41+ result: OrchestratorV2ScenarioResult,
42+ transcript: ProviderReplayTranscript,
43+) {
44+ assertPlanQuestionsOutputBase(result, transcript);
3845
46+ const projection = projectionFor(result, transcript.scenario);
3947 const requestItem = projection.turnItems.find((item) => item.type === "user_input_request");
4048 assert.isDefined(requestItem);
4149 assert.equal(requestItem?.questions[0]?.id, "schema_vs_ui_flexibility");
apps/server/src/orchestration-v2/testkit/fixtures/plan_questions/grok_transcript.ndjson+11−0
@@ -0,0 +1,11 @@
1+{"type":"transcript_start","provider":"grok","protocol":"acp.ndjson-jsonrpc","version":"1","scenario":"plan_questions","metadata":{"generatedBy":"live-grok-shape-probe","nativeSessionId":"grok-replay-session-1"}}
2+{"type":"expect_outbound","label":"initialize","frame":{"kind":"request","method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{"fs":{"readTextFile":false,"writeTextFile":false},"terminal":false,"elicitation":{"form":{}}},"clientInfo":{"name":"t3-code","version":"0.0.0"}}}}
3+{"type":"emit_inbound","label":"initialized","frame":{"kind":"response","method":"initialize","result":{"protocolVersion":1,"agentCapabilities":{"loadSession":true,"mcpCapabilities":{"http":true,"sse":true},"promptCapabilities":{"audio":false,"embeddedContext":true,"image":false}},"authMethods":[{"id":"replay","name":"Replay"}],"agentInfo":{"name":"grok-replay","version":"1"}}}}
4+{"type":"expect_outbound","label":"session.new","frame":{"kind":"request","method":"session/new","params":{"cwd":"<workspace>","mcpServers":"<any>"}}}
5+{"type":"emit_inbound","label":"session.created","frame":{"kind":"response","method":"session/new","result":{"sessionId":"grok-replay-session-1","models":{"currentModelId":"grok-build","availableModels":[{"modelId":"grok-build","name":"Grok Build"}]}}}}
6+{"type":"expect_outbound","label":"session.prompt","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"grok-replay-session-1","prompt":[{"type":"text","text":"Use request_user_input to ask one multiple-choice clarifying question about whether this fixture should prefer strict schemas or UI flexibility. After receiving the answer, respond exactly: plan questions fixture complete"}]}}}
7+{"type":"emit_inbound","label":"question.request","frame":{"kind":"request","method":"_x.ai/ask_user_question","params":{"method":"x.ai/ask_user_question","params":{"sessionId":"grok-replay-session-1","toolCallId":"ask-user-question-tool-call-1","questions":[{"id":"schema_vs_ui_flexibility","question":"For this fixture, should the plan prefer strict schemas or UI flexibility?","multiSelect":null,"options":[{"label":"Strict schemas (Recommended)","description":"Favor precise contracts and validation."},{"label":"UI flexibility","description":"Favor looser fixture shapes."}]}],"mode":"plan"}}}}
8+{"type":"expect_outbound","label":"question.response","frame":{"kind":"response","method":"_x.ai/ask_user_question","result":{"outcome":"accepted","answers":{"For this fixture, should the plan prefer strict schemas or UI flexibility?":["Strict schemas (Recommended)"]}}}}
9+{"type":"emit_inbound","label":"assistant.final","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"grok-replay-session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"plan questions fixture complete"}}}}}
10+{"type":"emit_inbound","label":"prompt.completed","frame":{"kind":"response","method":"session/prompt","result":{"stopReason":"end_turn"}}}
11+{"type":"runtime_exit","status":"success"}
apps/server/src/orchestration-v2/testkit/fixtures/plan_questions/input.ts+1−0
@@ -9,6 +9,7 @@ export function planQuestionsInput(): OrchestratorFixtureInput { · 8 unmodified lines
99 type: "answer_next_user_input_request",
1010 answers: {
1111 schema_vs_ui_flexibility: "Strict schemas (Recommended)",
12+ "question-0-schema-vs-flexibility": "Strict schemas",
1213 },
1314 },
1415 ],
apps/server/src/orchestration-v2/testkit/fixtures/plan_questions/opencode_output.ts+26−0
@@ -0,0 +1,26 @@
1+import { assert } from "@effect/vitest";
2+import type { ProviderReplayTranscript } from "@t3tools/contracts";
3+
4+import type { OrchestratorV2ScenarioResult } from "../../OrchestratorScenario.ts";
5+import { assertPlanQuestionsOutputBase } from "./codex_output.ts";
6+import { projectionFor } from "../shared.ts";
7+
8+export function assertOpenCodePlanQuestionsOutput(
9+ result: OrchestratorV2ScenarioResult,
10+ transcript: ProviderReplayTranscript,
11+) {
12+ // The shared assertions cover the runtime-request lifecycle. OpenCode owns
13+ // its question identifiers, so assert its normalized native header instead
14+ // of Codex's fixture-specific id.
15+ assertPlanQuestionsOutputBase(result, transcript);
16+ const projection = projectionFor(result, transcript.scenario);
17+ const requestItem = projection.turnItems.find((item) => item.type === "user_input_request");
18+ assert.equal(requestItem?.questions[0]?.id, "question-0-schema-vs-flexibility");
19+ assert.equal(requestItem?.status, "completed");
20+ assert.lengthOf(
21+ projection.turnItems.filter(
22+ (item) => item.type === "dynamic_tool" && item.toolName === "question",
23+ ),
24+ 0,
25+ );
26+}
apps/server/src/orchestration-v2/testkit/fixtures/plan_questions/opencode_transcript.ndjson+23−0
@@ -0,0 +1,23 @@
1+{"type":"transcript_start","provider":"opencode","protocol":"opencode-sdk.sse","version":"1.14.39","scenario":"plan_questions","metadata":{"source":"authenticated-opencode-sdk-probe","capturedAt":"2026-06-18","nativeSessionId":"ses_12369ea4cffePl0BsyVg3hGlmZ","model":"openai/gpt-5.4-mini","filteredToRelevantEvents":true,"description":"Real structured-question request/reply and continuation across two assistant steps."}}
2+{"type":"expect_outbound","label":"event.subscribe","frame":{"type":"event.subscribe"}}
3+{"type":"expect_outbound","label":"session.create","frame":{"type":"session.create","input":{"title":"<any>","permission":"<any>"}}}
4+{"type":"emit_inbound","label":"session.created","frame":{"type":"sdk.response","operation":"session.create","data":{"id":"ses_12369ea4cffePl0BsyVg3hGlmZ","slug":"mighty-harbor","version":"1.14.39","projectID":"global","directory":"/private/tmp/t3-opencode-probe","path":"private/tmp/t3-opencode-probe","title":"T3 OpenCode v2 probe","time":{"created":1781817284019,"updated":1781817284019}}}}
5+{"type":"expect_outbound","label":"session.promptAsync","frame":{"type":"session.promptAsync","input":{"sessionID":"ses_12369ea4cffePl0BsyVg3hGlmZ","model":{"providerID":"openai","modelID":"gpt-5.4-mini"},"agent":"build","parts":[{"type":"text","text":"Use request_user_input to ask one multiple-choice clarifying question about whether this fixture should prefer strict schemas or UI flexibility. After receiving the answer, respond exactly: plan questions fixture complete"}]}}}
6+{"type":"emit_inbound","label":"message.updated.user","frame":{"type":"sdk.event","event":{"id":"evt_edc9615bb003WEUXUNfpZxMZDD","type":"message.updated","properties":{"sessionID":"ses_12369ea4cffePl0BsyVg3hGlmZ","info":{"id":"msg_edc9615bb001iCnWab1qzSeRA3","role":"user","sessionID":"ses_12369ea4cffePl0BsyVg3hGlmZ","time":{"created":1781817284027},"agent":"build","model":{"providerID":"openai","modelID":"gpt-5.4-mini"}}}}}}
7+{"type":"emit_inbound","label":"message.part.updated.user","frame":{"type":"sdk.event","event":{"id":"evt_edc9615bc001l8KO7edlPViXO0","type":"message.part.updated","properties":{"sessionID":"ses_12369ea4cffePl0BsyVg3hGlmZ","part":{"type":"text","text":"Use request_user_input to ask one multiple-choice clarifying question about whether this fixture should prefer strict schemas or UI flexibility. After receiving the answer, respond exactly: plan questions fixture complete","messageID":"msg_edc9615bb001iCnWab1qzSeRA3","sessionID":"ses_12369ea4cffePl0BsyVg3hGlmZ","id":"prt_edc9615bb002fjGpO5oeQE22B9"},"time":1781817284028}}}}
8+{"type":"emit_inbound","label":"session.promptAsync.response","frame":{"type":"sdk.response","operation":"session.promptAsync","data":null}}
9+{"type":"emit_inbound","label":"session.status.busy","frame":{"type":"sdk.event","event":{"id":"evt_edc9615bd0012brtruCEXYmNyX","type":"session.status","properties":{"sessionID":"ses_12369ea4cffePl0BsyVg3hGlmZ","status":{"type":"busy"}}}}}
10+{"type":"emit_inbound","label":"message.updated.assistant","frame":{"type":"sdk.event","event":{"id":"evt_edc9615bd003GQj8GzeHgoMlZ2","type":"message.updated","properties":{"sessionID":"ses_12369ea4cffePl0BsyVg3hGlmZ","info":{"id":"msg_edc9615bd002qb49gh9N1krbX2","parentID":"msg_edc9615bb001iCnWab1qzSeRA3","role":"assistant","mode":"build","agent":"build","path":{"cwd":"/private/tmp/t3-opencode-probe","root":"/"},"cost":0,"tokens":{"input":0,"output":0,"reasoning":0,"cache":{"read":0,"write":0}},"modelID":"gpt-5.4-mini","providerID":"openai","time":{"created":1781817284029},"sessionID":"ses_12369ea4cffePl0BsyVg3hGlmZ"}}}}}
11+{"type":"emit_inbound","label":"reasoning.completed","frame":{"type":"sdk.event","event":{"id":"evt_edc96244e001ez77NBs6gJaPZh","type":"message.part.updated","properties":{"sessionID":"ses_12369ea4cffePl0BsyVg3hGlmZ","part":{"id":"prt_edc961988001aollEeE48XSJDg","messageID":"msg_edc9615bd002qb49gh9N1krbX2","sessionID":"ses_12369ea4cffePl0BsyVg3hGlmZ","type":"reasoning","text":"Clarifying user input before asking the structured question.","time":{"start":1781817285000,"end":1781817287757}},"time":1781817287757}}}}
12+{"type":"emit_inbound","label":"question.tool.pending","frame":{"type":"sdk.event","event":{"id":"evt_edc96244e003ljcUzfVu0WTFc2","type":"message.part.updated","properties":{"sessionID":"ses_12369ea4cffePl0BsyVg3hGlmZ","part":{"id":"prt_edc96244e002RBYg9ASwc10E8R","messageID":"msg_edc9615bd002qb49gh9N1krbX2","sessionID":"ses_12369ea4cffePl0BsyVg3hGlmZ","type":"tool","tool":"question","callID":"call_XU0dxWGu2ioLZscBtYAIqGfT","state":{"status":"pending","input":{},"raw":""}},"time":1781817287758}}}}
13+{"type":"emit_inbound","label":"question.asked","frame":{"type":"sdk.event","event":{"id":"evt_edc9624d90022x2I3Rv9S6t1tt","type":"question.asked","properties":{"id":"que_edc9624d9001e2GFKX724Z8b5Z","sessionID":"ses_12369ea4cffePl0BsyVg3hGlmZ","questions":[{"question":"Should this fixture prefer strict schemas or UI flexibility?","header":"Schema vs Flexibility","options":[{"label":"Strict schemas","description":"Prioritize validation and exact structure over visual freedom."},{"label":"UI flexibility","description":"Prioritize adaptable rendering and tolerant input handling."}],"multiple":false}],"tool":{"messageID":"msg_edc9615bd002qb49gh9N1krbX2","callID":"call_XU0dxWGu2ioLZscBtYAIqGfT"}}}}}
14+{"type":"expect_outbound","label":"question.reply","frame":{"type":"question.reply","input":{"requestID":"que_edc9624d9001e2GFKX724Z8b5Z","answers":[["Strict schemas"]]}}}
15+{"type":"emit_inbound","label":"question.reply.response","frame":{"type":"sdk.response","operation":"question.reply","data":true}}
16+{"type":"emit_inbound","label":"question.tool.running","frame":{"type":"sdk.event","event":{"id":"evt_edc9624da001qamTLzcYoY2CPY","type":"message.part.updated","properties":{"sessionID":"ses_12369ea4cffePl0BsyVg3hGlmZ","part":{"type":"tool","tool":"question","callID":"call_XU0dxWGu2ioLZscBtYAIqGfT","state":{"status":"running","input":{"questions":[{"question":"Should this fixture prefer strict schemas or UI flexibility?","header":"Schema vs Flexibility","options":[{"label":"Strict schemas","description":"Prioritize validation and exact structure over visual freedom."},{"label":"UI flexibility","description":"Prioritize adaptable rendering and tolerant input handling."}],"multiple":false}]},"raw":"","time":{"start":1781817287898}},"id":"prt_edc96244e002RBYg9ASwc10E8R","sessionID":"ses_12369ea4cffePl0BsyVg3hGlmZ","messageID":"msg_edc9615bd002qb49gh9N1krbX2"},"time":1781817287898}}}}
17+{"type":"emit_inbound","label":"question.replied","frame":{"type":"sdk.event","event":{"id":"evt_edc9624de001JBbX1qaXzgfeKl","type":"question.replied","properties":{"sessionID":"ses_12369ea4cffePl0BsyVg3hGlmZ","requestID":"que_edc9624d9001e2GFKX724Z8b5Z","answers":[["Strict schemas"]]}}}}
18+{"type":"emit_inbound","label":"question.tool.completed","frame":{"type":"sdk.event","event":{"id":"evt_edc9624df001Pw6H7GGa8tCa3V","type":"message.part.updated","properties":{"sessionID":"ses_12369ea4cffePl0BsyVg3hGlmZ","part":{"type":"tool","tool":"question","callID":"call_XU0dxWGu2ioLZscBtYAIqGfT","state":{"status":"completed","input":{"questions":[{"question":"Should this fixture prefer strict schemas or UI flexibility?","header":"Schema vs Flexibility","options":[{"label":"Strict schemas","description":"Prioritize validation and exact structure over visual freedom."},{"label":"UI flexibility","description":"Prioritize adaptable rendering and tolerant input handling."}],"multiple":false}]},"output":"User answered: Strict schemas","metadata":{"answers":[["Strict schemas"]],"truncated":false},"title":"Asked 1 question","time":{"start":1781817287898,"end":1781817287903}},"id":"prt_edc96244e002RBYg9ASwc10E8R","sessionID":"ses_12369ea4cffePl0BsyVg3hGlmZ","messageID":"msg_edc9615bd002qb49gh9N1krbX2"},"time":1781817287903}}}}
19+{"type":"emit_inbound","label":"message.updated.assistant.second-step","frame":{"type":"sdk.event","event":{"id":"evt_edc9624fa002nuPVbTfhxoDpJh","type":"message.updated","properties":{"sessionID":"ses_12369ea4cffePl0BsyVg3hGlmZ","info":{"id":"msg_edc9624fa0015O6R1rRjuqc4uS","parentID":"msg_edc9615bb001iCnWab1qzSeRA3","role":"assistant","mode":"build","agent":"build","path":{"cwd":"/private/tmp/t3-opencode-probe","root":"/"},"cost":0,"tokens":{"input":0,"output":0,"reasoning":0,"cache":{"read":0,"write":0}},"modelID":"gpt-5.4-mini","providerID":"openai","time":{"created":1781817287930},"sessionID":"ses_12369ea4cffePl0BsyVg3hGlmZ"}}}}}
20+{"type":"emit_inbound","label":"assistant.completed","frame":{"type":"sdk.event","event":{"id":"evt_edc9628b5001owQkxpRhqtRF5N","type":"message.part.updated","properties":{"sessionID":"ses_12369ea4cffePl0BsyVg3hGlmZ","part":{"id":"prt_edc962867001us1nuabvajQrgu","messageID":"msg_edc9624fa0015O6R1rRjuqc4uS","sessionID":"ses_12369ea4cffePl0BsyVg3hGlmZ","type":"text","text":"plan questions fixture complete","time":{"start":1781817288807,"end":1781817288885}},"time":1781817288885}}}}
21+{"type":"emit_inbound","label":"message.updated.assistant.completed","frame":{"type":"sdk.event","event":{"id":"evt_edc9628bf002uUONicDkn1X7YA","type":"message.updated","properties":{"sessionID":"ses_12369ea4cffePl0BsyVg3hGlmZ","info":{"id":"msg_edc9624fa0015O6R1rRjuqc4uS","parentID":"msg_edc9615bb001iCnWab1qzSeRA3","role":"assistant","mode":"build","agent":"build","path":{"cwd":"/private/tmp/t3-opencode-probe","root":"/"},"cost":0,"tokens":{"total":11041,"input":281,"output":8,"reasoning":0,"cache":{"write":0,"read":10752}},"modelID":"gpt-5.4-mini","providerID":"openai","time":{"created":1781817287930,"completed":1781817288895},"sessionID":"ses_12369ea4cffePl0BsyVg3hGlmZ","finish":"stop"}}}}}
22+{"type":"emit_inbound","label":"session.status.idle","frame":{"type":"sdk.event","event":{"id":"evt_edc9628c00021lW2ZypKwU71AE","type":"session.status","properties":{"sessionID":"ses_12369ea4cffePl0BsyVg3hGlmZ","status":{"type":"idle"}}}}}
23+{"type":"runtime_exit","status":"success"}
apps/server/src/orchestration-v2/testkit/fixtures/queued_turn/grok_transcript.ndjson+12−0
@@ -0,0 +1,12 @@
1+{"type":"transcript_start","provider":"grok","protocol":"acp.ndjson-jsonrpc","version":"1","scenario":"queued_turn","metadata":{"generatedBy":"protocol-semantic-fixture","nativeSessionId":"grok-replay-session-1"}}
2+{"type":"expect_outbound","label":"initialize","frame":{"kind":"request","method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{"fs":{"readTextFile":false,"writeTextFile":false},"terminal":false,"elicitation":{"form":{}}},"clientInfo":{"name":"t3-code","version":"0.0.0"}}}}
3+{"type":"emit_inbound","label":"initialized","frame":{"kind":"response","method":"initialize","result":{"protocolVersion":1,"agentCapabilities":{"loadSession":true,"mcpCapabilities":{"http":true,"sse":true},"promptCapabilities":{"audio":false,"embeddedContext":true,"image":false}},"authMethods":[{"id":"replay","name":"Replay"}],"agentInfo":{"name":"grok-replay","version":"1"}}}}
4+{"type":"expect_outbound","label":"session.new","frame":{"kind":"request","method":"session/new","params":{"cwd":"<workspace>","mcpServers":"<any>"}}}
5+{"type":"emit_inbound","label":"session.created","frame":{"kind":"response","method":"session/new","result":{"sessionId":"grok-replay-session-1","models":{"currentModelId":"grok-build","availableModels":[{"modelId":"grok-build","name":"Grok Build"}]}}}}
6+{"type":"expect_outbound","label":"first.prompt","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"grok-replay-session-1","prompt":[{"type":"text","text":"Respond with exactly: first fixture turn complete"}]}}}
7+{"type":"emit_inbound","label":"first.assistant","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"grok-replay-session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"first fixture turn complete"}}}}}
8+{"type":"emit_inbound","label":"first.completed","frame":{"kind":"response","method":"session/prompt","result":{"stopReason":"end_turn"}}}
9+{"type":"expect_outbound","label":"queued.prompt","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"grok-replay-session-1","prompt":[{"type":"text","text":"Respond with exactly: second fixture turn complete"}]}}}
10+{"type":"emit_inbound","label":"queued.assistant","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"grok-replay-session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"second fixture turn complete"}}}}}
11+{"type":"emit_inbound","label":"queued.completed","frame":{"kind":"response","method":"session/prompt","result":{"stopReason":"end_turn"}}}
12+{"type":"runtime_exit","status":"success"}
apps/server/src/orchestration-v2/testkit/fixtures/shared.ts+18−0
@@ -42,6 +42,8 @@ export const MESSAGE_STEERING_INITIAL_PROMPT = · 41 unmodified lines
4242 "Respond with exactly: steering fixture initial response";
4343 export const SUBAGENT_PROMPT =
4444 "Spawn 2 subagents, one to read package.json and one to read tsconfig.json";
45+export const OPENCODE_SUBAGENT_PROMPT =
46+ "Use the task tool exactly once. Delegate to the general subagent with this prompt: Respond exactly CHILD_OK. After the task completes, respond exactly PARENT_OK.";
4547 export const SUBAGENT_CONTINUE_PROMPT =
4648 "Spawn one subagent and have it reply exactly: initial subagent response";
4749 export const SUBAGENT_CONTINUE_PARENT_PROMPT =
@@ -237,6 +239,22 @@ export const CURSOR_MODEL_SELECTION = { · 189 unmodified lines
237239 model: "composer-2.5",
238240 } satisfies ModelSelection;
239241
242+export const GROK_MODEL_SELECTION = {
243+ instanceId: ProviderInstanceId.make("grok"),
244+ model: "grok-build",
245+} satisfies ModelSelection;
246+
247+export const OPENCODE_MODEL_SELECTION = {
248+ instanceId: ProviderInstanceId.make("opencode"),
249+ model: "openai/gpt-5.4-mini",
250+ options: [{ id: "agent", value: "build" }],
251+} satisfies ModelSelection;
252+
253+export const ACP_REGISTRY_MODEL_SELECTION = {
254+ instanceId: ProviderInstanceId.make("acpRegistry"),
255+ model: "default",
256+} satisfies ModelSelection;
257+
240258 export const READ_ONLY_ON_REQUEST_POLICY = {
241259 approvalPolicy: "on-request",
242260 sandboxPolicy: {
apps/server/src/orchestration-v2/testkit/fixtures/simple/grok_transcript.ndjson+12−0
@@ -0,0 +1,12 @@
1+{"type":"transcript_start","provider":"grok","protocol":"acp.ndjson-jsonrpc","version":"1","scenario":"simple","metadata":{"generatedBy":"live-grok-shape-probe","nativeSessionId":"grok-replay-session-1"}}
2+{"type":"expect_outbound","label":"initialize","frame":{"kind":"request","method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{"fs":{"readTextFile":false,"writeTextFile":false},"terminal":false,"elicitation":{"form":{}}},"clientInfo":{"name":"t3-code","version":"0.0.0"}}}}
3+{"type":"emit_inbound","label":"initialized","frame":{"kind":"response","method":"initialize","result":{"protocolVersion":1,"agentCapabilities":{"loadSession":true,"mcpCapabilities":{"http":true,"sse":true},"promptCapabilities":{"audio":false,"embeddedContext":true,"image":false}},"authMethods":[{"id":"replay","name":"Replay"}],"agentInfo":{"name":"grok-replay","version":"1"}}}}
4+{"type":"expect_outbound","label":"session.new","frame":{"kind":"request","method":"session/new","params":{"cwd":"<workspace>","mcpServers":"<any>"}}}
5+{"type":"emit_inbound","label":"session.created","frame":{"kind":"response","method":"session/new","result":{"sessionId":"grok-replay-session-1","models":{"currentModelId":"grok-build","availableModels":[{"modelId":"grok-build","name":"Grok Build"},{"modelId":"grok-composer-2.5-fast","name":"Grok Composer 2.5 Fast"}]}}}}
6+{"type":"expect_outbound","label":"session.prompt","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"grok-replay-session-1","prompt":[{"type":"text","text":"Respond with the following text: fixture simple ok"}]}}}
7+{"type":"emit_inbound","label":"reasoning.1","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"grok-replay-session-1","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"I should "}}}}}
8+{"type":"emit_inbound","label":"reasoning.2","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"grok-replay-session-1","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"answer exactly."}}}}}
9+{"type":"emit_inbound","label":"assistant.1","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"grok-replay-session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"fixture simple "}}}}}
10+{"type":"emit_inbound","label":"assistant.2","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"grok-replay-session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ok"}}}}}
11+{"type":"emit_inbound","label":"prompt.completed","frame":{"kind":"response","method":"session/prompt","result":{"stopReason":"end_turn"}}}
12+{"type":"runtime_exit","status":"success"}
apps/server/src/orchestration-v2/testkit/fixtures/simple/opencode_transcript.ndjson+19−0
@@ -0,0 +1,19 @@
1+{"type":"transcript_start","provider":"opencode","protocol":"opencode-sdk.sse","version":"1.14.39","scenario":"simple","metadata":{"source":"authenticated-opencode-sdk-probe","capturedAt":"2026-06-18","nativeSessionId":"ses_1236c522dffeunHBidt20OIzWc","model":"openai/gpt-5.4-mini","description":"One real OpenCode session and text-only turn, captured at the SDK request/SSE boundary."}}
2+{"type":"expect_outbound","label":"event.subscribe","frame":{"type":"event.subscribe"}}
3+{"type":"expect_outbound","label":"session.create","frame":{"type":"session.create","input":{"title":"<any>","permission":"<any>"}}}
4+{"type":"emit_inbound","label":"session.created","frame":{"type":"sdk.response","operation":"session.create","data":{"id":"ses_1236c522dffeunHBidt20OIzWc","slug":"shiny-tiger","version":"1.14.39","projectID":"global","directory":"/private/tmp/t3-opencode-probe","path":"private/tmp/t3-opencode-probe","title":"T3 OpenCode v2 probe","time":{"created":1781817126354,"updated":1781817126354}}}}
5+{"type":"expect_outbound","label":"session.promptAsync","frame":{"type":"session.promptAsync","input":{"sessionID":"ses_1236c522dffeunHBidt20OIzWc","model":{"providerID":"openai","modelID":"gpt-5.4-mini"},"agent":"build","parts":[{"type":"text","text":"Respond with the following text: fixture simple ok"}]}}}
6+{"type":"emit_inbound","label":"session.promptAsync.response","frame":{"type":"sdk.response","operation":"session.promptAsync","data":null}}
7+{"type":"emit_inbound","label":"message.updated.user","frame":{"type":"sdk.event","event":{"id":"evt_edc93ade5002giWB7gMKgKTh1N","type":"message.updated","properties":{"sessionID":"ses_1236c522dffeunHBidt20OIzWc","info":{"role":"user","time":{"created":1781817126365},"agent":"build","model":{"providerID":"openai","modelID":"gpt-5.4-mini"},"id":"msg_edc93addd001jzzrE6lotn7BJj","sessionID":"ses_1236c522dffeunHBidt20OIzWc","summary":{"diffs":[]}}}}}}
8+{"type":"emit_inbound","label":"session.status.busy","frame":{"type":"sdk.event","event":{"id":"evt_edc93ade80015pjdCBdTty67tQ","type":"session.status","properties":{"sessionID":"ses_1236c522dffeunHBidt20OIzWc","status":{"type":"busy"}}}}}
9+{"type":"emit_inbound","label":"step-start","frame":{"type":"sdk.event","event":{"id":"evt_edc93b29c002bOE0jF6Ngtp9WQ","type":"message.part.updated","properties":{"sessionID":"ses_1236c522dffeunHBidt20OIzWc","part":{"id":"prt_edc93b29c001e2RRrSU0wC3EBK","messageID":"msg_edc93ade1001nkGj5Jt1JAd8nq","sessionID":"ses_1236c522dffeunHBidt20OIzWc","type":"step-start"},"time":1781817127580}}}}
10+{"type":"emit_inbound","label":"reasoning.started","frame":{"type":"sdk.event","event":{"id":"evt_edc93b3ac002IjQHVrdbEr0grF","type":"message.part.updated","properties":{"sessionID":"ses_1236c522dffeunHBidt20OIzWc","part":{"id":"prt_edc93b3ac0018TDuhzb5SgaTNW","messageID":"msg_edc93ade1001nkGj5Jt1JAd8nq","sessionID":"ses_1236c522dffeunHBidt20OIzWc","type":"reasoning","text":"","time":{"start":1781817127852},"metadata":{"openai":{"itemId":"rs_0cebf1703d45601c016a345f27cd448193bf5ca8d64792912b","reasoningEncryptedContent":"<redacted>"}}},"time":1781817127852}}}}
11+{"type":"emit_inbound","label":"reasoning.completed","frame":{"type":"sdk.event","event":{"id":"evt_edc93b5b7001F8JgWVWz3axRi4","type":"message.part.updated","properties":{"sessionID":"ses_1236c522dffeunHBidt20OIzWc","part":{"id":"prt_edc93b3ac0018TDuhzb5SgaTNW","messageID":"msg_edc93ade1001nkGj5Jt1JAd8nq","sessionID":"ses_1236c522dffeunHBidt20OIzWc","type":"reasoning","text":"","time":{"start":1781817127852,"end":1781817128375},"metadata":{"openai":{"itemId":"rs_0cebf1703d45601c016a345f27cd448193bf5ca8d64792912b","reasoningEncryptedContent":"<redacted>"}}},"time":1781817128375}}}}
12+{"type":"emit_inbound","label":"assistant.started","frame":{"type":"sdk.event","event":{"id":"evt_edc93b5b8002b8hqk8j4ycOQDv","type":"message.part.updated","properties":{"sessionID":"ses_1236c522dffeunHBidt20OIzWc","part":{"id":"prt_edc93b5b8001T13mKZn2DRRWdH","messageID":"msg_edc93ade1001nkGj5Jt1JAd8nq","sessionID":"ses_1236c522dffeunHBidt20OIzWc","type":"text","text":"","time":{"start":1781817128376},"metadata":{"openai":{"itemId":"msg_0cebf1703d45601c016a345f284b7c819398d57b692541e5a1","phase":"final_answer"}}},"time":1781817128376}}}}
13+{"type":"emit_inbound","label":"assistant.completed","frame":{"type":"sdk.event","event":{"id":"evt_edc93b5b90010jWiRMyH8CF4tS","type":"message.part.updated","properties":{"sessionID":"ses_1236c522dffeunHBidt20OIzWc","part":{"id":"prt_edc93b5b8001T13mKZn2DRRWdH","messageID":"msg_edc93ade1001nkGj5Jt1JAd8nq","sessionID":"ses_1236c522dffeunHBidt20OIzWc","type":"text","text":"fixture simple ok","time":{"start":1781817128376,"end":1781817128377},"metadata":{"openai":{"itemId":"msg_0cebf1703d45601c016a345f284b7c819398d57b692541e5a1","phase":"final_answer"}}},"time":1781817128377}}}}
14+{"type":"emit_inbound","label":"step-finish","frame":{"type":"sdk.event","event":{"id":"evt_edc93b5d500236244jGWEFb6ac","type":"message.part.updated","properties":{"sessionID":"ses_1236c522dffeunHBidt20OIzWc","part":{"id":"prt_edc93b5d5001CPBPBe0mxI1DbU","reason":"stop","messageID":"msg_edc93ade1001nkGj5Jt1JAd8nq","sessionID":"ses_1236c522dffeunHBidt20OIzWc","type":"step-finish","tokens":{"total":10837,"input":571,"output":9,"reasoning":17,"cache":{"write":0,"read":10240}},"cost":0},"time":1781817128405}}}}
15+{"type":"emit_inbound","label":"message.updated.assistant","frame":{"type":"sdk.event","event":{"id":"evt_edc93b5d6001bMOQP8gbJZ0od6","type":"message.updated","properties":{"sessionID":"ses_1236c522dffeunHBidt20OIzWc","info":{"id":"msg_edc93ade1001nkGj5Jt1JAd8nq","parentID":"msg_edc93addd001jzzrE6lotn7BJj","role":"assistant","mode":"build","agent":"build","path":{"cwd":"/private/tmp/t3-opencode-probe","root":"/"},"cost":0,"tokens":{"total":10837,"input":571,"output":9,"reasoning":17,"cache":{"write":0,"read":10240}},"modelID":"gpt-5.4-mini","providerID":"openai","time":{"created":1781817126369},"sessionID":"ses_1236c522dffeunHBidt20OIzWc","finish":"stop"}}}}}
16+{"type":"emit_inbound","label":"message.updated.assistant.completed","frame":{"type":"sdk.event","event":{"id":"evt_edc93b5d7001XXJei8Ixk5qMHN","type":"message.updated","properties":{"sessionID":"ses_1236c522dffeunHBidt20OIzWc","info":{"id":"msg_edc93ade1001nkGj5Jt1JAd8nq","parentID":"msg_edc93addd001jzzrE6lotn7BJj","role":"assistant","mode":"build","agent":"build","path":{"cwd":"/private/tmp/t3-opencode-probe","root":"/"},"cost":0,"tokens":{"total":10837,"input":571,"output":9,"reasoning":17,"cache":{"write":0,"read":10240}},"modelID":"gpt-5.4-mini","providerID":"openai","time":{"created":1781817126369,"completed":1781817128407},"sessionID":"ses_1236c522dffeunHBidt20OIzWc","finish":"stop"}}}}}
17+{"type":"emit_inbound","label":"session.status.busy.finalizing","frame":{"type":"sdk.event","event":{"id":"evt_edc93b5d7002NYE00v0LorwLmf","type":"session.status","properties":{"sessionID":"ses_1236c522dffeunHBidt20OIzWc","status":{"type":"busy"}}}}}
18+{"type":"emit_inbound","label":"session.status.idle","frame":{"type":"sdk.event","event":{"id":"evt_edc93b5d8001wkyQQVJNv3M9vP","type":"session.status","properties":{"sessionID":"ses_1236c522dffeunHBidt20OIzWc","status":{"type":"idle"}}}}}
19+{"type":"runtime_exit","status":"success"}
apps/server/src/orchestration-v2/testkit/fixtures/todo_list/grok_output.ts+49−0
@@ -0,0 +1,49 @@
1+import { assert } from "@effect/vitest";
2+import type { ProviderReplayTranscript } from "@t3tools/contracts";
3+
4+import type { OrchestratorV2ScenarioResult } from "../../OrchestratorScenario.ts";
5+import {
6+ assertAssistantTextIncludes,
7+ assertBaseProjection,
8+ assertExecutionNodeKinds,
9+ assertSemanticProjectionIntegrity,
10+ assertTurnItemTypes,
11+ assertUserMessagesInclude,
12+ assertVisibleTurnItemsMirrorLocalTurnItems,
13+ projectionFor,
14+ TODO_LIST_PROMPT,
15+} from "../shared.ts";
16+
17+export function assertTodoListGrokOutput(
18+ result: OrchestratorV2ScenarioResult,
19+ transcript: ProviderReplayTranscript,
20+) {
21+ assertBaseProjection({ result, transcript, runCount: 1, runStatuses: ["completed"] });
22+
23+ const projection = projectionFor(result, transcript.scenario);
24+ assertSemanticProjectionIntegrity(projection);
25+ assertVisibleTurnItemsMirrorLocalTurnItems(projection);
26+ assertExecutionNodeKinds(projection, ["root_turn", "todo_list", "tool_call"]);
27+ assertTurnItemTypes(projection, [
28+ "user_message",
29+ "todo_list",
30+ "file_search",
31+ "assistant_message",
32+ ]);
33+ assertUserMessagesInclude(projection, [TODO_LIST_PROMPT]);
34+ assertAssistantTextIncludes(projection, "todo list fixture complete");
35+
36+ const planEvents = result.domainEvents.filter((event) => event.type === "plan.updated");
37+ assert.lengthOf(planEvents, 2);
38+ assert.lengthOf(
39+ new Set(planEvents.map((event) => (event.type === "plan.updated" ? event.payload.id : null))),
40+ 1,
41+ "ACP plan updates must preserve one plan identity",
42+ );
43+ const todoLists = projection.plans.filter((plan) => plan.kind === "todo_list");
44+ assert.isAtLeast(todoLists.length, 1);
45+ assert.deepEqual(
46+ todoLists.at(-1)?.steps.map((step) => step.status),
47+ ["completed", "completed", "completed"],
48+ );
49+}
apps/server/src/orchestration-v2/testkit/fixtures/todo_list/grok_transcript.ndjson+16−0
@@ -0,0 +1,16 @@
1+{"type":"transcript_start","provider":"grok","protocol":"acp.ndjson-jsonrpc","version":"1","scenario":"todo_list","metadata":{"generatedBy":"live-grok-shape-probe","nativeSessionId":"grok-replay-session-1"}}
2+{"type":"expect_outbound","label":"initialize","frame":{"kind":"request","method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{"fs":{"readTextFile":false,"writeTextFile":false},"terminal":false,"elicitation":{"form":{}}},"clientInfo":{"name":"t3-code","version":"0.0.0"}}}}
3+{"type":"emit_inbound","label":"initialized","frame":{"kind":"response","method":"initialize","result":{"protocolVersion":1,"agentCapabilities":{"loadSession":true,"mcpCapabilities":{"http":true,"sse":true},"promptCapabilities":{"audio":false,"embeddedContext":true,"image":false}},"authMethods":[{"id":"replay","name":"Replay"}],"agentInfo":{"name":"grok-replay","version":"1"}}}}
4+{"type":"expect_outbound","label":"session.new","frame":{"kind":"request","method":"session/new","params":{"cwd":"<workspace>","mcpServers":"<any>"}}}
5+{"type":"emit_inbound","label":"session.created","frame":{"kind":"response","method":"session/new","result":{"sessionId":"grok-replay-session-1","models":{"currentModelId":"grok-build","availableModels":[{"modelId":"grok-build","name":"Grok Build"}]}}}}
6+{"type":"expect_outbound","label":"session.prompt","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"grok-replay-session-1","prompt":[{"type":"text","text":"Use the update_plan tool to track exactly three steps: inspect package.json, inspect tsconfig.json, report completion. Then read package.json and tsconfig.json, and answer exactly: todo list fixture complete"}]}}}
7+{"type":"emit_inbound","label":"assistant.progress","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"grok-replay-session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I'll track the three steps and inspect both files."}}}}}
8+{"type":"emit_inbound","label":"plan.started","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"grok-replay-session-1","update":{"sessionUpdate":"plan","entries":[{"content":"Inspect package.json","priority":"high","status":"in_progress"},{"content":"Inspect tsconfig.json","priority":"high","status":"pending"},{"content":"Report completion","priority":"high","status":"pending"}]}}}}
9+{"type":"emit_inbound","label":"tool.package.started","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"grok-replay-session-1","update":{"sessionUpdate":"tool_call","toolCallId":"read-package","title":"Read package.json","kind":"read","status":"pending","rawInput":{"path":"package.json"}}}}}
10+{"type":"emit_inbound","label":"tool.package.completed","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"grok-replay-session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"read-package","status":"completed","rawOutput":{"content":"{\"name\":\"t3\"}"}}}}}
11+{"type":"emit_inbound","label":"tool.tsconfig.started","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"grok-replay-session-1","update":{"sessionUpdate":"tool_call","toolCallId":"read-tsconfig","title":"Read tsconfig.json","kind":"read","status":"pending","rawInput":{"path":"tsconfig.json"}}}}}
12+{"type":"emit_inbound","label":"tool.tsconfig.completed","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"grok-replay-session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"read-tsconfig","status":"completed","rawOutput":{"content":"{\"compilerOptions\":{}}"}}}}}
13+{"type":"emit_inbound","label":"plan.completed","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"grok-replay-session-1","update":{"sessionUpdate":"plan","entries":[{"content":"Inspect package.json","priority":"high","status":"completed"},{"content":"Inspect tsconfig.json","priority":"high","status":"completed"},{"content":"Report completion","priority":"high","status":"completed"}]}}}}
14+{"type":"emit_inbound","label":"assistant.final","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"grok-replay-session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"todo list fixture complete"}}}}}
15+{"type":"emit_inbound","label":"prompt.completed","frame":{"kind":"response","method":"session/prompt","result":{"stopReason":"end_turn"}}}
16+{"type":"runtime_exit","status":"success"}
apps/server/src/orchestration-v2/testkit/fixtures/tool_call_read_only/grok_transcript.ndjson+15−0
@@ -0,0 +1,15 @@
1+{"type":"transcript_start","provider":"grok","protocol":"acp.ndjson-jsonrpc","version":"1","scenario":"tool_call_read_only","metadata":{"generatedBy":"live-grok-shape-probe","nativeSessionId":"grok-replay-session-1"}}
2+{"type":"expect_outbound","label":"initialize","frame":{"kind":"request","method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{"fs":{"readTextFile":false,"writeTextFile":false},"terminal":false,"elicitation":{"form":{}}},"clientInfo":{"name":"t3-code","version":"0.0.0"}}}}
3+{"type":"emit_inbound","label":"initialized","frame":{"kind":"response","method":"initialize","result":{"protocolVersion":1,"agentCapabilities":{"loadSession":true,"mcpCapabilities":{"http":true,"sse":true},"promptCapabilities":{"audio":false,"embeddedContext":true,"image":false}},"authMethods":[{"id":"replay","name":"Replay"}],"agentInfo":{"name":"grok-replay","version":"1"}}}}
4+{"type":"expect_outbound","label":"session.new","frame":{"kind":"request","method":"session/new","params":{"cwd":"<workspace>","mcpServers":"<any>"}}}
5+{"type":"emit_inbound","label":"session.created","frame":{"kind":"response","method":"session/new","result":{"sessionId":"grok-replay-session-1","models":{"currentModelId":"grok-build","availableModels":[{"modelId":"grok-build","name":"Grok Build"}]}}}}
6+{"type":"expect_outbound","label":"session.prompt","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"grok-replay-session-1","prompt":[{"type":"text","text":"Read /tmp/claude-replay-tool_call_read_only/package.json and /tmp/claude-replay-tool_call_read_only/tsconfig.json, then answer exactly: read only tool fixture complete"}]}}}
7+{"type":"emit_inbound","label":"assistant.progress","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"grok-replay-session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"Reading both files now.\n"}}}}}
8+{"type":"emit_inbound","label":"tool.package.started","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"grok-replay-session-1","update":{"sessionUpdate":"tool_call","toolCallId":"read-package","title":"Read package.json","kind":"read","status":"pending","rawInput":{"path":"/tmp/claude-replay-tool_call_read_only/package.json"}}}}}
9+{"type":"emit_inbound","label":"tool.package.running","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"grok-replay-session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"read-package","status":"in_progress"}}}}
10+{"type":"emit_inbound","label":"tool.package.completed","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"grok-replay-session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"read-package","status":"completed","rawOutput":{"content":"{\"name\":\"cursor-read-only-fixture\"}"}}}}}
11+{"type":"emit_inbound","label":"tool.tsconfig.started","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"grok-replay-session-1","update":{"sessionUpdate":"tool_call","toolCallId":"read-tsconfig","title":"Read tsconfig.json","kind":"read","status":"pending","rawInput":{"path":"/tmp/claude-replay-tool_call_read_only/tsconfig.json"}}}}}
12+{"type":"emit_inbound","label":"tool.tsconfig.completed","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"grok-replay-session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"read-tsconfig","status":"completed","rawOutput":{"content":"{\"compilerOptions\":{\"target\":\"ES2022\"}}"}}}}}
13+{"type":"emit_inbound","label":"assistant.final","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"grok-replay-session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"read only tool fixture complete"}}}}}
14+{"type":"emit_inbound","label":"prompt.completed","frame":{"kind":"response","method":"session/prompt","result":{"stopReason":"end_turn"}}}
15+{"type":"runtime_exit","status":"success"}
apps/server/src/orchestration-v2/testkit/fixtures/tool_call_read_only_on_request/grok_transcript.ndjson+13−0
@@ -0,0 +1,13 @@
1+{"type":"transcript_start","provider":"grok","protocol":"acp.ndjson-jsonrpc","version":"1","scenario":"tool_call_read_only_on_request","metadata":{"generatedBy":"live-grok-shape-probe","nativeSessionId":"grok-replay-session-1"}}
2+{"type":"expect_outbound","label":"initialize","frame":{"kind":"request","method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{"fs":{"readTextFile":false,"writeTextFile":false},"terminal":false,"elicitation":{"form":{}}},"clientInfo":{"name":"t3-code","version":"0.0.0"}}}}
3+{"type":"emit_inbound","label":"initialized","frame":{"kind":"response","method":"initialize","result":{"protocolVersion":1,"agentCapabilities":{"loadSession":true,"mcpCapabilities":{"http":true,"sse":true},"promptCapabilities":{"audio":false,"embeddedContext":true,"image":false}},"authMethods":[{"id":"replay","name":"Replay"}],"agentInfo":{"name":"grok-replay","version":"1"}}}}
4+{"type":"expect_outbound","label":"session.new","frame":{"kind":"request","method":"session/new","params":{"cwd":"<workspace>","mcpServers":"<any>"}}}
5+{"type":"emit_inbound","label":"session.created","frame":{"kind":"response","method":"session/new","result":{"sessionId":"grok-replay-session-1","models":{"currentModelId":"grok-build","availableModels":[{"modelId":"grok-build","name":"Grok Build"}]}}}}
6+{"type":"expect_outbound","label":"session.prompt","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"grok-replay-session-1","prompt":[{"type":"text","text":"Create or overwrite .codex-probe-write-action.txt with exactly this text: codex app-server approval fixture. Use a local shell command or file edit only, then briefly report what happened. Do not read package metadata, use GitHub, use web, or use MCP."}]}}}
7+{"type":"emit_inbound","label":"tool.started","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"grok-replay-session-1","update":{"sessionUpdate":"tool_call","toolCallId":"write-probe","title":"Write probe file","kind":"execute","status":"pending","rawInput":{"command":"printf fixture > .codex-probe-write-action.txt"}}}}}
8+{"type":"emit_inbound","label":"permission.request","frame":{"kind":"request","method":"session/request_permission","params":{"sessionId":"grok-replay-session-1","toolCall":{"toolCallId":"write-probe","title":"Write probe file","kind":"execute","status":"pending","content":[{"type":"content","content":{"type":"text","text":"Write .codex-probe-write-action.txt"}}]},"options":[{"optionId":"allow-once","name":"Allow once","kind":"allow_once"},{"optionId":"allow-always","name":"Allow for session","kind":"allow_always"},{"optionId":"reject-once","name":"Reject","kind":"reject_once"}]}}}
9+{"type":"expect_outbound","label":"permission.response","frame":{"kind":"response","method":"session/request_permission","result":{"outcome":{"outcome":"selected","optionId":"allow-once"}}}}
10+{"type":"emit_inbound","label":"tool.completed","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"grok-replay-session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"write-probe","status":"completed","rawOutput":{"exitCode":0,"stdout":"","stderr":""}}}}}
11+{"type":"emit_inbound","label":"assistant.final","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"grok-replay-session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"Created the requested probe file."}}}}}
12+{"type":"emit_inbound","label":"prompt.completed","frame":{"kind":"response","method":"session/prompt","result":{"stopReason":"end_turn"}}}
13+{"type":"runtime_exit","status":"success"}
apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt/grok_transcript.ndjson+9−0
@@ -0,0 +1,9 @@
1+{"type":"transcript_start","provider":"grok","protocol":"acp.ndjson-jsonrpc","version":"1","scenario":"turn_interrupt","metadata":{"generatedBy":"protocol-semantic-fixture","nativeSessionId":"grok-replay-session-1"}}
2+{"type":"expect_outbound","label":"initialize","frame":{"kind":"request","method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{"fs":{"readTextFile":false,"writeTextFile":false},"terminal":false,"elicitation":{"form":{}}},"clientInfo":{"name":"t3-code","version":"0.0.0"}}}}
3+{"type":"emit_inbound","label":"initialized","frame":{"kind":"response","method":"initialize","result":{"protocolVersion":1,"agentCapabilities":{"loadSession":true,"mcpCapabilities":{"http":true,"sse":true},"promptCapabilities":{"audio":false,"embeddedContext":true,"image":false}},"authMethods":[{"id":"replay","name":"Replay"}],"agentInfo":{"name":"grok-replay","version":"1"}}}}
4+{"type":"expect_outbound","label":"session.new","frame":{"kind":"request","method":"session/new","params":{"cwd":"<workspace>","mcpServers":"<any>"}}}
5+{"type":"emit_inbound","label":"session.created","frame":{"kind":"response","method":"session/new","result":{"sessionId":"grok-replay-session-1","models":{"currentModelId":"grok-build","availableModels":[{"modelId":"grok-build","name":"Grok Build"}]}}}}
6+{"type":"expect_outbound","label":"session.prompt","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"grok-replay-session-1","prompt":[{"type":"text","text":"Do not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally."}]}}}
7+{"type":"expect_outbound","label":"session.cancel","frame":{"kind":"notification","method":"session/cancel","params":{"sessionId":"grok-replay-session-1"}}}
8+{"type":"emit_inbound","label":"prompt.cancelled","frame":{"kind":"response","method":"session/prompt","result":{"stopReason":"cancelled"}}}
9+{"type":"runtime_exit","status":"success"}
apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt/opencode_transcript.ndjson+14−0
@@ -0,0 +1,14 @@
1+{"type":"transcript_start","provider":"opencode","protocol":"opencode-sdk.sse","version":"1.14.39","scenario":"turn_interrupt","metadata":{"source":"authenticated-opencode-sdk-probe","capturedAt":"2026-06-18","nativeSessionId":"ses_123570840ffenLEumkPQfegGCd","model":"openai/gpt-5.4-mini","filteredToRelevantEvents":true,"description":"A real immediate session abort where the authoritative idle notification arrived before the abort HTTP response."}}
2+{"type":"expect_outbound","label":"event.subscribe","frame":{"type":"event.subscribe"}}
3+{"type":"expect_outbound","label":"session.create","frame":{"type":"session.create","input":{"title":"<any>","permission":"<any>"}}}
4+{"type":"emit_inbound","label":"session.created","frame":{"type":"sdk.response","operation":"session.create","data":{"id":"ses_123570840ffenLEumkPQfegGCd","slug":"shiny-wolf","version":"1.14.39","projectID":"global","directory":"/private/tmp/t3-opencode-probe","path":"private/tmp/t3-opencode-probe","title":"T3 OpenCode interrupt probe","permission":[{"permission":"bash","pattern":"*","action":"deny"},{"permission":"edit","pattern":"*","action":"deny"},{"permission":"webfetch","pattern":"*","action":"deny"},{"permission":"websearch","pattern":"*","action":"deny"},{"permission":"codesearch","pattern":"*","action":"deny"},{"permission":"external_directory","pattern":"*","action":"deny"},{"permission":"question","pattern":"*","action":"allow"},{"permission":"read","pattern":"*","action":"allow"},{"permission":"glob","pattern":"*","action":"allow"},{"permission":"grep","pattern":"*","action":"allow"},{"permission":"lsp","pattern":"*","action":"allow"},{"permission":"todowrite","pattern":"*","action":"allow"},{"permission":"task","pattern":"*","action":"allow"},{"permission":"skill","pattern":"*","action":"allow"},{"permission":"doom_loop","pattern":"*","action":"allow"},{"permission":"edit","pattern":"*","action":"allow"}],"time":{"created":1781818521535,"updated":1781818521535}}}}
5+{"type":"expect_outbound","label":"session.promptAsync","frame":{"type":"session.promptAsync","input":{"sessionID":"ses_123570840ffenLEumkPQfegGCd","model":{"providerID":"openai","modelID":"gpt-5.4-mini"},"agent":"build","parts":[{"type":"text","text":"Do not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally."}]}}}
6+{"type":"emit_inbound","label":"session.promptAsync.response","frame":{"type":"sdk.response","operation":"session.promptAsync","data":null}}
7+{"type":"expect_outbound","label":"session.abort","frame":{"type":"session.abort","input":{"sessionID":"ses_123570840ffenLEumkPQfegGCd"}}}
8+{"type":"emit_inbound","label":"message.updated.user","frame":{"type":"sdk.event","event":{"id":"evt_edca8f7ca002yIVK4oYgGErvdA","type":"message.updated","properties":{"sessionID":"ses_123570840ffenLEumkPQfegGCd","info":{"id":"msg_edca8f7c9001w8R30p1SpzPuUJ","role":"user","sessionID":"ses_123570840ffenLEumkPQfegGCd","time":{"created":1781818521545},"agent":"build","model":{"providerID":"openai","modelID":"gpt-5.4-mini"}}}}}}
9+{"type":"emit_inbound","label":"message.part.updated.user","frame":{"type":"sdk.event","event":{"id":"evt_edca8f7ca00377fxNZjgW62lds","type":"message.part.updated","properties":{"sessionID":"ses_123570840ffenLEumkPQfegGCd","part":{"type":"text","text":"Do not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally.","messageID":"msg_edca8f7c9001w8R30p1SpzPuUJ","sessionID":"ses_123570840ffenLEumkPQfegGCd","id":"prt_edca8f7ca001ugmdiNWtC2964M"},"time":1781818521546}}}}
10+{"type":"emit_inbound","label":"session.status.busy","frame":{"type":"sdk.event","event":{"id":"evt_edca8f7cc001qWTqVj4Xrui4Sc","type":"session.status","properties":{"sessionID":"ses_123570840ffenLEumkPQfegGCd","status":{"type":"busy"}}}}}
11+{"type":"emit_inbound","label":"message.updated.assistant","frame":{"type":"sdk.event","event":{"id":"evt_edca8f7cc003brTfV2AHRR2C61","type":"message.updated","properties":{"sessionID":"ses_123570840ffenLEumkPQfegGCd","info":{"id":"msg_edca8f7cc002W56d1gpf5e17kX","parentID":"msg_edca8f7c9001w8R30p1SpzPuUJ","role":"assistant","mode":"build","agent":"build","path":{"cwd":"/private/tmp/t3-opencode-probe","root":"/"},"cost":0,"tokens":{"input":0,"output":0,"reasoning":0,"cache":{"read":0,"write":0}},"modelID":"gpt-5.4-mini","providerID":"openai","time":{"created":1781818521548},"sessionID":"ses_123570840ffenLEumkPQfegGCd"}}}}}
12+{"type":"emit_inbound","label":"session.status.idle","frame":{"type":"sdk.event","event":{"id":"evt_edca8f7d0001Gjy7U3InhsfliL","type":"session.status","properties":{"sessionID":"ses_123570840ffenLEumkPQfegGCd","status":{"type":"idle"}}}}}
13+{"type":"emit_inbound","label":"session.abort.response","frame":{"type":"sdk.response","operation":"session.abort","data":true}}
14+{"type":"runtime_exit","status":"success"}
apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts+149−1
@@ -10,7 +10,11 @@ import * as Effect from "effect/Effect"; · 9 unmodified lines
1010 import * as Stream from "effect/Stream";
1111 import { describe, expect } from "vite-plus/test";
1212
13−import { AcpSessionRuntime, type AcpSessionRequestLogEvent } from "./AcpSessionRuntime.ts";
13+import {
14+ AcpSessionRuntime,
15+ selectAcpAgentAuthMethod,
16+ type AcpSessionRequestLogEvent,
17+} from "./AcpSessionRuntime.ts";
1418 import type * as EffectAcpProtocol from "effect-acp/protocol";
1519
1620 const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -19,6 +23,17 @@ const mockAgentCommand = "node"; · 2 unmodified lines
1923 const mockAgentArgs = [mockAgentPath];
2024
2125 describe("AcpSessionRuntime", () => {
26+ it("selects explicit or agent-managed authentication without choosing terminal auth", () => {
27+ const methods = [
28+ { id: "browser", name: "Browser", type: "terminal" as const },
29+ { id: "api-key", name: "API key" },
30+ ];
31+
32+ expect(selectAcpAgentAuthMethod(methods)?.id).toBe("api-key");
33+ expect(selectAcpAgentAuthMethod(methods, "browser")?.id).toBe("browser");
34+ expect(selectAcpAgentAuthMethod(methods, "missing")).toBeUndefined();
35+ });
36+
2237 it.effect("merges custom initialize client capabilities into the ACP handshake", () => {
2338 const requestEvents: Array<AcpSessionRequestLogEvent> = [];
2439 return Effect.gen(function* () {
@@ -62,6 +77,82 @@ describe("AcpSessionRuntime", () => { · 37 unmodified lines
6277 );
6378 });
6479
80+ it.effect("does not authenticate when an advertised method is not required", () => {
81+ const requestEvents: Array<AcpSessionRequestLogEvent> = [];
82+ return Effect.gen(function* () {
83+ const runtime = yield* AcpSessionRuntime;
84+ yield* runtime.start();
85+
86+ expect(
87+ requestEvents.filter((event) => event.status === "started").map((event) => event.method),
88+ ).toEqual(["initialize", "session/new"]);
89+ }).pipe(
90+ Effect.provide(
91+ AcpSessionRuntime.layer({
92+ authMethodId: "test",
93+ spawn: {
94+ command: mockAgentCommand,
95+ args: mockAgentArgs,
96+ env: { T3_ACP_AUTH_METHOD_ID: "test" },
97+ },
98+ cwd: process.cwd(),
99+ clientInfo: { name: "t3-test", version: "0.0.0" },
100+ requestLogger: (event) =>
101+ Effect.sync(() => {
102+ requestEvents.push(event);
103+ }),
104+ }),
105+ ),
106+ Effect.scoped,
107+ Effect.provide(NodeServices.layer),
108+ );
109+ });
110+
111+ it.effect("authenticates and retries once after session creation returns auth_required", () => {
112+ const requestEvents: Array<AcpSessionRequestLogEvent> = [];
113+ return Effect.gen(function* () {
114+ const runtime = yield* AcpSessionRuntime;
115+ const started = yield* runtime.start();
116+
117+ expect(started.sessionId).toBe("mock-session-1");
118+ expect(
119+ requestEvents.filter((event) => event.status === "started").map((event) => event.method),
120+ ).toEqual(["initialize", "session/new", "authenticate", "session/new"]);
121+ expect(
122+ requestEvents.filter(
123+ (event) => event.method === "session/new" && event.status === "failed",
124+ ),
125+ ).toHaveLength(1);
126+ expect(
127+ requestEvents.filter(
128+ (event) => event.method === "session/new" && event.status === "succeeded",
129+ ),
130+ ).toHaveLength(1);
131+ }).pipe(
132+ Effect.provide(
133+ AcpSessionRuntime.layer({
134+ authMethodId: "test",
135+ spawn: {
136+ command: mockAgentCommand,
137+ args: mockAgentArgs,
138+ env: {
139+ T3_ACP_AUTH_METHOD_ID: "test",
140+ T3_ACP_REQUIRE_AUTH: "1",
141+ },
142+ },
143+ cwd: process.cwd(),
144+ clientInfo: { name: "t3-test", version: "0.0.0" },
145+ requestLogger: (event) =>
146+ Effect.sync(() => {
147+ requestEvents.push(event);
148+ }),
149+ }),
150+ ),
151+ Effect.scoped,
152+ Effect.provide(NodeServices.layer),
153+ );
154+ });
155+
65156 it.effect("starts a session, prompts, and emits normalized events against the mock agent", () =>
66157 Effect.gen(function* () {
67158 const runtime = yield* AcpSessionRuntime;
@@ -264,6 +355,63 @@ describe("AcpSessionRuntime", () => { · 196 unmodified lines
264355 );
265356 });
266357
358+ it.effect("supports negotiated ACP session list, fork, resume, and close methods", () => {
359+ const requestEvents: Array<AcpSessionRequestLogEvent> = [];
360+ return Effect.gen(function* () {
361+ const runtime = yield* AcpSessionRuntime;
362+ const started = yield* runtime.start();
363+ expect(started.initializeResult.agentCapabilities?.sessionCapabilities).toMatchObject({
364+ list: {},
365+ fork: {},
366+ resume: {},
367+ close: {},
368+ });
369+
370+ const listed = yield* runtime.listSessions();
371+ expect(listed.sessions.map((session) => session.sessionId)).toEqual(["mock-session-1"]);
372+
373+ const forked = yield* runtime.forkSession(started.sessionId);
374+ expect(forked.sessionId).toBe("mock-session-1-fork");
375+ yield* runtime.prompt({ prompt: [{ type: "text", text: "on fork" }] });
376+
377+ const resumed = yield* runtime.resumeSession(started.sessionId);
378+ expect(resumed.sessionId).toBe("mock-session-1");
379+ yield* runtime.closeSession();
380+
381+ expect(
382+ requestEvents.find(
383+ (event) => event.method === "session/prompt" && event.status === "started",
384+ )?.payload,
385+ ).toMatchObject({ sessionId: "mock-session-1-fork" });
386+ expect(
387+ requestEvents.filter((event) => event.status === "started").map((event) => event.method),
388+ ).toEqual(
389+ expect.arrayContaining(["session/list", "session/fork", "session/resume", "session/close"]),
390+ );
391+ }).pipe(
392+ Effect.provide(
393+ AcpSessionRuntime.layer({
394+ authMethodId: "test",
395+ spawn: {
396+ command: mockAgentCommand,
397+ args: mockAgentArgs,
398+ env: {
399+ T3_ACP_SESSION_LIFECYCLE: "1",
400+ },
401+ },
402+ cwd: process.cwd(),
403+ clientInfo: { name: "t3-test", version: "0.0.0" },
404+ requestLogger: (event) =>
405+ Effect.sync(() => {
406+ requestEvents.push(event);
407+ }),
408+ }),
409+ ),
410+ Effect.scoped,
411+ Effect.provide(NodeServices.layer),
412+ );
413+ });
414+
267415 it.effect("skips no-op session config writes when the requested value is already active", () => {
268416 const requestEvents: Array<AcpSessionRequestLogEvent> = [];
269417 return Effect.gen(function* () {
apps/server/src/provider/acp/AcpRegistrySupport.test.ts+263−0
@@ -0,0 +1,263 @@
1+import * as NodeServices from "@effect/platform-node/NodeServices";
2+import { describe, expect, it } from "@effect/vitest";
3+import { AcpRegistrySettings } from "@t3tools/contracts";
4+import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";
5+import * as Effect from "effect/Effect";
6+import * as FileSystem from "effect/FileSystem";
7+import * as Layer from "effect/Layer";
8+import * as Schema from "effect/Schema";
9+import { HttpClient, HttpClientResponse } from "effect/unstable/http";
10+
11+import {
12+ makeAcpRegistryResolver,
13+ resolveAcpRegistryDistribution,
14+ resolveAcpRegistryPlatformTarget,
15+ type AcpRegistryAgent,
16+} from "./AcpRegistrySupport.ts";
17+
18+const registryUrl = "https://registry.test/registry.json";
19+const archiveUrl = "https://registry.test/example-agent.bin";
20+const decodeAcpRegistrySettings = Schema.decodeSync(AcpRegistrySettings);
21+
22+function makeAgent(distribution: AcpRegistryAgent["distribution"]): AcpRegistryAgent {
23+ return {
24+ id: "example-agent",
25+ name: "Example Agent",
26+ version: "1.2.3",
27+ description: "ACP Registry test agent",
28+ distribution,
29+ };
30+}
31+
32+function makeRegistry(agent: AcpRegistryAgent): string {
33+ return JSON.stringify({ version: "1.0.0", agents: [agent] });
34+}
35+
36+function settings(input: Partial<AcpRegistrySettings> = {}): AcpRegistrySettings {
37+ return decodeAcpRegistrySettings({
38+ agentId: "example-agent",
39+ ...input,
40+ });
41+}
42+
43+function resolverLayer(execute: Parameters<typeof HttpClient.make>[0]) {
44+ return Layer.mergeAll(
45+ NodeServices.layer,
46+ Layer.succeed(HostProcessPlatform, "linux"),
47+ Layer.succeed(HostProcessArchitecture, "x64"),
48+ Layer.succeed(HttpClient.HttpClient, HttpClient.make(execute)),
49+ );
50+}
51+
52+describe("AcpRegistrySupport", () => {
53+ it("maps supported Node platforms to ACP Registry target keys", () => {
54+ expect(resolveAcpRegistryPlatformTarget("darwin", "arm64")).toBe("darwin-aarch64");
55+ expect(resolveAcpRegistryPlatformTarget("linux", "x64")).toBe("linux-x86_64");
56+ expect(resolveAcpRegistryPlatformTarget("win32", "arm64")).toBe("windows-aarch64");
57+ expect(resolveAcpRegistryPlatformTarget("freebsd", "x64")).toBeUndefined();
58+ expect(resolveAcpRegistryPlatformTarget("linux", "ia32")).toBeUndefined();
59+ });
60+
61+ it("selects the preferred compatible distribution", () => {
62+ const agent = makeAgent({
63+ binary: {
64+ "linux-x86_64": {
65+ archive: archiveUrl,
66+ cmd: "./bin/example-agent",
67+ args: ["acp"],
68+ },
69+ },
70+ npx: {
71+ package: "@example/acp@1.2.3",
72+ args: ["--stdio"],
73+ },
74+ });
75+
76+ expect(
77+ resolveAcpRegistryDistribution({
78+ agent,
79+ preference: "auto",
80+ platformTarget: "linux-x86_64",
81+ }),
82+ ).toMatchObject({ kind: "binary", args: ["acp"] });
83+ expect(
84+ resolveAcpRegistryDistribution({
85+ agent,
86+ preference: "npx",
87+ platformTarget: "linux-x86_64",
88+ }),
89+ ).toEqual({
90+ kind: "npx",
91+ packageName: "@example/acp@1.2.3",
92+ args: ["--stdio"],
93+ env: {},
94+ });
95+ expect(
96+ resolveAcpRegistryDistribution({
97+ agent,
98+ preference: "binary",
99+ platformTarget: "darwin-aarch64",
100+ }),
101+ ).toBeUndefined();
102+ });
103+
104+ it.effect("resolves command overrides while preserving registry args and environment", () => {
105+ const agent = makeAgent({
106+ binary: {
107+ "linux-x86_64": {
108+ archive: archiveUrl,
109+ cmd: "./bin/example-agent",
110+ args: ["acp", "--stdio"],
111+ env: { REGISTRY_VALUE: "registry", OVERRIDE_ME: "registry" },
112+ },
113+ },
114+ });
115+ const requests: Array<string> = [];
116+ return Effect.gen(function* () {
117+ const fileSystem = yield* FileSystem.FileSystem;
118+ const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
119+ prefix: "t3-acp-registry-override-",
120+ });
121+ const resolver = yield* makeAcpRegistryResolver({ cacheDir, registryUrl });
122+ const resolved = yield* resolver.resolve(
123+ settings({ commandPath: "/opt/example-agent" }),
124+ "/workspace",
125+ { HOST_VALUE: "host", OVERRIDE_ME: "host" },
126+ );
127+
128+ expect(resolved.distribution).toBe("binary");
129+ expect(resolved.spawn).toEqual({
130+ command: "/opt/example-agent",
131+ args: ["acp", "--stdio"],
132+ cwd: "/workspace",
133+ env: {
134+ HOST_VALUE: "host",
135+ OVERRIDE_ME: "registry",
136+ REGISTRY_VALUE: "registry",
137+ },
138+ });
139+ expect(requests).toEqual([registryUrl]);
140+ }).pipe(
141+ Effect.scoped,
142+ Effect.provide(
143+ resolverLayer((request) => {
144+ requests.push(request.url);
145+ return Effect.succeed(
146+ HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent))),
147+ );
148+ }),
149+ ),
150+ );
151+ });
152+
153+ it.effect("installs and reuses a registry binary in the managed cache", () => {
154+ const agent = makeAgent({
155+ binary: {
156+ "linux-x86_64": {
157+ archive: archiveUrl,
158+ cmd: "./bin/example-agent",
159+ args: ["acp"],
160+ },
161+ },
162+ });
163+ const binaryBytes = new TextEncoder().encode("#!/bin/sh\necho example\n");
164+ const requests: Array<string> = [];
165+ return Effect.gen(function* () {
166+ const fileSystem = yield* FileSystem.FileSystem;
167+ const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
168+ prefix: "t3-acp-registry-install-",
169+ });
170+ const resolver = yield* makeAcpRegistryResolver({ cacheDir, registryUrl });
171+ const first = yield* resolver.resolve(settings(), "/workspace");
172+ const second = yield* resolver.resolve(settings(), "/workspace");
173+
174+ expect(first.spawn.command).toBe(second.spawn.command);
175+ expect(first.spawn.command).toContain(
176+ "/acp-registry/agents/example-agent/1.2.3/linux-x86_64/bin/example-agent",
177+ );
178+ expect(yield* fileSystem.readFileString(first.spawn.command)).toBe(
179+ "#!/bin/sh\necho example\n",
180+ );
181+ expect(requests).toEqual([registryUrl, archiveUrl]);
182+ }).pipe(
183+ Effect.scoped,
184+ Effect.provide(
185+ resolverLayer((request) => {
186+ requests.push(request.url);
187+ const response =
188+ request.url === registryUrl
189+ ? new Response(makeRegistry(agent))
190+ : new Response(binaryBytes.buffer as ArrayBuffer);
191+ return Effect.succeed(HttpClientResponse.fromWeb(request, response));
192+ }),
193+ ),
194+ );
195+ });
196+
197+ it.effect("falls back to a valid cached registry index when refresh fails", () => {
198+ const agent = makeAgent({
199+ npx: {
200+ package: "@example/acp@1.2.3",
201+ args: ["--stdio"],
202+ },
203+ });
204+ return Effect.gen(function* () {
205+ const fileSystem = yield* FileSystem.FileSystem;
206+ const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
207+ prefix: "t3-acp-registry-cache-",
208+ });
209+ const registryDirectory = `${cacheDir}/acp-registry`;
210+ yield* fileSystem.makeDirectory(registryDirectory, { recursive: true });
211+ yield* fileSystem.writeFileString(`${registryDirectory}/registry.json`, makeRegistry(agent));
212+ const resolver = yield* makeAcpRegistryResolver({ cacheDir, registryUrl });
213+ const resolved = yield* resolver.resolve(settings(), "/workspace");
214+
215+ expect(resolved.spawn).toMatchObject({
216+ command: "npx",
217+ args: ["--yes", "@example/acp@1.2.3", "--stdio"],
218+ });
219+ }).pipe(
220+ Effect.scoped,
221+ Effect.provide(
222+ resolverLayer((request) =>
223+ Effect.succeed(
224+ HttpClientResponse.fromWeb(request, new Response("unavailable", { status: 503 })),
225+ ),
226+ ),
227+ ),
228+ );
229+ });
230+
231+ it.effect("rejects unsafe command paths before downloading an archive", () => {
232+ const agent = makeAgent({
233+ binary: {
234+ "linux-x86_64": {
235+ archive: archiveUrl,
236+ cmd: "../outside",
237+ },
238+ },
239+ });
240+ const requests: Array<string> = [];
241+ return Effect.gen(function* () {
242+ const fileSystem = yield* FileSystem.FileSystem;
243+ const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
244+ prefix: "t3-acp-registry-invalid-",
245+ });
246+ const resolver = yield* makeAcpRegistryResolver({ cacheDir, registryUrl });
247+ const error = yield* resolver.resolve(settings(), "/workspace").pipe(Effect.flip);
248+
249+ expect(error.reason).toBe("archive_invalid");
250+ expect(requests).toEqual([registryUrl]);
251+ }).pipe(
252+ Effect.scoped,
253+ Effect.provide(
254+ resolverLayer((request) => {
255+ requests.push(request.url);
256+ return Effect.succeed(
257+ HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent))),
258+ );
259+ }),
260+ ),
261+ );
262+ });
263+});
apps/server/src/provider/acp/AcpRegistrySupport.ts+633−0
@@ -0,0 +1,633 @@
1+import {
2+ type AcpRegistryDistributionPreference,
3+ type AcpRegistrySettings,
4+} from "@t3tools/contracts";
5+import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";
6+import * as Clock from "effect/Clock";
7+import * as Effect from "effect/Effect";
8+import * as FileSystem from "effect/FileSystem";
9+import * as Option from "effect/Option";
10+import * as Path from "effect/Path";
11+import * as PlatformError from "effect/PlatformError";
12+import * as Ref from "effect/Ref";
13+import * as Schema from "effect/Schema";
14+import * as Semaphore from "effect/Semaphore";
15+import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
16+import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
17+
18+import { collectUint8StreamText } from "../../stream/collectUint8StreamText.ts";
19+import type { AcpSpawnInput } from "./AcpSessionRuntime.ts";
20+
21+export const ACP_REGISTRY_URL =
22+ "https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json";
23+
24+const AcpRegistryPackageDistribution = Schema.Struct({
25+ package: Schema.String,
26+ args: Schema.optionalKey(Schema.Array(Schema.String)),
27+ env: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)),
28+});
29+
30+const AcpRegistryBinaryTarget = Schema.Struct({
31+ archive: Schema.String,
32+ cmd: Schema.String,
33+ args: Schema.optionalKey(Schema.Array(Schema.String)),
34+ env: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)),
35+});
36+
37+const AcpRegistryAgent = Schema.Struct({
38+ id: Schema.String,
39+ name: Schema.String,
40+ version: Schema.String,
41+ description: Schema.String,
42+ distribution: Schema.Struct({
43+ binary: Schema.optionalKey(Schema.Record(Schema.String, AcpRegistryBinaryTarget)),
44+ npx: Schema.optionalKey(AcpRegistryPackageDistribution),
45+ uvx: Schema.optionalKey(AcpRegistryPackageDistribution),
46+ }),
47+});
48+export type AcpRegistryAgent = typeof AcpRegistryAgent.Type;
49+
50+const AcpRegistryIndex = Schema.Struct({
51+ version: Schema.String,
52+ agents: Schema.Array(AcpRegistryAgent),
53+});
54+export type AcpRegistryIndex = typeof AcpRegistryIndex.Type;
55+
56+const decodeRegistryIndex = Schema.decodeUnknownEffect(AcpRegistryIndex);
57+const decodeJson = Schema.decodeUnknownEffect(Schema.UnknownFromJsonString);
58+
59+export const AcpRegistryErrorReason = Schema.Literals([
60+ "agent_not_configured",
61+ "agent_not_found",
62+ "archive_invalid",
63+ "download_failed",
64+ "install_failed",
65+ "registry_unavailable",
66+ "unsupported_distribution",
67+ "unsupported_platform",
68+]);
69+export type AcpRegistryErrorReason = typeof AcpRegistryErrorReason.Type;
70+
71+export class AcpRegistryError extends Schema.TaggedErrorClass<AcpRegistryError>()(
72+ "AcpRegistryError",
73+ {
74+ reason: AcpRegistryErrorReason,
75+ detail: Schema.String,
76+ cause: Schema.optional(Schema.Defect()),
77+ },
78+) {
79+ override get message(): string {
80+ return this.detail;
81+ }
82+}
83+
84+const isAcpRegistryError = Schema.is(AcpRegistryError);
85+
86+export type AcpRegistryPlatformTarget =
87+ | "darwin-aarch64"
88+ | "darwin-x86_64"
89+ | "linux-aarch64"
90+ | "linux-x86_64"
91+ | "windows-aarch64"
92+ | "windows-x86_64";
93+
94+export function resolveAcpRegistryPlatformTarget(
95+ platform: NodeJS.Platform,
96+ architecture: NodeJS.Architecture,
97+): AcpRegistryPlatformTarget | undefined {
98+ const os =
99+ platform === "darwin"
100+ ? "darwin"
101+ : platform === "linux"
102+ ? "linux"
103+ : platform === "win32"
104+ ? "windows"
105+ : undefined;
106+ const arch = architecture === "arm64" ? "aarch64" : architecture === "x64" ? "x86_64" : undefined;
107+ return os && arch ? (`${os}-${arch}` as AcpRegistryPlatformTarget) : undefined;
108+}
109+
110+export type AcpRegistryDistributionKind = "binary" | "npx" | "uvx";
111+
112+export interface ResolvedAcpRegistryDistribution {
113+ readonly kind: AcpRegistryDistributionKind;
114+ readonly args: ReadonlyArray<string>;
115+ readonly env: Readonly<Record<string, string>>;
116+ readonly binaryTarget?: typeof AcpRegistryBinaryTarget.Type;
117+ readonly packageName?: string;
118+}
119+
120+export function resolveAcpRegistryDistribution(input: {
121+ readonly agent: AcpRegistryAgent;
122+ readonly preference: AcpRegistryDistributionPreference;
123+ readonly platformTarget: AcpRegistryPlatformTarget | undefined;
124+}): ResolvedAcpRegistryDistribution | undefined {
125+ const { agent, platformTarget } = input;
126+ const binary = platformTarget ? agent.distribution.binary?.[platformTarget] : undefined;
127+ const candidates: ReadonlyArray<AcpRegistryDistributionKind> =
128+ input.preference === "auto" ? ["binary", "npx", "uvx"] : [input.preference];
129+
130+ for (const kind of candidates) {
131+ if (kind === "binary" && binary) {
132+ return {
133+ kind,
134+ args: binary.args ?? [],
135+ env: binary.env ?? {},
136+ binaryTarget: binary,
137+ };
138+ }
139+ if (kind === "npx" && agent.distribution.npx) {
140+ return {
141+ kind,
142+ args: agent.distribution.npx.args ?? [],
143+ env: agent.distribution.npx.env ?? {},
144+ packageName: agent.distribution.npx.package,
145+ };
146+ }
147+ if (kind === "uvx" && agent.distribution.uvx) {
148+ return {
149+ kind,
150+ args: agent.distribution.uvx.args ?? [],
151+ env: agent.distribution.uvx.env ?? {},
152+ packageName: agent.distribution.uvx.package,
153+ };
154+ }
155+ }
156+ return undefined;
157+}
158+
159+export interface ResolvedAcpRegistryAgent {
160+ readonly agent: AcpRegistryAgent;
161+ readonly distribution: AcpRegistryDistributionKind;
162+ readonly spawn: AcpSpawnInput;
163+}
164+
165+export interface AcpRegistryResolverShape {
166+ readonly resolve: (
167+ settings: AcpRegistrySettings,
168+ cwd: string,
169+ environment?: NodeJS.ProcessEnv,
170+ ) => Effect.Effect<ResolvedAcpRegistryAgent, AcpRegistryError>;
171+}
172+
173+const INSTALL_LOCK_RETRY_COUNT = 300;
174+const INSTALL_LOCK_RETRY_DELAY = "100 millis";
175+const INSTALL_LOCK_STALE_MS = 5 * 60 * 1_000;
176+const MAX_ARCHIVE_BYTES = 512 * 1024 * 1024;
177+
178+function isAlreadyExists(error: PlatformError.PlatformError): boolean {
179+ return error.reason._tag === "AlreadyExists";
180+}
181+
182+function normalizeRegistryCommandPath(command: string): ReadonlyArray<string> | undefined {
183+ const normalized = command.trim().replaceAll("\\", "/").replace(/^\.\//u, "");
184+ const segments = normalized.split("/").filter((segment) => segment.length > 0);
185+ if (
186+ segments.length === 0 ||
187+ normalized.startsWith("/") ||
188+ /^[a-zA-Z]:/u.test(normalized) ||
189+ segments.some((segment) => segment === "." || segment === "..")
190+ ) {
191+ return undefined;
192+ }
193+ return segments;
194+}
195+
196+function validateArchiveEntries(output: string): boolean {
197+ return output
198+ .split(/\r?\n/u)
199+ .map((entry) => entry.trim())
200+ .filter((entry) => entry.length > 0)
201+ .every((entry) => normalizeRegistryCommandPath(entry) !== undefined);
202+}
203+
204+type ArchiveKind = "raw" | "tar_bz2" | "tar_gz" | "zip";
205+
206+function archiveKind(url: string): ArchiveKind {
207+ const pathname = new URL(url).pathname.toLowerCase();
208+ if (pathname.endsWith(".tar.gz") || pathname.endsWith(".tgz")) return "tar_gz";
209+ if (pathname.endsWith(".tar.bz2") || pathname.endsWith(".tbz2")) return "tar_bz2";
210+ if (pathname.endsWith(".zip")) return "zip";
211+ return "raw";
212+}
213+
214+function archiveFileName(kind: ArchiveKind): string {
215+ switch (kind) {
216+ case "tar_gz":
217+ return "agent.tar.gz";
218+ case "tar_bz2":
219+ return "agent.tar.bz2";
220+ case "zip":
221+ return "agent.zip";
222+ case "raw":
223+ return "agent.bin";
224+ }
225+}
226+
227+export const makeAcpRegistryResolver = Effect.fn("AcpRegistryResolver.make")(function* (input: {
228+ readonly cacheDir: string;
229+ readonly registryUrl?: string;
230+}): Effect.fn.Return<
231+ AcpRegistryResolverShape,
232+ never,
233+ | ChildProcessSpawner.ChildProcessSpawner
234+ | FileSystem.FileSystem
235+ | HttpClient.HttpClient
236+ | Path.Path
237+> {
238+ const fileSystem = yield* FileSystem.FileSystem;
239+ const path = yield* Path.Path;
240+ const httpClient = yield* HttpClient.HttpClient;
241+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
242+ const platform = yield* HostProcessPlatform;
243+ const architecture = yield* HostProcessArchitecture;
244+ const platformTarget = resolveAcpRegistryPlatformTarget(platform, architecture);
245+ const registryUrl = input.registryUrl ?? ACP_REGISTRY_URL;
246+ const registryDirectory = path.join(input.cacheDir, "acp-registry");
247+ const registryCachePath = path.join(registryDirectory, "registry.json");
248+ const installsDirectory = path.join(registryDirectory, "agents");
249+ const registryRef = yield* Ref.make<AcpRegistryIndex | undefined>(undefined);
250+ const registrySemaphore = yield* Semaphore.make(1);
251+ const installSemaphore = yield* Semaphore.make(1);
252+
253+ const decodeRegistryText = Effect.fn("AcpRegistryResolver.decodeRegistryText")(function* (
254+ text: string,
255+ ) {
256+ const decoded = yield* decodeJson(text).pipe(
257+ Effect.mapError(
258+ (cause) =>
259+ new AcpRegistryError({
260+ reason: "registry_unavailable",
261+ detail: "ACP Registry returned invalid JSON.",
262+ cause,
263+ }),
264+ ),
265+ );
266+ return yield* decodeRegistryIndex(decoded).pipe(
267+ Effect.mapError(
268+ (cause) =>
269+ new AcpRegistryError({
270+ reason: "registry_unavailable",
271+ detail: "ACP Registry returned an invalid index.",
272+ cause,
273+ }),
274+ ),
275+ );
276+ });
277+
278+ const readCachedRegistry = fileSystem
279+ .readFileString(registryCachePath)
280+ .pipe(Effect.flatMap(decodeRegistryText), Effect.option);
281+
282+ const writeRegistryCache = (text: string) =>
283+ fileSystem
284+ .makeDirectory(registryDirectory, { recursive: true })
285+ .pipe(
286+ Effect.andThen(fileSystem.writeFileString(`${registryCachePath}.${process.pid}.tmp`, text)),
287+ Effect.andThen(
288+ fileSystem.rename(`${registryCachePath}.${process.pid}.tmp`, registryCachePath),
289+ ),
290+ Effect.ignore,
291+ );
292+
293+ const fetchRegistry = Effect.gen(function* () {
294+ const response = yield* httpClient.execute(HttpClientRequest.get(registryUrl)).pipe(
295+ Effect.flatMap(HttpClientResponse.filterStatusOk),
296+ Effect.mapError(
297+ (cause) =>
298+ new AcpRegistryError({
299+ reason: "registry_unavailable",
300+ detail: `Could not fetch ACP Registry index from ${registryUrl}.`,
301+ cause,
302+ }),
303+ ),
304+ );
305+ const text = yield* response.text.pipe(
306+ Effect.mapError(
307+ (cause) =>
308+ new AcpRegistryError({
309+ reason: "registry_unavailable",
310+ detail: "Could not read ACP Registry response body.",
311+ cause,
312+ }),
313+ ),
314+ );
315+ const registry = yield* decodeRegistryText(text);
316+ yield* writeRegistryCache(text);
317+ return registry;
318+ });
319+
320+ const loadRegistry = registrySemaphore.withPermits(1)(
321+ Effect.gen(function* () {
322+ const memoized = yield* Ref.get(registryRef);
323+ if (memoized !== undefined) return memoized;
324+ const registry = yield* fetchRegistry.pipe(
325+ Effect.catch((networkError) =>
326+ readCachedRegistry.pipe(
327+ Effect.flatMap(
328+ Option.match({
329+ onNone: () => Effect.fail(networkError),
330+ onSome: Effect.succeed,
331+ }),
332+ ),
333+ ),
334+ ),
335+ );
336+ yield* Ref.set(registryRef, registry);
337+ return registry;
338+ }),
339+ );
340+
341+ const runCommand = Effect.fn("AcpRegistryResolver.runCommand")(function* (
342+ command: string,
343+ args: ReadonlyArray<string>,
344+ cwd?: string,
345+ ) {
346+ const child = yield* spawner.spawn(
347+ ChildProcess.make(command, args, {
348+ ...(cwd ? { cwd } : {}),
349+ shell: false,
350+ }),
351+ );
352+ const [stdout, stderr, exitCode] = yield* Effect.all(
353+ [
354+ collectUint8StreamText({ stream: child.stdout }),
355+ collectUint8StreamText({ stream: child.stderr }),
356+ child.exitCode,
357+ ],
358+ { concurrency: "unbounded" },
359+ );
360+ if (Number(exitCode) !== 0) {
361+ return yield* new AcpRegistryError({
362+ reason: "install_failed",
363+ detail: `ACP Registry install command '${command}' exited with code ${Number(exitCode)}: ${stderr.text.trim()}`,
364+ });
365+ }
366+ return stdout.text;
367+ });
368+
369+ const acquireInstallLock = Effect.fn("AcpRegistryResolver.acquireInstallLock")(function* (
370+ lockPath: string,
371+ ) {
372+ for (let attempt = 0; attempt < INSTALL_LOCK_RETRY_COUNT; attempt += 1) {
373+ const acquired = yield* fileSystem.writeFileString(lockPath, "", { flag: "wx" }).pipe(
374+ Effect.as(true),
375+ Effect.catch((error) =>
376+ isAlreadyExists(error) ? Effect.succeed(false) : Effect.fail(error),
377+ ),
378+ );
379+ if (acquired) return;
380+ const now = yield* Clock.currentTimeMillis;
381+ const lockInfo = yield* fileSystem.stat(lockPath).pipe(Effect.option);
382+ const mtime = Option.flatMap(lockInfo, (info) => info.mtime);
383+ if (Option.isSome(mtime) && now - mtime.value.getTime() > INSTALL_LOCK_STALE_MS) {
384+ yield* fileSystem.remove(lockPath, { force: true });
385+ continue;
386+ }
387+ yield* Effect.sleep(INSTALL_LOCK_RETRY_DELAY);
388+ }
389+ return yield* new AcpRegistryError({
390+ reason: "install_failed",
391+ detail: `Timed out waiting for ACP Registry install lock ${lockPath}.`,
392+ });
393+ });
394+
395+ const assertExecutableInRoot = Effect.fn("AcpRegistryResolver.assertExecutableInRoot")(function* (
396+ root: string,
397+ executablePath: string,
398+ ) {
399+ const rootRealPath = yield* fileSystem.realPath(root);
400+ const executableRealPath = yield* fileSystem.realPath(executablePath);
401+ const relative = path.relative(rootRealPath, executableRealPath);
402+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
403+ return yield* new AcpRegistryError({
404+ reason: "archive_invalid",
405+ detail: "ACP Registry archive command resolves outside its installation directory.",
406+ });
407+ }
408+ return executableRealPath;
409+ });
410+
411+ const installBinary = Effect.fn("AcpRegistryResolver.installBinary")(function* (
412+ agent: AcpRegistryAgent,
413+ target: typeof AcpRegistryBinaryTarget.Type,
414+ ) {
415+ if (platformTarget === undefined) {
416+ return yield* new AcpRegistryError({
417+ reason: "unsupported_platform",
418+ detail: `ACP Registry does not support platform ${platform}-${architecture}.`,
419+ });
420+ }
421+ const commandSegments = normalizeRegistryCommandPath(target.cmd);
422+ if (commandSegments === undefined) {
423+ return yield* new AcpRegistryError({
424+ reason: "archive_invalid",
425+ detail: `ACP Registry agent ${agent.id} declares an unsafe command path '${target.cmd}'.`,
426+ });
427+ }
428+ const installRoot = path.join(installsDirectory, agent.id, agent.version, platformTarget);
429+ const executablePath = path.join(installRoot, ...commandSegments);
430+ if (yield* fileSystem.exists(executablePath).pipe(Effect.orElseSucceed(() => false))) {
431+ return yield* assertExecutableInRoot(installRoot, executablePath).pipe(
432+ Effect.mapError((cause) =>
433+ isAcpRegistryError(cause)
434+ ? cause
435+ : new AcpRegistryError({
436+ reason: "install_failed",
437+ detail: `Could not validate cached ACP Registry agent ${agent.id}.`,
438+ cause,
439+ }),
440+ ),
441+ );
442+ }
443+
444+ yield* fileSystem.makeDirectory(path.dirname(installRoot), { recursive: true }).pipe(
445+ Effect.mapError(
446+ (cause) =>
447+ new AcpRegistryError({
448+ reason: "install_failed",
449+ detail: `Could not create ACP Registry cache for ${agent.id}.`,
450+ cause,
451+ }),
452+ ),
453+ );
454+ const lockPath = `${installRoot}.lock`;
455+ yield* acquireInstallLock(lockPath).pipe(
456+ Effect.mapError((cause) =>
457+ isAcpRegistryError(cause)
458+ ? cause
459+ : new AcpRegistryError({
460+ reason: "install_failed",
461+ detail: `Could not acquire install lock for ACP Registry agent ${agent.id}.`,
462+ cause,
463+ }),
464+ ),
465+ );
466+
467+ return yield* Effect.gen(function* () {
468+ if (yield* fileSystem.exists(executablePath).pipe(Effect.orElseSucceed(() => false))) {
469+ return yield* assertExecutableInRoot(installRoot, executablePath);
470+ }
471+ const temporaryRoot = yield* fileSystem.makeTempDirectoryScoped({
472+ directory: path.dirname(installRoot),
473+ prefix: `.${agent.id}-install-`,
474+ });
475+ const extractionRoot = path.join(temporaryRoot, "extracted");
476+ yield* fileSystem.makeDirectory(extractionRoot, { recursive: true });
477+ const kind = archiveKind(target.archive);
478+ const archivePath = path.join(temporaryRoot, archiveFileName(kind));
479+ const response = yield* httpClient.execute(HttpClientRequest.get(target.archive)).pipe(
480+ Effect.flatMap(HttpClientResponse.filterStatusOk),
481+ Effect.mapError(
482+ (cause) =>
483+ new AcpRegistryError({
484+ reason: "download_failed",
485+ detail: `Could not download ACP Registry agent ${agent.id} ${agent.version}.`,
486+ cause,
487+ }),
488+ ),
489+ );
490+ const bytes = new Uint8Array(
491+ yield* response.arrayBuffer.pipe(
492+ Effect.mapError(
493+ (cause) =>
494+ new AcpRegistryError({
495+ reason: "download_failed",
496+ detail: `Could not read ACP Registry agent ${agent.id} download.`,
497+ cause,
498+ }),
499+ ),
500+ ),
501+ );
502+ if (bytes.byteLength > MAX_ARCHIVE_BYTES) {
503+ return yield* new AcpRegistryError({
504+ reason: "archive_invalid",
505+ detail: `ACP Registry agent ${agent.id} archive exceeds ${MAX_ARCHIVE_BYTES} bytes.`,
506+ });
507+ }
508+ yield* fileSystem.writeFile(archivePath, bytes);
509+
510+ if (kind === "raw") {
511+ const targetPath = path.join(extractionRoot, ...commandSegments);
512+ yield* fileSystem.makeDirectory(path.dirname(targetPath), { recursive: true });
513+ yield* fileSystem.rename(archivePath, targetPath);
514+ } else {
515+ const listArgs =
516+ kind === "tar_gz"
517+ ? ["-tzf", archivePath]
518+ : kind === "tar_bz2"
519+ ? ["-tjf", archivePath]
520+ : platform === "win32"
521+ ? ["-tf", archivePath]
522+ : undefined;
523+ const entries =
524+ listArgs === undefined
525+ ? yield* runCommand("unzip", ["-Z1", archivePath])
526+ : yield* runCommand("tar", listArgs);
527+ if (!validateArchiveEntries(entries)) {
528+ return yield* new AcpRegistryError({
529+ reason: "archive_invalid",
530+ detail: `ACP Registry agent ${agent.id} archive contains an unsafe path.`,
531+ });
532+ }
533+ if (kind === "zip" && platform !== "win32") {
534+ yield* runCommand("unzip", ["-q", archivePath, "-d", extractionRoot]);
535+ } else {
536+ const extractFlag = kind === "tar_gz" ? "-xzf" : kind === "tar_bz2" ? "-xjf" : "-xf";
537+ yield* runCommand("tar", [extractFlag, archivePath, "-C", extractionRoot]);
538+ }
539+ }
540+
541+ const stagedExecutable = path.join(extractionRoot, ...commandSegments);
542+ if (!(yield* fileSystem.exists(stagedExecutable).pipe(Effect.orElseSucceed(() => false)))) {
543+ return yield* new AcpRegistryError({
544+ reason: "archive_invalid",
545+ detail: `ACP Registry archive for ${agent.id} did not contain '${target.cmd}'.`,
546+ });
547+ }
548+ if (platform !== "win32") yield* fileSystem.chmod(stagedExecutable, 0o755);
549+ yield* assertExecutableInRoot(extractionRoot, stagedExecutable);
550+ yield* fileSystem.remove(installRoot, { recursive: true, force: true });
551+ yield* fileSystem.rename(extractionRoot, installRoot);
552+ return yield* assertExecutableInRoot(installRoot, executablePath);
553+ }).pipe(
554+ Effect.scoped,
555+ Effect.ensuring(fileSystem.remove(lockPath, { force: true }).pipe(Effect.ignore)),
556+ Effect.mapError((cause) =>
557+ isAcpRegistryError(cause)
558+ ? cause
559+ : new AcpRegistryError({
560+ reason: "install_failed",
561+ detail: `Could not install ACP Registry agent ${agent.id}.`,
562+ cause,
563+ }),
564+ ),
565+ );
566+ });
567+
568+ const resolve: AcpRegistryResolverShape["resolve"] = (settings, cwd, environment) =>
569+ Effect.gen(function* () {
570+ const agentId = settings.agentId.trim();
571+ if (agentId.length === 0) {
572+ return yield* new AcpRegistryError({
573+ reason: "agent_not_configured",
574+ detail: "ACP Registry provider requires a registry agent ID.",
575+ });
576+ }
577+ const registry = yield* loadRegistry;
578+ const agent = registry.agents.find((candidate) => candidate.id === agentId);
579+ if (agent === undefined) {
580+ return yield* new AcpRegistryError({
581+ reason: "agent_not_found",
582+ detail: `ACP Registry does not contain agent '${agentId}'.`,
583+ });
584+ }
585+ const distribution = resolveAcpRegistryDistribution({
586+ agent,
587+ preference: settings.distribution,
588+ platformTarget,
589+ });
590+ if (distribution === undefined) {
591+ return yield* new AcpRegistryError({
592+ reason:
593+ platformTarget === undefined ? "unsupported_platform" : "unsupported_distribution",
594+ detail: `ACP Registry agent ${agent.id} has no ${settings.distribution === "auto" ? "compatible" : settings.distribution} distribution for ${platform}-${architecture}.`,
595+ });
596+ }
597+
598+ let command: string;
599+ let args: ReadonlyArray<string>;
600+ const commandOverride = settings.commandPath.trim();
601+ if (commandOverride.length > 0) {
602+ command = commandOverride;
603+ args = distribution.args;
604+ } else if (distribution.kind === "npx") {
605+ command = "npx";
606+ args = ["--yes", distribution.packageName!, ...distribution.args];
607+ } else if (distribution.kind === "uvx") {
608+ command = "uvx";
609+ args = [distribution.packageName!, ...distribution.args];
610+ } else {
611+ command = yield* installSemaphore.withPermits(1)(
612+ installBinary(agent, distribution.binaryTarget!),
613+ );
614+ args = distribution.args;
615+ }
616+
617+ return {
618+ agent,
619+ distribution: distribution.kind,
620+ spawn: {
621+ command,
622+ args,
623+ cwd,
624+ env: {
625+ ...environment,
626+ ...distribution.env,
627+ },
628+ },
629+ } satisfies ResolvedAcpRegistryAgent;
630+ });
631+
632+ return { resolve };
633+});
apps/server/src/provider/acp/AcpRuntimeModel.ts+1−0
@@ -72,6 +72,7 @@ export type AcpParsedSessionEvent = · 71 unmodified lines
7272 };
7373
7474 type AcpSessionSetupResponse =
75+ | EffectAcpSchema.ForkSessionResponse
7576 | EffectAcpSchema.LoadSessionResponse
7677 | EffectAcpSchema.NewSessionResponse
7778 | EffectAcpSchema.ResumeSessionResponse;
apps/server/src/provider/acp/AcpSessionRuntime.ts+216−41
@@ -47,7 +47,7 @@ export interface AcpSessionRuntimeOptions { · 46 unmodified lines
4747 readonly name: string;
4848 readonly version: string;
4949 };
50− readonly authMethodId: string;
50+ readonly authMethodId?: string;
5151 readonly mcpServers?: ReadonlyArray<EffectAcpSchema.McpServer>;
5252 readonly requestLogger?: (event: AcpSessionRequestLogEvent) => Effect.Effect<void, never>;
5353 readonly protocolLogging?: {
@@ -65,10 +65,26 @@ export interface AcpSessionRequestLogEvent { · 11 unmodified lines
6565 readonly cause?: Cause.Cause<EffectAcpErrors.AcpError>;
6666 }
6767
68+export function selectAcpAgentAuthMethod(
69+ authMethods: ReadonlyArray<EffectAcpSchema.AuthMethod> | undefined,
70+ preferredMethodId?: string,
71+): EffectAcpSchema.AuthMethod | undefined {
72+ const preferred = preferredMethodId?.trim();
73+ if (preferred) {
74+ return authMethods?.find((method) => method.id === preferred);
75+ }
76+ return authMethods?.find((method) => !("type" in method));
77+}
78+
79+function isAcpAuthenticationRequired(error: EffectAcpErrors.AcpError): boolean {
80+ return error._tag === "AcpRequestError" && error.code === -32000;
81+}
82+
6883 export interface AcpSessionRuntimeStartResult {
6984 readonly sessionId: string;
7085 readonly initializeResult: EffectAcpSchema.InitializeResponse;
7186 readonly sessionSetupResult:
87+ | EffectAcpSchema.ForkSessionResponse
7288 | EffectAcpSchema.LoadSessionResponse
7389 | EffectAcpSchema.NewSessionResponse
7490 | EffectAcpSchema.ResumeSessionResponse;
@@ -95,6 +111,21 @@ export interface AcpSessionRuntimeShape { · 20 unmodified lines
95111 readonly getEvents: () => Stream.Stream<AcpParsedSessionEvent, never>;
96112 readonly getModeState: Effect.Effect<AcpSessionModeState | undefined>;
97113 readonly getConfigOptions: Effect.Effect<ReadonlyArray<EffectAcpSchema.SessionConfigOption>>;
114+ readonly loadSession: (
115+ sessionId: string,
116+ ) => Effect.Effect<AcpSessionRuntimeStartResult, EffectAcpErrors.AcpError>;
117+ readonly resumeSession: (
118+ sessionId: string,
119+ ) => Effect.Effect<AcpSessionRuntimeStartResult, EffectAcpErrors.AcpError>;
120+ readonly forkSession: (
121+ sessionId: string,
122+ ) => Effect.Effect<AcpSessionRuntimeStartResult, EffectAcpErrors.AcpError>;
123+ readonly listSessions: (
124+ cursor?: string,
125+ ) => Effect.Effect<EffectAcpSchema.ListSessionsResponse, EffectAcpErrors.AcpError>;
126+ readonly closeSession: (
127+ sessionId?: string,
128+ ) => Effect.Effect<EffectAcpSchema.CloseSessionResponse, EffectAcpErrors.AcpError>;
98129 readonly prompt: (
99130 payload: Omit<EffectAcpSchema.PromptRequest, "sessionId">,
100131 ) => Effect.Effect<EffectAcpSchema.PromptResponse, EffectAcpErrors.AcpError>;
@@ -327,6 +358,7 @@ const makeAcpSessionRuntime = ( · 226 unmodified lines
327358 const updateConfigOptions = (
328359 response:
329360 | EffectAcpSchema.SetSessionConfigOptionResponse
361+ | EffectAcpSchema.ForkSessionResponse
330362 | EffectAcpSchema.LoadSessionResponse
331363 | EffectAcpSchema.NewSessionResponse
332364 | EffectAcpSchema.ResumeSessionResponse,
@@ -337,6 +369,30 @@ const makeAcpSessionRuntime = ( · 4 unmodified lines
337369 current ? { ...current, currentModeId: modeId } : current,
338370 );
339371
372+ const adoptSession = (
373+ sessionId: string,
374+ sessionSetupResult:
375+ | EffectAcpSchema.ForkSessionResponse
376+ | EffectAcpSchema.LoadSessionResponse
377+ | EffectAcpSchema.NewSessionResponse
378+ | EffectAcpSchema.ResumeSessionResponse,
379+ ): Effect.Effect<AcpSessionRuntimeStartResult, EffectAcpErrors.AcpError> =>
380+ Effect.gen(function* () {
381+ const current = yield* getStartedState;
382+ const nextState = {
383+ sessionId,
384+ initializeResult: current.initializeResult,
385+ sessionSetupResult,
386+ modelConfigId: extractModelConfigId(sessionSetupResult),
387+ } satisfies AcpStartedState;
388+ yield* Ref.set(modeStateRef, parseSessionModeState(sessionSetupResult));
389+ yield* Ref.set(configOptionsRef, sessionConfigOptionsFromSetup(sessionSetupResult));
390+ yield* Ref.set(toolCallsRef, new Map());
391+ yield* Ref.set(assistantSegmentRef, { nextSegmentIndex: 0 });
392+ yield* Ref.set(startStateRef, { _tag: "Started", result: nextState });
393+ return nextState;
394+ });
395+
340396 const setConfigOption = (
341397 configId: string,
342398 value: string | boolean,
@@ -388,35 +444,80 @@ const makeAcpSessionRuntime = ( · 45 unmodified lines
388444 acp.agent.initialize(initializePayload),
389445 );
390446
391− const authenticatePayload = {
392− methodId: options.authMethodId,
393− } satisfies EffectAcpSchema.AuthenticateRequest;
447+ const authenticateAfterRequired = (
448+ authRequiredError: EffectAcpErrors.AcpError,
449+ ): Effect.Effect<void, EffectAcpErrors.AcpError> =>
450+ Effect.gen(function* () {
451+ const configuredAuthMethodId = options.authMethodId?.trim();
452+ const authMethod = selectAcpAgentAuthMethod(
453+ initializeResult.authMethods,
454+ configuredAuthMethodId,
455+ );
456+ if (
457+ configuredAuthMethodId &&
458+ initializeResult.authMethods !== undefined &&
459+ authMethod === undefined
460+ ) {
461+ return yield* new EffectAcpErrors.AcpTransportError({
462+ detail: `ACP agent did not advertise configured authentication method "${configuredAuthMethodId}"`,
463+ cause: { configuredAuthMethodId, authMethods: initializeResult.authMethods },
464+ });
465+ }
466+ if (authMethod !== undefined && "type" in authMethod) {
467+ return yield* new EffectAcpErrors.AcpTransportError({
468+ detail: `ACP authentication method "${authMethod.id}" requires ${authMethod.type} authentication, which cannot run inside a headless provider session`,
469+ cause: authMethod,
470+ });
471+ }
394472
395− yield* runLoggedRequest(
396− "authenticate",
397− authenticatePayload,
398− acp.agent.authenticate(authenticatePayload),
399− );
473+ const authMethodId = authMethod?.id ?? configuredAuthMethodId;
474+ if (!authMethodId) {
475+ return yield* authRequiredError;
476+ }
400477
401− let sessionId: string;
402− let sessionSetupResult:
403− | EffectAcpSchema.LoadSessionResponse
404− | EffectAcpSchema.NewSessionResponse
405− | EffectAcpSchema.ResumeSessionResponse;
406− if (options.resumeSessionId) {
407− const loadPayload = {
408− sessionId: options.resumeSessionId,
409− cwd: options.cwd,
410− mcpServers: options.mcpServers ?? [],
411− } satisfies EffectAcpSchema.LoadSessionRequest;
412− const resumed = yield* runLoggedRequest(
413− "session/load",
414− loadPayload,
415− acp.agent.loadSession(loadPayload),
416− ).pipe(Effect.exit);
417− if (Exit.isSuccess(resumed)) {
418− sessionId = options.resumeSessionId;
419− sessionSetupResult = resumed.value;
478+ const authenticatePayload = {
479+ methodId: authMethodId,
480+ } satisfies EffectAcpSchema.AuthenticateRequest;
481+ yield* runLoggedRequest(
482+ "authenticate",
483+ authenticatePayload,
484+ acp.agent.authenticate(authenticatePayload),
485+ );
486+ });
487+
488+ const setupSession = Effect.gen(function* () {
489+ let sessionId: string;
490+ let sessionSetupResult:
491+ | EffectAcpSchema.LoadSessionResponse
492+ | EffectAcpSchema.NewSessionResponse
493+ | EffectAcpSchema.ResumeSessionResponse;
494+ if (options.resumeSessionId) {
495+ const loadPayload = {
496+ sessionId: options.resumeSessionId,
497+ cwd: options.cwd,
498+ mcpServers: options.mcpServers ?? [],
499+ } satisfies EffectAcpSchema.LoadSessionRequest;
500+ const resumed = yield* runLoggedRequest(
501+ "session/load",
502+ loadPayload,
503+ acp.agent.loadSession(loadPayload),
504+ ).pipe(Effect.exit);
505+ if (Exit.isSuccess(resumed)) {
506+ sessionId = options.resumeSessionId;
507+ sessionSetupResult = resumed.value;
508+ } else {
509+ const createPayload = {
510+ cwd: options.cwd,
511+ mcpServers: options.mcpServers ?? [],
512+ } satisfies EffectAcpSchema.NewSessionRequest;
513+ const created = yield* runLoggedRequest(
514+ "session/new",
515+ createPayload,
516+ acp.agent.createSession(createPayload),
517+ );
518+ sessionId = created.sessionId;
519+ sessionSetupResult = created;
520+ }
420521 } else {
421522 const createPayload = {
422523 cwd: options.cwd,
@@ -430,19 +531,17 @@ const makeAcpSessionRuntime = ( · 7 unmodified lines
430531 sessionId = created.sessionId;
431532 sessionSetupResult = created;
432533 }
433− } else {
434− const createPayload = {
435− cwd: options.cwd,
436− mcpServers: options.mcpServers ?? [],
437− } satisfies EffectAcpSchema.NewSessionRequest;
438− const created = yield* runLoggedRequest(
439− "session/new",
440− createPayload,
441− acp.agent.createSession(createPayload),
442− );
443− sessionId = created.sessionId;
444− sessionSetupResult = created;
445− }
534+
535+ return { sessionId, sessionSetupResult };
536+ });
537+
538+ const { sessionId, sessionSetupResult } = yield* setupSession.pipe(
539+ Effect.catch((error) =>
540+ isAcpAuthenticationRequired(error)
541+ ? authenticateAfterRequired(error).pipe(Effect.andThen(setupSession))
542+ : Effect.fail(error),
543+ ),
544+ );
446545
447546 yield* Ref.set(modeStateRef, parseSessionModeState(sessionSetupResult));
448547 yield* Ref.set(configOptionsRef, sessionConfigOptionsFromSetup(sessionSetupResult));
@@ -508,6 +607,82 @@ const makeAcpSessionRuntime = ( · 59 unmodified lines
508607 getEvents: () => Stream.fromQueue(eventQueue),
509608 getModeState: Ref.get(modeStateRef),
510609 getConfigOptions: Ref.get(configOptionsRef),
610+ loadSession: (sessionId) =>
611+ start.pipe(
612+ Effect.flatMap(() => {
613+ const requestPayload = {
614+ sessionId,
615+ cwd: options.cwd,
616+ mcpServers: options.mcpServers ?? [],
617+ } satisfies EffectAcpSchema.LoadSessionRequest;
618+ return runLoggedRequest(
619+ "session/load",
620+ requestPayload,
621+ acp.agent.loadSession(requestPayload),
622+ );
623+ }),
624+ Effect.flatMap((response) => adoptSession(sessionId, response)),
625+ ),
626+ resumeSession: (sessionId) =>
627+ start.pipe(
628+ Effect.flatMap(() => {
629+ const requestPayload = {
630+ sessionId,
631+ cwd: options.cwd,
632+ mcpServers: options.mcpServers ?? [],
633+ } satisfies EffectAcpSchema.ResumeSessionRequest;
634+ return runLoggedRequest(
635+ "session/resume",
636+ requestPayload,
637+ acp.agent.resumeSession(requestPayload),
638+ );
639+ }),
640+ Effect.flatMap((response) => adoptSession(sessionId, response)),
641+ ),
642+ forkSession: (sessionId) =>
643+ start.pipe(
644+ Effect.flatMap(() => {
645+ const requestPayload = {
646+ sessionId,
647+ cwd: options.cwd,
648+ mcpServers: options.mcpServers ?? [],
649+ } satisfies EffectAcpSchema.ForkSessionRequest;
650+ return runLoggedRequest(
651+ "session/fork",
652+ requestPayload,
653+ acp.agent.forkSession(requestPayload),
654+ );
655+ }),
656+ Effect.flatMap((response) => adoptSession(response.sessionId, response)),
657+ ),
658+ listSessions: (cursor) => {
659+ const requestPayload = {
660+ cwd: options.cwd,
661+ ...(cursor === undefined ? {} : { cursor }),
662+ } satisfies EffectAcpSchema.ListSessionsRequest;
663+ return start.pipe(
664+ Effect.andThen(
665+ runLoggedRequest(
666+ "session/list",
667+ requestPayload,
668+ acp.agent.listSessions(requestPayload),
669+ ),
670+ ),
671+ );
672+ },
673+ closeSession: (sessionId) =>
674+ start.pipe(
675+ Effect.flatMap((started) => {
676+ const requestPayload = {
677+ sessionId: sessionId ?? started.sessionId,
678+ } satisfies EffectAcpSchema.CloseSessionRequest;
679+ return runLoggedRequest(
680+ "session/close",
681+ requestPayload,
682+ acp.agent.closeSession(requestPayload),
683+ );
684+ }),
685+ ),
511686 prompt: (payload) =>
512687 getStartedState.pipe(
513688 Effect.flatMap((started) => {
apps/server/src/provider/acp/GrokAcpCliProbe.test.ts+19−0
@@ -66,4 +66,23 @@ describe.runIf(process.env.T3_GROK_ACP_PROBE === "1")("Grok ACP CLI probe", () = · 65 unmodified lines
6666 yield* runtime.setSessionModel(currentModelId);
6767 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
6868 );
69+
70+ it.effect("switches to another advertised model and completes a prompt", () =>
71+ Effect.gen(function* () {
72+ const runtime = yield* makeProbeRuntime;
73+ const started = yield* runtime.start();
74+ const models = started.sessionSetupResult.models;
75+ const targetModel = models?.availableModels.find(
76+ ({ modelId }) => modelId !== models.currentModelId,
77+ )?.modelId;
78+ expect(targetModel).toBeDefined();
79+ if (!targetModel) return;
80+
81+ yield* runtime.setSessionModel(targetModel).pipe(Effect.timeout("20 seconds"));
82+ const result = yield* runtime
83+ .prompt({ prompt: [{ type: "text", text: "Respond with exactly: grok switch ok" }] })
84+ .pipe(Effect.timeout("60 seconds"));
85+ expect(result.stopReason).toBe("end_turn");
86+ }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
87+ );
6988 });
apps/server/src/provider/acp/GrokAcpSupport.ts+1−4
@@ -23,10 +23,7 @@ const GROK_DRIVER_KIND = ProviderDriverKind.make("grok"); · 22 unmodified lines
2323
2424 type GrokAcpRuntimeGrokSettings = Pick<GrokSettings, "binaryPath">;
2525
26−interface GrokAcpRuntimeInput extends Omit<
27− AcpSessionRuntimeOptions,
28− "authMethodId" | "clientCapabilities" | "spawn"
29−> {
26+interface GrokAcpRuntimeInput extends Omit<AcpSessionRuntimeOptions, "authMethodId" | "spawn"> {
3027 readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"];
3128 readonly grokSettings: GrokAcpRuntimeGrokSettings | null | undefined;
3229 readonly environment?: NodeJS.ProcessEnv;
apps/server/src/provider/acp/XAiAcpExtension.ts+11−0
@@ -64,6 +64,17 @@ export function extractXAiAskUserQuestions( · 63 unmodified lines
6464 }));
6565 }
6666
67+export function extractXAiAskUserQuestionIdentity(params: XAiAskUserQuestionRequest): {
68+ readonly sessionId: string;
69+ readonly toolCallId: string;
70+} {
71+ const unwrapped = unwrapAskUserQuestionParams(params);
72+ return {
73+ sessionId: unwrapped.sessionId,
74+ toolCallId: unwrapped.toolCallId,
75+ };
76+}
77+
6778 interface XAiAskUserQuestionAnnotation {
6879 readonly preview?: string;
6980 readonly notes?: string;
apps/web/src/components/settings/AddProviderInstanceDialog.tsx+1−6
@@ -13,7 +13,7 @@ import { useSettings, useUpdateSettings } from "../../hooks/useSettings"; · 12 unmodified lines
1313 import { cn } from "../../lib/utils";
1414 import { normalizeProviderAccentColor } from "../../providerInstances";
1515 import { Button } from "../ui/button";
16−import { ACPRegistryIcon, Gemini, GithubCopilotIcon, PiAgentIcon, type Icon } from "../Icons";
16+import { Gemini, GithubCopilotIcon, PiAgentIcon, type Icon } from "../Icons";
1717 import {
1818 Dialog,
1919 DialogDescription,
@@ -81,11 +81,6 @@ const COMING_SOON_DRIVER_OPTIONS: readonly ComingSoonDriverOption[] = [ · 61 unmodified lines
8181 label: "Gemini",
8282 icon: Gemini,
8383 },
84− {
85− value: ProviderDriverKind.make("acpRegistry"),
86− label: "ACP Registry",
87− icon: ACPRegistryIcon,
88− },
8984 {
9085 value: ProviderDriverKind.make("piAgent"),
9186 label: "Pi Agent",
apps/web/src/components/settings/ProviderSettingsForm.test.ts+12−0
@@ -36,6 +36,18 @@ describe("ProviderSettingsForm helpers", () => { · 35 unmodified lines
3636 });
3737 });
3838
39+ it("exposes ACP Registry as an instance-only configurable driver", () => {
40+ const acpRegistry = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("acpRegistry")];
41+
42+ expect(acpRegistry).toBeDefined();
43+ expect(acpRegistry?.hasDefaultInstance).toBe(false);
44+ expect(deriveProviderSettingsFields(acpRegistry!).map((field) => field.key)).toEqual([
45+ "agentId",
46+ "commandPath",
47+ "authMethodId",
48+ ]);
49+ });
50+
3951 it("preserves unknown config keys while omitting empty configurable fields", () => {
4052 const opencode = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("opencode")];
4153 expect(opencode).toBeDefined();
apps/web/src/components/settings/SettingsPanels.browser.tsx+29−0
@@ -1233,6 +1233,35 @@ describe("GeneralSettingsPanel observability", () => { · 1232 unmodified lines
12331233 await expect.element(page.getByPlaceholder("Optional")).toBeInTheDocument();
12341234 });
12351235
1236+ it("renders configured ACP Registry instances without a blank default card", async () => {
1237+ const instanceId = ProviderInstanceId.make("acpRegistry_devin");
1238+ setServerConfigSnapshot({
1239+ ...createBaseServerConfig(),
1240+ settings: {
1241+ ...DEFAULT_SERVER_SETTINGS,
1242+ providerInstances: {
1243+ [instanceId]: {
1244+ driver: ProviderDriverKind.make("acpRegistry"),
1245+ displayName: "Devin ACP",
1246+ config: { agentId: "devin" },
1247+ },
1248+ },
1249+ },
1250+ });
1251+
1252+ mounted = await render(
1253+ <AppAtomRegistryProvider>
1254+ <ProviderSettingsPanel />
1255+ </AppAtomRegistryProvider>,
1256+ );
1257+
1258+ await expect
1259+ .element(page.getByLabelText("Toggle ACP Registry details"))
1260+ .not.toBeInTheDocument();
1261+ await page.getByLabelText("Toggle Devin ACP details").click();
1262+ await expect.element(page.getByText("Registry agent ID")).toBeInTheDocument();
1263+ });
1264+
12361265 it("runs one-click provider updates from the provider card", async () => {
12371266 const updateProvider = vi.fn<LocalApi["server"]["updateProvider"]>().mockResolvedValue({
12381267 providers: [createOutdatedProvider("codex")],
apps/web/src/components/settings/SettingsPanels.tsx+10−3
@@ -121,6 +121,7 @@ function withoutProviderInstanceFavorites( · 120 unmodified lines
121121
122122 const PROVIDER_SETTINGS = DRIVER_OPTIONS.map((definition) => ({
123123 provider: definition.value,
124+ hasDefaultInstance: definition.hasDefaultInstance !== false,
124125 }));
125126
126127 function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | null }) {
@@ -1032,9 +1033,9 @@ export function ProviderSettingsPanel() { · 905 unmodified lines
10321033 }
10331034
10341035 const defaultSlotIdsBySource = new Set<string>(
1035− visibleProviderSettings.map((providerSettings) =>
1036− String(defaultInstanceIdForDriver(providerSettings.provider)),
1037− ),
1036+ visibleProviderSettings
1037+ .filter((providerSettings) => providerSettings.hasDefaultInstance)
1038+ .map((providerSettings) => String(defaultInstanceIdForDriver(providerSettings.provider))),
10381039 );
10391040
10401041 const rows: InstanceRow[] = [];
@@ -1043,6 +1044,12 @@ export function ProviderSettingsPanel() { · 2 unmodified lines
10431044 );
10441045
10451046 for (const providerSettings of visibleProviderSettings) {
1047+ if (!providerSettings.hasDefaultInstance) {
1048+ for (const [id, instance] of instancesByDriver.get(providerSettings.provider) ?? []) {
1049+ rows.push({ instanceId: id, instance, driver: instance.driver, isDefault: false });
1050+ }
1051+ continue;
1052+ }
10461053 type LegacyProviderSettings = (typeof settings.providers)[keyof typeof settings.providers];
10471054 const legacyProviders = settings.providers as Record<string, LegacyProviderSettings>;
10481055 const defaultLegacyProviders = DEFAULT_UNIFIED_SETTINGS.providers as Record<
apps/web/src/components/settings/providerDriverMeta.ts+20−1
@@ -1,4 +1,5 @@
11 import {
2+ AcpRegistrySettings,
23 ClaudeSettings,
34 CodexSettings,
45 CursorSettings,
@@ -7,7 +8,15 @@ import { · 2 unmodified lines
78 ProviderDriverKind,
89 } from "@t3tools/contracts";
910 import type * as Schema from "effect/Schema";
10−import { ClaudeAI, CursorIcon, GrokIcon, type Icon, OpenAI, OpenCodeIcon } from "../Icons";
11+import {
12+ ACPRegistryIcon,
13+ ClaudeAI,
14+ CursorIcon,
15+ GrokIcon,
16+ type Icon,
17+ OpenAI,
18+ OpenCodeIcon,
19+} from "../Icons";
1120
1221 type ProviderSettingsSchema = {
1322 readonly fields: Readonly<Record<string, Schema.Top>>;
@@ -24,6 +33,8 @@ export interface ProviderClientDefinition { · 10 unmodified lines
2433 readonly label: string;
2534 readonly icon: Icon;
2635 readonly settingsSchema: ProviderSettingsSchema;
36+ /** Whether this driver has a built-in default instance backed by legacy settings. */
37+ readonly hasDefaultInstance?: boolean;
2738 /**
2839 * Optional short label rendered as a `variant="warning"` badge next to
2940 * the instance title. Used to flag drivers that still ship under an
@@ -61,6 +72,14 @@ export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = · 31 unmodified lines
6172 badgeLabel: "Early Access",
6273 settingsSchema: GrokSettings,
6374 },
75+ {
76+ value: ProviderDriverKind.make("acpRegistry"),
77+ label: "ACP Registry",
78+ icon: ACPRegistryIcon,
79+ badgeLabel: "V2 Preview",
80+ settingsSchema: AcpRegistrySettings,
81+ hasDefaultInstance: false,
82+ },
6483 {
6584 value: ProviderDriverKind.make("opencode"),
6685 label: "OpenCode",
apps/web/src/orchestrationV2DebugProviders.test.ts+82−0
@@ -0,0 +1,82 @@
1+import {
2+ ProviderDriverKind,
3+ ProviderInstanceId,
4+ type ProviderInstanceConfigMap,
5+ type ServerProvider,
6+} from "@t3tools/contracts";
7+import { describe, expect, it } from "vite-plus/test";
8+
9+import { deriveOrchestrationV2DebugProviderSnapshots } from "./orchestrationV2DebugProviders";
10+
11+const acpRegistry = ProviderDriverKind.make("acpRegistry");
12+const instanceId = ProviderInstanceId.make("acpRegistry_example");
13+
14+const unavailable: ServerProvider = {
15+ instanceId,
16+ driver: acpRegistry,
17+ displayName: "Example ACP",
18+ enabled: false,
19+ installed: false,
20+ version: null,
21+ status: "error",
22+ auth: { status: "unknown" },
23+ checkedAt: "2026-01-01T00:00:00.000Z",
24+ availability: "unavailable",
25+ unavailableReason: "Driver is not registered in the V1 runtime.",
26+ models: [],
27+ slashCommands: [],
28+ skills: [],
29+};
30+
31+describe("deriveOrchestrationV2DebugProviderSnapshots", () => {
32+ it("replaces the V1 unavailable shadow for a configured registry agent", () => {
33+ const providerInstances = {
34+ [instanceId]: {
35+ driver: acpRegistry,
36+ enabled: true,
37+ displayName: "Example ACP",
38+ config: {
39+ agentId: "example-agent",
40+ customModels: ["model-one"],
41+ },
42+ },
43+ } satisfies ProviderInstanceConfigMap;
44+
45+ expect(
46+ deriveOrchestrationV2DebugProviderSnapshots({
47+ providers: [unavailable],
48+ providerInstances,
49+ }),
50+ ).toEqual([
51+ expect.objectContaining({
52+ instanceId,
53+ driver: acpRegistry,
54+ displayName: "Example ACP",
55+ enabled: true,
56+ installed: true,
57+ status: "ready",
58+ availability: "available",
59+ models: [
60+ expect.objectContaining({ slug: "default", isCustom: false }),
61+ expect.objectContaining({ slug: "model-one", isCustom: true }),
62+ ],
63+ }),
64+ ]);
65+ });
66+
67+ it("does not make an incomplete registry instance selectable", () => {
68+ const providerInstances = {
69+ [instanceId]: {
70+ driver: acpRegistry,
71+ config: { agentId: "" },
72+ },
73+ } satisfies ProviderInstanceConfigMap;
74+
75+ expect(
76+ deriveOrchestrationV2DebugProviderSnapshots({
77+ providers: [unavailable],
78+ providerInstances,
79+ }),
80+ ).toEqual([unavailable]);
81+ });
82+});
apps/web/src/orchestrationV2DebugProviders.ts+88−0
@@ -0,0 +1,88 @@
1+import {
2+ ProviderDriverKind,
3+ type ProviderInstanceConfigMap,
4+ type ProviderInstanceId,
5+ type ServerProvider,
6+ type ServerProviderModel,
7+} from "@t3tools/contracts";
8+
9+const ACP_REGISTRY_DRIVER = ProviderDriverKind.make("acpRegistry");
10+const DEBUG_CHECKED_AT = "1970-01-01T00:00:00.000Z";
11+
12+function configRecord(config: unknown): Readonly<Record<string, unknown>> {
13+ return config !== null && typeof config === "object" && !Array.isArray(config)
14+ ? (config as Readonly<Record<string, unknown>>)
15+ : {};
16+}
17+
18+function configuredModels(
19+ config: Readonly<Record<string, unknown>>,
20+): ReadonlyArray<ServerProviderModel> {
21+ const customModels = Array.isArray(config.customModels)
22+ ? config.customModels.filter(
23+ (model): model is string => typeof model === "string" && model.trim().length > 0,
24+ )
25+ : [];
26+ const slugs = [...new Set(["default", ...customModels.map((model) => model.trim())])];
27+ return slugs.map((slug) => ({
28+ slug,
29+ name: slug === "default" ? "Agent default" : slug,
30+ shortName: slug === "default" ? "Default" : slug,
31+ isCustom: slug !== "default",
32+ capabilities: null,
33+ }));
34+}
35+
36+/**
37+ * Add V2-only ACP Registry instances to the orchestration debugger's provider catalog.
38+ * The regular application provider catalog remains V1-owned and is intentionally untouched.
39+ */
40+export function deriveOrchestrationV2DebugProviderSnapshots(input: {
41+ readonly providers: ReadonlyArray<ServerProvider>;
42+ readonly providerInstances: ProviderInstanceConfigMap;
43+}): ReadonlyArray<ServerProvider> {
44+ const snapshots = [...input.providers];
45+ const indexByInstanceId = new Map<ProviderInstanceId, number>(
46+ snapshots.map((provider, index) => [provider.instanceId, index]),
47+ );
48+
49+ for (const [instanceId, instance] of Object.entries(input.providerInstances)) {
50+ if (instance.driver !== ACP_REGISTRY_DRIVER) continue;
51+ const config = configRecord(instance.config);
52+ const agentId = typeof config.agentId === "string" ? config.agentId.trim() : "";
53+ if (agentId.length === 0) continue;
54+ const id = instanceId as ProviderInstanceId;
55+ const existingIndex = indexByInstanceId.get(id);
56+ const existing = existingIndex === undefined ? undefined : snapshots[existingIndex];
57+ const enabled =
58+ instance.enabled ?? (typeof config.enabled === "boolean" ? config.enabled : true);
59+ const snapshot: ServerProvider = {
60+ instanceId: id,
61+ driver: ACP_REGISTRY_DRIVER,
62+ displayName: instance.displayName?.trim() || `ACP: ${agentId}`,
63+ ...(instance.accentColor ? { accentColor: instance.accentColor } : {}),
64+ badgeLabel: "V2 Preview",
65+ showInteractionModeToggle: true,
66+ requiresNewThreadForModelChange: false,
67+ enabled,
68+ installed: true,
69+ version: null,
70+ status: enabled ? "ready" : "disabled",
71+ auth: { status: "unknown" },
72+ checkedAt: existing?.checkedAt ?? DEBUG_CHECKED_AT,
73+ message: `ACP Registry agent '${agentId}' is resolved when the V2 session starts.`,
74+ availability: "available",
75+ models: configuredModels(config),
76+ slashCommands: [],
77+ skills: [],
78+ };
79+ if (existingIndex === undefined) {
80+ indexByInstanceId.set(id, snapshots.length);
81+ snapshots.push(snapshot);
82+ } else {
83+ snapshots[existingIndex] = snapshot;
84+ }
85+ }
86+
87+ return snapshots;
88+}
apps/web/src/routes/debug.orchestration-v2.tsx+9−1
@@ -36,6 +36,7 @@ import { · 35 unmodified lines
3636 } from "../lib/orchestrationV2Timeline";
3737 import { newCommandId, newMessageId, newProjectId, newThreadId } from "../lib/utils";
3838 import { type AppModelOption, getAppModelOptionsForInstance } from "../modelSelection";
39+import { deriveOrchestrationV2DebugProviderSnapshots } from "../orchestrationV2DebugProviders";
3940 import {
4041 type ProviderInstanceEntry,
4142 deriveProviderInstanceEntries,
@@ -1621,7 +1622,14 @@ function OrchestrationV2DebugRoute() { · 1579 unmodified lines
16211622 const serverProviders = useServerProviders();
16221623 const settings = useSettings();
16231624 const keybindings = useServerKeybindings();
1624− const providerSnapshots = serverProviders.length > 0 ? serverProviders : DEBUG_PROVIDER_SNAPSHOTS;
1625+ const providerSnapshots = useMemo(
1626+ () =>
1627+ deriveOrchestrationV2DebugProviderSnapshots({
1628+ providers: serverProviders.length > 0 ? serverProviders : DEBUG_PROVIDER_SNAPSHOTS,
1629+ providerInstances: settings.providerInstances,
1630+ }),
1631+ [serverProviders, settings.providerInstances],
1632+ );
16251633 const providerInstanceEntries = useMemo<ReadonlyArray<ProviderInstanceEntry>>(
16261634 () => sortProviderInstanceEntries(deriveProviderInstanceEntries(providerSnapshots)),
16271635 [providerSnapshots],
docs/orchestration-v2/orchestrator-mcp-server.md+35−4
@@ -90,9 +90,40 @@ Cursor receives the same authenticated HTTP MCP endpoint through the SDK's · 89 unmodified lines
9090 `mcpServers` agent and send options. The adapter passes the authorization header
9191 to the SDK but projects only redacted option metadata into protocol diagnostics.
9292
93+### Grok ACP V2
94+
95+Grok receives the authenticated HTTP MCP endpoint through the ACP
96+`session/new`, `session/load`, and `session/fork` `mcpServers` field. The shared
97+ACP adapter owns standard protocol behavior; the Grok flavor adds xAI extension
98+requests such as structured user questions.
99+
100+ACP does not define native subagents or active steering. Grok therefore uses
101+orchestrator-owned child threads and implements steering through
102+cancel-and-restart. Its current driver also lacks `session/fork`, so app forks
103+use portable context transfer. These are orchestrator policies, not
104+provider-specific MCP tools.
105+
106+### ACP Registry V2
107+
108+The `acpRegistry` driver is the generic flavor of the same shared ACP adapter.
109+Each provider instance names an agent from the official ACP Registry. At
110+session startup the driver resolves the current platform distribution, uses a
111+managed binary cache or the declared `npx`/`uvx` package, and then negotiates
112+standard ACP capabilities during `initialize`. A local executable may override
113+the managed command without changing the registry-declared arguments or
114+environment.
115+
116+Capabilities such as session loading, session forking, models, modes, and MCP
117+transport are enabled only when the selected agent advertises them. Missing
118+features degrade through V2 policy: steering uses interrupt-and-restart,
119+forking uses portable context when native `session/fork` is unavailable, and
120+subagents use orchestrator-owned child threads. Registry agents do not receive
121+provider-specific extensions; those remain in flavors such as Grok.
122+
93123 ### Initial Provider Support
94124
95−The V2 provider adapters are Codex, Claude Agent SDK, and Cursor Agent SDK.
125+The V2 provider adapters are Codex, Claude Agent SDK, Cursor Agent SDK, and
126+Grok plus generic registry agents over ACP.
96127 Capability discovery still reports other registered provider instances, but marks them
97128 unavailable for orchestration when no V2 adapter exists. This keeps provider
98129 selection model-visible without allowing a request that cannot run.
@@ -299,6 +330,6 @@ Coverage includes: · 200 unmodified lines
299330 - inheritance and per-thread provider overrides; and
300331 - idempotent retries.
301332
302−Provider adapter tests separately verify Codex, Claude, and Cursor MCP injection, and
303−the provider-session manager test verifies that credentials exist before an
304−adapter opens and are revoked when it closes.
333+Provider adapter tests separately verify Codex, Claude, Cursor, Grok, and ACP
334+Registry behavior and MCP injection. The provider-session manager test verifies
335+that credentials exist before an adapter opens and are revoked when it closes.
docs/orchestration-v2/testing-strategy.md+20−1
@@ -122,6 +122,14 @@ Claude replay transcript · 121 unmodified lines
122122 Cursor replay transcript
123123 -> Cursor Agent SDK open/send calls and ordered onDelta/run results
124124 -> consumed by CursorAdapter
125+
126+OpenCode replay transcript
127+ -> OpenCode SDK requests/responses plus ordered SSE events
128+ -> consumed by OpenCodeAdapter
129+
130+ACP replay transcript
131+ -> logical JSON-RPC requests/responses/notifications over a strict NDJSON peer
132+ -> consumed by AcpAdapter and a provider flavor (Grok or ACP Registry)
125133 ```
126134
127135 Fixtures should preserve raw provider evidence as closely as possible. Expected V2 events or projections are assertions, not fixture input.
@@ -131,6 +139,17 @@ call. A transcript should include the `query` prompt/options we sent and then re · 3 unmodified lines
131139 `SDKMessage` chunks in provider order. This intentionally tests the V2 adapter against real Claude
132140 SDK output; it does not test the SDK's own subprocess or transport parser.
133141
142+ACP fixtures run against a child-process replay peer that validates every outbound frame and
143+serves recorded inbound frames. This replaces only the external ACP driver transport; the shared
144+runtime, adapter normalization, orchestration, persistence, and projections remain production code.
145+The generic ACP Registry harness retargets protocol-standard ACP transcripts;
146+provider-extension transcripts remain scoped to the flavor that owns the extension.
147+
148+OpenCode fixtures replace the SDK client at its HTTP/SSE boundary. They must preserve races between
149+request responses and SSE events, because user messages and terminal `session.status` events can
150+arrive before the corresponding `promptAsync` or `abort` response. Subagent fixtures must retain
151+both parent and child session ids so root-only terminal behavior remains testable.
152+
134153 Provider transcript recorders live with the server orchestration testkit, not with provider client
135154 packages. Use `bun run record:codex-replay -- --scenario <name>` for Codex app-server transcripts
136155 and `bun run record:claude-replay -- --scenario <name>` for Claude Agent SDK transcripts. Use
@@ -206,7 +225,7 @@ Testing infrastructure should be built before production rewrites: · 69 unmodified lines
206225 2. Effect service definitions.
207226 3. provider runtime transport abstraction.
208227 4. generic replay runtime.
209−5. Codex, Claude, and Cursor transcript loaders for app-owned provider replay fixtures.
228+5. Codex, Claude, Cursor, OpenCode, and ACP transcript loaders for app-owned provider replay fixtures.
210229 6. projection reducer tests for core invariants.
211230 7. full command-to-projection integration tests.
212231 8. production layers.
packages/contracts/src/model.ts+3−0
@@ -131,6 +131,7 @@ const CODEX_DRIVER_KIND = ProviderDriverKind.make("codex"); · 130 unmodified lines
131131 const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent");
132132 const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor");
133133 const GROK_DRIVER_KIND = ProviderDriverKind.make("grok");
134+const ACP_REGISTRY_DRIVER_KIND = ProviderDriverKind.make("acpRegistry");
134135 const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode");
135136
136137 export const DEFAULT_MODEL = "gpt-5.4";
@@ -141,6 +142,7 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial<Record<ProviderDriverKind, strin · 4 unmodified lines
141142 [CLAUDE_DRIVER_KIND]: "claude-sonnet-4-6",
142143 [CURSOR_DRIVER_KIND]: "auto",
143144 [GROK_DRIVER_KIND]: "grok-build",
145+ [ACP_REGISTRY_DRIVER_KIND]: "default",
144146 [OPENCODE_DRIVER_KIND]: "openai/gpt-5",
145147 };
146148
@@ -204,5 +206,6 @@ export const PROVIDER_DISPLAY_NAMES: Partial<Record<ProviderDriverKind, string>> · 57 unmodified lines
204206 [CLAUDE_DRIVER_KIND]: "Claude",
205207 [CURSOR_DRIVER_KIND]: "Cursor",
206208 [GROK_DRIVER_KIND]: "Grok",
209+ [ACP_REGISTRY_DRIVER_KIND]: "ACP Registry",
207210 [OPENCODE_DRIVER_KIND]: "OpenCode",
208211 };
packages/contracts/src/settings.ts+50−0
@@ -305,6 +305,56 @@ export const GrokSettings = makeProviderSettingsSchema( · 304 unmodified lines
305305 );
306306 export type GrokSettings = typeof GrokSettings.Type;
307307
308+export const AcpRegistryDistributionPreference = Schema.Literals(["auto", "binary", "npx", "uvx"]);
309+export type AcpRegistryDistributionPreference = typeof AcpRegistryDistributionPreference.Type;
310+
311+export const AcpRegistrySettings = makeProviderSettingsSchema(
312+ {
313+ enabled: Schema.Boolean.pipe(
314+ Schema.withDecodingDefault(Effect.succeed(true)),
315+ Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
316+ ),
317+ agentId: TrimmedString.pipe(
318+ Schema.withDecodingDefault(Effect.succeed("")),
319+ Schema.annotateKey({
320+ title: "Registry agent ID",
321+ description: "Agent identifier from the official ACP Registry, for example 'devin'.",
322+ providerSettingsForm: { placeholder: "devin", clearWhenEmpty: "persist" },
323+ }),
324+ ),
325+ commandPath: TrimmedString.pipe(
326+ Schema.withDecodingDefault(Effect.succeed("")),
327+ Schema.annotateKey({
328+ title: "Executable override",
329+ description:
330+ "Optional local executable to use instead of installing the registry distribution. Registry arguments and environment are still applied.",
331+ providerSettingsForm: { placeholder: "devin", clearWhenEmpty: "omit" },
332+ }),
333+ ),
334+ authMethodId: TrimmedString.pipe(
335+ Schema.withDecodingDefault(Effect.succeed("")),
336+ Schema.annotateKey({
337+ title: "Authentication method",
338+ description:
339+ "Optional ACP authentication method ID. By default, the first agent-managed method is selected.",
340+ providerSettingsForm: { placeholder: "auto", clearWhenEmpty: "omit" },
341+ }),
342+ ),
343+ distribution: AcpRegistryDistributionPreference.pipe(
344+ Schema.withDecodingDefault(Effect.succeed("auto")),
345+ Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
346+ ),
347+ customModels: Schema.Array(Schema.String).pipe(
348+ Schema.withDecodingDefault(Effect.succeed([])),
349+ Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
350+ ),
351+ },
352+ {
353+ order: ["agentId", "commandPath", "authMethodId"],
354+ },
355+);
356+export type AcpRegistrySettings = typeof AcpRegistrySettings.Type;
357+
308358 export const OpenCodeSettings = makeProviderSettingsSchema(
309359 {
310360 enabled: Schema.Boolean.pipe(