| 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 * as HostProcess from "@t3tools/shared/HostProcess"; |
| 13 | import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; |
| 14 | import { causeErrorTag } from "@t3tools/shared/observability"; |
| 15 | import { |
| 16 | defaultInstanceIdForDriver, |
| 17 | type ModelSelection, |
| 18 | type OrchestrationV2AppThread, |
| 19 | type OrchestrationV2ConversationMessage, |
| 20 | type OrchestrationV2ExecutionNode, |
| 21 | type OrchestrationV2PlanStep, |
| 22 | type OrchestrationV2ProviderCapabilities, |
| 23 | type OrchestrationV2ProviderFailure, |
| 24 | type OrchestrationV2ProviderRef, |
| 25 | type OrchestrationV2ProviderSession, |
| 26 | type OrchestrationV2ProviderThread, |
| 27 | type OrchestrationV2ProviderTurn, |
| 28 | type OrchestrationV2RuntimeRequest, |
| 29 | type OrchestrationV2Subagent, |
| 30 | type OrchestrationV2TurnItem, |
| 31 | type PlanId, |
| 32 | ProviderDriverKind, |
| 33 | type ProviderInstanceId, |
| 34 | type ProviderRequestKind, |
| 35 | type ProviderSessionId, |
| 36 | type RuntimeRequestId, |
| 37 | type ThreadId, |
| 38 | } from "@t3tools/contracts"; |
| 39 | import { OpenCodeSettings } from "../settings.ts"; |
| 40 | import * as Cause from "effect/Cause"; |
| 41 | import * as DateTime from "effect/DateTime"; |
| 42 | import * as Deferred from "effect/Deferred"; |
| 43 | import * as Duration from "effect/Duration"; |
| 44 | import * as Effect from "effect/Effect"; |
| 45 | import * as Exit from "effect/Exit"; |
| 46 | import * as Fiber from "effect/Fiber"; |
| 47 | import * as Layer from "effect/Layer"; |
| 48 | import * as Option from "effect/Option"; |
| 49 | import * as Queue from "effect/Queue"; |
| 50 | import * as Random from "effect/Random"; |
| 51 | import * as Ref from "effect/Ref"; |
| 52 | import * as Schema from "effect/Schema"; |
| 53 | import * as Semaphore from "effect/Semaphore"; |
| 54 | import * as Scope from "effect/Scope"; |
| 55 | import * as Stream from "effect/Stream"; |
| 56 | |
| 57 | import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; |
| 58 | import { mcpToolPresentation } from "@t3tools/provider-core/server/mcpToolPresentation"; |
| 59 | import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; |
| 60 | import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; |
| 61 | import { |
| 62 | structuralProtocolMethod, |
| 63 | summarizeNativeProtocolPayload, |
| 64 | } from "@t3tools/provider-core/server/nativeProtocolLogging"; |
| 65 | import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; |
| 66 | import { t3OrchestrationSystemPrompt } from "@t3tools/provider-core/server/orchestrationInstructions"; |
| 67 | import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions"; |
| 68 | import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; |
| 69 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; |
| 70 | import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; |
| 71 | import { turnScopedSelectionTransition } from "@t3tools/provider-core/server/selectionTransition"; |
| 72 | import { providerMessageTextWithAttachmentPaths } from "@t3tools/provider-core/server/attachmentPrompt"; |
| 73 | import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; |
| 74 | import { |
| 75 | ProviderAdapterDriverCreateError, |
| 76 | type ProviderAdapterDriver, |
| 77 | type ProviderAdapterDriverCreateInput, |
| 78 | } from "@t3tools/provider-core/server/adapterDriver"; |
| 79 | import { |
| 80 | makeSubagentChildThread, |
| 81 | subagentThreadTitle, |
| 82 | } from "@t3tools/provider-core/server/subagentProjection"; |
| 83 | import { openCodeToolTurnItem } from "./toolItems.ts"; |
| 84 | |
| 85 | export { openCodeToolProjectionKind } from "./toolItems.ts"; |
| 86 | |
| 87 | export const OPENCODE_PROVIDER = ProviderDriverKind.make("opencode"); |
| 88 | export const OPENCODE_DEFAULT_INSTANCE_ID = defaultInstanceIdForDriver(OPENCODE_PROVIDER); |
| 89 | export const OPENCODE_SDK_PROTOCOL = "opencode-sdk.sse" as const; |
| 90 | const DEFAULT_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({}); |
| 91 | |
| 92 | const makeOpenCodeMessageId = Effect.fnUntraced(function* ( |
| 93 | clock: Ref.Ref<{ readonly epochMillis: number; readonly counter: number }>, |
| 94 | ) { |
| 95 | const epochMillis = DateTime.toEpochMillis(yield* DateTime.now); |
| 96 | const counter = yield* Ref.modify(clock, (previous) => { |
| 97 | const next = previous.epochMillis === epochMillis ? previous.counter + 1 : 1; |
| 98 | return [next, { epochMillis, counter: next }] as const; |
| 99 | }); |
| 100 | const encodedTime = BigInt.asUintN(48, BigInt(epochMillis) * 0x1000n + BigInt(counter)) |
| 101 | .toString(16) |
| 102 | .padStart(12, "0"); |
| 103 | const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; |
| 104 | const random = (yield* Effect.forEach(Array.from({ length: 28 }), () => |
| 105 | Random.nextIntBetween(0, alphabet.length - 1), |
| 106 | )) |
| 107 | .map((index) => alphabet[index]) |
| 108 | .join(""); |
| 109 | return `msg_${encodedTime}${random}`; |
| 110 | }); |
| 111 | |
| 112 | /** |
| 113 | * OpenCode's session, message, part, and interaction-request identifiers are |
| 114 | * durable. It does not expose a first-class turn object: the initiating user |
| 115 | * message is the best native turn correlation point, and session idle is the |
| 116 | * authoritative terminal signal. |
| 117 | */ |
| 118 | const OpenCodeProviderCapabilitiesV2 = { |
| 119 | sessions: { |
| 120 | // The current adapter owns one directory-bound client/server per session. |
| 121 | // Keep it isolated until its runtime is made safe for cross-thread pooling. |
| 122 | supportsMultipleProviderThreadsPerSession: false, |
| 123 | supportsModelSwitchInSession: true, |
| 124 | supportsProviderSwitchingViaHandoff: true, |
| 125 | supportsRuntimeModeSwitchInSession: false, |
| 126 | pendingRequestsSurviveRestart: false, |
| 127 | }, |
| 128 | threads: { |
| 129 | canCreateEmptyThread: true, |
| 130 | canReadThreadSnapshot: true, |
| 131 | canRollbackThread: true, |
| 132 | canForkThread: true, |
| 133 | canForkFromTurn: true, |
| 134 | canForkFromSubagentThread: true, |
| 135 | exposesNativeThreadId: true, |
| 136 | }, |
| 137 | turns: { |
| 138 | exposesNativeTurnId: false, |
| 139 | emitsTurnStarted: true, |
| 140 | emitsTurnCompleted: true, |
| 141 | supportsInterrupt: true, |
| 142 | supportsActiveSteering: true, |
| 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: true, |
| 160 | supportsDynamicToolCallbacks: false, |
| 161 | }, |
| 162 | approvals: { |
| 163 | supportsCommandApproval: true, |
| 164 | supportsFileReadApproval: true, |
| 165 | supportsFileChangeApproval: true, |
| 166 | supportsApplyPatchApproval: true, |
| 167 | approvalsHaveNativeRequestIds: true, |
| 168 | approvalCallbacksAreLiveOnly: true, |
| 169 | approvalsCanOriginateFromSubagents: true, |
| 170 | }, |
| 171 | planning: { |
| 172 | emitsPlanUpdated: true, |
| 173 | emitsTodoList: true, |
| 174 | emitsProposedPlan: false, |
| 175 | supportsStructuredQuestions: true, |
| 176 | planDeltasHaveItemIds: false, |
| 177 | }, |
| 178 | subagents: { |
| 179 | supportsSubagents: true, |
| 180 | exposesSubagentThreadIds: true, |
| 181 | emitsSubagentLifecycle: true, |
| 182 | canWaitForSubagents: true, |
| 183 | canCloseSubagents: false, |
| 184 | canForkSubagentThread: true, |
| 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: true, |
| 200 | providerRollbackReturnsSnapshot: true, |
| 201 | providerCanReadConversationSnapshot: true, |
| 202 | }, |
| 203 | identity: { |
| 204 | nativeThreadIds: "strong", |
| 205 | nativeTurnIds: "weak", |
| 206 | nativeItemIds: "strong", |
| 207 | nativeRequestIds: "strong", |
| 208 | }, |
| 209 | runtimePolicy: { |
| 210 | enforcement: "native", |
| 211 | }, |
| 212 | } satisfies OrchestrationV2ProviderCapabilities; |
| 213 | |
| 214 | type TerminalTurnStatus = Extract< |
| 215 | OrchestrationV2ProviderTurn["status"], |
| 216 | "completed" | "interrupted" | "failed" | "cancelled" |
| 217 | >; |
| 218 | |
| 219 | type OpenCodeStepUsage = Pick< |
| 220 | Extract<OpenCodePart, { readonly type: "step-finish" }>, |
| 221 | "id" | "tokens" |
| 222 | >; |
| 223 | |
| 224 | interface OpenCodeTurnTokenUsageAccumulator { |
| 225 | readonly partIds: Set<string>; |
| 226 | readonly promptMessageIds: Set<string>; |
| 227 | readonly assistantOwnershipByMessageId: Map<string, "owned" | "other" | "unknown">; |
| 228 | // Native removal does not undo usage. Keep unresolved counts until this turn settles. |
| 229 | readonly unresolvedStepsByMessageId: Map<string, Map<string, OpenCodeStepUsage>>; |
| 230 | inputTokens: number; |
| 231 | cachedInputTokens: number; |
| 232 | cacheCreationTokens: number; |
| 233 | outputTokens: number; |
| 234 | reasoningTokens: number; |
| 235 | hasSubagents: boolean; |
| 236 | complete: boolean; |
| 237 | } |
| 238 | |
| 239 | function makeOpenCodeTurnTokenUsageAccumulator(): OpenCodeTurnTokenUsageAccumulator { |
| 240 | return { |
| 241 | partIds: new Set(), |
| 242 | promptMessageIds: new Set(), |
| 243 | assistantOwnershipByMessageId: new Map(), |
| 244 | unresolvedStepsByMessageId: new Map(), |
| 245 | inputTokens: 0, |
| 246 | cachedInputTokens: 0, |
| 247 | cacheCreationTokens: 0, |
| 248 | outputTokens: 0, |
| 249 | reasoningTokens: 0, |
| 250 | hasSubagents: false, |
| 251 | complete: true, |
| 252 | }; |
| 253 | } |
| 254 | |
| 255 | function accumulateOpenCodeStepUsage( |
| 256 | accumulator: OpenCodeTurnTokenUsageAccumulator, |
| 257 | part: OpenCodeStepUsage, |
| 258 | ): void { |
| 259 | if (accumulator.partIds.has(part.id)) return; |
| 260 | accumulator.partIds.add(part.id); |
| 261 | accumulator.inputTokens += part.tokens.input + part.tokens.cache.read + part.tokens.cache.write; |
| 262 | accumulator.cachedInputTokens += part.tokens.cache.read; |
| 263 | accumulator.cacheCreationTokens += part.tokens.cache.write; |
| 264 | accumulator.outputTokens += part.tokens.output + part.tokens.reasoning; |
| 265 | accumulator.reasoningTokens += part.tokens.reasoning; |
| 266 | } |
| 267 | |
| 268 | interface ActiveOpenCodeTurn { |
| 269 | readonly usage: OpenCodeTurnTokenUsageAccumulator; |
| 270 | readonly isRoot: boolean; |
| 271 | readonly threadId: ThreadId; |
| 272 | readonly runId: ProviderAdapter.ProviderAdapterV2TurnInput["runId"] | null; |
| 273 | readonly rootNodeId: ProviderAdapter.ProviderAdapterV2TurnInput["rootNodeId"]; |
| 274 | readonly appThread: OrchestrationV2AppThread; |
| 275 | readonly modelSelection: ModelSelection; |
| 276 | readonly runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy; |
| 277 | readonly providerTurnId: OrchestrationV2ProviderTurn["id"]; |
| 278 | /** The provider thread the turn started on, which its terminal names. */ |
| 279 | readonly providerThreadId: OrchestrationV2ProviderTurn["providerThreadId"]; |
| 280 | readonly providerTurnOrdinal: number; |
| 281 | readonly runOrdinal: number; |
| 282 | readonly runAttemptId: OrchestrationV2ProviderTurn["runAttemptId"]; |
| 283 | readonly startedAt: DateTime.Utc; |
| 284 | readonly itemOrdinals: Map<string, number>; |
| 285 | readonly parts: Map<string, Exclude<OpenCodePart, ToolPart>>; |
| 286 | readonly partIdsByMessage: Map<string, Set<string>>; |
| 287 | readonly toolNamesByCallId: Map<string, string>; |
| 288 | mcpServerNames?: ReadonlyArray<string>; |
| 289 | readonly providerTurn: OrchestrationV2ProviderTurn; |
| 290 | nextItemOrdinal: number; |
| 291 | nativeUserMessageId: string | null; |
| 292 | admissionMessageId: string | null; |
| 293 | interrupted: boolean; |
| 294 | finalized: boolean; |
| 295 | planId: PlanId | null; |
| 296 | admissionGeneration: number; |
| 297 | admissionReconciliationGeneration: number | null; |
| 298 | admissionPending: boolean; |
| 299 | admissionAccepted: boolean; |
| 300 | admissionMessageObserved: boolean; |
| 301 | idleDuringAdmission: boolean; |
| 302 | admissionSettled: Deferred.Deferred<void>; |
| 303 | admissionAbortController: AbortController | null; |
| 304 | } |
| 305 | |
| 306 | type OpenCodeAdmissionSignal = |
| 307 | | "accepted" |
| 308 | | "assistant-completed" |
| 309 | | "busy" |
| 310 | | "idle" |
| 311 | | "user-message"; |
| 312 | type OpenCodeAdmissionAction = "hold" | "reconcile-idle" | "release"; |
| 313 | |
| 314 | export function advanceOpenCodePromptAdmission( |
| 315 | admission: Pick< |
| 316 | ActiveOpenCodeTurn, |
| 317 | "admissionAccepted" | "admissionMessageObserved" | "admissionPending" | "idleDuringAdmission" |
| 318 | >, |
| 319 | signal: OpenCodeAdmissionSignal, |
| 320 | ): OpenCodeAdmissionAction { |
| 321 | if (!admission.admissionPending) return "release"; |
| 322 | if (signal === "assistant-completed") { |
| 323 | admission.admissionAccepted = true; |
| 324 | admission.admissionMessageObserved = true; |
| 325 | admission.admissionPending = false; |
| 326 | return "release"; |
| 327 | } |
| 328 | if (signal === "idle") { |
| 329 | admission.idleDuringAdmission = true; |
| 330 | return "hold"; |
| 331 | } |
| 332 | if (signal === "accepted") admission.admissionAccepted = true; |
| 333 | if (signal === "busy" || signal === "user-message") admission.admissionMessageObserved = true; |
| 334 | if (!admission.admissionAccepted || !admission.admissionMessageObserved) return "hold"; |
| 335 | if (admission.idleDuringAdmission) return "reconcile-idle"; |
| 336 | admission.admissionPending = false; |
| 337 | return "release"; |
| 338 | } |
| 339 | |
| 340 | export function cancelOpenCodePromptAdmission( |
| 341 | admission: Pick<ActiveOpenCodeTurn, "admissionGeneration" | "admissionPending">, |
| 342 | nextGeneration: number, |
| 343 | ): void { |
| 344 | admission.admissionGeneration = nextGeneration; |
| 345 | admission.admissionPending = false; |
| 346 | } |
| 347 | |
| 348 | export const reconcileOpenCodePromptAdmissionStatus = Effect.fn( |
| 349 | "reconcileOpenCodePromptAdmissionStatus", |
| 350 | )(function* ( |
| 351 | admission: Pick<ActiveOpenCodeTurn, "admissionGeneration" | "admissionPending">, |
| 352 | generation: number, |
| 353 | readStatus: Effect.Effect<"busy" | "idle" | "unknown">, |
| 354 | ) { |
| 355 | if (admission.admissionGeneration !== generation || !admission.admissionPending) { |
| 356 | return "stale" as const; |
| 357 | } |
| 358 | const status = yield* readStatus; |
| 359 | if (admission.admissionGeneration !== generation || !admission.admissionPending) { |
| 360 | return "stale" as const; |
| 361 | } |
| 362 | if (status !== "unknown") admission.admissionPending = false; |
| 363 | return status; |
| 364 | }); |
| 365 | |
| 366 | interface OpenCodeSubagentContext { |
| 367 | readonly nativeItemId: string; |
| 368 | readonly nodeId: OrchestrationV2Subagent["id"]; |
| 369 | readonly parentState: OpenCodeThreadState; |
| 370 | readonly parentTurn: ActiveOpenCodeTurn; |
| 371 | readonly prompt: string; |
| 372 | readonly title: string | null; |
| 373 | readonly startedAt: DateTime.Utc; |
| 374 | childSessionId: string | null; |
| 375 | childThreadId: ThreadId | null; |
| 376 | childProviderThreadId: OrchestrationV2ProviderThread["id"] | null; |
| 377 | model: string | null; |
| 378 | result: string | null; |
| 379 | } |
| 380 | |
| 381 | interface OpenCodeThreadState { |
| 382 | readonly nativeSessionId: string; |
| 383 | providerThread: OrchestrationV2ProviderThread; |
| 384 | appThread: OrchestrationV2AppThread | null; |
| 385 | activeTurn: ActiveOpenCodeTurn | null; |
| 386 | readonly providerTurns: Map<string, OrchestrationV2ProviderTurn>; |
| 387 | readonly runtimeRequests: Map<string, OrchestrationV2RuntimeRequest>; |
| 388 | readonly messageRoles: Map<string, "user" | "assistant">; |
| 389 | readonly userMessageIds: Array<string>; |
| 390 | parentSubagent: OpenCodeSubagentContext | null; |
| 391 | nextChildTurnOrdinal: number; |
| 392 | nextAdmissionGeneration: number; |
| 393 | } |
| 394 | |
| 395 | interface OpenCodeRequestOwner { |
| 396 | readonly state: OpenCodeThreadState; |
| 397 | readonly turn: ActiveOpenCodeTurn; |
| 398 | /** The subagent on the owner's thread that leads to the asking session. */ |
| 399 | readonly subagent: OpenCodeSubagentContext | null; |
| 400 | } |
| 401 | |
| 402 | /** |
| 403 | * The top-level thread and active turn that own a session's requests, walking |
| 404 | * up through (possibly nested) subagents. |
| 405 | */ |
| 406 | function topLevelRequestOwner(state: OpenCodeThreadState): OpenCodeRequestOwner | undefined { |
| 407 | let owner = state; |
| 408 | let subagent: OpenCodeSubagentContext | null = null; |
| 409 | while (owner.parentSubagent !== null) { |
| 410 | subagent = owner.parentSubagent; |
| 411 | owner = subagent.parentState; |
| 412 | } |
| 413 | return owner.activeTurn === null ? undefined : { state: owner, turn: owner.activeTurn, subagent }; |
| 414 | } |
| 415 | |
| 416 | interface PendingOpenCodeRequest { |
| 417 | readonly requestId: RuntimeRequestId; |
| 418 | readonly nativeRequestId: string; |
| 419 | /** The session that asked, which may be a subagent's. */ |
| 420 | readonly nativeSessionId: string; |
| 421 | readonly turn: ActiveOpenCodeTurn; |
| 422 | readonly state: OpenCodeThreadState; |
| 423 | readonly nodeId: OrchestrationV2ExecutionNode["id"]; |
| 424 | readonly parentNodeId: OrchestrationV2ExecutionNode["id"]; |
| 425 | readonly turnItemId: OrchestrationV2TurnItem["id"]; |
| 426 | readonly requestKind: OpenCodePermissionRequestKind | "user_input"; |
| 427 | readonly createdAt: DateTime.Utc; |
| 428 | readonly permission?: PermissionRequest; |
| 429 | readonly question?: QuestionRequest; |
| 430 | } |
| 431 | |
| 432 | export interface OpenCodeAdapterV2Options { |
| 433 | readonly instanceId: ProviderInstanceId; |
| 434 | readonly settings: OpenCodeSettings; |
| 435 | readonly environment: NodeJS.ProcessEnv; |
| 436 | readonly nativeEventLogger?: ProviderEventLoggers.EventNdjsonLogger; |
| 437 | } |
| 438 | |
| 439 | export interface OpenCodeProtocolLogEvent { |
| 440 | readonly direction: "incoming" | "outgoing"; |
| 441 | readonly messageKind: "request" | "response" | "notification" | "error"; |
| 442 | readonly method: string; |
| 443 | readonly payload: unknown; |
| 444 | } |
| 445 | |
| 446 | function formatOpenCodeProtocolLogPayload(event: OpenCodeProtocolLogEvent) { |
| 447 | return { |
| 448 | direction: event.direction, |
| 449 | messageKind: event.messageKind, |
| 450 | method: structuralProtocolMethod(event.method), |
| 451 | payload: summarizeNativeProtocolPayload(event.payload), |
| 452 | }; |
| 453 | } |
| 454 | |
| 455 | export function makeOpenCodeProtocolLogger(input: { |
| 456 | readonly nativeEventLogger: ProviderEventLoggers.EventNdjsonLogger | undefined; |
| 457 | readonly idAllocator: IdAllocator.IdAllocatorV2["Service"]; |
| 458 | readonly providerInstanceId: ProviderInstanceId; |
| 459 | readonly providerSessionId: ProviderSessionId; |
| 460 | readonly threadId: ThreadId; |
| 461 | }): (event: OpenCodeProtocolLogEvent) => Effect.Effect<void, never> { |
| 462 | return (event) => |
| 463 | Effect.gen(function* () { |
| 464 | if (!input.nativeEventLogger) return; |
| 465 | const observedAt = DateTime.formatIso(yield* DateTime.now); |
| 466 | const method = structuralProtocolMethod(event.method); |
| 467 | yield* input.nativeEventLogger.write( |
| 468 | { |
| 469 | observedAt, |
| 470 | event: { |
| 471 | id: yield* input.idAllocator.allocate.rawEvent({ |
| 472 | providerSessionId: input.providerSessionId, |
| 473 | method, |
| 474 | }), |
| 475 | kind: "protocol", |
| 476 | protocol: OPENCODE_SDK_PROTOCOL, |
| 477 | provider: OPENCODE_PROVIDER, |
| 478 | providerInstanceId: input.providerInstanceId, |
| 479 | providerSessionId: input.providerSessionId, |
| 480 | createdAt: observedAt, |
| 481 | threadId: input.threadId, |
| 482 | payload: formatOpenCodeProtocolLogPayload(event), |
| 483 | }, |
| 484 | }, |
| 485 | input.threadId, |
| 486 | ); |
| 487 | }).pipe( |
| 488 | Effect.catchCause((cause) => |
| 489 | Cause.hasInterrupts(cause) |
| 490 | ? Effect.interrupt |
| 491 | : Effect.logWarning("Failed to write native OpenCode event log.", { |
| 492 | errorTag: causeErrorTag(cause), |
| 493 | reasonCount: cause.reasons.length, |
| 494 | provider: OPENCODE_PROVIDER, |
| 495 | threadId: input.threadId, |
| 496 | }), |
| 497 | ), |
| 498 | ); |
| 499 | } |
| 500 | |
| 501 | function protocolError( |
| 502 | detail: string, |
| 503 | payload?: unknown, |
| 504 | ): ProviderAdapter.ProviderAdapterProtocolError { |
| 505 | return new ProviderAdapter.ProviderAdapterProtocolError({ |
| 506 | driver: OPENCODE_PROVIDER, |
| 507 | detail, |
| 508 | ...(payload === undefined ? {} : { payload }), |
| 509 | }); |
| 510 | } |
| 511 | |
| 512 | function nativeThreadId(providerThread: OrchestrationV2ProviderThread): string { |
| 513 | const nativeId = providerThread.nativeThreadRef?.nativeId; |
| 514 | if (nativeId === null || nativeId === undefined) { |
| 515 | throw protocolError(`Provider thread ${providerThread.id} has no OpenCode session id`); |
| 516 | } |
| 517 | return nativeId; |
| 518 | } |
| 519 | |
| 520 | function dateTimeFromEpoch(value: number | undefined, fallback: DateTime.Utc): DateTime.Utc { |
| 521 | if (value === undefined) return fallback; |
| 522 | return Option.getOrElse(DateTime.make(value), () => fallback); |
| 523 | } |
| 524 | |
| 525 | function nonEmptyString(value: unknown): string | undefined { |
| 526 | return typeof value === "string" && value.trim().length > 0 ? value : undefined; |
| 527 | } |
| 528 | |
| 529 | function recordValue(input: unknown, key: string): unknown { |
| 530 | return typeof input === "object" && input !== null && key in input |
| 531 | ? (input as Record<string, unknown>)[key] |
| 532 | : undefined; |
| 533 | } |
| 534 | |
| 535 | function recordString(input: unknown, ...keys: ReadonlyArray<string>): string | undefined { |
| 536 | for (const key of keys) { |
| 537 | const value = nonEmptyString(recordValue(input, key)); |
| 538 | if (value !== undefined) return value; |
| 539 | } |
| 540 | return undefined; |
| 541 | } |
| 542 | |
| 543 | function sdkResponseForRawLog(value: unknown): unknown { |
| 544 | if (typeof value !== "object" || value === null) return value; |
| 545 | if ("data" in value) return { data: (value as { readonly data?: unknown }).data ?? null }; |
| 546 | if ("stream" in value) return { subscribed: true }; |
| 547 | return value; |
| 548 | } |
| 549 | |
| 550 | type OpenCodePermissionRequestKind = Extract< |
| 551 | ProviderRequestKind, |
| 552 | "command" | "file-read" | "file-change" |
| 553 | >; |
| 554 | |
| 555 | export function openCodePermissionRequestKind( |
| 556 | permission: string, |
| 557 | toolName?: string, |
| 558 | ): OpenCodePermissionRequestKind { |
| 559 | const normalized = permission.toLowerCase(); |
| 560 | const normalizedTool = toolName?.toLowerCase() ?? ""; |
| 561 | if ( |
| 562 | normalized === "edit" || |
| 563 | normalized === "write" || |
| 564 | normalized === "patch" || |
| 565 | normalized === "apply_patch" || |
| 566 | normalizedTool === "edit" || |
| 567 | normalizedTool === "write" || |
| 568 | normalizedTool === "patch" || |
| 569 | normalizedTool === "apply_patch" |
| 570 | ) { |
| 571 | return "file-change"; |
| 572 | } |
| 573 | if ( |
| 574 | normalized === "read" || |
| 575 | normalized === "glob" || |
| 576 | normalized === "grep" || |
| 577 | normalized === "lsp" || |
| 578 | normalized === "external_directory" || |
| 579 | normalizedTool === "read" || |
| 580 | normalizedTool.includes("glob") || |
| 581 | normalizedTool.includes("grep") || |
| 582 | normalizedTool.includes("search") |
| 583 | ) { |
| 584 | return "file-read"; |
| 585 | } |
| 586 | return "command"; |
| 587 | } |
| 588 | |
| 589 | const OPENCODE_ALWAYS_ALLOWED_PERMISSIONS = [ |
| 590 | "question", |
| 591 | "read", |
| 592 | "glob", |
| 593 | "grep", |
| 594 | "lsp", |
| 595 | "todowrite", |
| 596 | "task", |
| 597 | "skill", |
| 598 | ] as const; |
| 599 | |
| 600 | const OPENCODE_RESTRICTED_PERMISSIONS = [ |
| 601 | "bash", |
| 602 | "edit", |
| 603 | "webfetch", |
| 604 | "websearch", |
| 605 | "codesearch", |
| 606 | "external_directory", |
| 607 | "doom_loop", |
| 608 | ] as const; |
| 609 | |
| 610 | /** |
| 611 | * OpenCode does not provide an OS sandbox, so permission rules are also the |
| 612 | * enforcement boundary for non-interactive policies. Read/planning tools are |
| 613 | * safe by default; edits are auto-approved only for workspace-write modes, |
| 614 | * while shell/network/external access remains gated unless policy explicitly |
| 615 | * allows it. |
| 616 | */ |
| 617 | export function openCodePermissionRules( |
| 618 | runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy, |
| 619 | ): PermissionRuleset { |
| 620 | const sandboxPolicy = recordValue(runtimePolicy, "sandboxPolicy"); |
| 621 | const sandboxType = recordString(sandboxPolicy, "type"); |
| 622 | const rawApprovalPolicy = runtimePolicy.approvalPolicy; |
| 623 | const approvalPolicy = nonEmptyString(rawApprovalPolicy); |
| 624 | const requiresApproval = |
| 625 | approvalPolicy === undefined |
| 626 | ? (typeof rawApprovalPolicy === "object" && rawApprovalPolicy !== null) || |
| 627 | runtimePolicy.runtimeMode !== "full-access" |
| 628 | : approvalPolicy !== "never"; |
| 629 | const externallySandboxed = sandboxType === "externalSandbox"; |
| 630 | const dangerFullAccess = sandboxType === "dangerFullAccess"; |
| 631 | const implicitFullAccess = |
| 632 | sandboxType === undefined && runtimePolicy.runtimeMode === "full-access"; |
| 633 | |
| 634 | if (!requiresApproval && (externallySandboxed || dangerFullAccess || implicitFullAccess)) { |
| 635 | return [{ permission: "*", pattern: "*", action: "allow" }]; |
| 636 | } |
| 637 | |
| 638 | // Task sessions initially inherit only parent deny rules. Seed explicit |
| 639 | // denies before the effective ask/allow overrides so a child is safe during |
| 640 | // the short interval before emitSubagent installs its complete policy. |
| 641 | const rules: PermissionRuleset = [ |
| 642 | { permission: "*", pattern: "*", action: "deny" }, |
| 643 | ...OPENCODE_RESTRICTED_PERMISSIONS.map((permission) => ({ |
| 644 | permission, |
| 645 | pattern: "*", |
| 646 | action: "deny" as const, |
| 647 | })), |
| 648 | ]; |
| 649 | |
| 650 | if (requiresApproval) { |
| 651 | rules.push({ permission: "*", pattern: "*", action: "ask" }); |
| 652 | for (const permission of OPENCODE_RESTRICTED_PERMISSIONS) { |
| 653 | rules.push({ permission, pattern: "*", action: "ask" }); |
| 654 | } |
| 655 | } |
| 656 | |
| 657 | rules.push( |
| 658 | ...OPENCODE_ALWAYS_ALLOWED_PERMISSIONS.map((permission) => ({ |
| 659 | permission, |
| 660 | pattern: "*", |
| 661 | action: "allow" as const, |
| 662 | })), |
| 663 | ); |
| 664 | |
| 665 | rules.push( |
| 666 | { permission: "read", pattern: "*.env", action: requiresApproval ? "ask" : "deny" }, |
| 667 | { permission: "read", pattern: "*.env.*", action: requiresApproval ? "ask" : "deny" }, |
| 668 | { permission: "read", pattern: "*.env.example", action: "allow" }, |
| 669 | ); |
| 670 | |
| 671 | if ( |
| 672 | runtimePolicy.runtimeMode === "auto-accept-edits" || |
| 673 | (!requiresApproval && sandboxType === "workspaceWrite") |
| 674 | ) { |
| 675 | rules.push({ permission: "edit", pattern: "*", action: "allow" }); |
| 676 | } |
| 677 | |
| 678 | if (!requiresApproval && recordValue(sandboxPolicy, "networkAccess") === true) { |
| 679 | for (const permission of ["webfetch", "websearch", "codesearch"] as const) { |
| 680 | rules.push({ permission, pattern: "*", action: "allow" }); |
| 681 | } |
| 682 | } |
| 683 | |
| 684 | if (!requiresApproval && sandboxType === "readOnly") { |
| 685 | const access = recordValue(sandboxPolicy, "access"); |
| 686 | if (recordString(access, "type") === "fullAccess") { |
| 687 | rules.push({ permission: "external_directory", pattern: "*", action: "allow" }); |
| 688 | } |
| 689 | } |
| 690 | |
| 691 | if (!requiresApproval && sandboxType === "workspaceWrite") { |
| 692 | const writableRoots = recordValue(sandboxPolicy, "writableRoots"); |
| 693 | if (Array.isArray(writableRoots)) { |
| 694 | for (const root of writableRoots) { |
| 695 | if (typeof root === "string" && root.trim().length > 0) { |
| 696 | rules.push({ |
| 697 | permission: "external_directory", |
| 698 | pattern: `${root.replace(/\/$/, "")}/*`, |
| 699 | action: "allow", |
| 700 | }); |
| 701 | } |
| 702 | } |
| 703 | } |
| 704 | } |
| 705 | |
| 706 | return rules; |
| 707 | } |
| 708 | |
| 709 | function permissionRuleEquals( |
| 710 | left: PermissionRuleset[number], |
| 711 | right: PermissionRuleset[number], |
| 712 | ): boolean { |
| 713 | return ( |
| 714 | left.permission === right.permission && |
| 715 | left.pattern === right.pattern && |
| 716 | left.action === right.action |
| 717 | ); |
| 718 | } |
| 719 | |
| 720 | /** |
| 721 | * OpenCode task sessions inherit only the parent's deny/external-directory |
| 722 | * rules and then add agent-specific restrictions such as disabling nested |
| 723 | * tasks. Install the complete parent policy while retaining only rules that |
| 724 | * were added specifically for the selected child agent. |
| 725 | */ |
| 726 | export function openCodeChildPermissionRules( |
| 727 | runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy, |
| 728 | nativeChildRules: PermissionRuleset, |
| 729 | ): PermissionRuleset { |
| 730 | const parentRules = openCodePermissionRules(runtimePolicy); |
| 731 | const inheritedRules = parentRules.filter( |
| 732 | (rule) => rule.permission === "external_directory" || rule.action === "deny", |
| 733 | ); |
| 734 | const childSpecificRules = nativeChildRules.filter( |
| 735 | (childRule) => |
| 736 | !inheritedRules.some((inheritedRule) => permissionRuleEquals(childRule, inheritedRule)), |
| 737 | ); |
| 738 | return [...parentRules, ...childSpecificRules]; |
| 739 | } |
| 740 | |
| 741 | /** |
| 742 | * OpenCode's fork/revert boundary is exclusive. To retain the selected app |
| 743 | * turn, address the next native user message; omitting a boundary retains the |
| 744 | * current head when the selected turn is already last. |
| 745 | */ |
| 746 | export function openCodeBoundaryAfterProviderTurn( |
| 747 | providerTurns: ReadonlyArray<OrchestrationV2ProviderTurn>, |
| 748 | selectedProviderTurnId: OrchestrationV2ProviderTurn["id"], |
| 749 | ): string | undefined { |
| 750 | const selected = providerTurns.find((turn) => turn.id === selectedProviderTurnId); |
| 751 | if (selected === undefined) return undefined; |
| 752 | return providerTurns |
| 753 | .filter((turn) => turn.ordinal > selected.ordinal) |
| 754 | .toSorted((left, right) => left.ordinal - right.ordinal) |
| 755 | .map((turn) => turn.nativeTurnRef?.nativeId) |
| 756 | .find((nativeId): nativeId is string => nativeId !== null && nativeId !== undefined); |
| 757 | } |
| 758 | |
| 759 | function toolStatus(part: ToolPart): { |
| 760 | readonly node: OrchestrationV2ExecutionNode["status"]; |
| 761 | readonly item: OrchestrationV2TurnItem["status"]; |
| 762 | } { |
| 763 | switch (part.state.status) { |
| 764 | case "pending": |
| 765 | return { node: "pending", item: "pending" }; |
| 766 | case "running": |
| 767 | return { node: "running", item: "running" }; |
| 768 | case "completed": |
| 769 | return { node: "completed", item: "completed" }; |
| 770 | case "error": |
| 771 | return { node: "failed", item: "failed" }; |
| 772 | } |
| 773 | } |
| 774 | |
| 775 | function toolInput(part: ToolPart): Record<string, unknown> { |
| 776 | return part.state.input; |
| 777 | } |
| 778 | |
| 779 | function toolOutput(part: ToolPart): string | undefined { |
| 780 | if (part.state.status === "completed") return part.state.output; |
| 781 | if (part.state.status === "error") return part.state.error; |
| 782 | return undefined; |
| 783 | } |
| 784 | |
| 785 | function toolStartedAt(part: ToolPart, now: DateTime.Utc): DateTime.Utc { |
| 786 | return dateTimeFromEpoch( |
| 787 | part.state.status === "pending" ? undefined : part.state.time.start, |
| 788 | now, |
| 789 | ); |
| 790 | } |
| 791 | |
| 792 | function toolCompletedAt(part: ToolPart, now: DateTime.Utc): DateTime.Utc | null { |
| 793 | return part.state.status === "completed" || part.state.status === "error" |
| 794 | ? dateTimeFromEpoch(part.state.time.end, now) |
| 795 | : null; |
| 796 | } |
| 797 | |
| 798 | function toolTitle(part: ToolPart): string | null { |
| 799 | return part.state.status === "running" || part.state.status === "completed" |
| 800 | ? (part.state.title ?? null) |
| 801 | : null; |
| 802 | } |
| 803 | |
| 804 | function toolModel(part: ToolPart): string | null { |
| 805 | const metadata = |
| 806 | part.state.status === "running" || |
| 807 | part.state.status === "completed" || |
| 808 | part.state.status === "error" |
| 809 | ? part.state.metadata |
| 810 | : undefined; |
| 811 | const model = recordValue(metadata, "model"); |
| 812 | const providerId = recordString(model, "providerID", "providerId"); |
| 813 | const modelId = recordString(model, "modelID", "modelId", "id"); |
| 814 | return providerId !== undefined && modelId !== undefined ? `${providerId}/${modelId}` : null; |
| 815 | } |
| 816 | |
| 817 | function taskSessionId(part: ToolPart): string | null { |
| 818 | const metadata = |
| 819 | part.state.status === "running" || |
| 820 | part.state.status === "completed" || |
| 821 | part.state.status === "error" |
| 822 | ? part.state.metadata |
| 823 | : undefined; |
| 824 | return recordString(metadata, "sessionId", "sessionID") ?? null; |
| 825 | } |
| 826 | |
| 827 | function makeProviderThread(input: { |
| 828 | readonly idAllocator: IdAllocator.IdAllocatorV2["Service"]; |
| 829 | readonly providerInstanceId: ProviderInstanceId; |
| 830 | readonly providerSessionId: OrchestrationV2ProviderThread["providerSessionId"]; |
| 831 | readonly appThreadId: OrchestrationV2ProviderThread["appThreadId"]; |
| 832 | readonly ownerNodeId?: OrchestrationV2ProviderThread["ownerNodeId"]; |
| 833 | readonly nativeSession: OpenCodeSession; |
| 834 | readonly forkedFrom?: OrchestrationV2ProviderThread["forkedFrom"]; |
| 835 | readonly now: DateTime.Utc; |
| 836 | }): OrchestrationV2ProviderThread { |
| 837 | const createdAt = dateTimeFromEpoch(input.nativeSession.time.created, input.now); |
| 838 | return { |
| 839 | id: input.idAllocator.derive.providerThread({ |
| 840 | driver: OPENCODE_PROVIDER, |
| 841 | nativeThreadId: input.nativeSession.id, |
| 842 | }), |
| 843 | driver: OPENCODE_PROVIDER, |
| 844 | providerInstanceId: input.providerInstanceId, |
| 845 | providerSessionId: input.providerSessionId, |
| 846 | appThreadId: input.appThreadId, |
| 847 | ownerNodeId: input.ownerNodeId ?? null, |
| 848 | nativeThreadRef: { |
| 849 | driver: OPENCODE_PROVIDER, |
| 850 | nativeId: input.nativeSession.id, |
| 851 | strength: "strong", |
| 852 | }, |
| 853 | nativeConversationHeadRef: null, |
| 854 | status: "idle", |
| 855 | firstRunOrdinal: null, |
| 856 | lastRunOrdinal: null, |
| 857 | handoffIds: [], |
| 858 | forkedFrom: input.forkedFrom ?? null, |
| 859 | createdAt, |
| 860 | updatedAt: dateTimeFromEpoch(input.nativeSession.time.updated, input.now), |
| 861 | }; |
| 862 | } |
| 863 | |
| 864 | function providerRef(nativeId: string, strength: "strong" | "weak" = "strong") { |
| 865 | return { |
| 866 | driver: OPENCODE_PROVIDER, |
| 867 | nativeId, |
| 868 | strength, |
| 869 | } satisfies OrchestrationV2ProviderRef; |
| 870 | } |
| 871 | |
| 872 | function openCodeErrorMessage(event: Extract<OpenCodeEvent, { type: "session.error" }>): string { |
| 873 | const error = event.properties.error; |
| 874 | if (error === undefined) return "OpenCode session failed without an error payload."; |
| 875 | return recordString(error.data, "message") ?? error.name; |
| 876 | } |
| 877 | |
| 878 | function terminalStatusForError( |
| 879 | event: Extract<OpenCodeEvent, { type: "session.error" }>, |
| 880 | turn: ActiveOpenCodeTurn, |
| 881 | ): TerminalTurnStatus { |
| 882 | return turn.interrupted || isMessageAbortedError(event) ? "interrupted" : "failed"; |
| 883 | } |
| 884 | |
| 885 | function isMessageAbortedError(event: Extract<OpenCodeEvent, { type: "session.error" }>): boolean { |
| 886 | return event.properties.error?.name === "MessageAbortedError"; |
| 887 | } |
| 888 | |
| 889 | function isOpenCodeNotFound(cause: unknown): boolean { |
| 890 | const seen = new Set<unknown>(); |
| 891 | const queue: Array<unknown> = [cause]; |
| 892 | for (let steps = 0; queue.length > 0 && steps < 32; steps += 1) { |
| 893 | const node = queue.shift(); |
| 894 | if (node === null || typeof node !== "object" || seen.has(node)) { |
| 895 | continue; |
| 896 | } |
| 897 | seen.add(node); |
| 898 | const record = node as Record<string, unknown>; |
| 899 | |
| 900 | const response = record.response; |
| 901 | const statuses = [ |
| 902 | record.status, |
| 903 | record.statusCode, |
| 904 | response !== null && typeof response === "object" |
| 905 | ? (response as { readonly status?: unknown }).status |
| 906 | : undefined, |
| 907 | ].filter((status): status is number => typeof status === "number"); |
| 908 | if (statuses.includes(404)) { |
| 909 | return true; |
| 910 | } |
| 911 | if (statuses.length > 0) { |
| 912 | continue; |
| 913 | } |
| 914 | |
| 915 | const name = record.name; |
| 916 | if (typeof name === "string" && name.toLowerCase() === "notfounderror") { |
| 917 | return true; |
| 918 | } |
| 919 | |
| 920 | for (const key of ["cause", "body", "error", "data"] as const) { |
| 921 | if (record[key] !== undefined) { |
| 922 | queue.push(record[key]); |
| 923 | } |
| 924 | } |
| 925 | } |
| 926 | return false; |
| 927 | } |
| 928 | |
| 929 | function unwrapData<A>(operation: string, result: { readonly data?: A }): NonNullable<A> { |
| 930 | if (result.data === undefined) { |
| 931 | throw new OpenCodeRuntime.OpenCodeRuntimeError({ |
| 932 | operation, |
| 933 | detail: `OpenCode ${operation} returned no response payload.`, |
| 934 | }); |
| 935 | } |
| 936 | return result.data as NonNullable<A>; |
| 937 | } |
| 938 | |
| 939 | export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function* ( |
| 940 | options: OpenCodeAdapterV2Options, |
| 941 | ) { |
| 942 | const runtime = yield* OpenCodeRuntime.OpenCodeRuntime; |
| 943 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 944 | const host = yield* ProviderHost.ProviderHost; |
| 945 | const mcpSessions = yield* McpProviderSessions.McpProviderSessions; |
| 946 | const messageIdClock = yield* Ref.make({ epochMillis: -1, counter: 0 }); |
| 947 | |
| 948 | return ProviderAdapter.ProviderAdapterV2.of({ |
| 949 | instanceId: options.instanceId, |
| 950 | driver: OPENCODE_PROVIDER, |
| 951 | getCapabilities: () => Effect.succeed(OpenCodeProviderCapabilitiesV2), |
| 952 | planSelectionTransition: () => Effect.succeed(turnScopedSelectionTransition()), |
| 953 | openSession: Effect.fn("OpenCodeAdapterV2.openSession")( |
| 954 | function* (input: ProviderAdapter.ProviderAdapterV2OpenSessionInput) { |
| 955 | const scope = yield* Effect.scope; |
| 956 | const cwd = input.runtimePolicy.cwd ?? host.paths.cwd; |
| 957 | const connection = yield* runtime.connectToOpenCodeServer({ |
| 958 | binaryPath: options.settings.binaryPath, |
| 959 | directory: cwd, |
| 960 | serverUrl: options.settings.serverUrl, |
| 961 | environment: options.environment, |
| 962 | }); |
| 963 | const client = runtime.createOpenCodeSdkClient({ |
| 964 | baseUrl: connection.url, |
| 965 | directory: cwd, |
| 966 | ...(connection.external && options.settings.serverPassword |
| 967 | ? { serverPassword: options.settings.serverPassword } |
| 968 | : {}), |
| 969 | }); |
| 970 | |
| 971 | const mcpSession = yield* mcpSessions.read(input.threadId); |
| 972 | const hasT3Mcp = mcpSession !== undefined && !connection.external; |
| 973 | const orchestrationSystemPrompt = t3OrchestrationSystemPrompt(hasT3Mcp); |
| 974 | if (hasT3Mcp) { |
| 975 | yield* OpenCodeRuntime.runOpenCodeSdk("mcp.add", () => |
| 976 | client.mcp.add({ |
| 977 | name: "t3-code", |
| 978 | config: { |
| 979 | type: "remote", |
| 980 | url: mcpSession.endpoint, |
| 981 | headers: { Authorization: mcpSession.authorizationHeader }, |
| 982 | oauth: false, |
| 983 | }, |
| 984 | }), |
| 985 | ); |
| 986 | } |
| 987 | |
| 988 | const now = yield* DateTime.now; |
| 989 | let sessionEntity: OrchestrationV2ProviderSession = { |
| 990 | id: input.providerSessionId, |
| 991 | driver: OPENCODE_PROVIDER, |
| 992 | providerInstanceId: options.instanceId, |
| 993 | status: "ready", |
| 994 | cwd, |
| 995 | model: input.modelSelection.model, |
| 996 | capabilities: OpenCodeProviderCapabilitiesV2, |
| 997 | createdAt: now, |
| 998 | updatedAt: now, |
| 999 | lastError: null, |
| 1000 | }; |
| 1001 | const events = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event, Cause.Done>(); |
| 1002 | let nativeStreamFailure: OrchestrationV2ProviderFailure | null = null; |
| 1003 | const threads = new Map<string, OpenCodeThreadState>(); |
| 1004 | const commandReceipts = new Map<string, Deferred.Deferred<void>>(); |
| 1005 | const commandControllers = new Map<string, Set<AbortController>>(); |
| 1006 | const pendingRequests = new Map<string, PendingOpenCodeRequest>(); |
| 1007 | const pendingRequestsByNativeId = new Map<string, PendingOpenCodeRequest>(); |
| 1008 | const subagentsByNativeItemId = new Map<string, OpenCodeSubagentContext>(); |
| 1009 | const subagentsByChildSessionId = new Map<string, OpenCodeSubagentContext>(); |
| 1010 | // Permission and question requests can originate from child sessions |
| 1011 | // (task subagents and their descendants). Related sessions map back |
| 1012 | // to the root thread state whose active turn owns the request; asks |
| 1013 | // arriving before the relation is known resolve it via session.get. |
| 1014 | const relatedSessionOwners = new Map<string, OpenCodeThreadState>(); |
| 1015 | // Sessions of this runtime that OpenCode reports busy. A background |
| 1016 | // task child keeps running after its parent turn settles, so idle |
| 1017 | // release must not close the server under it. |
| 1018 | const busySessionIds = new Set<string>(); |
| 1019 | // Requests settled before their session relation resolved: a late |
| 1020 | // routing attempt must never resurrect them. Bounded because entries |
| 1021 | // only matter for the seconds a routing retry can still be running. |
| 1022 | const settledNativeRequestIds = new Set<string>(); |
| 1023 | const pendingChildRequestRoutes = new Set<string>(); |
| 1024 | const rememberSettledRequest = (nativeRequestId: string) => { |
| 1025 | if (settledNativeRequestIds.size >= 2_048) settledNativeRequestIds.clear(); |
| 1026 | settledNativeRequestIds.add(nativeRequestId); |
| 1027 | }; |
| 1028 | const abortController = new AbortController(); |
| 1029 | let closing = false; |
| 1030 | let hasConnected = false; |
| 1031 | |
| 1032 | const emitProviderEvent = (event: ProviderAdapter.ProviderAdapterV2Event) => |
| 1033 | Queue.offer(events, event).pipe(Effect.asVoid); |
| 1034 | |
| 1035 | const logProtocolEvent = makeOpenCodeProtocolLogger({ |
| 1036 | nativeEventLogger: options.nativeEventLogger, |
| 1037 | idAllocator, |
| 1038 | providerInstanceId: options.instanceId, |
| 1039 | providerSessionId: input.providerSessionId, |
| 1040 | threadId: input.threadId, |
| 1041 | }); |
| 1042 | |
| 1043 | const sdkCall = <A>( |
| 1044 | method: string, |
| 1045 | payload: unknown, |
| 1046 | call: (signal: AbortSignal) => Promise<A>, |
| 1047 | ): Effect.Effect<A, OpenCodeRuntime.OpenCodeRuntimeError> => |
| 1048 | logProtocolEvent({ |
| 1049 | direction: "outgoing", |
| 1050 | messageKind: "request", |
| 1051 | method, |
| 1052 | payload, |
| 1053 | }).pipe( |
| 1054 | Effect.andThen(OpenCodeRuntime.runOpenCodeSdk(method, call)), |
| 1055 | Effect.tap((response) => |
| 1056 | logProtocolEvent({ |
| 1057 | direction: "incoming", |
| 1058 | messageKind: "response", |
| 1059 | method, |
| 1060 | payload: sdkResponseForRawLog(response), |
| 1061 | }), |
| 1062 | ), |
| 1063 | ); |
| 1064 | |
| 1065 | const abortDescendants = (rootId: string) => |
| 1066 | Effect.gen(function* () { |
| 1067 | const visited = new Set([rootId]); |
| 1068 | const semaphore = Semaphore.makeUnsafe(8); |
| 1069 | const visit = ( |
| 1070 | sessionId: string, |
| 1071 | abort: boolean, |
| 1072 | ): Effect.Effect<OpenCodeRuntime.OpenCodeRuntimeError | undefined> => |
| 1073 | Effect.gen(function* () { |
| 1074 | const abortResult = abort |
| 1075 | ? yield* sdkCall("session.abort", { sessionID: sessionId }, (signal) => |
| 1076 | client.session.abort({ sessionID: sessionId }, { signal }), |
| 1077 | ).pipe( |
| 1078 | semaphore.withPermit, |
| 1079 | Effect.catchIf(isOpenCodeNotFound, () => Effect.void), |
| 1080 | Effect.result, |
| 1081 | ) |
| 1082 | : undefined; |
| 1083 | const childrenResult = yield* sdkCall( |
| 1084 | "session.children", |
| 1085 | { sessionID: sessionId }, |
| 1086 | (signal) => client.session.children({ sessionID: sessionId }, { signal }), |
| 1087 | ).pipe( |
| 1088 | semaphore.withPermit, |
| 1089 | Effect.catchIf(isOpenCodeNotFound, () => Effect.void), |
| 1090 | Effect.result, |
| 1091 | ); |
| 1092 | const firstFailure = |
| 1093 | abortResult?._tag === "Failure" ? abortResult.failure : undefined; |
| 1094 | if (childrenResult._tag === "Failure") |
| 1095 | return firstFailure ?? childrenResult.failure; |
| 1096 | const fresh = (childrenResult.success?.data ?? []).filter((child) => { |
| 1097 | if (visited.has(child.id)) return false; |
| 1098 | visited.add(child.id); |
| 1099 | return true; |
| 1100 | }); |
| 1101 | const failures = yield* Effect.forEach(fresh, (child) => visit(child.id, true), { |
| 1102 | concurrency: 8, |
| 1103 | }); |
| 1104 | return firstFailure ?? failures.find((failure) => failure !== undefined); |
| 1105 | }); |
| 1106 | const failure = yield* visit(rootId, false); |
| 1107 | if (failure) return yield* Effect.fail(failure); |
| 1108 | }).pipe(Effect.timeout("15 seconds")); |
| 1109 | |
| 1110 | const updateProviderSession = ( |
| 1111 | status: OrchestrationV2ProviderSession["status"], |
| 1112 | lastError: string | null = sessionEntity.lastError, |
| 1113 | ) => |
| 1114 | Effect.gen(function* () { |
| 1115 | const updatedAt = yield* DateTime.now; |
| 1116 | sessionEntity = { ...sessionEntity, status, lastError, updatedAt }; |
| 1117 | yield* emitProviderEvent({ |
| 1118 | type: "provider_session.updated", |
| 1119 | driver: OPENCODE_PROVIDER, |
| 1120 | providerSession: sessionEntity, |
| 1121 | }); |
| 1122 | }); |
| 1123 | |
| 1124 | const updateProviderThread = ( |
| 1125 | state: OpenCodeThreadState, |
| 1126 | patch: Partial<OrchestrationV2ProviderThread>, |
| 1127 | ) => |
| 1128 | Effect.gen(function* () { |
| 1129 | const updatedAt = yield* DateTime.now; |
| 1130 | state.providerThread = { ...state.providerThread, ...patch, updatedAt }; |
| 1131 | yield* emitProviderEvent({ |
| 1132 | type: "provider_thread.updated", |
| 1133 | driver: OPENCODE_PROVIDER, |
| 1134 | providerThread: state.providerThread, |
| 1135 | }); |
| 1136 | }); |
| 1137 | |
| 1138 | const itemOrdinal = (turn: ActiveOpenCodeTurn, nativeItemId: string): number => { |
| 1139 | const existing = turn.itemOrdinals.get(nativeItemId); |
| 1140 | if (existing !== undefined) return existing; |
| 1141 | const ordinal = turn.nextItemOrdinal++; |
| 1142 | turn.itemOrdinals.set(nativeItemId, ordinal); |
| 1143 | return ordinal; |
| 1144 | }; |
| 1145 | |
| 1146 | const emitProviderTurn = ( |
| 1147 | state: OpenCodeThreadState, |
| 1148 | turn: ActiveOpenCodeTurn, |
| 1149 | status: OrchestrationV2ProviderTurn["status"], |
| 1150 | completedAt: DateTime.Utc | null, |
| 1151 | ) => { |
| 1152 | const providerTurn: OrchestrationV2ProviderTurn = { |
| 1153 | ...turn.providerTurn, |
| 1154 | nativeTurnRef: |
| 1155 | turn.nativeUserMessageId === null |
| 1156 | ? turn.providerTurn.nativeTurnRef |
| 1157 | : providerRef(turn.nativeUserMessageId, "weak"), |
| 1158 | status, |
| 1159 | completedAt, |
| 1160 | ...(completedAt === null |
| 1161 | ? {} |
| 1162 | : { |
| 1163 | turnTokenUsage: |
| 1164 | turn.usage.partIds.size === 0 |
| 1165 | ? { |
| 1166 | usageScope: "main_agent" as const, |
| 1167 | usageStatus: "unavailable" as const, |
| 1168 | hasSubagents: turn.usage.hasSubagents, |
| 1169 | } |
| 1170 | : { |
| 1171 | usageScope: "main_agent" as const, |
| 1172 | usageStatus: |
| 1173 | status === "completed" && |
| 1174 | turn.usage.complete && |
| 1175 | turn.usage.unresolvedStepsByMessageId.size === 0 |
| 1176 | ? ("complete" as const) |
| 1177 | : ("partial" as const), |
| 1178 | inputTokens: turn.usage.inputTokens, |
| 1179 | cachedInputTokens: turn.usage.cachedInputTokens, |
| 1180 | cacheCreationTokens: turn.usage.cacheCreationTokens, |
| 1181 | outputTokens: turn.usage.outputTokens, |
| 1182 | reasoningTokens: turn.usage.reasoningTokens, |
| 1183 | hasSubagents: turn.usage.hasSubagents, |
| 1184 | }, |
| 1185 | }), |
| 1186 | }; |
| 1187 | Object.assign(turn.providerTurn, providerTurn); |
| 1188 | state.providerTurns.set(String(providerTurn.id), providerTurn); |
| 1189 | return emitProviderEvent({ |
| 1190 | type: "provider_turn.updated", |
| 1191 | driver: OPENCODE_PROVIDER, |
| 1192 | threadId: turn.threadId, |
| 1193 | providerTurn, |
| 1194 | }); |
| 1195 | }; |
| 1196 | |
| 1197 | const emitTextPart = Effect.fnUntraced(function* ( |
| 1198 | state: OpenCodeThreadState, |
| 1199 | turn: ActiveOpenCodeTurn, |
| 1200 | part: Extract<OpenCodePart, { type: "text" | "reasoning" }>, |
| 1201 | forceCompleted = false, |
| 1202 | ) { |
| 1203 | if (part.type === "text" && (part.ignored === true || part.synthetic === true)) return; |
| 1204 | if (part.text.length === 0) return; |
| 1205 | const emittedAt = yield* DateTime.now; |
| 1206 | const isCompleted = forceCompleted || part.time?.end !== undefined; |
| 1207 | const startedAt = dateTimeFromEpoch(part.time?.start, emittedAt); |
| 1208 | const completedAt = isCompleted ? dateTimeFromEpoch(part.time?.end, emittedAt) : null; |
| 1209 | const nativeItemRef = providerRef(part.id); |
| 1210 | const nodeId = idAllocator.derive.nodeFromProviderItem({ |
| 1211 | driver: OPENCODE_PROVIDER, |
| 1212 | nativeItemId: part.id, |
| 1213 | }); |
| 1214 | const turnItemId = idAllocator.derive.turnItemFromProviderItem({ |
| 1215 | driver: OPENCODE_PROVIDER, |
| 1216 | nativeItemId: part.id, |
| 1217 | }); |
| 1218 | const ordinal = itemOrdinal(turn, part.id); |
| 1219 | yield* emitProviderEvent({ |
| 1220 | type: "node.updated", |
| 1221 | driver: OPENCODE_PROVIDER, |
| 1222 | node: { |
| 1223 | id: nodeId, |
| 1224 | threadId: turn.threadId, |
| 1225 | runId: turn.runId, |
| 1226 | parentNodeId: turn.rootNodeId, |
| 1227 | rootNodeId: turn.rootNodeId, |
| 1228 | kind: part.type === "text" ? "assistant_message" : "reasoning", |
| 1229 | status: isCompleted ? "completed" : "running", |
| 1230 | countsForRun: false, |
| 1231 | providerThreadId: state.providerThread.id, |
| 1232 | providerTurnId: turn.providerTurnId, |
| 1233 | nativeItemRef, |
| 1234 | runtimeRequestId: null, |
| 1235 | checkpointScopeId: null, |
| 1236 | startedAt, |
| 1237 | completedAt, |
| 1238 | }, |
| 1239 | }); |
| 1240 | if (part.type === "text") { |
| 1241 | const messageId = idAllocator.derive.messageFromProviderItem({ |
| 1242 | driver: OPENCODE_PROVIDER, |
| 1243 | nativeItemId: part.id, |
| 1244 | }); |
| 1245 | const message: OrchestrationV2ConversationMessage = { |
| 1246 | createdBy: "agent", |
| 1247 | creationSource: "provider", |
| 1248 | id: messageId, |
| 1249 | threadId: turn.threadId, |
| 1250 | runId: turn.runId, |
| 1251 | nodeId, |
| 1252 | role: "assistant", |
| 1253 | text: part.text, |
| 1254 | attachments: [], |
| 1255 | streaming: !isCompleted, |
| 1256 | createdAt: startedAt, |
| 1257 | updatedAt: emittedAt, |
| 1258 | }; |
| 1259 | yield* emitProviderEvent({ |
| 1260 | type: "message.updated", |
| 1261 | driver: OPENCODE_PROVIDER, |
| 1262 | message, |
| 1263 | }); |
| 1264 | yield* emitProviderEvent({ |
| 1265 | type: "turn_item.updated", |
| 1266 | driver: OPENCODE_PROVIDER, |
| 1267 | turnItem: { |
| 1268 | id: turnItemId, |
| 1269 | threadId: turn.threadId, |
| 1270 | runId: turn.runId, |
| 1271 | nodeId, |
| 1272 | providerThreadId: state.providerThread.id, |
| 1273 | providerTurnId: turn.providerTurnId, |
| 1274 | nativeItemRef, |
| 1275 | parentItemId: null, |
| 1276 | ordinal, |
| 1277 | status: isCompleted ? "completed" : "running", |
| 1278 | title: null, |
| 1279 | startedAt, |
| 1280 | completedAt, |
| 1281 | updatedAt: emittedAt, |
| 1282 | type: "assistant_message", |
| 1283 | messageId, |
| 1284 | text: part.text, |
| 1285 | streaming: !isCompleted, |
| 1286 | }, |
| 1287 | }); |
| 1288 | return; |
| 1289 | } |
| 1290 | yield* emitProviderEvent({ |
| 1291 | type: "turn_item.updated", |
| 1292 | driver: OPENCODE_PROVIDER, |
| 1293 | turnItem: { |
| 1294 | id: turnItemId, |
| 1295 | threadId: turn.threadId, |
| 1296 | runId: turn.runId, |
| 1297 | nodeId, |
| 1298 | providerThreadId: state.providerThread.id, |
| 1299 | providerTurnId: turn.providerTurnId, |
| 1300 | nativeItemRef, |
| 1301 | parentItemId: null, |
| 1302 | ordinal, |
| 1303 | status: isCompleted ? "completed" : "running", |
| 1304 | title: null, |
| 1305 | startedAt, |
| 1306 | completedAt, |
| 1307 | updatedAt: emittedAt, |
| 1308 | type: "reasoning", |
| 1309 | text: part.text, |
| 1310 | streaming: !isCompleted, |
| 1311 | }, |
| 1312 | }); |
| 1313 | }); |
| 1314 | |
| 1315 | const emitSubagent = Effect.fnUntraced(function* ( |
| 1316 | state: OpenCodeThreadState, |
| 1317 | turn: ActiveOpenCodeTurn, |
| 1318 | part: ToolPart, |
| 1319 | ) { |
| 1320 | const now = yield* DateTime.now; |
| 1321 | const nativeItemRef = providerRef(part.id); |
| 1322 | const nodeId = idAllocator.derive.nodeFromProviderItem({ |
| 1323 | driver: OPENCODE_PROVIDER, |
| 1324 | nativeItemId: part.id, |
| 1325 | }); |
| 1326 | const turnItemId = idAllocator.derive.turnItemFromProviderItem({ |
| 1327 | driver: OPENCODE_PROVIDER, |
| 1328 | nativeItemId: part.id, |
| 1329 | }); |
| 1330 | turn.usage.hasSubagents = true; |
| 1331 | const input = toolInput(part); |
| 1332 | const prompt = recordString(input, "prompt") ?? ""; |
| 1333 | const title = toolTitle(part) ?? recordString(input, "description") ?? null; |
| 1334 | let context = subagentsByNativeItemId.get(part.id); |
| 1335 | if (context === undefined) { |
| 1336 | context = { |
| 1337 | nativeItemId: part.id, |
| 1338 | nodeId, |
| 1339 | parentState: state, |
| 1340 | parentTurn: turn, |
| 1341 | prompt, |
| 1342 | title, |
| 1343 | startedAt: toolStartedAt(part, now), |
| 1344 | childSessionId: null, |
| 1345 | childThreadId: null, |
| 1346 | childProviderThreadId: null, |
| 1347 | model: null, |
| 1348 | result: null, |
| 1349 | }; |
| 1350 | subagentsByNativeItemId.set(part.id, context); |
| 1351 | } |
| 1352 | context.model = toolModel(part) ?? context.model; |
| 1353 | const childSessionId = taskSessionId(part); |
| 1354 | if (childSessionId !== null && context.childSessionId === null) { |
| 1355 | context.childSessionId = childSessionId; |
| 1356 | context.childThreadId = idAllocator.derive.threadFromProviderThread({ |
| 1357 | driver: OPENCODE_PROVIDER, |
| 1358 | nativeThreadId: childSessionId, |
| 1359 | }); |
| 1360 | context.childProviderThreadId = idAllocator.derive.providerThread({ |
| 1361 | driver: OPENCODE_PROVIDER, |
| 1362 | nativeThreadId: childSessionId, |
| 1363 | }); |
| 1364 | subagentsByChildSessionId.set(childSessionId, context); |
| 1365 | relatedSessionOwners.set(childSessionId, state); |
| 1366 | const childModelSelection: ModelSelection = { |
| 1367 | instanceId: options.instanceId, |
| 1368 | model: context.model ?? turn.modelSelection.model, |
| 1369 | }; |
| 1370 | const childThread = makeSubagentChildThread({ |
| 1371 | parentThread: turn.appThread, |
| 1372 | childThreadId: context.childThreadId, |
| 1373 | parentNodeId: nodeId, |
| 1374 | activeProviderThreadId: context.childProviderThreadId, |
| 1375 | providerInstanceId: options.instanceId, |
| 1376 | modelSelection: childModelSelection, |
| 1377 | title: subagentThreadTitle({ |
| 1378 | parentTitle: turn.appThread.title, |
| 1379 | title, |
| 1380 | prompt, |
| 1381 | ordinal: itemOrdinal(turn, part.id), |
| 1382 | }), |
| 1383 | now, |
| 1384 | createdBy: "agent", |
| 1385 | creationSource: "provider", |
| 1386 | }); |
| 1387 | const childProviderThread: OrchestrationV2ProviderThread = { |
| 1388 | id: context.childProviderThreadId, |
| 1389 | driver: OPENCODE_PROVIDER, |
| 1390 | providerInstanceId: options.instanceId, |
| 1391 | providerSessionId: inputProviderSessionId, |
| 1392 | appThreadId: context.childThreadId, |
| 1393 | ownerNodeId: nodeId, |
| 1394 | nativeThreadRef: providerRef(childSessionId), |
| 1395 | nativeConversationHeadRef: null, |
| 1396 | status: "active", |
| 1397 | firstRunOrdinal: null, |
| 1398 | lastRunOrdinal: null, |
| 1399 | handoffIds: [], |
| 1400 | forkedFrom: null, |
| 1401 | createdAt: now, |
| 1402 | updatedAt: now, |
| 1403 | }; |
| 1404 | const childSessionResponse = yield* sdkCall( |
| 1405 | "session.get", |
| 1406 | { sessionID: childSessionId }, |
| 1407 | () => client.session.get({ sessionID: childSessionId }), |
| 1408 | ); |
| 1409 | const nativeChildSession = unwrapData("session.get", childSessionResponse); |
| 1410 | const childPermission = openCodeChildPermissionRules( |
| 1411 | turn.runtimePolicy, |
| 1412 | nativeChildSession.permission ?? [], |
| 1413 | ); |
| 1414 | yield* sdkCall( |
| 1415 | "session.update", |
| 1416 | { sessionID: childSessionId, permission: childPermission }, |
| 1417 | () => |
| 1418 | client.session.update({ |
| 1419 | sessionID: childSessionId, |
| 1420 | permission: childPermission, |
| 1421 | }), |
| 1422 | ); |
| 1423 | threads.set(childSessionId, { |
| 1424 | nativeSessionId: childSessionId, |
| 1425 | providerThread: childProviderThread, |
| 1426 | appThread: childThread, |
| 1427 | activeTurn: null, |
| 1428 | providerTurns: new Map(), |
| 1429 | runtimeRequests: new Map(), |
| 1430 | messageRoles: new Map(), |
| 1431 | userMessageIds: [], |
| 1432 | parentSubagent: context, |
| 1433 | nextChildTurnOrdinal: 1, |
| 1434 | nextAdmissionGeneration: 1, |
| 1435 | }); |
| 1436 | yield* emitProviderEvent({ |
| 1437 | type: "app_thread.created", |
| 1438 | driver: OPENCODE_PROVIDER, |
| 1439 | appThread: childThread, |
| 1440 | }); |
| 1441 | yield* emitProviderEvent({ |
| 1442 | type: "provider_thread.updated", |
| 1443 | driver: OPENCODE_PROVIDER, |
| 1444 | providerThread: childProviderThread, |
| 1445 | }); |
| 1446 | } |
| 1447 | const output = toolOutput(part); |
| 1448 | if (part.state.status === "completed" && output !== undefined) context.result = output; |
| 1449 | const status = toolStatus(part); |
| 1450 | const completedAt = toolCompletedAt(part, now); |
| 1451 | const subagentStatus: OrchestrationV2Subagent["status"] = |
| 1452 | status.item === "failed" |
| 1453 | ? "failed" |
| 1454 | : status.item === "completed" |
| 1455 | ? "completed" |
| 1456 | : status.item === "pending" |
| 1457 | ? "pending" |
| 1458 | : "running"; |
| 1459 | const subagent: OrchestrationV2Subagent = { |
| 1460 | id: nodeId, |
| 1461 | threadId: turn.threadId, |
| 1462 | runId: turn.runId, |
| 1463 | parentNodeId: turn.rootNodeId, |
| 1464 | origin: "provider_native", |
| 1465 | createdBy: "agent", |
| 1466 | driver: OPENCODE_PROVIDER, |
| 1467 | providerInstanceId: options.instanceId, |
| 1468 | providerThreadId: context.childProviderThreadId, |
| 1469 | childThreadId: context.childThreadId, |
| 1470 | nativeTaskRef: nativeItemRef, |
| 1471 | prompt, |
| 1472 | title, |
| 1473 | model: context.model, |
| 1474 | status: subagentStatus, |
| 1475 | result: context.result, |
| 1476 | startedAt: context.startedAt, |
| 1477 | completedAt, |
| 1478 | updatedAt: now, |
| 1479 | }; |
| 1480 | yield* emitProviderEvent({ |
| 1481 | type: "node.updated", |
| 1482 | driver: OPENCODE_PROVIDER, |
| 1483 | node: { |
| 1484 | id: nodeId, |
| 1485 | threadId: turn.threadId, |
| 1486 | runId: turn.runId, |
| 1487 | parentNodeId: turn.rootNodeId, |
| 1488 | rootNodeId: turn.rootNodeId, |
| 1489 | kind: "subagent", |
| 1490 | status: status.node, |
| 1491 | countsForRun: false, |
| 1492 | providerThreadId: context.childProviderThreadId ?? state.providerThread.id, |
| 1493 | providerTurnId: turn.providerTurnId, |
| 1494 | nativeItemRef, |
| 1495 | runtimeRequestId: null, |
| 1496 | checkpointScopeId: null, |
| 1497 | startedAt: context.startedAt, |
| 1498 | completedAt, |
| 1499 | }, |
| 1500 | }); |
| 1501 | yield* emitProviderEvent({ |
| 1502 | type: "subagent.updated", |
| 1503 | driver: OPENCODE_PROVIDER, |
| 1504 | subagent, |
| 1505 | }); |
| 1506 | yield* emitProviderEvent({ |
| 1507 | type: "turn_item.updated", |
| 1508 | driver: OPENCODE_PROVIDER, |
| 1509 | turnItem: { |
| 1510 | id: turnItemId, |
| 1511 | threadId: turn.threadId, |
| 1512 | runId: turn.runId, |
| 1513 | nodeId, |
| 1514 | providerThreadId: state.providerThread.id, |
| 1515 | providerTurnId: turn.providerTurnId, |
| 1516 | nativeItemRef, |
| 1517 | parentItemId: null, |
| 1518 | ordinal: itemOrdinal(turn, part.id), |
| 1519 | status: status.item, |
| 1520 | title, |
| 1521 | startedAt: context.startedAt, |
| 1522 | completedAt, |
| 1523 | updatedAt: now, |
| 1524 | type: "subagent", |
| 1525 | subagentId: nodeId, |
| 1526 | origin: "provider_native", |
| 1527 | driver: OPENCODE_PROVIDER, |
| 1528 | providerInstanceId: options.instanceId, |
| 1529 | childThreadId: context.childThreadId, |
| 1530 | prompt, |
| 1531 | result: context.result, |
| 1532 | }, |
| 1533 | }); |
| 1534 | }); |
| 1535 | |
| 1536 | const emitToolPart = Effect.fnUntraced(function* ( |
| 1537 | state: OpenCodeThreadState, |
| 1538 | turn: ActiveOpenCodeTurn, |
| 1539 | part: ToolPart, |
| 1540 | ) { |
| 1541 | const normalizedTool = part.tool.toLowerCase(); |
| 1542 | if (normalizedTool === "task") { |
| 1543 | yield* emitSubagent(state, turn, part); |
| 1544 | return; |
| 1545 | } |
| 1546 | // question.asked carries the respondable semantic item. Projecting |
| 1547 | // the implementation tool as well would duplicate it in the UI. |
| 1548 | if (normalizedTool === "question") return; |
| 1549 | const now = yield* DateTime.now; |
| 1550 | const status = toolStatus(part); |
| 1551 | const startedAt = toolStartedAt(part, now); |
| 1552 | const completedAt = toolCompletedAt(part, now); |
| 1553 | const nativeItemRef = providerRef(part.id); |
| 1554 | const nodeId = idAllocator.derive.nodeFromProviderItem({ |
| 1555 | driver: OPENCODE_PROVIDER, |
| 1556 | nativeItemId: part.id, |
| 1557 | }); |
| 1558 | const turnItemId = idAllocator.derive.turnItemFromProviderItem({ |
| 1559 | driver: OPENCODE_PROVIDER, |
| 1560 | nativeItemId: part.id, |
| 1561 | }); |
| 1562 | const base = { |
| 1563 | id: turnItemId, |
| 1564 | threadId: turn.threadId, |
| 1565 | runId: turn.runId, |
| 1566 | nodeId, |
| 1567 | providerThreadId: state.providerThread.id, |
| 1568 | providerTurnId: turn.providerTurnId, |
| 1569 | nativeItemRef, |
| 1570 | parentItemId: null, |
| 1571 | ordinal: itemOrdinal(turn, part.id), |
| 1572 | status: status.item, |
| 1573 | title: toolTitle(part), |
| 1574 | startedAt, |
| 1575 | completedAt, |
| 1576 | updatedAt: now, |
| 1577 | } satisfies Pick< |
| 1578 | OrchestrationV2TurnItem, |
| 1579 | | "id" |
| 1580 | | "threadId" |
| 1581 | | "runId" |
| 1582 | | "nodeId" |
| 1583 | | "providerThreadId" |
| 1584 | | "providerTurnId" |
| 1585 | | "nativeItemRef" |
| 1586 | | "parentItemId" |
| 1587 | | "ordinal" |
| 1588 | | "status" |
| 1589 | | "title" |
| 1590 | | "startedAt" |
| 1591 | | "completedAt" |
| 1592 | | "updatedAt" |
| 1593 | >; |
| 1594 | const input = toolInput(part); |
| 1595 | const output = toolOutput(part); |
| 1596 | const isNativeTool = part.tool === "code_search" || part.tool === "apply_patch"; |
| 1597 | if (!isNativeTool && part.tool.includes("_") && turn.mcpServerNames === undefined) { |
| 1598 | const serverNames = yield* OpenCodeRuntime.runOpenCodeSdk("mcp.status", (signal) => |
| 1599 | client.mcp.status(undefined, { signal, throwOnError: true }), |
| 1600 | ).pipe( |
| 1601 | Effect.timeout("1 second"), |
| 1602 | Effect.map((response) => Object.keys(response.data ?? {})), |
| 1603 | Effect.catch(() => Effect.succeed(undefined)), |
| 1604 | ); |
| 1605 | if (serverNames !== undefined) turn.mcpServerNames = serverNames; |
| 1606 | } |
| 1607 | const matchingServers = turn.mcpServerNames?.filter( |
| 1608 | (name) => |
| 1609 | !isNativeTool && part.tool.startsWith(`${name.replace(/[^a-zA-Z0-9_-]/g, "_")}_`), |
| 1610 | ); |
| 1611 | const serverName = matchingServers?.length === 1 ? matchingServers[0] : undefined; |
| 1612 | const presentation = |
| 1613 | serverName === undefined |
| 1614 | ? {} |
| 1615 | : mcpToolPresentation({ |
| 1616 | serverName, |
| 1617 | toolName: part.tool.slice(serverName.replace(/[^a-zA-Z0-9_-]/g, "_").length + 1), |
| 1618 | title: toolTitle(part) === part.tool ? undefined : toolTitle(part), |
| 1619 | }); |
| 1620 | const turnItem: OrchestrationV2TurnItem = matchingServers?.length |
| 1621 | ? { |
| 1622 | ...base, |
| 1623 | type: "dynamic_tool", |
| 1624 | ...presentation, |
| 1625 | toolName: part.tool, |
| 1626 | input, |
| 1627 | ...(output === undefined ? {} : { output }), |
| 1628 | } |
| 1629 | : openCodeToolTurnItem(base, { |
| 1630 | name: part.tool, |
| 1631 | input, |
| 1632 | output, |
| 1633 | completedMetadata: |
| 1634 | part.state.status === "completed" ? part.state.metadata : undefined, |
| 1635 | }); |
| 1636 | yield* emitProviderEvent({ |
| 1637 | type: "node.updated", |
| 1638 | driver: OPENCODE_PROVIDER, |
| 1639 | node: { |
| 1640 | id: nodeId, |
| 1641 | threadId: turn.threadId, |
| 1642 | runId: turn.runId, |
| 1643 | parentNodeId: turn.rootNodeId, |
| 1644 | rootNodeId: turn.rootNodeId, |
| 1645 | kind: "tool_call", |
| 1646 | status: status.node, |
| 1647 | countsForRun: false, |
| 1648 | providerThreadId: state.providerThread.id, |
| 1649 | providerTurnId: turn.providerTurnId, |
| 1650 | nativeItemRef, |
| 1651 | runtimeRequestId: null, |
| 1652 | checkpointScopeId: null, |
| 1653 | startedAt, |
| 1654 | completedAt, |
| 1655 | }, |
| 1656 | }); |
| 1657 | yield* emitProviderEvent({ |
| 1658 | type: "turn_item.updated", |
| 1659 | driver: OPENCODE_PROVIDER, |
| 1660 | turnItem, |
| 1661 | }); |
| 1662 | }); |
| 1663 | |
| 1664 | const emitTodo = Effect.fnUntraced(function* ( |
| 1665 | state: OpenCodeThreadState, |
| 1666 | turn: ActiveOpenCodeTurn, |
| 1667 | todos: ReadonlyArray<OpenCodeTodo>, |
| 1668 | ) { |
| 1669 | const now = yield* DateTime.now; |
| 1670 | if (turn.planId === null) { |
| 1671 | turn.planId = yield* idAllocator.allocate.plan({ |
| 1672 | threadId: turn.threadId, |
| 1673 | ...(turn.runId === null ? {} : { runId: turn.runId }), |
| 1674 | driver: OPENCODE_PROVIDER, |
| 1675 | }); |
| 1676 | } |
| 1677 | const planId = turn.planId; |
| 1678 | const nativeItemId = `${state.nativeSessionId}:todo:${turn.providerTurnId}`; |
| 1679 | const nodeId = idAllocator.derive.nodeFromProviderItem({ |
| 1680 | driver: OPENCODE_PROVIDER, |
| 1681 | nativeItemId, |
| 1682 | }); |
| 1683 | const turnItemId = idAllocator.derive.turnItemFromProviderItem({ |
| 1684 | driver: OPENCODE_PROVIDER, |
| 1685 | nativeItemId, |
| 1686 | }); |
| 1687 | const steps: Array<OrchestrationV2PlanStep> = todos.map((todo, index) => ({ |
| 1688 | id: `${nativeItemId}:${index + 1}`, |
| 1689 | text: todo.content.trim() || `Todo ${index + 1}`, |
| 1690 | status: |
| 1691 | todo.status === "completed" |
| 1692 | ? "completed" |
| 1693 | : todo.status === "in_progress" |
| 1694 | ? "running" |
| 1695 | : "pending", |
| 1696 | })); |
| 1697 | const completed = steps.length > 0 && steps.every((step) => step.status === "completed"); |
| 1698 | yield* emitProviderEvent({ |
| 1699 | type: "node.updated", |
| 1700 | driver: OPENCODE_PROVIDER, |
| 1701 | node: { |
| 1702 | id: nodeId, |
| 1703 | threadId: turn.threadId, |
| 1704 | runId: turn.runId, |
| 1705 | parentNodeId: turn.rootNodeId, |
| 1706 | rootNodeId: turn.rootNodeId, |
| 1707 | kind: "todo_list", |
| 1708 | status: completed ? "completed" : "running", |
| 1709 | countsForRun: false, |
| 1710 | providerThreadId: state.providerThread.id, |
| 1711 | providerTurnId: turn.providerTurnId, |
| 1712 | nativeItemRef: providerRef(nativeItemId, "weak"), |
| 1713 | runtimeRequestId: null, |
| 1714 | checkpointScopeId: null, |
| 1715 | startedAt: turn.startedAt, |
| 1716 | completedAt: completed ? now : null, |
| 1717 | }, |
| 1718 | }); |
| 1719 | yield* emitProviderEvent({ |
| 1720 | type: "plan.updated", |
| 1721 | driver: OPENCODE_PROVIDER, |
| 1722 | plan: { |
| 1723 | id: planId, |
| 1724 | threadId: turn.threadId, |
| 1725 | runId: turn.runId, |
| 1726 | nodeId, |
| 1727 | status: completed ? "completed" : "active", |
| 1728 | kind: "todo_list", |
| 1729 | steps, |
| 1730 | }, |
| 1731 | }); |
| 1732 | yield* emitProviderEvent({ |
| 1733 | type: "turn_item.updated", |
| 1734 | driver: OPENCODE_PROVIDER, |
| 1735 | turnItem: { |
| 1736 | id: turnItemId, |
| 1737 | threadId: turn.threadId, |
| 1738 | runId: turn.runId, |
| 1739 | nodeId, |
| 1740 | providerThreadId: state.providerThread.id, |
| 1741 | providerTurnId: turn.providerTurnId, |
| 1742 | nativeItemRef: providerRef(nativeItemId, "weak"), |
| 1743 | parentItemId: null, |
| 1744 | ordinal: itemOrdinal(turn, nativeItemId), |
| 1745 | status: completed ? "completed" : "running", |
| 1746 | title: "Todo list", |
| 1747 | startedAt: turn.startedAt, |
| 1748 | completedAt: completed ? now : null, |
| 1749 | updatedAt: now, |
| 1750 | type: "todo_list", |
| 1751 | planId, |
| 1752 | steps, |
| 1753 | }, |
| 1754 | }); |
| 1755 | }); |
| 1756 | |
| 1757 | const requestQuestions = (request: QuestionRequest) => |
| 1758 | request.questions.map((question, index) => ({ |
| 1759 | id: OpenCodeRuntime.openCodeQuestionId(index, question), |
| 1760 | header: question.header.trim() || `Question ${index + 1}`, |
| 1761 | question: question.question.trim() || question.header.trim() || `Question ${index + 1}`, |
| 1762 | options: question.options.map((option) => ({ |
| 1763 | label: option.label.trim() || "Option", |
| 1764 | description: option.description.trim() || option.label.trim() || "Option", |
| 1765 | })), |
| 1766 | })); |
| 1767 | |
| 1768 | const runtimeRequestTurnItem = ( |
| 1769 | pending: PendingOpenCodeRequest, |
| 1770 | status: OrchestrationV2TurnItem["status"], |
| 1771 | completedAt: DateTime.Utc | null, |
| 1772 | updatedAt: DateTime.Utc, |
| 1773 | ): OrchestrationV2TurnItem => { |
| 1774 | const base = { |
| 1775 | id: pending.turnItemId, |
| 1776 | threadId: pending.turn.threadId, |
| 1777 | runId: pending.turn.runId, |
| 1778 | nodeId: pending.nodeId, |
| 1779 | providerThreadId: pending.state.providerThread.id, |
| 1780 | providerTurnId: pending.turn.providerTurnId, |
| 1781 | nativeItemRef: providerRef(pending.nativeRequestId), |
| 1782 | parentItemId: null, |
| 1783 | ordinal: itemOrdinal(pending.turn, pending.nativeRequestId), |
| 1784 | status, |
| 1785 | startedAt: pending.createdAt, |
| 1786 | completedAt, |
| 1787 | updatedAt, |
| 1788 | }; |
| 1789 | if (pending.question !== undefined) { |
| 1790 | return { |
| 1791 | ...base, |
| 1792 | title: "User input", |
| 1793 | type: "user_input_request", |
| 1794 | requestId: pending.requestId, |
| 1795 | questions: requestQuestions(pending.question), |
| 1796 | }; |
| 1797 | } |
| 1798 | const permission = pending.permission; |
| 1799 | if (permission === undefined) { |
| 1800 | throw protocolError(`OpenCode request ${pending.requestId} has no native payload`); |
| 1801 | } |
| 1802 | return { |
| 1803 | ...base, |
| 1804 | title: permission.permission, |
| 1805 | type: "approval_request", |
| 1806 | requestId: pending.requestId, |
| 1807 | requestKind: pending.requestKind === "user_input" ? "command" : pending.requestKind, |
| 1808 | prompt: |
| 1809 | permission.patterns.length === 0 |
| 1810 | ? permission.permission |
| 1811 | : permission.patterns.join("\n"), |
| 1812 | }; |
| 1813 | }; |
| 1814 | |
| 1815 | const emitRuntimeRequest = Effect.fnUntraced(function* ( |
| 1816 | owner: OpenCodeRequestOwner, |
| 1817 | nativeRequestId: string, |
| 1818 | request: |
| 1819 | | { readonly type: "permission"; readonly value: PermissionRequest } |
| 1820 | | { readonly type: "question"; readonly value: QuestionRequest }, |
| 1821 | ) { |
| 1822 | if (pendingRequestsByNativeId.has(nativeRequestId)) return; |
| 1823 | const { state, turn, subagent } = owner; |
| 1824 | const now = yield* DateTime.now; |
| 1825 | const requestId = yield* idAllocator.allocate.runtimeRequest({ |
| 1826 | driver: OPENCODE_PROVIDER, |
| 1827 | providerTurnId: turn.providerTurnId, |
| 1828 | nativeRequestId, |
| 1829 | }); |
| 1830 | const nodeId = idAllocator.derive.approvalNode({ requestId }); |
| 1831 | const turnItemId = idAllocator.derive.approvalTurnItem({ requestId }); |
| 1832 | // The tool call belongs to the asking session's turn, not the owner's. |
| 1833 | const permissionToolName = |
| 1834 | request.type === "permission" && request.value.tool !== undefined |
| 1835 | ? threads |
| 1836 | .get(request.value.sessionID) |
| 1837 | ?.activeTurn?.toolNamesByCallId.get(request.value.tool.callID) |
| 1838 | : undefined; |
| 1839 | const permissionRequestKind = |
| 1840 | request.type === "permission" |
| 1841 | ? openCodePermissionRequestKind(request.value.permission, permissionToolName) |
| 1842 | : undefined; |
| 1843 | const requestKind: OrchestrationV2RuntimeRequest["kind"] = |
| 1844 | permissionRequestKind ?? "user_input"; |
| 1845 | const pending: PendingOpenCodeRequest = { |
| 1846 | requestId, |
| 1847 | nativeRequestId, |
| 1848 | nativeSessionId: request.value.sessionID, |
| 1849 | turn, |
| 1850 | state, |
| 1851 | nodeId, |
| 1852 | parentNodeId: subagent?.nodeId ?? turn.rootNodeId, |
| 1853 | turnItemId, |
| 1854 | requestKind, |
| 1855 | createdAt: now, |
| 1856 | ...(request.type === "permission" |
| 1857 | ? { permission: request.value } |
| 1858 | : { question: request.value }), |
| 1859 | }; |
| 1860 | pendingRequests.set(String(requestId), pending); |
| 1861 | pendingRequestsByNativeId.set(nativeRequestId, pending); |
| 1862 | const runtimeRequest: OrchestrationV2RuntimeRequest = { |
| 1863 | id: requestId, |
| 1864 | nodeId, |
| 1865 | providerTurnId: turn.providerTurnId, |
| 1866 | nativeRequestRef: providerRef(nativeRequestId), |
| 1867 | kind: requestKind, |
| 1868 | status: "pending", |
| 1869 | responseCapability: { |
| 1870 | type: "live", |
| 1871 | providerSessionId: inputProviderSessionId, |
| 1872 | }, |
| 1873 | createdAt: now, |
| 1874 | resolvedAt: null, |
| 1875 | }; |
| 1876 | state.runtimeRequests.set(String(requestId), runtimeRequest); |
| 1877 | yield* emitProviderEvent({ |
| 1878 | type: "node.updated", |
| 1879 | driver: OPENCODE_PROVIDER, |
| 1880 | node: { |
| 1881 | id: nodeId, |
| 1882 | threadId: turn.threadId, |
| 1883 | runId: turn.runId, |
| 1884 | parentNodeId: pending.parentNodeId, |
| 1885 | rootNodeId: turn.rootNodeId, |
| 1886 | kind: request.type === "question" ? "user_input_request" : "approval_request", |
| 1887 | status: "waiting", |
| 1888 | countsForRun: false, |
| 1889 | providerThreadId: state.providerThread.id, |
| 1890 | providerTurnId: turn.providerTurnId, |
| 1891 | nativeItemRef: providerRef(nativeRequestId), |
| 1892 | runtimeRequestId: requestId, |
| 1893 | checkpointScopeId: null, |
| 1894 | startedAt: now, |
| 1895 | completedAt: null, |
| 1896 | }, |
| 1897 | }); |
| 1898 | yield* emitProviderEvent({ |
| 1899 | type: "runtime_request.updated", |
| 1900 | driver: OPENCODE_PROVIDER, |
| 1901 | threadId: turn.threadId, |
| 1902 | runtimeRequest, |
| 1903 | }); |
| 1904 | yield* emitProviderEvent({ |
| 1905 | type: "turn_item.updated", |
| 1906 | driver: OPENCODE_PROVIDER, |
| 1907 | turnItem: runtimeRequestTurnItem(pending, "waiting", null, now), |
| 1908 | }); |
| 1909 | yield* updateProviderSession("waiting", null); |
| 1910 | }); |
| 1911 | |
| 1912 | const resolveRuntimeRequest = Effect.fnUntraced(function* ( |
| 1913 | nativeRequestId: string, |
| 1914 | status: "resolved" | "cancelled", |
| 1915 | ) { |
| 1916 | const pending = pendingRequestsByNativeId.get(nativeRequestId); |
| 1917 | if (pending === undefined) return; |
| 1918 | const now = yield* DateTime.now; |
| 1919 | const current = pending.state.runtimeRequests.get(String(pending.requestId)); |
| 1920 | if (current !== undefined) { |
| 1921 | const resolved: OrchestrationV2RuntimeRequest = { |
| 1922 | ...current, |
| 1923 | status, |
| 1924 | resolvedAt: now, |
| 1925 | }; |
| 1926 | pending.state.runtimeRequests.set(String(pending.requestId), resolved); |
| 1927 | yield* emitProviderEvent({ |
| 1928 | type: "runtime_request.updated", |
| 1929 | driver: OPENCODE_PROVIDER, |
| 1930 | threadId: pending.turn.threadId, |
| 1931 | runtimeRequest: resolved, |
| 1932 | }); |
| 1933 | } |
| 1934 | yield* emitProviderEvent({ |
| 1935 | type: "node.updated", |
| 1936 | driver: OPENCODE_PROVIDER, |
| 1937 | node: { |
| 1938 | id: pending.nodeId, |
| 1939 | threadId: pending.turn.threadId, |
| 1940 | runId: pending.turn.runId, |
| 1941 | parentNodeId: pending.parentNodeId, |
| 1942 | rootNodeId: pending.turn.rootNodeId, |
| 1943 | kind: pending.question === undefined ? "approval_request" : "user_input_request", |
| 1944 | status: status === "resolved" ? "completed" : "cancelled", |
| 1945 | countsForRun: false, |
| 1946 | providerThreadId: pending.state.providerThread.id, |
| 1947 | providerTurnId: pending.turn.providerTurnId, |
| 1948 | nativeItemRef: providerRef(nativeRequestId), |
| 1949 | runtimeRequestId: pending.requestId, |
| 1950 | checkpointScopeId: null, |
| 1951 | startedAt: pending.createdAt, |
| 1952 | completedAt: now, |
| 1953 | }, |
| 1954 | }); |
| 1955 | yield* emitProviderEvent({ |
| 1956 | type: "turn_item.updated", |
| 1957 | driver: OPENCODE_PROVIDER, |
| 1958 | turnItem: runtimeRequestTurnItem( |
| 1959 | pending, |
| 1960 | status === "resolved" ? "completed" : "cancelled", |
| 1961 | now, |
| 1962 | now, |
| 1963 | ), |
| 1964 | }); |
| 1965 | pendingRequests.delete(String(pending.requestId)); |
| 1966 | pendingRequestsByNativeId.delete(nativeRequestId); |
| 1967 | const hasOtherPending = Array.from(pendingRequests.values()).some( |
| 1968 | (candidate) => candidate.turn.isRoot, |
| 1969 | ); |
| 1970 | if (!hasOtherPending) yield* updateProviderSession("running", null); |
| 1971 | }); |
| 1972 | |
| 1973 | /** Resolve the thread state a session belongs to: its own, or for a |
| 1974 | * child not registered yet, the nearest known ancestor found by walking |
| 1975 | * the native parent chain. Registers every hop so later requests from |
| 1976 | * the same child resolve without another lookup. */ |
| 1977 | const resolveSessionOwner = Effect.fnUntraced(function* (sessionId: string) { |
| 1978 | const known = threads.get(sessionId) ?? relatedSessionOwners.get(sessionId); |
| 1979 | if (known !== undefined) return known; |
| 1980 | let cursor = sessionId; |
| 1981 | const hops: string[] = []; |
| 1982 | for (let depth = 0; depth < 5; depth += 1) { |
| 1983 | const response = yield* sdkCall("session.get", { sessionID: cursor }, () => |
| 1984 | client.session.get({ sessionID: cursor }), |
| 1985 | ).pipe(Effect.option); |
| 1986 | const info = Option.getOrUndefined(response)?.data; |
| 1987 | const parentId = info?.parentID; |
| 1988 | if (parentId === undefined) return undefined; |
| 1989 | hops.push(cursor); |
| 1990 | const owner = threads.get(parentId) ?? relatedSessionOwners.get(parentId); |
| 1991 | if (owner !== undefined) { |
| 1992 | for (const hop of hops) relatedSessionOwners.set(hop, owner); |
| 1993 | return owner; |
| 1994 | } |
| 1995 | cursor = parentId; |
| 1996 | } |
| 1997 | return undefined; |
| 1998 | }); |
| 1999 | |
| 2000 | /** Every request is asked on the top-level thread and its active turn, |
| 2001 | * under the subagent that leads to the asking session, because native |
| 2002 | * subagent threads are hidden from the sidebar. A child's request can |
| 2003 | * arrive before the task part or session.created event that reveals |
| 2004 | * its relation to a thread. The first resolution attempt runs inline |
| 2005 | * (the replayable path); if the relation or the owning turn is not |
| 2006 | * established yet, a short forked backoff keeps trying instead of |
| 2007 | * dropping the request. */ |
| 2008 | const routeRuntimeRequest = Effect.fnUntraced(function* ( |
| 2009 | nativeRequestId: string, |
| 2010 | sessionId: string, |
| 2011 | request: |
| 2012 | | { readonly type: "permission"; readonly value: PermissionRequest } |
| 2013 | | { readonly type: "question"; readonly value: QuestionRequest }, |
| 2014 | ) { |
| 2015 | if (pendingChildRequestRoutes.has(nativeRequestId)) return; |
| 2016 | const attempt = Effect.gen(function* () { |
| 2017 | if ( |
| 2018 | settledNativeRequestIds.has(nativeRequestId) || |
| 2019 | pendingRequestsByNativeId.has(nativeRequestId) |
| 2020 | ) { |
| 2021 | return true; |
| 2022 | } |
| 2023 | const state = yield* resolveSessionOwner(sessionId); |
| 2024 | const owner = state === undefined ? undefined : topLevelRequestOwner(state); |
| 2025 | if (owner === undefined) return false; |
| 2026 | yield* emitRuntimeRequest(owner, nativeRequestId, request); |
| 2027 | return true; |
| 2028 | }); |
| 2029 | if (yield* attempt) return; |
| 2030 | pendingChildRequestRoutes.add(nativeRequestId); |
| 2031 | yield* Effect.gen(function* () { |
| 2032 | for (let retry = 0; retry < 5; retry += 1) { |
| 2033 | yield* Effect.sleep(Duration.millis(Math.min(200 * 2 ** retry, 2_000))); |
| 2034 | if (yield* attempt) return; |
| 2035 | } |
| 2036 | }).pipe( |
| 2037 | Effect.ensuring(Effect.sync(() => pendingChildRequestRoutes.delete(nativeRequestId))), |
| 2038 | Effect.forkIn(scope), |
| 2039 | ); |
| 2040 | }); |
| 2041 | |
| 2042 | const finalizeTurn = Effect.fnUntraced(function* ( |
| 2043 | state: OpenCodeThreadState, |
| 2044 | turn: ActiveOpenCodeTurn, |
| 2045 | status: TerminalTurnStatus, |
| 2046 | terminal?: { |
| 2047 | readonly failure?: OrchestrationV2ProviderFailure; |
| 2048 | readonly threadDisposition?: "reusable" | "broken"; |
| 2049 | }, |
| 2050 | ) { |
| 2051 | if (turn.finalized) return; |
| 2052 | if (nativeStreamFailure !== null) { |
| 2053 | status = "failed"; |
| 2054 | terminal = { failure: nativeStreamFailure, threadDisposition: "broken" }; |
| 2055 | } |
| 2056 | turn.finalized = true; |
| 2057 | const completedAt = yield* DateTime.now; |
| 2058 | for (const part of turn.parts.values()) { |
| 2059 | if (part.type === "text" || part.type === "reasoning") { |
| 2060 | yield* emitTextPart(state, turn, part, true); |
| 2061 | } |
| 2062 | } |
| 2063 | for (const pending of Array.from(pendingRequests.values())) { |
| 2064 | if ( |
| 2065 | pending.turn.providerTurnId === turn.providerTurnId || |
| 2066 | pending.nativeSessionId === state.nativeSessionId |
| 2067 | ) { |
| 2068 | yield* resolveRuntimeRequest(pending.nativeRequestId, "cancelled"); |
| 2069 | } |
| 2070 | } |
| 2071 | yield* emitProviderTurn(state, turn, status, completedAt); |
| 2072 | const threadDisposition = terminal?.threadDisposition ?? "reusable"; |
| 2073 | yield* updateProviderThread(state, { |
| 2074 | status: turn.isRoot ? "active" : threadDisposition === "broken" ? "error" : "idle", |
| 2075 | nativeConversationHeadRef: |
| 2076 | turn.nativeUserMessageId === null |
| 2077 | ? state.providerThread.nativeConversationHeadRef |
| 2078 | : providerRef(turn.nativeUserMessageId, "weak"), |
| 2079 | }); |
| 2080 | state.activeTurn = null; |
| 2081 | if (!turn.isRoot) { |
| 2082 | yield* emitProviderEvent({ |
| 2083 | type: "node.updated", |
| 2084 | driver: OPENCODE_PROVIDER, |
| 2085 | node: { |
| 2086 | id: turn.rootNodeId, |
| 2087 | threadId: turn.threadId, |
| 2088 | runId: null, |
| 2089 | parentNodeId: null, |
| 2090 | rootNodeId: turn.rootNodeId, |
| 2091 | kind: "root_turn", |
| 2092 | status, |
| 2093 | countsForRun: false, |
| 2094 | providerThreadId: state.providerThread.id, |
| 2095 | providerTurnId: turn.providerTurnId, |
| 2096 | nativeItemRef: providerRef(state.nativeSessionId), |
| 2097 | runtimeRequestId: null, |
| 2098 | checkpointScopeId: null, |
| 2099 | startedAt: turn.startedAt, |
| 2100 | completedAt, |
| 2101 | }, |
| 2102 | }); |
| 2103 | return; |
| 2104 | } |
| 2105 | const anotherTurnIsActive = Array.from(threads.values()).some( |
| 2106 | (candidate) => candidate.activeTurn?.isRoot === true, |
| 2107 | ); |
| 2108 | yield* updateProviderSession( |
| 2109 | anotherTurnIsActive ? "running" : status === "failed" ? "error" : "ready", |
| 2110 | status === "failed" ? sessionEntity.lastError : null, |
| 2111 | ); |
| 2112 | yield* emitProviderEvent( |
| 2113 | status === "failed" |
| 2114 | ? { |
| 2115 | type: "turn.terminal", |
| 2116 | driver: OPENCODE_PROVIDER, |
| 2117 | providerThreadId: turn.providerThreadId, |
| 2118 | providerTurnId: turn.providerTurnId, |
| 2119 | runOrdinal: turn.runOrdinal, |
| 2120 | failureItemOrdinal: itemOrdinal(turn, `terminal-failure:${turn.providerTurnId}`), |
| 2121 | status, |
| 2122 | failure: |
| 2123 | terminal?.failure ?? |
| 2124 | makeProviderFailure({ |
| 2125 | message: sessionEntity.lastError ?? undefined, |
| 2126 | class: "provider_error", |
| 2127 | }), |
| 2128 | threadDisposition, |
| 2129 | } |
| 2130 | : { |
| 2131 | type: "turn.terminal", |
| 2132 | driver: OPENCODE_PROVIDER, |
| 2133 | providerThreadId: turn.providerThreadId, |
| 2134 | providerTurnId: turn.providerTurnId, |
| 2135 | runOrdinal: turn.runOrdinal, |
| 2136 | status, |
| 2137 | failure: null, |
| 2138 | threadDisposition, |
| 2139 | }, |
| 2140 | ); |
| 2141 | }); |
| 2142 | |
| 2143 | const promptAdmissionIsCurrent = ( |
| 2144 | state: OpenCodeThreadState, |
| 2145 | turn: ActiveOpenCodeTurn, |
| 2146 | generation: number, |
| 2147 | ) => |
| 2148 | state.activeTurn === turn && |
| 2149 | !turn.finalized && |
| 2150 | !turn.interrupted && |
| 2151 | turn.admissionPending && |
| 2152 | turn.admissionGeneration === generation; |
| 2153 | |
| 2154 | const runPromptAdmissionReconciliation = Effect.fnUntraced(function* ( |
| 2155 | state: OpenCodeThreadState, |
| 2156 | turn: ActiveOpenCodeTurn, |
| 2157 | generation: number, |
| 2158 | ) { |
| 2159 | while (promptAdmissionIsCurrent(state, turn, generation)) { |
| 2160 | const status = yield* reconcileOpenCodePromptAdmissionStatus( |
| 2161 | turn, |
| 2162 | generation, |
| 2163 | sdkCall("session.status", { sessionID: state.nativeSessionId, generation }, () => |
| 2164 | client.session.status(), |
| 2165 | ).pipe( |
| 2166 | Effect.match({ |
| 2167 | onFailure: () => "unknown" as const, |
| 2168 | onSuccess: (response) => { |
| 2169 | const statuses = unwrapData("session.status", response); |
| 2170 | const sessionStatus = statuses[state.nativeSessionId]; |
| 2171 | return sessionStatus === undefined || sessionStatus.type === "idle" |
| 2172 | ? ("idle" as const) |
| 2173 | : ("busy" as const); |
| 2174 | }, |
| 2175 | }), |
| 2176 | ), |
| 2177 | ); |
| 2178 | if ( |
| 2179 | state.activeTurn !== turn || |
| 2180 | status === "stale" || |
| 2181 | turn.finalized || |
| 2182 | turn.interrupted || |
| 2183 | turn.admissionGeneration !== generation |
| 2184 | ) { |
| 2185 | return; |
| 2186 | } |
| 2187 | if (status === "idle") { |
| 2188 | yield* finalizeTurn(state, turn, "completed"); |
| 2189 | return; |
| 2190 | } |
| 2191 | if (status === "busy") return; |
| 2192 | yield* Effect.sleep("250 millis"); |
| 2193 | } |
| 2194 | }); |
| 2195 | |
| 2196 | const reconcilePromptAdmission = Effect.fnUntraced(function* ( |
| 2197 | state: OpenCodeThreadState, |
| 2198 | turn: ActiveOpenCodeTurn, |
| 2199 | ) { |
| 2200 | const generation = turn.admissionGeneration; |
| 2201 | if ( |
| 2202 | !promptAdmissionIsCurrent(state, turn, generation) || |
| 2203 | turn.admissionReconciliationGeneration === generation |
| 2204 | ) { |
| 2205 | return; |
| 2206 | } |
| 2207 | turn.admissionReconciliationGeneration = generation; |
| 2208 | yield* runPromptAdmissionReconciliation(state, turn, generation).pipe( |
| 2209 | Effect.ensuring( |
| 2210 | Effect.sync(() => { |
| 2211 | if (turn.admissionReconciliationGeneration === generation) { |
| 2212 | turn.admissionReconciliationGeneration = null; |
| 2213 | } |
| 2214 | }), |
| 2215 | ), |
| 2216 | Effect.forkIn(scope), |
| 2217 | ); |
| 2218 | }); |
| 2219 | |
| 2220 | const createChildTurn = Effect.fnUntraced(function* ( |
| 2221 | state: OpenCodeThreadState, |
| 2222 | message: Extract<OpenCodeMessage, { role: "user" }>, |
| 2223 | ) { |
| 2224 | if (state.appThread === null || state.parentSubagent === null) return null; |
| 2225 | const now = yield* DateTime.now; |
| 2226 | const startedAt = dateTimeFromEpoch(message.time.created, now); |
| 2227 | const rootNodeId = idAllocator.derive.nodeFromProviderItem({ |
| 2228 | driver: OPENCODE_PROVIDER, |
| 2229 | nativeItemId: `${state.nativeSessionId}:root:${message.id}`, |
| 2230 | }); |
| 2231 | const providerTurnId = idAllocator.derive.providerTurn({ |
| 2232 | driver: OPENCODE_PROVIDER, |
| 2233 | nativeTurnId: message.id, |
| 2234 | }); |
| 2235 | const providerTurn: OrchestrationV2ProviderTurn = { |
| 2236 | id: providerTurnId, |
| 2237 | providerThreadId: state.providerThread.id, |
| 2238 | nodeId: rootNodeId, |
| 2239 | runAttemptId: null, |
| 2240 | nativeTurnRef: providerRef(message.id, "weak"), |
| 2241 | ordinal: state.nextChildTurnOrdinal++, |
| 2242 | status: "running", |
| 2243 | startedAt, |
| 2244 | completedAt: null, |
| 2245 | }; |
| 2246 | const turn: ActiveOpenCodeTurn = { |
| 2247 | isRoot: false, |
| 2248 | threadId: state.appThread.id, |
| 2249 | runId: null, |
| 2250 | rootNodeId, |
| 2251 | appThread: state.appThread, |
| 2252 | modelSelection: state.appThread.modelSelection, |
| 2253 | runtimePolicy: state.parentSubagent.parentTurn.runtimePolicy, |
| 2254 | providerTurnId, |
| 2255 | providerThreadId: providerTurn.providerThreadId, |
| 2256 | providerTurnOrdinal: providerTurn.ordinal, |
| 2257 | runOrdinal: state.parentSubagent.parentTurn.runOrdinal, |
| 2258 | runAttemptId: null, |
| 2259 | startedAt, |
| 2260 | itemOrdinals: new Map(), |
| 2261 | usage: makeOpenCodeTurnTokenUsageAccumulator(), |
| 2262 | parts: new Map(), |
| 2263 | partIdsByMessage: new Map(), |
| 2264 | toolNamesByCallId: new Map(), |
| 2265 | providerTurn, |
| 2266 | nextItemOrdinal: 1, |
| 2267 | nativeUserMessageId: message.id, |
| 2268 | admissionMessageId: null, |
| 2269 | interrupted: false, |
| 2270 | finalized: false, |
| 2271 | planId: null, |
| 2272 | admissionGeneration: 0, |
| 2273 | admissionReconciliationGeneration: null, |
| 2274 | admissionPending: false, |
| 2275 | admissionAccepted: true, |
| 2276 | admissionMessageObserved: true, |
| 2277 | idleDuringAdmission: false, |
| 2278 | admissionSettled: Deferred.makeUnsafe<void>(), |
| 2279 | admissionAbortController: null, |
| 2280 | }; |
| 2281 | state.activeTurn = turn; |
| 2282 | state.providerTurns.set(String(providerTurnId), providerTurn); |
| 2283 | yield* emitProviderEvent({ |
| 2284 | type: "node.updated", |
| 2285 | driver: OPENCODE_PROVIDER, |
| 2286 | node: { |
| 2287 | id: rootNodeId, |
| 2288 | threadId: turn.threadId, |
| 2289 | runId: null, |
| 2290 | parentNodeId: null, |
| 2291 | rootNodeId, |
| 2292 | kind: "root_turn", |
| 2293 | status: "running", |
| 2294 | countsForRun: false, |
| 2295 | providerThreadId: state.providerThread.id, |
| 2296 | providerTurnId, |
| 2297 | nativeItemRef: providerRef(message.id, "weak"), |
| 2298 | runtimeRequestId: null, |
| 2299 | checkpointScopeId: null, |
| 2300 | startedAt, |
| 2301 | completedAt: null, |
| 2302 | }, |
| 2303 | }); |
| 2304 | yield* emitProviderTurn(state, turn, "running", null); |
| 2305 | return turn; |
| 2306 | }); |
| 2307 | |
| 2308 | const projectChildUserPart = Effect.fnUntraced(function* ( |
| 2309 | state: OpenCodeThreadState, |
| 2310 | turn: ActiveOpenCodeTurn, |
| 2311 | part: Extract<OpenCodePart, { type: "text" }>, |
| 2312 | ) { |
| 2313 | const now = yield* DateTime.now; |
| 2314 | const messageId = idAllocator.derive.messageFromProviderItem({ |
| 2315 | driver: OPENCODE_PROVIDER, |
| 2316 | nativeItemId: part.messageID, |
| 2317 | }); |
| 2318 | const turnItemId = idAllocator.derive.turnItemFromProviderItem({ |
| 2319 | driver: OPENCODE_PROVIDER, |
| 2320 | nativeItemId: part.messageID, |
| 2321 | }); |
| 2322 | const projected: OrchestrationV2ConversationMessage = { |
| 2323 | createdBy: "agent", |
| 2324 | creationSource: "provider", |
| 2325 | id: messageId, |
| 2326 | threadId: turn.threadId, |
| 2327 | runId: null, |
| 2328 | nodeId: turn.rootNodeId, |
| 2329 | role: "user", |
| 2330 | text: part.text, |
| 2331 | attachments: [], |
| 2332 | streaming: false, |
| 2333 | createdAt: turn.startedAt, |
| 2334 | updatedAt: now, |
| 2335 | }; |
| 2336 | yield* emitProviderEvent({ |
| 2337 | type: "message.updated", |
| 2338 | driver: OPENCODE_PROVIDER, |
| 2339 | message: projected, |
| 2340 | }); |
| 2341 | yield* emitProviderEvent({ |
| 2342 | type: "turn_item.updated", |
| 2343 | driver: OPENCODE_PROVIDER, |
| 2344 | turnItem: { |
| 2345 | createdBy: "agent", |
| 2346 | creationSource: "provider", |
| 2347 | id: turnItemId, |
| 2348 | threadId: turn.threadId, |
| 2349 | runId: null, |
| 2350 | nodeId: turn.rootNodeId, |
| 2351 | providerThreadId: state.providerThread.id, |
| 2352 | providerTurnId: turn.providerTurnId, |
| 2353 | nativeItemRef: providerRef(part.messageID), |
| 2354 | parentItemId: null, |
| 2355 | ordinal: itemOrdinal(turn, part.messageID), |
| 2356 | status: "completed", |
| 2357 | title: null, |
| 2358 | startedAt: projected.createdAt, |
| 2359 | completedAt: now, |
| 2360 | updatedAt: now, |
| 2361 | type: "user_message", |
| 2362 | messageId, |
| 2363 | inputIntent: "turn_start", |
| 2364 | text: part.text, |
| 2365 | attachments: [], |
| 2366 | }, |
| 2367 | }); |
| 2368 | }); |
| 2369 | |
| 2370 | const emitCompactionItem = Effect.fn("OpenCodeAdapterV2.emitCompactionItem")(function* ( |
| 2371 | state: OpenCodeThreadState, |
| 2372 | turn: ActiveOpenCodeTurn, |
| 2373 | ) { |
| 2374 | const nativeItemId = `${turn.providerTurnId}:compaction`; |
| 2375 | if (turn.itemOrdinals.has(nativeItemId)) return; |
| 2376 | const now = yield* DateTime.now; |
| 2377 | yield* emitProviderEvent({ |
| 2378 | type: "turn_item.updated", |
| 2379 | driver: OPENCODE_PROVIDER, |
| 2380 | turnItem: { |
| 2381 | id: idAllocator.derive.turnItemFromProviderItem({ |
| 2382 | driver: OPENCODE_PROVIDER, |
| 2383 | nativeItemId, |
| 2384 | }), |
| 2385 | threadId: turn.threadId, |
| 2386 | runId: turn.runId, |
| 2387 | nodeId: turn.rootNodeId, |
| 2388 | providerThreadId: state.providerThread.id, |
| 2389 | providerTurnId: turn.providerTurnId, |
| 2390 | nativeItemRef: providerRef(nativeItemId, "weak"), |
| 2391 | parentItemId: null, |
| 2392 | ordinal: itemOrdinal(turn, nativeItemId), |
| 2393 | type: "compaction", |
| 2394 | driver: OPENCODE_PROVIDER, |
| 2395 | status: "completed", |
| 2396 | title: "Context compacted", |
| 2397 | startedAt: turn.startedAt, |
| 2398 | completedAt: now, |
| 2399 | updatedAt: now, |
| 2400 | }, |
| 2401 | }); |
| 2402 | }); |
| 2403 | |
| 2404 | const handleMessageUpdated = Effect.fnUntraced(function* ( |
| 2405 | event: Extract<OpenCodeEvent, { type: "message.updated" }>, |
| 2406 | ) { |
| 2407 | const state = threads.get(event.properties.sessionID); |
| 2408 | if (state === undefined) return; |
| 2409 | const message = event.properties.info; |
| 2410 | state.messageRoles.set(message.id, message.role); |
| 2411 | if (message.role === "assistant") { |
| 2412 | const usage = state.activeTurn?.usage; |
| 2413 | if (usage === undefined) return; |
| 2414 | const prior = usage.assistantOwnershipByMessageId.get(message.id); |
| 2415 | const ownership = |
| 2416 | prior !== undefined && prior !== "unknown" |
| 2417 | ? prior |
| 2418 | : !message.parentID |
| 2419 | ? "unknown" |
| 2420 | : usage.promptMessageIds.has(message.parentID) |
| 2421 | ? "owned" |
| 2422 | : "other"; |
| 2423 | usage.assistantOwnershipByMessageId.set(message.id, ownership); |
| 2424 | if (ownership !== "unknown") { |
| 2425 | if (ownership === "owned") { |
| 2426 | for (const step of usage.unresolvedStepsByMessageId.get(message.id)?.values() ?? |
| 2427 | []) { |
| 2428 | accumulateOpenCodeStepUsage(usage, step); |
| 2429 | } |
| 2430 | } |
| 2431 | usage.unresolvedStepsByMessageId.delete(message.id); |
| 2432 | } |
| 2433 | return; |
| 2434 | } |
| 2435 | const commandReceipt = commandReceipts.get(message.id); |
| 2436 | if (commandReceipt) yield* Deferred.succeed(commandReceipt, undefined); |
| 2437 | const isNewUserMessage = !state.userMessageIds.includes(message.id); |
| 2438 | if (isNewUserMessage) state.userMessageIds.push(message.id); |
| 2439 | let turn = state.activeTurn; |
| 2440 | if (turn === null && state.parentSubagent !== null && isNewUserMessage) { |
| 2441 | turn = yield* createChildTurn(state, message); |
| 2442 | } |
| 2443 | const matchesAdmission = |
| 2444 | turn !== null && |
| 2445 | (turn.admissionMessageId === null || turn.admissionMessageId === message.id); |
| 2446 | if (turn !== null && matchesAdmission) turn.usage.promptMessageIds.add(message.id); |
| 2447 | if (turn !== null && matchesAdmission && turn.nativeUserMessageId === null) { |
| 2448 | turn.nativeUserMessageId = message.id; |
| 2449 | yield* emitProviderTurn(state, turn, "running", null); |
| 2450 | } |
| 2451 | if (turn !== null && matchesAdmission && turn.admissionPending) { |
| 2452 | if (advanceOpenCodePromptAdmission(turn, "user-message") === "reconcile-idle") { |
| 2453 | yield* reconcilePromptAdmission(state, turn); |
| 2454 | } |
| 2455 | } |
| 2456 | }); |
| 2457 | |
| 2458 | const handlePartUpdated = Effect.fnUntraced(function* ( |
| 2459 | event: Extract<OpenCodeEvent, { type: "message.part.updated" }>, |
| 2460 | ) { |
| 2461 | const part = event.properties.part; |
| 2462 | const state = threads.get(part.sessionID); |
| 2463 | const turn = state?.activeTurn; |
| 2464 | if (state === undefined || turn === null || turn === undefined || turn.finalized) return; |
| 2465 | if (part.type === "text" && state.messageRoles.get(part.messageID) === "user") { |
| 2466 | if (!turn.isRoot) yield* projectChildUserPart(state, turn, part); |
| 2467 | return; |
| 2468 | } |
| 2469 | if (part.type === "step-finish") { |
| 2470 | const usage = turn.usage; |
| 2471 | const ownership = usage.assistantOwnershipByMessageId.get(part.messageID); |
| 2472 | if (ownership === "owned") accumulateOpenCodeStepUsage(usage, part); |
| 2473 | else if ( |
| 2474 | ownership === "unknown" || |
| 2475 | (ownership === undefined && !state.messageRoles.has(part.messageID)) |
| 2476 | ) { |
| 2477 | const steps = |
| 2478 | usage.unresolvedStepsByMessageId.get(part.messageID) ?? |
| 2479 | new Map<string, OpenCodeStepUsage>(); |
| 2480 | steps.set(part.id, { id: part.id, tokens: part.tokens }); |
| 2481 | usage.unresolvedStepsByMessageId.set(part.messageID, steps); |
| 2482 | } |
| 2483 | return; |
| 2484 | } |
| 2485 | if (part.type === "tool") { |
| 2486 | // Approval routing needs the tool name, without retaining its input and output. |
| 2487 | turn.toolNamesByCallId.set(part.callID, part.tool); |
| 2488 | } else { |
| 2489 | turn.parts.set(part.id, part); |
| 2490 | const ids = turn.partIdsByMessage.get(part.messageID) ?? new Set<string>(); |
| 2491 | ids.add(part.id); |
| 2492 | turn.partIdsByMessage.set(part.messageID, ids); |
| 2493 | } |
| 2494 | switch (part.type) { |
| 2495 | case "text": |
| 2496 | case "reasoning": |
| 2497 | yield* emitTextPart(state, turn, part); |
| 2498 | return; |
| 2499 | case "tool": |
| 2500 | yield* emitToolPart(state, turn, part); |
| 2501 | return; |
| 2502 | default: |
| 2503 | return; |
| 2504 | } |
| 2505 | }); |
| 2506 | |
| 2507 | const handlePartDelta = Effect.fnUntraced(function* ( |
| 2508 | event: Extract<OpenCodeEvent, { type: "message.part.delta" }>, |
| 2509 | ) { |
| 2510 | if (event.properties.field !== "text") return; |
| 2511 | const state = threads.get(event.properties.sessionID); |
| 2512 | const turn = state?.activeTurn; |
| 2513 | const current = turn?.parts.get(event.properties.partID); |
| 2514 | if ( |
| 2515 | state === undefined || |
| 2516 | turn === null || |
| 2517 | turn === undefined || |
| 2518 | current === undefined || |
| 2519 | (current.type !== "text" && current.type !== "reasoning") |
| 2520 | ) { |
| 2521 | return; |
| 2522 | } |
| 2523 | const updated = { ...current, text: current.text + event.properties.delta }; |
| 2524 | turn.parts.set(updated.id, updated); |
| 2525 | yield* emitTextPart(state, turn, updated); |
| 2526 | }); |
| 2527 | |
| 2528 | const handleAssistantCompleted = Effect.fnUntraced(function* ( |
| 2529 | event: Extract<OpenCodeEvent, { type: "message.updated" }>, |
| 2530 | ) { |
| 2531 | const message = event.properties.info; |
| 2532 | if (message.role !== "assistant" || message.time.completed === undefined) return; |
| 2533 | const state = threads.get(message.sessionID); |
| 2534 | const turn = state?.activeTurn; |
| 2535 | if (state === undefined || turn === null || turn === undefined) return; |
| 2536 | // Some OpenCode versions ignore the client-provided message ID. A |
| 2537 | // completed assistant message is definitive admission evidence, so |
| 2538 | // let the following idle event settle the turn without weakening |
| 2539 | // the stale-user-message guard. |
| 2540 | advanceOpenCodePromptAdmission(turn, "assistant-completed"); |
| 2541 | for (const partId of turn.partIdsByMessage.get(message.id) ?? []) { |
| 2542 | const part = turn.parts.get(partId); |
| 2543 | if (part?.type === "text" || part?.type === "reasoning") { |
| 2544 | yield* emitTextPart(state, turn, part, true); |
| 2545 | } |
| 2546 | } |
| 2547 | }); |
| 2548 | |
| 2549 | const handleEvent = Effect.fnUntraced(function* (event: OpenCodeEvent) { |
| 2550 | yield* logProtocolEvent({ |
| 2551 | direction: "incoming", |
| 2552 | messageKind: "notification", |
| 2553 | method: event.type, |
| 2554 | payload: event, |
| 2555 | }); |
| 2556 | switch (event.type) { |
| 2557 | case "server.connected": |
| 2558 | if (hasConnected) { |
| 2559 | for (const state of threads.values()) { |
| 2560 | if (state.activeTurn !== null) state.activeTurn.usage.complete = false; |
| 2561 | } |
| 2562 | } |
| 2563 | hasConnected = true; |
| 2564 | return; |
| 2565 | case "message.updated": |
| 2566 | yield* handleMessageUpdated(event); |
| 2567 | yield* handleAssistantCompleted(event); |
| 2568 | return; |
| 2569 | case "session.compacted": { |
| 2570 | const state = threads.get(event.properties.sessionID); |
| 2571 | if (state?.activeTurn !== undefined && state.activeTurn !== null) { |
| 2572 | yield* emitCompactionItem(state, state.activeTurn); |
| 2573 | } |
| 2574 | return; |
| 2575 | } |
| 2576 | case "message.part.updated": |
| 2577 | yield* handlePartUpdated(event); |
| 2578 | return; |
| 2579 | case "message.part.delta": |
| 2580 | yield* handlePartDelta(event); |
| 2581 | return; |
| 2582 | case "todo.updated": { |
| 2583 | const state = threads.get(event.properties.sessionID); |
| 2584 | if (state?.activeTurn !== null && state?.activeTurn !== undefined) { |
| 2585 | yield* emitTodo(state, state.activeTurn, event.properties.todos); |
| 2586 | } |
| 2587 | return; |
| 2588 | } |
| 2589 | case "permission.asked": |
| 2590 | yield* routeRuntimeRequest(event.properties.id, event.properties.sessionID, { |
| 2591 | type: "permission", |
| 2592 | value: event.properties, |
| 2593 | }); |
| 2594 | return; |
| 2595 | case "question.asked": |
| 2596 | yield* routeRuntimeRequest(event.properties.id, event.properties.sessionID, { |
| 2597 | type: "question", |
| 2598 | value: event.properties, |
| 2599 | }); |
| 2600 | return; |
| 2601 | case "permission.replied": |
| 2602 | case "question.replied": |
| 2603 | rememberSettledRequest(event.properties.requestID); |
| 2604 | yield* resolveRuntimeRequest(event.properties.requestID, "resolved"); |
| 2605 | return; |
| 2606 | case "question.rejected": |
| 2607 | rememberSettledRequest(event.properties.requestID); |
| 2608 | yield* resolveRuntimeRequest(event.properties.requestID, "cancelled"); |
| 2609 | return; |
| 2610 | case "session.created": |
| 2611 | case "session.updated": { |
| 2612 | const info = event.properties.info; |
| 2613 | if (info.parentID !== undefined && !threads.has(info.id)) { |
| 2614 | const owner = threads.get(info.parentID) ?? relatedSessionOwners.get(info.parentID); |
| 2615 | if (owner !== undefined) { |
| 2616 | relatedSessionOwners.set(info.id, owner); |
| 2617 | } |
| 2618 | } |
| 2619 | return; |
| 2620 | } |
| 2621 | case "session.deleted": |
| 2622 | relatedSessionOwners.delete(event.properties.info.id); |
| 2623 | busySessionIds.delete(event.properties.info.id); |
| 2624 | return; |
| 2625 | case "session.status": { |
| 2626 | const sessionId = event.properties.sessionID; |
| 2627 | switch (event.properties.status.type) { |
| 2628 | case "busy": |
| 2629 | case "retry": |
| 2630 | if (threads.has(sessionId) || relatedSessionOwners.has(sessionId)) { |
| 2631 | busySessionIds.add(sessionId); |
| 2632 | } |
| 2633 | break; |
| 2634 | case "idle": |
| 2635 | busySessionIds.delete(sessionId); |
| 2636 | break; |
| 2637 | } |
| 2638 | const state = threads.get(sessionId); |
| 2639 | if (state === undefined) return; |
| 2640 | if (event.properties.status.type === "busy") { |
| 2641 | yield* updateProviderThread(state, { status: "active" }); |
| 2642 | return; |
| 2643 | } |
| 2644 | if (event.properties.status.type === "idle" && state.activeTurn !== null) { |
| 2645 | if (state.activeTurn.admissionPending) { |
| 2646 | advanceOpenCodePromptAdmission(state.activeTurn, "idle"); |
| 2647 | return; |
| 2648 | } |
| 2649 | yield* finalizeTurn( |
| 2650 | state, |
| 2651 | state.activeTurn, |
| 2652 | state.activeTurn.interrupted ? "interrupted" : "completed", |
| 2653 | ); |
| 2654 | } |
| 2655 | return; |
| 2656 | } |
| 2657 | case "session.idle": { |
| 2658 | busySessionIds.delete(event.properties.sessionID); |
| 2659 | const state = threads.get(event.properties.sessionID); |
| 2660 | if (state?.activeTurn !== null && state?.activeTurn !== undefined) { |
| 2661 | if (state.activeTurn.admissionPending) { |
| 2662 | advanceOpenCodePromptAdmission(state.activeTurn, "idle"); |
| 2663 | return; |
| 2664 | } |
| 2665 | yield* finalizeTurn( |
| 2666 | state, |
| 2667 | state.activeTurn, |
| 2668 | state.activeTurn.interrupted ? "interrupted" : "completed", |
| 2669 | ); |
| 2670 | } |
| 2671 | return; |
| 2672 | } |
| 2673 | case "session.error": { |
| 2674 | const states = |
| 2675 | event.properties.sessionID === undefined |
| 2676 | ? Array.from(threads.values()).filter((state) => state.activeTurn !== null) |
| 2677 | : [threads.get(event.properties.sessionID)].filter( |
| 2678 | (state): state is OpenCodeThreadState => state !== undefined, |
| 2679 | ); |
| 2680 | const message = openCodeErrorMessage(event); |
| 2681 | if ( |
| 2682 | !isMessageAbortedError(event) && |
| 2683 | (event.properties.sessionID === undefined || |
| 2684 | states.some((state) => state.parentSubagent === null)) |
| 2685 | ) { |
| 2686 | yield* updateProviderSession("error", message); |
| 2687 | } |
| 2688 | for (const state of states) { |
| 2689 | if (state.activeTurn !== null) { |
| 2690 | yield* finalizeTurn( |
| 2691 | state, |
| 2692 | state.activeTurn, |
| 2693 | terminalStatusForError(event, state.activeTurn), |
| 2694 | { |
| 2695 | failure: makeProviderFailure({ |
| 2696 | message, |
| 2697 | code: event.properties.error?.name ?? null, |
| 2698 | class: "provider_error", |
| 2699 | }), |
| 2700 | threadDisposition: |
| 2701 | event.properties.sessionID === undefined ? "broken" : "reusable", |
| 2702 | }, |
| 2703 | ); |
| 2704 | } |
| 2705 | } |
| 2706 | return; |
| 2707 | } |
| 2708 | default: |
| 2709 | return; |
| 2710 | } |
| 2711 | }); |
| 2712 | |
| 2713 | const inputProviderSessionId = input.providerSessionId; |
| 2714 | |
| 2715 | const subscription = yield* sdkCall("event.subscribe", {}, () => |
| 2716 | client.event.subscribe(undefined, { signal: abortController.signal }), |
| 2717 | ); |
| 2718 | yield* Scope.addFinalizer( |
| 2719 | scope, |
| 2720 | Effect.sync(() => abortController.abort()), |
| 2721 | ); |
| 2722 | yield* Stream.fromAsyncIterable( |
| 2723 | subscription.stream, |
| 2724 | (cause) => |
| 2725 | new OpenCodeRuntime.OpenCodeRuntimeError({ |
| 2726 | operation: "event.subscribe", |
| 2727 | detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause), |
| 2728 | cause, |
| 2729 | }), |
| 2730 | ).pipe( |
| 2731 | Stream.runForEach(handleEvent), |
| 2732 | Effect.exit, |
| 2733 | Effect.flatMap((exit) => |
| 2734 | Effect.gen(function* () { |
| 2735 | // No further status can clear a busy session once the stream ends. |
| 2736 | busySessionIds.clear(); |
| 2737 | if (closing || abortController.signal.aborted) return; |
| 2738 | const detail = Exit.isSuccess(exit) |
| 2739 | ? "OpenCode event stream ended unexpectedly." |
| 2740 | : OpenCodeRuntime.openCodeRuntimeErrorDetail(Cause.squash(exit.cause)); |
| 2741 | nativeStreamFailure = makeProviderFailure({ |
| 2742 | message: detail, |
| 2743 | class: "transport_error", |
| 2744 | }); |
| 2745 | yield* updateProviderSession("error", detail); |
| 2746 | for (const state of threads.values()) { |
| 2747 | if (state.activeTurn !== null) |
| 2748 | yield* finalizeTurn(state, state.activeTurn, "failed", { |
| 2749 | failure: makeProviderFailure({ message: detail, class: "transport_error" }), |
| 2750 | threadDisposition: "broken", |
| 2751 | }); |
| 2752 | } |
| 2753 | yield* Queue.end(events); |
| 2754 | }), |
| 2755 | ), |
| 2756 | Effect.forkIn(scope), |
| 2757 | ); |
| 2758 | |
| 2759 | if (!connection.external && connection.exitCode !== null) { |
| 2760 | yield* connection.exitCode.pipe( |
| 2761 | Effect.flatMap((code) => |
| 2762 | abortController.signal.aborted |
| 2763 | ? Effect.void |
| 2764 | : Effect.gen(function* () { |
| 2765 | const detail = `OpenCode server exited unexpectedly (${code}).`; |
| 2766 | yield* updateProviderSession("error", detail); |
| 2767 | for (const state of threads.values()) { |
| 2768 | if (state.activeTurn !== null) { |
| 2769 | yield* finalizeTurn(state, state.activeTurn, "failed", { |
| 2770 | failure: makeProviderFailure({ |
| 2771 | message: detail, |
| 2772 | class: "transport_error", |
| 2773 | }), |
| 2774 | threadDisposition: "broken", |
| 2775 | }); |
| 2776 | } |
| 2777 | } |
| 2778 | }), |
| 2779 | ), |
| 2780 | Effect.forkIn(scope), |
| 2781 | ); |
| 2782 | } |
| 2783 | |
| 2784 | if (connection.external) { |
| 2785 | yield* Scope.addFinalizer( |
| 2786 | scope, |
| 2787 | Effect.suspend(() => |
| 2788 | Effect.forEach( |
| 2789 | Array.from(threads.values()).filter((state) => state.parentSubagent === null), |
| 2790 | (state) => |
| 2791 | sdkCall("session.abort", { sessionID: state.nativeSessionId }, (signal) => |
| 2792 | client.session.abort({ sessionID: state.nativeSessionId }, { signal }), |
| 2793 | ).pipe( |
| 2794 | Effect.timeout("1 second"), |
| 2795 | Effect.ignore({ log: true }), |
| 2796 | Effect.andThen( |
| 2797 | abortDescendants(state.nativeSessionId).pipe( |
| 2798 | Effect.timeout("1 second"), |
| 2799 | Effect.ignore({ log: true }), |
| 2800 | ), |
| 2801 | ), |
| 2802 | ), |
| 2803 | { concurrency: 8, discard: true }, |
| 2804 | ), |
| 2805 | ), |
| 2806 | ); |
| 2807 | } |
| 2808 | |
| 2809 | yield* Scope.addFinalizer( |
| 2810 | scope, |
| 2811 | Effect.sync(() => { |
| 2812 | closing = true; |
| 2813 | }), |
| 2814 | ); |
| 2815 | |
| 2816 | const registerThread = ( |
| 2817 | nativeSession: OpenCodeSession, |
| 2818 | providerThread: OrchestrationV2ProviderThread, |
| 2819 | appThread: OrchestrationV2AppThread | null, |
| 2820 | ): OpenCodeThreadState => { |
| 2821 | const existing = threads.get(nativeSession.id); |
| 2822 | if (existing !== undefined) { |
| 2823 | existing.providerThread = providerThread; |
| 2824 | if (appThread !== null) existing.appThread = appThread; |
| 2825 | return existing; |
| 2826 | } |
| 2827 | const state: OpenCodeThreadState = { |
| 2828 | nativeSessionId: nativeSession.id, |
| 2829 | providerThread, |
| 2830 | appThread, |
| 2831 | activeTurn: null, |
| 2832 | providerTurns: new Map(), |
| 2833 | runtimeRequests: new Map(), |
| 2834 | messageRoles: new Map(), |
| 2835 | userMessageIds: [], |
| 2836 | parentSubagent: subagentsByChildSessionId.get(nativeSession.id) ?? null, |
| 2837 | nextChildTurnOrdinal: 1, |
| 2838 | nextAdmissionGeneration: 1, |
| 2839 | }; |
| 2840 | threads.set(nativeSession.id, state); |
| 2841 | return state; |
| 2842 | }; |
| 2843 | |
| 2844 | const resolvePromptParts = (turnInput: ProviderAdapter.ProviderAdapterV2TurnInput) => { |
| 2845 | const text = providerMessageTextWithAttachmentPaths({ |
| 2846 | text: turnInput.message.text, |
| 2847 | attachments: turnInput.message.attachments, |
| 2848 | resolveAttachmentPath: host.resolveAttachmentPath, |
| 2849 | }).trim(); |
| 2850 | const files = OpenCodeRuntime.toOpenCodeFileParts({ |
| 2851 | attachments: turnInput.message.attachments, |
| 2852 | resolveAttachmentPath: host.resolveAttachmentPath, |
| 2853 | }); |
| 2854 | if (text.length === 0 && files.length === 0) { |
| 2855 | throw protocolError("OpenCode turns require text or at least one valid attachment"); |
| 2856 | } |
| 2857 | return [...(text.length === 0 ? [] : [{ type: "text" as const, text }]), ...files]; |
| 2858 | }; |
| 2859 | |
| 2860 | const readSnapshot = Effect.fnUntraced(function* ( |
| 2861 | providerThread: OrchestrationV2ProviderThread, |
| 2862 | ) { |
| 2863 | const sessionId = nativeThreadId(providerThread); |
| 2864 | const sessionResponse = yield* sdkCall("session.get", { sessionID: sessionId }, () => |
| 2865 | client.session.get({ sessionID: sessionId }), |
| 2866 | ); |
| 2867 | const nativeSession = unwrapData("session.get", sessionResponse); |
| 2868 | const response = yield* sdkCall("session.messages", { sessionID: sessionId }, () => |
| 2869 | client.session.messages({ sessionID: sessionId }), |
| 2870 | ); |
| 2871 | const nativeMessages = []; |
| 2872 | for (const entry of unwrapData("session.messages", response)) { |
| 2873 | // OpenCode retains reverted records in session.messages. Its |
| 2874 | // revert marker identifies the first removed message, so stop |
| 2875 | // before it or a restart resurrects rolled-back assistant work. |
| 2876 | if (entry.info.id === nativeSession.revert?.messageID) break; |
| 2877 | nativeMessages.push(entry); |
| 2878 | } |
| 2879 | const state = threads.get(sessionId); |
| 2880 | const snapshotNow = yield* DateTime.now; |
| 2881 | const messages: Array<OrchestrationV2ConversationMessage> = nativeMessages.flatMap( |
| 2882 | ({ info, parts }) => { |
| 2883 | const text = parts |
| 2884 | .filter( |
| 2885 | (part): part is Extract<OpenCodePart, { type: "text" }> => part.type === "text", |
| 2886 | ) |
| 2887 | .filter((part) => part.ignored !== true && part.synthetic !== true) |
| 2888 | .map((part) => part.text) |
| 2889 | .join("\n"); |
| 2890 | if (text.length === 0) return []; |
| 2891 | const createdAt = dateTimeFromEpoch(info.time.created, snapshotNow); |
| 2892 | return [ |
| 2893 | { |
| 2894 | createdBy: info.role === "user" ? "user" : "agent", |
| 2895 | creationSource: "provider", |
| 2896 | id: idAllocator.derive.messageFromProviderItem({ |
| 2897 | driver: OPENCODE_PROVIDER, |
| 2898 | nativeItemId: info.id, |
| 2899 | }), |
| 2900 | threadId: providerThread.appThreadId ?? input.threadId, |
| 2901 | runId: null, |
| 2902 | nodeId: null, |
| 2903 | role: info.role, |
| 2904 | text, |
| 2905 | attachments: [], |
| 2906 | streaming: false, |
| 2907 | createdAt, |
| 2908 | updatedAt: |
| 2909 | info.role === "assistant" |
| 2910 | ? dateTimeFromEpoch(info.time.completed, createdAt) |
| 2911 | : createdAt, |
| 2912 | }, |
| 2913 | ]; |
| 2914 | }, |
| 2915 | ); |
| 2916 | const lastUser = nativeMessages.findLast(({ info }) => info.role === "user")?.info.id; |
| 2917 | return { |
| 2918 | providerThread: { |
| 2919 | ...providerThread, |
| 2920 | providerSessionId: input.providerSessionId, |
| 2921 | nativeConversationHeadRef: |
| 2922 | lastUser === undefined ? null : providerRef(lastUser, "weak"), |
| 2923 | status: "idle" as const, |
| 2924 | updatedAt: snapshotNow, |
| 2925 | }, |
| 2926 | providerTurns: state === undefined ? [] : [...state.providerTurns.values()], |
| 2927 | messages, |
| 2928 | runtimeRequests: state === undefined ? [] : [...state.runtimeRequests.values()], |
| 2929 | providerPayload: nativeMessages, |
| 2930 | }; |
| 2931 | }); |
| 2932 | |
| 2933 | // Native commands wait for generation, unlike promptAsync. A user-message |
| 2934 | // receipt admits the run while the scoped request continues in the background. |
| 2935 | const submitPrompt = Effect.fn("OpenCode.submitPrompt")(function* ( |
| 2936 | state: OpenCodeThreadState, |
| 2937 | turn: ActiveOpenCodeTurn, |
| 2938 | payload: Parameters<typeof client.session.promptAsync>[0] & { |
| 2939 | messageID: string; |
| 2940 | sessionID: string; |
| 2941 | }, |
| 2942 | abortController: AbortController, |
| 2943 | ) { |
| 2944 | const text = payload.parts |
| 2945 | ?.filter((part) => part.type === "text") |
| 2946 | .map((part) => part.text) |
| 2947 | .join("\n") |
| 2948 | .trim(); |
| 2949 | const match = text?.match(/^\/([^\s/]+)(?:\s+([\s\S]*))?$/); |
| 2950 | const command = match |
| 2951 | ? (yield* OpenCodeRuntime.loadOpenCodeCommands(client).pipe( |
| 2952 | Effect.timeout("10 seconds"), |
| 2953 | Effect.orElseSucceed(() => []), |
| 2954 | )).find((entry) => entry.name === match[1]) |
| 2955 | : undefined; |
| 2956 | if (!command) { |
| 2957 | return yield* sdkCall("session.promptAsync", payload, (signal) => |
| 2958 | client.session.promptAsync(payload, { |
| 2959 | signal: AbortSignal.any([signal, abortController.signal]), |
| 2960 | }), |
| 2961 | ).pipe(Effect.asVoid); |
| 2962 | } |
| 2963 | const receipt = Deferred.makeUnsafe<void>(); |
| 2964 | commandReceipts.set(payload.messageID, receipt); |
| 2965 | const controllers = |
| 2966 | commandControllers.get(payload.sessionID) ?? new Set<AbortController>(); |
| 2967 | controllers.add(abortController); |
| 2968 | commandControllers.set(payload.sessionID, controllers); |
| 2969 | const generation = turn.admissionGeneration; |
| 2970 | const commandPayload = { |
| 2971 | sessionID: payload.sessionID, |
| 2972 | messageID: payload.messageID, |
| 2973 | command: command.name, |
| 2974 | arguments: match?.[2] ?? "", |
| 2975 | ...(payload.model |
| 2976 | ? { model: `${payload.model.providerID}/${payload.model.modelID}` } |
| 2977 | : {}), |
| 2978 | ...(payload.agent ? { agent: payload.agent } : {}), |
| 2979 | ...(payload.variant ? { variant: payload.variant } : {}), |
| 2980 | parts: payload.parts?.filter((part) => part.type === "file") ?? [], |
| 2981 | }; |
| 2982 | const request = yield* sdkCall("session.command", commandPayload, (signal) => |
| 2983 | client.session.command(commandPayload, { |
| 2984 | signal: AbortSignal.any([signal, abortController.signal]), |
| 2985 | }), |
| 2986 | ).pipe( |
| 2987 | Effect.asVoid, |
| 2988 | Effect.tapError((cause) => |
| 2989 | abortController.signal.aborted || |
| 2990 | turn.finalized || |
| 2991 | turn.admissionGeneration !== generation |
| 2992 | ? Effect.void |
| 2993 | : finalizeTurn(state, turn, "failed", { |
| 2994 | failure: makeProviderFailure({ cause, class: "provider_error" }), |
| 2995 | }), |
| 2996 | ), |
| 2997 | Effect.ensuring( |
| 2998 | Effect.sync(() => { |
| 2999 | commandReceipts.delete(payload.messageID); |
| 3000 | controllers.delete(abortController); |
| 3001 | if (controllers.size === 0) commandControllers.delete(payload.sessionID); |
| 3002 | }), |
| 3003 | ), |
| 3004 | Effect.forkIn(scope), |
| 3005 | ); |
| 3006 | yield* Effect.raceFirst(Fiber.join(request), Deferred.await(receipt)).pipe( |
| 3007 | Effect.timeout("10 seconds"), |
| 3008 | Effect.catchTags({ |
| 3009 | TimeoutError: (cause) => { |
| 3010 | const error = new OpenCodeRuntime.OpenCodeRuntimeError({ |
| 3011 | operation: "session.command", |
| 3012 | detail: "OpenCode command admission did not complete within 10 seconds.", |
| 3013 | cause, |
| 3014 | }); |
| 3015 | abortController.abort(); |
| 3016 | return finalizeTurn(state, turn, "failed", { |
| 3017 | failure: makeProviderFailure({ cause: error, class: "provider_error" }), |
| 3018 | }).pipe(Effect.andThen(Effect.fail(error))); |
| 3019 | }, |
| 3020 | }), |
| 3021 | ); |
| 3022 | }); |
| 3023 | |
| 3024 | const runtimeSession: ProviderAdapter.ProviderAdapterV2SessionRuntime = { |
| 3025 | instanceId: options.instanceId, |
| 3026 | driver: OPENCODE_PROVIDER, |
| 3027 | providerSessionId: input.providerSessionId, |
| 3028 | providerSession: sessionEntity, |
| 3029 | events: Stream.fromEffectRepeat(Queue.take(events)), |
| 3030 | hasPendingBackgroundWork: Effect.sync(() => busySessionIds.size > 0), |
| 3031 | ensureThread: (threadInput) => |
| 3032 | Effect.gen(function* () { |
| 3033 | // Only a row that already carries a native session can be |
| 3034 | // resumed; a placeholder without one still needs session.create. |
| 3035 | if (threadInput.existingProviderThread?.nativeThreadRef != null) { |
| 3036 | return yield* runtimeSession.resumeThread({ |
| 3037 | providerThread: threadInput.existingProviderThread, |
| 3038 | }); |
| 3039 | } |
| 3040 | // No title: OpenCode generates one from the first prompt only when |
| 3041 | // session.create leaves it unset (SessionPrompt.ensureTitle). |
| 3042 | const response = yield* sdkCall( |
| 3043 | "session.create", |
| 3044 | { permission: openCodePermissionRules(threadInput.runtimePolicy) }, |
| 3045 | () => |
| 3046 | client.session.create({ |
| 3047 | permission: openCodePermissionRules(threadInput.runtimePolicy), |
| 3048 | }), |
| 3049 | ); |
| 3050 | const nativeSession = unwrapData("session.create", response); |
| 3051 | const createdAt = yield* DateTime.now; |
| 3052 | const created = makeProviderThread({ |
| 3053 | idAllocator, |
| 3054 | providerInstanceId: options.instanceId, |
| 3055 | providerSessionId: input.providerSessionId, |
| 3056 | appThreadId: threadInput.threadId, |
| 3057 | nativeSession, |
| 3058 | now: createdAt, |
| 3059 | }); |
| 3060 | const existing = threadInput.existingProviderThread; |
| 3061 | // Bind the new native session to the caller's row when one was |
| 3062 | // handed over: a second live row per app thread would make |
| 3063 | // `activeProviderThreadId` flap between the two on every update. |
| 3064 | const providerThread = |
| 3065 | existing === undefined |
| 3066 | ? created |
| 3067 | : { |
| 3068 | ...existing, |
| 3069 | providerSessionId: input.providerSessionId, |
| 3070 | nativeThreadRef: created.nativeThreadRef, |
| 3071 | nativeConversationHeadRef: created.nativeConversationHeadRef, |
| 3072 | status: created.status, |
| 3073 | updatedAt: created.updatedAt, |
| 3074 | }; |
| 3075 | registerThread(nativeSession, providerThread, null); |
| 3076 | return providerThread; |
| 3077 | }).pipe( |
| 3078 | Effect.mapError( |
| 3079 | (cause) => |
| 3080 | new ProviderAdapter.ProviderAdapterEnsureThreadError({ |
| 3081 | driver: OPENCODE_PROVIDER, |
| 3082 | threadId: threadInput.threadId, |
| 3083 | cause, |
| 3084 | }), |
| 3085 | ), |
| 3086 | ), |
| 3087 | resumeThread: (threadInput) => |
| 3088 | Effect.gen(function* () { |
| 3089 | const sessionId = nativeThreadId(threadInput.providerThread); |
| 3090 | const response = yield* sdkCall("session.get", { sessionID: sessionId }, () => |
| 3091 | client.session.get({ sessionID: sessionId }), |
| 3092 | ); |
| 3093 | const nativeSession = unwrapData("session.get", response); |
| 3094 | const resumedAt = yield* DateTime.now; |
| 3095 | const providerThread = { |
| 3096 | ...threadInput.providerThread, |
| 3097 | providerSessionId: input.providerSessionId, |
| 3098 | status: "idle" as const, |
| 3099 | updatedAt: dateTimeFromEpoch(nativeSession.time.updated, resumedAt), |
| 3100 | }; |
| 3101 | registerThread(nativeSession, providerThread, null); |
| 3102 | return providerThread; |
| 3103 | }).pipe( |
| 3104 | Effect.mapError( |
| 3105 | (cause) => |
| 3106 | new ProviderAdapter.ProviderAdapterResumeThreadError({ |
| 3107 | driver: OPENCODE_PROVIDER, |
| 3108 | providerSessionId: input.providerSessionId, |
| 3109 | providerThreadId: threadInput.providerThread.id, |
| 3110 | cause, |
| 3111 | }), |
| 3112 | ), |
| 3113 | ), |
| 3114 | compactThread: (turnInput) => |
| 3115 | runtimeSession.startTurn({ |
| 3116 | ...turnInput, |
| 3117 | message: { ...turnInput.message, text: "/compact" }, |
| 3118 | }), |
| 3119 | startTurn: (turnInput) => |
| 3120 | Effect.gen(function* () { |
| 3121 | if (nativeStreamFailure !== null) { |
| 3122 | return yield* protocolError( |
| 3123 | "OpenCode event stream has ended; reconnect the provider session before starting another turn.", |
| 3124 | ); |
| 3125 | } |
| 3126 | const sessionId = nativeThreadId(turnInput.providerThread); |
| 3127 | const state = threads.get(sessionId); |
| 3128 | if (state === undefined) { |
| 3129 | return yield* protocolError(`OpenCode session ${sessionId} is not registered`); |
| 3130 | } |
| 3131 | if (state.activeTurn !== null) { |
| 3132 | return yield* protocolError( |
| 3133 | `OpenCode provider thread ${turnInput.providerThread.id} already has an active turn`, |
| 3134 | ); |
| 3135 | } |
| 3136 | const parsedModel = OpenCodeRuntime.parseOpenCodeModelSlug( |
| 3137 | turnInput.modelSelection.model, |
| 3138 | ); |
| 3139 | if (parsedModel === null) { |
| 3140 | return yield* protocolError( |
| 3141 | `OpenCode model '${turnInput.modelSelection.model}' must use provider/model format`, |
| 3142 | ); |
| 3143 | } |
| 3144 | const isCompaction = |
| 3145 | turnInput.message.text.trim() === "/compact" && |
| 3146 | turnInput.message.attachments.length === 0; |
| 3147 | const parts = isCompaction ? [] : resolvePromptParts(turnInput); |
| 3148 | const startedAt = yield* DateTime.now; |
| 3149 | const syntheticNativeTurnId = `${sessionId}:attempt:${turnInput.attemptId}`; |
| 3150 | const providerTurnId = idAllocator.derive.providerTurn({ |
| 3151 | driver: OPENCODE_PROVIDER, |
| 3152 | nativeTurnId: syntheticNativeTurnId, |
| 3153 | }); |
| 3154 | const providerTurn: OrchestrationV2ProviderTurn = { |
| 3155 | id: providerTurnId, |
| 3156 | providerThreadId: turnInput.providerThread.id, |
| 3157 | nodeId: turnInput.rootNodeId, |
| 3158 | runAttemptId: turnInput.attemptId, |
| 3159 | nativeTurnRef: providerRef(syntheticNativeTurnId, "weak"), |
| 3160 | ordinal: turnInput.providerTurnOrdinal, |
| 3161 | status: "running", |
| 3162 | startedAt, |
| 3163 | completedAt: null, |
| 3164 | }; |
| 3165 | const admissionMessageId = yield* makeOpenCodeMessageId(messageIdClock); |
| 3166 | // No Effect may be yielded between this check and installing the |
| 3167 | // turn. If the event stream ended while IDs were being prepared, |
| 3168 | // registering afterward would leave a running turn that the EOF |
| 3169 | // handler had already finished scanning. |
| 3170 | if (nativeStreamFailure !== null) { |
| 3171 | return yield* protocolError( |
| 3172 | "OpenCode event stream has ended; reconnect the provider session before starting another turn.", |
| 3173 | ); |
| 3174 | } |
| 3175 | if (state.activeTurn !== null) { |
| 3176 | return yield* protocolError( |
| 3177 | `OpenCode provider thread ${turnInput.providerThread.id} already has an active turn`, |
| 3178 | ); |
| 3179 | } |
| 3180 | const turn: ActiveOpenCodeTurn = { |
| 3181 | isRoot: true, |
| 3182 | threadId: turnInput.threadId, |
| 3183 | runId: turnInput.runId, |
| 3184 | rootNodeId: turnInput.rootNodeId, |
| 3185 | appThread: turnInput.appThread, |
| 3186 | modelSelection: turnInput.modelSelection, |
| 3187 | runtimePolicy: turnInput.runtimePolicy, |
| 3188 | providerTurnId, |
| 3189 | providerThreadId: turnInput.providerThread.id, |
| 3190 | providerTurnOrdinal: turnInput.providerTurnOrdinal, |
| 3191 | runOrdinal: turnInput.runOrdinal, |
| 3192 | runAttemptId: turnInput.attemptId, |
| 3193 | startedAt, |
| 3194 | itemOrdinals: new Map(), |
| 3195 | usage: makeOpenCodeTurnTokenUsageAccumulator(), |
| 3196 | parts: new Map(), |
| 3197 | partIdsByMessage: new Map(), |
| 3198 | toolNamesByCallId: new Map(), |
| 3199 | providerTurn, |
| 3200 | nextItemOrdinal: turnInput.providerTurnOrdinal * 100 + 1, |
| 3201 | nativeUserMessageId: null, |
| 3202 | admissionMessageId, |
| 3203 | interrupted: false, |
| 3204 | finalized: false, |
| 3205 | planId: null, |
| 3206 | admissionGeneration: state.nextAdmissionGeneration++, |
| 3207 | admissionReconciliationGeneration: null, |
| 3208 | admissionPending: !isCompaction, |
| 3209 | admissionAccepted: isCompaction, |
| 3210 | admissionMessageObserved: false, |
| 3211 | idleDuringAdmission: false, |
| 3212 | admissionSettled: Deferred.makeUnsafe<void>(), |
| 3213 | admissionAbortController: new AbortController(), |
| 3214 | }; |
| 3215 | if (turn.admissionMessageId !== null) |
| 3216 | turn.usage.promptMessageIds.add(turn.admissionMessageId); |
| 3217 | const admissionSettled = turn.admissionSettled; |
| 3218 | const admissionAbortController = turn.admissionAbortController; |
| 3219 | state.appThread = turnInput.appThread; |
| 3220 | state.activeTurn = turn; |
| 3221 | state.providerTurns.set(String(providerTurnId), providerTurn); |
| 3222 | yield* emitProviderTurn(state, turn, "running", null); |
| 3223 | yield* updateProviderThread(state, { |
| 3224 | status: "active", |
| 3225 | firstRunOrdinal: state.providerThread.firstRunOrdinal ?? turnInput.runOrdinal, |
| 3226 | lastRunOrdinal: turnInput.runOrdinal, |
| 3227 | }); |
| 3228 | yield* updateProviderSession("running", null); |
| 3229 | if (isCompaction) { |
| 3230 | yield* sdkCall( |
| 3231 | "session.summarize", |
| 3232 | { sessionID: sessionId, ...parsedModel, auto: false }, |
| 3233 | (signal) => |
| 3234 | client.session.summarize( |
| 3235 | { sessionID: sessionId, ...parsedModel, auto: false }, |
| 3236 | { signal: AbortSignal.any([signal, admissionAbortController!.signal]) }, |
| 3237 | ), |
| 3238 | ).pipe( |
| 3239 | Effect.tap(() => |
| 3240 | turn.interrupted || turn.finalized || nativeStreamFailure !== null |
| 3241 | ? Effect.void |
| 3242 | : emitCompactionItem(state, turn), |
| 3243 | ), |
| 3244 | Effect.tap(() => |
| 3245 | finalizeTurn(state, turn, turn.interrupted ? "interrupted" : "completed"), |
| 3246 | ), |
| 3247 | Effect.tapError((cause) => |
| 3248 | finalizeTurn(state, turn, turn.interrupted ? "interrupted" : "failed", { |
| 3249 | failure: makeProviderFailure({ cause, class: "provider_error" }), |
| 3250 | }), |
| 3251 | ), |
| 3252 | Effect.ensuring(Deferred.succeed(admissionSettled, undefined)), |
| 3253 | ); |
| 3254 | return; |
| 3255 | } |
| 3256 | const systemPrompt = [ |
| 3257 | orchestrationSystemPrompt, |
| 3258 | buildRuntimeInstructions({ |
| 3259 | harness: "OpenCode", |
| 3260 | model: turnInput.modelSelection.model, |
| 3261 | }), |
| 3262 | ] |
| 3263 | .filter(Boolean) |
| 3264 | .join("\n\n"); |
| 3265 | const agent = |
| 3266 | getModelSelectionStringOptionValue(turnInput.modelSelection, "agent") ?? |
| 3267 | (turnInput.runtimePolicy.interactionMode === "plan" ? "plan" : undefined); |
| 3268 | const variant = getModelSelectionStringOptionValue( |
| 3269 | turnInput.modelSelection, |
| 3270 | "variant", |
| 3271 | ); |
| 3272 | yield* submitPrompt( |
| 3273 | state, |
| 3274 | turn, |
| 3275 | { |
| 3276 | sessionID: sessionId, |
| 3277 | messageID: turn.admissionMessageId!, |
| 3278 | model: parsedModel, |
| 3279 | ...(agent === undefined ? {} : { agent }), |
| 3280 | ...(variant === undefined ? {} : { variant }), |
| 3281 | system: systemPrompt, |
| 3282 | parts, |
| 3283 | }, |
| 3284 | admissionAbortController!, |
| 3285 | ).pipe( |
| 3286 | Effect.tapError((cause) => |
| 3287 | admissionAbortController!.signal.aborted |
| 3288 | ? Effect.void |
| 3289 | : finalizeTurn(state, turn, "failed", { |
| 3290 | failure: makeProviderFailure({ cause, class: "provider_error" }), |
| 3291 | }), |
| 3292 | ), |
| 3293 | Effect.catch((cause) => |
| 3294 | admissionAbortController!.signal.aborted ? Effect.void : Effect.fail(cause), |
| 3295 | ), |
| 3296 | Effect.ensuring( |
| 3297 | Effect.all([ |
| 3298 | Deferred.succeed(admissionSettled, undefined).pipe(Effect.ignore), |
| 3299 | Effect.sync(() => { |
| 3300 | if (turn.admissionAbortController === admissionAbortController) { |
| 3301 | turn.admissionAbortController = null; |
| 3302 | } |
| 3303 | }), |
| 3304 | ]).pipe(Effect.asVoid), |
| 3305 | ), |
| 3306 | ); |
| 3307 | if (state.activeTurn === turn && !turn.finalized && !turn.interrupted) { |
| 3308 | const admissionAction = advanceOpenCodePromptAdmission(turn, "accepted"); |
| 3309 | if (admissionAction === "reconcile-idle") { |
| 3310 | yield* reconcilePromptAdmission(state, turn); |
| 3311 | } |
| 3312 | } |
| 3313 | }).pipe( |
| 3314 | Effect.mapError( |
| 3315 | (cause) => |
| 3316 | new ProviderAdapter.ProviderAdapterTurnStartError({ |
| 3317 | driver: OPENCODE_PROVIDER, |
| 3318 | threadId: turnInput.threadId, |
| 3319 | providerThreadId: turnInput.providerThread.id, |
| 3320 | runId: turnInput.runId, |
| 3321 | cause, |
| 3322 | }), |
| 3323 | ), |
| 3324 | ), |
| 3325 | steerTurn: (steerInput) => |
| 3326 | Effect.gen(function* () { |
| 3327 | const sessionId = nativeThreadId(steerInput.providerThread); |
| 3328 | const state = threads.get(sessionId); |
| 3329 | const turn = state?.activeTurn; |
| 3330 | if ( |
| 3331 | state === undefined || |
| 3332 | turn === undefined || |
| 3333 | turn === null || |
| 3334 | turn.providerTurnId !== steerInput.providerTurnId || |
| 3335 | turn.interrupted |
| 3336 | ) { |
| 3337 | return yield* protocolError( |
| 3338 | `OpenCode turn ${steerInput.providerTurnId} is not active`, |
| 3339 | ); |
| 3340 | } |
| 3341 | const parsedModel = OpenCodeRuntime.parseOpenCodeModelSlug(turn.modelSelection.model); |
| 3342 | if (parsedModel === null) { |
| 3343 | return yield* protocolError( |
| 3344 | `OpenCode model '${turn.modelSelection.model}' must use provider/model format`, |
| 3345 | ); |
| 3346 | } |
| 3347 | const text = providerMessageTextWithAttachmentPaths({ |
| 3348 | text: steerInput.message.text, |
| 3349 | attachments: steerInput.message.attachments, |
| 3350 | resolveAttachmentPath: host.resolveAttachmentPath, |
| 3351 | }).trim(); |
| 3352 | const files = OpenCodeRuntime.toOpenCodeFileParts({ |
| 3353 | attachments: steerInput.message.attachments, |
| 3354 | resolveAttachmentPath: host.resolveAttachmentPath, |
| 3355 | }); |
| 3356 | if (text.length === 0 && files.length === 0) { |
| 3357 | return yield* protocolError("OpenCode steering requires text or an attachment"); |
| 3358 | } |
| 3359 | const parts = [ |
| 3360 | ...(text.length === 0 ? [] : [{ type: "text" as const, text }]), |
| 3361 | ...files, |
| 3362 | ]; |
| 3363 | turn.admissionGeneration = state.nextAdmissionGeneration++; |
| 3364 | turn.admissionMessageId = yield* makeOpenCodeMessageId(messageIdClock); |
| 3365 | turn.admissionPending = true; |
| 3366 | turn.admissionAccepted = false; |
| 3367 | turn.admissionMessageObserved = false; |
| 3368 | turn.idleDuringAdmission = false; |
| 3369 | turn.admissionSettled = Deferred.makeUnsafe<void>(); |
| 3370 | turn.admissionAbortController = new AbortController(); |
| 3371 | if (turn.admissionMessageId !== null) |
| 3372 | turn.usage.promptMessageIds.add(turn.admissionMessageId); |
| 3373 | const admissionSettled = turn.admissionSettled; |
| 3374 | const admissionAbortController = turn.admissionAbortController; |
| 3375 | yield* submitPrompt( |
| 3376 | state, |
| 3377 | turn, |
| 3378 | { |
| 3379 | sessionID: sessionId, |
| 3380 | messageID: turn.admissionMessageId, |
| 3381 | model: parsedModel, |
| 3382 | parts, |
| 3383 | }, |
| 3384 | admissionAbortController!, |
| 3385 | ).pipe( |
| 3386 | Effect.ensuring( |
| 3387 | Effect.all([ |
| 3388 | Deferred.succeed(admissionSettled, undefined).pipe(Effect.ignore), |
| 3389 | Effect.sync(() => { |
| 3390 | if (turn.admissionAbortController === admissionAbortController) { |
| 3391 | turn.admissionAbortController = null; |
| 3392 | } |
| 3393 | }), |
| 3394 | ]).pipe(Effect.asVoid), |
| 3395 | ), |
| 3396 | ); |
| 3397 | if (state.activeTurn === turn && !turn.finalized) { |
| 3398 | const admissionAction = advanceOpenCodePromptAdmission(turn, "accepted"); |
| 3399 | if (admissionAction === "reconcile-idle") { |
| 3400 | yield* reconcilePromptAdmission(state, turn); |
| 3401 | } |
| 3402 | } |
| 3403 | }).pipe( |
| 3404 | Effect.mapError( |
| 3405 | (cause) => |
| 3406 | new ProviderAdapter.ProviderAdapterSteerRunError({ |
| 3407 | driver: OPENCODE_PROVIDER, |
| 3408 | providerThreadId: steerInput.providerThread.id, |
| 3409 | providerTurnId: steerInput.providerTurnId, |
| 3410 | cause, |
| 3411 | }), |
| 3412 | ), |
| 3413 | ), |
| 3414 | interruptTurn: (interruptInput) => |
| 3415 | Effect.gen(function* () { |
| 3416 | const sessionId = nativeThreadId(interruptInput.providerThread); |
| 3417 | const state = threads.get(sessionId); |
| 3418 | const turn = state?.activeTurn; |
| 3419 | // Stop on a settled turn: a background task child can outlive |
| 3420 | // it (busySessionIds), and aborting the descendants ends it. |
| 3421 | if ( |
| 3422 | (turn === undefined || turn === null) && |
| 3423 | interruptInput.requestRuntimeRestart === true |
| 3424 | ) { |
| 3425 | for (const controller of commandControllers.get(sessionId) ?? []) |
| 3426 | controller.abort(); |
| 3427 | yield* abortDescendants(sessionId); |
| 3428 | return; |
| 3429 | } |
| 3430 | if ( |
| 3431 | turn === undefined || |
| 3432 | turn === null || |
| 3433 | turn.providerTurnId !== interruptInput.providerTurnId |
| 3434 | ) { |
| 3435 | return yield* protocolError( |
| 3436 | `OpenCode turn ${interruptInput.providerTurnId} is not active`, |
| 3437 | ); |
| 3438 | } |
| 3439 | turn.interrupted = true; |
| 3440 | for (const controller of commandControllers.get(sessionId) ?? []) controller.abort(); |
| 3441 | const admissionWasPending = turn.admissionPending; |
| 3442 | cancelOpenCodePromptAdmission(turn, state!.nextAdmissionGeneration++); |
| 3443 | turn.admissionAbortController?.abort(); |
| 3444 | if (admissionWasPending) { |
| 3445 | // Settle the cancelled request before sending the session abort. |
| 3446 | // A timed-out local request must not prevent the definitive |
| 3447 | // server-side abort below from running. |
| 3448 | yield* Deferred.await(turn.admissionSettled).pipe( |
| 3449 | Effect.timeout("10 seconds"), |
| 3450 | Effect.ignore, |
| 3451 | ); |
| 3452 | } |
| 3453 | yield* sdkCall("session.abort", { sessionID: sessionId }, (signal) => |
| 3454 | client.session.abort({ sessionID: sessionId }, { signal }), |
| 3455 | ).pipe( |
| 3456 | Effect.timeout("10 seconds"), |
| 3457 | // The turn can settle while the abort is in flight, and |
| 3458 | // aborting an already-idle session fails. That stop still |
| 3459 | // succeeded; only surface failures for a turn that is |
| 3460 | // genuinely still running. |
| 3461 | Effect.catch((cause) => |
| 3462 | turn.finalized |
| 3463 | ? Effect.void |
| 3464 | : Effect.suspend(() => { |
| 3465 | turn.interrupted = false; |
| 3466 | return Effect.fail(cause); |
| 3467 | }), |
| 3468 | ), |
| 3469 | ); |
| 3470 | // Root abort does not stop child sessions. Report incomplete cleanup |
| 3471 | // even if the root has already emitted its terminal event. |
| 3472 | yield* abortDescendants(sessionId); |
| 3473 | }).pipe( |
| 3474 | Effect.mapError( |
| 3475 | (cause) => |
| 3476 | new ProviderAdapter.ProviderAdapterInterruptError({ |
| 3477 | driver: OPENCODE_PROVIDER, |
| 3478 | providerThreadId: interruptInput.providerThread.id, |
| 3479 | providerTurnId: interruptInput.providerTurnId, |
| 3480 | cause, |
| 3481 | }), |
| 3482 | ), |
| 3483 | ), |
| 3484 | respondToRuntimeRequest: (requestInput) => |
| 3485 | Effect.gen(function* () { |
| 3486 | const pending = pendingRequests.get(String(requestInput.requestId)); |
| 3487 | if (pending === undefined) { |
| 3488 | return yield* protocolError( |
| 3489 | `No pending OpenCode request ${requestInput.requestId}`, |
| 3490 | ); |
| 3491 | } |
| 3492 | if (pending.question !== undefined) { |
| 3493 | if (requestInput.answers === undefined) { |
| 3494 | return yield* protocolError( |
| 3495 | `OpenCode question request ${requestInput.requestId} requires answers`, |
| 3496 | ); |
| 3497 | } |
| 3498 | const answers = OpenCodeRuntime.toOpenCodeQuestionAnswers( |
| 3499 | pending.question, |
| 3500 | requestInput.answers, |
| 3501 | ); |
| 3502 | yield* sdkCall( |
| 3503 | "question.reply", |
| 3504 | { requestID: pending.nativeRequestId, answers }, |
| 3505 | (signal) => |
| 3506 | client.question.reply( |
| 3507 | { |
| 3508 | requestID: pending.nativeRequestId, |
| 3509 | answers, |
| 3510 | }, |
| 3511 | { signal }, |
| 3512 | ), |
| 3513 | ).pipe(Effect.timeout("10 seconds")); |
| 3514 | return; |
| 3515 | } |
| 3516 | if (requestInput.decision === undefined) { |
| 3517 | return yield* protocolError( |
| 3518 | `OpenCode approval request ${requestInput.requestId} requires a decision`, |
| 3519 | ); |
| 3520 | } |
| 3521 | const reply = OpenCodeRuntime.toOpenCodePermissionReply(requestInput.decision); |
| 3522 | yield* sdkCall( |
| 3523 | "permission.reply", |
| 3524 | { requestID: pending.nativeRequestId, reply }, |
| 3525 | (signal) => |
| 3526 | client.permission.reply( |
| 3527 | { |
| 3528 | requestID: pending.nativeRequestId, |
| 3529 | reply, |
| 3530 | }, |
| 3531 | { signal }, |
| 3532 | ), |
| 3533 | ).pipe(Effect.timeout("10 seconds")); |
| 3534 | }).pipe( |
| 3535 | Effect.mapError( |
| 3536 | (cause) => |
| 3537 | new ProviderAdapter.ProviderAdapterRuntimeRequestResponseError({ |
| 3538 | driver: OPENCODE_PROVIDER, |
| 3539 | requestId: requestInput.requestId, |
| 3540 | cause, |
| 3541 | }), |
| 3542 | ), |
| 3543 | ), |
| 3544 | readThreadSnapshot: (snapshotInput) => |
| 3545 | readSnapshot(snapshotInput.providerThread).pipe( |
| 3546 | Effect.mapError( |
| 3547 | (cause) => |
| 3548 | new ProviderAdapter.ProviderAdapterReadThreadSnapshotError({ |
| 3549 | driver: OPENCODE_PROVIDER, |
| 3550 | providerThreadId: snapshotInput.providerThread.id, |
| 3551 | cause, |
| 3552 | }), |
| 3553 | ), |
| 3554 | ), |
| 3555 | rollbackThread: (rollbackInput) => |
| 3556 | Effect.gen(function* () { |
| 3557 | const sessionId = nativeThreadId(rollbackInput.providerThread); |
| 3558 | const state = threads.get(sessionId); |
| 3559 | if (state?.activeTurn !== null && state?.activeTurn !== undefined) { |
| 3560 | return yield* protocolError( |
| 3561 | `Cannot roll back OpenCode thread ${rollbackInput.providerThread.id} while a turn is active`, |
| 3562 | ); |
| 3563 | } |
| 3564 | const response = yield* sdkCall("session.messages", { sessionID: sessionId }, () => |
| 3565 | client.session.messages({ sessionID: sessionId }), |
| 3566 | ); |
| 3567 | const messages = unwrapData("session.messages", response); |
| 3568 | let boundaryMessageId: string | undefined; |
| 3569 | if (rollbackInput.target.type === "thread_start") { |
| 3570 | boundaryMessageId = messages.find(({ info }) => info.role === "user")?.info.id; |
| 3571 | } else { |
| 3572 | boundaryMessageId = openCodeBoundaryAfterProviderTurn( |
| 3573 | rollbackInput.providerThreadTurns, |
| 3574 | rollbackInput.target.providerTurn.id, |
| 3575 | ); |
| 3576 | } |
| 3577 | let retainedThread = rollbackInput.providerThread; |
| 3578 | if (boundaryMessageId !== undefined) { |
| 3579 | const boundaryIndex = messages.findIndex( |
| 3580 | ({ info }) => info.id === boundaryMessageId, |
| 3581 | ); |
| 3582 | if (boundaryIndex < 0) |
| 3583 | return yield* protocolError( |
| 3584 | "The OpenCode rewind boundary is no longer available.", |
| 3585 | ); |
| 3586 | const fork = unwrapData( |
| 3587 | "session.fork", |
| 3588 | yield* sdkCall( |
| 3589 | "session.fork", |
| 3590 | { sessionID: sessionId, messageID: boundaryMessageId }, |
| 3591 | () => |
| 3592 | client.session.fork({ sessionID: sessionId, messageID: boundaryMessageId }), |
| 3593 | ), |
| 3594 | ); |
| 3595 | const retained = unwrapData( |
| 3596 | "session.messages", |
| 3597 | yield* sdkCall("session.messages", { sessionID: fork.id }, () => |
| 3598 | client.session.messages({ sessionID: fork.id }), |
| 3599 | ), |
| 3600 | ); |
| 3601 | if (retained.length !== boundaryIndex) |
| 3602 | return yield* protocolError( |
| 3603 | "OpenCode did not preserve the requested rewind boundary.", |
| 3604 | ); |
| 3605 | yield* sdkCall("session.update", { sessionID: fork.id }, () => |
| 3606 | client.session.update({ |
| 3607 | sessionID: fork.id, |
| 3608 | permission: openCodePermissionRules(input.runtimePolicy), |
| 3609 | }), |
| 3610 | ); |
| 3611 | retainedThread = { |
| 3612 | ...rollbackInput.providerThread, |
| 3613 | nativeThreadRef: providerRef(fork.id), |
| 3614 | }; |
| 3615 | registerThread(fork, retainedThread, state?.appThread ?? null); |
| 3616 | } |
| 3617 | const snapshot = yield* readSnapshot(retainedThread); |
| 3618 | return { |
| 3619 | ...snapshot, |
| 3620 | providerThread: { |
| 3621 | ...snapshot.providerThread, |
| 3622 | nativeConversationHeadRef: |
| 3623 | rollbackInput.target.type === "provider_turn" |
| 3624 | ? rollbackInput.target.providerTurn.nativeTurnRef |
| 3625 | : null, |
| 3626 | }, |
| 3627 | }; |
| 3628 | }).pipe( |
| 3629 | Effect.mapError( |
| 3630 | (cause) => |
| 3631 | new ProviderAdapter.ProviderAdapterRollbackThreadError({ |
| 3632 | driver: OPENCODE_PROVIDER, |
| 3633 | providerThreadId: rollbackInput.providerThread.id, |
| 3634 | checkpointId: rollbackInput.target.checkpointId, |
| 3635 | cause, |
| 3636 | }), |
| 3637 | ), |
| 3638 | ), |
| 3639 | forkThread: (forkInput) => |
| 3640 | Effect.gen(function* () { |
| 3641 | const sourceSessionId = nativeThreadId(forkInput.sourceProviderThread); |
| 3642 | const sourceState = threads.get(sourceSessionId); |
| 3643 | if (sourceState?.activeTurn !== null && sourceState?.activeTurn !== undefined) { |
| 3644 | return yield* protocolError( |
| 3645 | `Cannot fork OpenCode thread ${forkInput.sourceProviderThread.id} while a turn is active`, |
| 3646 | ); |
| 3647 | } |
| 3648 | let boundaryMessageId: string | undefined; |
| 3649 | if (forkInput.providerTurnId !== undefined) { |
| 3650 | const sourceTurns = forkInput.sourceProviderTurns ?? []; |
| 3651 | const selected = sourceTurns.find((turn) => turn.id === forkInput.providerTurnId); |
| 3652 | if (selected === undefined) { |
| 3653 | return yield* protocolError( |
| 3654 | `OpenCode fork boundary turn ${forkInput.providerTurnId} was not found`, |
| 3655 | ); |
| 3656 | } |
| 3657 | boundaryMessageId = openCodeBoundaryAfterProviderTurn(sourceTurns, selected.id); |
| 3658 | } |
| 3659 | const response = yield* sdkCall( |
| 3660 | "session.fork", |
| 3661 | { |
| 3662 | sessionID: sourceSessionId, |
| 3663 | ...(boundaryMessageId === undefined ? {} : { messageID: boundaryMessageId }), |
| 3664 | }, |
| 3665 | () => |
| 3666 | client.session.fork({ |
| 3667 | sessionID: sourceSessionId, |
| 3668 | ...(boundaryMessageId === undefined ? {} : { messageID: boundaryMessageId }), |
| 3669 | }), |
| 3670 | ); |
| 3671 | const nativeSession = unwrapData("session.fork", response); |
| 3672 | const forkedAt = yield* DateTime.now; |
| 3673 | const providerThread = makeProviderThread({ |
| 3674 | idAllocator, |
| 3675 | providerInstanceId: options.instanceId, |
| 3676 | providerSessionId: input.providerSessionId, |
| 3677 | appThreadId: forkInput.targetThreadId, |
| 3678 | ...(forkInput.ownerNodeId === undefined |
| 3679 | ? {} |
| 3680 | : { ownerNodeId: forkInput.ownerNodeId }), |
| 3681 | nativeSession, |
| 3682 | forkedFrom: { |
| 3683 | providerThreadId: forkInput.sourceProviderThread.id, |
| 3684 | ...(forkInput.providerTurnId === undefined |
| 3685 | ? {} |
| 3686 | : { providerTurnId: forkInput.providerTurnId }), |
| 3687 | }, |
| 3688 | now: forkedAt, |
| 3689 | }); |
| 3690 | registerThread(nativeSession, providerThread, null); |
| 3691 | return providerThread; |
| 3692 | }).pipe( |
| 3693 | Effect.mapError( |
| 3694 | (cause) => |
| 3695 | new ProviderAdapter.ProviderAdapterForkThreadError({ |
| 3696 | driver: OPENCODE_PROVIDER, |
| 3697 | providerThreadId: forkInput.sourceProviderThread.id, |
| 3698 | cause, |
| 3699 | }), |
| 3700 | ), |
| 3701 | ), |
| 3702 | }; |
| 3703 | |
| 3704 | return runtimeSession; |
| 3705 | }, |
| 3706 | (effect, input) => |
| 3707 | effect.pipe( |
| 3708 | Effect.mapError( |
| 3709 | (cause) => |
| 3710 | new ProviderAdapter.ProviderAdapterOpenSessionError({ |
| 3711 | driver: OPENCODE_PROVIDER, |
| 3712 | providerSessionId: input.providerSessionId, |
| 3713 | cause, |
| 3714 | }), |
| 3715 | ), |
| 3716 | ), |
| 3717 | ), |
| 3718 | }); |
| 3719 | }); |
| 3720 | |
| 3721 | export type OpenCodeAdapterV2DriverEnv = |
| 3722 | | OpenCodeRuntime.OpenCodeRuntime |
| 3723 | | IdAllocator.IdAllocatorV2 |
| 3724 | | McpProviderSessions.McpProviderSessions |
| 3725 | | ProviderEventLoggers.ProviderEventLoggers |
| 3726 | | ProviderHost.ProviderHost; |
| 3727 | |
| 3728 | export const OpenCodeAdapterV2Driver: ProviderAdapterDriver< |
| 3729 | OpenCodeSettings, |
| 3730 | OpenCodeAdapterV2DriverEnv |
| 3731 | > = { |
| 3732 | driverKind: OPENCODE_PROVIDER, |
| 3733 | configSchema: OpenCodeSettings, |
| 3734 | defaultConfig: (): OpenCodeSettings => DEFAULT_OPENCODE_SETTINGS, |
| 3735 | create: Effect.fn("OpenCodeAdapterV2Driver.create")( |
| 3736 | function* (input: ProviderAdapterDriverCreateInput<OpenCodeSettings>) { |
| 3737 | const hostEnvironment = yield* HostProcess.Environment; |
| 3738 | const providerEventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers; |
| 3739 | return yield* makeOpenCodeAdapterV2({ |
| 3740 | instanceId: input.instanceId, |
| 3741 | settings: { ...input.config, enabled: input.enabled }, |
| 3742 | environment: yield* mergeProviderInstanceEnvironment(input.environment, hostEnvironment), |
| 3743 | ...(providerEventLoggers.native === undefined |
| 3744 | ? {} |
| 3745 | : { nativeEventLogger: providerEventLoggers.native }), |
| 3746 | }); |
| 3747 | }, |
| 3748 | (effect, input) => |
| 3749 | effect.pipe( |
| 3750 | Effect.mapError( |
| 3751 | (cause) => |
| 3752 | new ProviderAdapterDriverCreateError({ |
| 3753 | driver: OPENCODE_PROVIDER, |
| 3754 | instanceId: input.instanceId, |
| 3755 | detail: "Failed to create OpenCode v2 adapter.", |
| 3756 | cause, |
| 3757 | }), |
| 3758 | ), |
| 3759 | ), |
| 3760 | ), |
| 3761 | }; |
| 3762 | |
| 3763 | const layer: Layer.Layer<ProviderAdapter.ProviderAdapterV2, never, OpenCodeAdapterV2DriverEnv> = |
| 3764 | Layer.effect( |
| 3765 | ProviderAdapter.ProviderAdapterV2, |
| 3766 | Effect.gen(function* () { |
| 3767 | const hostEnvironment = yield* HostProcess.Environment; |
| 3768 | const providerEventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers; |
| 3769 | return yield* makeOpenCodeAdapterV2({ |
| 3770 | instanceId: OPENCODE_DEFAULT_INSTANCE_ID, |
| 3771 | settings: DEFAULT_OPENCODE_SETTINGS, |
| 3772 | environment: hostEnvironment, |
| 3773 | ...(providerEventLoggers.native === undefined |
| 3774 | ? {} |
| 3775 | : { nativeEventLogger: providerEventLoggers.native }), |
| 3776 | }); |
| 3777 | }), |
| 3778 | ); |