diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 08307ad65c2ac6c5c048b57aa9ae85d2feb690b1..02ba1b20ae859c7a7dd833f2241fc67cd3a1e78c 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -71,6 +71,7 @@ "@t3tools/mobile-terminal-native": "file:./modules/t3-terminal", "@t3tools/provider-core": "workspace:*", "@t3tools/provider-muse": "workspace:*", + "@t3tools/provider-opencode": "workspace:*", "@t3tools/provider-pi": "workspace:*", "@t3tools/shared": "workspace:*", "@tabler/icons-react-native": "^3.44.0", diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx index 0645e673981817cdd1b4eae9340d43c9f9949b45..dda6e6f3b544c2bbe559a2c3c1f5a906b36a2a7b 100644 --- a/apps/mobile/src/components/ProviderIcon.tsx +++ b/apps/mobile/src/components/ProviderIcon.tsx @@ -138,15 +138,6 @@ export function ProviderIcon(props: ProviderIconProps) { ); } - if (props.provider === "opencode") { - return ( - - - - - ); - } - // codex (and unknown drivers) return ( diff --git a/apps/mobile/src/lib/providerClients.ts b/apps/mobile/src/lib/providerClients.ts index 2d3ec1224992afbee93798b67c5865208f17a392..11f7e13a3a78dcf6f44ea15cf350deee24b4745a 100644 --- a/apps/mobile/src/lib/providerClients.ts +++ b/apps/mobile/src/lib/providerClients.ts @@ -1,10 +1,11 @@ import { isProviderDriverKind } from "@t3tools/contracts"; import { makeProviderClientRegistry } from "@t3tools/provider-core/client"; import { museClient } from "@t3tools/provider-muse/client"; +import { openCodeClient } from "@t3tools/provider-opencode/client"; import { piClient } from "@t3tools/provider-pi/client"; /** The provider client definitions this mobile build ships. */ -const providerClients = makeProviderClientRegistry([museClient, piClient]); +const providerClients = makeProviderClientRegistry([museClient, openCodeClient, piClient]); /** The client definition for a driver kind, or `undefined` for drivers drawn by hand. */ export function getProviderClient(driver: string | null | undefined) { diff --git a/apps/server/package.json b/apps/server/package.json index bb776149e4cd55099e572c832342ef2642029ab3..11f202120f85a6d19160255608e90e8c08d7b334 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -38,11 +38,9 @@ "@napi-rs/keyring": "^1.3.0", "@noble/hashes": "catalog:", "@opencode-ai/sdk": "^1.3.15", - "@opencode/client": "2.0.23", - "@opencode/protocol": "2.0.23", - "@opencode/schema": "2.0.23", "@t3tools/provider-core": "workspace:*", "@t3tools/provider-muse": "workspace:*", + "@t3tools/provider-opencode": "workspace:*", "@t3tools/provider-pi": "workspace:*", "diff": "8.0.3", "effect": "catalog:", diff --git a/apps/server/src/atomicWrite.test.ts b/apps/server/src/atomicWrite.test.ts deleted file mode 100644 index 28cc1e3ca8c5be77f2e14a5594f06c6c59eb1b4b..0000000000000000000000000000000000000000 --- a/apps/server/src/atomicWrite.test.ts +++ /dev/null @@ -1,185 +0,0 @@ -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; -import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; -import * as Path from "effect/Path"; -import * as PlatformError from "effect/PlatformError"; - -import { writeFileStringAtomically } from "./atomicWrite.ts"; - -it.layer(NodeServices.layer)("writeFileStringAtomically", (it) => { - it.effect("keeps a symlinked file linked and rewrites its destination", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-atomic-write-" }); - const destination = path.join(root, "dotfiles", "settings.json"); - const link = path.join(root, "home", "settings.json"); - yield* fs.makeDirectory(path.dirname(destination), { recursive: true }); - yield* fs.makeDirectory(path.dirname(link), { recursive: true }); - yield* fs.writeFileString(destination, "before"); - yield* fs.symlink(destination, link); - - yield* writeFileStringAtomically({ filePath: link, contents: "after" }); - - assert.strictEqual(yield* fs.readLink(link), destination); - assert.strictEqual(yield* fs.readFileString(destination), "after"); - }), - ); - - it.effect("keeps a dangling symlink linked and creates its destination", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-atomic-write-" }); - const destination = path.join(root, "dotfiles", "settings.json"); - const link = path.join(root, "home", "settings.json"); - yield* fs.makeDirectory(path.dirname(link), { recursive: true }); - yield* fs.symlink(destination, link); - - yield* writeFileStringAtomically({ filePath: link, contents: "fresh" }); - - assert.strictEqual(yield* fs.readLink(link), destination); - assert.strictEqual(yield* fs.readFileString(destination), "fresh"); - }), - ); - - it.effect("fails on a symlink cycle without replacing either link", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-atomic-write-" }); - const first = path.join(root, "first.json"); - const second = path.join(root, "second.json"); - yield* fs.symlink(second, first); - yield* fs.symlink(first, second); - - const result = yield* Effect.exit( - writeFileStringAtomically({ filePath: first, contents: "after" }), - ); - - assert.isTrue(Exit.isFailure(result)); - assert.strictEqual(yield* fs.readLink(first), second); - assert.strictEqual(yield* fs.readLink(second), first); - }), - ); - - it.effect("resolves a relative link through a symlinked parent directory", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-atomic-write-" }); - const destination = path.join(root, "dotfiles", "config", "settings.json"); - const linkedState = path.join(root, "dotfiles", "state"); - const home = path.join(root, "home"); - const link = path.join(home, "state", "settings.json"); - yield* fs.makeDirectory(path.dirname(destination), { recursive: true }); - yield* fs.makeDirectory(linkedState, { recursive: true }); - yield* fs.makeDirectory(home, { recursive: true }); - yield* fs.symlink(linkedState, path.join(home, "state")); - yield* fs.writeFileString(destination, "before"); - yield* fs.symlink("../config/settings.json", link); - - yield* writeFileStringAtomically({ filePath: link, contents: "after" }); - - assert.strictEqual(yield* fs.readLink(link), "../config/settings.json"); - assert.strictEqual(yield* fs.readFileString(destination), "after"); - }), - ); - - it.effect("creates a missing file and its directory", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-atomic-write-" }); - const filePath = path.join(root, "nested", "settings.json"); - - yield* writeFileStringAtomically({ filePath, contents: "fresh" }); - - assert.strictEqual(yield* fs.readFileString(filePath), "fresh"); - }), - ); -}); - -it.effect("surfaces an unreadable link instead of writing over it", () => - Effect.gen(function* () { - const readLinkFailure = PlatformError.systemError({ - _tag: "Unknown", - module: "FileSystem", - method: "readLink", - pathOrDescriptor: "/home/settings.json", - }); - - const result = yield* Effect.exit( - writeFileStringAtomically({ filePath: "/home/settings.json", contents: "after" }), - ); - - assert.deepStrictEqual(result, Exit.fail(readLinkFailure)); - }).pipe( - Effect.provide( - Layer.mergeAll( - Path.layer, - FileSystem.layerNoop({ - readLink: () => - Effect.fail( - PlatformError.systemError({ - _tag: "Unknown", - module: "FileSystem", - method: "readLink", - pathOrDescriptor: "/home/settings.json", - }), - ), - rename: () => Effect.die("an unreadable link must not be replaced"), - }), - ), - ), - ), -); - -it.effect("succeeds when the write lands but its temp directory cannot be removed", () => - Effect.gen(function* () { - const renamed: Array = []; - const removed: Array = []; - const fileSystem = FileSystem.layerNoop({ - // The target does not exist yet, so it is written in place. - readLink: (path) => - Effect.fail( - PlatformError.systemError({ - _tag: "NotFound", - module: "FileSystem", - method: "readLink", - pathOrDescriptor: path, - }), - ), - makeDirectory: () => Effect.void, - makeTempDirectory: () => Effect.succeed("/home/settings.json.abc123"), - writeFileString: () => Effect.void, - rename: (_from, to) => Effect.sync(() => void renamed.push(to)), - remove: (path) => - Effect.sync(() => void removed.push(path)).pipe( - Effect.andThen( - Effect.fail( - PlatformError.systemError({ - _tag: "PermissionDenied", - module: "FileSystem", - method: "remove", - pathOrDescriptor: path, - }), - ), - ), - ), - }); - - const result = yield* Effect.exit( - writeFileStringAtomically({ filePath: "/home/settings.json", contents: "after" }).pipe( - Effect.provide(Layer.mergeAll(Path.layer, fileSystem)), - ), - ); - - assert.deepStrictEqual(result, Exit.void); - assert.deepStrictEqual(renamed, ["/home/settings.json"]); - assert.deepStrictEqual(removed, ["/home/settings.json.abc123"]); - }), -); diff --git a/apps/server/src/atomicWrite.ts b/apps/server/src/atomicWrite.ts deleted file mode 100644 index bdceff501d6af63a516266bd92b22f2815d22a8b..0000000000000000000000000000000000000000 --- a/apps/server/src/atomicWrite.ts +++ /dev/null @@ -1,38 +0,0 @@ -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Path from "effect/Path"; -import { resolveSymlinkTarget } from "@t3tools/shared/symlink"; - -/** - * Replaces a file's contents via a sibling temp file and rename. A symlinked - * target is resolved first so the link survives and its destination is - * rewritten, since renaming over the link itself would swap it for a regular file. - */ -export const writeFileStringAtomically = (input: { - readonly filePath: string; - readonly contents: string; -}) => - Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const targetPath = yield* resolveSymlinkTarget(input.filePath); - const targetDirectory = path.dirname(targetPath); - - yield* fs.makeDirectory(targetDirectory, { recursive: true }); - // The temp directory is cleanup, not part of the write: failing to remove - // it (a virus scanner holding it on Windows) must not fail a write that - // already landed. - const tempDirectory = yield* Effect.acquireRelease( - fs.makeTempDirectory({ - directory: targetDirectory, - prefix: `${path.basename(targetPath)}.`, - }), - (directory) => fs.remove(directory, { recursive: true }).pipe(Effect.ignore({ log: true })), - ); - const tempPath = path.join(tempDirectory, "contents.tmp"); - - yield* fs.writeFileString(tempPath, input.contents); - yield* fs.rename(tempPath, targetPath); - }), - ); diff --git a/apps/server/src/cli/theme.ts b/apps/server/src/cli/theme.ts index 13f451b42363d6d9b26513dc0121e436f630fe9a..2c05c00f9a4e366c30cc90b6289483f40f93a315 100644 --- a/apps/server/src/cli/theme.ts +++ b/apps/server/src/cli/theme.ts @@ -35,7 +35,7 @@ import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { Argument, Command, Flag } from "effect/cli"; -import { writeFileStringAtomically } from "../atomicWrite.ts"; +import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; import * as ServerConfig from "../config.ts"; import { MAX_THEME_FILE_BYTES, diff --git a/apps/server/src/device/LocalDeviceHost.ts b/apps/server/src/device/LocalDeviceHost.ts index 65ddf6895742ecdd9eff0d2537981a4639d1d7ce..58179151877c25376458b4b27a20cd42fcf32b10 100644 --- a/apps/server/src/device/LocalDeviceHost.ts +++ b/apps/server/src/device/LocalDeviceHost.ts @@ -47,7 +47,7 @@ import { HttpClient } from "effect/http"; import * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; -import { writeFileStringAtomically } from "../atomicWrite.ts"; +import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; import * as ServerConfig from "../config.ts"; import * as ProcessRunner from "../processRunner.ts"; import * as DeviceHost from "./DeviceHost.ts"; diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index 3697b62cb403d4500d64927d660777057be3be2c..332aa7134bd0dd8e9f198efa580bb63982aeb9a8 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -41,7 +41,7 @@ import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as Semaphore from "effect/Semaphore"; import * as ServerConfig from "./config.ts"; -import { writeFileStringAtomically } from "./atomicWrite.ts"; +import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson"; import { DEFAULT_KEYBINDINGS, diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 78d4ee48e66645e040c6de3c9cfa9a03d1d278a1..b218a76d1ab5033f14e63b65ac1cfc2a642c3185 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -75,7 +75,7 @@ import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterReg import { subagentResultForRun, delegatedTaskProgress, -} from "../orchestration-v2/SubagentProjection.ts"; +} from "@t3tools/provider-core/server/subagentProjection"; import { DispatchModeLimit, type DispatchModeRefusal, diff --git a/apps/server/src/orchestration-v2/AcpRegistryOrchestratorV2.live.test.ts b/apps/server/src/orchestration-v2/AcpRegistryOrchestratorV2.live.test.ts index 2eb06f7570529863e79a2c4e4576e07dcda6d787..4f2ea3e2f3d61e0d6d9c52b0a8c55058e4094690 100644 --- a/apps/server/src/orchestration-v2/AcpRegistryOrchestratorV2.live.test.ts +++ b/apps/server/src/orchestration-v2/AcpRegistryOrchestratorV2.live.test.ts @@ -30,8 +30,8 @@ import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as ModelManifest from "../provider/ModelManifest.ts"; import * as ProviderInstanceRegistryHydration from "../provider/ProviderInstanceRegistryHydration.ts"; import * as ProviderEventLoggers from "../provider/ProviderEventLoggers.ts"; -import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts"; -import * as OpenCodeServerLedger from "../provider/OpenCodeServerLedger.ts"; +import * as OpenCodeRuntime from "@t3tools/provider-opencode/server/OpenCodeRuntime"; +import * as OpenCodeServerLedger from "@t3tools/provider-opencode/server/OpenCodeServerLedger"; import * as ServerSettings from "../serverSettings.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts index 90255fe75a78701fe5842be70694d55122a4fb43..03a5c629b66302c3dcf3d02971a57fea2de3b6e3 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts @@ -119,7 +119,7 @@ import { makeSubagentChildThread, makeSubagentConversationArtifacts, subagentThreadTitle, -} from "../SubagentProjection.ts"; +} from "@t3tools/provider-core/server/subagentProjection"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; export const ACP_PROTOCOL = "acp.ndjson-jsonrpc" as const; diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 50fab3e191dba7f5e4255b5f0375c1add9faf867..4cd45b86df7f9e6ca747782ab901915959d92ad3 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -140,7 +140,7 @@ import { makeSubagentChildThread, makeSubagentConversationArtifacts, subagentThreadTitle, -} from "../SubagentProjection.ts"; +} from "@t3tools/provider-core/server/subagentProjection"; export const CLAUDE_PROVIDER = ProviderDriverKind.make("claudeAgent"); export const CLAUDE_AGENT_SDK_QUERY_PROTOCOL = "claude-agent-sdk.query" as const; diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 5318971e89d4e7997209d65f8d4d4ec58d848d07..c8b98828939027d814389dc068db5b48b8b687fa 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -165,7 +165,7 @@ import { makeSubagentChildThread, makeSubagentConversationArtifacts, subagentThreadTitle, -} from "../SubagentProjection.ts"; +} from "@t3tools/provider-core/server/subagentProjection"; const CODEX_PROVIDER = ProviderDriverKind.make("codex"); export const CODEX_DRIVER_KIND = CODEX_PROVIDER; diff --git a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts index 53a1d260cc838d199857a293e5ee75722bb13a5a..273564e786c4949dd90b0814471accf62ea601d7 100644 --- a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts @@ -73,7 +73,7 @@ import { makeSubagentChildThread, makeSubagentConversationArtifacts, subagentThreadTitle, -} from "../SubagentProjection.ts"; +} from "@t3tools/provider-core/server/subagentProjection"; import * as CursorAgentSdk from "./CursorAgentSdk.ts"; export { cursorSdkModelSelection } from "../../provider/cursorSdkModel.ts"; diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts index 96b28d5e082e8ece871184103f64cb3289ecc53c..ff87c347641e5f6be84e47ac57330fddaecf7d5a 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -46,8 +46,11 @@ import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import type { ProviderContinuationRequest } from "@t3tools/provider-core/server/continuationRequests"; import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; -import { OPENCODE_PROVIDER } from "./OpenCodeAdapterV2.ts"; -import { OPENCODE_2_STILL_STOPPING, t3McpServerName } from "./OpenCode2AdapterV2.ts"; +import { + OPENCODE_2_STILL_STOPPING, + OPENCODE_PROVIDER, + t3McpServerName, +} from "@t3tools/provider-opencode/testing"; import { openCode2ReplayRuntime } from "./OpenCode2AdapterV2.testkit.ts"; const SESSION = "ses_f148ca2deffeJcwCnRQtb0YFNX"; diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts index 466349cc37ff73e2ecdac5b882cd345b93132768..2b009799cc5343cc5d8a45a4acd44fcb1aafe802 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts @@ -20,22 +20,19 @@ import * as HttpClientError from "effect/http/HttpClientError"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; import * as UrlParams from "effect/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 OpenCode2Client from "@t3tools/provider-opencode/server/v2/OpenCode2Client"; +import * as OpenCode2Server from "@t3tools/provider-opencode/server/v2/OpenCode2Server"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts"; import type { ProviderReplayGate } from "@t3tools/provider-testing/replayGate"; -import { - makeReplayServerConfig, - type OrchestratorV2ProviderReplayHarness, -} from "../testkit/ProviderReplayHarness.ts"; -import { OPENCODE_PROVIDER } from "./OpenCodeAdapterV2.ts"; +import type { OrchestratorV2ProviderReplayHarness } from "../testkit/ProviderReplayHarness.ts"; +import { layerTestProviderHost } from "@t3tools/provider-testing/host"; +import { OPENCODE_PROVIDER } from "@t3tools/provider-opencode/testing"; import { OpenCodeReplayController, OpenCodeReplayTranscriptDecodeError, } from "./OpenCodeAdapterV2.testkit.ts"; -import * as OpenCode2AdapterV2 from "./OpenCode2AdapterV2.ts"; +import * as OpenCode2Adapter from "@t3tools/provider-opencode/server/v2/adapter"; export const OPENCODE2_HTTP_PROTOCOL = "opencode2-http.sse" as const; const BASE_URL = "http://opencode2.replay"; @@ -265,15 +262,12 @@ const makeReplayAdapter = ( ) => Effect.gen(function* () { const server = yield* replayServer(transcript, options); - return yield* OpenCode2AdapterV2.make(ProviderInstanceId.make("opencode")).pipe( + return yield* OpenCode2Adapter.make(ProviderInstanceId.make("opencode")).pipe( Effect.provideService(OpenCode2Server.OpenCode2Server, server), ); }); -const layerReplayServerConfig = (scenario: string) => - Layer.effect(ServerConfig.ServerConfig, makeReplayServerConfig(scenario).pipe(Effect.orDie)).pipe( - Layer.provide(NodeServices.layer), - ); +const layerReplayHost = layerTestProviderHost().pipe(Layer.provide(NodeServices.layer)); function layerRegistry( transcript: OpenCode2ReplayTranscript, @@ -283,15 +277,7 @@ function layerRegistry( makeReplayAdapter(transcript, { external: true, ...options }).pipe( Effect.map((adapter) => ProviderAdapterRegistry.layerFromAdapters([adapter])), ), - ).pipe( - Layer.provide( - Layer.mergeAll( - layerReplayServerConfig(transcript.scenario), - IdAllocator.layer, - NodeServices.layer, - ), - ), - ); + ).pipe(Layer.provide(Layer.mergeAll(layerReplayHost, IdAllocator.layer, NodeServices.layer))); } /** @@ -327,15 +313,7 @@ export const openCode2ReplayRuntime = ( cwd: "/work/opencode2", }, }); - }).pipe( - Effect.provide( - Layer.mergeAll( - layerReplayServerConfig("opencode2_adapter"), - IdAllocator.layer, - NodeServices.layer, - ), - ), - ); + }).pipe(Effect.provide(Layer.mergeAll(layerReplayHost, IdAllocator.layer, NodeServices.layer))); export const OpenCode2OrchestratorReplayHarness: OrchestratorV2ProviderReplayHarness< OpenCode2ReplayTranscript, diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts deleted file mode 100644 index 6bc194b811a40fac09725f48863907cb8cc49eb1..0000000000000000000000000000000000000000 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts +++ /dev/null @@ -1,4291 +0,0 @@ -/** - * 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` (or `session.command`, or `session.compact` - * for `/compact`); the session's next `session.execution.*` terminal ends it. - * A steer is another prompt with `delivery: "steer"`, which OpenCode reads at - * the running execution's next step boundary, so it ends with the turn it - * joined. Fork and rollback cut the native history before the user message a - * turn prompted with (its `nativeTurnRef`). Each runtime mode is a set of - * session permission rules, plan mode is also OpenCode's `plan` agent - * (switched before the prompt like the model), and OpenCode's permission asks - * and question forms become runtime requests on the asking session's thread, a - * subagent's on its parent's. - * - * A `subagent` call runs in a child session shown as a subagent thread. A - * background one outlives its turn; when it ends, OpenCode starts a parent - * execution T3 did not ask for, which waits here for the continuation turn T3 - * opens for it. - * - * @module orchestration-v2/Adapters/OpenCode2AdapterV2 - */ - -import { - AbsolutePath, - Agent, - Form, - Location, - Model, - Permission, - Provider, - Session, - SessionMessage, - Skill, - type OpenCodeEvent, -} from "@opencode/client/effect"; -import { Mcp } from "@opencode/schema/mcp"; -import { - isOrchestrationV2WorkActive, - type OrchestrationV2AppThread, - type OrchestrationV2ConversationMessage, - type OrchestrationV2ExecutionNode, - type OrchestrationV2ProviderCapabilities, - type OrchestrationV2ProviderSession, - type OrchestrationV2ProviderThread, - type OrchestrationV2ProviderTurn, - type OrchestrationV2RuntimeRequest, - type OrchestrationV2Subagent, - type OrchestrationV2TurnItem, - type OrchestrationV2UserInputQuestion, - type ModelSelection, - type ProviderApprovalDecision, - type ProviderInstanceId, - type RunId, - type RuntimeRequestId, -} from "@t3tools/contracts"; -import type * as Cause from "effect/Cause"; -import * as DateTime from "effect/DateTime"; -import * as Deferred from "effect/Deferred"; -import * as Crypto from "effect/Crypto"; -import * as Effect from "effect/Effect"; -import * as Hex from "effect/encoding/Hex"; -import * as Exit from "effect/Exit"; -import * as Option from "effect/Option"; -import * as Queue from "effect/Queue"; -import * as Schedule from "effect/Schedule"; -import * as Scope from "effect/Scope"; -import * as Schema from "effect/Schema"; -import * as Semaphore from "effect/Semaphore"; -import * as Stream from "effect/Stream"; - -import { resolveAttachmentPath } from "../../attachmentStore.ts"; -import * as ServerConfig from "../../config.ts"; -import { paginate, type OpenCode2StreamEvent } from "../../provider/opencode2/OpenCode2Client.ts"; -import * as OpenCode2Server from "../../provider/opencode2/OpenCode2Server.ts"; -import { - parseOpenCodeModelSlug, - type OpenCodeRuntimeError, -} from "../../provider/opencodeRuntime.ts"; -import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; -import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions"; -import { t3OrchestrationSystemPrompt } from "@t3tools/provider-core/server/orchestrationInstructions"; -import { SKILL_MENTION_PATTERN } from "@t3tools/shared/composerInlineTokens"; -import * as KeyedLock from "@t3tools/shared/KeyedLock"; -import { getModelSelectionStringOptionValue, modelSelectionsEqual } from "@t3tools/shared/model"; -import { causeErrorTag } from "@t3tools/shared/observability"; - -import { providerMessageTextWithAttachmentPaths } from "@t3tools/provider-core/server/attachmentPrompt"; -import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; -import { - backgroundWorkNotification, - type BackgroundWorkReport, -} from "@t3tools/provider-core/server/notification"; -import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; -import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; -import { - makeSubagentChildThread, - makeSubagentConversationArtifacts, - subagentThreadTitle, -} from "../SubagentProjection.ts"; -import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; -import { turnScopedSelectionTransition } from "@t3tools/provider-core/server/selectionTransition"; -import { OPENCODE_PROVIDER, openCodePermissionRequestKind } 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, - // A mode change rewrites the session's rules when its next turn resumes it. - supportsRuntimeModeSwitchInSession: true, - pendingRequestsSurviveRestart: false, - }, - threads: { - canCreateEmptyThread: true, - canReadThreadSnapshot: true, - canRollbackThread: true, - canForkThread: true, - canForkFromTurn: true, - canForkFromSubagentThread: false, - exposesNativeThreadId: true, - }, - turns: { - exposesNativeTurnId: false, - emitsTurnStarted: true, - emitsTurnCompleted: true, - supportsInterrupt: true, - supportsActiveSteering: true, - supportsSteeringByInterruptRestart: true, - // T3 holds queued messages and starts each as its own turn once the one - // before it ends, so OpenCode's own `queue` delivery is never used. - 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: true, - supportsFileReadApproval: true, - supportsFileChangeApproval: true, - supportsApplyPatchApproval: true, - approvalsHaveNativeRequestIds: true, - approvalCallbacksAreLiveOnly: true, - approvalsCanOriginateFromSubagents: true, - }, - planning: { - emitsPlanUpdated: false, - emitsTodoList: false, - emitsProposedPlan: false, - supportsStructuredQuestions: true, - planDeltasHaveItemIds: false, - }, - subagents: { - supportsSubagents: true, - exposesSubagentThreadIds: true, - emitsSubagentLifecycle: true, - canWaitForSubagents: true, - 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: true, - providerRollbackReturnsSnapshot: true, - providerCanReadConversationSnapshot: true, - }, - identity: { - nativeThreadIds: "strong", - nativeTurnIds: "weak", - nativeItemIds: "strong", - nativeRequestIds: "strong", - }, - // OpenCode enforces each runtime mode through the session's permission rules. - runtimePolicy: { enforcement: "native" }, -} satisfies OrchestrationV2ProviderCapabilities; - -type EventOf = Extract; -type Tokens = EventOf<"session.step.ended">["data"]["tokens"]; - -/** What a turn reports against: a run's own turn, or a subagent session's runless one. */ -interface TurnOwner { - readonly appThread: OrchestrationV2AppThread; - readonly threadId: OrchestrationV2AppThread["id"]; - readonly runId: RunId | null; - readonly runOrdinal: number; - readonly rootNodeId: ProviderAdapter.ProviderAdapterV2TurnInput["rootNodeId"]; - readonly modelSelection: ModelSelection; - /** Where the turn runs: a subagent runs in its parent turn's directory. */ - readonly runtimePolicy: Pick; -} - -interface ActiveTurn { - readonly input: TurnOwner; - providerTurn: OrchestrationV2ProviderTurn; - /** Open text and reasoning blocks, keyed `::`. */ - readonly texts: Map; - readonly tools: Map }>; - readonly startedAt: Map; - readonly ordinals: Map; - nextOrdinal: number; - /** Input includes cache reads and writes, as 1.x reports it; the parts are also kept apart. */ - readonly usage: { - input: number; - cached: number; - cacheWrite: number; - output: number; - reasoning: number; - }; - steps: number; - lastStep: Tokens | undefined; - /** - * The history item this turn's execution follows, for a turn with no prompt - * id of T3's: the session's newest item before a `/name` command (null when - * the history was empty), since `session.command` takes no id and answers - * without one; the report a continuation turn's execution answers; or, on a - * subagent's session, the first item queued for its turn. A reconnect - * backfills that turn from everything after this. Other turns backfill from - * their own prompt's id (`nativeTurnRef`). - */ - before: string | null | undefined; - /** The compaction running in this turn, `/compact` or OpenCode's own when the context fills. */ - compaction: { readonly nativeId: string; readonly startedAt: DateTime.Utc } | undefined; - compactions: number; - interrupted: boolean; - /** - * Set until the turn's prompt, command or compaction is sent. A Stop before - * then has nothing on the server to stop, so it ends the turn here and the - * request is never sent. - */ - unsent: boolean; - /** - * Steers sent into this turn that OpenCode has not delivered yet, by inbox - * id. Each one wakes the session, so an execution that ends before reading - * one is followed by another that does, and the turn spans both. - */ - readonly steers: Set; - /** This turn's steers OpenCode delivered or cancelled: a retried one is not sent again. */ - readonly settledInbox: Set; - /** How the last execution ended, while the turn waits for the one that delivers its steers. */ - heldEnd: TurnTerminal | undefined; - /** - * 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; - /** - * Prefixes a subagent session's tool ids in native item ids: the model - * names tool calls, so a child's could repeat its parent's. - */ - readonly scope: string; - /** Set once the turn calls `subagent`: its usage then leaves out the subagents'. */ - usedSubagents: boolean; -} - -/** A `subagent` tool call. A background one outlives the turn that made it. */ -interface SubagentCall { - readonly toolId: string; - readonly nativeId: string; - /** The calling session and the turn whose item the call is. */ - readonly state: ThreadState; - readonly turn: ActiveTurn; - readonly ordinal: number; - readonly startedAt: DateTime.Utc; - prompt: string; - title: string | null; - agent: string | undefined; - model: string | null; - /** The tool returned while the subagent runs on; the report OpenCode gives its parent settles it. */ - background: boolean; - child: ThreadState | undefined; - status: OrchestrationV2Subagent["status"]; - result: string | null; - completedAt: DateTime.Utc | null; -} - -/** - * An execution OpenCode ran on a thread's session without T3 asking: the - * parent's answer once a background subagent ended. Its events are held until - * the continuation turn T3 opens for it takes them. - */ -interface Wake { - readonly events: Array; - running: boolean; - /** The background subagents whose end it answers, as its turn's notification names them. */ - readonly reports: Array; - /** What OpenCode told the model, the continuation's prompt text. */ - readonly detail: string | null; - /** Stopped, or taken by a user turn: the continuation it asked for is not needed. */ - dropped: boolean; - /** The last report it delivered (inbox ids are history ids): its execution follows it. */ - readonly after: string | undefined; - /** The first report it delivered, where fork and rollback cut before its turn. */ - readonly first: string | undefined; -} - -interface OpenBlock { - readonly block: { readonly assistantMessageID: string; readonly ordinal: number }; - readonly kind: "text" | "reasoning"; - readonly startedAt: DateTime.Utc; - text: string; -} - -interface ThreadState { - readonly sessionId: string; - 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, a prompt whose request failed without a clear answer, or a - * request T3 could not 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; - /** The session's location, where its agents' path rules are read. */ - directory: string; - /** - * The agent the session runs (`build`, or `plan` in plan mode); its own path - * rules stay in force, and a changed mode switches it before prompting. - */ - agent: string; - /** The native session's rules as T3 last read or wrote them, and the policy they are for. */ - rules: ReadonlyArray | undefined; - policy: RulesPolicy; - /** "Allow … this session" answers, kept in the session's rules while T3 has it open. */ - readonly grants: Array; - /** The session's `subagent` calls still running, by tool call id. */ - readonly calls: Map; - /** - * Set on a subagent's session: the call that runs it and the thread it - * shows in. Each of its executions is a runless turn there. - */ - subagent: - | { - call: SubagentCall; - readonly appThread: OrchestrationV2AppThread; - turns: number; - /** The prompt its next execution answers, shown as that turn's user message. */ - prompt: string | undefined; - /** - * The first item queued for the session since its last turn began (its - * prompt, or a report it answers): where the next turn's history - * begins, which a reconnect reads back. - */ - queued: string | undefined; - } - | undefined; - /** Executions OpenCode started on its own, oldest first, each waiting for its turn. */ - readonly wakes: Array; - /** - * Background subagents whose end OpenCode queued for this session and has - * not delivered yet, by inbox id. The wake that delivers them names them. - */ - readonly reports: Map< - string, - { - readonly inboxId: string; - readonly childId: string; - readonly report: BackgroundWorkReport; - readonly text: string; - } - >; - /** - * Background subagent sessions T3 stopped. OpenCode wakes the parent to - * report them; a wake that reports only these is stopped as well. - */ - readonly stoppedChildren: Set; - /** - * Steers a turn ended without (a Stop, a failure). They stay in OpenCode's - * inbox, where the next prompt would deliver them first, so it cancels them. - */ - readonly strandedSteers: Set; - /** T3's MCP server as registered for this thread, and the instructions entry sent with it. */ - mcp: - | { readonly name: string; readonly directory: string; readonly credential: string } - | undefined; - instructions: string | undefined; -} - -type TurnTerminal = - | { readonly status: "completed" | "interrupted" } - | { readonly status: "failed"; readonly failure: ReturnType }; - -type Rule = Permission.Rule; -type RulesPolicy = Pick< - ProviderAdapter.ProviderAdapterV2RuntimePolicy, - "runtimeMode" | "interactionMode" ->; -type NativeForm = EventOf<"form.created">["data"]["form"]; - -/** A permission ask or question form shown to the user and not answered yet. */ -interface PendingRequest { - readonly request: OrchestrationV2RuntimeRequest; - readonly item: OrchestrationV2TurnItem; - readonly node: OrchestrationV2ExecutionNode; - /** The thread the request is shown on: the asking session's, or its parent's. */ - readonly state: ThreadState; - readonly turn: ActiveTurn; - /** The session that asked: the thread's own, or one of its subagents'. */ - readonly sessionId: string; - /** Set once T3 sends its answer; the orchestrator has already recorded it. */ - answering: boolean; - readonly native: - | { - readonly type: "permission"; - readonly id: string; - readonly action: string; - readonly resources: ReadonlyArray; - readonly save: ReadonlyArray; - } - | { readonly type: "form"; readonly id: string; readonly form: NativeForm }; -} - -const rule = (action: string, effect: Rule["effect"]): Rule => ({ action, resource: "*", effect }); - -/** - * A session's permission rules. OpenCode checks the agent's rules and then - * these, and the last rule that matches decides, so these override the - * agent's. `paths` are the agent's own allows for its directories (saved tool - * output, the plan agent's plan directory), which the blanket rules here would - * otherwise override; `grants` are "Always allow this session" answers. - */ -/** - * T3's MCP server is registered per directory, not per session, so each thread - * gets its own `t3-code-` entry with its own credential. OpenCode names - * an MCP tool's permission `_` (non-alphanumerics become `_`). - * OpenCode skips every tool of a server whose name is over 64 characters (its - * tool namespace limit), and its router rejects adding one over 100, so a - * thread id that does not fit is replaced by a digest of it. - */ -export const t3McpServerName = Effect.fn("t3McpServerName")(function* (threadId: string) { - const name = `t3-code-${threadId.replaceAll(/[^a-zA-Z0-9_-]/g, "_")}`; - if (name.length <= 64) return name; - const crypto = yield* Crypto.Crypto; - const digest = yield* crypto - .digest("SHA-256", new TextEncoder().encode(threadId)) - .pipe(Effect.orDie); - return `t3-code-${Hex.encode(digest).slice(0, 16)}`; -}); - -/** - * The rules that keep T3's MCP servers to their own thread, after the mode's: - * the last matching rule wins, so every thread's T3 server is denied and then - * this thread's own is allowed again, in every mode. A subagent's session - * inherits the thread's. - */ -const mcpRules = (mcpServerName: string | null): ReadonlyArray => - mcpServerName === null - ? [] - : [ - { action: "t3-code-*", resource: "*", effect: "deny" }, - { action: `${mcpServerName}_*`, resource: "*", effect: "allow" }, - ]; - -const sessionRules = ( - policy: RulesPolicy, - paths: ReadonlyArray, - grants: ReadonlyArray, - mcpServerName: string | null, -): ReadonlyArray => [ - ...(policy.runtimeMode === "full-access" - ? [rule("*", "allow")] - : [ - rule("shell", "ask"), - rule("edit", policy.runtimeMode === "auto-accept-edits" ? "allow" : "ask"), - rule("external_directory", "ask"), - ]), - ...grants, - // Plan mode writes only its plan, which `paths` allows again. Shell and read - // are never denied: the free tier refuses sessions whose rules deny them. - ...(policy.interactionMode === "plan" ? [rule("edit", "deny")] : []), - ...paths, - ...mcpRules(mcpServerName), -]; - -const sameRules = (left: ReadonlyArray | undefined, right: ReadonlyArray) => - left?.length === right.length && - left.every( - (entry, index) => - entry.action === right[index]?.action && - entry.resource === right[index]?.resource && - entry.effect === right[index]?.effect, - ); - -/** - * Sent with a declined permission. A reject without a message is OpenCode's - * "stop": it ends the whole execution, which is Cancel. - */ -const DECLINED = "The user declined this request."; - -/** - * Steered into the session before a decline: a declined shell call reaches the - * model only as "Unable to execute command", which it retries. - */ -const declinedNote = (action: string, resources: ReadonlyArray) => - `The user declined the ${action} request${resources.length === 0 ? "" : ` (${resources.join(", ")})`}. Do not retry it; continue without it or ask the user how to proceed.`; - -/** The session-wide choice for a request whose `save` patterns OpenCode would remember. */ -const sessionGrantLabel = (action: string, save: ReadonlyArray) => - save.every((pattern) => pattern === "*") - ? `Allow every ${action} request this session` - : `Allow ${save.join(", ")} this session`; - -const text = (value: string | undefined, fallback: string) => value?.trim() || fallback; - -/** - * A form's fields as T3 questions, or why T3 cannot ask them: a link to open, - * a field shown only for another answer, a hidden field, or a number or yes/no - * value. OpenCode's question tool only asks text and multi-select fields. - */ -const formQuestions = ( - form: NativeForm, -): - | { readonly questions: ReadonlyArray } - | { readonly unsupported: string } => { - const questions: Array = []; - for (const [index, field] of form.fields.entries()) { - if (field.type === "external") return { unsupported: "a link to open" }; - if (field.type !== "string" && field.type !== "multiselect") { - return { unsupported: `a ${field.type} value` }; - } - if (field.hidden === true) return { unsupported: "a hidden field" }; - if ((field.when?.length ?? 0) > 0) return { unsupported: "a field that depends on another" }; - const header = text(field.title, `Question ${index + 1}`); - const options = (field.options ?? []).map((option) => { - const label = text(option.label, text(option.value, "Option")); - return { label, description: text(option.description, label), value: option.value }; - }); - questions.push({ - id: field.key, - header, - question: text(field.description, header), - options, - multiSelect: field.type === "multiselect", - allowCustomAnswer: field.custom === true || options.length === 0, - }); - } - return { questions }; -}; - -/** T3's answers in OpenCode's shape: a list for a multi-select, text otherwise. */ -const formAnswer = (form: NativeForm, answers: Readonly>) => { - const answer: Record> = {}; - for (const field of form.fields) { - const raw = answers[field.key]; - const values = (Array.isArray(raw) ? raw : [raw]).filter( - (value): value is string => typeof value === "string" && value.trim().length > 0, - ); - if (values.length > 0) { - answer[field.key] = field.type === "multiselect" ? values : values.join(", "); - } - } - return answer; -}; - -const approves = (decision: ProviderApprovalDecision) => - decision === "accept" || decision === "acceptForSession" || decision === "acceptAlways"; - -/** - * An answer to a request OpenCode already dropped (its execution ended, or - * its session is gone): nothing waits on it, so it counts as delivered. - */ -const permissionGone = { - PermissionNotFoundError: () => Effect.void, - SessionNotFoundError: () => Effect.void, -}; -const formGone = { - FormNotFoundError: () => Effect.void, - FormAlreadySettledError: () => Effect.void, - SessionNotFoundError: () => Effect.void, -}; - -/** The session rules an agent keeps for its own directories, which T3's blanket rules would override. */ -const agentPaths = (rules: ReadonlyArray) => - rules.filter( - (entry) => - entry.effect === "allow" && - entry.resource !== "*" && - (entry.action === "edit" || entry.action === "external_directory"), - ); - -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"); - -const stringField = (record: Readonly> | undefined, key: string) => { - const value = record?.[key]; - return typeof value === "string" && value.trim().length > 0 ? value : undefined; -}; - -/** How a background subagent ended, from the `state` of OpenCode's report to its parent. */ -const reportOutcome = (state: string | undefined) => - state === "completed" - ? ("completed" as const) - : state === "error" - ? ("failed" as const) - : state === "cancelled" - ? ("cancelled" as const) - : ("unknown" as const); - -/** A subagent's answer, without the `` wrapper OpenCode gives the model. */ -const subagentOutput = (text: string) => - /^]*>\n?([\s\S]*?)\n?<\/subagent>$/.exec(text.trim())?.[1] ?? text; - -const isContinuation = (turnInput: ProviderAdapter.ProviderAdapterV2TurnInput) => - turnInput.message.createdBy === "agent" && turnInput.message.creationSource === "provider"; - -/** The native turn id of a continuation turn, which answers a wake and has no prompt. */ -const wakeTurnId = (sessionId: string, attemptId: string) => `${sessionId}:wake:${attemptId}`; -const isWakeTurn = (turn: OrchestrationV2ProviderTurn) => - turn.nativeTurnRef?.nativeId?.includes(":wake:") === true; - -const INTERRUPT_TIMEOUT = "10 seconds"; -/** The session instructions entry T3 writes its per-turn system prompt to. */ -const INSTRUCTIONS_KEY = "t3-code"; -/** A lost event stream is resubscribed this many times, this far apart, before the session breaks. */ -const RECONNECT_ATTEMPTS = 5; -const RECONNECT_DELAY = "2 seconds"; -const RECONCILE_TIMEOUT = "15 seconds"; -/** How long a new turn waits for a reconnect in progress. */ -const RECONNECT_WAIT = "30 seconds"; -/** A background subagent's result when its end was lost with the event stream. */ -const LOST_BACKGROUND = - "T3 Code lost its connection to OpenCode while this subagent ran, so its result is not shown."; -/** How long a turn waits on the directory's commands or skills before sending the text as is. */ -const INVENTORY_TIMEOUT = "5 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", - "CommandNotFoundError", - "SkillNotFoundError", -]); - -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; - -/** - * The user message ids T3 prompts under. OpenCode takes a client id (it must - * start with `msg_`) and answers a repeat of one in the same session with the - * item it already has, so a retried request never queues a second message, - * and a turn knows the message fork and rollback cut at before OpenCode - * answers. The id is unique across the whole server, which refuses it in any - * other session (409), so it names the session: another T3 database or - * environment on the same server repeats thread ids and run ordinals, never - * session ids. A turn keeps its id in `nativeTurnRef`. - */ -const turnPromptId = (sessionId: string, attemptId: string) => - SessionMessage.ID.make(`msg_t3_turn_${sessionId}:${attemptId}`); -const steerPromptId = (sessionId: string, messageId: string) => - SessionMessage.ID.make(`msg_t3_steer_${sessionId}:${messageId}`); - -/** - * The user message a turn prompted with. Turns from before T3 chose prompt ids - * recorded `:attempt:`, which is no message. - */ -const promptOf = (turn: OrchestrationV2ProviderTurn) => { - const nativeId = turn.nativeTurnRef?.nativeId; - return nativeId?.startsWith("msg_") === true ? nativeId : undefined; -}; - -/** - * Where fork and rollback cut to keep the session's turns up to `kept` (all of - * them before the first turn when undefined): before the message that starts - * the next turn that reached OpenCode, which is in `prompts`. That is a turn's - * prompt, or for a continuation the report it answers. A turn refused before - * it prompted is not in the session and is passed over, and so is a - * continuation that took no report. `null` means nothing follows, so there is - * no cut; a later turn from before T3 chose prompt ids has no known message, - * so no cut is safe. - */ -const boundaryAfter = ( - turns: ReadonlyArray, - providerThreadId: OrchestrationV2ProviderThread["id"], - kept: OrchestrationV2ProviderTurn | undefined, - prompts: ReadonlySet, -) => { - const later = turns - .filter( - (turn) => - turn.providerThreadId === providerThreadId && - (kept === undefined || turn.ordinal > kept.ordinal), - ) - .toSorted((left, right) => left.ordinal - right.ordinal); - if (later.some((turn) => promptOf(turn) === undefined && !isWakeTurn(turn))) { - return Effect.fail( - new ProviderAdapter.ProviderAdapterProtocolError({ - driver: OPENCODE_PROVIDER, - detail: - "This OpenCode conversation has turns from an earlier T3 Code version, so it can't be cut there.", - }), - ); - } - return Effect.succeed( - later.map(promptOf).find((messageId) => messageId !== undefined && prompts.has(messageId)) ?? - null, - ); -}; - -// 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"); - -/** OpenCode's own agents for T3's interaction modes; plan mode is its read-only `plan` agent. */ -const agentFor = (input: ProviderAdapter.ProviderAdapterV2TurnInput) => - input.runtimePolicy.interactionMode === "plan" ? "plan" : "build"; - -/** `/name args` naming one of the workspace's commands, which OpenCode expands itself. */ -const commandOf = (text: string) => { - const match = /^\/([^\s/]+)(?:\s+([\s\S]*))?$/.exec(text.trim()); - return match === null ? undefined : { name: match[1]!, text: match[2] ?? "" }; -}; - -/** Whether a prompt names any skill at all, before the directory's skills are read. */ -const SKILL_MENTION = new RegExp(SKILL_MENTION_PATTERN.source, "u"); - -/** The workspace skills a prompt names with the composer's `$skill` tokens. */ -const skillsNamed = (text: string, known: ReadonlySet) => [ - ...new Set( - [...text.matchAll(SKILL_MENTION_PATTERN)].flatMap((match) => - known.has(match[2] ?? "") ? [match[2]!] : [], - ), - ), -]; - -/** - * The turn's own tokens: steps add up, and the last step's input is the live - * context size. A subagent's tokens are its own session's, never these. - */ -const turnTokenUsage = (turn: ActiveTurn, status: OrchestrationV2ProviderTurn["status"]) => - turn.steps === 0 - ? { - usageScope: "main_agent" as const, - usageStatus: "unavailable" as const, - hasSubagents: turn.usedSubagents, - } - : { - usageScope: "main_agent" as const, - usageStatus: status === "completed" ? ("complete" as const) : ("partial" as const), - inputTokens: turn.usage.input, - cachedInputTokens: turn.usage.cached, - cacheCreationTokens: turn.usage.cacheWrite, - outputTokens: turn.usage.output, - reasoningTokens: turn.usage.reasoning, - hasSubagents: turn.usedSubagents, - }; - -/** - * 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 continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests; - const crypto = yield* Crypto.Crypto; - const driver = OPENCODE_PROVIDER; - const mcpServerNameFor = (threadId: string) => - t3McpServerName(threadId).pipe(Effect.provideService(Crypto.Crypto, crypto)); - - /** - * Lends the instance's server to a session until its scope closes. A spawned - * server that died is started again on the next borrow. - */ - const borrow = Effect.gen(function* () { - const lent = yield* Deferred.make(); - 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* Deferred.await(lent); - }); - - const openSession = Effect.fn("OpenCode2Adapter.openSession")(function* ( - input: Parameters[0], - initial: { - readonly connection: OpenCode2Server.OpenCode2Connection; - readonly scope: Scope.Closeable; - }, - ) { - let connection = initial.connection; - // Replaced when the session reconnects to a restarted server. - let client = connection.client; - const sessionScope = yield* Effect.scope; - // Context windows by directory, then `provider/model`: a project's own - // OpenCode config can change a model's limits, and this one runtime serves - // the instance's threads in every directory. - const contextWindows = new Map>(); - /** A thread without a worktree runs where T3 does, as its session is created. */ - const directoryOf = (cwd: string | null | undefined) => cwd ?? serverConfig.cwd; - const windowOf = (cwd: string | null | undefined, model: string) => - contextWindows.get(directoryOf(cwd))?.get(model); - 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(); - // Thread sessions, and the sessions of their subagents. - const threads = new Map(); - // A subagent's session, by its id, to the thread whose session started it. - const childOwners = new Map(); - // What OpenCode announced about a subagent's session, until its call names it. - const announced = new Map["data"]>(); - // Sessions of these threads with an execution running, seen on the stream. - const busy = new Set(); - const pending = new Map(); - // Events and a wake's replay into its turn are handled one at a time, in order. - const lock = yield* Semaphore.make(1); - // Sessions whose `revert.clear` has not had its empty execution yet. - const clearing = new Map>(); - // Sessions a failed rollback may have left with a staged revert. - const stagedReverts = new Set(); - // Starting a turn and cutting the history take turns on a session: each - // checks that the other is not running before its own requests yield. - const sessionGates = yield* KeyedLock.make(); - const exclusive = - (providerThread: OrchestrationV2ProviderThread) => - (effect: Effect.Effect) => { - const sessionId = providerThread.nativeThreadRef?.nativeId; - if (sessionId == null) return effect; - return sessionGates.withLock(sessionId, effect); - }; - const emit = (event: ProviderAdapter.ProviderAdapterV2Event) => - Queue.offer(events, event).pipe(Effect.asVoid); - const ownerOf = (sessionId: string) => childOwners.get(sessionId) ?? threads.get(sessionId); - - const newThreadState = ( - sessionId: string, - providerThread: OrchestrationV2ProviderThread, - directory: string, - subagent: ThreadState["subagent"], - ): ThreadState => ({ - sessionId, - providerThread, - providerTurns: new Map(), - active: undefined, - model: undefined, - unsettled: false, - directory, - agent: "build", - rules: undefined, - policy: input.runtimePolicy, - grants: [], - calls: new Map(), - subagent, - wakes: [], - reports: new Map(), - stoppedChildren: new Set(), - strandedSteers: new Set(), - mcp: undefined, - instructions: undefined, - }); - - /** The thread whose session started this one, through any nesting. */ - const rootOf = (state: ThreadState): ThreadState => { - let current = state; - while (current.subagent !== undefined) current = current.subagent.call.state; - return current; - }; - - /** A thread's session and its subagents' sessions, through any nesting. */ - const sessionsOf = (thread: ThreadState): ReadonlyArray => [ - thread, - ...[...threads.values()].filter((state) => { - for (let above = state.subagent?.call.state; above; above = above.subagent?.call.state) { - if (above === thread) return true; - } - return false; - }), - ]; - - /** - * A thread's `subagent` calls still running, its subagents' included: a - * subagent's background call runs on after the call that made it ended. - */ - const runningCalls = (thread: ThreadState): ReadonlyArray => - sessionsOf(thread).flatMap((state) => [...state.calls.values()]); - - /** The `subagent` calls that lead to a session, from its own up to the thread's. */ - const callsAbove = (sessionId: string) => { - const calls: Array = []; - let current = threads.get(sessionId); - while (current?.subagent !== undefined) { - calls.push(current.subagent.call); - current = current.subagent.call.state; - } - return calls; - }; - - /** - * The turn a request from `sessionId` is shown under. A background - * subagent's goes to the turn that started it, whose run waits on the - * subagent; anything else goes to the thread's running turn. - */ - const requestTurn = (sessionId: string) => { - const state = ownerOf(sessionId); - if (state === undefined) return undefined; - const background = callsAbove(sessionId).findLast((call) => call.background); - if (background !== undefined) { - return isOrchestrationV2WorkActive(background.status) - ? { state, turn: background.turn } - : undefined; - } - return state.active === undefined ? undefined : { state, turn: state.active }; - }; - - /** - * What a session still owes its thread once the thread's turn ended: - * background subagents, held executions, reports OpenCode queued for the - * follow-up it will start, and anything a subagent's session still runs, - * such as its own follow-up to such a report. - */ - const owesWork = (state: ThreadState) => - state.wakes.length > 0 || - state.reports.size > 0 || - (state.subagent !== undefined && busy.has(state.sessionId)) || - [...state.calls.values()].some((call) => call.background); - - /** Work that outlives the thread's turn, on its own session or its subagents'. */ - const hasBackground = (thread: ThreadState) => sessionsOf(thread).some(owesWork); - - 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; - const nativeId = `${turn.scope}${id}`; - yield* emitNode(state, turn, nativeId, "tool_call", status, startedAt, completedAt); - yield* emit({ - type: "turn_item.updated", - driver, - turnItem: openCodeToolTurnItem( - itemBase(state, turn, nativeId, status, startedAt, completedAt, updatedAt), - { - name: tool.name, - input: tool.input, - output: result?.output, - completedMetadata: status === "completed" ? result?.metadata : undefined, - }, - ), - }); - }); - - /** The turn's compaction item; its summary is the text OpenCode carries forward. */ - const emitCompaction = Effect.fnUntraced(function* ( - state: ThreadState, - turn: ActiveTurn, - status: "running" | "completed" | "failed" | "interrupted", - summary?: string, - ) { - const compaction = turn.compaction; - if (compaction === undefined) return; - const updatedAt = yield* DateTime.now; - const completedAt = status === "running" ? null : updatedAt; - yield* emit({ - type: "turn_item.updated", - driver, - turnItem: { - ...itemBase( - state, - turn, - compaction.nativeId, - status, - compaction.startedAt, - completedAt, - updatedAt, - ), - nodeId: turn.input.rootNodeId, - type: "compaction", - driver, - title: - status === "running" - ? "Compacting context" - : status === "completed" - ? "Context compacted" - : status === "failed" - ? "Compaction failed" - : "Compaction interrupted", - ...(summary === undefined || summary.length === 0 ? {} : { summary }), - }, - }); - if (status !== "running") turn.compaction = 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 makeTurn = ( - owner: TurnOwner, - providerTurn: OrchestrationV2ProviderTurn, - options: { readonly scope: string; readonly awaitingStart: boolean }, - ): ActiveTurn => ({ - input: owner, - providerTurn, - texts: new Map(), - tools: new Map(), - startedAt: new Map(), - ordinals: new Map(), - nextOrdinal: providerTurn.ordinal * 100 + 1, - usage: { input: 0, cached: 0, cacheWrite: 0, output: 0, reasoning: 0 }, - steps: 0, - lastStep: undefined, - before: undefined, - compaction: undefined, - compactions: 0, - interrupted: false, - unsent: false, - steers: new Set(), - settledInbox: new Set(), - heldEnd: undefined, - awaitingStart: options.awaitingStart, - scope: options.scope, - usedSubagents: false, - }); - - /** A subagent call as its parent turn's item, node and subagent row. */ - const emitSubagent = Effect.fnUntraced(function* (call: SubagentCall) { - const updatedAt = yield* DateTime.now; - const { turn, state } = call; - const nodeId = idAllocator.derive.nodeFromProviderItem({ - driver, - nativeItemId: call.nativeId, - }); - const completedAt = isOrchestrationV2WorkActive(call.status) - ? null - : (call.completedAt ?? updatedAt); - const childProviderThreadId = call.child?.providerThread.id ?? null; - const childThreadId = call.child?.subagent?.appThread.id ?? null; - yield* emit({ - type: "node.updated", - driver, - node: { - id: nodeId, - threadId: turn.input.threadId, - runId: turn.input.runId, - parentNodeId: turn.input.rootNodeId, - rootNodeId: turn.input.rootNodeId, - kind: "subagent", - status: call.status, - countsForRun: false, - providerThreadId: childProviderThreadId ?? state.providerThread.id, - providerTurnId: turn.providerTurn.id, - nativeItemRef: ref(call.nativeId), - runtimeRequestId: null, - checkpointScopeId: null, - startedAt: call.startedAt, - completedAt, - }, - }); - yield* emit({ - type: "subagent.updated", - driver, - subagent: { - id: nodeId, - threadId: turn.input.threadId, - runId: turn.input.runId, - parentNodeId: turn.input.rootNodeId, - origin: "provider_native", - createdBy: "agent", - driver, - providerInstanceId: instanceId, - providerThreadId: childProviderThreadId, - childThreadId, - nativeTaskRef: ref(call.nativeId), - prompt: call.prompt, - title: call.title, - model: call.model, - status: call.status, - result: call.result, - startedAt: call.startedAt, - completedAt, - updatedAt, - }, - }); - yield* emit({ - type: "turn_item.updated", - driver, - turnItem: { - id: idAllocator.derive.turnItemFromProviderItem({ driver, nativeItemId: call.nativeId }), - threadId: turn.input.threadId, - runId: turn.input.runId, - nodeId, - providerThreadId: state.providerThread.id, - providerTurnId: turn.providerTurn.id, - nativeItemRef: ref(call.nativeId), - parentItemId: null, - ordinal: call.ordinal, - status: call.status, - title: call.title, - startedAt: call.startedAt, - completedAt, - updatedAt, - type: "subagent", - subagentId: nodeId, - origin: "provider_native", - driver, - providerInstanceId: instanceId, - childThreadId, - prompt: call.prompt, - result: call.result, - }, - }); - yield* syncRoster(rootOf(call.state)); - }); - - /** - * Lists a thread's background subagents on its provider thread, as typed - * `subagent` work: the thread shows it waits on them after its turn ends, - * and a Stop reaches them. Emitted only when the list changes. - */ - const syncRoster = Effect.fnUntraced(function* (state: ThreadState) { - const roster = runningCalls(state) - .filter((call) => call.background) - .map((call) => ({ - taskId: call.nativeId, - kind: "subagent" as const, - ...(call.title?.trim() ? { description: call.title.trim() } : {}), - ...(call.child?.subagent === undefined - ? {} - : { childThreadId: call.child.subagent.appThread.id }), - })); - const current = state.providerThread.pendingBackgroundTasks ?? []; - if ( - current.length === roster.length && - current.every( - (task, index) => - task.taskId === roster[index]?.taskId && - task.description === roster[index]?.description && - (task.kind === "subagent" ? task.childThreadId : undefined) === - roster[index]?.childThreadId, - ) - ) { - return; - } - state.providerThread = { - ...state.providerThread, - pendingBackgroundTasks: roster, - updatedAt: yield* DateTime.now, - }; - yield* emit({ - type: "provider_thread.updated", - driver, - providerThread: state.providerThread, - }); - }); - - const withReportedModel = ( - providerThread: OrchestrationV2ProviderThread, - model: ModelRef | undefined, - ): OrchestrationV2ProviderThread => { - if (model === undefined) return providerThread; - const modelSelection: ModelSelection = { - instanceId, - model: `${model.providerID}/${model.id}`, - options: model.variant === undefined ? [] : [{ id: "variant", value: model.variant }], - }; - if ( - providerThread.nativeMetadata?.modelSelection !== undefined && - modelSelectionsEqual(providerThread.nativeMetadata.modelSelection, modelSelection) - ) - return providerThread; - return { - ...providerThread, - nativeMetadata: { ...providerThread.nativeMetadata, modelSelection }, - }; - }; - - /** - * Gives a subagent call its session once both are known: OpenCode names - * the session on the call's progress, and announces it just before. The - * session becomes a child thread under the call's item. - */ - const attachChild = Effect.fnUntraced(function* (call: SubagentCall, childId: string) { - if (call.child !== undefined) return; - const info = announced.get(childId); - announced.delete(childId); - const now = yield* DateTime.now; - const nodeId = idAllocator.derive.nodeFromProviderItem({ - driver, - nativeItemId: call.nativeId, - }); - const childThreadId = idAllocator.derive.threadFromProviderThread({ - driver, - nativeThreadId: childId, - providerInstanceId: instanceId, - }); - const providerThreadId = idAllocator.derive.providerThread({ - driver, - nativeThreadId: childId, - providerInstanceId: instanceId, - }); - if (info?.model !== undefined) call.model = `${info.model.providerID}/${info.model.id}`; - call.title = call.title ?? info?.title ?? null; - const parentThread = call.turn.input.appThread; - const appThread = makeSubagentChildThread({ - parentThread, - childThreadId, - parentNodeId: nodeId, - activeProviderThreadId: providerThreadId, - providerInstanceId: instanceId, - modelSelection: { - instanceId, - model: call.model ?? call.turn.input.modelSelection.model, - }, - title: subagentThreadTitle({ - parentTitle: parentThread.title, - title: call.title, - prompt: call.prompt, - ordinal: call.ordinal, - }), - now, - createdBy: "agent", - creationSource: "provider", - }); - const providerThread: OrchestrationV2ProviderThread = { - id: providerThreadId, - driver, - providerInstanceId: instanceId, - providerSessionId: input.providerSessionId, - appThreadId: childThreadId, - ownerNodeId: nodeId, - nativeThreadRef: ref(childId), - nativeConversationHeadRef: null, - status: "active", - firstRunOrdinal: null, - lastRunOrdinal: null, - handoffIds: [], - forkedFrom: null, - createdAt: now, - updatedAt: now, - }; - // A subagent called again keeps its session's state: how many turns it - // ran (each one's native id is its own), its grants and rules, and the - // background work an earlier call left running, which Stop must reach. - const previous = threads.get(childId); - const subagent = { - call, - appThread, - turns: previous?.subagent?.turns ?? 0, - prompt: call.prompt, - queued: undefined, - }; - const child = - previous ?? newThreadState(childId, providerThread, call.state.directory, subagent); - child.subagent = subagent; - child.model = info?.model ?? child.model; - child.providerThread = withReportedModel(providerThread, child.model); - child.directory = call.state.directory; - child.agent = info?.agent ?? call.agent ?? child.agent; - // OpenCode gives a new session its parent's rules, which are the thread's. - child.rules = info?.permissions ?? child.rules; - threads.set(childId, child); - childOwners.set(childId, rootOf(call.state)); - call.child = child; - yield* emit({ type: "app_thread.created", driver, appThread }); - yield* emit({ - type: "provider_thread.updated", - driver, - providerThread: child.providerThread, - }); - yield* emitSubagent(call); - // A session called again was not made now, so it may hold the rules of - // a mode the thread has left. OpenCode applies a rules change to the - // asks after it, so it gets the thread's as soon as its call names it. - if (info === undefined) { - yield* writeRules(child, rootOf(call.state).policy).pipe( - Effect.timeout(REQUEST_REPLY_TIMEOUT), - Effect.ignore({ log: true }), - ); - } - }); - - /** - * Ends a subagent call and every call under it that is still running. A - * background one outlives the call that made it, as it outlives a turn, - * unless that call failed. - */ - const settleCall: ( - call: SubagentCall, - status: OrchestrationV2Subagent["status"], - result?: string, - ) => Effect.Effect = Effect.fnUntraced(function* (call, status, result) { - if (!isOrchestrationV2WorkActive(call.status)) return; - call.status = status; - call.completedAt = yield* DateTime.now; - if (result !== undefined) call.result = result; - call.state.calls.delete(call.toolId); - const child = call.child; - if (child !== undefined) { - // A snapshot: settling a call removes it from the map. - for (const nested of Array.from(child.calls.values())) { - if (nested.background && status !== "failed") continue; - yield* settleCall(nested, status === "completed" ? "interrupted" : status); - } - if (child.active !== undefined && status !== "completed") { - yield* finishTurn(child, { status: "interrupted" }); - } - child.providerThread = { - ...child.providerThread, - status: "idle", - updatedAt: call.completedAt, - }; - yield* emit({ - type: "provider_thread.updated", - driver, - providerThread: child.providerThread, - }); - } - yield* emitSubagent(call); - }); - - /** - * Opens the runless turn a subagent's session runs each execution in, on - * its child thread, with the prompt it answers as the turn's user message. - */ - const startChildTurn = Effect.fnUntraced(function* (child: ThreadState) { - const subagent = child.subagent; - if (subagent === undefined || child.active !== undefined) return; - subagent.turns += 1; - const startedAt = yield* DateTime.now; - const nativeTurnId = `${child.sessionId}:turn:${subagent.turns}`; - const rootNodeId = idAllocator.derive.nodeFromProviderItem({ - driver, - nativeItemId: `${nativeTurnId}:root`, - }); - const providerTurn: OrchestrationV2ProviderTurn = { - id: idAllocator.derive.providerTurn({ driver, nativeTurnId }), - providerThreadId: child.providerThread.id, - nodeId: rootNodeId, - runAttemptId: null, - nativeTurnRef: ref(nativeTurnId, "weak"), - ordinal: subagent.turns, - status: "running", - startedAt, - completedAt: null, - }; - const turn = makeTurn( - { - appThread: subagent.appThread, - threadId: subagent.appThread.id, - runId: null, - runOrdinal: subagent.call.turn.input.runOrdinal, - rootNodeId, - modelSelection: subagent.appThread.modelSelection, - runtimePolicy: subagent.call.turn.input.runtimePolicy, - }, - providerTurn, - { scope: `${child.sessionId}:`, awaitingStart: false }, - ); - turn.before = subagent.queued; - subagent.queued = undefined; - child.active = turn; - yield* emit({ - type: "node.updated", - driver, - node: { - id: rootNodeId, - threadId: turn.input.threadId, - runId: null, - parentNodeId: null, - rootNodeId, - kind: "root_turn", - status: "running", - countsForRun: false, - providerThreadId: child.providerThread.id, - providerTurnId: providerTurn.id, - nativeItemRef: ref(nativeTurnId, "weak"), - runtimeRequestId: null, - checkpointScopeId: null, - startedAt, - completedAt: null, - }, - }); - yield* emitProviderTurn(child, turn, providerTurn); - const prompt = subagent.prompt; - subagent.prompt = undefined; - if (prompt !== undefined && prompt.length > 0) { - const nativeId = `${nativeTurnId}:prompt`; - const artifacts = makeSubagentConversationArtifacts({ - messageId: idAllocator.derive.messageFromProviderItem({ driver, nativeItemId: nativeId }), - senderThreadId: subagent.call.turn.input.threadId, - turnItemId: idAllocator.derive.turnItemFromProviderItem({ - driver, - nativeItemId: nativeId, - }), - threadId: turn.input.threadId, - rootNodeId, - providerThreadId: child.providerThread.id, - providerTurnId: providerTurn.id, - nativeItemRef: ref(nativeId, "weak"), - role: "user", - text: prompt, - ordinal: ordinalOf(turn, nativeId), - now: startedAt, - }); - yield* emit({ type: "message.updated", driver, message: artifacts.message }); - yield* emit({ type: "turn_item.updated", driver, turnItem: artifacts.turnItem }); - } - }); - - /** - * Asks the orchestrator for the continuation turn a held wake needs. It - * names the subagents whose end woke the thread. A wake a Stop or an - * earlier turn already took needs no turn, so its offer is dropped. - */ - const offerWake = Effect.fnUntraced(function* (state: ThreadState, wake: Wake) { - const route = state.providerThread.appThreadId; - if (route === null) return; - const notification = backgroundWorkNotification(wake.reports); - yield* continuationRequests.offer({ - threadId: route, - providerThreadId: state.providerThread.id, - driver, - detail: wake.detail, - ...(notification === null ? {} : { notification }), - dispatchIfCurrent: (dispatch) => - wake.dropped ? Effect.succeed(Option.none()) : Effect.map(dispatch, Option.some), - }); - }); - - const finishTurn = Effect.fnUntraced(function* ( - state: ThreadState, - terminal: TurnTerminal, - threadDisposition: "reusable" | "broken" = "reusable", - ) { - const turn = state.active; - if (turn === undefined) return; - state.active = undefined; - for (const inboxID of turn.steers) state.strandedSteers.add(inboxID); - // OpenCode drops a request when the asking session's execution ends. A - // subagent's request shown on this turn ends with it too, unless the - // subagent runs in the background past a turn that did not fail. - for (const entry of pending.values()) { - const background = callsAbove(entry.sessionId).some((call) => call.background); - if ( - entry.sessionId === state.sessionId || - (entry.turn === turn && !(background && terminal.status !== "failed")) - ) { - yield* settleRequest(entry, "cancelled"); - } - } - 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", - ); - } - // A foreground subagent ends with the turn that waits on it. A background - // one outlives a finished or interrupted turn (a user Stop has already - // stopped it), and a failed turn stops it. - // A snapshot: settling a call removes it from the map. - for (const call of Array.from(state.calls.values())) { - if (call.turn !== turn) continue; - if (call.background && terminal.status !== "failed") continue; - yield* settleCall(call, terminal.status === "completed" ? "completed" : terminal.status); - } - if (state.subagent !== undefined) { - yield* emit({ - type: "node.updated", - driver, - node: { - id: turn.input.rootNodeId, - threadId: turn.input.threadId, - runId: null, - parentNodeId: null, - rootNodeId: turn.input.rootNodeId, - kind: "root_turn", - status: terminal.status, - countsForRun: false, - providerThreadId: state.providerThread.id, - providerTurnId: turn.providerTurn.id, - nativeItemRef: turn.providerTurn.nativeTurnRef, - runtimeRequestId: null, - checkpointScopeId: null, - startedAt: turn.providerTurn.startedAt, - completedAt, - }, - }); - } - yield* emitCompaction(state, turn, terminal.status === "failed" ? "failed" : "interrupted"); - const window = windowOf(turn.input.runtimePolicy.cwd, turn.input.modelSelection.model); - const lastStep = turn.lastStep; - yield* emitProviderTurn(state, turn, { - ...turn.providerTurn, - status: terminal.status, - completedAt, - turnTokenUsage: turnTokenUsage(turn, terminal.status), - ...(lastStep === undefined - ? {} - : { - tokenUsage: { - usedTokens: - lastStep.input + lastStep.cache.read + lastStep.cache.write + lastStep.output, - maxTokens: window ?? null, - inputTokens: lastStep.input + lastStep.cache.read + lastStep.cache.write, - cachedInputTokens: lastStep.cache.read, - outputTokens: lastStep.output, - reasoningOutputTokens: lastStep.reasoning, - updatedAt: DateTime.formatIso(completedAt), - }, - }), - }); - // A subagent's session is its call's to settle: a turn there is runless, - // and T3 has no terminal to wait on. - if (state.subagent !== undefined) { - const call = state.subagent.call; - if (!call.background && terminal.status !== "completed") { - yield* settleCall(call, terminal.status); - } - return; - } - 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.subagent === undefined && candidate.active !== undefined, - ); - yield* setSessionStatus(pending.size > 0 ? "waiting" : anyActive ? "running" : "ready", null); - const base = { - type: "turn.terminal" as const, - driver, - // The provider thread the turn started on. After a native fork the - // session's own thread has a fresh id, and the terminal must name the - // thread the start was recorded against. - providerThreadId: turn.providerTurn.providerThreadId, - 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 }, - ); - // A spawned server lists its models lazily, so a window still unknown is - // read again for the next turn, off this stream so it never delays one. - if (window === undefined) { - yield* Effect.forkIn(readModels(directoryOf(turn.input.runtimePolicy.cwd)), sessionScope); - } - }); - - /** - * Ends the turn a request T3 could not answer would block, and stops the - * session: OpenCode waits on an unanswered request forever. The stopped - * run's end is its own, not the next turn's. - */ - const abandonRequest = Effect.fnUntraced(function* (state: ThreadState, reason: string) { - yield* Effect.logWarning("Could not answer an OpenCode request; ending its turn.", { - reason, - }); - if (state.active === undefined) return; - state.unsettled = true; - 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(state.sessionId) }) - .pipe(Effect.timeout("2 seconds"), Effect.ignore({ log: true })); - }); - - /** Stops tracking a request; the session is waiting only while any remain. */ - const forgetRequest = Effect.fnUntraced(function* (entry: PendingRequest) { - if (!pending.delete(entry.request.id)) return false; - if (pending.size === 0 && session.status === "waiting") { - yield* setSessionStatus("running", null); - } - return true; - }); - - /** - * Settles a request OpenCode answered or dropped on its own. T3's own - * answers are only forgotten: the orchestrator already recorded them. - */ - const settleRequest = Effect.fnUntraced(function* ( - entry: PendingRequest, - status: "resolved" | "cancelled", - ) { - if (!(yield* forgetRequest(entry)) || entry.answering) return; - const now = yield* DateTime.now; - const itemStatus = status === "resolved" ? "completed" : "cancelled"; - yield* emit({ - type: "runtime_request.updated", - driver, - threadId: entry.turn.input.threadId, - runtimeRequest: { ...entry.request, status, resolvedAt: now }, - }); - yield* emit({ - type: "node.updated", - driver, - node: { ...entry.node, status: itemStatus, completedAt: now }, - }); - yield* emit({ - type: "turn_item.updated", - driver, - turnItem: { ...entry.item, status: itemStatus, completedAt: now, updatedAt: now }, - }); - }); - - /** - * Stops a run no turn of T3's waits on (one a Stop left running, or one - * found asking after a reconnect): a reject without a message and a - * cancelled form both end OpenCode's execution. Its end is not a turn's. - */ - const stopStaleRequest = ( - sessionId: string, - native: { readonly type: "permission" | "form"; readonly id: string }, - ): Effect.Effect => - native.type === "permission" - ? client.permission - .reply({ - sessionID: Session.ID.make(sessionId), - requestID: Permission.ID.make(native.id), - decision: "reject", - }) - .pipe( - Effect.catchTags(permissionGone), - Effect.timeout(REQUEST_REPLY_TIMEOUT), - Effect.ignore({ log: true }), - ) - : client.session.form - .cancel({ sessionID: sessionId, formID: Form.ID.make(native.id) }) - .pipe( - Effect.catchTags(formGone), - Effect.timeout(REQUEST_REPLY_TIMEOUT), - Effect.ignore({ log: true }), - ); - - /** - * Stops what a session still waits on when this runtime first loads it: - * T3 shows none of those requests (a restart or a closed session expired - * them), and OpenCode would wait on them forever. - */ - const stopLeftoverRequests = Effect.fnUntraced(function* (state: ThreadState) { - const sessionID = Session.ID.make(state.sessionId); - const listed = yield* Effect.all([ - client.permission.list({ sessionID }), - client.session.form.list({ sessionID }), - ]).pipe(Effect.timeout(ACTIVE_CHECK_TIMEOUT), Effect.option); - if (listed._tag === "None") { - return yield* Effect.logWarning("Could not list an OpenCode session's open requests."); - } - const [permissions, forms] = listed.value; - if (permissions.length === 0 && forms.length === 0) return; - // The stopped run's end is not the next turn's. - state.unsettled = true; - for (const request of permissions) { - yield* stopStaleRequest(state.sessionId, { type: "permission", id: request.id }); - } - for (const form of forms) { - yield* stopStaleRequest(state.sessionId, { type: "form", id: form.id }); - } - }); - - /** - * Shows a permission ask or question form on the thread whose session (or - * subagent session) asked, under that thread's running turn. A request no - * turn of T3's is waiting on is left for OpenCode's own clients. - */ - const showRequest = Effect.fnUntraced(function* ( - sessionId: string, - native: PendingRequest["native"], - body: - | { - readonly type: "approval_request"; - readonly requestKind: Extract< - OrchestrationV2TurnItem, - { type: "approval_request" } - >["requestKind"]; - readonly prompt: string; - readonly options: Extract< - OrchestrationV2TurnItem, - { type: "approval_request" } - >["options"]; - } - | { - readonly type: "user_input_request"; - readonly questions: ReadonlyArray; - }, - ) { - const target = requestTurn(sessionId); - if (target === undefined) return; - const { state, turn } = target; - if ([...pending.values()].some((entry) => entry.native.id === native.id)) return; - const now = yield* DateTime.now; - const requestId = yield* idAllocator.allocate.runtimeRequest({ - driver, - providerTurnId: turn.providerTurn.id, - nativeRequestId: native.id, - }); - const nodeId = idAllocator.derive.approvalNode({ requestId }); - const request: OrchestrationV2RuntimeRequest = { - id: requestId, - nodeId, - providerTurnId: turn.providerTurn.id, - nativeRequestRef: ref(native.id), - kind: body.type === "approval_request" ? body.requestKind : "user_input", - status: "pending", - responseCapability: { type: "live", providerSessionId: input.providerSessionId }, - createdAt: now, - resolvedAt: null, - }; - const node: OrchestrationV2ExecutionNode = { - id: nodeId, - threadId: turn.input.threadId, - runId: turn.input.runId, - parentNodeId: turn.input.rootNodeId, - rootNodeId: turn.input.rootNodeId, - kind: body.type, - status: "waiting", - countsForRun: false, - providerThreadId: state.providerThread.id, - providerTurnId: turn.providerTurn.id, - nativeItemRef: ref(native.id), - runtimeRequestId: requestId, - checkpointScopeId: null, - startedAt: now, - completedAt: null, - }; - const base = { - id: idAllocator.derive.approvalTurnItem({ requestId }), - threadId: turn.input.threadId, - runId: turn.input.runId, - nodeId, - providerThreadId: state.providerThread.id, - providerTurnId: turn.providerTurn.id, - nativeItemRef: ref(native.id), - parentItemId: null, - ordinal: ordinalOf(turn, native.id), - status: "waiting" as const, - startedAt: now, - completedAt: null, - updatedAt: now, - }; - const item: OrchestrationV2TurnItem = - body.type === "approval_request" - ? { - ...base, - title: native.type === "permission" ? native.action : null, - type: "approval_request", - requestId, - requestKind: body.requestKind, - prompt: body.prompt, - ...(body.options === undefined ? {} : { options: body.options }), - } - : { - ...base, - title: "User input", - type: "user_input_request", - requestId, - questions: body.questions, - }; - pending.set(requestId, { - request, - item, - node, - state, - turn, - sessionId, - answering: false, - native, - }); - yield* emit({ type: "node.updated", driver, node }); - yield* emit({ - type: "runtime_request.updated", - driver, - threadId: turn.input.threadId, - runtimeRequest: request, - }); - yield* emit({ type: "turn_item.updated", driver, turnItem: item }); - yield* setSessionStatus("waiting", null); - }); - - const onPermissionAsked = Effect.fnUntraced(function* (data: Permission.Request) { - // The asking session's own turn knows the tool: a subagent's session runs its own. - const turn = threads.get(data.sessionID)?.active; - const toolName = - data.source === undefined ? undefined : turn?.tools.get(data.source.id)?.name; - const save = data.save ?? []; - yield* showRequest( - data.sessionID, - { type: "permission", id: data.id, action: data.action, resources: data.resources, save }, - { - type: "approval_request", - requestKind: openCodePermissionRequestKind(data.action, toolName), - prompt: data.resources.length === 0 ? data.action : data.resources.join("\n"), - // "Always" in OpenCode saves a grant for the whole project, so the - // session-wide choice is T3's own rule on this session instead. - options: [ - { decision: "cancel", label: "Cancel" }, - { decision: "decline", label: "Decline" }, - ...(save.length > 0 - ? [ - { - decision: "acceptForSession" as const, - label: sessionGrantLabel(data.action, save), - }, - ] - : []), - { decision: "accept", label: "Approve" }, - ], - }, - ); - }); - - const onFormCreated = Effect.fnUntraced(function* (form: NativeForm) { - const target = requestTurn(form.sessionID); - if (target === undefined) return; - const { state } = target; - const mapped = formQuestions(form); - if ("questions" in mapped) { - return yield* showRequest( - form.sessionID, - { type: "form", id: form.id, form }, - { type: "user_input_request", questions: mapped.questions }, - ); - } - // Cancelling ends OpenCode's execution as a user stop, so the turn is - // failed here with the reason and that stop's end is skipped. - yield* Effect.logWarning("Declined an OpenCode form T3 Code cannot show.", { - reason: mapped.unsupported, - }); - const cancelled = yield* deliver( - client.session.form.cancel({ sessionID: form.sessionID, formID: form.id }), - ); - // The session that asked is the one blocked on the form and stopped by - // its cancel: a subagent's own, whose parent reads its failed call and - // goes on. A subagent's session has no Stop end to skip. - const asker = threads.get(form.sessionID) ?? state; - if (!cancelled) return yield* abandonRequest(asker, "form cancel failed"); - if (asker.subagent === undefined) asker.unsettled = true; - yield* finishTurn(asker, { - status: "failed", - failure: makeProviderFailure({ - message: `OpenCode asked for ${mapped.unsupported}, which T3 Code can't show. The question was declined.`, - class: "provider_error", - }), - }); - }); - - /** - * A permission ask or question form, from the stream or read back after a - * reconnect: shown under the turn it blocks, or stopped when it is the run - * a Stop left behind, which nothing answers. - */ - const onAsked = Effect.fnUntraced(function* ( - asked: - | { readonly type: "permission"; readonly request: Permission.Request } - | { readonly type: "form"; readonly form: NativeForm }, - ) { - const asking = asked.type === "permission" ? asked.request.sessionID : asked.form.sessionID; - const state = ownerOf(asking); - if (state === undefined) return; - const target = requestTurn(asking); - if (target !== undefined && !target.turn.awaitingStart) { - if (asked.type === "permission") return yield* onPermissionAsked(asked.request); - return yield* onFormCreated(asked.form); - } - if (state.unsettled || state.active?.awaitingStart === true) { - yield* stopStaleRequest( - asking, - asked.type === "permission" - ? { type: "permission", id: asked.request.id } - : { type: "form", id: asked.form.id }, - ); - } - }); - - const sessionOfEvent = (event: OpenCode2StreamEvent) => - event.type === "unreadable.execution.ended" || event.type === "unreadable.execution.started" - ? event.sessionID - : "sessionID" in event.data && typeof event.data.sessionID === "string" - ? event.data.sessionID - : undefined; - - const executionEnd = (type: string) => - type === "session.execution.succeeded" || - type === "session.execution.failed" || - type === "session.execution.interrupted"; - - /** A `subagent` call's item, made when the model starts it. */ - const startCall = Effect.fnUntraced(function* ( - state: ThreadState, - turn: ActiveTurn, - toolId: string, - ) { - const nativeId = `${turn.scope}${toolId}`; - const call: SubagentCall = { - toolId, - nativeId, - state, - turn, - ordinal: ordinalOf(turn, nativeId), - startedAt: yield* DateTime.now, - prompt: "", - title: null, - agent: undefined, - model: null, - background: false, - child: undefined, - status: "running", - result: null, - completedAt: null, - }; - turn.usedSubagents = true; - state.calls.set(toolId, call); - yield* emitSubagent(call); - }); - - /** - * One event for a session's running turn: its text, tools, steps and end. - * A `subagent` call is its item and child thread rather than a tool. - */ - const onTurnEvent = Effect.fnUntraced(function* ( - state: ThreadState, - turn: ActiveTurn, - event: OpenCode2StreamEvent, - ) { - 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": - // Its form is the item the user answers; the tool call would repeat it. - if (event.data.name === "question") return; - if (event.data.name === "subagent") return yield* startCall(state, turn, event.data.id); - 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 call = state.calls.get(event.data.id); - if (call !== undefined) { - const { input } = event.data; - call.prompt = stringField(input, "prompt") ?? call.prompt; - call.title = stringField(input, "description") ?? call.title; - call.agent = stringField(input, "agent"); - call.model = stringField(input, "model") ?? call.model; - call.background = input["background"] === true; - return yield* emitSubagent(call); - } - 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.progress": { - const call = state.calls.get(event.data.id); - const childId = stringField(event.data.metadata, "sessionID"); - if (call !== undefined && childId !== undefined) yield* attachChild(call, childId); - return; - } - case "session.tool.success": { - const call = state.calls.get(event.data.id); - if (call !== undefined) { - const childId = stringField(event.data.metadata, "sessionID"); - if (childId !== undefined) yield* attachChild(call, childId); - // A background call returns at launch; its report settles it. - if (event.data.metadata?.["status"] === "running") return; - return yield* settleCall(call, "completed", subagentOutput(textOf(event.data.content))); - } - 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"; - const call = state.calls.get(event.data.id); - // A refused call (unknown agent, the nesting limit) is a failed subagent. - if (call !== undefined) { - return yield* settleCall( - call, - aborted ? "interrupted" : "failed", - event.data.error.message, - ); - } - 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; - } - // `/compact` and OpenCode's own compaction when the context fills - // (`reason: "auto"`) both run inside a turn's execution. - case "session.compaction.started": - turn.compaction = { - nativeId: `${turn.providerTurn.id}:compaction:${turn.compactions++}`, - startedAt: yield* DateTime.now, - }; - return yield* emitCompaction(state, turn, "running"); - case "session.compaction.ended": - return yield* emitCompaction(state, turn, "completed", event.data.text); - case "session.compaction.failed": - return yield* emitCompaction(state, turn, "failed"); - case "session.step.ended": - case "session.step.failed": { - const tokens = event.data.tokens; - if (tokens === undefined) return; - turn.steps += 1; - turn.lastStep = tokens; - turn.usage.input += tokens.input + tokens.cache.read + tokens.cache.write; - turn.usage.cached += tokens.cache.read; - turn.usage.cacheWrite += tokens.cache.write; - turn.usage.output += tokens.output + tokens.reasoning; - turn.usage.reasoning += tokens.reasoning; - return; - } - case "session.execution.started": - // The execution that delivers steers the last one ended without. - turn.heldEnd = undefined; - return; - case "session.execution.succeeded": - return yield* endExecution(state, turn, { - status: turn.interrupted ? "interrupted" : "completed", - }); - case "session.execution.interrupted": - return yield* endExecution(state, turn, { status: "interrupted" }); - case "session.execution.failed": - return yield* endExecution(state, turn, { - status: "failed", - failure: makeProviderFailure({ - message: event.data.error.message, - code: event.data.error.type, - class: "provider_error", - }), - }); - default: - return; - } - }); - - /** - * An execution OpenCode started on a thread's session with no turn of T3's - * running: the parent's answer to a background subagent's report. It is - * held for the continuation turn it asks for, or stopped when it only - * reports subagents a Stop ended. - */ - /** - * Takes the reports an execution OpenCode started on its own delivers. When - * they all report subagents a Stop ended, that execution only answers the - * Stop, so it is stopped and `stopped` is true. - */ - const takeReports = Effect.fnUntraced(function* (state: ThreadState) { - const delivered = [...state.reports.values()]; - state.reports.clear(); - const stopped = - delivered.length > 0 && - delivered.every((entry) => state.stoppedChildren.has(entry.childId)); - for (const entry of delivered) state.stoppedChildren.delete(entry.childId); - if (stopped) { - state.unsettled = true; - yield* client.session - .interrupt({ sessionID: Session.ID.make(state.sessionId) }) - .pipe(Effect.timeout(INTERRUPT_TIMEOUT), Effect.ignore({ log: true })); - } - return { delivered, stopped }; - }); - - const onWake = Effect.fnUntraced(function* (state: ThreadState) { - const { delivered, stopped } = yield* takeReports(state); - if (stopped) return; - const wake: Wake = { - events: [], - running: true, - reports: delivered.map((entry) => entry.report), - detail: delivered.length === 0 ? null : delivered.map((entry) => entry.text).join("\n\n"), - dropped: false, - after: delivered.at(-1)?.inboxId, - first: delivered[0]?.inboxId, - }; - state.wakes.push(wake); - yield* Effect.logInfo("OpenCode started a turn on its own; asking for a continuation.", { - providerThreadId: state.providerThread.id, - }); - yield* offerWake(state, wake); - }); - - /** A background subagent's end, as OpenCode queues it for its parent. */ - const onReport = Effect.fnUntraced(function* ( - state: ThreadState, - inboxId: string, - payload: { - readonly text: string; - readonly metadata?: Readonly> | undefined; - }, - ) { - const childId = stringField(payload.metadata, "childID"); - if (stringField(payload.metadata, "source") !== "subagent" || childId === undefined) return; - const call = [...state.calls.values()].find( - (candidate) => candidate.child?.sessionId === childId, - ); - const outcome = reportOutcome(stringField(payload.metadata, "state")); - state.reports.set(inboxId, { - inboxId, - childId, - text: payload.text, - report: { - kind: "subagent", - label: call?.title ?? stringField(payload.metadata, "description"), - childThreadId: call?.child?.subagent?.appThread.id, - outcome, - }, - }); - if (call === undefined) return; - yield* settleCall( - call, - state.stoppedChildren.has(childId) - ? "interrupted" - : outcome === "failed" - ? "failed" - : outcome === "cancelled" - ? "cancelled" - : "completed", - subagentOutput(payload.text), - ); - }); - - /** - * The thread whose held execution an event belongs to: the thread's own - * session, or a subagent session its held execution started. - */ - const holderOf = (sessionId: string) => { - const own = threads.get(sessionId); - if (own !== undefined) return own.wakes.at(-1)?.running === true ? own : undefined; - // A subagent a held execution started is named only when that execution replays. - const parent = announced.get(sessionId)?.parentID; - const holder = parent === undefined ? undefined : threads.get(parent); - return holder !== undefined && holder.active === undefined && holder.wakes.length > 0 - ? holder - : undefined; - }; - - /** - * Ends the turn with its execution, unless a steer sent into it is still - * undelivered: OpenCode then starts another execution for it, which the - * turn owns too. A Stop leaves steers undelivered and starts nothing. - */ - const endExecution = Effect.fnUntraced(function* ( - state: ThreadState, - turn: ActiveTurn, - end: TurnTerminal, - ) { - if (end.status === "completed" && turn.steers.size > 0) { - turn.heldEnd = end; - return; - } - yield* finishTurn(state, end); - }); - - /** Ends a turn held for its steers once none is left to deliver. */ - const endIfSettled = (state: ThreadState, turn: ActiveTurn) => - turn.heldEnd !== undefined && turn.steers.size === 0 - ? finishTurn(state, turn.heldEnd) - : Effect.void; - - const handleEvent = Effect.fnUntraced(function* (event: OpenCode2StreamEvent) { - const sessionId = sessionOfEvent(event); - // `revert.clear` wakes the session into an empty execution of its own - // (2.0.18's `Session.revert.clear` ends with a wake). It is no run of - // T3's, and no follow-up to a subagent either. - const cleared = sessionId === undefined ? undefined : clearing.get(sessionId); - if (cleared !== undefined) { - if (event.type === "unreadable.execution.ended" || executionEnd(event.type)) { - return yield* Deferred.succeed(cleared, undefined); - } - if ( - event.type === "session.execution.started" || - event.type === "unreadable.execution.started" - ) { - return; - } - } - if (sessionId !== undefined) { - if ( - event.type === "session.execution.started" || - event.type === "unreadable.execution.started" - ) { - busy.add(sessionId); - } else if (event.type === "unreadable.execution.ended" || executionEnd(event.type)) { - busy.delete(sessionId); - } - } - // A subagent's session: named on its parent's `subagent` call just after. - if (event.type === "session.created" && event.data.parentID !== undefined) { - const owner = ownerOf(event.data.parentID); - if (owner !== undefined) { - childOwners.set(event.data.sessionID, owner); - announced.set(event.data.sessionID, event.data); - } - return; - } - // What a held execution does, and what the subagents it starts do, - // waits for the turn that takes it. - const holder = sessionId === undefined ? undefined : holderOf(sessionId); - const held = holder?.wakes.at(-1); - if (holder !== undefined && held !== undefined) { - held.events.push(event); - if ( - sessionId === holder.sessionId && - (event.type === "unreadable.execution.ended" || executionEnd(event.type)) - ) { - held.running = false; - } - return; - } - return yield* route(event, sessionId); - }); - - /** Handles one event for its session, as the turn running there sees it. */ - const route = Effect.fnUntraced(function* ( - event: OpenCode2StreamEvent, - sessionId: string | undefined, - ) { - // The end of the run a timed-out Stop left behind; no turn is its own. - const ended = - event.type === "unreadable.execution.ended" || executionEnd(event.type) - ? threads.get(sessionId ?? "") - : undefined; - if (ended?.unsettled === true) { - ended.unsettled = false; - return; - } - // Marks where a running turn's own execution begins; it never ends one. - // With no turn running it is a subagent's or a follow-up's start, below. - if (event.type === "unreadable.execution.started") { - const turn = threads.get(event.sessionID)?.active; - if (turn !== undefined) { - turn.awaitingStart = false; - turn.heldEnd = undefined; - return; - } - } - if (event.type === "unreadable.execution.ended") { - const state = threads.get(event.sessionID); - const turn = state?.active; - if (state === undefined || turn === undefined || turn.awaitingStart) return; - return yield* endExecution( - state, - turn, - event.executionType === "session.execution.succeeded" - ? { status: turn.interrupted ? "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") { - return yield* onAsked({ type: "permission", request: event.data }); - } - if (event.type === "form.created") - return yield* onAsked({ type: "form", form: event.data.form }); - // Answered in another OpenCode client, or dropped by OpenCode: a reject - // it sends on its own (a Stop, or another reject in the same session) - // cancels the request. T3's own answers are settled where they are sent. - if ( - event.type === "permission.replied" || - event.type === "form.replied" || - event.type === "form.cancelled" - ) { - const nativeId = event.type === "permission.replied" ? event.data.requestID : event.data.id; - const entry = [...pending.values()].find((candidate) => candidate.native.id === nativeId); - if (entry === undefined || entry.answering) return; - const answered = - event.type === "form.replied" || - (event.type === "permission.replied" && event.data.reply !== "reject"); - return yield* settleRequest(entry, answered ? "resolved" : "cancelled"); - } - if (sessionId === undefined) return; - const state = threads.get(sessionId); - if (state === undefined) return; - if (event.type === "session.model.selected" || event.type === "session.step.started") { - if (event.type === "session.model.selected") state.model = event.data.model; - const providerThread = withReportedModel(state.providerThread, event.data.model); - if (providerThread !== state.providerThread) { - state.providerThread = { ...providerThread, updatedAt: yield* DateTime.now }; - yield* emit({ - type: "provider_thread.updated", - driver, - providerThread: state.providerThread, - }); - } - } - if ( - event.type === "session.inbox.enqueued" && - state.subagent !== undefined && - state.active === undefined - ) { - state.subagent.queued ??= event.data.inboxID; - } - if (event.type === "session.inbox.enqueued" && event.data.item.type === "synthetic") { - return yield* onReport(state, event.data.inboxID, event.data.item.payload); - } - // Cancelled, or delivered into a turn already running: no wake reports it. - // A steer of the running turn's is settled the same way. - if (event.type === "session.inbox.cancelled" || event.type === "session.inbox.delivered") { - state.reports.delete(event.data.inboxID); - const turn = state.active; - if (turn !== undefined && turn.steers.delete(event.data.inboxID)) { - turn.settledInbox.add(event.data.inboxID); - // A cancelled steer may leave a held turn nothing to wait for. - if (event.type === "session.inbox.cancelled") yield* endIfSettled(state, turn); - } - return; - } - // A subagent's later prompts (a resumed subagent) open its next turn's message. - if ( - event.type === "session.inbox.enqueued" && - event.data.item.type === "user" && - state.subagent !== undefined && - state.subagent.prompt === undefined && - state.active === undefined - ) { - state.subagent.prompt = event.data.item.payload.text; - return; - } - const started = - event.type === "session.execution.started" || event.type === "unreadable.execution.started"; - // Each execution of a subagent's session is a turn on its child thread, - // unless it only answers the reports of nested subagents a Stop ended. - if (state.subagent !== undefined && state.active === undefined && started) { - if ((yield* takeReports(state)).stopped) { - state.subagent.queued = undefined; - return; - } - return yield* startChildTurn(state); - } - const turn = state.active; - if (turn === undefined) { - // OpenCode started the thread's session on its own. - if (started && state.subagent === undefined) return yield* onWake(state); - 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; - } - return yield* onTurnEvent(state, turn, event); - }); - - // The stream is the only terminal signal, and it is volatile: events sent - // while it is down are gone, and a restarted server never ends the - // execution it lost. So a lost stream reconnects, then reconciles each - // running turn from the server's own state (see `reconcile`). Only when - // reconnecting keeps failing are the turns failed and the session broken, - // so T3 reopens it. Set first, so a turn starting meanwhile waits or refuses. - let streamFailure: string | undefined; - let reconnected = yield* Deferred.make(); - const failAll = Effect.fnUntraced(function* (message: string) { - streamFailure = message; - yield* Deferred.succeed(reconnected, undefined); - for (const state of threads.values()) { - const failure = makeProviderFailure({ message, class: "transport_error" }); - yield* finishTurn(state, { status: "failed", failure }, "broken"); - } - // Nothing can report a background subagent's end any more. - for (const state of threads.values()) { - for (const call of runningCalls(state)) yield* settleCall(call, "failed"); - for (const wake of state.wakes.splice(0)) wake.dropped = true; - state.reports.clear(); - } - yield* setSessionStatus("error", message); - yield* Queue.end(events); - }); - /** - * Emits what a running turn missed while the stream was down, from the - * session's history since the turn began: text, reasoning and tools, each - * under the same native id its live events would have used, so nothing - * already shown is duplicated. A turn begins at its prompt, whose id T3 - * chose (`nativeTurnRef`), or after `before` for a `/name` command or a - * continuation. - * Returns how that history says the turn's execution ended: the `idle` item - * OpenCode appends after each one, or undefined when there is none after - * the turn's start. - */ - const backfill = Effect.fnUntraced(function* (sessionId: string, state: ThreadState) { - const turn = state.active; - if (turn === undefined) return undefined; - const promptId = promptOf(turn.providerTurn); - const { before } = turn; - if (promptId === undefined && before === undefined) return undefined; - const start = before !== undefined ? before : promptId; - const read = yield* paginate( - { sessionID: Session.ID.make(sessionId), order: "desc" as const, limit: 50 }, - client.message.list, - ).pipe( - Stream.takeUntil((message) => message.id === start), - Stream.runCollect, - ); - // Without its start the history holds only earlier turns (the stream - // dropped before the prompt landed): none of it is this turn's. A null - // `before` is a command that started on an empty history. - const found = start === null || read.at(-1)?.id === start; - if (!found) return undefined; - const recent = before !== undefined && before !== null ? read.slice(0, -1) : read; - const idle = recent.find((message) => message.type === "idle"); - for (const message of recent.toReversed()) { - if (message.type !== "assistant" || state.active !== turn) continue; - const ordinals = { text: 0, reasoning: 0 }; - for (const part of message.content) { - if (part.type === "text" || part.type === "reasoning") { - const block = { assistantMessageID: message.id, ordinal: ordinals[part.type]++ }; - yield* emitText(state, turn, block, part.type, () => part.text, true); - continue; - } - if (part.type !== "tool") continue; - if (!turn.tools.has(part.id)) { - turn.tools.set(part.id, { name: part.name, input: {} }); - turn.startedAt.set(part.id, yield* DateTime.now); - } - const tool = turn.tools.get(part.id)!; - if (typeof part.state.input === "object" && part.state.input !== null) { - tool.input = part.state.input as Record; - } - if (part.state.status === "completed") { - yield* emitTool(state, turn, part.id, "completed", { - output: textOf(part.state.content), - metadata: part.state.metadata, - }); - turn.tools.delete(part.id); - } else if (part.state.status === "error") { - yield* emitTool(state, turn, part.id, "failed", { - output: part.state.error.message, - metadata: part.state.metadata, - }); - turn.tools.delete(part.id); - } else { - yield* emitTool(state, turn, part.id, "running"); - } - } - } - return idle === undefined ? undefined : { outcome: idle.outcome }; - }); - - /** - * Settles each thread turn that was running when the stream dropped, from - * the server: a session still running keeps its turn (its next events - * arrive on the new stream); one that stopped ends the turn with the - * outcome of the `idle` item after the turn's start. A restarted server - * writes no such item for the execution it lost, and `session.outcome` is - * still the previous execution's, so no `idle` means the turn was - * interrupted. A continuation turn is settled the same way, from the report - * its execution answers. A turn still holding for an undelivered steer only - * ends once the session is idle. Permission asks and question forms are - * read back from every session still running, so a run that asked during - * the gap can be answered. - * - * What the stream carried outside a turn is not read back: a background - * subagent's report, a follow-up OpenCode started on its own, or one held - * for its continuation turn. So background work is settled, not resumed: a - * subagent whose session stopped ends as interrupted, and a thread's held - * follow-ups and queued reports are dropped, so nothing waits on an event - * that already went by. - */ - const reconcile = Effect.gen(function* () { - // A subagent's turn ends before the turn that waits on it, so the - // deepest sessions are read back first. - const running = [...threads] - .filter(([, state]) => state.active !== undefined) - .toSorted(([left], [right]) => callsAbove(right).length - callsAbove(left).length); - const background = [...threads.values()].filter( - (state) => state.subagent === undefined && hasBackground(state), - ); - if (running.length === 0 && background.length === 0 && busy.size === 0) return; - const active = yield* client.session.active(); - // An execution that ended while the stream was down never said so. - for (const sessionId of busy) if (!(sessionId in active)) busy.delete(sessionId); - for (const [sessionId, state] of running) { - const turn = state.active; - if (turn === undefined) continue; - const ended = yield* backfill(sessionId, state); - if (sessionId in active || state.active !== turn) continue; - yield* finishTurn( - state, - ended?.outcome === "succeeded" - ? { status: turn.interrupted ? "interrupted" : "completed" } - : ended?.outcome === "failed" - ? { - status: "failed", - failure: makeProviderFailure({ - message: - "OpenCode ended the turn with an error while T3 Code was reconnecting.", - class: "provider_error", - }), - } - : { status: "interrupted" }, - ); - } - for (const state of background) { - // A subagent still running keeps its call; its end arrives on the new stream. - for (const call of runningCalls(state)) { - const child = call.child?.sessionId; - if (child === undefined || !(child in active)) { - yield* settleCall(call, "interrupted", LOST_BACKGROUND); - } - } - // A subagent's session holds reports of its own background subagents. - for (const session of sessionsOf(state)) { - for (const wake of session.wakes.splice(0)) wake.dropped = true; - session.reports.clear(); - } - } - // A request asked while the stream was down was never shown, and the - // run waits on it; one T3 shows that OpenCode no longer lists was - // answered elsewhere or dropped with its execution. A session that - // stopped dropped its requests. - const listed = new Map>(); - for (const sessionId of Object.keys(active)) { - if (ownerOf(sessionId) === undefined) continue; - const sessionID = Session.ID.make(sessionId); - const [permissions, forms] = yield* Effect.all([ - client.permission.list({ sessionID }), - client.session.form.list({ sessionID }), - ]); - listed.set(sessionId, new Set([...permissions, ...forms].map((request) => request.id))); - const shown = new Set([...pending.values()].map((entry) => entry.native.id)); - for (const request of permissions) { - if (!shown.has(request.id)) yield* onAsked({ type: "permission", request }); - } - for (const form of forms) { - if (!shown.has(form.id)) yield* onAsked({ type: "form", form }); - } - } - // A snapshot: settling a request removes it from the map. - for (const entry of Array.from(pending.values())) { - if (listed.get(entry.sessionId)?.has(entry.native.id) !== true) { - yield* settleRequest(entry, "cancelled"); - } - } - }); - - /** - * Subscribes again, on a restarted server if the old one is gone, and - * reconciles. Retried a few times; the caller fails everything after that. - */ - const reconnect = Effect.gen(function* () { - // Returned on any failure, including the interrupt a closing session - // sends a reconnect still in flight, so a spawned server can idle-stop. - const scope = yield* Scope.make(); - const { next, stream } = yield* Effect.gen(function* () { - const next = yield* borrow.pipe(Effect.provideService(Scope.Scope, scope)); - return { next, stream: yield* next.events }; - }).pipe(Effect.onError(() => Scope.close(scope, Exit.void))); - const previous = currentScope; - connection = next; - client = next.client; - currentScope = scope; - yield* Scope.close(previous, Exit.void); - // A restarted server forgot T3's MCP servers; the next turn adds them again. - for (const state of threads.values()) state.mcp = undefined; - yield* lock.withPermit(reconcile).pipe(Effect.timeout(RECONCILE_TIMEOUT)); - return stream; - }).pipe( - Effect.retry({ times: RECONNECT_ATTEMPTS - 1, schedule: Schedule.spaced(RECONNECT_DELAY) }), - ); - - let currentScope = initial.scope; - // The borrow in use when the session closes is returned with it, so a - // spawned server can still reach its idle shutdown. - yield* Effect.addFinalizer(() => Scope.close(currentScope, Exit.void)); - const follow = (stream: Stream.Stream): Effect.Effect => - stream.pipe( - Stream.runForEach((event) => lock.withPermit(handleEvent(event))), - Effect.exit, - Effect.flatMap(() => - Effect.gen(function* () { - streamFailure = "The OpenCode event stream was lost. Reconnecting."; - yield* Effect.logWarning("Lost the OpenCode event stream; reconnecting."); - // The last failure is kept for the log: it can carry the server's - // URL or response text, so only its tag is annotated and the full - // failure stays in the log's cause. - const next = yield* reconnect.pipe( - Effect.tapCause((cause) => - Effect.logWarning( - "Could not reconnect to the OpenCode event stream; failing the session.", - cause, - ).pipe(Effect.annotateLogs({ errorTag: causeErrorTag(cause) })), - ), - Effect.option, - ); - if (next._tag === "None") { - return yield* failAll("The OpenCode event stream was lost and could not reconnect."); - } - streamFailure = undefined; - const done = reconnected; - reconnected = yield* Deferred.make(); - yield* Deferred.succeed(done, undefined); - return yield* follow(next.value); - }), - ), - ); - // Subscribed before any session or prompt call, so no event of theirs is missed. - yield* follow(yield* connection.events).pipe(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 }, - ), - ); - } - - // Context windows come from the server's model list for a directory, read - // when the session opens, before a thread first runs in another directory, - // and again after a turn whose model had none yet. A directory counts as - // known once its read starts, so a failed read is retried only after a turn. - const readModels = (directory: string) => - Effect.suspend(() => { - const windows = contextWindows.get(directory) ?? new Map(); - contextWindows.set(directory, windows); - return client.model.list({ location: { directory } }).pipe( - Effect.timeout("5 seconds"), - Effect.tap((models) => - Effect.sync(() => { - for (const model of models.data) { - // A model's input limit, when it has one, is its real headroom. - windows.set( - `${model.providerID}/${model.id}`, - model.limit.input ?? model.limit.context, - ); - } - }), - ), - Effect.ignore({ log: true }), - ); - }); - const readModelsOnce = (cwd: string | null | undefined) => - contextWindows.has(directoryOf(cwd)) ? Effect.void : readModels(directoryOf(cwd)); - yield* readModels(session.cwd); - - // Each agent's own path allows, by the directory they were listed for. - const agentRules = new Map>>(); - const pathsFor = Effect.fnUntraced(function* ( - directory: string, - agents: ReadonlyArray, - ) { - let known = agentRules.get(directory); - if (known === undefined) { - // A fresh server lists no agents for its first moments, like its models. - const listed = yield* client.agent.list({ location: { directory } }).pipe( - Effect.repeat({ - until: (list) => list.data.length > 0, - schedule: Schedule.spaced("250 millis"), - }), - Effect.timeout("5 seconds"), - // The failure can carry the server's URL or response text, so only its - // tag is annotated; the full failure stays in the log's cause. - Effect.tapCause((cause) => - Effect.logWarning( - "Could not list OpenCode agents; their path rules are skipped.", - cause, - ).pipe(Effect.annotateLogs({ errorTag: causeErrorTag(cause) })), - ), - Effect.option, - ); - if (listed._tag === "None") return []; - known = new Map( - listed.value.data.map((agent) => [agent.id, agentPaths(agent.permissions)]), - ); - agentRules.set(directory, known); - } - const paths = new Map(); - for (const agent of agents) { - for (const entry of known.get(agent) ?? []) { - paths.set(`${entry.action}\u0000${entry.resource}`, entry); - } - } - return [...paths.values()]; - }); - - /** - * The rules a thread's session runs `policy` with. Full access allows - * every path already, so the agent's own path rules are read only for the - * modes that narrow it. - */ - const rulesFor = Effect.fnUntraced(function* ( - thread: Pick, - policy: RulesPolicy, - appThreadId: string | null, - ) { - const plan = policy.interactionMode === "plan"; - const paths = - policy.runtimeMode === "full-access" && !plan - ? [] - : yield* pathsFor(thread.directory, plan ? [thread.agent, "plan"] : [thread.agent]); - return sessionRules( - policy, - paths, - policy.runtimeMode === "full-access" ? [] : thread.grants, - appThreadId === null ? null : yield* mcpServerNameFor(appThreadId), - ); - }); - - /** Writes the session's rules for `policy` when they differ from what it has. */ - const writeRules = Effect.fnUntraced(function* (state: ThreadState, policy: RulesPolicy) { - // A subagent's session may use its thread's T3 server, the root's. - const rules = yield* rulesFor(state, policy, rootOf(state).providerThread.appThreadId); - if (!sameRules(state.rules, rules)) { - yield* client.session.update({ - sessionID: Session.ID.make(state.sessionId), - permissions: rules, - }); - state.rules = rules; - } - state.policy = policy; - }); - - const register = ( - providerThread: OrchestrationV2ProviderThread, - native: { - readonly id: string; - readonly model?: ModelRef | undefined; - readonly agent?: string | undefined; - readonly permissions?: ReadonlyArray | undefined; - }, - directory: string, - ) => { - providerThread = withReportedModel(providerThread, native.model); - const existing = threads.get(native.id); - if (existing !== undefined) { - existing.providerThread = providerThread; - existing.model = native.model; - existing.directory = directory; - existing.agent = native.agent ?? existing.agent; - existing.rules = native.permissions; - return existing; - } - const state = newThreadState(native.id, providerThread, directory, undefined); - state.model = native.model; - state.agent = native.agent ?? state.agent; - state.rules = native.permissions; - threads.set(native.id, state); - return state; - }; - - /** - * Refuses to cut or copy a session's history while something writes to - * it: a turn of T3's, an execution seen running on the stream (a held - * follow-up), or a run on the server this runtime does not own: one a - * timed-out Stop or an unanswered request left behind, or any run on a - * session loaded after the server outlived T3. Only that last case asks - * the server. A rollback also waits for background subagents, whose - * reports wake the session into the history it would cut. - */ - const ensureQuiet = Effect.fnUntraced(function* ( - sessionId: string, - verb: "roll back" | "fork", - ) { - const state = threads.get(sessionId); - const busyHere = - state !== undefined && - (state.active !== undefined || - busy.has(sessionId) || - (verb === "roll back" && hasBackground(state))); - const busyThere = - !busyHere && - (state === undefined || state.unsettled) && - sessionId in (yield* client.session.active().pipe(Effect.timeout(ACTIVE_CHECK_TIMEOUT))); - if (busyHere || busyThere) { - return yield* new ProviderAdapter.ProviderAdapterProtocolError({ - driver: OPENCODE_PROVIDER, - detail: `Cannot ${verb} OpenCode session ${sessionId} while it is still working`, - }); - } - }); - - /** - * Takes back steers a finished turn left in OpenCode's inbox. One whose - * cancel fails may still be there, so it is tried again before the next prompt. - */ - const cancelStrandedSteers = Effect.fnUntraced(function* (state: ThreadState) { - const sessionID = Session.ID.make(state.sessionId); - // A snapshot: a cancelled steer leaves the set. - for (const inboxID of Array.from(state.strandedSteers)) { - const cancelled = yield* client.session.inbox - .cancel({ sessionID, inboxID: SessionMessage.ID.make(inboxID) }) - .pipe( - Effect.timeout(REQUEST_REPLY_TIMEOUT), - Effect.tapCause((cause) => - Effect.logWarning("Could not take back an OpenCode steer.", cause), - ), - Effect.exit, - Effect.map(Exit.isSuccess), - ); - if (cancelled) state.strandedSteers.delete(inboxID); - } - }); - - /** - * Clears a staged revert and waits out the empty execution `clear` runs, - * which is no turn of T3's. 2.0.18 wakes the session after every clear, - * with or without a stage (seen live), so that execution always comes. - */ - const clearRevert = Effect.fnUntraced(function* (sessionId: string) { - const settled = yield* Deferred.make(); - clearing.set(sessionId, settled); - yield* client.session.revert - .clear({ sessionID: Session.ID.make(sessionId) }) - .pipe( - Effect.andThen(Deferred.await(settled)), - Effect.timeout(REQUEST_REPLY_TIMEOUT), - Effect.ensuring(Effect.sync(() => clearing.delete(sessionId))), - ); - stagedReverts.delete(sessionId); - }); - - /** - * Truncates the session's history before `boundary`: OpenCode stages the - * revert, then commits it. If either fails or is interrupted the stage is - * cleared, since OpenCode commits a staged revert on the next prompt; a - * clear that fails too is tried again before that prompt. Files stay where - * they are: T3 restores its own checkpoint when the user asked for files, - * and a rewind without files must not touch them. - */ - const rollBackTo = Effect.fnUntraced(function* ( - sessionId: string, - boundary: SessionMessage.ID, - ) { - const sessionID = Session.ID.make(sessionId); - // Each step is bounded: a server that never answers would otherwise - // hold the session's gate, and the next turn with it. - yield* client.session.revert.stage({ sessionID, messageID: boundary, files: false }).pipe( - Effect.timeout(REQUEST_REPLY_TIMEOUT), - Effect.andThen( - client.session.revert.commit({ sessionID }).pipe(Effect.timeout(REQUEST_REPLY_TIMEOUT)), - ), - Effect.onError(() => - clearRevert(sessionId).pipe( - Effect.tapError(() => Effect.sync(() => stagedReverts.add(sessionId))), - Effect.ignore({ log: true }), - ), - ), - ); - }); - - /** - * The ids fork and rollback can cut before: the session's prompts, and the - * reports continuations answer. The list takes one type, so it reads all. - */ - const userMessages = (sessionId: string) => - paginate({ sessionID: Session.ID.make(sessionId), limit: 100 }, client.message.list).pipe( - Stream.runCollect, - Effect.map( - (messages) => - new Set( - messages.flatMap((message) => - message.type === "user" || message.type === "synthetic" ? [String(message.id)] : [], - ), - ), - ), - ); - - /** The session's history as V2 messages, read after any change T3 made to it. */ - const snapshotOf = Effect.fnUntraced(function* ( - providerThread: OrchestrationV2ProviderThread, - sessionId: string, - ) { - const history = yield* paginate( - { sessionID: Session.ID.make(sessionId), order: "asc" as const, limit: 100 }, - client.message.list, - ).pipe(Stream.runCollect); - const snapshotAt = yield* DateTime.now; - const messages = history.flatMap((message): Array => { - const text = - message.type === "user" - ? message.text - : message.type === "assistant" - ? textOf(message.content) - : ""; - if (text.length === 0 || (message.type !== "user" && message.type !== "assistant")) { - return []; - } - const createdAt = message.time.created; - return [ - { - createdBy: message.type === "user" ? "user" : "agent", - creationSource: "provider", - id: idAllocator.derive.messageFromProviderItem({ - driver, - nativeItemId: message.id, - }), - threadId: providerThread.appThreadId ?? input.threadId, - runId: null, - nodeId: null, - role: message.type, - text, - attachments: [], - streaming: false, - createdAt, - updatedAt: createdAt, - }, - ]; - }); - const lastUser = history.findLast((message) => message.type === "user")?.id; - const state = threads.get(sessionId); - return { - providerThread: { - ...providerThread, - providerSessionId: input.providerSessionId, - nativeConversationHeadRef: lastUser === undefined ? null : ref(lastUser, "weak"), - status: "idle" as const, - updatedAt: snapshotAt, - }, - providerTurns: state === undefined ? [] : [...state.providerTurns.values()], - messages, - runtimeRequests: [], - }; - }); - - const promptText = (turnInput: ProviderAdapter.ProviderAdapterV2TurnInput) => - providerMessageTextWithAttachmentPaths({ - text: turnInput.message.text, - attachments: turnInput.message.attachments, - resolveAttachmentPath: (attachment) => - resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }), - }).trim(); - - const removeMcp = (mcp: { readonly name: string; readonly directory: string }) => - Effect.suspend(() => - client.mcp.remove({ server: mcp.name, location: { directory: mcp.directory } }), - ).pipe(Effect.timeout("5 seconds"), Effect.ignore({ log: true })); - - // T3's MCP registrations outlive a session only on an external server; a - // spawned one forgets them when it stops. - yield* Effect.addFinalizer(() => - Effect.forEach( - [...threads.values()].flatMap((state) => (state.mcp === undefined ? [] : [state.mcp])), - removeMcp, - { concurrency: 8, discard: true }, - ), - ); - - /** - * T3's MCP server for this thread, and the per-turn instructions. The MCP - * server is registered for the session's directory under the thread's own - * name and credential (the session rules allow only it), and removed when - * the thread unloads or the session closes. OpenCode 2 has no per-prompt - * system field, so the instructions are a session instructions entry, - * which applies from the next step; it is only rewritten when it changes. - */ - const prepareTurn = Effect.fnUntraced(function* ( - sessionId: string, - state: ThreadState, - turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, - ) { - const mcpSession = McpProviderSession.readMcpProviderSession(turnInput.threadId); - const directory = turnInput.runtimePolicy.cwd ?? serverConfig.cwd; - const name = yield* mcpServerNameFor(turnInput.threadId); - // An external server may not reach T3's MCP endpoint, as with 1.x. - const wanted = - mcpSession === undefined || connection.external - ? undefined - : { name, directory, credential: mcpSession.authorizationHeader }; - if ( - state.mcp !== undefined && - (wanted === undefined || - state.mcp.directory !== wanted.directory || - state.mcp.credential !== wanted.credential) - ) { - yield* removeMcp(state.mcp); - state.mcp = undefined; - } - // T3's tools are an addition: a server that cannot add them still runs the turn. - if (wanted !== undefined && state.mcp === undefined) { - const added = yield* client.mcp - .add({ - server: name, - location: { directory }, - config: new Mcp.RemoteConfig({ - type: "remote", - url: mcpSession!.endpoint, - headers: { Authorization: wanted.credential }, - oauth: false, - }), - }) - .pipe( - Effect.timeout(INVENTORY_TIMEOUT), - Effect.as(true), - Effect.catchCause((cause) => - Effect.logWarning("Could not add T3 Code's MCP server to OpenCode.", cause).pipe( - Effect.as(false), - ), - ), - ); - if (added) state.mcp = wanted; - } - const instructions = [ - buildRuntimeInstructions({ harness: "OpenCode", model: turnInput.modelSelection.model }), - t3OrchestrationSystemPrompt(state.mcp !== undefined), - ] - .filter((part) => part !== undefined && part.length > 0) - .join("\n\n"); - if (instructions !== state.instructions) { - yield* client.session.instructions.entry.put({ - sessionID: Session.ID.make(sessionId), - key: INSTRUCTIONS_KEY, - value: instructions, - }); - state.instructions = instructions; - } - }); - - const markBefore = (sessionId: string, before: string | null) => - Effect.sync(() => { - const turn = threads.get(sessionId)?.active; - if (turn !== undefined) turn.before = before; - }); - - /** Installs a turn T3 started; every path after it ends the turn with a terminal. */ - const beginTurn = ( - state: ThreadState, - turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, - awaitingStart: boolean, - ) => - Effect.gen(function* () { - const startedAt = yield* DateTime.now; - const nativeTurnId = `${state.sessionId}:attempt:${turnInput.attemptId}`; - const providerTurn: OrchestrationV2ProviderTurn = { - id: idAllocator.derive.providerTurn({ driver, nativeTurnId }), - providerThreadId: turnInput.providerThread.id, - nodeId: turnInput.rootNodeId, - runAttemptId: turnInput.attemptId, - // The user message it prompts with, where fork and rollback cut. A - // continuation prompts nothing; it records the report it answers - // once it takes its execution. - nativeTurnRef: ref( - isContinuation(turnInput) - ? wakeTurnId(state.sessionId, turnInput.attemptId) - : turnPromptId(state.sessionId, turnInput.attemptId), - "weak", - ), - ordinal: turnInput.providerTurnOrdinal, - status: "running", - startedAt, - completedAt: null, - }; - const turn = makeTurn(turnInput, providerTurn, { scope: "", awaitingStart }); - // 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; - }); - - /** Feeds a held execution's events to the turn now running on its session. */ - const replay = Effect.fnUntraced(function* (wake: Wake) { - wake.dropped = true; - for (const event of wake.events) yield* route(event, sessionOfEvent(event)); - }); - - /** - * A user turn that starts while OpenCode runs an execution on its own: the - * prompt joins that execution, so the turn takes it, and its continuation - * turn is no longer needed. Executions that already ended stay for theirs. - */ - const takeRunningWake = Effect.fnUntraced(function* (state: ThreadState) { - const wake = state.wakes.at(-1); - if (wake?.running !== true) return; - state.wakes.pop(); - yield* replay(wake); - }); - - /** - * The continuation turn for an execution OpenCode started on its own: it - * takes the oldest one held and ends with it. One already taken (a user - * turn joined it) leaves nothing to run, so the turn ends at once. - */ - const runWake = Effect.fnUntraced(function* ( - state: ThreadState, - turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, - ) { - yield* beginTurn(state, turnInput, false); - yield* lock.withPermit( - Effect.gen(function* () { - const wake = state.wakes.shift(); - if (wake === undefined) return yield* finishTurn(state, { status: "completed" }); - const turn = state.active; - if (turn !== undefined && wake.after !== undefined) turn.before = wake.after; - // Its first report is where its history begins, so fork and rollback cut there. - if (turn !== undefined && wake.first !== undefined) { - turn.providerTurn = { ...turn.providerTurn, nativeTurnRef: ref(wake.first, "weak") }; - } - yield* replay(wake); - }), - ); - }); - - /** - * Stops a thread's background subagents and any execution OpenCode is - * running on its own. Their sessions end as interrupted; the reports - * OpenCode then queues wake the parent into an execution that is stopped - * as well, so no turn takes it. A subagent the Stop did not reach runs on, - * so its call stays tracked: the session is not released under it, and - * the next Stop tries it again. - */ - const stopBackground = Effect.fnUntraced(function* (state: ThreadState) { - const calls = runningCalls(state).filter((call) => call.background); - // Every session under the thread, which a stopped subagent may report to. - const callers = sessionsOf(state); - // A subagent whose call ended runs again when OpenCode wakes it to - // answer a report of its own background subagent. - const followUps = callers.filter( - (caller) => - caller.subagent !== undefined && - busy.has(caller.sessionId) && - !isOrchestrationV2WorkActive(caller.subagent.call.status), - ); - // OpenCode announces a child's session before the call's progress names - // it, so a call without a child yet is stopped through its caller's - // announced children. - const announcedTo = (caller: ThreadState) => - [...announced.values()].flatMap((info) => - info.parentID === caller.sessionId ? [info.sessionID] : [], - ); - const childrenOf = (call: SubagentCall) => - call.child === undefined ? announcedTo(call.state) : [call.child.sessionId]; - // Each child reports to the session that called it, so its marker goes - // on that caller's state: the thread's own, or a subagent's for a nested one. - const children = new Map( - calls.flatMap((call) => childrenOf(call).map((childId) => [childId, call.state] as const)), - ); - const unreached = new Set(); - for (const [childId, caller] of children) { - caller.stoppedChildren.add(childId); - const reached = yield* client.session - .interrupt({ sessionID: Session.ID.make(childId) }) - .pipe( - // A session that is gone runs nothing. - Effect.catchTags({ SessionNotFoundError: () => Effect.void }), - Effect.timeout(INTERRUPT_TIMEOUT), - Effect.tapCause((cause) => - Effect.logWarning("Could not stop an OpenCode subagent.", cause), - ), - Effect.exit, - Effect.map(Exit.isSuccess), - ); - if (!reached) unreached.add(childId); - } - // Its end closes its turn; one the Stop did not reach stays pending work. - for (const followUp of followUps) { - yield* client.session - .interrupt({ sessionID: Session.ID.make(followUp.sessionId) }) - .pipe( - Effect.catchTags({ SessionNotFoundError: () => Effect.void }), - Effect.timeout(INTERRUPT_TIMEOUT), - Effect.ignore({ log: true }), - ); - } - yield* lock.withPermit( - Effect.gen(function* () { - for (const call of calls) { - if (childrenOf(call).some((childId) => unreached.has(childId))) continue; - yield* settleCall(call, "interrupted"); - } - // A report already queued starts a follow-up the Stop must end too. - for (const caller of callers) { - for (const report of caller.reports.values()) { - caller.stoppedChildren.add(report.childId); - } - } - // A held wake no turn will take: its execution is stopped, not replayed. - const running = state.wakes.some((wake) => wake.running); - for (const wake of state.wakes.splice(0)) wake.dropped = true; - if (running) { - state.unsettled = true; - yield* client.session - .interrupt({ sessionID: Session.ID.make(state.sessionId) }) - .pipe(Effect.timeout(INTERRUPT_TIMEOUT), Effect.ignore({ log: true })); - } - }), - ); - }); - - /** - * What a turn sends: `/compact` compacts, `/name args` naming a workspace - * command runs it, and anything else is a prompt with the `$skill`s it - * names attached. Commands and skills are read from the session's - * directory only when the text could use them. A turn stopped meanwhile - * sends nothing. - */ - const submit = Effect.fnUntraced(function* ( - sessionId: string, - turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, - state: ThreadState, - turn: ActiveTurn, - ) { - const sessionID = Session.ID.make(sessionId); - /** Whether the turn still sends; past this point a Stop goes to the server. */ - const sending = () => { - if (state.active !== turn || turn.interrupted) return false; - turn.unsent = false; - return true; - }; - const text = turnInput.message.text.trim(); - const bare = turnInput.message.attachments.length === 0; - // The turn's own id, so fork and rollback cut before this turn's item. - const id = turnPromptId(sessionId, turnInput.attemptId); - if (bare && text === "/compact") { - if (!sending()) return; - return yield* client.session.compact({ sessionID, id }).pipe(Effect.asVoid); - } - const location = { directory: turnInput.runtimePolicy.cwd ?? serverConfig.cwd }; - const command = bare ? commandOf(text) : undefined; - if (command !== undefined) { - const commands = yield* client.command.list({ location }).pipe( - Effect.timeout(INVENTORY_TIMEOUT), - Effect.map((list) => list.data), - Effect.orElseSucceed(() => []), - ); - if (commands.some((entry) => entry.name === command.name)) { - // `session.command` takes no id of T3's and its answer carries none, - // so the turn remembers where the history stood before it. - const newest = yield* client.message.list({ sessionID, order: "desc", limit: 1 }); - yield* markBefore(sessionId, newest.data[0]?.id ?? null); - if (!sending()) return; - return yield* client.session.command({ sessionID, ...command }); - } - } - const skills = SKILL_MENTION.test(text) - ? skillsNamed( - text, - yield* client.skill.list({ location }).pipe( - Effect.timeout(INVENTORY_TIMEOUT), - Effect.map((list) => new Set(list.data.map((skill) => skill.id))), - Effect.orElseSucceed(() => new Set()), - ), - ) - : []; - if (!sending()) return; - return yield* client.session - .prompt({ - sessionID, - id, - text: promptText(turnInput), - ...(skills.length === 0 - ? {} - : { skills: skills.map((id) => ({ id: Skill.ID.make(id) })) }), - }) - .pipe(Effect.asVoid); - }); - - const runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime = { - instanceId, - driver, - providerSessionId: input.providerSessionId, - get providerSession() { - return session; - }, - events: Stream.fromQueue(events), - // A background subagent keeps its session busy after its parent's turn, - // and a held wake still needs its turn: idle release must wait for both. - hasPendingBackgroundWork: Effect.sync(() => [...threads.values()].some(owesWork)), - hasPendingBackgroundWorkForThread: (providerThread) => - Effect.sync(() => { - const nativeId = providerThread.nativeThreadRef?.nativeId; - const state = nativeId == null ? undefined : threads.get(nativeId); - return state !== undefined && hasBackground(state); - }), - // A caller that names no directory gets the one this session opened in. - getModelContextWindow: (selection, cwd) => - selection.instanceId === instanceId - ? windowOf(cwd === undefined ? session.cwd : cwd, selection.model) - : undefined, - 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, - }); - } - yield* readModelsOnce(threadInput.runtimePolicy.cwd); - const model = modelRef(threadInput.modelSelection); - if (model === undefined) { - return yield* new ProviderAdapter.ProviderAdapterProtocolError({ - driver: OPENCODE_PROVIDER, - detail: malformedModel(threadInput.modelSelection.model), - }); - } - const directory = threadInput.runtimePolicy.cwd ?? serverConfig.cwd; - const policy = threadInput.runtimePolicy; - // A new session runs OpenCode's default agent. - const permissions = yield* rulesFor( - { directory, agent: "build", grants: [] }, - policy, - threadInput.threadId, - ); - const created = yield* client.session.create({ - location: Location.PublicRef.make({ directory: AbsolutePath.make(directory) }), - model, - 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, - }; - const state = register( - providerThread, - { id: created.id, model: created.model, agent: created.agent, permissions }, - directory, - ); - state.policy = policy; - return state.providerThread; - }).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); - if (threadInput.runtimePolicy !== undefined) { - yield* readModelsOnce(threadInput.runtimePolicy.cwd); - } - // 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) }); - const providerThread: OrchestrationV2ProviderThread = { - ...threadInput.providerThread, - providerSessionId: input.providerSessionId, - status: "idle", - updatedAt: yield* DateTime.now, - }; - const cwd = threadInput.runtimePolicy?.cwd; - const loaded = threads.has(sessionId); - const state = register(providerThread, native, cwd ?? native.location.directory); - // OpenCode keeps no request across its own restart, but a server that - // outlived T3 may still wait on one T3 no longer shows. - if (!loaded) yield* stopLeftoverRequests(state); - // The session gets the rules for this thread's mode: it may have run - // another mode, or been made by 1.x or an earlier build. - yield* writeRules(state, threadInput.runtimePolicy ?? state.policy); - // A thread moved to another worktree takes its session with it. - if (cwd != null && native.location.directory !== cwd) { - yield* client.session.move({ - sessionID: Session.ID.make(sessionId), - directory: AbsolutePath.make(cwd), - }); - } - return state.providerThread; - }).pipe( - Effect.mapError((cause) => - isProviderAdapterError(cause) - ? cause - : new ProviderAdapter.ProviderAdapterResumeThreadError({ - driver, - providerSessionId: input.providerSessionId, - providerThreadId: threadInput.providerThread.id, - cause, - }), - ), - ), - // `/compact` is its own turn, which `startTurn` sends as `session.compact`. - compactThread: (turnInput) => - runtime.startTurn({ ...turnInput, message: { ...turnInput.message, text: "/compact" } }), - startTurn: (turnInput) => - Effect.gen(function* () { - // A lost stream is reconnecting: wait for it rather than prompt into - // a dead stream, and refuse the turn before any request if it gave up. - // Waited out before the session's gate, so a rollback or fork is not - // held behind a reconnect. - if (streamFailure !== undefined) { - yield* Deferred.await(reconnected).pipe(Effect.timeout(RECONNECT_WAIT), Effect.ignore); - } - if (streamFailure !== undefined) { - return yield* new ProviderAdapter.ProviderAdapterEventStreamError({ - driver, - providerSessionId: input.providerSessionId, - cause: streamFailure, - }); - } - // Its checks and setup take the session's gate, so a rollback or - // fork sees either no turn or this one installed; the prompt after - // it does not hold the gate. - const started = yield* exclusive(turnInput.providerThread)( - 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`, - }); - } - // OpenCode already ran this turn on its own; it prompts nothing. - if (isContinuation(turnInput)) return yield* runWake(state, turnInput); - // 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 = beginTurn(state, turnInput, afterUnsettled); - 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. - const model = modelRef(turnInput.modelSelection); - if (model === undefined) { - yield* begin; - return yield* finishTurn(state, { - status: "failed", - failure: makeProviderFailure({ - message: malformedModel(turnInput.modelSelection.model), - class: "validation_error", - }), - }); - } - // Plan mode is OpenCode's `plan` agent, switched before the rules are - // written so they keep that agent's own path allows. Switching to or - // from `plan` queues OpenCode's own "Plan mode" reminder for the prompt. - const agent = agentFor(turnInput); - if (agent !== state.agent) { - yield* client.session.switchAgent({ - sessionID: Session.ID.make(sessionId), - agent: Agent.ID.make(agent), - }); - state.agent = agent; - } - // The thread's mode may have changed since the session was loaded, - // and its subagents still running hold the rules they started with. - // Those run on whether or not this turn starts, so theirs are best effort. - yield* writeRules(state, turnInput.runtimePolicy); - for (const call of runningCalls(state)) { - if (call.child === undefined) continue; - yield* writeRules(call.child, turnInput.runtimePolicy).pipe( - Effect.timeout(REQUEST_REPLY_TIMEOUT), - Effect.ignore({ log: true }), - ); - } - // 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; - } - // Steers a stopped turn never delivered would reach the model first, - // and a revert a failed rollback left staged would be committed. - yield* cancelStrandedSteers(state); - if (stagedReverts.has(sessionId)) yield* clearRevert(sessionId); - yield* prepareTurn(sessionId, state, turnInput); - const turn = yield* begin; - turn.unsent = true; - // An execution OpenCode is running on its own takes this prompt at - // its next step, so this turn is that execution from here on. - yield* lock.withPermit(takeRunningWake(state)); - return { state, sessionId, turn }; - }), - ); - if (started === undefined) return; - const { state, sessionId, turn } = started; - yield* submit(sessionId, turnInput, state, turn).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.gen(function* () { - const sessionId = yield* sessionIdOf(steerInput.providerThread); - const state = threads.get(sessionId); - const turn = state?.active; - if ( - state === undefined || - turn === undefined || - turn.providerTurn.id !== steerInput.providerTurnId || - turn.interrupted - ) { - return yield* new ProviderAdapter.ProviderAdapterProtocolError({ - driver: OPENCODE_PROVIDER, - detail: `OpenCode turn ${steerInput.providerTurnId} is not active`, - }); - } - const inboxID = steerPromptId(sessionId, steerInput.message.messageId); - // A retried steer that already reached the model is not sent again. - if (turn.settledInbox.has(inboxID)) return; - turn.steers.add(inboxID); - state.strandedSteers.delete(inboxID); - yield* client.session - .prompt({ - sessionID: Session.ID.make(sessionId), - id: inboxID, - text: providerMessageTextWithAttachmentPaths({ - text: steerInput.message.text, - attachments: steerInput.message.attachments, - resolveAttachmentPath: (attachment) => - resolveAttachmentPath({ - attachmentsDir: serverConfig.attachmentsDir, - attachment, - }), - }).trim(), - delivery: "steer", - }) - .pipe( - Effect.tapError((cause) => - lock.withPermit( - Effect.gen(function* () { - turn.steers.delete(inboxID); - // Without a clear refusal OpenCode may hold it; the next prompt takes it back. - if (!CLEAR_PROMPT_REJECTIONS.has(cause._tag)) state.strandedSteers.add(inboxID); - if (state.active === turn) yield* endIfSettled(state, turn); - }), - ), - ), - ); - }).pipe( - Effect.mapError((cause) => - isProviderAdapterError(cause) - ? cause - : new ProviderAdapter.ProviderAdapterSteerRunError({ - driver, - providerThreadId: steerInput.providerThread.id, - providerTurnId: steerInput.providerTurnId, - cause, - }), - ), - ), - interruptTurn: (interruptInput) => - Effect.gen(function* () { - const sessionId = yield* sessionIdOf(interruptInput.providerThread); - const state = threads.get(sessionId); - if (state === undefined) return; - const turn = state.active; - // OpenCode stops a foreground subagent with its parent, but not a - // background one: a user Stop (`requestRuntimeRestart`) stops those - // too, and the execution OpenCode starts to report them. A turn - // interrupted to restart it with new input leaves them running. - if (interruptInput.requestRuntimeRestart === true) yield* stopBackground(state); - if (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* lock.withPermit(finishTurn(state, { status: "interrupted" })); - } - // Between the executions a late steer spans nothing runs yet. The - // steers are taken back, and a run they already started is skipped. - if (!reply.value.interrupted && state.active === turn && turn.heldEnd !== undefined) { - state.unsettled = true; - yield* lock.withPermit(finishTurn(state, { status: "interrupted" })); - return yield* cancelStrandedSteers(state); - } - // Nothing was running because the turn has not sent its request yet - // (it is reading the workspace's commands or skills): it ends here, - // and never sends it. - if (!reply.value.interrupted && state.active === turn && turn.unsent) { - return yield* lock.withPermit(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.gen(function* () { - const nativeId = providerThread.nativeThreadRef?.nativeId; - const state = nativeId == null ? undefined : threads.get(nativeId); - if ( - nativeId == null || - state === undefined || - state.active !== undefined || - hasBackground(state) - ) { - return; - } - threads.delete(nativeId); - for (const [child, owner] of childOwners) { - if (owner !== state) continue; - childOwners.delete(child); - threads.delete(child); - } - if (state.mcp !== undefined) yield* removeMcp(state.mcp); - }), - respondToRuntimeRequest: (requestInput) => - Effect.gen(function* () { - const entry = pending.get(requestInput.requestId); - if (entry === undefined || entry.answering) { - return yield* new ProviderAdapter.ProviderAdapterProtocolError({ - driver, - detail: `No pending OpenCode request ${requestInput.requestId}`, - }); - } - const { decision, answers } = requestInput; - const { native } = entry; - if (native.type === "permission" && decision === undefined) { - return yield* new ProviderAdapter.ProviderAdapterProtocolError({ - driver, - detail: `OpenCode approval request ${requestInput.requestId} requires a decision`, - }); - } - entry.answering = true; - // OpenCode's own "always" saves a grant for the whole project, so a - // session-wide answer is a rule on this session instead. The grant - // is best effort: this request is answered either way. - // The session that asked waits on the answer and holds the grant: a - // subagent's own, not its parent's. - const asker = threads.get(entry.sessionId) ?? entry.state; - if ( - native.type === "permission" && - (decision === "acceptForSession" || decision === "acceptAlways") - ) { - const state = asker; - for (const resource of native.save) { - if ( - !state.grants.some( - (grant) => grant.action === native.action && grant.resource === resource, - ) - ) { - state.grants.push({ action: native.action, resource, effect: "allow" }); - } - } - // A subagent runs under its thread's mode. - yield* writeRules(state, rootOf(state).policy).pipe(Effect.ignore({ log: true })); - } - const sessionID = Session.ID.make(entry.sessionId); - // Best effort: the decline stands without the note. - if (native.type === "permission" && decision === "decline") { - yield* client.session - .synthetic({ - sessionID, - text: declinedNote(native.action, native.resources), - delivery: "steer", - resume: false, - }) - .pipe(Effect.timeout(REQUEST_REPLY_TIMEOUT), Effect.ignore({ log: true })); - } - const delivered = yield* native.type === "permission" - ? deliver( - client.permission - .reply({ - sessionID, - requestID: Permission.ID.make(native.id), - decision: decision !== undefined && approves(decision) ? "once" : "reject", - // Without a message OpenCode ends the whole run, which is Cancel. - ...(decision === "decline" ? { message: DECLINED } : {}), - }) - .pipe(Effect.catchTags(permissionGone)), - ) - : deliver( - (answers === undefined || decision === "decline" || decision === "cancel" - ? client.session.form.cancel({ - sessionID: entry.sessionId, - formID: Form.ID.make(native.id), - }) - : client.session.form.reply({ - sessionID: entry.sessionId, - formID: Form.ID.make(native.id), - answer: formAnswer(native.form, answers), - }) - ).pipe(Effect.catchTags(formGone)), - ); - yield* forgetRequest(entry); - if (!delivered) yield* abandonRequest(asker, "answer not delivered"); - }).pipe( - Effect.mapError((cause) => - isProviderAdapterError(cause) - ? cause - : new ProviderAdapter.ProviderAdapterRuntimeRequestResponseError({ - driver, - requestId: requestInput.requestId, - cause, - }), - ), - ), - readThreadSnapshot: ({ providerThread }) => - Effect.gen(function* () { - const sessionId = yield* sessionIdOf(providerThread); - return yield* snapshotOf(providerThread, sessionId); - }).pipe( - Effect.mapError((cause) => - isProviderAdapterError(cause) - ? cause - : new ProviderAdapter.ProviderAdapterReadThreadSnapshotError({ - driver, - providerThreadId: providerThread.id, - cause, - }), - ), - ), - rollbackThread: (rollbackInput) => - Effect.gen(function* () { - const { providerThread, target } = rollbackInput; - const sessionId = yield* sessionIdOf(providerThread); - // A stopped run's end may still be on the stream, so `unsettled` - // stays for the next turn to skip it. - yield* ensureQuiet(sessionId, "roll back"); - // A session this runtime has not loaded yet is loaded the way a turn - // would load it, which stops anything it still waits on. Requests it - // stops end their run, so the server is asked again after that. - if (!threads.has(sessionId)) { - yield* runtime.resumeThread({ providerThread }); - yield* ensureQuiet(sessionId, "roll back"); - } - const boundary = yield* boundaryAfter( - rollbackInput.providerThreadTurns, - providerThread.id, - target.type === "provider_turn" ? target.providerTurn : undefined, - yield* userMessages(sessionId), - ); - if (boundary !== null) { - yield* rollBackTo(sessionId, SessionMessage.ID.make(boundary)); - } - // The turns after the target left the history with their messages. - // The snapshot lists the ones kept as T3 recorded them, which a - // runtime that loaded the session after they ran never saw. - const keptOrdinal = target.type === "provider_turn" ? target.providerTurn.ordinal : 0; - const loaded = threads.get(sessionId); - for (const [id, turn] of loaded?.providerTurns ?? []) { - if (turn.ordinal > keptOrdinal) loaded?.providerTurns.delete(id); - } - const snapshot = yield* snapshotOf(providerThread, sessionId); - const kept = new Map( - rollbackInput.providerThreadTurns - .filter( - (turn) => - turn.providerThreadId === providerThread.id && turn.ordinal <= keptOrdinal, - ) - .map((turn) => [turn.id, turn] as const), - ); - for (const turn of snapshot.providerTurns) kept.set(turn.id, turn); - // The snapshot's head is the last user message OpenCode kept. - return { ...snapshot, providerTurns: [...kept.values()] }; - }).pipe( - exclusive(rollbackInput.providerThread), - Effect.mapError((cause) => - isProviderAdapterError(cause) - ? cause - : new ProviderAdapter.ProviderAdapterRollbackThreadError({ - driver, - providerThreadId: rollbackInput.providerThread.id, - checkpointId: rollbackInput.target.checkpointId, - cause, - }), - ), - ), - forkThread: (forkInput) => - Effect.gen(function* () { - const source = forkInput.sourceProviderThread; - const sourceId = yield* sessionIdOf(source); - // OpenCode forks whatever history the source has, so a fork taken - // while anything writes to it would copy a run half done. - yield* ensureQuiet(sourceId, "fork"); - const selected = - forkInput.providerTurnId === undefined - ? undefined - : forkInput.sourceProviderTurns?.find((turn) => turn.id === forkInput.providerTurnId); - if (forkInput.providerTurnId !== undefined && selected === undefined) { - return yield* new ProviderAdapter.ProviderAdapterProtocolError({ - driver: OPENCODE_PROVIDER, - detail: `OpenCode fork boundary turn ${forkInput.providerTurnId} was not found`, - }); - } - const before = - selected === undefined - ? null - : yield* boundaryAfter( - forkInput.sourceProviderTurns ?? [], - source.id, - selected, - yield* userMessages(sourceId), - ); - // A fork copies the source's history, model, location and rules; its - // message ids are new, so it has no turns T3 could cut at yet. - const forked = yield* client.session.fork({ - sessionID: Session.ID.make(sourceId), - ...(before === null ? {} : { before: SessionMessage.ID.make(before) }), - }); - const createdAt = yield* DateTime.now; - const providerThread: OrchestrationV2ProviderThread = { - id: idAllocator.derive.providerThread({ driver, nativeThreadId: forked.id }), - driver, - providerInstanceId: instanceId, - providerSessionId: input.providerSessionId, - appThreadId: forkInput.targetThreadId, - ownerNodeId: forkInput.ownerNodeId ?? null, - nativeThreadRef: ref(forked.id), - nativeConversationHeadRef: null, - status: "idle", - firstRunOrdinal: null, - lastRunOrdinal: null, - handoffIds: [], - forkedFrom: { - providerThreadId: source.id, - ...(forkInput.providerTurnId === undefined - ? {} - : { providerTurnId: forkInput.providerTurnId }), - }, - createdAt, - updatedAt: createdAt, - }; - // A fork keeps the source's location. The target thread usually - // inherits that worktree too, but its first turn starts without a - // resume, so a target in another worktree takes the session there now. - const cwd = forkInput.runtimePolicy?.cwd; - const state = register(providerThread, forked, cwd ?? forked.location.directory); - // The fork runs the target thread's mode, not whatever the source ran. - yield* writeRules(state, forkInput.runtimePolicy ?? state.policy); - if (cwd != null && forked.location.directory !== cwd) { - yield* client.session.move({ - sessionID: Session.ID.make(forked.id), - directory: AbsolutePath.make(cwd), - }); - } - return state.providerThread; - }).pipe( - exclusive(forkInput.sourceProviderThread), - Effect.mapError((cause) => - isProviderAdapterError(cause) - ? cause - : new ProviderAdapter.ProviderAdapterForkThreadError({ - driver, - providerThreadId: forkInput.sourceProviderThread.id, - cause, - }), - ), - ), - }; - 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 scope = yield* Scope.make(); - yield* Effect.addFinalizer((exit) => Scope.close(scope, exit)); - const connection = yield* borrow.pipe(Effect.provideService(Scope.Scope, scope)); - return yield* openSession(input, { connection, scope }); - }).pipe( - Effect.mapError( - (cause) => - new ProviderAdapter.ProviderAdapterOpenSessionError({ - driver, - providerSessionId: input.providerSessionId, - cause, - }), - ), - ), - }); -}); diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts deleted file mode 100644 index c52447fafaeb3a7400aed17f5d334eef3df961be..0000000000000000000000000000000000000000 --- a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts +++ /dev/null @@ -1,2731 +0,0 @@ -import { assert, describe, it } from "@effect/vitest"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import type { OpencodeClient, ToolPart } from "@opencode-ai/sdk/v2"; -import { - CheckpointId, - NodeId, - OpenCodeSettings, - ProjectId, - ProviderInstanceId, - ProviderSessionId, - ProviderThreadId, - ProviderTurnId, - RunAttemptId, - RunId, - MessageId, - ThreadId, - type OrchestrationV2ProviderThread, - type OrchestrationV2ProviderTurn, -} from "@t3tools/contracts"; -import * as Effect from "effect/Effect"; -import * as Clock from "effect/Clock"; -import * as DateTime from "effect/DateTime"; -import * as Deferred from "effect/Deferred"; -import * as Fiber from "effect/Fiber"; -import * as Exit from "effect/Exit"; -import * as Queue from "effect/Queue"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; -import * as Scope from "effect/Scope"; -import * as Stream from "effect/Stream"; -import * as TestClock from "effect/testing/TestClock"; - -import * as ServerConfig from "../../config.ts"; -import type { EventNdjsonLogger } from "../../provider/EventNdjsonLogger.ts"; -import type { OpenCodeRuntimeShape } from "../../provider/opencodeRuntime.ts"; -import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; - -import { - advanceOpenCodePromptAdmission, - cancelOpenCodePromptAdmission, - openCodeBoundaryAfterProviderTurn, - openCodeChildPermissionRules, - openCodePermissionRules, - openCodePermissionRequestKind, - openCodeToolProjectionKind, - makeOpenCodeProtocolLogger, - makeOpenCodeAdapterV2, - OPENCODE_PROVIDER, - reconcileOpenCodePromptAdmissionStatus, -} from "./OpenCodeAdapterV2.ts"; -import { ProviderAdapterV2RuntimePolicy } from "@t3tools/provider-core/server/ProviderAdapter"; - -const encodeUnknownJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); -const OPEN_CODE_TEST_SETTINGS = Schema.decodeSync(OpenCodeSettings)({ - serverUrl: "http://test.invalid", -}); - -function promiseGate() { - let resolve!: (value: A) => void; - const promise = new Promise((done) => { - resolve = done; - }); - return { promise, resolve }; -} - -function asyncEventStream() { - const values: Array<{ value: unknown; handled: ReturnType> }> = []; - const waiters: Array<(value: IteratorResult) => void> = []; - let previousHandled: ReturnType> | undefined; - let closed = false; - return { - close() { - closed = true; - for (const waiter of waiters.splice(0)) waiter({ done: true, value: undefined }); - }, - push(value: unknown) { - const handled = promiseGate(); - const waiter = waiters.shift(); - if (waiter) { - previousHandled = handled; - waiter({ done: false, value }); - } else values.push({ value, handled }); - return handled.promise; - }, - stream: { - [Symbol.asyncIterator]() { - return { - next: () => { - previousHandled?.resolve(); - if (closed) return Promise.resolve({ done: true as const, value: undefined }); - const entry = values.shift(); - if (entry !== undefined) { - previousHandled = entry.handled; - return Promise.resolve({ done: false as const, value: entry.value }); - } - return new Promise>((resolve) => waiters.push(resolve)); - }, - }; - }, - }, - }; -} - -const OPENCODE_TEST_SETTINGS = Schema.decodeUnknownSync(OpenCodeSettings)({}); - -function runtimePolicy( - runtimeMode: ProviderAdapterV2RuntimePolicy["runtimeMode"], - override: Partial = {}, -): ProviderAdapterV2RuntimePolicy { - return ProviderAdapterV2RuntimePolicy.make({ - runtimeMode, - interactionMode: "default", - cwd: null, - ...override, - }); -} - -function permissionAction(rules: ReturnType, permission: string) { - return rules.findLast((rule) => rule.permission === "*" || rule.permission === permission) - ?.action; -} - -function providerTurn(input: { - readonly id: string; - readonly ordinal: number; - readonly nativeId: string | null; -}): OrchestrationV2ProviderTurn { - return { - id: ProviderTurnId.make(input.id), - providerThreadId: ProviderThreadId.make("provider-thread:opencode-test"), - nodeId: NodeId.make(`node:${input.id}`), - runAttemptId: null, - nativeTurnRef: - input.nativeId === null - ? null - : { driver: OPENCODE_PROVIDER, nativeId: input.nativeId, strength: "weak" }, - ordinal: input.ordinal, - status: "completed", - startedAt: null, - completedAt: null, - }; -} - -const makeOpenCodeRuntimeHarness = Effect.fn("makeOpenCodeRuntimeHarness")(function* ( - suffix: string, - nativeSessionId: string, - client: object, -) { - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const instanceId = ProviderInstanceId.make(`opencode-${suffix}`); - const threadId = ThreadId.make(`thread-opencode-${suffix}`); - const modelSelection = { - instanceId, - model: "anthropic/claude-sonnet", - options: [], - }; - const policy = runtimePolicy("full-access", { cwd: "/workspace" }); - const adapter = makeOpenCodeAdapterV2({ - instanceId, - settings: OPEN_CODE_TEST_SETTINGS, - environment: {}, - runtime: { - connectToOpenCodeServer: () => Effect.succeed({ url: "http://test.invalid", external: true }), - createOpenCodeSdkClient: () => client, - } as unknown as OpenCodeRuntimeShape, - idAllocator, - serverConfig: { - cwd: "/workspace", - attachmentsDir: "/tmp/attachments", - } as ServerConfig.ServerConfig["Service"], - }); - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make(`session-opencode-${suffix}`), - modelSelection, - runtimePolicy: policy, - }); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection, - runtimePolicy: policy, - }); - const now = yield* DateTime.now; - const startTurn = ( - text = "hello", - startProviderThread: OrchestrationV2ProviderThread = providerThread, - ) => - runtime.startTurn({ - appThread: { - id: threadId, - projectId: ProjectId.make(`project-opencode-${suffix}`), - title: suffix, - providerInstanceId: modelSelection.instanceId, - modelSelection, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - activeProviderThreadId: providerThread.id, - lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, - forkedFrom: null, - createdBy: "user", - creationSource: "web", - createdAt: now, - updatedAt: now, - archivedAt: null, - settledOverride: null, - settledAt: null, - lastVisitedAt: null, - deletedAt: null, - }, - threadId, - runId: RunId.make(`run-opencode-${suffix}`), - runOrdinal: 1, - providerTurnOrdinal: 1, - attemptId: RunAttemptId.make(`attempt-opencode-${suffix}`), - rootNodeId: NodeId.make(`node-opencode-${suffix}`), - providerThread: startProviderThread, - message: { - createdBy: "user", - creationSource: "web", - messageId: MessageId.make(`message-opencode-${suffix}`), - text, - attachments: [], - }, - modelSelection, - runtimePolicy: policy, - }); - return { - nativeSessionId, - now, - policy, - providerThread, - runId: RunId.make(`run-opencode-${suffix}`), - runtime, - startTurn, - threadId, - }; -}); - -describe("OpenCodeAdapterV2", () => { - it.effect.each(["completed", "failed", "unresolved", "unavailable", "reconnect"] as const)( - "normalizes OpenCode step usage for %s turns", - (ending) => - Effect.gen(function* () { - const nativeEvents = asyncEventStream(); - let promptId = ""; - const harness = yield* makeOpenCodeRuntimeHarness(`usage-${ending}`, "root", { - event: { subscribe: async () => ({ stream: nativeEvents.stream }) }, - session: { - create: async () => ({ data: { id: "root", time: { created: 1, updated: 1 } } }), - promptAsync: async (input: { messageID: string }) => { - promptId = input.messageID; - return { data: true }; - }, - abort: async () => ({ data: true }), - children: async () => ({ data: [] }), - }, - }); - yield* harness.startTurn(); - const received = yield* harness.runtime.events.pipe( - Stream.takeUntil((event) => event.type === "turn.terminal"), - Stream.runCollect, - Effect.forkScoped, - ); - yield* Effect.promise(() => - nativeEvents.push({ - type: "message.updated", - properties: { - sessionID: "root", - info: { id: promptId, role: "user", time: { created: 1 } }, - }, - }), - ); - const step = (id: string, messageID = "assistant") => ({ - type: "message.part.updated", - properties: { - part: { - type: "step-finish", - id, - sessionID: "root", - messageID, - reason: "stop", - cost: 0.01, - tokens: { input: 10, output: 5, reasoning: 2, cache: { read: 3, write: 4 } }, - }, - }, - }); - if (ending !== "unavailable") { - yield* Effect.promise(() => nativeEvents.push(step("one"))); - yield* Effect.promise(() => - nativeEvents.push({ - type: "message.updated", - properties: { - sessionID: "root", - info: { - id: "assistant", - role: "assistant", - time: { created: 1 }, - parentID: promptId, - }, - }, - }), - ); - yield* Effect.promise(() => nativeEvents.push(step("one"))); - yield* Effect.promise(() => nativeEvents.push(step("two"))); - yield* Effect.promise(() => - nativeEvents.push({ - type: "message.part.removed", - properties: { sessionID: "root", messageID: "assistant", partID: "one" }, - }), - ); - yield* Effect.promise(() => - nativeEvents.push({ - type: "message.updated", - properties: { - sessionID: "root", - info: { - id: "old-assistant", - role: "assistant", - time: { created: 1 }, - parentID: "old-prompt", - }, - }, - }), - ); - yield* Effect.promise(() => nativeEvents.push(step("old", "old-assistant"))); - } - if (ending === "unresolved") - yield* Effect.promise(() => nativeEvents.push(step("unknown", "unknown-assistant"))); - if (ending === "reconnect") { - yield* Effect.promise(() => - nativeEvents.push({ type: "server.connected", properties: {} }), - ); - yield* Effect.promise(() => - nativeEvents.push({ type: "server.connected", properties: {} }), - ); - } - if (ending === "failed") { - yield* Effect.promise(() => - nativeEvents.push({ - type: "session.error", - properties: { - sessionID: "root", - error: { name: "UnknownError", data: { message: "failed" } }, - }, - }), - ); - } else { - yield* Effect.promise(() => - nativeEvents.push({ - type: "session.status", - properties: { sessionID: "root", status: { type: "busy" } }, - }), - ); - yield* Effect.promise(() => - nativeEvents.push({ - type: "session.status", - properties: { sessionID: "root", status: { type: "idle" } }, - }), - ); - } - const events = yield* Fiber.join(received); - const completed = events.findLast((event) => event.type === "provider_turn.updated"); - assert.deepEqual( - completed?.providerTurn.turnTokenUsage, - ending === "unavailable" - ? { usageStatus: "unavailable", usageScope: "main_agent", hasSubagents: false } - : { - usageStatus: ending === "completed" ? "complete" : "partial", - usageScope: "main_agent", - inputTokens: 34, - cachedInputTokens: 6, - cacheCreationTokens: 8, - outputTokens: 14, - reasoningTokens: 4, - hasSubagents: false, - }, - ); - }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), - ); - - it.effect.each(["permission", "question"] as const)( - "cancels an undelivered %s reply at its deadline", - (kind) => - Effect.gen(function* () { - const nativeEvents = asyncEventStream(); - const called = promiseGate(); - let signal: AbortSignal | undefined; - let deliver = false; - const harness = yield* makeOpenCodeRuntimeHarness(`reply-${kind}`, "root", { - event: { subscribe: async () => ({ stream: nativeEvents.stream }) }, - session: { - create: async () => ({ data: { id: "root", time: { created: 1, updated: 1 } } }), - promptAsync: async () => ({ data: true }), - abort: async () => ({ data: true }), - children: async () => ({ data: [] }), - }, - [kind]: { - reply: async (_input: unknown, options: { signal: AbortSignal }) => { - if (deliver) return { data: true }; - signal = options.signal; - called.resolve(); - return new Promise(() => {}); - }, - }, - }); - yield* harness.startTurn(); - const received = yield* harness.runtime.events.pipe( - Stream.filter((event) => event.type === "runtime_request.updated"), - Stream.take(1), - Stream.runCollect, - Effect.forkScoped, - ); - yield* Effect.promise(() => - nativeEvents.push({ - type: `${kind}.asked`, - properties: - kind === "permission" - ? { - id: "request", - sessionID: "root", - permission: "bash", - patterns: ["*"], - always: [], - metadata: {}, - } - : { - id: "request", - sessionID: "root", - questions: [ - { - header: "Choice", - question: "Which?", - options: [{ label: "Yes", description: "Proceed" }], - }, - ], - }, - }), - ); - const request = (yield* Fiber.join(received))[0]!.runtimeRequest; - const response = { - requestId: request.id, - ...(kind === "permission" - ? { decision: "accept" as const } - : { answers: { Choice: "Yes" } }), - }; - const reply = yield* harness.runtime - .respondToRuntimeRequest(response) - .pipe(Effect.exit, Effect.forkScoped); - yield* Effect.promise(() => called.promise); - yield* TestClock.adjust("10 seconds"); - assert.isTrue(Exit.isFailure(yield* Fiber.join(reply))); - assert.isTrue(signal?.aborted); - deliver = true; - // Failed delivery leaves the request available for an explicit retry. - yield* harness.runtime.respondToRuntimeRequest(response); - }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), - ); - - it.effect("aborts external root and descendants before closing the event stream", () => - Effect.gen(function* () { - const scope = yield* Scope.make(); - const nativeEvents = asyncEventStream(); - const calls: string[] = []; - yield* makeOpenCodeRuntimeHarness("release", "root", { - event: { - subscribe: async (_input: unknown, options: { signal: AbortSignal }) => { - options.signal.addEventListener("abort", () => { - calls.push("stream.close"); - nativeEvents.close(); - }); - return { stream: nativeEvents.stream }; - }, - }, - session: { - create: async () => ({ data: { id: "root", time: { created: 1, updated: 1 } } }), - abort: async ({ sessionID }: { sessionID: string }) => { - calls.push(`abort:${sessionID}`); - return { data: true }; - }, - children: async ({ sessionID }: { sessionID: string }) => { - calls.push(`children:${sessionID}`); - return { data: sessionID === "root" ? [{ id: "child" }] : [] }; - }, - }, - }).pipe(Effect.provideService(Scope.Scope, scope)); - yield* Scope.close(scope, Exit.void); - assert.deepEqual(calls, [ - "abort:root", - "children:root", - "abort:child", - "children:child", - "stream.close", - ]); - }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), - ); - - it.effect.each(["enumeration", "abort", "not-found", "timeout"] as const)( - "reports descendant cleanup %s", - (failure) => - Effect.gen(function* () { - const nativeEvents = asyncEventStream(); - const called = promiseGate(); - let childSignal: AbortSignal | undefined; - const harness = yield* makeOpenCodeRuntimeHarness(`cleanup-${failure}`, "root", { - event: { subscribe: async () => ({ stream: nativeEvents.stream }) }, - session: { - create: async () => ({ data: { id: "root", time: { created: 1, updated: 1 } } }), - promptAsync: async () => ({ data: true }), - get: async () => ({ data: { id: "root", time: { created: 1, updated: 1 } } }), - messages: async () => ({ data: [] }), - children: async ({ sessionID }: { sessionID: string }) => { - if (failure === "enumeration") throw new Error("cannot enumerate"); - return { data: sessionID === "root" ? [{ id: "child" }] : [] }; - }, - abort: async ( - { sessionID }: { sessionID: string }, - options: { signal: AbortSignal }, - ) => { - if (sessionID === "root") return { data: true }; - if (failure === "timeout" && childSignal?.aborted) return { data: true }; - childSignal = options.signal; - called.resolve(); - if (failure === "timeout") { - return new Promise(() => {}); - } - if (failure === "not-found") throw { status: 404 }; - throw new Error("child abort failed"); - }, - }, - }); - yield* harness.startTurn(); - const snapshot = yield* harness.runtime.readThreadSnapshot({ - providerThread: harness.providerThread, - }); - const stop = yield* harness.runtime - .interruptTurn({ - providerThread: harness.providerThread, - providerTurnId: snapshot.providerTurns.at(-1)!.id, - }) - .pipe(Effect.exit, Effect.forkScoped); - if (failure === "timeout") { - yield* Effect.promise(() => called.promise); - yield* TestClock.adjust("15 seconds"); - } - const result = yield* Fiber.join(stop); - assert.equal(Exit.isSuccess(result), failure === "not-found"); - if (failure === "timeout") assert.isTrue(childSignal?.aborted); - }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), - ); - - it.effect( - "preserves tool lifecycle, approval kinds, and late assistant text without cached tool payloads", - () => - Effect.gen(function* () { - const nativeEvents = asyncEventStream(); - const nativeSessionId = "native-opencode-tool-lifecycle"; - const harness = yield* makeOpenCodeRuntimeHarness("tool-lifecycle", nativeSessionId, { - event: { - subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { - options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); - return { stream: nativeEvents.stream }; - }, - }, - session: { - create: async () => ({ - data: { id: nativeSessionId, time: { created: 1, updated: 1 } }, - }), - promptAsync: async () => ({ data: true }), - }, - }); - yield* harness.startTurn(); - const received = yield* harness.runtime.events.pipe( - Stream.takeUntil( - (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction", - ), - Stream.runCollect, - Effect.forkScoped, - ); - const states = [ - { status: "pending", input: { command: "pwd" }, raw: "" }, - { - status: "running", - input: { command: "pwd" }, - title: "Working directory", - time: { start: 1 }, - }, - { - status: "completed", - input: { command: "pwd" }, - output: "/repo\n", - title: "Working directory", - metadata: {}, - time: { start: 1, end: 2 }, - }, - { - status: "error", - input: { command: "pwd" }, - error: "Command failed", - time: { start: 3, end: 4 }, - }, - ] satisfies ReadonlyArray; - for (const state of states) { - yield* Effect.promise(() => - nativeEvents.push({ - type: "message.part.updated", - properties: { - sessionID: nativeSessionId, - part: { - id: state.status === "error" ? "part-failed" : "part-working", - sessionID: nativeSessionId, - messageID: "late-assistant", - type: "tool", - callID: state.status === "error" ? "call-failed" : "call-working", - tool: "bash", - state, - }, - }, - }), - ); - } - yield* Effect.promise(() => - nativeEvents.push({ - type: "message.part.updated", - properties: { - sessionID: nativeSessionId, - part: { - id: "part-read", - sessionID: nativeSessionId, - messageID: "late-assistant", - type: "tool", - callID: "call-read", - tool: "read", - state: { status: "pending", input: { filePath: "/outside/file" }, raw: "" }, - }, - }, - }), - ); - yield* Effect.promise(() => - nativeEvents.push({ - type: "permission.asked", - properties: { - id: "read-permission", - sessionID: nativeSessionId, - permission: "external_directory", - patterns: ["/outside/*"], - always: [], - metadata: {}, - tool: { messageID: "late-assistant", callID: "call-read" }, - }, - }), - ); - yield* Effect.promise(() => - nativeEvents.push({ - type: "message.part.updated", - properties: { - sessionID: nativeSessionId, - part: { - id: "part-text", - sessionID: nativeSessionId, - messageID: "late-assistant", - type: "text", - text: "Tool results received", - time: { start: 4 }, - }, - }, - }), - ); - yield* Effect.promise(() => - nativeEvents.push({ - type: "message.updated", - properties: { - sessionID: nativeSessionId, - info: { - id: "late-assistant", - sessionID: nativeSessionId, - role: "assistant", - time: { created: 1, completed: 4 }, - }, - }, - }), - ); - yield* Effect.promise(() => - nativeEvents.push({ - type: "session.compacted", - properties: { sessionID: nativeSessionId }, - }), - ); - const events = yield* Fiber.join(received); - const items = events.flatMap((event) => - event.type === "turn_item.updated" ? [event.turnItem] : [], - ); - const commands = items.filter((item) => item.type === "command_execution"); - assert.deepEqual( - commands.map((item) => item.status), - ["pending", "running", "completed", "failed"], - ); - assert.deepEqual( - commands.map((item) => item.input), - ["pwd", "pwd", "pwd", "pwd"], - ); - assert.equal(commands[2]?.output, "/repo\n"); - assert.equal(commands[3]?.output, "Command failed"); - assert.isTrue( - events.some( - (event) => - event.type === "runtime_request.updated" && event.runtimeRequest.kind === "file-read", - ), - ); - const assistant = items.filter((item) => item.type === "assistant_message"); - assert.equal(assistant.at(-1)?.text, "Tool results received"); - assert.equal(assistant.at(-1)?.status, "completed"); - }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), - ); - - // Event order from a live OpenCode 1.18.32 run of a `task` call with - // background=true: the task part completes at launch, the root session - // settles, and the child session stays busy until its own work ends. - it.effect("reports a background task child as pending work after the root turn settles", () => - Effect.gen(function* () { - const nativeEvents = asyncEventStream(); - const push = (event: unknown) => Effect.promise(() => nativeEvents.push(event)); - const root = "ses_root"; - const child = "ses_child"; - let promptId = ""; - const harness = yield* makeOpenCodeRuntimeHarness("background-child", root, { - event: { subscribe: async () => ({ stream: nativeEvents.stream }) }, - session: { - create: async () => ({ data: { id: root, time: { created: 1, updated: 1 } } }), - get: async () => ({ - data: { id: child, parentID: root, permission: [], time: { created: 2, updated: 2 } }, - }), - update: async () => ({ data: { id: child, parentID: root } }), - promptAsync: async (input: { messageID: string }) => { - promptId = input.messageID; - return { data: true }; - }, - abort: async () => ({ data: true }), - children: async () => ({ data: [] }), - }, - }); - const hasPendingBackgroundWork = harness.runtime.hasPendingBackgroundWork; - if (hasPendingBackgroundWork === undefined) { - return yield* Effect.die("OpenCode runtime must expose hasPendingBackgroundWork."); - } - yield* harness.startTurn(); - const terminal = yield* harness.runtime.events.pipe( - Stream.filter((event) => event.type === "turn.terminal"), - Stream.runHead, - Effect.forkScoped, - ); - const taskPart = (status: "running" | "completed") => ({ - type: "message.part.updated", - properties: { - sessionID: root, - part: { - id: "prt_task", - sessionID: root, - messageID: "msg_root_assistant", - type: "tool", - tool: "task", - callID: "call_task", - state: { - status, - input: { - description: "Background sleep task", - prompt: "sleep", - subagent_type: "general", - }, - title: "Background sleep task", - metadata: { parentSessionId: root, sessionId: child, background: true }, - time: { start: 3, ...(status === "completed" ? { end: 3 } : {}) }, - ...(status === "completed" ? { output: "Background task started" } : {}), - }, - }, - }, - }); - const status = (sessionID: string, type: "busy" | "idle") => ({ - type: "session.status", - properties: { sessionID, status: { type } }, - }); - - yield* push({ - type: "message.updated", - properties: { - sessionID: root, - info: { id: promptId, sessionID: root, role: "user", time: { created: 1 } }, - }, - }); - yield* push(status(root, "busy")); - yield* push(taskPart("running")); - yield* push({ - type: "session.created", - properties: { - sessionID: child, - info: { id: child, parentID: root, time: { created: 2, updated: 2 } }, - }, - }); - yield* push(status(child, "busy")); - yield* push(taskPart("completed")); - yield* push(status(root, "idle")); - assert.equal(Option.getOrUndefined(yield* Fiber.join(terminal))?.status, "completed"); - assert.isTrue(yield* hasPendingBackgroundWork, "the running child must pin idle release"); - - yield* push(status(child, "idle")); - yield* push({ type: "session.idle", properties: { sessionID: child } }); - assert.isFalse(yield* hasPendingBackgroundWork, "an idle child must not pin idle release"); - }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), - ); - - it.effect("titles OpenCode reads and searches from their input", () => - Effect.gen(function* () { - const nativeEvents = asyncEventStream(); - const nativeSessionId = "native-opencode-search"; - const harness = yield* makeOpenCodeRuntimeHarness("search-projection", nativeSessionId, { - event: { - subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { - options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); - return { stream: nativeEvents.stream }; - }, - }, - session: { - create: async () => ({ - data: { id: nativeSessionId, time: { created: 1, updated: 1 } }, - }), - promptAsync: async () => ({ data: true }), - }, - }); - yield* harness.startTurn(); - const received = yield* harness.runtime.events.pipe( - Stream.takeUntil( - (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction", - ), - Stream.runCollect, - Effect.forkScoped, - ); - for (const [tool, input, output] of [ - ["read", { filePath: "src/env.ts" }, "---\nfile body"], - ["grep", { pattern: "TODO", path: "apps/web" }, "---\nfile body"], - ["websearch", { query: "OpenCode documentation" }, "---\nfile body"], - ["glob", { pattern: "missing", path: "apps/web" }, ""], - ["codesearch", {}, " \n\t"], - ] as const) { - yield* Effect.promise(() => - nativeEvents.push({ - type: "message.part.updated", - properties: { - sessionID: nativeSessionId, - part: { - id: `part-${tool}`, - sessionID: nativeSessionId, - messageID: "assistant-search", - type: "tool", - callID: `call-${tool}`, - tool, - state: { - status: "completed", - input, - output, - title: tool, - metadata: {}, - time: { start: 1, end: 2 }, - }, - }, - }, - }), - ); - } - yield* Effect.promise(() => - nativeEvents.push({ - type: "session.compacted", - properties: { sessionID: nativeSessionId }, - }), - ); - const items = (yield* Fiber.join(received)).flatMap((event) => - event.type === "turn_item.updated" ? [event.turnItem] : [], - ); - const read = items.find((item) => item.type === "dynamic_tool"); - assert.equal(read?.title, "Read src/env.ts"); - const grep = items.find((item) => item.type === "file_search"); - assert.equal(grep?.title, "Searched TODO in web"); - assert.equal(grep?.type === "file_search" ? grep.pattern : null, "TODO"); - assert.deepEqual(grep?.type === "file_search" ? grep.results : null, [ - { fileName: "apps/web", preview: "---\nfile body" }, - ]); - const webSearch = items.find((item) => item.type === "web_search"); - assert.deepEqual(webSearch?.type === "web_search" ? webSearch.patterns : null, [ - "OpenCode documentation", - ]); - assert.deepEqual(webSearch?.type === "web_search" ? webSearch.results : null, [ - { snippet: "---\nfile body" }, - ]); - const emptyFileSearch = items.find( - (item) => item.type === "file_search" && item.pattern === "missing", - ); - assert.ok(emptyFileSearch?.type === "file_search"); - assert.equal(emptyFileSearch.results, undefined); - const emptyWebSearch = items.find( - (item) => item.type === "web_search" && item.patterns === undefined, - ); - assert.ok(emptyWebSearch?.type === "web_search"); - assert.equal(emptyWebSearch.results, undefined); - }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), - ); - - it.effect("presents OpenCode MCP calls without treating remote tools as local edits", () => - Effect.gen(function* () { - const nativeEvents = asyncEventStream(); - const nativeSessionId = "native-opencode-mcp"; - let statusReads = 0; - const harness = yield* makeOpenCodeRuntimeHarness("mcp-presentation", nativeSessionId, { - event: { - subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { - options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); - return { stream: nativeEvents.stream }; - }, - }, - session: { - create: async () => ({ data: { id: nativeSessionId, time: { created: 1, updated: 1 } } }), - promptAsync: async () => ({ data: true }), - }, - mcp: { - status: async () => { - statusReads++; - return { - data: { - "my.server_with_underscores": { status: "connected" }, - ambiguous: { status: "connected" }, - ambiguous_server: { status: "connected" }, - code: { status: "connected" }, - apply: { status: "connected" }, - }, - }; - }, - }, - }); - yield* harness.startTurn(); - const received = yield* harness.runtime.events.pipe( - Stream.takeUntil( - (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction", - ), - Stream.runCollect, - Effect.forkScoped, - ); - for (const status of ["running", "completed", "error"] as const) { - yield* Effect.promise(() => - nativeEvents.push({ - type: "message.part.updated", - properties: { - sessionID: nativeSessionId, - part: { - id: "mcp-part", - sessionID: nativeSessionId, - messageID: "mcp-assistant", - type: "tool", - callID: "mcp-call", - tool: "my_server_with_underscores_edit_document", - state: { - status, - input: { document: "remote-doc" }, - title: "Edit remote document", - metadata: {}, - output: "saved", - error: "failed", - time: { start: 1, end: 2 }, - }, - }, - }, - }), - ); - } - yield* Effect.promise(() => - nativeEvents.push({ - type: "message.part.updated", - properties: { - sessionID: nativeSessionId, - part: { - id: "ambiguous-part", - sessionID: nativeSessionId, - messageID: "mcp-assistant", - type: "tool", - callID: "ambiguous-call", - tool: "ambiguous_server_edit_document", - state: { - status: "completed", - input: {}, - title: "Ambiguous tool", - metadata: {}, - output: "", - time: { start: 1, end: 2 }, - }, - }, - }, - }), - ); - for (const tool of ["code_search", "apply_patch"]) { - yield* Effect.promise(() => - nativeEvents.push({ - type: "message.part.updated", - properties: { - sessionID: nativeSessionId, - part: { - id: `native-${tool}`, - sessionID: nativeSessionId, - messageID: "mcp-assistant", - type: "tool", - callID: `native-${tool}`, - tool, - state: { - status: "completed", - input: { query: "needle", filePath: "local.ts" }, - title: `Native ${tool}`, - metadata: {}, - output: "done", - time: { start: 1, end: 2 }, - }, - }, - }, - }), - ); - } - yield* Effect.promise(() => - nativeEvents.push({ - type: "session.compacted", - properties: { sessionID: nativeSessionId }, - }), - ); - const allItems = (yield* Fiber.join(received)).flatMap((event) => - event.type === "turn_item.updated" ? [event.turnItem] : [], - ); - assert.equal( - allItems.find((item) => item.title === "Native code_search")?.type, - "web_search", - ); - assert.equal( - allItems.find((item) => item.title === "Native apply_patch")?.type, - "file_change", - ); - const items = allItems.filter((item) => item.type === "dynamic_tool"); - assert.deepEqual( - items.slice(0, 3).map((item) => item.status), - ["running", "completed", "failed"], - ); - assert.deepEqual( - items.slice(0, 3).map((item) => item.title), - ["Edit remote document", "Edit remote document", "edit document"], - ); - for (const item of items.slice(0, 3)) { - assert.deepEqual(item.toolSource, { - key: "mcp:my.server_with_underscores", - name: "my.server with underscores", - kind: "integration", - }); - assert.deepEqual(item.input, { document: "remote-doc" }); - } - assert.equal(items[3]?.toolName, "ambiguous_server_edit_document"); - assert.isUndefined(items[3]?.toolSource); - assert.equal(statusReads, 1); - }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), - ); - - it.effect.each(["failure", "timeout"] as const)("retries MCP status after %s", (failure) => - Effect.gen(function* () { - const nativeEvents = asyncEventStream(); - const called = promiseGate(); - const sessionId = `mcp-status-${failure}`; - let statusReads = 0; - let signal: AbortSignal | undefined; - const harness = yield* makeOpenCodeRuntimeHarness(sessionId, sessionId, { - event: { subscribe: async () => ({ stream: nativeEvents.stream }) }, - session: { - create: async () => ({ data: { id: sessionId, time: { created: 1, updated: 1 } } }), - promptAsync: async () => ({ data: true }), - }, - mcp: { - status: async (_input: unknown, options: { signal?: AbortSignal }) => { - statusReads++; - if (statusReads === 1) { - signal = options?.signal; - called.resolve(); - if (failure === "timeout") return new Promise(() => {}); - throw new Error("MCP status unavailable"); - } - return { data: { weather: { status: "connected" } } }; - }, - }, - }); - yield* harness.startTurn(); - const received = yield* harness.runtime.events.pipe( - Stream.takeUntil( - (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction", - ), - Stream.runCollect, - Effect.forkScoped, - ); - for (const status of ["running", "completed"] as const) { - const emitted = yield* Effect.promise(() => - nativeEvents.push({ - type: "message.part.updated", - properties: { - sessionID: sessionId, - part: { - id: "weather-part", - sessionID: sessionId, - messageID: "weather-message", - type: "tool", - callID: "weather-call", - tool: "weather_edit_document", - state: { - status, - input: { document: "remote-doc" }, - title: "Edit remote document", - metadata: {}, - output: "saved", - time: { start: 1, end: 2 }, - }, - }, - }, - }), - ).pipe(Effect.forkScoped); - if (status === "running" && failure === "timeout") { - yield* Effect.promise(() => called.promise); - yield* TestClock.adjust("1 second"); - } - yield* Fiber.join(emitted); - } - yield* Effect.promise(() => - nativeEvents.push({ type: "session.compacted", properties: { sessionID: sessionId } }), - ); - const completed = (yield* Fiber.join(received)).find( - (event) => - event.type === "turn_item.updated" && - event.turnItem.type === "dynamic_tool" && - event.turnItem.status === "completed", - ); - assert.equal(statusReads, 2); - assert.equal( - completed?.type === "turn_item.updated" && completed.turnItem.title, - "Edit remote document", - ); - assert.deepEqual(completed?.type === "turn_item.updated" && completed.turnItem.toolSource, { - key: "mcp:weather", - name: "weather", - kind: "integration", - }); - if (failure === "timeout") assert.isTrue(signal?.aborted); - }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), - ); - - it.effect("admits a native command on its user receipt before generation completes", () => - Effect.gen(function* () { - const nativeEvents = asyncEventStream(); - const release = promiseGate(); - const calls = yield* Queue.unbounded<{ - messageID: string; - command: string; - arguments: string; - model: string; - }>(); - let promptCalls = 0; - const sessionId = "native-command"; - const harness = yield* makeOpenCodeRuntimeHarness("native-command", sessionId, { - event: { - subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { - options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); - return { stream: nativeEvents.stream }; - }, - }, - command: { list: async () => ({ data: [{ name: "review" }] }) }, - session: { - create: async () => ({ data: { id: sessionId, time: { created: 1, updated: 1 } } }), - command: async (input: { - messageID: string; - command: string; - arguments: string; - model: string; - }) => { - Queue.offerUnsafe(calls, input); - await release.promise; - return { data: true }; - }, - promptAsync: async () => { - promptCalls++; - return { data: true }; - }, - }, - }); - const start = yield* harness.startTurn("/review staged changes").pipe(Effect.forkScoped); - const call = yield* Queue.take(calls); - assert.equal(call.command, "review"); - assert.equal(call.arguments, "staged changes"); - assert.equal(call.model, "anthropic/claude-sonnet"); - yield* Effect.promise(() => - nativeEvents.push({ - type: "message.updated", - properties: { - sessionID: sessionId, - info: { - id: call.messageID, - sessionID: sessionId, - role: "user", - time: { created: DateTime.toEpochMillis(harness.now) }, - }, - }, - }), - ); - yield* Fiber.join(start); - assert.equal(promptCalls, 0); - release.resolve(); - }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), - ); - - it.effect("sends unadvertised slash commands as ordinary prompts", () => - Effect.gen(function* () { - const nativeEvents = asyncEventStream(); - const prompts: unknown[] = []; - const harness = yield* makeOpenCodeRuntimeHarness("unknown-command", "unknown-command", { - event: { - subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { - options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); - return { stream: nativeEvents.stream }; - }, - }, - command: { list: async () => ({ data: [{ name: "review" }] }) }, - session: { - create: async () => ({ - data: { id: "unknown-command", time: { created: 1, updated: 1 } }, - }), - promptAsync: async (input: { parts: unknown }) => { - prompts.push(input.parts); - return { data: true }; - }, - }, - }); - yield* harness.startTurn("/unknown words"); - assert.deepEqual(prompts, [[{ type: "text", text: "/unknown words" }]]); - }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), - ); - - it.effect("compacts with the native summarize API and emits a completed compaction", () => - Effect.gen(function* () { - const nativeEvents = asyncEventStream(); - const summarizeCalls: Array = []; - let promptCalls = 0; - const harness = yield* makeOpenCodeRuntimeHarness("compact", "native-opencode-compact", { - event: { - subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { - options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); - return { stream: nativeEvents.stream }; - }, - }, - session: { - create: async () => ({ - data: { id: "native-opencode-compact", time: { created: 1, updated: 1 } }, - }), - summarize: async (input: unknown) => { - summarizeCalls.push(input); - return { data: true }; - }, - promptAsync: async () => { - promptCalls += 1; - return { data: true }; - }, - }, - }); - yield* harness.startTurn("/compact"); - const events = yield* harness.runtime.events.pipe( - Stream.takeUntil((event) => event.type === "turn.terminal"), - Stream.runCollect, - ); - assert.deepEqual(summarizeCalls, [ - { - sessionID: "native-opencode-compact", - providerID: "anthropic", - modelID: "claude-sonnet", - auto: false, - }, - ]); - assert.equal(promptCalls, 0); - assert.equal( - events.filter( - (event) => - event.type === "turn_item.updated" && - event.turnItem.type === "compaction" && - event.turnItem.status === "completed", - ).length, - 1, - ); - assert.isTrue( - events.some((event) => event.type === "turn.terminal" && event.status === "completed"), - ); - }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), - ); - - it.effect("ends a turn on the provider thread it started on", () => - Effect.gen(function* () { - const nativeEvents = asyncEventStream(); - const harness = yield* makeOpenCodeRuntimeHarness( - "forked-row", - "native-opencode-forked-row", - { - event: { - subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { - options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); - return { stream: nativeEvents.stream }; - }, - }, - session: { - create: async () => ({ - data: { id: "native-opencode-forked-row", time: { created: 1, updated: 1 } }, - }), - summarize: async () => ({ data: true }), - }, - }, - ); - // A forked run starts on its own row for the same native session, while - // the adapter tracks the session under the id it minted. - const forkedRow = { - ...harness.providerThread, - id: ProviderThreadId.make("provider-thread:opencode-test:forked-run-row"), - }; - yield* harness.startTurn("/compact", forkedRow); - const terminal = yield* harness.runtime.events.pipe( - Stream.filter( - (event): event is Extract => - event.type === "turn.terminal", - ), - Stream.runHead, - ); - assert.equal(Option.getOrUndefined(terminal)?.providerThreadId, forkedRow.id); - }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), - ); - - it.effect("does not restore messages beyond OpenCode's persisted revert boundary", () => - Effect.gen(function* () { - const nativeEvents = asyncEventStream(); - const nativeSessionId = "native-opencode-reverted-snapshot"; - const messages = [ - { - info: { - id: "user-kept", - sessionID: nativeSessionId, - role: "user", - time: { created: 1 }, - }, - parts: [{ type: "text", text: "keep this prompt" }], - }, - { - info: { - id: "assistant-kept", - sessionID: nativeSessionId, - role: "assistant", - time: { created: 2, completed: 3 }, - }, - parts: [{ type: "text", text: "keep this answer" }], - }, - { - info: { - id: "user-reverted", - sessionID: nativeSessionId, - role: "user", - time: { created: 4 }, - }, - parts: [{ type: "text", text: "remove this prompt" }], - }, - { - info: { - id: "assistant-reverted", - sessionID: nativeSessionId, - role: "assistant", - time: { created: 5, completed: 6 }, - }, - parts: [{ type: "text", text: "remove this answer" }], - }, - ]; - const harness = yield* makeOpenCodeRuntimeHarness("reverted-snapshot", nativeSessionId, { - event: { - subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { - options.signal?.addEventListener("abort", () => nativeEvents.close(), { - once: true, - }); - return { stream: nativeEvents.stream }; - }, - }, - session: { - create: async () => ({ - data: { id: nativeSessionId, time: { created: 1, updated: 1 } }, - }), - get: async () => ({ - data: { - id: nativeSessionId, - time: { created: 1, updated: 6 }, - revert: { messageID: "user-reverted" }, - }, - }), - messages: async () => ({ data: messages }), - }, - }); - - const snapshot = yield* harness.runtime.readThreadSnapshot({ - providerThread: harness.providerThread, - }); - - assert.deepEqual( - snapshot.messages.map((message) => message.text), - ["keep this prompt", "keep this answer"], - ); - const providerPayload = snapshot.providerPayload as ReadonlyArray<{ - readonly info: { readonly id: string }; - }>; - assert.deepEqual( - providerPayload.map((entry) => entry.info.id), - ["user-kept", "assistant-kept"], - ); - assert.equal(snapshot.providerThread.nativeConversationHeadRef?.nativeId, "user-kept"); - }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), - ); - - it.effect("keeps a newly admitted prompt alive across stale idle and delayed busy evidence", () => - Effect.gen(function* () { - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const nativeEvents = asyncEventStream(); - const prompt = promiseGate(); - const promptStarted = promiseGate(); - const steerPrompt = promiseGate(); - const steerPromptStarted = promiseGate(); - const stopPromptStarted = promiseGate(); - const statusCalled = promiseGate(); - const steerStatusCalled = promiseGate(); - const abortCalled = promiseGate(); - let status: "idle" | "busy" = "busy"; - let promptCalls = 0; - let statusCalls = 0; - const promptInputs: Array<{ readonly messageID?: string }> = []; - const client = { - event: { - subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { - options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); - return { stream: nativeEvents.stream }; - }, - }, - session: { - create: async () => ({ - data: { id: "native-opencode-race", time: { created: 1, updated: 1 } }, - }), - get: async () => ({ - data: { id: "native-opencode-race", time: { created: 1, updated: 1 } }, - }), - promptAsync: async ( - input: { readonly messageID?: string }, - options?: { readonly signal?: AbortSignal }, - ) => { - promptInputs.push(input); - promptCalls += 1; - if (promptCalls === 1) { - promptStarted.resolve(); - await prompt.promise; - } else if (promptCalls === 2) { - steerPromptStarted.resolve(); - await steerPrompt.promise; - } else { - stopPromptStarted.resolve(); - await new Promise((_resolve, reject) => - options?.signal?.addEventListener("abort", () => reject(options.signal!.reason), { - once: true, - }), - ); - } - return { data: true }; - }, - status: async () => ({ - data: (() => { - statusCalls += 1; - if (statusCalls === 1) statusCalled.resolve(); - else steerStatusCalled.resolve(); - return { "native-opencode-race": { type: status } }; - })(), - }), - messages: async () => ({ data: [] }), - children: async () => ({ data: [] }), - abort: async () => { - abortCalled.resolve(); - return { data: true }; - }, - }, - mcp: { add: async () => ({ data: true }) }, - }; - const adapter = makeOpenCodeAdapterV2({ - instanceId: ProviderInstanceId.make("opencode-test"), - settings: OPEN_CODE_TEST_SETTINGS, - environment: {}, - runtime: { - connectToOpenCodeServer: () => - Effect.succeed({ url: "http://test.invalid", external: true }), - createOpenCodeSdkClient: () => client, - } as unknown as OpenCodeRuntimeShape, - idAllocator, - serverConfig: { - cwd: "/workspace", - attachmentsDir: "/tmp/attachments", - } as ServerConfig.ServerConfig["Service"], - }); - const threadId = ThreadId.make("thread-opencode-admission-race"); - const providerSessionId = ProviderSessionId.make("session-opencode-admission-race"); - const modelSelection = { - instanceId: ProviderInstanceId.make("opencode-test"), - model: "anthropic/claude-sonnet", - options: [], - }; - const policy = runtimePolicy("full-access", { cwd: "/workspace" }); - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId, - modelSelection, - runtimePolicy: policy, - }); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection, - runtimePolicy: policy, - }); - const now = yield* DateTime.now; - const attemptId = RunAttemptId.make("attempt-opencode-admission-race"); - const runId = RunId.make("run-opencode-admission-race"); - const start = yield* runtime - .startTurn({ - appThread: { - id: threadId, - projectId: ProjectId.make("project-opencode-admission-race"), - title: "race", - providerInstanceId: modelSelection.instanceId, - modelSelection, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - activeProviderThreadId: providerThread.id, - lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, - forkedFrom: null, - createdBy: "user", - creationSource: "web", - createdAt: now, - updatedAt: now, - archivedAt: null, - settledOverride: null, - settledAt: null, - lastVisitedAt: null, - deletedAt: null, - }, - threadId, - runId, - runOrdinal: 1, - providerTurnOrdinal: 1, - attemptId, - rootNodeId: NodeId.make("node-opencode-admission-race"), - providerThread, - message: { - createdBy: "user", - creationSource: "web", - messageId: MessageId.make("message-opencode-admission-race"), - text: "hello", - attachments: [], - }, - modelSelection, - runtimePolicy: policy, - }) - .pipe(Effect.forkScoped); - - yield* Effect.promise(() => promptStarted.promise); - yield* Effect.promise(() => - nativeEvents.push({ - type: "session.status", - properties: { sessionID: "native-opencode-race", status: { type: "idle" } }, - }), - ); - prompt.resolve(); - yield* Fiber.join(start); - assert.match(promptInputs[0]?.messageID ?? "", /^msg_[0-9a-f]{12}[0-9A-Za-z]{28}$/); - yield* Effect.promise(() => - nativeEvents.push({ - type: "session.status", - properties: { sessionID: "native-opencode-race", status: { type: "busy" } }, - }), - ); - status = "busy"; - yield* Effect.promise(() => - nativeEvents.push({ - type: "message.updated", - properties: { - sessionID: "native-opencode-race", - info: { - id: "stale-user-message", - sessionID: "native-opencode-race", - role: "user", - time: { created: DateTime.toEpochMillis(now) }, - }, - }, - }), - ); - assert.equal(statusCalls, 0, "an unrelated user message must not release prompt admission"); - yield* Effect.promise(() => - nativeEvents.push({ - type: "session.status", - properties: { sessionID: "native-opencode-race", status: { type: "idle" } }, - }), - ); - yield* Effect.promise(() => - nativeEvents.push({ - type: "message.updated", - properties: { - sessionID: "native-opencode-race", - info: { - id: promptInputs[0]!.messageID!, - sessionID: "native-opencode-race", - role: "user", - time: { created: DateTime.toEpochMillis(now) }, - }, - }, - }), - ); - - yield* Effect.promise(() => statusCalled.promise); - - const snapshot = yield* runtime.readThreadSnapshot({ providerThread }); - assert.equal(snapshot.providerTurns.at(-1)?.status, "running"); - const activeTurn = snapshot.providerTurns.at(-1)!; - const steer = yield* runtime - .steerTurn({ - threadId, - runId, - providerThread, - providerTurnId: activeTurn.id, - message: { - messageId: MessageId.make("message-opencode-steer-race"), - text: "follow up", - attachments: [], - createdBy: "user", - creationSource: "web", - }, - }) - .pipe(Effect.forkScoped); - yield* Effect.promise(() => steerPromptStarted.promise); - assert.notEqual(promptInputs[1]?.messageID, promptInputs[0]?.messageID); - steerPrompt.resolve(); - yield* Fiber.join(steer); - yield* Effect.promise(() => - nativeEvents.push({ - type: "session.status", - properties: { sessionID: "native-opencode-race", status: { type: "busy" } }, - }), - ); - yield* Effect.promise(() => - nativeEvents.push({ - type: "message.updated", - properties: { - sessionID: "native-opencode-race", - info: { - id: promptInputs[0]!.messageID!, - sessionID: "native-opencode-race", - role: "user", - time: { created: DateTime.toEpochMillis(now) }, - }, - }, - }), - ); - yield* Effect.promise(() => - nativeEvents.push({ - type: "session.status", - properties: { sessionID: "native-opencode-race", status: { type: "idle" } }, - }), - ); - yield* Effect.promise(() => - nativeEvents.push({ - type: "message.updated", - properties: { - sessionID: "native-opencode-race", - info: { - id: promptInputs[1]!.messageID!, - sessionID: "native-opencode-race", - role: "user", - time: { created: DateTime.toEpochMillis(now) }, - }, - }, - }), - ); - yield* Effect.promise(() => steerStatusCalled.promise); - const pendingSteer = yield* runtime - .steerTurn({ - threadId, - runId, - providerThread, - providerTurnId: activeTurn.id, - message: { - messageId: MessageId.make("message-opencode-stop-pending"), - text: "pending when stopped", - attachments: [], - createdBy: "user", - creationSource: "web", - }, - }) - .pipe(Effect.exit, Effect.forkScoped); - yield* Effect.promise(() => stopPromptStarted.promise); - const interrupt = yield* runtime - .interruptTurn({ providerThread, providerTurnId: activeTurn.id }) - .pipe(Effect.forkScoped); - yield* Effect.promise(() => abortCalled.promise); - yield* Fiber.join(interrupt); - assert.isTrue(Exit.isFailure(yield* Fiber.join(pendingSteer))); - const promptWhileStopping = yield* Effect.exit( - runtime.steerTurn({ - threadId, - runId, - providerThread, - providerTurnId: activeTurn.id, - message: { - messageId: MessageId.make("message-opencode-after-stop"), - text: "must not pass the pending stop", - attachments: [], - createdBy: "user", - creationSource: "web", - }, - }), - ); - assert.isTrue(Exit.isFailure(promptWhileStopping)); - assert.equal(promptCalls, 3); - yield* Effect.promise(() => - nativeEvents.push({ - type: "session.status", - properties: { sessionID: "native-opencode-race", status: { type: "idle" } }, - }), - ); - const interrupted = yield* runtime.readThreadSnapshot({ providerThread }); - assert.equal(interrupted.providerTurns.at(-1)?.status, "interrupted"); - }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), - ); - - it.effect("interrupts an initial prompt while its SDK request is pending", () => - Effect.gen(function* () { - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const nativeEvents = asyncEventStream(); - const promptStarted = promiseGate(); - const abortCalled = promiseGate(); - let abortCalls = 0; - const client = { - event: { - subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { - options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); - return { stream: nativeEvents.stream }; - }, - }, - session: { - create: async () => ({ - data: { id: "native-opencode-initial-stop", time: { created: 1, updated: 1 } }, - }), - get: async () => ({ - data: { id: "native-opencode-initial-stop", time: { created: 1, updated: 1 } }, - }), - promptAsync: async (_input: unknown, options?: { readonly signal?: AbortSignal }) => { - promptStarted.resolve(); - await new Promise((_resolve, reject) => { - const signal = options?.signal; - if (signal?.aborted) { - reject(signal.reason); - return; - } - signal?.addEventListener("abort", () => reject(signal.reason), { once: true }); - }); - return { data: true }; - }, - status: async () => ({ - data: { "native-opencode-initial-stop": { type: "idle" as const } }, - }), - messages: async () => ({ data: [] }), - children: async () => ({ data: [] }), - abort: async () => { - abortCalls += 1; - abortCalled.resolve(); - return { data: true }; - }, - }, - mcp: { add: async () => ({ data: true }) }, - }; - const adapter = makeOpenCodeAdapterV2({ - instanceId: ProviderInstanceId.make("opencode-initial-stop-test"), - settings: OPEN_CODE_TEST_SETTINGS, - environment: {}, - runtime: { - connectToOpenCodeServer: () => - Effect.succeed({ url: "http://test.invalid", external: true }), - createOpenCodeSdkClient: () => client, - } as unknown as OpenCodeRuntimeShape, - idAllocator, - serverConfig: { - cwd: "/workspace", - attachmentsDir: "/tmp/attachments", - } as ServerConfig.ServerConfig["Service"], - }); - const threadId = ThreadId.make("thread-opencode-initial-stop"); - const providerSessionId = ProviderSessionId.make("session-opencode-initial-stop"); - const modelSelection = { - instanceId: ProviderInstanceId.make("opencode-initial-stop-test"), - model: "anthropic/claude-sonnet", - options: [], - }; - const policy = runtimePolicy("full-access", { cwd: "/workspace" }); - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId, - modelSelection, - runtimePolicy: policy, - }); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection, - runtimePolicy: policy, - }); - const terminalEvents = yield* runtime.events.pipe( - Stream.takeUntil((event) => event.type === "turn.terminal"), - Stream.runCollect, - Effect.forkScoped, - ); - const now = yield* DateTime.now; - const start = yield* runtime - .startTurn({ - appThread: { - id: threadId, - projectId: ProjectId.make("project-opencode-initial-stop"), - title: "initial stop", - providerInstanceId: modelSelection.instanceId, - modelSelection, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - activeProviderThreadId: providerThread.id, - lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, - forkedFrom: null, - createdBy: "user", - creationSource: "web", - createdAt: now, - updatedAt: now, - archivedAt: null, - settledOverride: null, - settledAt: null, - lastVisitedAt: null, - deletedAt: null, - }, - threadId, - runId: RunId.make("run-opencode-initial-stop"), - runOrdinal: 1, - providerTurnOrdinal: 1, - attemptId: RunAttemptId.make("attempt-opencode-initial-stop"), - rootNodeId: NodeId.make("node-opencode-initial-stop"), - providerThread, - message: { - createdBy: "user", - creationSource: "web", - messageId: MessageId.make("message-opencode-initial-stop"), - text: "hello", - attachments: [], - }, - modelSelection, - runtimePolicy: policy, - }) - .pipe(Effect.forkScoped); - - yield* Effect.promise(() => promptStarted.promise); - const running = yield* runtime.readThreadSnapshot({ providerThread }); - const activeTurn = running.providerTurns.at(-1)!; - const interrupt = yield* runtime - .interruptTurn({ providerThread, providerTurnId: activeTurn.id }) - .pipe(Effect.forkScoped); - - yield* Effect.promise(() => abortCalled.promise); - yield* Fiber.join(start); - yield* Fiber.join(interrupt); - yield* Effect.promise(() => - nativeEvents.push({ - type: "session.status", - properties: { - sessionID: "native-opencode-initial-stop", - status: { type: "idle" }, - }, - }), - ); - - const events = Array.from(yield* Fiber.join(terminalEvents)); - const terminals = events.filter((event) => event.type === "turn.terminal"); - assert.equal(abortCalls, 1); - assert.lengthOf(terminals, 1); - assert.equal(terminals[0]?.status, "interrupted"); - assert.isNull(terminals[0]?.failure ?? null); - assert.isFalse( - events.some( - (event) => - (event.type === "turn.terminal" && event.status === "failed") || - (event.type === "provider_session.updated" && event.providerSession.status === "error"), - ), - ); - }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), - ); - - it.effect("fails an active turn when the OpenCode event stream ends cleanly", () => - Effect.gen(function* () { - const nativeEvents = asyncEventStream(); - const harness = yield* makeOpenCodeRuntimeHarness( - "clean-event-eof", - "native-opencode-clean-event-eof", - { - event: { - subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { - options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); - return { stream: nativeEvents.stream }; - }, - }, - session: { - create: async () => ({ - data: { id: "native-opencode-clean-event-eof", time: { created: 1, updated: 1 } }, - }), - promptAsync: async () => ({ data: true }), - }, - }, - ); - yield* harness.startTurn(); - const terminalEvents = yield* harness.runtime.events.pipe( - Stream.runCollect, - Effect.forkScoped, - ); - - nativeEvents.close(); - const received = Array.from(yield* Fiber.join(terminalEvents)); - assert.isTrue( - received.some( - (event) => - event.type === "provider_session.updated" && event.providerSession.status === "error", - ), - ); - const terminal = received.find((event) => event.type === "turn.terminal"); - assert.equal(terminal?.status, "failed"); - assert.equal(terminal?.failure?.class, "transport_error"); - assert.equal(terminal?.threadDisposition, "broken"); - assert.equal((yield* Effect.exit(harness.startTurn()))._tag, "Failure"); - }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), - ); - - it.effect("fails compaction when its response races stream termination", () => - Effect.gen(function* () { - const nativeEvents = asyncEventStream(); - const summarizeStarted = promiseGate(); - const summarizeResult = promiseGate<{ data: boolean }>(); - const baseClock = yield* Clock.Clock; - const eofClockRead = yield* Deferred.make(); - const releaseEofClockRead = yield* Deferred.make(); - let blockNextClockRead = false; - const blockingClock: Clock.Clock = { - ...baseClock, - currentTimeMillis: Effect.suspend(() => { - if (!blockNextClockRead) return baseClock.currentTimeMillis; - blockNextClockRead = false; - return Deferred.succeed(eofClockRead, undefined).pipe( - Effect.andThen(Deferred.await(releaseEofClockRead)), - Effect.andThen(baseClock.currentTimeMillis), - ); - }), - }; - const harness = yield* makeOpenCodeRuntimeHarness( - "compaction-eof-race", - "native-opencode-compaction-eof-race", - { - event: { subscribe: async () => ({ stream: nativeEvents.stream }) }, - session: { - create: async () => ({ - data: { id: "native-opencode-compaction-eof-race", time: { created: 1, updated: 1 } }, - }), - summarize: () => { - summarizeStarted.resolve(); - return summarizeResult.promise; - }, - }, - }, - ).pipe(Effect.provideService(Clock.Clock, blockingClock)); - const events = yield* harness.runtime.events.pipe(Stream.runCollect, Effect.forkScoped); - const start = yield* harness.startTurn("/compact").pipe(Effect.forkScoped); - yield* Effect.promise(() => summarizeStarted.promise); - blockNextClockRead = true; - nativeEvents.close(); - yield* Deferred.await(eofClockRead); - summarizeResult.resolve({ data: true }); - yield* Fiber.join(start); - yield* Deferred.succeed(releaseEofClockRead, undefined); - const received = Array.from(yield* Fiber.join(events)); - const terminals = received.filter((event) => event.type === "turn.terminal"); - assert.lengthOf(terminals, 1); - assert.equal(terminals[0]?.status, "failed"); - assert.equal(terminals[0]?.failure?.class, "transport_error"); - assert.isFalse( - received.some( - (event) => - event.type === "turn_item.updated" && - event.turnItem.type === "compaction" && - event.turnItem.status === "completed", - ), - ); - }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), - ); - - it.effect("does not register a turn after the OpenCode event stream ends", () => - Effect.gen(function* () { - const nativeEvents = asyncEventStream(); - let promptCalls = 0; - const harness = yield* makeOpenCodeRuntimeHarness( - "event-eof-start-race", - "native-opencode-event-eof-start-race", - { - event: { - subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { - options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); - return { stream: nativeEvents.stream }; - }, - }, - session: { - create: async () => ({ - data: { - id: "native-opencode-event-eof-start-race", - time: { created: 1, updated: 1 }, - }, - }), - promptAsync: async () => { - promptCalls += 1; - return { data: true }; - }, - }, - }, - ); - const baseClock = yield* Clock.Clock; - const startClockRead = yield* Deferred.make(); - const releaseStartClockRead = yield* Deferred.make(); - let blockNextClockRead = true; - const blockingClock: Clock.Clock = { - ...baseClock, - currentTimeMillis: Effect.suspend(() => { - if (!blockNextClockRead) return baseClock.currentTimeMillis; - blockNextClockRead = false; - return Deferred.succeed(startClockRead, undefined).pipe( - Effect.andThen(Deferred.await(releaseStartClockRead)), - Effect.andThen(baseClock.currentTimeMillis), - ); - }), - }; - const start = yield* harness - .startTurn() - .pipe(Effect.provideService(Clock.Clock, blockingClock), Effect.exit, Effect.forkScoped); - yield* Deferred.await(startClockRead); - - const events = yield* harness.runtime.events.pipe(Stream.runCollect, Effect.forkScoped); - nativeEvents.close(); - yield* Fiber.join(events); - yield* Deferred.succeed(releaseStartClockRead, undefined); - - assert.isTrue(Exit.isFailure(yield* Fiber.join(start))); - assert.equal(promptCalls, 0); - }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), - ); - - it("holds stale idle through prompt admission until the new user message is observed", () => { - const admission = { - admissionPending: true, - admissionAccepted: false, - admissionMessageObserved: false, - idleDuringAdmission: false, - }; - - assert.equal(advanceOpenCodePromptAdmission(admission, "idle"), "hold"); - assert.equal(advanceOpenCodePromptAdmission(admission, "accepted"), "hold"); - assert.isTrue(admission.admissionPending); - assert.equal(advanceOpenCodePromptAdmission(admission, "user-message"), "reconcile-idle"); - assert.isTrue(admission.admissionPending); - }); - - it("releases admission only after prompt acceptance and new-turn evidence", () => { - const admission = { - admissionPending: true, - admissionAccepted: false, - admissionMessageObserved: false, - idleDuringAdmission: false, - }; - - assert.equal(advanceOpenCodePromptAdmission(admission, "busy"), "hold"); - assert.equal(advanceOpenCodePromptAdmission(admission, "accepted"), "release"); - assert.isFalse(admission.admissionPending); - }); - - it("releases admission when an assistant completes under a provider-assigned message id", () => { - const admission = { - admissionPending: true, - admissionAccepted: false, - admissionMessageObserved: false, - idleDuringAdmission: true, - }; - - assert.equal(advanceOpenCodePromptAdmission(admission, "assistant-completed"), "release"); - assert.isTrue(admission.admissionAccepted); - assert.isTrue(admission.admissionMessageObserved); - assert.isFalse(admission.admissionPending); - }); - - it("invalidates pending admission before aborting a turn", () => { - const admission = { - admissionGeneration: 4, - admissionPending: true, - admissionAccepted: false, - admissionMessageObserved: false, - idleDuringAdmission: false, - }; - - cancelOpenCodePromptAdmission(admission, 5); - - assert.equal(admission.admissionGeneration, 5); - assert.isFalse(admission.admissionPending); - assert.equal(advanceOpenCodePromptAdmission(admission, "accepted"), "release"); - }); - - it.effect("reconciles current idle and busy status replies after prompt admission", () => - Effect.gen(function* () { - for (const expected of ["idle", "busy"] as const) { - const admission = { admissionGeneration: 4, admissionPending: true }; - const status = yield* Deferred.make<"idle" | "busy" | "unknown">(); - const fiber = yield* reconcileOpenCodePromptAdmissionStatus( - admission, - 4, - Deferred.await(status), - ).pipe(Effect.forkChild({ startImmediately: true })); - - yield* Deferred.succeed(status, expected); - - assert.equal(yield* Fiber.join(fiber), expected); - assert.isFalse(admission.admissionPending); - } - }), - ); - - it.effect("keeps admission pending after a transient status failure", () => - Effect.gen(function* () { - const admission = { admissionGeneration: 4, admissionPending: true }; - assert.equal( - yield* reconcileOpenCodePromptAdmissionStatus(admission, 4, Effect.succeed("unknown")), - "unknown", - ); - assert.isTrue(admission.admissionPending); - assert.equal( - yield* reconcileOpenCodePromptAdmissionStatus(admission, 4, Effect.succeed("idle")), - "idle", - ); - assert.isFalse(admission.admissionPending); - }), - ); - - it.effect("retries a transient status failure without another idle event", () => - Effect.gen(function* () { - const nativeSessionId = "native-opencode-status-retry"; - const nativeEvents = asyncEventStream(); - const promptRelease = promiseGate(); - const promptCalls = yield* Queue.unbounded(); - const statusCalls = yield* Queue.unbounded(); - let statusCallCount = 0; - const client = { - event: { - subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { - options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); - return { stream: nativeEvents.stream }; - }, - }, - session: { - create: async () => ({ - data: { id: nativeSessionId, time: { created: 1, updated: 1 } }, - }), - get: async () => ({ - data: { id: nativeSessionId, time: { created: 1, updated: 1 } }, - }), - promptAsync: async (input: { readonly messageID?: string }) => { - Queue.offerUnsafe(promptCalls, input.messageID!); - await promptRelease.promise; - return { data: true }; - }, - status: async () => { - statusCallCount += 1; - Queue.offerUnsafe(statusCalls, statusCallCount); - if (statusCallCount === 1) throw new Error("transient status failure"); - return { data: { [nativeSessionId]: { type: "idle" as const } } }; - }, - messages: async () => ({ data: [] }), - children: async () => ({ data: [] }), - abort: async () => ({ data: true }), - }, - mcp: { add: async () => ({ data: true }) }, - }; - const harness = yield* makeOpenCodeRuntimeHarness("status-retry", nativeSessionId, client); - const terminalEvents = yield* harness.runtime.events.pipe( - Stream.takeUntil((event) => event.type === "turn.terminal"), - Stream.runCollect, - Effect.forkScoped, - ); - const start = yield* harness.startTurn().pipe(Effect.forkScoped); - const admissionMessageId = yield* Queue.take(promptCalls); - - yield* Effect.promise(() => - nativeEvents.push({ - type: "session.status", - properties: { sessionID: nativeSessionId, status: { type: "idle" } }, - }), - ); - promptRelease.resolve(); - yield* Fiber.join(start); - const userMessage = { - type: "message.updated", - properties: { - sessionID: nativeSessionId, - info: { - id: admissionMessageId, - sessionID: nativeSessionId, - role: "user", - time: { created: DateTime.toEpochMillis(harness.now) }, - }, - }, - }; - yield* Effect.promise(() => nativeEvents.push(userMessage)); - assert.equal(yield* Queue.take(statusCalls), 1); - - yield* Effect.promise(() => nativeEvents.push(userMessage)); - assert.equal(statusCallCount, 1, "duplicate events must share the generation's retry worker"); - yield* TestClock.adjust("250 millis"); - assert.equal(yield* Queue.take(statusCalls), 2); - - const events = Array.from(yield* Fiber.join(terminalEvents)); - const terminals = events.filter((event) => event.type === "turn.terminal"); - assert.equal(statusCallCount, 2); - assert.lengthOf(terminals, 1); - assert.equal(terminals[0]?.status, "completed"); - assert.isNull(terminals[0]?.failure ?? null); - }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), - ); - - it.effect("does not let a queued status retry adopt a newer steer generation", () => - Effect.gen(function* () { - const nativeSessionId = "native-opencode-stale-status-retry"; - const nativeEvents = asyncEventStream(); - const firstPromptRelease = promiseGate(); - const promptCalls = yield* Queue.unbounded(); - const statusCalls = yield* Queue.unbounded(); - let promptCallCount = 0; - let statusCallCount = 0; - const client = { - event: { - subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { - options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); - return { stream: nativeEvents.stream }; - }, - }, - session: { - create: async () => ({ - data: { id: nativeSessionId, time: { created: 1, updated: 1 } }, - }), - get: async () => ({ - data: { id: nativeSessionId, time: { created: 1, updated: 1 } }, - }), - promptAsync: async (input: { readonly messageID?: string }) => { - promptCallCount += 1; - Queue.offerUnsafe(promptCalls, input.messageID!); - if (promptCallCount === 1) { - await firstPromptRelease.promise; - } - return { data: true }; - }, - status: async () => { - statusCallCount += 1; - Queue.offerUnsafe(statusCalls, statusCallCount); - if (statusCallCount === 1) throw new Error("transient status failure"); - return { data: { [nativeSessionId]: { type: "idle" as const } } }; - }, - messages: async () => ({ data: [] }), - children: async () => ({ data: [] }), - abort: async () => ({ data: true }), - }, - mcp: { add: async () => ({ data: true }) }, - }; - const harness = yield* makeOpenCodeRuntimeHarness( - "stale-status-retry", - nativeSessionId, - client, - ); - const start = yield* harness.startTurn().pipe(Effect.forkScoped); - const firstAdmissionMessageId = yield* Queue.take(promptCalls); - - yield* Effect.promise(() => - nativeEvents.push({ - type: "session.status", - properties: { sessionID: nativeSessionId, status: { type: "idle" } }, - }), - ); - firstPromptRelease.resolve(); - yield* Fiber.join(start); - yield* Effect.promise(() => - nativeEvents.push({ - type: "message.updated", - properties: { - sessionID: nativeSessionId, - info: { - id: firstAdmissionMessageId, - sessionID: nativeSessionId, - role: "user", - time: { created: DateTime.toEpochMillis(harness.now) }, - }, - }, - }), - ); - assert.equal(yield* Queue.take(statusCalls), 1); - - const running = yield* harness.runtime.readThreadSnapshot({ - providerThread: harness.providerThread, - }); - const activeTurn = running.providerTurns.at(-1)!; - yield* harness.runtime.steerTurn({ - threadId: harness.threadId, - runId: harness.runId, - providerThread: harness.providerThread, - providerTurnId: activeTurn.id, - message: { - messageId: MessageId.make("message-opencode-stale-status-retry-steer"), - text: "new generation", - attachments: [], - createdBy: "user", - creationSource: "web", - }, - }); - yield* Queue.take(promptCalls); - yield* TestClock.adjust("250 millis"); - - const afterRetry = yield* harness.runtime.readThreadSnapshot({ - providerThread: harness.providerThread, - }); - assert.equal(statusCallCount, 1); - assert.equal(afterRetry.providerTurns.at(-1)?.status, "running"); - }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), - ); - - it.effect("ignores a delayed status reply after steering starts a newer admission", () => - Effect.gen(function* () { - const admission = { admissionGeneration: 4, admissionPending: true }; - const status = yield* Deferred.make<"idle" | "busy" | "unknown">(); - const fiber = yield* reconcileOpenCodePromptAdmissionStatus( - admission, - 4, - Deferred.await(status), - ).pipe(Effect.forkChild({ startImmediately: true })); - - admission.admissionGeneration = 5; - admission.admissionPending = true; - yield* Deferred.succeed(status, "idle"); - - assert.equal(yield* Fiber.join(fiber), "stale"); - assert.equal(admission.admissionGeneration, 5); - assert.isTrue(admission.admissionPending); - }), - ); - - it.effect("does not revive admission when abort wins a pending status lookup", () => - Effect.gen(function* () { - const admission = { admissionGeneration: 4, admissionPending: true }; - const status = yield* Deferred.make<"idle" | "busy" | "unknown">(); - const fiber = yield* reconcileOpenCodePromptAdmissionStatus( - admission, - 4, - Deferred.await(status), - ).pipe(Effect.forkChild({ startImmediately: true })); - - cancelOpenCodePromptAdmission(admission, 5); - yield* Deferred.succeed(status, "idle"); - - assert.equal(yield* Fiber.join(fiber), "stale"); - assert.isFalse(admission.admissionPending); - }), - ); - - it.effect("logs bounded structural protocol diagnostics without native payload values", () => - Effect.gen(function* () { - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const records: Array = []; - const nativeEventLogger: EventNdjsonLogger = { - filePath: "/tmp/provider-native.ndjson", - write: (event) => Effect.sync(() => void records.push(event)), - close: () => Effect.void, - }; - const logProtocolEvent = makeOpenCodeProtocolLogger({ - nativeEventLogger, - idAllocator, - providerInstanceId: ProviderInstanceId.make("opencode-test"), - providerSessionId: ProviderSessionId.make("provider-session-opencode-test"), - threadId: ThreadId.make("thread-opencode-test"), - }); - const secret = "secret-opencode-prompt"; - - yield* logProtocolEvent({ - direction: "outgoing", - messageKind: "request", - method: "session.prompt", - payload: { prompt: secret, nested: { token: secret } }, - }); - - const serialized = encodeUnknownJson(records); - assert.notInclude(serialized, secret); - assert.include(serialized, '"protocol":"opencode-sdk.sse"'); - assert.include(serialized, '"method":"session.prompt"'); - assert.include(serialized, '"fieldCount":2'); - }).pipe(Effect.provide(IdAllocator.layer)), - ); - - it.effect("adopts the handed-over provider thread identity on session create", () => - Effect.gen(function* () { - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const serverConfig = yield* ServerConfig.ServerConfig; - let createCount = 0; - const createInputs: Array = []; - const fakeClient = { - event: { - subscribe: async (_input?: unknown, options?: { readonly signal?: AbortSignal }) => ({ - // Emits nothing and ends when the pump's abort signal fires. - stream: { - [Symbol.asyncIterator]: () => ({ - next: () => - new Promise>((resolve) => { - const done = () => resolve({ done: true, value: undefined }); - if (options?.signal?.aborted) return done(); - options?.signal?.addEventListener("abort", done, { once: true }); - }), - }), - }, - }), - }, - session: { - create: async (input: unknown) => { - createCount += 1; - createInputs.push(input); - return { data: { id: `ses_native_${createCount}`, time: { created: 1, updated: 1 } } }; - }, - }, - } as unknown as OpencodeClient; - const unused = (operation: string) => () => Effect.die(`${operation} is not used`); - const runtime: OpenCodeRuntimeShape = { - startOpenCodeServerProcess: unused("startOpenCodeServerProcess"), - connectToOpenCodeServer: () => - Effect.succeed({ - url: "test://opencode", - version: "test", - exitCode: null, - external: true, - }), - runOpenCodeCommand: unused("runOpenCodeCommand"), - createOpenCodeSdkClient: () => fakeClient, - loadOpenCodeInventory: unused("loadOpenCodeInventory"), - loadInventoryFromCli: unused("loadInventoryFromCli"), - loadOpenCodeSkills: unused("loadOpenCodeSkills"), - loadSkillsFromCli: unused("loadSkillsFromCli"), - }; - const instanceId = ProviderInstanceId.make("opencode"); - const threadId = ThreadId.make("thread-opencode-adopt"); - const modelSelection = { instanceId, model: "default" }; - const adapter = makeOpenCodeAdapterV2({ - instanceId, - settings: OPENCODE_TEST_SETTINGS, - environment: {}, - runtime, - idAllocator, - serverConfig, - }); - const session = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make("provider-session-opencode-adopt"), - modelSelection, - runtimePolicy: runtimePolicy("full-access"), - }); - const now = yield* DateTime.now; - // The placeholder row the orchestrator creates for a first run: no - // native identity yet. The adapter must bind the created session to - // this row instead of minting a second session-keyed row. - const placeholder: OrchestrationV2ProviderThread = { - id: ProviderThreadId.make("thread:provider:opencode:native-thread:pending:run:adopt:1"), - driver: OPENCODE_PROVIDER, - providerInstanceId: instanceId, - providerSessionId: null, - appThreadId: threadId, - ownerNodeId: null, - nativeThreadRef: null, - nativeConversationHeadRef: null, - status: "not_loaded", - firstRunOrdinal: 1, - lastRunOrdinal: 1, - handoffIds: [], - forkedFrom: null, - createdAt: now, - updatedAt: now, - }; - const adopted = yield* session.ensureThread({ - threadId, - modelSelection, - runtimePolicy: runtimePolicy("full-access"), - existingProviderThread: placeholder, - }); - assert.equal(adopted.id, placeholder.id); - assert.equal(adopted.nativeThreadRef?.nativeId, "ses_native_1"); - // Without a handed-over row the adapter still derives its own id. - const minted = yield* session.ensureThread({ - threadId, - modelSelection, - runtimePolicy: runtimePolicy("full-access"), - }); - assert.notEqual(minted.id, placeholder.id); - assert.equal(minted.nativeThreadRef?.nativeId, "ses_native_2"); - // OpenCode names a session from its first prompt only when create - // leaves the title unset, so the adapter never sends one. - for (const input of createInputs) { - assert.notProperty(input, "title"); - } - }).pipe( - Effect.scoped, - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-opencode-v2-adapter-", - }).pipe(Layer.provide(NodeServices.layer)), - ), - ), - ), - ); - - it("maps native permission families to orchestration request kinds", () => { - assert.equal(openCodePermissionRequestKind("bash"), "command"); - assert.equal(openCodePermissionRequestKind("read"), "file-read"); - assert.equal(openCodePermissionRequestKind("grep"), "file-read"); - assert.equal(openCodePermissionRequestKind("external_directory"), "file-read"); - assert.equal(openCodePermissionRequestKind("external_directory", "edit"), "file-change"); - assert.equal(openCodePermissionRequestKind("edit"), "file-change"); - assert.equal(openCodePermissionRequestKind("apply_patch"), "file-change"); - assert.equal(openCodePermissionRequestKind("todowrite"), "command"); - assert.equal(openCodePermissionRequestKind("custom", "todowrite"), "command"); - }); - - it("maps OpenCode tools to semantic turn-item families", () => { - assert.equal(openCodeToolProjectionKind("bash"), "command_execution"); - assert.equal(openCodeToolProjectionKind("edit"), "file_change"); - assert.equal(openCodeToolProjectionKind("read"), "dynamic_tool"); - assert.equal(openCodeToolProjectionKind("lsp"), "file_search"); - assert.equal(openCodeToolProjectionKind("websearch"), "web_search"); - assert.equal(openCodeToolProjectionKind("codesearch"), "web_search"); - assert.equal(openCodeToolProjectionKind("todowrite"), "dynamic_tool"); - assert.equal(openCodeToolProjectionKind("custom_tool"), "dynamic_tool"); - }); - - it("maps runtime modes to safe OpenCode permission rules", () => { - const approvalRequired = openCodePermissionRules(runtimePolicy("approval-required")); - assert.equal(permissionAction(approvalRequired, "read"), "allow"); - assert.equal(permissionAction(approvalRequired, "edit"), "ask"); - assert.equal(permissionAction(approvalRequired, "bash"), "ask"); - assert.equal(permissionAction(approvalRequired, "doom_loop"), "ask"); - assert.equal(permissionAction(approvalRequired, "unknown_plugin_tool"), "ask"); - assert.equal(permissionAction(approvalRequired, "question"), "allow"); - - const autoAcceptEdits = openCodePermissionRules(runtimePolicy("auto-accept-edits")); - assert.equal(permissionAction(autoAcceptEdits, "edit"), "allow"); - assert.equal(permissionAction(autoAcceptEdits, "bash"), "ask"); - - const fullAccess = openCodePermissionRules(runtimePolicy("full-access")); - assert.equal(permissionAction(fullAccess, "bash"), "allow"); - assert.equal(permissionAction(fullAccess, "edit"), "allow"); - - const granularApproval = openCodePermissionRules( - runtimePolicy("full-access", { - approvalPolicy: { granular: { request_permissions: true } }, - }), - ); - assert.equal(permissionAction(granularApproval, "bash"), "ask"); - assert.equal(permissionAction(granularApproval, "read"), "allow"); - - const approvalRequiredWorkspaceWrite = openCodePermissionRules( - runtimePolicy("approval-required", { - sandboxPolicy: { - type: "workspaceWrite", - writableRoots: ["/tmp/opencode-workspace"], - networkAccess: false, - }, - }), - ); - assert.equal(permissionAction(approvalRequiredWorkspaceWrite, "edit"), "ask"); - }); - - it("enforces non-interactive sandbox policy through OpenCode permissions", () => { - const readOnly = openCodePermissionRules( - runtimePolicy("full-access", { - approvalPolicy: "never", - sandboxPolicy: { - type: "readOnly", - access: { type: "fullAccess" }, - networkAccess: false, - }, - }), - ); - assert.equal(permissionAction(readOnly, "read"), "allow"); - assert.equal(permissionAction(readOnly, "edit"), "deny"); - assert.equal(permissionAction(readOnly, "bash"), "deny"); - assert.equal(permissionAction(readOnly, "webfetch"), "deny"); - assert.equal(permissionAction(readOnly, "doom_loop"), "deny"); - assert.equal(permissionAction(readOnly, "unknown_plugin_tool"), "deny"); - assert.equal(permissionAction(readOnly, "external_directory"), "allow"); - - const workspaceWrite = openCodePermissionRules( - runtimePolicy("auto-accept-edits", { - approvalPolicy: "never", - sandboxPolicy: { - type: "workspaceWrite", - writableRoots: ["/tmp/opencode-workspace"], - networkAccess: true, - }, - }), - ); - assert.equal(permissionAction(workspaceWrite, "edit"), "allow"); - assert.equal(permissionAction(workspaceWrite, "bash"), "deny"); - assert.equal(permissionAction(workspaceWrite, "webfetch"), "allow"); - assert.deepInclude(workspaceWrite, { - permission: "external_directory", - pattern: "/tmp/opencode-workspace/*", - action: "allow", - }); - }); - - it("preserves OpenCode's recursion guard on task-created child sessions", () => { - const childRules = openCodeChildPermissionRules(runtimePolicy("full-access"), [ - { permission: "task", pattern: "*", action: "deny" }, - ]); - - assert.equal(permissionAction(childRules, "read"), "allow"); - assert.equal(permissionAction(childRules, "bash"), "allow"); - assert.equal(permissionAction(childRules, "task"), "deny"); - - const approvalRequiredPolicy = runtimePolicy("approval-required"); - const parentRules = openCodePermissionRules(approvalRequiredPolicy); - const childApprovalRules = openCodeChildPermissionRules(approvalRequiredPolicy, [ - ...parentRules.filter((rule) => rule.action === "deny"), - { permission: "task", pattern: "*", action: "deny" }, - ]); - assert.equal(permissionAction(childApprovalRules, "bash"), "ask"); - assert.equal(permissionAction(childApprovalRules, "task"), "deny"); - }); - - it("uses the next native user message as the exclusive fork and revert boundary", () => { - const first = providerTurn({ id: "turn:first", ordinal: 1, nativeId: "msg-user-1" }); - const synthetic = providerTurn({ id: "turn:synthetic", ordinal: 2, nativeId: null }); - const third = providerTurn({ id: "turn:third", ordinal: 3, nativeId: "msg-user-3" }); - - assert.equal( - openCodeBoundaryAfterProviderTurn([third, first, synthetic], first.id), - "msg-user-3", - ); - assert.isUndefined(openCodeBoundaryAfterProviderTurn([first, synthetic, third], third.id)); - }); -}); - -it.effect.each([false, true])( - "OpenCode rewind forks history and validates the retained boundary, invalid=%s", - (invalid) => - Effect.gen(function* () { - const events = asyncEventStream(); - const nativeSessionId = "rewind-source"; - const forkId = "rewind-fork"; - const calls: string[] = []; - const removed = { - info: { id: "first-user", sessionID: nativeSessionId, role: "user", time: { created: 1 } }, - parts: [], - }; - const harness = yield* makeOpenCodeRuntimeHarness("rewind-fork", nativeSessionId, { - event: { - subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { - options.signal?.addEventListener("abort", () => events.close(), { once: true }); - return { stream: events.stream }; - }, - }, - session: { - create: async () => ({ data: { id: nativeSessionId, time: { created: 1, updated: 1 } } }), - get: async ({ sessionID }: { sessionID: string }) => ({ - data: { id: sessionID, time: { created: 1, updated: 2 } }, - }), - messages: async ({ sessionID }: { sessionID: string }) => ({ - data: sessionID === nativeSessionId || invalid ? [removed] : [], - }), - fork: async ({ sessionID, messageID }: { sessionID: string; messageID: string }) => { - assert.equal(sessionID, nativeSessionId); - assert.equal(messageID, "first-user"); - calls.push("fork"); - return { data: { id: forkId, time: { created: 1, updated: 2 } } }; - }, - update: async () => { - calls.push("permissions"); - return { data: {} }; - }, - revert: async () => { - throw new Error("Native revert would change workspace files"); - }, - }, - }); - const effect = harness.runtime.rollbackThread({ - providerThread: harness.providerThread, - target: { - type: "thread_start", - checkpointId: CheckpointId.make("rewind-checkpoint"), - appRunOrdinal: 0, - }, - providerThreadTurns: [], - }); - if (invalid) { - const error = yield* effect.pipe(Effect.flip); - assert.equal(error._tag, "ProviderAdapterRollbackThreadError"); - assert.deepEqual(calls, ["fork"]); - } else { - const result = yield* effect; - assert.equal(result.providerThread.nativeThreadRef?.nativeId, forkId); - assert.equal(result.messages.length, 0); - assert.deepEqual(calls, ["fork", "permissions"]); - } - }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), -); diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.testkit.ts index aaf50f24a6ac4f32aa2c669e19ca06f90ea56c11..36d0e35ddcede3f369e2167c06f8e99e0ad48043 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.testkit.ts @@ -6,22 +6,19 @@ import * as Duration from "effect/Duration"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; -import * as ServerConfig from "../../config.ts"; -import * as OpenCodeRuntime from "../../provider/opencodeRuntime.ts"; +import { OpenCodeAdapterV2Driver } from "@t3tools/provider-opencode/server"; +import * as OpenCodeRuntime from "@t3tools/provider-opencode/server/OpenCodeRuntime"; +import { layerTestProviderHost } from "@t3tools/provider-testing/host"; import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { ProviderAdapterDriverCreateError } from "@t3tools/provider-core/server/adapterDriver"; import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts"; -import { - makeReplayServerConfig, - type OrchestratorV2ProviderReplayHarness, -} from "../testkit/ProviderReplayHarness.ts"; +import { type OrchestratorV2ProviderReplayHarness } from "../testkit/ProviderReplayHarness.ts"; import { OPENCODE_DEFAULT_INSTANCE_ID, OPENCODE_PROVIDER, OPENCODE_SDK_PROTOCOL, - OpenCodeAdapterV2Driver, -} from "./OpenCodeAdapterV2.ts"; +} from "@t3tools/provider-opencode/testing"; const OPENCODE_SDK_REPLAY_PROTOCOL = OPENCODE_SDK_PROTOCOL; @@ -457,10 +454,6 @@ function layerOpenCodeReplayRuntime(transcript: OpenCodeSdkReplayTranscript) { } function layerOpenCodeProviderAdapterRegistryReplay(transcript: OpenCodeSdkReplayTranscript) { - const layerServerConfig = Layer.effect( - ServerConfig.ServerConfig, - makeReplayServerConfig(transcript.scenario).pipe(Effect.orDie), - ).pipe(Layer.provide(NodeServices.layer)); return ProviderAdapterRegistry.layerFromDrivers({ drivers: [OpenCodeAdapterV2Driver], configMap: { @@ -473,7 +466,7 @@ function layerOpenCodeProviderAdapterRegistryReplay(transcript: OpenCodeSdkRepla Layer.provide( Layer.mergeAll( layerOpenCodeReplayRuntime(transcript), - layerServerConfig, + layerTestProviderHost().pipe(Layer.provide(NodeServices.layer)), NodeServices.layer, IdAllocator.layer, Layer.succeed( diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts deleted file mode 100644 index c746bec2180e61fef52195da1d78bdc9a526d682..0000000000000000000000000000000000000000 --- a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts +++ /dev/null @@ -1,3807 +0,0 @@ -import type { - Event as OpenCodeEvent, - Message as OpenCodeMessage, - Part as OpenCodePart, - PermissionRequest, - PermissionRuleset, - QuestionRequest, - Session as OpenCodeSession, - Todo as OpenCodeTodo, - ToolPart, -} from "@opencode-ai/sdk/v2"; -import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; -import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; -import { causeErrorTag } from "@t3tools/shared/observability"; -import { - defaultInstanceIdForDriver, - type ModelSelection, - type OpenCodeSettings, - type OrchestrationV2AppThread, - type OrchestrationV2ConversationMessage, - type OrchestrationV2ExecutionNode, - type OrchestrationV2PlanStep, - type OrchestrationV2ProviderCapabilities, - type OrchestrationV2ProviderFailure, - type OrchestrationV2ProviderRef, - type OrchestrationV2ProviderSession, - type OrchestrationV2ProviderThread, - type OrchestrationV2ProviderTurn, - type OrchestrationV2RuntimeRequest, - type OrchestrationV2Subagent, - type OrchestrationV2TurnItem, - OpenCodeSettings as OpenCodeSettingsSchema, - type PlanId, - ProviderDriverKind, - type ProviderInstanceId, - type ProviderRequestKind, - type ProviderSessionId, - type RuntimeRequestId, - type ThreadId, -} from "@t3tools/contracts"; -import * as Cause from "effect/Cause"; -import * as DateTime from "effect/DateTime"; -import * as Deferred from "effect/Deferred"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; -import * as Fiber from "effect/Fiber"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Queue from "effect/Queue"; -import * as Random from "effect/Random"; -import * as Schema from "effect/Schema"; -import * as Semaphore from "effect/Semaphore"; -import * as Scope from "effect/Scope"; -import * as Stream from "effect/Stream"; - -import { resolveAttachmentPath } from "../../attachmentStore.ts"; -import * as ServerConfig from "../../config.ts"; -import { mcpToolPresentation } from "@t3tools/provider-core/server/mcpToolPresentation"; -import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; -import type { EventNdjsonLogger } from "../../provider/EventNdjsonLogger.ts"; -import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts"; -import { - structuralProtocolMethod, - summarizeNativeProtocolPayload, -} from "../../provider/NativeProtocolLogging.ts"; -import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; -import { t3OrchestrationSystemPrompt } from "@t3tools/provider-core/server/orchestrationInstructions"; -import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions"; -import * as OpenCodeRuntime from "../../provider/opencodeRuntime.ts"; -import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; -import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; -import { turnScopedSelectionTransition } from "@t3tools/provider-core/server/selectionTransition"; -import { providerMessageTextWithAttachmentPaths } from "@t3tools/provider-core/server/attachmentPrompt"; -import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; -import { - ProviderAdapterDriverCreateError, - type ProviderAdapterDriver, - type ProviderAdapterDriverCreateInput, -} from "@t3tools/provider-core/server/adapterDriver"; -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); -export const OPENCODE_SDK_PROTOCOL = "opencode-sdk.sse" as const; -const DEFAULT_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettingsSchema)({}); - -let openCodeMessageIdEpochMillis = -1; -let openCodeMessageIdCounter = 0; - -const makeOpenCodeMessageId = Effect.fnUntraced(function* () { - const epochMillis = DateTime.toEpochMillis(yield* DateTime.now); - if (epochMillis !== openCodeMessageIdEpochMillis) { - openCodeMessageIdEpochMillis = epochMillis; - openCodeMessageIdCounter = 0; - } - openCodeMessageIdCounter += 1; - const encodedTime = BigInt.asUintN( - 48, - BigInt(epochMillis) * 0x1000n + BigInt(openCodeMessageIdCounter), - ) - .toString(16) - .padStart(12, "0"); - const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; - const random = (yield* Effect.forEach(Array.from({ length: 28 }), () => - Random.nextIntBetween(0, alphabet.length - 1), - )) - .map((index) => alphabet[index]) - .join(""); - return `msg_${encodedTime}${random}`; -}); - -/** - * OpenCode's session, message, part, and interaction-request identifiers are - * durable. It does not expose a first-class turn object: the initiating user - * message is the best native turn correlation point, and session idle is the - * authoritative terminal signal. - */ -const OpenCodeProviderCapabilitiesV2 = { - sessions: { - // The current adapter owns one directory-bound client/server per session. - // Keep it isolated until its runtime is made safe for cross-thread pooling. - supportsMultipleProviderThreadsPerSession: false, - supportsModelSwitchInSession: true, - supportsProviderSwitchingViaHandoff: true, - supportsRuntimeModeSwitchInSession: false, - pendingRequestsSurviveRestart: false, - }, - threads: { - canCreateEmptyThread: true, - canReadThreadSnapshot: true, - canRollbackThread: true, - canForkThread: true, - canForkFromTurn: true, - canForkFromSubagentThread: true, - exposesNativeThreadId: true, - }, - turns: { - exposesNativeTurnId: false, - emitsTurnStarted: true, - emitsTurnCompleted: true, - supportsInterrupt: true, - supportsActiveSteering: true, - supportsSteeringByInterruptRestart: true, - supportsQueuedMessages: true, - terminalStatusQuality: "strong", - }, - streaming: { - streamsAssistantText: true, - streamsReasoning: true, - streamsToolOutput: true, - streamsPlanText: false, - emitsMessageCompleted: true, - }, - tools: { - exposesToolItemIds: true, - emitsToolStarted: true, - emitsToolCompleted: true, - emitsToolOutput: true, - supportsMcpTools: true, - supportsDynamicToolCallbacks: false, - }, - approvals: { - supportsCommandApproval: true, - supportsFileReadApproval: true, - supportsFileChangeApproval: true, - supportsApplyPatchApproval: true, - approvalsHaveNativeRequestIds: true, - approvalCallbacksAreLiveOnly: true, - approvalsCanOriginateFromSubagents: true, - }, - planning: { - emitsPlanUpdated: true, - emitsTodoList: true, - emitsProposedPlan: false, - supportsStructuredQuestions: true, - planDeltasHaveItemIds: false, - }, - subagents: { - supportsSubagents: true, - exposesSubagentThreadIds: true, - emitsSubagentLifecycle: true, - canWaitForSubagents: true, - canCloseSubagents: false, - canForkSubagentThread: true, - }, - context: { - acceptsSystemContext: false, - acceptsDeveloperContext: false, - acceptsSyntheticUserContext: true, - canGenerateSummaries: true, - canConsumeHandoffSummaries: true, - supportsDeltaHandoff: true, - supportsFullThreadHandoff: true, - maxRecommendedHandoffChars: null, - }, - checkpointing: { - appCanCheckpointFilesystem: true, - supportsNestedCheckpointScopes: true, - providerCanRollbackConversation: true, - providerRollbackReturnsSnapshot: true, - providerCanReadConversationSnapshot: true, - }, - identity: { - nativeThreadIds: "strong", - nativeTurnIds: "weak", - nativeItemIds: "strong", - nativeRequestIds: "strong", - }, - runtimePolicy: { - enforcement: "native", - }, -} satisfies OrchestrationV2ProviderCapabilities; - -type TerminalTurnStatus = Extract< - OrchestrationV2ProviderTurn["status"], - "completed" | "interrupted" | "failed" | "cancelled" ->; - -type OpenCodeStepUsage = Pick< - Extract, - "id" | "tokens" ->; - -interface OpenCodeTurnTokenUsageAccumulator { - readonly partIds: Set; - readonly promptMessageIds: Set; - readonly assistantOwnershipByMessageId: Map; - // Native removal does not undo usage. Keep unresolved counts until this turn settles. - readonly unresolvedStepsByMessageId: Map>; - inputTokens: number; - cachedInputTokens: number; - cacheCreationTokens: number; - outputTokens: number; - reasoningTokens: number; - hasSubagents: boolean; - complete: boolean; -} - -function makeOpenCodeTurnTokenUsageAccumulator(): OpenCodeTurnTokenUsageAccumulator { - return { - partIds: new Set(), - promptMessageIds: new Set(), - assistantOwnershipByMessageId: new Map(), - unresolvedStepsByMessageId: new Map(), - inputTokens: 0, - cachedInputTokens: 0, - cacheCreationTokens: 0, - outputTokens: 0, - reasoningTokens: 0, - hasSubagents: false, - complete: true, - }; -} - -function accumulateOpenCodeStepUsage( - accumulator: OpenCodeTurnTokenUsageAccumulator, - part: OpenCodeStepUsage, -): void { - if (accumulator.partIds.has(part.id)) return; - accumulator.partIds.add(part.id); - accumulator.inputTokens += part.tokens.input + part.tokens.cache.read + part.tokens.cache.write; - accumulator.cachedInputTokens += part.tokens.cache.read; - accumulator.cacheCreationTokens += part.tokens.cache.write; - accumulator.outputTokens += part.tokens.output + part.tokens.reasoning; - accumulator.reasoningTokens += part.tokens.reasoning; -} - -interface ActiveOpenCodeTurn { - readonly usage: OpenCodeTurnTokenUsageAccumulator; - readonly isRoot: boolean; - readonly threadId: ThreadId; - readonly runId: ProviderAdapter.ProviderAdapterV2TurnInput["runId"] | null; - readonly rootNodeId: ProviderAdapter.ProviderAdapterV2TurnInput["rootNodeId"]; - readonly appThread: OrchestrationV2AppThread; - readonly modelSelection: ModelSelection; - readonly runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy; - readonly providerTurnId: OrchestrationV2ProviderTurn["id"]; - /** The provider thread the turn started on, which its terminal names. */ - readonly providerThreadId: OrchestrationV2ProviderTurn["providerThreadId"]; - readonly providerTurnOrdinal: number; - readonly runOrdinal: number; - readonly runAttemptId: OrchestrationV2ProviderTurn["runAttemptId"]; - readonly startedAt: DateTime.Utc; - readonly itemOrdinals: Map; - readonly parts: Map>; - readonly partIdsByMessage: Map>; - readonly toolNamesByCallId: Map; - mcpServerNames?: ReadonlyArray; - readonly providerTurn: OrchestrationV2ProviderTurn; - nextItemOrdinal: number; - nativeUserMessageId: string | null; - admissionMessageId: string | null; - interrupted: boolean; - finalized: boolean; - planId: PlanId | null; - admissionGeneration: number; - admissionReconciliationGeneration: number | null; - admissionPending: boolean; - admissionAccepted: boolean; - admissionMessageObserved: boolean; - idleDuringAdmission: boolean; - admissionSettled: Deferred.Deferred; - admissionAbortController: AbortController | null; -} - -type OpenCodeAdmissionSignal = - | "accepted" - | "assistant-completed" - | "busy" - | "idle" - | "user-message"; -type OpenCodeAdmissionAction = "hold" | "reconcile-idle" | "release"; - -export function advanceOpenCodePromptAdmission( - admission: Pick< - ActiveOpenCodeTurn, - "admissionAccepted" | "admissionMessageObserved" | "admissionPending" | "idleDuringAdmission" - >, - signal: OpenCodeAdmissionSignal, -): OpenCodeAdmissionAction { - if (!admission.admissionPending) return "release"; - if (signal === "assistant-completed") { - admission.admissionAccepted = true; - admission.admissionMessageObserved = true; - admission.admissionPending = false; - return "release"; - } - if (signal === "idle") { - admission.idleDuringAdmission = true; - return "hold"; - } - if (signal === "accepted") admission.admissionAccepted = true; - if (signal === "busy" || signal === "user-message") admission.admissionMessageObserved = true; - if (!admission.admissionAccepted || !admission.admissionMessageObserved) return "hold"; - if (admission.idleDuringAdmission) return "reconcile-idle"; - admission.admissionPending = false; - return "release"; -} - -export function cancelOpenCodePromptAdmission( - admission: Pick, - nextGeneration: number, -): void { - admission.admissionGeneration = nextGeneration; - admission.admissionPending = false; -} - -export const reconcileOpenCodePromptAdmissionStatus = Effect.fn( - "reconcileOpenCodePromptAdmissionStatus", -)(function* ( - admission: Pick, - generation: number, - readStatus: Effect.Effect<"busy" | "idle" | "unknown">, -) { - if (admission.admissionGeneration !== generation || !admission.admissionPending) { - return "stale" as const; - } - const status = yield* readStatus; - if (admission.admissionGeneration !== generation || !admission.admissionPending) { - return "stale" as const; - } - if (status !== "unknown") admission.admissionPending = false; - return status; -}); - -interface OpenCodeSubagentContext { - readonly nativeItemId: string; - readonly nodeId: OrchestrationV2Subagent["id"]; - readonly parentState: OpenCodeThreadState; - readonly parentTurn: ActiveOpenCodeTurn; - readonly prompt: string; - readonly title: string | null; - readonly startedAt: DateTime.Utc; - childSessionId: string | null; - childThreadId: ThreadId | null; - childProviderThreadId: OrchestrationV2ProviderThread["id"] | null; - model: string | null; - result: string | null; -} - -interface OpenCodeThreadState { - readonly nativeSessionId: string; - providerThread: OrchestrationV2ProviderThread; - appThread: OrchestrationV2AppThread | null; - activeTurn: ActiveOpenCodeTurn | null; - readonly providerTurns: Map; - readonly messages: Map; - readonly runtimeRequests: Map; - readonly messageRoles: Map; - readonly userMessageIds: Array; - parentSubagent: OpenCodeSubagentContext | null; - nextChildTurnOrdinal: number; - nextAdmissionGeneration: number; -} - -interface OpenCodeRequestOwner { - readonly state: OpenCodeThreadState; - readonly turn: ActiveOpenCodeTurn; - /** The subagent on the owner's thread that leads to the asking session. */ - readonly subagent: OpenCodeSubagentContext | null; -} - -/** - * The top-level thread and active turn that own a session's requests, walking - * up through (possibly nested) subagents. - */ -function topLevelRequestOwner(state: OpenCodeThreadState): OpenCodeRequestOwner | undefined { - let owner = state; - let subagent: OpenCodeSubagentContext | null = null; - while (owner.parentSubagent !== null) { - subagent = owner.parentSubagent; - owner = subagent.parentState; - } - return owner.activeTurn === null ? undefined : { state: owner, turn: owner.activeTurn, subagent }; -} - -interface PendingOpenCodeRequest { - readonly requestId: RuntimeRequestId; - readonly nativeRequestId: string; - /** The session that asked, which may be a subagent's. */ - readonly nativeSessionId: string; - readonly turn: ActiveOpenCodeTurn; - readonly state: OpenCodeThreadState; - readonly nodeId: OrchestrationV2ExecutionNode["id"]; - readonly parentNodeId: OrchestrationV2ExecutionNode["id"]; - readonly turnItemId: OrchestrationV2TurnItem["id"]; - readonly requestKind: OpenCodePermissionRequestKind | "user_input"; - readonly createdAt: DateTime.Utc; - readonly permission?: PermissionRequest; - readonly question?: QuestionRequest; -} - -export interface OpenCodeAdapterV2Options { - readonly instanceId: ProviderInstanceId; - readonly settings: OpenCodeSettings; - readonly environment: NodeJS.ProcessEnv; - readonly runtime: OpenCodeRuntime.OpenCodeRuntimeShape; - readonly idAllocator: IdAllocator.IdAllocatorV2Shape; - readonly serverConfig: ServerConfig.ServerConfig["Service"]; - readonly nativeEventLogger?: EventNdjsonLogger; -} - -export interface OpenCodeProtocolLogEvent { - readonly direction: "incoming" | "outgoing"; - readonly messageKind: "request" | "response" | "notification" | "error"; - readonly method: string; - readonly payload: unknown; -} - -function formatOpenCodeProtocolLogPayload(event: OpenCodeProtocolLogEvent) { - return { - direction: event.direction, - messageKind: event.messageKind, - method: structuralProtocolMethod(event.method), - payload: summarizeNativeProtocolPayload(event.payload), - }; -} - -export function makeOpenCodeProtocolLogger(input: { - readonly nativeEventLogger: EventNdjsonLogger | undefined; - readonly idAllocator: IdAllocator.IdAllocatorV2Shape; - readonly providerInstanceId: ProviderInstanceId; - readonly providerSessionId: ProviderSessionId; - readonly threadId: ThreadId; -}): (event: OpenCodeProtocolLogEvent) => Effect.Effect { - return (event) => - Effect.gen(function* () { - if (!input.nativeEventLogger) return; - const observedAt = DateTime.formatIso(yield* DateTime.now); - const method = structuralProtocolMethod(event.method); - yield* input.nativeEventLogger.write( - { - observedAt, - event: { - id: yield* input.idAllocator.allocate.rawEvent({ - providerSessionId: input.providerSessionId, - method, - }), - kind: "protocol", - protocol: OPENCODE_SDK_PROTOCOL, - provider: OPENCODE_PROVIDER, - providerInstanceId: input.providerInstanceId, - providerSessionId: input.providerSessionId, - createdAt: observedAt, - threadId: input.threadId, - payload: formatOpenCodeProtocolLogPayload(event), - }, - }, - input.threadId, - ); - }).pipe( - Effect.catchCause((cause) => - Cause.hasInterrupts(cause) - ? Effect.interrupt - : Effect.logWarning("Failed to write native OpenCode event log.", { - errorTag: causeErrorTag(cause), - reasonCount: cause.reasons.length, - provider: OPENCODE_PROVIDER, - threadId: input.threadId, - }), - ), - ); -} - -function protocolError( - detail: string, - payload?: unknown, -): ProviderAdapter.ProviderAdapterProtocolError { - return new ProviderAdapter.ProviderAdapterProtocolError({ - driver: OPENCODE_PROVIDER, - detail, - ...(payload === undefined ? {} : { payload }), - }); -} - -function nativeThreadId(providerThread: OrchestrationV2ProviderThread): string { - const nativeId = providerThread.nativeThreadRef?.nativeId; - if (nativeId === null || nativeId === undefined) { - throw protocolError(`Provider thread ${providerThread.id} has no OpenCode session id`); - } - return nativeId; -} - -function dateTimeFromEpoch(value: number | undefined, fallback: DateTime.Utc): DateTime.Utc { - if (value === undefined) return fallback; - return Option.getOrElse(DateTime.make(value), () => fallback); -} - -function nonEmptyString(value: unknown): string | undefined { - return typeof value === "string" && value.trim().length > 0 ? value : undefined; -} - -function recordValue(input: unknown, key: string): unknown { - return typeof input === "object" && input !== null && key in input - ? (input as Record)[key] - : undefined; -} - -function recordString(input: unknown, ...keys: ReadonlyArray): string | undefined { - for (const key of keys) { - const value = nonEmptyString(recordValue(input, key)); - if (value !== undefined) return value; - } - return undefined; -} - -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 }; - if ("stream" in value) return { subscribed: true }; - return value; -} - -type OpenCodePermissionRequestKind = Extract< - ProviderRequestKind, - "command" | "file-read" | "file-change" ->; - -export function openCodePermissionRequestKind( - permission: string, - toolName?: string, -): OpenCodePermissionRequestKind { - const normalized = permission.toLowerCase(); - const normalizedTool = toolName?.toLowerCase() ?? ""; - if ( - normalized === "edit" || - normalized === "write" || - normalized === "patch" || - normalized === "apply_patch" || - normalizedTool === "edit" || - normalizedTool === "write" || - normalizedTool === "patch" || - normalizedTool === "apply_patch" - ) { - return "file-change"; - } - if ( - normalized === "read" || - normalized === "glob" || - normalized === "grep" || - normalized === "lsp" || - normalized === "external_directory" || - normalizedTool === "read" || - normalizedTool.includes("glob") || - normalizedTool.includes("grep") || - normalizedTool.includes("search") - ) { - return "file-read"; - } - return "command"; -} - -const OPENCODE_ALWAYS_ALLOWED_PERMISSIONS = [ - "question", - "read", - "glob", - "grep", - "lsp", - "todowrite", - "task", - "skill", -] as const; - -const OPENCODE_RESTRICTED_PERMISSIONS = [ - "bash", - "edit", - "webfetch", - "websearch", - "codesearch", - "external_directory", - "doom_loop", -] as const; - -/** - * OpenCode does not provide an OS sandbox, so permission rules are also the - * enforcement boundary for non-interactive policies. Read/planning tools are - * safe by default; edits are auto-approved only for workspace-write modes, - * while shell/network/external access remains gated unless policy explicitly - * allows it. - */ -export function openCodePermissionRules( - runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy, -): PermissionRuleset { - const sandboxPolicy = recordValue(runtimePolicy, "sandboxPolicy"); - const sandboxType = recordString(sandboxPolicy, "type"); - const rawApprovalPolicy = runtimePolicy.approvalPolicy; - const approvalPolicy = nonEmptyString(rawApprovalPolicy); - const requiresApproval = - approvalPolicy === undefined - ? (typeof rawApprovalPolicy === "object" && rawApprovalPolicy !== null) || - runtimePolicy.runtimeMode !== "full-access" - : approvalPolicy !== "never"; - const externallySandboxed = sandboxType === "externalSandbox"; - const dangerFullAccess = sandboxType === "dangerFullAccess"; - const implicitFullAccess = - sandboxType === undefined && runtimePolicy.runtimeMode === "full-access"; - - if (!requiresApproval && (externallySandboxed || dangerFullAccess || implicitFullAccess)) { - return [{ permission: "*", pattern: "*", action: "allow" }]; - } - - // Task sessions initially inherit only parent deny rules. Seed explicit - // denies before the effective ask/allow overrides so a child is safe during - // the short interval before emitSubagent installs its complete policy. - const rules: PermissionRuleset = [ - { permission: "*", pattern: "*", action: "deny" }, - ...OPENCODE_RESTRICTED_PERMISSIONS.map((permission) => ({ - permission, - pattern: "*", - action: "deny" as const, - })), - ]; - - if (requiresApproval) { - rules.push({ permission: "*", pattern: "*", action: "ask" }); - for (const permission of OPENCODE_RESTRICTED_PERMISSIONS) { - rules.push({ permission, pattern: "*", action: "ask" }); - } - } - - rules.push( - ...OPENCODE_ALWAYS_ALLOWED_PERMISSIONS.map((permission) => ({ - permission, - pattern: "*", - action: "allow" as const, - })), - ); - - rules.push( - { permission: "read", pattern: "*.env", action: requiresApproval ? "ask" : "deny" }, - { permission: "read", pattern: "*.env.*", action: requiresApproval ? "ask" : "deny" }, - { permission: "read", pattern: "*.env.example", action: "allow" }, - ); - - if ( - runtimePolicy.runtimeMode === "auto-accept-edits" || - (!requiresApproval && sandboxType === "workspaceWrite") - ) { - rules.push({ permission: "edit", pattern: "*", action: "allow" }); - } - - if (!requiresApproval && recordValue(sandboxPolicy, "networkAccess") === true) { - for (const permission of ["webfetch", "websearch", "codesearch"] as const) { - rules.push({ permission, pattern: "*", action: "allow" }); - } - } - - if (!requiresApproval && sandboxType === "readOnly") { - const access = recordValue(sandboxPolicy, "access"); - if (recordString(access, "type") === "fullAccess") { - rules.push({ permission: "external_directory", pattern: "*", action: "allow" }); - } - } - - if (!requiresApproval && sandboxType === "workspaceWrite") { - const writableRoots = recordValue(sandboxPolicy, "writableRoots"); - if (Array.isArray(writableRoots)) { - for (const root of writableRoots) { - if (typeof root === "string" && root.trim().length > 0) { - rules.push({ - permission: "external_directory", - pattern: `${root.replace(/\/$/, "")}/*`, - action: "allow", - }); - } - } - } - } - - return rules; -} - -function permissionRuleEquals( - left: PermissionRuleset[number], - right: PermissionRuleset[number], -): boolean { - return ( - left.permission === right.permission && - left.pattern === right.pattern && - left.action === right.action - ); -} - -/** - * OpenCode task sessions inherit only the parent's deny/external-directory - * rules and then add agent-specific restrictions such as disabling nested - * tasks. Install the complete parent policy while retaining only rules that - * were added specifically for the selected child agent. - */ -export function openCodeChildPermissionRules( - runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy, - nativeChildRules: PermissionRuleset, -): PermissionRuleset { - const parentRules = openCodePermissionRules(runtimePolicy); - const inheritedRules = parentRules.filter( - (rule) => rule.permission === "external_directory" || rule.action === "deny", - ); - const childSpecificRules = nativeChildRules.filter( - (childRule) => - !inheritedRules.some((inheritedRule) => permissionRuleEquals(childRule, inheritedRule)), - ); - return [...parentRules, ...childSpecificRules]; -} - -/** - * OpenCode's fork/revert boundary is exclusive. To retain the selected app - * turn, address the next native user message; omitting a boundary retains the - * current head when the selected turn is already last. - */ -export function openCodeBoundaryAfterProviderTurn( - providerTurns: ReadonlyArray, - selectedProviderTurnId: OrchestrationV2ProviderTurn["id"], -): string | undefined { - const selected = providerTurns.find((turn) => turn.id === selectedProviderTurnId); - if (selected === undefined) return undefined; - return providerTurns - .filter((turn) => turn.ordinal > selected.ordinal) - .toSorted((left, right) => left.ordinal - right.ordinal) - .map((turn) => turn.nativeTurnRef?.nativeId) - .find((nativeId): nativeId is string => nativeId !== null && nativeId !== undefined); -} - -function toolStatus(part: ToolPart): { - readonly node: OrchestrationV2ExecutionNode["status"]; - readonly item: OrchestrationV2TurnItem["status"]; -} { - switch (part.state.status) { - case "pending": - return { node: "pending", item: "pending" }; - case "running": - return { node: "running", item: "running" }; - case "completed": - return { node: "completed", item: "completed" }; - case "error": - return { node: "failed", item: "failed" }; - } -} - -function toolInput(part: ToolPart): Record { - return part.state.input; -} - -function toolOutput(part: ToolPart): string | undefined { - if (part.state.status === "completed") return part.state.output; - if (part.state.status === "error") return part.state.error; - return undefined; -} - -function toolStartedAt(part: ToolPart, now: DateTime.Utc): DateTime.Utc { - return dateTimeFromEpoch( - part.state.status === "pending" ? undefined : part.state.time.start, - now, - ); -} - -function toolCompletedAt(part: ToolPart, now: DateTime.Utc): DateTime.Utc | null { - return part.state.status === "completed" || part.state.status === "error" - ? dateTimeFromEpoch(part.state.time.end, now) - : null; -} - -function toolTitle(part: ToolPart): string | null { - return part.state.status === "running" || part.state.status === "completed" - ? (part.state.title ?? null) - : null; -} - -function toolModel(part: ToolPart): string | null { - const metadata = - part.state.status === "running" || - part.state.status === "completed" || - part.state.status === "error" - ? part.state.metadata - : undefined; - const model = recordValue(metadata, "model"); - const providerId = recordString(model, "providerID", "providerId"); - const modelId = recordString(model, "modelID", "modelId", "id"); - return providerId !== undefined && modelId !== undefined ? `${providerId}/${modelId}` : null; -} - -function taskSessionId(part: ToolPart): string | null { - const metadata = - part.state.status === "running" || - part.state.status === "completed" || - part.state.status === "error" - ? part.state.metadata - : undefined; - return recordString(metadata, "sessionId", "sessionID") ?? null; -} - -function makeProviderThread(input: { - readonly idAllocator: IdAllocator.IdAllocatorV2Shape; - readonly providerInstanceId: ProviderInstanceId; - readonly providerSessionId: OrchestrationV2ProviderThread["providerSessionId"]; - readonly appThreadId: OrchestrationV2ProviderThread["appThreadId"]; - readonly ownerNodeId?: OrchestrationV2ProviderThread["ownerNodeId"]; - readonly nativeSession: OpenCodeSession; - readonly forkedFrom?: OrchestrationV2ProviderThread["forkedFrom"]; - readonly now: DateTime.Utc; -}): OrchestrationV2ProviderThread { - const createdAt = dateTimeFromEpoch(input.nativeSession.time.created, input.now); - return { - id: input.idAllocator.derive.providerThread({ - driver: OPENCODE_PROVIDER, - nativeThreadId: input.nativeSession.id, - }), - driver: OPENCODE_PROVIDER, - providerInstanceId: input.providerInstanceId, - providerSessionId: input.providerSessionId, - appThreadId: input.appThreadId, - ownerNodeId: input.ownerNodeId ?? null, - nativeThreadRef: { - driver: OPENCODE_PROVIDER, - nativeId: input.nativeSession.id, - strength: "strong", - }, - nativeConversationHeadRef: null, - status: "idle", - firstRunOrdinal: null, - lastRunOrdinal: null, - handoffIds: [], - forkedFrom: input.forkedFrom ?? null, - createdAt, - updatedAt: dateTimeFromEpoch(input.nativeSession.time.updated, input.now), - }; -} - -function providerRef(nativeId: string, strength: "strong" | "weak" = "strong") { - return { - driver: OPENCODE_PROVIDER, - nativeId, - strength, - } satisfies OrchestrationV2ProviderRef; -} - -function openCodeErrorMessage(event: Extract): string { - const error = event.properties.error; - if (error === undefined) return "OpenCode session failed without an error payload."; - return recordString(error.data, "message") ?? error.name; -} - -function terminalStatusForError( - event: Extract, - turn: ActiveOpenCodeTurn, -): TerminalTurnStatus { - return turn.interrupted || isMessageAbortedError(event) ? "interrupted" : "failed"; -} - -function isMessageAbortedError(event: Extract): boolean { - return event.properties.error?.name === "MessageAbortedError"; -} - -function isOpenCodeNotFound(cause: unknown): boolean { - const seen = new Set(); - const queue: Array = [cause]; - for (let steps = 0; queue.length > 0 && steps < 32; steps += 1) { - const node = queue.shift(); - if (node === null || typeof node !== "object" || seen.has(node)) { - continue; - } - seen.add(node); - const record = node as Record; - - const response = record.response; - const statuses = [ - record.status, - record.statusCode, - response !== null && typeof response === "object" - ? (response as { readonly status?: unknown }).status - : undefined, - ].filter((status): status is number => typeof status === "number"); - if (statuses.includes(404)) { - return true; - } - if (statuses.length > 0) { - continue; - } - - const name = record.name; - if (typeof name === "string" && name.toLowerCase() === "notfounderror") { - return true; - } - - for (const key of ["cause", "body", "error", "data"] as const) { - if (record[key] !== undefined) { - queue.push(record[key]); - } - } - } - return false; -} - -function unwrapData(operation: string, result: { readonly data?: A }): NonNullable { - if (result.data === undefined) { - throw new OpenCodeRuntime.OpenCodeRuntimeError({ - operation, - detail: `OpenCode ${operation} returned no response payload.`, - }); - } - return result.data as NonNullable; -} - -export function makeOpenCodeAdapterV2( - options: OpenCodeAdapterV2Options, -): ProviderAdapter.ProviderAdapterV2Shape { - const { idAllocator, runtime, serverConfig } = options; - - 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: ProviderAdapter.ProviderAdapterV2OpenSessionInput) { - const scope = yield* Effect.scope; - const cwd = input.runtimePolicy.cwd ?? serverConfig.cwd; - const connection = yield* runtime.connectToOpenCodeServer({ - binaryPath: options.settings.binaryPath, - directory: cwd, - serverUrl: options.settings.serverUrl, - environment: options.environment, - }); - const client = runtime.createOpenCodeSdkClient({ - baseUrl: connection.url, - directory: cwd, - ...(connection.external && options.settings.serverPassword - ? { serverPassword: options.settings.serverPassword } - : {}), - }); - - const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); - const hasT3Mcp = mcpSession !== undefined && !connection.external; - const orchestrationSystemPrompt = t3OrchestrationSystemPrompt(hasT3Mcp); - if (hasT3Mcp) { - yield* OpenCodeRuntime.runOpenCodeSdk("mcp.add", () => - client.mcp.add({ - name: "t3-code", - config: { - type: "remote", - url: mcpSession.endpoint, - headers: { Authorization: mcpSession.authorizationHeader }, - oauth: false, - }, - }), - ); - } - - const now = yield* DateTime.now; - let sessionEntity: OrchestrationV2ProviderSession = { - id: input.providerSessionId, - driver: OPENCODE_PROVIDER, - providerInstanceId: options.instanceId, - status: "ready", - cwd, - model: input.modelSelection.model, - capabilities: OpenCodeProviderCapabilitiesV2, - createdAt: now, - updatedAt: now, - lastError: null, - }; - const events = yield* Queue.unbounded(); - let nativeStreamFailure: OrchestrationV2ProviderFailure | null = null; - const threads = new Map(); - const commandReceipts = new Map>(); - const commandControllers = new Map>(); - const pendingRequests = new Map(); - const pendingRequestsByNativeId = new Map(); - const subagentsByNativeItemId = new Map(); - const subagentsByChildSessionId = new Map(); - // Permission and question requests can originate from child sessions - // (task subagents and their descendants). Related sessions map back - // to the root thread state whose active turn owns the request; asks - // arriving before the relation is known resolve it via session.get. - const relatedSessionOwners = new Map(); - // Sessions of this runtime that OpenCode reports busy. A background - // task child keeps running after its parent turn settles, so idle - // release must not close the server under it. - const busySessionIds = new Set(); - // Requests settled before their session relation resolved: a late - // routing attempt must never resurrect them. Bounded because entries - // only matter for the seconds a routing retry can still be running. - const settledNativeRequestIds = new Set(); - const pendingChildRequestRoutes = new Set(); - const rememberSettledRequest = (nativeRequestId: string) => { - if (settledNativeRequestIds.size >= 2_048) settledNativeRequestIds.clear(); - settledNativeRequestIds.add(nativeRequestId); - }; - const abortController = new AbortController(); - let closing = false; - let hasConnected = false; - - const emitProviderEvent = (event: ProviderAdapter.ProviderAdapterV2Event) => - Queue.offer(events, event).pipe(Effect.asVoid); - - const logProtocolEvent = makeOpenCodeProtocolLogger({ - nativeEventLogger: options.nativeEventLogger, - idAllocator, - providerInstanceId: options.instanceId, - providerSessionId: input.providerSessionId, - threadId: input.threadId, - }); - - const sdkCall = ( - method: string, - payload: unknown, - call: (signal: AbortSignal) => Promise, - ): Effect.Effect => - logProtocolEvent({ - direction: "outgoing", - messageKind: "request", - method, - payload, - }).pipe( - Effect.andThen(OpenCodeRuntime.runOpenCodeSdk(method, call)), - Effect.tap((response) => - logProtocolEvent({ - direction: "incoming", - messageKind: "response", - method, - payload: sdkResponseForRawLog(response), - }), - ), - ); - - const abortDescendants = (rootId: string) => - Effect.gen(function* () { - const visited = new Set([rootId]); - const semaphore = Semaphore.makeUnsafe(8); - const visit = ( - sessionId: string, - abort: boolean, - ): Effect.Effect => - Effect.gen(function* () { - const abortResult = abort - ? yield* sdkCall("session.abort", { sessionID: sessionId }, (signal) => - client.session.abort({ sessionID: sessionId }, { signal }), - ).pipe( - semaphore.withPermit, - Effect.catchIf(isOpenCodeNotFound, () => Effect.void), - Effect.result, - ) - : undefined; - const childrenResult = yield* sdkCall( - "session.children", - { sessionID: sessionId }, - (signal) => client.session.children({ sessionID: sessionId }, { signal }), - ).pipe( - semaphore.withPermit, - Effect.catchIf(isOpenCodeNotFound, () => Effect.void), - Effect.result, - ); - const firstFailure = - abortResult?._tag === "Failure" ? abortResult.failure : undefined; - if (childrenResult._tag === "Failure") - return firstFailure ?? childrenResult.failure; - const fresh = (childrenResult.success?.data ?? []).filter((child) => { - if (visited.has(child.id)) return false; - visited.add(child.id); - return true; - }); - const failures = yield* Effect.forEach(fresh, (child) => visit(child.id, true), { - concurrency: 8, - }); - return firstFailure ?? failures.find((failure) => failure !== undefined); - }); - const failure = yield* visit(rootId, false); - if (failure) return yield* Effect.fail(failure); - }).pipe(Effect.timeout("15 seconds")); - - const updateProviderSession = ( - status: OrchestrationV2ProviderSession["status"], - lastError: string | null = sessionEntity.lastError, - ) => - Effect.gen(function* () { - const updatedAt = yield* DateTime.now; - sessionEntity = { ...sessionEntity, status, lastError, updatedAt }; - yield* emitProviderEvent({ - type: "provider_session.updated", - driver: OPENCODE_PROVIDER, - providerSession: sessionEntity, - }); - }); - - const updateProviderThread = ( - state: OpenCodeThreadState, - patch: Partial, - ) => - Effect.gen(function* () { - const updatedAt = yield* DateTime.now; - state.providerThread = { ...state.providerThread, ...patch, updatedAt }; - yield* emitProviderEvent({ - type: "provider_thread.updated", - driver: OPENCODE_PROVIDER, - providerThread: state.providerThread, - }); - }); - - const itemOrdinal = (turn: ActiveOpenCodeTurn, nativeItemId: string): number => { - const existing = turn.itemOrdinals.get(nativeItemId); - if (existing !== undefined) return existing; - const ordinal = turn.nextItemOrdinal++; - turn.itemOrdinals.set(nativeItemId, ordinal); - return ordinal; - }; - - const emitProviderTurn = ( - state: OpenCodeThreadState, - turn: ActiveOpenCodeTurn, - status: OrchestrationV2ProviderTurn["status"], - completedAt: DateTime.Utc | null, - ) => { - const providerTurn: OrchestrationV2ProviderTurn = { - ...turn.providerTurn, - nativeTurnRef: - turn.nativeUserMessageId === null - ? turn.providerTurn.nativeTurnRef - : providerRef(turn.nativeUserMessageId, "weak"), - status, - completedAt, - ...(completedAt === null - ? {} - : { - turnTokenUsage: - turn.usage.partIds.size === 0 - ? { - usageScope: "main_agent" as const, - usageStatus: "unavailable" as const, - hasSubagents: turn.usage.hasSubagents, - } - : { - usageScope: "main_agent" as const, - usageStatus: - status === "completed" && - turn.usage.complete && - turn.usage.unresolvedStepsByMessageId.size === 0 - ? ("complete" as const) - : ("partial" as const), - inputTokens: turn.usage.inputTokens, - cachedInputTokens: turn.usage.cachedInputTokens, - cacheCreationTokens: turn.usage.cacheCreationTokens, - outputTokens: turn.usage.outputTokens, - reasoningTokens: turn.usage.reasoningTokens, - hasSubagents: turn.usage.hasSubagents, - }, - }), - }; - Object.assign(turn.providerTurn, providerTurn); - state.providerTurns.set(String(providerTurn.id), providerTurn); - return emitProviderEvent({ - type: "provider_turn.updated", - driver: OPENCODE_PROVIDER, - threadId: turn.threadId, - providerTurn, - }); - }; - - const emitTextPart = Effect.fnUntraced(function* ( - state: OpenCodeThreadState, - turn: ActiveOpenCodeTurn, - part: Extract, - forceCompleted = false, - ) { - if (part.type === "text" && (part.ignored === true || part.synthetic === true)) return; - if (part.text.length === 0) return; - const emittedAt = yield* DateTime.now; - const isCompleted = forceCompleted || part.time?.end !== undefined; - const startedAt = dateTimeFromEpoch(part.time?.start, emittedAt); - const completedAt = isCompleted ? dateTimeFromEpoch(part.time?.end, emittedAt) : null; - const nativeItemRef = providerRef(part.id); - const nodeId = idAllocator.derive.nodeFromProviderItem({ - driver: OPENCODE_PROVIDER, - nativeItemId: part.id, - }); - const turnItemId = idAllocator.derive.turnItemFromProviderItem({ - driver: OPENCODE_PROVIDER, - nativeItemId: part.id, - }); - const ordinal = itemOrdinal(turn, part.id); - yield* emitProviderEvent({ - type: "node.updated", - driver: OPENCODE_PROVIDER, - node: { - id: nodeId, - threadId: turn.threadId, - runId: turn.runId, - parentNodeId: turn.rootNodeId, - rootNodeId: turn.rootNodeId, - kind: part.type === "text" ? "assistant_message" : "reasoning", - status: isCompleted ? "completed" : "running", - countsForRun: false, - providerThreadId: state.providerThread.id, - providerTurnId: turn.providerTurnId, - nativeItemRef, - runtimeRequestId: null, - checkpointScopeId: null, - startedAt, - completedAt, - }, - }); - if (part.type === "text") { - const messageId = idAllocator.derive.messageFromProviderItem({ - driver: OPENCODE_PROVIDER, - nativeItemId: part.id, - }); - const message: OrchestrationV2ConversationMessage = { - createdBy: "agent", - creationSource: "provider", - id: messageId, - threadId: turn.threadId, - runId: turn.runId, - nodeId, - role: "assistant", - text: part.text, - attachments: [], - streaming: !isCompleted, - createdAt: startedAt, - updatedAt: emittedAt, - }; - state.messages.set(String(message.id), message); - yield* emitProviderEvent({ - type: "message.updated", - driver: OPENCODE_PROVIDER, - message, - }); - yield* emitProviderEvent({ - type: "turn_item.updated", - driver: OPENCODE_PROVIDER, - turnItem: { - id: turnItemId, - threadId: turn.threadId, - runId: turn.runId, - nodeId, - providerThreadId: state.providerThread.id, - providerTurnId: turn.providerTurnId, - nativeItemRef, - parentItemId: null, - ordinal, - status: isCompleted ? "completed" : "running", - title: null, - startedAt, - completedAt, - updatedAt: emittedAt, - type: "assistant_message", - messageId, - text: part.text, - streaming: !isCompleted, - }, - }); - return; - } - yield* emitProviderEvent({ - type: "turn_item.updated", - driver: OPENCODE_PROVIDER, - turnItem: { - id: turnItemId, - threadId: turn.threadId, - runId: turn.runId, - nodeId, - providerThreadId: state.providerThread.id, - providerTurnId: turn.providerTurnId, - nativeItemRef, - parentItemId: null, - ordinal, - status: isCompleted ? "completed" : "running", - title: null, - startedAt, - completedAt, - updatedAt: emittedAt, - type: "reasoning", - text: part.text, - streaming: !isCompleted, - }, - }); - }); - - const emitSubagent = Effect.fnUntraced(function* ( - state: OpenCodeThreadState, - turn: ActiveOpenCodeTurn, - part: ToolPart, - ) { - const now = yield* DateTime.now; - const nativeItemRef = providerRef(part.id); - const nodeId = idAllocator.derive.nodeFromProviderItem({ - driver: OPENCODE_PROVIDER, - nativeItemId: part.id, - }); - const turnItemId = idAllocator.derive.turnItemFromProviderItem({ - driver: OPENCODE_PROVIDER, - nativeItemId: part.id, - }); - turn.usage.hasSubagents = true; - const input = toolInput(part); - const prompt = recordString(input, "prompt") ?? ""; - const title = toolTitle(part) ?? recordString(input, "description") ?? null; - let context = subagentsByNativeItemId.get(part.id); - if (context === undefined) { - context = { - nativeItemId: part.id, - nodeId, - parentState: state, - parentTurn: turn, - prompt, - title, - startedAt: toolStartedAt(part, now), - childSessionId: null, - childThreadId: null, - childProviderThreadId: null, - model: null, - result: null, - }; - subagentsByNativeItemId.set(part.id, context); - } - context.model = toolModel(part) ?? context.model; - const childSessionId = taskSessionId(part); - if (childSessionId !== null && context.childSessionId === null) { - context.childSessionId = childSessionId; - context.childThreadId = idAllocator.derive.threadFromProviderThread({ - driver: OPENCODE_PROVIDER, - nativeThreadId: childSessionId, - }); - context.childProviderThreadId = idAllocator.derive.providerThread({ - driver: OPENCODE_PROVIDER, - nativeThreadId: childSessionId, - }); - subagentsByChildSessionId.set(childSessionId, context); - relatedSessionOwners.set(childSessionId, state); - const childModelSelection: ModelSelection = { - instanceId: options.instanceId, - model: context.model ?? turn.modelSelection.model, - }; - const childThread = makeSubagentChildThread({ - parentThread: turn.appThread, - childThreadId: context.childThreadId, - parentNodeId: nodeId, - activeProviderThreadId: context.childProviderThreadId, - providerInstanceId: options.instanceId, - modelSelection: childModelSelection, - title: subagentThreadTitle({ - parentTitle: turn.appThread.title, - title, - prompt, - ordinal: itemOrdinal(turn, part.id), - }), - now, - createdBy: "agent", - creationSource: "provider", - }); - const childProviderThread: OrchestrationV2ProviderThread = { - id: context.childProviderThreadId, - driver: OPENCODE_PROVIDER, - providerInstanceId: options.instanceId, - providerSessionId: inputProviderSessionId, - appThreadId: context.childThreadId, - ownerNodeId: nodeId, - nativeThreadRef: providerRef(childSessionId), - nativeConversationHeadRef: null, - status: "active", - firstRunOrdinal: null, - lastRunOrdinal: null, - handoffIds: [], - forkedFrom: null, - createdAt: now, - updatedAt: now, - }; - const childSessionResponse = yield* sdkCall( - "session.get", - { sessionID: childSessionId }, - () => client.session.get({ sessionID: childSessionId }), - ); - const nativeChildSession = unwrapData("session.get", childSessionResponse); - const childPermission = openCodeChildPermissionRules( - turn.runtimePolicy, - nativeChildSession.permission ?? [], - ); - yield* sdkCall( - "session.update", - { sessionID: childSessionId, permission: childPermission }, - () => - client.session.update({ - sessionID: childSessionId, - permission: childPermission, - }), - ); - threads.set(childSessionId, { - nativeSessionId: childSessionId, - providerThread: childProviderThread, - appThread: childThread, - activeTurn: null, - providerTurns: new Map(), - messages: new Map(), - runtimeRequests: new Map(), - messageRoles: new Map(), - userMessageIds: [], - parentSubagent: context, - nextChildTurnOrdinal: 1, - nextAdmissionGeneration: 1, - }); - yield* emitProviderEvent({ - type: "app_thread.created", - driver: OPENCODE_PROVIDER, - appThread: childThread, - }); - yield* emitProviderEvent({ - type: "provider_thread.updated", - driver: OPENCODE_PROVIDER, - providerThread: childProviderThread, - }); - } - const output = toolOutput(part); - if (part.state.status === "completed" && output !== undefined) context.result = output; - const status = toolStatus(part); - const completedAt = toolCompletedAt(part, now); - const subagentStatus: OrchestrationV2Subagent["status"] = - status.item === "failed" - ? "failed" - : status.item === "completed" - ? "completed" - : status.item === "pending" - ? "pending" - : "running"; - const subagent: OrchestrationV2Subagent = { - id: nodeId, - threadId: turn.threadId, - runId: turn.runId, - parentNodeId: turn.rootNodeId, - origin: "provider_native", - createdBy: "agent", - driver: OPENCODE_PROVIDER, - providerInstanceId: options.instanceId, - providerThreadId: context.childProviderThreadId, - childThreadId: context.childThreadId, - nativeTaskRef: nativeItemRef, - prompt, - title, - model: context.model, - status: subagentStatus, - result: context.result, - startedAt: context.startedAt, - completedAt, - updatedAt: now, - }; - yield* emitProviderEvent({ - type: "node.updated", - driver: OPENCODE_PROVIDER, - node: { - id: nodeId, - threadId: turn.threadId, - runId: turn.runId, - parentNodeId: turn.rootNodeId, - rootNodeId: turn.rootNodeId, - kind: "subagent", - status: status.node, - countsForRun: false, - providerThreadId: context.childProviderThreadId ?? state.providerThread.id, - providerTurnId: turn.providerTurnId, - nativeItemRef, - runtimeRequestId: null, - checkpointScopeId: null, - startedAt: context.startedAt, - completedAt, - }, - }); - yield* emitProviderEvent({ - type: "subagent.updated", - driver: OPENCODE_PROVIDER, - subagent, - }); - yield* emitProviderEvent({ - type: "turn_item.updated", - driver: OPENCODE_PROVIDER, - turnItem: { - id: turnItemId, - threadId: turn.threadId, - runId: turn.runId, - nodeId, - providerThreadId: state.providerThread.id, - providerTurnId: turn.providerTurnId, - nativeItemRef, - parentItemId: null, - ordinal: itemOrdinal(turn, part.id), - status: status.item, - title, - startedAt: context.startedAt, - completedAt, - updatedAt: now, - type: "subagent", - subagentId: nodeId, - origin: "provider_native", - driver: OPENCODE_PROVIDER, - providerInstanceId: options.instanceId, - childThreadId: context.childThreadId, - prompt, - result: context.result, - }, - }); - }); - - const emitToolPart = Effect.fnUntraced(function* ( - state: OpenCodeThreadState, - turn: ActiveOpenCodeTurn, - part: ToolPart, - ) { - const normalizedTool = part.tool.toLowerCase(); - if (normalizedTool === "task") { - yield* emitSubagent(state, turn, part); - return; - } - // question.asked carries the respondable semantic item. Projecting - // the implementation tool as well would duplicate it in the UI. - if (normalizedTool === "question") return; - const now = yield* DateTime.now; - const status = toolStatus(part); - const startedAt = toolStartedAt(part, now); - const completedAt = toolCompletedAt(part, now); - const nativeItemRef = providerRef(part.id); - const nodeId = idAllocator.derive.nodeFromProviderItem({ - driver: OPENCODE_PROVIDER, - nativeItemId: part.id, - }); - const turnItemId = idAllocator.derive.turnItemFromProviderItem({ - driver: OPENCODE_PROVIDER, - nativeItemId: part.id, - }); - const base = { - id: turnItemId, - threadId: turn.threadId, - runId: turn.runId, - nodeId, - providerThreadId: state.providerThread.id, - providerTurnId: turn.providerTurnId, - nativeItemRef, - parentItemId: null, - ordinal: itemOrdinal(turn, part.id), - status: status.item, - title: toolTitle(part), - startedAt, - completedAt, - updatedAt: now, - } satisfies Pick< - OrchestrationV2TurnItem, - | "id" - | "threadId" - | "runId" - | "nodeId" - | "providerThreadId" - | "providerTurnId" - | "nativeItemRef" - | "parentItemId" - | "ordinal" - | "status" - | "title" - | "startedAt" - | "completedAt" - | "updatedAt" - >; - const input = toolInput(part); - const output = toolOutput(part); - const isNativeTool = part.tool === "code_search" || part.tool === "apply_patch"; - if (!isNativeTool && part.tool.includes("_") && turn.mcpServerNames === undefined) { - const serverNames = yield* OpenCodeRuntime.runOpenCodeSdk("mcp.status", (signal) => - client.mcp.status(undefined, { signal, throwOnError: true }), - ).pipe( - Effect.timeout("1 second"), - Effect.map((response) => Object.keys(response.data ?? {})), - Effect.catch(() => Effect.succeed(undefined)), - ); - if (serverNames !== undefined) turn.mcpServerNames = serverNames; - } - const matchingServers = turn.mcpServerNames?.filter( - (name) => - !isNativeTool && part.tool.startsWith(`${name.replace(/[^a-zA-Z0-9_-]/g, "_")}_`), - ); - const serverName = matchingServers?.length === 1 ? matchingServers[0] : undefined; - const presentation = - serverName === undefined - ? {} - : mcpToolPresentation({ - serverName, - toolName: part.tool.slice(serverName.replace(/[^a-zA-Z0-9_-]/g, "_").length + 1), - title: toolTitle(part) === part.tool ? undefined : toolTitle(part), - }); - const turnItem: OrchestrationV2TurnItem = matchingServers?.length - ? { - ...base, - type: "dynamic_tool", - ...presentation, - toolName: part.tool, - input, - ...(output === undefined ? {} : { output }), - } - : openCodeToolTurnItem(base, { - name: part.tool, - input, - output, - completedMetadata: - part.state.status === "completed" ? part.state.metadata : undefined, - }); - yield* emitProviderEvent({ - type: "node.updated", - driver: OPENCODE_PROVIDER, - node: { - id: nodeId, - threadId: turn.threadId, - runId: turn.runId, - parentNodeId: turn.rootNodeId, - rootNodeId: turn.rootNodeId, - kind: "tool_call", - status: status.node, - countsForRun: false, - providerThreadId: state.providerThread.id, - providerTurnId: turn.providerTurnId, - nativeItemRef, - runtimeRequestId: null, - checkpointScopeId: null, - startedAt, - completedAt, - }, - }); - yield* emitProviderEvent({ - type: "turn_item.updated", - driver: OPENCODE_PROVIDER, - turnItem, - }); - }); - - const emitTodo = Effect.fnUntraced(function* ( - state: OpenCodeThreadState, - turn: ActiveOpenCodeTurn, - todos: ReadonlyArray, - ) { - const now = yield* DateTime.now; - if (turn.planId === null) { - turn.planId = yield* idAllocator.allocate.plan({ - threadId: turn.threadId, - ...(turn.runId === null ? {} : { runId: turn.runId }), - driver: OPENCODE_PROVIDER, - }); - } - const planId = turn.planId; - const nativeItemId = `${state.nativeSessionId}:todo:${turn.providerTurnId}`; - const nodeId = idAllocator.derive.nodeFromProviderItem({ - driver: OPENCODE_PROVIDER, - nativeItemId, - }); - const turnItemId = idAllocator.derive.turnItemFromProviderItem({ - driver: OPENCODE_PROVIDER, - nativeItemId, - }); - const steps: Array = todos.map((todo, index) => ({ - id: `${nativeItemId}:${index + 1}`, - text: todo.content.trim() || `Todo ${index + 1}`, - status: - todo.status === "completed" - ? "completed" - : todo.status === "in_progress" - ? "running" - : "pending", - })); - const completed = steps.length > 0 && steps.every((step) => step.status === "completed"); - yield* emitProviderEvent({ - type: "node.updated", - driver: OPENCODE_PROVIDER, - node: { - id: nodeId, - threadId: turn.threadId, - runId: turn.runId, - parentNodeId: turn.rootNodeId, - rootNodeId: turn.rootNodeId, - kind: "todo_list", - status: completed ? "completed" : "running", - countsForRun: false, - providerThreadId: state.providerThread.id, - providerTurnId: turn.providerTurnId, - nativeItemRef: providerRef(nativeItemId, "weak"), - runtimeRequestId: null, - checkpointScopeId: null, - startedAt: turn.startedAt, - completedAt: completed ? now : null, - }, - }); - yield* emitProviderEvent({ - type: "plan.updated", - driver: OPENCODE_PROVIDER, - plan: { - id: planId, - threadId: turn.threadId, - runId: turn.runId, - nodeId, - status: completed ? "completed" : "active", - kind: "todo_list", - steps, - }, - }); - yield* emitProviderEvent({ - type: "turn_item.updated", - driver: OPENCODE_PROVIDER, - turnItem: { - id: turnItemId, - threadId: turn.threadId, - runId: turn.runId, - nodeId, - providerThreadId: state.providerThread.id, - providerTurnId: turn.providerTurnId, - nativeItemRef: providerRef(nativeItemId, "weak"), - parentItemId: null, - ordinal: itemOrdinal(turn, nativeItemId), - status: completed ? "completed" : "running", - title: "Todo list", - startedAt: turn.startedAt, - completedAt: completed ? now : null, - updatedAt: now, - type: "todo_list", - planId, - steps, - }, - }); - }); - - const requestQuestions = (request: QuestionRequest) => - request.questions.map((question, index) => ({ - 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) => ({ - label: option.label.trim() || "Option", - description: option.description.trim() || option.label.trim() || "Option", - })), - })); - - const runtimeRequestTurnItem = ( - pending: PendingOpenCodeRequest, - status: OrchestrationV2TurnItem["status"], - completedAt: DateTime.Utc | null, - updatedAt: DateTime.Utc, - ): OrchestrationV2TurnItem => { - const base = { - id: pending.turnItemId, - threadId: pending.turn.threadId, - runId: pending.turn.runId, - nodeId: pending.nodeId, - providerThreadId: pending.state.providerThread.id, - providerTurnId: pending.turn.providerTurnId, - nativeItemRef: providerRef(pending.nativeRequestId), - parentItemId: null, - ordinal: itemOrdinal(pending.turn, pending.nativeRequestId), - status, - startedAt: pending.createdAt, - completedAt, - updatedAt, - }; - if (pending.question !== undefined) { - return { - ...base, - title: "User input", - type: "user_input_request", - requestId: pending.requestId, - questions: requestQuestions(pending.question), - }; - } - const permission = pending.permission; - if (permission === undefined) { - throw protocolError(`OpenCode request ${pending.requestId} has no native payload`); - } - return { - ...base, - title: permission.permission, - type: "approval_request", - requestId: pending.requestId, - requestKind: pending.requestKind === "user_input" ? "command" : pending.requestKind, - prompt: - permission.patterns.length === 0 - ? permission.permission - : permission.patterns.join("\n"), - }; - }; - - const emitRuntimeRequest = Effect.fnUntraced(function* ( - owner: OpenCodeRequestOwner, - nativeRequestId: string, - request: - | { readonly type: "permission"; readonly value: PermissionRequest } - | { readonly type: "question"; readonly value: QuestionRequest }, - ) { - if (pendingRequestsByNativeId.has(nativeRequestId)) return; - const { state, turn, subagent } = owner; - const now = yield* DateTime.now; - const requestId = yield* idAllocator.allocate.runtimeRequest({ - driver: OPENCODE_PROVIDER, - providerTurnId: turn.providerTurnId, - nativeRequestId, - }); - const nodeId = idAllocator.derive.approvalNode({ requestId }); - const turnItemId = idAllocator.derive.approvalTurnItem({ requestId }); - // The tool call belongs to the asking session's turn, not the owner's. - const permissionToolName = - request.type === "permission" && request.value.tool !== undefined - ? threads - .get(request.value.sessionID) - ?.activeTurn?.toolNamesByCallId.get(request.value.tool.callID) - : undefined; - const permissionRequestKind = - request.type === "permission" - ? openCodePermissionRequestKind(request.value.permission, permissionToolName) - : undefined; - const requestKind: OrchestrationV2RuntimeRequest["kind"] = - permissionRequestKind ?? "user_input"; - const pending: PendingOpenCodeRequest = { - requestId, - nativeRequestId, - nativeSessionId: request.value.sessionID, - turn, - state, - nodeId, - parentNodeId: subagent?.nodeId ?? turn.rootNodeId, - turnItemId, - requestKind, - createdAt: now, - ...(request.type === "permission" - ? { permission: request.value } - : { question: request.value }), - }; - pendingRequests.set(String(requestId), pending); - pendingRequestsByNativeId.set(nativeRequestId, pending); - const runtimeRequest: OrchestrationV2RuntimeRequest = { - id: requestId, - nodeId, - providerTurnId: turn.providerTurnId, - nativeRequestRef: providerRef(nativeRequestId), - kind: requestKind, - status: "pending", - responseCapability: { - type: "live", - providerSessionId: inputProviderSessionId, - }, - createdAt: now, - resolvedAt: null, - }; - state.runtimeRequests.set(String(requestId), runtimeRequest); - yield* emitProviderEvent({ - type: "node.updated", - driver: OPENCODE_PROVIDER, - node: { - id: nodeId, - threadId: turn.threadId, - runId: turn.runId, - parentNodeId: pending.parentNodeId, - rootNodeId: turn.rootNodeId, - kind: request.type === "question" ? "user_input_request" : "approval_request", - status: "waiting", - countsForRun: false, - providerThreadId: state.providerThread.id, - providerTurnId: turn.providerTurnId, - nativeItemRef: providerRef(nativeRequestId), - runtimeRequestId: requestId, - checkpointScopeId: null, - startedAt: now, - completedAt: null, - }, - }); - yield* emitProviderEvent({ - type: "runtime_request.updated", - driver: OPENCODE_PROVIDER, - threadId: turn.threadId, - runtimeRequest, - }); - yield* emitProviderEvent({ - type: "turn_item.updated", - driver: OPENCODE_PROVIDER, - turnItem: runtimeRequestTurnItem(pending, "waiting", null, now), - }); - yield* updateProviderSession("waiting", null); - }); - - const resolveRuntimeRequest = Effect.fnUntraced(function* ( - nativeRequestId: string, - status: "resolved" | "cancelled", - ) { - const pending = pendingRequestsByNativeId.get(nativeRequestId); - if (pending === undefined) return; - const now = yield* DateTime.now; - const current = pending.state.runtimeRequests.get(String(pending.requestId)); - if (current !== undefined) { - const resolved: OrchestrationV2RuntimeRequest = { - ...current, - status, - resolvedAt: now, - }; - pending.state.runtimeRequests.set(String(pending.requestId), resolved); - yield* emitProviderEvent({ - type: "runtime_request.updated", - driver: OPENCODE_PROVIDER, - threadId: pending.turn.threadId, - runtimeRequest: resolved, - }); - } - yield* emitProviderEvent({ - type: "node.updated", - driver: OPENCODE_PROVIDER, - node: { - id: pending.nodeId, - threadId: pending.turn.threadId, - runId: pending.turn.runId, - parentNodeId: pending.parentNodeId, - rootNodeId: pending.turn.rootNodeId, - kind: pending.question === undefined ? "approval_request" : "user_input_request", - status: status === "resolved" ? "completed" : "cancelled", - countsForRun: false, - providerThreadId: pending.state.providerThread.id, - providerTurnId: pending.turn.providerTurnId, - nativeItemRef: providerRef(nativeRequestId), - runtimeRequestId: pending.requestId, - checkpointScopeId: null, - startedAt: pending.createdAt, - completedAt: now, - }, - }); - yield* emitProviderEvent({ - type: "turn_item.updated", - driver: OPENCODE_PROVIDER, - turnItem: runtimeRequestTurnItem( - pending, - status === "resolved" ? "completed" : "cancelled", - now, - now, - ), - }); - pendingRequests.delete(String(pending.requestId)); - pendingRequestsByNativeId.delete(nativeRequestId); - const hasOtherPending = Array.from(pendingRequests.values()).some( - (candidate) => candidate.turn.isRoot, - ); - if (!hasOtherPending) yield* updateProviderSession("running", null); - }); - - /** Resolve the thread state a session belongs to: its own, or for a - * child not registered yet, the nearest known ancestor found by walking - * the native parent chain. Registers every hop so later requests from - * the same child resolve without another lookup. */ - const resolveSessionOwner = Effect.fnUntraced(function* (sessionId: string) { - const known = threads.get(sessionId) ?? relatedSessionOwners.get(sessionId); - if (known !== undefined) return known; - let cursor = sessionId; - const hops: string[] = []; - for (let depth = 0; depth < 5; depth += 1) { - const response = yield* sdkCall("session.get", { sessionID: cursor }, () => - client.session.get({ sessionID: cursor }), - ).pipe(Effect.option); - const info = Option.getOrUndefined(response)?.data; - const parentId = info?.parentID; - if (parentId === undefined) return undefined; - hops.push(cursor); - const owner = threads.get(parentId) ?? relatedSessionOwners.get(parentId); - if (owner !== undefined) { - for (const hop of hops) relatedSessionOwners.set(hop, owner); - return owner; - } - cursor = parentId; - } - return undefined; - }); - - /** Every request is asked on the top-level thread and its active turn, - * under the subagent that leads to the asking session, because native - * subagent threads are hidden from the sidebar. A child's request can - * arrive before the task part or session.created event that reveals - * its relation to a thread. The first resolution attempt runs inline - * (the replayable path); if the relation or the owning turn is not - * established yet, a short forked backoff keeps trying instead of - * dropping the request. */ - const routeRuntimeRequest = Effect.fnUntraced(function* ( - nativeRequestId: string, - sessionId: string, - request: - | { readonly type: "permission"; readonly value: PermissionRequest } - | { readonly type: "question"; readonly value: QuestionRequest }, - ) { - if (pendingChildRequestRoutes.has(nativeRequestId)) return; - const attempt = Effect.gen(function* () { - if ( - settledNativeRequestIds.has(nativeRequestId) || - pendingRequestsByNativeId.has(nativeRequestId) - ) { - return true; - } - const state = yield* resolveSessionOwner(sessionId); - const owner = state === undefined ? undefined : topLevelRequestOwner(state); - if (owner === undefined) return false; - yield* emitRuntimeRequest(owner, nativeRequestId, request); - return true; - }); - if (yield* attempt) return; - pendingChildRequestRoutes.add(nativeRequestId); - yield* Effect.gen(function* () { - for (let retry = 0; retry < 5; retry += 1) { - yield* Effect.sleep(Duration.millis(Math.min(200 * 2 ** retry, 2_000))); - if (yield* attempt) return; - } - }).pipe( - Effect.ensuring(Effect.sync(() => pendingChildRequestRoutes.delete(nativeRequestId))), - Effect.forkIn(scope), - ); - }); - - const finalizeTurn = Effect.fnUntraced(function* ( - state: OpenCodeThreadState, - turn: ActiveOpenCodeTurn, - status: TerminalTurnStatus, - terminal?: { - readonly failure?: OrchestrationV2ProviderFailure; - readonly threadDisposition?: "reusable" | "broken"; - }, - ) { - if (turn.finalized) return; - if (nativeStreamFailure !== null) { - status = "failed"; - terminal = { failure: nativeStreamFailure, threadDisposition: "broken" }; - } - turn.finalized = true; - const completedAt = yield* DateTime.now; - for (const part of turn.parts.values()) { - if (part.type === "text" || part.type === "reasoning") { - yield* emitTextPart(state, turn, part, true); - } - } - for (const pending of Array.from(pendingRequests.values())) { - if ( - pending.turn.providerTurnId === turn.providerTurnId || - pending.nativeSessionId === state.nativeSessionId - ) { - yield* resolveRuntimeRequest(pending.nativeRequestId, "cancelled"); - } - } - yield* emitProviderTurn(state, turn, status, completedAt); - const threadDisposition = terminal?.threadDisposition ?? "reusable"; - yield* updateProviderThread(state, { - status: turn.isRoot ? "active" : threadDisposition === "broken" ? "error" : "idle", - nativeConversationHeadRef: - turn.nativeUserMessageId === null - ? state.providerThread.nativeConversationHeadRef - : providerRef(turn.nativeUserMessageId, "weak"), - }); - state.activeTurn = null; - if (!turn.isRoot) { - yield* emitProviderEvent({ - type: "node.updated", - driver: OPENCODE_PROVIDER, - node: { - id: turn.rootNodeId, - threadId: turn.threadId, - runId: null, - parentNodeId: null, - rootNodeId: turn.rootNodeId, - kind: "root_turn", - status, - countsForRun: false, - providerThreadId: state.providerThread.id, - providerTurnId: turn.providerTurnId, - nativeItemRef: providerRef(state.nativeSessionId), - runtimeRequestId: null, - checkpointScopeId: null, - startedAt: turn.startedAt, - completedAt, - }, - }); - return; - } - const anotherTurnIsActive = Array.from(threads.values()).some( - (candidate) => candidate.activeTurn?.isRoot === true, - ); - yield* updateProviderSession( - anotherTurnIsActive ? "running" : status === "failed" ? "error" : "ready", - status === "failed" ? sessionEntity.lastError : null, - ); - yield* emitProviderEvent( - status === "failed" - ? { - type: "turn.terminal", - driver: OPENCODE_PROVIDER, - providerThreadId: turn.providerThreadId, - providerTurnId: turn.providerTurnId, - runOrdinal: turn.runOrdinal, - failureItemOrdinal: itemOrdinal(turn, `terminal-failure:${turn.providerTurnId}`), - status, - failure: - terminal?.failure ?? - makeProviderFailure({ - message: sessionEntity.lastError ?? undefined, - class: "provider_error", - }), - threadDisposition, - } - : { - type: "turn.terminal", - driver: OPENCODE_PROVIDER, - providerThreadId: turn.providerThreadId, - providerTurnId: turn.providerTurnId, - runOrdinal: turn.runOrdinal, - status, - failure: null, - threadDisposition, - }, - ); - }); - - const promptAdmissionIsCurrent = ( - state: OpenCodeThreadState, - turn: ActiveOpenCodeTurn, - generation: number, - ) => - state.activeTurn === turn && - !turn.finalized && - !turn.interrupted && - turn.admissionPending && - turn.admissionGeneration === generation; - - const runPromptAdmissionReconciliation = Effect.fnUntraced(function* ( - state: OpenCodeThreadState, - turn: ActiveOpenCodeTurn, - generation: number, - ) { - while (promptAdmissionIsCurrent(state, turn, generation)) { - const status = yield* reconcileOpenCodePromptAdmissionStatus( - turn, - generation, - sdkCall("session.status", { sessionID: state.nativeSessionId, generation }, () => - client.session.status(), - ).pipe( - Effect.match({ - onFailure: () => "unknown" as const, - onSuccess: (response) => { - const statuses = unwrapData("session.status", response); - const sessionStatus = statuses[state.nativeSessionId]; - return sessionStatus === undefined || sessionStatus.type === "idle" - ? ("idle" as const) - : ("busy" as const); - }, - }), - ), - ); - if ( - state.activeTurn !== turn || - status === "stale" || - turn.finalized || - turn.interrupted || - turn.admissionGeneration !== generation - ) { - return; - } - if (status === "idle") { - yield* finalizeTurn(state, turn, "completed"); - return; - } - if (status === "busy") return; - yield* Effect.sleep("250 millis"); - } - }); - - const reconcilePromptAdmission = Effect.fnUntraced(function* ( - state: OpenCodeThreadState, - turn: ActiveOpenCodeTurn, - ) { - const generation = turn.admissionGeneration; - if ( - !promptAdmissionIsCurrent(state, turn, generation) || - turn.admissionReconciliationGeneration === generation - ) { - return; - } - turn.admissionReconciliationGeneration = generation; - yield* runPromptAdmissionReconciliation(state, turn, generation).pipe( - Effect.ensuring( - Effect.sync(() => { - if (turn.admissionReconciliationGeneration === generation) { - turn.admissionReconciliationGeneration = null; - } - }), - ), - Effect.forkIn(scope), - ); - }); - - const createChildTurn = Effect.fnUntraced(function* ( - state: OpenCodeThreadState, - message: Extract, - ) { - if (state.appThread === null || state.parentSubagent === null) return null; - const now = yield* DateTime.now; - const startedAt = dateTimeFromEpoch(message.time.created, now); - const rootNodeId = idAllocator.derive.nodeFromProviderItem({ - driver: OPENCODE_PROVIDER, - nativeItemId: `${state.nativeSessionId}:root:${message.id}`, - }); - const providerTurnId = idAllocator.derive.providerTurn({ - driver: OPENCODE_PROVIDER, - nativeTurnId: message.id, - }); - const providerTurn: OrchestrationV2ProviderTurn = { - id: providerTurnId, - providerThreadId: state.providerThread.id, - nodeId: rootNodeId, - runAttemptId: null, - nativeTurnRef: providerRef(message.id, "weak"), - ordinal: state.nextChildTurnOrdinal++, - status: "running", - startedAt, - completedAt: null, - }; - const turn: ActiveOpenCodeTurn = { - isRoot: false, - threadId: state.appThread.id, - runId: null, - rootNodeId, - appThread: state.appThread, - modelSelection: state.appThread.modelSelection, - runtimePolicy: state.parentSubagent.parentTurn.runtimePolicy, - providerTurnId, - providerThreadId: providerTurn.providerThreadId, - providerTurnOrdinal: providerTurn.ordinal, - runOrdinal: state.parentSubagent.parentTurn.runOrdinal, - runAttemptId: null, - startedAt, - itemOrdinals: new Map(), - usage: makeOpenCodeTurnTokenUsageAccumulator(), - parts: new Map(), - partIdsByMessage: new Map(), - toolNamesByCallId: new Map(), - providerTurn, - nextItemOrdinal: 1, - nativeUserMessageId: message.id, - admissionMessageId: null, - interrupted: false, - finalized: false, - planId: null, - admissionGeneration: 0, - admissionReconciliationGeneration: null, - admissionPending: false, - admissionAccepted: true, - admissionMessageObserved: true, - idleDuringAdmission: false, - admissionSettled: Deferred.makeUnsafe(), - admissionAbortController: null, - }; - state.activeTurn = turn; - state.providerTurns.set(String(providerTurnId), providerTurn); - yield* emitProviderEvent({ - type: "node.updated", - driver: OPENCODE_PROVIDER, - node: { - id: rootNodeId, - threadId: turn.threadId, - runId: null, - parentNodeId: null, - rootNodeId, - kind: "root_turn", - status: "running", - countsForRun: false, - providerThreadId: state.providerThread.id, - providerTurnId, - nativeItemRef: providerRef(message.id, "weak"), - runtimeRequestId: null, - checkpointScopeId: null, - startedAt, - completedAt: null, - }, - }); - yield* emitProviderTurn(state, turn, "running", null); - return turn; - }); - - const projectChildUserPart = Effect.fnUntraced(function* ( - state: OpenCodeThreadState, - turn: ActiveOpenCodeTurn, - part: Extract, - ) { - const now = yield* DateTime.now; - const messageId = idAllocator.derive.messageFromProviderItem({ - driver: OPENCODE_PROVIDER, - nativeItemId: part.messageID, - }); - const turnItemId = idAllocator.derive.turnItemFromProviderItem({ - driver: OPENCODE_PROVIDER, - nativeItemId: part.messageID, - }); - const projected: OrchestrationV2ConversationMessage = { - createdBy: "agent", - creationSource: "provider", - id: messageId, - threadId: turn.threadId, - runId: null, - nodeId: turn.rootNodeId, - role: "user", - text: part.text, - attachments: [], - streaming: false, - createdAt: turn.startedAt, - updatedAt: now, - }; - state.messages.set(String(messageId), projected); - yield* emitProviderEvent({ - type: "message.updated", - driver: OPENCODE_PROVIDER, - message: projected, - }); - yield* emitProviderEvent({ - type: "turn_item.updated", - driver: OPENCODE_PROVIDER, - turnItem: { - createdBy: "agent", - creationSource: "provider", - id: turnItemId, - threadId: turn.threadId, - runId: null, - nodeId: turn.rootNodeId, - providerThreadId: state.providerThread.id, - providerTurnId: turn.providerTurnId, - nativeItemRef: providerRef(part.messageID), - parentItemId: null, - ordinal: itemOrdinal(turn, part.messageID), - status: "completed", - title: null, - startedAt: projected.createdAt, - completedAt: now, - updatedAt: now, - type: "user_message", - messageId, - inputIntent: "turn_start", - text: part.text, - attachments: [], - }, - }); - }); - - const emitCompactionItem = Effect.fn("OpenCodeAdapterV2.emitCompactionItem")(function* ( - state: OpenCodeThreadState, - turn: ActiveOpenCodeTurn, - ) { - const nativeItemId = `${turn.providerTurnId}:compaction`; - if (turn.itemOrdinals.has(nativeItemId)) return; - const now = yield* DateTime.now; - yield* emitProviderEvent({ - type: "turn_item.updated", - driver: OPENCODE_PROVIDER, - turnItem: { - id: idAllocator.derive.turnItemFromProviderItem({ - driver: OPENCODE_PROVIDER, - nativeItemId, - }), - threadId: turn.threadId, - runId: turn.runId, - nodeId: turn.rootNodeId, - providerThreadId: state.providerThread.id, - providerTurnId: turn.providerTurnId, - nativeItemRef: providerRef(nativeItemId, "weak"), - parentItemId: null, - ordinal: itemOrdinal(turn, nativeItemId), - type: "compaction", - driver: OPENCODE_PROVIDER, - status: "completed", - title: "Context compacted", - startedAt: turn.startedAt, - completedAt: now, - updatedAt: now, - }, - }); - }); - - const handleMessageUpdated = Effect.fnUntraced(function* ( - event: Extract, - ) { - const state = threads.get(event.properties.sessionID); - if (state === undefined) return; - const message = event.properties.info; - state.messageRoles.set(message.id, message.role); - if (message.role === "assistant") { - const usage = state.activeTurn?.usage; - if (usage === undefined) return; - const prior = usage.assistantOwnershipByMessageId.get(message.id); - const ownership = - prior !== undefined && prior !== "unknown" - ? prior - : !message.parentID - ? "unknown" - : usage.promptMessageIds.has(message.parentID) - ? "owned" - : "other"; - usage.assistantOwnershipByMessageId.set(message.id, ownership); - if (ownership !== "unknown") { - if (ownership === "owned") { - for (const step of usage.unresolvedStepsByMessageId.get(message.id)?.values() ?? - []) { - accumulateOpenCodeStepUsage(usage, step); - } - } - usage.unresolvedStepsByMessageId.delete(message.id); - } - return; - } - const commandReceipt = commandReceipts.get(message.id); - if (commandReceipt) yield* Deferred.succeed(commandReceipt, undefined); - const isNewUserMessage = !state.userMessageIds.includes(message.id); - if (isNewUserMessage) state.userMessageIds.push(message.id); - let turn = state.activeTurn; - if (turn === null && state.parentSubagent !== null && isNewUserMessage) { - turn = yield* createChildTurn(state, message); - } - const matchesAdmission = - turn !== null && - (turn.admissionMessageId === null || turn.admissionMessageId === message.id); - if (turn !== null && matchesAdmission) turn.usage.promptMessageIds.add(message.id); - if (turn !== null && matchesAdmission && turn.nativeUserMessageId === null) { - turn.nativeUserMessageId = message.id; - yield* emitProviderTurn(state, turn, "running", null); - } - if (turn !== null && matchesAdmission && turn.admissionPending) { - if (advanceOpenCodePromptAdmission(turn, "user-message") === "reconcile-idle") { - yield* reconcilePromptAdmission(state, turn); - } - } - }); - - const handlePartUpdated = Effect.fnUntraced(function* ( - event: Extract, - ) { - const part = event.properties.part; - const state = threads.get(part.sessionID); - const turn = state?.activeTurn; - if (state === undefined || turn === null || turn === undefined || turn.finalized) return; - if (part.type === "text" && state.messageRoles.get(part.messageID) === "user") { - if (!turn.isRoot) yield* projectChildUserPart(state, turn, part); - return; - } - if (part.type === "step-finish") { - const usage = turn.usage; - const ownership = usage.assistantOwnershipByMessageId.get(part.messageID); - if (ownership === "owned") accumulateOpenCodeStepUsage(usage, part); - else if ( - ownership === "unknown" || - (ownership === undefined && !state.messageRoles.has(part.messageID)) - ) { - const steps = - usage.unresolvedStepsByMessageId.get(part.messageID) ?? - new Map(); - steps.set(part.id, { id: part.id, tokens: part.tokens }); - usage.unresolvedStepsByMessageId.set(part.messageID, steps); - } - return; - } - if (part.type === "tool") { - // Approval routing needs the tool name, without retaining its input and output. - turn.toolNamesByCallId.set(part.callID, part.tool); - } else { - turn.parts.set(part.id, part); - const ids = turn.partIdsByMessage.get(part.messageID) ?? new Set(); - ids.add(part.id); - turn.partIdsByMessage.set(part.messageID, ids); - } - switch (part.type) { - case "text": - case "reasoning": - yield* emitTextPart(state, turn, part); - return; - case "tool": - yield* emitToolPart(state, turn, part); - return; - default: - return; - } - }); - - const handlePartDelta = Effect.fnUntraced(function* ( - event: Extract, - ) { - if (event.properties.field !== "text") return; - const state = threads.get(event.properties.sessionID); - const turn = state?.activeTurn; - const current = turn?.parts.get(event.properties.partID); - if ( - state === undefined || - turn === null || - turn === undefined || - current === undefined || - (current.type !== "text" && current.type !== "reasoning") - ) { - return; - } - const updated = { ...current, text: current.text + event.properties.delta }; - turn.parts.set(updated.id, updated); - yield* emitTextPart(state, turn, updated); - }); - - const handleAssistantCompleted = Effect.fnUntraced(function* ( - event: Extract, - ) { - const message = event.properties.info; - if (message.role !== "assistant" || message.time.completed === undefined) return; - const state = threads.get(message.sessionID); - const turn = state?.activeTurn; - if (state === undefined || turn === null || turn === undefined) return; - // Some OpenCode versions ignore the client-provided message ID. A - // completed assistant message is definitive admission evidence, so - // let the following idle event settle the turn without weakening - // the stale-user-message guard. - advanceOpenCodePromptAdmission(turn, "assistant-completed"); - for (const partId of turn.partIdsByMessage.get(message.id) ?? []) { - const part = turn.parts.get(partId); - if (part?.type === "text" || part?.type === "reasoning") { - yield* emitTextPart(state, turn, part, true); - } - } - }); - - const handleEvent = Effect.fnUntraced(function* (event: OpenCodeEvent) { - yield* logProtocolEvent({ - direction: "incoming", - messageKind: "notification", - method: event.type, - payload: event, - }); - switch (event.type) { - case "server.connected": - if (hasConnected) { - for (const state of threads.values()) { - if (state.activeTurn !== null) state.activeTurn.usage.complete = false; - } - } - hasConnected = true; - return; - case "message.updated": - yield* handleMessageUpdated(event); - yield* handleAssistantCompleted(event); - return; - case "session.compacted": { - const state = threads.get(event.properties.sessionID); - if (state?.activeTurn !== undefined && state.activeTurn !== null) { - yield* emitCompactionItem(state, state.activeTurn); - } - return; - } - case "message.part.updated": - yield* handlePartUpdated(event); - return; - case "message.part.delta": - yield* handlePartDelta(event); - return; - case "todo.updated": { - const state = threads.get(event.properties.sessionID); - if (state?.activeTurn !== null && state?.activeTurn !== undefined) { - yield* emitTodo(state, state.activeTurn, event.properties.todos); - } - return; - } - case "permission.asked": - yield* routeRuntimeRequest(event.properties.id, event.properties.sessionID, { - type: "permission", - value: event.properties, - }); - return; - case "question.asked": - yield* routeRuntimeRequest(event.properties.id, event.properties.sessionID, { - type: "question", - value: event.properties, - }); - return; - case "permission.replied": - case "question.replied": - rememberSettledRequest(event.properties.requestID); - yield* resolveRuntimeRequest(event.properties.requestID, "resolved"); - return; - case "question.rejected": - rememberSettledRequest(event.properties.requestID); - yield* resolveRuntimeRequest(event.properties.requestID, "cancelled"); - return; - case "session.created": - case "session.updated": { - const info = event.properties.info; - if (info.parentID !== undefined && !threads.has(info.id)) { - const owner = threads.get(info.parentID) ?? relatedSessionOwners.get(info.parentID); - if (owner !== undefined) { - relatedSessionOwners.set(info.id, owner); - } - } - return; - } - case "session.deleted": - relatedSessionOwners.delete(event.properties.info.id); - busySessionIds.delete(event.properties.info.id); - return; - case "session.status": { - const sessionId = event.properties.sessionID; - switch (event.properties.status.type) { - case "busy": - case "retry": - if (threads.has(sessionId) || relatedSessionOwners.has(sessionId)) { - busySessionIds.add(sessionId); - } - break; - case "idle": - busySessionIds.delete(sessionId); - break; - } - const state = threads.get(sessionId); - if (state === undefined) return; - if (event.properties.status.type === "busy") { - yield* updateProviderThread(state, { status: "active" }); - return; - } - if (event.properties.status.type === "idle" && state.activeTurn !== null) { - if (state.activeTurn.admissionPending) { - advanceOpenCodePromptAdmission(state.activeTurn, "idle"); - return; - } - yield* finalizeTurn( - state, - state.activeTurn, - state.activeTurn.interrupted ? "interrupted" : "completed", - ); - } - return; - } - case "session.idle": { - busySessionIds.delete(event.properties.sessionID); - const state = threads.get(event.properties.sessionID); - if (state?.activeTurn !== null && state?.activeTurn !== undefined) { - if (state.activeTurn.admissionPending) { - advanceOpenCodePromptAdmission(state.activeTurn, "idle"); - return; - } - yield* finalizeTurn( - state, - state.activeTurn, - state.activeTurn.interrupted ? "interrupted" : "completed", - ); - } - return; - } - case "session.error": { - const states = - event.properties.sessionID === undefined - ? Array.from(threads.values()).filter((state) => state.activeTurn !== null) - : [threads.get(event.properties.sessionID)].filter( - (state): state is OpenCodeThreadState => state !== undefined, - ); - const message = openCodeErrorMessage(event); - if ( - !isMessageAbortedError(event) && - (event.properties.sessionID === undefined || - states.some((state) => state.parentSubagent === null)) - ) { - yield* updateProviderSession("error", message); - } - for (const state of states) { - if (state.activeTurn !== null) { - yield* finalizeTurn( - state, - state.activeTurn, - terminalStatusForError(event, state.activeTurn), - { - failure: makeProviderFailure({ - message, - code: event.properties.error?.name ?? null, - class: "provider_error", - }), - threadDisposition: - event.properties.sessionID === undefined ? "broken" : "reusable", - }, - ); - } - } - return; - } - default: - return; - } - }); - - const inputProviderSessionId = input.providerSessionId; - - const subscription = yield* sdkCall("event.subscribe", {}, () => - client.event.subscribe(undefined, { signal: abortController.signal }), - ); - yield* Scope.addFinalizer( - scope, - Effect.sync(() => abortController.abort()), - ); - yield* Stream.fromAsyncIterable( - subscription.stream, - (cause) => - new OpenCodeRuntime.OpenCodeRuntimeError({ - operation: "event.subscribe", - detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause), - cause, - }), - ).pipe( - Stream.runForEach(handleEvent), - Effect.exit, - Effect.flatMap((exit) => - Effect.gen(function* () { - // No further status can clear a busy session once the stream ends. - busySessionIds.clear(); - if (closing || abortController.signal.aborted) return; - const detail = Exit.isSuccess(exit) - ? "OpenCode event stream ended unexpectedly." - : OpenCodeRuntime.openCodeRuntimeErrorDetail(Cause.squash(exit.cause)); - nativeStreamFailure = makeProviderFailure({ - message: detail, - class: "transport_error", - }); - yield* updateProviderSession("error", detail); - for (const state of threads.values()) { - if (state.activeTurn !== null) - yield* finalizeTurn(state, state.activeTurn, "failed", { - failure: makeProviderFailure({ message: detail, class: "transport_error" }), - threadDisposition: "broken", - }); - } - yield* Queue.end(events); - }), - ), - Effect.forkIn(scope), - ); - - if (!connection.external && connection.exitCode !== null) { - yield* connection.exitCode.pipe( - Effect.flatMap((code) => - abortController.signal.aborted - ? Effect.void - : Effect.gen(function* () { - const detail = `OpenCode server exited unexpectedly (${code}).`; - yield* updateProviderSession("error", detail); - for (const state of threads.values()) { - if (state.activeTurn !== null) { - yield* finalizeTurn(state, state.activeTurn, "failed", { - failure: makeProviderFailure({ - message: detail, - class: "transport_error", - }), - threadDisposition: "broken", - }); - } - } - }), - ), - Effect.forkIn(scope), - ); - } - - if (connection.external) { - yield* Scope.addFinalizer( - scope, - Effect.suspend(() => - Effect.forEach( - Array.from(threads.values()).filter((state) => state.parentSubagent === null), - (state) => - sdkCall("session.abort", { sessionID: state.nativeSessionId }, (signal) => - client.session.abort({ sessionID: state.nativeSessionId }, { signal }), - ).pipe( - Effect.timeout("1 second"), - Effect.ignore({ log: true }), - Effect.andThen( - abortDescendants(state.nativeSessionId).pipe( - Effect.timeout("1 second"), - Effect.ignore({ log: true }), - ), - ), - ), - { concurrency: 8, discard: true }, - ), - ), - ); - } - - yield* Scope.addFinalizer( - scope, - Effect.sync(() => { - closing = true; - }), - ); - - const registerThread = ( - nativeSession: OpenCodeSession, - providerThread: OrchestrationV2ProviderThread, - appThread: OrchestrationV2AppThread | null, - ): OpenCodeThreadState => { - const existing = threads.get(nativeSession.id); - if (existing !== undefined) { - existing.providerThread = providerThread; - if (appThread !== null) existing.appThread = appThread; - return existing; - } - const state: OpenCodeThreadState = { - nativeSessionId: nativeSession.id, - providerThread, - appThread, - activeTurn: null, - providerTurns: new Map(), - messages: new Map(), - runtimeRequests: new Map(), - messageRoles: new Map(), - userMessageIds: [], - parentSubagent: subagentsByChildSessionId.get(nativeSession.id) ?? null, - nextChildTurnOrdinal: 1, - nextAdmissionGeneration: 1, - }; - threads.set(nativeSession.id, state); - return state; - }; - - const resolvePromptParts = (turnInput: ProviderAdapter.ProviderAdapterV2TurnInput) => { - const text = providerMessageTextWithAttachmentPaths({ - text: turnInput.message.text, - attachments: turnInput.message.attachments, - resolveAttachmentPath: (attachment) => - resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }), - }).trim(); - const files = OpenCodeRuntime.toOpenCodeFileParts({ - attachments: turnInput.message.attachments, - resolveAttachmentPath: (attachment) => - resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }), - }); - if (text.length === 0 && files.length === 0) { - throw protocolError("OpenCode turns require text or at least one valid attachment"); - } - return [...(text.length === 0 ? [] : [{ type: "text" as const, text }]), ...files]; - }; - - const readSnapshot = Effect.fnUntraced(function* ( - providerThread: OrchestrationV2ProviderThread, - ) { - const sessionId = nativeThreadId(providerThread); - const sessionResponse = yield* sdkCall("session.get", { sessionID: sessionId }, () => - client.session.get({ sessionID: sessionId }), - ); - const nativeSession = unwrapData("session.get", sessionResponse); - const response = yield* sdkCall("session.messages", { sessionID: sessionId }, () => - client.session.messages({ sessionID: sessionId }), - ); - const nativeMessages = []; - for (const entry of unwrapData("session.messages", response)) { - // OpenCode retains reverted records in session.messages. Its - // revert marker identifies the first removed message, so stop - // before it or a restart resurrects rolled-back assistant work. - if (entry.info.id === nativeSession.revert?.messageID) break; - nativeMessages.push(entry); - } - const state = threads.get(sessionId); - const snapshotNow = yield* DateTime.now; - const messages: Array = nativeMessages.flatMap( - ({ info, parts }) => { - const text = parts - .filter( - (part): part is Extract => part.type === "text", - ) - .filter((part) => part.ignored !== true && part.synthetic !== true) - .map((part) => part.text) - .join("\n"); - if (text.length === 0) return []; - const createdAt = dateTimeFromEpoch(info.time.created, snapshotNow); - return [ - { - createdBy: info.role === "user" ? "user" : "agent", - creationSource: "provider", - id: idAllocator.derive.messageFromProviderItem({ - driver: OPENCODE_PROVIDER, - nativeItemId: info.id, - }), - threadId: providerThread.appThreadId ?? input.threadId, - runId: null, - nodeId: null, - role: info.role, - text, - attachments: [], - streaming: false, - createdAt, - updatedAt: - info.role === "assistant" - ? dateTimeFromEpoch(info.time.completed, createdAt) - : createdAt, - }, - ]; - }, - ); - const lastUser = nativeMessages.findLast(({ info }) => info.role === "user")?.info.id; - return { - providerThread: { - ...providerThread, - providerSessionId: input.providerSessionId, - nativeConversationHeadRef: - lastUser === undefined ? null : providerRef(lastUser, "weak"), - status: "idle" as const, - updatedAt: snapshotNow, - }, - providerTurns: state === undefined ? [] : [...state.providerTurns.values()], - messages, - runtimeRequests: state === undefined ? [] : [...state.runtimeRequests.values()], - providerPayload: nativeMessages, - }; - }); - - // Native commands wait for generation, unlike promptAsync. A user-message - // receipt admits the run while the scoped request continues in the background. - const submitPrompt = Effect.fn("OpenCode.submitPrompt")(function* ( - state: OpenCodeThreadState, - turn: ActiveOpenCodeTurn, - payload: Parameters[0] & { - messageID: string; - sessionID: string; - }, - abortController: AbortController, - ) { - const text = payload.parts - ?.filter((part) => part.type === "text") - .map((part) => part.text) - .join("\n") - .trim(); - const match = text?.match(/^\/([^\s/]+)(?:\s+([\s\S]*))?$/); - const command = match - ? (yield* OpenCodeRuntime.loadOpenCodeCommands(client).pipe( - Effect.timeout("10 seconds"), - Effect.orElseSucceed(() => []), - )).find((entry) => entry.name === match[1]) - : undefined; - if (!command) { - return yield* sdkCall("session.promptAsync", payload, (signal) => - client.session.promptAsync(payload, { - signal: AbortSignal.any([signal, abortController.signal]), - }), - ).pipe(Effect.asVoid); - } - const receipt = Deferred.makeUnsafe(); - commandReceipts.set(payload.messageID, receipt); - const controllers = - commandControllers.get(payload.sessionID) ?? new Set(); - controllers.add(abortController); - commandControllers.set(payload.sessionID, controllers); - const generation = turn.admissionGeneration; - const commandPayload = { - sessionID: payload.sessionID, - messageID: payload.messageID, - command: command.name, - arguments: match?.[2] ?? "", - ...(payload.model - ? { model: `${payload.model.providerID}/${payload.model.modelID}` } - : {}), - ...(payload.agent ? { agent: payload.agent } : {}), - ...(payload.variant ? { variant: payload.variant } : {}), - parts: payload.parts?.filter((part) => part.type === "file") ?? [], - }; - const request = yield* sdkCall("session.command", commandPayload, (signal) => - client.session.command(commandPayload, { - signal: AbortSignal.any([signal, abortController.signal]), - }), - ).pipe( - Effect.asVoid, - Effect.tapError((cause) => - abortController.signal.aborted || - turn.finalized || - turn.admissionGeneration !== generation - ? Effect.void - : finalizeTurn(state, turn, "failed", { - failure: makeProviderFailure({ cause, class: "provider_error" }), - }), - ), - Effect.ensuring( - Effect.sync(() => { - commandReceipts.delete(payload.messageID); - controllers.delete(abortController); - if (controllers.size === 0) commandControllers.delete(payload.sessionID); - }), - ), - Effect.forkIn(scope), - ); - yield* Effect.raceFirst(Fiber.join(request), Deferred.await(receipt)).pipe( - Effect.timeout("10 seconds"), - Effect.catchTags({ - TimeoutError: (cause) => { - const error = new OpenCodeRuntime.OpenCodeRuntimeError({ - operation: "session.command", - detail: "OpenCode command admission did not complete within 10 seconds.", - cause, - }); - abortController.abort(); - return finalizeTurn(state, turn, "failed", { - failure: makeProviderFailure({ cause: error, class: "provider_error" }), - }).pipe(Effect.andThen(Effect.fail(error))); - }, - }), - ); - }); - - const runtimeSession: ProviderAdapter.ProviderAdapterV2SessionRuntime = { - instanceId: options.instanceId, - driver: OPENCODE_PROVIDER, - providerSessionId: input.providerSessionId, - providerSession: sessionEntity, - events: Stream.fromEffectRepeat(Queue.take(events)), - hasPendingBackgroundWork: Effect.sync(() => busySessionIds.size > 0), - ensureThread: (threadInput) => - Effect.gen(function* () { - // Only a row that already carries a native session can be - // resumed; a placeholder without one still needs session.create. - if (threadInput.existingProviderThread?.nativeThreadRef != null) { - return yield* runtimeSession.resumeThread({ - providerThread: threadInput.existingProviderThread, - }); - } - // No title: OpenCode generates one from the first prompt only when - // session.create leaves it unset (SessionPrompt.ensureTitle). - const response = yield* sdkCall( - "session.create", - { permission: openCodePermissionRules(threadInput.runtimePolicy) }, - () => - client.session.create({ - permission: openCodePermissionRules(threadInput.runtimePolicy), - }), - ); - const nativeSession = unwrapData("session.create", response); - const createdAt = yield* DateTime.now; - const created = makeProviderThread({ - idAllocator, - providerInstanceId: options.instanceId, - providerSessionId: input.providerSessionId, - appThreadId: threadInput.threadId, - nativeSession, - now: createdAt, - }); - const existing = threadInput.existingProviderThread; - // Bind the new native session to the caller's row when one was - // handed over: a second live row per app thread would make - // `activeProviderThreadId` flap between the two on every update. - const providerThread = - existing === undefined - ? created - : { - ...existing, - providerSessionId: input.providerSessionId, - nativeThreadRef: created.nativeThreadRef, - nativeConversationHeadRef: created.nativeConversationHeadRef, - status: created.status, - updatedAt: created.updatedAt, - }; - registerThread(nativeSession, providerThread, null); - return providerThread; - }).pipe( - Effect.mapError( - (cause) => - new ProviderAdapter.ProviderAdapterEnsureThreadError({ - driver: OPENCODE_PROVIDER, - threadId: threadInput.threadId, - cause, - }), - ), - ), - resumeThread: (threadInput) => - Effect.gen(function* () { - const sessionId = nativeThreadId(threadInput.providerThread); - const response = yield* sdkCall("session.get", { sessionID: sessionId }, () => - client.session.get({ sessionID: sessionId }), - ); - const nativeSession = unwrapData("session.get", response); - const resumedAt = yield* DateTime.now; - const providerThread = { - ...threadInput.providerThread, - providerSessionId: input.providerSessionId, - status: "idle" as const, - updatedAt: dateTimeFromEpoch(nativeSession.time.updated, resumedAt), - }; - registerThread(nativeSession, providerThread, null); - return providerThread; - }).pipe( - Effect.mapError( - (cause) => - new ProviderAdapter.ProviderAdapterResumeThreadError({ - driver: OPENCODE_PROVIDER, - providerSessionId: input.providerSessionId, - providerThreadId: threadInput.providerThread.id, - cause, - }), - ), - ), - compactThread: (turnInput) => - runtimeSession.startTurn({ - ...turnInput, - message: { ...turnInput.message, text: "/compact" }, - }), - startTurn: (turnInput) => - Effect.gen(function* () { - if (nativeStreamFailure !== null) { - return yield* protocolError( - "OpenCode event stream has ended; reconnect the provider session before starting another turn.", - ); - } - const sessionId = nativeThreadId(turnInput.providerThread); - const state = threads.get(sessionId); - if (state === undefined) { - return yield* protocolError(`OpenCode session ${sessionId} is not registered`); - } - if (state.activeTurn !== null) { - return yield* protocolError( - `OpenCode provider thread ${turnInput.providerThread.id} already has an active turn`, - ); - } - const parsedModel = OpenCodeRuntime.parseOpenCodeModelSlug( - turnInput.modelSelection.model, - ); - if (parsedModel === null) { - return yield* protocolError( - `OpenCode model '${turnInput.modelSelection.model}' must use provider/model format`, - ); - } - const isCompaction = - turnInput.message.text.trim() === "/compact" && - turnInput.message.attachments.length === 0; - const parts = isCompaction ? [] : resolvePromptParts(turnInput); - const startedAt = yield* DateTime.now; - const syntheticNativeTurnId = `${sessionId}:attempt:${turnInput.attemptId}`; - const providerTurnId = idAllocator.derive.providerTurn({ - driver: OPENCODE_PROVIDER, - nativeTurnId: syntheticNativeTurnId, - }); - const providerTurn: OrchestrationV2ProviderTurn = { - id: providerTurnId, - providerThreadId: turnInput.providerThread.id, - nodeId: turnInput.rootNodeId, - runAttemptId: turnInput.attemptId, - nativeTurnRef: providerRef(syntheticNativeTurnId, "weak"), - ordinal: turnInput.providerTurnOrdinal, - status: "running", - startedAt, - completedAt: null, - }; - const admissionMessageId = yield* makeOpenCodeMessageId(); - // No Effect may be yielded between this check and installing the - // turn. If the event stream ended while IDs were being prepared, - // registering afterward would leave a running turn that the EOF - // handler had already finished scanning. - if (nativeStreamFailure !== null) { - return yield* protocolError( - "OpenCode event stream has ended; reconnect the provider session before starting another turn.", - ); - } - if (state.activeTurn !== null) { - return yield* protocolError( - `OpenCode provider thread ${turnInput.providerThread.id} already has an active turn`, - ); - } - const turn: ActiveOpenCodeTurn = { - isRoot: true, - threadId: turnInput.threadId, - runId: turnInput.runId, - rootNodeId: turnInput.rootNodeId, - appThread: turnInput.appThread, - modelSelection: turnInput.modelSelection, - runtimePolicy: turnInput.runtimePolicy, - providerTurnId, - providerThreadId: turnInput.providerThread.id, - providerTurnOrdinal: turnInput.providerTurnOrdinal, - runOrdinal: turnInput.runOrdinal, - runAttemptId: turnInput.attemptId, - startedAt, - itemOrdinals: new Map(), - usage: makeOpenCodeTurnTokenUsageAccumulator(), - parts: new Map(), - partIdsByMessage: new Map(), - toolNamesByCallId: new Map(), - providerTurn, - nextItemOrdinal: turnInput.providerTurnOrdinal * 100 + 1, - nativeUserMessageId: null, - admissionMessageId, - interrupted: false, - finalized: false, - planId: null, - admissionGeneration: state.nextAdmissionGeneration++, - admissionReconciliationGeneration: null, - admissionPending: !isCompaction, - admissionAccepted: isCompaction, - admissionMessageObserved: false, - idleDuringAdmission: false, - admissionSettled: Deferred.makeUnsafe(), - admissionAbortController: new AbortController(), - }; - if (turn.admissionMessageId !== null) - turn.usage.promptMessageIds.add(turn.admissionMessageId); - const admissionSettled = turn.admissionSettled; - const admissionAbortController = turn.admissionAbortController; - state.appThread = turnInput.appThread; - state.activeTurn = turn; - state.providerTurns.set(String(providerTurnId), providerTurn); - yield* emitProviderTurn(state, turn, "running", null); - yield* updateProviderThread(state, { - status: "active", - firstRunOrdinal: state.providerThread.firstRunOrdinal ?? turnInput.runOrdinal, - lastRunOrdinal: turnInput.runOrdinal, - }); - yield* updateProviderSession("running", null); - if (isCompaction) { - yield* sdkCall( - "session.summarize", - { sessionID: sessionId, ...parsedModel, auto: false }, - (signal) => - client.session.summarize( - { sessionID: sessionId, ...parsedModel, auto: false }, - { signal: AbortSignal.any([signal, admissionAbortController!.signal]) }, - ), - ).pipe( - Effect.tap(() => - turn.interrupted || turn.finalized || nativeStreamFailure !== null - ? Effect.void - : emitCompactionItem(state, turn), - ), - Effect.tap(() => - finalizeTurn(state, turn, turn.interrupted ? "interrupted" : "completed"), - ), - Effect.tapError((cause) => - finalizeTurn(state, turn, turn.interrupted ? "interrupted" : "failed", { - failure: makeProviderFailure({ cause, class: "provider_error" }), - }), - ), - Effect.ensuring(Deferred.succeed(admissionSettled, undefined)), - ); - return; - } - const systemPrompt = [ - orchestrationSystemPrompt, - buildRuntimeInstructions({ - harness: "OpenCode", - model: turnInput.modelSelection.model, - }), - ] - .filter(Boolean) - .join("\n\n"); - const agent = - getModelSelectionStringOptionValue(turnInput.modelSelection, "agent") ?? - (turnInput.runtimePolicy.interactionMode === "plan" ? "plan" : undefined); - const variant = getModelSelectionStringOptionValue( - turnInput.modelSelection, - "variant", - ); - yield* submitPrompt( - state, - turn, - { - sessionID: sessionId, - messageID: turn.admissionMessageId!, - model: parsedModel, - ...(agent === undefined ? {} : { agent }), - ...(variant === undefined ? {} : { variant }), - system: systemPrompt, - parts, - }, - admissionAbortController!, - ).pipe( - Effect.tapError((cause) => - admissionAbortController!.signal.aborted - ? Effect.void - : finalizeTurn(state, turn, "failed", { - failure: makeProviderFailure({ cause, class: "provider_error" }), - }), - ), - Effect.catch((cause) => - admissionAbortController!.signal.aborted ? Effect.void : Effect.fail(cause), - ), - Effect.ensuring( - Effect.all([ - Deferred.succeed(admissionSettled, undefined).pipe(Effect.ignore), - Effect.sync(() => { - if (turn.admissionAbortController === admissionAbortController) { - turn.admissionAbortController = null; - } - }), - ]).pipe(Effect.asVoid), - ), - ); - if (state.activeTurn === turn && !turn.finalized && !turn.interrupted) { - const admissionAction = advanceOpenCodePromptAdmission(turn, "accepted"); - if (admissionAction === "reconcile-idle") { - yield* reconcilePromptAdmission(state, turn); - } - } - }).pipe( - Effect.mapError( - (cause) => - new ProviderAdapter.ProviderAdapterTurnStartError({ - driver: OPENCODE_PROVIDER, - threadId: turnInput.threadId, - providerThreadId: turnInput.providerThread.id, - runId: turnInput.runId, - cause, - }), - ), - ), - steerTurn: (steerInput) => - Effect.gen(function* () { - const sessionId = nativeThreadId(steerInput.providerThread); - const state = threads.get(sessionId); - const turn = state?.activeTurn; - if ( - state === undefined || - turn === undefined || - turn === null || - turn.providerTurnId !== steerInput.providerTurnId || - turn.interrupted - ) { - return yield* protocolError( - `OpenCode turn ${steerInput.providerTurnId} is not active`, - ); - } - const parsedModel = OpenCodeRuntime.parseOpenCodeModelSlug(turn.modelSelection.model); - if (parsedModel === null) { - return yield* protocolError( - `OpenCode model '${turn.modelSelection.model}' must use provider/model format`, - ); - } - const text = providerMessageTextWithAttachmentPaths({ - text: steerInput.message.text, - attachments: steerInput.message.attachments, - resolveAttachmentPath: (attachment) => - resolveAttachmentPath({ - attachmentsDir: serverConfig.attachmentsDir, - attachment, - }), - }).trim(); - const files = OpenCodeRuntime.toOpenCodeFileParts({ - attachments: steerInput.message.attachments, - resolveAttachmentPath: (attachment) => - resolveAttachmentPath({ - attachmentsDir: serverConfig.attachmentsDir, - attachment, - }), - }); - if (text.length === 0 && files.length === 0) { - return yield* protocolError("OpenCode steering requires text or an attachment"); - } - const parts = [ - ...(text.length === 0 ? [] : [{ type: "text" as const, text }]), - ...files, - ]; - turn.admissionGeneration = state.nextAdmissionGeneration++; - turn.admissionMessageId = yield* makeOpenCodeMessageId(); - turn.admissionPending = true; - turn.admissionAccepted = false; - turn.admissionMessageObserved = false; - turn.idleDuringAdmission = false; - turn.admissionSettled = Deferred.makeUnsafe(); - turn.admissionAbortController = new AbortController(); - if (turn.admissionMessageId !== null) - turn.usage.promptMessageIds.add(turn.admissionMessageId); - const admissionSettled = turn.admissionSettled; - const admissionAbortController = turn.admissionAbortController; - yield* submitPrompt( - state, - turn, - { - sessionID: sessionId, - messageID: turn.admissionMessageId, - model: parsedModel, - parts, - }, - admissionAbortController!, - ).pipe( - Effect.ensuring( - Effect.all([ - Deferred.succeed(admissionSettled, undefined).pipe(Effect.ignore), - Effect.sync(() => { - if (turn.admissionAbortController === admissionAbortController) { - turn.admissionAbortController = null; - } - }), - ]).pipe(Effect.asVoid), - ), - ); - if (state.activeTurn === turn && !turn.finalized) { - const admissionAction = advanceOpenCodePromptAdmission(turn, "accepted"); - if (admissionAction === "reconcile-idle") { - yield* reconcilePromptAdmission(state, turn); - } - } - }).pipe( - Effect.mapError( - (cause) => - new ProviderAdapter.ProviderAdapterSteerRunError({ - driver: OPENCODE_PROVIDER, - providerThreadId: steerInput.providerThread.id, - providerTurnId: steerInput.providerTurnId, - cause, - }), - ), - ), - interruptTurn: (interruptInput) => - Effect.gen(function* () { - const sessionId = nativeThreadId(interruptInput.providerThread); - const state = threads.get(sessionId); - const turn = state?.activeTurn; - // Stop on a settled turn: a background task child can outlive - // it (busySessionIds), and aborting the descendants ends it. - if ( - (turn === undefined || turn === null) && - interruptInput.requestRuntimeRestart === true - ) { - for (const controller of commandControllers.get(sessionId) ?? []) - controller.abort(); - yield* abortDescendants(sessionId); - return; - } - if ( - turn === undefined || - turn === null || - turn.providerTurnId !== interruptInput.providerTurnId - ) { - return yield* protocolError( - `OpenCode turn ${interruptInput.providerTurnId} is not active`, - ); - } - turn.interrupted = true; - for (const controller of commandControllers.get(sessionId) ?? []) controller.abort(); - const admissionWasPending = turn.admissionPending; - cancelOpenCodePromptAdmission(turn, state!.nextAdmissionGeneration++); - turn.admissionAbortController?.abort(); - if (admissionWasPending) { - // Settle the cancelled request before sending the session abort. - // A timed-out local request must not prevent the definitive - // server-side abort below from running. - yield* Deferred.await(turn.admissionSettled).pipe( - Effect.timeout("10 seconds"), - Effect.ignore, - ); - } - yield* sdkCall("session.abort", { sessionID: sessionId }, (signal) => - client.session.abort({ sessionID: sessionId }, { signal }), - ).pipe( - Effect.timeout("10 seconds"), - // The turn can settle while the abort is in flight, and - // aborting an already-idle session fails. That stop still - // succeeded; only surface failures for a turn that is - // genuinely still running. - Effect.catch((cause) => - turn.finalized - ? Effect.void - : Effect.suspend(() => { - turn.interrupted = false; - return Effect.fail(cause); - }), - ), - ); - // Root abort does not stop child sessions. Report incomplete cleanup - // even if the root has already emitted its terminal event. - yield* abortDescendants(sessionId); - }).pipe( - Effect.mapError( - (cause) => - new ProviderAdapter.ProviderAdapterInterruptError({ - driver: OPENCODE_PROVIDER, - providerThreadId: interruptInput.providerThread.id, - providerTurnId: interruptInput.providerTurnId, - cause, - }), - ), - ), - respondToRuntimeRequest: (requestInput) => - Effect.gen(function* () { - const pending = pendingRequests.get(String(requestInput.requestId)); - if (pending === undefined) { - return yield* protocolError( - `No pending OpenCode request ${requestInput.requestId}`, - ); - } - if (pending.question !== undefined) { - if (requestInput.answers === undefined) { - return yield* protocolError( - `OpenCode question request ${requestInput.requestId} requires answers`, - ); - } - const answers = OpenCodeRuntime.toOpenCodeQuestionAnswers( - pending.question, - requestInput.answers, - ); - yield* sdkCall( - "question.reply", - { requestID: pending.nativeRequestId, answers }, - (signal) => - client.question.reply( - { - requestID: pending.nativeRequestId, - answers, - }, - { signal }, - ), - ).pipe(Effect.timeout("10 seconds")); - return; - } - if (requestInput.decision === undefined) { - return yield* protocolError( - `OpenCode approval request ${requestInput.requestId} requires a decision`, - ); - } - const reply = OpenCodeRuntime.toOpenCodePermissionReply(requestInput.decision); - yield* sdkCall( - "permission.reply", - { requestID: pending.nativeRequestId, reply }, - (signal) => - client.permission.reply( - { - requestID: pending.nativeRequestId, - reply, - }, - { signal }, - ), - ).pipe(Effect.timeout("10 seconds")); - }).pipe( - Effect.mapError( - (cause) => - new ProviderAdapter.ProviderAdapterRuntimeRequestResponseError({ - driver: OPENCODE_PROVIDER, - requestId: requestInput.requestId, - cause, - }), - ), - ), - readThreadSnapshot: (snapshotInput) => - readSnapshot(snapshotInput.providerThread).pipe( - Effect.mapError( - (cause) => - new ProviderAdapter.ProviderAdapterReadThreadSnapshotError({ - driver: OPENCODE_PROVIDER, - providerThreadId: snapshotInput.providerThread.id, - cause, - }), - ), - ), - rollbackThread: (rollbackInput) => - Effect.gen(function* () { - const sessionId = nativeThreadId(rollbackInput.providerThread); - const state = threads.get(sessionId); - if (state?.activeTurn !== null && state?.activeTurn !== undefined) { - return yield* protocolError( - `Cannot roll back OpenCode thread ${rollbackInput.providerThread.id} while a turn is active`, - ); - } - const response = yield* sdkCall("session.messages", { sessionID: sessionId }, () => - client.session.messages({ sessionID: sessionId }), - ); - const messages = unwrapData("session.messages", response); - let boundaryMessageId: string | undefined; - if (rollbackInput.target.type === "thread_start") { - boundaryMessageId = messages.find(({ info }) => info.role === "user")?.info.id; - } else { - boundaryMessageId = openCodeBoundaryAfterProviderTurn( - rollbackInput.providerThreadTurns, - rollbackInput.target.providerTurn.id, - ); - } - let retainedThread = rollbackInput.providerThread; - if (boundaryMessageId !== undefined) { - const boundaryIndex = messages.findIndex( - ({ info }) => info.id === boundaryMessageId, - ); - if (boundaryIndex < 0) - return yield* protocolError( - "The OpenCode rewind boundary is no longer available.", - ); - const fork = unwrapData( - "session.fork", - yield* sdkCall( - "session.fork", - { sessionID: sessionId, messageID: boundaryMessageId }, - () => - client.session.fork({ sessionID: sessionId, messageID: boundaryMessageId }), - ), - ); - const retained = unwrapData( - "session.messages", - yield* sdkCall("session.messages", { sessionID: fork.id }, () => - client.session.messages({ sessionID: fork.id }), - ), - ); - if (retained.length !== boundaryIndex) - return yield* protocolError( - "OpenCode did not preserve the requested rewind boundary.", - ); - yield* sdkCall("session.update", { sessionID: fork.id }, () => - client.session.update({ - sessionID: fork.id, - permission: openCodePermissionRules(input.runtimePolicy), - }), - ); - retainedThread = { - ...rollbackInput.providerThread, - nativeThreadRef: providerRef(fork.id), - }; - registerThread(fork, retainedThread, state?.appThread ?? null); - } - const snapshot = yield* readSnapshot(retainedThread); - return { - ...snapshot, - providerThread: { - ...snapshot.providerThread, - nativeConversationHeadRef: - rollbackInput.target.type === "provider_turn" - ? rollbackInput.target.providerTurn.nativeTurnRef - : null, - }, - }; - }).pipe( - Effect.mapError( - (cause) => - new ProviderAdapter.ProviderAdapterRollbackThreadError({ - driver: OPENCODE_PROVIDER, - providerThreadId: rollbackInput.providerThread.id, - checkpointId: rollbackInput.target.checkpointId, - cause, - }), - ), - ), - forkThread: (forkInput) => - Effect.gen(function* () { - const sourceSessionId = nativeThreadId(forkInput.sourceProviderThread); - const sourceState = threads.get(sourceSessionId); - if (sourceState?.activeTurn !== null && sourceState?.activeTurn !== undefined) { - return yield* protocolError( - `Cannot fork OpenCode thread ${forkInput.sourceProviderThread.id} while a turn is active`, - ); - } - let boundaryMessageId: string | undefined; - if (forkInput.providerTurnId !== undefined) { - const sourceTurns = forkInput.sourceProviderTurns ?? []; - const selected = sourceTurns.find((turn) => turn.id === forkInput.providerTurnId); - if (selected === undefined) { - return yield* protocolError( - `OpenCode fork boundary turn ${forkInput.providerTurnId} was not found`, - ); - } - boundaryMessageId = openCodeBoundaryAfterProviderTurn(sourceTurns, selected.id); - } - const response = yield* sdkCall( - "session.fork", - { - sessionID: sourceSessionId, - ...(boundaryMessageId === undefined ? {} : { messageID: boundaryMessageId }), - }, - () => - client.session.fork({ - sessionID: sourceSessionId, - ...(boundaryMessageId === undefined ? {} : { messageID: boundaryMessageId }), - }), - ); - const nativeSession = unwrapData("session.fork", response); - const forkedAt = yield* DateTime.now; - const providerThread = makeProviderThread({ - idAllocator, - providerInstanceId: options.instanceId, - providerSessionId: input.providerSessionId, - appThreadId: forkInput.targetThreadId, - ...(forkInput.ownerNodeId === undefined - ? {} - : { ownerNodeId: forkInput.ownerNodeId }), - nativeSession, - forkedFrom: { - providerThreadId: forkInput.sourceProviderThread.id, - ...(forkInput.providerTurnId === undefined - ? {} - : { providerTurnId: forkInput.providerTurnId }), - }, - now: forkedAt, - }); - registerThread(nativeSession, providerThread, null); - return providerThread; - }).pipe( - Effect.mapError( - (cause) => - new ProviderAdapter.ProviderAdapterForkThreadError({ - driver: OPENCODE_PROVIDER, - providerThreadId: forkInput.sourceProviderThread.id, - cause, - }), - ), - ), - }; - - return runtimeSession; - }, - (effect, input) => - effect.pipe( - Effect.mapError( - (cause) => - new ProviderAdapter.ProviderAdapterOpenSessionError({ - driver: OPENCODE_PROVIDER, - providerSessionId: input.providerSessionId, - cause, - }), - ), - ), - ), - }); -} - -export type OpenCodeAdapterV2DriverEnv = - | OpenCodeRuntime.OpenCodeRuntime - | IdAllocator.IdAllocatorV2 - | ProviderEventLoggers.ProviderEventLoggers - | ServerConfig.ServerConfig; - -export const OpenCodeAdapterV2Driver: ProviderAdapterDriver< - OpenCodeSettings, - OpenCodeAdapterV2DriverEnv -> = { - driverKind: OPENCODE_PROVIDER, - configSchema: OpenCodeSettingsSchema, - defaultConfig: (): OpenCodeSettings => DEFAULT_OPENCODE_SETTINGS, - create: Effect.fn("OpenCodeAdapterV2Driver.create")( - function* (input: ProviderAdapterDriverCreateInput) { - 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: input.instanceId, - settings: { ...input.config, enabled: input.enabled }, - environment: mergeProviderInstanceEnvironment(input.environment, hostEnvironment), - runtime: openCodeRuntime, - idAllocator, - serverConfig, - ...(providerEventLoggers.native === undefined - ? {} - : { nativeEventLogger: providerEventLoggers.native }), - }); - }, - (effect, input) => - effect.pipe( - Effect.mapError( - (cause) => - new ProviderAdapterDriverCreateError({ - driver: OPENCODE_PROVIDER, - instanceId: input.instanceId, - detail: "Failed to create OpenCode v2 adapter.", - cause, - }), - ), - ), - ), -}; - -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 deleted file mode 100644 index 1b01b2b8007cbe0c51ee47ce839ac6d970c5f649..0000000000000000000000000000000000000000 --- a/apps/server/src/orchestration-v2/Adapters/OpenCodeToolItems.ts +++ /dev/null @@ -1,170 +0,0 @@ -/** - * 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"; - -// Search results stay on the timeline wire, so keep their text a preview. -const SEARCH_PREVIEW_MAX_CHARS = 8_000; - -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"); - // A failed edit has no diff; keep its error where the diff would be. - const diffStr = - recordString(tool.completedMetadata, "diff", "patch") ?? - (base.status === "failed" && output?.trim() ? output : undefined); - 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"); - // OpenCode reports matches as plain text, so keep it as one result row - // under the searched path, like the ACP search projection. - const searchRoot = (recordString(input, "path", "filePath") ?? pattern)?.trim(); - return { - ...base, - title: - formatSearchToolLabel({ input, ...(pattern === undefined ? {} : { pattern }) }) ?? - base.title, - type: "file_search", - ...(pattern === undefined ? {} : { pattern }), - ...(!output?.trim() || searchRoot === undefined - ? {} - : { - results: [ - { fileName: searchRoot, preview: output.slice(0, SEARCH_PREVIEW_MAX_CHARS) }, - ], - }), - }; - } - case "web_search": { - const pattern = recordString(input, "query", "url", "pattern"); - const url = recordString(input, "url")?.trim(); - return { - ...base, - type: "web_search", - ...(pattern === undefined ? {} : { patterns: [pattern] }), - ...(!output?.trim() - ? {} - : { - results: [ - { - ...(url === undefined ? {} : { url }), - snippet: output.slice(0, SEARCH_PREVIEW_MAX_CHARS), - }, - ], - }), - }; - } - 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/CursorOrchestratorV2.live.test.ts b/apps/server/src/orchestration-v2/CursorOrchestratorV2.live.test.ts index d6dc569db2b684b7a5ace7e4074e46cb30c405ea..1c74ee18d7a925016f9d1f436449d7d55936c08e 100644 --- a/apps/server/src/orchestration-v2/CursorOrchestratorV2.live.test.ts +++ b/apps/server/src/orchestration-v2/CursorOrchestratorV2.live.test.ts @@ -30,8 +30,8 @@ import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as ModelManifest from "../provider/ModelManifest.ts"; import * as ProviderInstanceRegistryHydration from "../provider/ProviderInstanceRegistryHydration.ts"; import * as ProviderEventLoggers from "../provider/ProviderEventLoggers.ts"; -import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts"; -import * as OpenCodeServerLedger from "../provider/OpenCodeServerLedger.ts"; +import * as OpenCodeRuntime from "@t3tools/provider-opencode/server/OpenCodeRuntime"; +import * as OpenCodeServerLedger from "@t3tools/provider-opencode/server/OpenCodeServerLedger"; import * as ServerSettings from "../serverSettings.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; diff --git a/apps/server/src/orchestration-v2/GrokOrchestratorV2.live.test.ts b/apps/server/src/orchestration-v2/GrokOrchestratorV2.live.test.ts index b6b5eee97a0d99bc530a46d9da448c761d8015f0..15d29d30f8acd5eee121103d8f1aed08c6cbbd72 100644 --- a/apps/server/src/orchestration-v2/GrokOrchestratorV2.live.test.ts +++ b/apps/server/src/orchestration-v2/GrokOrchestratorV2.live.test.ts @@ -30,8 +30,8 @@ import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as ModelManifest from "../provider/ModelManifest.ts"; import * as ProviderInstanceRegistryHydration from "../provider/ProviderInstanceRegistryHydration.ts"; import * as ProviderEventLoggers from "../provider/ProviderEventLoggers.ts"; -import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts"; -import * as OpenCodeServerLedger from "../provider/OpenCodeServerLedger.ts"; +import * as OpenCodeRuntime from "@t3tools/provider-opencode/server/OpenCodeRuntime"; +import * as OpenCodeServerLedger from "@t3tools/provider-opencode/server/OpenCodeServerLedger"; import * as ServerSettings from "../serverSettings.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; diff --git a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts index 873ca407a7012a9f1827759a782817875d04cb23..3b9fa218d052172847a9c38808de417e623abf30 100644 --- a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts +++ b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts @@ -27,7 +27,7 @@ import { OPENCODE2_HTTP_PROTOCOL, OpenCode2OrchestratorReplayHarness, } from "./Adapters/OpenCode2AdapterV2.testkit.ts"; -import { OPENCODE_PROVIDER } from "./Adapters/OpenCodeAdapterV2.ts"; +import { OPENCODE_PROVIDER } from "@t3tools/provider-opencode/testing"; import { provideDeterministicTestRuntime } from "./testkit/DeterministicRuntime.ts"; import type { OrchestratorV2ScenarioStep } from "./testkit/OrchestratorScenario.ts"; import { runOrchestratorV2ProviderReplayScenario } from "./testkit/ProviderReplayHarness.ts"; diff --git a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts index 9f046abba25f3dde4656c5a962e084e65b356a3c..55d93ded6e6e74a6d90171b836cdc3825efb9f4e 100644 --- a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts +++ b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts @@ -55,9 +55,9 @@ import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as ModelManifest from "../provider/ModelManifest.ts"; import * as ProviderInstanceRegistryHydration from "../provider/ProviderInstanceRegistryHydration.ts"; import * as ProviderEventLoggers from "../provider/ProviderEventLoggers.ts"; -import * as OpenCode2Client from "../provider/opencode2/OpenCode2Client.ts"; -import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts"; -import * as OpenCodeServerLedger from "../provider/OpenCodeServerLedger.ts"; +import * as OpenCode2Client from "@t3tools/provider-opencode/server/v2/OpenCode2Client"; +import * as OpenCodeRuntime from "@t3tools/provider-opencode/server/OpenCodeRuntime"; +import * as OpenCodeServerLedger from "@t3tools/provider-opencode/server/OpenCodeServerLedger"; import * as ServerSettings from "../serverSettings.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 54127e20bc7a375a491d02d3d56a3a243ba4c27a..c85f11c568350e52a261804c45703fb49e03c3ee 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -121,7 +121,7 @@ import { subagentResultForRun, delegatedTaskProgress, subagentThreadTitle, -} from "./SubagentProjection.ts"; +} from "@t3tools/provider-core/server/subagentProjection"; import { forkableSourceRunStatusError, isForkableSourceRunStatus, diff --git a/apps/server/src/orchestration-v2/SubagentProjection.test.ts b/apps/server/src/orchestration-v2/SubagentProjection.test.ts index e2691e08e0db6680099c3957821cbab6ae9f4def..59eca69cf35a17dcb1686e1b72a35f2d4ba2e6ea 100644 --- a/apps/server/src/orchestration-v2/SubagentProjection.test.ts +++ b/apps/server/src/orchestration-v2/SubagentProjection.test.ts @@ -21,7 +21,7 @@ import { delegatedTaskProgress, subagentResultForRun, makeSubagentConversationArtifacts, -} from "./SubagentProjection.ts"; +} from "@t3tools/provider-core/server/subagentProjection"; import { emptyProjection } from "./ProjectionStore.ts"; diff --git a/apps/server/src/orchestration-v2/SubagentProjection.ts b/apps/server/src/orchestration-v2/SubagentProjection.ts deleted file mode 100644 index 7ee8d7c1aa8d9d0554697c6e7b5138991c51e9d5..0000000000000000000000000000000000000000 --- a/apps/server/src/orchestration-v2/SubagentProjection.ts +++ /dev/null @@ -1,259 +0,0 @@ -import type { - MessageId, - ModelSelection, - NodeId, - OrchestrationV2AppThread, - OrchestrationV2Actor, - OrchestrationV2ConversationMessage, - OrchestrationV2CreationSource, - OrchestrationV2ProviderRef, - OrchestrationV2Run, - OrchestrationV2ThreadProjection, - OrchestrationV2TurnItem, - ProviderInstanceId, - ProviderThreadId, - ProviderTurnId, - ThreadId, - TurnItemId, -} from "@t3tools/contracts"; -import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError"; -import * as DateTime from "effect/DateTime"; -import { isOrchestrationV2WorkActive } from "@t3tools/contracts"; - -function trimmed(value: string | null | undefined): string | undefined { - const result = value?.trim(); - return result && result.length > 0 ? result : undefined; -} - -export function subagentThreadTitle(input: { - readonly parentTitle: string; - readonly title?: string | null; - readonly prompt: string; - readonly ordinal: number; -}): string { - const detail = trimmed(input.title) ?? trimmed(input.prompt); - if (detail === undefined) { - return `${input.parentTitle} subagent ${input.ordinal}`; - } - const clipped = detail.length > 72 ? `${detail.slice(0, 69)}...` : detail; - return clipped; -} - -export function makeSubagentChildThread(input: { - readonly parentThread: OrchestrationV2AppThread; - readonly childThreadId: ThreadId; - readonly parentNodeId: NodeId; - readonly activeProviderThreadId: ProviderThreadId | null; - readonly providerInstanceId: ProviderInstanceId; - readonly modelSelection: ModelSelection; - readonly title: string; - readonly now: DateTime.Utc; - readonly createdBy: OrchestrationV2Actor; - readonly creationSource: OrchestrationV2CreationSource; -}): OrchestrationV2AppThread { - return { - ...input.parentThread, - createdBy: input.createdBy, - creationSource: input.creationSource, - id: input.childThreadId, - title: input.title, - linkedPullRequest: null, - pullRequests: [], - historyOrigin: undefined, - providerInstanceId: input.providerInstanceId, - modelSelection: input.modelSelection, - activeProviderThreadId: input.activeProviderThreadId, - lineage: { - parentThreadId: input.parentThread.id, - relationshipToParent: "subagent", - rootThreadId: input.parentThread.lineage.rootThreadId, - }, - forkedFrom: { - type: "node", - nodeId: input.parentNodeId, - }, - createdAt: input.now, - updatedAt: input.now, - archivedAt: null, - settledOverride: null, - settledAt: null, - snoozedUntil: null, - snoozedAt: null, - lastVisitedAt: null, - deletedAt: null, - }; -} - -export function makeSubagentConversationArtifacts(input: { - readonly messageId: MessageId; - readonly senderThreadId?: ThreadId; - readonly turnItemId: TurnItemId; - readonly threadId: ThreadId; - readonly rootNodeId: NodeId; - readonly providerThreadId: ProviderThreadId | null; - readonly providerTurnId: ProviderTurnId | null; - readonly nativeItemRef: OrchestrationV2ProviderRef | null; - readonly role: "user" | "assistant"; - readonly text: string; - readonly ordinal: number; - readonly now: DateTime.Utc; -}): { - readonly message: OrchestrationV2ConversationMessage; - readonly turnItem: OrchestrationV2TurnItem; -} { - const message: OrchestrationV2ConversationMessage = { - createdBy: "agent", - creationSource: "provider", - id: input.messageId, - threadId: input.threadId, - runId: null, - nodeId: input.rootNodeId, - role: input.role, - ...(input.role === "user" && input.senderThreadId !== undefined - ? { senderThreadId: input.senderThreadId } - : {}), - text: input.text, - attachments: [], - streaming: false, - createdAt: input.now, - updatedAt: input.now, - }; - const base = { - id: input.turnItemId, - threadId: input.threadId, - runId: null, - nodeId: input.rootNodeId, - providerThreadId: input.providerThreadId, - providerTurnId: input.providerTurnId, - nativeItemRef: input.nativeItemRef, - parentItemId: null, - ordinal: input.ordinal, - status: "completed" as const, - title: null, - startedAt: input.now, - completedAt: input.now, - updatedAt: input.now, - messageId: input.messageId, - text: input.text, - }; - const turnItem: OrchestrationV2TurnItem = - input.role === "user" - ? { - ...base, - createdBy: "agent", - creationSource: "provider", - type: "user_message", - ...(input.senderThreadId === undefined ? {} : { senderThreadId: input.senderThreadId }), - inputIntent: "turn_start", - attachments: [], - } - : { - ...base, - type: "assistant_message", - streaming: false, - }; - return { message, turnItem }; -} - -export function subagentResultForRun( - projection: Pick, - run: OrchestrationV2Run, -): { - readonly text: string; - readonly messageId: OrchestrationV2ConversationMessage["id"] | null; - readonly turnItemId: OrchestrationV2TurnItem["id"] | null; -} { - const message = - projection.messages - .filter( - (candidate) => - candidate.runId === run.id && - candidate.role === "assistant" && - candidate.text.trim().length > 0, - ) - .toSorted( - (left, right) => - DateTime.toEpochMillis(right.updatedAt) - DateTime.toEpochMillis(left.updatedAt), - )[0] ?? null; - const turnItem = - projection.turnItems - .filter( - ( - candidate, - ): candidate is Extract => - candidate.runId === run.id && - candidate.type === "assistant_message" && - candidate.text.trim().length > 0, - ) - .toSorted((left, right) => right.ordinal - left.ordinal)[0] ?? null; - const failure = - run.status === "failed" - ? projection.turnItems - .filter((item) => item.runId === run.id && item.type === "error") - .toSorted((left, right) => right.ordinal - left.ordinal)[0] - : undefined; - const text = - (failure?.type === "error" ? failure.failure.message : undefined) ?? - message?.text ?? - turnItem?.text ?? - (run.status === "completed" - ? "Child task completed without an assistant result." - : `Child task ended with status ${run.status}.`); - return { - text, - messageId: failure === undefined ? (message?.id ?? turnItem?.messageId ?? null) : null, - turnItemId: failure?.id ?? turnItem?.id ?? null, - }; -} - -/** A finished turn can still own live children or queued completion follow-ups. */ -export function delegatedTaskProgress(projection: { - readonly runs: OrchestrationV2ThreadProjection["runs"]; - readonly messages: ReadonlyArray< - Pick - >; - readonly subagents: ReadonlyArray< - Pick - >; - readonly providerThreads: ReadonlyArray< - Pick - >; -}) { - const terminal = (status: string) => - ["completed", "failed", "cancelled", "interrupted", "rolled_back"].includes(status); - const monitorRuns = new Set( - projection.messages - .filter((message) => message.notification?.source.kind === "monitor") - .map((message) => message.runId), - ); - const workRuns = projection.runs.filter( - (run) => !monitorRuns.has(run.id) && run.status !== "rolled_back", - ); - // A held queue waits for the user to resume it (after Stop, a restart, or a - // provider failure), so its runs are not work the task still owes. - const active = workRuns.some( - (run) => !terminal(run.status) && !(run.status === "queued" && run.queueHeld === true), - ); - const children = - projection.subagents.some( - (task) => - isOrchestrationV2WorkActive(task.status) || - // Publishing a child's result precedes scheduling its parent's wake. - // The parent still owes that follow-up even between those transactions. - task.completionDelivery?.state === "pending" || - task.completionDelivery?.state === "claimed", - ) || - projection.providerThreads.some((thread) => (thread.pendingBackgroundTasks?.length ?? 0) > 0); - const resultRun = workRuns - .filter((run) => terminal(run.status) && (run.startedAt !== null || run.ordinal === 1)) - .toSorted((a, b) => (runRanAfter(a, b) ? -1 : runRanAfter(b, a) ? 1 : 0))[0]; - return { - state: - active || resultRun === undefined - ? ("working" as const) - : children - ? ("waiting_for_children" as const) - : ("result_available" as const), - resultRun, - }; -} diff --git a/apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts b/apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts index 8a6490ef798fc4d4c32028bf854611a56250c290..3e33d5b2de417ec349ba7410d45098f4fe00da98 100644 --- a/apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts +++ b/apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts @@ -17,7 +17,7 @@ import { GrokAdapterV2Driver, type GrokAdapterV2DriverEnv } from "./Adapters/Gro import { OpenCodeAdapterV2Driver, type OpenCodeAdapterV2DriverEnv, -} from "./Adapters/OpenCodeAdapterV2.ts"; +} from "@t3tools/provider-opencode/server"; import { PiAdapterV2Driver, type PiAdapterV2DriverEnv } from "@t3tools/provider-pi/server"; import type { AnyProviderAdapterDriver } from "@t3tools/provider-core/server/adapterDriver"; diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.test.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.test.ts deleted file mode 100644 index 072e9e22df79873cdd532024ce4731c5245badc1..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.test.ts +++ /dev/null @@ -1,282 +0,0 @@ -// @effect-diagnostics nodeBuiltinImport:off -import * as NodeFS from "node:fs"; -import * as NodeOS from "node:os"; -import * as NodePath from "node:path"; - -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, it } from "@effect/vitest"; -import { ProviderInstanceId, type OpenCodeSettings } from "@t3tools/contracts"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; -import * as Layer from "effect/Layer"; -import * as TestClock from "effect/testing/TestClock"; -import { HttpClient } from "effect/http"; -import { ChildProcessSpawner } from "effect/process"; - -import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; -import * as ServerConfig from "../../config.ts"; -import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; -import * as ServerSettings from "../../serverSettings.ts"; -import * as ProviderEventLoggers from "../ProviderEventLoggers.ts"; -import * as ProviderMaintenance from "@t3tools/provider-core/server/maintenanceResolver"; -import * as OpenCodeRuntime from "../opencodeRuntime.ts"; -import { - OPENCODE_2_RESPONSES, - OPENCODE_2_WORKSPACE_RESPONSES, - replayOpenCodeServer, -} from "../testFixtures/opencodeProbeResponses.ts"; -import { OpenCodeDriver, openCodeUpdateFor } from "./OpenCodeDriver.ts"; -import * as ProviderHostLive from "../ProviderHostLive.ts"; - -const serverStarts: Array = []; -const reachedServer = (operation: string) => - Effect.sync(() => serverStarts.push(operation)).pipe( - Effect.andThen( - Effect.fail( - new OpenCodeRuntime.OpenCodeRuntimeError({ - operation, - detail: "reached a 1.x server path", - }), - ), - ), - ); -// Reports OpenCode 2 from `--version`; any attempt to reach a server is recorded and refused. -const openCode2Runtime = { - runOpenCodeCommand: () => Effect.succeed({ stdout: "opencode v2.0.18\n", stderr: "", code: 0 }), - startOpenCodeServerProcess: () => reachedServer("start"), - connectToOpenCodeServer: () => reachedServer("connect"), -} as unknown as OpenCodeRuntime.OpenCodeRuntimeShape; - -const layerDeps = Layer.mergeAll( - ServerConfig.layerTest(process.cwd(), { prefix: "t3-opencode-driver-" }), - IdAllocator.layer, - ServerSettings.layerTest(), - Layer.mock(BackgroundPolicy.BackgroundPolicy)({}), - Layer.succeed( - ProviderEventLoggers.ProviderEventLoggers, - ProviderEventLoggers.NoOpProviderEventLoggers, - ), - Layer.succeed(OpenCodeRuntime.OpenCodeRuntime, openCode2Runtime), -).pipe(Layer.provideMerge(NodeServices.layer)); -const layer = ProviderHostLive.layer.pipe(Layer.provideMerge(layerDeps)); - -const create = (config: Partial, http: HttpClient.HttpClient) => - OpenCodeDriver.create({ - instanceId: ProviderInstanceId.make("opencode-test"), - displayName: undefined, - environment: [], - enabled: true, - config: { ...OpenCodeDriver.defaultConfig(), ...config }, - }).pipe(Effect.provideService(HttpClient.HttpClient, http)); - -const noHttp = HttpClient.make(() => Effect.die("A local binary must not be probed over HTTP")); - -it.layer(layer)("OpenCodeDriver runtime selection", (it) => { - it.effect("never starts a 1.x server for an OpenCode 2 instance", () => - Effect.gen(function* () { - serverStarts.length = 0; - const instance = yield* create({}, noHttp); - // Text generation goes to the instance's 2.x server, which this test refuses to start. - yield* Effect.flip( - instance.textGeneration.generateThreadTitle({ - cwd: process.cwd(), - message: "hello", - modelSelection: { instanceId: instance.instanceId, model: "opencode/big-pickle" }, - }), - ); - assert.deepStrictEqual(serverStarts, ["start"]); - }).pipe(Effect.scoped), - ); - - it.effect("lists an OpenCode 2 workspace's own skills and commands from its server", () => - Effect.gen(function* () { - serverStarts.length = 0; - const requested: Array = []; - const server = replayOpenCodeServer( - { ...OPENCODE_2_RESPONSES, ...OPENCODE_2_WORKSPACE_RESPONSES }, - "secret", - requested, - ); - const instance = yield* create( - { serverUrl: "http://127.0.0.1:4096", serverPassword: "secret" }, - server, - ); - - const workspace = yield* instance.snapshotForCwd!("/work"); - assert.includeMembers( - workspace.skills.map((skill) => skill.name), - ["plum", "opencode"], - ); - assert.deepStrictEqual( - workspace.slashCommands.map((command) => command.name), - ["compact", "init", "review", "bee"], - ); - assert.include(requested, "/api/skill"); - assert.deepStrictEqual(serverStarts, []); - }).pipe(Effect.scoped), - ); - - it.effect("answers capability reads without waiting on an unreachable server", () => - Effect.gen(function* () { - const hang = HttpClient.make(() => Effect.never); - const instance = yield* create({ serverUrl: "http://127.0.0.1:9" }, hang); - - // No probe has succeeded yet, and the server never answers: the 1.x default applies. - const capabilities = yield* instance.orchestrationAdapter - .getCapabilities() - .pipe(Effect.forkChild); - yield* Effect.yieldNow; - assert.isDefined(capabilities.pollUnsafe()); - yield* Fiber.join(capabilities); - }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), - ); -}); - -// npm links `/bin/opencode` into the package that installed it: -// `opencode-ai` for 1.x and `@opencode/cli` (whose bin is `bin/opencode.exe`) for 2.x. -const npmGlobalInstall = (prefix: string, packagePath: ReadonlyArray, bin: string) => { - const target = NodePath.join(prefix, "lib", "node_modules", ...packagePath, "bin", bin); - NodeFS.mkdirSync(NodePath.dirname(target), { recursive: true }); - NodeFS.writeFileSync(target, "#!/bin/sh\n"); - NodeFS.chmodSync(target, 0o755); - const link = NodePath.join(prefix, "bin", "opencode"); - NodeFS.mkdirSync(NodePath.dirname(link), { recursive: true }); - NodeFS.symlinkSync(target, link); - return link; -}; -const resolveUpdate = (generation: "v1" | "v2", binaryPath: string) => - ProviderMaintenance.resolveProviderMaintenanceCapabilitiesEffect(openCodeUpdateFor(generation), { - binaryPath, - env: { PATH: "" }, - }).pipe( - Effect.provideService( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => Effect.die("resolving an update must not spawn")), - ), - Effect.provide(NodeServices.layer), - ); - -it.effect.skipIf(HostProcessPlatform.defaultValue() === "win32")( - "updates an npm-global OpenCode 2 install as @opencode/cli and a 1.x one as opencode-ai", - () => - Effect.gen(function* () { - const root = NodeFS.realpathSync( - NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-opencode-update-")), - ); - const v2Prefix = NodePath.join(root, "v2"); - const v1Prefix = NodePath.join(root, "v1"); - const v2 = yield* resolveUpdate( - "v2", - npmGlobalInstall(v2Prefix, ["@opencode", "cli"], "opencode.exe"), - ); - assert.deepStrictEqual(v2.update?.args, [ - "install", - "-g", - "--prefix", - v2Prefix, - "--allow-scripts=@opencode/cli", - "@opencode/cli@latest", - ]); - const v1 = yield* resolveUpdate( - "v1", - npmGlobalInstall(v1Prefix, ["opencode-ai"], "opencode"), - ); - assert.strictEqual(v1.update?.args.at(-1), "opencode-ai@latest"); - // A 1.x install is never offered 2.x's package, or the reverse. - assert.isNull( - (yield* resolveUpdate("v2", NodePath.join(v1Prefix, "bin", "opencode"))).update, - ); - assert.isNull( - (yield* resolveUpdate("v1", NodePath.join(v2Prefix, "bin", "opencode"))).update, - ); - }), -); - -// The update the driver offers follows the version the binary reports now. -const versionOutput: { current: string | undefined } = { current: undefined }; -const versionProbes: Array = []; -const changingRuntime = { - runOpenCodeCommand: ({ binaryPath }: { readonly binaryPath: string }) => - Effect.sync(() => versionProbes.push(binaryPath)).pipe( - Effect.andThen( - versionOutput.current === undefined - ? Effect.fail( - new OpenCodeRuntime.OpenCodeRuntimeError({ - operation: "version", - detail: "no version", - }), - ) - : Effect.succeed({ stdout: versionOutput.current, stderr: "", code: 0 }), - ), - ), - startOpenCodeServerProcess: () => reachedServer("start"), - connectToOpenCodeServer: () => reachedServer("connect"), -} as unknown as OpenCodeRuntime.OpenCodeRuntimeShape; -const layerUpdateDeps = Layer.mergeAll( - ServerConfig.layerTest(process.cwd(), { prefix: "t3-opencode-driver-update-" }), - IdAllocator.layer, - ServerSettings.layerTest(), - Layer.mock(BackgroundPolicy.BackgroundPolicy)({}), - Layer.succeed( - ProviderEventLoggers.ProviderEventLoggers, - ProviderEventLoggers.NoOpProviderEventLoggers, - ), - Layer.succeed(OpenCodeRuntime.OpenCodeRuntime, changingRuntime), -).pipe(Layer.provideMerge(NodeServices.layer)); -const layerUpdate = ProviderHostLive.layer.pipe(Layer.provideMerge(layerUpdateDeps)); - -it.layer(layerUpdate)("OpenCodeDriver updates", (it) => { - it.effect("never runs the binary for a disabled instance's update check", () => - Effect.gen(function* () { - versionProbes.length = 0; - versionOutput.current = "opencode v2.0.18\n"; - const instance = yield* OpenCodeDriver.create({ - instanceId: ProviderInstanceId.make("opencode-disabled"), - displayName: undefined, - environment: [], - enabled: false, - config: { ...OpenCodeDriver.defaultConfig(), binaryPath: "opencode" }, - }).pipe(Effect.provideService(HttpClient.HttpClient, noHttp)); - const maintenance = yield* instance.snapshot.resolveMaintenance(); - yield* instance.snapshot.resolveMaintenance({ fresh: true }); - assert.isNull(maintenance.update); - assert.deepStrictEqual(versionProbes, []); - }).pipe(Effect.scoped), - ); - - it.effect.skipIf(HostProcessPlatform.defaultValue() === "win32")( - "offers no package update for an unknown version and follows a changed one on a fresh read", - () => - Effect.gen(function* () { - const root = NodeFS.realpathSync( - NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-opencode-driver-update-")), - ); - const binaryPath = npmGlobalInstall(NodePath.join(root, "v1"), ["opencode-ai"], "opencode"); - const instance = yield* OpenCodeDriver.create({ - instanceId: ProviderInstanceId.make("opencode-update"), - displayName: undefined, - environment: [], - enabled: true, - config: { ...OpenCodeDriver.defaultConfig(), binaryPath }, - }).pipe(Effect.provideService(HttpClient.HttpClient, noHttp)); - - // The version could not be read: which package owns the binary is unknown. - versionOutput.current = undefined; - const unknown = yield* instance.snapshot.resolveMaintenance({ fresh: true }); - assert.isNull(unknown.update); - assert.isNull(unknown.packageName); - - versionOutput.current = "1.18.32\n"; - const v1 = yield* instance.snapshot.resolveMaintenance({ fresh: true }); - assert.strictEqual(v1.packageName, "opencode-ai"); - - // The same path now reports 2.x (reinstalled in place): a fresh read re-probes - // and never offers 1.x's package for it. - versionOutput.current = "opencode v2.0.18\n"; - const v2 = yield* instance.snapshot.resolveMaintenance({ fresh: true }); - assert.strictEqual(v2.packageName, "@opencode/cli"); - assert.isNull(v2.update); - }).pipe(Effect.scoped), - ); -}); diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.ts deleted file mode 100644 index 83347d86a87a6540183f81003a3fe4576c4778e9..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.ts +++ /dev/null @@ -1,542 +0,0 @@ -/** - * OpenCodeDriver — `ProviderDriver` for the OpenCode runtime. - * - * Mirrors the Codex / Claude drivers: a plain value whose `create()` - * bundles `snapshot` / `adapter` / `textGeneration` closures over the - * per-instance `OpenCodeSettings`. - * - * Two instances with different `serverUrl`s therefore talk to independent - * OpenCode servers; when no `serverUrl` is set, the adapter + text-generation - * shares spin up their own scoped child processes, and those child - * processes are released when the registry scope closes. - * - * @module provider/Drivers/OpenCodeDriver - */ -import { OpenCodeSettings, ProviderDriverKind } from "@t3tools/contracts"; -import * as Crypto from "effect/Crypto"; -import * as Deferred from "effect/Deferred"; -import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; -import * as FileSystem from "effect/FileSystem"; -import * as Path from "effect/Path"; -import * as Schema from "effect/Schema"; -import * as Stream from "effect/Stream"; -import { HttpClient } from "effect/http"; -import { ChildProcessSpawner } from "effect/process"; - -import * as OpenCode2TextGeneration from "../../textGeneration/OpenCode2TextGeneration.ts"; -import { makeOpenCodeTextGeneration } from "../../textGeneration/OpenCodeTextGeneration.ts"; -import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost"; -import * as ServerConfig from "../../config.ts"; -import * as OpenCodeAdapterV2 from "../../orchestration-v2/Adapters/OpenCodeAdapterV2.ts"; -import * as OpenCode2AdapterV2 from "../../orchestration-v2/Adapters/OpenCode2AdapterV2.ts"; -import type { ProviderAdapterV2Shape } from "@t3tools/provider-core/server/ProviderAdapter"; -import type { TextGeneration } from "../../textGeneration/TextGeneration.ts"; -import { ProviderDriverError } from "../Errors.ts"; -import { readOpenCodeGoUsageLimits } from "../openCodeUsageLimits.ts"; -import { - checkOpenCodeProviderStatus, - loadOpenCode2Workspace, - makeOpenCode2ModelLoader, - makePendingOpenCodeProvider, - openCode2CommandsToServerProviderSlashCommands, - openCode2SkillsToServerProviderSkills, - openCodeSkillsToServerProviderSkills, - openCodeCommandsToServerProviderSlashCommands, -} from "../OpenCodeProvider.ts"; -import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; -import * as OpenCodeRuntime from "../opencodeRuntime.ts"; -import { - makeOpenCodeRuntimeProbe, - 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, - type ProviderInstance, -} from "@t3tools/provider-core/server/driver"; -import { withInstanceIdentity } from "@t3tools/provider-core/server/instanceIdentity"; -import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; -import { - enrichProviderSnapshotWithVersionAdvisory, - makeCachedProviderMaintenanceResolution, - makeManualOnlyProviderMaintenanceCapabilities, - makePackageManagedProviderMaintenanceResolver, - normalizeCommandPath, - resolveProviderMaintenanceCapabilitiesEffect, -} from "@t3tools/provider-core/server/maintenanceResolver"; -import { - haveProviderSnapshotSettingsChanged, - makeProviderSnapshotSettingsSource, - type ProviderSnapshotSettings, -} from "@t3tools/provider-core/server/snapshotSettings"; -const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings); - -const DRIVER_KIND = ProviderDriverKind.make("opencode"); - -function isOpenCodeNativeCommandPath(commandPath: string): boolean { - const normalized = normalizeCommandPath(commandPath); - return ( - normalized.endsWith("/.opencode/bin/opencode") || - normalized.endsWith("/.opencode/bin/opencode.exe") - ); -} - -/** - * OpenCode 1.x ships as `opencode-ai` and 2.x as `@opencode/cli`. An install is - * only ever updated within its own package: T3 never moves a 1.x install onto - * 2.x or back, since 2.x converts the shared database in place. - */ -export const openCodeUpdateFor = (generation: ProbedOpenCode["generation"]) => - makePackageManagedProviderMaintenanceResolver({ - provider: DRIVER_KIND, - npmPackageName: generation === "v2" ? "@opencode/cli" : "opencode-ai", - nativeUpdate: { - args: ["upgrade"], - isCommandPath: isOpenCodeNativeCommandPath, - }, - }); - -type OpenCodeRuntimeProbe = Effect.Success< - ReturnType> ->; - -/** 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, - paths: { readonly v1: Effect.Effect; readonly v2: Effect.Effect }, -): Effect.Effect { - return probed.pipe( - Effect.orElseSucceed(() => undefined), - 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 each server is spoken to in its own protocol. - */ -function selectOpenCodeRuntimeAdapter(input: { - readonly probe: OpenCodeRuntimeProbe; - readonly v1: ProviderAdapterV2Shape; - readonly v2: ProviderAdapterV2Shape; -}): ProviderAdapterV2Shape { - 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: () => Effect.flatMap(hot, (adapter) => adapter.getCapabilities()), - planSelectionTransition: (transition) => - Effect.flatMap(hot, (adapter) => adapter.planSelectionTransition(transition)), - openSession: (session) => - Effect.flatMap(pick(input.probe.get), (adapter) => adapter.openSession(session)), - }; -} - -/** Text generation runs on the server the instance's probe detected, each in its own protocol. */ -function selectOpenCodeRuntimeTextGeneration( - probe: OpenCodeRuntimeProbe, - v1: TextGeneration["Service"], - v2: TextGeneration["Service"], -): TextGeneration["Service"] { - return { - generateCommitMessage: (input) => - byOpenCodeRuntime(probe.get, { - v1: v1.generateCommitMessage(input), - v2: v2.generateCommitMessage(input), - }), - generatePrContent: (input) => - byOpenCodeRuntime(probe.get, { - v1: v1.generatePrContent(input), - v2: v2.generatePrContent(input), - }), - generateBranchName: (input) => - byOpenCodeRuntime(probe.get, { - v1: v1.generateBranchName(input), - v2: v2.generateBranchName(input), - }), - generateThreadTitle: (input) => - byOpenCodeRuntime(probe.get, { - v1: v1.generateThreadTitle(input), - v2: v2.generateThreadTitle(input), - }), - }; -} - -export type OpenCodeDriverEnv = - | OpenCodeAdapterV2.OpenCodeAdapterV2DriverEnv - | ProviderHost - | ChildProcessSpawner.ChildProcessSpawner - | Crypto.Crypto - | FileSystem.FileSystem - | HttpClient.HttpClient - | OpenCodeRuntime.OpenCodeRuntime - | Path.Path - | ServerConfig.ServerConfig; - -export const OpenCodeDriver: ProviderDriver = { - driverKind: DRIVER_KIND, - metadata: { - displayName: "OpenCode", - supportsMultipleInstances: true, - }, - configSchema: OpenCodeSettings, - defaultConfig: (): OpenCodeSettings => decodeOpenCodeSettings({}), - create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => - Effect.gen(function* () { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; - const pathService = yield* Path.Path; - const openCodeRuntime = yield* OpenCodeRuntime.OpenCodeRuntime; - const serverConfig = yield* ServerConfig.ServerConfig; - const httpClient = yield* HttpClient.HttpClient; - const crypto = yield* Crypto.Crypto; - const host = yield* ProviderHost; - const processEnv = mergeProviderInstanceEnvironment(environment); - const continuationIdentity = defaultProviderContinuationIdentity({ - driverKind: DRIVER_KIND, - instanceId, - }); - const stampIdentity = withInstanceIdentity({ - instanceId, - driverKind: DRIVER_KIND, - displayName, - accentColor, - continuationGroupKey: continuationIdentity.continuationKey, - }); - const effectiveConfig = { ...config, enabled } satisfies OpenCodeSettings; - const runtimeProbe = yield* makeOpenCodeRuntimeProbe( - probeOpenCodeRuntime(effectiveConfig, processEnv).pipe( - Effect.provideService(HttpClient.HttpClient, httpClient), - Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, openCodeRuntime), - ), - ); - // Updates follow the installed package, which the version probe names. An - // unknown version offers no package update, since a guess could move a - // 2.x install onto 1.x's package or the reverse. A disabled instance never - // runs its binary, so it has no version and offers no update. - const noUpdate = makeManualOnlyProviderMaintenanceCapabilities({ - provider: DRIVER_KIND, - packageName: null, - }); - const maintenanceFor = (probed: typeof runtimeProbe.get) => - probed.pipe( - Effect.map((result) => result.generation), - Effect.option, - Effect.flatMap((generation) => - Option.isNone(generation) - ? Effect.succeed(noUpdate) - : resolveProviderMaintenanceCapabilitiesEffect(openCodeUpdateFor(generation.value), { - binaryPath: effectiveConfig.binaryPath, - env: processEnv, - }), - ), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, pathService), - ); - const cachedMaintenance = yield* makeCachedProviderMaintenanceResolution( - maintenanceFor(runtimeProbe.get), - ); - // A fresh read (an update about to run, or a manual refresh) re-probes, so - // a binary replaced by the other major version gets its own package. - const resolveMaintenance = (options?: { readonly fresh?: boolean }) => - !effectiveConfig.enabled - ? Effect.succeed(noUpdate) - : options?.fresh === true - ? runtimeProbe.refresh.pipe( - Effect.ignore, - Effect.andThen(cachedMaintenance({ fresh: true })), - ) - : cachedMaintenance(); - const openCodeV1Adapter = yield* OpenCodeAdapterV2.OpenCodeAdapterV2Driver.create({ - instanceId, - displayName, - accentColor, - environment, - enabled, - config, - }).pipe( - Effect.mapError( - (cause) => - new ProviderDriverError({ - driver: DRIVER_KIND, - instanceId, - detail: "Failed to build OpenCode orchestration adapter.", - cause, - }), - ), - ); - // 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 loadOpenCode2Models = yield* makeOpenCode2ModelLoader( - openCode2Server.withConnection((connection) => - connection.client.model.list({ location: { directory: serverConfig.cwd } }).pipe( - Effect.map((models) => models.data), - Effect.mapError( - (cause) => - new OpenCodeRuntime.OpenCodeRuntimeError({ - operation: "model.list", - detail: "The OpenCode server could not list its models.", - cause, - }), - ), - ), - ), - ); - // A 2.x server lists skills and commands per directory, so one server - // answers every workspace. Its event stream says when a directory it had - // not served yet finished scanning. - const listOpenCode2Workspace = (cwd: string) => - openCode2Server.withConnection(({ client, events }) => - Effect.gen(function* () { - const location = { directory: cwd }; - const scanned = yield* Deferred.make(); - const pending = new Set(["command.updated", "skill.updated"]); - const stream = yield* events.pipe(Effect.option); - if (stream._tag === "Some") { - yield* stream.value.pipe( - Stream.runForEach((event) => - "location" in event && - event.location?.directory === cwd && - pending.delete(event.type) && - pending.size === 0 - ? Deferred.succeed(scanned, undefined) - : Effect.void, - ), - Effect.ignore, - Effect.forkScoped, - ); - } - return yield* loadOpenCode2Workspace( - Effect.all( - { - skills: client.skill.list({ location }).pipe(Effect.map((list) => list.data)), - commands: client.command.list({ location }).pipe(Effect.map((list) => list.data)), - }, - { concurrency: "unbounded" }, - ), - Deferred.await(scanned), - ); - }).pipe(Effect.scoped), - ); - const serverOwner = yield* OpenCodeServerOwner.make({ - binaryPath: effectiveConfig.binaryPath, - directory: serverConfig.cwd, - ...(effectiveConfig.serverPassword - ? { serverPassword: effectiveConfig.serverPassword } - : {}), - environment: processEnv, - }); - const textGeneration = selectOpenCodeRuntimeTextGeneration( - runtimeProbe, - yield* makeOpenCodeTextGeneration(effectiveConfig).pipe( - Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner), - ), - yield* OpenCode2TextGeneration.make().pipe( - Effect.provideService(OpenCode2Server.OpenCode2Server, openCode2Server), - ), - ); - - const checkProvider = Effect.all( - { - provider: checkOpenCodeProviderStatus( - effectiveConfig, - serverConfig.cwd, - runtimeProbe.refresh, - loadOpenCode2Models, - ), - usageLimits: readOpenCodeGoUsageLimits({ - enabled: effectiveConfig.enabled, - serverUrl: effectiveConfig.serverUrl, - environment: processEnv, - }), - }, - { concurrency: "unbounded" }, - ).pipe( - Effect.map(({ provider, usageLimits }) => ({ ...provider, usageLimits })), - Effect.map(stampIdentity), - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, pathService), - Effect.provideService(HttpClient.HttpClient, httpClient), - Effect.provideService(Crypto.Crypto, crypto), - Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner), - Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, openCodeRuntime), - ); - // NOTE: the local branch intentionally uses the shared SDK server - // instead of `opencode debug skill` (loadSkillsFromCli). The CLI writes - // its full JSON inventory to stdout, but the Bun-compiled binary does - // not flush more than one 64KB pipe buffer to a non-TTY stdout, so the - // piped output arrives truncated and unparseable — which degrades to an - // empty skill list and poisons the workspace snapshot the `$` picker - // reads. The SDK `app.skills` endpoint honors the per-request directory - // and returns complete results regardless of size. - const loadWorkspaceInventory = ( - client: Parameters[0], - ) => - Effect.all( - { - skills: openCodeRuntime.loadOpenCodeSkills(client), - commands: OpenCodeRuntime.loadOpenCodeCommands(client).pipe( - Effect.timeout("10 seconds"), - Effect.orElseSucceed(() => []), - ), - }, - { concurrency: "unbounded" }, - ); - const loadWorkspaceForCwd = (cwd: string) => - effectiveConfig.serverUrl.trim().length > 0 - ? Effect.scoped( - Effect.gen(function* () { - const server = yield* openCodeRuntime.connectToOpenCodeServer({ - binaryPath: effectiveConfig.binaryPath, - directory: cwd, - serverUrl: effectiveConfig.serverUrl, - ...(effectiveConfig.serverPassword - ? { serverPassword: effectiveConfig.serverPassword } - : {}), - environment: processEnv, - }); - const client = openCodeRuntime.createOpenCodeSdkClient({ - baseUrl: server.url, - directory: cwd, - ...(effectiveConfig.serverPassword - ? { serverPassword: effectiveConfig.serverPassword } - : {}), - }); - return yield* loadWorkspaceInventory(client); - }), - ) - : serverOwner.withServer((server) => - loadWorkspaceInventory( - openCodeRuntime.createOpenCodeSdkClient({ - baseUrl: server.url, - directory: cwd, - ...(server.serverPassword !== undefined - ? { serverPassword: server.serverPassword } - : {}), - }), - ), - ); - - const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, host.settings); - const snapshot = yield* makeManagedServerProvider>( - { - resolveMaintenance, - getSettings: snapshotSettings.getSettings, - streamSettings: snapshotSettings.streamSettings, - haveSettingsChanged: haveProviderSnapshotSettingsChanged, - checkProviderOnSettingsChange: () => false, - refreshOnInterval: false, - initialSnapshot: (settings) => - makePendingOpenCodeProvider(settings.provider).pipe(Effect.map(stampIdentity)), - checkProvider, - enrichSnapshot: ({ settings, snapshot, publishSnapshot }) => - resolveMaintenance().pipe( - Effect.flatMap((maintenanceCapabilities) => - enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities, { - enableProviderUpdateChecks: settings.enableProviderUpdateChecks, - }), - ), - Effect.provideService(HttpClient.HttpClient, httpClient), - Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), - ), - }, - ).pipe( - Effect.mapError( - (cause) => - new ProviderDriverError({ - driver: DRIVER_KIND, - instanceId, - detail: `Failed to build OpenCode snapshot: ${cause.message ?? String(cause)}`, - cause, - }), - ), - ); - - return { - instanceId, - driverKind: DRIVER_KIND, - continuationIdentity, - displayName, - accentColor, - enabled, - snapshot, - snapshotForCwd: (cwd) => - !effectiveConfig.enabled - ? snapshot.getSnapshot - : byOpenCodeRuntime(runtimeProbe.get, { - v2: Effect.all([ - snapshot.getSnapshot, - listOpenCode2Workspace(cwd).pipe( - Effect.timeout("20 seconds"), - Effect.mapError( - (cause) => - new ProviderDriverError({ - driver: DRIVER_KIND, - instanceId, - detail: `Failed to list OpenCode commands and skills for '${cwd}'`, - cause, - }), - ), - ), - ]).pipe( - Effect.map(([machineSnapshot, { skills, commands }]) => ({ - ...machineSnapshot, - skills: openCode2SkillsToServerProviderSkills(skills), - slashCommands: openCode2CommandsToServerProviderSlashCommands(commands), - })), - ), - v1: Effect.all([ - snapshot.getSnapshot, - loadWorkspaceForCwd(cwd).pipe(Effect.timeout("20 seconds")), - ]).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/ModelManifest.ts b/apps/server/src/provider/ModelManifest.ts index 2f0f65cf5390378795c54ea892d73b213fe8ce15..37c2bbec91b0a235ab1547cbae9e61ddba23e1a8 100644 --- a/apps/server/src/provider/ModelManifest.ts +++ b/apps/server/src/provider/ModelManifest.ts @@ -33,7 +33,7 @@ import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; import { HttpClient, HttpClientResponse } from "effect/http"; -import { writeFileStringAtomically } from "../atomicWrite.ts"; +import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; import { ServerConfig } from "../config.ts"; import * as ServerSettings from "../serverSettings.ts"; import { hasValidClaudeManifestAdapters } from "./ClaudeModelManifest.ts"; diff --git a/apps/server/src/provider/NativeProtocolLogging.ts b/apps/server/src/provider/NativeProtocolLogging.ts deleted file mode 100644 index a85833b0e862bb15b6c6b3b4db9ea0edec8d601e..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/NativeProtocolLogging.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { errorTag } from "@t3tools/shared/observability"; - -export function structuralProtocolMethod(value: string): string { - return value.length <= 128 && /^[A-Za-z][A-Za-z0-9._:/-]*$/.test(value) ? value : "unknown"; -} - -/** - * Retain only bounded structural metadata for native protocol diagnostics. - * Provider payload values may contain prompts, credentials, or arbitrarily - * large tool output and must never be copied into the observability stream. - */ -export function summarizeNativeProtocolPayload( - payload: unknown, -): Readonly> { - if (payload === null) return { valueType: "null" }; - if (typeof payload === "string") { - return { valueType: "string", byteLength: new TextEncoder().encode(payload).byteLength }; - } - if (payload instanceof Uint8Array) { - return { valueType: "bytes", byteLength: payload.byteLength }; - } - if (Array.isArray(payload)) { - return { valueType: "array", itemCount: payload.length }; - } - if (typeof payload !== "object") { - return { valueType: typeof payload }; - } - - try { - const record = payload as Record; - return { - valueType: "object", - fieldCount: Object.keys(record).length, - ...(typeof record._tag === "string" ? { messageTag: errorTag(record) } : {}), - ...(typeof record.tag === "string" ? { method: structuralProtocolMethod(record.tag) } : {}), - }; - } catch { - return { valueType: "object" }; - } -} diff --git a/apps/server/src/provider/OpenCode2Client.events.test.ts b/apps/server/src/provider/OpenCode2Client.events.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..25f246d89acb3594977529c717589652369c7d15 --- /dev/null +++ b/apps/server/src/provider/OpenCode2Client.events.test.ts @@ -0,0 +1,196 @@ +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/http"; +import { describe } from "vite-plus/test"; + +import * as OpenCode2Client from "@t3tools/provider-opencode/server/v2/OpenCode2Client"; + +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 layerServing = (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 layerServingThenSilent = (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( + layerServingThenSilent([{ 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( + layerServing([ + { + 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(layerServing(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(layerServing(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/OpenCodeProvider.test.ts b/apps/server/src/provider/OpenCodeProvider.test.ts deleted file mode 100644 index 1341e851a80cba39aea9e6f1c5b74f4f998194ef..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/OpenCodeProvider.test.ts +++ /dev/null @@ -1,873 +0,0 @@ -import * as NodeAssert from "node:assert/strict"; - -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { it } from "@effect/vitest"; -import * as Deferred from "effect/Deferred"; -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 TestClock from "effect/testing/TestClock"; -import { HttpClient, HttpClientResponse } from "effect/http"; -import { beforeEach } from "vite-plus/test"; - -import { OpenCodeSettings } from "@t3tools/contracts"; -import * as ServerConfig from "../config.ts"; -import * as OpenCodeRuntime from "./opencodeRuntime.ts"; -import * as OpenCodeServerOwner from "./OpenCodeServerOwner.ts"; -import { - checkOpenCodeProviderStatus, - loadOpenCode2Workspace, - makeOpenCode2ModelLoader, - type OpenCode2Model, - type OpenCode2Workspace, - openCode2CommandsToServerProviderSlashCommands, - openCode2SkillsToServerProviderSkills, - openCodeCommandsToServerProviderSlashCommands, -} from "./OpenCodeProvider.ts"; -import { readOpenCodeGoUsageLimits } from "./openCodeUsageLimits.ts"; -import { probeOpenCodeRuntime } from "./opencodeVersionProbe.ts"; -import { - OPENCODE_1_RESPONSES, - OPENCODE_2_RESPONSES, - replayOpenCodeServer, -} from "./testFixtures/opencodeProbeResponses.ts"; -const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings); - -const DEFAULT_VERSION_STDOUT = "opencode 1.14.19\n"; - -it.effect("reads Go limits with the instance's XDG credentials and preserves reset times", () => - Effect.gen(function* () { - const resetsAt = "2026-09-17T12:00:00.000Z"; - const limits = yield* readOpenCodeGoUsageLimits({ - enabled: true, - serverUrl: "", - environment: { XDG_DATA_HOME: "/instance/data", OPENCODE_API_KEY: "env-key" }, - }).pipe( - Effect.provideService( - FileSystem.FileSystem, - FileSystem.makeNoop({ - readFileString: (path) => { - NodeAssert.equal(path, "/instance/data/opencode/auth.json"); - return Effect.succeed( - JSON.stringify({ "opencode-go": { type: "api", key: "instance-key" } }), - ); - }, - }), - ), - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make((request) => { - NodeAssert.equal(request.url, "https://opencode.ai/zen/go/v1/usage"); - NodeAssert.equal(request.headers.authorization, "Bearer instance-key"); - return Effect.succeed( - HttpClientResponse.fromWeb( - request, - Response.json({ - usage: { - rolling: { percent: 0, resetsAt }, - weekly: { percent: 10, resetsAt }, - monthly: { percent: 125, resetsAt }, - }, - }), - ), - ); - }), - ), - Effect.provide(NodeServices.layer), - ); - NodeAssert.equal(limits.unavailable, undefined); - NodeAssert.equal( - limits.credentialFingerprint, - // SHA-256 of "opencode-go\0instance-key". - "aba48e85c981a8edc1c9fb4575121accc7235fa55d0f8689f66f153de5566a37", - ); - NodeAssert.deepEqual( - limits.windows.map(({ kind, usedPercent, resetsAt: reset }) => ({ - kind, - usedPercent, - reset, - })), - [ - { kind: "session", usedPercent: 0, reset: resetsAt }, - { kind: "weekly", usedPercent: 10, reset: resetsAt }, - { kind: "monthly", usedPercent: 100, reset: resetsAt }, - ], - ); - }), -); - -it.effect("does not read local credentials for external or disabled OpenCode instances", () => - Effect.gen(function* () { - for (const settings of [ - { enabled: true, serverUrl: "https://remote.example" }, - { enabled: false, serverUrl: "" }, - ]) { - const limits = yield* readOpenCodeGoUsageLimits({ ...settings, environment: {} }).pipe( - Effect.provideService( - FileSystem.FileSystem, - FileSystem.makeNoop({ - readFileString: () => Effect.die("unexpected credential read"), - }), - ), - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make(() => Effect.die("unexpected usage request")), - ), - Effect.provide(NodeServices.layer), - ); - NodeAssert.equal(limits.unavailable?.reason, "unsupported"); - } - }), -); - -it.effect("keeps Go entitlement absence distinct from failed or malformed usage responses", () => - Effect.gen(function* () { - for (const [status, reason] of [ - [403, "unsupported"], - [401, "probeFailed"], - [200, "probeFailed"], - ] as const) { - const limits = yield* readOpenCodeGoUsageLimits({ - enabled: true, - serverUrl: "", - environment: { - OPENCODE_AUTH_CONTENT: '{"opencode-go":{"type":"api","key":"inline-key"}}', - }, - }).pipe( - Effect.provideService( - FileSystem.FileSystem, - FileSystem.makeNoop({ - readFileString: () => Effect.die("inline credentials must bypass disk"), - }), - ), - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make((request) => - Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({}, { status }))), - ), - ), - Effect.provide(NodeServices.layer), - ); - NodeAssert.equal(limits.unavailable?.reason, reason); - NodeAssert.equal(limits.credentialFingerprint, undefined); - NodeAssert.deepEqual(limits.windows, []); - } - }), -); - -/** - * The legacy `OpenCodeProviderLive` Layer + `OpenCodeProvider` service tag - * are deleted. The snapshot-producing logic they wrapped now lives in the - * standalone `checkOpenCodeProviderStatus(settings, cwd)` Effect, which - * drivers call directly when building their per-instance snapshot - * `ServerProviderShape`. Tests mirror that shape: build a settings payload, - * invoke the check, assert on the returned snapshot. - */ - -const runtimeMock = { - state: { - runVersionError: null as Error | null, - runVersionPending: false, - versionStdout: DEFAULT_VERSION_STDOUT, - inventoryError: null as Error | null, - connectionError: null as Error | null, - inventoryCwd: null as string | null, - closeCalls: 0, - sdkClientInputs: [] as Array<{ - baseUrl: string; - directory: string; - serverPassword?: string; - }>, - inventory: { - providerList: { connected: [] as string[], all: [] as unknown[], default: {} }, - agents: [] as unknown[], - skills: [] as unknown[], - } as unknown, - }, - reset() { - this.state.runVersionError = null; - this.state.runVersionPending = false; - this.state.versionStdout = DEFAULT_VERSION_STDOUT; - this.state.inventoryError = null; - this.state.connectionError = null; - this.state.inventoryCwd = null; - this.state.closeCalls = 0; - this.state.sdkClientInputs.length = 0; - this.state.inventory = { - providerList: { connected: [], all: [] as unknown[], default: {} }, - agents: [] as unknown[], - skills: [] as unknown[], - }; - }, -}; - -const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = { - startOpenCodeServerProcess: ({ serverPassword, environment }) => - Effect.gen(function* () { - yield* Effect.addFinalizer(() => - Effect.sync(() => { - runtimeMock.state.closeCalls += 1; - }), - ); - const effectiveServerPassword = OpenCodeRuntime.resolveOpenCodeServerPassword({ - external: false, - ...(serverPassword !== undefined ? { serverPassword } : {}), - ...(environment !== undefined ? { environment } : {}), - }); - return { - url: "http://127.0.0.1:4301", - ...(effectiveServerPassword !== undefined - ? { serverPassword: effectiveServerPassword } - : {}), - version: "1.14.19", - isRunning: Effect.succeed(true), - exitCode: Effect.never, - }; - }), - connectToOpenCodeServer: ({ serverUrl, serverPassword }) => - Effect.gen(function* () { - if (runtimeMock.state.connectionError) { - return yield* new OpenCodeRuntime.OpenCodeRuntimeError({ - operation: "global.health", - detail: runtimeMock.state.connectionError.message, - cause: runtimeMock.state.connectionError, - }); - } - if (!serverUrl) { - yield* Effect.addFinalizer(() => - Effect.sync(() => { - runtimeMock.state.closeCalls += 1; - }), - ); - } - return { - url: serverUrl ?? "http://127.0.0.1:4301", - ...(serverPassword ? { serverPassword } : {}), - version: "1.14.19", - exitCode: null, - external: Boolean(serverUrl), - }; - }), - runOpenCodeCommand: () => - runtimeMock.state.runVersionPending - ? Effect.never - : runtimeMock.state.runVersionError - ? Effect.fail( - new OpenCodeRuntime.OpenCodeRuntimeError({ - operation: "runOpenCodeCommand", - detail: runtimeMock.state.runVersionError.message, - cause: runtimeMock.state.runVersionError, - }), - ) - : Effect.succeed({ stdout: runtimeMock.state.versionStdout, stderr: "", code: 0 }), - createOpenCodeSdkClient: (input) => { - runtimeMock.state.sdkClientInputs.push(input); - return {} as unknown as ReturnType< - OpenCodeRuntime.OpenCodeRuntimeShape["createOpenCodeSdkClient"] - >; - }, - loadOpenCodeInventory: () => - runtimeMock.state.inventoryError - ? Effect.fail( - new OpenCodeRuntime.OpenCodeRuntimeError({ - operation: "loadOpenCodeInventory", - detail: runtimeMock.state.inventoryError.message, - cause: runtimeMock.state.inventoryError, - }), - ) - : Effect.succeed(runtimeMock.state.inventory as OpenCodeRuntime.OpenCodeInventory), - loadInventoryFromCli: ({ cwd }) => { - runtimeMock.state.inventoryCwd = cwd; - return runtimeMock.state.inventoryError - ? Effect.fail( - new OpenCodeRuntime.OpenCodeRuntimeError({ - operation: "loadInventoryFromCli", - detail: runtimeMock.state.inventoryError.message, - cause: runtimeMock.state.inventoryError, - }), - ) - : Effect.succeed(runtimeMock.state.inventory as OpenCodeRuntime.OpenCodeInventory); - }, - loadOpenCodeSkills: () => Effect.succeed([]), - loadSkillsFromCli: () => Effect.succeed([]), -}; - -beforeEach(() => { - runtimeMock.reset(); -}); - -it("keeps native and MCP commands while preserving compaction and separate skills", () => { - NodeAssert.deepEqual( - openCodeCommandsToServerProviderSlashCommands([ - { name: "review", description: "Review changes", source: "command", hints: ["$ARGUMENTS"] }, - { name: "review", source: "command", hints: [] }, - { name: "compact", source: "command", hints: [] }, - { name: "skill", source: "skill", hints: [] }, - { name: "mcp:search", source: "mcp", hints: ["query"] }, - ]).slice(1), - [ - { name: "review", description: "Review changes", input: { hint: "$ARGUMENTS" } }, - { name: "mcp:search", input: { hint: "query" } }, - ], - ); -}); - -const layerTest = Layer.succeed(OpenCodeRuntime.OpenCodeRuntime, OpenCodeRuntimeTestDouble).pipe( - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), - Layer.provideMerge(NodeServices.layer), -); - -const makeOpenCodeSettings = (overrides?: Partial): OpenCodeSettings => - decodeOpenCodeSettings({ - enabled: true, - binaryPath: "opencode", - serverUrl: "", - serverPassword: "", - customModels: [], - ...overrides, - }); - -const checkProvider = Effect.fn("checkProvider")(function* ( - settings: OpenCodeSettings, - cwd = process.cwd(), - environment?: NodeJS.ProcessEnv, - server = replayOpenCodeServer(OPENCODE_1_RESPONSES, settings.serverPassword), - openCode2Models: Effect.Effect< - ReadonlyArray, - OpenCodeRuntime.OpenCodeRuntimeError - > = Effect.succeed([]), -) { - return yield* Effect.scoped( - Effect.gen(function* () { - const serverOwner = yield* OpenCodeServerOwner.make({ - binaryPath: settings.binaryPath, - directory: cwd, - ...(settings.serverPassword ? { serverPassword: settings.serverPassword } : {}), - ...(environment ? { environment } : {}), - }); - const probe = probeOpenCodeRuntime(settings, environment).pipe( - Effect.provideService(HttpClient.HttpClient, server), - Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, OpenCodeRuntimeTestDouble), - ); - return yield* checkOpenCodeProviderStatus(settings, cwd, probe, openCode2Models).pipe( - Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner), - ); - }), - ); -}); - -it.layer(layerTest)("checkOpenCodeProviderStatus", (it) => { - it.effect("shows a codex-style missing binary message", () => - Effect.gen(function* () { - runtimeMock.state.runVersionError = new Error("spawn opencode ENOENT"); - const snapshot = yield* checkProvider(makeOpenCodeSettings()); - - NodeAssert.equal(snapshot.status, "error"); - NodeAssert.equal(snapshot.installed, false); - NodeAssert.equal( - snapshot.message, - "OpenCode CLI (`opencode`) is not installed or not on PATH.", - ); - }), - ); - - it.effect("hides generic Effect.tryPromise text for local CLI probe failures", () => - Effect.gen(function* () { - runtimeMock.state.runVersionError = new Error("An error occurred in Effect.tryPromise"); - const snapshot = yield* checkProvider(makeOpenCodeSettings()); - - NodeAssert.equal(snapshot.status, "error"); - NodeAssert.equal(snapshot.installed, true); - NodeAssert.equal(snapshot.message, "Failed to execute OpenCode CLI health check."); - }), - ); - - it.effect("times out a hanging local CLI version probe", () => - Effect.gen(function* () { - runtimeMock.state.runVersionPending = true; - const probeFiber = yield* checkProvider(makeOpenCodeSettings()).pipe(Effect.forkChild); - - yield* Effect.yieldNow; - yield* TestClock.adjust("4 seconds"); - const snapshot = yield* Fiber.join(probeFiber); - - NodeAssert.equal(snapshot.status, "error"); - NodeAssert.equal(snapshot.installed, true); - NodeAssert.equal( - snapshot.message, - "Failed to execute OpenCode CLI health check: OpenCode CLI version probe timed out after 4 seconds.", - ); - }).pipe(Effect.provide(TestClock.layer())), - ); - - it.effect("emits OpenCode variant defaults so trait picker can resolve a visible selection", () => - Effect.gen(function* () { - runtimeMock.state.inventory = { - providerList: { - connected: ["openai"], - all: [ - { - id: "openai", - name: "OpenAI", - models: { - "gpt-5.4": { - id: "gpt-5.4", - name: "GPT-5.4", - variants: { - none: {}, - low: {}, - medium: {}, - high: {}, - xhigh: {}, - }, - }, - }, - }, - ], - default: {}, - }, - agents: [ - { name: "build", hidden: false, mode: "primary" }, - { name: "plan", hidden: false, mode: "primary" }, - ], - }; - - const snapshot = yield* checkProvider(makeOpenCodeSettings()); - const model = snapshot.models.find((entry) => entry.slug === "openai/gpt-5.4"); - - NodeAssert.ok(model); - const variantDescriptor = model.capabilities?.optionDescriptors?.find( - (descriptor) => descriptor.id === "variant" && descriptor.type === "select", - ); - NodeAssert.ok(variantDescriptor && variantDescriptor.type === "select"); - NodeAssert.equal( - variantDescriptor.options.find((option) => option.isDefault === true)?.id, - "medium", - ); - const agentDescriptor = model.capabilities?.optionDescriptors?.find( - (descriptor) => descriptor.id === "agent" && descriptor.type === "select", - ); - NodeAssert.ok(agentDescriptor && agentDescriptor.type === "select"); - NodeAssert.equal( - agentDescriptor.options.find((option) => option.isDefault === true)?.id, - "build", - ); - }), - ); - - it.effect("includes OpenCode skills in the provider snapshot", () => - Effect.gen(function* () { - runtimeMock.state.inventory = { - providerList: { - connected: ["openai"], - all: [ - { - id: "openai", - name: "OpenAI", - models: { - "gpt-5.4": { - id: "gpt-5.4", - name: "GPT-5.4", - variants: {}, - }, - }, - }, - ], - default: {}, - }, - agents: [], - skills: [ - { - name: "openclaw-review", - description: "Review OpenClaw workflow changes.", - location: "/Users/test/.agents/skills/openclaw-review/SKILL.md", - }, - { - name: "openclaw-triage", - description: "Triage OpenClaw routing issues.", - location: "/Users/test/.agents/skills/openclaw-triage/SKILL.md", - }, - { - name: "missing-location", - description: "This incomplete SDK row should be skipped.", - location: "", - }, - ], - }; - - const snapshot = yield* checkProvider(makeOpenCodeSettings()); - - NodeAssert.deepEqual( - snapshot.skills.map((skill) => ({ - name: skill.name, - path: skill.path, - enabled: skill.enabled, - shortDescription: skill.shortDescription, - })), - [ - { - name: "openclaw-review", - path: "/Users/test/.agents/skills/openclaw-review/SKILL.md", - enabled: true, - shortDescription: "Review OpenClaw workflow changes.", - }, - { - name: "openclaw-triage", - path: "/Users/test/.agents/skills/openclaw-triage/SKILL.md", - enabled: true, - shortDescription: "Triage OpenClaw routing issues.", - }, - ], - ); - }), - ); - - it.effect("loads local inventory from a scoped OpenCode server", () => - Effect.gen(function* () { - yield* checkProvider(makeOpenCodeSettings({ serverPassword: "secret-password" })); - - NodeAssert.deepEqual(runtimeMock.state.sdkClientInputs, [ - { - baseUrl: "http://127.0.0.1:4301", - directory: process.cwd(), - serverPassword: "secret-password", - }, - ]); - NodeAssert.equal(runtimeMock.state.closeCalls, 1); - NodeAssert.equal(runtimeMock.state.inventoryCwd, null); - }), - ); - - it.effect("lists a local OpenCode 2 binary's models in Full access only, never via 1.x", () => - Effect.gen(function* () { - runtimeMock.state.versionStdout = "opencode v2.0.18\n"; - const snapshot = yield* checkProvider( - makeOpenCodeSettings(), - process.cwd(), - undefined, - undefined, - Effect.succeed([ - { providerID: "opencode", id: "big-pickle", name: "Big Pickle", variants: [] }, - { - providerID: "opencode", - id: "space-bunny-free", - name: "Space Bunny Free", - variants: [{ id: "low" }, { id: "medium" }, { id: "high" }], - }, - ]), - ); - - NodeAssert.equal(snapshot.status, "ready"); - NodeAssert.equal(snapshot.version, "2.0.18"); - NodeAssert.deepEqual(snapshot.supportedRuntimeModes, [ - "approval-required", - "auto-accept-edits", - "auto", - "full-access", - ]); - // Plan mode runs as OpenCode's plan agent, and every session can compact. - NodeAssert.equal(snapshot.showInteractionModeToggle, true); - NodeAssert.deepEqual( - snapshot.slashCommands.map((command) => command.name), - ["compact"], - ); - NodeAssert.deepEqual( - snapshot.models.map((model) => model.slug), - ["opencode/big-pickle", "opencode/space-bunny-free"], - ); - const variant = snapshot.models.find((model) => model.slug === "opencode/space-bunny-free") - ?.capabilities?.optionDescriptors?.[0]; - NodeAssert.deepEqual( - variant?.type === "select" ? variant.options.map((option) => option.id) : [], - ["low", "medium", "high"], - ); - NodeAssert.equal(runtimeMock.state.sdkClientInputs.length, 0); - }), - ); - - it.effect("reports a failed OpenCode 2 model list without the server's response", () => - Effect.gen(function* () { - runtimeMock.state.versionStdout = "opencode v2.0.18\n"; - const snapshot = yield* checkProvider( - makeOpenCodeSettings(), - process.cwd(), - undefined, - undefined, - Effect.fail( - new OpenCodeRuntime.OpenCodeRuntimeError({ - operation: "model.list", - detail: 'status=500 body={"token":"leaked-response-body"}', - }), - ), - ); - - NodeAssert.equal(snapshot.status, "error"); - NodeAssert.equal(snapshot.message, "OpenCode could not load its model list."); - // A failed catalog read still leaves the session able to compact. - NodeAssert.deepEqual( - snapshot.slashCommands.map((command) => command.name), - ["compact"], - ); - }), - ); - - it.effect("uses an environment-only password for local inventory", () => - Effect.gen(function* () { - yield* checkProvider(makeOpenCodeSettings(), process.cwd(), { - OPENCODE_SERVER_PASSWORD: "environment-password", - }); - - NodeAssert.deepEqual(runtimeMock.state.sdkClientInputs, [ - { - baseUrl: "http://127.0.0.1:4301", - directory: process.cwd(), - serverPassword: "environment-password", - }, - ]); - }), - ); - - it.effect("uses the settings password when local environment auth differs", () => - Effect.gen(function* () { - yield* checkProvider( - makeOpenCodeSettings({ serverPassword: "settings-password" }), - process.cwd(), - { OPENCODE_SERVER_PASSWORD: "environment-password" }, - ); - - NodeAssert.equal(runtimeMock.state.sdkClientInputs[0]?.serverPassword, "settings-password"); - }), - ); - - it.effect("reports local model inventory failures without treating them as empty", () => - Effect.gen(function* () { - runtimeMock.state.inventoryError = new Error("opencode models failed"); - const snapshot = yield* checkProvider(makeOpenCodeSettings()); - - NodeAssert.equal(snapshot.status, "error"); - NodeAssert.equal(snapshot.installed, true); - NodeAssert.equal(snapshot.models.length, 0); - NodeAssert.equal( - snapshot.message, - "Failed to load OpenCode provider inventory: opencode models failed", - ); - }), - ); -}); - -it.layer(layerTest)("checkOpenCodeProviderStatus with configured server URL", (it) => { - it.effect("does not send a local environment password to a configured server", () => - Effect.gen(function* () { - const snapshot = yield* checkProvider( - makeOpenCodeSettings({ serverUrl: "http://127.0.0.1:9999" }), - process.cwd(), - { OPENCODE_SERVER_PASSWORD: "local-secret" }, - ); - - NodeAssert.equal(snapshot.version, "1.14.19"); - NodeAssert.deepEqual(runtimeMock.state.sdkClientInputs, [ - { - baseUrl: "http://127.0.0.1:9999", - directory: process.cwd(), - }, - ]); - }), - ); - - 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", - serverPassword: "secret-password", - }); - const snapshot = yield* checkProvider( - settings, - process.cwd(), - undefined, - replayOpenCodeServer(OPENCODE_2_RESPONSES, "secret-password"), - ); - - NodeAssert.equal(snapshot.status, "warning"); - NodeAssert.equal(snapshot.version, "2.0.18"); - NodeAssert.deepEqual(snapshot.supportedRuntimeModes, [ - "approval-required", - "auto-accept-edits", - "auto", - "full-access", - ]); - NodeAssert.equal(runtimeMock.state.sdkClientInputs.length, 0); - }), - ); - - it.effect("reports a rejected OpenCode 2 password as an auth error, not a version", () => - Effect.gen(function* () { - const snapshot = yield* checkProvider( - makeOpenCodeSettings({ serverUrl: "http://127.0.0.1:9999", serverPassword: "wrong" }), - process.cwd(), - undefined, - replayOpenCodeServer(OPENCODE_2_RESPONSES, "secret-password"), - ); - - NodeAssert.equal(snapshot.status, "error"); - NodeAssert.equal( - snapshot.message, - "OpenCode server rejected authentication. Check the server URL and password.", - ); - }), - ); - - it.effect("rejects an unsupported server before loading inventory", () => - Effect.gen(function* () { - runtimeMock.state.connectionError = new Error( - "OpenCode v1.14.18 is too old. Upgrade to v1.14.19 or newer.", - ); - const snapshot = yield* checkProvider( - makeOpenCodeSettings({ serverUrl: "http://127.0.0.1:9999" }), - ); - - NodeAssert.equal(snapshot.status, "error"); - NodeAssert.equal(snapshot.models.length, 0); - NodeAssert.match(snapshot.message ?? "", /v1\.14\.18 is too old/); - NodeAssert.equal(runtimeMock.state.sdkClientInputs.length, 0); - }), - ); - - it.effect("surfaces a friendly auth error for configured servers", () => - Effect.gen(function* () { - runtimeMock.state.connectionError = new Error("401 Unauthorized"); - const snapshot = yield* checkProvider( - makeOpenCodeSettings({ - serverUrl: "http://127.0.0.1:9999", - serverPassword: "secret-password", - }), - ); - - NodeAssert.equal(snapshot.status, "error"); - NodeAssert.equal(snapshot.installed, true); - NodeAssert.equal( - snapshot.message, - "OpenCode server rejected authentication. Check the server URL and password.", - ); - }), - ); - - it.effect("surfaces a friendly connection error for configured servers", () => - Effect.gen(function* () { - runtimeMock.state.connectionError = new Error( - "fetch failed: connect ECONNREFUSED 127.0.0.1:9999", - ); - const snapshot = yield* checkProvider( - makeOpenCodeSettings({ - serverUrl: "http://127.0.0.1:9999", - serverPassword: "secret-password", - }), - ); - - NodeAssert.equal(snapshot.status, "error"); - NodeAssert.equal(snapshot.installed, true); - NodeAssert.equal( - snapshot.message, - "Couldn't reach the configured OpenCode server at http://127.0.0.1:9999. Check that the server is running and the URL is correct.", - ); - }), - ); -}); - -const bigPickle: OpenCode2Model = { - providerID: "opencode", - id: "big-pickle", - name: "Big Pickle", - variants: [], -}; - -it.effect("waits for a fresh OpenCode 2 server to list its models", () => - Effect.gen(function* () { - // A fresh server lists nothing until its catalog loads. - const replies: Array> = [[], [], [bigPickle]]; - const load = yield* makeOpenCode2ModelLoader(Effect.sync(() => replies.shift() ?? [bigPickle])); - const fiber = yield* load.pipe(Effect.forkChild); - yield* TestClock.adjust("1 second"); - NodeAssert.deepEqual(yield* Fiber.join(fiber), [bigPickle]); - }).pipe(Effect.provide(TestClock.layer())), -); - -it.effect("keeps the last OpenCode 2 model list while a fresh server's stays empty", () => - Effect.gen(function* () { - let listed: ReadonlyArray = [bigPickle]; - const load = yield* makeOpenCode2ModelLoader(Effect.sync(() => listed)); - NodeAssert.deepEqual(yield* load, [bigPickle]); - listed = []; - const fiber = yield* load.pipe(Effect.forkChild); - yield* TestClock.adjust("6 seconds"); - NodeAssert.deepEqual(yield* Fiber.join(fiber), [bigPickle]); - }).pipe(Effect.provide(TestClock.layer())), -); - -// What 2.0.18 lists for a directory once it has scanned it (live, 2026-09-29). -const builtinCommands = [ - { name: "init", description: "guided AGENTS.md setup" }, - { name: "review", description: "review changes [commit|branch|pr], defaults to uncommitted" }, -]; -const builtinSkills = [ - { id: "opencode", name: "OpenCode", path: "/builtin/opencode.md" }, - { id: "report", name: "Report", path: "/builtin/report.md" }, -]; -const scanned: OpenCode2Workspace = { - commands: [...builtinCommands, { name: "hello", description: "Say hello to the workspace" }], - skills: [ - ...builtinSkills, - { - id: "greet", - name: "greet", - path: "/work/.opencode/skills/greet/SKILL.md", - description: "Greets the user with the secret word MANGO.", - }, - ], -}; - -it.effect("reads a fresh OpenCode 2 directory again once its scan has ended", () => - Effect.gen(function* () { - // A directory the server has not served yet lists no commands until its scan ends. - const reads: Array = [{ commands: [], skills: [] }, scanned]; - const scanEnded = yield* Deferred.make(); - const load = yield* loadOpenCode2Workspace( - Effect.sync(() => reads.shift()!), - Deferred.await(scanEnded), - ).pipe(Effect.forkChild); - yield* Effect.yieldNow; - NodeAssert.equal(reads.length, 1); - yield* Deferred.succeed(scanEnded, undefined); - NodeAssert.deepEqual(yield* Fiber.join(load), scanned); - }), -); - -it.effect("takes a served OpenCode 2 directory's first listing as is", () => - Effect.gen(function* () { - const workspace = yield* loadOpenCode2Workspace(Effect.succeed(scanned), Effect.never); - NodeAssert.deepEqual(workspace, scanned); - }), -); - -it("lists an OpenCode 2 directory's skills by id and its commands after /compact", () => { - NodeAssert.deepEqual( - openCode2SkillsToServerProviderSkills(scanned.skills).map((skill) => [ - skill.name, - skill.displayName, - skill.path, - ]), - [ - ["greet", undefined, "/work/.opencode/skills/greet/SKILL.md"], - ["opencode", "OpenCode", "/builtin/opencode.md"], - ["report", "Report", "/builtin/report.md"], - ], - ); - NodeAssert.deepEqual( - openCode2CommandsToServerProviderSlashCommands([ - ...scanned.commands, - { name: "compact", description: "a workspace command named like T3's own" }, - ]).map((command) => command.name), - ["compact", "init", "review", "hello"], - ); -}); diff --git a/apps/server/src/provider/OpenCodeProvider.ts b/apps/server/src/provider/OpenCodeProvider.ts deleted file mode 100644 index 0b638c47821c70497a9a87bd1c4e54becdd6a247..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/OpenCodeProvider.ts +++ /dev/null @@ -1,747 +0,0 @@ -import { - type ModelCapabilities, - type OpenCodeSettings, - type ServerProviderModel, - type ServerProviderSkill, - type ServerProviderSlashCommand, -} from "@t3tools/contracts"; -import * as Cause from "effect/Cause"; -import * as Data from "effect/Data"; -import * as DateTime from "effect/DateTime"; -import * as Effect from "effect/Effect"; -import * as Schedule from "effect/Schedule"; - -import { createModelCapabilities } from "@t3tools/shared/model"; -import { compareSemverVersions } from "@t3tools/shared/semver"; -import { - buildServerProvider, - COMPACT_SLASH_COMMAND, - nonEmptyTrimmed, - providerModelsFromSettings, - type ServerProviderDraft, -} from "@t3tools/provider-core/server/snapshotProbe"; -import * as OpenCodeRuntime from "./opencodeRuntime.ts"; -import type { ProbedOpenCode } from "./opencodeVersionProbe.ts"; -import type { Agent, ProviderListResponse } from "@opencode-ai/sdk/v2"; -import * as OpenCodeServerOwner from "./OpenCodeServerOwner.ts"; - -const OPENCODE_PRESENTATION = { - displayName: "OpenCode", - showInteractionModeToggle: false, -} as const; - -class OpenCodeProbeError extends Data.TaggedError("OpenCodeProbeError")<{ - readonly cause?: unknown; - readonly detail: string; -}> {} - -function normalizeProbeMessage(message: string): string | undefined { - const trimmed = message.trim(); - if (trimmed.length === 0) { - return undefined; - } - if ( - trimmed === "An error occurred in Effect.tryPromise" || - trimmed === "An error occurred in Effect.try" - ) { - return undefined; - } - return trimmed; -} - -function normalizedErrorMessage(cause: unknown): string | undefined { - if (cause instanceof OpenCodeProbeError) { - return normalizeProbeMessage(cause.detail); - } - - if (!(cause instanceof Error)) { - return undefined; - } - - return normalizeProbeMessage(cause.message); -} - -function formatOpenCodeProbeError(input: { - readonly cause: unknown; - readonly isExternalServer: boolean; - readonly phase: "version" | "inventory"; - readonly serverUrl: string; -}): { readonly installed: boolean; readonly message: string } { - const detail = normalizedErrorMessage(input.cause); - const lower = detail?.toLowerCase() ?? ""; - - if (input.isExternalServer) { - if ( - lower.includes("401") || - lower.includes("403") || - lower.includes("unauthorized") || - lower.includes("forbidden") - ) { - return { - installed: true, - message: "OpenCode server rejected authentication. Check the server URL and password.", - }; - } - - if ( - lower.includes("econnrefused") || - lower.includes("enotfound") || - lower.includes("fetch failed") || - lower.includes("networkerror") || - lower.includes("timed out") || - lower.includes("timeout") || - lower.includes("socket hang up") - ) { - return { - installed: true, - message: `Couldn't reach the configured OpenCode server at ${input.serverUrl}. Check that the server is running and the URL is correct.`, - }; - } - - return { - installed: true, - message: detail ?? "Failed to connect to the configured OpenCode server.", - }; - } - - if (lower.includes("enoent") || lower.includes("notfound")) { - return { - installed: false, - message: "OpenCode CLI (`opencode`) is not installed or not on PATH.", - }; - } - - if (lower.includes("quarantine")) { - return { - installed: true, - message: - "macOS is blocking the OpenCode binary (quarantine). Run `xattr -d com.apple.quarantine $(which opencode)` to fix this.", - }; - } - - if (lower.includes("invalid code signature") || lower.includes("corrupted")) { - return { - installed: true, - message: - "macOS killed the OpenCode process due to an invalid code signature. The binary may be corrupted — try reinstalling OpenCode.", - }; - } - - const failureLabel = - input.phase === "inventory" - ? "Failed to load OpenCode provider inventory" - : "Failed to execute OpenCode CLI health check"; - return { - installed: true, - message: detail ? `${failureLabel}: ${detail}` : `${failureLabel}.`, - }; -} - -function titleCaseSlug(value: string): string { - const segments: Array = []; - for (const segment of value.split(/[-_/]+/)) { - if (segment.length > 0) { - segments.push(segment.charAt(0).toUpperCase() + segment.slice(1)); - } - } - return segments.join(" "); -} - -function inferDefaultVariant( - providerID: string, - variants: ReadonlyArray, -): string | undefined { - if (variants.length === 1) { - return variants[0]; - } - if (providerID === "anthropic" || providerID.startsWith("google")) { - return variants.includes("high") ? "high" : undefined; - } - if (providerID === "openai" || providerID === "opencode") { - return variants.includes("medium") ? "medium" : variants.includes("high") ? "high" : undefined; - } - return undefined; -} - -function inferDefaultAgent(agents: ReadonlyArray): string | undefined { - return agents.find((agent) => agent.name === "build")?.name ?? agents[0]?.name ?? undefined; -} - -const DEFAULT_OPENCODE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ - optionDescriptors: [ - { - id: "variant", - label: "Reasoning", - type: "select", - options: [ - { id: "low", label: "Low" }, - { id: "medium", label: "Medium", isDefault: true }, - { id: "high", label: "High" }, - { id: "xhigh", label: "Extra High" }, - ], - currentValue: "medium", - }, - { - id: "agent", - label: "Agent", - type: "select", - options: [ - { id: "build", label: "Build", isDefault: true }, - { id: "plan", label: "Plan" }, - ], - currentValue: "build", - }, - ], -}); - -function openCodeCapabilitiesForModel(input: { - readonly providerID: string; - readonly model: ProviderListResponse["all"][number]["models"][string]; - readonly agents: ReadonlyArray; -}): ModelCapabilities { - const rawVariantValues = Object.keys(input.model.variants ?? {}); - // When a model advertises no variants, synthesize the standard reasoning - // levels so the composer still offers a Reasoning selector (mirrors the - // Codex/Grok experience where reasoning is always configurable). The set - // covers the common OpenCode variant spectrum; `inferDefaultVariant` - // picks the provider-appropriate default (e.g. medium for openai/opencode). - const variantValues = - rawVariantValues.length > 0 ? rawVariantValues : ["low", "medium", "high", "xhigh"]; - const defaultVariant = inferDefaultVariant(input.providerID, variantValues); - const variantOptions = variantValues.map((value) => - defaultVariant === value - ? { id: value, label: titleCaseSlug(value), isDefault: true as const } - : { id: value, label: titleCaseSlug(value) }, - ); - const primaryAgents = input.agents.filter( - (agent) => !agent.hidden && (agent.mode === "primary" || agent.mode === "all"), - ); - const defaultAgent = inferDefaultAgent(primaryAgents); - const agentOptions = primaryAgents.map((agent) => - defaultAgent === agent.name - ? { id: agent.name, label: titleCaseSlug(agent.name), isDefault: true as const } - : { id: agent.name, label: titleCaseSlug(agent.name) }, - ); - return createModelCapabilities({ - optionDescriptors: [ - ...(variantOptions.length > 0 - ? [ - { - id: "variant", - label: "Reasoning", - type: "select" as const, - options: variantOptions, - ...(defaultVariant ? { currentValue: defaultVariant } : {}), - }, - ] - : []), - ...(agentOptions.length > 0 - ? [ - { - id: "agent", - label: "Agent", - type: "select" as const, - options: agentOptions, - ...(defaultAgent ? { currentValue: defaultAgent } : {}), - }, - ] - : []), - ], - }); -} - -function flattenOpenCodeModels( - input: OpenCodeRuntime.OpenCodeInventory, -): ReadonlyArray { - const connected = new Set(input.providerList.connected); - const models: Array = []; - - for (const provider of input.providerList.all) { - if (!connected.has(provider.id)) { - continue; - } - - for (const model of Object.values(provider.models)) { - const name = nonEmptyTrimmed(model.name); - if (!name) { - continue; - } - - const subProvider = nonEmptyTrimmed(provider.name); - models.push({ - slug: `${provider.id}/${model.id}`, - name, - ...(subProvider ? { subProvider } : {}), - isCustom: false, - capabilities: openCodeCapabilitiesForModel({ - providerID: provider.id, - model, - agents: input.agents, - }), - }); - } - } - - return models.toSorted((left, right) => left.name.localeCompare(right.name)); -} - -function trimOptional(value: string | null | undefined): string | undefined { - const trimmed = value?.trim(); - return trimmed && trimmed.length > 0 ? trimmed : undefined; -} - -export function openCodeSkillsToServerProviderSkills( - input: OpenCodeRuntime.OpenCodeInventory["skills"] | undefined, -): ReadonlyArray { - const skills: ServerProviderSkill[] = []; - for (const skill of input ?? []) { - const name = trimOptional(skill.name); - const path = trimOptional(skill.location); - if (!name || !path) { - continue; - } - - const description = trimOptional(skill.description); - skills.push({ - name, - path, - enabled: true, - ...(description ? { description, shortDescription: description } : {}), - }); - } - - return skills.toSorted((left, right) => left.name.localeCompare(right.name)); -} - -export function openCodeCommandsToServerProviderSlashCommands( - input: OpenCodeRuntime.OpenCodeInventory["commands"], -): ReadonlyArray { - const commands: ServerProviderSlashCommand[] = [COMPACT_SLASH_COMMAND]; - const names = new Set([COMPACT_SLASH_COMMAND.name]); - for (const command of input ?? []) { - const name = trimOptional(command.name); - if (!name || names.has(name) || command.source === "skill") continue; - names.add(name); - const description = trimOptional(command.description); - const hint = trimOptional(command.hints.join(" ")); - commands.push({ - name, - ...(description ? { description } : {}), - ...(hint ? { input: { hint } } : {}), - }); - } - return commands; -} - -export const makePendingOpenCodeProvider = ( - openCodeSettings: OpenCodeSettings, -): Effect.Effect => - Effect.gen(function* () { - const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); - const models = providerModelsFromSettings( - [], - openCodeSettings.customModels, - DEFAULT_OPENCODE_MODEL_CAPABILITIES, - ); - - if (!openCodeSettings.enabled) { - return buildServerProvider({ - presentation: OPENCODE_PRESENTATION, - enabled: false, - checkedAt, - models, - probe: { - installed: false, - version: null, - status: "warning", - auth: { status: "unknown" }, - message: - openCodeSettings.serverUrl.trim().length > 0 - ? "OpenCode is disabled in T3 Code settings. A server URL is configured." - : "OpenCode is disabled in T3 Code settings.", - }, - }); - } - - return buildServerProvider({ - presentation: OPENCODE_PRESENTATION, - enabled: true, - checkedAt, - models, - probe: { - installed: false, - version: null, - status: "warning", - auth: { status: "unknown" }, - message: "OpenCode provider status has not been checked in this session yet.", - }, - }); - }); - -/** One model an OpenCode 2 server lists, as the status check reads it. */ -export interface OpenCode2Model { - readonly providerID: string; - readonly id: string; - readonly name: string; - readonly variants: ReadonlyArray<{ readonly id: string }>; -} - -/** - * A server loads its catalog lazily and lists nothing for its first few - * hundred milliseconds, and a local instance starts a fresh server for each - * status check. So an empty list is read again for a while, and the last - * non-empty list stands in if the catalog still has not loaded. - */ -export const makeOpenCode2ModelLoader = ( - list: Effect.Effect, E>, -) => - Effect.sync(() => { - let lastLoaded: ReadonlyArray = []; - return list.pipe( - Effect.repeat({ - until: (models) => models.length > 0, - schedule: Schedule.spaced("250 millis"), - }), - Effect.timeoutOption("5 seconds"), - Effect.map((loaded) => { - if (loaded._tag === "Some" && loaded.value.length > 0) lastLoaded = loaded.value; - return lastLoaded; - }), - ); - }); - -/** A workspace's skills and commands, as an OpenCode 2 server lists them for its directory. */ -export interface OpenCode2Workspace { - readonly skills: ReadonlyArray<{ - readonly id: string; - readonly name: string; - readonly path: string; - readonly description?: string | undefined; - }>; - readonly commands: ReadonlyArray<{ - readonly name: string; - readonly description?: string | undefined; - }>; -} - -/** - * A server scans a directory it has not served yet in the background: its - * first read lists no commands at all (not even the built-ins), and - * `command.updated` plus `skill.updated` for the directory end the scan. A - * directory it already serves lists everything at once. `scanned` must be - * listening before `read` runs, since the read is what starts the scan. - */ -export const loadOpenCode2Workspace = ( - read: Effect.Effect, - scanned: Effect.Effect, -) => - Effect.gen(function* () { - const first = yield* read; - if (first.commands.length > 0) return first; - yield* scanned.pipe(Effect.timeoutOption("10 seconds")); - return yield* read; - }); - -export function openCode2SkillsToServerProviderSkills( - skills: OpenCode2Workspace["skills"], -): ReadonlyArray { - return skills - .map((skill) => { - const description = trimOptional(skill.description); - return { - name: skill.id, - path: skill.path, - enabled: true, - ...(skill.name === skill.id ? {} : { displayName: skill.name }), - ...(description ? { description, shortDescription: description } : {}), - }; - }) - .toSorted((left, right) => left.name.localeCompare(right.name)); -} - -export function openCode2CommandsToServerProviderSlashCommands( - commands: OpenCode2Workspace["commands"], -): ReadonlyArray { - const slashCommands: ServerProviderSlashCommand[] = [COMPACT_SLASH_COMMAND]; - const names = new Set([COMPACT_SLASH_COMMAND.name]); - for (const command of commands) { - const name = trimOptional(command.name); - if (!name || names.has(name)) continue; - names.add(name); - const description = trimOptional(command.description); - slashCommands.push({ name, ...(description ? { description } : {}) }); - } - return slashCommands; -} - -/** - * Every mode maps onto OpenCode 2 session rules. Auto asks like Supervised: - * OpenCode has no reviewer that approves routine actions. Plan mode is its - * `plan` agent, so the composer's mode toggle drives it. - */ -const OPENCODE_2_PRESENTATION = { - ...OPENCODE_PRESENTATION, - showInteractionModeToggle: true, - supportedRuntimeModes: ["approval-required", "auto-accept-edits", "auto", "full-access"], -} as const; - -function openCode2ModelCapabilities(model: OpenCode2Model): ModelCapabilities { - const variants = model.variants.map((variant) => variant.id); - const defaultVariant = inferDefaultVariant(model.providerID, variants); - return createModelCapabilities({ - optionDescriptors: - variants.length === 0 - ? [] - : [ - { - id: "variant", - label: "Reasoning", - type: "select", - options: variants.map((id) => ({ - id, - label: titleCaseSlug(id), - ...(id === defaultVariant ? { isDefault: true } : {}), - })), - ...(defaultVariant === undefined ? {} : { currentValue: defaultVariant }), - }, - ], - }); -} - -const checkOpenCode2 = Effect.fn("checkOpenCode2")(function* ( - settings: OpenCodeSettings, - version: string, - checkedAt: string, - loadModels: Effect.Effect, OpenCodeRuntime.OpenCodeRuntimeError>, -) { - const result = yield* Effect.exit(loadModels); - const probe = (status: "ready" | "warning" | "error", message: string) => ({ - installed: true, - version, - status, - auth: { status: status === "ready" ? ("authenticated" as const) : ("unknown" as const) }, - message, - }); - if (result._tag === "Failure") { - // The detail can carry a response body; it stays in the log, not the status. - yield* Effect.logWarning("OpenCode 2 model list failed", result.cause); - return buildServerProvider({ - presentation: OPENCODE_2_PRESENTATION, - enabled: true, - checkedAt, - models: providerModelsFromSettings( - [], - settings.customModels, - DEFAULT_OPENCODE_MODEL_CAPABILITIES, - ), - // Compaction does not depend on the model catalog. - slashCommands: [COMPACT_SLASH_COMMAND], - probe: probe("error", "OpenCode could not load its model list."), - }); - } - const models = providerModelsFromSettings( - result.value - .map((model) => ({ - slug: `${model.providerID}/${model.id}`, - name: model.name, - isCustom: false, - capabilities: openCode2ModelCapabilities(model), - })) - .toSorted((left, right) => left.name.localeCompare(right.name)), - settings.customModels, - DEFAULT_OPENCODE_MODEL_CAPABILITIES, - ); - return buildServerProvider({ - presentation: OPENCODE_2_PRESENTATION, - enabled: true, - checkedAt, - models, - // Every session can compact; a workspace snapshot adds that directory's commands. - slashCommands: [COMPACT_SLASH_COMMAND], - probe: - result.value.length > 0 - ? probe( - "ready", - `OpenCode ${version} lists ${result.value.length} model${result.value.length === 1 ? "" : "s"}.`, - ) - : probe("warning", "OpenCode 2 is running, but it did not list any models yet."), - }); -}); - -/** - * `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 - * upgrade re-routes the instance. - */ -export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatus")(function* ( - openCodeSettings: OpenCodeSettings, - cwd: string, - probeRuntime: Effect.Effect, - loadOpenCode2Models: Effect.Effect< - ReadonlyArray, - OpenCodeRuntime.OpenCodeRuntimeError - >, -): Effect.fn.Return< - ServerProviderDraft, - never, - OpenCodeRuntime.OpenCodeRuntime | OpenCodeServerOwner.OpenCodeServerOwner -> { - const openCodeRuntime = yield* OpenCodeRuntime.OpenCodeRuntime; - const serverOwner = yield* OpenCodeServerOwner.OpenCodeServerOwner; - const checkedAt = DateTime.formatIso(yield* DateTime.now); - const customModels = openCodeSettings.customModels; - const isExternalServer = openCodeSettings.serverUrl.trim().length > 0; - - const fallback = ( - cause: unknown, - version: string | null = null, - phase: "version" | "inventory" = "version", - ) => { - const failure = formatOpenCodeProbeError({ - cause, - isExternalServer, - phase, - serverUrl: openCodeSettings.serverUrl, - }); - return buildServerProvider({ - presentation: OPENCODE_PRESENTATION, - enabled: openCodeSettings.enabled, - checkedAt, - models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES), - probe: { - installed: failure.installed, - version, - status: "error", - auth: { status: "unknown" }, - message: failure.message, - }, - }); - }; - - if (!openCodeSettings.enabled) { - return buildServerProvider({ - presentation: OPENCODE_PRESENTATION, - enabled: false, - checkedAt, - models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES), - probe: { - installed: false, - version: null, - status: "warning", - auth: { status: "unknown" }, - message: isExternalServer - ? "OpenCode is disabled in T3 Code settings. A server URL is configured." - : "OpenCode is disabled in T3 Code settings.", - }, - }); - } - - const probedExit = yield* Effect.exit( - probeRuntime.pipe( - Effect.mapError( - (cause) => - new OpenCodeProbeError({ - cause, - detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause), - }), - ), - ), - ); - if (probedExit._tag === "Failure") return fallback(Cause.squash(probedExit.cause)); - const probed = probedExit.value; - if (probed.generation === "v2") { - return yield* checkOpenCode2(openCodeSettings, probed.version, checkedAt, loadOpenCode2Models); - } - let version: string | null = probed.version; - if (compareSemverVersions(probed.version, OpenCodeRuntime.MINIMUM_OPENCODE_VERSION) < 0) { - return buildServerProvider({ - presentation: OPENCODE_PRESENTATION, - enabled: openCodeSettings.enabled, - checkedAt, - models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES), - probe: { - installed: true, - version: probed.version, - status: "error", - auth: { status: "unknown" }, - message: `OpenCode v${probed.version} is too old. Upgrade to v${OpenCodeRuntime.MINIMUM_OPENCODE_VERSION} or newer.`, - }, - }); - } - - const loadInventory = (server: { - readonly url: string; - readonly serverPassword?: string; - readonly version: string; - }) => - openCodeRuntime - .loadOpenCodeInventory( - openCodeRuntime.createOpenCodeSdkClient({ - baseUrl: server.url, - directory: cwd, - ...(server.serverPassword !== undefined ? { serverPassword: server.serverPassword } : {}), - }), - ) - .pipe(Effect.map((inventory) => ({ inventory, version: server.version }))); - const inventoryEffect = isExternalServer - ? openCodeRuntime - .connectToOpenCodeServer({ - binaryPath: openCodeSettings.binaryPath, - directory: cwd, - serverUrl: openCodeSettings.serverUrl, - ...(openCodeSettings.serverPassword - ? { serverPassword: openCodeSettings.serverPassword } - : {}), - }) - .pipe(Effect.flatMap(loadInventory), Effect.scoped) - : serverOwner.withServer(loadInventory); - const inventoryExit = yield* Effect.exit( - inventoryEffect.pipe( - Effect.mapError( - (cause) => - new OpenCodeProbeError({ - cause, - detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause), - }), - ), - ), - ); - if (inventoryExit._tag === "Failure") { - return fallback(Cause.squash(inventoryExit.cause), version, "inventory"); - } - - version = inventoryExit.value.version; - - const models = providerModelsFromSettings( - flattenOpenCodeModels(inventoryExit.value.inventory), - customModels, - DEFAULT_OPENCODE_MODEL_CAPABILITIES, - ); - const skills = openCodeSkillsToServerProviderSkills(inventoryExit.value.inventory.skills); - const connectedCount = inventoryExit.value.inventory.providerList.connected.length; - return buildServerProvider({ - presentation: OPENCODE_PRESENTATION, - enabled: true, - checkedAt, - models, - skills, - slashCommands: openCodeCommandsToServerProviderSlashCommands( - inventoryExit.value.inventory.commands, - ), - probe: { - installed: true, - version, - status: connectedCount > 0 ? "ready" : "warning", - auth: { - status: connectedCount > 0 ? "authenticated" : "unknown", - type: "opencode", - }, - message: - connectedCount > 0 - ? `${connectedCount} upstream provider${connectedCount === 1 ? "" : "s"} connected through ${isExternalServer ? "the configured OpenCode server" : "OpenCode"}.` - : isExternalServer - ? "Connected to the configured OpenCode server, but it did not report any connected upstream providers." - : "OpenCode is available, but it did not report any connected upstream providers.", - }, - }); -}); diff --git a/apps/server/src/provider/OpenCodeServerLedger.test.ts b/apps/server/src/provider/OpenCodeServerLedger.test.ts deleted file mode 100644 index 9f1b297b816d915e73cfcdf6a994300623f9646e..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/OpenCodeServerLedger.test.ts +++ /dev/null @@ -1,244 +0,0 @@ -// @effect-diagnostics nodeBuiltinImport:off -import * as NodeChildProcess from "node:child_process"; - -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { describe, expect, it } from "@effect/vitest"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import * as Deferred from "effect/Deferred"; -import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; -import * as Fiber from "effect/Fiber"; -import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; -import * as Path from "effect/Path"; -import * as Scope from "effect/Scope"; -import { ChildProcessSpawner } from "effect/process"; - -import * as OpenCodeRuntime from "./opencodeRuntime.ts"; -import * as OpenCodeServerLedger from "./OpenCodeServerLedger.ts"; - -const SERVE_ARGS = ["serve", "--hostname=127.0.0.1", "--port=4096"]; - -const groupExists = (pgid: number) => { - try { - process.kill(-pgid, 0); - return true; - } catch { - return false; - } -}; - -const killGroup = (pgid: number) => - Effect.sync(() => { - if (groupExists(pgid)) process.kill(-pgid, "SIGKILL"); - }); - -/** - * Spawns a throwaway process group shaped like `opencode serve`: by default a - * shell leader whose argv ends in the serve arguments, plus a `sleep` member. - */ -const spawnGroup = (args: ReadonlyArray, script = "sleep 600 & wait") => - Effect.acquireRelease( - Effect.gen(function* () { - const child = NodeChildProcess.spawn("/bin/sh", ["-c", script, ...args], { - detached: true, - stdio: "ignore", - }); - const exited = yield* Deferred.make(); - child.once("exit", (_code, signal) => Deferred.doneUnsafe(exited, Effect.succeed(signal))); - const pid = child.pid; - if (pid === undefined) return yield* Effect.die("spawn failed"); - return { pid, exited: Deferred.await(exited) }; - }), - ({ pid }) => killGroup(pid), - ); - -/** A T3 server that recorded its OpenCode server and then died without cleanup. */ -const recordFromDeadServer = (stateDir: string, server: { readonly pid: number }) => - Effect.gen(function* () { - const previousServer = yield* spawnGroup([]); - const previousLedger = yield* OpenCodeServerLedger.make({ - stateDir, - ownerPid: previousServer.pid, - }); - // The previous server never gets to forget its entry. - yield* Effect.asVoid(previousLedger.track({ pid: server.pid, port: 4096, args: SERVE_ARGS })); - process.kill(-previousServer.pid, "SIGKILL"); - yield* previousServer.exited; - }); - -const hostPlatform = HostProcessPlatform.defaultValue(); -// procps accepts the same `ps` flags as macOS, so Linux also covers the macOS path. -const observedPlatforms: ReadonlyArray = - hostPlatform === "linux" ? ["linux", "darwin"] : hostPlatform === "darwin" ? ["darwin"] : []; - -describe.each(observedPlatforms)("OpenCodeServerLedger observing as %s", (platform) => { - const provideHost = (effect: Effect.Effect) => - effect.pipe( - Effect.provideService(HostProcessPlatform, platform), - Effect.provide(NodeServices.layer), - ); - - it.live("stops an OpenCode server group left by a server that died", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const stateDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-ledger-" }); - const orphan = yield* spawnGroup(SERVE_ARGS); - yield* recordFromDeadServer(stateDir, orphan); - expect(yield* fs.readDirectory(path.join(stateDir, "opencode-servers"))).toHaveLength(1); - - const restarted = yield* OpenCodeServerLedger.make({ stateDir }); - yield* restarted.reapOrphans; - - expect(yield* orphan.exited).toBe("SIGTERM"); - expect(groupExists(orphan.pid)).toBe(false); - expect(yield* fs.readDirectory(path.join(stateDir, "opencode-servers"))).toEqual([]); - }).pipe(provideHost), - ); - - it.live("records a group whose spawned wrapper already exited", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const stateDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-ledger-" }); - // The wrapper starts the serve process in its group and exits. - const wrapper = yield* spawnGroup( - SERVE_ARGS, - '/bin/sh -c "sleep 600 & wait" "$0" "$@" & exit 0', - ); - yield* wrapper.exited; - expect(groupExists(wrapper.pid)).toBe(true); - - yield* recordFromDeadServer(stateDir, wrapper); - expect(yield* fs.readDirectory(path.join(stateDir, "opencode-servers"))).toHaveLength(1); - - const restarted = yield* OpenCodeServerLedger.make({ stateDir }); - yield* restarted.reapOrphans; - - expect(groupExists(wrapper.pid)).toBe(false); - expect(yield* fs.readDirectory(path.join(stateDir, "opencode-servers"))).toEqual([]); - }).pipe(provideHost), - ); - - it.live("leaves a recycled pid alone when its start time does not match", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const stateDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-ledger-" }); - const unrelated = yield* spawnGroup(SERVE_ARGS); - yield* recordFromDeadServer(stateDir, unrelated); - const entryPath = path.join(stateDir, "opencode-servers", `${unrelated.pid}.json`); - // Same pid, a different process: what a recycled pid looks like. - const entry = yield* fs.readFileString(entryPath); - yield* fs.writeFileString(entryPath, entry.replace(/"startTime":"[^"]+"/, '"startTime":"1"')); - - const restarted = yield* OpenCodeServerLedger.make({ stateDir }); - yield* restarted.reapOrphans; - - expect(groupExists(unrelated.pid)).toBe(true); - expect(yield* fs.exists(entryPath)).toBe(false); - }).pipe(provideHost), - ); - - it.live("leaves a serve process on another port alone", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const stateDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-ledger-" }); - const unrelated = yield* spawnGroup(SERVE_ARGS); - yield* recordFromDeadServer(stateDir, unrelated); - const entryPath = path.join(stateDir, "opencode-servers", `${unrelated.pid}.json`); - // Same pid and start second, but not the server T3 started. - const entry = yield* fs.readFileString(entryPath); - yield* fs.writeFileString(entryPath, entry.replace("--port=4096", "--port=4097")); - - const restarted = yield* OpenCodeServerLedger.make({ stateDir }); - yield* restarted.reapOrphans; - - expect(groupExists(unrelated.pid)).toBe(true); - expect(yield* fs.exists(entryPath)).toBe(false); - }).pipe(provideHost), - ); - - it.live("leaves the servers of a running T3 server alone", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const stateDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-ledger-" }); - const running = yield* OpenCodeServerLedger.make({ stateDir }); - const server = yield* spawnGroup(SERVE_ARGS); - const forget = yield* running.track({ pid: server.pid, port: 4096, args: SERVE_ARGS }); - - const other = yield* OpenCodeServerLedger.make({ stateDir }); - yield* other.reapOrphans; - expect(groupExists(server.pid)).toBe(true); - - yield* forget; - expect(yield* fs.readDirectory(path.join(stateDir, "opencode-servers"))).toEqual([]); - }).pipe(provideHost), - ); -}); - -describe.skipIf(observedPlatforms.length === 0)("OpenCode server startup", () => { - it.live("stops the group when interrupted while the server is being recorded", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-startup-" }); - // A wrapper that leaves the serve process running in its group and exits 0, - // which the spawner's own cleanup does not stop. - const binaryPath = path.join(tempDir, "opencode"); - yield* fs.writeFileString(binaryPath, "#!/bin/sh\nsleep 600 &\nexit 0\n"); - yield* fs.chmod(binaryPath, 0o755); - - const spawned = yield* Deferred.make(); - const recording = yield* Deferred.make(); - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const layerRuntime = OpenCodeRuntime.layer.pipe( - Layer.provide( - Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make((command) => - spawner - .spawn(command) - .pipe(Effect.tap((handle) => Deferred.succeed(spawned, handle))), - ), - ), - ), - Layer.provide( - Layer.succeed( - OpenCodeServerLedger.OpenCodeServerLedger, - OpenCodeServerLedger.OpenCodeServerLedger.of({ - // Recording stalls until the scope is interrupted, after the wrapper exited. - track: (server) => - Effect.gen(function* () { - yield* (yield* Deferred.await(spawned)).exitCode.pipe(Effect.ignore); - yield* Deferred.succeed(recording, server.pid); - return yield* Effect.never; - }), - }), - ), - ), - ); - - const serverScope = yield* Scope.make(); - const starting = yield* OpenCodeRuntime.OpenCodeRuntime.pipe( - Effect.flatMap((runtime) => - runtime.startOpenCodeServerProcess({ binaryPath, directory: tempDir, port: 4096 }), - ), - Effect.provideService(Scope.Scope, serverScope), - Effect.provide(layerRuntime), - Effect.forkChild, - ); - const pgid = yield* Deferred.await(recording); - yield* Effect.addFinalizer(() => killGroup(pgid)); - expect(groupExists(pgid)).toBe(true); - - yield* Fiber.interrupt(starting); - yield* Scope.close(serverScope, Exit.void); - - expect(groupExists(pgid)).toBe(false); - }).pipe(Effect.provide(NodeServices.layer)), - ); -}); diff --git a/apps/server/src/provider/OpenCodeServerLedger.ts b/apps/server/src/provider/OpenCodeServerLedger.ts deleted file mode 100644 index 8243e37f048135ea5ed4c3d721a327bac990b9d5..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/OpenCodeServerLedger.ts +++ /dev/null @@ -1,339 +0,0 @@ -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import * as Context from "effect/Context"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Path from "effect/Path"; -import * as Schema from "effect/Schema"; -import { ChildProcess, ChildProcessSpawner } from "effect/process"; - -import { writeFileStringAtomically } from "../atomicWrite.ts"; -import * as ServerConfig from "../config.ts"; -import { signalProcessGroup } from "@t3tools/provider-core/server/processGroup"; - -const ProcessIdentity = Schema.Struct({ pid: Schema.Int, startTime: Schema.String }); -type ProcessIdentity = typeof ProcessIdentity.Type; - -const OpenCodeServerEntry = Schema.Struct({ - version: Schema.Literal(1), - /** The spawned group. Its id is the pid T3 spawned, which also names the entry file. */ - pgid: Schema.Int, - /** - * A group member seen at spawn: the spawned process, or a process it left in - * the group if it had already exited. Its pid, start time, and command line - * must all still match before the group is signalled. - */ - pid: Schema.Int, - /** Linux `/proc//stat` start ticks, or macOS `ps` lstart (whole seconds). */ - startTime: Schema.String, - command: Schema.String, - port: Schema.Int, - /** Entries copied along with a state directory are dropped, never acted on. */ - stateDir: Schema.String, - /** The T3 server that spawned it. Entries of a live owner are never touched. */ - owner: ProcessIdentity, -}); -type OpenCodeServerEntry = typeof OpenCodeServerEntry.Type; -const OpenCodeServerEntryJson = Schema.fromJsonString(OpenCodeServerEntry); -const decodeEntry = Schema.decodeUnknownOption(OpenCodeServerEntryJson); -const encodeEntry = Schema.encodeEffect(OpenCodeServerEntryJson); - -/** - * Local `opencode serve` processes run in their own process group so T3 can - * stop the whole group, which also means they outlive a T3 server that is - * SIGKILLed or crashes. Each spawn is recorded under the state directory and - * removed on a graceful stop; the next server start stops whatever a dead - * server left behind. - */ -export class OpenCodeServerLedger extends Context.Service< - OpenCodeServerLedger, - { - /** Records a spawned server group and returns the effect that forgets it after a graceful stop. */ - readonly track: (server: { - readonly pid: number; - readonly port: number; - /** The argv after the binary, e.g. `["serve", "--hostname=127.0.0.1", "--port=4096"]`. */ - readonly args: ReadonlyArray; - }) => Effect.Effect>; - } ->()("t3/provider/OpenCodeServerLedger") {} - -const ENTRY_DIRECTORY = "opencode-servers"; -const ENTRY_FILE = /^\d+\.json$/; -const STOP_POLL_INTERVAL = "50 millis"; -const STOP_POLL_ATTEMPTS = 40; - -interface ObservedProcess { - readonly pid: number; - readonly pgid: number; - readonly startTime: string; - readonly command: string; - readonly zombie: boolean; -} - -// `ps -o lstart` prints e.g. `Sat Sep 6 20:51:57 2026`; C locale and UTC keep it stable. -const DARWIN_PS_LINE = - /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(\w{3} \w{3} +\d{1,2} \d{2}:\d{2}:\d{2} \d{4})\s+(.*)$/; - -const parseDarwinPs = (output: string): ReadonlyArray => - output.split("\n").flatMap((line) => { - const match = DARWIN_PS_LINE.exec(line); - if (match === null) return []; - return [ - { - pid: Number(match[1]), - pgid: Number(match[2]), - zombie: match[3]!.startsWith("Z"), - startTime: match[4]!, - command: match[5]!.trimEnd(), - }, - ]; - }); - -const signalGroup = (pgid: number, signal: NodeJS.Signals) => { - try { - signalProcessGroup(pgid, signal); - } catch { - // The group may already be gone. - } -}; - -const groupExists = (pgid: number) => { - try { - signalProcessGroup(pgid, 0); - return true; - } catch (cause) { - return (cause as NodeJS.ErrnoException | undefined)?.code !== "ESRCH"; - } -}; - -/** - * Builds a ledger for one state directory. `ownerPid` is the T3 server that - * owns the servers it tracks; it defaults to this process. - */ -export const make = Effect.fn("OpenCodeServerLedger.make")(function* (input: { - readonly stateDir: string; - readonly ownerPid?: number; -}) { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const platform = yield* HostProcessPlatform; - const directory = path.join(input.stateDir, ENTRY_DIRECTORY); - // Windows servers are not detached and are never recorded. - const recordable = platform === "linux" || platform === "darwin"; - - /** Observes `pid`, or nothing when it is not in `group` (if given). */ - const observeLinux = (pid: number, group?: number) => - Effect.gen(function* () { - const stat = yield* fs.readFileString(`/proc/${pid}/stat`); - // After the parenthesized comm: state, ppid, pgrp, session, … starttime (index 19). - const fields = stat - .slice(stat.lastIndexOf(")") + 2) - .trim() - .split(/\s+/); - const pgid = Number(fields[2]); - const startTime = fields[19]; - if (!Number.isSafeInteger(pgid) || startTime === undefined) return undefined; - if (group !== undefined && pgid !== group) return undefined; - const cmdline = yield* fs.readFileString(`/proc/${pid}/cmdline`); - return { - pid, - pgid, - startTime, - command: cmdline.replace(/\0$/, "").replaceAll("\0", " "), - zombie: fields[0] === "Z", - } satisfies ObservedProcess; - }); - - const psDarwin = (selection: ReadonlyArray) => - spawner - .string( - ChildProcess.make( - "/bin/ps", - ["-ww", "-o", "pid=,pgid=,stat=,lstart=,args=", ...selection], - { - env: { LC_ALL: "C", TZ: "UTC" }, - extendEnv: true, - stdin: "ignore", - stderr: "ignore", - }, - ), - ) - .pipe(Effect.timeout("5 seconds"), Effect.map(parseDarwinPs)); - - const observe = (pid: number): Effect.Effect => { - if (platform === "linux") return observeLinux(pid).pipe(Effect.orElseSucceed(() => undefined)); - if (platform === "darwin") { - return psDarwin(["-p", String(pid)]).pipe( - Effect.map((found) => found.find((candidate) => candidate.pid === pid)), - Effect.orElseSucceed(() => undefined), - ); - } - return Effect.succeed(undefined); - }; - - // Scans every process, so it is only used when the spawned process is gone. - const observeGroup = (pgid: number): Effect.Effect> => { - if (platform === "linux") { - return fs.readDirectory("/proc").pipe( - Effect.flatMap((names) => - Effect.forEach( - names.filter((name) => /^\d+$/.test(name)), - (name) => observeLinux(Number(name), pgid).pipe(Effect.orElseSucceed(() => undefined)), - { concurrency: 16 }, - ), - ), - Effect.map((found) => found.filter((candidate) => candidate !== undefined)), - Effect.orElseSucceed(() => []), - ); - } - if (platform === "darwin") { - return psDarwin(["-A"]).pipe( - Effect.map((found) => found.filter((candidate) => candidate.pgid === pgid)), - Effect.orElseSucceed(() => []), - ); - } - return Effect.succeed([]); - }; - - const isRunning = (identity: ProcessIdentity) => - observe(identity.pid).pipe( - Effect.map( - (observed) => - observed !== undefined && !observed.zombie && observed.startTime === identity.startTime, - ), - ); - - const ownerPid = input.ownerPid ?? process.pid; - const ownerProcess = yield* observe(ownerPid); - const owner = - ownerProcess === undefined ? undefined : { pid: ownerPid, startTime: ownerProcess.startTime }; - - const track: OpenCodeServerLedger["Service"]["track"] = (server) => - Effect.gen(function* () { - if (!recordable) return Effect.void; - const pgid = server.pid; - // The spawned process leads the group. If it already exited (a wrapper - // that left the server running), any live member identifies the group: - // a group id is not reused while the group has members. - const leader = yield* observe(pgid); - const members = - leader !== undefined && !leader.zombie && leader.pgid === pgid - ? [leader] - : (yield* observeGroup(pgid)).filter((candidate) => !candidate.zombie); - const member = - members.find((candidate) => candidate.command.endsWith(` ${server.args.join(" ")}`)) ?? - members[0]; - if (owner === undefined || member === undefined) { - if (groupExists(pgid)) { - yield* Effect.logWarning( - "Could not record an OpenCode server; it will keep running if this server crashes", - { pid: pgid, port: server.port }, - ); - } - return Effect.void; - } - const entryPath = path.join(directory, `${pgid}.json`); - const entry: OpenCodeServerEntry = { - version: 1, - pgid, - pid: member.pid, - startTime: member.startTime, - command: member.command, - port: server.port, - stateDir: input.stateDir, - owner, - }; - yield* writeFileStringAtomically({ - filePath: entryPath, - contents: yield* encodeEntry(entry), - }).pipe( - Effect.provideService(FileSystem.FileSystem, fs), - Effect.provideService(Path.Path, path), - ); - return fs - .remove(entryPath, { force: true }) - .pipe( - Effect.catchCause((cause) => - Effect.logWarning("Could not forget a stopped OpenCode server", { cause }), - ), - ); - }).pipe( - Effect.catchCause((cause) => - Effect.logWarning("Could not record an OpenCode server process", { cause }).pipe( - Effect.as(Effect.void), - ), - ), - ); - - // Signals only a group whose recorded member still has the same pid, start - // time, group, and full command line, so a recycled pid is never touched. - const stopOrphan = (entry: OpenCodeServerEntry) => - Effect.gen(function* () { - const observed = yield* observe(entry.pid); - if ( - observed === undefined || - observed.zombie || - observed.startTime !== entry.startTime || - observed.pgid !== entry.pgid || - observed.command !== entry.command - ) { - return; - } - yield* Effect.logInfo("Stopping an OpenCode server left by a previous T3 Code server", { - pid: entry.pgid, - port: entry.port, - }); - signalGroup(entry.pgid, "SIGTERM"); - for (let attempt = 0; attempt < STOP_POLL_ATTEMPTS && groupExists(entry.pgid); attempt++) { - yield* Effect.sleep(STOP_POLL_INTERVAL); - } - // The group never emptied, so its pgid cannot have been reused. - if (groupExists(entry.pgid)) signalGroup(entry.pgid, "SIGKILL"); - }); - - const reapEntry = (entryPath: string) => - Effect.gen(function* () { - const entry = decodeEntry(yield* fs.readFileString(entryPath)); - if (Option.isSome(entry) && entry.value.stateDir === input.stateDir) { - if (yield* isRunning(entry.value.owner)) return; - yield* stopOrphan(entry.value); - } - yield* fs.remove(entryPath, { force: true }); - }).pipe( - Effect.catchCause((cause) => - Effect.logWarning("Could not clean up a recorded OpenCode server", { entryPath, cause }), - ), - ); - - /** Stops recorded servers whose owning T3 server is gone and drops stale entries. */ - const reapOrphans = Effect.gen(function* () { - const names = yield* fs.readDirectory(directory).pipe(Effect.orElseSucceed(() => [])); - yield* Effect.forEach( - names.filter((name) => ENTRY_FILE.test(name)), - (name) => reapEntry(path.join(directory, name)), - { concurrency: "unbounded", discard: true }, - ); - }); - - return { track, reapOrphans }; -}); - -export const layer = Layer.effect( - OpenCodeServerLedger, - Effect.gen(function* () { - const config = yield* ServerConfig.ServerConfig; - const ledger = yield* make({ stateDir: config.stateDir }); - // Reaping waits for orphans to exit, so it must not hold up startup. - yield* ledger.reapOrphans.pipe(Effect.forkScoped); - return OpenCodeServerLedger.of({ track: ledger.track }); - }), -); - -/** Records nothing. For tests that start OpenCode servers without a state directory. */ -export const layerTest = Layer.succeed( - OpenCodeServerLedger, - OpenCodeServerLedger.of({ track: () => Effect.succeed(Effect.void) }), -); diff --git a/apps/server/src/provider/OpenCodeServerOwner.test.ts b/apps/server/src/provider/OpenCodeServerOwner.test.ts deleted file mode 100644 index e789e2bec7191639d04c6f9f4fe266a93dc728bc..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/OpenCodeServerOwner.test.ts +++ /dev/null @@ -1,289 +0,0 @@ -import { it } from "@effect/vitest"; -import * as Deferred from "effect/Deferred"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; -import * as Ref from "effect/Ref"; -import * as TestClock from "effect/testing/TestClock"; -import { expect } from "vite-plus/test"; - -import * as OpenCodeRuntime from "./opencodeRuntime.ts"; -import * as OpenCodeServerOwner from "./OpenCodeServerOwner.ts"; - -const unusedRuntimeMethod = () => - Effect.fail( - new OpenCodeRuntime.OpenCodeRuntimeError({ - operation: "unused", - detail: "unused test method", - }), - ); - -const makeRuntime = Effect.gen(function* () { - const starts = yield* Ref.make(0); - const closes = yield* Ref.make(0); - const failNextStart = yield* Ref.make(false); - const started = yield* Deferred.make(); - const closed = yield* Deferred.make(); - const runtime: OpenCodeRuntime.OpenCodeRuntimeShape = { - startOpenCodeServerProcess: () => - Effect.gen(function* () { - if (yield* Ref.getAndSet(failNextStart, false)) { - return yield* new OpenCodeRuntime.OpenCodeRuntimeError({ - operation: "startOpenCodeServerProcess", - detail: "start failed", - }); - } - const index = yield* Ref.updateAndGet(starts, (count) => count + 1); - yield* Deferred.succeed(started, undefined).pipe(Effect.ignore); - yield* Effect.addFinalizer(() => - Ref.update(closes, (count) => count + 1).pipe( - Effect.andThen(Deferred.succeed(closed, undefined)), - Effect.ignore, - ), - ); - return { - url: `http://127.0.0.1:${index}`, - version: "1.14.19", - isRunning: Effect.succeed(true), - exitCode: Effect.never, - }; - }), - connectToOpenCodeServer: unusedRuntimeMethod, - runOpenCodeCommand: unusedRuntimeMethod, - createOpenCodeSdkClient: () => ({}) as never, - loadOpenCodeInventory: unusedRuntimeMethod, - loadOpenCodeSkills: unusedRuntimeMethod, - loadInventoryFromCli: unusedRuntimeMethod, - loadSkillsFromCli: unusedRuntimeMethod, - }; - return { runtime, starts, closes, failNextStart, started, closed }; -}); - -it.effect("shares concurrent borrowers and closes after the idle TTL", () => - Effect.gen(function* () { - const testRuntime = yield* makeRuntime; - const release = yield* Deferred.make(); - yield* Effect.scoped( - Effect.gen(function* () { - const owner = yield* OpenCodeServerOwner.make({ - binaryPath: "opencode", - directory: "/project", - }); - const useServer = owner.withServer((server) => - Deferred.await(release).pipe(Effect.as(server.url)), - ); - const fibers = yield* Effect.all([useServer, useServer], { - concurrency: "unbounded", - }).pipe(Effect.forkChild); - yield* Deferred.await(testRuntime.started); - expect(yield* Ref.get(testRuntime.starts)).toBe(1); - yield* Deferred.succeed(release, undefined); - expect(yield* Fiber.join(fibers)).toEqual(["http://127.0.0.1:1", "http://127.0.0.1:1"]); - yield* TestClock.adjust(Duration.seconds(31)); - yield* Deferred.await(testRuntime.closed); - expect(yield* Ref.get(testRuntime.closes)).toBe(1); - }), - ).pipe(Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, testRuntime.runtime)); - }).pipe(Effect.provide(TestClock.layer())), -); - -it.effect("retries a failed start and closes on owner scope shutdown", () => - Effect.gen(function* () { - const testRuntime = yield* makeRuntime; - yield* Ref.set(testRuntime.failNextStart, true); - yield* Effect.scoped( - Effect.gen(function* () { - const owner = yield* OpenCodeServerOwner.make({ - binaryPath: "opencode", - directory: "/project", - }); - expect( - (yield* Effect.exit(owner.withServer((server) => Effect.succeed(server.url))))._tag, - ).toBe("Failure"); - expect(yield* owner.withServer((server) => Effect.succeed(server.url))).toBe( - "http://127.0.0.1:1", - ); - }), - ).pipe(Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, testRuntime.runtime)); - expect(yield* Ref.get(testRuntime.starts)).toBe(1); - expect(yield* Ref.get(testRuntime.closes)).toBe(1); - }), -); - -it.effect("invalidates an exited process so the next borrower starts a new one", () => - Effect.gen(function* () { - const starts = yield* Ref.make(0); - const processExits: Array> = []; - const processClosed = yield* Deferred.make(); - const runtime: OpenCodeRuntime.OpenCodeRuntimeShape = { - startOpenCodeServerProcess: () => - Effect.gen(function* () { - const index = yield* Ref.updateAndGet(starts, (count) => count + 1); - const exitCode = yield* Deferred.make(); - processExits.push(exitCode); - yield* Effect.addFinalizer(() => - Deferred.succeed(processClosed, undefined).pipe(Effect.ignore), - ); - return { - url: `http://127.0.0.1:${index}`, - version: "1.14.19", - isRunning: Effect.succeed(true), - exitCode: Deferred.await(exitCode), - }; - }), - connectToOpenCodeServer: unusedRuntimeMethod, - runOpenCodeCommand: unusedRuntimeMethod, - createOpenCodeSdkClient: () => ({}) as never, - loadOpenCodeInventory: unusedRuntimeMethod, - loadOpenCodeSkills: unusedRuntimeMethod, - loadInventoryFromCli: unusedRuntimeMethod, - loadSkillsFromCli: unusedRuntimeMethod, - }; - - yield* Effect.scoped( - Effect.gen(function* () { - const owner = yield* OpenCodeServerOwner.make({ - binaryPath: "opencode", - directory: "/project", - }); - expect(yield* owner.withServer((server) => Effect.succeed(server.url))).toBe( - "http://127.0.0.1:1", - ); - yield* Deferred.succeed(processExits[0]!, 1); - yield* Deferred.await(processClosed); - expect(yield* owner.withServer((server) => Effect.succeed(server.url))).toBe( - "http://127.0.0.1:2", - ); - }), - ).pipe(Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, runtime)); - expect(yield* Ref.get(starts)).toBe(2); - }), -); - -it.effect("replaces a dead cached process before its exit watcher runs", () => - Effect.gen(function* () { - const starts = yield* Ref.make(0); - const closes = yield* Ref.make(0); - const processRunning: Array> = []; - const runtime: OpenCodeRuntime.OpenCodeRuntimeShape = { - startOpenCodeServerProcess: () => - Effect.gen(function* () { - const index = yield* Ref.updateAndGet(starts, (count) => count + 1); - const isRunning = yield* Ref.make(true); - processRunning.push(isRunning); - yield* Effect.addFinalizer(() => Ref.update(closes, (count) => count + 1)); - return { - url: `http://127.0.0.1:${index}`, - version: "1.14.19", - isRunning: Ref.get(isRunning), - exitCode: Effect.never, - }; - }), - connectToOpenCodeServer: unusedRuntimeMethod, - runOpenCodeCommand: unusedRuntimeMethod, - createOpenCodeSdkClient: () => ({}) as never, - loadOpenCodeInventory: unusedRuntimeMethod, - loadOpenCodeSkills: unusedRuntimeMethod, - loadInventoryFromCli: unusedRuntimeMethod, - loadSkillsFromCli: unusedRuntimeMethod, - }; - - yield* Effect.scoped( - Effect.gen(function* () { - const owner = yield* OpenCodeServerOwner.make({ - binaryPath: "opencode", - directory: "/project", - }); - expect(yield* owner.withServer((server) => Effect.succeed(server.url))).toBe( - "http://127.0.0.1:1", - ); - yield* Ref.set(processRunning[0]!, false); - - expect(yield* owner.withServer((server) => Effect.succeed(server.url))).toBe( - "http://127.0.0.1:2", - ); - expect(yield* Ref.get(starts)).toBe(2); - expect(yield* Ref.get(closes)).toBe(1); - }), - ).pipe(Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, runtime)); - }), -); - -it.effect("cleans up an interrupted startup and allows a retry", () => - Effect.gen(function* () { - const starts = yield* Ref.make(0); - const firstStartEntered = yield* Deferred.make(); - const firstStartClosed = yield* Deferred.make(); - const runtime: OpenCodeRuntime.OpenCodeRuntimeShape = { - startOpenCodeServerProcess: () => - Effect.gen(function* () { - const index = yield* Ref.updateAndGet(starts, (count) => count + 1); - yield* Effect.addFinalizer(() => - index === 1 - ? Deferred.succeed(firstStartClosed, undefined).pipe(Effect.ignore) - : Effect.void, - ); - if (index === 1) { - yield* Deferred.succeed(firstStartEntered, undefined); - return yield* Effect.never; - } - return { - url: `http://127.0.0.1:${index}`, - version: "1.14.19", - isRunning: Effect.succeed(true), - exitCode: Effect.never, - }; - }), - connectToOpenCodeServer: unusedRuntimeMethod, - runOpenCodeCommand: unusedRuntimeMethod, - createOpenCodeSdkClient: () => ({}) as never, - loadOpenCodeInventory: unusedRuntimeMethod, - loadOpenCodeSkills: unusedRuntimeMethod, - loadInventoryFromCli: unusedRuntimeMethod, - loadSkillsFromCli: unusedRuntimeMethod, - }; - - yield* Effect.scoped( - Effect.gen(function* () { - const owner = yield* OpenCodeServerOwner.make({ - binaryPath: "opencode", - directory: "/project", - }); - const firstBorrower = yield* owner - .withServer((server) => Effect.succeed(server.url)) - .pipe(Effect.forkChild); - yield* Deferred.await(firstStartEntered); - yield* Fiber.interrupt(firstBorrower); - yield* Deferred.await(firstStartClosed); - expect(yield* owner.withServer((server) => Effect.succeed(server.url))).toBe( - "http://127.0.0.1:2", - ); - }), - ).pipe(Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, runtime)); - }), -); - -it.effect("releases an interrupted borrower and closes after the idle TTL", () => - Effect.gen(function* () { - const testRuntime = yield* makeRuntime; - const borrowerEntered = yield* Deferred.make(); - yield* Effect.scoped( - Effect.gen(function* () { - const owner = yield* OpenCodeServerOwner.make({ - binaryPath: "opencode", - directory: "/project", - }); - const borrower = yield* owner - .withServer(() => - Deferred.succeed(borrowerEntered, undefined).pipe(Effect.andThen(Effect.never)), - ) - .pipe(Effect.forkChild); - yield* Deferred.await(borrowerEntered); - yield* Fiber.interrupt(borrower); - yield* TestClock.adjust(Duration.seconds(31)); - yield* Deferred.await(testRuntime.closed); - expect(yield* Ref.get(testRuntime.closes)).toBe(1); - }), - ).pipe(Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, testRuntime.runtime)); - }).pipe(Effect.provide(TestClock.layer())), -); diff --git a/apps/server/src/provider/OpenCodeServerOwner.ts b/apps/server/src/provider/OpenCodeServerOwner.ts deleted file mode 100644 index 7b8999213673b68e6a9c688b9c0d3407daf21d96..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/OpenCodeServerOwner.ts +++ /dev/null @@ -1,188 +0,0 @@ -import * as Context from "effect/Context"; -import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; -import * as Fiber from "effect/Fiber"; -import * as Layer from "effect/Layer"; -import * as Scope from "effect/Scope"; -import * as Semaphore from "effect/Semaphore"; - -import * as OpenCodeRuntime from "./opencodeRuntime.ts"; - -const OPENCODE_SERVER_IDLE_TTL = "30 seconds"; - -interface OpenCodeServerOwnerState { - server: OpenCodeRuntime.OpenCodeServerProcess | null; - serverScope: Scope.Closeable | null; - borrowers: number; - idleCloseFiber: Fiber.Fiber | null; -} - -export class OpenCodeServerOwner extends Context.Service< - OpenCodeServerOwner, - { - readonly withServer: ( - use: (server: OpenCodeRuntime.OpenCodeServerProcess) => Effect.Effect, - ) => Effect.Effect; - } ->()("t3/provider/OpenCodeServerOwner") {} - -/** Owns the lazy local OpenCode server shared by one provider instance. */ -export const make = Effect.fn("OpenCodeServerOwner.make")(function* (input: { - readonly binaryPath: string; - readonly directory: string; - readonly serverPassword?: string; - readonly environment?: NodeJS.ProcessEnv; - readonly verify?: (url: string) => Effect.Effect; -}) { - const runtime = yield* OpenCodeRuntime.OpenCodeRuntime; - const ownerScope = yield* Effect.acquireRelease(Scope.make(), (scope) => - Scope.close(scope, Exit.void), - ); - const mutex = yield* Semaphore.make(1); - const state: OpenCodeServerOwnerState = { - server: null, - serverScope: null, - borrowers: 0, - idleCloseFiber: null, - }; - - const cancelIdleClose = Effect.fn("OpenCodeServerOwner.cancelIdleClose")(function* () { - const fiber = state.idleCloseFiber; - state.idleCloseFiber = null; - if (fiber !== null) { - yield* Fiber.interrupt(fiber).pipe(Effect.ignore); - } - }); - - const closeServer = Effect.fn("OpenCodeServerOwner.closeServer")(function* ( - expected?: OpenCodeRuntime.OpenCodeServerProcess, - ) { - if (expected !== undefined && state.server !== expected) { - return; - } - const scope = state.serverScope; - state.server = null; - state.serverScope = null; - if (scope !== null) { - yield* Scope.close(scope, Exit.void).pipe(Effect.ignore); - } - }); - - const watchServerExit = Effect.fn("OpenCodeServerOwner.watchServerExit")(function* ( - server: OpenCodeRuntime.OpenCodeServerProcess, - ) { - yield* server.exitCode; - yield* mutex.withPermit( - Effect.gen(function* () { - if (state.server !== server) { - return; - } - yield* cancelIdleClose(); - yield* closeServer(server); - }), - ); - }); - - const acquireServer = mutex.withPermit( - Effect.gen(function* () { - yield* cancelIdleClose(); - if (state.server !== null) { - if (yield* state.server.isRunning) { - state.borrowers += 1; - return state.server; - } - yield* closeServer(state.server); - } - - return yield* Effect.uninterruptibleMask((restore) => - Effect.gen(function* () { - const serverScope = yield* Scope.make(); - const started = yield* Effect.exit( - restore( - runtime - .startOpenCodeServerProcess({ - binaryPath: input.binaryPath, - directory: input.directory, - ...(input.serverPassword !== undefined - ? { serverPassword: input.serverPassword } - : {}), - ...(input.environment ? { environment: input.environment } : {}), - ...(input.verify ? { verify: input.verify } : {}), - }) - .pipe(Effect.provideService(Scope.Scope, serverScope)), - ), - ); - if (Exit.isFailure(started)) { - yield* Scope.close(serverScope, Exit.void).pipe(Effect.ignore); - return yield* Effect.failCause(started.cause); - } - - const server = started.value; - state.server = server; - state.serverScope = serverScope; - state.borrowers = 1; - yield* watchServerExit(server).pipe(Effect.forkIn(ownerScope)); - return server; - }), - ); - }), - ); - - const releaseServer = (server: OpenCodeRuntime.OpenCodeServerProcess) => - mutex.withPermit( - Effect.gen(function* () { - if (state.server !== server) { - return; - } - state.borrowers = Math.max(0, state.borrowers - 1); - if (state.borrowers > 0) { - return; - } - yield* cancelIdleClose(); - state.idleCloseFiber = yield* Effect.sleep(OPENCODE_SERVER_IDLE_TTL).pipe( - Effect.andThen( - mutex.withPermit( - Effect.gen(function* () { - if (state.server !== server || state.borrowers > 0) { - return; - } - state.idleCloseFiber = null; - yield* closeServer(server); - }), - ), - ), - Effect.forkIn(ownerScope), - ); - }), - ); - - yield* Effect.addFinalizer(() => - mutex.withPermit( - Effect.gen(function* () { - yield* cancelIdleClose(); - state.borrowers = 0; - yield* closeServer(); - }), - ), - ); - - return OpenCodeServerOwner.of({ - withServer: (use) => - Effect.uninterruptibleMask((restore) => - restore(acquireServer).pipe( - Effect.flatMap((server) => - restore(use(server)).pipe(Effect.ensuring(releaseServer(server))), - ), - ), - ), - }); -}); - -/** @public Service construction is part of the canonical Effect module API. */ -export const layer = (input: { - readonly binaryPath: string; - readonly directory: string; - readonly serverPassword?: string; - readonly environment?: NodeJS.ProcessEnv; - readonly verify?: (url: string) => Effect.Effect; -}) => Layer.effect(OpenCodeServerOwner, make(input)); diff --git a/apps/server/src/provider/ProviderInstanceRegistry.test.ts b/apps/server/src/provider/ProviderInstanceRegistry.test.ts index 8050e426dd6c20eafd5366dd01d73ff9c8914b0d..5c1bbb1658223dd4bcbb6e1d46ad136a2ecddeec 100644 --- a/apps/server/src/provider/ProviderInstanceRegistry.test.ts +++ b/apps/server/src/provider/ProviderInstanceRegistry.test.ts @@ -33,7 +33,6 @@ import { type CodexSettings, type CursorSettings, type GrokSettings, - type OpenCodeSettings, ProviderDriverKind, type ProviderInstanceConfigMap, ProviderInstanceId, @@ -56,10 +55,11 @@ import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts"; -import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts"; +import { OpenCodeDriver, type OpenCodeDriverEnv } from "@t3tools/provider-opencode/server"; +import type { OpenCodeSettings } from "@t3tools/provider-opencode/settings"; import * as ModelManifest from "./ModelManifest.ts"; -import * as OpenCodeRuntime from "./opencodeRuntime.ts"; -import * as OpenCodeServerLedger from "./OpenCodeServerLedger.ts"; +import * as OpenCodeRuntime from "@t3tools/provider-opencode/server/OpenCodeRuntime"; +import * as OpenCodeServerLedger from "@t3tools/provider-opencode/server/OpenCodeServerLedger"; import * as ResetCreditCoordinator from "./resetCreditCoordinator.ts"; import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; import { makeProviderInstanceRegistry } from "./ProviderInstanceRegistry.ts"; diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index 733bb59280cfbc793022f5acd7e95b2f754db707..b76e079fc38a11f7280c2e6000de556296625b54 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -45,8 +45,8 @@ import * as AntigravityInstallation from "./AntigravityInstallation.ts"; import * as ModelManifest from "./ModelManifest.ts"; import { applyProviderCompatibility } from "./providerCompatibility.ts"; import * as ResetCreditCoordinator from "./resetCreditCoordinator.ts"; -import * as OpenCodeRuntime from "./opencodeRuntime.ts"; -import * as OpenCodeServerLedger from "./OpenCodeServerLedger.ts"; +import * as OpenCodeRuntime from "@t3tools/provider-opencode/server/OpenCodeRuntime"; +import * as OpenCodeServerLedger from "@t3tools/provider-opencode/server/OpenCodeServerLedger"; import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; import * as ProviderInstanceRegistryHydration from "./ProviderInstanceRegistryHydration.ts"; import * as ServerConfig from "../config.ts"; diff --git a/apps/server/src/provider/acp/AcpNativeLogging.ts b/apps/server/src/provider/acp/AcpNativeLogging.ts index c909f5a007b8198def306c582e2ff19b25c5b724..a510c5f88b5a3f12ff05a213ca95710c0eec8a6e 100644 --- a/apps/server/src/provider/acp/AcpNativeLogging.ts +++ b/apps/server/src/provider/acp/AcpNativeLogging.ts @@ -10,7 +10,7 @@ import type { EventNdjsonLogger } from "../EventNdjsonLogger.ts"; import { structuralProtocolMethod, summarizeNativeProtocolPayload, -} from "../NativeProtocolLogging.ts"; +} from "@t3tools/provider-core/server/nativeProtocolLogging"; import type * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; const transientProtocolUpdates = new Set(["agent_message_chunk", "agent_thought_chunk"]); diff --git a/apps/server/src/provider/acp/AcpRegistryAuthenticationState.ts b/apps/server/src/provider/acp/AcpRegistryAuthenticationState.ts index dee416d776cae92518c2130dd0d490bffb382442..986ed6d0ca72784cedb2ee49a66b773f51e6aaa1 100644 --- a/apps/server/src/provider/acp/AcpRegistryAuthenticationState.ts +++ b/apps/server/src/provider/acp/AcpRegistryAuthenticationState.ts @@ -12,7 +12,7 @@ import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; -import { writeFileStringAtomically } from "../../atomicWrite.ts"; +import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; const decodeState = Schema.decodeUnknownEffect( Schema.fromJsonString(Schema.Struct({ binding: Schema.String, authenticated: Schema.Boolean })), diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 1690a998c5f4016117d9a6bb154e06e402bcb418..8cfcc007e9c16fbca591dadb9953830f610d5ad3 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -26,7 +26,7 @@ import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts"; -import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts"; +import { OpenCodeDriver, type OpenCodeDriverEnv } from "@t3tools/provider-opencode/server"; import { MuseDriver, type MuseDriverEnv } from "@t3tools/provider-muse/server"; import { PiDriver, type PiDriverEnv } from "@t3tools/provider-pi/server"; import type { AnyProviderDriver } from "@t3tools/provider-core/server/driver"; diff --git a/apps/server/src/provider/openCodeUsageLimits.ts b/apps/server/src/provider/openCodeUsageLimits.ts deleted file mode 100644 index 2192e75a7b3d854e6b82601b1e336e0af866d9e3..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/openCodeUsageLimits.ts +++ /dev/null @@ -1,121 +0,0 @@ -import * as NodeOS from "node:os"; - -import type { ServerProviderUsageWindow } from "@t3tools/contracts"; -import * as Crypto from "effect/Crypto"; -import * as DateTime from "effect/DateTime"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Option from "effect/Option"; -import * as Path from "effect/Path"; -import * as Schema from "effect/Schema"; -import * as Hex from "effect/encoding/Hex"; -import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; - -import { - clampPercent, - makeUnavailableUsageLimits, - makeUsageLimits, -} from "@t3tools/provider-core/server/usageLimits"; - -const AuthFile = Schema.Struct({ "opencode-go": Schema.optionalKey(Schema.Unknown) }); -const ApiAuth = Schema.Struct({ type: Schema.Literal("api"), key: Schema.String }); -const decodeAuthFile = Schema.decodeEffect(Schema.fromJsonString(AuthFile)); -const decodeApiAuth = Schema.decodeUnknownOption(ApiAuth); -const UsageWindow = Schema.Struct({ - percent: Schema.Finite, - resetsAt: Schema.DateTimeUtcFromString, -}); -const UsageResponse = Schema.Struct({ - usage: Schema.Struct({ rolling: UsageWindow, weekly: UsageWindow, monthly: UsageWindow }), -}); - -/** External OpenCode servers own their credentials; never read the host's account for them. */ -export const readOpenCodeGoUsageLimits = Effect.fn("readOpenCodeGoUsageLimits")(function* (input: { - readonly enabled: boolean; - readonly serverUrl: string; - readonly environment: NodeJS.ProcessEnv; -}) { - const checkedAt = DateTime.formatIso(yield* DateTime.now); - const unsupported = makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); - if (!input.enabled || input.serverUrl.trim()) return unsupported; - - return yield* Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const env = input.environment; - const dataHome = - env.XDG_DATA_HOME || - path.join(env.HOME || env.USERPROFILE || NodeOS.homedir(), ".local", "share"); - const authPath = path.join(dataHome, "opencode", "auth.json"); - const contents = - env.OPENCODE_AUTH_CONTENT || - (yield* fs.readFileString(authPath).pipe( - Effect.catchTags({ - PlatformError: (error) => - error.reason._tag === "NotFound" ? Effect.succeed("{}") : Effect.fail(error), - }), - )); - const auth = yield* decodeAuthFile(contents); - const apiAuth = decodeApiAuth(auth["opencode-go"]); - // OpenCode overlays stored API credentials after environment credentials. - const apiKey = (Option.isSome(apiAuth) ? apiAuth.value.key : env.OPENCODE_API_KEY)?.trim(); - if (!apiKey) return unsupported; - - const client = yield* HttpClient.HttpClient; - const response = yield* client.execute( - HttpClientRequest.get("https://opencode.ai/zen/go/v1/usage").pipe( - HttpClientRequest.bearerToken(apiKey), - ), - ); - // A valid Zen key can exist without a Go subscription. - if (response.status === 403) return unsupported; - const body = yield* HttpClientResponse.filterStatusOk(response).pipe( - Effect.flatMap(HttpClientResponse.schemaBodyJson(UsageResponse)), - ); - const windows: ServerProviderUsageWindow[] = [ - { - id: "go_rolling", - kind: "session", - label: "Go · Session", - windowDurationMins: 5 * 60, - usedPercent: clampPercent(body.usage.rolling.percent), - resetsAt: DateTime.formatIso(body.usage.rolling.resetsAt), - }, - { - id: "go_weekly", - kind: "weekly", - label: "Go · Weekly", - windowDurationMins: 7 * 24 * 60, - usedPercent: clampPercent(body.usage.weekly.percent), - resetsAt: DateTime.formatIso(body.usage.weekly.resetsAt), - }, - { - id: "go_monthly", - kind: "monthly", - label: "Go · Monthly", - usedPercent: clampPercent(body.usage.monthly.percent), - resetsAt: DateTime.formatIso(body.usage.monthly.resetsAt), - }, - ]; - const crypto = yield* Crypto.Crypto; - // Go's usage response has no account ID. An unkeyed hash matches across - // environments without a shared secret. It permits offline guesses, but - // Go keys are randomly generated. - const credentialFingerprint = yield* crypto - .digest("SHA-256", new TextEncoder().encode(`opencode-go\0${apiKey}`)) - .pipe(Effect.map(Hex.encode), Effect.orDie); - return { - ...makeUsageLimits({ checkedAt, windows }), - credentialFingerprint, - }; - }).pipe( - Effect.timeout("5 seconds"), - Effect.orElseSucceed(() => - makeUnavailableUsageLimits({ - checkedAt, - reason: "probeFailed", - message: "OpenCode Go could not read usage.", - }), - ), - ); -}); diff --git a/apps/server/src/provider/opencode2/OpenCode2Client.events.test.ts b/apps/server/src/provider/opencode2/OpenCode2Client.events.test.ts deleted file mode 100644 index 0c4ae966e2d0140d761d519d334954bee48dc13a..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/opencode2/OpenCode2Client.events.test.ts +++ /dev/null @@ -1,196 +0,0 @@ -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/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 layerServing = (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 layerServingThenSilent = (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( - layerServingThenSilent([{ 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( - layerServing([ - { - 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(layerServing(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(layerServing(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 deleted file mode 100644 index 21c01be67185a5e14f40ea4c5b82397b4c54e52e..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/opencode2/OpenCode2Client.live.test.ts +++ /dev/null @@ -1,151 +0,0 @@ -/** - * Live contract check for `@opencode/client` against a real OpenCode 2 server. - * It re-proves that OpenCode's schemas decode under the workspace Effect - * version, which the pnpm overrides force. Run it after bumping either side: - * - * OPENCODE2_BIN=/path/to/opencode vp test run src/provider/opencode2/OpenCode2Client.live.test.ts - * - * The turn uses the free `opencode/big-pickle` model. - */ -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { AbsolutePath, Location, Model, Provider } from "@opencode/client/effect"; -import { assert, it } from "@effect/vitest"; -import * as Crypto from "effect/Crypto"; -import * as Effect from "effect/Effect"; -import * as Base64Url from "effect/encoding/Base64Url"; -import * as Fiber from "effect/Fiber"; -import * as FileSystem from "effect/FileSystem"; -import * as Filter from "effect/Filter"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Path from "effect/Path"; -import * as Stream from "effect/Stream"; -import { FetchHttpClient } from "effect/http"; -import { ChildProcess, ChildProcessSpawner } from "effect/process"; -import { describe } from "vite-plus/test"; - -import * as OpenCode2Client from "./OpenCode2Client.ts"; - -const binaryPath = process.env.OPENCODE2_BIN; - -const startServer = Effect.fn("OpenCode2ClientLive.startServer")(function* (binary: string) { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode2-live-" }); - const directory = path.join(root, "work"); - yield* fs.makeDirectory(directory); - // Non-ASCII on purpose: OpenCode decodes Basic credentials as UTF-8. - const crypto = yield* Crypto.Crypto; - const password = `${Base64Url.encode(yield* crypto.randomBytes(32))}-pässwörd€`; - const child = yield* spawner.spawn( - ChildProcess.make(binary, ["serve", "--hostname=127.0.0.1", "--port=0"], { - cwd: directory, - env: { - PATH: process.env.PATH, - HOME: root, - XDG_CONFIG_HOME: path.join(root, "config"), - XDG_DATA_HOME: path.join(root, "data"), - XDG_STATE_HOME: path.join(root, "state"), - XDG_CACHE_HOME: path.join(root, "cache"), - OPENCODE_PASSWORD: password, - }, - }), - ); - // Stop by the PID captured at spawn, before the temp directory is removed. - yield* Effect.addFinalizer(() => - Effect.sync(() => { - try { - process.kill(Number(child.pid), "SIGTERM"); - } catch { - // Already exited. - } - }).pipe(Effect.andThen(child.exitCode), Effect.timeout("10 seconds"), Effect.ignore), - ); - const baseUrl = yield* child.stdout.pipe( - Stream.decodeText(), - Stream.splitLines, - Stream.filterMap( - Filter.fromPredicateOption((line: string) => - Option.fromUndefinedOr(/server listening on\s+(https?:\/\/\S+)/i.exec(line)?.[1]), - ), - ), - Stream.runHead, - Effect.flatMap(Effect.fromOption), - Effect.timeout("30 seconds"), - ); - return { baseUrl, password, directory, pid: Number(child.pid) }; -}); - -describe.runIf(binaryPath !== undefined)("OpenCode2Client live", () => { - it.live( - "reads server info, runs one turn, and pages the history", - () => - Effect.gen(function* () { - const server = yield* startServer(binaryPath!); - const opencode = yield* OpenCode2Client.OpenCode2Client; - const { client, events } = yield* opencode.connect({ - baseUrl: server.baseUrl, - password: server.password, - }); - - const info = yield* client.server.info(); - assert.strictEqual(info.version, "2.0.23"); - assert.strictEqual(info.pid, server.pid); - - const session = yield* client.session.create({ - title: "t3 client live check", - location: Location.PublicRef.make({ directory: AbsolutePath.make(server.directory) }), - model: Model.Ref.make({ - providerID: Provider.ID.make("opencode"), - id: Model.ID.make("big-pickle"), - }), - }); - 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", - ), - Stream.runHead, - Effect.forkChild, - ); - yield* client.session.prompt({ - sessionID: session.id, - text: "Reply with exactly: OPENCODE2_CLIENT_OK", - }); - const terminal = yield* Fiber.join(finished).pipe( - Effect.flatMap(Effect.fromOption), - Effect.timeout("120 seconds"), - ); - assert.strictEqual(terminal.type, "session.execution.succeeded"); - - // One message per page makes every request after the first send only a cursor. - const history = yield* OpenCode2Client.paginate( - { sessionID: session.id, order: "asc", limit: 1 }, - client.message.list, - ).pipe(Stream.runCollect); - assert.deepStrictEqual( - history.map((message) => message.type), - ["user", "assistant", "idle"], - ); - const assistant = history[1]; - assert(assistant?.type === "assistant"); - const reply = assistant.content.flatMap((part) => - part.type === "text" ? [part.text] : [], - ); - assert.include(reply.join(""), "OPENCODE2_CLIENT_OK"); - }).pipe( - Effect.scoped, - Effect.provide( - OpenCode2Client.layer.pipe( - Layer.provideMerge(Layer.mergeAll(NodeServices.layer, FetchHttpClient.layer)), - ), - ), - ), - 180_000, - ); -}); diff --git a/apps/server/src/provider/opencode2/OpenCode2Client.test.ts b/apps/server/src/provider/opencode2/OpenCode2Client.test.ts deleted file mode 100644 index 5bfbd6ab901ce5a3128be3536e2bbeef66d62aac..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/opencode2/OpenCode2Client.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { assert, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Redacted from "effect/Redacted"; -import { HttpClient, HttpClientResponse } from "effect/http"; -import { describe } from "vite-plus/test"; - -import * as OpenCode2Client from "./OpenCode2Client.ts"; - -const INFO_BODY = '{"version":"2.0.18","pid":4242,"urls":[],"paths":{"tmp":"/tmp"}}'; - -/** Sends one request through the client and returns the Authorization header it carried. */ -const authorizationFor = (password: string | Redacted.Redacted) => { - const seen: Array = []; - const layerCapturing = Layer.succeed( - HttpClient.HttpClient, - HttpClient.make((request) => - Effect.sync(() => { - seen.push(request.headers.authorization); - return HttpClientResponse.fromWeb( - request, - new Response(INFO_BODY, { headers: { "content-type": "application/json" } }), - ); - }), - ), - ); - return Effect.gen(function* () { - const opencode = yield* OpenCode2Client.OpenCode2Client; - 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(layerCapturing)))); -}; - -const utf8Basic = (password: string) => - `Basic ${Buffer.from(`opencode:${password}`, "utf8").toString("base64")}`; - -describe("OpenCode2Client authorization", () => { - // OpenCode decodes Basic credentials as UTF-8. Latin-1 (`btoa`) encoding gets - // `pässwörd` rejected with a 401 and throws on characters such as `€`. - it.effect("encodes non-ASCII passwords as UTF-8", () => - Effect.gen(function* () { - assert.strictEqual(yield* authorizationFor("pässwörd"), utf8Basic("pässwörd")); - assert.strictEqual(yield* authorizationFor("pass€word"), utf8Basic("pass€word")); - assert.strictEqual(yield* authorizationFor(Redacted.make("pässwörd")), utf8Basic("pässwörd")); - }), - ); -}); diff --git a/apps/server/src/provider/opencode2/OpenCode2Client.ts b/apps/server/src/provider/opencode2/OpenCode2Client.ts deleted file mode 100644 index 231a5da3ae2b06de2299d0c317f22afaba2bcfb0..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/opencode2/OpenCode2Client.ts +++ /dev/null @@ -1,223 +0,0 @@ -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/encoding/Sse"; -import * as HttpClient from "effect/http/HttpClient"; -import * as HttpClientError from "effect/http/HttpClientError"; -import * as HttpClientRequest from "effect/http/HttpClientRequest"; -import * as HttpClientResponse from "effect/http/HttpClientResponse"; - -/** OpenCode 2 accepts only HTTP Basic auth, always with this user name. */ -const OPENCODE_USERNAME = "opencode"; - -/** - * A session's execution ended, but this build could not decode the event (a - * newer server added a field or a value). Only the type and session survive, - * so the turn still ends. - */ -export interface OpenCode2UnreadableTerminal { - readonly type: "unreadable.execution.ended"; - readonly executionType: (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; - } ->()("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. - */ -const basicAuthorization = (password: string | Redacted.Redacted) => { - const plain = Redacted.isRedacted(password) ? Redacted.value(password) : password; - return `Basic ${Buffer.from(`${OPENCODE_USERNAME}:${plain}`, "utf8").toString("base64")}`; -}; - -/** @public Service construction is part of the canonical Effect module API. */ -export const make = Effect.gen(function* () { - const httpClient = yield* HttpClient.HttpClient; - return OpenCode2Client.of({ - 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)), - ), - })), - ); - }, - }); -}); - -export const layer = Layer.effect(OpenCode2Client, make); - -/** - * Streams every item of a cursor-paged OpenCode 2 list. The first request - * carries the caller's input (including `order`); later requests send only - * the cursor, because OpenCode rejects a cursor combined with `order`. - */ -export const paginate = OpenCode"; - -const layerServerReplying = (reply: { - readonly status: number; - readonly contentType?: string; - readonly body: string; -}) => - Layer.succeed( - HttpClient.HttpClient, - HttpClient.make((request) => - Effect.succeed( - HttpClientResponse.fromWeb( - request, - new Response(reply.body, { - status: reply.status, - headers: reply.contentType === undefined ? {} : { "content-type": reply.contentType }, - }), - ), - ), - ), - ); - -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" }); - return yield* OpenCode2Server.verifyServer(client); - }).pipe(Effect.provide(OpenCode2Client.layer.pipe(Layer.provide(httpClient)))); - -describe("OpenCode2Server.verifyServer", () => { - it.effect("returns the version of an authenticated OpenCode 2 server", () => - Effect.gen(function* () { - const version = yield* verify( - layerServerReplying({ status: 200, contentType: "application/json", body: INFO_BODY }), - ); - assert.strictEqual(version, "2.0.18"); - }), - ); - - it.effect("reports a rejected password, not a wrong server", () => - Effect.gen(function* () { - const error = yield* verify( - layerServerReplying({ - status: 401, - contentType: "application/json", - body: UNAUTHORIZED_BODY, - }), - ).pipe(Effect.flip); - assert.include(error.detail, "rejected the server password"); - }), - ); - - // 1.x rejects a wrong password with an empty 401, which the client cannot decode. - it.effect("reports an empty-body 401 as a rejected password", () => - Effect.gen(function* () { - const error = yield* verify(layerServerReplying({ status: 401, body: "" })).pipe(Effect.flip); - assert.include(error.detail, "rejected the server password"); - }), - ); - - it.effect("reports a server error as a server error, not as unreachable", () => - Effect.gen(function* () { - for (const status of [500, 502]) { - const error = yield* verify( - layerServerReplying({ status, contentType: "text/plain", body: "upstream failed" }), - ).pipe(Effect.flip); - assert.include(error.detail, `returned HTTP ${status}`); - } - }), - ); - - // 1.x (and the 2.x web UI on unknown paths) answers /api/info with 200 HTML. - it.effect("rejects a server that answers with the web UI's HTML", () => - Effect.gen(function* () { - const error = yield* verify( - layerServerReplying({ status: 200, contentType: "text/html", body: SPA_BODY }), - ).pipe(Effect.flip); - assert.include(error.detail, "is not an OpenCode 2 server"); - }), - ); - - it.effect("reports an unreachable server as unreachable", () => - Effect.gen(function* () { - const error = yield* verify(FetchHttpClient.layer, "http://127.0.0.1:1").pipe(Effect.flip); - assert.include(error.detail, "Could not reach the OpenCode server"); - }), - ); -}); - -describe("OpenCode2Server error details", () => { - // `detail` reaches clients through the provider status message, and a - // `serverUrl` can carry credentials in its userinfo or query. - const serverUrl = "http://user:url-secret@127.0.0.1:1/?token=query-secret"; - const detailFor = (httpClient: Layer.Layer) => - Effect.gen(function* () { - const server = yield* OpenCode2Server.make({ - binaryPath: "opencode", - serverUrl, - serverPassword: "secret", - directory: "/project", - environment: {}, - }); - const error = yield* server.withConnection(() => Effect.void).pipe(Effect.flip); - return error.detail; - }).pipe( - Effect.provide( - Layer.mergeAll( - OpenCode2Client.layer.pipe(Layer.provide(httpClient)), - OpenCodeRuntime.layer.pipe(Layer.provide(OpenCodeServerLedger.layerTest)), - ).pipe(Layer.provideMerge(NodeServices.layer)), - ), - ); - - it.effect("never include the server URL", () => - Effect.gen(function* () { - const layerHanging = Layer.succeed( - HttpClient.HttpClient, - HttpClient.make(() => Effect.never), - ); - const timedOut = yield* detailFor(layerHanging).pipe(Effect.forkChild); - yield* TestClock.adjust("10 seconds"); - const details = [ - yield* detailFor(FetchHttpClient.layer), - yield* detailFor(layerServerReplying({ status: 401, body: "" })), - yield* detailFor(layerServerReplying({ status: 502, contentType: "text/plain", body: "" })), - yield* detailFor( - layerServerReplying({ status: 200, contentType: "text/html", body: SPA_BODY }), - ), - yield* Fiber.join(timedOut), - ]; - assert.deepStrictEqual(details, [ - "Could not reach the OpenCode server.", - "The OpenCode server rejected the server password.", - "The OpenCode server returned HTTP 502.", - "The server is not an OpenCode 2 server.", - "Timed out waiting for the OpenCode server.", - ]); - for (const detail of details) { - assert.notInclude(detail, "url-secret"); - assert.notInclude(detail, "query-secret"); - assert.notInclude(detail, "127.0.0.1"); - } - }).pipe(Effect.provide(TestClock.layer())), - ); -}); - -describe("OpenCode2Server passwords", () => { - it.effect("generates a distinct 256-bit password per server", () => - Effect.gen(function* () { - const first = yield* OpenCode2Server.generatePassword; - const second = yield* OpenCode2Server.generatePassword; - assert.match(Redacted.value(first), /^[A-Za-z0-9_-]{43}$/); - assert.notStrictEqual(Redacted.value(first), Redacted.value(second)); - assert.notInclude(String(first), Redacted.value(first)); - }).pipe(Effect.provide(NodeServices.layer)), - ); - - it("hands the spawned server only the T3 password", () => { - const password = Redacted.make("t3-generated"); - const environment = OpenCode2Server.serverEnvironment( - { PATH: "/bin", OPENCODE_SERVER_PASSWORD: "ambient", OPENCODE_PASSWORD: "ambient" }, - password, - ); - assert.deepStrictEqual(environment, { PATH: "/bin", OPENCODE_PASSWORD: "t3-generated" }); - }); -}); - -// Serves /api/info only with the password from OPENCODE_PASSWORD, like 2.x, and -// prints the 2.x banner (plus the generated-password line 2.x prints when no -// password is set, which T3 must never need). -const FAKE_SERVER = `import { createServer } from "node:http"; -const expected = "Basic " + Buffer.from("opencode:" + process.env.OPENCODE_PASSWORD).toString("base64"); -const server = createServer((request, response) => { - if (request.headers.authorization !== expected) { - response.writeHead(401, { "content-type": "application/json", "www-authenticate": "Basic" }); - response.end(${JSON.stringify(UNAUTHORIZED_BODY)}); - return; - } - response.writeHead(200, { "content-type": "application/json" }); - response.end(JSON.stringify({ version: "2.0.18", pid: process.pid, urls: [], paths: { tmp: "/tmp" } })); -}); -server.listen(0, "127.0.0.1", () => { - process.stdout.write("server listening on http://127.0.0.1:" + server.address().port + "\\n"); - process.stdout.write("server password not-the-t3-password\\n"); -}); -`; - -describe("OpenCode2Server spawned server", () => { - it.live( - "is ready once /api/info accepts the generated password", - () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const platform = yield* HostProcessPlatform; - const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode2-fake-" }); - const isWindows = platform === "win32"; - const binaryPath = path.join(directory, isWindows ? "opencode.cmd" : "opencode"); - const scriptPath = path.join(directory, "opencode.mjs"); - yield* fs.writeFileString(scriptPath, FAKE_SERVER); - yield* fs.writeFileString( - binaryPath, - isWindows - ? `@echo off\r\n"${yield* HostProcessExecutablePath}" "${scriptPath}" %*\r\n` - : `#!/bin/sh\nexec "${yield* HostProcessExecutablePath}" "${scriptPath}" "$@"\n`, - ); - if (!isWindows) yield* fs.chmod(binaryPath, 0o755); - - const server = yield* OpenCode2Server.make({ - binaryPath, - serverUrl: "", - serverPassword: "", - directory, - environment: { ...(yield* HostProcessEnvironment), OPENCODE_SERVER_PASSWORD: "ambient" }, - }); - const first = yield* server.withConnection((connection) => Effect.succeed(connection)); - const second = yield* server.withConnection((connection) => Effect.succeed(connection)); - assert.strictEqual(first.version, "2.0.18"); - assert.strictEqual(first.external, false); - assert.strictEqual(second.client, first.client); - }).pipe( - Effect.scoped, - Effect.provide( - Layer.mergeAll( - OpenCode2Client.layer, - OpenCodeRuntime.layer.pipe(Layer.provide(OpenCodeServerLedger.layerTest)), - ).pipe(Layer.provideMerge(Layer.mergeAll(NodeServices.layer, FetchHttpClient.layer))), - ), - ), - 15_000, - ); -}); diff --git a/apps/server/src/provider/opencode2/OpenCode2Server.ts b/apps/server/src/provider/opencode2/OpenCode2Server.ts deleted file mode 100644 index 9939055211469efb2a09679105bccbebd1bb97ad..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/opencode2/OpenCode2Server.ts +++ /dev/null @@ -1,198 +0,0 @@ -/** - * The OpenCode 2 server behind one provider instance. A 2.x server serves every - * location from one process, so an instance shares one server across all of its - * threads and directories. - * - * @module provider/opencode2/OpenCode2Server - */ -import type { OpenCodeClient } from "@opencode/client/effect"; -import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; -import * as Effect from "effect/Effect"; -import * as Base64Url from "effect/encoding/Base64Url"; -import * as Layer from "effect/Layer"; -import * as P from "effect/Predicate"; -import * as Redacted from "effect/Redacted"; -import * as HttpClientError from "effect/http/HttpClientError"; - -import { OpenCodeRuntimeError } from "../opencodeRuntime.ts"; -import * as OpenCodeServerOwner from "../OpenCodeServerOwner.ts"; -import * as OpenCode2Client from "./OpenCode2Client.ts"; - -const INFO_TIMEOUT = "5 seconds"; - -export interface OpenCode2Connection extends OpenCode2Client.OpenCode2Api { - readonly url: string; - readonly version: string; - readonly external: boolean; -} - -export class OpenCode2Server extends Context.Service< - OpenCode2Server, - { - /** Runs `use` against the instance's server, spawning it first when T3 owns it. */ - readonly withConnection: ( - use: (connection: OpenCode2Connection) => Effect.Effect, - ) => Effect.Effect; - } ->()("t3/provider/opencode2/OpenCode2Server") {} - -/** - * A fresh password for a spawned server. OpenCode 2 always requires one and - * prints a generated one to stdout otherwise, so T3 supplies its own and keeps - * it in memory. - */ -export const generatePassword = Effect.gen(function* () { - const crypto = yield* Crypto.Crypto; - const bytes = yield* crypto.randomBytes(32).pipe(Effect.orDie); - return Redacted.make(Base64Url.encode(bytes), { label: "OPENCODE_PASSWORD" }); -}); - -/** - * The environment for a spawned 2.x server. `OPENCODE_PASSWORD` wins over - * `OPENCODE_SERVER_PASSWORD` in OpenCode 2, so the inherited 1.x variable is - * dropped to keep the T3 password the only one in play. - */ -export const serverEnvironment = ( - environment: NodeJS.ProcessEnv, - password: Redacted.Redacted, -): NodeJS.ProcessEnv => { - const { OPENCODE_SERVER_PASSWORD: _inherited, ...rest } = environment; - return { ...rest, OPENCODE_PASSWORD: Redacted.value(password) }; -}; - -/** The client wraps HTTP failures in a `ClientError`; this unwraps either shape. */ -const httpFailureOf = (cause: unknown): HttpClientError.HttpClientError | undefined => { - if (HttpClientError.isHttpClientError(cause)) return cause; - if ( - P.isTagged(cause, "ClientError") && - P.hasProperty(cause, "cause") && - HttpClientError.isHttpClientError(cause.cause) - ) { - return cause.cause; - } - return undefined; -}; - -/** - * Describes a failed `/api/info` call. Only a transport failure means the - * server is unreachable; a 401 without the 2.x error body is still a rejected - * password (1.x sends it empty), and any other failed status is a server error. - * A 2xx the client cannot decode is not OpenCode 2 (1.x answers with HTML). - */ -const describeInfoFailure = (cause: unknown) => { - const failure = httpFailureOf(cause); - if (failure?.reason._tag === "TransportError") { - return "Could not reach the OpenCode server."; - } - const status = failure?.response?.status; - if (status === 401) return "The OpenCode server rejected the server password."; - if (status !== undefined && (status < 200 || status >= 300)) { - return `The OpenCode server returned HTTP ${status}.`; - } - return "The server is not an OpenCode 2 server."; -}; - -/** - * Confirms a server is an authenticated OpenCode 2 server through `/api/info` - * and returns its version. `/health` and friends answer 200 with the web UI's - * HTML on every version, so only this endpoint proves readiness. Details are - * fixed text because they reach clients and a `serverUrl` can carry - * credentials; the underlying failure stays in `cause`. - */ -export const verifyServer = (client: OpenCodeClient) => - client.server.info().pipe( - Effect.timeoutOrElse({ - duration: INFO_TIMEOUT, - orElse: () => - Effect.fail( - new OpenCodeRuntimeError({ - operation: "server.info", - detail: "Timed out waiting for the OpenCode server.", - }), - ), - }), - Effect.catchTags({ - UnauthorizedError: (cause) => - Effect.fail( - new OpenCodeRuntimeError({ - operation: "server.info", - detail: "The OpenCode server rejected the server password.", - cause, - }), - ), - }), - Effect.mapError((cause) => - OpenCodeRuntimeError.is(cause) - ? cause - : new OpenCodeRuntimeError({ - operation: "server.info", - detail: describeInfoFailure(cause), - cause, - }), - ), - Effect.map((info) => info.version), - ); - -/** - * One server per provider instance. With a `serverUrl` it connects to that - * server with the configured password; otherwise it spawns `binaryPath serve` - * with a generated password through {@link OpenCodeServerOwner}, which shares - * the process between borrowers and stops it after an idle period. Clients are - * built once per server; a failed check is not remembered. - */ -export const make = Effect.fn("OpenCode2Server.make")(function* (input: { - readonly binaryPath: string; - readonly serverUrl: string; - readonly serverPassword: string; - readonly directory: string; - readonly environment: NodeJS.ProcessEnv; -}) { - const opencode = yield* OpenCode2Client.OpenCode2Client; - const connectTo = (url: string, password: Redacted.Redacted, external: boolean) => - Effect.gen(function* () { - 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) => - Effect.sync(() => { - latest = connection; - return connection.version; - }); - - const serverUrl = input.serverUrl.trim(); - if (serverUrl.length > 0) { - const connect = connectTo(serverUrl, Redacted.make(input.serverPassword), true).pipe( - Effect.tap(remember), - ); - return OpenCode2Server.of({ - withConnection: (use) => - Effect.suspend(() => (latest === undefined ? connect : Effect.succeed(latest))).pipe( - Effect.flatMap(use), - ), - }); - } - - const password = yield* generatePassword; - const owner = yield* OpenCodeServerOwner.make({ - binaryPath: input.binaryPath, - directory: input.directory, - environment: serverEnvironment(input.environment, password), - verify: (url) => connectTo(url, password, false).pipe(Effect.flatMap(remember)), - }); - return OpenCode2Server.of({ - withConnection: (use) => - owner.withServer((server) => - // The owner verifies every server it starts before lending it out. - latest?.url === server.url - ? use(latest) - : Effect.die(new Error("OpenCode 2 server was lent before verification.")), - ), - }); -}); - -/** Built once per provider instance from its settings; closing it stops a spawned server. */ -export const layer = (input: Parameters[0]) => - Layer.effect(OpenCode2Server, make(input)); diff --git a/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts b/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts deleted file mode 100644 index c66a2f36a2073e923148b3c3fabbd3116a215e65..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts +++ /dev/null @@ -1,335 +0,0 @@ -import * as NodeAssert from "node:assert/strict"; - -import { describe, it } from "vite-plus/test"; - -import { - parseAgentListCliOutput, - parseModelsCliOutput, - parseSkillsCliOutput, - toOpenCodeFileParts, -} from "./opencodeRuntime.ts"; - -describe("parseModelsCliOutput", () => { - it("parses a single model from a single provider", () => { - const stdout = [ - "anthropic/claude-sonnet-4-5", - JSON.stringify({ - id: "claude-sonnet-4-5", - providerID: "anthropic", - name: "Claude Sonnet 4.5", - capabilities: { temperature: true, reasoning: true, toolcall: true }, - cost: { input: 3, output: 15 }, - limit: { context: 200000, output: 8192 }, - status: "active", - options: {}, - headers: {}, - release_date: "2025-01-01", - }), - ].join("\n"); - - const result = parseModelsCliOutput(stdout); - NodeAssert.equal(result.providers.size, 1); - NodeAssert.equal(result.connected.length, 1); - NodeAssert.equal(result.connected[0], "anthropic"); - - const provider = result.providers.get("anthropic")!; - NodeAssert.ok(provider); - NodeAssert.equal(provider.id, "anthropic"); - NodeAssert.equal(provider.name, "anthropic"); - NodeAssert.equal(Object.keys(provider.models).length, 1); - - const model = provider.models["claude-sonnet-4-5"]!; - NodeAssert.ok(model); - NodeAssert.equal(model.id, "claude-sonnet-4-5"); - NodeAssert.equal(model.providerID, "anthropic"); - NodeAssert.equal(model.name, "Claude Sonnet 4.5"); - }); - - it("parses multiple models from multiple providers", () => { - const stdout = [ - "anthropic/claude-sonnet-4-5", - JSON.stringify({ id: "claude-sonnet-4-5", providerID: "anthropic", name: "Sonnet 4.5" }), - "anthropic/claude-haiku-4-5", - JSON.stringify({ id: "claude-haiku-4-5", providerID: "anthropic", name: "Haiku 4.5" }), - "openai/gpt-4o", - JSON.stringify({ id: "gpt-4o", providerID: "openai", name: "GPT-4o" }), - ].join("\n"); - - const result = parseModelsCliOutput(stdout); - NodeAssert.equal(result.providers.size, 2); - NodeAssert.equal(result.connected.length, 2); - NodeAssert.equal([...result.connected].sort().join(","), "anthropic,openai"); - NodeAssert.equal(Object.keys(result.providers.get("anthropic")!.models).length, 2); - NodeAssert.equal(Object.keys(result.providers.get("openai")!.models).length, 1); - }); - - it("handles empty input", () => { - const result = parseModelsCliOutput(""); - NodeAssert.equal(result.providers.size, 0); - NodeAssert.equal(result.connected.length, 0); - }); - - it("skips unparseable JSON blocks", () => { - const stdout = [ - "anthropic/claude-sonnet-4-5", - "this is not valid json {{{", - "anthropic/claude-haiku-4-5", - JSON.stringify({ id: "claude-haiku-4-5", providerID: "anthropic", name: "Haiku 4.5" }), - ].join("\n"); - - const result = parseModelsCliOutput(stdout); - NodeAssert.equal(result.providers.size, 1); - const provider = result.providers.get("anthropic")!; - NodeAssert.equal(Object.keys(provider.models).length, 1); - NodeAssert.ok(provider.models["claude-haiku-4-5"]); - }); - - it("handles Windows-style CRLF line endings", () => { - const stdout = - "anthropic/claude-sonnet-4-5\r\n" + - JSON.stringify({ id: "claude-sonnet-4-5", providerID: "anthropic", name: "Sonnet" }) + - "\r\n"; - - const result = parseModelsCliOutput(stdout); - NodeAssert.equal(result.providers.size, 1); - NodeAssert.ok(result.providers.get("anthropic")!.models["claude-sonnet-4-5"]); - }); - - it("handles model JSON with variants and nested fields", () => { - const stdout = [ - "opencode/gpt-5.4", - JSON.stringify({ - id: "gpt-5.4", - providerID: "opencode", - name: "GPT-5.4", - family: "gpt", - capabilities: { - temperature: true, - reasoning: true, - attachment: false, - toolcall: true, - input: { text: true, audio: false, image: false, video: false, pdf: false }, - output: { text: true, audio: false, image: false, video: false, pdf: false }, - interleaved: false, - }, - cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, - limit: { context: 200000, input: 160000, output: 32000 }, - status: "active", - options: {}, - headers: {}, - release_date: "2025-01-01", - variants: { none: {}, low: {}, medium: {}, high: {} }, - }), - ].join("\n"); - - const result = parseModelsCliOutput(stdout); - const model = result.providers.get("opencode")!.models["gpt-5.4"]!; - NodeAssert.ok(model); - NodeAssert.ok(model.capabilities); - NodeAssert.equal(model.capabilities!.reasoning, true); - NodeAssert.ok(model.variants); - NodeAssert.equal(model.variants!["medium"] !== undefined, true); - }); - - it("keeps a model whose JSON body has a slash and no interior whitespace", () => { - // OpenRouter-style: the model id contains a `/` and no string value has a - // space, so the JSON body line itself matches the slug regex. It must still - // be treated as the body of the preceding slug, not a new slug. - const stdout = [ - "openrouter/qwen/qwen3-coder", - JSON.stringify({ - id: "qwen/qwen3-coder", - providerID: "openrouter", - name: "qwen3-coder", - status: "active", - }), - ].join("\n"); - - const result = parseModelsCliOutput(stdout); - NodeAssert.equal(result.providers.size, 1); - NodeAssert.deepEqual([...result.connected], ["openrouter"]); - const provider = result.providers.get("openrouter")!; - NodeAssert.ok(provider); - const model = provider.models["qwen/qwen3-coder"]!; - NodeAssert.ok(model); - NodeAssert.equal(model.id, "qwen/qwen3-coder"); - NodeAssert.equal(model.providerID, "openrouter"); - }); -}); - -describe("parseAgentListCliOutput", () => { - it("parses a single agent", () => { - const stdout = [ - "build (primary)", - " " + JSON.stringify([{ permission: "*", action: "allow", pattern: "*" }]), - ].join("\n"); - - const result = parseAgentListCliOutput(stdout); - NodeAssert.equal(result.length, 1); - NodeAssert.equal(result[0]!.name, "build"); - NodeAssert.equal(result[0]!.mode, "primary"); - NodeAssert.equal(result[0]!.permission.length, 1); - }); - - it("parses multiple agents", () => { - const stdout = [ - "build (primary)", - " " + JSON.stringify([{ permission: "*", action: "allow", pattern: "*" }]), - "explore (subagent)", - " " + JSON.stringify([{ permission: "read", action: "allow", pattern: "*" }]), - "plan (primary)", - " " + JSON.stringify([{ permission: "edit", action: "ask", pattern: "*.md" }]), - ].join("\n"); - - const result = parseAgentListCliOutput(stdout); - NodeAssert.equal(result.length, 3); - NodeAssert.equal(result[0]!.name, "build"); - NodeAssert.equal(result[0]!.mode, "primary"); - NodeAssert.equal(result[1]!.name, "explore"); - NodeAssert.equal(result[1]!.mode, "subagent"); - NodeAssert.equal(result[2]!.name, "plan"); - NodeAssert.equal(result[2]!.mode, "primary"); - }); - - it("handles empty input", () => { - const result = parseAgentListCliOutput(""); - NodeAssert.equal(result.length, 0); - }); - - it("skips agents with unparseable permission JSON", () => { - const stdout = [ - "build (primary)", - " not valid json {", - "explore (subagent)", - " " + JSON.stringify([{ permission: "read", action: "allow", pattern: "*" }]), - ].join("\n"); - - const result = parseAgentListCliOutput(stdout); - NodeAssert.equal(result.length, 1); - NodeAssert.equal(result[0]!.name, "explore"); - }); - - it("handles real-world permission blocks with nested paths", () => { - const permissions = [ - { permission: "*", action: "allow", pattern: "*" }, - { - permission: "external_directory", - pattern: "C:\\Users\\test\\.local\\*", - action: "allow", - }, - { permission: "read", pattern: "*.env", action: "ask" }, - ]; - const stdout = ["build (primary)", " " + JSON.stringify(permissions)].join("\n"); - - const result = parseAgentListCliOutput(stdout); - NodeAssert.equal(result.length, 1); - NodeAssert.equal(result[0]!.permission.length, 3); - NodeAssert.equal(result[0]!.permission[0]!.action, "allow"); - NodeAssert.equal(result[0]!.permission[2]!.action, "ask"); - }); - - it("handles agent names with spaces", () => { - const stdout = [ - "code reviewer (subagent)", - " " + JSON.stringify([{ permission: "read", action: "allow", pattern: "*" }]), - "my custom agent (primary)", - " " + JSON.stringify([{ permission: "edit", action: "ask", pattern: "*.ts" }]), - ].join("\n"); - - const result = parseAgentListCliOutput(stdout); - NodeAssert.equal(result.length, 2); - NodeAssert.equal(result[0]!.name, "code reviewer"); - NodeAssert.equal(result[0]!.mode, "subagent"); - NodeAssert.equal(result[1]!.name, "my custom agent"); - NodeAssert.equal(result[1]!.mode, "primary"); - }); - - it("marks known hidden agents", () => { - const stdout = [ - "compaction (primary)", - " " + JSON.stringify([{ permission: "*", action: "allow", pattern: "*" }]), - "build (primary)", - " " + JSON.stringify([{ permission: "*", action: "allow", pattern: "*" }]), - ].join("\n"); - - const result = parseAgentListCliOutput(stdout); - NodeAssert.equal(result[0]!.hidden, true); - NodeAssert.equal(result[1]!.hidden, false); - }); -}); - -describe("parseSkillsCliOutput", () => { - it("parses only skill metadata from the CLI JSON output", () => { - const result = parseSkillsCliOutput( - JSON.stringify([ - { - name: "review-pr", - description: "Review a pull request.", - location: "/tmp/review-pr/SKILL.md", - content: "---\nname: review-pr\n---\n", - }, - ]), - ); - - NodeAssert.deepEqual(result, [ - { - name: "review-pr", - description: "Review a pull request.", - location: "/tmp/review-pr/SKILL.md", - }, - ]); - }); - - it("degrades malformed output to an empty skill list", () => { - NodeAssert.deepEqual(parseSkillsCliOutput("not json"), []); - }); -}); - -describe("toOpenCodeFileParts", () => { - const attachment = (mimeType: string, sizeBytes = 12) => ({ - type: "file" as const, - id: "thread-1-00000000-0000-4000-8000-000000000001-bin", - name: "attachment", - mimeType, - sizeBytes, - }); - - it("sends supported images, text, and PDFs natively and skips what models reject", () => { - const parts = toOpenCodeFileParts({ - attachments: [ - attachment("application/pdf"), - attachment("text/markdown"), - attachment("image/png"), - // A ZIP file part makes OpenCode's Anthropic path throw before the - // turn starts; it must ride only as the prompt's file path line. - attachment("application/zip"), - attachment("application/octet-stream"), - // Image formats the model APIs reject stay on the fallback path too. - attachment("image/bmp"), - attachment("image/svg+xml"), - // Over the direct-attachment limit: path fallback even for a PDF. - attachment("application/pdf", 21 * 1024 * 1024), - ], - resolveAttachmentPath: () => "/tmp/attachment", - }); - - NodeAssert.deepEqual( - parts.map((part) => part.mime), - ["application/pdf", "text/markdown", "image/png"], - ); - }); - - it("keeps folded clipboard text on the lazy path fallback", () => { - const parts = toOpenCodeFileParts({ - attachments: [ - { - ...attachment("text/plain"), - source: { _tag: "pasted-text" as const }, - }, - ], - resolveAttachmentPath: () => "/tmp/pasted-text.txt", - }); - - NodeAssert.deepEqual(parts, []); - }); -}); diff --git a/apps/server/src/provider/opencodeRuntime.environment.test.ts b/apps/server/src/provider/opencodeRuntime.environment.test.ts deleted file mode 100644 index 9b382347dcd6dd9afb68d706cb57776aeaf428bb..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/opencodeRuntime.environment.test.ts +++ /dev/null @@ -1,239 +0,0 @@ -import type { OpencodeClient } from "@opencode-ai/sdk/v2"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { it as effectIt } from "@effect/vitest"; -import { - HostProcessEnvironment, - HostProcessExecutablePath, - HostProcessPlatform, -} from "@t3tools/shared/hostProcess"; -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 Path from "effect/Path"; -import * as TestClock from "effect/testing/TestClock"; -import { FetchHttpClient, HttpClient } from "effect/http"; -import { describe, expect, it } from "vite-plus/test"; - -import * as OpenCodeRuntime from "./opencodeRuntime.ts"; -import * as OpenCodeServerLedger from "./OpenCodeServerLedger.ts"; - -describe("resolveOpenCodeConfigContent", () => { - it("prefers the caller environment over the inherited environment", () => { - expect( - OpenCodeRuntime.resolveOpenCodeConfigContent( - { OPENCODE_CONFIG_CONTENT: '{"source":"caller"}' }, - { OPENCODE_CONFIG_CONTENT: '{"source":"process"}' }, - ), - ).toBe('{"source":"caller"}'); - }); - - it("falls back to the inherited environment and then an empty config", () => { - expect( - OpenCodeRuntime.resolveOpenCodeConfigContent(undefined, { - OPENCODE_CONFIG_CONTENT: '{"source":"process"}', - }), - ).toBe('{"source":"process"}'); - expect(OpenCodeRuntime.resolveOpenCodeConfigContent(undefined, {})).toBe("{}"); - }); -}); - -describe("resolveOpenCodeServerPassword", () => { - it("uses the local environment password when settings do not provide one", () => { - expect( - OpenCodeRuntime.resolveOpenCodeServerPassword( - { external: false, environment: { OPENCODE_SERVER_PASSWORD: " env password " } }, - {}, - ), - ).toBe(" env password "); - }); - - it("uses the settings password for a local server", () => { - expect( - OpenCodeRuntime.resolveOpenCodeServerPassword( - { external: false, serverPassword: " settings password " }, - {}, - ), - ).toBe(" settings password "); - }); - - it("uses the settings password when local settings and environment differ", () => { - expect( - OpenCodeRuntime.resolveOpenCodeServerPassword( - { - external: false, - serverPassword: "settings-password", - environment: { OPENCODE_SERVER_PASSWORD: "environment-password" }, - }, - {}, - ), - ).toBe("settings-password"); - }); - - it("does not send an inherited local password to an external server", () => { - expect( - OpenCodeRuntime.resolveOpenCodeServerPassword( - { external: true, environment: { OPENCODE_SERVER_PASSWORD: "local-secret" } }, - { OPENCODE_SERVER_PASSWORD: "inherited-secret" }, - ), - ).toBeUndefined(); - }); -}); - -function makeHealthClient( - result: (options?: { readonly signal?: AbortSignal }) => Promise, -): OpencodeClient { - return { - global: { - health: result, - }, - } as unknown as OpencodeClient; -} - -describe("verifyOpenCodeServerVersion", () => { - effectIt.effect("accepts a supported server version", () => - Effect.gen(function* () { - const version = yield* OpenCodeRuntime.verifyOpenCodeServerVersion( - makeHealthClient(() => Promise.resolve({ data: { healthy: true, version: "1.14.19" } })), - ); - expect(version).toBe("1.14.19"); - }), - ); - - effectIt.effect("rejects a server below the supported version", () => - Effect.gen(function* () { - const error = yield* OpenCodeRuntime.verifyOpenCodeServerVersion( - makeHealthClient(() => Promise.resolve({ data: { healthy: true, version: "1.14.18" } })), - ).pipe(Effect.flip); - expect(error).toBeInstanceOf(OpenCodeRuntime.OpenCodeRuntimeError); - expect(error.detail).toContain("v1.14.18 is too old"); - }), - ); - - for (const data of [ - { healthy: true }, - { healthy: true, version: "not-a-version" }, - { healthy: false, version: "1.14.19" }, - ]) { - effectIt.effect(`rejects an invalid health response: ${JSON.stringify(data)}`, () => - Effect.gen(function* () { - const error = yield* OpenCodeRuntime.verifyOpenCodeServerVersion( - makeHealthClient(() => Promise.resolve({ data })), - ).pipe(Effect.flip); - expect(error).toBeInstanceOf(OpenCodeRuntime.OpenCodeRuntimeError); - expect(error.detail).toContain("requires OpenCode v1.14.19 or newer"); - }), - ); - } - - effectIt.effect("preserves an unauthorized health error", () => - Effect.gen(function* () { - const error = yield* OpenCodeRuntime.verifyOpenCodeServerVersion( - makeHealthClient(() => - Promise.reject({ response: { status: 401 }, error: { message: "Unauthorized" } }), - ), - ).pipe(Effect.flip); - expect(error).toBeInstanceOf(OpenCodeRuntime.OpenCodeRuntimeError); - expect(error.detail).toContain("status=401"); - expect(error.detail).toContain("Unauthorized"); - }), - ); - - effectIt.effect("aborts a health request when the version check times out", () => - Effect.gen(function* () { - let requestSignal: AbortSignal | undefined; - const checkFiber = yield* OpenCodeRuntime.verifyOpenCodeServerVersion( - makeHealthClient((options) => { - requestSignal = options?.signal; - return new Promise(() => undefined); - }), - ).pipe(Effect.flip, Effect.forkChild); - - yield* Effect.yieldNow; - expect(requestSignal).toBeDefined(); - yield* TestClock.adjust("6 seconds"); - - const error = yield* Fiber.join(checkFiber); - expect(error.detail).toBe("Timed out while checking the OpenCode server version."); - expect(requestSignal?.aborted).toBe(true); - }).pipe(Effect.provide(TestClock.layer())), - ); -}); - -describe("OpenCode server output", () => { - effectIt.live( - "drains stdout and stderr after startup so server requests can finish", - () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const environment = yield* HostProcessEnvironment; - const executablePath = yield* HostProcessExecutablePath; - const platform = yield* HostProcessPlatform; - const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-output-" }); - const isWindows = platform === "win32"; - const binaryPath = path.join(tempDir, isWindows ? "opencode.cmd" : "opencode"); - const scriptPath = path.join(tempDir, "opencode.mjs"); - - yield* fs.writeFileString( - scriptPath, - `import { createServer } from "node:http"; -const writeOutput = (stream) => new Promise((resolve, reject) => { - stream.write("x".repeat(2 * 1024 * 1024), (error) => error ? reject(error) : resolve()); -}); -const server = createServer(async (request, response) => { - if (request.url.startsWith("/global/health")) { - response.setHeader("Content-Type", "application/json"); - response.end(JSON.stringify({ healthy: true, version: "1.14.19" })); - return; - } - await Promise.all([writeOutput(process.stdout), writeOutput(process.stderr)]); - response.end("drained"); -}); -server.listen(0, "127.0.0.1", () => { - process.stdout.write("opencode server listening on http://127.0.0.1:" + server.address().port + "\\n"); -}); -`, - ); - yield* fs.writeFileString( - binaryPath, - [ - ...(isWindows ? ["@echo off"] : ["#!/bin/sh"]), - isWindows - ? '"%T3_TEST_NODE_BINARY%" "%T3_TEST_OPENCODE_SCRIPT%" %*' - : 'exec "$T3_TEST_NODE_BINARY" "$T3_TEST_OPENCODE_SCRIPT" "$@"', - "", - ].join("\n"), - ); - if (!isWindows) { - yield* fs.chmod(binaryPath, 0o755); - } - - const runtime = yield* OpenCodeRuntime.OpenCodeRuntime; - const server = yield* runtime.startOpenCodeServerProcess({ - binaryPath, - directory: tempDir, - port: 0, - environment: { - ...environment, - T3_TEST_NODE_BINARY: executablePath, - T3_TEST_OPENCODE_SCRIPT: scriptPath, - }, - }); - const response = yield* HttpClient.get(`${server.url}/output`); - - expect(yield* response.text).toBe("drained"); - expect(yield* server.isRunning).toBe(true); - }).pipe( - Effect.scoped, - Effect.provide([ - OpenCodeRuntime.layer.pipe( - Layer.provide(OpenCodeServerLedger.layerTest), - Layer.provideMerge(NodeServices.layer), - ), - FetchHttpClient.layer, - ]), - ), - 10_000, - ); -}); diff --git a/apps/server/src/provider/opencodeRuntime.inventory.test.ts b/apps/server/src/provider/opencodeRuntime.inventory.test.ts deleted file mode 100644 index ad29f73c8d6d101d63887a9ea4fbff32f9f4c01a..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/opencodeRuntime.inventory.test.ts +++ /dev/null @@ -1,290 +0,0 @@ -import * as NodeAssert from "node:assert/strict"; - -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2"; -import { 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 Path from "effect/Path"; -import * as Queue from "effect/Queue"; -import { - HostProcessEnvironment, - HostProcessExecutablePath, - HostProcessPlatform, -} from "@t3tools/shared/hostProcess"; - -import * as OpenCodeRuntime from "./opencodeRuntime.ts"; -import * as OpenCodeServerLedger from "./OpenCodeServerLedger.ts"; - -const layerTest = OpenCodeRuntime.layer.pipe( - Layer.provide(OpenCodeServerLedger.layerTest), - Layer.provideMerge(NodeServices.layer), -); - -it.layer(layerTest)("OpenCodeRuntime inventory", (it) => { - it.effect("aborts pending SDK requests when inventory loading is interrupted", () => - Effect.gen(function* () { - const runtime = yield* OpenCodeRuntime.OpenCodeRuntime; - const started = yield* Queue.make(); - const aborted = yield* Queue.make(); - const client = createOpencodeClient({ - baseUrl: "http://opencode.test", - fetch: Object.assign( - (input: string | Request | URL) => { - const request = input instanceof Request ? input : new Request(input.toString()); - return new Promise((_resolve, reject) => { - request.signal.addEventListener( - "abort", - () => { - Queue.offerUnsafe(aborted, new URL(request.url).pathname); - reject(request.signal.reason); - }, - { once: true }, - ); - Queue.offerUnsafe(started, undefined); - }); - }, - { preconnect: () => undefined }, - ), - }); - - const inventoryFiber = yield* runtime.loadOpenCodeInventory(client).pipe(Effect.forkChild); - yield* Queue.takeN(started, 4); - yield* Fiber.interrupt(inventoryFiber); - - NodeAssert.deepEqual((yield* Queue.takeAll(aborted)).toSorted(), [ - "/agent", - "/command", - "/provider", - "/skill", - ]); - }), - ); - - it.effect("discovers directory-scoped commands without retaining prompt templates", () => - Effect.gen(function* () { - const runtime = yield* OpenCodeRuntime.OpenCodeRuntime; - const requests: Request[] = []; - const client = createOpencodeClient({ - baseUrl: "http://opencode.test", - directory: "/workspace/project", - fetch: Object.assign( - async (input: string | Request | URL) => { - const request = input instanceof Request ? input : new Request(input.toString()); - requests.push(request); - const route = new URL(request.url).pathname; - return Response.json( - route === "/provider" - ? { connected: ["openai"], all: [], default: {} } - : route === "/command" - ? [ - { - name: "review", - description: "Review changes", - source: "command", - hints: ["$ARGUMENTS"], - template: "private native template", - }, - ] - : [], - ); - }, - { preconnect: () => undefined }, - ), - }); - const inventory = yield* runtime.loadOpenCodeInventory(client); - NodeAssert.deepEqual(inventory.commands, [ - { - name: "review", - description: "Review changes", - source: "command", - hints: ["$ARGUMENTS"], - }, - ]); - const commandRequest = requests.find( - (request) => new URL(request.url).pathname === "/command", - ); - NodeAssert.ok(commandRequest); - NodeAssert.equal( - new URL(commandRequest.url).searchParams.get("directory") ?? - decodeURIComponent(commandRequest.headers.get("x-opencode-directory") ?? ""), - "/workspace/project", - ); - }), - ); - - it.effect("keeps provider inventory when agent discovery fails", () => - Effect.gen(function* () { - const runtime = yield* OpenCodeRuntime.OpenCodeRuntime; - const client = { - provider: { - list: () => - Promise.resolve({ - data: { - connected: ["openai"], - all: [], - default: {}, - }, - }), - }, - app: { - agents: () => Promise.reject(new Error("agents endpoint unavailable")), - skills: () => Promise.resolve({ data: [] }), - }, - } as unknown as OpencodeClient; - - const inventory = yield* runtime.loadOpenCodeInventory(client); - - NodeAssert.deepEqual(inventory.providerList.connected, ["openai"]); - NodeAssert.deepEqual(inventory.agents, []); - NodeAssert.deepEqual(inventory.skills, []); - }), - ); - - it.effect("keeps provider inventory when skill discovery fails", () => - Effect.gen(function* () { - const runtime = yield* OpenCodeRuntime.OpenCodeRuntime; - const client = { - provider: { - list: () => - Promise.resolve({ - data: { - connected: ["openai"], - all: [], - default: {}, - }, - }), - }, - app: { - agents: () => Promise.resolve({ data: [] }), - skills: () => Promise.reject(new Error("skills endpoint unavailable")), - }, - } as unknown as OpencodeClient; - - const inventory = yield* runtime.loadOpenCodeInventory(client); - - NodeAssert.deepEqual(inventory.providerList.connected, ["openai"]); - NodeAssert.deepEqual(inventory.agents, []); - NodeAssert.deepEqual(inventory.skills, []); - }), - ); - - it.effect("keeps only SDK skill metadata in inventory", () => - Effect.gen(function* () { - const runtime = yield* OpenCodeRuntime.OpenCodeRuntime; - const client = { - provider: { - list: () => - Promise.resolve({ - data: { - connected: ["openai"], - all: [], - default: {}, - }, - }), - }, - app: { - agents: () => Promise.resolve({ data: [] }), - skills: () => - Promise.resolve({ - data: [ - { - name: "review", - description: "Review code changes", - location: "/skills/review/SKILL.md", - content: "unused skill content", - }, - ], - }), - }, - } as unknown as OpencodeClient; - - const inventory = yield* runtime.loadOpenCodeInventory(client); - - NodeAssert.deepEqual(inventory.skills, [ - { - name: "review", - description: "Review code changes", - location: "/skills/review/SKILL.md", - }, - ]); - }), - ); - - it.effect("drops oversized CLI skill output without losing the model inventory", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const hostEnvironment = yield* HostProcessEnvironment; - const executablePath = yield* HostProcessExecutablePath; - const hostPlatform = yield* HostProcessPlatform; - const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-inventory-" }); - const isWindows = hostPlatform === "win32"; - const binaryPath = path.join(tempDir, isWindows ? "opencode.cmd" : "opencode"); - const scriptPath = path.join(tempDir, "opencode.mjs"); - const oversizedContentBytes = 8 * 1024 * 1024 + 1; - - yield* fs.writeFileString( - scriptPath, - [ - 'if (process.argv[2] === "models") {', - ' process.stdout.write(`openai/gpt-test\\n{"id":"gpt-test","providerID":"openai","name":"GPT Test"}\\n`);', - '} else if (process.argv[2] === "debug") {', - ` const content = "x".repeat(${oversizedContentBytes});`, - ' process.stdout.write(`[{"name":"oversized","content":"${content}"}]`);', - "}", - "", - ].join("\n"), - ); - yield* fs.writeFileString( - binaryPath, - [ - ...(isWindows ? ["@echo off"] : ["#!/bin/sh"]), - isWindows - ? '"%T3_TEST_NODE_BINARY%" "%T3_TEST_OPENCODE_SCRIPT%" %*' - : 'exec "$T3_TEST_NODE_BINARY" "$T3_TEST_OPENCODE_SCRIPT" "$@"', - "", - ].join("\n"), - ); - if (!isWindows) { - yield* fs.chmod(binaryPath, 0o755); - } - - const runtime = yield* OpenCodeRuntime.OpenCodeRuntime; - const inventory = yield* runtime.loadInventoryFromCli({ - binaryPath, - cwd: tempDir, - environment: { - ...hostEnvironment, - T3_TEST_NODE_BINARY: executablePath, - T3_TEST_OPENCODE_SCRIPT: scriptPath, - }, - }); - - NodeAssert.deepEqual(inventory.providerList.connected, ["openai"]); - NodeAssert.equal(inventory.skills.length, 0); - }), - ); - - it.effect("caps and drains command stdout and stderr when requested", () => - Effect.gen(function* () { - const runtime = yield* OpenCodeRuntime.OpenCodeRuntime; - const executablePath = yield* HostProcessExecutablePath; - const outputBytes = 2 * 1024 * 1024; - const result = yield* runtime.runOpenCodeCommand({ - binaryPath: executablePath, - args: [ - "-e", - `process.stdout.write("o".repeat(${outputBytes})); process.stderr.write("e".repeat(${outputBytes}));`, - ], - maxOutputBytes: 64, - }); - - NodeAssert.equal(result.stdout, "o".repeat(64)); - NodeAssert.equal(result.stderr, "e".repeat(64)); - NodeAssert.equal(result.code, 0); - }), - ); -}); diff --git a/apps/server/src/provider/opencodeRuntime.permissions.test.ts b/apps/server/src/provider/opencodeRuntime.permissions.test.ts deleted file mode 100644 index a6ae1fbe04376939c4f4dedcb8345e1f4531b5c4..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/opencodeRuntime.permissions.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import * as NodeAssert from "node:assert/strict"; - -import * as RegExpUtils from "effect/RegExp"; -import { describe, it } from "vite-plus/test"; - -import { buildOpenCodePermissionRules, toOpenCodePermissionReply } from "./opencodeRuntime.ts"; - -function actionFor( - runtimeMode: Parameters[0], - permission: string, - target = "*", -) { - // OpenCode uses the last matching rule. Its wildcards match directory separators. - return buildOpenCodePermissionRules(runtimeMode).findLast( - (rule) => - (rule.permission === "*" || rule.permission === permission) && - new RegExp(`^${RegExpUtils.escape(rule.pattern).replaceAll("\\*", ".*")}$`, "s").test(target), - )?.action; -} - -describe("buildOpenCodePermissionRules", () => { - it("pre-approves edits once the user has chosen to auto-accept them", () => { - NodeAssert.equal(actionFor("auto-accept-edits", "edit"), "allow"); - }); - - it("still asks before editing when approval is required", () => { - NodeAssert.equal(actionFor("approval-required", "edit"), "ask"); - }); - - // Documented in docs/user/permission-modes.md: providers without an AI - // reviewer, OpenCode among them, fall back to Supervised for "auto". - it("leaves auto asking, as the docs say it does without a reviewer", () => { - NodeAssert.equal(actionFor("auto", "edit"), "ask"); - }); - - it("allows workspace reads and task updates without asking in supervised modes", () => { - for (const runtimeMode of ["approval-required", "auto-accept-edits", "auto"] as const) { - for (const permission of ["read", "glob", "grep", "lsp", "skill", "todowrite"]) { - NodeAssert.equal(actionFor(runtimeMode, permission, "src/index.ts"), "allow"); - } - } - }); - - it("preserves OpenCode's environment-file approval rules", () => { - for (const runtimeMode of ["approval-required", "auto-accept-edits", "auto"] as const) { - for (const target of [ - ".env", - ".env.local", - "config/service.env", - "config/service.env.local", - ]) { - NodeAssert.equal(actionFor(runtimeMode, "read", target), "ask"); - } - for (const target of [".env.example", "config/service.env.example"]) { - NodeAssert.equal(actionFor(runtimeMode, "read", target), "allow"); - } - } - }); - - it("still asks before commands, network access, external directories and unknown tools", () => { - for (const runtimeMode of ["approval-required", "auto-accept-edits", "auto"] as const) { - NodeAssert.equal(actionFor(runtimeMode, "bash"), "ask"); - NodeAssert.equal(actionFor(runtimeMode, "webfetch"), "ask"); - NodeAssert.equal(actionFor(runtimeMode, "websearch"), "ask"); - NodeAssert.equal(actionFor(runtimeMode, "external_directory"), "ask"); - NodeAssert.equal(actionFor(runtimeMode, "doom_loop"), "ask"); - NodeAssert.equal(actionFor(runtimeMode, "custom_tool"), "ask"); - } - }); - - it("allows everything only under full access", () => { - NodeAssert.deepEqual(buildOpenCodePermissionRules("full-access"), [ - { permission: "*", pattern: "*", action: "allow" }, - { permission: "external_directory", pattern: "*", action: "allow" }, - ]); - }); -}); - -describe("toOpenCodePermissionReply", () => { - it.each([ - ["accept", "once"], - ["acceptForSession", "always"], - ["acceptAlways", "always"], - ["decline", "reject"], - ["cancel", "reject"], - ] as const)("maps %s to %s", (decision, reply) => { - NodeAssert.equal(toOpenCodePermissionReply(decision), reply); - }); -}); diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts deleted file mode 100644 index 979cacc75b145897770e3381992e2465f34e0539..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/opencodeRuntime.ts +++ /dev/null @@ -1,1111 +0,0 @@ -import * as NodeURL from "node:url"; - -import type { ChatAttachment, ProviderApprovalDecision, RuntimeMode } from "@t3tools/contracts"; -import { - createOpencodeClient, - type Agent, - type Command, - type FilePartInput, - type Model, - type OpencodeClient, - type PermissionRuleset, - type ProviderListResponse, - type QuestionAnswer, - type QuestionRequest, -} from "@opencode-ai/sdk/v2"; -import * as Cause from "effect/Cause"; -import * as Context from "effect/Context"; -import * as Data from "effect/Data"; -import * as Deferred from "effect/Deferred"; -import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; -import * as Fiber from "effect/Fiber"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as P from "effect/Predicate"; -import * as Ref from "effect/Ref"; -import * as Result from "effect/Result"; -import * as Scope from "effect/Scope"; -import * as Schema from "effect/Schema"; -import * as Stream from "effect/Stream"; -import { ChildProcess, ChildProcessSpawner } from "effect/process"; - -import { signalProcessGroup } from "@t3tools/provider-core/server/processGroup"; -import { isWindowsCommandNotFound } from "../processRunner.ts"; -import * as OpenCodeServerLedger from "./OpenCodeServerLedger.ts"; -import { collectStreamAsString } from "@t3tools/provider-core/server/snapshotProbe"; -import * as NetService from "@t3tools/shared/Net"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import { compareSemverVersions, parseSemver } from "@t3tools/shared/semver"; -import { resolveSpawnCommand } from "@t3tools/shared/shell"; -const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown)); -const OPENCODE_EMPTY_CONFIG_CONTENT = "{}"; - -export const MINIMUM_OPENCODE_VERSION = "1.14.19"; -const OPENCODE_HEALTH_TIMEOUT = "5 seconds"; - -const OpenCodeHealthSchema = Schema.Struct({ - healthy: Schema.Literal(true), - version: Schema.String, -}); -const decodeOpenCodeHealth = Schema.decodeUnknownEffect(OpenCodeHealthSchema); - -export function resolveOpenCodeConfigContent( - inputEnvironment: Readonly> | undefined, - inheritedEnvironment: Readonly> = process.env, -): string { - return ( - inputEnvironment?.OPENCODE_CONFIG_CONTENT ?? - inheritedEnvironment.OPENCODE_CONFIG_CONTENT ?? - OPENCODE_EMPTY_CONFIG_CONTENT - ); -} - -export function resolveOpenCodeServerPassword( - input: { - readonly external: boolean; - readonly serverPassword?: string; - readonly environment?: Readonly>; - }, - inheritedEnvironment: Readonly> = process.env, -): string | undefined { - if (input.serverPassword !== undefined) { - return input.serverPassword; - } - if (input.external) { - return undefined; - } - return input.environment === undefined - ? inheritedEnvironment.OPENCODE_SERVER_PASSWORD - : input.environment.OPENCODE_SERVER_PASSWORD; -} - -const DEFAULT_OPENCODE_SERVER_TIMEOUT_MS = 30_000; -const DEFAULT_HOSTNAME = "127.0.0.1"; -const OPENCODE_SERVER_STARTUP_MAX_OUTPUT_CHARS = 64 * 1024; -const OPENCODE_SKILL_DISCOVERY_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; -export interface OpenCodeServerProcess { - readonly url: string; - readonly serverPassword?: string; - readonly version: string; - readonly isRunning: Effect.Effect; - readonly exitCode: Effect.Effect; -} - -export interface OpenCodeServerConnection { - readonly url: string; - readonly serverPassword?: string; - readonly version: string; - readonly exitCode: Effect.Effect | null; - readonly external: boolean; -} - -const OPENCODE_RUNTIME_ERROR_TAG = "OpenCodeRuntimeError"; -export class OpenCodeRuntimeError extends Data.TaggedError(OPENCODE_RUNTIME_ERROR_TAG)<{ - readonly operation: string; - readonly cause?: unknown; - readonly detail: string; -}> { - static readonly is = (u: unknown): u is OpenCodeRuntimeError => - P.isTagged(u, OPENCODE_RUNTIME_ERROR_TAG); -} - -function encodeJsonStringForDiagnostics(input: unknown): string | undefined { - const result = encodeUnknownJsonStringExit(input); - return Exit.isSuccess(result) ? result.value : undefined; -} - -export function openCodeRuntimeErrorDetail(cause: unknown): string { - if (OpenCodeRuntimeError.is(cause)) return cause.detail; - if (cause instanceof Error && cause.message.trim().length > 0) return cause.message.trim(); - if (cause && typeof cause === "object") { - // SDK v2 throws { response, request, error? } shapes — extract what's useful - const anyCause = cause as Record; - const status = (anyCause.response as { status?: number } | undefined)?.status; - const body = anyCause.error ?? anyCause.data ?? anyCause.body; - const encodedBody = encodeJsonStringForDiagnostics(body ?? cause); - if (encodedBody) { - return `status=${status ?? "?"} body=${encodedBody}`; - } - } - return String(cause); -} - -export const runOpenCodeSdk = ( - operation: string, - fn: (signal: AbortSignal) => Promise, -): Effect.Effect => - Effect.tryPromise({ - try: fn, - catch: (cause) => - new OpenCodeRuntimeError({ operation, detail: openCodeRuntimeErrorDetail(cause), cause }), - }).pipe(Effect.withSpan(`opencode.${operation}`)); - -export const verifyOpenCodeServerVersion = Effect.fn("verifyOpenCodeServerVersion")(function* ( - client: OpencodeClient, -) { - const healthOption = yield* runOpenCodeSdk("global.health", (signal) => - client.global.health({ signal }), - ).pipe(Effect.timeoutOption(OPENCODE_HEALTH_TIMEOUT)); - if (Option.isNone(healthOption)) { - return yield* new OpenCodeRuntimeError({ - operation: "global.health", - detail: "Timed out while checking the OpenCode server version.", - }); - } - - const health = yield* decodeOpenCodeHealth(healthOption.value.data).pipe( - Effect.mapError( - (cause) => - new OpenCodeRuntimeError({ - operation: "global.health", - detail: `OpenCode server returned an invalid health response. T3 Code requires OpenCode v${MINIMUM_OPENCODE_VERSION} or newer.`, - cause, - }), - ), - ); - if (parseSemver(health.version) === null) { - return yield* new OpenCodeRuntimeError({ - operation: "global.health", - detail: `OpenCode server returned an invalid version. T3 Code requires OpenCode v${MINIMUM_OPENCODE_VERSION} or newer.`, - }); - } - if (compareSemverVersions(health.version, MINIMUM_OPENCODE_VERSION) < 0) { - return yield* new OpenCodeRuntimeError({ - operation: "global.health", - detail: `OpenCode v${health.version} is too old. Upgrade to v${MINIMUM_OPENCODE_VERSION} or newer.`, - }); - } - return health.version; -}); - -export interface OpenCodeCommandResult { - readonly stdout: string; - readonly stderr: string; - readonly code: number; -} - -export interface OpenCodeInventory { - readonly providerList: ProviderListResponse; - readonly agents: ReadonlyArray; - readonly skills: ReadonlyArray; - readonly commands?: ReadonlyArray; -} - -export type OpenCodeSlashCommand = Pick; - -/** Command templates stay in OpenCode, which expands arguments and runs MCP prompts. */ -export const loadOpenCodeCommands = (client: OpencodeClient) => - runOpenCodeSdk("command.list", (signal) => client.command.list(undefined, { signal })).pipe( - Effect.map((result): ReadonlyArray => - (result.data ?? []).map(({ name, description, source, hints }) => ({ - name, - ...(description === undefined ? {} : { description }), - ...(source === undefined ? {} : { source }), - hints, - })), - ), - ); - -export interface ParsedOpenCodeModelSlug { - readonly providerID: string; - readonly modelID: string; -} - -export interface OpenCodeSkill { - readonly name?: string | null; - readonly description?: string | null; - readonly location?: string | null; -} - -const OpenCodeSkillSchema = Schema.Struct({ - name: Schema.optionalKey(Schema.NullOr(Schema.String)), - description: Schema.optionalKey(Schema.NullOr(Schema.String)), - location: Schema.optionalKey(Schema.NullOr(Schema.String)), -}); -const decodeOpenCodeSkillsCliOutputExit = Schema.decodeUnknownExit( - Schema.fromJsonString(Schema.Array(OpenCodeSkillSchema)), -); - -export interface OpenCodeRuntimeShape { - /** - * Spawns a local OpenCode server process. Its lifetime is bound to the caller's - * `Scope.Scope` — the child is killed automatically when that scope closes. - * Consumers that want a long-lived server must create and hold a scope explicitly - * (see {@link Scope.make}) and close it when done. - */ - readonly startOpenCodeServerProcess: (input: { - readonly binaryPath: string; - readonly directory: string; - readonly serverPassword?: string; - readonly environment?: NodeJS.ProcessEnv; - readonly port?: number; - readonly hostname?: string; - readonly timeoutMs?: number; - /** Checks the listening server and returns its version. Defaults to the 1.x health check. */ - readonly verify?: (url: string) => Effect.Effect; - }) => Effect.Effect; - /** - * Returns a handle to either an externally-managed OpenCode server (when - * `serverUrl` is provided — no lifetime is attached to the caller's scope) or a - * freshly spawned local server whose lifetime is bound to the caller's scope. - */ - readonly connectToOpenCodeServer: (input: { - readonly binaryPath: string; - readonly directory: string; - readonly serverUrl?: string | null; - readonly serverPassword?: string; - readonly environment?: NodeJS.ProcessEnv; - readonly port?: number; - readonly hostname?: string; - readonly timeoutMs?: number; - }) => Effect.Effect; - readonly runOpenCodeCommand: (input: { - readonly binaryPath: string; - readonly args: ReadonlyArray; - readonly environment?: NodeJS.ProcessEnv; - readonly cwd?: string; - readonly maxOutputBytes?: number; - }) => Effect.Effect; - readonly createOpenCodeSdkClient: (input: { - readonly baseUrl: string; - readonly directory: string; - readonly serverPassword?: string; - }) => OpencodeClient; - readonly loadOpenCodeInventory: ( - client: OpencodeClient, - ) => Effect.Effect; - readonly loadOpenCodeSkills: ( - client: OpencodeClient, - ) => Effect.Effect, OpenCodeRuntimeError>; - readonly loadInventoryFromCli: (input: { - readonly binaryPath: string; - readonly cwd: string; - readonly environment?: NodeJS.ProcessEnv; - }) => Effect.Effect; - readonly loadSkillsFromCli: (input: { - readonly binaryPath: string; - readonly cwd: string; - readonly environment?: NodeJS.ProcessEnv; - }) => Effect.Effect, OpenCodeRuntimeError>; -} - -function parseServerUrlFromOutput(output: string): string | null { - for (const line of output.split("\n")) { - const match = line.match(/server listening on\s+(https?:\/\/[^\s]+)/i); - if (match?.[1]) return match[1]; - } - return null; -} - -const SLUG_LINE_RE = /^(\S+\/\S+)\s*$/; -const AGENT_HEADER_RE = /^(.+)\s+\((\S+)\)\s*$/; - -// Agents that are always hidden in OpenCode but the CLI "agent list" command -// does not expose the hidden flag. Keep in sync with OpenCode agent -// definitions (in the OpenCode repo: packages/opencode/src/agent/agent.ts). -const KNOWN_HIDDEN_AGENTS = new Set(["compaction", "summary", "title"]); - -/** @internal */ -export function parseModelsCliOutput(stdout: string): { - readonly providers: ReadonlyMap< - string, - { readonly id: string; readonly name: string; readonly models: { [key: string]: Model } } - >; - readonly connected: ReadonlyArray; -} { - const providers = new Map< - string, - { id: string; name: string; models: { [key: string]: Model } } - >(); - const lines = stdout.split("\n"); - let currentSlug: string | null = null; - const jsonLines: Array = []; - - const flushModel = () => { - if (currentSlug !== null && jsonLines.length > 0) { - const jsonStr = jsonLines.join("\n").trim(); - if (jsonStr.length > 0) { - try { - const model = JSON.parse(jsonStr) as Model; - const separator = currentSlug.indexOf("/"); - if (separator > 0) { - const providerID = currentSlug.slice(0, separator); - const modelID = currentSlug.slice(separator + 1); - let provider = providers.get(providerID); - if (!provider) { - provider = { id: providerID, name: providerID, models: {} }; - providers.set(providerID, provider); - } - provider.models[modelID] = model; - } - } catch { - // Skip unparseable model JSON - } - } - } - currentSlug = null; - jsonLines.length = 0; - }; - - for (const line of lines) { - // A model's JSON body is a single `JSON.stringify` line starting with `{`, - // while a provider/model slug is a bare `provider/model` header. Only the - // latter can be a slug: without this guard a body line with no interior - // whitespace and a `/` in one of its values (e.g. an OpenRouter model whose - // `id` is `vendor/model`) matches SLUG_LINE_RE, so flushModel runs against - // an empty body and the model is silently dropped. - const slugMatch = line.trimStart().startsWith("{") ? null : SLUG_LINE_RE.exec(line); - if (slugMatch) { - flushModel(); - currentSlug = slugMatch[1]!; - } else if (currentSlug !== null) { - jsonLines.push(line); - } - } - flushModel(); - - return { providers, connected: [...providers.keys()] }; -} - -/** @internal */ -export function parseAgentListCliOutput(stdout: string): ReadonlyArray { - const agents: Array = []; - const lines = stdout.split("\n"); - let currentHeader: { name: string; mode: string } | null = null; - const blockLines: Array = []; - - const flushAgent = () => { - if (currentHeader !== null) { - const jsonStr = blockLines.join("\n").trim(); - if (jsonStr.length > 0) { - try { - const permission = JSON.parse(jsonStr); - agents.push({ - name: currentHeader.name, - mode: currentHeader.mode as Agent["mode"], - hidden: KNOWN_HIDDEN_AGENTS.has(currentHeader.name), - permission, - options: {}, - }); - } catch { - // Skip unparseable agent - } - } - } - currentHeader = null; - blockLines.length = 0; - }; - - for (const line of lines) { - const match = AGENT_HEADER_RE.exec(line); - if (match) { - flushAgent(); - currentHeader = { name: match[1]!, mode: match[2]! }; - } else if (currentHeader !== null) { - blockLines.push(line); - } - } - flushAgent(); - - return agents; -} - -/** @internal */ -export function parseSkillsCliOutput(stdout: string): ReadonlyArray { - const result = decodeOpenCodeSkillsCliOutputExit(stdout); - return Exit.isSuccess(result) ? result.value : []; -} - -export function parseOpenCodeModelSlug( - slug: string | null | undefined, -): ParsedOpenCodeModelSlug | null { - if (typeof slug !== "string") { - return null; - } - - const trimmed = slug.trim(); - const separator = trimmed.indexOf("/"); - if (separator <= 0 || separator === trimmed.length - 1) { - return null; - } - - return { - providerID: trimmed.slice(0, separator), - modelID: trimmed.slice(separator + 1), - }; -} - -export function openCodeQuestionId( - index: number, - question: QuestionRequest["questions"][number], -): string { - const header = question.header - .trim() - .toLowerCase() - .replace(/[^a-z0-9_-]+/g, "-"); - return header.length > 0 ? `question-${index}-${header}` : `question-${index}`; -} - -/** - * Attachments OpenCode can hand to a model as a native file part. Anything - * else (ZIP, binaries, image formats like BMP/AVIF/SVG that model APIs - * reject, or files over the direct-attachment size limit) would make the turn - * fail before it starts, so those ride only as the file path ProviderService - * puts in the prompt. - */ -const OPENCODE_NATIVE_IMAGE_MIMES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]); -const OPENCODE_NATIVE_FILE_PART_MAX_BYTES = 20 * 1024 * 1024; - -function isOpenCodeNativeFilePart(input: { - readonly mimeType: string; - readonly sizeBytes: number; -}): boolean { - if (input.sizeBytes > OPENCODE_NATIVE_FILE_PART_MAX_BYTES) { - return false; - } - const normalized = input.mimeType.trim().toLowerCase(); - return ( - OPENCODE_NATIVE_IMAGE_MIMES.has(normalized) || - normalized.startsWith("text/") || - normalized === "application/pdf" - ); -} - -export function toOpenCodeFileParts(input: { - readonly attachments: ReadonlyArray | undefined; - readonly resolveAttachmentPath: (attachment: ChatAttachment) => string | null; -}): Array { - const parts: Array = []; - - for (const attachment of input.attachments ?? []) { - if ( - attachment.type === "file" && - "source" in attachment && - attachment.source?._tag === "pasted-text" - ) { - continue; - } - if (!isOpenCodeNativeFilePart(attachment)) { - continue; - } - const attachmentPath = input.resolveAttachmentPath(attachment); - if (!attachmentPath) { - continue; - } - - parts.push({ - type: "file", - mime: attachment.mimeType, - filename: attachment.name, - url: NodeURL.pathToFileURL(attachmentPath).href, - }); - } - - return parts; -} - -export function buildOpenCodePermissionRules(runtimeMode: RuntimeMode): PermissionRuleset { - if (runtimeMode === "full-access") { - return [ - { permission: "*", pattern: "*", action: "allow" }, - { permission: "external_directory", pattern: "*", action: "allow" }, - ]; - } - - // "Auto-accept edits" is documented as "auto-approve edits, ask before other - // actions", so prompting for every edit ignores the mode the user picked. - // "auto" is left asking on purpose: the docs say providers without an AI - // reviewer, OpenCode among them, fall back to Supervised for that mode. - const editAction = runtimeMode === "auto-accept-edits" ? "allow" : "ask"; - - // Session rules override OpenCode's agent defaults. Allow reads and task - // updates, but keep its default approval rules for environment files. - return [ - { permission: "*", pattern: "*", action: "ask" }, - { permission: "read", pattern: "*", action: "allow" }, - { permission: "read", pattern: "*.env", action: "ask" }, - { permission: "read", pattern: "*.env.*", action: "ask" }, - { permission: "read", pattern: "*.env.example", action: "allow" }, - { permission: "glob", pattern: "*", action: "allow" }, - { permission: "grep", pattern: "*", action: "allow" }, - { permission: "lsp", pattern: "*", action: "allow" }, - { permission: "skill", pattern: "*", action: "allow" }, - { permission: "todowrite", pattern: "*", action: "allow" }, - { permission: "bash", pattern: "*", action: "ask" }, - { permission: "edit", pattern: "*", action: editAction }, - { permission: "webfetch", pattern: "*", action: "ask" }, - { permission: "websearch", pattern: "*", action: "ask" }, - { permission: "codesearch", pattern: "*", action: "ask" }, - { permission: "external_directory", pattern: "*", action: "ask" }, - { permission: "doom_loop", pattern: "*", action: "ask" }, - { permission: "question", pattern: "*", action: "allow" }, - ]; -} - -export function toOpenCodePermissionReply( - decision: ProviderApprovalDecision, -): "once" | "always" | "reject" { - switch (decision) { - case "accept": - return "once"; - case "acceptForSession": - case "acceptAlways": - return "always"; - case "decline": - case "cancel": - default: - return "reject"; - } -} - -export function toOpenCodeQuestionAnswers( - request: QuestionRequest, - answers: Record, -): Array { - return request.questions.map((question, index) => { - const raw = - answers[openCodeQuestionId(index, question)] ?? - answers[question.header] ?? - answers[question.question]; - if (Array.isArray(raw)) { - return raw.filter((value): value is string => typeof value === "string"); - } - if (typeof raw === "string") { - return raw.trim().length > 0 ? [raw] : []; - } - return []; - }); -} - -function ensureRuntimeError( - operation: OpenCodeRuntimeError["operation"], - detail: string, - cause: unknown, -): OpenCodeRuntimeError { - return OpenCodeRuntimeError.is(cause) - ? cause - : new OpenCodeRuntimeError({ operation, detail, cause }); -} - -const makeOpenCodeRuntime = Effect.gen(function* () { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const netService = yield* NetService.NetService; - const hostPlatform = yield* HostProcessPlatform; - const serverLedger = yield* OpenCodeServerLedger.OpenCodeServerLedger; - const resolveCommand = (command: string, args: ReadonlyArray, env?: NodeJS.ProcessEnv) => - resolveSpawnCommand(command, args, env ? { env } : {}); - - const runOpenCodeCommand: OpenCodeRuntimeShape["runOpenCodeCommand"] = (input) => - Effect.gen(function* () { - const spawnCommand = yield* resolveCommand(input.binaryPath, input.args, input.environment); - const child = yield* spawner.spawn( - ChildProcess.make(spawnCommand.command, spawnCommand.args, { - detached: hostPlatform !== "win32", - shell: spawnCommand.shell, - ...(input.cwd ? { cwd: input.cwd } : {}), - ...(input.environment ? { env: input.environment } : { extendEnv: true }), - }), - ); - const terminateCommandGroup = - hostPlatform === "win32" - ? child.kill({ killSignal: "SIGKILL" }).pipe(Effect.asVoid) - : Effect.sync(() => { - try { - signalProcessGroup(Number(child.pid), "SIGKILL"); - } catch { - // The command and its process group may already have exited. - } - }); - yield* Effect.addFinalizer(() => terminateCommandGroup.pipe(Effect.ignore)); - const collectOptions = - input.maxOutputBytes === undefined ? undefined : { maxBytes: input.maxOutputBytes }; - const [stdout, stderr, code] = yield* Effect.all( - [ - collectStreamAsString(child.stdout, collectOptions), - collectStreamAsString(child.stderr, collectOptions), - child.exitCode, - ], - { concurrency: "unbounded" }, - ); - const exitCode = Number(code); - if (yield* isWindowsCommandNotFound(exitCode, stderr)) { - return yield* new OpenCodeRuntimeError({ - operation: "runOpenCodeCommand", - detail: `spawn ${input.binaryPath} ENOENT`, - }); - } - return { - stdout, - stderr, - code: exitCode, - } satisfies OpenCodeCommandResult; - }).pipe( - Effect.scoped, - Effect.mapError((cause) => - ensureRuntimeError( - "runOpenCodeCommand", - `Failed to execute '${input.binaryPath} ${input.args.join(" ")}': ${openCodeRuntimeErrorDetail(cause)}`, - cause, - ), - ), - ); - - const createOpenCodeSdkClient: OpenCodeRuntimeShape["createOpenCodeSdkClient"] = (input) => - createOpencodeClient({ - baseUrl: input.baseUrl, - directory: input.directory, - ...(input.serverPassword - ? { - headers: { - Authorization: `Basic ${Buffer.from(`opencode:${input.serverPassword}`, "utf8").toString("base64")}`, - }, - } - : {}), - throwOnError: true, - }); - - const startOpenCodeServerProcess: OpenCodeRuntimeShape["startOpenCodeServerProcess"] = (input) => - Effect.gen(function* () { - // Bind this server's lifetime to the caller's scope. When the caller's - // scope closes, the spawned child is killed and all associated fibers - // are interrupted automatically — no `close()` method needed. - const runtimeScope = yield* Scope.Scope; - - const hostname = input.hostname ?? DEFAULT_HOSTNAME; - const port = - input.port ?? - (yield* netService.findAvailablePort(0).pipe( - Effect.mapError( - (cause) => - new OpenCodeRuntimeError({ - operation: "startOpenCodeServerProcess", - detail: `Failed to find available port: ${openCodeRuntimeErrorDetail(cause)}`, - cause, - }), - ), - )); - const timeoutMs = input.timeoutMs ?? DEFAULT_OPENCODE_SERVER_TIMEOUT_MS; - const args = ["serve", `--hostname=${hostname}`, `--port=${port}`]; - const spawnCommand = yield* resolveCommand(input.binaryPath, args, input.environment); - const serverPassword = resolveOpenCodeServerPassword({ - external: false, - ...(input.serverPassword !== undefined ? { serverPassword: input.serverPassword } : {}), - ...(input.environment !== undefined ? { environment: input.environment } : {}), - }); - - // Scopes close in reverse order. Forking this before the group kill is - // registered forgets the ledger entry only once the group is stopped. - const ledgerScope = yield* Scope.fork(runtimeScope); - const child = yield* spawner - .spawn( - ChildProcess.make(spawnCommand.command, spawnCommand.args, { - detached: hostPlatform !== "win32", - shell: spawnCommand.shell, - env: { - ...input.environment, - ...(serverPassword !== undefined ? { OPENCODE_SERVER_PASSWORD: serverPassword } : {}), - // Respect an OPENCODE_CONFIG_CONTENT provided by the caller or - // the inherited process environment, only falling back to the - // empty config when neither is set. Setting it unconditionally - // previously clobbered the user's opencode config, hiding their - // providers/models. The value is set explicitly (rather than - // relying on inheritance) because `extendEnv` is false whenever - // `input.environment` is provided. - OPENCODE_CONFIG_CONTENT: resolveOpenCodeConfigContent(input.environment), - }, - extendEnv: input.environment === undefined, - }), - ) - .pipe( - Effect.provideService(Scope.Scope, runtimeScope), - Effect.mapError( - (cause) => - new OpenCodeRuntimeError({ - operation: "startOpenCodeServerProcess", - detail: `Failed to spawn OpenCode server process: ${openCodeRuntimeErrorDetail(cause)}`, - cause, - }), - ), - ); - - const killOpenCodeProcessGroup = (signal: NodeJS.Signals) => - hostPlatform === "win32" - ? child.kill({ killSignal: signal, forceKillAfter: "1 second" }).pipe(Effect.asVoid) - : Effect.sync(() => { - try { - signalProcessGroup(Number(child.pid), signal); - } catch { - // The direct child may already have exited after starting the - // server; the process group kill is best-effort cleanup for - // any serve process left in that group. - } - }); - const terminateChild = killOpenCodeProcessGroup("SIGTERM").pipe( - Effect.andThen(Effect.sleep("1 second")), - Effect.andThen(killOpenCodeProcessGroup("SIGKILL")), - Effect.ignore, - ); - // Registered before recording, so an interrupt while the ledger writes - // still stops the group. - yield* Scope.addFinalizer(runtimeScope, terminateChild); - const forgetServer = yield* serverLedger.track({ pid: Number(child.pid), port, args }); - yield* Scope.addFinalizer(ledgerScope, forgetServer); - - const stdoutRef = yield* Ref.make(""); - const stderrRef = yield* Ref.make(""); - const readyDeferred = yield* Deferred.make(); - - const setReadyFromStdoutChunk = (chunk: string) => - Ref.modify(stdoutRef, (stdout) => { - if (stdout === null) { - return [null, null] as const; - } - const nextStdout = `${stdout}${chunk}`; - return [ - parseServerUrlFromOutput(nextStdout), - nextStdout.slice(-OPENCODE_SERVER_STARTUP_MAX_OUTPUT_CHARS), - ] as const; - }).pipe( - Effect.flatMap((parsed) => - parsed ? Deferred.succeed(readyDeferred, parsed).pipe(Effect.ignore) : Effect.void, - ), - ); - - const stdoutFiber = yield* child.stdout.pipe( - Stream.decodeText(), - Stream.runForEach(setReadyFromStdoutChunk), - Effect.ignore, - Effect.forkIn(runtimeScope), - ); - const stderrFiber = yield* child.stderr.pipe( - Stream.decodeText(), - Stream.runForEach((chunk) => - Ref.update(stderrRef, (stderr) => - stderr === null - ? null - : `${stderr}${chunk}`.slice(-OPENCODE_SERVER_STARTUP_MAX_OUTPUT_CHARS), - ), - ), - Effect.ignore, - Effect.forkIn(runtimeScope), - ); - - const exitFiber = yield* child.exitCode.pipe( - Effect.flatMap((code) => - Effect.gen(function* () { - const stdout = (yield* Ref.get(stdoutRef)) ?? ""; - const stderr = (yield* Ref.get(stderrRef)) ?? ""; - const exitCode = Number(code); - yield* Deferred.fail( - readyDeferred, - new OpenCodeRuntimeError({ - operation: "startOpenCodeServerProcess", - detail: [ - `OpenCode server exited before startup completed (code: ${String(exitCode)}).`, - stdout.trim() ? `stdout:\n${stdout.trim()}` : null, - stderr.trim() ? `stderr:\n${stderr.trim()}` : null, - ] - .filter(Boolean) - .join("\n\n"), - cause: { exitCode, stdout, stderr }, - }), - ).pipe(Effect.ignore); - }), - ), - Effect.ignore, - Effect.forkIn(runtimeScope), - ); - - const readyExit = yield* Effect.exit( - Deferred.await(readyDeferred).pipe(Effect.timeoutOption(timeoutMs)), - ); - - if (Exit.isFailure(readyExit) || Option.isNone(readyExit.value)) { - yield* Fiber.interruptAll([stdoutFiber, stderrFiber, exitFiber]).pipe(Effect.ignore); - } - - if (Exit.isFailure(readyExit)) { - const squashed = Cause.squash(readyExit.cause); - return yield* ensureRuntimeError( - "startOpenCodeServerProcess", - `Failed while waiting for OpenCode server startup: ${openCodeRuntimeErrorDetail(squashed)}`, - squashed, - ); - } - - const readyOption = readyExit.value; - if (Option.isNone(readyOption)) { - return yield* new OpenCodeRuntimeError({ - operation: "startOpenCodeServerProcess", - detail: `Timed out waiting for OpenCode server start after ${timeoutMs}ms.`, - }); - } - - // Keep draining both pipes until the process scope closes. Stopping the - // readers can block OpenCode when its output buffers fill. Startup output - // is no longer needed, so discard later output instead of retaining it. - yield* Ref.set(stdoutRef, null); - yield* Ref.set(stderrRef, null); - - const url = readyOption.value; - const version = yield* ( - input.verify?.(url) ?? - verifyOpenCodeServerVersion( - createOpenCodeSdkClient({ - baseUrl: url, - directory: input.directory, - ...(serverPassword !== undefined ? { serverPassword } : {}), - }), - ) - ); - - return { - url, - ...(serverPassword !== undefined ? { serverPassword } : {}), - version, - isRunning: child.isRunning.pipe(Effect.orElseSucceed(() => false)), - exitCode: child.exitCode.pipe( - Effect.map(Number), - Effect.orElseSucceed(() => 0), - ), - } satisfies OpenCodeServerProcess; - }); - - const connectToOpenCodeServer: OpenCodeRuntimeShape["connectToOpenCodeServer"] = (input) => { - const serverUrl = input.serverUrl?.trim(); - if (serverUrl) { - const serverPassword = resolveOpenCodeServerPassword({ - external: true, - ...(input.serverPassword !== undefined ? { serverPassword: input.serverPassword } : {}), - }); - return verifyOpenCodeServerVersion( - createOpenCodeSdkClient({ - baseUrl: serverUrl, - directory: input.directory, - ...(serverPassword !== undefined ? { serverPassword } : {}), - }), - ).pipe( - Effect.map((version) => ({ - url: serverUrl, - ...(serverPassword !== undefined ? { serverPassword } : {}), - version, - exitCode: null, - external: true, - })), - ); - } - - return startOpenCodeServerProcess({ - binaryPath: input.binaryPath, - directory: input.directory, - ...(input.serverPassword !== undefined ? { serverPassword: input.serverPassword } : {}), - ...(input.environment !== undefined ? { environment: input.environment } : {}), - ...(input.port !== undefined ? { port: input.port } : {}), - ...(input.hostname !== undefined ? { hostname: input.hostname } : {}), - ...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}), - }).pipe( - Effect.map((server) => ({ - url: server.url, - ...(server.serverPassword !== undefined ? { serverPassword: server.serverPassword } : {}), - version: server.version, - exitCode: server.exitCode, - external: false, - })), - ); - }; - - const loadProviders = (client: OpencodeClient) => - runOpenCodeSdk("provider.list", (signal) => client.provider.list(undefined, { signal })).pipe( - Effect.filterMapOrFail( - (list) => - list.data - ? Result.succeed(list.data) - : Result.fail( - new OpenCodeRuntimeError({ - operation: "provider.list", - detail: "OpenCode provider list was empty.", - }), - ), - (result) => result, - ), - ); - - const loadAgents = (client: OpencodeClient) => - runOpenCodeSdk("app.agents", (signal) => client.app.agents(undefined, { signal })).pipe( - Effect.map((result) => result.data ?? []), - Effect.orElseSucceed((): ReadonlyArray => []), - ); - - const loadOpenCodeSkills: OpenCodeRuntimeShape["loadOpenCodeSkills"] = (client) => - runOpenCodeSdk("app.skills", (signal) => client.app.skills(undefined, { signal })).pipe( - Effect.map((result) => - (result.data ?? []).map((skill) => ({ - name: skill.name, - ...(skill.description === undefined ? {} : { description: skill.description }), - location: skill.location, - })), - ), - ); - const loadSkills = (client: OpencodeClient) => - loadOpenCodeSkills(client).pipe(Effect.orElseSucceed((): ReadonlyArray => [])); - - const loadOpenCodeInventory: OpenCodeRuntimeShape["loadOpenCodeInventory"] = (client) => - Effect.all( - [ - loadProviders(client), - loadAgents(client), - loadSkills(client), - loadOpenCodeCommands(client).pipe(Effect.orElseSucceed(() => [])), - ], - { - concurrency: "unbounded", - }, - ).pipe( - Effect.map(([providerList, agents, skills, commands]) => ({ - providerList, - agents, - skills, - commands, - })), - ); - - const loadInventoryFromCli: OpenCodeRuntimeShape["loadInventoryFromCli"] = (input) => - Effect.gen(function* () { - const env = input.environment !== undefined ? { environment: input.environment } : ({} as {}); - const commandContext = { cwd: input.cwd, ...env }; - - const runModelsCli = () => - runOpenCodeCommand({ - binaryPath: input.binaryPath, - args: ["models", "--verbose"], - ...commandContext, - }).pipe(Effect.exit); - const runAgentsCli = () => - runOpenCodeCommand({ - binaryPath: input.binaryPath, - args: ["agent", "list"], - ...commandContext, - }).pipe(Effect.exit); - const runSkillsCli = () => - runOpenCodeCommand({ - binaryPath: input.binaryPath, - args: ["debug", "skill"], - maxOutputBytes: OPENCODE_SKILL_DISCOVERY_MAX_OUTPUT_BYTES, - ...commandContext, - }).pipe(Effect.exit); - - // Every OpenCode CLI command opens the same shared SQLite database. Running them - // concurrently causes "database is locked" failures, so run them one at a time. - const [initialModelsResult, initialAgentsResult, initialSkillsResult] = yield* Effect.all( - [runModelsCli(), runAgentsCli(), runSkillsCli()], - { concurrency: 1 }, - ); - let modelsResult = initialModelsResult; - let agentsResult = initialAgentsResult; - let skillsResult = initialSkillsResult; - - // Retry once after 1s on transient failures (e.g. SQLite "database is locked") - const needsModelsRetry = modelsResult._tag === "Failure" || modelsResult.value.code !== 0; - const needsAgentsRetry = agentsResult._tag === "Failure" || agentsResult.value.code !== 0; - const needsSkillsRetry = skillsResult._tag === "Failure" || skillsResult.value.code !== 0; - if (needsModelsRetry || needsAgentsRetry || needsSkillsRetry) { - yield* Effect.sleep("1 second"); - const [m2, a2, s2] = yield* Effect.all( - [ - needsModelsRetry ? runModelsCli() : Effect.succeed(modelsResult), - needsAgentsRetry ? runAgentsCli() : Effect.succeed(agentsResult), - needsSkillsRetry ? runSkillsCli() : Effect.succeed(skillsResult), - ], - { concurrency: 1 }, - ); - modelsResult = m2; - agentsResult = a2; - skillsResult = s2; - } - - if (modelsResult._tag === "Failure") { - const cause = Cause.squash(modelsResult.cause); - return yield* ensureRuntimeError( - "loadInventoryFromCli", - `Failed to load OpenCode models: ${openCodeRuntimeErrorDetail(cause)}`, - cause, - ); - } - if (modelsResult.value.code !== 0) { - return yield* new OpenCodeRuntimeError({ - operation: "loadInventoryFromCli", - detail: `OpenCode models command exited with code ${modelsResult.value.code}.`, - }); - } - - const parsed = parseModelsCliOutput(modelsResult.value.stdout); - const connected = [...parsed.connected]; - const allProviders: ProviderListResponse["all"] = [...parsed.providers.values()].map( - (provider) => ({ - id: provider.id, - name: provider.name, - source: "config" as const, - env: [], - options: {}, - models: provider.models, - }), - ); - - // Agent and skill metadata enrich the provider snapshot but are not required - // for an authoritative model inventory, so either may degrade to an empty list. - let agents: ReadonlyArray = []; - if (agentsResult._tag === "Success" && agentsResult.value.code === 0) { - agents = parseAgentListCliOutput(agentsResult.value.stdout); - } - let skills: ReadonlyArray = []; - if (skillsResult._tag === "Success" && skillsResult.value.code === 0) { - skills = parseSkillsCliOutput(skillsResult.value.stdout); - } - - return { - providerList: { all: allProviders, default: {}, connected }, - agents, - skills, - }; - }); - - const loadSkillsFromCli: OpenCodeRuntimeShape["loadSkillsFromCli"] = (input) => - runOpenCodeCommand({ - binaryPath: input.binaryPath, - args: ["debug", "skill"], - cwd: input.cwd, - maxOutputBytes: OPENCODE_SKILL_DISCOVERY_MAX_OUTPUT_BYTES, - ...(input.environment !== undefined ? { environment: input.environment } : {}), - }).pipe( - Effect.flatMap((result) => - result.code === 0 - ? Effect.succeed(parseSkillsCliOutput(result.stdout)) - : Effect.fail( - new OpenCodeRuntimeError({ - operation: "loadSkillsFromCli", - detail: `OpenCode skills command exited with code ${result.code}.`, - }), - ), - ), - ); - - return { - startOpenCodeServerProcess, - connectToOpenCodeServer, - runOpenCodeCommand, - createOpenCodeSdkClient, - loadOpenCodeInventory, - loadOpenCodeSkills, - loadInventoryFromCli, - loadSkillsFromCli, - } satisfies OpenCodeRuntimeShape; -}); - -export class OpenCodeRuntime extends Context.Service()( - "t3/provider/opencodeRuntime", -) {} - -export const layer = Layer.effect(OpenCodeRuntime, makeOpenCodeRuntime).pipe( - Layer.provide(NetService.layer), -); diff --git a/apps/server/src/provider/opencodeVersionProbe.test.ts b/apps/server/src/provider/opencodeVersionProbe.test.ts deleted file mode 100644 index 75d3873e19a4d7a1d50f2fd5f46c54f8505ec965..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/opencodeVersionProbe.test.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { assert, describe, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import { HttpClient, HttpClientError, HttpClientRequest } from "effect/http"; - -import * as OpenCodeRuntime from "./opencodeRuntime.ts"; -import { - classifyOpenCodeCliVersion, - makeOpenCodeRuntimeProbe, - probeOpenCodeRuntime, -} from "./opencodeVersionProbe.ts"; -import { - OPENCODE_1_RESPONSES, - OPENCODE_2_RESPONSES, - replayOpenCodeServer, -} from "./testFixtures/opencodeProbeResponses.ts"; - -const noBinary = { - runOpenCodeCommand: () => Effect.die("A configured server must not run the local binary"), -} as unknown as OpenCodeRuntime.OpenCodeRuntimeShape; - -const probeServer = (serverUrl: string, serverPassword: string, http: HttpClient.HttpClient) => - probeOpenCodeRuntime({ binaryPath: "opencode", serverUrl, serverPassword }).pipe( - Effect.provideService(HttpClient.HttpClient, http), - Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, noBinary), - ); -const SERVER_URL = "http://127.0.0.1:4096/"; - -describe("OpenCode version probe", () => { - it("classifies the recorded `opencode --version` output of both versions", () => { - assert.deepStrictEqual(classifyOpenCodeCliVersion("1.18.32\n"), { - generation: "v1", - version: "1.18.32", - }); - assert.deepStrictEqual(classifyOpenCodeCliVersion("opencode v2.0.18\n"), { - generation: "v2", - version: "2.0.18", - }); - assert.isUndefined(classifyOpenCodeCliVersion("opencode dev build\n")); - }); - - it.effect("finds 2.x at /api/info without falling through to its HTML /global/health", () => - Effect.gen(function* () { - const paths: Array = []; - const probed = yield* probeServer( - SERVER_URL, - "pw", - replayOpenCodeServer(OPENCODE_2_RESPONSES, "pw", paths), - ); - assert.deepStrictEqual(probed, { generation: "v2", version: "2.0.18" }); - assert.deepStrictEqual(paths, ["/api/info"]); - }), - ); - - it.effect("skips the HTML 1.x serves at /api/info and finds it at /global/health", () => - Effect.gen(function* () { - const paths: Array = []; - const probed = yield* probeServer( - SERVER_URL, - "pw", - replayOpenCodeServer(OPENCODE_1_RESPONSES, "pw", paths), - ); - assert.deepStrictEqual(probed, { generation: "v1", version: "1.18.32" }); - assert.deepStrictEqual(paths, ["/api/info", "/global/health"]); - }), - ); - - it.effect("keeps a server URL's path prefix and query when probing", () => - Effect.gen(function* () { - const urls: Array = []; - const replay = replayOpenCodeServer(OPENCODE_1_RESPONSES, "pw"); - // A reverse proxy mounting OpenCode under /opencode, reached with a routing query. - const proxied = HttpClient.make((request, url) => { - urls.push(url.href); - return replay.execute( - HttpClientRequest.setUrl( - request, - `http://127.0.0.1:4096${url.pathname.replace(/^\/opencode/, "")}`, - ), - ); - }); - const probed = yield* probeServer("http://proxy:8080/opencode/?route=oc", "pw", proxied); - assert.strictEqual(probed.generation, "v1"); - assert.deepStrictEqual(urls, [ - "http://proxy:8080/opencode/api/info?route=oc", - "http://proxy:8080/opencode/global/health?route=oc", - ]); - }), - ); - - it.effect("reports a rejected password instead of guessing a version", () => - Effect.gen(function* () { - for (const responses of [OPENCODE_1_RESPONSES, OPENCODE_2_RESPONSES]) { - const paths: Array = []; - const error = yield* Effect.flip( - probeServer(SERVER_URL, "wrong", replayOpenCodeServer(responses, "pw", paths)), - ); - assert.match(error.detail, /401 Unauthorized/); - assert.deepStrictEqual(paths, ["/api/info"]); - } - }), - ); - - it.effect("sends a non-ASCII password as UTF-8, as both versions expect", () => - Effect.gen(function* () { - for (const [responses, password, generation] of [ - [OPENCODE_1_RESPONSES, "pässwörd", "v1"], - [OPENCODE_2_RESPONSES, "pass€word", "v2"], - ] as const) { - const probed = yield* probeServer( - SERVER_URL, - password, - replayOpenCodeServer(responses, password), - ); - assert.strictEqual(probed.generation, generation); - } - }), - ); - - it.effect("does not take other JSON at /api/info for OpenCode 2", () => - Effect.gen(function* () { - const impostor = { - ...OPENCODE_1_RESPONSES, - "/api/info": { status: 200, contentType: "application/json", body: '{"version":"3.4.1"}' }, - "/global/health": OPENCODE_1_RESPONSES["/api/info"], - }; - const error = yield* Effect.flip( - probeServer(SERVER_URL, "pw", replayOpenCodeServer(impostor, "pw")), - ); - assert.match(error.detail, /did not identify itself as OpenCode/); - }), - ); - - it.effect("never puts the configured URL or its credentials in an error detail", () => - Effect.gen(function* () { - const secret = "userinfo-secret"; - const unreachable = HttpClient.make((request) => - Effect.die(`should not be reached for ${request.url}`), - ); - // A fetch failure that echoes the full request URL, as Node's does. - const refused = HttpClient.make((request) => - Effect.fail( - new HttpClientError.HttpClientError({ - reason: new HttpClientError.TransportError({ - request, - description: `connect ECONNREFUSED ${request.url}`, - }), - }), - ), - ); - for (const [serverUrl, http] of [ - [`127.0.0.1:4096?token=${secret}`, unreachable], - [`localhost:4096/${secret}`, unreachable], - [`http://user:${secret}@10.0.0.1:4096/?token=${secret}`, refused], - ] as const) { - const error = yield* Effect.flip(probeServer(serverUrl, secret, http)); - assert.notInclude(error.detail, secret, serverUrl); - assert.notInclude(error.detail, "10.0.0.1", serverUrl); - } - }), - ); - - it.effect("remembers a probed runtime but never a failure", () => - Effect.gen(function* () { - const outputs = ["", "opencode v2.0.18\n", "1.18.32\n"]; - let calls = 0; - const runtime = { - runOpenCodeCommand: () => { - const stdout = outputs[calls++]; - return stdout - ? Effect.succeed({ stdout, stderr: "", code: 0 }) - : Effect.fail( - new OpenCodeRuntime.OpenCodeRuntimeError({ operation: "spawn", detail: "ENOENT" }), - ); - }, - } as unknown as OpenCodeRuntime.OpenCodeRuntimeShape; - const probe = yield* makeOpenCodeRuntimeProbe( - probeOpenCodeRuntime({ binaryPath: "opencode", serverUrl: "", serverPassword: "" }).pipe( - Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, runtime), - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make(() => Effect.die("A local binary must not be probed over HTTP")), - ), - ), - ); - - yield* Effect.flip(probe.get); - assert.isTrue((yield* probe.lastSuccess)._tag === "None"); - assert.strictEqual((yield* probe.get).generation, "v2"); - assert.strictEqual((yield* probe.get).generation, "v2"); - assert.strictEqual(calls, 2); - // A status refresh re-probes, so an in-place downgrade or upgrade re-routes. - assert.strictEqual((yield* probe.refresh).generation, "v1"); - assert.strictEqual((yield* probe.get).generation, "v1"); - assert.strictEqual(calls, 3); - }), - ); -}); diff --git a/apps/server/src/provider/opencodeVersionProbe.ts b/apps/server/src/provider/opencodeVersionProbe.ts deleted file mode 100644 index aca8ab2e8189e0800a2538426ac2ffb090bed833..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/opencodeVersionProbe.ts +++ /dev/null @@ -1,198 +0,0 @@ -/** Detects whether an OpenCode instance runs 1.x or 2.x, so the driver can pick its runtime. */ -import { parseSemver } from "@t3tools/shared/semver"; -import * as Cache from "effect/Cache"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; -import { HttpClient, HttpClientRequest } from "effect/http"; - -import * as OpenCodeRuntime from "./opencodeRuntime.ts"; -import { parseGenericCliVersion } from "@t3tools/provider-core/server/snapshotProbe"; - -export interface ProbedOpenCode { - readonly generation: "v1" | "v2"; - readonly version: string; -} - -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. -const decodeApiInfo = Schema.decodeUnknownOption( - Schema.fromJsonString(Schema.Struct({ version: Schema.String, pid: Schema.Int })), -); -const decodeGlobalHealth = Schema.decodeUnknownOption( - Schema.fromJsonString(Schema.Struct({ healthy: Schema.Literal(true), version: Schema.String })), -); - -function probed(version: string | null | undefined): ProbedOpenCode | undefined { - const major = parseSemver(version ?? "")?.major; - if (!version || major === undefined) return undefined; - return { generation: major >= 2 ? "v2" : "v1", version }; -} - -/** `opencode --version` prints `1.18.32` on 1.x and `opencode v2.0.18` on 2.x. */ -export const classifyOpenCodeCliVersion = (output: string) => - probed(parseGenericCliVersion(output)); - -/** - * 2.x answers `/api/info` and 1.x answers `/global/health`. Each serves its web UI's HTML with a - * 200 on the other's path, so only a JSON body counts. Both versions answer a wrong password with - * a 401 on either path, so a 401 says nothing about the version. - */ -function classifyOpenCodeProbeResponse( - path: "/api/info" | "/global/health", - response: { - readonly status: number; - readonly contentType: string | undefined; - readonly body: string; - }, -): ProbedOpenCode | "unauthorized" | undefined { - if (response.status === 401) return "unauthorized"; - const mediaType = response.contentType?.split(";")[0]?.trim().toLowerCase(); - if (response.status !== 200 || mediaType !== "application/json") return undefined; - const version: Option.Option = - path === "/api/info" - ? Option.map(decodeApiInfo(response.body), (info) => info.version) - : Option.map(decodeGlobalHealth(response.body), (health) => health.version); - return probed(Option.getOrUndefined(version)); -} - -const probeOpenCodeBinary = Effect.fn("probeOpenCodeBinary")(function* ( - binaryPath: string, - environment: NodeJS.ProcessEnv | undefined, -) { - const runtime = yield* OpenCodeRuntime.OpenCodeRuntime; - const { stdout } = yield* runtime - .runOpenCodeCommand({ - binaryPath, - args: ["--version"], - ...(environment === undefined ? {} : { environment }), - }) - .pipe( - Effect.timeoutOrElse({ - duration: OPENCODE_VERSION_PROBE_TIMEOUT, - orElse: () => - Effect.fail( - new OpenCodeRuntime.OpenCodeRuntimeError({ - operation: "probeOpenCodeBinary", - detail: `OpenCode CLI version probe timed out after ${OPENCODE_VERSION_PROBE_TIMEOUT}.`, - }), - ), - }), - ); - const result = classifyOpenCodeCliVersion(stdout); - if (result) return result; - return yield* new OpenCodeRuntime.OpenCodeRuntimeError({ - operation: "probeOpenCodeBinary", - detail: `Unable to determine OpenCode version from \`opencode --version\` output. T3 Code requires OpenCode v${OpenCodeRuntime.MINIMUM_OPENCODE_VERSION} or newer.`, - }); -}); - -// Server failures reach clients through the provider status, so their details are fixed text: -// the underlying error can carry the configured URL, its credentials, or the password header. -const probeOpenCodeServer = Effect.fn("probeOpenCodeServer")(function* ( - serverUrl: string, - serverPassword: string, -) { - const client = yield* HttpClient.HttpClient; - const baseUrl = URL.parse(serverUrl.trim()); - if (baseUrl?.protocol !== "http:" && baseUrl?.protocol !== "https:") { - return yield* new OpenCodeRuntime.OpenCodeRuntimeError({ - operation: "probeOpenCodeServer", - detail: "The OpenCode server URL is not a valid http:// or https:// URL.", - }); - } - // UTF-8, as the 1.x SDK client sends it; `HttpClientRequest.basicAuth` uses Latin-1 `btoa`. - const authorization = serverPassword - ? `Basic ${Buffer.from(`opencode:${serverPassword}`, "utf8").toString("base64")}` - : undefined; - for (const path of ["/api/info", "/global/health"] as const) { - // A path prefix and query on the configured URL are kept: `/base/?x=1` → `/base/api/info?x=1`. - const url = new URL(baseUrl); - url.pathname = `${url.pathname.replace(/\/+$/, "")}${path}`; - const request = HttpClientRequest.get(url.href); - const result = yield* client - .execute( - authorization - ? HttpClientRequest.setHeader(request, "authorization", authorization) - : request, - ) - .pipe( - Effect.flatMap((response) => - Effect.map(response.text, (body) => - classifyOpenCodeProbeResponse(path, { - status: response.status, - contentType: response.headers["content-type"], - body, - }), - ), - ), - Effect.mapError( - (cause) => - new OpenCodeRuntime.OpenCodeRuntimeError({ - operation: "probeOpenCodeServer", - detail: "Couldn't reach the OpenCode server.", - cause, - }), - ), - Effect.timeoutOrElse({ - duration: OPENCODE_SERVER_PROBE_TIMEOUT, - orElse: () => - Effect.fail( - new OpenCodeRuntime.OpenCodeRuntimeError({ - operation: "probeOpenCodeServer", - detail: "Timed out while checking the OpenCode server version.", - }), - ), - }), - ); - if (result === "unauthorized") { - return yield* new OpenCodeRuntime.OpenCodeRuntimeError({ - operation: "probeOpenCodeServer", - detail: "401 Unauthorized: the OpenCode server rejected the password.", - }); - } - if (result !== undefined) return result; - } - return yield* new OpenCodeRuntime.OpenCodeRuntimeError({ - operation: "probeOpenCodeServer", - detail: `The server did not identify itself as OpenCode. T3 Code requires OpenCode v${OpenCodeRuntime.MINIMUM_OPENCODE_VERSION} or newer.`, - }); -}); - -/** Probes a configured server when `serverUrl` is set, otherwise the local binary. */ -export const probeOpenCodeRuntime = ( - settings: { - readonly binaryPath: string; - readonly serverUrl: string; - readonly serverPassword: string; - }, - environment?: NodeJS.ProcessEnv, -): Effect.Effect< - ProbedOpenCode, - OpenCodeRuntime.OpenCodeRuntimeError, - HttpClient.HttpClient | OpenCodeRuntime.OpenCodeRuntime -> => - settings.serverUrl.trim().length > 0 - ? probeOpenCodeServer(settings.serverUrl, settings.serverPassword) - : probeOpenCodeBinary(settings.binaryPath, environment); - -/** - * One instance's runtime, remembered after the first successful probe. Settings changes rebuild - * the driver; `refresh` re-probes (status checks use it, so an in-place upgrade re-routes). A - * failed probe is never remembered. `lastSuccess` never probes, for calls too hot to wait on one. - */ -export const makeOpenCodeRuntimeProbe = (probe: Effect.Effect) => - Effect.map( - Cache.makeWith(() => probe, { - capacity: 1, - timeToLive: (exit) => (Exit.isSuccess(exit) ? Duration.infinity : Duration.zero), - }), - (cache) => ({ - get: Cache.get(cache, undefined), - refresh: Cache.refresh(cache, undefined), - lastSuccess: Cache.getSuccess(cache, undefined), - }), - ); diff --git a/apps/server/src/provider/providerStatusCache.ts b/apps/server/src/provider/providerStatusCache.ts index a4b260f43bf62d224316d8b4c0d9cdc091ec8113..ea9e7ff9ce7a8f7820dcfebb8b9cf98c0bb45e3d 100644 --- a/apps/server/src/provider/providerStatusCache.ts +++ b/apps/server/src/provider/providerStatusCache.ts @@ -9,7 +9,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; -import { writeFileStringAtomically } from "../atomicWrite.ts"; +import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; const decodeProviderStatusCache = Schema.decodeUnknownEffect( Schema.fromJsonString(ServerProviderSchema), diff --git a/apps/server/src/provider/testFixtures/opencodeProbeResponses.ts b/apps/server/src/provider/testFixtures/opencodeProbeResponses.ts deleted file mode 100644 index 0eff526acd4f5d0b393845f76b8f32f499b2e60b..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/testFixtures/opencodeProbeResponses.ts +++ /dev/null @@ -1,93 +0,0 @@ -/** - * Responses recorded from `opencode serve` 1.18.32 and 2.0.18 on 2026-09-29, replayed by an - * `HttpClient` so the version probe runs against real bytes. - */ -import * as Effect from "effect/Effect"; -import { HttpClient, HttpClientResponse } from "effect/http"; - -interface RecordedResponse { - readonly status: number; - readonly contentType?: string; - readonly body: string; -} - -// Both versions serve their web UI's index page for unknown paths. Bodies are cut after the -// first lines; the classifier rejects them on content type alone. -const SPA_HTML = - '\n\n'; - -export const OPENCODE_2_RESPONSES = { - "/api/info": { - status: 200, - contentType: "application/json", - body: '{"version":"2.0.18","pid":2393556,"urls":["http://127.0.0.1:48771"],"paths":{"tmp":"/tmp/opencode"}}', - }, - "/global/health": { status: 200, contentType: "text/html", body: SPA_HTML }, - unauthorized: { - status: 401, - contentType: "application/json", - body: '{"_tag":"UnauthorizedError","message":"Authentication required"}', - }, -} satisfies Record; - -/** - * A 2.0.18 server's command and skill lists for a directory with one command - * (`.opencode/command/bee.md`) and one skill (`.opencode/skills/plum`), live on - * 2026-09-29. Skill contents are cut; machine-wide skills are dropped. - */ -export const OPENCODE_2_WORKSPACE_RESPONSES = { - "/api/command": { - status: 200, - contentType: "application/json", - body: '{"location":{"directory":"/work"},"data":[{"name":"init","description":"guided AGENTS.md setup"},{"name":"review","description":"review changes [commit|branch|pr], defaults to uncommitted"},{"name":"bee","description":"B command"}]}', - }, - "/api/skill": { - status: 200, - contentType: "application/json", - body: '{"location":{"directory":"/work"},"data":[{"id":"opencode","name":"OpenCode","description":"Use this skill for any question about OpenCode itself.","path":"/builtin/opencode.md","content":"# OpenCode"},{"id":"report","name":"Report","description":"Use when the user wants to report an opencode issue or bug.","path":"/builtin/report.md","content":""},{"id":"plum","name":"plum","description":"plum skill","path":"/work/.opencode/skills/plum/SKILL.md","content":"Say PLUM."}]}', - }, -} satisfies Record; - -export const OPENCODE_1_RESPONSES = { - "/api/info": { status: 200, contentType: "text/html", body: SPA_HTML }, - "/global/health": { - status: 200, - contentType: "application/json", - body: '{"healthy":true,"version":"1.18.32"}', - }, - // 1.x also answers a wrong password with an empty 401 on every path. - unauthorized: { status: 401, body: "" }, -} satisfies Record; - -/** Replays a recorded server; credentials other than `password` (none when empty) get its 401. */ -export function replayOpenCodeServer( - responses: - | typeof OPENCODE_1_RESPONSES - | typeof OPENCODE_2_RESPONSES - | (typeof OPENCODE_2_RESPONSES & typeof OPENCODE_2_WORKSPACE_RESPONSES), - password: string, - requestedPaths: Array = [], -) { - const answers: Record = responses; - // Both versions decode Basic credentials as UTF-8 (checked live with `pässwörd` and `pass€word`). - const expected = password - ? `Basic ${Buffer.from(`opencode:${password}`, "utf8").toString("base64")}` - : undefined; - return HttpClient.make((request) => { - const path = new URL(request.url).pathname; - requestedPaths.push(path); - const recorded: RecordedResponse = - request.headers.authorization === expected - ? (answers[path] ?? responses.unauthorized) - : responses.unauthorized; - return Effect.succeed( - HttpClientResponse.fromWeb( - request, - new Response(recorded.body, { - status: recorded.status, - headers: recorded.contentType ? { "content-type": recorded.contentType } : {}, - }), - ), - ); - }); -} diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index b6c0d99848ae8457df3903d3cb5e30ec52b0a1d8..f91976f38ece92999306a05dab03e7a69f635810 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -43,8 +43,8 @@ import * as ProviderEventIngestor from "./orchestration-v2/ProviderEventIngestor import * as ModelManifest from "./provider/ModelManifest.ts"; import * as ResetCreditCoordinator from "./provider/resetCreditCoordinator.ts"; import * as ProviderEventLoggers from "./provider/ProviderEventLoggers.ts"; -import * as OpenCodeRuntime from "./provider/opencodeRuntime.ts"; -import * as OpenCodeServerLedger from "./provider/OpenCodeServerLedger.ts"; +import * as OpenCodeRuntime from "@t3tools/provider-opencode/server/OpenCodeRuntime"; +import * as OpenCodeServerLedger from "@t3tools/provider-opencode/server/OpenCodeServerLedger"; import * as AcpRegistryCatalog from "./provider/AcpRegistryCatalog.ts"; import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; import * as CheckpointStore from "./checkpointing/CheckpointStore.ts"; @@ -614,7 +614,18 @@ const layerRuntimeCoreDependencies = layerRuntimeCoreDependenciesBase.pipe( // the rewritten registry reads snapshots off the instance registry and // no longer transitively provides it. Exposing it at the runtime level // keeps a single Live for all opencode consumers. - Layer.provideMerge(OpenCodeRuntime.layer.pipe(Layer.provide(OpenCodeServerLedger.layer))), + Layer.provideMerge( + OpenCodeRuntime.layer.pipe( + Layer.provide( + Layer.unwrap( + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + return OpenCodeServerLedger.layer({ stateDir: config.stateDir }); + }), + ), + ), + ), + ), Layer.provideMerge(layerWorkspace), Layer.provideMerge(ProjectEnrichmentService.layer), Layer.provideMerge(Layer.mergeAll(NativeAppIconResolver.layer, layerProjectFaviconResolver)), diff --git a/apps/server/src/serverRuntimeState.ts b/apps/server/src/serverRuntimeState.ts index 26535ae348a2d60b320dbc947ab264000ce194d9..8e565b46e26b7e1c32b90aeae9811551dc645e57 100644 --- a/apps/server/src/serverRuntimeState.ts +++ b/apps/server/src/serverRuntimeState.ts @@ -4,7 +4,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; -import { writeFileStringAtomically } from "./atomicWrite.ts"; +import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; import type * as ServerConfig from "./config.ts"; import { formatHostForUrl, isWildcardHost } from "./startupAccess.ts"; diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 1611b7dfddb9520f2222912dda1148cf22114d38..4444a7851dffca7c862ed945342d6ef486895ba0 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -28,7 +28,7 @@ import * as SqlClient from "effect/sql/SqlClient"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as ServerConfig from "./config.ts"; import * as SqlitePersistence from "./persistence/Sqlite.ts"; -import { writeFileStringAtomically } from "./atomicWrite.ts"; +import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; import * as ServerSettingsModule from "./serverSettings.ts"; import { resolveProviderInstanceTerminalEnvironment } from "./terminal/Manager.ts"; diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 0695f673650d9422fbc7de13786c2e0be4541f87..7536a74d6e02cc56f1123eedec70c472a10731db 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -53,7 +53,7 @@ import * as Semaphore from "effect/Semaphore"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as SqlClient from "effect/sql/SqlClient"; -import { writeFileStringAtomically } from "./atomicWrite.ts"; +import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; import { resolveSymlinkTarget } from "@t3tools/shared/symlink"; import * as ServerConfig from "./config.ts"; import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct"; diff --git a/apps/server/src/telemetry/Identify.ts b/apps/server/src/telemetry/Identify.ts index dcb59d8bb3fbe6cee448749301a075a762fcfeb0..4009625d57525cfacd64fe62626b16d90ef35991 100644 --- a/apps/server/src/telemetry/Identify.ts +++ b/apps/server/src/telemetry/Identify.ts @@ -8,7 +8,7 @@ import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; -import { writeFileStringAtomically } from "../atomicWrite.ts"; +import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; import * as ServerConfig from "../config.ts"; /** diff --git a/apps/server/src/textGeneration/OpenCode2TextGeneration.test.ts b/apps/server/src/textGeneration/OpenCode2TextGeneration.test.ts index eaed9ea6abd6a36a9d53aa17470ca71c71cd448a..424764cd8f377ab8befecfd3e41270d3fdaacf1e 100644 --- a/apps/server/src/textGeneration/OpenCode2TextGeneration.test.ts +++ b/apps/server/src/textGeneration/OpenCode2TextGeneration.test.ts @@ -4,15 +4,13 @@ import { ProviderInstanceId } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as ServerConfig from "../config.ts"; +import { layerTestProviderHost } from "@t3tools/provider-testing/host"; import * as OpenCode2AdapterV2Testkit from "../orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts"; -import * as OpenCode2Server from "../provider/opencode2/OpenCode2Server.ts"; -import * as OpenCode2TextGeneration from "./OpenCode2TextGeneration.ts"; +import * as OpenCode2Server from "@t3tools/provider-opencode/server/v2/OpenCode2Server"; +import * as OpenCode2TextGeneration from "@t3tools/provider-opencode/server/v2/textGeneration"; import { OPENCODE2_TITLE_GENERATION } from "./OpenCode2TextGeneration.fixture.ts"; -const layer = ServerConfig.ServerConfig.layerTest(process.cwd(), { - prefix: "t3code-opencode2-text-generation-test-", -}).pipe(Layer.provideMerge(NodeServices.layer)); +const layer = layerTestProviderHost().pipe(Layer.provideMerge(NodeServices.layer)); it.layer(layer)("OpenCode2TextGeneration", (it) => { it.effect("generates a title in a temporary session on the free tier and removes it", () => diff --git a/apps/server/src/textGeneration/OpenCode2TextGeneration.ts b/apps/server/src/textGeneration/OpenCode2TextGeneration.ts deleted file mode 100644 index f6ceb7252a8e1e70df12cbb9c21cd3861973ad10..0000000000000000000000000000000000000000 --- a/apps/server/src/textGeneration/OpenCode2TextGeneration.ts +++ /dev/null @@ -1,206 +0,0 @@ -/** - * Text generation on an OpenCode 2 server. Both of its `generate` endpoints - * answer 403 on OpenCode's free tier ("free tier can only be used from within - * OpenCode"), so each request runs as a prompt in a temporary session, as 1.x - * does, and the session is removed afterwards. - * - * @module textGeneration/OpenCode2TextGeneration - */ -import { AbsolutePath, Location, Model, Provider, Session } from "@opencode/client/effect"; -import { TextGenerationError } from "@t3tools/contracts"; -import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; -import * as Deferred from "effect/Deferred"; -import * as Effect from "effect/Effect"; -import * as Schema from "effect/Schema"; -import * as Stream from "effect/Stream"; - -import * as ServerConfig from "../config.ts"; -import { resolveAttachmentPath } from "../attachmentStore.ts"; -import type { OpenCode2Connection } from "../provider/opencode2/OpenCode2Server.ts"; -import * as OpenCode2Server from "../provider/opencode2/OpenCode2Server.ts"; -import { parseOpenCodeModelSlug } from "../provider/opencodeRuntime.ts"; -import * as TextGenerationOperations from "@t3tools/provider-core/server/textGenerationOperations"; - -const isTextGenerationError = Schema.is(TextGenerationError); - -/** A generation that takes this long is stuck; its session is stopped and removed. */ -const GENERATION_TIMEOUT = "3 minutes"; - -/** - * Nothing in a text generation needs a tool, so every tool asks and T3 is not - * there to answer. Denying `shell` or `read` outright gets the whole session - * refused on OpenCode's free models, and an ask that is never answered would - * hang, so asks are rejected as they arrive. - */ -const GENERATION_PERMISSIONS = [{ action: "*", resource: "*", effect: "ask" }] as const; - -/** How a generation ended. A failure's cause is the provider's own error, kept out of `detail`. */ -type Outcome = - | { readonly _tag: "text"; readonly text: string } - | { readonly _tag: "failed"; readonly detail: string; readonly cause?: unknown }; - -const runOnServer = ( - connection: OpenCode2Connection, - input: TextGenerationOperations.Request, - attachmentsDir: string, -) => - Effect.gen(function* () { - const { client } = connection; - const parsed = parseOpenCodeModelSlug(input.modelSelection.model); - if (parsed === null) { - return yield* new TextGenerationError({ - operation: input.operation, - detail: "OpenCode model selection must use the 'provider/model' format.", - }); - } - const variant = getModelSelectionStringOptionValue(input.modelSelection, "variant"); - const outcome = yield* Deferred.make(); - let sessionId: string | undefined; - const texts = new Map(); - // Subscribed first: the stream is volatile, and the reply arrives on it. - const events = yield* connection.events; - yield* events.pipe( - Stream.runForEach((event) => { - // An execution end a newer server sent in a shape this build cannot - // read still ends the generation, as it ends a turn in the adapter. - if (event.type === "unreadable.execution.ended") { - if (event.sessionID !== sessionId) return Effect.void; - return Deferred.succeed( - outcome, - event.executionType === "session.execution.succeeded" - ? { _tag: "text", text: [...texts.values()].join("\n").trim() } - : { - _tag: "failed", - detail: "OpenCode ended the generation in a way this version cannot read.", - cause: event, - }, - ); - } - if ( - sessionId === undefined || - !("data" in event) || - !("sessionID" in event.data) || - event.data.sessionID !== sessionId - ) { - return Effect.void; - } - switch (event.type) { - case "session.text.ended": - texts.set(`${event.data.assistantMessageID}:${event.data.ordinal}`, event.data.text); - return Effect.void; - case "permission.asked": - return client.permission - .reply({ - sessionID: event.data.sessionID, - requestID: event.data.id, - decision: "reject", - message: "Tools are not available while generating text.", - }) - .pipe(Effect.ignore({ log: true })); - case "session.execution.succeeded": - return Deferred.succeed(outcome, { - _tag: "text", - text: [...texts.values()].join("\n").trim(), - }); - case "session.execution.failed": - return Deferred.succeed(outcome, { - _tag: "failed", - detail: "OpenCode could not generate the text.", - cause: event.data.error, - }); - case "session.execution.interrupted": - return Deferred.succeed(outcome, { - _tag: "failed", - detail: "OpenCode stopped the generation.", - }); - default: - return Effect.void; - } - }), - // The reply only arrives on this stream, so a lost stream ends the wait. - Effect.exit, - Effect.flatMap((exit) => - Deferred.succeed(outcome, { - _tag: "failed", - detail: "The OpenCode event stream was lost.", - cause: exit, - }), - ), - Effect.forkScoped, - ); - const session = yield* client.session.create({ - title: `T3 Code ${input.operation}`, - location: Location.PublicRef.make({ directory: AbsolutePath.make(input.cwd) }), - model: Model.Ref.make({ - providerID: Provider.ID.make(parsed.providerID), - id: Model.ID.make(parsed.modelID), - ...(variant === undefined ? {} : { variant: Model.VariantID.make(variant) }), - }), - permissions: GENERATION_PERMISSIONS, - }); - sessionId = session.id; - yield* Effect.addFinalizer(() => - client.session - .remove({ sessionID: session.id }) - .pipe(Effect.timeout("5 seconds"), Effect.ignore({ log: true })), - ); - const images = (input.attachments ?? []).flatMap((attachment) => { - if (attachment.type !== "image") return []; - const path = resolveAttachmentPath({ attachmentsDir, attachment }); - return path === null ? [] : [{ uri: `file://${path}`, name: attachment.name }]; - }); - yield* client.session.prompt({ - sessionID: session.id, - text: input.prompt, - ...(images.length === 0 ? {} : { files: images }), - }); - const result = yield* Deferred.await(outcome).pipe( - Effect.timeoutOrElse({ - duration: GENERATION_TIMEOUT, - orElse: () => - client.session.interrupt({ sessionID: Session.ID.make(session.id) }).pipe( - Effect.ignore, - Effect.as({ - _tag: "failed", - detail: "OpenCode did not finish generating in time.", - }), - ), - }), - ); - if (result._tag === "failed") { - return yield* new TextGenerationError({ - operation: input.operation, - detail: result.detail, - ...(result.cause === undefined ? {} : { cause: result.cause }), - }); - } - if (result.text.length === 0) { - return yield* new TextGenerationError({ - operation: input.operation, - detail: "OpenCode returned empty output.", - }); - } - return result.text; - }).pipe(Effect.scoped); - -/** Text generation for an instance whose server is OpenCode 2. */ -export const make = Effect.fn("OpenCode2TextGeneration.make")(function* () { - const server = yield* OpenCode2Server.OpenCode2Server; - const { attachmentsDir } = yield* ServerConfig.ServerConfig; - const run: TextGenerationOperations.Runner = (input) => - server - .withConnection((connection) => runOnServer(connection, input, attachmentsDir)) - .pipe( - Effect.mapError((cause) => - isTextGenerationError(cause) - ? cause - : new TextGenerationError({ - operation: input.operation, - detail: "OpenCode text generation failed.", - cause, - }), - ), - Effect.flatMap((raw) => TextGenerationOperations.decodeJsonReply(input, "OpenCode", raw)), - ); - return TextGenerationOperations.fromRunner("OpenCode2TextGeneration", run); -}); diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts deleted file mode 100644 index f5d2aa1d2bbd6438d24cc5577e873cb57d5391a1..0000000000000000000000000000000000000000 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts +++ /dev/null @@ -1,616 +0,0 @@ -import { OpenCodeSettings, ProviderInstanceId, TextGenerationError } from "@t3tools/contracts"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { it } from "@effect/vitest"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Schema from "effect/Schema"; -import * as TestClock from "effect/testing/TestClock"; -import * as NetService from "@t3tools/shared/Net"; -import { beforeEach, expect } from "vite-plus/test"; - -import * as ServerConfig from "../config.ts"; -import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts"; -import * as OpenCodeServerOwner from "../provider/OpenCodeServerOwner.ts"; -import * as OpenCodeTextGeneration from "./OpenCodeTextGeneration.ts"; -import * as TextGeneration from "./TextGeneration.ts"; - -const runtimeMock = { - state: { - startCalls: [] as string[], - promptUrls: [] as string[], - promptParts: [] as ReadonlyArray[], - authHeaders: [] as Array, - closeCalls: [] as string[], - sessionCreateCalls: 0, - connectionError: undefined as Error | undefined, - sessionCreateError: undefined as unknown, - sessionResult: undefined as { data?: { id: string } } | undefined, - promptRequestError: undefined as unknown, - promptResult: undefined as - | { data?: { info?: { error?: unknown }; parts?: Array } } - | undefined, - }, - reset() { - this.state.startCalls.length = 0; - this.state.promptUrls.length = 0; - this.state.promptParts.length = 0; - this.state.authHeaders.length = 0; - this.state.closeCalls.length = 0; - this.state.sessionCreateCalls = 0; - this.state.connectionError = undefined; - this.state.sessionCreateError = undefined; - this.state.sessionResult = undefined; - this.state.promptRequestError = undefined; - this.state.promptResult = undefined; - }, -}; - -const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = { - startOpenCodeServerProcess: ({ binaryPath, serverPassword, environment }) => - Effect.gen(function* () { - const index = runtimeMock.state.startCalls.length + 1; - const url = `http://127.0.0.1:${4_300 + index}`; - runtimeMock.state.startCalls.push(binaryPath); - // The production runtime binds server lifetime to the caller's scope. - // Mirror that here so the closeCalls probe observes scope close. - yield* Effect.addFinalizer(() => - Effect.sync(() => { - runtimeMock.state.closeCalls.push(url); - }), - ); - const effectiveServerPassword = OpenCodeRuntime.resolveOpenCodeServerPassword({ - external: false, - ...(serverPassword !== undefined ? { serverPassword } : {}), - ...(environment !== undefined ? { environment } : {}), - }); - return { - url, - ...(effectiveServerPassword !== undefined - ? { serverPassword: effectiveServerPassword } - : {}), - version: "1.14.19", - isRunning: Effect.succeed(true), - exitCode: Effect.never, - }; - }), - connectToOpenCodeServer: ({ serverUrl, serverPassword }) => - runtimeMock.state.connectionError - ? Effect.fail( - new OpenCodeRuntime.OpenCodeRuntimeError({ - operation: "global.health", - detail: runtimeMock.state.connectionError.message, - cause: runtimeMock.state.connectionError, - }), - ) - : Effect.succeed({ - url: serverUrl ?? "http://127.0.0.1:4301", - ...(serverPassword ? { serverPassword } : {}), - version: "1.14.19", - exitCode: null, - external: Boolean(serverUrl), - }), - runOpenCodeCommand: () => Effect.succeed({ stdout: "", stderr: "", code: 0 }), - createOpenCodeSdkClient: ({ baseUrl, serverPassword }) => - ({ - session: { - create: async () => { - runtimeMock.state.sessionCreateCalls += 1; - if (runtimeMock.state.sessionCreateError !== undefined) { - throw runtimeMock.state.sessionCreateError; - } - return runtimeMock.state.sessionResult ?? { data: { id: `${baseUrl}/session` } }; - }, - prompt: async (input: { readonly parts: ReadonlyArray }) => { - runtimeMock.state.promptUrls.push(baseUrl); - runtimeMock.state.promptParts.push(input.parts); - runtimeMock.state.authHeaders.push( - serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null, - ); - if (runtimeMock.state.promptRequestError !== undefined) { - throw runtimeMock.state.promptRequestError; - } - return ( - runtimeMock.state.promptResult ?? { - data: { - parts: [ - { - type: "text", - text: JSON.stringify({ - subject: "Improve OpenCode reuse", - body: "Reuse one server for the full action.", - }), - }, - ], - }, - } - ); - }, - }, - }) as unknown as ReturnType, - loadOpenCodeInventory: () => - Effect.fail( - new OpenCodeRuntime.OpenCodeRuntimeError({ - operation: "loadOpenCodeInventory", - detail: "OpenCodeRuntimeTestDouble.loadOpenCodeInventory not used in this test", - cause: null, - }), - ), - loadOpenCodeSkills: () => Effect.succeed([]), - loadInventoryFromCli: () => - Effect.fail( - new OpenCodeRuntime.OpenCodeRuntimeError({ - operation: "loadInventoryFromCli", - detail: "OpenCodeRuntimeTestDouble.loadInventoryFromCli not used in this test", - cause: null, - }), - ), - loadSkillsFromCli: () => Effect.succeed([]), -}; - -const DEFAULT_TEST_MODEL_SELECTION = { - instanceId: ProviderInstanceId.make("opencode"), - model: "openai/gpt-5", -}; -const DEFAULT_COMMIT_MESSAGE_INPUT = { - cwd: process.cwd(), - branch: "feature/opencode-reuse", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, -}; - -const OPENCODE_TEXT_GENERATION_IDLE_TTL_MS = 30_000; - -const layerOpenCodeTextGenerationTest = Layer.succeed( - OpenCodeRuntime.OpenCodeRuntime, - OpenCodeRuntimeTestDouble, -).pipe( - Layer.provideMerge( - ServerConfig.ServerConfig.layerTest(process.cwd(), { - prefix: "t3code-opencode-text-generation-test-", - }), - ), - Layer.provideMerge(NetService.layer), - Layer.provideMerge(NodeServices.layer), -); - -const layerOpenCodeTextGenerationExistingServerTest = Layer.succeed( - OpenCodeRuntime.OpenCodeRuntime, - OpenCodeRuntimeTestDouble, -).pipe( - Layer.provideMerge( - ServerConfig.ServerConfig.layerTest(process.cwd(), { - prefix: "t3code-opencode-text-generation-existing-server-test-", - }), - ), - Layer.provideMerge(NetService.layer), - Layer.provideMerge(NodeServices.layer), -); - -const DEFAULT_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({ - binaryPath: "fake-opencode", -}); -const LOCAL_AUTH_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({ - binaryPath: "fake-opencode", - serverPassword: "secret-password", -}); -const EXISTING_SERVER_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({ - binaryPath: "fake-opencode", - serverUrl: "http://127.0.0.1:9999", - serverPassword: "secret-password", -}); -const EXTERNAL_SERVER_WITHOUT_AUTH_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({ - binaryPath: "fake-opencode", - serverUrl: "http://127.0.0.1:9999", -}); - -function withOpenCodeTextGeneration( - settings: OpenCodeSettings, - effectFn: (textGeneration: TextGeneration.TextGeneration["Service"]) => Effect.Effect, - environment?: NodeJS.ProcessEnv, -) { - return Effect.gen(function* () { - const serverOwner = yield* OpenCodeServerOwner.make({ - binaryPath: settings.binaryPath, - directory: process.cwd(), - ...(settings.serverPassword ? { serverPassword: settings.serverPassword } : {}), - ...(environment ? { environment } : {}), - }); - const textGeneration = yield* OpenCodeTextGeneration.makeOpenCodeTextGeneration(settings).pipe( - Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner), - ); - return yield* effectFn(textGeneration); - }).pipe(Effect.scoped); -} - -beforeEach(() => { - runtimeMock.reset(); -}); - -const advanceIdleClock = Effect.gen(function* () { - yield* Effect.yieldNow; - yield* TestClock.adjust(Duration.millis(OPENCODE_TEXT_GENERATION_IDLE_TTL_MS + 1)); - yield* Effect.yieldNow; -}); - -it.layer(layerOpenCodeTextGenerationTest)("OpenCodeTextGeneration", (it) => { - it.effect("excludes generic files from thread title generation", () => - withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => - Effect.gen(function* () { - runtimeMock.state.promptResult = { - data: { - parts: [{ type: "text", text: '{"title":"Review uploaded report"}' }], - }, - }; - - yield* textGeneration.generateThreadTitle({ - cwd: process.cwd(), - message: "Review these attachments.", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - attachments: [ - { - type: "image", - id: "thread-image-attachment", - name: "screenshot.png", - mimeType: "image/png", - sizeBytes: 3, - }, - { - type: "file", - id: "thread-report-attachment-pdf", - name: "report.pdf", - mimeType: "application/pdf", - sizeBytes: 42, - }, - ], - }); - - expect(runtimeMock.state.promptParts[0]).toEqual([ - expect.objectContaining({ type: "text" }), - expect.objectContaining({ type: "file", filename: "screenshot.png" }), - ]); - }), - ), - ); - - it.effect("passes configured authentication to a locally spawned server", () => - withOpenCodeTextGeneration(LOCAL_AUTH_OPENCODE_SETTINGS, (textGeneration) => - Effect.gen(function* () { - yield* textGeneration.generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT); - - expect(runtimeMock.state.startCalls).toEqual(["fake-opencode"]); - expect(runtimeMock.state.authHeaders).toEqual([ - `Basic ${btoa("opencode:secret-password")}`, - ]); - }), - ), - ); - - it.effect("uses an environment-only password for a locally spawned server", () => - withOpenCodeTextGeneration( - DEFAULT_OPENCODE_SETTINGS, - (textGeneration) => - Effect.gen(function* () { - yield* textGeneration.generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT); - - expect(runtimeMock.state.authHeaders).toEqual([ - `Basic ${btoa("opencode:environment-password")}`, - ]); - }), - { OPENCODE_SERVER_PASSWORD: "environment-password" }, - ), - ); - - it.effect("uses settings auth when the local environment password differs", () => - withOpenCodeTextGeneration( - LOCAL_AUTH_OPENCODE_SETTINGS, - (textGeneration) => - Effect.gen(function* () { - yield* textGeneration.generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT); - - expect(runtimeMock.state.authHeaders).toEqual([ - `Basic ${btoa("opencode:secret-password")}`, - ]); - }), - { OPENCODE_SERVER_PASSWORD: "environment-password" }, - ), - ); - - it.effect("reuses a warm server across back-to-back requests and closes it after idling", () => - withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => - Effect.gen(function* () { - yield* textGeneration.generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/opencode-reuse", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }); - yield* textGeneration.generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/opencode-reuse", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }); - - expect(runtimeMock.state.startCalls).toEqual(["fake-opencode"]); - expect(runtimeMock.state.promptUrls).toEqual([ - "http://127.0.0.1:4301", - "http://127.0.0.1:4301", - ]); - expect(runtimeMock.state.closeCalls).toEqual([]); - - yield* advanceIdleClock; - - expect(runtimeMock.state.closeCalls).toEqual(["http://127.0.0.1:4301"]); - }), - ).pipe(Effect.provide(TestClock.layer())), - ); - - it.effect("starts a new server after the warm server idles out", () => - withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => - Effect.gen(function* () { - yield* textGeneration.generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/opencode-reuse", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }); - - yield* advanceIdleClock; - - yield* textGeneration.generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/opencode-reuse", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }); - - expect(runtimeMock.state.startCalls).toEqual(["fake-opencode", "fake-opencode"]); - expect(runtimeMock.state.promptUrls).toEqual([ - "http://127.0.0.1:4301", - "http://127.0.0.1:4302", - ]); - expect(runtimeMock.state.closeCalls).toEqual(["http://127.0.0.1:4301"]); - }), - ).pipe(Effect.provide(TestClock.layer())), - ); - - it.effect("preserves the SDK cause when session creation fails", () => - withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => - Effect.gen(function* () { - const sdkCause = new Error("session endpoint unavailable"); - runtimeMock.state.sessionCreateError = sdkCause; - - const error = yield* textGeneration - .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) - .pipe(Effect.flip); - - expect(error).toBeInstanceOf(TextGenerationError); - expect(error.message).toContain("OpenCode session.create request failed."); - expect(error.cause).toMatchObject({ - _tag: "OpenCodeTextGenerationSessionRequestError", - operation: "generateCommitMessage", - cwd: process.cwd(), - cause: sdkCause, - }); - expect((error.cause as { cause: unknown }).cause).toBe(sdkCause); - }), - ), - ); - - it.effect("reports a missing session payload without manufacturing a cause", () => - withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => - Effect.gen(function* () { - runtimeMock.state.sessionResult = {}; - - const error = yield* textGeneration - .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) - .pipe(Effect.flip); - - expect(error.message).toContain("OpenCode session.create returned no session payload."); - expect(error.cause).toMatchObject({ - _tag: "OpenCodeTextGenerationSessionPayloadError", - operation: "generateCommitMessage", - cwd: process.cwd(), - }); - expect(error.cause).not.toHaveProperty("cause"); - }), - ), - ); - - it.effect("preserves the SDK cause and request context when prompting fails", () => - withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => - Effect.gen(function* () { - const sdkCause = new Error("prompt endpoint unavailable"); - runtimeMock.state.promptRequestError = sdkCause; - - const error = yield* textGeneration - .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) - .pipe(Effect.flip); - - expect(error.message).toContain("OpenCode session.prompt request failed."); - expect(error.cause).toMatchObject({ - _tag: "OpenCodeTextGenerationPromptRequestError", - operation: "generateCommitMessage", - cwd: process.cwd(), - sessionId: "http://127.0.0.1:4301/session", - providerId: "openai", - modelId: "gpt-5", - cause: sdkCause, - }); - expect((error.cause as { cause: unknown }).cause).toBe(sdkCause); - }), - ), - ); - - it.effect("returns a typed empty-output error for malformed and blank response parts", () => - withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => - Effect.gen(function* () { - runtimeMock.state.promptResult = { - data: { - parts: [null, { type: "tool" }, { type: "text", text: " " }], - }, - }; - - const error = yield* textGeneration - .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) - .pipe(Effect.flip); - - expect(error.message).toContain("OpenCode returned empty output."); - expect(error.cause).toMatchObject({ - _tag: "OpenCodeTextGenerationEmptyOutputError", - operation: "generateCommitMessage", - cwd: process.cwd(), - sessionId: "http://127.0.0.1:4301/session", - providerId: "openai", - modelId: "gpt-5", - responsePartCount: 3, - textPartCount: 1, - }); - expect(error.cause).not.toHaveProperty("cause"); - }), - ), - ); - - it.effect("parses JSON returned as plain text output", () => - withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => - Effect.gen(function* () { - runtimeMock.state.promptResult = { - data: { - parts: [ - { - type: "text", - text: 'Here is the result:\n{"subject":"Tighten OpenCode parsing","body":"Handle JSON text output locally."}', - }, - ], - }, - }; - - const result = yield* textGeneration.generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/opencode-reuse", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }); - - expect(result).toEqual({ - subject: "Tighten OpenCode parsing", - body: "Handle JSON text output locally.", - }); - }), - ), - ); - - it.effect("surfaces the upstream OpenCode structured-output error message", () => - withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => - Effect.gen(function* () { - runtimeMock.state.promptResult = { - data: { - info: { - error: { - name: "StructuredOutputError", - data: { - message: "Model did not produce structured output", - retries: 2, - }, - }, - }, - }, - }; - - const error = yield* textGeneration - .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) - .pipe(Effect.flip); - - expect(error.message).toContain("Model did not produce structured output"); - expect(error.cause).toMatchObject({ - _tag: "OpenCodeTextGenerationPromptResponseError", - operation: "generateCommitMessage", - cwd: process.cwd(), - sessionId: "http://127.0.0.1:4301/session", - providerId: "openai", - modelId: "gpt-5", - providerErrorName: "StructuredOutputError", - providerMessage: "Model did not produce structured output", - }); - expect(error.cause).not.toHaveProperty("cause"); - }), - ), - ); -}); - -it.layer(layerOpenCodeTextGenerationExistingServerTest)( - "OpenCodeTextGeneration with configured server URL", - (it) => { - it.effect("does not send a local environment password to a configured server", () => - withOpenCodeTextGeneration( - EXTERNAL_SERVER_WITHOUT_AUTH_OPENCODE_SETTINGS, - (textGeneration) => - Effect.gen(function* () { - yield* textGeneration.generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT); - expect(runtimeMock.state.authHeaders).toEqual([null]); - }), - { OPENCODE_SERVER_PASSWORD: "local-secret" }, - ), - ); - - it.effect("does not create a session when the server version is unsupported", () => - withOpenCodeTextGeneration(EXISTING_SERVER_OPENCODE_SETTINGS, (textGeneration) => - Effect.gen(function* () { - runtimeMock.state.connectionError = new Error( - "OpenCode v1.14.18 is too old. Upgrade to v1.14.19 or newer.", - ); - - const error = yield* textGeneration - .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) - .pipe(Effect.flip); - - expect(error).toBeInstanceOf(TextGenerationError); - expect(error.message).toContain("v1.14.18 is too old"); - expect(runtimeMock.state.sessionCreateCalls).toBe(0); - }), - ), - ); - - it.effect("reuses a configured OpenCode server URL without spawning or applying idle TTL", () => - withOpenCodeTextGeneration(EXISTING_SERVER_OPENCODE_SETTINGS, (textGeneration) => - Effect.gen(function* () { - yield* textGeneration.generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/opencode-reuse", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }); - yield* textGeneration.generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/opencode-reuse", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }); - - expect(runtimeMock.state.startCalls).toEqual([]); - expect(runtimeMock.state.promptUrls).toEqual([ - "http://127.0.0.1:9999", - "http://127.0.0.1:9999", - ]); - expect(runtimeMock.state.authHeaders).toEqual([ - `Basic ${btoa("opencode:secret-password")}`, - `Basic ${btoa("opencode:secret-password")}`, - ]); - - yield* advanceIdleClock; - - expect(runtimeMock.state.closeCalls).toEqual([]); - }), - ).pipe(Effect.provide(TestClock.layer())), - ); - }, -); diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts deleted file mode 100644 index 5ffcf45661852267f5bfe8a9dc05b2ca6047bacf..0000000000000000000000000000000000000000 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts +++ /dev/null @@ -1,325 +0,0 @@ -import * as Effect from "effect/Effect"; -import * as Schema from "effect/Schema"; - -import { NonNegativeInt, TextGenerationError, type OpenCodeSettings } from "@t3tools/contracts"; -import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; - -import * as ServerConfig from "../config.ts"; -import { resolveAttachmentPath } from "../attachmentStore.ts"; -import * as TextGenerationOperations from "@t3tools/provider-core/server/textGenerationOperations"; -import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts"; -import * as OpenCodeServerOwner from "../provider/OpenCodeServerOwner.ts"; - -const OpenCodeTextGenerationOperation = Schema.Literals([ - "generateCommitMessage", - "generatePrContent", - "generateBranchName", - "generateThreadTitle", -]); - -const openCodeTextGenerationErrorContext = { - operation: OpenCodeTextGenerationOperation, - cwd: Schema.String, -}; - -export class OpenCodeTextGenerationSessionRequestError extends Schema.TaggedError()( - "OpenCodeTextGenerationSessionRequestError", - { - ...openCodeTextGenerationErrorContext, - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `OpenCode session creation request failed for ${this.operation} in ${this.cwd}.`; - } -} - -export class OpenCodeTextGenerationSessionPayloadError extends Schema.TaggedError()( - "OpenCodeTextGenerationSessionPayloadError", - openCodeTextGenerationErrorContext, -) { - override get message(): string { - return `OpenCode session.create returned no session payload for ${this.operation} in ${this.cwd}.`; - } -} - -const openCodePromptErrorContext = { - ...openCodeTextGenerationErrorContext, - sessionId: Schema.String, - providerId: Schema.String, - modelId: Schema.String, -}; - -export class OpenCodeTextGenerationPromptRequestError extends Schema.TaggedError()( - "OpenCodeTextGenerationPromptRequestError", - { - ...openCodePromptErrorContext, - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `OpenCode prompt request failed for ${this.operation} in ${this.cwd} using ${this.providerId}/${this.modelId} (session ${this.sessionId}).`; - } -} - -export class OpenCodeTextGenerationPromptResponseError extends Schema.TaggedError()( - "OpenCodeTextGenerationPromptResponseError", - { - ...openCodePromptErrorContext, - providerErrorName: Schema.optional(Schema.String), - providerMessage: Schema.String, - }, -) { - override get message(): string { - const providerError = this.providerErrorName ? ` ${this.providerErrorName}` : ""; - return `OpenCode prompt${providerError} failed for ${this.operation} in ${this.cwd} using ${this.providerId}/${this.modelId} (session ${this.sessionId}): ${this.providerMessage}`; - } -} - -export class OpenCodeTextGenerationEmptyOutputError extends Schema.TaggedError()( - "OpenCodeTextGenerationEmptyOutputError", - { - ...openCodePromptErrorContext, - responsePartCount: NonNegativeInt, - textPartCount: NonNegativeInt, - }, -) { - override get message(): string { - return `OpenCode returned empty output for ${this.operation} in ${this.cwd} using ${this.providerId}/${this.modelId} (session ${this.sessionId}, ${this.responsePartCount} response parts, ${this.textPartCount} text parts).`; - } -} - -interface OpenCodePromptFailure { - readonly name?: string; - readonly message: string; -} - -interface OpenCodeTextPart { - readonly type: "text"; - readonly text: string; -} - -function getOpenCodePromptFailure(error: unknown): OpenCodePromptFailure | null { - if (!error || typeof error !== "object") { - return null; - } - - const name = - "name" in error && typeof error.name === "string" && error.name.trim().length > 0 - ? error.name.trim() - : undefined; - const message = - "data" in error && - error.data && - typeof error.data === "object" && - "message" in error.data && - typeof error.data.message === "string" - ? error.data.message.trim() - : ""; - if (message.length > 0) { - return { - ...(name ? { name } : {}), - message, - }; - } - - if (name) { - return { name, message: name }; - } - - return null; -} - -function isOpenCodeTextPart(part: unknown): part is OpenCodeTextPart { - return ( - part !== null && - typeof part === "object" && - "type" in part && - part.type === "text" && - "text" in part && - typeof part.text === "string" - ); -} - -function getOpenCodeTextResponse(parts: ReadonlyArray | undefined): string { - return (parts ?? []) - .filter(isOpenCodeTextPart) - .map((part) => part.text) - .join("") - .trim(); -} - -export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration")(function* ( - openCodeSettings: OpenCodeSettings, -) { - const serverConfig = yield* ServerConfig.ServerConfig; - const openCodeRuntime = yield* OpenCodeRuntime.OpenCodeRuntime; - const serverOwner = yield* OpenCodeServerOwner.OpenCodeServerOwner; - - const runOpenCodeJson = Effect.fn("runOpenCodeJson")(function* ( - input: TextGenerationOperations.Request, - ) { - const parsedModel = OpenCodeRuntime.parseOpenCodeModelSlug(input.modelSelection.model); - if (!parsedModel) { - return yield* new TextGenerationError({ - operation: input.operation, - detail: "OpenCode model selection must use the 'provider/model' format.", - }); - } - - const fileParts = OpenCodeRuntime.toOpenCodeFileParts({ - attachments: input.attachments?.filter((attachment) => attachment.type === "image"), - resolveAttachmentPath: (attachment) => - resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }), - }); - - const runAgainstServer = Effect.fn("runOpenCodeJson.runAgainstServer")( - function* ( - server: Pick< - OpenCodeRuntime.OpenCodeServerConnection, - "url" | "serverPassword" | "version" - >, - ) { - const client = openCodeRuntime.createOpenCodeSdkClient({ - baseUrl: server.url, - directory: input.cwd, - ...(server.serverPassword !== undefined ? { serverPassword: server.serverPassword } : {}), - }); - const session = yield* Effect.tryPromise({ - try: () => - client.session.create({ - title: `T3 Code ${input.operation}`, - permission: [{ permission: "*", pattern: "*", action: "deny" }], - }), - catch: (cause) => - new OpenCodeTextGenerationSessionRequestError({ - operation: input.operation, - cwd: input.cwd, - cause, - }), - }); - if (!session.data) { - return yield* new OpenCodeTextGenerationSessionPayloadError({ - operation: input.operation, - cwd: input.cwd, - }); - } - const selectedAgent = getModelSelectionStringOptionValue(input.modelSelection, "agent"); - const selectedVariant = getModelSelectionStringOptionValue(input.modelSelection, "variant"); - const promptContext = { - operation: input.operation, - cwd: input.cwd, - sessionId: session.data.id, - providerId: parsedModel.providerID, - modelId: parsedModel.modelID, - }; - - const result = yield* Effect.tryPromise({ - try: () => - client.session.prompt({ - sessionID: session.data.id, - model: parsedModel, - ...(selectedAgent ? { agent: selectedAgent } : {}), - ...(selectedVariant ? { variant: selectedVariant } : {}), - parts: [{ type: "text", text: input.prompt }, ...fileParts], - }), - catch: (cause) => - new OpenCodeTextGenerationPromptRequestError({ - ...promptContext, - cause, - }), - }); - const promptFailure = getOpenCodePromptFailure(result.data?.info?.error); - if (promptFailure) { - return yield* new OpenCodeTextGenerationPromptResponseError({ - ...promptContext, - ...(promptFailure.name ? { providerErrorName: promptFailure.name } : {}), - providerMessage: promptFailure.message, - }); - } - const responseParts = result.data?.parts ?? []; - const rawText = getOpenCodeTextResponse(responseParts); - if (rawText.length === 0) { - return yield* new OpenCodeTextGenerationEmptyOutputError({ - ...promptContext, - responsePartCount: responseParts.length, - textPartCount: responseParts.filter(isOpenCodeTextPart).length, - }); - } - return rawText; - }, - Effect.catchTags({ - OpenCodeTextGenerationSessionRequestError: (cause) => - Effect.fail( - new TextGenerationError({ - operation: cause.operation, - detail: "OpenCode session.create request failed.", - cause, - }), - ), - OpenCodeTextGenerationSessionPayloadError: (cause) => - Effect.fail( - new TextGenerationError({ - operation: cause.operation, - detail: "OpenCode session.create returned no session payload.", - cause, - }), - ), - OpenCodeTextGenerationPromptRequestError: (cause) => - Effect.fail( - new TextGenerationError({ - operation: cause.operation, - detail: "OpenCode session.prompt request failed.", - cause, - }), - ), - OpenCodeTextGenerationPromptResponseError: (cause) => - Effect.fail( - new TextGenerationError({ - operation: cause.operation, - detail: cause.providerMessage, - cause, - }), - ), - OpenCodeTextGenerationEmptyOutputError: (cause) => - Effect.fail( - new TextGenerationError({ - operation: cause.operation, - detail: "OpenCode returned empty output.", - cause, - }), - ), - }), - ); - - const serverOutput = - openCodeSettings.serverUrl.length > 0 - ? openCodeRuntime - .connectToOpenCodeServer({ - binaryPath: openCodeSettings.binaryPath, - directory: input.cwd, - serverUrl: openCodeSettings.serverUrl, - ...(openCodeSettings.serverPassword - ? { serverPassword: openCodeSettings.serverPassword } - : {}), - }) - .pipe(Effect.flatMap(runAgainstServer), Effect.scoped) - : serverOwner.withServer(runAgainstServer); - const rawOutput = yield* serverOutput.pipe( - Effect.catchTags({ - OpenCodeRuntimeError: (cause) => - Effect.fail( - new TextGenerationError({ - operation: input.operation, - detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause), - cause, - }), - ), - }), - ); - - return yield* TextGenerationOperations.decodeJsonReply(input, "OpenCode", rawOutput); - }); - - return TextGenerationOperations.fromRunner("OpenCodeTextGeneration", runOpenCodeJson); -}); diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 7b4269287f4bd56497a3e90eb308e8c3c75137b7..76bdf21f377acf9d1918de1286e01dee385ddcda 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -52,7 +52,7 @@ import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; import { HttpClient, HttpClientResponse } from "effect/http"; -import { writeFileStringAtomically } from "../atomicWrite.ts"; +import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; import * as ServerConfig from "../config.ts"; import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; import * as ServerSettings from "../serverSettings.ts"; diff --git a/apps/web/package.json b/apps/web/package.json index 63a5f5c2ad357b62aa031ba5a4f78b563a9a1c7b..372c956ec04ea06c17a98e5bdaafc940c6d65def 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -30,6 +30,7 @@ "@t3tools/contracts": "workspace:*", "@t3tools/provider-core": "workspace:*", "@t3tools/provider-muse": "workspace:*", + "@t3tools/provider-opencode": "workspace:*", "@t3tools/provider-pi": "workspace:*", "@t3tools/shared": "workspace:*", "@tanstack/react-pacer": "^0.19.4", diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 7f38d65f2a14e426134172d89be89d8cc4778193..be008bac333089870dd2640de1099c4155b2f2d1 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -681,32 +681,6 @@ export const AntigravityIcon: Icon = (props) => ( ); -export const OpenCodeIcon: Icon = (props) => ( - - - - - - - - - - - - - -); - export const ACPRegistryIcon: Icon = ({ className, ...props }) => ( > = { [ProviderDriverKind.make("codex")]: OpenAI, [ProviderDriverKind.make("claudeAgent")]: ClaudeAI, - [ProviderDriverKind.make("opencode")]: OpenCodeIcon, [ProviderDriverKind.make("cursor")]: CursorIcon, [ProviderDriverKind.make("grok")]: GrokIcon, [ProviderDriverKind.make("antigravity")]: AntigravityIcon, @@ -36,7 +27,6 @@ const PROVIDER_TEXT_COLOR_BY_PROVIDER: Partial` package, with - **Package exports.** `./settings` holds the instance settings schema, built with `makeProviderSettingsSchema` from contracts. `./client` exports a `ProviderClientDefinition` (label, settings schema, plain-data icon, badge) and must stay browser- and React Native-safe. - `./server` exports the `ProviderDriver` and its adapter driver. Settings live in - `providerInstances`; new providers are off by default. + `./server` exports the `ProviderDriver` and its adapter driver. `./testing` exposes internals + the server's replay testkits drive; testkits that need the orchestrator stay in the server. + Settings live in `providerInstances`; new providers are off by default. - **Package boundaries.** Server code imports only `@t3tools/provider-core/server/*`, contracts, and shared. It reaches the server through `ProviderHost` (paths, settings, background demand, attachments), never through `apps/server`. Tests use `@t3tools/provider-testing`. diff --git a/docs/internals/providers.md b/docs/internals/providers.md index b8542cbbef38b07782417e772c361811bc386981..6de371a95084745b046d12595c48e1ca24a5a44a 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -17,12 +17,12 @@ directory must not share one T3 MCP entry. - **1.x** uses one T3-managed chat server per thread, so threads cannot replace each other's connection. Catalog and text-generation work can share the - [instance-owned helper](../../apps/server/src/provider/OpenCodeServerOwner.ts), which closes - after an idle period. See the [1.x adapter](../../apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts). + [instance-owned helper](../../packages/provider-opencode/src/server/OpenCodeServerOwner.ts), which closes + after an idle period. See the [1.x adapter](../../packages/provider-opencode/src/server/adapter.ts). - **2.x** serves every directory from one - [server per instance](../../apps/server/src/provider/opencode2/OpenCode2Server.ts). Each thread + [server per instance](../../packages/provider-opencode/src/server/v2/OpenCode2Server.ts). Each thread registers its own `t3-code-` MCP entry, and session permission rules deny every other - thread's entry. See the [2.x adapter](../../apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts). + thread's entry. See the [2.x adapter](../../packages/provider-opencode/src/server/v2/adapter.ts). External OpenCode servers remain externally owned and can require an external restart to pick up configuration changes. OpenCode stores "always" approval grants for the whole project. Automatic diff --git a/package.json b/package.json index a345b307273db1b89d716eb8830e526229d88d9e..e261ffedfb3509af6d12ab3f16eb00e1aa35f9aa 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "tc": "vp run -r --concurrency-limit 2 typecheck", "lint": "vp lint --report-unused-disable-directives", "knip": "knip --preprocessor ./scripts/knip-schemas.ts", - "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", + "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts", "lint:mobile": "node scripts/mobile-native-static-check.ts", "test": "vp run -r test", diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index e62f4ff578b791a07ef161e328c00bbf02c57ccc..a302412f179e77931910b91d45fea8fcc49a328d 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -907,58 +907,6 @@ export const AcpRegistrySettings = makeProviderSettingsSchema( ); export type AcpRegistrySettings = typeof AcpRegistrySettings.Type; -export const OpenCodeSettings = makeProviderSettingsSchema( - { - // Off by default (like Cursor and Grok): the binding is not yet stable - // enough to probe on every install. Users opt in from Settings. - enabled: Schema.Boolean.pipe( - Schema.withDecodingDefault(Effect.succeed(false)), - Schema.annotateKey({ providerSettingsForm: { hidden: true } }), - ), - binaryPath: makeBinaryPathSetting("opencode").pipe( - Schema.annotateKey({ - title: "Binary path", - description: "Path to the OpenCode binary.", - providerSettingsForm: { - placeholder: "opencode", - clearWhenEmpty: "omit", - }, - }), - ), - serverUrl: TrimmedString.pipe( - Schema.withDecodingDefault(Effect.succeed("")), - Schema.annotateKey({ - title: "Server URL", - description: "Leave blank to let T3 Code spawn the server when needed.", - providerSettingsForm: { - placeholder: "http://127.0.0.1:4096", - clearWhenEmpty: "omit", - }, - }), - ), - serverPassword: TrimmedString.pipe( - Schema.withDecodingDefault(Effect.succeed("")), - Schema.annotateKey({ - title: "Server password", - description: "Stored in plain text on disk.", - providerSettingsForm: { - control: "password", - placeholder: "Optional", - clearWhenEmpty: "omit", - }, - }), - ), - customModels: Schema.Array(CustomModelSetting).pipe( - Schema.withDecodingDefault(Effect.succeed([])), - Schema.annotateKey({ providerSettingsForm: { hidden: true } }), - ), - }, - { - order: ["binaryPath", "serverUrl", "serverPassword"], - }, -); -export type OpenCodeSettings = typeof OpenCodeSettings.Type; - /** * A read-only quota source outside this environment's provider CLIs. The * only kind today is a CLIProxyAPI hub, whose management API reports the diff --git a/packages/provider-core/package.json b/packages/provider-core/package.json index fa21d1f2efeec0a8e3ad17749ee2556b435f1f05..a733491676b758b464df8e530f4a8a3422c3c265 100644 --- a/packages/provider-core/package.json +++ b/packages/provider-core/package.json @@ -75,6 +75,10 @@ "types": "./src/server/mcpToolPresentation.ts", "import": "./src/server/mcpToolPresentation.ts" }, + "./server/nativeProtocolLogging": { + "types": "./src/server/nativeProtocolLogging.ts", + "import": "./src/server/nativeProtocolLogging.ts" + }, "./server/notification": { "types": "./src/server/notification.ts", "import": "./src/server/notification.ts" @@ -127,6 +131,10 @@ "types": "./src/server/snapshotSettings.ts", "import": "./src/server/snapshotSettings.ts" }, + "./server/subagentProjection": { + "types": "./src/server/subagentProjection.ts", + "import": "./src/server/subagentProjection.ts" + }, "./server/textGeneration": { "types": "./src/server/textGeneration.ts", "import": "./src/server/textGeneration.ts" diff --git a/packages/provider-core/src/server/nativeProtocolLogging.ts b/packages/provider-core/src/server/nativeProtocolLogging.ts new file mode 100644 index 0000000000000000000000000000000000000000..a85833b0e862bb15b6c6b3b4db9ea0edec8d601e --- /dev/null +++ b/packages/provider-core/src/server/nativeProtocolLogging.ts @@ -0,0 +1,40 @@ +import { errorTag } from "@t3tools/shared/observability"; + +export function structuralProtocolMethod(value: string): string { + return value.length <= 128 && /^[A-Za-z][A-Za-z0-9._:/-]*$/.test(value) ? value : "unknown"; +} + +/** + * Retain only bounded structural metadata for native protocol diagnostics. + * Provider payload values may contain prompts, credentials, or arbitrarily + * large tool output and must never be copied into the observability stream. + */ +export function summarizeNativeProtocolPayload( + payload: unknown, +): Readonly> { + if (payload === null) return { valueType: "null" }; + if (typeof payload === "string") { + return { valueType: "string", byteLength: new TextEncoder().encode(payload).byteLength }; + } + if (payload instanceof Uint8Array) { + return { valueType: "bytes", byteLength: payload.byteLength }; + } + if (Array.isArray(payload)) { + return { valueType: "array", itemCount: payload.length }; + } + if (typeof payload !== "object") { + return { valueType: typeof payload }; + } + + try { + const record = payload as Record; + return { + valueType: "object", + fieldCount: Object.keys(record).length, + ...(typeof record._tag === "string" ? { messageTag: errorTag(record) } : {}), + ...(typeof record.tag === "string" ? { method: structuralProtocolMethod(record.tag) } : {}), + }; + } catch { + return { valueType: "object" }; + } +} diff --git a/packages/provider-core/src/server/subagentProjection.ts b/packages/provider-core/src/server/subagentProjection.ts new file mode 100644 index 0000000000000000000000000000000000000000..7ee8d7c1aa8d9d0554697c6e7b5138991c51e9d5 --- /dev/null +++ b/packages/provider-core/src/server/subagentProjection.ts @@ -0,0 +1,259 @@ +import type { + MessageId, + ModelSelection, + NodeId, + OrchestrationV2AppThread, + OrchestrationV2Actor, + OrchestrationV2ConversationMessage, + OrchestrationV2CreationSource, + OrchestrationV2ProviderRef, + OrchestrationV2Run, + OrchestrationV2ThreadProjection, + OrchestrationV2TurnItem, + ProviderInstanceId, + ProviderThreadId, + ProviderTurnId, + ThreadId, + TurnItemId, +} from "@t3tools/contracts"; +import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError"; +import * as DateTime from "effect/DateTime"; +import { isOrchestrationV2WorkActive } from "@t3tools/contracts"; + +function trimmed(value: string | null | undefined): string | undefined { + const result = value?.trim(); + return result && result.length > 0 ? result : undefined; +} + +export function subagentThreadTitle(input: { + readonly parentTitle: string; + readonly title?: string | null; + readonly prompt: string; + readonly ordinal: number; +}): string { + const detail = trimmed(input.title) ?? trimmed(input.prompt); + if (detail === undefined) { + return `${input.parentTitle} subagent ${input.ordinal}`; + } + const clipped = detail.length > 72 ? `${detail.slice(0, 69)}...` : detail; + return clipped; +} + +export function makeSubagentChildThread(input: { + readonly parentThread: OrchestrationV2AppThread; + readonly childThreadId: ThreadId; + readonly parentNodeId: NodeId; + readonly activeProviderThreadId: ProviderThreadId | null; + readonly providerInstanceId: ProviderInstanceId; + readonly modelSelection: ModelSelection; + readonly title: string; + readonly now: DateTime.Utc; + readonly createdBy: OrchestrationV2Actor; + readonly creationSource: OrchestrationV2CreationSource; +}): OrchestrationV2AppThread { + return { + ...input.parentThread, + createdBy: input.createdBy, + creationSource: input.creationSource, + id: input.childThreadId, + title: input.title, + linkedPullRequest: null, + pullRequests: [], + historyOrigin: undefined, + providerInstanceId: input.providerInstanceId, + modelSelection: input.modelSelection, + activeProviderThreadId: input.activeProviderThreadId, + lineage: { + parentThreadId: input.parentThread.id, + relationshipToParent: "subagent", + rootThreadId: input.parentThread.lineage.rootThreadId, + }, + forkedFrom: { + type: "node", + nodeId: input.parentNodeId, + }, + createdAt: input.now, + updatedAt: input.now, + archivedAt: null, + settledOverride: null, + settledAt: null, + snoozedUntil: null, + snoozedAt: null, + lastVisitedAt: null, + deletedAt: null, + }; +} + +export function makeSubagentConversationArtifacts(input: { + readonly messageId: MessageId; + readonly senderThreadId?: ThreadId; + readonly turnItemId: TurnItemId; + readonly threadId: ThreadId; + readonly rootNodeId: NodeId; + readonly providerThreadId: ProviderThreadId | null; + readonly providerTurnId: ProviderTurnId | null; + readonly nativeItemRef: OrchestrationV2ProviderRef | null; + readonly role: "user" | "assistant"; + readonly text: string; + readonly ordinal: number; + readonly now: DateTime.Utc; +}): { + readonly message: OrchestrationV2ConversationMessage; + readonly turnItem: OrchestrationV2TurnItem; +} { + const message: OrchestrationV2ConversationMessage = { + createdBy: "agent", + creationSource: "provider", + id: input.messageId, + threadId: input.threadId, + runId: null, + nodeId: input.rootNodeId, + role: input.role, + ...(input.role === "user" && input.senderThreadId !== undefined + ? { senderThreadId: input.senderThreadId } + : {}), + text: input.text, + attachments: [], + streaming: false, + createdAt: input.now, + updatedAt: input.now, + }; + const base = { + id: input.turnItemId, + threadId: input.threadId, + runId: null, + nodeId: input.rootNodeId, + providerThreadId: input.providerThreadId, + providerTurnId: input.providerTurnId, + nativeItemRef: input.nativeItemRef, + parentItemId: null, + ordinal: input.ordinal, + status: "completed" as const, + title: null, + startedAt: input.now, + completedAt: input.now, + updatedAt: input.now, + messageId: input.messageId, + text: input.text, + }; + const turnItem: OrchestrationV2TurnItem = + input.role === "user" + ? { + ...base, + createdBy: "agent", + creationSource: "provider", + type: "user_message", + ...(input.senderThreadId === undefined ? {} : { senderThreadId: input.senderThreadId }), + inputIntent: "turn_start", + attachments: [], + } + : { + ...base, + type: "assistant_message", + streaming: false, + }; + return { message, turnItem }; +} + +export function subagentResultForRun( + projection: Pick, + run: OrchestrationV2Run, +): { + readonly text: string; + readonly messageId: OrchestrationV2ConversationMessage["id"] | null; + readonly turnItemId: OrchestrationV2TurnItem["id"] | null; +} { + const message = + projection.messages + .filter( + (candidate) => + candidate.runId === run.id && + candidate.role === "assistant" && + candidate.text.trim().length > 0, + ) + .toSorted( + (left, right) => + DateTime.toEpochMillis(right.updatedAt) - DateTime.toEpochMillis(left.updatedAt), + )[0] ?? null; + const turnItem = + projection.turnItems + .filter( + ( + candidate, + ): candidate is Extract => + candidate.runId === run.id && + candidate.type === "assistant_message" && + candidate.text.trim().length > 0, + ) + .toSorted((left, right) => right.ordinal - left.ordinal)[0] ?? null; + const failure = + run.status === "failed" + ? projection.turnItems + .filter((item) => item.runId === run.id && item.type === "error") + .toSorted((left, right) => right.ordinal - left.ordinal)[0] + : undefined; + const text = + (failure?.type === "error" ? failure.failure.message : undefined) ?? + message?.text ?? + turnItem?.text ?? + (run.status === "completed" + ? "Child task completed without an assistant result." + : `Child task ended with status ${run.status}.`); + return { + text, + messageId: failure === undefined ? (message?.id ?? turnItem?.messageId ?? null) : null, + turnItemId: failure?.id ?? turnItem?.id ?? null, + }; +} + +/** A finished turn can still own live children or queued completion follow-ups. */ +export function delegatedTaskProgress(projection: { + readonly runs: OrchestrationV2ThreadProjection["runs"]; + readonly messages: ReadonlyArray< + Pick + >; + readonly subagents: ReadonlyArray< + Pick + >; + readonly providerThreads: ReadonlyArray< + Pick + >; +}) { + const terminal = (status: string) => + ["completed", "failed", "cancelled", "interrupted", "rolled_back"].includes(status); + const monitorRuns = new Set( + projection.messages + .filter((message) => message.notification?.source.kind === "monitor") + .map((message) => message.runId), + ); + const workRuns = projection.runs.filter( + (run) => !monitorRuns.has(run.id) && run.status !== "rolled_back", + ); + // A held queue waits for the user to resume it (after Stop, a restart, or a + // provider failure), so its runs are not work the task still owes. + const active = workRuns.some( + (run) => !terminal(run.status) && !(run.status === "queued" && run.queueHeld === true), + ); + const children = + projection.subagents.some( + (task) => + isOrchestrationV2WorkActive(task.status) || + // Publishing a child's result precedes scheduling its parent's wake. + // The parent still owes that follow-up even between those transactions. + task.completionDelivery?.state === "pending" || + task.completionDelivery?.state === "claimed", + ) || + projection.providerThreads.some((thread) => (thread.pendingBackgroundTasks?.length ?? 0) > 0); + const resultRun = workRuns + .filter((run) => terminal(run.status) && (run.startedAt !== null || run.ordinal === 1)) + .toSorted((a, b) => (runRanAfter(a, b) ? -1 : runRanAfter(b, a) ? 1 : 0))[0]; + return { + state: + active || resultRun === undefined + ? ("working" as const) + : children + ? ("waiting_for_children" as const) + : ("result_available" as const), + resultRun, + }; +} diff --git a/packages/provider-opencode/package.json b/packages/provider-opencode/package.json new file mode 100644 index 0000000000000000000000000000000000000000..9aa836a69a6d1c99b08542ea88997e1c710bebbd --- /dev/null +++ b/packages/provider-opencode/package.json @@ -0,0 +1,68 @@ +{ + "name": "@t3tools/provider-opencode", + "private": true, + "type": "module", + "exports": { + "./client": { + "types": "./src/client.ts", + "import": "./src/client.ts" + }, + "./settings": { + "types": "./src/settings.ts", + "import": "./src/settings.ts" + }, + "./server": { + "types": "./src/server.ts", + "import": "./src/server.ts" + }, + "./testing": { + "types": "./src/testing.ts", + "import": "./src/testing.ts" + }, + "./server/OpenCodeRuntime": { + "types": "./src/server/OpenCodeRuntime.ts", + "import": "./src/server/OpenCodeRuntime.ts" + }, + "./server/OpenCodeServerLedger": { + "types": "./src/server/OpenCodeServerLedger.ts", + "import": "./src/server/OpenCodeServerLedger.ts" + }, + "./server/v2/adapter": { + "types": "./src/server/v2/adapter.ts", + "import": "./src/server/v2/adapter.ts" + }, + "./server/v2/OpenCode2Client": { + "types": "./src/server/v2/OpenCode2Client.ts", + "import": "./src/server/v2/OpenCode2Client.ts" + }, + "./server/v2/OpenCode2Server": { + "types": "./src/server/v2/OpenCode2Server.ts", + "import": "./src/server/v2/OpenCode2Server.ts" + }, + "./server/v2/textGeneration": { + "types": "./src/server/v2/textGeneration.ts", + "import": "./src/server/v2/textGeneration.ts" + } + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vp test run --config ../../vite.config.ts --dir ." + }, + "dependencies": { + "@opencode-ai/sdk": "^1.3.15", + "@opencode/client": "2.0.23", + "@opencode/protocol": "2.0.23", + "@opencode/schema": "2.0.23", + "@t3tools/contracts": "workspace:*", + "@t3tools/provider-core": "workspace:*", + "@t3tools/shared": "workspace:*", + "effect": "catalog:" + }, + "devDependencies": { + "@effect/platform-node": "catalog:", + "@effect/vitest": "catalog:", + "@t3tools/provider-testing": "workspace:*", + "@types/node": "catalog:", + "vite-plus": "catalog:" + } +} diff --git a/packages/provider-opencode/src/client.ts b/packages/provider-opencode/src/client.ts new file mode 100644 index 0000000000000000000000000000000000000000..4c5e2d165db0ff3cec93a94347f0f9f90a53a035 --- /dev/null +++ b/packages/provider-opencode/src/client.ts @@ -0,0 +1,24 @@ +/** + * OpenCode's client definition: settings form, label, and glyph. + * Browser- and React Native-safe. + * + * @module provider-opencode/client + */ +import { ProviderDriverKind } from "@t3tools/contracts"; +import { defineProviderClient } from "@t3tools/provider-core/client"; + +import { OpenCodeSettings } from "./settings.ts"; + +export const openCodeClient = defineProviderClient({ + driverKind: ProviderDriverKind.make("opencode"), + label: "OpenCode", + settingsSchema: OpenCodeSettings, + icon: { + viewBox: "0 0 32 40", + fill: { light: "#211E1E", dark: "#F1ECEC" }, + paths: [ + { d: "M24 32H8V16H24V32Z", fill: { light: "#CFCECD", dark: "#4B4646" } }, + { d: "M24 8H8V32H24V8ZM32 40H0V0H32V40Z" }, + ], + }, +}); diff --git a/packages/provider-opencode/src/server.ts b/packages/provider-opencode/src/server.ts new file mode 100644 index 0000000000000000000000000000000000000000..a9e0e2653cc0821bb44421bc025c57fa1205f8e6 --- /dev/null +++ b/packages/provider-opencode/src/server.ts @@ -0,0 +1,8 @@ +/** + * OpenCode's server entry: the driver the server registers, its adapter + * driver, and the runtime layer the driver needs from the server. + * + * @module provider-opencode/server + */ +export { OpenCodeDriver, type OpenCodeDriverEnv } from "./server/driver.ts"; +export { OpenCodeAdapterV2Driver, type OpenCodeAdapterV2DriverEnv } from "./server/adapter.ts"; diff --git a/packages/provider-opencode/src/server/OpenCodeRuntime.cliParsers.test.ts b/packages/provider-opencode/src/server/OpenCodeRuntime.cliParsers.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..499d7d5fee7368c24d2e415479bd4775cf6582fc --- /dev/null +++ b/packages/provider-opencode/src/server/OpenCodeRuntime.cliParsers.test.ts @@ -0,0 +1,330 @@ +import * as NodeAssert from "node:assert/strict"; + +import { describe, it } from "vite-plus/test"; + +import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; + +describe("parseModelsCliOutput", () => { + it("parses a single model from a single provider", () => { + const stdout = [ + "anthropic/claude-sonnet-4-5", + JSON.stringify({ + id: "claude-sonnet-4-5", + providerID: "anthropic", + name: "Claude Sonnet 4.5", + capabilities: { temperature: true, reasoning: true, toolcall: true }, + cost: { input: 3, output: 15 }, + limit: { context: 200000, output: 8192 }, + status: "active", + options: {}, + headers: {}, + release_date: "2025-01-01", + }), + ].join("\n"); + + const result = OpenCodeRuntime.parseModelsCliOutput(stdout); + NodeAssert.equal(result.providers.size, 1); + NodeAssert.equal(result.connected.length, 1); + NodeAssert.equal(result.connected[0], "anthropic"); + + const provider = result.providers.get("anthropic")!; + NodeAssert.ok(provider); + NodeAssert.equal(provider.id, "anthropic"); + NodeAssert.equal(provider.name, "anthropic"); + NodeAssert.equal(Object.keys(provider.models).length, 1); + + const model = provider.models["claude-sonnet-4-5"]!; + NodeAssert.ok(model); + NodeAssert.equal(model.id, "claude-sonnet-4-5"); + NodeAssert.equal(model.providerID, "anthropic"); + NodeAssert.equal(model.name, "Claude Sonnet 4.5"); + }); + + it("parses multiple models from multiple providers", () => { + const stdout = [ + "anthropic/claude-sonnet-4-5", + JSON.stringify({ id: "claude-sonnet-4-5", providerID: "anthropic", name: "Sonnet 4.5" }), + "anthropic/claude-haiku-4-5", + JSON.stringify({ id: "claude-haiku-4-5", providerID: "anthropic", name: "Haiku 4.5" }), + "openai/gpt-4o", + JSON.stringify({ id: "gpt-4o", providerID: "openai", name: "GPT-4o" }), + ].join("\n"); + + const result = OpenCodeRuntime.parseModelsCliOutput(stdout); + NodeAssert.equal(result.providers.size, 2); + NodeAssert.equal(result.connected.length, 2); + NodeAssert.equal([...result.connected].sort().join(","), "anthropic,openai"); + NodeAssert.equal(Object.keys(result.providers.get("anthropic")!.models).length, 2); + NodeAssert.equal(Object.keys(result.providers.get("openai")!.models).length, 1); + }); + + it("handles empty input", () => { + const result = OpenCodeRuntime.parseModelsCliOutput(""); + NodeAssert.equal(result.providers.size, 0); + NodeAssert.equal(result.connected.length, 0); + }); + + it("skips unparseable JSON blocks", () => { + const stdout = [ + "anthropic/claude-sonnet-4-5", + "this is not valid json {{{", + "anthropic/claude-haiku-4-5", + JSON.stringify({ id: "claude-haiku-4-5", providerID: "anthropic", name: "Haiku 4.5" }), + ].join("\n"); + + const result = OpenCodeRuntime.parseModelsCliOutput(stdout); + NodeAssert.equal(result.providers.size, 1); + const provider = result.providers.get("anthropic")!; + NodeAssert.equal(Object.keys(provider.models).length, 1); + NodeAssert.ok(provider.models["claude-haiku-4-5"]); + }); + + it("handles Windows-style CRLF line endings", () => { + const stdout = + "anthropic/claude-sonnet-4-5\r\n" + + JSON.stringify({ id: "claude-sonnet-4-5", providerID: "anthropic", name: "Sonnet" }) + + "\r\n"; + + const result = OpenCodeRuntime.parseModelsCliOutput(stdout); + NodeAssert.equal(result.providers.size, 1); + NodeAssert.ok(result.providers.get("anthropic")!.models["claude-sonnet-4-5"]); + }); + + it("handles model JSON with variants and nested fields", () => { + const stdout = [ + "opencode/gpt-5.4", + JSON.stringify({ + id: "gpt-5.4", + providerID: "opencode", + name: "GPT-5.4", + family: "gpt", + capabilities: { + temperature: true, + reasoning: true, + attachment: false, + toolcall: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + limit: { context: 200000, input: 160000, output: 32000 }, + status: "active", + options: {}, + headers: {}, + release_date: "2025-01-01", + variants: { none: {}, low: {}, medium: {}, high: {} }, + }), + ].join("\n"); + + const result = OpenCodeRuntime.parseModelsCliOutput(stdout); + const model = result.providers.get("opencode")!.models["gpt-5.4"]!; + NodeAssert.ok(model); + NodeAssert.ok(model.capabilities); + NodeAssert.equal(model.capabilities!.reasoning, true); + NodeAssert.ok(model.variants); + NodeAssert.equal(model.variants!["medium"] !== undefined, true); + }); + + it("keeps a model whose JSON body has a slash and no interior whitespace", () => { + // OpenRouter-style: the model id contains a `/` and no string value has a + // space, so the JSON body line itself matches the slug regex. It must still + // be treated as the body of the preceding slug, not a new slug. + const stdout = [ + "openrouter/qwen/qwen3-coder", + JSON.stringify({ + id: "qwen/qwen3-coder", + providerID: "openrouter", + name: "qwen3-coder", + status: "active", + }), + ].join("\n"); + + const result = OpenCodeRuntime.parseModelsCliOutput(stdout); + NodeAssert.equal(result.providers.size, 1); + NodeAssert.deepEqual([...result.connected], ["openrouter"]); + const provider = result.providers.get("openrouter")!; + NodeAssert.ok(provider); + const model = provider.models["qwen/qwen3-coder"]!; + NodeAssert.ok(model); + NodeAssert.equal(model.id, "qwen/qwen3-coder"); + NodeAssert.equal(model.providerID, "openrouter"); + }); +}); + +describe("parseAgentListCliOutput", () => { + it("parses a single agent", () => { + const stdout = [ + "build (primary)", + " " + JSON.stringify([{ permission: "*", action: "allow", pattern: "*" }]), + ].join("\n"); + + const result = OpenCodeRuntime.parseAgentListCliOutput(stdout); + NodeAssert.equal(result.length, 1); + NodeAssert.equal(result[0]!.name, "build"); + NodeAssert.equal(result[0]!.mode, "primary"); + NodeAssert.equal(result[0]!.permission.length, 1); + }); + + it("parses multiple agents", () => { + const stdout = [ + "build (primary)", + " " + JSON.stringify([{ permission: "*", action: "allow", pattern: "*" }]), + "explore (subagent)", + " " + JSON.stringify([{ permission: "read", action: "allow", pattern: "*" }]), + "plan (primary)", + " " + JSON.stringify([{ permission: "edit", action: "ask", pattern: "*.md" }]), + ].join("\n"); + + const result = OpenCodeRuntime.parseAgentListCliOutput(stdout); + NodeAssert.equal(result.length, 3); + NodeAssert.equal(result[0]!.name, "build"); + NodeAssert.equal(result[0]!.mode, "primary"); + NodeAssert.equal(result[1]!.name, "explore"); + NodeAssert.equal(result[1]!.mode, "subagent"); + NodeAssert.equal(result[2]!.name, "plan"); + NodeAssert.equal(result[2]!.mode, "primary"); + }); + + it("handles empty input", () => { + const result = OpenCodeRuntime.parseAgentListCliOutput(""); + NodeAssert.equal(result.length, 0); + }); + + it("skips agents with unparseable permission JSON", () => { + const stdout = [ + "build (primary)", + " not valid json {", + "explore (subagent)", + " " + JSON.stringify([{ permission: "read", action: "allow", pattern: "*" }]), + ].join("\n"); + + const result = OpenCodeRuntime.parseAgentListCliOutput(stdout); + NodeAssert.equal(result.length, 1); + NodeAssert.equal(result[0]!.name, "explore"); + }); + + it("handles real-world permission blocks with nested paths", () => { + const permissions = [ + { permission: "*", action: "allow", pattern: "*" }, + { + permission: "external_directory", + pattern: "C:\\Users\\test\\.local\\*", + action: "allow", + }, + { permission: "read", pattern: "*.env", action: "ask" }, + ]; + const stdout = ["build (primary)", " " + JSON.stringify(permissions)].join("\n"); + + const result = OpenCodeRuntime.parseAgentListCliOutput(stdout); + NodeAssert.equal(result.length, 1); + NodeAssert.equal(result[0]!.permission.length, 3); + NodeAssert.equal(result[0]!.permission[0]!.action, "allow"); + NodeAssert.equal(result[0]!.permission[2]!.action, "ask"); + }); + + it("handles agent names with spaces", () => { + const stdout = [ + "code reviewer (subagent)", + " " + JSON.stringify([{ permission: "read", action: "allow", pattern: "*" }]), + "my custom agent (primary)", + " " + JSON.stringify([{ permission: "edit", action: "ask", pattern: "*.ts" }]), + ].join("\n"); + + const result = OpenCodeRuntime.parseAgentListCliOutput(stdout); + NodeAssert.equal(result.length, 2); + NodeAssert.equal(result[0]!.name, "code reviewer"); + NodeAssert.equal(result[0]!.mode, "subagent"); + NodeAssert.equal(result[1]!.name, "my custom agent"); + NodeAssert.equal(result[1]!.mode, "primary"); + }); + + it("marks known hidden agents", () => { + const stdout = [ + "compaction (primary)", + " " + JSON.stringify([{ permission: "*", action: "allow", pattern: "*" }]), + "build (primary)", + " " + JSON.stringify([{ permission: "*", action: "allow", pattern: "*" }]), + ].join("\n"); + + const result = OpenCodeRuntime.parseAgentListCliOutput(stdout); + NodeAssert.equal(result[0]!.hidden, true); + NodeAssert.equal(result[1]!.hidden, false); + }); +}); + +describe("parseSkillsCliOutput", () => { + it("parses only skill metadata from the CLI JSON output", () => { + const result = OpenCodeRuntime.parseSkillsCliOutput( + JSON.stringify([ + { + name: "review-pr", + description: "Review a pull request.", + location: "/tmp/review-pr/SKILL.md", + content: "---\nname: review-pr\n---\n", + }, + ]), + ); + + NodeAssert.deepEqual(result, [ + { + name: "review-pr", + description: "Review a pull request.", + location: "/tmp/review-pr/SKILL.md", + }, + ]); + }); + + it("degrades malformed output to an empty skill list", () => { + NodeAssert.deepEqual(OpenCodeRuntime.parseSkillsCliOutput("not json"), []); + }); +}); + +describe("toOpenCodeFileParts", () => { + const attachment = (mimeType: string, sizeBytes = 12) => ({ + type: "file" as const, + id: "thread-1-00000000-0000-4000-8000-000000000001-bin", + name: "attachment", + mimeType, + sizeBytes, + }); + + it("sends supported images, text, and PDFs natively and skips what models reject", () => { + const parts = OpenCodeRuntime.toOpenCodeFileParts({ + attachments: [ + attachment("application/pdf"), + attachment("text/markdown"), + attachment("image/png"), + // A ZIP file part makes OpenCode's Anthropic path throw before the + // turn starts; it must ride only as the prompt's file path line. + attachment("application/zip"), + attachment("application/octet-stream"), + // Image formats the model APIs reject stay on the fallback path too. + attachment("image/bmp"), + attachment("image/svg+xml"), + // Over the direct-attachment limit: path fallback even for a PDF. + attachment("application/pdf", 21 * 1024 * 1024), + ], + resolveAttachmentPath: () => "/tmp/attachment", + }); + + NodeAssert.deepEqual( + parts.map((part) => part.mime), + ["application/pdf", "text/markdown", "image/png"], + ); + }); + + it("keeps folded clipboard text on the lazy path fallback", () => { + const parts = OpenCodeRuntime.toOpenCodeFileParts({ + attachments: [ + { + ...attachment("text/plain"), + source: { _tag: "pasted-text" as const }, + }, + ], + resolveAttachmentPath: () => "/tmp/pasted-text.txt", + }); + + NodeAssert.deepEqual(parts, []); + }); +}); diff --git a/packages/provider-opencode/src/server/OpenCodeRuntime.environment.test.ts b/packages/provider-opencode/src/server/OpenCodeRuntime.environment.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..fdd1ae3319bd934b790ca50ea2727a9dbf682903 --- /dev/null +++ b/packages/provider-opencode/src/server/OpenCodeRuntime.environment.test.ts @@ -0,0 +1,239 @@ +import type { OpencodeClient } from "@opencode-ai/sdk/v2"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it as effectIt } from "@effect/vitest"; +import { + HostProcessEnvironment, + HostProcessExecutablePath, + HostProcessPlatform, +} from "@t3tools/shared/hostProcess"; +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 Path from "effect/Path"; +import * as TestClock from "effect/testing/TestClock"; +import { FetchHttpClient, HttpClient } from "effect/http"; +import { describe, expect, it } from "vite-plus/test"; + +import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; +import * as OpenCodeServerLedger from "./OpenCodeServerLedger.ts"; + +describe("resolveOpenCodeConfigContent", () => { + it("prefers the caller environment over the inherited environment", () => { + expect( + OpenCodeRuntime.resolveOpenCodeConfigContent( + { OPENCODE_CONFIG_CONTENT: '{"source":"caller"}' }, + { OPENCODE_CONFIG_CONTENT: '{"source":"process"}' }, + ), + ).toBe('{"source":"caller"}'); + }); + + it("falls back to the inherited environment and then an empty config", () => { + expect( + OpenCodeRuntime.resolveOpenCodeConfigContent(undefined, { + OPENCODE_CONFIG_CONTENT: '{"source":"process"}', + }), + ).toBe('{"source":"process"}'); + expect(OpenCodeRuntime.resolveOpenCodeConfigContent(undefined, {})).toBe("{}"); + }); +}); + +describe("resolveOpenCodeServerPassword", () => { + it("uses the local environment password when settings do not provide one", () => { + expect( + OpenCodeRuntime.resolveOpenCodeServerPassword( + { external: false, environment: { OPENCODE_SERVER_PASSWORD: " env password " } }, + {}, + ), + ).toBe(" env password "); + }); + + it("uses the settings password for a local server", () => { + expect( + OpenCodeRuntime.resolveOpenCodeServerPassword( + { external: false, serverPassword: " settings password " }, + {}, + ), + ).toBe(" settings password "); + }); + + it("uses the settings password when local settings and environment differ", () => { + expect( + OpenCodeRuntime.resolveOpenCodeServerPassword( + { + external: false, + serverPassword: "settings-password", + environment: { OPENCODE_SERVER_PASSWORD: "environment-password" }, + }, + {}, + ), + ).toBe("settings-password"); + }); + + it("does not send an inherited local password to an external server", () => { + expect( + OpenCodeRuntime.resolveOpenCodeServerPassword( + { external: true, environment: { OPENCODE_SERVER_PASSWORD: "local-secret" } }, + { OPENCODE_SERVER_PASSWORD: "inherited-secret" }, + ), + ).toBeUndefined(); + }); +}); + +function makeHealthClient( + result: (options?: { readonly signal?: AbortSignal }) => Promise, +): OpencodeClient { + return { + global: { + health: result, + }, + } as unknown as OpencodeClient; +} + +describe("verifyOpenCodeServerVersion", () => { + effectIt.effect("accepts a supported server version", () => + Effect.gen(function* () { + const version = yield* OpenCodeRuntime.verifyOpenCodeServerVersion( + makeHealthClient(() => Promise.resolve({ data: { healthy: true, version: "1.14.19" } })), + ); + expect(version).toBe("1.14.19"); + }), + ); + + effectIt.effect("rejects a server below the supported version", () => + Effect.gen(function* () { + const error = yield* OpenCodeRuntime.verifyOpenCodeServerVersion( + makeHealthClient(() => Promise.resolve({ data: { healthy: true, version: "1.14.18" } })), + ).pipe(Effect.flip); + expect(error).toBeInstanceOf(OpenCodeRuntime.OpenCodeRuntimeError); + expect(error.detail).toContain("v1.14.18 is too old"); + }), + ); + + for (const data of [ + { healthy: true }, + { healthy: true, version: "not-a-version" }, + { healthy: false, version: "1.14.19" }, + ]) { + effectIt.effect(`rejects an invalid health response: ${JSON.stringify(data)}`, () => + Effect.gen(function* () { + const error = yield* OpenCodeRuntime.verifyOpenCodeServerVersion( + makeHealthClient(() => Promise.resolve({ data })), + ).pipe(Effect.flip); + expect(error).toBeInstanceOf(OpenCodeRuntime.OpenCodeRuntimeError); + expect(error.detail).toContain("requires OpenCode v1.14.19 or newer"); + }), + ); + } + + effectIt.effect("preserves an unauthorized health error", () => + Effect.gen(function* () { + const error = yield* OpenCodeRuntime.verifyOpenCodeServerVersion( + makeHealthClient(() => + Promise.reject({ response: { status: 401 }, error: { message: "Unauthorized" } }), + ), + ).pipe(Effect.flip); + expect(error).toBeInstanceOf(OpenCodeRuntime.OpenCodeRuntimeError); + expect(error.detail).toContain("status=401"); + expect(error.detail).toContain("Unauthorized"); + }), + ); + + effectIt.effect("aborts a health request when the version check times out", () => + Effect.gen(function* () { + let requestSignal: AbortSignal | undefined; + const checkFiber = yield* OpenCodeRuntime.verifyOpenCodeServerVersion( + makeHealthClient((options) => { + requestSignal = options?.signal; + return new Promise(() => undefined); + }), + ).pipe(Effect.flip, Effect.forkChild); + + yield* Effect.yieldNow; + expect(requestSignal).toBeDefined(); + yield* TestClock.adjust("6 seconds"); + + const error = yield* Fiber.join(checkFiber); + expect(error.detail).toBe("Timed out while checking the OpenCode server version."); + expect(requestSignal?.aborted).toBe(true); + }).pipe(Effect.provide(TestClock.layer())), + ); +}); + +describe("OpenCode server output", () => { + effectIt.live( + "drains stdout and stderr after startup so server requests can finish", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const environment = yield* HostProcessEnvironment; + const executablePath = yield* HostProcessExecutablePath; + const platform = yield* HostProcessPlatform; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-output-" }); + const isWindows = platform === "win32"; + const binaryPath = path.join(tempDir, isWindows ? "opencode.cmd" : "opencode"); + const scriptPath = path.join(tempDir, "opencode.mjs"); + + yield* fs.writeFileString( + scriptPath, + `import { createServer } from "node:http"; +const writeOutput = (stream) => new Promise((resolve, reject) => { + stream.write("x".repeat(2 * 1024 * 1024), (error) => error ? reject(error) : resolve()); +}); +const server = createServer(async (request, response) => { + if (request.url.startsWith("/global/health")) { + response.setHeader("Content-Type", "application/json"); + response.end(JSON.stringify({ healthy: true, version: "1.14.19" })); + return; + } + await Promise.all([writeOutput(process.stdout), writeOutput(process.stderr)]); + response.end("drained"); +}); +server.listen(0, "127.0.0.1", () => { + process.stdout.write("opencode server listening on http://127.0.0.1:" + server.address().port + "\\n"); +}); +`, + ); + yield* fs.writeFileString( + binaryPath, + [ + ...(isWindows ? ["@echo off"] : ["#!/bin/sh"]), + isWindows + ? '"%T3_TEST_NODE_BINARY%" "%T3_TEST_OPENCODE_SCRIPT%" %*' + : 'exec "$T3_TEST_NODE_BINARY" "$T3_TEST_OPENCODE_SCRIPT" "$@"', + "", + ].join("\n"), + ); + if (!isWindows) { + yield* fs.chmod(binaryPath, 0o755); + } + + const runtime = yield* OpenCodeRuntime.OpenCodeRuntime; + const server = yield* runtime.startOpenCodeServerProcess({ + binaryPath, + directory: tempDir, + port: 0, + environment: { + ...environment, + T3_TEST_NODE_BINARY: executablePath, + T3_TEST_OPENCODE_SCRIPT: scriptPath, + }, + }); + const response = yield* HttpClient.get(`${server.url}/output`); + + expect(yield* response.text).toBe("drained"); + expect(yield* server.isRunning).toBe(true); + }).pipe( + Effect.scoped, + Effect.provide([ + OpenCodeRuntime.layer.pipe( + Layer.provide(OpenCodeServerLedger.layerTest), + Layer.provideMerge(NodeServices.layer), + ), + FetchHttpClient.layer, + ]), + ), + 10_000, + ); +}); diff --git a/packages/provider-opencode/src/server/OpenCodeRuntime.inventory.test.ts b/packages/provider-opencode/src/server/OpenCodeRuntime.inventory.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..29e302c134f1d48a6f3d3f9352ea1c4683a0ecb7 --- /dev/null +++ b/packages/provider-opencode/src/server/OpenCodeRuntime.inventory.test.ts @@ -0,0 +1,290 @@ +import * as NodeAssert from "node:assert/strict"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2"; +import { 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 Path from "effect/Path"; +import * as Queue from "effect/Queue"; +import { + HostProcessEnvironment, + HostProcessExecutablePath, + HostProcessPlatform, +} from "@t3tools/shared/hostProcess"; + +import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; +import * as OpenCodeServerLedger from "./OpenCodeServerLedger.ts"; + +const layerTest = OpenCodeRuntime.layer.pipe( + Layer.provide(OpenCodeServerLedger.layerTest), + Layer.provideMerge(NodeServices.layer), +); + +it.layer(layerTest)("OpenCodeRuntime inventory", (it) => { + it.effect("aborts pending SDK requests when inventory loading is interrupted", () => + Effect.gen(function* () { + const runtime = yield* OpenCodeRuntime.OpenCodeRuntime; + const started = yield* Queue.make(); + const aborted = yield* Queue.make(); + const client = createOpencodeClient({ + baseUrl: "http://opencode.test", + fetch: Object.assign( + (input: string | Request | URL) => { + const request = input instanceof Request ? input : new Request(input.toString()); + return new Promise((_resolve, reject) => { + request.signal.addEventListener( + "abort", + () => { + Queue.offerUnsafe(aborted, new URL(request.url).pathname); + reject(request.signal.reason); + }, + { once: true }, + ); + Queue.offerUnsafe(started, undefined); + }); + }, + { preconnect: () => undefined }, + ), + }); + + const inventoryFiber = yield* runtime.loadOpenCodeInventory(client).pipe(Effect.forkChild); + yield* Queue.takeN(started, 4); + yield* Fiber.interrupt(inventoryFiber); + + NodeAssert.deepEqual((yield* Queue.takeAll(aborted)).toSorted(), [ + "/agent", + "/command", + "/provider", + "/skill", + ]); + }), + ); + + it.effect("discovers directory-scoped commands without retaining prompt templates", () => + Effect.gen(function* () { + const runtime = yield* OpenCodeRuntime.OpenCodeRuntime; + const requests: Request[] = []; + const client = createOpencodeClient({ + baseUrl: "http://opencode.test", + directory: "/workspace/project", + fetch: Object.assign( + async (input: string | Request | URL) => { + const request = input instanceof Request ? input : new Request(input.toString()); + requests.push(request); + const route = new URL(request.url).pathname; + return Response.json( + route === "/provider" + ? { connected: ["openai"], all: [], default: {} } + : route === "/command" + ? [ + { + name: "review", + description: "Review changes", + source: "command", + hints: ["$ARGUMENTS"], + template: "private native template", + }, + ] + : [], + ); + }, + { preconnect: () => undefined }, + ), + }); + const inventory = yield* runtime.loadOpenCodeInventory(client); + NodeAssert.deepEqual(inventory.commands, [ + { + name: "review", + description: "Review changes", + source: "command", + hints: ["$ARGUMENTS"], + }, + ]); + const commandRequest = requests.find( + (request) => new URL(request.url).pathname === "/command", + ); + NodeAssert.ok(commandRequest); + NodeAssert.equal( + new URL(commandRequest.url).searchParams.get("directory") ?? + decodeURIComponent(commandRequest.headers.get("x-opencode-directory") ?? ""), + "/workspace/project", + ); + }), + ); + + it.effect("keeps provider inventory when agent discovery fails", () => + Effect.gen(function* () { + const runtime = yield* OpenCodeRuntime.OpenCodeRuntime; + const client = { + provider: { + list: () => + Promise.resolve({ + data: { + connected: ["openai"], + all: [], + default: {}, + }, + }), + }, + app: { + agents: () => Promise.reject(new Error("agents endpoint unavailable")), + skills: () => Promise.resolve({ data: [] }), + }, + } as unknown as OpencodeClient; + + const inventory = yield* runtime.loadOpenCodeInventory(client); + + NodeAssert.deepEqual(inventory.providerList.connected, ["openai"]); + NodeAssert.deepEqual(inventory.agents, []); + NodeAssert.deepEqual(inventory.skills, []); + }), + ); + + it.effect("keeps provider inventory when skill discovery fails", () => + Effect.gen(function* () { + const runtime = yield* OpenCodeRuntime.OpenCodeRuntime; + const client = { + provider: { + list: () => + Promise.resolve({ + data: { + connected: ["openai"], + all: [], + default: {}, + }, + }), + }, + app: { + agents: () => Promise.resolve({ data: [] }), + skills: () => Promise.reject(new Error("skills endpoint unavailable")), + }, + } as unknown as OpencodeClient; + + const inventory = yield* runtime.loadOpenCodeInventory(client); + + NodeAssert.deepEqual(inventory.providerList.connected, ["openai"]); + NodeAssert.deepEqual(inventory.agents, []); + NodeAssert.deepEqual(inventory.skills, []); + }), + ); + + it.effect("keeps only SDK skill metadata in inventory", () => + Effect.gen(function* () { + const runtime = yield* OpenCodeRuntime.OpenCodeRuntime; + const client = { + provider: { + list: () => + Promise.resolve({ + data: { + connected: ["openai"], + all: [], + default: {}, + }, + }), + }, + app: { + agents: () => Promise.resolve({ data: [] }), + skills: () => + Promise.resolve({ + data: [ + { + name: "review", + description: "Review code changes", + location: "/skills/review/SKILL.md", + content: "unused skill content", + }, + ], + }), + }, + } as unknown as OpencodeClient; + + const inventory = yield* runtime.loadOpenCodeInventory(client); + + NodeAssert.deepEqual(inventory.skills, [ + { + name: "review", + description: "Review code changes", + location: "/skills/review/SKILL.md", + }, + ]); + }), + ); + + it.effect("drops oversized CLI skill output without losing the model inventory", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const hostEnvironment = yield* HostProcessEnvironment; + const executablePath = yield* HostProcessExecutablePath; + const hostPlatform = yield* HostProcessPlatform; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-inventory-" }); + const isWindows = hostPlatform === "win32"; + const binaryPath = path.join(tempDir, isWindows ? "opencode.cmd" : "opencode"); + const scriptPath = path.join(tempDir, "opencode.mjs"); + const oversizedContentBytes = 8 * 1024 * 1024 + 1; + + yield* fs.writeFileString( + scriptPath, + [ + 'if (process.argv[2] === "models") {', + ' process.stdout.write(`openai/gpt-test\\n{"id":"gpt-test","providerID":"openai","name":"GPT Test"}\\n`);', + '} else if (process.argv[2] === "debug") {', + ` const content = "x".repeat(${oversizedContentBytes});`, + ' process.stdout.write(`[{"name":"oversized","content":"${content}"}]`);', + "}", + "", + ].join("\n"), + ); + yield* fs.writeFileString( + binaryPath, + [ + ...(isWindows ? ["@echo off"] : ["#!/bin/sh"]), + isWindows + ? '"%T3_TEST_NODE_BINARY%" "%T3_TEST_OPENCODE_SCRIPT%" %*' + : 'exec "$T3_TEST_NODE_BINARY" "$T3_TEST_OPENCODE_SCRIPT" "$@"', + "", + ].join("\n"), + ); + if (!isWindows) { + yield* fs.chmod(binaryPath, 0o755); + } + + const runtime = yield* OpenCodeRuntime.OpenCodeRuntime; + const inventory = yield* runtime.loadInventoryFromCli({ + binaryPath, + cwd: tempDir, + environment: { + ...hostEnvironment, + T3_TEST_NODE_BINARY: executablePath, + T3_TEST_OPENCODE_SCRIPT: scriptPath, + }, + }); + + NodeAssert.deepEqual(inventory.providerList.connected, ["openai"]); + NodeAssert.equal(inventory.skills.length, 0); + }), + ); + + it.effect("caps and drains command stdout and stderr when requested", () => + Effect.gen(function* () { + const runtime = yield* OpenCodeRuntime.OpenCodeRuntime; + const executablePath = yield* HostProcessExecutablePath; + const outputBytes = 2 * 1024 * 1024; + const result = yield* runtime.runOpenCodeCommand({ + binaryPath: executablePath, + args: [ + "-e", + `process.stdout.write("o".repeat(${outputBytes})); process.stderr.write("e".repeat(${outputBytes}));`, + ], + maxOutputBytes: 64, + }); + + NodeAssert.equal(result.stdout, "o".repeat(64)); + NodeAssert.equal(result.stderr, "e".repeat(64)); + NodeAssert.equal(result.code, 0); + }), + ); +}); diff --git a/packages/provider-opencode/src/server/OpenCodeRuntime.permissions.test.ts b/packages/provider-opencode/src/server/OpenCodeRuntime.permissions.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..afa6da63f94cf4660e613cfedaf835a0b7db64b7 --- /dev/null +++ b/packages/provider-opencode/src/server/OpenCodeRuntime.permissions.test.ts @@ -0,0 +1,89 @@ +import * as NodeAssert from "node:assert/strict"; + +import * as RegExpUtils from "effect/RegExp"; +import { describe, it } from "vite-plus/test"; + +import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; + +function actionFor( + runtimeMode: Parameters[0], + permission: string, + target = "*", +) { + // OpenCode uses the last matching rule. Its wildcards match directory separators. + return OpenCodeRuntime.buildOpenCodePermissionRules(runtimeMode).findLast( + (rule) => + (rule.permission === "*" || rule.permission === permission) && + new RegExp(`^${RegExpUtils.escape(rule.pattern).replaceAll("\\*", ".*")}$`, "s").test(target), + )?.action; +} + +describe("buildOpenCodePermissionRules", () => { + it("pre-approves edits once the user has chosen to auto-accept them", () => { + NodeAssert.equal(actionFor("auto-accept-edits", "edit"), "allow"); + }); + + it("still asks before editing when approval is required", () => { + NodeAssert.equal(actionFor("approval-required", "edit"), "ask"); + }); + + // Documented in docs/user/permission-modes.md: providers without an AI + // reviewer, OpenCode among them, fall back to Supervised for "auto". + it("leaves auto asking, as the docs say it does without a reviewer", () => { + NodeAssert.equal(actionFor("auto", "edit"), "ask"); + }); + + it("allows workspace reads and task updates without asking in supervised modes", () => { + for (const runtimeMode of ["approval-required", "auto-accept-edits", "auto"] as const) { + for (const permission of ["read", "glob", "grep", "lsp", "skill", "todowrite"]) { + NodeAssert.equal(actionFor(runtimeMode, permission, "src/index.ts"), "allow"); + } + } + }); + + it("preserves OpenCode's environment-file approval rules", () => { + for (const runtimeMode of ["approval-required", "auto-accept-edits", "auto"] as const) { + for (const target of [ + ".env", + ".env.local", + "config/service.env", + "config/service.env.local", + ]) { + NodeAssert.equal(actionFor(runtimeMode, "read", target), "ask"); + } + for (const target of [".env.example", "config/service.env.example"]) { + NodeAssert.equal(actionFor(runtimeMode, "read", target), "allow"); + } + } + }); + + it("still asks before commands, network access, external directories and unknown tools", () => { + for (const runtimeMode of ["approval-required", "auto-accept-edits", "auto"] as const) { + NodeAssert.equal(actionFor(runtimeMode, "bash"), "ask"); + NodeAssert.equal(actionFor(runtimeMode, "webfetch"), "ask"); + NodeAssert.equal(actionFor(runtimeMode, "websearch"), "ask"); + NodeAssert.equal(actionFor(runtimeMode, "external_directory"), "ask"); + NodeAssert.equal(actionFor(runtimeMode, "doom_loop"), "ask"); + NodeAssert.equal(actionFor(runtimeMode, "custom_tool"), "ask"); + } + }); + + it("allows everything only under full access", () => { + NodeAssert.deepEqual(OpenCodeRuntime.buildOpenCodePermissionRules("full-access"), [ + { permission: "*", pattern: "*", action: "allow" }, + { permission: "external_directory", pattern: "*", action: "allow" }, + ]); + }); +}); + +describe("toOpenCodePermissionReply", () => { + it.each([ + ["accept", "once"], + ["acceptForSession", "always"], + ["acceptAlways", "always"], + ["decline", "reject"], + ["cancel", "reject"], + ] as const)("maps %s to %s", (decision, reply) => { + NodeAssert.equal(OpenCodeRuntime.toOpenCodePermissionReply(decision), reply); + }); +}); diff --git a/packages/provider-opencode/src/server/OpenCodeRuntime.ts b/packages/provider-opencode/src/server/OpenCodeRuntime.ts new file mode 100644 index 0000000000000000000000000000000000000000..935294575057d062c82f32852a00a9007aa5a9b7 --- /dev/null +++ b/packages/provider-opencode/src/server/OpenCodeRuntime.ts @@ -0,0 +1,1111 @@ +import * as NodeURL from "node:url"; + +import type { ChatAttachment, ProviderApprovalDecision, RuntimeMode } from "@t3tools/contracts"; +import { + createOpencodeClient, + type Agent, + type Command, + type FilePartInput, + type Model, + type OpencodeClient, + type PermissionRuleset, + type ProviderListResponse, + type QuestionAnswer, + type QuestionRequest, +} from "@opencode-ai/sdk/v2"; +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as P from "effect/Predicate"; +import * as Ref from "effect/Ref"; +import * as Result from "effect/Result"; +import * as Scope from "effect/Scope"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; + +import { signalProcessGroup } from "@t3tools/provider-core/server/processGroup"; +import { isWindowsCommandNotFound } from "@t3tools/provider-core/server/snapshotProbe"; +import * as OpenCodeServerLedger from "./OpenCodeServerLedger.ts"; +import { collectStreamAsString } from "@t3tools/provider-core/server/snapshotProbe"; +import * as NetService from "@t3tools/shared/Net"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { compareSemverVersions, parseSemver } from "@t3tools/shared/semver"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; +const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown)); +const OPENCODE_EMPTY_CONFIG_CONTENT = "{}"; + +export const MINIMUM_OPENCODE_VERSION = "1.14.19"; +const OPENCODE_HEALTH_TIMEOUT = "5 seconds"; + +const OpenCodeHealthSchema = Schema.Struct({ + healthy: Schema.Literal(true), + version: Schema.String, +}); +const decodeOpenCodeHealth = Schema.decodeUnknownEffect(OpenCodeHealthSchema); + +export function resolveOpenCodeConfigContent( + inputEnvironment: Readonly> | undefined, + inheritedEnvironment: Readonly> = process.env, +): string { + return ( + inputEnvironment?.OPENCODE_CONFIG_CONTENT ?? + inheritedEnvironment.OPENCODE_CONFIG_CONTENT ?? + OPENCODE_EMPTY_CONFIG_CONTENT + ); +} + +export function resolveOpenCodeServerPassword( + input: { + readonly external: boolean; + readonly serverPassword?: string; + readonly environment?: Readonly>; + }, + inheritedEnvironment: Readonly> = process.env, +): string | undefined { + if (input.serverPassword !== undefined) { + return input.serverPassword; + } + if (input.external) { + return undefined; + } + return input.environment === undefined + ? inheritedEnvironment.OPENCODE_SERVER_PASSWORD + : input.environment.OPENCODE_SERVER_PASSWORD; +} + +const DEFAULT_OPENCODE_SERVER_TIMEOUT_MS = 30_000; +const DEFAULT_HOSTNAME = "127.0.0.1"; +const OPENCODE_SERVER_STARTUP_MAX_OUTPUT_CHARS = 64 * 1024; +const OPENCODE_SKILL_DISCOVERY_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; +export interface OpenCodeServerProcess { + readonly url: string; + readonly serverPassword?: string; + readonly version: string; + readonly isRunning: Effect.Effect; + readonly exitCode: Effect.Effect; +} + +export interface OpenCodeServerConnection { + readonly url: string; + readonly serverPassword?: string; + readonly version: string; + readonly exitCode: Effect.Effect | null; + readonly external: boolean; +} + +const OPENCODE_RUNTIME_ERROR_TAG = "OpenCodeRuntimeError"; +export class OpenCodeRuntimeError extends Data.TaggedError(OPENCODE_RUNTIME_ERROR_TAG)<{ + readonly operation: string; + readonly cause?: unknown; + readonly detail: string; +}> { + static readonly is = (u: unknown): u is OpenCodeRuntimeError => + P.isTagged(u, OPENCODE_RUNTIME_ERROR_TAG); +} + +function encodeJsonStringForDiagnostics(input: unknown): string | undefined { + const result = encodeUnknownJsonStringExit(input); + return Exit.isSuccess(result) ? result.value : undefined; +} + +export function openCodeRuntimeErrorDetail(cause: unknown): string { + if (OpenCodeRuntimeError.is(cause)) return cause.detail; + if (cause instanceof Error && cause.message.trim().length > 0) return cause.message.trim(); + if (cause && typeof cause === "object") { + // SDK v2 throws { response, request, error? } shapes — extract what's useful + const anyCause = cause as Record; + const status = (anyCause.response as { status?: number } | undefined)?.status; + const body = anyCause.error ?? anyCause.data ?? anyCause.body; + const encodedBody = encodeJsonStringForDiagnostics(body ?? cause); + if (encodedBody) { + return `status=${status ?? "?"} body=${encodedBody}`; + } + } + return String(cause); +} + +export const runOpenCodeSdk = ( + operation: string, + fn: (signal: AbortSignal) => Promise, +): Effect.Effect => + Effect.tryPromise({ + try: fn, + catch: (cause) => + new OpenCodeRuntimeError({ operation, detail: openCodeRuntimeErrorDetail(cause), cause }), + }).pipe(Effect.withSpan(`opencode.${operation}`)); + +export const verifyOpenCodeServerVersion = Effect.fn("verifyOpenCodeServerVersion")(function* ( + client: OpencodeClient, +) { + const healthOption = yield* runOpenCodeSdk("global.health", (signal) => + client.global.health({ signal }), + ).pipe(Effect.timeoutOption(OPENCODE_HEALTH_TIMEOUT)); + if (Option.isNone(healthOption)) { + return yield* new OpenCodeRuntimeError({ + operation: "global.health", + detail: "Timed out while checking the OpenCode server version.", + }); + } + + const health = yield* decodeOpenCodeHealth(healthOption.value.data).pipe( + Effect.mapError( + (cause) => + new OpenCodeRuntimeError({ + operation: "global.health", + detail: `OpenCode server returned an invalid health response. T3 Code requires OpenCode v${MINIMUM_OPENCODE_VERSION} or newer.`, + cause, + }), + ), + ); + if (parseSemver(health.version) === null) { + return yield* new OpenCodeRuntimeError({ + operation: "global.health", + detail: `OpenCode server returned an invalid version. T3 Code requires OpenCode v${MINIMUM_OPENCODE_VERSION} or newer.`, + }); + } + if (compareSemverVersions(health.version, MINIMUM_OPENCODE_VERSION) < 0) { + return yield* new OpenCodeRuntimeError({ + operation: "global.health", + detail: `OpenCode v${health.version} is too old. Upgrade to v${MINIMUM_OPENCODE_VERSION} or newer.`, + }); + } + return health.version; +}); + +export interface OpenCodeCommandResult { + readonly stdout: string; + readonly stderr: string; + readonly code: number; +} + +export interface OpenCodeInventory { + readonly providerList: ProviderListResponse; + readonly agents: ReadonlyArray; + readonly skills: ReadonlyArray; + readonly commands?: ReadonlyArray; +} + +export type OpenCodeSlashCommand = Pick; + +/** Command templates stay in OpenCode, which expands arguments and runs MCP prompts. */ +export const loadOpenCodeCommands = (client: OpencodeClient) => + runOpenCodeSdk("command.list", (signal) => client.command.list(undefined, { signal })).pipe( + Effect.map((result): ReadonlyArray => + (result.data ?? []).map(({ name, description, source, hints }) => ({ + name, + ...(description === undefined ? {} : { description }), + ...(source === undefined ? {} : { source }), + hints, + })), + ), + ); + +export interface ParsedOpenCodeModelSlug { + readonly providerID: string; + readonly modelID: string; +} + +export interface OpenCodeSkill { + readonly name?: string | null; + readonly description?: string | null; + readonly location?: string | null; +} + +const OpenCodeSkillSchema = Schema.Struct({ + name: Schema.optionalKey(Schema.NullOr(Schema.String)), + description: Schema.optionalKey(Schema.NullOr(Schema.String)), + location: Schema.optionalKey(Schema.NullOr(Schema.String)), +}); +const decodeOpenCodeSkillsCliOutputExit = Schema.decodeUnknownExit( + Schema.fromJsonString(Schema.Array(OpenCodeSkillSchema)), +); + +export interface OpenCodeRuntimeShape { + /** + * Spawns a local OpenCode server process. Its lifetime is bound to the caller's + * `Scope.Scope` — the child is killed automatically when that scope closes. + * Consumers that want a long-lived server must create and hold a scope explicitly + * (see {@link Scope.make}) and close it when done. + */ + readonly startOpenCodeServerProcess: (input: { + readonly binaryPath: string; + readonly directory: string; + readonly serverPassword?: string; + readonly environment?: NodeJS.ProcessEnv; + readonly port?: number; + readonly hostname?: string; + readonly timeoutMs?: number; + /** Checks the listening server and returns its version. Defaults to the 1.x health check. */ + readonly verify?: (url: string) => Effect.Effect; + }) => Effect.Effect; + /** + * Returns a handle to either an externally-managed OpenCode server (when + * `serverUrl` is provided — no lifetime is attached to the caller's scope) or a + * freshly spawned local server whose lifetime is bound to the caller's scope. + */ + readonly connectToOpenCodeServer: (input: { + readonly binaryPath: string; + readonly directory: string; + readonly serverUrl?: string | null; + readonly serverPassword?: string; + readonly environment?: NodeJS.ProcessEnv; + readonly port?: number; + readonly hostname?: string; + readonly timeoutMs?: number; + }) => Effect.Effect; + readonly runOpenCodeCommand: (input: { + readonly binaryPath: string; + readonly args: ReadonlyArray; + readonly environment?: NodeJS.ProcessEnv; + readonly cwd?: string; + readonly maxOutputBytes?: number; + }) => Effect.Effect; + readonly createOpenCodeSdkClient: (input: { + readonly baseUrl: string; + readonly directory: string; + readonly serverPassword?: string; + }) => OpencodeClient; + readonly loadOpenCodeInventory: ( + client: OpencodeClient, + ) => Effect.Effect; + readonly loadOpenCodeSkills: ( + client: OpencodeClient, + ) => Effect.Effect, OpenCodeRuntimeError>; + readonly loadInventoryFromCli: (input: { + readonly binaryPath: string; + readonly cwd: string; + readonly environment?: NodeJS.ProcessEnv; + }) => Effect.Effect; + readonly loadSkillsFromCli: (input: { + readonly binaryPath: string; + readonly cwd: string; + readonly environment?: NodeJS.ProcessEnv; + }) => Effect.Effect, OpenCodeRuntimeError>; +} + +function parseServerUrlFromOutput(output: string): string | null { + for (const line of output.split("\n")) { + const match = line.match(/server listening on\s+(https?:\/\/[^\s]+)/i); + if (match?.[1]) return match[1]; + } + return null; +} + +const SLUG_LINE_RE = /^(\S+\/\S+)\s*$/; +const AGENT_HEADER_RE = /^(.+)\s+\((\S+)\)\s*$/; + +// Agents that are always hidden in OpenCode but the CLI "agent list" command +// does not expose the hidden flag. Keep in sync with OpenCode agent +// definitions (in the OpenCode repo: packages/opencode/src/agent/agent.ts). +const KNOWN_HIDDEN_AGENTS = new Set(["compaction", "summary", "title"]); + +/** @internal */ +export function parseModelsCliOutput(stdout: string): { + readonly providers: ReadonlyMap< + string, + { readonly id: string; readonly name: string; readonly models: { [key: string]: Model } } + >; + readonly connected: ReadonlyArray; +} { + const providers = new Map< + string, + { id: string; name: string; models: { [key: string]: Model } } + >(); + const lines = stdout.split("\n"); + let currentSlug: string | null = null; + const jsonLines: Array = []; + + const flushModel = () => { + if (currentSlug !== null && jsonLines.length > 0) { + const jsonStr = jsonLines.join("\n").trim(); + if (jsonStr.length > 0) { + try { + const model = JSON.parse(jsonStr) as Model; + const separator = currentSlug.indexOf("/"); + if (separator > 0) { + const providerID = currentSlug.slice(0, separator); + const modelID = currentSlug.slice(separator + 1); + let provider = providers.get(providerID); + if (!provider) { + provider = { id: providerID, name: providerID, models: {} }; + providers.set(providerID, provider); + } + provider.models[modelID] = model; + } + } catch { + // Skip unparseable model JSON + } + } + } + currentSlug = null; + jsonLines.length = 0; + }; + + for (const line of lines) { + // A model's JSON body is a single `JSON.stringify` line starting with `{`, + // while a provider/model slug is a bare `provider/model` header. Only the + // latter can be a slug: without this guard a body line with no interior + // whitespace and a `/` in one of its values (e.g. an OpenRouter model whose + // `id` is `vendor/model`) matches SLUG_LINE_RE, so flushModel runs against + // an empty body and the model is silently dropped. + const slugMatch = line.trimStart().startsWith("{") ? null : SLUG_LINE_RE.exec(line); + if (slugMatch) { + flushModel(); + currentSlug = slugMatch[1]!; + } else if (currentSlug !== null) { + jsonLines.push(line); + } + } + flushModel(); + + return { providers, connected: [...providers.keys()] }; +} + +/** @internal */ +export function parseAgentListCliOutput(stdout: string): ReadonlyArray { + const agents: Array = []; + const lines = stdout.split("\n"); + let currentHeader: { name: string; mode: string } | null = null; + const blockLines: Array = []; + + const flushAgent = () => { + if (currentHeader !== null) { + const jsonStr = blockLines.join("\n").trim(); + if (jsonStr.length > 0) { + try { + const permission = JSON.parse(jsonStr); + agents.push({ + name: currentHeader.name, + mode: currentHeader.mode as Agent["mode"], + hidden: KNOWN_HIDDEN_AGENTS.has(currentHeader.name), + permission, + options: {}, + }); + } catch { + // Skip unparseable agent + } + } + } + currentHeader = null; + blockLines.length = 0; + }; + + for (const line of lines) { + const match = AGENT_HEADER_RE.exec(line); + if (match) { + flushAgent(); + currentHeader = { name: match[1]!, mode: match[2]! }; + } else if (currentHeader !== null) { + blockLines.push(line); + } + } + flushAgent(); + + return agents; +} + +/** @internal */ +export function parseSkillsCliOutput(stdout: string): ReadonlyArray { + const result = decodeOpenCodeSkillsCliOutputExit(stdout); + return Exit.isSuccess(result) ? result.value : []; +} + +export function parseOpenCodeModelSlug( + slug: string | null | undefined, +): ParsedOpenCodeModelSlug | null { + if (typeof slug !== "string") { + return null; + } + + const trimmed = slug.trim(); + const separator = trimmed.indexOf("/"); + if (separator <= 0 || separator === trimmed.length - 1) { + return null; + } + + return { + providerID: trimmed.slice(0, separator), + modelID: trimmed.slice(separator + 1), + }; +} + +export function openCodeQuestionId( + index: number, + question: QuestionRequest["questions"][number], +): string { + const header = question.header + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, "-"); + return header.length > 0 ? `question-${index}-${header}` : `question-${index}`; +} + +/** + * Attachments OpenCode can hand to a model as a native file part. Anything + * else (ZIP, binaries, image formats like BMP/AVIF/SVG that model APIs + * reject, or files over the direct-attachment size limit) would make the turn + * fail before it starts, so those ride only as the file path ProviderService + * puts in the prompt. + */ +const OPENCODE_NATIVE_IMAGE_MIMES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]); +const OPENCODE_NATIVE_FILE_PART_MAX_BYTES = 20 * 1024 * 1024; + +function isOpenCodeNativeFilePart(input: { + readonly mimeType: string; + readonly sizeBytes: number; +}): boolean { + if (input.sizeBytes > OPENCODE_NATIVE_FILE_PART_MAX_BYTES) { + return false; + } + const normalized = input.mimeType.trim().toLowerCase(); + return ( + OPENCODE_NATIVE_IMAGE_MIMES.has(normalized) || + normalized.startsWith("text/") || + normalized === "application/pdf" + ); +} + +export function toOpenCodeFileParts(input: { + readonly attachments: ReadonlyArray | undefined; + readonly resolveAttachmentPath: (attachment: ChatAttachment) => string | null; +}): Array { + const parts: Array = []; + + for (const attachment of input.attachments ?? []) { + if ( + attachment.type === "file" && + "source" in attachment && + attachment.source?._tag === "pasted-text" + ) { + continue; + } + if (!isOpenCodeNativeFilePart(attachment)) { + continue; + } + const attachmentPath = input.resolveAttachmentPath(attachment); + if (!attachmentPath) { + continue; + } + + parts.push({ + type: "file", + mime: attachment.mimeType, + filename: attachment.name, + url: NodeURL.pathToFileURL(attachmentPath).href, + }); + } + + return parts; +} + +export function buildOpenCodePermissionRules(runtimeMode: RuntimeMode): PermissionRuleset { + if (runtimeMode === "full-access") { + return [ + { permission: "*", pattern: "*", action: "allow" }, + { permission: "external_directory", pattern: "*", action: "allow" }, + ]; + } + + // "Auto-accept edits" is documented as "auto-approve edits, ask before other + // actions", so prompting for every edit ignores the mode the user picked. + // "auto" is left asking on purpose: the docs say providers without an AI + // reviewer, OpenCode among them, fall back to Supervised for that mode. + const editAction = runtimeMode === "auto-accept-edits" ? "allow" : "ask"; + + // Session rules override OpenCode's agent defaults. Allow reads and task + // updates, but keep its default approval rules for environment files. + return [ + { permission: "*", pattern: "*", action: "ask" }, + { permission: "read", pattern: "*", action: "allow" }, + { permission: "read", pattern: "*.env", action: "ask" }, + { permission: "read", pattern: "*.env.*", action: "ask" }, + { permission: "read", pattern: "*.env.example", action: "allow" }, + { permission: "glob", pattern: "*", action: "allow" }, + { permission: "grep", pattern: "*", action: "allow" }, + { permission: "lsp", pattern: "*", action: "allow" }, + { permission: "skill", pattern: "*", action: "allow" }, + { permission: "todowrite", pattern: "*", action: "allow" }, + { permission: "bash", pattern: "*", action: "ask" }, + { permission: "edit", pattern: "*", action: editAction }, + { permission: "webfetch", pattern: "*", action: "ask" }, + { permission: "websearch", pattern: "*", action: "ask" }, + { permission: "codesearch", pattern: "*", action: "ask" }, + { permission: "external_directory", pattern: "*", action: "ask" }, + { permission: "doom_loop", pattern: "*", action: "ask" }, + { permission: "question", pattern: "*", action: "allow" }, + ]; +} + +export function toOpenCodePermissionReply( + decision: ProviderApprovalDecision, +): "once" | "always" | "reject" { + switch (decision) { + case "accept": + return "once"; + case "acceptForSession": + case "acceptAlways": + return "always"; + case "decline": + case "cancel": + default: + return "reject"; + } +} + +export function toOpenCodeQuestionAnswers( + request: QuestionRequest, + answers: Record, +): Array { + return request.questions.map((question, index) => { + const raw = + answers[openCodeQuestionId(index, question)] ?? + answers[question.header] ?? + answers[question.question]; + if (Array.isArray(raw)) { + return raw.filter((value): value is string => typeof value === "string"); + } + if (typeof raw === "string") { + return raw.trim().length > 0 ? [raw] : []; + } + return []; + }); +} + +function ensureRuntimeError( + operation: OpenCodeRuntimeError["operation"], + detail: string, + cause: unknown, +): OpenCodeRuntimeError { + return OpenCodeRuntimeError.is(cause) + ? cause + : new OpenCodeRuntimeError({ operation, detail, cause }); +} + +const makeOpenCodeRuntime = Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const netService = yield* NetService.NetService; + const hostPlatform = yield* HostProcessPlatform; + const serverLedger = yield* OpenCodeServerLedger.OpenCodeServerLedger; + const resolveCommand = (command: string, args: ReadonlyArray, env?: NodeJS.ProcessEnv) => + resolveSpawnCommand(command, args, env ? { env } : {}); + + const runOpenCodeCommand: OpenCodeRuntimeShape["runOpenCodeCommand"] = (input) => + Effect.gen(function* () { + const spawnCommand = yield* resolveCommand(input.binaryPath, input.args, input.environment); + const child = yield* spawner.spawn( + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + detached: hostPlatform !== "win32", + shell: spawnCommand.shell, + ...(input.cwd ? { cwd: input.cwd } : {}), + ...(input.environment ? { env: input.environment } : { extendEnv: true }), + }), + ); + const terminateCommandGroup = + hostPlatform === "win32" + ? child.kill({ killSignal: "SIGKILL" }).pipe(Effect.asVoid) + : Effect.sync(() => { + try { + signalProcessGroup(Number(child.pid), "SIGKILL"); + } catch { + // The command and its process group may already have exited. + } + }); + yield* Effect.addFinalizer(() => terminateCommandGroup.pipe(Effect.ignore)); + const collectOptions = + input.maxOutputBytes === undefined ? undefined : { maxBytes: input.maxOutputBytes }; + const [stdout, stderr, code] = yield* Effect.all( + [ + collectStreamAsString(child.stdout, collectOptions), + collectStreamAsString(child.stderr, collectOptions), + child.exitCode, + ], + { concurrency: "unbounded" }, + ); + const exitCode = Number(code); + if (yield* isWindowsCommandNotFound(exitCode, stderr)) { + return yield* new OpenCodeRuntimeError({ + operation: "runOpenCodeCommand", + detail: `spawn ${input.binaryPath} ENOENT`, + }); + } + return { + stdout, + stderr, + code: exitCode, + } satisfies OpenCodeCommandResult; + }).pipe( + Effect.scoped, + Effect.mapError((cause) => + ensureRuntimeError( + "runOpenCodeCommand", + `Failed to execute '${input.binaryPath} ${input.args.join(" ")}': ${openCodeRuntimeErrorDetail(cause)}`, + cause, + ), + ), + ); + + const createOpenCodeSdkClient: OpenCodeRuntimeShape["createOpenCodeSdkClient"] = (input) => + createOpencodeClient({ + baseUrl: input.baseUrl, + directory: input.directory, + ...(input.serverPassword + ? { + headers: { + Authorization: `Basic ${Buffer.from(`opencode:${input.serverPassword}`, "utf8").toString("base64")}`, + }, + } + : {}), + throwOnError: true, + }); + + const startOpenCodeServerProcess: OpenCodeRuntimeShape["startOpenCodeServerProcess"] = (input) => + Effect.gen(function* () { + // Bind this server's lifetime to the caller's scope. When the caller's + // scope closes, the spawned child is killed and all associated fibers + // are interrupted automatically — no `close()` method needed. + const runtimeScope = yield* Scope.Scope; + + const hostname = input.hostname ?? DEFAULT_HOSTNAME; + const port = + input.port ?? + (yield* netService.findAvailablePort(0).pipe( + Effect.mapError( + (cause) => + new OpenCodeRuntimeError({ + operation: "startOpenCodeServerProcess", + detail: `Failed to find available port: ${openCodeRuntimeErrorDetail(cause)}`, + cause, + }), + ), + )); + const timeoutMs = input.timeoutMs ?? DEFAULT_OPENCODE_SERVER_TIMEOUT_MS; + const args = ["serve", `--hostname=${hostname}`, `--port=${port}`]; + const spawnCommand = yield* resolveCommand(input.binaryPath, args, input.environment); + const serverPassword = resolveOpenCodeServerPassword({ + external: false, + ...(input.serverPassword !== undefined ? { serverPassword: input.serverPassword } : {}), + ...(input.environment !== undefined ? { environment: input.environment } : {}), + }); + + // Scopes close in reverse order. Forking this before the group kill is + // registered forgets the ledger entry only once the group is stopped. + const ledgerScope = yield* Scope.fork(runtimeScope); + const child = yield* spawner + .spawn( + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + detached: hostPlatform !== "win32", + shell: spawnCommand.shell, + env: { + ...input.environment, + ...(serverPassword !== undefined ? { OPENCODE_SERVER_PASSWORD: serverPassword } : {}), + // Respect an OPENCODE_CONFIG_CONTENT provided by the caller or + // the inherited process environment, only falling back to the + // empty config when neither is set. Setting it unconditionally + // previously clobbered the user's opencode config, hiding their + // providers/models. The value is set explicitly (rather than + // relying on inheritance) because `extendEnv` is false whenever + // `input.environment` is provided. + OPENCODE_CONFIG_CONTENT: resolveOpenCodeConfigContent(input.environment), + }, + extendEnv: input.environment === undefined, + }), + ) + .pipe( + Effect.provideService(Scope.Scope, runtimeScope), + Effect.mapError( + (cause) => + new OpenCodeRuntimeError({ + operation: "startOpenCodeServerProcess", + detail: `Failed to spawn OpenCode server process: ${openCodeRuntimeErrorDetail(cause)}`, + cause, + }), + ), + ); + + const killOpenCodeProcessGroup = (signal: NodeJS.Signals) => + hostPlatform === "win32" + ? child.kill({ killSignal: signal, forceKillAfter: "1 second" }).pipe(Effect.asVoid) + : Effect.sync(() => { + try { + signalProcessGroup(Number(child.pid), signal); + } catch { + // The direct child may already have exited after starting the + // server; the process group kill is best-effort cleanup for + // any serve process left in that group. + } + }); + const terminateChild = killOpenCodeProcessGroup("SIGTERM").pipe( + Effect.andThen(Effect.sleep("1 second")), + Effect.andThen(killOpenCodeProcessGroup("SIGKILL")), + Effect.ignore, + ); + // Registered before recording, so an interrupt while the ledger writes + // still stops the group. + yield* Scope.addFinalizer(runtimeScope, terminateChild); + const forgetServer = yield* serverLedger.track({ pid: Number(child.pid), port, args }); + yield* Scope.addFinalizer(ledgerScope, forgetServer); + + const stdoutRef = yield* Ref.make(""); + const stderrRef = yield* Ref.make(""); + const readyDeferred = yield* Deferred.make(); + + const setReadyFromStdoutChunk = (chunk: string) => + Ref.modify(stdoutRef, (stdout) => { + if (stdout === null) { + return [null, null] as const; + } + const nextStdout = `${stdout}${chunk}`; + return [ + parseServerUrlFromOutput(nextStdout), + nextStdout.slice(-OPENCODE_SERVER_STARTUP_MAX_OUTPUT_CHARS), + ] as const; + }).pipe( + Effect.flatMap((parsed) => + parsed ? Deferred.succeed(readyDeferred, parsed).pipe(Effect.ignore) : Effect.void, + ), + ); + + const stdoutFiber = yield* child.stdout.pipe( + Stream.decodeText(), + Stream.runForEach(setReadyFromStdoutChunk), + Effect.ignore, + Effect.forkIn(runtimeScope), + ); + const stderrFiber = yield* child.stderr.pipe( + Stream.decodeText(), + Stream.runForEach((chunk) => + Ref.update(stderrRef, (stderr) => + stderr === null + ? null + : `${stderr}${chunk}`.slice(-OPENCODE_SERVER_STARTUP_MAX_OUTPUT_CHARS), + ), + ), + Effect.ignore, + Effect.forkIn(runtimeScope), + ); + + const exitFiber = yield* child.exitCode.pipe( + Effect.flatMap((code) => + Effect.gen(function* () { + const stdout = (yield* Ref.get(stdoutRef)) ?? ""; + const stderr = (yield* Ref.get(stderrRef)) ?? ""; + const exitCode = Number(code); + yield* Deferred.fail( + readyDeferred, + new OpenCodeRuntimeError({ + operation: "startOpenCodeServerProcess", + detail: [ + `OpenCode server exited before startup completed (code: ${String(exitCode)}).`, + stdout.trim() ? `stdout:\n${stdout.trim()}` : null, + stderr.trim() ? `stderr:\n${stderr.trim()}` : null, + ] + .filter(Boolean) + .join("\n\n"), + cause: { exitCode, stdout, stderr }, + }), + ).pipe(Effect.ignore); + }), + ), + Effect.ignore, + Effect.forkIn(runtimeScope), + ); + + const readyExit = yield* Effect.exit( + Deferred.await(readyDeferred).pipe(Effect.timeoutOption(timeoutMs)), + ); + + if (Exit.isFailure(readyExit) || Option.isNone(readyExit.value)) { + yield* Fiber.interruptAll([stdoutFiber, stderrFiber, exitFiber]).pipe(Effect.ignore); + } + + if (Exit.isFailure(readyExit)) { + const squashed = Cause.squash(readyExit.cause); + return yield* ensureRuntimeError( + "startOpenCodeServerProcess", + `Failed while waiting for OpenCode server startup: ${openCodeRuntimeErrorDetail(squashed)}`, + squashed, + ); + } + + const readyOption = readyExit.value; + if (Option.isNone(readyOption)) { + return yield* new OpenCodeRuntimeError({ + operation: "startOpenCodeServerProcess", + detail: `Timed out waiting for OpenCode server start after ${timeoutMs}ms.`, + }); + } + + // Keep draining both pipes until the process scope closes. Stopping the + // readers can block OpenCode when its output buffers fill. Startup output + // is no longer needed, so discard later output instead of retaining it. + yield* Ref.set(stdoutRef, null); + yield* Ref.set(stderrRef, null); + + const url = readyOption.value; + const version = yield* ( + input.verify?.(url) ?? + verifyOpenCodeServerVersion( + createOpenCodeSdkClient({ + baseUrl: url, + directory: input.directory, + ...(serverPassword !== undefined ? { serverPassword } : {}), + }), + ) + ); + + return { + url, + ...(serverPassword !== undefined ? { serverPassword } : {}), + version, + isRunning: child.isRunning.pipe(Effect.orElseSucceed(() => false)), + exitCode: child.exitCode.pipe( + Effect.map(Number), + Effect.orElseSucceed(() => 0), + ), + } satisfies OpenCodeServerProcess; + }); + + const connectToOpenCodeServer: OpenCodeRuntimeShape["connectToOpenCodeServer"] = (input) => { + const serverUrl = input.serverUrl?.trim(); + if (serverUrl) { + const serverPassword = resolveOpenCodeServerPassword({ + external: true, + ...(input.serverPassword !== undefined ? { serverPassword: input.serverPassword } : {}), + }); + return verifyOpenCodeServerVersion( + createOpenCodeSdkClient({ + baseUrl: serverUrl, + directory: input.directory, + ...(serverPassword !== undefined ? { serverPassword } : {}), + }), + ).pipe( + Effect.map((version) => ({ + url: serverUrl, + ...(serverPassword !== undefined ? { serverPassword } : {}), + version, + exitCode: null, + external: true, + })), + ); + } + + return startOpenCodeServerProcess({ + binaryPath: input.binaryPath, + directory: input.directory, + ...(input.serverPassword !== undefined ? { serverPassword: input.serverPassword } : {}), + ...(input.environment !== undefined ? { environment: input.environment } : {}), + ...(input.port !== undefined ? { port: input.port } : {}), + ...(input.hostname !== undefined ? { hostname: input.hostname } : {}), + ...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}), + }).pipe( + Effect.map((server) => ({ + url: server.url, + ...(server.serverPassword !== undefined ? { serverPassword: server.serverPassword } : {}), + version: server.version, + exitCode: server.exitCode, + external: false, + })), + ); + }; + + const loadProviders = (client: OpencodeClient) => + runOpenCodeSdk("provider.list", (signal) => client.provider.list(undefined, { signal })).pipe( + Effect.filterMapOrFail( + (list) => + list.data + ? Result.succeed(list.data) + : Result.fail( + new OpenCodeRuntimeError({ + operation: "provider.list", + detail: "OpenCode provider list was empty.", + }), + ), + (result) => result, + ), + ); + + const loadAgents = (client: OpencodeClient) => + runOpenCodeSdk("app.agents", (signal) => client.app.agents(undefined, { signal })).pipe( + Effect.map((result) => result.data ?? []), + Effect.orElseSucceed((): ReadonlyArray => []), + ); + + const loadOpenCodeSkills: OpenCodeRuntimeShape["loadOpenCodeSkills"] = (client) => + runOpenCodeSdk("app.skills", (signal) => client.app.skills(undefined, { signal })).pipe( + Effect.map((result) => + (result.data ?? []).map((skill) => ({ + name: skill.name, + ...(skill.description === undefined ? {} : { description: skill.description }), + location: skill.location, + })), + ), + ); + const loadSkills = (client: OpencodeClient) => + loadOpenCodeSkills(client).pipe(Effect.orElseSucceed((): ReadonlyArray => [])); + + const loadOpenCodeInventory: OpenCodeRuntimeShape["loadOpenCodeInventory"] = (client) => + Effect.all( + [ + loadProviders(client), + loadAgents(client), + loadSkills(client), + loadOpenCodeCommands(client).pipe(Effect.orElseSucceed(() => [])), + ], + { + concurrency: "unbounded", + }, + ).pipe( + Effect.map(([providerList, agents, skills, commands]) => ({ + providerList, + agents, + skills, + commands, + })), + ); + + const loadInventoryFromCli: OpenCodeRuntimeShape["loadInventoryFromCli"] = (input) => + Effect.gen(function* () { + const env = input.environment !== undefined ? { environment: input.environment } : ({} as {}); + const commandContext = { cwd: input.cwd, ...env }; + + const runModelsCli = () => + runOpenCodeCommand({ + binaryPath: input.binaryPath, + args: ["models", "--verbose"], + ...commandContext, + }).pipe(Effect.exit); + const runAgentsCli = () => + runOpenCodeCommand({ + binaryPath: input.binaryPath, + args: ["agent", "list"], + ...commandContext, + }).pipe(Effect.exit); + const runSkillsCli = () => + runOpenCodeCommand({ + binaryPath: input.binaryPath, + args: ["debug", "skill"], + maxOutputBytes: OPENCODE_SKILL_DISCOVERY_MAX_OUTPUT_BYTES, + ...commandContext, + }).pipe(Effect.exit); + + // Every OpenCode CLI command opens the same shared SQLite database. Running them + // concurrently causes "database is locked" failures, so run them one at a time. + const [initialModelsResult, initialAgentsResult, initialSkillsResult] = yield* Effect.all( + [runModelsCli(), runAgentsCli(), runSkillsCli()], + { concurrency: 1 }, + ); + let modelsResult = initialModelsResult; + let agentsResult = initialAgentsResult; + let skillsResult = initialSkillsResult; + + // Retry once after 1s on transient failures (e.g. SQLite "database is locked") + const needsModelsRetry = modelsResult._tag === "Failure" || modelsResult.value.code !== 0; + const needsAgentsRetry = agentsResult._tag === "Failure" || agentsResult.value.code !== 0; + const needsSkillsRetry = skillsResult._tag === "Failure" || skillsResult.value.code !== 0; + if (needsModelsRetry || needsAgentsRetry || needsSkillsRetry) { + yield* Effect.sleep("1 second"); + const [m2, a2, s2] = yield* Effect.all( + [ + needsModelsRetry ? runModelsCli() : Effect.succeed(modelsResult), + needsAgentsRetry ? runAgentsCli() : Effect.succeed(agentsResult), + needsSkillsRetry ? runSkillsCli() : Effect.succeed(skillsResult), + ], + { concurrency: 1 }, + ); + modelsResult = m2; + agentsResult = a2; + skillsResult = s2; + } + + if (modelsResult._tag === "Failure") { + const cause = Cause.squash(modelsResult.cause); + return yield* ensureRuntimeError( + "loadInventoryFromCli", + `Failed to load OpenCode models: ${openCodeRuntimeErrorDetail(cause)}`, + cause, + ); + } + if (modelsResult.value.code !== 0) { + return yield* new OpenCodeRuntimeError({ + operation: "loadInventoryFromCli", + detail: `OpenCode models command exited with code ${modelsResult.value.code}.`, + }); + } + + const parsed = parseModelsCliOutput(modelsResult.value.stdout); + const connected = [...parsed.connected]; + const allProviders: ProviderListResponse["all"] = [...parsed.providers.values()].map( + (provider) => ({ + id: provider.id, + name: provider.name, + source: "config" as const, + env: [], + options: {}, + models: provider.models, + }), + ); + + // Agent and skill metadata enrich the provider snapshot but are not required + // for an authoritative model inventory, so either may degrade to an empty list. + let agents: ReadonlyArray = []; + if (agentsResult._tag === "Success" && agentsResult.value.code === 0) { + agents = parseAgentListCliOutput(agentsResult.value.stdout); + } + let skills: ReadonlyArray = []; + if (skillsResult._tag === "Success" && skillsResult.value.code === 0) { + skills = parseSkillsCliOutput(skillsResult.value.stdout); + } + + return { + providerList: { all: allProviders, default: {}, connected }, + agents, + skills, + }; + }); + + const loadSkillsFromCli: OpenCodeRuntimeShape["loadSkillsFromCli"] = (input) => + runOpenCodeCommand({ + binaryPath: input.binaryPath, + args: ["debug", "skill"], + cwd: input.cwd, + maxOutputBytes: OPENCODE_SKILL_DISCOVERY_MAX_OUTPUT_BYTES, + ...(input.environment !== undefined ? { environment: input.environment } : {}), + }).pipe( + Effect.flatMap((result) => + result.code === 0 + ? Effect.succeed(parseSkillsCliOutput(result.stdout)) + : Effect.fail( + new OpenCodeRuntimeError({ + operation: "loadSkillsFromCli", + detail: `OpenCode skills command exited with code ${result.code}.`, + }), + ), + ), + ); + + return { + startOpenCodeServerProcess, + connectToOpenCodeServer, + runOpenCodeCommand, + createOpenCodeSdkClient, + loadOpenCodeInventory, + loadOpenCodeSkills, + loadInventoryFromCli, + loadSkillsFromCli, + } satisfies OpenCodeRuntimeShape; +}); + +export class OpenCodeRuntime extends Context.Service()( + "@t3tools/provider-opencode/server/OpenCodeRuntime", +) {} + +export const layer = Layer.effect(OpenCodeRuntime, makeOpenCodeRuntime).pipe( + Layer.provide(NetService.layer), +); diff --git a/packages/provider-opencode/src/server/OpenCodeServerLedger.test.ts b/packages/provider-opencode/src/server/OpenCodeServerLedger.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..a805572e8aac4d01daa03c74c0d2632e7bdde981 --- /dev/null +++ b/packages/provider-opencode/src/server/OpenCodeServerLedger.test.ts @@ -0,0 +1,244 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeChildProcess from "node:child_process"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Scope from "effect/Scope"; +import { ChildProcessSpawner } from "effect/process"; + +import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; +import * as OpenCodeServerLedger from "./OpenCodeServerLedger.ts"; + +const SERVE_ARGS = ["serve", "--hostname=127.0.0.1", "--port=4096"]; + +const groupExists = (pgid: number) => { + try { + process.kill(-pgid, 0); + return true; + } catch { + return false; + } +}; + +const killGroup = (pgid: number) => + Effect.sync(() => { + if (groupExists(pgid)) process.kill(-pgid, "SIGKILL"); + }); + +/** + * Spawns a throwaway process group shaped like `opencode serve`: by default a + * shell leader whose argv ends in the serve arguments, plus a `sleep` member. + */ +const spawnGroup = (args: ReadonlyArray, script = "sleep 600 & wait") => + Effect.acquireRelease( + Effect.gen(function* () { + const child = NodeChildProcess.spawn("/bin/sh", ["-c", script, ...args], { + detached: true, + stdio: "ignore", + }); + const exited = yield* Deferred.make(); + child.once("exit", (_code, signal) => Deferred.doneUnsafe(exited, Effect.succeed(signal))); + const pid = child.pid; + if (pid === undefined) return yield* Effect.die("spawn failed"); + return { pid, exited: Deferred.await(exited) }; + }), + ({ pid }) => killGroup(pid), + ); + +/** A T3 server that recorded its OpenCode server and then died without cleanup. */ +const recordFromDeadServer = (stateDir: string, server: { readonly pid: number }) => + Effect.gen(function* () { + const previousServer = yield* spawnGroup([]); + const previousLedger = yield* OpenCodeServerLedger.make({ + stateDir, + ownerPid: previousServer.pid, + }); + // The previous server never gets to forget its entry. + yield* Effect.asVoid(previousLedger.track({ pid: server.pid, port: 4096, args: SERVE_ARGS })); + process.kill(-previousServer.pid, "SIGKILL"); + yield* previousServer.exited; + }); + +const hostPlatform = HostProcessPlatform.defaultValue(); +// procps accepts the same `ps` flags as macOS, so Linux also covers the macOS path. +const observedPlatforms: ReadonlyArray = + hostPlatform === "linux" ? ["linux", "darwin"] : hostPlatform === "darwin" ? ["darwin"] : []; + +describe.each(observedPlatforms)("OpenCodeServerLedger observing as %s", (platform) => { + const provideHost = (effect: Effect.Effect) => + effect.pipe( + Effect.provideService(HostProcessPlatform, platform), + Effect.provide(NodeServices.layer), + ); + + it.live("stops an OpenCode server group left by a server that died", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const stateDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-ledger-" }); + const orphan = yield* spawnGroup(SERVE_ARGS); + yield* recordFromDeadServer(stateDir, orphan); + expect(yield* fs.readDirectory(path.join(stateDir, "opencode-servers"))).toHaveLength(1); + + const restarted = yield* OpenCodeServerLedger.make({ stateDir }); + yield* restarted.reapOrphans; + + expect(yield* orphan.exited).toBe("SIGTERM"); + expect(groupExists(orphan.pid)).toBe(false); + expect(yield* fs.readDirectory(path.join(stateDir, "opencode-servers"))).toEqual([]); + }).pipe(provideHost), + ); + + it.live("records a group whose spawned wrapper already exited", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const stateDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-ledger-" }); + // The wrapper starts the serve process in its group and exits. + const wrapper = yield* spawnGroup( + SERVE_ARGS, + '/bin/sh -c "sleep 600 & wait" "$0" "$@" & exit 0', + ); + yield* wrapper.exited; + expect(groupExists(wrapper.pid)).toBe(true); + + yield* recordFromDeadServer(stateDir, wrapper); + expect(yield* fs.readDirectory(path.join(stateDir, "opencode-servers"))).toHaveLength(1); + + const restarted = yield* OpenCodeServerLedger.make({ stateDir }); + yield* restarted.reapOrphans; + + expect(groupExists(wrapper.pid)).toBe(false); + expect(yield* fs.readDirectory(path.join(stateDir, "opencode-servers"))).toEqual([]); + }).pipe(provideHost), + ); + + it.live("leaves a recycled pid alone when its start time does not match", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const stateDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-ledger-" }); + const unrelated = yield* spawnGroup(SERVE_ARGS); + yield* recordFromDeadServer(stateDir, unrelated); + const entryPath = path.join(stateDir, "opencode-servers", `${unrelated.pid}.json`); + // Same pid, a different process: what a recycled pid looks like. + const entry = yield* fs.readFileString(entryPath); + yield* fs.writeFileString(entryPath, entry.replace(/"startTime":"[^"]+"/, '"startTime":"1"')); + + const restarted = yield* OpenCodeServerLedger.make({ stateDir }); + yield* restarted.reapOrphans; + + expect(groupExists(unrelated.pid)).toBe(true); + expect(yield* fs.exists(entryPath)).toBe(false); + }).pipe(provideHost), + ); + + it.live("leaves a serve process on another port alone", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const stateDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-ledger-" }); + const unrelated = yield* spawnGroup(SERVE_ARGS); + yield* recordFromDeadServer(stateDir, unrelated); + const entryPath = path.join(stateDir, "opencode-servers", `${unrelated.pid}.json`); + // Same pid and start second, but not the server T3 started. + const entry = yield* fs.readFileString(entryPath); + yield* fs.writeFileString(entryPath, entry.replace("--port=4096", "--port=4097")); + + const restarted = yield* OpenCodeServerLedger.make({ stateDir }); + yield* restarted.reapOrphans; + + expect(groupExists(unrelated.pid)).toBe(true); + expect(yield* fs.exists(entryPath)).toBe(false); + }).pipe(provideHost), + ); + + it.live("leaves the servers of a running T3 server alone", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const stateDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-ledger-" }); + const running = yield* OpenCodeServerLedger.make({ stateDir }); + const server = yield* spawnGroup(SERVE_ARGS); + const forget = yield* running.track({ pid: server.pid, port: 4096, args: SERVE_ARGS }); + + const other = yield* OpenCodeServerLedger.make({ stateDir }); + yield* other.reapOrphans; + expect(groupExists(server.pid)).toBe(true); + + yield* forget; + expect(yield* fs.readDirectory(path.join(stateDir, "opencode-servers"))).toEqual([]); + }).pipe(provideHost), + ); +}); + +describe.skipIf(observedPlatforms.length === 0)("OpenCode server startup", () => { + it.live("stops the group when interrupted while the server is being recorded", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-startup-" }); + // A wrapper that leaves the serve process running in its group and exits 0, + // which the spawner's own cleanup does not stop. + const binaryPath = path.join(tempDir, "opencode"); + yield* fs.writeFileString(binaryPath, "#!/bin/sh\nsleep 600 &\nexit 0\n"); + yield* fs.chmod(binaryPath, 0o755); + + const spawned = yield* Deferred.make(); + const recording = yield* Deferred.make(); + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const layerRuntime = OpenCodeRuntime.layer.pipe( + Layer.provide( + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => + spawner + .spawn(command) + .pipe(Effect.tap((handle) => Deferred.succeed(spawned, handle))), + ), + ), + ), + Layer.provide( + Layer.succeed( + OpenCodeServerLedger.OpenCodeServerLedger, + OpenCodeServerLedger.OpenCodeServerLedger.of({ + // Recording stalls until the scope is interrupted, after the wrapper exited. + track: (server) => + Effect.gen(function* () { + yield* (yield* Deferred.await(spawned)).exitCode.pipe(Effect.ignore); + yield* Deferred.succeed(recording, server.pid); + return yield* Effect.never; + }), + }), + ), + ), + ); + + const serverScope = yield* Scope.make(); + const starting = yield* OpenCodeRuntime.OpenCodeRuntime.pipe( + Effect.flatMap((runtime) => + runtime.startOpenCodeServerProcess({ binaryPath, directory: tempDir, port: 4096 }), + ), + Effect.provideService(Scope.Scope, serverScope), + Effect.provide(layerRuntime), + Effect.forkChild, + ); + const pgid = yield* Deferred.await(recording); + yield* Effect.addFinalizer(() => killGroup(pgid)); + expect(groupExists(pgid)).toBe(true); + + yield* Fiber.interrupt(starting); + yield* Scope.close(serverScope, Exit.void); + + expect(groupExists(pgid)).toBe(false); + }).pipe(Effect.provide(NodeServices.layer)), + ); +}); diff --git a/packages/provider-opencode/src/server/OpenCodeServerLedger.ts b/packages/provider-opencode/src/server/OpenCodeServerLedger.ts new file mode 100644 index 0000000000000000000000000000000000000000..0c721cf6ffd52c531f4d93f6fa7c68266cf2fab7 --- /dev/null +++ b/packages/provider-opencode/src/server/OpenCodeServerLedger.ts @@ -0,0 +1,339 @@ +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; + +import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; +import { signalProcessGroup } from "@t3tools/provider-core/server/processGroup"; + +const ProcessIdentity = Schema.Struct({ pid: Schema.Int, startTime: Schema.String }); +type ProcessIdentity = typeof ProcessIdentity.Type; + +const OpenCodeServerEntry = Schema.Struct({ + version: Schema.Literal(1), + /** The spawned group. Its id is the pid T3 spawned, which also names the entry file. */ + pgid: Schema.Int, + /** + * A group member seen at spawn: the spawned process, or a process it left in + * the group if it had already exited. Its pid, start time, and command line + * must all still match before the group is signalled. + */ + pid: Schema.Int, + /** Linux `/proc//stat` start ticks, or macOS `ps` lstart (whole seconds). */ + startTime: Schema.String, + command: Schema.String, + port: Schema.Int, + /** Entries copied along with a state directory are dropped, never acted on. */ + stateDir: Schema.String, + /** The T3 server that spawned it. Entries of a live owner are never touched. */ + owner: ProcessIdentity, +}); +type OpenCodeServerEntry = typeof OpenCodeServerEntry.Type; +const OpenCodeServerEntryJson = Schema.fromJsonString(OpenCodeServerEntry); +const decodeEntry = Schema.decodeUnknownOption(OpenCodeServerEntryJson); +const encodeEntry = Schema.encodeEffect(OpenCodeServerEntryJson); + +/** + * Local `opencode serve` processes run in their own process group so T3 can + * stop the whole group, which also means they outlive a T3 server that is + * SIGKILLed or crashes. Each spawn is recorded under the state directory and + * removed on a graceful stop; the next server start stops whatever a dead + * server left behind. + */ +export class OpenCodeServerLedger extends Context.Service< + OpenCodeServerLedger, + { + /** Records a spawned server group and returns the effect that forgets it after a graceful stop. */ + readonly track: (server: { + readonly pid: number; + readonly port: number; + /** The argv after the binary, e.g. `["serve", "--hostname=127.0.0.1", "--port=4096"]`. */ + readonly args: ReadonlyArray; + }) => Effect.Effect>; + } +>()("@t3tools/provider-opencode/server/OpenCodeServerLedger") {} + +const ENTRY_DIRECTORY = "opencode-servers"; +const ENTRY_FILE = /^\d+\.json$/; +const STOP_POLL_INTERVAL = "50 millis"; +const STOP_POLL_ATTEMPTS = 40; + +interface ObservedProcess { + readonly pid: number; + readonly pgid: number; + readonly startTime: string; + readonly command: string; + readonly zombie: boolean; +} + +// `ps -o lstart` prints e.g. `Sat Sep 6 20:51:57 2026`; C locale and UTC keep it stable. +const DARWIN_PS_LINE = + /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(\w{3} \w{3} +\d{1,2} \d{2}:\d{2}:\d{2} \d{4})\s+(.*)$/; + +const parseDarwinPs = (output: string): ReadonlyArray => + output.split("\n").flatMap((line) => { + const match = DARWIN_PS_LINE.exec(line); + if (match === null) return []; + return [ + { + pid: Number(match[1]), + pgid: Number(match[2]), + zombie: match[3]!.startsWith("Z"), + startTime: match[4]!, + command: match[5]!.trimEnd(), + }, + ]; + }); + +const signalGroup = (pgid: number, signal: NodeJS.Signals) => { + try { + signalProcessGroup(pgid, signal); + } catch { + // The group may already be gone. + } +}; + +const groupExists = (pgid: number) => { + try { + signalProcessGroup(pgid, 0); + return true; + } catch (cause) { + return (cause as NodeJS.ErrnoException | undefined)?.code !== "ESRCH"; + } +}; + +/** + * Builds a ledger for one state directory. `ownerPid` is the T3 server that + * owns the servers it tracks; it defaults to this process. + */ +export const make = Effect.fn("OpenCodeServerLedger.make")(function* (input: { + readonly stateDir: string; + readonly ownerPid?: number; +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const platform = yield* HostProcessPlatform; + const directory = path.join(input.stateDir, ENTRY_DIRECTORY); + // Windows servers are not detached and are never recorded. + const recordable = platform === "linux" || platform === "darwin"; + + /** Observes `pid`, or nothing when it is not in `group` (if given). */ + const observeLinux = (pid: number, group?: number) => + Effect.gen(function* () { + const stat = yield* fs.readFileString(`/proc/${pid}/stat`); + // After the parenthesized comm: state, ppid, pgrp, session, … starttime (index 19). + const fields = stat + .slice(stat.lastIndexOf(")") + 2) + .trim() + .split(/\s+/); + const pgid = Number(fields[2]); + const startTime = fields[19]; + if (!Number.isSafeInteger(pgid) || startTime === undefined) return undefined; + if (group !== undefined && pgid !== group) return undefined; + const cmdline = yield* fs.readFileString(`/proc/${pid}/cmdline`); + return { + pid, + pgid, + startTime, + command: cmdline.replace(/\0$/, "").replaceAll("\0", " "), + zombie: fields[0] === "Z", + } satisfies ObservedProcess; + }); + + const psDarwin = (selection: ReadonlyArray) => + spawner + .string( + ChildProcess.make( + "/bin/ps", + ["-ww", "-o", "pid=,pgid=,stat=,lstart=,args=", ...selection], + { + env: { LC_ALL: "C", TZ: "UTC" }, + extendEnv: true, + stdin: "ignore", + stderr: "ignore", + }, + ), + ) + .pipe(Effect.timeout("5 seconds"), Effect.map(parseDarwinPs)); + + const observe = (pid: number): Effect.Effect => { + if (platform === "linux") return observeLinux(pid).pipe(Effect.orElseSucceed(() => undefined)); + if (platform === "darwin") { + return psDarwin(["-p", String(pid)]).pipe( + Effect.map((found) => found.find((candidate) => candidate.pid === pid)), + Effect.orElseSucceed(() => undefined), + ); + } + return Effect.succeed(undefined); + }; + + // Scans every process, so it is only used when the spawned process is gone. + const observeGroup = (pgid: number): Effect.Effect> => { + if (platform === "linux") { + return fs.readDirectory("/proc").pipe( + Effect.flatMap((names) => + Effect.forEach( + names.filter((name) => /^\d+$/.test(name)), + (name) => observeLinux(Number(name), pgid).pipe(Effect.orElseSucceed(() => undefined)), + { concurrency: 16 }, + ), + ), + Effect.map((found) => found.filter((candidate) => candidate !== undefined)), + Effect.orElseSucceed(() => []), + ); + } + if (platform === "darwin") { + return psDarwin(["-A"]).pipe( + Effect.map((found) => found.filter((candidate) => candidate.pgid === pgid)), + Effect.orElseSucceed(() => []), + ); + } + return Effect.succeed([]); + }; + + const isRunning = (identity: ProcessIdentity) => + observe(identity.pid).pipe( + Effect.map( + (observed) => + observed !== undefined && !observed.zombie && observed.startTime === identity.startTime, + ), + ); + + const ownerPid = input.ownerPid ?? process.pid; + const ownerProcess = yield* observe(ownerPid); + const owner = + ownerProcess === undefined ? undefined : { pid: ownerPid, startTime: ownerProcess.startTime }; + + const track: OpenCodeServerLedger["Service"]["track"] = (server) => + Effect.gen(function* () { + if (!recordable) return Effect.void; + const pgid = server.pid; + // The spawned process leads the group. If it already exited (a wrapper + // that left the server running), any live member identifies the group: + // a group id is not reused while the group has members. + const leader = yield* observe(pgid); + const members = + leader !== undefined && !leader.zombie && leader.pgid === pgid + ? [leader] + : (yield* observeGroup(pgid)).filter((candidate) => !candidate.zombie); + const member = + members.find((candidate) => candidate.command.endsWith(` ${server.args.join(" ")}`)) ?? + members[0]; + if (owner === undefined || member === undefined) { + if (groupExists(pgid)) { + yield* Effect.logWarning( + "Could not record an OpenCode server; it will keep running if this server crashes", + { pid: pgid, port: server.port }, + ); + } + return Effect.void; + } + const entryPath = path.join(directory, `${pgid}.json`); + const entry: OpenCodeServerEntry = { + version: 1, + pgid, + pid: member.pid, + startTime: member.startTime, + command: member.command, + port: server.port, + stateDir: input.stateDir, + owner, + }; + yield* writeFileStringAtomically({ + filePath: entryPath, + contents: yield* encodeEntry(entry), + }).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + ); + return fs + .remove(entryPath, { force: true }) + .pipe( + Effect.catchCause((cause) => + Effect.logWarning("Could not forget a stopped OpenCode server", { cause }), + ), + ); + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("Could not record an OpenCode server process", { cause }).pipe( + Effect.as(Effect.void), + ), + ), + ); + + // Signals only a group whose recorded member still has the same pid, start + // time, group, and full command line, so a recycled pid is never touched. + const stopOrphan = (entry: OpenCodeServerEntry) => + Effect.gen(function* () { + const observed = yield* observe(entry.pid); + if ( + observed === undefined || + observed.zombie || + observed.startTime !== entry.startTime || + observed.pgid !== entry.pgid || + observed.command !== entry.command + ) { + return; + } + yield* Effect.logInfo("Stopping an OpenCode server left by a previous T3 Code server", { + pid: entry.pgid, + port: entry.port, + }); + signalGroup(entry.pgid, "SIGTERM"); + for (let attempt = 0; attempt < STOP_POLL_ATTEMPTS && groupExists(entry.pgid); attempt++) { + yield* Effect.sleep(STOP_POLL_INTERVAL); + } + // The group never emptied, so its pgid cannot have been reused. + if (groupExists(entry.pgid)) signalGroup(entry.pgid, "SIGKILL"); + }); + + const reapEntry = (entryPath: string) => + Effect.gen(function* () { + const entry = decodeEntry(yield* fs.readFileString(entryPath)); + if (Option.isSome(entry) && entry.value.stateDir === input.stateDir) { + if (yield* isRunning(entry.value.owner)) return; + yield* stopOrphan(entry.value); + } + yield* fs.remove(entryPath, { force: true }); + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("Could not clean up a recorded OpenCode server", { entryPath, cause }), + ), + ); + + /** Stops recorded servers whose owning T3 server is gone and drops stale entries. */ + const reapOrphans = Effect.gen(function* () { + const names = yield* fs.readDirectory(directory).pipe(Effect.orElseSucceed(() => [])); + yield* Effect.forEach( + names.filter((name) => ENTRY_FILE.test(name)), + (name) => reapEntry(path.join(directory, name)), + { concurrency: "unbounded", discard: true }, + ); + }); + + return { track, reapOrphans }; +}); + +/** Tracks servers under `stateDir`, the server's own state directory. */ +export const layer = (input: { readonly stateDir: string }) => + Layer.effect( + OpenCodeServerLedger, + Effect.gen(function* () { + const ledger = yield* make(input); + // Reaping waits for orphans to exit, so it must not hold up startup. + yield* ledger.reapOrphans.pipe(Effect.forkScoped); + return OpenCodeServerLedger.of({ track: ledger.track }); + }), + ); + +/** Records nothing. For tests that start OpenCode servers without a state directory. */ +export const layerTest = Layer.succeed( + OpenCodeServerLedger, + OpenCodeServerLedger.of({ track: () => Effect.succeed(Effect.void) }), +); diff --git a/packages/provider-opencode/src/server/OpenCodeServerOwner.test.ts b/packages/provider-opencode/src/server/OpenCodeServerOwner.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..f1f9881fdc293d81c410a2a7f015524eb3cb4646 --- /dev/null +++ b/packages/provider-opencode/src/server/OpenCodeServerOwner.test.ts @@ -0,0 +1,289 @@ +import { it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Ref from "effect/Ref"; +import * as TestClock from "effect/testing/TestClock"; +import { expect } from "vite-plus/test"; + +import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; +import * as OpenCodeServerOwner from "./OpenCodeServerOwner.ts"; + +const unusedRuntimeMethod = () => + Effect.fail( + new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: "unused", + detail: "unused test method", + }), + ); + +const makeRuntime = Effect.gen(function* () { + const starts = yield* Ref.make(0); + const closes = yield* Ref.make(0); + const failNextStart = yield* Ref.make(false); + const started = yield* Deferred.make(); + const closed = yield* Deferred.make(); + const runtime: OpenCodeRuntime.OpenCodeRuntimeShape = { + startOpenCodeServerProcess: () => + Effect.gen(function* () { + if (yield* Ref.getAndSet(failNextStart, false)) { + return yield* new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: "startOpenCodeServerProcess", + detail: "start failed", + }); + } + const index = yield* Ref.updateAndGet(starts, (count) => count + 1); + yield* Deferred.succeed(started, undefined).pipe(Effect.ignore); + yield* Effect.addFinalizer(() => + Ref.update(closes, (count) => count + 1).pipe( + Effect.andThen(Deferred.succeed(closed, undefined)), + Effect.ignore, + ), + ); + return { + url: `http://127.0.0.1:${index}`, + version: "1.14.19", + isRunning: Effect.succeed(true), + exitCode: Effect.never, + }; + }), + connectToOpenCodeServer: unusedRuntimeMethod, + runOpenCodeCommand: unusedRuntimeMethod, + createOpenCodeSdkClient: () => ({}) as never, + loadOpenCodeInventory: unusedRuntimeMethod, + loadOpenCodeSkills: unusedRuntimeMethod, + loadInventoryFromCli: unusedRuntimeMethod, + loadSkillsFromCli: unusedRuntimeMethod, + }; + return { runtime, starts, closes, failNextStart, started, closed }; +}); + +it.effect("shares concurrent borrowers and closes after the idle TTL", () => + Effect.gen(function* () { + const testRuntime = yield* makeRuntime; + const release = yield* Deferred.make(); + yield* Effect.scoped( + Effect.gen(function* () { + const owner = yield* OpenCodeServerOwner.make({ + binaryPath: "opencode", + directory: "/project", + }); + const useServer = owner.withServer((server) => + Deferred.await(release).pipe(Effect.as(server.url)), + ); + const fibers = yield* Effect.all([useServer, useServer], { + concurrency: "unbounded", + }).pipe(Effect.forkChild); + yield* Deferred.await(testRuntime.started); + expect(yield* Ref.get(testRuntime.starts)).toBe(1); + yield* Deferred.succeed(release, undefined); + expect(yield* Fiber.join(fibers)).toEqual(["http://127.0.0.1:1", "http://127.0.0.1:1"]); + yield* TestClock.adjust(Duration.seconds(31)); + yield* Deferred.await(testRuntime.closed); + expect(yield* Ref.get(testRuntime.closes)).toBe(1); + }), + ).pipe(Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, testRuntime.runtime)); + }).pipe(Effect.provide(TestClock.layer())), +); + +it.effect("retries a failed start and closes on owner scope shutdown", () => + Effect.gen(function* () { + const testRuntime = yield* makeRuntime; + yield* Ref.set(testRuntime.failNextStart, true); + yield* Effect.scoped( + Effect.gen(function* () { + const owner = yield* OpenCodeServerOwner.make({ + binaryPath: "opencode", + directory: "/project", + }); + expect( + (yield* Effect.exit(owner.withServer((server) => Effect.succeed(server.url))))._tag, + ).toBe("Failure"); + expect(yield* owner.withServer((server) => Effect.succeed(server.url))).toBe( + "http://127.0.0.1:1", + ); + }), + ).pipe(Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, testRuntime.runtime)); + expect(yield* Ref.get(testRuntime.starts)).toBe(1); + expect(yield* Ref.get(testRuntime.closes)).toBe(1); + }), +); + +it.effect("invalidates an exited process so the next borrower starts a new one", () => + Effect.gen(function* () { + const starts = yield* Ref.make(0); + const processExits: Array> = []; + const processClosed = yield* Deferred.make(); + const runtime: OpenCodeRuntime.OpenCodeRuntimeShape = { + startOpenCodeServerProcess: () => + Effect.gen(function* () { + const index = yield* Ref.updateAndGet(starts, (count) => count + 1); + const exitCode = yield* Deferred.make(); + processExits.push(exitCode); + yield* Effect.addFinalizer(() => + Deferred.succeed(processClosed, undefined).pipe(Effect.ignore), + ); + return { + url: `http://127.0.0.1:${index}`, + version: "1.14.19", + isRunning: Effect.succeed(true), + exitCode: Deferred.await(exitCode), + }; + }), + connectToOpenCodeServer: unusedRuntimeMethod, + runOpenCodeCommand: unusedRuntimeMethod, + createOpenCodeSdkClient: () => ({}) as never, + loadOpenCodeInventory: unusedRuntimeMethod, + loadOpenCodeSkills: unusedRuntimeMethod, + loadInventoryFromCli: unusedRuntimeMethod, + loadSkillsFromCli: unusedRuntimeMethod, + }; + + yield* Effect.scoped( + Effect.gen(function* () { + const owner = yield* OpenCodeServerOwner.make({ + binaryPath: "opencode", + directory: "/project", + }); + expect(yield* owner.withServer((server) => Effect.succeed(server.url))).toBe( + "http://127.0.0.1:1", + ); + yield* Deferred.succeed(processExits[0]!, 1); + yield* Deferred.await(processClosed); + expect(yield* owner.withServer((server) => Effect.succeed(server.url))).toBe( + "http://127.0.0.1:2", + ); + }), + ).pipe(Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, runtime)); + expect(yield* Ref.get(starts)).toBe(2); + }), +); + +it.effect("replaces a dead cached process before its exit watcher runs", () => + Effect.gen(function* () { + const starts = yield* Ref.make(0); + const closes = yield* Ref.make(0); + const processRunning: Array> = []; + const runtime: OpenCodeRuntime.OpenCodeRuntimeShape = { + startOpenCodeServerProcess: () => + Effect.gen(function* () { + const index = yield* Ref.updateAndGet(starts, (count) => count + 1); + const isRunning = yield* Ref.make(true); + processRunning.push(isRunning); + yield* Effect.addFinalizer(() => Ref.update(closes, (count) => count + 1)); + return { + url: `http://127.0.0.1:${index}`, + version: "1.14.19", + isRunning: Ref.get(isRunning), + exitCode: Effect.never, + }; + }), + connectToOpenCodeServer: unusedRuntimeMethod, + runOpenCodeCommand: unusedRuntimeMethod, + createOpenCodeSdkClient: () => ({}) as never, + loadOpenCodeInventory: unusedRuntimeMethod, + loadOpenCodeSkills: unusedRuntimeMethod, + loadInventoryFromCli: unusedRuntimeMethod, + loadSkillsFromCli: unusedRuntimeMethod, + }; + + yield* Effect.scoped( + Effect.gen(function* () { + const owner = yield* OpenCodeServerOwner.make({ + binaryPath: "opencode", + directory: "/project", + }); + expect(yield* owner.withServer((server) => Effect.succeed(server.url))).toBe( + "http://127.0.0.1:1", + ); + yield* Ref.set(processRunning[0]!, false); + + expect(yield* owner.withServer((server) => Effect.succeed(server.url))).toBe( + "http://127.0.0.1:2", + ); + expect(yield* Ref.get(starts)).toBe(2); + expect(yield* Ref.get(closes)).toBe(1); + }), + ).pipe(Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, runtime)); + }), +); + +it.effect("cleans up an interrupted startup and allows a retry", () => + Effect.gen(function* () { + const starts = yield* Ref.make(0); + const firstStartEntered = yield* Deferred.make(); + const firstStartClosed = yield* Deferred.make(); + const runtime: OpenCodeRuntime.OpenCodeRuntimeShape = { + startOpenCodeServerProcess: () => + Effect.gen(function* () { + const index = yield* Ref.updateAndGet(starts, (count) => count + 1); + yield* Effect.addFinalizer(() => + index === 1 + ? Deferred.succeed(firstStartClosed, undefined).pipe(Effect.ignore) + : Effect.void, + ); + if (index === 1) { + yield* Deferred.succeed(firstStartEntered, undefined); + return yield* Effect.never; + } + return { + url: `http://127.0.0.1:${index}`, + version: "1.14.19", + isRunning: Effect.succeed(true), + exitCode: Effect.never, + }; + }), + connectToOpenCodeServer: unusedRuntimeMethod, + runOpenCodeCommand: unusedRuntimeMethod, + createOpenCodeSdkClient: () => ({}) as never, + loadOpenCodeInventory: unusedRuntimeMethod, + loadOpenCodeSkills: unusedRuntimeMethod, + loadInventoryFromCli: unusedRuntimeMethod, + loadSkillsFromCli: unusedRuntimeMethod, + }; + + yield* Effect.scoped( + Effect.gen(function* () { + const owner = yield* OpenCodeServerOwner.make({ + binaryPath: "opencode", + directory: "/project", + }); + const firstBorrower = yield* owner + .withServer((server) => Effect.succeed(server.url)) + .pipe(Effect.forkChild); + yield* Deferred.await(firstStartEntered); + yield* Fiber.interrupt(firstBorrower); + yield* Deferred.await(firstStartClosed); + expect(yield* owner.withServer((server) => Effect.succeed(server.url))).toBe( + "http://127.0.0.1:2", + ); + }), + ).pipe(Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, runtime)); + }), +); + +it.effect("releases an interrupted borrower and closes after the idle TTL", () => + Effect.gen(function* () { + const testRuntime = yield* makeRuntime; + const borrowerEntered = yield* Deferred.make(); + yield* Effect.scoped( + Effect.gen(function* () { + const owner = yield* OpenCodeServerOwner.make({ + binaryPath: "opencode", + directory: "/project", + }); + const borrower = yield* owner + .withServer(() => + Deferred.succeed(borrowerEntered, undefined).pipe(Effect.andThen(Effect.never)), + ) + .pipe(Effect.forkChild); + yield* Deferred.await(borrowerEntered); + yield* Fiber.interrupt(borrower); + yield* TestClock.adjust(Duration.seconds(31)); + yield* Deferred.await(testRuntime.closed); + expect(yield* Ref.get(testRuntime.closes)).toBe(1); + }), + ).pipe(Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, testRuntime.runtime)); + }).pipe(Effect.provide(TestClock.layer())), +); diff --git a/packages/provider-opencode/src/server/OpenCodeServerOwner.ts b/packages/provider-opencode/src/server/OpenCodeServerOwner.ts new file mode 100644 index 0000000000000000000000000000000000000000..05d65dba377dcc21b8219c9e09b9ddafa6d0ad37 --- /dev/null +++ b/packages/provider-opencode/src/server/OpenCodeServerOwner.ts @@ -0,0 +1,188 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; + +import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; + +const OPENCODE_SERVER_IDLE_TTL = "30 seconds"; + +interface OpenCodeServerOwnerState { + server: OpenCodeRuntime.OpenCodeServerProcess | null; + serverScope: Scope.Closeable | null; + borrowers: number; + idleCloseFiber: Fiber.Fiber | null; +} + +export class OpenCodeServerOwner extends Context.Service< + OpenCodeServerOwner, + { + readonly withServer: ( + use: (server: OpenCodeRuntime.OpenCodeServerProcess) => Effect.Effect, + ) => Effect.Effect; + } +>()("@t3tools/provider-opencode/server/OpenCodeServerOwner") {} + +/** Owns the lazy local OpenCode server shared by one provider instance. */ +export const make = Effect.fn("OpenCodeServerOwner.make")(function* (input: { + readonly binaryPath: string; + readonly directory: string; + readonly serverPassword?: string; + readonly environment?: NodeJS.ProcessEnv; + readonly verify?: (url: string) => Effect.Effect; +}) { + const runtime = yield* OpenCodeRuntime.OpenCodeRuntime; + const ownerScope = yield* Effect.acquireRelease(Scope.make(), (scope) => + Scope.close(scope, Exit.void), + ); + const mutex = yield* Semaphore.make(1); + const state: OpenCodeServerOwnerState = { + server: null, + serverScope: null, + borrowers: 0, + idleCloseFiber: null, + }; + + const cancelIdleClose = Effect.fn("OpenCodeServerOwner.cancelIdleClose")(function* () { + const fiber = state.idleCloseFiber; + state.idleCloseFiber = null; + if (fiber !== null) { + yield* Fiber.interrupt(fiber).pipe(Effect.ignore); + } + }); + + const closeServer = Effect.fn("OpenCodeServerOwner.closeServer")(function* ( + expected?: OpenCodeRuntime.OpenCodeServerProcess, + ) { + if (expected !== undefined && state.server !== expected) { + return; + } + const scope = state.serverScope; + state.server = null; + state.serverScope = null; + if (scope !== null) { + yield* Scope.close(scope, Exit.void).pipe(Effect.ignore); + } + }); + + const watchServerExit = Effect.fn("OpenCodeServerOwner.watchServerExit")(function* ( + server: OpenCodeRuntime.OpenCodeServerProcess, + ) { + yield* server.exitCode; + yield* mutex.withPermit( + Effect.gen(function* () { + if (state.server !== server) { + return; + } + yield* cancelIdleClose(); + yield* closeServer(server); + }), + ); + }); + + const acquireServer = mutex.withPermit( + Effect.gen(function* () { + yield* cancelIdleClose(); + if (state.server !== null) { + if (yield* state.server.isRunning) { + state.borrowers += 1; + return state.server; + } + yield* closeServer(state.server); + } + + return yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const serverScope = yield* Scope.make(); + const started = yield* Effect.exit( + restore( + runtime + .startOpenCodeServerProcess({ + binaryPath: input.binaryPath, + directory: input.directory, + ...(input.serverPassword !== undefined + ? { serverPassword: input.serverPassword } + : {}), + ...(input.environment ? { environment: input.environment } : {}), + ...(input.verify ? { verify: input.verify } : {}), + }) + .pipe(Effect.provideService(Scope.Scope, serverScope)), + ), + ); + if (Exit.isFailure(started)) { + yield* Scope.close(serverScope, Exit.void).pipe(Effect.ignore); + return yield* Effect.failCause(started.cause); + } + + const server = started.value; + state.server = server; + state.serverScope = serverScope; + state.borrowers = 1; + yield* watchServerExit(server).pipe(Effect.forkIn(ownerScope)); + return server; + }), + ); + }), + ); + + const releaseServer = (server: OpenCodeRuntime.OpenCodeServerProcess) => + mutex.withPermit( + Effect.gen(function* () { + if (state.server !== server) { + return; + } + state.borrowers = Math.max(0, state.borrowers - 1); + if (state.borrowers > 0) { + return; + } + yield* cancelIdleClose(); + state.idleCloseFiber = yield* Effect.sleep(OPENCODE_SERVER_IDLE_TTL).pipe( + Effect.andThen( + mutex.withPermit( + Effect.gen(function* () { + if (state.server !== server || state.borrowers > 0) { + return; + } + state.idleCloseFiber = null; + yield* closeServer(server); + }), + ), + ), + Effect.forkIn(ownerScope), + ); + }), + ); + + yield* Effect.addFinalizer(() => + mutex.withPermit( + Effect.gen(function* () { + yield* cancelIdleClose(); + state.borrowers = 0; + yield* closeServer(); + }), + ), + ); + + return OpenCodeServerOwner.of({ + withServer: (use) => + Effect.uninterruptibleMask((restore) => + restore(acquireServer).pipe( + Effect.flatMap((server) => + restore(use(server)).pipe(Effect.ensuring(releaseServer(server))), + ), + ), + ), + }); +}); + +/** @public Service construction is part of the canonical Effect module API. */ +export const layer = (input: { + readonly binaryPath: string; + readonly directory: string; + readonly serverPassword?: string; + readonly environment?: NodeJS.ProcessEnv; + readonly verify?: (url: string) => Effect.Effect; +}) => Layer.effect(OpenCodeServerOwner, make(input)); diff --git a/packages/provider-opencode/src/server/adapter.test.ts b/packages/provider-opencode/src/server/adapter.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..9f80cdbead986173ebccdb296a78ff7352f19c86 --- /dev/null +++ b/packages/provider-opencode/src/server/adapter.test.ts @@ -0,0 +1,2730 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import type { OpencodeClient, ToolPart } from "@opencode-ai/sdk/v2"; +import { + CheckpointId, + NodeId, + ProjectId, + ProviderInstanceId, + ProviderSessionId, + ProviderThreadId, + ProviderTurnId, + RunAttemptId, + RunId, + MessageId, + ThreadId, + type OrchestrationV2ProviderThread, + type OrchestrationV2ProviderTurn, +} from "@t3tools/contracts"; +import { OpenCodeSettings } from "../settings.ts"; +import * as Effect from "effect/Effect"; +import * as Clock from "effect/Clock"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Fiber from "effect/Fiber"; +import * as Exit from "effect/Exit"; +import * as Queue from "effect/Queue"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; + +import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; +import { layerTestProviderHost } from "@t3tools/provider-testing/host"; +import type * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; +import type * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; + +import { + advanceOpenCodePromptAdmission, + cancelOpenCodePromptAdmission, + openCodeBoundaryAfterProviderTurn, + openCodeChildPermissionRules, + openCodePermissionRules, + openCodePermissionRequestKind, + openCodeToolProjectionKind, + makeOpenCodeProtocolLogger, + makeOpenCodeAdapterV2, + OPENCODE_PROVIDER, + reconcileOpenCodePromptAdmissionStatus, +} from "./adapter.ts"; +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; + +const encodeUnknownJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); +const OPEN_CODE_TEST_SETTINGS = Schema.decodeSync(OpenCodeSettings)({ + serverUrl: "http://test.invalid", +}); + +function promiseGate() { + let resolve!: (value: A) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +function asyncEventStream() { + const values: Array<{ value: unknown; handled: ReturnType> }> = []; + const waiters: Array<(value: IteratorResult) => void> = []; + let previousHandled: ReturnType> | undefined; + let closed = false; + return { + close() { + closed = true; + for (const waiter of waiters.splice(0)) waiter({ done: true, value: undefined }); + }, + push(value: unknown) { + const handled = promiseGate(); + const waiter = waiters.shift(); + if (waiter) { + previousHandled = handled; + waiter({ done: false, value }); + } else values.push({ value, handled }); + return handled.promise; + }, + stream: { + [Symbol.asyncIterator]() { + return { + next: () => { + previousHandled?.resolve(); + if (closed) return Promise.resolve({ done: true as const, value: undefined }); + const entry = values.shift(); + if (entry !== undefined) { + previousHandled = entry.handled; + return Promise.resolve({ done: false as const, value: entry.value }); + } + return new Promise>((resolve) => waiters.push(resolve)); + }, + }; + }, + }, + }; +} + +const OPENCODE_TEST_SETTINGS = Schema.decodeUnknownSync(OpenCodeSettings)({}); + +function runtimePolicy( + runtimeMode: ProviderAdapter.ProviderAdapterV2RuntimePolicy["runtimeMode"], + override: Partial = {}, +): ProviderAdapter.ProviderAdapterV2RuntimePolicy { + return ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode, + interactionMode: "default", + cwd: null, + ...override, + }); +} + +function permissionAction(rules: ReturnType, permission: string) { + return rules.findLast((rule) => rule.permission === "*" || rule.permission === permission) + ?.action; +} + +function providerTurn(input: { + readonly id: string; + readonly ordinal: number; + readonly nativeId: string | null; +}): OrchestrationV2ProviderTurn { + return { + id: ProviderTurnId.make(input.id), + providerThreadId: ProviderThreadId.make("provider-thread:opencode-test"), + nodeId: NodeId.make(`node:${input.id}`), + runAttemptId: null, + nativeTurnRef: + input.nativeId === null + ? null + : { driver: OPENCODE_PROVIDER, nativeId: input.nativeId, strength: "weak" }, + ordinal: input.ordinal, + status: "completed", + startedAt: null, + completedAt: null, + }; +} + +const makeOpenCodeRuntimeHarness = Effect.fn("makeOpenCodeRuntimeHarness")(function* ( + suffix: string, + nativeSessionId: string, + client: object, +) { + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const instanceId = ProviderInstanceId.make(`opencode-${suffix}`); + const threadId = ThreadId.make(`thread-opencode-${suffix}`); + const modelSelection = { + instanceId, + model: "anthropic/claude-sonnet", + options: [], + }; + const policy = runtimePolicy("full-access", { cwd: "/workspace" }); + const adapter = makeOpenCodeAdapterV2({ + instanceId, + settings: OPEN_CODE_TEST_SETTINGS, + environment: {}, + runtime: { + connectToOpenCodeServer: () => Effect.succeed({ url: "http://test.invalid", external: true }), + createOpenCodeSdkClient: () => client, + } as unknown as OpenCodeRuntime.OpenCodeRuntimeShape, + idAllocator, + host: { + paths: { cwd: "/workspace" }, + resolveAttachmentPath: () => null, + } as unknown as ProviderHost.ProviderHostShape, + }); + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make(`session-opencode-${suffix}`), + modelSelection, + runtimePolicy: policy, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy: policy, + }); + const now = yield* DateTime.now; + const startTurn = ( + text = "hello", + startProviderThread: OrchestrationV2ProviderThread = providerThread, + ) => + runtime.startTurn({ + appThread: { + id: threadId, + projectId: ProjectId.make(`project-opencode-${suffix}`), + title: suffix, + providerInstanceId: modelSelection.instanceId, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: providerThread.id, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, + forkedFrom: null, + createdBy: "user", + creationSource: "web", + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + }, + threadId, + runId: RunId.make(`run-opencode-${suffix}`), + runOrdinal: 1, + providerTurnOrdinal: 1, + attemptId: RunAttemptId.make(`attempt-opencode-${suffix}`), + rootNodeId: NodeId.make(`node-opencode-${suffix}`), + providerThread: startProviderThread, + message: { + createdBy: "user", + creationSource: "web", + messageId: MessageId.make(`message-opencode-${suffix}`), + text, + attachments: [], + }, + modelSelection, + runtimePolicy: policy, + }); + return { + nativeSessionId, + now, + policy, + providerThread, + runId: RunId.make(`run-opencode-${suffix}`), + runtime, + startTurn, + threadId, + }; +}); + +describe("OpenCodeAdapterV2", () => { + it.effect.each(["completed", "failed", "unresolved", "unavailable", "reconnect"] as const)( + "normalizes OpenCode step usage for %s turns", + (ending) => + Effect.gen(function* () { + const nativeEvents = asyncEventStream(); + let promptId = ""; + const harness = yield* makeOpenCodeRuntimeHarness(`usage-${ending}`, "root", { + event: { subscribe: async () => ({ stream: nativeEvents.stream }) }, + session: { + create: async () => ({ data: { id: "root", time: { created: 1, updated: 1 } } }), + promptAsync: async (input: { messageID: string }) => { + promptId = input.messageID; + return { data: true }; + }, + abort: async () => ({ data: true }), + children: async () => ({ data: [] }), + }, + }); + yield* harness.startTurn(); + const received = yield* harness.runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + Effect.forkScoped, + ); + yield* Effect.promise(() => + nativeEvents.push({ + type: "message.updated", + properties: { + sessionID: "root", + info: { id: promptId, role: "user", time: { created: 1 } }, + }, + }), + ); + const step = (id: string, messageID = "assistant") => ({ + type: "message.part.updated", + properties: { + part: { + type: "step-finish", + id, + sessionID: "root", + messageID, + reason: "stop", + cost: 0.01, + tokens: { input: 10, output: 5, reasoning: 2, cache: { read: 3, write: 4 } }, + }, + }, + }); + if (ending !== "unavailable") { + yield* Effect.promise(() => nativeEvents.push(step("one"))); + yield* Effect.promise(() => + nativeEvents.push({ + type: "message.updated", + properties: { + sessionID: "root", + info: { + id: "assistant", + role: "assistant", + time: { created: 1 }, + parentID: promptId, + }, + }, + }), + ); + yield* Effect.promise(() => nativeEvents.push(step("one"))); + yield* Effect.promise(() => nativeEvents.push(step("two"))); + yield* Effect.promise(() => + nativeEvents.push({ + type: "message.part.removed", + properties: { sessionID: "root", messageID: "assistant", partID: "one" }, + }), + ); + yield* Effect.promise(() => + nativeEvents.push({ + type: "message.updated", + properties: { + sessionID: "root", + info: { + id: "old-assistant", + role: "assistant", + time: { created: 1 }, + parentID: "old-prompt", + }, + }, + }), + ); + yield* Effect.promise(() => nativeEvents.push(step("old", "old-assistant"))); + } + if (ending === "unresolved") + yield* Effect.promise(() => nativeEvents.push(step("unknown", "unknown-assistant"))); + if (ending === "reconnect") { + yield* Effect.promise(() => + nativeEvents.push({ type: "server.connected", properties: {} }), + ); + yield* Effect.promise(() => + nativeEvents.push({ type: "server.connected", properties: {} }), + ); + } + if (ending === "failed") { + yield* Effect.promise(() => + nativeEvents.push({ + type: "session.error", + properties: { + sessionID: "root", + error: { name: "UnknownError", data: { message: "failed" } }, + }, + }), + ); + } else { + yield* Effect.promise(() => + nativeEvents.push({ + type: "session.status", + properties: { sessionID: "root", status: { type: "busy" } }, + }), + ); + yield* Effect.promise(() => + nativeEvents.push({ + type: "session.status", + properties: { sessionID: "root", status: { type: "idle" } }, + }), + ); + } + const events = yield* Fiber.join(received); + const completed = events.findLast((event) => event.type === "provider_turn.updated"); + assert.deepEqual( + completed?.providerTurn.turnTokenUsage, + ending === "unavailable" + ? { usageStatus: "unavailable", usageScope: "main_agent", hasSubagents: false } + : { + usageStatus: ending === "completed" ? "complete" : "partial", + usageScope: "main_agent", + inputTokens: 34, + cachedInputTokens: 6, + cacheCreationTokens: 8, + outputTokens: 14, + reasoningTokens: 4, + hasSubagents: false, + }, + ); + }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), + ); + + it.effect.each(["permission", "question"] as const)( + "cancels an undelivered %s reply at its deadline", + (kind) => + Effect.gen(function* () { + const nativeEvents = asyncEventStream(); + const called = promiseGate(); + let signal: AbortSignal | undefined; + let deliver = false; + const harness = yield* makeOpenCodeRuntimeHarness(`reply-${kind}`, "root", { + event: { subscribe: async () => ({ stream: nativeEvents.stream }) }, + session: { + create: async () => ({ data: { id: "root", time: { created: 1, updated: 1 } } }), + promptAsync: async () => ({ data: true }), + abort: async () => ({ data: true }), + children: async () => ({ data: [] }), + }, + [kind]: { + reply: async (_input: unknown, options: { signal: AbortSignal }) => { + if (deliver) return { data: true }; + signal = options.signal; + called.resolve(); + return new Promise(() => {}); + }, + }, + }); + yield* harness.startTurn(); + const received = yield* harness.runtime.events.pipe( + Stream.filter((event) => event.type === "runtime_request.updated"), + Stream.take(1), + Stream.runCollect, + Effect.forkScoped, + ); + yield* Effect.promise(() => + nativeEvents.push({ + type: `${kind}.asked`, + properties: + kind === "permission" + ? { + id: "request", + sessionID: "root", + permission: "bash", + patterns: ["*"], + always: [], + metadata: {}, + } + : { + id: "request", + sessionID: "root", + questions: [ + { + header: "Choice", + question: "Which?", + options: [{ label: "Yes", description: "Proceed" }], + }, + ], + }, + }), + ); + const request = (yield* Fiber.join(received))[0]!.runtimeRequest; + const response = { + requestId: request.id, + ...(kind === "permission" + ? { decision: "accept" as const } + : { answers: { Choice: "Yes" } }), + }; + const reply = yield* harness.runtime + .respondToRuntimeRequest(response) + .pipe(Effect.exit, Effect.forkScoped); + yield* Effect.promise(() => called.promise); + yield* TestClock.adjust("10 seconds"); + assert.isTrue(Exit.isFailure(yield* Fiber.join(reply))); + assert.isTrue(signal?.aborted); + deliver = true; + // Failed delivery leaves the request available for an explicit retry. + yield* harness.runtime.respondToRuntimeRequest(response); + }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), + ); + + it.effect("aborts external root and descendants before closing the event stream", () => + Effect.gen(function* () { + const scope = yield* Scope.make(); + const nativeEvents = asyncEventStream(); + const calls: string[] = []; + yield* makeOpenCodeRuntimeHarness("release", "root", { + event: { + subscribe: async (_input: unknown, options: { signal: AbortSignal }) => { + options.signal.addEventListener("abort", () => { + calls.push("stream.close"); + nativeEvents.close(); + }); + return { stream: nativeEvents.stream }; + }, + }, + session: { + create: async () => ({ data: { id: "root", time: { created: 1, updated: 1 } } }), + abort: async ({ sessionID }: { sessionID: string }) => { + calls.push(`abort:${sessionID}`); + return { data: true }; + }, + children: async ({ sessionID }: { sessionID: string }) => { + calls.push(`children:${sessionID}`); + return { data: sessionID === "root" ? [{ id: "child" }] : [] }; + }, + }, + }).pipe(Effect.provideService(Scope.Scope, scope)); + yield* Scope.close(scope, Exit.void); + assert.deepEqual(calls, [ + "abort:root", + "children:root", + "abort:child", + "children:child", + "stream.close", + ]); + }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), + ); + + it.effect.each(["enumeration", "abort", "not-found", "timeout"] as const)( + "reports descendant cleanup %s", + (failure) => + Effect.gen(function* () { + const nativeEvents = asyncEventStream(); + const called = promiseGate(); + let childSignal: AbortSignal | undefined; + const harness = yield* makeOpenCodeRuntimeHarness(`cleanup-${failure}`, "root", { + event: { subscribe: async () => ({ stream: nativeEvents.stream }) }, + session: { + create: async () => ({ data: { id: "root", time: { created: 1, updated: 1 } } }), + promptAsync: async () => ({ data: true }), + get: async () => ({ data: { id: "root", time: { created: 1, updated: 1 } } }), + messages: async () => ({ data: [] }), + children: async ({ sessionID }: { sessionID: string }) => { + if (failure === "enumeration") throw new Error("cannot enumerate"); + return { data: sessionID === "root" ? [{ id: "child" }] : [] }; + }, + abort: async ( + { sessionID }: { sessionID: string }, + options: { signal: AbortSignal }, + ) => { + if (sessionID === "root") return { data: true }; + if (failure === "timeout" && childSignal?.aborted) return { data: true }; + childSignal = options.signal; + called.resolve(); + if (failure === "timeout") { + return new Promise(() => {}); + } + if (failure === "not-found") throw { status: 404 }; + throw new Error("child abort failed"); + }, + }, + }); + yield* harness.startTurn(); + const snapshot = yield* harness.runtime.readThreadSnapshot({ + providerThread: harness.providerThread, + }); + const stop = yield* harness.runtime + .interruptTurn({ + providerThread: harness.providerThread, + providerTurnId: snapshot.providerTurns.at(-1)!.id, + }) + .pipe(Effect.exit, Effect.forkScoped); + if (failure === "timeout") { + yield* Effect.promise(() => called.promise); + yield* TestClock.adjust("15 seconds"); + } + const result = yield* Fiber.join(stop); + assert.equal(Exit.isSuccess(result), failure === "not-found"); + if (failure === "timeout") assert.isTrue(childSignal?.aborted); + }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), + ); + + it.effect( + "preserves tool lifecycle, approval kinds, and late assistant text without cached tool payloads", + () => + Effect.gen(function* () { + const nativeEvents = asyncEventStream(); + const nativeSessionId = "native-opencode-tool-lifecycle"; + const harness = yield* makeOpenCodeRuntimeHarness("tool-lifecycle", nativeSessionId, { + event: { + subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { + options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); + return { stream: nativeEvents.stream }; + }, + }, + session: { + create: async () => ({ + data: { id: nativeSessionId, time: { created: 1, updated: 1 } }, + }), + promptAsync: async () => ({ data: true }), + }, + }); + yield* harness.startTurn(); + const received = yield* harness.runtime.events.pipe( + Stream.takeUntil( + (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction", + ), + Stream.runCollect, + Effect.forkScoped, + ); + const states = [ + { status: "pending", input: { command: "pwd" }, raw: "" }, + { + status: "running", + input: { command: "pwd" }, + title: "Working directory", + time: { start: 1 }, + }, + { + status: "completed", + input: { command: "pwd" }, + output: "/repo\n", + title: "Working directory", + metadata: {}, + time: { start: 1, end: 2 }, + }, + { + status: "error", + input: { command: "pwd" }, + error: "Command failed", + time: { start: 3, end: 4 }, + }, + ] satisfies ReadonlyArray; + for (const state of states) { + yield* Effect.promise(() => + nativeEvents.push({ + type: "message.part.updated", + properties: { + sessionID: nativeSessionId, + part: { + id: state.status === "error" ? "part-failed" : "part-working", + sessionID: nativeSessionId, + messageID: "late-assistant", + type: "tool", + callID: state.status === "error" ? "call-failed" : "call-working", + tool: "bash", + state, + }, + }, + }), + ); + } + yield* Effect.promise(() => + nativeEvents.push({ + type: "message.part.updated", + properties: { + sessionID: nativeSessionId, + part: { + id: "part-read", + sessionID: nativeSessionId, + messageID: "late-assistant", + type: "tool", + callID: "call-read", + tool: "read", + state: { status: "pending", input: { filePath: "/outside/file" }, raw: "" }, + }, + }, + }), + ); + yield* Effect.promise(() => + nativeEvents.push({ + type: "permission.asked", + properties: { + id: "read-permission", + sessionID: nativeSessionId, + permission: "external_directory", + patterns: ["/outside/*"], + always: [], + metadata: {}, + tool: { messageID: "late-assistant", callID: "call-read" }, + }, + }), + ); + yield* Effect.promise(() => + nativeEvents.push({ + type: "message.part.updated", + properties: { + sessionID: nativeSessionId, + part: { + id: "part-text", + sessionID: nativeSessionId, + messageID: "late-assistant", + type: "text", + text: "Tool results received", + time: { start: 4 }, + }, + }, + }), + ); + yield* Effect.promise(() => + nativeEvents.push({ + type: "message.updated", + properties: { + sessionID: nativeSessionId, + info: { + id: "late-assistant", + sessionID: nativeSessionId, + role: "assistant", + time: { created: 1, completed: 4 }, + }, + }, + }), + ); + yield* Effect.promise(() => + nativeEvents.push({ + type: "session.compacted", + properties: { sessionID: nativeSessionId }, + }), + ); + const events = yield* Fiber.join(received); + const items = events.flatMap((event) => + event.type === "turn_item.updated" ? [event.turnItem] : [], + ); + const commands = items.filter((item) => item.type === "command_execution"); + assert.deepEqual( + commands.map((item) => item.status), + ["pending", "running", "completed", "failed"], + ); + assert.deepEqual( + commands.map((item) => item.input), + ["pwd", "pwd", "pwd", "pwd"], + ); + assert.equal(commands[2]?.output, "/repo\n"); + assert.equal(commands[3]?.output, "Command failed"); + assert.isTrue( + events.some( + (event) => + event.type === "runtime_request.updated" && event.runtimeRequest.kind === "file-read", + ), + ); + const assistant = items.filter((item) => item.type === "assistant_message"); + assert.equal(assistant.at(-1)?.text, "Tool results received"); + assert.equal(assistant.at(-1)?.status, "completed"); + }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), + ); + + // Event order from a live OpenCode 1.18.32 run of a `task` call with + // background=true: the task part completes at launch, the root session + // settles, and the child session stays busy until its own work ends. + it.effect("reports a background task child as pending work after the root turn settles", () => + Effect.gen(function* () { + const nativeEvents = asyncEventStream(); + const push = (event: unknown) => Effect.promise(() => nativeEvents.push(event)); + const root = "ses_root"; + const child = "ses_child"; + let promptId = ""; + const harness = yield* makeOpenCodeRuntimeHarness("background-child", root, { + event: { subscribe: async () => ({ stream: nativeEvents.stream }) }, + session: { + create: async () => ({ data: { id: root, time: { created: 1, updated: 1 } } }), + get: async () => ({ + data: { id: child, parentID: root, permission: [], time: { created: 2, updated: 2 } }, + }), + update: async () => ({ data: { id: child, parentID: root } }), + promptAsync: async (input: { messageID: string }) => { + promptId = input.messageID; + return { data: true }; + }, + abort: async () => ({ data: true }), + children: async () => ({ data: [] }), + }, + }); + const hasPendingBackgroundWork = harness.runtime.hasPendingBackgroundWork; + if (hasPendingBackgroundWork === undefined) { + return yield* Effect.die("OpenCode runtime must expose hasPendingBackgroundWork."); + } + yield* harness.startTurn(); + const terminal = yield* harness.runtime.events.pipe( + Stream.filter((event) => event.type === "turn.terminal"), + Stream.runHead, + Effect.forkScoped, + ); + const taskPart = (status: "running" | "completed") => ({ + type: "message.part.updated", + properties: { + sessionID: root, + part: { + id: "prt_task", + sessionID: root, + messageID: "msg_root_assistant", + type: "tool", + tool: "task", + callID: "call_task", + state: { + status, + input: { + description: "Background sleep task", + prompt: "sleep", + subagent_type: "general", + }, + title: "Background sleep task", + metadata: { parentSessionId: root, sessionId: child, background: true }, + time: { start: 3, ...(status === "completed" ? { end: 3 } : {}) }, + ...(status === "completed" ? { output: "Background task started" } : {}), + }, + }, + }, + }); + const status = (sessionID: string, type: "busy" | "idle") => ({ + type: "session.status", + properties: { sessionID, status: { type } }, + }); + + yield* push({ + type: "message.updated", + properties: { + sessionID: root, + info: { id: promptId, sessionID: root, role: "user", time: { created: 1 } }, + }, + }); + yield* push(status(root, "busy")); + yield* push(taskPart("running")); + yield* push({ + type: "session.created", + properties: { + sessionID: child, + info: { id: child, parentID: root, time: { created: 2, updated: 2 } }, + }, + }); + yield* push(status(child, "busy")); + yield* push(taskPart("completed")); + yield* push(status(root, "idle")); + assert.equal(Option.getOrUndefined(yield* Fiber.join(terminal))?.status, "completed"); + assert.isTrue(yield* hasPendingBackgroundWork, "the running child must pin idle release"); + + yield* push(status(child, "idle")); + yield* push({ type: "session.idle", properties: { sessionID: child } }); + assert.isFalse(yield* hasPendingBackgroundWork, "an idle child must not pin idle release"); + }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), + ); + + it.effect("titles OpenCode reads and searches from their input", () => + Effect.gen(function* () { + const nativeEvents = asyncEventStream(); + const nativeSessionId = "native-opencode-search"; + const harness = yield* makeOpenCodeRuntimeHarness("search-projection", nativeSessionId, { + event: { + subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { + options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); + return { stream: nativeEvents.stream }; + }, + }, + session: { + create: async () => ({ + data: { id: nativeSessionId, time: { created: 1, updated: 1 } }, + }), + promptAsync: async () => ({ data: true }), + }, + }); + yield* harness.startTurn(); + const received = yield* harness.runtime.events.pipe( + Stream.takeUntil( + (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction", + ), + Stream.runCollect, + Effect.forkScoped, + ); + for (const [tool, input, output] of [ + ["read", { filePath: "src/env.ts" }, "---\nfile body"], + ["grep", { pattern: "TODO", path: "apps/web" }, "---\nfile body"], + ["websearch", { query: "OpenCode documentation" }, "---\nfile body"], + ["glob", { pattern: "missing", path: "apps/web" }, ""], + ["codesearch", {}, " \n\t"], + ] as const) { + yield* Effect.promise(() => + nativeEvents.push({ + type: "message.part.updated", + properties: { + sessionID: nativeSessionId, + part: { + id: `part-${tool}`, + sessionID: nativeSessionId, + messageID: "assistant-search", + type: "tool", + callID: `call-${tool}`, + tool, + state: { + status: "completed", + input, + output, + title: tool, + metadata: {}, + time: { start: 1, end: 2 }, + }, + }, + }, + }), + ); + } + yield* Effect.promise(() => + nativeEvents.push({ + type: "session.compacted", + properties: { sessionID: nativeSessionId }, + }), + ); + const items = (yield* Fiber.join(received)).flatMap((event) => + event.type === "turn_item.updated" ? [event.turnItem] : [], + ); + const read = items.find((item) => item.type === "dynamic_tool"); + assert.equal(read?.title, "Read src/env.ts"); + const grep = items.find((item) => item.type === "file_search"); + assert.equal(grep?.title, "Searched TODO in web"); + assert.equal(grep?.type === "file_search" ? grep.pattern : null, "TODO"); + assert.deepEqual(grep?.type === "file_search" ? grep.results : null, [ + { fileName: "apps/web", preview: "---\nfile body" }, + ]); + const webSearch = items.find((item) => item.type === "web_search"); + assert.deepEqual(webSearch?.type === "web_search" ? webSearch.patterns : null, [ + "OpenCode documentation", + ]); + assert.deepEqual(webSearch?.type === "web_search" ? webSearch.results : null, [ + { snippet: "---\nfile body" }, + ]); + const emptyFileSearch = items.find( + (item) => item.type === "file_search" && item.pattern === "missing", + ); + assert.ok(emptyFileSearch?.type === "file_search"); + assert.equal(emptyFileSearch.results, undefined); + const emptyWebSearch = items.find( + (item) => item.type === "web_search" && item.patterns === undefined, + ); + assert.ok(emptyWebSearch?.type === "web_search"); + assert.equal(emptyWebSearch.results, undefined); + }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), + ); + + it.effect("presents OpenCode MCP calls without treating remote tools as local edits", () => + Effect.gen(function* () { + const nativeEvents = asyncEventStream(); + const nativeSessionId = "native-opencode-mcp"; + let statusReads = 0; + const harness = yield* makeOpenCodeRuntimeHarness("mcp-presentation", nativeSessionId, { + event: { + subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { + options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); + return { stream: nativeEvents.stream }; + }, + }, + session: { + create: async () => ({ data: { id: nativeSessionId, time: { created: 1, updated: 1 } } }), + promptAsync: async () => ({ data: true }), + }, + mcp: { + status: async () => { + statusReads++; + return { + data: { + "my.server_with_underscores": { status: "connected" }, + ambiguous: { status: "connected" }, + ambiguous_server: { status: "connected" }, + code: { status: "connected" }, + apply: { status: "connected" }, + }, + }; + }, + }, + }); + yield* harness.startTurn(); + const received = yield* harness.runtime.events.pipe( + Stream.takeUntil( + (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction", + ), + Stream.runCollect, + Effect.forkScoped, + ); + for (const status of ["running", "completed", "error"] as const) { + yield* Effect.promise(() => + nativeEvents.push({ + type: "message.part.updated", + properties: { + sessionID: nativeSessionId, + part: { + id: "mcp-part", + sessionID: nativeSessionId, + messageID: "mcp-assistant", + type: "tool", + callID: "mcp-call", + tool: "my_server_with_underscores_edit_document", + state: { + status, + input: { document: "remote-doc" }, + title: "Edit remote document", + metadata: {}, + output: "saved", + error: "failed", + time: { start: 1, end: 2 }, + }, + }, + }, + }), + ); + } + yield* Effect.promise(() => + nativeEvents.push({ + type: "message.part.updated", + properties: { + sessionID: nativeSessionId, + part: { + id: "ambiguous-part", + sessionID: nativeSessionId, + messageID: "mcp-assistant", + type: "tool", + callID: "ambiguous-call", + tool: "ambiguous_server_edit_document", + state: { + status: "completed", + input: {}, + title: "Ambiguous tool", + metadata: {}, + output: "", + time: { start: 1, end: 2 }, + }, + }, + }, + }), + ); + for (const tool of ["code_search", "apply_patch"]) { + yield* Effect.promise(() => + nativeEvents.push({ + type: "message.part.updated", + properties: { + sessionID: nativeSessionId, + part: { + id: `native-${tool}`, + sessionID: nativeSessionId, + messageID: "mcp-assistant", + type: "tool", + callID: `native-${tool}`, + tool, + state: { + status: "completed", + input: { query: "needle", filePath: "local.ts" }, + title: `Native ${tool}`, + metadata: {}, + output: "done", + time: { start: 1, end: 2 }, + }, + }, + }, + }), + ); + } + yield* Effect.promise(() => + nativeEvents.push({ + type: "session.compacted", + properties: { sessionID: nativeSessionId }, + }), + ); + const allItems = (yield* Fiber.join(received)).flatMap((event) => + event.type === "turn_item.updated" ? [event.turnItem] : [], + ); + assert.equal( + allItems.find((item) => item.title === "Native code_search")?.type, + "web_search", + ); + assert.equal( + allItems.find((item) => item.title === "Native apply_patch")?.type, + "file_change", + ); + const items = allItems.filter((item) => item.type === "dynamic_tool"); + assert.deepEqual( + items.slice(0, 3).map((item) => item.status), + ["running", "completed", "failed"], + ); + assert.deepEqual( + items.slice(0, 3).map((item) => item.title), + ["Edit remote document", "Edit remote document", "edit document"], + ); + for (const item of items.slice(0, 3)) { + assert.deepEqual(item.toolSource, { + key: "mcp:my.server_with_underscores", + name: "my.server with underscores", + kind: "integration", + }); + assert.deepEqual(item.input, { document: "remote-doc" }); + } + assert.equal(items[3]?.toolName, "ambiguous_server_edit_document"); + assert.isUndefined(items[3]?.toolSource); + assert.equal(statusReads, 1); + }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), + ); + + it.effect.each(["failure", "timeout"] as const)("retries MCP status after %s", (failure) => + Effect.gen(function* () { + const nativeEvents = asyncEventStream(); + const called = promiseGate(); + const sessionId = `mcp-status-${failure}`; + let statusReads = 0; + let signal: AbortSignal | undefined; + const harness = yield* makeOpenCodeRuntimeHarness(sessionId, sessionId, { + event: { subscribe: async () => ({ stream: nativeEvents.stream }) }, + session: { + create: async () => ({ data: { id: sessionId, time: { created: 1, updated: 1 } } }), + promptAsync: async () => ({ data: true }), + }, + mcp: { + status: async (_input: unknown, options: { signal?: AbortSignal }) => { + statusReads++; + if (statusReads === 1) { + signal = options?.signal; + called.resolve(); + if (failure === "timeout") return new Promise(() => {}); + throw new Error("MCP status unavailable"); + } + return { data: { weather: { status: "connected" } } }; + }, + }, + }); + yield* harness.startTurn(); + const received = yield* harness.runtime.events.pipe( + Stream.takeUntil( + (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction", + ), + Stream.runCollect, + Effect.forkScoped, + ); + for (const status of ["running", "completed"] as const) { + const emitted = yield* Effect.promise(() => + nativeEvents.push({ + type: "message.part.updated", + properties: { + sessionID: sessionId, + part: { + id: "weather-part", + sessionID: sessionId, + messageID: "weather-message", + type: "tool", + callID: "weather-call", + tool: "weather_edit_document", + state: { + status, + input: { document: "remote-doc" }, + title: "Edit remote document", + metadata: {}, + output: "saved", + time: { start: 1, end: 2 }, + }, + }, + }, + }), + ).pipe(Effect.forkScoped); + if (status === "running" && failure === "timeout") { + yield* Effect.promise(() => called.promise); + yield* TestClock.adjust("1 second"); + } + yield* Fiber.join(emitted); + } + yield* Effect.promise(() => + nativeEvents.push({ type: "session.compacted", properties: { sessionID: sessionId } }), + ); + const completed = (yield* Fiber.join(received)).find( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "dynamic_tool" && + event.turnItem.status === "completed", + ); + assert.equal(statusReads, 2); + assert.equal( + completed?.type === "turn_item.updated" && completed.turnItem.title, + "Edit remote document", + ); + assert.deepEqual(completed?.type === "turn_item.updated" && completed.turnItem.toolSource, { + key: "mcp:weather", + name: "weather", + kind: "integration", + }); + if (failure === "timeout") assert.isTrue(signal?.aborted); + }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), + ); + + it.effect("admits a native command on its user receipt before generation completes", () => + Effect.gen(function* () { + const nativeEvents = asyncEventStream(); + const release = promiseGate(); + const calls = yield* Queue.unbounded<{ + messageID: string; + command: string; + arguments: string; + model: string; + }>(); + let promptCalls = 0; + const sessionId = "native-command"; + const harness = yield* makeOpenCodeRuntimeHarness("native-command", sessionId, { + event: { + subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { + options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); + return { stream: nativeEvents.stream }; + }, + }, + command: { list: async () => ({ data: [{ name: "review" }] }) }, + session: { + create: async () => ({ data: { id: sessionId, time: { created: 1, updated: 1 } } }), + command: async (input: { + messageID: string; + command: string; + arguments: string; + model: string; + }) => { + Queue.offerUnsafe(calls, input); + await release.promise; + return { data: true }; + }, + promptAsync: async () => { + promptCalls++; + return { data: true }; + }, + }, + }); + const start = yield* harness.startTurn("/review staged changes").pipe(Effect.forkScoped); + const call = yield* Queue.take(calls); + assert.equal(call.command, "review"); + assert.equal(call.arguments, "staged changes"); + assert.equal(call.model, "anthropic/claude-sonnet"); + yield* Effect.promise(() => + nativeEvents.push({ + type: "message.updated", + properties: { + sessionID: sessionId, + info: { + id: call.messageID, + sessionID: sessionId, + role: "user", + time: { created: DateTime.toEpochMillis(harness.now) }, + }, + }, + }), + ); + yield* Fiber.join(start); + assert.equal(promptCalls, 0); + release.resolve(); + }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), + ); + + it.effect("sends unadvertised slash commands as ordinary prompts", () => + Effect.gen(function* () { + const nativeEvents = asyncEventStream(); + const prompts: unknown[] = []; + const harness = yield* makeOpenCodeRuntimeHarness("unknown-command", "unknown-command", { + event: { + subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { + options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); + return { stream: nativeEvents.stream }; + }, + }, + command: { list: async () => ({ data: [{ name: "review" }] }) }, + session: { + create: async () => ({ + data: { id: "unknown-command", time: { created: 1, updated: 1 } }, + }), + promptAsync: async (input: { parts: unknown }) => { + prompts.push(input.parts); + return { data: true }; + }, + }, + }); + yield* harness.startTurn("/unknown words"); + assert.deepEqual(prompts, [[{ type: "text", text: "/unknown words" }]]); + }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), + ); + + it.effect("compacts with the native summarize API and emits a completed compaction", () => + Effect.gen(function* () { + const nativeEvents = asyncEventStream(); + const summarizeCalls: Array = []; + let promptCalls = 0; + const harness = yield* makeOpenCodeRuntimeHarness("compact", "native-opencode-compact", { + event: { + subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { + options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); + return { stream: nativeEvents.stream }; + }, + }, + session: { + create: async () => ({ + data: { id: "native-opencode-compact", time: { created: 1, updated: 1 } }, + }), + summarize: async (input: unknown) => { + summarizeCalls.push(input); + return { data: true }; + }, + promptAsync: async () => { + promptCalls += 1; + return { data: true }; + }, + }, + }); + yield* harness.startTurn("/compact"); + const events = yield* harness.runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + ); + assert.deepEqual(summarizeCalls, [ + { + sessionID: "native-opencode-compact", + providerID: "anthropic", + modelID: "claude-sonnet", + auto: false, + }, + ]); + assert.equal(promptCalls, 0); + assert.equal( + events.filter( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "compaction" && + event.turnItem.status === "completed", + ).length, + 1, + ); + assert.isTrue( + events.some((event) => event.type === "turn.terminal" && event.status === "completed"), + ); + }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), + ); + + it.effect("ends a turn on the provider thread it started on", () => + Effect.gen(function* () { + const nativeEvents = asyncEventStream(); + const harness = yield* makeOpenCodeRuntimeHarness( + "forked-row", + "native-opencode-forked-row", + { + event: { + subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { + options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); + return { stream: nativeEvents.stream }; + }, + }, + session: { + create: async () => ({ + data: { id: "native-opencode-forked-row", time: { created: 1, updated: 1 } }, + }), + summarize: async () => ({ data: true }), + }, + }, + ); + // A forked run starts on its own row for the same native session, while + // the adapter tracks the session under the id it minted. + const forkedRow = { + ...harness.providerThread, + id: ProviderThreadId.make("provider-thread:opencode-test:forked-run-row"), + }; + yield* harness.startTurn("/compact", forkedRow); + const terminal = yield* harness.runtime.events.pipe( + Stream.filter( + (event): event is Extract => + event.type === "turn.terminal", + ), + Stream.runHead, + ); + assert.equal(Option.getOrUndefined(terminal)?.providerThreadId, forkedRow.id); + }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), + ); + + it.effect("does not restore messages beyond OpenCode's persisted revert boundary", () => + Effect.gen(function* () { + const nativeEvents = asyncEventStream(); + const nativeSessionId = "native-opencode-reverted-snapshot"; + const messages = [ + { + info: { + id: "user-kept", + sessionID: nativeSessionId, + role: "user", + time: { created: 1 }, + }, + parts: [{ type: "text", text: "keep this prompt" }], + }, + { + info: { + id: "assistant-kept", + sessionID: nativeSessionId, + role: "assistant", + time: { created: 2, completed: 3 }, + }, + parts: [{ type: "text", text: "keep this answer" }], + }, + { + info: { + id: "user-reverted", + sessionID: nativeSessionId, + role: "user", + time: { created: 4 }, + }, + parts: [{ type: "text", text: "remove this prompt" }], + }, + { + info: { + id: "assistant-reverted", + sessionID: nativeSessionId, + role: "assistant", + time: { created: 5, completed: 6 }, + }, + parts: [{ type: "text", text: "remove this answer" }], + }, + ]; + const harness = yield* makeOpenCodeRuntimeHarness("reverted-snapshot", nativeSessionId, { + event: { + subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { + options.signal?.addEventListener("abort", () => nativeEvents.close(), { + once: true, + }); + return { stream: nativeEvents.stream }; + }, + }, + session: { + create: async () => ({ + data: { id: nativeSessionId, time: { created: 1, updated: 1 } }, + }), + get: async () => ({ + data: { + id: nativeSessionId, + time: { created: 1, updated: 6 }, + revert: { messageID: "user-reverted" }, + }, + }), + messages: async () => ({ data: messages }), + }, + }); + + const snapshot = yield* harness.runtime.readThreadSnapshot({ + providerThread: harness.providerThread, + }); + + assert.deepEqual( + snapshot.messages.map((message) => message.text), + ["keep this prompt", "keep this answer"], + ); + const providerPayload = snapshot.providerPayload as ReadonlyArray<{ + readonly info: { readonly id: string }; + }>; + assert.deepEqual( + providerPayload.map((entry) => entry.info.id), + ["user-kept", "assistant-kept"], + ); + assert.equal(snapshot.providerThread.nativeConversationHeadRef?.nativeId, "user-kept"); + }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), + ); + + it.effect("keeps a newly admitted prompt alive across stale idle and delayed busy evidence", () => + Effect.gen(function* () { + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const nativeEvents = asyncEventStream(); + const prompt = promiseGate(); + const promptStarted = promiseGate(); + const steerPrompt = promiseGate(); + const steerPromptStarted = promiseGate(); + const stopPromptStarted = promiseGate(); + const statusCalled = promiseGate(); + const steerStatusCalled = promiseGate(); + const abortCalled = promiseGate(); + let status: "idle" | "busy" = "busy"; + let promptCalls = 0; + let statusCalls = 0; + const promptInputs: Array<{ readonly messageID?: string }> = []; + const client = { + event: { + subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { + options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); + return { stream: nativeEvents.stream }; + }, + }, + session: { + create: async () => ({ + data: { id: "native-opencode-race", time: { created: 1, updated: 1 } }, + }), + get: async () => ({ + data: { id: "native-opencode-race", time: { created: 1, updated: 1 } }, + }), + promptAsync: async ( + input: { readonly messageID?: string }, + options?: { readonly signal?: AbortSignal }, + ) => { + promptInputs.push(input); + promptCalls += 1; + if (promptCalls === 1) { + promptStarted.resolve(); + await prompt.promise; + } else if (promptCalls === 2) { + steerPromptStarted.resolve(); + await steerPrompt.promise; + } else { + stopPromptStarted.resolve(); + await new Promise((_resolve, reject) => + options?.signal?.addEventListener("abort", () => reject(options.signal!.reason), { + once: true, + }), + ); + } + return { data: true }; + }, + status: async () => ({ + data: (() => { + statusCalls += 1; + if (statusCalls === 1) statusCalled.resolve(); + else steerStatusCalled.resolve(); + return { "native-opencode-race": { type: status } }; + })(), + }), + messages: async () => ({ data: [] }), + children: async () => ({ data: [] }), + abort: async () => { + abortCalled.resolve(); + return { data: true }; + }, + }, + mcp: { add: async () => ({ data: true }) }, + }; + const adapter = makeOpenCodeAdapterV2({ + instanceId: ProviderInstanceId.make("opencode-test"), + settings: OPEN_CODE_TEST_SETTINGS, + environment: {}, + runtime: { + connectToOpenCodeServer: () => + Effect.succeed({ url: "http://test.invalid", external: true }), + createOpenCodeSdkClient: () => client, + } as unknown as OpenCodeRuntime.OpenCodeRuntimeShape, + idAllocator, + host: { + paths: { cwd: "/workspace" }, + resolveAttachmentPath: () => null, + } as unknown as ProviderHost.ProviderHostShape, + }); + const threadId = ThreadId.make("thread-opencode-admission-race"); + const providerSessionId = ProviderSessionId.make("session-opencode-admission-race"); + const modelSelection = { + instanceId: ProviderInstanceId.make("opencode-test"), + model: "anthropic/claude-sonnet", + options: [], + }; + const policy = runtimePolicy("full-access", { cwd: "/workspace" }); + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId, + modelSelection, + runtimePolicy: policy, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy: policy, + }); + const now = yield* DateTime.now; + const attemptId = RunAttemptId.make("attempt-opencode-admission-race"); + const runId = RunId.make("run-opencode-admission-race"); + const start = yield* runtime + .startTurn({ + appThread: { + id: threadId, + projectId: ProjectId.make("project-opencode-admission-race"), + title: "race", + providerInstanceId: modelSelection.instanceId, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: providerThread.id, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, + forkedFrom: null, + createdBy: "user", + creationSource: "web", + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + }, + threadId, + runId, + runOrdinal: 1, + providerTurnOrdinal: 1, + attemptId, + rootNodeId: NodeId.make("node-opencode-admission-race"), + providerThread, + message: { + createdBy: "user", + creationSource: "web", + messageId: MessageId.make("message-opencode-admission-race"), + text: "hello", + attachments: [], + }, + modelSelection, + runtimePolicy: policy, + }) + .pipe(Effect.forkScoped); + + yield* Effect.promise(() => promptStarted.promise); + yield* Effect.promise(() => + nativeEvents.push({ + type: "session.status", + properties: { sessionID: "native-opencode-race", status: { type: "idle" } }, + }), + ); + prompt.resolve(); + yield* Fiber.join(start); + assert.match(promptInputs[0]?.messageID ?? "", /^msg_[0-9a-f]{12}[0-9A-Za-z]{28}$/); + yield* Effect.promise(() => + nativeEvents.push({ + type: "session.status", + properties: { sessionID: "native-opencode-race", status: { type: "busy" } }, + }), + ); + status = "busy"; + yield* Effect.promise(() => + nativeEvents.push({ + type: "message.updated", + properties: { + sessionID: "native-opencode-race", + info: { + id: "stale-user-message", + sessionID: "native-opencode-race", + role: "user", + time: { created: DateTime.toEpochMillis(now) }, + }, + }, + }), + ); + assert.equal(statusCalls, 0, "an unrelated user message must not release prompt admission"); + yield* Effect.promise(() => + nativeEvents.push({ + type: "session.status", + properties: { sessionID: "native-opencode-race", status: { type: "idle" } }, + }), + ); + yield* Effect.promise(() => + nativeEvents.push({ + type: "message.updated", + properties: { + sessionID: "native-opencode-race", + info: { + id: promptInputs[0]!.messageID!, + sessionID: "native-opencode-race", + role: "user", + time: { created: DateTime.toEpochMillis(now) }, + }, + }, + }), + ); + + yield* Effect.promise(() => statusCalled.promise); + + const snapshot = yield* runtime.readThreadSnapshot({ providerThread }); + assert.equal(snapshot.providerTurns.at(-1)?.status, "running"); + const activeTurn = snapshot.providerTurns.at(-1)!; + const steer = yield* runtime + .steerTurn({ + threadId, + runId, + providerThread, + providerTurnId: activeTurn.id, + message: { + messageId: MessageId.make("message-opencode-steer-race"), + text: "follow up", + attachments: [], + createdBy: "user", + creationSource: "web", + }, + }) + .pipe(Effect.forkScoped); + yield* Effect.promise(() => steerPromptStarted.promise); + assert.notEqual(promptInputs[1]?.messageID, promptInputs[0]?.messageID); + steerPrompt.resolve(); + yield* Fiber.join(steer); + yield* Effect.promise(() => + nativeEvents.push({ + type: "session.status", + properties: { sessionID: "native-opencode-race", status: { type: "busy" } }, + }), + ); + yield* Effect.promise(() => + nativeEvents.push({ + type: "message.updated", + properties: { + sessionID: "native-opencode-race", + info: { + id: promptInputs[0]!.messageID!, + sessionID: "native-opencode-race", + role: "user", + time: { created: DateTime.toEpochMillis(now) }, + }, + }, + }), + ); + yield* Effect.promise(() => + nativeEvents.push({ + type: "session.status", + properties: { sessionID: "native-opencode-race", status: { type: "idle" } }, + }), + ); + yield* Effect.promise(() => + nativeEvents.push({ + type: "message.updated", + properties: { + sessionID: "native-opencode-race", + info: { + id: promptInputs[1]!.messageID!, + sessionID: "native-opencode-race", + role: "user", + time: { created: DateTime.toEpochMillis(now) }, + }, + }, + }), + ); + yield* Effect.promise(() => steerStatusCalled.promise); + const pendingSteer = yield* runtime + .steerTurn({ + threadId, + runId, + providerThread, + providerTurnId: activeTurn.id, + message: { + messageId: MessageId.make("message-opencode-stop-pending"), + text: "pending when stopped", + attachments: [], + createdBy: "user", + creationSource: "web", + }, + }) + .pipe(Effect.exit, Effect.forkScoped); + yield* Effect.promise(() => stopPromptStarted.promise); + const interrupt = yield* runtime + .interruptTurn({ providerThread, providerTurnId: activeTurn.id }) + .pipe(Effect.forkScoped); + yield* Effect.promise(() => abortCalled.promise); + yield* Fiber.join(interrupt); + assert.isTrue(Exit.isFailure(yield* Fiber.join(pendingSteer))); + const promptWhileStopping = yield* Effect.exit( + runtime.steerTurn({ + threadId, + runId, + providerThread, + providerTurnId: activeTurn.id, + message: { + messageId: MessageId.make("message-opencode-after-stop"), + text: "must not pass the pending stop", + attachments: [], + createdBy: "user", + creationSource: "web", + }, + }), + ); + assert.isTrue(Exit.isFailure(promptWhileStopping)); + assert.equal(promptCalls, 3); + yield* Effect.promise(() => + nativeEvents.push({ + type: "session.status", + properties: { sessionID: "native-opencode-race", status: { type: "idle" } }, + }), + ); + const interrupted = yield* runtime.readThreadSnapshot({ providerThread }); + assert.equal(interrupted.providerTurns.at(-1)?.status, "interrupted"); + }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), + ); + + it.effect("interrupts an initial prompt while its SDK request is pending", () => + Effect.gen(function* () { + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const nativeEvents = asyncEventStream(); + const promptStarted = promiseGate(); + const abortCalled = promiseGate(); + let abortCalls = 0; + const client = { + event: { + subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { + options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); + return { stream: nativeEvents.stream }; + }, + }, + session: { + create: async () => ({ + data: { id: "native-opencode-initial-stop", time: { created: 1, updated: 1 } }, + }), + get: async () => ({ + data: { id: "native-opencode-initial-stop", time: { created: 1, updated: 1 } }, + }), + promptAsync: async (_input: unknown, options?: { readonly signal?: AbortSignal }) => { + promptStarted.resolve(); + await new Promise((_resolve, reject) => { + const signal = options?.signal; + if (signal?.aborted) { + reject(signal.reason); + return; + } + signal?.addEventListener("abort", () => reject(signal.reason), { once: true }); + }); + return { data: true }; + }, + status: async () => ({ + data: { "native-opencode-initial-stop": { type: "idle" as const } }, + }), + messages: async () => ({ data: [] }), + children: async () => ({ data: [] }), + abort: async () => { + abortCalls += 1; + abortCalled.resolve(); + return { data: true }; + }, + }, + mcp: { add: async () => ({ data: true }) }, + }; + const adapter = makeOpenCodeAdapterV2({ + instanceId: ProviderInstanceId.make("opencode-initial-stop-test"), + settings: OPEN_CODE_TEST_SETTINGS, + environment: {}, + runtime: { + connectToOpenCodeServer: () => + Effect.succeed({ url: "http://test.invalid", external: true }), + createOpenCodeSdkClient: () => client, + } as unknown as OpenCodeRuntime.OpenCodeRuntimeShape, + idAllocator, + host: { + paths: { cwd: "/workspace" }, + resolveAttachmentPath: () => null, + } as unknown as ProviderHost.ProviderHostShape, + }); + const threadId = ThreadId.make("thread-opencode-initial-stop"); + const providerSessionId = ProviderSessionId.make("session-opencode-initial-stop"); + const modelSelection = { + instanceId: ProviderInstanceId.make("opencode-initial-stop-test"), + model: "anthropic/claude-sonnet", + options: [], + }; + const policy = runtimePolicy("full-access", { cwd: "/workspace" }); + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId, + modelSelection, + runtimePolicy: policy, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy: policy, + }); + const terminalEvents = yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + Effect.forkScoped, + ); + const now = yield* DateTime.now; + const start = yield* runtime + .startTurn({ + appThread: { + id: threadId, + projectId: ProjectId.make("project-opencode-initial-stop"), + title: "initial stop", + providerInstanceId: modelSelection.instanceId, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: providerThread.id, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, + forkedFrom: null, + createdBy: "user", + creationSource: "web", + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + }, + threadId, + runId: RunId.make("run-opencode-initial-stop"), + runOrdinal: 1, + providerTurnOrdinal: 1, + attemptId: RunAttemptId.make("attempt-opencode-initial-stop"), + rootNodeId: NodeId.make("node-opencode-initial-stop"), + providerThread, + message: { + createdBy: "user", + creationSource: "web", + messageId: MessageId.make("message-opencode-initial-stop"), + text: "hello", + attachments: [], + }, + modelSelection, + runtimePolicy: policy, + }) + .pipe(Effect.forkScoped); + + yield* Effect.promise(() => promptStarted.promise); + const running = yield* runtime.readThreadSnapshot({ providerThread }); + const activeTurn = running.providerTurns.at(-1)!; + const interrupt = yield* runtime + .interruptTurn({ providerThread, providerTurnId: activeTurn.id }) + .pipe(Effect.forkScoped); + + yield* Effect.promise(() => abortCalled.promise); + yield* Fiber.join(start); + yield* Fiber.join(interrupt); + yield* Effect.promise(() => + nativeEvents.push({ + type: "session.status", + properties: { + sessionID: "native-opencode-initial-stop", + status: { type: "idle" }, + }, + }), + ); + + const events = Array.from(yield* Fiber.join(terminalEvents)); + const terminals = events.filter((event) => event.type === "turn.terminal"); + assert.equal(abortCalls, 1); + assert.lengthOf(terminals, 1); + assert.equal(terminals[0]?.status, "interrupted"); + assert.isNull(terminals[0]?.failure ?? null); + assert.isFalse( + events.some( + (event) => + (event.type === "turn.terminal" && event.status === "failed") || + (event.type === "provider_session.updated" && event.providerSession.status === "error"), + ), + ); + }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), + ); + + it.effect("fails an active turn when the OpenCode event stream ends cleanly", () => + Effect.gen(function* () { + const nativeEvents = asyncEventStream(); + const harness = yield* makeOpenCodeRuntimeHarness( + "clean-event-eof", + "native-opencode-clean-event-eof", + { + event: { + subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { + options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); + return { stream: nativeEvents.stream }; + }, + }, + session: { + create: async () => ({ + data: { id: "native-opencode-clean-event-eof", time: { created: 1, updated: 1 } }, + }), + promptAsync: async () => ({ data: true }), + }, + }, + ); + yield* harness.startTurn(); + const terminalEvents = yield* harness.runtime.events.pipe( + Stream.runCollect, + Effect.forkScoped, + ); + + nativeEvents.close(); + const received = Array.from(yield* Fiber.join(terminalEvents)); + assert.isTrue( + received.some( + (event) => + event.type === "provider_session.updated" && event.providerSession.status === "error", + ), + ); + const terminal = received.find((event) => event.type === "turn.terminal"); + assert.equal(terminal?.status, "failed"); + assert.equal(terminal?.failure?.class, "transport_error"); + assert.equal(terminal?.threadDisposition, "broken"); + assert.equal((yield* Effect.exit(harness.startTurn()))._tag, "Failure"); + }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), + ); + + it.effect("fails compaction when its response races stream termination", () => + Effect.gen(function* () { + const nativeEvents = asyncEventStream(); + const summarizeStarted = promiseGate(); + const summarizeResult = promiseGate<{ data: boolean }>(); + const baseClock = yield* Clock.Clock; + const eofClockRead = yield* Deferred.make(); + const releaseEofClockRead = yield* Deferred.make(); + let blockNextClockRead = false; + const blockingClock: Clock.Clock = { + ...baseClock, + currentTimeMillis: Effect.suspend(() => { + if (!blockNextClockRead) return baseClock.currentTimeMillis; + blockNextClockRead = false; + return Deferred.succeed(eofClockRead, undefined).pipe( + Effect.andThen(Deferred.await(releaseEofClockRead)), + Effect.andThen(baseClock.currentTimeMillis), + ); + }), + }; + const harness = yield* makeOpenCodeRuntimeHarness( + "compaction-eof-race", + "native-opencode-compaction-eof-race", + { + event: { subscribe: async () => ({ stream: nativeEvents.stream }) }, + session: { + create: async () => ({ + data: { id: "native-opencode-compaction-eof-race", time: { created: 1, updated: 1 } }, + }), + summarize: () => { + summarizeStarted.resolve(); + return summarizeResult.promise; + }, + }, + }, + ).pipe(Effect.provideService(Clock.Clock, blockingClock)); + const events = yield* harness.runtime.events.pipe(Stream.runCollect, Effect.forkScoped); + const start = yield* harness.startTurn("/compact").pipe(Effect.forkScoped); + yield* Effect.promise(() => summarizeStarted.promise); + blockNextClockRead = true; + nativeEvents.close(); + yield* Deferred.await(eofClockRead); + summarizeResult.resolve({ data: true }); + yield* Fiber.join(start); + yield* Deferred.succeed(releaseEofClockRead, undefined); + const received = Array.from(yield* Fiber.join(events)); + const terminals = received.filter((event) => event.type === "turn.terminal"); + assert.lengthOf(terminals, 1); + assert.equal(terminals[0]?.status, "failed"); + assert.equal(terminals[0]?.failure?.class, "transport_error"); + assert.isFalse( + received.some( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "compaction" && + event.turnItem.status === "completed", + ), + ); + }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), + ); + + it.effect("does not register a turn after the OpenCode event stream ends", () => + Effect.gen(function* () { + const nativeEvents = asyncEventStream(); + let promptCalls = 0; + const harness = yield* makeOpenCodeRuntimeHarness( + "event-eof-start-race", + "native-opencode-event-eof-start-race", + { + event: { + subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { + options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); + return { stream: nativeEvents.stream }; + }, + }, + session: { + create: async () => ({ + data: { + id: "native-opencode-event-eof-start-race", + time: { created: 1, updated: 1 }, + }, + }), + promptAsync: async () => { + promptCalls += 1; + return { data: true }; + }, + }, + }, + ); + const baseClock = yield* Clock.Clock; + const startClockRead = yield* Deferred.make(); + const releaseStartClockRead = yield* Deferred.make(); + let blockNextClockRead = true; + const blockingClock: Clock.Clock = { + ...baseClock, + currentTimeMillis: Effect.suspend(() => { + if (!blockNextClockRead) return baseClock.currentTimeMillis; + blockNextClockRead = false; + return Deferred.succeed(startClockRead, undefined).pipe( + Effect.andThen(Deferred.await(releaseStartClockRead)), + Effect.andThen(baseClock.currentTimeMillis), + ); + }), + }; + const start = yield* harness + .startTurn() + .pipe(Effect.provideService(Clock.Clock, blockingClock), Effect.exit, Effect.forkScoped); + yield* Deferred.await(startClockRead); + + const events = yield* harness.runtime.events.pipe(Stream.runCollect, Effect.forkScoped); + nativeEvents.close(); + yield* Fiber.join(events); + yield* Deferred.succeed(releaseStartClockRead, undefined); + + assert.isTrue(Exit.isFailure(yield* Fiber.join(start))); + assert.equal(promptCalls, 0); + }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), + ); + + it("holds stale idle through prompt admission until the new user message is observed", () => { + const admission = { + admissionPending: true, + admissionAccepted: false, + admissionMessageObserved: false, + idleDuringAdmission: false, + }; + + assert.equal(advanceOpenCodePromptAdmission(admission, "idle"), "hold"); + assert.equal(advanceOpenCodePromptAdmission(admission, "accepted"), "hold"); + assert.isTrue(admission.admissionPending); + assert.equal(advanceOpenCodePromptAdmission(admission, "user-message"), "reconcile-idle"); + assert.isTrue(admission.admissionPending); + }); + + it("releases admission only after prompt acceptance and new-turn evidence", () => { + const admission = { + admissionPending: true, + admissionAccepted: false, + admissionMessageObserved: false, + idleDuringAdmission: false, + }; + + assert.equal(advanceOpenCodePromptAdmission(admission, "busy"), "hold"); + assert.equal(advanceOpenCodePromptAdmission(admission, "accepted"), "release"); + assert.isFalse(admission.admissionPending); + }); + + it("releases admission when an assistant completes under a provider-assigned message id", () => { + const admission = { + admissionPending: true, + admissionAccepted: false, + admissionMessageObserved: false, + idleDuringAdmission: true, + }; + + assert.equal(advanceOpenCodePromptAdmission(admission, "assistant-completed"), "release"); + assert.isTrue(admission.admissionAccepted); + assert.isTrue(admission.admissionMessageObserved); + assert.isFalse(admission.admissionPending); + }); + + it("invalidates pending admission before aborting a turn", () => { + const admission = { + admissionGeneration: 4, + admissionPending: true, + admissionAccepted: false, + admissionMessageObserved: false, + idleDuringAdmission: false, + }; + + cancelOpenCodePromptAdmission(admission, 5); + + assert.equal(admission.admissionGeneration, 5); + assert.isFalse(admission.admissionPending); + assert.equal(advanceOpenCodePromptAdmission(admission, "accepted"), "release"); + }); + + it.effect("reconciles current idle and busy status replies after prompt admission", () => + Effect.gen(function* () { + for (const expected of ["idle", "busy"] as const) { + const admission = { admissionGeneration: 4, admissionPending: true }; + const status = yield* Deferred.make<"idle" | "busy" | "unknown">(); + const fiber = yield* reconcileOpenCodePromptAdmissionStatus( + admission, + 4, + Deferred.await(status), + ).pipe(Effect.forkChild({ startImmediately: true })); + + yield* Deferred.succeed(status, expected); + + assert.equal(yield* Fiber.join(fiber), expected); + assert.isFalse(admission.admissionPending); + } + }), + ); + + it.effect("keeps admission pending after a transient status failure", () => + Effect.gen(function* () { + const admission = { admissionGeneration: 4, admissionPending: true }; + assert.equal( + yield* reconcileOpenCodePromptAdmissionStatus(admission, 4, Effect.succeed("unknown")), + "unknown", + ); + assert.isTrue(admission.admissionPending); + assert.equal( + yield* reconcileOpenCodePromptAdmissionStatus(admission, 4, Effect.succeed("idle")), + "idle", + ); + assert.isFalse(admission.admissionPending); + }), + ); + + it.effect("retries a transient status failure without another idle event", () => + Effect.gen(function* () { + const nativeSessionId = "native-opencode-status-retry"; + const nativeEvents = asyncEventStream(); + const promptRelease = promiseGate(); + const promptCalls = yield* Queue.unbounded(); + const statusCalls = yield* Queue.unbounded(); + let statusCallCount = 0; + const client = { + event: { + subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { + options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); + return { stream: nativeEvents.stream }; + }, + }, + session: { + create: async () => ({ + data: { id: nativeSessionId, time: { created: 1, updated: 1 } }, + }), + get: async () => ({ + data: { id: nativeSessionId, time: { created: 1, updated: 1 } }, + }), + promptAsync: async (input: { readonly messageID?: string }) => { + Queue.offerUnsafe(promptCalls, input.messageID!); + await promptRelease.promise; + return { data: true }; + }, + status: async () => { + statusCallCount += 1; + Queue.offerUnsafe(statusCalls, statusCallCount); + if (statusCallCount === 1) throw new Error("transient status failure"); + return { data: { [nativeSessionId]: { type: "idle" as const } } }; + }, + messages: async () => ({ data: [] }), + children: async () => ({ data: [] }), + abort: async () => ({ data: true }), + }, + mcp: { add: async () => ({ data: true }) }, + }; + const harness = yield* makeOpenCodeRuntimeHarness("status-retry", nativeSessionId, client); + const terminalEvents = yield* harness.runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + Effect.forkScoped, + ); + const start = yield* harness.startTurn().pipe(Effect.forkScoped); + const admissionMessageId = yield* Queue.take(promptCalls); + + yield* Effect.promise(() => + nativeEvents.push({ + type: "session.status", + properties: { sessionID: nativeSessionId, status: { type: "idle" } }, + }), + ); + promptRelease.resolve(); + yield* Fiber.join(start); + const userMessage = { + type: "message.updated", + properties: { + sessionID: nativeSessionId, + info: { + id: admissionMessageId, + sessionID: nativeSessionId, + role: "user", + time: { created: DateTime.toEpochMillis(harness.now) }, + }, + }, + }; + yield* Effect.promise(() => nativeEvents.push(userMessage)); + assert.equal(yield* Queue.take(statusCalls), 1); + + yield* Effect.promise(() => nativeEvents.push(userMessage)); + assert.equal(statusCallCount, 1, "duplicate events must share the generation's retry worker"); + yield* TestClock.adjust("250 millis"); + assert.equal(yield* Queue.take(statusCalls), 2); + + const events = Array.from(yield* Fiber.join(terminalEvents)); + const terminals = events.filter((event) => event.type === "turn.terminal"); + assert.equal(statusCallCount, 2); + assert.lengthOf(terminals, 1); + assert.equal(terminals[0]?.status, "completed"); + assert.isNull(terminals[0]?.failure ?? null); + }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), + ); + + it.effect("does not let a queued status retry adopt a newer steer generation", () => + Effect.gen(function* () { + const nativeSessionId = "native-opencode-stale-status-retry"; + const nativeEvents = asyncEventStream(); + const firstPromptRelease = promiseGate(); + const promptCalls = yield* Queue.unbounded(); + const statusCalls = yield* Queue.unbounded(); + let promptCallCount = 0; + let statusCallCount = 0; + const client = { + event: { + subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { + options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); + return { stream: nativeEvents.stream }; + }, + }, + session: { + create: async () => ({ + data: { id: nativeSessionId, time: { created: 1, updated: 1 } }, + }), + get: async () => ({ + data: { id: nativeSessionId, time: { created: 1, updated: 1 } }, + }), + promptAsync: async (input: { readonly messageID?: string }) => { + promptCallCount += 1; + Queue.offerUnsafe(promptCalls, input.messageID!); + if (promptCallCount === 1) { + await firstPromptRelease.promise; + } + return { data: true }; + }, + status: async () => { + statusCallCount += 1; + Queue.offerUnsafe(statusCalls, statusCallCount); + if (statusCallCount === 1) throw new Error("transient status failure"); + return { data: { [nativeSessionId]: { type: "idle" as const } } }; + }, + messages: async () => ({ data: [] }), + children: async () => ({ data: [] }), + abort: async () => ({ data: true }), + }, + mcp: { add: async () => ({ data: true }) }, + }; + const harness = yield* makeOpenCodeRuntimeHarness( + "stale-status-retry", + nativeSessionId, + client, + ); + const start = yield* harness.startTurn().pipe(Effect.forkScoped); + const firstAdmissionMessageId = yield* Queue.take(promptCalls); + + yield* Effect.promise(() => + nativeEvents.push({ + type: "session.status", + properties: { sessionID: nativeSessionId, status: { type: "idle" } }, + }), + ); + firstPromptRelease.resolve(); + yield* Fiber.join(start); + yield* Effect.promise(() => + nativeEvents.push({ + type: "message.updated", + properties: { + sessionID: nativeSessionId, + info: { + id: firstAdmissionMessageId, + sessionID: nativeSessionId, + role: "user", + time: { created: DateTime.toEpochMillis(harness.now) }, + }, + }, + }), + ); + assert.equal(yield* Queue.take(statusCalls), 1); + + const running = yield* harness.runtime.readThreadSnapshot({ + providerThread: harness.providerThread, + }); + const activeTurn = running.providerTurns.at(-1)!; + yield* harness.runtime.steerTurn({ + threadId: harness.threadId, + runId: harness.runId, + providerThread: harness.providerThread, + providerTurnId: activeTurn.id, + message: { + messageId: MessageId.make("message-opencode-stale-status-retry-steer"), + text: "new generation", + attachments: [], + createdBy: "user", + creationSource: "web", + }, + }); + yield* Queue.take(promptCalls); + yield* TestClock.adjust("250 millis"); + + const afterRetry = yield* harness.runtime.readThreadSnapshot({ + providerThread: harness.providerThread, + }); + assert.equal(statusCallCount, 1); + assert.equal(afterRetry.providerTurns.at(-1)?.status, "running"); + }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), + ); + + it.effect("ignores a delayed status reply after steering starts a newer admission", () => + Effect.gen(function* () { + const admission = { admissionGeneration: 4, admissionPending: true }; + const status = yield* Deferred.make<"idle" | "busy" | "unknown">(); + const fiber = yield* reconcileOpenCodePromptAdmissionStatus( + admission, + 4, + Deferred.await(status), + ).pipe(Effect.forkChild({ startImmediately: true })); + + admission.admissionGeneration = 5; + admission.admissionPending = true; + yield* Deferred.succeed(status, "idle"); + + assert.equal(yield* Fiber.join(fiber), "stale"); + assert.equal(admission.admissionGeneration, 5); + assert.isTrue(admission.admissionPending); + }), + ); + + it.effect("does not revive admission when abort wins a pending status lookup", () => + Effect.gen(function* () { + const admission = { admissionGeneration: 4, admissionPending: true }; + const status = yield* Deferred.make<"idle" | "busy" | "unknown">(); + const fiber = yield* reconcileOpenCodePromptAdmissionStatus( + admission, + 4, + Deferred.await(status), + ).pipe(Effect.forkChild({ startImmediately: true })); + + cancelOpenCodePromptAdmission(admission, 5); + yield* Deferred.succeed(status, "idle"); + + assert.equal(yield* Fiber.join(fiber), "stale"); + assert.isFalse(admission.admissionPending); + }), + ); + + it.effect("logs bounded structural protocol diagnostics without native payload values", () => + Effect.gen(function* () { + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const records: Array = []; + const nativeEventLogger: ProviderEventLoggers.EventNdjsonLogger = { + filePath: "/tmp/provider-native.ndjson", + write: (event) => Effect.sync(() => void records.push(event)), + close: () => Effect.void, + }; + const logProtocolEvent = makeOpenCodeProtocolLogger({ + nativeEventLogger, + idAllocator, + providerInstanceId: ProviderInstanceId.make("opencode-test"), + providerSessionId: ProviderSessionId.make("provider-session-opencode-test"), + threadId: ThreadId.make("thread-opencode-test"), + }); + const secret = "secret-opencode-prompt"; + + yield* logProtocolEvent({ + direction: "outgoing", + messageKind: "request", + method: "session.prompt", + payload: { prompt: secret, nested: { token: secret } }, + }); + + const serialized = encodeUnknownJson(records); + assert.notInclude(serialized, secret); + assert.include(serialized, '"protocol":"opencode-sdk.sse"'); + assert.include(serialized, '"method":"session.prompt"'); + assert.include(serialized, '"fieldCount":2'); + }).pipe(Effect.provide(IdAllocator.layer)), + ); + + it.effect("adopts the handed-over provider thread identity on session create", () => + Effect.gen(function* () { + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const host = yield* ProviderHost.ProviderHost; + let createCount = 0; + const createInputs: Array = []; + const fakeClient = { + event: { + subscribe: async (_input?: unknown, options?: { readonly signal?: AbortSignal }) => ({ + // Emits nothing and ends when the pump's abort signal fires. + stream: { + [Symbol.asyncIterator]: () => ({ + next: () => + new Promise>((resolve) => { + const done = () => resolve({ done: true, value: undefined }); + if (options?.signal?.aborted) return done(); + options?.signal?.addEventListener("abort", done, { once: true }); + }), + }), + }, + }), + }, + session: { + create: async (input: unknown) => { + createCount += 1; + createInputs.push(input); + return { data: { id: `ses_native_${createCount}`, time: { created: 1, updated: 1 } } }; + }, + }, + } as unknown as OpencodeClient; + const unused = (operation: string) => () => Effect.die(`${operation} is not used`); + const runtime: OpenCodeRuntime.OpenCodeRuntimeShape = { + startOpenCodeServerProcess: unused("startOpenCodeServerProcess"), + connectToOpenCodeServer: () => + Effect.succeed({ + url: "test://opencode", + version: "test", + exitCode: null, + external: true, + }), + runOpenCodeCommand: unused("runOpenCodeCommand"), + createOpenCodeSdkClient: () => fakeClient, + loadOpenCodeInventory: unused("loadOpenCodeInventory"), + loadInventoryFromCli: unused("loadInventoryFromCli"), + loadOpenCodeSkills: unused("loadOpenCodeSkills"), + loadSkillsFromCli: unused("loadSkillsFromCli"), + }; + const instanceId = ProviderInstanceId.make("opencode"); + const threadId = ThreadId.make("thread-opencode-adopt"); + const modelSelection = { instanceId, model: "default" }; + const adapter = makeOpenCodeAdapterV2({ + instanceId, + settings: OPENCODE_TEST_SETTINGS, + environment: {}, + runtime, + idAllocator, + host, + }); + const session = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-opencode-adopt"), + modelSelection, + runtimePolicy: runtimePolicy("full-access"), + }); + const now = yield* DateTime.now; + // The placeholder row the orchestrator creates for a first run: no + // native identity yet. The adapter must bind the created session to + // this row instead of minting a second session-keyed row. + const placeholder: OrchestrationV2ProviderThread = { + id: ProviderThreadId.make("thread:provider:opencode:native-thread:pending:run:adopt:1"), + driver: OPENCODE_PROVIDER, + providerInstanceId: instanceId, + providerSessionId: null, + appThreadId: threadId, + ownerNodeId: null, + nativeThreadRef: null, + nativeConversationHeadRef: null, + status: "not_loaded", + firstRunOrdinal: 1, + lastRunOrdinal: 1, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, + }; + const adopted = yield* session.ensureThread({ + threadId, + modelSelection, + runtimePolicy: runtimePolicy("full-access"), + existingProviderThread: placeholder, + }); + assert.equal(adopted.id, placeholder.id); + assert.equal(adopted.nativeThreadRef?.nativeId, "ses_native_1"); + // Without a handed-over row the adapter still derives its own id. + const minted = yield* session.ensureThread({ + threadId, + modelSelection, + runtimePolicy: runtimePolicy("full-access"), + }); + assert.notEqual(minted.id, placeholder.id); + assert.equal(minted.nativeThreadRef?.nativeId, "ses_native_2"); + // OpenCode names a session from its first prompt only when create + // leaves the title unset, so the adapter never sends one. + for (const input of createInputs) { + assert.notProperty(input, "title"); + } + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + layerTestProviderHost().pipe(Layer.provide(NodeServices.layer)), + ), + ), + ), + ); + + it("maps native permission families to orchestration request kinds", () => { + assert.equal(openCodePermissionRequestKind("bash"), "command"); + assert.equal(openCodePermissionRequestKind("read"), "file-read"); + assert.equal(openCodePermissionRequestKind("grep"), "file-read"); + assert.equal(openCodePermissionRequestKind("external_directory"), "file-read"); + assert.equal(openCodePermissionRequestKind("external_directory", "edit"), "file-change"); + assert.equal(openCodePermissionRequestKind("edit"), "file-change"); + assert.equal(openCodePermissionRequestKind("apply_patch"), "file-change"); + assert.equal(openCodePermissionRequestKind("todowrite"), "command"); + assert.equal(openCodePermissionRequestKind("custom", "todowrite"), "command"); + }); + + it("maps OpenCode tools to semantic turn-item families", () => { + assert.equal(openCodeToolProjectionKind("bash"), "command_execution"); + assert.equal(openCodeToolProjectionKind("edit"), "file_change"); + assert.equal(openCodeToolProjectionKind("read"), "dynamic_tool"); + assert.equal(openCodeToolProjectionKind("lsp"), "file_search"); + assert.equal(openCodeToolProjectionKind("websearch"), "web_search"); + assert.equal(openCodeToolProjectionKind("codesearch"), "web_search"); + assert.equal(openCodeToolProjectionKind("todowrite"), "dynamic_tool"); + assert.equal(openCodeToolProjectionKind("custom_tool"), "dynamic_tool"); + }); + + it("maps runtime modes to safe OpenCode permission rules", () => { + const approvalRequired = openCodePermissionRules(runtimePolicy("approval-required")); + assert.equal(permissionAction(approvalRequired, "read"), "allow"); + assert.equal(permissionAction(approvalRequired, "edit"), "ask"); + assert.equal(permissionAction(approvalRequired, "bash"), "ask"); + assert.equal(permissionAction(approvalRequired, "doom_loop"), "ask"); + assert.equal(permissionAction(approvalRequired, "unknown_plugin_tool"), "ask"); + assert.equal(permissionAction(approvalRequired, "question"), "allow"); + + const autoAcceptEdits = openCodePermissionRules(runtimePolicy("auto-accept-edits")); + assert.equal(permissionAction(autoAcceptEdits, "edit"), "allow"); + assert.equal(permissionAction(autoAcceptEdits, "bash"), "ask"); + + const fullAccess = openCodePermissionRules(runtimePolicy("full-access")); + assert.equal(permissionAction(fullAccess, "bash"), "allow"); + assert.equal(permissionAction(fullAccess, "edit"), "allow"); + + const granularApproval = openCodePermissionRules( + runtimePolicy("full-access", { + approvalPolicy: { granular: { request_permissions: true } }, + }), + ); + assert.equal(permissionAction(granularApproval, "bash"), "ask"); + assert.equal(permissionAction(granularApproval, "read"), "allow"); + + const approvalRequiredWorkspaceWrite = openCodePermissionRules( + runtimePolicy("approval-required", { + sandboxPolicy: { + type: "workspaceWrite", + writableRoots: ["/tmp/opencode-workspace"], + networkAccess: false, + }, + }), + ); + assert.equal(permissionAction(approvalRequiredWorkspaceWrite, "edit"), "ask"); + }); + + it("enforces non-interactive sandbox policy through OpenCode permissions", () => { + const readOnly = openCodePermissionRules( + runtimePolicy("full-access", { + approvalPolicy: "never", + sandboxPolicy: { + type: "readOnly", + access: { type: "fullAccess" }, + networkAccess: false, + }, + }), + ); + assert.equal(permissionAction(readOnly, "read"), "allow"); + assert.equal(permissionAction(readOnly, "edit"), "deny"); + assert.equal(permissionAction(readOnly, "bash"), "deny"); + assert.equal(permissionAction(readOnly, "webfetch"), "deny"); + assert.equal(permissionAction(readOnly, "doom_loop"), "deny"); + assert.equal(permissionAction(readOnly, "unknown_plugin_tool"), "deny"); + assert.equal(permissionAction(readOnly, "external_directory"), "allow"); + + const workspaceWrite = openCodePermissionRules( + runtimePolicy("auto-accept-edits", { + approvalPolicy: "never", + sandboxPolicy: { + type: "workspaceWrite", + writableRoots: ["/tmp/opencode-workspace"], + networkAccess: true, + }, + }), + ); + assert.equal(permissionAction(workspaceWrite, "edit"), "allow"); + assert.equal(permissionAction(workspaceWrite, "bash"), "deny"); + assert.equal(permissionAction(workspaceWrite, "webfetch"), "allow"); + assert.deepInclude(workspaceWrite, { + permission: "external_directory", + pattern: "/tmp/opencode-workspace/*", + action: "allow", + }); + }); + + it("preserves OpenCode's recursion guard on task-created child sessions", () => { + const childRules = openCodeChildPermissionRules(runtimePolicy("full-access"), [ + { permission: "task", pattern: "*", action: "deny" }, + ]); + + assert.equal(permissionAction(childRules, "read"), "allow"); + assert.equal(permissionAction(childRules, "bash"), "allow"); + assert.equal(permissionAction(childRules, "task"), "deny"); + + const approvalRequiredPolicy = runtimePolicy("approval-required"); + const parentRules = openCodePermissionRules(approvalRequiredPolicy); + const childApprovalRules = openCodeChildPermissionRules(approvalRequiredPolicy, [ + ...parentRules.filter((rule) => rule.action === "deny"), + { permission: "task", pattern: "*", action: "deny" }, + ]); + assert.equal(permissionAction(childApprovalRules, "bash"), "ask"); + assert.equal(permissionAction(childApprovalRules, "task"), "deny"); + }); + + it("uses the next native user message as the exclusive fork and revert boundary", () => { + const first = providerTurn({ id: "turn:first", ordinal: 1, nativeId: "msg-user-1" }); + const synthetic = providerTurn({ id: "turn:synthetic", ordinal: 2, nativeId: null }); + const third = providerTurn({ id: "turn:third", ordinal: 3, nativeId: "msg-user-3" }); + + assert.equal( + openCodeBoundaryAfterProviderTurn([third, first, synthetic], first.id), + "msg-user-3", + ); + assert.isUndefined(openCodeBoundaryAfterProviderTurn([first, synthetic, third], third.id)); + }); +}); + +it.effect.each([false, true])( + "OpenCode rewind forks history and validates the retained boundary, invalid=%s", + (invalid) => + Effect.gen(function* () { + const events = asyncEventStream(); + const nativeSessionId = "rewind-source"; + const forkId = "rewind-fork"; + const calls: string[] = []; + const removed = { + info: { id: "first-user", sessionID: nativeSessionId, role: "user", time: { created: 1 } }, + parts: [], + }; + const harness = yield* makeOpenCodeRuntimeHarness("rewind-fork", nativeSessionId, { + event: { + subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { + options.signal?.addEventListener("abort", () => events.close(), { once: true }); + return { stream: events.stream }; + }, + }, + session: { + create: async () => ({ data: { id: nativeSessionId, time: { created: 1, updated: 1 } } }), + get: async ({ sessionID }: { sessionID: string }) => ({ + data: { id: sessionID, time: { created: 1, updated: 2 } }, + }), + messages: async ({ sessionID }: { sessionID: string }) => ({ + data: sessionID === nativeSessionId || invalid ? [removed] : [], + }), + fork: async ({ sessionID, messageID }: { sessionID: string; messageID: string }) => { + assert.equal(sessionID, nativeSessionId); + assert.equal(messageID, "first-user"); + calls.push("fork"); + return { data: { id: forkId, time: { created: 1, updated: 2 } } }; + }, + update: async () => { + calls.push("permissions"); + return { data: {} }; + }, + revert: async () => { + throw new Error("Native revert would change workspace files"); + }, + }, + }); + const effect = harness.runtime.rollbackThread({ + providerThread: harness.providerThread, + target: { + type: "thread_start", + checkpointId: CheckpointId.make("rewind-checkpoint"), + appRunOrdinal: 0, + }, + providerThreadTurns: [], + }); + if (invalid) { + const error = yield* effect.pipe(Effect.flip); + assert.equal(error._tag, "ProviderAdapterRollbackThreadError"); + assert.deepEqual(calls, ["fork"]); + } else { + const result = yield* effect; + assert.equal(result.providerThread.nativeThreadRef?.nativeId, forkId); + assert.equal(result.messages.length, 0); + assert.deepEqual(calls, ["fork", "permissions"]); + } + }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), +); diff --git a/packages/provider-opencode/src/server/adapter.ts b/packages/provider-opencode/src/server/adapter.ts new file mode 100644 index 0000000000000000000000000000000000000000..0fa85f3be6d49cfc505401de54f77a81578b42ab --- /dev/null +++ b/packages/provider-opencode/src/server/adapter.ts @@ -0,0 +1,3797 @@ +import type { + Event as OpenCodeEvent, + Message as OpenCodeMessage, + Part as OpenCodePart, + PermissionRequest, + PermissionRuleset, + QuestionRequest, + Session as OpenCodeSession, + Todo as OpenCodeTodo, + ToolPart, +} from "@opencode-ai/sdk/v2"; +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; +import { causeErrorTag } from "@t3tools/shared/observability"; +import { + defaultInstanceIdForDriver, + type ModelSelection, + type OrchestrationV2AppThread, + type OrchestrationV2ConversationMessage, + type OrchestrationV2ExecutionNode, + type OrchestrationV2PlanStep, + type OrchestrationV2ProviderCapabilities, + type OrchestrationV2ProviderFailure, + type OrchestrationV2ProviderRef, + type OrchestrationV2ProviderSession, + type OrchestrationV2ProviderThread, + type OrchestrationV2ProviderTurn, + type OrchestrationV2RuntimeRequest, + type OrchestrationV2Subagent, + type OrchestrationV2TurnItem, + type PlanId, + ProviderDriverKind, + type ProviderInstanceId, + type ProviderRequestKind, + type ProviderSessionId, + type RuntimeRequestId, + type ThreadId, +} from "@t3tools/contracts"; +import { OpenCodeSettings } from "../settings.ts"; +import * as Cause from "effect/Cause"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Random from "effect/Random"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; + +import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; +import { mcpToolPresentation } from "@t3tools/provider-core/server/mcpToolPresentation"; +import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; +import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; +import { + structuralProtocolMethod, + summarizeNativeProtocolPayload, +} from "@t3tools/provider-core/server/nativeProtocolLogging"; +import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; +import { t3OrchestrationSystemPrompt } from "@t3tools/provider-core/server/orchestrationInstructions"; +import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions"; +import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; +import { turnScopedSelectionTransition } from "@t3tools/provider-core/server/selectionTransition"; +import { providerMessageTextWithAttachmentPaths } from "@t3tools/provider-core/server/attachmentPrompt"; +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; +import { + ProviderAdapterDriverCreateError, + type ProviderAdapterDriver, + type ProviderAdapterDriverCreateInput, +} from "@t3tools/provider-core/server/adapterDriver"; +import { + makeSubagentChildThread, + subagentThreadTitle, +} from "@t3tools/provider-core/server/subagentProjection"; +import { openCodeToolTurnItem } from "./toolItems.ts"; + +export { openCodeToolProjectionKind } from "./toolItems.ts"; + +export const OPENCODE_PROVIDER = ProviderDriverKind.make("opencode"); +export const OPENCODE_DEFAULT_INSTANCE_ID = defaultInstanceIdForDriver(OPENCODE_PROVIDER); +export const OPENCODE_SDK_PROTOCOL = "opencode-sdk.sse" as const; +const DEFAULT_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({}); + +let openCodeMessageIdEpochMillis = -1; +let openCodeMessageIdCounter = 0; + +const makeOpenCodeMessageId = Effect.fnUntraced(function* () { + const epochMillis = DateTime.toEpochMillis(yield* DateTime.now); + if (epochMillis !== openCodeMessageIdEpochMillis) { + openCodeMessageIdEpochMillis = epochMillis; + openCodeMessageIdCounter = 0; + } + openCodeMessageIdCounter += 1; + const encodedTime = BigInt.asUintN( + 48, + BigInt(epochMillis) * 0x1000n + BigInt(openCodeMessageIdCounter), + ) + .toString(16) + .padStart(12, "0"); + const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + const random = (yield* Effect.forEach(Array.from({ length: 28 }), () => + Random.nextIntBetween(0, alphabet.length - 1), + )) + .map((index) => alphabet[index]) + .join(""); + return `msg_${encodedTime}${random}`; +}); + +/** + * OpenCode's session, message, part, and interaction-request identifiers are + * durable. It does not expose a first-class turn object: the initiating user + * message is the best native turn correlation point, and session idle is the + * authoritative terminal signal. + */ +const OpenCodeProviderCapabilitiesV2 = { + sessions: { + // The current adapter owns one directory-bound client/server per session. + // Keep it isolated until its runtime is made safe for cross-thread pooling. + supportsMultipleProviderThreadsPerSession: false, + supportsModelSwitchInSession: true, + supportsProviderSwitchingViaHandoff: true, + supportsRuntimeModeSwitchInSession: false, + pendingRequestsSurviveRestart: false, + }, + threads: { + canCreateEmptyThread: true, + canReadThreadSnapshot: true, + canRollbackThread: true, + canForkThread: true, + canForkFromTurn: true, + canForkFromSubagentThread: true, + exposesNativeThreadId: true, + }, + turns: { + exposesNativeTurnId: false, + emitsTurnStarted: true, + emitsTurnCompleted: true, + supportsInterrupt: true, + supportsActiveSteering: true, + supportsSteeringByInterruptRestart: true, + supportsQueuedMessages: true, + terminalStatusQuality: "strong", + }, + streaming: { + streamsAssistantText: true, + streamsReasoning: true, + streamsToolOutput: true, + streamsPlanText: false, + emitsMessageCompleted: true, + }, + tools: { + exposesToolItemIds: true, + emitsToolStarted: true, + emitsToolCompleted: true, + emitsToolOutput: true, + supportsMcpTools: true, + supportsDynamicToolCallbacks: false, + }, + approvals: { + supportsCommandApproval: true, + supportsFileReadApproval: true, + supportsFileChangeApproval: true, + supportsApplyPatchApproval: true, + approvalsHaveNativeRequestIds: true, + approvalCallbacksAreLiveOnly: true, + approvalsCanOriginateFromSubagents: true, + }, + planning: { + emitsPlanUpdated: true, + emitsTodoList: true, + emitsProposedPlan: false, + supportsStructuredQuestions: true, + planDeltasHaveItemIds: false, + }, + subagents: { + supportsSubagents: true, + exposesSubagentThreadIds: true, + emitsSubagentLifecycle: true, + canWaitForSubagents: true, + canCloseSubagents: false, + canForkSubagentThread: true, + }, + context: { + acceptsSystemContext: false, + acceptsDeveloperContext: false, + acceptsSyntheticUserContext: true, + canGenerateSummaries: true, + canConsumeHandoffSummaries: true, + supportsDeltaHandoff: true, + supportsFullThreadHandoff: true, + maxRecommendedHandoffChars: null, + }, + checkpointing: { + appCanCheckpointFilesystem: true, + supportsNestedCheckpointScopes: true, + providerCanRollbackConversation: true, + providerRollbackReturnsSnapshot: true, + providerCanReadConversationSnapshot: true, + }, + identity: { + nativeThreadIds: "strong", + nativeTurnIds: "weak", + nativeItemIds: "strong", + nativeRequestIds: "strong", + }, + runtimePolicy: { + enforcement: "native", + }, +} satisfies OrchestrationV2ProviderCapabilities; + +type TerminalTurnStatus = Extract< + OrchestrationV2ProviderTurn["status"], + "completed" | "interrupted" | "failed" | "cancelled" +>; + +type OpenCodeStepUsage = Pick< + Extract, + "id" | "tokens" +>; + +interface OpenCodeTurnTokenUsageAccumulator { + readonly partIds: Set; + readonly promptMessageIds: Set; + readonly assistantOwnershipByMessageId: Map; + // Native removal does not undo usage. Keep unresolved counts until this turn settles. + readonly unresolvedStepsByMessageId: Map>; + inputTokens: number; + cachedInputTokens: number; + cacheCreationTokens: number; + outputTokens: number; + reasoningTokens: number; + hasSubagents: boolean; + complete: boolean; +} + +function makeOpenCodeTurnTokenUsageAccumulator(): OpenCodeTurnTokenUsageAccumulator { + return { + partIds: new Set(), + promptMessageIds: new Set(), + assistantOwnershipByMessageId: new Map(), + unresolvedStepsByMessageId: new Map(), + inputTokens: 0, + cachedInputTokens: 0, + cacheCreationTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + hasSubagents: false, + complete: true, + }; +} + +function accumulateOpenCodeStepUsage( + accumulator: OpenCodeTurnTokenUsageAccumulator, + part: OpenCodeStepUsage, +): void { + if (accumulator.partIds.has(part.id)) return; + accumulator.partIds.add(part.id); + accumulator.inputTokens += part.tokens.input + part.tokens.cache.read + part.tokens.cache.write; + accumulator.cachedInputTokens += part.tokens.cache.read; + accumulator.cacheCreationTokens += part.tokens.cache.write; + accumulator.outputTokens += part.tokens.output + part.tokens.reasoning; + accumulator.reasoningTokens += part.tokens.reasoning; +} + +interface ActiveOpenCodeTurn { + readonly usage: OpenCodeTurnTokenUsageAccumulator; + readonly isRoot: boolean; + readonly threadId: ThreadId; + readonly runId: ProviderAdapter.ProviderAdapterV2TurnInput["runId"] | null; + readonly rootNodeId: ProviderAdapter.ProviderAdapterV2TurnInput["rootNodeId"]; + readonly appThread: OrchestrationV2AppThread; + readonly modelSelection: ModelSelection; + readonly runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy; + readonly providerTurnId: OrchestrationV2ProviderTurn["id"]; + /** The provider thread the turn started on, which its terminal names. */ + readonly providerThreadId: OrchestrationV2ProviderTurn["providerThreadId"]; + readonly providerTurnOrdinal: number; + readonly runOrdinal: number; + readonly runAttemptId: OrchestrationV2ProviderTurn["runAttemptId"]; + readonly startedAt: DateTime.Utc; + readonly itemOrdinals: Map; + readonly parts: Map>; + readonly partIdsByMessage: Map>; + readonly toolNamesByCallId: Map; + mcpServerNames?: ReadonlyArray; + readonly providerTurn: OrchestrationV2ProviderTurn; + nextItemOrdinal: number; + nativeUserMessageId: string | null; + admissionMessageId: string | null; + interrupted: boolean; + finalized: boolean; + planId: PlanId | null; + admissionGeneration: number; + admissionReconciliationGeneration: number | null; + admissionPending: boolean; + admissionAccepted: boolean; + admissionMessageObserved: boolean; + idleDuringAdmission: boolean; + admissionSettled: Deferred.Deferred; + admissionAbortController: AbortController | null; +} + +type OpenCodeAdmissionSignal = + | "accepted" + | "assistant-completed" + | "busy" + | "idle" + | "user-message"; +type OpenCodeAdmissionAction = "hold" | "reconcile-idle" | "release"; + +export function advanceOpenCodePromptAdmission( + admission: Pick< + ActiveOpenCodeTurn, + "admissionAccepted" | "admissionMessageObserved" | "admissionPending" | "idleDuringAdmission" + >, + signal: OpenCodeAdmissionSignal, +): OpenCodeAdmissionAction { + if (!admission.admissionPending) return "release"; + if (signal === "assistant-completed") { + admission.admissionAccepted = true; + admission.admissionMessageObserved = true; + admission.admissionPending = false; + return "release"; + } + if (signal === "idle") { + admission.idleDuringAdmission = true; + return "hold"; + } + if (signal === "accepted") admission.admissionAccepted = true; + if (signal === "busy" || signal === "user-message") admission.admissionMessageObserved = true; + if (!admission.admissionAccepted || !admission.admissionMessageObserved) return "hold"; + if (admission.idleDuringAdmission) return "reconcile-idle"; + admission.admissionPending = false; + return "release"; +} + +export function cancelOpenCodePromptAdmission( + admission: Pick, + nextGeneration: number, +): void { + admission.admissionGeneration = nextGeneration; + admission.admissionPending = false; +} + +export const reconcileOpenCodePromptAdmissionStatus = Effect.fn( + "reconcileOpenCodePromptAdmissionStatus", +)(function* ( + admission: Pick, + generation: number, + readStatus: Effect.Effect<"busy" | "idle" | "unknown">, +) { + if (admission.admissionGeneration !== generation || !admission.admissionPending) { + return "stale" as const; + } + const status = yield* readStatus; + if (admission.admissionGeneration !== generation || !admission.admissionPending) { + return "stale" as const; + } + if (status !== "unknown") admission.admissionPending = false; + return status; +}); + +interface OpenCodeSubagentContext { + readonly nativeItemId: string; + readonly nodeId: OrchestrationV2Subagent["id"]; + readonly parentState: OpenCodeThreadState; + readonly parentTurn: ActiveOpenCodeTurn; + readonly prompt: string; + readonly title: string | null; + readonly startedAt: DateTime.Utc; + childSessionId: string | null; + childThreadId: ThreadId | null; + childProviderThreadId: OrchestrationV2ProviderThread["id"] | null; + model: string | null; + result: string | null; +} + +interface OpenCodeThreadState { + readonly nativeSessionId: string; + providerThread: OrchestrationV2ProviderThread; + appThread: OrchestrationV2AppThread | null; + activeTurn: ActiveOpenCodeTurn | null; + readonly providerTurns: Map; + readonly messages: Map; + readonly runtimeRequests: Map; + readonly messageRoles: Map; + readonly userMessageIds: Array; + parentSubagent: OpenCodeSubagentContext | null; + nextChildTurnOrdinal: number; + nextAdmissionGeneration: number; +} + +interface OpenCodeRequestOwner { + readonly state: OpenCodeThreadState; + readonly turn: ActiveOpenCodeTurn; + /** The subagent on the owner's thread that leads to the asking session. */ + readonly subagent: OpenCodeSubagentContext | null; +} + +/** + * The top-level thread and active turn that own a session's requests, walking + * up through (possibly nested) subagents. + */ +function topLevelRequestOwner(state: OpenCodeThreadState): OpenCodeRequestOwner | undefined { + let owner = state; + let subagent: OpenCodeSubagentContext | null = null; + while (owner.parentSubagent !== null) { + subagent = owner.parentSubagent; + owner = subagent.parentState; + } + return owner.activeTurn === null ? undefined : { state: owner, turn: owner.activeTurn, subagent }; +} + +interface PendingOpenCodeRequest { + readonly requestId: RuntimeRequestId; + readonly nativeRequestId: string; + /** The session that asked, which may be a subagent's. */ + readonly nativeSessionId: string; + readonly turn: ActiveOpenCodeTurn; + readonly state: OpenCodeThreadState; + readonly nodeId: OrchestrationV2ExecutionNode["id"]; + readonly parentNodeId: OrchestrationV2ExecutionNode["id"]; + readonly turnItemId: OrchestrationV2TurnItem["id"]; + readonly requestKind: OpenCodePermissionRequestKind | "user_input"; + readonly createdAt: DateTime.Utc; + readonly permission?: PermissionRequest; + readonly question?: QuestionRequest; +} + +export interface OpenCodeAdapterV2Options { + readonly instanceId: ProviderInstanceId; + readonly settings: OpenCodeSettings; + readonly environment: NodeJS.ProcessEnv; + readonly runtime: OpenCodeRuntime.OpenCodeRuntimeShape; + readonly idAllocator: IdAllocator.IdAllocatorV2Shape; + readonly host: ProviderHost.ProviderHostShape; + readonly nativeEventLogger?: ProviderEventLoggers.EventNdjsonLogger; +} + +export interface OpenCodeProtocolLogEvent { + readonly direction: "incoming" | "outgoing"; + readonly messageKind: "request" | "response" | "notification" | "error"; + readonly method: string; + readonly payload: unknown; +} + +function formatOpenCodeProtocolLogPayload(event: OpenCodeProtocolLogEvent) { + return { + direction: event.direction, + messageKind: event.messageKind, + method: structuralProtocolMethod(event.method), + payload: summarizeNativeProtocolPayload(event.payload), + }; +} + +export function makeOpenCodeProtocolLogger(input: { + readonly nativeEventLogger: ProviderEventLoggers.EventNdjsonLogger | undefined; + readonly idAllocator: IdAllocator.IdAllocatorV2Shape; + readonly providerInstanceId: ProviderInstanceId; + readonly providerSessionId: ProviderSessionId; + readonly threadId: ThreadId; +}): (event: OpenCodeProtocolLogEvent) => Effect.Effect { + return (event) => + Effect.gen(function* () { + if (!input.nativeEventLogger) return; + const observedAt = DateTime.formatIso(yield* DateTime.now); + const method = structuralProtocolMethod(event.method); + yield* input.nativeEventLogger.write( + { + observedAt, + event: { + id: yield* input.idAllocator.allocate.rawEvent({ + providerSessionId: input.providerSessionId, + method, + }), + kind: "protocol", + protocol: OPENCODE_SDK_PROTOCOL, + provider: OPENCODE_PROVIDER, + providerInstanceId: input.providerInstanceId, + providerSessionId: input.providerSessionId, + createdAt: observedAt, + threadId: input.threadId, + payload: formatOpenCodeProtocolLogPayload(event), + }, + }, + input.threadId, + ); + }).pipe( + Effect.catchCause((cause) => + Cause.hasInterrupts(cause) + ? Effect.interrupt + : Effect.logWarning("Failed to write native OpenCode event log.", { + errorTag: causeErrorTag(cause), + reasonCount: cause.reasons.length, + provider: OPENCODE_PROVIDER, + threadId: input.threadId, + }), + ), + ); +} + +function protocolError( + detail: string, + payload?: unknown, +): ProviderAdapter.ProviderAdapterProtocolError { + return new ProviderAdapter.ProviderAdapterProtocolError({ + driver: OPENCODE_PROVIDER, + detail, + ...(payload === undefined ? {} : { payload }), + }); +} + +function nativeThreadId(providerThread: OrchestrationV2ProviderThread): string { + const nativeId = providerThread.nativeThreadRef?.nativeId; + if (nativeId === null || nativeId === undefined) { + throw protocolError(`Provider thread ${providerThread.id} has no OpenCode session id`); + } + return nativeId; +} + +function dateTimeFromEpoch(value: number | undefined, fallback: DateTime.Utc): DateTime.Utc { + if (value === undefined) return fallback; + return Option.getOrElse(DateTime.make(value), () => fallback); +} + +function nonEmptyString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value : undefined; +} + +function recordValue(input: unknown, key: string): unknown { + return typeof input === "object" && input !== null && key in input + ? (input as Record)[key] + : undefined; +} + +function recordString(input: unknown, ...keys: ReadonlyArray): string | undefined { + for (const key of keys) { + const value = nonEmptyString(recordValue(input, key)); + if (value !== undefined) return value; + } + return undefined; +} + +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 }; + if ("stream" in value) return { subscribed: true }; + return value; +} + +type OpenCodePermissionRequestKind = Extract< + ProviderRequestKind, + "command" | "file-read" | "file-change" +>; + +export function openCodePermissionRequestKind( + permission: string, + toolName?: string, +): OpenCodePermissionRequestKind { + const normalized = permission.toLowerCase(); + const normalizedTool = toolName?.toLowerCase() ?? ""; + if ( + normalized === "edit" || + normalized === "write" || + normalized === "patch" || + normalized === "apply_patch" || + normalizedTool === "edit" || + normalizedTool === "write" || + normalizedTool === "patch" || + normalizedTool === "apply_patch" + ) { + return "file-change"; + } + if ( + normalized === "read" || + normalized === "glob" || + normalized === "grep" || + normalized === "lsp" || + normalized === "external_directory" || + normalizedTool === "read" || + normalizedTool.includes("glob") || + normalizedTool.includes("grep") || + normalizedTool.includes("search") + ) { + return "file-read"; + } + return "command"; +} + +const OPENCODE_ALWAYS_ALLOWED_PERMISSIONS = [ + "question", + "read", + "glob", + "grep", + "lsp", + "todowrite", + "task", + "skill", +] as const; + +const OPENCODE_RESTRICTED_PERMISSIONS = [ + "bash", + "edit", + "webfetch", + "websearch", + "codesearch", + "external_directory", + "doom_loop", +] as const; + +/** + * OpenCode does not provide an OS sandbox, so permission rules are also the + * enforcement boundary for non-interactive policies. Read/planning tools are + * safe by default; edits are auto-approved only for workspace-write modes, + * while shell/network/external access remains gated unless policy explicitly + * allows it. + */ +export function openCodePermissionRules( + runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy, +): PermissionRuleset { + const sandboxPolicy = recordValue(runtimePolicy, "sandboxPolicy"); + const sandboxType = recordString(sandboxPolicy, "type"); + const rawApprovalPolicy = runtimePolicy.approvalPolicy; + const approvalPolicy = nonEmptyString(rawApprovalPolicy); + const requiresApproval = + approvalPolicy === undefined + ? (typeof rawApprovalPolicy === "object" && rawApprovalPolicy !== null) || + runtimePolicy.runtimeMode !== "full-access" + : approvalPolicy !== "never"; + const externallySandboxed = sandboxType === "externalSandbox"; + const dangerFullAccess = sandboxType === "dangerFullAccess"; + const implicitFullAccess = + sandboxType === undefined && runtimePolicy.runtimeMode === "full-access"; + + if (!requiresApproval && (externallySandboxed || dangerFullAccess || implicitFullAccess)) { + return [{ permission: "*", pattern: "*", action: "allow" }]; + } + + // Task sessions initially inherit only parent deny rules. Seed explicit + // denies before the effective ask/allow overrides so a child is safe during + // the short interval before emitSubagent installs its complete policy. + const rules: PermissionRuleset = [ + { permission: "*", pattern: "*", action: "deny" }, + ...OPENCODE_RESTRICTED_PERMISSIONS.map((permission) => ({ + permission, + pattern: "*", + action: "deny" as const, + })), + ]; + + if (requiresApproval) { + rules.push({ permission: "*", pattern: "*", action: "ask" }); + for (const permission of OPENCODE_RESTRICTED_PERMISSIONS) { + rules.push({ permission, pattern: "*", action: "ask" }); + } + } + + rules.push( + ...OPENCODE_ALWAYS_ALLOWED_PERMISSIONS.map((permission) => ({ + permission, + pattern: "*", + action: "allow" as const, + })), + ); + + rules.push( + { permission: "read", pattern: "*.env", action: requiresApproval ? "ask" : "deny" }, + { permission: "read", pattern: "*.env.*", action: requiresApproval ? "ask" : "deny" }, + { permission: "read", pattern: "*.env.example", action: "allow" }, + ); + + if ( + runtimePolicy.runtimeMode === "auto-accept-edits" || + (!requiresApproval && sandboxType === "workspaceWrite") + ) { + rules.push({ permission: "edit", pattern: "*", action: "allow" }); + } + + if (!requiresApproval && recordValue(sandboxPolicy, "networkAccess") === true) { + for (const permission of ["webfetch", "websearch", "codesearch"] as const) { + rules.push({ permission, pattern: "*", action: "allow" }); + } + } + + if (!requiresApproval && sandboxType === "readOnly") { + const access = recordValue(sandboxPolicy, "access"); + if (recordString(access, "type") === "fullAccess") { + rules.push({ permission: "external_directory", pattern: "*", action: "allow" }); + } + } + + if (!requiresApproval && sandboxType === "workspaceWrite") { + const writableRoots = recordValue(sandboxPolicy, "writableRoots"); + if (Array.isArray(writableRoots)) { + for (const root of writableRoots) { + if (typeof root === "string" && root.trim().length > 0) { + rules.push({ + permission: "external_directory", + pattern: `${root.replace(/\/$/, "")}/*`, + action: "allow", + }); + } + } + } + } + + return rules; +} + +function permissionRuleEquals( + left: PermissionRuleset[number], + right: PermissionRuleset[number], +): boolean { + return ( + left.permission === right.permission && + left.pattern === right.pattern && + left.action === right.action + ); +} + +/** + * OpenCode task sessions inherit only the parent's deny/external-directory + * rules and then add agent-specific restrictions such as disabling nested + * tasks. Install the complete parent policy while retaining only rules that + * were added specifically for the selected child agent. + */ +export function openCodeChildPermissionRules( + runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy, + nativeChildRules: PermissionRuleset, +): PermissionRuleset { + const parentRules = openCodePermissionRules(runtimePolicy); + const inheritedRules = parentRules.filter( + (rule) => rule.permission === "external_directory" || rule.action === "deny", + ); + const childSpecificRules = nativeChildRules.filter( + (childRule) => + !inheritedRules.some((inheritedRule) => permissionRuleEquals(childRule, inheritedRule)), + ); + return [...parentRules, ...childSpecificRules]; +} + +/** + * OpenCode's fork/revert boundary is exclusive. To retain the selected app + * turn, address the next native user message; omitting a boundary retains the + * current head when the selected turn is already last. + */ +export function openCodeBoundaryAfterProviderTurn( + providerTurns: ReadonlyArray, + selectedProviderTurnId: OrchestrationV2ProviderTurn["id"], +): string | undefined { + const selected = providerTurns.find((turn) => turn.id === selectedProviderTurnId); + if (selected === undefined) return undefined; + return providerTurns + .filter((turn) => turn.ordinal > selected.ordinal) + .toSorted((left, right) => left.ordinal - right.ordinal) + .map((turn) => turn.nativeTurnRef?.nativeId) + .find((nativeId): nativeId is string => nativeId !== null && nativeId !== undefined); +} + +function toolStatus(part: ToolPart): { + readonly node: OrchestrationV2ExecutionNode["status"]; + readonly item: OrchestrationV2TurnItem["status"]; +} { + switch (part.state.status) { + case "pending": + return { node: "pending", item: "pending" }; + case "running": + return { node: "running", item: "running" }; + case "completed": + return { node: "completed", item: "completed" }; + case "error": + return { node: "failed", item: "failed" }; + } +} + +function toolInput(part: ToolPart): Record { + return part.state.input; +} + +function toolOutput(part: ToolPart): string | undefined { + if (part.state.status === "completed") return part.state.output; + if (part.state.status === "error") return part.state.error; + return undefined; +} + +function toolStartedAt(part: ToolPart, now: DateTime.Utc): DateTime.Utc { + return dateTimeFromEpoch( + part.state.status === "pending" ? undefined : part.state.time.start, + now, + ); +} + +function toolCompletedAt(part: ToolPart, now: DateTime.Utc): DateTime.Utc | null { + return part.state.status === "completed" || part.state.status === "error" + ? dateTimeFromEpoch(part.state.time.end, now) + : null; +} + +function toolTitle(part: ToolPart): string | null { + return part.state.status === "running" || part.state.status === "completed" + ? (part.state.title ?? null) + : null; +} + +function toolModel(part: ToolPart): string | null { + const metadata = + part.state.status === "running" || + part.state.status === "completed" || + part.state.status === "error" + ? part.state.metadata + : undefined; + const model = recordValue(metadata, "model"); + const providerId = recordString(model, "providerID", "providerId"); + const modelId = recordString(model, "modelID", "modelId", "id"); + return providerId !== undefined && modelId !== undefined ? `${providerId}/${modelId}` : null; +} + +function taskSessionId(part: ToolPart): string | null { + const metadata = + part.state.status === "running" || + part.state.status === "completed" || + part.state.status === "error" + ? part.state.metadata + : undefined; + return recordString(metadata, "sessionId", "sessionID") ?? null; +} + +function makeProviderThread(input: { + readonly idAllocator: IdAllocator.IdAllocatorV2Shape; + readonly providerInstanceId: ProviderInstanceId; + readonly providerSessionId: OrchestrationV2ProviderThread["providerSessionId"]; + readonly appThreadId: OrchestrationV2ProviderThread["appThreadId"]; + readonly ownerNodeId?: OrchestrationV2ProviderThread["ownerNodeId"]; + readonly nativeSession: OpenCodeSession; + readonly forkedFrom?: OrchestrationV2ProviderThread["forkedFrom"]; + readonly now: DateTime.Utc; +}): OrchestrationV2ProviderThread { + const createdAt = dateTimeFromEpoch(input.nativeSession.time.created, input.now); + return { + id: input.idAllocator.derive.providerThread({ + driver: OPENCODE_PROVIDER, + nativeThreadId: input.nativeSession.id, + }), + driver: OPENCODE_PROVIDER, + providerInstanceId: input.providerInstanceId, + providerSessionId: input.providerSessionId, + appThreadId: input.appThreadId, + ownerNodeId: input.ownerNodeId ?? null, + nativeThreadRef: { + driver: OPENCODE_PROVIDER, + nativeId: input.nativeSession.id, + strength: "strong", + }, + nativeConversationHeadRef: null, + status: "idle", + firstRunOrdinal: null, + lastRunOrdinal: null, + handoffIds: [], + forkedFrom: input.forkedFrom ?? null, + createdAt, + updatedAt: dateTimeFromEpoch(input.nativeSession.time.updated, input.now), + }; +} + +function providerRef(nativeId: string, strength: "strong" | "weak" = "strong") { + return { + driver: OPENCODE_PROVIDER, + nativeId, + strength, + } satisfies OrchestrationV2ProviderRef; +} + +function openCodeErrorMessage(event: Extract): string { + const error = event.properties.error; + if (error === undefined) return "OpenCode session failed without an error payload."; + return recordString(error.data, "message") ?? error.name; +} + +function terminalStatusForError( + event: Extract, + turn: ActiveOpenCodeTurn, +): TerminalTurnStatus { + return turn.interrupted || isMessageAbortedError(event) ? "interrupted" : "failed"; +} + +function isMessageAbortedError(event: Extract): boolean { + return event.properties.error?.name === "MessageAbortedError"; +} + +function isOpenCodeNotFound(cause: unknown): boolean { + const seen = new Set(); + const queue: Array = [cause]; + for (let steps = 0; queue.length > 0 && steps < 32; steps += 1) { + const node = queue.shift(); + if (node === null || typeof node !== "object" || seen.has(node)) { + continue; + } + seen.add(node); + const record = node as Record; + + const response = record.response; + const statuses = [ + record.status, + record.statusCode, + response !== null && typeof response === "object" + ? (response as { readonly status?: unknown }).status + : undefined, + ].filter((status): status is number => typeof status === "number"); + if (statuses.includes(404)) { + return true; + } + if (statuses.length > 0) { + continue; + } + + const name = record.name; + if (typeof name === "string" && name.toLowerCase() === "notfounderror") { + return true; + } + + for (const key of ["cause", "body", "error", "data"] as const) { + if (record[key] !== undefined) { + queue.push(record[key]); + } + } + } + return false; +} + +function unwrapData(operation: string, result: { readonly data?: A }): NonNullable { + if (result.data === undefined) { + throw new OpenCodeRuntime.OpenCodeRuntimeError({ + operation, + detail: `OpenCode ${operation} returned no response payload.`, + }); + } + return result.data as NonNullable; +} + +export function makeOpenCodeAdapterV2( + options: OpenCodeAdapterV2Options, +): ProviderAdapter.ProviderAdapterV2Shape { + const { idAllocator, runtime, host } = options; + + 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: ProviderAdapter.ProviderAdapterV2OpenSessionInput) { + const scope = yield* Effect.scope; + const cwd = input.runtimePolicy.cwd ?? host.paths.cwd; + const connection = yield* runtime.connectToOpenCodeServer({ + binaryPath: options.settings.binaryPath, + directory: cwd, + serverUrl: options.settings.serverUrl, + environment: options.environment, + }); + const client = runtime.createOpenCodeSdkClient({ + baseUrl: connection.url, + directory: cwd, + ...(connection.external && options.settings.serverPassword + ? { serverPassword: options.settings.serverPassword } + : {}), + }); + + const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); + const hasT3Mcp = mcpSession !== undefined && !connection.external; + const orchestrationSystemPrompt = t3OrchestrationSystemPrompt(hasT3Mcp); + if (hasT3Mcp) { + yield* OpenCodeRuntime.runOpenCodeSdk("mcp.add", () => + client.mcp.add({ + name: "t3-code", + config: { + type: "remote", + url: mcpSession.endpoint, + headers: { Authorization: mcpSession.authorizationHeader }, + oauth: false, + }, + }), + ); + } + + const now = yield* DateTime.now; + let sessionEntity: OrchestrationV2ProviderSession = { + id: input.providerSessionId, + driver: OPENCODE_PROVIDER, + providerInstanceId: options.instanceId, + status: "ready", + cwd, + model: input.modelSelection.model, + capabilities: OpenCodeProviderCapabilitiesV2, + createdAt: now, + updatedAt: now, + lastError: null, + }; + const events = yield* Queue.unbounded(); + let nativeStreamFailure: OrchestrationV2ProviderFailure | null = null; + const threads = new Map(); + const commandReceipts = new Map>(); + const commandControllers = new Map>(); + const pendingRequests = new Map(); + const pendingRequestsByNativeId = new Map(); + const subagentsByNativeItemId = new Map(); + const subagentsByChildSessionId = new Map(); + // Permission and question requests can originate from child sessions + // (task subagents and their descendants). Related sessions map back + // to the root thread state whose active turn owns the request; asks + // arriving before the relation is known resolve it via session.get. + const relatedSessionOwners = new Map(); + // Sessions of this runtime that OpenCode reports busy. A background + // task child keeps running after its parent turn settles, so idle + // release must not close the server under it. + const busySessionIds = new Set(); + // Requests settled before their session relation resolved: a late + // routing attempt must never resurrect them. Bounded because entries + // only matter for the seconds a routing retry can still be running. + const settledNativeRequestIds = new Set(); + const pendingChildRequestRoutes = new Set(); + const rememberSettledRequest = (nativeRequestId: string) => { + if (settledNativeRequestIds.size >= 2_048) settledNativeRequestIds.clear(); + settledNativeRequestIds.add(nativeRequestId); + }; + const abortController = new AbortController(); + let closing = false; + let hasConnected = false; + + const emitProviderEvent = (event: ProviderAdapter.ProviderAdapterV2Event) => + Queue.offer(events, event).pipe(Effect.asVoid); + + const logProtocolEvent = makeOpenCodeProtocolLogger({ + nativeEventLogger: options.nativeEventLogger, + idAllocator, + providerInstanceId: options.instanceId, + providerSessionId: input.providerSessionId, + threadId: input.threadId, + }); + + const sdkCall = ( + method: string, + payload: unknown, + call: (signal: AbortSignal) => Promise, + ): Effect.Effect => + logProtocolEvent({ + direction: "outgoing", + messageKind: "request", + method, + payload, + }).pipe( + Effect.andThen(OpenCodeRuntime.runOpenCodeSdk(method, call)), + Effect.tap((response) => + logProtocolEvent({ + direction: "incoming", + messageKind: "response", + method, + payload: sdkResponseForRawLog(response), + }), + ), + ); + + const abortDescendants = (rootId: string) => + Effect.gen(function* () { + const visited = new Set([rootId]); + const semaphore = Semaphore.makeUnsafe(8); + const visit = ( + sessionId: string, + abort: boolean, + ): Effect.Effect => + Effect.gen(function* () { + const abortResult = abort + ? yield* sdkCall("session.abort", { sessionID: sessionId }, (signal) => + client.session.abort({ sessionID: sessionId }, { signal }), + ).pipe( + semaphore.withPermit, + Effect.catchIf(isOpenCodeNotFound, () => Effect.void), + Effect.result, + ) + : undefined; + const childrenResult = yield* sdkCall( + "session.children", + { sessionID: sessionId }, + (signal) => client.session.children({ sessionID: sessionId }, { signal }), + ).pipe( + semaphore.withPermit, + Effect.catchIf(isOpenCodeNotFound, () => Effect.void), + Effect.result, + ); + const firstFailure = + abortResult?._tag === "Failure" ? abortResult.failure : undefined; + if (childrenResult._tag === "Failure") + return firstFailure ?? childrenResult.failure; + const fresh = (childrenResult.success?.data ?? []).filter((child) => { + if (visited.has(child.id)) return false; + visited.add(child.id); + return true; + }); + const failures = yield* Effect.forEach(fresh, (child) => visit(child.id, true), { + concurrency: 8, + }); + return firstFailure ?? failures.find((failure) => failure !== undefined); + }); + const failure = yield* visit(rootId, false); + if (failure) return yield* Effect.fail(failure); + }).pipe(Effect.timeout("15 seconds")); + + const updateProviderSession = ( + status: OrchestrationV2ProviderSession["status"], + lastError: string | null = sessionEntity.lastError, + ) => + Effect.gen(function* () { + const updatedAt = yield* DateTime.now; + sessionEntity = { ...sessionEntity, status, lastError, updatedAt }; + yield* emitProviderEvent({ + type: "provider_session.updated", + driver: OPENCODE_PROVIDER, + providerSession: sessionEntity, + }); + }); + + const updateProviderThread = ( + state: OpenCodeThreadState, + patch: Partial, + ) => + Effect.gen(function* () { + const updatedAt = yield* DateTime.now; + state.providerThread = { ...state.providerThread, ...patch, updatedAt }; + yield* emitProviderEvent({ + type: "provider_thread.updated", + driver: OPENCODE_PROVIDER, + providerThread: state.providerThread, + }); + }); + + const itemOrdinal = (turn: ActiveOpenCodeTurn, nativeItemId: string): number => { + const existing = turn.itemOrdinals.get(nativeItemId); + if (existing !== undefined) return existing; + const ordinal = turn.nextItemOrdinal++; + turn.itemOrdinals.set(nativeItemId, ordinal); + return ordinal; + }; + + const emitProviderTurn = ( + state: OpenCodeThreadState, + turn: ActiveOpenCodeTurn, + status: OrchestrationV2ProviderTurn["status"], + completedAt: DateTime.Utc | null, + ) => { + const providerTurn: OrchestrationV2ProviderTurn = { + ...turn.providerTurn, + nativeTurnRef: + turn.nativeUserMessageId === null + ? turn.providerTurn.nativeTurnRef + : providerRef(turn.nativeUserMessageId, "weak"), + status, + completedAt, + ...(completedAt === null + ? {} + : { + turnTokenUsage: + turn.usage.partIds.size === 0 + ? { + usageScope: "main_agent" as const, + usageStatus: "unavailable" as const, + hasSubagents: turn.usage.hasSubagents, + } + : { + usageScope: "main_agent" as const, + usageStatus: + status === "completed" && + turn.usage.complete && + turn.usage.unresolvedStepsByMessageId.size === 0 + ? ("complete" as const) + : ("partial" as const), + inputTokens: turn.usage.inputTokens, + cachedInputTokens: turn.usage.cachedInputTokens, + cacheCreationTokens: turn.usage.cacheCreationTokens, + outputTokens: turn.usage.outputTokens, + reasoningTokens: turn.usage.reasoningTokens, + hasSubagents: turn.usage.hasSubagents, + }, + }), + }; + Object.assign(turn.providerTurn, providerTurn); + state.providerTurns.set(String(providerTurn.id), providerTurn); + return emitProviderEvent({ + type: "provider_turn.updated", + driver: OPENCODE_PROVIDER, + threadId: turn.threadId, + providerTurn, + }); + }; + + const emitTextPart = Effect.fnUntraced(function* ( + state: OpenCodeThreadState, + turn: ActiveOpenCodeTurn, + part: Extract, + forceCompleted = false, + ) { + if (part.type === "text" && (part.ignored === true || part.synthetic === true)) return; + if (part.text.length === 0) return; + const emittedAt = yield* DateTime.now; + const isCompleted = forceCompleted || part.time?.end !== undefined; + const startedAt = dateTimeFromEpoch(part.time?.start, emittedAt); + const completedAt = isCompleted ? dateTimeFromEpoch(part.time?.end, emittedAt) : null; + const nativeItemRef = providerRef(part.id); + const nodeId = idAllocator.derive.nodeFromProviderItem({ + driver: OPENCODE_PROVIDER, + nativeItemId: part.id, + }); + const turnItemId = idAllocator.derive.turnItemFromProviderItem({ + driver: OPENCODE_PROVIDER, + nativeItemId: part.id, + }); + const ordinal = itemOrdinal(turn, part.id); + yield* emitProviderEvent({ + type: "node.updated", + driver: OPENCODE_PROVIDER, + node: { + id: nodeId, + threadId: turn.threadId, + runId: turn.runId, + parentNodeId: turn.rootNodeId, + rootNodeId: turn.rootNodeId, + kind: part.type === "text" ? "assistant_message" : "reasoning", + status: isCompleted ? "completed" : "running", + countsForRun: false, + providerThreadId: state.providerThread.id, + providerTurnId: turn.providerTurnId, + nativeItemRef, + runtimeRequestId: null, + checkpointScopeId: null, + startedAt, + completedAt, + }, + }); + if (part.type === "text") { + const messageId = idAllocator.derive.messageFromProviderItem({ + driver: OPENCODE_PROVIDER, + nativeItemId: part.id, + }); + const message: OrchestrationV2ConversationMessage = { + createdBy: "agent", + creationSource: "provider", + id: messageId, + threadId: turn.threadId, + runId: turn.runId, + nodeId, + role: "assistant", + text: part.text, + attachments: [], + streaming: !isCompleted, + createdAt: startedAt, + updatedAt: emittedAt, + }; + state.messages.set(String(message.id), message); + yield* emitProviderEvent({ + type: "message.updated", + driver: OPENCODE_PROVIDER, + message, + }); + yield* emitProviderEvent({ + type: "turn_item.updated", + driver: OPENCODE_PROVIDER, + turnItem: { + id: turnItemId, + threadId: turn.threadId, + runId: turn.runId, + nodeId, + providerThreadId: state.providerThread.id, + providerTurnId: turn.providerTurnId, + nativeItemRef, + parentItemId: null, + ordinal, + status: isCompleted ? "completed" : "running", + title: null, + startedAt, + completedAt, + updatedAt: emittedAt, + type: "assistant_message", + messageId, + text: part.text, + streaming: !isCompleted, + }, + }); + return; + } + yield* emitProviderEvent({ + type: "turn_item.updated", + driver: OPENCODE_PROVIDER, + turnItem: { + id: turnItemId, + threadId: turn.threadId, + runId: turn.runId, + nodeId, + providerThreadId: state.providerThread.id, + providerTurnId: turn.providerTurnId, + nativeItemRef, + parentItemId: null, + ordinal, + status: isCompleted ? "completed" : "running", + title: null, + startedAt, + completedAt, + updatedAt: emittedAt, + type: "reasoning", + text: part.text, + streaming: !isCompleted, + }, + }); + }); + + const emitSubagent = Effect.fnUntraced(function* ( + state: OpenCodeThreadState, + turn: ActiveOpenCodeTurn, + part: ToolPart, + ) { + const now = yield* DateTime.now; + const nativeItemRef = providerRef(part.id); + const nodeId = idAllocator.derive.nodeFromProviderItem({ + driver: OPENCODE_PROVIDER, + nativeItemId: part.id, + }); + const turnItemId = idAllocator.derive.turnItemFromProviderItem({ + driver: OPENCODE_PROVIDER, + nativeItemId: part.id, + }); + turn.usage.hasSubagents = true; + const input = toolInput(part); + const prompt = recordString(input, "prompt") ?? ""; + const title = toolTitle(part) ?? recordString(input, "description") ?? null; + let context = subagentsByNativeItemId.get(part.id); + if (context === undefined) { + context = { + nativeItemId: part.id, + nodeId, + parentState: state, + parentTurn: turn, + prompt, + title, + startedAt: toolStartedAt(part, now), + childSessionId: null, + childThreadId: null, + childProviderThreadId: null, + model: null, + result: null, + }; + subagentsByNativeItemId.set(part.id, context); + } + context.model = toolModel(part) ?? context.model; + const childSessionId = taskSessionId(part); + if (childSessionId !== null && context.childSessionId === null) { + context.childSessionId = childSessionId; + context.childThreadId = idAllocator.derive.threadFromProviderThread({ + driver: OPENCODE_PROVIDER, + nativeThreadId: childSessionId, + }); + context.childProviderThreadId = idAllocator.derive.providerThread({ + driver: OPENCODE_PROVIDER, + nativeThreadId: childSessionId, + }); + subagentsByChildSessionId.set(childSessionId, context); + relatedSessionOwners.set(childSessionId, state); + const childModelSelection: ModelSelection = { + instanceId: options.instanceId, + model: context.model ?? turn.modelSelection.model, + }; + const childThread = makeSubagentChildThread({ + parentThread: turn.appThread, + childThreadId: context.childThreadId, + parentNodeId: nodeId, + activeProviderThreadId: context.childProviderThreadId, + providerInstanceId: options.instanceId, + modelSelection: childModelSelection, + title: subagentThreadTitle({ + parentTitle: turn.appThread.title, + title, + prompt, + ordinal: itemOrdinal(turn, part.id), + }), + now, + createdBy: "agent", + creationSource: "provider", + }); + const childProviderThread: OrchestrationV2ProviderThread = { + id: context.childProviderThreadId, + driver: OPENCODE_PROVIDER, + providerInstanceId: options.instanceId, + providerSessionId: inputProviderSessionId, + appThreadId: context.childThreadId, + ownerNodeId: nodeId, + nativeThreadRef: providerRef(childSessionId), + nativeConversationHeadRef: null, + status: "active", + firstRunOrdinal: null, + lastRunOrdinal: null, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, + }; + const childSessionResponse = yield* sdkCall( + "session.get", + { sessionID: childSessionId }, + () => client.session.get({ sessionID: childSessionId }), + ); + const nativeChildSession = unwrapData("session.get", childSessionResponse); + const childPermission = openCodeChildPermissionRules( + turn.runtimePolicy, + nativeChildSession.permission ?? [], + ); + yield* sdkCall( + "session.update", + { sessionID: childSessionId, permission: childPermission }, + () => + client.session.update({ + sessionID: childSessionId, + permission: childPermission, + }), + ); + threads.set(childSessionId, { + nativeSessionId: childSessionId, + providerThread: childProviderThread, + appThread: childThread, + activeTurn: null, + providerTurns: new Map(), + messages: new Map(), + runtimeRequests: new Map(), + messageRoles: new Map(), + userMessageIds: [], + parentSubagent: context, + nextChildTurnOrdinal: 1, + nextAdmissionGeneration: 1, + }); + yield* emitProviderEvent({ + type: "app_thread.created", + driver: OPENCODE_PROVIDER, + appThread: childThread, + }); + yield* emitProviderEvent({ + type: "provider_thread.updated", + driver: OPENCODE_PROVIDER, + providerThread: childProviderThread, + }); + } + const output = toolOutput(part); + if (part.state.status === "completed" && output !== undefined) context.result = output; + const status = toolStatus(part); + const completedAt = toolCompletedAt(part, now); + const subagentStatus: OrchestrationV2Subagent["status"] = + status.item === "failed" + ? "failed" + : status.item === "completed" + ? "completed" + : status.item === "pending" + ? "pending" + : "running"; + const subagent: OrchestrationV2Subagent = { + id: nodeId, + threadId: turn.threadId, + runId: turn.runId, + parentNodeId: turn.rootNodeId, + origin: "provider_native", + createdBy: "agent", + driver: OPENCODE_PROVIDER, + providerInstanceId: options.instanceId, + providerThreadId: context.childProviderThreadId, + childThreadId: context.childThreadId, + nativeTaskRef: nativeItemRef, + prompt, + title, + model: context.model, + status: subagentStatus, + result: context.result, + startedAt: context.startedAt, + completedAt, + updatedAt: now, + }; + yield* emitProviderEvent({ + type: "node.updated", + driver: OPENCODE_PROVIDER, + node: { + id: nodeId, + threadId: turn.threadId, + runId: turn.runId, + parentNodeId: turn.rootNodeId, + rootNodeId: turn.rootNodeId, + kind: "subagent", + status: status.node, + countsForRun: false, + providerThreadId: context.childProviderThreadId ?? state.providerThread.id, + providerTurnId: turn.providerTurnId, + nativeItemRef, + runtimeRequestId: null, + checkpointScopeId: null, + startedAt: context.startedAt, + completedAt, + }, + }); + yield* emitProviderEvent({ + type: "subagent.updated", + driver: OPENCODE_PROVIDER, + subagent, + }); + yield* emitProviderEvent({ + type: "turn_item.updated", + driver: OPENCODE_PROVIDER, + turnItem: { + id: turnItemId, + threadId: turn.threadId, + runId: turn.runId, + nodeId, + providerThreadId: state.providerThread.id, + providerTurnId: turn.providerTurnId, + nativeItemRef, + parentItemId: null, + ordinal: itemOrdinal(turn, part.id), + status: status.item, + title, + startedAt: context.startedAt, + completedAt, + updatedAt: now, + type: "subagent", + subagentId: nodeId, + origin: "provider_native", + driver: OPENCODE_PROVIDER, + providerInstanceId: options.instanceId, + childThreadId: context.childThreadId, + prompt, + result: context.result, + }, + }); + }); + + const emitToolPart = Effect.fnUntraced(function* ( + state: OpenCodeThreadState, + turn: ActiveOpenCodeTurn, + part: ToolPart, + ) { + const normalizedTool = part.tool.toLowerCase(); + if (normalizedTool === "task") { + yield* emitSubagent(state, turn, part); + return; + } + // question.asked carries the respondable semantic item. Projecting + // the implementation tool as well would duplicate it in the UI. + if (normalizedTool === "question") return; + const now = yield* DateTime.now; + const status = toolStatus(part); + const startedAt = toolStartedAt(part, now); + const completedAt = toolCompletedAt(part, now); + const nativeItemRef = providerRef(part.id); + const nodeId = idAllocator.derive.nodeFromProviderItem({ + driver: OPENCODE_PROVIDER, + nativeItemId: part.id, + }); + const turnItemId = idAllocator.derive.turnItemFromProviderItem({ + driver: OPENCODE_PROVIDER, + nativeItemId: part.id, + }); + const base = { + id: turnItemId, + threadId: turn.threadId, + runId: turn.runId, + nodeId, + providerThreadId: state.providerThread.id, + providerTurnId: turn.providerTurnId, + nativeItemRef, + parentItemId: null, + ordinal: itemOrdinal(turn, part.id), + status: status.item, + title: toolTitle(part), + startedAt, + completedAt, + updatedAt: now, + } satisfies Pick< + OrchestrationV2TurnItem, + | "id" + | "threadId" + | "runId" + | "nodeId" + | "providerThreadId" + | "providerTurnId" + | "nativeItemRef" + | "parentItemId" + | "ordinal" + | "status" + | "title" + | "startedAt" + | "completedAt" + | "updatedAt" + >; + const input = toolInput(part); + const output = toolOutput(part); + const isNativeTool = part.tool === "code_search" || part.tool === "apply_patch"; + if (!isNativeTool && part.tool.includes("_") && turn.mcpServerNames === undefined) { + const serverNames = yield* OpenCodeRuntime.runOpenCodeSdk("mcp.status", (signal) => + client.mcp.status(undefined, { signal, throwOnError: true }), + ).pipe( + Effect.timeout("1 second"), + Effect.map((response) => Object.keys(response.data ?? {})), + Effect.catch(() => Effect.succeed(undefined)), + ); + if (serverNames !== undefined) turn.mcpServerNames = serverNames; + } + const matchingServers = turn.mcpServerNames?.filter( + (name) => + !isNativeTool && part.tool.startsWith(`${name.replace(/[^a-zA-Z0-9_-]/g, "_")}_`), + ); + const serverName = matchingServers?.length === 1 ? matchingServers[0] : undefined; + const presentation = + serverName === undefined + ? {} + : mcpToolPresentation({ + serverName, + toolName: part.tool.slice(serverName.replace(/[^a-zA-Z0-9_-]/g, "_").length + 1), + title: toolTitle(part) === part.tool ? undefined : toolTitle(part), + }); + const turnItem: OrchestrationV2TurnItem = matchingServers?.length + ? { + ...base, + type: "dynamic_tool", + ...presentation, + toolName: part.tool, + input, + ...(output === undefined ? {} : { output }), + } + : openCodeToolTurnItem(base, { + name: part.tool, + input, + output, + completedMetadata: + part.state.status === "completed" ? part.state.metadata : undefined, + }); + yield* emitProviderEvent({ + type: "node.updated", + driver: OPENCODE_PROVIDER, + node: { + id: nodeId, + threadId: turn.threadId, + runId: turn.runId, + parentNodeId: turn.rootNodeId, + rootNodeId: turn.rootNodeId, + kind: "tool_call", + status: status.node, + countsForRun: false, + providerThreadId: state.providerThread.id, + providerTurnId: turn.providerTurnId, + nativeItemRef, + runtimeRequestId: null, + checkpointScopeId: null, + startedAt, + completedAt, + }, + }); + yield* emitProviderEvent({ + type: "turn_item.updated", + driver: OPENCODE_PROVIDER, + turnItem, + }); + }); + + const emitTodo = Effect.fnUntraced(function* ( + state: OpenCodeThreadState, + turn: ActiveOpenCodeTurn, + todos: ReadonlyArray, + ) { + const now = yield* DateTime.now; + if (turn.planId === null) { + turn.planId = yield* idAllocator.allocate.plan({ + threadId: turn.threadId, + ...(turn.runId === null ? {} : { runId: turn.runId }), + driver: OPENCODE_PROVIDER, + }); + } + const planId = turn.planId; + const nativeItemId = `${state.nativeSessionId}:todo:${turn.providerTurnId}`; + const nodeId = idAllocator.derive.nodeFromProviderItem({ + driver: OPENCODE_PROVIDER, + nativeItemId, + }); + const turnItemId = idAllocator.derive.turnItemFromProviderItem({ + driver: OPENCODE_PROVIDER, + nativeItemId, + }); + const steps: Array = todos.map((todo, index) => ({ + id: `${nativeItemId}:${index + 1}`, + text: todo.content.trim() || `Todo ${index + 1}`, + status: + todo.status === "completed" + ? "completed" + : todo.status === "in_progress" + ? "running" + : "pending", + })); + const completed = steps.length > 0 && steps.every((step) => step.status === "completed"); + yield* emitProviderEvent({ + type: "node.updated", + driver: OPENCODE_PROVIDER, + node: { + id: nodeId, + threadId: turn.threadId, + runId: turn.runId, + parentNodeId: turn.rootNodeId, + rootNodeId: turn.rootNodeId, + kind: "todo_list", + status: completed ? "completed" : "running", + countsForRun: false, + providerThreadId: state.providerThread.id, + providerTurnId: turn.providerTurnId, + nativeItemRef: providerRef(nativeItemId, "weak"), + runtimeRequestId: null, + checkpointScopeId: null, + startedAt: turn.startedAt, + completedAt: completed ? now : null, + }, + }); + yield* emitProviderEvent({ + type: "plan.updated", + driver: OPENCODE_PROVIDER, + plan: { + id: planId, + threadId: turn.threadId, + runId: turn.runId, + nodeId, + status: completed ? "completed" : "active", + kind: "todo_list", + steps, + }, + }); + yield* emitProviderEvent({ + type: "turn_item.updated", + driver: OPENCODE_PROVIDER, + turnItem: { + id: turnItemId, + threadId: turn.threadId, + runId: turn.runId, + nodeId, + providerThreadId: state.providerThread.id, + providerTurnId: turn.providerTurnId, + nativeItemRef: providerRef(nativeItemId, "weak"), + parentItemId: null, + ordinal: itemOrdinal(turn, nativeItemId), + status: completed ? "completed" : "running", + title: "Todo list", + startedAt: turn.startedAt, + completedAt: completed ? now : null, + updatedAt: now, + type: "todo_list", + planId, + steps, + }, + }); + }); + + const requestQuestions = (request: QuestionRequest) => + request.questions.map((question, index) => ({ + 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) => ({ + label: option.label.trim() || "Option", + description: option.description.trim() || option.label.trim() || "Option", + })), + })); + + const runtimeRequestTurnItem = ( + pending: PendingOpenCodeRequest, + status: OrchestrationV2TurnItem["status"], + completedAt: DateTime.Utc | null, + updatedAt: DateTime.Utc, + ): OrchestrationV2TurnItem => { + const base = { + id: pending.turnItemId, + threadId: pending.turn.threadId, + runId: pending.turn.runId, + nodeId: pending.nodeId, + providerThreadId: pending.state.providerThread.id, + providerTurnId: pending.turn.providerTurnId, + nativeItemRef: providerRef(pending.nativeRequestId), + parentItemId: null, + ordinal: itemOrdinal(pending.turn, pending.nativeRequestId), + status, + startedAt: pending.createdAt, + completedAt, + updatedAt, + }; + if (pending.question !== undefined) { + return { + ...base, + title: "User input", + type: "user_input_request", + requestId: pending.requestId, + questions: requestQuestions(pending.question), + }; + } + const permission = pending.permission; + if (permission === undefined) { + throw protocolError(`OpenCode request ${pending.requestId} has no native payload`); + } + return { + ...base, + title: permission.permission, + type: "approval_request", + requestId: pending.requestId, + requestKind: pending.requestKind === "user_input" ? "command" : pending.requestKind, + prompt: + permission.patterns.length === 0 + ? permission.permission + : permission.patterns.join("\n"), + }; + }; + + const emitRuntimeRequest = Effect.fnUntraced(function* ( + owner: OpenCodeRequestOwner, + nativeRequestId: string, + request: + | { readonly type: "permission"; readonly value: PermissionRequest } + | { readonly type: "question"; readonly value: QuestionRequest }, + ) { + if (pendingRequestsByNativeId.has(nativeRequestId)) return; + const { state, turn, subagent } = owner; + const now = yield* DateTime.now; + const requestId = yield* idAllocator.allocate.runtimeRequest({ + driver: OPENCODE_PROVIDER, + providerTurnId: turn.providerTurnId, + nativeRequestId, + }); + const nodeId = idAllocator.derive.approvalNode({ requestId }); + const turnItemId = idAllocator.derive.approvalTurnItem({ requestId }); + // The tool call belongs to the asking session's turn, not the owner's. + const permissionToolName = + request.type === "permission" && request.value.tool !== undefined + ? threads + .get(request.value.sessionID) + ?.activeTurn?.toolNamesByCallId.get(request.value.tool.callID) + : undefined; + const permissionRequestKind = + request.type === "permission" + ? openCodePermissionRequestKind(request.value.permission, permissionToolName) + : undefined; + const requestKind: OrchestrationV2RuntimeRequest["kind"] = + permissionRequestKind ?? "user_input"; + const pending: PendingOpenCodeRequest = { + requestId, + nativeRequestId, + nativeSessionId: request.value.sessionID, + turn, + state, + nodeId, + parentNodeId: subagent?.nodeId ?? turn.rootNodeId, + turnItemId, + requestKind, + createdAt: now, + ...(request.type === "permission" + ? { permission: request.value } + : { question: request.value }), + }; + pendingRequests.set(String(requestId), pending); + pendingRequestsByNativeId.set(nativeRequestId, pending); + const runtimeRequest: OrchestrationV2RuntimeRequest = { + id: requestId, + nodeId, + providerTurnId: turn.providerTurnId, + nativeRequestRef: providerRef(nativeRequestId), + kind: requestKind, + status: "pending", + responseCapability: { + type: "live", + providerSessionId: inputProviderSessionId, + }, + createdAt: now, + resolvedAt: null, + }; + state.runtimeRequests.set(String(requestId), runtimeRequest); + yield* emitProviderEvent({ + type: "node.updated", + driver: OPENCODE_PROVIDER, + node: { + id: nodeId, + threadId: turn.threadId, + runId: turn.runId, + parentNodeId: pending.parentNodeId, + rootNodeId: turn.rootNodeId, + kind: request.type === "question" ? "user_input_request" : "approval_request", + status: "waiting", + countsForRun: false, + providerThreadId: state.providerThread.id, + providerTurnId: turn.providerTurnId, + nativeItemRef: providerRef(nativeRequestId), + runtimeRequestId: requestId, + checkpointScopeId: null, + startedAt: now, + completedAt: null, + }, + }); + yield* emitProviderEvent({ + type: "runtime_request.updated", + driver: OPENCODE_PROVIDER, + threadId: turn.threadId, + runtimeRequest, + }); + yield* emitProviderEvent({ + type: "turn_item.updated", + driver: OPENCODE_PROVIDER, + turnItem: runtimeRequestTurnItem(pending, "waiting", null, now), + }); + yield* updateProviderSession("waiting", null); + }); + + const resolveRuntimeRequest = Effect.fnUntraced(function* ( + nativeRequestId: string, + status: "resolved" | "cancelled", + ) { + const pending = pendingRequestsByNativeId.get(nativeRequestId); + if (pending === undefined) return; + const now = yield* DateTime.now; + const current = pending.state.runtimeRequests.get(String(pending.requestId)); + if (current !== undefined) { + const resolved: OrchestrationV2RuntimeRequest = { + ...current, + status, + resolvedAt: now, + }; + pending.state.runtimeRequests.set(String(pending.requestId), resolved); + yield* emitProviderEvent({ + type: "runtime_request.updated", + driver: OPENCODE_PROVIDER, + threadId: pending.turn.threadId, + runtimeRequest: resolved, + }); + } + yield* emitProviderEvent({ + type: "node.updated", + driver: OPENCODE_PROVIDER, + node: { + id: pending.nodeId, + threadId: pending.turn.threadId, + runId: pending.turn.runId, + parentNodeId: pending.parentNodeId, + rootNodeId: pending.turn.rootNodeId, + kind: pending.question === undefined ? "approval_request" : "user_input_request", + status: status === "resolved" ? "completed" : "cancelled", + countsForRun: false, + providerThreadId: pending.state.providerThread.id, + providerTurnId: pending.turn.providerTurnId, + nativeItemRef: providerRef(nativeRequestId), + runtimeRequestId: pending.requestId, + checkpointScopeId: null, + startedAt: pending.createdAt, + completedAt: now, + }, + }); + yield* emitProviderEvent({ + type: "turn_item.updated", + driver: OPENCODE_PROVIDER, + turnItem: runtimeRequestTurnItem( + pending, + status === "resolved" ? "completed" : "cancelled", + now, + now, + ), + }); + pendingRequests.delete(String(pending.requestId)); + pendingRequestsByNativeId.delete(nativeRequestId); + const hasOtherPending = Array.from(pendingRequests.values()).some( + (candidate) => candidate.turn.isRoot, + ); + if (!hasOtherPending) yield* updateProviderSession("running", null); + }); + + /** Resolve the thread state a session belongs to: its own, or for a + * child not registered yet, the nearest known ancestor found by walking + * the native parent chain. Registers every hop so later requests from + * the same child resolve without another lookup. */ + const resolveSessionOwner = Effect.fnUntraced(function* (sessionId: string) { + const known = threads.get(sessionId) ?? relatedSessionOwners.get(sessionId); + if (known !== undefined) return known; + let cursor = sessionId; + const hops: string[] = []; + for (let depth = 0; depth < 5; depth += 1) { + const response = yield* sdkCall("session.get", { sessionID: cursor }, () => + client.session.get({ sessionID: cursor }), + ).pipe(Effect.option); + const info = Option.getOrUndefined(response)?.data; + const parentId = info?.parentID; + if (parentId === undefined) return undefined; + hops.push(cursor); + const owner = threads.get(parentId) ?? relatedSessionOwners.get(parentId); + if (owner !== undefined) { + for (const hop of hops) relatedSessionOwners.set(hop, owner); + return owner; + } + cursor = parentId; + } + return undefined; + }); + + /** Every request is asked on the top-level thread and its active turn, + * under the subagent that leads to the asking session, because native + * subagent threads are hidden from the sidebar. A child's request can + * arrive before the task part or session.created event that reveals + * its relation to a thread. The first resolution attempt runs inline + * (the replayable path); if the relation or the owning turn is not + * established yet, a short forked backoff keeps trying instead of + * dropping the request. */ + const routeRuntimeRequest = Effect.fnUntraced(function* ( + nativeRequestId: string, + sessionId: string, + request: + | { readonly type: "permission"; readonly value: PermissionRequest } + | { readonly type: "question"; readonly value: QuestionRequest }, + ) { + if (pendingChildRequestRoutes.has(nativeRequestId)) return; + const attempt = Effect.gen(function* () { + if ( + settledNativeRequestIds.has(nativeRequestId) || + pendingRequestsByNativeId.has(nativeRequestId) + ) { + return true; + } + const state = yield* resolveSessionOwner(sessionId); + const owner = state === undefined ? undefined : topLevelRequestOwner(state); + if (owner === undefined) return false; + yield* emitRuntimeRequest(owner, nativeRequestId, request); + return true; + }); + if (yield* attempt) return; + pendingChildRequestRoutes.add(nativeRequestId); + yield* Effect.gen(function* () { + for (let retry = 0; retry < 5; retry += 1) { + yield* Effect.sleep(Duration.millis(Math.min(200 * 2 ** retry, 2_000))); + if (yield* attempt) return; + } + }).pipe( + Effect.ensuring(Effect.sync(() => pendingChildRequestRoutes.delete(nativeRequestId))), + Effect.forkIn(scope), + ); + }); + + const finalizeTurn = Effect.fnUntraced(function* ( + state: OpenCodeThreadState, + turn: ActiveOpenCodeTurn, + status: TerminalTurnStatus, + terminal?: { + readonly failure?: OrchestrationV2ProviderFailure; + readonly threadDisposition?: "reusable" | "broken"; + }, + ) { + if (turn.finalized) return; + if (nativeStreamFailure !== null) { + status = "failed"; + terminal = { failure: nativeStreamFailure, threadDisposition: "broken" }; + } + turn.finalized = true; + const completedAt = yield* DateTime.now; + for (const part of turn.parts.values()) { + if (part.type === "text" || part.type === "reasoning") { + yield* emitTextPart(state, turn, part, true); + } + } + for (const pending of Array.from(pendingRequests.values())) { + if ( + pending.turn.providerTurnId === turn.providerTurnId || + pending.nativeSessionId === state.nativeSessionId + ) { + yield* resolveRuntimeRequest(pending.nativeRequestId, "cancelled"); + } + } + yield* emitProviderTurn(state, turn, status, completedAt); + const threadDisposition = terminal?.threadDisposition ?? "reusable"; + yield* updateProviderThread(state, { + status: turn.isRoot ? "active" : threadDisposition === "broken" ? "error" : "idle", + nativeConversationHeadRef: + turn.nativeUserMessageId === null + ? state.providerThread.nativeConversationHeadRef + : providerRef(turn.nativeUserMessageId, "weak"), + }); + state.activeTurn = null; + if (!turn.isRoot) { + yield* emitProviderEvent({ + type: "node.updated", + driver: OPENCODE_PROVIDER, + node: { + id: turn.rootNodeId, + threadId: turn.threadId, + runId: null, + parentNodeId: null, + rootNodeId: turn.rootNodeId, + kind: "root_turn", + status, + countsForRun: false, + providerThreadId: state.providerThread.id, + providerTurnId: turn.providerTurnId, + nativeItemRef: providerRef(state.nativeSessionId), + runtimeRequestId: null, + checkpointScopeId: null, + startedAt: turn.startedAt, + completedAt, + }, + }); + return; + } + const anotherTurnIsActive = Array.from(threads.values()).some( + (candidate) => candidate.activeTurn?.isRoot === true, + ); + yield* updateProviderSession( + anotherTurnIsActive ? "running" : status === "failed" ? "error" : "ready", + status === "failed" ? sessionEntity.lastError : null, + ); + yield* emitProviderEvent( + status === "failed" + ? { + type: "turn.terminal", + driver: OPENCODE_PROVIDER, + providerThreadId: turn.providerThreadId, + providerTurnId: turn.providerTurnId, + runOrdinal: turn.runOrdinal, + failureItemOrdinal: itemOrdinal(turn, `terminal-failure:${turn.providerTurnId}`), + status, + failure: + terminal?.failure ?? + makeProviderFailure({ + message: sessionEntity.lastError ?? undefined, + class: "provider_error", + }), + threadDisposition, + } + : { + type: "turn.terminal", + driver: OPENCODE_PROVIDER, + providerThreadId: turn.providerThreadId, + providerTurnId: turn.providerTurnId, + runOrdinal: turn.runOrdinal, + status, + failure: null, + threadDisposition, + }, + ); + }); + + const promptAdmissionIsCurrent = ( + state: OpenCodeThreadState, + turn: ActiveOpenCodeTurn, + generation: number, + ) => + state.activeTurn === turn && + !turn.finalized && + !turn.interrupted && + turn.admissionPending && + turn.admissionGeneration === generation; + + const runPromptAdmissionReconciliation = Effect.fnUntraced(function* ( + state: OpenCodeThreadState, + turn: ActiveOpenCodeTurn, + generation: number, + ) { + while (promptAdmissionIsCurrent(state, turn, generation)) { + const status = yield* reconcileOpenCodePromptAdmissionStatus( + turn, + generation, + sdkCall("session.status", { sessionID: state.nativeSessionId, generation }, () => + client.session.status(), + ).pipe( + Effect.match({ + onFailure: () => "unknown" as const, + onSuccess: (response) => { + const statuses = unwrapData("session.status", response); + const sessionStatus = statuses[state.nativeSessionId]; + return sessionStatus === undefined || sessionStatus.type === "idle" + ? ("idle" as const) + : ("busy" as const); + }, + }), + ), + ); + if ( + state.activeTurn !== turn || + status === "stale" || + turn.finalized || + turn.interrupted || + turn.admissionGeneration !== generation + ) { + return; + } + if (status === "idle") { + yield* finalizeTurn(state, turn, "completed"); + return; + } + if (status === "busy") return; + yield* Effect.sleep("250 millis"); + } + }); + + const reconcilePromptAdmission = Effect.fnUntraced(function* ( + state: OpenCodeThreadState, + turn: ActiveOpenCodeTurn, + ) { + const generation = turn.admissionGeneration; + if ( + !promptAdmissionIsCurrent(state, turn, generation) || + turn.admissionReconciliationGeneration === generation + ) { + return; + } + turn.admissionReconciliationGeneration = generation; + yield* runPromptAdmissionReconciliation(state, turn, generation).pipe( + Effect.ensuring( + Effect.sync(() => { + if (turn.admissionReconciliationGeneration === generation) { + turn.admissionReconciliationGeneration = null; + } + }), + ), + Effect.forkIn(scope), + ); + }); + + const createChildTurn = Effect.fnUntraced(function* ( + state: OpenCodeThreadState, + message: Extract, + ) { + if (state.appThread === null || state.parentSubagent === null) return null; + const now = yield* DateTime.now; + const startedAt = dateTimeFromEpoch(message.time.created, now); + const rootNodeId = idAllocator.derive.nodeFromProviderItem({ + driver: OPENCODE_PROVIDER, + nativeItemId: `${state.nativeSessionId}:root:${message.id}`, + }); + const providerTurnId = idAllocator.derive.providerTurn({ + driver: OPENCODE_PROVIDER, + nativeTurnId: message.id, + }); + const providerTurn: OrchestrationV2ProviderTurn = { + id: providerTurnId, + providerThreadId: state.providerThread.id, + nodeId: rootNodeId, + runAttemptId: null, + nativeTurnRef: providerRef(message.id, "weak"), + ordinal: state.nextChildTurnOrdinal++, + status: "running", + startedAt, + completedAt: null, + }; + const turn: ActiveOpenCodeTurn = { + isRoot: false, + threadId: state.appThread.id, + runId: null, + rootNodeId, + appThread: state.appThread, + modelSelection: state.appThread.modelSelection, + runtimePolicy: state.parentSubagent.parentTurn.runtimePolicy, + providerTurnId, + providerThreadId: providerTurn.providerThreadId, + providerTurnOrdinal: providerTurn.ordinal, + runOrdinal: state.parentSubagent.parentTurn.runOrdinal, + runAttemptId: null, + startedAt, + itemOrdinals: new Map(), + usage: makeOpenCodeTurnTokenUsageAccumulator(), + parts: new Map(), + partIdsByMessage: new Map(), + toolNamesByCallId: new Map(), + providerTurn, + nextItemOrdinal: 1, + nativeUserMessageId: message.id, + admissionMessageId: null, + interrupted: false, + finalized: false, + planId: null, + admissionGeneration: 0, + admissionReconciliationGeneration: null, + admissionPending: false, + admissionAccepted: true, + admissionMessageObserved: true, + idleDuringAdmission: false, + admissionSettled: Deferred.makeUnsafe(), + admissionAbortController: null, + }; + state.activeTurn = turn; + state.providerTurns.set(String(providerTurnId), providerTurn); + yield* emitProviderEvent({ + type: "node.updated", + driver: OPENCODE_PROVIDER, + node: { + id: rootNodeId, + threadId: turn.threadId, + runId: null, + parentNodeId: null, + rootNodeId, + kind: "root_turn", + status: "running", + countsForRun: false, + providerThreadId: state.providerThread.id, + providerTurnId, + nativeItemRef: providerRef(message.id, "weak"), + runtimeRequestId: null, + checkpointScopeId: null, + startedAt, + completedAt: null, + }, + }); + yield* emitProviderTurn(state, turn, "running", null); + return turn; + }); + + const projectChildUserPart = Effect.fnUntraced(function* ( + state: OpenCodeThreadState, + turn: ActiveOpenCodeTurn, + part: Extract, + ) { + const now = yield* DateTime.now; + const messageId = idAllocator.derive.messageFromProviderItem({ + driver: OPENCODE_PROVIDER, + nativeItemId: part.messageID, + }); + const turnItemId = idAllocator.derive.turnItemFromProviderItem({ + driver: OPENCODE_PROVIDER, + nativeItemId: part.messageID, + }); + const projected: OrchestrationV2ConversationMessage = { + createdBy: "agent", + creationSource: "provider", + id: messageId, + threadId: turn.threadId, + runId: null, + nodeId: turn.rootNodeId, + role: "user", + text: part.text, + attachments: [], + streaming: false, + createdAt: turn.startedAt, + updatedAt: now, + }; + state.messages.set(String(messageId), projected); + yield* emitProviderEvent({ + type: "message.updated", + driver: OPENCODE_PROVIDER, + message: projected, + }); + yield* emitProviderEvent({ + type: "turn_item.updated", + driver: OPENCODE_PROVIDER, + turnItem: { + createdBy: "agent", + creationSource: "provider", + id: turnItemId, + threadId: turn.threadId, + runId: null, + nodeId: turn.rootNodeId, + providerThreadId: state.providerThread.id, + providerTurnId: turn.providerTurnId, + nativeItemRef: providerRef(part.messageID), + parentItemId: null, + ordinal: itemOrdinal(turn, part.messageID), + status: "completed", + title: null, + startedAt: projected.createdAt, + completedAt: now, + updatedAt: now, + type: "user_message", + messageId, + inputIntent: "turn_start", + text: part.text, + attachments: [], + }, + }); + }); + + const emitCompactionItem = Effect.fn("OpenCodeAdapterV2.emitCompactionItem")(function* ( + state: OpenCodeThreadState, + turn: ActiveOpenCodeTurn, + ) { + const nativeItemId = `${turn.providerTurnId}:compaction`; + if (turn.itemOrdinals.has(nativeItemId)) return; + const now = yield* DateTime.now; + yield* emitProviderEvent({ + type: "turn_item.updated", + driver: OPENCODE_PROVIDER, + turnItem: { + id: idAllocator.derive.turnItemFromProviderItem({ + driver: OPENCODE_PROVIDER, + nativeItemId, + }), + threadId: turn.threadId, + runId: turn.runId, + nodeId: turn.rootNodeId, + providerThreadId: state.providerThread.id, + providerTurnId: turn.providerTurnId, + nativeItemRef: providerRef(nativeItemId, "weak"), + parentItemId: null, + ordinal: itemOrdinal(turn, nativeItemId), + type: "compaction", + driver: OPENCODE_PROVIDER, + status: "completed", + title: "Context compacted", + startedAt: turn.startedAt, + completedAt: now, + updatedAt: now, + }, + }); + }); + + const handleMessageUpdated = Effect.fnUntraced(function* ( + event: Extract, + ) { + const state = threads.get(event.properties.sessionID); + if (state === undefined) return; + const message = event.properties.info; + state.messageRoles.set(message.id, message.role); + if (message.role === "assistant") { + const usage = state.activeTurn?.usage; + if (usage === undefined) return; + const prior = usage.assistantOwnershipByMessageId.get(message.id); + const ownership = + prior !== undefined && prior !== "unknown" + ? prior + : !message.parentID + ? "unknown" + : usage.promptMessageIds.has(message.parentID) + ? "owned" + : "other"; + usage.assistantOwnershipByMessageId.set(message.id, ownership); + if (ownership !== "unknown") { + if (ownership === "owned") { + for (const step of usage.unresolvedStepsByMessageId.get(message.id)?.values() ?? + []) { + accumulateOpenCodeStepUsage(usage, step); + } + } + usage.unresolvedStepsByMessageId.delete(message.id); + } + return; + } + const commandReceipt = commandReceipts.get(message.id); + if (commandReceipt) yield* Deferred.succeed(commandReceipt, undefined); + const isNewUserMessage = !state.userMessageIds.includes(message.id); + if (isNewUserMessage) state.userMessageIds.push(message.id); + let turn = state.activeTurn; + if (turn === null && state.parentSubagent !== null && isNewUserMessage) { + turn = yield* createChildTurn(state, message); + } + const matchesAdmission = + turn !== null && + (turn.admissionMessageId === null || turn.admissionMessageId === message.id); + if (turn !== null && matchesAdmission) turn.usage.promptMessageIds.add(message.id); + if (turn !== null && matchesAdmission && turn.nativeUserMessageId === null) { + turn.nativeUserMessageId = message.id; + yield* emitProviderTurn(state, turn, "running", null); + } + if (turn !== null && matchesAdmission && turn.admissionPending) { + if (advanceOpenCodePromptAdmission(turn, "user-message") === "reconcile-idle") { + yield* reconcilePromptAdmission(state, turn); + } + } + }); + + const handlePartUpdated = Effect.fnUntraced(function* ( + event: Extract, + ) { + const part = event.properties.part; + const state = threads.get(part.sessionID); + const turn = state?.activeTurn; + if (state === undefined || turn === null || turn === undefined || turn.finalized) return; + if (part.type === "text" && state.messageRoles.get(part.messageID) === "user") { + if (!turn.isRoot) yield* projectChildUserPart(state, turn, part); + return; + } + if (part.type === "step-finish") { + const usage = turn.usage; + const ownership = usage.assistantOwnershipByMessageId.get(part.messageID); + if (ownership === "owned") accumulateOpenCodeStepUsage(usage, part); + else if ( + ownership === "unknown" || + (ownership === undefined && !state.messageRoles.has(part.messageID)) + ) { + const steps = + usage.unresolvedStepsByMessageId.get(part.messageID) ?? + new Map(); + steps.set(part.id, { id: part.id, tokens: part.tokens }); + usage.unresolvedStepsByMessageId.set(part.messageID, steps); + } + return; + } + if (part.type === "tool") { + // Approval routing needs the tool name, without retaining its input and output. + turn.toolNamesByCallId.set(part.callID, part.tool); + } else { + turn.parts.set(part.id, part); + const ids = turn.partIdsByMessage.get(part.messageID) ?? new Set(); + ids.add(part.id); + turn.partIdsByMessage.set(part.messageID, ids); + } + switch (part.type) { + case "text": + case "reasoning": + yield* emitTextPart(state, turn, part); + return; + case "tool": + yield* emitToolPart(state, turn, part); + return; + default: + return; + } + }); + + const handlePartDelta = Effect.fnUntraced(function* ( + event: Extract, + ) { + if (event.properties.field !== "text") return; + const state = threads.get(event.properties.sessionID); + const turn = state?.activeTurn; + const current = turn?.parts.get(event.properties.partID); + if ( + state === undefined || + turn === null || + turn === undefined || + current === undefined || + (current.type !== "text" && current.type !== "reasoning") + ) { + return; + } + const updated = { ...current, text: current.text + event.properties.delta }; + turn.parts.set(updated.id, updated); + yield* emitTextPart(state, turn, updated); + }); + + const handleAssistantCompleted = Effect.fnUntraced(function* ( + event: Extract, + ) { + const message = event.properties.info; + if (message.role !== "assistant" || message.time.completed === undefined) return; + const state = threads.get(message.sessionID); + const turn = state?.activeTurn; + if (state === undefined || turn === null || turn === undefined) return; + // Some OpenCode versions ignore the client-provided message ID. A + // completed assistant message is definitive admission evidence, so + // let the following idle event settle the turn without weakening + // the stale-user-message guard. + advanceOpenCodePromptAdmission(turn, "assistant-completed"); + for (const partId of turn.partIdsByMessage.get(message.id) ?? []) { + const part = turn.parts.get(partId); + if (part?.type === "text" || part?.type === "reasoning") { + yield* emitTextPart(state, turn, part, true); + } + } + }); + + const handleEvent = Effect.fnUntraced(function* (event: OpenCodeEvent) { + yield* logProtocolEvent({ + direction: "incoming", + messageKind: "notification", + method: event.type, + payload: event, + }); + switch (event.type) { + case "server.connected": + if (hasConnected) { + for (const state of threads.values()) { + if (state.activeTurn !== null) state.activeTurn.usage.complete = false; + } + } + hasConnected = true; + return; + case "message.updated": + yield* handleMessageUpdated(event); + yield* handleAssistantCompleted(event); + return; + case "session.compacted": { + const state = threads.get(event.properties.sessionID); + if (state?.activeTurn !== undefined && state.activeTurn !== null) { + yield* emitCompactionItem(state, state.activeTurn); + } + return; + } + case "message.part.updated": + yield* handlePartUpdated(event); + return; + case "message.part.delta": + yield* handlePartDelta(event); + return; + case "todo.updated": { + const state = threads.get(event.properties.sessionID); + if (state?.activeTurn !== null && state?.activeTurn !== undefined) { + yield* emitTodo(state, state.activeTurn, event.properties.todos); + } + return; + } + case "permission.asked": + yield* routeRuntimeRequest(event.properties.id, event.properties.sessionID, { + type: "permission", + value: event.properties, + }); + return; + case "question.asked": + yield* routeRuntimeRequest(event.properties.id, event.properties.sessionID, { + type: "question", + value: event.properties, + }); + return; + case "permission.replied": + case "question.replied": + rememberSettledRequest(event.properties.requestID); + yield* resolveRuntimeRequest(event.properties.requestID, "resolved"); + return; + case "question.rejected": + rememberSettledRequest(event.properties.requestID); + yield* resolveRuntimeRequest(event.properties.requestID, "cancelled"); + return; + case "session.created": + case "session.updated": { + const info = event.properties.info; + if (info.parentID !== undefined && !threads.has(info.id)) { + const owner = threads.get(info.parentID) ?? relatedSessionOwners.get(info.parentID); + if (owner !== undefined) { + relatedSessionOwners.set(info.id, owner); + } + } + return; + } + case "session.deleted": + relatedSessionOwners.delete(event.properties.info.id); + busySessionIds.delete(event.properties.info.id); + return; + case "session.status": { + const sessionId = event.properties.sessionID; + switch (event.properties.status.type) { + case "busy": + case "retry": + if (threads.has(sessionId) || relatedSessionOwners.has(sessionId)) { + busySessionIds.add(sessionId); + } + break; + case "idle": + busySessionIds.delete(sessionId); + break; + } + const state = threads.get(sessionId); + if (state === undefined) return; + if (event.properties.status.type === "busy") { + yield* updateProviderThread(state, { status: "active" }); + return; + } + if (event.properties.status.type === "idle" && state.activeTurn !== null) { + if (state.activeTurn.admissionPending) { + advanceOpenCodePromptAdmission(state.activeTurn, "idle"); + return; + } + yield* finalizeTurn( + state, + state.activeTurn, + state.activeTurn.interrupted ? "interrupted" : "completed", + ); + } + return; + } + case "session.idle": { + busySessionIds.delete(event.properties.sessionID); + const state = threads.get(event.properties.sessionID); + if (state?.activeTurn !== null && state?.activeTurn !== undefined) { + if (state.activeTurn.admissionPending) { + advanceOpenCodePromptAdmission(state.activeTurn, "idle"); + return; + } + yield* finalizeTurn( + state, + state.activeTurn, + state.activeTurn.interrupted ? "interrupted" : "completed", + ); + } + return; + } + case "session.error": { + const states = + event.properties.sessionID === undefined + ? Array.from(threads.values()).filter((state) => state.activeTurn !== null) + : [threads.get(event.properties.sessionID)].filter( + (state): state is OpenCodeThreadState => state !== undefined, + ); + const message = openCodeErrorMessage(event); + if ( + !isMessageAbortedError(event) && + (event.properties.sessionID === undefined || + states.some((state) => state.parentSubagent === null)) + ) { + yield* updateProviderSession("error", message); + } + for (const state of states) { + if (state.activeTurn !== null) { + yield* finalizeTurn( + state, + state.activeTurn, + terminalStatusForError(event, state.activeTurn), + { + failure: makeProviderFailure({ + message, + code: event.properties.error?.name ?? null, + class: "provider_error", + }), + threadDisposition: + event.properties.sessionID === undefined ? "broken" : "reusable", + }, + ); + } + } + return; + } + default: + return; + } + }); + + const inputProviderSessionId = input.providerSessionId; + + const subscription = yield* sdkCall("event.subscribe", {}, () => + client.event.subscribe(undefined, { signal: abortController.signal }), + ); + yield* Scope.addFinalizer( + scope, + Effect.sync(() => abortController.abort()), + ); + yield* Stream.fromAsyncIterable( + subscription.stream, + (cause) => + new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: "event.subscribe", + detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause), + cause, + }), + ).pipe( + Stream.runForEach(handleEvent), + Effect.exit, + Effect.flatMap((exit) => + Effect.gen(function* () { + // No further status can clear a busy session once the stream ends. + busySessionIds.clear(); + if (closing || abortController.signal.aborted) return; + const detail = Exit.isSuccess(exit) + ? "OpenCode event stream ended unexpectedly." + : OpenCodeRuntime.openCodeRuntimeErrorDetail(Cause.squash(exit.cause)); + nativeStreamFailure = makeProviderFailure({ + message: detail, + class: "transport_error", + }); + yield* updateProviderSession("error", detail); + for (const state of threads.values()) { + if (state.activeTurn !== null) + yield* finalizeTurn(state, state.activeTurn, "failed", { + failure: makeProviderFailure({ message: detail, class: "transport_error" }), + threadDisposition: "broken", + }); + } + yield* Queue.end(events); + }), + ), + Effect.forkIn(scope), + ); + + if (!connection.external && connection.exitCode !== null) { + yield* connection.exitCode.pipe( + Effect.flatMap((code) => + abortController.signal.aborted + ? Effect.void + : Effect.gen(function* () { + const detail = `OpenCode server exited unexpectedly (${code}).`; + yield* updateProviderSession("error", detail); + for (const state of threads.values()) { + if (state.activeTurn !== null) { + yield* finalizeTurn(state, state.activeTurn, "failed", { + failure: makeProviderFailure({ + message: detail, + class: "transport_error", + }), + threadDisposition: "broken", + }); + } + } + }), + ), + Effect.forkIn(scope), + ); + } + + if (connection.external) { + yield* Scope.addFinalizer( + scope, + Effect.suspend(() => + Effect.forEach( + Array.from(threads.values()).filter((state) => state.parentSubagent === null), + (state) => + sdkCall("session.abort", { sessionID: state.nativeSessionId }, (signal) => + client.session.abort({ sessionID: state.nativeSessionId }, { signal }), + ).pipe( + Effect.timeout("1 second"), + Effect.ignore({ log: true }), + Effect.andThen( + abortDescendants(state.nativeSessionId).pipe( + Effect.timeout("1 second"), + Effect.ignore({ log: true }), + ), + ), + ), + { concurrency: 8, discard: true }, + ), + ), + ); + } + + yield* Scope.addFinalizer( + scope, + Effect.sync(() => { + closing = true; + }), + ); + + const registerThread = ( + nativeSession: OpenCodeSession, + providerThread: OrchestrationV2ProviderThread, + appThread: OrchestrationV2AppThread | null, + ): OpenCodeThreadState => { + const existing = threads.get(nativeSession.id); + if (existing !== undefined) { + existing.providerThread = providerThread; + if (appThread !== null) existing.appThread = appThread; + return existing; + } + const state: OpenCodeThreadState = { + nativeSessionId: nativeSession.id, + providerThread, + appThread, + activeTurn: null, + providerTurns: new Map(), + messages: new Map(), + runtimeRequests: new Map(), + messageRoles: new Map(), + userMessageIds: [], + parentSubagent: subagentsByChildSessionId.get(nativeSession.id) ?? null, + nextChildTurnOrdinal: 1, + nextAdmissionGeneration: 1, + }; + threads.set(nativeSession.id, state); + return state; + }; + + const resolvePromptParts = (turnInput: ProviderAdapter.ProviderAdapterV2TurnInput) => { + const text = providerMessageTextWithAttachmentPaths({ + text: turnInput.message.text, + attachments: turnInput.message.attachments, + resolveAttachmentPath: host.resolveAttachmentPath, + }).trim(); + const files = OpenCodeRuntime.toOpenCodeFileParts({ + attachments: turnInput.message.attachments, + resolveAttachmentPath: host.resolveAttachmentPath, + }); + if (text.length === 0 && files.length === 0) { + throw protocolError("OpenCode turns require text or at least one valid attachment"); + } + return [...(text.length === 0 ? [] : [{ type: "text" as const, text }]), ...files]; + }; + + const readSnapshot = Effect.fnUntraced(function* ( + providerThread: OrchestrationV2ProviderThread, + ) { + const sessionId = nativeThreadId(providerThread); + const sessionResponse = yield* sdkCall("session.get", { sessionID: sessionId }, () => + client.session.get({ sessionID: sessionId }), + ); + const nativeSession = unwrapData("session.get", sessionResponse); + const response = yield* sdkCall("session.messages", { sessionID: sessionId }, () => + client.session.messages({ sessionID: sessionId }), + ); + const nativeMessages = []; + for (const entry of unwrapData("session.messages", response)) { + // OpenCode retains reverted records in session.messages. Its + // revert marker identifies the first removed message, so stop + // before it or a restart resurrects rolled-back assistant work. + if (entry.info.id === nativeSession.revert?.messageID) break; + nativeMessages.push(entry); + } + const state = threads.get(sessionId); + const snapshotNow = yield* DateTime.now; + const messages: Array = nativeMessages.flatMap( + ({ info, parts }) => { + const text = parts + .filter( + (part): part is Extract => part.type === "text", + ) + .filter((part) => part.ignored !== true && part.synthetic !== true) + .map((part) => part.text) + .join("\n"); + if (text.length === 0) return []; + const createdAt = dateTimeFromEpoch(info.time.created, snapshotNow); + return [ + { + createdBy: info.role === "user" ? "user" : "agent", + creationSource: "provider", + id: idAllocator.derive.messageFromProviderItem({ + driver: OPENCODE_PROVIDER, + nativeItemId: info.id, + }), + threadId: providerThread.appThreadId ?? input.threadId, + runId: null, + nodeId: null, + role: info.role, + text, + attachments: [], + streaming: false, + createdAt, + updatedAt: + info.role === "assistant" + ? dateTimeFromEpoch(info.time.completed, createdAt) + : createdAt, + }, + ]; + }, + ); + const lastUser = nativeMessages.findLast(({ info }) => info.role === "user")?.info.id; + return { + providerThread: { + ...providerThread, + providerSessionId: input.providerSessionId, + nativeConversationHeadRef: + lastUser === undefined ? null : providerRef(lastUser, "weak"), + status: "idle" as const, + updatedAt: snapshotNow, + }, + providerTurns: state === undefined ? [] : [...state.providerTurns.values()], + messages, + runtimeRequests: state === undefined ? [] : [...state.runtimeRequests.values()], + providerPayload: nativeMessages, + }; + }); + + // Native commands wait for generation, unlike promptAsync. A user-message + // receipt admits the run while the scoped request continues in the background. + const submitPrompt = Effect.fn("OpenCode.submitPrompt")(function* ( + state: OpenCodeThreadState, + turn: ActiveOpenCodeTurn, + payload: Parameters[0] & { + messageID: string; + sessionID: string; + }, + abortController: AbortController, + ) { + const text = payload.parts + ?.filter((part) => part.type === "text") + .map((part) => part.text) + .join("\n") + .trim(); + const match = text?.match(/^\/([^\s/]+)(?:\s+([\s\S]*))?$/); + const command = match + ? (yield* OpenCodeRuntime.loadOpenCodeCommands(client).pipe( + Effect.timeout("10 seconds"), + Effect.orElseSucceed(() => []), + )).find((entry) => entry.name === match[1]) + : undefined; + if (!command) { + return yield* sdkCall("session.promptAsync", payload, (signal) => + client.session.promptAsync(payload, { + signal: AbortSignal.any([signal, abortController.signal]), + }), + ).pipe(Effect.asVoid); + } + const receipt = Deferred.makeUnsafe(); + commandReceipts.set(payload.messageID, receipt); + const controllers = + commandControllers.get(payload.sessionID) ?? new Set(); + controllers.add(abortController); + commandControllers.set(payload.sessionID, controllers); + const generation = turn.admissionGeneration; + const commandPayload = { + sessionID: payload.sessionID, + messageID: payload.messageID, + command: command.name, + arguments: match?.[2] ?? "", + ...(payload.model + ? { model: `${payload.model.providerID}/${payload.model.modelID}` } + : {}), + ...(payload.agent ? { agent: payload.agent } : {}), + ...(payload.variant ? { variant: payload.variant } : {}), + parts: payload.parts?.filter((part) => part.type === "file") ?? [], + }; + const request = yield* sdkCall("session.command", commandPayload, (signal) => + client.session.command(commandPayload, { + signal: AbortSignal.any([signal, abortController.signal]), + }), + ).pipe( + Effect.asVoid, + Effect.tapError((cause) => + abortController.signal.aborted || + turn.finalized || + turn.admissionGeneration !== generation + ? Effect.void + : finalizeTurn(state, turn, "failed", { + failure: makeProviderFailure({ cause, class: "provider_error" }), + }), + ), + Effect.ensuring( + Effect.sync(() => { + commandReceipts.delete(payload.messageID); + controllers.delete(abortController); + if (controllers.size === 0) commandControllers.delete(payload.sessionID); + }), + ), + Effect.forkIn(scope), + ); + yield* Effect.raceFirst(Fiber.join(request), Deferred.await(receipt)).pipe( + Effect.timeout("10 seconds"), + Effect.catchTags({ + TimeoutError: (cause) => { + const error = new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: "session.command", + detail: "OpenCode command admission did not complete within 10 seconds.", + cause, + }); + abortController.abort(); + return finalizeTurn(state, turn, "failed", { + failure: makeProviderFailure({ cause: error, class: "provider_error" }), + }).pipe(Effect.andThen(Effect.fail(error))); + }, + }), + ); + }); + + const runtimeSession: ProviderAdapter.ProviderAdapterV2SessionRuntime = { + instanceId: options.instanceId, + driver: OPENCODE_PROVIDER, + providerSessionId: input.providerSessionId, + providerSession: sessionEntity, + events: Stream.fromEffectRepeat(Queue.take(events)), + hasPendingBackgroundWork: Effect.sync(() => busySessionIds.size > 0), + ensureThread: (threadInput) => + Effect.gen(function* () { + // Only a row that already carries a native session can be + // resumed; a placeholder without one still needs session.create. + if (threadInput.existingProviderThread?.nativeThreadRef != null) { + return yield* runtimeSession.resumeThread({ + providerThread: threadInput.existingProviderThread, + }); + } + // No title: OpenCode generates one from the first prompt only when + // session.create leaves it unset (SessionPrompt.ensureTitle). + const response = yield* sdkCall( + "session.create", + { permission: openCodePermissionRules(threadInput.runtimePolicy) }, + () => + client.session.create({ + permission: openCodePermissionRules(threadInput.runtimePolicy), + }), + ); + const nativeSession = unwrapData("session.create", response); + const createdAt = yield* DateTime.now; + const created = makeProviderThread({ + idAllocator, + providerInstanceId: options.instanceId, + providerSessionId: input.providerSessionId, + appThreadId: threadInput.threadId, + nativeSession, + now: createdAt, + }); + const existing = threadInput.existingProviderThread; + // Bind the new native session to the caller's row when one was + // handed over: a second live row per app thread would make + // `activeProviderThreadId` flap between the two on every update. + const providerThread = + existing === undefined + ? created + : { + ...existing, + providerSessionId: input.providerSessionId, + nativeThreadRef: created.nativeThreadRef, + nativeConversationHeadRef: created.nativeConversationHeadRef, + status: created.status, + updatedAt: created.updatedAt, + }; + registerThread(nativeSession, providerThread, null); + return providerThread; + }).pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterEnsureThreadError({ + driver: OPENCODE_PROVIDER, + threadId: threadInput.threadId, + cause, + }), + ), + ), + resumeThread: (threadInput) => + Effect.gen(function* () { + const sessionId = nativeThreadId(threadInput.providerThread); + const response = yield* sdkCall("session.get", { sessionID: sessionId }, () => + client.session.get({ sessionID: sessionId }), + ); + const nativeSession = unwrapData("session.get", response); + const resumedAt = yield* DateTime.now; + const providerThread = { + ...threadInput.providerThread, + providerSessionId: input.providerSessionId, + status: "idle" as const, + updatedAt: dateTimeFromEpoch(nativeSession.time.updated, resumedAt), + }; + registerThread(nativeSession, providerThread, null); + return providerThread; + }).pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterResumeThreadError({ + driver: OPENCODE_PROVIDER, + providerSessionId: input.providerSessionId, + providerThreadId: threadInput.providerThread.id, + cause, + }), + ), + ), + compactThread: (turnInput) => + runtimeSession.startTurn({ + ...turnInput, + message: { ...turnInput.message, text: "/compact" }, + }), + startTurn: (turnInput) => + Effect.gen(function* () { + if (nativeStreamFailure !== null) { + return yield* protocolError( + "OpenCode event stream has ended; reconnect the provider session before starting another turn.", + ); + } + const sessionId = nativeThreadId(turnInput.providerThread); + const state = threads.get(sessionId); + if (state === undefined) { + return yield* protocolError(`OpenCode session ${sessionId} is not registered`); + } + if (state.activeTurn !== null) { + return yield* protocolError( + `OpenCode provider thread ${turnInput.providerThread.id} already has an active turn`, + ); + } + const parsedModel = OpenCodeRuntime.parseOpenCodeModelSlug( + turnInput.modelSelection.model, + ); + if (parsedModel === null) { + return yield* protocolError( + `OpenCode model '${turnInput.modelSelection.model}' must use provider/model format`, + ); + } + const isCompaction = + turnInput.message.text.trim() === "/compact" && + turnInput.message.attachments.length === 0; + const parts = isCompaction ? [] : resolvePromptParts(turnInput); + const startedAt = yield* DateTime.now; + const syntheticNativeTurnId = `${sessionId}:attempt:${turnInput.attemptId}`; + const providerTurnId = idAllocator.derive.providerTurn({ + driver: OPENCODE_PROVIDER, + nativeTurnId: syntheticNativeTurnId, + }); + const providerTurn: OrchestrationV2ProviderTurn = { + id: providerTurnId, + providerThreadId: turnInput.providerThread.id, + nodeId: turnInput.rootNodeId, + runAttemptId: turnInput.attemptId, + nativeTurnRef: providerRef(syntheticNativeTurnId, "weak"), + ordinal: turnInput.providerTurnOrdinal, + status: "running", + startedAt, + completedAt: null, + }; + const admissionMessageId = yield* makeOpenCodeMessageId(); + // No Effect may be yielded between this check and installing the + // turn. If the event stream ended while IDs were being prepared, + // registering afterward would leave a running turn that the EOF + // handler had already finished scanning. + if (nativeStreamFailure !== null) { + return yield* protocolError( + "OpenCode event stream has ended; reconnect the provider session before starting another turn.", + ); + } + if (state.activeTurn !== null) { + return yield* protocolError( + `OpenCode provider thread ${turnInput.providerThread.id} already has an active turn`, + ); + } + const turn: ActiveOpenCodeTurn = { + isRoot: true, + threadId: turnInput.threadId, + runId: turnInput.runId, + rootNodeId: turnInput.rootNodeId, + appThread: turnInput.appThread, + modelSelection: turnInput.modelSelection, + runtimePolicy: turnInput.runtimePolicy, + providerTurnId, + providerThreadId: turnInput.providerThread.id, + providerTurnOrdinal: turnInput.providerTurnOrdinal, + runOrdinal: turnInput.runOrdinal, + runAttemptId: turnInput.attemptId, + startedAt, + itemOrdinals: new Map(), + usage: makeOpenCodeTurnTokenUsageAccumulator(), + parts: new Map(), + partIdsByMessage: new Map(), + toolNamesByCallId: new Map(), + providerTurn, + nextItemOrdinal: turnInput.providerTurnOrdinal * 100 + 1, + nativeUserMessageId: null, + admissionMessageId, + interrupted: false, + finalized: false, + planId: null, + admissionGeneration: state.nextAdmissionGeneration++, + admissionReconciliationGeneration: null, + admissionPending: !isCompaction, + admissionAccepted: isCompaction, + admissionMessageObserved: false, + idleDuringAdmission: false, + admissionSettled: Deferred.makeUnsafe(), + admissionAbortController: new AbortController(), + }; + if (turn.admissionMessageId !== null) + turn.usage.promptMessageIds.add(turn.admissionMessageId); + const admissionSettled = turn.admissionSettled; + const admissionAbortController = turn.admissionAbortController; + state.appThread = turnInput.appThread; + state.activeTurn = turn; + state.providerTurns.set(String(providerTurnId), providerTurn); + yield* emitProviderTurn(state, turn, "running", null); + yield* updateProviderThread(state, { + status: "active", + firstRunOrdinal: state.providerThread.firstRunOrdinal ?? turnInput.runOrdinal, + lastRunOrdinal: turnInput.runOrdinal, + }); + yield* updateProviderSession("running", null); + if (isCompaction) { + yield* sdkCall( + "session.summarize", + { sessionID: sessionId, ...parsedModel, auto: false }, + (signal) => + client.session.summarize( + { sessionID: sessionId, ...parsedModel, auto: false }, + { signal: AbortSignal.any([signal, admissionAbortController!.signal]) }, + ), + ).pipe( + Effect.tap(() => + turn.interrupted || turn.finalized || nativeStreamFailure !== null + ? Effect.void + : emitCompactionItem(state, turn), + ), + Effect.tap(() => + finalizeTurn(state, turn, turn.interrupted ? "interrupted" : "completed"), + ), + Effect.tapError((cause) => + finalizeTurn(state, turn, turn.interrupted ? "interrupted" : "failed", { + failure: makeProviderFailure({ cause, class: "provider_error" }), + }), + ), + Effect.ensuring(Deferred.succeed(admissionSettled, undefined)), + ); + return; + } + const systemPrompt = [ + orchestrationSystemPrompt, + buildRuntimeInstructions({ + harness: "OpenCode", + model: turnInput.modelSelection.model, + }), + ] + .filter(Boolean) + .join("\n\n"); + const agent = + getModelSelectionStringOptionValue(turnInput.modelSelection, "agent") ?? + (turnInput.runtimePolicy.interactionMode === "plan" ? "plan" : undefined); + const variant = getModelSelectionStringOptionValue( + turnInput.modelSelection, + "variant", + ); + yield* submitPrompt( + state, + turn, + { + sessionID: sessionId, + messageID: turn.admissionMessageId!, + model: parsedModel, + ...(agent === undefined ? {} : { agent }), + ...(variant === undefined ? {} : { variant }), + system: systemPrompt, + parts, + }, + admissionAbortController!, + ).pipe( + Effect.tapError((cause) => + admissionAbortController!.signal.aborted + ? Effect.void + : finalizeTurn(state, turn, "failed", { + failure: makeProviderFailure({ cause, class: "provider_error" }), + }), + ), + Effect.catch((cause) => + admissionAbortController!.signal.aborted ? Effect.void : Effect.fail(cause), + ), + Effect.ensuring( + Effect.all([ + Deferred.succeed(admissionSettled, undefined).pipe(Effect.ignore), + Effect.sync(() => { + if (turn.admissionAbortController === admissionAbortController) { + turn.admissionAbortController = null; + } + }), + ]).pipe(Effect.asVoid), + ), + ); + if (state.activeTurn === turn && !turn.finalized && !turn.interrupted) { + const admissionAction = advanceOpenCodePromptAdmission(turn, "accepted"); + if (admissionAction === "reconcile-idle") { + yield* reconcilePromptAdmission(state, turn); + } + } + }).pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterTurnStartError({ + driver: OPENCODE_PROVIDER, + threadId: turnInput.threadId, + providerThreadId: turnInput.providerThread.id, + runId: turnInput.runId, + cause, + }), + ), + ), + steerTurn: (steerInput) => + Effect.gen(function* () { + const sessionId = nativeThreadId(steerInput.providerThread); + const state = threads.get(sessionId); + const turn = state?.activeTurn; + if ( + state === undefined || + turn === undefined || + turn === null || + turn.providerTurnId !== steerInput.providerTurnId || + turn.interrupted + ) { + return yield* protocolError( + `OpenCode turn ${steerInput.providerTurnId} is not active`, + ); + } + const parsedModel = OpenCodeRuntime.parseOpenCodeModelSlug(turn.modelSelection.model); + if (parsedModel === null) { + return yield* protocolError( + `OpenCode model '${turn.modelSelection.model}' must use provider/model format`, + ); + } + const text = providerMessageTextWithAttachmentPaths({ + text: steerInput.message.text, + attachments: steerInput.message.attachments, + resolveAttachmentPath: host.resolveAttachmentPath, + }).trim(); + const files = OpenCodeRuntime.toOpenCodeFileParts({ + attachments: steerInput.message.attachments, + resolveAttachmentPath: host.resolveAttachmentPath, + }); + if (text.length === 0 && files.length === 0) { + return yield* protocolError("OpenCode steering requires text or an attachment"); + } + const parts = [ + ...(text.length === 0 ? [] : [{ type: "text" as const, text }]), + ...files, + ]; + turn.admissionGeneration = state.nextAdmissionGeneration++; + turn.admissionMessageId = yield* makeOpenCodeMessageId(); + turn.admissionPending = true; + turn.admissionAccepted = false; + turn.admissionMessageObserved = false; + turn.idleDuringAdmission = false; + turn.admissionSettled = Deferred.makeUnsafe(); + turn.admissionAbortController = new AbortController(); + if (turn.admissionMessageId !== null) + turn.usage.promptMessageIds.add(turn.admissionMessageId); + const admissionSettled = turn.admissionSettled; + const admissionAbortController = turn.admissionAbortController; + yield* submitPrompt( + state, + turn, + { + sessionID: sessionId, + messageID: turn.admissionMessageId, + model: parsedModel, + parts, + }, + admissionAbortController!, + ).pipe( + Effect.ensuring( + Effect.all([ + Deferred.succeed(admissionSettled, undefined).pipe(Effect.ignore), + Effect.sync(() => { + if (turn.admissionAbortController === admissionAbortController) { + turn.admissionAbortController = null; + } + }), + ]).pipe(Effect.asVoid), + ), + ); + if (state.activeTurn === turn && !turn.finalized) { + const admissionAction = advanceOpenCodePromptAdmission(turn, "accepted"); + if (admissionAction === "reconcile-idle") { + yield* reconcilePromptAdmission(state, turn); + } + } + }).pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterSteerRunError({ + driver: OPENCODE_PROVIDER, + providerThreadId: steerInput.providerThread.id, + providerTurnId: steerInput.providerTurnId, + cause, + }), + ), + ), + interruptTurn: (interruptInput) => + Effect.gen(function* () { + const sessionId = nativeThreadId(interruptInput.providerThread); + const state = threads.get(sessionId); + const turn = state?.activeTurn; + // Stop on a settled turn: a background task child can outlive + // it (busySessionIds), and aborting the descendants ends it. + if ( + (turn === undefined || turn === null) && + interruptInput.requestRuntimeRestart === true + ) { + for (const controller of commandControllers.get(sessionId) ?? []) + controller.abort(); + yield* abortDescendants(sessionId); + return; + } + if ( + turn === undefined || + turn === null || + turn.providerTurnId !== interruptInput.providerTurnId + ) { + return yield* protocolError( + `OpenCode turn ${interruptInput.providerTurnId} is not active`, + ); + } + turn.interrupted = true; + for (const controller of commandControllers.get(sessionId) ?? []) controller.abort(); + const admissionWasPending = turn.admissionPending; + cancelOpenCodePromptAdmission(turn, state!.nextAdmissionGeneration++); + turn.admissionAbortController?.abort(); + if (admissionWasPending) { + // Settle the cancelled request before sending the session abort. + // A timed-out local request must not prevent the definitive + // server-side abort below from running. + yield* Deferred.await(turn.admissionSettled).pipe( + Effect.timeout("10 seconds"), + Effect.ignore, + ); + } + yield* sdkCall("session.abort", { sessionID: sessionId }, (signal) => + client.session.abort({ sessionID: sessionId }, { signal }), + ).pipe( + Effect.timeout("10 seconds"), + // The turn can settle while the abort is in flight, and + // aborting an already-idle session fails. That stop still + // succeeded; only surface failures for a turn that is + // genuinely still running. + Effect.catch((cause) => + turn.finalized + ? Effect.void + : Effect.suspend(() => { + turn.interrupted = false; + return Effect.fail(cause); + }), + ), + ); + // Root abort does not stop child sessions. Report incomplete cleanup + // even if the root has already emitted its terminal event. + yield* abortDescendants(sessionId); + }).pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterInterruptError({ + driver: OPENCODE_PROVIDER, + providerThreadId: interruptInput.providerThread.id, + providerTurnId: interruptInput.providerTurnId, + cause, + }), + ), + ), + respondToRuntimeRequest: (requestInput) => + Effect.gen(function* () { + const pending = pendingRequests.get(String(requestInput.requestId)); + if (pending === undefined) { + return yield* protocolError( + `No pending OpenCode request ${requestInput.requestId}`, + ); + } + if (pending.question !== undefined) { + if (requestInput.answers === undefined) { + return yield* protocolError( + `OpenCode question request ${requestInput.requestId} requires answers`, + ); + } + const answers = OpenCodeRuntime.toOpenCodeQuestionAnswers( + pending.question, + requestInput.answers, + ); + yield* sdkCall( + "question.reply", + { requestID: pending.nativeRequestId, answers }, + (signal) => + client.question.reply( + { + requestID: pending.nativeRequestId, + answers, + }, + { signal }, + ), + ).pipe(Effect.timeout("10 seconds")); + return; + } + if (requestInput.decision === undefined) { + return yield* protocolError( + `OpenCode approval request ${requestInput.requestId} requires a decision`, + ); + } + const reply = OpenCodeRuntime.toOpenCodePermissionReply(requestInput.decision); + yield* sdkCall( + "permission.reply", + { requestID: pending.nativeRequestId, reply }, + (signal) => + client.permission.reply( + { + requestID: pending.nativeRequestId, + reply, + }, + { signal }, + ), + ).pipe(Effect.timeout("10 seconds")); + }).pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterRuntimeRequestResponseError({ + driver: OPENCODE_PROVIDER, + requestId: requestInput.requestId, + cause, + }), + ), + ), + readThreadSnapshot: (snapshotInput) => + readSnapshot(snapshotInput.providerThread).pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterReadThreadSnapshotError({ + driver: OPENCODE_PROVIDER, + providerThreadId: snapshotInput.providerThread.id, + cause, + }), + ), + ), + rollbackThread: (rollbackInput) => + Effect.gen(function* () { + const sessionId = nativeThreadId(rollbackInput.providerThread); + const state = threads.get(sessionId); + if (state?.activeTurn !== null && state?.activeTurn !== undefined) { + return yield* protocolError( + `Cannot roll back OpenCode thread ${rollbackInput.providerThread.id} while a turn is active`, + ); + } + const response = yield* sdkCall("session.messages", { sessionID: sessionId }, () => + client.session.messages({ sessionID: sessionId }), + ); + const messages = unwrapData("session.messages", response); + let boundaryMessageId: string | undefined; + if (rollbackInput.target.type === "thread_start") { + boundaryMessageId = messages.find(({ info }) => info.role === "user")?.info.id; + } else { + boundaryMessageId = openCodeBoundaryAfterProviderTurn( + rollbackInput.providerThreadTurns, + rollbackInput.target.providerTurn.id, + ); + } + let retainedThread = rollbackInput.providerThread; + if (boundaryMessageId !== undefined) { + const boundaryIndex = messages.findIndex( + ({ info }) => info.id === boundaryMessageId, + ); + if (boundaryIndex < 0) + return yield* protocolError( + "The OpenCode rewind boundary is no longer available.", + ); + const fork = unwrapData( + "session.fork", + yield* sdkCall( + "session.fork", + { sessionID: sessionId, messageID: boundaryMessageId }, + () => + client.session.fork({ sessionID: sessionId, messageID: boundaryMessageId }), + ), + ); + const retained = unwrapData( + "session.messages", + yield* sdkCall("session.messages", { sessionID: fork.id }, () => + client.session.messages({ sessionID: fork.id }), + ), + ); + if (retained.length !== boundaryIndex) + return yield* protocolError( + "OpenCode did not preserve the requested rewind boundary.", + ); + yield* sdkCall("session.update", { sessionID: fork.id }, () => + client.session.update({ + sessionID: fork.id, + permission: openCodePermissionRules(input.runtimePolicy), + }), + ); + retainedThread = { + ...rollbackInput.providerThread, + nativeThreadRef: providerRef(fork.id), + }; + registerThread(fork, retainedThread, state?.appThread ?? null); + } + const snapshot = yield* readSnapshot(retainedThread); + return { + ...snapshot, + providerThread: { + ...snapshot.providerThread, + nativeConversationHeadRef: + rollbackInput.target.type === "provider_turn" + ? rollbackInput.target.providerTurn.nativeTurnRef + : null, + }, + }; + }).pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterRollbackThreadError({ + driver: OPENCODE_PROVIDER, + providerThreadId: rollbackInput.providerThread.id, + checkpointId: rollbackInput.target.checkpointId, + cause, + }), + ), + ), + forkThread: (forkInput) => + Effect.gen(function* () { + const sourceSessionId = nativeThreadId(forkInput.sourceProviderThread); + const sourceState = threads.get(sourceSessionId); + if (sourceState?.activeTurn !== null && sourceState?.activeTurn !== undefined) { + return yield* protocolError( + `Cannot fork OpenCode thread ${forkInput.sourceProviderThread.id} while a turn is active`, + ); + } + let boundaryMessageId: string | undefined; + if (forkInput.providerTurnId !== undefined) { + const sourceTurns = forkInput.sourceProviderTurns ?? []; + const selected = sourceTurns.find((turn) => turn.id === forkInput.providerTurnId); + if (selected === undefined) { + return yield* protocolError( + `OpenCode fork boundary turn ${forkInput.providerTurnId} was not found`, + ); + } + boundaryMessageId = openCodeBoundaryAfterProviderTurn(sourceTurns, selected.id); + } + const response = yield* sdkCall( + "session.fork", + { + sessionID: sourceSessionId, + ...(boundaryMessageId === undefined ? {} : { messageID: boundaryMessageId }), + }, + () => + client.session.fork({ + sessionID: sourceSessionId, + ...(boundaryMessageId === undefined ? {} : { messageID: boundaryMessageId }), + }), + ); + const nativeSession = unwrapData("session.fork", response); + const forkedAt = yield* DateTime.now; + const providerThread = makeProviderThread({ + idAllocator, + providerInstanceId: options.instanceId, + providerSessionId: input.providerSessionId, + appThreadId: forkInput.targetThreadId, + ...(forkInput.ownerNodeId === undefined + ? {} + : { ownerNodeId: forkInput.ownerNodeId }), + nativeSession, + forkedFrom: { + providerThreadId: forkInput.sourceProviderThread.id, + ...(forkInput.providerTurnId === undefined + ? {} + : { providerTurnId: forkInput.providerTurnId }), + }, + now: forkedAt, + }); + registerThread(nativeSession, providerThread, null); + return providerThread; + }).pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterForkThreadError({ + driver: OPENCODE_PROVIDER, + providerThreadId: forkInput.sourceProviderThread.id, + cause, + }), + ), + ), + }; + + return runtimeSession; + }, + (effect, input) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterOpenSessionError({ + driver: OPENCODE_PROVIDER, + providerSessionId: input.providerSessionId, + cause, + }), + ), + ), + ), + }); +} + +export type OpenCodeAdapterV2DriverEnv = + | OpenCodeRuntime.OpenCodeRuntime + | IdAllocator.IdAllocatorV2 + | ProviderEventLoggers.ProviderEventLoggers + | ProviderHost.ProviderHost; + +export const OpenCodeAdapterV2Driver: ProviderAdapterDriver< + OpenCodeSettings, + OpenCodeAdapterV2DriverEnv +> = { + driverKind: OPENCODE_PROVIDER, + configSchema: OpenCodeSettings, + defaultConfig: (): OpenCodeSettings => DEFAULT_OPENCODE_SETTINGS, + create: Effect.fn("OpenCodeAdapterV2Driver.create")( + function* (input: ProviderAdapterDriverCreateInput) { + const hostEnvironment = yield* HostProcessEnvironment; + const openCodeRuntime = yield* OpenCodeRuntime.OpenCodeRuntime; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const providerEventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers; + const host = yield* ProviderHost.ProviderHost; + return makeOpenCodeAdapterV2({ + instanceId: input.instanceId, + settings: { ...input.config, enabled: input.enabled }, + environment: mergeProviderInstanceEnvironment(input.environment, hostEnvironment), + runtime: openCodeRuntime, + idAllocator, + host, + ...(providerEventLoggers.native === undefined + ? {} + : { nativeEventLogger: providerEventLoggers.native }), + }); + }, + (effect, input) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapterDriverCreateError({ + driver: OPENCODE_PROVIDER, + instanceId: input.instanceId, + detail: "Failed to create OpenCode v2 adapter.", + cause, + }), + ), + ), + ), +}; + +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 host = yield* ProviderHost.ProviderHost; + return makeOpenCodeAdapterV2({ + instanceId: OPENCODE_DEFAULT_INSTANCE_ID, + settings: DEFAULT_OPENCODE_SETTINGS, + environment: hostEnvironment, + runtime: openCodeRuntime, + idAllocator, + host, + ...(providerEventLoggers.native === undefined + ? {} + : { nativeEventLogger: providerEventLoggers.native }), + }); + }), + ); diff --git a/packages/provider-opencode/src/server/driver.test.ts b/packages/provider-opencode/src/server/driver.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..10410d3abab987c8adf9d4836eab7cfbdd9542cb --- /dev/null +++ b/packages/provider-opencode/src/server/driver.test.ts @@ -0,0 +1,274 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { ProviderInstanceId } from "@t3tools/contracts"; +import type { OpenCodeSettings } from "../settings.ts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as TestClock from "effect/testing/TestClock"; +import { HttpClient } from "effect/http"; +import { ChildProcessSpawner } from "effect/process"; + +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; +import * as ProviderMaintenance from "@t3tools/provider-core/server/maintenanceResolver"; +import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; +import { + OPENCODE_2_RESPONSES, + OPENCODE_2_WORKSPACE_RESPONSES, + replayOpenCodeServer, +} from "./probeResponses.fixture.ts"; +import { OpenCodeDriver, openCodeUpdateFor } from "./driver.ts"; +import { layerTestProviderHost } from "@t3tools/provider-testing/host"; + +const serverStarts: Array = []; +const reachedServer = (operation: string) => + Effect.sync(() => serverStarts.push(operation)).pipe( + Effect.andThen( + Effect.fail( + new OpenCodeRuntime.OpenCodeRuntimeError({ + operation, + detail: "reached a 1.x server path", + }), + ), + ), + ); +// Reports OpenCode 2 from `--version`; any attempt to reach a server is recorded and refused. +const openCode2Runtime = { + runOpenCodeCommand: () => Effect.succeed({ stdout: "opencode v2.0.18\n", stderr: "", code: 0 }), + startOpenCodeServerProcess: () => reachedServer("start"), + connectToOpenCodeServer: () => reachedServer("connect"), +} as unknown as OpenCodeRuntime.OpenCodeRuntimeShape; + +const layer = Layer.mergeAll( + IdAllocator.layer, + layerTestProviderHost(), + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + Layer.succeed(OpenCodeRuntime.OpenCodeRuntime, openCode2Runtime), +).pipe(Layer.provideMerge(NodeServices.layer)); + +const create = (config: Partial, http: HttpClient.HttpClient) => + OpenCodeDriver.create({ + instanceId: ProviderInstanceId.make("opencode-test"), + displayName: undefined, + environment: [], + enabled: true, + config: { ...OpenCodeDriver.defaultConfig(), ...config }, + }).pipe(Effect.provideService(HttpClient.HttpClient, http)); + +const noHttp = HttpClient.make(() => Effect.die("A local binary must not be probed over HTTP")); + +it.layer(layer)("OpenCodeDriver runtime selection", (it) => { + it.effect("never starts a 1.x server for an OpenCode 2 instance", () => + Effect.gen(function* () { + serverStarts.length = 0; + const instance = yield* create({}, noHttp); + // Text generation goes to the instance's 2.x server, which this test refuses to start. + yield* Effect.flip( + instance.textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "hello", + modelSelection: { instanceId: instance.instanceId, model: "opencode/big-pickle" }, + }), + ); + assert.deepStrictEqual(serverStarts, ["start"]); + }).pipe(Effect.scoped), + ); + + it.effect("lists an OpenCode 2 workspace's own skills and commands from its server", () => + Effect.gen(function* () { + serverStarts.length = 0; + const requested: Array = []; + const server = replayOpenCodeServer( + { ...OPENCODE_2_RESPONSES, ...OPENCODE_2_WORKSPACE_RESPONSES }, + "secret", + requested, + ); + const instance = yield* create( + { serverUrl: "http://127.0.0.1:4096", serverPassword: "secret" }, + server, + ); + + const workspace = yield* instance.snapshotForCwd!("/work"); + assert.includeMembers( + workspace.skills.map((skill) => skill.name), + ["plum", "opencode"], + ); + assert.deepStrictEqual( + workspace.slashCommands.map((command) => command.name), + ["compact", "init", "review", "bee"], + ); + assert.include(requested, "/api/skill"); + assert.deepStrictEqual(serverStarts, []); + }).pipe(Effect.scoped), + ); + + it.effect("answers capability reads without waiting on an unreachable server", () => + Effect.gen(function* () { + const hang = HttpClient.make(() => Effect.never); + const instance = yield* create({ serverUrl: "http://127.0.0.1:9" }, hang); + + // No probe has succeeded yet, and the server never answers: the 1.x default applies. + const capabilities = yield* instance.orchestrationAdapter + .getCapabilities() + .pipe(Effect.forkChild); + yield* Effect.yieldNow; + assert.isDefined(capabilities.pollUnsafe()); + yield* Fiber.join(capabilities); + }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), + ); +}); + +// npm links `/bin/opencode` into the package that installed it: +// `opencode-ai` for 1.x and `@opencode/cli` (whose bin is `bin/opencode.exe`) for 2.x. +const npmGlobalInstall = (prefix: string, packagePath: ReadonlyArray, bin: string) => { + const target = NodePath.join(prefix, "lib", "node_modules", ...packagePath, "bin", bin); + NodeFS.mkdirSync(NodePath.dirname(target), { recursive: true }); + NodeFS.writeFileSync(target, "#!/bin/sh\n"); + NodeFS.chmodSync(target, 0o755); + const link = NodePath.join(prefix, "bin", "opencode"); + NodeFS.mkdirSync(NodePath.dirname(link), { recursive: true }); + NodeFS.symlinkSync(target, link); + return link; +}; +const resolveUpdate = (generation: "v1" | "v2", binaryPath: string) => + ProviderMaintenance.resolveProviderMaintenanceCapabilitiesEffect(openCodeUpdateFor(generation), { + binaryPath, + env: { PATH: "" }, + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.die("resolving an update must not spawn")), + ), + Effect.provide(NodeServices.layer), + ); + +it.effect.skipIf(HostProcessPlatform.defaultValue() === "win32")( + "updates an npm-global OpenCode 2 install as @opencode/cli and a 1.x one as opencode-ai", + () => + Effect.gen(function* () { + const root = NodeFS.realpathSync( + NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-opencode-update-")), + ); + const v2Prefix = NodePath.join(root, "v2"); + const v1Prefix = NodePath.join(root, "v1"); + const v2 = yield* resolveUpdate( + "v2", + npmGlobalInstall(v2Prefix, ["@opencode", "cli"], "opencode.exe"), + ); + assert.deepStrictEqual(v2.update?.args, [ + "install", + "-g", + "--prefix", + v2Prefix, + "--allow-scripts=@opencode/cli", + "@opencode/cli@latest", + ]); + const v1 = yield* resolveUpdate( + "v1", + npmGlobalInstall(v1Prefix, ["opencode-ai"], "opencode"), + ); + assert.strictEqual(v1.update?.args.at(-1), "opencode-ai@latest"); + // A 1.x install is never offered 2.x's package, or the reverse. + assert.isNull( + (yield* resolveUpdate("v2", NodePath.join(v1Prefix, "bin", "opencode"))).update, + ); + assert.isNull( + (yield* resolveUpdate("v1", NodePath.join(v2Prefix, "bin", "opencode"))).update, + ); + }), +); + +// The update the driver offers follows the version the binary reports now. +const versionOutput: { current: string | undefined } = { current: undefined }; +const versionProbes: Array = []; +const changingRuntime = { + runOpenCodeCommand: ({ binaryPath }: { readonly binaryPath: string }) => + Effect.sync(() => versionProbes.push(binaryPath)).pipe( + Effect.andThen( + versionOutput.current === undefined + ? Effect.fail( + new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: "version", + detail: "no version", + }), + ) + : Effect.succeed({ stdout: versionOutput.current, stderr: "", code: 0 }), + ), + ), + startOpenCodeServerProcess: () => reachedServer("start"), + connectToOpenCodeServer: () => reachedServer("connect"), +} as unknown as OpenCodeRuntime.OpenCodeRuntimeShape; +const layerUpdate = Layer.mergeAll( + IdAllocator.layer, + layerTestProviderHost(), + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + Layer.succeed(OpenCodeRuntime.OpenCodeRuntime, changingRuntime), +).pipe(Layer.provideMerge(NodeServices.layer)); + +it.layer(layerUpdate)("OpenCodeDriver updates", (it) => { + it.effect("never runs the binary for a disabled instance's update check", () => + Effect.gen(function* () { + versionProbes.length = 0; + versionOutput.current = "opencode v2.0.18\n"; + const instance = yield* OpenCodeDriver.create({ + instanceId: ProviderInstanceId.make("opencode-disabled"), + displayName: undefined, + environment: [], + enabled: false, + config: { ...OpenCodeDriver.defaultConfig(), binaryPath: "opencode" }, + }).pipe(Effect.provideService(HttpClient.HttpClient, noHttp)); + const maintenance = yield* instance.snapshot.resolveMaintenance(); + yield* instance.snapshot.resolveMaintenance({ fresh: true }); + assert.isNull(maintenance.update); + assert.deepStrictEqual(versionProbes, []); + }).pipe(Effect.scoped), + ); + + it.effect.skipIf(HostProcessPlatform.defaultValue() === "win32")( + "offers no package update for an unknown version and follows a changed one on a fresh read", + () => + Effect.gen(function* () { + const root = NodeFS.realpathSync( + NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-opencode-driver-update-")), + ); + const binaryPath = npmGlobalInstall(NodePath.join(root, "v1"), ["opencode-ai"], "opencode"); + const instance = yield* OpenCodeDriver.create({ + instanceId: ProviderInstanceId.make("opencode-update"), + displayName: undefined, + environment: [], + enabled: true, + config: { ...OpenCodeDriver.defaultConfig(), binaryPath }, + }).pipe(Effect.provideService(HttpClient.HttpClient, noHttp)); + + // The version could not be read: which package owns the binary is unknown. + versionOutput.current = undefined; + const unknown = yield* instance.snapshot.resolveMaintenance({ fresh: true }); + assert.isNull(unknown.update); + assert.isNull(unknown.packageName); + + versionOutput.current = "1.18.32\n"; + const v1 = yield* instance.snapshot.resolveMaintenance({ fresh: true }); + assert.strictEqual(v1.packageName, "opencode-ai"); + + // The same path now reports 2.x (reinstalled in place): a fresh read re-probes + // and never offers 1.x's package for it. + versionOutput.current = "opencode v2.0.18\n"; + const v2 = yield* instance.snapshot.resolveMaintenance({ fresh: true }); + assert.strictEqual(v2.packageName, "@opencode/cli"); + assert.isNull(v2.update); + }).pipe(Effect.scoped), + ); +}); diff --git a/packages/provider-opencode/src/server/driver.ts b/packages/provider-opencode/src/server/driver.ts new file mode 100644 index 0000000000000000000000000000000000000000..9c37ab2abd48a0d0e1d18bbd2e4abbfbefe768f7 --- /dev/null +++ b/packages/provider-opencode/src/server/driver.ts @@ -0,0 +1,540 @@ +/** + * OpenCodeDriver — `ProviderDriver` for the OpenCode runtime. + * + * Mirrors the Codex / Claude drivers: a plain value whose `create()` + * bundles `snapshot` / `adapter` / `textGeneration` closures over the + * per-instance `OpenCodeSettings`. + * + * Two instances with different `serverUrl`s therefore talk to independent + * OpenCode servers; when no `serverUrl` is set, the adapter + text-generation + * shares spin up their own scoped child processes, and those child + * processes are released when the registry scope closes. + * + * @module provider/Drivers/OpenCodeDriver + */ +import { ProviderDriverKind } from "@t3tools/contracts"; +import { OpenCodeSettings } from "../settings.ts"; +import * as Crypto from "effect/Crypto"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { HttpClient } from "effect/http"; +import { ChildProcessSpawner } from "effect/process"; + +import * as OpenCode2TextGeneration from "./v2/textGeneration.ts"; +import { makeOpenCodeTextGeneration } from "./textGeneration.ts"; +import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; +import * as OpenCodeAdapterV2 from "./adapter.ts"; +import * as OpenCode2AdapterV2 from "./v2/adapter.ts"; +import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; +import type { ProviderTextGeneration } from "@t3tools/provider-core/server/textGeneration"; +import { ProviderDriverError } from "@t3tools/provider-core/server/errors"; +import { readOpenCodeGoUsageLimits } from "./usageLimits.ts"; +import { + checkOpenCodeProviderStatus, + loadOpenCode2Workspace, + makeOpenCode2ModelLoader, + makePendingOpenCodeProvider, + openCode2CommandsToServerProviderSlashCommands, + openCode2SkillsToServerProviderSkills, + openCodeSkillsToServerProviderSkills, + openCodeCommandsToServerProviderSlashCommands, +} from "./status.ts"; +import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; +import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; +import { + makeOpenCodeRuntimeProbe, + probeOpenCodeRuntime, + type ProbedOpenCode, +} from "./versionProbe.ts"; +import * as OpenCodeServerOwner from "./OpenCodeServerOwner.ts"; +import * as OpenCode2Client from "./v2/OpenCode2Client.ts"; +import * as OpenCode2Server from "./v2/OpenCode2Server.ts"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "@t3tools/provider-core/server/driver"; +import { withInstanceIdentity } from "@t3tools/provider-core/server/instanceIdentity"; +import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; +import { + enrichProviderSnapshotWithVersionAdvisory, + makeCachedProviderMaintenanceResolution, + makeManualOnlyProviderMaintenanceCapabilities, + makePackageManagedProviderMaintenanceResolver, + normalizeCommandPath, + resolveProviderMaintenanceCapabilitiesEffect, +} from "@t3tools/provider-core/server/maintenanceResolver"; +import { + haveProviderSnapshotSettingsChanged, + makeProviderSnapshotSettingsSource, + type ProviderSnapshotSettings, +} from "@t3tools/provider-core/server/snapshotSettings"; +const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings); + +const DRIVER_KIND = ProviderDriverKind.make("opencode"); + +function isOpenCodeNativeCommandPath(commandPath: string): boolean { + const normalized = normalizeCommandPath(commandPath); + return ( + normalized.endsWith("/.opencode/bin/opencode") || + normalized.endsWith("/.opencode/bin/opencode.exe") + ); +} + +/** + * OpenCode 1.x ships as `opencode-ai` and 2.x as `@opencode/cli`. An install is + * only ever updated within its own package: T3 never moves a 1.x install onto + * 2.x or back, since 2.x converts the shared database in place. + */ +export const openCodeUpdateFor = (generation: ProbedOpenCode["generation"]) => + makePackageManagedProviderMaintenanceResolver({ + provider: DRIVER_KIND, + npmPackageName: generation === "v2" ? "@opencode/cli" : "opencode-ai", + nativeUpdate: { + args: ["upgrade"], + isCommandPath: isOpenCodeNativeCommandPath, + }, + }); + +type OpenCodeRuntimeProbe = Effect.Success< + ReturnType> +>; + +/** 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, + paths: { readonly v1: Effect.Effect; readonly v2: Effect.Effect }, +): Effect.Effect { + return probed.pipe( + Effect.orElseSucceed(() => undefined), + 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 each server is spoken to in its own protocol. + */ +function selectOpenCodeRuntimeAdapter(input: { + readonly probe: OpenCodeRuntimeProbe; + readonly v1: ProviderAdapter.ProviderAdapterV2Shape; + readonly v2: ProviderAdapter.ProviderAdapterV2Shape; +}): ProviderAdapter.ProviderAdapterV2Shape { + 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: () => Effect.flatMap(hot, (adapter) => adapter.getCapabilities()), + planSelectionTransition: (transition) => + Effect.flatMap(hot, (adapter) => adapter.planSelectionTransition(transition)), + openSession: (session) => + Effect.flatMap(pick(input.probe.get), (adapter) => adapter.openSession(session)), + }; +} + +/** Text generation runs on the server the instance's probe detected, each in its own protocol. */ +function selectOpenCodeRuntimeTextGeneration( + probe: OpenCodeRuntimeProbe, + v1: ProviderTextGeneration, + v2: ProviderTextGeneration, +): ProviderTextGeneration { + return { + generateCommitMessage: (input) => + byOpenCodeRuntime(probe.get, { + v1: v1.generateCommitMessage(input), + v2: v2.generateCommitMessage(input), + }), + generatePrContent: (input) => + byOpenCodeRuntime(probe.get, { + v1: v1.generatePrContent(input), + v2: v2.generatePrContent(input), + }), + generateBranchName: (input) => + byOpenCodeRuntime(probe.get, { + v1: v1.generateBranchName(input), + v2: v2.generateBranchName(input), + }), + generateThreadTitle: (input) => + byOpenCodeRuntime(probe.get, { + v1: v1.generateThreadTitle(input), + v2: v2.generateThreadTitle(input), + }), + }; +} + +export type OpenCodeDriverEnv = + | OpenCodeAdapterV2.OpenCodeAdapterV2DriverEnv + | ProviderHost.ProviderHost + | ChildProcessSpawner.ChildProcessSpawner + | Crypto.Crypto + | FileSystem.FileSystem + | HttpClient.HttpClient + | OpenCodeRuntime.OpenCodeRuntime + | Path.Path; + +export const OpenCodeDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "OpenCode", + supportsMultipleInstances: true, + }, + configSchema: OpenCodeSettings, + defaultConfig: (): OpenCodeSettings => decodeOpenCodeSettings({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const openCodeRuntime = yield* OpenCodeRuntime.OpenCodeRuntime; + const httpClient = yield* HttpClient.HttpClient; + const crypto = yield* Crypto.Crypto; + const host = yield* ProviderHost.ProviderHost; + const processEnv = mergeProviderInstanceEnvironment(environment); + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const stampIdentity = withInstanceIdentity({ + instanceId, + driverKind: DRIVER_KIND, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const effectiveConfig = { ...config, enabled } satisfies OpenCodeSettings; + const runtimeProbe = yield* makeOpenCodeRuntimeProbe( + probeOpenCodeRuntime(effectiveConfig, processEnv).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, openCodeRuntime), + ), + ); + // Updates follow the installed package, which the version probe names. An + // unknown version offers no package update, since a guess could move a + // 2.x install onto 1.x's package or the reverse. A disabled instance never + // runs its binary, so it has no version and offers no update. + const noUpdate = makeManualOnlyProviderMaintenanceCapabilities({ + provider: DRIVER_KIND, + packageName: null, + }); + const maintenanceFor = (probed: typeof runtimeProbe.get) => + probed.pipe( + Effect.map((result) => result.generation), + Effect.option, + Effect.flatMap((generation) => + Option.isNone(generation) + ? Effect.succeed(noUpdate) + : resolveProviderMaintenanceCapabilitiesEffect(openCodeUpdateFor(generation.value), { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, + }), + ), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, pathService), + ); + const cachedMaintenance = yield* makeCachedProviderMaintenanceResolution( + maintenanceFor(runtimeProbe.get), + ); + // A fresh read (an update about to run, or a manual refresh) re-probes, so + // a binary replaced by the other major version gets its own package. + const resolveMaintenance = (options?: { readonly fresh?: boolean }) => + !effectiveConfig.enabled + ? Effect.succeed(noUpdate) + : options?.fresh === true + ? runtimeProbe.refresh.pipe( + Effect.ignore, + Effect.andThen(cachedMaintenance({ fresh: true })), + ) + : cachedMaintenance(); + const openCodeV1Adapter = yield* OpenCodeAdapterV2.OpenCodeAdapterV2Driver.create({ + instanceId, + displayName, + accentColor, + environment, + enabled, + config, + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "Failed to build OpenCode orchestration adapter.", + cause, + }), + ), + ); + // 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: host.paths.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 loadOpenCode2Models = yield* makeOpenCode2ModelLoader( + openCode2Server.withConnection((connection) => + connection.client.model.list({ location: { directory: host.paths.cwd } }).pipe( + Effect.map((models) => models.data), + Effect.mapError( + (cause) => + new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: "model.list", + detail: "The OpenCode server could not list its models.", + cause, + }), + ), + ), + ), + ); + // A 2.x server lists skills and commands per directory, so one server + // answers every workspace. Its event stream says when a directory it had + // not served yet finished scanning. + const listOpenCode2Workspace = (cwd: string) => + openCode2Server.withConnection(({ client, events }) => + Effect.gen(function* () { + const location = { directory: cwd }; + const scanned = yield* Deferred.make(); + const pending = new Set(["command.updated", "skill.updated"]); + const stream = yield* events.pipe(Effect.option); + if (stream._tag === "Some") { + yield* stream.value.pipe( + Stream.runForEach((event) => + "location" in event && + event.location?.directory === cwd && + pending.delete(event.type) && + pending.size === 0 + ? Deferred.succeed(scanned, undefined) + : Effect.void, + ), + Effect.ignore, + Effect.forkScoped, + ); + } + return yield* loadOpenCode2Workspace( + Effect.all( + { + skills: client.skill.list({ location }).pipe(Effect.map((list) => list.data)), + commands: client.command.list({ location }).pipe(Effect.map((list) => list.data)), + }, + { concurrency: "unbounded" }, + ), + Deferred.await(scanned), + ); + }).pipe(Effect.scoped), + ); + const serverOwner = yield* OpenCodeServerOwner.make({ + binaryPath: effectiveConfig.binaryPath, + directory: host.paths.cwd, + ...(effectiveConfig.serverPassword + ? { serverPassword: effectiveConfig.serverPassword } + : {}), + environment: processEnv, + }); + const textGeneration = selectOpenCodeRuntimeTextGeneration( + runtimeProbe, + yield* makeOpenCodeTextGeneration(effectiveConfig).pipe( + Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner), + ), + yield* OpenCode2TextGeneration.make().pipe( + Effect.provideService(OpenCode2Server.OpenCode2Server, openCode2Server), + ), + ); + + const checkProvider = Effect.all( + { + provider: checkOpenCodeProviderStatus( + effectiveConfig, + host.paths.cwd, + runtimeProbe.refresh, + loadOpenCode2Models, + ), + usageLimits: readOpenCodeGoUsageLimits({ + enabled: effectiveConfig.enabled, + serverUrl: effectiveConfig.serverUrl, + environment: processEnv, + }), + }, + { concurrency: "unbounded" }, + ).pipe( + Effect.map(({ provider, usageLimits }) => ({ ...provider, usageLimits })), + Effect.map(stampIdentity), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, pathService), + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner), + Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, openCodeRuntime), + ); + // NOTE: the local branch intentionally uses the shared SDK server + // instead of `opencode debug skill` (loadSkillsFromCli). The CLI writes + // its full JSON inventory to stdout, but the Bun-compiled binary does + // not flush more than one 64KB pipe buffer to a non-TTY stdout, so the + // piped output arrives truncated and unparseable — which degrades to an + // empty skill list and poisons the workspace snapshot the `$` picker + // reads. The SDK `app.skills` endpoint honors the per-request directory + // and returns complete results regardless of size. + const loadWorkspaceInventory = ( + client: Parameters[0], + ) => + Effect.all( + { + skills: openCodeRuntime.loadOpenCodeSkills(client), + commands: OpenCodeRuntime.loadOpenCodeCommands(client).pipe( + Effect.timeout("10 seconds"), + Effect.orElseSucceed(() => []), + ), + }, + { concurrency: "unbounded" }, + ); + const loadWorkspaceForCwd = (cwd: string) => + effectiveConfig.serverUrl.trim().length > 0 + ? Effect.scoped( + Effect.gen(function* () { + const server = yield* openCodeRuntime.connectToOpenCodeServer({ + binaryPath: effectiveConfig.binaryPath, + directory: cwd, + serverUrl: effectiveConfig.serverUrl, + ...(effectiveConfig.serverPassword + ? { serverPassword: effectiveConfig.serverPassword } + : {}), + environment: processEnv, + }); + const client = openCodeRuntime.createOpenCodeSdkClient({ + baseUrl: server.url, + directory: cwd, + ...(effectiveConfig.serverPassword + ? { serverPassword: effectiveConfig.serverPassword } + : {}), + }); + return yield* loadWorkspaceInventory(client); + }), + ) + : serverOwner.withServer((server) => + loadWorkspaceInventory( + openCodeRuntime.createOpenCodeSdkClient({ + baseUrl: server.url, + directory: cwd, + ...(server.serverPassword !== undefined + ? { serverPassword: server.serverPassword } + : {}), + }), + ), + ); + + const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, host.settings); + const snapshot = yield* makeManagedServerProvider>( + { + resolveMaintenance, + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, + checkProviderOnSettingsChange: () => false, + refreshOnInterval: false, + initialSnapshot: (settings) => + makePendingOpenCodeProvider(settings.provider).pipe(Effect.map(stampIdentity)), + checkProvider, + enrichSnapshot: ({ settings, snapshot, publishSnapshot }) => + resolveMaintenance().pipe( + Effect.flatMap((maintenanceCapabilities) => + enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities, { + enableProviderUpdateChecks: settings.enableProviderUpdateChecks, + }), + ), + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), + ), + }, + ).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to build OpenCode snapshot: ${cause.message ?? String(cause)}`, + cause, + }), + ), + ); + + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + snapshotForCwd: (cwd) => + !effectiveConfig.enabled + ? snapshot.getSnapshot + : byOpenCodeRuntime(runtimeProbe.get, { + v2: Effect.all([ + snapshot.getSnapshot, + listOpenCode2Workspace(cwd).pipe( + Effect.timeout("20 seconds"), + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to list OpenCode commands and skills for '${cwd}'`, + cause, + }), + ), + ), + ]).pipe( + Effect.map(([machineSnapshot, { skills, commands }]) => ({ + ...machineSnapshot, + skills: openCode2SkillsToServerProviderSkills(skills), + slashCommands: openCode2CommandsToServerProviderSlashCommands(commands), + })), + ), + v1: Effect.all([ + snapshot.getSnapshot, + loadWorkspaceForCwd(cwd).pipe(Effect.timeout("20 seconds")), + ]).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/packages/provider-opencode/src/server/probeResponses.fixture.ts b/packages/provider-opencode/src/server/probeResponses.fixture.ts new file mode 100644 index 0000000000000000000000000000000000000000..0eff526acd4f5d0b393845f76b8f32f499b2e60b --- /dev/null +++ b/packages/provider-opencode/src/server/probeResponses.fixture.ts @@ -0,0 +1,93 @@ +/** + * Responses recorded from `opencode serve` 1.18.32 and 2.0.18 on 2026-09-29, replayed by an + * `HttpClient` so the version probe runs against real bytes. + */ +import * as Effect from "effect/Effect"; +import { HttpClient, HttpClientResponse } from "effect/http"; + +interface RecordedResponse { + readonly status: number; + readonly contentType?: string; + readonly body: string; +} + +// Both versions serve their web UI's index page for unknown paths. Bodies are cut after the +// first lines; the classifier rejects them on content type alone. +const SPA_HTML = + '\n\n'; + +export const OPENCODE_2_RESPONSES = { + "/api/info": { + status: 200, + contentType: "application/json", + body: '{"version":"2.0.18","pid":2393556,"urls":["http://127.0.0.1:48771"],"paths":{"tmp":"/tmp/opencode"}}', + }, + "/global/health": { status: 200, contentType: "text/html", body: SPA_HTML }, + unauthorized: { + status: 401, + contentType: "application/json", + body: '{"_tag":"UnauthorizedError","message":"Authentication required"}', + }, +} satisfies Record; + +/** + * A 2.0.18 server's command and skill lists for a directory with one command + * (`.opencode/command/bee.md`) and one skill (`.opencode/skills/plum`), live on + * 2026-09-29. Skill contents are cut; machine-wide skills are dropped. + */ +export const OPENCODE_2_WORKSPACE_RESPONSES = { + "/api/command": { + status: 200, + contentType: "application/json", + body: '{"location":{"directory":"/work"},"data":[{"name":"init","description":"guided AGENTS.md setup"},{"name":"review","description":"review changes [commit|branch|pr], defaults to uncommitted"},{"name":"bee","description":"B command"}]}', + }, + "/api/skill": { + status: 200, + contentType: "application/json", + body: '{"location":{"directory":"/work"},"data":[{"id":"opencode","name":"OpenCode","description":"Use this skill for any question about OpenCode itself.","path":"/builtin/opencode.md","content":"# OpenCode"},{"id":"report","name":"Report","description":"Use when the user wants to report an opencode issue or bug.","path":"/builtin/report.md","content":""},{"id":"plum","name":"plum","description":"plum skill","path":"/work/.opencode/skills/plum/SKILL.md","content":"Say PLUM."}]}', + }, +} satisfies Record; + +export const OPENCODE_1_RESPONSES = { + "/api/info": { status: 200, contentType: "text/html", body: SPA_HTML }, + "/global/health": { + status: 200, + contentType: "application/json", + body: '{"healthy":true,"version":"1.18.32"}', + }, + // 1.x also answers a wrong password with an empty 401 on every path. + unauthorized: { status: 401, body: "" }, +} satisfies Record; + +/** Replays a recorded server; credentials other than `password` (none when empty) get its 401. */ +export function replayOpenCodeServer( + responses: + | typeof OPENCODE_1_RESPONSES + | typeof OPENCODE_2_RESPONSES + | (typeof OPENCODE_2_RESPONSES & typeof OPENCODE_2_WORKSPACE_RESPONSES), + password: string, + requestedPaths: Array = [], +) { + const answers: Record = responses; + // Both versions decode Basic credentials as UTF-8 (checked live with `pässwörd` and `pass€word`). + const expected = password + ? `Basic ${Buffer.from(`opencode:${password}`, "utf8").toString("base64")}` + : undefined; + return HttpClient.make((request) => { + const path = new URL(request.url).pathname; + requestedPaths.push(path); + const recorded: RecordedResponse = + request.headers.authorization === expected + ? (answers[path] ?? responses.unauthorized) + : responses.unauthorized; + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(recorded.body, { + status: recorded.status, + headers: recorded.contentType ? { "content-type": recorded.contentType } : {}, + }), + ), + ); + }); +} diff --git a/packages/provider-opencode/src/server/status.test.ts b/packages/provider-opencode/src/server/status.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..f062330ad733e05e71068ef7d3d12340bbfde2f2 --- /dev/null +++ b/packages/provider-opencode/src/server/status.test.ts @@ -0,0 +1,871 @@ +import * as NodeAssert from "node:assert/strict"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +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 TestClock from "effect/testing/TestClock"; +import { HttpClient, HttpClientResponse } from "effect/http"; +import { beforeEach } from "vite-plus/test"; + +import { OpenCodeSettings } from "../settings.ts"; +import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; +import * as OpenCodeServerOwner from "./OpenCodeServerOwner.ts"; +import { + checkOpenCodeProviderStatus, + loadOpenCode2Workspace, + makeOpenCode2ModelLoader, + type OpenCode2Model, + type OpenCode2Workspace, + openCode2CommandsToServerProviderSlashCommands, + openCode2SkillsToServerProviderSkills, + openCodeCommandsToServerProviderSlashCommands, +} from "./status.ts"; +import { readOpenCodeGoUsageLimits } from "./usageLimits.ts"; +import { probeOpenCodeRuntime } from "./versionProbe.ts"; +import { + OPENCODE_1_RESPONSES, + OPENCODE_2_RESPONSES, + replayOpenCodeServer, +} from "./probeResponses.fixture.ts"; +const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings); + +const DEFAULT_VERSION_STDOUT = "opencode 1.14.19\n"; + +it.effect("reads Go limits with the instance's XDG credentials and preserves reset times", () => + Effect.gen(function* () { + const resetsAt = "2026-09-17T12:00:00.000Z"; + const limits = yield* readOpenCodeGoUsageLimits({ + enabled: true, + serverUrl: "", + environment: { XDG_DATA_HOME: "/instance/data", OPENCODE_API_KEY: "env-key" }, + }).pipe( + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + readFileString: (path) => { + NodeAssert.equal(path, "/instance/data/opencode/auth.json"); + return Effect.succeed( + JSON.stringify({ "opencode-go": { type: "api", key: "instance-key" } }), + ); + }, + }), + ), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => { + NodeAssert.equal(request.url, "https://opencode.ai/zen/go/v1/usage"); + NodeAssert.equal(request.headers.authorization, "Bearer instance-key"); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json({ + usage: { + rolling: { percent: 0, resetsAt }, + weekly: { percent: 10, resetsAt }, + monthly: { percent: 125, resetsAt }, + }, + }), + ), + ); + }), + ), + Effect.provide(NodeServices.layer), + ); + NodeAssert.equal(limits.unavailable, undefined); + NodeAssert.equal( + limits.credentialFingerprint, + // SHA-256 of "opencode-go\0instance-key". + "aba48e85c981a8edc1c9fb4575121accc7235fa55d0f8689f66f153de5566a37", + ); + NodeAssert.deepEqual( + limits.windows.map(({ kind, usedPercent, resetsAt: reset }) => ({ + kind, + usedPercent, + reset, + })), + [ + { kind: "session", usedPercent: 0, reset: resetsAt }, + { kind: "weekly", usedPercent: 10, reset: resetsAt }, + { kind: "monthly", usedPercent: 100, reset: resetsAt }, + ], + ); + }), +); + +it.effect("does not read local credentials for external or disabled OpenCode instances", () => + Effect.gen(function* () { + for (const settings of [ + { enabled: true, serverUrl: "https://remote.example" }, + { enabled: false, serverUrl: "" }, + ]) { + const limits = yield* readOpenCodeGoUsageLimits({ ...settings, environment: {} }).pipe( + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + readFileString: () => Effect.die("unexpected credential read"), + }), + ), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("unexpected usage request")), + ), + Effect.provide(NodeServices.layer), + ); + NodeAssert.equal(limits.unavailable?.reason, "unsupported"); + } + }), +); + +it.effect("keeps Go entitlement absence distinct from failed or malformed usage responses", () => + Effect.gen(function* () { + for (const [status, reason] of [ + [403, "unsupported"], + [401, "probeFailed"], + [200, "probeFailed"], + ] as const) { + const limits = yield* readOpenCodeGoUsageLimits({ + enabled: true, + serverUrl: "", + environment: { + OPENCODE_AUTH_CONTENT: '{"opencode-go":{"type":"api","key":"inline-key"}}', + }, + }).pipe( + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + readFileString: () => Effect.die("inline credentials must bypass disk"), + }), + ), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({}, { status }))), + ), + ), + Effect.provide(NodeServices.layer), + ); + NodeAssert.equal(limits.unavailable?.reason, reason); + NodeAssert.equal(limits.credentialFingerprint, undefined); + NodeAssert.deepEqual(limits.windows, []); + } + }), +); + +/** + * The legacy `OpenCodeProviderLive` Layer + `OpenCodeProvider` service tag + * are deleted. The snapshot-producing logic they wrapped now lives in the + * standalone `checkOpenCodeProviderStatus(settings, cwd)` Effect, which + * drivers call directly when building their per-instance snapshot + * `ServerProviderShape`. Tests mirror that shape: build a settings payload, + * invoke the check, assert on the returned snapshot. + */ + +const runtimeMock = { + state: { + runVersionError: null as Error | null, + runVersionPending: false, + versionStdout: DEFAULT_VERSION_STDOUT, + inventoryError: null as Error | null, + connectionError: null as Error | null, + inventoryCwd: null as string | null, + closeCalls: 0, + sdkClientInputs: [] as Array<{ + baseUrl: string; + directory: string; + serverPassword?: string; + }>, + inventory: { + providerList: { connected: [] as string[], all: [] as unknown[], default: {} }, + agents: [] as unknown[], + skills: [] as unknown[], + } as unknown, + }, + reset() { + this.state.runVersionError = null; + this.state.runVersionPending = false; + this.state.versionStdout = DEFAULT_VERSION_STDOUT; + this.state.inventoryError = null; + this.state.connectionError = null; + this.state.inventoryCwd = null; + this.state.closeCalls = 0; + this.state.sdkClientInputs.length = 0; + this.state.inventory = { + providerList: { connected: [], all: [] as unknown[], default: {} }, + agents: [] as unknown[], + skills: [] as unknown[], + }; + }, +}; + +const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = { + startOpenCodeServerProcess: ({ serverPassword, environment }) => + Effect.gen(function* () { + yield* Effect.addFinalizer(() => + Effect.sync(() => { + runtimeMock.state.closeCalls += 1; + }), + ); + const effectiveServerPassword = OpenCodeRuntime.resolveOpenCodeServerPassword({ + external: false, + ...(serverPassword !== undefined ? { serverPassword } : {}), + ...(environment !== undefined ? { environment } : {}), + }); + return { + url: "http://127.0.0.1:4301", + ...(effectiveServerPassword !== undefined + ? { serverPassword: effectiveServerPassword } + : {}), + version: "1.14.19", + isRunning: Effect.succeed(true), + exitCode: Effect.never, + }; + }), + connectToOpenCodeServer: ({ serverUrl, serverPassword }) => + Effect.gen(function* () { + if (runtimeMock.state.connectionError) { + return yield* new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: "global.health", + detail: runtimeMock.state.connectionError.message, + cause: runtimeMock.state.connectionError, + }); + } + if (!serverUrl) { + yield* Effect.addFinalizer(() => + Effect.sync(() => { + runtimeMock.state.closeCalls += 1; + }), + ); + } + return { + url: serverUrl ?? "http://127.0.0.1:4301", + ...(serverPassword ? { serverPassword } : {}), + version: "1.14.19", + exitCode: null, + external: Boolean(serverUrl), + }; + }), + runOpenCodeCommand: () => + runtimeMock.state.runVersionPending + ? Effect.never + : runtimeMock.state.runVersionError + ? Effect.fail( + new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: "runOpenCodeCommand", + detail: runtimeMock.state.runVersionError.message, + cause: runtimeMock.state.runVersionError, + }), + ) + : Effect.succeed({ stdout: runtimeMock.state.versionStdout, stderr: "", code: 0 }), + createOpenCodeSdkClient: (input) => { + runtimeMock.state.sdkClientInputs.push(input); + return {} as unknown as ReturnType< + OpenCodeRuntime.OpenCodeRuntimeShape["createOpenCodeSdkClient"] + >; + }, + loadOpenCodeInventory: () => + runtimeMock.state.inventoryError + ? Effect.fail( + new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: "loadOpenCodeInventory", + detail: runtimeMock.state.inventoryError.message, + cause: runtimeMock.state.inventoryError, + }), + ) + : Effect.succeed(runtimeMock.state.inventory as OpenCodeRuntime.OpenCodeInventory), + loadInventoryFromCli: ({ cwd }) => { + runtimeMock.state.inventoryCwd = cwd; + return runtimeMock.state.inventoryError + ? Effect.fail( + new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: "loadInventoryFromCli", + detail: runtimeMock.state.inventoryError.message, + cause: runtimeMock.state.inventoryError, + }), + ) + : Effect.succeed(runtimeMock.state.inventory as OpenCodeRuntime.OpenCodeInventory); + }, + loadOpenCodeSkills: () => Effect.succeed([]), + loadSkillsFromCli: () => Effect.succeed([]), +}; + +beforeEach(() => { + runtimeMock.reset(); +}); + +it("keeps native and MCP commands while preserving compaction and separate skills", () => { + NodeAssert.deepEqual( + openCodeCommandsToServerProviderSlashCommands([ + { name: "review", description: "Review changes", source: "command", hints: ["$ARGUMENTS"] }, + { name: "review", source: "command", hints: [] }, + { name: "compact", source: "command", hints: [] }, + { name: "skill", source: "skill", hints: [] }, + { name: "mcp:search", source: "mcp", hints: ["query"] }, + ]).slice(1), + [ + { name: "review", description: "Review changes", input: { hint: "$ARGUMENTS" } }, + { name: "mcp:search", input: { hint: "query" } }, + ], + ); +}); + +const layerTest = Layer.succeed(OpenCodeRuntime.OpenCodeRuntime, OpenCodeRuntimeTestDouble).pipe( + Layer.provideMerge(NodeServices.layer), +); + +const makeOpenCodeSettings = (overrides?: Partial): OpenCodeSettings => + decodeOpenCodeSettings({ + enabled: true, + binaryPath: "opencode", + serverUrl: "", + serverPassword: "", + customModels: [], + ...overrides, + }); + +const checkProvider = Effect.fn("checkProvider")(function* ( + settings: OpenCodeSettings, + cwd = process.cwd(), + environment?: NodeJS.ProcessEnv, + server = replayOpenCodeServer(OPENCODE_1_RESPONSES, settings.serverPassword), + openCode2Models: Effect.Effect< + ReadonlyArray, + OpenCodeRuntime.OpenCodeRuntimeError + > = Effect.succeed([]), +) { + return yield* Effect.scoped( + Effect.gen(function* () { + const serverOwner = yield* OpenCodeServerOwner.make({ + binaryPath: settings.binaryPath, + directory: cwd, + ...(settings.serverPassword ? { serverPassword: settings.serverPassword } : {}), + ...(environment ? { environment } : {}), + }); + const probe = probeOpenCodeRuntime(settings, environment).pipe( + Effect.provideService(HttpClient.HttpClient, server), + Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, OpenCodeRuntimeTestDouble), + ); + return yield* checkOpenCodeProviderStatus(settings, cwd, probe, openCode2Models).pipe( + Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner), + ); + }), + ); +}); + +it.layer(layerTest)("checkOpenCodeProviderStatus", (it) => { + it.effect("shows a codex-style missing binary message", () => + Effect.gen(function* () { + runtimeMock.state.runVersionError = new Error("spawn opencode ENOENT"); + const snapshot = yield* checkProvider(makeOpenCodeSettings()); + + NodeAssert.equal(snapshot.status, "error"); + NodeAssert.equal(snapshot.installed, false); + NodeAssert.equal( + snapshot.message, + "OpenCode CLI (`opencode`) is not installed or not on PATH.", + ); + }), + ); + + it.effect("hides generic Effect.tryPromise text for local CLI probe failures", () => + Effect.gen(function* () { + runtimeMock.state.runVersionError = new Error("An error occurred in Effect.tryPromise"); + const snapshot = yield* checkProvider(makeOpenCodeSettings()); + + NodeAssert.equal(snapshot.status, "error"); + NodeAssert.equal(snapshot.installed, true); + NodeAssert.equal(snapshot.message, "Failed to execute OpenCode CLI health check."); + }), + ); + + it.effect("times out a hanging local CLI version probe", () => + Effect.gen(function* () { + runtimeMock.state.runVersionPending = true; + const probeFiber = yield* checkProvider(makeOpenCodeSettings()).pipe(Effect.forkChild); + + yield* Effect.yieldNow; + yield* TestClock.adjust("4 seconds"); + const snapshot = yield* Fiber.join(probeFiber); + + NodeAssert.equal(snapshot.status, "error"); + NodeAssert.equal(snapshot.installed, true); + NodeAssert.equal( + snapshot.message, + "Failed to execute OpenCode CLI health check: OpenCode CLI version probe timed out after 4 seconds.", + ); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("emits OpenCode variant defaults so trait picker can resolve a visible selection", () => + Effect.gen(function* () { + runtimeMock.state.inventory = { + providerList: { + connected: ["openai"], + all: [ + { + id: "openai", + name: "OpenAI", + models: { + "gpt-5.4": { + id: "gpt-5.4", + name: "GPT-5.4", + variants: { + none: {}, + low: {}, + medium: {}, + high: {}, + xhigh: {}, + }, + }, + }, + }, + ], + default: {}, + }, + agents: [ + { name: "build", hidden: false, mode: "primary" }, + { name: "plan", hidden: false, mode: "primary" }, + ], + }; + + const snapshot = yield* checkProvider(makeOpenCodeSettings()); + const model = snapshot.models.find((entry) => entry.slug === "openai/gpt-5.4"); + + NodeAssert.ok(model); + const variantDescriptor = model.capabilities?.optionDescriptors?.find( + (descriptor) => descriptor.id === "variant" && descriptor.type === "select", + ); + NodeAssert.ok(variantDescriptor && variantDescriptor.type === "select"); + NodeAssert.equal( + variantDescriptor.options.find((option) => option.isDefault === true)?.id, + "medium", + ); + const agentDescriptor = model.capabilities?.optionDescriptors?.find( + (descriptor) => descriptor.id === "agent" && descriptor.type === "select", + ); + NodeAssert.ok(agentDescriptor && agentDescriptor.type === "select"); + NodeAssert.equal( + agentDescriptor.options.find((option) => option.isDefault === true)?.id, + "build", + ); + }), + ); + + it.effect("includes OpenCode skills in the provider snapshot", () => + Effect.gen(function* () { + runtimeMock.state.inventory = { + providerList: { + connected: ["openai"], + all: [ + { + id: "openai", + name: "OpenAI", + models: { + "gpt-5.4": { + id: "gpt-5.4", + name: "GPT-5.4", + variants: {}, + }, + }, + }, + ], + default: {}, + }, + agents: [], + skills: [ + { + name: "openclaw-review", + description: "Review OpenClaw workflow changes.", + location: "/Users/test/.agents/skills/openclaw-review/SKILL.md", + }, + { + name: "openclaw-triage", + description: "Triage OpenClaw routing issues.", + location: "/Users/test/.agents/skills/openclaw-triage/SKILL.md", + }, + { + name: "missing-location", + description: "This incomplete SDK row should be skipped.", + location: "", + }, + ], + }; + + const snapshot = yield* checkProvider(makeOpenCodeSettings()); + + NodeAssert.deepEqual( + snapshot.skills.map((skill) => ({ + name: skill.name, + path: skill.path, + enabled: skill.enabled, + shortDescription: skill.shortDescription, + })), + [ + { + name: "openclaw-review", + path: "/Users/test/.agents/skills/openclaw-review/SKILL.md", + enabled: true, + shortDescription: "Review OpenClaw workflow changes.", + }, + { + name: "openclaw-triage", + path: "/Users/test/.agents/skills/openclaw-triage/SKILL.md", + enabled: true, + shortDescription: "Triage OpenClaw routing issues.", + }, + ], + ); + }), + ); + + it.effect("loads local inventory from a scoped OpenCode server", () => + Effect.gen(function* () { + yield* checkProvider(makeOpenCodeSettings({ serverPassword: "secret-password" })); + + NodeAssert.deepEqual(runtimeMock.state.sdkClientInputs, [ + { + baseUrl: "http://127.0.0.1:4301", + directory: process.cwd(), + serverPassword: "secret-password", + }, + ]); + NodeAssert.equal(runtimeMock.state.closeCalls, 1); + NodeAssert.equal(runtimeMock.state.inventoryCwd, null); + }), + ); + + it.effect("lists a local OpenCode 2 binary's models in Full access only, never via 1.x", () => + Effect.gen(function* () { + runtimeMock.state.versionStdout = "opencode v2.0.18\n"; + const snapshot = yield* checkProvider( + makeOpenCodeSettings(), + process.cwd(), + undefined, + undefined, + Effect.succeed([ + { providerID: "opencode", id: "big-pickle", name: "Big Pickle", variants: [] }, + { + providerID: "opencode", + id: "space-bunny-free", + name: "Space Bunny Free", + variants: [{ id: "low" }, { id: "medium" }, { id: "high" }], + }, + ]), + ); + + NodeAssert.equal(snapshot.status, "ready"); + NodeAssert.equal(snapshot.version, "2.0.18"); + NodeAssert.deepEqual(snapshot.supportedRuntimeModes, [ + "approval-required", + "auto-accept-edits", + "auto", + "full-access", + ]); + // Plan mode runs as OpenCode's plan agent, and every session can compact. + NodeAssert.equal(snapshot.showInteractionModeToggle, true); + NodeAssert.deepEqual( + snapshot.slashCommands.map((command) => command.name), + ["compact"], + ); + NodeAssert.deepEqual( + snapshot.models.map((model) => model.slug), + ["opencode/big-pickle", "opencode/space-bunny-free"], + ); + const variant = snapshot.models.find((model) => model.slug === "opencode/space-bunny-free") + ?.capabilities?.optionDescriptors?.[0]; + NodeAssert.deepEqual( + variant?.type === "select" ? variant.options.map((option) => option.id) : [], + ["low", "medium", "high"], + ); + NodeAssert.equal(runtimeMock.state.sdkClientInputs.length, 0); + }), + ); + + it.effect("reports a failed OpenCode 2 model list without the server's response", () => + Effect.gen(function* () { + runtimeMock.state.versionStdout = "opencode v2.0.18\n"; + const snapshot = yield* checkProvider( + makeOpenCodeSettings(), + process.cwd(), + undefined, + undefined, + Effect.fail( + new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: "model.list", + detail: 'status=500 body={"token":"leaked-response-body"}', + }), + ), + ); + + NodeAssert.equal(snapshot.status, "error"); + NodeAssert.equal(snapshot.message, "OpenCode could not load its model list."); + // A failed catalog read still leaves the session able to compact. + NodeAssert.deepEqual( + snapshot.slashCommands.map((command) => command.name), + ["compact"], + ); + }), + ); + + it.effect("uses an environment-only password for local inventory", () => + Effect.gen(function* () { + yield* checkProvider(makeOpenCodeSettings(), process.cwd(), { + OPENCODE_SERVER_PASSWORD: "environment-password", + }); + + NodeAssert.deepEqual(runtimeMock.state.sdkClientInputs, [ + { + baseUrl: "http://127.0.0.1:4301", + directory: process.cwd(), + serverPassword: "environment-password", + }, + ]); + }), + ); + + it.effect("uses the settings password when local environment auth differs", () => + Effect.gen(function* () { + yield* checkProvider( + makeOpenCodeSettings({ serverPassword: "settings-password" }), + process.cwd(), + { OPENCODE_SERVER_PASSWORD: "environment-password" }, + ); + + NodeAssert.equal(runtimeMock.state.sdkClientInputs[0]?.serverPassword, "settings-password"); + }), + ); + + it.effect("reports local model inventory failures without treating them as empty", () => + Effect.gen(function* () { + runtimeMock.state.inventoryError = new Error("opencode models failed"); + const snapshot = yield* checkProvider(makeOpenCodeSettings()); + + NodeAssert.equal(snapshot.status, "error"); + NodeAssert.equal(snapshot.installed, true); + NodeAssert.equal(snapshot.models.length, 0); + NodeAssert.equal( + snapshot.message, + "Failed to load OpenCode provider inventory: opencode models failed", + ); + }), + ); +}); + +it.layer(layerTest)("checkOpenCodeProviderStatus with configured server URL", (it) => { + it.effect("does not send a local environment password to a configured server", () => + Effect.gen(function* () { + const snapshot = yield* checkProvider( + makeOpenCodeSettings({ serverUrl: "http://127.0.0.1:9999" }), + process.cwd(), + { OPENCODE_SERVER_PASSWORD: "local-secret" }, + ); + + NodeAssert.equal(snapshot.version, "1.14.19"); + NodeAssert.deepEqual(runtimeMock.state.sdkClientInputs, [ + { + baseUrl: "http://127.0.0.1:9999", + directory: process.cwd(), + }, + ]); + }), + ); + + 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", + serverPassword: "secret-password", + }); + const snapshot = yield* checkProvider( + settings, + process.cwd(), + undefined, + replayOpenCodeServer(OPENCODE_2_RESPONSES, "secret-password"), + ); + + NodeAssert.equal(snapshot.status, "warning"); + NodeAssert.equal(snapshot.version, "2.0.18"); + NodeAssert.deepEqual(snapshot.supportedRuntimeModes, [ + "approval-required", + "auto-accept-edits", + "auto", + "full-access", + ]); + NodeAssert.equal(runtimeMock.state.sdkClientInputs.length, 0); + }), + ); + + it.effect("reports a rejected OpenCode 2 password as an auth error, not a version", () => + Effect.gen(function* () { + const snapshot = yield* checkProvider( + makeOpenCodeSettings({ serverUrl: "http://127.0.0.1:9999", serverPassword: "wrong" }), + process.cwd(), + undefined, + replayOpenCodeServer(OPENCODE_2_RESPONSES, "secret-password"), + ); + + NodeAssert.equal(snapshot.status, "error"); + NodeAssert.equal( + snapshot.message, + "OpenCode server rejected authentication. Check the server URL and password.", + ); + }), + ); + + it.effect("rejects an unsupported server before loading inventory", () => + Effect.gen(function* () { + runtimeMock.state.connectionError = new Error( + "OpenCode v1.14.18 is too old. Upgrade to v1.14.19 or newer.", + ); + const snapshot = yield* checkProvider( + makeOpenCodeSettings({ serverUrl: "http://127.0.0.1:9999" }), + ); + + NodeAssert.equal(snapshot.status, "error"); + NodeAssert.equal(snapshot.models.length, 0); + NodeAssert.match(snapshot.message ?? "", /v1\.14\.18 is too old/); + NodeAssert.equal(runtimeMock.state.sdkClientInputs.length, 0); + }), + ); + + it.effect("surfaces a friendly auth error for configured servers", () => + Effect.gen(function* () { + runtimeMock.state.connectionError = new Error("401 Unauthorized"); + const snapshot = yield* checkProvider( + makeOpenCodeSettings({ + serverUrl: "http://127.0.0.1:9999", + serverPassword: "secret-password", + }), + ); + + NodeAssert.equal(snapshot.status, "error"); + NodeAssert.equal(snapshot.installed, true); + NodeAssert.equal( + snapshot.message, + "OpenCode server rejected authentication. Check the server URL and password.", + ); + }), + ); + + it.effect("surfaces a friendly connection error for configured servers", () => + Effect.gen(function* () { + runtimeMock.state.connectionError = new Error( + "fetch failed: connect ECONNREFUSED 127.0.0.1:9999", + ); + const snapshot = yield* checkProvider( + makeOpenCodeSettings({ + serverUrl: "http://127.0.0.1:9999", + serverPassword: "secret-password", + }), + ); + + NodeAssert.equal(snapshot.status, "error"); + NodeAssert.equal(snapshot.installed, true); + NodeAssert.equal( + snapshot.message, + "Couldn't reach the configured OpenCode server at http://127.0.0.1:9999. Check that the server is running and the URL is correct.", + ); + }), + ); +}); + +const bigPickle: OpenCode2Model = { + providerID: "opencode", + id: "big-pickle", + name: "Big Pickle", + variants: [], +}; + +it.effect("waits for a fresh OpenCode 2 server to list its models", () => + Effect.gen(function* () { + // A fresh server lists nothing until its catalog loads. + const replies: Array> = [[], [], [bigPickle]]; + const load = yield* makeOpenCode2ModelLoader(Effect.sync(() => replies.shift() ?? [bigPickle])); + const fiber = yield* load.pipe(Effect.forkChild); + yield* TestClock.adjust("1 second"); + NodeAssert.deepEqual(yield* Fiber.join(fiber), [bigPickle]); + }).pipe(Effect.provide(TestClock.layer())), +); + +it.effect("keeps the last OpenCode 2 model list while a fresh server's stays empty", () => + Effect.gen(function* () { + let listed: ReadonlyArray = [bigPickle]; + const load = yield* makeOpenCode2ModelLoader(Effect.sync(() => listed)); + NodeAssert.deepEqual(yield* load, [bigPickle]); + listed = []; + const fiber = yield* load.pipe(Effect.forkChild); + yield* TestClock.adjust("6 seconds"); + NodeAssert.deepEqual(yield* Fiber.join(fiber), [bigPickle]); + }).pipe(Effect.provide(TestClock.layer())), +); + +// What 2.0.18 lists for a directory once it has scanned it (live, 2026-09-29). +const builtinCommands = [ + { name: "init", description: "guided AGENTS.md setup" }, + { name: "review", description: "review changes [commit|branch|pr], defaults to uncommitted" }, +]; +const builtinSkills = [ + { id: "opencode", name: "OpenCode", path: "/builtin/opencode.md" }, + { id: "report", name: "Report", path: "/builtin/report.md" }, +]; +const scanned: OpenCode2Workspace = { + commands: [...builtinCommands, { name: "hello", description: "Say hello to the workspace" }], + skills: [ + ...builtinSkills, + { + id: "greet", + name: "greet", + path: "/work/.opencode/skills/greet/SKILL.md", + description: "Greets the user with the secret word MANGO.", + }, + ], +}; + +it.effect("reads a fresh OpenCode 2 directory again once its scan has ended", () => + Effect.gen(function* () { + // A directory the server has not served yet lists no commands until its scan ends. + const reads: Array = [{ commands: [], skills: [] }, scanned]; + const scanEnded = yield* Deferred.make(); + const load = yield* loadOpenCode2Workspace( + Effect.sync(() => reads.shift()!), + Deferred.await(scanEnded), + ).pipe(Effect.forkChild); + yield* Effect.yieldNow; + NodeAssert.equal(reads.length, 1); + yield* Deferred.succeed(scanEnded, undefined); + NodeAssert.deepEqual(yield* Fiber.join(load), scanned); + }), +); + +it.effect("takes a served OpenCode 2 directory's first listing as is", () => + Effect.gen(function* () { + const workspace = yield* loadOpenCode2Workspace(Effect.succeed(scanned), Effect.never); + NodeAssert.deepEqual(workspace, scanned); + }), +); + +it("lists an OpenCode 2 directory's skills by id and its commands after /compact", () => { + NodeAssert.deepEqual( + openCode2SkillsToServerProviderSkills(scanned.skills).map((skill) => [ + skill.name, + skill.displayName, + skill.path, + ]), + [ + ["greet", undefined, "/work/.opencode/skills/greet/SKILL.md"], + ["opencode", "OpenCode", "/builtin/opencode.md"], + ["report", "Report", "/builtin/report.md"], + ], + ); + NodeAssert.deepEqual( + openCode2CommandsToServerProviderSlashCommands([ + ...scanned.commands, + { name: "compact", description: "a workspace command named like T3's own" }, + ]).map((command) => command.name), + ["compact", "init", "review", "hello"], + ); +}); diff --git a/packages/provider-opencode/src/server/status.ts b/packages/provider-opencode/src/server/status.ts new file mode 100644 index 0000000000000000000000000000000000000000..f89298af01ae61c02aff0ec66886ae58419ee4d7 --- /dev/null +++ b/packages/provider-opencode/src/server/status.ts @@ -0,0 +1,747 @@ +import { + type ModelCapabilities, + type ServerProviderModel, + type ServerProviderSkill, + type ServerProviderSlashCommand, +} from "@t3tools/contracts"; +import type { OpenCodeSettings } from "../settings.ts"; +import * as Cause from "effect/Cause"; +import * as Data from "effect/Data"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Schedule from "effect/Schedule"; + +import { createModelCapabilities } from "@t3tools/shared/model"; +import { compareSemverVersions } from "@t3tools/shared/semver"; +import { + buildServerProvider, + COMPACT_SLASH_COMMAND, + nonEmptyTrimmed, + providerModelsFromSettings, + type ServerProviderDraft, +} from "@t3tools/provider-core/server/snapshotProbe"; +import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; +import type { ProbedOpenCode } from "./versionProbe.ts"; +import type { Agent, ProviderListResponse } from "@opencode-ai/sdk/v2"; +import * as OpenCodeServerOwner from "./OpenCodeServerOwner.ts"; + +const OPENCODE_PRESENTATION = { + displayName: "OpenCode", + showInteractionModeToggle: false, +} as const; + +class OpenCodeProbeError extends Data.TaggedError("OpenCodeProbeError")<{ + readonly cause?: unknown; + readonly detail: string; +}> {} + +function normalizeProbeMessage(message: string): string | undefined { + const trimmed = message.trim(); + if (trimmed.length === 0) { + return undefined; + } + if ( + trimmed === "An error occurred in Effect.tryPromise" || + trimmed === "An error occurred in Effect.try" + ) { + return undefined; + } + return trimmed; +} + +function normalizedErrorMessage(cause: unknown): string | undefined { + if (cause instanceof OpenCodeProbeError) { + return normalizeProbeMessage(cause.detail); + } + + if (!(cause instanceof Error)) { + return undefined; + } + + return normalizeProbeMessage(cause.message); +} + +function formatOpenCodeProbeError(input: { + readonly cause: unknown; + readonly isExternalServer: boolean; + readonly phase: "version" | "inventory"; + readonly serverUrl: string; +}): { readonly installed: boolean; readonly message: string } { + const detail = normalizedErrorMessage(input.cause); + const lower = detail?.toLowerCase() ?? ""; + + if (input.isExternalServer) { + if ( + lower.includes("401") || + lower.includes("403") || + lower.includes("unauthorized") || + lower.includes("forbidden") + ) { + return { + installed: true, + message: "OpenCode server rejected authentication. Check the server URL and password.", + }; + } + + if ( + lower.includes("econnrefused") || + lower.includes("enotfound") || + lower.includes("fetch failed") || + lower.includes("networkerror") || + lower.includes("timed out") || + lower.includes("timeout") || + lower.includes("socket hang up") + ) { + return { + installed: true, + message: `Couldn't reach the configured OpenCode server at ${input.serverUrl}. Check that the server is running and the URL is correct.`, + }; + } + + return { + installed: true, + message: detail ?? "Failed to connect to the configured OpenCode server.", + }; + } + + if (lower.includes("enoent") || lower.includes("notfound")) { + return { + installed: false, + message: "OpenCode CLI (`opencode`) is not installed or not on PATH.", + }; + } + + if (lower.includes("quarantine")) { + return { + installed: true, + message: + "macOS is blocking the OpenCode binary (quarantine). Run `xattr -d com.apple.quarantine $(which opencode)` to fix this.", + }; + } + + if (lower.includes("invalid code signature") || lower.includes("corrupted")) { + return { + installed: true, + message: + "macOS killed the OpenCode process due to an invalid code signature. The binary may be corrupted — try reinstalling OpenCode.", + }; + } + + const failureLabel = + input.phase === "inventory" + ? "Failed to load OpenCode provider inventory" + : "Failed to execute OpenCode CLI health check"; + return { + installed: true, + message: detail ? `${failureLabel}: ${detail}` : `${failureLabel}.`, + }; +} + +function titleCaseSlug(value: string): string { + const segments: Array = []; + for (const segment of value.split(/[-_/]+/)) { + if (segment.length > 0) { + segments.push(segment.charAt(0).toUpperCase() + segment.slice(1)); + } + } + return segments.join(" "); +} + +function inferDefaultVariant( + providerID: string, + variants: ReadonlyArray, +): string | undefined { + if (variants.length === 1) { + return variants[0]; + } + if (providerID === "anthropic" || providerID.startsWith("google")) { + return variants.includes("high") ? "high" : undefined; + } + if (providerID === "openai" || providerID === "opencode") { + return variants.includes("medium") ? "medium" : variants.includes("high") ? "high" : undefined; + } + return undefined; +} + +function inferDefaultAgent(agents: ReadonlyArray): string | undefined { + return agents.find((agent) => agent.name === "build")?.name ?? agents[0]?.name ?? undefined; +} + +const DEFAULT_OPENCODE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [ + { + id: "variant", + label: "Reasoning", + type: "select", + options: [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium", isDefault: true }, + { id: "high", label: "High" }, + { id: "xhigh", label: "Extra High" }, + ], + currentValue: "medium", + }, + { + id: "agent", + label: "Agent", + type: "select", + options: [ + { id: "build", label: "Build", isDefault: true }, + { id: "plan", label: "Plan" }, + ], + currentValue: "build", + }, + ], +}); + +function openCodeCapabilitiesForModel(input: { + readonly providerID: string; + readonly model: ProviderListResponse["all"][number]["models"][string]; + readonly agents: ReadonlyArray; +}): ModelCapabilities { + const rawVariantValues = Object.keys(input.model.variants ?? {}); + // When a model advertises no variants, synthesize the standard reasoning + // levels so the composer still offers a Reasoning selector (mirrors the + // Codex/Grok experience where reasoning is always configurable). The set + // covers the common OpenCode variant spectrum; `inferDefaultVariant` + // picks the provider-appropriate default (e.g. medium for openai/opencode). + const variantValues = + rawVariantValues.length > 0 ? rawVariantValues : ["low", "medium", "high", "xhigh"]; + const defaultVariant = inferDefaultVariant(input.providerID, variantValues); + const variantOptions = variantValues.map((value) => + defaultVariant === value + ? { id: value, label: titleCaseSlug(value), isDefault: true as const } + : { id: value, label: titleCaseSlug(value) }, + ); + const primaryAgents = input.agents.filter( + (agent) => !agent.hidden && (agent.mode === "primary" || agent.mode === "all"), + ); + const defaultAgent = inferDefaultAgent(primaryAgents); + const agentOptions = primaryAgents.map((agent) => + defaultAgent === agent.name + ? { id: agent.name, label: titleCaseSlug(agent.name), isDefault: true as const } + : { id: agent.name, label: titleCaseSlug(agent.name) }, + ); + return createModelCapabilities({ + optionDescriptors: [ + ...(variantOptions.length > 0 + ? [ + { + id: "variant", + label: "Reasoning", + type: "select" as const, + options: variantOptions, + ...(defaultVariant ? { currentValue: defaultVariant } : {}), + }, + ] + : []), + ...(agentOptions.length > 0 + ? [ + { + id: "agent", + label: "Agent", + type: "select" as const, + options: agentOptions, + ...(defaultAgent ? { currentValue: defaultAgent } : {}), + }, + ] + : []), + ], + }); +} + +function flattenOpenCodeModels( + input: OpenCodeRuntime.OpenCodeInventory, +): ReadonlyArray { + const connected = new Set(input.providerList.connected); + const models: Array = []; + + for (const provider of input.providerList.all) { + if (!connected.has(provider.id)) { + continue; + } + + for (const model of Object.values(provider.models)) { + const name = nonEmptyTrimmed(model.name); + if (!name) { + continue; + } + + const subProvider = nonEmptyTrimmed(provider.name); + models.push({ + slug: `${provider.id}/${model.id}`, + name, + ...(subProvider ? { subProvider } : {}), + isCustom: false, + capabilities: openCodeCapabilitiesForModel({ + providerID: provider.id, + model, + agents: input.agents, + }), + }); + } + } + + return models.toSorted((left, right) => left.name.localeCompare(right.name)); +} + +function trimOptional(value: string | null | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : undefined; +} + +export function openCodeSkillsToServerProviderSkills( + input: OpenCodeRuntime.OpenCodeInventory["skills"] | undefined, +): ReadonlyArray { + const skills: ServerProviderSkill[] = []; + for (const skill of input ?? []) { + const name = trimOptional(skill.name); + const path = trimOptional(skill.location); + if (!name || !path) { + continue; + } + + const description = trimOptional(skill.description); + skills.push({ + name, + path, + enabled: true, + ...(description ? { description, shortDescription: description } : {}), + }); + } + + return skills.toSorted((left, right) => left.name.localeCompare(right.name)); +} + +export function openCodeCommandsToServerProviderSlashCommands( + input: OpenCodeRuntime.OpenCodeInventory["commands"], +): ReadonlyArray { + const commands: ServerProviderSlashCommand[] = [COMPACT_SLASH_COMMAND]; + const names = new Set([COMPACT_SLASH_COMMAND.name]); + for (const command of input ?? []) { + const name = trimOptional(command.name); + if (!name || names.has(name) || command.source === "skill") continue; + names.add(name); + const description = trimOptional(command.description); + const hint = trimOptional(command.hints.join(" ")); + commands.push({ + name, + ...(description ? { description } : {}), + ...(hint ? { input: { hint } } : {}), + }); + } + return commands; +} + +export const makePendingOpenCodeProvider = ( + openCodeSettings: OpenCodeSettings, +): Effect.Effect => + Effect.gen(function* () { + const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); + const models = providerModelsFromSettings( + [], + openCodeSettings.customModels, + DEFAULT_OPENCODE_MODEL_CAPABILITIES, + ); + + if (!openCodeSettings.enabled) { + return buildServerProvider({ + presentation: OPENCODE_PRESENTATION, + enabled: false, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: + openCodeSettings.serverUrl.trim().length > 0 + ? "OpenCode is disabled in T3 Code settings. A server URL is configured." + : "OpenCode is disabled in T3 Code settings.", + }, + }); + } + + return buildServerProvider({ + presentation: OPENCODE_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "OpenCode provider status has not been checked in this session yet.", + }, + }); + }); + +/** One model an OpenCode 2 server lists, as the status check reads it. */ +export interface OpenCode2Model { + readonly providerID: string; + readonly id: string; + readonly name: string; + readonly variants: ReadonlyArray<{ readonly id: string }>; +} + +/** + * A server loads its catalog lazily and lists nothing for its first few + * hundred milliseconds, and a local instance starts a fresh server for each + * status check. So an empty list is read again for a while, and the last + * non-empty list stands in if the catalog still has not loaded. + */ +export const makeOpenCode2ModelLoader = ( + list: Effect.Effect, E>, +) => + Effect.sync(() => { + let lastLoaded: ReadonlyArray = []; + return list.pipe( + Effect.repeat({ + until: (models) => models.length > 0, + schedule: Schedule.spaced("250 millis"), + }), + Effect.timeoutOption("5 seconds"), + Effect.map((loaded) => { + if (loaded._tag === "Some" && loaded.value.length > 0) lastLoaded = loaded.value; + return lastLoaded; + }), + ); + }); + +/** A workspace's skills and commands, as an OpenCode 2 server lists them for its directory. */ +export interface OpenCode2Workspace { + readonly skills: ReadonlyArray<{ + readonly id: string; + readonly name: string; + readonly path: string; + readonly description?: string | undefined; + }>; + readonly commands: ReadonlyArray<{ + readonly name: string; + readonly description?: string | undefined; + }>; +} + +/** + * A server scans a directory it has not served yet in the background: its + * first read lists no commands at all (not even the built-ins), and + * `command.updated` plus `skill.updated` for the directory end the scan. A + * directory it already serves lists everything at once. `scanned` must be + * listening before `read` runs, since the read is what starts the scan. + */ +export const loadOpenCode2Workspace = ( + read: Effect.Effect, + scanned: Effect.Effect, +) => + Effect.gen(function* () { + const first = yield* read; + if (first.commands.length > 0) return first; + yield* scanned.pipe(Effect.timeoutOption("10 seconds")); + return yield* read; + }); + +export function openCode2SkillsToServerProviderSkills( + skills: OpenCode2Workspace["skills"], +): ReadonlyArray { + return skills + .map((skill) => { + const description = trimOptional(skill.description); + return { + name: skill.id, + path: skill.path, + enabled: true, + ...(skill.name === skill.id ? {} : { displayName: skill.name }), + ...(description ? { description, shortDescription: description } : {}), + }; + }) + .toSorted((left, right) => left.name.localeCompare(right.name)); +} + +export function openCode2CommandsToServerProviderSlashCommands( + commands: OpenCode2Workspace["commands"], +): ReadonlyArray { + const slashCommands: ServerProviderSlashCommand[] = [COMPACT_SLASH_COMMAND]; + const names = new Set([COMPACT_SLASH_COMMAND.name]); + for (const command of commands) { + const name = trimOptional(command.name); + if (!name || names.has(name)) continue; + names.add(name); + const description = trimOptional(command.description); + slashCommands.push({ name, ...(description ? { description } : {}) }); + } + return slashCommands; +} + +/** + * Every mode maps onto OpenCode 2 session rules. Auto asks like Supervised: + * OpenCode has no reviewer that approves routine actions. Plan mode is its + * `plan` agent, so the composer's mode toggle drives it. + */ +const OPENCODE_2_PRESENTATION = { + ...OPENCODE_PRESENTATION, + showInteractionModeToggle: true, + supportedRuntimeModes: ["approval-required", "auto-accept-edits", "auto", "full-access"], +} as const; + +function openCode2ModelCapabilities(model: OpenCode2Model): ModelCapabilities { + const variants = model.variants.map((variant) => variant.id); + const defaultVariant = inferDefaultVariant(model.providerID, variants); + return createModelCapabilities({ + optionDescriptors: + variants.length === 0 + ? [] + : [ + { + id: "variant", + label: "Reasoning", + type: "select", + options: variants.map((id) => ({ + id, + label: titleCaseSlug(id), + ...(id === defaultVariant ? { isDefault: true } : {}), + })), + ...(defaultVariant === undefined ? {} : { currentValue: defaultVariant }), + }, + ], + }); +} + +const checkOpenCode2 = Effect.fn("checkOpenCode2")(function* ( + settings: OpenCodeSettings, + version: string, + checkedAt: string, + loadModels: Effect.Effect, OpenCodeRuntime.OpenCodeRuntimeError>, +) { + const result = yield* Effect.exit(loadModels); + const probe = (status: "ready" | "warning" | "error", message: string) => ({ + installed: true, + version, + status, + auth: { status: status === "ready" ? ("authenticated" as const) : ("unknown" as const) }, + message, + }); + if (result._tag === "Failure") { + // The detail can carry a response body; it stays in the log, not the status. + yield* Effect.logWarning("OpenCode 2 model list failed", result.cause); + return buildServerProvider({ + presentation: OPENCODE_2_PRESENTATION, + enabled: true, + checkedAt, + models: providerModelsFromSettings( + [], + settings.customModels, + DEFAULT_OPENCODE_MODEL_CAPABILITIES, + ), + // Compaction does not depend on the model catalog. + slashCommands: [COMPACT_SLASH_COMMAND], + probe: probe("error", "OpenCode could not load its model list."), + }); + } + const models = providerModelsFromSettings( + result.value + .map((model) => ({ + slug: `${model.providerID}/${model.id}`, + name: model.name, + isCustom: false, + capabilities: openCode2ModelCapabilities(model), + })) + .toSorted((left, right) => left.name.localeCompare(right.name)), + settings.customModels, + DEFAULT_OPENCODE_MODEL_CAPABILITIES, + ); + return buildServerProvider({ + presentation: OPENCODE_2_PRESENTATION, + enabled: true, + checkedAt, + models, + // Every session can compact; a workspace snapshot adds that directory's commands. + slashCommands: [COMPACT_SLASH_COMMAND], + probe: + result.value.length > 0 + ? probe( + "ready", + `OpenCode ${version} lists ${result.value.length} model${result.value.length === 1 ? "" : "s"}.`, + ) + : probe("warning", "OpenCode 2 is running, but it did not list any models yet."), + }); +}); + +/** + * `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 + * upgrade re-routes the instance. + */ +export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatus")(function* ( + openCodeSettings: OpenCodeSettings, + cwd: string, + probeRuntime: Effect.Effect, + loadOpenCode2Models: Effect.Effect< + ReadonlyArray, + OpenCodeRuntime.OpenCodeRuntimeError + >, +): Effect.fn.Return< + ServerProviderDraft, + never, + OpenCodeRuntime.OpenCodeRuntime | OpenCodeServerOwner.OpenCodeServerOwner +> { + const openCodeRuntime = yield* OpenCodeRuntime.OpenCodeRuntime; + const serverOwner = yield* OpenCodeServerOwner.OpenCodeServerOwner; + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const customModels = openCodeSettings.customModels; + const isExternalServer = openCodeSettings.serverUrl.trim().length > 0; + + const fallback = ( + cause: unknown, + version: string | null = null, + phase: "version" | "inventory" = "version", + ) => { + const failure = formatOpenCodeProbeError({ + cause, + isExternalServer, + phase, + serverUrl: openCodeSettings.serverUrl, + }); + return buildServerProvider({ + presentation: OPENCODE_PRESENTATION, + enabled: openCodeSettings.enabled, + checkedAt, + models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES), + probe: { + installed: failure.installed, + version, + status: "error", + auth: { status: "unknown" }, + message: failure.message, + }, + }); + }; + + if (!openCodeSettings.enabled) { + return buildServerProvider({ + presentation: OPENCODE_PRESENTATION, + enabled: false, + checkedAt, + models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES), + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: isExternalServer + ? "OpenCode is disabled in T3 Code settings. A server URL is configured." + : "OpenCode is disabled in T3 Code settings.", + }, + }); + } + + const probedExit = yield* Effect.exit( + probeRuntime.pipe( + Effect.mapError( + (cause) => + new OpenCodeProbeError({ + cause, + detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause), + }), + ), + ), + ); + if (probedExit._tag === "Failure") return fallback(Cause.squash(probedExit.cause)); + const probed = probedExit.value; + if (probed.generation === "v2") { + return yield* checkOpenCode2(openCodeSettings, probed.version, checkedAt, loadOpenCode2Models); + } + let version: string | null = probed.version; + if (compareSemverVersions(probed.version, OpenCodeRuntime.MINIMUM_OPENCODE_VERSION) < 0) { + return buildServerProvider({ + presentation: OPENCODE_PRESENTATION, + enabled: openCodeSettings.enabled, + checkedAt, + models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES), + probe: { + installed: true, + version: probed.version, + status: "error", + auth: { status: "unknown" }, + message: `OpenCode v${probed.version} is too old. Upgrade to v${OpenCodeRuntime.MINIMUM_OPENCODE_VERSION} or newer.`, + }, + }); + } + + const loadInventory = (server: { + readonly url: string; + readonly serverPassword?: string; + readonly version: string; + }) => + openCodeRuntime + .loadOpenCodeInventory( + openCodeRuntime.createOpenCodeSdkClient({ + baseUrl: server.url, + directory: cwd, + ...(server.serverPassword !== undefined ? { serverPassword: server.serverPassword } : {}), + }), + ) + .pipe(Effect.map((inventory) => ({ inventory, version: server.version }))); + const inventoryEffect = isExternalServer + ? openCodeRuntime + .connectToOpenCodeServer({ + binaryPath: openCodeSettings.binaryPath, + directory: cwd, + serverUrl: openCodeSettings.serverUrl, + ...(openCodeSettings.serverPassword + ? { serverPassword: openCodeSettings.serverPassword } + : {}), + }) + .pipe(Effect.flatMap(loadInventory), Effect.scoped) + : serverOwner.withServer(loadInventory); + const inventoryExit = yield* Effect.exit( + inventoryEffect.pipe( + Effect.mapError( + (cause) => + new OpenCodeProbeError({ + cause, + detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause), + }), + ), + ), + ); + if (inventoryExit._tag === "Failure") { + return fallback(Cause.squash(inventoryExit.cause), version, "inventory"); + } + + version = inventoryExit.value.version; + + const models = providerModelsFromSettings( + flattenOpenCodeModels(inventoryExit.value.inventory), + customModels, + DEFAULT_OPENCODE_MODEL_CAPABILITIES, + ); + const skills = openCodeSkillsToServerProviderSkills(inventoryExit.value.inventory.skills); + const connectedCount = inventoryExit.value.inventory.providerList.connected.length; + return buildServerProvider({ + presentation: OPENCODE_PRESENTATION, + enabled: true, + checkedAt, + models, + skills, + slashCommands: openCodeCommandsToServerProviderSlashCommands( + inventoryExit.value.inventory.commands, + ), + probe: { + installed: true, + version, + status: connectedCount > 0 ? "ready" : "warning", + auth: { + status: connectedCount > 0 ? "authenticated" : "unknown", + type: "opencode", + }, + message: + connectedCount > 0 + ? `${connectedCount} upstream provider${connectedCount === 1 ? "" : "s"} connected through ${isExternalServer ? "the configured OpenCode server" : "OpenCode"}.` + : isExternalServer + ? "Connected to the configured OpenCode server, but it did not report any connected upstream providers." + : "OpenCode is available, but it did not report any connected upstream providers.", + }, + }); +}); diff --git a/packages/provider-opencode/src/server/textGeneration.test.ts b/packages/provider-opencode/src/server/textGeneration.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..db93d27d274740905fc41d4f7ede24e3b8391f4b --- /dev/null +++ b/packages/provider-opencode/src/server/textGeneration.test.ts @@ -0,0 +1,609 @@ +import { ProviderInstanceId, TextGenerationError } from "@t3tools/contracts"; +import { OpenCodeSettings } from "../settings.ts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as TestClock from "effect/testing/TestClock"; +import * as NetService from "@t3tools/shared/Net"; +import { beforeEach, expect } from "vite-plus/test"; + +import { layerTestProviderHost } from "@t3tools/provider-testing/host"; +import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; +import * as OpenCodeServerOwner from "./OpenCodeServerOwner.ts"; +import * as OpenCodeTextGeneration from "./textGeneration.ts"; +import type { ProviderTextGeneration } from "@t3tools/provider-core/server/textGeneration"; + +const runtimeMock = { + state: { + startCalls: [] as string[], + promptUrls: [] as string[], + promptParts: [] as ReadonlyArray[], + authHeaders: [] as Array, + closeCalls: [] as string[], + sessionCreateCalls: 0, + connectionError: undefined as Error | undefined, + sessionCreateError: undefined as unknown, + sessionResult: undefined as { data?: { id: string } } | undefined, + promptRequestError: undefined as unknown, + promptResult: undefined as + | { data?: { info?: { error?: unknown }; parts?: Array } } + | undefined, + }, + reset() { + this.state.startCalls.length = 0; + this.state.promptUrls.length = 0; + this.state.promptParts.length = 0; + this.state.authHeaders.length = 0; + this.state.closeCalls.length = 0; + this.state.sessionCreateCalls = 0; + this.state.connectionError = undefined; + this.state.sessionCreateError = undefined; + this.state.sessionResult = undefined; + this.state.promptRequestError = undefined; + this.state.promptResult = undefined; + }, +}; + +const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = { + startOpenCodeServerProcess: ({ binaryPath, serverPassword, environment }) => + Effect.gen(function* () { + const index = runtimeMock.state.startCalls.length + 1; + const url = `http://127.0.0.1:${4_300 + index}`; + runtimeMock.state.startCalls.push(binaryPath); + // The production runtime binds server lifetime to the caller's scope. + // Mirror that here so the closeCalls probe observes scope close. + yield* Effect.addFinalizer(() => + Effect.sync(() => { + runtimeMock.state.closeCalls.push(url); + }), + ); + const effectiveServerPassword = OpenCodeRuntime.resolveOpenCodeServerPassword({ + external: false, + ...(serverPassword !== undefined ? { serverPassword } : {}), + ...(environment !== undefined ? { environment } : {}), + }); + return { + url, + ...(effectiveServerPassword !== undefined + ? { serverPassword: effectiveServerPassword } + : {}), + version: "1.14.19", + isRunning: Effect.succeed(true), + exitCode: Effect.never, + }; + }), + connectToOpenCodeServer: ({ serverUrl, serverPassword }) => + runtimeMock.state.connectionError + ? Effect.fail( + new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: "global.health", + detail: runtimeMock.state.connectionError.message, + cause: runtimeMock.state.connectionError, + }), + ) + : Effect.succeed({ + url: serverUrl ?? "http://127.0.0.1:4301", + ...(serverPassword ? { serverPassword } : {}), + version: "1.14.19", + exitCode: null, + external: Boolean(serverUrl), + }), + runOpenCodeCommand: () => Effect.succeed({ stdout: "", stderr: "", code: 0 }), + createOpenCodeSdkClient: ({ baseUrl, serverPassword }) => + ({ + session: { + create: async () => { + runtimeMock.state.sessionCreateCalls += 1; + if (runtimeMock.state.sessionCreateError !== undefined) { + throw runtimeMock.state.sessionCreateError; + } + return runtimeMock.state.sessionResult ?? { data: { id: `${baseUrl}/session` } }; + }, + prompt: async (input: { readonly parts: ReadonlyArray }) => { + runtimeMock.state.promptUrls.push(baseUrl); + runtimeMock.state.promptParts.push(input.parts); + runtimeMock.state.authHeaders.push( + serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null, + ); + if (runtimeMock.state.promptRequestError !== undefined) { + throw runtimeMock.state.promptRequestError; + } + return ( + runtimeMock.state.promptResult ?? { + data: { + parts: [ + { + type: "text", + text: JSON.stringify({ + subject: "Improve OpenCode reuse", + body: "Reuse one server for the full action.", + }), + }, + ], + }, + } + ); + }, + }, + }) as unknown as ReturnType, + loadOpenCodeInventory: () => + Effect.fail( + new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: "loadOpenCodeInventory", + detail: "OpenCodeRuntimeTestDouble.loadOpenCodeInventory not used in this test", + cause: null, + }), + ), + loadOpenCodeSkills: () => Effect.succeed([]), + loadInventoryFromCli: () => + Effect.fail( + new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: "loadInventoryFromCli", + detail: "OpenCodeRuntimeTestDouble.loadInventoryFromCli not used in this test", + cause: null, + }), + ), + loadSkillsFromCli: () => Effect.succeed([]), +}; + +const DEFAULT_TEST_MODEL_SELECTION = { + instanceId: ProviderInstanceId.make("opencode"), + model: "openai/gpt-5", +}; +const DEFAULT_COMMIT_MESSAGE_INPUT = { + cwd: process.cwd(), + branch: "feature/opencode-reuse", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, +}; + +const OPENCODE_TEXT_GENERATION_IDLE_TTL_MS = 30_000; + +const layerOpenCodeTextGenerationTest = Layer.succeed( + OpenCodeRuntime.OpenCodeRuntime, + OpenCodeRuntimeTestDouble, +).pipe( + Layer.provideMerge(layerTestProviderHost()), + Layer.provideMerge(NetService.layer), + Layer.provideMerge(NodeServices.layer), +); + +const layerOpenCodeTextGenerationExistingServerTest = Layer.succeed( + OpenCodeRuntime.OpenCodeRuntime, + OpenCodeRuntimeTestDouble, +).pipe( + Layer.provideMerge(layerTestProviderHost()), + Layer.provideMerge(NetService.layer), + Layer.provideMerge(NodeServices.layer), +); + +const DEFAULT_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({ + binaryPath: "fake-opencode", +}); +const LOCAL_AUTH_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({ + binaryPath: "fake-opencode", + serverPassword: "secret-password", +}); +const EXISTING_SERVER_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({ + binaryPath: "fake-opencode", + serverUrl: "http://127.0.0.1:9999", + serverPassword: "secret-password", +}); +const EXTERNAL_SERVER_WITHOUT_AUTH_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({ + binaryPath: "fake-opencode", + serverUrl: "http://127.0.0.1:9999", +}); + +function withOpenCodeTextGeneration( + settings: OpenCodeSettings, + effectFn: (textGeneration: ProviderTextGeneration) => Effect.Effect, + environment?: NodeJS.ProcessEnv, +) { + return Effect.gen(function* () { + const serverOwner = yield* OpenCodeServerOwner.make({ + binaryPath: settings.binaryPath, + directory: process.cwd(), + ...(settings.serverPassword ? { serverPassword: settings.serverPassword } : {}), + ...(environment ? { environment } : {}), + }); + const textGeneration = yield* OpenCodeTextGeneration.makeOpenCodeTextGeneration(settings).pipe( + Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner), + ); + return yield* effectFn(textGeneration); + }).pipe(Effect.scoped); +} + +beforeEach(() => { + runtimeMock.reset(); +}); + +const advanceIdleClock = Effect.gen(function* () { + yield* Effect.yieldNow; + yield* TestClock.adjust(Duration.millis(OPENCODE_TEXT_GENERATION_IDLE_TTL_MS + 1)); + yield* Effect.yieldNow; +}); + +it.layer(layerOpenCodeTextGenerationTest)("OpenCodeTextGeneration", (it) => { + it.effect("excludes generic files from thread title generation", () => + withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + runtimeMock.state.promptResult = { + data: { + parts: [{ type: "text", text: '{"title":"Review uploaded report"}' }], + }, + }; + + yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "Review these attachments.", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + attachments: [ + { + type: "image", + id: "thread-image-attachment", + name: "screenshot.png", + mimeType: "image/png", + sizeBytes: 3, + }, + { + type: "file", + id: "thread-report-attachment-pdf", + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 42, + }, + ], + }); + + expect(runtimeMock.state.promptParts[0]).toEqual([ + expect.objectContaining({ type: "text" }), + expect.objectContaining({ type: "file", filename: "screenshot.png" }), + ]); + }), + ), + ); + + it.effect("passes configured authentication to a locally spawned server", () => + withOpenCodeTextGeneration(LOCAL_AUTH_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + yield* textGeneration.generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT); + + expect(runtimeMock.state.startCalls).toEqual(["fake-opencode"]); + expect(runtimeMock.state.authHeaders).toEqual([ + `Basic ${btoa("opencode:secret-password")}`, + ]); + }), + ), + ); + + it.effect("uses an environment-only password for a locally spawned server", () => + withOpenCodeTextGeneration( + DEFAULT_OPENCODE_SETTINGS, + (textGeneration) => + Effect.gen(function* () { + yield* textGeneration.generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT); + + expect(runtimeMock.state.authHeaders).toEqual([ + `Basic ${btoa("opencode:environment-password")}`, + ]); + }), + { OPENCODE_SERVER_PASSWORD: "environment-password" }, + ), + ); + + it.effect("uses settings auth when the local environment password differs", () => + withOpenCodeTextGeneration( + LOCAL_AUTH_OPENCODE_SETTINGS, + (textGeneration) => + Effect.gen(function* () { + yield* textGeneration.generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT); + + expect(runtimeMock.state.authHeaders).toEqual([ + `Basic ${btoa("opencode:secret-password")}`, + ]); + }), + { OPENCODE_SERVER_PASSWORD: "environment-password" }, + ), + ); + + it.effect("reuses a warm server across back-to-back requests and closes it after idling", () => + withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/opencode-reuse", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); + yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/opencode-reuse", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); + + expect(runtimeMock.state.startCalls).toEqual(["fake-opencode"]); + expect(runtimeMock.state.promptUrls).toEqual([ + "http://127.0.0.1:4301", + "http://127.0.0.1:4301", + ]); + expect(runtimeMock.state.closeCalls).toEqual([]); + + yield* advanceIdleClock; + + expect(runtimeMock.state.closeCalls).toEqual(["http://127.0.0.1:4301"]); + }), + ).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("starts a new server after the warm server idles out", () => + withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/opencode-reuse", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); + + yield* advanceIdleClock; + + yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/opencode-reuse", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); + + expect(runtimeMock.state.startCalls).toEqual(["fake-opencode", "fake-opencode"]); + expect(runtimeMock.state.promptUrls).toEqual([ + "http://127.0.0.1:4301", + "http://127.0.0.1:4302", + ]); + expect(runtimeMock.state.closeCalls).toEqual(["http://127.0.0.1:4301"]); + }), + ).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("preserves the SDK cause when session creation fails", () => + withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + const sdkCause = new Error("session endpoint unavailable"); + runtimeMock.state.sessionCreateError = sdkCause; + + const error = yield* textGeneration + .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) + .pipe(Effect.flip); + + expect(error).toBeInstanceOf(TextGenerationError); + expect(error.message).toContain("OpenCode session.create request failed."); + expect(error.cause).toMatchObject({ + _tag: "OpenCodeTextGenerationSessionRequestError", + operation: "generateCommitMessage", + cwd: process.cwd(), + cause: sdkCause, + }); + expect((error.cause as { cause: unknown }).cause).toBe(sdkCause); + }), + ), + ); + + it.effect("reports a missing session payload without manufacturing a cause", () => + withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + runtimeMock.state.sessionResult = {}; + + const error = yield* textGeneration + .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) + .pipe(Effect.flip); + + expect(error.message).toContain("OpenCode session.create returned no session payload."); + expect(error.cause).toMatchObject({ + _tag: "OpenCodeTextGenerationSessionPayloadError", + operation: "generateCommitMessage", + cwd: process.cwd(), + }); + expect(error.cause).not.toHaveProperty("cause"); + }), + ), + ); + + it.effect("preserves the SDK cause and request context when prompting fails", () => + withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + const sdkCause = new Error("prompt endpoint unavailable"); + runtimeMock.state.promptRequestError = sdkCause; + + const error = yield* textGeneration + .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) + .pipe(Effect.flip); + + expect(error.message).toContain("OpenCode session.prompt request failed."); + expect(error.cause).toMatchObject({ + _tag: "OpenCodeTextGenerationPromptRequestError", + operation: "generateCommitMessage", + cwd: process.cwd(), + sessionId: "http://127.0.0.1:4301/session", + providerId: "openai", + modelId: "gpt-5", + cause: sdkCause, + }); + expect((error.cause as { cause: unknown }).cause).toBe(sdkCause); + }), + ), + ); + + it.effect("returns a typed empty-output error for malformed and blank response parts", () => + withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + runtimeMock.state.promptResult = { + data: { + parts: [null, { type: "tool" }, { type: "text", text: " " }], + }, + }; + + const error = yield* textGeneration + .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) + .pipe(Effect.flip); + + expect(error.message).toContain("OpenCode returned empty output."); + expect(error.cause).toMatchObject({ + _tag: "OpenCodeTextGenerationEmptyOutputError", + operation: "generateCommitMessage", + cwd: process.cwd(), + sessionId: "http://127.0.0.1:4301/session", + providerId: "openai", + modelId: "gpt-5", + responsePartCount: 3, + textPartCount: 1, + }); + expect(error.cause).not.toHaveProperty("cause"); + }), + ), + ); + + it.effect("parses JSON returned as plain text output", () => + withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + runtimeMock.state.promptResult = { + data: { + parts: [ + { + type: "text", + text: 'Here is the result:\n{"subject":"Tighten OpenCode parsing","body":"Handle JSON text output locally."}', + }, + ], + }, + }; + + const result = yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/opencode-reuse", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); + + expect(result).toEqual({ + subject: "Tighten OpenCode parsing", + body: "Handle JSON text output locally.", + }); + }), + ), + ); + + it.effect("surfaces the upstream OpenCode structured-output error message", () => + withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + runtimeMock.state.promptResult = { + data: { + info: { + error: { + name: "StructuredOutputError", + data: { + message: "Model did not produce structured output", + retries: 2, + }, + }, + }, + }, + }; + + const error = yield* textGeneration + .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) + .pipe(Effect.flip); + + expect(error.message).toContain("Model did not produce structured output"); + expect(error.cause).toMatchObject({ + _tag: "OpenCodeTextGenerationPromptResponseError", + operation: "generateCommitMessage", + cwd: process.cwd(), + sessionId: "http://127.0.0.1:4301/session", + providerId: "openai", + modelId: "gpt-5", + providerErrorName: "StructuredOutputError", + providerMessage: "Model did not produce structured output", + }); + expect(error.cause).not.toHaveProperty("cause"); + }), + ), + ); +}); + +it.layer(layerOpenCodeTextGenerationExistingServerTest)( + "OpenCodeTextGeneration with configured server URL", + (it) => { + it.effect("does not send a local environment password to a configured server", () => + withOpenCodeTextGeneration( + EXTERNAL_SERVER_WITHOUT_AUTH_OPENCODE_SETTINGS, + (textGeneration) => + Effect.gen(function* () { + yield* textGeneration.generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT); + expect(runtimeMock.state.authHeaders).toEqual([null]); + }), + { OPENCODE_SERVER_PASSWORD: "local-secret" }, + ), + ); + + it.effect("does not create a session when the server version is unsupported", () => + withOpenCodeTextGeneration(EXISTING_SERVER_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + runtimeMock.state.connectionError = new Error( + "OpenCode v1.14.18 is too old. Upgrade to v1.14.19 or newer.", + ); + + const error = yield* textGeneration + .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) + .pipe(Effect.flip); + + expect(error).toBeInstanceOf(TextGenerationError); + expect(error.message).toContain("v1.14.18 is too old"); + expect(runtimeMock.state.sessionCreateCalls).toBe(0); + }), + ), + ); + + it.effect("reuses a configured OpenCode server URL without spawning or applying idle TTL", () => + withOpenCodeTextGeneration(EXISTING_SERVER_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/opencode-reuse", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); + yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/opencode-reuse", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); + + expect(runtimeMock.state.startCalls).toEqual([]); + expect(runtimeMock.state.promptUrls).toEqual([ + "http://127.0.0.1:9999", + "http://127.0.0.1:9999", + ]); + expect(runtimeMock.state.authHeaders).toEqual([ + `Basic ${btoa("opencode:secret-password")}`, + `Basic ${btoa("opencode:secret-password")}`, + ]); + + yield* advanceIdleClock; + + expect(runtimeMock.state.closeCalls).toEqual([]); + }), + ).pipe(Effect.provide(TestClock.layer())), + ); + }, +); diff --git a/packages/provider-opencode/src/server/textGeneration.ts b/packages/provider-opencode/src/server/textGeneration.ts new file mode 100644 index 0000000000000000000000000000000000000000..8a48eaec7b41578cdc3eb5c2059d2d16dd9c4c7d --- /dev/null +++ b/packages/provider-opencode/src/server/textGeneration.ts @@ -0,0 +1,324 @@ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import { NonNegativeInt, TextGenerationError } from "@t3tools/contracts"; +import type { OpenCodeSettings } from "../settings.ts"; +import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; + +import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; +import * as TextGenerationOperations from "@t3tools/provider-core/server/textGenerationOperations"; +import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; +import * as OpenCodeServerOwner from "./OpenCodeServerOwner.ts"; + +const OpenCodeTextGenerationOperation = Schema.Literals([ + "generateCommitMessage", + "generatePrContent", + "generateBranchName", + "generateThreadTitle", +]); + +const openCodeTextGenerationErrorContext = { + operation: OpenCodeTextGenerationOperation, + cwd: Schema.String, +}; + +export class OpenCodeTextGenerationSessionRequestError extends Schema.TaggedError()( + "OpenCodeTextGenerationSessionRequestError", + { + ...openCodeTextGenerationErrorContext, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `OpenCode session creation request failed for ${this.operation} in ${this.cwd}.`; + } +} + +export class OpenCodeTextGenerationSessionPayloadError extends Schema.TaggedError()( + "OpenCodeTextGenerationSessionPayloadError", + openCodeTextGenerationErrorContext, +) { + override get message(): string { + return `OpenCode session.create returned no session payload for ${this.operation} in ${this.cwd}.`; + } +} + +const openCodePromptErrorContext = { + ...openCodeTextGenerationErrorContext, + sessionId: Schema.String, + providerId: Schema.String, + modelId: Schema.String, +}; + +export class OpenCodeTextGenerationPromptRequestError extends Schema.TaggedError()( + "OpenCodeTextGenerationPromptRequestError", + { + ...openCodePromptErrorContext, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `OpenCode prompt request failed for ${this.operation} in ${this.cwd} using ${this.providerId}/${this.modelId} (session ${this.sessionId}).`; + } +} + +export class OpenCodeTextGenerationPromptResponseError extends Schema.TaggedError()( + "OpenCodeTextGenerationPromptResponseError", + { + ...openCodePromptErrorContext, + providerErrorName: Schema.optional(Schema.String), + providerMessage: Schema.String, + }, +) { + override get message(): string { + const providerError = this.providerErrorName ? ` ${this.providerErrorName}` : ""; + return `OpenCode prompt${providerError} failed for ${this.operation} in ${this.cwd} using ${this.providerId}/${this.modelId} (session ${this.sessionId}): ${this.providerMessage}`; + } +} + +export class OpenCodeTextGenerationEmptyOutputError extends Schema.TaggedError()( + "OpenCodeTextGenerationEmptyOutputError", + { + ...openCodePromptErrorContext, + responsePartCount: NonNegativeInt, + textPartCount: NonNegativeInt, + }, +) { + override get message(): string { + return `OpenCode returned empty output for ${this.operation} in ${this.cwd} using ${this.providerId}/${this.modelId} (session ${this.sessionId}, ${this.responsePartCount} response parts, ${this.textPartCount} text parts).`; + } +} + +interface OpenCodePromptFailure { + readonly name?: string; + readonly message: string; +} + +interface OpenCodeTextPart { + readonly type: "text"; + readonly text: string; +} + +function getOpenCodePromptFailure(error: unknown): OpenCodePromptFailure | null { + if (!error || typeof error !== "object") { + return null; + } + + const name = + "name" in error && typeof error.name === "string" && error.name.trim().length > 0 + ? error.name.trim() + : undefined; + const message = + "data" in error && + error.data && + typeof error.data === "object" && + "message" in error.data && + typeof error.data.message === "string" + ? error.data.message.trim() + : ""; + if (message.length > 0) { + return { + ...(name ? { name } : {}), + message, + }; + } + + if (name) { + return { name, message: name }; + } + + return null; +} + +function isOpenCodeTextPart(part: unknown): part is OpenCodeTextPart { + return ( + part !== null && + typeof part === "object" && + "type" in part && + part.type === "text" && + "text" in part && + typeof part.text === "string" + ); +} + +function getOpenCodeTextResponse(parts: ReadonlyArray | undefined): string { + return (parts ?? []) + .filter(isOpenCodeTextPart) + .map((part) => part.text) + .join("") + .trim(); +} + +export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration")(function* ( + openCodeSettings: OpenCodeSettings, +) { + const host = yield* ProviderHost.ProviderHost; + const openCodeRuntime = yield* OpenCodeRuntime.OpenCodeRuntime; + const serverOwner = yield* OpenCodeServerOwner.OpenCodeServerOwner; + + const runOpenCodeJson = Effect.fn("runOpenCodeJson")(function* ( + input: TextGenerationOperations.Request, + ) { + const parsedModel = OpenCodeRuntime.parseOpenCodeModelSlug(input.modelSelection.model); + if (!parsedModel) { + return yield* new TextGenerationError({ + operation: input.operation, + detail: "OpenCode model selection must use the 'provider/model' format.", + }); + } + + const fileParts = OpenCodeRuntime.toOpenCodeFileParts({ + attachments: input.attachments?.filter((attachment) => attachment.type === "image"), + resolveAttachmentPath: host.resolveAttachmentPath, + }); + + const runAgainstServer = Effect.fn("runOpenCodeJson.runAgainstServer")( + function* ( + server: Pick< + OpenCodeRuntime.OpenCodeServerConnection, + "url" | "serverPassword" | "version" + >, + ) { + const client = openCodeRuntime.createOpenCodeSdkClient({ + baseUrl: server.url, + directory: input.cwd, + ...(server.serverPassword !== undefined ? { serverPassword: server.serverPassword } : {}), + }); + const session = yield* Effect.tryPromise({ + try: () => + client.session.create({ + title: `T3 Code ${input.operation}`, + permission: [{ permission: "*", pattern: "*", action: "deny" }], + }), + catch: (cause) => + new OpenCodeTextGenerationSessionRequestError({ + operation: input.operation, + cwd: input.cwd, + cause, + }), + }); + if (!session.data) { + return yield* new OpenCodeTextGenerationSessionPayloadError({ + operation: input.operation, + cwd: input.cwd, + }); + } + const selectedAgent = getModelSelectionStringOptionValue(input.modelSelection, "agent"); + const selectedVariant = getModelSelectionStringOptionValue(input.modelSelection, "variant"); + const promptContext = { + operation: input.operation, + cwd: input.cwd, + sessionId: session.data.id, + providerId: parsedModel.providerID, + modelId: parsedModel.modelID, + }; + + const result = yield* Effect.tryPromise({ + try: () => + client.session.prompt({ + sessionID: session.data.id, + model: parsedModel, + ...(selectedAgent ? { agent: selectedAgent } : {}), + ...(selectedVariant ? { variant: selectedVariant } : {}), + parts: [{ type: "text", text: input.prompt }, ...fileParts], + }), + catch: (cause) => + new OpenCodeTextGenerationPromptRequestError({ + ...promptContext, + cause, + }), + }); + const promptFailure = getOpenCodePromptFailure(result.data?.info?.error); + if (promptFailure) { + return yield* new OpenCodeTextGenerationPromptResponseError({ + ...promptContext, + ...(promptFailure.name ? { providerErrorName: promptFailure.name } : {}), + providerMessage: promptFailure.message, + }); + } + const responseParts = result.data?.parts ?? []; + const rawText = getOpenCodeTextResponse(responseParts); + if (rawText.length === 0) { + return yield* new OpenCodeTextGenerationEmptyOutputError({ + ...promptContext, + responsePartCount: responseParts.length, + textPartCount: responseParts.filter(isOpenCodeTextPart).length, + }); + } + return rawText; + }, + Effect.catchTags({ + OpenCodeTextGenerationSessionRequestError: (cause) => + Effect.fail( + new TextGenerationError({ + operation: cause.operation, + detail: "OpenCode session.create request failed.", + cause, + }), + ), + OpenCodeTextGenerationSessionPayloadError: (cause) => + Effect.fail( + new TextGenerationError({ + operation: cause.operation, + detail: "OpenCode session.create returned no session payload.", + cause, + }), + ), + OpenCodeTextGenerationPromptRequestError: (cause) => + Effect.fail( + new TextGenerationError({ + operation: cause.operation, + detail: "OpenCode session.prompt request failed.", + cause, + }), + ), + OpenCodeTextGenerationPromptResponseError: (cause) => + Effect.fail( + new TextGenerationError({ + operation: cause.operation, + detail: cause.providerMessage, + cause, + }), + ), + OpenCodeTextGenerationEmptyOutputError: (cause) => + Effect.fail( + new TextGenerationError({ + operation: cause.operation, + detail: "OpenCode returned empty output.", + cause, + }), + ), + }), + ); + + const serverOutput = + openCodeSettings.serverUrl.length > 0 + ? openCodeRuntime + .connectToOpenCodeServer({ + binaryPath: openCodeSettings.binaryPath, + directory: input.cwd, + serverUrl: openCodeSettings.serverUrl, + ...(openCodeSettings.serverPassword + ? { serverPassword: openCodeSettings.serverPassword } + : {}), + }) + .pipe(Effect.flatMap(runAgainstServer), Effect.scoped) + : serverOwner.withServer(runAgainstServer); + const rawOutput = yield* serverOutput.pipe( + Effect.catchTags({ + OpenCodeRuntimeError: (cause) => + Effect.fail( + new TextGenerationError({ + operation: input.operation, + detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause), + cause, + }), + ), + }), + ); + + return yield* TextGenerationOperations.decodeJsonReply(input, "OpenCode", rawOutput); + }); + + return TextGenerationOperations.fromRunner("OpenCodeTextGeneration", runOpenCodeJson); +}); diff --git a/packages/provider-opencode/src/server/toolItems.ts b/packages/provider-opencode/src/server/toolItems.ts new file mode 100644 index 0000000000000000000000000000000000000000..1b01b2b8007cbe0c51ee47ce839ac6d970c5f649 --- /dev/null +++ b/packages/provider-opencode/src/server/toolItems.ts @@ -0,0 +1,170 @@ +/** + * 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"; + +// Search results stay on the timeline wire, so keep their text a preview. +const SEARCH_PREVIEW_MAX_CHARS = 8_000; + +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"); + // A failed edit has no diff; keep its error where the diff would be. + const diffStr = + recordString(tool.completedMetadata, "diff", "patch") ?? + (base.status === "failed" && output?.trim() ? output : undefined); + 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"); + // OpenCode reports matches as plain text, so keep it as one result row + // under the searched path, like the ACP search projection. + const searchRoot = (recordString(input, "path", "filePath") ?? pattern)?.trim(); + return { + ...base, + title: + formatSearchToolLabel({ input, ...(pattern === undefined ? {} : { pattern }) }) ?? + base.title, + type: "file_search", + ...(pattern === undefined ? {} : { pattern }), + ...(!output?.trim() || searchRoot === undefined + ? {} + : { + results: [ + { fileName: searchRoot, preview: output.slice(0, SEARCH_PREVIEW_MAX_CHARS) }, + ], + }), + }; + } + case "web_search": { + const pattern = recordString(input, "query", "url", "pattern"); + const url = recordString(input, "url")?.trim(); + return { + ...base, + type: "web_search", + ...(pattern === undefined ? {} : { patterns: [pattern] }), + ...(!output?.trim() + ? {} + : { + results: [ + { + ...(url === undefined ? {} : { url }), + snippet: output.slice(0, SEARCH_PREVIEW_MAX_CHARS), + }, + ], + }), + }; + } + 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/packages/provider-opencode/src/server/usageLimits.ts b/packages/provider-opencode/src/server/usageLimits.ts new file mode 100644 index 0000000000000000000000000000000000000000..2192e75a7b3d854e6b82601b1e336e0af866d9e3 --- /dev/null +++ b/packages/provider-opencode/src/server/usageLimits.ts @@ -0,0 +1,121 @@ +import * as NodeOS from "node:os"; + +import type { ServerProviderUsageWindow } from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Hex from "effect/encoding/Hex"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; + +import { + clampPercent, + makeUnavailableUsageLimits, + makeUsageLimits, +} from "@t3tools/provider-core/server/usageLimits"; + +const AuthFile = Schema.Struct({ "opencode-go": Schema.optionalKey(Schema.Unknown) }); +const ApiAuth = Schema.Struct({ type: Schema.Literal("api"), key: Schema.String }); +const decodeAuthFile = Schema.decodeEffect(Schema.fromJsonString(AuthFile)); +const decodeApiAuth = Schema.decodeUnknownOption(ApiAuth); +const UsageWindow = Schema.Struct({ + percent: Schema.Finite, + resetsAt: Schema.DateTimeUtcFromString, +}); +const UsageResponse = Schema.Struct({ + usage: Schema.Struct({ rolling: UsageWindow, weekly: UsageWindow, monthly: UsageWindow }), +}); + +/** External OpenCode servers own their credentials; never read the host's account for them. */ +export const readOpenCodeGoUsageLimits = Effect.fn("readOpenCodeGoUsageLimits")(function* (input: { + readonly enabled: boolean; + readonly serverUrl: string; + readonly environment: NodeJS.ProcessEnv; +}) { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const unsupported = makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); + if (!input.enabled || input.serverUrl.trim()) return unsupported; + + return yield* Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const env = input.environment; + const dataHome = + env.XDG_DATA_HOME || + path.join(env.HOME || env.USERPROFILE || NodeOS.homedir(), ".local", "share"); + const authPath = path.join(dataHome, "opencode", "auth.json"); + const contents = + env.OPENCODE_AUTH_CONTENT || + (yield* fs.readFileString(authPath).pipe( + Effect.catchTags({ + PlatformError: (error) => + error.reason._tag === "NotFound" ? Effect.succeed("{}") : Effect.fail(error), + }), + )); + const auth = yield* decodeAuthFile(contents); + const apiAuth = decodeApiAuth(auth["opencode-go"]); + // OpenCode overlays stored API credentials after environment credentials. + const apiKey = (Option.isSome(apiAuth) ? apiAuth.value.key : env.OPENCODE_API_KEY)?.trim(); + if (!apiKey) return unsupported; + + const client = yield* HttpClient.HttpClient; + const response = yield* client.execute( + HttpClientRequest.get("https://opencode.ai/zen/go/v1/usage").pipe( + HttpClientRequest.bearerToken(apiKey), + ), + ); + // A valid Zen key can exist without a Go subscription. + if (response.status === 403) return unsupported; + const body = yield* HttpClientResponse.filterStatusOk(response).pipe( + Effect.flatMap(HttpClientResponse.schemaBodyJson(UsageResponse)), + ); + const windows: ServerProviderUsageWindow[] = [ + { + id: "go_rolling", + kind: "session", + label: "Go · Session", + windowDurationMins: 5 * 60, + usedPercent: clampPercent(body.usage.rolling.percent), + resetsAt: DateTime.formatIso(body.usage.rolling.resetsAt), + }, + { + id: "go_weekly", + kind: "weekly", + label: "Go · Weekly", + windowDurationMins: 7 * 24 * 60, + usedPercent: clampPercent(body.usage.weekly.percent), + resetsAt: DateTime.formatIso(body.usage.weekly.resetsAt), + }, + { + id: "go_monthly", + kind: "monthly", + label: "Go · Monthly", + usedPercent: clampPercent(body.usage.monthly.percent), + resetsAt: DateTime.formatIso(body.usage.monthly.resetsAt), + }, + ]; + const crypto = yield* Crypto.Crypto; + // Go's usage response has no account ID. An unkeyed hash matches across + // environments without a shared secret. It permits offline guesses, but + // Go keys are randomly generated. + const credentialFingerprint = yield* crypto + .digest("SHA-256", new TextEncoder().encode(`opencode-go\0${apiKey}`)) + .pipe(Effect.map(Hex.encode), Effect.orDie); + return { + ...makeUsageLimits({ checkedAt, windows }), + credentialFingerprint, + }; + }).pipe( + Effect.timeout("5 seconds"), + Effect.orElseSucceed(() => + makeUnavailableUsageLimits({ + checkedAt, + reason: "probeFailed", + message: "OpenCode Go could not read usage.", + }), + ), + ); +}); diff --git a/packages/provider-opencode/src/server/v2/OpenCode2Client.live.test.ts b/packages/provider-opencode/src/server/v2/OpenCode2Client.live.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..21c01be67185a5e14f40ea4c5b82397b4c54e52e --- /dev/null +++ b/packages/provider-opencode/src/server/v2/OpenCode2Client.live.test.ts @@ -0,0 +1,151 @@ +/** + * Live contract check for `@opencode/client` against a real OpenCode 2 server. + * It re-proves that OpenCode's schemas decode under the workspace Effect + * version, which the pnpm overrides force. Run it after bumping either side: + * + * OPENCODE2_BIN=/path/to/opencode vp test run src/provider/opencode2/OpenCode2Client.live.test.ts + * + * The turn uses the free `opencode/big-pickle` model. + */ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { AbsolutePath, Location, Model, Provider } from "@opencode/client/effect"; +import { assert, it } from "@effect/vitest"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Base64Url from "effect/encoding/Base64Url"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Filter from "effect/Filter"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Stream from "effect/Stream"; +import { FetchHttpClient } from "effect/http"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; +import { describe } from "vite-plus/test"; + +import * as OpenCode2Client from "./OpenCode2Client.ts"; + +const binaryPath = process.env.OPENCODE2_BIN; + +const startServer = Effect.fn("OpenCode2ClientLive.startServer")(function* (binary: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode2-live-" }); + const directory = path.join(root, "work"); + yield* fs.makeDirectory(directory); + // Non-ASCII on purpose: OpenCode decodes Basic credentials as UTF-8. + const crypto = yield* Crypto.Crypto; + const password = `${Base64Url.encode(yield* crypto.randomBytes(32))}-pässwörd€`; + const child = yield* spawner.spawn( + ChildProcess.make(binary, ["serve", "--hostname=127.0.0.1", "--port=0"], { + cwd: directory, + env: { + PATH: process.env.PATH, + HOME: root, + XDG_CONFIG_HOME: path.join(root, "config"), + XDG_DATA_HOME: path.join(root, "data"), + XDG_STATE_HOME: path.join(root, "state"), + XDG_CACHE_HOME: path.join(root, "cache"), + OPENCODE_PASSWORD: password, + }, + }), + ); + // Stop by the PID captured at spawn, before the temp directory is removed. + yield* Effect.addFinalizer(() => + Effect.sync(() => { + try { + process.kill(Number(child.pid), "SIGTERM"); + } catch { + // Already exited. + } + }).pipe(Effect.andThen(child.exitCode), Effect.timeout("10 seconds"), Effect.ignore), + ); + const baseUrl = yield* child.stdout.pipe( + Stream.decodeText(), + Stream.splitLines, + Stream.filterMap( + Filter.fromPredicateOption((line: string) => + Option.fromUndefinedOr(/server listening on\s+(https?:\/\/\S+)/i.exec(line)?.[1]), + ), + ), + Stream.runHead, + Effect.flatMap(Effect.fromOption), + Effect.timeout("30 seconds"), + ); + return { baseUrl, password, directory, pid: Number(child.pid) }; +}); + +describe.runIf(binaryPath !== undefined)("OpenCode2Client live", () => { + it.live( + "reads server info, runs one turn, and pages the history", + () => + Effect.gen(function* () { + const server = yield* startServer(binaryPath!); + const opencode = yield* OpenCode2Client.OpenCode2Client; + const { client, events } = yield* opencode.connect({ + baseUrl: server.baseUrl, + password: server.password, + }); + + const info = yield* client.server.info(); + assert.strictEqual(info.version, "2.0.23"); + assert.strictEqual(info.pid, server.pid); + + const session = yield* client.session.create({ + title: "t3 client live check", + location: Location.PublicRef.make({ directory: AbsolutePath.make(server.directory) }), + model: Model.Ref.make({ + providerID: Provider.ID.make("opencode"), + id: Model.ID.make("big-pickle"), + }), + }); + 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", + ), + Stream.runHead, + Effect.forkChild, + ); + yield* client.session.prompt({ + sessionID: session.id, + text: "Reply with exactly: OPENCODE2_CLIENT_OK", + }); + const terminal = yield* Fiber.join(finished).pipe( + Effect.flatMap(Effect.fromOption), + Effect.timeout("120 seconds"), + ); + assert.strictEqual(terminal.type, "session.execution.succeeded"); + + // One message per page makes every request after the first send only a cursor. + const history = yield* OpenCode2Client.paginate( + { sessionID: session.id, order: "asc", limit: 1 }, + client.message.list, + ).pipe(Stream.runCollect); + assert.deepStrictEqual( + history.map((message) => message.type), + ["user", "assistant", "idle"], + ); + const assistant = history[1]; + assert(assistant?.type === "assistant"); + const reply = assistant.content.flatMap((part) => + part.type === "text" ? [part.text] : [], + ); + assert.include(reply.join(""), "OPENCODE2_CLIENT_OK"); + }).pipe( + Effect.scoped, + Effect.provide( + OpenCode2Client.layer.pipe( + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, FetchHttpClient.layer)), + ), + ), + ), + 180_000, + ); +}); diff --git a/packages/provider-opencode/src/server/v2/OpenCode2Client.test.ts b/packages/provider-opencode/src/server/v2/OpenCode2Client.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..5bfbd6ab901ce5a3128be3536e2bbeef66d62aac --- /dev/null +++ b/packages/provider-opencode/src/server/v2/OpenCode2Client.test.ts @@ -0,0 +1,48 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Redacted from "effect/Redacted"; +import { HttpClient, HttpClientResponse } from "effect/http"; +import { describe } from "vite-plus/test"; + +import * as OpenCode2Client from "./OpenCode2Client.ts"; + +const INFO_BODY = '{"version":"2.0.18","pid":4242,"urls":[],"paths":{"tmp":"/tmp"}}'; + +/** Sends one request through the client and returns the Authorization header it carried. */ +const authorizationFor = (password: string | Redacted.Redacted) => { + const seen: Array = []; + const layerCapturing = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.sync(() => { + seen.push(request.headers.authorization); + return HttpClientResponse.fromWeb( + request, + new Response(INFO_BODY, { headers: { "content-type": "application/json" } }), + ); + }), + ), + ); + return Effect.gen(function* () { + const opencode = yield* OpenCode2Client.OpenCode2Client; + 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(layerCapturing)))); +}; + +const utf8Basic = (password: string) => + `Basic ${Buffer.from(`opencode:${password}`, "utf8").toString("base64")}`; + +describe("OpenCode2Client authorization", () => { + // OpenCode decodes Basic credentials as UTF-8. Latin-1 (`btoa`) encoding gets + // `pässwörd` rejected with a 401 and throws on characters such as `€`. + it.effect("encodes non-ASCII passwords as UTF-8", () => + Effect.gen(function* () { + assert.strictEqual(yield* authorizationFor("pässwörd"), utf8Basic("pässwörd")); + assert.strictEqual(yield* authorizationFor("pass€word"), utf8Basic("pass€word")); + assert.strictEqual(yield* authorizationFor(Redacted.make("pässwörd")), utf8Basic("pässwörd")); + }), + ); +}); diff --git a/packages/provider-opencode/src/server/v2/OpenCode2Client.ts b/packages/provider-opencode/src/server/v2/OpenCode2Client.ts new file mode 100644 index 0000000000000000000000000000000000000000..3b9141e6e7183cdd11e8ef9c34ae79fdc8bc2675 --- /dev/null +++ b/packages/provider-opencode/src/server/v2/OpenCode2Client.ts @@ -0,0 +1,223 @@ +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/encoding/Sse"; +import * as HttpClient from "effect/http/HttpClient"; +import * as HttpClientError from "effect/http/HttpClientError"; +import * as HttpClientRequest from "effect/http/HttpClientRequest"; +import * as HttpClientResponse from "effect/http/HttpClientResponse"; + +/** OpenCode 2 accepts only HTTP Basic auth, always with this user name. */ +const OPENCODE_USERNAME = "opencode"; + +/** + * A session's execution ended, but this build could not decode the event (a + * newer server added a field or a value). Only the type and session survive, + * so the turn still ends. + */ +export interface OpenCode2UnreadableTerminal { + readonly type: "unreadable.execution.ended"; + readonly executionType: (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; + } +>()("@t3tools/provider-opencode/server/v2/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. + */ +const basicAuthorization = (password: string | Redacted.Redacted) => { + const plain = Redacted.isRedacted(password) ? Redacted.value(password) : password; + return `Basic ${Buffer.from(`${OPENCODE_USERNAME}:${plain}`, "utf8").toString("base64")}`; +}; + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.gen(function* () { + const httpClient = yield* HttpClient.HttpClient; + return OpenCode2Client.of({ + 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)), + ), + })), + ); + }, + }); +}); + +export const layer = Layer.effect(OpenCode2Client, make); + +/** + * Streams every item of a cursor-paged OpenCode 2 list. The first request + * carries the caller's input (including `order`); later requests send only + * the cursor, because OpenCode rejects a cursor combined with `order`. + */ +export const paginate = ( + input: Input, + list: ( + input: Input, + ) => Effect.Effect< + { readonly data: ReadonlyArray; readonly cursor: { readonly next?: Input["cursor"] } }, + E, + R + >, +): Stream.Stream => + Stream.paginate(input, (request) => + list(request).pipe( + Effect.map( + (page) => + [ + page.data, + page.data.length === 0 || page.cursor.next === undefined + ? Option.none() + : Option.some({ ...request, order: undefined, cursor: page.cursor.next }), + ] as const, + ), + ), + ); diff --git a/packages/provider-opencode/src/server/v2/OpenCode2Server.live.test.ts b/packages/provider-opencode/src/server/v2/OpenCode2Server.live.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..81a38f9ae22b24a14443eaa80052b4da8b3c3d46 --- /dev/null +++ b/packages/provider-opencode/src/server/v2/OpenCode2Server.live.test.ts @@ -0,0 +1,238 @@ +/** + * Live check of the OpenCode 2 server lifecycle against a real binary: + * + * OPENCODE2_BIN=/path/to/opencode vp test run src/provider/opencode2/OpenCode2Server.live.test.ts + * + * No model is called. The server runs with isolated HOME and XDG directories. + */ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { AbsolutePath, Location } from "@opencode/client/effect"; +import { assert, it } from "@effect/vitest"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Filter from "effect/Filter"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Redacted from "effect/Redacted"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import { FetchHttpClient } from "effect/http"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; +import { describe } from "vite-plus/test"; + +import * as OpenCodeRuntime from "../OpenCodeRuntime.ts"; +import * as OpenCodeServerLedger from "../OpenCodeServerLedger.ts"; +import * as OpenCode2Client from "./OpenCode2Client.ts"; +import * as OpenCode2Server from "./OpenCode2Server.ts"; + +const binaryPath = process.env.OPENCODE2_BIN; + +// A negative pid probes the whole process group, which T3 stops as one unit. +const isAlive = (pid: number) => { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +}; + +/** + * Stops a server the test spawned itself: SIGTERM, then SIGKILL if it is still + * alive after five seconds. Keyed on the PID captured at spawn, never on a + * pattern. Servers T3 spawns are stopped by T3, through the instance scope. + */ +const stopByPid = (pid: number) => + Effect.gen(function* () { + const signal = (name: NodeJS.Signals) => { + try { + process.kill(pid, name); + } catch { + // Already exited. + } + }; + signal("SIGTERM"); + for (let attempt = 0; attempt < 50 && isAlive(pid); attempt++) { + yield* Effect.sleep("100 millis"); + } + if (isAlive(pid)) signal("SIGKILL"); + }); + +/** + * Starts `opencode serve` the way a user would run it themselves. It is stopped + * by its PID when the calling scope closes, whether or not the test passed. + */ +const startExternalServer = Effect.fn("OpenCode2ServerLive.startExternalServer")(function* (input: { + readonly environment: NodeJS.ProcessEnv; + readonly directory: string; +}) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const child = yield* spawner.spawn( + ChildProcess.make(binaryPath!, ["serve", "--hostname=127.0.0.1", "--port=0"], { + cwd: input.directory, + env: input.environment, + }), + ); + const pid = Number(child.pid); + yield* Effect.addFinalizer(() => stopByPid(pid)); + const url = yield* child.stdout.pipe( + Stream.decodeText(), + Stream.splitLines, + Stream.filterMap( + Filter.fromPredicateOption((line: string) => + Option.fromUndefinedOr(/server listening on\s+(https?:\/\/\S+)/i.exec(line)?.[1]), + ), + ), + Stream.runHead, + Effect.flatMap(Effect.fromOption), + Effect.timeout("30 seconds"), + ); + return { url, pid }; +}); + +describe.runIf(binaryPath !== undefined)("OpenCode2Server live", () => { + it.live( + "spawns one authenticated server per instance and stops it by PID", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode2-server-live-" }); + const first = path.join(root, "first"); + const second = path.join(root, "second"); + yield* fs.makeDirectory(first); + yield* fs.makeDirectory(second); + const environment = { + PATH: process.env.PATH, + HOME: root, + XDG_CONFIG_HOME: path.join(root, "config"), + XDG_DATA_HOME: path.join(root, "data"), + XDG_STATE_HOME: path.join(root, "state"), + XDG_CACHE_HOME: path.join(root, "cache"), + // Ignored: the T3 password is the only one the server sees. + OPENCODE_SERVER_PASSWORD: "ambient-password", + }; + + // The instance owns its server: building the layer spawns nothing, and + // closing its scope stops whatever it spawned. The scope is released + // even when an assertion below fails; it is closed early on purpose. + const instanceScope = yield* Effect.acquireRelease(Scope.make(), (scope) => + Scope.close(scope, Exit.void), + ); + const server = Context.get( + yield* Layer.buildWithScope( + OpenCode2Server.layer({ + binaryPath: binaryPath!, + serverUrl: "", + serverPassword: "", + directory: first, + environment, + }), + instanceScope, + ), + OpenCode2Server.OpenCode2Server, + ); + + const spawned = yield* server.withConnection((connection) => + Effect.gen(function* () { + const info = yield* connection.client.server.info(); + const session = yield* connection.client.session.create({ + title: "first location", + location: Location.PublicRef.make({ directory: AbsolutePath.make(first) }), + }); + return { ...connection, pid: info.pid, session }; + }), + ); + assert.strictEqual(spawned.version, "2.0.18"); + assert.isFalse(spawned.external); + assert.isTrue(isAlive(spawned.pid)); + + // A second location is served by the same process. + const secondLocation = yield* server.withConnection((connection) => + Effect.gen(function* () { + const info = yield* connection.client.server.info(); + const session = yield* connection.client.session.create({ + title: "second location", + location: Location.PublicRef.make({ directory: AbsolutePath.make(second) }), + }); + return { url: connection.url, pid: info.pid, session }; + }), + ); + assert.strictEqual(secondLocation.url, spawned.url); + assert.strictEqual(secondLocation.pid, spawned.pid); + assert.strictEqual(secondLocation.session.location.directory, second); + assert.strictEqual(spawned.session.location.directory, first); + + // External URL: the configured password works, a wrong one is a 401. + const external = yield* OpenCode2Server.make({ + binaryPath: binaryPath!, + serverUrl: spawned.url, + serverPassword: "wrong-password", + directory: first, + environment, + }); + const rejected = yield* external.withConnection(() => Effect.void).pipe(Effect.flip); + assert.include(rejected.detail, "rejected the server password"); + const ambient = yield* OpenCode2Server.make({ + binaryPath: binaryPath!, + serverUrl: spawned.url, + serverPassword: environment.OPENCODE_SERVER_PASSWORD, + directory: first, + environment, + }); + const ambientRejected = yield* ambient.withConnection(() => Effect.void).pipe(Effect.flip); + assert.include(ambientRejected.detail, "rejected the server password"); + + // An external server whose password is not ASCII: OpenCode decodes Basic + // credentials as UTF-8, so the configured password must be sent that way. + const utf8Password = "pässwörd€"; + const externalServer = yield* startExternalServer({ + environment: OpenCode2Server.serverEnvironment(environment, Redacted.make(utf8Password)), + directory: second, + }); + const utf8 = yield* OpenCode2Server.make({ + binaryPath: binaryPath!, + serverUrl: externalServer.url, + serverPassword: utf8Password, + directory: second, + environment, + }); + const utf8Connection = yield* utf8.withConnection((connection) => + Effect.succeed(connection), + ); + assert.strictEqual(utf8Connection.version, "2.0.18"); + assert.isTrue(utf8Connection.external); + const asciiOnly = yield* OpenCode2Server.make({ + binaryPath: binaryPath!, + serverUrl: externalServer.url, + serverPassword: "passwrd", + directory: second, + environment, + }); + const asciiRejected = yield* asciiOnly.withConnection(() => Effect.void).pipe(Effect.flip); + assert.include(asciiRejected.detail, "rejected the server password"); + + // Closing the instance stops the server it spawned. + yield* Scope.close(instanceScope, Exit.void); + for (let attempt = 0; attempt < 50 && isAlive(-spawned.pid); attempt++) { + yield* Effect.sleep("100 millis"); + } + assert.isFalse(isAlive(spawned.pid), `opencode serve ${spawned.pid} outlived its instance`); + assert.isFalse(isAlive(-spawned.pid), `a process in group ${spawned.pid} outlived it`); + const unreachable = yield* external.withConnection(() => Effect.void).pipe(Effect.flip); + assert.include(unreachable.detail, "Could not reach the OpenCode server"); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + OpenCode2Client.layer, + OpenCodeRuntime.layer.pipe(Layer.provide(OpenCodeServerLedger.layerTest)), + ).pipe(Layer.provideMerge(Layer.mergeAll(NodeServices.layer, FetchHttpClient.layer))), + ), + ), + 60_000, + ); +}); diff --git a/packages/provider-opencode/src/server/v2/OpenCode2Server.test.ts b/packages/provider-opencode/src/server/v2/OpenCode2Server.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..43e31ece86fac86a2421ff31738a3e69ce73d197 --- /dev/null +++ b/packages/provider-opencode/src/server/v2/OpenCode2Server.test.ts @@ -0,0 +1,259 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { + HostProcessEnvironment, + HostProcessExecutablePath, + HostProcessPlatform, +} from "@t3tools/shared/hostProcess"; +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 Path from "effect/Path"; +import * as Redacted from "effect/Redacted"; +import * as TestClock from "effect/testing/TestClock"; +import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/http"; +import { describe } from "vite-plus/test"; + +import * as OpenCodeRuntime from "../OpenCodeRuntime.ts"; +import * as OpenCodeServerLedger from "../OpenCodeServerLedger.ts"; +import * as OpenCode2Client from "./OpenCode2Client.ts"; +import * as OpenCode2Server from "./OpenCode2Server.ts"; + +// Shapes and content types observed on `opencode serve` 2.0.18 (pid and port replaced). 1.x +// answers /api/info with its web UI: 200 text/html. +const INFO_BODY = + '{"version":"2.0.18","pid":4242,"urls":["http://127.0.0.1:4096"],"paths":{"tmp":"/tmp/opencode"}}'; +const UNAUTHORIZED_BODY = '{"_tag":"UnauthorizedError","message":"Authentication required"}'; +const SPA_BODY = "OpenCode"; + +const layerServerReplying = (reply: { + readonly status: number; + readonly contentType?: string; + readonly body: string; +}) => + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(reply.body, { + status: reply.status, + headers: reply.contentType === undefined ? {} : { "content-type": reply.contentType }, + }), + ), + ), + ), + ); + +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" }); + return yield* OpenCode2Server.verifyServer(client); + }).pipe(Effect.provide(OpenCode2Client.layer.pipe(Layer.provide(httpClient)))); + +describe("OpenCode2Server.verifyServer", () => { + it.effect("returns the version of an authenticated OpenCode 2 server", () => + Effect.gen(function* () { + const version = yield* verify( + layerServerReplying({ status: 200, contentType: "application/json", body: INFO_BODY }), + ); + assert.strictEqual(version, "2.0.18"); + }), + ); + + it.effect("reports a rejected password, not a wrong server", () => + Effect.gen(function* () { + const error = yield* verify( + layerServerReplying({ + status: 401, + contentType: "application/json", + body: UNAUTHORIZED_BODY, + }), + ).pipe(Effect.flip); + assert.include(error.detail, "rejected the server password"); + }), + ); + + // 1.x rejects a wrong password with an empty 401, which the client cannot decode. + it.effect("reports an empty-body 401 as a rejected password", () => + Effect.gen(function* () { + const error = yield* verify(layerServerReplying({ status: 401, body: "" })).pipe(Effect.flip); + assert.include(error.detail, "rejected the server password"); + }), + ); + + it.effect("reports a server error as a server error, not as unreachable", () => + Effect.gen(function* () { + for (const status of [500, 502]) { + const error = yield* verify( + layerServerReplying({ status, contentType: "text/plain", body: "upstream failed" }), + ).pipe(Effect.flip); + assert.include(error.detail, `returned HTTP ${status}`); + } + }), + ); + + // 1.x (and the 2.x web UI on unknown paths) answers /api/info with 200 HTML. + it.effect("rejects a server that answers with the web UI's HTML", () => + Effect.gen(function* () { + const error = yield* verify( + layerServerReplying({ status: 200, contentType: "text/html", body: SPA_BODY }), + ).pipe(Effect.flip); + assert.include(error.detail, "is not an OpenCode 2 server"); + }), + ); + + it.effect("reports an unreachable server as unreachable", () => + Effect.gen(function* () { + const error = yield* verify(FetchHttpClient.layer, "http://127.0.0.1:1").pipe(Effect.flip); + assert.include(error.detail, "Could not reach the OpenCode server"); + }), + ); +}); + +describe("OpenCode2Server error details", () => { + // `detail` reaches clients through the provider status message, and a + // `serverUrl` can carry credentials in its userinfo or query. + const serverUrl = "http://user:url-secret@127.0.0.1:1/?token=query-secret"; + const detailFor = (httpClient: Layer.Layer) => + Effect.gen(function* () { + const server = yield* OpenCode2Server.make({ + binaryPath: "opencode", + serverUrl, + serverPassword: "secret", + directory: "/project", + environment: {}, + }); + const error = yield* server.withConnection(() => Effect.void).pipe(Effect.flip); + return error.detail; + }).pipe( + Effect.provide( + Layer.mergeAll( + OpenCode2Client.layer.pipe(Layer.provide(httpClient)), + OpenCodeRuntime.layer.pipe(Layer.provide(OpenCodeServerLedger.layerTest)), + ).pipe(Layer.provideMerge(NodeServices.layer)), + ), + ); + + it.effect("never include the server URL", () => + Effect.gen(function* () { + const layerHanging = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make(() => Effect.never), + ); + const timedOut = yield* detailFor(layerHanging).pipe(Effect.forkChild); + yield* TestClock.adjust("10 seconds"); + const details = [ + yield* detailFor(FetchHttpClient.layer), + yield* detailFor(layerServerReplying({ status: 401, body: "" })), + yield* detailFor(layerServerReplying({ status: 502, contentType: "text/plain", body: "" })), + yield* detailFor( + layerServerReplying({ status: 200, contentType: "text/html", body: SPA_BODY }), + ), + yield* Fiber.join(timedOut), + ]; + assert.deepStrictEqual(details, [ + "Could not reach the OpenCode server.", + "The OpenCode server rejected the server password.", + "The OpenCode server returned HTTP 502.", + "The server is not an OpenCode 2 server.", + "Timed out waiting for the OpenCode server.", + ]); + for (const detail of details) { + assert.notInclude(detail, "url-secret"); + assert.notInclude(detail, "query-secret"); + assert.notInclude(detail, "127.0.0.1"); + } + }).pipe(Effect.provide(TestClock.layer())), + ); +}); + +describe("OpenCode2Server passwords", () => { + it.effect("generates a distinct 256-bit password per server", () => + Effect.gen(function* () { + const first = yield* OpenCode2Server.generatePassword; + const second = yield* OpenCode2Server.generatePassword; + assert.match(Redacted.value(first), /^[A-Za-z0-9_-]{43}$/); + assert.notStrictEqual(Redacted.value(first), Redacted.value(second)); + assert.notInclude(String(first), Redacted.value(first)); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it("hands the spawned server only the T3 password", () => { + const password = Redacted.make("t3-generated"); + const environment = OpenCode2Server.serverEnvironment( + { PATH: "/bin", OPENCODE_SERVER_PASSWORD: "ambient", OPENCODE_PASSWORD: "ambient" }, + password, + ); + assert.deepStrictEqual(environment, { PATH: "/bin", OPENCODE_PASSWORD: "t3-generated" }); + }); +}); + +// Serves /api/info only with the password from OPENCODE_PASSWORD, like 2.x, and +// prints the 2.x banner (plus the generated-password line 2.x prints when no +// password is set, which T3 must never need). +const FAKE_SERVER = `import { createServer } from "node:http"; +const expected = "Basic " + Buffer.from("opencode:" + process.env.OPENCODE_PASSWORD).toString("base64"); +const server = createServer((request, response) => { + if (request.headers.authorization !== expected) { + response.writeHead(401, { "content-type": "application/json", "www-authenticate": "Basic" }); + response.end(${JSON.stringify(UNAUTHORIZED_BODY)}); + return; + } + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ version: "2.0.18", pid: process.pid, urls: [], paths: { tmp: "/tmp" } })); +}); +server.listen(0, "127.0.0.1", () => { + process.stdout.write("server listening on http://127.0.0.1:" + server.address().port + "\\n"); + process.stdout.write("server password not-the-t3-password\\n"); +}); +`; + +describe("OpenCode2Server spawned server", () => { + it.live( + "is ready once /api/info accepts the generated password", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const platform = yield* HostProcessPlatform; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode2-fake-" }); + const isWindows = platform === "win32"; + const binaryPath = path.join(directory, isWindows ? "opencode.cmd" : "opencode"); + const scriptPath = path.join(directory, "opencode.mjs"); + yield* fs.writeFileString(scriptPath, FAKE_SERVER); + yield* fs.writeFileString( + binaryPath, + isWindows + ? `@echo off\r\n"${yield* HostProcessExecutablePath}" "${scriptPath}" %*\r\n` + : `#!/bin/sh\nexec "${yield* HostProcessExecutablePath}" "${scriptPath}" "$@"\n`, + ); + if (!isWindows) yield* fs.chmod(binaryPath, 0o755); + + const server = yield* OpenCode2Server.make({ + binaryPath, + serverUrl: "", + serverPassword: "", + directory, + environment: { ...(yield* HostProcessEnvironment), OPENCODE_SERVER_PASSWORD: "ambient" }, + }); + const first = yield* server.withConnection((connection) => Effect.succeed(connection)); + const second = yield* server.withConnection((connection) => Effect.succeed(connection)); + assert.strictEqual(first.version, "2.0.18"); + assert.strictEqual(first.external, false); + assert.strictEqual(second.client, first.client); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + OpenCode2Client.layer, + OpenCodeRuntime.layer.pipe(Layer.provide(OpenCodeServerLedger.layerTest)), + ).pipe(Layer.provideMerge(Layer.mergeAll(NodeServices.layer, FetchHttpClient.layer))), + ), + ), + 15_000, + ); +}); diff --git a/packages/provider-opencode/src/server/v2/OpenCode2Server.ts b/packages/provider-opencode/src/server/v2/OpenCode2Server.ts new file mode 100644 index 0000000000000000000000000000000000000000..80b3dda1a10157529d2d3cc612348b13b5d12b52 --- /dev/null +++ b/packages/provider-opencode/src/server/v2/OpenCode2Server.ts @@ -0,0 +1,198 @@ +/** + * The OpenCode 2 server behind one provider instance. A 2.x server serves every + * location from one process, so an instance shares one server across all of its + * threads and directories. + * + * @module provider/opencode2/OpenCode2Server + */ +import type { OpenCodeClient } from "@opencode/client/effect"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Base64Url from "effect/encoding/Base64Url"; +import * as Layer from "effect/Layer"; +import * as P from "effect/Predicate"; +import * as Redacted from "effect/Redacted"; +import * as HttpClientError from "effect/http/HttpClientError"; + +import * as OpenCodeRuntime from "../OpenCodeRuntime.ts"; +import * as OpenCodeServerOwner from "../OpenCodeServerOwner.ts"; +import * as OpenCode2Client from "./OpenCode2Client.ts"; + +const INFO_TIMEOUT = "5 seconds"; + +export interface OpenCode2Connection extends OpenCode2Client.OpenCode2Api { + readonly url: string; + readonly version: string; + readonly external: boolean; +} + +export class OpenCode2Server extends Context.Service< + OpenCode2Server, + { + /** Runs `use` against the instance's server, spawning it first when T3 owns it. */ + readonly withConnection: ( + use: (connection: OpenCode2Connection) => Effect.Effect, + ) => Effect.Effect; + } +>()("@t3tools/provider-opencode/server/v2/OpenCode2Server") {} + +/** + * A fresh password for a spawned server. OpenCode 2 always requires one and + * prints a generated one to stdout otherwise, so T3 supplies its own and keeps + * it in memory. + */ +export const generatePassword = Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const bytes = yield* crypto.randomBytes(32).pipe(Effect.orDie); + return Redacted.make(Base64Url.encode(bytes), { label: "OPENCODE_PASSWORD" }); +}); + +/** + * The environment for a spawned 2.x server. `OPENCODE_PASSWORD` wins over + * `OPENCODE_SERVER_PASSWORD` in OpenCode 2, so the inherited 1.x variable is + * dropped to keep the T3 password the only one in play. + */ +export const serverEnvironment = ( + environment: NodeJS.ProcessEnv, + password: Redacted.Redacted, +): NodeJS.ProcessEnv => { + const { OPENCODE_SERVER_PASSWORD: _inherited, ...rest } = environment; + return { ...rest, OPENCODE_PASSWORD: Redacted.value(password) }; +}; + +/** The client wraps HTTP failures in a `ClientError`; this unwraps either shape. */ +const httpFailureOf = (cause: unknown): HttpClientError.HttpClientError | undefined => { + if (HttpClientError.isHttpClientError(cause)) return cause; + if ( + P.isTagged(cause, "ClientError") && + P.hasProperty(cause, "cause") && + HttpClientError.isHttpClientError(cause.cause) + ) { + return cause.cause; + } + return undefined; +}; + +/** + * Describes a failed `/api/info` call. Only a transport failure means the + * server is unreachable; a 401 without the 2.x error body is still a rejected + * password (1.x sends it empty), and any other failed status is a server error. + * A 2xx the client cannot decode is not OpenCode 2 (1.x answers with HTML). + */ +const describeInfoFailure = (cause: unknown) => { + const failure = httpFailureOf(cause); + if (failure?.reason._tag === "TransportError") { + return "Could not reach the OpenCode server."; + } + const status = failure?.response?.status; + if (status === 401) return "The OpenCode server rejected the server password."; + if (status !== undefined && (status < 200 || status >= 300)) { + return `The OpenCode server returned HTTP ${status}.`; + } + return "The server is not an OpenCode 2 server."; +}; + +/** + * Confirms a server is an authenticated OpenCode 2 server through `/api/info` + * and returns its version. `/health` and friends answer 200 with the web UI's + * HTML on every version, so only this endpoint proves readiness. Details are + * fixed text because they reach clients and a `serverUrl` can carry + * credentials; the underlying failure stays in `cause`. + */ +export const verifyServer = (client: OpenCodeClient) => + client.server.info().pipe( + Effect.timeoutOrElse({ + duration: INFO_TIMEOUT, + orElse: () => + Effect.fail( + new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: "server.info", + detail: "Timed out waiting for the OpenCode server.", + }), + ), + }), + Effect.catchTags({ + UnauthorizedError: (cause) => + Effect.fail( + new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: "server.info", + detail: "The OpenCode server rejected the server password.", + cause, + }), + ), + }), + Effect.mapError((cause) => + OpenCodeRuntime.OpenCodeRuntimeError.is(cause) + ? cause + : new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: "server.info", + detail: describeInfoFailure(cause), + cause, + }), + ), + Effect.map((info) => info.version), + ); + +/** + * One server per provider instance. With a `serverUrl` it connects to that + * server with the configured password; otherwise it spawns `binaryPath serve` + * with a generated password through {@link OpenCodeServerOwner}, which shares + * the process between borrowers and stops it after an idle period. Clients are + * built once per server; a failed check is not remembered. + */ +export const make = Effect.fn("OpenCode2Server.make")(function* (input: { + readonly binaryPath: string; + readonly serverUrl: string; + readonly serverPassword: string; + readonly directory: string; + readonly environment: NodeJS.ProcessEnv; +}) { + const opencode = yield* OpenCode2Client.OpenCode2Client; + const connectTo = (url: string, password: Redacted.Redacted, external: boolean) => + Effect.gen(function* () { + 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) => + Effect.sync(() => { + latest = connection; + return connection.version; + }); + + const serverUrl = input.serverUrl.trim(); + if (serverUrl.length > 0) { + const connect = connectTo(serverUrl, Redacted.make(input.serverPassword), true).pipe( + Effect.tap(remember), + ); + return OpenCode2Server.of({ + withConnection: (use) => + Effect.suspend(() => (latest === undefined ? connect : Effect.succeed(latest))).pipe( + Effect.flatMap(use), + ), + }); + } + + const password = yield* generatePassword; + const owner = yield* OpenCodeServerOwner.make({ + binaryPath: input.binaryPath, + directory: input.directory, + environment: serverEnvironment(input.environment, password), + verify: (url) => connectTo(url, password, false).pipe(Effect.flatMap(remember)), + }); + return OpenCode2Server.of({ + withConnection: (use) => + owner.withServer((server) => + // The owner verifies every server it starts before lending it out. + latest?.url === server.url + ? use(latest) + : Effect.die(new Error("OpenCode 2 server was lent before verification.")), + ), + }); +}); + +/** Built once per provider instance from its settings; closing it stops a spawned server. */ +export const layer = (input: Parameters[0]) => + Layer.effect(OpenCode2Server, make(input)); diff --git a/packages/provider-opencode/src/server/v2/adapter.ts b/packages/provider-opencode/src/server/v2/adapter.ts new file mode 100644 index 0000000000000000000000000000000000000000..97429ddfde8581dc9587ab2b4eae8b73f44dc0c2 --- /dev/null +++ b/packages/provider-opencode/src/server/v2/adapter.ts @@ -0,0 +1,4290 @@ +/** + * 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` (or `session.command`, or `session.compact` + * for `/compact`); the session's next `session.execution.*` terminal ends it. + * A steer is another prompt with `delivery: "steer"`, which OpenCode reads at + * the running execution's next step boundary, so it ends with the turn it + * joined. Fork and rollback cut the native history before the user message a + * turn prompted with (its `nativeTurnRef`). Each runtime mode is a set of + * session permission rules, plan mode is also OpenCode's `plan` agent + * (switched before the prompt like the model), and OpenCode's permission asks + * and question forms become runtime requests on the asking session's thread, a + * subagent's on its parent's. + * + * A `subagent` call runs in a child session shown as a subagent thread. A + * background one outlives its turn; when it ends, OpenCode starts a parent + * execution T3 did not ask for, which waits here for the continuation turn T3 + * opens for it. + * + * @module orchestration-v2/Adapters/OpenCode2AdapterV2 + */ + +import { + AbsolutePath, + Agent, + Form, + Location, + Model, + Permission, + Provider, + Session, + SessionMessage, + Skill, + type OpenCodeEvent, +} from "@opencode/client/effect"; +import { Mcp } from "@opencode/schema/mcp"; +import { + isOrchestrationV2WorkActive, + type OrchestrationV2AppThread, + type OrchestrationV2ConversationMessage, + type OrchestrationV2ExecutionNode, + type OrchestrationV2ProviderCapabilities, + type OrchestrationV2ProviderSession, + type OrchestrationV2ProviderThread, + type OrchestrationV2ProviderTurn, + type OrchestrationV2RuntimeRequest, + type OrchestrationV2Subagent, + type OrchestrationV2TurnItem, + type OrchestrationV2UserInputQuestion, + type ModelSelection, + type ProviderApprovalDecision, + type ProviderInstanceId, + type RunId, + type RuntimeRequestId, +} from "@t3tools/contracts"; +import type * as Cause from "effect/Cause"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Hex from "effect/encoding/Hex"; +import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Schedule from "effect/Schedule"; +import * as Scope from "effect/Scope"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; + +import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; +import * as OpenCode2Client from "./OpenCode2Client.ts"; +import * as OpenCode2Server from "./OpenCode2Server.ts"; +import * as OpenCodeRuntime from "../OpenCodeRuntime.ts"; +import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; +import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions"; +import { t3OrchestrationSystemPrompt } from "@t3tools/provider-core/server/orchestrationInstructions"; +import { SKILL_MENTION_PATTERN } from "@t3tools/shared/composerInlineTokens"; +import * as KeyedLock from "@t3tools/shared/KeyedLock"; +import { getModelSelectionStringOptionValue, modelSelectionsEqual } from "@t3tools/shared/model"; +import { causeErrorTag } from "@t3tools/shared/observability"; + +import { providerMessageTextWithAttachmentPaths } from "@t3tools/provider-core/server/attachmentPrompt"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import { + backgroundWorkNotification, + type BackgroundWorkReport, +} from "@t3tools/provider-core/server/notification"; +import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; +import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; +import { + makeSubagentChildThread, + makeSubagentConversationArtifacts, + subagentThreadTitle, +} from "@t3tools/provider-core/server/subagentProjection"; +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; +import { turnScopedSelectionTransition } from "@t3tools/provider-core/server/selectionTransition"; +import { OPENCODE_PROVIDER, openCodePermissionRequestKind } from "../adapter.ts"; +import { openCodeToolTurnItem } from "../toolItems.ts"; + +const OpenCode2ProviderCapabilities = { + sessions: { + // One server serves every location, so one session runtime owns them all. + supportsMultipleProviderThreadsPerSession: true, + supportsModelSwitchInSession: true, + supportsProviderSwitchingViaHandoff: true, + // A mode change rewrites the session's rules when its next turn resumes it. + supportsRuntimeModeSwitchInSession: true, + pendingRequestsSurviveRestart: false, + }, + threads: { + canCreateEmptyThread: true, + canReadThreadSnapshot: true, + canRollbackThread: true, + canForkThread: true, + canForkFromTurn: true, + canForkFromSubagentThread: false, + exposesNativeThreadId: true, + }, + turns: { + exposesNativeTurnId: false, + emitsTurnStarted: true, + emitsTurnCompleted: true, + supportsInterrupt: true, + supportsActiveSteering: true, + supportsSteeringByInterruptRestart: true, + // T3 holds queued messages and starts each as its own turn once the one + // before it ends, so OpenCode's own `queue` delivery is never used. + 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: true, + supportsFileReadApproval: true, + supportsFileChangeApproval: true, + supportsApplyPatchApproval: true, + approvalsHaveNativeRequestIds: true, + approvalCallbacksAreLiveOnly: true, + approvalsCanOriginateFromSubagents: true, + }, + planning: { + emitsPlanUpdated: false, + emitsTodoList: false, + emitsProposedPlan: false, + supportsStructuredQuestions: true, + planDeltasHaveItemIds: false, + }, + subagents: { + supportsSubagents: true, + exposesSubagentThreadIds: true, + emitsSubagentLifecycle: true, + canWaitForSubagents: true, + 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: true, + providerRollbackReturnsSnapshot: true, + providerCanReadConversationSnapshot: true, + }, + identity: { + nativeThreadIds: "strong", + nativeTurnIds: "weak", + nativeItemIds: "strong", + nativeRequestIds: "strong", + }, + // OpenCode enforces each runtime mode through the session's permission rules. + runtimePolicy: { enforcement: "native" }, +} satisfies OrchestrationV2ProviderCapabilities; + +type EventOf = Extract; +type Tokens = EventOf<"session.step.ended">["data"]["tokens"]; + +/** What a turn reports against: a run's own turn, or a subagent session's runless one. */ +interface TurnOwner { + readonly appThread: OrchestrationV2AppThread; + readonly threadId: OrchestrationV2AppThread["id"]; + readonly runId: RunId | null; + readonly runOrdinal: number; + readonly rootNodeId: ProviderAdapter.ProviderAdapterV2TurnInput["rootNodeId"]; + readonly modelSelection: ModelSelection; + /** Where the turn runs: a subagent runs in its parent turn's directory. */ + readonly runtimePolicy: Pick; +} + +interface ActiveTurn { + readonly input: TurnOwner; + providerTurn: OrchestrationV2ProviderTurn; + /** Open text and reasoning blocks, keyed `::`. */ + readonly texts: Map; + readonly tools: Map }>; + readonly startedAt: Map; + readonly ordinals: Map; + nextOrdinal: number; + /** Input includes cache reads and writes, as 1.x reports it; the parts are also kept apart. */ + readonly usage: { + input: number; + cached: number; + cacheWrite: number; + output: number; + reasoning: number; + }; + steps: number; + lastStep: Tokens | undefined; + /** + * The history item this turn's execution follows, for a turn with no prompt + * id of T3's: the session's newest item before a `/name` command (null when + * the history was empty), since `session.command` takes no id and answers + * without one; the report a continuation turn's execution answers; or, on a + * subagent's session, the first item queued for its turn. A reconnect + * backfills that turn from everything after this. Other turns backfill from + * their own prompt's id (`nativeTurnRef`). + */ + before: string | null | undefined; + /** The compaction running in this turn, `/compact` or OpenCode's own when the context fills. */ + compaction: { readonly nativeId: string; readonly startedAt: DateTime.Utc } | undefined; + compactions: number; + interrupted: boolean; + /** + * Set until the turn's prompt, command or compaction is sent. A Stop before + * then has nothing on the server to stop, so it ends the turn here and the + * request is never sent. + */ + unsent: boolean; + /** + * Steers sent into this turn that OpenCode has not delivered yet, by inbox + * id. Each one wakes the session, so an execution that ends before reading + * one is followed by another that does, and the turn spans both. + */ + readonly steers: Set; + /** This turn's steers OpenCode delivered or cancelled: a retried one is not sent again. */ + readonly settledInbox: Set; + /** How the last execution ended, while the turn waits for the one that delivers its steers. */ + heldEnd: TurnTerminal | undefined; + /** + * 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; + /** + * Prefixes a subagent session's tool ids in native item ids: the model + * names tool calls, so a child's could repeat its parent's. + */ + readonly scope: string; + /** Set once the turn calls `subagent`: its usage then leaves out the subagents'. */ + usedSubagents: boolean; +} + +/** A `subagent` tool call. A background one outlives the turn that made it. */ +interface SubagentCall { + readonly toolId: string; + readonly nativeId: string; + /** The calling session and the turn whose item the call is. */ + readonly state: ThreadState; + readonly turn: ActiveTurn; + readonly ordinal: number; + readonly startedAt: DateTime.Utc; + prompt: string; + title: string | null; + agent: string | undefined; + model: string | null; + /** The tool returned while the subagent runs on; the report OpenCode gives its parent settles it. */ + background: boolean; + child: ThreadState | undefined; + status: OrchestrationV2Subagent["status"]; + result: string | null; + completedAt: DateTime.Utc | null; +} + +/** + * An execution OpenCode ran on a thread's session without T3 asking: the + * parent's answer once a background subagent ended. Its events are held until + * the continuation turn T3 opens for it takes them. + */ +interface Wake { + readonly events: Array; + running: boolean; + /** The background subagents whose end it answers, as its turn's notification names them. */ + readonly reports: Array; + /** What OpenCode told the model, the continuation's prompt text. */ + readonly detail: string | null; + /** Stopped, or taken by a user turn: the continuation it asked for is not needed. */ + dropped: boolean; + /** The last report it delivered (inbox ids are history ids): its execution follows it. */ + readonly after: string | undefined; + /** The first report it delivered, where fork and rollback cut before its turn. */ + readonly first: string | undefined; +} + +interface OpenBlock { + readonly block: { readonly assistantMessageID: string; readonly ordinal: number }; + readonly kind: "text" | "reasoning"; + readonly startedAt: DateTime.Utc; + text: string; +} + +interface ThreadState { + readonly sessionId: string; + 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, a prompt whose request failed without a clear answer, or a + * request T3 could not 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; + /** The session's location, where its agents' path rules are read. */ + directory: string; + /** + * The agent the session runs (`build`, or `plan` in plan mode); its own path + * rules stay in force, and a changed mode switches it before prompting. + */ + agent: string; + /** The native session's rules as T3 last read or wrote them, and the policy they are for. */ + rules: ReadonlyArray | undefined; + policy: RulesPolicy; + /** "Allow … this session" answers, kept in the session's rules while T3 has it open. */ + readonly grants: Array; + /** The session's `subagent` calls still running, by tool call id. */ + readonly calls: Map; + /** + * Set on a subagent's session: the call that runs it and the thread it + * shows in. Each of its executions is a runless turn there. + */ + subagent: + | { + call: SubagentCall; + readonly appThread: OrchestrationV2AppThread; + turns: number; + /** The prompt its next execution answers, shown as that turn's user message. */ + prompt: string | undefined; + /** + * The first item queued for the session since its last turn began (its + * prompt, or a report it answers): where the next turn's history + * begins, which a reconnect reads back. + */ + queued: string | undefined; + } + | undefined; + /** Executions OpenCode started on its own, oldest first, each waiting for its turn. */ + readonly wakes: Array; + /** + * Background subagents whose end OpenCode queued for this session and has + * not delivered yet, by inbox id. The wake that delivers them names them. + */ + readonly reports: Map< + string, + { + readonly inboxId: string; + readonly childId: string; + readonly report: BackgroundWorkReport; + readonly text: string; + } + >; + /** + * Background subagent sessions T3 stopped. OpenCode wakes the parent to + * report them; a wake that reports only these is stopped as well. + */ + readonly stoppedChildren: Set; + /** + * Steers a turn ended without (a Stop, a failure). They stay in OpenCode's + * inbox, where the next prompt would deliver them first, so it cancels them. + */ + readonly strandedSteers: Set; + /** T3's MCP server as registered for this thread, and the instructions entry sent with it. */ + mcp: + | { readonly name: string; readonly directory: string; readonly credential: string } + | undefined; + instructions: string | undefined; +} + +type TurnTerminal = + | { readonly status: "completed" | "interrupted" } + | { readonly status: "failed"; readonly failure: ReturnType }; + +type Rule = Permission.Rule; +type RulesPolicy = Pick< + ProviderAdapter.ProviderAdapterV2RuntimePolicy, + "runtimeMode" | "interactionMode" +>; +type NativeForm = EventOf<"form.created">["data"]["form"]; + +/** A permission ask or question form shown to the user and not answered yet. */ +interface PendingRequest { + readonly request: OrchestrationV2RuntimeRequest; + readonly item: OrchestrationV2TurnItem; + readonly node: OrchestrationV2ExecutionNode; + /** The thread the request is shown on: the asking session's, or its parent's. */ + readonly state: ThreadState; + readonly turn: ActiveTurn; + /** The session that asked: the thread's own, or one of its subagents'. */ + readonly sessionId: string; + /** Set once T3 sends its answer; the orchestrator has already recorded it. */ + answering: boolean; + readonly native: + | { + readonly type: "permission"; + readonly id: string; + readonly action: string; + readonly resources: ReadonlyArray; + readonly save: ReadonlyArray; + } + | { readonly type: "form"; readonly id: string; readonly form: NativeForm }; +} + +const rule = (action: string, effect: Rule["effect"]): Rule => ({ action, resource: "*", effect }); + +/** + * A session's permission rules. OpenCode checks the agent's rules and then + * these, and the last rule that matches decides, so these override the + * agent's. `paths` are the agent's own allows for its directories (saved tool + * output, the plan agent's plan directory), which the blanket rules here would + * otherwise override; `grants` are "Always allow this session" answers. + */ +/** + * T3's MCP server is registered per directory, not per session, so each thread + * gets its own `t3-code-` entry with its own credential. OpenCode names + * an MCP tool's permission `_` (non-alphanumerics become `_`). + * OpenCode skips every tool of a server whose name is over 64 characters (its + * tool namespace limit), and its router rejects adding one over 100, so a + * thread id that does not fit is replaced by a digest of it. + */ +export const t3McpServerName = Effect.fn("t3McpServerName")(function* (threadId: string) { + const name = `t3-code-${threadId.replaceAll(/[^a-zA-Z0-9_-]/g, "_")}`; + if (name.length <= 64) return name; + const crypto = yield* Crypto.Crypto; + const digest = yield* crypto + .digest("SHA-256", new TextEncoder().encode(threadId)) + .pipe(Effect.orDie); + return `t3-code-${Hex.encode(digest).slice(0, 16)}`; +}); + +/** + * The rules that keep T3's MCP servers to their own thread, after the mode's: + * the last matching rule wins, so every thread's T3 server is denied and then + * this thread's own is allowed again, in every mode. A subagent's session + * inherits the thread's. + */ +const mcpRules = (mcpServerName: string | null): ReadonlyArray => + mcpServerName === null + ? [] + : [ + { action: "t3-code-*", resource: "*", effect: "deny" }, + { action: `${mcpServerName}_*`, resource: "*", effect: "allow" }, + ]; + +const sessionRules = ( + policy: RulesPolicy, + paths: ReadonlyArray, + grants: ReadonlyArray, + mcpServerName: string | null, +): ReadonlyArray => [ + ...(policy.runtimeMode === "full-access" + ? [rule("*", "allow")] + : [ + rule("shell", "ask"), + rule("edit", policy.runtimeMode === "auto-accept-edits" ? "allow" : "ask"), + rule("external_directory", "ask"), + ]), + ...grants, + // Plan mode writes only its plan, which `paths` allows again. Shell and read + // are never denied: the free tier refuses sessions whose rules deny them. + ...(policy.interactionMode === "plan" ? [rule("edit", "deny")] : []), + ...paths, + ...mcpRules(mcpServerName), +]; + +const sameRules = (left: ReadonlyArray | undefined, right: ReadonlyArray) => + left?.length === right.length && + left.every( + (entry, index) => + entry.action === right[index]?.action && + entry.resource === right[index]?.resource && + entry.effect === right[index]?.effect, + ); + +/** + * Sent with a declined permission. A reject without a message is OpenCode's + * "stop": it ends the whole execution, which is Cancel. + */ +const DECLINED = "The user declined this request."; + +/** + * Steered into the session before a decline: a declined shell call reaches the + * model only as "Unable to execute command", which it retries. + */ +const declinedNote = (action: string, resources: ReadonlyArray) => + `The user declined the ${action} request${resources.length === 0 ? "" : ` (${resources.join(", ")})`}. Do not retry it; continue without it or ask the user how to proceed.`; + +/** The session-wide choice for a request whose `save` patterns OpenCode would remember. */ +const sessionGrantLabel = (action: string, save: ReadonlyArray) => + save.every((pattern) => pattern === "*") + ? `Allow every ${action} request this session` + : `Allow ${save.join(", ")} this session`; + +const text = (value: string | undefined, fallback: string) => value?.trim() || fallback; + +/** + * A form's fields as T3 questions, or why T3 cannot ask them: a link to open, + * a field shown only for another answer, a hidden field, or a number or yes/no + * value. OpenCode's question tool only asks text and multi-select fields. + */ +const formQuestions = ( + form: NativeForm, +): + | { readonly questions: ReadonlyArray } + | { readonly unsupported: string } => { + const questions: Array = []; + for (const [index, field] of form.fields.entries()) { + if (field.type === "external") return { unsupported: "a link to open" }; + if (field.type !== "string" && field.type !== "multiselect") { + return { unsupported: `a ${field.type} value` }; + } + if (field.hidden === true) return { unsupported: "a hidden field" }; + if ((field.when?.length ?? 0) > 0) return { unsupported: "a field that depends on another" }; + const header = text(field.title, `Question ${index + 1}`); + const options = (field.options ?? []).map((option) => { + const label = text(option.label, text(option.value, "Option")); + return { label, description: text(option.description, label), value: option.value }; + }); + questions.push({ + id: field.key, + header, + question: text(field.description, header), + options, + multiSelect: field.type === "multiselect", + allowCustomAnswer: field.custom === true || options.length === 0, + }); + } + return { questions }; +}; + +/** T3's answers in OpenCode's shape: a list for a multi-select, text otherwise. */ +const formAnswer = (form: NativeForm, answers: Readonly>) => { + const answer: Record> = {}; + for (const field of form.fields) { + const raw = answers[field.key]; + const values = (Array.isArray(raw) ? raw : [raw]).filter( + (value): value is string => typeof value === "string" && value.trim().length > 0, + ); + if (values.length > 0) { + answer[field.key] = field.type === "multiselect" ? values : values.join(", "); + } + } + return answer; +}; + +const approves = (decision: ProviderApprovalDecision) => + decision === "accept" || decision === "acceptForSession" || decision === "acceptAlways"; + +/** + * An answer to a request OpenCode already dropped (its execution ended, or + * its session is gone): nothing waits on it, so it counts as delivered. + */ +const permissionGone = { + PermissionNotFoundError: () => Effect.void, + SessionNotFoundError: () => Effect.void, +}; +const formGone = { + FormNotFoundError: () => Effect.void, + FormAlreadySettledError: () => Effect.void, + SessionNotFoundError: () => Effect.void, +}; + +/** The session rules an agent keeps for its own directories, which T3's blanket rules would override. */ +const agentPaths = (rules: ReadonlyArray) => + rules.filter( + (entry) => + entry.effect === "allow" && + entry.resource !== "*" && + (entry.action === "edit" || entry.action === "external_directory"), + ); + +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"); + +const stringField = (record: Readonly> | undefined, key: string) => { + const value = record?.[key]; + return typeof value === "string" && value.trim().length > 0 ? value : undefined; +}; + +/** How a background subagent ended, from the `state` of OpenCode's report to its parent. */ +const reportOutcome = (state: string | undefined) => + state === "completed" + ? ("completed" as const) + : state === "error" + ? ("failed" as const) + : state === "cancelled" + ? ("cancelled" as const) + : ("unknown" as const); + +/** A subagent's answer, without the `` wrapper OpenCode gives the model. */ +const subagentOutput = (text: string) => + /^]*>\n?([\s\S]*?)\n?<\/subagent>$/.exec(text.trim())?.[1] ?? text; + +const isContinuation = (turnInput: ProviderAdapter.ProviderAdapterV2TurnInput) => + turnInput.message.createdBy === "agent" && turnInput.message.creationSource === "provider"; + +/** The native turn id of a continuation turn, which answers a wake and has no prompt. */ +const wakeTurnId = (sessionId: string, attemptId: string) => `${sessionId}:wake:${attemptId}`; +const isWakeTurn = (turn: OrchestrationV2ProviderTurn) => + turn.nativeTurnRef?.nativeId?.includes(":wake:") === true; + +const INTERRUPT_TIMEOUT = "10 seconds"; +/** The session instructions entry T3 writes its per-turn system prompt to. */ +const INSTRUCTIONS_KEY = "t3-code"; +/** A lost event stream is resubscribed this many times, this far apart, before the session breaks. */ +const RECONNECT_ATTEMPTS = 5; +const RECONNECT_DELAY = "2 seconds"; +const RECONCILE_TIMEOUT = "15 seconds"; +/** How long a new turn waits for a reconnect in progress. */ +const RECONNECT_WAIT = "30 seconds"; +/** A background subagent's result when its end was lost with the event stream. */ +const LOST_BACKGROUND = + "T3 Code lost its connection to OpenCode while this subagent ran, so its result is not shown."; +/** How long a turn waits on the directory's commands or skills before sending the text as is. */ +const INVENTORY_TIMEOUT = "5 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", + "CommandNotFoundError", + "SkillNotFoundError", +]); + +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; + +/** + * The user message ids T3 prompts under. OpenCode takes a client id (it must + * start with `msg_`) and answers a repeat of one in the same session with the + * item it already has, so a retried request never queues a second message, + * and a turn knows the message fork and rollback cut at before OpenCode + * answers. The id is unique across the whole server, which refuses it in any + * other session (409), so it names the session: another T3 database or + * environment on the same server repeats thread ids and run ordinals, never + * session ids. A turn keeps its id in `nativeTurnRef`. + */ +const turnPromptId = (sessionId: string, attemptId: string) => + SessionMessage.ID.make(`msg_t3_turn_${sessionId}:${attemptId}`); +const steerPromptId = (sessionId: string, messageId: string) => + SessionMessage.ID.make(`msg_t3_steer_${sessionId}:${messageId}`); + +/** + * The user message a turn prompted with. Turns from before T3 chose prompt ids + * recorded `:attempt:`, which is no message. + */ +const promptOf = (turn: OrchestrationV2ProviderTurn) => { + const nativeId = turn.nativeTurnRef?.nativeId; + return nativeId?.startsWith("msg_") === true ? nativeId : undefined; +}; + +/** + * Where fork and rollback cut to keep the session's turns up to `kept` (all of + * them before the first turn when undefined): before the message that starts + * the next turn that reached OpenCode, which is in `prompts`. That is a turn's + * prompt, or for a continuation the report it answers. A turn refused before + * it prompted is not in the session and is passed over, and so is a + * continuation that took no report. `null` means nothing follows, so there is + * no cut; a later turn from before T3 chose prompt ids has no known message, + * so no cut is safe. + */ +const boundaryAfter = ( + turns: ReadonlyArray, + providerThreadId: OrchestrationV2ProviderThread["id"], + kept: OrchestrationV2ProviderTurn | undefined, + prompts: ReadonlySet, +) => { + const later = turns + .filter( + (turn) => + turn.providerThreadId === providerThreadId && + (kept === undefined || turn.ordinal > kept.ordinal), + ) + .toSorted((left, right) => left.ordinal - right.ordinal); + if (later.some((turn) => promptOf(turn) === undefined && !isWakeTurn(turn))) { + return Effect.fail( + new ProviderAdapter.ProviderAdapterProtocolError({ + driver: OPENCODE_PROVIDER, + detail: + "This OpenCode conversation has turns from an earlier T3 Code version, so it can't be cut there.", + }), + ); + } + return Effect.succeed( + later.map(promptOf).find((messageId) => messageId !== undefined && prompts.has(messageId)) ?? + null, + ); +}; + +// 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 = OpenCodeRuntime.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"); + +/** OpenCode's own agents for T3's interaction modes; plan mode is its read-only `plan` agent. */ +const agentFor = (input: ProviderAdapter.ProviderAdapterV2TurnInput) => + input.runtimePolicy.interactionMode === "plan" ? "plan" : "build"; + +/** `/name args` naming one of the workspace's commands, which OpenCode expands itself. */ +const commandOf = (text: string) => { + const match = /^\/([^\s/]+)(?:\s+([\s\S]*))?$/.exec(text.trim()); + return match === null ? undefined : { name: match[1]!, text: match[2] ?? "" }; +}; + +/** Whether a prompt names any skill at all, before the directory's skills are read. */ +const SKILL_MENTION = new RegExp(SKILL_MENTION_PATTERN.source, "u"); + +/** The workspace skills a prompt names with the composer's `$skill` tokens. */ +const skillsNamed = (text: string, known: ReadonlySet) => [ + ...new Set( + [...text.matchAll(SKILL_MENTION_PATTERN)].flatMap((match) => + known.has(match[2] ?? "") ? [match[2]!] : [], + ), + ), +]; + +/** + * The turn's own tokens: steps add up, and the last step's input is the live + * context size. A subagent's tokens are its own session's, never these. + */ +const turnTokenUsage = (turn: ActiveTurn, status: OrchestrationV2ProviderTurn["status"]) => + turn.steps === 0 + ? { + usageScope: "main_agent" as const, + usageStatus: "unavailable" as const, + hasSubagents: turn.usedSubagents, + } + : { + usageScope: "main_agent" as const, + usageStatus: status === "completed" ? ("complete" as const) : ("partial" as const), + inputTokens: turn.usage.input, + cachedInputTokens: turn.usage.cached, + cacheCreationTokens: turn.usage.cacheWrite, + outputTokens: turn.usage.output, + reasoningTokens: turn.usage.reasoning, + hasSubagents: turn.usedSubagents, + }; + +/** + * 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 host = yield* ProviderHost.ProviderHost; + const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests; + const crypto = yield* Crypto.Crypto; + const driver = OPENCODE_PROVIDER; + const mcpServerNameFor = (threadId: string) => + t3McpServerName(threadId).pipe(Effect.provideService(Crypto.Crypto, crypto)); + + /** + * Lends the instance's server to a session until its scope closes. A spawned + * server that died is started again on the next borrow. + */ + const borrow = Effect.gen(function* () { + const lent = yield* Deferred.make< + OpenCode2Server.OpenCode2Connection, + OpenCodeRuntime.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* Deferred.await(lent); + }); + + const openSession = Effect.fn("OpenCode2Adapter.openSession")(function* ( + input: Parameters[0], + initial: { + readonly connection: OpenCode2Server.OpenCode2Connection; + readonly scope: Scope.Closeable; + }, + ) { + let connection = initial.connection; + // Replaced when the session reconnects to a restarted server. + let client = connection.client; + const sessionScope = yield* Effect.scope; + // Context windows by directory, then `provider/model`: a project's own + // OpenCode config can change a model's limits, and this one runtime serves + // the instance's threads in every directory. + const contextWindows = new Map>(); + /** A thread without a worktree runs where T3 does, as its session is created. */ + const directoryOf = (cwd: string | null | undefined) => cwd ?? host.paths.cwd; + const windowOf = (cwd: string | null | undefined, model: string) => + contextWindows.get(directoryOf(cwd))?.get(model); + const now = yield* DateTime.now; + let session: OrchestrationV2ProviderSession = { + id: input.providerSessionId, + driver, + providerInstanceId: instanceId, + status: "ready", + cwd: input.runtimePolicy.cwd ?? host.paths.cwd, + model: input.modelSelection.model, + capabilities: OpenCode2ProviderCapabilities, + createdAt: now, + updatedAt: now, + lastError: null, + }; + const events = yield* Queue.unbounded(); + // Thread sessions, and the sessions of their subagents. + const threads = new Map(); + // A subagent's session, by its id, to the thread whose session started it. + const childOwners = new Map(); + // What OpenCode announced about a subagent's session, until its call names it. + const announced = new Map["data"]>(); + // Sessions of these threads with an execution running, seen on the stream. + const busy = new Set(); + const pending = new Map(); + // Events and a wake's replay into its turn are handled one at a time, in order. + const lock = yield* Semaphore.make(1); + // Sessions whose `revert.clear` has not had its empty execution yet. + const clearing = new Map>(); + // Sessions a failed rollback may have left with a staged revert. + const stagedReverts = new Set(); + // Starting a turn and cutting the history take turns on a session: each + // checks that the other is not running before its own requests yield. + const sessionGates = yield* KeyedLock.make(); + const exclusive = + (providerThread: OrchestrationV2ProviderThread) => + (effect: Effect.Effect) => { + const sessionId = providerThread.nativeThreadRef?.nativeId; + if (sessionId == null) return effect; + return sessionGates.withLock(sessionId, effect); + }; + const emit = (event: ProviderAdapter.ProviderAdapterV2Event) => + Queue.offer(events, event).pipe(Effect.asVoid); + const ownerOf = (sessionId: string) => childOwners.get(sessionId) ?? threads.get(sessionId); + + const newThreadState = ( + sessionId: string, + providerThread: OrchestrationV2ProviderThread, + directory: string, + subagent: ThreadState["subagent"], + ): ThreadState => ({ + sessionId, + providerThread, + providerTurns: new Map(), + active: undefined, + model: undefined, + unsettled: false, + directory, + agent: "build", + rules: undefined, + policy: input.runtimePolicy, + grants: [], + calls: new Map(), + subagent, + wakes: [], + reports: new Map(), + stoppedChildren: new Set(), + strandedSteers: new Set(), + mcp: undefined, + instructions: undefined, + }); + + /** The thread whose session started this one, through any nesting. */ + const rootOf = (state: ThreadState): ThreadState => { + let current = state; + while (current.subagent !== undefined) current = current.subagent.call.state; + return current; + }; + + /** A thread's session and its subagents' sessions, through any nesting. */ + const sessionsOf = (thread: ThreadState): ReadonlyArray => [ + thread, + ...[...threads.values()].filter((state) => { + for (let above = state.subagent?.call.state; above; above = above.subagent?.call.state) { + if (above === thread) return true; + } + return false; + }), + ]; + + /** + * A thread's `subagent` calls still running, its subagents' included: a + * subagent's background call runs on after the call that made it ended. + */ + const runningCalls = (thread: ThreadState): ReadonlyArray => + sessionsOf(thread).flatMap((state) => [...state.calls.values()]); + + /** The `subagent` calls that lead to a session, from its own up to the thread's. */ + const callsAbove = (sessionId: string) => { + const calls: Array = []; + let current = threads.get(sessionId); + while (current?.subagent !== undefined) { + calls.push(current.subagent.call); + current = current.subagent.call.state; + } + return calls; + }; + + /** + * The turn a request from `sessionId` is shown under. A background + * subagent's goes to the turn that started it, whose run waits on the + * subagent; anything else goes to the thread's running turn. + */ + const requestTurn = (sessionId: string) => { + const state = ownerOf(sessionId); + if (state === undefined) return undefined; + const background = callsAbove(sessionId).findLast((call) => call.background); + if (background !== undefined) { + return isOrchestrationV2WorkActive(background.status) + ? { state, turn: background.turn } + : undefined; + } + return state.active === undefined ? undefined : { state, turn: state.active }; + }; + + /** + * What a session still owes its thread once the thread's turn ended: + * background subagents, held executions, reports OpenCode queued for the + * follow-up it will start, and anything a subagent's session still runs, + * such as its own follow-up to such a report. + */ + const owesWork = (state: ThreadState) => + state.wakes.length > 0 || + state.reports.size > 0 || + (state.subagent !== undefined && busy.has(state.sessionId)) || + [...state.calls.values()].some((call) => call.background); + + /** Work that outlives the thread's turn, on its own session or its subagents'. */ + const hasBackground = (thread: ThreadState) => sessionsOf(thread).some(owesWork); + + 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; + const nativeId = `${turn.scope}${id}`; + yield* emitNode(state, turn, nativeId, "tool_call", status, startedAt, completedAt); + yield* emit({ + type: "turn_item.updated", + driver, + turnItem: openCodeToolTurnItem( + itemBase(state, turn, nativeId, status, startedAt, completedAt, updatedAt), + { + name: tool.name, + input: tool.input, + output: result?.output, + completedMetadata: status === "completed" ? result?.metadata : undefined, + }, + ), + }); + }); + + /** The turn's compaction item; its summary is the text OpenCode carries forward. */ + const emitCompaction = Effect.fnUntraced(function* ( + state: ThreadState, + turn: ActiveTurn, + status: "running" | "completed" | "failed" | "interrupted", + summary?: string, + ) { + const compaction = turn.compaction; + if (compaction === undefined) return; + const updatedAt = yield* DateTime.now; + const completedAt = status === "running" ? null : updatedAt; + yield* emit({ + type: "turn_item.updated", + driver, + turnItem: { + ...itemBase( + state, + turn, + compaction.nativeId, + status, + compaction.startedAt, + completedAt, + updatedAt, + ), + nodeId: turn.input.rootNodeId, + type: "compaction", + driver, + title: + status === "running" + ? "Compacting context" + : status === "completed" + ? "Context compacted" + : status === "failed" + ? "Compaction failed" + : "Compaction interrupted", + ...(summary === undefined || summary.length === 0 ? {} : { summary }), + }, + }); + if (status !== "running") turn.compaction = 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 makeTurn = ( + owner: TurnOwner, + providerTurn: OrchestrationV2ProviderTurn, + options: { readonly scope: string; readonly awaitingStart: boolean }, + ): ActiveTurn => ({ + input: owner, + providerTurn, + texts: new Map(), + tools: new Map(), + startedAt: new Map(), + ordinals: new Map(), + nextOrdinal: providerTurn.ordinal * 100 + 1, + usage: { input: 0, cached: 0, cacheWrite: 0, output: 0, reasoning: 0 }, + steps: 0, + lastStep: undefined, + before: undefined, + compaction: undefined, + compactions: 0, + interrupted: false, + unsent: false, + steers: new Set(), + settledInbox: new Set(), + heldEnd: undefined, + awaitingStart: options.awaitingStart, + scope: options.scope, + usedSubagents: false, + }); + + /** A subagent call as its parent turn's item, node and subagent row. */ + const emitSubagent = Effect.fnUntraced(function* (call: SubagentCall) { + const updatedAt = yield* DateTime.now; + const { turn, state } = call; + const nodeId = idAllocator.derive.nodeFromProviderItem({ + driver, + nativeItemId: call.nativeId, + }); + const completedAt = isOrchestrationV2WorkActive(call.status) + ? null + : (call.completedAt ?? updatedAt); + const childProviderThreadId = call.child?.providerThread.id ?? null; + const childThreadId = call.child?.subagent?.appThread.id ?? null; + yield* emit({ + type: "node.updated", + driver, + node: { + id: nodeId, + threadId: turn.input.threadId, + runId: turn.input.runId, + parentNodeId: turn.input.rootNodeId, + rootNodeId: turn.input.rootNodeId, + kind: "subagent", + status: call.status, + countsForRun: false, + providerThreadId: childProviderThreadId ?? state.providerThread.id, + providerTurnId: turn.providerTurn.id, + nativeItemRef: ref(call.nativeId), + runtimeRequestId: null, + checkpointScopeId: null, + startedAt: call.startedAt, + completedAt, + }, + }); + yield* emit({ + type: "subagent.updated", + driver, + subagent: { + id: nodeId, + threadId: turn.input.threadId, + runId: turn.input.runId, + parentNodeId: turn.input.rootNodeId, + origin: "provider_native", + createdBy: "agent", + driver, + providerInstanceId: instanceId, + providerThreadId: childProviderThreadId, + childThreadId, + nativeTaskRef: ref(call.nativeId), + prompt: call.prompt, + title: call.title, + model: call.model, + status: call.status, + result: call.result, + startedAt: call.startedAt, + completedAt, + updatedAt, + }, + }); + yield* emit({ + type: "turn_item.updated", + driver, + turnItem: { + id: idAllocator.derive.turnItemFromProviderItem({ driver, nativeItemId: call.nativeId }), + threadId: turn.input.threadId, + runId: turn.input.runId, + nodeId, + providerThreadId: state.providerThread.id, + providerTurnId: turn.providerTurn.id, + nativeItemRef: ref(call.nativeId), + parentItemId: null, + ordinal: call.ordinal, + status: call.status, + title: call.title, + startedAt: call.startedAt, + completedAt, + updatedAt, + type: "subagent", + subagentId: nodeId, + origin: "provider_native", + driver, + providerInstanceId: instanceId, + childThreadId, + prompt: call.prompt, + result: call.result, + }, + }); + yield* syncRoster(rootOf(call.state)); + }); + + /** + * Lists a thread's background subagents on its provider thread, as typed + * `subagent` work: the thread shows it waits on them after its turn ends, + * and a Stop reaches them. Emitted only when the list changes. + */ + const syncRoster = Effect.fnUntraced(function* (state: ThreadState) { + const roster = runningCalls(state) + .filter((call) => call.background) + .map((call) => ({ + taskId: call.nativeId, + kind: "subagent" as const, + ...(call.title?.trim() ? { description: call.title.trim() } : {}), + ...(call.child?.subagent === undefined + ? {} + : { childThreadId: call.child.subagent.appThread.id }), + })); + const current = state.providerThread.pendingBackgroundTasks ?? []; + if ( + current.length === roster.length && + current.every( + (task, index) => + task.taskId === roster[index]?.taskId && + task.description === roster[index]?.description && + (task.kind === "subagent" ? task.childThreadId : undefined) === + roster[index]?.childThreadId, + ) + ) { + return; + } + state.providerThread = { + ...state.providerThread, + pendingBackgroundTasks: roster, + updatedAt: yield* DateTime.now, + }; + yield* emit({ + type: "provider_thread.updated", + driver, + providerThread: state.providerThread, + }); + }); + + const withReportedModel = ( + providerThread: OrchestrationV2ProviderThread, + model: ModelRef | undefined, + ): OrchestrationV2ProviderThread => { + if (model === undefined) return providerThread; + const modelSelection: ModelSelection = { + instanceId, + model: `${model.providerID}/${model.id}`, + options: model.variant === undefined ? [] : [{ id: "variant", value: model.variant }], + }; + if ( + providerThread.nativeMetadata?.modelSelection !== undefined && + modelSelectionsEqual(providerThread.nativeMetadata.modelSelection, modelSelection) + ) + return providerThread; + return { + ...providerThread, + nativeMetadata: { ...providerThread.nativeMetadata, modelSelection }, + }; + }; + + /** + * Gives a subagent call its session once both are known: OpenCode names + * the session on the call's progress, and announces it just before. The + * session becomes a child thread under the call's item. + */ + const attachChild = Effect.fnUntraced(function* (call: SubagentCall, childId: string) { + if (call.child !== undefined) return; + const info = announced.get(childId); + announced.delete(childId); + const now = yield* DateTime.now; + const nodeId = idAllocator.derive.nodeFromProviderItem({ + driver, + nativeItemId: call.nativeId, + }); + const childThreadId = idAllocator.derive.threadFromProviderThread({ + driver, + nativeThreadId: childId, + providerInstanceId: instanceId, + }); + const providerThreadId = idAllocator.derive.providerThread({ + driver, + nativeThreadId: childId, + providerInstanceId: instanceId, + }); + if (info?.model !== undefined) call.model = `${info.model.providerID}/${info.model.id}`; + call.title = call.title ?? info?.title ?? null; + const parentThread = call.turn.input.appThread; + const appThread = makeSubagentChildThread({ + parentThread, + childThreadId, + parentNodeId: nodeId, + activeProviderThreadId: providerThreadId, + providerInstanceId: instanceId, + modelSelection: { + instanceId, + model: call.model ?? call.turn.input.modelSelection.model, + }, + title: subagentThreadTitle({ + parentTitle: parentThread.title, + title: call.title, + prompt: call.prompt, + ordinal: call.ordinal, + }), + now, + createdBy: "agent", + creationSource: "provider", + }); + const providerThread: OrchestrationV2ProviderThread = { + id: providerThreadId, + driver, + providerInstanceId: instanceId, + providerSessionId: input.providerSessionId, + appThreadId: childThreadId, + ownerNodeId: nodeId, + nativeThreadRef: ref(childId), + nativeConversationHeadRef: null, + status: "active", + firstRunOrdinal: null, + lastRunOrdinal: null, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, + }; + // A subagent called again keeps its session's state: how many turns it + // ran (each one's native id is its own), its grants and rules, and the + // background work an earlier call left running, which Stop must reach. + const previous = threads.get(childId); + const subagent = { + call, + appThread, + turns: previous?.subagent?.turns ?? 0, + prompt: call.prompt, + queued: undefined, + }; + const child = + previous ?? newThreadState(childId, providerThread, call.state.directory, subagent); + child.subagent = subagent; + child.model = info?.model ?? child.model; + child.providerThread = withReportedModel(providerThread, child.model); + child.directory = call.state.directory; + child.agent = info?.agent ?? call.agent ?? child.agent; + // OpenCode gives a new session its parent's rules, which are the thread's. + child.rules = info?.permissions ?? child.rules; + threads.set(childId, child); + childOwners.set(childId, rootOf(call.state)); + call.child = child; + yield* emit({ type: "app_thread.created", driver, appThread }); + yield* emit({ + type: "provider_thread.updated", + driver, + providerThread: child.providerThread, + }); + yield* emitSubagent(call); + // A session called again was not made now, so it may hold the rules of + // a mode the thread has left. OpenCode applies a rules change to the + // asks after it, so it gets the thread's as soon as its call names it. + if (info === undefined) { + yield* writeRules(child, rootOf(call.state).policy).pipe( + Effect.timeout(REQUEST_REPLY_TIMEOUT), + Effect.ignore({ log: true }), + ); + } + }); + + /** + * Ends a subagent call and every call under it that is still running. A + * background one outlives the call that made it, as it outlives a turn, + * unless that call failed. + */ + const settleCall: ( + call: SubagentCall, + status: OrchestrationV2Subagent["status"], + result?: string, + ) => Effect.Effect = Effect.fnUntraced(function* (call, status, result) { + if (!isOrchestrationV2WorkActive(call.status)) return; + call.status = status; + call.completedAt = yield* DateTime.now; + if (result !== undefined) call.result = result; + call.state.calls.delete(call.toolId); + const child = call.child; + if (child !== undefined) { + // A snapshot: settling a call removes it from the map. + for (const nested of Array.from(child.calls.values())) { + if (nested.background && status !== "failed") continue; + yield* settleCall(nested, status === "completed" ? "interrupted" : status); + } + if (child.active !== undefined && status !== "completed") { + yield* finishTurn(child, { status: "interrupted" }); + } + child.providerThread = { + ...child.providerThread, + status: "idle", + updatedAt: call.completedAt, + }; + yield* emit({ + type: "provider_thread.updated", + driver, + providerThread: child.providerThread, + }); + } + yield* emitSubagent(call); + }); + + /** + * Opens the runless turn a subagent's session runs each execution in, on + * its child thread, with the prompt it answers as the turn's user message. + */ + const startChildTurn = Effect.fnUntraced(function* (child: ThreadState) { + const subagent = child.subagent; + if (subagent === undefined || child.active !== undefined) return; + subagent.turns += 1; + const startedAt = yield* DateTime.now; + const nativeTurnId = `${child.sessionId}:turn:${subagent.turns}`; + const rootNodeId = idAllocator.derive.nodeFromProviderItem({ + driver, + nativeItemId: `${nativeTurnId}:root`, + }); + const providerTurn: OrchestrationV2ProviderTurn = { + id: idAllocator.derive.providerTurn({ driver, nativeTurnId }), + providerThreadId: child.providerThread.id, + nodeId: rootNodeId, + runAttemptId: null, + nativeTurnRef: ref(nativeTurnId, "weak"), + ordinal: subagent.turns, + status: "running", + startedAt, + completedAt: null, + }; + const turn = makeTurn( + { + appThread: subagent.appThread, + threadId: subagent.appThread.id, + runId: null, + runOrdinal: subagent.call.turn.input.runOrdinal, + rootNodeId, + modelSelection: subagent.appThread.modelSelection, + runtimePolicy: subagent.call.turn.input.runtimePolicy, + }, + providerTurn, + { scope: `${child.sessionId}:`, awaitingStart: false }, + ); + turn.before = subagent.queued; + subagent.queued = undefined; + child.active = turn; + yield* emit({ + type: "node.updated", + driver, + node: { + id: rootNodeId, + threadId: turn.input.threadId, + runId: null, + parentNodeId: null, + rootNodeId, + kind: "root_turn", + status: "running", + countsForRun: false, + providerThreadId: child.providerThread.id, + providerTurnId: providerTurn.id, + nativeItemRef: ref(nativeTurnId, "weak"), + runtimeRequestId: null, + checkpointScopeId: null, + startedAt, + completedAt: null, + }, + }); + yield* emitProviderTurn(child, turn, providerTurn); + const prompt = subagent.prompt; + subagent.prompt = undefined; + if (prompt !== undefined && prompt.length > 0) { + const nativeId = `${nativeTurnId}:prompt`; + const artifacts = makeSubagentConversationArtifacts({ + messageId: idAllocator.derive.messageFromProviderItem({ driver, nativeItemId: nativeId }), + senderThreadId: subagent.call.turn.input.threadId, + turnItemId: idAllocator.derive.turnItemFromProviderItem({ + driver, + nativeItemId: nativeId, + }), + threadId: turn.input.threadId, + rootNodeId, + providerThreadId: child.providerThread.id, + providerTurnId: providerTurn.id, + nativeItemRef: ref(nativeId, "weak"), + role: "user", + text: prompt, + ordinal: ordinalOf(turn, nativeId), + now: startedAt, + }); + yield* emit({ type: "message.updated", driver, message: artifacts.message }); + yield* emit({ type: "turn_item.updated", driver, turnItem: artifacts.turnItem }); + } + }); + + /** + * Asks the orchestrator for the continuation turn a held wake needs. It + * names the subagents whose end woke the thread. A wake a Stop or an + * earlier turn already took needs no turn, so its offer is dropped. + */ + const offerWake = Effect.fnUntraced(function* (state: ThreadState, wake: Wake) { + const route = state.providerThread.appThreadId; + if (route === null) return; + const notification = backgroundWorkNotification(wake.reports); + yield* continuationRequests.offer({ + threadId: route, + providerThreadId: state.providerThread.id, + driver, + detail: wake.detail, + ...(notification === null ? {} : { notification }), + dispatchIfCurrent: (dispatch) => + wake.dropped ? Effect.succeed(Option.none()) : Effect.map(dispatch, Option.some), + }); + }); + + const finishTurn = Effect.fnUntraced(function* ( + state: ThreadState, + terminal: TurnTerminal, + threadDisposition: "reusable" | "broken" = "reusable", + ) { + const turn = state.active; + if (turn === undefined) return; + state.active = undefined; + for (const inboxID of turn.steers) state.strandedSteers.add(inboxID); + // OpenCode drops a request when the asking session's execution ends. A + // subagent's request shown on this turn ends with it too, unless the + // subagent runs in the background past a turn that did not fail. + for (const entry of pending.values()) { + const background = callsAbove(entry.sessionId).some((call) => call.background); + if ( + entry.sessionId === state.sessionId || + (entry.turn === turn && !(background && terminal.status !== "failed")) + ) { + yield* settleRequest(entry, "cancelled"); + } + } + 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", + ); + } + // A foreground subagent ends with the turn that waits on it. A background + // one outlives a finished or interrupted turn (a user Stop has already + // stopped it), and a failed turn stops it. + // A snapshot: settling a call removes it from the map. + for (const call of Array.from(state.calls.values())) { + if (call.turn !== turn) continue; + if (call.background && terminal.status !== "failed") continue; + yield* settleCall(call, terminal.status === "completed" ? "completed" : terminal.status); + } + if (state.subagent !== undefined) { + yield* emit({ + type: "node.updated", + driver, + node: { + id: turn.input.rootNodeId, + threadId: turn.input.threadId, + runId: null, + parentNodeId: null, + rootNodeId: turn.input.rootNodeId, + kind: "root_turn", + status: terminal.status, + countsForRun: false, + providerThreadId: state.providerThread.id, + providerTurnId: turn.providerTurn.id, + nativeItemRef: turn.providerTurn.nativeTurnRef, + runtimeRequestId: null, + checkpointScopeId: null, + startedAt: turn.providerTurn.startedAt, + completedAt, + }, + }); + } + yield* emitCompaction(state, turn, terminal.status === "failed" ? "failed" : "interrupted"); + const window = windowOf(turn.input.runtimePolicy.cwd, turn.input.modelSelection.model); + const lastStep = turn.lastStep; + yield* emitProviderTurn(state, turn, { + ...turn.providerTurn, + status: terminal.status, + completedAt, + turnTokenUsage: turnTokenUsage(turn, terminal.status), + ...(lastStep === undefined + ? {} + : { + tokenUsage: { + usedTokens: + lastStep.input + lastStep.cache.read + lastStep.cache.write + lastStep.output, + maxTokens: window ?? null, + inputTokens: lastStep.input + lastStep.cache.read + lastStep.cache.write, + cachedInputTokens: lastStep.cache.read, + outputTokens: lastStep.output, + reasoningOutputTokens: lastStep.reasoning, + updatedAt: DateTime.formatIso(completedAt), + }, + }), + }); + // A subagent's session is its call's to settle: a turn there is runless, + // and T3 has no terminal to wait on. + if (state.subagent !== undefined) { + const call = state.subagent.call; + if (!call.background && terminal.status !== "completed") { + yield* settleCall(call, terminal.status); + } + return; + } + 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.subagent === undefined && candidate.active !== undefined, + ); + yield* setSessionStatus(pending.size > 0 ? "waiting" : anyActive ? "running" : "ready", null); + const base = { + type: "turn.terminal" as const, + driver, + // The provider thread the turn started on. After a native fork the + // session's own thread has a fresh id, and the terminal must name the + // thread the start was recorded against. + providerThreadId: turn.providerTurn.providerThreadId, + 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 }, + ); + // A spawned server lists its models lazily, so a window still unknown is + // read again for the next turn, off this stream so it never delays one. + if (window === undefined) { + yield* Effect.forkIn(readModels(directoryOf(turn.input.runtimePolicy.cwd)), sessionScope); + } + }); + + /** + * Ends the turn a request T3 could not answer would block, and stops the + * session: OpenCode waits on an unanswered request forever. The stopped + * run's end is its own, not the next turn's. + */ + const abandonRequest = Effect.fnUntraced(function* (state: ThreadState, reason: string) { + yield* Effect.logWarning("Could not answer an OpenCode request; ending its turn.", { + reason, + }); + if (state.active === undefined) return; + state.unsettled = true; + 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(state.sessionId) }) + .pipe(Effect.timeout("2 seconds"), Effect.ignore({ log: true })); + }); + + /** Stops tracking a request; the session is waiting only while any remain. */ + const forgetRequest = Effect.fnUntraced(function* (entry: PendingRequest) { + if (!pending.delete(entry.request.id)) return false; + if (pending.size === 0 && session.status === "waiting") { + yield* setSessionStatus("running", null); + } + return true; + }); + + /** + * Settles a request OpenCode answered or dropped on its own. T3's own + * answers are only forgotten: the orchestrator already recorded them. + */ + const settleRequest = Effect.fnUntraced(function* ( + entry: PendingRequest, + status: "resolved" | "cancelled", + ) { + if (!(yield* forgetRequest(entry)) || entry.answering) return; + const now = yield* DateTime.now; + const itemStatus = status === "resolved" ? "completed" : "cancelled"; + yield* emit({ + type: "runtime_request.updated", + driver, + threadId: entry.turn.input.threadId, + runtimeRequest: { ...entry.request, status, resolvedAt: now }, + }); + yield* emit({ + type: "node.updated", + driver, + node: { ...entry.node, status: itemStatus, completedAt: now }, + }); + yield* emit({ + type: "turn_item.updated", + driver, + turnItem: { ...entry.item, status: itemStatus, completedAt: now, updatedAt: now }, + }); + }); + + /** + * Stops a run no turn of T3's waits on (one a Stop left running, or one + * found asking after a reconnect): a reject without a message and a + * cancelled form both end OpenCode's execution. Its end is not a turn's. + */ + const stopStaleRequest = ( + sessionId: string, + native: { readonly type: "permission" | "form"; readonly id: string }, + ): Effect.Effect => + native.type === "permission" + ? client.permission + .reply({ + sessionID: Session.ID.make(sessionId), + requestID: Permission.ID.make(native.id), + decision: "reject", + }) + .pipe( + Effect.catchTags(permissionGone), + Effect.timeout(REQUEST_REPLY_TIMEOUT), + Effect.ignore({ log: true }), + ) + : client.session.form + .cancel({ sessionID: sessionId, formID: Form.ID.make(native.id) }) + .pipe( + Effect.catchTags(formGone), + Effect.timeout(REQUEST_REPLY_TIMEOUT), + Effect.ignore({ log: true }), + ); + + /** + * Stops what a session still waits on when this runtime first loads it: + * T3 shows none of those requests (a restart or a closed session expired + * them), and OpenCode would wait on them forever. + */ + const stopLeftoverRequests = Effect.fnUntraced(function* (state: ThreadState) { + const sessionID = Session.ID.make(state.sessionId); + const listed = yield* Effect.all([ + client.permission.list({ sessionID }), + client.session.form.list({ sessionID }), + ]).pipe(Effect.timeout(ACTIVE_CHECK_TIMEOUT), Effect.option); + if (listed._tag === "None") { + return yield* Effect.logWarning("Could not list an OpenCode session's open requests."); + } + const [permissions, forms] = listed.value; + if (permissions.length === 0 && forms.length === 0) return; + // The stopped run's end is not the next turn's. + state.unsettled = true; + for (const request of permissions) { + yield* stopStaleRequest(state.sessionId, { type: "permission", id: request.id }); + } + for (const form of forms) { + yield* stopStaleRequest(state.sessionId, { type: "form", id: form.id }); + } + }); + + /** + * Shows a permission ask or question form on the thread whose session (or + * subagent session) asked, under that thread's running turn. A request no + * turn of T3's is waiting on is left for OpenCode's own clients. + */ + const showRequest = Effect.fnUntraced(function* ( + sessionId: string, + native: PendingRequest["native"], + body: + | { + readonly type: "approval_request"; + readonly requestKind: Extract< + OrchestrationV2TurnItem, + { type: "approval_request" } + >["requestKind"]; + readonly prompt: string; + readonly options: Extract< + OrchestrationV2TurnItem, + { type: "approval_request" } + >["options"]; + } + | { + readonly type: "user_input_request"; + readonly questions: ReadonlyArray; + }, + ) { + const target = requestTurn(sessionId); + if (target === undefined) return; + const { state, turn } = target; + if ([...pending.values()].some((entry) => entry.native.id === native.id)) return; + const now = yield* DateTime.now; + const requestId = yield* idAllocator.allocate.runtimeRequest({ + driver, + providerTurnId: turn.providerTurn.id, + nativeRequestId: native.id, + }); + const nodeId = idAllocator.derive.approvalNode({ requestId }); + const request: OrchestrationV2RuntimeRequest = { + id: requestId, + nodeId, + providerTurnId: turn.providerTurn.id, + nativeRequestRef: ref(native.id), + kind: body.type === "approval_request" ? body.requestKind : "user_input", + status: "pending", + responseCapability: { type: "live", providerSessionId: input.providerSessionId }, + createdAt: now, + resolvedAt: null, + }; + const node: OrchestrationV2ExecutionNode = { + id: nodeId, + threadId: turn.input.threadId, + runId: turn.input.runId, + parentNodeId: turn.input.rootNodeId, + rootNodeId: turn.input.rootNodeId, + kind: body.type, + status: "waiting", + countsForRun: false, + providerThreadId: state.providerThread.id, + providerTurnId: turn.providerTurn.id, + nativeItemRef: ref(native.id), + runtimeRequestId: requestId, + checkpointScopeId: null, + startedAt: now, + completedAt: null, + }; + const base = { + id: idAllocator.derive.approvalTurnItem({ requestId }), + threadId: turn.input.threadId, + runId: turn.input.runId, + nodeId, + providerThreadId: state.providerThread.id, + providerTurnId: turn.providerTurn.id, + nativeItemRef: ref(native.id), + parentItemId: null, + ordinal: ordinalOf(turn, native.id), + status: "waiting" as const, + startedAt: now, + completedAt: null, + updatedAt: now, + }; + const item: OrchestrationV2TurnItem = + body.type === "approval_request" + ? { + ...base, + title: native.type === "permission" ? native.action : null, + type: "approval_request", + requestId, + requestKind: body.requestKind, + prompt: body.prompt, + ...(body.options === undefined ? {} : { options: body.options }), + } + : { + ...base, + title: "User input", + type: "user_input_request", + requestId, + questions: body.questions, + }; + pending.set(requestId, { + request, + item, + node, + state, + turn, + sessionId, + answering: false, + native, + }); + yield* emit({ type: "node.updated", driver, node }); + yield* emit({ + type: "runtime_request.updated", + driver, + threadId: turn.input.threadId, + runtimeRequest: request, + }); + yield* emit({ type: "turn_item.updated", driver, turnItem: item }); + yield* setSessionStatus("waiting", null); + }); + + const onPermissionAsked = Effect.fnUntraced(function* (data: Permission.Request) { + // The asking session's own turn knows the tool: a subagent's session runs its own. + const turn = threads.get(data.sessionID)?.active; + const toolName = + data.source === undefined ? undefined : turn?.tools.get(data.source.id)?.name; + const save = data.save ?? []; + yield* showRequest( + data.sessionID, + { type: "permission", id: data.id, action: data.action, resources: data.resources, save }, + { + type: "approval_request", + requestKind: openCodePermissionRequestKind(data.action, toolName), + prompt: data.resources.length === 0 ? data.action : data.resources.join("\n"), + // "Always" in OpenCode saves a grant for the whole project, so the + // session-wide choice is T3's own rule on this session instead. + options: [ + { decision: "cancel", label: "Cancel" }, + { decision: "decline", label: "Decline" }, + ...(save.length > 0 + ? [ + { + decision: "acceptForSession" as const, + label: sessionGrantLabel(data.action, save), + }, + ] + : []), + { decision: "accept", label: "Approve" }, + ], + }, + ); + }); + + const onFormCreated = Effect.fnUntraced(function* (form: NativeForm) { + const target = requestTurn(form.sessionID); + if (target === undefined) return; + const { state } = target; + const mapped = formQuestions(form); + if ("questions" in mapped) { + return yield* showRequest( + form.sessionID, + { type: "form", id: form.id, form }, + { type: "user_input_request", questions: mapped.questions }, + ); + } + // Cancelling ends OpenCode's execution as a user stop, so the turn is + // failed here with the reason and that stop's end is skipped. + yield* Effect.logWarning("Declined an OpenCode form T3 Code cannot show.", { + reason: mapped.unsupported, + }); + const cancelled = yield* deliver( + client.session.form.cancel({ sessionID: form.sessionID, formID: form.id }), + ); + // The session that asked is the one blocked on the form and stopped by + // its cancel: a subagent's own, whose parent reads its failed call and + // goes on. A subagent's session has no Stop end to skip. + const asker = threads.get(form.sessionID) ?? state; + if (!cancelled) return yield* abandonRequest(asker, "form cancel failed"); + if (asker.subagent === undefined) asker.unsettled = true; + yield* finishTurn(asker, { + status: "failed", + failure: makeProviderFailure({ + message: `OpenCode asked for ${mapped.unsupported}, which T3 Code can't show. The question was declined.`, + class: "provider_error", + }), + }); + }); + + /** + * A permission ask or question form, from the stream or read back after a + * reconnect: shown under the turn it blocks, or stopped when it is the run + * a Stop left behind, which nothing answers. + */ + const onAsked = Effect.fnUntraced(function* ( + asked: + | { readonly type: "permission"; readonly request: Permission.Request } + | { readonly type: "form"; readonly form: NativeForm }, + ) { + const asking = asked.type === "permission" ? asked.request.sessionID : asked.form.sessionID; + const state = ownerOf(asking); + if (state === undefined) return; + const target = requestTurn(asking); + if (target !== undefined && !target.turn.awaitingStart) { + if (asked.type === "permission") return yield* onPermissionAsked(asked.request); + return yield* onFormCreated(asked.form); + } + if (state.unsettled || state.active?.awaitingStart === true) { + yield* stopStaleRequest( + asking, + asked.type === "permission" + ? { type: "permission", id: asked.request.id } + : { type: "form", id: asked.form.id }, + ); + } + }); + + const sessionOfEvent = (event: OpenCode2Client.OpenCode2StreamEvent) => + event.type === "unreadable.execution.ended" || event.type === "unreadable.execution.started" + ? event.sessionID + : "sessionID" in event.data && typeof event.data.sessionID === "string" + ? event.data.sessionID + : undefined; + + const executionEnd = (type: string) => + type === "session.execution.succeeded" || + type === "session.execution.failed" || + type === "session.execution.interrupted"; + + /** A `subagent` call's item, made when the model starts it. */ + const startCall = Effect.fnUntraced(function* ( + state: ThreadState, + turn: ActiveTurn, + toolId: string, + ) { + const nativeId = `${turn.scope}${toolId}`; + const call: SubagentCall = { + toolId, + nativeId, + state, + turn, + ordinal: ordinalOf(turn, nativeId), + startedAt: yield* DateTime.now, + prompt: "", + title: null, + agent: undefined, + model: null, + background: false, + child: undefined, + status: "running", + result: null, + completedAt: null, + }; + turn.usedSubagents = true; + state.calls.set(toolId, call); + yield* emitSubagent(call); + }); + + /** + * One event for a session's running turn: its text, tools, steps and end. + * A `subagent` call is its item and child thread rather than a tool. + */ + const onTurnEvent = Effect.fnUntraced(function* ( + state: ThreadState, + turn: ActiveTurn, + event: OpenCode2Client.OpenCode2StreamEvent, + ) { + 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": + // Its form is the item the user answers; the tool call would repeat it. + if (event.data.name === "question") return; + if (event.data.name === "subagent") return yield* startCall(state, turn, event.data.id); + 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 call = state.calls.get(event.data.id); + if (call !== undefined) { + const { input } = event.data; + call.prompt = stringField(input, "prompt") ?? call.prompt; + call.title = stringField(input, "description") ?? call.title; + call.agent = stringField(input, "agent"); + call.model = stringField(input, "model") ?? call.model; + call.background = input["background"] === true; + return yield* emitSubagent(call); + } + 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.progress": { + const call = state.calls.get(event.data.id); + const childId = stringField(event.data.metadata, "sessionID"); + if (call !== undefined && childId !== undefined) yield* attachChild(call, childId); + return; + } + case "session.tool.success": { + const call = state.calls.get(event.data.id); + if (call !== undefined) { + const childId = stringField(event.data.metadata, "sessionID"); + if (childId !== undefined) yield* attachChild(call, childId); + // A background call returns at launch; its report settles it. + if (event.data.metadata?.["status"] === "running") return; + return yield* settleCall(call, "completed", subagentOutput(textOf(event.data.content))); + } + 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"; + const call = state.calls.get(event.data.id); + // A refused call (unknown agent, the nesting limit) is a failed subagent. + if (call !== undefined) { + return yield* settleCall( + call, + aborted ? "interrupted" : "failed", + event.data.error.message, + ); + } + 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; + } + // `/compact` and OpenCode's own compaction when the context fills + // (`reason: "auto"`) both run inside a turn's execution. + case "session.compaction.started": + turn.compaction = { + nativeId: `${turn.providerTurn.id}:compaction:${turn.compactions++}`, + startedAt: yield* DateTime.now, + }; + return yield* emitCompaction(state, turn, "running"); + case "session.compaction.ended": + return yield* emitCompaction(state, turn, "completed", event.data.text); + case "session.compaction.failed": + return yield* emitCompaction(state, turn, "failed"); + case "session.step.ended": + case "session.step.failed": { + const tokens = event.data.tokens; + if (tokens === undefined) return; + turn.steps += 1; + turn.lastStep = tokens; + turn.usage.input += tokens.input + tokens.cache.read + tokens.cache.write; + turn.usage.cached += tokens.cache.read; + turn.usage.cacheWrite += tokens.cache.write; + turn.usage.output += tokens.output + tokens.reasoning; + turn.usage.reasoning += tokens.reasoning; + return; + } + case "session.execution.started": + // The execution that delivers steers the last one ended without. + turn.heldEnd = undefined; + return; + case "session.execution.succeeded": + return yield* endExecution(state, turn, { + status: turn.interrupted ? "interrupted" : "completed", + }); + case "session.execution.interrupted": + return yield* endExecution(state, turn, { status: "interrupted" }); + case "session.execution.failed": + return yield* endExecution(state, turn, { + status: "failed", + failure: makeProviderFailure({ + message: event.data.error.message, + code: event.data.error.type, + class: "provider_error", + }), + }); + default: + return; + } + }); + + /** + * An execution OpenCode started on a thread's session with no turn of T3's + * running: the parent's answer to a background subagent's report. It is + * held for the continuation turn it asks for, or stopped when it only + * reports subagents a Stop ended. + */ + /** + * Takes the reports an execution OpenCode started on its own delivers. When + * they all report subagents a Stop ended, that execution only answers the + * Stop, so it is stopped and `stopped` is true. + */ + const takeReports = Effect.fnUntraced(function* (state: ThreadState) { + const delivered = [...state.reports.values()]; + state.reports.clear(); + const stopped = + delivered.length > 0 && + delivered.every((entry) => state.stoppedChildren.has(entry.childId)); + for (const entry of delivered) state.stoppedChildren.delete(entry.childId); + if (stopped) { + state.unsettled = true; + yield* client.session + .interrupt({ sessionID: Session.ID.make(state.sessionId) }) + .pipe(Effect.timeout(INTERRUPT_TIMEOUT), Effect.ignore({ log: true })); + } + return { delivered, stopped }; + }); + + const onWake = Effect.fnUntraced(function* (state: ThreadState) { + const { delivered, stopped } = yield* takeReports(state); + if (stopped) return; + const wake: Wake = { + events: [], + running: true, + reports: delivered.map((entry) => entry.report), + detail: delivered.length === 0 ? null : delivered.map((entry) => entry.text).join("\n\n"), + dropped: false, + after: delivered.at(-1)?.inboxId, + first: delivered[0]?.inboxId, + }; + state.wakes.push(wake); + yield* Effect.logInfo("OpenCode started a turn on its own; asking for a continuation.", { + providerThreadId: state.providerThread.id, + }); + yield* offerWake(state, wake); + }); + + /** A background subagent's end, as OpenCode queues it for its parent. */ + const onReport = Effect.fnUntraced(function* ( + state: ThreadState, + inboxId: string, + payload: { + readonly text: string; + readonly metadata?: Readonly> | undefined; + }, + ) { + const childId = stringField(payload.metadata, "childID"); + if (stringField(payload.metadata, "source") !== "subagent" || childId === undefined) return; + const call = [...state.calls.values()].find( + (candidate) => candidate.child?.sessionId === childId, + ); + const outcome = reportOutcome(stringField(payload.metadata, "state")); + state.reports.set(inboxId, { + inboxId, + childId, + text: payload.text, + report: { + kind: "subagent", + label: call?.title ?? stringField(payload.metadata, "description"), + childThreadId: call?.child?.subagent?.appThread.id, + outcome, + }, + }); + if (call === undefined) return; + yield* settleCall( + call, + state.stoppedChildren.has(childId) + ? "interrupted" + : outcome === "failed" + ? "failed" + : outcome === "cancelled" + ? "cancelled" + : "completed", + subagentOutput(payload.text), + ); + }); + + /** + * The thread whose held execution an event belongs to: the thread's own + * session, or a subagent session its held execution started. + */ + const holderOf = (sessionId: string) => { + const own = threads.get(sessionId); + if (own !== undefined) return own.wakes.at(-1)?.running === true ? own : undefined; + // A subagent a held execution started is named only when that execution replays. + const parent = announced.get(sessionId)?.parentID; + const holder = parent === undefined ? undefined : threads.get(parent); + return holder !== undefined && holder.active === undefined && holder.wakes.length > 0 + ? holder + : undefined; + }; + + /** + * Ends the turn with its execution, unless a steer sent into it is still + * undelivered: OpenCode then starts another execution for it, which the + * turn owns too. A Stop leaves steers undelivered and starts nothing. + */ + const endExecution = Effect.fnUntraced(function* ( + state: ThreadState, + turn: ActiveTurn, + end: TurnTerminal, + ) { + if (end.status === "completed" && turn.steers.size > 0) { + turn.heldEnd = end; + return; + } + yield* finishTurn(state, end); + }); + + /** Ends a turn held for its steers once none is left to deliver. */ + const endIfSettled = (state: ThreadState, turn: ActiveTurn) => + turn.heldEnd !== undefined && turn.steers.size === 0 + ? finishTurn(state, turn.heldEnd) + : Effect.void; + + const handleEvent = Effect.fnUntraced(function* (event: OpenCode2Client.OpenCode2StreamEvent) { + const sessionId = sessionOfEvent(event); + // `revert.clear` wakes the session into an empty execution of its own + // (2.0.18's `Session.revert.clear` ends with a wake). It is no run of + // T3's, and no follow-up to a subagent either. + const cleared = sessionId === undefined ? undefined : clearing.get(sessionId); + if (cleared !== undefined) { + if (event.type === "unreadable.execution.ended" || executionEnd(event.type)) { + return yield* Deferred.succeed(cleared, undefined); + } + if ( + event.type === "session.execution.started" || + event.type === "unreadable.execution.started" + ) { + return; + } + } + if (sessionId !== undefined) { + if ( + event.type === "session.execution.started" || + event.type === "unreadable.execution.started" + ) { + busy.add(sessionId); + } else if (event.type === "unreadable.execution.ended" || executionEnd(event.type)) { + busy.delete(sessionId); + } + } + // A subagent's session: named on its parent's `subagent` call just after. + if (event.type === "session.created" && event.data.parentID !== undefined) { + const owner = ownerOf(event.data.parentID); + if (owner !== undefined) { + childOwners.set(event.data.sessionID, owner); + announced.set(event.data.sessionID, event.data); + } + return; + } + // What a held execution does, and what the subagents it starts do, + // waits for the turn that takes it. + const holder = sessionId === undefined ? undefined : holderOf(sessionId); + const held = holder?.wakes.at(-1); + if (holder !== undefined && held !== undefined) { + held.events.push(event); + if ( + sessionId === holder.sessionId && + (event.type === "unreadable.execution.ended" || executionEnd(event.type)) + ) { + held.running = false; + } + return; + } + return yield* route(event, sessionId); + }); + + /** Handles one event for its session, as the turn running there sees it. */ + const route = Effect.fnUntraced(function* ( + event: OpenCode2Client.OpenCode2StreamEvent, + sessionId: string | undefined, + ) { + // The end of the run a timed-out Stop left behind; no turn is its own. + const ended = + event.type === "unreadable.execution.ended" || executionEnd(event.type) + ? threads.get(sessionId ?? "") + : undefined; + if (ended?.unsettled === true) { + ended.unsettled = false; + return; + } + // Marks where a running turn's own execution begins; it never ends one. + // With no turn running it is a subagent's or a follow-up's start, below. + if (event.type === "unreadable.execution.started") { + const turn = threads.get(event.sessionID)?.active; + if (turn !== undefined) { + turn.awaitingStart = false; + turn.heldEnd = undefined; + return; + } + } + if (event.type === "unreadable.execution.ended") { + const state = threads.get(event.sessionID); + const turn = state?.active; + if (state === undefined || turn === undefined || turn.awaitingStart) return; + return yield* endExecution( + state, + turn, + event.executionType === "session.execution.succeeded" + ? { status: turn.interrupted ? "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") { + return yield* onAsked({ type: "permission", request: event.data }); + } + if (event.type === "form.created") + return yield* onAsked({ type: "form", form: event.data.form }); + // Answered in another OpenCode client, or dropped by OpenCode: a reject + // it sends on its own (a Stop, or another reject in the same session) + // cancels the request. T3's own answers are settled where they are sent. + if ( + event.type === "permission.replied" || + event.type === "form.replied" || + event.type === "form.cancelled" + ) { + const nativeId = event.type === "permission.replied" ? event.data.requestID : event.data.id; + const entry = [...pending.values()].find((candidate) => candidate.native.id === nativeId); + if (entry === undefined || entry.answering) return; + const answered = + event.type === "form.replied" || + (event.type === "permission.replied" && event.data.reply !== "reject"); + return yield* settleRequest(entry, answered ? "resolved" : "cancelled"); + } + if (sessionId === undefined) return; + const state = threads.get(sessionId); + if (state === undefined) return; + if (event.type === "session.model.selected" || event.type === "session.step.started") { + if (event.type === "session.model.selected") state.model = event.data.model; + const providerThread = withReportedModel(state.providerThread, event.data.model); + if (providerThread !== state.providerThread) { + state.providerThread = { ...providerThread, updatedAt: yield* DateTime.now }; + yield* emit({ + type: "provider_thread.updated", + driver, + providerThread: state.providerThread, + }); + } + } + if ( + event.type === "session.inbox.enqueued" && + state.subagent !== undefined && + state.active === undefined + ) { + state.subagent.queued ??= event.data.inboxID; + } + if (event.type === "session.inbox.enqueued" && event.data.item.type === "synthetic") { + return yield* onReport(state, event.data.inboxID, event.data.item.payload); + } + // Cancelled, or delivered into a turn already running: no wake reports it. + // A steer of the running turn's is settled the same way. + if (event.type === "session.inbox.cancelled" || event.type === "session.inbox.delivered") { + state.reports.delete(event.data.inboxID); + const turn = state.active; + if (turn !== undefined && turn.steers.delete(event.data.inboxID)) { + turn.settledInbox.add(event.data.inboxID); + // A cancelled steer may leave a held turn nothing to wait for. + if (event.type === "session.inbox.cancelled") yield* endIfSettled(state, turn); + } + return; + } + // A subagent's later prompts (a resumed subagent) open its next turn's message. + if ( + event.type === "session.inbox.enqueued" && + event.data.item.type === "user" && + state.subagent !== undefined && + state.subagent.prompt === undefined && + state.active === undefined + ) { + state.subagent.prompt = event.data.item.payload.text; + return; + } + const started = + event.type === "session.execution.started" || event.type === "unreadable.execution.started"; + // Each execution of a subagent's session is a turn on its child thread, + // unless it only answers the reports of nested subagents a Stop ended. + if (state.subagent !== undefined && state.active === undefined && started) { + if ((yield* takeReports(state)).stopped) { + state.subagent.queued = undefined; + return; + } + return yield* startChildTurn(state); + } + const turn = state.active; + if (turn === undefined) { + // OpenCode started the thread's session on its own. + if (started && state.subagent === undefined) return yield* onWake(state); + 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; + } + return yield* onTurnEvent(state, turn, event); + }); + + // The stream is the only terminal signal, and it is volatile: events sent + // while it is down are gone, and a restarted server never ends the + // execution it lost. So a lost stream reconnects, then reconciles each + // running turn from the server's own state (see `reconcile`). Only when + // reconnecting keeps failing are the turns failed and the session broken, + // so T3 reopens it. Set first, so a turn starting meanwhile waits or refuses. + let streamFailure: string | undefined; + let reconnected = yield* Deferred.make(); + const failAll = Effect.fnUntraced(function* (message: string) { + streamFailure = message; + yield* Deferred.succeed(reconnected, undefined); + for (const state of threads.values()) { + const failure = makeProviderFailure({ message, class: "transport_error" }); + yield* finishTurn(state, { status: "failed", failure }, "broken"); + } + // Nothing can report a background subagent's end any more. + for (const state of threads.values()) { + for (const call of runningCalls(state)) yield* settleCall(call, "failed"); + for (const wake of state.wakes.splice(0)) wake.dropped = true; + state.reports.clear(); + } + yield* setSessionStatus("error", message); + yield* Queue.end(events); + }); + /** + * Emits what a running turn missed while the stream was down, from the + * session's history since the turn began: text, reasoning and tools, each + * under the same native id its live events would have used, so nothing + * already shown is duplicated. A turn begins at its prompt, whose id T3 + * chose (`nativeTurnRef`), or after `before` for a `/name` command or a + * continuation. + * Returns how that history says the turn's execution ended: the `idle` item + * OpenCode appends after each one, or undefined when there is none after + * the turn's start. + */ + const backfill = Effect.fnUntraced(function* (sessionId: string, state: ThreadState) { + const turn = state.active; + if (turn === undefined) return undefined; + const promptId = promptOf(turn.providerTurn); + const { before } = turn; + if (promptId === undefined && before === undefined) return undefined; + const start = before !== undefined ? before : promptId; + const read = yield* OpenCode2Client.paginate( + { sessionID: Session.ID.make(sessionId), order: "desc" as const, limit: 50 }, + client.message.list, + ).pipe( + Stream.takeUntil((message) => message.id === start), + Stream.runCollect, + ); + // Without its start the history holds only earlier turns (the stream + // dropped before the prompt landed): none of it is this turn's. A null + // `before` is a command that started on an empty history. + const found = start === null || read.at(-1)?.id === start; + if (!found) return undefined; + const recent = before !== undefined && before !== null ? read.slice(0, -1) : read; + const idle = recent.find((message) => message.type === "idle"); + for (const message of recent.toReversed()) { + if (message.type !== "assistant" || state.active !== turn) continue; + const ordinals = { text: 0, reasoning: 0 }; + for (const part of message.content) { + if (part.type === "text" || part.type === "reasoning") { + const block = { assistantMessageID: message.id, ordinal: ordinals[part.type]++ }; + yield* emitText(state, turn, block, part.type, () => part.text, true); + continue; + } + if (part.type !== "tool") continue; + if (!turn.tools.has(part.id)) { + turn.tools.set(part.id, { name: part.name, input: {} }); + turn.startedAt.set(part.id, yield* DateTime.now); + } + const tool = turn.tools.get(part.id)!; + if (typeof part.state.input === "object" && part.state.input !== null) { + tool.input = part.state.input as Record; + } + if (part.state.status === "completed") { + yield* emitTool(state, turn, part.id, "completed", { + output: textOf(part.state.content), + metadata: part.state.metadata, + }); + turn.tools.delete(part.id); + } else if (part.state.status === "error") { + yield* emitTool(state, turn, part.id, "failed", { + output: part.state.error.message, + metadata: part.state.metadata, + }); + turn.tools.delete(part.id); + } else { + yield* emitTool(state, turn, part.id, "running"); + } + } + } + return idle === undefined ? undefined : { outcome: idle.outcome }; + }); + + /** + * Settles each thread turn that was running when the stream dropped, from + * the server: a session still running keeps its turn (its next events + * arrive on the new stream); one that stopped ends the turn with the + * outcome of the `idle` item after the turn's start. A restarted server + * writes no such item for the execution it lost, and `session.outcome` is + * still the previous execution's, so no `idle` means the turn was + * interrupted. A continuation turn is settled the same way, from the report + * its execution answers. A turn still holding for an undelivered steer only + * ends once the session is idle. Permission asks and question forms are + * read back from every session still running, so a run that asked during + * the gap can be answered. + * + * What the stream carried outside a turn is not read back: a background + * subagent's report, a follow-up OpenCode started on its own, or one held + * for its continuation turn. So background work is settled, not resumed: a + * subagent whose session stopped ends as interrupted, and a thread's held + * follow-ups and queued reports are dropped, so nothing waits on an event + * that already went by. + */ + const reconcile = Effect.gen(function* () { + // A subagent's turn ends before the turn that waits on it, so the + // deepest sessions are read back first. + const running = [...threads] + .filter(([, state]) => state.active !== undefined) + .toSorted(([left], [right]) => callsAbove(right).length - callsAbove(left).length); + const background = [...threads.values()].filter( + (state) => state.subagent === undefined && hasBackground(state), + ); + if (running.length === 0 && background.length === 0 && busy.size === 0) return; + const active = yield* client.session.active(); + // An execution that ended while the stream was down never said so. + for (const sessionId of busy) if (!(sessionId in active)) busy.delete(sessionId); + for (const [sessionId, state] of running) { + const turn = state.active; + if (turn === undefined) continue; + const ended = yield* backfill(sessionId, state); + if (sessionId in active || state.active !== turn) continue; + yield* finishTurn( + state, + ended?.outcome === "succeeded" + ? { status: turn.interrupted ? "interrupted" : "completed" } + : ended?.outcome === "failed" + ? { + status: "failed", + failure: makeProviderFailure({ + message: + "OpenCode ended the turn with an error while T3 Code was reconnecting.", + class: "provider_error", + }), + } + : { status: "interrupted" }, + ); + } + for (const state of background) { + // A subagent still running keeps its call; its end arrives on the new stream. + for (const call of runningCalls(state)) { + const child = call.child?.sessionId; + if (child === undefined || !(child in active)) { + yield* settleCall(call, "interrupted", LOST_BACKGROUND); + } + } + // A subagent's session holds reports of its own background subagents. + for (const session of sessionsOf(state)) { + for (const wake of session.wakes.splice(0)) wake.dropped = true; + session.reports.clear(); + } + } + // A request asked while the stream was down was never shown, and the + // run waits on it; one T3 shows that OpenCode no longer lists was + // answered elsewhere or dropped with its execution. A session that + // stopped dropped its requests. + const listed = new Map>(); + for (const sessionId of Object.keys(active)) { + if (ownerOf(sessionId) === undefined) continue; + const sessionID = Session.ID.make(sessionId); + const [permissions, forms] = yield* Effect.all([ + client.permission.list({ sessionID }), + client.session.form.list({ sessionID }), + ]); + listed.set(sessionId, new Set([...permissions, ...forms].map((request) => request.id))); + const shown = new Set([...pending.values()].map((entry) => entry.native.id)); + for (const request of permissions) { + if (!shown.has(request.id)) yield* onAsked({ type: "permission", request }); + } + for (const form of forms) { + if (!shown.has(form.id)) yield* onAsked({ type: "form", form }); + } + } + // A snapshot: settling a request removes it from the map. + for (const entry of Array.from(pending.values())) { + if (listed.get(entry.sessionId)?.has(entry.native.id) !== true) { + yield* settleRequest(entry, "cancelled"); + } + } + }); + + /** + * Subscribes again, on a restarted server if the old one is gone, and + * reconciles. Retried a few times; the caller fails everything after that. + */ + const reconnect = Effect.gen(function* () { + // Returned on any failure, including the interrupt a closing session + // sends a reconnect still in flight, so a spawned server can idle-stop. + const scope = yield* Scope.make(); + const { next, stream } = yield* Effect.gen(function* () { + const next = yield* borrow.pipe(Effect.provideService(Scope.Scope, scope)); + return { next, stream: yield* next.events }; + }).pipe(Effect.onError(() => Scope.close(scope, Exit.void))); + const previous = currentScope; + connection = next; + client = next.client; + currentScope = scope; + yield* Scope.close(previous, Exit.void); + // A restarted server forgot T3's MCP servers; the next turn adds them again. + for (const state of threads.values()) state.mcp = undefined; + yield* lock.withPermit(reconcile).pipe(Effect.timeout(RECONCILE_TIMEOUT)); + return stream; + }).pipe( + Effect.retry({ times: RECONNECT_ATTEMPTS - 1, schedule: Schedule.spaced(RECONNECT_DELAY) }), + ); + + let currentScope = initial.scope; + // The borrow in use when the session closes is returned with it, so a + // spawned server can still reach its idle shutdown. + yield* Effect.addFinalizer(() => Scope.close(currentScope, Exit.void)); + const follow = ( + stream: Stream.Stream, + ): Effect.Effect => + stream.pipe( + Stream.runForEach((event) => lock.withPermit(handleEvent(event))), + Effect.exit, + Effect.flatMap(() => + Effect.gen(function* () { + streamFailure = "The OpenCode event stream was lost. Reconnecting."; + yield* Effect.logWarning("Lost the OpenCode event stream; reconnecting."); + // The last failure is kept for the log: it can carry the server's + // URL or response text, so only its tag is annotated and the full + // failure stays in the log's cause. + const next = yield* reconnect.pipe( + Effect.tapCause((cause) => + Effect.logWarning( + "Could not reconnect to the OpenCode event stream; failing the session.", + cause, + ).pipe(Effect.annotateLogs({ errorTag: causeErrorTag(cause) })), + ), + Effect.option, + ); + if (next._tag === "None") { + return yield* failAll("The OpenCode event stream was lost and could not reconnect."); + } + streamFailure = undefined; + const done = reconnected; + reconnected = yield* Deferred.make(); + yield* Deferred.succeed(done, undefined); + return yield* follow(next.value); + }), + ), + ); + // Subscribed before any session or prompt call, so no event of theirs is missed. + yield* follow(yield* connection.events).pipe(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 }, + ), + ); + } + + // Context windows come from the server's model list for a directory, read + // when the session opens, before a thread first runs in another directory, + // and again after a turn whose model had none yet. A directory counts as + // known once its read starts, so a failed read is retried only after a turn. + const readModels = (directory: string) => + Effect.suspend(() => { + const windows = contextWindows.get(directory) ?? new Map(); + contextWindows.set(directory, windows); + return client.model.list({ location: { directory } }).pipe( + Effect.timeout("5 seconds"), + Effect.tap((models) => + Effect.sync(() => { + for (const model of models.data) { + // A model's input limit, when it has one, is its real headroom. + windows.set( + `${model.providerID}/${model.id}`, + model.limit.input ?? model.limit.context, + ); + } + }), + ), + Effect.ignore({ log: true }), + ); + }); + const readModelsOnce = (cwd: string | null | undefined) => + contextWindows.has(directoryOf(cwd)) ? Effect.void : readModels(directoryOf(cwd)); + yield* readModels(session.cwd); + + // Each agent's own path allows, by the directory they were listed for. + const agentRules = new Map>>(); + const pathsFor = Effect.fnUntraced(function* ( + directory: string, + agents: ReadonlyArray, + ) { + let known = agentRules.get(directory); + if (known === undefined) { + // A fresh server lists no agents for its first moments, like its models. + const listed = yield* client.agent.list({ location: { directory } }).pipe( + Effect.repeat({ + until: (list) => list.data.length > 0, + schedule: Schedule.spaced("250 millis"), + }), + Effect.timeout("5 seconds"), + // The failure can carry the server's URL or response text, so only its + // tag is annotated; the full failure stays in the log's cause. + Effect.tapCause((cause) => + Effect.logWarning( + "Could not list OpenCode agents; their path rules are skipped.", + cause, + ).pipe(Effect.annotateLogs({ errorTag: causeErrorTag(cause) })), + ), + Effect.option, + ); + if (listed._tag === "None") return []; + known = new Map( + listed.value.data.map((agent) => [agent.id, agentPaths(agent.permissions)]), + ); + agentRules.set(directory, known); + } + const paths = new Map(); + for (const agent of agents) { + for (const entry of known.get(agent) ?? []) { + paths.set(`${entry.action}\u0000${entry.resource}`, entry); + } + } + return [...paths.values()]; + }); + + /** + * The rules a thread's session runs `policy` with. Full access allows + * every path already, so the agent's own path rules are read only for the + * modes that narrow it. + */ + const rulesFor = Effect.fnUntraced(function* ( + thread: Pick, + policy: RulesPolicy, + appThreadId: string | null, + ) { + const plan = policy.interactionMode === "plan"; + const paths = + policy.runtimeMode === "full-access" && !plan + ? [] + : yield* pathsFor(thread.directory, plan ? [thread.agent, "plan"] : [thread.agent]); + return sessionRules( + policy, + paths, + policy.runtimeMode === "full-access" ? [] : thread.grants, + appThreadId === null ? null : yield* mcpServerNameFor(appThreadId), + ); + }); + + /** Writes the session's rules for `policy` when they differ from what it has. */ + const writeRules = Effect.fnUntraced(function* (state: ThreadState, policy: RulesPolicy) { + // A subagent's session may use its thread's T3 server, the root's. + const rules = yield* rulesFor(state, policy, rootOf(state).providerThread.appThreadId); + if (!sameRules(state.rules, rules)) { + yield* client.session.update({ + sessionID: Session.ID.make(state.sessionId), + permissions: rules, + }); + state.rules = rules; + } + state.policy = policy; + }); + + const register = ( + providerThread: OrchestrationV2ProviderThread, + native: { + readonly id: string; + readonly model?: ModelRef | undefined; + readonly agent?: string | undefined; + readonly permissions?: ReadonlyArray | undefined; + }, + directory: string, + ) => { + providerThread = withReportedModel(providerThread, native.model); + const existing = threads.get(native.id); + if (existing !== undefined) { + existing.providerThread = providerThread; + existing.model = native.model; + existing.directory = directory; + existing.agent = native.agent ?? existing.agent; + existing.rules = native.permissions; + return existing; + } + const state = newThreadState(native.id, providerThread, directory, undefined); + state.model = native.model; + state.agent = native.agent ?? state.agent; + state.rules = native.permissions; + threads.set(native.id, state); + return state; + }; + + /** + * Refuses to cut or copy a session's history while something writes to + * it: a turn of T3's, an execution seen running on the stream (a held + * follow-up), or a run on the server this runtime does not own: one a + * timed-out Stop or an unanswered request left behind, or any run on a + * session loaded after the server outlived T3. Only that last case asks + * the server. A rollback also waits for background subagents, whose + * reports wake the session into the history it would cut. + */ + const ensureQuiet = Effect.fnUntraced(function* ( + sessionId: string, + verb: "roll back" | "fork", + ) { + const state = threads.get(sessionId); + const busyHere = + state !== undefined && + (state.active !== undefined || + busy.has(sessionId) || + (verb === "roll back" && hasBackground(state))); + const busyThere = + !busyHere && + (state === undefined || state.unsettled) && + sessionId in (yield* client.session.active().pipe(Effect.timeout(ACTIVE_CHECK_TIMEOUT))); + if (busyHere || busyThere) { + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver: OPENCODE_PROVIDER, + detail: `Cannot ${verb} OpenCode session ${sessionId} while it is still working`, + }); + } + }); + + /** + * Takes back steers a finished turn left in OpenCode's inbox. One whose + * cancel fails may still be there, so it is tried again before the next prompt. + */ + const cancelStrandedSteers = Effect.fnUntraced(function* (state: ThreadState) { + const sessionID = Session.ID.make(state.sessionId); + // A snapshot: a cancelled steer leaves the set. + for (const inboxID of Array.from(state.strandedSteers)) { + const cancelled = yield* client.session.inbox + .cancel({ sessionID, inboxID: SessionMessage.ID.make(inboxID) }) + .pipe( + Effect.timeout(REQUEST_REPLY_TIMEOUT), + Effect.tapCause((cause) => + Effect.logWarning("Could not take back an OpenCode steer.", cause), + ), + Effect.exit, + Effect.map(Exit.isSuccess), + ); + if (cancelled) state.strandedSteers.delete(inboxID); + } + }); + + /** + * Clears a staged revert and waits out the empty execution `clear` runs, + * which is no turn of T3's. 2.0.18 wakes the session after every clear, + * with or without a stage (seen live), so that execution always comes. + */ + const clearRevert = Effect.fnUntraced(function* (sessionId: string) { + const settled = yield* Deferred.make(); + clearing.set(sessionId, settled); + yield* client.session.revert + .clear({ sessionID: Session.ID.make(sessionId) }) + .pipe( + Effect.andThen(Deferred.await(settled)), + Effect.timeout(REQUEST_REPLY_TIMEOUT), + Effect.ensuring(Effect.sync(() => clearing.delete(sessionId))), + ); + stagedReverts.delete(sessionId); + }); + + /** + * Truncates the session's history before `boundary`: OpenCode stages the + * revert, then commits it. If either fails or is interrupted the stage is + * cleared, since OpenCode commits a staged revert on the next prompt; a + * clear that fails too is tried again before that prompt. Files stay where + * they are: T3 restores its own checkpoint when the user asked for files, + * and a rewind without files must not touch them. + */ + const rollBackTo = Effect.fnUntraced(function* ( + sessionId: string, + boundary: SessionMessage.ID, + ) { + const sessionID = Session.ID.make(sessionId); + // Each step is bounded: a server that never answers would otherwise + // hold the session's gate, and the next turn with it. + yield* client.session.revert.stage({ sessionID, messageID: boundary, files: false }).pipe( + Effect.timeout(REQUEST_REPLY_TIMEOUT), + Effect.andThen( + client.session.revert.commit({ sessionID }).pipe(Effect.timeout(REQUEST_REPLY_TIMEOUT)), + ), + Effect.onError(() => + clearRevert(sessionId).pipe( + Effect.tapError(() => Effect.sync(() => stagedReverts.add(sessionId))), + Effect.ignore({ log: true }), + ), + ), + ); + }); + + /** + * The ids fork and rollback can cut before: the session's prompts, and the + * reports continuations answer. The list takes one type, so it reads all. + */ + const userMessages = (sessionId: string) => + OpenCode2Client.paginate( + { sessionID: Session.ID.make(sessionId), limit: 100 }, + client.message.list, + ).pipe( + Stream.runCollect, + Effect.map( + (messages) => + new Set( + messages.flatMap((message) => + message.type === "user" || message.type === "synthetic" ? [String(message.id)] : [], + ), + ), + ), + ); + + /** The session's history as V2 messages, read after any change T3 made to it. */ + const snapshotOf = Effect.fnUntraced(function* ( + providerThread: OrchestrationV2ProviderThread, + sessionId: string, + ) { + const history = yield* OpenCode2Client.paginate( + { sessionID: Session.ID.make(sessionId), order: "asc" as const, limit: 100 }, + client.message.list, + ).pipe(Stream.runCollect); + const snapshotAt = yield* DateTime.now; + const messages = history.flatMap((message): Array => { + const text = + message.type === "user" + ? message.text + : message.type === "assistant" + ? textOf(message.content) + : ""; + if (text.length === 0 || (message.type !== "user" && message.type !== "assistant")) { + return []; + } + const createdAt = message.time.created; + return [ + { + createdBy: message.type === "user" ? "user" : "agent", + creationSource: "provider", + id: idAllocator.derive.messageFromProviderItem({ + driver, + nativeItemId: message.id, + }), + threadId: providerThread.appThreadId ?? input.threadId, + runId: null, + nodeId: null, + role: message.type, + text, + attachments: [], + streaming: false, + createdAt, + updatedAt: createdAt, + }, + ]; + }); + const lastUser = history.findLast((message) => message.type === "user")?.id; + const state = threads.get(sessionId); + return { + providerThread: { + ...providerThread, + providerSessionId: input.providerSessionId, + nativeConversationHeadRef: lastUser === undefined ? null : ref(lastUser, "weak"), + status: "idle" as const, + updatedAt: snapshotAt, + }, + providerTurns: state === undefined ? [] : [...state.providerTurns.values()], + messages, + runtimeRequests: [], + }; + }); + + const promptText = (turnInput: ProviderAdapter.ProviderAdapterV2TurnInput) => + providerMessageTextWithAttachmentPaths({ + text: turnInput.message.text, + attachments: turnInput.message.attachments, + resolveAttachmentPath: host.resolveAttachmentPath, + }).trim(); + + const removeMcp = (mcp: { readonly name: string; readonly directory: string }) => + Effect.suspend(() => + client.mcp.remove({ server: mcp.name, location: { directory: mcp.directory } }), + ).pipe(Effect.timeout("5 seconds"), Effect.ignore({ log: true })); + + // T3's MCP registrations outlive a session only on an external server; a + // spawned one forgets them when it stops. + yield* Effect.addFinalizer(() => + Effect.forEach( + [...threads.values()].flatMap((state) => (state.mcp === undefined ? [] : [state.mcp])), + removeMcp, + { concurrency: 8, discard: true }, + ), + ); + + /** + * T3's MCP server for this thread, and the per-turn instructions. The MCP + * server is registered for the session's directory under the thread's own + * name and credential (the session rules allow only it), and removed when + * the thread unloads or the session closes. OpenCode 2 has no per-prompt + * system field, so the instructions are a session instructions entry, + * which applies from the next step; it is only rewritten when it changes. + */ + const prepareTurn = Effect.fnUntraced(function* ( + sessionId: string, + state: ThreadState, + turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, + ) { + const mcpSession = McpProviderSession.readMcpProviderSession(turnInput.threadId); + const directory = turnInput.runtimePolicy.cwd ?? host.paths.cwd; + const name = yield* mcpServerNameFor(turnInput.threadId); + // An external server may not reach T3's MCP endpoint, as with 1.x. + const wanted = + mcpSession === undefined || connection.external + ? undefined + : { name, directory, credential: mcpSession.authorizationHeader }; + if ( + state.mcp !== undefined && + (wanted === undefined || + state.mcp.directory !== wanted.directory || + state.mcp.credential !== wanted.credential) + ) { + yield* removeMcp(state.mcp); + state.mcp = undefined; + } + // T3's tools are an addition: a server that cannot add them still runs the turn. + if (wanted !== undefined && state.mcp === undefined) { + const added = yield* client.mcp + .add({ + server: name, + location: { directory }, + config: new Mcp.RemoteConfig({ + type: "remote", + url: mcpSession!.endpoint, + headers: { Authorization: wanted.credential }, + oauth: false, + }), + }) + .pipe( + Effect.timeout(INVENTORY_TIMEOUT), + Effect.as(true), + Effect.catchCause((cause) => + Effect.logWarning("Could not add T3 Code's MCP server to OpenCode.", cause).pipe( + Effect.as(false), + ), + ), + ); + if (added) state.mcp = wanted; + } + const instructions = [ + buildRuntimeInstructions({ harness: "OpenCode", model: turnInput.modelSelection.model }), + t3OrchestrationSystemPrompt(state.mcp !== undefined), + ] + .filter((part) => part !== undefined && part.length > 0) + .join("\n\n"); + if (instructions !== state.instructions) { + yield* client.session.instructions.entry.put({ + sessionID: Session.ID.make(sessionId), + key: INSTRUCTIONS_KEY, + value: instructions, + }); + state.instructions = instructions; + } + }); + + const markBefore = (sessionId: string, before: string | null) => + Effect.sync(() => { + const turn = threads.get(sessionId)?.active; + if (turn !== undefined) turn.before = before; + }); + + /** Installs a turn T3 started; every path after it ends the turn with a terminal. */ + const beginTurn = ( + state: ThreadState, + turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, + awaitingStart: boolean, + ) => + Effect.gen(function* () { + const startedAt = yield* DateTime.now; + const nativeTurnId = `${state.sessionId}:attempt:${turnInput.attemptId}`; + const providerTurn: OrchestrationV2ProviderTurn = { + id: idAllocator.derive.providerTurn({ driver, nativeTurnId }), + providerThreadId: turnInput.providerThread.id, + nodeId: turnInput.rootNodeId, + runAttemptId: turnInput.attemptId, + // The user message it prompts with, where fork and rollback cut. A + // continuation prompts nothing; it records the report it answers + // once it takes its execution. + nativeTurnRef: ref( + isContinuation(turnInput) + ? wakeTurnId(state.sessionId, turnInput.attemptId) + : turnPromptId(state.sessionId, turnInput.attemptId), + "weak", + ), + ordinal: turnInput.providerTurnOrdinal, + status: "running", + startedAt, + completedAt: null, + }; + const turn = makeTurn(turnInput, providerTurn, { scope: "", awaitingStart }); + // 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; + }); + + /** Feeds a held execution's events to the turn now running on its session. */ + const replay = Effect.fnUntraced(function* (wake: Wake) { + wake.dropped = true; + for (const event of wake.events) yield* route(event, sessionOfEvent(event)); + }); + + /** + * A user turn that starts while OpenCode runs an execution on its own: the + * prompt joins that execution, so the turn takes it, and its continuation + * turn is no longer needed. Executions that already ended stay for theirs. + */ + const takeRunningWake = Effect.fnUntraced(function* (state: ThreadState) { + const wake = state.wakes.at(-1); + if (wake?.running !== true) return; + state.wakes.pop(); + yield* replay(wake); + }); + + /** + * The continuation turn for an execution OpenCode started on its own: it + * takes the oldest one held and ends with it. One already taken (a user + * turn joined it) leaves nothing to run, so the turn ends at once. + */ + const runWake = Effect.fnUntraced(function* ( + state: ThreadState, + turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, + ) { + yield* beginTurn(state, turnInput, false); + yield* lock.withPermit( + Effect.gen(function* () { + const wake = state.wakes.shift(); + if (wake === undefined) return yield* finishTurn(state, { status: "completed" }); + const turn = state.active; + if (turn !== undefined && wake.after !== undefined) turn.before = wake.after; + // Its first report is where its history begins, so fork and rollback cut there. + if (turn !== undefined && wake.first !== undefined) { + turn.providerTurn = { ...turn.providerTurn, nativeTurnRef: ref(wake.first, "weak") }; + } + yield* replay(wake); + }), + ); + }); + + /** + * Stops a thread's background subagents and any execution OpenCode is + * running on its own. Their sessions end as interrupted; the reports + * OpenCode then queues wake the parent into an execution that is stopped + * as well, so no turn takes it. A subagent the Stop did not reach runs on, + * so its call stays tracked: the session is not released under it, and + * the next Stop tries it again. + */ + const stopBackground = Effect.fnUntraced(function* (state: ThreadState) { + const calls = runningCalls(state).filter((call) => call.background); + // Every session under the thread, which a stopped subagent may report to. + const callers = sessionsOf(state); + // A subagent whose call ended runs again when OpenCode wakes it to + // answer a report of its own background subagent. + const followUps = callers.filter( + (caller) => + caller.subagent !== undefined && + busy.has(caller.sessionId) && + !isOrchestrationV2WorkActive(caller.subagent.call.status), + ); + // OpenCode announces a child's session before the call's progress names + // it, so a call without a child yet is stopped through its caller's + // announced children. + const announcedTo = (caller: ThreadState) => + [...announced.values()].flatMap((info) => + info.parentID === caller.sessionId ? [info.sessionID] : [], + ); + const childrenOf = (call: SubagentCall) => + call.child === undefined ? announcedTo(call.state) : [call.child.sessionId]; + // Each child reports to the session that called it, so its marker goes + // on that caller's state: the thread's own, or a subagent's for a nested one. + const children = new Map( + calls.flatMap((call) => childrenOf(call).map((childId) => [childId, call.state] as const)), + ); + const unreached = new Set(); + for (const [childId, caller] of children) { + caller.stoppedChildren.add(childId); + const reached = yield* client.session + .interrupt({ sessionID: Session.ID.make(childId) }) + .pipe( + // A session that is gone runs nothing. + Effect.catchTags({ SessionNotFoundError: () => Effect.void }), + Effect.timeout(INTERRUPT_TIMEOUT), + Effect.tapCause((cause) => + Effect.logWarning("Could not stop an OpenCode subagent.", cause), + ), + Effect.exit, + Effect.map(Exit.isSuccess), + ); + if (!reached) unreached.add(childId); + } + // Its end closes its turn; one the Stop did not reach stays pending work. + for (const followUp of followUps) { + yield* client.session + .interrupt({ sessionID: Session.ID.make(followUp.sessionId) }) + .pipe( + Effect.catchTags({ SessionNotFoundError: () => Effect.void }), + Effect.timeout(INTERRUPT_TIMEOUT), + Effect.ignore({ log: true }), + ); + } + yield* lock.withPermit( + Effect.gen(function* () { + for (const call of calls) { + if (childrenOf(call).some((childId) => unreached.has(childId))) continue; + yield* settleCall(call, "interrupted"); + } + // A report already queued starts a follow-up the Stop must end too. + for (const caller of callers) { + for (const report of caller.reports.values()) { + caller.stoppedChildren.add(report.childId); + } + } + // A held wake no turn will take: its execution is stopped, not replayed. + const running = state.wakes.some((wake) => wake.running); + for (const wake of state.wakes.splice(0)) wake.dropped = true; + if (running) { + state.unsettled = true; + yield* client.session + .interrupt({ sessionID: Session.ID.make(state.sessionId) }) + .pipe(Effect.timeout(INTERRUPT_TIMEOUT), Effect.ignore({ log: true })); + } + }), + ); + }); + + /** + * What a turn sends: `/compact` compacts, `/name args` naming a workspace + * command runs it, and anything else is a prompt with the `$skill`s it + * names attached. Commands and skills are read from the session's + * directory only when the text could use them. A turn stopped meanwhile + * sends nothing. + */ + const submit = Effect.fnUntraced(function* ( + sessionId: string, + turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, + state: ThreadState, + turn: ActiveTurn, + ) { + const sessionID = Session.ID.make(sessionId); + /** Whether the turn still sends; past this point a Stop goes to the server. */ + const sending = () => { + if (state.active !== turn || turn.interrupted) return false; + turn.unsent = false; + return true; + }; + const text = turnInput.message.text.trim(); + const bare = turnInput.message.attachments.length === 0; + // The turn's own id, so fork and rollback cut before this turn's item. + const id = turnPromptId(sessionId, turnInput.attemptId); + if (bare && text === "/compact") { + if (!sending()) return; + return yield* client.session.compact({ sessionID, id }).pipe(Effect.asVoid); + } + const location = { directory: turnInput.runtimePolicy.cwd ?? host.paths.cwd }; + const command = bare ? commandOf(text) : undefined; + if (command !== undefined) { + const commands = yield* client.command.list({ location }).pipe( + Effect.timeout(INVENTORY_TIMEOUT), + Effect.map((list) => list.data), + Effect.orElseSucceed(() => []), + ); + if (commands.some((entry) => entry.name === command.name)) { + // `session.command` takes no id of T3's and its answer carries none, + // so the turn remembers where the history stood before it. + const newest = yield* client.message.list({ sessionID, order: "desc", limit: 1 }); + yield* markBefore(sessionId, newest.data[0]?.id ?? null); + if (!sending()) return; + return yield* client.session.command({ sessionID, ...command }); + } + } + const skills = SKILL_MENTION.test(text) + ? skillsNamed( + text, + yield* client.skill.list({ location }).pipe( + Effect.timeout(INVENTORY_TIMEOUT), + Effect.map((list) => new Set(list.data.map((skill) => skill.id))), + Effect.orElseSucceed(() => new Set()), + ), + ) + : []; + if (!sending()) return; + return yield* client.session + .prompt({ + sessionID, + id, + text: promptText(turnInput), + ...(skills.length === 0 + ? {} + : { skills: skills.map((id) => ({ id: Skill.ID.make(id) })) }), + }) + .pipe(Effect.asVoid); + }); + + const runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime = { + instanceId, + driver, + providerSessionId: input.providerSessionId, + get providerSession() { + return session; + }, + events: Stream.fromQueue(events), + // A background subagent keeps its session busy after its parent's turn, + // and a held wake still needs its turn: idle release must wait for both. + hasPendingBackgroundWork: Effect.sync(() => [...threads.values()].some(owesWork)), + hasPendingBackgroundWorkForThread: (providerThread) => + Effect.sync(() => { + const nativeId = providerThread.nativeThreadRef?.nativeId; + const state = nativeId == null ? undefined : threads.get(nativeId); + return state !== undefined && hasBackground(state); + }), + // A caller that names no directory gets the one this session opened in. + getModelContextWindow: (selection, cwd) => + selection.instanceId === instanceId + ? windowOf(cwd === undefined ? session.cwd : cwd, selection.model) + : undefined, + 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, + }); + } + yield* readModelsOnce(threadInput.runtimePolicy.cwd); + const model = modelRef(threadInput.modelSelection); + if (model === undefined) { + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver: OPENCODE_PROVIDER, + detail: malformedModel(threadInput.modelSelection.model), + }); + } + const directory = threadInput.runtimePolicy.cwd ?? host.paths.cwd; + const policy = threadInput.runtimePolicy; + // A new session runs OpenCode's default agent. + const permissions = yield* rulesFor( + { directory, agent: "build", grants: [] }, + policy, + threadInput.threadId, + ); + const created = yield* client.session.create({ + location: Location.PublicRef.make({ directory: AbsolutePath.make(directory) }), + model, + 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, + }; + const state = register( + providerThread, + { id: created.id, model: created.model, agent: created.agent, permissions }, + directory, + ); + state.policy = policy; + return state.providerThread; + }).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); + if (threadInput.runtimePolicy !== undefined) { + yield* readModelsOnce(threadInput.runtimePolicy.cwd); + } + // 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) }); + const providerThread: OrchestrationV2ProviderThread = { + ...threadInput.providerThread, + providerSessionId: input.providerSessionId, + status: "idle", + updatedAt: yield* DateTime.now, + }; + const cwd = threadInput.runtimePolicy?.cwd; + const loaded = threads.has(sessionId); + const state = register(providerThread, native, cwd ?? native.location.directory); + // OpenCode keeps no request across its own restart, but a server that + // outlived T3 may still wait on one T3 no longer shows. + if (!loaded) yield* stopLeftoverRequests(state); + // The session gets the rules for this thread's mode: it may have run + // another mode, or been made by 1.x or an earlier build. + yield* writeRules(state, threadInput.runtimePolicy ?? state.policy); + // A thread moved to another worktree takes its session with it. + if (cwd != null && native.location.directory !== cwd) { + yield* client.session.move({ + sessionID: Session.ID.make(sessionId), + directory: AbsolutePath.make(cwd), + }); + } + return state.providerThread; + }).pipe( + Effect.mapError((cause) => + isProviderAdapterError(cause) + ? cause + : new ProviderAdapter.ProviderAdapterResumeThreadError({ + driver, + providerSessionId: input.providerSessionId, + providerThreadId: threadInput.providerThread.id, + cause, + }), + ), + ), + // `/compact` is its own turn, which `startTurn` sends as `session.compact`. + compactThread: (turnInput) => + runtime.startTurn({ ...turnInput, message: { ...turnInput.message, text: "/compact" } }), + startTurn: (turnInput) => + Effect.gen(function* () { + // A lost stream is reconnecting: wait for it rather than prompt into + // a dead stream, and refuse the turn before any request if it gave up. + // Waited out before the session's gate, so a rollback or fork is not + // held behind a reconnect. + if (streamFailure !== undefined) { + yield* Deferred.await(reconnected).pipe(Effect.timeout(RECONNECT_WAIT), Effect.ignore); + } + if (streamFailure !== undefined) { + return yield* new ProviderAdapter.ProviderAdapterEventStreamError({ + driver, + providerSessionId: input.providerSessionId, + cause: streamFailure, + }); + } + // Its checks and setup take the session's gate, so a rollback or + // fork sees either no turn or this one installed; the prompt after + // it does not hold the gate. + const started = yield* exclusive(turnInput.providerThread)( + 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`, + }); + } + // OpenCode already ran this turn on its own; it prompts nothing. + if (isContinuation(turnInput)) return yield* runWake(state, turnInput); + // 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 = beginTurn(state, turnInput, afterUnsettled); + 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. + const model = modelRef(turnInput.modelSelection); + if (model === undefined) { + yield* begin; + return yield* finishTurn(state, { + status: "failed", + failure: makeProviderFailure({ + message: malformedModel(turnInput.modelSelection.model), + class: "validation_error", + }), + }); + } + // Plan mode is OpenCode's `plan` agent, switched before the rules are + // written so they keep that agent's own path allows. Switching to or + // from `plan` queues OpenCode's own "Plan mode" reminder for the prompt. + const agent = agentFor(turnInput); + if (agent !== state.agent) { + yield* client.session.switchAgent({ + sessionID: Session.ID.make(sessionId), + agent: Agent.ID.make(agent), + }); + state.agent = agent; + } + // The thread's mode may have changed since the session was loaded, + // and its subagents still running hold the rules they started with. + // Those run on whether or not this turn starts, so theirs are best effort. + yield* writeRules(state, turnInput.runtimePolicy); + for (const call of runningCalls(state)) { + if (call.child === undefined) continue; + yield* writeRules(call.child, turnInput.runtimePolicy).pipe( + Effect.timeout(REQUEST_REPLY_TIMEOUT), + Effect.ignore({ log: true }), + ); + } + // 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; + } + // Steers a stopped turn never delivered would reach the model first, + // and a revert a failed rollback left staged would be committed. + yield* cancelStrandedSteers(state); + if (stagedReverts.has(sessionId)) yield* clearRevert(sessionId); + yield* prepareTurn(sessionId, state, turnInput); + const turn = yield* begin; + turn.unsent = true; + // An execution OpenCode is running on its own takes this prompt at + // its next step, so this turn is that execution from here on. + yield* lock.withPermit(takeRunningWake(state)); + return { state, sessionId, turn }; + }), + ); + if (started === undefined) return; + const { state, sessionId, turn } = started; + yield* submit(sessionId, turnInput, state, turn).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.gen(function* () { + const sessionId = yield* sessionIdOf(steerInput.providerThread); + const state = threads.get(sessionId); + const turn = state?.active; + if ( + state === undefined || + turn === undefined || + turn.providerTurn.id !== steerInput.providerTurnId || + turn.interrupted + ) { + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver: OPENCODE_PROVIDER, + detail: `OpenCode turn ${steerInput.providerTurnId} is not active`, + }); + } + const inboxID = steerPromptId(sessionId, steerInput.message.messageId); + // A retried steer that already reached the model is not sent again. + if (turn.settledInbox.has(inboxID)) return; + turn.steers.add(inboxID); + state.strandedSteers.delete(inboxID); + yield* client.session + .prompt({ + sessionID: Session.ID.make(sessionId), + id: inboxID, + text: providerMessageTextWithAttachmentPaths({ + text: steerInput.message.text, + attachments: steerInput.message.attachments, + resolveAttachmentPath: host.resolveAttachmentPath, + }).trim(), + delivery: "steer", + }) + .pipe( + Effect.tapError((cause) => + lock.withPermit( + Effect.gen(function* () { + turn.steers.delete(inboxID); + // Without a clear refusal OpenCode may hold it; the next prompt takes it back. + if (!CLEAR_PROMPT_REJECTIONS.has(cause._tag)) state.strandedSteers.add(inboxID); + if (state.active === turn) yield* endIfSettled(state, turn); + }), + ), + ), + ); + }).pipe( + Effect.mapError((cause) => + isProviderAdapterError(cause) + ? cause + : new ProviderAdapter.ProviderAdapterSteerRunError({ + driver, + providerThreadId: steerInput.providerThread.id, + providerTurnId: steerInput.providerTurnId, + cause, + }), + ), + ), + interruptTurn: (interruptInput) => + Effect.gen(function* () { + const sessionId = yield* sessionIdOf(interruptInput.providerThread); + const state = threads.get(sessionId); + if (state === undefined) return; + const turn = state.active; + // OpenCode stops a foreground subagent with its parent, but not a + // background one: a user Stop (`requestRuntimeRestart`) stops those + // too, and the execution OpenCode starts to report them. A turn + // interrupted to restart it with new input leaves them running. + if (interruptInput.requestRuntimeRestart === true) yield* stopBackground(state); + if (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* lock.withPermit(finishTurn(state, { status: "interrupted" })); + } + // Between the executions a late steer spans nothing runs yet. The + // steers are taken back, and a run they already started is skipped. + if (!reply.value.interrupted && state.active === turn && turn.heldEnd !== undefined) { + state.unsettled = true; + yield* lock.withPermit(finishTurn(state, { status: "interrupted" })); + return yield* cancelStrandedSteers(state); + } + // Nothing was running because the turn has not sent its request yet + // (it is reading the workspace's commands or skills): it ends here, + // and never sends it. + if (!reply.value.interrupted && state.active === turn && turn.unsent) { + return yield* lock.withPermit(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.gen(function* () { + const nativeId = providerThread.nativeThreadRef?.nativeId; + const state = nativeId == null ? undefined : threads.get(nativeId); + if ( + nativeId == null || + state === undefined || + state.active !== undefined || + hasBackground(state) + ) { + return; + } + threads.delete(nativeId); + for (const [child, owner] of childOwners) { + if (owner !== state) continue; + childOwners.delete(child); + threads.delete(child); + } + if (state.mcp !== undefined) yield* removeMcp(state.mcp); + }), + respondToRuntimeRequest: (requestInput) => + Effect.gen(function* () { + const entry = pending.get(requestInput.requestId); + if (entry === undefined || entry.answering) { + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver, + detail: `No pending OpenCode request ${requestInput.requestId}`, + }); + } + const { decision, answers } = requestInput; + const { native } = entry; + if (native.type === "permission" && decision === undefined) { + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver, + detail: `OpenCode approval request ${requestInput.requestId} requires a decision`, + }); + } + entry.answering = true; + // OpenCode's own "always" saves a grant for the whole project, so a + // session-wide answer is a rule on this session instead. The grant + // is best effort: this request is answered either way. + // The session that asked waits on the answer and holds the grant: a + // subagent's own, not its parent's. + const asker = threads.get(entry.sessionId) ?? entry.state; + if ( + native.type === "permission" && + (decision === "acceptForSession" || decision === "acceptAlways") + ) { + const state = asker; + for (const resource of native.save) { + if ( + !state.grants.some( + (grant) => grant.action === native.action && grant.resource === resource, + ) + ) { + state.grants.push({ action: native.action, resource, effect: "allow" }); + } + } + // A subagent runs under its thread's mode. + yield* writeRules(state, rootOf(state).policy).pipe(Effect.ignore({ log: true })); + } + const sessionID = Session.ID.make(entry.sessionId); + // Best effort: the decline stands without the note. + if (native.type === "permission" && decision === "decline") { + yield* client.session + .synthetic({ + sessionID, + text: declinedNote(native.action, native.resources), + delivery: "steer", + resume: false, + }) + .pipe(Effect.timeout(REQUEST_REPLY_TIMEOUT), Effect.ignore({ log: true })); + } + const delivered = yield* native.type === "permission" + ? deliver( + client.permission + .reply({ + sessionID, + requestID: Permission.ID.make(native.id), + decision: decision !== undefined && approves(decision) ? "once" : "reject", + // Without a message OpenCode ends the whole run, which is Cancel. + ...(decision === "decline" ? { message: DECLINED } : {}), + }) + .pipe(Effect.catchTags(permissionGone)), + ) + : deliver( + (answers === undefined || decision === "decline" || decision === "cancel" + ? client.session.form.cancel({ + sessionID: entry.sessionId, + formID: Form.ID.make(native.id), + }) + : client.session.form.reply({ + sessionID: entry.sessionId, + formID: Form.ID.make(native.id), + answer: formAnswer(native.form, answers), + }) + ).pipe(Effect.catchTags(formGone)), + ); + yield* forgetRequest(entry); + if (!delivered) yield* abandonRequest(asker, "answer not delivered"); + }).pipe( + Effect.mapError((cause) => + isProviderAdapterError(cause) + ? cause + : new ProviderAdapter.ProviderAdapterRuntimeRequestResponseError({ + driver, + requestId: requestInput.requestId, + cause, + }), + ), + ), + readThreadSnapshot: ({ providerThread }) => + Effect.gen(function* () { + const sessionId = yield* sessionIdOf(providerThread); + return yield* snapshotOf(providerThread, sessionId); + }).pipe( + Effect.mapError((cause) => + isProviderAdapterError(cause) + ? cause + : new ProviderAdapter.ProviderAdapterReadThreadSnapshotError({ + driver, + providerThreadId: providerThread.id, + cause, + }), + ), + ), + rollbackThread: (rollbackInput) => + Effect.gen(function* () { + const { providerThread, target } = rollbackInput; + const sessionId = yield* sessionIdOf(providerThread); + // A stopped run's end may still be on the stream, so `unsettled` + // stays for the next turn to skip it. + yield* ensureQuiet(sessionId, "roll back"); + // A session this runtime has not loaded yet is loaded the way a turn + // would load it, which stops anything it still waits on. Requests it + // stops end their run, so the server is asked again after that. + if (!threads.has(sessionId)) { + yield* runtime.resumeThread({ providerThread }); + yield* ensureQuiet(sessionId, "roll back"); + } + const boundary = yield* boundaryAfter( + rollbackInput.providerThreadTurns, + providerThread.id, + target.type === "provider_turn" ? target.providerTurn : undefined, + yield* userMessages(sessionId), + ); + if (boundary !== null) { + yield* rollBackTo(sessionId, SessionMessage.ID.make(boundary)); + } + // The turns after the target left the history with their messages. + // The snapshot lists the ones kept as T3 recorded them, which a + // runtime that loaded the session after they ran never saw. + const keptOrdinal = target.type === "provider_turn" ? target.providerTurn.ordinal : 0; + const loaded = threads.get(sessionId); + for (const [id, turn] of loaded?.providerTurns ?? []) { + if (turn.ordinal > keptOrdinal) loaded?.providerTurns.delete(id); + } + const snapshot = yield* snapshotOf(providerThread, sessionId); + const kept = new Map( + rollbackInput.providerThreadTurns + .filter( + (turn) => + turn.providerThreadId === providerThread.id && turn.ordinal <= keptOrdinal, + ) + .map((turn) => [turn.id, turn] as const), + ); + for (const turn of snapshot.providerTurns) kept.set(turn.id, turn); + // The snapshot's head is the last user message OpenCode kept. + return { ...snapshot, providerTurns: [...kept.values()] }; + }).pipe( + exclusive(rollbackInput.providerThread), + Effect.mapError((cause) => + isProviderAdapterError(cause) + ? cause + : new ProviderAdapter.ProviderAdapterRollbackThreadError({ + driver, + providerThreadId: rollbackInput.providerThread.id, + checkpointId: rollbackInput.target.checkpointId, + cause, + }), + ), + ), + forkThread: (forkInput) => + Effect.gen(function* () { + const source = forkInput.sourceProviderThread; + const sourceId = yield* sessionIdOf(source); + // OpenCode forks whatever history the source has, so a fork taken + // while anything writes to it would copy a run half done. + yield* ensureQuiet(sourceId, "fork"); + const selected = + forkInput.providerTurnId === undefined + ? undefined + : forkInput.sourceProviderTurns?.find((turn) => turn.id === forkInput.providerTurnId); + if (forkInput.providerTurnId !== undefined && selected === undefined) { + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver: OPENCODE_PROVIDER, + detail: `OpenCode fork boundary turn ${forkInput.providerTurnId} was not found`, + }); + } + const before = + selected === undefined + ? null + : yield* boundaryAfter( + forkInput.sourceProviderTurns ?? [], + source.id, + selected, + yield* userMessages(sourceId), + ); + // A fork copies the source's history, model, location and rules; its + // message ids are new, so it has no turns T3 could cut at yet. + const forked = yield* client.session.fork({ + sessionID: Session.ID.make(sourceId), + ...(before === null ? {} : { before: SessionMessage.ID.make(before) }), + }); + const createdAt = yield* DateTime.now; + const providerThread: OrchestrationV2ProviderThread = { + id: idAllocator.derive.providerThread({ driver, nativeThreadId: forked.id }), + driver, + providerInstanceId: instanceId, + providerSessionId: input.providerSessionId, + appThreadId: forkInput.targetThreadId, + ownerNodeId: forkInput.ownerNodeId ?? null, + nativeThreadRef: ref(forked.id), + nativeConversationHeadRef: null, + status: "idle", + firstRunOrdinal: null, + lastRunOrdinal: null, + handoffIds: [], + forkedFrom: { + providerThreadId: source.id, + ...(forkInput.providerTurnId === undefined + ? {} + : { providerTurnId: forkInput.providerTurnId }), + }, + createdAt, + updatedAt: createdAt, + }; + // A fork keeps the source's location. The target thread usually + // inherits that worktree too, but its first turn starts without a + // resume, so a target in another worktree takes the session there now. + const cwd = forkInput.runtimePolicy?.cwd; + const state = register(providerThread, forked, cwd ?? forked.location.directory); + // The fork runs the target thread's mode, not whatever the source ran. + yield* writeRules(state, forkInput.runtimePolicy ?? state.policy); + if (cwd != null && forked.location.directory !== cwd) { + yield* client.session.move({ + sessionID: Session.ID.make(forked.id), + directory: AbsolutePath.make(cwd), + }); + } + return state.providerThread; + }).pipe( + exclusive(forkInput.sourceProviderThread), + Effect.mapError((cause) => + isProviderAdapterError(cause) + ? cause + : new ProviderAdapter.ProviderAdapterForkThreadError({ + driver, + providerThreadId: forkInput.sourceProviderThread.id, + cause, + }), + ), + ), + }; + 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 scope = yield* Scope.make(); + yield* Effect.addFinalizer((exit) => Scope.close(scope, exit)); + const connection = yield* borrow.pipe(Effect.provideService(Scope.Scope, scope)); + return yield* openSession(input, { connection, scope }); + }).pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterOpenSessionError({ + driver, + providerSessionId: input.providerSessionId, + cause, + }), + ), + ), + }); +}); diff --git a/packages/provider-opencode/src/server/v2/textGeneration.ts b/packages/provider-opencode/src/server/v2/textGeneration.ts new file mode 100644 index 0000000000000000000000000000000000000000..4ac6b8e2ec81ac967e0a77a9cdefb59fe0498681 --- /dev/null +++ b/packages/provider-opencode/src/server/v2/textGeneration.ts @@ -0,0 +1,204 @@ +/** + * Text generation on an OpenCode 2 server. Both of its `generate` endpoints + * answer 403 on OpenCode's free tier ("free tier can only be used from within + * OpenCode"), so each request runs as a prompt in a temporary session, as 1.x + * does, and the session is removed afterwards. + * + * @module textGeneration/OpenCode2TextGeneration + */ +import { AbsolutePath, Location, Model, Provider, Session } from "@opencode/client/effect"; +import { TextGenerationError } from "@t3tools/contracts"; +import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; + +import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; +import * as OpenCode2Server from "./OpenCode2Server.ts"; +import * as OpenCodeRuntime from "../OpenCodeRuntime.ts"; +import * as TextGenerationOperations from "@t3tools/provider-core/server/textGenerationOperations"; + +const isTextGenerationError = Schema.is(TextGenerationError); + +/** A generation that takes this long is stuck; its session is stopped and removed. */ +const GENERATION_TIMEOUT = "3 minutes"; + +/** + * Nothing in a text generation needs a tool, so every tool asks and T3 is not + * there to answer. Denying `shell` or `read` outright gets the whole session + * refused on OpenCode's free models, and an ask that is never answered would + * hang, so asks are rejected as they arrive. + */ +const GENERATION_PERMISSIONS = [{ action: "*", resource: "*", effect: "ask" }] as const; + +/** How a generation ended. A failure's cause is the provider's own error, kept out of `detail`. */ +type Outcome = + | { readonly _tag: "text"; readonly text: string } + | { readonly _tag: "failed"; readonly detail: string; readonly cause?: unknown }; + +const runOnServer = ( + connection: OpenCode2Server.OpenCode2Connection, + input: TextGenerationOperations.Request, + resolveAttachmentPath: ProviderHost.ProviderHostShape["resolveAttachmentPath"], +) => + Effect.gen(function* () { + const { client } = connection; + const parsed = OpenCodeRuntime.parseOpenCodeModelSlug(input.modelSelection.model); + if (parsed === null) { + return yield* new TextGenerationError({ + operation: input.operation, + detail: "OpenCode model selection must use the 'provider/model' format.", + }); + } + const variant = getModelSelectionStringOptionValue(input.modelSelection, "variant"); + const outcome = yield* Deferred.make(); + let sessionId: string | undefined; + const texts = new Map(); + // Subscribed first: the stream is volatile, and the reply arrives on it. + const events = yield* connection.events; + yield* events.pipe( + Stream.runForEach((event) => { + // An execution end a newer server sent in a shape this build cannot + // read still ends the generation, as it ends a turn in the adapter. + if (event.type === "unreadable.execution.ended") { + if (event.sessionID !== sessionId) return Effect.void; + return Deferred.succeed( + outcome, + event.executionType === "session.execution.succeeded" + ? { _tag: "text", text: [...texts.values()].join("\n").trim() } + : { + _tag: "failed", + detail: "OpenCode ended the generation in a way this version cannot read.", + cause: event, + }, + ); + } + if ( + sessionId === undefined || + !("data" in event) || + !("sessionID" in event.data) || + event.data.sessionID !== sessionId + ) { + return Effect.void; + } + switch (event.type) { + case "session.text.ended": + texts.set(`${event.data.assistantMessageID}:${event.data.ordinal}`, event.data.text); + return Effect.void; + case "permission.asked": + return client.permission + .reply({ + sessionID: event.data.sessionID, + requestID: event.data.id, + decision: "reject", + message: "Tools are not available while generating text.", + }) + .pipe(Effect.ignore({ log: true })); + case "session.execution.succeeded": + return Deferred.succeed(outcome, { + _tag: "text", + text: [...texts.values()].join("\n").trim(), + }); + case "session.execution.failed": + return Deferred.succeed(outcome, { + _tag: "failed", + detail: "OpenCode could not generate the text.", + cause: event.data.error, + }); + case "session.execution.interrupted": + return Deferred.succeed(outcome, { + _tag: "failed", + detail: "OpenCode stopped the generation.", + }); + default: + return Effect.void; + } + }), + // The reply only arrives on this stream, so a lost stream ends the wait. + Effect.exit, + Effect.flatMap((exit) => + Deferred.succeed(outcome, { + _tag: "failed", + detail: "The OpenCode event stream was lost.", + cause: exit, + }), + ), + Effect.forkScoped, + ); + const session = yield* client.session.create({ + title: `T3 Code ${input.operation}`, + location: Location.PublicRef.make({ directory: AbsolutePath.make(input.cwd) }), + model: Model.Ref.make({ + providerID: Provider.ID.make(parsed.providerID), + id: Model.ID.make(parsed.modelID), + ...(variant === undefined ? {} : { variant: Model.VariantID.make(variant) }), + }), + permissions: GENERATION_PERMISSIONS, + }); + sessionId = session.id; + yield* Effect.addFinalizer(() => + client.session + .remove({ sessionID: session.id }) + .pipe(Effect.timeout("5 seconds"), Effect.ignore({ log: true })), + ); + const images = (input.attachments ?? []).flatMap((attachment) => { + if (attachment.type !== "image") return []; + const path = resolveAttachmentPath(attachment); + return path === null ? [] : [{ uri: `file://${path}`, name: attachment.name }]; + }); + yield* client.session.prompt({ + sessionID: session.id, + text: input.prompt, + ...(images.length === 0 ? {} : { files: images }), + }); + const result = yield* Deferred.await(outcome).pipe( + Effect.timeoutOrElse({ + duration: GENERATION_TIMEOUT, + orElse: () => + client.session.interrupt({ sessionID: Session.ID.make(session.id) }).pipe( + Effect.ignore, + Effect.as({ + _tag: "failed", + detail: "OpenCode did not finish generating in time.", + }), + ), + }), + ); + if (result._tag === "failed") { + return yield* new TextGenerationError({ + operation: input.operation, + detail: result.detail, + ...(result.cause === undefined ? {} : { cause: result.cause }), + }); + } + if (result.text.length === 0) { + return yield* new TextGenerationError({ + operation: input.operation, + detail: "OpenCode returned empty output.", + }); + } + return result.text; + }).pipe(Effect.scoped); + +/** Text generation for an instance whose server is OpenCode 2. */ +export const make = Effect.fn("OpenCode2TextGeneration.make")(function* () { + const server = yield* OpenCode2Server.OpenCode2Server; + const { resolveAttachmentPath } = yield* ProviderHost.ProviderHost; + const run: TextGenerationOperations.Runner = (input) => + server + .withConnection((connection) => runOnServer(connection, input, resolveAttachmentPath)) + .pipe( + Effect.mapError((cause) => + isTextGenerationError(cause) + ? cause + : new TextGenerationError({ + operation: input.operation, + detail: "OpenCode text generation failed.", + cause, + }), + ), + Effect.flatMap((raw) => TextGenerationOperations.decodeJsonReply(input, "OpenCode", raw)), + ); + return TextGenerationOperations.fromRunner("OpenCode2TextGeneration", run); +}); diff --git a/packages/provider-opencode/src/server/versionProbe.test.ts b/packages/provider-opencode/src/server/versionProbe.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..f0296025d3a87b3a248f89aceae6b09e1993a718 --- /dev/null +++ b/packages/provider-opencode/src/server/versionProbe.test.ts @@ -0,0 +1,197 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { HttpClient, HttpClientError, HttpClientRequest } from "effect/http"; + +import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; +import { + classifyOpenCodeCliVersion, + makeOpenCodeRuntimeProbe, + probeOpenCodeRuntime, +} from "./versionProbe.ts"; +import { + OPENCODE_1_RESPONSES, + OPENCODE_2_RESPONSES, + replayOpenCodeServer, +} from "./probeResponses.fixture.ts"; + +const noBinary = { + runOpenCodeCommand: () => Effect.die("A configured server must not run the local binary"), +} as unknown as OpenCodeRuntime.OpenCodeRuntimeShape; + +const probeServer = (serverUrl: string, serverPassword: string, http: HttpClient.HttpClient) => + probeOpenCodeRuntime({ binaryPath: "opencode", serverUrl, serverPassword }).pipe( + Effect.provideService(HttpClient.HttpClient, http), + Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, noBinary), + ); +const SERVER_URL = "http://127.0.0.1:4096/"; + +describe("OpenCode version probe", () => { + it("classifies the recorded `opencode --version` output of both versions", () => { + assert.deepStrictEqual(classifyOpenCodeCliVersion("1.18.32\n"), { + generation: "v1", + version: "1.18.32", + }); + assert.deepStrictEqual(classifyOpenCodeCliVersion("opencode v2.0.18\n"), { + generation: "v2", + version: "2.0.18", + }); + assert.isUndefined(classifyOpenCodeCliVersion("opencode dev build\n")); + }); + + it.effect("finds 2.x at /api/info without falling through to its HTML /global/health", () => + Effect.gen(function* () { + const paths: Array = []; + const probed = yield* probeServer( + SERVER_URL, + "pw", + replayOpenCodeServer(OPENCODE_2_RESPONSES, "pw", paths), + ); + assert.deepStrictEqual(probed, { generation: "v2", version: "2.0.18" }); + assert.deepStrictEqual(paths, ["/api/info"]); + }), + ); + + it.effect("skips the HTML 1.x serves at /api/info and finds it at /global/health", () => + Effect.gen(function* () { + const paths: Array = []; + const probed = yield* probeServer( + SERVER_URL, + "pw", + replayOpenCodeServer(OPENCODE_1_RESPONSES, "pw", paths), + ); + assert.deepStrictEqual(probed, { generation: "v1", version: "1.18.32" }); + assert.deepStrictEqual(paths, ["/api/info", "/global/health"]); + }), + ); + + it.effect("keeps a server URL's path prefix and query when probing", () => + Effect.gen(function* () { + const urls: Array = []; + const replay = replayOpenCodeServer(OPENCODE_1_RESPONSES, "pw"); + // A reverse proxy mounting OpenCode under /opencode, reached with a routing query. + const proxied = HttpClient.make((request, url) => { + urls.push(url.href); + return replay.execute( + HttpClientRequest.setUrl( + request, + `http://127.0.0.1:4096${url.pathname.replace(/^\/opencode/, "")}`, + ), + ); + }); + const probed = yield* probeServer("http://proxy:8080/opencode/?route=oc", "pw", proxied); + assert.strictEqual(probed.generation, "v1"); + assert.deepStrictEqual(urls, [ + "http://proxy:8080/opencode/api/info?route=oc", + "http://proxy:8080/opencode/global/health?route=oc", + ]); + }), + ); + + it.effect("reports a rejected password instead of guessing a version", () => + Effect.gen(function* () { + for (const responses of [OPENCODE_1_RESPONSES, OPENCODE_2_RESPONSES]) { + const paths: Array = []; + const error = yield* Effect.flip( + probeServer(SERVER_URL, "wrong", replayOpenCodeServer(responses, "pw", paths)), + ); + assert.match(error.detail, /401 Unauthorized/); + assert.deepStrictEqual(paths, ["/api/info"]); + } + }), + ); + + it.effect("sends a non-ASCII password as UTF-8, as both versions expect", () => + Effect.gen(function* () { + for (const [responses, password, generation] of [ + [OPENCODE_1_RESPONSES, "pässwörd", "v1"], + [OPENCODE_2_RESPONSES, "pass€word", "v2"], + ] as const) { + const probed = yield* probeServer( + SERVER_URL, + password, + replayOpenCodeServer(responses, password), + ); + assert.strictEqual(probed.generation, generation); + } + }), + ); + + it.effect("does not take other JSON at /api/info for OpenCode 2", () => + Effect.gen(function* () { + const impostor = { + ...OPENCODE_1_RESPONSES, + "/api/info": { status: 200, contentType: "application/json", body: '{"version":"3.4.1"}' }, + "/global/health": OPENCODE_1_RESPONSES["/api/info"], + }; + const error = yield* Effect.flip( + probeServer(SERVER_URL, "pw", replayOpenCodeServer(impostor, "pw")), + ); + assert.match(error.detail, /did not identify itself as OpenCode/); + }), + ); + + it.effect("never puts the configured URL or its credentials in an error detail", () => + Effect.gen(function* () { + const secret = "userinfo-secret"; + const unreachable = HttpClient.make((request) => + Effect.die(`should not be reached for ${request.url}`), + ); + // A fetch failure that echoes the full request URL, as Node's does. + const refused = HttpClient.make((request) => + Effect.fail( + new HttpClientError.HttpClientError({ + reason: new HttpClientError.TransportError({ + request, + description: `connect ECONNREFUSED ${request.url}`, + }), + }), + ), + ); + for (const [serverUrl, http] of [ + [`127.0.0.1:4096?token=${secret}`, unreachable], + [`localhost:4096/${secret}`, unreachable], + [`http://user:${secret}@10.0.0.1:4096/?token=${secret}`, refused], + ] as const) { + const error = yield* Effect.flip(probeServer(serverUrl, secret, http)); + assert.notInclude(error.detail, secret, serverUrl); + assert.notInclude(error.detail, "10.0.0.1", serverUrl); + } + }), + ); + + it.effect("remembers a probed runtime but never a failure", () => + Effect.gen(function* () { + const outputs = ["", "opencode v2.0.18\n", "1.18.32\n"]; + let calls = 0; + const runtime = { + runOpenCodeCommand: () => { + const stdout = outputs[calls++]; + return stdout + ? Effect.succeed({ stdout, stderr: "", code: 0 }) + : Effect.fail( + new OpenCodeRuntime.OpenCodeRuntimeError({ operation: "spawn", detail: "ENOENT" }), + ); + }, + } as unknown as OpenCodeRuntime.OpenCodeRuntimeShape; + const probe = yield* makeOpenCodeRuntimeProbe( + probeOpenCodeRuntime({ binaryPath: "opencode", serverUrl: "", serverPassword: "" }).pipe( + Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, runtime), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("A local binary must not be probed over HTTP")), + ), + ), + ); + + yield* Effect.flip(probe.get); + assert.isTrue((yield* probe.lastSuccess)._tag === "None"); + assert.strictEqual((yield* probe.get).generation, "v2"); + assert.strictEqual((yield* probe.get).generation, "v2"); + assert.strictEqual(calls, 2); + // A status refresh re-probes, so an in-place downgrade or upgrade re-routes. + assert.strictEqual((yield* probe.refresh).generation, "v1"); + assert.strictEqual((yield* probe.get).generation, "v1"); + assert.strictEqual(calls, 3); + }), + ); +}); diff --git a/packages/provider-opencode/src/server/versionProbe.ts b/packages/provider-opencode/src/server/versionProbe.ts new file mode 100644 index 0000000000000000000000000000000000000000..c1ac522f34247cde90fa50f9980733c798a2f8ff --- /dev/null +++ b/packages/provider-opencode/src/server/versionProbe.ts @@ -0,0 +1,198 @@ +/** Detects whether an OpenCode instance runs 1.x or 2.x, so the driver can pick its runtime. */ +import { parseSemver } from "@t3tools/shared/semver"; +import * as Cache from "effect/Cache"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { HttpClient, HttpClientRequest } from "effect/http"; + +import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; +import { parseGenericCliVersion } from "@t3tools/provider-core/server/snapshotProbe"; + +export interface ProbedOpenCode { + readonly generation: "v1" | "v2"; + readonly version: string; +} + +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. +const decodeApiInfo = Schema.decodeUnknownOption( + Schema.fromJsonString(Schema.Struct({ version: Schema.String, pid: Schema.Int })), +); +const decodeGlobalHealth = Schema.decodeUnknownOption( + Schema.fromJsonString(Schema.Struct({ healthy: Schema.Literal(true), version: Schema.String })), +); + +function probed(version: string | null | undefined): ProbedOpenCode | undefined { + const major = parseSemver(version ?? "")?.major; + if (!version || major === undefined) return undefined; + return { generation: major >= 2 ? "v2" : "v1", version }; +} + +/** `opencode --version` prints `1.18.32` on 1.x and `opencode v2.0.18` on 2.x. */ +export const classifyOpenCodeCliVersion = (output: string) => + probed(parseGenericCliVersion(output)); + +/** + * 2.x answers `/api/info` and 1.x answers `/global/health`. Each serves its web UI's HTML with a + * 200 on the other's path, so only a JSON body counts. Both versions answer a wrong password with + * a 401 on either path, so a 401 says nothing about the version. + */ +function classifyOpenCodeProbeResponse( + path: "/api/info" | "/global/health", + response: { + readonly status: number; + readonly contentType: string | undefined; + readonly body: string; + }, +): ProbedOpenCode | "unauthorized" | undefined { + if (response.status === 401) return "unauthorized"; + const mediaType = response.contentType?.split(";")[0]?.trim().toLowerCase(); + if (response.status !== 200 || mediaType !== "application/json") return undefined; + const version: Option.Option = + path === "/api/info" + ? Option.map(decodeApiInfo(response.body), (info) => info.version) + : Option.map(decodeGlobalHealth(response.body), (health) => health.version); + return probed(Option.getOrUndefined(version)); +} + +const probeOpenCodeBinary = Effect.fn("probeOpenCodeBinary")(function* ( + binaryPath: string, + environment: NodeJS.ProcessEnv | undefined, +) { + const runtime = yield* OpenCodeRuntime.OpenCodeRuntime; + const { stdout } = yield* runtime + .runOpenCodeCommand({ + binaryPath, + args: ["--version"], + ...(environment === undefined ? {} : { environment }), + }) + .pipe( + Effect.timeoutOrElse({ + duration: OPENCODE_VERSION_PROBE_TIMEOUT, + orElse: () => + Effect.fail( + new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: "probeOpenCodeBinary", + detail: `OpenCode CLI version probe timed out after ${OPENCODE_VERSION_PROBE_TIMEOUT}.`, + }), + ), + }), + ); + const result = classifyOpenCodeCliVersion(stdout); + if (result) return result; + return yield* new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: "probeOpenCodeBinary", + detail: `Unable to determine OpenCode version from \`opencode --version\` output. T3 Code requires OpenCode v${OpenCodeRuntime.MINIMUM_OPENCODE_VERSION} or newer.`, + }); +}); + +// Server failures reach clients through the provider status, so their details are fixed text: +// the underlying error can carry the configured URL, its credentials, or the password header. +const probeOpenCodeServer = Effect.fn("probeOpenCodeServer")(function* ( + serverUrl: string, + serverPassword: string, +) { + const client = yield* HttpClient.HttpClient; + const baseUrl = URL.parse(serverUrl.trim()); + if (baseUrl?.protocol !== "http:" && baseUrl?.protocol !== "https:") { + return yield* new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: "probeOpenCodeServer", + detail: "The OpenCode server URL is not a valid http:// or https:// URL.", + }); + } + // UTF-8, as the 1.x SDK client sends it; `HttpClientRequest.basicAuth` uses Latin-1 `btoa`. + const authorization = serverPassword + ? `Basic ${Buffer.from(`opencode:${serverPassword}`, "utf8").toString("base64")}` + : undefined; + for (const path of ["/api/info", "/global/health"] as const) { + // A path prefix and query on the configured URL are kept: `/base/?x=1` → `/base/api/info?x=1`. + const url = new URL(baseUrl); + url.pathname = `${url.pathname.replace(/\/+$/, "")}${path}`; + const request = HttpClientRequest.get(url.href); + const result = yield* client + .execute( + authorization + ? HttpClientRequest.setHeader(request, "authorization", authorization) + : request, + ) + .pipe( + Effect.flatMap((response) => + Effect.map(response.text, (body) => + classifyOpenCodeProbeResponse(path, { + status: response.status, + contentType: response.headers["content-type"], + body, + }), + ), + ), + Effect.mapError( + (cause) => + new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: "probeOpenCodeServer", + detail: "Couldn't reach the OpenCode server.", + cause, + }), + ), + Effect.timeoutOrElse({ + duration: OPENCODE_SERVER_PROBE_TIMEOUT, + orElse: () => + Effect.fail( + new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: "probeOpenCodeServer", + detail: "Timed out while checking the OpenCode server version.", + }), + ), + }), + ); + if (result === "unauthorized") { + return yield* new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: "probeOpenCodeServer", + detail: "401 Unauthorized: the OpenCode server rejected the password.", + }); + } + if (result !== undefined) return result; + } + return yield* new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: "probeOpenCodeServer", + detail: `The server did not identify itself as OpenCode. T3 Code requires OpenCode v${OpenCodeRuntime.MINIMUM_OPENCODE_VERSION} or newer.`, + }); +}); + +/** Probes a configured server when `serverUrl` is set, otherwise the local binary. */ +export const probeOpenCodeRuntime = ( + settings: { + readonly binaryPath: string; + readonly serverUrl: string; + readonly serverPassword: string; + }, + environment?: NodeJS.ProcessEnv, +): Effect.Effect< + ProbedOpenCode, + OpenCodeRuntime.OpenCodeRuntimeError, + HttpClient.HttpClient | OpenCodeRuntime.OpenCodeRuntime +> => + settings.serverUrl.trim().length > 0 + ? probeOpenCodeServer(settings.serverUrl, settings.serverPassword) + : probeOpenCodeBinary(settings.binaryPath, environment); + +/** + * One instance's runtime, remembered after the first successful probe. Settings changes rebuild + * the driver; `refresh` re-probes (status checks use it, so an in-place upgrade re-routes). A + * failed probe is never remembered. `lastSuccess` never probes, for calls too hot to wait on one. + */ +export const makeOpenCodeRuntimeProbe = (probe: Effect.Effect) => + Effect.map( + Cache.makeWith(() => probe, { + capacity: 1, + timeToLive: (exit) => (Exit.isSuccess(exit) ? Duration.infinity : Duration.zero), + }), + (cache) => ({ + get: Cache.get(cache, undefined), + refresh: Cache.refresh(cache, undefined), + lastSuccess: Cache.getSuccess(cache, undefined), + }), + ); diff --git a/packages/provider-opencode/src/settings.ts b/packages/provider-opencode/src/settings.ts new file mode 100644 index 0000000000000000000000000000000000000000..5881b75e00f94df14540cfa9c40bf766e9390dd6 --- /dev/null +++ b/packages/provider-opencode/src/settings.ts @@ -0,0 +1,66 @@ +/** + * OpenCode instance settings. Shared by the server driver and the client + * settings form, so it holds only browser-safe schema code. + * + * @module provider-opencode/settings + */ +import { + CustomModelSetting, + makeBinaryPathSetting, + makeProviderSettingsSchema, + TrimmedString, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +export const OpenCodeSettings = makeProviderSettingsSchema( + { + // Off by default (like Cursor and Grok): the binding is not yet stable + // enough to probe on every install. Users opt in from Settings. + enabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + binaryPath: makeBinaryPathSetting("opencode").pipe( + Schema.annotateKey({ + title: "Binary path", + description: "Path to the OpenCode binary.", + providerSettingsForm: { + placeholder: "opencode", + clearWhenEmpty: "omit", + }, + }), + ), + serverUrl: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "Server URL", + description: "Leave blank to let T3 Code spawn the server when needed.", + providerSettingsForm: { + placeholder: "http://127.0.0.1:4096", + clearWhenEmpty: "omit", + }, + }), + ), + serverPassword: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "Server password", + description: "Stored in plain text on disk.", + providerSettingsForm: { + control: "password", + placeholder: "Optional", + clearWhenEmpty: "omit", + }, + }), + ), + customModels: Schema.Array(CustomModelSetting).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { + order: ["binaryPath", "serverUrl", "serverPassword"], + }, +); +export type OpenCodeSettings = typeof OpenCodeSettings.Type; diff --git a/packages/provider-opencode/src/testing.ts b/packages/provider-opencode/src/testing.ts new file mode 100644 index 0000000000000000000000000000000000000000..5fa87cde4e5082c2a35ee03233ba58fe866cddff --- /dev/null +++ b/packages/provider-opencode/src/testing.ts @@ -0,0 +1,13 @@ +/** + * OpenCode internals the server's replay testkits and live tests drive + * directly: the 2.x client, server, adapter, and text generation, plus the + * 1.x transcript identifiers. + * + * @module provider-opencode/testing + */ +export { + OPENCODE_DEFAULT_INSTANCE_ID, + OPENCODE_PROVIDER, + OPENCODE_SDK_PROTOCOL, +} from "./server/adapter.ts"; +export { OPENCODE_2_STILL_STOPPING, t3McpServerName } from "./server/v2/adapter.ts"; diff --git a/packages/provider-opencode/tsconfig.json b/packages/provider-opencode/tsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..374bac55202ddcad695ede1c567ec219a61e9c48 --- /dev/null +++ b/packages/provider-opencode/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src"] +} diff --git a/packages/shared/package.json b/packages/shared/package.json index ec9277fc0cd46a0e0fac51c406a80d059856ebf0..c5bca30542902a24907e24e49323dd2699275094 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -462,6 +462,10 @@ "./mcpApp": { "types": "./src/mcpApp.ts", "import": "./src/mcpApp.ts" + }, + "./atomicWrite": { + "types": "./src/atomicWrite.ts", + "import": "./src/atomicWrite.ts" } }, "scripts": { diff --git a/packages/shared/src/atomicWrite.test.ts b/packages/shared/src/atomicWrite.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..28cc1e3ca8c5be77f2e14a5594f06c6c59eb1b4b --- /dev/null +++ b/packages/shared/src/atomicWrite.test.ts @@ -0,0 +1,185 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; + +import { writeFileStringAtomically } from "./atomicWrite.ts"; + +it.layer(NodeServices.layer)("writeFileStringAtomically", (it) => { + it.effect("keeps a symlinked file linked and rewrites its destination", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-atomic-write-" }); + const destination = path.join(root, "dotfiles", "settings.json"); + const link = path.join(root, "home", "settings.json"); + yield* fs.makeDirectory(path.dirname(destination), { recursive: true }); + yield* fs.makeDirectory(path.dirname(link), { recursive: true }); + yield* fs.writeFileString(destination, "before"); + yield* fs.symlink(destination, link); + + yield* writeFileStringAtomically({ filePath: link, contents: "after" }); + + assert.strictEqual(yield* fs.readLink(link), destination); + assert.strictEqual(yield* fs.readFileString(destination), "after"); + }), + ); + + it.effect("keeps a dangling symlink linked and creates its destination", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-atomic-write-" }); + const destination = path.join(root, "dotfiles", "settings.json"); + const link = path.join(root, "home", "settings.json"); + yield* fs.makeDirectory(path.dirname(link), { recursive: true }); + yield* fs.symlink(destination, link); + + yield* writeFileStringAtomically({ filePath: link, contents: "fresh" }); + + assert.strictEqual(yield* fs.readLink(link), destination); + assert.strictEqual(yield* fs.readFileString(destination), "fresh"); + }), + ); + + it.effect("fails on a symlink cycle without replacing either link", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-atomic-write-" }); + const first = path.join(root, "first.json"); + const second = path.join(root, "second.json"); + yield* fs.symlink(second, first); + yield* fs.symlink(first, second); + + const result = yield* Effect.exit( + writeFileStringAtomically({ filePath: first, contents: "after" }), + ); + + assert.isTrue(Exit.isFailure(result)); + assert.strictEqual(yield* fs.readLink(first), second); + assert.strictEqual(yield* fs.readLink(second), first); + }), + ); + + it.effect("resolves a relative link through a symlinked parent directory", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-atomic-write-" }); + const destination = path.join(root, "dotfiles", "config", "settings.json"); + const linkedState = path.join(root, "dotfiles", "state"); + const home = path.join(root, "home"); + const link = path.join(home, "state", "settings.json"); + yield* fs.makeDirectory(path.dirname(destination), { recursive: true }); + yield* fs.makeDirectory(linkedState, { recursive: true }); + yield* fs.makeDirectory(home, { recursive: true }); + yield* fs.symlink(linkedState, path.join(home, "state")); + yield* fs.writeFileString(destination, "before"); + yield* fs.symlink("../config/settings.json", link); + + yield* writeFileStringAtomically({ filePath: link, contents: "after" }); + + assert.strictEqual(yield* fs.readLink(link), "../config/settings.json"); + assert.strictEqual(yield* fs.readFileString(destination), "after"); + }), + ); + + it.effect("creates a missing file and its directory", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-atomic-write-" }); + const filePath = path.join(root, "nested", "settings.json"); + + yield* writeFileStringAtomically({ filePath, contents: "fresh" }); + + assert.strictEqual(yield* fs.readFileString(filePath), "fresh"); + }), + ); +}); + +it.effect("surfaces an unreadable link instead of writing over it", () => + Effect.gen(function* () { + const readLinkFailure = PlatformError.systemError({ + _tag: "Unknown", + module: "FileSystem", + method: "readLink", + pathOrDescriptor: "/home/settings.json", + }); + + const result = yield* Effect.exit( + writeFileStringAtomically({ filePath: "/home/settings.json", contents: "after" }), + ); + + assert.deepStrictEqual(result, Exit.fail(readLinkFailure)); + }).pipe( + Effect.provide( + Layer.mergeAll( + Path.layer, + FileSystem.layerNoop({ + readLink: () => + Effect.fail( + PlatformError.systemError({ + _tag: "Unknown", + module: "FileSystem", + method: "readLink", + pathOrDescriptor: "/home/settings.json", + }), + ), + rename: () => Effect.die("an unreadable link must not be replaced"), + }), + ), + ), + ), +); + +it.effect("succeeds when the write lands but its temp directory cannot be removed", () => + Effect.gen(function* () { + const renamed: Array = []; + const removed: Array = []; + const fileSystem = FileSystem.layerNoop({ + // The target does not exist yet, so it is written in place. + readLink: (path) => + Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "FileSystem", + method: "readLink", + pathOrDescriptor: path, + }), + ), + makeDirectory: () => Effect.void, + makeTempDirectory: () => Effect.succeed("/home/settings.json.abc123"), + writeFileString: () => Effect.void, + rename: (_from, to) => Effect.sync(() => void renamed.push(to)), + remove: (path) => + Effect.sync(() => void removed.push(path)).pipe( + Effect.andThen( + Effect.fail( + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "remove", + pathOrDescriptor: path, + }), + ), + ), + ), + }); + + const result = yield* Effect.exit( + writeFileStringAtomically({ filePath: "/home/settings.json", contents: "after" }).pipe( + Effect.provide(Layer.mergeAll(Path.layer, fileSystem)), + ), + ); + + assert.deepStrictEqual(result, Exit.void); + assert.deepStrictEqual(renamed, ["/home/settings.json"]); + assert.deepStrictEqual(removed, ["/home/settings.json.abc123"]); + }), +); diff --git a/packages/shared/src/atomicWrite.ts b/packages/shared/src/atomicWrite.ts new file mode 100644 index 0000000000000000000000000000000000000000..bdceff501d6af63a516266bd92b22f2815d22a8b --- /dev/null +++ b/packages/shared/src/atomicWrite.ts @@ -0,0 +1,38 @@ +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import { resolveSymlinkTarget } from "@t3tools/shared/symlink"; + +/** + * Replaces a file's contents via a sibling temp file and rename. A symlinked + * target is resolved first so the link survives and its destination is + * rewritten, since renaming over the link itself would swap it for a regular file. + */ +export const writeFileStringAtomically = (input: { + readonly filePath: string; + readonly contents: string; +}) => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const targetPath = yield* resolveSymlinkTarget(input.filePath); + const targetDirectory = path.dirname(targetPath); + + yield* fs.makeDirectory(targetDirectory, { recursive: true }); + // The temp directory is cleanup, not part of the write: failing to remove + // it (a virus scanner holding it on Windows) must not fail a write that + // already landed. + const tempDirectory = yield* Effect.acquireRelease( + fs.makeTempDirectory({ + directory: targetDirectory, + prefix: `${path.basename(targetPath)}.`, + }), + (directory) => fs.remove(directory, { recursive: true }).pipe(Effect.ignore({ log: true })), + ); + const tempPath = path.join(tempDirectory, "contents.tmp"); + + yield* fs.writeFileString(tempPath, input.contents); + yield* fs.rename(tempPath, targetPath); + }), + ); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4502702d9c1955d2aa5560290792ced89da23075..39a350f843f440e0d337f6c32403ea318eaad1c0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -359,6 +359,9 @@ importers: '@t3tools/provider-muse': specifier: workspace:* version: link:../../packages/provider-muse + '@t3tools/provider-opencode': + specifier: workspace:* + version: link:../../packages/provider-opencode '@t3tools/provider-pi': specifier: workspace:* version: link:../../packages/provider-pi @@ -594,21 +597,15 @@ importers: '@opencode-ai/sdk': specifier: ^1.3.15 version: 1.15.13 - '@opencode/client': - specifier: 2.0.23 - version: 2.0.23(patch_hash=f725d6e6d9af0d56dc6ed71b863518fbfc751af1773cff2311209d2c404cf4cd)(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f)) - '@opencode/protocol': - specifier: 2.0.23 - version: 2.0.23(patch_hash=17a82f6c2c98fd0371e786c211a4fbdaaba403577a25733e5e99a0c25a16a685) - '@opencode/schema': - specifier: 2.0.23 - version: 2.0.23(patch_hash=650f1b8602789407c010861284272dcb6bd6de01ec6a75bf3c0904a0e7596d59) '@t3tools/provider-core': specifier: workspace:* version: link:../../packages/provider-core '@t3tools/provider-muse': specifier: workspace:* version: link:../../packages/provider-muse + '@t3tools/provider-opencode': + specifier: workspace:* + version: link:../../packages/provider-opencode '@t3tools/provider-pi': specifier: workspace:* version: link:../../packages/provider-pi @@ -742,6 +739,9 @@ importers: '@t3tools/provider-muse': specifier: workspace:* version: link:../../packages/provider-muse + '@t3tools/provider-opencode': + specifier: workspace:* + version: link:../../packages/provider-opencode '@t3tools/provider-pi': specifier: workspace:* version: link:../../packages/provider-pi @@ -1142,6 +1142,49 @@ importers: specifier: 1.0.0 version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + packages/provider-opencode: + dependencies: + '@opencode-ai/sdk': + specifier: ^1.3.15 + version: 1.15.13 + '@opencode/client': + specifier: 2.0.23 + version: 2.0.23(patch_hash=f725d6e6d9af0d56dc6ed71b863518fbfc751af1773cff2311209d2c404cf4cd)(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f)) + '@opencode/protocol': + specifier: 2.0.23 + version: 2.0.23(patch_hash=17a82f6c2c98fd0371e786c211a4fbdaaba403577a25733e5e99a0c25a16a685) + '@opencode/schema': + specifier: 2.0.23 + version: 2.0.23(patch_hash=650f1b8602789407c010861284272dcb6bd6de01ec6a75bf3c0904a0e7596d59) + '@t3tools/contracts': + specifier: workspace:* + version: link:../contracts + '@t3tools/provider-core': + specifier: workspace:* + version: link:../provider-core + '@t3tools/shared': + specifier: workspace:* + version: link:../shared + effect: + specifier: 4.0.1 + version: 4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f) + devDependencies: + '@effect/platform-node': + specifier: 4.0.1 + version: 4.0.1(bufferutil@4.1.0)(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f))(redis@6.2.1)(utf-8-validate@6.0.6) + '@effect/vitest': + specifier: 4.0.1 + version: 4.0.1(patch_hash=359f6fb2f7b3ec145bb72208edb9034f02489791aa2491a55cdbd69bd56ee0d2)(@types/node@24.12.4)(@vitest/ui@5.0.1)(bufferutil@4.1.0)(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f))(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + '@t3tools/provider-testing': + specifier: workspace:* + version: link:../provider-testing + '@types/node': + specifier: 24.12.4 + version: 24.12.4 + vite-plus: + specifier: 1.0.0 + version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + packages/provider-pi: dependencies: '@t3tools/contracts': diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index 2f39d7a61e93727a4fdde815fc2a0eb7313f6b13..400fc17efab4e3c477be224049a5222f6eea3345 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -32,6 +32,7 @@ const workspaceFiles = [ "packages/provider-testing/package.json", "packages/provider-pi/package.json", "packages/provider-muse/package.json", + "packages/provider-opencode/package.json", "packages/effect-acp/package.json", "packages/effect-codex-app-server/package.json", "scripts/package.json",