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
23const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1";23const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1";
24const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT;24const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT;
25const promptDelayMs = Number(process.env.T3_ACP_PROMPT_DELAY_MS ?? "0");25const promptDelayMs = Number(process.env.T3_ACP_PROMPT_DELAY_MS ?? "0");
26const supportsSessionLifecycle = process.env.T3_ACP_SESSION_LIFECYCLE === "1";
27const advertisedAuthMethodId = process.env.T3_ACP_AUTH_METHOD_ID?.trim();
28const requiresAuthentication = process.env.T3_ACP_REQUIRE_AUTH === "1";
26const permissionOptionIds = {29const permissionOptionIds = {
27 allowOnce: process.env.T3_ACP_ALLOW_ONCE_OPTION_ID ?? "allow-once",30 allowOnce: process.env.T3_ACP_ALLOW_ONCE_OPTION_ID ?? "allow-once",
28 allowAlways: process.env.T3_ACP_ALLOW_ALWAYS_OPTION_ID ?? "allow-always",31 allowAlways: process.env.T3_ACP_ALLOW_ALWAYS_OPTION_ID ?? "allow-always",
@@ -36,6 +39,7 @@ let parameterizedModelPicker = false; · 7 unmodified lines
36let currentReasoning = "medium";39let currentReasoning = "medium";
37let currentContext = "272k";40let currentContext = "272k";
38let currentFast = false;41let currentFast = false;
42let authenticated = !requiresAuthentication;
39const cancelledSessions = new Set<string>();43const cancelledSessions = new Set<string>();
40 44
41function logExit(reason: string): void {45function logExit(reason: string): void {
@@ -268,19 +272,56 @@ const program = Effect.gen(function* () { · 226 unmodified lines
268 request.clientCapabilities?._meta?.parameterizedModelPicker === true;272 request.clientCapabilities?._meta?.parameterizedModelPicker === true;
269 return {273 return {
270 protocolVersion: 1,274 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 : {}),
272 };298 };
273 }),299 }),
274 );300 );
275 301
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 );
277 313
278 yield* agent.handleCreateSession(() =>314 yield* agent.handleCreateSession(() =>
279 Effect.succeed({315 Effect.gen(function* () {
280 sessionId,316 if (!authenticated) {
281 modes: modeState(),317 return yield* AcpError.AcpRequestError.authRequired();
282 models: modelState(),318 }
283 configOptions: configOptions(),319 return {
320 sessionId,
321 modes: modeState(),
322 models: modelState(),
323 configOptions: configOptions(),
324 };
284 }),325 }),
285 );326 );
286 327
@@ -302,6 +343,38 @@ const program = Effect.gen(function* () { · 15 unmodified lines
302 ),343 ),
303 );344 );
304 345
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
305 yield* agent.handleSetSessionModel((request) =>378 yield* agent.handleSetSessionModel((request) =>
306 Effect.gen(function* () {379 Effect.gen(function* () {
307 if (!grokAcpModels.some((model) => model.modelId === request.modelId)) {380 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
3import { writeFileSync } from "node:fs";
4import { createInterface } from "node:readline";
5
6interface 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
14interface ReplayTranscript {
15 readonly scenario: string;
16 readonly entries: ReadonlyArray<ReplayEntry>;
17}
18
19interface 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
27interface 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
37const encodedTranscript = process.env.T3_ACP_REPLAY_TRANSCRIPT;
38const statusPath = process.env.T3_ACP_REPLAY_STATUS_PATH;
39const replayWorkspace = process.env.T3_ACP_REPLAY_WORKSPACE ?? process.cwd();
40
41if (encodedTranscript === undefined || statusPath === undefined) {
42 process.stderr.write("ACP replay requires transcript and status environment variables.\n");
43 process.exit(2);
44}
45
46const replayStatusPath = statusPath;
47const transcript = JSON.parse(
48 Buffer.from(encodedTranscript, "base64").toString("utf8"),
49) as ReplayTranscript;
50let cursor = 0;
51let stopped = false;
52let nextAgentRequestId = 1;
53const pendingClientRequestIds = new Map<string, string | number>();
54const pendingAgentRequestMethods = new Map<string, string>();
55
56function 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
69function 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
83function expandExpectedString(value: string): string {
84 return value.replaceAll("<workspace>", replayWorkspace);
85}
86
87function 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
122function 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
138function advance(): void {
139 cursor += 1;
140 writeStatus();
141}
142
143function send(message: JsonRpcMessage): void {
144 process.stdout.write(`${JSON.stringify(message)}\n`);
145}
146
147function pendingClientRequestId(method: string): string | number | undefined {
148 return pendingClientRequestIds.get(method);
149}
150
151function 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
173function 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
212function 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
240function 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
268writeStatus();
269flushInbound();
270
271const input = createInterface({ input: process.stdin, crlfDelay: Infinity });
272input.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
281input.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 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
3import {
4 CommandId,
5 type ModelSelection,
6 MessageId,
7 ProjectId,
8 ProviderDriverKind,
9 ProviderInstanceId,
10 ThreadId,
11} from "@t3tools/contracts";
12import * as Console from "effect/Console";
13import * as Effect from "effect/Effect";
14import * as Layer from "effect/Layer";
15import { describe } from "vite-plus/test";
16
17import { CheckpointStoreLive } from "../checkpointing/Layers/CheckpointStore.ts";
18import { ServerConfig } from "../config.ts";
19import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";
20import { ServerSettingsService } from "../serverSettings.ts";
21import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
22import * as VcsProcess from "../vcs/VcsProcess.ts";
23import { OrchestratorV2 } from "./Orchestrator.ts";
24import { OrchestrationV2LayerLive } from "./runtimeLayer.ts";
25
26const liveAgentId = process.env.T3_ACP_REGISTRY_LIVE_AGENT_ID?.trim() || "devin";
27const liveCommandPath = process.env.T3_ACP_REGISTRY_LIVE_COMMAND?.trim();
28const liveInstanceId = ProviderInstanceId.make("acpRegistry_live");
29const liveModelSelection = {
30 instanceId: liveInstanceId,
31 model: "default",
32} satisfies ModelSelection;
33
34const serverConfigLayer = ServerConfig.layerTest(process.cwd(), {
35 prefix: "t3-acp-registry-v2-live-",
36});
37
38const vcsDriverRegistryLayer = VcsDriverRegistry.layer.pipe(
39 Layer.provide(VcsProcess.layer),
40 Layer.provide(serverConfigLayer),
41 Layer.provide(NodeServices.layer),
42);
43
44const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(vcsDriverRegistryLayer));
45
46const 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
68const 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
88describe.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 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, describe, it } from "@effect/vitest";
3import {
4 MessageId,
5 NodeId,
6 ProjectId,
7 ProviderInstanceId,
8 ProviderSessionId,
9 RunAttemptId,
10 RunId,
11 ThreadId,
12 type OrchestrationV2ProviderThread,
13} from "@t3tools/contracts";
14import * as DateTime from "effect/DateTime";
15import * as Effect from "effect/Effect";
16import * as FileSystem from "effect/FileSystem";
17import * as Fiber from "effect/Fiber";
18import * as Layer from "effect/Layer";
19import * as Option from "effect/Option";
20import * as Path from "effect/Path";
21import * as Stream from "effect/Stream";
22import * as TestClock from "effect/testing/TestClock";
23import { ChildProcessSpawner } from "effect/unstable/process";
24
25import { ServerConfig } from "../../config.ts";
26import { AcpSessionRuntime } from "../../provider/acp/AcpSessionRuntime.ts";
27import { layer as idAllocatorLayer, IdAllocatorV2 } from "../IdAllocator.ts";
28import {
29 ProviderAdapterV2RuntimePolicy,
30 type ProviderAdapterV2TurnInput,
31} from "../ProviderAdapter.ts";
32import {
33 AcpProviderCapabilitiesV2,
34 makeAcpAdapterV2,
35 type AcpAdapterV2Flavor,
36} from "./AcpAdapterV2.ts";
37
38const serverConfigLayer = ServerConfig.layerTest(process.cwd(), {
39 prefix: "t3-acp-v2-adapter-",
40}).pipe(Layer.provide(NodeServices.layer));
41
42const testLayer = Layer.mergeAll(NodeServices.layer, idAllocatorLayer, serverConfigLayer);
43
44function 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
71function 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
122describe("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 @@
1import {
2 ProviderKind,
3 ProviderReplayEntry,
4 type ProviderReplayTranscript,
5} from "@t3tools/contracts";
6import * as Effect from "effect/Effect";
7import * as FileSystem from "effect/FileSystem";
8import * as Layer from "effect/Layer";
9import * as Schema from "effect/Schema";
10import type * as Scope from "effect/Scope";
11import { ChildProcessSpawner } from "effect/unstable/process";
12import * as EffectAcpErrors from "effect-acp/errors";
13
14import {
15 AcpSessionRuntime,
16 type AcpSessionRuntimeShape,
17} from "../../provider/acp/AcpSessionRuntime.ts";
18import { ACP_PROTOCOL, type AcpAdapterV2RuntimeInput } from "./AcpAdapterV2.ts";
19
20export 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});
28export type AcpReplayTranscript = typeof AcpReplayTranscript.Type;
29
30const decodeAcpReplayTranscriptSchema = Schema.decodeUnknownEffect(AcpReplayTranscript);
31
32export 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
47const isAcpReplayTranscriptDecodeError = Schema.is(AcpReplayTranscriptDecodeError);
48
49function 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
61export 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
91interface ReplayStatus {
92 readonly cursor: number;
93 readonly total: number;
94 readonly failure?: unknown;
95}
96
97function 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
110export 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
151export 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 @@
1import {
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";
24import * as DateTime from "effect/DateTime";
25import * as Deferred from "effect/Deferred";
26import * as Effect from "effect/Effect";
27import * as FileSystem from "effect/FileSystem";
28import * as Option from "effect/Option";
29import * as Queue from "effect/Queue";
30import * as Ref from "effect/Ref";
31import type * as Scope from "effect/Scope";
32import * as Stream from "effect/Stream";
33import * as EffectAcpErrors from "effect-acp/errors";
34import type * as EffectAcpProtocol from "effect-acp/protocol";
35import type * as EffectAcpSchema from "effect-acp/schema";
36
37import { resolveAttachmentPath } from "../../attachmentStore.ts";
38import { type ServerConfigShape } from "../../config.ts";
39import * as McpProviderSession from "../../mcp/McpProviderSession.ts";
40import {
41 mergeToolCallState,
42 parsePermissionRequest,
43 parseSessionUpdateEvent,
44 type AcpPlanUpdate,
45 type AcpToolCallState,
46} from "../../provider/acp/AcpRuntimeModel.ts";
47import type {
48 AcpSessionRuntimeOptions,
49 AcpSessionRuntimeShape,
50 AcpSessionRuntimeStartResult,
51} from "../../provider/acp/AcpSessionRuntime.ts";
52import { IdAllocatorV2, type IdAllocatorV2Shape } from "../IdAllocator.ts";
53import {
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
76export const ACP_PROTOCOL = "acp.ndjson-jsonrpc" as const;
77
78export 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
86export interface AcpAdapterV2UserInputRequest {
87 readonly nativeItemId: string;
88 readonly nativeRequestId: string;
89 readonly questions: ReadonlyArray<OrchestrationV2UserInputQuestion>;
90}
91
92export interface AcpAdapterV2ExtensionContext {
93 readonly runtime: AcpSessionRuntimeShape;
94 readonly requestUserInput: (
95 input: AcpAdapterV2UserInputRequest,
96 ) => Effect.Effect<ProviderUserInputAnswers | null, EffectAcpErrors.AcpError>;
97}
98
99export 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
112export 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
120export 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
211function 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
248function 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
268function 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
279function 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
314function 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
320function nonEmptyText(value: unknown, fallback: string): string {
321 return typeof value === "string" && value.trim().length > 0 ? value.trim() : fallback;
322}
323
324function 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
348function 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
359function 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
379function 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
396function 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
411function nodeStatus(status: ReturnType<typeof toolStatus>): OrchestrationV2ExecutionNode["status"] {
412 return status === "pending" ? "running" : status;
413}
414
415function completedAtForStatus(
416 status: ReturnType<typeof toolStatus>,
417 now: DateTime.Utc,
418): DateTime.Utc | null {
419 return status === "completed" || status === "failed" ? now : null;
420}
421
422function 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
435function selectAutoApprovedPermissionOption(
436 request: EffectAcpSchema.RequestPermissionRequest,
437): string | undefined {
438 return (
439 selectPermissionOptionId(request, "acceptForSession") ??
440 selectPermissionOptionId(request, "accept")
441 );
442}
443
444export type AcpPermissionDisposition = "allow" | "ask" | "deny";
445
446export 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
486function jsonRpcId(value: unknown): string | number | null {
487 return (typeof value === "string" && value.length > 0) || typeof value === "number"
488 ? value
489 : null;
490}
491
492function 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
506function 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
532interface ActiveTextSegment {
533 readonly nativeItemId: string;
534 readonly startedAt: DateTime.Utc;
535 text: string;
536}
537
538interface ActiveTextStream {
539 current: ActiveTextSegment | null;
540 nextSegment: number;
541}
542
543interface 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
561type 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
577interface SnapshotMessageState {
578 readonly order: Array<string>;
579 readonly messages: Map<string, OrchestrationV2ConversationMessage>;
580 loadingRole: "user" | "assistant" | null;
581 loadingIndex: number;
582}
583
584interface RawProtocolState {
585 readonly pendingMethods: Map<string, string>;
586 sequence: number;
587}
588
589function requestKey(direction: "incoming" | "outgoing", id: string | number): string {
590 return `${direction}:${String(id)}`;
591}
592
593function oppositeDirection(direction: "incoming" | "outgoing"): "incoming" | "outgoing" {
594 return direction === "incoming" ? "outgoing" : "incoming";
595}
596
597export 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
2203export type AcpAdapterV2Env = FileSystem.FileSystem | IdAllocatorV2;
apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts+157−0
@@ -0,0 +1,157 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, describe, it } from "@effect/vitest";
3import { ProviderInstanceId, ProviderSessionId, ThreadId } from "@t3tools/contracts";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Layer from "effect/Layer";
7import * as Path from "effect/Path";
8import * as Schema from "effect/Schema";
9import { HttpClient, HttpClientResponse } from "effect/unstable/http";
10import { ChildProcessSpawner } from "effect/unstable/process";
11
12import { ServerConfig } from "../../config.ts";
13import { makeAcpRegistryResolver } from "../../provider/acp/AcpRegistrySupport.ts";
14import { layer as idAllocatorLayer, IdAllocatorV2 } from "../IdAllocator.ts";
15import { ProviderAdapterV2RuntimePolicy } from "../ProviderAdapter.ts";
16import { BUILT_IN_PROVIDER_ADAPTER_DRIVER_KINDS_V2 } from "../builtInProviderAdapterDrivers.ts";
17import {
18 ACP_REGISTRY_DRIVER_KIND,
19 AcpRegistryAdapterV2Driver,
20 makeAcpRegistryAdapterV2,
21} from "./AcpRegistryAdapterV2.ts";
22
23const registryUrl = "https://registry.test/registry.json";
24const decodeAcpRegistryAdapterSettings = Schema.decodeUnknownEffect(
25 AcpRegistryAdapterV2Driver.configSchema,
26);
27
28const serverConfigLayer = ServerConfig.layerTest(process.cwd(), {
29 prefix: "t3-acp-registry-v2-adapter-",
30}).pipe(Layer.provide(NodeServices.layer));
31
32const 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
68const testLayer = Layer.mergeAll(
69 NodeServices.layer,
70 idAllocatorLayer,
71 serverConfigLayer,
72 registryLayer,
73);
74
75describe("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 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { AcpRegistrySettings } from "@t3tools/contracts";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Layer from "effect/Layer";
6import * as Path from "effect/Path";
7import * as Schema from "effect/Schema";
8import { ChildProcessSpawner } from "effect/unstable/process";
9
10import { ServerConfig } from "../../config.ts";
11import { layer as idAllocatorLayer, IdAllocatorV2 } from "../IdAllocator.ts";
12import { makeLayerEffect as makeProviderAdapterRegistryLayerEffect } from "../ProviderAdapterRegistry.ts";
13import type { OrchestratorV2ProviderReplayHarness } from "../testkit/ProviderReplayHarness.ts";
14import { makeReplayServerConfig } from "../testkit/ProviderReplayHarness.ts";
15import {
16 type AcpReplayTranscript,
17 AcpReplayTranscriptDecodeError,
18 decodeAcpReplayTranscript,
19 makeAcpReplayCompletenessAssertion,
20 makeAcpReplayRuntime,
21} from "./AcpAdapterV2.testkit.ts";
22import {
23 ACP_REGISTRY_DEFAULT_INSTANCE_ID,
24 ACP_REGISTRY_PROVIDER,
25 makeAcpRegistryAdapterV2,
26} from "./AcpRegistryAdapterV2.ts";
27
28const REPLAY_SETTINGS = Schema.decodeUnknownSync(AcpRegistrySettings)({
29 agentId: "replay-agent",
30 authMethodId: "replay",
31});
32
33export 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
79export 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 @@
1import {
2 AcpRegistrySettings,
3 defaultInstanceIdForDriver,
4 ProviderDriverKind,
5} from "@t3tools/contracts";
6import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
7import * as Effect from "effect/Effect";
8import * as FileSystem from "effect/FileSystem";
9import * as Layer from "effect/Layer";
10import * as Path from "effect/Path";
11import * as Schema from "effect/Schema";
12import type * as Scope from "effect/Scope";
13import { HttpClient } from "effect/unstable/http";
14import { ChildProcessSpawner } from "effect/unstable/process";
15import * as EffectAcpErrors from "effect-acp/errors";
16
17import { ServerConfig } from "../../config.ts";
18import {
19 makeAcpRegistryResolver,
20 type AcpRegistryResolverShape,
21} from "../../provider/acp/AcpRegistrySupport.ts";
22import {
23 AcpSessionRuntime,
24 type AcpSessionRuntimeShape,
25} from "../../provider/acp/AcpSessionRuntime.ts";
26import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts";
27import { IdAllocatorV2 } from "../IdAllocator.ts";
28import {
29 ProviderAdapterDriverCreateError,
30 type ProviderAdapterDriver,
31 type ProviderAdapterDriverCreateInput,
32} from "../ProviderAdapterDriver.ts";
33import {
34 AcpProviderCapabilitiesV2,
35 makeAcpAdapterV2,
36 type AcpAdapterV2Flavor,
37 type AcpAdapterV2RuntimeInput,
38} from "./AcpAdapterV2.ts";
39
40export const ACP_REGISTRY_PROVIDER = "acpRegistry" as const;
41export const ACP_REGISTRY_DRIVER_KIND = ProviderDriverKind.make(ACP_REGISTRY_PROVIDER);
42export const ACP_REGISTRY_DEFAULT_INSTANCE_ID =
43 defaultInstanceIdForDriver(ACP_REGISTRY_DRIVER_KIND);
44
45const DEFAULT_ACP_REGISTRY_SETTINGS = Schema.decodeSync(AcpRegistrySettings)({});
46
47export 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
62function 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
93export 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
109export type AcpRegistryAdapterV2DriverEnv =
110 | ChildProcessSpawner.ChildProcessSpawner
111 | FileSystem.FileSystem
112 | HttpClient.HttpClient
113 | IdAllocatorV2
114 | Path.Path
115 | ServerConfig;
116
117export 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 @@
1import { assert, describe, it } from "@effect/vitest";
2import type * as EffectAcpSchema from "effect-acp/schema";
3
4import { ProviderAdapterV2RuntimePolicy } from "../ProviderAdapter.ts";
5import { AcpProviderCapabilitiesV2, acpPermissionDisposition } from "./AcpAdapterV2.ts";
6import { GrokProviderCapabilitiesV2 } from "./GrokAdapterV2.ts";
7
8function 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
26function 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
40describe("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
65describe("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 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { GrokSettings } from "@t3tools/contracts";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Layer from "effect/Layer";
6import * as Path from "effect/Path";
7import * as Schema from "effect/Schema";
8import { ChildProcessSpawner } from "effect/unstable/process";
9
10import { ServerConfig } from "../../config.ts";
11import { layer as idAllocatorLayer, IdAllocatorV2 } from "../IdAllocator.ts";
12import { makeLayerEffect as makeProviderAdapterRegistryLayerEffect } from "../ProviderAdapterRegistry.ts";
13import type { OrchestratorV2ProviderReplayHarness } from "../testkit/ProviderReplayHarness.ts";
14import { makeReplayServerConfig } from "../testkit/ProviderReplayHarness.ts";
15import {
16 type AcpReplayTranscript,
17 AcpReplayTranscriptDecodeError,
18 decodeAcpReplayTranscript,
19 makeAcpReplayCompletenessAssertion,
20 makeAcpReplayRuntime,
21} from "./AcpAdapterV2.testkit.ts";
22import { GROK_DEFAULT_INSTANCE_ID, GROK_PROVIDER, makeGrokAdapterV2 } from "./GrokAdapterV2.ts";
23
24const DEFAULT_GROK_SETTINGS = Schema.decodeUnknownSync(GrokSettings)({});
25
26export 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
69export 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 @@
1import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
2import {
3 defaultInstanceIdForDriver,
4 GrokSettings,
5 ProviderDriverKind,
6 type OrchestrationV2ProviderCapabilities,
7} from "@t3tools/contracts";
8import * as Effect from "effect/Effect";
9import * as FileSystem from "effect/FileSystem";
10import * as Layer from "effect/Layer";
11import * as Schema from "effect/Schema";
12import type * as Scope from "effect/Scope";
13import { ChildProcessSpawner } from "effect/unstable/process";
14import type * as EffectAcpErrors from "effect-acp/errors";
15
16import { ServerConfig } from "../../config.ts";
17import {
18 makeGrokAcpRuntime,
19 resolveGrokAcpBaseModelId,
20} from "../../provider/acp/GrokAcpSupport.ts";
21import {
22 extractXAiAskUserQuestionIdentity,
23 extractXAiAskUserQuestions,
24 makeXAiAskUserQuestionCancelledResponse,
25 makeXAiAskUserQuestionResponse,
26 XAiAskUserQuestionRequest,
27} from "../../provider/acp/XAiAcpExtension.ts";
28import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts";
29import type { AcpSessionRuntimeShape } from "../../provider/acp/AcpSessionRuntime.ts";
30import { IdAllocatorV2 } from "../IdAllocator.ts";
31import { ProviderAdapterV2 } from "../ProviderAdapter.ts";
32import {
33 ProviderAdapterDriverCreateError,
34 type ProviderAdapterDriver,
35 type ProviderAdapterDriverCreateInput,
36} from "../ProviderAdapterDriver.ts";
37import {
38 AcpProviderCapabilitiesV2,
39 makeAcpAdapterV2,
40 type AcpAdapterV2Flavor,
41 type AcpAdapterV2RuntimeInput,
42} from "./AcpAdapterV2.ts";
43
44export const GROK_PROVIDER = "grok" as const;
45export const GROK_DRIVER_KIND = ProviderDriverKind.make(GROK_PROVIDER);
46export const GROK_DEFAULT_INSTANCE_ID = defaultInstanceIdForDriver(GROK_DRIVER_KIND);
47const DEFAULT_GROK_SETTINGS = Schema.decodeSync(GrokSettings)({});
48
49export 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
76export 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
90export 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
120export 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
146export type GrokAdapterV2DriverEnv =
147 | ChildProcessSpawner.ChildProcessSpawner
148 | FileSystem.FileSystem
149 | IdAllocatorV2
150 | ServerConfig;
151
152export 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
188export 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 @@
1import { assert, describe, it } from "@effect/vitest";
2import {
3 NodeId,
4 ProviderThreadId,
5 ProviderTurnId,
6 type OrchestrationV2ProviderTurn,
7} from "@t3tools/contracts";
8
9import {
10 openCodeBoundaryAfterProviderTurn,
11 openCodeChildPermissionRules,
12 openCodePermissionRules,
13 openCodePermissionRequestKind,
14 openCodeToolProjectionKind,
15 OpenCodeProviderCapabilitiesV2,
16} from "./OpenCodeAdapterV2.ts";
17import { ProviderAdapterV2RuntimePolicy } from "../ProviderAdapter.ts";
18
19function 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
31function permissionAction(rules: ReturnType<typeof openCodePermissionRules>, permission: string) {
32 return rules.findLast((rule) => rule.permission === "*" || rule.permission === permission)
33 ?.action;
34}
35
36function 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
57describe("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 @@
1import type { Event as OpenCodeEvent, OpencodeClient } from "@opencode-ai/sdk/v2";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { ProviderReplayEntry, type ProviderReplayTranscript } from "@t3tools/contracts";
4import * as Effect from "effect/Effect";
5import * as Duration from "effect/Duration";
6import * as Layer from "effect/Layer";
7import * as Schema from "effect/Schema";
8
9import { ServerConfig } from "../../config.ts";
10import {
11 OpenCodeRuntime,
12 OpenCodeRuntimeError,
13 type OpenCodeRuntimeShape,
14} from "../../provider/opencodeRuntime.ts";
15import { layer as idAllocatorLayer } from "../IdAllocator.ts";
16import { ProviderAdapterDriverCreateError } from "../ProviderAdapterDriver.ts";
17import { makeDriverLayer as makeProviderAdapterRegistryDriverLayer } from "../ProviderAdapterRegistry.ts";
18import {
19 makeReplayServerConfig,
20 type OrchestratorV2ProviderReplayHarness,
21} from "../testkit/ProviderReplayHarness.ts";
22import {
23 OPENCODE_DEFAULT_INSTANCE_ID,
24 OPENCODE_DRIVER_KIND,
25 OPENCODE_PROVIDER,
26 OpenCodeAdapterV2Driver,
27} from "./OpenCodeAdapterV2.ts";
28
29export const OPENCODE_SDK_REPLAY_PROTOCOL = "opencode-sdk.sse" as const;
30
31const 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});
39export type OpenCodeSdkReplayTranscript = typeof OpenCodeSdkReplayTranscript.Type;
40const decodeOpenCodeSdkReplayTranscript = Schema.decodeUnknownEffect(OpenCodeSdkReplayTranscript);
41
42export 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
56export 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
70export 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
83export const OpenCodeReplayError = Schema.Union([
84 OpenCodeReplayTranscriptDecodeError,
85 OpenCodeReplayMismatchError,
86 OpenCodeReplayIncompleteError,
87]);
88export type OpenCodeReplayError = typeof OpenCodeReplayError.Type;
89export const OpenCodeOrchestratorReplayHarnessError = Schema.Union([
90 OpenCodeReplayError,
91 ProviderAdapterDriverCreateError,
92]);
93export type OpenCodeOrchestratorReplayHarnessError =
94 typeof OpenCodeOrchestratorReplayHarnessError.Type;
95
96function 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
114function frameRecord(frame: unknown): Record<string, unknown> | null {
115 return typeof frame === "object" && frame !== null ? (frame as Record<string, unknown>) : null;
116}
117
118class 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
254function 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
289function 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
334export 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
361function transcriptMetadata(transcript: ProviderReplayTranscript) {
362 return {
363 provider: transcript.provider,
364 protocol: transcript.protocol,
365 scenario: transcript.scenario,
366 };
367}
368
369export 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 @@
1import 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";
12import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
13import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";
14import {
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";
39import * as Cause from "effect/Cause";
40import * as DateTime from "effect/DateTime";
41import * as Effect from "effect/Effect";
42import * as Exit from "effect/Exit";
43import * as Layer from "effect/Layer";
44import * as Option from "effect/Option";
45import * as Queue from "effect/Queue";
46import * as Ref from "effect/Ref";
47import * as Schema from "effect/Schema";
48import * as Scope from "effect/Scope";
49import * as Stream from "effect/Stream";
50
51import { resolveAttachmentPath } from "../../attachmentStore.ts";
52import { ServerConfig, type ServerConfigShape } from "../../config.ts";
53import * as McpProviderSession from "../../mcp/McpProviderSession.ts";
54import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts";
55import {
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";
67import { IdAllocatorV2, type IdAllocatorV2Shape } from "../IdAllocator.ts";
68import {
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";
88import {
89 ProviderAdapterDriverCreateError,
90 type ProviderAdapterDriver,
91 type ProviderAdapterDriverCreateInput,
92} from "../ProviderAdapterDriver.ts";
93import { makeSubagentChildThread, subagentThreadTitle } from "../SubagentProjection.ts";
94
95export const OPENCODE_PROVIDER = "opencode" as const;
96export const OPENCODE_DRIVER_KIND = ProviderDriverKind.make(OPENCODE_PROVIDER);
97export const OPENCODE_DEFAULT_INSTANCE_ID = defaultInstanceIdForDriver(OPENCODE_DRIVER_KIND);
98const 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 */
106export 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
197type TerminalTurnStatus = Extract<
198 OrchestrationV2ProviderTurn["status"],
199 "completed" | "interrupted" | "failed" | "cancelled"
200>;
201
202interface 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
225interface 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
239interface 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
253interface 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
266export 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
275function protocolError(detail: string, payload?: unknown): ProviderAdapterProtocolError {
276 return new ProviderAdapterProtocolError({
277 provider: OPENCODE_PROVIDER,
278 detail,
279 ...(payload === undefined ? {} : { payload }),
280 });
281}
282
283function 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
291function 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
296function nonEmptyString(value: unknown): string | undefined {
297 return typeof value === "string" && value.trim().length > 0 ? value : undefined;
298}
299
300function 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
306function 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
314function 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
322function 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
331function 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
338type OpenCodePermissionRequestKind = Extract<
339 ProviderRequestKind,
340 "command" | "file-read" | "file-change"
341>;
342
343export 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
375export 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
400const OPENCODE_ALWAYS_ALLOWED_PERMISSIONS = [
401 "question",
402 "read",
403 "glob",
404 "grep",
405 "lsp",
406 "todowrite",
407 "task",
408 "skill",
409] as const;
410
411const 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 */
428export 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
511function 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 */
528export 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 */
548export 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
561function 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
577function toolInput(part: ToolPart): Record<string, unknown> {
578 return part.state.input;
579}
580
581function 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
587function 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
594function 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
600function toolTitle(part: ToolPart): string | null {
601 return part.state.status === "running" || part.state.status === "completed"
602 ? (part.state.title ?? null)
603 : null;
604}
605
606function 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
619function 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
629function 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
664function providerRef(nativeId: string, strength: "strong" | "weak" = "strong") {
665 return {
666 provider: OPENCODE_PROVIDER,
667 nativeId,
668 strength,
669 } satisfies OrchestrationV2ProviderRef;
670}
671
672function 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
678function terminalStatusForError(
679 event: Extract<OpenCodeEvent, { type: "session.error" }>,
680 turn: ActiveOpenCodeTurn,
681): TerminalTurnStatus {
682 return turn.interrupted || isMessageAbortedError(event) ? "interrupted" : "failed";
683}
684
685function isMessageAbortedError(event: Extract<OpenCodeEvent, { type: "session.error" }>): boolean {
686 return event.properties.error?.name === "MessageAbortedError";
687}
688
689function 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
699export 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
2687export type OpenCodeAdapterV2DriverEnv = OpenCodeRuntime | IdAllocatorV2 | ServerConfig;
2688
2689export 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
2726export 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
4 4
5import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts";5import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts";
6import { CursorProviderCapabilitiesV2 } from "./Adapters/CursorAdapterV2.ts";6import { CursorProviderCapabilitiesV2 } from "./Adapters/CursorAdapterV2.ts";
7import { GrokProviderCapabilitiesV2 } from "./Adapters/GrokAdapterV2.ts";
7import {8import {
8 CommandPolicyCapabilityUnsupportedError,9 CommandPolicyCapabilityUnsupportedError,
9 CommandPolicyV2,10 CommandPolicyV2,
@@ -62,6 +63,21 @@ layer("CommandPolicyV2", (it) => { · 52 unmodified lines
62 }),63 }),
63 );64 );
64 65
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
65 it.effect("honors an explicit interrupt-and-restart request", () =>81 it.effect("honors an explicit interrupt-and-restart request", () =>
66 Effect.gen(function* () {82 Effect.gen(function* () {
67 const policy = yield* CommandPolicyV2;83 const policy = yield* CommandPolicyV2;
@@ -165,6 +181,24 @@ layer("CommandPolicyV2", (it) => { · 97 unmodified lines
165 }),181 }),
166 );182 );
167 183
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
168 it.effect("returns a typed error when neither native nor portable fork is available", () =>202 it.effect("returns a typed error when neither native nor portable fork is available", () =>
169 Effect.gen(function* () {203 Effect.gen(function* () {
170 const policy = yield* CommandPolicyV2;204 const policy = yield* CommandPolicyV2;
apps/server/src/orchestration-v2/GrokOrchestratorV2.live.test.ts+158−0
@@ -0,0 +1,158 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
3import {
4 CommandId,
5 MessageId,
6 type OrchestrationV2ThreadProjection,
7 ProjectId,
8 ThreadId,
9} from "@t3tools/contracts";
10import * as Console from "effect/Console";
11import * as Effect from "effect/Effect";
12import * as Layer from "effect/Layer";
13import { describe } from "vite-plus/test";
14
15import { CheckpointStoreLive } from "../checkpointing/Layers/CheckpointStore.ts";
16import { ServerConfig } from "../config.ts";
17import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";
18import { ServerSettingsService } from "../serverSettings.ts";
19import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
20import * as VcsProcess from "../vcs/VcsProcess.ts";
21import { OrchestratorV2 } from "./Orchestrator.ts";
22import { OrchestrationV2LayerLive } from "./runtimeLayer.ts";
23import { GROK_MODEL_SELECTION } from "./testkit/fixtures/shared.ts";
24
25const serverConfigLayer = ServerConfig.layerTest(process.cwd(), {
26 prefix: "t3-grok-v2-live-",
27});
28
29const vcsDriverRegistryLayer = VcsDriverRegistry.layer.pipe(
30 Layer.provide(VcsProcess.layer),
31 Layer.provide(serverConfigLayer),
32 Layer.provide(NodeServices.layer),
33);
34
35const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(vcsDriverRegistryLayer));
36
37const 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
51const 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
68describe.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 @@
1import type { ProviderDriverKind } from "@t3tools/contracts";1import type { ProviderDriverKind } from "@t3tools/contracts";
2 2
3import {
4 AcpRegistryAdapterV2Driver,
5 type AcpRegistryAdapterV2DriverEnv,
6} from "./Adapters/AcpRegistryAdapterV2.ts";
3import {7import {
4 ClaudeAdapterV2Driver,8 ClaudeAdapterV2Driver,
5 type ClaudeAdapterV2DriverEnv,9 type ClaudeAdapterV2DriverEnv,
@@ -9,16 +13,31 @@ import { · 3 unmodified lines
9 CursorAdapterV2Driver,13 CursorAdapterV2Driver,
10 type CursorAdapterV2DriverEnv,14 type CursorAdapterV2DriverEnv,
11} from "./Adapters/CursorAdapterV2.ts";15} from "./Adapters/CursorAdapterV2.ts";
16import { GrokAdapterV2Driver, type GrokAdapterV2DriverEnv } from "./Adapters/GrokAdapterV2.ts";
17import {
18 OpenCodeAdapterV2Driver,
19 type OpenCodeAdapterV2DriverEnv,
20} from "./Adapters/OpenCodeAdapterV2.ts";
12import type { AnyProviderAdapterDriver } from "./ProviderAdapterDriver.ts";21import type { AnyProviderAdapterDriver } from "./ProviderAdapterDriver.ts";
13 22
14export type BuiltInProviderAdapterDriversV2Env =23export type BuiltInProviderAdapterDriversV2Env =
24 | AcpRegistryAdapterV2DriverEnv
15 | ClaudeAdapterV2DriverEnv25 | ClaudeAdapterV2DriverEnv
16 | CodexAdapterV2DriverEnv26 | CodexAdapterV2DriverEnv
17 | CursorAdapterV2DriverEnv;27 | CursorAdapterV2DriverEnv
28 | GrokAdapterV2DriverEnv
29 | OpenCodeAdapterV2DriverEnv;
18 30
19export const BUILT_IN_PROVIDER_ADAPTER_DRIVERS_V2: ReadonlyArray<31export const BUILT_IN_PROVIDER_ADAPTER_DRIVERS_V2: ReadonlyArray<
20 AnyProviderAdapterDriver<BuiltInProviderAdapterDriversV2Env>32 AnyProviderAdapterDriver<BuiltInProviderAdapterDriversV2Env>
21> = [CodexAdapterV2Driver, ClaudeAdapterV2Driver, CursorAdapterV2Driver];33> = [
34 CodexAdapterV2Driver,
35 ClaudeAdapterV2Driver,
36 CursorAdapterV2Driver,
37 OpenCodeAdapterV2Driver,
38 GrokAdapterV2Driver,
39 AcpRegistryAdapterV2Driver,
40];
22 41
23export const BUILT_IN_PROVIDER_ADAPTER_DRIVER_KINDS_V2: ReadonlySet<ProviderDriverKind> = new Set(42export const BUILT_IN_PROVIDER_ADAPTER_DRIVER_KINDS_V2: ReadonlySet<ProviderDriverKind> = new Set(
24 BUILT_IN_PROVIDER_ADAPTER_DRIVERS_V2.map((driver) => driver.driverKind),43 BUILT_IN_PROVIDER_ADAPTER_DRIVERS_V2.map((driver) => driver.driverKind),
apps/server/src/orchestration-v2/runtimeLayer.ts+4−0
@@ -1,5 +1,7 @@
1import * as Layer from "effect/Layer";1import * as Layer from "effect/Layer";
2import { FetchHttpClient } from "effect/unstable/http";
2 3
4import { OpenCodeRuntimeLive } from "../provider/opencodeRuntime.ts";
3import { claudeAgentSdkQueryRunnerLiveLayer } from "./Adapters/ClaudeAdapterV2.ts";5import { claudeAgentSdkQueryRunnerLiveLayer } from "./Adapters/ClaudeAdapterV2.ts";
4import { codexAppServerClientFactoryFromSettingsLayer } from "./Adapters/CodexAdapterV2.ts";6import { codexAppServerClientFactoryFromSettingsLayer } from "./Adapters/CodexAdapterV2.ts";
5import { cursorAgentSdkRunnerLiveLayer } from "./Adapters/CursorAgentSdk.ts";7import { cursorAgentSdkRunnerLiveLayer } from "./Adapters/CursorAgentSdk.ts";
@@ -40,6 +42,8 @@ const providerAdapterRegistryProvided = providerAdapterRegistryLayerFromSettings · 34 unmodified lines
40 Layer.provide(codexAppServerClientFactoryFromSettingsLayer),42 Layer.provide(codexAppServerClientFactoryFromSettingsLayer),
41 Layer.provide(claudeAgentSdkQueryRunnerLiveLayer),43 Layer.provide(claudeAgentSdkQueryRunnerLiveLayer),
42 Layer.provide(cursorAgentSdkRunnerLiveLayer),44 Layer.provide(cursorAgentSdkRunnerLiveLayer),
45 Layer.provide(OpenCodeRuntimeLive),
46 Layer.provide(FetchHttpClient.layer),
43 Layer.provide(idAllocatorLayer),47 Layer.provide(idAllocatorLayer),
44);48);
45 49
apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.contract.test.ts+9−1
@@ -49,7 +49,15 @@ describe("orchestrator replay fixture contract", () => { · 48 unmodified lines
49 const firstCommand = materialized.commands[0];49 const firstCommand = materialized.commands[0];
50 50
51 assert.equal(transcript.scenario, fixture.name);51 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 }
53 assert.equal(provider.modelSelection.instanceId, provider.provider);61 assert.equal(provider.modelSelection.instanceId, provider.provider);
54 assert.isDefined(materialized.projectionThreadIds[0]);62 assert.isDefined(materialized.projectionThreadIds[0]);
55 assert.equal(firstCommand?.type, "thread.create");63 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
7import { ClaudeOrchestratorReplayHarness } from "../Adapters/ClaudeAdapterV2.testkit.ts";7import { ClaudeOrchestratorReplayHarness } from "../Adapters/ClaudeAdapterV2.testkit.ts";
8import { CodexOrchestratorReplayHarness } from "../Adapters/CodexAdapterV2.testkit.ts";8import { CodexOrchestratorReplayHarness } from "../Adapters/CodexAdapterV2.testkit.ts";
9import { CursorOrchestratorReplayHarness } from "../Adapters/CursorAdapterV2.testkit.ts";9import { CursorOrchestratorReplayHarness } from "../Adapters/CursorAdapterV2.testkit.ts";
10import { AcpRegistryOrchestratorReplayHarness } from "../Adapters/AcpRegistryAdapterV2.testkit.ts";
11import { GrokOrchestratorReplayHarness } from "../Adapters/GrokAdapterV2.testkit.ts";
12import { OpenCodeOrchestratorReplayHarness } from "../Adapters/OpenCodeAdapterV2.testkit.ts";
10import { layer as idAllocatorLayer } from "../IdAllocator.ts";13import { layer as idAllocatorLayer } from "../IdAllocator.ts";
11import { provideDeterministicTestRuntime } from "./DeterministicRuntime.ts";14import { provideDeterministicTestRuntime } from "./DeterministicRuntime.ts";
12import { ORCHESTRATOR_REPLAY_FIXTURES } from "./fixtures/index.ts";15import { ORCHESTRATOR_REPLAY_FIXTURES } from "./fixtures/index.ts";
@@ -118,6 +121,21 @@ function runFixtureProviderWithRegisteredHarness(input: { · 105 unmodified lines
118 ...input,121 ...input,
119 harness: CursorOrchestratorReplayHarness,122 harness: CursorOrchestratorReplayHarness,
120 }).pipe(Effect.mapError(normalizeTestError), Effect.scoped);123 }).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);
121 default:139 default:
122 return Effect.die(140 return Effect.die(
123 new Error(`No replay harness registered for provider ${input.provider.provider}.`),141 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
41 type OrchestratorV2ScenarioResult,41 type OrchestratorV2ScenarioResult,
42} from "./OrchestratorScenario.ts";42} from "./OrchestratorScenario.ts";
43 43
44function makeReplayServerConfig(44export function makeReplayServerConfig(
45 scenario: string,45 scenario: string,
46): Effect.Effect<46): Effect.Effect<
47 ServerConfigShape,47 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 @@
1import { assertClaudeMessageSteeringOutput } from "./message_steering/claude_output.ts";1import { assertClaudeMessageSteeringOutput } from "./message_steering/claude_output.ts";
2import { assertMessageSteeringOutput } from "./message_steering/codex_output.ts";2import { assertMessageSteeringOutput } from "./message_steering/codex_output.ts";
3import { assertCursorMessageSteeringOutput } from "./message_steering/cursor_output.ts";3import { assertCursorMessageSteeringOutput } from "./message_steering/cursor_output.ts";
4import { assertGrokMessageSteeringOutput } from "./message_steering/grok_output.ts";
4import { messageSteeringInput } from "./message_steering/input.ts";5import { messageSteeringInput } from "./message_steering/input.ts";
5import { assertMultiTurnClaudeOutput } from "./multi_turn/claude_output.ts";6import { assertMultiTurnClaudeOutput } from "./multi_turn/claude_output.ts";
6import { assertMultiTurnOutput } from "./multi_turn/codex_output.ts";7import { assertMultiTurnOutput } from "./multi_turn/codex_output.ts";
7import { multiTurnInput } from "./multi_turn/input.ts";8import { multiTurnInput } from "./multi_turn/input.ts";
9import { openCodeSubagentInput } from "./opencode_subagent/input.ts";
10import { assertOpenCodeSubagentOutput } from "./opencode_subagent/output.ts";
8import { assertPlanQuestionsOutput } from "./plan_questions/codex_output.ts";11import { assertPlanQuestionsOutput } from "./plan_questions/codex_output.ts";
12import { assertOpenCodePlanQuestionsOutput } from "./plan_questions/opencode_output.ts";
9import { planQuestionsInput } from "./plan_questions/input.ts";13import { planQuestionsInput } from "./plan_questions/input.ts";
10import { assertProposedPlanOutput } from "./proposed_plan/codex_output.ts";14import { assertProposedPlanOutput } from "./proposed_plan/codex_output.ts";
11import { assertProposedPlanCursorOutput } from "./proposed_plan/cursor_output.ts";15import { assertProposedPlanCursorOutput } from "./proposed_plan/cursor_output.ts";
@@ -26,6 +30,7 @@ import { assertThreadRollbackOutput } from "./thread_rollback/codex_output.ts"; · 14 unmodified lines
26import { threadRollbackInput } from "./thread_rollback/input.ts";30import { threadRollbackInput } from "./thread_rollback/input.ts";
27import { assertTodoListOutput } from "./todo_list/codex_output.ts";31import { assertTodoListOutput } from "./todo_list/codex_output.ts";
28import { assertTodoListCursorOutput } from "./todo_list/cursor_output.ts";32import { assertTodoListCursorOutput } from "./todo_list/cursor_output.ts";
33import { assertTodoListGrokOutput } from "./todo_list/grok_output.ts";
29import { todoListInput } from "./todo_list/input.ts";34import { todoListInput } from "./todo_list/input.ts";
30import { assertToolCallReadOnlyClaudeOutput } from "./tool_call_read_only/claude_output.ts";35import { assertToolCallReadOnlyClaudeOutput } from "./tool_call_read_only/claude_output.ts";
31import { assertToolCallReadOnlyCursorOutput } from "./tool_call_read_only/cursor_output.ts";36import { assertToolCallReadOnlyCursorOutput } from "./tool_call_read_only/cursor_output.ts";
@@ -52,9 +57,12 @@ import { assertClaudeWebSearchOutput } from "./web_search/claude_output.ts"; · 20 unmodified lines
52import { assertWebSearchOutput } from "./web_search/codex_output.ts";57import { assertWebSearchOutput } from "./web_search/codex_output.ts";
53import { webSearchInput } from "./web_search/input.ts";58import { webSearchInput } from "./web_search/input.ts";
54import {59import {
60 ACP_REGISTRY_MODEL_SELECTION,
55 CLAUDE_MODEL_SELECTION,61 CLAUDE_MODEL_SELECTION,
56 CODEX_MODEL_SELECTION,62 CODEX_MODEL_SELECTION,
57 CURSOR_MODEL_SELECTION,63 CURSOR_MODEL_SELECTION,
64 GROK_MODEL_SELECTION,
65 OPENCODE_MODEL_SELECTION,
58 READ_ONLY_NEVER_POLICY,66 READ_ONLY_NEVER_POLICY,
59 READ_ONLY_ON_REQUEST_POLICY,67 READ_ONLY_ON_REQUEST_POLICY,
60 RESTRICTED_GRANULAR_POLICY,68 RESTRICTED_GRANULAR_POLICY,
@@ -63,6 +71,26 @@ import { · 2 unmodified lines
63} from "./shared.ts";71} from "./shared.ts";
64 72
65export const ORCHESTRATOR_REPLAY_FIXTURES = [73export 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 },
66 {94 {
67 name: "simple",95 name: "simple",
68 buildInput: simpleInput,96 buildInput: simpleInput,
@@ -85,6 +113,24 @@ export const ORCHESTRATOR_REPLAY_FIXTURES = [ · 16 unmodified lines
85 modelSelection: CURSOR_MODEL_SELECTION,113 modelSelection: CURSOR_MODEL_SELECTION,
86 assertOutput: assertSimpleOutput,114 assertOutput: assertSimpleOutput,
87 },115 },
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 },
88 ],134 ],
89 },135 },
90 {136 {
@@ -105,6 +151,20 @@ export const ORCHESTRATOR_REPLAY_FIXTURES = [ · 14 unmodified lines
105 runtimePolicyOverride: READ_ONLY_NEVER_POLICY,151 runtimePolicyOverride: READ_ONLY_NEVER_POLICY,
106 assertOutput: assertToolCallReadOnlyCursorOutput,152 assertOutput: assertToolCallReadOnlyCursorOutput,
107 },153 },
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 },
108 ],168 ],
109 },169 },
110 {170 {
@@ -131,6 +191,26 @@ export const ORCHESTRATOR_REPLAY_FIXTURES = [ · 20 unmodified lines
131 runtimePolicyOverride: READ_ONLY_ON_REQUEST_POLICY,191 runtimePolicyOverride: READ_ONLY_ON_REQUEST_POLICY,
132 assertOutput: assertToolCallReadOnlyOnRequestClaudeOutput,192 assertOutput: assertToolCallReadOnlyOnRequestClaudeOutput,
133 },193 },
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 },
134 ],214 ],
135 },215 },
136 {216 {
@@ -223,6 +303,18 @@ export const ORCHESTRATOR_REPLAY_FIXTURES = [ · 86 unmodified lines
223 },303 },
224 ],304 ],
225 },305 },
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 },
226 {318 {
227 name: "multi_turn",319 name: "multi_turn",
228 buildInput: multiTurnInput,320 buildInput: multiTurnInput,
@@ -245,6 +337,18 @@ export const ORCHESTRATOR_REPLAY_FIXTURES = [ · 16 unmodified lines
245 modelSelection: CURSOR_MODEL_SELECTION,337 modelSelection: CURSOR_MODEL_SELECTION,
246 assertOutput: assertMultiTurnOutput,338 assertOutput: assertMultiTurnOutput,
247 },339 },
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 },
248 ],352 ],
249 },353 },
250 {354 {
@@ -281,6 +385,18 @@ export const ORCHESTRATOR_REPLAY_FIXTURES = [ · 30 unmodified lines
281 modelSelection: CURSOR_MODEL_SELECTION,385 modelSelection: CURSOR_MODEL_SELECTION,
282 assertOutput: assertQueuedTurnOutput,386 assertOutput: assertQueuedTurnOutput,
283 },387 },
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 },
284 ],400 ],
285 },401 },
286 {402 {
@@ -301,6 +417,18 @@ export const ORCHESTRATOR_REPLAY_FIXTURES = [ · 14 unmodified lines
301 runtimePolicyOverride: READ_ONLY_NEVER_POLICY,417 runtimePolicyOverride: READ_ONLY_NEVER_POLICY,
302 assertOutput: assertTodoListCursorOutput,418 assertOutput: assertTodoListCursorOutput,
303 },419 },
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 },
304 ],432 ],
305 },433 },
306 {434 {
@@ -332,6 +460,20 @@ export const ORCHESTRATOR_REPLAY_FIXTURES = [ · 25 unmodified lines
332 runtimePolicyOverride: READ_ONLY_NEVER_POLICY,460 runtimePolicyOverride: READ_ONLY_NEVER_POLICY,
333 assertOutput: assertPlanQuestionsOutput,461 assertOutput: assertPlanQuestionsOutput,
334 },462 },
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 },
335 ],477 ],
336 },478 },
337 {479 {
@@ -376,6 +518,18 @@ export const ORCHESTRATOR_REPLAY_FIXTURES = [ · 38 unmodified lines
376 modelSelection: CURSOR_MODEL_SELECTION,518 modelSelection: CURSOR_MODEL_SELECTION,
377 assertOutput: assertCursorMessageSteeringOutput,519 assertOutput: assertCursorMessageSteeringOutput,
378 },520 },
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 },
379 ],533 ],
380 },534 },
381 {535 {
@@ -396,6 +550,27 @@ export const ORCHESTRATOR_REPLAY_FIXTURES = [ · 14 unmodified lines
396 runtimePolicyOverride: WORKSPACE_NEVER_POLICY,550 runtimePolicyOverride: WORKSPACE_NEVER_POLICY,
397 assertOutput: assertTurnInterruptClaudeOutput,551 assertOutput: assertTurnInterruptClaudeOutput,
398 },552 },
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 },
399 ],574 ],
400 },575 },
401 {576 {
apps/server/src/orchestration-v2/testkit/fixtures/message_steering/grok_output.ts+54−0
@@ -0,0 +1,54 @@
1import { assert } from "@effect/vitest";
2import type { ProviderReplayTranscript } from "@t3tools/contracts";
3
4import type { OrchestratorV2ScenarioResult } from "../../OrchestratorScenario.ts";
5import {
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
18export 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 @@
1import { OPENCODE_SUBAGENT_PROMPT, type OrchestratorFixtureInput } from "../shared.ts";
2
3export 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 @@
1import { assert } from "@effect/vitest";
2import type { ProviderReplayTranscript } from "@t3tools/contracts";
3
4import type { OrchestratorV2ScenarioResult } from "../../OrchestratorScenario.ts";
5import {
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
18export 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
18 projectionFor,18 projectionFor,
19} from "../shared.ts";19} from "../shared.ts";
20 20
21export function assertPlanQuestionsOutput(21export function assertPlanQuestionsOutputBase(
22 result: OrchestratorV2ScenarioResult,22 result: OrchestratorV2ScenarioResult,
23 transcript: ProviderReplayTranscript,23 transcript: ProviderReplayTranscript,
24) {24) {
@@ -35,7 +35,15 @@ export function assertPlanQuestionsOutput( · 10 unmodified lines
35 assertTurnItemTypes(projection, ["user_message", "user_input_request", "assistant_message"]);35 assertTurnItemTypes(projection, ["user_message", "user_input_request", "assistant_message"]);
36 assertUserMessagesInclude(projection, [PLAN_QUESTIONS_PROMPT]);36 assertUserMessagesInclude(projection, [PLAN_QUESTIONS_PROMPT]);
37 assertAssistantTextIncludes(projection, "plan questions fixture complete");37 assertAssistantTextIncludes(projection, "plan questions fixture complete");
38}
39
40export function assertPlanQuestionsOutput(
41 result: OrchestratorV2ScenarioResult,
42 transcript: ProviderReplayTranscript,
43) {
44 assertPlanQuestionsOutputBase(result, transcript);
38 45
46 const projection = projectionFor(result, transcript.scenario);
39 const requestItem = projection.turnItems.find((item) => item.type === "user_input_request");47 const requestItem = projection.turnItems.find((item) => item.type === "user_input_request");
40 assert.isDefined(requestItem);48 assert.isDefined(requestItem);
41 assert.equal(requestItem?.questions[0]?.id, "schema_vs_ui_flexibility");49 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
9 type: "answer_next_user_input_request",9 type: "answer_next_user_input_request",
10 answers: {10 answers: {
11 schema_vs_ui_flexibility: "Strict schemas (Recommended)",11 schema_vs_ui_flexibility: "Strict schemas (Recommended)",
12 "question-0-schema-vs-flexibility": "Strict schemas",
12 },13 },
13 },14 },
14 ],15 ],
apps/server/src/orchestration-v2/testkit/fixtures/plan_questions/opencode_output.ts+26−0
@@ -0,0 +1,26 @@
1import { assert } from "@effect/vitest";
2import type { ProviderReplayTranscript } from "@t3tools/contracts";
3
4import type { OrchestratorV2ScenarioResult } from "../../OrchestratorScenario.ts";
5import { assertPlanQuestionsOutputBase } from "./codex_output.ts";
6import { projectionFor } from "../shared.ts";
7
8export 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
42 "Respond with exactly: steering fixture initial response";42 "Respond with exactly: steering fixture initial response";
43export const SUBAGENT_PROMPT =43export const SUBAGENT_PROMPT =
44 "Spawn 2 subagents, one to read package.json and one to read tsconfig.json";44 "Spawn 2 subagents, one to read package.json and one to read tsconfig.json";
45export 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.";
45export const SUBAGENT_CONTINUE_PROMPT =47export const SUBAGENT_CONTINUE_PROMPT =
46 "Spawn one subagent and have it reply exactly: initial subagent response";48 "Spawn one subagent and have it reply exactly: initial subagent response";
47export const SUBAGENT_CONTINUE_PARENT_PROMPT =49export const SUBAGENT_CONTINUE_PARENT_PROMPT =
@@ -237,6 +239,22 @@ export const CURSOR_MODEL_SELECTION = { · 189 unmodified lines
237 model: "composer-2.5",239 model: "composer-2.5",
238} satisfies ModelSelection;240} satisfies ModelSelection;
239 241
242export const GROK_MODEL_SELECTION = {
243 instanceId: ProviderInstanceId.make("grok"),
244 model: "grok-build",
245} satisfies ModelSelection;
246
247export 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
253export const ACP_REGISTRY_MODEL_SELECTION = {
254 instanceId: ProviderInstanceId.make("acpRegistry"),
255 model: "default",
256} satisfies ModelSelection;
257
240export const READ_ONLY_ON_REQUEST_POLICY = {258export const READ_ONLY_ON_REQUEST_POLICY = {
241 approvalPolicy: "on-request",259 approvalPolicy: "on-request",
242 sandboxPolicy: {260 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 @@
1import { assert } from "@effect/vitest";
2import type { ProviderReplayTranscript } from "@t3tools/contracts";
3
4import type { OrchestratorV2ScenarioResult } from "../../OrchestratorScenario.ts";
5import {
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
17export 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
10import * as Stream from "effect/Stream";10import * as Stream from "effect/Stream";
11import { describe, expect } from "vite-plus/test";11import { describe, expect } from "vite-plus/test";
12 12
13import { AcpSessionRuntime, type AcpSessionRequestLogEvent } from "./AcpSessionRuntime.ts";13import {
14 AcpSessionRuntime,
15 selectAcpAgentAuthMethod,
16 type AcpSessionRequestLogEvent,
17} from "./AcpSessionRuntime.ts";
14import type * as EffectAcpProtocol from "effect-acp/protocol";18import type * as EffectAcpProtocol from "effect-acp/protocol";
15 19
16const __dirname = path.dirname(fileURLToPath(import.meta.url));20const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -19,6 +23,17 @@ const mockAgentCommand = "node"; · 2 unmodified lines
19const mockAgentArgs = [mockAgentPath];23const mockAgentArgs = [mockAgentPath];
20 24
21describe("AcpSessionRuntime", () => {25describe("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
22 it.effect("merges custom initialize client capabilities into the ACP handshake", () => {37 it.effect("merges custom initialize client capabilities into the ACP handshake", () => {
23 const requestEvents: Array<AcpSessionRequestLogEvent> = [];38 const requestEvents: Array<AcpSessionRequestLogEvent> = [];
24 return Effect.gen(function* () {39 return Effect.gen(function* () {
@@ -62,6 +77,82 @@ describe("AcpSessionRuntime", () => { · 37 unmodified lines
62 );77 );
63 });78 });
64 79
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
65 it.effect("starts a session, prompts, and emits normalized events against the mock agent", () =>156 it.effect("starts a session, prompts, and emits normalized events against the mock agent", () =>
66 Effect.gen(function* () {157 Effect.gen(function* () {
67 const runtime = yield* AcpSessionRuntime;158 const runtime = yield* AcpSessionRuntime;
@@ -264,6 +355,63 @@ describe("AcpSessionRuntime", () => { · 196 unmodified lines
264 );355 );
265 });356 });
266 357
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
267 it.effect("skips no-op session config writes when the requested value is already active", () => {415 it.effect("skips no-op session config writes when the requested value is already active", () => {
268 const requestEvents: Array<AcpSessionRequestLogEvent> = [];416 const requestEvents: Array<AcpSessionRequestLogEvent> = [];
269 return Effect.gen(function* () {417 return Effect.gen(function* () {
apps/server/src/provider/acp/AcpRegistrySupport.test.ts+263−0
@@ -0,0 +1,263 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { describe, expect, it } from "@effect/vitest";
3import { AcpRegistrySettings } from "@t3tools/contracts";
4import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";
5import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
7import * as Layer from "effect/Layer";
8import * as Schema from "effect/Schema";
9import { HttpClient, HttpClientResponse } from "effect/unstable/http";
10
11import {
12 makeAcpRegistryResolver,
13 resolveAcpRegistryDistribution,
14 resolveAcpRegistryPlatformTarget,
15 type AcpRegistryAgent,
16} from "./AcpRegistrySupport.ts";
17
18const registryUrl = "https://registry.test/registry.json";
19const archiveUrl = "https://registry.test/example-agent.bin";
20const decodeAcpRegistrySettings = Schema.decodeSync(AcpRegistrySettings);
21
22function 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
32function makeRegistry(agent: AcpRegistryAgent): string {
33 return JSON.stringify({ version: "1.0.0", agents: [agent] });
34}
35
36function settings(input: Partial<AcpRegistrySettings> = {}): AcpRegistrySettings {
37 return decodeAcpRegistrySettings({
38 agentId: "example-agent",
39 ...input,
40 });
41}
42
43function 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
52describe("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 @@
1import {
2 type AcpRegistryDistributionPreference,
3 type AcpRegistrySettings,
4} from "@t3tools/contracts";
5import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";
6import * as Clock from "effect/Clock";
7import * as Effect from "effect/Effect";
8import * as FileSystem from "effect/FileSystem";
9import * as Option from "effect/Option";
10import * as Path from "effect/Path";
11import * as PlatformError from "effect/PlatformError";
12import * as Ref from "effect/Ref";
13import * as Schema from "effect/Schema";
14import * as Semaphore from "effect/Semaphore";
15import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
16import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
17
18import { collectUint8StreamText } from "../../stream/collectUint8StreamText.ts";
19import type { AcpSpawnInput } from "./AcpSessionRuntime.ts";
20
21export const ACP_REGISTRY_URL =
22 "https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json";
23
24const 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
30const 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
37const 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});
48export type AcpRegistryAgent = typeof AcpRegistryAgent.Type;
49
50const AcpRegistryIndex = Schema.Struct({
51 version: Schema.String,
52 agents: Schema.Array(AcpRegistryAgent),
53});
54export type AcpRegistryIndex = typeof AcpRegistryIndex.Type;
55
56const decodeRegistryIndex = Schema.decodeUnknownEffect(AcpRegistryIndex);
57const decodeJson = Schema.decodeUnknownEffect(Schema.UnknownFromJsonString);
58
59export 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]);
69export type AcpRegistryErrorReason = typeof AcpRegistryErrorReason.Type;
70
71export 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
84const isAcpRegistryError = Schema.is(AcpRegistryError);
85
86export type AcpRegistryPlatformTarget =
87 | "darwin-aarch64"
88 | "darwin-x86_64"
89 | "linux-aarch64"
90 | "linux-x86_64"
91 | "windows-aarch64"
92 | "windows-x86_64";
93
94export 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
110export type AcpRegistryDistributionKind = "binary" | "npx" | "uvx";
111
112export 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
120export 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
159export interface ResolvedAcpRegistryAgent {
160 readonly agent: AcpRegistryAgent;
161 readonly distribution: AcpRegistryDistributionKind;
162 readonly spawn: AcpSpawnInput;
163}
164
165export interface AcpRegistryResolverShape {
166 readonly resolve: (
167 settings: AcpRegistrySettings,
168 cwd: string,
169 environment?: NodeJS.ProcessEnv,
170 ) => Effect.Effect<ResolvedAcpRegistryAgent, AcpRegistryError>;
171}
172
173const INSTALL_LOCK_RETRY_COUNT = 300;
174const INSTALL_LOCK_RETRY_DELAY = "100 millis";
175const INSTALL_LOCK_STALE_MS = 5 * 60 * 1_000;
176const MAX_ARCHIVE_BYTES = 512 * 1024 * 1024;
177
178function isAlreadyExists(error: PlatformError.PlatformError): boolean {
179 return error.reason._tag === "AlreadyExists";
180}
181
182function 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
196function 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
204type ArchiveKind = "raw" | "tar_bz2" | "tar_gz" | "zip";
205
206function 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
214function 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
227export 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
72 };72 };
73 73
74type AcpSessionSetupResponse =74type AcpSessionSetupResponse =
75 | EffectAcpSchema.ForkSessionResponse
75 | EffectAcpSchema.LoadSessionResponse76 | EffectAcpSchema.LoadSessionResponse
76 | EffectAcpSchema.NewSessionResponse77 | EffectAcpSchema.NewSessionResponse
77 | EffectAcpSchema.ResumeSessionResponse;78 | EffectAcpSchema.ResumeSessionResponse;
apps/server/src/provider/acp/AcpSessionRuntime.ts+216−41
@@ -47,7 +47,7 @@ export interface AcpSessionRuntimeOptions { · 46 unmodified lines
47 readonly name: string;47 readonly name: string;
48 readonly version: string;48 readonly version: string;
49 };49 };
50 readonly authMethodId: string;50 readonly authMethodId?: string;
51 readonly mcpServers?: ReadonlyArray<EffectAcpSchema.McpServer>;51 readonly mcpServers?: ReadonlyArray<EffectAcpSchema.McpServer>;
52 readonly requestLogger?: (event: AcpSessionRequestLogEvent) => Effect.Effect<void, never>;52 readonly requestLogger?: (event: AcpSessionRequestLogEvent) => Effect.Effect<void, never>;
53 readonly protocolLogging?: {53 readonly protocolLogging?: {
@@ -65,10 +65,26 @@ export interface AcpSessionRequestLogEvent { · 11 unmodified lines
65 readonly cause?: Cause.Cause<EffectAcpErrors.AcpError>;65 readonly cause?: Cause.Cause<EffectAcpErrors.AcpError>;
66}66}
67 67
68export 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
79function isAcpAuthenticationRequired(error: EffectAcpErrors.AcpError): boolean {
80 return error._tag === "AcpRequestError" && error.code === -32000;
81}
82
68export interface AcpSessionRuntimeStartResult {83export interface AcpSessionRuntimeStartResult {
69 readonly sessionId: string;84 readonly sessionId: string;
70 readonly initializeResult: EffectAcpSchema.InitializeResponse;85 readonly initializeResult: EffectAcpSchema.InitializeResponse;
71 readonly sessionSetupResult:86 readonly sessionSetupResult:
87 | EffectAcpSchema.ForkSessionResponse
72 | EffectAcpSchema.LoadSessionResponse88 | EffectAcpSchema.LoadSessionResponse
73 | EffectAcpSchema.NewSessionResponse89 | EffectAcpSchema.NewSessionResponse
74 | EffectAcpSchema.ResumeSessionResponse;90 | EffectAcpSchema.ResumeSessionResponse;
@@ -95,6 +111,21 @@ export interface AcpSessionRuntimeShape { · 20 unmodified lines
95 readonly getEvents: () => Stream.Stream<AcpParsedSessionEvent, never>;111 readonly getEvents: () => Stream.Stream<AcpParsedSessionEvent, never>;
96 readonly getModeState: Effect.Effect<AcpSessionModeState | undefined>;112 readonly getModeState: Effect.Effect<AcpSessionModeState | undefined>;
97 readonly getConfigOptions: Effect.Effect<ReadonlyArray<EffectAcpSchema.SessionConfigOption>>;113 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>;
98 readonly prompt: (129 readonly prompt: (
99 payload: Omit<EffectAcpSchema.PromptRequest, "sessionId">,130 payload: Omit<EffectAcpSchema.PromptRequest, "sessionId">,
100 ) => Effect.Effect<EffectAcpSchema.PromptResponse, EffectAcpErrors.AcpError>;131 ) => Effect.Effect<EffectAcpSchema.PromptResponse, EffectAcpErrors.AcpError>;
@@ -327,6 +358,7 @@ const makeAcpSessionRuntime = ( · 226 unmodified lines
327 const updateConfigOptions = (358 const updateConfigOptions = (
328 response:359 response:
329 | EffectAcpSchema.SetSessionConfigOptionResponse360 | EffectAcpSchema.SetSessionConfigOptionResponse
361 | EffectAcpSchema.ForkSessionResponse
330 | EffectAcpSchema.LoadSessionResponse362 | EffectAcpSchema.LoadSessionResponse
331 | EffectAcpSchema.NewSessionResponse363 | EffectAcpSchema.NewSessionResponse
332 | EffectAcpSchema.ResumeSessionResponse,364 | EffectAcpSchema.ResumeSessionResponse,
@@ -337,6 +369,30 @@ const makeAcpSessionRuntime = ( · 4 unmodified lines
337 current ? { ...current, currentModeId: modeId } : current,369 current ? { ...current, currentModeId: modeId } : current,
338 );370 );
339 371
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
340 const setConfigOption = (396 const setConfigOption = (
341 configId: string,397 configId: string,
342 value: string | boolean,398 value: string | boolean,
@@ -388,35 +444,80 @@ const makeAcpSessionRuntime = ( · 45 unmodified lines
388 acp.agent.initialize(initializePayload),444 acp.agent.initialize(initializePayload),
389 );445 );
390 446
391 const authenticatePayload = {447 const authenticateAfterRequired = (
392 methodId: options.authMethodId,448 authRequiredError: EffectAcpErrors.AcpError,
393 } satisfies EffectAcpSchema.AuthenticateRequest;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 }
394 472
395 yield* runLoggedRequest(473 const authMethodId = authMethod?.id ?? configuredAuthMethodId;
396 "authenticate",474 if (!authMethodId) {
397 authenticatePayload,475 return yield* authRequiredError;
398 acp.agent.authenticate(authenticatePayload),476 }
399 );
400 477
401 let sessionId: string;478 const authenticatePayload = {
402 let sessionSetupResult:479 methodId: authMethodId,
403 | EffectAcpSchema.LoadSessionResponse480 } satisfies EffectAcpSchema.AuthenticateRequest;
404 | EffectAcpSchema.NewSessionResponse481 yield* runLoggedRequest(
405 | EffectAcpSchema.ResumeSessionResponse;482 "authenticate",
406 if (options.resumeSessionId) {483 authenticatePayload,
407 const loadPayload = {484 acp.agent.authenticate(authenticatePayload),
408 sessionId: options.resumeSessionId,485 );
409 cwd: options.cwd,486 });
410 mcpServers: options.mcpServers ?? [],487
411 } satisfies EffectAcpSchema.LoadSessionRequest;488 const setupSession = Effect.gen(function* () {
412 const resumed = yield* runLoggedRequest(489 let sessionId: string;
413 "session/load",490 let sessionSetupResult:
414 loadPayload,491 | EffectAcpSchema.LoadSessionResponse
415 acp.agent.loadSession(loadPayload),492 | EffectAcpSchema.NewSessionResponse
416 ).pipe(Effect.exit);493 | EffectAcpSchema.ResumeSessionResponse;
417 if (Exit.isSuccess(resumed)) {494 if (options.resumeSessionId) {
418 sessionId = options.resumeSessionId;495 const loadPayload = {
419 sessionSetupResult = resumed.value;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 }
420 } else {521 } else {
421 const createPayload = {522 const createPayload = {
422 cwd: options.cwd,523 cwd: options.cwd,
@@ -430,19 +531,17 @@ const makeAcpSessionRuntime = ( · 7 unmodified lines
430 sessionId = created.sessionId;531 sessionId = created.sessionId;
431 sessionSetupResult = created;532 sessionSetupResult = created;
432 }533 }
433 } else {534
434 const createPayload = {535 return { sessionId, sessionSetupResult };
435 cwd: options.cwd,536 });
436 mcpServers: options.mcpServers ?? [],537
437 } satisfies EffectAcpSchema.NewSessionRequest;538 const { sessionId, sessionSetupResult } = yield* setupSession.pipe(
438 const created = yield* runLoggedRequest(539 Effect.catch((error) =>
439 "session/new",540 isAcpAuthenticationRequired(error)
440 createPayload,541 ? authenticateAfterRequired(error).pipe(Effect.andThen(setupSession))
441 acp.agent.createSession(createPayload),542 : Effect.fail(error),
442 );543 ),
443 sessionId = created.sessionId;544 );
444 sessionSetupResult = created;
445 }
446 545
447 yield* Ref.set(modeStateRef, parseSessionModeState(sessionSetupResult));546 yield* Ref.set(modeStateRef, parseSessionModeState(sessionSetupResult));
448 yield* Ref.set(configOptionsRef, sessionConfigOptionsFromSetup(sessionSetupResult));547 yield* Ref.set(configOptionsRef, sessionConfigOptionsFromSetup(sessionSetupResult));
@@ -508,6 +607,82 @@ const makeAcpSessionRuntime = ( · 59 unmodified lines
508 getEvents: () => Stream.fromQueue(eventQueue),607 getEvents: () => Stream.fromQueue(eventQueue),
509 getModeState: Ref.get(modeStateRef),608 getModeState: Ref.get(modeStateRef),
510 getConfigOptions: Ref.get(configOptionsRef),609 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 ),
511 prompt: (payload) =>686 prompt: (payload) =>
512 getStartedState.pipe(687 getStartedState.pipe(
513 Effect.flatMap((started) => {688 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
66 yield* runtime.setSessionModel(currentModelId);66 yield* runtime.setSessionModel(currentModelId);
67 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),67 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
68 );68 );
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 );
69});88});
apps/server/src/provider/acp/GrokAcpSupport.ts+1−4
@@ -23,10 +23,7 @@ const GROK_DRIVER_KIND = ProviderDriverKind.make("grok"); · 22 unmodified lines
23 23
24type GrokAcpRuntimeGrokSettings = Pick<GrokSettings, "binaryPath">;24type GrokAcpRuntimeGrokSettings = Pick<GrokSettings, "binaryPath">;
25 25
26interface GrokAcpRuntimeInput extends Omit<26interface GrokAcpRuntimeInput extends Omit<AcpSessionRuntimeOptions, "authMethodId" | "spawn"> {
27 AcpSessionRuntimeOptions,
28 "authMethodId" | "clientCapabilities" | "spawn"
29> {
30 readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"];27 readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"];
31 readonly grokSettings: GrokAcpRuntimeGrokSettings | null | undefined;28 readonly grokSettings: GrokAcpRuntimeGrokSettings | null | undefined;
32 readonly environment?: NodeJS.ProcessEnv;29 readonly environment?: NodeJS.ProcessEnv;
apps/server/src/provider/acp/XAiAcpExtension.ts+11−0
@@ -64,6 +64,17 @@ export function extractXAiAskUserQuestions( · 63 unmodified lines
64 }));64 }));
65}65}
66 66
67export 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
67interface XAiAskUserQuestionAnnotation {78interface XAiAskUserQuestionAnnotation {
68 readonly preview?: string;79 readonly preview?: string;
69 readonly notes?: string;80 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
13import { cn } from "../../lib/utils";13import { cn } from "../../lib/utils";
14import { normalizeProviderAccentColor } from "../../providerInstances";14import { normalizeProviderAccentColor } from "../../providerInstances";
15import { Button } from "../ui/button";15import { Button } from "../ui/button";
16import { ACPRegistryIcon, Gemini, GithubCopilotIcon, PiAgentIcon, type Icon } from "../Icons";16import { Gemini, GithubCopilotIcon, PiAgentIcon, type Icon } from "../Icons";
17import {17import {
18 Dialog,18 Dialog,
19 DialogDescription,19 DialogDescription,
@@ -81,11 +81,6 @@ const COMING_SOON_DRIVER_OPTIONS: readonly ComingSoonDriverOption[] = [ · 61 unmodified lines
81 label: "Gemini",81 label: "Gemini",
82 icon: Gemini,82 icon: Gemini,
83 },83 },
84 {
85 value: ProviderDriverKind.make("acpRegistry"),
86 label: "ACP Registry",
87 icon: ACPRegistryIcon,
88 },
89 {84 {
90 value: ProviderDriverKind.make("piAgent"),85 value: ProviderDriverKind.make("piAgent"),
91 label: "Pi Agent",86 label: "Pi Agent",
apps/web/src/components/settings/ProviderSettingsForm.test.ts+12−0
@@ -36,6 +36,18 @@ describe("ProviderSettingsForm helpers", () => { · 35 unmodified lines
36 });36 });
37 });37 });
38 38
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
39 it("preserves unknown config keys while omitting empty configurable fields", () => {51 it("preserves unknown config keys while omitting empty configurable fields", () => {
40 const opencode = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("opencode")];52 const opencode = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("opencode")];
41 expect(opencode).toBeDefined();53 expect(opencode).toBeDefined();
apps/web/src/components/settings/SettingsPanels.browser.tsx+29−0
@@ -1233,6 +1233,35 @@ describe("GeneralSettingsPanel observability", () => { · 1232 unmodified lines
1233 await expect.element(page.getByPlaceholder("Optional")).toBeInTheDocument();1233 await expect.element(page.getByPlaceholder("Optional")).toBeInTheDocument();
1234 });1234 });
1235 1235
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
1236 it("runs one-click provider updates from the provider card", async () => {1265 it("runs one-click provider updates from the provider card", async () => {
1237 const updateProvider = vi.fn<LocalApi["server"]["updateProvider"]>().mockResolvedValue({1266 const updateProvider = vi.fn<LocalApi["server"]["updateProvider"]>().mockResolvedValue({
1238 providers: [createOutdatedProvider("codex")],1267 providers: [createOutdatedProvider("codex")],
apps/web/src/components/settings/SettingsPanels.tsx+10−3
@@ -121,6 +121,7 @@ function withoutProviderInstanceFavorites( · 120 unmodified lines
121 121
122const PROVIDER_SETTINGS = DRIVER_OPTIONS.map((definition) => ({122const PROVIDER_SETTINGS = DRIVER_OPTIONS.map((definition) => ({
123 provider: definition.value,123 provider: definition.value,
124 hasDefaultInstance: definition.hasDefaultInstance !== false,
124}));125}));
125 126
126function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | null }) {127function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | null }) {
@@ -1032,9 +1033,9 @@ export function ProviderSettingsPanel() { · 905 unmodified lines
1032 }1033 }
1033 1034
1034 const defaultSlotIdsBySource = new Set<string>(1035 const defaultSlotIdsBySource = new Set<string>(
1035 visibleProviderSettings.map((providerSettings) =>1036 visibleProviderSettings
1036 String(defaultInstanceIdForDriver(providerSettings.provider)),1037 .filter((providerSettings) => providerSettings.hasDefaultInstance)
1037 ),1038 .map((providerSettings) => String(defaultInstanceIdForDriver(providerSettings.provider))),
1038 );1039 );
1039 1040
1040 const rows: InstanceRow[] = [];1041 const rows: InstanceRow[] = [];
@@ -1043,6 +1044,12 @@ export function ProviderSettingsPanel() { · 2 unmodified lines
1043 );1044 );
1044 1045
1045 for (const providerSettings of visibleProviderSettings) {1046 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 }
1046 type LegacyProviderSettings = (typeof settings.providers)[keyof typeof settings.providers];1053 type LegacyProviderSettings = (typeof settings.providers)[keyof typeof settings.providers];
1047 const legacyProviders = settings.providers as Record<string, LegacyProviderSettings>;1054 const legacyProviders = settings.providers as Record<string, LegacyProviderSettings>;
1048 const defaultLegacyProviders = DEFAULT_UNIFIED_SETTINGS.providers as Record<1055 const defaultLegacyProviders = DEFAULT_UNIFIED_SETTINGS.providers as Record<
apps/web/src/components/settings/providerDriverMeta.ts+20−1
@@ -1,4 +1,5 @@
1import {1import {
2 AcpRegistrySettings,
2 ClaudeSettings,3 ClaudeSettings,
3 CodexSettings,4 CodexSettings,
4 CursorSettings,5 CursorSettings,
@@ -7,7 +8,15 @@ import { · 2 unmodified lines
7 ProviderDriverKind,8 ProviderDriverKind,
8} from "@t3tools/contracts";9} from "@t3tools/contracts";
9import type * as Schema from "effect/Schema";10import type * as Schema from "effect/Schema";
10import { ClaudeAI, CursorIcon, GrokIcon, type Icon, OpenAI, OpenCodeIcon } from "../Icons";11import {
12 ACPRegistryIcon,
13 ClaudeAI,
14 CursorIcon,
15 GrokIcon,
16 type Icon,
17 OpenAI,
18 OpenCodeIcon,
19} from "../Icons";
11 20
12type ProviderSettingsSchema = {21type ProviderSettingsSchema = {
13 readonly fields: Readonly<Record<string, Schema.Top>>;22 readonly fields: Readonly<Record<string, Schema.Top>>;
@@ -24,6 +33,8 @@ export interface ProviderClientDefinition { · 10 unmodified lines
24 readonly label: string;33 readonly label: string;
25 readonly icon: Icon;34 readonly icon: Icon;
26 readonly settingsSchema: ProviderSettingsSchema;35 readonly settingsSchema: ProviderSettingsSchema;
36 /** Whether this driver has a built-in default instance backed by legacy settings. */
37 readonly hasDefaultInstance?: boolean;
27 /**38 /**
28 * Optional short label rendered as a `variant="warning"` badge next to39 * Optional short label rendered as a `variant="warning"` badge next to
29 * the instance title. Used to flag drivers that still ship under an40 * 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
61 badgeLabel: "Early Access",72 badgeLabel: "Early Access",
62 settingsSchema: GrokSettings,73 settingsSchema: GrokSettings,
63 },74 },
75 {
76 value: ProviderDriverKind.make("acpRegistry"),
77 label: "ACP Registry",
78 icon: ACPRegistryIcon,
79 badgeLabel: "V2 Preview",
80 settingsSchema: AcpRegistrySettings,
81 hasDefaultInstance: false,
82 },
64 {83 {
65 value: ProviderDriverKind.make("opencode"),84 value: ProviderDriverKind.make("opencode"),
66 label: "OpenCode",85 label: "OpenCode",
apps/web/src/orchestrationV2DebugProviders.test.ts+82−0
@@ -0,0 +1,82 @@
1import {
2 ProviderDriverKind,
3 ProviderInstanceId,
4 type ProviderInstanceConfigMap,
5 type ServerProvider,
6} from "@t3tools/contracts";
7import { describe, expect, it } from "vite-plus/test";
8
9import { deriveOrchestrationV2DebugProviderSnapshots } from "./orchestrationV2DebugProviders";
10
11const acpRegistry = ProviderDriverKind.make("acpRegistry");
12const instanceId = ProviderInstanceId.make("acpRegistry_example");
13
14const 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
31describe("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 @@
1import {
2 ProviderDriverKind,
3 type ProviderInstanceConfigMap,
4 type ProviderInstanceId,
5 type ServerProvider,
6 type ServerProviderModel,
7} from "@t3tools/contracts";
8
9const ACP_REGISTRY_DRIVER = ProviderDriverKind.make("acpRegistry");
10const DEBUG_CHECKED_AT = "1970-01-01T00:00:00.000Z";
11
12function 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
18function 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 */
40export 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
36} from "../lib/orchestrationV2Timeline";36} from "../lib/orchestrationV2Timeline";
37import { newCommandId, newMessageId, newProjectId, newThreadId } from "../lib/utils";37import { newCommandId, newMessageId, newProjectId, newThreadId } from "../lib/utils";
38import { type AppModelOption, getAppModelOptionsForInstance } from "../modelSelection";38import { type AppModelOption, getAppModelOptionsForInstance } from "../modelSelection";
39import { deriveOrchestrationV2DebugProviderSnapshots } from "../orchestrationV2DebugProviders";
39import {40import {
40 type ProviderInstanceEntry,41 type ProviderInstanceEntry,
41 deriveProviderInstanceEntries,42 deriveProviderInstanceEntries,
@@ -1621,7 +1622,14 @@ function OrchestrationV2DebugRoute() { · 1579 unmodified lines
1621 const serverProviders = useServerProviders();1622 const serverProviders = useServerProviders();
1622 const settings = useSettings();1623 const settings = useSettings();
1623 const keybindings = useServerKeybindings();1624 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 );
1625 const providerInstanceEntries = useMemo<ReadonlyArray<ProviderInstanceEntry>>(1633 const providerInstanceEntries = useMemo<ReadonlyArray<ProviderInstanceEntry>>(
1626 () => sortProviderInstanceEntries(deriveProviderInstanceEntries(providerSnapshots)),1634 () => sortProviderInstanceEntries(deriveProviderInstanceEntries(providerSnapshots)),
1627 [providerSnapshots],1635 [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
90`mcpServers` agent and send options. The adapter passes the authorization header90`mcpServers` agent and send options. The adapter passes the authorization header
91to the SDK but projects only redacted option metadata into protocol diagnostics.91to the SDK but projects only redacted option metadata into protocol diagnostics.
92 92
93### Grok ACP V2
94
95Grok receives the authenticated HTTP MCP endpoint through the ACP
96`session/new`, `session/load`, and `session/fork` `mcpServers` field. The shared
97ACP adapter owns standard protocol behavior; the Grok flavor adds xAI extension
98requests such as structured user questions.
99
100ACP does not define native subagents or active steering. Grok therefore uses
101orchestrator-owned child threads and implements steering through
102cancel-and-restart. Its current driver also lacks `session/fork`, so app forks
103use portable context transfer. These are orchestrator policies, not
104provider-specific MCP tools.
105
106### ACP Registry V2
107
108The `acpRegistry` driver is the generic flavor of the same shared ACP adapter.
109Each provider instance names an agent from the official ACP Registry. At
110session startup the driver resolves the current platform distribution, uses a
111managed binary cache or the declared `npx`/`uvx` package, and then negotiates
112standard ACP capabilities during `initialize`. A local executable may override
113the managed command without changing the registry-declared arguments or
114environment.
115
116Capabilities such as session loading, session forking, models, modes, and MCP
117transport are enabled only when the selected agent advertises them. Missing
118features degrade through V2 policy: steering uses interrupt-and-restart,
119forking uses portable context when native `session/fork` is unavailable, and
120subagents use orchestrator-owned child threads. Registry agents do not receive
121provider-specific extensions; those remain in flavors such as Grok.
122
93### Initial Provider Support123### Initial Provider Support
94 124
95The V2 provider adapters are Codex, Claude Agent SDK, and Cursor Agent SDK.125The V2 provider adapters are Codex, Claude Agent SDK, Cursor Agent SDK, and
126Grok plus generic registry agents over ACP.
96Capability discovery still reports other registered provider instances, but marks them127Capability discovery still reports other registered provider instances, but marks them
97unavailable for orchestration when no V2 adapter exists. This keeps provider128unavailable for orchestration when no V2 adapter exists. This keeps provider
98selection model-visible without allowing a request that cannot run.129selection model-visible without allowing a request that cannot run.
@@ -299,6 +330,6 @@ Coverage includes: · 200 unmodified lines
299- inheritance and per-thread provider overrides; and330- inheritance and per-thread provider overrides; and
300- idempotent retries.331- idempotent retries.
301 332
302Provider adapter tests separately verify Codex, Claude, and Cursor MCP injection, and333Provider adapter tests separately verify Codex, Claude, Cursor, Grok, and ACP
303the provider-session manager test verifies that credentials exist before an334Registry behavior and MCP injection. The provider-session manager test verifies
304adapter opens and are revoked when it closes.335that 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
122Cursor replay transcript122Cursor replay transcript
123 -> Cursor Agent SDK open/send calls and ordered onDelta/run results123 -> Cursor Agent SDK open/send calls and ordered onDelta/run results
124 -> consumed by CursorAdapter124 -> consumed by CursorAdapter
125
126OpenCode replay transcript
127 -> OpenCode SDK requests/responses plus ordered SSE events
128 -> consumed by OpenCodeAdapter
129
130ACP 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)
125```133```
126 134
127Fixtures should preserve raw provider evidence as closely as possible. Expected V2 events or projections are assertions, not fixture input.135Fixtures 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
131`SDKMessage` chunks in provider order. This intentionally tests the V2 adapter against real Claude139`SDKMessage` chunks in provider order. This intentionally tests the V2 adapter against real Claude
132SDK output; it does not test the SDK's own subprocess or transport parser.140SDK output; it does not test the SDK's own subprocess or transport parser.
133 141
142ACP fixtures run against a child-process replay peer that validates every outbound frame and
143serves recorded inbound frames. This replaces only the external ACP driver transport; the shared
144runtime, adapter normalization, orchestration, persistence, and projections remain production code.
145The generic ACP Registry harness retargets protocol-standard ACP transcripts;
146provider-extension transcripts remain scoped to the flavor that owns the extension.
147
148OpenCode fixtures replace the SDK client at its HTTP/SSE boundary. They must preserve races between
149request responses and SSE events, because user messages and terminal `session.status` events can
150arrive before the corresponding `promptAsync` or `abort` response. Subagent fixtures must retain
151both parent and child session ids so root-only terminal behavior remains testable.
152
134Provider transcript recorders live with the server orchestration testkit, not with provider client153Provider transcript recorders live with the server orchestration testkit, not with provider client
135packages. Use `bun run record:codex-replay -- --scenario <name>` for Codex app-server transcripts154packages. Use `bun run record:codex-replay -- --scenario <name>` for Codex app-server transcripts
136and `bun run record:claude-replay -- --scenario <name>` for Claude Agent SDK transcripts. Use155and `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
2062. Effect service definitions.2252. Effect service definitions.
2073. provider runtime transport abstraction.2263. provider runtime transport abstraction.
2084. generic replay runtime.2274. generic replay runtime.
2095. Codex, Claude, and Cursor transcript loaders for app-owned provider replay fixtures.2285. Codex, Claude, Cursor, OpenCode, and ACP transcript loaders for app-owned provider replay fixtures.
2106. projection reducer tests for core invariants.2296. projection reducer tests for core invariants.
2117. full command-to-projection integration tests.2307. full command-to-projection integration tests.
2128. production layers.2318. production layers.
packages/contracts/src/model.ts+3−0
@@ -131,6 +131,7 @@ const CODEX_DRIVER_KIND = ProviderDriverKind.make("codex"); · 130 unmodified lines
131const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent");131const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent");
132const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor");132const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor");
133const GROK_DRIVER_KIND = ProviderDriverKind.make("grok");133const GROK_DRIVER_KIND = ProviderDriverKind.make("grok");
134const ACP_REGISTRY_DRIVER_KIND = ProviderDriverKind.make("acpRegistry");
134const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode");135const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode");
135 136
136export const DEFAULT_MODEL = "gpt-5.4";137export const DEFAULT_MODEL = "gpt-5.4";
@@ -141,6 +142,7 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial<Record<ProviderDriverKind, strin · 4 unmodified lines
141 [CLAUDE_DRIVER_KIND]: "claude-sonnet-4-6",142 [CLAUDE_DRIVER_KIND]: "claude-sonnet-4-6",
142 [CURSOR_DRIVER_KIND]: "auto",143 [CURSOR_DRIVER_KIND]: "auto",
143 [GROK_DRIVER_KIND]: "grok-build",144 [GROK_DRIVER_KIND]: "grok-build",
145 [ACP_REGISTRY_DRIVER_KIND]: "default",
144 [OPENCODE_DRIVER_KIND]: "openai/gpt-5",146 [OPENCODE_DRIVER_KIND]: "openai/gpt-5",
145};147};
146 148
@@ -204,5 +206,6 @@ export const PROVIDER_DISPLAY_NAMES: Partial<Record<ProviderDriverKind, string>> · 57 unmodified lines
204 [CLAUDE_DRIVER_KIND]: "Claude",206 [CLAUDE_DRIVER_KIND]: "Claude",
205 [CURSOR_DRIVER_KIND]: "Cursor",207 [CURSOR_DRIVER_KIND]: "Cursor",
206 [GROK_DRIVER_KIND]: "Grok",208 [GROK_DRIVER_KIND]: "Grok",
209 [ACP_REGISTRY_DRIVER_KIND]: "ACP Registry",
207 [OPENCODE_DRIVER_KIND]: "OpenCode",210 [OPENCODE_DRIVER_KIND]: "OpenCode",
208};211};
packages/contracts/src/settings.ts+50−0
@@ -305,6 +305,56 @@ export const GrokSettings = makeProviderSettingsSchema( · 304 unmodified lines
305);305);
306export type GrokSettings = typeof GrokSettings.Type;306export type GrokSettings = typeof GrokSettings.Type;
307 307
308export const AcpRegistryDistributionPreference = Schema.Literals(["auto", "binary", "npx", "uvx"]);
309export type AcpRegistryDistributionPreference = typeof AcpRegistryDistributionPreference.Type;
310
311export 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);
356export type AcpRegistrySettings = typeof AcpRegistrySettings.Type;
357
308export const OpenCodeSettings = makeProviderSettingsSchema(358export const OpenCodeSettings = makeProviderSettingsSchema(
309 {359 {
310 enabled: Schema.Boolean.pipe(360 enabled: Schema.Boolean.pipe(