- 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
Unified · Split
| @@ -23,6 +23,9 @@ const failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1"; · 22 unmodified lines | ||
| 23 | 23 | const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1"; |
| 24 | 24 | const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT; |
| 25 | 25 | const promptDelayMs = Number(process.env.T3_ACP_PROMPT_DELAY_MS ?? "0"); |
| 26 | +const supportsSessionLifecycle = process.env.T3_ACP_SESSION_LIFECYCLE === "1"; | |
| 27 | +const advertisedAuthMethodId = process.env.T3_ACP_AUTH_METHOD_ID?.trim(); | |
| 28 | +const requiresAuthentication = process.env.T3_ACP_REQUIRE_AUTH === "1"; | |
| 26 | 29 | const permissionOptionIds = { |
| 27 | 30 | allowOnce: process.env.T3_ACP_ALLOW_ONCE_OPTION_ID ?? "allow-once", |
| 28 | 31 | allowAlways: process.env.T3_ACP_ALLOW_ALWAYS_OPTION_ID ?? "allow-always", |
| @@ -36,6 +39,7 @@ let parameterizedModelPicker = false; · 7 unmodified lines | ||
| 36 | 39 | let currentReasoning = "medium"; |
| 37 | 40 | let currentContext = "272k"; |
| 38 | 41 | let currentFast = false; |
| 42 | +let authenticated = !requiresAuthentication; | |
| 39 | 43 | const cancelledSessions = new Set<string>(); |
| 40 | 44 | |
| 41 | 45 | function logExit(reason: string): void { |
| @@ -268,19 +272,56 @@ const program = Effect.gen(function* () { · 226 unmodified lines | ||
| 268 | 272 | request.clientCapabilities?._meta?.parameterizedModelPicker === true; |
| 269 | 273 | return { |
| 270 | 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 | 314 | yield* agent.handleCreateSession(() => |
| 279 | − Effect.succeed({ | |
| 280 | − sessionId, | |
| 281 | − modes: modeState(), | |
| 282 | − models: modelState(), | |
| 283 | − configOptions: configOptions(), | |
| 315 | + Effect.gen(function* () { | |
| 316 | + if (!authenticated) { | |
| 317 | + return yield* AcpError.AcpRequestError.authRequired(); | |
| 318 | + } | |
| 319 | + return { | |
| 320 | + sessionId, | |
| 321 | + modes: modeState(), | |
| 322 | + models: modelState(), | |
| 323 | + configOptions: configOptions(), | |
| 324 | + }; | |
| 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 | 378 | yield* agent.handleSetSessionModel((request) => |
| 306 | 379 | Effect.gen(function* () { |
| 307 | 380 | if (!grokAcpModels.some((model) => model.modelId === request.modelId)) { |
| @@ -0,0 +1,285 @@ | ||
| 1 | +#!/usr/bin/env node | |
| 2 | +// @effect-diagnostics nodeBuiltinImport:off | |
| 3 | +import { writeFileSync } from "node:fs"; | |
| 4 | +import { createInterface } from "node:readline"; | |
| 5 | + | |
| 6 | +interface ReplayEntry { | |
| 7 | + readonly type: "emit_inbound" | "expect_outbound" | "runtime_exit"; | |
| 8 | + readonly label?: string; | |
| 9 | + readonly frame?: unknown; | |
| 10 | + readonly status?: "success" | "error" | "cancelled"; | |
| 11 | + readonly error?: unknown; | |
| 12 | +} | |
| 13 | + | |
| 14 | +interface ReplayTranscript { | |
| 15 | + readonly scenario: string; | |
| 16 | + readonly entries: ReadonlyArray<ReplayEntry>; | |
| 17 | +} | |
| 18 | + | |
| 19 | +interface LogicalFrame { | |
| 20 | + readonly kind: "notification" | "request" | "response"; | |
| 21 | + readonly method: string; | |
| 22 | + readonly params?: unknown; | |
| 23 | + readonly result?: unknown; | |
| 24 | + readonly error?: unknown; | |
| 25 | +} | |
| 26 | + | |
| 27 | +interface JsonRpcMessage { | |
| 28 | + readonly jsonrpc?: string; | |
| 29 | + readonly id?: string | number | null; | |
| 30 | + readonly method?: string; | |
| 31 | + readonly params?: unknown; | |
| 32 | + readonly result?: unknown; | |
| 33 | + readonly error?: unknown; | |
| 34 | + readonly headers?: ReadonlyArray<unknown>; | |
| 35 | +} | |
| 36 | + | |
| 37 | +const encodedTranscript = process.env.T3_ACP_REPLAY_TRANSCRIPT; | |
| 38 | +const statusPath = process.env.T3_ACP_REPLAY_STATUS_PATH; | |
| 39 | +const replayWorkspace = process.env.T3_ACP_REPLAY_WORKSPACE ?? process.cwd(); | |
| 40 | + | |
| 41 | +if (encodedTranscript === undefined || statusPath === undefined) { | |
| 42 | + process.stderr.write("ACP replay requires transcript and status environment variables.\n"); | |
| 43 | + process.exit(2); | |
| 44 | +} | |
| 45 | + | |
| 46 | +const replayStatusPath = statusPath; | |
| 47 | +const transcript = JSON.parse( | |
| 48 | + Buffer.from(encodedTranscript, "base64").toString("utf8"), | |
| 49 | +) as ReplayTranscript; | |
| 50 | +let cursor = 0; | |
| 51 | +let stopped = false; | |
| 52 | +let nextAgentRequestId = 1; | |
| 53 | +const pendingClientRequestIds = new Map<string, string | number>(); | |
| 54 | +const pendingAgentRequestMethods = new Map<string, string>(); | |
| 55 | + | |
| 56 | +function writeStatus(failure?: unknown): void { | |
| 57 | + writeFileSync( | |
| 58 | + replayStatusPath, | |
| 59 | + JSON.stringify({ | |
| 60 | + scenario: transcript.scenario, | |
| 61 | + cursor, | |
| 62 | + total: transcript.entries.length, | |
| 63 | + ...(failure === undefined ? {} : { failure }), | |
| 64 | + }), | |
| 65 | + "utf8", | |
| 66 | + ); | |
| 67 | +} | |
| 68 | + | |
| 69 | +function stableStringify(value: unknown): string { | |
| 70 | + if (Array.isArray(value)) { | |
| 71 | + return `[${value.map(stableStringify).join(",")}]`; | |
| 72 | + } | |
| 73 | + if (typeof value === "object" && value !== null) { | |
| 74 | + const record = value as Record<string, unknown>; | |
| 75 | + return `{${Object.keys(record) | |
| 76 | + .toSorted() | |
| 77 | + .map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`) | |
| 78 | + .join(",")}}`; | |
| 79 | + } | |
| 80 | + return JSON.stringify(value); | |
| 81 | +} | |
| 82 | + | |
| 83 | +function expandExpectedString(value: string): string { | |
| 84 | + return value.replaceAll("<workspace>", replayWorkspace); | |
| 85 | +} | |
| 86 | + | |
| 87 | +function matchesExpected(expected: unknown, actual: unknown): boolean { | |
| 88 | + if (expected === "<any>") return true; | |
| 89 | + if (typeof expected === "string" && typeof actual === "string") { | |
| 90 | + const expanded = expandExpectedString(expected); | |
| 91 | + if (!expanded.includes("<any>")) return expanded === actual; | |
| 92 | + const parts = expanded.split("<any>"); | |
| 93 | + let offset = 0; | |
| 94 | + for (const part of parts) { | |
| 95 | + const index = actual.indexOf(part, offset); | |
| 96 | + if (index === -1) return false; | |
| 97 | + offset = index + part.length; | |
| 98 | + } | |
| 99 | + return true; | |
| 100 | + } | |
| 101 | + if (Array.isArray(expected)) { | |
| 102 | + return ( | |
| 103 | + Array.isArray(actual) && | |
| 104 | + expected.length === actual.length && | |
| 105 | + expected.every((entry, index) => matchesExpected(entry, actual[index])) | |
| 106 | + ); | |
| 107 | + } | |
| 108 | + if (typeof expected === "object" && expected !== null) { | |
| 109 | + if (typeof actual !== "object" || actual === null || Array.isArray(actual)) return false; | |
| 110 | + const expectedRecord = expected as Record<string, unknown>; | |
| 111 | + const actualRecord = actual as Record<string, unknown>; | |
| 112 | + const expectedKeys = Object.keys(expectedRecord).toSorted(); | |
| 113 | + const actualKeys = Object.keys(actualRecord).toSorted(); | |
| 114 | + return ( | |
| 115 | + stableStringify(expectedKeys) === stableStringify(actualKeys) && | |
| 116 | + expectedKeys.every((key) => matchesExpected(expectedRecord[key], actualRecord[key])) | |
| 117 | + ); | |
| 118 | + } | |
| 119 | + return Object.is(expected, actual); | |
| 120 | +} | |
| 121 | + | |
| 122 | +function stopWithFailure(detail: string, actual?: unknown): void { | |
| 123 | + if (stopped) return; | |
| 124 | + stopped = true; | |
| 125 | + const entry = transcript.entries[cursor]; | |
| 126 | + const failure = { | |
| 127 | + detail, | |
| 128 | + cursor, | |
| 129 | + expected: entry, | |
| 130 | + ...(actual === undefined ? {} : { actual }), | |
| 131 | + }; | |
| 132 | + writeStatus(failure); | |
| 133 | + process.stderr.write(`ACP replay mismatch: ${JSON.stringify(failure)}\n`); | |
| 134 | + process.exitCode = 1; | |
| 135 | + process.stdin.pause(); | |
| 136 | +} | |
| 137 | + | |
| 138 | +function advance(): void { | |
| 139 | + cursor += 1; | |
| 140 | + writeStatus(); | |
| 141 | +} | |
| 142 | + | |
| 143 | +function send(message: JsonRpcMessage): void { | |
| 144 | + process.stdout.write(`${JSON.stringify(message)}\n`); | |
| 145 | +} | |
| 146 | + | |
| 147 | +function pendingClientRequestId(method: string): string | number | undefined { | |
| 148 | + return pendingClientRequestIds.get(method); | |
| 149 | +} | |
| 150 | + | |
| 151 | +function logicalIncoming(message: JsonRpcMessage): LogicalFrame | undefined { | |
| 152 | + if (typeof message.method === "string") { | |
| 153 | + return { | |
| 154 | + kind: | |
| 155 | + message.id === undefined || message.id === null || message.id === "" | |
| 156 | + ? "notification" | |
| 157 | + : "request", | |
| 158 | + method: message.method, | |
| 159 | + ...(message.params === undefined ? {} : { params: message.params }), | |
| 160 | + }; | |
| 161 | + } | |
| 162 | + if (message.id === undefined || message.id === null) return undefined; | |
| 163 | + const method = pendingAgentRequestMethods.get(String(message.id)); | |
| 164 | + if (method === undefined) return undefined; | |
| 165 | + return { | |
| 166 | + kind: "response", | |
| 167 | + method, | |
| 168 | + ...(message.result === undefined ? {} : { result: message.result }), | |
| 169 | + ...(message.error === undefined ? {} : { error: message.error }), | |
| 170 | + }; | |
| 171 | +} | |
| 172 | + | |
| 173 | +function emitInbound(frame: LogicalFrame): void { | |
| 174 | + switch (frame.kind) { | |
| 175 | + case "notification": | |
| 176 | + send({ | |
| 177 | + jsonrpc: "2.0", | |
| 178 | + method: frame.method, | |
| 179 | + ...(frame.params === undefined ? {} : { params: frame.params }), | |
| 180 | + }); | |
| 181 | + return; | |
| 182 | + case "request": { | |
| 183 | + const id = nextAgentRequestId; | |
| 184 | + nextAgentRequestId += 1; | |
| 185 | + pendingAgentRequestMethods.set(String(id), frame.method); | |
| 186 | + send({ | |
| 187 | + jsonrpc: "2.0", | |
| 188 | + id, | |
| 189 | + method: frame.method, | |
| 190 | + ...(frame.params === undefined ? {} : { params: frame.params }), | |
| 191 | + headers: [], | |
| 192 | + }); | |
| 193 | + return; | |
| 194 | + } | |
| 195 | + case "response": { | |
| 196 | + const id = pendingClientRequestId(frame.method); | |
| 197 | + if (id === undefined) { | |
| 198 | + stopWithFailure(`No pending client request for ${frame.method}`, frame); | |
| 199 | + return; | |
| 200 | + } | |
| 201 | + pendingClientRequestIds.delete(frame.method); | |
| 202 | + send({ | |
| 203 | + jsonrpc: "2.0", | |
| 204 | + id, | |
| 205 | + ...(frame.result === undefined ? {} : { result: frame.result }), | |
| 206 | + ...(frame.error === undefined ? {} : { error: frame.error }), | |
| 207 | + }); | |
| 208 | + } | |
| 209 | + } | |
| 210 | +} | |
| 211 | + | |
| 212 | +function flushInbound(): void { | |
| 213 | + while (!stopped) { | |
| 214 | + const entry = transcript.entries[cursor]; | |
| 215 | + if (entry === undefined || entry.type === "expect_outbound") return; | |
| 216 | + if (entry.type === "runtime_exit") { | |
| 217 | + if (entry.status !== "success") { | |
| 218 | + stopWithFailure(`Recorded runtime exit was ${entry.status ?? "unknown"}`, entry.error); | |
| 219 | + return; | |
| 220 | + } | |
| 221 | + advance(); | |
| 222 | + continue; | |
| 223 | + } | |
| 224 | + const frame = entry.frame as LogicalFrame; | |
| 225 | + if ( | |
| 226 | + typeof frame !== "object" || | |
| 227 | + frame === null || | |
| 228 | + !["notification", "request", "response"].includes(frame.kind) || | |
| 229 | + typeof frame.method !== "string" | |
| 230 | + ) { | |
| 231 | + stopWithFailure("Invalid emit_inbound logical ACP frame", entry.frame); | |
| 232 | + return; | |
| 233 | + } | |
| 234 | + emitInbound(frame); | |
| 235 | + if (stopped) return; | |
| 236 | + advance(); | |
| 237 | + } | |
| 238 | +} | |
| 239 | + | |
| 240 | +function handleMessage(message: JsonRpcMessage): void { | |
| 241 | + if (stopped) return; | |
| 242 | + const actual = logicalIncoming(message); | |
| 243 | + if (actual === undefined) { | |
| 244 | + stopWithFailure("Could not identify outbound ACP frame", message); | |
| 245 | + return; | |
| 246 | + } | |
| 247 | + const entry = transcript.entries[cursor]; | |
| 248 | + if (entry?.type !== "expect_outbound" || !matchesExpected(entry.frame, actual)) { | |
| 249 | + if (actual.kind === "request" && message.id !== undefined && message.id !== null) { | |
| 250 | + send({ | |
| 251 | + jsonrpc: "2.0", | |
| 252 | + id: message.id, | |
| 253 | + error: { code: -32603, message: "ACP replay frame mismatch" }, | |
| 254 | + }); | |
| 255 | + } | |
| 256 | + stopWithFailure("Unexpected outbound ACP frame", actual); | |
| 257 | + return; | |
| 258 | + } | |
| 259 | + if (actual.kind === "request" && message.id !== undefined && message.id !== null) { | |
| 260 | + pendingClientRequestIds.set(actual.method, message.id); | |
| 261 | + } else if (actual.kind === "response" && message.id !== undefined && message.id !== null) { | |
| 262 | + pendingAgentRequestMethods.delete(String(message.id)); | |
| 263 | + } | |
| 264 | + advance(); | |
| 265 | + flushInbound(); | |
| 266 | +} | |
| 267 | + | |
| 268 | +writeStatus(); | |
| 269 | +flushInbound(); | |
| 270 | + | |
| 271 | +const input = createInterface({ input: process.stdin, crlfDelay: Infinity }); | |
| 272 | +input.on("line", (line) => { | |
| 273 | + if (stopped || line.trim().length === 0) return; | |
| 274 | + try { | |
| 275 | + handleMessage(JSON.parse(line) as JsonRpcMessage); | |
| 276 | + } catch (cause) { | |
| 277 | + stopWithFailure("Failed to decode outbound ACP JSON-RPC", String(cause)); | |
| 278 | + } | |
| 279 | +}); | |
| 280 | + | |
| 281 | +input.on("close", () => { | |
| 282 | + if (!stopped && cursor !== transcript.entries.length) { | |
| 283 | + stopWithFailure("ACP replay input closed before transcript completion"); | |
| 284 | + } | |
| 285 | +}); | |
| @@ -0,0 +1,170 @@ | ||
| 1 | +import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | +import { assert, it } from "@effect/vitest"; | |
| 3 | +import { | |
| 4 | + CommandId, | |
| 5 | + type ModelSelection, | |
| 6 | + MessageId, | |
| 7 | + ProjectId, | |
| 8 | + ProviderDriverKind, | |
| 9 | + ProviderInstanceId, | |
| 10 | + ThreadId, | |
| 11 | +} from "@t3tools/contracts"; | |
| 12 | +import * as Console from "effect/Console"; | |
| 13 | +import * as Effect from "effect/Effect"; | |
| 14 | +import * as Layer from "effect/Layer"; | |
| 15 | +import { describe } from "vite-plus/test"; | |
| 16 | + | |
| 17 | +import { CheckpointStoreLive } from "../checkpointing/Layers/CheckpointStore.ts"; | |
| 18 | +import { ServerConfig } from "../config.ts"; | |
| 19 | +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; | |
| 20 | +import { ServerSettingsService } from "../serverSettings.ts"; | |
| 21 | +import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; | |
| 22 | +import * as VcsProcess from "../vcs/VcsProcess.ts"; | |
| 23 | +import { OrchestratorV2 } from "./Orchestrator.ts"; | |
| 24 | +import { OrchestrationV2LayerLive } from "./runtimeLayer.ts"; | |
| 25 | + | |
| 26 | +const liveAgentId = process.env.T3_ACP_REGISTRY_LIVE_AGENT_ID?.trim() || "devin"; | |
| 27 | +const liveCommandPath = process.env.T3_ACP_REGISTRY_LIVE_COMMAND?.trim(); | |
| 28 | +const liveInstanceId = ProviderInstanceId.make("acpRegistry_live"); | |
| 29 | +const liveModelSelection = { | |
| 30 | + instanceId: liveInstanceId, | |
| 31 | + model: "default", | |
| 32 | +} satisfies ModelSelection; | |
| 33 | + | |
| 34 | +const serverConfigLayer = ServerConfig.layerTest(process.cwd(), { | |
| 35 | + prefix: "t3-acp-registry-v2-live-", | |
| 36 | +}); | |
| 37 | + | |
| 38 | +const vcsDriverRegistryLayer = VcsDriverRegistry.layer.pipe( | |
| 39 | + Layer.provide(VcsProcess.layer), | |
| 40 | + Layer.provide(serverConfigLayer), | |
| 41 | + Layer.provide(NodeServices.layer), | |
| 42 | +); | |
| 43 | + | |
| 44 | +const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(vcsDriverRegistryLayer)); | |
| 45 | + | |
| 46 | +const liveLayer = OrchestrationV2LayerLive.pipe( | |
| 47 | + Layer.provide(SqlitePersistenceMemory), | |
| 48 | + Layer.provide(checkpointStoreLayer), | |
| 49 | + Layer.provide(serverConfigLayer), | |
| 50 | + Layer.provide( | |
| 51 | + ServerSettingsService.layerTest({ | |
| 52 | + providerInstances: { | |
| 53 | + [liveInstanceId]: { | |
| 54 | + driver: ProviderDriverKind.make("acpRegistry"), | |
| 55 | + displayName: `ACP Registry: ${liveAgentId}`, | |
| 56 | + enabled: true, | |
| 57 | + config: { | |
| 58 | + agentId: liveAgentId, | |
| 59 | + ...(liveCommandPath ? { commandPath: liveCommandPath } : {}), | |
| 60 | + }, | |
| 61 | + }, | |
| 62 | + }, | |
| 63 | + }), | |
| 64 | + ), | |
| 65 | + Layer.provide(NodeServices.layer), | |
| 66 | +); | |
| 67 | + | |
| 68 | +const waitForIdle = Effect.fn("AcpRegistryOrchestratorV2Live.waitForIdle")(function* ( | |
| 69 | + threadId: ThreadId, | |
| 70 | + expectedRunCount: number, | |
| 71 | +) { | |
| 72 | + const orchestrator = yield* OrchestratorV2; | |
| 73 | + for (let attempt = 0; attempt < 900; attempt += 1) { | |
| 74 | + const projection = yield* orchestrator.getThreadProjection(threadId); | |
| 75 | + if ( | |
| 76 | + projection.runs.length >= expectedRunCount && | |
| 77 | + projection.runs.every( | |
| 78 | + (run) => !["queued", "starting", "running", "waiting"].includes(run.status), | |
| 79 | + ) | |
| 80 | + ) { | |
| 81 | + return projection; | |
| 82 | + } | |
| 83 | + yield* Effect.sleep("500 millis"); | |
| 84 | + } | |
| 85 | + return yield* Effect.die(new Error(`Timed out waiting for ACP Registry thread ${threadId}.`)); | |
| 86 | +}); | |
| 87 | + | |
| 88 | +describe.runIf(process.env.T3_ACP_REGISTRY_LIVE_ORCHESTRATOR === "1")( | |
| 89 | + "ACP Registry V2 live orchestrator", | |
| 90 | + () => { | |
| 91 | + it.live( | |
| 92 | + "runs and resumes a real registry agent through the production V2 harness", | |
| 93 | + () => | |
| 94 | + Effect.gen(function* () { | |
| 95 | + const orchestrator = yield* OrchestratorV2; | |
| 96 | + const projectId = ProjectId.make("project:acp-registry-live"); | |
| 97 | + const threadId = ThreadId.make("thread:acp-registry-live"); | |
| 98 | + const marker = "ACP_REGISTRY_LIVE_7H3Q"; | |
| 99 | + | |
| 100 | + yield* orchestrator.dispatch({ | |
| 101 | + type: "thread.create", | |
| 102 | + commandId: CommandId.make("command:acp-registry-live:create"), | |
| 103 | + threadId, | |
| 104 | + projectId, | |
| 105 | + title: `ACP Registry live: ${liveAgentId}`, | |
| 106 | + modelSelection: liveModelSelection, | |
| 107 | + runtimeMode: "full-access", | |
| 108 | + interactionMode: "default", | |
| 109 | + branch: null, | |
| 110 | + worktreePath: null, | |
| 111 | + }); | |
| 112 | + yield* Console.log( | |
| 113 | + `ACP Registry thread created for '${liveAgentId}'; dispatching first prompt.`, | |
| 114 | + ); | |
| 115 | + yield* orchestrator.dispatch({ | |
| 116 | + type: "message.dispatch", | |
| 117 | + commandId: CommandId.make("command:acp-registry-live:first"), | |
| 118 | + threadId, | |
| 119 | + messageId: MessageId.make("message:acp-registry-live:first"), | |
| 120 | + text: `Remember this opaque marker. Respond with exactly: ${marker}`, | |
| 121 | + attachments: [], | |
| 122 | + modelSelection: liveModelSelection, | |
| 123 | + dispatchMode: { type: "start_immediately" }, | |
| 124 | + }); | |
| 125 | + const firstProjection = yield* waitForIdle(threadId, 1); | |
| 126 | + const firstAssistant = firstProjection.messages.findLast( | |
| 127 | + (message) => message.role === "assistant", | |
| 128 | + )?.text; | |
| 129 | + | |
| 130 | + assert.deepEqual( | |
| 131 | + firstProjection.runs.map((run) => [run.provider, run.status]), | |
| 132 | + [[liveInstanceId, "completed"]], | |
| 133 | + ); | |
| 134 | + assert.include(firstAssistant ?? "", marker); | |
| 135 | + | |
| 136 | + yield* Console.log("First ACP turn completed; dispatching continuation prompt."); | |
| 137 | + yield* orchestrator.dispatch({ | |
| 138 | + type: "message.dispatch", | |
| 139 | + commandId: CommandId.make("command:acp-registry-live:second"), | |
| 140 | + threadId, | |
| 141 | + messageId: MessageId.make("message:acp-registry-live:second"), | |
| 142 | + text: "Return the opaque marker from the previous turn. Respond with only the marker.", | |
| 143 | + attachments: [], | |
| 144 | + modelSelection: liveModelSelection, | |
| 145 | + dispatchMode: { type: "start_immediately" }, | |
| 146 | + }); | |
| 147 | + const finalProjection = yield* waitForIdle(threadId, 2); | |
| 148 | + const finalAssistant = finalProjection.messages.findLast( | |
| 149 | + (message) => message.role === "assistant", | |
| 150 | + )?.text; | |
| 151 | + | |
| 152 | + assert.deepEqual( | |
| 153 | + finalProjection.runs.map((run) => [run.provider, run.status]), | |
| 154 | + [ | |
| 155 | + [liveInstanceId, "completed"], | |
| 156 | + [liveInstanceId, "completed"], | |
| 157 | + ], | |
| 158 | + ); | |
| 159 | + assert.include(finalAssistant ?? "", marker); | |
| 160 | + assert.deepEqual(finalProjection.providerSessions.length, 2); | |
| 161 | + assert.isAtLeast(finalProjection.providerThreads.length, 1); | |
| 162 | + assert.deepEqual( | |
| 163 | + finalProjection.providerTurns.map((turn) => turn.status), | |
| 164 | + ["completed", "completed"], | |
| 165 | + ); | |
| 166 | + }).pipe(Effect.provide(liveLayer), Effect.scoped), | |
| 167 | + 480_000, | |
| 168 | + ); | |
| 169 | + }, | |
| 170 | +); | |
| @@ -0,0 +1,358 @@ | ||
| 1 | +import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | +import { assert, describe, it } from "@effect/vitest"; | |
| 3 | +import { | |
| 4 | + MessageId, | |
| 5 | + NodeId, | |
| 6 | + ProjectId, | |
| 7 | + ProviderInstanceId, | |
| 8 | + ProviderSessionId, | |
| 9 | + RunAttemptId, | |
| 10 | + RunId, | |
| 11 | + ThreadId, | |
| 12 | + type OrchestrationV2ProviderThread, | |
| 13 | +} from "@t3tools/contracts"; | |
| 14 | +import * as DateTime from "effect/DateTime"; | |
| 15 | +import * as Effect from "effect/Effect"; | |
| 16 | +import * as FileSystem from "effect/FileSystem"; | |
| 17 | +import * as Fiber from "effect/Fiber"; | |
| 18 | +import * as Layer from "effect/Layer"; | |
| 19 | +import * as Option from "effect/Option"; | |
| 20 | +import * as Path from "effect/Path"; | |
| 21 | +import * as Stream from "effect/Stream"; | |
| 22 | +import * as TestClock from "effect/testing/TestClock"; | |
| 23 | +import { ChildProcessSpawner } from "effect/unstable/process"; | |
| 24 | + | |
| 25 | +import { ServerConfig } from "../../config.ts"; | |
| 26 | +import { AcpSessionRuntime } from "../../provider/acp/AcpSessionRuntime.ts"; | |
| 27 | +import { layer as idAllocatorLayer, IdAllocatorV2 } from "../IdAllocator.ts"; | |
| 28 | +import { | |
| 29 | + ProviderAdapterV2RuntimePolicy, | |
| 30 | + type ProviderAdapterV2TurnInput, | |
| 31 | +} from "../ProviderAdapter.ts"; | |
| 32 | +import { | |
| 33 | + AcpProviderCapabilitiesV2, | |
| 34 | + makeAcpAdapterV2, | |
| 35 | + type AcpAdapterV2Flavor, | |
| 36 | +} from "./AcpAdapterV2.ts"; | |
| 37 | + | |
| 38 | +const serverConfigLayer = ServerConfig.layerTest(process.cwd(), { | |
| 39 | + prefix: "t3-acp-v2-adapter-", | |
| 40 | +}).pipe(Layer.provide(NodeServices.layer)); | |
| 41 | + | |
| 42 | +const testLayer = Layer.mergeAll(NodeServices.layer, idAllocatorLayer, serverConfigLayer); | |
| 43 | + | |
| 44 | +function makeMockRuntime(input: { | |
| 45 | + readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; | |
| 46 | + readonly mockAgentPath: string; | |
| 47 | + readonly environment?: Readonly<Record<string, string>>; | |
| 48 | +}): AcpAdapterV2Flavor["makeRuntime"] { | |
| 49 | + return (runtimeInput) => | |
| 50 | + Effect.gen(function* () { | |
| 51 | + const context = yield* Layer.build( | |
| 52 | + AcpSessionRuntime.layer({ | |
| 53 | + ...runtimeInput, | |
| 54 | + spawn: { | |
| 55 | + command: process.execPath, | |
| 56 | + args: [input.mockAgentPath], | |
| 57 | + cwd: runtimeInput.cwd, | |
| 58 | + env: { T3_ACP_SESSION_LIFECYCLE: "1", ...input.environment }, | |
| 59 | + }, | |
| 60 | + authMethodId: "test", | |
| 61 | + }).pipe( | |
| 62 | + Layer.provide( | |
| 63 | + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner), | |
| 64 | + ), | |
| 65 | + ), | |
| 66 | + ); | |
| 67 | + return yield* Effect.service(AcpSessionRuntime).pipe(Effect.provide(context)); | |
| 68 | + }); | |
| 69 | +} | |
| 70 | + | |
| 71 | +function makeTurnInput(input: { | |
| 72 | + readonly threadId: ThreadId; | |
| 73 | + readonly providerThread: OrchestrationV2ProviderThread; | |
| 74 | + readonly instanceId: ProviderInstanceId; | |
| 75 | + readonly runtimePolicy: ProviderAdapterV2RuntimePolicy; | |
| 76 | + readonly now: DateTime.Utc; | |
| 77 | + readonly ordinal?: number; | |
| 78 | +}): ProviderAdapterV2TurnInput { | |
| 79 | + const ordinal = input.ordinal ?? 1; | |
| 80 | + const suffix = `${input.threadId}:${ordinal}`; | |
| 81 | + const modelSelection = { instanceId: input.instanceId, model: "default" } as const; | |
| 82 | + return { | |
| 83 | + appThread: { | |
| 84 | + id: input.threadId, | |
| 85 | + projectId: ProjectId.make(`project:${input.threadId}`), | |
| 86 | + title: "ACP adapter test", | |
| 87 | + defaultProvider: "acp-test", | |
| 88 | + modelSelection, | |
| 89 | + runtimeMode: "approval-required", | |
| 90 | + interactionMode: "default", | |
| 91 | + branch: null, | |
| 92 | + worktreePath: null, | |
| 93 | + activeProviderThreadId: input.providerThread.id, | |
| 94 | + lineage: { | |
| 95 | + parentThreadId: null, | |
| 96 | + relationshipToParent: null, | |
| 97 | + rootThreadId: input.threadId, | |
| 98 | + }, | |
| 99 | + forkedFrom: null, | |
| 100 | + createdAt: input.now, | |
| 101 | + updatedAt: input.now, | |
| 102 | + archivedAt: null, | |
| 103 | + deletedAt: null, | |
| 104 | + }, | |
| 105 | + threadId: input.threadId, | |
| 106 | + runId: RunId.make(`run:${suffix}`), | |
| 107 | + runOrdinal: ordinal, | |
| 108 | + providerTurnOrdinal: ordinal, | |
| 109 | + attemptId: RunAttemptId.make(`attempt:${suffix}`), | |
| 110 | + rootNodeId: NodeId.make(`node:${suffix}`), | |
| 111 | + providerThread: input.providerThread, | |
| 112 | + message: { | |
| 113 | + messageId: MessageId.make(`message:${suffix}`), | |
| 114 | + text: "test prompt", | |
| 115 | + attachments: [], | |
| 116 | + }, | |
| 117 | + modelSelection, | |
| 118 | + runtimePolicy: input.runtimePolicy, | |
| 119 | + }; | |
| 120 | +} | |
| 121 | + | |
| 122 | +describe("AcpAdapterV2", () => { | |
| 123 | + it.effect("negotiates and executes optional native session forks through the ACP runtime", () => | |
| 124 | + Effect.gen(function* () { | |
| 125 | + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 126 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 127 | + const idAllocator = yield* IdAllocatorV2; | |
| 128 | + const path = yield* Path.Path; | |
| 129 | + const serverConfig = yield* ServerConfig; | |
| 130 | + const mockAgentPath = yield* path.fromFileUrl( | |
| 131 | + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | |
| 132 | + ); | |
| 133 | + const makeRuntime = makeMockRuntime({ childProcessSpawner, mockAgentPath }); | |
| 134 | + | |
| 135 | + const instanceId = ProviderInstanceId.make("acp-test"); | |
| 136 | + const adapter = makeAcpAdapterV2({ | |
| 137 | + instanceId, | |
| 138 | + flavor: { | |
| 139 | + provider: "acp-test", | |
| 140 | + capabilities: AcpProviderCapabilitiesV2, | |
| 141 | + makeRuntime, | |
| 142 | + }, | |
| 143 | + fileSystem, | |
| 144 | + idAllocator, | |
| 145 | + serverConfig, | |
| 146 | + }); | |
| 147 | + const sourceThreadId = ThreadId.make("thread-acp-native-fork-source"); | |
| 148 | + const targetThreadId = ThreadId.make("thread-acp-native-fork-target"); | |
| 149 | + const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | |
| 150 | + runtimeMode: "full-access", | |
| 151 | + interactionMode: "default", | |
| 152 | + cwd: process.cwd(), | |
| 153 | + }); | |
| 154 | + const modelSelection = { instanceId, model: "default" } as const; | |
| 155 | + const runtime = yield* adapter.openSession({ | |
| 156 | + threadId: sourceThreadId, | |
| 157 | + providerSessionId: ProviderSessionId.make("provider-session-acp-native-fork"), | |
| 158 | + modelSelection, | |
| 159 | + runtimePolicy, | |
| 160 | + }); | |
| 161 | + | |
| 162 | + assert.isTrue(runtime.providerSession.capabilities.threads.canForkThread); | |
| 163 | + assert.isTrue(runtime.providerSession.capabilities.threads.canReadThreadSnapshot); | |
| 164 | + | |
| 165 | + const sourceProviderThread = yield* runtime.ensureThread({ | |
| 166 | + threadId: sourceThreadId, | |
| 167 | + modelSelection, | |
| 168 | + runtimePolicy, | |
| 169 | + }); | |
| 170 | + const forkedProviderThread = yield* runtime.forkThread({ | |
| 171 | + sourceProviderThread, | |
| 172 | + targetThreadId, | |
| 173 | + }); | |
| 174 | + | |
| 175 | + assert.equal(sourceProviderThread.nativeThreadRef?.nativeId, "mock-session-1"); | |
| 176 | + assert.equal(forkedProviderThread.nativeThreadRef?.nativeId, "mock-session-1-fork"); | |
| 177 | + assert.equal(forkedProviderThread.appThreadId, targetThreadId); | |
| 178 | + assert.equal(forkedProviderThread.forkedFrom?.providerThreadId, sourceProviderThread.id); | |
| 179 | + }).pipe(Effect.provide(testLayer), Effect.scoped), | |
| 180 | + ); | |
| 181 | + | |
| 182 | + it.effect("cancels pending permission requests while interrupting an ACP turn", () => | |
| 183 | + Effect.gen(function* () { | |
| 184 | + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 185 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 186 | + const idAllocator = yield* IdAllocatorV2; | |
| 187 | + const path = yield* Path.Path; | |
| 188 | + const serverConfig = yield* ServerConfig; | |
| 189 | + const mockAgentPath = yield* path.fromFileUrl( | |
| 190 | + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | |
| 191 | + ); | |
| 192 | + const instanceId = ProviderInstanceId.make("acp-test"); | |
| 193 | + const adapter = makeAcpAdapterV2({ | |
| 194 | + instanceId, | |
| 195 | + flavor: { | |
| 196 | + provider: "acp-test", | |
| 197 | + capabilities: AcpProviderCapabilitiesV2, | |
| 198 | + makeRuntime: makeMockRuntime({ | |
| 199 | + childProcessSpawner, | |
| 200 | + mockAgentPath, | |
| 201 | + environment: { T3_ACP_EMIT_TOOL_CALLS: "1" }, | |
| 202 | + }), | |
| 203 | + }, | |
| 204 | + fileSystem, | |
| 205 | + idAllocator, | |
| 206 | + serverConfig, | |
| 207 | + }); | |
| 208 | + const threadId = ThreadId.make("thread-acp-cancel-permission"); | |
| 209 | + const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | |
| 210 | + runtimeMode: "approval-required", | |
| 211 | + interactionMode: "default", | |
| 212 | + cwd: process.cwd(), | |
| 213 | + }); | |
| 214 | + const modelSelection = { instanceId, model: "default" } as const; | |
| 215 | + const runtime = yield* adapter.openSession({ | |
| 216 | + threadId, | |
| 217 | + providerSessionId: ProviderSessionId.make("provider-session-acp-cancel-permission"), | |
| 218 | + modelSelection, | |
| 219 | + runtimePolicy, | |
| 220 | + }); | |
| 221 | + const providerThread = yield* runtime.ensureThread({ | |
| 222 | + threadId, | |
| 223 | + modelSelection, | |
| 224 | + runtimePolicy, | |
| 225 | + }); | |
| 226 | + const now = yield* DateTime.now; | |
| 227 | + yield* runtime.startTurn( | |
| 228 | + makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), | |
| 229 | + ); | |
| 230 | + | |
| 231 | + const pendingRequest = Option.getOrThrow( | |
| 232 | + yield* runtime.events.pipe( | |
| 233 | + Stream.filter( | |
| 234 | + (event) => | |
| 235 | + event.type === "runtime_request.updated" && event.runtimeRequest.status === "pending", | |
| 236 | + ), | |
| 237 | + Stream.runHead, | |
| 238 | + ), | |
| 239 | + ); | |
| 240 | + if ( | |
| 241 | + pendingRequest.type !== "runtime_request.updated" || | |
| 242 | + pendingRequest.runtimeRequest.providerTurnId === null | |
| 243 | + ) { | |
| 244 | + return yield* Effect.die("Expected a pending ACP permission request with a provider turn"); | |
| 245 | + } | |
| 246 | + | |
| 247 | + yield* runtime.interruptTurn({ | |
| 248 | + providerThread, | |
| 249 | + providerTurnId: pendingRequest.runtimeRequest.providerTurnId, | |
| 250 | + }); | |
| 251 | + | |
| 252 | + const cancelledRequest = Option.getOrThrow( | |
| 253 | + yield* runtime.events.pipe( | |
| 254 | + Stream.filter( | |
| 255 | + (event) => | |
| 256 | + event.type === "runtime_request.updated" && | |
| 257 | + event.runtimeRequest.id === pendingRequest.runtimeRequest.id && | |
| 258 | + event.runtimeRequest.status === "cancelled", | |
| 259 | + ), | |
| 260 | + Stream.runHead, | |
| 261 | + ), | |
| 262 | + ); | |
| 263 | + assert.equal(cancelledRequest.type, "runtime_request.updated"); | |
| 264 | + }).pipe(Effect.provide(testLayer), Effect.scoped), | |
| 265 | + ); | |
| 266 | + | |
| 267 | + it.effect("does not release an ACP turn when cancellation is not acknowledged", () => | |
| 268 | + Effect.gen(function* () { | |
| 269 | + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 270 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 271 | + const idAllocator = yield* IdAllocatorV2; | |
| 272 | + const path = yield* Path.Path; | |
| 273 | + const serverConfig = yield* ServerConfig; | |
| 274 | + const mockAgentPath = yield* path.fromFileUrl( | |
| 275 | + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | |
| 276 | + ); | |
| 277 | + const instanceId = ProviderInstanceId.make("acp-test"); | |
| 278 | + const adapter = makeAcpAdapterV2({ | |
| 279 | + instanceId, | |
| 280 | + flavor: { | |
| 281 | + provider: "acp-test", | |
| 282 | + capabilities: AcpProviderCapabilitiesV2, | |
| 283 | + makeRuntime: makeMockRuntime({ | |
| 284 | + childProcessSpawner, | |
| 285 | + mockAgentPath, | |
| 286 | + environment: { T3_ACP_PROMPT_DELAY_MS: "5000" }, | |
| 287 | + }), | |
| 288 | + }, | |
| 289 | + fileSystem, | |
| 290 | + idAllocator, | |
| 291 | + serverConfig, | |
| 292 | + }); | |
| 293 | + const threadId = ThreadId.make("thread-acp-cancel-timeout"); | |
| 294 | + const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | |
| 295 | + runtimeMode: "full-access", | |
| 296 | + interactionMode: "default", | |
| 297 | + cwd: process.cwd(), | |
| 298 | + }); | |
| 299 | + const modelSelection = { instanceId, model: "default" } as const; | |
| 300 | + const runtime = yield* adapter.openSession({ | |
| 301 | + threadId, | |
| 302 | + providerSessionId: ProviderSessionId.make("provider-session-acp-cancel-timeout"), | |
| 303 | + modelSelection, | |
| 304 | + runtimePolicy, | |
| 305 | + }); | |
| 306 | + const providerThread = yield* runtime.ensureThread({ | |
| 307 | + threadId, | |
| 308 | + modelSelection, | |
| 309 | + runtimePolicy, | |
| 310 | + }); | |
| 311 | + const now = yield* DateTime.now; | |
| 312 | + const firstTurn = makeTurnInput({ | |
| 313 | + threadId, | |
| 314 | + providerThread, | |
| 315 | + instanceId, | |
| 316 | + runtimePolicy, | |
| 317 | + now, | |
| 318 | + }); | |
| 319 | + yield* runtime.startTurn(firstTurn); | |
| 320 | + yield* runtime.rawEvents.pipe( | |
| 321 | + Stream.filter( | |
| 322 | + (event) => event.direction === "outgoing" && event.method === "session/prompt", | |
| 323 | + ), | |
| 324 | + Stream.runHead, | |
| 325 | + ); | |
| 326 | + const providerTurnId = idAllocator.derive.providerTurn({ | |
| 327 | + provider: "acp-test", | |
| 328 | + nativeTurnId: "mock-session-1:turn:1", | |
| 329 | + }); | |
| 330 | + const interruptFiber = yield* runtime | |
| 331 | + .interruptTurn({ providerThread, providerTurnId }) | |
| 332 | + .pipe(Effect.flip, Effect.forkScoped); | |
| 333 | + yield* runtime.rawEvents.pipe( | |
| 334 | + Stream.filter( | |
| 335 | + (event) => event.direction === "outgoing" && event.method === "session/cancel", | |
| 336 | + ), | |
| 337 | + Stream.runHead, | |
| 338 | + ); | |
| 339 | + yield* TestClock.adjust("10 seconds"); | |
| 340 | + const interruptError = yield* Fiber.join(interruptFiber); | |
| 341 | + assert.equal(interruptError._tag, "ProviderAdapterInterruptError"); | |
| 342 | + | |
| 343 | + const secondTurnError = yield* runtime | |
| 344 | + .startTurn( | |
| 345 | + makeTurnInput({ | |
| 346 | + threadId, | |
| 347 | + providerThread, | |
| 348 | + instanceId, | |
| 349 | + runtimePolicy, | |
| 350 | + now, | |
| 351 | + ordinal: 2, | |
| 352 | + }), | |
| 353 | + ) | |
| 354 | + .pipe(Effect.flip); | |
| 355 | + assert.equal(secondTurnError._tag, "ProviderAdapterTurnStartError"); | |
| 356 | + }).pipe(Effect.provide(testLayer), Effect.scoped), | |
| 357 | + ); | |
| 358 | +}); | |
| @@ -0,0 +1,187 @@ | ||
| 1 | +import { | |
| 2 | + ProviderKind, | |
| 3 | + ProviderReplayEntry, | |
| 4 | + type ProviderReplayTranscript, | |
| 5 | +} from "@t3tools/contracts"; | |
| 6 | +import * as Effect from "effect/Effect"; | |
| 7 | +import * as FileSystem from "effect/FileSystem"; | |
| 8 | +import * as Layer from "effect/Layer"; | |
| 9 | +import * as Schema from "effect/Schema"; | |
| 10 | +import type * as Scope from "effect/Scope"; | |
| 11 | +import { ChildProcessSpawner } from "effect/unstable/process"; | |
| 12 | +import * as EffectAcpErrors from "effect-acp/errors"; | |
| 13 | + | |
| 14 | +import { | |
| 15 | + AcpSessionRuntime, | |
| 16 | + type AcpSessionRuntimeShape, | |
| 17 | +} from "../../provider/acp/AcpSessionRuntime.ts"; | |
| 18 | +import { ACP_PROTOCOL, type AcpAdapterV2RuntimeInput } from "./AcpAdapterV2.ts"; | |
| 19 | + | |
| 20 | +export const AcpReplayTranscript = Schema.Struct({ | |
| 21 | + provider: ProviderKind, | |
| 22 | + protocol: Schema.Literal(ACP_PROTOCOL), | |
| 23 | + version: Schema.String, | |
| 24 | + scenario: Schema.String, | |
| 25 | + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), | |
| 26 | + entries: Schema.Array(ProviderReplayEntry), | |
| 27 | +}); | |
| 28 | +export type AcpReplayTranscript = typeof AcpReplayTranscript.Type; | |
| 29 | + | |
| 30 | +const decodeAcpReplayTranscriptSchema = Schema.decodeUnknownEffect(AcpReplayTranscript); | |
| 31 | + | |
| 32 | +export class AcpReplayTranscriptDecodeError extends Schema.TaggedErrorClass<AcpReplayTranscriptDecodeError>()( | |
| 33 | + "AcpReplayTranscriptDecodeError", | |
| 34 | + { | |
| 35 | + expectedProvider: ProviderKind, | |
| 36 | + provider: Schema.optional(Schema.String), | |
| 37 | + protocol: Schema.optional(Schema.String), | |
| 38 | + scenario: Schema.optional(Schema.String), | |
| 39 | + cause: Schema.Defect(), | |
| 40 | + }, | |
| 41 | +) { | |
| 42 | + override get message(): string { | |
| 43 | + return `Failed to decode ${this.expectedProvider} ACP replay transcript for scenario ${this.scenario ?? "<unknown>"}.`; | |
| 44 | + } | |
| 45 | +} | |
| 46 | + | |
| 47 | +const isAcpReplayTranscriptDecodeError = Schema.is(AcpReplayTranscriptDecodeError); | |
| 48 | + | |
| 49 | +function metadataFromTranscript(transcript: ProviderReplayTranscript): { | |
| 50 | + readonly provider?: string; | |
| 51 | + readonly protocol?: string; | |
| 52 | + readonly scenario?: string; | |
| 53 | +} { | |
| 54 | + return { | |
| 55 | + provider: transcript.provider, | |
| 56 | + protocol: transcript.protocol, | |
| 57 | + scenario: transcript.scenario, | |
| 58 | + }; | |
| 59 | +} | |
| 60 | + | |
| 61 | +export function decodeAcpReplayTranscript( | |
| 62 | + transcript: ProviderReplayTranscript, | |
| 63 | + expectedProvider: ProviderKind, | |
| 64 | + options: { readonly retargetProvider?: boolean } = {}, | |
| 65 | +): Effect.Effect<AcpReplayTranscript, AcpReplayTranscriptDecodeError> { | |
| 66 | + const candidate = options.retargetProvider | |
| 67 | + ? { ...transcript, provider: expectedProvider } | |
| 68 | + : transcript; | |
| 69 | + return decodeAcpReplayTranscriptSchema(candidate).pipe( | |
| 70 | + Effect.filterOrFail( | |
| 71 | + (decoded) => decoded.provider === expectedProvider, | |
| 72 | + () => | |
| 73 | + new AcpReplayTranscriptDecodeError({ | |
| 74 | + expectedProvider, | |
| 75 | + ...metadataFromTranscript(transcript), | |
| 76 | + cause: `Expected provider ${expectedProvider}, received ${transcript.provider}`, | |
| 77 | + }), | |
| 78 | + ), | |
| 79 | + Effect.mapError((cause) => | |
| 80 | + isAcpReplayTranscriptDecodeError(cause) | |
| 81 | + ? cause | |
| 82 | + : new AcpReplayTranscriptDecodeError({ | |
| 83 | + expectedProvider, | |
| 84 | + ...metadataFromTranscript(transcript), | |
| 85 | + cause, | |
| 86 | + }), | |
| 87 | + ), | |
| 88 | + ); | |
| 89 | +} | |
| 90 | + | |
| 91 | +interface ReplayStatus { | |
| 92 | + readonly cursor: number; | |
| 93 | + readonly total: number; | |
| 94 | + readonly failure?: unknown; | |
| 95 | +} | |
| 96 | + | |
| 97 | +function decodeReplayStatus(raw: string): ReplayStatus { | |
| 98 | + const value: unknown = JSON.parse(raw); | |
| 99 | + if ( | |
| 100 | + typeof value !== "object" || | |
| 101 | + value === null || | |
| 102 | + typeof Reflect.get(value, "cursor") !== "number" || | |
| 103 | + typeof Reflect.get(value, "total") !== "number" | |
| 104 | + ) { | |
| 105 | + throw new Error("ACP replay status is malformed."); | |
| 106 | + } | |
| 107 | + return value as ReplayStatus; | |
| 108 | +} | |
| 109 | + | |
| 110 | +export function makeAcpReplayCompletenessAssertion( | |
| 111 | + fileSystem: FileSystem.FileSystem, | |
| 112 | + statusPath: string, | |
| 113 | + transcript: AcpReplayTranscript, | |
| 114 | +): Effect.Effect<void, EffectAcpErrors.AcpError> { | |
| 115 | + return fileSystem.readFileString(statusPath).pipe( | |
| 116 | + Effect.mapError( | |
| 117 | + (cause) => | |
| 118 | + new EffectAcpErrors.AcpTransportError({ | |
| 119 | + detail: `Failed to read ACP replay status for ${transcript.scenario}`, | |
| 120 | + cause, | |
| 121 | + }), | |
| 122 | + ), | |
| 123 | + Effect.flatMap((raw) => | |
| 124 | + Effect.try({ | |
| 125 | + try: () => decodeReplayStatus(raw), | |
| 126 | + catch: (cause) => | |
| 127 | + new EffectAcpErrors.AcpTransportError({ | |
| 128 | + detail: `Failed to decode ACP replay status for ${transcript.scenario}`, | |
| 129 | + cause, | |
| 130 | + }), | |
| 131 | + }), | |
| 132 | + ), | |
| 133 | + Effect.flatMap((status) => { | |
| 134 | + if ( | |
| 135 | + status.failure === undefined && | |
| 136 | + status.cursor === transcript.entries.length && | |
| 137 | + status.total === transcript.entries.length | |
| 138 | + ) { | |
| 139 | + return Effect.void; | |
| 140 | + } | |
| 141 | + return Effect.fail( | |
| 142 | + new EffectAcpErrors.AcpTransportError({ | |
| 143 | + detail: `ACP replay did not consume all frames for ${transcript.scenario}`, | |
| 144 | + cause: status, | |
| 145 | + }), | |
| 146 | + ); | |
| 147 | + }), | |
| 148 | + ); | |
| 149 | +} | |
| 150 | + | |
| 151 | +export function makeAcpReplayRuntime(input: { | |
| 152 | + readonly transcript: AcpReplayTranscript; | |
| 153 | + readonly statusPath: string; | |
| 154 | + readonly scriptPath: string; | |
| 155 | + readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; | |
| 156 | +}): ( | |
| 157 | + runtimeInput: AcpAdapterV2RuntimeInput, | |
| 158 | +) => Effect.Effect<AcpSessionRuntimeShape, EffectAcpErrors.AcpError, Scope.Scope> { | |
| 159 | + const encodedTranscript = Buffer.from(JSON.stringify(input.transcript), "utf8").toString( | |
| 160 | + "base64", | |
| 161 | + ); | |
| 162 | + return (runtimeInput) => | |
| 163 | + Effect.gen(function* () { | |
| 164 | + const context = yield* Layer.build( | |
| 165 | + AcpSessionRuntime.layer({ | |
| 166 | + ...runtimeInput, | |
| 167 | + spawn: { | |
| 168 | + command: process.execPath, | |
| 169 | + args: [input.scriptPath], | |
| 170 | + cwd: runtimeInput.cwd, | |
| 171 | + env: { | |
| 172 | + ...process.env, | |
| 173 | + T3_ACP_REPLAY_TRANSCRIPT: encodedTranscript, | |
| 174 | + T3_ACP_REPLAY_STATUS_PATH: input.statusPath, | |
| 175 | + T3_ACP_REPLAY_WORKSPACE: runtimeInput.cwd, | |
| 176 | + }, | |
| 177 | + }, | |
| 178 | + authMethodId: "replay", | |
| 179 | + }).pipe( | |
| 180 | + Layer.provide( | |
| 181 | + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner), | |
| 182 | + ), | |
| 183 | + ), | |
| 184 | + ); | |
| 185 | + return yield* Effect.service(AcpSessionRuntime).pipe(Effect.provide(context)); | |
| 186 | + }); | |
| 187 | +} | |
| @@ -0,0 +1,2203 @@ | ||
| 1 | +import { | |
| 2 | + type ChatAttachment, | |
| 3 | + type ModelSelection, | |
| 4 | + type OrchestrationV2ConversationMessage, | |
| 5 | + type OrchestrationV2ExecutionNode, | |
| 6 | + type OrchestrationV2PlanArtifact, | |
| 7 | + type OrchestrationV2PlanStep, | |
| 8 | + type OrchestrationV2ProviderCapabilities, | |
| 9 | + type OrchestrationV2ProviderSession, | |
| 10 | + type OrchestrationV2ProviderThread, | |
| 11 | + type OrchestrationV2ProviderTurn, | |
| 12 | + type OrchestrationV2RawProviderEvent, | |
| 13 | + type OrchestrationV2RuntimeRequest, | |
| 14 | + type OrchestrationV2TurnItem, | |
| 15 | + type OrchestrationV2UserInputQuestion, | |
| 16 | + type ProviderApprovalDecision, | |
| 17 | + type ProviderInstanceId, | |
| 18 | + type ProviderKind, | |
| 19 | + type ProviderRequestKind, | |
| 20 | + type ProviderUserInputAnswers, | |
| 21 | + type RuntimeRequestId, | |
| 22 | + type ThreadId, | |
| 23 | +} from "@t3tools/contracts"; | |
| 24 | +import * as DateTime from "effect/DateTime"; | |
| 25 | +import * as Deferred from "effect/Deferred"; | |
| 26 | +import * as Effect from "effect/Effect"; | |
| 27 | +import * as FileSystem from "effect/FileSystem"; | |
| 28 | +import * as Option from "effect/Option"; | |
| 29 | +import * as Queue from "effect/Queue"; | |
| 30 | +import * as Ref from "effect/Ref"; | |
| 31 | +import type * as Scope from "effect/Scope"; | |
| 32 | +import * as Stream from "effect/Stream"; | |
| 33 | +import * as EffectAcpErrors from "effect-acp/errors"; | |
| 34 | +import type * as EffectAcpProtocol from "effect-acp/protocol"; | |
| 35 | +import type * as EffectAcpSchema from "effect-acp/schema"; | |
| 36 | + | |
| 37 | +import { resolveAttachmentPath } from "../../attachmentStore.ts"; | |
| 38 | +import { type ServerConfigShape } from "../../config.ts"; | |
| 39 | +import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; | |
| 40 | +import { | |
| 41 | + mergeToolCallState, | |
| 42 | + parsePermissionRequest, | |
| 43 | + parseSessionUpdateEvent, | |
| 44 | + type AcpPlanUpdate, | |
| 45 | + type AcpToolCallState, | |
| 46 | +} from "../../provider/acp/AcpRuntimeModel.ts"; | |
| 47 | +import type { | |
| 48 | + AcpSessionRuntimeOptions, | |
| 49 | + AcpSessionRuntimeShape, | |
| 50 | + AcpSessionRuntimeStartResult, | |
| 51 | +} from "../../provider/acp/AcpSessionRuntime.ts"; | |
| 52 | +import { IdAllocatorV2, type IdAllocatorV2Shape } from "../IdAllocator.ts"; | |
| 53 | +import { | |
| 54 | + ProviderAdapterEnsureThreadError, | |
| 55 | + ProviderAdapterForkThreadError, | |
| 56 | + ProviderAdapterInterruptError, | |
| 57 | + ProviderAdapterOpenSessionError, | |
| 58 | + ProviderAdapterProtocolError, | |
| 59 | + ProviderAdapterReadThreadSnapshotError, | |
| 60 | + ProviderAdapterResumeThreadError, | |
| 61 | + ProviderAdapterRollbackThreadError, | |
| 62 | + ProviderAdapterRuntimeRequestResponseError, | |
| 63 | + ProviderAdapterSteerRunUnsupportedError, | |
| 64 | + ProviderAdapterTurnStartError, | |
| 65 | + ProviderAdapterV2, | |
| 66 | + type ProviderAdapterV2EnsureThreadInput, | |
| 67 | + type ProviderAdapterV2Event, | |
| 68 | + type ProviderAdapterV2InterruptInput, | |
| 69 | + type ProviderAdapterV2OpenSessionInput, | |
| 70 | + type ProviderAdapterV2RuntimePolicy, | |
| 71 | + type ProviderAdapterV2SessionRuntime, | |
| 72 | + type ProviderAdapterV2Shape, | |
| 73 | + type ProviderAdapterV2TurnInput, | |
| 74 | +} from "../ProviderAdapter.ts"; | |
| 75 | + | |
| 76 | +export const ACP_PROTOCOL = "acp.ndjson-jsonrpc" as const; | |
| 77 | + | |
| 78 | +export interface AcpAdapterV2RuntimeInput { | |
| 79 | + readonly cwd: string; | |
| 80 | + readonly mcpServers: ReadonlyArray<EffectAcpSchema.McpServer>; | |
| 81 | + readonly clientCapabilities: EffectAcpSchema.InitializeRequest["clientCapabilities"]; | |
| 82 | + readonly clientInfo: AcpSessionRuntimeOptions["clientInfo"]; | |
| 83 | + readonly protocolLogging: NonNullable<AcpSessionRuntimeOptions["protocolLogging"]>; | |
| 84 | +} | |
| 85 | + | |
| 86 | +export interface AcpAdapterV2UserInputRequest { | |
| 87 | + readonly nativeItemId: string; | |
| 88 | + readonly nativeRequestId: string; | |
| 89 | + readonly questions: ReadonlyArray<OrchestrationV2UserInputQuestion>; | |
| 90 | +} | |
| 91 | + | |
| 92 | +export interface AcpAdapterV2ExtensionContext { | |
| 93 | + readonly runtime: AcpSessionRuntimeShape; | |
| 94 | + readonly requestUserInput: ( | |
| 95 | + input: AcpAdapterV2UserInputRequest, | |
| 96 | + ) => Effect.Effect<ProviderUserInputAnswers | null, EffectAcpErrors.AcpError>; | |
| 97 | +} | |
| 98 | + | |
| 99 | +export interface AcpAdapterV2Flavor { | |
| 100 | + readonly provider: ProviderKind; | |
| 101 | + readonly capabilities: OrchestrationV2ProviderCapabilities; | |
| 102 | + readonly makeRuntime: ( | |
| 103 | + input: AcpAdapterV2RuntimeInput, | |
| 104 | + ) => Effect.Effect<AcpSessionRuntimeShape, EffectAcpErrors.AcpError, Scope.Scope>; | |
| 105 | + readonly resolveModelId?: (selection: ModelSelection) => string | undefined; | |
| 106 | + readonly registerExtensions?: ( | |
| 107 | + context: AcpAdapterV2ExtensionContext, | |
| 108 | + ) => Effect.Effect<void, EffectAcpErrors.AcpError>; | |
| 109 | + readonly assertComplete?: Effect.Effect<void, EffectAcpErrors.AcpError>; | |
| 110 | +} | |
| 111 | + | |
| 112 | +export interface AcpAdapterV2Options { | |
| 113 | + readonly instanceId: ProviderInstanceId; | |
| 114 | + readonly flavor: AcpAdapterV2Flavor; | |
| 115 | + readonly fileSystem: FileSystem.FileSystem; | |
| 116 | + readonly idAllocator: IdAllocatorV2Shape; | |
| 117 | + readonly serverConfig: ServerConfigShape; | |
| 118 | +} | |
| 119 | + | |
| 120 | +export const AcpProviderCapabilitiesV2 = { | |
| 121 | + sessions: { | |
| 122 | + supportsMultipleProviderThreadsPerSession: false, | |
| 123 | + supportsModelSwitchInSession: false, | |
| 124 | + supportsProviderSwitchingViaHandoff: true, | |
| 125 | + supportsRuntimeModeSwitchInSession: false, | |
| 126 | + pendingRequestsSurviveRestart: false, | |
| 127 | + }, | |
| 128 | + threads: { | |
| 129 | + canCreateEmptyThread: true, | |
| 130 | + canReadThreadSnapshot: false, | |
| 131 | + canRollbackThread: false, | |
| 132 | + canForkThread: false, | |
| 133 | + canForkFromTurn: false, | |
| 134 | + canForkFromSubagentThread: false, | |
| 135 | + exposesNativeThreadId: true, | |
| 136 | + }, | |
| 137 | + turns: { | |
| 138 | + exposesNativeTurnId: false, | |
| 139 | + emitsTurnStarted: true, | |
| 140 | + emitsTurnCompleted: true, | |
| 141 | + supportsInterrupt: true, | |
| 142 | + supportsActiveSteering: false, | |
| 143 | + supportsSteeringByInterruptRestart: true, | |
| 144 | + supportsQueuedMessages: true, | |
| 145 | + terminalStatusQuality: "strong", | |
| 146 | + }, | |
| 147 | + streaming: { | |
| 148 | + streamsAssistantText: true, | |
| 149 | + streamsReasoning: true, | |
| 150 | + streamsToolOutput: true, | |
| 151 | + streamsPlanText: false, | |
| 152 | + emitsMessageCompleted: true, | |
| 153 | + }, | |
| 154 | + tools: { | |
| 155 | + exposesToolItemIds: true, | |
| 156 | + emitsToolStarted: true, | |
| 157 | + emitsToolCompleted: true, | |
| 158 | + emitsToolOutput: true, | |
| 159 | + supportsMcpTools: false, | |
| 160 | + supportsDynamicToolCallbacks: false, | |
| 161 | + }, | |
| 162 | + approvals: { | |
| 163 | + supportsCommandApproval: true, | |
| 164 | + supportsFileReadApproval: true, | |
| 165 | + supportsFileChangeApproval: true, | |
| 166 | + supportsApplyPatchApproval: false, | |
| 167 | + approvalsHaveNativeRequestIds: false, | |
| 168 | + approvalCallbacksAreLiveOnly: true, | |
| 169 | + approvalsCanOriginateFromSubagents: false, | |
| 170 | + }, | |
| 171 | + planning: { | |
| 172 | + emitsPlanUpdated: true, | |
| 173 | + emitsTodoList: true, | |
| 174 | + emitsProposedPlan: false, | |
| 175 | + supportsStructuredQuestions: true, | |
| 176 | + planDeltasHaveItemIds: false, | |
| 177 | + }, | |
| 178 | + subagents: { | |
| 179 | + supportsSubagents: false, | |
| 180 | + exposesSubagentThreadIds: false, | |
| 181 | + emitsSubagentLifecycle: false, | |
| 182 | + canWaitForSubagents: false, | |
| 183 | + canCloseSubagents: false, | |
| 184 | + canForkSubagentThread: false, | |
| 185 | + }, | |
| 186 | + context: { | |
| 187 | + acceptsSystemContext: false, | |
| 188 | + acceptsDeveloperContext: false, | |
| 189 | + acceptsSyntheticUserContext: true, | |
| 190 | + canGenerateSummaries: true, | |
| 191 | + canConsumeHandoffSummaries: true, | |
| 192 | + supportsDeltaHandoff: true, | |
| 193 | + supportsFullThreadHandoff: true, | |
| 194 | + maxRecommendedHandoffChars: null, | |
| 195 | + }, | |
| 196 | + checkpointing: { | |
| 197 | + appCanCheckpointFilesystem: true, | |
| 198 | + supportsNestedCheckpointScopes: true, | |
| 199 | + providerCanRollbackConversation: false, | |
| 200 | + providerRollbackReturnsSnapshot: false, | |
| 201 | + providerCanReadConversationSnapshot: false, | |
| 202 | + }, | |
| 203 | + identity: { | |
| 204 | + nativeThreadIds: "strong", | |
| 205 | + nativeTurnIds: "weak", | |
| 206 | + nativeItemIds: "weak", | |
| 207 | + nativeRequestIds: "weak", | |
| 208 | + }, | |
| 209 | +} satisfies OrchestrationV2ProviderCapabilities; | |
| 210 | + | |
| 211 | +function negotiatedCapabilities( | |
| 212 | + base: OrchestrationV2ProviderCapabilities, | |
| 213 | + started: AcpSessionRuntimeStartResult, | |
| 214 | +): OrchestrationV2ProviderCapabilities { | |
| 215 | + const agent = started.initializeResult.agentCapabilities ?? {}; | |
| 216 | + const session = agent.sessionCapabilities; | |
| 217 | + const setup = started.sessionSetupResult; | |
| 218 | + const hasModelConfig = | |
| 219 | + setup.configOptions?.some((option) => option.category === "model") === true; | |
| 220 | + const hasModeConfig = setup.configOptions?.some((option) => option.category === "mode") === true; | |
| 221 | + const supportsMcp = agent.mcpCapabilities?.http === true || agent.mcpCapabilities?.sse === true; | |
| 222 | + const canLoad = agent.loadSession === true; | |
| 223 | + const canFork = session?.fork != null; | |
| 224 | + return { | |
| 225 | + ...base, | |
| 226 | + sessions: { | |
| 227 | + ...base.sessions, | |
| 228 | + supportsModelSwitchInSession: setup.models != null || hasModelConfig, | |
| 229 | + supportsRuntimeModeSwitchInSession: setup.modes != null || hasModeConfig, | |
| 230 | + }, | |
| 231 | + threads: { | |
| 232 | + ...base.threads, | |
| 233 | + canReadThreadSnapshot: canLoad, | |
| 234 | + canForkThread: canFork, | |
| 235 | + canForkFromTurn: false, | |
| 236 | + }, | |
| 237 | + tools: { | |
| 238 | + ...base.tools, | |
| 239 | + supportsMcpTools: supportsMcp, | |
| 240 | + }, | |
| 241 | + checkpointing: { | |
| 242 | + ...base.checkpointing, | |
| 243 | + providerCanReadConversationSnapshot: canLoad, | |
| 244 | + }, | |
| 245 | + }; | |
| 246 | +} | |
| 247 | + | |
| 248 | +function acpMcpServers(threadId: ThreadId): ReadonlyArray<EffectAcpSchema.McpServer> { | |
| 249 | + const session = McpProviderSession.readMcpProviderSession(threadId); | |
| 250 | + if (session === undefined) { | |
| 251 | + return []; | |
| 252 | + } | |
| 253 | + return [ | |
| 254 | + { | |
| 255 | + type: "http", | |
| 256 | + name: "t3-code", | |
| 257 | + url: session.endpoint, | |
| 258 | + headers: [ | |
| 259 | + { | |
| 260 | + name: "Authorization", | |
| 261 | + value: session.authorizationHeader, | |
| 262 | + }, | |
| 263 | + ], | |
| 264 | + }, | |
| 265 | + ]; | |
| 266 | +} | |
| 267 | + | |
| 268 | +function nativeThreadId(provider: ProviderKind, thread: OrchestrationV2ProviderThread): string { | |
| 269 | + const id = thread.nativeThreadRef?.nativeId; | |
| 270 | + if (id === null || id === undefined || id.trim().length === 0) { | |
| 271 | + throw new ProviderAdapterProtocolError({ | |
| 272 | + provider, | |
| 273 | + detail: `Provider thread ${thread.id} is missing its ACP session id`, | |
| 274 | + }); | |
| 275 | + } | |
| 276 | + return id; | |
| 277 | +} | |
| 278 | + | |
| 279 | +function makeProviderThread(input: { | |
| 280 | + readonly provider: ProviderKind; | |
| 281 | + readonly idAllocator: IdAllocatorV2Shape; | |
| 282 | + readonly appThreadId: OrchestrationV2ProviderThread["appThreadId"]; | |
| 283 | + readonly providerSessionId: OrchestrationV2ProviderThread["providerSessionId"]; | |
| 284 | + readonly nativeThreadId: string; | |
| 285 | + readonly ownerNodeId?: OrchestrationV2ProviderThread["ownerNodeId"]; | |
| 286 | + readonly forkedFrom?: OrchestrationV2ProviderThread["forkedFrom"]; | |
| 287 | + readonly now: DateTime.Utc; | |
| 288 | +}): OrchestrationV2ProviderThread { | |
| 289 | + return { | |
| 290 | + id: input.idAllocator.derive.providerThread({ | |
| 291 | + provider: input.provider, | |
| 292 | + nativeThreadId: input.nativeThreadId, | |
| 293 | + }), | |
| 294 | + provider: input.provider, | |
| 295 | + providerSessionId: input.providerSessionId, | |
| 296 | + appThreadId: input.appThreadId, | |
| 297 | + ownerNodeId: input.ownerNodeId ?? null, | |
| 298 | + nativeThreadRef: { | |
| 299 | + provider: input.provider, | |
| 300 | + nativeId: input.nativeThreadId, | |
| 301 | + strength: "strong", | |
| 302 | + }, | |
| 303 | + nativeConversationHeadRef: null, | |
| 304 | + status: "idle", | |
| 305 | + firstRunOrdinal: null, | |
| 306 | + lastRunOrdinal: null, | |
| 307 | + handoffIds: [], | |
| 308 | + forkedFrom: input.forkedFrom ?? null, | |
| 309 | + createdAt: input.now, | |
| 310 | + updatedAt: input.now, | |
| 311 | + }; | |
| 312 | +} | |
| 313 | + | |
| 314 | +function unknownRecord(value: unknown): Record<string, unknown> | undefined { | |
| 315 | + return typeof value === "object" && value !== null && !Array.isArray(value) | |
| 316 | + ? (value as Record<string, unknown>) | |
| 317 | + : undefined; | |
| 318 | +} | |
| 319 | + | |
| 320 | +function nonEmptyText(value: unknown, fallback: string): string { | |
| 321 | + return typeof value === "string" && value.trim().length > 0 ? value.trim() : fallback; | |
| 322 | +} | |
| 323 | + | |
| 324 | +function textFromUnknown(value: unknown): string | undefined { | |
| 325 | + if (typeof value === "string") { | |
| 326 | + return value; | |
| 327 | + } | |
| 328 | + if (Array.isArray(value)) { | |
| 329 | + const parts = value.flatMap((entry) => { | |
| 330 | + const text = textFromUnknown(entry); | |
| 331 | + return text === undefined || text.length === 0 ? [] : [text]; | |
| 332 | + }); | |
| 333 | + return parts.length === 0 ? undefined : parts.join("\n"); | |
| 334 | + } | |
| 335 | + const record = unknownRecord(value); | |
| 336 | + if (record === undefined) { | |
| 337 | + return undefined; | |
| 338 | + } | |
| 339 | + for (const key of ["stdout", "stderr", "output", "content", "text", "message"]) { | |
| 340 | + const text = textFromUnknown(record[key]); | |
| 341 | + if (text !== undefined && text.length > 0) { | |
| 342 | + return text; | |
| 343 | + } | |
| 344 | + } | |
| 345 | + return undefined; | |
| 346 | +} | |
| 347 | + | |
| 348 | +function commandExitCode(value: unknown): number | undefined { | |
| 349 | + const record = unknownRecord(value); | |
| 350 | + for (const key of ["exitCode", "exit_code", "code"]) { | |
| 351 | + const candidate = record?.[key]; | |
| 352 | + if (typeof candidate === "number" && Number.isInteger(candidate)) { | |
| 353 | + return candidate; | |
| 354 | + } | |
| 355 | + } | |
| 356 | + return undefined; | |
| 357 | +} | |
| 358 | + | |
| 359 | +function pathFromToolCall(toolCall: AcpToolCallState): string | undefined { | |
| 360 | + const locations = toolCall.data.locations; | |
| 361 | + if (Array.isArray(locations)) { | |
| 362 | + for (const location of locations) { | |
| 363 | + const path = unknownRecord(location)?.path; | |
| 364 | + if (typeof path === "string" && path.trim().length > 0) { | |
| 365 | + return path.trim(); | |
| 366 | + } | |
| 367 | + } | |
| 368 | + } | |
| 369 | + const rawInput = unknownRecord(toolCall.data.rawInput); | |
| 370 | + for (const key of ["path", "filePath", "file_path", "url", "query", "pattern"]) { | |
| 371 | + const candidate = rawInput?.[key]; | |
| 372 | + if (typeof candidate === "string" && candidate.trim().length > 0) { | |
| 373 | + return candidate.trim(); | |
| 374 | + } | |
| 375 | + } | |
| 376 | + return undefined; | |
| 377 | +} | |
| 378 | + | |
| 379 | +function providerRequestKind(kind: string | "unknown"): ProviderRequestKind { | |
| 380 | + switch (kind) { | |
| 381 | + case "execute": | |
| 382 | + return "command"; | |
| 383 | + case "read": | |
| 384 | + case "search": | |
| 385 | + case "fetch": | |
| 386 | + return "file-read"; | |
| 387 | + case "edit": | |
| 388 | + case "delete": | |
| 389 | + case "move": | |
| 390 | + return "file-change"; | |
| 391 | + default: | |
| 392 | + return "command"; | |
| 393 | + } | |
| 394 | +} | |
| 395 | + | |
| 396 | +function toolStatus( | |
| 397 | + status: AcpToolCallState["status"], | |
| 398 | +): "pending" | "running" | "completed" | "failed" { | |
| 399 | + switch (status) { | |
| 400 | + case "completed": | |
| 401 | + return "completed"; | |
| 402 | + case "failed": | |
| 403 | + return "failed"; | |
| 404 | + case "pending": | |
| 405 | + return "pending"; | |
| 406 | + default: | |
| 407 | + return "running"; | |
| 408 | + } | |
| 409 | +} | |
| 410 | + | |
| 411 | +function nodeStatus(status: ReturnType<typeof toolStatus>): OrchestrationV2ExecutionNode["status"] { | |
| 412 | + return status === "pending" ? "running" : status; | |
| 413 | +} | |
| 414 | + | |
| 415 | +function completedAtForStatus( | |
| 416 | + status: ReturnType<typeof toolStatus>, | |
| 417 | + now: DateTime.Utc, | |
| 418 | +): DateTime.Utc | null { | |
| 419 | + return status === "completed" || status === "failed" ? now : null; | |
| 420 | +} | |
| 421 | + | |
| 422 | +function selectPermissionOptionId( | |
| 423 | + request: EffectAcpSchema.RequestPermissionRequest, | |
| 424 | + decision: Exclude<ProviderApprovalDecision, "cancel">, | |
| 425 | +): string | undefined { | |
| 426 | + const kind = | |
| 427 | + decision === "acceptForSession" | |
| 428 | + ? "allow_always" | |
| 429 | + : decision === "accept" | |
| 430 | + ? "allow_once" | |
| 431 | + : "reject_once"; | |
| 432 | + return request.options.find((option) => option.kind === kind)?.optionId.trim() || undefined; | |
| 433 | +} | |
| 434 | + | |
| 435 | +function selectAutoApprovedPermissionOption( | |
| 436 | + request: EffectAcpSchema.RequestPermissionRequest, | |
| 437 | +): string | undefined { | |
| 438 | + return ( | |
| 439 | + selectPermissionOptionId(request, "acceptForSession") ?? | |
| 440 | + selectPermissionOptionId(request, "accept") | |
| 441 | + ); | |
| 442 | +} | |
| 443 | + | |
| 444 | +export type AcpPermissionDisposition = "allow" | "ask" | "deny"; | |
| 445 | + | |
| 446 | +export function acpPermissionDisposition( | |
| 447 | + runtimePolicy: ProviderAdapterV2RuntimePolicy, | |
| 448 | + request: EffectAcpSchema.RequestPermissionRequest, | |
| 449 | +): AcpPermissionDisposition { | |
| 450 | + const approvalPolicy = runtimePolicy.approvalPolicy; | |
| 451 | + const requiresApproval = | |
| 452 | + approvalPolicy === undefined | |
| 453 | + ? runtimePolicy.runtimeMode === "approval-required" | |
| 454 | + : approvalPolicy !== "never"; | |
| 455 | + if (requiresApproval) { | |
| 456 | + return "ask"; | |
| 457 | + } | |
| 458 | + | |
| 459 | + const sandboxPolicy = unknownRecord(runtimePolicy.sandboxPolicy); | |
| 460 | + const sandboxType = sandboxPolicy?.type; | |
| 461 | + const toolKind = request.toolCall.kind ?? "other"; | |
| 462 | + switch (sandboxType) { | |
| 463 | + case "readOnly": | |
| 464 | + return toolKind === "read" || toolKind === "search" || toolKind === "think" | |
| 465 | + ? "allow" | |
| 466 | + : "deny"; | |
| 467 | + case "workspaceWrite": | |
| 468 | + return toolKind === "read" || | |
| 469 | + toolKind === "search" || | |
| 470 | + toolKind === "think" || | |
| 471 | + toolKind === "edit" || | |
| 472 | + toolKind === "delete" || | |
| 473 | + toolKind === "move" | |
| 474 | + ? "allow" | |
| 475 | + : "deny"; | |
| 476 | + case "dangerFullAccess": | |
| 477 | + case "externalSandbox": | |
| 478 | + return "allow"; | |
| 479 | + case undefined: | |
| 480 | + return runtimePolicy.runtimeMode === "approval-required" ? "deny" : "allow"; | |
| 481 | + default: | |
| 482 | + return "deny"; | |
| 483 | + } | |
| 484 | +} | |
| 485 | + | |
| 486 | +function jsonRpcId(value: unknown): string | number | null { | |
| 487 | + return (typeof value === "string" && value.length > 0) || typeof value === "number" | |
| 488 | + ? value | |
| 489 | + : null; | |
| 490 | +} | |
| 491 | + | |
| 492 | +function elicitationContent( | |
| 493 | + answers: ProviderUserInputAnswers, | |
| 494 | +): Record<string, EffectAcpSchema.ElicitationContentValue> { | |
| 495 | + const content: Record<string, EffectAcpSchema.ElicitationContentValue> = {}; | |
| 496 | + for (const [key, value] of Object.entries(answers)) { | |
| 497 | + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { | |
| 498 | + content[key] = value; | |
| 499 | + } else if (Array.isArray(value)) { | |
| 500 | + content[key] = value.filter((entry): entry is string => typeof entry === "string"); | |
| 501 | + } | |
| 502 | + } | |
| 503 | + return content; | |
| 504 | +} | |
| 505 | + | |
| 506 | +function rawJsonRpcMessages(payload: unknown): ReadonlyArray<Record<string, unknown>> { | |
| 507 | + if (typeof payload !== "string") { | |
| 508 | + return []; | |
| 509 | + } | |
| 510 | + const messages: Array<Record<string, unknown>> = []; | |
| 511 | + for (const line of payload.split("\n")) { | |
| 512 | + const trimmed = line.trim(); | |
| 513 | + if (trimmed.length === 0) continue; | |
| 514 | + try { | |
| 515 | + const decoded: unknown = JSON.parse(trimmed); | |
| 516 | + if (Array.isArray(decoded)) { | |
| 517 | + for (const entry of decoded) { | |
| 518 | + const record = unknownRecord(entry); | |
| 519 | + if (record !== undefined) messages.push(record); | |
| 520 | + } | |
| 521 | + } else { | |
| 522 | + const record = unknownRecord(decoded); | |
| 523 | + if (record !== undefined) messages.push(record); | |
| 524 | + } | |
| 525 | + } catch { | |
| 526 | + messages.push({ raw: trimmed }); | |
| 527 | + } | |
| 528 | + } | |
| 529 | + return messages; | |
| 530 | +} | |
| 531 | + | |
| 532 | +interface ActiveTextSegment { | |
| 533 | + readonly nativeItemId: string; | |
| 534 | + readonly startedAt: DateTime.Utc; | |
| 535 | + text: string; | |
| 536 | +} | |
| 537 | + | |
| 538 | +interface ActiveTextStream { | |
| 539 | + current: ActiveTextSegment | null; | |
| 540 | + nextSegment: number; | |
| 541 | +} | |
| 542 | + | |
| 543 | +interface ActiveAcpTurn { | |
| 544 | + readonly input: ProviderAdapterV2TurnInput; | |
| 545 | + readonly providerTurnId: OrchestrationV2ProviderTurn["id"]; | |
| 546 | + readonly nativeTurnId: string; | |
| 547 | + readonly startedAt: DateTime.Utc; | |
| 548 | + readonly completed: Deferred.Deferred<void, never>; | |
| 549 | + readonly assistant: ActiveTextStream; | |
| 550 | + readonly reasoning: ActiveTextStream; | |
| 551 | + readonly tools: Map<string, AcpToolCallState>; | |
| 552 | + readonly toolStartedAt: Map<string, DateTime.Utc>; | |
| 553 | + plan: { | |
| 554 | + readonly id: OrchestrationV2PlanArtifact["id"]; | |
| 555 | + readonly startedAt: DateTime.Utc; | |
| 556 | + } | null; | |
| 557 | + interrupted: boolean; | |
| 558 | + finalized: boolean; | |
| 559 | +} | |
| 560 | + | |
| 561 | +type PendingRuntimeRequest = { | |
| 562 | + readonly requestId: RuntimeRequestId; | |
| 563 | + readonly runtimeRequest: OrchestrationV2RuntimeRequest; | |
| 564 | + readonly node: OrchestrationV2ExecutionNode; | |
| 565 | + readonly turnItem: OrchestrationV2TurnItem; | |
| 566 | +} & ( | |
| 567 | + | { | |
| 568 | + readonly type: "approval"; | |
| 569 | + readonly decision: Deferred.Deferred<ProviderApprovalDecision>; | |
| 570 | + } | |
| 571 | + | { | |
| 572 | + readonly type: "user_input"; | |
| 573 | + readonly answers: Deferred.Deferred<ProviderUserInputAnswers | null>; | |
| 574 | + } | |
| 575 | +); | |
| 576 | + | |
| 577 | +interface SnapshotMessageState { | |
| 578 | + readonly order: Array<string>; | |
| 579 | + readonly messages: Map<string, OrchestrationV2ConversationMessage>; | |
| 580 | + loadingRole: "user" | "assistant" | null; | |
| 581 | + loadingIndex: number; | |
| 582 | +} | |
| 583 | + | |
| 584 | +interface RawProtocolState { | |
| 585 | + readonly pendingMethods: Map<string, string>; | |
| 586 | + sequence: number; | |
| 587 | +} | |
| 588 | + | |
| 589 | +function requestKey(direction: "incoming" | "outgoing", id: string | number): string { | |
| 590 | + return `${direction}:${String(id)}`; | |
| 591 | +} | |
| 592 | + | |
| 593 | +function oppositeDirection(direction: "incoming" | "outgoing"): "incoming" | "outgoing" { | |
| 594 | + return direction === "incoming" ? "outgoing" : "incoming"; | |
| 595 | +} | |
| 596 | + | |
| 597 | +export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV2Shape { | |
| 598 | + const { flavor, fileSystem, idAllocator, serverConfig } = options; | |
| 599 | + const provider = flavor.provider; | |
| 600 | + | |
| 601 | + return ProviderAdapterV2.of({ | |
| 602 | + instanceId: options.instanceId, | |
| 603 | + provider, | |
| 604 | + getCapabilities: () => Effect.succeed(flavor.capabilities), | |
| 605 | + openSession: Effect.fn("AcpAdapterV2.openSession")( | |
| 606 | + function* (input: ProviderAdapterV2OpenSessionInput) { | |
| 607 | + const sessionScope = yield* Effect.scope; | |
| 608 | + const events = yield* Queue.unbounded<ProviderAdapterV2Event>(); | |
| 609 | + const rawEvents = yield* Queue.unbounded<OrchestrationV2RawProviderEvent>(); | |
| 610 | + const rawState = yield* Ref.make<RawProtocolState>({ | |
| 611 | + pendingMethods: new Map(), | |
| 612 | + sequence: 0, | |
| 613 | + }); | |
| 614 | + const activeTurn = yield* Ref.make<ActiveAcpTurn | null>(null); | |
| 615 | + const activeSessionId = yield* Ref.make<string | null>(null); | |
| 616 | + const pendingRuntimeRequests = yield* Ref.make(new Map<string, PendingRuntimeRequest>()); | |
| 617 | + const nextElicitationOrdinal = yield* Ref.make(0); | |
| 618 | + const itemOrdinals = yield* Ref.make(new Map<string, number>()); | |
| 619 | + const nextItemOrdinalsByTurn = yield* Ref.make(new Map<string, number>()); | |
| 620 | + const providerTurns = yield* Ref.make(new Map<string, OrchestrationV2ProviderTurn>()); | |
| 621 | + const snapshot = yield* Ref.make<SnapshotMessageState>({ | |
| 622 | + order: [], | |
| 623 | + messages: new Map(), | |
| 624 | + loadingRole: null, | |
| 625 | + loadingIndex: 0, | |
| 626 | + }); | |
| 627 | + | |
| 628 | + const emitProviderEvent = (event: ProviderAdapterV2Event) => | |
| 629 | + Queue.offer(events, event).pipe(Effect.asVoid); | |
| 630 | + | |
| 631 | + const protocolLogger = ( | |
| 632 | + event: EffectAcpProtocol.AcpProtocolLogEvent, | |
| 633 | + ): Effect.Effect<void, never> => | |
| 634 | + Effect.gen(function* () { | |
| 635 | + if (event.stage !== "raw") return; | |
| 636 | + for (const message of rawJsonRpcMessages(event.payload)) { | |
| 637 | + const methodValue = message.method; | |
| 638 | + const id = jsonRpcId(message.id); | |
| 639 | + let method = typeof methodValue === "string" ? methodValue : null; | |
| 640 | + let messageKind: OrchestrationV2RawProviderEvent["messageKind"]; | |
| 641 | + if (method !== null) { | |
| 642 | + messageKind = id === null ? "notification" : "request"; | |
| 643 | + } else if ("error" in message) { | |
| 644 | + messageKind = "error"; | |
| 645 | + } else { | |
| 646 | + messageKind = "response"; | |
| 647 | + } | |
| 648 | + const state = yield* Ref.get(rawState); | |
| 649 | + if (method !== null && id !== null) { | |
| 650 | + state.pendingMethods.set(requestKey(event.direction, id), method); | |
| 651 | + } else if (id !== null) { | |
| 652 | + method = | |
| 653 | + state.pendingMethods.get(requestKey(oppositeDirection(event.direction), id)) ?? | |
| 654 | + null; | |
| 655 | + state.pendingMethods.delete(requestKey(oppositeDirection(event.direction), id)); | |
| 656 | + } | |
| 657 | + state.sequence += 1; | |
| 658 | + const now = yield* DateTime.now; | |
| 659 | + const rawEventId = yield* idAllocator.allocate.rawEvent({ | |
| 660 | + providerSessionId: input.providerSessionId, | |
| 661 | + method, | |
| 662 | + }); | |
| 663 | + yield* Queue.offer(rawEvents, { | |
| 664 | + id: rawEventId, | |
| 665 | + provider, | |
| 666 | + providerSessionId: input.providerSessionId, | |
| 667 | + sequence: state.sequence, | |
| 668 | + direction: event.direction, | |
| 669 | + messageKind, | |
| 670 | + method, | |
| 671 | + jsonRpcId: id, | |
| 672 | + payload: message, | |
| 673 | + observedAt: now, | |
| 674 | + }); | |
| 675 | + } | |
| 676 | + }).pipe( | |
| 677 | + Effect.catchCause((cause) => | |
| 678 | + Effect.logWarning("orchestration-v2.acp-raw-event-failed", { | |
| 679 | + provider, | |
| 680 | + providerSessionId: input.providerSessionId, | |
| 681 | + cause, | |
| 682 | + }), | |
| 683 | + ), | |
| 684 | + ); | |
| 685 | + | |
| 686 | + const runtime = yield* flavor.makeRuntime({ | |
| 687 | + cwd: input.runtimePolicy.cwd ?? process.cwd(), | |
| 688 | + mcpServers: acpMcpServers(input.threadId), | |
| 689 | + clientCapabilities: { | |
| 690 | + fs: { readTextFile: false, writeTextFile: false }, | |
| 691 | + terminal: false, | |
| 692 | + elicitation: { form: {} }, | |
| 693 | + }, | |
| 694 | + clientInfo: { name: "t3-code", version: "0.0.0" }, | |
| 695 | + protocolLogging: { | |
| 696 | + logIncoming: true, | |
| 697 | + logOutgoing: true, | |
| 698 | + logger: protocolLogger, | |
| 699 | + }, | |
| 700 | + }); | |
| 701 | + | |
| 702 | + const resolveItemOrdinal = Effect.fnUntraced(function* ( | |
| 703 | + context: ActiveAcpTurn, | |
| 704 | + nativeItemId: string, | |
| 705 | + ) { | |
| 706 | + const existing = (yield* Ref.get(itemOrdinals)).get(nativeItemId); | |
| 707 | + if (existing !== undefined) return existing; | |
| 708 | + const nextWithinTurn = yield* Ref.modify(nextItemOrdinalsByTurn, (current) => { | |
| 709 | + const next = (current.get(context.nativeTurnId) ?? 0) + 1; | |
| 710 | + const updated = new Map(current); | |
| 711 | + updated.set(context.nativeTurnId, next); | |
| 712 | + return [next, updated] as const; | |
| 713 | + }); | |
| 714 | + const ordinal = context.input.runOrdinal * 100 + nextWithinTurn; | |
| 715 | + yield* Ref.update(itemOrdinals, (current) => { | |
| 716 | + const updated = new Map(current); | |
| 717 | + updated.set(nativeItemId, ordinal); | |
| 718 | + return updated; | |
| 719 | + }); | |
| 720 | + return ordinal; | |
| 721 | + }); | |
| 722 | + | |
| 723 | + const rememberSnapshotMessage = (message: OrchestrationV2ConversationMessage) => | |
| 724 | + Ref.update(snapshot, (current) => { | |
| 725 | + const key = String(message.id); | |
| 726 | + const exists = current.messages.has(key); | |
| 727 | + const messages = new Map(current.messages); | |
| 728 | + messages.set(key, message); | |
| 729 | + return { | |
| 730 | + ...current, | |
| 731 | + order: exists ? current.order : [...current.order, key], | |
| 732 | + messages, | |
| 733 | + }; | |
| 734 | + }); | |
| 735 | + | |
| 736 | + const emitTextSegment = Effect.fnUntraced(function* ( | |
| 737 | + context: ActiveAcpTurn, | |
| 738 | + kind: "assistant" | "reasoning", | |
| 739 | + completed: boolean, | |
| 740 | + ) { | |
| 741 | + const stream = kind === "assistant" ? context.assistant : context.reasoning; | |
| 742 | + const segment = stream.current; | |
| 743 | + if (segment === null || segment.text.length === 0) return; | |
| 744 | + const now = yield* DateTime.now; | |
| 745 | + const ordinal = yield* resolveItemOrdinal(context, segment.nativeItemId); | |
| 746 | + const nodeId = idAllocator.derive.nodeFromProviderItem({ | |
| 747 | + provider, | |
| 748 | + nativeItemId: segment.nativeItemId, | |
| 749 | + }); | |
| 750 | + const turnItemId = idAllocator.derive.turnItemFromProviderItem({ | |
| 751 | + provider, | |
| 752 | + nativeItemId: segment.nativeItemId, | |
| 753 | + }); | |
| 754 | + const nativeItemRef = { | |
| 755 | + provider, | |
| 756 | + nativeId: segment.nativeItemId, | |
| 757 | + strength: "weak" as const, | |
| 758 | + }; | |
| 759 | + yield* emitProviderEvent({ | |
| 760 | + type: "node.updated", | |
| 761 | + provider, | |
| 762 | + node: { | |
| 763 | + id: nodeId, | |
| 764 | + threadId: context.input.threadId, | |
| 765 | + runId: context.input.runId, | |
| 766 | + parentNodeId: context.input.rootNodeId, | |
| 767 | + rootNodeId: context.input.rootNodeId, | |
| 768 | + kind: kind === "assistant" ? "assistant_message" : "reasoning", | |
| 769 | + status: completed ? "completed" : "running", | |
| 770 | + countsForRun: false, | |
| 771 | + providerThreadId: context.input.providerThread.id, | |
| 772 | + providerTurnId: context.providerTurnId, | |
| 773 | + nativeItemRef, | |
| 774 | + runtimeRequestId: null, | |
| 775 | + checkpointScopeId: null, | |
| 776 | + startedAt: segment.startedAt, | |
| 777 | + completedAt: completed ? now : null, | |
| 778 | + }, | |
| 779 | + }); | |
| 780 | + if (kind === "assistant") { | |
| 781 | + const messageId = idAllocator.derive.messageFromProviderItem({ | |
| 782 | + provider, | |
| 783 | + nativeItemId: segment.nativeItemId, | |
| 784 | + }); | |
| 785 | + const message: OrchestrationV2ConversationMessage = { | |
| 786 | + id: messageId, | |
| 787 | + threadId: context.input.threadId, | |
| 788 | + runId: context.input.runId, | |
| 789 | + nodeId, | |
| 790 | + role: "assistant", | |
| 791 | + text: segment.text, | |
| 792 | + attachments: [], | |
| 793 | + streaming: !completed, | |
| 794 | + createdAt: segment.startedAt, | |
| 795 | + updatedAt: now, | |
| 796 | + }; | |
| 797 | + yield* emitProviderEvent({ type: "message.updated", provider, message }); | |
| 798 | + yield* emitProviderEvent({ | |
| 799 | + type: "turn_item.updated", | |
| 800 | + provider, | |
| 801 | + turnItem: { | |
| 802 | + id: turnItemId, | |
| 803 | + threadId: context.input.threadId, | |
| 804 | + runId: context.input.runId, | |
| 805 | + nodeId, | |
| 806 | + providerThreadId: context.input.providerThread.id, | |
| 807 | + providerTurnId: context.providerTurnId, | |
| 808 | + nativeItemRef, | |
| 809 | + parentItemId: null, | |
| 810 | + ordinal, | |
| 811 | + status: completed ? "completed" : "running", | |
| 812 | + title: null, | |
| 813 | + startedAt: segment.startedAt, | |
| 814 | + completedAt: completed ? now : null, | |
| 815 | + updatedAt: now, | |
| 816 | + type: "assistant_message", | |
| 817 | + messageId, | |
| 818 | + text: segment.text, | |
| 819 | + streaming: !completed, | |
| 820 | + }, | |
| 821 | + }); | |
| 822 | + if (completed) yield* rememberSnapshotMessage(message); | |
| 823 | + return; | |
| 824 | + } | |
| 825 | + yield* emitProviderEvent({ | |
| 826 | + type: "turn_item.updated", | |
| 827 | + provider, | |
| 828 | + turnItem: { | |
| 829 | + id: turnItemId, | |
| 830 | + threadId: context.input.threadId, | |
| 831 | + runId: context.input.runId, | |
| 832 | + nodeId, | |
| 833 | + providerThreadId: context.input.providerThread.id, | |
| 834 | + providerTurnId: context.providerTurnId, | |
| 835 | + nativeItemRef, | |
| 836 | + parentItemId: null, | |
| 837 | + ordinal, | |
| 838 | + status: completed ? "completed" : "running", | |
| 839 | + title: null, | |
| 840 | + startedAt: segment.startedAt, | |
| 841 | + completedAt: completed ? now : null, | |
| 842 | + updatedAt: now, | |
| 843 | + type: "reasoning", | |
| 844 | + text: segment.text, | |
| 845 | + streaming: !completed, | |
| 846 | + }, | |
| 847 | + }); | |
| 848 | + }); | |
| 849 | + | |
| 850 | + const closeTextStream = Effect.fnUntraced(function* ( | |
| 851 | + context: ActiveAcpTurn, | |
| 852 | + kind: "assistant" | "reasoning", | |
| 853 | + ) { | |
| 854 | + const stream = kind === "assistant" ? context.assistant : context.reasoning; | |
| 855 | + if (stream.current === null) return; | |
| 856 | + yield* emitTextSegment(context, kind, true); | |
| 857 | + stream.current = null; | |
| 858 | + }); | |
| 859 | + | |
| 860 | + const closeTextStreams = Effect.fnUntraced(function* (context: ActiveAcpTurn) { | |
| 861 | + yield* closeTextStream(context, "reasoning"); | |
| 862 | + yield* closeTextStream(context, "assistant"); | |
| 863 | + }); | |
| 864 | + | |
| 865 | + const appendText = Effect.fnUntraced(function* ( | |
| 866 | + context: ActiveAcpTurn, | |
| 867 | + kind: "assistant" | "reasoning", | |
| 868 | + text: string, | |
| 869 | + ) { | |
| 870 | + if (text.length === 0) return; | |
| 871 | + const other = kind === "assistant" ? "reasoning" : "assistant"; | |
| 872 | + yield* closeTextStream(context, other); | |
| 873 | + const stream = kind === "assistant" ? context.assistant : context.reasoning; | |
| 874 | + if (stream.current === null) { | |
| 875 | + const now = yield* DateTime.now; | |
| 876 | + stream.current = { | |
| 877 | + nativeItemId: `${context.nativeTurnId}:${kind}:${stream.nextSegment}`, | |
| 878 | + startedAt: now, | |
| 879 | + text: "", | |
| 880 | + }; | |
| 881 | + stream.nextSegment += 1; | |
| 882 | + } | |
| 883 | + stream.current.text += text; | |
| 884 | + yield* emitTextSegment(context, kind, false); | |
| 885 | + }); | |
| 886 | + | |
| 887 | + const emitTool = Effect.fnUntraced(function* ( | |
| 888 | + context: ActiveAcpTurn, | |
| 889 | + incoming: AcpToolCallState, | |
| 890 | + ) { | |
| 891 | + yield* closeTextStreams(context); | |
| 892 | + const previous = context.tools.get(incoming.toolCallId); | |
| 893 | + const toolCall = mergeToolCallState(previous, incoming); | |
| 894 | + context.tools.set(toolCall.toolCallId, toolCall); | |
| 895 | + const status = toolStatus(toolCall.status); | |
| 896 | + const now = yield* DateTime.now; | |
| 897 | + const nativeItemId = `${nativeThreadId(provider, context.input.providerThread)}:tool:${toolCall.toolCallId}`; | |
| 898 | + const ordinal = yield* resolveItemOrdinal(context, nativeItemId); | |
| 899 | + const nodeId = idAllocator.derive.nodeFromProviderItem({ provider, nativeItemId }); | |
| 900 | + const turnItemId = idAllocator.derive.turnItemFromProviderItem({ | |
| 901 | + provider, | |
| 902 | + nativeItemId, | |
| 903 | + }); | |
| 904 | + const nativeItemRef = { | |
| 905 | + provider, | |
| 906 | + nativeId: toolCall.toolCallId, | |
| 907 | + strength: "strong" as const, | |
| 908 | + }; | |
| 909 | + const startedAt = context.toolStartedAt.get(toolCall.toolCallId) ?? now; | |
| 910 | + context.toolStartedAt.set(toolCall.toolCallId, startedAt); | |
| 911 | + const completedAt = completedAtForStatus(status, now); | |
| 912 | + const title = toolCall.title ?? null; | |
| 913 | + yield* emitProviderEvent({ | |
| 914 | + type: "node.updated", | |
| 915 | + provider, | |
| 916 | + node: { | |
| 917 | + id: nodeId, | |
| 918 | + threadId: context.input.threadId, | |
| 919 | + runId: context.input.runId, | |
| 920 | + parentNodeId: context.input.rootNodeId, | |
| 921 | + rootNodeId: context.input.rootNodeId, | |
| 922 | + kind: "tool_call", | |
| 923 | + status: nodeStatus(status), | |
| 924 | + countsForRun: true, | |
| 925 | + providerThreadId: context.input.providerThread.id, | |
| 926 | + providerTurnId: context.providerTurnId, | |
| 927 | + nativeItemRef, | |
| 928 | + runtimeRequestId: null, | |
| 929 | + checkpointScopeId: null, | |
| 930 | + startedAt, | |
| 931 | + completedAt, | |
| 932 | + }, | |
| 933 | + }); | |
| 934 | + | |
| 935 | + const base = { | |
| 936 | + id: turnItemId, | |
| 937 | + threadId: context.input.threadId, | |
| 938 | + runId: context.input.runId, | |
| 939 | + nodeId, | |
| 940 | + providerThreadId: context.input.providerThread.id, | |
| 941 | + providerTurnId: context.providerTurnId, | |
| 942 | + nativeItemRef, | |
| 943 | + parentItemId: null, | |
| 944 | + ordinal, | |
| 945 | + status, | |
| 946 | + title, | |
| 947 | + startedAt, | |
| 948 | + completedAt, | |
| 949 | + updatedAt: now, | |
| 950 | + } as const; | |
| 951 | + const rawInput = toolCall.data.rawInput; | |
| 952 | + const rawOutput = toolCall.data.rawOutput ?? toolCall.data.content; | |
| 953 | + const path = pathFromToolCall(toolCall); | |
| 954 | + let turnItem: OrchestrationV2TurnItem; | |
| 955 | + switch (toolCall.kind) { | |
| 956 | + case "read": | |
| 957 | + case "search": | |
| 958 | + turnItem = { | |
| 959 | + ...base, | |
| 960 | + type: "file_search", | |
| 961 | + ...(path === undefined ? {} : { pattern: path }), | |
| 962 | + ...(path === undefined | |
| 963 | + ? {} | |
| 964 | + : { | |
| 965 | + results: [ | |
| 966 | + { | |
| 967 | + fileName: path, | |
| 968 | + ...(textFromUnknown(rawOutput) === undefined | |
| 969 | + ? {} | |
| 970 | + : { preview: textFromUnknown(rawOutput) }), | |
| 971 | + }, | |
| 972 | + ], | |
| 973 | + }), | |
| 974 | + }; | |
| 975 | + break; | |
| 976 | + case "execute": | |
| 977 | + turnItem = { | |
| 978 | + ...base, | |
| 979 | + type: "command_execution", | |
| 980 | + input: toolCall.command ?? toolCall.title ?? "Command", | |
| 981 | + ...(textFromUnknown(rawOutput) === undefined | |
| 982 | + ? {} | |
| 983 | + : { output: textFromUnknown(rawOutput) }), | |
| 984 | + ...(commandExitCode(rawOutput) === undefined | |
| 985 | + ? {} | |
| 986 | + : { exitCode: commandExitCode(rawOutput) }), | |
| 987 | + }; | |
| 988 | + break; | |
| 989 | + case "edit": | |
| 990 | + case "delete": | |
| 991 | + case "move": | |
| 992 | + turnItem = { | |
| 993 | + ...base, | |
| 994 | + type: "file_change", | |
| 995 | + fileName: path ?? toolCall.title ?? "File change", | |
| 996 | + ...(textFromUnknown(rawOutput) === undefined | |
| 997 | + ? {} | |
| 998 | + : { diffStr: textFromUnknown(rawOutput) }), | |
| 999 | + }; | |
| 1000 | + break; | |
| 1001 | + case "fetch": | |
| 1002 | + turnItem = { | |
| 1003 | + ...base, | |
| 1004 | + type: "web_search", | |
| 1005 | + ...(path === undefined ? {} : { patterns: [path] }), | |
| 1006 | + ...(path === undefined | |
| 1007 | + ? {} | |
| 1008 | + : { | |
| 1009 | + results: [ | |
| 1010 | + { | |
| 1011 | + url: path, | |
| 1012 | + ...(textFromUnknown(rawOutput) === undefined | |
| 1013 | + ? {} | |
| 1014 | + : { snippet: textFromUnknown(rawOutput) }), | |
| 1015 | + }, | |
| 1016 | + ], | |
| 1017 | + }), | |
| 1018 | + }; | |
| 1019 | + break; | |
| 1020 | + default: | |
| 1021 | + turnItem = { | |
| 1022 | + ...base, | |
| 1023 | + type: "dynamic_tool", | |
| 1024 | + toolName: toolCall.title ?? toolCall.kind ?? null, | |
| 1025 | + input: rawInput ?? {}, | |
| 1026 | + ...(rawOutput === undefined ? {} : { output: rawOutput }), | |
| 1027 | + }; | |
| 1028 | + } | |
| 1029 | + yield* emitProviderEvent({ type: "turn_item.updated", provider, turnItem }); | |
| 1030 | + }); | |
| 1031 | + | |
| 1032 | + const emitPlan = Effect.fnUntraced(function* ( | |
| 1033 | + context: ActiveAcpTurn, | |
| 1034 | + update: AcpPlanUpdate, | |
| 1035 | + ) { | |
| 1036 | + yield* closeTextStreams(context); | |
| 1037 | + const nativeItemId = `${context.nativeTurnId}:plan`; | |
| 1038 | + const ordinal = yield* resolveItemOrdinal(context, nativeItemId); | |
| 1039 | + const now = yield* DateTime.now; | |
| 1040 | + const nodeId = idAllocator.derive.nodeFromProviderItem({ provider, nativeItemId }); | |
| 1041 | + const turnItemId = idAllocator.derive.turnItemFromProviderItem({ | |
| 1042 | + provider, | |
| 1043 | + nativeItemId, | |
| 1044 | + }); | |
| 1045 | + if (context.plan === null) { | |
| 1046 | + context.plan = { | |
| 1047 | + id: yield* idAllocator.allocate.plan({ | |
| 1048 | + threadId: context.input.threadId, | |
| 1049 | + runId: context.input.runId, | |
| 1050 | + provider, | |
| 1051 | + }), | |
| 1052 | + startedAt: now, | |
| 1053 | + }; | |
| 1054 | + } | |
| 1055 | + const planId = context.plan.id; | |
| 1056 | + const steps: ReadonlyArray<OrchestrationV2PlanStep> = update.plan.map((step, index) => ({ | |
| 1057 | + id: `acp-step-${index + 1}`, | |
| 1058 | + text: nonEmptyText(step.step, `Step ${index + 1}`), | |
| 1059 | + status: | |
| 1060 | + step.status === "inProgress" | |
| 1061 | + ? "running" | |
| 1062 | + : step.status === "completed" | |
| 1063 | + ? "completed" | |
| 1064 | + : "pending", | |
| 1065 | + })); | |
| 1066 | + const completed = steps.length > 0 && steps.every((step) => step.status === "completed"); | |
| 1067 | + const nativeItemRef = { provider, nativeId: nativeItemId, strength: "weak" as const }; | |
| 1068 | + const plan: OrchestrationV2PlanArtifact = { | |
| 1069 | + id: planId, | |
| 1070 | + threadId: context.input.threadId, | |
| 1071 | + runId: context.input.runId, | |
| 1072 | + nodeId, | |
| 1073 | + status: completed ? "completed" : "active", | |
| 1074 | + kind: "todo_list", | |
| 1075 | + steps, | |
| 1076 | + ...(update.explanation == null ? {} : { explanation: update.explanation }), | |
| 1077 | + }; | |
| 1078 | + yield* emitProviderEvent({ | |
| 1079 | + type: "node.updated", | |
| 1080 | + provider, | |
| 1081 | + node: { | |
| 1082 | + id: nodeId, | |
| 1083 | + threadId: context.input.threadId, | |
| 1084 | + runId: context.input.runId, | |
| 1085 | + parentNodeId: context.input.rootNodeId, | |
| 1086 | + rootNodeId: context.input.rootNodeId, | |
| 1087 | + kind: "todo_list", | |
| 1088 | + status: completed ? "completed" : "running", | |
| 1089 | + countsForRun: false, | |
| 1090 | + providerThreadId: context.input.providerThread.id, | |
| 1091 | + providerTurnId: context.providerTurnId, | |
| 1092 | + nativeItemRef, | |
| 1093 | + runtimeRequestId: null, | |
| 1094 | + checkpointScopeId: null, | |
| 1095 | + startedAt: context.plan.startedAt, | |
| 1096 | + completedAt: completed ? now : null, | |
| 1097 | + }, | |
| 1098 | + }); | |
| 1099 | + yield* emitProviderEvent({ type: "plan.updated", provider, plan }); | |
| 1100 | + yield* emitProviderEvent({ | |
| 1101 | + type: "turn_item.updated", | |
| 1102 | + provider, | |
| 1103 | + turnItem: { | |
| 1104 | + id: turnItemId, | |
| 1105 | + threadId: context.input.threadId, | |
| 1106 | + runId: context.input.runId, | |
| 1107 | + nodeId, | |
| 1108 | + providerThreadId: context.input.providerThread.id, | |
| 1109 | + providerTurnId: context.providerTurnId, | |
| 1110 | + nativeItemRef, | |
| 1111 | + parentItemId: null, | |
| 1112 | + ordinal, | |
| 1113 | + status: completed ? "completed" : "running", | |
| 1114 | + title: null, | |
| 1115 | + startedAt: context.plan.startedAt, | |
| 1116 | + completedAt: completed ? now : null, | |
| 1117 | + updatedAt: now, | |
| 1118 | + type: "todo_list", | |
| 1119 | + planId, | |
| 1120 | + steps, | |
| 1121 | + ...(update.explanation == null ? {} : { explanation: update.explanation }), | |
| 1122 | + }, | |
| 1123 | + }); | |
| 1124 | + }); | |
| 1125 | + | |
| 1126 | + const appendLoadedHistory = ( | |
| 1127 | + notification: EffectAcpSchema.SessionNotification, | |
| 1128 | + role: "user" | "assistant", | |
| 1129 | + text: string, | |
| 1130 | + ) => | |
| 1131 | + Effect.gen(function* () { | |
| 1132 | + if (text.length === 0) return; | |
| 1133 | + const now = yield* DateTime.now; | |
| 1134 | + yield* Ref.update(snapshot, (current) => { | |
| 1135 | + const startsNew = current.loadingRole !== role; | |
| 1136 | + const loadingIndex = startsNew ? current.loadingIndex + 1 : current.loadingIndex; | |
| 1137 | + const nativeItemId = `${notification.sessionId}:history:${role}:${loadingIndex}`; | |
| 1138 | + const messageId = idAllocator.derive.messageFromProviderItem({ | |
| 1139 | + provider, | |
| 1140 | + nativeItemId, | |
| 1141 | + }); | |
| 1142 | + const key = String(messageId); | |
| 1143 | + const previous = current.messages.get(key); | |
| 1144 | + const messages = new Map(current.messages); | |
| 1145 | + messages.set(key, { | |
| 1146 | + id: messageId, | |
| 1147 | + threadId: input.threadId, | |
| 1148 | + runId: null, | |
| 1149 | + nodeId: null, | |
| 1150 | + role, | |
| 1151 | + text: `${previous?.text ?? ""}${text}`, | |
| 1152 | + attachments: [], | |
| 1153 | + streaming: false, | |
| 1154 | + createdAt: previous?.createdAt ?? now, | |
| 1155 | + updatedAt: now, | |
| 1156 | + }); | |
| 1157 | + return { | |
| 1158 | + order: current.order.includes(key) ? current.order : [...current.order, key], | |
| 1159 | + messages, | |
| 1160 | + loadingRole: role, | |
| 1161 | + loadingIndex, | |
| 1162 | + }; | |
| 1163 | + }); | |
| 1164 | + }); | |
| 1165 | + | |
| 1166 | + const handleSessionUpdate = Effect.fnUntraced(function* ( | |
| 1167 | + notification: EffectAcpSchema.SessionNotification, | |
| 1168 | + ) { | |
| 1169 | + const context = yield* Ref.get(activeTurn); | |
| 1170 | + const update = notification.update; | |
| 1171 | + if (context === null) { | |
| 1172 | + if ( | |
| 1173 | + (update.sessionUpdate === "user_message_chunk" || | |
| 1174 | + update.sessionUpdate === "agent_message_chunk") && | |
| 1175 | + update.content.type === "text" | |
| 1176 | + ) { | |
| 1177 | + yield* appendLoadedHistory( | |
| 1178 | + notification, | |
| 1179 | + update.sessionUpdate === "user_message_chunk" ? "user" : "assistant", | |
| 1180 | + update.content.text, | |
| 1181 | + ); | |
| 1182 | + } else if ( | |
| 1183 | + update.sessionUpdate === "tool_call" || | |
| 1184 | + update.sessionUpdate === "tool_call_update" || | |
| 1185 | + update.sessionUpdate === "plan" | |
| 1186 | + ) { | |
| 1187 | + yield* Ref.update(snapshot, (current) => ({ ...current, loadingRole: null })); | |
| 1188 | + } | |
| 1189 | + return; | |
| 1190 | + } | |
| 1191 | + if (context.finalized) return; | |
| 1192 | + if (notification.sessionId !== (yield* Ref.get(activeSessionId))) return; | |
| 1193 | + switch (update.sessionUpdate) { | |
| 1194 | + case "agent_message_chunk": | |
| 1195 | + if (update.content.type === "text") { | |
| 1196 | + yield* appendText(context, "assistant", update.content.text); | |
| 1197 | + } | |
| 1198 | + return; | |
| 1199 | + case "agent_thought_chunk": | |
| 1200 | + if (update.content.type === "text") { | |
| 1201 | + yield* appendText(context, "reasoning", update.content.text); | |
| 1202 | + } | |
| 1203 | + return; | |
| 1204 | + default: { | |
| 1205 | + const parsed = parseSessionUpdateEvent(notification); | |
| 1206 | + for (const event of parsed.events) { | |
| 1207 | + if (event._tag === "ToolCallUpdated") { | |
| 1208 | + yield* emitTool(context, event.toolCall); | |
| 1209 | + } else if (event._tag === "PlanUpdated") { | |
| 1210 | + yield* emitPlan(context, event.payload); | |
| 1211 | + } | |
| 1212 | + } | |
| 1213 | + } | |
| 1214 | + } | |
| 1215 | + }); | |
| 1216 | + | |
| 1217 | + yield* runtime.handleSessionUpdate((notification) => | |
| 1218 | + handleSessionUpdate(notification).pipe( | |
| 1219 | + Effect.mapError( | |
| 1220 | + (cause) => | |
| 1221 | + new EffectAcpErrors.AcpTransportError({ | |
| 1222 | + detail: "Failed to project an ACP session update", | |
| 1223 | + cause, | |
| 1224 | + }), | |
| 1225 | + ), | |
| 1226 | + ), | |
| 1227 | + ); | |
| 1228 | + | |
| 1229 | + const activeContext = Effect.gen(function* () { | |
| 1230 | + const context = yield* Ref.get(activeTurn); | |
| 1231 | + if (context === null) { | |
| 1232 | + return yield* new EffectAcpErrors.AcpTransportError({ | |
| 1233 | + detail: "ACP agent requested input without an active turn", | |
| 1234 | + cause: "No active ACP turn", | |
| 1235 | + }); | |
| 1236 | + } | |
| 1237 | + return context; | |
| 1238 | + }); | |
| 1239 | + | |
| 1240 | + const emitApprovalRequest = Effect.fnUntraced(function* ( | |
| 1241 | + context: ActiveAcpTurn, | |
| 1242 | + params: EffectAcpSchema.RequestPermissionRequest, | |
| 1243 | + ) { | |
| 1244 | + yield* closeTextStreams(context); | |
| 1245 | + const parsed = parsePermissionRequest(params); | |
| 1246 | + const nativeRequestId = params.toolCall.toolCallId; | |
| 1247 | + const requestId = yield* idAllocator.allocate.runtimeRequest({ | |
| 1248 | + provider, | |
| 1249 | + providerTurnId: context.providerTurnId, | |
| 1250 | + nativeRequestId, | |
| 1251 | + }); | |
| 1252 | + const decision = yield* Deferred.make<ProviderApprovalDecision>(); | |
| 1253 | + const now = yield* DateTime.now; | |
| 1254 | + const nodeId = idAllocator.derive.approvalNode({ requestId }); | |
| 1255 | + const requestKind = providerRequestKind(parsed.kind); | |
| 1256 | + const nativeItemRef = { provider, nativeId: nativeRequestId, strength: "weak" as const }; | |
| 1257 | + const ordinal = yield* resolveItemOrdinal( | |
| 1258 | + context, | |
| 1259 | + `${context.nativeTurnId}:approval:${nativeRequestId}`, | |
| 1260 | + ); | |
| 1261 | + const runtimeRequest: OrchestrationV2RuntimeRequest = { | |
| 1262 | + id: requestId, | |
| 1263 | + nodeId, | |
| 1264 | + providerTurnId: context.providerTurnId, | |
| 1265 | + nativeRequestRef: nativeItemRef, | |
| 1266 | + kind: requestKind, | |
| 1267 | + status: "pending", | |
| 1268 | + responseCapability: { | |
| 1269 | + type: "live", | |
| 1270 | + providerSessionId: input.providerSessionId, | |
| 1271 | + }, | |
| 1272 | + createdAt: now, | |
| 1273 | + resolvedAt: null, | |
| 1274 | + }; | |
| 1275 | + const node: OrchestrationV2ExecutionNode = { | |
| 1276 | + id: nodeId, | |
| 1277 | + threadId: context.input.threadId, | |
| 1278 | + runId: context.input.runId, | |
| 1279 | + parentNodeId: context.input.rootNodeId, | |
| 1280 | + rootNodeId: context.input.rootNodeId, | |
| 1281 | + kind: "approval_request", | |
| 1282 | + status: "waiting", | |
| 1283 | + countsForRun: false, | |
| 1284 | + providerThreadId: context.input.providerThread.id, | |
| 1285 | + providerTurnId: context.providerTurnId, | |
| 1286 | + nativeItemRef, | |
| 1287 | + runtimeRequestId: requestId, | |
| 1288 | + checkpointScopeId: null, | |
| 1289 | + startedAt: now, | |
| 1290 | + completedAt: null, | |
| 1291 | + }; | |
| 1292 | + const turnItem: OrchestrationV2TurnItem = { | |
| 1293 | + id: idAllocator.derive.approvalTurnItem({ requestId }), | |
| 1294 | + threadId: context.input.threadId, | |
| 1295 | + runId: context.input.runId, | |
| 1296 | + nodeId, | |
| 1297 | + providerThreadId: context.input.providerThread.id, | |
| 1298 | + providerTurnId: context.providerTurnId, | |
| 1299 | + nativeItemRef, | |
| 1300 | + parentItemId: null, | |
| 1301 | + ordinal, | |
| 1302 | + status: "waiting", | |
| 1303 | + title: null, | |
| 1304 | + startedAt: now, | |
| 1305 | + completedAt: null, | |
| 1306 | + updatedAt: now, | |
| 1307 | + type: "approval_request", | |
| 1308 | + requestId, | |
| 1309 | + requestKind, | |
| 1310 | + ...(parsed.detail === undefined ? {} : { prompt: parsed.detail }), | |
| 1311 | + }; | |
| 1312 | + yield* Ref.update(pendingRuntimeRequests, (current) => { | |
| 1313 | + const updated = new Map(current); | |
| 1314 | + updated.set(String(requestId), { | |
| 1315 | + type: "approval", | |
| 1316 | + requestId, | |
| 1317 | + decision, | |
| 1318 | + runtimeRequest, | |
| 1319 | + node, | |
| 1320 | + turnItem, | |
| 1321 | + }); | |
| 1322 | + return updated; | |
| 1323 | + }); | |
| 1324 | + yield* emitProviderEvent({ | |
| 1325 | + type: "node.updated", | |
| 1326 | + provider, | |
| 1327 | + node, | |
| 1328 | + }); | |
| 1329 | + yield* emitProviderEvent({ | |
| 1330 | + type: "runtime_request.updated", | |
| 1331 | + provider, | |
| 1332 | + threadId: context.input.threadId, | |
| 1333 | + runtimeRequest, | |
| 1334 | + }); | |
| 1335 | + yield* emitProviderEvent({ | |
| 1336 | + type: "turn_item.updated", | |
| 1337 | + provider, | |
| 1338 | + turnItem, | |
| 1339 | + }); | |
| 1340 | + const resolved = yield* Deferred.await(decision).pipe( | |
| 1341 | + Effect.ensuring( | |
| 1342 | + Ref.update(pendingRuntimeRequests, (current) => { | |
| 1343 | + const updated = new Map(current); | |
| 1344 | + updated.delete(String(requestId)); | |
| 1345 | + return updated; | |
| 1346 | + }), | |
| 1347 | + ), | |
| 1348 | + ); | |
| 1349 | + return resolved; | |
| 1350 | + }); | |
| 1351 | + | |
| 1352 | + yield* runtime.handleRequestPermission((params) => | |
| 1353 | + Effect.gen(function* () { | |
| 1354 | + const context = yield* activeContext; | |
| 1355 | + const disposition = acpPermissionDisposition(context.input.runtimePolicy, params); | |
| 1356 | + if (disposition === "allow") { | |
| 1357 | + const optionId = selectAutoApprovedPermissionOption(params); | |
| 1358 | + return optionId === undefined | |
| 1359 | + ? ({ outcome: { outcome: "cancelled" } } as const) | |
| 1360 | + : ({ outcome: { outcome: "selected", optionId } } as const); | |
| 1361 | + } | |
| 1362 | + if (disposition === "deny") { | |
| 1363 | + const optionId = selectPermissionOptionId(params, "decline"); | |
| 1364 | + return optionId === undefined | |
| 1365 | + ? ({ outcome: { outcome: "cancelled" } } as const) | |
| 1366 | + : ({ outcome: { outcome: "selected", optionId } } as const); | |
| 1367 | + } | |
| 1368 | + const decision = yield* emitApprovalRequest(context, params); | |
| 1369 | + if (decision === "cancel") { | |
| 1370 | + return { outcome: { outcome: "cancelled" } } as const; | |
| 1371 | + } | |
| 1372 | + const optionId = selectPermissionOptionId(params, decision); | |
| 1373 | + return optionId === undefined | |
| 1374 | + ? ({ outcome: { outcome: "cancelled" } } as const) | |
| 1375 | + : ({ outcome: { outcome: "selected", optionId } } as const); | |
| 1376 | + }).pipe( | |
| 1377 | + Effect.mapError( | |
| 1378 | + (cause) => | |
| 1379 | + new EffectAcpErrors.AcpTransportError({ | |
| 1380 | + detail: "Failed to handle an ACP permission request", | |
| 1381 | + cause, | |
| 1382 | + }), | |
| 1383 | + ), | |
| 1384 | + ), | |
| 1385 | + ); | |
| 1386 | + | |
| 1387 | + const requestUserInputInternal = Effect.fnUntraced(function* ( | |
| 1388 | + request: AcpAdapterV2UserInputRequest, | |
| 1389 | + ) { | |
| 1390 | + const context = yield* activeContext; | |
| 1391 | + yield* closeTextStreams(context); | |
| 1392 | + const requestId = yield* idAllocator.allocate.runtimeRequest({ | |
| 1393 | + provider, | |
| 1394 | + providerTurnId: context.providerTurnId, | |
| 1395 | + nativeRequestId: request.nativeRequestId, | |
| 1396 | + }); | |
| 1397 | + const answers = yield* Deferred.make<ProviderUserInputAnswers | null>(); | |
| 1398 | + const now = yield* DateTime.now; | |
| 1399 | + const nodeId = idAllocator.derive.nodeFromProviderItem({ | |
| 1400 | + provider, | |
| 1401 | + nativeItemId: request.nativeItemId, | |
| 1402 | + }); | |
| 1403 | + const turnItemId = idAllocator.derive.turnItemFromProviderItem({ | |
| 1404 | + provider, | |
| 1405 | + nativeItemId: request.nativeItemId, | |
| 1406 | + }); | |
| 1407 | + const nativeItemRef = { | |
| 1408 | + provider, | |
| 1409 | + nativeId: request.nativeItemId, | |
| 1410 | + strength: "weak" as const, | |
| 1411 | + }; | |
| 1412 | + const ordinal = yield* resolveItemOrdinal(context, request.nativeItemId); | |
| 1413 | + const runtimeRequest: OrchestrationV2RuntimeRequest = { | |
| 1414 | + id: requestId, | |
| 1415 | + nodeId, | |
| 1416 | + providerTurnId: context.providerTurnId, | |
| 1417 | + nativeRequestRef: { | |
| 1418 | + provider, | |
| 1419 | + nativeId: request.nativeRequestId, | |
| 1420 | + strength: "weak", | |
| 1421 | + }, | |
| 1422 | + kind: "user_input", | |
| 1423 | + status: "pending", | |
| 1424 | + responseCapability: { | |
| 1425 | + type: "live", | |
| 1426 | + providerSessionId: input.providerSessionId, | |
| 1427 | + }, | |
| 1428 | + createdAt: now, | |
| 1429 | + resolvedAt: null, | |
| 1430 | + }; | |
| 1431 | + const node: OrchestrationV2ExecutionNode = { | |
| 1432 | + id: nodeId, | |
| 1433 | + threadId: context.input.threadId, | |
| 1434 | + runId: context.input.runId, | |
| 1435 | + parentNodeId: context.input.rootNodeId, | |
| 1436 | + rootNodeId: context.input.rootNodeId, | |
| 1437 | + kind: "user_input_request", | |
| 1438 | + status: "waiting", | |
| 1439 | + countsForRun: false, | |
| 1440 | + providerThreadId: context.input.providerThread.id, | |
| 1441 | + providerTurnId: context.providerTurnId, | |
| 1442 | + nativeItemRef, | |
| 1443 | + runtimeRequestId: requestId, | |
| 1444 | + checkpointScopeId: null, | |
| 1445 | + startedAt: now, | |
| 1446 | + completedAt: null, | |
| 1447 | + }; | |
| 1448 | + const turnItem: OrchestrationV2TurnItem = { | |
| 1449 | + id: turnItemId, | |
| 1450 | + threadId: context.input.threadId, | |
| 1451 | + runId: context.input.runId, | |
| 1452 | + nodeId, | |
| 1453 | + providerThreadId: context.input.providerThread.id, | |
| 1454 | + providerTurnId: context.providerTurnId, | |
| 1455 | + nativeItemRef, | |
| 1456 | + parentItemId: null, | |
| 1457 | + ordinal, | |
| 1458 | + status: "waiting", | |
| 1459 | + title: null, | |
| 1460 | + startedAt: now, | |
| 1461 | + completedAt: null, | |
| 1462 | + updatedAt: now, | |
| 1463 | + type: "user_input_request", | |
| 1464 | + requestId, | |
| 1465 | + questions: [...request.questions], | |
| 1466 | + }; | |
| 1467 | + yield* Ref.update(pendingRuntimeRequests, (current) => { | |
| 1468 | + const updated = new Map(current); | |
| 1469 | + updated.set(String(requestId), { | |
| 1470 | + type: "user_input", | |
| 1471 | + requestId, | |
| 1472 | + answers, | |
| 1473 | + runtimeRequest, | |
| 1474 | + node, | |
| 1475 | + turnItem, | |
| 1476 | + }); | |
| 1477 | + return updated; | |
| 1478 | + }); | |
| 1479 | + yield* emitProviderEvent({ | |
| 1480 | + type: "node.updated", | |
| 1481 | + provider, | |
| 1482 | + node, | |
| 1483 | + }); | |
| 1484 | + yield* emitProviderEvent({ | |
| 1485 | + type: "runtime_request.updated", | |
| 1486 | + provider, | |
| 1487 | + threadId: context.input.threadId, | |
| 1488 | + runtimeRequest, | |
| 1489 | + }); | |
| 1490 | + yield* emitProviderEvent({ | |
| 1491 | + type: "turn_item.updated", | |
| 1492 | + provider, | |
| 1493 | + turnItem, | |
| 1494 | + }); | |
| 1495 | + return yield* Deferred.await(answers).pipe( | |
| 1496 | + Effect.ensuring( | |
| 1497 | + Ref.update(pendingRuntimeRequests, (current) => { | |
| 1498 | + const updated = new Map(current); | |
| 1499 | + updated.delete(String(requestId)); | |
| 1500 | + return updated; | |
| 1501 | + }), | |
| 1502 | + ), | |
| 1503 | + ); | |
| 1504 | + }); | |
| 1505 | + | |
| 1506 | + const requestUserInput = (request: AcpAdapterV2UserInputRequest) => | |
| 1507 | + requestUserInputInternal(request).pipe( | |
| 1508 | + Effect.mapError( | |
| 1509 | + (cause) => | |
| 1510 | + new EffectAcpErrors.AcpTransportError({ | |
| 1511 | + detail: "Failed to handle ACP user input request", | |
| 1512 | + cause, | |
| 1513 | + }), | |
| 1514 | + ), | |
| 1515 | + ); | |
| 1516 | + | |
| 1517 | + const cancelPendingRuntimeRequests = Effect.fnUntraced(function* () { | |
| 1518 | + const requests = [...(yield* Ref.get(pendingRuntimeRequests)).values()]; | |
| 1519 | + if (requests.length === 0) return; | |
| 1520 | + | |
| 1521 | + const now = yield* DateTime.now; | |
| 1522 | + yield* Effect.forEach( | |
| 1523 | + requests, | |
| 1524 | + (request) => | |
| 1525 | + Effect.gen(function* () { | |
| 1526 | + const cancelled = yield* request.type === "approval" | |
| 1527 | + ? Deferred.succeed(request.decision, "cancel") | |
| 1528 | + : Deferred.succeed(request.answers, null); | |
| 1529 | + if (!cancelled) return; | |
| 1530 | + | |
| 1531 | + yield* emitProviderEvent({ | |
| 1532 | + type: "runtime_request.updated", | |
| 1533 | + provider, | |
| 1534 | + threadId: request.node.threadId, | |
| 1535 | + runtimeRequest: { | |
| 1536 | + ...request.runtimeRequest, | |
| 1537 | + status: "cancelled", | |
| 1538 | + resolvedAt: now, | |
| 1539 | + }, | |
| 1540 | + }); | |
| 1541 | + yield* emitProviderEvent({ | |
| 1542 | + type: "node.updated", | |
| 1543 | + provider, | |
| 1544 | + node: { | |
| 1545 | + ...request.node, | |
| 1546 | + status: "cancelled", | |
| 1547 | + completedAt: now, | |
| 1548 | + }, | |
| 1549 | + }); | |
| 1550 | + yield* emitProviderEvent({ | |
| 1551 | + type: "turn_item.updated", | |
| 1552 | + provider, | |
| 1553 | + turnItem: { | |
| 1554 | + ...request.turnItem, | |
| 1555 | + status: "cancelled", | |
| 1556 | + completedAt: now, | |
| 1557 | + updatedAt: now, | |
| 1558 | + }, | |
| 1559 | + }); | |
| 1560 | + }), | |
| 1561 | + { concurrency: 1, discard: true }, | |
| 1562 | + ); | |
| 1563 | + }); | |
| 1564 | + | |
| 1565 | + yield* runtime.handleElicitation((params) => | |
| 1566 | + Effect.gen(function* () { | |
| 1567 | + if (params.mode === "url") { | |
| 1568 | + return { action: { action: "decline" } } as const; | |
| 1569 | + } | |
| 1570 | + const questions = Object.entries(params.requestedSchema.properties ?? {}).map( | |
| 1571 | + ([id, property], index): OrchestrationV2UserInputQuestion => { | |
| 1572 | + const record = unknownRecord(property); | |
| 1573 | + const enumValues = Array.isArray(record?.enum) | |
| 1574 | + ? record.enum.filter((value): value is string => typeof value === "string") | |
| 1575 | + : []; | |
| 1576 | + const options = | |
| 1577 | + enumValues.length > 0 | |
| 1578 | + ? enumValues.map((value) => ({ label: value, description: value })) | |
| 1579 | + : record?.type === "boolean" | |
| 1580 | + ? [ | |
| 1581 | + { label: "true", description: "Yes" }, | |
| 1582 | + { label: "false", description: "No" }, | |
| 1583 | + ] | |
| 1584 | + : []; | |
| 1585 | + return { | |
| 1586 | + id, | |
| 1587 | + header: nonEmptyText(record?.title, `Question ${index + 1}`), | |
| 1588 | + question: nonEmptyText(record?.description, params.message), | |
| 1589 | + options, | |
| 1590 | + }; | |
| 1591 | + }, | |
| 1592 | + ); | |
| 1593 | + const ordinal = yield* Ref.getAndUpdate( | |
| 1594 | + nextElicitationOrdinal, | |
| 1595 | + (current) => current + 1, | |
| 1596 | + ); | |
| 1597 | + const nativeRequestId = `${params.sessionId}:elicitation:${ordinal}`; | |
| 1598 | + const answers = yield* requestUserInput({ | |
| 1599 | + nativeItemId: nativeRequestId, | |
| 1600 | + nativeRequestId, | |
| 1601 | + questions, | |
| 1602 | + }); | |
| 1603 | + return answers === null | |
| 1604 | + ? ({ action: { action: "cancel" } } as const) | |
| 1605 | + : ({ action: { action: "accept", content: elicitationContent(answers) } } as const); | |
| 1606 | + }), | |
| 1607 | + ); | |
| 1608 | + | |
| 1609 | + if (flavor.registerExtensions !== undefined) { | |
| 1610 | + yield* flavor.registerExtensions({ runtime, requestUserInput }); | |
| 1611 | + } | |
| 1612 | + | |
| 1613 | + const started = yield* runtime.start(); | |
| 1614 | + yield* Ref.set(activeSessionId, started.sessionId); | |
| 1615 | + const capabilities = negotiatedCapabilities(flavor.capabilities, started); | |
| 1616 | + const canLoadSession = started.initializeResult.agentCapabilities?.loadSession === true; | |
| 1617 | + const canResumeSession = | |
| 1618 | + started.initializeResult.agentCapabilities?.sessionCapabilities?.resume != null; | |
| 1619 | + const supportsImagePrompts = | |
| 1620 | + started.initializeResult.agentCapabilities?.promptCapabilities?.image === true; | |
| 1621 | + | |
| 1622 | + const activateSession = Effect.fnUntraced(function* (sessionId: string) { | |
| 1623 | + if (canLoadSession) { | |
| 1624 | + return yield* runtime.loadSession(sessionId); | |
| 1625 | + } | |
| 1626 | + if (canResumeSession) { | |
| 1627 | + return yield* runtime.resumeSession(sessionId); | |
| 1628 | + } | |
| 1629 | + return yield* new ProviderAdapterProtocolError({ | |
| 1630 | + provider, | |
| 1631 | + detail: `ACP driver cannot load or resume session ${sessionId}`, | |
| 1632 | + }); | |
| 1633 | + }); | |
| 1634 | + | |
| 1635 | + const configureSession = Effect.fnUntraced(function* ( | |
| 1636 | + startResult: AcpSessionRuntimeStartResult, | |
| 1637 | + modelSelection: ModelSelection, | |
| 1638 | + runtimePolicy: ProviderAdapterV2RuntimePolicy, | |
| 1639 | + ) { | |
| 1640 | + const requestedModel = flavor.resolveModelId?.(modelSelection) ?? modelSelection.model; | |
| 1641 | + if ( | |
| 1642 | + requestedModel.length > 0 && | |
| 1643 | + requestedModel !== "auto" && | |
| 1644 | + requestedModel !== "default" | |
| 1645 | + ) { | |
| 1646 | + const currentModel = startResult.sessionSetupResult.models?.currentModelId; | |
| 1647 | + if (currentModel !== requestedModel) { | |
| 1648 | + if (startResult.sessionSetupResult.models != null) { | |
| 1649 | + yield* runtime.setSessionModel(requestedModel); | |
| 1650 | + } else if ( | |
| 1651 | + startResult.sessionSetupResult.configOptions?.some( | |
| 1652 | + (option) => option.category === "model", | |
| 1653 | + ) === true | |
| 1654 | + ) { | |
| 1655 | + yield* runtime.setModel(requestedModel); | |
| 1656 | + } | |
| 1657 | + } | |
| 1658 | + } | |
| 1659 | + const configOptions = yield* runtime.getConfigOptions; | |
| 1660 | + for (const selection of modelSelection.options ?? []) { | |
| 1661 | + if (configOptions.some((option) => option.id === selection.id)) { | |
| 1662 | + yield* runtime.setConfigOption(selection.id, selection.value); | |
| 1663 | + } | |
| 1664 | + } | |
| 1665 | + const modeState = yield* runtime.getModeState; | |
| 1666 | + if (runtimePolicy.interactionMode === "plan" && modeState !== undefined) { | |
| 1667 | + const planMode = modeState.availableModes.find( | |
| 1668 | + (mode) => mode.id === "plan" || mode.id === "architect", | |
| 1669 | + ); | |
| 1670 | + if (planMode !== undefined) yield* runtime.setMode(planMode.id); | |
| 1671 | + } | |
| 1672 | + }); | |
| 1673 | + | |
| 1674 | + yield* configureSession(started, input.modelSelection, input.runtimePolicy); | |
| 1675 | + const createdAt = yield* DateTime.now; | |
| 1676 | + const providerSession: OrchestrationV2ProviderSession = { | |
| 1677 | + id: input.providerSessionId, | |
| 1678 | + provider, | |
| 1679 | + status: "ready", | |
| 1680 | + cwd: input.runtimePolicy.cwd ?? process.cwd(), | |
| 1681 | + model: input.modelSelection.model, | |
| 1682 | + capabilities, | |
| 1683 | + createdAt, | |
| 1684 | + updatedAt: createdAt, | |
| 1685 | + lastError: null, | |
| 1686 | + }; | |
| 1687 | + | |
| 1688 | + const providerTurnPayload = ( | |
| 1689 | + context: ActiveAcpTurn, | |
| 1690 | + status: OrchestrationV2ProviderTurn["status"], | |
| 1691 | + completedAt: DateTime.Utc | null, | |
| 1692 | + ): OrchestrationV2ProviderTurn => ({ | |
| 1693 | + id: context.providerTurnId, | |
| 1694 | + providerThreadId: context.input.providerThread.id, | |
| 1695 | + nodeId: context.input.rootNodeId, | |
| 1696 | + runAttemptId: context.input.attemptId, | |
| 1697 | + nativeTurnRef: { | |
| 1698 | + provider, | |
| 1699 | + nativeId: context.nativeTurnId, | |
| 1700 | + strength: "weak", | |
| 1701 | + }, | |
| 1702 | + ordinal: context.input.providerTurnOrdinal, | |
| 1703 | + status, | |
| 1704 | + startedAt: context.startedAt, | |
| 1705 | + completedAt, | |
| 1706 | + }); | |
| 1707 | + | |
| 1708 | + const finalizeTurn = Effect.fnUntraced(function* ( | |
| 1709 | + context: ActiveAcpTurn, | |
| 1710 | + status: "completed" | "interrupted" | "failed" | "cancelled", | |
| 1711 | + ) { | |
| 1712 | + if (context.finalized) return; | |
| 1713 | + context.finalized = true; | |
| 1714 | + yield* closeTextStreams(context); | |
| 1715 | + const now = yield* DateTime.now; | |
| 1716 | + const turn = providerTurnPayload(context, status, now); | |
| 1717 | + yield* Ref.update(providerTurns, (current) => { | |
| 1718 | + const updated = new Map(current); | |
| 1719 | + updated.set(String(turn.id), turn); | |
| 1720 | + return updated; | |
| 1721 | + }); | |
| 1722 | + yield* emitProviderEvent({ | |
| 1723 | + type: "provider_turn.updated", | |
| 1724 | + provider, | |
| 1725 | + threadId: context.input.threadId, | |
| 1726 | + providerTurn: turn, | |
| 1727 | + }); | |
| 1728 | + yield* emitProviderEvent({ | |
| 1729 | + type: "provider_thread.updated", | |
| 1730 | + provider, | |
| 1731 | + providerThread: { | |
| 1732 | + ...context.input.providerThread, | |
| 1733 | + providerSessionId: input.providerSessionId, | |
| 1734 | + status: "idle", | |
| 1735 | + lastRunOrdinal: context.input.runOrdinal, | |
| 1736 | + firstRunOrdinal: | |
| 1737 | + context.input.providerThread.firstRunOrdinal ?? context.input.runOrdinal, | |
| 1738 | + updatedAt: now, | |
| 1739 | + }, | |
| 1740 | + }); | |
| 1741 | + yield* emitProviderEvent({ | |
| 1742 | + type: "turn.terminal", | |
| 1743 | + provider, | |
| 1744 | + providerTurnId: context.providerTurnId, | |
| 1745 | + status, | |
| 1746 | + }); | |
| 1747 | + yield* Ref.set(activeTurn, null); | |
| 1748 | + yield* Deferred.succeed(context.completed, undefined).pipe(Effect.ignore); | |
| 1749 | + }); | |
| 1750 | + | |
| 1751 | + const resolvePromptParts = Effect.fnUntraced(function* ( | |
| 1752 | + turnInput: ProviderAdapterV2TurnInput, | |
| 1753 | + ) { | |
| 1754 | + const prompt: Array<EffectAcpSchema.ContentBlock> = []; | |
| 1755 | + if (turnInput.message.text.length > 0) { | |
| 1756 | + prompt.push({ type: "text", text: turnInput.message.text }); | |
| 1757 | + } | |
| 1758 | + if (turnInput.message.attachments.length > 0 && !supportsImagePrompts) { | |
| 1759 | + return yield* new ProviderAdapterProtocolError({ | |
| 1760 | + provider, | |
| 1761 | + detail: "ACP driver did not negotiate image prompt support", | |
| 1762 | + }); | |
| 1763 | + } | |
| 1764 | + for (const attachment of turnInput.message.attachments) { | |
| 1765 | + const path = resolveAttachmentPath({ | |
| 1766 | + attachmentsDir: serverConfig.attachmentsDir, | |
| 1767 | + attachment: attachment as ChatAttachment, | |
| 1768 | + }); | |
| 1769 | + if (path === null) { | |
| 1770 | + return yield* new ProviderAdapterProtocolError({ | |
| 1771 | + provider, | |
| 1772 | + detail: `Invalid attachment id '${attachment.id}'`, | |
| 1773 | + }); | |
| 1774 | + } | |
| 1775 | + const bytes = yield* fileSystem.readFile(path).pipe( | |
| 1776 | + Effect.mapError( | |
| 1777 | + (cause) => | |
| 1778 | + new ProviderAdapterProtocolError({ | |
| 1779 | + provider, | |
| 1780 | + detail: `Failed to read attachment '${attachment.id}'`, | |
| 1781 | + payload: cause, | |
| 1782 | + }), | |
| 1783 | + ), | |
| 1784 | + ); | |
| 1785 | + prompt.push({ | |
| 1786 | + type: "image", | |
| 1787 | + data: Buffer.from(bytes).toString("base64"), | |
| 1788 | + mimeType: attachment.mimeType, | |
| 1789 | + }); | |
| 1790 | + } | |
| 1791 | + if (prompt.length === 0) { | |
| 1792 | + return yield* new ProviderAdapterProtocolError({ | |
| 1793 | + provider, | |
| 1794 | + detail: "ACP turn requires non-empty text or attachments", | |
| 1795 | + }); | |
| 1796 | + } | |
| 1797 | + return prompt; | |
| 1798 | + }); | |
| 1799 | + | |
| 1800 | + const startTurn = Effect.fn("AcpAdapterV2.startTurn")( | |
| 1801 | + function* (turnInput: ProviderAdapterV2TurnInput) { | |
| 1802 | + const existing = yield* Ref.get(activeTurn); | |
| 1803 | + if (existing !== null) { | |
| 1804 | + return yield* new ProviderAdapterProtocolError({ | |
| 1805 | + provider, | |
| 1806 | + detail: `ACP provider turn ${existing.providerTurnId} is still active`, | |
| 1807 | + }); | |
| 1808 | + } | |
| 1809 | + const requestedSessionId = nativeThreadId(provider, turnInput.providerThread); | |
| 1810 | + if ((yield* Ref.get(activeSessionId)) !== requestedSessionId) { | |
| 1811 | + const activated = yield* activateSession(requestedSessionId); | |
| 1812 | + yield* Ref.set(activeSessionId, activated.sessionId); | |
| 1813 | + yield* configureSession(activated, turnInput.modelSelection, turnInput.runtimePolicy); | |
| 1814 | + } | |
| 1815 | + const prompt = yield* resolvePromptParts(turnInput); | |
| 1816 | + const startedAt = yield* DateTime.now; | |
| 1817 | + const nativeTurnId = `${requestedSessionId}:turn:${turnInput.providerTurnOrdinal}`; | |
| 1818 | + const providerTurnId = idAllocator.derive.providerTurn({ provider, nativeTurnId }); | |
| 1819 | + const completed = yield* Deferred.make<void, never>(); | |
| 1820 | + const context: ActiveAcpTurn = { | |
| 1821 | + input: turnInput, | |
| 1822 | + providerTurnId, | |
| 1823 | + nativeTurnId, | |
| 1824 | + startedAt, | |
| 1825 | + completed, | |
| 1826 | + assistant: { current: null, nextSegment: 0 }, | |
| 1827 | + reasoning: { current: null, nextSegment: 0 }, | |
| 1828 | + tools: new Map(), | |
| 1829 | + toolStartedAt: new Map(), | |
| 1830 | + plan: null, | |
| 1831 | + interrupted: false, | |
| 1832 | + finalized: false, | |
| 1833 | + }; | |
| 1834 | + yield* Ref.set(activeTurn, context); | |
| 1835 | + const runningTurn = providerTurnPayload(context, "running", null); | |
| 1836 | + yield* Ref.update(providerTurns, (current) => { | |
| 1837 | + const updated = new Map(current); | |
| 1838 | + updated.set(String(runningTurn.id), runningTurn); | |
| 1839 | + return updated; | |
| 1840 | + }); | |
| 1841 | + yield* emitProviderEvent({ | |
| 1842 | + type: "provider_turn.updated", | |
| 1843 | + provider, | |
| 1844 | + threadId: turnInput.threadId, | |
| 1845 | + providerTurn: runningTurn, | |
| 1846 | + }); | |
| 1847 | + yield* emitProviderEvent({ | |
| 1848 | + type: "provider_thread.updated", | |
| 1849 | + provider, | |
| 1850 | + providerThread: { | |
| 1851 | + ...turnInput.providerThread, | |
| 1852 | + providerSessionId: input.providerSessionId, | |
| 1853 | + status: "active", | |
| 1854 | + updatedAt: startedAt, | |
| 1855 | + }, | |
| 1856 | + }); | |
| 1857 | + yield* rememberSnapshotMessage({ | |
| 1858 | + id: turnInput.message.messageId, | |
| 1859 | + threadId: turnInput.threadId, | |
| 1860 | + runId: turnInput.runId, | |
| 1861 | + nodeId: turnInput.rootNodeId, | |
| 1862 | + role: "user", | |
| 1863 | + text: turnInput.message.text, | |
| 1864 | + attachments: [...turnInput.message.attachments], | |
| 1865 | + streaming: false, | |
| 1866 | + createdAt: startedAt, | |
| 1867 | + updatedAt: startedAt, | |
| 1868 | + }); | |
| 1869 | + yield* runtime.prompt({ prompt }).pipe( | |
| 1870 | + Effect.flatMap((result) => { | |
| 1871 | + const status = | |
| 1872 | + result.stopReason === "cancelled" | |
| 1873 | + ? context.interrupted | |
| 1874 | + ? "interrupted" | |
| 1875 | + : "cancelled" | |
| 1876 | + : "completed"; | |
| 1877 | + return finalizeTurn(context, status); | |
| 1878 | + }), | |
| 1879 | + Effect.catchCause((cause) => | |
| 1880 | + finalizeTurn(context, context.interrupted ? "interrupted" : "failed").pipe( | |
| 1881 | + Effect.andThen( | |
| 1882 | + Effect.logWarning("orchestration-v2.acp-prompt-failed", { | |
| 1883 | + provider, | |
| 1884 | + providerSessionId: input.providerSessionId, | |
| 1885 | + providerThreadId: turnInput.providerThread.id, | |
| 1886 | + providerTurnId, | |
| 1887 | + cause, | |
| 1888 | + }), | |
| 1889 | + ), | |
| 1890 | + ), | |
| 1891 | + ), | |
| 1892 | + Effect.forkIn(sessionScope), | |
| 1893 | + ); | |
| 1894 | + }, | |
| 1895 | + (effect, turnInput) => | |
| 1896 | + effect.pipe( | |
| 1897 | + Effect.mapError( | |
| 1898 | + (cause) => | |
| 1899 | + new ProviderAdapterTurnStartError({ | |
| 1900 | + provider, | |
| 1901 | + threadId: turnInput.threadId, | |
| 1902 | + providerThreadId: turnInput.providerThread.id, | |
| 1903 | + runId: turnInput.runId, | |
| 1904 | + cause, | |
| 1905 | + }), | |
| 1906 | + ), | |
| 1907 | + ), | |
| 1908 | + ); | |
| 1909 | + | |
| 1910 | + yield* Effect.addFinalizer(() => | |
| 1911 | + Effect.gen(function* () { | |
| 1912 | + const requests = [...(yield* Ref.get(pendingRuntimeRequests)).values()]; | |
| 1913 | + yield* Effect.forEach( | |
| 1914 | + requests, | |
| 1915 | + (request) => | |
| 1916 | + request.type === "approval" | |
| 1917 | + ? Deferred.succeed(request.decision, "cancel").pipe(Effect.ignore) | |
| 1918 | + : Deferred.succeed(request.answers, null).pipe(Effect.ignore), | |
| 1919 | + { discard: true }, | |
| 1920 | + ); | |
| 1921 | + const sessionCapabilities = | |
| 1922 | + started.initializeResult.agentCapabilities?.sessionCapabilities; | |
| 1923 | + if (sessionCapabilities?.close != null) { | |
| 1924 | + yield* runtime.closeSession().pipe(Effect.ignore); | |
| 1925 | + } | |
| 1926 | + if (flavor.assertComplete !== undefined) { | |
| 1927 | + yield* flavor.assertComplete.pipe(Effect.orDie); | |
| 1928 | + } | |
| 1929 | + }), | |
| 1930 | + ); | |
| 1931 | + | |
| 1932 | + const sessionRuntime: ProviderAdapterV2SessionRuntime = { | |
| 1933 | + instanceId: options.instanceId, | |
| 1934 | + provider, | |
| 1935 | + providerSessionId: input.providerSessionId, | |
| 1936 | + providerSession, | |
| 1937 | + rawEvents: Stream.fromEffectRepeat(Queue.take(rawEvents)), | |
| 1938 | + events: Stream.fromEffectRepeat(Queue.take(events)), | |
| 1939 | + ensureThread: Effect.fn("AcpAdapterV2.ensureThread")( | |
| 1940 | + function* (threadInput: ProviderAdapterV2EnsureThreadInput) { | |
| 1941 | + const now = yield* DateTime.now; | |
| 1942 | + const sessionId = yield* Ref.get(activeSessionId); | |
| 1943 | + if (sessionId === null) { | |
| 1944 | + return yield* new ProviderAdapterProtocolError({ | |
| 1945 | + provider, | |
| 1946 | + detail: "ACP runtime did not produce a session id", | |
| 1947 | + }); | |
| 1948 | + } | |
| 1949 | + return makeProviderThread({ | |
| 1950 | + provider, | |
| 1951 | + idAllocator, | |
| 1952 | + appThreadId: threadInput.threadId, | |
| 1953 | + providerSessionId: input.providerSessionId, | |
| 1954 | + nativeThreadId: sessionId, | |
| 1955 | + now, | |
| 1956 | + }); | |
| 1957 | + }, | |
| 1958 | + (effect, threadInput) => | |
| 1959 | + effect.pipe( | |
| 1960 | + Effect.mapError( | |
| 1961 | + (cause) => | |
| 1962 | + new ProviderAdapterEnsureThreadError({ | |
| 1963 | + provider, | |
| 1964 | + threadId: threadInput.threadId, | |
| 1965 | + cause, | |
| 1966 | + }), | |
| 1967 | + ), | |
| 1968 | + ), | |
| 1969 | + ), | |
| 1970 | + resumeThread: Effect.fn("AcpAdapterV2.resumeThread")( | |
| 1971 | + function* (threadInput: { readonly providerThread: OrchestrationV2ProviderThread }) { | |
| 1972 | + const sessionId = nativeThreadId(provider, threadInput.providerThread); | |
| 1973 | + if ((yield* Ref.get(activeSessionId)) !== sessionId) { | |
| 1974 | + yield* Ref.set(snapshot, { | |
| 1975 | + order: [], | |
| 1976 | + messages: new Map(), | |
| 1977 | + loadingRole: null, | |
| 1978 | + loadingIndex: 0, | |
| 1979 | + }); | |
| 1980 | + const activated = yield* activateSession(sessionId); | |
| 1981 | + yield* Ref.set(activeSessionId, activated.sessionId); | |
| 1982 | + yield* configureSession(activated, input.modelSelection, input.runtimePolicy); | |
| 1983 | + } | |
| 1984 | + const now = yield* DateTime.now; | |
| 1985 | + return { | |
| 1986 | + ...threadInput.providerThread, | |
| 1987 | + providerSessionId: input.providerSessionId, | |
| 1988 | + status: "idle" as const, | |
| 1989 | + updatedAt: now, | |
| 1990 | + }; | |
| 1991 | + }, | |
| 1992 | + (effect, threadInput) => | |
| 1993 | + effect.pipe( | |
| 1994 | + Effect.mapError( | |
| 1995 | + (cause) => | |
| 1996 | + new ProviderAdapterResumeThreadError({ | |
| 1997 | + provider, | |
| 1998 | + providerSessionId: input.providerSessionId, | |
| 1999 | + providerThreadId: threadInput.providerThread.id, | |
| 2000 | + cause, | |
| 2001 | + }), | |
| 2002 | + ), | |
| 2003 | + ), | |
| 2004 | + ), | |
| 2005 | + startTurn, | |
| 2006 | + steerTurn: (turnInput) => | |
| 2007 | + Effect.fail( | |
| 2008 | + new ProviderAdapterSteerRunUnsupportedError({ | |
| 2009 | + provider, | |
| 2010 | + providerThreadId: turnInput.providerThread.id, | |
| 2011 | + }), | |
| 2012 | + ), | |
| 2013 | + interruptTurn: Effect.fn("AcpAdapterV2.interruptTurn")( | |
| 2014 | + function* (turnInput: ProviderAdapterV2InterruptInput) { | |
| 2015 | + const context = yield* Ref.get(activeTurn); | |
| 2016 | + if (context?.providerTurnId !== turnInput.providerTurnId) { | |
| 2017 | + return yield* new ProviderAdapterProtocolError({ | |
| 2018 | + provider, | |
| 2019 | + detail: `ACP provider turn ${turnInput.providerTurnId} is not active`, | |
| 2020 | + }); | |
| 2021 | + } | |
| 2022 | + context.interrupted = true; | |
| 2023 | + yield* runtime.cancel.pipe(Effect.ensuring(cancelPendingRuntimeRequests())); | |
| 2024 | + const stopped = yield* Deferred.await(context.completed).pipe( | |
| 2025 | + Effect.timeoutOption("10 seconds"), | |
| 2026 | + ); | |
| 2027 | + if (Option.isNone(stopped)) { | |
| 2028 | + return yield* new ProviderAdapterProtocolError({ | |
| 2029 | + provider, | |
| 2030 | + detail: `ACP provider turn ${turnInput.providerTurnId} did not acknowledge cancellation before the interrupt timeout`, | |
| 2031 | + }); | |
| 2032 | + } | |
| 2033 | + }, | |
| 2034 | + (effect, turnInput) => | |
| 2035 | + effect.pipe( | |
| 2036 | + Effect.mapError( | |
| 2037 | + (cause) => | |
| 2038 | + new ProviderAdapterInterruptError({ | |
| 2039 | + provider, | |
| 2040 | + providerThreadId: turnInput.providerThread.id, | |
| 2041 | + providerTurnId: turnInput.providerTurnId, | |
| 2042 | + cause, | |
| 2043 | + }), | |
| 2044 | + ), | |
| 2045 | + ), | |
| 2046 | + ), | |
| 2047 | + respondToRuntimeRequest: (requestInput) => | |
| 2048 | + Effect.gen(function* () { | |
| 2049 | + const pending = (yield* Ref.get(pendingRuntimeRequests)).get( | |
| 2050 | + String(requestInput.requestId), | |
| 2051 | + ); | |
| 2052 | + if (pending === undefined) { | |
| 2053 | + return yield* new ProviderAdapterProtocolError({ | |
| 2054 | + provider, | |
| 2055 | + detail: `No pending ACP runtime request ${requestInput.requestId}`, | |
| 2056 | + }); | |
| 2057 | + } | |
| 2058 | + if (pending.type === "user_input") { | |
| 2059 | + yield* Deferred.succeed(pending.answers, requestInput.answers ?? null); | |
| 2060 | + return; | |
| 2061 | + } | |
| 2062 | + if (requestInput.decision === undefined) { | |
| 2063 | + return yield* new ProviderAdapterProtocolError({ | |
| 2064 | + provider, | |
| 2065 | + detail: `ACP approval request ${requestInput.requestId} requires a decision`, | |
| 2066 | + }); | |
| 2067 | + } | |
| 2068 | + yield* Deferred.succeed(pending.decision, requestInput.decision); | |
| 2069 | + }).pipe( | |
| 2070 | + Effect.mapError( | |
| 2071 | + (cause) => | |
| 2072 | + new ProviderAdapterRuntimeRequestResponseError({ | |
| 2073 | + provider, | |
| 2074 | + requestId: requestInput.requestId, | |
| 2075 | + cause, | |
| 2076 | + }), | |
| 2077 | + ), | |
| 2078 | + ), | |
| 2079 | + readThreadSnapshot: Effect.fn("AcpAdapterV2.readThreadSnapshot")( | |
| 2080 | + function* (snapshotInput) { | |
| 2081 | + const sessionId = nativeThreadId(provider, snapshotInput.providerThread); | |
| 2082 | + if ((yield* Ref.get(activeSessionId)) !== sessionId) { | |
| 2083 | + if (!capabilities.threads.canReadThreadSnapshot) { | |
| 2084 | + return yield* new ProviderAdapterProtocolError({ | |
| 2085 | + provider, | |
| 2086 | + detail: "ACP driver does not support session/load snapshots", | |
| 2087 | + }); | |
| 2088 | + } | |
| 2089 | + yield* Ref.set(snapshot, { | |
| 2090 | + order: [], | |
| 2091 | + messages: new Map(), | |
| 2092 | + loadingRole: null, | |
| 2093 | + loadingIndex: 0, | |
| 2094 | + }); | |
| 2095 | + const activated = yield* runtime.loadSession(sessionId); | |
| 2096 | + yield* Ref.set(activeSessionId, activated.sessionId); | |
| 2097 | + } | |
| 2098 | + const state = yield* Ref.get(snapshot); | |
| 2099 | + const now = yield* DateTime.now; | |
| 2100 | + return { | |
| 2101 | + providerThread: { | |
| 2102 | + ...snapshotInput.providerThread, | |
| 2103 | + providerSessionId: input.providerSessionId, | |
| 2104 | + status: "idle" as const, | |
| 2105 | + updatedAt: now, | |
| 2106 | + }, | |
| 2107 | + providerTurns: [...(yield* Ref.get(providerTurns)).values()], | |
| 2108 | + messages: state.order.flatMap((key) => { | |
| 2109 | + const message = state.messages.get(key); | |
| 2110 | + return message === undefined ? [] : [message]; | |
| 2111 | + }), | |
| 2112 | + runtimeRequests: [], | |
| 2113 | + providerPayload: { protocol: ACP_PROTOCOL, sessionId }, | |
| 2114 | + }; | |
| 2115 | + }, | |
| 2116 | + (effect, snapshotInput) => | |
| 2117 | + effect.pipe( | |
| 2118 | + Effect.mapError( | |
| 2119 | + (cause) => | |
| 2120 | + new ProviderAdapterReadThreadSnapshotError({ | |
| 2121 | + provider, | |
| 2122 | + providerThreadId: snapshotInput.providerThread.id, | |
| 2123 | + cause, | |
| 2124 | + }), | |
| 2125 | + ), | |
| 2126 | + ), | |
| 2127 | + ), | |
| 2128 | + rollbackThread: (rollbackInput) => | |
| 2129 | + Effect.fail( | |
| 2130 | + new ProviderAdapterRollbackThreadError({ | |
| 2131 | + provider, | |
| 2132 | + providerThreadId: rollbackInput.providerThread.id, | |
| 2133 | + checkpointId: rollbackInput.target.checkpointId, | |
| 2134 | + cause: "ACP does not define conversation rollback.", | |
| 2135 | + }), | |
| 2136 | + ), | |
| 2137 | + forkThread: Effect.fn("AcpAdapterV2.forkThread")( | |
| 2138 | + function* (forkInput) { | |
| 2139 | + if (!capabilities.threads.canForkThread) { | |
| 2140 | + return yield* new ProviderAdapterProtocolError({ | |
| 2141 | + provider, | |
| 2142 | + detail: "ACP driver did not negotiate session/fork", | |
| 2143 | + }); | |
| 2144 | + } | |
| 2145 | + if (forkInput.providerTurnId !== undefined) { | |
| 2146 | + return yield* new ProviderAdapterProtocolError({ | |
| 2147 | + provider, | |
| 2148 | + detail: "ACP session/fork can only fork the current session head", | |
| 2149 | + }); | |
| 2150 | + } | |
| 2151 | + const sourceSessionId = nativeThreadId(provider, forkInput.sourceProviderThread); | |
| 2152 | + const forked = yield* runtime.forkSession(sourceSessionId); | |
| 2153 | + yield* Ref.set(activeSessionId, forked.sessionId); | |
| 2154 | + const now = yield* DateTime.now; | |
| 2155 | + return makeProviderThread({ | |
| 2156 | + provider, | |
| 2157 | + idAllocator, | |
| 2158 | + appThreadId: forkInput.targetThreadId, | |
| 2159 | + providerSessionId: input.providerSessionId, | |
| 2160 | + nativeThreadId: forked.sessionId, | |
| 2161 | + ...(forkInput.ownerNodeId === undefined | |
| 2162 | + ? {} | |
| 2163 | + : { ownerNodeId: forkInput.ownerNodeId }), | |
| 2164 | + forkedFrom: { | |
| 2165 | + providerThreadId: forkInput.sourceProviderThread.id, | |
| 2166 | + ...(forkInput.providerTurnId === undefined | |
| 2167 | + ? {} | |
| 2168 | + : { providerTurnId: forkInput.providerTurnId }), | |
| 2169 | + }, | |
| 2170 | + now, | |
| 2171 | + }); | |
| 2172 | + }, | |
| 2173 | + (effect, forkInput) => | |
| 2174 | + effect.pipe( | |
| 2175 | + Effect.mapError( | |
| 2176 | + (cause) => | |
| 2177 | + new ProviderAdapterForkThreadError({ | |
| 2178 | + provider, | |
| 2179 | + providerThreadId: forkInput.sourceProviderThread.id, | |
| 2180 | + cause, | |
| 2181 | + }), | |
| 2182 | + ), | |
| 2183 | + ), | |
| 2184 | + ), | |
| 2185 | + }; | |
| 2186 | + return sessionRuntime; | |
| 2187 | + }, | |
| 2188 | + (effect, input) => | |
| 2189 | + effect.pipe( | |
| 2190 | + Effect.mapError( | |
| 2191 | + (cause) => | |
| 2192 | + new ProviderAdapterOpenSessionError({ | |
| 2193 | + provider, | |
| 2194 | + providerSessionId: input.providerSessionId, | |
| 2195 | + cause, | |
| 2196 | + }), | |
| 2197 | + ), | |
| 2198 | + ), | |
| 2199 | + ), | |
| 2200 | + }); | |
| 2201 | +} | |
| 2202 | + | |
| 2203 | +export type AcpAdapterV2Env = FileSystem.FileSystem | IdAllocatorV2; | |
| @@ -0,0 +1,157 @@ | ||
| 1 | +import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | +import { assert, describe, it } from "@effect/vitest"; | |
| 3 | +import { ProviderInstanceId, ProviderSessionId, ThreadId } from "@t3tools/contracts"; | |
| 4 | +import * as Effect from "effect/Effect"; | |
| 5 | +import * as FileSystem from "effect/FileSystem"; | |
| 6 | +import * as Layer from "effect/Layer"; | |
| 7 | +import * as Path from "effect/Path"; | |
| 8 | +import * as Schema from "effect/Schema"; | |
| 9 | +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; | |
| 10 | +import { ChildProcessSpawner } from "effect/unstable/process"; | |
| 11 | + | |
| 12 | +import { ServerConfig } from "../../config.ts"; | |
| 13 | +import { makeAcpRegistryResolver } from "../../provider/acp/AcpRegistrySupport.ts"; | |
| 14 | +import { layer as idAllocatorLayer, IdAllocatorV2 } from "../IdAllocator.ts"; | |
| 15 | +import { ProviderAdapterV2RuntimePolicy } from "../ProviderAdapter.ts"; | |
| 16 | +import { BUILT_IN_PROVIDER_ADAPTER_DRIVER_KINDS_V2 } from "../builtInProviderAdapterDrivers.ts"; | |
| 17 | +import { | |
| 18 | + ACP_REGISTRY_DRIVER_KIND, | |
| 19 | + AcpRegistryAdapterV2Driver, | |
| 20 | + makeAcpRegistryAdapterV2, | |
| 21 | +} from "./AcpRegistryAdapterV2.ts"; | |
| 22 | + | |
| 23 | +const registryUrl = "https://registry.test/registry.json"; | |
| 24 | +const decodeAcpRegistryAdapterSettings = Schema.decodeUnknownEffect( | |
| 25 | + AcpRegistryAdapterV2Driver.configSchema, | |
| 26 | +); | |
| 27 | + | |
| 28 | +const serverConfigLayer = ServerConfig.layerTest(process.cwd(), { | |
| 29 | + prefix: "t3-acp-registry-v2-adapter-", | |
| 30 | +}).pipe(Layer.provide(NodeServices.layer)); | |
| 31 | + | |
| 32 | +const registryLayer = Layer.succeed( | |
| 33 | + HttpClient.HttpClient, | |
| 34 | + HttpClient.make((request) => | |
| 35 | + Effect.succeed( | |
| 36 | + HttpClientResponse.fromWeb( | |
| 37 | + request, | |
| 38 | + Response.json({ | |
| 39 | + version: "1.0.0", | |
| 40 | + agents: [ | |
| 41 | + { | |
| 42 | + id: "fixture-agent", | |
| 43 | + name: "Fixture Agent", | |
| 44 | + version: "1.0.0", | |
| 45 | + description: "ACP V2 adapter fixture", | |
| 46 | + distribution: { | |
| 47 | + binary: { | |
| 48 | + "darwin-aarch64": { | |
| 49 | + archive: "https://registry.test/unused", | |
| 50 | + cmd: "fixture-agent", | |
| 51 | + args: [], | |
| 52 | + }, | |
| 53 | + "linux-x86_64": { | |
| 54 | + archive: "https://registry.test/unused", | |
| 55 | + cmd: "fixture-agent", | |
| 56 | + args: [], | |
| 57 | + }, | |
| 58 | + }, | |
| 59 | + }, | |
| 60 | + }, | |
| 61 | + ], | |
| 62 | + }), | |
| 63 | + ), | |
| 64 | + ), | |
| 65 | + ), | |
| 66 | +); | |
| 67 | + | |
| 68 | +const testLayer = Layer.mergeAll( | |
| 69 | + NodeServices.layer, | |
| 70 | + idAllocatorLayer, | |
| 71 | + serverConfigLayer, | |
| 72 | + registryLayer, | |
| 73 | +); | |
| 74 | + | |
| 75 | +describe("AcpRegistryAdapterV2", () => { | |
| 76 | + it("is registered as a generic provider driver with schema defaults", () => { | |
| 77 | + assert.isTrue(BUILT_IN_PROVIDER_ADAPTER_DRIVER_KINDS_V2.has(ACP_REGISTRY_DRIVER_KIND)); | |
| 78 | + assert.equal(AcpRegistryAdapterV2Driver.driverKind, ACP_REGISTRY_DRIVER_KIND); | |
| 79 | + assert.deepEqual(AcpRegistryAdapterV2Driver.defaultConfig(), { | |
| 80 | + enabled: true, | |
| 81 | + agentId: "", | |
| 82 | + commandPath: "", | |
| 83 | + authMethodId: "", | |
| 84 | + distribution: "auto", | |
| 85 | + customModels: [], | |
| 86 | + }); | |
| 87 | + }); | |
| 88 | + | |
| 89 | + it.effect("opens a real ACP child process resolved from registry configuration", () => | |
| 90 | + Effect.gen(function* () { | |
| 91 | + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 92 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 93 | + const idAllocator = yield* IdAllocatorV2; | |
| 94 | + const path = yield* Path.Path; | |
| 95 | + const serverConfig = yield* ServerConfig; | |
| 96 | + const mockAgentPath = yield* path.fromFileUrl( | |
| 97 | + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | |
| 98 | + ); | |
| 99 | + const resolver = yield* makeAcpRegistryResolver({ | |
| 100 | + cacheDir: serverConfig.providerStatusCacheDir, | |
| 101 | + registryUrl, | |
| 102 | + }); | |
| 103 | + const settings = yield* decodeAcpRegistryAdapterSettings({ | |
| 104 | + agentId: "fixture-agent", | |
| 105 | + commandPath: process.execPath, | |
| 106 | + authMethodId: "test", | |
| 107 | + }); | |
| 108 | + const instanceId = ProviderInstanceId.make("acp-registry-fixture"); | |
| 109 | + const adapter = makeAcpRegistryAdapterV2({ | |
| 110 | + instanceId, | |
| 111 | + settings, | |
| 112 | + environment: { | |
| 113 | + T3_ACP_SESSION_LIFECYCLE: "1", | |
| 114 | + }, | |
| 115 | + childProcessSpawner, | |
| 116 | + fileSystem, | |
| 117 | + idAllocator, | |
| 118 | + resolver: { | |
| 119 | + resolve: (configuredSettings, cwd, environment) => | |
| 120 | + resolver.resolve(configuredSettings, cwd, environment).pipe( | |
| 121 | + Effect.map((resolved) => ({ | |
| 122 | + ...resolved, | |
| 123 | + spawn: { | |
| 124 | + ...resolved.spawn, | |
| 125 | + args: [mockAgentPath], | |
| 126 | + }, | |
| 127 | + })), | |
| 128 | + ), | |
| 129 | + }, | |
| 130 | + serverConfig, | |
| 131 | + }); | |
| 132 | + const threadId = ThreadId.make("thread-acp-registry-fixture"); | |
| 133 | + const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | |
| 134 | + runtimeMode: "full-access", | |
| 135 | + interactionMode: "default", | |
| 136 | + cwd: process.cwd(), | |
| 137 | + }); | |
| 138 | + const modelSelection = { instanceId, model: "default" } as const; | |
| 139 | + const runtime = yield* adapter.openSession({ | |
| 140 | + threadId, | |
| 141 | + providerSessionId: ProviderSessionId.make("provider-session-acp-registry-fixture"), | |
| 142 | + modelSelection, | |
| 143 | + runtimePolicy, | |
| 144 | + }); | |
| 145 | + const providerThread = yield* runtime.ensureThread({ | |
| 146 | + threadId, | |
| 147 | + modelSelection, | |
| 148 | + runtimePolicy, | |
| 149 | + }); | |
| 150 | + | |
| 151 | + assert.equal(runtime.providerSession.provider, "acpRegistry"); | |
| 152 | + assert.equal(providerThread.nativeThreadRef?.nativeId, "mock-session-1"); | |
| 153 | + assert.isTrue(runtime.providerSession.capabilities.threads.canReadThreadSnapshot); | |
| 154 | + assert.isTrue(runtime.providerSession.capabilities.threads.canForkThread); | |
| 155 | + }).pipe(Effect.provide(testLayer), Effect.scoped), | |
| 156 | + ); | |
| 157 | +}); | |
| @@ -0,0 +1,89 @@ | ||
| 1 | +import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | +import { AcpRegistrySettings } from "@t3tools/contracts"; | |
| 3 | +import * as Effect from "effect/Effect"; | |
| 4 | +import * as FileSystem from "effect/FileSystem"; | |
| 5 | +import * as Layer from "effect/Layer"; | |
| 6 | +import * as Path from "effect/Path"; | |
| 7 | +import * as Schema from "effect/Schema"; | |
| 8 | +import { ChildProcessSpawner } from "effect/unstable/process"; | |
| 9 | + | |
| 10 | +import { ServerConfig } from "../../config.ts"; | |
| 11 | +import { layer as idAllocatorLayer, IdAllocatorV2 } from "../IdAllocator.ts"; | |
| 12 | +import { makeLayerEffect as makeProviderAdapterRegistryLayerEffect } from "../ProviderAdapterRegistry.ts"; | |
| 13 | +import type { OrchestratorV2ProviderReplayHarness } from "../testkit/ProviderReplayHarness.ts"; | |
| 14 | +import { makeReplayServerConfig } from "../testkit/ProviderReplayHarness.ts"; | |
| 15 | +import { | |
| 16 | + type AcpReplayTranscript, | |
| 17 | + AcpReplayTranscriptDecodeError, | |
| 18 | + decodeAcpReplayTranscript, | |
| 19 | + makeAcpReplayCompletenessAssertion, | |
| 20 | + makeAcpReplayRuntime, | |
| 21 | +} from "./AcpAdapterV2.testkit.ts"; | |
| 22 | +import { | |
| 23 | + ACP_REGISTRY_DEFAULT_INSTANCE_ID, | |
| 24 | + ACP_REGISTRY_PROVIDER, | |
| 25 | + makeAcpRegistryAdapterV2, | |
| 26 | +} from "./AcpRegistryAdapterV2.ts"; | |
| 27 | + | |
| 28 | +const REPLAY_SETTINGS = Schema.decodeUnknownSync(AcpRegistrySettings)({ | |
| 29 | + agentId: "replay-agent", | |
| 30 | + authMethodId: "replay", | |
| 31 | +}); | |
| 32 | + | |
| 33 | +export function makeAcpRegistryProviderAdapterRegistryReplayLayer(transcript: AcpReplayTranscript) { | |
| 34 | + const serverConfigLayer = Layer.effect( | |
| 35 | + ServerConfig, | |
| 36 | + makeReplayServerConfig(`acp-registry-${transcript.scenario}`).pipe(Effect.orDie), | |
| 37 | + ).pipe(Layer.provide(NodeServices.layer)); | |
| 38 | + | |
| 39 | + return makeProviderAdapterRegistryLayerEffect( | |
| 40 | + Effect.gen(function* () { | |
| 41 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 42 | + const path = yield* Path.Path; | |
| 43 | + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 44 | + const idAllocator = yield* IdAllocatorV2; | |
| 45 | + const serverConfig = yield* ServerConfig; | |
| 46 | + const replayDir = yield* fileSystem | |
| 47 | + .makeTempDirectory({ | |
| 48 | + prefix: `t3-orchestration-v2-acp-registry-replay-${transcript.scenario}-`, | |
| 49 | + }) | |
| 50 | + .pipe(Effect.orDie); | |
| 51 | + const statusPath = path.join(replayDir, "status.json"); | |
| 52 | + const scriptPath = yield* path | |
| 53 | + .fromFileUrl(new URL("../../../scripts/acp-replay-agent.ts", import.meta.url)) | |
| 54 | + .pipe(Effect.orDie); | |
| 55 | + const adapter = makeAcpRegistryAdapterV2({ | |
| 56 | + instanceId: ACP_REGISTRY_DEFAULT_INSTANCE_ID, | |
| 57 | + settings: REPLAY_SETTINGS, | |
| 58 | + environment: {}, | |
| 59 | + childProcessSpawner, | |
| 60 | + fileSystem, | |
| 61 | + idAllocator, | |
| 62 | + resolver: { | |
| 63 | + resolve: () => Effect.die("ACP registry resolver must not run during replay"), | |
| 64 | + }, | |
| 65 | + serverConfig, | |
| 66 | + makeRuntime: makeAcpReplayRuntime({ | |
| 67 | + transcript, | |
| 68 | + statusPath, | |
| 69 | + scriptPath, | |
| 70 | + childProcessSpawner, | |
| 71 | + }), | |
| 72 | + assertComplete: makeAcpReplayCompletenessAssertion(fileSystem, statusPath, transcript), | |
| 73 | + }); | |
| 74 | + return [adapter]; | |
| 75 | + }), | |
| 76 | + ).pipe(Layer.provide(Layer.mergeAll(serverConfigLayer, NodeServices.layer, idAllocatorLayer))); | |
| 77 | +} | |
| 78 | + | |
| 79 | +export const AcpRegistryOrchestratorReplayHarness: OrchestratorV2ProviderReplayHarness< | |
| 80 | + AcpReplayTranscript, | |
| 81 | + AcpReplayTranscriptDecodeError | |
| 82 | +> = { | |
| 83 | + provider: ACP_REGISTRY_PROVIDER, | |
| 84 | + decodeTranscript: (transcript) => | |
| 85 | + decodeAcpReplayTranscript(transcript, ACP_REGISTRY_PROVIDER, { | |
| 86 | + retargetProvider: true, | |
| 87 | + }), | |
| 88 | + makeProviderAdapterRegistryLayer: makeAcpRegistryProviderAdapterRegistryReplayLayer, | |
| 89 | +}; | |
| @@ -0,0 +1,158 @@ | ||
| 1 | +import { | |
| 2 | + AcpRegistrySettings, | |
| 3 | + defaultInstanceIdForDriver, | |
| 4 | + ProviderDriverKind, | |
| 5 | +} from "@t3tools/contracts"; | |
| 6 | +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; | |
| 7 | +import * as Effect from "effect/Effect"; | |
| 8 | +import * as FileSystem from "effect/FileSystem"; | |
| 9 | +import * as Layer from "effect/Layer"; | |
| 10 | +import * as Path from "effect/Path"; | |
| 11 | +import * as Schema from "effect/Schema"; | |
| 12 | +import type * as Scope from "effect/Scope"; | |
| 13 | +import { HttpClient } from "effect/unstable/http"; | |
| 14 | +import { ChildProcessSpawner } from "effect/unstable/process"; | |
| 15 | +import * as EffectAcpErrors from "effect-acp/errors"; | |
| 16 | + | |
| 17 | +import { ServerConfig } from "../../config.ts"; | |
| 18 | +import { | |
| 19 | + makeAcpRegistryResolver, | |
| 20 | + type AcpRegistryResolverShape, | |
| 21 | +} from "../../provider/acp/AcpRegistrySupport.ts"; | |
| 22 | +import { | |
| 23 | + AcpSessionRuntime, | |
| 24 | + type AcpSessionRuntimeShape, | |
| 25 | +} from "../../provider/acp/AcpSessionRuntime.ts"; | |
| 26 | +import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts"; | |
| 27 | +import { IdAllocatorV2 } from "../IdAllocator.ts"; | |
| 28 | +import { | |
| 29 | + ProviderAdapterDriverCreateError, | |
| 30 | + type ProviderAdapterDriver, | |
| 31 | + type ProviderAdapterDriverCreateInput, | |
| 32 | +} from "../ProviderAdapterDriver.ts"; | |
| 33 | +import { | |
| 34 | + AcpProviderCapabilitiesV2, | |
| 35 | + makeAcpAdapterV2, | |
| 36 | + type AcpAdapterV2Flavor, | |
| 37 | + type AcpAdapterV2RuntimeInput, | |
| 38 | +} from "./AcpAdapterV2.ts"; | |
| 39 | + | |
| 40 | +export const ACP_REGISTRY_PROVIDER = "acpRegistry" as const; | |
| 41 | +export const ACP_REGISTRY_DRIVER_KIND = ProviderDriverKind.make(ACP_REGISTRY_PROVIDER); | |
| 42 | +export const ACP_REGISTRY_DEFAULT_INSTANCE_ID = | |
| 43 | + defaultInstanceIdForDriver(ACP_REGISTRY_DRIVER_KIND); | |
| 44 | + | |
| 45 | +const DEFAULT_ACP_REGISTRY_SETTINGS = Schema.decodeSync(AcpRegistrySettings)({}); | |
| 46 | + | |
| 47 | +export interface AcpRegistryAdapterV2Options { | |
| 48 | + readonly instanceId: Parameters<typeof makeAcpAdapterV2>[0]["instanceId"]; | |
| 49 | + readonly settings: AcpRegistrySettings; | |
| 50 | + readonly environment: NodeJS.ProcessEnv; | |
| 51 | + readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; | |
| 52 | + readonly fileSystem: FileSystem.FileSystem; | |
| 53 | + readonly idAllocator: IdAllocatorV2["Service"]; | |
| 54 | + readonly resolver: AcpRegistryResolverShape; | |
| 55 | + readonly serverConfig: ServerConfig["Service"]; | |
| 56 | + readonly makeRuntime?: ( | |
| 57 | + input: AcpAdapterV2RuntimeInput, | |
| 58 | + ) => Effect.Effect<AcpSessionRuntimeShape, EffectAcpErrors.AcpError, Scope.Scope>; | |
| 59 | + readonly assertComplete?: Effect.Effect<void, EffectAcpErrors.AcpError>; | |
| 60 | +} | |
| 61 | + | |
| 62 | +function makeAcpRegistryRuntime(options: AcpRegistryAdapterV2Options) { | |
| 63 | + return ( | |
| 64 | + input: AcpAdapterV2RuntimeInput, | |
| 65 | + ): Effect.Effect<AcpSessionRuntimeShape, EffectAcpErrors.AcpError, Scope.Scope> => | |
| 66 | + Effect.gen(function* () { | |
| 67 | + const resolved = yield* options.resolver | |
| 68 | + .resolve(options.settings, input.cwd, options.environment) | |
| 69 | + .pipe( | |
| 70 | + Effect.mapError( | |
| 71 | + (cause) => | |
| 72 | + new EffectAcpErrors.AcpSpawnError({ | |
| 73 | + command: options.settings.agentId || ACP_REGISTRY_PROVIDER, | |
| 74 | + cause, | |
| 75 | + }), | |
| 76 | + ), | |
| 77 | + ); | |
| 78 | + const context = yield* Layer.build( | |
| 79 | + AcpSessionRuntime.layer({ | |
| 80 | + ...input, | |
| 81 | + spawn: resolved.spawn, | |
| 82 | + ...(options.settings.authMethodId ? { authMethodId: options.settings.authMethodId } : {}), | |
| 83 | + }).pipe( | |
| 84 | + Layer.provide( | |
| 85 | + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, options.childProcessSpawner), | |
| 86 | + ), | |
| 87 | + ), | |
| 88 | + ); | |
| 89 | + return yield* Effect.service(AcpSessionRuntime).pipe(Effect.provide(context)); | |
| 90 | + }); | |
| 91 | +} | |
| 92 | + | |
| 93 | +export function makeAcpRegistryAdapterV2(options: AcpRegistryAdapterV2Options) { | |
| 94 | + const flavor: AcpAdapterV2Flavor = { | |
| 95 | + provider: ACP_REGISTRY_PROVIDER, | |
| 96 | + capabilities: AcpProviderCapabilitiesV2, | |
| 97 | + makeRuntime: options.makeRuntime ?? makeAcpRegistryRuntime(options), | |
| 98 | + ...(options.assertComplete === undefined ? {} : { assertComplete: options.assertComplete }), | |
| 99 | + }; | |
| 100 | + return makeAcpAdapterV2({ | |
| 101 | + instanceId: options.instanceId, | |
| 102 | + flavor, | |
| 103 | + fileSystem: options.fileSystem, | |
| 104 | + idAllocator: options.idAllocator, | |
| 105 | + serverConfig: options.serverConfig, | |
| 106 | + }); | |
| 107 | +} | |
| 108 | + | |
| 109 | +export type AcpRegistryAdapterV2DriverEnv = | |
| 110 | + | ChildProcessSpawner.ChildProcessSpawner | |
| 111 | + | FileSystem.FileSystem | |
| 112 | + | HttpClient.HttpClient | |
| 113 | + | IdAllocatorV2 | |
| 114 | + | Path.Path | |
| 115 | + | ServerConfig; | |
| 116 | + | |
| 117 | +export const AcpRegistryAdapterV2Driver: ProviderAdapterDriver< | |
| 118 | + AcpRegistrySettings, | |
| 119 | + AcpRegistryAdapterV2DriverEnv | |
| 120 | +> = { | |
| 121 | + driverKind: ACP_REGISTRY_DRIVER_KIND, | |
| 122 | + configSchema: AcpRegistrySettings, | |
| 123 | + defaultConfig: (): AcpRegistrySettings => DEFAULT_ACP_REGISTRY_SETTINGS, | |
| 124 | + create: Effect.fn("AcpRegistryAdapterV2Driver.create")( | |
| 125 | + function* (input: ProviderAdapterDriverCreateInput<AcpRegistrySettings>) { | |
| 126 | + const hostEnvironment = yield* HostProcessEnvironment; | |
| 127 | + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 128 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 129 | + const idAllocator = yield* IdAllocatorV2; | |
| 130 | + const serverConfig = yield* ServerConfig; | |
| 131 | + const resolver = yield* makeAcpRegistryResolver({ | |
| 132 | + cacheDir: serverConfig.providerStatusCacheDir, | |
| 133 | + }); | |
| 134 | + return makeAcpRegistryAdapterV2({ | |
| 135 | + instanceId: input.instanceId, | |
| 136 | + settings: { ...input.config, enabled: input.enabled }, | |
| 137 | + environment: mergeProviderInstanceEnvironment(input.environment, hostEnvironment), | |
| 138 | + childProcessSpawner, | |
| 139 | + fileSystem, | |
| 140 | + idAllocator, | |
| 141 | + resolver, | |
| 142 | + serverConfig, | |
| 143 | + }); | |
| 144 | + }, | |
| 145 | + (effect, input) => | |
| 146 | + effect.pipe( | |
| 147 | + Effect.mapError( | |
| 148 | + (cause) => | |
| 149 | + new ProviderAdapterDriverCreateError({ | |
| 150 | + driver: ACP_REGISTRY_DRIVER_KIND, | |
| 151 | + instanceId: input.instanceId, | |
| 152 | + detail: "Failed to create ACP Registry adapter.", | |
| 153 | + cause, | |
| 154 | + }), | |
| 155 | + ), | |
| 156 | + ), | |
| 157 | + ), | |
| 158 | +}; | |
| @@ -0,0 +1,111 @@ | ||
| 1 | +import { assert, describe, it } from "@effect/vitest"; | |
| 2 | +import type * as EffectAcpSchema from "effect-acp/schema"; | |
| 3 | + | |
| 4 | +import { ProviderAdapterV2RuntimePolicy } from "../ProviderAdapter.ts"; | |
| 5 | +import { AcpProviderCapabilitiesV2, acpPermissionDisposition } from "./AcpAdapterV2.ts"; | |
| 6 | +import { GrokProviderCapabilitiesV2 } from "./GrokAdapterV2.ts"; | |
| 7 | + | |
| 8 | +function permissionRequest( | |
| 9 | + kind: EffectAcpSchema.ToolKind, | |
| 10 | +): EffectAcpSchema.RequestPermissionRequest { | |
| 11 | + return { | |
| 12 | + sessionId: "session-1", | |
| 13 | + options: [ | |
| 14 | + { optionId: "allow-once", name: "Allow once", kind: "allow_once" }, | |
| 15 | + { optionId: "allow-always", name: "Allow always", kind: "allow_always" }, | |
| 16 | + { optionId: "reject-once", name: "Reject", kind: "reject_once" }, | |
| 17 | + ], | |
| 18 | + toolCall: { | |
| 19 | + toolCallId: "tool-1", | |
| 20 | + title: "Test tool", | |
| 21 | + kind, | |
| 22 | + }, | |
| 23 | + }; | |
| 24 | +} | |
| 25 | + | |
| 26 | +function runtimePolicy(input: { | |
| 27 | + readonly runtimeMode: "approval-required" | "auto-accept-edits" | "full-access"; | |
| 28 | + readonly approvalPolicy?: unknown; | |
| 29 | + readonly sandboxPolicy?: unknown; | |
| 30 | +}) { | |
| 31 | + return ProviderAdapterV2RuntimePolicy.make({ | |
| 32 | + runtimeMode: input.runtimeMode, | |
| 33 | + interactionMode: "default", | |
| 34 | + cwd: "/workspace", | |
| 35 | + ...(input.approvalPolicy === undefined ? {} : { approvalPolicy: input.approvalPolicy }), | |
| 36 | + ...(input.sandboxPolicy === undefined ? {} : { sandboxPolicy: input.sandboxPolicy }), | |
| 37 | + }); | |
| 38 | +} | |
| 39 | + | |
| 40 | +describe("GrokAdapterV2 capabilities", () => { | |
| 41 | + it("keeps optional protocol features conservative until a flavor or handshake confirms them", () => { | |
| 42 | + assert.isFalse(AcpProviderCapabilitiesV2.sessions.supportsModelSwitchInSession); | |
| 43 | + assert.isFalse(AcpProviderCapabilitiesV2.sessions.supportsRuntimeModeSwitchInSession); | |
| 44 | + assert.isFalse(AcpProviderCapabilitiesV2.threads.canReadThreadSnapshot); | |
| 45 | + assert.isFalse(AcpProviderCapabilitiesV2.tools.supportsMcpTools); | |
| 46 | + }); | |
| 47 | + | |
| 48 | + it("leaves non-native features available to orchestrator fallbacks", () => { | |
| 49 | + assert.isFalse(GrokProviderCapabilitiesV2.threads.canForkThread); | |
| 50 | + assert.isFalse(GrokProviderCapabilitiesV2.subagents.supportsSubagents); | |
| 51 | + assert.isFalse(GrokProviderCapabilitiesV2.turns.supportsActiveSteering); | |
| 52 | + assert.isTrue(GrokProviderCapabilitiesV2.turns.supportsInterrupt); | |
| 53 | + assert.isTrue(GrokProviderCapabilitiesV2.turns.supportsSteeringByInterruptRestart); | |
| 54 | + assert.isTrue(GrokProviderCapabilitiesV2.context.supportsFullThreadHandoff); | |
| 55 | + }); | |
| 56 | + | |
| 57 | + it("declares the optional ACP features verified by the Grok handshake", () => { | |
| 58 | + assert.isTrue(GrokProviderCapabilitiesV2.sessions.supportsModelSwitchInSession); | |
| 59 | + assert.isTrue(GrokProviderCapabilitiesV2.threads.canReadThreadSnapshot); | |
| 60 | + assert.isTrue(GrokProviderCapabilitiesV2.tools.supportsMcpTools); | |
| 61 | + assert.isTrue(GrokProviderCapabilitiesV2.checkpointing.providerCanReadConversationSnapshot); | |
| 62 | + }); | |
| 63 | +}); | |
| 64 | + | |
| 65 | +describe("ACP permission policy", () => { | |
| 66 | + it("honors explicit on-request approval over full-access runtime mode", () => { | |
| 67 | + assert.equal( | |
| 68 | + acpPermissionDisposition( | |
| 69 | + runtimePolicy({ | |
| 70 | + runtimeMode: "full-access", | |
| 71 | + approvalPolicy: "on-request", | |
| 72 | + sandboxPolicy: { type: "readOnly" }, | |
| 73 | + }), | |
| 74 | + permissionRequest("execute"), | |
| 75 | + ), | |
| 76 | + "ask", | |
| 77 | + ); | |
| 78 | + }); | |
| 79 | + | |
| 80 | + it("rejects mutating escalation under a non-interactive read-only policy", () => { | |
| 81 | + const policy = runtimePolicy({ | |
| 82 | + runtimeMode: "full-access", | |
| 83 | + approvalPolicy: "never", | |
| 84 | + sandboxPolicy: { type: "readOnly" }, | |
| 85 | + }); | |
| 86 | + assert.equal(acpPermissionDisposition(policy, permissionRequest("execute")), "deny"); | |
| 87 | + assert.equal(acpPermissionDisposition(policy, permissionRequest("edit")), "deny"); | |
| 88 | + assert.equal(acpPermissionDisposition(policy, permissionRequest("read")), "allow"); | |
| 89 | + }); | |
| 90 | + | |
| 91 | + it("auto-approves requests only when the resolved policy permits them", () => { | |
| 92 | + assert.equal( | |
| 93 | + acpPermissionDisposition( | |
| 94 | + runtimePolicy({ | |
| 95 | + runtimeMode: "full-access", | |
| 96 | + approvalPolicy: "never", | |
| 97 | + sandboxPolicy: { type: "dangerFullAccess" }, | |
| 98 | + }), | |
| 99 | + permissionRequest("execute"), | |
| 100 | + ), | |
| 101 | + "allow", | |
| 102 | + ); | |
| 103 | + assert.equal( | |
| 104 | + acpPermissionDisposition( | |
| 105 | + runtimePolicy({ runtimeMode: "approval-required" }), | |
| 106 | + permissionRequest("read"), | |
| 107 | + ), | |
| 108 | + "ask", | |
| 109 | + ); | |
| 110 | + }); | |
| 111 | +}); | |
| @@ -0,0 +1,76 @@ | ||
| 1 | +import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | +import { GrokSettings } from "@t3tools/contracts"; | |
| 3 | +import * as Effect from "effect/Effect"; | |
| 4 | +import * as FileSystem from "effect/FileSystem"; | |
| 5 | +import * as Layer from "effect/Layer"; | |
| 6 | +import * as Path from "effect/Path"; | |
| 7 | +import * as Schema from "effect/Schema"; | |
| 8 | +import { ChildProcessSpawner } from "effect/unstable/process"; | |
| 9 | + | |
| 10 | +import { ServerConfig } from "../../config.ts"; | |
| 11 | +import { layer as idAllocatorLayer, IdAllocatorV2 } from "../IdAllocator.ts"; | |
| 12 | +import { makeLayerEffect as makeProviderAdapterRegistryLayerEffect } from "../ProviderAdapterRegistry.ts"; | |
| 13 | +import type { OrchestratorV2ProviderReplayHarness } from "../testkit/ProviderReplayHarness.ts"; | |
| 14 | +import { makeReplayServerConfig } from "../testkit/ProviderReplayHarness.ts"; | |
| 15 | +import { | |
| 16 | + type AcpReplayTranscript, | |
| 17 | + AcpReplayTranscriptDecodeError, | |
| 18 | + decodeAcpReplayTranscript, | |
| 19 | + makeAcpReplayCompletenessAssertion, | |
| 20 | + makeAcpReplayRuntime, | |
| 21 | +} from "./AcpAdapterV2.testkit.ts"; | |
| 22 | +import { GROK_DEFAULT_INSTANCE_ID, GROK_PROVIDER, makeGrokAdapterV2 } from "./GrokAdapterV2.ts"; | |
| 23 | + | |
| 24 | +const DEFAULT_GROK_SETTINGS = Schema.decodeUnknownSync(GrokSettings)({}); | |
| 25 | + | |
| 26 | +export function makeGrokProviderAdapterRegistryReplayLayer(transcript: AcpReplayTranscript) { | |
| 27 | + const serverConfigLayer = Layer.effect( | |
| 28 | + ServerConfig, | |
| 29 | + makeReplayServerConfig(`grok-${transcript.scenario}`).pipe(Effect.orDie), | |
| 30 | + ).pipe(Layer.provide(NodeServices.layer)); | |
| 31 | + | |
| 32 | + return makeProviderAdapterRegistryLayerEffect( | |
| 33 | + Effect.gen(function* () { | |
| 34 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 35 | + const path = yield* Path.Path; | |
| 36 | + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 37 | + const idAllocator = yield* IdAllocatorV2; | |
| 38 | + const serverConfig = yield* ServerConfig; | |
| 39 | + const replayDir = yield* fileSystem | |
| 40 | + .makeTempDirectory({ | |
| 41 | + prefix: `t3-orchestration-v2-grok-replay-${transcript.scenario}-`, | |
| 42 | + }) | |
| 43 | + .pipe(Effect.orDie); | |
| 44 | + const statusPath = path.join(replayDir, "status.json"); | |
| 45 | + const scriptPath = yield* path | |
| 46 | + .fromFileUrl(new URL("../../../scripts/acp-replay-agent.ts", import.meta.url)) | |
| 47 | + .pipe(Effect.orDie); | |
| 48 | + const adapter = makeGrokAdapterV2({ | |
| 49 | + instanceId: GROK_DEFAULT_INSTANCE_ID, | |
| 50 | + settings: DEFAULT_GROK_SETTINGS, | |
| 51 | + environment: {}, | |
| 52 | + childProcessSpawner, | |
| 53 | + fileSystem, | |
| 54 | + idAllocator, | |
| 55 | + serverConfig, | |
| 56 | + makeRuntime: makeAcpReplayRuntime({ | |
| 57 | + transcript, | |
| 58 | + statusPath, | |
| 59 | + scriptPath, | |
| 60 | + childProcessSpawner, | |
| 61 | + }), | |
| 62 | + assertComplete: makeAcpReplayCompletenessAssertion(fileSystem, statusPath, transcript), | |
| 63 | + }); | |
| 64 | + return [adapter]; | |
| 65 | + }), | |
| 66 | + ).pipe(Layer.provide(Layer.mergeAll(serverConfigLayer, NodeServices.layer, idAllocatorLayer))); | |
| 67 | +} | |
| 68 | + | |
| 69 | +export const GrokOrchestratorReplayHarness: OrchestratorV2ProviderReplayHarness< | |
| 70 | + AcpReplayTranscript, | |
| 71 | + AcpReplayTranscriptDecodeError | |
| 72 | +> = { | |
| 73 | + provider: GROK_PROVIDER, | |
| 74 | + decodeTranscript: (transcript) => decodeAcpReplayTranscript(transcript, GROK_PROVIDER), | |
| 75 | + makeProviderAdapterRegistryLayer: makeGrokProviderAdapterRegistryReplayLayer, | |
| 76 | +}; | |
| @@ -0,0 +1,210 @@ | ||
| 1 | +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; | |
| 2 | +import { | |
| 3 | + defaultInstanceIdForDriver, | |
| 4 | + GrokSettings, | |
| 5 | + ProviderDriverKind, | |
| 6 | + type OrchestrationV2ProviderCapabilities, | |
| 7 | +} from "@t3tools/contracts"; | |
| 8 | +import * as Effect from "effect/Effect"; | |
| 9 | +import * as FileSystem from "effect/FileSystem"; | |
| 10 | +import * as Layer from "effect/Layer"; | |
| 11 | +import * as Schema from "effect/Schema"; | |
| 12 | +import type * as Scope from "effect/Scope"; | |
| 13 | +import { ChildProcessSpawner } from "effect/unstable/process"; | |
| 14 | +import type * as EffectAcpErrors from "effect-acp/errors"; | |
| 15 | + | |
| 16 | +import { ServerConfig } from "../../config.ts"; | |
| 17 | +import { | |
| 18 | + makeGrokAcpRuntime, | |
| 19 | + resolveGrokAcpBaseModelId, | |
| 20 | +} from "../../provider/acp/GrokAcpSupport.ts"; | |
| 21 | +import { | |
| 22 | + extractXAiAskUserQuestionIdentity, | |
| 23 | + extractXAiAskUserQuestions, | |
| 24 | + makeXAiAskUserQuestionCancelledResponse, | |
| 25 | + makeXAiAskUserQuestionResponse, | |
| 26 | + XAiAskUserQuestionRequest, | |
| 27 | +} from "../../provider/acp/XAiAcpExtension.ts"; | |
| 28 | +import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts"; | |
| 29 | +import type { AcpSessionRuntimeShape } from "../../provider/acp/AcpSessionRuntime.ts"; | |
| 30 | +import { IdAllocatorV2 } from "../IdAllocator.ts"; | |
| 31 | +import { ProviderAdapterV2 } from "../ProviderAdapter.ts"; | |
| 32 | +import { | |
| 33 | + ProviderAdapterDriverCreateError, | |
| 34 | + type ProviderAdapterDriver, | |
| 35 | + type ProviderAdapterDriverCreateInput, | |
| 36 | +} from "../ProviderAdapterDriver.ts"; | |
| 37 | +import { | |
| 38 | + AcpProviderCapabilitiesV2, | |
| 39 | + makeAcpAdapterV2, | |
| 40 | + type AcpAdapterV2Flavor, | |
| 41 | + type AcpAdapterV2RuntimeInput, | |
| 42 | +} from "./AcpAdapterV2.ts"; | |
| 43 | + | |
| 44 | +export const GROK_PROVIDER = "grok" as const; | |
| 45 | +export const GROK_DRIVER_KIND = ProviderDriverKind.make(GROK_PROVIDER); | |
| 46 | +export const GROK_DEFAULT_INSTANCE_ID = defaultInstanceIdForDriver(GROK_DRIVER_KIND); | |
| 47 | +const DEFAULT_GROK_SETTINGS = Schema.decodeSync(GrokSettings)({}); | |
| 48 | + | |
| 49 | +export const GrokProviderCapabilitiesV2 = { | |
| 50 | + ...AcpProviderCapabilitiesV2, | |
| 51 | + sessions: { | |
| 52 | + ...AcpProviderCapabilitiesV2.sessions, | |
| 53 | + supportsModelSwitchInSession: true, | |
| 54 | + supportsRuntimeModeSwitchInSession: false, | |
| 55 | + }, | |
| 56 | + threads: { | |
| 57 | + ...AcpProviderCapabilitiesV2.threads, | |
| 58 | + canReadThreadSnapshot: true, | |
| 59 | + canForkThread: false, | |
| 60 | + canForkFromTurn: false, | |
| 61 | + }, | |
| 62 | + subagents: { | |
| 63 | + ...AcpProviderCapabilitiesV2.subagents, | |
| 64 | + supportsSubagents: false, | |
| 65 | + }, | |
| 66 | + tools: { | |
| 67 | + ...AcpProviderCapabilitiesV2.tools, | |
| 68 | + supportsMcpTools: true, | |
| 69 | + }, | |
| 70 | + checkpointing: { | |
| 71 | + ...AcpProviderCapabilitiesV2.checkpointing, | |
| 72 | + providerCanReadConversationSnapshot: true, | |
| 73 | + }, | |
| 74 | +} satisfies OrchestrationV2ProviderCapabilities; | |
| 75 | + | |
| 76 | +export interface GrokAdapterV2Options { | |
| 77 | + readonly instanceId: Parameters<typeof makeAcpAdapterV2>[0]["instanceId"]; | |
| 78 | + readonly settings: GrokSettings; | |
| 79 | + readonly environment: NodeJS.ProcessEnv; | |
| 80 | + readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; | |
| 81 | + readonly fileSystem: FileSystem.FileSystem; | |
| 82 | + readonly idAllocator: IdAllocatorV2["Service"]; | |
| 83 | + readonly serverConfig: ServerConfig["Service"]; | |
| 84 | + readonly makeRuntime?: ( | |
| 85 | + input: AcpAdapterV2RuntimeInput, | |
| 86 | + ) => Effect.Effect<AcpSessionRuntimeShape, EffectAcpErrors.AcpError, Scope.Scope>; | |
| 87 | + readonly assertComplete?: Effect.Effect<void, EffectAcpErrors.AcpError>; | |
| 88 | +} | |
| 89 | + | |
| 90 | +export const registerGrokAcpExtensions: NonNullable<AcpAdapterV2Flavor["registerExtensions"]> = ({ | |
| 91 | + runtime, | |
| 92 | + requestUserInput, | |
| 93 | +}) => | |
| 94 | + Effect.forEach( | |
| 95 | + ["x.ai/ask_user_question", "_x.ai/ask_user_question"] as const, | |
| 96 | + (method) => | |
| 97 | + runtime.handleExtRequest(method, XAiAskUserQuestionRequest, (params) => { | |
| 98 | + const identity = extractXAiAskUserQuestionIdentity(params); | |
| 99 | + const questions = extractXAiAskUserQuestions(params).map((question) => ({ | |
| 100 | + id: question.id, | |
| 101 | + header: question.header, | |
| 102 | + question: question.question, | |
| 103 | + options: [...question.options], | |
| 104 | + })); | |
| 105 | + return requestUserInput({ | |
| 106 | + nativeItemId: `${identity.sessionId}:xai-question:${identity.toolCallId}`, | |
| 107 | + nativeRequestId: identity.toolCallId, | |
| 108 | + questions, | |
| 109 | + }).pipe( | |
| 110 | + Effect.map((answers) => | |
| 111 | + answers === null | |
| 112 | + ? makeXAiAskUserQuestionCancelledResponse() | |
| 113 | + : makeXAiAskUserQuestionResponse(params, answers), | |
| 114 | + ), | |
| 115 | + ); | |
| 116 | + }), | |
| 117 | + { discard: true }, | |
| 118 | + ); | |
| 119 | + | |
| 120 | +export function makeGrokAdapterV2(options: GrokAdapterV2Options) { | |
| 121 | + const flavor: AcpAdapterV2Flavor = { | |
| 122 | + provider: GROK_PROVIDER, | |
| 123 | + capabilities: GrokProviderCapabilitiesV2, | |
| 124 | + resolveModelId: (selection) => resolveGrokAcpBaseModelId(selection.model), | |
| 125 | + makeRuntime: | |
| 126 | + options.makeRuntime ?? | |
| 127 | + ((input) => | |
| 128 | + makeGrokAcpRuntime({ | |
| 129 | + ...input, | |
| 130 | + grokSettings: options.settings, | |
| 131 | + environment: options.environment, | |
| 132 | + childProcessSpawner: options.childProcessSpawner, | |
| 133 | + })), | |
| 134 | + registerExtensions: registerGrokAcpExtensions, | |
| 135 | + ...(options.assertComplete === undefined ? {} : { assertComplete: options.assertComplete }), | |
| 136 | + }; | |
| 137 | + return makeAcpAdapterV2({ | |
| 138 | + instanceId: options.instanceId, | |
| 139 | + flavor, | |
| 140 | + fileSystem: options.fileSystem, | |
| 141 | + idAllocator: options.idAllocator, | |
| 142 | + serverConfig: options.serverConfig, | |
| 143 | + }); | |
| 144 | +} | |
| 145 | + | |
| 146 | +export type GrokAdapterV2DriverEnv = | |
| 147 | + | ChildProcessSpawner.ChildProcessSpawner | |
| 148 | + | FileSystem.FileSystem | |
| 149 | + | IdAllocatorV2 | |
| 150 | + | ServerConfig; | |
| 151 | + | |
| 152 | +export const GrokAdapterV2Driver: ProviderAdapterDriver<GrokSettings, GrokAdapterV2DriverEnv> = { | |
| 153 | + driverKind: GROK_DRIVER_KIND, | |
| 154 | + configSchema: GrokSettings, | |
| 155 | + defaultConfig: (): GrokSettings => DEFAULT_GROK_SETTINGS, | |
| 156 | + create: Effect.fn("GrokAdapterV2Driver.create")( | |
| 157 | + function* (input: ProviderAdapterDriverCreateInput<GrokSettings>) { | |
| 158 | + const hostEnvironment = yield* HostProcessEnvironment; | |
| 159 | + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 160 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 161 | + const idAllocator = yield* IdAllocatorV2; | |
| 162 | + const serverConfig = yield* ServerConfig; | |
| 163 | + return makeGrokAdapterV2({ | |
| 164 | + instanceId: input.instanceId, | |
| 165 | + settings: { ...input.config, enabled: input.enabled }, | |
| 166 | + environment: mergeProviderInstanceEnvironment(input.environment, hostEnvironment), | |
| 167 | + childProcessSpawner, | |
| 168 | + fileSystem, | |
| 169 | + idAllocator, | |
| 170 | + serverConfig, | |
| 171 | + }); | |
| 172 | + }, | |
| 173 | + (effect, input) => | |
| 174 | + effect.pipe( | |
| 175 | + Effect.mapError( | |
| 176 | + (cause) => | |
| 177 | + new ProviderAdapterDriverCreateError({ | |
| 178 | + driver: GROK_DRIVER_KIND, | |
| 179 | + instanceId: input.instanceId, | |
| 180 | + detail: "Failed to create Grok ACP adapter.", | |
| 181 | + cause, | |
| 182 | + }), | |
| 183 | + ), | |
| 184 | + ), | |
| 185 | + ), | |
| 186 | +}; | |
| 187 | + | |
| 188 | +export const layer: Layer.Layer< | |
| 189 | + ProviderAdapterV2, | |
| 190 | + never, | |
| 191 | + ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | IdAllocatorV2 | ServerConfig | |
| 192 | +> = Layer.effect( | |
| 193 | + ProviderAdapterV2, | |
| 194 | + Effect.gen(function* () { | |
| 195 | + const hostEnvironment = yield* HostProcessEnvironment; | |
| 196 | + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 197 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 198 | + const idAllocator = yield* IdAllocatorV2; | |
| 199 | + const serverConfig = yield* ServerConfig; | |
| 200 | + return makeGrokAdapterV2({ | |
| 201 | + instanceId: GROK_DEFAULT_INSTANCE_ID, | |
| 202 | + settings: DEFAULT_GROK_SETTINGS, | |
| 203 | + environment: hostEnvironment, | |
| 204 | + childProcessSpawner, | |
| 205 | + fileSystem, | |
| 206 | + idAllocator, | |
| 207 | + serverConfig, | |
| 208 | + }); | |
| 209 | + }), | |
| 210 | +); | |
| @@ -0,0 +1,184 @@ | ||
| 1 | +import { assert, describe, it } from "@effect/vitest"; | |
| 2 | +import { | |
| 3 | + NodeId, | |
| 4 | + ProviderThreadId, | |
| 5 | + ProviderTurnId, | |
| 6 | + type OrchestrationV2ProviderTurn, | |
| 7 | +} from "@t3tools/contracts"; | |
| 8 | + | |
| 9 | +import { | |
| 10 | + openCodeBoundaryAfterProviderTurn, | |
| 11 | + openCodeChildPermissionRules, | |
| 12 | + openCodePermissionRules, | |
| 13 | + openCodePermissionRequestKind, | |
| 14 | + openCodeToolProjectionKind, | |
| 15 | + OpenCodeProviderCapabilitiesV2, | |
| 16 | +} from "./OpenCodeAdapterV2.ts"; | |
| 17 | +import { ProviderAdapterV2RuntimePolicy } from "../ProviderAdapter.ts"; | |
| 18 | + | |
| 19 | +function runtimePolicy( | |
| 20 | + runtimeMode: ProviderAdapterV2RuntimePolicy["runtimeMode"], | |
| 21 | + override: Partial<ProviderAdapterV2RuntimePolicy> = {}, | |
| 22 | +): ProviderAdapterV2RuntimePolicy { | |
| 23 | + return ProviderAdapterV2RuntimePolicy.make({ | |
| 24 | + runtimeMode, | |
| 25 | + interactionMode: "default", | |
| 26 | + cwd: null, | |
| 27 | + ...override, | |
| 28 | + }); | |
| 29 | +} | |
| 30 | + | |
| 31 | +function permissionAction(rules: ReturnType<typeof openCodePermissionRules>, permission: string) { | |
| 32 | + return rules.findLast((rule) => rule.permission === "*" || rule.permission === permission) | |
| 33 | + ?.action; | |
| 34 | +} | |
| 35 | + | |
| 36 | +function providerTurn(input: { | |
| 37 | + readonly id: string; | |
| 38 | + readonly ordinal: number; | |
| 39 | + readonly nativeId: string | null; | |
| 40 | +}): OrchestrationV2ProviderTurn { | |
| 41 | + return { | |
| 42 | + id: ProviderTurnId.make(input.id), | |
| 43 | + providerThreadId: ProviderThreadId.make("provider-thread:opencode-test"), | |
| 44 | + nodeId: NodeId.make(`node:${input.id}`), | |
| 45 | + runAttemptId: null, | |
| 46 | + nativeTurnRef: | |
| 47 | + input.nativeId === null | |
| 48 | + ? null | |
| 49 | + : { provider: "opencode", nativeId: input.nativeId, strength: "weak" }, | |
| 50 | + ordinal: input.ordinal, | |
| 51 | + status: "completed", | |
| 52 | + startedAt: null, | |
| 53 | + completedAt: null, | |
| 54 | + }; | |
| 55 | +} | |
| 56 | + | |
| 57 | +describe("OpenCodeAdapterV2", () => { | |
| 58 | + it("advertises the identity strengths exposed by the SDK boundary", () => { | |
| 59 | + assert.equal(OpenCodeProviderCapabilitiesV2.identity.nativeThreadIds, "strong"); | |
| 60 | + assert.equal(OpenCodeProviderCapabilitiesV2.identity.nativeTurnIds, "weak"); | |
| 61 | + assert.equal(OpenCodeProviderCapabilitiesV2.identity.nativeItemIds, "strong"); | |
| 62 | + assert.equal(OpenCodeProviderCapabilitiesV2.identity.nativeRequestIds, "strong"); | |
| 63 | + assert.isTrue(OpenCodeProviderCapabilitiesV2.threads.canForkFromTurn); | |
| 64 | + assert.isTrue(OpenCodeProviderCapabilitiesV2.turns.supportsActiveSteering); | |
| 65 | + assert.equal(OpenCodeProviderCapabilitiesV2.turns.terminalStatusQuality, "strong"); | |
| 66 | + assert.isFalse(OpenCodeProviderCapabilitiesV2.subagents.canCloseSubagents); | |
| 67 | + }); | |
| 68 | + | |
| 69 | + it("maps native permission families to orchestration request kinds", () => { | |
| 70 | + assert.equal(openCodePermissionRequestKind("bash"), "command"); | |
| 71 | + assert.equal(openCodePermissionRequestKind("read"), "file-read"); | |
| 72 | + assert.equal(openCodePermissionRequestKind("grep"), "file-read"); | |
| 73 | + assert.equal(openCodePermissionRequestKind("external_directory"), "file-read"); | |
| 74 | + assert.equal(openCodePermissionRequestKind("external_directory", "edit"), "file-change"); | |
| 75 | + assert.equal(openCodePermissionRequestKind("edit"), "file-change"); | |
| 76 | + assert.equal(openCodePermissionRequestKind("apply_patch"), "file-change"); | |
| 77 | + }); | |
| 78 | + | |
| 79 | + it("maps OpenCode tools to semantic turn-item families", () => { | |
| 80 | + assert.equal(openCodeToolProjectionKind("bash"), "command_execution"); | |
| 81 | + assert.equal(openCodeToolProjectionKind("edit"), "file_change"); | |
| 82 | + assert.equal(openCodeToolProjectionKind("read"), "file_search"); | |
| 83 | + assert.equal(openCodeToolProjectionKind("lsp"), "file_search"); | |
| 84 | + assert.equal(openCodeToolProjectionKind("websearch"), "web_search"); | |
| 85 | + assert.equal(openCodeToolProjectionKind("codesearch"), "web_search"); | |
| 86 | + assert.equal(openCodeToolProjectionKind("custom_tool"), "dynamic_tool"); | |
| 87 | + }); | |
| 88 | + | |
| 89 | + it("maps runtime modes to safe OpenCode permission rules", () => { | |
| 90 | + const approvalRequired = openCodePermissionRules(runtimePolicy("approval-required")); | |
| 91 | + assert.equal(permissionAction(approvalRequired, "read"), "allow"); | |
| 92 | + assert.equal(permissionAction(approvalRequired, "edit"), "ask"); | |
| 93 | + assert.equal(permissionAction(approvalRequired, "bash"), "ask"); | |
| 94 | + assert.equal(permissionAction(approvalRequired, "doom_loop"), "ask"); | |
| 95 | + assert.equal(permissionAction(approvalRequired, "unknown_plugin_tool"), "ask"); | |
| 96 | + assert.equal(permissionAction(approvalRequired, "question"), "allow"); | |
| 97 | + | |
| 98 | + const autoAcceptEdits = openCodePermissionRules(runtimePolicy("auto-accept-edits")); | |
| 99 | + assert.equal(permissionAction(autoAcceptEdits, "edit"), "allow"); | |
| 100 | + assert.equal(permissionAction(autoAcceptEdits, "bash"), "ask"); | |
| 101 | + | |
| 102 | + const fullAccess = openCodePermissionRules(runtimePolicy("full-access")); | |
| 103 | + assert.equal(permissionAction(fullAccess, "bash"), "allow"); | |
| 104 | + assert.equal(permissionAction(fullAccess, "edit"), "allow"); | |
| 105 | + | |
| 106 | + const granularApproval = openCodePermissionRules( | |
| 107 | + runtimePolicy("full-access", { | |
| 108 | + approvalPolicy: { granular: { request_permissions: true } }, | |
| 109 | + }), | |
| 110 | + ); | |
| 111 | + assert.equal(permissionAction(granularApproval, "bash"), "ask"); | |
| 112 | + assert.equal(permissionAction(granularApproval, "read"), "allow"); | |
| 113 | + }); | |
| 114 | + | |
| 115 | + it("enforces non-interactive sandbox policy through OpenCode permissions", () => { | |
| 116 | + const readOnly = openCodePermissionRules( | |
| 117 | + runtimePolicy("full-access", { | |
| 118 | + approvalPolicy: "never", | |
| 119 | + sandboxPolicy: { | |
| 120 | + type: "readOnly", | |
| 121 | + access: { type: "fullAccess" }, | |
| 122 | + networkAccess: false, | |
| 123 | + }, | |
| 124 | + }), | |
| 125 | + ); | |
| 126 | + assert.equal(permissionAction(readOnly, "read"), "allow"); | |
| 127 | + assert.equal(permissionAction(readOnly, "edit"), "deny"); | |
| 128 | + assert.equal(permissionAction(readOnly, "bash"), "deny"); | |
| 129 | + assert.equal(permissionAction(readOnly, "webfetch"), "deny"); | |
| 130 | + assert.equal(permissionAction(readOnly, "doom_loop"), "deny"); | |
| 131 | + assert.equal(permissionAction(readOnly, "unknown_plugin_tool"), "deny"); | |
| 132 | + assert.equal(permissionAction(readOnly, "external_directory"), "allow"); | |
| 133 | + | |
| 134 | + const workspaceWrite = openCodePermissionRules( | |
| 135 | + runtimePolicy("auto-accept-edits", { | |
| 136 | + approvalPolicy: "never", | |
| 137 | + sandboxPolicy: { | |
| 138 | + type: "workspaceWrite", | |
| 139 | + writableRoots: ["/tmp/opencode-workspace"], | |
| 140 | + networkAccess: true, | |
| 141 | + }, | |
| 142 | + }), | |
| 143 | + ); | |
| 144 | + assert.equal(permissionAction(workspaceWrite, "edit"), "allow"); | |
| 145 | + assert.equal(permissionAction(workspaceWrite, "bash"), "deny"); | |
| 146 | + assert.equal(permissionAction(workspaceWrite, "webfetch"), "allow"); | |
| 147 | + assert.deepInclude(workspaceWrite, { | |
| 148 | + permission: "external_directory", | |
| 149 | + pattern: "/tmp/opencode-workspace/*", | |
| 150 | + action: "allow", | |
| 151 | + }); | |
| 152 | + }); | |
| 153 | + | |
| 154 | + it("preserves OpenCode's recursion guard on task-created child sessions", () => { | |
| 155 | + const childRules = openCodeChildPermissionRules(runtimePolicy("full-access"), [ | |
| 156 | + { permission: "task", pattern: "*", action: "deny" }, | |
| 157 | + ]); | |
| 158 | + | |
| 159 | + assert.equal(permissionAction(childRules, "read"), "allow"); | |
| 160 | + assert.equal(permissionAction(childRules, "bash"), "allow"); | |
| 161 | + assert.equal(permissionAction(childRules, "task"), "deny"); | |
| 162 | + | |
| 163 | + const approvalRequiredPolicy = runtimePolicy("approval-required"); | |
| 164 | + const parentRules = openCodePermissionRules(approvalRequiredPolicy); | |
| 165 | + const childApprovalRules = openCodeChildPermissionRules(approvalRequiredPolicy, [ | |
| 166 | + ...parentRules.filter((rule) => rule.action === "deny"), | |
| 167 | + { permission: "task", pattern: "*", action: "deny" }, | |
| 168 | + ]); | |
| 169 | + assert.equal(permissionAction(childApprovalRules, "bash"), "ask"); | |
| 170 | + assert.equal(permissionAction(childApprovalRules, "task"), "deny"); | |
| 171 | + }); | |
| 172 | + | |
| 173 | + it("uses the next native user message as the exclusive fork and revert boundary", () => { | |
| 174 | + const first = providerTurn({ id: "turn:first", ordinal: 1, nativeId: "msg-user-1" }); | |
| 175 | + const synthetic = providerTurn({ id: "turn:synthetic", ordinal: 2, nativeId: null }); | |
| 176 | + const third = providerTurn({ id: "turn:third", ordinal: 3, nativeId: "msg-user-3" }); | |
| 177 | + | |
| 178 | + assert.equal( | |
| 179 | + openCodeBoundaryAfterProviderTurn([third, first, synthetic], first.id), | |
| 180 | + "msg-user-3", | |
| 181 | + ); | |
| 182 | + assert.isUndefined(openCodeBoundaryAfterProviderTurn([first, synthetic, third], third.id)); | |
| 183 | + }); | |
| 184 | +}); | |
| @@ -0,0 +1,385 @@ | ||
| 1 | +import type { Event as OpenCodeEvent, OpencodeClient } from "@opencode-ai/sdk/v2"; | |
| 2 | +import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 3 | +import { ProviderReplayEntry, type ProviderReplayTranscript } from "@t3tools/contracts"; | |
| 4 | +import * as Effect from "effect/Effect"; | |
| 5 | +import * as Duration from "effect/Duration"; | |
| 6 | +import * as Layer from "effect/Layer"; | |
| 7 | +import * as Schema from "effect/Schema"; | |
| 8 | + | |
| 9 | +import { ServerConfig } from "../../config.ts"; | |
| 10 | +import { | |
| 11 | + OpenCodeRuntime, | |
| 12 | + OpenCodeRuntimeError, | |
| 13 | + type OpenCodeRuntimeShape, | |
| 14 | +} from "../../provider/opencodeRuntime.ts"; | |
| 15 | +import { layer as idAllocatorLayer } from "../IdAllocator.ts"; | |
| 16 | +import { ProviderAdapterDriverCreateError } from "../ProviderAdapterDriver.ts"; | |
| 17 | +import { makeDriverLayer as makeProviderAdapterRegistryDriverLayer } from "../ProviderAdapterRegistry.ts"; | |
| 18 | +import { | |
| 19 | + makeReplayServerConfig, | |
| 20 | + type OrchestratorV2ProviderReplayHarness, | |
| 21 | +} from "../testkit/ProviderReplayHarness.ts"; | |
| 22 | +import { | |
| 23 | + OPENCODE_DEFAULT_INSTANCE_ID, | |
| 24 | + OPENCODE_DRIVER_KIND, | |
| 25 | + OPENCODE_PROVIDER, | |
| 26 | + OpenCodeAdapterV2Driver, | |
| 27 | +} from "./OpenCodeAdapterV2.ts"; | |
| 28 | + | |
| 29 | +export const OPENCODE_SDK_REPLAY_PROTOCOL = "opencode-sdk.sse" as const; | |
| 30 | + | |
| 31 | +const OpenCodeSdkReplayTranscript = Schema.Struct({ | |
| 32 | + provider: Schema.Literal(OPENCODE_PROVIDER), | |
| 33 | + protocol: Schema.Literal(OPENCODE_SDK_REPLAY_PROTOCOL), | |
| 34 | + version: Schema.String, | |
| 35 | + scenario: Schema.String, | |
| 36 | + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), | |
| 37 | + entries: Schema.Array(ProviderReplayEntry), | |
| 38 | +}); | |
| 39 | +export type OpenCodeSdkReplayTranscript = typeof OpenCodeSdkReplayTranscript.Type; | |
| 40 | +const decodeOpenCodeSdkReplayTranscript = Schema.decodeUnknownEffect(OpenCodeSdkReplayTranscript); | |
| 41 | + | |
| 42 | +export class OpenCodeReplayTranscriptDecodeError extends Schema.TaggedErrorClass<OpenCodeReplayTranscriptDecodeError>()( | |
| 43 | + "OpenCodeReplayTranscriptDecodeError", | |
| 44 | + { | |
| 45 | + provider: Schema.optional(Schema.String), | |
| 46 | + protocol: Schema.optional(Schema.String), | |
| 47 | + scenario: Schema.optional(Schema.String), | |
| 48 | + cause: Schema.Defect(), | |
| 49 | + }, | |
| 50 | +) { | |
| 51 | + override get message(): string { | |
| 52 | + return `Failed to decode OpenCode replay transcript for scenario ${this.scenario ?? "<unknown>"}.`; | |
| 53 | + } | |
| 54 | +} | |
| 55 | + | |
| 56 | +export class OpenCodeReplayMismatchError extends Schema.TaggedErrorClass<OpenCodeReplayMismatchError>()( | |
| 57 | + "OpenCodeReplayMismatchError", | |
| 58 | + { | |
| 59 | + scenario: Schema.String, | |
| 60 | + cursor: Schema.Number, | |
| 61 | + expected: Schema.Unknown, | |
| 62 | + actual: Schema.Unknown, | |
| 63 | + }, | |
| 64 | +) { | |
| 65 | + override get message(): string { | |
| 66 | + return `OpenCode replay frame mismatch at cursor ${this.cursor} in scenario ${this.scenario}.`; | |
| 67 | + } | |
| 68 | +} | |
| 69 | + | |
| 70 | +export class OpenCodeReplayIncompleteError extends Schema.TaggedErrorClass<OpenCodeReplayIncompleteError>()( | |
| 71 | + "OpenCodeReplayIncompleteError", | |
| 72 | + { | |
| 73 | + scenario: Schema.String, | |
| 74 | + cursor: Schema.Number, | |
| 75 | + remaining: Schema.Number, | |
| 76 | + }, | |
| 77 | +) { | |
| 78 | + override get message(): string { | |
| 79 | + return `OpenCode replay ended with ${this.remaining} unconsumed entries in scenario ${this.scenario}.`; | |
| 80 | + } | |
| 81 | +} | |
| 82 | + | |
| 83 | +export const OpenCodeReplayError = Schema.Union([ | |
| 84 | + OpenCodeReplayTranscriptDecodeError, | |
| 85 | + OpenCodeReplayMismatchError, | |
| 86 | + OpenCodeReplayIncompleteError, | |
| 87 | +]); | |
| 88 | +export type OpenCodeReplayError = typeof OpenCodeReplayError.Type; | |
| 89 | +export const OpenCodeOrchestratorReplayHarnessError = Schema.Union([ | |
| 90 | + OpenCodeReplayError, | |
| 91 | + ProviderAdapterDriverCreateError, | |
| 92 | +]); | |
| 93 | +export type OpenCodeOrchestratorReplayHarnessError = | |
| 94 | + typeof OpenCodeOrchestratorReplayHarnessError.Type; | |
| 95 | + | |
| 96 | +function replayValueMatches(expected: unknown, actual: unknown): boolean { | |
| 97 | + if (expected === "<any>" || expected === "<workspace>") return true; | |
| 98 | + if (Array.isArray(expected)) { | |
| 99 | + return ( | |
| 100 | + Array.isArray(actual) && | |
| 101 | + expected.length === actual.length && | |
| 102 | + expected.every((entry, index) => replayValueMatches(entry, actual[index])) | |
| 103 | + ); | |
| 104 | + } | |
| 105 | + if (typeof expected === "object" && expected !== null) { | |
| 106 | + if (typeof actual !== "object" || actual === null) return false; | |
| 107 | + return Object.entries(expected).every(([key, value]) => | |
| 108 | + replayValueMatches(value, (actual as Record<string, unknown>)[key]), | |
| 109 | + ); | |
| 110 | + } | |
| 111 | + return Object.is(expected, actual); | |
| 112 | +} | |
| 113 | + | |
| 114 | +function frameRecord(frame: unknown): Record<string, unknown> | null { | |
| 115 | + return typeof frame === "object" && frame !== null ? (frame as Record<string, unknown>) : null; | |
| 116 | +} | |
| 117 | + | |
| 118 | +class OpenCodeReplayController { | |
| 119 | + private cursor = 0; | |
| 120 | + private readonly waiters = new Set<() => void>(); | |
| 121 | + private failure: unknown = null; | |
| 122 | + private readonly transcript: OpenCodeSdkReplayTranscript; | |
| 123 | + | |
| 124 | + constructor(transcript: OpenCodeSdkReplayTranscript) { | |
| 125 | + this.transcript = transcript; | |
| 126 | + } | |
| 127 | + | |
| 128 | + async expectOutbound(actual: unknown): Promise<void> { | |
| 129 | + try { | |
| 130 | + const entry = this.transcript.entries[this.cursor]; | |
| 131 | + if (entry?.type !== "expect_outbound" || !replayValueMatches(entry.frame, actual)) { | |
| 132 | + throw new OpenCodeReplayMismatchError({ | |
| 133 | + scenario: this.transcript.scenario, | |
| 134 | + cursor: this.cursor, | |
| 135 | + expected: entry?.type === "expect_outbound" ? entry.frame : (entry ?? null), | |
| 136 | + actual, | |
| 137 | + }); | |
| 138 | + } | |
| 139 | + this.advance(); | |
| 140 | + } catch (cause) { | |
| 141 | + this.fail(cause); | |
| 142 | + throw cause; | |
| 143 | + } | |
| 144 | + } | |
| 145 | + | |
| 146 | + async response(operation: string): Promise<unknown> { | |
| 147 | + while (true) { | |
| 148 | + this.throwFailure(); | |
| 149 | + const entry = this.transcript.entries[this.cursor]; | |
| 150 | + if (entry?.type === "emit_inbound") { | |
| 151 | + const frame = frameRecord(entry.frame); | |
| 152 | + if (frame?.type === "sdk.response" && frame.operation === operation) { | |
| 153 | + if (entry.afterMs !== undefined && entry.afterMs > 0) { | |
| 154 | + await Effect.runPromise(Effect.sleep(Duration.millis(entry.afterMs))); | |
| 155 | + } | |
| 156 | + const data = frame.data; | |
| 157 | + this.advance(); | |
| 158 | + return data; | |
| 159 | + } | |
| 160 | + } | |
| 161 | + if (entry?.type === "runtime_exit") { | |
| 162 | + const mismatch = new OpenCodeReplayMismatchError({ | |
| 163 | + scenario: this.transcript.scenario, | |
| 164 | + cursor: this.cursor, | |
| 165 | + expected: { type: "sdk.response", operation }, | |
| 166 | + actual: entry, | |
| 167 | + }); | |
| 168 | + this.fail(mismatch); | |
| 169 | + throw mismatch; | |
| 170 | + } | |
| 171 | + await this.changed(); | |
| 172 | + } | |
| 173 | + } | |
| 174 | + | |
| 175 | + async *events(signal?: AbortSignal): AsyncIterable<OpenCodeEvent> { | |
| 176 | + while (true) { | |
| 177 | + if (signal?.aborted === true) return; | |
| 178 | + this.throwFailure(); | |
| 179 | + const entry = this.transcript.entries[this.cursor]; | |
| 180 | + if (entry?.type === "emit_inbound") { | |
| 181 | + const frame = frameRecord(entry.frame); | |
| 182 | + if (frame?.type === "sdk.event") { | |
| 183 | + if (entry.afterMs !== undefined && entry.afterMs > 0) { | |
| 184 | + await Effect.runPromise(Effect.sleep(Duration.millis(entry.afterMs))); | |
| 185 | + } | |
| 186 | + const event = frame.event as OpenCodeEvent; | |
| 187 | + this.advance(); | |
| 188 | + yield event; | |
| 189 | + continue; | |
| 190 | + } | |
| 191 | + } | |
| 192 | + if (entry?.type === "runtime_exit") { | |
| 193 | + this.advance(); | |
| 194 | + if (entry.status === "success") return; | |
| 195 | + const mismatch = new OpenCodeReplayMismatchError({ | |
| 196 | + scenario: this.transcript.scenario, | |
| 197 | + cursor: this.cursor - 1, | |
| 198 | + expected: { status: "success" }, | |
| 199 | + actual: entry, | |
| 200 | + }); | |
| 201 | + this.fail(mismatch); | |
| 202 | + throw mismatch; | |
| 203 | + } | |
| 204 | + await this.changed(signal); | |
| 205 | + } | |
| 206 | + } | |
| 207 | + | |
| 208 | + assertComplete(): void { | |
| 209 | + while (this.transcript.entries[this.cursor]?.type === "runtime_exit") { | |
| 210 | + const exit = this.transcript.entries[this.cursor]; | |
| 211 | + if (exit?.type !== "runtime_exit" || exit.status !== "success") break; | |
| 212 | + this.cursor += 1; | |
| 213 | + } | |
| 214 | + this.throwFailure(); | |
| 215 | + if (this.cursor !== this.transcript.entries.length) { | |
| 216 | + throw new OpenCodeReplayIncompleteError({ | |
| 217 | + scenario: this.transcript.scenario, | |
| 218 | + cursor: this.cursor, | |
| 219 | + remaining: this.transcript.entries.length - this.cursor, | |
| 220 | + }); | |
| 221 | + } | |
| 222 | + } | |
| 223 | + | |
| 224 | + private advance(): void { | |
| 225 | + this.cursor += 1; | |
| 226 | + for (const waiter of this.waiters) waiter(); | |
| 227 | + this.waiters.clear(); | |
| 228 | + } | |
| 229 | + | |
| 230 | + private fail(cause: unknown): void { | |
| 231 | + this.failure = cause; | |
| 232 | + for (const waiter of this.waiters) waiter(); | |
| 233 | + this.waiters.clear(); | |
| 234 | + } | |
| 235 | + | |
| 236 | + private throwFailure(): void { | |
| 237 | + if (this.failure !== null) throw this.failure; | |
| 238 | + } | |
| 239 | + | |
| 240 | + private changed(signal?: AbortSignal): Promise<void> { | |
| 241 | + if (signal?.aborted === true) return Promise.resolve(); | |
| 242 | + return new Promise((resolve) => { | |
| 243 | + const done = () => { | |
| 244 | + signal?.removeEventListener("abort", done); | |
| 245 | + this.waiters.delete(done); | |
| 246 | + resolve(); | |
| 247 | + }; | |
| 248 | + this.waiters.add(done); | |
| 249 | + signal?.addEventListener("abort", done, { once: true }); | |
| 250 | + }); | |
| 251 | + } | |
| 252 | +} | |
| 253 | + | |
| 254 | +function makeReplayClient(controller: OpenCodeReplayController): OpencodeClient { | |
| 255 | + const request = async (operation: string, input: unknown) => { | |
| 256 | + await controller.expectOutbound({ type: operation, input }); | |
| 257 | + return { data: await controller.response(operation) }; | |
| 258 | + }; | |
| 259 | + return { | |
| 260 | + event: { | |
| 261 | + subscribe: async (_input?: unknown, options?: { readonly signal?: AbortSignal }) => { | |
| 262 | + await controller.expectOutbound({ type: "event.subscribe" }); | |
| 263 | + return { stream: controller.events(options?.signal) }; | |
| 264 | + }, | |
| 265 | + }, | |
| 266 | + session: { | |
| 267 | + create: (input: unknown) => request("session.create", input), | |
| 268 | + get: (input: unknown) => request("session.get", input), | |
| 269 | + update: (input: unknown) => request("session.update", input), | |
| 270 | + messages: (input: unknown) => request("session.messages", input), | |
| 271 | + promptAsync: (input: unknown) => request("session.promptAsync", input), | |
| 272 | + abort: (input: unknown) => request("session.abort", input), | |
| 273 | + revert: (input: unknown) => request("session.revert", input), | |
| 274 | + unrevert: (input: unknown) => request("session.unrevert", input), | |
| 275 | + fork: (input: unknown) => request("session.fork", input), | |
| 276 | + }, | |
| 277 | + permission: { | |
| 278 | + reply: (input: unknown) => request("permission.reply", input), | |
| 279 | + }, | |
| 280 | + question: { | |
| 281 | + reply: (input: unknown) => request("question.reply", input), | |
| 282 | + }, | |
| 283 | + mcp: { | |
| 284 | + add: (input: unknown) => request("mcp.add", input), | |
| 285 | + }, | |
| 286 | + } as unknown as OpencodeClient; | |
| 287 | +} | |
| 288 | + | |
| 289 | +function makeOpenCodeReplayRuntimeLayer(transcript: OpenCodeSdkReplayTranscript) { | |
| 290 | + return Layer.effect( | |
| 291 | + OpenCodeRuntime, | |
| 292 | + Effect.gen(function* () { | |
| 293 | + const controller = new OpenCodeReplayController(transcript); | |
| 294 | + yield* Effect.addFinalizer(() => | |
| 295 | + Effect.sync(() => { | |
| 296 | + controller.assertComplete(); | |
| 297 | + }), | |
| 298 | + ); | |
| 299 | + const client = makeReplayClient(controller); | |
| 300 | + return OpenCodeRuntime.of({ | |
| 301 | + startOpenCodeServerProcess: () => | |
| 302 | + Effect.fail( | |
| 303 | + new OpenCodeRuntimeError({ | |
| 304 | + operation: "startOpenCodeServerProcess", | |
| 305 | + detail: "OpenCode replay uses an external in-memory SDK boundary.", | |
| 306 | + }), | |
| 307 | + ), | |
| 308 | + connectToOpenCodeServer: () => | |
| 309 | + Effect.succeed({ | |
| 310 | + url: "replay://opencode", | |
| 311 | + exitCode: null, | |
| 312 | + external: true, | |
| 313 | + }), | |
| 314 | + runOpenCodeCommand: () => | |
| 315 | + Effect.fail( | |
| 316 | + new OpenCodeRuntimeError({ | |
| 317 | + operation: "runOpenCodeCommand", | |
| 318 | + detail: "OpenCode replay does not execute commands.", | |
| 319 | + }), | |
| 320 | + ), | |
| 321 | + createOpenCodeSdkClient: () => client, | |
| 322 | + loadOpenCodeInventory: () => | |
| 323 | + Effect.fail( | |
| 324 | + new OpenCodeRuntimeError({ | |
| 325 | + operation: "loadOpenCodeInventory", | |
| 326 | + detail: "OpenCode replay does not load inventory.", | |
| 327 | + }), | |
| 328 | + ), | |
| 329 | + } satisfies OpenCodeRuntimeShape); | |
| 330 | + }), | |
| 331 | + ); | |
| 332 | +} | |
| 333 | + | |
| 334 | +export function makeOpenCodeProviderAdapterRegistryReplayLayer( | |
| 335 | + transcript: OpenCodeSdkReplayTranscript, | |
| 336 | +) { | |
| 337 | + const serverConfigLayer = Layer.effect( | |
| 338 | + ServerConfig, | |
| 339 | + makeReplayServerConfig(transcript.scenario).pipe(Effect.orDie), | |
| 340 | + ).pipe(Layer.provide(NodeServices.layer)); | |
| 341 | + return makeProviderAdapterRegistryDriverLayer({ | |
| 342 | + drivers: [OpenCodeAdapterV2Driver], | |
| 343 | + configMap: { | |
| 344 | + [OPENCODE_DEFAULT_INSTANCE_ID]: { | |
| 345 | + driver: OPENCODE_DRIVER_KIND, | |
| 346 | + config: { serverUrl: "replay://opencode" }, | |
| 347 | + }, | |
| 348 | + }, | |
| 349 | + }).pipe( | |
| 350 | + Layer.provide( | |
| 351 | + Layer.mergeAll( | |
| 352 | + makeOpenCodeReplayRuntimeLayer(transcript), | |
| 353 | + serverConfigLayer, | |
| 354 | + NodeServices.layer, | |
| 355 | + idAllocatorLayer, | |
| 356 | + ), | |
| 357 | + ), | |
| 358 | + ); | |
| 359 | +} | |
| 360 | + | |
| 361 | +function transcriptMetadata(transcript: ProviderReplayTranscript) { | |
| 362 | + return { | |
| 363 | + provider: transcript.provider, | |
| 364 | + protocol: transcript.protocol, | |
| 365 | + scenario: transcript.scenario, | |
| 366 | + }; | |
| 367 | +} | |
| 368 | + | |
| 369 | +export const OpenCodeOrchestratorReplayHarness: OrchestratorV2ProviderReplayHarness< | |
| 370 | + OpenCodeSdkReplayTranscript, | |
| 371 | + OpenCodeOrchestratorReplayHarnessError | |
| 372 | +> = { | |
| 373 | + provider: OPENCODE_PROVIDER, | |
| 374 | + decodeTranscript: (transcript) => | |
| 375 | + decodeOpenCodeSdkReplayTranscript(transcript).pipe( | |
| 376 | + Effect.mapError( | |
| 377 | + (cause) => | |
| 378 | + new OpenCodeReplayTranscriptDecodeError({ | |
| 379 | + ...transcriptMetadata(transcript), | |
| 380 | + cause, | |
| 381 | + }), | |
| 382 | + ), | |
| 383 | + ), | |
| 384 | + makeProviderAdapterRegistryLayer: makeOpenCodeProviderAdapterRegistryReplayLayer, | |
| 385 | +}; | |
| @@ -0,0 +1,2743 @@ | ||
| 1 | +import type { | |
| 2 | + Event as OpenCodeEvent, | |
| 3 | + Message as OpenCodeMessage, | |
| 4 | + Part as OpenCodePart, | |
| 5 | + PermissionRequest, | |
| 6 | + PermissionRuleset, | |
| 7 | + QuestionRequest, | |
| 8 | + Session as OpenCodeSession, | |
| 9 | + Todo as OpenCodeTodo, | |
| 10 | + ToolPart, | |
| 11 | +} from "@opencode-ai/sdk/v2"; | |
| 12 | +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; | |
| 13 | +import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; | |
| 14 | +import { | |
| 15 | + defaultInstanceIdForDriver, | |
| 16 | + type ModelSelection, | |
| 17 | + type OpenCodeSettings, | |
| 18 | + type OrchestrationV2AppThread, | |
| 19 | + type OrchestrationV2ConversationMessage, | |
| 20 | + type OrchestrationV2ExecutionNode, | |
| 21 | + type OrchestrationV2PlanStep, | |
| 22 | + type OrchestrationV2ProviderCapabilities, | |
| 23 | + type OrchestrationV2ProviderRef, | |
| 24 | + type OrchestrationV2ProviderSession, | |
| 25 | + type OrchestrationV2ProviderThread, | |
| 26 | + type OrchestrationV2ProviderTurn, | |
| 27 | + type OrchestrationV2RawProviderEvent, | |
| 28 | + type OrchestrationV2RuntimeRequest, | |
| 29 | + type OrchestrationV2Subagent, | |
| 30 | + type OrchestrationV2TurnItem, | |
| 31 | + OpenCodeSettings as OpenCodeSettingsSchema, | |
| 32 | + type PlanId, | |
| 33 | + ProviderDriverKind, | |
| 34 | + type ProviderInstanceId, | |
| 35 | + type ProviderRequestKind, | |
| 36 | + type RuntimeRequestId, | |
| 37 | + type ThreadId, | |
| 38 | +} from "@t3tools/contracts"; | |
| 39 | +import * as Cause from "effect/Cause"; | |
| 40 | +import * as DateTime from "effect/DateTime"; | |
| 41 | +import * as Effect from "effect/Effect"; | |
| 42 | +import * as Exit from "effect/Exit"; | |
| 43 | +import * as Layer from "effect/Layer"; | |
| 44 | +import * as Option from "effect/Option"; | |
| 45 | +import * as Queue from "effect/Queue"; | |
| 46 | +import * as Ref from "effect/Ref"; | |
| 47 | +import * as Schema from "effect/Schema"; | |
| 48 | +import * as Scope from "effect/Scope"; | |
| 49 | +import * as Stream from "effect/Stream"; | |
| 50 | + | |
| 51 | +import { resolveAttachmentPath } from "../../attachmentStore.ts"; | |
| 52 | +import { ServerConfig, type ServerConfigShape } from "../../config.ts"; | |
| 53 | +import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; | |
| 54 | +import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts"; | |
| 55 | +import { | |
| 56 | + OpenCodeRuntime, | |
| 57 | + OpenCodeRuntimeError, | |
| 58 | + openCodeQuestionId, | |
| 59 | + openCodeRuntimeErrorDetail, | |
| 60 | + parseOpenCodeModelSlug, | |
| 61 | + runOpenCodeSdk, | |
| 62 | + toOpenCodeFileParts, | |
| 63 | + toOpenCodePermissionReply, | |
| 64 | + toOpenCodeQuestionAnswers, | |
| 65 | + type OpenCodeRuntimeShape, | |
| 66 | +} from "../../provider/opencodeRuntime.ts"; | |
| 67 | +import { IdAllocatorV2, type IdAllocatorV2Shape } from "../IdAllocator.ts"; | |
| 68 | +import { | |
| 69 | + ProviderAdapterEnsureThreadError, | |
| 70 | + ProviderAdapterForkThreadError, | |
| 71 | + ProviderAdapterInterruptError, | |
| 72 | + ProviderAdapterOpenSessionError, | |
| 73 | + ProviderAdapterProtocolError, | |
| 74 | + ProviderAdapterReadThreadSnapshotError, | |
| 75 | + ProviderAdapterResumeThreadError, | |
| 76 | + ProviderAdapterRollbackThreadError, | |
| 77 | + ProviderAdapterRuntimeRequestResponseError, | |
| 78 | + ProviderAdapterSteerRunError, | |
| 79 | + ProviderAdapterTurnStartError, | |
| 80 | + ProviderAdapterV2, | |
| 81 | + type ProviderAdapterV2Event, | |
| 82 | + type ProviderAdapterV2OpenSessionInput, | |
| 83 | + type ProviderAdapterV2SessionRuntime, | |
| 84 | + type ProviderAdapterV2Shape, | |
| 85 | + type ProviderAdapterV2RuntimePolicy, | |
| 86 | + type ProviderAdapterV2TurnInput, | |
| 87 | +} from "../ProviderAdapter.ts"; | |
| 88 | +import { | |
| 89 | + ProviderAdapterDriverCreateError, | |
| 90 | + type ProviderAdapterDriver, | |
| 91 | + type ProviderAdapterDriverCreateInput, | |
| 92 | +} from "../ProviderAdapterDriver.ts"; | |
| 93 | +import { makeSubagentChildThread, subagentThreadTitle } from "../SubagentProjection.ts"; | |
| 94 | + | |
| 95 | +export const OPENCODE_PROVIDER = "opencode" as const; | |
| 96 | +export const OPENCODE_DRIVER_KIND = ProviderDriverKind.make(OPENCODE_PROVIDER); | |
| 97 | +export const OPENCODE_DEFAULT_INSTANCE_ID = defaultInstanceIdForDriver(OPENCODE_DRIVER_KIND); | |
| 98 | +const DEFAULT_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettingsSchema)({}); | |
| 99 | + | |
| 100 | +/** | |
| 101 | + * OpenCode's session, message, part, and interaction-request identifiers are | |
| 102 | + * durable. It does not expose a first-class turn object: the initiating user | |
| 103 | + * message is the best native turn correlation point, and session idle is the | |
| 104 | + * authoritative terminal signal. | |
| 105 | + */ | |
| 106 | +export const OpenCodeProviderCapabilitiesV2 = { | |
| 107 | + sessions: { | |
| 108 | + supportsMultipleProviderThreadsPerSession: true, | |
| 109 | + supportsModelSwitchInSession: true, | |
| 110 | + supportsProviderSwitchingViaHandoff: true, | |
| 111 | + supportsRuntimeModeSwitchInSession: false, | |
| 112 | + pendingRequestsSurviveRestart: false, | |
| 113 | + }, | |
| 114 | + threads: { | |
| 115 | + canCreateEmptyThread: true, | |
| 116 | + canReadThreadSnapshot: true, | |
| 117 | + canRollbackThread: true, | |
| 118 | + canForkThread: true, | |
| 119 | + canForkFromTurn: true, | |
| 120 | + canForkFromSubagentThread: true, | |
| 121 | + exposesNativeThreadId: true, | |
| 122 | + }, | |
| 123 | + turns: { | |
| 124 | + exposesNativeTurnId: false, | |
| 125 | + emitsTurnStarted: true, | |
| 126 | + emitsTurnCompleted: true, | |
| 127 | + supportsInterrupt: true, | |
| 128 | + supportsActiveSteering: true, | |
| 129 | + supportsSteeringByInterruptRestart: true, | |
| 130 | + supportsQueuedMessages: true, | |
| 131 | + terminalStatusQuality: "strong", | |
| 132 | + }, | |
| 133 | + streaming: { | |
| 134 | + streamsAssistantText: true, | |
| 135 | + streamsReasoning: true, | |
| 136 | + streamsToolOutput: true, | |
| 137 | + streamsPlanText: false, | |
| 138 | + emitsMessageCompleted: true, | |
| 139 | + }, | |
| 140 | + tools: { | |
| 141 | + exposesToolItemIds: true, | |
| 142 | + emitsToolStarted: true, | |
| 143 | + emitsToolCompleted: true, | |
| 144 | + emitsToolOutput: true, | |
| 145 | + supportsMcpTools: true, | |
| 146 | + supportsDynamicToolCallbacks: false, | |
| 147 | + }, | |
| 148 | + approvals: { | |
| 149 | + supportsCommandApproval: true, | |
| 150 | + supportsFileReadApproval: true, | |
| 151 | + supportsFileChangeApproval: true, | |
| 152 | + supportsApplyPatchApproval: true, | |
| 153 | + approvalsHaveNativeRequestIds: true, | |
| 154 | + approvalCallbacksAreLiveOnly: true, | |
| 155 | + approvalsCanOriginateFromSubagents: true, | |
| 156 | + }, | |
| 157 | + planning: { | |
| 158 | + emitsPlanUpdated: true, | |
| 159 | + emitsTodoList: true, | |
| 160 | + emitsProposedPlan: false, | |
| 161 | + supportsStructuredQuestions: true, | |
| 162 | + planDeltasHaveItemIds: false, | |
| 163 | + }, | |
| 164 | + subagents: { | |
| 165 | + supportsSubagents: true, | |
| 166 | + exposesSubagentThreadIds: true, | |
| 167 | + emitsSubagentLifecycle: true, | |
| 168 | + canWaitForSubagents: true, | |
| 169 | + canCloseSubagents: false, | |
| 170 | + canForkSubagentThread: true, | |
| 171 | + }, | |
| 172 | + context: { | |
| 173 | + acceptsSystemContext: false, | |
| 174 | + acceptsDeveloperContext: false, | |
| 175 | + acceptsSyntheticUserContext: true, | |
| 176 | + canGenerateSummaries: true, | |
| 177 | + canConsumeHandoffSummaries: true, | |
| 178 | + supportsDeltaHandoff: true, | |
| 179 | + supportsFullThreadHandoff: true, | |
| 180 | + maxRecommendedHandoffChars: null, | |
| 181 | + }, | |
| 182 | + checkpointing: { | |
| 183 | + appCanCheckpointFilesystem: true, | |
| 184 | + supportsNestedCheckpointScopes: true, | |
| 185 | + providerCanRollbackConversation: true, | |
| 186 | + providerRollbackReturnsSnapshot: true, | |
| 187 | + providerCanReadConversationSnapshot: true, | |
| 188 | + }, | |
| 189 | + identity: { | |
| 190 | + nativeThreadIds: "strong", | |
| 191 | + nativeTurnIds: "weak", | |
| 192 | + nativeItemIds: "strong", | |
| 193 | + nativeRequestIds: "strong", | |
| 194 | + }, | |
| 195 | +} satisfies OrchestrationV2ProviderCapabilities; | |
| 196 | + | |
| 197 | +type TerminalTurnStatus = Extract< | |
| 198 | + OrchestrationV2ProviderTurn["status"], | |
| 199 | + "completed" | "interrupted" | "failed" | "cancelled" | |
| 200 | +>; | |
| 201 | + | |
| 202 | +interface ActiveOpenCodeTurn { | |
| 203 | + readonly isRoot: boolean; | |
| 204 | + readonly threadId: ThreadId; | |
| 205 | + readonly runId: ProviderAdapterV2TurnInput["runId"] | null; | |
| 206 | + readonly rootNodeId: ProviderAdapterV2TurnInput["rootNodeId"]; | |
| 207 | + readonly appThread: OrchestrationV2AppThread; | |
| 208 | + readonly modelSelection: ModelSelection; | |
| 209 | + readonly runtimePolicy: ProviderAdapterV2RuntimePolicy; | |
| 210 | + readonly providerTurnId: OrchestrationV2ProviderTurn["id"]; | |
| 211 | + readonly providerTurnOrdinal: number; | |
| 212 | + readonly runAttemptId: OrchestrationV2ProviderTurn["runAttemptId"]; | |
| 213 | + readonly startedAt: DateTime.Utc; | |
| 214 | + readonly itemOrdinals: Map<string, number>; | |
| 215 | + readonly parts: Map<string, OpenCodePart>; | |
| 216 | + readonly partIdsByMessage: Map<string, Set<string>>; | |
| 217 | + readonly providerTurn: OrchestrationV2ProviderTurn; | |
| 218 | + nextItemOrdinal: number; | |
| 219 | + nativeUserMessageId: string | null; | |
| 220 | + interrupted: boolean; | |
| 221 | + finalized: boolean; | |
| 222 | + planId: PlanId | null; | |
| 223 | +} | |
| 224 | + | |
| 225 | +interface OpenCodeSubagentContext { | |
| 226 | + readonly nativeItemId: string; | |
| 227 | + readonly nodeId: OrchestrationV2Subagent["id"]; | |
| 228 | + readonly parentTurn: ActiveOpenCodeTurn; | |
| 229 | + readonly prompt: string; | |
| 230 | + readonly title: string | null; | |
| 231 | + readonly startedAt: DateTime.Utc; | |
| 232 | + childSessionId: string | null; | |
| 233 | + childThreadId: ThreadId | null; | |
| 234 | + childProviderThreadId: OrchestrationV2ProviderThread["id"] | null; | |
| 235 | + model: string | null; | |
| 236 | + result: string | null; | |
| 237 | +} | |
| 238 | + | |
| 239 | +interface OpenCodeThreadState { | |
| 240 | + readonly nativeSessionId: string; | |
| 241 | + providerThread: OrchestrationV2ProviderThread; | |
| 242 | + appThread: OrchestrationV2AppThread | null; | |
| 243 | + activeTurn: ActiveOpenCodeTurn | null; | |
| 244 | + readonly providerTurns: Map<string, OrchestrationV2ProviderTurn>; | |
| 245 | + readonly messages: Map<string, OrchestrationV2ConversationMessage>; | |
| 246 | + readonly runtimeRequests: Map<string, OrchestrationV2RuntimeRequest>; | |
| 247 | + readonly messageRoles: Map<string, "user" | "assistant">; | |
| 248 | + readonly userMessageIds: Array<string>; | |
| 249 | + parentSubagent: OpenCodeSubagentContext | null; | |
| 250 | + nextChildTurnOrdinal: number; | |
| 251 | +} | |
| 252 | + | |
| 253 | +interface PendingOpenCodeRequest { | |
| 254 | + readonly requestId: RuntimeRequestId; | |
| 255 | + readonly nativeRequestId: string; | |
| 256 | + readonly turn: ActiveOpenCodeTurn; | |
| 257 | + readonly state: OpenCodeThreadState; | |
| 258 | + readonly nodeId: OrchestrationV2ExecutionNode["id"]; | |
| 259 | + readonly turnItemId: OrchestrationV2TurnItem["id"]; | |
| 260 | + readonly requestKind: OpenCodePermissionRequestKind | "user_input"; | |
| 261 | + readonly createdAt: DateTime.Utc; | |
| 262 | + readonly permission?: PermissionRequest; | |
| 263 | + readonly question?: QuestionRequest; | |
| 264 | +} | |
| 265 | + | |
| 266 | +export interface OpenCodeAdapterV2Options { | |
| 267 | + readonly instanceId: ProviderInstanceId; | |
| 268 | + readonly settings: OpenCodeSettings; | |
| 269 | + readonly environment: NodeJS.ProcessEnv; | |
| 270 | + readonly runtime: OpenCodeRuntimeShape; | |
| 271 | + readonly idAllocator: IdAllocatorV2Shape; | |
| 272 | + readonly serverConfig: ServerConfigShape; | |
| 273 | +} | |
| 274 | + | |
| 275 | +function protocolError(detail: string, payload?: unknown): ProviderAdapterProtocolError { | |
| 276 | + return new ProviderAdapterProtocolError({ | |
| 277 | + provider: OPENCODE_PROVIDER, | |
| 278 | + detail, | |
| 279 | + ...(payload === undefined ? {} : { payload }), | |
| 280 | + }); | |
| 281 | +} | |
| 282 | + | |
| 283 | +function nativeThreadId(providerThread: OrchestrationV2ProviderThread): string { | |
| 284 | + const nativeId = providerThread.nativeThreadRef?.nativeId; | |
| 285 | + if (nativeId === null || nativeId === undefined) { | |
| 286 | + throw protocolError(`Provider thread ${providerThread.id} has no OpenCode session id`); | |
| 287 | + } | |
| 288 | + return nativeId; | |
| 289 | +} | |
| 290 | + | |
| 291 | +function dateTimeFromEpoch(value: number | undefined, fallback: DateTime.Utc): DateTime.Utc { | |
| 292 | + if (value === undefined) return fallback; | |
| 293 | + return Option.getOrElse(DateTime.make(value), () => fallback); | |
| 294 | +} | |
| 295 | + | |
| 296 | +function nonEmptyString(value: unknown): string | undefined { | |
| 297 | + return typeof value === "string" && value.trim().length > 0 ? value : undefined; | |
| 298 | +} | |
| 299 | + | |
| 300 | +function recordValue(input: unknown, key: string): unknown { | |
| 301 | + return typeof input === "object" && input !== null && key in input | |
| 302 | + ? (input as Record<string, unknown>)[key] | |
| 303 | + : undefined; | |
| 304 | +} | |
| 305 | + | |
| 306 | +function recordString(input: unknown, ...keys: ReadonlyArray<string>): string | undefined { | |
| 307 | + for (const key of keys) { | |
| 308 | + const value = nonEmptyString(recordValue(input, key)); | |
| 309 | + if (value !== undefined) return value; | |
| 310 | + } | |
| 311 | + return undefined; | |
| 312 | +} | |
| 313 | + | |
| 314 | +function recordNumber(input: unknown, ...keys: ReadonlyArray<string>): number | undefined { | |
| 315 | + for (const key of keys) { | |
| 316 | + const value = recordValue(input, key); | |
| 317 | + if (typeof value === "number" && Number.isFinite(value)) return value; | |
| 318 | + } | |
| 319 | + return undefined; | |
| 320 | +} | |
| 321 | + | |
| 322 | +function stableJson(value: unknown): string { | |
| 323 | + if (typeof value === "string") return value; | |
| 324 | + try { | |
| 325 | + return JSON.stringify(value, null, 2); | |
| 326 | + } catch { | |
| 327 | + return String(value); | |
| 328 | + } | |
| 329 | +} | |
| 330 | + | |
| 331 | +function sdkResponseForRawLog(value: unknown): unknown { | |
| 332 | + if (typeof value !== "object" || value === null) return value; | |
| 333 | + if ("data" in value) return { data: (value as { readonly data?: unknown }).data ?? null }; | |
| 334 | + if ("stream" in value) return { subscribed: true }; | |
| 335 | + return value; | |
| 336 | +} | |
| 337 | + | |
| 338 | +type OpenCodePermissionRequestKind = Extract< | |
| 339 | + ProviderRequestKind, | |
| 340 | + "command" | "file-read" | "file-change" | |
| 341 | +>; | |
| 342 | + | |
| 343 | +export function openCodePermissionRequestKind( | |
| 344 | + permission: string, | |
| 345 | + toolName?: string, | |
| 346 | +): OpenCodePermissionRequestKind { | |
| 347 | + const normalized = permission.toLowerCase(); | |
| 348 | + const normalizedTool = toolName?.toLowerCase() ?? ""; | |
| 349 | + if ( | |
| 350 | + normalized === "edit" || | |
| 351 | + normalized.includes("write") || | |
| 352 | + normalized.includes("patch") || | |
| 353 | + normalizedTool.includes("edit") || | |
| 354 | + normalizedTool.includes("write") || | |
| 355 | + normalizedTool.includes("patch") | |
| 356 | + ) { | |
| 357 | + return "file-change"; | |
| 358 | + } | |
| 359 | + if ( | |
| 360 | + normalized === "read" || | |
| 361 | + normalized === "glob" || | |
| 362 | + normalized === "grep" || | |
| 363 | + normalized === "lsp" || | |
| 364 | + normalized === "external_directory" || | |
| 365 | + normalizedTool === "read" || | |
| 366 | + normalizedTool.includes("glob") || | |
| 367 | + normalizedTool.includes("grep") || | |
| 368 | + normalizedTool.includes("search") | |
| 369 | + ) { | |
| 370 | + return "file-read"; | |
| 371 | + } | |
| 372 | + return "command"; | |
| 373 | +} | |
| 374 | + | |
| 375 | +export function openCodeToolProjectionKind( | |
| 376 | + toolName: string, | |
| 377 | +): "command_execution" | "file_change" | "file_search" | "web_search" | "dynamic_tool" { | |
| 378 | + const normalized = toolName.toLowerCase(); | |
| 379 | + if (normalized.includes("bash") || normalized.includes("shell")) { | |
| 380 | + return "command_execution"; | |
| 381 | + } | |
| 382 | + if (normalized.includes("edit") || normalized.includes("write") || normalized.includes("patch")) { | |
| 383 | + return "file_change"; | |
| 384 | + } | |
| 385 | + if (normalized.includes("web") || normalized === "codesearch" || normalized === "code_search") { | |
| 386 | + return "web_search"; | |
| 387 | + } | |
| 388 | + if ( | |
| 389 | + normalized === "read" || | |
| 390 | + normalized.includes("glob") || | |
| 391 | + normalized.includes("grep") || | |
| 392 | + normalized.includes("search") || | |
| 393 | + normalized.includes("lsp") | |
| 394 | + ) { | |
| 395 | + return "file_search"; | |
| 396 | + } | |
| 397 | + return "dynamic_tool"; | |
| 398 | +} | |
| 399 | + | |
| 400 | +const OPENCODE_ALWAYS_ALLOWED_PERMISSIONS = [ | |
| 401 | + "question", | |
| 402 | + "read", | |
| 403 | + "glob", | |
| 404 | + "grep", | |
| 405 | + "lsp", | |
| 406 | + "todowrite", | |
| 407 | + "task", | |
| 408 | + "skill", | |
| 409 | +] as const; | |
| 410 | + | |
| 411 | +const OPENCODE_RESTRICTED_PERMISSIONS = [ | |
| 412 | + "bash", | |
| 413 | + "edit", | |
| 414 | + "webfetch", | |
| 415 | + "websearch", | |
| 416 | + "codesearch", | |
| 417 | + "external_directory", | |
| 418 | + "doom_loop", | |
| 419 | +] as const; | |
| 420 | + | |
| 421 | +/** | |
| 422 | + * OpenCode does not provide an OS sandbox, so permission rules are also the | |
| 423 | + * enforcement boundary for non-interactive policies. Read/planning tools are | |
| 424 | + * safe by default; edits are auto-approved only for workspace-write modes, | |
| 425 | + * while shell/network/external access remains gated unless policy explicitly | |
| 426 | + * allows it. | |
| 427 | + */ | |
| 428 | +export function openCodePermissionRules( | |
| 429 | + runtimePolicy: ProviderAdapterV2RuntimePolicy, | |
| 430 | +): PermissionRuleset { | |
| 431 | + const sandboxPolicy = recordValue(runtimePolicy, "sandboxPolicy"); | |
| 432 | + const sandboxType = recordString(sandboxPolicy, "type"); | |
| 433 | + const rawApprovalPolicy = runtimePolicy.approvalPolicy; | |
| 434 | + const approvalPolicy = nonEmptyString(rawApprovalPolicy); | |
| 435 | + const requiresApproval = | |
| 436 | + approvalPolicy === undefined | |
| 437 | + ? (typeof rawApprovalPolicy === "object" && rawApprovalPolicy !== null) || | |
| 438 | + runtimePolicy.runtimeMode !== "full-access" | |
| 439 | + : approvalPolicy !== "never"; | |
| 440 | + const externallySandboxed = sandboxType === "externalSandbox"; | |
| 441 | + const dangerFullAccess = sandboxType === "dangerFullAccess"; | |
| 442 | + const implicitFullAccess = | |
| 443 | + sandboxType === undefined && runtimePolicy.runtimeMode === "full-access"; | |
| 444 | + | |
| 445 | + if (!requiresApproval && (externallySandboxed || dangerFullAccess || implicitFullAccess)) { | |
| 446 | + return [{ permission: "*", pattern: "*", action: "allow" }]; | |
| 447 | + } | |
| 448 | + | |
| 449 | + // Task sessions initially inherit only parent deny rules. Seed explicit | |
| 450 | + // denies before the effective ask/allow overrides so a child is safe during | |
| 451 | + // the short interval before emitSubagent installs its complete policy. | |
| 452 | + const rules: PermissionRuleset = [ | |
| 453 | + { permission: "*", pattern: "*", action: "deny" }, | |
| 454 | + ...OPENCODE_RESTRICTED_PERMISSIONS.map((permission) => ({ | |
| 455 | + permission, | |
| 456 | + pattern: "*", | |
| 457 | + action: "deny" as const, | |
| 458 | + })), | |
| 459 | + ]; | |
| 460 | + | |
| 461 | + if (requiresApproval) { | |
| 462 | + rules.push({ permission: "*", pattern: "*", action: "ask" }); | |
| 463 | + for (const permission of OPENCODE_RESTRICTED_PERMISSIONS) { | |
| 464 | + rules.push({ permission, pattern: "*", action: "ask" }); | |
| 465 | + } | |
| 466 | + } | |
| 467 | + | |
| 468 | + rules.push( | |
| 469 | + ...OPENCODE_ALWAYS_ALLOWED_PERMISSIONS.map((permission) => ({ | |
| 470 | + permission, | |
| 471 | + pattern: "*", | |
| 472 | + action: "allow" as const, | |
| 473 | + })), | |
| 474 | + ); | |
| 475 | + | |
| 476 | + if (runtimePolicy.runtimeMode === "auto-accept-edits" || sandboxType === "workspaceWrite") { | |
| 477 | + rules.push({ permission: "edit", pattern: "*", action: "allow" }); | |
| 478 | + } | |
| 479 | + | |
| 480 | + if (!requiresApproval && recordValue(sandboxPolicy, "networkAccess") === true) { | |
| 481 | + for (const permission of ["webfetch", "websearch", "codesearch"] as const) { | |
| 482 | + rules.push({ permission, pattern: "*", action: "allow" }); | |
| 483 | + } | |
| 484 | + } | |
| 485 | + | |
| 486 | + if (!requiresApproval && sandboxType === "readOnly") { | |
| 487 | + const access = recordValue(sandboxPolicy, "access"); | |
| 488 | + if (recordString(access, "type") === "fullAccess") { | |
| 489 | + rules.push({ permission: "external_directory", pattern: "*", action: "allow" }); | |
| 490 | + } | |
| 491 | + } | |
| 492 | + | |
| 493 | + if (!requiresApproval && sandboxType === "workspaceWrite") { | |
| 494 | + const writableRoots = recordValue(sandboxPolicy, "writableRoots"); | |
| 495 | + if (Array.isArray(writableRoots)) { | |
| 496 | + for (const root of writableRoots) { | |
| 497 | + if (typeof root === "string" && root.trim().length > 0) { | |
| 498 | + rules.push({ | |
| 499 | + permission: "external_directory", | |
| 500 | + pattern: `${root.replace(/\/$/, "")}/*`, | |
| 501 | + action: "allow", | |
| 502 | + }); | |
| 503 | + } | |
| 504 | + } | |
| 505 | + } | |
| 506 | + } | |
| 507 | + | |
| 508 | + return rules; | |
| 509 | +} | |
| 510 | + | |
| 511 | +function permissionRuleEquals( | |
| 512 | + left: PermissionRuleset[number], | |
| 513 | + right: PermissionRuleset[number], | |
| 514 | +): boolean { | |
| 515 | + return ( | |
| 516 | + left.permission === right.permission && | |
| 517 | + left.pattern === right.pattern && | |
| 518 | + left.action === right.action | |
| 519 | + ); | |
| 520 | +} | |
| 521 | + | |
| 522 | +/** | |
| 523 | + * OpenCode task sessions inherit only the parent's deny/external-directory | |
| 524 | + * rules and then add agent-specific restrictions such as disabling nested | |
| 525 | + * tasks. Install the complete parent policy while retaining only rules that | |
| 526 | + * were added specifically for the selected child agent. | |
| 527 | + */ | |
| 528 | +export function openCodeChildPermissionRules( | |
| 529 | + runtimePolicy: ProviderAdapterV2RuntimePolicy, | |
| 530 | + nativeChildRules: PermissionRuleset, | |
| 531 | +): PermissionRuleset { | |
| 532 | + const parentRules = openCodePermissionRules(runtimePolicy); | |
| 533 | + const inheritedRules = parentRules.filter( | |
| 534 | + (rule) => rule.permission === "external_directory" || rule.action === "deny", | |
| 535 | + ); | |
| 536 | + const childSpecificRules = nativeChildRules.filter( | |
| 537 | + (childRule) => | |
| 538 | + !inheritedRules.some((inheritedRule) => permissionRuleEquals(childRule, inheritedRule)), | |
| 539 | + ); | |
| 540 | + return [...parentRules, ...childSpecificRules]; | |
| 541 | +} | |
| 542 | + | |
| 543 | +/** | |
| 544 | + * OpenCode's fork/revert boundary is exclusive. To retain the selected app | |
| 545 | + * turn, address the next native user message; omitting a boundary retains the | |
| 546 | + * current head when the selected turn is already last. | |
| 547 | + */ | |
| 548 | +export function openCodeBoundaryAfterProviderTurn( | |
| 549 | + providerTurns: ReadonlyArray<OrchestrationV2ProviderTurn>, | |
| 550 | + selectedProviderTurnId: OrchestrationV2ProviderTurn["id"], | |
| 551 | +): string | undefined { | |
| 552 | + const selected = providerTurns.find((turn) => turn.id === selectedProviderTurnId); | |
| 553 | + if (selected === undefined) return undefined; | |
| 554 | + return providerTurns | |
| 555 | + .filter((turn) => turn.ordinal > selected.ordinal) | |
| 556 | + .toSorted((left, right) => left.ordinal - right.ordinal) | |
| 557 | + .map((turn) => turn.nativeTurnRef?.nativeId) | |
| 558 | + .find((nativeId): nativeId is string => nativeId !== null && nativeId !== undefined); | |
| 559 | +} | |
| 560 | + | |
| 561 | +function toolStatus(part: ToolPart): { | |
| 562 | + readonly node: OrchestrationV2ExecutionNode["status"]; | |
| 563 | + readonly item: OrchestrationV2TurnItem["status"]; | |
| 564 | +} { | |
| 565 | + switch (part.state.status) { | |
| 566 | + case "pending": | |
| 567 | + return { node: "pending", item: "pending" }; | |
| 568 | + case "running": | |
| 569 | + return { node: "running", item: "running" }; | |
| 570 | + case "completed": | |
| 571 | + return { node: "completed", item: "completed" }; | |
| 572 | + case "error": | |
| 573 | + return { node: "failed", item: "failed" }; | |
| 574 | + } | |
| 575 | +} | |
| 576 | + | |
| 577 | +function toolInput(part: ToolPart): Record<string, unknown> { | |
| 578 | + return part.state.input; | |
| 579 | +} | |
| 580 | + | |
| 581 | +function toolOutput(part: ToolPart): string | undefined { | |
| 582 | + if (part.state.status === "completed") return part.state.output; | |
| 583 | + if (part.state.status === "error") return part.state.error; | |
| 584 | + return undefined; | |
| 585 | +} | |
| 586 | + | |
| 587 | +function toolStartedAt(part: ToolPart, now: DateTime.Utc): DateTime.Utc { | |
| 588 | + return dateTimeFromEpoch( | |
| 589 | + part.state.status === "pending" ? undefined : part.state.time.start, | |
| 590 | + now, | |
| 591 | + ); | |
| 592 | +} | |
| 593 | + | |
| 594 | +function toolCompletedAt(part: ToolPart, now: DateTime.Utc): DateTime.Utc | null { | |
| 595 | + return part.state.status === "completed" || part.state.status === "error" | |
| 596 | + ? dateTimeFromEpoch(part.state.time.end, now) | |
| 597 | + : null; | |
| 598 | +} | |
| 599 | + | |
| 600 | +function toolTitle(part: ToolPart): string | null { | |
| 601 | + return part.state.status === "running" || part.state.status === "completed" | |
| 602 | + ? (part.state.title ?? null) | |
| 603 | + : null; | |
| 604 | +} | |
| 605 | + | |
| 606 | +function toolModel(part: ToolPart): string | null { | |
| 607 | + const metadata = | |
| 608 | + part.state.status === "running" || | |
| 609 | + part.state.status === "completed" || | |
| 610 | + part.state.status === "error" | |
| 611 | + ? part.state.metadata | |
| 612 | + : undefined; | |
| 613 | + const model = recordValue(metadata, "model"); | |
| 614 | + const providerId = recordString(model, "providerID", "providerId"); | |
| 615 | + const modelId = recordString(model, "modelID", "modelId", "id"); | |
| 616 | + return providerId !== undefined && modelId !== undefined ? `${providerId}/${modelId}` : null; | |
| 617 | +} | |
| 618 | + | |
| 619 | +function taskSessionId(part: ToolPart): string | null { | |
| 620 | + const metadata = | |
| 621 | + part.state.status === "running" || | |
| 622 | + part.state.status === "completed" || | |
| 623 | + part.state.status === "error" | |
| 624 | + ? part.state.metadata | |
| 625 | + : undefined; | |
| 626 | + return recordString(metadata, "sessionId", "sessionID") ?? null; | |
| 627 | +} | |
| 628 | + | |
| 629 | +function makeProviderThread(input: { | |
| 630 | + readonly idAllocator: IdAllocatorV2Shape; | |
| 631 | + readonly providerSessionId: OrchestrationV2ProviderThread["providerSessionId"]; | |
| 632 | + readonly appThreadId: OrchestrationV2ProviderThread["appThreadId"]; | |
| 633 | + readonly ownerNodeId?: OrchestrationV2ProviderThread["ownerNodeId"]; | |
| 634 | + readonly nativeSession: OpenCodeSession; | |
| 635 | + readonly forkedFrom?: OrchestrationV2ProviderThread["forkedFrom"]; | |
| 636 | + readonly now: DateTime.Utc; | |
| 637 | +}): OrchestrationV2ProviderThread { | |
| 638 | + const createdAt = dateTimeFromEpoch(input.nativeSession.time.created, input.now); | |
| 639 | + return { | |
| 640 | + id: input.idAllocator.derive.providerThread({ | |
| 641 | + provider: OPENCODE_PROVIDER, | |
| 642 | + nativeThreadId: input.nativeSession.id, | |
| 643 | + }), | |
| 644 | + provider: OPENCODE_PROVIDER, | |
| 645 | + providerSessionId: input.providerSessionId, | |
| 646 | + appThreadId: input.appThreadId, | |
| 647 | + ownerNodeId: input.ownerNodeId ?? null, | |
| 648 | + nativeThreadRef: { | |
| 649 | + provider: OPENCODE_PROVIDER, | |
| 650 | + nativeId: input.nativeSession.id, | |
| 651 | + strength: "strong", | |
| 652 | + }, | |
| 653 | + nativeConversationHeadRef: null, | |
| 654 | + status: "idle", | |
| 655 | + firstRunOrdinal: null, | |
| 656 | + lastRunOrdinal: null, | |
| 657 | + handoffIds: [], | |
| 658 | + forkedFrom: input.forkedFrom ?? null, | |
| 659 | + createdAt, | |
| 660 | + updatedAt: dateTimeFromEpoch(input.nativeSession.time.updated, input.now), | |
| 661 | + }; | |
| 662 | +} | |
| 663 | + | |
| 664 | +function providerRef(nativeId: string, strength: "strong" | "weak" = "strong") { | |
| 665 | + return { | |
| 666 | + provider: OPENCODE_PROVIDER, | |
| 667 | + nativeId, | |
| 668 | + strength, | |
| 669 | + } satisfies OrchestrationV2ProviderRef; | |
| 670 | +} | |
| 671 | + | |
| 672 | +function openCodeErrorMessage(event: Extract<OpenCodeEvent, { type: "session.error" }>): string { | |
| 673 | + const error = event.properties.error; | |
| 674 | + if (error === undefined) return "OpenCode session failed without an error payload."; | |
| 675 | + return recordString(error.data, "message") ?? error.name; | |
| 676 | +} | |
| 677 | + | |
| 678 | +function terminalStatusForError( | |
| 679 | + event: Extract<OpenCodeEvent, { type: "session.error" }>, | |
| 680 | + turn: ActiveOpenCodeTurn, | |
| 681 | +): TerminalTurnStatus { | |
| 682 | + return turn.interrupted || isMessageAbortedError(event) ? "interrupted" : "failed"; | |
| 683 | +} | |
| 684 | + | |
| 685 | +function isMessageAbortedError(event: Extract<OpenCodeEvent, { type: "session.error" }>): boolean { | |
| 686 | + return event.properties.error?.name === "MessageAbortedError"; | |
| 687 | +} | |
| 688 | + | |
| 689 | +function unwrapData<A>(operation: string, result: { readonly data?: A }): NonNullable<A> { | |
| 690 | + if (result.data === undefined) { | |
| 691 | + throw new OpenCodeRuntimeError({ | |
| 692 | + operation, | |
| 693 | + detail: `OpenCode ${operation} returned no response payload.`, | |
| 694 | + }); | |
| 695 | + } | |
| 696 | + return result.data as NonNullable<A>; | |
| 697 | +} | |
| 698 | + | |
| 699 | +export function makeOpenCodeAdapterV2(options: OpenCodeAdapterV2Options): ProviderAdapterV2Shape { | |
| 700 | + const { idAllocator, runtime, serverConfig } = options; | |
| 701 | + | |
| 702 | + return ProviderAdapterV2.of({ | |
| 703 | + instanceId: options.instanceId, | |
| 704 | + provider: OPENCODE_PROVIDER, | |
| 705 | + getCapabilities: () => Effect.succeed(OpenCodeProviderCapabilitiesV2), | |
| 706 | + openSession: Effect.fn("OpenCodeAdapterV2.openSession")( | |
| 707 | + function* (input: ProviderAdapterV2OpenSessionInput) { | |
| 708 | + const scope = yield* Effect.scope; | |
| 709 | + const cwd = input.runtimePolicy.cwd ?? serverConfig.cwd; | |
| 710 | + const connection = yield* runtime.connectToOpenCodeServer({ | |
| 711 | + binaryPath: options.settings.binaryPath, | |
| 712 | + serverUrl: options.settings.serverUrl, | |
| 713 | + environment: options.environment, | |
| 714 | + }); | |
| 715 | + const client = runtime.createOpenCodeSdkClient({ | |
| 716 | + baseUrl: connection.url, | |
| 717 | + directory: cwd, | |
| 718 | + ...(connection.external && options.settings.serverPassword | |
| 719 | + ? { serverPassword: options.settings.serverPassword } | |
| 720 | + : {}), | |
| 721 | + }); | |
| 722 | + | |
| 723 | + const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); | |
| 724 | + if (mcpSession !== undefined && !connection.external) { | |
| 725 | + yield* runOpenCodeSdk("mcp.add", () => | |
| 726 | + client.mcp.add({ | |
| 727 | + name: "t3-code", | |
| 728 | + config: { | |
| 729 | + type: "remote", | |
| 730 | + url: mcpSession.endpoint, | |
| 731 | + headers: { Authorization: mcpSession.authorizationHeader }, | |
| 732 | + oauth: false, | |
| 733 | + }, | |
| 734 | + }), | |
| 735 | + ); | |
| 736 | + } | |
| 737 | + | |
| 738 | + const now = yield* DateTime.now; | |
| 739 | + let sessionEntity: OrchestrationV2ProviderSession = { | |
| 740 | + id: input.providerSessionId, | |
| 741 | + provider: OPENCODE_PROVIDER, | |
| 742 | + status: "ready", | |
| 743 | + cwd, | |
| 744 | + model: input.modelSelection.model, | |
| 745 | + capabilities: OpenCodeProviderCapabilitiesV2, | |
| 746 | + createdAt: now, | |
| 747 | + updatedAt: now, | |
| 748 | + lastError: null, | |
| 749 | + }; | |
| 750 | + const events = yield* Queue.unbounded<ProviderAdapterV2Event>(); | |
| 751 | + const rawEvents = yield* Queue.unbounded<OrchestrationV2RawProviderEvent>(); | |
| 752 | + const rawSequence = yield* Ref.make(0); | |
| 753 | + const threads = new Map<string, OpenCodeThreadState>(); | |
| 754 | + const pendingRequests = new Map<string, PendingOpenCodeRequest>(); | |
| 755 | + const pendingRequestsByNativeId = new Map<string, PendingOpenCodeRequest>(); | |
| 756 | + const subagentsByNativeItemId = new Map<string, OpenCodeSubagentContext>(); | |
| 757 | + const subagentsByChildSessionId = new Map<string, OpenCodeSubagentContext>(); | |
| 758 | + const abortController = new AbortController(); | |
| 759 | + | |
| 760 | + const emitProviderEvent = (event: ProviderAdapterV2Event) => | |
| 761 | + Queue.offer(events, event).pipe(Effect.asVoid); | |
| 762 | + | |
| 763 | + const emitRawEvent = (raw: { | |
| 764 | + readonly direction: "incoming" | "outgoing"; | |
| 765 | + readonly messageKind: OrchestrationV2RawProviderEvent["messageKind"]; | |
| 766 | + readonly method: string; | |
| 767 | + readonly payload: unknown; | |
| 768 | + }): Effect.Effect<void, never> => | |
| 769 | + Effect.gen(function* () { | |
| 770 | + const sequence = yield* Ref.updateAndGet(rawSequence, (value) => value + 1); | |
| 771 | + const observedAt = yield* DateTime.now; | |
| 772 | + const rawEventId = yield* idAllocator.allocate.rawEvent({ | |
| 773 | + providerSessionId: input.providerSessionId, | |
| 774 | + method: raw.method, | |
| 775 | + }); | |
| 776 | + yield* Queue.offer(rawEvents, { | |
| 777 | + id: rawEventId, | |
| 778 | + provider: OPENCODE_PROVIDER, | |
| 779 | + providerSessionId: input.providerSessionId, | |
| 780 | + sequence, | |
| 781 | + direction: raw.direction, | |
| 782 | + messageKind: raw.messageKind, | |
| 783 | + method: raw.method, | |
| 784 | + jsonRpcId: null, | |
| 785 | + payload: raw.payload, | |
| 786 | + observedAt, | |
| 787 | + }); | |
| 788 | + }).pipe( | |
| 789 | + Effect.catchCause((cause) => | |
| 790 | + Effect.logWarning("orchestration-v2.opencode-raw-event-failed", { | |
| 791 | + providerSessionId: input.providerSessionId, | |
| 792 | + cause, | |
| 793 | + }), | |
| 794 | + ), | |
| 795 | + ); | |
| 796 | + | |
| 797 | + const sdkCall = <A>( | |
| 798 | + method: string, | |
| 799 | + payload: unknown, | |
| 800 | + call: () => Promise<A>, | |
| 801 | + ): Effect.Effect<A, OpenCodeRuntimeError> => | |
| 802 | + emitRawEvent({ | |
| 803 | + direction: "outgoing", | |
| 804 | + messageKind: "request", | |
| 805 | + method, | |
| 806 | + payload, | |
| 807 | + }).pipe( | |
| 808 | + Effect.andThen(runOpenCodeSdk(method, call)), | |
| 809 | + Effect.tap((response) => | |
| 810 | + emitRawEvent({ | |
| 811 | + direction: "incoming", | |
| 812 | + messageKind: "response", | |
| 813 | + method, | |
| 814 | + payload: sdkResponseForRawLog(response), | |
| 815 | + }), | |
| 816 | + ), | |
| 817 | + ); | |
| 818 | + | |
| 819 | + const updateProviderSession = ( | |
| 820 | + status: OrchestrationV2ProviderSession["status"], | |
| 821 | + lastError: string | null = sessionEntity.lastError, | |
| 822 | + ) => | |
| 823 | + Effect.gen(function* () { | |
| 824 | + const updatedAt = yield* DateTime.now; | |
| 825 | + sessionEntity = { ...sessionEntity, status, lastError, updatedAt }; | |
| 826 | + yield* emitProviderEvent({ | |
| 827 | + type: "provider_session.updated", | |
| 828 | + provider: OPENCODE_PROVIDER, | |
| 829 | + providerSession: sessionEntity, | |
| 830 | + }); | |
| 831 | + }); | |
| 832 | + | |
| 833 | + const updateProviderThread = ( | |
| 834 | + state: OpenCodeThreadState, | |
| 835 | + patch: Partial<OrchestrationV2ProviderThread>, | |
| 836 | + ) => | |
| 837 | + Effect.gen(function* () { | |
| 838 | + const updatedAt = yield* DateTime.now; | |
| 839 | + state.providerThread = { ...state.providerThread, ...patch, updatedAt }; | |
| 840 | + yield* emitProviderEvent({ | |
| 841 | + type: "provider_thread.updated", | |
| 842 | + provider: OPENCODE_PROVIDER, | |
| 843 | + providerThread: state.providerThread, | |
| 844 | + }); | |
| 845 | + }); | |
| 846 | + | |
| 847 | + const itemOrdinal = (turn: ActiveOpenCodeTurn, nativeItemId: string): number => { | |
| 848 | + const existing = turn.itemOrdinals.get(nativeItemId); | |
| 849 | + if (existing !== undefined) return existing; | |
| 850 | + const ordinal = turn.nextItemOrdinal++; | |
| 851 | + turn.itemOrdinals.set(nativeItemId, ordinal); | |
| 852 | + return ordinal; | |
| 853 | + }; | |
| 854 | + | |
| 855 | + const emitProviderTurn = ( | |
| 856 | + state: OpenCodeThreadState, | |
| 857 | + turn: ActiveOpenCodeTurn, | |
| 858 | + status: OrchestrationV2ProviderTurn["status"], | |
| 859 | + completedAt: DateTime.Utc | null, | |
| 860 | + ) => { | |
| 861 | + const providerTurn: OrchestrationV2ProviderTurn = { | |
| 862 | + ...turn.providerTurn, | |
| 863 | + nativeTurnRef: | |
| 864 | + turn.nativeUserMessageId === null | |
| 865 | + ? turn.providerTurn.nativeTurnRef | |
| 866 | + : providerRef(turn.nativeUserMessageId, "weak"), | |
| 867 | + status, | |
| 868 | + completedAt, | |
| 869 | + }; | |
| 870 | + Object.assign(turn.providerTurn, providerTurn); | |
| 871 | + state.providerTurns.set(String(providerTurn.id), providerTurn); | |
| 872 | + return emitProviderEvent({ | |
| 873 | + type: "provider_turn.updated", | |
| 874 | + provider: OPENCODE_PROVIDER, | |
| 875 | + threadId: turn.threadId, | |
| 876 | + providerTurn, | |
| 877 | + }); | |
| 878 | + }; | |
| 879 | + | |
| 880 | + const emitTextPart = Effect.fnUntraced(function* ( | |
| 881 | + state: OpenCodeThreadState, | |
| 882 | + turn: ActiveOpenCodeTurn, | |
| 883 | + part: Extract<OpenCodePart, { type: "text" | "reasoning" }>, | |
| 884 | + forceCompleted = false, | |
| 885 | + ) { | |
| 886 | + if (part.type === "text" && (part.ignored === true || part.synthetic === true)) return; | |
| 887 | + if (part.text.length === 0) return; | |
| 888 | + const emittedAt = yield* DateTime.now; | |
| 889 | + const isCompleted = forceCompleted || part.time?.end !== undefined; | |
| 890 | + const startedAt = dateTimeFromEpoch(part.time?.start, emittedAt); | |
| 891 | + const completedAt = isCompleted ? dateTimeFromEpoch(part.time?.end, emittedAt) : null; | |
| 892 | + const nativeItemRef = providerRef(part.id); | |
| 893 | + const nodeId = idAllocator.derive.nodeFromProviderItem({ | |
| 894 | + provider: OPENCODE_PROVIDER, | |
| 895 | + nativeItemId: part.id, | |
| 896 | + }); | |
| 897 | + const turnItemId = idAllocator.derive.turnItemFromProviderItem({ | |
| 898 | + provider: OPENCODE_PROVIDER, | |
| 899 | + nativeItemId: part.id, | |
| 900 | + }); | |
| 901 | + const ordinal = itemOrdinal(turn, part.id); | |
| 902 | + yield* emitProviderEvent({ | |
| 903 | + type: "node.updated", | |
| 904 | + provider: OPENCODE_PROVIDER, | |
| 905 | + node: { | |
| 906 | + id: nodeId, | |
| 907 | + threadId: turn.threadId, | |
| 908 | + runId: turn.runId, | |
| 909 | + parentNodeId: turn.rootNodeId, | |
| 910 | + rootNodeId: turn.rootNodeId, | |
| 911 | + kind: part.type === "text" ? "assistant_message" : "reasoning", | |
| 912 | + status: isCompleted ? "completed" : "running", | |
| 913 | + countsForRun: false, | |
| 914 | + providerThreadId: state.providerThread.id, | |
| 915 | + providerTurnId: turn.providerTurnId, | |
| 916 | + nativeItemRef, | |
| 917 | + runtimeRequestId: null, | |
| 918 | + checkpointScopeId: null, | |
| 919 | + startedAt, | |
| 920 | + completedAt, | |
| 921 | + }, | |
| 922 | + }); | |
| 923 | + if (part.type === "text") { | |
| 924 | + const messageId = idAllocator.derive.messageFromProviderItem({ | |
| 925 | + provider: OPENCODE_PROVIDER, | |
| 926 | + nativeItemId: part.id, | |
| 927 | + }); | |
| 928 | + const message: OrchestrationV2ConversationMessage = { | |
| 929 | + id: messageId, | |
| 930 | + threadId: turn.threadId, | |
| 931 | + runId: turn.runId, | |
| 932 | + nodeId, | |
| 933 | + role: "assistant", | |
| 934 | + text: part.text, | |
| 935 | + attachments: [], | |
| 936 | + streaming: !isCompleted, | |
| 937 | + createdAt: startedAt, | |
| 938 | + updatedAt: emittedAt, | |
| 939 | + }; | |
| 940 | + state.messages.set(String(message.id), message); | |
| 941 | + yield* emitProviderEvent({ | |
| 942 | + type: "message.updated", | |
| 943 | + provider: OPENCODE_PROVIDER, | |
| 944 | + message, | |
| 945 | + }); | |
| 946 | + yield* emitProviderEvent({ | |
| 947 | + type: "turn_item.updated", | |
| 948 | + provider: OPENCODE_PROVIDER, | |
| 949 | + turnItem: { | |
| 950 | + id: turnItemId, | |
| 951 | + threadId: turn.threadId, | |
| 952 | + runId: turn.runId, | |
| 953 | + nodeId, | |
| 954 | + providerThreadId: state.providerThread.id, | |
| 955 | + providerTurnId: turn.providerTurnId, | |
| 956 | + nativeItemRef, | |
| 957 | + parentItemId: null, | |
| 958 | + ordinal, | |
| 959 | + status: isCompleted ? "completed" : "running", | |
| 960 | + title: null, | |
| 961 | + startedAt, | |
| 962 | + completedAt, | |
| 963 | + updatedAt: emittedAt, | |
| 964 | + type: "assistant_message", | |
| 965 | + messageId, | |
| 966 | + text: part.text, | |
| 967 | + streaming: !isCompleted, | |
| 968 | + }, | |
| 969 | + }); | |
| 970 | + return; | |
| 971 | + } | |
| 972 | + yield* emitProviderEvent({ | |
| 973 | + type: "turn_item.updated", | |
| 974 | + provider: OPENCODE_PROVIDER, | |
| 975 | + turnItem: { | |
| 976 | + id: turnItemId, | |
| 977 | + threadId: turn.threadId, | |
| 978 | + runId: turn.runId, | |
| 979 | + nodeId, | |
| 980 | + providerThreadId: state.providerThread.id, | |
| 981 | + providerTurnId: turn.providerTurnId, | |
| 982 | + nativeItemRef, | |
| 983 | + parentItemId: null, | |
| 984 | + ordinal, | |
| 985 | + status: isCompleted ? "completed" : "running", | |
| 986 | + title: null, | |
| 987 | + startedAt, | |
| 988 | + completedAt, | |
| 989 | + updatedAt: emittedAt, | |
| 990 | + type: "reasoning", | |
| 991 | + text: part.text, | |
| 992 | + streaming: !isCompleted, | |
| 993 | + }, | |
| 994 | + }); | |
| 995 | + }); | |
| 996 | + | |
| 997 | + const emitSubagent = Effect.fnUntraced(function* ( | |
| 998 | + state: OpenCodeThreadState, | |
| 999 | + turn: ActiveOpenCodeTurn, | |
| 1000 | + part: ToolPart, | |
| 1001 | + ) { | |
| 1002 | + const now = yield* DateTime.now; | |
| 1003 | + const nativeItemRef = providerRef(part.id); | |
| 1004 | + const nodeId = idAllocator.derive.nodeFromProviderItem({ | |
| 1005 | + provider: OPENCODE_PROVIDER, | |
| 1006 | + nativeItemId: part.id, | |
| 1007 | + }); | |
| 1008 | + const turnItemId = idAllocator.derive.turnItemFromProviderItem({ | |
| 1009 | + provider: OPENCODE_PROVIDER, | |
| 1010 | + nativeItemId: part.id, | |
| 1011 | + }); | |
| 1012 | + const input = toolInput(part); | |
| 1013 | + const prompt = recordString(input, "prompt") ?? ""; | |
| 1014 | + const title = toolTitle(part) ?? recordString(input, "description") ?? null; | |
| 1015 | + let context = subagentsByNativeItemId.get(part.id); | |
| 1016 | + if (context === undefined) { | |
| 1017 | + context = { | |
| 1018 | + nativeItemId: part.id, | |
| 1019 | + nodeId, | |
| 1020 | + parentTurn: turn, | |
| 1021 | + prompt, | |
| 1022 | + title, | |
| 1023 | + startedAt: toolStartedAt(part, now), | |
| 1024 | + childSessionId: null, | |
| 1025 | + childThreadId: null, | |
| 1026 | + childProviderThreadId: null, | |
| 1027 | + model: null, | |
| 1028 | + result: null, | |
| 1029 | + }; | |
| 1030 | + subagentsByNativeItemId.set(part.id, context); | |
| 1031 | + } | |
| 1032 | + context.model = toolModel(part) ?? context.model; | |
| 1033 | + const childSessionId = taskSessionId(part); | |
| 1034 | + if (childSessionId !== null && context.childSessionId === null) { | |
| 1035 | + context.childSessionId = childSessionId; | |
| 1036 | + context.childThreadId = idAllocator.derive.threadFromProviderThread({ | |
| 1037 | + provider: OPENCODE_PROVIDER, | |
| 1038 | + nativeThreadId: childSessionId, | |
| 1039 | + }); | |
| 1040 | + context.childProviderThreadId = idAllocator.derive.providerThread({ | |
| 1041 | + provider: OPENCODE_PROVIDER, | |
| 1042 | + nativeThreadId: childSessionId, | |
| 1043 | + }); | |
| 1044 | + subagentsByChildSessionId.set(childSessionId, context); | |
| 1045 | + const childModelSelection: ModelSelection = { | |
| 1046 | + instanceId: options.instanceId, | |
| 1047 | + model: context.model ?? turn.modelSelection.model, | |
| 1048 | + }; | |
| 1049 | + const childThread = makeSubagentChildThread({ | |
| 1050 | + parentThread: turn.appThread, | |
| 1051 | + childThreadId: context.childThreadId, | |
| 1052 | + parentNodeId: nodeId, | |
| 1053 | + activeProviderThreadId: context.childProviderThreadId, | |
| 1054 | + providerInstanceId: options.instanceId, | |
| 1055 | + modelSelection: childModelSelection, | |
| 1056 | + title: subagentThreadTitle({ | |
| 1057 | + parentTitle: turn.appThread.title, | |
| 1058 | + title, | |
| 1059 | + prompt, | |
| 1060 | + ordinal: itemOrdinal(turn, part.id), | |
| 1061 | + }), | |
| 1062 | + now, | |
| 1063 | + }); | |
| 1064 | + const childProviderThread: OrchestrationV2ProviderThread = { | |
| 1065 | + id: context.childProviderThreadId, | |
| 1066 | + provider: OPENCODE_PROVIDER, | |
| 1067 | + providerSessionId: inputProviderSessionId, | |
| 1068 | + appThreadId: context.childThreadId, | |
| 1069 | + ownerNodeId: nodeId, | |
| 1070 | + nativeThreadRef: providerRef(childSessionId), | |
| 1071 | + nativeConversationHeadRef: null, | |
| 1072 | + status: "active", | |
| 1073 | + firstRunOrdinal: null, | |
| 1074 | + lastRunOrdinal: null, | |
| 1075 | + handoffIds: [], | |
| 1076 | + forkedFrom: null, | |
| 1077 | + createdAt: now, | |
| 1078 | + updatedAt: now, | |
| 1079 | + }; | |
| 1080 | + const childSessionResponse = yield* sdkCall( | |
| 1081 | + "session.get", | |
| 1082 | + { sessionID: childSessionId }, | |
| 1083 | + () => client.session.get({ sessionID: childSessionId }), | |
| 1084 | + ); | |
| 1085 | + const nativeChildSession = unwrapData("session.get", childSessionResponse); | |
| 1086 | + const childPermission = openCodeChildPermissionRules( | |
| 1087 | + turn.runtimePolicy, | |
| 1088 | + nativeChildSession.permission ?? [], | |
| 1089 | + ); | |
| 1090 | + yield* sdkCall( | |
| 1091 | + "session.update", | |
| 1092 | + { sessionID: childSessionId, permission: childPermission }, | |
| 1093 | + () => | |
| 1094 | + client.session.update({ | |
| 1095 | + sessionID: childSessionId, | |
| 1096 | + permission: childPermission, | |
| 1097 | + }), | |
| 1098 | + ); | |
| 1099 | + threads.set(childSessionId, { | |
| 1100 | + nativeSessionId: childSessionId, | |
| 1101 | + providerThread: childProviderThread, | |
| 1102 | + appThread: childThread, | |
| 1103 | + activeTurn: null, | |
| 1104 | + providerTurns: new Map(), | |
| 1105 | + messages: new Map(), | |
| 1106 | + runtimeRequests: new Map(), | |
| 1107 | + messageRoles: new Map(), | |
| 1108 | + userMessageIds: [], | |
| 1109 | + parentSubagent: context, | |
| 1110 | + nextChildTurnOrdinal: 1, | |
| 1111 | + }); | |
| 1112 | + yield* emitProviderEvent({ | |
| 1113 | + type: "app_thread.created", | |
| 1114 | + provider: OPENCODE_PROVIDER, | |
| 1115 | + appThread: childThread, | |
| 1116 | + }); | |
| 1117 | + yield* emitProviderEvent({ | |
| 1118 | + type: "provider_thread.updated", | |
| 1119 | + provider: OPENCODE_PROVIDER, | |
| 1120 | + providerThread: childProviderThread, | |
| 1121 | + }); | |
| 1122 | + } | |
| 1123 | + const output = toolOutput(part); | |
| 1124 | + if (part.state.status === "completed" && output !== undefined) context.result = output; | |
| 1125 | + const status = toolStatus(part); | |
| 1126 | + const completedAt = toolCompletedAt(part, now); | |
| 1127 | + const subagentStatus: OrchestrationV2Subagent["status"] = | |
| 1128 | + status.item === "failed" | |
| 1129 | + ? "failed" | |
| 1130 | + : status.item === "completed" | |
| 1131 | + ? "completed" | |
| 1132 | + : status.item === "pending" | |
| 1133 | + ? "pending" | |
| 1134 | + : "running"; | |
| 1135 | + const subagent: OrchestrationV2Subagent = { | |
| 1136 | + id: nodeId, | |
| 1137 | + threadId: turn.threadId, | |
| 1138 | + runId: turn.runId, | |
| 1139 | + parentNodeId: turn.rootNodeId, | |
| 1140 | + origin: "provider_native", | |
| 1141 | + createdBy: "agent", | |
| 1142 | + provider: OPENCODE_PROVIDER, | |
| 1143 | + providerThreadId: context.childProviderThreadId, | |
| 1144 | + childThreadId: context.childThreadId, | |
| 1145 | + nativeTaskRef: nativeItemRef, | |
| 1146 | + prompt, | |
| 1147 | + title, | |
| 1148 | + model: context.model, | |
| 1149 | + status: subagentStatus, | |
| 1150 | + result: context.result, | |
| 1151 | + startedAt: context.startedAt, | |
| 1152 | + completedAt, | |
| 1153 | + updatedAt: now, | |
| 1154 | + }; | |
| 1155 | + yield* emitProviderEvent({ | |
| 1156 | + type: "node.updated", | |
| 1157 | + provider: OPENCODE_PROVIDER, | |
| 1158 | + node: { | |
| 1159 | + id: nodeId, | |
| 1160 | + threadId: turn.threadId, | |
| 1161 | + runId: turn.runId, | |
| 1162 | + parentNodeId: turn.rootNodeId, | |
| 1163 | + rootNodeId: turn.rootNodeId, | |
| 1164 | + kind: "subagent", | |
| 1165 | + status: status.node, | |
| 1166 | + countsForRun: false, | |
| 1167 | + providerThreadId: context.childProviderThreadId ?? state.providerThread.id, | |
| 1168 | + providerTurnId: turn.providerTurnId, | |
| 1169 | + nativeItemRef, | |
| 1170 | + runtimeRequestId: null, | |
| 1171 | + checkpointScopeId: null, | |
| 1172 | + startedAt: context.startedAt, | |
| 1173 | + completedAt, | |
| 1174 | + }, | |
| 1175 | + }); | |
| 1176 | + yield* emitProviderEvent({ | |
| 1177 | + type: "subagent.updated", | |
| 1178 | + provider: OPENCODE_PROVIDER, | |
| 1179 | + subagent, | |
| 1180 | + }); | |
| 1181 | + yield* emitProviderEvent({ | |
| 1182 | + type: "turn_item.updated", | |
| 1183 | + provider: OPENCODE_PROVIDER, | |
| 1184 | + turnItem: { | |
| 1185 | + id: turnItemId, | |
| 1186 | + threadId: turn.threadId, | |
| 1187 | + runId: turn.runId, | |
| 1188 | + nodeId, | |
| 1189 | + providerThreadId: state.providerThread.id, | |
| 1190 | + providerTurnId: turn.providerTurnId, | |
| 1191 | + nativeItemRef, | |
| 1192 | + parentItemId: null, | |
| 1193 | + ordinal: itemOrdinal(turn, part.id), | |
| 1194 | + status: status.item, | |
| 1195 | + title, | |
| 1196 | + startedAt: context.startedAt, | |
| 1197 | + completedAt, | |
| 1198 | + updatedAt: now, | |
| 1199 | + type: "subagent", | |
| 1200 | + subagentId: nodeId, | |
| 1201 | + origin: "provider_native", | |
| 1202 | + provider: OPENCODE_PROVIDER, | |
| 1203 | + childThreadId: context.childThreadId, | |
| 1204 | + prompt, | |
| 1205 | + result: context.result, | |
| 1206 | + }, | |
| 1207 | + }); | |
| 1208 | + }); | |
| 1209 | + | |
| 1210 | + const emitToolPart = Effect.fnUntraced(function* ( | |
| 1211 | + state: OpenCodeThreadState, | |
| 1212 | + turn: ActiveOpenCodeTurn, | |
| 1213 | + part: ToolPart, | |
| 1214 | + ) { | |
| 1215 | + const normalizedTool = part.tool.toLowerCase(); | |
| 1216 | + if (normalizedTool === "task") { | |
| 1217 | + yield* emitSubagent(state, turn, part); | |
| 1218 | + return; | |
| 1219 | + } | |
| 1220 | + // question.asked carries the respondable semantic item. Projecting | |
| 1221 | + // the implementation tool as well would duplicate it in the UI. | |
| 1222 | + if (normalizedTool === "question") return; | |
| 1223 | + const now = yield* DateTime.now; | |
| 1224 | + const status = toolStatus(part); | |
| 1225 | + const startedAt = toolStartedAt(part, now); | |
| 1226 | + const completedAt = toolCompletedAt(part, now); | |
| 1227 | + const nativeItemRef = providerRef(part.id); | |
| 1228 | + const nodeId = idAllocator.derive.nodeFromProviderItem({ | |
| 1229 | + provider: OPENCODE_PROVIDER, | |
| 1230 | + nativeItemId: part.id, | |
| 1231 | + }); | |
| 1232 | + const turnItemId = idAllocator.derive.turnItemFromProviderItem({ | |
| 1233 | + provider: OPENCODE_PROVIDER, | |
| 1234 | + nativeItemId: part.id, | |
| 1235 | + }); | |
| 1236 | + const base = { | |
| 1237 | + id: turnItemId, | |
| 1238 | + threadId: turn.threadId, | |
| 1239 | + runId: turn.runId, | |
| 1240 | + nodeId, | |
| 1241 | + providerThreadId: state.providerThread.id, | |
| 1242 | + providerTurnId: turn.providerTurnId, | |
| 1243 | + nativeItemRef, | |
| 1244 | + parentItemId: null, | |
| 1245 | + ordinal: itemOrdinal(turn, part.id), | |
| 1246 | + status: status.item, | |
| 1247 | + title: toolTitle(part), | |
| 1248 | + startedAt, | |
| 1249 | + completedAt, | |
| 1250 | + updatedAt: now, | |
| 1251 | + } satisfies Pick< | |
| 1252 | + OrchestrationV2TurnItem, | |
| 1253 | + | "id" | |
| 1254 | + | "threadId" | |
| 1255 | + | "runId" | |
| 1256 | + | "nodeId" | |
| 1257 | + | "providerThreadId" | |
| 1258 | + | "providerTurnId" | |
| 1259 | + | "nativeItemRef" | |
| 1260 | + | "parentItemId" | |
| 1261 | + | "ordinal" | |
| 1262 | + | "status" | |
| 1263 | + | "title" | |
| 1264 | + | "startedAt" | |
| 1265 | + | "completedAt" | |
| 1266 | + | "updatedAt" | |
| 1267 | + >; | |
| 1268 | + const input = toolInput(part); | |
| 1269 | + const output = toolOutput(part); | |
| 1270 | + const projectionKind = openCodeToolProjectionKind(part.tool); | |
| 1271 | + let turnItem: OrchestrationV2TurnItem; | |
| 1272 | + if (projectionKind === "command_execution") { | |
| 1273 | + turnItem = { | |
| 1274 | + ...base, | |
| 1275 | + type: "command_execution", | |
| 1276 | + input: recordString(input, "command", "cmd") ?? stableJson(input), | |
| 1277 | + ...(output === undefined ? {} : { output }), | |
| 1278 | + ...(recordNumber( | |
| 1279 | + part.state.status === "completed" ? part.state.metadata : undefined, | |
| 1280 | + "exit", | |
| 1281 | + "exitCode", | |
| 1282 | + ) === undefined | |
| 1283 | + ? {} | |
| 1284 | + : { | |
| 1285 | + exitCode: recordNumber( | |
| 1286 | + part.state.status === "completed" ? part.state.metadata : undefined, | |
| 1287 | + "exit", | |
| 1288 | + "exitCode", | |
| 1289 | + )!, | |
| 1290 | + }), | |
| 1291 | + }; | |
| 1292 | + } else if (projectionKind === "file_change") { | |
| 1293 | + turnItem = { | |
| 1294 | + ...base, | |
| 1295 | + type: "file_change", | |
| 1296 | + fileName: recordString(input, "filePath", "path", "file") ?? part.tool, | |
| 1297 | + ...(recordString(input, "oldString", "oldText") === undefined | |
| 1298 | + ? {} | |
| 1299 | + : { oldStr: recordString(input, "oldString", "oldText")! }), | |
| 1300 | + ...(recordString(input, "newString", "content", "newText") === undefined | |
| 1301 | + ? {} | |
| 1302 | + : { newStr: recordString(input, "newString", "content", "newText")! }), | |
| 1303 | + ...(recordString( | |
| 1304 | + part.state.status === "completed" ? part.state.metadata : undefined, | |
| 1305 | + "diff", | |
| 1306 | + "patch", | |
| 1307 | + ) === undefined | |
| 1308 | + ? {} | |
| 1309 | + : { | |
| 1310 | + diffStr: recordString( | |
| 1311 | + part.state.status === "completed" ? part.state.metadata : undefined, | |
| 1312 | + "diff", | |
| 1313 | + "patch", | |
| 1314 | + )!, | |
| 1315 | + }), | |
| 1316 | + }; | |
| 1317 | + } else if (projectionKind === "file_search") { | |
| 1318 | + turnItem = { | |
| 1319 | + ...base, | |
| 1320 | + type: "file_search", | |
| 1321 | + ...(recordString(input, "pattern", "query", "path", "filePath") === undefined | |
| 1322 | + ? {} | |
| 1323 | + : { pattern: recordString(input, "pattern", "query", "path", "filePath")! }), | |
| 1324 | + }; | |
| 1325 | + } else if (projectionKind === "web_search") { | |
| 1326 | + const pattern = recordString(input, "query", "url", "pattern"); | |
| 1327 | + turnItem = { | |
| 1328 | + ...base, | |
| 1329 | + type: "web_search", | |
| 1330 | + ...(pattern === undefined ? {} : { patterns: [pattern] }), | |
| 1331 | + }; | |
| 1332 | + } else { | |
| 1333 | + turnItem = { | |
| 1334 | + ...base, | |
| 1335 | + type: "dynamic_tool", | |
| 1336 | + toolName: part.tool, | |
| 1337 | + input, | |
| 1338 | + ...(output === undefined ? {} : { output }), | |
| 1339 | + }; | |
| 1340 | + } | |
| 1341 | + yield* emitProviderEvent({ | |
| 1342 | + type: "node.updated", | |
| 1343 | + provider: OPENCODE_PROVIDER, | |
| 1344 | + node: { | |
| 1345 | + id: nodeId, | |
| 1346 | + threadId: turn.threadId, | |
| 1347 | + runId: turn.runId, | |
| 1348 | + parentNodeId: turn.rootNodeId, | |
| 1349 | + rootNodeId: turn.rootNodeId, | |
| 1350 | + kind: "tool_call", | |
| 1351 | + status: status.node, | |
| 1352 | + countsForRun: false, | |
| 1353 | + providerThreadId: state.providerThread.id, | |
| 1354 | + providerTurnId: turn.providerTurnId, | |
| 1355 | + nativeItemRef, | |
| 1356 | + runtimeRequestId: null, | |
| 1357 | + checkpointScopeId: null, | |
| 1358 | + startedAt, | |
| 1359 | + completedAt, | |
| 1360 | + }, | |
| 1361 | + }); | |
| 1362 | + yield* emitProviderEvent({ | |
| 1363 | + type: "turn_item.updated", | |
| 1364 | + provider: OPENCODE_PROVIDER, | |
| 1365 | + turnItem, | |
| 1366 | + }); | |
| 1367 | + }); | |
| 1368 | + | |
| 1369 | + const emitTodo = Effect.fnUntraced(function* ( | |
| 1370 | + state: OpenCodeThreadState, | |
| 1371 | + turn: ActiveOpenCodeTurn, | |
| 1372 | + todos: ReadonlyArray<OpenCodeTodo>, | |
| 1373 | + ) { | |
| 1374 | + const now = yield* DateTime.now; | |
| 1375 | + if (turn.planId === null) { | |
| 1376 | + turn.planId = yield* idAllocator.allocate.plan({ | |
| 1377 | + threadId: turn.threadId, | |
| 1378 | + ...(turn.runId === null ? {} : { runId: turn.runId }), | |
| 1379 | + provider: OPENCODE_PROVIDER, | |
| 1380 | + }); | |
| 1381 | + } | |
| 1382 | + const planId = turn.planId; | |
| 1383 | + const nativeItemId = `${state.nativeSessionId}:todo:${turn.providerTurnId}`; | |
| 1384 | + const nodeId = idAllocator.derive.nodeFromProviderItem({ | |
| 1385 | + provider: OPENCODE_PROVIDER, | |
| 1386 | + nativeItemId, | |
| 1387 | + }); | |
| 1388 | + const turnItemId = idAllocator.derive.turnItemFromProviderItem({ | |
| 1389 | + provider: OPENCODE_PROVIDER, | |
| 1390 | + nativeItemId, | |
| 1391 | + }); | |
| 1392 | + const steps: Array<OrchestrationV2PlanStep> = todos.map((todo, index) => ({ | |
| 1393 | + id: `${nativeItemId}:${index + 1}`, | |
| 1394 | + text: todo.content.trim() || `Todo ${index + 1}`, | |
| 1395 | + status: | |
| 1396 | + todo.status === "completed" | |
| 1397 | + ? "completed" | |
| 1398 | + : todo.status === "in_progress" | |
| 1399 | + ? "running" | |
| 1400 | + : "pending", | |
| 1401 | + })); | |
| 1402 | + const completed = steps.length > 0 && steps.every((step) => step.status === "completed"); | |
| 1403 | + yield* emitProviderEvent({ | |
| 1404 | + type: "node.updated", | |
| 1405 | + provider: OPENCODE_PROVIDER, | |
| 1406 | + node: { | |
| 1407 | + id: nodeId, | |
| 1408 | + threadId: turn.threadId, | |
| 1409 | + runId: turn.runId, | |
| 1410 | + parentNodeId: turn.rootNodeId, | |
| 1411 | + rootNodeId: turn.rootNodeId, | |
| 1412 | + kind: "todo_list", | |
| 1413 | + status: completed ? "completed" : "running", | |
| 1414 | + countsForRun: false, | |
| 1415 | + providerThreadId: state.providerThread.id, | |
| 1416 | + providerTurnId: turn.providerTurnId, | |
| 1417 | + nativeItemRef: providerRef(nativeItemId, "weak"), | |
| 1418 | + runtimeRequestId: null, | |
| 1419 | + checkpointScopeId: null, | |
| 1420 | + startedAt: turn.startedAt, | |
| 1421 | + completedAt: completed ? now : null, | |
| 1422 | + }, | |
| 1423 | + }); | |
| 1424 | + yield* emitProviderEvent({ | |
| 1425 | + type: "plan.updated", | |
| 1426 | + provider: OPENCODE_PROVIDER, | |
| 1427 | + plan: { | |
| 1428 | + id: planId, | |
| 1429 | + threadId: turn.threadId, | |
| 1430 | + runId: turn.runId, | |
| 1431 | + nodeId, | |
| 1432 | + status: completed ? "completed" : "active", | |
| 1433 | + kind: "todo_list", | |
| 1434 | + steps, | |
| 1435 | + }, | |
| 1436 | + }); | |
| 1437 | + yield* emitProviderEvent({ | |
| 1438 | + type: "turn_item.updated", | |
| 1439 | + provider: OPENCODE_PROVIDER, | |
| 1440 | + turnItem: { | |
| 1441 | + id: turnItemId, | |
| 1442 | + threadId: turn.threadId, | |
| 1443 | + runId: turn.runId, | |
| 1444 | + nodeId, | |
| 1445 | + providerThreadId: state.providerThread.id, | |
| 1446 | + providerTurnId: turn.providerTurnId, | |
| 1447 | + nativeItemRef: providerRef(nativeItemId, "weak"), | |
| 1448 | + parentItemId: null, | |
| 1449 | + ordinal: itemOrdinal(turn, nativeItemId), | |
| 1450 | + status: completed ? "completed" : "running", | |
| 1451 | + title: "Todo list", | |
| 1452 | + startedAt: turn.startedAt, | |
| 1453 | + completedAt: completed ? now : null, | |
| 1454 | + updatedAt: now, | |
| 1455 | + type: "todo_list", | |
| 1456 | + planId, | |
| 1457 | + steps, | |
| 1458 | + }, | |
| 1459 | + }); | |
| 1460 | + }); | |
| 1461 | + | |
| 1462 | + const requestQuestions = (request: QuestionRequest) => | |
| 1463 | + request.questions.map((question, index) => ({ | |
| 1464 | + id: openCodeQuestionId(index, question), | |
| 1465 | + header: question.header.trim() || `Question ${index + 1}`, | |
| 1466 | + question: question.question.trim() || question.header.trim() || `Question ${index + 1}`, | |
| 1467 | + options: question.options.map((option) => ({ | |
| 1468 | + label: option.label.trim() || "Option", | |
| 1469 | + description: option.description.trim() || option.label.trim() || "Option", | |
| 1470 | + })), | |
| 1471 | + })); | |
| 1472 | + | |
| 1473 | + const runtimeRequestTurnItem = ( | |
| 1474 | + pending: PendingOpenCodeRequest, | |
| 1475 | + status: OrchestrationV2TurnItem["status"], | |
| 1476 | + completedAt: DateTime.Utc | null, | |
| 1477 | + updatedAt: DateTime.Utc, | |
| 1478 | + ): OrchestrationV2TurnItem => { | |
| 1479 | + const base = { | |
| 1480 | + id: pending.turnItemId, | |
| 1481 | + threadId: pending.turn.threadId, | |
| 1482 | + runId: pending.turn.runId, | |
| 1483 | + nodeId: pending.nodeId, | |
| 1484 | + providerThreadId: pending.state.providerThread.id, | |
| 1485 | + providerTurnId: pending.turn.providerTurnId, | |
| 1486 | + nativeItemRef: providerRef(pending.nativeRequestId), | |
| 1487 | + parentItemId: null, | |
| 1488 | + ordinal: itemOrdinal(pending.turn, pending.nativeRequestId), | |
| 1489 | + status, | |
| 1490 | + startedAt: pending.createdAt, | |
| 1491 | + completedAt, | |
| 1492 | + updatedAt, | |
| 1493 | + }; | |
| 1494 | + if (pending.question !== undefined) { | |
| 1495 | + return { | |
| 1496 | + ...base, | |
| 1497 | + title: "User input", | |
| 1498 | + type: "user_input_request", | |
| 1499 | + requestId: pending.requestId, | |
| 1500 | + questions: requestQuestions(pending.question), | |
| 1501 | + }; | |
| 1502 | + } | |
| 1503 | + const permission = pending.permission; | |
| 1504 | + if (permission === undefined) { | |
| 1505 | + throw protocolError(`OpenCode request ${pending.requestId} has no native payload`); | |
| 1506 | + } | |
| 1507 | + return { | |
| 1508 | + ...base, | |
| 1509 | + title: permission.permission, | |
| 1510 | + type: "approval_request", | |
| 1511 | + requestId: pending.requestId, | |
| 1512 | + requestKind: pending.requestKind === "user_input" ? "command" : pending.requestKind, | |
| 1513 | + prompt: | |
| 1514 | + permission.patterns.length === 0 | |
| 1515 | + ? permission.permission | |
| 1516 | + : permission.patterns.join("\n"), | |
| 1517 | + }; | |
| 1518 | + }; | |
| 1519 | + | |
| 1520 | + const emitRuntimeRequest = Effect.fnUntraced(function* ( | |
| 1521 | + state: OpenCodeThreadState, | |
| 1522 | + turn: ActiveOpenCodeTurn, | |
| 1523 | + nativeRequestId: string, | |
| 1524 | + request: | |
| 1525 | + | { readonly type: "permission"; readonly value: PermissionRequest } | |
| 1526 | + | { readonly type: "question"; readonly value: QuestionRequest }, | |
| 1527 | + ) { | |
| 1528 | + if (pendingRequestsByNativeId.has(nativeRequestId)) return; | |
| 1529 | + const now = yield* DateTime.now; | |
| 1530 | + const requestId = yield* idAllocator.allocate.runtimeRequest({ | |
| 1531 | + provider: OPENCODE_PROVIDER, | |
| 1532 | + providerTurnId: turn.providerTurnId, | |
| 1533 | + nativeRequestId, | |
| 1534 | + }); | |
| 1535 | + const nodeId = idAllocator.derive.approvalNode({ requestId }); | |
| 1536 | + const turnItemId = idAllocator.derive.approvalTurnItem({ requestId }); | |
| 1537 | + const permissionToolName = | |
| 1538 | + request.type === "permission" && request.value.tool !== undefined | |
| 1539 | + ? Array.from(turn.parts.values()).find( | |
| 1540 | + (part): part is ToolPart => | |
| 1541 | + part.type === "tool" && part.callID === request.value.tool?.callID, | |
| 1542 | + )?.tool | |
| 1543 | + : undefined; | |
| 1544 | + const permissionRequestKind = | |
| 1545 | + request.type === "permission" | |
| 1546 | + ? openCodePermissionRequestKind(request.value.permission, permissionToolName) | |
| 1547 | + : undefined; | |
| 1548 | + const requestKind: OrchestrationV2RuntimeRequest["kind"] = | |
| 1549 | + permissionRequestKind ?? "user_input"; | |
| 1550 | + const pending: PendingOpenCodeRequest = { | |
| 1551 | + requestId, | |
| 1552 | + nativeRequestId, | |
| 1553 | + turn, | |
| 1554 | + state, | |
| 1555 | + nodeId, | |
| 1556 | + turnItemId, | |
| 1557 | + requestKind, | |
| 1558 | + createdAt: now, | |
| 1559 | + ...(request.type === "permission" | |
| 1560 | + ? { permission: request.value } | |
| 1561 | + : { question: request.value }), | |
| 1562 | + }; | |
| 1563 | + pendingRequests.set(String(requestId), pending); | |
| 1564 | + pendingRequestsByNativeId.set(nativeRequestId, pending); | |
| 1565 | + const runtimeRequest: OrchestrationV2RuntimeRequest = { | |
| 1566 | + id: requestId, | |
| 1567 | + nodeId, | |
| 1568 | + providerTurnId: turn.providerTurnId, | |
| 1569 | + nativeRequestRef: providerRef(nativeRequestId), | |
| 1570 | + kind: requestKind, | |
| 1571 | + status: "pending", | |
| 1572 | + responseCapability: { | |
| 1573 | + type: "live", | |
| 1574 | + providerSessionId: inputProviderSessionId, | |
| 1575 | + }, | |
| 1576 | + createdAt: now, | |
| 1577 | + resolvedAt: null, | |
| 1578 | + }; | |
| 1579 | + state.runtimeRequests.set(String(requestId), runtimeRequest); | |
| 1580 | + yield* emitProviderEvent({ | |
| 1581 | + type: "node.updated", | |
| 1582 | + provider: OPENCODE_PROVIDER, | |
| 1583 | + node: { | |
| 1584 | + id: nodeId, | |
| 1585 | + threadId: turn.threadId, | |
| 1586 | + runId: turn.runId, | |
| 1587 | + parentNodeId: turn.rootNodeId, | |
| 1588 | + rootNodeId: turn.rootNodeId, | |
| 1589 | + kind: request.type === "question" ? "user_input_request" : "approval_request", | |
| 1590 | + status: "waiting", | |
| 1591 | + countsForRun: false, | |
| 1592 | + providerThreadId: state.providerThread.id, | |
| 1593 | + providerTurnId: turn.providerTurnId, | |
| 1594 | + nativeItemRef: providerRef(nativeRequestId), | |
| 1595 | + runtimeRequestId: requestId, | |
| 1596 | + checkpointScopeId: null, | |
| 1597 | + startedAt: now, | |
| 1598 | + completedAt: null, | |
| 1599 | + }, | |
| 1600 | + }); | |
| 1601 | + yield* emitProviderEvent({ | |
| 1602 | + type: "runtime_request.updated", | |
| 1603 | + provider: OPENCODE_PROVIDER, | |
| 1604 | + threadId: turn.threadId, | |
| 1605 | + runtimeRequest, | |
| 1606 | + }); | |
| 1607 | + yield* emitProviderEvent({ | |
| 1608 | + type: "turn_item.updated", | |
| 1609 | + provider: OPENCODE_PROVIDER, | |
| 1610 | + turnItem: runtimeRequestTurnItem(pending, "waiting", null, now), | |
| 1611 | + }); | |
| 1612 | + yield* updateProviderSession("waiting", null); | |
| 1613 | + }); | |
| 1614 | + | |
| 1615 | + const resolveRuntimeRequest = Effect.fnUntraced(function* ( | |
| 1616 | + nativeRequestId: string, | |
| 1617 | + status: "resolved" | "cancelled", | |
| 1618 | + ) { | |
| 1619 | + const pending = pendingRequestsByNativeId.get(nativeRequestId); | |
| 1620 | + if (pending === undefined) return; | |
| 1621 | + const now = yield* DateTime.now; | |
| 1622 | + const current = pending.state.runtimeRequests.get(String(pending.requestId)); | |
| 1623 | + if (current !== undefined) { | |
| 1624 | + const resolved: OrchestrationV2RuntimeRequest = { | |
| 1625 | + ...current, | |
| 1626 | + status, | |
| 1627 | + resolvedAt: now, | |
| 1628 | + }; | |
| 1629 | + pending.state.runtimeRequests.set(String(pending.requestId), resolved); | |
| 1630 | + yield* emitProviderEvent({ | |
| 1631 | + type: "runtime_request.updated", | |
| 1632 | + provider: OPENCODE_PROVIDER, | |
| 1633 | + threadId: pending.turn.threadId, | |
| 1634 | + runtimeRequest: resolved, | |
| 1635 | + }); | |
| 1636 | + } | |
| 1637 | + yield* emitProviderEvent({ | |
| 1638 | + type: "node.updated", | |
| 1639 | + provider: OPENCODE_PROVIDER, | |
| 1640 | + node: { | |
| 1641 | + id: pending.nodeId, | |
| 1642 | + threadId: pending.turn.threadId, | |
| 1643 | + runId: pending.turn.runId, | |
| 1644 | + parentNodeId: pending.turn.rootNodeId, | |
| 1645 | + rootNodeId: pending.turn.rootNodeId, | |
| 1646 | + kind: pending.question === undefined ? "approval_request" : "user_input_request", | |
| 1647 | + status: status === "resolved" ? "completed" : "cancelled", | |
| 1648 | + countsForRun: false, | |
| 1649 | + providerThreadId: pending.state.providerThread.id, | |
| 1650 | + providerTurnId: pending.turn.providerTurnId, | |
| 1651 | + nativeItemRef: providerRef(nativeRequestId), | |
| 1652 | + runtimeRequestId: pending.requestId, | |
| 1653 | + checkpointScopeId: null, | |
| 1654 | + startedAt: pending.createdAt, | |
| 1655 | + completedAt: now, | |
| 1656 | + }, | |
| 1657 | + }); | |
| 1658 | + yield* emitProviderEvent({ | |
| 1659 | + type: "turn_item.updated", | |
| 1660 | + provider: OPENCODE_PROVIDER, | |
| 1661 | + turnItem: runtimeRequestTurnItem( | |
| 1662 | + pending, | |
| 1663 | + status === "resolved" ? "completed" : "cancelled", | |
| 1664 | + now, | |
| 1665 | + now, | |
| 1666 | + ), | |
| 1667 | + }); | |
| 1668 | + pendingRequests.delete(String(pending.requestId)); | |
| 1669 | + pendingRequestsByNativeId.delete(nativeRequestId); | |
| 1670 | + const hasOtherPending = Array.from(pendingRequests.values()).some( | |
| 1671 | + (candidate) => candidate.turn.isRoot, | |
| 1672 | + ); | |
| 1673 | + if (!hasOtherPending) yield* updateProviderSession("running", null); | |
| 1674 | + }); | |
| 1675 | + | |
| 1676 | + const finalizeTurn = Effect.fnUntraced(function* ( | |
| 1677 | + state: OpenCodeThreadState, | |
| 1678 | + turn: ActiveOpenCodeTurn, | |
| 1679 | + status: TerminalTurnStatus, | |
| 1680 | + ) { | |
| 1681 | + if (turn.finalized) return; | |
| 1682 | + turn.finalized = true; | |
| 1683 | + const completedAt = yield* DateTime.now; | |
| 1684 | + for (const part of turn.parts.values()) { | |
| 1685 | + if (part.type === "text" || part.type === "reasoning") { | |
| 1686 | + yield* emitTextPart(state, turn, part, true); | |
| 1687 | + } | |
| 1688 | + } | |
| 1689 | + for (const pending of Array.from(pendingRequests.values())) { | |
| 1690 | + if (pending.turn.providerTurnId === turn.providerTurnId) { | |
| 1691 | + yield* resolveRuntimeRequest(pending.nativeRequestId, "cancelled"); | |
| 1692 | + } | |
| 1693 | + } | |
| 1694 | + yield* emitProviderTurn(state, turn, status, completedAt); | |
| 1695 | + yield* updateProviderThread(state, { | |
| 1696 | + status: status === "failed" ? "error" : "idle", | |
| 1697 | + nativeConversationHeadRef: | |
| 1698 | + turn.nativeUserMessageId === null | |
| 1699 | + ? state.providerThread.nativeConversationHeadRef | |
| 1700 | + : providerRef(turn.nativeUserMessageId, "weak"), | |
| 1701 | + }); | |
| 1702 | + state.activeTurn = null; | |
| 1703 | + if (!turn.isRoot) { | |
| 1704 | + yield* emitProviderEvent({ | |
| 1705 | + type: "node.updated", | |
| 1706 | + provider: OPENCODE_PROVIDER, | |
| 1707 | + node: { | |
| 1708 | + id: turn.rootNodeId, | |
| 1709 | + threadId: turn.threadId, | |
| 1710 | + runId: null, | |
| 1711 | + parentNodeId: null, | |
| 1712 | + rootNodeId: turn.rootNodeId, | |
| 1713 | + kind: "root_turn", | |
| 1714 | + status, | |
| 1715 | + countsForRun: false, | |
| 1716 | + providerThreadId: state.providerThread.id, | |
| 1717 | + providerTurnId: turn.providerTurnId, | |
| 1718 | + nativeItemRef: providerRef(state.nativeSessionId), | |
| 1719 | + runtimeRequestId: null, | |
| 1720 | + checkpointScopeId: null, | |
| 1721 | + startedAt: turn.startedAt, | |
| 1722 | + completedAt, | |
| 1723 | + }, | |
| 1724 | + }); | |
| 1725 | + return; | |
| 1726 | + } | |
| 1727 | + const anotherTurnIsActive = Array.from(threads.values()).some( | |
| 1728 | + (candidate) => candidate.activeTurn?.isRoot === true, | |
| 1729 | + ); | |
| 1730 | + yield* updateProviderSession( | |
| 1731 | + anotherTurnIsActive ? "running" : status === "failed" ? "error" : "ready", | |
| 1732 | + status === "failed" ? sessionEntity.lastError : null, | |
| 1733 | + ); | |
| 1734 | + yield* emitProviderEvent({ | |
| 1735 | + type: "turn.terminal", | |
| 1736 | + provider: OPENCODE_PROVIDER, | |
| 1737 | + providerTurnId: turn.providerTurnId, | |
| 1738 | + status, | |
| 1739 | + }); | |
| 1740 | + }); | |
| 1741 | + | |
| 1742 | + const createChildTurn = Effect.fnUntraced(function* ( | |
| 1743 | + state: OpenCodeThreadState, | |
| 1744 | + message: Extract<OpenCodeMessage, { role: "user" }>, | |
| 1745 | + ) { | |
| 1746 | + if (state.appThread === null || state.parentSubagent === null) return null; | |
| 1747 | + const now = yield* DateTime.now; | |
| 1748 | + const startedAt = dateTimeFromEpoch(message.time.created, now); | |
| 1749 | + const rootNodeId = idAllocator.derive.nodeFromProviderItem({ | |
| 1750 | + provider: OPENCODE_PROVIDER, | |
| 1751 | + nativeItemId: `${state.nativeSessionId}:root:${message.id}`, | |
| 1752 | + }); | |
| 1753 | + const providerTurnId = idAllocator.derive.providerTurn({ | |
| 1754 | + provider: OPENCODE_PROVIDER, | |
| 1755 | + nativeTurnId: message.id, | |
| 1756 | + }); | |
| 1757 | + const providerTurn: OrchestrationV2ProviderTurn = { | |
| 1758 | + id: providerTurnId, | |
| 1759 | + providerThreadId: state.providerThread.id, | |
| 1760 | + nodeId: rootNodeId, | |
| 1761 | + runAttemptId: null, | |
| 1762 | + nativeTurnRef: providerRef(message.id, "weak"), | |
| 1763 | + ordinal: state.nextChildTurnOrdinal++, | |
| 1764 | + status: "running", | |
| 1765 | + startedAt, | |
| 1766 | + completedAt: null, | |
| 1767 | + }; | |
| 1768 | + const turn: ActiveOpenCodeTurn = { | |
| 1769 | + isRoot: false, | |
| 1770 | + threadId: state.appThread.id, | |
| 1771 | + runId: null, | |
| 1772 | + rootNodeId, | |
| 1773 | + appThread: state.appThread, | |
| 1774 | + modelSelection: state.appThread.modelSelection, | |
| 1775 | + runtimePolicy: state.parentSubagent.parentTurn.runtimePolicy, | |
| 1776 | + providerTurnId, | |
| 1777 | + providerTurnOrdinal: providerTurn.ordinal, | |
| 1778 | + runAttemptId: null, | |
| 1779 | + startedAt, | |
| 1780 | + itemOrdinals: new Map(), | |
| 1781 | + parts: new Map(), | |
| 1782 | + partIdsByMessage: new Map(), | |
| 1783 | + providerTurn, | |
| 1784 | + nextItemOrdinal: 1, | |
| 1785 | + nativeUserMessageId: message.id, | |
| 1786 | + interrupted: false, | |
| 1787 | + finalized: false, | |
| 1788 | + planId: null, | |
| 1789 | + }; | |
| 1790 | + state.activeTurn = turn; | |
| 1791 | + state.providerTurns.set(String(providerTurnId), providerTurn); | |
| 1792 | + yield* emitProviderEvent({ | |
| 1793 | + type: "node.updated", | |
| 1794 | + provider: OPENCODE_PROVIDER, | |
| 1795 | + node: { | |
| 1796 | + id: rootNodeId, | |
| 1797 | + threadId: turn.threadId, | |
| 1798 | + runId: null, | |
| 1799 | + parentNodeId: null, | |
| 1800 | + rootNodeId, | |
| 1801 | + kind: "root_turn", | |
| 1802 | + status: "running", | |
| 1803 | + countsForRun: false, | |
| 1804 | + providerThreadId: state.providerThread.id, | |
| 1805 | + providerTurnId, | |
| 1806 | + nativeItemRef: providerRef(message.id, "weak"), | |
| 1807 | + runtimeRequestId: null, | |
| 1808 | + checkpointScopeId: null, | |
| 1809 | + startedAt, | |
| 1810 | + completedAt: null, | |
| 1811 | + }, | |
| 1812 | + }); | |
| 1813 | + yield* emitProviderTurn(state, turn, "running", null); | |
| 1814 | + return turn; | |
| 1815 | + }); | |
| 1816 | + | |
| 1817 | + const projectChildUserPart = Effect.fnUntraced(function* ( | |
| 1818 | + state: OpenCodeThreadState, | |
| 1819 | + turn: ActiveOpenCodeTurn, | |
| 1820 | + part: Extract<OpenCodePart, { type: "text" }>, | |
| 1821 | + ) { | |
| 1822 | + const now = yield* DateTime.now; | |
| 1823 | + const messageId = idAllocator.derive.messageFromProviderItem({ | |
| 1824 | + provider: OPENCODE_PROVIDER, | |
| 1825 | + nativeItemId: part.messageID, | |
| 1826 | + }); | |
| 1827 | + const turnItemId = idAllocator.derive.turnItemFromProviderItem({ | |
| 1828 | + provider: OPENCODE_PROVIDER, | |
| 1829 | + nativeItemId: part.messageID, | |
| 1830 | + }); | |
| 1831 | + const projected: OrchestrationV2ConversationMessage = { | |
| 1832 | + id: messageId, | |
| 1833 | + threadId: turn.threadId, | |
| 1834 | + runId: null, | |
| 1835 | + nodeId: turn.rootNodeId, | |
| 1836 | + role: "user", | |
| 1837 | + text: part.text, | |
| 1838 | + attachments: [], | |
| 1839 | + streaming: false, | |
| 1840 | + createdAt: turn.startedAt, | |
| 1841 | + updatedAt: now, | |
| 1842 | + }; | |
| 1843 | + state.messages.set(String(messageId), projected); | |
| 1844 | + yield* emitProviderEvent({ | |
| 1845 | + type: "message.updated", | |
| 1846 | + provider: OPENCODE_PROVIDER, | |
| 1847 | + message: projected, | |
| 1848 | + }); | |
| 1849 | + yield* emitProviderEvent({ | |
| 1850 | + type: "turn_item.updated", | |
| 1851 | + provider: OPENCODE_PROVIDER, | |
| 1852 | + turnItem: { | |
| 1853 | + id: turnItemId, | |
| 1854 | + threadId: turn.threadId, | |
| 1855 | + runId: null, | |
| 1856 | + nodeId: turn.rootNodeId, | |
| 1857 | + providerThreadId: state.providerThread.id, | |
| 1858 | + providerTurnId: turn.providerTurnId, | |
| 1859 | + nativeItemRef: providerRef(part.messageID), | |
| 1860 | + parentItemId: null, | |
| 1861 | + ordinal: itemOrdinal(turn, part.messageID), | |
| 1862 | + status: "completed", | |
| 1863 | + title: null, | |
| 1864 | + startedAt: projected.createdAt, | |
| 1865 | + completedAt: now, | |
| 1866 | + updatedAt: now, | |
| 1867 | + type: "user_message", | |
| 1868 | + messageId, | |
| 1869 | + inputIntent: "turn_start", | |
| 1870 | + text: part.text, | |
| 1871 | + attachments: [], | |
| 1872 | + }, | |
| 1873 | + }); | |
| 1874 | + }); | |
| 1875 | + | |
| 1876 | + const handleMessageUpdated = Effect.fnUntraced(function* ( | |
| 1877 | + event: Extract<OpenCodeEvent, { type: "message.updated" }>, | |
| 1878 | + ) { | |
| 1879 | + const state = threads.get(event.properties.sessionID); | |
| 1880 | + if (state === undefined) return; | |
| 1881 | + const message = event.properties.info; | |
| 1882 | + state.messageRoles.set(message.id, message.role); | |
| 1883 | + if (message.role !== "user") return; | |
| 1884 | + const isNewUserMessage = !state.userMessageIds.includes(message.id); | |
| 1885 | + if (isNewUserMessage) state.userMessageIds.push(message.id); | |
| 1886 | + let turn = state.activeTurn; | |
| 1887 | + if (turn === null && state.parentSubagent !== null && isNewUserMessage) { | |
| 1888 | + turn = yield* createChildTurn(state, message); | |
| 1889 | + } | |
| 1890 | + if (turn !== null && turn.nativeUserMessageId === null) { | |
| 1891 | + turn.nativeUserMessageId = message.id; | |
| 1892 | + yield* emitProviderTurn(state, turn, "running", null); | |
| 1893 | + } | |
| 1894 | + }); | |
| 1895 | + | |
| 1896 | + const handlePartUpdated = Effect.fnUntraced(function* ( | |
| 1897 | + event: Extract<OpenCodeEvent, { type: "message.part.updated" }>, | |
| 1898 | + ) { | |
| 1899 | + const part = event.properties.part; | |
| 1900 | + const state = threads.get(part.sessionID); | |
| 1901 | + const turn = state?.activeTurn; | |
| 1902 | + if (state === undefined || turn === null || turn === undefined || turn.finalized) return; | |
| 1903 | + if (part.type === "text" && state.messageRoles.get(part.messageID) === "user") { | |
| 1904 | + if (!turn.isRoot) yield* projectChildUserPart(state, turn, part); | |
| 1905 | + return; | |
| 1906 | + } | |
| 1907 | + turn.parts.set(part.id, part); | |
| 1908 | + const ids = turn.partIdsByMessage.get(part.messageID) ?? new Set<string>(); | |
| 1909 | + ids.add(part.id); | |
| 1910 | + turn.partIdsByMessage.set(part.messageID, ids); | |
| 1911 | + switch (part.type) { | |
| 1912 | + case "text": | |
| 1913 | + case "reasoning": | |
| 1914 | + yield* emitTextPart(state, turn, part); | |
| 1915 | + return; | |
| 1916 | + case "tool": | |
| 1917 | + yield* emitToolPart(state, turn, part); | |
| 1918 | + return; | |
| 1919 | + default: | |
| 1920 | + return; | |
| 1921 | + } | |
| 1922 | + }); | |
| 1923 | + | |
| 1924 | + const handlePartDelta = Effect.fnUntraced(function* ( | |
| 1925 | + event: Extract<OpenCodeEvent, { type: "message.part.delta" }>, | |
| 1926 | + ) { | |
| 1927 | + if (event.properties.field !== "text") return; | |
| 1928 | + const state = threads.get(event.properties.sessionID); | |
| 1929 | + const turn = state?.activeTurn; | |
| 1930 | + const current = turn?.parts.get(event.properties.partID); | |
| 1931 | + if ( | |
| 1932 | + state === undefined || | |
| 1933 | + turn === null || | |
| 1934 | + turn === undefined || | |
| 1935 | + current === undefined || | |
| 1936 | + (current.type !== "text" && current.type !== "reasoning") | |
| 1937 | + ) { | |
| 1938 | + return; | |
| 1939 | + } | |
| 1940 | + const updated = { ...current, text: current.text + event.properties.delta }; | |
| 1941 | + turn.parts.set(updated.id, updated); | |
| 1942 | + yield* emitTextPart(state, turn, updated); | |
| 1943 | + }); | |
| 1944 | + | |
| 1945 | + const handleAssistantCompleted = Effect.fnUntraced(function* ( | |
| 1946 | + event: Extract<OpenCodeEvent, { type: "message.updated" }>, | |
| 1947 | + ) { | |
| 1948 | + const message = event.properties.info; | |
| 1949 | + if (message.role !== "assistant" || message.time.completed === undefined) return; | |
| 1950 | + const state = threads.get(message.sessionID); | |
| 1951 | + const turn = state?.activeTurn; | |
| 1952 | + if (state === undefined || turn === null || turn === undefined) return; | |
| 1953 | + for (const partId of turn.partIdsByMessage.get(message.id) ?? []) { | |
| 1954 | + const part = turn.parts.get(partId); | |
| 1955 | + if (part?.type === "text" || part?.type === "reasoning") { | |
| 1956 | + yield* emitTextPart(state, turn, part, true); | |
| 1957 | + } | |
| 1958 | + } | |
| 1959 | + }); | |
| 1960 | + | |
| 1961 | + const handleEvent = Effect.fnUntraced(function* (event: OpenCodeEvent) { | |
| 1962 | + yield* emitRawEvent({ | |
| 1963 | + direction: "incoming", | |
| 1964 | + messageKind: "notification", | |
| 1965 | + method: event.type, | |
| 1966 | + payload: event, | |
| 1967 | + }); | |
| 1968 | + switch (event.type) { | |
| 1969 | + case "message.updated": | |
| 1970 | + yield* handleMessageUpdated(event); | |
| 1971 | + yield* handleAssistantCompleted(event); | |
| 1972 | + return; | |
| 1973 | + case "message.part.updated": | |
| 1974 | + yield* handlePartUpdated(event); | |
| 1975 | + return; | |
| 1976 | + case "message.part.delta": | |
| 1977 | + yield* handlePartDelta(event); | |
| 1978 | + return; | |
| 1979 | + case "todo.updated": { | |
| 1980 | + const state = threads.get(event.properties.sessionID); | |
| 1981 | + if (state?.activeTurn !== null && state?.activeTurn !== undefined) { | |
| 1982 | + yield* emitTodo(state, state.activeTurn, event.properties.todos); | |
| 1983 | + } | |
| 1984 | + return; | |
| 1985 | + } | |
| 1986 | + case "permission.asked": { | |
| 1987 | + const state = threads.get(event.properties.sessionID); | |
| 1988 | + if (state?.activeTurn !== null && state?.activeTurn !== undefined) { | |
| 1989 | + yield* emitRuntimeRequest(state, state.activeTurn, event.properties.id, { | |
| 1990 | + type: "permission", | |
| 1991 | + value: event.properties, | |
| 1992 | + }); | |
| 1993 | + } | |
| 1994 | + return; | |
| 1995 | + } | |
| 1996 | + case "question.asked": { | |
| 1997 | + const state = threads.get(event.properties.sessionID); | |
| 1998 | + if (state?.activeTurn !== null && state?.activeTurn !== undefined) { | |
| 1999 | + yield* emitRuntimeRequest(state, state.activeTurn, event.properties.id, { | |
| 2000 | + type: "question", | |
| 2001 | + value: event.properties, | |
| 2002 | + }); | |
| 2003 | + } | |
| 2004 | + return; | |
| 2005 | + } | |
| 2006 | + case "permission.replied": | |
| 2007 | + yield* resolveRuntimeRequest(event.properties.requestID, "resolved"); | |
| 2008 | + return; | |
| 2009 | + case "question.replied": | |
| 2010 | + yield* resolveRuntimeRequest(event.properties.requestID, "resolved"); | |
| 2011 | + return; | |
| 2012 | + case "question.rejected": | |
| 2013 | + yield* resolveRuntimeRequest(event.properties.requestID, "cancelled"); | |
| 2014 | + return; | |
| 2015 | + case "session.status": { | |
| 2016 | + const state = threads.get(event.properties.sessionID); | |
| 2017 | + if (state === undefined) return; | |
| 2018 | + if (event.properties.status.type === "busy") { | |
| 2019 | + yield* updateProviderThread(state, { status: "active" }); | |
| 2020 | + return; | |
| 2021 | + } | |
| 2022 | + if (event.properties.status.type === "idle" && state.activeTurn !== null) { | |
| 2023 | + yield* finalizeTurn( | |
| 2024 | + state, | |
| 2025 | + state.activeTurn, | |
| 2026 | + state.activeTurn.interrupted ? "interrupted" : "completed", | |
| 2027 | + ); | |
| 2028 | + } | |
| 2029 | + return; | |
| 2030 | + } | |
| 2031 | + case "session.idle": { | |
| 2032 | + const state = threads.get(event.properties.sessionID); | |
| 2033 | + if (state?.activeTurn !== null && state?.activeTurn !== undefined) { | |
| 2034 | + yield* finalizeTurn( | |
| 2035 | + state, | |
| 2036 | + state.activeTurn, | |
| 2037 | + state.activeTurn.interrupted ? "interrupted" : "completed", | |
| 2038 | + ); | |
| 2039 | + } | |
| 2040 | + return; | |
| 2041 | + } | |
| 2042 | + case "session.error": { | |
| 2043 | + const states = | |
| 2044 | + event.properties.sessionID === undefined | |
| 2045 | + ? Array.from(threads.values()).filter((state) => state.activeTurn !== null) | |
| 2046 | + : [threads.get(event.properties.sessionID)].filter( | |
| 2047 | + (state): state is OpenCodeThreadState => state !== undefined, | |
| 2048 | + ); | |
| 2049 | + const message = openCodeErrorMessage(event); | |
| 2050 | + if ( | |
| 2051 | + !isMessageAbortedError(event) && | |
| 2052 | + (event.properties.sessionID === undefined || | |
| 2053 | + states.some((state) => state.parentSubagent === null)) | |
| 2054 | + ) { | |
| 2055 | + yield* updateProviderSession("error", message); | |
| 2056 | + } | |
| 2057 | + for (const state of states) { | |
| 2058 | + if (state.activeTurn !== null) { | |
| 2059 | + yield* finalizeTurn( | |
| 2060 | + state, | |
| 2061 | + state.activeTurn, | |
| 2062 | + terminalStatusForError(event, state.activeTurn), | |
| 2063 | + ); | |
| 2064 | + } | |
| 2065 | + } | |
| 2066 | + return; | |
| 2067 | + } | |
| 2068 | + default: | |
| 2069 | + return; | |
| 2070 | + } | |
| 2071 | + }); | |
| 2072 | + | |
| 2073 | + const inputProviderSessionId = input.providerSessionId; | |
| 2074 | + | |
| 2075 | + const subscription = yield* sdkCall("event.subscribe", {}, () => | |
| 2076 | + client.event.subscribe(undefined, { signal: abortController.signal }), | |
| 2077 | + ); | |
| 2078 | + yield* Scope.addFinalizer( | |
| 2079 | + scope, | |
| 2080 | + Effect.sync(() => abortController.abort()), | |
| 2081 | + ); | |
| 2082 | + yield* Stream.fromAsyncIterable( | |
| 2083 | + subscription.stream, | |
| 2084 | + (cause) => | |
| 2085 | + new OpenCodeRuntimeError({ | |
| 2086 | + operation: "event.subscribe", | |
| 2087 | + detail: openCodeRuntimeErrorDetail(cause), | |
| 2088 | + cause, | |
| 2089 | + }), | |
| 2090 | + ).pipe( | |
| 2091 | + Stream.runForEach(handleEvent), | |
| 2092 | + Effect.exit, | |
| 2093 | + Effect.flatMap((exit) => | |
| 2094 | + Effect.gen(function* () { | |
| 2095 | + if (abortController.signal.aborted || Exit.isSuccess(exit)) return; | |
| 2096 | + const detail = openCodeRuntimeErrorDetail(Cause.squash(exit.cause)); | |
| 2097 | + yield* updateProviderSession("error", detail); | |
| 2098 | + for (const state of threads.values()) { | |
| 2099 | + if (state.activeTurn !== null) | |
| 2100 | + yield* finalizeTurn(state, state.activeTurn, "failed"); | |
| 2101 | + } | |
| 2102 | + }), | |
| 2103 | + ), | |
| 2104 | + Effect.forkIn(scope), | |
| 2105 | + ); | |
| 2106 | + | |
| 2107 | + if (!connection.external && connection.exitCode !== null) { | |
| 2108 | + yield* connection.exitCode.pipe( | |
| 2109 | + Effect.flatMap((code) => | |
| 2110 | + abortController.signal.aborted | |
| 2111 | + ? Effect.void | |
| 2112 | + : Effect.gen(function* () { | |
| 2113 | + const detail = `OpenCode server exited unexpectedly (${code}).`; | |
| 2114 | + yield* updateProviderSession("error", detail); | |
| 2115 | + for (const state of threads.values()) { | |
| 2116 | + if (state.activeTurn !== null) { | |
| 2117 | + yield* finalizeTurn(state, state.activeTurn, "failed"); | |
| 2118 | + } | |
| 2119 | + } | |
| 2120 | + }), | |
| 2121 | + ), | |
| 2122 | + Effect.forkIn(scope), | |
| 2123 | + ); | |
| 2124 | + } | |
| 2125 | + | |
| 2126 | + const registerThread = ( | |
| 2127 | + nativeSession: OpenCodeSession, | |
| 2128 | + providerThread: OrchestrationV2ProviderThread, | |
| 2129 | + appThread: OrchestrationV2AppThread | null, | |
| 2130 | + ): OpenCodeThreadState => { | |
| 2131 | + const existing = threads.get(nativeSession.id); | |
| 2132 | + if (existing !== undefined) { | |
| 2133 | + existing.providerThread = providerThread; | |
| 2134 | + if (appThread !== null) existing.appThread = appThread; | |
| 2135 | + return existing; | |
| 2136 | + } | |
| 2137 | + const state: OpenCodeThreadState = { | |
| 2138 | + nativeSessionId: nativeSession.id, | |
| 2139 | + providerThread, | |
| 2140 | + appThread, | |
| 2141 | + activeTurn: null, | |
| 2142 | + providerTurns: new Map(), | |
| 2143 | + messages: new Map(), | |
| 2144 | + runtimeRequests: new Map(), | |
| 2145 | + messageRoles: new Map(), | |
| 2146 | + userMessageIds: [], | |
| 2147 | + parentSubagent: subagentsByChildSessionId.get(nativeSession.id) ?? null, | |
| 2148 | + nextChildTurnOrdinal: 1, | |
| 2149 | + }; | |
| 2150 | + threads.set(nativeSession.id, state); | |
| 2151 | + return state; | |
| 2152 | + }; | |
| 2153 | + | |
| 2154 | + const resolvePromptParts = (turnInput: ProviderAdapterV2TurnInput) => { | |
| 2155 | + const text = turnInput.message.text.trim(); | |
| 2156 | + const files = toOpenCodeFileParts({ | |
| 2157 | + attachments: turnInput.message.attachments, | |
| 2158 | + resolveAttachmentPath: (attachment) => | |
| 2159 | + resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }), | |
| 2160 | + }); | |
| 2161 | + if (text.length === 0 && files.length === 0) { | |
| 2162 | + throw protocolError("OpenCode turns require text or at least one valid attachment"); | |
| 2163 | + } | |
| 2164 | + return [...(text.length === 0 ? [] : [{ type: "text" as const, text }]), ...files]; | |
| 2165 | + }; | |
| 2166 | + | |
| 2167 | + const readSnapshot = Effect.fnUntraced(function* ( | |
| 2168 | + providerThread: OrchestrationV2ProviderThread, | |
| 2169 | + ) { | |
| 2170 | + const sessionId = nativeThreadId(providerThread); | |
| 2171 | + const response = yield* sdkCall("session.messages", { sessionID: sessionId }, () => | |
| 2172 | + client.session.messages({ sessionID: sessionId }), | |
| 2173 | + ); | |
| 2174 | + const nativeMessages = unwrapData("session.messages", response); | |
| 2175 | + const state = threads.get(sessionId); | |
| 2176 | + const snapshotNow = yield* DateTime.now; | |
| 2177 | + const messages: Array<OrchestrationV2ConversationMessage> = nativeMessages.flatMap( | |
| 2178 | + ({ info, parts }) => { | |
| 2179 | + const text = parts | |
| 2180 | + .filter( | |
| 2181 | + (part): part is Extract<OpenCodePart, { type: "text" }> => part.type === "text", | |
| 2182 | + ) | |
| 2183 | + .filter((part) => part.ignored !== true && part.synthetic !== true) | |
| 2184 | + .map((part) => part.text) | |
| 2185 | + .join("\n"); | |
| 2186 | + if (text.length === 0) return []; | |
| 2187 | + const createdAt = dateTimeFromEpoch(info.time.created, snapshotNow); | |
| 2188 | + return [ | |
| 2189 | + { | |
| 2190 | + id: idAllocator.derive.messageFromProviderItem({ | |
| 2191 | + provider: OPENCODE_PROVIDER, | |
| 2192 | + nativeItemId: info.id, | |
| 2193 | + }), | |
| 2194 | + threadId: providerThread.appThreadId ?? input.threadId, | |
| 2195 | + runId: null, | |
| 2196 | + nodeId: null, | |
| 2197 | + role: info.role, | |
| 2198 | + text, | |
| 2199 | + attachments: [], | |
| 2200 | + streaming: false, | |
| 2201 | + createdAt, | |
| 2202 | + updatedAt: | |
| 2203 | + info.role === "assistant" | |
| 2204 | + ? dateTimeFromEpoch(info.time.completed, createdAt) | |
| 2205 | + : createdAt, | |
| 2206 | + }, | |
| 2207 | + ]; | |
| 2208 | + }, | |
| 2209 | + ); | |
| 2210 | + const lastUser = nativeMessages.findLast(({ info }) => info.role === "user")?.info.id; | |
| 2211 | + return { | |
| 2212 | + providerThread: { | |
| 2213 | + ...providerThread, | |
| 2214 | + providerSessionId: input.providerSessionId, | |
| 2215 | + nativeConversationHeadRef: | |
| 2216 | + lastUser === undefined ? null : providerRef(lastUser, "weak"), | |
| 2217 | + status: "idle" as const, | |
| 2218 | + updatedAt: snapshotNow, | |
| 2219 | + }, | |
| 2220 | + providerTurns: state === undefined ? [] : [...state.providerTurns.values()], | |
| 2221 | + messages, | |
| 2222 | + runtimeRequests: state === undefined ? [] : [...state.runtimeRequests.values()], | |
| 2223 | + providerPayload: nativeMessages, | |
| 2224 | + }; | |
| 2225 | + }); | |
| 2226 | + | |
| 2227 | + const runtimeSession: ProviderAdapterV2SessionRuntime = { | |
| 2228 | + instanceId: options.instanceId, | |
| 2229 | + provider: OPENCODE_PROVIDER, | |
| 2230 | + providerSessionId: input.providerSessionId, | |
| 2231 | + providerSession: sessionEntity, | |
| 2232 | + rawEvents: Stream.fromEffectRepeat(Queue.take(rawEvents)), | |
| 2233 | + events: Stream.fromEffectRepeat(Queue.take(events)), | |
| 2234 | + ensureThread: (threadInput) => | |
| 2235 | + Effect.gen(function* () { | |
| 2236 | + if (threadInput.existingProviderThread !== undefined) { | |
| 2237 | + return yield* runtimeSession.resumeThread({ | |
| 2238 | + providerThread: threadInput.existingProviderThread, | |
| 2239 | + }); | |
| 2240 | + } | |
| 2241 | + const response = yield* sdkCall( | |
| 2242 | + "session.create", | |
| 2243 | + { | |
| 2244 | + title: `T3 Code ${threadInput.threadId}`, | |
| 2245 | + permission: openCodePermissionRules(threadInput.runtimePolicy), | |
| 2246 | + }, | |
| 2247 | + () => | |
| 2248 | + client.session.create({ | |
| 2249 | + title: `T3 Code ${threadInput.threadId}`, | |
| 2250 | + permission: openCodePermissionRules(threadInput.runtimePolicy), | |
| 2251 | + }), | |
| 2252 | + ); | |
| 2253 | + const nativeSession = unwrapData("session.create", response); | |
| 2254 | + const createdAt = yield* DateTime.now; | |
| 2255 | + const providerThread = makeProviderThread({ | |
| 2256 | + idAllocator, | |
| 2257 | + providerSessionId: input.providerSessionId, | |
| 2258 | + appThreadId: threadInput.threadId, | |
| 2259 | + nativeSession, | |
| 2260 | + now: createdAt, | |
| 2261 | + }); | |
| 2262 | + registerThread(nativeSession, providerThread, null); | |
| 2263 | + return providerThread; | |
| 2264 | + }).pipe( | |
| 2265 | + Effect.mapError( | |
| 2266 | + (cause) => | |
| 2267 | + new ProviderAdapterEnsureThreadError({ | |
| 2268 | + provider: OPENCODE_PROVIDER, | |
| 2269 | + threadId: threadInput.threadId, | |
| 2270 | + cause, | |
| 2271 | + }), | |
| 2272 | + ), | |
| 2273 | + ), | |
| 2274 | + resumeThread: (threadInput) => | |
| 2275 | + Effect.gen(function* () { | |
| 2276 | + const sessionId = nativeThreadId(threadInput.providerThread); | |
| 2277 | + const response = yield* sdkCall("session.get", { sessionID: sessionId }, () => | |
| 2278 | + client.session.get({ sessionID: sessionId }), | |
| 2279 | + ); | |
| 2280 | + const nativeSession = unwrapData("session.get", response); | |
| 2281 | + const resumedAt = yield* DateTime.now; | |
| 2282 | + const providerThread = { | |
| 2283 | + ...threadInput.providerThread, | |
| 2284 | + providerSessionId: input.providerSessionId, | |
| 2285 | + status: "idle" as const, | |
| 2286 | + updatedAt: dateTimeFromEpoch(nativeSession.time.updated, resumedAt), | |
| 2287 | + }; | |
| 2288 | + registerThread(nativeSession, providerThread, null); | |
| 2289 | + return providerThread; | |
| 2290 | + }).pipe( | |
| 2291 | + Effect.mapError( | |
| 2292 | + (cause) => | |
| 2293 | + new ProviderAdapterResumeThreadError({ | |
| 2294 | + provider: OPENCODE_PROVIDER, | |
| 2295 | + providerSessionId: input.providerSessionId, | |
| 2296 | + providerThreadId: threadInput.providerThread.id, | |
| 2297 | + cause, | |
| 2298 | + }), | |
| 2299 | + ), | |
| 2300 | + ), | |
| 2301 | + startTurn: (turnInput) => | |
| 2302 | + Effect.gen(function* () { | |
| 2303 | + const sessionId = nativeThreadId(turnInput.providerThread); | |
| 2304 | + const state = threads.get(sessionId); | |
| 2305 | + if (state === undefined) { | |
| 2306 | + return yield* protocolError(`OpenCode session ${sessionId} is not registered`); | |
| 2307 | + } | |
| 2308 | + if (state.activeTurn !== null) { | |
| 2309 | + return yield* protocolError( | |
| 2310 | + `OpenCode provider thread ${turnInput.providerThread.id} already has an active turn`, | |
| 2311 | + ); | |
| 2312 | + } | |
| 2313 | + const parsedModel = parseOpenCodeModelSlug(turnInput.modelSelection.model); | |
| 2314 | + if (parsedModel === null) { | |
| 2315 | + return yield* protocolError( | |
| 2316 | + `OpenCode model '${turnInput.modelSelection.model}' must use provider/model format`, | |
| 2317 | + ); | |
| 2318 | + } | |
| 2319 | + const parts = resolvePromptParts(turnInput); | |
| 2320 | + const startedAt = yield* DateTime.now; | |
| 2321 | + const syntheticNativeTurnId = `${sessionId}:attempt:${turnInput.attemptId}`; | |
| 2322 | + const providerTurnId = idAllocator.derive.providerTurn({ | |
| 2323 | + provider: OPENCODE_PROVIDER, | |
| 2324 | + nativeTurnId: syntheticNativeTurnId, | |
| 2325 | + }); | |
| 2326 | + const providerTurn: OrchestrationV2ProviderTurn = { | |
| 2327 | + id: providerTurnId, | |
| 2328 | + providerThreadId: turnInput.providerThread.id, | |
| 2329 | + nodeId: turnInput.rootNodeId, | |
| 2330 | + runAttemptId: turnInput.attemptId, | |
| 2331 | + nativeTurnRef: providerRef(syntheticNativeTurnId, "weak"), | |
| 2332 | + ordinal: turnInput.providerTurnOrdinal, | |
| 2333 | + status: "running", | |
| 2334 | + startedAt, | |
| 2335 | + completedAt: null, | |
| 2336 | + }; | |
| 2337 | + const turn: ActiveOpenCodeTurn = { | |
| 2338 | + isRoot: true, | |
| 2339 | + threadId: turnInput.threadId, | |
| 2340 | + runId: turnInput.runId, | |
| 2341 | + rootNodeId: turnInput.rootNodeId, | |
| 2342 | + appThread: turnInput.appThread, | |
| 2343 | + modelSelection: turnInput.modelSelection, | |
| 2344 | + runtimePolicy: turnInput.runtimePolicy, | |
| 2345 | + providerTurnId, | |
| 2346 | + providerTurnOrdinal: turnInput.providerTurnOrdinal, | |
| 2347 | + runAttemptId: turnInput.attemptId, | |
| 2348 | + startedAt, | |
| 2349 | + itemOrdinals: new Map(), | |
| 2350 | + parts: new Map(), | |
| 2351 | + partIdsByMessage: new Map(), | |
| 2352 | + providerTurn, | |
| 2353 | + nextItemOrdinal: 100, | |
| 2354 | + nativeUserMessageId: null, | |
| 2355 | + interrupted: false, | |
| 2356 | + finalized: false, | |
| 2357 | + planId: null, | |
| 2358 | + }; | |
| 2359 | + state.appThread = turnInput.appThread; | |
| 2360 | + state.activeTurn = turn; | |
| 2361 | + state.providerTurns.set(String(providerTurnId), providerTurn); | |
| 2362 | + yield* emitProviderTurn(state, turn, "running", null); | |
| 2363 | + yield* updateProviderThread(state, { | |
| 2364 | + status: "active", | |
| 2365 | + firstRunOrdinal: state.providerThread.firstRunOrdinal ?? turnInput.runOrdinal, | |
| 2366 | + lastRunOrdinal: turnInput.runOrdinal, | |
| 2367 | + }); | |
| 2368 | + yield* updateProviderSession("running", null); | |
| 2369 | + const agent = | |
| 2370 | + getModelSelectionStringOptionValue(turnInput.modelSelection, "agent") ?? | |
| 2371 | + (turnInput.runtimePolicy.interactionMode === "plan" ? "plan" : undefined); | |
| 2372 | + const variant = getModelSelectionStringOptionValue( | |
| 2373 | + turnInput.modelSelection, | |
| 2374 | + "variant", | |
| 2375 | + ); | |
| 2376 | + yield* sdkCall( | |
| 2377 | + "session.promptAsync", | |
| 2378 | + { | |
| 2379 | + sessionID: sessionId, | |
| 2380 | + model: parsedModel, | |
| 2381 | + ...(agent === undefined ? {} : { agent }), | |
| 2382 | + ...(variant === undefined ? {} : { variant }), | |
| 2383 | + parts, | |
| 2384 | + }, | |
| 2385 | + () => | |
| 2386 | + client.session.promptAsync({ | |
| 2387 | + sessionID: sessionId, | |
| 2388 | + model: parsedModel, | |
| 2389 | + ...(agent === undefined ? {} : { agent }), | |
| 2390 | + ...(variant === undefined ? {} : { variant }), | |
| 2391 | + parts, | |
| 2392 | + }), | |
| 2393 | + ).pipe(Effect.tapError(() => finalizeTurn(state, turn, "failed"))); | |
| 2394 | + }).pipe( | |
| 2395 | + Effect.mapError( | |
| 2396 | + (cause) => | |
| 2397 | + new ProviderAdapterTurnStartError({ | |
| 2398 | + provider: OPENCODE_PROVIDER, | |
| 2399 | + threadId: turnInput.threadId, | |
| 2400 | + providerThreadId: turnInput.providerThread.id, | |
| 2401 | + runId: turnInput.runId, | |
| 2402 | + cause, | |
| 2403 | + }), | |
| 2404 | + ), | |
| 2405 | + ), | |
| 2406 | + steerTurn: (steerInput) => | |
| 2407 | + Effect.gen(function* () { | |
| 2408 | + const sessionId = nativeThreadId(steerInput.providerThread); | |
| 2409 | + const state = threads.get(sessionId); | |
| 2410 | + const turn = state?.activeTurn; | |
| 2411 | + if ( | |
| 2412 | + turn === undefined || | |
| 2413 | + turn === null || | |
| 2414 | + turn.providerTurnId !== steerInput.providerTurnId | |
| 2415 | + ) { | |
| 2416 | + return yield* protocolError( | |
| 2417 | + `OpenCode turn ${steerInput.providerTurnId} is not active`, | |
| 2418 | + ); | |
| 2419 | + } | |
| 2420 | + const parsedModel = parseOpenCodeModelSlug(turn.modelSelection.model); | |
| 2421 | + if (parsedModel === null) { | |
| 2422 | + return yield* protocolError( | |
| 2423 | + `OpenCode model '${turn.modelSelection.model}' must use provider/model format`, | |
| 2424 | + ); | |
| 2425 | + } | |
| 2426 | + const text = steerInput.message.text.trim(); | |
| 2427 | + const files = toOpenCodeFileParts({ | |
| 2428 | + attachments: steerInput.message.attachments, | |
| 2429 | + resolveAttachmentPath: (attachment) => | |
| 2430 | + resolveAttachmentPath({ | |
| 2431 | + attachmentsDir: serverConfig.attachmentsDir, | |
| 2432 | + attachment, | |
| 2433 | + }), | |
| 2434 | + }); | |
| 2435 | + if (text.length === 0 && files.length === 0) { | |
| 2436 | + return yield* protocolError("OpenCode steering requires text or an attachment"); | |
| 2437 | + } | |
| 2438 | + const parts = [ | |
| 2439 | + ...(text.length === 0 ? [] : [{ type: "text" as const, text }]), | |
| 2440 | + ...files, | |
| 2441 | + ]; | |
| 2442 | + yield* sdkCall( | |
| 2443 | + "session.promptAsync", | |
| 2444 | + { sessionID: sessionId, model: parsedModel, parts }, | |
| 2445 | + () => | |
| 2446 | + client.session.promptAsync({ | |
| 2447 | + sessionID: sessionId, | |
| 2448 | + model: parsedModel, | |
| 2449 | + parts, | |
| 2450 | + }), | |
| 2451 | + ); | |
| 2452 | + }).pipe( | |
| 2453 | + Effect.mapError( | |
| 2454 | + (cause) => | |
| 2455 | + new ProviderAdapterSteerRunError({ | |
| 2456 | + provider: OPENCODE_PROVIDER, | |
| 2457 | + providerThreadId: steerInput.providerThread.id, | |
| 2458 | + providerTurnId: steerInput.providerTurnId, | |
| 2459 | + cause, | |
| 2460 | + }), | |
| 2461 | + ), | |
| 2462 | + ), | |
| 2463 | + interruptTurn: (interruptInput) => | |
| 2464 | + Effect.gen(function* () { | |
| 2465 | + const sessionId = nativeThreadId(interruptInput.providerThread); | |
| 2466 | + const state = threads.get(sessionId); | |
| 2467 | + const turn = state?.activeTurn; | |
| 2468 | + if ( | |
| 2469 | + turn === undefined || | |
| 2470 | + turn === null || | |
| 2471 | + turn.providerTurnId !== interruptInput.providerTurnId | |
| 2472 | + ) { | |
| 2473 | + return yield* protocolError( | |
| 2474 | + `OpenCode turn ${interruptInput.providerTurnId} is not active`, | |
| 2475 | + ); | |
| 2476 | + } | |
| 2477 | + turn.interrupted = true; | |
| 2478 | + yield* sdkCall("session.abort", { sessionID: sessionId }, () => | |
| 2479 | + client.session.abort({ sessionID: sessionId }), | |
| 2480 | + ).pipe(Effect.tapError(() => Effect.sync(() => (turn.interrupted = false)))); | |
| 2481 | + }).pipe( | |
| 2482 | + Effect.mapError( | |
| 2483 | + (cause) => | |
| 2484 | + new ProviderAdapterInterruptError({ | |
| 2485 | + provider: OPENCODE_PROVIDER, | |
| 2486 | + providerThreadId: interruptInput.providerThread.id, | |
| 2487 | + providerTurnId: interruptInput.providerTurnId, | |
| 2488 | + cause, | |
| 2489 | + }), | |
| 2490 | + ), | |
| 2491 | + ), | |
| 2492 | + respondToRuntimeRequest: (requestInput) => | |
| 2493 | + Effect.gen(function* () { | |
| 2494 | + const pending = pendingRequests.get(String(requestInput.requestId)); | |
| 2495 | + if (pending === undefined) { | |
| 2496 | + return yield* protocolError( | |
| 2497 | + `No pending OpenCode request ${requestInput.requestId}`, | |
| 2498 | + ); | |
| 2499 | + } | |
| 2500 | + if (pending.question !== undefined) { | |
| 2501 | + if (requestInput.answers === undefined) { | |
| 2502 | + return yield* protocolError( | |
| 2503 | + `OpenCode question request ${requestInput.requestId} requires answers`, | |
| 2504 | + ); | |
| 2505 | + } | |
| 2506 | + const answers = toOpenCodeQuestionAnswers(pending.question, requestInput.answers); | |
| 2507 | + yield* sdkCall( | |
| 2508 | + "question.reply", | |
| 2509 | + { requestID: pending.nativeRequestId, answers }, | |
| 2510 | + () => | |
| 2511 | + client.question.reply({ | |
| 2512 | + requestID: pending.nativeRequestId, | |
| 2513 | + answers, | |
| 2514 | + }), | |
| 2515 | + ); | |
| 2516 | + return; | |
| 2517 | + } | |
| 2518 | + if (requestInput.decision === undefined) { | |
| 2519 | + return yield* protocolError( | |
| 2520 | + `OpenCode approval request ${requestInput.requestId} requires a decision`, | |
| 2521 | + ); | |
| 2522 | + } | |
| 2523 | + const reply = toOpenCodePermissionReply(requestInput.decision); | |
| 2524 | + yield* sdkCall( | |
| 2525 | + "permission.reply", | |
| 2526 | + { requestID: pending.nativeRequestId, reply }, | |
| 2527 | + () => | |
| 2528 | + client.permission.reply({ | |
| 2529 | + requestID: pending.nativeRequestId, | |
| 2530 | + reply, | |
| 2531 | + }), | |
| 2532 | + ); | |
| 2533 | + }).pipe( | |
| 2534 | + Effect.mapError( | |
| 2535 | + (cause) => | |
| 2536 | + new ProviderAdapterRuntimeRequestResponseError({ | |
| 2537 | + provider: OPENCODE_PROVIDER, | |
| 2538 | + requestId: requestInput.requestId, | |
| 2539 | + cause, | |
| 2540 | + }), | |
| 2541 | + ), | |
| 2542 | + ), | |
| 2543 | + readThreadSnapshot: (snapshotInput) => | |
| 2544 | + readSnapshot(snapshotInput.providerThread).pipe( | |
| 2545 | + Effect.mapError( | |
| 2546 | + (cause) => | |
| 2547 | + new ProviderAdapterReadThreadSnapshotError({ | |
| 2548 | + provider: OPENCODE_PROVIDER, | |
| 2549 | + providerThreadId: snapshotInput.providerThread.id, | |
| 2550 | + cause, | |
| 2551 | + }), | |
| 2552 | + ), | |
| 2553 | + ), | |
| 2554 | + rollbackThread: (rollbackInput) => | |
| 2555 | + Effect.gen(function* () { | |
| 2556 | + const sessionId = nativeThreadId(rollbackInput.providerThread); | |
| 2557 | + const state = threads.get(sessionId); | |
| 2558 | + if (state?.activeTurn !== null && state?.activeTurn !== undefined) { | |
| 2559 | + return yield* protocolError( | |
| 2560 | + `Cannot roll back OpenCode thread ${rollbackInput.providerThread.id} while a turn is active`, | |
| 2561 | + ); | |
| 2562 | + } | |
| 2563 | + const response = yield* sdkCall("session.messages", { sessionID: sessionId }, () => | |
| 2564 | + client.session.messages({ sessionID: sessionId }), | |
| 2565 | + ); | |
| 2566 | + const messages = unwrapData("session.messages", response); | |
| 2567 | + let boundaryMessageId: string | undefined; | |
| 2568 | + if (rollbackInput.target.type === "thread_start") { | |
| 2569 | + boundaryMessageId = messages.find(({ info }) => info.role === "user")?.info.id; | |
| 2570 | + } else { | |
| 2571 | + boundaryMessageId = openCodeBoundaryAfterProviderTurn( | |
| 2572 | + rollbackInput.providerThreadTurns, | |
| 2573 | + rollbackInput.target.providerTurn.id, | |
| 2574 | + ); | |
| 2575 | + } | |
| 2576 | + if (boundaryMessageId !== undefined) { | |
| 2577 | + yield* sdkCall( | |
| 2578 | + "session.revert", | |
| 2579 | + { sessionID: sessionId, messageID: boundaryMessageId }, | |
| 2580 | + () => | |
| 2581 | + client.session.revert({ sessionID: sessionId, messageID: boundaryMessageId }), | |
| 2582 | + ); | |
| 2583 | + } | |
| 2584 | + const snapshot = yield* readSnapshot(rollbackInput.providerThread); | |
| 2585 | + return { | |
| 2586 | + ...snapshot, | |
| 2587 | + providerThread: { | |
| 2588 | + ...snapshot.providerThread, | |
| 2589 | + nativeConversationHeadRef: | |
| 2590 | + rollbackInput.target.type === "provider_turn" | |
| 2591 | + ? rollbackInput.target.providerTurn.nativeTurnRef | |
| 2592 | + : null, | |
| 2593 | + }, | |
| 2594 | + }; | |
| 2595 | + }).pipe( | |
| 2596 | + Effect.mapError( | |
| 2597 | + (cause) => | |
| 2598 | + new ProviderAdapterRollbackThreadError({ | |
| 2599 | + provider: OPENCODE_PROVIDER, | |
| 2600 | + providerThreadId: rollbackInput.providerThread.id, | |
| 2601 | + checkpointId: rollbackInput.target.checkpointId, | |
| 2602 | + cause, | |
| 2603 | + }), | |
| 2604 | + ), | |
| 2605 | + ), | |
| 2606 | + forkThread: (forkInput) => | |
| 2607 | + Effect.gen(function* () { | |
| 2608 | + const sourceSessionId = nativeThreadId(forkInput.sourceProviderThread); | |
| 2609 | + const sourceState = threads.get(sourceSessionId); | |
| 2610 | + if (sourceState?.activeTurn !== null && sourceState?.activeTurn !== undefined) { | |
| 2611 | + return yield* protocolError( | |
| 2612 | + `Cannot fork OpenCode thread ${forkInput.sourceProviderThread.id} while a turn is active`, | |
| 2613 | + ); | |
| 2614 | + } | |
| 2615 | + let boundaryMessageId: string | undefined; | |
| 2616 | + if (forkInput.providerTurnId !== undefined) { | |
| 2617 | + const sourceTurns = forkInput.sourceProviderTurns ?? []; | |
| 2618 | + const selected = sourceTurns.find((turn) => turn.id === forkInput.providerTurnId); | |
| 2619 | + if (selected === undefined) { | |
| 2620 | + return yield* protocolError( | |
| 2621 | + `OpenCode fork boundary turn ${forkInput.providerTurnId} was not found`, | |
| 2622 | + ); | |
| 2623 | + } | |
| 2624 | + boundaryMessageId = openCodeBoundaryAfterProviderTurn(sourceTurns, selected.id); | |
| 2625 | + } | |
| 2626 | + const response = yield* sdkCall( | |
| 2627 | + "session.fork", | |
| 2628 | + { | |
| 2629 | + sessionID: sourceSessionId, | |
| 2630 | + ...(boundaryMessageId === undefined ? {} : { messageID: boundaryMessageId }), | |
| 2631 | + }, | |
| 2632 | + () => | |
| 2633 | + client.session.fork({ | |
| 2634 | + sessionID: sourceSessionId, | |
| 2635 | + ...(boundaryMessageId === undefined ? {} : { messageID: boundaryMessageId }), | |
| 2636 | + }), | |
| 2637 | + ); | |
| 2638 | + const nativeSession = unwrapData("session.fork", response); | |
| 2639 | + const forkedAt = yield* DateTime.now; | |
| 2640 | + const providerThread = makeProviderThread({ | |
| 2641 | + idAllocator, | |
| 2642 | + providerSessionId: input.providerSessionId, | |
| 2643 | + appThreadId: forkInput.targetThreadId, | |
| 2644 | + ...(forkInput.ownerNodeId === undefined | |
| 2645 | + ? {} | |
| 2646 | + : { ownerNodeId: forkInput.ownerNodeId }), | |
| 2647 | + nativeSession, | |
| 2648 | + forkedFrom: { | |
| 2649 | + providerThreadId: forkInput.sourceProviderThread.id, | |
| 2650 | + ...(forkInput.providerTurnId === undefined | |
| 2651 | + ? {} | |
| 2652 | + : { providerTurnId: forkInput.providerTurnId }), | |
| 2653 | + }, | |
| 2654 | + now: forkedAt, | |
| 2655 | + }); | |
| 2656 | + registerThread(nativeSession, providerThread, null); | |
| 2657 | + return providerThread; | |
| 2658 | + }).pipe( | |
| 2659 | + Effect.mapError( | |
| 2660 | + (cause) => | |
| 2661 | + new ProviderAdapterForkThreadError({ | |
| 2662 | + provider: OPENCODE_PROVIDER, | |
| 2663 | + providerThreadId: forkInput.sourceProviderThread.id, | |
| 2664 | + cause, | |
| 2665 | + }), | |
| 2666 | + ), | |
| 2667 | + ), | |
| 2668 | + }; | |
| 2669 | + | |
| 2670 | + return runtimeSession; | |
| 2671 | + }, | |
| 2672 | + (effect, input) => | |
| 2673 | + effect.pipe( | |
| 2674 | + Effect.mapError( | |
| 2675 | + (cause) => | |
| 2676 | + new ProviderAdapterOpenSessionError({ | |
| 2677 | + provider: OPENCODE_PROVIDER, | |
| 2678 | + providerSessionId: input.providerSessionId, | |
| 2679 | + cause, | |
| 2680 | + }), | |
| 2681 | + ), | |
| 2682 | + ), | |
| 2683 | + ), | |
| 2684 | + }); | |
| 2685 | +} | |
| 2686 | + | |
| 2687 | +export type OpenCodeAdapterV2DriverEnv = OpenCodeRuntime | IdAllocatorV2 | ServerConfig; | |
| 2688 | + | |
| 2689 | +export const OpenCodeAdapterV2Driver: ProviderAdapterDriver< | |
| 2690 | + OpenCodeSettings, | |
| 2691 | + OpenCodeAdapterV2DriverEnv | |
| 2692 | +> = { | |
| 2693 | + driverKind: OPENCODE_DRIVER_KIND, | |
| 2694 | + configSchema: OpenCodeSettingsSchema, | |
| 2695 | + defaultConfig: (): OpenCodeSettings => DEFAULT_OPENCODE_SETTINGS, | |
| 2696 | + create: Effect.fn("OpenCodeAdapterV2Driver.create")( | |
| 2697 | + function* (input: ProviderAdapterDriverCreateInput<OpenCodeSettings>) { | |
| 2698 | + const hostEnvironment = yield* HostProcessEnvironment; | |
| 2699 | + const openCodeRuntime = yield* OpenCodeRuntime; | |
| 2700 | + const idAllocator = yield* IdAllocatorV2; | |
| 2701 | + const serverConfig = yield* ServerConfig; | |
| 2702 | + return makeOpenCodeAdapterV2({ | |
| 2703 | + instanceId: input.instanceId, | |
| 2704 | + settings: { ...input.config, enabled: input.enabled }, | |
| 2705 | + environment: mergeProviderInstanceEnvironment(input.environment, hostEnvironment), | |
| 2706 | + runtime: openCodeRuntime, | |
| 2707 | + idAllocator, | |
| 2708 | + serverConfig, | |
| 2709 | + }); | |
| 2710 | + }, | |
| 2711 | + (effect, input) => | |
| 2712 | + effect.pipe( | |
| 2713 | + Effect.mapError( | |
| 2714 | + (cause) => | |
| 2715 | + new ProviderAdapterDriverCreateError({ | |
| 2716 | + driver: OPENCODE_DRIVER_KIND, | |
| 2717 | + instanceId: input.instanceId, | |
| 2718 | + detail: "Failed to create OpenCode v2 adapter.", | |
| 2719 | + cause, | |
| 2720 | + }), | |
| 2721 | + ), | |
| 2722 | + ), | |
| 2723 | + ), | |
| 2724 | +}; | |
| 2725 | + | |
| 2726 | +export const layer: Layer.Layer<ProviderAdapterV2, never, OpenCodeAdapterV2DriverEnv> = | |
| 2727 | + Layer.effect( | |
| 2728 | + ProviderAdapterV2, | |
| 2729 | + Effect.gen(function* () { | |
| 2730 | + const hostEnvironment = yield* HostProcessEnvironment; | |
| 2731 | + const openCodeRuntime = yield* OpenCodeRuntime; | |
| 2732 | + const idAllocator = yield* IdAllocatorV2; | |
| 2733 | + const serverConfig = yield* ServerConfig; | |
| 2734 | + return makeOpenCodeAdapterV2({ | |
| 2735 | + instanceId: OPENCODE_DEFAULT_INSTANCE_ID, | |
| 2736 | + settings: DEFAULT_OPENCODE_SETTINGS, | |
| 2737 | + environment: hostEnvironment, | |
| 2738 | + runtime: openCodeRuntime, | |
| 2739 | + idAllocator, | |
| 2740 | + serverConfig, | |
| 2741 | + }); | |
| 2742 | + }), | |
| 2743 | + ); | |
| @@ -4,6 +4,7 @@ import * as Effect from "effect/Effect"; · 3 unmodified lines | ||
| 4 | 4 | |
| 5 | 5 | import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; |
| 6 | 6 | import { CursorProviderCapabilitiesV2 } from "./Adapters/CursorAdapterV2.ts"; |
| 7 | +import { GrokProviderCapabilitiesV2 } from "./Adapters/GrokAdapterV2.ts"; | |
| 7 | 8 | import { |
| 8 | 9 | CommandPolicyCapabilityUnsupportedError, |
| 9 | 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 | 81 | it.effect("honors an explicit interrupt-and-restart request", () => |
| 66 | 82 | Effect.gen(function* () { |
| 67 | 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 | 202 | it.effect("returns a typed error when neither native nor portable fork is available", () => |
| 169 | 203 | Effect.gen(function* () { |
| 170 | 204 | const policy = yield* CommandPolicyV2; |
| @@ -0,0 +1,158 @@ | ||
| 1 | +import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | +import { assert, it } from "@effect/vitest"; | |
| 3 | +import { | |
| 4 | + CommandId, | |
| 5 | + MessageId, | |
| 6 | + type OrchestrationV2ThreadProjection, | |
| 7 | + ProjectId, | |
| 8 | + ThreadId, | |
| 9 | +} from "@t3tools/contracts"; | |
| 10 | +import * as Console from "effect/Console"; | |
| 11 | +import * as Effect from "effect/Effect"; | |
| 12 | +import * as Layer from "effect/Layer"; | |
| 13 | +import { describe } from "vite-plus/test"; | |
| 14 | + | |
| 15 | +import { CheckpointStoreLive } from "../checkpointing/Layers/CheckpointStore.ts"; | |
| 16 | +import { ServerConfig } from "../config.ts"; | |
| 17 | +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; | |
| 18 | +import { ServerSettingsService } from "../serverSettings.ts"; | |
| 19 | +import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; | |
| 20 | +import * as VcsProcess from "../vcs/VcsProcess.ts"; | |
| 21 | +import { OrchestratorV2 } from "./Orchestrator.ts"; | |
| 22 | +import { OrchestrationV2LayerLive } from "./runtimeLayer.ts"; | |
| 23 | +import { GROK_MODEL_SELECTION } from "./testkit/fixtures/shared.ts"; | |
| 24 | + | |
| 25 | +const serverConfigLayer = ServerConfig.layerTest(process.cwd(), { | |
| 26 | + prefix: "t3-grok-v2-live-", | |
| 27 | +}); | |
| 28 | + | |
| 29 | +const vcsDriverRegistryLayer = VcsDriverRegistry.layer.pipe( | |
| 30 | + Layer.provide(VcsProcess.layer), | |
| 31 | + Layer.provide(serverConfigLayer), | |
| 32 | + Layer.provide(NodeServices.layer), | |
| 33 | +); | |
| 34 | + | |
| 35 | +const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(vcsDriverRegistryLayer)); | |
| 36 | + | |
| 37 | +const liveLayer = OrchestrationV2LayerLive.pipe( | |
| 38 | + Layer.provide(SqlitePersistenceMemory), | |
| 39 | + Layer.provide(checkpointStoreLayer), | |
| 40 | + Layer.provide(serverConfigLayer), | |
| 41 | + Layer.provide( | |
| 42 | + ServerSettingsService.layerTest({ | |
| 43 | + providers: { | |
| 44 | + grok: { enabled: true }, | |
| 45 | + }, | |
| 46 | + }), | |
| 47 | + ), | |
| 48 | + Layer.provide(NodeServices.layer), | |
| 49 | +); | |
| 50 | + | |
| 51 | +const waitForIdle = Effect.fn("GrokOrchestratorV2Live.waitForIdle")(function* (threadId: ThreadId) { | |
| 52 | + const orchestrator = yield* OrchestratorV2; | |
| 53 | + for (let attempt = 0; attempt < 600; attempt += 1) { | |
| 54 | + const projection = yield* orchestrator.getThreadProjection(threadId); | |
| 55 | + if ( | |
| 56 | + projection.runs.length > 0 && | |
| 57 | + projection.runs.every( | |
| 58 | + (run) => !["queued", "starting", "running", "waiting"].includes(run.status), | |
| 59 | + ) | |
| 60 | + ) { | |
| 61 | + return projection; | |
| 62 | + } | |
| 63 | + yield* Effect.sleep("500 millis"); | |
| 64 | + } | |
| 65 | + return yield* Effect.die(new Error(`Timed out waiting for Grok thread ${threadId}.`)); | |
| 66 | +}); | |
| 67 | + | |
| 68 | +describe.runIf(process.env.T3_GROK_LIVE_ORCHESTRATOR === "1")("Grok V2 live orchestrator", () => { | |
| 69 | + it.live( | |
| 70 | + "forks through portable context using real Grok ACP agents", | |
| 71 | + () => | |
| 72 | + Effect.gen(function* () { | |
| 73 | + const orchestrator = yield* OrchestratorV2; | |
| 74 | + const projectId = ProjectId.make("project:grok-live-portable-fork"); | |
| 75 | + const sourceThreadId = ThreadId.make("thread:grok-live-portable-fork:source"); | |
| 76 | + const targetThreadId = ThreadId.make("thread:grok-live-portable-fork:target"); | |
| 77 | + const marker = "GROK_LIVE_PORTABLE_FORK_7H3Q"; | |
| 78 | + | |
| 79 | + yield* orchestrator.dispatch({ | |
| 80 | + type: "thread.create", | |
| 81 | + commandId: CommandId.make("command:grok-live-portable-fork:create"), | |
| 82 | + threadId: sourceThreadId, | |
| 83 | + projectId, | |
| 84 | + title: "Grok live portable fork source", | |
| 85 | + modelSelection: GROK_MODEL_SELECTION, | |
| 86 | + runtimeMode: "full-access", | |
| 87 | + interactionMode: "default", | |
| 88 | + branch: null, | |
| 89 | + worktreePath: null, | |
| 90 | + }); | |
| 91 | + yield* Console.log("Grok live source thread created; dispatching source prompt."); | |
| 92 | + yield* orchestrator.dispatch({ | |
| 93 | + type: "message.dispatch", | |
| 94 | + commandId: CommandId.make("command:grok-live-portable-fork:source"), | |
| 95 | + threadId: sourceThreadId, | |
| 96 | + messageId: MessageId.make("message:grok-live-portable-fork:source"), | |
| 97 | + text: `Remember this opaque marker. Respond with exactly: ${marker}`, | |
| 98 | + attachments: [], | |
| 99 | + modelSelection: GROK_MODEL_SELECTION, | |
| 100 | + dispatchMode: { type: "start_immediately" }, | |
| 101 | + }); | |
| 102 | + yield* Console.log("Grok live source prompt dispatched; waiting for completion."); | |
| 103 | + const sourceProjection = yield* waitForIdle(sourceThreadId); | |
| 104 | + yield* Console.log("Grok live source turn completed; dispatching portable fork."); | |
| 105 | + | |
| 106 | + yield* orchestrator.dispatch({ | |
| 107 | + type: "thread.fork", | |
| 108 | + commandId: CommandId.make("command:grok-live-portable-fork:fork"), | |
| 109 | + sourceThreadId, | |
| 110 | + targetThreadId, | |
| 111 | + sourcePoint: { type: "latest_stable" }, | |
| 112 | + title: "Grok live portable fork target", | |
| 113 | + }); | |
| 114 | + yield* orchestrator.dispatch({ | |
| 115 | + type: "message.dispatch", | |
| 116 | + commandId: CommandId.make("command:grok-live-portable-fork:target"), | |
| 117 | + threadId: targetThreadId, | |
| 118 | + messageId: MessageId.make("message:grok-live-portable-fork:target"), | |
| 119 | + text: "Return the opaque marker from the transferred conversation. Respond with only the marker.", | |
| 120 | + attachments: [], | |
| 121 | + modelSelection: GROK_MODEL_SELECTION, | |
| 122 | + dispatchMode: { type: "start_immediately" }, | |
| 123 | + }); | |
| 124 | + const targetProjection = yield* waitForIdle(targetThreadId); | |
| 125 | + yield* Console.log("Grok live portable fork target completed."); | |
| 126 | + | |
| 127 | + const assistantText = (projection: OrchestrationV2ThreadProjection) => | |
| 128 | + projection.messages | |
| 129 | + .filter((message) => message.role === "assistant") | |
| 130 | + .map((message) => message.text) | |
| 131 | + .join("\n"); | |
| 132 | + | |
| 133 | + assert.deepEqual( | |
| 134 | + sourceProjection.runs.map((run) => [run.provider, run.status]), | |
| 135 | + [["grok", "completed"]], | |
| 136 | + ); | |
| 137 | + assert.deepEqual( | |
| 138 | + targetProjection.runs.map((run) => [run.provider, run.status]), | |
| 139 | + [["grok", "completed"]], | |
| 140 | + ); | |
| 141 | + assert.deepEqual( | |
| 142 | + targetProjection.contextTransfers.map((transfer) => [ | |
| 143 | + transfer.type, | |
| 144 | + transfer.status, | |
| 145 | + transfer.resolution?.strategy, | |
| 146 | + ]), | |
| 147 | + [["fork", "consumed", "portable_context"]], | |
| 148 | + ); | |
| 149 | + assert.deepEqual( | |
| 150 | + targetProjection.contextHandoffs.map((handoff) => handoff.strategy), | |
| 151 | + ["full_thread_summary"], | |
| 152 | + ); | |
| 153 | + assert.include(targetProjection.contextHandoffs[0]?.summaryText ?? "", marker); | |
| 154 | + assert.include(assistantText(targetProjection), marker); | |
| 155 | + }).pipe(Effect.provide(liveLayer), Effect.scoped), | |
| 156 | + 360_000, | |
| 157 | + ); | |
| 158 | +}); | |
| @@ -1,5 +1,9 @@ | ||
| 1 | 1 | import type { ProviderDriverKind } from "@t3tools/contracts"; |
| 2 | 2 | |
| 3 | +import { | |
| 4 | + AcpRegistryAdapterV2Driver, | |
| 5 | + type AcpRegistryAdapterV2DriverEnv, | |
| 6 | +} from "./Adapters/AcpRegistryAdapterV2.ts"; | |
| 3 | 7 | import { |
| 4 | 8 | ClaudeAdapterV2Driver, |
| 5 | 9 | type ClaudeAdapterV2DriverEnv, |
| @@ -9,16 +13,31 @@ import { · 3 unmodified lines | ||
| 9 | 13 | CursorAdapterV2Driver, |
| 10 | 14 | type CursorAdapterV2DriverEnv, |
| 11 | 15 | } from "./Adapters/CursorAdapterV2.ts"; |
| 16 | +import { GrokAdapterV2Driver, type GrokAdapterV2DriverEnv } from "./Adapters/GrokAdapterV2.ts"; | |
| 17 | +import { | |
| 18 | + OpenCodeAdapterV2Driver, | |
| 19 | + type OpenCodeAdapterV2DriverEnv, | |
| 20 | +} from "./Adapters/OpenCodeAdapterV2.ts"; | |
| 12 | 21 | import type { AnyProviderAdapterDriver } from "./ProviderAdapterDriver.ts"; |
| 13 | 22 | |
| 14 | 23 | export type BuiltInProviderAdapterDriversV2Env = |
| 24 | + | AcpRegistryAdapterV2DriverEnv | |
| 15 | 25 | | ClaudeAdapterV2DriverEnv |
| 16 | 26 | | CodexAdapterV2DriverEnv |
| 17 | − | CursorAdapterV2DriverEnv; | |
| 27 | + | CursorAdapterV2DriverEnv | |
| 28 | + | GrokAdapterV2DriverEnv | |
| 29 | + | OpenCodeAdapterV2DriverEnv; | |
| 18 | 30 | |
| 19 | 31 | export const BUILT_IN_PROVIDER_ADAPTER_DRIVERS_V2: ReadonlyArray< |
| 20 | 32 | AnyProviderAdapterDriver<BuiltInProviderAdapterDriversV2Env> |
| 21 | −> = [CodexAdapterV2Driver, ClaudeAdapterV2Driver, CursorAdapterV2Driver]; | |
| 33 | +> = [ | |
| 34 | + CodexAdapterV2Driver, | |
| 35 | + ClaudeAdapterV2Driver, | |
| 36 | + CursorAdapterV2Driver, | |
| 37 | + OpenCodeAdapterV2Driver, | |
| 38 | + GrokAdapterV2Driver, | |
| 39 | + AcpRegistryAdapterV2Driver, | |
| 40 | +]; | |
| 22 | 41 | |
| 23 | 42 | export const BUILT_IN_PROVIDER_ADAPTER_DRIVER_KINDS_V2: ReadonlySet<ProviderDriverKind> = new Set( |
| 24 | 43 | BUILT_IN_PROVIDER_ADAPTER_DRIVERS_V2.map((driver) => driver.driverKind), |
| @@ -1,5 +1,7 @@ | ||
| 1 | 1 | import * as Layer from "effect/Layer"; |
| 2 | +import { FetchHttpClient } from "effect/unstable/http"; | |
| 2 | 3 | |
| 4 | +import { OpenCodeRuntimeLive } from "../provider/opencodeRuntime.ts"; | |
| 3 | 5 | import { claudeAgentSdkQueryRunnerLiveLayer } from "./Adapters/ClaudeAdapterV2.ts"; |
| 4 | 6 | import { codexAppServerClientFactoryFromSettingsLayer } from "./Adapters/CodexAdapterV2.ts"; |
| 5 | 7 | import { cursorAgentSdkRunnerLiveLayer } from "./Adapters/CursorAgentSdk.ts"; |
| @@ -40,6 +42,8 @@ const providerAdapterRegistryProvided = providerAdapterRegistryLayerFromSettings · 34 unmodified lines | ||
| 40 | 42 | Layer.provide(codexAppServerClientFactoryFromSettingsLayer), |
| 41 | 43 | Layer.provide(claudeAgentSdkQueryRunnerLiveLayer), |
| 42 | 44 | Layer.provide(cursorAgentSdkRunnerLiveLayer), |
| 45 | + Layer.provide(OpenCodeRuntimeLive), | |
| 46 | + Layer.provide(FetchHttpClient.layer), | |
| 43 | 47 | Layer.provide(idAllocatorLayer), |
| 44 | 48 | ); |
| 45 | 49 | |
| @@ -49,7 +49,15 @@ describe("orchestrator replay fixture contract", () => { · 48 unmodified lines | ||
| 49 | 49 | const firstCommand = materialized.commands[0]; |
| 50 | 50 | |
| 51 | 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 | 61 | assert.equal(provider.modelSelection.instanceId, provider.provider); |
| 54 | 62 | assert.isDefined(materialized.projectionThreadIds[0]); |
| 55 | 63 | assert.equal(firstCommand?.type, "thread.create"); |
| @@ -7,6 +7,9 @@ import * as FileSystem from "effect/FileSystem"; · 6 unmodified lines | ||
| 7 | 7 | import { ClaudeOrchestratorReplayHarness } from "../Adapters/ClaudeAdapterV2.testkit.ts"; |
| 8 | 8 | import { CodexOrchestratorReplayHarness } from "../Adapters/CodexAdapterV2.testkit.ts"; |
| 9 | 9 | import { CursorOrchestratorReplayHarness } from "../Adapters/CursorAdapterV2.testkit.ts"; |
| 10 | +import { AcpRegistryOrchestratorReplayHarness } from "../Adapters/AcpRegistryAdapterV2.testkit.ts"; | |
| 11 | +import { GrokOrchestratorReplayHarness } from "../Adapters/GrokAdapterV2.testkit.ts"; | |
| 12 | +import { OpenCodeOrchestratorReplayHarness } from "../Adapters/OpenCodeAdapterV2.testkit.ts"; | |
| 10 | 13 | import { layer as idAllocatorLayer } from "../IdAllocator.ts"; |
| 11 | 14 | import { provideDeterministicTestRuntime } from "./DeterministicRuntime.ts"; |
| 12 | 15 | import { ORCHESTRATOR_REPLAY_FIXTURES } from "./fixtures/index.ts"; |
| @@ -118,6 +121,21 @@ function runFixtureProviderWithRegisteredHarness(input: { · 105 unmodified lines | ||
| 118 | 121 | ...input, |
| 119 | 122 | harness: CursorOrchestratorReplayHarness, |
| 120 | 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 | 139 | default: |
| 122 | 140 | return Effect.die( |
| 123 | 141 | new Error(`No replay harness registered for provider ${input.provider.provider}.`), |
| @@ -41,7 +41,7 @@ import { · 40 unmodified lines | ||
| 41 | 41 | type OrchestratorV2ScenarioResult, |
| 42 | 42 | } from "./OrchestratorScenario.ts"; |
| 43 | 43 | |
| 44 | −function makeReplayServerConfig( | |
| 44 | +export function makeReplayServerConfig( | |
| 45 | 45 | scenario: string, |
| 46 | 46 | ): Effect.Effect< |
| 47 | 47 | ServerConfigShape, |
| @@ -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"} | |
| @@ -1,11 +1,15 @@ | ||
| 1 | 1 | import { assertClaudeMessageSteeringOutput } from "./message_steering/claude_output.ts"; |
| 2 | 2 | import { assertMessageSteeringOutput } from "./message_steering/codex_output.ts"; |
| 3 | 3 | import { assertCursorMessageSteeringOutput } from "./message_steering/cursor_output.ts"; |
| 4 | +import { assertGrokMessageSteeringOutput } from "./message_steering/grok_output.ts"; | |
| 4 | 5 | import { messageSteeringInput } from "./message_steering/input.ts"; |
| 5 | 6 | import { assertMultiTurnClaudeOutput } from "./multi_turn/claude_output.ts"; |
| 6 | 7 | import { assertMultiTurnOutput } from "./multi_turn/codex_output.ts"; |
| 7 | 8 | import { multiTurnInput } from "./multi_turn/input.ts"; |
| 9 | +import { openCodeSubagentInput } from "./opencode_subagent/input.ts"; | |
| 10 | +import { assertOpenCodeSubagentOutput } from "./opencode_subagent/output.ts"; | |
| 8 | 11 | import { assertPlanQuestionsOutput } from "./plan_questions/codex_output.ts"; |
| 12 | +import { assertOpenCodePlanQuestionsOutput } from "./plan_questions/opencode_output.ts"; | |
| 9 | 13 | import { planQuestionsInput } from "./plan_questions/input.ts"; |
| 10 | 14 | import { assertProposedPlanOutput } from "./proposed_plan/codex_output.ts"; |
| 11 | 15 | import { assertProposedPlanCursorOutput } from "./proposed_plan/cursor_output.ts"; |
| @@ -26,6 +30,7 @@ import { assertThreadRollbackOutput } from "./thread_rollback/codex_output.ts"; · 14 unmodified lines | ||
| 26 | 30 | import { threadRollbackInput } from "./thread_rollback/input.ts"; |
| 27 | 31 | import { assertTodoListOutput } from "./todo_list/codex_output.ts"; |
| 28 | 32 | import { assertTodoListCursorOutput } from "./todo_list/cursor_output.ts"; |
| 33 | +import { assertTodoListGrokOutput } from "./todo_list/grok_output.ts"; | |
| 29 | 34 | import { todoListInput } from "./todo_list/input.ts"; |
| 30 | 35 | import { assertToolCallReadOnlyClaudeOutput } from "./tool_call_read_only/claude_output.ts"; |
| 31 | 36 | import { assertToolCallReadOnlyCursorOutput } from "./tool_call_read_only/cursor_output.ts"; |
| @@ -52,9 +57,12 @@ import { assertClaudeWebSearchOutput } from "./web_search/claude_output.ts"; · 20 unmodified lines | ||
| 52 | 57 | import { assertWebSearchOutput } from "./web_search/codex_output.ts"; |
| 53 | 58 | import { webSearchInput } from "./web_search/input.ts"; |
| 54 | 59 | import { |
| 60 | + ACP_REGISTRY_MODEL_SELECTION, | |
| 55 | 61 | CLAUDE_MODEL_SELECTION, |
| 56 | 62 | CODEX_MODEL_SELECTION, |
| 57 | 63 | CURSOR_MODEL_SELECTION, |
| 64 | + GROK_MODEL_SELECTION, | |
| 65 | + OPENCODE_MODEL_SELECTION, | |
| 58 | 66 | READ_ONLY_NEVER_POLICY, |
| 59 | 67 | READ_ONLY_ON_REQUEST_POLICY, |
| 60 | 68 | RESTRICTED_GRANULAR_POLICY, |
| @@ -63,6 +71,26 @@ import { · 2 unmodified lines | ||
| 63 | 71 | } from "./shared.ts"; |
| 64 | 72 | |
| 65 | 73 | export const ORCHESTRATOR_REPLAY_FIXTURES = [ |
| 74 | + { | |
| 75 | + name: "acp_elicitation", | |
| 76 | + buildInput: planQuestionsInput, | |
| 77 | + providers: [ | |
| 78 | + { | |
| 79 | + provider: "grok", | |
| 80 | + transcriptFile: new URL("./acp_elicitation/grok_transcript.ndjson", import.meta.url), | |
| 81 | + modelSelection: GROK_MODEL_SELECTION, | |
| 82 | + runtimePolicyOverride: READ_ONLY_NEVER_POLICY, | |
| 83 | + assertOutput: assertPlanQuestionsOutput, | |
| 84 | + }, | |
| 85 | + { | |
| 86 | + provider: "acpRegistry", | |
| 87 | + transcriptFile: new URL("./acp_elicitation/grok_transcript.ndjson", import.meta.url), | |
| 88 | + modelSelection: ACP_REGISTRY_MODEL_SELECTION, | |
| 89 | + runtimePolicyOverride: READ_ONLY_NEVER_POLICY, | |
| 90 | + assertOutput: assertPlanQuestionsOutput, | |
| 91 | + }, | |
| 92 | + ], | |
| 93 | + }, | |
| 66 | 94 | { |
| 67 | 95 | name: "simple", |
| 68 | 96 | buildInput: simpleInput, |
| @@ -85,6 +113,24 @@ export const ORCHESTRATOR_REPLAY_FIXTURES = [ · 16 unmodified lines | ||
| 85 | 113 | modelSelection: CURSOR_MODEL_SELECTION, |
| 86 | 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 | 151 | runtimePolicyOverride: READ_ONLY_NEVER_POLICY, |
| 106 | 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 | 191 | runtimePolicyOverride: READ_ONLY_ON_REQUEST_POLICY, |
| 132 | 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 | 319 | name: "multi_turn", |
| 228 | 320 | buildInput: multiTurnInput, |
| @@ -245,6 +337,18 @@ export const ORCHESTRATOR_REPLAY_FIXTURES = [ · 16 unmodified lines | ||
| 245 | 337 | modelSelection: CURSOR_MODEL_SELECTION, |
| 246 | 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 | 385 | modelSelection: CURSOR_MODEL_SELECTION, |
| 282 | 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 | 417 | runtimePolicyOverride: READ_ONLY_NEVER_POLICY, |
| 302 | 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 | 460 | runtimePolicyOverride: READ_ONLY_NEVER_POLICY, |
| 333 | 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 | 518 | modelSelection: CURSOR_MODEL_SELECTION, |
| 377 | 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 | 550 | runtimePolicyOverride: WORKSPACE_NEVER_POLICY, |
| 397 | 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 | { |
| @@ -0,0 +1,54 @@ | ||
| 1 | +import { assert } from "@effect/vitest"; | |
| 2 | +import type { ProviderReplayTranscript } from "@t3tools/contracts"; | |
| 3 | + | |
| 4 | +import type { OrchestratorV2ScenarioResult } from "../../OrchestratorScenario.ts"; | |
| 5 | +import { | |
| 6 | + assertAssistantTextIncludes, | |
| 7 | + assertBaseProjection, | |
| 8 | + assertRuntimeRequestCounts, | |
| 9 | + assertSemanticProjectionIntegrity, | |
| 10 | + assertTurnItemTypes, | |
| 11 | + assertUserMessageInputIntents, | |
| 12 | + assertUserMessagesInclude, | |
| 13 | + MESSAGE_STEERING_INITIAL_PROMPT, | |
| 14 | + MESSAGE_STEERING_STEER_PROMPT, | |
| 15 | + projectionFor, | |
| 16 | +} from "../shared.ts"; | |
| 17 | + | |
| 18 | +export function assertGrokMessageSteeringOutput( | |
| 19 | + result: OrchestratorV2ScenarioResult, | |
| 20 | + transcript: ProviderReplayTranscript, | |
| 21 | +) { | |
| 22 | + assertBaseProjection({ result, transcript, runCount: 1, runStatuses: ["completed"] }); | |
| 23 | + | |
| 24 | + const projection = projectionFor(result, transcript.scenario); | |
| 25 | + assertSemanticProjectionIntegrity(projection); | |
| 26 | + assertTurnItemTypes(projection, ["user_message", "run_interrupt_result", "assistant_message"]); | |
| 27 | + assertRuntimeRequestCounts(projection, { total: 0 }); | |
| 28 | + assertUserMessagesInclude(projection, [ | |
| 29 | + MESSAGE_STEERING_INITIAL_PROMPT, | |
| 30 | + MESSAGE_STEERING_STEER_PROMPT, | |
| 31 | + ]); | |
| 32 | + assertUserMessageInputIntents(projection, ["turn_start", "steer"]); | |
| 33 | + assertAssistantTextIncludes(projection, "steering fixture observed"); | |
| 34 | + | |
| 35 | + assert.lengthOf(projection.runs, 1, "steering must preserve the app run"); | |
| 36 | + assert.deepEqual( | |
| 37 | + projection.attempts.map((attempt) => [attempt.reason, attempt.status]), | |
| 38 | + [ | |
| 39 | + ["initial", "superseded"], | |
| 40 | + ["steering_restart", "completed"], | |
| 41 | + ], | |
| 42 | + ); | |
| 43 | + assert.deepEqual( | |
| 44 | + projection.providerTurns.map((turn) => turn.status), | |
| 45 | + ["interrupted", "completed"], | |
| 46 | + ); | |
| 47 | + assert.equal(projection.runs[0]?.activeAttemptId, projection.attempts[1]?.id); | |
| 48 | + assert.equal(projection.runs[0]?.rootNodeId, projection.attempts[1]?.rootNodeId); | |
| 49 | + assert.notInclude( | |
| 50 | + projection.visibleTurnItems.map((row) => row.item.type), | |
| 51 | + "run_interrupt_result", | |
| 52 | + "restart steering must keep its internal interruption out of visible chat history", | |
| 53 | + ); | |
| 54 | +} | |
| @@ -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"} | |
| @@ -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"} | |
| @@ -0,0 +1,7 @@ | ||
| 1 | +import { OPENCODE_SUBAGENT_PROMPT, type OrchestratorFixtureInput } from "../shared.ts"; | |
| 2 | + | |
| 3 | +export function openCodeSubagentInput(): OrchestratorFixtureInput { | |
| 4 | + return { | |
| 5 | + steps: [{ type: "message", text: OPENCODE_SUBAGENT_PROMPT }], | |
| 6 | + }; | |
| 7 | +} | |
| @@ -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"} | |
| @@ -0,0 +1,71 @@ | ||
| 1 | +import { assert } from "@effect/vitest"; | |
| 2 | +import type { ProviderReplayTranscript } from "@t3tools/contracts"; | |
| 3 | + | |
| 4 | +import type { OrchestratorV2ScenarioResult } from "../../OrchestratorScenario.ts"; | |
| 5 | +import { | |
| 6 | + assertAssistantTextIncludes, | |
| 7 | + assertBaseProjection, | |
| 8 | + assertExecutionNodeKinds, | |
| 9 | + assertNoExtraAppRunsForProviderChildren, | |
| 10 | + assertRunProviderTurnCardinality, | |
| 11 | + assertSemanticProjectionIntegrity, | |
| 12 | + assertTurnItemTypes, | |
| 13 | + assertUserMessagesInclude, | |
| 14 | + OPENCODE_SUBAGENT_PROMPT, | |
| 15 | + projectionFor, | |
| 16 | +} from "../shared.ts"; | |
| 17 | + | |
| 18 | +export function assertOpenCodeSubagentOutput( | |
| 19 | + result: OrchestratorV2ScenarioResult, | |
| 20 | + transcript: ProviderReplayTranscript, | |
| 21 | +) { | |
| 22 | + assertBaseProjection({ result, transcript, runCount: 1, runStatuses: ["completed"] }); | |
| 23 | + | |
| 24 | + const projection = projectionFor(result, transcript.scenario); | |
| 25 | + assertSemanticProjectionIntegrity(projection); | |
| 26 | + assertExecutionNodeKinds(projection, ["root_turn", "subagent", "assistant_message"]); | |
| 27 | + assertTurnItemTypes(projection, ["user_message", "subagent", "assistant_message"]); | |
| 28 | + assertRunProviderTurnCardinality({ projection, rootRunCount: 1 }); | |
| 29 | + assertNoExtraAppRunsForProviderChildren({ projection, expectedAppRuns: 1 }); | |
| 30 | + assertUserMessagesInclude(projection, [OPENCODE_SUBAGENT_PROMPT]); | |
| 31 | + assertAssistantTextIncludes(projection, "PARENT_OK"); | |
| 32 | + | |
| 33 | + assert.lengthOf(projection.subagents, 1); | |
| 34 | + assert.lengthOf(result.shellSnapshot.threads, 2); | |
| 35 | + const subagent = projection.subagents[0]; | |
| 36 | + assert.isDefined(subagent); | |
| 37 | + assert.equal(subagent.origin, "provider_native"); | |
| 38 | + assert.equal(subagent.createdBy, "agent"); | |
| 39 | + assert.equal(subagent.provider, "opencode"); | |
| 40 | + assert.equal(subagent.status, "completed"); | |
| 41 | + assert.equal(subagent.prompt, "Respond exactly CHILD_OK."); | |
| 42 | + assert.include(subagent.result ?? "", "CHILD_OK"); | |
| 43 | + assert.isNotNull(subagent.childThreadId); | |
| 44 | + assert.isNotNull(subagent.providerThreadId); | |
| 45 | + assert.isNotNull(subagent.nativeTaskRef); | |
| 46 | + assert.isNotNull(subagent.completedAt); | |
| 47 | + if (subagent.childThreadId === null || subagent.providerThreadId === null) { | |
| 48 | + throw new Error("OpenCode subagent is missing its child thread identity"); | |
| 49 | + } | |
| 50 | + | |
| 51 | + const providerThread = projection.providerThreads.find( | |
| 52 | + (thread) => thread.id === subagent.providerThreadId, | |
| 53 | + ); | |
| 54 | + assert.isDefined(providerThread); | |
| 55 | + assert.equal(providerThread.appThreadId, subagent.childThreadId); | |
| 56 | + assert.equal(providerThread.ownerNodeId, subagent.id); | |
| 57 | + | |
| 58 | + const childProjection = result.projections.get(subagent.childThreadId); | |
| 59 | + assert.isDefined(childProjection); | |
| 60 | + assert.equal(childProjection.thread.lineage.parentThreadId, projection.thread.id); | |
| 61 | + assert.equal(childProjection.thread.lineage.relationshipToParent, "subagent"); | |
| 62 | + assert.equal(childProjection.thread.activeProviderThreadId, providerThread.id); | |
| 63 | + assert.lengthOf(childProjection.runs, 0); | |
| 64 | + assert.lengthOf(childProjection.providerThreads, 1); | |
| 65 | + assert.lengthOf(childProjection.providerTurns, 1); | |
| 66 | + assert.equal(childProjection.providerTurns[0]?.status, "completed"); | |
| 67 | + assertExecutionNodeKinds(childProjection, ["root_turn", "assistant_message"]); | |
| 68 | + assertTurnItemTypes(childProjection, ["user_message", "assistant_message"]); | |
| 69 | + assertUserMessagesInclude(childProjection, ["Respond exactly CHILD_OK."]); | |
| 70 | + assertAssistantTextIncludes(childProjection, "CHILD_OK"); | |
| 71 | +} | |
| @@ -18,7 +18,7 @@ import { · 17 unmodified lines | ||
| 18 | 18 | projectionFor, |
| 19 | 19 | } from "../shared.ts"; |
| 20 | 20 | |
| 21 | −export function assertPlanQuestionsOutput( | |
| 21 | +export function assertPlanQuestionsOutputBase( | |
| 22 | 22 | result: OrchestratorV2ScenarioResult, |
| 23 | 23 | transcript: ProviderReplayTranscript, |
| 24 | 24 | ) { |
| @@ -35,7 +35,15 @@ export function assertPlanQuestionsOutput( · 10 unmodified lines | ||
| 35 | 35 | assertTurnItemTypes(projection, ["user_message", "user_input_request", "assistant_message"]); |
| 36 | 36 | assertUserMessagesInclude(projection, [PLAN_QUESTIONS_PROMPT]); |
| 37 | 37 | assertAssistantTextIncludes(projection, "plan questions fixture complete"); |
| 38 | +} | |
| 39 | + | |
| 40 | +export function assertPlanQuestionsOutput( | |
| 41 | + result: OrchestratorV2ScenarioResult, | |
| 42 | + transcript: ProviderReplayTranscript, | |
| 43 | +) { | |
| 44 | + assertPlanQuestionsOutputBase(result, transcript); | |
| 38 | 45 | |
| 46 | + const projection = projectionFor(result, transcript.scenario); | |
| 39 | 47 | const requestItem = projection.turnItems.find((item) => item.type === "user_input_request"); |
| 40 | 48 | assert.isDefined(requestItem); |
| 41 | 49 | assert.equal(requestItem?.questions[0]?.id, "schema_vs_ui_flexibility"); |
| @@ -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"} | |
| @@ -9,6 +9,7 @@ export function planQuestionsInput(): OrchestratorFixtureInput { · 8 unmodified lines | ||
| 9 | 9 | type: "answer_next_user_input_request", |
| 10 | 10 | answers: { |
| 11 | 11 | schema_vs_ui_flexibility: "Strict schemas (Recommended)", |
| 12 | + "question-0-schema-vs-flexibility": "Strict schemas", | |
| 12 | 13 | }, |
| 13 | 14 | }, |
| 14 | 15 | ], |
| @@ -0,0 +1,26 @@ | ||
| 1 | +import { assert } from "@effect/vitest"; | |
| 2 | +import type { ProviderReplayTranscript } from "@t3tools/contracts"; | |
| 3 | + | |
| 4 | +import type { OrchestratorV2ScenarioResult } from "../../OrchestratorScenario.ts"; | |
| 5 | +import { assertPlanQuestionsOutputBase } from "./codex_output.ts"; | |
| 6 | +import { projectionFor } from "../shared.ts"; | |
| 7 | + | |
| 8 | +export function assertOpenCodePlanQuestionsOutput( | |
| 9 | + result: OrchestratorV2ScenarioResult, | |
| 10 | + transcript: ProviderReplayTranscript, | |
| 11 | +) { | |
| 12 | + // The shared assertions cover the runtime-request lifecycle. OpenCode owns | |
| 13 | + // its question identifiers, so assert its normalized native header instead | |
| 14 | + // of Codex's fixture-specific id. | |
| 15 | + assertPlanQuestionsOutputBase(result, transcript); | |
| 16 | + const projection = projectionFor(result, transcript.scenario); | |
| 17 | + const requestItem = projection.turnItems.find((item) => item.type === "user_input_request"); | |
| 18 | + assert.equal(requestItem?.questions[0]?.id, "question-0-schema-vs-flexibility"); | |
| 19 | + assert.equal(requestItem?.status, "completed"); | |
| 20 | + assert.lengthOf( | |
| 21 | + projection.turnItems.filter( | |
| 22 | + (item) => item.type === "dynamic_tool" && item.toolName === "question", | |
| 23 | + ), | |
| 24 | + 0, | |
| 25 | + ); | |
| 26 | +} | |
| @@ -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"} | |
| @@ -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"} | |
| @@ -42,6 +42,8 @@ export const MESSAGE_STEERING_INITIAL_PROMPT = · 41 unmodified lines | ||
| 42 | 42 | "Respond with exactly: steering fixture initial response"; |
| 43 | 43 | export const SUBAGENT_PROMPT = |
| 44 | 44 | "Spawn 2 subagents, one to read package.json and one to read tsconfig.json"; |
| 45 | +export const OPENCODE_SUBAGENT_PROMPT = | |
| 46 | + "Use the task tool exactly once. Delegate to the general subagent with this prompt: Respond exactly CHILD_OK. After the task completes, respond exactly PARENT_OK."; | |
| 45 | 47 | export const SUBAGENT_CONTINUE_PROMPT = |
| 46 | 48 | "Spawn one subagent and have it reply exactly: initial subagent response"; |
| 47 | 49 | export const SUBAGENT_CONTINUE_PARENT_PROMPT = |
| @@ -237,6 +239,22 @@ export const CURSOR_MODEL_SELECTION = { · 189 unmodified lines | ||
| 237 | 239 | model: "composer-2.5", |
| 238 | 240 | } satisfies ModelSelection; |
| 239 | 241 | |
| 242 | +export const GROK_MODEL_SELECTION = { | |
| 243 | + instanceId: ProviderInstanceId.make("grok"), | |
| 244 | + model: "grok-build", | |
| 245 | +} satisfies ModelSelection; | |
| 246 | + | |
| 247 | +export const OPENCODE_MODEL_SELECTION = { | |
| 248 | + instanceId: ProviderInstanceId.make("opencode"), | |
| 249 | + model: "openai/gpt-5.4-mini", | |
| 250 | + options: [{ id: "agent", value: "build" }], | |
| 251 | +} satisfies ModelSelection; | |
| 252 | + | |
| 253 | +export const ACP_REGISTRY_MODEL_SELECTION = { | |
| 254 | + instanceId: ProviderInstanceId.make("acpRegistry"), | |
| 255 | + model: "default", | |
| 256 | +} satisfies ModelSelection; | |
| 257 | + | |
| 240 | 258 | export const READ_ONLY_ON_REQUEST_POLICY = { |
| 241 | 259 | approvalPolicy: "on-request", |
| 242 | 260 | sandboxPolicy: { |
| @@ -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"} | |
| @@ -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"} | |
| @@ -0,0 +1,49 @@ | ||
| 1 | +import { assert } from "@effect/vitest"; | |
| 2 | +import type { ProviderReplayTranscript } from "@t3tools/contracts"; | |
| 3 | + | |
| 4 | +import type { OrchestratorV2ScenarioResult } from "../../OrchestratorScenario.ts"; | |
| 5 | +import { | |
| 6 | + assertAssistantTextIncludes, | |
| 7 | + assertBaseProjection, | |
| 8 | + assertExecutionNodeKinds, | |
| 9 | + assertSemanticProjectionIntegrity, | |
| 10 | + assertTurnItemTypes, | |
| 11 | + assertUserMessagesInclude, | |
| 12 | + assertVisibleTurnItemsMirrorLocalTurnItems, | |
| 13 | + projectionFor, | |
| 14 | + TODO_LIST_PROMPT, | |
| 15 | +} from "../shared.ts"; | |
| 16 | + | |
| 17 | +export function assertTodoListGrokOutput( | |
| 18 | + result: OrchestratorV2ScenarioResult, | |
| 19 | + transcript: ProviderReplayTranscript, | |
| 20 | +) { | |
| 21 | + assertBaseProjection({ result, transcript, runCount: 1, runStatuses: ["completed"] }); | |
| 22 | + | |
| 23 | + const projection = projectionFor(result, transcript.scenario); | |
| 24 | + assertSemanticProjectionIntegrity(projection); | |
| 25 | + assertVisibleTurnItemsMirrorLocalTurnItems(projection); | |
| 26 | + assertExecutionNodeKinds(projection, ["root_turn", "todo_list", "tool_call"]); | |
| 27 | + assertTurnItemTypes(projection, [ | |
| 28 | + "user_message", | |
| 29 | + "todo_list", | |
| 30 | + "file_search", | |
| 31 | + "assistant_message", | |
| 32 | + ]); | |
| 33 | + assertUserMessagesInclude(projection, [TODO_LIST_PROMPT]); | |
| 34 | + assertAssistantTextIncludes(projection, "todo list fixture complete"); | |
| 35 | + | |
| 36 | + const planEvents = result.domainEvents.filter((event) => event.type === "plan.updated"); | |
| 37 | + assert.lengthOf(planEvents, 2); | |
| 38 | + assert.lengthOf( | |
| 39 | + new Set(planEvents.map((event) => (event.type === "plan.updated" ? event.payload.id : null))), | |
| 40 | + 1, | |
| 41 | + "ACP plan updates must preserve one plan identity", | |
| 42 | + ); | |
| 43 | + const todoLists = projection.plans.filter((plan) => plan.kind === "todo_list"); | |
| 44 | + assert.isAtLeast(todoLists.length, 1); | |
| 45 | + assert.deepEqual( | |
| 46 | + todoLists.at(-1)?.steps.map((step) => step.status), | |
| 47 | + ["completed", "completed", "completed"], | |
| 48 | + ); | |
| 49 | +} | |
| @@ -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"} | |
| @@ -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"} | |
| @@ -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"} | |
| @@ -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"} | |
| @@ -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"} | |
| @@ -10,7 +10,11 @@ import * as Effect from "effect/Effect"; · 9 unmodified lines | ||
| 10 | 10 | import * as Stream from "effect/Stream"; |
| 11 | 11 | import { describe, expect } from "vite-plus/test"; |
| 12 | 12 | |
| 13 | −import { AcpSessionRuntime, type AcpSessionRequestLogEvent } from "./AcpSessionRuntime.ts"; | |
| 13 | +import { | |
| 14 | + AcpSessionRuntime, | |
| 15 | + selectAcpAgentAuthMethod, | |
| 16 | + type AcpSessionRequestLogEvent, | |
| 17 | +} from "./AcpSessionRuntime.ts"; | |
| 14 | 18 | import type * as EffectAcpProtocol from "effect-acp/protocol"; |
| 15 | 19 | |
| 16 | 20 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); |
| @@ -19,6 +23,17 @@ const mockAgentCommand = "node"; · 2 unmodified lines | ||
| 19 | 23 | const mockAgentArgs = [mockAgentPath]; |
| 20 | 24 | |
| 21 | 25 | describe("AcpSessionRuntime", () => { |
| 26 | + it("selects explicit or agent-managed authentication without choosing terminal auth", () => { | |
| 27 | + const methods = [ | |
| 28 | + { id: "browser", name: "Browser", type: "terminal" as const }, | |
| 29 | + { id: "api-key", name: "API key" }, | |
| 30 | + ]; | |
| 31 | + | |
| 32 | + expect(selectAcpAgentAuthMethod(methods)?.id).toBe("api-key"); | |
| 33 | + expect(selectAcpAgentAuthMethod(methods, "browser")?.id).toBe("browser"); | |
| 34 | + expect(selectAcpAgentAuthMethod(methods, "missing")).toBeUndefined(); | |
| 35 | + }); | |
| 36 | + | |
| 22 | 37 | it.effect("merges custom initialize client capabilities into the ACP handshake", () => { |
| 23 | 38 | const requestEvents: Array<AcpSessionRequestLogEvent> = []; |
| 24 | 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 | 156 | it.effect("starts a session, prompts, and emits normalized events against the mock agent", () => |
| 66 | 157 | Effect.gen(function* () { |
| 67 | 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 | 415 | it.effect("skips no-op session config writes when the requested value is already active", () => { |
| 268 | 416 | const requestEvents: Array<AcpSessionRequestLogEvent> = []; |
| 269 | 417 | return Effect.gen(function* () { |
| @@ -0,0 +1,263 @@ | ||
| 1 | +import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | +import { describe, expect, it } from "@effect/vitest"; | |
| 3 | +import { AcpRegistrySettings } from "@t3tools/contracts"; | |
| 4 | +import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 5 | +import * as Effect from "effect/Effect"; | |
| 6 | +import * as FileSystem from "effect/FileSystem"; | |
| 7 | +import * as Layer from "effect/Layer"; | |
| 8 | +import * as Schema from "effect/Schema"; | |
| 9 | +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; | |
| 10 | + | |
| 11 | +import { | |
| 12 | + makeAcpRegistryResolver, | |
| 13 | + resolveAcpRegistryDistribution, | |
| 14 | + resolveAcpRegistryPlatformTarget, | |
| 15 | + type AcpRegistryAgent, | |
| 16 | +} from "./AcpRegistrySupport.ts"; | |
| 17 | + | |
| 18 | +const registryUrl = "https://registry.test/registry.json"; | |
| 19 | +const archiveUrl = "https://registry.test/example-agent.bin"; | |
| 20 | +const decodeAcpRegistrySettings = Schema.decodeSync(AcpRegistrySettings); | |
| 21 | + | |
| 22 | +function makeAgent(distribution: AcpRegistryAgent["distribution"]): AcpRegistryAgent { | |
| 23 | + return { | |
| 24 | + id: "example-agent", | |
| 25 | + name: "Example Agent", | |
| 26 | + version: "1.2.3", | |
| 27 | + description: "ACP Registry test agent", | |
| 28 | + distribution, | |
| 29 | + }; | |
| 30 | +} | |
| 31 | + | |
| 32 | +function makeRegistry(agent: AcpRegistryAgent): string { | |
| 33 | + return JSON.stringify({ version: "1.0.0", agents: [agent] }); | |
| 34 | +} | |
| 35 | + | |
| 36 | +function settings(input: Partial<AcpRegistrySettings> = {}): AcpRegistrySettings { | |
| 37 | + return decodeAcpRegistrySettings({ | |
| 38 | + agentId: "example-agent", | |
| 39 | + ...input, | |
| 40 | + }); | |
| 41 | +} | |
| 42 | + | |
| 43 | +function resolverLayer(execute: Parameters<typeof HttpClient.make>[0]) { | |
| 44 | + return Layer.mergeAll( | |
| 45 | + NodeServices.layer, | |
| 46 | + Layer.succeed(HostProcessPlatform, "linux"), | |
| 47 | + Layer.succeed(HostProcessArchitecture, "x64"), | |
| 48 | + Layer.succeed(HttpClient.HttpClient, HttpClient.make(execute)), | |
| 49 | + ); | |
| 50 | +} | |
| 51 | + | |
| 52 | +describe("AcpRegistrySupport", () => { | |
| 53 | + it("maps supported Node platforms to ACP Registry target keys", () => { | |
| 54 | + expect(resolveAcpRegistryPlatformTarget("darwin", "arm64")).toBe("darwin-aarch64"); | |
| 55 | + expect(resolveAcpRegistryPlatformTarget("linux", "x64")).toBe("linux-x86_64"); | |
| 56 | + expect(resolveAcpRegistryPlatformTarget("win32", "arm64")).toBe("windows-aarch64"); | |
| 57 | + expect(resolveAcpRegistryPlatformTarget("freebsd", "x64")).toBeUndefined(); | |
| 58 | + expect(resolveAcpRegistryPlatformTarget("linux", "ia32")).toBeUndefined(); | |
| 59 | + }); | |
| 60 | + | |
| 61 | + it("selects the preferred compatible distribution", () => { | |
| 62 | + const agent = makeAgent({ | |
| 63 | + binary: { | |
| 64 | + "linux-x86_64": { | |
| 65 | + archive: archiveUrl, | |
| 66 | + cmd: "./bin/example-agent", | |
| 67 | + args: ["acp"], | |
| 68 | + }, | |
| 69 | + }, | |
| 70 | + npx: { | |
| 71 | + package: "@example/acp@1.2.3", | |
| 72 | + args: ["--stdio"], | |
| 73 | + }, | |
| 74 | + }); | |
| 75 | + | |
| 76 | + expect( | |
| 77 | + resolveAcpRegistryDistribution({ | |
| 78 | + agent, | |
| 79 | + preference: "auto", | |
| 80 | + platformTarget: "linux-x86_64", | |
| 81 | + }), | |
| 82 | + ).toMatchObject({ kind: "binary", args: ["acp"] }); | |
| 83 | + expect( | |
| 84 | + resolveAcpRegistryDistribution({ | |
| 85 | + agent, | |
| 86 | + preference: "npx", | |
| 87 | + platformTarget: "linux-x86_64", | |
| 88 | + }), | |
| 89 | + ).toEqual({ | |
| 90 | + kind: "npx", | |
| 91 | + packageName: "@example/acp@1.2.3", | |
| 92 | + args: ["--stdio"], | |
| 93 | + env: {}, | |
| 94 | + }); | |
| 95 | + expect( | |
| 96 | + resolveAcpRegistryDistribution({ | |
| 97 | + agent, | |
| 98 | + preference: "binary", | |
| 99 | + platformTarget: "darwin-aarch64", | |
| 100 | + }), | |
| 101 | + ).toBeUndefined(); | |
| 102 | + }); | |
| 103 | + | |
| 104 | + it.effect("resolves command overrides while preserving registry args and environment", () => { | |
| 105 | + const agent = makeAgent({ | |
| 106 | + binary: { | |
| 107 | + "linux-x86_64": { | |
| 108 | + archive: archiveUrl, | |
| 109 | + cmd: "./bin/example-agent", | |
| 110 | + args: ["acp", "--stdio"], | |
| 111 | + env: { REGISTRY_VALUE: "registry", OVERRIDE_ME: "registry" }, | |
| 112 | + }, | |
| 113 | + }, | |
| 114 | + }); | |
| 115 | + const requests: Array<string> = []; | |
| 116 | + return Effect.gen(function* () { | |
| 117 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 118 | + const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 119 | + prefix: "t3-acp-registry-override-", | |
| 120 | + }); | |
| 121 | + const resolver = yield* makeAcpRegistryResolver({ cacheDir, registryUrl }); | |
| 122 | + const resolved = yield* resolver.resolve( | |
| 123 | + settings({ commandPath: "/opt/example-agent" }), | |
| 124 | + "/workspace", | |
| 125 | + { HOST_VALUE: "host", OVERRIDE_ME: "host" }, | |
| 126 | + ); | |
| 127 | + | |
| 128 | + expect(resolved.distribution).toBe("binary"); | |
| 129 | + expect(resolved.spawn).toEqual({ | |
| 130 | + command: "/opt/example-agent", | |
| 131 | + args: ["acp", "--stdio"], | |
| 132 | + cwd: "/workspace", | |
| 133 | + env: { | |
| 134 | + HOST_VALUE: "host", | |
| 135 | + OVERRIDE_ME: "registry", | |
| 136 | + REGISTRY_VALUE: "registry", | |
| 137 | + }, | |
| 138 | + }); | |
| 139 | + expect(requests).toEqual([registryUrl]); | |
| 140 | + }).pipe( | |
| 141 | + Effect.scoped, | |
| 142 | + Effect.provide( | |
| 143 | + resolverLayer((request) => { | |
| 144 | + requests.push(request.url); | |
| 145 | + return Effect.succeed( | |
| 146 | + HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent))), | |
| 147 | + ); | |
| 148 | + }), | |
| 149 | + ), | |
| 150 | + ); | |
| 151 | + }); | |
| 152 | + | |
| 153 | + it.effect("installs and reuses a registry binary in the managed cache", () => { | |
| 154 | + const agent = makeAgent({ | |
| 155 | + binary: { | |
| 156 | + "linux-x86_64": { | |
| 157 | + archive: archiveUrl, | |
| 158 | + cmd: "./bin/example-agent", | |
| 159 | + args: ["acp"], | |
| 160 | + }, | |
| 161 | + }, | |
| 162 | + }); | |
| 163 | + const binaryBytes = new TextEncoder().encode("#!/bin/sh\necho example\n"); | |
| 164 | + const requests: Array<string> = []; | |
| 165 | + return Effect.gen(function* () { | |
| 166 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 167 | + const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 168 | + prefix: "t3-acp-registry-install-", | |
| 169 | + }); | |
| 170 | + const resolver = yield* makeAcpRegistryResolver({ cacheDir, registryUrl }); | |
| 171 | + const first = yield* resolver.resolve(settings(), "/workspace"); | |
| 172 | + const second = yield* resolver.resolve(settings(), "/workspace"); | |
| 173 | + | |
| 174 | + expect(first.spawn.command).toBe(second.spawn.command); | |
| 175 | + expect(first.spawn.command).toContain( | |
| 176 | + "/acp-registry/agents/example-agent/1.2.3/linux-x86_64/bin/example-agent", | |
| 177 | + ); | |
| 178 | + expect(yield* fileSystem.readFileString(first.spawn.command)).toBe( | |
| 179 | + "#!/bin/sh\necho example\n", | |
| 180 | + ); | |
| 181 | + expect(requests).toEqual([registryUrl, archiveUrl]); | |
| 182 | + }).pipe( | |
| 183 | + Effect.scoped, | |
| 184 | + Effect.provide( | |
| 185 | + resolverLayer((request) => { | |
| 186 | + requests.push(request.url); | |
| 187 | + const response = | |
| 188 | + request.url === registryUrl | |
| 189 | + ? new Response(makeRegistry(agent)) | |
| 190 | + : new Response(binaryBytes.buffer as ArrayBuffer); | |
| 191 | + return Effect.succeed(HttpClientResponse.fromWeb(request, response)); | |
| 192 | + }), | |
| 193 | + ), | |
| 194 | + ); | |
| 195 | + }); | |
| 196 | + | |
| 197 | + it.effect("falls back to a valid cached registry index when refresh fails", () => { | |
| 198 | + const agent = makeAgent({ | |
| 199 | + npx: { | |
| 200 | + package: "@example/acp@1.2.3", | |
| 201 | + args: ["--stdio"], | |
| 202 | + }, | |
| 203 | + }); | |
| 204 | + return Effect.gen(function* () { | |
| 205 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 206 | + const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 207 | + prefix: "t3-acp-registry-cache-", | |
| 208 | + }); | |
| 209 | + const registryDirectory = `${cacheDir}/acp-registry`; | |
| 210 | + yield* fileSystem.makeDirectory(registryDirectory, { recursive: true }); | |
| 211 | + yield* fileSystem.writeFileString(`${registryDirectory}/registry.json`, makeRegistry(agent)); | |
| 212 | + const resolver = yield* makeAcpRegistryResolver({ cacheDir, registryUrl }); | |
| 213 | + const resolved = yield* resolver.resolve(settings(), "/workspace"); | |
| 214 | + | |
| 215 | + expect(resolved.spawn).toMatchObject({ | |
| 216 | + command: "npx", | |
| 217 | + args: ["--yes", "@example/acp@1.2.3", "--stdio"], | |
| 218 | + }); | |
| 219 | + }).pipe( | |
| 220 | + Effect.scoped, | |
| 221 | + Effect.provide( | |
| 222 | + resolverLayer((request) => | |
| 223 | + Effect.succeed( | |
| 224 | + HttpClientResponse.fromWeb(request, new Response("unavailable", { status: 503 })), | |
| 225 | + ), | |
| 226 | + ), | |
| 227 | + ), | |
| 228 | + ); | |
| 229 | + }); | |
| 230 | + | |
| 231 | + it.effect("rejects unsafe command paths before downloading an archive", () => { | |
| 232 | + const agent = makeAgent({ | |
| 233 | + binary: { | |
| 234 | + "linux-x86_64": { | |
| 235 | + archive: archiveUrl, | |
| 236 | + cmd: "../outside", | |
| 237 | + }, | |
| 238 | + }, | |
| 239 | + }); | |
| 240 | + const requests: Array<string> = []; | |
| 241 | + return Effect.gen(function* () { | |
| 242 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 243 | + const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 244 | + prefix: "t3-acp-registry-invalid-", | |
| 245 | + }); | |
| 246 | + const resolver = yield* makeAcpRegistryResolver({ cacheDir, registryUrl }); | |
| 247 | + const error = yield* resolver.resolve(settings(), "/workspace").pipe(Effect.flip); | |
| 248 | + | |
| 249 | + expect(error.reason).toBe("archive_invalid"); | |
| 250 | + expect(requests).toEqual([registryUrl]); | |
| 251 | + }).pipe( | |
| 252 | + Effect.scoped, | |
| 253 | + Effect.provide( | |
| 254 | + resolverLayer((request) => { | |
| 255 | + requests.push(request.url); | |
| 256 | + return Effect.succeed( | |
| 257 | + HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent))), | |
| 258 | + ); | |
| 259 | + }), | |
| 260 | + ), | |
| 261 | + ); | |
| 262 | + }); | |
| 263 | +}); | |
| @@ -0,0 +1,633 @@ | ||
| 1 | +import { | |
| 2 | + type AcpRegistryDistributionPreference, | |
| 3 | + type AcpRegistrySettings, | |
| 4 | +} from "@t3tools/contracts"; | |
| 5 | +import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 6 | +import * as Clock from "effect/Clock"; | |
| 7 | +import * as Effect from "effect/Effect"; | |
| 8 | +import * as FileSystem from "effect/FileSystem"; | |
| 9 | +import * as Option from "effect/Option"; | |
| 10 | +import * as Path from "effect/Path"; | |
| 11 | +import * as PlatformError from "effect/PlatformError"; | |
| 12 | +import * as Ref from "effect/Ref"; | |
| 13 | +import * as Schema from "effect/Schema"; | |
| 14 | +import * as Semaphore from "effect/Semaphore"; | |
| 15 | +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; | |
| 16 | +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | |
| 17 | + | |
| 18 | +import { collectUint8StreamText } from "../../stream/collectUint8StreamText.ts"; | |
| 19 | +import type { AcpSpawnInput } from "./AcpSessionRuntime.ts"; | |
| 20 | + | |
| 21 | +export const ACP_REGISTRY_URL = | |
| 22 | + "https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json"; | |
| 23 | + | |
| 24 | +const AcpRegistryPackageDistribution = Schema.Struct({ | |
| 25 | + package: Schema.String, | |
| 26 | + args: Schema.optionalKey(Schema.Array(Schema.String)), | |
| 27 | + env: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)), | |
| 28 | +}); | |
| 29 | + | |
| 30 | +const AcpRegistryBinaryTarget = Schema.Struct({ | |
| 31 | + archive: Schema.String, | |
| 32 | + cmd: Schema.String, | |
| 33 | + args: Schema.optionalKey(Schema.Array(Schema.String)), | |
| 34 | + env: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)), | |
| 35 | +}); | |
| 36 | + | |
| 37 | +const AcpRegistryAgent = Schema.Struct({ | |
| 38 | + id: Schema.String, | |
| 39 | + name: Schema.String, | |
| 40 | + version: Schema.String, | |
| 41 | + description: Schema.String, | |
| 42 | + distribution: Schema.Struct({ | |
| 43 | + binary: Schema.optionalKey(Schema.Record(Schema.String, AcpRegistryBinaryTarget)), | |
| 44 | + npx: Schema.optionalKey(AcpRegistryPackageDistribution), | |
| 45 | + uvx: Schema.optionalKey(AcpRegistryPackageDistribution), | |
| 46 | + }), | |
| 47 | +}); | |
| 48 | +export type AcpRegistryAgent = typeof AcpRegistryAgent.Type; | |
| 49 | + | |
| 50 | +const AcpRegistryIndex = Schema.Struct({ | |
| 51 | + version: Schema.String, | |
| 52 | + agents: Schema.Array(AcpRegistryAgent), | |
| 53 | +}); | |
| 54 | +export type AcpRegistryIndex = typeof AcpRegistryIndex.Type; | |
| 55 | + | |
| 56 | +const decodeRegistryIndex = Schema.decodeUnknownEffect(AcpRegistryIndex); | |
| 57 | +const decodeJson = Schema.decodeUnknownEffect(Schema.UnknownFromJsonString); | |
| 58 | + | |
| 59 | +export const AcpRegistryErrorReason = Schema.Literals([ | |
| 60 | + "agent_not_configured", | |
| 61 | + "agent_not_found", | |
| 62 | + "archive_invalid", | |
| 63 | + "download_failed", | |
| 64 | + "install_failed", | |
| 65 | + "registry_unavailable", | |
| 66 | + "unsupported_distribution", | |
| 67 | + "unsupported_platform", | |
| 68 | +]); | |
| 69 | +export type AcpRegistryErrorReason = typeof AcpRegistryErrorReason.Type; | |
| 70 | + | |
| 71 | +export class AcpRegistryError extends Schema.TaggedErrorClass<AcpRegistryError>()( | |
| 72 | + "AcpRegistryError", | |
| 73 | + { | |
| 74 | + reason: AcpRegistryErrorReason, | |
| 75 | + detail: Schema.String, | |
| 76 | + cause: Schema.optional(Schema.Defect()), | |
| 77 | + }, | |
| 78 | +) { | |
| 79 | + override get message(): string { | |
| 80 | + return this.detail; | |
| 81 | + } | |
| 82 | +} | |
| 83 | + | |
| 84 | +const isAcpRegistryError = Schema.is(AcpRegistryError); | |
| 85 | + | |
| 86 | +export type AcpRegistryPlatformTarget = | |
| 87 | + | "darwin-aarch64" | |
| 88 | + | "darwin-x86_64" | |
| 89 | + | "linux-aarch64" | |
| 90 | + | "linux-x86_64" | |
| 91 | + | "windows-aarch64" | |
| 92 | + | "windows-x86_64"; | |
| 93 | + | |
| 94 | +export function resolveAcpRegistryPlatformTarget( | |
| 95 | + platform: NodeJS.Platform, | |
| 96 | + architecture: NodeJS.Architecture, | |
| 97 | +): AcpRegistryPlatformTarget | undefined { | |
| 98 | + const os = | |
| 99 | + platform === "darwin" | |
| 100 | + ? "darwin" | |
| 101 | + : platform === "linux" | |
| 102 | + ? "linux" | |
| 103 | + : platform === "win32" | |
| 104 | + ? "windows" | |
| 105 | + : undefined; | |
| 106 | + const arch = architecture === "arm64" ? "aarch64" : architecture === "x64" ? "x86_64" : undefined; | |
| 107 | + return os && arch ? (`${os}-${arch}` as AcpRegistryPlatformTarget) : undefined; | |
| 108 | +} | |
| 109 | + | |
| 110 | +export type AcpRegistryDistributionKind = "binary" | "npx" | "uvx"; | |
| 111 | + | |
| 112 | +export interface ResolvedAcpRegistryDistribution { | |
| 113 | + readonly kind: AcpRegistryDistributionKind; | |
| 114 | + readonly args: ReadonlyArray<string>; | |
| 115 | + readonly env: Readonly<Record<string, string>>; | |
| 116 | + readonly binaryTarget?: typeof AcpRegistryBinaryTarget.Type; | |
| 117 | + readonly packageName?: string; | |
| 118 | +} | |
| 119 | + | |
| 120 | +export function resolveAcpRegistryDistribution(input: { | |
| 121 | + readonly agent: AcpRegistryAgent; | |
| 122 | + readonly preference: AcpRegistryDistributionPreference; | |
| 123 | + readonly platformTarget: AcpRegistryPlatformTarget | undefined; | |
| 124 | +}): ResolvedAcpRegistryDistribution | undefined { | |
| 125 | + const { agent, platformTarget } = input; | |
| 126 | + const binary = platformTarget ? agent.distribution.binary?.[platformTarget] : undefined; | |
| 127 | + const candidates: ReadonlyArray<AcpRegistryDistributionKind> = | |
| 128 | + input.preference === "auto" ? ["binary", "npx", "uvx"] : [input.preference]; | |
| 129 | + | |
| 130 | + for (const kind of candidates) { | |
| 131 | + if (kind === "binary" && binary) { | |
| 132 | + return { | |
| 133 | + kind, | |
| 134 | + args: binary.args ?? [], | |
| 135 | + env: binary.env ?? {}, | |
| 136 | + binaryTarget: binary, | |
| 137 | + }; | |
| 138 | + } | |
| 139 | + if (kind === "npx" && agent.distribution.npx) { | |
| 140 | + return { | |
| 141 | + kind, | |
| 142 | + args: agent.distribution.npx.args ?? [], | |
| 143 | + env: agent.distribution.npx.env ?? {}, | |
| 144 | + packageName: agent.distribution.npx.package, | |
| 145 | + }; | |
| 146 | + } | |
| 147 | + if (kind === "uvx" && agent.distribution.uvx) { | |
| 148 | + return { | |
| 149 | + kind, | |
| 150 | + args: agent.distribution.uvx.args ?? [], | |
| 151 | + env: agent.distribution.uvx.env ?? {}, | |
| 152 | + packageName: agent.distribution.uvx.package, | |
| 153 | + }; | |
| 154 | + } | |
| 155 | + } | |
| 156 | + return undefined; | |
| 157 | +} | |
| 158 | + | |
| 159 | +export interface ResolvedAcpRegistryAgent { | |
| 160 | + readonly agent: AcpRegistryAgent; | |
| 161 | + readonly distribution: AcpRegistryDistributionKind; | |
| 162 | + readonly spawn: AcpSpawnInput; | |
| 163 | +} | |
| 164 | + | |
| 165 | +export interface AcpRegistryResolverShape { | |
| 166 | + readonly resolve: ( | |
| 167 | + settings: AcpRegistrySettings, | |
| 168 | + cwd: string, | |
| 169 | + environment?: NodeJS.ProcessEnv, | |
| 170 | + ) => Effect.Effect<ResolvedAcpRegistryAgent, AcpRegistryError>; | |
| 171 | +} | |
| 172 | + | |
| 173 | +const INSTALL_LOCK_RETRY_COUNT = 300; | |
| 174 | +const INSTALL_LOCK_RETRY_DELAY = "100 millis"; | |
| 175 | +const INSTALL_LOCK_STALE_MS = 5 * 60 * 1_000; | |
| 176 | +const MAX_ARCHIVE_BYTES = 512 * 1024 * 1024; | |
| 177 | + | |
| 178 | +function isAlreadyExists(error: PlatformError.PlatformError): boolean { | |
| 179 | + return error.reason._tag === "AlreadyExists"; | |
| 180 | +} | |
| 181 | + | |
| 182 | +function normalizeRegistryCommandPath(command: string): ReadonlyArray<string> | undefined { | |
| 183 | + const normalized = command.trim().replaceAll("\\", "/").replace(/^\.\//u, ""); | |
| 184 | + const segments = normalized.split("/").filter((segment) => segment.length > 0); | |
| 185 | + if ( | |
| 186 | + segments.length === 0 || | |
| 187 | + normalized.startsWith("/") || | |
| 188 | + /^[a-zA-Z]:/u.test(normalized) || | |
| 189 | + segments.some((segment) => segment === "." || segment === "..") | |
| 190 | + ) { | |
| 191 | + return undefined; | |
| 192 | + } | |
| 193 | + return segments; | |
| 194 | +} | |
| 195 | + | |
| 196 | +function validateArchiveEntries(output: string): boolean { | |
| 197 | + return output | |
| 198 | + .split(/\r?\n/u) | |
| 199 | + .map((entry) => entry.trim()) | |
| 200 | + .filter((entry) => entry.length > 0) | |
| 201 | + .every((entry) => normalizeRegistryCommandPath(entry) !== undefined); | |
| 202 | +} | |
| 203 | + | |
| 204 | +type ArchiveKind = "raw" | "tar_bz2" | "tar_gz" | "zip"; | |
| 205 | + | |
| 206 | +function archiveKind(url: string): ArchiveKind { | |
| 207 | + const pathname = new URL(url).pathname.toLowerCase(); | |
| 208 | + if (pathname.endsWith(".tar.gz") || pathname.endsWith(".tgz")) return "tar_gz"; | |
| 209 | + if (pathname.endsWith(".tar.bz2") || pathname.endsWith(".tbz2")) return "tar_bz2"; | |
| 210 | + if (pathname.endsWith(".zip")) return "zip"; | |
| 211 | + return "raw"; | |
| 212 | +} | |
| 213 | + | |
| 214 | +function archiveFileName(kind: ArchiveKind): string { | |
| 215 | + switch (kind) { | |
| 216 | + case "tar_gz": | |
| 217 | + return "agent.tar.gz"; | |
| 218 | + case "tar_bz2": | |
| 219 | + return "agent.tar.bz2"; | |
| 220 | + case "zip": | |
| 221 | + return "agent.zip"; | |
| 222 | + case "raw": | |
| 223 | + return "agent.bin"; | |
| 224 | + } | |
| 225 | +} | |
| 226 | + | |
| 227 | +export const makeAcpRegistryResolver = Effect.fn("AcpRegistryResolver.make")(function* (input: { | |
| 228 | + readonly cacheDir: string; | |
| 229 | + readonly registryUrl?: string; | |
| 230 | +}): Effect.fn.Return< | |
| 231 | + AcpRegistryResolverShape, | |
| 232 | + never, | |
| 233 | + | ChildProcessSpawner.ChildProcessSpawner | |
| 234 | + | FileSystem.FileSystem | |
| 235 | + | HttpClient.HttpClient | |
| 236 | + | Path.Path | |
| 237 | +> { | |
| 238 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 239 | + const path = yield* Path.Path; | |
| 240 | + const httpClient = yield* HttpClient.HttpClient; | |
| 241 | + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 242 | + const platform = yield* HostProcessPlatform; | |
| 243 | + const architecture = yield* HostProcessArchitecture; | |
| 244 | + const platformTarget = resolveAcpRegistryPlatformTarget(platform, architecture); | |
| 245 | + const registryUrl = input.registryUrl ?? ACP_REGISTRY_URL; | |
| 246 | + const registryDirectory = path.join(input.cacheDir, "acp-registry"); | |
| 247 | + const registryCachePath = path.join(registryDirectory, "registry.json"); | |
| 248 | + const installsDirectory = path.join(registryDirectory, "agents"); | |
| 249 | + const registryRef = yield* Ref.make<AcpRegistryIndex | undefined>(undefined); | |
| 250 | + const registrySemaphore = yield* Semaphore.make(1); | |
| 251 | + const installSemaphore = yield* Semaphore.make(1); | |
| 252 | + | |
| 253 | + const decodeRegistryText = Effect.fn("AcpRegistryResolver.decodeRegistryText")(function* ( | |
| 254 | + text: string, | |
| 255 | + ) { | |
| 256 | + const decoded = yield* decodeJson(text).pipe( | |
| 257 | + Effect.mapError( | |
| 258 | + (cause) => | |
| 259 | + new AcpRegistryError({ | |
| 260 | + reason: "registry_unavailable", | |
| 261 | + detail: "ACP Registry returned invalid JSON.", | |
| 262 | + cause, | |
| 263 | + }), | |
| 264 | + ), | |
| 265 | + ); | |
| 266 | + return yield* decodeRegistryIndex(decoded).pipe( | |
| 267 | + Effect.mapError( | |
| 268 | + (cause) => | |
| 269 | + new AcpRegistryError({ | |
| 270 | + reason: "registry_unavailable", | |
| 271 | + detail: "ACP Registry returned an invalid index.", | |
| 272 | + cause, | |
| 273 | + }), | |
| 274 | + ), | |
| 275 | + ); | |
| 276 | + }); | |
| 277 | + | |
| 278 | + const readCachedRegistry = fileSystem | |
| 279 | + .readFileString(registryCachePath) | |
| 280 | + .pipe(Effect.flatMap(decodeRegistryText), Effect.option); | |
| 281 | + | |
| 282 | + const writeRegistryCache = (text: string) => | |
| 283 | + fileSystem | |
| 284 | + .makeDirectory(registryDirectory, { recursive: true }) | |
| 285 | + .pipe( | |
| 286 | + Effect.andThen(fileSystem.writeFileString(`${registryCachePath}.${process.pid}.tmp`, text)), | |
| 287 | + Effect.andThen( | |
| 288 | + fileSystem.rename(`${registryCachePath}.${process.pid}.tmp`, registryCachePath), | |
| 289 | + ), | |
| 290 | + Effect.ignore, | |
| 291 | + ); | |
| 292 | + | |
| 293 | + const fetchRegistry = Effect.gen(function* () { | |
| 294 | + const response = yield* httpClient.execute(HttpClientRequest.get(registryUrl)).pipe( | |
| 295 | + Effect.flatMap(HttpClientResponse.filterStatusOk), | |
| 296 | + Effect.mapError( | |
| 297 | + (cause) => | |
| 298 | + new AcpRegistryError({ | |
| 299 | + reason: "registry_unavailable", | |
| 300 | + detail: `Could not fetch ACP Registry index from ${registryUrl}.`, | |
| 301 | + cause, | |
| 302 | + }), | |
| 303 | + ), | |
| 304 | + ); | |
| 305 | + const text = yield* response.text.pipe( | |
| 306 | + Effect.mapError( | |
| 307 | + (cause) => | |
| 308 | + new AcpRegistryError({ | |
| 309 | + reason: "registry_unavailable", | |
| 310 | + detail: "Could not read ACP Registry response body.", | |
| 311 | + cause, | |
| 312 | + }), | |
| 313 | + ), | |
| 314 | + ); | |
| 315 | + const registry = yield* decodeRegistryText(text); | |
| 316 | + yield* writeRegistryCache(text); | |
| 317 | + return registry; | |
| 318 | + }); | |
| 319 | + | |
| 320 | + const loadRegistry = registrySemaphore.withPermits(1)( | |
| 321 | + Effect.gen(function* () { | |
| 322 | + const memoized = yield* Ref.get(registryRef); | |
| 323 | + if (memoized !== undefined) return memoized; | |
| 324 | + const registry = yield* fetchRegistry.pipe( | |
| 325 | + Effect.catch((networkError) => | |
| 326 | + readCachedRegistry.pipe( | |
| 327 | + Effect.flatMap( | |
| 328 | + Option.match({ | |
| 329 | + onNone: () => Effect.fail(networkError), | |
| 330 | + onSome: Effect.succeed, | |
| 331 | + }), | |
| 332 | + ), | |
| 333 | + ), | |
| 334 | + ), | |
| 335 | + ); | |
| 336 | + yield* Ref.set(registryRef, registry); | |
| 337 | + return registry; | |
| 338 | + }), | |
| 339 | + ); | |
| 340 | + | |
| 341 | + const runCommand = Effect.fn("AcpRegistryResolver.runCommand")(function* ( | |
| 342 | + command: string, | |
| 343 | + args: ReadonlyArray<string>, | |
| 344 | + cwd?: string, | |
| 345 | + ) { | |
| 346 | + const child = yield* spawner.spawn( | |
| 347 | + ChildProcess.make(command, args, { | |
| 348 | + ...(cwd ? { cwd } : {}), | |
| 349 | + shell: false, | |
| 350 | + }), | |
| 351 | + ); | |
| 352 | + const [stdout, stderr, exitCode] = yield* Effect.all( | |
| 353 | + [ | |
| 354 | + collectUint8StreamText({ stream: child.stdout }), | |
| 355 | + collectUint8StreamText({ stream: child.stderr }), | |
| 356 | + child.exitCode, | |
| 357 | + ], | |
| 358 | + { concurrency: "unbounded" }, | |
| 359 | + ); | |
| 360 | + if (Number(exitCode) !== 0) { | |
| 361 | + return yield* new AcpRegistryError({ | |
| 362 | + reason: "install_failed", | |
| 363 | + detail: `ACP Registry install command '${command}' exited with code ${Number(exitCode)}: ${stderr.text.trim()}`, | |
| 364 | + }); | |
| 365 | + } | |
| 366 | + return stdout.text; | |
| 367 | + }); | |
| 368 | + | |
| 369 | + const acquireInstallLock = Effect.fn("AcpRegistryResolver.acquireInstallLock")(function* ( | |
| 370 | + lockPath: string, | |
| 371 | + ) { | |
| 372 | + for (let attempt = 0; attempt < INSTALL_LOCK_RETRY_COUNT; attempt += 1) { | |
| 373 | + const acquired = yield* fileSystem.writeFileString(lockPath, "", { flag: "wx" }).pipe( | |
| 374 | + Effect.as(true), | |
| 375 | + Effect.catch((error) => | |
| 376 | + isAlreadyExists(error) ? Effect.succeed(false) : Effect.fail(error), | |
| 377 | + ), | |
| 378 | + ); | |
| 379 | + if (acquired) return; | |
| 380 | + const now = yield* Clock.currentTimeMillis; | |
| 381 | + const lockInfo = yield* fileSystem.stat(lockPath).pipe(Effect.option); | |
| 382 | + const mtime = Option.flatMap(lockInfo, (info) => info.mtime); | |
| 383 | + if (Option.isSome(mtime) && now - mtime.value.getTime() > INSTALL_LOCK_STALE_MS) { | |
| 384 | + yield* fileSystem.remove(lockPath, { force: true }); | |
| 385 | + continue; | |
| 386 | + } | |
| 387 | + yield* Effect.sleep(INSTALL_LOCK_RETRY_DELAY); | |
| 388 | + } | |
| 389 | + return yield* new AcpRegistryError({ | |
| 390 | + reason: "install_failed", | |
| 391 | + detail: `Timed out waiting for ACP Registry install lock ${lockPath}.`, | |
| 392 | + }); | |
| 393 | + }); | |
| 394 | + | |
| 395 | + const assertExecutableInRoot = Effect.fn("AcpRegistryResolver.assertExecutableInRoot")(function* ( | |
| 396 | + root: string, | |
| 397 | + executablePath: string, | |
| 398 | + ) { | |
| 399 | + const rootRealPath = yield* fileSystem.realPath(root); | |
| 400 | + const executableRealPath = yield* fileSystem.realPath(executablePath); | |
| 401 | + const relative = path.relative(rootRealPath, executableRealPath); | |
| 402 | + if (relative.startsWith("..") || path.isAbsolute(relative)) { | |
| 403 | + return yield* new AcpRegistryError({ | |
| 404 | + reason: "archive_invalid", | |
| 405 | + detail: "ACP Registry archive command resolves outside its installation directory.", | |
| 406 | + }); | |
| 407 | + } | |
| 408 | + return executableRealPath; | |
| 409 | + }); | |
| 410 | + | |
| 411 | + const installBinary = Effect.fn("AcpRegistryResolver.installBinary")(function* ( | |
| 412 | + agent: AcpRegistryAgent, | |
| 413 | + target: typeof AcpRegistryBinaryTarget.Type, | |
| 414 | + ) { | |
| 415 | + if (platformTarget === undefined) { | |
| 416 | + return yield* new AcpRegistryError({ | |
| 417 | + reason: "unsupported_platform", | |
| 418 | + detail: `ACP Registry does not support platform ${platform}-${architecture}.`, | |
| 419 | + }); | |
| 420 | + } | |
| 421 | + const commandSegments = normalizeRegistryCommandPath(target.cmd); | |
| 422 | + if (commandSegments === undefined) { | |
| 423 | + return yield* new AcpRegistryError({ | |
| 424 | + reason: "archive_invalid", | |
| 425 | + detail: `ACP Registry agent ${agent.id} declares an unsafe command path '${target.cmd}'.`, | |
| 426 | + }); | |
| 427 | + } | |
| 428 | + const installRoot = path.join(installsDirectory, agent.id, agent.version, platformTarget); | |
| 429 | + const executablePath = path.join(installRoot, ...commandSegments); | |
| 430 | + if (yield* fileSystem.exists(executablePath).pipe(Effect.orElseSucceed(() => false))) { | |
| 431 | + return yield* assertExecutableInRoot(installRoot, executablePath).pipe( | |
| 432 | + Effect.mapError((cause) => | |
| 433 | + isAcpRegistryError(cause) | |
| 434 | + ? cause | |
| 435 | + : new AcpRegistryError({ | |
| 436 | + reason: "install_failed", | |
| 437 | + detail: `Could not validate cached ACP Registry agent ${agent.id}.`, | |
| 438 | + cause, | |
| 439 | + }), | |
| 440 | + ), | |
| 441 | + ); | |
| 442 | + } | |
| 443 | + | |
| 444 | + yield* fileSystem.makeDirectory(path.dirname(installRoot), { recursive: true }).pipe( | |
| 445 | + Effect.mapError( | |
| 446 | + (cause) => | |
| 447 | + new AcpRegistryError({ | |
| 448 | + reason: "install_failed", | |
| 449 | + detail: `Could not create ACP Registry cache for ${agent.id}.`, | |
| 450 | + cause, | |
| 451 | + }), | |
| 452 | + ), | |
| 453 | + ); | |
| 454 | + const lockPath = `${installRoot}.lock`; | |
| 455 | + yield* acquireInstallLock(lockPath).pipe( | |
| 456 | + Effect.mapError((cause) => | |
| 457 | + isAcpRegistryError(cause) | |
| 458 | + ? cause | |
| 459 | + : new AcpRegistryError({ | |
| 460 | + reason: "install_failed", | |
| 461 | + detail: `Could not acquire install lock for ACP Registry agent ${agent.id}.`, | |
| 462 | + cause, | |
| 463 | + }), | |
| 464 | + ), | |
| 465 | + ); | |
| 466 | + | |
| 467 | + return yield* Effect.gen(function* () { | |
| 468 | + if (yield* fileSystem.exists(executablePath).pipe(Effect.orElseSucceed(() => false))) { | |
| 469 | + return yield* assertExecutableInRoot(installRoot, executablePath); | |
| 470 | + } | |
| 471 | + const temporaryRoot = yield* fileSystem.makeTempDirectoryScoped({ | |
| 472 | + directory: path.dirname(installRoot), | |
| 473 | + prefix: `.${agent.id}-install-`, | |
| 474 | + }); | |
| 475 | + const extractionRoot = path.join(temporaryRoot, "extracted"); | |
| 476 | + yield* fileSystem.makeDirectory(extractionRoot, { recursive: true }); | |
| 477 | + const kind = archiveKind(target.archive); | |
| 478 | + const archivePath = path.join(temporaryRoot, archiveFileName(kind)); | |
| 479 | + const response = yield* httpClient.execute(HttpClientRequest.get(target.archive)).pipe( | |
| 480 | + Effect.flatMap(HttpClientResponse.filterStatusOk), | |
| 481 | + Effect.mapError( | |
| 482 | + (cause) => | |
| 483 | + new AcpRegistryError({ | |
| 484 | + reason: "download_failed", | |
| 485 | + detail: `Could not download ACP Registry agent ${agent.id} ${agent.version}.`, | |
| 486 | + cause, | |
| 487 | + }), | |
| 488 | + ), | |
| 489 | + ); | |
| 490 | + const bytes = new Uint8Array( | |
| 491 | + yield* response.arrayBuffer.pipe( | |
| 492 | + Effect.mapError( | |
| 493 | + (cause) => | |
| 494 | + new AcpRegistryError({ | |
| 495 | + reason: "download_failed", | |
| 496 | + detail: `Could not read ACP Registry agent ${agent.id} download.`, | |
| 497 | + cause, | |
| 498 | + }), | |
| 499 | + ), | |
| 500 | + ), | |
| 501 | + ); | |
| 502 | + if (bytes.byteLength > MAX_ARCHIVE_BYTES) { | |
| 503 | + return yield* new AcpRegistryError({ | |
| 504 | + reason: "archive_invalid", | |
| 505 | + detail: `ACP Registry agent ${agent.id} archive exceeds ${MAX_ARCHIVE_BYTES} bytes.`, | |
| 506 | + }); | |
| 507 | + } | |
| 508 | + yield* fileSystem.writeFile(archivePath, bytes); | |
| 509 | + | |
| 510 | + if (kind === "raw") { | |
| 511 | + const targetPath = path.join(extractionRoot, ...commandSegments); | |
| 512 | + yield* fileSystem.makeDirectory(path.dirname(targetPath), { recursive: true }); | |
| 513 | + yield* fileSystem.rename(archivePath, targetPath); | |
| 514 | + } else { | |
| 515 | + const listArgs = | |
| 516 | + kind === "tar_gz" | |
| 517 | + ? ["-tzf", archivePath] | |
| 518 | + : kind === "tar_bz2" | |
| 519 | + ? ["-tjf", archivePath] | |
| 520 | + : platform === "win32" | |
| 521 | + ? ["-tf", archivePath] | |
| 522 | + : undefined; | |
| 523 | + const entries = | |
| 524 | + listArgs === undefined | |
| 525 | + ? yield* runCommand("unzip", ["-Z1", archivePath]) | |
| 526 | + : yield* runCommand("tar", listArgs); | |
| 527 | + if (!validateArchiveEntries(entries)) { | |
| 528 | + return yield* new AcpRegistryError({ | |
| 529 | + reason: "archive_invalid", | |
| 530 | + detail: `ACP Registry agent ${agent.id} archive contains an unsafe path.`, | |
| 531 | + }); | |
| 532 | + } | |
| 533 | + if (kind === "zip" && platform !== "win32") { | |
| 534 | + yield* runCommand("unzip", ["-q", archivePath, "-d", extractionRoot]); | |
| 535 | + } else { | |
| 536 | + const extractFlag = kind === "tar_gz" ? "-xzf" : kind === "tar_bz2" ? "-xjf" : "-xf"; | |
| 537 | + yield* runCommand("tar", [extractFlag, archivePath, "-C", extractionRoot]); | |
| 538 | + } | |
| 539 | + } | |
| 540 | + | |
| 541 | + const stagedExecutable = path.join(extractionRoot, ...commandSegments); | |
| 542 | + if (!(yield* fileSystem.exists(stagedExecutable).pipe(Effect.orElseSucceed(() => false)))) { | |
| 543 | + return yield* new AcpRegistryError({ | |
| 544 | + reason: "archive_invalid", | |
| 545 | + detail: `ACP Registry archive for ${agent.id} did not contain '${target.cmd}'.`, | |
| 546 | + }); | |
| 547 | + } | |
| 548 | + if (platform !== "win32") yield* fileSystem.chmod(stagedExecutable, 0o755); | |
| 549 | + yield* assertExecutableInRoot(extractionRoot, stagedExecutable); | |
| 550 | + yield* fileSystem.remove(installRoot, { recursive: true, force: true }); | |
| 551 | + yield* fileSystem.rename(extractionRoot, installRoot); | |
| 552 | + return yield* assertExecutableInRoot(installRoot, executablePath); | |
| 553 | + }).pipe( | |
| 554 | + Effect.scoped, | |
| 555 | + Effect.ensuring(fileSystem.remove(lockPath, { force: true }).pipe(Effect.ignore)), | |
| 556 | + Effect.mapError((cause) => | |
| 557 | + isAcpRegistryError(cause) | |
| 558 | + ? cause | |
| 559 | + : new AcpRegistryError({ | |
| 560 | + reason: "install_failed", | |
| 561 | + detail: `Could not install ACP Registry agent ${agent.id}.`, | |
| 562 | + cause, | |
| 563 | + }), | |
| 564 | + ), | |
| 565 | + ); | |
| 566 | + }); | |
| 567 | + | |
| 568 | + const resolve: AcpRegistryResolverShape["resolve"] = (settings, cwd, environment) => | |
| 569 | + Effect.gen(function* () { | |
| 570 | + const agentId = settings.agentId.trim(); | |
| 571 | + if (agentId.length === 0) { | |
| 572 | + return yield* new AcpRegistryError({ | |
| 573 | + reason: "agent_not_configured", | |
| 574 | + detail: "ACP Registry provider requires a registry agent ID.", | |
| 575 | + }); | |
| 576 | + } | |
| 577 | + const registry = yield* loadRegistry; | |
| 578 | + const agent = registry.agents.find((candidate) => candidate.id === agentId); | |
| 579 | + if (agent === undefined) { | |
| 580 | + return yield* new AcpRegistryError({ | |
| 581 | + reason: "agent_not_found", | |
| 582 | + detail: `ACP Registry does not contain agent '${agentId}'.`, | |
| 583 | + }); | |
| 584 | + } | |
| 585 | + const distribution = resolveAcpRegistryDistribution({ | |
| 586 | + agent, | |
| 587 | + preference: settings.distribution, | |
| 588 | + platformTarget, | |
| 589 | + }); | |
| 590 | + if (distribution === undefined) { | |
| 591 | + return yield* new AcpRegistryError({ | |
| 592 | + reason: | |
| 593 | + platformTarget === undefined ? "unsupported_platform" : "unsupported_distribution", | |
| 594 | + detail: `ACP Registry agent ${agent.id} has no ${settings.distribution === "auto" ? "compatible" : settings.distribution} distribution for ${platform}-${architecture}.`, | |
| 595 | + }); | |
| 596 | + } | |
| 597 | + | |
| 598 | + let command: string; | |
| 599 | + let args: ReadonlyArray<string>; | |
| 600 | + const commandOverride = settings.commandPath.trim(); | |
| 601 | + if (commandOverride.length > 0) { | |
| 602 | + command = commandOverride; | |
| 603 | + args = distribution.args; | |
| 604 | + } else if (distribution.kind === "npx") { | |
| 605 | + command = "npx"; | |
| 606 | + args = ["--yes", distribution.packageName!, ...distribution.args]; | |
| 607 | + } else if (distribution.kind === "uvx") { | |
| 608 | + command = "uvx"; | |
| 609 | + args = [distribution.packageName!, ...distribution.args]; | |
| 610 | + } else { | |
| 611 | + command = yield* installSemaphore.withPermits(1)( | |
| 612 | + installBinary(agent, distribution.binaryTarget!), | |
| 613 | + ); | |
| 614 | + args = distribution.args; | |
| 615 | + } | |
| 616 | + | |
| 617 | + return { | |
| 618 | + agent, | |
| 619 | + distribution: distribution.kind, | |
| 620 | + spawn: { | |
| 621 | + command, | |
| 622 | + args, | |
| 623 | + cwd, | |
| 624 | + env: { | |
| 625 | + ...environment, | |
| 626 | + ...distribution.env, | |
| 627 | + }, | |
| 628 | + }, | |
| 629 | + } satisfies ResolvedAcpRegistryAgent; | |
| 630 | + }); | |
| 631 | + | |
| 632 | + return { resolve }; | |
| 633 | +}); | |
| @@ -72,6 +72,7 @@ export type AcpParsedSessionEvent = · 71 unmodified lines | ||
| 72 | 72 | }; |
| 73 | 73 | |
| 74 | 74 | type AcpSessionSetupResponse = |
| 75 | + | EffectAcpSchema.ForkSessionResponse | |
| 75 | 76 | | EffectAcpSchema.LoadSessionResponse |
| 76 | 77 | | EffectAcpSchema.NewSessionResponse |
| 77 | 78 | | EffectAcpSchema.ResumeSessionResponse; |
| @@ -47,7 +47,7 @@ export interface AcpSessionRuntimeOptions { · 46 unmodified lines | ||
| 47 | 47 | readonly name: string; |
| 48 | 48 | readonly version: string; |
| 49 | 49 | }; |
| 50 | − readonly authMethodId: string; | |
| 50 | + readonly authMethodId?: string; | |
| 51 | 51 | readonly mcpServers?: ReadonlyArray<EffectAcpSchema.McpServer>; |
| 52 | 52 | readonly requestLogger?: (event: AcpSessionRequestLogEvent) => Effect.Effect<void, never>; |
| 53 | 53 | readonly protocolLogging?: { |
| @@ -65,10 +65,26 @@ export interface AcpSessionRequestLogEvent { · 11 unmodified lines | ||
| 65 | 65 | readonly cause?: Cause.Cause<EffectAcpErrors.AcpError>; |
| 66 | 66 | } |
| 67 | 67 | |
| 68 | +export function selectAcpAgentAuthMethod( | |
| 69 | + authMethods: ReadonlyArray<EffectAcpSchema.AuthMethod> | undefined, | |
| 70 | + preferredMethodId?: string, | |
| 71 | +): EffectAcpSchema.AuthMethod | undefined { | |
| 72 | + const preferred = preferredMethodId?.trim(); | |
| 73 | + if (preferred) { | |
| 74 | + return authMethods?.find((method) => method.id === preferred); | |
| 75 | + } | |
| 76 | + return authMethods?.find((method) => !("type" in method)); | |
| 77 | +} | |
| 78 | + | |
| 79 | +function isAcpAuthenticationRequired(error: EffectAcpErrors.AcpError): boolean { | |
| 80 | + return error._tag === "AcpRequestError" && error.code === -32000; | |
| 81 | +} | |
| 82 | + | |
| 68 | 83 | export interface AcpSessionRuntimeStartResult { |
| 69 | 84 | readonly sessionId: string; |
| 70 | 85 | readonly initializeResult: EffectAcpSchema.InitializeResponse; |
| 71 | 86 | readonly sessionSetupResult: |
| 87 | + | EffectAcpSchema.ForkSessionResponse | |
| 72 | 88 | | EffectAcpSchema.LoadSessionResponse |
| 73 | 89 | | EffectAcpSchema.NewSessionResponse |
| 74 | 90 | | EffectAcpSchema.ResumeSessionResponse; |
| @@ -95,6 +111,21 @@ export interface AcpSessionRuntimeShape { · 20 unmodified lines | ||
| 95 | 111 | readonly getEvents: () => Stream.Stream<AcpParsedSessionEvent, never>; |
| 96 | 112 | readonly getModeState: Effect.Effect<AcpSessionModeState | undefined>; |
| 97 | 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 | 129 | readonly prompt: ( |
| 99 | 130 | payload: Omit<EffectAcpSchema.PromptRequest, "sessionId">, |
| 100 | 131 | ) => Effect.Effect<EffectAcpSchema.PromptResponse, EffectAcpErrors.AcpError>; |
| @@ -327,6 +358,7 @@ const makeAcpSessionRuntime = ( · 226 unmodified lines | ||
| 327 | 358 | const updateConfigOptions = ( |
| 328 | 359 | response: |
| 329 | 360 | | EffectAcpSchema.SetSessionConfigOptionResponse |
| 361 | + | EffectAcpSchema.ForkSessionResponse | |
| 330 | 362 | | EffectAcpSchema.LoadSessionResponse |
| 331 | 363 | | EffectAcpSchema.NewSessionResponse |
| 332 | 364 | | EffectAcpSchema.ResumeSessionResponse, |
| @@ -337,6 +369,30 @@ const makeAcpSessionRuntime = ( · 4 unmodified lines | ||
| 337 | 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 | 396 | const setConfigOption = ( |
| 341 | 397 | configId: string, |
| 342 | 398 | value: string | boolean, |
| @@ -388,35 +444,80 @@ const makeAcpSessionRuntime = ( · 45 unmodified lines | ||
| 388 | 444 | acp.agent.initialize(initializePayload), |
| 389 | 445 | ); |
| 390 | 446 | |
| 391 | − const authenticatePayload = { | |
| 392 | − methodId: options.authMethodId, | |
| 393 | − } satisfies EffectAcpSchema.AuthenticateRequest; | |
| 447 | + const authenticateAfterRequired = ( | |
| 448 | + authRequiredError: EffectAcpErrors.AcpError, | |
| 449 | + ): Effect.Effect<void, EffectAcpErrors.AcpError> => | |
| 450 | + Effect.gen(function* () { | |
| 451 | + const configuredAuthMethodId = options.authMethodId?.trim(); | |
| 452 | + const authMethod = selectAcpAgentAuthMethod( | |
| 453 | + initializeResult.authMethods, | |
| 454 | + configuredAuthMethodId, | |
| 455 | + ); | |
| 456 | + if ( | |
| 457 | + configuredAuthMethodId && | |
| 458 | + initializeResult.authMethods !== undefined && | |
| 459 | + authMethod === undefined | |
| 460 | + ) { | |
| 461 | + return yield* new EffectAcpErrors.AcpTransportError({ | |
| 462 | + detail: `ACP agent did not advertise configured authentication method "${configuredAuthMethodId}"`, | |
| 463 | + cause: { configuredAuthMethodId, authMethods: initializeResult.authMethods }, | |
| 464 | + }); | |
| 465 | + } | |
| 466 | + if (authMethod !== undefined && "type" in authMethod) { | |
| 467 | + return yield* new EffectAcpErrors.AcpTransportError({ | |
| 468 | + detail: `ACP authentication method "${authMethod.id}" requires ${authMethod.type} authentication, which cannot run inside a headless provider session`, | |
| 469 | + cause: authMethod, | |
| 470 | + }); | |
| 471 | + } | |
| 394 | 472 | |
| 395 | − yield* runLoggedRequest( | |
| 396 | − "authenticate", | |
| 397 | − authenticatePayload, | |
| 398 | − acp.agent.authenticate(authenticatePayload), | |
| 399 | − ); | |
| 473 | + const authMethodId = authMethod?.id ?? configuredAuthMethodId; | |
| 474 | + if (!authMethodId) { | |
| 475 | + return yield* authRequiredError; | |
| 476 | + } | |
| 400 | 477 | |
| 401 | − let sessionId: string; | |
| 402 | − let sessionSetupResult: | |
| 403 | − | EffectAcpSchema.LoadSessionResponse | |
| 404 | − | EffectAcpSchema.NewSessionResponse | |
| 405 | − | EffectAcpSchema.ResumeSessionResponse; | |
| 406 | − if (options.resumeSessionId) { | |
| 407 | − const loadPayload = { | |
| 408 | − sessionId: options.resumeSessionId, | |
| 409 | − cwd: options.cwd, | |
| 410 | − mcpServers: options.mcpServers ?? [], | |
| 411 | − } satisfies EffectAcpSchema.LoadSessionRequest; | |
| 412 | − const resumed = yield* runLoggedRequest( | |
| 413 | − "session/load", | |
| 414 | − loadPayload, | |
| 415 | − acp.agent.loadSession(loadPayload), | |
| 416 | − ).pipe(Effect.exit); | |
| 417 | − if (Exit.isSuccess(resumed)) { | |
| 418 | − sessionId = options.resumeSessionId; | |
| 419 | − sessionSetupResult = resumed.value; | |
| 478 | + const authenticatePayload = { | |
| 479 | + methodId: authMethodId, | |
| 480 | + } satisfies EffectAcpSchema.AuthenticateRequest; | |
| 481 | + yield* runLoggedRequest( | |
| 482 | + "authenticate", | |
| 483 | + authenticatePayload, | |
| 484 | + acp.agent.authenticate(authenticatePayload), | |
| 485 | + ); | |
| 486 | + }); | |
| 487 | + | |
| 488 | + const setupSession = Effect.gen(function* () { | |
| 489 | + let sessionId: string; | |
| 490 | + let sessionSetupResult: | |
| 491 | + | EffectAcpSchema.LoadSessionResponse | |
| 492 | + | EffectAcpSchema.NewSessionResponse | |
| 493 | + | EffectAcpSchema.ResumeSessionResponse; | |
| 494 | + if (options.resumeSessionId) { | |
| 495 | + const loadPayload = { | |
| 496 | + sessionId: options.resumeSessionId, | |
| 497 | + cwd: options.cwd, | |
| 498 | + mcpServers: options.mcpServers ?? [], | |
| 499 | + } satisfies EffectAcpSchema.LoadSessionRequest; | |
| 500 | + const resumed = yield* runLoggedRequest( | |
| 501 | + "session/load", | |
| 502 | + loadPayload, | |
| 503 | + acp.agent.loadSession(loadPayload), | |
| 504 | + ).pipe(Effect.exit); | |
| 505 | + if (Exit.isSuccess(resumed)) { | |
| 506 | + sessionId = options.resumeSessionId; | |
| 507 | + sessionSetupResult = resumed.value; | |
| 508 | + } else { | |
| 509 | + const createPayload = { | |
| 510 | + cwd: options.cwd, | |
| 511 | + mcpServers: options.mcpServers ?? [], | |
| 512 | + } satisfies EffectAcpSchema.NewSessionRequest; | |
| 513 | + const created = yield* runLoggedRequest( | |
| 514 | + "session/new", | |
| 515 | + createPayload, | |
| 516 | + acp.agent.createSession(createPayload), | |
| 517 | + ); | |
| 518 | + sessionId = created.sessionId; | |
| 519 | + sessionSetupResult = created; | |
| 520 | + } | |
| 420 | 521 | } else { |
| 421 | 522 | const createPayload = { |
| 422 | 523 | cwd: options.cwd, |
| @@ -430,19 +531,17 @@ const makeAcpSessionRuntime = ( · 7 unmodified lines | ||
| 430 | 531 | sessionId = created.sessionId; |
| 431 | 532 | sessionSetupResult = created; |
| 432 | 533 | } |
| 433 | − } else { | |
| 434 | − const createPayload = { | |
| 435 | − cwd: options.cwd, | |
| 436 | − mcpServers: options.mcpServers ?? [], | |
| 437 | − } satisfies EffectAcpSchema.NewSessionRequest; | |
| 438 | − const created = yield* runLoggedRequest( | |
| 439 | − "session/new", | |
| 440 | − createPayload, | |
| 441 | − acp.agent.createSession(createPayload), | |
| 442 | − ); | |
| 443 | − sessionId = created.sessionId; | |
| 444 | − sessionSetupResult = created; | |
| 445 | − } | |
| 534 | + | |
| 535 | + return { sessionId, sessionSetupResult }; | |
| 536 | + }); | |
| 537 | + | |
| 538 | + const { sessionId, sessionSetupResult } = yield* setupSession.pipe( | |
| 539 | + Effect.catch((error) => | |
| 540 | + isAcpAuthenticationRequired(error) | |
| 541 | + ? authenticateAfterRequired(error).pipe(Effect.andThen(setupSession)) | |
| 542 | + : Effect.fail(error), | |
| 543 | + ), | |
| 544 | + ); | |
| 446 | 545 | |
| 447 | 546 | yield* Ref.set(modeStateRef, parseSessionModeState(sessionSetupResult)); |
| 448 | 547 | yield* Ref.set(configOptionsRef, sessionConfigOptionsFromSetup(sessionSetupResult)); |
| @@ -508,6 +607,82 @@ const makeAcpSessionRuntime = ( · 59 unmodified lines | ||
| 508 | 607 | getEvents: () => Stream.fromQueue(eventQueue), |
| 509 | 608 | getModeState: Ref.get(modeStateRef), |
| 510 | 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 | 686 | prompt: (payload) => |
| 512 | 687 | getStartedState.pipe( |
| 513 | 688 | Effect.flatMap((started) => { |
| @@ -66,4 +66,23 @@ describe.runIf(process.env.T3_GROK_ACP_PROBE === "1")("Grok ACP CLI probe", () = · 65 unmodified lines | ||
| 66 | 66 | yield* runtime.setSessionModel(currentModelId); |
| 67 | 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 | }); |
| @@ -23,10 +23,7 @@ const GROK_DRIVER_KIND = ProviderDriverKind.make("grok"); · 22 unmodified lines | ||
| 23 | 23 | |
| 24 | 24 | type GrokAcpRuntimeGrokSettings = Pick<GrokSettings, "binaryPath">; |
| 25 | 25 | |
| 26 | −interface GrokAcpRuntimeInput extends Omit< | |
| 27 | − AcpSessionRuntimeOptions, | |
| 28 | − "authMethodId" | "clientCapabilities" | "spawn" | |
| 29 | −> { | |
| 26 | +interface GrokAcpRuntimeInput extends Omit<AcpSessionRuntimeOptions, "authMethodId" | "spawn"> { | |
| 30 | 27 | readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; |
| 31 | 28 | readonly grokSettings: GrokAcpRuntimeGrokSettings | null | undefined; |
| 32 | 29 | readonly environment?: NodeJS.ProcessEnv; |
| @@ -64,6 +64,17 @@ export function extractXAiAskUserQuestions( · 63 unmodified lines | ||
| 64 | 64 | })); |
| 65 | 65 | } |
| 66 | 66 | |
| 67 | +export function extractXAiAskUserQuestionIdentity(params: XAiAskUserQuestionRequest): { | |
| 68 | + readonly sessionId: string; | |
| 69 | + readonly toolCallId: string; | |
| 70 | +} { | |
| 71 | + const unwrapped = unwrapAskUserQuestionParams(params); | |
| 72 | + return { | |
| 73 | + sessionId: unwrapped.sessionId, | |
| 74 | + toolCallId: unwrapped.toolCallId, | |
| 75 | + }; | |
| 76 | +} | |
| 77 | + | |
| 67 | 78 | interface XAiAskUserQuestionAnnotation { |
| 68 | 79 | readonly preview?: string; |
| 69 | 80 | readonly notes?: string; |
| @@ -13,7 +13,7 @@ import { useSettings, useUpdateSettings } from "../../hooks/useSettings"; · 12 unmodified lines | ||
| 13 | 13 | import { cn } from "../../lib/utils"; |
| 14 | 14 | import { normalizeProviderAccentColor } from "../../providerInstances"; |
| 15 | 15 | import { Button } from "../ui/button"; |
| 16 | −import { ACPRegistryIcon, Gemini, GithubCopilotIcon, PiAgentIcon, type Icon } from "../Icons"; | |
| 16 | +import { Gemini, GithubCopilotIcon, PiAgentIcon, type Icon } from "../Icons"; | |
| 17 | 17 | import { |
| 18 | 18 | Dialog, |
| 19 | 19 | DialogDescription, |
| @@ -81,11 +81,6 @@ const COMING_SOON_DRIVER_OPTIONS: readonly ComingSoonDriverOption[] = [ · 61 unmodified lines | ||
| 81 | 81 | label: "Gemini", |
| 82 | 82 | icon: Gemini, |
| 83 | 83 | }, |
| 84 | − { | |
| 85 | − value: ProviderDriverKind.make("acpRegistry"), | |
| 86 | − label: "ACP Registry", | |
| 87 | − icon: ACPRegistryIcon, | |
| 88 | − }, | |
| 89 | 84 | { |
| 90 | 85 | value: ProviderDriverKind.make("piAgent"), |
| 91 | 86 | label: "Pi Agent", |
| @@ -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 | 51 | it("preserves unknown config keys while omitting empty configurable fields", () => { |
| 40 | 52 | const opencode = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("opencode")]; |
| 41 | 53 | expect(opencode).toBeDefined(); |
| @@ -1233,6 +1233,35 @@ describe("GeneralSettingsPanel observability", () => { · 1232 unmodified lines | ||
| 1233 | 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 | 1265 | it("runs one-click provider updates from the provider card", async () => { |
| 1237 | 1266 | const updateProvider = vi.fn<LocalApi["server"]["updateProvider"]>().mockResolvedValue({ |
| 1238 | 1267 | providers: [createOutdatedProvider("codex")], |
| @@ -121,6 +121,7 @@ function withoutProviderInstanceFavorites( · 120 unmodified lines | ||
| 121 | 121 | |
| 122 | 122 | const PROVIDER_SETTINGS = DRIVER_OPTIONS.map((definition) => ({ |
| 123 | 123 | provider: definition.value, |
| 124 | + hasDefaultInstance: definition.hasDefaultInstance !== false, | |
| 124 | 125 | })); |
| 125 | 126 | |
| 126 | 127 | function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | null }) { |
| @@ -1032,9 +1033,9 @@ export function ProviderSettingsPanel() { · 905 unmodified lines | ||
| 1032 | 1033 | } |
| 1033 | 1034 | |
| 1034 | 1035 | const defaultSlotIdsBySource = new Set<string>( |
| 1035 | − visibleProviderSettings.map((providerSettings) => | |
| 1036 | − String(defaultInstanceIdForDriver(providerSettings.provider)), | |
| 1037 | − ), | |
| 1036 | + visibleProviderSettings | |
| 1037 | + .filter((providerSettings) => providerSettings.hasDefaultInstance) | |
| 1038 | + .map((providerSettings) => String(defaultInstanceIdForDriver(providerSettings.provider))), | |
| 1038 | 1039 | ); |
| 1039 | 1040 | |
| 1040 | 1041 | const rows: InstanceRow[] = []; |
| @@ -1043,6 +1044,12 @@ export function ProviderSettingsPanel() { · 2 unmodified lines | ||
| 1043 | 1044 | ); |
| 1044 | 1045 | |
| 1045 | 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 | 1053 | type LegacyProviderSettings = (typeof settings.providers)[keyof typeof settings.providers]; |
| 1047 | 1054 | const legacyProviders = settings.providers as Record<string, LegacyProviderSettings>; |
| 1048 | 1055 | const defaultLegacyProviders = DEFAULT_UNIFIED_SETTINGS.providers as Record< |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import { |
| 2 | + AcpRegistrySettings, | |
| 2 | 3 | ClaudeSettings, |
| 3 | 4 | CodexSettings, |
| 4 | 5 | CursorSettings, |
| @@ -7,7 +8,15 @@ import { · 2 unmodified lines | ||
| 7 | 8 | ProviderDriverKind, |
| 8 | 9 | } from "@t3tools/contracts"; |
| 9 | 10 | import type * as Schema from "effect/Schema"; |
| 10 | −import { ClaudeAI, CursorIcon, GrokIcon, type Icon, OpenAI, OpenCodeIcon } from "../Icons"; | |
| 11 | +import { | |
| 12 | + ACPRegistryIcon, | |
| 13 | + ClaudeAI, | |
| 14 | + CursorIcon, | |
| 15 | + GrokIcon, | |
| 16 | + type Icon, | |
| 17 | + OpenAI, | |
| 18 | + OpenCodeIcon, | |
| 19 | +} from "../Icons"; | |
| 11 | 20 | |
| 12 | 21 | type ProviderSettingsSchema = { |
| 13 | 22 | readonly fields: Readonly<Record<string, Schema.Top>>; |
| @@ -24,6 +33,8 @@ export interface ProviderClientDefinition { · 10 unmodified lines | ||
| 24 | 33 | readonly label: string; |
| 25 | 34 | readonly icon: Icon; |
| 26 | 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 | 39 | * Optional short label rendered as a `variant="warning"` badge next to |
| 29 | 40 | * 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 | 72 | badgeLabel: "Early Access", |
| 62 | 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 | 84 | value: ProviderDriverKind.make("opencode"), |
| 66 | 85 | label: "OpenCode", |
| @@ -0,0 +1,82 @@ | ||
| 1 | +import { | |
| 2 | + ProviderDriverKind, | |
| 3 | + ProviderInstanceId, | |
| 4 | + type ProviderInstanceConfigMap, | |
| 5 | + type ServerProvider, | |
| 6 | +} from "@t3tools/contracts"; | |
| 7 | +import { describe, expect, it } from "vite-plus/test"; | |
| 8 | + | |
| 9 | +import { deriveOrchestrationV2DebugProviderSnapshots } from "./orchestrationV2DebugProviders"; | |
| 10 | + | |
| 11 | +const acpRegistry = ProviderDriverKind.make("acpRegistry"); | |
| 12 | +const instanceId = ProviderInstanceId.make("acpRegistry_example"); | |
| 13 | + | |
| 14 | +const unavailable: ServerProvider = { | |
| 15 | + instanceId, | |
| 16 | + driver: acpRegistry, | |
| 17 | + displayName: "Example ACP", | |
| 18 | + enabled: false, | |
| 19 | + installed: false, | |
| 20 | + version: null, | |
| 21 | + status: "error", | |
| 22 | + auth: { status: "unknown" }, | |
| 23 | + checkedAt: "2026-01-01T00:00:00.000Z", | |
| 24 | + availability: "unavailable", | |
| 25 | + unavailableReason: "Driver is not registered in the V1 runtime.", | |
| 26 | + models: [], | |
| 27 | + slashCommands: [], | |
| 28 | + skills: [], | |
| 29 | +}; | |
| 30 | + | |
| 31 | +describe("deriveOrchestrationV2DebugProviderSnapshots", () => { | |
| 32 | + it("replaces the V1 unavailable shadow for a configured registry agent", () => { | |
| 33 | + const providerInstances = { | |
| 34 | + [instanceId]: { | |
| 35 | + driver: acpRegistry, | |
| 36 | + enabled: true, | |
| 37 | + displayName: "Example ACP", | |
| 38 | + config: { | |
| 39 | + agentId: "example-agent", | |
| 40 | + customModels: ["model-one"], | |
| 41 | + }, | |
| 42 | + }, | |
| 43 | + } satisfies ProviderInstanceConfigMap; | |
| 44 | + | |
| 45 | + expect( | |
| 46 | + deriveOrchestrationV2DebugProviderSnapshots({ | |
| 47 | + providers: [unavailable], | |
| 48 | + providerInstances, | |
| 49 | + }), | |
| 50 | + ).toEqual([ | |
| 51 | + expect.objectContaining({ | |
| 52 | + instanceId, | |
| 53 | + driver: acpRegistry, | |
| 54 | + displayName: "Example ACP", | |
| 55 | + enabled: true, | |
| 56 | + installed: true, | |
| 57 | + status: "ready", | |
| 58 | + availability: "available", | |
| 59 | + models: [ | |
| 60 | + expect.objectContaining({ slug: "default", isCustom: false }), | |
| 61 | + expect.objectContaining({ slug: "model-one", isCustom: true }), | |
| 62 | + ], | |
| 63 | + }), | |
| 64 | + ]); | |
| 65 | + }); | |
| 66 | + | |
| 67 | + it("does not make an incomplete registry instance selectable", () => { | |
| 68 | + const providerInstances = { | |
| 69 | + [instanceId]: { | |
| 70 | + driver: acpRegistry, | |
| 71 | + config: { agentId: "" }, | |
| 72 | + }, | |
| 73 | + } satisfies ProviderInstanceConfigMap; | |
| 74 | + | |
| 75 | + expect( | |
| 76 | + deriveOrchestrationV2DebugProviderSnapshots({ | |
| 77 | + providers: [unavailable], | |
| 78 | + providerInstances, | |
| 79 | + }), | |
| 80 | + ).toEqual([unavailable]); | |
| 81 | + }); | |
| 82 | +}); | |
| @@ -0,0 +1,88 @@ | ||
| 1 | +import { | |
| 2 | + ProviderDriverKind, | |
| 3 | + type ProviderInstanceConfigMap, | |
| 4 | + type ProviderInstanceId, | |
| 5 | + type ServerProvider, | |
| 6 | + type ServerProviderModel, | |
| 7 | +} from "@t3tools/contracts"; | |
| 8 | + | |
| 9 | +const ACP_REGISTRY_DRIVER = ProviderDriverKind.make("acpRegistry"); | |
| 10 | +const DEBUG_CHECKED_AT = "1970-01-01T00:00:00.000Z"; | |
| 11 | + | |
| 12 | +function configRecord(config: unknown): Readonly<Record<string, unknown>> { | |
| 13 | + return config !== null && typeof config === "object" && !Array.isArray(config) | |
| 14 | + ? (config as Readonly<Record<string, unknown>>) | |
| 15 | + : {}; | |
| 16 | +} | |
| 17 | + | |
| 18 | +function configuredModels( | |
| 19 | + config: Readonly<Record<string, unknown>>, | |
| 20 | +): ReadonlyArray<ServerProviderModel> { | |
| 21 | + const customModels = Array.isArray(config.customModels) | |
| 22 | + ? config.customModels.filter( | |
| 23 | + (model): model is string => typeof model === "string" && model.trim().length > 0, | |
| 24 | + ) | |
| 25 | + : []; | |
| 26 | + const slugs = [...new Set(["default", ...customModels.map((model) => model.trim())])]; | |
| 27 | + return slugs.map((slug) => ({ | |
| 28 | + slug, | |
| 29 | + name: slug === "default" ? "Agent default" : slug, | |
| 30 | + shortName: slug === "default" ? "Default" : slug, | |
| 31 | + isCustom: slug !== "default", | |
| 32 | + capabilities: null, | |
| 33 | + })); | |
| 34 | +} | |
| 35 | + | |
| 36 | +/** | |
| 37 | + * Add V2-only ACP Registry instances to the orchestration debugger's provider catalog. | |
| 38 | + * The regular application provider catalog remains V1-owned and is intentionally untouched. | |
| 39 | + */ | |
| 40 | +export function deriveOrchestrationV2DebugProviderSnapshots(input: { | |
| 41 | + readonly providers: ReadonlyArray<ServerProvider>; | |
| 42 | + readonly providerInstances: ProviderInstanceConfigMap; | |
| 43 | +}): ReadonlyArray<ServerProvider> { | |
| 44 | + const snapshots = [...input.providers]; | |
| 45 | + const indexByInstanceId = new Map<ProviderInstanceId, number>( | |
| 46 | + snapshots.map((provider, index) => [provider.instanceId, index]), | |
| 47 | + ); | |
| 48 | + | |
| 49 | + for (const [instanceId, instance] of Object.entries(input.providerInstances)) { | |
| 50 | + if (instance.driver !== ACP_REGISTRY_DRIVER) continue; | |
| 51 | + const config = configRecord(instance.config); | |
| 52 | + const agentId = typeof config.agentId === "string" ? config.agentId.trim() : ""; | |
| 53 | + if (agentId.length === 0) continue; | |
| 54 | + const id = instanceId as ProviderInstanceId; | |
| 55 | + const existingIndex = indexByInstanceId.get(id); | |
| 56 | + const existing = existingIndex === undefined ? undefined : snapshots[existingIndex]; | |
| 57 | + const enabled = | |
| 58 | + instance.enabled ?? (typeof config.enabled === "boolean" ? config.enabled : true); | |
| 59 | + const snapshot: ServerProvider = { | |
| 60 | + instanceId: id, | |
| 61 | + driver: ACP_REGISTRY_DRIVER, | |
| 62 | + displayName: instance.displayName?.trim() || `ACP: ${agentId}`, | |
| 63 | + ...(instance.accentColor ? { accentColor: instance.accentColor } : {}), | |
| 64 | + badgeLabel: "V2 Preview", | |
| 65 | + showInteractionModeToggle: true, | |
| 66 | + requiresNewThreadForModelChange: false, | |
| 67 | + enabled, | |
| 68 | + installed: true, | |
| 69 | + version: null, | |
| 70 | + status: enabled ? "ready" : "disabled", | |
| 71 | + auth: { status: "unknown" }, | |
| 72 | + checkedAt: existing?.checkedAt ?? DEBUG_CHECKED_AT, | |
| 73 | + message: `ACP Registry agent '${agentId}' is resolved when the V2 session starts.`, | |
| 74 | + availability: "available", | |
| 75 | + models: configuredModels(config), | |
| 76 | + slashCommands: [], | |
| 77 | + skills: [], | |
| 78 | + }; | |
| 79 | + if (existingIndex === undefined) { | |
| 80 | + indexByInstanceId.set(id, snapshots.length); | |
| 81 | + snapshots.push(snapshot); | |
| 82 | + } else { | |
| 83 | + snapshots[existingIndex] = snapshot; | |
| 84 | + } | |
| 85 | + } | |
| 86 | + | |
| 87 | + return snapshots; | |
| 88 | +} | |
| @@ -36,6 +36,7 @@ import { · 35 unmodified lines | ||
| 36 | 36 | } from "../lib/orchestrationV2Timeline"; |
| 37 | 37 | import { newCommandId, newMessageId, newProjectId, newThreadId } from "../lib/utils"; |
| 38 | 38 | import { type AppModelOption, getAppModelOptionsForInstance } from "../modelSelection"; |
| 39 | +import { deriveOrchestrationV2DebugProviderSnapshots } from "../orchestrationV2DebugProviders"; | |
| 39 | 40 | import { |
| 40 | 41 | type ProviderInstanceEntry, |
| 41 | 42 | deriveProviderInstanceEntries, |
| @@ -1621,7 +1622,14 @@ function OrchestrationV2DebugRoute() { · 1579 unmodified lines | ||
| 1621 | 1622 | const serverProviders = useServerProviders(); |
| 1622 | 1623 | const settings = useSettings(); |
| 1623 | 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 | 1633 | const providerInstanceEntries = useMemo<ReadonlyArray<ProviderInstanceEntry>>( |
| 1626 | 1634 | () => sortProviderInstanceEntries(deriveProviderInstanceEntries(providerSnapshots)), |
| 1627 | 1635 | [providerSnapshots], |
| @@ -90,9 +90,40 @@ Cursor receives the same authenticated HTTP MCP endpoint through the SDK's · 89 unmodified lines | ||
| 90 | 90 | `mcpServers` agent and send options. The adapter passes the authorization header |
| 91 | 91 | to the SDK but projects only redacted option metadata into protocol diagnostics. |
| 92 | 92 | |
| 93 | +### Grok ACP V2 | |
| 94 | + | |
| 95 | +Grok receives the authenticated HTTP MCP endpoint through the ACP | |
| 96 | +`session/new`, `session/load`, and `session/fork` `mcpServers` field. The shared | |
| 97 | +ACP adapter owns standard protocol behavior; the Grok flavor adds xAI extension | |
| 98 | +requests such as structured user questions. | |
| 99 | + | |
| 100 | +ACP does not define native subagents or active steering. Grok therefore uses | |
| 101 | +orchestrator-owned child threads and implements steering through | |
| 102 | +cancel-and-restart. Its current driver also lacks `session/fork`, so app forks | |
| 103 | +use portable context transfer. These are orchestrator policies, not | |
| 104 | +provider-specific MCP tools. | |
| 105 | + | |
| 106 | +### ACP Registry V2 | |
| 107 | + | |
| 108 | +The `acpRegistry` driver is the generic flavor of the same shared ACP adapter. | |
| 109 | +Each provider instance names an agent from the official ACP Registry. At | |
| 110 | +session startup the driver resolves the current platform distribution, uses a | |
| 111 | +managed binary cache or the declared `npx`/`uvx` package, and then negotiates | |
| 112 | +standard ACP capabilities during `initialize`. A local executable may override | |
| 113 | +the managed command without changing the registry-declared arguments or | |
| 114 | +environment. | |
| 115 | + | |
| 116 | +Capabilities such as session loading, session forking, models, modes, and MCP | |
| 117 | +transport are enabled only when the selected agent advertises them. Missing | |
| 118 | +features degrade through V2 policy: steering uses interrupt-and-restart, | |
| 119 | +forking uses portable context when native `session/fork` is unavailable, and | |
| 120 | +subagents use orchestrator-owned child threads. Registry agents do not receive | |
| 121 | +provider-specific extensions; those remain in flavors such as Grok. | |
| 122 | + | |
| 93 | 123 | ### Initial Provider Support |
| 94 | 124 | |
| 95 | −The V2 provider adapters are Codex, Claude Agent SDK, and Cursor Agent SDK. | |
| 125 | +The V2 provider adapters are Codex, Claude Agent SDK, Cursor Agent SDK, and | |
| 126 | +Grok plus generic registry agents over ACP. | |
| 96 | 127 | Capability discovery still reports other registered provider instances, but marks them |
| 97 | 128 | unavailable for orchestration when no V2 adapter exists. This keeps provider |
| 98 | 129 | selection model-visible without allowing a request that cannot run. |
| @@ -299,6 +330,6 @@ Coverage includes: · 200 unmodified lines | ||
| 299 | 330 | - inheritance and per-thread provider overrides; and |
| 300 | 331 | - idempotent retries. |
| 301 | 332 | |
| 302 | −Provider adapter tests separately verify Codex, Claude, and Cursor MCP injection, and | |
| 303 | −the provider-session manager test verifies that credentials exist before an | |
| 304 | −adapter opens and are revoked when it closes. | |
| 333 | +Provider adapter tests separately verify Codex, Claude, Cursor, Grok, and ACP | |
| 334 | +Registry behavior and MCP injection. The provider-session manager test verifies | |
| 335 | +that credentials exist before an adapter opens and are revoked when it closes. | |
| @@ -122,6 +122,14 @@ Claude replay transcript · 121 unmodified lines | ||
| 122 | 122 | Cursor replay transcript |
| 123 | 123 | -> Cursor Agent SDK open/send calls and ordered onDelta/run results |
| 124 | 124 | -> consumed by CursorAdapter |
| 125 | + | |
| 126 | +OpenCode replay transcript | |
| 127 | + -> OpenCode SDK requests/responses plus ordered SSE events | |
| 128 | + -> consumed by OpenCodeAdapter | |
| 129 | + | |
| 130 | +ACP replay transcript | |
| 131 | + -> logical JSON-RPC requests/responses/notifications over a strict NDJSON peer | |
| 132 | + -> consumed by AcpAdapter and a provider flavor (Grok or ACP Registry) | |
| 125 | 133 | ``` |
| 126 | 134 | |
| 127 | 135 | Fixtures should preserve raw provider evidence as closely as possible. Expected V2 events or projections are assertions, not fixture input. |
| @@ -131,6 +139,17 @@ call. A transcript should include the `query` prompt/options we sent and then re · 3 unmodified lines | ||
| 131 | 139 | `SDKMessage` chunks in provider order. This intentionally tests the V2 adapter against real Claude |
| 132 | 140 | SDK output; it does not test the SDK's own subprocess or transport parser. |
| 133 | 141 | |
| 142 | +ACP fixtures run against a child-process replay peer that validates every outbound frame and | |
| 143 | +serves recorded inbound frames. This replaces only the external ACP driver transport; the shared | |
| 144 | +runtime, adapter normalization, orchestration, persistence, and projections remain production code. | |
| 145 | +The generic ACP Registry harness retargets protocol-standard ACP transcripts; | |
| 146 | +provider-extension transcripts remain scoped to the flavor that owns the extension. | |
| 147 | + | |
| 148 | +OpenCode fixtures replace the SDK client at its HTTP/SSE boundary. They must preserve races between | |
| 149 | +request responses and SSE events, because user messages and terminal `session.status` events can | |
| 150 | +arrive before the corresponding `promptAsync` or `abort` response. Subagent fixtures must retain | |
| 151 | +both parent and child session ids so root-only terminal behavior remains testable. | |
| 152 | + | |
| 134 | 153 | Provider transcript recorders live with the server orchestration testkit, not with provider client |
| 135 | 154 | packages. Use `bun run record:codex-replay -- --scenario <name>` for Codex app-server transcripts |
| 136 | 155 | and `bun run record:claude-replay -- --scenario <name>` for Claude Agent SDK transcripts. Use |
| @@ -206,7 +225,7 @@ Testing infrastructure should be built before production rewrites: · 69 unmodified lines | ||
| 206 | 225 | 2. Effect service definitions. |
| 207 | 226 | 3. provider runtime transport abstraction. |
| 208 | 227 | 4. generic replay runtime. |
| 209 | −5. Codex, Claude, and Cursor transcript loaders for app-owned provider replay fixtures. | |
| 228 | +5. Codex, Claude, Cursor, OpenCode, and ACP transcript loaders for app-owned provider replay fixtures. | |
| 210 | 229 | 6. projection reducer tests for core invariants. |
| 211 | 230 | 7. full command-to-projection integration tests. |
| 212 | 231 | 8. production layers. |
| @@ -131,6 +131,7 @@ const CODEX_DRIVER_KIND = ProviderDriverKind.make("codex"); · 130 unmodified lines | ||
| 131 | 131 | const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); |
| 132 | 132 | const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor"); |
| 133 | 133 | const GROK_DRIVER_KIND = ProviderDriverKind.make("grok"); |
| 134 | +const ACP_REGISTRY_DRIVER_KIND = ProviderDriverKind.make("acpRegistry"); | |
| 134 | 135 | const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode"); |
| 135 | 136 | |
| 136 | 137 | export const DEFAULT_MODEL = "gpt-5.4"; |
| @@ -141,6 +142,7 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial<Record<ProviderDriverKind, strin · 4 unmodified lines | ||
| 141 | 142 | [CLAUDE_DRIVER_KIND]: "claude-sonnet-4-6", |
| 142 | 143 | [CURSOR_DRIVER_KIND]: "auto", |
| 143 | 144 | [GROK_DRIVER_KIND]: "grok-build", |
| 145 | + [ACP_REGISTRY_DRIVER_KIND]: "default", | |
| 144 | 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 | 206 | [CLAUDE_DRIVER_KIND]: "Claude", |
| 205 | 207 | [CURSOR_DRIVER_KIND]: "Cursor", |
| 206 | 208 | [GROK_DRIVER_KIND]: "Grok", |
| 209 | + [ACP_REGISTRY_DRIVER_KIND]: "ACP Registry", | |
| 207 | 210 | [OPENCODE_DRIVER_KIND]: "OpenCode", |
| 208 | 211 | }; |
| @@ -305,6 +305,56 @@ export const GrokSettings = makeProviderSettingsSchema( · 304 unmodified lines | ||
| 305 | 305 | ); |
| 306 | 306 | export type GrokSettings = typeof GrokSettings.Type; |
| 307 | 307 | |
| 308 | +export const AcpRegistryDistributionPreference = Schema.Literals(["auto", "binary", "npx", "uvx"]); | |
| 309 | +export type AcpRegistryDistributionPreference = typeof AcpRegistryDistributionPreference.Type; | |
| 310 | + | |
| 311 | +export const AcpRegistrySettings = makeProviderSettingsSchema( | |
| 312 | + { | |
| 313 | + enabled: Schema.Boolean.pipe( | |
| 314 | + Schema.withDecodingDefault(Effect.succeed(true)), | |
| 315 | + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), | |
| 316 | + ), | |
| 317 | + agentId: TrimmedString.pipe( | |
| 318 | + Schema.withDecodingDefault(Effect.succeed("")), | |
| 319 | + Schema.annotateKey({ | |
| 320 | + title: "Registry agent ID", | |
| 321 | + description: "Agent identifier from the official ACP Registry, for example 'devin'.", | |
| 322 | + providerSettingsForm: { placeholder: "devin", clearWhenEmpty: "persist" }, | |
| 323 | + }), | |
| 324 | + ), | |
| 325 | + commandPath: TrimmedString.pipe( | |
| 326 | + Schema.withDecodingDefault(Effect.succeed("")), | |
| 327 | + Schema.annotateKey({ | |
| 328 | + title: "Executable override", | |
| 329 | + description: | |
| 330 | + "Optional local executable to use instead of installing the registry distribution. Registry arguments and environment are still applied.", | |
| 331 | + providerSettingsForm: { placeholder: "devin", clearWhenEmpty: "omit" }, | |
| 332 | + }), | |
| 333 | + ), | |
| 334 | + authMethodId: TrimmedString.pipe( | |
| 335 | + Schema.withDecodingDefault(Effect.succeed("")), | |
| 336 | + Schema.annotateKey({ | |
| 337 | + title: "Authentication method", | |
| 338 | + description: | |
| 339 | + "Optional ACP authentication method ID. By default, the first agent-managed method is selected.", | |
| 340 | + providerSettingsForm: { placeholder: "auto", clearWhenEmpty: "omit" }, | |
| 341 | + }), | |
| 342 | + ), | |
| 343 | + distribution: AcpRegistryDistributionPreference.pipe( | |
| 344 | + Schema.withDecodingDefault(Effect.succeed("auto")), | |
| 345 | + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), | |
| 346 | + ), | |
| 347 | + customModels: Schema.Array(Schema.String).pipe( | |
| 348 | + Schema.withDecodingDefault(Effect.succeed([])), | |
| 349 | + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), | |
| 350 | + ), | |
| 351 | + }, | |
| 352 | + { | |
| 353 | + order: ["agentId", "commandPath", "authMethodId"], | |
| 354 | + }, | |
| 355 | +); | |
| 356 | +export type AcpRegistrySettings = typeof AcpRegistrySettings.Type; | |
| 357 | + | |
| 308 | 358 | export const OpenCodeSettings = makeProviderSettingsSchema( |
| 309 | 359 | { |
| 310 | 360 | enabled: Schema.Boolean.pipe( |