From 2f2bc1de61e0187d7d8e305c1032e7fe78952c74 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 29 Sep 2026 07:40:20 -0700 Subject: [PATCH 1/9] feat(server): run turns, text, reasoning and tools on OpenCode 2 Adds the OpenCode 2 runtime behind the `opencode` driver. The driver builds the instance's 2.x server (spawned with a T3 password or reached at `serverUrl`) and routes a detected 2.x to the new adapter instead of refusing it. - Sessions and threads: `session.create` with Full access rules; resume by `session.get`, so 1.x session ids continue natively and a server without the session fails the resume into a handoff. - Turns: `session.prompt`; `session.execution.*` ends the turn; interrupt through `session.interrupt`. Text and reasoning stream from `text.*` and `reasoning.*`; `tool.*` events become tool items through a helper now shared with the 1.x adapter. - Events: one `/api/event` stream per session runtime, decoded frame by frame so an event a newer server adds is skipped instead of failing it. - A session holds the spawned server for as long as it is open, so a long tool call is not idle-stopped mid-turn. - The 2.x snapshot offers Full access only. Approvals, questions, subagents, steering, fork, rollback and compaction report unsupported; a permission or form that still arrives is refused so the turn cannot hang. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/server/package.json | 1 + .../Adapters/OpenCode2AdapterV2.testkit.ts | 164 ++++ .../Adapters/OpenCode2AdapterV2.ts | 891 ++++++++++++++++++ .../Adapters/OpenCodeAdapterV2.testkit.ts | 17 +- .../Adapters/OpenCodeAdapterV2.ts | 137 +-- .../Adapters/OpenCodeToolItems.ts | 143 +++ .../OpenCode2OrchestratorV2.live.test.ts | 256 +++++ ...estratorReplayFixtures.integration.test.ts | 18 +- .../testkit/fixtures/index.ts | 50 + .../fixtures/opencode2_interrupt/input.ts | 11 + .../opencode_transcript.ndjson | 32 + .../fixtures/opencode2_interrupt/output.ts | 47 + .../fixtures/opencode2_simple/input.ts | 5 + .../opencode_transcript.ndjson | 49 + .../fixtures/opencode2_simple/output.ts | 41 + .../fixtures/opencode2_tool_call/input.ts | 5 + .../opencode_transcript.ndjson | 40 + .../fixtures/opencode2_tool_call/output.ts | 46 + .../testkit/fixtures/shared.ts | 13 + .../provider/Drivers/OpenCodeDriver.test.ts | 15 +- .../src/provider/Drivers/OpenCodeDriver.ts | 149 +-- .../provider/Layers/OpenCodeProvider.test.ts | 14 +- .../src/provider/Layers/OpenCodeProvider.ts | 19 +- .../opencode2/OpenCode2Client.events.test.ts | 104 ++ .../opencode2/OpenCode2Client.live.test.ts | 4 +- .../opencode2/OpenCode2Client.test.ts | 2 +- .../src/provider/opencode2/OpenCode2Client.ts | 75 +- .../opencode2/OpenCode2Server.test.ts | 2 +- .../src/provider/opencode2/OpenCode2Server.ts | 9 +- .../src/provider/opencodeVersionProbe.ts | 4 - pnpm-lock.yaml | 3 + 31 files changed, 2118 insertions(+), 248 deletions(-) create mode 100644 apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts create mode 100644 apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts create mode 100644 apps/server/src/orchestration-v2/Adapters/OpenCodeToolItems.ts create mode 100644 apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_interrupt/input.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_interrupt/opencode_transcript.ndjson create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_interrupt/output.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_simple/input.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_simple/opencode_transcript.ndjson create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_simple/output.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_tool_call/input.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_tool_call/opencode_transcript.ndjson create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_tool_call/output.ts create mode 100644 apps/server/src/provider/opencode2/OpenCode2Client.events.test.ts diff --git a/apps/server/package.json b/apps/server/package.json index 0d6a0a8ffce1..fc8b83cbc68a 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -36,6 +36,7 @@ "@napi-rs/keyring": "^1.3.0", "@opencode-ai/sdk": "^1.3.15", "@opencode/client": "2.0.18", + "@opencode/protocol": "2.0.18", "diff": "8.0.3", "effect": "catalog:", "jose": "catalog:", diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts new file mode 100644 index 000000000000..4617bf4093cb --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts @@ -0,0 +1,164 @@ +/** + * Replays an OpenCode 2 transcript at the HTTP boundary: the real + * `@opencode/client` and the adapter's event reader run against an + * `HttpClient` that answers from the transcript, so request encoding and + * response decoding are both exercised. + */ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { + ProviderInstanceId, + ProviderReplayEntry, + type ProviderReplayTranscript, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientError from "effect/unstable/http/HttpClientError"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; +import * as UrlParams from "effect/unstable/http/UrlParams"; + +import { ServerConfig } from "../../config.ts"; +import * as OpenCode2Client from "../../provider/opencode2/OpenCode2Client.ts"; +import { layer as idAllocatorLayer, IdAllocatorV2 } from "../IdAllocator.ts"; +import { makeLayer } from "../ProviderAdapterRegistry.ts"; +import { + makeReplayServerConfig, + type OrchestratorV2ProviderReplayHarness, +} from "../testkit/ProviderReplayHarness.ts"; +import { OPENCODE_PROVIDER } from "./OpenCodeAdapterV2.ts"; +import { + OpenCodeReplayController, + OpenCodeReplayTranscriptDecodeError, +} from "./OpenCodeAdapterV2.testkit.ts"; +import { makeOpenCode2Adapter } from "./OpenCode2AdapterV2.ts"; + +export const OPENCODE2_HTTP_PROTOCOL = "opencode2-http.sse" as const; +const BASE_URL = "http://opencode2.replay"; +const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + +const OpenCode2ReplayTranscript = Schema.Struct({ + provider: Schema.Literal(OPENCODE_PROVIDER), + protocol: Schema.Literal(OPENCODE2_HTTP_PROTOCOL), + version: Schema.String, + scenario: Schema.String, + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), + entries: Schema.Array(ProviderReplayEntry), +}); +type OpenCode2ReplayTranscript = typeof OpenCode2ReplayTranscript.Type; +const decodeOpenCode2ReplayTranscript = Schema.decodeUnknownEffect(OpenCode2ReplayTranscript); + +/** Names a request the way the recordings do: the client operation and its JSON input. */ +const operationOf = ( + method: string, + path: string, + query: Record, + body: unknown, +) => { + const session = /^\/api\/session\/([^/]+)(\/.*)?$/.exec(path); + if (method === "GET" && path === "/api/event") return { type: "event.subscribe" }; + if (method === "GET" && path === "/api/model") return { type: "model.list", input: query }; + if (method === "POST" && path === "/api/session") return { type: "session.create", input: body }; + if (session !== null) { + const [, sessionID, rest = ""] = session; + const input = { sessionID, ...query, ...(body === undefined ? {} : (body as object)) }; + if (method === "GET" && rest === "") return { type: "session.get", input }; + if (method === "POST" && rest === "/prompt") return { type: "session.prompt", input }; + if (method === "POST" && rest === "/interrupt") return { type: "session.interrupt", input }; + if (method === "GET" && rest === "/message") return { type: "message.list", input }; + } + return { type: `${method} ${path}`, input: { ...query, body } }; +}; + +/** An `HttpClient` that answers every request from the transcript. */ +const replayHttpClient = (controller: OpenCodeReplayController) => + HttpClient.make((request, url) => + Effect.tryPromise({ + try: async () => { + const raw = + request.body._tag === "Uint8Array" + ? new TextDecoder().decode(request.body.body) + : undefined; + const query = UrlParams.toRecord(UrlParams.fromInput(url.searchParams)); + const operation = operationOf( + request.method, + url.pathname, + query, + raw === undefined ? undefined : decodeJson(raw), + ); + await controller.expectOutbound(operation); + if (operation.type === "event.subscribe") { + const encoder = new TextEncoder(); + const frames = controller.events()[Symbol.asyncIterator](); + const body = new ReadableStream({ + async pull(stream) { + const next = await frames.next(); + if (next.done === true) stream.close(); + else stream.enqueue(encoder.encode(`data: ${encodeJson(next.value)}\n\n`)); + }, + }); + return new Response(body, { headers: { "content-type": "text/event-stream" } }); + } + // Recorded responses are the raw HTTP bodies; `null` is an empty 204. + const body = await controller.response(operation.type); + return body === null + ? new Response(null, { status: 204 }) + : new Response(encodeJson(body), { headers: { "content-type": "application/json" } }); + }, + catch: (cause) => + new HttpClientError.HttpClientError({ + reason: new HttpClientError.TransportError({ request, cause }), + }), + }).pipe(Effect.map((response) => HttpClientResponse.fromWeb(request, response))), + ); + +function makeRegistryLayer(transcript: OpenCode2ReplayTranscript) { + const controller = new OpenCodeReplayController(transcript); + const serverConfigLayer = Layer.effect( + ServerConfig, + makeReplayServerConfig(transcript.scenario).pipe(Effect.orDie), + ).pipe(Layer.provide(NodeServices.layer)); + return Layer.unwrap( + Effect.gen(function* () { + yield* Effect.addFinalizer(() => Effect.sync(() => controller.assertComplete())); + const opencode = yield* OpenCode2Client.make.pipe( + Effect.provideService(HttpClient.HttpClient, replayHttpClient(controller)), + ); + const connection = { + ...(yield* opencode.connect({ baseUrl: BASE_URL, password: "replay" })), + url: BASE_URL, + version: transcript.version, + external: true, + }; + return makeLayer([ + makeOpenCode2Adapter({ + instanceId: ProviderInstanceId.make("opencode"), + server: { withConnection: (use) => use(connection) }, + idAllocator: yield* IdAllocatorV2, + serverConfig: yield* ServerConfig, + }), + ]); + }), + ).pipe(Layer.provide(Layer.mergeAll(serverConfigLayer, idAllocatorLayer))); +} + +export const OpenCode2OrchestratorReplayHarness: OrchestratorV2ProviderReplayHarness< + OpenCode2ReplayTranscript, + OpenCodeReplayTranscriptDecodeError +> = { + driver: OPENCODE_PROVIDER, + decodeTranscript: (transcript: ProviderReplayTranscript) => + decodeOpenCode2ReplayTranscript(transcript).pipe( + Effect.mapError( + (cause) => + new OpenCodeReplayTranscriptDecodeError({ + driver: transcript.provider, + protocol: transcript.protocol, + scenario: transcript.scenario, + cause, + }), + ), + ), + makeProviderAdapterRegistryLayer: makeRegistryLayer, +}; diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts new file mode 100644 index 000000000000..e8282e170a51 --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts @@ -0,0 +1,891 @@ +/** + * The OpenCode 2 runtime behind the `opencode` driver. It talks to the + * instance's `opencode serve` process through the HTTP client and reads that + * server's `/api/event` stream, routed here by session id. + * + * A turn is one `session.prompt`; the session's next `session.execution.*` + * terminal ends it. Approvals, questions, subagents, steering, fork, rollback + * and compaction arrive in later layers, so the capabilities below say no and + * a permission or form that still reaches a session is refused so the turn + * cannot hang. + * + * @module orchestration-v2/Adapters/OpenCode2AdapterV2 + */ +import { + AbsolutePath, + Location, + Model, + Provider, + Session, + type OpenCodeEvent, +} from "@opencode/client/effect"; +import type { + OrchestrationV2ConversationMessage, + OrchestrationV2ExecutionNode, + OrchestrationV2ProviderCapabilities, + OrchestrationV2ProviderSession, + OrchestrationV2ProviderThread, + OrchestrationV2ProviderTurn, + OrchestrationV2TurnItem, + ProviderInstanceId, +} from "@t3tools/contracts"; +import type * as Cause from "effect/Cause"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; + +import type { ServerConfig } from "../../config.ts"; +import type { + OpenCode2Connection, + OpenCode2Server, +} from "../../provider/opencode2/OpenCode2Server.ts"; +import { + parseOpenCodeModelSlug, + type OpenCodeRuntimeError, +} from "../../provider/opencodeRuntime.ts"; +import { buildRuntimeInstructions } from "../../provider/RuntimeInstructions.ts"; +import { providerMessageTextWithAttachmentPaths } from "../AttachmentPrompt.ts"; +import type { IdAllocatorV2Shape } from "../IdAllocator.ts"; +import { makeProviderFailure } from "../ProviderFailure.ts"; +import { + ProviderAdapterEnsureThreadError, + ProviderAdapterForkThreadError, + ProviderAdapterInterruptError, + ProviderAdapterOpenSessionError, + ProviderAdapterProtocolError, + ProviderAdapterReadThreadSnapshotError, + ProviderAdapterResumeThreadError, + ProviderAdapterRollbackThreadError, + ProviderAdapterRuntimeRequestResponseError, + ProviderAdapterSteerRunUnsupportedError, + ProviderAdapterTurnStartError, + type ProviderAdapterV2Event, + type ProviderAdapterV2SessionRuntime, + type ProviderAdapterV2Shape, + type ProviderAdapterV2TurnInput, +} from "../ProviderAdapter.ts"; +import { turnScopedSelectionTransition } from "../ProviderSelectionTransition.ts"; +import { OPENCODE_PROVIDER } from "./OpenCodeAdapterV2.ts"; +import { openCodeToolTurnItem } from "./OpenCodeToolItems.ts"; + +const OpenCode2ProviderCapabilities = { + sessions: { + // One server serves every location, so one session runtime owns them all. + supportsMultipleProviderThreadsPerSession: true, + supportsModelSwitchInSession: false, + supportsProviderSwitchingViaHandoff: true, + supportsRuntimeModeSwitchInSession: false, + pendingRequestsSurviveRestart: false, + }, + threads: { + canCreateEmptyThread: true, + canReadThreadSnapshot: false, + canRollbackThread: false, + canForkThread: false, + canForkFromTurn: false, + canForkFromSubagentThread: false, + exposesNativeThreadId: true, + }, + turns: { + exposesNativeTurnId: false, + emitsTurnStarted: true, + emitsTurnCompleted: true, + supportsInterrupt: true, + supportsActiveSteering: false, + supportsSteeringByInterruptRestart: true, + supportsQueuedMessages: true, + terminalStatusQuality: "strong", + }, + streaming: { + streamsAssistantText: true, + streamsReasoning: true, + streamsToolOutput: false, + streamsPlanText: false, + emitsMessageCompleted: true, + }, + tools: { + exposesToolItemIds: true, + emitsToolStarted: true, + emitsToolCompleted: true, + emitsToolOutput: true, + supportsMcpTools: false, + supportsDynamicToolCallbacks: false, + }, + approvals: { + supportsCommandApproval: false, + supportsFileReadApproval: false, + supportsFileChangeApproval: false, + supportsApplyPatchApproval: false, + approvalsHaveNativeRequestIds: false, + approvalCallbacksAreLiveOnly: true, + approvalsCanOriginateFromSubagents: false, + }, + planning: { + emitsPlanUpdated: false, + emitsTodoList: false, + emitsProposedPlan: false, + supportsStructuredQuestions: false, + planDeltasHaveItemIds: false, + }, + subagents: { + supportsSubagents: false, + exposesSubagentThreadIds: false, + emitsSubagentLifecycle: false, + canWaitForSubagents: false, + canCloseSubagents: false, + canForkSubagentThread: false, + }, + context: { + acceptsSystemContext: false, + acceptsDeveloperContext: false, + acceptsSyntheticUserContext: true, + canGenerateSummaries: false, + canConsumeHandoffSummaries: true, + supportsDeltaHandoff: true, + supportsFullThreadHandoff: true, + maxRecommendedHandoffChars: null, + }, + checkpointing: { + appCanCheckpointFilesystem: true, + supportsNestedCheckpointScopes: true, + providerCanRollbackConversation: false, + providerRollbackReturnsSnapshot: false, + providerCanReadConversationSnapshot: false, + }, + identity: { + nativeThreadIds: "strong", + nativeTurnIds: "weak", + nativeItemIds: "strong", + nativeRequestIds: "none", + }, + // Sessions run with every tool allowed; the snapshot offers only Full access. + runtimePolicy: { enforcement: "native" }, +} satisfies OrchestrationV2ProviderCapabilities; + +type EventOf = Extract; + +interface ActiveTurn { + readonly input: ProviderAdapterV2TurnInput; + readonly providerTurn: OrchestrationV2ProviderTurn; + /** Open text and reasoning blocks, keyed `::`. */ + readonly texts: Map; + readonly tools: Map }>; + readonly startedAt: Map; + readonly ordinals: Map; + nextOrdinal: number; + interrupted: boolean; +} + +interface OpenBlock { + readonly block: { readonly assistantMessageID: string; readonly ordinal: number }; + readonly kind: "text" | "reasoning"; + readonly startedAt: DateTime.Utc; + text: string; +} + +interface ThreadState { + providerThread: OrchestrationV2ProviderThread; + readonly providerTurns: Map; + active: ActiveTurn | undefined; +} + +const protocolError = (detail: string) => + new ProviderAdapterProtocolError({ driver: OPENCODE_PROVIDER, detail }); +const notYet = (feature: string) => protocolError(`OpenCode 2 ${feature} is not supported yet`); + +const ref = (nativeId: string, strength: "strong" | "weak" = "strong") => ({ + driver: OPENCODE_PROVIDER, + nativeId, + strength, +}); + +const sessionIdOf = (providerThread: OrchestrationV2ProviderThread) => { + const nativeId = providerThread.nativeThreadRef?.nativeId; + return nativeId === undefined || nativeId === null + ? Effect.fail(protocolError(`Provider thread ${providerThread.id} has no OpenCode session`)) + : Effect.succeed(nativeId); +}; + +const textOf = (content: ReadonlyArray<{ readonly type: string; readonly text?: string }>) => + content.flatMap((part) => (part.type === "text" && part.text ? [part.text] : [])).join("\n"); + +export interface OpenCode2AdapterOptions { + readonly instanceId: ProviderInstanceId; + readonly server: OpenCode2Server["Service"]; + readonly idAllocator: IdAllocatorV2Shape; + readonly serverConfig: ServerConfig["Service"]; +} + +export function makeOpenCode2Adapter(options: OpenCode2AdapterOptions): ProviderAdapterV2Shape { + const { idAllocator, instanceId, serverConfig } = options; + const driver = OPENCODE_PROVIDER; + + const openSession = Effect.fn("OpenCode2Adapter.openSession")(function* ( + input: Parameters[0], + connection: OpenCode2Connection, + ) { + const { client } = connection; + const now = yield* DateTime.now; + let session: OrchestrationV2ProviderSession = { + id: input.providerSessionId, + driver, + providerInstanceId: instanceId, + status: "ready", + cwd: input.runtimePolicy.cwd ?? serverConfig.cwd, + model: input.modelSelection.model, + capabilities: OpenCode2ProviderCapabilities, + createdAt: now, + updatedAt: now, + lastError: null, + }; + const events = yield* Queue.unbounded(); + const threads = new Map(); + const emit = (event: ProviderAdapterV2Event) => Queue.offer(events, event).pipe(Effect.asVoid); + + const setSessionStatus = ( + status: OrchestrationV2ProviderSession["status"], + lastError: string | null, + ) => + Effect.gen(function* () { + session = { ...session, status, lastError, updatedAt: yield* DateTime.now }; + yield* emit({ type: "provider_session.updated", driver, providerSession: session }); + }); + + const ordinalOf = (turn: ActiveTurn, nativeId: string) => { + const known = turn.ordinals.get(nativeId); + if (known !== undefined) return known; + const next = turn.nextOrdinal++; + turn.ordinals.set(nativeId, next); + return next; + }; + + const itemBase = ( + state: ThreadState, + turn: ActiveTurn, + nativeId: string, + status: OrchestrationV2TurnItem["status"], + startedAt: DateTime.Utc, + completedAt: DateTime.Utc | null, + updatedAt: DateTime.Utc, + ) => ({ + id: idAllocator.derive.turnItemFromProviderItem({ driver, nativeItemId: nativeId }), + threadId: turn.input.threadId, + runId: turn.input.runId, + nodeId: idAllocator.derive.nodeFromProviderItem({ driver, nativeItemId: nativeId }), + providerThreadId: state.providerThread.id, + providerTurnId: turn.providerTurn.id, + nativeItemRef: ref(nativeId), + parentItemId: null, + ordinal: ordinalOf(turn, nativeId), + status, + title: null, + startedAt, + completedAt, + updatedAt, + }); + + const emitNode = ( + state: ThreadState, + turn: ActiveTurn, + nativeId: string, + kind: OrchestrationV2ExecutionNode["kind"], + status: OrchestrationV2ExecutionNode["status"], + startedAt: DateTime.Utc, + completedAt: DateTime.Utc | null, + ) => + emit({ + type: "node.updated", + driver, + node: { + id: idAllocator.derive.nodeFromProviderItem({ driver, nativeItemId: nativeId }), + threadId: turn.input.threadId, + runId: turn.input.runId, + parentNodeId: turn.input.rootNodeId, + rootNodeId: turn.input.rootNodeId, + kind, + status, + countsForRun: false, + providerThreadId: state.providerThread.id, + providerTurnId: turn.providerTurn.id, + nativeItemRef: ref(nativeId), + runtimeRequestId: null, + checkpointScopeId: null, + startedAt, + completedAt, + }, + }); + + /** One text or reasoning block, re-emitted with its accumulated text on every change. */ + const emitText = Effect.fnUntraced(function* ( + state: ThreadState, + turn: ActiveTurn, + data: { + readonly assistantMessageID: string; + readonly ordinal: number; + readonly text?: string; + }, + kind: "text" | "reasoning", + update: (current: string) => string, + completed = "text" in data, + ) { + const nativeId = `${data.assistantMessageID}:${kind}:${data.ordinal}`; + const updatedAt = yield* DateTime.now; + const entry = turn.texts.get(nativeId) ?? { + block: data, + kind, + startedAt: updatedAt, + text: "", + }; + entry.text = update(entry.text); + if (completed) turn.texts.delete(nativeId); + else turn.texts.set(nativeId, entry); + if (entry.text.length === 0) return; + const status = completed ? "completed" : "running"; + const completedAt = completed ? updatedAt : null; + const nodeKind = kind === "text" ? "assistant_message" : "reasoning"; + yield* emitNode(state, turn, nativeId, nodeKind, status, entry.startedAt, completedAt); + const base = itemBase(state, turn, nativeId, status, entry.startedAt, completedAt, updatedAt); + if (kind === "reasoning") { + yield* emit({ + type: "turn_item.updated", + driver, + turnItem: { ...base, type: "reasoning", text: entry.text, streaming: !completed }, + }); + return; + } + const messageId = idAllocator.derive.messageFromProviderItem({ + driver, + nativeItemId: nativeId, + }); + const message: OrchestrationV2ConversationMessage = { + createdBy: "agent", + creationSource: "provider", + id: messageId, + threadId: turn.input.threadId, + runId: turn.input.runId, + nodeId: base.nodeId, + role: "assistant", + text: entry.text, + attachments: [], + streaming: !completed, + createdAt: entry.startedAt, + updatedAt, + }; + yield* emit({ type: "message.updated", driver, message }); + yield* emit({ + type: "turn_item.updated", + driver, + turnItem: { + ...base, + type: "assistant_message", + messageId, + text: entry.text, + streaming: !completed, + }, + }); + }); + + const emitTool = Effect.fnUntraced(function* ( + state: ThreadState, + turn: ActiveTurn, + id: string, + status: "running" | "completed" | "failed" | "interrupted", + result?: { readonly output: string | undefined; readonly metadata: unknown }, + ) { + const tool = turn.tools.get(id); + if (tool === undefined) return; + const updatedAt = yield* DateTime.now; + const startedAt = turn.startedAt.get(id) ?? updatedAt; + const completedAt = status === "running" ? null : updatedAt; + yield* emitNode(state, turn, id, "tool_call", status, startedAt, completedAt); + yield* emit({ + type: "turn_item.updated", + driver, + turnItem: openCodeToolTurnItem( + itemBase(state, turn, id, status, startedAt, completedAt, updatedAt), + { + name: tool.name, + input: tool.input, + output: result?.output, + completedMetadata: status === "completed" ? result?.metadata : undefined, + }, + ), + }); + }); + + const emitProviderTurn = ( + state: ThreadState, + turn: ActiveTurn, + providerTurn: OrchestrationV2ProviderTurn, + ) => { + state.providerTurns.set(String(providerTurn.id), providerTurn); + return emit({ + type: "provider_turn.updated", + driver, + threadId: turn.input.threadId, + providerTurn, + }); + }; + + const finishTurn = Effect.fnUntraced(function* ( + state: ThreadState, + terminal: + | { readonly status: "completed" | "interrupted" } + | { readonly status: "failed"; readonly failure: ReturnType }, + threadDisposition: "reusable" | "broken" = "reusable", + ) { + const turn = state.active; + if (turn === undefined) return; + state.active = undefined; + const completedAt = yield* DateTime.now; + // Blocks still open when the execution ends are final as they stand. + for (const open of turn.texts.values()) { + yield* emitText(state, turn, open.block, open.kind, (text) => text, true); + } + for (const id of turn.tools.keys()) { + yield* emitTool( + state, + turn, + id, + terminal.status === "completed" ? "completed" : "interrupted", + ); + } + yield* emitProviderTurn(state, turn, { + ...turn.providerTurn, + status: terminal.status, + completedAt, + }); + state.providerThread = { + ...state.providerThread, + status: threadDisposition === "broken" ? "error" : "idle", + updatedAt: completedAt, + }; + yield* emit({ + type: "provider_thread.updated", + driver, + providerThread: state.providerThread, + }); + const anyActive = [...threads.values()].some((candidate) => candidate.active !== undefined); + yield* setSessionStatus(anyActive ? "running" : "ready", null); + const base = { + type: "turn.terminal" as const, + driver, + providerThreadId: state.providerThread.id, + providerTurnId: turn.providerTurn.id, + runOrdinal: turn.input.runOrdinal, + threadDisposition, + }; + yield* emit( + terminal.status === "failed" + ? { + ...base, + status: "failed", + failure: terminal.failure, + failureItemOrdinal: ordinalOf(turn, `terminal-failure:${turn.providerTurn.id}`), + } + : { ...base, status: terminal.status, failure: null }, + ); + }); + + // Refuses a permission or form so a session never waits on a reply T3 cannot give yet. + const refuseRequest = (event: EventOf<"permission.asked"> | EventOf<"form.created">) => { + const refusal = + event.type === "permission.asked" + ? client.permission + .reply({ + sessionID: event.data.sessionID, + requestID: event.data.id, + decision: "reject", + message: "T3 Code cannot answer this request for OpenCode 2 yet.", + }) + .pipe(Effect.ignore({ log: true })) + : client.session.form + .cancel({ sessionID: event.data.form.sessionID, formID: event.data.form.id }) + .pipe(Effect.ignore({ log: true })); + return Effect.logWarning("Refused an OpenCode request this runtime cannot answer yet.", { + type: event.type, + }).pipe(Effect.andThen(refusal)); + }; + + const handleEvent = Effect.fnUntraced(function* (event: OpenCodeEvent) { + if (event.type === "permission.asked" && threads.has(event.data.sessionID)) { + return yield* refuseRequest(event); + } + if (event.type === "form.created" && threads.has(event.data.form.sessionID)) { + return yield* refuseRequest(event); + } + if (!("sessionID" in event.data) || typeof event.data.sessionID !== "string") return; + const state = threads.get(event.data.sessionID); + const turn = state?.active; + if (state === undefined || turn === undefined) return; + switch (event.type) { + case "session.text.started": + case "session.reasoning.started": + case "session.text.delta": + case "session.reasoning.delta": + case "session.text.ended": + case "session.reasoning.ended": { + const data = event.data; + const kind = event.type.startsWith("session.text.") ? "text" : "reasoning"; + return yield* emitText(state, turn, data, kind, (text) => + "delta" in data ? text + data.delta : "text" in data ? data.text : text, + ); + } + case "session.tool.input.started": + turn.tools.set(event.data.id, { name: event.data.name, input: {} }); + turn.startedAt.set(event.data.id, yield* DateTime.now); + return yield* emitTool(state, turn, event.data.id, "running"); + case "session.tool.called": { + const tool = turn.tools.get(event.data.id); + if (tool !== undefined) tool.input = event.data.input; + return yield* emitTool(state, turn, event.data.id, "running"); + } + case "session.tool.success": { + const output = textOf(event.data.content); + yield* emitTool(state, turn, event.data.id, "completed", { + output, + metadata: event.data.metadata, + }); + turn.tools.delete(event.data.id); + return; + } + case "session.tool.failed": { + const aborted = event.data.error.type === "aborted"; + yield* emitTool(state, turn, event.data.id, aborted ? "interrupted" : "failed", { + output: event.data.error.message, + metadata: event.data.metadata, + }); + turn.tools.delete(event.data.id); + return; + } + case "session.execution.succeeded": + return yield* finishTurn(state, { + status: turn.interrupted ? "interrupted" : "completed", + }); + case "session.execution.interrupted": + return yield* finishTurn(state, { status: "interrupted" }); + case "session.execution.failed": + return yield* finishTurn(state, { + status: "failed", + failure: makeProviderFailure({ + message: event.data.error.message, + code: event.data.error.type, + class: "provider_error", + }), + }); + default: + return; + } + }); + + // The stream is the only terminal signal, so a lost stream settles every + // running turn and breaks the session: T3 reopens it for the next turn. + const failAll = Effect.fnUntraced(function* (message: string) { + yield* setSessionStatus("error", message); + for (const state of threads.values()) { + const failure = makeProviderFailure({ message, class: "transport_error" }); + yield* finishTurn(state, { status: "failed", failure }, "broken"); + } + yield* Queue.end(events); + }); + // Subscribed before any session or prompt call, so no event of theirs is missed. + const stream = yield* connection.events; + yield* stream.pipe( + Stream.runForEach(handleEvent), + Effect.matchCauseEffect({ + onSuccess: () => failAll("The OpenCode event stream ended."), + onFailure: () => failAll("The OpenCode event stream failed."), + }), + Effect.forkScoped, + ); + + const register = (providerThread: OrchestrationV2ProviderThread, sessionId: string) => { + const existing = threads.get(sessionId); + if (existing !== undefined) { + existing.providerThread = providerThread; + return providerThread; + } + threads.set(sessionId, { providerThread, providerTurns: new Map(), active: undefined }); + return providerThread; + }; + + const prompt = (turnInput: ProviderAdapterV2TurnInput) => { + const text = providerMessageTextWithAttachmentPaths({ + text: turnInput.message.text, + attachments: turnInput.message.attachments, + attachmentsDir: serverConfig.attachmentsDir, + }).trim(); + const instructions = buildRuntimeInstructions({ + harness: "OpenCode", + model: turnInput.modelSelection.model, + }); + return `${text}\n\n${instructions}`; + }; + + const runtime: ProviderAdapterV2SessionRuntime = { + instanceId, + driver, + providerSessionId: input.providerSessionId, + get providerSession() { + return session; + }, + events: Stream.fromQueue(events), + ensureThread: (threadInput) => + Effect.gen(function* () { + if (threadInput.existingProviderThread?.nativeThreadRef != null) { + return yield* runtime.resumeThread({ + providerThread: threadInput.existingProviderThread, + }); + } + const parsed = parseOpenCodeModelSlug(threadInput.modelSelection.model); + const created = yield* client.session.create({ + location: Location.PublicRef.make({ + directory: AbsolutePath.make(threadInput.runtimePolicy.cwd ?? serverConfig.cwd), + }), + ...(parsed === null + ? {} + : { + model: Model.Ref.make({ + providerID: Provider.ID.make(parsed.providerID), + id: Model.ID.make(parsed.modelID), + }), + }), + // Full access is the only mode the snapshot offers until layer 5. + permissions: [{ action: "*", resource: "*", effect: "allow" }], + }); + const createdAt = yield* DateTime.now; + const providerThread: OrchestrationV2ProviderThread = { + ...(threadInput.existingProviderThread ?? { + id: idAllocator.derive.providerThread({ driver, nativeThreadId: created.id }), + driver, + providerInstanceId: instanceId, + appThreadId: threadInput.threadId, + ownerNodeId: null, + firstRunOrdinal: null, + lastRunOrdinal: null, + handoffIds: [], + forkedFrom: null, + createdAt, + }), + providerSessionId: input.providerSessionId, + nativeThreadRef: ref(created.id), + nativeConversationHeadRef: null, + status: "idle", + updatedAt: createdAt, + }; + return register(providerThread, created.id); + }).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterEnsureThreadError({ + driver, + threadId: threadInput.threadId, + cause, + }), + ), + ), + resumeThread: (threadInput) => + Effect.gen(function* () { + const sessionId = yield* sessionIdOf(threadInput.providerThread); + // 1.x session ids survive the upgrade; a server without this session + // fails the resume, so T3 recreates the thread with a handoff. + yield* client.session.get({ sessionID: Session.ID.make(sessionId) }); + return register( + { + ...threadInput.providerThread, + providerSessionId: input.providerSessionId, + status: "idle", + updatedAt: yield* DateTime.now, + }, + sessionId, + ); + }).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterResumeThreadError({ + driver, + providerSessionId: input.providerSessionId, + providerThreadId: threadInput.providerThread.id, + cause, + }), + ), + ), + startTurn: (turnInput) => + Effect.gen(function* () { + const sessionId = yield* sessionIdOf(turnInput.providerThread); + const state = threads.get(sessionId); + if (state === undefined) { + return yield* protocolError(`OpenCode session ${sessionId} is not registered`); + } + if (state.active !== undefined) { + return yield* protocolError(`OpenCode session ${sessionId} already has an active turn`); + } + const startedAt = yield* DateTime.now; + const nativeTurnId = `${sessionId}:attempt:${turnInput.attemptId}`; + const providerTurn: OrchestrationV2ProviderTurn = { + id: idAllocator.derive.providerTurn({ driver, nativeTurnId }), + providerThreadId: turnInput.providerThread.id, + nodeId: turnInput.rootNodeId, + runAttemptId: turnInput.attemptId, + nativeTurnRef: ref(nativeTurnId, "weak"), + ordinal: turnInput.providerTurnOrdinal, + status: "running", + startedAt, + completedAt: null, + }; + const turn: ActiveTurn = { + input: turnInput, + providerTurn, + texts: new Map(), + tools: new Map(), + startedAt: new Map(), + ordinals: new Map(), + nextOrdinal: turnInput.providerTurnOrdinal * 100 + 1, + interrupted: false, + }; + state.active = turn; + yield* emitProviderTurn(state, turn, providerTurn); + state.providerThread = { + ...state.providerThread, + status: "active", + firstRunOrdinal: state.providerThread.firstRunOrdinal ?? turnInput.runOrdinal, + lastRunOrdinal: turnInput.runOrdinal, + updatedAt: startedAt, + }; + yield* emit({ + type: "provider_thread.updated", + driver, + providerThread: state.providerThread, + }); + yield* setSessionStatus("running", null); + yield* client.session + .prompt({ sessionID: Session.ID.make(sessionId), text: prompt(turnInput) }) + .pipe( + Effect.tapError((cause) => + state.active === turn + ? finishTurn(state, { + status: "failed", + failure: makeProviderFailure({ cause, class: "provider_error" }), + }) + : Effect.void, + ), + ); + }).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterTurnStartError({ + driver, + threadId: turnInput.threadId, + providerThreadId: turnInput.providerThread.id, + runId: turnInput.runId, + cause, + }), + ), + ), + steerTurn: (steerInput) => + Effect.fail( + new ProviderAdapterSteerRunUnsupportedError({ + driver, + providerThreadId: steerInput.providerThread.id, + }), + ), + interruptTurn: (interruptInput) => + Effect.gen(function* () { + const sessionId = yield* sessionIdOf(interruptInput.providerThread); + const turn = threads.get(sessionId)?.active; + if (turn === undefined || turn.providerTurn.id !== interruptInput.providerTurnId) return; + turn.interrupted = true; + // The session answers with `session.execution.interrupted`, which ends the turn. + yield* client.session.interrupt({ sessionID: Session.ID.make(sessionId) }); + }).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterInterruptError({ + driver, + providerThreadId: interruptInput.providerThread.id, + providerTurnId: interruptInput.providerTurnId, + cause, + }), + ), + ), + unloadThread: ({ providerThread }) => + Effect.sync(() => { + const nativeId = providerThread.nativeThreadRef?.nativeId; + if ( + nativeId !== undefined && + nativeId !== null && + threads.get(nativeId)?.active === undefined + ) { + threads.delete(nativeId); + } + }), + respondToRuntimeRequest: (requestInput) => + Effect.fail( + new ProviderAdapterRuntimeRequestResponseError({ + driver, + requestId: requestInput.requestId, + cause: notYet("answering runtime requests"), + }), + ), + readThreadSnapshot: ({ providerThread }) => + Effect.fail( + new ProviderAdapterReadThreadSnapshotError({ + driver, + providerThreadId: providerThread.id, + cause: notYet("history snapshots"), + }), + ), + rollbackThread: (rollbackInput) => + Effect.fail( + new ProviderAdapterRollbackThreadError({ + driver, + providerThreadId: rollbackInput.providerThread.id, + checkpointId: rollbackInput.target.checkpointId, + cause: notYet("rollback"), + }), + ), + forkThread: (forkInput) => + Effect.fail( + new ProviderAdapterForkThreadError({ + driver, + providerThreadId: forkInput.sourceProviderThread.id, + cause: notYet("fork"), + }), + ), + }; + return runtime; + }); + + return { + instanceId, + driver, + getCapabilities: () => Effect.succeed(OpenCode2ProviderCapabilities), + planSelectionTransition: () => Effect.succeed(turnScopedSelectionTransition()), + // The session borrows the instance's server for as long as it is open, so a + // spawned server is not idle-stopped under a long tool call. + openSession: (input) => + Effect.gen(function* () { + const lent = yield* Deferred.make(); + yield* options.server + .withConnection((connection) => + Deferred.succeed(lent, connection).pipe(Effect.andThen(Effect.never)), + ) + .pipe( + Effect.catch((error) => Deferred.fail(lent, error)), + Effect.forkScoped, + ); + return yield* openSession(input, yield* Deferred.await(lent)); + }).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterOpenSessionError({ + driver, + providerSessionId: input.providerSessionId, + cause, + }), + ), + ), + }; +} diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.testkit.ts index 4112394422a5..b818f1ae28ac 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.testkit.ts @@ -1,4 +1,4 @@ -import type { Event as OpenCodeEvent, OpencodeClient } from "@opencode-ai/sdk/v2"; +import type { OpencodeClient } from "@opencode-ai/sdk/v2"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { ProviderReplayEntry, type ProviderReplayTranscript } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; @@ -126,14 +126,19 @@ function materializeMessageIds(value: unknown, messageIds: ReadonlyMap void>(); private failure: unknown = null; - private readonly transcript: OpenCodeSdkReplayTranscript; + private readonly transcript: ProviderReplayTranscript; private messageIds = new Map(); - constructor(transcript: OpenCodeSdkReplayTranscript) { + constructor(transcript: ProviderReplayTranscript) { this.transcript = transcript; } @@ -214,7 +219,7 @@ class OpenCodeReplayController { } } - async *events(signal?: AbortSignal): AsyncIterable { + async *events(signal?: AbortSignal): AsyncIterable { while (true) { if (signal?.aborted === true) return; this.throwFailure(); @@ -225,7 +230,7 @@ class OpenCodeReplayController { if (entry.afterMs !== undefined && entry.afterMs > 0) { await Effect.runPromise(Effect.sleep(Duration.millis(entry.afterMs))); } - const event = materializeMessageIds(frame.event, this.messageIds) as OpenCodeEvent; + const event = materializeMessageIds(frame.event, this.messageIds); this.advance(); yield event; continue; diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts index 0b781b5f82cf..308ff353e478 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts @@ -9,7 +9,6 @@ import type { Todo as OpenCodeTodo, ToolPart, } from "@opencode-ai/sdk/v2"; -import { formatReadToolLabel, formatSearchToolLabel } from "@t3tools/shared/toolActivity"; import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { causeErrorTag } from "@t3tools/shared/observability"; @@ -110,6 +109,9 @@ import { type ProviderAdapterDriverCreateInput, } from "../ProviderAdapterDriver.ts"; import { makeSubagentChildThread, subagentThreadTitle } from "../SubagentProjection.ts"; +import { openCodeToolTurnItem } from "./OpenCodeToolItems.ts"; + +export { openCodeToolProjectionKind } from "./OpenCodeToolItems.ts"; export const OPENCODE_PROVIDER = ProviderDriverKind.make("opencode"); export const OPENCODE_DEFAULT_INSTANCE_ID = defaultInstanceIdForDriver(OPENCODE_PROVIDER); @@ -570,23 +572,6 @@ function recordString(input: unknown, ...keys: ReadonlyArray): string | return undefined; } -function recordNumber(input: unknown, ...keys: ReadonlyArray): number | undefined { - for (const key of keys) { - const value = recordValue(input, key); - if (typeof value === "number" && Number.isFinite(value)) return value; - } - return undefined; -} - -function stableJson(value: unknown): string { - if (typeof value === "string") return value; - try { - return JSON.stringify(value, null, 2); - } catch { - return String(value); - } -} - function sdkResponseForRawLog(value: unknown): unknown { if (typeof value !== "object" || value === null) return value; if ("data" in value) return { data: (value as { readonly data?: unknown }).data ?? null }; @@ -633,36 +618,6 @@ export function openCodePermissionRequestKind( return "command"; } -export function openCodeToolProjectionKind( - toolName: string, -): "command_execution" | "file_change" | "file_search" | "web_search" | "dynamic_tool" { - const normalized = toolName.toLowerCase(); - if (normalized === "todowrite") { - return "dynamic_tool"; - } - if (normalized.includes("bash") || normalized.includes("shell")) { - return "command_execution"; - } - if (normalized.includes("edit") || normalized.includes("write") || normalized.includes("patch")) { - return "file_change"; - } - if (normalized.includes("web") || normalized === "codesearch" || normalized === "code_search") { - return "web_search"; - } - if (normalized === "read") { - return "dynamic_tool"; - } - if ( - normalized.includes("glob") || - normalized.includes("grep") || - normalized.includes("search") || - normalized.includes("lsp") - ) { - return "file_search"; - } - return "dynamic_tool"; -} - const OPENCODE_ALWAYS_ALLOWED_PERMISSIONS = [ "question", "read", @@ -1664,86 +1619,12 @@ export function makeOpenCodeAdapterV2(options: OpenCodeAdapterV2Options): Provid | "completedAt" | "updatedAt" >; - const input = toolInput(part); - const output = toolOutput(part); - const projectionKind = openCodeToolProjectionKind(part.tool); - let turnItem: OrchestrationV2TurnItem; - if (projectionKind === "command_execution") { - turnItem = { - ...base, - type: "command_execution", - input: recordString(input, "command", "cmd") ?? stableJson(input), - ...(output === undefined ? {} : { output }), - ...(recordNumber( - part.state.status === "completed" ? part.state.metadata : undefined, - "exit", - "exitCode", - ) === undefined - ? {} - : { - exitCode: recordNumber( - part.state.status === "completed" ? part.state.metadata : undefined, - "exit", - "exitCode", - )!, - }), - }; - } else if (projectionKind === "file_change") { - turnItem = { - ...base, - type: "file_change", - fileName: recordString(input, "filePath", "path", "file") ?? part.tool, - ...(recordString(input, "oldString", "oldText") === undefined - ? {} - : { oldStr: recordString(input, "oldString", "oldText")! }), - ...(recordString(input, "newString", "content", "newText") === undefined - ? {} - : { newStr: recordString(input, "newString", "content", "newText")! }), - ...(recordString( - part.state.status === "completed" ? part.state.metadata : undefined, - "diff", - "patch", - ) === undefined - ? {} - : { - diffStr: recordString( - part.state.status === "completed" ? part.state.metadata : undefined, - "diff", - "patch", - )!, - }), - }; - } else if (projectionKind === "file_search") { - const pattern = recordString(input, "pattern", "query", "path", "filePath"); - turnItem = { - ...base, - title: - formatSearchToolLabel({ input, ...(pattern === undefined ? {} : { pattern }) }) ?? - base.title, - type: "file_search", - ...(pattern === undefined ? {} : { pattern }), - }; - } else if (projectionKind === "web_search") { - const pattern = recordString(input, "query", "url", "pattern"); - turnItem = { - ...base, - type: "web_search", - ...(pattern === undefined ? {} : { patterns: [pattern] }), - }; - } else { - const readPath = recordString(input, "filePath", "path", "file"); - turnItem = { - ...base, - title: - part.tool.toLowerCase() === "read" && readPath !== undefined - ? formatReadToolLabel(readPath) - : base.title, - type: "dynamic_tool", - toolName: part.tool, - input, - ...(output === undefined ? {} : { output }), - }; - } + const turnItem = openCodeToolTurnItem(base, { + name: part.tool, + input: toolInput(part), + output: toolOutput(part), + completedMetadata: part.state.status === "completed" ? part.state.metadata : undefined, + }); yield* emitProviderEvent({ type: "node.updated", driver: OPENCODE_PROVIDER, diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCodeToolItems.ts b/apps/server/src/orchestration-v2/Adapters/OpenCodeToolItems.ts new file mode 100644 index 000000000000..d4d8c86df654 --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/OpenCodeToolItems.ts @@ -0,0 +1,143 @@ +/** + * Turn-item bodies for OpenCode tool calls, shared by the 1.x and 2.x runtimes. + * Both report a tool as a name, a JSON input, text output, and free-form + * metadata; only how they deliver those differs. + */ +import type { OrchestrationV2TurnItem } from "@t3tools/contracts"; +import { formatReadToolLabel, formatSearchToolLabel } from "@t3tools/shared/toolActivity"; + +type ToolItemBase = Omit< + Extract, + "type" | "toolName" | "input" | "output" +>; + +export function openCodeToolProjectionKind( + toolName: string, +): "command_execution" | "file_change" | "file_search" | "web_search" | "dynamic_tool" { + const normalized = toolName.toLowerCase(); + if (normalized === "todowrite") { + return "dynamic_tool"; + } + if (normalized.includes("bash") || normalized.includes("shell")) { + return "command_execution"; + } + if (normalized.includes("edit") || normalized.includes("write") || normalized.includes("patch")) { + return "file_change"; + } + if (normalized.includes("web") || normalized === "codesearch" || normalized === "code_search") { + return "web_search"; + } + if (normalized === "read") { + return "dynamic_tool"; + } + if ( + normalized.includes("glob") || + normalized.includes("grep") || + normalized.includes("search") || + normalized.includes("lsp") + ) { + return "file_search"; + } + return "dynamic_tool"; +} + +function recordString(input: unknown, ...keys: ReadonlyArray): string | undefined { + if (typeof input !== "object" || input === null) return undefined; + for (const key of keys) { + const value = (input as Record)[key]; + if (typeof value === "string" && value.trim().length > 0) return value; + } + return undefined; +} + +function recordNumber(input: unknown, ...keys: ReadonlyArray): number | undefined { + if (typeof input !== "object" || input === null) return undefined; + for (const key of keys) { + const value = (input as Record)[key]; + if (typeof value === "number" && Number.isFinite(value)) return value; + } + return undefined; +} + +function stableJson(value: unknown): string { + if (typeof value === "string") return value; + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} + +/** + * The turn item for one tool call. `completedMetadata` is the tool's metadata + * once it succeeded: exit codes and diffs are only trusted from a finished call. + */ +export function openCodeToolTurnItem( + base: ToolItemBase, + tool: { + readonly name: string; + readonly input: Record; + readonly output: string | undefined; + readonly completedMetadata: unknown; + }, +): OrchestrationV2TurnItem { + const { input, output } = tool; + switch (openCodeToolProjectionKind(tool.name)) { + case "command_execution": { + const exitCode = recordNumber(tool.completedMetadata, "exit", "exitCode"); + return { + ...base, + type: "command_execution", + input: recordString(input, "command", "cmd") ?? stableJson(input), + ...(output === undefined ? {} : { output }), + ...(exitCode === undefined ? {} : { exitCode }), + }; + } + case "file_change": { + const oldStr = recordString(input, "oldString", "oldText"); + const newStr = recordString(input, "newString", "content", "newText"); + const diffStr = recordString(tool.completedMetadata, "diff", "patch"); + return { + ...base, + type: "file_change", + fileName: recordString(input, "filePath", "path", "file") ?? tool.name, + ...(oldStr === undefined ? {} : { oldStr }), + ...(newStr === undefined ? {} : { newStr }), + ...(diffStr === undefined ? {} : { diffStr }), + }; + } + case "file_search": { + const pattern = recordString(input, "pattern", "query", "path", "filePath"); + return { + ...base, + title: + formatSearchToolLabel({ input, ...(pattern === undefined ? {} : { pattern }) }) ?? + base.title, + type: "file_search", + ...(pattern === undefined ? {} : { pattern }), + }; + } + case "web_search": { + const pattern = recordString(input, "query", "url", "pattern"); + return { + ...base, + type: "web_search", + ...(pattern === undefined ? {} : { patterns: [pattern] }), + }; + } + case "dynamic_tool": { + const readPath = recordString(input, "filePath", "path", "file"); + return { + ...base, + title: + tool.name.toLowerCase() === "read" && readPath !== undefined + ? formatReadToolLabel(readPath) + : base.title, + type: "dynamic_tool", + toolName: tool.name, + input, + ...(output === undefined ? {} : { output }), + }; + } + } +} diff --git a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts new file mode 100644 index 000000000000..3ac9982cc1cf --- /dev/null +++ b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts @@ -0,0 +1,256 @@ +/** + * Runs OpenCode 2 through the whole orchestrator with the real driver: the + * driver probes the binary, spawns `opencode serve`, and routes to the 2.x + * adapter. One turn reads a file and runs a shell command; a second is + * stopped while its shell command runs. + * + * OPENCODE2_BIN=/path/to/opencode vp test run src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts + * + * The server runs with isolated HOME and XDG directories on the free + * `opencode/big-pickle` model; `OPENCODE2_MODEL` picks another. + */ +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import { SourceControlProviderRegistry } from "../sourceControl/SourceControlProviderRegistry.ts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { + CommandId, + MessageId, + type ModelSelection, + type OrchestrationV2ThreadProjection, + ProjectId, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import { FetchHttpClient } from "effect/unstable/http"; +import { describe } from "vite-plus/test"; + +import * as ResetCreditCoordinator from "../provider/Layers/resetCreditCoordinator.ts"; +import * as CheckpointStore from "../checkpointing/CheckpointStore.ts"; +import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; +import * as HostPowerMonitor from "../background/HostPowerMonitor.ts"; +import { ServerConfig } from "../config.ts"; +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; +import { AntigravityInstallation } from "../provider/AntigravityInstallation.ts"; +import * as ModelManifest from "../provider/ModelManifest.ts"; +import { ProviderInstanceRegistryHydrationLive } from "../provider/Layers/ProviderInstanceRegistryHydration.ts"; +import { + NoOpProviderEventLoggers, + ProviderEventLoggers, +} from "../provider/Layers/ProviderEventLoggers.ts"; +import { OpenCodeRuntimeLive } from "../provider/opencodeRuntime.ts"; +import * as OpenCodeServerLedger from "../provider/OpenCodeServerLedger.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; +import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import { runDaemonWithOptions as runEffectWorkerDaemonWithOptions } from "./EffectWorker.ts"; +import { OrchestratorV2 } from "./Orchestrator.ts"; +import { worktreeRepairDependenciesTestLayer } from "./ProviderTurnStartService.testkit.ts"; +import { OrchestrationV2LayerLive } from "./runtimeLayer.ts"; +import { layer as mcpSessionRegistryTestLayer } from "../mcp/McpSessionRegistry.testkit.ts"; + +const binaryPath = process.env.OPENCODE2_BIN; +const ROOT = process.env.OPENCODE2_LIVE_ROOT ?? ""; +const INSTANCE = ProviderInstanceId.make("opencode"); +const MODEL: ModelSelection = { + instanceId: INSTANCE, + model: process.env.OPENCODE2_MODEL ?? "opencode/big-pickle", +}; + +const PlatformTestLayer = Layer.merge( + NodeServices.layer, + Layer.mock(SourceControlProviderRegistry)({ resolveLink: () => Effect.die("unused title link") }), +); +const serverConfigLayer = ServerConfig.layerTest(`${ROOT}/work`, { prefix: "t3-opencode2-live-" }); +const vcsDriverRegistryLayer = VcsDriverRegistry.layer.pipe( + Layer.provide(VcsProcess.layer), + Layer.provide(serverConfigLayer), + Layer.provide(PlatformTestLayer), +); +// Isolated OpenCode state: the server never touches the developer's own data. +const serverSettingsLayer = ServerSettingsService.layerTest({ + providerInstances: { + [INSTANCE]: { + driver: "opencode", + enabled: true, + environment: [ + { name: "HOME", value: ROOT }, + { name: "XDG_CONFIG_HOME", value: `${ROOT}/config` }, + { name: "XDG_DATA_HOME", value: `${ROOT}/data` }, + { name: "XDG_STATE_HOME", value: `${ROOT}/state` }, + { name: "XDG_CACHE_HOME", value: `${ROOT}/cache` }, + ], + config: { enabled: true, binaryPath }, + }, + }, +} as never); +const backgroundPolicyLayer = BackgroundPolicy.layer.pipe( + Layer.provide(Layer.effect(HostPowerMonitor.HostPowerMonitor, HostPowerMonitor.make())), + Layer.provide(serverSettingsLayer), +); +const providerInstanceRegistryLayer = ProviderInstanceRegistryHydrationLive.pipe( + Layer.provide( + Layer.mergeAll( + serverConfigLayer.pipe(Layer.provide(PlatformTestLayer)), + serverSettingsLayer, + ServerSecretStore.layer.pipe( + Layer.provide(serverConfigLayer), + Layer.provide(PlatformTestLayer), + ), + NodeServices.layer, + FetchHttpClient.layer, + OpenCodeRuntimeLive.pipe( + Layer.provide(OpenCodeServerLedger.layerTest), + Layer.provide(PlatformTestLayer), + ), + Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers), + ModelManifest.layerTest, + AntigravityInstallation.layer.pipe( + Layer.provide(serverConfigLayer.pipe(Layer.provide(PlatformTestLayer))), + Layer.provide(FetchHttpClient.layer), + Layer.provide(PlatformTestLayer), + ), + ), + ), +); +const liveLayer = OrchestrationV2LayerLive.pipe( + Layer.provide(worktreeRepairDependenciesTestLayer), + Layer.provide(mcpSessionRegistryTestLayer), + Layer.provide(SqlitePersistenceMemory), + Layer.provide(CheckpointStore.layer.pipe(Layer.provide(vcsDriverRegistryLayer))), + Layer.provide(serverConfigLayer), + Layer.provide(serverSettingsLayer), + Layer.provide(providerInstanceRegistryLayer), + Layer.provide(ResetCreditCoordinator.layer), + Layer.provide(backgroundPolicyLayer), + Layer.provide(PlatformTestLayer), +); + +const settled = (projection: OrchestrationV2ThreadProjection) => + projection.runs.length > 0 && + projection.runs.every( + (run) => !["queued", "starting", "running", "waiting"].includes(run.status), + ); + +const waitFor = Effect.fn("OpenCode2Live.waitFor")(function* ( + threadId: ThreadId, + done: (projection: OrchestrationV2ThreadProjection) => boolean, +) { + const orchestrator = yield* OrchestratorV2; + for (let attempt = 0; attempt < 240; attempt += 1) { + const projection = yield* orchestrator.getThreadProjection(threadId); + if (done(projection)) return projection; + yield* Effect.sleep("500 millis"); + } + const last = yield* orchestrator.getThreadProjection(threadId); + const items = last.turnItems.map((item) => + item.type === "error" ? `error:${item.failure.message}` : `${item.type}:${item.status}`, + ); + return yield* Effect.die( + new Error( + `Timed out waiting on OpenCode 2 thread ${threadId}: runs ${last.runs.map((run) => run.status).join(",")}; items ${items.join(",")}`, + ), + ); +}); + +const send = Effect.fn("OpenCode2Live.send")(function* ( + threadId: ThreadId, + key: string, + text: string, +) { + const orchestrator = yield* OrchestratorV2; + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`command:opencode2-live:${key}`), + threadId, + messageId: MessageId.make(`message:opencode2-live:${key}`), + text, + attachments: [], + modelSelection: MODEL, + dispatchMode: { type: "start_immediately" }, + }); +}); + +describe.runIf(binaryPath !== undefined && ROOT !== "")("OpenCode 2 live orchestrator", () => { + it.live( + "runs a tool turn and stops a running shell command through the real driver", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.writeFileString(path.join(ROOT, "work", "hello.txt"), "hello from t3 live\n"); + yield* runEffectWorkerDaemonWithOptions({ concurrency: 2 }).pipe(Effect.forkScoped); + const orchestrator = yield* OrchestratorV2; + const threadId = ThreadId.make("thread:opencode2-live"); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:opencode2-live:create"), + threadId, + projectId: ProjectId.make("project:opencode2-live"), + title: "OpenCode 2 live", + modelSelection: MODEL, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: `${ROOT}/work`, + }); + + yield* send( + threadId, + "tools", + // The shell call outlasts the spawned server's 30 second idle timeout. + "Use the read tool to read hello.txt, then run the shell command `sleep 35 && echo TOOL_OK` with the shell tool in the foreground (not in the background) and wait for it to finish, then reply DONE.", + ); + const first = yield* waitFor(threadId, settled); + assert.deepEqual( + first.runs.map((run) => run.status), + ["completed"], + ); + const shell = first.turnItems.find((item) => item.type === "command_execution"); + assert.deepInclude(shell, { status: "completed", exitCode: 0 }); + assert.include(shell?.type === "command_execution" ? shell.output : "", "TOOL_OK"); + assert.isDefined( + first.turnItems.find((item) => item.type === "dynamic_tool" && item.toolName === "read"), + ); + + yield* send( + threadId, + "stop", + "Run the shell command `sleep 60 && echo LATE` with the shell tool in the foreground (not in the background) and wait for it to finish, then reply DONE.", + ); + const running = yield* waitFor(threadId, (projection) => + projection.turnItems.some( + (item) => + item.type === "command_execution" && + item.status === "running" && + item.input.includes("sleep 60"), + ), + ); + const secondRun = running.runs.at(-1)!; + yield* orchestrator.dispatch({ + type: "run.interrupt", + commandId: CommandId.make("command:opencode2-live:interrupt"), + threadId, + runId: secondRun.id, + }); + const stopped = yield* waitFor(threadId, settled); + assert.deepEqual( + stopped.runs.map((run) => run.status), + ["completed", "interrupted"], + ); + const sleep = stopped.turnItems.find( + (item) => item.type === "command_execution" && item.input.includes("sleep 60"), + ); + assert.equal(sleep?.status, "interrupted"); + }).pipe(Effect.provide(liveLayer), Effect.provide(NodeServices.layer), Effect.scoped), + 360_000, + ); +}); diff --git a/apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.integration.test.ts b/apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.integration.test.ts index ea39e935f944..e6f708d88db6 100644 --- a/apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.integration.test.ts @@ -11,6 +11,10 @@ import { CursorOrchestratorReplayHarness } from "../Adapters/CursorAdapterV2.tes import { AcpRegistryOrchestratorReplayHarness } from "../Adapters/AcpRegistryAdapterV2.testkit.ts"; import { GrokOrchestratorReplayHarness } from "../Adapters/GrokAdapterV2.testkit.ts"; import { OpenCodeOrchestratorReplayHarness } from "../Adapters/OpenCodeAdapterV2.testkit.ts"; +import { + OPENCODE2_HTTP_PROTOCOL, + OpenCode2OrchestratorReplayHarness, +} from "../Adapters/OpenCode2AdapterV2.testkit.ts"; import { PiOrchestratorReplayHarness } from "../Adapters/PiAdapterV2.testkit.ts"; import * as IdAllocator from "../IdAllocator.ts"; import { provideDeterministicTestRuntime } from "./DeterministicRuntime.ts"; @@ -187,10 +191,16 @@ function runFixtureProviderWithRegisteredHarness(input: { harness: AcpRegistryOrchestratorReplayHarness, }).pipe(Effect.mapError(normalizeTestError), Effect.scoped); case "opencode": - return runFixtureProvider({ - ...input, - harness: OpenCodeOrchestratorReplayHarness, - }).pipe(Effect.mapError(normalizeTestError), Effect.scoped); + // One driver, two runtimes: the transcript's protocol says which one recorded it. + return readTranscript(input.driver.transcriptFile).pipe( + Effect.flatMap((transcript) => + transcript.protocol === OPENCODE2_HTTP_PROTOCOL + ? runFixtureProvider({ ...input, harness: OpenCode2OrchestratorReplayHarness }) + : runFixtureProvider({ ...input, harness: OpenCodeOrchestratorReplayHarness }), + ), + Effect.mapError(normalizeTestError), + Effect.scoped, + ); case "pi": return runFixtureProvider({ ...input, diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/index.ts b/apps/server/src/orchestration-v2/testkit/fixtures/index.ts index 24f2ca24ebe3..f9ef79fd18b3 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/index.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/index.ts @@ -55,6 +55,12 @@ import { assertOpenCodeChildApprovalOutput } from "./opencode_child_approval/out import { openCodeRunningChildApprovalInput } from "./opencode_running_child_approval/input.ts"; import { assertOpenCodeRunningChildApprovalOutput } from "./opencode_running_child_approval/output.ts"; import { openCodeSubagentInput } from "./opencode_subagent/input.ts"; +import { openCode2InterruptInput } from "./opencode2_interrupt/input.ts"; +import { assertOpenCode2InterruptOutput } from "./opencode2_interrupt/output.ts"; +import { openCode2SimpleInput } from "./opencode2_simple/input.ts"; +import { assertOpenCode2SimpleOutput } from "./opencode2_simple/output.ts"; +import { openCode2ToolCallInput } from "./opencode2_tool_call/input.ts"; +import { assertOpenCode2ToolCallOutput } from "./opencode2_tool_call/output.ts"; import { assertOpenCodeSubagentOutput } from "./opencode_subagent/output.ts"; import { assertCodexPlanQuestionsOutput, @@ -145,6 +151,7 @@ import { CURSOR_MODEL_SELECTION, GROK_MODEL_SELECTION, OPENCODE_MODEL_SELECTION, + OPENCODE2_MODEL_SELECTION, PI_MODEL_SELECTION, READ_ONLY_NEVER_POLICY, READ_ONLY_ON_REQUEST_POLICY, @@ -760,6 +767,49 @@ export const ORCHESTRATOR_REPLAY_FIXTURES: ReadonlyArray"}} +{"type": "emit_inbound", "label": "session.create.response", "frame": {"type": "sdk.response", "operation": "session.create", "data": {"data": {"id": "ses_f148c8240ffegmJ5ZhqhyYOcSH", "projectID": "global", "model": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}, "cost": 0, "tokens": {"input": 0, "output": 0, "reasoning": 0, "cache": {"read": 0, "write": 0}}, "time": {"created": 1790656609743, "updated": 1790656609743}, "title": "spike interrupt", "location": {"directory": ""}}}}} +{"type": "emit_inbound", "label": "session.created", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb737dcf001Bcj0k2QTAM0Kvj", "created": 1790656609743, "type": "session.created", "location": {"directory": ""}, "data": {"sessionID": "ses_f148c8240ffegmJ5ZhqhyYOcSH", "slug": "gentle-cabin", "version": "2.0.18", "projectID": "global", "location": {"directory": ""}, "subpath": "", "title": "spike interrupt", "model": {"id": "big-pickle", "providerID": "opencode"}}, "durable": {"aggregateID": "ses_f148c8240ffegmJ5ZhqhyYOcSH", "seq": 0, "version": 1}}}} +{"type": "expect_outbound", "label": "session.prompt", "frame": {"type": "session.prompt", "input": {"sessionID": "ses_f148c8240ffegmJ5ZhqhyYOcSH", "text": ""}}} +{"type": "emit_inbound", "label": "session.prompt.response", "frame": {"type": "sdk.response", "operation": "session.prompt", "data": {"data": {"id": "msg_0eb737de3001m3HJF1mWJrpc0l", "sessionID": "ses_f148c8240ffegmJ5ZhqhyYOcSH", "time": {"created": 1790656609769}, "type": "user", "payload": {"text": "Run the shell command `sleep 60 && echo LATE` with the bash tool, then reply DONE."}, "delivery": "steer"}}}} +{"type": "emit_inbound", "label": "session.inbox.enqueued", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb737de9001N9VhyJRu4KRiDv", "created": 1790656609769, "type": "session.inbox.enqueued", "location": {"directory": ""}, "data": {"inboxID": "msg_0eb737de3001m3HJF1mWJrpc0l", "sessionID": "ses_f148c8240ffegmJ5ZhqhyYOcSH", "item": {"type": "user", "payload": {"text": "Run the shell command `sleep 60 && echo LATE` with the bash tool, then reply DONE."}, "delivery": "steer"}}, "durable": {"aggregateID": "ses_f148c8240ffegmJ5ZhqhyYOcSH", "seq": 1, "version": 1}}}} +{"type": "emit_inbound", "label": "session.execution.started", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb737dee001ZuAVZxlTiA7c0q", "created": 1790656609774, "type": "session.execution.started", "data": {"sessionID": "ses_f148c8240ffegmJ5ZhqhyYOcSH"}, "durable": {"aggregateID": "ses_f148c8240ffegmJ5ZhqhyYOcSH", "seq": 2, "version": 1}}}} +{"type": "emit_inbound", "label": "session.instructions.updated", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb737e07001fxasLhcGBUnjTn", "created": 1790656609799, "metadata": {"instructions": {"initial": true}}, "type": "session.instructions.updated", "location": {"directory": ""}, "data": {"sessionID": "ses_f148c8240ffegmJ5ZhqhyYOcSH", "delta": {"core/environment": "88f327784ee8236f77386494957829084cfea17bd5045c9657e7a0157e297b52", "core/date": "bf08b8486572ab3eef677a3c9ee12468e411ae41a8f4d2fb6cf02a6ca73f1986", "core/codemode": "616a7ad8b8e40e8a71eab238dfbbe9eaf0f4a9e6fdad853226d075bfe602e1e6", "core/skill-guidance": "0d001919496102654717308c72635523bf7c3cd99c81e058cc79f2947104f9a5"}}, "durable": {"aggregateID": "ses_f148c8240ffegmJ5ZhqhyYOcSH", "seq": 3, "version": 2}}}} +{"type": "emit_inbound", "label": "session.inbox.delivered", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb737e0e001zOVQWTib5evpnt", "created": 1790656609806, "type": "session.inbox.delivered", "location": {"directory": ""}, "data": {"sessionID": "ses_f148c8240ffegmJ5ZhqhyYOcSH", "inboxID": "msg_0eb737de3001m3HJF1mWJrpc0l"}, "durable": {"aggregateID": "ses_f148c8240ffegmJ5ZhqhyYOcSH", "seq": 4, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.started", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7392d8001Mb6P9mUDAqzdcK", "created": 1790656615128, "type": "session.step.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f148c8240ffegmJ5ZhqhyYOcSH", "agent": "build", "model": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}, "assistantMessageID": "msg_0eb737e1a001oFX6xYltb810rB", "snapshot": "99482b54df6361365fa0ed7363ccf07edaa7fc93", "started": 1790656609845}, "durable": {"aggregateID": "ses_f148c8240ffegmJ5ZhqhyYOcSH", "seq": 5, "version": 1}}}} +{"type": "emit_inbound", "label": "session.text.started", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7392df001Hhopzs7fProqK8", "created": 1790656615135, "type": "session.text.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f148c8240ffegmJ5ZhqhyYOcSH", "assistantMessageID": "msg_0eb737e1a001oFX6xYltb810rB", "ordinal": 0}, "durable": {"aggregateID": "ses_f148c8240ffegmJ5ZhqhyYOcSH", "seq": 6, "version": 1}}}} +{"type": "emit_inbound", "label": "session.text.delta", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb739348001XY34oXJ8rQZr90", "created": 1790656615240, "type": "session.text.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f148c8240ffegmJ5ZhqhyYOcSH", "assistantMessageID": "msg_0eb737e1a001oFX6xYltb810rB", "ordinal": 0, "delta": "I'll run that now"}}}} +{"type": "emit_inbound", "label": "session.text.delta.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb73943100113RElcMOYeb4iX", "created": 1790656615473, "type": "session.text.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f148c8240ffegmJ5ZhqhyYOcSH", "assistantMessageID": "msg_0eb737e1a001oFX6xYltb810rB", "ordinal": 0, "delta": "."}}}} +{"type": "emit_inbound", "label": "session.tool.input.started", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb739432001ZjlIdIWwJBPzGR", "created": 1790656615474, "type": "session.tool.input.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f148c8240ffegmJ5ZhqhyYOcSH", "assistantMessageID": "msg_0eb737e1a001oFX6xYltb810rB", "id": "call_function_1swk4r27uraj_1", "name": "shell"}, "durable": {"aggregateID": "ses_f148c8240ffegmJ5ZhqhyYOcSH", "seq": 7, "version": 1}}}} +{"type": "emit_inbound", "label": "session.tool.input.ended", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb739464001q1x8uivfkNg2Oe", "created": 1790656615524, "type": "session.tool.input.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f148c8240ffegmJ5ZhqhyYOcSH", "assistantMessageID": "msg_0eb737e1a001oFX6xYltb810rB", "id": "call_function_1swk4r27uraj_1", "text": "{\"command\": \"sleep 60 && echo LATE\", \"timeout\": 90000}"}, "durable": {"aggregateID": "ses_f148c8240ffegmJ5ZhqhyYOcSH", "seq": 8, "version": 1}}}} +{"type": "emit_inbound", "label": "session.tool.called", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb739469001e9W3tarUt9Y2oN", "created": 1790656615529, "type": "session.tool.called", "location": {"directory": ""}, "data": {"sessionID": "ses_f148c8240ffegmJ5ZhqhyYOcSH", "assistantMessageID": "msg_0eb737e1a001oFX6xYltb810rB", "id": "call_function_1swk4r27uraj_1", "input": {"command": "sleep 60 && echo LATE", "timeout": 90000}, "executed": false}, "durable": {"aggregateID": "ses_f148c8240ffegmJ5ZhqhyYOcSH", "seq": 9, "version": 1}}}} +{"type": "emit_inbound", "label": "session.text.ended", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb73946e001RTVpUhMUPMWgG9", "created": 1790656615534, "type": "session.text.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f148c8240ffegmJ5ZhqhyYOcSH", "assistantMessageID": "msg_0eb737e1a001oFX6xYltb810rB", "ordinal": 0, "text": "I'll run that now."}, "durable": {"aggregateID": "ses_f148c8240ffegmJ5ZhqhyYOcSH", "seq": 10, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.streamed", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7394730017IaLf37di2Kvhq", "created": 1790656615539, "type": "session.step.streamed", "location": {"directory": ""}, "data": {"sessionID": "ses_f148c8240ffegmJ5ZhqhyYOcSH", "assistantMessageID": "msg_0eb737e1a001oFX6xYltb810rB"}, "durable": {"aggregateID": "ses_f148c8240ffegmJ5ZhqhyYOcSH", "seq": 11, "version": 1}}}} +{"type": "emit_inbound", "label": "shell.created", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb73947f00101SkoJKHKPbjnG", "created": 1790656615551, "type": "shell.created", "location": {"directory": ""}, "data": {"info": {"id": "sh_0eb73947c001P1Uf57ZhQytZgd", "status": "running", "command": "sleep 60 && echo LATE", "cwd": "", "shell": "/bin/bash", "file": "/xdg-data/opencode/shell/global/sh_0eb73947c001P1Uf57ZhQytZgd.out", "metadata": {"sessionID": "ses_f148c8240ffegmJ5ZhqhyYOcSH"}, "time": {"started": 1790656615548}}}}}} +{"type": "emit_inbound", "label": "session.tool.progress", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb739480001xmZEQ2fMQ8ijwb", "created": 1790656615552, "type": "session.tool.progress", "location": {"directory": ""}, "data": {"sessionID": "ses_f148c8240ffegmJ5ZhqhyYOcSH", "assistantMessageID": "msg_0eb737e1a001oFX6xYltb810rB", "id": "call_function_1swk4r27uraj_1", "metadata": {"shellID": "sh_0eb73947c001P1Uf57ZhQytZgd"}}}}} +{"type": "expect_outbound", "label": "session.interrupt", "frame": {"type": "session.interrupt", "input": {"sessionID": "ses_f148c8240ffegmJ5ZhqhyYOcSH"}}} +{"type": "emit_inbound", "label": "session.interrupt.response", "frame": {"type": "sdk.response", "operation": "session.interrupt", "data": {"interrupted": true}}} +{"type": "emit_inbound", "label": "shell.deleted", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb739a65001hovknjExaKp2Pa", "created": 1790656617061, "type": "shell.deleted", "location": {"directory": ""}, "data": {"id": "sh_0eb73947c001P1Uf57ZhQytZgd"}}}} +{"type": "emit_inbound", "label": "session.tool.failed", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb739a67001CAYtMibREOcIVU", "created": 1790656617063, "type": "session.tool.failed", "location": {"directory": ""}, "data": {"sessionID": "ses_f148c8240ffegmJ5ZhqhyYOcSH", "assistantMessageID": "msg_0eb737e1a001oFX6xYltb810rB", "id": "call_function_1swk4r27uraj_1", "error": {"type": "aborted", "message": "Tool execution interrupted"}, "metadata": {"shellID": "sh_0eb73947c001P1Uf57ZhQytZgd"}, "executed": false}, "durable": {"aggregateID": "ses_f148c8240ffegmJ5ZhqhyYOcSH", "seq": 12, "version": 2}}}} +{"type": "emit_inbound", "label": "shell.exited", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb739a76001gLJM0X8oOYS91E", "created": 1790656617078, "type": "shell.exited", "location": {"directory": ""}, "data": {"id": "sh_0eb73947c001P1Uf57ZhQytZgd", "status": "exited"}}}} +{"type": "emit_inbound", "label": "session.step.failed", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb739a80001oP9Vi3qmDblnTE", "created": 1790656617088, "type": "session.step.failed", "location": {"directory": ""}, "data": {"sessionID": "ses_f148c8240ffegmJ5ZhqhyYOcSH", "assistantMessageID": "msg_0eb737e1a001oFX6xYltb810rB", "error": {"type": "aborted", "message": "Step interrupted"}, "rawFinish": "tool_calls", "cost": 0, "tokens": {"input": 8762, "output": 47, "reasoning": 0, "cache": {"read": 486, "write": 0}}, "snapshot": "99482b54df6361365fa0ed7363ccf07edaa7fc93", "files": []}, "durable": {"aggregateID": "ses_f148c8240ffegmJ5ZhqhyYOcSH", "seq": 13, "version": 1}}}} +{"type": "emit_inbound", "label": "session.usage.updated", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb739a88001DgMUWPTkTPH9Wr", "created": 1790656617096, "type": "session.usage.updated", "data": {"sessionID": "ses_f148c8240ffegmJ5ZhqhyYOcSH", "cost": 0, "tokens": {"input": 8762, "output": 47, "reasoning": 0, "cache": {"read": 486, "write": 0}}}}}} +{"type": "emit_inbound", "label": "session.execution.interrupted", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb739a8a001WpmfQsDSGLFO9H", "created": 1790656617098, "type": "session.execution.interrupted", "data": {"sessionID": "ses_f148c8240ffegmJ5ZhqhyYOcSH", "reason": "user"}, "durable": {"aggregateID": "ses_f148c8240ffegmJ5ZhqhyYOcSH", "seq": 14, "version": 1}}}} +{"type": "runtime_exit", "status": "success"} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_interrupt/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_interrupt/output.ts new file mode 100644 index 000000000000..69cc24f2fd85 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_interrupt/output.ts @@ -0,0 +1,47 @@ +import { assert } from "@effect/vitest"; +import type { ProviderReplayTranscript } from "@t3tools/contracts"; + +import type { OrchestratorV2ScenarioResult } from "../../OrchestratorScenario.ts"; +import { + assertBaseProjection, + assertSemanticProjectionIntegrity, + assertTurnItemTypes, + assertUserMessagesInclude, + assertVisibleTurnItemsMirrorLocalTurnItems, + OPENCODE2_INTERRUPT_PROMPT, + projectionFor, +} from "../shared.ts"; + +/** + * Stop during a running shell call: `session.interrupt`, then OpenCode fails the + * tool as aborted and ends the execution with `interrupted`. + */ +export function assertOpenCode2InterruptOutput( + result: OrchestratorV2ScenarioResult, + transcript: ProviderReplayTranscript, +) { + assertBaseProjection({ result, transcript, runCount: 1, runStatuses: ["interrupted"] }); + const projection = projectionFor(result, transcript.scenario); + assertSemanticProjectionIntegrity(projection); + assertVisibleTurnItemsMirrorLocalTurnItems(projection); + assertTurnItemTypes(projection, [ + "user_message", + "assistant_message", + "command_execution", + "run_interrupt_request", + "run_interrupt_result", + ]); + assertUserMessagesInclude(projection, [OPENCODE2_INTERRUPT_PROMPT]); + + const shell = projection.turnItems.find((item) => item.type === "command_execution"); + assert.deepInclude(shell, { input: "sleep 60 && echo LATE", status: "interrupted" }); + assert.equal( + projection.turnItems.find((item) => item.type === "run_interrupt_result")?.status, + "interrupted", + ); + assert.deepEqual( + projection.providerTurns.map((turn) => turn.status), + ["interrupted"], + ); + assert.equal(projection.providerThreads[0]?.status, "idle"); +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_simple/input.ts b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_simple/input.ts new file mode 100644 index 000000000000..2c7658f74ade --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_simple/input.ts @@ -0,0 +1,5 @@ +import { OPENCODE2_SIMPLE_PROMPT, type OrchestratorFixtureInput } from "../shared.ts"; + +export function openCode2SimpleInput(): OrchestratorFixtureInput { + return { steps: [{ type: "message", text: OPENCODE2_SIMPLE_PROMPT }] }; +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_simple/opencode_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_simple/opencode_transcript.ndjson new file mode 100644 index 000000000000..b4693de82df0 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_simple/opencode_transcript.ndjson @@ -0,0 +1,49 @@ +{"type": "transcript_start", "provider": "opencode", "protocol": "opencode2-http.sse", "version": "2.0.18", "scenario": "opencode2_simple", "metadata": {"source": "opencode2-spike/recordings/simple.ndjson", "capturedAt": "2026-09-28", "nativeSessionId": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "description": "A real OpenCode 2.0.18 session recorded at the HTTP and SSE boundary by the spike recorder."}} +{"type": "expect_outbound", "label": "event.subscribe", "frame": {"type": "event.subscribe"}} +{"type": "emit_inbound", "label": "server.connected", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb73205b001QM7spc7rTwRevR", "type": "server.connected", "data": {}}}} +{"type": "expect_outbound", "label": "session.create", "frame": {"type": "session.create", "input": ""}} +{"type": "emit_inbound", "label": "session.create.response", "frame": {"type": "sdk.response", "operation": "session.create", "data": {"data": {"id": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "projectID": "global", "model": {"id": "space-bunny-free", "providerID": "opencode", "variant": "high"}, "cost": 0, "tokens": {"input": 0, "output": 0, "reasoning": 0, "cache": {"read": 0, "write": 0}}, "time": {"created": 1790656585840, "updated": 1790656585840}, "title": "spike simple", "location": {"directory": ""}}}}} +{"type": "emit_inbound", "label": "session.created", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb732070001MeC12AQ1Ds6a0a", "created": 1790656585840, "type": "session.created", "location": {"directory": ""}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "slug": "sunny-pixel", "version": "2.0.18", "projectID": "global", "location": {"directory": ""}, "subpath": "", "title": "spike simple", "model": {"id": "space-bunny-free", "providerID": "opencode", "variant": "high"}}, "durable": {"aggregateID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "seq": 0, "version": 1}}}} +{"type": "emit_inbound", "label": "project.updated", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb732078001oO5ghtqXkMav9d", "created": 1790656585848, "type": "project.updated", "data": {"id": "global", "canonical": "", "vcs": "git", "time": {"created": 1790656063718, "updated": 1790656063718, "active": 1790656585847}, "sandboxes": []}}}} +{"type": "expect_outbound", "label": "session.prompt", "frame": {"type": "session.prompt", "input": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "text": ""}}} +{"type": "emit_inbound", "label": "session.prompt.response", "frame": {"type": "sdk.response", "operation": "session.prompt", "data": {"data": {"id": "msg_0eb732081001RntUJfRtTXOAjd", "sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "time": {"created": 1790656585859}, "type": "user", "payload": {"text": "Think carefully step by step about whether 391 is prime, showing your reasoning, then answer in one short sentence."}, "delivery": "steer"}}}} +{"type": "emit_inbound", "label": "session.inbox.enqueued", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb732083001UPoh8n1ciLGG4J", "created": 1790656585859, "type": "session.inbox.enqueued", "location": {"directory": ""}, "data": {"inboxID": "msg_0eb732081001RntUJfRtTXOAjd", "sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "item": {"type": "user", "payload": {"text": "Think carefully step by step about whether 391 is prime, showing your reasoning, then answer in one short sentence."}, "delivery": "steer"}}, "durable": {"aggregateID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "seq": 1, "version": 1}}}} +{"type": "emit_inbound", "label": "session.execution.started", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb732089001TRfMB4IJIUtJuG", "created": 1790656585865, "type": "session.execution.started", "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk"}, "durable": {"aggregateID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "seq": 2, "version": 1}}}} +{"type": "emit_inbound", "label": "session.instructions.updated", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb732098001v3n3L11aMx0r6G", "created": 1790656585880, "metadata": {"instructions": {"initial": true}}, "type": "session.instructions.updated", "location": {"directory": ""}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "delta": {"core/environment": "27a84baf7fee37fde05d49a6aaf0f184019068a15362a38f5d1ec17ac7e8f2b7", "core/date": "bf08b8486572ab3eef677a3c9ee12468e411ae41a8f4d2fb6cf02a6ca73f1986", "core/codemode": "616a7ad8b8e40e8a71eab238dfbbe9eaf0f4a9e6fdad853226d075bfe602e1e6", "core/skill-guidance": "0d001919496102654717308c72635523bf7c3cd99c81e058cc79f2947104f9a5"}}, "durable": {"aggregateID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "seq": 3, "version": 2}}}} +{"type": "emit_inbound", "label": "session.inbox.delivered", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb73209d001wgPxWUQRwkXvBn", "created": 1790656585885, "type": "session.inbox.delivered", "location": {"directory": ""}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "inboxID": "msg_0eb732081001RntUJfRtTXOAjd"}, "durable": {"aggregateID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "seq": 4, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.started", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7326930015SMmHkSszcae37", "created": 1790656587411, "type": "session.step.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "agent": "build", "model": {"id": "space-bunny-free", "providerID": "opencode", "variant": "high"}, "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "snapshot": "99482b54df6361365fa0ed7363ccf07edaa7fc93", "started": 1790656585925}, "durable": {"aggregateID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "seq": 5, "version": 1}}}} +{"type": "emit_inbound", "label": "session.reasoning.started", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb732699001FC5efnDXzkrBV3", "created": 1790656587417, "type": "session.reasoning.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "ordinal": 0, "state": {"reasoningField": "reasoning_content"}}, "durable": {"aggregateID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "seq": 6, "version": 1}}}} +{"type": "emit_inbound", "label": "session.reasoning.delta", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb732702001XkfhcVQWas7guy", "created": 1790656587522, "type": "session.reasoning.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "ordinal": 0, "delta": "Check divisibility up to sqrt("}}}} +{"type": "emit_inbound", "label": "session.reasoning.delta.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb73277b001U6tDaZPzh5G8Ub", "created": 1790656587643, "type": "session.reasoning.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "ordinal": 0, "delta": "391) ≈ 19.77."}}}} +{"type": "emit_inbound", "label": "session.reasoning.delta.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb732805001zRua1yhKskE6du", "created": 1790656587781, "type": "session.reasoning.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "ordinal": 0, "delta": " Primes: 2,3,5,7,"}}}} +{"type": "emit_inbound", "label": "session.reasoning.delta.4", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb73286e001XXnCtQeutby7Jh", "created": 1790656587886, "type": "session.reasoning.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "ordinal": 0, "delta": "11,13,17,19.\n\n391 is"}}}} +{"type": "emit_inbound", "label": "session.reasoning.delta.5", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7328e8001vpkz0eDsHflXmv", "created": 1790656588008, "type": "session.reasoning.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "ordinal": 0, "delta": " odd, not div by 2"}}}} +{"type": "emit_inbound", "label": "session.reasoning.delta.6", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb732955001YnC9O9FAgIagoL", "created": 1790656588118, "type": "session.reasoning.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "ordinal": 0, "delta": ". 3+9+"}}}} +{"type": "emit_inbound", "label": "session.reasoning.delta.7", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb732aaa001b129VgQ4aT7avM", "created": 1790656588458, "type": "session.reasoning.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "ordinal": 0, "delta": "1=13, not div by 3. Not by 5. 7: 7×55=385, remainder 6. 11: 11×"}}}} +{"type": "emit_inbound", "label": "session.reasoning.delta.8", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb732b29001AMm839z4dDYXHE", "created": 1790656588585, "type": "session.reasoning.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "ordinal": 0, "delta": "35=385, remainder 6. 13: "}}}} +{"type": "emit_inbound", "label": "session.reasoning.delta.9", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb732ba0001Ivdi55eX7RWaIN", "created": 1790656588704, "type": "session.reasoning.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "ordinal": 0, "delta": "13×30=390, remainder 1. "}}}} +{"type": "emit_inbound", "label": "session.reasoning.delta.10", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb732c1f001l5voAVE8zZty1G", "created": 1790656588832, "type": "session.reasoning.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "ordinal": 0, "delta": "17: 17×23=391!"}}}} +{"type": "emit_inbound", "label": "session.reasoning.delta.11", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb732c95001xtU1t86iQ32FcX", "created": 1790656588949, "type": "session.reasoning.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "ordinal": 0, "delta": " 17×23 = 391. Yes"}}}} +{"type": "emit_inbound", "label": "session.reasoning.delta.12", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb732d3b001F922FhdbWHFFh9", "created": 1790656589115, "type": "session.reasoning.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "ordinal": 0, "delta": ": 17×23 = 340 +"}}}} +{"type": "emit_inbound", "label": "session.reasoning.delta.13", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb732db1001Llbce6UsLrsMtr", "created": 1790656589234, "type": "session.reasoning.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "ordinal": 0, "delta": " 51 = 391. So "}}}} +{"type": "emit_inbound", "label": "session.reasoning.delta.14", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb732de6001fDagd9vuCaT77g", "created": 1790656589286, "type": "session.reasoning.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "ordinal": 0, "delta": "391 is composite."}}}} +{"type": "emit_inbound", "label": "session.text.started", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb732de7001zYvkQcsI5WOzgG", "created": 1790656589287, "type": "session.text.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "ordinal": 0}, "durable": {"aggregateID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "seq": 7, "version": 1}}}} +{"type": "emit_inbound", "label": "session.text.delta", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb732e50001GdabW0WKYVl32T", "created": 1790656589392, "type": "session.text.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "ordinal": 0, "delta": "√391 ≈ 19.8,"}}}} +{"type": "emit_inbound", "label": "session.text.delta.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb732ed2001IBPZhAC1kx4d1v", "created": 1790656589522, "type": "session.text.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "ordinal": 0, "delta": " so I need to test primes "}}}} +{"type": "emit_inbound", "label": "session.text.delta.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb732f38001E0KjfQvDXHZ7h1", "created": 1790656589624, "type": "session.text.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "ordinal": 0, "delta": "2, 3, 5, 7, "}}}} +{"type": "emit_inbound", "label": "session.text.delta.4", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb732fad001JzcESOkjYwU5ik", "created": 1790656589741, "type": "session.text.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "ordinal": 0, "delta": "11, 13, 17, 19: it's"}}}} +{"type": "emit_inbound", "label": "session.text.delta.5", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb73301c001UBnr5x154Roys2", "created": 1790656589852, "type": "session.text.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "ordinal": 0, "delta": " odd, digits sum to 13, doesn't"}}}} +{"type": "emit_inbound", "label": "session.text.delta.6", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb73308e001sYejhe1LAMtvcI", "created": 1790656589966, "type": "session.text.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "ordinal": 0, "delta": " end in 0/5, and 7×"}}}} +{"type": "emit_inbound", "label": "session.text.delta.7", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7331030012XzmUwjcWGmPUe", "created": 1790656590084, "type": "session.text.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "ordinal": 0, "delta": "55=385, 11"}}}} +{"type": "emit_inbound", "label": "session.text.delta.8", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7331700018ST8kVeG36WRUV", "created": 1790656590192, "type": "session.text.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "ordinal": 0, "delta": "×35=385, 13×30=390 leave remain"}}}} +{"type": "emit_inbound", "label": "session.text.delta.9", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7331df001Nm2KLtcVET4KlU", "created": 1790656590304, "type": "session.text.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "ordinal": 0, "delta": "ders — but 17×23 = 391"}}}} +{"type": "emit_inbound", "label": "session.text.delta.10", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb733251001HkPIEuVZxWlLIU", "created": 1790656590417, "type": "session.text.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "ordinal": 0, "delta": " exactly, so the search"}}}} +{"type": "emit_inbound", "label": "session.text.delta.11", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7332bd001f1FZxccg5aNmhq", "created": 1790656590525, "type": "session.text.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "ordinal": 0, "delta": " stops there.\n\n391 is not prime: it's the"}}}} +{"type": "emit_inbound", "label": "session.text.delta.12", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb73333d001mIrQbkY6rbRMi3", "created": 1790656590654, "type": "session.text.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "ordinal": 0, "delta": " product 17 × 23."}}}} +{"type": "emit_inbound", "label": "session.reasoning.ended", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7333720010sLSSNU5xRXUwa", "created": 1790656590707, "type": "session.reasoning.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "ordinal": 0, "text": "Check divisibility up to sqrt(391) ≈ 19.77. Primes: 2,3,5,7,11,13,17,19.\n\n391 is odd, not div by 2. 3+9+1=13, not div by 3. Not by 5. 7: 7×55=385, remainder 6. 11: 11×35=385, remainder 6. 13: 13×30=390, remainder 1. 17: 17×23=391! 17×23 = 391. Yes: 17×23 = 340 + 51 = 391. So 391 is composite.", "state": {"reasoningField": "reasoning_content"}}, "durable": {"aggregateID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "seq": 8, "version": 1}}}} +{"type": "emit_inbound", "label": "session.text.ended", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb733378001jAPLwPeycQ4M5d", "created": 1790656590712, "type": "session.text.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "ordinal": 0, "text": "√391 ≈ 19.8, so I need to test primes 2, 3, 5, 7, 11, 13, 17, 19: it's odd, digits sum to 13, doesn't end in 0/5, and 7×55=385, 11×35=385, 13×30=390 leave remainders — but 17×23 = 391 exactly, so the search stops there.\n\n391 is not prime: it's the product 17 × 23."}, "durable": {"aggregateID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "seq": 9, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.streamed", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb73337f001Y6nJkjv1tXkqGt", "created": 1790656590719, "type": "session.step.streamed", "location": {"directory": ""}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT"}, "durable": {"aggregateID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "seq": 10, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.ended", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb733390001vg5GRCuNzMFbCA", "created": 1790656590736, "type": "session.step.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "finish": "stop", "rawFinish": "stop", "cost": 0, "tokens": {"input": 8701, "output": 113, "reasoning": 147, "cache": {"read": 489, "write": 0}}, "snapshot": "99482b54df6361365fa0ed7363ccf07edaa7fc93", "files": []}, "durable": {"aggregateID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "seq": 11, "version": 1}}}} +{"type": "emit_inbound", "label": "session.usage.updated", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb733396001Ke5QhqiR5CdvP6", "created": 1790656590742, "type": "session.usage.updated", "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "cost": 0, "tokens": {"input": 8701, "output": 113, "reasoning": 147, "cache": {"read": 489, "write": 0}}}}}} +{"type": "emit_inbound", "label": "session.execution.succeeded", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb733399001NhwTrB32UU6d6H", "created": 1790656590745, "type": "session.execution.succeeded", "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk"}, "durable": {"aggregateID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "seq": 12, "version": 1}}}} +{"type": "runtime_exit", "status": "success"} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_simple/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_simple/output.ts new file mode 100644 index 000000000000..c238110c27c8 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_simple/output.ts @@ -0,0 +1,41 @@ +import { assert } from "@effect/vitest"; +import type { ProviderReplayTranscript } from "@t3tools/contracts"; + +import type { OrchestratorV2ScenarioResult } from "../../OrchestratorScenario.ts"; +import { + assertAssistantTextIncludes, + assertBaseProjection, + assertSemanticProjectionIntegrity, + assertTurnItemTypeSequence, + assertUserMessagesInclude, + assertVisibleTurnItemsMirrorLocalTurnItems, + OPENCODE2_SIMPLE_PROMPT, + projectionFor, +} from "../shared.ts"; + +/** One OpenCode 2 turn with streamed reasoning and text, ended by `execution.succeeded`. */ +export function assertOpenCode2SimpleOutput( + result: OrchestratorV2ScenarioResult, + transcript: ProviderReplayTranscript, +) { + assertBaseProjection({ result, transcript, runCount: 1, runStatuses: ["completed"] }); + const projection = projectionFor(result, transcript.scenario); + assertSemanticProjectionIntegrity(projection); + assertVisibleTurnItemsMirrorLocalTurnItems(projection); + assertTurnItemTypeSequence(projection, [ + "user_message", + "reasoning", + "assistant_message", + "checkpoint", + ]); + assertUserMessagesInclude(projection, [OPENCODE2_SIMPLE_PROMPT]); + assertAssistantTextIncludes(projection, "391 is not prime: it's the product 17 × 23."); + + const reasoning = projection.turnItems.find((item) => item.type === "reasoning"); + assert.include(reasoning?.type === "reasoning" ? reasoning.text : "", "17×23 = 391"); + + assert.deepEqual( + projection.providerTurns.map((turn) => turn.status), + ["completed"], + ); +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_tool_call/input.ts b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_tool_call/input.ts new file mode 100644 index 000000000000..f403c2805693 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_tool_call/input.ts @@ -0,0 +1,5 @@ +import { OPENCODE2_TOOL_CALL_PROMPT, type OrchestratorFixtureInput } from "../shared.ts"; + +export function openCode2ToolCallInput(): OrchestratorFixtureInput { + return { steps: [{ type: "message", text: OPENCODE2_TOOL_CALL_PROMPT }] }; +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_tool_call/opencode_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_tool_call/opencode_transcript.ndjson new file mode 100644 index 000000000000..06bcfa7f50b4 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_tool_call/opencode_transcript.ndjson @@ -0,0 +1,40 @@ +{"type": "transcript_start", "provider": "opencode", "protocol": "opencode2-http.sse", "version": "2.0.18", "scenario": "opencode2_tool_call", "metadata": {"source": "opencode2-spike/recordings/tool_call.ndjson", "capturedAt": "2026-09-28", "nativeSessionId": "ses_f148ca2deffeJcwCnRQtb0YFNX", "description": "A real OpenCode 2.0.18 session recorded at the HTTP and SSE boundary by the spike recorder."}} +{"type": "expect_outbound", "label": "event.subscribe", "frame": {"type": "event.subscribe"}} +{"type": "emit_inbound", "label": "server.connected", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb735d1f001svh3y4Umi1226r", "type": "server.connected", "data": {}}}} +{"type": "expect_outbound", "label": "session.create", "frame": {"type": "session.create", "input": ""}} +{"type": "emit_inbound", "label": "session.create.response", "frame": {"type": "sdk.response", "operation": "session.create", "data": {"data": {"id": "ses_f148ca2deffeJcwCnRQtb0YFNX", "projectID": "global", "model": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}, "cost": 0, "tokens": {"input": 0, "output": 0, "reasoning": 0, "cache": {"read": 0, "write": 0}}, "time": {"created": 1790656601394, "updated": 1790656601394}, "title": "spike tool_call", "location": {"directory": ""}}}}} +{"type": "emit_inbound", "label": "session.created", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb735d32001oBstTIapVlESgH", "created": 1790656601394, "type": "session.created", "location": {"directory": ""}, "data": {"sessionID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "slug": "shiny-pixel", "version": "2.0.18", "projectID": "global", "location": {"directory": ""}, "subpath": "", "title": "spike tool_call", "model": {"id": "big-pickle", "providerID": "opencode"}}, "durable": {"aggregateID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "seq": 0, "version": 1}}}} +{"type": "expect_outbound", "label": "session.prompt", "frame": {"type": "session.prompt", "input": {"sessionID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "text": ""}}} +{"type": "emit_inbound", "label": "session.prompt.response", "frame": {"type": "sdk.response", "operation": "session.prompt", "data": {"data": {"id": "msg_0eb735d41001NJee1EvVePJAK5", "sessionID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "time": {"created": 1790656601410}, "type": "user", "payload": {"text": "Use the read tool to read hello.txt, then run the shell command `echo TOOL_OK` with the bash tool, then reply DONE."}, "delivery": "steer"}}}} +{"type": "emit_inbound", "label": "session.inbox.enqueued", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb735d42001UMQBpocmd7OqMP", "created": 1790656601410, "type": "session.inbox.enqueued", "location": {"directory": ""}, "data": {"inboxID": "msg_0eb735d41001NJee1EvVePJAK5", "sessionID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "item": {"type": "user", "payload": {"text": "Use the read tool to read hello.txt, then run the shell command `echo TOOL_OK` with the bash tool, then reply DONE."}, "delivery": "steer"}}, "durable": {"aggregateID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "seq": 1, "version": 1}}}} +{"type": "emit_inbound", "label": "session.execution.started", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb735d46001o7CyucPD4Xr2AG", "created": 1790656601414, "type": "session.execution.started", "data": {"sessionID": "ses_f148ca2deffeJcwCnRQtb0YFNX"}, "durable": {"aggregateID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "seq": 2, "version": 1}}}} +{"type": "emit_inbound", "label": "session.instructions.updated", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb735d4f001xePHrNQBjv7Wxz", "created": 1790656601423, "metadata": {"instructions": {"initial": true}}, "type": "session.instructions.updated", "location": {"directory": ""}, "data": {"sessionID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "delta": {"core/environment": "4124f26df9139f18cb4ef13782d7be5e235b3e740a3061fc32ed563cebb67a9a", "core/date": "bf08b8486572ab3eef677a3c9ee12468e411ae41a8f4d2fb6cf02a6ca73f1986", "core/codemode": "616a7ad8b8e40e8a71eab238dfbbe9eaf0f4a9e6fdad853226d075bfe602e1e6", "core/skill-guidance": "0d001919496102654717308c72635523bf7c3cd99c81e058cc79f2947104f9a5"}}, "durable": {"aggregateID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "seq": 3, "version": 2}}}} +{"type": "emit_inbound", "label": "session.inbox.delivered", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb735d53001Ev2w1ZYD97yZYh", "created": 1790656601427, "type": "session.inbox.delivered", "location": {"directory": ""}, "data": {"sessionID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "inboxID": "msg_0eb735d41001NJee1EvVePJAK5"}, "durable": {"aggregateID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "seq": 4, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.started", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7361fd0012GEQCfpQlNDBNo", "created": 1790656602621, "type": "session.step.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "agent": "build", "model": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}, "assistantMessageID": "msg_0eb735d5b001oAFVeY5jz3WD4Z", "snapshot": "99482b54df6361365fa0ed7363ccf07edaa7fc93", "started": 1790656601463}, "durable": {"aggregateID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "seq": 5, "version": 1}}}} +{"type": "emit_inbound", "label": "session.text.started", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb736203001bd6vAXMGy7hRxI", "created": 1790656602627, "type": "session.text.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "assistantMessageID": "msg_0eb735d5b001oAFVeY5jz3WD4Z", "ordinal": 0}, "durable": {"aggregateID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "seq": 6, "version": 1}}}} +{"type": "emit_inbound", "label": "session.text.delta", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb73626c001wyiNfKgecgIZfS", "created": 1790656602732, "type": "session.text.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "assistantMessageID": "msg_0eb735d5b001oAFVeY5jz3WD4Z", "ordinal": 0, "delta": "I'll read the"}}}} +{"type": "emit_inbound", "label": "session.text.delta.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb736359001hBVh4GO11sxJSW", "created": 1790656602969, "type": "session.text.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "assistantMessageID": "msg_0eb735d5b001oAFVeY5jz3WD4Z", "ordinal": 0, "delta": " file and run the command."}}}} +{"type": "emit_inbound", "label": "session.tool.input.started", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb73635a0011XYyhWnFWuvsiP", "created": 1790656602971, "type": "session.tool.input.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "assistantMessageID": "msg_0eb735d5b001oAFVeY5jz3WD4Z", "id": "call_function_xjy1lqis49ru_1", "name": "read"}, "durable": {"aggregateID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "seq": 7, "version": 1}}}} +{"type": "emit_inbound", "label": "session.tool.input.started.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb736362001r14GAowIQYQ2Iy", "created": 1790656602978, "type": "session.tool.input.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "assistantMessageID": "msg_0eb735d5b001oAFVeY5jz3WD4Z", "id": "call_function_xjy1lqis49ru_2", "name": "shell"}, "durable": {"aggregateID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "seq": 8, "version": 1}}}} +{"type": "emit_inbound", "label": "session.tool.input.ended", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb736369001k2d65sN0QIz4h4", "created": 1790656602985, "type": "session.tool.input.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "assistantMessageID": "msg_0eb735d5b001oAFVeY5jz3WD4Z", "id": "call_function_xjy1lqis49ru_1", "text": "{\"path\": \"hello.txt\"}"}, "durable": {"aggregateID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "seq": 9, "version": 1}}}} +{"type": "emit_inbound", "label": "session.tool.called", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb73636e001T0cGRe6bsaiDNZ", "created": 1790656602990, "type": "session.tool.called", "location": {"directory": ""}, "data": {"sessionID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "assistantMessageID": "msg_0eb735d5b001oAFVeY5jz3WD4Z", "id": "call_function_xjy1lqis49ru_1", "input": {"path": "hello.txt"}, "executed": false}, "durable": {"aggregateID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "seq": 10, "version": 1}}}} +{"type": "emit_inbound", "label": "session.tool.input.ended.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb736372001KkIVDspIy1oRiM", "created": 1790656602994, "type": "session.tool.input.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "assistantMessageID": "msg_0eb735d5b001oAFVeY5jz3WD4Z", "id": "call_function_xjy1lqis49ru_2", "text": "{\"command\": \"echo TOOL_OK\"}"}, "durable": {"aggregateID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "seq": 11, "version": 1}}}} +{"type": "emit_inbound", "label": "session.tool.called.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb736376001f2qzvWG98b5hqG", "created": 1790656602998, "type": "session.tool.called", "location": {"directory": ""}, "data": {"sessionID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "assistantMessageID": "msg_0eb735d5b001oAFVeY5jz3WD4Z", "id": "call_function_xjy1lqis49ru_2", "input": {"command": "echo TOOL_OK"}, "executed": false}, "durable": {"aggregateID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "seq": 12, "version": 1}}}} +{"type": "emit_inbound", "label": "session.text.ended", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb736379001dh9hbrIOsB9t53", "created": 1790656603001, "type": "session.text.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "assistantMessageID": "msg_0eb735d5b001oAFVeY5jz3WD4Z", "ordinal": 0, "text": "I'll read the file and run the command."}, "durable": {"aggregateID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "seq": 13, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.streamed", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb73637d001B6WQNmGyz1KGpg", "created": 1790656603005, "type": "session.step.streamed", "location": {"directory": ""}, "data": {"sessionID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "assistantMessageID": "msg_0eb735d5b001oAFVeY5jz3WD4Z"}, "durable": {"aggregateID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "seq": 14, "version": 1}}}} +{"type": "emit_inbound", "label": "shell.created", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb736387001zHcrJlSPReRICf", "created": 1790656603015, "type": "shell.created", "location": {"directory": ""}, "data": {"info": {"id": "sh_0eb736385001GFTd7x3S55tePV", "status": "running", "command": "echo TOOL_OK", "cwd": "", "shell": "/bin/bash", "file": "/xdg-data/opencode/shell/global/sh_0eb736385001GFTd7x3S55tePV.out", "metadata": {"sessionID": "ses_f148ca2deffeJcwCnRQtb0YFNX"}, "time": {"started": 1790656603013}}}}}} +{"type": "emit_inbound", "label": "session.tool.progress", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7363880010GUjmxAATflebF", "created": 1790656603016, "type": "session.tool.progress", "location": {"directory": ""}, "data": {"sessionID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "assistantMessageID": "msg_0eb735d5b001oAFVeY5jz3WD4Z", "id": "call_function_xjy1lqis49ru_2", "metadata": {"shellID": "sh_0eb736385001GFTd7x3S55tePV"}}}}} +{"type": "emit_inbound", "label": "session.tool.success", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb73638b001AE5MpU95OxE8eP", "created": 1790656603019, "type": "session.tool.success", "location": {"directory": ""}, "data": {"sessionID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "assistantMessageID": "msg_0eb735d5b001oAFVeY5jz3WD4Z", "id": "call_function_xjy1lqis49ru_1", "content": [{"type": "text", "text": "Read file hello.txt, lines 1-1\n1: hello from the spike"}], "metadata": {"truncated": false}, "executed": false}, "durable": {"aggregateID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "seq": 15, "version": 2}}}} +{"type": "emit_inbound", "label": "shell.exited", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb736390001M9qbN1C3J2tdnF", "created": 1790656603024, "type": "shell.exited", "location": {"directory": ""}, "data": {"id": "sh_0eb736385001GFTd7x3S55tePV", "exit": 0, "status": "exited"}}}} +{"type": "emit_inbound", "label": "session.tool.success.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb736392001mAzIR895FlyTr6", "created": 1790656603026, "type": "session.tool.success", "location": {"directory": ""}, "data": {"sessionID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "assistantMessageID": "msg_0eb735d5b001oAFVeY5jz3WD4Z", "id": "call_function_xjy1lqis49ru_2", "content": [{"type": "text", "text": "TOOL_OK\n"}], "metadata": {"status": "completed", "truncated": false, "exit": 0}, "executed": false}, "durable": {"aggregateID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "seq": 16, "version": 2}}}} +{"type": "emit_inbound", "label": "session.step.ended", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb73639f001IQrLIY35bD64TL", "created": 1790656603039, "type": "session.step.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "assistantMessageID": "msg_0eb735d5b001oAFVeY5jz3WD4Z", "finish": "tool-calls", "rawFinish": "tool_calls", "cost": 0, "tokens": {"input": 8767, "output": 59, "reasoning": 0, "cache": {"read": 487, "write": 0}}, "snapshot": "99482b54df6361365fa0ed7363ccf07edaa7fc93", "files": []}, "durable": {"aggregateID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "seq": 17, "version": 1}}}} +{"type": "emit_inbound", "label": "session.usage.updated", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7363a5001EKYF8FhM4SplVu", "created": 1790656603045, "type": "session.usage.updated", "data": {"sessionID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "cost": 0, "tokens": {"input": 8767, "output": 59, "reasoning": 0, "cache": {"read": 487, "write": 0}}}}}} +{"type": "emit_inbound", "label": "session.step.started.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7366f2001WQBXMBgRtPuQh1", "created": 1790656603890, "type": "session.step.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "agent": "build", "model": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}, "assistantMessageID": "msg_0eb7363b20015PvR7k5Er4JlrJ", "snapshot": "99482b54df6361365fa0ed7363ccf07edaa7fc93", "started": 1790656603085}, "durable": {"aggregateID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "seq": 18, "version": 1}}}} +{"type": "emit_inbound", "label": "session.text.started.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7366f7001rQGF9KFTnxrW1P", "created": 1790656603895, "type": "session.text.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "assistantMessageID": "msg_0eb7363b20015PvR7k5Er4JlrJ", "ordinal": 0}, "durable": {"aggregateID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "seq": 19, "version": 1}}}} +{"type": "emit_inbound", "label": "session.text.delta.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb73675f001lQg1eGqHoCo1vk", "created": 1790656603999, "type": "session.text.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "assistantMessageID": "msg_0eb7363b20015PvR7k5Er4JlrJ", "ordinal": 0, "delta": "Both tools worked:\n\n- `read hello.txt` → `hello from the spike`\n- `echo TOOL_OK` → `TOOL_OK`\n\nDONE"}}}} +{"type": "emit_inbound", "label": "session.text.ended.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb73677c001jJzmP1jN5gZUfh", "created": 1790656604028, "type": "session.text.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "assistantMessageID": "msg_0eb7363b20015PvR7k5Er4JlrJ", "ordinal": 0, "text": "Both tools worked:\n\n- `read hello.txt` → `hello from the spike`\n- `echo TOOL_OK` → `TOOL_OK`\n\nDONE"}, "durable": {"aggregateID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "seq": 20, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.streamed.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7367830016divwEpkO8f6Eu", "created": 1790656604035, "type": "session.step.streamed", "location": {"directory": ""}, "data": {"sessionID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "assistantMessageID": "msg_0eb7363b20015PvR7k5Er4JlrJ"}, "durable": {"aggregateID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "seq": 21, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.ended.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb73678f001CMnYCUExYim7Lu", "created": 1790656604047, "type": "session.step.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "assistantMessageID": "msg_0eb7363b20015PvR7k5Er4JlrJ", "finish": "stop", "rawFinish": "stop", "cost": 0, "tokens": {"input": 45, "output": 33, "reasoning": 0, "cache": {"read": 9311, "write": 0}}, "snapshot": "99482b54df6361365fa0ed7363ccf07edaa7fc93", "files": []}, "durable": {"aggregateID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "seq": 22, "version": 1}}}} +{"type": "emit_inbound", "label": "session.usage.updated.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb736795001KWJroUfZyIUpKX", "created": 1790656604053, "type": "session.usage.updated", "data": {"sessionID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "cost": 0, "tokens": {"input": 8812, "output": 92, "reasoning": 0, "cache": {"read": 9798, "write": 0}}}}}} +{"type": "emit_inbound", "label": "session.execution.succeeded", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb736797001btSl4mFcA5RrdQ", "created": 1790656604055, "type": "session.execution.succeeded", "data": {"sessionID": "ses_f148ca2deffeJcwCnRQtb0YFNX"}, "durable": {"aggregateID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "seq": 23, "version": 1}}}} +{"type": "runtime_exit", "status": "success"} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_tool_call/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_tool_call/output.ts new file mode 100644 index 000000000000..62003aa5bdea --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_tool_call/output.ts @@ -0,0 +1,46 @@ +import { assert } from "@effect/vitest"; +import type { ProviderReplayTranscript } from "@t3tools/contracts"; + +import type { OrchestratorV2ScenarioResult } from "../../OrchestratorScenario.ts"; +import { + assertAssistantTextIncludes, + assertBaseProjection, + assertSemanticProjectionIntegrity, + assertTurnItemTypeSequence, + assertUserMessagesInclude, + assertVisibleTurnItemsMirrorLocalTurnItems, + OPENCODE2_TOOL_CALL_PROMPT, + projectionFor, +} from "../shared.ts"; + +/** OpenCode 2's renamed `read` and `shell` tools, run in one step, then a second step. */ +export function assertOpenCode2ToolCallOutput( + result: OrchestratorV2ScenarioResult, + transcript: ProviderReplayTranscript, +) { + assertBaseProjection({ result, transcript, runCount: 1, runStatuses: ["completed"] }); + const projection = projectionFor(result, transcript.scenario); + assertSemanticProjectionIntegrity(projection); + assertVisibleTurnItemsMirrorLocalTurnItems(projection); + // Buffered delivery keeps streaming text out of the read model until + // `text.ended`, which OpenCode sends after both tool calls started. + assertTurnItemTypeSequence(projection, [ + "user_message", + "dynamic_tool", + "command_execution", + "assistant_message", + "assistant_message", + "checkpoint", + ]); + assertUserMessagesInclude(projection, [OPENCODE2_TOOL_CALL_PROMPT]); + assertAssistantTextIncludes(projection, "DONE"); + + const read = projection.turnItems.find((item) => item.type === "dynamic_tool"); + assert.equal(read?.status, "completed"); + assert.equal(read?.title, "Read hello.txt"); + assert.include(read?.type === "dynamic_tool" ? read.output : "", "hello from the spike"); + + const shell = projection.turnItems.find((item) => item.type === "command_execution"); + assert.equal(shell?.status, "completed"); + assert.deepInclude(shell, { input: "echo TOOL_OK", output: "TOOL_OK\n", exitCode: 0 }); +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/shared.ts b/apps/server/src/orchestration-v2/testkit/fixtures/shared.ts index 93a638dc119a..c1e6dc4c6324 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/shared.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/shared.ts @@ -64,6 +64,13 @@ export const SUBAGENT_CONTINUE_PROMPT = export const SUBAGENT_CONTINUE_PARENT_PROMPT = "Have the same subagent you spawned earlier reply exactly: continued subagent response"; export const SUBAGENT_CONTINUE_CHILD_PROMPT = "Reply exactly: continued subagent response"; +/** Prompts the OpenCode 2 spike recorded against 2.0.18 (`opencode2_*` fixtures). */ +export const OPENCODE2_SIMPLE_PROMPT = + "Think carefully step by step about whether 391 is prime, showing your reasoning, then answer in one short sentence."; +export const OPENCODE2_TOOL_CALL_PROMPT = + "Use the read tool to read hello.txt, then run the shell command `echo TOOL_OK` with the bash tool, then reply DONE."; +export const OPENCODE2_INTERRUPT_PROMPT = + "Run the shell command `sleep 60 && echo LATE` with the bash tool, then reply DONE."; export const TURN_INTERRUPT_PROMPT = "Do not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally."; export const TURN_INTERRUPT_MID_TOOL_PROMPT = @@ -337,6 +344,12 @@ export const OPENCODE_MODEL_SELECTION = { options: [{ id: "agent", value: "build" }], } satisfies ModelSelection; +/** The free OpenCode Zen model the OpenCode 2 spike recorded its reasoning run with. */ +export const OPENCODE2_MODEL_SELECTION = { + instanceId: ProviderInstanceId.make("opencode"), + model: "opencode/space-bunny-free", +} satisfies ModelSelection; + /** Pi fixtures are recorded against this pinned OpenRouter model; the slug is `provider/model`. */ export const PI_MODEL_SELECTION = { instanceId: ProviderInstanceId.make("pi"), diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.test.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.test.ts index d1742d2c0762..ab5b89e97833 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.test.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.test.ts @@ -13,8 +13,7 @@ import * as IdAllocator from "../../orchestration-v2/IdAllocator.ts"; import * as ServerSettings from "../../serverSettings.ts"; import * as ProviderEventLoggers from "../Layers/ProviderEventLoggers.ts"; import * as OpenCodeRuntime from "../opencodeRuntime.ts"; -import { OPENCODE_2_UNSUPPORTED_MESSAGE } from "../opencodeVersionProbe.ts"; -import { OpenCodeDriver } from "./OpenCodeDriver.ts"; +import { OPENCODE_2_TEXT_GENERATION_UNSUPPORTED, OpenCodeDriver } from "./OpenCodeDriver.ts"; const serverStarts: Array = []; const reachedServer = (operation: string) => @@ -59,16 +58,14 @@ const create = (config: Partial, http: HttpClient.HttpClient) const noHttp = HttpClient.make(() => Effect.die("A local binary must not be probed over HTTP")); it.layer(layer)("OpenCodeDriver runtime selection", (it) => { - it.effect("refuses OpenCode 2 on every path that would start a 1.x server", () => + it.effect("never starts a 1.x server for an OpenCode 2 instance", () => Effect.gen(function* () { serverStarts.length = 0; const instance = yield* create({}, noHttp); - const workspace = yield* Effect.flip(instance.snapshotForCwd!(process.cwd())); - assert.strictEqual( - (workspace.cause as { readonly detail?: string }).detail, - OPENCODE_2_UNSUPPORTED_MESSAGE, - ); + // The workspace snapshot falls back to the machine snapshot. + const workspace = yield* instance.snapshotForCwd!(process.cwd()); + assert.strictEqual(workspace.instanceId, instance.instanceId); const title = yield* Effect.flip( instance.textGeneration.generateThreadTitle({ cwd: process.cwd(), @@ -76,7 +73,7 @@ it.layer(layer)("OpenCodeDriver runtime selection", (it) => { modelSelection: { instanceId: instance.instanceId, model: "opencode/big-pickle" }, }), ); - assert.strictEqual(title.detail, OPENCODE_2_UNSUPPORTED_MESSAGE); + assert.strictEqual(title.detail, OPENCODE_2_TEXT_GENERATION_UNSUPPORTED); assert.deepStrictEqual(serverStarts, []); }).pipe(Effect.scoped), ); diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.ts index e43e3599b54f..a186b92b195c 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.ts @@ -29,11 +29,9 @@ import { OpenCodeAdapterV2Driver, type OpenCodeAdapterV2DriverEnv, } from "../../orchestration-v2/Adapters/OpenCodeAdapterV2.ts"; -import { - ProviderAdapterCapabilitiesError, - ProviderAdapterOpenSessionError, - type ProviderAdapterV2Shape, -} from "../../orchestration-v2/ProviderAdapter.ts"; +import { makeOpenCode2Adapter } from "../../orchestration-v2/Adapters/OpenCode2AdapterV2.ts"; +import { IdAllocatorV2 } from "../../orchestration-v2/IdAllocator.ts"; +import type { ProviderAdapterV2Shape } from "../../orchestration-v2/ProviderAdapter.ts"; import * as ServerSettings from "../../serverSettings.ts"; import type { TextGeneration } from "../../textGeneration/TextGeneration.ts"; import { ProviderDriverError } from "../Errors.ts"; @@ -48,11 +46,12 @@ import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; import * as OpenCodeRuntime from "../opencodeRuntime.ts"; import { makeOpenCodeRuntimeProbe, - OPENCODE_2_UNSUPPORTED_MESSAGE, probeOpenCodeRuntime, type ProbedOpenCode, } from "../opencodeVersionProbe.ts"; import * as OpenCodeServerOwner from "../OpenCodeServerOwner.ts"; +import * as OpenCode2Client from "../opencode2/OpenCode2Client.ts"; +import * as OpenCode2Server from "../opencode2/OpenCode2Server.ts"; import { defaultProviderContinuationIdentity, type ProviderDriver, @@ -93,81 +92,78 @@ const UPDATE = makePackageManagedProviderMaintenanceResolver({ }, }); -const openCode2Unsupported = () => - new OpenCodeRuntime.OpenCodeRuntimeError({ - operation: "selectOpenCodeRuntime", - detail: OPENCODE_2_UNSUPPORTED_MESSAGE, - }); +export const OPENCODE_2_TEXT_GENERATION_UNSUPPORTED = + "T3 Code cannot generate text with OpenCode 2 yet."; type OpenCodeRuntimeProbe = Effect.Success< ReturnType> >; -/** - * Runs `use` only when the instance is not 2.x, since only the 1.x runtime exists so far. A failed - * probe keeps the 1.x path, whose own server checks report the failure. - */ -function onOpenCodeV1( +/** Runs `v2` for a 2.x instance and `v1` otherwise; a failed probe keeps 1.x, whose checks report it. */ +function byOpenCodeRuntime( probed: Effect.Effect, - use: Effect.Effect, - refuse: (cause: OpenCodeRuntime.OpenCodeRuntimeError) => E, + paths: { readonly v1: Effect.Effect; readonly v2: Effect.Effect }, ): Effect.Effect { return probed.pipe( Effect.orElseSucceed(() => undefined), - Effect.flatMap((result) => - result?.generation === "v2" ? Effect.fail(refuse(openCode2Unsupported())) : use, - ), + Effect.flatMap((result) => (result?.generation === "v2" ? paths.v2 : paths.v1)), ); } /** * Routes each adapter call to the runtime the instance's probe detected. Capability and selection * reads are hot, so they use the last successful probe (1.x before one lands) and never wait on a - * slow server. Opening a session waits for a probe, so a 2.x is refused before it is spoken to. + * slow server. Opening a session waits for a probe, so each server is spoken to in its own protocol. */ function selectOpenCodeRuntimeAdapter(input: { readonly probe: OpenCodeRuntimeProbe; readonly v1: ProviderAdapterV2Shape; + readonly v2: ProviderAdapterV2Shape; }): ProviderAdapterV2Shape { - const lastSuccess = Effect.map(input.probe.lastSuccess, Option.getOrUndefined); - const capabilitiesError = (cause: OpenCodeRuntime.OpenCodeRuntimeError) => - new ProviderAdapterCapabilitiesError({ driver: DRIVER_KIND, cause }); + const pick = (probed: Effect.Effect) => + byOpenCodeRuntime(probed, { v1: Effect.succeed(input.v1), v2: Effect.succeed(input.v2) }); + const hot = pick(Effect.map(input.probe.lastSuccess, Option.getOrUndefined)); return { instanceId: input.v1.instanceId, driver: DRIVER_KIND, - getCapabilities: () => onOpenCodeV1(lastSuccess, input.v1.getCapabilities(), capabilitiesError), + getCapabilities: () => Effect.flatMap(hot, (adapter) => adapter.getCapabilities()), planSelectionTransition: (transition) => - onOpenCodeV1(lastSuccess, input.v1.planSelectionTransition(transition), capabilitiesError), + Effect.flatMap(hot, (adapter) => adapter.planSelectionTransition(transition)), openSession: (session) => - onOpenCodeV1( - input.probe.get, - input.v1.openSession(session), - (cause) => - new ProviderAdapterOpenSessionError({ - driver: DRIVER_KIND, - providerSessionId: session.providerSessionId, - cause, - }), - ), + Effect.flatMap(pick(input.probe.get), (adapter) => adapter.openSession(session)), }; } -/** Text generation starts or connects to a server per call, so a 2.x is refused first. */ +/** Text generation starts or connects to a 1.x server per call, so a 2.x is refused first. */ function selectOpenCodeRuntimeTextGeneration( probe: OpenCodeRuntimeProbe, v1: TextGeneration["Service"], ): TextGeneration["Service"] { - const refuse = (operation: string) => (cause: OpenCodeRuntime.OpenCodeRuntimeError) => - new TextGenerationError({ operation, detail: cause.detail, cause }); + const refuse = (operation: string) => + Effect.fail( + new TextGenerationError({ operation, detail: OPENCODE_2_TEXT_GENERATION_UNSUPPORTED }), + ); return { generateCommitMessage: (input) => - onOpenCodeV1(probe.get, v1.generateCommitMessage(input), refuse("generateCommitMessage")), + byOpenCodeRuntime(probe.get, { + v1: v1.generateCommitMessage(input), + v2: refuse("generateCommitMessage"), + }), generatePrContent: (input) => - onOpenCodeV1(probe.get, v1.generatePrContent(input), refuse("generatePrContent")), + byOpenCodeRuntime(probe.get, { + v1: v1.generatePrContent(input), + v2: refuse("generatePrContent"), + }), generateBranchName: (input) => - onOpenCodeV1(probe.get, v1.generateBranchName(input), refuse("generateBranchName")), + byOpenCodeRuntime(probe.get, { + v1: v1.generateBranchName(input), + v2: refuse("generateBranchName"), + }), generateThreadTitle: (input) => - onOpenCodeV1(probe.get, v1.generateThreadTitle(input), refuse("generateThreadTitle")), + byOpenCodeRuntime(probe.get, { + v1: v1.generateThreadTitle(input), + v2: refuse("generateThreadTitle"), + }), }; } @@ -248,9 +244,31 @@ export const OpenCodeDriver: ProviderDriver }), ), ); + // One OpenCode 2 server per instance, spawned on first use or reached at `serverUrl`. + const openCode2Server = yield* OpenCode2Server.make({ + binaryPath: effectiveConfig.binaryPath, + serverUrl: effectiveConfig.serverUrl, + serverPassword: effectiveConfig.serverPassword, + directory: serverConfig.cwd, + environment: processEnv, + }).pipe( + Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, openCodeRuntime), + Effect.provideService( + OpenCode2Client.OpenCode2Client, + yield* OpenCode2Client.make.pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + ), + ), + ); const orchestrationAdapter = selectOpenCodeRuntimeAdapter({ probe: runtimeProbe, v1: openCodeV1Adapter, + v2: makeOpenCode2Adapter({ + instanceId, + server: openCode2Server, + idAllocator: yield* IdAllocatorV2, + serverConfig, + }), }); const serverOwner = yield* OpenCodeServerOwner.make({ binaryPath: effectiveConfig.binaryPath, @@ -389,32 +407,33 @@ export const OpenCodeDriver: ProviderDriver accentColor, enabled, snapshot, + // OpenCode 2 has no per-workspace skill and command inventory yet, so a + // 2.x workspace shows the machine snapshot instead of starting a 1.x server. snapshotForCwd: (cwd) => !effectiveConfig.enabled ? snapshot.getSnapshot - : Effect.all([ - snapshot.getSnapshot, - onOpenCodeV1( - runtimeProbe.get, + : byOpenCodeRuntime(runtimeProbe.get, { + v2: snapshot.getSnapshot, + v1: Effect.all([ + snapshot.getSnapshot, loadWorkspaceForCwd(cwd).pipe(Effect.timeout("20 seconds")), - (cause) => cause, + ]).pipe( + Effect.map(([machineSnapshot, { skills, commands }]) => ({ + ...machineSnapshot, + skills: openCodeSkillsToServerProviderSkills(skills), + slashCommands: openCodeCommandsToServerProviderSlashCommands(commands), + })), + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to probe OpenCode commands and skills for '${cwd}'`, + cause, + }), + ), ), - ]).pipe( - Effect.map(([machineSnapshot, { skills, commands }]) => ({ - ...machineSnapshot, - skills: openCodeSkillsToServerProviderSkills(skills), - slashCommands: openCodeCommandsToServerProviderSlashCommands(commands), - })), - Effect.mapError( - (cause) => - new ProviderDriverError({ - driver: DRIVER_KIND, - instanceId, - detail: `Failed to probe OpenCode commands and skills for '${cwd}'`, - cause, - }), - ), - ), + }), orchestrationAdapter, textGeneration, } satisfies ProviderInstance; diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index 4659a28dc966..493774d27ba0 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -21,7 +21,7 @@ import { openCodeCommandsToServerProviderSlashCommands, } from "./OpenCodeProvider.ts"; import { readOpenCodeGoUsageLimits } from "./openCodeUsageLimits.ts"; -import { OPENCODE_2_UNSUPPORTED_MESSAGE, probeOpenCodeRuntime } from "../opencodeVersionProbe.ts"; +import { probeOpenCodeRuntime } from "../opencodeVersionProbe.ts"; import { OPENCODE_1_RESPONSES, OPENCODE_2_RESPONSES, @@ -529,14 +529,14 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { }), ); - it.effect("refuses a local OpenCode 2 binary before starting a 1.x server for it", () => + it.effect("offers a local OpenCode 2 binary in Full access only, never via 1.x", () => Effect.gen(function* () { runtimeMock.state.versionStdout = "opencode v2.0.18\n"; const snapshot = yield* checkProvider(makeOpenCodeSettings()); - NodeAssert.equal(snapshot.status, "error"); + NodeAssert.equal(snapshot.status, "ready"); NodeAssert.equal(snapshot.version, "2.0.18"); - NodeAssert.equal(snapshot.message, OPENCODE_2_UNSUPPORTED_MESSAGE); + NodeAssert.deepEqual(snapshot.supportedRuntimeModes, ["full-access"]); NodeAssert.equal(runtimeMock.state.sdkClientInputs.length, 0); }), ); @@ -604,7 +604,7 @@ it.layer(testLayer)("checkOpenCodeProviderStatus with configured server URL", (i }), ); - it.effect("refuses a configured OpenCode 2 server before speaking 1.x to it", () => + it.effect("routes a configured OpenCode 2 server to the 2.x check, never via 1.x", () => Effect.gen(function* () { const settings = makeOpenCodeSettings({ serverUrl: "http://127.0.0.1:9999", @@ -617,9 +617,9 @@ it.layer(testLayer)("checkOpenCodeProviderStatus with configured server URL", (i replayOpenCodeServer(OPENCODE_2_RESPONSES, "secret-password"), ); - NodeAssert.equal(snapshot.status, "error"); + NodeAssert.equal(snapshot.status, "ready"); NodeAssert.equal(snapshot.version, "2.0.18"); - NodeAssert.equal(snapshot.message, OPENCODE_2_UNSUPPORTED_MESSAGE); + NodeAssert.deepEqual(snapshot.supportedRuntimeModes, ["full-access"]); NodeAssert.equal(runtimeMock.state.sdkClientInputs.length, 0); }), ); diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index 2395130b5e18..ae7d3704b1a3 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -20,7 +20,7 @@ import { type ServerProviderDraft, } from "../providerSnapshot.ts"; import * as OpenCodeRuntime from "../opencodeRuntime.ts"; -import { OPENCODE_2_UNSUPPORTED_MESSAGE, type ProbedOpenCode } from "../opencodeVersionProbe.ts"; +import type { ProbedOpenCode } from "../opencodeVersionProbe.ts"; import type { Agent, ProviderListResponse } from "@opencode-ai/sdk/v2"; import * as OpenCodeServerOwner from "../OpenCodeServerOwner.ts"; @@ -377,6 +377,16 @@ export const makePendingOpenCodeProvider = ( }); }); +/** + * OpenCode 2 only runs in Full access until approvals map onto its permission + * rules, so the snapshot offers that mode alone. Its models come from the + * user's custom models until the 2.x model list lands. + */ +const OPENCODE_2_PRESENTATION = { + ...OPENCODE_PRESENTATION, + supportedRuntimeModes: ["full-access"], +} as const; + /** * `probeRuntime` is the driver's memoized version probe: `opencode --version` for a local binary, * the version endpoints for a configured server. The status check refreshes it, so an in-place @@ -454,19 +464,18 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu ); if (probedExit._tag === "Failure") return fallback(Cause.squash(probedExit.cause)); const probed = probedExit.value; - // Temporary until the OpenCode 2 runtime lands: 2.x must never reach the 1.x server path. if (probed.generation === "v2") { return buildServerProvider({ - presentation: OPENCODE_PRESENTATION, + presentation: OPENCODE_2_PRESENTATION, enabled: true, checkedAt, models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES), probe: { installed: true, version: probed.version, - status: "error", + status: "ready", auth: { status: "unknown" }, - message: OPENCODE_2_UNSUPPORTED_MESSAGE, + message: `OpenCode ${probed.version} is available.`, }, }); } diff --git a/apps/server/src/provider/opencode2/OpenCode2Client.events.test.ts b/apps/server/src/provider/opencode2/OpenCode2Client.events.test.ts new file mode 100644 index 000000000000..9d1de49bf010 --- /dev/null +++ b/apps/server/src/provider/opencode2/OpenCode2Client.events.test.ts @@ -0,0 +1,104 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; +import { describe } from "vite-plus/test"; + +import * as OpenCode2Client from "./OpenCode2Client.ts"; + +const RECORDING = new URL( + "../../orchestration-v2/testkit/fixtures/opencode2_simple/opencode_transcript.ndjson", + import.meta.url, +); +const Entry = Schema.Struct({ type: Schema.String, frame: Schema.optional(Schema.Unknown) }); +const decodeEntry = Schema.decodeUnknownSync(Schema.fromJsonString(Entry)); +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + +/** The recorded `simple` turn's events, as a newer server would send them. */ +const newerServerStream = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const recorded = (yield* fs.readFileString(RECORDING.pathname)) + .split("\n") + .filter((line) => line.length > 0) + .map((line) => decodeEntry(line)) + .flatMap((entry) => { + const frame = entry.frame as { readonly type?: string; readonly event?: unknown } | undefined; + return frame?.type === "sdk.event" ? [frame.event as Record] : []; + }); + const executionStarted = recorded.findIndex( + (event) => event.type === "session.execution.started", + ); + const [first] = recorded; + return [ + ...recorded.slice(0, executionStarted), + // An event type this build has never heard of. + { + id: "evt_0000000000newtype", + created: 1, + type: "session.hologram.projected", + data: { beams: 3 }, + }, + // A known event carrying a field added after 2.0.18. + { + ...recorded[executionStarted], + data: { ...(recorded[executionStarted]!.data as object), lane: "fast" }, + }, + ...recorded.slice(executionStarted + 1), + first, + ]; +}); + +const serving = (events: ReadonlyArray) => + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(events.map((event) => `data: ${encodeJson(event)}\n\n`).join(""), { + headers: { "content-type": "text/event-stream" }, + }), + ), + ), + ), + ); + +const connect = Effect.gen(function* () { + const opencode = yield* OpenCode2Client.OpenCode2Client; + return yield* opencode.connect({ baseUrl: "http://127.0.0.1:4096", password: "secret" }); +}); + +describe("OpenCode2Client events", () => { + it.effect("skips events a newer server adds and still sees the turn end", () => + Effect.gen(function* () { + const events = yield* newerServerStream; + const { events: subscribe } = yield* connect.pipe( + Effect.provide(OpenCode2Client.layer.pipe(Layer.provide(serving(events)))), + ); + const types = yield* (yield* subscribe).pipe( + Stream.map((event) => event.type), + Stream.runCollect, + ); + + assert.notInclude(types, "session.hologram.projected"); + assert.include(types, "session.execution.started"); + assert.include(types, "session.execution.succeeded"); + assert.strictEqual(types.length, events.length - 1); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("the client's own subscription fails the whole stream on the same events", () => + Effect.gen(function* () { + const events = yield* newerServerStream; + const { client } = yield* connect.pipe( + Effect.provide(OpenCode2Client.layer.pipe(Layer.provide(serving(events)))), + ); + const failure = yield* client.event.subscribe().pipe(Stream.runDrain, Effect.flip); + assert.strictEqual(failure._tag, "ClientError"); + }).pipe(Effect.provide(NodeServices.layer)), + ); +}); diff --git a/apps/server/src/provider/opencode2/OpenCode2Client.live.test.ts b/apps/server/src/provider/opencode2/OpenCode2Client.live.test.ts index c063d301683b..b92531cbdfab 100644 --- a/apps/server/src/provider/opencode2/OpenCode2Client.live.test.ts +++ b/apps/server/src/provider/opencode2/OpenCode2Client.live.test.ts @@ -83,7 +83,7 @@ describe.runIf(binaryPath !== undefined)("OpenCode2Client live", () => { Effect.gen(function* () { const server = yield* startServer(binaryPath!); const opencode = yield* OpenCode2Client.OpenCode2Client; - const client = yield* opencode.connect({ + const { client, events } = yield* opencode.connect({ baseUrl: server.baseUrl, password: server.password, }); @@ -100,7 +100,7 @@ describe.runIf(binaryPath !== undefined)("OpenCode2Client live", () => { id: Model.ID.make("big-pickle"), }), }); - const finished = yield* client.event.subscribe().pipe( + const finished = yield* (yield* events).pipe( Stream.filter( (event) => (event.data as { readonly sessionID?: string }).sessionID === session.id && diff --git a/apps/server/src/provider/opencode2/OpenCode2Client.test.ts b/apps/server/src/provider/opencode2/OpenCode2Client.test.ts index 7bf5329c46ba..33fe77d096ad 100644 --- a/apps/server/src/provider/opencode2/OpenCode2Client.test.ts +++ b/apps/server/src/provider/opencode2/OpenCode2Client.test.ts @@ -26,7 +26,7 @@ const authorizationFor = (password: string | Redacted.Redacted) => { ); return Effect.gen(function* () { const opencode = yield* OpenCode2Client.OpenCode2Client; - const client = yield* opencode.connect({ baseUrl: "http://127.0.0.1:4096", password }); + const { client } = yield* opencode.connect({ baseUrl: "http://127.0.0.1:4096", password }); yield* client.server.info(); return seen[0]; }).pipe(Effect.provide(OpenCode2Client.layer.pipe(Layer.provide(capturing)))); diff --git a/apps/server/src/provider/opencode2/OpenCode2Client.ts b/apps/server/src/provider/opencode2/OpenCode2Client.ts index c2e7424b0c52..818460323493 100644 --- a/apps/server/src/provider/opencode2/OpenCode2Client.ts +++ b/apps/server/src/provider/opencode2/OpenCode2Client.ts @@ -1,27 +1,75 @@ import { OpenCode, type OpenCodeClient } from "@opencode/client/effect"; +import { OpenCodeEvent } from "@opencode/protocol/groups/event"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Redacted from "effect/Redacted"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; +import * as Sse from "effect/unstable/encoding/Sse"; import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientError from "effect/unstable/http/HttpClientError"; import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; /** OpenCode 2 accepts only HTTP Basic auth, always with this user name. */ const OPENCODE_USERNAME = "opencode"; -/** Builds Effect clients for OpenCode 2 servers, one per base URL and password. */ +/** + * An OpenCode 2 client plus a forward-compatible `/api/event` stream. The + * client's own `event.subscribe` fails the whole stream on the first event a + * newer server adds, so `events` decodes each frame on its own and skips the + * ones this build does not know. `events` succeeds once the server accepted + * the subscription: the stream is volatile, so callers subscribe before they + * start work whose events they need. + */ +export interface OpenCode2Api { + readonly client: OpenCodeClient; + readonly events: Effect.Effect< + Stream.Stream, + HttpClientError.HttpClientError + >; +} + +/** Builds clients for OpenCode 2 servers, one per base URL and password. */ export class OpenCode2Client extends Context.Service< OpenCode2Client, { readonly connect: (input: { readonly baseUrl: string; readonly password: string | Redacted.Redacted; - }) => Effect.Effect; + }) => Effect.Effect; } >()("t3/provider/opencode2/OpenCode2Client") {} +const decodeEvent = Schema.decodeUnknownResult(Schema.fromJsonString(OpenCodeEvent)); +const unknownEventType = (data: string) => { + const match = /"type"\s*:\s*"([^"]{1,80})"/.exec(data); + return match?.[1] ?? ""; +}; + +/** Subscribes to `/api/event`, then streams every frame this build can decode. */ +const readEvents = (httpClient: HttpClient.HttpClient) => + httpClient.get("/api/event", { headers: { accept: "text/event-stream" } }).pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.map((response) => + response.stream.pipe( + Stream.decodeText, + Stream.pipeThroughChannel(Sse.decode()), + Stream.filterMapEffect((frame) => { + const decoded = decodeEvent(frame.data); + return Result.isSuccess(decoded) + ? Effect.succeed(Result.succeed(decoded.success)) + : Effect.logDebug("Skipped an OpenCode event this build cannot decode.", { + type: unknownEventType(frame.data), + }).pipe(Effect.as(Result.failVoid)); + }), + ), + ), + ); + /** * OpenCode decodes Basic credentials as UTF-8. `HttpClientRequest.basicAuth` * encodes them as Latin-1 (`btoa`), which gets non-ASCII passwords rejected. @@ -35,16 +83,21 @@ const basicAuthorization = (password: string | Redacted.Redacted) => { export const make = Effect.gen(function* () { const httpClient = yield* HttpClient.HttpClient; return OpenCode2Client.of({ - connect: ({ baseUrl, password }) => - OpenCode.make({ baseUrl }).pipe( - Effect.provideService( - HttpClient.HttpClient, - HttpClient.mapRequest( - httpClient, - HttpClientRequest.setHeader("Authorization", basicAuthorization(password)), + connect: ({ baseUrl, password }) => { + const authenticated = HttpClient.mapRequest( + httpClient, + HttpClientRequest.setHeader("Authorization", basicAuthorization(password)), + ); + return OpenCode.make({ baseUrl }).pipe( + Effect.provideService(HttpClient.HttpClient, authenticated), + Effect.map((client) => ({ + client, + events: readEvents( + HttpClient.mapRequest(authenticated, HttpClientRequest.prependUrl(baseUrl)), ), - ), - ), + })), + ); + }, }); }); diff --git a/apps/server/src/provider/opencode2/OpenCode2Server.test.ts b/apps/server/src/provider/opencode2/OpenCode2Server.test.ts index 983be93e1249..f55af658f0b2 100644 --- a/apps/server/src/provider/opencode2/OpenCode2Server.test.ts +++ b/apps/server/src/provider/opencode2/OpenCode2Server.test.ts @@ -50,7 +50,7 @@ const serverReplying = (reply: { const verify = (httpClient: Layer.Layer, url = "http://127.0.0.1:4096") => Effect.gen(function* () { const opencode = yield* OpenCode2Client.OpenCode2Client; - const client = yield* opencode.connect({ baseUrl: url, password: "secret" }); + const { client } = yield* opencode.connect({ baseUrl: url, password: "secret" }); return yield* OpenCode2Server.verifyServer(client); }).pipe(Effect.provide(OpenCode2Client.layer.pipe(Layer.provide(httpClient)))); diff --git a/apps/server/src/provider/opencode2/OpenCode2Server.ts b/apps/server/src/provider/opencode2/OpenCode2Server.ts index 6acfb624f7e1..ebb8dbd48d30 100644 --- a/apps/server/src/provider/opencode2/OpenCode2Server.ts +++ b/apps/server/src/provider/opencode2/OpenCode2Server.ts @@ -21,9 +21,8 @@ import * as OpenCode2Client from "./OpenCode2Client.ts"; const INFO_TIMEOUT = "5 seconds"; -export interface OpenCode2Connection { +export interface OpenCode2Connection extends OpenCode2Client.OpenCode2Api { readonly url: string; - readonly client: OpenCodeClient; readonly version: string; readonly external: boolean; } @@ -152,9 +151,9 @@ export const make = Effect.fn("OpenCode2Server.make")(function* (input: { const opencode = yield* OpenCode2Client.OpenCode2Client; const connectTo = (url: string, password: Redacted.Redacted, external: boolean) => Effect.gen(function* () { - const client = yield* opencode.connect({ baseUrl: url, password }); - const version = yield* verifyServer(client); - return { url, client, version, external } satisfies OpenCode2Connection; + const api = yield* opencode.connect({ baseUrl: url, password }); + const version = yield* verifyServer(api.client); + return { ...api, url, version, external } satisfies OpenCode2Connection; }); let latest: OpenCode2Connection | undefined; const remember = (connection: OpenCode2Connection) => diff --git a/apps/server/src/provider/opencodeVersionProbe.ts b/apps/server/src/provider/opencodeVersionProbe.ts index 89f6c3839d43..f31a79b63b47 100644 --- a/apps/server/src/provider/opencodeVersionProbe.ts +++ b/apps/server/src/provider/opencodeVersionProbe.ts @@ -16,10 +16,6 @@ export interface ProbedOpenCode { readonly version: string; } -// Temporary: the OpenCode 2 runtime replaces this refusal. -export const OPENCODE_2_UNSUPPORTED_MESSAGE = - "OpenCode 2 is not supported yet. Use OpenCode 1.x until T3 Code adds OpenCode 2 support."; - const OPENCODE_VERSION_PROBE_TIMEOUT = "4 seconds"; const OPENCODE_SERVER_PROBE_TIMEOUT = "5 seconds"; // 2.x's own CLI decodes `{version, pid}` from `/api/info`; requiring both keeps unrelated JSON out. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c9d653e1a1de..d62416ece1ea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -533,6 +533,9 @@ importers: '@opencode/client': specifier: 2.0.18 version: 2.0.18(effect@4.0.0-rc.115(patch_hash=0dfc4bb8ebd80fb3e06b91ef61346f5259517ab0f2437644fe95ae531084b1f5)) + '@opencode/protocol': + specifier: 2.0.18 + version: 2.0.18 diff: specifier: 8.0.3 version: 8.0.3 From 0d3589d148963a263379e993ffbea89d58e8989e Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:20:46 -0700 Subject: [PATCH 2/9] fix(server): make the OpenCode 2 turn runtime safe to leave running Fixes from the audit of the turn runtime. - A model or variant change reaches OpenCode: `startTurn` switches the session before prompting, and `session.create` sends the variant. - A thread outside Full access is refused with an actionable message instead of running with every tool allowed. - Sessions deny the `subagent` tool until subagents are supported, so a background child cannot wake its parent in a turn T3 never sees. - An execution's end that this build cannot decode still ends the turn; other unknown events are still skipped. - A frozen server no longer holds a turn: Stop gives up after 10 seconds and ends the turn locally, and a stream silent for 45 seconds (OpenCode sends a heartbeat every 10 to 15) fails like a lost stream. A Stop the server reports as a no-op is not reported as a stop. - Closing a session on an external server interrupts its running turns. - A thread whose worktree changed moves its session there. - A session deleted outside T3 breaks the thread instead of failing every turn until restart. - A question form is answered with a fixed reply instead of cancelled, so the execution no longer ends as a user stop. - A lost stream leaves the session in error instead of ready. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Adapters/OpenCode2AdapterV2.test.ts | 416 ++++++++++++++++++ .../Adapters/OpenCode2AdapterV2.testkit.ts | 116 +++-- .../Adapters/OpenCode2AdapterV2.ts | 193 ++++++-- .../OpenCode2OrchestratorV2.live.test.ts | 68 ++- .../testkit/fixtures/index.ts | 10 +- .../testkit/fixtures/shared.ts | 3 +- .../opencode2/OpenCode2Client.events.test.ts | 79 ++++ .../opencode2/OpenCode2Client.live.test.ts | 1 + .../src/provider/opencode2/OpenCode2Client.ts | 88 +++- 9 files changed, 902 insertions(+), 72 deletions(-) create mode 100644 apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts new file mode 100644 index 000000000000..b4cadb62782c --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -0,0 +1,416 @@ +/** + * Failures and settings a live OpenCode 2 server cannot produce on demand, + * driven through the real adapter and `@opencode/client` against a replayed + * HTTP server. Frames reuse the shapes recorded against 2.0.18. + */ +import { assert, it } from "@effect/vitest"; +import { + MessageId, + NodeId, + ProviderInstanceId, + ProviderSessionId, + ProviderThreadId, + RunAttemptId, + RunId, + ThreadId, + type ModelSelection, + type OrchestrationV2AppThread, + type OrchestrationV2ProviderThread, + type ProviderReplayEntry, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Option from "effect/Option"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import * as Exit from "effect/Exit"; +import { TestClock } from "effect/testing"; +import { describe } from "vite-plus/test"; + +import type { + ProviderAdapterV2Event, + ProviderAdapterV2SessionRuntime, +} from "../ProviderAdapter.ts"; +import { IdAllocatorV2, layer as idAllocatorLayer } from "../IdAllocator.ts"; +import { OPENCODE_PROVIDER } from "./OpenCodeAdapterV2.ts"; +import { OPENCODE_2_FULL_ACCESS_ONLY } from "./OpenCode2AdapterV2.ts"; +import { openCode2ReplayRuntime } from "./OpenCode2AdapterV2.testkit.ts"; + +const SESSION = "ses_f148ca2deffeJcwCnRQtb0YFNX"; +const WORK = "/work/opencode2"; +const instanceId = ProviderInstanceId.make("opencode"); +const threadId = ThreadId.make("thread:opencode2-adapter"); + +const out = (type: string, input?: unknown): ProviderReplayEntry => ({ + type: "expect_outbound", + frame: input === undefined ? { type } : { type, input }, +}); +/** A recorded response body; `{ data }` is the server's envelope, `null` an empty 204. */ +const reply = (operation: string, data: unknown): ProviderReplayEntry => ({ + type: "emit_inbound", + frame: { type: "sdk.response", operation, data }, +}); +const replyData = (operation: string, data: unknown) => reply(operation, { data }); +const event = (type: string, data: Record): ProviderReplayEntry => ({ + type: "emit_inbound", + frame: { + type: "sdk.event", + event: { id: `evt_${type.replaceAll(".", "")}0000`, created: 1, type, data, ...durable }, + }, +}); +const durable = { durable: { aggregateID: SESSION, seq: 1, version: 1 } }; + +const sessionInfo = (overrides: Record = {}) => ({ + id: SESSION, + projectID: "global", + model: { id: "big-pickle", providerID: "opencode", variant: "default" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1790656601394, updated: 1790656601394 }, + location: { directory: WORK }, + ...overrides, +}); +const promptAccepted = replyData("session.prompt", { + id: "msg_0eb735d41001NJee1EvVePJAK5", + sessionID: SESSION, + time: { created: 1790656601410 }, + type: "user", + payload: { text: "hi" }, + delivery: "steer", +}); + +const bigPickle: ModelSelection = { instanceId, model: "opencode/big-pickle" }; +const policy = (runtimeMode: "full-access" | "approval-required" = "full-access") => ({ + runtimeMode, + interactionMode: "default" as const, + cwd: WORK, +}); + +const providerThread = (now: DateTime.Utc): OrchestrationV2ProviderThread => ({ + id: ProviderThreadId.make("provider-thread:opencode2-adapter"), + driver: OPENCODE_PROVIDER, + providerInstanceId: instanceId, + providerSessionId: ProviderSessionId.make("provider-session:opencode2-adapter"), + appThreadId: threadId, + ownerNodeId: null, + nativeThreadRef: { driver: OPENCODE_PROVIDER, nativeId: SESSION, strength: "strong" }, + nativeConversationHeadRef: null, + status: "idle", + firstRunOrdinal: null, + lastRunOrdinal: null, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, +}); + +const turnInput = ( + thread: OrchestrationV2ProviderThread, + modelSelection: ModelSelection = bigPickle, + runtimeMode: "full-access" | "approval-required" = "full-access", +) => ({ + appThread: {} as OrchestrationV2AppThread, + threadId, + runId: RunId.make("run:opencode2-adapter"), + runOrdinal: 1, + providerTurnOrdinal: 1, + attemptId: RunAttemptId.make("attempt:opencode2-adapter"), + rootNodeId: NodeId.make("node:opencode2-adapter"), + providerThread: thread, + message: { + messageId: MessageId.make("message:opencode2-adapter"), + text: "hi", + attachments: [], + createdBy: "user" as const, + creationSource: "web" as const, + scheduledTaskId: undefined, + senderThreadId: undefined, + }, + modelSelection, + runtimePolicy: policy(runtimeMode), +}); + +/** Resumes the recorded session and returns the runtime, the thread, and its event stream. */ +const resumed = (entries: ReadonlyArray, options?: { external?: boolean }) => + Effect.gen(function* () { + const runtime = yield* openCode2ReplayRuntime( + [ + out("event.subscribe"), + out("session.get", { sessionID: SESSION }), + replyData("session.get", sessionInfo()), + ...entries, + ], + options, + ); + const thread = yield* runtime.resumeThread({ + providerThread: providerThread(yield* DateTime.now), + threadId, + modelSelection: bigPickle, + runtimePolicy: policy(), + }); + return { runtime, thread }; + }); + +const terminalOf = (runtime: ProviderAdapterV2SessionRuntime) => + runtime.events.pipe( + Stream.filter( + (event): event is Extract => + event.type === "turn.terminal", + ), + Stream.runHead, + Effect.map(Option.getOrUndefined), + ); + +describe("OpenCode2 adapter", () => { + it.effect("switches the session's model and variant before a turn that changed them", () => + Effect.gen(function* () { + const { runtime, thread } = yield* resumed([ + out("session.switchModel", { + sessionID: SESSION, + model: { providerID: "openrouter", id: "deepseek/deepseek-v4-flash", variant: "high" }, + }), + reply("session.switchModel", null), + out("session.prompt", { sessionID: SESSION, text: "" }), + promptAccepted, + event("session.execution.succeeded", { sessionID: SESSION }), + ]); + const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped); + yield* runtime.startTurn( + turnInput(thread, { + instanceId, + model: "openrouter/deepseek/deepseek-v4-flash", + options: [{ id: "variant", value: "high" }], + }), + ); + assert.equal((yield* Fiber.join(terminal))?.status, "completed"); + }).pipe(Effect.scoped), + ); + + it.effect( + "refuses a turn outside Full access instead of running it with every tool allowed", + () => + Effect.gen(function* () { + const { runtime, thread } = yield* resumed([]); + const error = yield* runtime + .startTurn(turnInput(thread, bigPickle, "approval-required")) + .pipe(Effect.flip); + assert.equal( + (error.cause as { readonly detail?: string } | undefined)?.detail, + OPENCODE_2_FULL_ACCESS_ONLY, + ); + }).pipe(Effect.scoped), + ); + + it.effect("ends the turn when its terminal event is one this build cannot decode", () => + Effect.gen(function* () { + const { runtime, thread } = yield* resumed([ + out("session.prompt", { sessionID: SESSION, text: "" }), + promptAccepted, + // A reason added after 2.0.18: the full schema rejects the frame. + event("session.execution.interrupted", { sessionID: SESSION, reason: "budget" }), + ]); + const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped); + yield* runtime.startTurn(turnInput(thread)); + assert.equal((yield* Fiber.join(terminal))?.status, "interrupted"); + }).pipe(Effect.scoped), + ); + + it.effect("denies the subagent tool on the sessions it creates", () => + Effect.gen(function* () { + const runtime = yield* openCode2ReplayRuntime([ + out("event.subscribe"), + out("session.create", { + location: { directory: WORK }, + model: { providerID: "opencode", id: "big-pickle" }, + permissions: [ + { action: "*", resource: "*", effect: "allow" }, + { action: "subagent", resource: "*", effect: "deny" }, + ], + }), + replyData("session.create", sessionInfo()), + ]); + const thread = yield* runtime.ensureThread({ + threadId, + modelSelection: bigPickle, + runtimePolicy: policy(), + }); + assert.equal(thread.nativeThreadRef?.nativeId, SESSION); + }).pipe(Effect.scoped), + ); + + it.effect("stops running turns on an external server when the session closes", () => + Effect.gen(function* () { + const scope = yield* Scope.make(); + const { runtime, thread } = yield* resumed( + [ + out("session.prompt", { sessionID: SESSION, text: "" }), + promptAccepted, + out("session.interrupt", { sessionID: SESSION }), + reply("session.interrupt", { interrupted: true }), + ], + { external: true }, + ).pipe(Scope.provide(scope)); + yield* runtime.startTurn(turnInput(thread)); + yield* Scope.close(scope, Exit.void); + }), + ); + + it.effect("ends a turn locally when a stuck server never answers Stop", () => + Effect.gen(function* () { + const { runtime, thread } = yield* resumed([ + out("session.prompt", { sessionID: SESSION, text: "" }), + promptAccepted, + out("session.interrupt", { sessionID: SESSION }), + reply("session.interrupt", ""), + ]); + const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped); + yield* runtime.startTurn(turnInput(thread)); + const interrupt = yield* runtime + .interruptTurn({ + providerThread: thread, + providerTurnId: yield* providerTurnId, + }) + .pipe(Effect.forkScoped); + yield* TestClock.adjust("11 seconds"); + yield* Fiber.join(interrupt); + assert.equal((yield* Fiber.join(terminal))?.status, "interrupted"); + }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), + ); + + it.effect("does not report a Stop the server says did nothing", () => + Effect.gen(function* () { + const { runtime, thread } = yield* resumed([ + out("session.prompt", { sessionID: SESSION, text: "" }), + promptAccepted, + out("session.interrupt", { sessionID: SESSION }), + reply("session.interrupt", { interrupted: false }), + ]); + yield* runtime.startTurn(turnInput(thread)); + const failed = yield* runtime + .interruptTurn({ + providerThread: thread, + providerTurnId: yield* providerTurnId, + }) + .pipe(Effect.flip); + assert.equal(failed._tag, "ProviderAdapterInterruptError"); + }).pipe(Effect.scoped), + ); + + it.effect("moves the session when the thread's worktree changed", () => + Effect.gen(function* () { + const runtime = yield* openCode2ReplayRuntime([ + out("event.subscribe"), + out("session.get", { sessionID: SESSION }), + replyData("session.get", sessionInfo()), + out("session.move", { sessionID: SESSION, directory: "/work/opencode2-feature" }), + reply("session.move", null), + ]); + yield* runtime.resumeThread({ + providerThread: providerThread(yield* DateTime.now), + threadId, + modelSelection: bigPickle, + runtimePolicy: { ...policy(), cwd: "/work/opencode2-feature" }, + }); + }).pipe(Effect.scoped), + ); + + it.effect("breaks the thread and forgets it when the session was deleted outside T3", () => + Effect.gen(function* () { + const { runtime, thread } = yield* resumed([ + out("session.prompt", { sessionID: SESSION, text: "" }), + reply("session.prompt", { + status: 404, + body: { + _tag: "SessionNotFoundError", + sessionID: SESSION, + message: `Session not found: ${SESSION}`, + }, + }), + ]); + const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped); + yield* runtime.startTurn(turnInput(thread)).pipe(Effect.ignore); + const ended = yield* Fiber.join(terminal); + assert.equal(ended?.status, "failed"); + assert.equal(ended?.threadDisposition, "broken"); + // The next turn must resume (and fail into a handoff), not reuse the dead session. + const again = yield* runtime.startTurn(turnInput(thread)).pipe(Effect.flip); + assert.include(String((again.cause as { detail?: string }).detail), "not registered"); + }).pipe(Effect.scoped), + ); + + it.effect("answers a question form instead of cancelling it", () => + Effect.gen(function* () { + const { runtime, thread } = yield* resumed([ + out("session.prompt", { sessionID: SESSION, text: "" }), + promptAccepted, + { + type: "emit_inbound", + frame: { + type: "sdk.event", + event: { + id: "evt_formcreated0000", + created: 1, + type: "form.created", + data: { + form: { + id: "frm_0eb79ab35001fkvFECSh3wYNVD", + sessionID: SESSION, + title: "Questions", + metadata: { kind: "question" }, + fields: [ + { + key: "q0", + title: "Color preference", + type: "string", + options: [{ value: "Red", label: "Red" }], + custom: true, + }, + ], + }, + }, + }, + }, + }, + out("session.form.reply", { + sessionID: SESSION, + formID: "frm_0eb79ab35001fkvFECSh3wYNVD", + answer: { q0: "Questions aren't supported by this OpenCode integration yet." }, + }), + reply("session.form.reply", null), + event("session.execution.succeeded", { sessionID: SESSION }), + ]); + const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped); + yield* runtime.startTurn(turnInput(thread)); + assert.equal((yield* Fiber.join(terminal))?.status, "completed"); + }).pipe(Effect.scoped), + ); + + it.effect("reports the session as failed after a lost stream settles its turns", () => + Effect.gen(function* () { + const { runtime, thread } = yield* resumed([ + out("session.prompt", { sessionID: SESSION, text: "" }), + promptAccepted, + { type: "runtime_exit", status: "success" }, + ]); + // The runtime's event stream has one consumer. + const events = yield* runtime.events.pipe(Stream.runCollect, Effect.forkScoped); + yield* runtime.startTurn(turnInput(thread)); + const collected = yield* Fiber.join(events); + const last = collected.findLast((event) => event.type === "provider_session.updated"); + assert.equal( + last?.type === "provider_session.updated" ? last.providerSession.status : undefined, + "error", + ); + }).pipe(Effect.scoped), + ); +}); + +/** The provider turn the adapter derives for `turnInput`'s attempt. */ +const providerTurnId = Effect.gen(function* () { + const ids = yield* IdAllocatorV2; + return ids.derive.providerTurn({ + driver: OPENCODE_PROVIDER, + nativeTurnId: `${SESSION}:attempt:attempt:opencode2-adapter`, + }); +}).pipe(Effect.provide(idAllocatorLayer)); diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts index 4617bf4093cb..358906aa3db1 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts @@ -8,6 +8,8 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { ProviderInstanceId, ProviderReplayEntry, + ProviderSessionId, + ThreadId, type ProviderReplayTranscript, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; @@ -66,7 +68,21 @@ const operationOf = ( if (method === "GET" && rest === "") return { type: "session.get", input }; if (method === "POST" && rest === "/prompt") return { type: "session.prompt", input }; if (method === "POST" && rest === "/interrupt") return { type: "session.interrupt", input }; + if (method === "POST" && rest === "/model") return { type: "session.switchModel", input }; + if (method === "POST" && rest === "/move") return { type: "session.move", input }; if (method === "GET" && rest === "/message") return { type: "message.list", input }; + const form = /^\/form\/([^/]+)(\/reply)?$/.exec(rest); + if (form !== null) { + const formInput = { ...input, formID: form[1] }; + if (method === "POST" && form[2] !== undefined) { + return { type: "session.form.reply", input: formInput }; + } + if (method === "DELETE") return { type: "session.form.cancel", input: formInput }; + } + const permission = /^\/permission\/([^/]+)\/reply$/.exec(rest); + if (method === "POST" && permission !== null) { + return { type: "permission.reply", input: { ...input, requestID: permission[1] } }; + } } return { type: `${method} ${path}`, input: { ...query, body } }; }; @@ -101,7 +117,15 @@ const replayHttpClient = (controller: OpenCodeReplayController) => return new Response(body, { headers: { "content-type": "text/event-stream" } }); } // Recorded responses are the raw HTTP bodies; `null` is an empty 204. + // `{ status, body }` replays an error status and `""` never answers. const body = await controller.response(operation.type); + if (body === "") return new Promise(() => {}); + if (typeof body === "object" && body !== null && "status" in body && "body" in body) { + return new Response(encodeJson(body.body), { + status: Number(body.status), + headers: { "content-type": "application/json" }, + }); + } return body === null ? new Response(null, { status: 204 }) : new Response(encodeJson(body), { headers: { "content-type": "application/json" } }); @@ -113,36 +137,76 @@ const replayHttpClient = (controller: OpenCodeReplayController) => }).pipe(Effect.map((response) => HttpClientResponse.fromWeb(request, response))), ); +/** The 2.x adapter over a replayed server, checking at scope close that the transcript ran out. */ +const makeReplayAdapter = ( + transcript: ProviderReplayTranscript, + options?: { external?: boolean }, +) => + Effect.gen(function* () { + const controller = new OpenCodeReplayController(transcript); + yield* Effect.addFinalizer(() => Effect.sync(() => controller.assertComplete())); + const opencode = yield* OpenCode2Client.make.pipe( + Effect.provideService(HttpClient.HttpClient, replayHttpClient(controller)), + ); + const connection = { + ...(yield* opencode.connect({ baseUrl: BASE_URL, password: "replay" })), + url: BASE_URL, + version: transcript.version, + external: options?.external ?? false, + }; + return makeOpenCode2Adapter({ + instanceId: ProviderInstanceId.make("opencode"), + server: { withConnection: (use) => use(connection) }, + idAllocator: yield* IdAllocatorV2, + serverConfig: yield* ServerConfig, + }); + }); + +const replayServerConfig = (scenario: string) => + Layer.effect(ServerConfig, makeReplayServerConfig(scenario).pipe(Effect.orDie)).pipe( + Layer.provide(NodeServices.layer), + ); + function makeRegistryLayer(transcript: OpenCode2ReplayTranscript) { - const controller = new OpenCodeReplayController(transcript); - const serverConfigLayer = Layer.effect( - ServerConfig, - makeReplayServerConfig(transcript.scenario).pipe(Effect.orDie), - ).pipe(Layer.provide(NodeServices.layer)); return Layer.unwrap( - Effect.gen(function* () { - yield* Effect.addFinalizer(() => Effect.sync(() => controller.assertComplete())); - const opencode = yield* OpenCode2Client.make.pipe( - Effect.provideService(HttpClient.HttpClient, replayHttpClient(controller)), - ); - const connection = { - ...(yield* opencode.connect({ baseUrl: BASE_URL, password: "replay" })), - url: BASE_URL, - version: transcript.version, - external: true, - }; - return makeLayer([ - makeOpenCode2Adapter({ - instanceId: ProviderInstanceId.make("opencode"), - server: { withConnection: (use) => use(connection) }, - idAllocator: yield* IdAllocatorV2, - serverConfig: yield* ServerConfig, - }), - ]); - }), - ).pipe(Layer.provide(Layer.mergeAll(serverConfigLayer, idAllocatorLayer))); + makeReplayAdapter(transcript, { external: true }).pipe( + Effect.map((adapter) => makeLayer([adapter])), + ), + ).pipe(Layer.provide(Layer.mergeAll(replayServerConfig(transcript.scenario), idAllocatorLayer))); } +/** + * An open 2.x session runtime whose server answers from `entries`, for + * adapter-level tests of failures a real server cannot produce on demand. + */ +export const openCode2ReplayRuntime = ( + entries: ReadonlyArray, + options?: { readonly external?: boolean }, +) => + Effect.gen(function* () { + const adapter = yield* makeReplayAdapter( + { + provider: OPENCODE_PROVIDER, + protocol: OPENCODE2_HTTP_PROTOCOL, + version: "2.0.18", + scenario: "opencode2_adapter", + entries, + }, + options, + ); + return yield* adapter.openSession({ + threadId: ThreadId.make("thread:opencode2-adapter"), + providerSessionId: ProviderSessionId.make("provider-session:opencode2-adapter"), + modelSelection: { + instanceId: ProviderInstanceId.make("opencode"), + model: "opencode/big-pickle", + }, + runtimePolicy: { runtimeMode: "full-access", interactionMode: "default", cwd: null }, + }); + }).pipe( + Effect.provide(Layer.mergeAll(replayServerConfig("opencode2_adapter"), idAllocatorLayer)), + ); + export const OpenCode2OrchestratorReplayHarness: OrchestratorV2ProviderReplayHarness< OpenCode2ReplayTranscript, OpenCodeReplayTranscriptDecodeError diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts index e8282e170a51..855abc5d41af 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts @@ -5,8 +5,9 @@ * * A turn is one `session.prompt`; the session's next `session.execution.*` * terminal ends it. Approvals, questions, subagents, steering, fork, rollback - * and compaction arrive in later layers, so the capabilities below say no and - * a permission or form that still reaches a session is refused so the turn + * and compaction arrive in later layers: sessions run in Full access only, + * with the `subagent` tool denied, the capabilities below say no, and a + * permission or form that still reaches a session is answered so the turn * cannot hang. * * @module orchestration-v2/Adapters/OpenCode2AdapterV2 @@ -30,6 +31,7 @@ import type { ProviderInstanceId, } from "@t3tools/contracts"; import type * as Cause from "effect/Cause"; +import * as P from "effect/Predicate"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -37,6 +39,7 @@ import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; import type { ServerConfig } from "../../config.ts"; +import type { OpenCode2StreamEvent } from "../../provider/opencode2/OpenCode2Client.ts"; import type { OpenCode2Connection, OpenCode2Server, @@ -46,6 +49,8 @@ import { type OpenCodeRuntimeError, } from "../../provider/opencodeRuntime.ts"; import { buildRuntimeInstructions } from "../../provider/RuntimeInstructions.ts"; +import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; + import { providerMessageTextWithAttachmentPaths } from "../AttachmentPrompt.ts"; import type { IdAllocatorV2Shape } from "../IdAllocator.ts"; import { makeProviderFailure } from "../ProviderFailure.ts"; @@ -189,6 +194,8 @@ interface ThreadState { providerThread: OrchestrationV2ProviderThread; readonly providerTurns: Map; active: ActiveTurn | undefined; + /** What the native session runs now, so a changed selection is switched before prompting. */ + model: ReturnType; } const protocolError = (detail: string) => @@ -211,6 +218,39 @@ const sessionIdOf = (providerThread: OrchestrationV2ProviderThread) => { const textOf = (content: ReadonlyArray<{ readonly type: string; readonly text?: string }>) => content.flatMap((part) => (part.type === "text" && part.text ? [part.text] : [])).join("\n"); +/** + * Every tool runs without asking, except `subagent`: a background child wakes + * its parent in a turn T3 would not see. Both go when approvals and subagents land. + */ +const SESSION_PERMISSIONS = [ + { action: "*", resource: "*", effect: "allow" }, + { action: "subagent", resource: "*", effect: "deny" }, +] as const; + +export const OPENCODE_2_FULL_ACCESS_ONLY = + "OpenCode 2 needs Full access for now; approvals come in a later update. Switch this thread's mode to continue."; + +// Questions are answered, not cancelled: a cancelled form ends the execution as a user stop. +const QUESTION_REPLY = "Questions aren't supported by this OpenCode integration yet."; + +const INTERRUPT_TIMEOUT = "10 seconds"; + +/** The model OpenCode should run for a `provider/model` slug and its reasoning variant. */ +const modelRef = (selection: ProviderAdapterV2TurnInput["modelSelection"]) => { + const parsed = parseOpenCodeModelSlug(selection.model); + if (parsed === null) return undefined; + const variant = getModelSelectionStringOptionValue(selection, "variant"); + return Model.Ref.make({ + providerID: Provider.ID.make(parsed.providerID), + id: Model.ID.make(parsed.modelID), + ...(variant === undefined ? {} : { variant: Model.VariantID.make(variant) }), + }); +}; +const sameModel = (left: ReturnType, right: ReturnType) => + left?.providerID === right?.providerID && + left?.id === right?.id && + (left?.variant ?? "default") === (right?.variant ?? "default"); + export interface OpenCode2AdapterOptions { readonly instanceId: ProviderInstanceId; readonly server: OpenCode2Server["Service"]; @@ -489,7 +529,7 @@ export function makeOpenCode2Adapter(options: OpenCode2AdapterOptions): Provider ); }); - // Refuses a permission or form so a session never waits on a reply T3 cannot give yet. + // Answers a permission or form T3 cannot show yet, so a session never waits on it. const refuseRequest = (event: EventOf<"permission.asked"> | EventOf<"form.created">) => { const refusal = event.type === "permission.asked" @@ -502,14 +542,38 @@ export function makeOpenCode2Adapter(options: OpenCode2AdapterOptions): Provider }) .pipe(Effect.ignore({ log: true })) : client.session.form - .cancel({ sessionID: event.data.form.sessionID, formID: event.data.form.id }) + .reply({ + sessionID: event.data.form.sessionID, + formID: event.data.form.id, + answer: Object.fromEntries( + event.data.form.fields.map((field) => [field.key, QUESTION_REPLY]), + ), + }) .pipe(Effect.ignore({ log: true })); - return Effect.logWarning("Refused an OpenCode request this runtime cannot answer yet.", { + return Effect.logWarning("Answered an OpenCode request this runtime cannot show yet.", { type: event.type, }).pipe(Effect.andThen(refusal)); }; - const handleEvent = Effect.fnUntraced(function* (event: OpenCodeEvent) { + const handleEvent = Effect.fnUntraced(function* (event: OpenCode2StreamEvent) { + if (event.type === "unreadable.execution.ended") { + const state = threads.get(event.sessionID); + if (state === undefined) return; + return yield* finishTurn( + state, + event.executionType === "session.execution.succeeded" + ? { status: state.active?.interrupted === true ? "interrupted" : "completed" } + : event.executionType === "session.execution.interrupted" + ? { status: "interrupted" } + : { + status: "failed", + failure: makeProviderFailure({ + message: "OpenCode ended the turn with an error this version cannot read.", + class: "provider_error", + }), + }, + ); + } if (event.type === "permission.asked" && threads.has(event.data.sessionID)) { return yield* refuseRequest(event); } @@ -583,11 +647,11 @@ export function makeOpenCode2Adapter(options: OpenCode2AdapterOptions): Provider // The stream is the only terminal signal, so a lost stream settles every // running turn and breaks the session: T3 reopens it for the next turn. const failAll = Effect.fnUntraced(function* (message: string) { - yield* setSessionStatus("error", message); for (const state of threads.values()) { const failure = makeProviderFailure({ message, class: "transport_error" }); yield* finishTurn(state, { status: "failed", failure }, "broken"); } + yield* setSessionStatus("error", message); yield* Queue.end(events); }); // Subscribed before any session or prompt call, so no event of theirs is missed. @@ -601,13 +665,38 @@ export function makeOpenCode2Adapter(options: OpenCode2AdapterOptions): Provider Effect.forkScoped, ); - const register = (providerThread: OrchestrationV2ProviderThread, sessionId: string) => { + // A server T3 did not start keeps running after T3 stops, so stop the turns + // it would otherwise finish unseen. A spawned server stops with its owner. + if (connection.external) { + yield* Effect.addFinalizer(() => + Effect.forEach( + [...threads].filter(([, state]) => state.active !== undefined), + ([sessionId]) => + client.session + .interrupt({ sessionID: Session.ID.make(sessionId) }) + .pipe(Effect.timeout("1 second"), Effect.ignore({ log: true })), + { concurrency: 8, discard: true }, + ), + ); + } + + const register = ( + providerThread: OrchestrationV2ProviderThread, + sessionId: string, + model: ReturnType, + ) => { const existing = threads.get(sessionId); if (existing !== undefined) { existing.providerThread = providerThread; + existing.model = model; return providerThread; } - threads.set(sessionId, { providerThread, providerTurns: new Map(), active: undefined }); + threads.set(sessionId, { + providerThread, + providerTurns: new Map(), + active: undefined, + model, + }); return providerThread; }; @@ -639,21 +728,13 @@ export function makeOpenCode2Adapter(options: OpenCode2AdapterOptions): Provider providerThread: threadInput.existingProviderThread, }); } - const parsed = parseOpenCodeModelSlug(threadInput.modelSelection.model); + const model = modelRef(threadInput.modelSelection); const created = yield* client.session.create({ location: Location.PublicRef.make({ directory: AbsolutePath.make(threadInput.runtimePolicy.cwd ?? serverConfig.cwd), }), - ...(parsed === null - ? {} - : { - model: Model.Ref.make({ - providerID: Provider.ID.make(parsed.providerID), - id: Model.ID.make(parsed.modelID), - }), - }), - // Full access is the only mode the snapshot offers until layer 5. - permissions: [{ action: "*", resource: "*", effect: "allow" }], + ...(model === undefined ? {} : { model }), + permissions: SESSION_PERMISSIONS, }); const createdAt = yield* DateTime.now; const providerThread: OrchestrationV2ProviderThread = { @@ -675,7 +756,7 @@ export function makeOpenCode2Adapter(options: OpenCode2AdapterOptions): Provider status: "idle", updatedAt: createdAt, }; - return register(providerThread, created.id); + return register(providerThread, created.id, created.model); }).pipe( Effect.mapError( (cause) => @@ -691,7 +772,15 @@ export function makeOpenCode2Adapter(options: OpenCode2AdapterOptions): Provider const sessionId = yield* sessionIdOf(threadInput.providerThread); // 1.x session ids survive the upgrade; a server without this session // fails the resume, so T3 recreates the thread with a handoff. - yield* client.session.get({ sessionID: Session.ID.make(sessionId) }); + const native = yield* client.session.get({ sessionID: Session.ID.make(sessionId) }); + // A thread moved to another worktree takes its session with it. + const cwd = threadInput.runtimePolicy?.cwd; + if (cwd != null && native.location.directory !== cwd) { + yield* client.session.move({ + sessionID: Session.ID.make(sessionId), + directory: AbsolutePath.make(cwd), + }); + } return register( { ...threadInput.providerThread, @@ -700,6 +789,7 @@ export function makeOpenCode2Adapter(options: OpenCode2AdapterOptions): Provider updatedAt: yield* DateTime.now, }, sessionId, + native.model, ); }).pipe( Effect.mapError( @@ -714,6 +804,10 @@ export function makeOpenCode2Adapter(options: OpenCode2AdapterOptions): Provider ), startTurn: (turnInput) => Effect.gen(function* () { + // Sessions allow every tool, so any other mode would silently run as Full access. + if (turnInput.runtimePolicy.runtimeMode !== "full-access") { + return yield* protocolError(OPENCODE_2_FULL_ACCESS_ONLY); + } const sessionId = yield* sessionIdOf(turnInput.providerThread); const state = threads.get(sessionId); if (state === undefined) { @@ -722,6 +816,13 @@ export function makeOpenCode2Adapter(options: OpenCode2AdapterOptions): Provider if (state.active !== undefined) { return yield* protocolError(`OpenCode session ${sessionId} already has an active turn`); } + // A selection changed since the last turn applies now; OpenCode keeps + // the session's model otherwise. + const model = modelRef(turnInput.modelSelection); + if (model !== undefined && !sameModel(model, state.model)) { + yield* client.session.switchModel({ sessionID: Session.ID.make(sessionId), model }); + state.model = model; + } const startedAt = yield* DateTime.now; const nativeTurnId = `${sessionId}:attempt:${turnInput.attemptId}`; const providerTurn: OrchestrationV2ProviderTurn = { @@ -763,6 +864,24 @@ export function makeOpenCode2Adapter(options: OpenCode2AdapterOptions): Provider yield* client.session .prompt({ sessionID: Session.ID.make(sessionId), text: prompt(turnInput) }) .pipe( + Effect.catchIf( + (cause) => P.isTagged(cause, "SessionNotFoundError"), + // Deleted outside T3: the thread is broken, and forgetting it makes + // the next turn resume, fail, and recreate it with a handoff. + () => + finishTurn( + state, + { + status: "failed", + failure: makeProviderFailure({ + message: + "The OpenCode session no longer exists. Send the message again to continue in a new session.", + class: "provider_error", + }), + }, + "broken", + ).pipe(Effect.andThen(Effect.sync(() => threads.delete(sessionId)))), + ), Effect.tapError((cause) => state.active === turn ? finishTurn(state, { @@ -794,11 +913,33 @@ export function makeOpenCode2Adapter(options: OpenCode2AdapterOptions): Provider interruptTurn: (interruptInput) => Effect.gen(function* () { const sessionId = yield* sessionIdOf(interruptInput.providerThread); - const turn = threads.get(sessionId)?.active; - if (turn === undefined || turn.providerTurn.id !== interruptInput.providerTurnId) return; + const state = threads.get(sessionId); + const turn = state?.active; + if ( + state === undefined || + turn === undefined || + turn.providerTurn.id !== interruptInput.providerTurnId + ) { + return; + } + // The session answers with `session.execution.interrupted`, which ends + // the turn. A server that does not answer in time is stuck, so the turn + // ends here instead of waiting on it. turn.interrupted = true; - // The session answers with `session.execution.interrupted`, which ends the turn. - yield* client.session.interrupt({ sessionID: Session.ID.make(sessionId) }); + const reply = yield* client.session + .interrupt({ sessionID: Session.ID.make(sessionId) }) + .pipe(Effect.timeoutOption(INTERRUPT_TIMEOUT)); + if (reply._tag === "None") { + return yield* finishTurn(state, { status: "interrupted" }); + } + // Nothing was running. Unless the execution already ended (its event + // is on the way), the turn is still open and nothing stopped. + if (!reply.value.interrupted && state.active === turn) { + turn.interrupted = false; + return yield* protocolError( + `OpenCode session ${sessionId} had nothing running to stop`, + ); + } }).pipe( Effect.mapError( (cause) => diff --git a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts index 3ac9982cc1cf..62cd36c0ce8e 100644 --- a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts +++ b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts @@ -12,6 +12,7 @@ import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import { SourceControlProviderRegistry } from "../sourceControl/SourceControlProviderRegistry.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as NodeSqlite from "node:sqlite"; import { assert, it } from "@effect/vitest"; import { CommandId, @@ -19,6 +20,7 @@ import { type ModelSelection, type OrchestrationV2ThreadProjection, ProjectId, + ProviderDriverKind, ProviderInstanceId, ThreadId, } from "@t3tools/contracts"; @@ -26,6 +28,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; import { FetchHttpClient } from "effect/unstable/http"; import { describe } from "vite-plus/test"; @@ -60,6 +63,8 @@ const MODEL: ModelSelection = { instanceId: INSTANCE, model: process.env.OPENCODE2_MODEL ?? "opencode/big-pickle", }; +// The free model the thread switches to mid-conversation. +const SWITCHED_MODEL = "opencode/mimo-v2.6-flash-free"; const PlatformTestLayer = Layer.merge( NodeServices.layer, @@ -75,7 +80,7 @@ const vcsDriverRegistryLayer = VcsDriverRegistry.layer.pipe( const serverSettingsLayer = ServerSettingsService.layerTest({ providerInstances: { [INSTANCE]: { - driver: "opencode", + driver: ProviderDriverKind.make("opencode"), enabled: true, environment: [ { name: "HOME", value: ROOT }, @@ -87,7 +92,7 @@ const serverSettingsLayer = ServerSettingsService.layerTest({ config: { enabled: true, binaryPath }, }, }, -} as never); +}); const backgroundPolicyLayer = BackgroundPolicy.layer.pipe( Layer.provide(Layer.effect(HostPowerMonitor.HostPowerMonitor, HostPowerMonitor.make())), Layer.provide(serverSettingsLayer), @@ -161,6 +166,7 @@ const send = Effect.fn("OpenCode2Live.send")(function* ( threadId: ThreadId, key: string, text: string, + modelSelection: ModelSelection = MODEL, ) { const orchestrator = yield* OrchestratorV2; yield* orchestrator.dispatch({ @@ -172,11 +178,36 @@ const send = Effect.fn("OpenCode2Live.send")(function* ( messageId: MessageId.make(`message:opencode2-live:${key}`), text, attachments: [], - modelSelection: MODEL, + modelSelection, dispatchMode: { type: "start_immediately" }, }); }); +const AssistantModel = Schema.fromJsonString( + Schema.Struct({ model: Schema.Struct({ providerID: Schema.String, id: Schema.String }) }), +); +const decodeAssistantModel = Schema.decodeUnknownSync(AssistantModel); + +/** + * The `provider/model` of each assistant message in a native session, oldest + * first, from the spawned server's own database under the isolated XDG root. + */ +const assistantModels = (nativeSessionId: string) => + Effect.sync(() => { + const db = new NodeSqlite.DatabaseSync(`${ROOT}/data/opencode/opencode.db`, { readOnly: true }); + try { + return db + .prepare( + "SELECT data FROM session_message WHERE session_id = ? AND type = 'assistant' ORDER BY seq", + ) + .all(nativeSessionId) + .map((row) => decodeAssistantModel(row.data).model) + .map((model) => `${model.providerID}/${model.id}`); + } finally { + db.close(); + } + }); + describe.runIf(binaryPath !== undefined && ROOT !== "")("OpenCode 2 live orchestrator", () => { it.live( "runs a tool turn and stops a running shell command through the real driver", @@ -250,7 +281,36 @@ describe.runIf(binaryPath !== undefined && ROOT !== "")("OpenCode 2 live orchest (item) => item.type === "command_execution" && item.input.includes("sleep 60"), ); assert.equal(sleep?.status, "interrupted"); - }).pipe(Effect.provide(liveLayer), Effect.provide(NodeServices.layer), Effect.scoped), + + // A model change applies to the same native session on the next turn. + const switched: ModelSelection = { instanceId: INSTANCE, model: SWITCHED_MODEL }; + yield* send(threadId, "switch", "Reply with exactly: SWITCHED", switched); + const third = yield* waitFor( + threadId, + (projection) => projection.runs.length === 3 && settled(projection), + ); + assert.equal(third.runs.at(-1)?.status, "completed"); + const sessionId = third.providerThreads[0]?.nativeThreadRef?.nativeId; + assert.isDefined(sessionId); + const models = yield* assistantModels(sessionId!); + assert.equal(models.at(-1), SWITCHED_MODEL); + assert.notEqual(models[0], SWITCHED_MODEL); + + // Supervised threads are refused, not run with every tool allowed. + yield* orchestrator.dispatch({ + type: "thread.runtime-mode.set", + commandId: CommandId.make("command:opencode2-live:runtime-mode"), + threadId, + runtimeMode: "approval-required", + }); + yield* send(threadId, "supervised", "Create a file named supervised.txt containing NO."); + const refused = yield* waitFor( + threadId, + (projection) => projection.runs.length === 4 && settled(projection), + ); + assert.equal(refused.runs.at(-1)?.status, "failed"); + assert.isFalse(yield* fs.exists(path.join(ROOT, "work", "supervised.txt"))); + }).pipe(Effect.provide(Layer.merge(liveLayer, NodeServices.layer)), Effect.scoped), 360_000, ); }); diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/index.ts b/apps/server/src/orchestration-v2/testkit/fixtures/index.ts index f9ef79fd18b3..867d9f55cb94 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/index.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/index.ts @@ -790,7 +790,10 @@ export const ORCHESTRATOR_REPLAY_FIXTURES: ReadonlyArray) => + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response( + new ReadableStream({ + start(controller) { + for (const event of events) { + controller.enqueue(new TextEncoder().encode(`data: ${encodeJson(event)}\n\n`)); + } + }, + }), + { headers: { "content-type": "text/event-stream" } }, + ), + ), + ), + ), + ); + describe("OpenCode2Client events", () => { + it.effect("fails a stream that stops sending anything, even heartbeats", () => + Effect.gen(function* () { + const { events } = yield* connect.pipe( + Effect.provide( + OpenCode2Client.layer.pipe( + Layer.provide(servingThenSilent([{ id: "evt_1", type: "server.connected", data: {} }])), + ), + ), + ); + const drained = yield* (yield* events).pipe(Stream.runDrain, Effect.flip, Effect.forkChild); + // Let the first frame arrive and the timer start before the clock moves. + for (let i = 0; i < 20; i++) yield* Effect.yieldNow; + yield* TestClock.adjust("46 seconds"); + const failure = yield* Fiber.join(drained); + assert.strictEqual(failure._tag, "OpenCode2SilentStreamError"); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("keeps an execution's end it cannot decode, and skips other unknown events", () => + Effect.gen(function* () { + const { events } = yield* connect.pipe( + Effect.provide( + OpenCode2Client.layer.pipe( + Layer.provide( + serving([ + { + id: "evt_1", + created: 1, + type: "session.hologram.projected", + data: { sessionID: "ses_x" }, + }, + { + id: "evt_2", + created: 1, + type: "session.execution.interrupted", + data: { sessionID: "ses_x", reason: "budget" }, + durable: { aggregateID: "ses_x", seq: 1, version: 1 }, + }, + ]), + ), + ), + ), + ); + const received = yield* (yield* events).pipe(Stream.runCollect); + assert.deepStrictEqual(received, [ + { + type: "unreadable.execution.ended", + executionType: "session.execution.interrupted", + sessionID: "ses_x", + }, + ]); + }), + ); + it.effect("skips events a newer server adds and still sees the turn end", () => Effect.gen(function* () { const events = yield* newerServerStream; diff --git a/apps/server/src/provider/opencode2/OpenCode2Client.live.test.ts b/apps/server/src/provider/opencode2/OpenCode2Client.live.test.ts index b92531cbdfab..387cce34b932 100644 --- a/apps/server/src/provider/opencode2/OpenCode2Client.live.test.ts +++ b/apps/server/src/provider/opencode2/OpenCode2Client.live.test.ts @@ -103,6 +103,7 @@ describe.runIf(binaryPath !== undefined)("OpenCode2Client live", () => { const finished = yield* (yield* events).pipe( Stream.filter( (event) => + event.type !== "unreadable.execution.ended" && (event.data as { readonly sessionID?: string }).sessionID === session.id && event.type.startsWith("session.execution.") && event.type !== "session.execution.started", diff --git a/apps/server/src/provider/opencode2/OpenCode2Client.ts b/apps/server/src/provider/opencode2/OpenCode2Client.ts index 818460323493..bf99c2c79036 100644 --- a/apps/server/src/provider/opencode2/OpenCode2Client.ts +++ b/apps/server/src/provider/opencode2/OpenCode2Client.ts @@ -17,18 +17,45 @@ import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; /** OpenCode 2 accepts only HTTP Basic auth, always with this user name. */ const OPENCODE_USERNAME = "opencode"; +/** + * A session's execution ended, but this build could not decode the event (a + * newer server added a field or a value). Only the type and session survive, + * so the turn still ends. + */ +export interface OpenCode2UnreadableTerminal { + readonly type: "unreadable.execution.ended"; + readonly executionType: string; + readonly sessionID: string; +} + +/** The server sent nothing, not even a heartbeat, for `SILENT_STREAM_TIMEOUT`. */ +export class OpenCode2SilentStreamError extends Schema.TaggedError()( + "OpenCode2SilentStreamError", + {}, +) { + override get message(): string { + return "The OpenCode server stopped sending events."; + } +} + +export type OpenCode2StreamEvent = OpenCodeEvent | OpenCode2UnreadableTerminal; + /** * An OpenCode 2 client plus a forward-compatible `/api/event` stream. The * client's own `event.subscribe` fails the whole stream on the first event a * newer server adds, so `events` decodes each frame on its own and skips the - * ones this build does not know. `events` succeeds once the server accepted - * the subscription: the stream is volatile, so callers subscribe before they - * start work whose events they need. + * ones this build does not know, except an execution's end, which it keeps as + * an {@link OpenCode2UnreadableTerminal}. `events` succeeds once the server + * accepted the subscription: the stream is volatile, so callers subscribe + * before they start work whose events they need. */ export interface OpenCode2Api { readonly client: OpenCodeClient; readonly events: Effect.Effect< - Stream.Stream, + Stream.Stream< + OpenCode2StreamEvent, + HttpClientError.HttpClientError | Sse.Retry | Sse.SseError | OpenCode2SilentStreamError + >, HttpClientError.HttpClientError >; } @@ -44,27 +71,62 @@ export class OpenCode2Client extends Context.Service< } >()("t3/provider/opencode2/OpenCode2Client") {} +/** + * OpenCode writes a `: heartbeat` comment every 10 to 15 seconds, so this long + * without a single byte means the server is stuck, not idle. + */ +const SILENT_STREAM_TIMEOUT = "45 seconds"; + const decodeEvent = Schema.decodeUnknownResult(Schema.fromJsonString(OpenCodeEvent)); -const unknownEventType = (data: string) => { - const match = /"type"\s*:\s*"([^"]{1,80})"/.exec(data); - return match?.[1] ?? ""; -}; +// Just enough of an event to route it, for frames the full schema rejects. +const decodeEnvelope = Schema.decodeUnknownOption( + Schema.fromJsonString( + Schema.Struct({ + type: Schema.String, + data: Schema.optional(Schema.Struct({ sessionID: Schema.optional(Schema.String) })), + }), + ), +); -/** Subscribes to `/api/event`, then streams every frame this build can decode. */ +const undecodable = (data: string) => + Effect.gen(function* () { + const envelope = decodeEnvelope(data); + const type = envelope._tag === "Some" ? envelope.value.type.slice(0, 80) : ""; + const sessionID = envelope._tag === "Some" ? envelope.value.data?.sessionID : undefined; + if (type.startsWith("session.execution.") && sessionID !== undefined) { + yield* Effect.logWarning( + "Ended an OpenCode execution from an event this build cannot decode.", + { + type, + }, + ); + return Result.succeed({ + type: "unreadable.execution.ended", + executionType: type, + sessionID, + }); + } + yield* Effect.logDebug("Skipped an OpenCode event this build cannot decode.", { type }); + return Result.failVoid; + }); + +/** Subscribes to `/api/event`, then streams every frame this build can route. */ const readEvents = (httpClient: HttpClient.HttpClient) => httpClient.get("/api/event", { headers: { accept: "text/event-stream" } }).pipe( Effect.flatMap(HttpClientResponse.filterStatusOk), Effect.map((response) => response.stream.pipe( + Stream.timeoutOrElse({ + duration: SILENT_STREAM_TIMEOUT, + orElse: () => Stream.fail(new OpenCode2SilentStreamError()), + }), Stream.decodeText, Stream.pipeThroughChannel(Sse.decode()), Stream.filterMapEffect((frame) => { const decoded = decodeEvent(frame.data); return Result.isSuccess(decoded) - ? Effect.succeed(Result.succeed(decoded.success)) - : Effect.logDebug("Skipped an OpenCode event this build cannot decode.", { - type: unknownEventType(frame.data), - }).pipe(Effect.as(Result.failVoid)); + ? Effect.succeed(Result.succeed(decoded.success)) + : undecodable(frame.data); }), ), ), From 817924b9a08d9264404540680e8b71db0a8e5086 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:23:50 -0700 Subject: [PATCH 3/9] fix(server): settle OpenCode 2 turns only on a real end, and order replay requests - Only an undecodable `session.execution.succeeded`, `failed` or `interrupted` ends a turn; an undecodable start or other execution event is skipped like any unknown event. - A Stop whose request failed leaves the turn as it was, so its later `succeeded` is reported as completed, not interrupted. - The OpenCode 2 replay transport matches a request only after the events recorded before it were delivered. Events and requests use separate connections, so a slow event reader let a Stop reach the replay ahead of the recorded progress frame (`opencode2_interrupt` failed on CI). - The adapter is built with `make`, taking its server, id allocator and config from the environment; imports use namespaces. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Adapters/OpenCode2AdapterV2.test.ts | 77 +++++++++++++++++++ .../Adapters/OpenCode2AdapterV2.testkit.ts | 24 +++--- .../Adapters/OpenCode2AdapterV2.ts | 44 ++++++----- .../Adapters/OpenCodeAdapterV2.testkit.ts | 14 ++++ .../src/provider/Drivers/OpenCodeDriver.ts | 21 ++--- .../opencode2/OpenCode2Client.events.test.ts | 8 ++ .../src/provider/opencode2/OpenCode2Client.ts | 13 +++- 7 files changed, 157 insertions(+), 44 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts index b4cadb62782c..426dd7e9eb9e 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -216,6 +216,61 @@ describe("OpenCode2 adapter", () => { }).pipe(Effect.scoped), ); + it.effect("keeps a turn running through a start event this build cannot decode", () => + Effect.gen(function* () { + const { runtime, thread } = yield* resumed([ + out("session.prompt", { sessionID: SESSION, text: "" }), + promptAccepted, + { + type: "emit_inbound", + frame: { + type: "sdk.event", + event: { + id: "evt_executionstartedx", + created: 1, + type: "session.execution.started", + data: { sessionID: SESSION }, + durable: "not-an-envelope", + }, + }, + }, + event("session.text.started", { + sessionID: SESSION, + assistantMessageID: "msg_0eb735d5b001oAFVeY5jz3WD4Z", + ordinal: 0, + }), + event("session.text.ended", { + sessionID: SESSION, + assistantMessageID: "msg_0eb735d5b001oAFVeY5jz3WD4Z", + ordinal: 0, + text: "DONE", + }), + event("session.execution.succeeded", { sessionID: SESSION }), + ]); + const collected = yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + Effect.forkScoped, + ); + yield* runtime.startTurn(turnInput(thread)); + const seen = yield* Fiber.join(collected); + const terminals = seen.filter((event) => event.type === "turn.terminal"); + assert.deepEqual( + terminals.map((event) => event.type === "turn.terminal" && event.status), + ["completed"], + ); + // The reply after the malformed start still reached the turn. + assert.isTrue( + seen.some( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "assistant_message" && + event.turnItem.text === "DONE", + ), + ); + }).pipe(Effect.scoped), + ); + it.effect("denies the subagent tool on the sessions it creates", () => Effect.gen(function* () { const runtime = yield* openCode2ReplayRuntime([ @@ -297,6 +352,28 @@ describe("OpenCode2 adapter", () => { }).pipe(Effect.scoped), ); + it.effect("reports a turn as completed when its Stop request failed", () => + Effect.gen(function* () { + const { runtime, thread } = yield* resumed([ + out("session.prompt", { sessionID: SESSION, text: "" }), + promptAccepted, + out("session.interrupt", { sessionID: SESSION }), + reply("session.interrupt", { + status: 500, + body: { _tag: "UnknownError", message: "interrupt failed" }, + }), + event("session.execution.succeeded", { sessionID: SESSION }), + ]); + const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped); + yield* runtime.startTurn(turnInput(thread)); + const failed = yield* runtime + .interruptTurn({ providerThread: thread, providerTurnId: yield* providerTurnId }) + .pipe(Effect.flip); + assert.equal(failed._tag, "ProviderAdapterInterruptError"); + assert.equal((yield* Fiber.join(terminal))?.status, "completed"); + }).pipe(Effect.scoped), + ); + it.effect("moves the session when the thread's worktree changed", () => Effect.gen(function* () { const runtime = yield* openCode2ReplayRuntime([ diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts index 358906aa3db1..d8c263bb9066 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts @@ -22,7 +22,8 @@ import * as UrlParams from "effect/unstable/http/UrlParams"; import { ServerConfig } from "../../config.ts"; import * as OpenCode2Client from "../../provider/opencode2/OpenCode2Client.ts"; -import { layer as idAllocatorLayer, IdAllocatorV2 } from "../IdAllocator.ts"; +import * as OpenCode2Server from "../../provider/opencode2/OpenCode2Server.ts"; +import * as IdAllocator from "../IdAllocator.ts"; import { makeLayer } from "../ProviderAdapterRegistry.ts"; import { makeReplayServerConfig, @@ -33,7 +34,7 @@ import { OpenCodeReplayController, OpenCodeReplayTranscriptDecodeError, } from "./OpenCodeAdapterV2.testkit.ts"; -import { makeOpenCode2Adapter } from "./OpenCode2AdapterV2.ts"; +import * as OpenCode2AdapterV2 from "./OpenCode2AdapterV2.ts"; export const OPENCODE2_HTTP_PROTOCOL = "opencode2-http.sse" as const; const BASE_URL = "http://opencode2.replay"; @@ -103,6 +104,9 @@ const replayHttpClient = (controller: OpenCodeReplayController) => query, raw === undefined ? undefined : decodeJson(raw), ); + // The event stream and requests are separate connections: a request + // is matched only once the events recorded before it were delivered. + if (operation.type !== "event.subscribe") await controller.untilEventsDelivered(); await controller.expectOutbound(operation); if (operation.type === "event.subscribe") { const encoder = new TextEncoder(); @@ -154,12 +158,12 @@ const makeReplayAdapter = ( version: transcript.version, external: options?.external ?? false, }; - return makeOpenCode2Adapter({ - instanceId: ProviderInstanceId.make("opencode"), - server: { withConnection: (use) => use(connection) }, - idAllocator: yield* IdAllocatorV2, - serverConfig: yield* ServerConfig, - }); + return yield* OpenCode2AdapterV2.make(ProviderInstanceId.make("opencode")).pipe( + Effect.provideService( + OpenCode2Server.OpenCode2Server, + OpenCode2Server.OpenCode2Server.of({ withConnection: (use) => use(connection) }), + ), + ); }); const replayServerConfig = (scenario: string) => @@ -172,7 +176,7 @@ function makeRegistryLayer(transcript: OpenCode2ReplayTranscript) { makeReplayAdapter(transcript, { external: true }).pipe( Effect.map((adapter) => makeLayer([adapter])), ), - ).pipe(Layer.provide(Layer.mergeAll(replayServerConfig(transcript.scenario), idAllocatorLayer))); + ).pipe(Layer.provide(Layer.mergeAll(replayServerConfig(transcript.scenario), IdAllocator.layer))); } /** @@ -204,7 +208,7 @@ export const openCode2ReplayRuntime = ( runtimePolicy: { runtimeMode: "full-access", interactionMode: "default", cwd: null }, }); }).pipe( - Effect.provide(Layer.mergeAll(replayServerConfig("opencode2_adapter"), idAllocatorLayer)), + Effect.provide(Layer.mergeAll(replayServerConfig("opencode2_adapter"), IdAllocator.layer)), ); export const OpenCode2OrchestratorReplayHarness: OrchestratorV2ProviderReplayHarness< diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts index 855abc5d41af..881a3efb74bb 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts @@ -38,11 +38,11 @@ import * as Effect from "effect/Effect"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; -import type { ServerConfig } from "../../config.ts"; +import { ServerConfig } from "../../config.ts"; import type { OpenCode2StreamEvent } from "../../provider/opencode2/OpenCode2Client.ts"; -import type { - OpenCode2Connection, +import { OpenCode2Server, + type OpenCode2Connection, } from "../../provider/opencode2/OpenCode2Server.ts"; import { parseOpenCodeModelSlug, @@ -52,7 +52,7 @@ import { buildRuntimeInstructions } from "../../provider/RuntimeInstructions.ts" import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { providerMessageTextWithAttachmentPaths } from "../AttachmentPrompt.ts"; -import type { IdAllocatorV2Shape } from "../IdAllocator.ts"; +import { IdAllocatorV2 } from "../IdAllocator.ts"; import { makeProviderFailure } from "../ProviderFailure.ts"; import { ProviderAdapterEnsureThreadError, @@ -66,6 +66,7 @@ import { ProviderAdapterRuntimeRequestResponseError, ProviderAdapterSteerRunUnsupportedError, ProviderAdapterTurnStartError, + ProviderAdapterV2, type ProviderAdapterV2Event, type ProviderAdapterV2SessionRuntime, type ProviderAdapterV2Shape, @@ -251,15 +252,14 @@ const sameModel = (left: ReturnType, right: ReturnType + Effect.sync(() => { + turn.interrupted = false; + }), + ), + ); if (reply._tag === "None") { return yield* finishTurn(state, { status: "interrupted" }); } @@ -999,7 +1007,7 @@ export function makeOpenCode2Adapter(options: OpenCode2AdapterOptions): Provider return runtime; }); - return { + return ProviderAdapterV2.of({ instanceId, driver, getCapabilities: () => Effect.succeed(OpenCode2ProviderCapabilities), @@ -1009,7 +1017,7 @@ export function makeOpenCode2Adapter(options: OpenCode2AdapterOptions): Provider openSession: (input) => Effect.gen(function* () { const lent = yield* Deferred.make(); - yield* options.server + yield* server .withConnection((connection) => Deferred.succeed(lent, connection).pipe(Effect.andThen(Effect.never)), ) @@ -1028,5 +1036,5 @@ export function makeOpenCode2Adapter(options: OpenCode2AdapterOptions): Provider }), ), ), - }; -} + }); +}); diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.testkit.ts index b818f1ae28ac..74e4e8948306 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.testkit.ts @@ -219,6 +219,20 @@ export class OpenCodeReplayController { } } + /** + * Resolves once the server events recorded before the next entry have been + * delivered. For transports whose events and requests travel separately, a + * request is matched at its recorded point instead of racing those events. + */ + async untilEventsDelivered(): Promise { + while (true) { + this.throwFailure(); + const entry = this.transcript.entries[this.cursor]; + if (entry?.type !== "emit_inbound" || frameRecord(entry.frame)?.type !== "sdk.event") return; + await this.changed(); + } + } + async *events(signal?: AbortSignal): AsyncIterable { while (true) { if (signal?.aborted === true) return; diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.ts index a186b92b195c..f84192842152 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.ts @@ -25,12 +25,8 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { makeOpenCodeTextGeneration } from "../../textGeneration/OpenCodeTextGeneration.ts"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import * as ServerConfig from "../../config.ts"; -import { - OpenCodeAdapterV2Driver, - type OpenCodeAdapterV2DriverEnv, -} from "../../orchestration-v2/Adapters/OpenCodeAdapterV2.ts"; -import { makeOpenCode2Adapter } from "../../orchestration-v2/Adapters/OpenCode2AdapterV2.ts"; -import { IdAllocatorV2 } from "../../orchestration-v2/IdAllocator.ts"; +import * as OpenCodeAdapterV2 from "../../orchestration-v2/Adapters/OpenCodeAdapterV2.ts"; +import * as OpenCode2AdapterV2 from "../../orchestration-v2/Adapters/OpenCode2AdapterV2.ts"; import type { ProviderAdapterV2Shape } from "../../orchestration-v2/ProviderAdapter.ts"; import * as ServerSettings from "../../serverSettings.ts"; import type { TextGeneration } from "../../textGeneration/TextGeneration.ts"; @@ -168,7 +164,7 @@ function selectOpenCodeRuntimeTextGeneration( } export type OpenCodeDriverEnv = - | OpenCodeAdapterV2DriverEnv + | OpenCodeAdapterV2.OpenCodeAdapterV2DriverEnv | BackgroundPolicy.BackgroundPolicy | ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto @@ -226,7 +222,7 @@ export const OpenCodeDriver: ProviderDriver Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, openCodeRuntime), ), ); - const openCodeV1Adapter = yield* OpenCodeAdapterV2Driver.create({ + const openCodeV1Adapter = yield* OpenCodeAdapterV2.OpenCodeAdapterV2Driver.create({ instanceId, displayName, accentColor, @@ -263,12 +259,9 @@ export const OpenCodeDriver: ProviderDriver const orchestrationAdapter = selectOpenCodeRuntimeAdapter({ probe: runtimeProbe, v1: openCodeV1Adapter, - v2: makeOpenCode2Adapter({ - instanceId, - server: openCode2Server, - idAllocator: yield* IdAllocatorV2, - serverConfig, - }), + v2: yield* OpenCode2AdapterV2.make(instanceId).pipe( + Effect.provideService(OpenCode2Server.OpenCode2Server, openCode2Server), + ), }); const serverOwner = yield* OpenCodeServerOwner.make({ binaryPath: effectiveConfig.binaryPath, diff --git a/apps/server/src/provider/opencode2/OpenCode2Client.events.test.ts b/apps/server/src/provider/opencode2/OpenCode2Client.events.test.ts index d3dae38653ef..8d9325bdd3d1 100644 --- a/apps/server/src/provider/opencode2/OpenCode2Client.events.test.ts +++ b/apps/server/src/provider/opencode2/OpenCode2Client.events.test.ts @@ -129,6 +129,14 @@ describe("OpenCode2Client events", () => { type: "session.hologram.projected", data: { sessionID: "ses_x" }, }, + // A start is not an end: an undecodable one is skipped like any other event. + { + id: "evt_0", + created: 1, + type: "session.execution.started", + data: { sessionID: "ses_x", lane: 7 }, + durable: "not-an-envelope", + }, { id: "evt_2", created: 1, diff --git a/apps/server/src/provider/opencode2/OpenCode2Client.ts b/apps/server/src/provider/opencode2/OpenCode2Client.ts index bf99c2c79036..41886fc0ac35 100644 --- a/apps/server/src/provider/opencode2/OpenCode2Client.ts +++ b/apps/server/src/provider/opencode2/OpenCode2Client.ts @@ -24,10 +24,19 @@ const OPENCODE_USERNAME = "opencode"; */ export interface OpenCode2UnreadableTerminal { readonly type: "unreadable.execution.ended"; - readonly executionType: string; + readonly executionType: (typeof EXECUTION_ENDS)[number]; readonly sessionID: string; } +/** The events that end an execution; any other undecodable event is skipped. */ +const EXECUTION_ENDS = [ + "session.execution.succeeded", + "session.execution.failed", + "session.execution.interrupted", +] as const; +const isExecutionEnd = (type: string): type is (typeof EXECUTION_ENDS)[number] => + (EXECUTION_ENDS as ReadonlyArray).includes(type); + /** The server sent nothing, not even a heartbeat, for `SILENT_STREAM_TIMEOUT`. */ export class OpenCode2SilentStreamError extends Schema.TaggedError()( "OpenCode2SilentStreamError", @@ -93,7 +102,7 @@ const undecodable = (data: string) => const envelope = decodeEnvelope(data); const type = envelope._tag === "Some" ? envelope.value.type.slice(0, 80) : ""; const sessionID = envelope._tag === "Some" ? envelope.value.data?.sessionID : undefined; - if (type.startsWith("session.execution.") && sessionID !== undefined) { + if (isExecutionEnd(type) && sessionID !== undefined) { yield* Effect.logWarning( "Ended an OpenCode execution from an event this build cannot decode.", { From 2c14ab55d728e7634a99e9af8a70864bcaf0e9c5 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:39:30 -0700 Subject: [PATCH 4/9] fix(server): refuse malformed OpenCode 2 models and never leave a refused request paused - A model slug that is not `provider/model` fails the thread or turn before any session call, as the 1.x adapter does. Sending no model ran OpenCode's default while T3 recorded the requested one. - A permission or form answer that cannot be delivered is retried once; if it still fails, the turn it would block ends as failed ("OpenCode is waiting on a request T3 Code couldn't answer.") and the session is interrupted, since OpenCode otherwise waits on the request forever. - `Effect.catchTags` for a deleted session, and namespace imports for the server and id allocator modules. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Adapters/OpenCode2AdapterV2.test.ts | 84 +++++++++ .../Adapters/OpenCode2AdapterV2.ts | 161 +++++++++++------- 2 files changed, 187 insertions(+), 58 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts index 426dd7e9eb9e..02887288a881 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -416,6 +416,90 @@ describe("OpenCode2 adapter", () => { }).pipe(Effect.scoped), ); + it.effect("refuses a model slug that is not provider/model before creating a session", () => + Effect.gen(function* () { + // Nothing but the session's opening is expected: no create, no prompt. + const runtime = yield* openCode2ReplayRuntime([out("event.subscribe")]); + const created = yield* runtime + .ensureThread({ + threadId, + modelSelection: { instanceId, model: "big-pickle" }, + runtimePolicy: policy(), + }) + .pipe(Effect.flip); + assert.include( + String((created.cause as { detail?: string } | undefined)?.detail), + "OpenCode model 'big-pickle' must use provider/model format", + ); + }).pipe(Effect.scoped), + ); + + it.effect("refuses a turn whose model slug is not provider/model before prompting", () => + Effect.gen(function* () { + const { runtime, thread } = yield* resumed([]); + const refused = yield* runtime + .startTurn(turnInput(thread, { instanceId, model: "big-pickle" })) + .pipe(Effect.flip); + assert.include( + String((refused.cause as { detail?: string } | undefined)?.detail), + "must use provider/model format", + ); + }).pipe(Effect.scoped), + ); + + it.effect("ends the turn when a permission it refuses cannot be answered", () => + Effect.gen(function* () { + const failedReply = reply("permission.reply", { + status: 500, + body: { _tag: "UnknownError", message: "reply failed" }, + }); + const replyOut = out("permission.reply", { + sessionID: SESSION, + requestID: "per_0eb7c4d7e001Pyt8o50Vi4KrOO", + decision: "reject", + message: "", + }); + const { runtime, thread } = yield* resumed([ + out("session.prompt", { sessionID: SESSION, text: "" }), + promptAccepted, + { + type: "emit_inbound", + frame: { + type: "sdk.event", + event: { + id: "evt_permissionasked0", + created: 1, + type: "permission.asked", + data: { + id: "per_0eb7c4d7e001Pyt8o50Vi4KrOO", + sessionID: SESSION, + action: "shell", + resources: ["echo FIRST"], + }, + }, + }, + }, + // One try and one retry, then the turn ends and the session is stopped. + replyOut, + failedReply, + replyOut, + failedReply, + out("session.interrupt", { sessionID: SESSION }), + reply("session.interrupt", { interrupted: true }), + ]); + const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped); + yield* runtime.startTurn(turnInput(thread)); + const ended = yield* Fiber.join(terminal); + assert.equal(ended?.status, "failed"); + assert.equal( + ended?.status === "failed" ? ended.failure.message : undefined, + "OpenCode is waiting on a request T3 Code couldn't answer.", + ); + // Let the best-effort interrupt reach the server before the scope closes. + for (let i = 0; i < 20; i++) yield* Effect.yieldNow; + }).pipe(Effect.scoped), + ); + it.effect("answers a question form instead of cancelling it", () => Effect.gen(function* () { const { runtime, thread } = yield* resumed([ diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts index 881a3efb74bb..d105c610debb 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts @@ -31,19 +31,16 @@ import type { ProviderInstanceId, } from "@t3tools/contracts"; import type * as Cause from "effect/Cause"; -import * as P from "effect/Predicate"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; import { ServerConfig } from "../../config.ts"; import type { OpenCode2StreamEvent } from "../../provider/opencode2/OpenCode2Client.ts"; -import { - OpenCode2Server, - type OpenCode2Connection, -} from "../../provider/opencode2/OpenCode2Server.ts"; +import * as OpenCode2Server from "../../provider/opencode2/OpenCode2Server.ts"; import { parseOpenCodeModelSlug, type OpenCodeRuntimeError, @@ -52,7 +49,7 @@ import { buildRuntimeInstructions } from "../../provider/RuntimeInstructions.ts" import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { providerMessageTextWithAttachmentPaths } from "../AttachmentPrompt.ts"; -import { IdAllocatorV2 } from "../IdAllocator.ts"; +import * as IdAllocator from "../IdAllocator.ts"; import { makeProviderFailure } from "../ProviderFailure.ts"; import { ProviderAdapterEnsureThreadError, @@ -196,7 +193,7 @@ interface ThreadState { readonly providerTurns: Map; active: ActiveTurn | undefined; /** What the native session runs now, so a changed selection is switched before prompting. */ - model: ReturnType; + model: ModelRef | undefined; } const protocolError = (detail: string) => @@ -235,36 +232,58 @@ export const OPENCODE_2_FULL_ACCESS_ONLY = const QUESTION_REPLY = "Questions aren't supported by this OpenCode integration yet."; const INTERRUPT_TIMEOUT = "10 seconds"; +const REQUEST_REPLY_TIMEOUT = "10 seconds"; + +/** Whether an answer to a paused request reached the server, trying twice. */ +const deliver = (answer: Effect.Effect) => + answer.pipe( + Effect.retry({ times: 1 }), + Effect.timeout(REQUEST_REPLY_TIMEOUT), + Effect.exit, + Effect.map(Exit.isSuccess), + ); + +type ModelRef = ReturnType; -/** The model OpenCode should run for a `provider/model` slug and its reasoning variant. */ +/** + * The model OpenCode should run for a `provider/model` slug and its reasoning + * variant. Any other slug fails: sending none would run OpenCode's default + * while T3 records the requested model. + */ const modelRef = (selection: ProviderAdapterV2TurnInput["modelSelection"]) => { const parsed = parseOpenCodeModelSlug(selection.model); - if (parsed === null) return undefined; + if (parsed === null) { + return Effect.fail( + protocolError(`OpenCode model '${selection.model}' must use provider/model format`), + ); + } const variant = getModelSelectionStringOptionValue(selection, "variant"); - return Model.Ref.make({ - providerID: Provider.ID.make(parsed.providerID), - id: Model.ID.make(parsed.modelID), - ...(variant === undefined ? {} : { variant: Model.VariantID.make(variant) }), - }); + return Effect.succeed( + Model.Ref.make({ + providerID: Provider.ID.make(parsed.providerID), + id: Model.ID.make(parsed.modelID), + ...(variant === undefined ? {} : { variant: Model.VariantID.make(variant) }), + }), + ); }; -const sameModel = (left: ReturnType, right: ReturnType) => - left?.providerID === right?.providerID && - left?.id === right?.id && - (left?.variant ?? "default") === (right?.variant ?? "default"); +const sameModel = (left: ModelRef, right: ModelRef | undefined) => + left.providerID === right?.providerID && + left.id === right?.id && + (left.variant ?? "default") === (right?.variant ?? "default"); /** * The adapter for one provider instance. It talks to the instance's - * {@link OpenCode2Server}, which the driver builds from the instance's settings. + * {@link OpenCode2Server.OpenCode2Server}, which the driver builds from the instance's settings. */ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: ProviderInstanceId) { - const server = yield* OpenCode2Server; - const idAllocator = yield* IdAllocatorV2; + const server = yield* OpenCode2Server.OpenCode2Server; + const idAllocator = yield* IdAllocator.IdAllocatorV2; const serverConfig = yield* ServerConfig; const driver = OPENCODE_PROVIDER; const openSession = Effect.fn("OpenCode2Adapter.openSession")(function* ( input: Parameters[0], - connection: OpenCode2Connection, + connection: OpenCode2Server.OpenCode2Connection, ) { const { client } = connection; const now = yield* DateTime.now; @@ -529,31 +548,55 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr ); }); - // Answers a permission or form T3 cannot show yet, so a session never waits on it. - const refuseRequest = (event: EventOf<"permission.asked"> | EventOf<"form.created">) => { - const refusal = - event.type === "permission.asked" - ? client.permission - .reply({ - sessionID: event.data.sessionID, - requestID: event.data.id, - decision: "reject", - message: "T3 Code cannot answer this request for OpenCode 2 yet.", - }) - .pipe(Effect.ignore({ log: true })) - : client.session.form - .reply({ - sessionID: event.data.form.sessionID, - formID: event.data.form.id, - answer: Object.fromEntries( - event.data.form.fields.map((field) => [field.key, QUESTION_REPLY]), - ), - }) - .pipe(Effect.ignore({ log: true })); - return Effect.logWarning("Answered an OpenCode request this runtime cannot show yet.", { + /** + * Answers a permission or form T3 cannot show yet, so a session never waits + * on it. An answer that cannot be delivered leaves OpenCode paused, so the + * turn it would block ends as failed and the session is stopped instead. + */ + const refuseRequest = Effect.fnUntraced(function* ( + event: EventOf<"permission.asked"> | EventOf<"form.created">, + ) { + const sessionId = + event.type === "permission.asked" ? event.data.sessionID : event.data.form.sessionID; + yield* Effect.logWarning("Answered an OpenCode request this runtime cannot show yet.", { type: event.type, - }).pipe(Effect.andThen(refusal)); - }; + }); + const delivered = yield* event.type === "permission.asked" + ? deliver( + client.permission.reply({ + sessionID: event.data.sessionID, + requestID: event.data.id, + decision: "reject", + message: "T3 Code cannot answer this request for OpenCode 2 yet.", + }), + ) + : deliver( + client.session.form.reply({ + sessionID: event.data.form.sessionID, + formID: event.data.form.id, + answer: Object.fromEntries( + event.data.form.fields.map((field) => [field.key, QUESTION_REPLY]), + ), + }), + ); + if (delivered) return; + yield* Effect.logWarning("Could not answer an OpenCode request; ending its turn.", { + type: event.type, + }); + const state = threads.get(sessionId); + if (state !== undefined) { + yield* finishTurn(state, { + status: "failed", + failure: makeProviderFailure({ + message: "OpenCode is waiting on a request T3 Code couldn't answer.", + class: "provider_error", + }), + }); + } + yield* client.session + .interrupt({ sessionID: Session.ID.make(sessionId) }) + .pipe(Effect.timeout("2 seconds"), Effect.ignore({ log: true })); + }); const handleEvent = Effect.fnUntraced(function* (event: OpenCode2StreamEvent) { if (event.type === "unreadable.execution.ended") { @@ -683,7 +726,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr const register = ( providerThread: OrchestrationV2ProviderThread, sessionId: string, - model: ReturnType, + model: ModelRef | undefined, ) => { const existing = threads.get(sessionId); if (existing !== undefined) { @@ -728,12 +771,12 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr providerThread: threadInput.existingProviderThread, }); } - const model = modelRef(threadInput.modelSelection); + const model = yield* modelRef(threadInput.modelSelection); const created = yield* client.session.create({ location: Location.PublicRef.make({ directory: AbsolutePath.make(threadInput.runtimePolicy.cwd ?? serverConfig.cwd), }), - ...(model === undefined ? {} : { model }), + model, permissions: SESSION_PERMISSIONS, }); const createdAt = yield* DateTime.now; @@ -818,8 +861,8 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr } // A selection changed since the last turn applies now; OpenCode keeps // the session's model otherwise. - const model = modelRef(turnInput.modelSelection); - if (model !== undefined && !sameModel(model, state.model)) { + const model = yield* modelRef(turnInput.modelSelection); + if (!sameModel(model, state.model)) { yield* client.session.switchModel({ sessionID: Session.ID.make(sessionId), model }); state.model = model; } @@ -864,11 +907,10 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr yield* client.session .prompt({ sessionID: Session.ID.make(sessionId), text: prompt(turnInput) }) .pipe( - Effect.catchIf( - (cause) => P.isTagged(cause, "SessionNotFoundError"), - // Deleted outside T3: the thread is broken, and forgetting it makes - // the next turn resume, fail, and recreate it with a handoff. - () => + // Deleted outside T3: the thread is broken, and forgetting it makes + // the next turn resume, fail, and recreate it with a handoff. + Effect.catchTags({ + SessionNotFoundError: () => finishTurn( state, { @@ -881,7 +923,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr }, "broken", ).pipe(Effect.andThen(Effect.sync(() => threads.delete(sessionId)))), - ), + }), Effect.tapError((cause) => state.active === turn ? finishTurn(state, { @@ -1016,7 +1058,10 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr // spawned server is not idle-stopped under a long tool call. openSession: (input) => Effect.gen(function* () { - const lent = yield* Deferred.make(); + const lent = yield* Deferred.make< + OpenCode2Server.OpenCode2Connection, + OpenCodeRuntimeError + >(); yield* server .withConnection((connection) => Deferred.succeed(lent, connection).pipe(Effect.andThen(Effect.never)), From 8f2d00d63b3ffd926024a431bad7a5dc6a213c9c Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:51:45 -0700 Subject: [PATCH 5/9] fix(server): refuse OpenCode 2 turns once the event stream is gone A lost stream settled the turns that existed and ended the runtime's events, but a turn started after that still installed itself and sent `session.prompt`, with nothing left to deliver its end. The runtime now records the lost stream before settling, and a turn start after it fails with an event stream error so the orchestrator opens a new session. Protocol errors are built at each failure site; `notYet` keeps the one wording for capabilities later layers add. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Adapters/OpenCode2AdapterV2.test.ts | 17 ++++++ .../Adapters/OpenCode2AdapterV2.ts | 55 +++++++++++++++---- 2 files changed, 61 insertions(+), 11 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts index 02887288a881..e51ce242cb29 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -547,6 +547,23 @@ describe("OpenCode2 adapter", () => { }).pipe(Effect.scoped), ); + it.effect("refuses a turn after the event stream is gone instead of prompting", () => + Effect.gen(function* () { + // The stream ends with no turn running; no prompt is expected after it. + const { runtime, thread } = yield* resumed([{ type: "runtime_exit", status: "success" }]); + // The runtime ends its own events once the lost stream is settled. + yield* runtime.events.pipe(Stream.runDrain); + const refused = yield* runtime + .startTurn(turnInput(thread)) + .pipe(Effect.flip, Effect.timeout("5 seconds")); + assert.equal(refused._tag, "ProviderAdapterTurnStartError"); + assert.equal( + (refused.cause as { readonly _tag?: string } | undefined)?._tag, + "ProviderAdapterEventStreamError", + ); + }).pipe(Effect.scoped), + ); + it.effect("reports the session as failed after a lost stream settles its turns", () => Effect.gen(function* () { const { runtime, thread } = yield* resumed([ diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts index d105c610debb..65e3e76dbf62 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts @@ -53,6 +53,7 @@ import * as IdAllocator from "../IdAllocator.ts"; import { makeProviderFailure } from "../ProviderFailure.ts"; import { ProviderAdapterEnsureThreadError, + ProviderAdapterEventStreamError, ProviderAdapterForkThreadError, ProviderAdapterInterruptError, ProviderAdapterOpenSessionError, @@ -196,9 +197,12 @@ interface ThreadState { model: ModelRef | undefined; } -const protocolError = (detail: string) => - new ProviderAdapterProtocolError({ driver: OPENCODE_PROVIDER, detail }); -const notYet = (feature: string) => protocolError(`OpenCode 2 ${feature} is not supported yet`); +/** One wording for every capability later layers add. */ +const notYet = (feature: string) => + new ProviderAdapterProtocolError({ + driver: OPENCODE_PROVIDER, + detail: `OpenCode 2 ${feature} is not supported yet`, + }); const ref = (nativeId: string, strength: "strong" | "weak" = "strong") => ({ driver: OPENCODE_PROVIDER, @@ -209,7 +213,12 @@ const ref = (nativeId: string, strength: "strong" | "weak" = "strong") => ({ const sessionIdOf = (providerThread: OrchestrationV2ProviderThread) => { const nativeId = providerThread.nativeThreadRef?.nativeId; return nativeId === undefined || nativeId === null - ? Effect.fail(protocolError(`Provider thread ${providerThread.id} has no OpenCode session`)) + ? Effect.fail( + new ProviderAdapterProtocolError({ + driver: OPENCODE_PROVIDER, + detail: `Provider thread ${providerThread.id} has no OpenCode session`, + }), + ) : Effect.succeed(nativeId); }; @@ -254,7 +263,10 @@ const modelRef = (selection: ProviderAdapterV2TurnInput["modelSelection"]) => { const parsed = parseOpenCodeModelSlug(selection.model); if (parsed === null) { return Effect.fail( - protocolError(`OpenCode model '${selection.model}' must use provider/model format`), + new ProviderAdapterProtocolError({ + driver: OPENCODE_PROVIDER, + detail: `OpenCode model '${selection.model}' must use provider/model format`, + }), ); } const variant = getModelSelectionStringOptionValue(selection, "variant"); @@ -689,7 +701,10 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr // The stream is the only terminal signal, so a lost stream settles every // running turn and breaks the session: T3 reopens it for the next turn. + // Set before the turns are settled, so a turn starting meanwhile sees it. + let streamFailure: string | undefined; const failAll = Effect.fnUntraced(function* (message: string) { + streamFailure = message; for (const state of threads.values()) { const failure = makeProviderFailure({ message, class: "transport_error" }); yield* finishTurn(state, { status: "failed", failure }, "broken"); @@ -849,15 +864,24 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr Effect.gen(function* () { // Sessions allow every tool, so any other mode would silently run as Full access. if (turnInput.runtimePolicy.runtimeMode !== "full-access") { - return yield* protocolError(OPENCODE_2_FULL_ACCESS_ONLY); + return yield* new ProviderAdapterProtocolError({ + driver: OPENCODE_PROVIDER, + detail: OPENCODE_2_FULL_ACCESS_ONLY, + }); } const sessionId = yield* sessionIdOf(turnInput.providerThread); const state = threads.get(sessionId); if (state === undefined) { - return yield* protocolError(`OpenCode session ${sessionId} is not registered`); + return yield* new ProviderAdapterProtocolError({ + driver: OPENCODE_PROVIDER, + detail: `OpenCode session ${sessionId} is not registered`, + }); } if (state.active !== undefined) { - return yield* protocolError(`OpenCode session ${sessionId} already has an active turn`); + return yield* new ProviderAdapterProtocolError({ + driver: OPENCODE_PROVIDER, + detail: `OpenCode session ${sessionId} already has an active turn`, + }); } // A selection changed since the last turn applies now; OpenCode keeps // the session's model otherwise. @@ -889,6 +913,14 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr nextOrdinal: turnInput.providerTurnOrdinal * 100 + 1, interrupted: false, }; + // No stream is left to end this turn, so it must not start. + if (streamFailure !== undefined) { + return yield* new ProviderAdapterEventStreamError({ + driver, + providerSessionId: input.providerSessionId, + cause: streamFailure, + }); + } state.active = turn; yield* emitProviderTurn(state, turn, providerTurn); state.providerThread = { @@ -986,9 +1018,10 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr // is on the way), the turn is still open and nothing stopped. if (!reply.value.interrupted && state.active === turn) { turn.interrupted = false; - return yield* protocolError( - `OpenCode session ${sessionId} had nothing running to stop`, - ); + return yield* new ProviderAdapterProtocolError({ + driver: OPENCODE_PROVIDER, + detail: `OpenCode session ${sessionId} had nothing running to stop`, + }); } }).pipe( Effect.mapError( From 53905927a84a65ab48c3e0970753d36d7050e214 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:21:46 -0700 Subject: [PATCH 6/9] fix(server): keep OpenCode 2 refusals readable and tags intact Refusals are turns: a Full access or malformed model refusal starts the turn and fails it as a validation error with its own text, so the run shows why it did not run instead of a generic start failure. startTurn, ensureThread and interruptTurn now pass adapter errors through and wrap only lower-level ones. ensureThread hands its runtime policy to resumeThread, and the session advertises in-session model switching, which it already did through switchModel. Orchestrator replay tests prove a model change and a worktree change reach OpenCode through the real flow, and that the refusal text reaches the run. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Adapters/OpenCode2AdapterV2.test.ts | 64 ++-- .../Adapters/OpenCode2AdapterV2.ts | 218 ++++++++------ ...penCode2OrchestratorV2.integration.test.ts | 275 ++++++++++++++++++ .../OpenCode2OrchestratorV2.live.test.ts | 10 + 4 files changed, 445 insertions(+), 122 deletions(-) create mode 100644 apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts index e51ce242cb29..cafd4caff632 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -191,14 +191,15 @@ describe("OpenCode2 adapter", () => { "refuses a turn outside Full access instead of running it with every tool allowed", () => Effect.gen(function* () { + // No prompt is expected: the turn fails without reaching the server. const { runtime, thread } = yield* resumed([]); - const error = yield* runtime - .startTurn(turnInput(thread, bigPickle, "approval-required")) - .pipe(Effect.flip); - assert.equal( - (error.cause as { readonly detail?: string } | undefined)?.detail, - OPENCODE_2_FULL_ACCESS_ONLY, - ); + const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped); + yield* runtime.startTurn(turnInput(thread, bigPickle, "approval-required")); + const refused = yield* Fiber.join(terminal); + assert.deepInclude(refused?.failure, { + class: "validation_error", + message: OPENCODE_2_FULL_ACCESS_ONLY, + }); }).pipe(Effect.scoped), ); @@ -348,7 +349,7 @@ describe("OpenCode2 adapter", () => { providerTurnId: yield* providerTurnId, }) .pipe(Effect.flip); - assert.equal(failed._tag, "ProviderAdapterInterruptError"); + assert.equal(failed._tag, "ProviderAdapterProtocolError"); }).pipe(Effect.scoped), ); @@ -392,6 +393,26 @@ describe("OpenCode2 adapter", () => { }).pipe(Effect.scoped), ); + it.effect( + "moves the session when a thread it resumes through ensureThread changed worktree", + () => + Effect.gen(function* () { + const runtime = yield* openCode2ReplayRuntime([ + out("event.subscribe"), + out("session.get", { sessionID: SESSION }), + replyData("session.get", sessionInfo()), + out("session.move", { sessionID: SESSION, directory: "/work/opencode2-feature" }), + reply("session.move", null), + ]); + yield* runtime.ensureThread({ + threadId, + modelSelection: bigPickle, + runtimePolicy: { ...policy(), cwd: "/work/opencode2-feature" }, + existingProviderThread: providerThread(yield* DateTime.now), + }); + }).pipe(Effect.scoped), + ); + it.effect("breaks the thread and forgets it when the session was deleted outside T3", () => Effect.gen(function* () { const { runtime, thread } = yield* resumed([ @@ -412,7 +433,8 @@ describe("OpenCode2 adapter", () => { assert.equal(ended?.threadDisposition, "broken"); // The next turn must resume (and fail into a handoff), not reuse the dead session. const again = yield* runtime.startTurn(turnInput(thread)).pipe(Effect.flip); - assert.include(String((again.cause as { detail?: string }).detail), "not registered"); + assert.equal(again._tag, "ProviderAdapterProtocolError"); + assert.include(again.message, "not registered"); }).pipe(Effect.scoped), ); @@ -427,23 +449,19 @@ describe("OpenCode2 adapter", () => { runtimePolicy: policy(), }) .pipe(Effect.flip); - assert.include( - String((created.cause as { detail?: string } | undefined)?.detail), - "OpenCode model 'big-pickle' must use provider/model format", - ); + assert.equal(created._tag, "ProviderAdapterProtocolError"); + assert.include(created.message, "OpenCode model 'big-pickle' must use provider/model format"); }).pipe(Effect.scoped), ); it.effect("refuses a turn whose model slug is not provider/model before prompting", () => Effect.gen(function* () { const { runtime, thread } = yield* resumed([]); - const refused = yield* runtime - .startTurn(turnInput(thread, { instanceId, model: "big-pickle" })) - .pipe(Effect.flip); - assert.include( - String((refused.cause as { detail?: string } | undefined)?.detail), - "must use provider/model format", - ); + const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped); + yield* runtime.startTurn(turnInput(thread, { instanceId, model: "big-pickle" })); + const refused = yield* Fiber.join(terminal); + assert.equal(refused?.failure?.class, "validation_error"); + assert.include(refused?.failure?.message, "must use provider/model format"); }).pipe(Effect.scoped), ); @@ -556,11 +574,7 @@ describe("OpenCode2 adapter", () => { const refused = yield* runtime .startTurn(turnInput(thread)) .pipe(Effect.flip, Effect.timeout("5 seconds")); - assert.equal(refused._tag, "ProviderAdapterTurnStartError"); - assert.equal( - (refused.cause as { readonly _tag?: string } | undefined)?._tag, - "ProviderAdapterEventStreamError", - ); + assert.equal(refused._tag, "ProviderAdapterEventStreamError"); }).pipe(Effect.scoped), ); diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts index 65e3e76dbf62..d3e3bdfd1adc 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts @@ -36,6 +36,7 @@ import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { ServerConfig } from "../../config.ts"; @@ -65,6 +66,7 @@ import { ProviderAdapterSteerRunUnsupportedError, ProviderAdapterTurnStartError, ProviderAdapterV2, + ProviderAdapterV2Error, type ProviderAdapterV2Event, type ProviderAdapterV2SessionRuntime, type ProviderAdapterV2Shape, @@ -78,7 +80,7 @@ const OpenCode2ProviderCapabilities = { sessions: { // One server serves every location, so one session runtime owns them all. supportsMultipleProviderThreadsPerSession: true, - supportsModelSwitchInSession: false, + supportsModelSwitchInSession: true, supportsProviderSwitchingViaHandoff: true, supportsRuntimeModeSwitchInSession: false, pendingRequestsSurviveRestart: false, @@ -254,30 +256,26 @@ const deliver = (answer: Effect.Effect) => type ModelRef = ReturnType; +// Errors already in the adapter channel keep their tag; only lower-level ones are wrapped. +const isProviderAdapterError = Schema.is(ProviderAdapterV2Error); + /** * The model OpenCode should run for a `provider/model` slug and its reasoning - * variant. Any other slug fails: sending none would run OpenCode's default - * while T3 records the requested model. + * variant, or undefined for any other slug: sending none would run OpenCode's + * default while T3 records the requested model. */ const modelRef = (selection: ProviderAdapterV2TurnInput["modelSelection"]) => { const parsed = parseOpenCodeModelSlug(selection.model); - if (parsed === null) { - return Effect.fail( - new ProviderAdapterProtocolError({ - driver: OPENCODE_PROVIDER, - detail: `OpenCode model '${selection.model}' must use provider/model format`, - }), - ); - } + if (parsed === null) return undefined; const variant = getModelSelectionStringOptionValue(selection, "variant"); - return Effect.succeed( - Model.Ref.make({ - providerID: Provider.ID.make(parsed.providerID), - id: Model.ID.make(parsed.modelID), - ...(variant === undefined ? {} : { variant: Model.VariantID.make(variant) }), - }), - ); + return Model.Ref.make({ + providerID: Provider.ID.make(parsed.providerID), + id: Model.ID.make(parsed.modelID), + ...(variant === undefined ? {} : { variant: Model.VariantID.make(variant) }), + }); }; +const malformedModel = (model: string) => + `OpenCode model '${model}' must use provider/model format`; const sameModel = (left: ModelRef, right: ModelRef | undefined) => left.providerID === right?.providerID && left.id === right?.id && @@ -784,9 +782,18 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr if (threadInput.existingProviderThread?.nativeThreadRef != null) { return yield* runtime.resumeThread({ providerThread: threadInput.existingProviderThread, + threadId: threadInput.threadId, + modelSelection: threadInput.modelSelection, + runtimePolicy: threadInput.runtimePolicy, + }); + } + const model = modelRef(threadInput.modelSelection); + if (model === undefined) { + return yield* new ProviderAdapterProtocolError({ + driver: OPENCODE_PROVIDER, + detail: malformedModel(threadInput.modelSelection.model), }); } - const model = yield* modelRef(threadInput.modelSelection); const created = yield* client.session.create({ location: Location.PublicRef.make({ directory: AbsolutePath.make(threadInput.runtimePolicy.cwd ?? serverConfig.cwd), @@ -816,13 +823,14 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr }; return register(providerThread, created.id, created.model); }).pipe( - Effect.mapError( - (cause) => - new ProviderAdapterEnsureThreadError({ - driver, - threadId: threadInput.threadId, - cause, - }), + Effect.mapError((cause) => + isProviderAdapterError(cause) + ? cause + : new ProviderAdapterEnsureThreadError({ + driver, + threadId: threadInput.threadId, + cause, + }), ), ), resumeThread: (threadInput) => @@ -862,13 +870,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr ), startTurn: (turnInput) => Effect.gen(function* () { - // Sessions allow every tool, so any other mode would silently run as Full access. - if (turnInput.runtimePolicy.runtimeMode !== "full-access") { - return yield* new ProviderAdapterProtocolError({ - driver: OPENCODE_PROVIDER, - detail: OPENCODE_2_FULL_ACCESS_ONLY, - }); - } const sessionId = yield* sessionIdOf(turnInput.providerThread); const state = threads.get(sessionId); if (state === undefined) { @@ -883,59 +884,80 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr detail: `OpenCode session ${sessionId} already has an active turn`, }); } + // Installs the turn; every path after it ends the turn with a terminal. + const begin = Effect.gen(function* () { + const startedAt = yield* DateTime.now; + const nativeTurnId = `${sessionId}:attempt:${turnInput.attemptId}`; + const providerTurn: OrchestrationV2ProviderTurn = { + id: idAllocator.derive.providerTurn({ driver, nativeTurnId }), + providerThreadId: turnInput.providerThread.id, + nodeId: turnInput.rootNodeId, + runAttemptId: turnInput.attemptId, + nativeTurnRef: ref(nativeTurnId, "weak"), + ordinal: turnInput.providerTurnOrdinal, + status: "running", + startedAt, + completedAt: null, + }; + const turn: ActiveTurn = { + input: turnInput, + providerTurn, + texts: new Map(), + tools: new Map(), + startedAt: new Map(), + ordinals: new Map(), + nextOrdinal: turnInput.providerTurnOrdinal * 100 + 1, + interrupted: false, + }; + // No stream is left to end this turn, so it must not start. + if (streamFailure !== undefined) { + return yield* new ProviderAdapterEventStreamError({ + driver, + providerSessionId: input.providerSessionId, + cause: streamFailure, + }); + } + state.active = turn; + yield* emitProviderTurn(state, turn, providerTurn); + state.providerThread = { + ...state.providerThread, + status: "active", + firstRunOrdinal: state.providerThread.firstRunOrdinal ?? turnInput.runOrdinal, + lastRunOrdinal: turnInput.runOrdinal, + updatedAt: startedAt, + }; + yield* emit({ + type: "provider_thread.updated", + driver, + providerThread: state.providerThread, + }); + yield* setSessionStatus("running", null); + return turn; + }); + // A turn T3 will not run still starts and fails, so the refusal is what + // the user reads. Sessions allow every tool, so any other mode would + // silently run as Full access. + const model = modelRef(turnInput.modelSelection); + if (turnInput.runtimePolicy.runtimeMode !== "full-access" || model === undefined) { + yield* begin; + return yield* finishTurn(state, { + status: "failed", + failure: makeProviderFailure({ + message: + turnInput.runtimePolicy.runtimeMode !== "full-access" + ? OPENCODE_2_FULL_ACCESS_ONLY + : malformedModel(turnInput.modelSelection.model), + class: "validation_error", + }), + }); + } // A selection changed since the last turn applies now; OpenCode keeps // the session's model otherwise. - const model = yield* modelRef(turnInput.modelSelection); if (!sameModel(model, state.model)) { yield* client.session.switchModel({ sessionID: Session.ID.make(sessionId), model }); state.model = model; } - const startedAt = yield* DateTime.now; - const nativeTurnId = `${sessionId}:attempt:${turnInput.attemptId}`; - const providerTurn: OrchestrationV2ProviderTurn = { - id: idAllocator.derive.providerTurn({ driver, nativeTurnId }), - providerThreadId: turnInput.providerThread.id, - nodeId: turnInput.rootNodeId, - runAttemptId: turnInput.attemptId, - nativeTurnRef: ref(nativeTurnId, "weak"), - ordinal: turnInput.providerTurnOrdinal, - status: "running", - startedAt, - completedAt: null, - }; - const turn: ActiveTurn = { - input: turnInput, - providerTurn, - texts: new Map(), - tools: new Map(), - startedAt: new Map(), - ordinals: new Map(), - nextOrdinal: turnInput.providerTurnOrdinal * 100 + 1, - interrupted: false, - }; - // No stream is left to end this turn, so it must not start. - if (streamFailure !== undefined) { - return yield* new ProviderAdapterEventStreamError({ - driver, - providerSessionId: input.providerSessionId, - cause: streamFailure, - }); - } - state.active = turn; - yield* emitProviderTurn(state, turn, providerTurn); - state.providerThread = { - ...state.providerThread, - status: "active", - firstRunOrdinal: state.providerThread.firstRunOrdinal ?? turnInput.runOrdinal, - lastRunOrdinal: turnInput.runOrdinal, - updatedAt: startedAt, - }; - yield* emit({ - type: "provider_thread.updated", - driver, - providerThread: state.providerThread, - }); - yield* setSessionStatus("running", null); + const turn = yield* begin; yield* client.session .prompt({ sessionID: Session.ID.make(sessionId), text: prompt(turnInput) }) .pipe( @@ -966,15 +988,16 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr ), ); }).pipe( - Effect.mapError( - (cause) => - new ProviderAdapterTurnStartError({ - driver, - threadId: turnInput.threadId, - providerThreadId: turnInput.providerThread.id, - runId: turnInput.runId, - cause, - }), + Effect.mapError((cause) => + isProviderAdapterError(cause) + ? cause + : new ProviderAdapterTurnStartError({ + driver, + threadId: turnInput.threadId, + providerThreadId: turnInput.providerThread.id, + runId: turnInput.runId, + cause, + }), ), ), steerTurn: (steerInput) => @@ -1024,14 +1047,15 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr }); } }).pipe( - Effect.mapError( - (cause) => - new ProviderAdapterInterruptError({ - driver, - providerThreadId: interruptInput.providerThread.id, - providerTurnId: interruptInput.providerTurnId, - cause, - }), + Effect.mapError((cause) => + isProviderAdapterError(cause) + ? cause + : new ProviderAdapterInterruptError({ + driver, + providerThreadId: interruptInput.providerThread.id, + providerTurnId: interruptInput.providerTurnId, + cause, + }), ), ), unloadThread: ({ providerThread }) => diff --git a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts new file mode 100644 index 000000000000..76609e91b957 --- /dev/null +++ b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts @@ -0,0 +1,275 @@ +/** + * OpenCode 2 through the whole orchestrator, against a replayed HTTP server: + * the transcript fixes the order of every request the adapter sends, so a + * request the orchestrator never lets it make fails the run. + */ +import { assert, describe, it } from "@effect/vitest"; +import { + CommandId, + MessageId, + type ModelSelection, + type OrchestrationV2Command, + ProjectId, + ProviderInstanceId, + type ProviderReplayEntry, + type RuntimeMode, + ThreadId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +import { OPENCODE_2_FULL_ACCESS_ONLY } from "./Adapters/OpenCode2AdapterV2.ts"; +import { + OPENCODE2_HTTP_PROTOCOL, + OpenCode2OrchestratorReplayHarness, +} from "./Adapters/OpenCode2AdapterV2.testkit.ts"; +import { OPENCODE_PROVIDER } from "./Adapters/OpenCodeAdapterV2.ts"; +import { provideDeterministicTestRuntime } from "./testkit/DeterministicRuntime.ts"; +import type { OrchestratorV2ScenarioStep } from "./testkit/OrchestratorScenario.ts"; +import { runOrchestratorV2ProviderReplayScenario } from "./testkit/ProviderReplayHarness.ts"; +import { checkpointWorkspace } from "./testkit/ReplayFixtureWorkspace.ts"; + +const SESSION = "ses_f148ca2deffeJcwCnRQtb0YFNX"; +const instanceId = ProviderInstanceId.make("opencode"); +const bigPickle: ModelSelection = { instanceId, model: "opencode/big-pickle" }; +const mimo: ModelSelection = { instanceId, model: "opencode/mimo-v2.6-flash-free" }; + +const out = (type: string, input?: unknown): ProviderReplayEntry => ({ + type: "expect_outbound", + frame: input === undefined ? { type } : { type, input }, +}); +const reply = (operation: string, data: unknown): ProviderReplayEntry => ({ + type: "emit_inbound", + frame: { type: "sdk.response", operation, data }, +}); +const event = (type: string, data: Record): ProviderReplayEntry => ({ + type: "emit_inbound", + frame: { + type: "sdk.event", + event: { id: `evt_${type.replaceAll(".", "")}`, created: 1, type, data }, + }, +}); +const sessionInfo = (directory: string) => ({ + data: { + id: SESSION, + projectID: "global", + model: { id: "big-pickle", providerID: "opencode", variant: "default" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1790656601394, updated: 1790656601394 }, + location: { directory }, + }, +}); +/** One prompt the server accepts and answers with `text`. */ +const answeredPrompt = (text: string): ReadonlyArray => [ + out("session.prompt", { sessionID: SESSION, text: "" }), + reply("session.prompt", { + data: { + id: `msg_user_${text}`, + sessionID: SESSION, + time: { created: 1790656601410 }, + type: "user", + payload: { text: "" }, + delivery: "steer", + }, + }), + event("session.text.ended", { + sessionID: SESSION, + assistantMessageID: `msg_assistant_${text}`, + ordinal: 0, + text, + }), + event("session.execution.succeeded", { sessionID: SESSION }), +]; +const createdSession = (directory: string): ReadonlyArray => [ + out("event.subscribe"), + out("session.create", ""), + reply("session.create", sessionInfo(directory)), +]; + +const threadCommands = (input: { + readonly name: string; + readonly worktreePath: string; + readonly runtimeMode?: RuntimeMode; +}) => { + const threadId = ThreadId.make(`thread:${input.name}`); + const command = (key: string) => CommandId.make(`command:${input.name}:${key}`); + return { + threadId, + create: { + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: command("create"), + threadId, + projectId: ProjectId.make(`project:${input.name}`), + title: input.name, + modelSelection: bigPickle, + runtimeMode: input.runtimeMode ?? "full-access", + interactionMode: "default", + branch: null, + worktreePath: input.worktreePath, + } satisfies OrchestrationV2Command, + message: (key: string, modelSelection: ModelSelection = bigPickle) => + ({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: command(key), + threadId, + messageId: MessageId.make(`message:${input.name}:${key}`), + text: `Reply with exactly: ${key}`, + attachments: [], + modelSelection, + dispatchMode: { type: "start_immediately" }, + }) satisfies OrchestrationV2Command, + command, + }; +}; + +/** Runs `commands` in order, letting the thread go idle after each message. */ +const runScenario = (input: { + readonly name: string; + readonly threadId: ThreadId; + readonly entries: ReadonlyArray; + readonly commands: ReadonlyArray; +}) => + Effect.gen(function* () { + const transcript = yield* OpenCode2OrchestratorReplayHarness.decodeTranscript({ + provider: OPENCODE_PROVIDER, + protocol: OPENCODE2_HTTP_PROTOCOL, + version: "2.0.18", + scenario: input.name, + entries: input.entries, + }); + const steps = input.commands.flatMap((command): Array => [ + { type: "dispatch", command }, + { type: "advance_clock", duration: "1 millis" }, + ...(command.type === "message.dispatch" + ? [{ type: "await_thread_idle" as const, threadId: input.threadId }] + : []), + ]); + const result = yield* runOrchestratorV2ProviderReplayScenario( + { name: input.name, transcript, commands: input.commands, steps }, + OpenCode2OrchestratorReplayHarness, + ).pipe(provideDeterministicTestRuntime); + const projection = result.projections.get(input.threadId); + assert.isDefined(projection); + return projection; + }); + +describe("OpenCode 2 through the orchestrator", () => { + for (const via of ["message", "thread settings"] as const) { + it.effect( + `switches the session's model before the next prompt when changed from the ${via}`, + () => + Effect.gen(function* () { + const name = `opencode2-model-switch-${via.replace(" ", "-")}`; + const cwd = yield* checkpointWorkspace(name); + const thread = threadCommands({ name, worktreePath: cwd }); + const projection = yield* runScenario({ + name, + threadId: thread.threadId, + entries: [ + ...createdSession(cwd), + ...answeredPrompt("FIRST"), + // The next turn resumes the session at its new selection. + out("session.get", { sessionID: SESSION }), + reply("session.get", sessionInfo(cwd)), + out("session.switchModel", { + sessionID: SESSION, + model: { providerID: "opencode", id: "mimo-v2.6-flash-free" }, + }), + reply("session.switchModel", null), + ...answeredPrompt("SECOND"), + ], + commands: [ + thread.create, + thread.message("first"), + ...(via === "thread settings" + ? [ + { + type: "thread.model-selection.set", + commandId: thread.command("model"), + threadId: thread.threadId, + modelSelection: mimo, + } satisfies OrchestrationV2Command, + ] + : []), + thread.message("second", mimo), + ], + }); + assert.deepEqual( + projection.runs.map((run) => [run.status, run.modelSelection.model]), + [ + ["completed", bigPickle.model], + ["completed", mimo.model], + ], + ); + // One native session carried both turns. + assert.lengthOf(projection.providerThreads, 1); + }).pipe(Effect.scoped), + ); + } + + it.effect("moves the session to the thread's new worktree before the next prompt", () => + Effect.gen(function* () { + const name = "opencode2-worktree-move"; + const before = yield* checkpointWorkspace(`${name}-before`); + const after = yield* checkpointWorkspace(`${name}-after`); + const thread = threadCommands({ name, worktreePath: before }); + const projection = yield* runScenario({ + name, + threadId: thread.threadId, + entries: [ + ...createdSession(before), + ...answeredPrompt("FIRST"), + out("session.get", { sessionID: SESSION }), + reply("session.get", sessionInfo(before)), + out("session.move", { sessionID: SESSION, directory: after }), + reply("session.move", null), + ...answeredPrompt("SECOND"), + ], + commands: [ + thread.create, + thread.message("first"), + { + type: "thread.metadata.update", + commandId: thread.command("worktree"), + threadId: thread.threadId, + worktreePath: after, + }, + thread.message("second"), + ], + }); + assert.deepEqual( + projection.runs.map((run) => run.status), + ["completed", "completed"], + ); + assert.lengthOf(projection.providerThreads, 1); + }).pipe(Effect.scoped), + ); + + it.effect("fails a turn outside Full access with the refusal, never prompting", () => + Effect.gen(function* () { + const name = "opencode2-supervised-refusal"; + const cwd = yield* checkpointWorkspace(name); + const thread = threadCommands({ name, worktreePath: cwd, runtimeMode: "approval-required" }); + const projection = yield* runScenario({ + name, + threadId: thread.threadId, + // The session is created, then the turn is refused: no prompt is expected. + entries: createdSession(cwd), + commands: [thread.create, thread.message("refused")], + }); + assert.deepEqual( + projection.runs.map((run) => run.status), + ["failed"], + ); + const failure = projection.turnItems.find((item) => item.type === "error"); + assert.equal( + failure?.type === "error" ? failure.failure.message : undefined, + OPENCODE_2_FULL_ACCESS_ONLY, + ); + }).pipe(Effect.scoped), + ); +}); diff --git a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts index 62cd36c0ce8e..190478b65dd6 100644 --- a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts +++ b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts @@ -16,6 +16,7 @@ import * as NodeSqlite from "node:sqlite"; import { assert, it } from "@effect/vitest"; import { CommandId, + EnvironmentId, MessageId, type ModelSelection, type OrchestrationV2ThreadProjection, @@ -39,6 +40,8 @@ import * as HostPowerMonitor from "../background/HostPowerMonitor.ts"; import { ServerConfig } from "../config.ts"; import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; import { AntigravityInstallation } from "../provider/AntigravityInstallation.ts"; +import { CodexInstallation } from "../provider/CodexInstallation.ts"; +import { ServerEnvironmentIdentity } from "../environment/ServerEnvironment.ts"; import * as ModelManifest from "../provider/ModelManifest.ts"; import { ProviderInstanceRegistryHydrationLive } from "../provider/Layers/ProviderInstanceRegistryHydration.ts"; import { @@ -119,6 +122,13 @@ const providerInstanceRegistryLayer = ProviderInstanceRegistryHydrationLive.pipe Layer.provide(FetchHttpClient.layer), Layer.provide(PlatformTestLayer), ), + // The Codex driver now resolves managed ChatGPT installs; these runs never launch Codex. + Layer.mock(CodexInstallation)({ managedDirectory: "unused-managed-installation" }), + Layer.succeed(ServerEnvironmentIdentity, { + getEnvironmentId: Effect.succeed( + EnvironmentId.make("00000000-0000-4000-8000-000000000001"), + ), + }), ), ), ); From ad0386a40e7aa6de1fe3dd6466e942965a3519bb Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:03:00 -0700 Subject: [PATCH 7/9] fix(server): keep a timed-out OpenCode 2 Stop from ending the next turn A Stop the server does not answer in 10 seconds ends the turn locally, but OpenCode may still be running it. Its execution events carry only the session id, so that run's late end could finish the user's next turn. The session is now held after such a Stop. The next execution end is taken as that run's and ends no turn. A turn started meanwhile asks the server whether the session is still running: if not, the turn starts and skips what comes before its own execution start; if it is, it is stopped again and the turn fails with a message to send it again. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Adapters/OpenCode2AdapterV2.test.ts | 107 +++++++++++++++++- .../Adapters/OpenCode2AdapterV2.testkit.ts | 1 + .../Adapters/OpenCode2AdapterV2.ts | 70 +++++++++++- 3 files changed, 176 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts index cafd4caff632..36d1467d279d 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -34,7 +34,7 @@ import type { } from "../ProviderAdapter.ts"; import { IdAllocatorV2, layer as idAllocatorLayer } from "../IdAllocator.ts"; import { OPENCODE_PROVIDER } from "./OpenCodeAdapterV2.ts"; -import { OPENCODE_2_FULL_ACCESS_ONLY } from "./OpenCode2AdapterV2.ts"; +import { OPENCODE_2_FULL_ACCESS_ONLY, OPENCODE_2_STILL_STOPPING } from "./OpenCode2AdapterV2.ts"; import { openCode2ReplayRuntime } from "./OpenCode2AdapterV2.testkit.ts"; const SESSION = "ses_f148ca2deffeJcwCnRQtb0YFNX"; @@ -334,6 +334,111 @@ describe("OpenCode2 adapter", () => { }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), ); + /** A prompt accepted, then a Stop the server never answers, advanced past its timeout. */ + const stopTimedOut: ReadonlyArray = [ + out("session.prompt", { sessionID: SESSION, text: "" }), + promptAccepted, + out("session.interrupt", { sessionID: SESSION }), + reply("session.interrupt", ""), + ]; + const secondTurn = (thread: OrchestrationV2ProviderThread) => ({ + ...turnInput(thread), + runId: RunId.make("run:opencode2-adapter:2"), + runOrdinal: 2, + providerTurnOrdinal: 2, + attemptId: RunAttemptId.make("attempt:opencode2-adapter:2"), + }); + const stopFirstTurn = ( + runtime: ProviderAdapterV2SessionRuntime, + thread: OrchestrationV2ProviderThread, + ) => + Effect.gen(function* () { + yield* runtime.startTurn(turnInput(thread)); + const interrupt = yield* runtime + .interruptTurn({ providerThread: thread, providerTurnId: yield* providerTurnId }) + .pipe(Effect.forkScoped); + yield* TestClock.adjust("11 seconds"); + yield* Fiber.join(interrupt); + }); + const terminals = (runtime: ProviderAdapterV2SessionRuntime, count: number) => + runtime.events.pipe( + Stream.filter( + (event): event is Extract => + event.type === "turn.terminal", + ), + Stream.take(count), + Stream.runCollect, + Effect.forkScoped, + ); + + it.effect("never lets a timed-out Stop's late end finish the next turn", () => + Effect.gen(function* () { + const { runtime, thread } = yield* resumed([ + ...stopTimedOut, + // The server no longer runs the stopped execution, so the next turn goes ahead. + out("session.active"), + reply("session.active", { data: {} }), + out("session.prompt", { sessionID: SESSION, text: "" }), + promptAccepted, + // The stopped execution's end arrives late, then the new turn's own. + event("session.execution.succeeded", { sessionID: SESSION }), + event("session.execution.started", { sessionID: SESSION }), + event("session.execution.failed", { + sessionID: SESSION, + error: { type: "provider", message: "second turn failed" }, + }), + ]); + const ended = yield* terminals(runtime, 2); + yield* stopFirstTurn(runtime, thread); + yield* runtime.startTurn(secondTurn(thread)); + const [first, second] = yield* Fiber.join(ended); + assert.equal(first?.status, "interrupted"); + // Only the second turn's own end finishes it. + assert.equal(second?.status, "failed"); + assert.equal(second?.failure?.message, "second turn failed"); + }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), + ); + + it.effect( + "starts the next turn once the server no longer runs a timed-out Stop's execution", + () => + Effect.gen(function* () { + const { runtime, thread } = yield* resumed([ + ...stopTimedOut, + out("session.active"), + reply("session.active", { data: {} }), + out("session.prompt", { sessionID: SESSION, text: "" }), + promptAccepted, + event("session.execution.started", { sessionID: SESSION }), + event("session.execution.succeeded", { sessionID: SESSION }), + ]); + const ended = yield* terminals(runtime, 2); + yield* stopFirstTurn(runtime, thread); + yield* runtime.startTurn(secondTurn(thread)); + const [, second] = yield* Fiber.join(ended); + assert.equal(second?.status, "completed"); + }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), + ); + + it.effect("stops a timed-out Stop's execution again and fails the turn while it runs", () => + Effect.gen(function* () { + const { runtime, thread } = yield* resumed([ + ...stopTimedOut, + out("session.active"), + reply("session.active", { data: { [SESSION]: { type: "running" } } }), + // Stopped again, and the turn fails without a prompt. + out("session.interrupt", { sessionID: SESSION }), + reply("session.interrupt", { interrupted: true }), + ]); + const ended = yield* terminals(runtime, 2); + yield* stopFirstTurn(runtime, thread); + yield* runtime.startTurn(secondTurn(thread)); + const [, second] = yield* Fiber.join(ended); + assert.equal(second?.status, "failed"); + assert.equal(second?.failure?.message, OPENCODE_2_STILL_STOPPING); + }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), + ); + it.effect("does not report a Stop the server says did nothing", () => Effect.gen(function* () { const { runtime, thread } = yield* resumed([ diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts index d8c263bb9066..02e3618f0e66 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts @@ -63,6 +63,7 @@ const operationOf = ( if (method === "GET" && path === "/api/event") return { type: "event.subscribe" }; if (method === "GET" && path === "/api/model") return { type: "model.list", input: query }; if (method === "POST" && path === "/api/session") return { type: "session.create", input: body }; + if (method === "GET" && path === "/api/session/active") return { type: "session.active" }; if (session !== null) { const [, sessionID, rest = ""] = session; const input = { sessionID, ...query, ...(body === undefined ? {} : (body as object)) }; diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts index d3e3bdfd1adc..2317ae1e08ac 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts @@ -182,6 +182,12 @@ interface ActiveTurn { readonly ordinals: Map; nextOrdinal: number; interrupted: boolean; + /** + * Set on a turn started after a timed-out Stop's run left the server: that + * run's tail can still be on the stream, and everything before this turn's + * own `session.execution.started` belongs to it. + */ + awaitingStart: boolean; } interface OpenBlock { @@ -197,6 +203,12 @@ interface ThreadState { active: ActiveTurn | undefined; /** What the native session runs now, so a changed selection is switched before prompting. */ model: ModelRef | undefined; + /** + * Set when a Stop timed out and the turn ended locally while OpenCode may + * still be running it. Execution events carry only the session id, so the + * next execution end belongs to that run; it clears this and ends no turn. + */ + unsettled: boolean; } /** One wording for every capability later layers add. */ @@ -243,6 +255,10 @@ export const OPENCODE_2_FULL_ACCESS_ONLY = const QUESTION_REPLY = "Questions aren't supported by this OpenCode integration yet."; const INTERRUPT_TIMEOUT = "10 seconds"; +const ACTIVE_CHECK_TIMEOUT = "5 seconds"; + +export const OPENCODE_2_STILL_STOPPING = + "OpenCode is still stopping the previous turn. Send the message again in a moment."; const REQUEST_REPLY_TIMEOUT = "10 seconds"; /** Whether an answer to a paused request reached the server, trying twice. */ @@ -609,9 +625,23 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr }); const handleEvent = Effect.fnUntraced(function* (event: OpenCode2StreamEvent) { + // The end of the run a timed-out Stop left behind; no turn is its own. + const endedSession = + event.type === "unreadable.execution.ended" + ? event.sessionID + : event.type === "session.execution.succeeded" || + event.type === "session.execution.failed" || + event.type === "session.execution.interrupted" + ? event.data.sessionID + : undefined; + const ended = endedSession === undefined ? undefined : threads.get(endedSession); + if (ended?.unsettled === true) { + ended.unsettled = false; + return; + } if (event.type === "unreadable.execution.ended") { const state = threads.get(event.sessionID); - if (state === undefined) return; + if (state === undefined || state.active?.awaitingStart === true) return; return yield* finishTurn( state, event.executionType === "session.execution.succeeded" @@ -637,6 +667,12 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr const state = threads.get(event.data.sessionID); const turn = state?.active; if (state === undefined || turn === undefined) return; + // A session runs one execution at a time, and each opens with `started` + // on this ordered stream, so what comes before it is the stopped run's. + if (turn.awaitingStart) { + if (event.type === "session.execution.started") turn.awaitingStart = false; + return; + } switch (event.type) { case "session.text.started": case "session.reasoning.started": @@ -752,6 +788,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr providerTurns: new Map(), active: undefined, model, + unsettled: false, }); return providerThread; }; @@ -884,6 +921,25 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr detail: `OpenCode session ${sessionId} already has an active turn`, }); } + // After a timed-out Stop the server says whether that run is gone. A + // run still going is stopped again and this turn fails so it can be + // sent again; a run that is gone may still have its end on the + // stream, which the turn skips. + const afterUnsettled = state.unsettled; + let stillStopping = false; + if (state.unsettled) { + const active = yield* client.session + .active() + .pipe(Effect.timeout(ACTIVE_CHECK_TIMEOUT)); + stillStopping = sessionId in active; + if (stillStopping) { + yield* client.session + .interrupt({ sessionID: Session.ID.make(sessionId) }) + .pipe(Effect.timeout(INTERRUPT_TIMEOUT), Effect.ignore({ log: true })); + } else { + state.unsettled = false; + } + } // Installs the turn; every path after it ends the turn with a terminal. const begin = Effect.gen(function* () { const startedAt = yield* DateTime.now; @@ -908,6 +964,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr ordinals: new Map(), nextOrdinal: turnInput.providerTurnOrdinal * 100 + 1, interrupted: false, + awaitingStart: afterUnsettled, }; // No stream is left to end this turn, so it must not start. if (streamFailure !== undefined) { @@ -934,6 +991,16 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr yield* setSessionStatus("running", null); return turn; }); + if (stillStopping) { + yield* begin; + return yield* finishTurn(state, { + status: "failed", + failure: makeProviderFailure({ + message: OPENCODE_2_STILL_STOPPING, + class: "provider_error", + }), + }); + } // A turn T3 will not run still starts and fails, so the refusal is what // the user reads. Sessions allow every tool, so any other mode would // silently run as Full access. @@ -1035,6 +1102,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr ), ); if (reply._tag === "None") { + state.unsettled = true; return yield* finishTurn(state, { status: "interrupted" }); } // Nothing was running. Unless the execution already ended (its event From 0a1b5d04d0c8fa68777dcffe5df22f1886921220 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:22:15 -0700 Subject: [PATCH 8/9] fix(server): close OpenCode 2 gaps around resumed sessions and uncertain turns - An execution start this build cannot decode is kept as a marker, so a turn that waits for its own start after a timed-out Stop still sees it. It never ends a turn. - A resumed session made by 1.x or an earlier build gets T3's rules (allow everything, deny subagents) through session.update when its rules differ, instead of keeping a catch-all that allows subagents. - A prompt request that fails without a clear refusal may have been taken, so the session is held like after a timed-out Stop and the next turn asks the server first. - resumeThread passes adapter errors through like the other runtime methods. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Adapters/OpenCode2AdapterV2.test.ts | 127 +++++++++++++++++- .../Adapters/OpenCode2AdapterV2.testkit.ts | 1 + .../Adapters/OpenCode2AdapterV2.ts | 69 ++++++++-- ...penCode2OrchestratorV2.integration.test.ts | 51 ++++++- .../opencode2/OpenCode2Client.events.test.ts | 83 ++++++------ .../opencode2/OpenCode2Client.live.test.ts | 1 + .../src/provider/opencode2/OpenCode2Client.ts | 29 +++- 7 files changed, 297 insertions(+), 64 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts index 36d1467d279d..1a12784647dc 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -61,8 +61,14 @@ const event = (type: string, data: Record): ProviderReplayEntry }); const durable = { durable: { aggregateID: SESSION, seq: 1, version: 1 } }; +/** The rules T3 gives every session it runs. */ +const t3Rules = [ + { action: "*", resource: "*", effect: "allow" }, + { action: "subagent", resource: "*", effect: "deny" }, +]; const sessionInfo = (overrides: Record = {}) => ({ id: SESSION, + permissions: t3Rules, projectID: "global", model: { id: "big-pickle", providerID: "opencode", variant: "default" }, cost: 0, @@ -279,10 +285,7 @@ describe("OpenCode2 adapter", () => { out("session.create", { location: { directory: WORK }, model: { providerID: "opencode", id: "big-pickle" }, - permissions: [ - { action: "*", resource: "*", effect: "allow" }, - { action: "subagent", resource: "*", effect: "deny" }, - ], + permissions: t3Rules, }), replyData("session.create", sessionInfo()), ]); @@ -420,6 +423,88 @@ describe("OpenCode2 adapter", () => { }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), ); + it.effect("reads a timed-out Stop's next turn from an execution start it cannot decode", () => + Effect.gen(function* () { + const { runtime, thread } = yield* resumed([ + ...stopTimedOut, + out("session.active"), + reply("session.active", { data: {} }), + out("session.prompt", { sessionID: SESSION, text: "" }), + promptAccepted, + // A newer server's start: the full schema rejects it, but it still opens the turn. + { + type: "emit_inbound", + frame: { + type: "sdk.event", + event: { + id: "evt_executionstartednewer", + created: 1, + type: "session.execution.started", + data: { sessionID: SESSION }, + durable: "not-an-envelope", + }, + }, + }, + event("session.text.ended", { + sessionID: SESSION, + assistantMessageID: "msg_0eb735d5b001oAFVeY5jz3WD4Z", + ordinal: 0, + text: "DONE", + }), + event("session.execution.succeeded", { sessionID: SESSION }), + ]); + const ended = yield* terminals(runtime, 2); + yield* stopFirstTurn(runtime, thread); + yield* runtime.startTurn(secondTurn(thread)); + const [, second] = yield* Fiber.join(ended); + assert.equal(second?.status, "completed"); + }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), + ); + + it.effect("checks the server before prompting again after a prompt request failed", () => + Effect.gen(function* () { + const { runtime, thread } = yield* resumed([ + out("session.prompt", { sessionID: SESSION, text: "" }), + // The request failed, but the server may have taken the prompt. + reply("session.prompt", { + status: 502, + body: { _tag: "UnknownError", message: "bad gateway" }, + }), + out("session.active"), + reply("session.active", { data: { [SESSION]: { type: "running" } } }), + out("session.interrupt", { sessionID: SESSION }), + reply("session.interrupt", { interrupted: true }), + ]); + const ended = yield* terminals(runtime, 2); + yield* runtime.startTurn(turnInput(thread)).pipe(Effect.ignore); + yield* runtime.startTurn(secondTurn(thread)); + const [first, second] = yield* Fiber.join(ended); + assert.equal(first?.status, "failed"); + assert.equal(second?.failure?.message, OPENCODE_2_STILL_STOPPING); + }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), + ); + + it.effect("prompts again without a check after the server refused a prompt", () => + Effect.gen(function* () { + const { runtime, thread } = yield* resumed([ + out("session.prompt", { sessionID: SESSION, text: "" }), + reply("session.prompt", { + status: 400, + body: { _tag: "InvalidRequestError", message: "bad prompt" }, + }), + // A clear refusal: nothing runs, so the next turn prompts directly. + out("session.prompt", { sessionID: SESSION, text: "" }), + promptAccepted, + event("session.execution.succeeded", { sessionID: SESSION }), + ]); + const ended = yield* terminals(runtime, 2); + yield* runtime.startTurn(turnInput(thread)).pipe(Effect.ignore); + yield* runtime.startTurn(secondTurn(thread)); + const [first, second] = yield* Fiber.join(ended); + assert.deepEqual([first?.status, second?.status], ["failed", "completed"]); + }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), + ); + it.effect("stops a timed-out Stop's execution again and fails the turn while it runs", () => Effect.gen(function* () { const { runtime, thread } = yield* resumed([ @@ -480,6 +565,40 @@ describe("OpenCode2 adapter", () => { }).pipe(Effect.scoped), ); + it.effect("refuses to resume a thread without an OpenCode session as a protocol error", () => + Effect.gen(function* () { + const runtime = yield* openCode2ReplayRuntime([out("event.subscribe")]); + const failed = yield* runtime + .resumeThread({ + providerThread: { ...providerThread(yield* DateTime.now), nativeThreadRef: null }, + }) + .pipe(Effect.flip); + assert.equal(failed._tag, "ProviderAdapterProtocolError"); + }).pipe(Effect.scoped), + ); + + it.effect("gives a resumed session T3's rules when it was made with others", () => + Effect.gen(function* () { + const runtime = yield* openCode2ReplayRuntime([ + out("event.subscribe"), + out("session.get", { sessionID: SESSION }), + // Made before the subagent rule: it still allows everything. + replyData( + "session.get", + sessionInfo({ permissions: [{ action: "*", resource: "*", effect: "allow" }] }), + ), + out("session.update", { sessionID: SESSION, permissions: t3Rules }), + reply("session.update", null), + ]); + yield* runtime.resumeThread({ + providerThread: providerThread(yield* DateTime.now), + threadId, + modelSelection: bigPickle, + runtimePolicy: policy(), + }); + }).pipe(Effect.scoped), + ); + it.effect("moves the session when the thread's worktree changed", () => Effect.gen(function* () { const runtime = yield* openCode2ReplayRuntime([ diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts index 02e3618f0e66..cb5c03c6a2bb 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts @@ -68,6 +68,7 @@ const operationOf = ( const [, sessionID, rest = ""] = session; const input = { sessionID, ...query, ...(body === undefined ? {} : (body as object)) }; if (method === "GET" && rest === "") return { type: "session.get", input }; + if (method === "PATCH" && rest === "") return { type: "session.update", input }; if (method === "POST" && rest === "/prompt") return { type: "session.prompt", input }; if (method === "POST" && rest === "/interrupt") return { type: "session.interrupt", input }; if (method === "POST" && rest === "/model") return { type: "session.switchModel", input }; diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts index 2317ae1e08ac..a0296252ef08 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts @@ -204,9 +204,10 @@ interface ThreadState { /** What the native session runs now, so a changed selection is switched before prompting. */ model: ModelRef | undefined; /** - * Set when a Stop timed out and the turn ended locally while OpenCode may - * still be running it. Execution events carry only the session id, so the - * next execution end belongs to that run; it clears this and ends no turn. + * Set when a turn ended here while OpenCode may still be running it: a Stop + * that timed out, or a prompt whose request failed without a clear answer. + * Execution events carry only the session id, so the next execution end + * belongs to that run; it clears this and ends no turn. */ unsettled: boolean; } @@ -248,6 +249,20 @@ const SESSION_PERMISSIONS = [ { action: "subagent", resource: "*", effect: "deny" }, ] as const; +const sameRules = ( + left: + | ReadonlyArray<{ readonly action: string; readonly resource: string; readonly effect: string }> + | undefined, + right: typeof SESSION_PERMISSIONS, +) => + left?.length === right.length && + left.every( + (rule, index) => + rule.action === right[index]?.action && + rule.resource === right[index]?.resource && + rule.effect === right[index]?.effect, + ); + export const OPENCODE_2_FULL_ACCESS_ONLY = "OpenCode 2 needs Full access for now; approvals come in a later update. Switch this thread's mode to continue."; @@ -256,6 +271,12 @@ const QUESTION_REPLY = "Questions aren't supported by this OpenCode integration const INTERRUPT_TIMEOUT = "10 seconds"; const ACTIVE_CHECK_TIMEOUT = "5 seconds"; +/** Answers that mean the server refused a prompt; any other failure may have been accepted. */ +const CLEAR_PROMPT_REJECTIONS: ReadonlySet = new Set([ + "InvalidRequestError", + "ConflictError", + "UnauthorizedError", +]); export const OPENCODE_2_STILL_STOPPING = "OpenCode is still stopping the previous turn. Send the message again in a moment."; @@ -639,6 +660,12 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr ended.unsettled = false; return; } + // Only marks where a turn's own execution begins; it never ends one. + if (event.type === "unreadable.execution.started") { + const turn = threads.get(event.sessionID)?.active; + if (turn !== undefined) turn.awaitingStart = false; + return; + } if (event.type === "unreadable.execution.ended") { const state = threads.get(event.sessionID); if (state === undefined || state.active?.awaitingStart === true) return; @@ -876,6 +903,14 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr // 1.x session ids survive the upgrade; a server without this session // fails the resume, so T3 recreates the thread with a handoff. const native = yield* client.session.get({ sessionID: Session.ID.make(sessionId) }); + // A session made by 1.x or an earlier build may still allow what + // T3 now denies, such as subagents. + if (!sameRules(native.permissions, SESSION_PERMISSIONS)) { + yield* client.session.update({ + sessionID: Session.ID.make(sessionId), + permissions: SESSION_PERMISSIONS, + }); + } // A thread moved to another worktree takes its session with it. const cwd = threadInput.runtimePolicy?.cwd; if (cwd != null && native.location.directory !== cwd) { @@ -895,14 +930,15 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr native.model, ); }).pipe( - Effect.mapError( - (cause) => - new ProviderAdapterResumeThreadError({ - driver, - providerSessionId: input.providerSessionId, - providerThreadId: threadInput.providerThread.id, - cause, - }), + Effect.mapError((cause) => + isProviderAdapterError(cause) + ? cause + : new ProviderAdapterResumeThreadError({ + driver, + providerSessionId: input.providerSessionId, + providerThreadId: threadInput.providerThread.id, + cause, + }), ), ), startTurn: (turnInput) => @@ -1047,9 +1083,14 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr }), Effect.tapError((cause) => state.active === turn - ? finishTurn(state, { - status: "failed", - failure: makeProviderFailure({ cause, class: "provider_error" }), + ? Effect.gen(function* () { + // Without a clear rejection the server may have taken the + // prompt, so the next turn checks before it prompts again. + if (!CLEAR_PROMPT_REJECTIONS.has(cause._tag)) state.unsettled = true; + yield* finishTurn(state, { + status: "failed", + failure: makeProviderFailure({ cause, class: "provider_error" }), + }); }) : Effect.void, ), diff --git a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts index 76609e91b957..1f0084540289 100644 --- a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts +++ b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts @@ -48,7 +48,11 @@ const event = (type: string, data: Record): ProviderReplayEntry event: { id: `evt_${type.replaceAll(".", "")}`, created: 1, type, data }, }, }); -const sessionInfo = (directory: string) => ({ +const T3_RULES = [ + { action: "*", resource: "*", effect: "allow" }, + { action: "subagent", resource: "*", effect: "deny" }, +]; +const sessionInfo = (directory: string, permissions: ReadonlyArray = T3_RULES) => ({ data: { id: SESSION, projectID: "global", @@ -57,6 +61,7 @@ const sessionInfo = (directory: string) => ({ tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, time: { created: 1790656601394, updated: 1790656601394 }, location: { directory }, + permissions, }, }); /** One prompt the server accepts and answers with `text`. */ @@ -249,6 +254,50 @@ describe("OpenCode 2 through the orchestrator", () => { }).pipe(Effect.scoped), ); + it.effect("gives a session made with older rules T3's rules before its next prompt", () => + Effect.gen(function* () { + const name = "opencode2-resume-rules"; + const before = yield* checkpointWorkspace(`${name}-before`); + const after = yield* checkpointWorkspace(`${name}-after`); + const thread = threadCommands({ name, worktreePath: before }); + const projection = yield* runScenario({ + name, + threadId: thread.threadId, + entries: [ + ...createdSession(before), + ...answeredPrompt("FIRST"), + // Reopened after a worktree change, the session reports the rules an + // older build gave it; they are replaced before anything runs. + out("session.get", { sessionID: SESSION }), + reply( + "session.get", + sessionInfo(before, [{ action: "*", resource: "*", effect: "allow" }]), + ), + out("session.update", { sessionID: SESSION, permissions: T3_RULES }), + reply("session.update", null), + out("session.move", { sessionID: SESSION, directory: after }), + reply("session.move", null), + ...answeredPrompt("SECOND"), + ], + commands: [ + thread.create, + thread.message("first"), + { + type: "thread.metadata.update", + commandId: thread.command("worktree"), + threadId: thread.threadId, + worktreePath: after, + }, + thread.message("second"), + ], + }); + assert.deepEqual( + projection.runs.map((run) => run.status), + ["completed", "completed"], + ); + }).pipe(Effect.scoped), + ); + it.effect("fails a turn outside Full access with the refusal, never prompting", () => Effect.gen(function* () { const name = "opencode2-supervised-refusal"; diff --git a/apps/server/src/provider/opencode2/OpenCode2Client.events.test.ts b/apps/server/src/provider/opencode2/OpenCode2Client.events.test.ts index 8d9325bdd3d1..4dfcfad26e30 100644 --- a/apps/server/src/provider/opencode2/OpenCode2Client.events.test.ts +++ b/apps/server/src/provider/opencode2/OpenCode2Client.events.test.ts @@ -116,48 +116,51 @@ describe("OpenCode2Client events", () => { }).pipe(Effect.provide(TestClock.layer())), ); - it.effect("keeps an execution's end it cannot decode, and skips other unknown events", () => - Effect.gen(function* () { - const { events } = yield* connect.pipe( - Effect.provide( - OpenCode2Client.layer.pipe( - Layer.provide( - serving([ - { - id: "evt_1", - created: 1, - type: "session.hologram.projected", - data: { sessionID: "ses_x" }, - }, - // A start is not an end: an undecodable one is skipped like any other event. - { - id: "evt_0", - created: 1, - type: "session.execution.started", - data: { sessionID: "ses_x", lane: 7 }, - durable: "not-an-envelope", - }, - { - id: "evt_2", - created: 1, - type: "session.execution.interrupted", - data: { sessionID: "ses_x", reason: "budget" }, - durable: { aggregateID: "ses_x", seq: 1, version: 1 }, - }, - ]), + it.effect( + "keeps an execution's end and start it cannot decode, and skips other unknown events", + () => + Effect.gen(function* () { + const { events } = yield* connect.pipe( + Effect.provide( + OpenCode2Client.layer.pipe( + Layer.provide( + serving([ + { + id: "evt_1", + created: 1, + type: "session.hologram.projected", + data: { sessionID: "ses_x" }, + }, + // A start is kept as a marker; it never ends anything. + { + id: "evt_0", + created: 1, + type: "session.execution.started", + data: { sessionID: "ses_x", lane: 7 }, + durable: "not-an-envelope", + }, + { + id: "evt_2", + created: 1, + type: "session.execution.interrupted", + data: { sessionID: "ses_x", reason: "budget" }, + durable: { aggregateID: "ses_x", seq: 1, version: 1 }, + }, + ]), + ), ), ), - ), - ); - const received = yield* (yield* events).pipe(Stream.runCollect); - assert.deepStrictEqual(received, [ - { - type: "unreadable.execution.ended", - executionType: "session.execution.interrupted", - sessionID: "ses_x", - }, - ]); - }), + ); + const received = yield* (yield* events).pipe(Stream.runCollect); + assert.deepStrictEqual(received, [ + { type: "unreadable.execution.started", sessionID: "ses_x" }, + { + type: "unreadable.execution.ended", + executionType: "session.execution.interrupted", + sessionID: "ses_x", + }, + ]); + }), ); it.effect("skips events a newer server adds and still sees the turn end", () => diff --git a/apps/server/src/provider/opencode2/OpenCode2Client.live.test.ts b/apps/server/src/provider/opencode2/OpenCode2Client.live.test.ts index 387cce34b932..8c62ebafba60 100644 --- a/apps/server/src/provider/opencode2/OpenCode2Client.live.test.ts +++ b/apps/server/src/provider/opencode2/OpenCode2Client.live.test.ts @@ -104,6 +104,7 @@ describe.runIf(binaryPath !== undefined)("OpenCode2Client live", () => { Stream.filter( (event) => event.type !== "unreadable.execution.ended" && + event.type !== "unreadable.execution.started" && (event.data as { readonly sessionID?: string }).sessionID === session.id && event.type.startsWith("session.execution.") && event.type !== "session.execution.started", diff --git a/apps/server/src/provider/opencode2/OpenCode2Client.ts b/apps/server/src/provider/opencode2/OpenCode2Client.ts index 41886fc0ac35..d42b9e0cef86 100644 --- a/apps/server/src/provider/opencode2/OpenCode2Client.ts +++ b/apps/server/src/provider/opencode2/OpenCode2Client.ts @@ -28,6 +28,15 @@ export interface OpenCode2UnreadableTerminal { readonly sessionID: string; } +/** + * A session's execution started, but this build could not decode the event. + * It marks where a new execution begins and never ends a turn. + */ +export interface OpenCode2UnreadableStart { + readonly type: "unreadable.execution.started"; + readonly sessionID: string; +} + /** The events that end an execution; any other undecodable event is skipped. */ const EXECUTION_ENDS = [ "session.execution.succeeded", @@ -47,16 +56,19 @@ export class OpenCode2SilentStreamError extends Schema.TaggedError sessionID, }); } + if (type === "session.execution.started" && sessionID !== undefined) { + yield* Effect.logDebug("Read an OpenCode execution start this build cannot decode."); + return Result.succeed({ + type: "unreadable.execution.started", + sessionID, + }); + } yield* Effect.logDebug("Skipped an OpenCode event this build cannot decode.", { type }); return Result.failVoid; }); From d95113b641be178a075e248ac7ada1c2eae0e98f Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:07:46 -0700 Subject: [PATCH 9/9] refactor(server): import Effect services as namespaces in the OpenCode adapters The OpenCode 2 adapter, its testkit, test, live test and the shared fixture helpers take the namespace form the rest of V2 uses, and so does the 1.x adapter this layer edits. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Adapters/OpenCode2AdapterV2.test.ts | 6 +- .../Adapters/OpenCode2AdapterV2.testkit.ts | 8 +- .../Adapters/OpenCode2AdapterV2.ts | 78 +++---- .../Adapters/OpenCodeAdapterV2.ts | 202 ++++++++---------- .../OpenCode2OrchestratorV2.live.test.ts | 56 ++--- .../testkit/fixtures/shared.ts | 10 +- 6 files changed, 165 insertions(+), 195 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts index 1a12784647dc..0315014f7972 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -32,7 +32,7 @@ import type { ProviderAdapterV2Event, ProviderAdapterV2SessionRuntime, } from "../ProviderAdapter.ts"; -import { IdAllocatorV2, layer as idAllocatorLayer } from "../IdAllocator.ts"; +import * as IdAllocator from "../IdAllocator.ts"; import { OPENCODE_PROVIDER } from "./OpenCodeAdapterV2.ts"; import { OPENCODE_2_FULL_ACCESS_ONLY, OPENCODE_2_STILL_STOPPING } from "./OpenCode2AdapterV2.ts"; import { openCode2ReplayRuntime } from "./OpenCode2AdapterV2.testkit.ts"; @@ -824,9 +824,9 @@ describe("OpenCode2 adapter", () => { /** The provider turn the adapter derives for `turnInput`'s attempt. */ const providerTurnId = Effect.gen(function* () { - const ids = yield* IdAllocatorV2; + const ids = yield* IdAllocator.IdAllocatorV2; return ids.derive.providerTurn({ driver: OPENCODE_PROVIDER, nativeTurnId: `${SESSION}:attempt:attempt:opencode2-adapter`, }); -}).pipe(Effect.provide(idAllocatorLayer)); +}).pipe(Effect.provide(IdAllocator.layer)); diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts index cb5c03c6a2bb..5f6a272bc3e9 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts @@ -20,11 +20,11 @@ import * as HttpClientError from "effect/unstable/http/HttpClientError"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import * as UrlParams from "effect/unstable/http/UrlParams"; -import { ServerConfig } from "../../config.ts"; +import * as ServerConfig from "../../config.ts"; import * as OpenCode2Client from "../../provider/opencode2/OpenCode2Client.ts"; import * as OpenCode2Server from "../../provider/opencode2/OpenCode2Server.ts"; import * as IdAllocator from "../IdAllocator.ts"; -import { makeLayer } from "../ProviderAdapterRegistry.ts"; +import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts"; import { makeReplayServerConfig, type OrchestratorV2ProviderReplayHarness, @@ -169,14 +169,14 @@ const makeReplayAdapter = ( }); const replayServerConfig = (scenario: string) => - Layer.effect(ServerConfig, makeReplayServerConfig(scenario).pipe(Effect.orDie)).pipe( + Layer.effect(ServerConfig.ServerConfig, makeReplayServerConfig(scenario).pipe(Effect.orDie)).pipe( Layer.provide(NodeServices.layer), ); function makeRegistryLayer(transcript: OpenCode2ReplayTranscript) { return Layer.unwrap( makeReplayAdapter(transcript, { external: true }).pipe( - Effect.map((adapter) => makeLayer([adapter])), + Effect.map((adapter) => ProviderAdapterRegistry.makeLayer([adapter])), ), ).pipe(Layer.provide(Layer.mergeAll(replayServerConfig(transcript.scenario), IdAllocator.layer))); } diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts index a0296252ef08..b0a1ac26dc5b 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts @@ -39,7 +39,7 @@ import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; -import { ServerConfig } from "../../config.ts"; +import * as ServerConfig from "../../config.ts"; import type { OpenCode2StreamEvent } from "../../provider/opencode2/OpenCode2Client.ts"; import * as OpenCode2Server from "../../provider/opencode2/OpenCode2Server.ts"; import { @@ -52,26 +52,7 @@ import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { providerMessageTextWithAttachmentPaths } from "../AttachmentPrompt.ts"; import * as IdAllocator from "../IdAllocator.ts"; import { makeProviderFailure } from "../ProviderFailure.ts"; -import { - ProviderAdapterEnsureThreadError, - ProviderAdapterEventStreamError, - ProviderAdapterForkThreadError, - ProviderAdapterInterruptError, - ProviderAdapterOpenSessionError, - ProviderAdapterProtocolError, - ProviderAdapterReadThreadSnapshotError, - ProviderAdapterResumeThreadError, - ProviderAdapterRollbackThreadError, - ProviderAdapterRuntimeRequestResponseError, - ProviderAdapterSteerRunUnsupportedError, - ProviderAdapterTurnStartError, - ProviderAdapterV2, - ProviderAdapterV2Error, - type ProviderAdapterV2Event, - type ProviderAdapterV2SessionRuntime, - type ProviderAdapterV2Shape, - type ProviderAdapterV2TurnInput, -} from "../ProviderAdapter.ts"; +import * as ProviderAdapter from "../ProviderAdapter.ts"; import { turnScopedSelectionTransition } from "../ProviderSelectionTransition.ts"; import { OPENCODE_PROVIDER } from "./OpenCodeAdapterV2.ts"; import { openCodeToolTurnItem } from "./OpenCodeToolItems.ts"; @@ -173,7 +154,7 @@ const OpenCode2ProviderCapabilities = { type EventOf = Extract; interface ActiveTurn { - readonly input: ProviderAdapterV2TurnInput; + readonly input: ProviderAdapter.ProviderAdapterV2TurnInput; readonly providerTurn: OrchestrationV2ProviderTurn; /** Open text and reasoning blocks, keyed `::`. */ readonly texts: Map; @@ -214,7 +195,7 @@ interface ThreadState { /** One wording for every capability later layers add. */ const notYet = (feature: string) => - new ProviderAdapterProtocolError({ + new ProviderAdapter.ProviderAdapterProtocolError({ driver: OPENCODE_PROVIDER, detail: `OpenCode 2 ${feature} is not supported yet`, }); @@ -229,7 +210,7 @@ const sessionIdOf = (providerThread: OrchestrationV2ProviderThread) => { const nativeId = providerThread.nativeThreadRef?.nativeId; return nativeId === undefined || nativeId === null ? Effect.fail( - new ProviderAdapterProtocolError({ + new ProviderAdapter.ProviderAdapterProtocolError({ driver: OPENCODE_PROVIDER, detail: `Provider thread ${providerThread.id} has no OpenCode session`, }), @@ -294,14 +275,14 @@ const deliver = (answer: Effect.Effect) => type ModelRef = ReturnType; // Errors already in the adapter channel keep their tag; only lower-level ones are wrapped. -const isProviderAdapterError = Schema.is(ProviderAdapterV2Error); +const isProviderAdapterError = Schema.is(ProviderAdapter.ProviderAdapterV2Error); /** * The model OpenCode should run for a `provider/model` slug and its reasoning * variant, or undefined for any other slug: sending none would run OpenCode's * default while T3 records the requested model. */ -const modelRef = (selection: ProviderAdapterV2TurnInput["modelSelection"]) => { +const modelRef = (selection: ProviderAdapter.ProviderAdapterV2TurnInput["modelSelection"]) => { const parsed = parseOpenCodeModelSlug(selection.model); if (parsed === null) return undefined; const variant = getModelSelectionStringOptionValue(selection, "variant"); @@ -325,11 +306,11 @@ const sameModel = (left: ModelRef, right: ModelRef | undefined) => export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: ProviderInstanceId) { const server = yield* OpenCode2Server.OpenCode2Server; const idAllocator = yield* IdAllocator.IdAllocatorV2; - const serverConfig = yield* ServerConfig; + const serverConfig = yield* ServerConfig.ServerConfig; const driver = OPENCODE_PROVIDER; const openSession = Effect.fn("OpenCode2Adapter.openSession")(function* ( - input: Parameters[0], + input: Parameters[0], connection: OpenCode2Server.OpenCode2Connection, ) { const { client } = connection; @@ -346,9 +327,10 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr updatedAt: now, lastError: null, }; - const events = yield* Queue.unbounded(); + const events = yield* Queue.unbounded(); const threads = new Map(); - const emit = (event: ProviderAdapterV2Event) => Queue.offer(events, event).pipe(Effect.asVoid); + const emit = (event: ProviderAdapter.ProviderAdapterV2Event) => + Queue.offer(events, event).pipe(Effect.asVoid); const setSessionStatus = ( status: OrchestrationV2ProviderSession["status"], @@ -820,7 +802,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr return providerThread; }; - const prompt = (turnInput: ProviderAdapterV2TurnInput) => { + const prompt = (turnInput: ProviderAdapter.ProviderAdapterV2TurnInput) => { const text = providerMessageTextWithAttachmentPaths({ text: turnInput.message.text, attachments: turnInput.message.attachments, @@ -833,7 +815,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr return `${text}\n\n${instructions}`; }; - const runtime: ProviderAdapterV2SessionRuntime = { + const runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime = { instanceId, driver, providerSessionId: input.providerSessionId, @@ -853,7 +835,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr } const model = modelRef(threadInput.modelSelection); if (model === undefined) { - return yield* new ProviderAdapterProtocolError({ + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ driver: OPENCODE_PROVIDER, detail: malformedModel(threadInput.modelSelection.model), }); @@ -890,7 +872,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr Effect.mapError((cause) => isProviderAdapterError(cause) ? cause - : new ProviderAdapterEnsureThreadError({ + : new ProviderAdapter.ProviderAdapterEnsureThreadError({ driver, threadId: threadInput.threadId, cause, @@ -933,7 +915,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr Effect.mapError((cause) => isProviderAdapterError(cause) ? cause - : new ProviderAdapterResumeThreadError({ + : new ProviderAdapter.ProviderAdapterResumeThreadError({ driver, providerSessionId: input.providerSessionId, providerThreadId: threadInput.providerThread.id, @@ -946,13 +928,13 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr const sessionId = yield* sessionIdOf(turnInput.providerThread); const state = threads.get(sessionId); if (state === undefined) { - return yield* new ProviderAdapterProtocolError({ + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ driver: OPENCODE_PROVIDER, detail: `OpenCode session ${sessionId} is not registered`, }); } if (state.active !== undefined) { - return yield* new ProviderAdapterProtocolError({ + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ driver: OPENCODE_PROVIDER, detail: `OpenCode session ${sessionId} already has an active turn`, }); @@ -1004,7 +986,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr }; // No stream is left to end this turn, so it must not start. if (streamFailure !== undefined) { - return yield* new ProviderAdapterEventStreamError({ + return yield* new ProviderAdapter.ProviderAdapterEventStreamError({ driver, providerSessionId: input.providerSessionId, cause: streamFailure, @@ -1099,7 +1081,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr Effect.mapError((cause) => isProviderAdapterError(cause) ? cause - : new ProviderAdapterTurnStartError({ + : new ProviderAdapter.ProviderAdapterTurnStartError({ driver, threadId: turnInput.threadId, providerThreadId: turnInput.providerThread.id, @@ -1110,7 +1092,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr ), steerTurn: (steerInput) => Effect.fail( - new ProviderAdapterSteerRunUnsupportedError({ + new ProviderAdapter.ProviderAdapterSteerRunUnsupportedError({ driver, providerThreadId: steerInput.providerThread.id, }), @@ -1150,7 +1132,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr // is on the way), the turn is still open and nothing stopped. if (!reply.value.interrupted && state.active === turn) { turn.interrupted = false; - return yield* new ProviderAdapterProtocolError({ + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ driver: OPENCODE_PROVIDER, detail: `OpenCode session ${sessionId} had nothing running to stop`, }); @@ -1159,7 +1141,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr Effect.mapError((cause) => isProviderAdapterError(cause) ? cause - : new ProviderAdapterInterruptError({ + : new ProviderAdapter.ProviderAdapterInterruptError({ driver, providerThreadId: interruptInput.providerThread.id, providerTurnId: interruptInput.providerTurnId, @@ -1180,7 +1162,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr }), respondToRuntimeRequest: (requestInput) => Effect.fail( - new ProviderAdapterRuntimeRequestResponseError({ + new ProviderAdapter.ProviderAdapterRuntimeRequestResponseError({ driver, requestId: requestInput.requestId, cause: notYet("answering runtime requests"), @@ -1188,7 +1170,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr ), readThreadSnapshot: ({ providerThread }) => Effect.fail( - new ProviderAdapterReadThreadSnapshotError({ + new ProviderAdapter.ProviderAdapterReadThreadSnapshotError({ driver, providerThreadId: providerThread.id, cause: notYet("history snapshots"), @@ -1196,7 +1178,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr ), rollbackThread: (rollbackInput) => Effect.fail( - new ProviderAdapterRollbackThreadError({ + new ProviderAdapter.ProviderAdapterRollbackThreadError({ driver, providerThreadId: rollbackInput.providerThread.id, checkpointId: rollbackInput.target.checkpointId, @@ -1205,7 +1187,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr ), forkThread: (forkInput) => Effect.fail( - new ProviderAdapterForkThreadError({ + new ProviderAdapter.ProviderAdapterForkThreadError({ driver, providerThreadId: forkInput.sourceProviderThread.id, cause: notYet("fork"), @@ -1215,7 +1197,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr return runtime; }); - return ProviderAdapterV2.of({ + return ProviderAdapter.ProviderAdapterV2.of({ instanceId, driver, getCapabilities: () => Effect.succeed(OpenCode2ProviderCapabilities), @@ -1240,7 +1222,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr }).pipe( Effect.mapError( (cause) => - new ProviderAdapterOpenSessionError({ + new ProviderAdapter.ProviderAdapterOpenSessionError({ driver, providerSessionId: input.providerSessionId, cause, diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts index 308ff353e478..98203a7f91af 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts @@ -55,10 +55,10 @@ import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; -import { ServerConfig } from "../../config.ts"; +import * as ServerConfig from "../../config.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import type { EventNdjsonLogger } from "../../provider/Layers/EventNdjsonLogger.ts"; -import { ProviderEventLoggers } from "../../provider/Layers/ProviderEventLoggers.ts"; +import * as ProviderEventLoggers from "../../provider/Layers/ProviderEventLoggers.ts"; import { structuralProtocolMethod, summarizeNativeProtocolPayload, @@ -66,43 +66,12 @@ import { import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts"; import { t3OrchestrationSystemPrompt } from "../../provider/T3OrchestrationInstructions.ts"; import { buildRuntimeInstructions } from "../../provider/RuntimeInstructions.ts"; -import { - OpenCodeRuntime, - loadOpenCodeCommands, - OpenCodeRuntimeError, - openCodeQuestionId, - openCodeRuntimeErrorDetail, - parseOpenCodeModelSlug, - runOpenCodeSdk, - toOpenCodeFileParts, - toOpenCodePermissionReply, - toOpenCodeQuestionAnswers, - type OpenCodeRuntimeShape, -} from "../../provider/opencodeRuntime.ts"; -import { IdAllocatorV2, type IdAllocatorV2Shape } from "../IdAllocator.ts"; +import * as OpenCodeRuntime from "../../provider/opencodeRuntime.ts"; +import * as IdAllocator from "../IdAllocator.ts"; import { makeProviderFailure } from "../ProviderFailure.ts"; import { turnScopedSelectionTransition } from "../ProviderSelectionTransition.ts"; import { providerMessageTextWithAttachmentPaths } from "../AttachmentPrompt.ts"; -import { - ProviderAdapterEnsureThreadError, - ProviderAdapterForkThreadError, - ProviderAdapterInterruptError, - ProviderAdapterOpenSessionError, - ProviderAdapterProtocolError, - ProviderAdapterReadThreadSnapshotError, - ProviderAdapterResumeThreadError, - ProviderAdapterRollbackThreadError, - ProviderAdapterRuntimeRequestResponseError, - ProviderAdapterSteerRunError, - ProviderAdapterTurnStartError, - ProviderAdapterV2, - type ProviderAdapterV2Event, - type ProviderAdapterV2OpenSessionInput, - type ProviderAdapterV2SessionRuntime, - type ProviderAdapterV2Shape, - type ProviderAdapterV2RuntimePolicy, - type ProviderAdapterV2TurnInput, -} from "../ProviderAdapter.ts"; +import * as ProviderAdapter from "../ProviderAdapter.ts"; import { ProviderAdapterDriverCreateError, type ProviderAdapterDriver, @@ -303,11 +272,11 @@ interface ActiveOpenCodeTurn { readonly usage: OpenCodeTurnTokenUsageAccumulator; readonly isRoot: boolean; readonly threadId: ThreadId; - readonly runId: ProviderAdapterV2TurnInput["runId"] | null; - readonly rootNodeId: ProviderAdapterV2TurnInput["rootNodeId"]; + readonly runId: ProviderAdapter.ProviderAdapterV2TurnInput["runId"] | null; + readonly rootNodeId: ProviderAdapter.ProviderAdapterV2TurnInput["rootNodeId"]; readonly appThread: OrchestrationV2AppThread; readonly modelSelection: ModelSelection; - readonly runtimePolicy: ProviderAdapterV2RuntimePolicy; + readonly runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy; readonly providerTurnId: OrchestrationV2ProviderTurn["id"]; readonly providerTurnOrdinal: number; readonly runOrdinal: number; @@ -465,9 +434,9 @@ export interface OpenCodeAdapterV2Options { readonly instanceId: ProviderInstanceId; readonly settings: OpenCodeSettings; readonly environment: NodeJS.ProcessEnv; - readonly runtime: OpenCodeRuntimeShape; - readonly idAllocator: IdAllocatorV2Shape; - readonly serverConfig: ServerConfig["Service"]; + readonly runtime: OpenCodeRuntime.OpenCodeRuntimeShape; + readonly idAllocator: IdAllocator.IdAllocatorV2Shape; + readonly serverConfig: ServerConfig.ServerConfig["Service"]; readonly nativeEventLogger?: EventNdjsonLogger; } @@ -489,7 +458,7 @@ function formatOpenCodeProtocolLogPayload(event: OpenCodeProtocolLogEvent) { export function makeOpenCodeProtocolLogger(input: { readonly nativeEventLogger: EventNdjsonLogger | undefined; - readonly idAllocator: IdAllocatorV2Shape; + readonly idAllocator: IdAllocator.IdAllocatorV2Shape; readonly providerInstanceId: ProviderInstanceId; readonly providerSessionId: ProviderSessionId; readonly threadId: ThreadId; @@ -533,8 +502,11 @@ export function makeOpenCodeProtocolLogger(input: { ); } -function protocolError(detail: string, payload?: unknown): ProviderAdapterProtocolError { - return new ProviderAdapterProtocolError({ +function protocolError( + detail: string, + payload?: unknown, +): ProviderAdapter.ProviderAdapterProtocolError { + return new ProviderAdapter.ProviderAdapterProtocolError({ driver: OPENCODE_PROVIDER, detail, ...(payload === undefined ? {} : { payload }), @@ -647,7 +619,7 @@ const OPENCODE_RESTRICTED_PERMISSIONS = [ * allows it. */ export function openCodePermissionRules( - runtimePolicy: ProviderAdapterV2RuntimePolicy, + runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy, ): PermissionRuleset { const sandboxPolicy = recordValue(runtimePolicy, "sandboxPolicy"); const sandboxType = recordString(sandboxPolicy, "type"); @@ -756,7 +728,7 @@ function permissionRuleEquals( * were added specifically for the selected child agent. */ export function openCodeChildPermissionRules( - runtimePolicy: ProviderAdapterV2RuntimePolicy, + runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy, nativeChildRules: PermissionRuleset, ): PermissionRuleset { const parentRules = openCodePermissionRules(runtimePolicy); @@ -857,7 +829,7 @@ function taskSessionId(part: ToolPart): string | null { } function makeProviderThread(input: { - readonly idAllocator: IdAllocatorV2Shape; + readonly idAllocator: IdAllocator.IdAllocatorV2Shape; readonly providerInstanceId: ProviderInstanceId; readonly providerSessionId: OrchestrationV2ProviderThread["providerSessionId"]; readonly appThreadId: OrchestrationV2ProviderThread["appThreadId"]; @@ -960,7 +932,7 @@ function isOpenCodeNotFound(cause: unknown): boolean { function unwrapData(operation: string, result: { readonly data?: A }): NonNullable { if (result.data === undefined) { - throw new OpenCodeRuntimeError({ + throw new OpenCodeRuntime.OpenCodeRuntimeError({ operation, detail: `OpenCode ${operation} returned no response payload.`, }); @@ -968,16 +940,18 @@ function unwrapData(operation: string, result: { readonly data?: A }): NonNul return result.data as NonNullable; } -export function makeOpenCodeAdapterV2(options: OpenCodeAdapterV2Options): ProviderAdapterV2Shape { +export function makeOpenCodeAdapterV2( + options: OpenCodeAdapterV2Options, +): ProviderAdapter.ProviderAdapterV2Shape { const { idAllocator, runtime, serverConfig } = options; - return ProviderAdapterV2.of({ + return ProviderAdapter.ProviderAdapterV2.of({ instanceId: options.instanceId, driver: OPENCODE_PROVIDER, getCapabilities: () => Effect.succeed(OpenCodeProviderCapabilitiesV2), planSelectionTransition: () => Effect.succeed(turnScopedSelectionTransition()), openSession: Effect.fn("OpenCodeAdapterV2.openSession")( - function* (input: ProviderAdapterV2OpenSessionInput) { + function* (input: ProviderAdapter.ProviderAdapterV2OpenSessionInput) { const scope = yield* Effect.scope; const cwd = input.runtimePolicy.cwd ?? serverConfig.cwd; const connection = yield* runtime.connectToOpenCodeServer({ @@ -998,7 +972,7 @@ export function makeOpenCodeAdapterV2(options: OpenCodeAdapterV2Options): Provid const hasT3Mcp = mcpSession !== undefined && !connection.external; const orchestrationSystemPrompt = t3OrchestrationSystemPrompt(hasT3Mcp); if (hasT3Mcp) { - yield* runOpenCodeSdk("mcp.add", () => + yield* OpenCodeRuntime.runOpenCodeSdk("mcp.add", () => client.mcp.add({ name: "t3-code", config: { @@ -1024,7 +998,7 @@ export function makeOpenCodeAdapterV2(options: OpenCodeAdapterV2Options): Provid updatedAt: now, lastError: null, }; - const events = yield* Queue.unbounded(); + const events = yield* Queue.unbounded(); let nativeStreamFailure: OrchestrationV2ProviderFailure | null = null; const threads = new Map(); const commandReceipts = new Map>(); @@ -1055,7 +1029,7 @@ export function makeOpenCodeAdapterV2(options: OpenCodeAdapterV2Options): Provid let closing = false; let hasConnected = false; - const emitProviderEvent = (event: ProviderAdapterV2Event) => + const emitProviderEvent = (event: ProviderAdapter.ProviderAdapterV2Event) => Queue.offer(events, event).pipe(Effect.asVoid); const logProtocolEvent = makeOpenCodeProtocolLogger({ @@ -1070,14 +1044,14 @@ export function makeOpenCodeAdapterV2(options: OpenCodeAdapterV2Options): Provid method: string, payload: unknown, call: (signal: AbortSignal) => Promise, - ): Effect.Effect => + ): Effect.Effect => logProtocolEvent({ direction: "outgoing", messageKind: "request", method, payload, }).pipe( - Effect.andThen(runOpenCodeSdk(method, call)), + Effect.andThen(OpenCodeRuntime.runOpenCodeSdk(method, call)), Effect.tap((response) => logProtocolEvent({ direction: "incoming", @@ -1095,7 +1069,7 @@ export function makeOpenCodeAdapterV2(options: OpenCodeAdapterV2Options): Provid const visit = ( sessionId: string, abort: boolean, - ): Effect.Effect => + ): Effect.Effect => Effect.gen(function* () { const abortResult = abort ? yield* sdkCall("session.abort", { sessionID: sessionId }, (signal) => @@ -1748,7 +1722,7 @@ export function makeOpenCodeAdapterV2(options: OpenCodeAdapterV2Options): Provid const requestQuestions = (request: QuestionRequest) => request.questions.map((question, index) => ({ - id: openCodeQuestionId(index, question), + id: OpenCodeRuntime.openCodeQuestionId(index, question), header: question.header.trim() || `Question ${index + 1}`, question: question.question.trim() || question.header.trim() || `Question ${index + 1}`, options: question.options.map((option) => ({ @@ -2714,9 +2688,9 @@ export function makeOpenCodeAdapterV2(options: OpenCodeAdapterV2Options): Provid yield* Stream.fromAsyncIterable( subscription.stream, (cause) => - new OpenCodeRuntimeError({ + new OpenCodeRuntime.OpenCodeRuntimeError({ operation: "event.subscribe", - detail: openCodeRuntimeErrorDetail(cause), + detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause), cause, }), ).pipe( @@ -2729,7 +2703,7 @@ export function makeOpenCodeAdapterV2(options: OpenCodeAdapterV2Options): Provid if (closing || abortController.signal.aborted) return; const detail = Exit.isSuccess(exit) ? "OpenCode event stream ended unexpectedly." - : openCodeRuntimeErrorDetail(Cause.squash(exit.cause)); + : OpenCodeRuntime.openCodeRuntimeErrorDetail(Cause.squash(exit.cause)); nativeStreamFailure = makeProviderFailure({ message: detail, class: "transport_error", @@ -2834,13 +2808,13 @@ export function makeOpenCodeAdapterV2(options: OpenCodeAdapterV2Options): Provid return state; }; - const resolvePromptParts = (turnInput: ProviderAdapterV2TurnInput) => { + const resolvePromptParts = (turnInput: ProviderAdapter.ProviderAdapterV2TurnInput) => { const text = providerMessageTextWithAttachmentPaths({ text: turnInput.message.text, attachments: turnInput.message.attachments, attachmentsDir: serverConfig.attachmentsDir, }).trim(); - const files = toOpenCodeFileParts({ + const files = OpenCodeRuntime.toOpenCodeFileParts({ attachments: turnInput.message.attachments, resolveAttachmentPath: (attachment) => resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }), @@ -2942,7 +2916,7 @@ export function makeOpenCodeAdapterV2(options: OpenCodeAdapterV2Options): Provid .trim(); const match = text?.match(/^\/([^\s/]+)(?:\s+([\s\S]*))?$/); const command = match - ? (yield* loadOpenCodeCommands(client).pipe( + ? (yield* OpenCodeRuntime.loadOpenCodeCommands(client).pipe( Effect.timeout("10 seconds"), Effect.orElseSucceed(() => []), )).find((entry) => entry.name === match[1]) @@ -3000,7 +2974,7 @@ export function makeOpenCodeAdapterV2(options: OpenCodeAdapterV2Options): Provid yield* Effect.raceFirst(Fiber.join(request), Deferred.await(receipt)).pipe( Effect.timeout("10 seconds"), Effect.catchTag("TimeoutError", (cause) => { - const error = new OpenCodeRuntimeError({ + const error = new OpenCodeRuntime.OpenCodeRuntimeError({ operation: "session.command", detail: "OpenCode command admission did not complete within 10 seconds.", cause, @@ -3013,7 +2987,7 @@ export function makeOpenCodeAdapterV2(options: OpenCodeAdapterV2Options): Provid ); }); - const runtimeSession: ProviderAdapterV2SessionRuntime = { + const runtimeSession: ProviderAdapter.ProviderAdapterV2SessionRuntime = { instanceId: options.instanceId, driver: OPENCODE_PROVIDER, providerSessionId: input.providerSessionId, @@ -3069,7 +3043,7 @@ export function makeOpenCodeAdapterV2(options: OpenCodeAdapterV2Options): Provid }).pipe( Effect.mapError( (cause) => - new ProviderAdapterEnsureThreadError({ + new ProviderAdapter.ProviderAdapterEnsureThreadError({ driver: OPENCODE_PROVIDER, threadId: threadInput.threadId, cause, @@ -3095,7 +3069,7 @@ export function makeOpenCodeAdapterV2(options: OpenCodeAdapterV2Options): Provid }).pipe( Effect.mapError( (cause) => - new ProviderAdapterResumeThreadError({ + new ProviderAdapter.ProviderAdapterResumeThreadError({ driver: OPENCODE_PROVIDER, providerSessionId: input.providerSessionId, providerThreadId: threadInput.providerThread.id, @@ -3125,7 +3099,9 @@ export function makeOpenCodeAdapterV2(options: OpenCodeAdapterV2Options): Provid `OpenCode provider thread ${turnInput.providerThread.id} already has an active turn`, ); } - const parsedModel = parseOpenCodeModelSlug(turnInput.modelSelection.model); + const parsedModel = OpenCodeRuntime.parseOpenCodeModelSlug( + turnInput.modelSelection.model, + ); if (parsedModel === null) { return yield* protocolError( `OpenCode model '${turnInput.modelSelection.model}' must use provider/model format`, @@ -3302,7 +3278,7 @@ export function makeOpenCodeAdapterV2(options: OpenCodeAdapterV2Options): Provid }).pipe( Effect.mapError( (cause) => - new ProviderAdapterTurnStartError({ + new ProviderAdapter.ProviderAdapterTurnStartError({ driver: OPENCODE_PROVIDER, threadId: turnInput.threadId, providerThreadId: turnInput.providerThread.id, @@ -3327,7 +3303,7 @@ export function makeOpenCodeAdapterV2(options: OpenCodeAdapterV2Options): Provid `OpenCode turn ${steerInput.providerTurnId} is not active`, ); } - const parsedModel = parseOpenCodeModelSlug(turn.modelSelection.model); + const parsedModel = OpenCodeRuntime.parseOpenCodeModelSlug(turn.modelSelection.model); if (parsedModel === null) { return yield* protocolError( `OpenCode model '${turn.modelSelection.model}' must use provider/model format`, @@ -3338,7 +3314,7 @@ export function makeOpenCodeAdapterV2(options: OpenCodeAdapterV2Options): Provid attachments: steerInput.message.attachments, attachmentsDir: serverConfig.attachmentsDir, }).trim(); - const files = toOpenCodeFileParts({ + const files = OpenCodeRuntime.toOpenCodeFileParts({ attachments: steerInput.message.attachments, resolveAttachmentPath: (attachment) => resolveAttachmentPath({ @@ -3396,7 +3372,7 @@ export function makeOpenCodeAdapterV2(options: OpenCodeAdapterV2Options): Provid }).pipe( Effect.mapError( (cause) => - new ProviderAdapterSteerRunError({ + new ProviderAdapter.ProviderAdapterSteerRunError({ driver: OPENCODE_PROVIDER, providerThreadId: steerInput.providerThread.id, providerTurnId: steerInput.providerTurnId, @@ -3455,7 +3431,7 @@ export function makeOpenCodeAdapterV2(options: OpenCodeAdapterV2Options): Provid }).pipe( Effect.mapError( (cause) => - new ProviderAdapterInterruptError({ + new ProviderAdapter.ProviderAdapterInterruptError({ driver: OPENCODE_PROVIDER, providerThreadId: interruptInput.providerThread.id, providerTurnId: interruptInput.providerTurnId, @@ -3477,7 +3453,10 @@ export function makeOpenCodeAdapterV2(options: OpenCodeAdapterV2Options): Provid `OpenCode question request ${requestInput.requestId} requires answers`, ); } - const answers = toOpenCodeQuestionAnswers(pending.question, requestInput.answers); + const answers = OpenCodeRuntime.toOpenCodeQuestionAnswers( + pending.question, + requestInput.answers, + ); yield* sdkCall( "question.reply", { requestID: pending.nativeRequestId, answers }, @@ -3497,7 +3476,7 @@ export function makeOpenCodeAdapterV2(options: OpenCodeAdapterV2Options): Provid `OpenCode approval request ${requestInput.requestId} requires a decision`, ); } - const reply = toOpenCodePermissionReply(requestInput.decision); + const reply = OpenCodeRuntime.toOpenCodePermissionReply(requestInput.decision); yield* sdkCall( "permission.reply", { requestID: pending.nativeRequestId, reply }, @@ -3513,7 +3492,7 @@ export function makeOpenCodeAdapterV2(options: OpenCodeAdapterV2Options): Provid }).pipe( Effect.mapError( (cause) => - new ProviderAdapterRuntimeRequestResponseError({ + new ProviderAdapter.ProviderAdapterRuntimeRequestResponseError({ driver: OPENCODE_PROVIDER, requestId: requestInput.requestId, cause, @@ -3524,7 +3503,7 @@ export function makeOpenCodeAdapterV2(options: OpenCodeAdapterV2Options): Provid readSnapshot(snapshotInput.providerThread).pipe( Effect.mapError( (cause) => - new ProviderAdapterReadThreadSnapshotError({ + new ProviderAdapter.ProviderAdapterReadThreadSnapshotError({ driver: OPENCODE_PROVIDER, providerThreadId: snapshotInput.providerThread.id, cause, @@ -3607,7 +3586,7 @@ export function makeOpenCodeAdapterV2(options: OpenCodeAdapterV2Options): Provid }).pipe( Effect.mapError( (cause) => - new ProviderAdapterRollbackThreadError({ + new ProviderAdapter.ProviderAdapterRollbackThreadError({ driver: OPENCODE_PROVIDER, providerThreadId: rollbackInput.providerThread.id, checkpointId: rollbackInput.target.checkpointId, @@ -3671,7 +3650,7 @@ export function makeOpenCodeAdapterV2(options: OpenCodeAdapterV2Options): Provid }).pipe( Effect.mapError( (cause) => - new ProviderAdapterForkThreadError({ + new ProviderAdapter.ProviderAdapterForkThreadError({ driver: OPENCODE_PROVIDER, providerThreadId: forkInput.sourceProviderThread.id, cause, @@ -3686,7 +3665,7 @@ export function makeOpenCodeAdapterV2(options: OpenCodeAdapterV2Options): Provid effect.pipe( Effect.mapError( (cause) => - new ProviderAdapterOpenSessionError({ + new ProviderAdapter.ProviderAdapterOpenSessionError({ driver: OPENCODE_PROVIDER, providerSessionId: input.providerSessionId, cause, @@ -3698,10 +3677,10 @@ export function makeOpenCodeAdapterV2(options: OpenCodeAdapterV2Options): Provid } export type OpenCodeAdapterV2DriverEnv = - | OpenCodeRuntime - | IdAllocatorV2 - | ProviderEventLoggers - | ServerConfig; + | OpenCodeRuntime.OpenCodeRuntime + | IdAllocator.IdAllocatorV2 + | ProviderEventLoggers.ProviderEventLoggers + | ServerConfig.ServerConfig; export const OpenCodeAdapterV2Driver: ProviderAdapterDriver< OpenCodeSettings, @@ -3713,10 +3692,10 @@ export const OpenCodeAdapterV2Driver: ProviderAdapterDriver< create: Effect.fn("OpenCodeAdapterV2Driver.create")( function* (input: ProviderAdapterDriverCreateInput) { const hostEnvironment = yield* HostProcessEnvironment; - const openCodeRuntime = yield* OpenCodeRuntime; - const idAllocator = yield* IdAllocatorV2; - const providerEventLoggers = yield* ProviderEventLoggers; - const serverConfig = yield* ServerConfig; + const openCodeRuntime = yield* OpenCodeRuntime.OpenCodeRuntime; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const providerEventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers; + const serverConfig = yield* ServerConfig.ServerConfig; return makeOpenCodeAdapterV2({ instanceId: input.instanceId, settings: { ...input.config, enabled: input.enabled }, @@ -3744,24 +3723,25 @@ export const OpenCodeAdapterV2Driver: ProviderAdapterDriver< ), }; -const layer: Layer.Layer = Layer.effect( - ProviderAdapterV2, - Effect.gen(function* () { - const hostEnvironment = yield* HostProcessEnvironment; - const openCodeRuntime = yield* OpenCodeRuntime; - const idAllocator = yield* IdAllocatorV2; - const providerEventLoggers = yield* ProviderEventLoggers; - const serverConfig = yield* ServerConfig; - return makeOpenCodeAdapterV2({ - instanceId: OPENCODE_DEFAULT_INSTANCE_ID, - settings: DEFAULT_OPENCODE_SETTINGS, - environment: hostEnvironment, - runtime: openCodeRuntime, - idAllocator, - serverConfig, - ...(providerEventLoggers.native === undefined - ? {} - : { nativeEventLogger: providerEventLoggers.native }), - }); - }), -); +const layer: Layer.Layer = + Layer.effect( + ProviderAdapter.ProviderAdapterV2, + Effect.gen(function* () { + const hostEnvironment = yield* HostProcessEnvironment; + const openCodeRuntime = yield* OpenCodeRuntime.OpenCodeRuntime; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const providerEventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers; + const serverConfig = yield* ServerConfig.ServerConfig; + return makeOpenCodeAdapterV2({ + instanceId: OPENCODE_DEFAULT_INSTANCE_ID, + settings: DEFAULT_OPENCODE_SETTINGS, + environment: hostEnvironment, + runtime: openCodeRuntime, + idAllocator, + serverConfig, + ...(providerEventLoggers.native === undefined + ? {} + : { nativeEventLogger: providerEventLoggers.native }), + }); + }), + ); diff --git a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts index 190478b65dd6..50f31477f21c 100644 --- a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts +++ b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts @@ -10,7 +10,7 @@ * `opencode/big-pickle` model; `OPENCODE2_MODEL` picks another. */ import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; -import { SourceControlProviderRegistry } from "../sourceControl/SourceControlProviderRegistry.ts"; +import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NodeSqlite from "node:sqlite"; import { assert, it } from "@effect/vitest"; @@ -37,27 +37,24 @@ import * as ResetCreditCoordinator from "../provider/Layers/resetCreditCoordinat import * as CheckpointStore from "../checkpointing/CheckpointStore.ts"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; import * as HostPowerMonitor from "../background/HostPowerMonitor.ts"; -import { ServerConfig } from "../config.ts"; +import * as ServerConfig from "../config.ts"; import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; -import { AntigravityInstallation } from "../provider/AntigravityInstallation.ts"; -import { CodexInstallation } from "../provider/CodexInstallation.ts"; -import { ServerEnvironmentIdentity } from "../environment/ServerEnvironment.ts"; +import * as AntigravityInstallation from "../provider/AntigravityInstallation.ts"; +import * as CodexInstallation from "../provider/CodexInstallation.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as ModelManifest from "../provider/ModelManifest.ts"; import { ProviderInstanceRegistryHydrationLive } from "../provider/Layers/ProviderInstanceRegistryHydration.ts"; -import { - NoOpProviderEventLoggers, - ProviderEventLoggers, -} from "../provider/Layers/ProviderEventLoggers.ts"; -import { OpenCodeRuntimeLive } from "../provider/opencodeRuntime.ts"; +import * as ProviderEventLoggers from "../provider/Layers/ProviderEventLoggers.ts"; +import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts"; import * as OpenCodeServerLedger from "../provider/OpenCodeServerLedger.ts"; -import { ServerSettingsService } from "../serverSettings.ts"; +import * as ServerSettings from "../serverSettings.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; -import { runDaemonWithOptions as runEffectWorkerDaemonWithOptions } from "./EffectWorker.ts"; -import { OrchestratorV2 } from "./Orchestrator.ts"; +import * as EffectWorker from "./EffectWorker.ts"; +import * as Orchestrator from "./Orchestrator.ts"; import { worktreeRepairDependenciesTestLayer } from "./ProviderTurnStartService.testkit.ts"; import { OrchestrationV2LayerLive } from "./runtimeLayer.ts"; -import { layer as mcpSessionRegistryTestLayer } from "../mcp/McpSessionRegistry.testkit.ts"; +import * as McpSessionRegistryTestkit from "../mcp/McpSessionRegistry.testkit.ts"; const binaryPath = process.env.OPENCODE2_BIN; const ROOT = process.env.OPENCODE2_LIVE_ROOT ?? ""; @@ -71,7 +68,9 @@ const SWITCHED_MODEL = "opencode/mimo-v2.6-flash-free"; const PlatformTestLayer = Layer.merge( NodeServices.layer, - Layer.mock(SourceControlProviderRegistry)({ resolveLink: () => Effect.die("unused title link") }), + Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ + resolveLink: () => Effect.die("unused title link"), + }), ); const serverConfigLayer = ServerConfig.layerTest(`${ROOT}/work`, { prefix: "t3-opencode2-live-" }); const vcsDriverRegistryLayer = VcsDriverRegistry.layer.pipe( @@ -80,7 +79,7 @@ const vcsDriverRegistryLayer = VcsDriverRegistry.layer.pipe( Layer.provide(PlatformTestLayer), ); // Isolated OpenCode state: the server never touches the developer's own data. -const serverSettingsLayer = ServerSettingsService.layerTest({ +const serverSettingsLayer = ServerSettings.layerTest({ providerInstances: { [INSTANCE]: { driver: ProviderDriverKind.make("opencode"), @@ -111,20 +110,25 @@ const providerInstanceRegistryLayer = ProviderInstanceRegistryHydrationLive.pipe ), NodeServices.layer, FetchHttpClient.layer, - OpenCodeRuntimeLive.pipe( + OpenCodeRuntime.OpenCodeRuntimeLive.pipe( Layer.provide(OpenCodeServerLedger.layerTest), Layer.provide(PlatformTestLayer), ), - Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers), + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), ModelManifest.layerTest, - AntigravityInstallation.layer.pipe( + AntigravityInstallation.AntigravityInstallation.layer.pipe( Layer.provide(serverConfigLayer.pipe(Layer.provide(PlatformTestLayer))), Layer.provide(FetchHttpClient.layer), Layer.provide(PlatformTestLayer), ), // The Codex driver now resolves managed ChatGPT installs; these runs never launch Codex. - Layer.mock(CodexInstallation)({ managedDirectory: "unused-managed-installation" }), - Layer.succeed(ServerEnvironmentIdentity, { + Layer.mock(CodexInstallation.CodexInstallation)({ + managedDirectory: "unused-managed-installation", + }), + Layer.succeed(ServerEnvironment.ServerEnvironmentIdentity, { getEnvironmentId: Effect.succeed( EnvironmentId.make("00000000-0000-4000-8000-000000000001"), ), @@ -134,7 +138,7 @@ const providerInstanceRegistryLayer = ProviderInstanceRegistryHydrationLive.pipe ); const liveLayer = OrchestrationV2LayerLive.pipe( Layer.provide(worktreeRepairDependenciesTestLayer), - Layer.provide(mcpSessionRegistryTestLayer), + Layer.provide(McpSessionRegistryTestkit.layer), Layer.provide(SqlitePersistenceMemory), Layer.provide(CheckpointStore.layer.pipe(Layer.provide(vcsDriverRegistryLayer))), Layer.provide(serverConfigLayer), @@ -155,7 +159,7 @@ const waitFor = Effect.fn("OpenCode2Live.waitFor")(function* ( threadId: ThreadId, done: (projection: OrchestrationV2ThreadProjection) => boolean, ) { - const orchestrator = yield* OrchestratorV2; + const orchestrator = yield* Orchestrator.OrchestratorV2; for (let attempt = 0; attempt < 240; attempt += 1) { const projection = yield* orchestrator.getThreadProjection(threadId); if (done(projection)) return projection; @@ -178,7 +182,7 @@ const send = Effect.fn("OpenCode2Live.send")(function* ( text: string, modelSelection: ModelSelection = MODEL, ) { - const orchestrator = yield* OrchestratorV2; + const orchestrator = yield* Orchestrator.OrchestratorV2; yield* orchestrator.dispatch({ type: "message.dispatch", createdBy: "user", @@ -226,8 +230,8 @@ describe.runIf(binaryPath !== undefined && ROOT !== "")("OpenCode 2 live orchest const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; yield* fs.writeFileString(path.join(ROOT, "work", "hello.txt"), "hello from t3 live\n"); - yield* runEffectWorkerDaemonWithOptions({ concurrency: 2 }).pipe(Effect.forkScoped); - const orchestrator = yield* OrchestratorV2; + yield* EffectWorker.runDaemonWithOptions({ concurrency: 2 }).pipe(Effect.forkScoped); + const orchestrator = yield* Orchestrator.OrchestratorV2; const threadId = ThreadId.make("thread:opencode2-live"); yield* orchestrator.dispatch({ type: "thread.create", diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/shared.ts b/apps/server/src/orchestration-v2/testkit/fixtures/shared.ts index 335f412a6862..ffcf4a646fb1 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/shared.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/shared.ts @@ -27,7 +27,7 @@ import type { OrchestratorV2ScenarioResult, OrchestratorV2ScenarioStep, } from "../OrchestratorScenario.ts"; -import { IdAllocatorV2, type IdAllocatorV2Error } from "../../IdAllocator.ts"; +import * as IdAllocator from "../../IdAllocator.ts"; import type { RuntimePolicyV2Override } from "../../RuntimePolicy.ts"; export const SIMPLE_PROMPT = "Respond with the following text: fixture simple ok"; @@ -474,9 +474,13 @@ export function materializeFixtureInput(input: { readonly fixtureInput: OrchestratorFixtureInput; readonly driver: ProviderDriverKind; readonly modelSelection: ModelSelection; -}): Effect.Effect { +}): Effect.Effect< + MaterializedOrchestratorFixtureInput, + IdAllocator.IdAllocatorV2Error, + IdAllocator.IdAllocatorV2 +> { return Effect.gen(function* () { - const idAllocator = yield* IdAllocatorV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; const projectId = yield* idAllocator.allocate.project({ fixtureName: input.scenario }); const threadId = yield* idAllocator.allocate.thread({ fixtureName: input.scenario,