- 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 | const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1"; | 23 | const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1"; |
| 24 | const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT; | 24 | const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT; |
| 25 | const promptDelayMs = Number(process.env.T3_ACP_PROMPT_DELAY_MS ?? "0"); | 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 | const permissionOptionIds = { | 29 | const permissionOptionIds = { |
| 27 | allowOnce: process.env.T3_ACP_ALLOW_ONCE_OPTION_ID ?? "allow-once", | 30 | allowOnce: process.env.T3_ACP_ALLOW_ONCE_OPTION_ID ?? "allow-once", |
| 28 | allowAlways: process.env.T3_ACP_ALLOW_ALWAYS_OPTION_ID ?? "allow-always", | 31 | allowAlways: process.env.T3_ACP_ALLOW_ALWAYS_OPTION_ID ?? "allow-always", |
| @@ -36,6 +39,7 @@ let parameterizedModelPicker = false; · 7 unmodified lines | |||
| 36 | let currentReasoning = "medium"; | 39 | let currentReasoning = "medium"; |
| 37 | let currentContext = "272k"; | 40 | let currentContext = "272k"; |
| 38 | let currentFast = false; | 41 | let currentFast = false; |
| 42 | let authenticated = !requiresAuthentication; | ||
| 39 | const cancelledSessions = new Set<string>(); | 43 | const cancelledSessions = new Set<string>(); |
| 40 | 44 | ||
| 41 | function logExit(reason: string): void { | 45 | function logExit(reason: string): void { |
| @@ -268,19 +272,56 @@ const program = Effect.gen(function* () { · 226 unmodified lines | |||
| 268 | request.clientCapabilities?._meta?.parameterizedModelPicker === true; | 272 | request.clientCapabilities?._meta?.parameterizedModelPicker === true; |
| 269 | return { | 273 | return { |
| 270 | protocolVersion: 1, | 274 | protocolVersion: 1, |
| 271 | agentCapabilities: { loadSession: true }, | 275 | agentCapabilities: { |
| 276 | loadSession: true, | ||
| 277 | ...(supportsSessionLifecycle | ||
| 278 | ? { | ||
| 279 | sessionCapabilities: { | ||
| 280 | list: {}, | ||
| 281 | fork: {}, | ||
| 282 | resume: {}, | ||
| 283 | close: {}, | ||
| 284 | }, | ||
| 285 | } | ||
| 286 | : {}), | ||
| 287 | }, | ||
| 288 | ...(advertisedAuthMethodId | ||
| 289 | ? { | ||
| 290 | authMethods: [ | ||
| 291 | { | ||
| 292 | id: advertisedAuthMethodId, | ||
| 293 | name: "Mock agent authentication", | ||
| 294 | }, | ||
| 295 | ], | ||
| 296 | } | ||
| 297 | : {}), | ||
| 272 | }; | 298 | }; |
| 273 | }), | 299 | }), |
| 274 | ); | 300 | ); |
| 275 | 301 | ||
| 276 | yield* agent.handleAuthenticate(() => Effect.succeed({})); | 302 | yield* agent.handleAuthenticate((request) => |
| 303 | Effect.gen(function* () { | ||
| 304 | if (advertisedAuthMethodId && request.methodId !== advertisedAuthMethodId) { | ||
| 305 | return yield* AcpError.AcpRequestError.invalidParams( | ||
| 306 | `Unknown mock authentication method: ${request.methodId}`, | ||
| 307 | ); | ||
| 308 | } | ||
| 309 | authenticated = true; | ||
| 310 | return {}; | ||
| 311 | }), | ||
| 312 | ); | ||
| 277 | 313 | ||
| 278 | yield* agent.handleCreateSession(() => | 314 | yield* agent.handleCreateSession(() => |
| 279 | Effect.succeed({ | 315 | Effect.gen(function* () { |
| 280 | sessionId, | 316 | if (!authenticated) { |
| 281 | modes: modeState(), | 317 | return yield* AcpError.AcpRequestError.authRequired(); |
| 282 | models: modelState(), | 318 | } |
| 283 | configOptions: configOptions(), | 319 | return { |
| 320 | sessionId, | ||
| 321 | modes: modeState(), | ||
| 322 | models: modelState(), | ||
| 323 | configOptions: configOptions(), | ||
| 324 | }; | ||
| 284 | }), | 325 | }), |
| 285 | ); | 326 | ); |
| 286 | 327 | ||
| @@ -302,6 +343,38 @@ const program = Effect.gen(function* () { · 15 unmodified lines | |||
| 302 | ), | 343 | ), |
| 303 | ); | 344 | ); |
| 304 | 345 | ||
| 346 | yield* agent.handleListSessions((request) => | ||
| 347 | Effect.succeed({ | ||
| 348 | sessions: [ | ||
| 349 | { | ||
| 350 | sessionId, | ||
| 351 | cwd: request.cwd ?? process.cwd(), | ||
| 352 | title: "Mock session", | ||
| 353 | updatedAt: "1970-01-01T00:00:00.000Z", | ||
| 354 | }, | ||
| 355 | ], | ||
| 356 | }), | ||
| 357 | ); | ||
| 358 | |||
| 359 | yield* agent.handleForkSession((request) => | ||
| 360 | Effect.succeed({ | ||
| 361 | sessionId: `${request.sessionId}-fork`, | ||
| 362 | modes: modeState(), | ||
| 363 | models: modelState(), | ||
| 364 | configOptions: configOptions(), | ||
| 365 | }), | ||
| 366 | ); | ||
| 367 | |||
| 368 | yield* agent.handleResumeSession(() => | ||
| 369 | Effect.succeed({ | ||
| 370 | modes: modeState(), | ||
| 371 | models: modelState(), | ||
| 372 | configOptions: configOptions(), | ||
| 373 | }), | ||
| 374 | ); | ||
| 375 | |||
| 376 | yield* agent.handleCloseSession(() => Effect.succeed({})); | ||
| 377 | |||
| 305 | yield* agent.handleSetSessionModel((request) => | 378 | yield* agent.handleSetSessionModel((request) => |
| 306 | Effect.gen(function* () { | 379 | Effect.gen(function* () { |
| 307 | if (!grokAcpModels.some((model) => model.modelId === request.modelId)) { | 380 | if (!grokAcpModels.some((model) => model.modelId === request.modelId)) { |
| @@ -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 | import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; | 5 | import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; |
| 6 | import { CursorProviderCapabilitiesV2 } from "./Adapters/CursorAdapterV2.ts"; | 6 | import { CursorProviderCapabilitiesV2 } from "./Adapters/CursorAdapterV2.ts"; |
| 7 | import { GrokProviderCapabilitiesV2 } from "./Adapters/GrokAdapterV2.ts"; | ||
| 7 | import { | 8 | import { |
| 8 | CommandPolicyCapabilityUnsupportedError, | 9 | CommandPolicyCapabilityUnsupportedError, |
| 9 | CommandPolicyV2, | 10 | CommandPolicyV2, |
| @@ -62,6 +63,21 @@ layer("CommandPolicyV2", (it) => { · 52 unmodified lines | |||
| 62 | }), | 63 | }), |
| 63 | ); | 64 | ); |
| 64 | 65 | ||
| 66 | it.effect("uses interrupt-and-restart steering for Grok ACP", () => | ||
| 67 | Effect.gen(function* () { | ||
| 68 | const policy = yield* CommandPolicyV2; | ||
| 69 | |||
| 70 | const result = yield* policy.decideSteeringExecution({ | ||
| 71 | commandId, | ||
| 72 | threadId, | ||
| 73 | provider: "grok", | ||
| 74 | capabilities: GrokProviderCapabilitiesV2, | ||
| 75 | }); | ||
| 76 | |||
| 77 | assert.equal(result, "interrupt_restart"); | ||
| 78 | }), | ||
| 79 | ); | ||
| 80 | |||
| 65 | it.effect("honors an explicit interrupt-and-restart request", () => | 81 | it.effect("honors an explicit interrupt-and-restart request", () => |
| 66 | Effect.gen(function* () { | 82 | Effect.gen(function* () { |
| 67 | const policy = yield* CommandPolicyV2; | 83 | const policy = yield* CommandPolicyV2; |
| @@ -165,6 +181,24 @@ layer("CommandPolicyV2", (it) => { · 97 unmodified lines | |||
| 165 | }), | 181 | }), |
| 166 | ); | 182 | ); |
| 167 | 183 | ||
| 184 | it.effect("falls back to portable context when Grok ACP cannot fork natively", () => | ||
| 185 | Effect.gen(function* () { | ||
| 186 | const policy = yield* CommandPolicyV2; | ||
| 187 | |||
| 188 | const result = yield* policy.decideForkExecution({ | ||
| 189 | commandId, | ||
| 190 | threadId, | ||
| 191 | provider: "grok", | ||
| 192 | capabilities: GrokProviderCapabilitiesV2, | ||
| 193 | sameProvider: true, | ||
| 194 | hasStrongNativeSource: true, | ||
| 195 | fromSpecificTurn: true, | ||
| 196 | }); | ||
| 197 | |||
| 198 | assert.equal(result, "portable_context"); | ||
| 199 | }), | ||
| 200 | ); | ||
| 201 | |||
| 168 | it.effect("returns a typed error when neither native nor portable fork is available", () => | 202 | it.effect("returns a typed error when neither native nor portable fork is available", () => |
| 169 | Effect.gen(function* () { | 203 | Effect.gen(function* () { |
| 170 | const policy = yield* CommandPolicyV2; | 204 | const policy = yield* CommandPolicyV2; |
| @@ -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 | import type { ProviderDriverKind } from "@t3tools/contracts"; | 1 | import type { ProviderDriverKind } from "@t3tools/contracts"; |
| 2 | 2 | ||
| 3 | import { | ||
| 4 | AcpRegistryAdapterV2Driver, | ||
| 5 | type AcpRegistryAdapterV2DriverEnv, | ||
| 6 | } from "./Adapters/AcpRegistryAdapterV2.ts"; | ||
| 3 | import { | 7 | import { |
| 4 | ClaudeAdapterV2Driver, | 8 | ClaudeAdapterV2Driver, |
| 5 | type ClaudeAdapterV2DriverEnv, | 9 | type ClaudeAdapterV2DriverEnv, |
| @@ -9,16 +13,31 @@ import { · 3 unmodified lines | |||
| 9 | CursorAdapterV2Driver, | 13 | CursorAdapterV2Driver, |
| 10 | type CursorAdapterV2DriverEnv, | 14 | type CursorAdapterV2DriverEnv, |
| 11 | } from "./Adapters/CursorAdapterV2.ts"; | 15 | } from "./Adapters/CursorAdapterV2.ts"; |
| 16 | import { GrokAdapterV2Driver, type GrokAdapterV2DriverEnv } from "./Adapters/GrokAdapterV2.ts"; | ||
| 17 | import { | ||
| 18 | OpenCodeAdapterV2Driver, | ||
| 19 | type OpenCodeAdapterV2DriverEnv, | ||
| 20 | } from "./Adapters/OpenCodeAdapterV2.ts"; | ||
| 12 | import type { AnyProviderAdapterDriver } from "./ProviderAdapterDriver.ts"; | 21 | import type { AnyProviderAdapterDriver } from "./ProviderAdapterDriver.ts"; |
| 13 | 22 | ||
| 14 | export type BuiltInProviderAdapterDriversV2Env = | 23 | export type BuiltInProviderAdapterDriversV2Env = |
| 24 | | AcpRegistryAdapterV2DriverEnv | ||
| 15 | | ClaudeAdapterV2DriverEnv | 25 | | ClaudeAdapterV2DriverEnv |
| 16 | | CodexAdapterV2DriverEnv | 26 | | CodexAdapterV2DriverEnv |
| 17 | | CursorAdapterV2DriverEnv; | 27 | | CursorAdapterV2DriverEnv |
| 28 | | GrokAdapterV2DriverEnv | ||
| 29 | | OpenCodeAdapterV2DriverEnv; | ||
| 18 | 30 | ||
| 19 | export const BUILT_IN_PROVIDER_ADAPTER_DRIVERS_V2: ReadonlyArray< | 31 | export const BUILT_IN_PROVIDER_ADAPTER_DRIVERS_V2: ReadonlyArray< |
| 20 | AnyProviderAdapterDriver<BuiltInProviderAdapterDriversV2Env> | 32 | AnyProviderAdapterDriver<BuiltInProviderAdapterDriversV2Env> |
| 21 | > = [CodexAdapterV2Driver, ClaudeAdapterV2Driver, CursorAdapterV2Driver]; | 33 | > = [ |
| 34 | CodexAdapterV2Driver, | ||
| 35 | ClaudeAdapterV2Driver, | ||
| 36 | CursorAdapterV2Driver, | ||
| 37 | OpenCodeAdapterV2Driver, | ||
| 38 | GrokAdapterV2Driver, | ||
| 39 | AcpRegistryAdapterV2Driver, | ||
| 40 | ]; | ||
| 22 | 41 | ||
| 23 | export const BUILT_IN_PROVIDER_ADAPTER_DRIVER_KINDS_V2: ReadonlySet<ProviderDriverKind> = new Set( | 42 | export const BUILT_IN_PROVIDER_ADAPTER_DRIVER_KINDS_V2: ReadonlySet<ProviderDriverKind> = new Set( |
| 24 | BUILT_IN_PROVIDER_ADAPTER_DRIVERS_V2.map((driver) => driver.driverKind), | 43 | BUILT_IN_PROVIDER_ADAPTER_DRIVERS_V2.map((driver) => driver.driverKind), |
| @@ -1,5 +1,7 @@ | |||
| 1 | import * as Layer from "effect/Layer"; | 1 | import * as Layer from "effect/Layer"; |
| 2 | import { FetchHttpClient } from "effect/unstable/http"; | ||
| 2 | 3 | ||
| 4 | import { OpenCodeRuntimeLive } from "../provider/opencodeRuntime.ts"; | ||
| 3 | import { claudeAgentSdkQueryRunnerLiveLayer } from "./Adapters/ClaudeAdapterV2.ts"; | 5 | import { claudeAgentSdkQueryRunnerLiveLayer } from "./Adapters/ClaudeAdapterV2.ts"; |
| 4 | import { codexAppServerClientFactoryFromSettingsLayer } from "./Adapters/CodexAdapterV2.ts"; | 6 | import { codexAppServerClientFactoryFromSettingsLayer } from "./Adapters/CodexAdapterV2.ts"; |
| 5 | import { cursorAgentSdkRunnerLiveLayer } from "./Adapters/CursorAgentSdk.ts"; | 7 | import { cursorAgentSdkRunnerLiveLayer } from "./Adapters/CursorAgentSdk.ts"; |
| @@ -40,6 +42,8 @@ const providerAdapterRegistryProvided = providerAdapterRegistryLayerFromSettings · 34 unmodified lines | |||
| 40 | Layer.provide(codexAppServerClientFactoryFromSettingsLayer), | 42 | Layer.provide(codexAppServerClientFactoryFromSettingsLayer), |
| 41 | Layer.provide(claudeAgentSdkQueryRunnerLiveLayer), | 43 | Layer.provide(claudeAgentSdkQueryRunnerLiveLayer), |
| 42 | Layer.provide(cursorAgentSdkRunnerLiveLayer), | 44 | Layer.provide(cursorAgentSdkRunnerLiveLayer), |
| 45 | Layer.provide(OpenCodeRuntimeLive), | ||
| 46 | Layer.provide(FetchHttpClient.layer), | ||
| 43 | Layer.provide(idAllocatorLayer), | 47 | Layer.provide(idAllocatorLayer), |
| 44 | ); | 48 | ); |
| 45 | 49 | ||
| @@ -49,7 +49,15 @@ describe("orchestrator replay fixture contract", () => { · 48 unmodified lines | |||
| 49 | const firstCommand = materialized.commands[0]; | 49 | const firstCommand = materialized.commands[0]; |
| 50 | 50 | ||
| 51 | assert.equal(transcript.scenario, fixture.name); | 51 | assert.equal(transcript.scenario, fixture.name); |
| 52 | assert.equal(transcript.provider, provider.provider); | 52 | if (provider.provider === "acpRegistry") { |
| 53 | assert.include( | ||
| 54 | ["acpRegistry", "grok"], | ||
| 55 | transcript.provider, | ||
| 56 | "ACP Registry may retarget protocol-standard Grok ACP evidence", | ||
| 57 | ); | ||
| 58 | } else { | ||
| 59 | assert.equal(transcript.provider, provider.provider); | ||
| 60 | } | ||
| 53 | assert.equal(provider.modelSelection.instanceId, provider.provider); | 61 | assert.equal(provider.modelSelection.instanceId, provider.provider); |
| 54 | assert.isDefined(materialized.projectionThreadIds[0]); | 62 | assert.isDefined(materialized.projectionThreadIds[0]); |
| 55 | assert.equal(firstCommand?.type, "thread.create"); | 63 | assert.equal(firstCommand?.type, "thread.create"); |
| @@ -7,6 +7,9 @@ import * as FileSystem from "effect/FileSystem"; · 6 unmodified lines | |||
| 7 | import { ClaudeOrchestratorReplayHarness } from "../Adapters/ClaudeAdapterV2.testkit.ts"; | 7 | import { ClaudeOrchestratorReplayHarness } from "../Adapters/ClaudeAdapterV2.testkit.ts"; |
| 8 | import { CodexOrchestratorReplayHarness } from "../Adapters/CodexAdapterV2.testkit.ts"; | 8 | import { CodexOrchestratorReplayHarness } from "../Adapters/CodexAdapterV2.testkit.ts"; |
| 9 | import { CursorOrchestratorReplayHarness } from "../Adapters/CursorAdapterV2.testkit.ts"; | 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 | import { layer as idAllocatorLayer } from "../IdAllocator.ts"; | 13 | import { layer as idAllocatorLayer } from "../IdAllocator.ts"; |
| 11 | import { provideDeterministicTestRuntime } from "./DeterministicRuntime.ts"; | 14 | import { provideDeterministicTestRuntime } from "./DeterministicRuntime.ts"; |
| 12 | import { ORCHESTRATOR_REPLAY_FIXTURES } from "./fixtures/index.ts"; | 15 | import { ORCHESTRATOR_REPLAY_FIXTURES } from "./fixtures/index.ts"; |
| @@ -118,6 +121,21 @@ function runFixtureProviderWithRegisteredHarness(input: { · 105 unmodified lines | |||
| 118 | ...input, | 121 | ...input, |
| 119 | harness: CursorOrchestratorReplayHarness, | 122 | harness: CursorOrchestratorReplayHarness, |
| 120 | }).pipe(Effect.mapError(normalizeTestError), Effect.scoped); | 123 | }).pipe(Effect.mapError(normalizeTestError), Effect.scoped); |
| 124 | case "grok": | ||
| 125 | return runFixtureProvider({ | ||
| 126 | ...input, | ||
| 127 | harness: GrokOrchestratorReplayHarness, | ||
| 128 | }).pipe(Effect.mapError(normalizeTestError), Effect.scoped); | ||
| 129 | case "acpRegistry": | ||
| 130 | return runFixtureProvider({ | ||
| 131 | ...input, | ||
| 132 | harness: AcpRegistryOrchestratorReplayHarness, | ||
| 133 | }).pipe(Effect.mapError(normalizeTestError), Effect.scoped); | ||
| 134 | case "opencode": | ||
| 135 | return runFixtureProvider({ | ||
| 136 | ...input, | ||
| 137 | harness: OpenCodeOrchestratorReplayHarness, | ||
| 138 | }).pipe(Effect.mapError(normalizeTestError), Effect.scoped); | ||
| 121 | default: | 139 | default: |
| 122 | return Effect.die( | 140 | return Effect.die( |
| 123 | new Error(`No replay harness registered for provider ${input.provider.provider}.`), | 141 | new Error(`No replay harness registered for provider ${input.provider.provider}.`), |
| @@ -41,7 +41,7 @@ import { · 40 unmodified lines | |||
| 41 | type OrchestratorV2ScenarioResult, | 41 | type OrchestratorV2ScenarioResult, |
| 42 | } from "./OrchestratorScenario.ts"; | 42 | } from "./OrchestratorScenario.ts"; |
| 43 | 43 | ||
| 44 | function makeReplayServerConfig( | 44 | export function makeReplayServerConfig( |
| 45 | scenario: string, | 45 | scenario: string, |
| 46 | ): Effect.Effect< | 46 | ): Effect.Effect< |
| 47 | ServerConfigShape, | 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 | import { assertClaudeMessageSteeringOutput } from "./message_steering/claude_output.ts"; | 1 | import { assertClaudeMessageSteeringOutput } from "./message_steering/claude_output.ts"; |
| 2 | import { assertMessageSteeringOutput } from "./message_steering/codex_output.ts"; | 2 | import { assertMessageSteeringOutput } from "./message_steering/codex_output.ts"; |
| 3 | import { assertCursorMessageSteeringOutput } from "./message_steering/cursor_output.ts"; | 3 | import { assertCursorMessageSteeringOutput } from "./message_steering/cursor_output.ts"; |
| 4 | import { assertGrokMessageSteeringOutput } from "./message_steering/grok_output.ts"; | ||
| 4 | import { messageSteeringInput } from "./message_steering/input.ts"; | 5 | import { messageSteeringInput } from "./message_steering/input.ts"; |
| 5 | import { assertMultiTurnClaudeOutput } from "./multi_turn/claude_output.ts"; | 6 | import { assertMultiTurnClaudeOutput } from "./multi_turn/claude_output.ts"; |
| 6 | import { assertMultiTurnOutput } from "./multi_turn/codex_output.ts"; | 7 | import { assertMultiTurnOutput } from "./multi_turn/codex_output.ts"; |
| 7 | import { multiTurnInput } from "./multi_turn/input.ts"; | 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 | import { assertPlanQuestionsOutput } from "./plan_questions/codex_output.ts"; | 11 | import { assertPlanQuestionsOutput } from "./plan_questions/codex_output.ts"; |
| 12 | import { assertOpenCodePlanQuestionsOutput } from "./plan_questions/opencode_output.ts"; | ||
| 9 | import { planQuestionsInput } from "./plan_questions/input.ts"; | 13 | import { planQuestionsInput } from "./plan_questions/input.ts"; |
| 10 | import { assertProposedPlanOutput } from "./proposed_plan/codex_output.ts"; | 14 | import { assertProposedPlanOutput } from "./proposed_plan/codex_output.ts"; |
| 11 | import { assertProposedPlanCursorOutput } from "./proposed_plan/cursor_output.ts"; | 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 | import { threadRollbackInput } from "./thread_rollback/input.ts"; | 30 | import { threadRollbackInput } from "./thread_rollback/input.ts"; |
| 27 | import { assertTodoListOutput } from "./todo_list/codex_output.ts"; | 31 | import { assertTodoListOutput } from "./todo_list/codex_output.ts"; |
| 28 | import { assertTodoListCursorOutput } from "./todo_list/cursor_output.ts"; | 32 | import { assertTodoListCursorOutput } from "./todo_list/cursor_output.ts"; |
| 33 | import { assertTodoListGrokOutput } from "./todo_list/grok_output.ts"; | ||
| 29 | import { todoListInput } from "./todo_list/input.ts"; | 34 | import { todoListInput } from "./todo_list/input.ts"; |
| 30 | import { assertToolCallReadOnlyClaudeOutput } from "./tool_call_read_only/claude_output.ts"; | 35 | import { assertToolCallReadOnlyClaudeOutput } from "./tool_call_read_only/claude_output.ts"; |
| 31 | import { assertToolCallReadOnlyCursorOutput } from "./tool_call_read_only/cursor_output.ts"; | 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 | import { assertWebSearchOutput } from "./web_search/codex_output.ts"; | 57 | import { assertWebSearchOutput } from "./web_search/codex_output.ts"; |
| 53 | import { webSearchInput } from "./web_search/input.ts"; | 58 | import { webSearchInput } from "./web_search/input.ts"; |
| 54 | import { | 59 | import { |
| 60 | ACP_REGISTRY_MODEL_SELECTION, | ||
| 55 | CLAUDE_MODEL_SELECTION, | 61 | CLAUDE_MODEL_SELECTION, |
| 56 | CODEX_MODEL_SELECTION, | 62 | CODEX_MODEL_SELECTION, |
| 57 | CURSOR_MODEL_SELECTION, | 63 | CURSOR_MODEL_SELECTION, |
| 64 | GROK_MODEL_SELECTION, | ||
| 65 | OPENCODE_MODEL_SELECTION, | ||
| 58 | READ_ONLY_NEVER_POLICY, | 66 | READ_ONLY_NEVER_POLICY, |
| 59 | READ_ONLY_ON_REQUEST_POLICY, | 67 | READ_ONLY_ON_REQUEST_POLICY, |
| 60 | RESTRICTED_GRANULAR_POLICY, | 68 | RESTRICTED_GRANULAR_POLICY, |
| @@ -63,6 +71,26 @@ import { · 2 unmodified lines | |||
| 63 | } from "./shared.ts"; | 71 | } from "./shared.ts"; |
| 64 | 72 | ||
| 65 | export const ORCHESTRATOR_REPLAY_FIXTURES = [ | 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 | name: "simple", | 95 | name: "simple", |
| 68 | buildInput: simpleInput, | 96 | buildInput: simpleInput, |
| @@ -85,6 +113,24 @@ export const ORCHESTRATOR_REPLAY_FIXTURES = [ · 16 unmodified lines | |||
| 85 | modelSelection: CURSOR_MODEL_SELECTION, | 113 | modelSelection: CURSOR_MODEL_SELECTION, |
| 86 | assertOutput: assertSimpleOutput, | 114 | assertOutput: assertSimpleOutput, |
| 87 | }, | 115 | }, |
| 116 | { | ||
| 117 | provider: "grok", | ||
| 118 | transcriptFile: new URL("./simple/grok_transcript.ndjson", import.meta.url), | ||
| 119 | modelSelection: GROK_MODEL_SELECTION, | ||
| 120 | assertOutput: assertSimpleOutput, | ||
| 121 | }, | ||
| 122 | { | ||
| 123 | provider: "acpRegistry", | ||
| 124 | transcriptFile: new URL("./simple/grok_transcript.ndjson", import.meta.url), | ||
| 125 | modelSelection: ACP_REGISTRY_MODEL_SELECTION, | ||
| 126 | assertOutput: assertSimpleOutput, | ||
| 127 | }, | ||
| 128 | { | ||
| 129 | provider: "opencode", | ||
| 130 | transcriptFile: new URL("./simple/opencode_transcript.ndjson", import.meta.url), | ||
| 131 | modelSelection: OPENCODE_MODEL_SELECTION, | ||
| 132 | assertOutput: assertSimpleOutput, | ||
| 133 | }, | ||
| 88 | ], | 134 | ], |
| 89 | }, | 135 | }, |
| 90 | { | 136 | { |
| @@ -105,6 +151,20 @@ export const ORCHESTRATOR_REPLAY_FIXTURES = [ · 14 unmodified lines | |||
| 105 | runtimePolicyOverride: READ_ONLY_NEVER_POLICY, | 151 | runtimePolicyOverride: READ_ONLY_NEVER_POLICY, |
| 106 | assertOutput: assertToolCallReadOnlyCursorOutput, | 152 | assertOutput: assertToolCallReadOnlyCursorOutput, |
| 107 | }, | 153 | }, |
| 154 | { | ||
| 155 | provider: "grok", | ||
| 156 | transcriptFile: new URL("./tool_call_read_only/grok_transcript.ndjson", import.meta.url), | ||
| 157 | modelSelection: GROK_MODEL_SELECTION, | ||
| 158 | runtimePolicyOverride: READ_ONLY_NEVER_POLICY, | ||
| 159 | assertOutput: assertToolCallReadOnlyCursorOutput, | ||
| 160 | }, | ||
| 161 | { | ||
| 162 | provider: "acpRegistry", | ||
| 163 | transcriptFile: new URL("./tool_call_read_only/grok_transcript.ndjson", import.meta.url), | ||
| 164 | modelSelection: ACP_REGISTRY_MODEL_SELECTION, | ||
| 165 | runtimePolicyOverride: READ_ONLY_NEVER_POLICY, | ||
| 166 | assertOutput: assertToolCallReadOnlyCursorOutput, | ||
| 167 | }, | ||
| 108 | ], | 168 | ], |
| 109 | }, | 169 | }, |
| 110 | { | 170 | { |
| @@ -131,6 +191,26 @@ export const ORCHESTRATOR_REPLAY_FIXTURES = [ · 20 unmodified lines | |||
| 131 | runtimePolicyOverride: READ_ONLY_ON_REQUEST_POLICY, | 191 | runtimePolicyOverride: READ_ONLY_ON_REQUEST_POLICY, |
| 132 | assertOutput: assertToolCallReadOnlyOnRequestClaudeOutput, | 192 | assertOutput: assertToolCallReadOnlyOnRequestClaudeOutput, |
| 133 | }, | 193 | }, |
| 194 | { | ||
| 195 | provider: "grok", | ||
| 196 | transcriptFile: new URL( | ||
| 197 | "./tool_call_read_only_on_request/grok_transcript.ndjson", | ||
| 198 | import.meta.url, | ||
| 199 | ), | ||
| 200 | modelSelection: GROK_MODEL_SELECTION, | ||
| 201 | runtimePolicyOverride: READ_ONLY_ON_REQUEST_POLICY, | ||
| 202 | assertOutput: assertToolCallReadOnlyOnRequestOutput, | ||
| 203 | }, | ||
| 204 | { | ||
| 205 | provider: "acpRegistry", | ||
| 206 | transcriptFile: new URL( | ||
| 207 | "./tool_call_read_only_on_request/grok_transcript.ndjson", | ||
| 208 | import.meta.url, | ||
| 209 | ), | ||
| 210 | modelSelection: ACP_REGISTRY_MODEL_SELECTION, | ||
| 211 | runtimePolicyOverride: READ_ONLY_ON_REQUEST_POLICY, | ||
| 212 | assertOutput: assertToolCallReadOnlyOnRequestOutput, | ||
| 213 | }, | ||
| 134 | ], | 214 | ], |
| 135 | }, | 215 | }, |
| 136 | { | 216 | { |
| @@ -223,6 +303,18 @@ export const ORCHESTRATOR_REPLAY_FIXTURES = [ · 86 unmodified lines | |||
| 223 | }, | 303 | }, |
| 224 | ], | 304 | ], |
| 225 | }, | 305 | }, |
| 306 | { | ||
| 307 | name: "opencode_subagent", | ||
| 308 | buildInput: openCodeSubagentInput, | ||
| 309 | providers: [ | ||
| 310 | { | ||
| 311 | provider: "opencode", | ||
| 312 | transcriptFile: new URL("./opencode_subagent/opencode_transcript.ndjson", import.meta.url), | ||
| 313 | modelSelection: OPENCODE_MODEL_SELECTION, | ||
| 314 | assertOutput: assertOpenCodeSubagentOutput, | ||
| 315 | }, | ||
| 316 | ], | ||
| 317 | }, | ||
| 226 | { | 318 | { |
| 227 | name: "multi_turn", | 319 | name: "multi_turn", |
| 228 | buildInput: multiTurnInput, | 320 | buildInput: multiTurnInput, |
| @@ -245,6 +337,18 @@ export const ORCHESTRATOR_REPLAY_FIXTURES = [ · 16 unmodified lines | |||
| 245 | modelSelection: CURSOR_MODEL_SELECTION, | 337 | modelSelection: CURSOR_MODEL_SELECTION, |
| 246 | assertOutput: assertMultiTurnOutput, | 338 | assertOutput: assertMultiTurnOutput, |
| 247 | }, | 339 | }, |
| 340 | { | ||
| 341 | provider: "grok", | ||
| 342 | transcriptFile: new URL("./multi_turn/grok_transcript.ndjson", import.meta.url), | ||
| 343 | modelSelection: GROK_MODEL_SELECTION, | ||
| 344 | assertOutput: assertMultiTurnOutput, | ||
| 345 | }, | ||
| 346 | { | ||
| 347 | provider: "acpRegistry", | ||
| 348 | transcriptFile: new URL("./multi_turn/grok_transcript.ndjson", import.meta.url), | ||
| 349 | modelSelection: ACP_REGISTRY_MODEL_SELECTION, | ||
| 350 | assertOutput: assertMultiTurnOutput, | ||
| 351 | }, | ||
| 248 | ], | 352 | ], |
| 249 | }, | 353 | }, |
| 250 | { | 354 | { |
| @@ -281,6 +385,18 @@ export const ORCHESTRATOR_REPLAY_FIXTURES = [ · 30 unmodified lines | |||
| 281 | modelSelection: CURSOR_MODEL_SELECTION, | 385 | modelSelection: CURSOR_MODEL_SELECTION, |
| 282 | assertOutput: assertQueuedTurnOutput, | 386 | assertOutput: assertQueuedTurnOutput, |
| 283 | }, | 387 | }, |
| 388 | { | ||
| 389 | provider: "grok", | ||
| 390 | transcriptFile: new URL("./queued_turn/grok_transcript.ndjson", import.meta.url), | ||
| 391 | modelSelection: GROK_MODEL_SELECTION, | ||
| 392 | assertOutput: assertQueuedTurnOutput, | ||
| 393 | }, | ||
| 394 | { | ||
| 395 | provider: "acpRegistry", | ||
| 396 | transcriptFile: new URL("./queued_turn/grok_transcript.ndjson", import.meta.url), | ||
| 397 | modelSelection: ACP_REGISTRY_MODEL_SELECTION, | ||
| 398 | assertOutput: assertQueuedTurnOutput, | ||
| 399 | }, | ||
| 284 | ], | 400 | ], |
| 285 | }, | 401 | }, |
| 286 | { | 402 | { |
| @@ -301,6 +417,18 @@ export const ORCHESTRATOR_REPLAY_FIXTURES = [ · 14 unmodified lines | |||
| 301 | runtimePolicyOverride: READ_ONLY_NEVER_POLICY, | 417 | runtimePolicyOverride: READ_ONLY_NEVER_POLICY, |
| 302 | assertOutput: assertTodoListCursorOutput, | 418 | assertOutput: assertTodoListCursorOutput, |
| 303 | }, | 419 | }, |
| 420 | { | ||
| 421 | provider: "grok", | ||
| 422 | transcriptFile: new URL("./todo_list/grok_transcript.ndjson", import.meta.url), | ||
| 423 | modelSelection: GROK_MODEL_SELECTION, | ||
| 424 | assertOutput: assertTodoListGrokOutput, | ||
| 425 | }, | ||
| 426 | { | ||
| 427 | provider: "acpRegistry", | ||
| 428 | transcriptFile: new URL("./todo_list/grok_transcript.ndjson", import.meta.url), | ||
| 429 | modelSelection: ACP_REGISTRY_MODEL_SELECTION, | ||
| 430 | assertOutput: assertTodoListGrokOutput, | ||
| 431 | }, | ||
| 304 | ], | 432 | ], |
| 305 | }, | 433 | }, |
| 306 | { | 434 | { |
| @@ -332,6 +460,20 @@ export const ORCHESTRATOR_REPLAY_FIXTURES = [ · 25 unmodified lines | |||
| 332 | runtimePolicyOverride: READ_ONLY_NEVER_POLICY, | 460 | runtimePolicyOverride: READ_ONLY_NEVER_POLICY, |
| 333 | assertOutput: assertPlanQuestionsOutput, | 461 | assertOutput: assertPlanQuestionsOutput, |
| 334 | }, | 462 | }, |
| 463 | { | ||
| 464 | provider: "grok", | ||
| 465 | transcriptFile: new URL("./plan_questions/grok_transcript.ndjson", import.meta.url), | ||
| 466 | modelSelection: GROK_MODEL_SELECTION, | ||
| 467 | runtimePolicyOverride: READ_ONLY_NEVER_POLICY, | ||
| 468 | assertOutput: assertPlanQuestionsOutput, | ||
| 469 | }, | ||
| 470 | { | ||
| 471 | provider: "opencode", | ||
| 472 | transcriptFile: new URL("./plan_questions/opencode_transcript.ndjson", import.meta.url), | ||
| 473 | modelSelection: OPENCODE_MODEL_SELECTION, | ||
| 474 | runtimePolicyOverride: READ_ONLY_NEVER_POLICY, | ||
| 475 | assertOutput: assertOpenCodePlanQuestionsOutput, | ||
| 476 | }, | ||
| 335 | ], | 477 | ], |
| 336 | }, | 478 | }, |
| 337 | { | 479 | { |
| @@ -376,6 +518,18 @@ export const ORCHESTRATOR_REPLAY_FIXTURES = [ · 38 unmodified lines | |||
| 376 | modelSelection: CURSOR_MODEL_SELECTION, | 518 | modelSelection: CURSOR_MODEL_SELECTION, |
| 377 | assertOutput: assertCursorMessageSteeringOutput, | 519 | assertOutput: assertCursorMessageSteeringOutput, |
| 378 | }, | 520 | }, |
| 521 | { | ||
| 522 | provider: "grok", | ||
| 523 | transcriptFile: new URL("./message_steering/grok_transcript.ndjson", import.meta.url), | ||
| 524 | modelSelection: GROK_MODEL_SELECTION, | ||
| 525 | assertOutput: assertGrokMessageSteeringOutput, | ||
| 526 | }, | ||
| 527 | { | ||
| 528 | provider: "acpRegistry", | ||
| 529 | transcriptFile: new URL("./message_steering/grok_transcript.ndjson", import.meta.url), | ||
| 530 | modelSelection: ACP_REGISTRY_MODEL_SELECTION, | ||
| 531 | assertOutput: assertGrokMessageSteeringOutput, | ||
| 532 | }, | ||
| 379 | ], | 533 | ], |
| 380 | }, | 534 | }, |
| 381 | { | 535 | { |
| @@ -396,6 +550,27 @@ export const ORCHESTRATOR_REPLAY_FIXTURES = [ · 14 unmodified lines | |||
| 396 | runtimePolicyOverride: WORKSPACE_NEVER_POLICY, | 550 | runtimePolicyOverride: WORKSPACE_NEVER_POLICY, |
| 397 | assertOutput: assertTurnInterruptClaudeOutput, | 551 | assertOutput: assertTurnInterruptClaudeOutput, |
| 398 | }, | 552 | }, |
| 553 | { | ||
| 554 | provider: "grok", | ||
| 555 | transcriptFile: new URL("./turn_interrupt/grok_transcript.ndjson", import.meta.url), | ||
| 556 | modelSelection: GROK_MODEL_SELECTION, | ||
| 557 | runtimePolicyOverride: WORKSPACE_NEVER_POLICY, | ||
| 558 | assertOutput: assertTurnInterruptOutput, | ||
| 559 | }, | ||
| 560 | { | ||
| 561 | provider: "acpRegistry", | ||
| 562 | transcriptFile: new URL("./turn_interrupt/grok_transcript.ndjson", import.meta.url), | ||
| 563 | modelSelection: ACP_REGISTRY_MODEL_SELECTION, | ||
| 564 | runtimePolicyOverride: WORKSPACE_NEVER_POLICY, | ||
| 565 | assertOutput: assertTurnInterruptOutput, | ||
| 566 | }, | ||
| 567 | { | ||
| 568 | provider: "opencode", | ||
| 569 | transcriptFile: new URL("./turn_interrupt/opencode_transcript.ndjson", import.meta.url), | ||
| 570 | modelSelection: OPENCODE_MODEL_SELECTION, | ||
| 571 | runtimePolicyOverride: WORKSPACE_NEVER_POLICY, | ||
| 572 | assertOutput: assertTurnInterruptOutput, | ||
| 573 | }, | ||
| 399 | ], | 574 | ], |
| 400 | }, | 575 | }, |
| 401 | { | 576 | { |
| @@ -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 | projectionFor, | 18 | projectionFor, |
| 19 | } from "../shared.ts"; | 19 | } from "../shared.ts"; |
| 20 | 20 | ||
| 21 | export function assertPlanQuestionsOutput( | 21 | export function assertPlanQuestionsOutputBase( |
| 22 | result: OrchestratorV2ScenarioResult, | 22 | result: OrchestratorV2ScenarioResult, |
| 23 | transcript: ProviderReplayTranscript, | 23 | transcript: ProviderReplayTranscript, |
| 24 | ) { | 24 | ) { |
| @@ -35,7 +35,15 @@ export function assertPlanQuestionsOutput( · 10 unmodified lines | |||
| 35 | assertTurnItemTypes(projection, ["user_message", "user_input_request", "assistant_message"]); | 35 | assertTurnItemTypes(projection, ["user_message", "user_input_request", "assistant_message"]); |
| 36 | assertUserMessagesInclude(projection, [PLAN_QUESTIONS_PROMPT]); | 36 | assertUserMessagesInclude(projection, [PLAN_QUESTIONS_PROMPT]); |
| 37 | assertAssistantTextIncludes(projection, "plan questions fixture complete"); | 37 | assertAssistantTextIncludes(projection, "plan questions fixture complete"); |
| 38 | } | ||
| 39 | |||
| 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 | const requestItem = projection.turnItems.find((item) => item.type === "user_input_request"); | 47 | const requestItem = projection.turnItems.find((item) => item.type === "user_input_request"); |
| 40 | assert.isDefined(requestItem); | 48 | assert.isDefined(requestItem); |
| 41 | assert.equal(requestItem?.questions[0]?.id, "schema_vs_ui_flexibility"); | 49 | assert.equal(requestItem?.questions[0]?.id, "schema_vs_ui_flexibility"); |
| @@ -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 | type: "answer_next_user_input_request", | 9 | type: "answer_next_user_input_request", |
| 10 | answers: { | 10 | answers: { |
| 11 | schema_vs_ui_flexibility: "Strict schemas (Recommended)", | 11 | schema_vs_ui_flexibility: "Strict schemas (Recommended)", |
| 12 | "question-0-schema-vs-flexibility": "Strict schemas", | ||
| 12 | }, | 13 | }, |
| 13 | }, | 14 | }, |
| 14 | ], | 15 | ], |
| @@ -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 | "Respond with exactly: steering fixture initial response"; | 42 | "Respond with exactly: steering fixture initial response"; |
| 43 | export const SUBAGENT_PROMPT = | 43 | export const SUBAGENT_PROMPT = |
| 44 | "Spawn 2 subagents, one to read package.json and one to read tsconfig.json"; | 44 | "Spawn 2 subagents, one to read package.json and one to read tsconfig.json"; |
| 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 | export const SUBAGENT_CONTINUE_PROMPT = | 47 | export const SUBAGENT_CONTINUE_PROMPT = |
| 46 | "Spawn one subagent and have it reply exactly: initial subagent response"; | 48 | "Spawn one subagent and have it reply exactly: initial subagent response"; |
| 47 | export const SUBAGENT_CONTINUE_PARENT_PROMPT = | 49 | export const SUBAGENT_CONTINUE_PARENT_PROMPT = |
| @@ -237,6 +239,22 @@ export const CURSOR_MODEL_SELECTION = { · 189 unmodified lines | |||
| 237 | model: "composer-2.5", | 239 | model: "composer-2.5", |
| 238 | } satisfies ModelSelection; | 240 | } satisfies ModelSelection; |
| 239 | 241 | ||
| 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 | export const READ_ONLY_ON_REQUEST_POLICY = { | 258 | export const READ_ONLY_ON_REQUEST_POLICY = { |
| 241 | approvalPolicy: "on-request", | 259 | approvalPolicy: "on-request", |
| 242 | sandboxPolicy: { | 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 | import * as Stream from "effect/Stream"; | 10 | import * as Stream from "effect/Stream"; |
| 11 | import { describe, expect } from "vite-plus/test"; | 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 | import type * as EffectAcpProtocol from "effect-acp/protocol"; | 18 | import type * as EffectAcpProtocol from "effect-acp/protocol"; |
| 15 | 19 | ||
| 16 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); | 20 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); |
| @@ -19,6 +23,17 @@ const mockAgentCommand = "node"; · 2 unmodified lines | |||
| 19 | const mockAgentArgs = [mockAgentPath]; | 23 | const mockAgentArgs = [mockAgentPath]; |
| 20 | 24 | ||
| 21 | describe("AcpSessionRuntime", () => { | 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 | it.effect("merges custom initialize client capabilities into the ACP handshake", () => { | 37 | it.effect("merges custom initialize client capabilities into the ACP handshake", () => { |
| 23 | const requestEvents: Array<AcpSessionRequestLogEvent> = []; | 38 | const requestEvents: Array<AcpSessionRequestLogEvent> = []; |
| 24 | return Effect.gen(function* () { | 39 | return Effect.gen(function* () { |
| @@ -62,6 +77,82 @@ describe("AcpSessionRuntime", () => { · 37 unmodified lines | |||
| 62 | ); | 77 | ); |
| 63 | }); | 78 | }); |
| 64 | 79 | ||
| 80 | it.effect("does not authenticate when an advertised method is not required", () => { | ||
| 81 | const requestEvents: Array<AcpSessionRequestLogEvent> = []; | ||
| 82 | return Effect.gen(function* () { | ||
| 83 | const runtime = yield* AcpSessionRuntime; | ||
| 84 | yield* runtime.start(); | ||
| 85 | |||
| 86 | expect( | ||
| 87 | requestEvents.filter((event) => event.status === "started").map((event) => event.method), | ||
| 88 | ).toEqual(["initialize", "session/new"]); | ||
| 89 | }).pipe( | ||
| 90 | Effect.provide( | ||
| 91 | AcpSessionRuntime.layer({ | ||
| 92 | authMethodId: "test", | ||
| 93 | spawn: { | ||
| 94 | command: mockAgentCommand, | ||
| 95 | args: mockAgentArgs, | ||
| 96 | env: { T3_ACP_AUTH_METHOD_ID: "test" }, | ||
| 97 | }, | ||
| 98 | cwd: process.cwd(), | ||
| 99 | clientInfo: { name: "t3-test", version: "0.0.0" }, | ||
| 100 | requestLogger: (event) => | ||
| 101 | Effect.sync(() => { | ||
| 102 | requestEvents.push(event); | ||
| 103 | }), | ||
| 104 | }), | ||
| 105 | ), | ||
| 106 | Effect.scoped, | ||
| 107 | Effect.provide(NodeServices.layer), | ||
| 108 | ); | ||
| 109 | }); | ||
| 110 | |||
| 111 | it.effect("authenticates and retries once after session creation returns auth_required", () => { | ||
| 112 | const requestEvents: Array<AcpSessionRequestLogEvent> = []; | ||
| 113 | return Effect.gen(function* () { | ||
| 114 | const runtime = yield* AcpSessionRuntime; | ||
| 115 | const started = yield* runtime.start(); | ||
| 116 | |||
| 117 | expect(started.sessionId).toBe("mock-session-1"); | ||
| 118 | expect( | ||
| 119 | requestEvents.filter((event) => event.status === "started").map((event) => event.method), | ||
| 120 | ).toEqual(["initialize", "session/new", "authenticate", "session/new"]); | ||
| 121 | expect( | ||
| 122 | requestEvents.filter( | ||
| 123 | (event) => event.method === "session/new" && event.status === "failed", | ||
| 124 | ), | ||
| 125 | ).toHaveLength(1); | ||
| 126 | expect( | ||
| 127 | requestEvents.filter( | ||
| 128 | (event) => event.method === "session/new" && event.status === "succeeded", | ||
| 129 | ), | ||
| 130 | ).toHaveLength(1); | ||
| 131 | }).pipe( | ||
| 132 | Effect.provide( | ||
| 133 | AcpSessionRuntime.layer({ | ||
| 134 | authMethodId: "test", | ||
| 135 | spawn: { | ||
| 136 | command: mockAgentCommand, | ||
| 137 | args: mockAgentArgs, | ||
| 138 | env: { | ||
| 139 | T3_ACP_AUTH_METHOD_ID: "test", | ||
| 140 | T3_ACP_REQUIRE_AUTH: "1", | ||
| 141 | }, | ||
| 142 | }, | ||
| 143 | cwd: process.cwd(), | ||
| 144 | clientInfo: { name: "t3-test", version: "0.0.0" }, | ||
| 145 | requestLogger: (event) => | ||
| 146 | Effect.sync(() => { | ||
| 147 | requestEvents.push(event); | ||
| 148 | }), | ||
| 149 | }), | ||
| 150 | ), | ||
| 151 | Effect.scoped, | ||
| 152 | Effect.provide(NodeServices.layer), | ||
| 153 | ); | ||
| 154 | }); | ||
| 155 | |||
| 65 | it.effect("starts a session, prompts, and emits normalized events against the mock agent", () => | 156 | it.effect("starts a session, prompts, and emits normalized events against the mock agent", () => |
| 66 | Effect.gen(function* () { | 157 | Effect.gen(function* () { |
| 67 | const runtime = yield* AcpSessionRuntime; | 158 | const runtime = yield* AcpSessionRuntime; |
| @@ -264,6 +355,63 @@ describe("AcpSessionRuntime", () => { · 196 unmodified lines | |||
| 264 | ); | 355 | ); |
| 265 | }); | 356 | }); |
| 266 | 357 | ||
| 358 | it.effect("supports negotiated ACP session list, fork, resume, and close methods", () => { | ||
| 359 | const requestEvents: Array<AcpSessionRequestLogEvent> = []; | ||
| 360 | return Effect.gen(function* () { | ||
| 361 | const runtime = yield* AcpSessionRuntime; | ||
| 362 | const started = yield* runtime.start(); | ||
| 363 | expect(started.initializeResult.agentCapabilities?.sessionCapabilities).toMatchObject({ | ||
| 364 | list: {}, | ||
| 365 | fork: {}, | ||
| 366 | resume: {}, | ||
| 367 | close: {}, | ||
| 368 | }); | ||
| 369 | |||
| 370 | const listed = yield* runtime.listSessions(); | ||
| 371 | expect(listed.sessions.map((session) => session.sessionId)).toEqual(["mock-session-1"]); | ||
| 372 | |||
| 373 | const forked = yield* runtime.forkSession(started.sessionId); | ||
| 374 | expect(forked.sessionId).toBe("mock-session-1-fork"); | ||
| 375 | yield* runtime.prompt({ prompt: [{ type: "text", text: "on fork" }] }); | ||
| 376 | |||
| 377 | const resumed = yield* runtime.resumeSession(started.sessionId); | ||
| 378 | expect(resumed.sessionId).toBe("mock-session-1"); | ||
| 379 | yield* runtime.closeSession(); | ||
| 380 | |||
| 381 | expect( | ||
| 382 | requestEvents.find( | ||
| 383 | (event) => event.method === "session/prompt" && event.status === "started", | ||
| 384 | )?.payload, | ||
| 385 | ).toMatchObject({ sessionId: "mock-session-1-fork" }); | ||
| 386 | expect( | ||
| 387 | requestEvents.filter((event) => event.status === "started").map((event) => event.method), | ||
| 388 | ).toEqual( | ||
| 389 | expect.arrayContaining(["session/list", "session/fork", "session/resume", "session/close"]), | ||
| 390 | ); | ||
| 391 | }).pipe( | ||
| 392 | Effect.provide( | ||
| 393 | AcpSessionRuntime.layer({ | ||
| 394 | authMethodId: "test", | ||
| 395 | spawn: { | ||
| 396 | command: mockAgentCommand, | ||
| 397 | args: mockAgentArgs, | ||
| 398 | env: { | ||
| 399 | T3_ACP_SESSION_LIFECYCLE: "1", | ||
| 400 | }, | ||
| 401 | }, | ||
| 402 | cwd: process.cwd(), | ||
| 403 | clientInfo: { name: "t3-test", version: "0.0.0" }, | ||
| 404 | requestLogger: (event) => | ||
| 405 | Effect.sync(() => { | ||
| 406 | requestEvents.push(event); | ||
| 407 | }), | ||
| 408 | }), | ||
| 409 | ), | ||
| 410 | Effect.scoped, | ||
| 411 | Effect.provide(NodeServices.layer), | ||
| 412 | ); | ||
| 413 | }); | ||
| 414 | |||
| 267 | it.effect("skips no-op session config writes when the requested value is already active", () => { | 415 | it.effect("skips no-op session config writes when the requested value is already active", () => { |
| 268 | const requestEvents: Array<AcpSessionRequestLogEvent> = []; | 416 | const requestEvents: Array<AcpSessionRequestLogEvent> = []; |
| 269 | return Effect.gen(function* () { | 417 | return Effect.gen(function* () { |
| @@ -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 | type AcpSessionSetupResponse = | 74 | type AcpSessionSetupResponse = |
| 75 | | EffectAcpSchema.ForkSessionResponse | ||
| 75 | | EffectAcpSchema.LoadSessionResponse | 76 | | EffectAcpSchema.LoadSessionResponse |
| 76 | | EffectAcpSchema.NewSessionResponse | 77 | | EffectAcpSchema.NewSessionResponse |
| 77 | | EffectAcpSchema.ResumeSessionResponse; | 78 | | EffectAcpSchema.ResumeSessionResponse; |
| @@ -47,7 +47,7 @@ export interface AcpSessionRuntimeOptions { · 46 unmodified lines | |||
| 47 | readonly name: string; | 47 | readonly name: string; |
| 48 | readonly version: string; | 48 | readonly version: string; |
| 49 | }; | 49 | }; |
| 50 | readonly authMethodId: string; | 50 | readonly authMethodId?: string; |
| 51 | readonly mcpServers?: ReadonlyArray<EffectAcpSchema.McpServer>; | 51 | readonly mcpServers?: ReadonlyArray<EffectAcpSchema.McpServer>; |
| 52 | readonly requestLogger?: (event: AcpSessionRequestLogEvent) => Effect.Effect<void, never>; | 52 | readonly requestLogger?: (event: AcpSessionRequestLogEvent) => Effect.Effect<void, never>; |
| 53 | readonly protocolLogging?: { | 53 | readonly protocolLogging?: { |
| @@ -65,10 +65,26 @@ export interface AcpSessionRequestLogEvent { · 11 unmodified lines | |||
| 65 | readonly cause?: Cause.Cause<EffectAcpErrors.AcpError>; | 65 | readonly cause?: Cause.Cause<EffectAcpErrors.AcpError>; |
| 66 | } | 66 | } |
| 67 | 67 | ||
| 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 | export interface AcpSessionRuntimeStartResult { | 83 | export interface AcpSessionRuntimeStartResult { |
| 69 | readonly sessionId: string; | 84 | readonly sessionId: string; |
| 70 | readonly initializeResult: EffectAcpSchema.InitializeResponse; | 85 | readonly initializeResult: EffectAcpSchema.InitializeResponse; |
| 71 | readonly sessionSetupResult: | 86 | readonly sessionSetupResult: |
| 87 | | EffectAcpSchema.ForkSessionResponse | ||
| 72 | | EffectAcpSchema.LoadSessionResponse | 88 | | EffectAcpSchema.LoadSessionResponse |
| 73 | | EffectAcpSchema.NewSessionResponse | 89 | | EffectAcpSchema.NewSessionResponse |
| 74 | | EffectAcpSchema.ResumeSessionResponse; | 90 | | EffectAcpSchema.ResumeSessionResponse; |
| @@ -95,6 +111,21 @@ export interface AcpSessionRuntimeShape { · 20 unmodified lines | |||
| 95 | readonly getEvents: () => Stream.Stream<AcpParsedSessionEvent, never>; | 111 | readonly getEvents: () => Stream.Stream<AcpParsedSessionEvent, never>; |
| 96 | readonly getModeState: Effect.Effect<AcpSessionModeState | undefined>; | 112 | readonly getModeState: Effect.Effect<AcpSessionModeState | undefined>; |
| 97 | readonly getConfigOptions: Effect.Effect<ReadonlyArray<EffectAcpSchema.SessionConfigOption>>; | 113 | readonly getConfigOptions: Effect.Effect<ReadonlyArray<EffectAcpSchema.SessionConfigOption>>; |
| 114 | readonly loadSession: ( | ||
| 115 | sessionId: string, | ||
| 116 | ) => Effect.Effect<AcpSessionRuntimeStartResult, EffectAcpErrors.AcpError>; | ||
| 117 | readonly resumeSession: ( | ||
| 118 | sessionId: string, | ||
| 119 | ) => Effect.Effect<AcpSessionRuntimeStartResult, EffectAcpErrors.AcpError>; | ||
| 120 | readonly forkSession: ( | ||
| 121 | sessionId: string, | ||
| 122 | ) => Effect.Effect<AcpSessionRuntimeStartResult, EffectAcpErrors.AcpError>; | ||
| 123 | readonly listSessions: ( | ||
| 124 | cursor?: string, | ||
| 125 | ) => Effect.Effect<EffectAcpSchema.ListSessionsResponse, EffectAcpErrors.AcpError>; | ||
| 126 | readonly closeSession: ( | ||
| 127 | sessionId?: string, | ||
| 128 | ) => Effect.Effect<EffectAcpSchema.CloseSessionResponse, EffectAcpErrors.AcpError>; | ||
| 98 | readonly prompt: ( | 129 | readonly prompt: ( |
| 99 | payload: Omit<EffectAcpSchema.PromptRequest, "sessionId">, | 130 | payload: Omit<EffectAcpSchema.PromptRequest, "sessionId">, |
| 100 | ) => Effect.Effect<EffectAcpSchema.PromptResponse, EffectAcpErrors.AcpError>; | 131 | ) => Effect.Effect<EffectAcpSchema.PromptResponse, EffectAcpErrors.AcpError>; |
| @@ -327,6 +358,7 @@ const makeAcpSessionRuntime = ( · 226 unmodified lines | |||
| 327 | const updateConfigOptions = ( | 358 | const updateConfigOptions = ( |
| 328 | response: | 359 | response: |
| 329 | | EffectAcpSchema.SetSessionConfigOptionResponse | 360 | | EffectAcpSchema.SetSessionConfigOptionResponse |
| 361 | | EffectAcpSchema.ForkSessionResponse | ||
| 330 | | EffectAcpSchema.LoadSessionResponse | 362 | | EffectAcpSchema.LoadSessionResponse |
| 331 | | EffectAcpSchema.NewSessionResponse | 363 | | EffectAcpSchema.NewSessionResponse |
| 332 | | EffectAcpSchema.ResumeSessionResponse, | 364 | | EffectAcpSchema.ResumeSessionResponse, |
| @@ -337,6 +369,30 @@ const makeAcpSessionRuntime = ( · 4 unmodified lines | |||
| 337 | current ? { ...current, currentModeId: modeId } : current, | 369 | current ? { ...current, currentModeId: modeId } : current, |
| 338 | ); | 370 | ); |
| 339 | 371 | ||
| 372 | const adoptSession = ( | ||
| 373 | sessionId: string, | ||
| 374 | sessionSetupResult: | ||
| 375 | | EffectAcpSchema.ForkSessionResponse | ||
| 376 | | EffectAcpSchema.LoadSessionResponse | ||
| 377 | | EffectAcpSchema.NewSessionResponse | ||
| 378 | | EffectAcpSchema.ResumeSessionResponse, | ||
| 379 | ): Effect.Effect<AcpSessionRuntimeStartResult, EffectAcpErrors.AcpError> => | ||
| 380 | Effect.gen(function* () { | ||
| 381 | const current = yield* getStartedState; | ||
| 382 | const nextState = { | ||
| 383 | sessionId, | ||
| 384 | initializeResult: current.initializeResult, | ||
| 385 | sessionSetupResult, | ||
| 386 | modelConfigId: extractModelConfigId(sessionSetupResult), | ||
| 387 | } satisfies AcpStartedState; | ||
| 388 | yield* Ref.set(modeStateRef, parseSessionModeState(sessionSetupResult)); | ||
| 389 | yield* Ref.set(configOptionsRef, sessionConfigOptionsFromSetup(sessionSetupResult)); | ||
| 390 | yield* Ref.set(toolCallsRef, new Map()); | ||
| 391 | yield* Ref.set(assistantSegmentRef, { nextSegmentIndex: 0 }); | ||
| 392 | yield* Ref.set(startStateRef, { _tag: "Started", result: nextState }); | ||
| 393 | return nextState; | ||
| 394 | }); | ||
| 395 | |||
| 340 | const setConfigOption = ( | 396 | const setConfigOption = ( |
| 341 | configId: string, | 397 | configId: string, |
| 342 | value: string | boolean, | 398 | value: string | boolean, |
| @@ -388,35 +444,80 @@ const makeAcpSessionRuntime = ( · 45 unmodified lines | |||
| 388 | acp.agent.initialize(initializePayload), | 444 | acp.agent.initialize(initializePayload), |
| 389 | ); | 445 | ); |
| 390 | 446 | ||
| 391 | const authenticatePayload = { | 447 | const authenticateAfterRequired = ( |
| 392 | methodId: options.authMethodId, | 448 | authRequiredError: EffectAcpErrors.AcpError, |
| 393 | } satisfies EffectAcpSchema.AuthenticateRequest; | 449 | ): Effect.Effect<void, EffectAcpErrors.AcpError> => |
| 450 | Effect.gen(function* () { | ||
| 451 | const configuredAuthMethodId = options.authMethodId?.trim(); | ||
| 452 | const authMethod = selectAcpAgentAuthMethod( | ||
| 453 | initializeResult.authMethods, | ||
| 454 | configuredAuthMethodId, | ||
| 455 | ); | ||
| 456 | if ( | ||
| 457 | configuredAuthMethodId && | ||
| 458 | initializeResult.authMethods !== undefined && | ||
| 459 | authMethod === undefined | ||
| 460 | ) { | ||
| 461 | return yield* new EffectAcpErrors.AcpTransportError({ | ||
| 462 | detail: `ACP agent did not advertise configured authentication method "${configuredAuthMethodId}"`, | ||
| 463 | cause: { configuredAuthMethodId, authMethods: initializeResult.authMethods }, | ||
| 464 | }); | ||
| 465 | } | ||
| 466 | if (authMethod !== undefined && "type" in authMethod) { | ||
| 467 | return yield* new EffectAcpErrors.AcpTransportError({ | ||
| 468 | detail: `ACP authentication method "${authMethod.id}" requires ${authMethod.type} authentication, which cannot run inside a headless provider session`, | ||
| 469 | cause: authMethod, | ||
| 470 | }); | ||
| 471 | } | ||
| 394 | 472 | ||
| 395 | yield* runLoggedRequest( | 473 | const authMethodId = authMethod?.id ?? configuredAuthMethodId; |
| 396 | "authenticate", | 474 | if (!authMethodId) { |
| 397 | authenticatePayload, | 475 | return yield* authRequiredError; |
| 398 | acp.agent.authenticate(authenticatePayload), | 476 | } |
| 399 | ); | ||
| 400 | 477 | ||
| 401 | let sessionId: string; | 478 | const authenticatePayload = { |
| 402 | let sessionSetupResult: | 479 | methodId: authMethodId, |
| 403 | | EffectAcpSchema.LoadSessionResponse | 480 | } satisfies EffectAcpSchema.AuthenticateRequest; |
| 404 | | EffectAcpSchema.NewSessionResponse | 481 | yield* runLoggedRequest( |
| 405 | | EffectAcpSchema.ResumeSessionResponse; | 482 | "authenticate", |
| 406 | if (options.resumeSessionId) { | 483 | authenticatePayload, |
| 407 | const loadPayload = { | 484 | acp.agent.authenticate(authenticatePayload), |
| 408 | sessionId: options.resumeSessionId, | 485 | ); |
| 409 | cwd: options.cwd, | 486 | }); |
| 410 | mcpServers: options.mcpServers ?? [], | 487 | |
| 411 | } satisfies EffectAcpSchema.LoadSessionRequest; | 488 | const setupSession = Effect.gen(function* () { |
| 412 | const resumed = yield* runLoggedRequest( | 489 | let sessionId: string; |
| 413 | "session/load", | 490 | let sessionSetupResult: |
| 414 | loadPayload, | 491 | | EffectAcpSchema.LoadSessionResponse |
| 415 | acp.agent.loadSession(loadPayload), | 492 | | EffectAcpSchema.NewSessionResponse |
| 416 | ).pipe(Effect.exit); | 493 | | EffectAcpSchema.ResumeSessionResponse; |
| 417 | if (Exit.isSuccess(resumed)) { | 494 | if (options.resumeSessionId) { |
| 418 | sessionId = options.resumeSessionId; | 495 | const loadPayload = { |
| 419 | sessionSetupResult = resumed.value; | 496 | sessionId: options.resumeSessionId, |
| 497 | cwd: options.cwd, | ||
| 498 | mcpServers: options.mcpServers ?? [], | ||
| 499 | } satisfies EffectAcpSchema.LoadSessionRequest; | ||
| 500 | const resumed = yield* runLoggedRequest( | ||
| 501 | "session/load", | ||
| 502 | loadPayload, | ||
| 503 | acp.agent.loadSession(loadPayload), | ||
| 504 | ).pipe(Effect.exit); | ||
| 505 | if (Exit.isSuccess(resumed)) { | ||
| 506 | sessionId = options.resumeSessionId; | ||
| 507 | sessionSetupResult = resumed.value; | ||
| 508 | } else { | ||
| 509 | const createPayload = { | ||
| 510 | cwd: options.cwd, | ||
| 511 | mcpServers: options.mcpServers ?? [], | ||
| 512 | } satisfies EffectAcpSchema.NewSessionRequest; | ||
| 513 | const created = yield* runLoggedRequest( | ||
| 514 | "session/new", | ||
| 515 | createPayload, | ||
| 516 | acp.agent.createSession(createPayload), | ||
| 517 | ); | ||
| 518 | sessionId = created.sessionId; | ||
| 519 | sessionSetupResult = created; | ||
| 520 | } | ||
| 420 | } else { | 521 | } else { |
| 421 | const createPayload = { | 522 | const createPayload = { |
| 422 | cwd: options.cwd, | 523 | cwd: options.cwd, |
| @@ -430,19 +531,17 @@ const makeAcpSessionRuntime = ( · 7 unmodified lines | |||
| 430 | sessionId = created.sessionId; | 531 | sessionId = created.sessionId; |
| 431 | sessionSetupResult = created; | 532 | sessionSetupResult = created; |
| 432 | } | 533 | } |
| 433 | } else { | 534 | |
| 434 | const createPayload = { | 535 | return { sessionId, sessionSetupResult }; |
| 435 | cwd: options.cwd, | 536 | }); |
| 436 | mcpServers: options.mcpServers ?? [], | 537 | |
| 437 | } satisfies EffectAcpSchema.NewSessionRequest; | 538 | const { sessionId, sessionSetupResult } = yield* setupSession.pipe( |
| 438 | const created = yield* runLoggedRequest( | 539 | Effect.catch((error) => |
| 439 | "session/new", | 540 | isAcpAuthenticationRequired(error) |
| 440 | createPayload, | 541 | ? authenticateAfterRequired(error).pipe(Effect.andThen(setupSession)) |
| 441 | acp.agent.createSession(createPayload), | 542 | : Effect.fail(error), |
| 442 | ); | 543 | ), |
| 443 | sessionId = created.sessionId; | 544 | ); |
| 444 | sessionSetupResult = created; | ||
| 445 | } | ||
| 446 | 545 | ||
| 447 | yield* Ref.set(modeStateRef, parseSessionModeState(sessionSetupResult)); | 546 | yield* Ref.set(modeStateRef, parseSessionModeState(sessionSetupResult)); |
| 448 | yield* Ref.set(configOptionsRef, sessionConfigOptionsFromSetup(sessionSetupResult)); | 547 | yield* Ref.set(configOptionsRef, sessionConfigOptionsFromSetup(sessionSetupResult)); |
| @@ -508,6 +607,82 @@ const makeAcpSessionRuntime = ( · 59 unmodified lines | |||
| 508 | getEvents: () => Stream.fromQueue(eventQueue), | 607 | getEvents: () => Stream.fromQueue(eventQueue), |
| 509 | getModeState: Ref.get(modeStateRef), | 608 | getModeState: Ref.get(modeStateRef), |
| 510 | getConfigOptions: Ref.get(configOptionsRef), | 609 | getConfigOptions: Ref.get(configOptionsRef), |
| 610 | loadSession: (sessionId) => | ||
| 611 | start.pipe( | ||
| 612 | Effect.flatMap(() => { | ||
| 613 | const requestPayload = { | ||
| 614 | sessionId, | ||
| 615 | cwd: options.cwd, | ||
| 616 | mcpServers: options.mcpServers ?? [], | ||
| 617 | } satisfies EffectAcpSchema.LoadSessionRequest; | ||
| 618 | return runLoggedRequest( | ||
| 619 | "session/load", | ||
| 620 | requestPayload, | ||
| 621 | acp.agent.loadSession(requestPayload), | ||
| 622 | ); | ||
| 623 | }), | ||
| 624 | Effect.flatMap((response) => adoptSession(sessionId, response)), | ||
| 625 | ), | ||
| 626 | resumeSession: (sessionId) => | ||
| 627 | start.pipe( | ||
| 628 | Effect.flatMap(() => { | ||
| 629 | const requestPayload = { | ||
| 630 | sessionId, | ||
| 631 | cwd: options.cwd, | ||
| 632 | mcpServers: options.mcpServers ?? [], | ||
| 633 | } satisfies EffectAcpSchema.ResumeSessionRequest; | ||
| 634 | return runLoggedRequest( | ||
| 635 | "session/resume", | ||
| 636 | requestPayload, | ||
| 637 | acp.agent.resumeSession(requestPayload), | ||
| 638 | ); | ||
| 639 | }), | ||
| 640 | Effect.flatMap((response) => adoptSession(sessionId, response)), | ||
| 641 | ), | ||
| 642 | forkSession: (sessionId) => | ||
| 643 | start.pipe( | ||
| 644 | Effect.flatMap(() => { | ||
| 645 | const requestPayload = { | ||
| 646 | sessionId, | ||
| 647 | cwd: options.cwd, | ||
| 648 | mcpServers: options.mcpServers ?? [], | ||
| 649 | } satisfies EffectAcpSchema.ForkSessionRequest; | ||
| 650 | return runLoggedRequest( | ||
| 651 | "session/fork", | ||
| 652 | requestPayload, | ||
| 653 | acp.agent.forkSession(requestPayload), | ||
| 654 | ); | ||
| 655 | }), | ||
| 656 | Effect.flatMap((response) => adoptSession(response.sessionId, response)), | ||
| 657 | ), | ||
| 658 | listSessions: (cursor) => { | ||
| 659 | const requestPayload = { | ||
| 660 | cwd: options.cwd, | ||
| 661 | ...(cursor === undefined ? {} : { cursor }), | ||
| 662 | } satisfies EffectAcpSchema.ListSessionsRequest; | ||
| 663 | return start.pipe( | ||
| 664 | Effect.andThen( | ||
| 665 | runLoggedRequest( | ||
| 666 | "session/list", | ||
| 667 | requestPayload, | ||
| 668 | acp.agent.listSessions(requestPayload), | ||
| 669 | ), | ||
| 670 | ), | ||
| 671 | ); | ||
| 672 | }, | ||
| 673 | closeSession: (sessionId) => | ||
| 674 | start.pipe( | ||
| 675 | Effect.flatMap((started) => { | ||
| 676 | const requestPayload = { | ||
| 677 | sessionId: sessionId ?? started.sessionId, | ||
| 678 | } satisfies EffectAcpSchema.CloseSessionRequest; | ||
| 679 | return runLoggedRequest( | ||
| 680 | "session/close", | ||
| 681 | requestPayload, | ||
| 682 | acp.agent.closeSession(requestPayload), | ||
| 683 | ); | ||
| 684 | }), | ||
| 685 | ), | ||
| 511 | prompt: (payload) => | 686 | prompt: (payload) => |
| 512 | getStartedState.pipe( | 687 | getStartedState.pipe( |
| 513 | Effect.flatMap((started) => { | 688 | Effect.flatMap((started) => { |
| @@ -66,4 +66,23 @@ describe.runIf(process.env.T3_GROK_ACP_PROBE === "1")("Grok ACP CLI probe", () = · 65 unmodified lines | |||
| 66 | yield* runtime.setSessionModel(currentModelId); | 66 | yield* runtime.setSessionModel(currentModelId); |
| 67 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | 67 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), |
| 68 | ); | 68 | ); |
| 69 | |||
| 70 | it.effect("switches to another advertised model and completes a prompt", () => | ||
| 71 | Effect.gen(function* () { | ||
| 72 | const runtime = yield* makeProbeRuntime; | ||
| 73 | const started = yield* runtime.start(); | ||
| 74 | const models = started.sessionSetupResult.models; | ||
| 75 | const targetModel = models?.availableModels.find( | ||
| 76 | ({ modelId }) => modelId !== models.currentModelId, | ||
| 77 | )?.modelId; | ||
| 78 | expect(targetModel).toBeDefined(); | ||
| 79 | if (!targetModel) return; | ||
| 80 | |||
| 81 | yield* runtime.setSessionModel(targetModel).pipe(Effect.timeout("20 seconds")); | ||
| 82 | const result = yield* runtime | ||
| 83 | .prompt({ prompt: [{ type: "text", text: "Respond with exactly: grok switch ok" }] }) | ||
| 84 | .pipe(Effect.timeout("60 seconds")); | ||
| 85 | expect(result.stopReason).toBe("end_turn"); | ||
| 86 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | ||
| 87 | ); | ||
| 69 | }); | 88 | }); |
| @@ -23,10 +23,7 @@ const GROK_DRIVER_KIND = ProviderDriverKind.make("grok"); · 22 unmodified lines | |||
| 23 | 23 | ||
| 24 | type GrokAcpRuntimeGrokSettings = Pick<GrokSettings, "binaryPath">; | 24 | type GrokAcpRuntimeGrokSettings = Pick<GrokSettings, "binaryPath">; |
| 25 | 25 | ||
| 26 | interface GrokAcpRuntimeInput extends Omit< | 26 | interface GrokAcpRuntimeInput extends Omit<AcpSessionRuntimeOptions, "authMethodId" | "spawn"> { |
| 27 | AcpSessionRuntimeOptions, | ||
| 28 | "authMethodId" | "clientCapabilities" | "spawn" | ||
| 29 | > { | ||
| 30 | readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; | 27 | readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; |
| 31 | readonly grokSettings: GrokAcpRuntimeGrokSettings | null | undefined; | 28 | readonly grokSettings: GrokAcpRuntimeGrokSettings | null | undefined; |
| 32 | readonly environment?: NodeJS.ProcessEnv; | 29 | readonly environment?: NodeJS.ProcessEnv; |
| @@ -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 | interface XAiAskUserQuestionAnnotation { | 78 | interface XAiAskUserQuestionAnnotation { |
| 68 | readonly preview?: string; | 79 | readonly preview?: string; |
| 69 | readonly notes?: string; | 80 | readonly notes?: string; |
| @@ -13,7 +13,7 @@ import { useSettings, useUpdateSettings } from "../../hooks/useSettings"; · 12 unmodified lines | |||
| 13 | import { cn } from "../../lib/utils"; | 13 | import { cn } from "../../lib/utils"; |
| 14 | import { normalizeProviderAccentColor } from "../../providerInstances"; | 14 | import { normalizeProviderAccentColor } from "../../providerInstances"; |
| 15 | import { Button } from "../ui/button"; | 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 | import { | 17 | import { |
| 18 | Dialog, | 18 | Dialog, |
| 19 | DialogDescription, | 19 | DialogDescription, |
| @@ -81,11 +81,6 @@ const COMING_SOON_DRIVER_OPTIONS: readonly ComingSoonDriverOption[] = [ · 61 unmodified lines | |||
| 81 | label: "Gemini", | 81 | label: "Gemini", |
| 82 | icon: Gemini, | 82 | icon: Gemini, |
| 83 | }, | 83 | }, |
| 84 | { | ||
| 85 | value: ProviderDriverKind.make("acpRegistry"), | ||
| 86 | label: "ACP Registry", | ||
| 87 | icon: ACPRegistryIcon, | ||
| 88 | }, | ||
| 89 | { | 84 | { |
| 90 | value: ProviderDriverKind.make("piAgent"), | 85 | value: ProviderDriverKind.make("piAgent"), |
| 91 | label: "Pi Agent", | 86 | label: "Pi Agent", |
| @@ -36,6 +36,18 @@ describe("ProviderSettingsForm helpers", () => { · 35 unmodified lines | |||
| 36 | }); | 36 | }); |
| 37 | }); | 37 | }); |
| 38 | 38 | ||
| 39 | it("exposes ACP Registry as an instance-only configurable driver", () => { | ||
| 40 | const acpRegistry = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("acpRegistry")]; | ||
| 41 | |||
| 42 | expect(acpRegistry).toBeDefined(); | ||
| 43 | expect(acpRegistry?.hasDefaultInstance).toBe(false); | ||
| 44 | expect(deriveProviderSettingsFields(acpRegistry!).map((field) => field.key)).toEqual([ | ||
| 45 | "agentId", | ||
| 46 | "commandPath", | ||
| 47 | "authMethodId", | ||
| 48 | ]); | ||
| 49 | }); | ||
| 50 | |||
| 39 | it("preserves unknown config keys while omitting empty configurable fields", () => { | 51 | it("preserves unknown config keys while omitting empty configurable fields", () => { |
| 40 | const opencode = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("opencode")]; | 52 | const opencode = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("opencode")]; |
| 41 | expect(opencode).toBeDefined(); | 53 | expect(opencode).toBeDefined(); |
| @@ -1233,6 +1233,35 @@ describe("GeneralSettingsPanel observability", () => { · 1232 unmodified lines | |||
| 1233 | await expect.element(page.getByPlaceholder("Optional")).toBeInTheDocument(); | 1233 | await expect.element(page.getByPlaceholder("Optional")).toBeInTheDocument(); |
| 1234 | }); | 1234 | }); |
| 1235 | 1235 | ||
| 1236 | it("renders configured ACP Registry instances without a blank default card", async () => { | ||
| 1237 | const instanceId = ProviderInstanceId.make("acpRegistry_devin"); | ||
| 1238 | setServerConfigSnapshot({ | ||
| 1239 | ...createBaseServerConfig(), | ||
| 1240 | settings: { | ||
| 1241 | ...DEFAULT_SERVER_SETTINGS, | ||
| 1242 | providerInstances: { | ||
| 1243 | [instanceId]: { | ||
| 1244 | driver: ProviderDriverKind.make("acpRegistry"), | ||
| 1245 | displayName: "Devin ACP", | ||
| 1246 | config: { agentId: "devin" }, | ||
| 1247 | }, | ||
| 1248 | }, | ||
| 1249 | }, | ||
| 1250 | }); | ||
| 1251 | |||
| 1252 | mounted = await render( | ||
| 1253 | <AppAtomRegistryProvider> | ||
| 1254 | <ProviderSettingsPanel /> | ||
| 1255 | </AppAtomRegistryProvider>, | ||
| 1256 | ); | ||
| 1257 | |||
| 1258 | await expect | ||
| 1259 | .element(page.getByLabelText("Toggle ACP Registry details")) | ||
| 1260 | .not.toBeInTheDocument(); | ||
| 1261 | await page.getByLabelText("Toggle Devin ACP details").click(); | ||
| 1262 | await expect.element(page.getByText("Registry agent ID")).toBeInTheDocument(); | ||
| 1263 | }); | ||
| 1264 | |||
| 1236 | it("runs one-click provider updates from the provider card", async () => { | 1265 | it("runs one-click provider updates from the provider card", async () => { |
| 1237 | const updateProvider = vi.fn<LocalApi["server"]["updateProvider"]>().mockResolvedValue({ | 1266 | const updateProvider = vi.fn<LocalApi["server"]["updateProvider"]>().mockResolvedValue({ |
| 1238 | providers: [createOutdatedProvider("codex")], | 1267 | providers: [createOutdatedProvider("codex")], |
| @@ -121,6 +121,7 @@ function withoutProviderInstanceFavorites( · 120 unmodified lines | |||
| 121 | 121 | ||
| 122 | const PROVIDER_SETTINGS = DRIVER_OPTIONS.map((definition) => ({ | 122 | const PROVIDER_SETTINGS = DRIVER_OPTIONS.map((definition) => ({ |
| 123 | provider: definition.value, | 123 | provider: definition.value, |
| 124 | hasDefaultInstance: definition.hasDefaultInstance !== false, | ||
| 124 | })); | 125 | })); |
| 125 | 126 | ||
| 126 | function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | null }) { | 127 | function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | null }) { |
| @@ -1032,9 +1033,9 @@ export function ProviderSettingsPanel() { · 905 unmodified lines | |||
| 1032 | } | 1033 | } |
| 1033 | 1034 | ||
| 1034 | const defaultSlotIdsBySource = new Set<string>( | 1035 | const defaultSlotIdsBySource = new Set<string>( |
| 1035 | visibleProviderSettings.map((providerSettings) => | 1036 | visibleProviderSettings |
| 1036 | String(defaultInstanceIdForDriver(providerSettings.provider)), | 1037 | .filter((providerSettings) => providerSettings.hasDefaultInstance) |
| 1037 | ), | 1038 | .map((providerSettings) => String(defaultInstanceIdForDriver(providerSettings.provider))), |
| 1038 | ); | 1039 | ); |
| 1039 | 1040 | ||
| 1040 | const rows: InstanceRow[] = []; | 1041 | const rows: InstanceRow[] = []; |
| @@ -1043,6 +1044,12 @@ export function ProviderSettingsPanel() { · 2 unmodified lines | |||
| 1043 | ); | 1044 | ); |
| 1044 | 1045 | ||
| 1045 | for (const providerSettings of visibleProviderSettings) { | 1046 | for (const providerSettings of visibleProviderSettings) { |
| 1047 | if (!providerSettings.hasDefaultInstance) { | ||
| 1048 | for (const [id, instance] of instancesByDriver.get(providerSettings.provider) ?? []) { | ||
| 1049 | rows.push({ instanceId: id, instance, driver: instance.driver, isDefault: false }); | ||
| 1050 | } | ||
| 1051 | continue; | ||
| 1052 | } | ||
| 1046 | type LegacyProviderSettings = (typeof settings.providers)[keyof typeof settings.providers]; | 1053 | type LegacyProviderSettings = (typeof settings.providers)[keyof typeof settings.providers]; |
| 1047 | const legacyProviders = settings.providers as Record<string, LegacyProviderSettings>; | 1054 | const legacyProviders = settings.providers as Record<string, LegacyProviderSettings>; |
| 1048 | const defaultLegacyProviders = DEFAULT_UNIFIED_SETTINGS.providers as Record< | 1055 | const defaultLegacyProviders = DEFAULT_UNIFIED_SETTINGS.providers as Record< |
| @@ -1,4 +1,5 @@ | |||
| 1 | import { | 1 | import { |
| 2 | AcpRegistrySettings, | ||
| 2 | ClaudeSettings, | 3 | ClaudeSettings, |
| 3 | CodexSettings, | 4 | CodexSettings, |
| 4 | CursorSettings, | 5 | CursorSettings, |
| @@ -7,7 +8,15 @@ import { · 2 unmodified lines | |||
| 7 | ProviderDriverKind, | 8 | ProviderDriverKind, |
| 8 | } from "@t3tools/contracts"; | 9 | } from "@t3tools/contracts"; |
| 9 | import type * as Schema from "effect/Schema"; | 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 | type ProviderSettingsSchema = { | 21 | type ProviderSettingsSchema = { |
| 13 | readonly fields: Readonly<Record<string, Schema.Top>>; | 22 | readonly fields: Readonly<Record<string, Schema.Top>>; |
| @@ -24,6 +33,8 @@ export interface ProviderClientDefinition { · 10 unmodified lines | |||
| 24 | readonly label: string; | 33 | readonly label: string; |
| 25 | readonly icon: Icon; | 34 | readonly icon: Icon; |
| 26 | readonly settingsSchema: ProviderSettingsSchema; | 35 | readonly settingsSchema: ProviderSettingsSchema; |
| 36 | /** Whether this driver has a built-in default instance backed by legacy settings. */ | ||
| 37 | readonly hasDefaultInstance?: boolean; | ||
| 27 | /** | 38 | /** |
| 28 | * Optional short label rendered as a `variant="warning"` badge next to | 39 | * Optional short label rendered as a `variant="warning"` badge next to |
| 29 | * the instance title. Used to flag drivers that still ship under an | 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 | badgeLabel: "Early Access", | 72 | badgeLabel: "Early Access", |
| 62 | settingsSchema: GrokSettings, | 73 | settingsSchema: GrokSettings, |
| 63 | }, | 74 | }, |
| 75 | { | ||
| 76 | value: ProviderDriverKind.make("acpRegistry"), | ||
| 77 | label: "ACP Registry", | ||
| 78 | icon: ACPRegistryIcon, | ||
| 79 | badgeLabel: "V2 Preview", | ||
| 80 | settingsSchema: AcpRegistrySettings, | ||
| 81 | hasDefaultInstance: false, | ||
| 82 | }, | ||
| 64 | { | 83 | { |
| 65 | value: ProviderDriverKind.make("opencode"), | 84 | value: ProviderDriverKind.make("opencode"), |
| 66 | label: "OpenCode", | 85 | label: "OpenCode", |
| @@ -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 | } from "../lib/orchestrationV2Timeline"; | 36 | } from "../lib/orchestrationV2Timeline"; |
| 37 | import { newCommandId, newMessageId, newProjectId, newThreadId } from "../lib/utils"; | 37 | import { newCommandId, newMessageId, newProjectId, newThreadId } from "../lib/utils"; |
| 38 | import { type AppModelOption, getAppModelOptionsForInstance } from "../modelSelection"; | 38 | import { type AppModelOption, getAppModelOptionsForInstance } from "../modelSelection"; |
| 39 | import { deriveOrchestrationV2DebugProviderSnapshots } from "../orchestrationV2DebugProviders"; | ||
| 39 | import { | 40 | import { |
| 40 | type ProviderInstanceEntry, | 41 | type ProviderInstanceEntry, |
| 41 | deriveProviderInstanceEntries, | 42 | deriveProviderInstanceEntries, |
| @@ -1621,7 +1622,14 @@ function OrchestrationV2DebugRoute() { · 1579 unmodified lines | |||
| 1621 | const serverProviders = useServerProviders(); | 1622 | const serverProviders = useServerProviders(); |
| 1622 | const settings = useSettings(); | 1623 | const settings = useSettings(); |
| 1623 | const keybindings = useServerKeybindings(); | 1624 | const keybindings = useServerKeybindings(); |
| 1624 | const providerSnapshots = serverProviders.length > 0 ? serverProviders : DEBUG_PROVIDER_SNAPSHOTS; | 1625 | const providerSnapshots = useMemo( |
| 1626 | () => | ||
| 1627 | deriveOrchestrationV2DebugProviderSnapshots({ | ||
| 1628 | providers: serverProviders.length > 0 ? serverProviders : DEBUG_PROVIDER_SNAPSHOTS, | ||
| 1629 | providerInstances: settings.providerInstances, | ||
| 1630 | }), | ||
| 1631 | [serverProviders, settings.providerInstances], | ||
| 1632 | ); | ||
| 1625 | const providerInstanceEntries = useMemo<ReadonlyArray<ProviderInstanceEntry>>( | 1633 | const providerInstanceEntries = useMemo<ReadonlyArray<ProviderInstanceEntry>>( |
| 1626 | () => sortProviderInstanceEntries(deriveProviderInstanceEntries(providerSnapshots)), | 1634 | () => sortProviderInstanceEntries(deriveProviderInstanceEntries(providerSnapshots)), |
| 1627 | [providerSnapshots], | 1635 | [providerSnapshots], |
| @@ -90,9 +90,40 @@ Cursor receives the same authenticated HTTP MCP endpoint through the SDK's · 89 unmodified lines | |||
| 90 | `mcpServers` agent and send options. The adapter passes the authorization header | 90 | `mcpServers` agent and send options. The adapter passes the authorization header |
| 91 | to the SDK but projects only redacted option metadata into protocol diagnostics. | 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 | ### Initial Provider Support | 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 | Capability discovery still reports other registered provider instances, but marks them | 127 | Capability discovery still reports other registered provider instances, but marks them |
| 97 | unavailable for orchestration when no V2 adapter exists. This keeps provider | 128 | unavailable for orchestration when no V2 adapter exists. This keeps provider |
| 98 | selection model-visible without allowing a request that cannot run. | 129 | selection model-visible without allowing a request that cannot run. |
| @@ -299,6 +330,6 @@ Coverage includes: · 200 unmodified lines | |||
| 299 | - inheritance and per-thread provider overrides; and | 330 | - inheritance and per-thread provider overrides; and |
| 300 | - idempotent retries. | 331 | - idempotent retries. |
| 301 | 332 | ||
| 302 | Provider adapter tests separately verify Codex, Claude, and Cursor MCP injection, and | 333 | Provider adapter tests separately verify Codex, Claude, Cursor, Grok, and ACP |
| 303 | the provider-session manager test verifies that credentials exist before an | 334 | Registry behavior and MCP injection. The provider-session manager test verifies |
| 304 | adapter opens and are revoked when it closes. | 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 | Cursor replay transcript | 122 | Cursor replay transcript |
| 123 | -> Cursor Agent SDK open/send calls and ordered onDelta/run results | 123 | -> Cursor Agent SDK open/send calls and ordered onDelta/run results |
| 124 | -> consumed by CursorAdapter | 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 | Fixtures should preserve raw provider evidence as closely as possible. Expected V2 events or projections are assertions, not fixture input. | 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 | `SDKMessage` chunks in provider order. This intentionally tests the V2 adapter against real Claude | 139 | `SDKMessage` chunks in provider order. This intentionally tests the V2 adapter against real Claude |
| 132 | SDK output; it does not test the SDK's own subprocess or transport parser. | 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 | Provider transcript recorders live with the server orchestration testkit, not with provider client | 153 | Provider transcript recorders live with the server orchestration testkit, not with provider client |
| 135 | packages. Use `bun run record:codex-replay -- --scenario <name>` for Codex app-server transcripts | 154 | packages. Use `bun run record:codex-replay -- --scenario <name>` for Codex app-server transcripts |
| 136 | and `bun run record:claude-replay -- --scenario <name>` for Claude Agent SDK transcripts. Use | 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 | 2. Effect service definitions. | 225 | 2. Effect service definitions. |
| 207 | 3. provider runtime transport abstraction. | 226 | 3. provider runtime transport abstraction. |
| 208 | 4. generic replay runtime. | 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 | 6. projection reducer tests for core invariants. | 229 | 6. projection reducer tests for core invariants. |
| 211 | 7. full command-to-projection integration tests. | 230 | 7. full command-to-projection integration tests. |
| 212 | 8. production layers. | 231 | 8. production layers. |
| @@ -131,6 +131,7 @@ const CODEX_DRIVER_KIND = ProviderDriverKind.make("codex"); · 130 unmodified lines | |||
| 131 | const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); | 131 | const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); |
| 132 | const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor"); | 132 | const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor"); |
| 133 | const GROK_DRIVER_KIND = ProviderDriverKind.make("grok"); | 133 | const GROK_DRIVER_KIND = ProviderDriverKind.make("grok"); |
| 134 | const ACP_REGISTRY_DRIVER_KIND = ProviderDriverKind.make("acpRegistry"); | ||
| 134 | const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode"); | 135 | const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode"); |
| 135 | 136 | ||
| 136 | export const DEFAULT_MODEL = "gpt-5.4"; | 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 | [CLAUDE_DRIVER_KIND]: "claude-sonnet-4-6", | 142 | [CLAUDE_DRIVER_KIND]: "claude-sonnet-4-6", |
| 142 | [CURSOR_DRIVER_KIND]: "auto", | 143 | [CURSOR_DRIVER_KIND]: "auto", |
| 143 | [GROK_DRIVER_KIND]: "grok-build", | 144 | [GROK_DRIVER_KIND]: "grok-build", |
| 145 | [ACP_REGISTRY_DRIVER_KIND]: "default", | ||
| 144 | [OPENCODE_DRIVER_KIND]: "openai/gpt-5", | 146 | [OPENCODE_DRIVER_KIND]: "openai/gpt-5", |
| 145 | }; | 147 | }; |
| 146 | 148 | ||
| @@ -204,5 +206,6 @@ export const PROVIDER_DISPLAY_NAMES: Partial<Record<ProviderDriverKind, string>> · 57 unmodified lines | |||
| 204 | [CLAUDE_DRIVER_KIND]: "Claude", | 206 | [CLAUDE_DRIVER_KIND]: "Claude", |
| 205 | [CURSOR_DRIVER_KIND]: "Cursor", | 207 | [CURSOR_DRIVER_KIND]: "Cursor", |
| 206 | [GROK_DRIVER_KIND]: "Grok", | 208 | [GROK_DRIVER_KIND]: "Grok", |
| 209 | [ACP_REGISTRY_DRIVER_KIND]: "ACP Registry", | ||
| 207 | [OPENCODE_DRIVER_KIND]: "OpenCode", | 210 | [OPENCODE_DRIVER_KIND]: "OpenCode", |
| 208 | }; | 211 | }; |
| @@ -305,6 +305,56 @@ export const GrokSettings = makeProviderSettingsSchema( · 304 unmodified lines | |||
| 305 | ); | 305 | ); |
| 306 | export type GrokSettings = typeof GrokSettings.Type; | 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 | export const OpenCodeSettings = makeProviderSettingsSchema( | 358 | export const OpenCodeSettings = makeProviderSettingsSchema( |
| 309 | { | 359 | { |
| 310 | enabled: Schema.Boolean.pipe( | 360 | enabled: Schema.Boolean.pipe( |