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.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts new file mode 100644 index 000000000000..0315014f7972 --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -0,0 +1,832 @@ +/** + * 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 * 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"; + +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 } }; + +/** 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, + 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* () { + // No prompt is expected: the turn fails without reaching the server. + const { runtime, thread } = yield* resumed([]); + 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), + ); + + 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("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([ + out("event.subscribe"), + out("session.create", { + location: { directory: WORK }, + model: { providerID: "opencode", id: "big-pickle" }, + permissions: t3Rules, + }), + 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())), + ); + + /** 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("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([ + ...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([ + 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, "ProviderAdapterProtocolError"); + }).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("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([ + 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( + "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([ + 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.equal(again._tag, "ProviderAdapterProtocolError"); + assert.include(again.message, "not registered"); + }).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.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 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), + ); + + 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([ + 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("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, "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([ + 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* IdAllocator.IdAllocatorV2; + return ids.derive.providerTurn({ + driver: OPENCODE_PROVIDER, + nativeTurnId: `${SESSION}:attempt:attempt:opencode2-adapter`, + }); +}).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 new file mode 100644 index 000000000000..5f6a272bc3e9 --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts @@ -0,0 +1,234 @@ +/** + * 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, + ProviderSessionId, + ThreadId, + 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 * 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 * as ProviderAdapterRegistry 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 * as OpenCode2AdapterV2 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 (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)) }; + 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 }; + 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 } }; +}; + +/** 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), + ); + // 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(); + 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. + // `{ 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" } }); + }, + catch: (cause) => + new HttpClientError.HttpClientError({ + reason: new HttpClientError.TransportError({ request, cause }), + }), + }).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 yield* OpenCode2AdapterV2.make(ProviderInstanceId.make("opencode")).pipe( + Effect.provideService( + OpenCode2Server.OpenCode2Server, + OpenCode2Server.OpenCode2Server.of({ withConnection: (use) => use(connection) }), + ), + ); + }); + +const replayServerConfig = (scenario: string) => + 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) => ProviderAdapterRegistry.makeLayer([adapter])), + ), + ).pipe(Layer.provide(Layer.mergeAll(replayServerConfig(transcript.scenario), IdAllocator.layer))); +} + +/** + * 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"), IdAllocator.layer)), + ); + +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..b0a1ac26dc5b --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts @@ -0,0 +1,1233 @@ +/** + * 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: 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 + */ +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 Exit from "effect/Exit"; +import * as Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; + +import * as ServerConfig from "../../config.ts"; +import type { OpenCode2StreamEvent } from "../../provider/opencode2/OpenCode2Client.ts"; +import * as OpenCode2Server from "../../provider/opencode2/OpenCode2Server.ts"; +import { + parseOpenCodeModelSlug, + type OpenCodeRuntimeError, +} from "../../provider/opencodeRuntime.ts"; +import { buildRuntimeInstructions } from "../../provider/RuntimeInstructions.ts"; +import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; + +import { providerMessageTextWithAttachmentPaths } from "../AttachmentPrompt.ts"; +import * as IdAllocator from "../IdAllocator.ts"; +import { makeProviderFailure } from "../ProviderFailure.ts"; +import * as ProviderAdapter 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: true, + 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: ProviderAdapter.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; + /** + * 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 { + 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; + /** What the native session runs now, so a changed selection is switched before prompting. */ + model: ModelRef | undefined; + /** + * 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; +} + +/** One wording for every capability later layers add. */ +const notYet = (feature: string) => + new ProviderAdapter.ProviderAdapterProtocolError({ + driver: OPENCODE_PROVIDER, + detail: `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( + new ProviderAdapter.ProviderAdapterProtocolError({ + driver: OPENCODE_PROVIDER, + detail: `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"); + +/** + * 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; + +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."; + +// 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"; +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."; +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; + +// Errors already in the adapter channel keep their tag; only lower-level ones are wrapped. +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: ProviderAdapter.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 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 && + (left.variant ?? "default") === (right?.variant ?? "default"); + +/** + * The adapter for one provider instance. It talks to the instance's + * {@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.OpenCode2Server; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const serverConfig = yield* ServerConfig.ServerConfig; + const driver = OPENCODE_PROVIDER; + + const openSession = Effect.fn("OpenCode2Adapter.openSession")(function* ( + input: Parameters[0], + connection: OpenCode2Server.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: ProviderAdapter.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 }, + ); + }); + + /** + * 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, + }); + 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) { + // 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; + } + // 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; + 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); + } + 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; + // 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": + 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. + // 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"); + } + yield* setSessionStatus("error", message); + 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, + ); + + // 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: ModelRef | undefined, + ) => { + 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, + model, + unsettled: false, + }); + return providerThread; + }; + + const prompt = (turnInput: ProviderAdapter.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: ProviderAdapter.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, + threadId: threadInput.threadId, + modelSelection: threadInput.modelSelection, + runtimePolicy: threadInput.runtimePolicy, + }); + } + const model = modelRef(threadInput.modelSelection); + if (model === undefined) { + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver: OPENCODE_PROVIDER, + detail: malformedModel(threadInput.modelSelection.model), + }); + } + const created = yield* client.session.create({ + location: Location.PublicRef.make({ + directory: AbsolutePath.make(threadInput.runtimePolicy.cwd ?? serverConfig.cwd), + }), + model, + permissions: SESSION_PERMISSIONS, + }); + 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, created.model); + }).pipe( + Effect.mapError((cause) => + isProviderAdapterError(cause) + ? cause + : new ProviderAdapter.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. + 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) { + yield* client.session.move({ + sessionID: Session.ID.make(sessionId), + directory: AbsolutePath.make(cwd), + }); + } + return register( + { + ...threadInput.providerThread, + providerSessionId: input.providerSessionId, + status: "idle", + updatedAt: yield* DateTime.now, + }, + sessionId, + native.model, + ); + }).pipe( + Effect.mapError((cause) => + isProviderAdapterError(cause) + ? cause + : new ProviderAdapter.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* new ProviderAdapter.ProviderAdapterProtocolError({ + driver: OPENCODE_PROVIDER, + detail: `OpenCode session ${sessionId} is not registered`, + }); + } + if (state.active !== undefined) { + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver: OPENCODE_PROVIDER, + 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; + 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, + awaitingStart: afterUnsettled, + }; + // No stream is left to end this turn, so it must not start. + if (streamFailure !== undefined) { + return yield* new ProviderAdapter.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; + }); + 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. + 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. + if (!sameModel(model, state.model)) { + yield* client.session.switchModel({ sessionID: Session.ID.make(sessionId), model }); + state.model = model; + } + const turn = yield* begin; + yield* client.session + .prompt({ sessionID: Session.ID.make(sessionId), text: prompt(turnInput) }) + .pipe( + // 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, + { + 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 + ? 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, + ), + ); + }).pipe( + Effect.mapError((cause) => + isProviderAdapterError(cause) + ? cause + : new ProviderAdapter.ProviderAdapterTurnStartError({ + driver, + threadId: turnInput.threadId, + providerThreadId: turnInput.providerThread.id, + runId: turnInput.runId, + cause, + }), + ), + ), + steerTurn: (steerInput) => + Effect.fail( + new ProviderAdapter.ProviderAdapterSteerRunUnsupportedError({ + driver, + providerThreadId: steerInput.providerThread.id, + }), + ), + interruptTurn: (interruptInput) => + Effect.gen(function* () { + const sessionId = yield* sessionIdOf(interruptInput.providerThread); + 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; + const reply = yield* client.session + .interrupt({ sessionID: Session.ID.make(sessionId) }) + .pipe( + Effect.timeoutOption(INTERRUPT_TIMEOUT), + // A Stop that never reached the server stopped nothing. + Effect.tapError(() => + Effect.sync(() => { + turn.interrupted = false; + }), + ), + ); + if (reply._tag === "None") { + state.unsettled = true; + 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* new ProviderAdapter.ProviderAdapterProtocolError({ + driver: OPENCODE_PROVIDER, + detail: `OpenCode session ${sessionId} had nothing running to stop`, + }); + } + }).pipe( + Effect.mapError((cause) => + isProviderAdapterError(cause) + ? cause + : new ProviderAdapter.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 ProviderAdapter.ProviderAdapterRuntimeRequestResponseError({ + driver, + requestId: requestInput.requestId, + cause: notYet("answering runtime requests"), + }), + ), + readThreadSnapshot: ({ providerThread }) => + Effect.fail( + new ProviderAdapter.ProviderAdapterReadThreadSnapshotError({ + driver, + providerThreadId: providerThread.id, + cause: notYet("history snapshots"), + }), + ), + rollbackThread: (rollbackInput) => + Effect.fail( + new ProviderAdapter.ProviderAdapterRollbackThreadError({ + driver, + providerThreadId: rollbackInput.providerThread.id, + checkpointId: rollbackInput.target.checkpointId, + cause: notYet("rollback"), + }), + ), + forkThread: (forkInput) => + Effect.fail( + new ProviderAdapter.ProviderAdapterForkThreadError({ + driver, + providerThreadId: forkInput.sourceProviderThread.id, + cause: notYet("fork"), + }), + ), + }; + return runtime; + }); + + return ProviderAdapter.ProviderAdapterV2.of({ + 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< + OpenCode2Server.OpenCode2Connection, + OpenCodeRuntimeError + >(); + yield* 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 ProviderAdapter.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..74e4e8948306 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,21 @@ class OpenCodeReplayController { } } - async *events(signal?: AbortSignal): AsyncIterable { + /** + * 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; this.throwFailure(); @@ -225,7 +244,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..98203a7f91af 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"; @@ -56,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, @@ -67,49 +66,21 @@ 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, 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); @@ -301,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; @@ -463,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; } @@ -487,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; @@ -531,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 }), @@ -570,23 +544,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 +590,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", @@ -692,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"); @@ -801,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); @@ -902,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"]; @@ -1005,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.`, }); @@ -1013,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({ @@ -1043,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: { @@ -1069,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>(); @@ -1100,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({ @@ -1115,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", @@ -1140,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) => @@ -1664,86 +1593,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, @@ -1867,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) => ({ @@ -2833,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( @@ -2848,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", @@ -2953,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 }), @@ -3061,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]) @@ -3119,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, @@ -3132,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, @@ -3188,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, @@ -3214,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, @@ -3244,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`, @@ -3421,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, @@ -3446,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`, @@ -3457,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({ @@ -3515,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, @@ -3574,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, @@ -3596,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 }, @@ -3616,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 }, @@ -3632,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, @@ -3643,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, @@ -3726,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, @@ -3790,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, @@ -3805,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, @@ -3817,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, @@ -3832,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 }, @@ -3863,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/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.integration.test.ts b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts new file mode 100644 index 000000000000..1f0084540289 --- /dev/null +++ b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts @@ -0,0 +1,324 @@ +/** + * 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 T3_RULES = [ + { action: "*", resource: "*", effect: "allow" }, + { action: "subagent", resource: "*", effect: "deny" }, +]; +const sessionInfo = (directory: string, permissions: ReadonlyArray = T3_RULES) => ({ + 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 }, + permissions, + }, +}); +/** 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("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"; + 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 new file mode 100644 index 000000000000..50f31477f21c --- /dev/null +++ b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts @@ -0,0 +1,330 @@ +/** + * 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 * 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"; +import { + CommandId, + EnvironmentId, + MessageId, + type ModelSelection, + type OrchestrationV2ThreadProjection, + ProjectId, + ProviderDriverKind, + 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 * as Schema from "effect/Schema"; +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 * as ServerConfig from "../config.ts"; +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.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 * as ProviderEventLoggers from "../provider/Layers/ProviderEventLoggers.ts"; +import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts"; +import * as OpenCodeServerLedger from "../provider/OpenCodeServerLedger.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; +import * as VcsProcess from "../vcs/VcsProcess.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 * as McpSessionRegistryTestkit 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", +}; +// The free model the thread switches to mid-conversation. +const SWITCHED_MODEL = "opencode/mimo-v2.6-flash-free"; + +const PlatformTestLayer = Layer.merge( + NodeServices.layer, + 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( + Layer.provide(VcsProcess.layer), + Layer.provide(serverConfigLayer), + Layer.provide(PlatformTestLayer), +); +// Isolated OpenCode state: the server never touches the developer's own data. +const serverSettingsLayer = ServerSettings.layerTest({ + providerInstances: { + [INSTANCE]: { + driver: ProviderDriverKind.make("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 }, + }, + }, +}); +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, + OpenCodeRuntime.OpenCodeRuntimeLive.pipe( + Layer.provide(OpenCodeServerLedger.layerTest), + Layer.provide(PlatformTestLayer), + ), + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ModelManifest.layerTest, + 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.CodexInstallation)({ + managedDirectory: "unused-managed-installation", + }), + Layer.succeed(ServerEnvironment.ServerEnvironmentIdentity, { + getEnvironmentId: Effect.succeed( + EnvironmentId.make("00000000-0000-4000-8000-000000000001"), + ), + }), + ), + ), +); +const liveLayer = OrchestrationV2LayerLive.pipe( + Layer.provide(worktreeRepairDependenciesTestLayer), + Layer.provide(McpSessionRegistryTestkit.layer), + 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* Orchestrator.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, + modelSelection: ModelSelection = MODEL, +) { + const orchestrator = yield* Orchestrator.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, + 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", + () => + 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* 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", + 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"); + + // 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/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..867d9f55cb94 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,55 @@ 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..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"; @@ -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,13 @@ export const OPENCODE_MODEL_SELECTION = { options: [{ id: "agent", value: "build" }], } satisfies ModelSelection; +/** The free OpenCode Zen model and variant the OpenCode 2 spike recorded its reasoning run with. */ +export const OPENCODE2_MODEL_SELECTION = { + instanceId: ProviderInstanceId.make("opencode"), + model: "opencode/space-bunny-free", + options: [{ id: "variant", value: "high" }], +} 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"), @@ -460,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, 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..f84192842152 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.ts @@ -25,15 +25,9 @@ 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 { - ProviderAdapterCapabilitiesError, - ProviderAdapterOpenSessionError, - type ProviderAdapterV2Shape, -} from "../../orchestration-v2/ProviderAdapter.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"; import { ProviderDriverError } from "../Errors.ts"; @@ -48,11 +42,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,86 +88,83 @@ 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"), + }), }; } export type OpenCodeDriverEnv = - | OpenCodeAdapterV2DriverEnv + | OpenCodeAdapterV2.OpenCodeAdapterV2DriverEnv | BackgroundPolicy.BackgroundPolicy | ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto @@ -230,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, @@ -248,9 +240,28 @@ 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: yield* OpenCode2AdapterV2.make(instanceId).pipe( + Effect.provideService(OpenCode2Server.OpenCode2Server, openCode2Server), + ), }); const serverOwner = yield* OpenCodeServerOwner.make({ binaryPath: effectiveConfig.binaryPath, @@ -389,32 +400,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..4dfcfad26e30 --- /dev/null +++ b/apps/server/src/provider/opencode2/OpenCode2Client.events.test.ts @@ -0,0 +1,194 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +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 { TestClock } from "effect/testing"; +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" }); +}); + +/** Sends `events`, then keeps the connection open without another byte, like a frozen server. */ +const servingThenSilent = (events: 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 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.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", () => + 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..8c62ebafba60 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,9 +100,11 @@ 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.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.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..d42b9e0cef86 100644 --- a/apps/server/src/provider/opencode2/OpenCode2Client.ts +++ b/apps/server/src/provider/opencode2/OpenCode2Client.ts @@ -1,27 +1,165 @@ 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. */ +/** + * 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: (typeof EXECUTION_ENDS)[number]; + 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", + "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", + {}, +) { + override get message(): string { + return "The OpenCode server stopped sending events."; + } +} + +export type OpenCode2StreamEvent = + | OpenCodeEvent + | OpenCode2UnreadableTerminal + | OpenCode2UnreadableStart; + +/** + * 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, except an execution's end or start, which it + * keeps as an {@link OpenCode2UnreadableTerminal} or {@link OpenCode2UnreadableStart}. + * `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< + OpenCode2StreamEvent, + HttpClientError.HttpClientError | Sse.Retry | Sse.SseError | OpenCode2SilentStreamError + >, + 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") {} +/** + * 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)); +// 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) })), + }), + ), +); + +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 (isExecutionEnd(type) && 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, + }); + } + 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; + }); + +/** 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)) + : undecodable(frame.data); + }), + ), + ), + ); + /** * OpenCode decodes Basic credentials as UTF-8. `HttpClientRequest.basicAuth` * encodes them as Latin-1 (`btoa`), which gets non-ASCII passwords rejected. @@ -35,16 +173,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