| author | |
| committer | |
| log | e62161bc5b53389a462d066836d47d25df0f4134 |
| tree | 95efd08cde40d42df6f911c664d0a5d0f89576a4 |
| parent | 456930a09db29efdd3d4a397fe214e9ede2d6975 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>127 files changed, 20465 insertions(+), 20376 deletions(-)
apps/mobile/package.json+1| ... | ... | @@ -71,6 +71,7 @@ |
| 71 | 71 | "@t3tools/mobile-terminal-native": "file:./modules/t3-terminal", |
| 72 | 72 | "@t3tools/provider-core": "workspace:*", |
| 73 | 73 | "@t3tools/provider-muse": "workspace:*", |
| 74 | "@t3tools/provider-opencode": "workspace:*", | |
| 74 | 75 | "@t3tools/provider-pi": "workspace:*", |
| 75 | 76 | "@t3tools/shared": "workspace:*", |
| 76 | 77 | "@tabler/icons-react-native": "^3.44.0", |
apps/mobile/src/components/ProviderIcon.tsx-9| ... | ... | @@ -138,15 +138,6 @@ export function ProviderIcon(props: ProviderIconProps) { |
| 138 | 138 | ); |
| 139 | 139 | } |
| 140 | 140 | |
| 141 | if (props.provider === "opencode") { | |
| 142 | return ( | |
| 143 | <Svg width={size} height={size} viewBox="0 0 32 40" fill="none"> | |
| 144 | <Path d="M24 32H8V16H24V32Z" fill={isDarkMode ? "#4B4646" : "#CFCECD"} /> | |
| 145 | <Path d="M24 8H8V32H24V8ZM32 40H0V0H32V40Z" fill={isDarkMode ? "#F1ECEC" : "#211E1E"} /> | |
| 146 | </Svg> | |
| 147 | ); | |
| 148 | } | |
| 149 | ||
| 150 | 141 | // codex (and unknown drivers) |
| 151 | 142 | return ( |
| 152 | 143 | <Svg width={size} height={size} viewBox="100 100 411 411" fill="none"> |
apps/mobile/src/lib/providerClients.ts+2-1| ... | ... | @@ -1,10 +1,11 @@ |
| 1 | 1 | import { isProviderDriverKind } from "@t3tools/contracts"; |
| 2 | 2 | import { makeProviderClientRegistry } from "@t3tools/provider-core/client"; |
| 3 | 3 | import { museClient } from "@t3tools/provider-muse/client"; |
| 4 | import { openCodeClient } from "@t3tools/provider-opencode/client"; | |
| 4 | 5 | import { piClient } from "@t3tools/provider-pi/client"; |
| 5 | 6 | |
| 6 | 7 | /** The provider client definitions this mobile build ships. */ |
| 7 | const providerClients = makeProviderClientRegistry([museClient, piClient]); | |
| 8 | const providerClients = makeProviderClientRegistry([museClient, openCodeClient, piClient]); | |
| 8 | 9 | |
| 9 | 10 | /** The client definition for a driver kind, or `undefined` for drivers drawn by hand. */ |
| 10 | 11 | export function getProviderClient(driver: string | null | undefined) { |
apps/server/package.json+1-3| ... | ... | @@ -38,11 +38,9 @@ |
| 38 | 38 | "@napi-rs/keyring": "^1.3.0", |
| 39 | 39 | "@noble/hashes": "catalog:", |
| 40 | 40 | "@opencode-ai/sdk": "^1.3.15", |
| 41 | "@opencode/client": "2.0.23", | |
| 42 | "@opencode/protocol": "2.0.23", | |
| 43 | "@opencode/schema": "2.0.23", | |
| 44 | 41 | "@t3tools/provider-core": "workspace:*", |
| 45 | 42 | "@t3tools/provider-muse": "workspace:*", |
| 43 | "@t3tools/provider-opencode": "workspace:*", | |
| 46 | 44 | "@t3tools/provider-pi": "workspace:*", |
| 47 | 45 | "diff": "8.0.3", |
| 48 | 46 | "effect": "catalog:", |
apps/server/src/atomicWrite.test.ts deleted-185| ... | ... | @@ -1,185 +0,0 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { assert, it } from "@effect/vitest"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Exit from "effect/Exit"; | |
| 5 | import * as FileSystem from "effect/FileSystem"; | |
| 6 | import * as Layer from "effect/Layer"; | |
| 7 | import * as Path from "effect/Path"; | |
| 8 | import * as PlatformError from "effect/PlatformError"; | |
| 9 | ||
| 10 | import { writeFileStringAtomically } from "./atomicWrite.ts"; | |
| 11 | ||
| 12 | it.layer(NodeServices.layer)("writeFileStringAtomically", (it) => { | |
| 13 | it.effect("keeps a symlinked file linked and rewrites its destination", () => | |
| 14 | Effect.gen(function* () { | |
| 15 | const fs = yield* FileSystem.FileSystem; | |
| 16 | const path = yield* Path.Path; | |
| 17 | const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-atomic-write-" }); | |
| 18 | const destination = path.join(root, "dotfiles", "settings.json"); | |
| 19 | const link = path.join(root, "home", "settings.json"); | |
| 20 | yield* fs.makeDirectory(path.dirname(destination), { recursive: true }); | |
| 21 | yield* fs.makeDirectory(path.dirname(link), { recursive: true }); | |
| 22 | yield* fs.writeFileString(destination, "before"); | |
| 23 | yield* fs.symlink(destination, link); | |
| 24 | ||
| 25 | yield* writeFileStringAtomically({ filePath: link, contents: "after" }); | |
| 26 | ||
| 27 | assert.strictEqual(yield* fs.readLink(link), destination); | |
| 28 | assert.strictEqual(yield* fs.readFileString(destination), "after"); | |
| 29 | }), | |
| 30 | ); | |
| 31 | ||
| 32 | it.effect("keeps a dangling symlink linked and creates its destination", () => | |
| 33 | Effect.gen(function* () { | |
| 34 | const fs = yield* FileSystem.FileSystem; | |
| 35 | const path = yield* Path.Path; | |
| 36 | const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-atomic-write-" }); | |
| 37 | const destination = path.join(root, "dotfiles", "settings.json"); | |
| 38 | const link = path.join(root, "home", "settings.json"); | |
| 39 | yield* fs.makeDirectory(path.dirname(link), { recursive: true }); | |
| 40 | yield* fs.symlink(destination, link); | |
| 41 | ||
| 42 | yield* writeFileStringAtomically({ filePath: link, contents: "fresh" }); | |
| 43 | ||
| 44 | assert.strictEqual(yield* fs.readLink(link), destination); | |
| 45 | assert.strictEqual(yield* fs.readFileString(destination), "fresh"); | |
| 46 | }), | |
| 47 | ); | |
| 48 | ||
| 49 | it.effect("fails on a symlink cycle without replacing either link", () => | |
| 50 | Effect.gen(function* () { | |
| 51 | const fs = yield* FileSystem.FileSystem; | |
| 52 | const path = yield* Path.Path; | |
| 53 | const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-atomic-write-" }); | |
| 54 | const first = path.join(root, "first.json"); | |
| 55 | const second = path.join(root, "second.json"); | |
| 56 | yield* fs.symlink(second, first); | |
| 57 | yield* fs.symlink(first, second); | |
| 58 | ||
| 59 | const result = yield* Effect.exit( | |
| 60 | writeFileStringAtomically({ filePath: first, contents: "after" }), | |
| 61 | ); | |
| 62 | ||
| 63 | assert.isTrue(Exit.isFailure(result)); | |
| 64 | assert.strictEqual(yield* fs.readLink(first), second); | |
| 65 | assert.strictEqual(yield* fs.readLink(second), first); | |
| 66 | }), | |
| 67 | ); | |
| 68 | ||
| 69 | it.effect("resolves a relative link through a symlinked parent directory", () => | |
| 70 | Effect.gen(function* () { | |
| 71 | const fs = yield* FileSystem.FileSystem; | |
| 72 | const path = yield* Path.Path; | |
| 73 | const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-atomic-write-" }); | |
| 74 | const destination = path.join(root, "dotfiles", "config", "settings.json"); | |
| 75 | const linkedState = path.join(root, "dotfiles", "state"); | |
| 76 | const home = path.join(root, "home"); | |
| 77 | const link = path.join(home, "state", "settings.json"); | |
| 78 | yield* fs.makeDirectory(path.dirname(destination), { recursive: true }); | |
| 79 | yield* fs.makeDirectory(linkedState, { recursive: true }); | |
| 80 | yield* fs.makeDirectory(home, { recursive: true }); | |
| 81 | yield* fs.symlink(linkedState, path.join(home, "state")); | |
| 82 | yield* fs.writeFileString(destination, "before"); | |
| 83 | yield* fs.symlink("../config/settings.json", link); | |
| 84 | ||
| 85 | yield* writeFileStringAtomically({ filePath: link, contents: "after" }); | |
| 86 | ||
| 87 | assert.strictEqual(yield* fs.readLink(link), "../config/settings.json"); | |
| 88 | assert.strictEqual(yield* fs.readFileString(destination), "after"); | |
| 89 | }), | |
| 90 | ); | |
| 91 | ||
| 92 | it.effect("creates a missing file and its directory", () => | |
| 93 | Effect.gen(function* () { | |
| 94 | const fs = yield* FileSystem.FileSystem; | |
| 95 | const path = yield* Path.Path; | |
| 96 | const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-atomic-write-" }); | |
| 97 | const filePath = path.join(root, "nested", "settings.json"); | |
| 98 | ||
| 99 | yield* writeFileStringAtomically({ filePath, contents: "fresh" }); | |
| 100 | ||
| 101 | assert.strictEqual(yield* fs.readFileString(filePath), "fresh"); | |
| 102 | }), | |
| 103 | ); | |
| 104 | }); | |
| 105 | ||
| 106 | it.effect("surfaces an unreadable link instead of writing over it", () => | |
| 107 | Effect.gen(function* () { | |
| 108 | const readLinkFailure = PlatformError.systemError({ | |
| 109 | _tag: "Unknown", | |
| 110 | module: "FileSystem", | |
| 111 | method: "readLink", | |
| 112 | pathOrDescriptor: "/home/settings.json", | |
| 113 | }); | |
| 114 | ||
| 115 | const result = yield* Effect.exit( | |
| 116 | writeFileStringAtomically({ filePath: "/home/settings.json", contents: "after" }), | |
| 117 | ); | |
| 118 | ||
| 119 | assert.deepStrictEqual(result, Exit.fail(readLinkFailure)); | |
| 120 | }).pipe( | |
| 121 | Effect.provide( | |
| 122 | Layer.mergeAll( | |
| 123 | Path.layer, | |
| 124 | FileSystem.layerNoop({ | |
| 125 | readLink: () => | |
| 126 | Effect.fail( | |
| 127 | PlatformError.systemError({ | |
| 128 | _tag: "Unknown", | |
| 129 | module: "FileSystem", | |
| 130 | method: "readLink", | |
| 131 | pathOrDescriptor: "/home/settings.json", | |
| 132 | }), | |
| 133 | ), | |
| 134 | rename: () => Effect.die("an unreadable link must not be replaced"), | |
| 135 | }), | |
| 136 | ), | |
| 137 | ), | |
| 138 | ), | |
| 139 | ); | |
| 140 | ||
| 141 | it.effect("succeeds when the write lands but its temp directory cannot be removed", () => | |
| 142 | Effect.gen(function* () { | |
| 143 | const renamed: Array<string> = []; | |
| 144 | const removed: Array<string> = []; | |
| 145 | const fileSystem = FileSystem.layerNoop({ | |
| 146 | // The target does not exist yet, so it is written in place. | |
| 147 | readLink: (path) => | |
| 148 | Effect.fail( | |
| 149 | PlatformError.systemError({ | |
| 150 | _tag: "NotFound", | |
| 151 | module: "FileSystem", | |
| 152 | method: "readLink", | |
| 153 | pathOrDescriptor: path, | |
| 154 | }), | |
| 155 | ), | |
| 156 | makeDirectory: () => Effect.void, | |
| 157 | makeTempDirectory: () => Effect.succeed("/home/settings.json.abc123"), | |
| 158 | writeFileString: () => Effect.void, | |
| 159 | rename: (_from, to) => Effect.sync(() => void renamed.push(to)), | |
| 160 | remove: (path) => | |
| 161 | Effect.sync(() => void removed.push(path)).pipe( | |
| 162 | Effect.andThen( | |
| 163 | Effect.fail( | |
| 164 | PlatformError.systemError({ | |
| 165 | _tag: "PermissionDenied", | |
| 166 | module: "FileSystem", | |
| 167 | method: "remove", | |
| 168 | pathOrDescriptor: path, | |
| 169 | }), | |
| 170 | ), | |
| 171 | ), | |
| 172 | ), | |
| 173 | }); | |
| 174 | ||
| 175 | const result = yield* Effect.exit( | |
| 176 | writeFileStringAtomically({ filePath: "/home/settings.json", contents: "after" }).pipe( | |
| 177 | Effect.provide(Layer.mergeAll(Path.layer, fileSystem)), | |
| 178 | ), | |
| 179 | ); | |
| 180 | ||
| 181 | assert.deepStrictEqual(result, Exit.void); | |
| 182 | assert.deepStrictEqual(renamed, ["/home/settings.json"]); | |
| 183 | assert.deepStrictEqual(removed, ["/home/settings.json.abc123"]); | |
| 184 | }), | |
| 185 | ); |
apps/server/src/atomicWrite.ts deleted-38| ... | ... | @@ -1,38 +0,0 @@ |
| 1 | import * as Effect from "effect/Effect"; | |
| 2 | import * as FileSystem from "effect/FileSystem"; | |
| 3 | import * as Path from "effect/Path"; | |
| 4 | import { resolveSymlinkTarget } from "@t3tools/shared/symlink"; | |
| 5 | ||
| 6 | /** | |
| 7 | * Replaces a file's contents via a sibling temp file and rename. A symlinked | |
| 8 | * target is resolved first so the link survives and its destination is | |
| 9 | * rewritten, since renaming over the link itself would swap it for a regular file. | |
| 10 | */ | |
| 11 | export const writeFileStringAtomically = (input: { | |
| 12 | readonly filePath: string; | |
| 13 | readonly contents: string; | |
| 14 | }) => | |
| 15 | Effect.scoped( | |
| 16 | Effect.gen(function* () { | |
| 17 | const fs = yield* FileSystem.FileSystem; | |
| 18 | const path = yield* Path.Path; | |
| 19 | const targetPath = yield* resolveSymlinkTarget(input.filePath); | |
| 20 | const targetDirectory = path.dirname(targetPath); | |
| 21 | ||
| 22 | yield* fs.makeDirectory(targetDirectory, { recursive: true }); | |
| 23 | // The temp directory is cleanup, not part of the write: failing to remove | |
| 24 | // it (a virus scanner holding it on Windows) must not fail a write that | |
| 25 | // already landed. | |
| 26 | const tempDirectory = yield* Effect.acquireRelease( | |
| 27 | fs.makeTempDirectory({ | |
| 28 | directory: targetDirectory, | |
| 29 | prefix: `${path.basename(targetPath)}.`, | |
| 30 | }), | |
| 31 | (directory) => fs.remove(directory, { recursive: true }).pipe(Effect.ignore({ log: true })), | |
| 32 | ); | |
| 33 | const tempPath = path.join(tempDirectory, "contents.tmp"); | |
| 34 | ||
| 35 | yield* fs.writeFileString(tempPath, input.contents); | |
| 36 | yield* fs.rename(tempPath, targetPath); | |
| 37 | }), | |
| 38 | ); |
apps/server/src/cli/theme.ts+1-1| ... | ... | @@ -35,7 +35,7 @@ import * as Path from "effect/Path"; |
| 35 | 35 | import * as Schema from "effect/Schema"; |
| 36 | 36 | import { Argument, Command, Flag } from "effect/cli"; |
| 37 | 37 | |
| 38 | import { writeFileStringAtomically } from "../atomicWrite.ts"; | |
| 38 | import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; | |
| 39 | 39 | import * as ServerConfig from "../config.ts"; |
| 40 | 40 | import { |
| 41 | 41 | MAX_THEME_FILE_BYTES, |
apps/server/src/device/LocalDeviceHost.ts+1-1| ... | ... | @@ -47,7 +47,7 @@ import { HttpClient } from "effect/http"; |
| 47 | 47 | import * as ChildProcess from "effect/process/ChildProcess"; |
| 48 | 48 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; |
| 49 | 49 | |
| 50 | import { writeFileStringAtomically } from "../atomicWrite.ts"; | |
| 50 | import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; | |
| 51 | 51 | import * as ServerConfig from "../config.ts"; |
| 52 | 52 | import * as ProcessRunner from "../processRunner.ts"; |
| 53 | 53 | import * as DeviceHost from "./DeviceHost.ts"; |
apps/server/src/keybindings.ts+1-1| ... | ... | @@ -41,7 +41,7 @@ import * as Scope from "effect/Scope"; |
| 41 | 41 | import * as Stream from "effect/Stream"; |
| 42 | 42 | import * as Semaphore from "effect/Semaphore"; |
| 43 | 43 | import * as ServerConfig from "./config.ts"; |
| 44 | import { writeFileStringAtomically } from "./atomicWrite.ts"; | |
| 44 | import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; | |
| 45 | 45 | import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson"; |
| 46 | 46 | import { |
| 47 | 47 | DEFAULT_KEYBINDINGS, |
apps/server/src/mcp/OrchestratorMcpService.ts+1-1| ... | ... | @@ -75,7 +75,7 @@ import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterReg |
| 75 | 75 | import { |
| 76 | 76 | subagentResultForRun, |
| 77 | 77 | delegatedTaskProgress, |
| 78 | } from "../orchestration-v2/SubagentProjection.ts"; | |
| 78 | } from "@t3tools/provider-core/server/subagentProjection"; | |
| 79 | 79 | import { |
| 80 | 80 | DispatchModeLimit, |
| 81 | 81 | type DispatchModeRefusal, |
apps/server/src/orchestration-v2/AcpRegistryOrchestratorV2.live.test.ts+2-2| ... | ... | @@ -30,8 +30,8 @@ import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; |
| 30 | 30 | import * as ModelManifest from "../provider/ModelManifest.ts"; |
| 31 | 31 | import * as ProviderInstanceRegistryHydration from "../provider/ProviderInstanceRegistryHydration.ts"; |
| 32 | 32 | import * as ProviderEventLoggers from "../provider/ProviderEventLoggers.ts"; |
| 33 | import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts"; | |
| 34 | import * as OpenCodeServerLedger from "../provider/OpenCodeServerLedger.ts"; | |
| 33 | import * as OpenCodeRuntime from "@t3tools/provider-opencode/server/OpenCodeRuntime"; | |
| 34 | import * as OpenCodeServerLedger from "@t3tools/provider-opencode/server/OpenCodeServerLedger"; | |
| 35 | 35 | import * as ServerSettings from "../serverSettings.ts"; |
| 36 | 36 | import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; |
| 37 | 37 | import * as VcsProcess from "../vcs/VcsProcess.ts"; |
apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts+1-1| ... | ... | @@ -119,7 +119,7 @@ import { |
| 119 | 119 | makeSubagentChildThread, |
| 120 | 120 | makeSubagentConversationArtifacts, |
| 121 | 121 | subagentThreadTitle, |
| 122 | } from "../SubagentProjection.ts"; | |
| 122 | } from "@t3tools/provider-core/server/subagentProjection"; | |
| 123 | 123 | import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; |
| 124 | 124 | |
| 125 | 125 | export const ACP_PROTOCOL = "acp.ndjson-jsonrpc" as const; |
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts+1-1| ... | ... | @@ -140,7 +140,7 @@ import { |
| 140 | 140 | makeSubagentChildThread, |
| 141 | 141 | makeSubagentConversationArtifacts, |
| 142 | 142 | subagentThreadTitle, |
| 143 | } from "../SubagentProjection.ts"; | |
| 143 | } from "@t3tools/provider-core/server/subagentProjection"; | |
| 144 | 144 | |
| 145 | 145 | export const CLAUDE_PROVIDER = ProviderDriverKind.make("claudeAgent"); |
| 146 | 146 | export const CLAUDE_AGENT_SDK_QUERY_PROTOCOL = "claude-agent-sdk.query" as const; |
apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts+1-1| ... | ... | @@ -165,7 +165,7 @@ import { |
| 165 | 165 | makeSubagentChildThread, |
| 166 | 166 | makeSubagentConversationArtifacts, |
| 167 | 167 | subagentThreadTitle, |
| 168 | } from "../SubagentProjection.ts"; | |
| 168 | } from "@t3tools/provider-core/server/subagentProjection"; | |
| 169 | 169 | |
| 170 | 170 | const CODEX_PROVIDER = ProviderDriverKind.make("codex"); |
| 171 | 171 | export const CODEX_DRIVER_KIND = CODEX_PROVIDER; |
apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts+1-1| ... | ... | @@ -73,7 +73,7 @@ import { |
| 73 | 73 | makeSubagentChildThread, |
| 74 | 74 | makeSubagentConversationArtifacts, |
| 75 | 75 | subagentThreadTitle, |
| 76 | } from "../SubagentProjection.ts"; | |
| 76 | } from "@t3tools/provider-core/server/subagentProjection"; | |
| 77 | 77 | import * as CursorAgentSdk from "./CursorAgentSdk.ts"; |
| 78 | 78 | export { cursorSdkModelSelection } from "../../provider/cursorSdkModel.ts"; |
| 79 | 79 |
apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts+5-2| ... | ... | @@ -46,8 +46,11 @@ import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; |
| 46 | 46 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; |
| 47 | 47 | import type { ProviderContinuationRequest } from "@t3tools/provider-core/server/continuationRequests"; |
| 48 | 48 | import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; |
| 49 | import { OPENCODE_PROVIDER } from "./OpenCodeAdapterV2.ts"; | |
| 50 | import { OPENCODE_2_STILL_STOPPING, t3McpServerName } from "./OpenCode2AdapterV2.ts"; | |
| 49 | import { | |
| 50 | OPENCODE_2_STILL_STOPPING, | |
| 51 | OPENCODE_PROVIDER, | |
| 52 | t3McpServerName, | |
| 53 | } from "@t3tools/provider-opencode/testing"; | |
| 51 | 54 | import { openCode2ReplayRuntime } from "./OpenCode2AdapterV2.testkit.ts"; |
| 52 | 55 | |
| 53 | 56 | const SESSION = "ses_f148ca2deffeJcwCnRQtb0YFNX"; |
apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts+10-32| ... | ... | @@ -20,22 +20,19 @@ import * as HttpClientError from "effect/http/HttpClientError"; |
| 20 | 20 | import * as HttpClientResponse from "effect/http/HttpClientResponse"; |
| 21 | 21 | import * as UrlParams from "effect/http/UrlParams"; |
| 22 | 22 | |
| 23 | import * as ServerConfig from "../../config.ts"; | |
| 24 | import * as OpenCode2Client from "../../provider/opencode2/OpenCode2Client.ts"; | |
| 25 | import * as OpenCode2Server from "../../provider/opencode2/OpenCode2Server.ts"; | |
| 23 | import * as OpenCode2Client from "@t3tools/provider-opencode/server/v2/OpenCode2Client"; | |
| 24 | import * as OpenCode2Server from "@t3tools/provider-opencode/server/v2/OpenCode2Server"; | |
| 26 | 25 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; |
| 27 | 26 | import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts"; |
| 28 | 27 | import type { ProviderReplayGate } from "@t3tools/provider-testing/replayGate"; |
| 29 | import { | |
| 30 | makeReplayServerConfig, | |
| 31 | type OrchestratorV2ProviderReplayHarness, | |
| 32 | } from "../testkit/ProviderReplayHarness.ts"; | |
| 33 | import { OPENCODE_PROVIDER } from "./OpenCodeAdapterV2.ts"; | |
| 28 | import type { OrchestratorV2ProviderReplayHarness } from "../testkit/ProviderReplayHarness.ts"; | |
| 29 | import { layerTestProviderHost } from "@t3tools/provider-testing/host"; | |
| 30 | import { OPENCODE_PROVIDER } from "@t3tools/provider-opencode/testing"; | |
| 34 | 31 | import { |
| 35 | 32 | OpenCodeReplayController, |
| 36 | 33 | OpenCodeReplayTranscriptDecodeError, |
| 37 | 34 | } from "./OpenCodeAdapterV2.testkit.ts"; |
| 38 | import * as OpenCode2AdapterV2 from "./OpenCode2AdapterV2.ts"; | |
| 35 | import * as OpenCode2Adapter from "@t3tools/provider-opencode/server/v2/adapter"; | |
| 39 | 36 | |
| 40 | 37 | export const OPENCODE2_HTTP_PROTOCOL = "opencode2-http.sse" as const; |
| 41 | 38 | const BASE_URL = "http://opencode2.replay"; |
| ... | ... | @@ -265,15 +262,12 @@ const makeReplayAdapter = ( |
| 265 | 262 | ) => |
| 266 | 263 | Effect.gen(function* () { |
| 267 | 264 | const server = yield* replayServer(transcript, options); |
| 268 | return yield* OpenCode2AdapterV2.make(ProviderInstanceId.make("opencode")).pipe( | |
| 265 | return yield* OpenCode2Adapter.make(ProviderInstanceId.make("opencode")).pipe( | |
| 269 | 266 | Effect.provideService(OpenCode2Server.OpenCode2Server, server), |
| 270 | 267 | ); |
| 271 | 268 | }); |
| 272 | 269 | |
| 273 | const layerReplayServerConfig = (scenario: string) => | |
| 274 | Layer.effect(ServerConfig.ServerConfig, makeReplayServerConfig(scenario).pipe(Effect.orDie)).pipe( | |
| 275 | Layer.provide(NodeServices.layer), | |
| 276 | ); | |
| 270 | const layerReplayHost = layerTestProviderHost().pipe(Layer.provide(NodeServices.layer)); | |
| 277 | 271 | |
| 278 | 272 | function layerRegistry( |
| 279 | 273 | transcript: OpenCode2ReplayTranscript, |
| ... | ... | @@ -283,15 +277,7 @@ function layerRegistry( |
| 283 | 277 | makeReplayAdapter(transcript, { external: true, ...options }).pipe( |
| 284 | 278 | Effect.map((adapter) => ProviderAdapterRegistry.layerFromAdapters([adapter])), |
| 285 | 279 | ), |
| 286 | ).pipe( | |
| 287 | Layer.provide( | |
| 288 | Layer.mergeAll( | |
| 289 | layerReplayServerConfig(transcript.scenario), | |
| 290 | IdAllocator.layer, | |
| 291 | NodeServices.layer, | |
| 292 | ), | |
| 293 | ), | |
| 294 | ); | |
| 280 | ).pipe(Layer.provide(Layer.mergeAll(layerReplayHost, IdAllocator.layer, NodeServices.layer))); | |
| 295 | 281 | } |
| 296 | 282 | |
| 297 | 283 | /** |
| ... | ... | @@ -327,15 +313,7 @@ export const openCode2ReplayRuntime = ( |
| 327 | 313 | cwd: "/work/opencode2", |
| 328 | 314 | }, |
| 329 | 315 | }); |
| 330 | }).pipe( | |
| 331 | Effect.provide( | |
| 332 | Layer.mergeAll( | |
| 333 | layerReplayServerConfig("opencode2_adapter"), | |
| 334 | IdAllocator.layer, | |
| 335 | NodeServices.layer, | |
| 336 | ), | |
| 337 | ), | |
| 338 | ); | |
| 316 | }).pipe(Effect.provide(Layer.mergeAll(layerReplayHost, IdAllocator.layer, NodeServices.layer))); | |
| 339 | 317 | |
| 340 | 318 | export const OpenCode2OrchestratorReplayHarness: OrchestratorV2ProviderReplayHarness< |
| 341 | 319 | OpenCode2ReplayTranscript, |
apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts deleted-4291| ... | ... | @@ -1,4291 +0,0 @@ |
| 1 | /** | |
| 2 | * The OpenCode 2 runtime behind the `opencode` driver. It talks to the | |
| 3 | * instance's `opencode serve` process through the HTTP client and reads that | |
| 4 | * server's `/api/event` stream, routed here by session id. | |
| 5 | * | |
| 6 | * A turn is one `session.prompt` (or `session.command`, or `session.compact` | |
| 7 | * for `/compact`); the session's next `session.execution.*` terminal ends it. | |
| 8 | * A steer is another prompt with `delivery: "steer"`, which OpenCode reads at | |
| 9 | * the running execution's next step boundary, so it ends with the turn it | |
| 10 | * joined. Fork and rollback cut the native history before the user message a | |
| 11 | * turn prompted with (its `nativeTurnRef`). Each runtime mode is a set of | |
| 12 | * session permission rules, plan mode is also OpenCode's `plan` agent | |
| 13 | * (switched before the prompt like the model), and OpenCode's permission asks | |
| 14 | * and question forms become runtime requests on the asking session's thread, a | |
| 15 | * subagent's on its parent's. | |
| 16 | * | |
| 17 | * A `subagent` call runs in a child session shown as a subagent thread. A | |
| 18 | * background one outlives its turn; when it ends, OpenCode starts a parent | |
| 19 | * execution T3 did not ask for, which waits here for the continuation turn T3 | |
| 20 | * opens for it. | |
| 21 | * | |
| 22 | * @module orchestration-v2/Adapters/OpenCode2AdapterV2 | |
| 23 | */ | |
| 24 | ||
| 25 | import { | |
| 26 | AbsolutePath, | |
| 27 | Agent, | |
| 28 | Form, | |
| 29 | Location, | |
| 30 | Model, | |
| 31 | Permission, | |
| 32 | Provider, | |
| 33 | Session, | |
| 34 | SessionMessage, | |
| 35 | Skill, | |
| 36 | type OpenCodeEvent, | |
| 37 | } from "@opencode/client/effect"; | |
| 38 | import { Mcp } from "@opencode/schema/mcp"; | |
| 39 | import { | |
| 40 | isOrchestrationV2WorkActive, | |
| 41 | type OrchestrationV2AppThread, | |
| 42 | type OrchestrationV2ConversationMessage, | |
| 43 | type OrchestrationV2ExecutionNode, | |
| 44 | type OrchestrationV2ProviderCapabilities, | |
| 45 | type OrchestrationV2ProviderSession, | |
| 46 | type OrchestrationV2ProviderThread, | |
| 47 | type OrchestrationV2ProviderTurn, | |
| 48 | type OrchestrationV2RuntimeRequest, | |
| 49 | type OrchestrationV2Subagent, | |
| 50 | type OrchestrationV2TurnItem, | |
| 51 | type OrchestrationV2UserInputQuestion, | |
| 52 | type ModelSelection, | |
| 53 | type ProviderApprovalDecision, | |
| 54 | type ProviderInstanceId, | |
| 55 | type RunId, | |
| 56 | type RuntimeRequestId, | |
| 57 | } from "@t3tools/contracts"; | |
| 58 | import type * as Cause from "effect/Cause"; | |
| 59 | import * as DateTime from "effect/DateTime"; | |
| 60 | import * as Deferred from "effect/Deferred"; | |
| 61 | import * as Crypto from "effect/Crypto"; | |
| 62 | import * as Effect from "effect/Effect"; | |
| 63 | import * as Hex from "effect/encoding/Hex"; | |
| 64 | import * as Exit from "effect/Exit"; | |
| 65 | import * as Option from "effect/Option"; | |
| 66 | import * as Queue from "effect/Queue"; | |
| 67 | import * as Schedule from "effect/Schedule"; | |
| 68 | import * as Scope from "effect/Scope"; | |
| 69 | import * as Schema from "effect/Schema"; | |
| 70 | import * as Semaphore from "effect/Semaphore"; | |
| 71 | import * as Stream from "effect/Stream"; | |
| 72 | ||
| 73 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; | |
| 74 | import * as ServerConfig from "../../config.ts"; | |
| 75 | import { paginate, type OpenCode2StreamEvent } from "../../provider/opencode2/OpenCode2Client.ts"; | |
| 76 | import * as OpenCode2Server from "../../provider/opencode2/OpenCode2Server.ts"; | |
| 77 | import { | |
| 78 | parseOpenCodeModelSlug, | |
| 79 | type OpenCodeRuntimeError, | |
| 80 | } from "../../provider/opencodeRuntime.ts"; | |
| 81 | import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; | |
| 82 | import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions"; | |
| 83 | import { t3OrchestrationSystemPrompt } from "@t3tools/provider-core/server/orchestrationInstructions"; | |
| 84 | import { SKILL_MENTION_PATTERN } from "@t3tools/shared/composerInlineTokens"; | |
| 85 | import * as KeyedLock from "@t3tools/shared/KeyedLock"; | |
| 86 | import { getModelSelectionStringOptionValue, modelSelectionsEqual } from "@t3tools/shared/model"; | |
| 87 | import { causeErrorTag } from "@t3tools/shared/observability"; | |
| 88 | ||
| 89 | import { providerMessageTextWithAttachmentPaths } from "@t3tools/provider-core/server/attachmentPrompt"; | |
| 90 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; | |
| 91 | import { | |
| 92 | backgroundWorkNotification, | |
| 93 | type BackgroundWorkReport, | |
| 94 | } from "@t3tools/provider-core/server/notification"; | |
| 95 | import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; | |
| 96 | import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; | |
| 97 | import { | |
| 98 | makeSubagentChildThread, | |
| 99 | makeSubagentConversationArtifacts, | |
| 100 | subagentThreadTitle, | |
| 101 | } from "../SubagentProjection.ts"; | |
| 102 | import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; | |
| 103 | import { turnScopedSelectionTransition } from "@t3tools/provider-core/server/selectionTransition"; | |
| 104 | import { OPENCODE_PROVIDER, openCodePermissionRequestKind } from "./OpenCodeAdapterV2.ts"; | |
| 105 | import { openCodeToolTurnItem } from "./OpenCodeToolItems.ts"; | |
| 106 | ||
| 107 | const OpenCode2ProviderCapabilities = { | |
| 108 | sessions: { | |
| 109 | // One server serves every location, so one session runtime owns them all. | |
| 110 | supportsMultipleProviderThreadsPerSession: true, | |
| 111 | supportsModelSwitchInSession: true, | |
| 112 | supportsProviderSwitchingViaHandoff: true, | |
| 113 | // A mode change rewrites the session's rules when its next turn resumes it. | |
| 114 | supportsRuntimeModeSwitchInSession: true, | |
| 115 | pendingRequestsSurviveRestart: false, | |
| 116 | }, | |
| 117 | threads: { | |
| 118 | canCreateEmptyThread: true, | |
| 119 | canReadThreadSnapshot: true, | |
| 120 | canRollbackThread: true, | |
| 121 | canForkThread: true, | |
| 122 | canForkFromTurn: true, | |
| 123 | canForkFromSubagentThread: false, | |
| 124 | exposesNativeThreadId: true, | |
| 125 | }, | |
| 126 | turns: { | |
| 127 | exposesNativeTurnId: false, | |
| 128 | emitsTurnStarted: true, | |
| 129 | emitsTurnCompleted: true, | |
| 130 | supportsInterrupt: true, | |
| 131 | supportsActiveSteering: true, | |
| 132 | supportsSteeringByInterruptRestart: true, | |
| 133 | // T3 holds queued messages and starts each as its own turn once the one | |
| 134 | // before it ends, so OpenCode's own `queue` delivery is never used. | |
| 135 | supportsQueuedMessages: true, | |
| 136 | terminalStatusQuality: "strong", | |
| 137 | }, | |
| 138 | streaming: { | |
| 139 | streamsAssistantText: true, | |
| 140 | streamsReasoning: true, | |
| 141 | streamsToolOutput: false, | |
| 142 | streamsPlanText: false, | |
| 143 | emitsMessageCompleted: true, | |
| 144 | }, | |
| 145 | tools: { | |
| 146 | exposesToolItemIds: true, | |
| 147 | emitsToolStarted: true, | |
| 148 | emitsToolCompleted: true, | |
| 149 | emitsToolOutput: true, | |
| 150 | supportsMcpTools: false, | |
| 151 | supportsDynamicToolCallbacks: false, | |
| 152 | }, | |
| 153 | approvals: { | |
| 154 | supportsCommandApproval: true, | |
| 155 | supportsFileReadApproval: true, | |
| 156 | supportsFileChangeApproval: true, | |
| 157 | supportsApplyPatchApproval: true, | |
| 158 | approvalsHaveNativeRequestIds: true, | |
| 159 | approvalCallbacksAreLiveOnly: true, | |
| 160 | approvalsCanOriginateFromSubagents: true, | |
| 161 | }, | |
| 162 | planning: { | |
| 163 | emitsPlanUpdated: false, | |
| 164 | emitsTodoList: false, | |
| 165 | emitsProposedPlan: false, | |
| 166 | supportsStructuredQuestions: true, | |
| 167 | planDeltasHaveItemIds: false, | |
| 168 | }, | |
| 169 | subagents: { | |
| 170 | supportsSubagents: true, | |
| 171 | exposesSubagentThreadIds: true, | |
| 172 | emitsSubagentLifecycle: true, | |
| 173 | canWaitForSubagents: true, | |
| 174 | canCloseSubagents: false, | |
| 175 | canForkSubagentThread: false, | |
| 176 | }, | |
| 177 | context: { | |
| 178 | acceptsSystemContext: false, | |
| 179 | acceptsDeveloperContext: false, | |
| 180 | acceptsSyntheticUserContext: true, | |
| 181 | canGenerateSummaries: false, | |
| 182 | canConsumeHandoffSummaries: true, | |
| 183 | supportsDeltaHandoff: true, | |
| 184 | supportsFullThreadHandoff: true, | |
| 185 | maxRecommendedHandoffChars: null, | |
| 186 | }, | |
| 187 | checkpointing: { | |
| 188 | appCanCheckpointFilesystem: true, | |
| 189 | supportsNestedCheckpointScopes: true, | |
| 190 | providerCanRollbackConversation: true, | |
| 191 | providerRollbackReturnsSnapshot: true, | |
| 192 | providerCanReadConversationSnapshot: true, | |
| 193 | }, | |
| 194 | identity: { | |
| 195 | nativeThreadIds: "strong", | |
| 196 | nativeTurnIds: "weak", | |
| 197 | nativeItemIds: "strong", | |
| 198 | nativeRequestIds: "strong", | |
| 199 | }, | |
| 200 | // OpenCode enforces each runtime mode through the session's permission rules. | |
| 201 | runtimePolicy: { enforcement: "native" }, | |
| 202 | } satisfies OrchestrationV2ProviderCapabilities; | |
| 203 | ||
| 204 | type EventOf<T extends OpenCodeEvent["type"]> = Extract<OpenCodeEvent, { readonly type: T }>; | |
| 205 | type Tokens = EventOf<"session.step.ended">["data"]["tokens"]; | |
| 206 | ||
| 207 | /** What a turn reports against: a run's own turn, or a subagent session's runless one. */ | |
| 208 | interface TurnOwner { | |
| 209 | readonly appThread: OrchestrationV2AppThread; | |
| 210 | readonly threadId: OrchestrationV2AppThread["id"]; | |
| 211 | readonly runId: RunId | null; | |
| 212 | readonly runOrdinal: number; | |
| 213 | readonly rootNodeId: ProviderAdapter.ProviderAdapterV2TurnInput["rootNodeId"]; | |
| 214 | readonly modelSelection: ModelSelection; | |
| 215 | /** Where the turn runs: a subagent runs in its parent turn's directory. */ | |
| 216 | readonly runtimePolicy: Pick<ProviderAdapter.ProviderAdapterV2RuntimePolicy, "cwd">; | |
| 217 | } | |
| 218 | ||
| 219 | interface ActiveTurn { | |
| 220 | readonly input: TurnOwner; | |
| 221 | providerTurn: OrchestrationV2ProviderTurn; | |
| 222 | /** Open text and reasoning blocks, keyed `<assistantMessageID>:<kind>:<ordinal>`. */ | |
| 223 | readonly texts: Map<string, OpenBlock>; | |
| 224 | readonly tools: Map<string, { readonly name: string; input: Record<string, unknown> }>; | |
| 225 | readonly startedAt: Map<string, DateTime.Utc>; | |
| 226 | readonly ordinals: Map<string, number>; | |
| 227 | nextOrdinal: number; | |
| 228 | /** Input includes cache reads and writes, as 1.x reports it; the parts are also kept apart. */ | |
| 229 | readonly usage: { | |
| 230 | input: number; | |
| 231 | cached: number; | |
| 232 | cacheWrite: number; | |
| 233 | output: number; | |
| 234 | reasoning: number; | |
| 235 | }; | |
| 236 | steps: number; | |
| 237 | lastStep: Tokens | undefined; | |
| 238 | /** | |
| 239 | * The history item this turn's execution follows, for a turn with no prompt | |
| 240 | * id of T3's: the session's newest item before a `/name` command (null when | |
| 241 | * the history was empty), since `session.command` takes no id and answers | |
| 242 | * without one; the report a continuation turn's execution answers; or, on a | |
| 243 | * subagent's session, the first item queued for its turn. A reconnect | |
| 244 | * backfills that turn from everything after this. Other turns backfill from | |
| 245 | * their own prompt's id (`nativeTurnRef`). | |
| 246 | */ | |
| 247 | before: string | null | undefined; | |
| 248 | /** The compaction running in this turn, `/compact` or OpenCode's own when the context fills. */ | |
| 249 | compaction: { readonly nativeId: string; readonly startedAt: DateTime.Utc } | undefined; | |
| 250 | compactions: number; | |
| 251 | interrupted: boolean; | |
| 252 | /** | |
| 253 | * Set until the turn's prompt, command or compaction is sent. A Stop before | |
| 254 | * then has nothing on the server to stop, so it ends the turn here and the | |
| 255 | * request is never sent. | |
| 256 | */ | |
| 257 | unsent: boolean; | |
| 258 | /** | |
| 259 | * Steers sent into this turn that OpenCode has not delivered yet, by inbox | |
| 260 | * id. Each one wakes the session, so an execution that ends before reading | |
| 261 | * one is followed by another that does, and the turn spans both. | |
| 262 | */ | |
| 263 | readonly steers: Set<string>; | |
| 264 | /** This turn's steers OpenCode delivered or cancelled: a retried one is not sent again. */ | |
| 265 | readonly settledInbox: Set<string>; | |
| 266 | /** How the last execution ended, while the turn waits for the one that delivers its steers. */ | |
| 267 | heldEnd: TurnTerminal | undefined; | |
| 268 | /** | |
| 269 | * Set on a turn started after a timed-out Stop's run left the server: that | |
| 270 | * run's tail can still be on the stream, and everything before this turn's | |
| 271 | * own `session.execution.started` belongs to it. | |
| 272 | */ | |
| 273 | awaitingStart: boolean; | |
| 274 | /** | |
| 275 | * Prefixes a subagent session's tool ids in native item ids: the model | |
| 276 | * names tool calls, so a child's could repeat its parent's. | |
| 277 | */ | |
| 278 | readonly scope: string; | |
| 279 | /** Set once the turn calls `subagent`: its usage then leaves out the subagents'. */ | |
| 280 | usedSubagents: boolean; | |
| 281 | } | |
| 282 | ||
| 283 | /** A `subagent` tool call. A background one outlives the turn that made it. */ | |
| 284 | interface SubagentCall { | |
| 285 | readonly toolId: string; | |
| 286 | readonly nativeId: string; | |
| 287 | /** The calling session and the turn whose item the call is. */ | |
| 288 | readonly state: ThreadState; | |
| 289 | readonly turn: ActiveTurn; | |
| 290 | readonly ordinal: number; | |
| 291 | readonly startedAt: DateTime.Utc; | |
| 292 | prompt: string; | |
| 293 | title: string | null; | |
| 294 | agent: string | undefined; | |
| 295 | model: string | null; | |
| 296 | /** The tool returned while the subagent runs on; the report OpenCode gives its parent settles it. */ | |
| 297 | background: boolean; | |
| 298 | child: ThreadState | undefined; | |
| 299 | status: OrchestrationV2Subagent["status"]; | |
| 300 | result: string | null; | |
| 301 | completedAt: DateTime.Utc | null; | |
| 302 | } | |
| 303 | ||
| 304 | /** | |
| 305 | * An execution OpenCode ran on a thread's session without T3 asking: the | |
| 306 | * parent's answer once a background subagent ended. Its events are held until | |
| 307 | * the continuation turn T3 opens for it takes them. | |
| 308 | */ | |
| 309 | interface Wake { | |
| 310 | readonly events: Array<OpenCode2StreamEvent>; | |
| 311 | running: boolean; | |
| 312 | /** The background subagents whose end it answers, as its turn's notification names them. */ | |
| 313 | readonly reports: Array<BackgroundWorkReport>; | |
| 314 | /** What OpenCode told the model, the continuation's prompt text. */ | |
| 315 | readonly detail: string | null; | |
| 316 | /** Stopped, or taken by a user turn: the continuation it asked for is not needed. */ | |
| 317 | dropped: boolean; | |
| 318 | /** The last report it delivered (inbox ids are history ids): its execution follows it. */ | |
| 319 | readonly after: string | undefined; | |
| 320 | /** The first report it delivered, where fork and rollback cut before its turn. */ | |
| 321 | readonly first: string | undefined; | |
| 322 | } | |
| 323 | ||
| 324 | interface OpenBlock { | |
| 325 | readonly block: { readonly assistantMessageID: string; readonly ordinal: number }; | |
| 326 | readonly kind: "text" | "reasoning"; | |
| 327 | readonly startedAt: DateTime.Utc; | |
| 328 | text: string; | |
| 329 | } | |
| 330 | ||
| 331 | interface ThreadState { | |
| 332 | readonly sessionId: string; | |
| 333 | providerThread: OrchestrationV2ProviderThread; | |
| 334 | readonly providerTurns: Map<string, OrchestrationV2ProviderTurn>; | |
| 335 | active: ActiveTurn | undefined; | |
| 336 | /** What the native session runs now, so a changed selection is switched before prompting. */ | |
| 337 | model: ModelRef | undefined; | |
| 338 | /** | |
| 339 | * Set when a turn ended here while OpenCode may still be running it: a Stop | |
| 340 | * that timed out, a prompt whose request failed without a clear answer, or a | |
| 341 | * request T3 could not answer. Execution events carry only the session id, | |
| 342 | * so the next execution end belongs to that run; it clears this and ends no turn. | |
| 343 | */ | |
| 344 | unsettled: boolean; | |
| 345 | /** The session's location, where its agents' path rules are read. */ | |
| 346 | directory: string; | |
| 347 | /** | |
| 348 | * The agent the session runs (`build`, or `plan` in plan mode); its own path | |
| 349 | * rules stay in force, and a changed mode switches it before prompting. | |
| 350 | */ | |
| 351 | agent: string; | |
| 352 | /** The native session's rules as T3 last read or wrote them, and the policy they are for. */ | |
| 353 | rules: ReadonlyArray<Rule> | undefined; | |
| 354 | policy: RulesPolicy; | |
| 355 | /** "Allow … this session" answers, kept in the session's rules while T3 has it open. */ | |
| 356 | readonly grants: Array<Rule>; | |
| 357 | /** The session's `subagent` calls still running, by tool call id. */ | |
| 358 | readonly calls: Map<string, SubagentCall>; | |
| 359 | /** | |
| 360 | * Set on a subagent's session: the call that runs it and the thread it | |
| 361 | * shows in. Each of its executions is a runless turn there. | |
| 362 | */ | |
| 363 | subagent: | |
| 364 | | { | |
| 365 | call: SubagentCall; | |
| 366 | readonly appThread: OrchestrationV2AppThread; | |
| 367 | turns: number; | |
| 368 | /** The prompt its next execution answers, shown as that turn's user message. */ | |
| 369 | prompt: string | undefined; | |
| 370 | /** | |
| 371 | * The first item queued for the session since its last turn began (its | |
| 372 | * prompt, or a report it answers): where the next turn's history | |
| 373 | * begins, which a reconnect reads back. | |
| 374 | */ | |
| 375 | queued: string | undefined; | |
| 376 | } | |
| 377 | | undefined; | |
| 378 | /** Executions OpenCode started on its own, oldest first, each waiting for its turn. */ | |
| 379 | readonly wakes: Array<Wake>; | |
| 380 | /** | |
| 381 | * Background subagents whose end OpenCode queued for this session and has | |
| 382 | * not delivered yet, by inbox id. The wake that delivers them names them. | |
| 383 | */ | |
| 384 | readonly reports: Map< | |
| 385 | string, | |
| 386 | { | |
| 387 | readonly inboxId: string; | |
| 388 | readonly childId: string; | |
| 389 | readonly report: BackgroundWorkReport; | |
| 390 | readonly text: string; | |
| 391 | } | |
| 392 | >; | |
| 393 | /** | |
| 394 | * Background subagent sessions T3 stopped. OpenCode wakes the parent to | |
| 395 | * report them; a wake that reports only these is stopped as well. | |
| 396 | */ | |
| 397 | readonly stoppedChildren: Set<string>; | |
| 398 | /** | |
| 399 | * Steers a turn ended without (a Stop, a failure). They stay in OpenCode's | |
| 400 | * inbox, where the next prompt would deliver them first, so it cancels them. | |
| 401 | */ | |
| 402 | readonly strandedSteers: Set<string>; | |
| 403 | /** T3's MCP server as registered for this thread, and the instructions entry sent with it. */ | |
| 404 | mcp: | |
| 405 | | { readonly name: string; readonly directory: string; readonly credential: string } | |
| 406 | | undefined; | |
| 407 | instructions: string | undefined; | |
| 408 | } | |
| 409 | ||
| 410 | type TurnTerminal = | |
| 411 | | { readonly status: "completed" | "interrupted" } | |
| 412 | | { readonly status: "failed"; readonly failure: ReturnType<typeof makeProviderFailure> }; | |
| 413 | ||
| 414 | type Rule = Permission.Rule; | |
| 415 | type RulesPolicy = Pick< | |
| 416 | ProviderAdapter.ProviderAdapterV2RuntimePolicy, | |
| 417 | "runtimeMode" | "interactionMode" | |
| 418 | >; | |
| 419 | type NativeForm = EventOf<"form.created">["data"]["form"]; | |
| 420 | ||
| 421 | /** A permission ask or question form shown to the user and not answered yet. */ | |
| 422 | interface PendingRequest { | |
| 423 | readonly request: OrchestrationV2RuntimeRequest; | |
| 424 | readonly item: OrchestrationV2TurnItem; | |
| 425 | readonly node: OrchestrationV2ExecutionNode; | |
| 426 | /** The thread the request is shown on: the asking session's, or its parent's. */ | |
| 427 | readonly state: ThreadState; | |
| 428 | readonly turn: ActiveTurn; | |
| 429 | /** The session that asked: the thread's own, or one of its subagents'. */ | |
| 430 | readonly sessionId: string; | |
| 431 | /** Set once T3 sends its answer; the orchestrator has already recorded it. */ | |
| 432 | answering: boolean; | |
| 433 | readonly native: | |
| 434 | | { | |
| 435 | readonly type: "permission"; | |
| 436 | readonly id: string; | |
| 437 | readonly action: string; | |
| 438 | readonly resources: ReadonlyArray<string>; | |
| 439 | readonly save: ReadonlyArray<string>; | |
| 440 | } | |
| 441 | | { readonly type: "form"; readonly id: string; readonly form: NativeForm }; | |
| 442 | } | |
| 443 | ||
| 444 | const rule = (action: string, effect: Rule["effect"]): Rule => ({ action, resource: "*", effect }); | |
| 445 | ||
| 446 | /** | |
| 447 | * A session's permission rules. OpenCode checks the agent's rules and then | |
| 448 | * these, and the last rule that matches decides, so these override the | |
| 449 | * agent's. `paths` are the agent's own allows for its directories (saved tool | |
| 450 | * output, the plan agent's plan directory), which the blanket rules here would | |
| 451 | * otherwise override; `grants` are "Always allow this session" answers. | |
| 452 | */ | |
| 453 | /** | |
| 454 | * T3's MCP server is registered per directory, not per session, so each thread | |
| 455 | * gets its own `t3-code-<thread>` entry with its own credential. OpenCode names | |
| 456 | * an MCP tool's permission `<server>_<tool>` (non-alphanumerics become `_`). | |
| 457 | * OpenCode skips every tool of a server whose name is over 64 characters (its | |
| 458 | * tool namespace limit), and its router rejects adding one over 100, so a | |
| 459 | * thread id that does not fit is replaced by a digest of it. | |
| 460 | */ | |
| 461 | export const t3McpServerName = Effect.fn("t3McpServerName")(function* (threadId: string) { | |
| 462 | const name = `t3-code-${threadId.replaceAll(/[^a-zA-Z0-9_-]/g, "_")}`; | |
| 463 | if (name.length <= 64) return name; | |
| 464 | const crypto = yield* Crypto.Crypto; | |
| 465 | const digest = yield* crypto | |
| 466 | .digest("SHA-256", new TextEncoder().encode(threadId)) | |
| 467 | .pipe(Effect.orDie); | |
| 468 | return `t3-code-${Hex.encode(digest).slice(0, 16)}`; | |
| 469 | }); | |
| 470 | ||
| 471 | /** | |
| 472 | * The rules that keep T3's MCP servers to their own thread, after the mode's: | |
| 473 | * the last matching rule wins, so every thread's T3 server is denied and then | |
| 474 | * this thread's own is allowed again, in every mode. A subagent's session | |
| 475 | * inherits the thread's. | |
| 476 | */ | |
| 477 | const mcpRules = (mcpServerName: string | null): ReadonlyArray<Rule> => | |
| 478 | mcpServerName === null | |
| 479 | ? [] | |
| 480 | : [ | |
| 481 | { action: "t3-code-*", resource: "*", effect: "deny" }, | |
| 482 | { action: `${mcpServerName}_*`, resource: "*", effect: "allow" }, | |
| 483 | ]; | |
| 484 | ||
| 485 | const sessionRules = ( | |
| 486 | policy: RulesPolicy, | |
| 487 | paths: ReadonlyArray<Rule>, | |
| 488 | grants: ReadonlyArray<Rule>, | |
| 489 | mcpServerName: string | null, | |
| 490 | ): ReadonlyArray<Rule> => [ | |
| 491 | ...(policy.runtimeMode === "full-access" | |
| 492 | ? [rule("*", "allow")] | |
| 493 | : [ | |
| 494 | rule("shell", "ask"), | |
| 495 | rule("edit", policy.runtimeMode === "auto-accept-edits" ? "allow" : "ask"), | |
| 496 | rule("external_directory", "ask"), | |
| 497 | ]), | |
| 498 | ...grants, | |
| 499 | // Plan mode writes only its plan, which `paths` allows again. Shell and read | |
| 500 | // are never denied: the free tier refuses sessions whose rules deny them. | |
| 501 | ...(policy.interactionMode === "plan" ? [rule("edit", "deny")] : []), | |
| 502 | ...paths, | |
| 503 | ...mcpRules(mcpServerName), | |
| 504 | ]; | |
| 505 | ||
| 506 | const sameRules = (left: ReadonlyArray<Rule> | undefined, right: ReadonlyArray<Rule>) => | |
| 507 | left?.length === right.length && | |
| 508 | left.every( | |
| 509 | (entry, index) => | |
| 510 | entry.action === right[index]?.action && | |
| 511 | entry.resource === right[index]?.resource && | |
| 512 | entry.effect === right[index]?.effect, | |
| 513 | ); | |
| 514 | ||
| 515 | /** | |
| 516 | * Sent with a declined permission. A reject without a message is OpenCode's | |
| 517 | * "stop": it ends the whole execution, which is Cancel. | |
| 518 | */ | |
| 519 | const DECLINED = "The user declined this request."; | |
| 520 | ||
| 521 | /** | |
| 522 | * Steered into the session before a decline: a declined shell call reaches the | |
| 523 | * model only as "Unable to execute command", which it retries. | |
| 524 | */ | |
| 525 | const declinedNote = (action: string, resources: ReadonlyArray<string>) => | |
| 526 | `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.`; | |
| 527 | ||
| 528 | /** The session-wide choice for a request whose `save` patterns OpenCode would remember. */ | |
| 529 | const sessionGrantLabel = (action: string, save: ReadonlyArray<string>) => | |
| 530 | save.every((pattern) => pattern === "*") | |
| 531 | ? `Allow every ${action} request this session` | |
| 532 | : `Allow ${save.join(", ")} this session`; | |
| 533 | ||
| 534 | const text = (value: string | undefined, fallback: string) => value?.trim() || fallback; | |
| 535 | ||
| 536 | /** | |
| 537 | * A form's fields as T3 questions, or why T3 cannot ask them: a link to open, | |
| 538 | * a field shown only for another answer, a hidden field, or a number or yes/no | |
| 539 | * value. OpenCode's question tool only asks text and multi-select fields. | |
| 540 | */ | |
| 541 | const formQuestions = ( | |
| 542 | form: NativeForm, | |
| 543 | ): | |
| 544 | | { readonly questions: ReadonlyArray<OrchestrationV2UserInputQuestion> } | |
| 545 | | { readonly unsupported: string } => { | |
| 546 | const questions: Array<OrchestrationV2UserInputQuestion> = []; | |
| 547 | for (const [index, field] of form.fields.entries()) { | |
| 548 | if (field.type === "external") return { unsupported: "a link to open" }; | |
| 549 | if (field.type !== "string" && field.type !== "multiselect") { | |
| 550 | return { unsupported: `a ${field.type} value` }; | |
| 551 | } | |
| 552 | if (field.hidden === true) return { unsupported: "a hidden field" }; | |
| 553 | if ((field.when?.length ?? 0) > 0) return { unsupported: "a field that depends on another" }; | |
| 554 | const header = text(field.title, `Question ${index + 1}`); | |
| 555 | const options = (field.options ?? []).map((option) => { | |
| 556 | const label = text(option.label, text(option.value, "Option")); | |
| 557 | return { label, description: text(option.description, label), value: option.value }; | |
| 558 | }); | |
| 559 | questions.push({ | |
| 560 | id: field.key, | |
| 561 | header, | |
| 562 | question: text(field.description, header), | |
| 563 | options, | |
| 564 | multiSelect: field.type === "multiselect", | |
| 565 | allowCustomAnswer: field.custom === true || options.length === 0, | |
| 566 | }); | |
| 567 | } | |
| 568 | return { questions }; | |
| 569 | }; | |
| 570 | ||
| 571 | /** T3's answers in OpenCode's shape: a list for a multi-select, text otherwise. */ | |
| 572 | const formAnswer = (form: NativeForm, answers: Readonly<Record<string, unknown>>) => { | |
| 573 | const answer: Record<string, string | ReadonlyArray<string>> = {}; | |
| 574 | for (const field of form.fields) { | |
| 575 | const raw = answers[field.key]; | |
| 576 | const values = (Array.isArray(raw) ? raw : [raw]).filter( | |
| 577 | (value): value is string => typeof value === "string" && value.trim().length > 0, | |
| 578 | ); | |
| 579 | if (values.length > 0) { | |
| 580 | answer[field.key] = field.type === "multiselect" ? values : values.join(", "); | |
| 581 | } | |
| 582 | } | |
| 583 | return answer; | |
| 584 | }; | |
| 585 | ||
| 586 | const approves = (decision: ProviderApprovalDecision) => | |
| 587 | decision === "accept" || decision === "acceptForSession" || decision === "acceptAlways"; | |
| 588 | ||
| 589 | /** | |
| 590 | * An answer to a request OpenCode already dropped (its execution ended, or | |
| 591 | * its session is gone): nothing waits on it, so it counts as delivered. | |
| 592 | */ | |
| 593 | const permissionGone = { | |
| 594 | PermissionNotFoundError: () => Effect.void, | |
| 595 | SessionNotFoundError: () => Effect.void, | |
| 596 | }; | |
| 597 | const formGone = { | |
| 598 | FormNotFoundError: () => Effect.void, | |
| 599 | FormAlreadySettledError: () => Effect.void, | |
| 600 | SessionNotFoundError: () => Effect.void, | |
| 601 | }; | |
| 602 | ||
| 603 | /** The session rules an agent keeps for its own directories, which T3's blanket rules would override. */ | |
| 604 | const agentPaths = (rules: ReadonlyArray<Rule>) => | |
| 605 | rules.filter( | |
| 606 | (entry) => | |
| 607 | entry.effect === "allow" && | |
| 608 | entry.resource !== "*" && | |
| 609 | (entry.action === "edit" || entry.action === "external_directory"), | |
| 610 | ); | |
| 611 | ||
| 612 | const ref = (nativeId: string, strength: "strong" | "weak" = "strong") => ({ | |
| 613 | driver: OPENCODE_PROVIDER, | |
| 614 | nativeId, | |
| 615 | strength, | |
| 616 | }); | |
| 617 | ||
| 618 | const sessionIdOf = (providerThread: OrchestrationV2ProviderThread) => { | |
| 619 | const nativeId = providerThread.nativeThreadRef?.nativeId; | |
| 620 | return nativeId === undefined || nativeId === null | |
| 621 | ? Effect.fail( | |
| 622 | new ProviderAdapter.ProviderAdapterProtocolError({ | |
| 623 | driver: OPENCODE_PROVIDER, | |
| 624 | detail: `Provider thread ${providerThread.id} has no OpenCode session`, | |
| 625 | }), | |
| 626 | ) | |
| 627 | : Effect.succeed(nativeId); | |
| 628 | }; | |
| 629 | ||
| 630 | const textOf = (content: ReadonlyArray<{ readonly type: string; readonly text?: string }>) => | |
| 631 | content.flatMap((part) => (part.type === "text" && part.text ? [part.text] : [])).join("\n"); | |
| 632 | ||
| 633 | const stringField = (record: Readonly<Record<string, unknown>> | undefined, key: string) => { | |
| 634 | const value = record?.[key]; | |
| 635 | return typeof value === "string" && value.trim().length > 0 ? value : undefined; | |
| 636 | }; | |
| 637 | ||
| 638 | /** How a background subagent ended, from the `state` of OpenCode's report to its parent. */ | |
| 639 | const reportOutcome = (state: string | undefined) => | |
| 640 | state === "completed" | |
| 641 | ? ("completed" as const) | |
| 642 | : state === "error" | |
| 643 | ? ("failed" as const) | |
| 644 | : state === "cancelled" | |
| 645 | ? ("cancelled" as const) | |
| 646 | : ("unknown" as const); | |
| 647 | ||
| 648 | /** A subagent's answer, without the `<subagent …>` wrapper OpenCode gives the model. */ | |
| 649 | const subagentOutput = (text: string) => | |
| 650 | /^<subagent\b[^>]*>\n?([\s\S]*?)\n?<\/subagent>$/.exec(text.trim())?.[1] ?? text; | |
| 651 | ||
| 652 | const isContinuation = (turnInput: ProviderAdapter.ProviderAdapterV2TurnInput) => | |
| 653 | turnInput.message.createdBy === "agent" && turnInput.message.creationSource === "provider"; | |
| 654 | ||
| 655 | /** The native turn id of a continuation turn, which answers a wake and has no prompt. */ | |
| 656 | const wakeTurnId = (sessionId: string, attemptId: string) => `${sessionId}:wake:${attemptId}`; | |
| 657 | const isWakeTurn = (turn: OrchestrationV2ProviderTurn) => | |
| 658 | turn.nativeTurnRef?.nativeId?.includes(":wake:") === true; | |
| 659 | ||
| 660 | const INTERRUPT_TIMEOUT = "10 seconds"; | |
| 661 | /** The session instructions entry T3 writes its per-turn system prompt to. */ | |
| 662 | const INSTRUCTIONS_KEY = "t3-code"; | |
| 663 | /** A lost event stream is resubscribed this many times, this far apart, before the session breaks. */ | |
| 664 | const RECONNECT_ATTEMPTS = 5; | |
| 665 | const RECONNECT_DELAY = "2 seconds"; | |
| 666 | const RECONCILE_TIMEOUT = "15 seconds"; | |
| 667 | /** How long a new turn waits for a reconnect in progress. */ | |
| 668 | const RECONNECT_WAIT = "30 seconds"; | |
| 669 | /** A background subagent's result when its end was lost with the event stream. */ | |
| 670 | const LOST_BACKGROUND = | |
| 671 | "T3 Code lost its connection to OpenCode while this subagent ran, so its result is not shown."; | |
| 672 | /** How long a turn waits on the directory's commands or skills before sending the text as is. */ | |
| 673 | const INVENTORY_TIMEOUT = "5 seconds"; | |
| 674 | const ACTIVE_CHECK_TIMEOUT = "5 seconds"; | |
| 675 | /** Answers that mean the server refused a prompt; any other failure may have been accepted. */ | |
| 676 | const CLEAR_PROMPT_REJECTIONS: ReadonlySet<string> = new Set([ | |
| 677 | "InvalidRequestError", | |
| 678 | "ConflictError", | |
| 679 | "UnauthorizedError", | |
| 680 | "CommandNotFoundError", | |
| 681 | "SkillNotFoundError", | |
| 682 | ]); | |
| 683 | ||
| 684 | export const OPENCODE_2_STILL_STOPPING = | |
| 685 | "OpenCode is still stopping the previous turn. Send the message again in a moment."; | |
| 686 | const REQUEST_REPLY_TIMEOUT = "10 seconds"; | |
| 687 | ||
| 688 | /** Whether an answer to a paused request reached the server, trying twice. */ | |
| 689 | const deliver = <E>(answer: Effect.Effect<void, E>) => | |
| 690 | answer.pipe( | |
| 691 | Effect.retry({ times: 1 }), | |
| 692 | Effect.timeout(REQUEST_REPLY_TIMEOUT), | |
| 693 | Effect.exit, | |
| 694 | Effect.map(Exit.isSuccess), | |
| 695 | ); | |
| 696 | ||
| 697 | type ModelRef = ReturnType<typeof Model.Ref.make>; | |
| 698 | ||
| 699 | /** | |
| 700 | * The user message ids T3 prompts under. OpenCode takes a client id (it must | |
| 701 | * start with `msg_`) and answers a repeat of one in the same session with the | |
| 702 | * item it already has, so a retried request never queues a second message, | |
| 703 | * and a turn knows the message fork and rollback cut at before OpenCode | |
| 704 | * answers. The id is unique across the whole server, which refuses it in any | |
| 705 | * other session (409), so it names the session: another T3 database or | |
| 706 | * environment on the same server repeats thread ids and run ordinals, never | |
| 707 | * session ids. A turn keeps its id in `nativeTurnRef`. | |
| 708 | */ | |
| 709 | const turnPromptId = (sessionId: string, attemptId: string) => | |
| 710 | SessionMessage.ID.make(`msg_t3_turn_${sessionId}:${attemptId}`); | |
| 711 | const steerPromptId = (sessionId: string, messageId: string) => | |
| 712 | SessionMessage.ID.make(`msg_t3_steer_${sessionId}:${messageId}`); | |
| 713 | ||
| 714 | /** | |
| 715 | * The user message a turn prompted with. Turns from before T3 chose prompt ids | |
| 716 | * recorded `<session>:attempt:<id>`, which is no message. | |
| 717 | */ | |
| 718 | const promptOf = (turn: OrchestrationV2ProviderTurn) => { | |
| 719 | const nativeId = turn.nativeTurnRef?.nativeId; | |
| 720 | return nativeId?.startsWith("msg_") === true ? nativeId : undefined; | |
| 721 | }; | |
| 722 | ||
| 723 | /** | |
| 724 | * Where fork and rollback cut to keep the session's turns up to `kept` (all of | |
| 725 | * them before the first turn when undefined): before the message that starts | |
| 726 | * the next turn that reached OpenCode, which is in `prompts`. That is a turn's | |
| 727 | * prompt, or for a continuation the report it answers. A turn refused before | |
| 728 | * it prompted is not in the session and is passed over, and so is a | |
| 729 | * continuation that took no report. `null` means nothing follows, so there is | |
| 730 | * no cut; a later turn from before T3 chose prompt ids has no known message, | |
| 731 | * so no cut is safe. | |
| 732 | */ | |
| 733 | const boundaryAfter = ( | |
| 734 | turns: ReadonlyArray<OrchestrationV2ProviderTurn>, | |
| 735 | providerThreadId: OrchestrationV2ProviderThread["id"], | |
| 736 | kept: OrchestrationV2ProviderTurn | undefined, | |
| 737 | prompts: ReadonlySet<string>, | |
| 738 | ) => { | |
| 739 | const later = turns | |
| 740 | .filter( | |
| 741 | (turn) => | |
| 742 | turn.providerThreadId === providerThreadId && | |
| 743 | (kept === undefined || turn.ordinal > kept.ordinal), | |
| 744 | ) | |
| 745 | .toSorted((left, right) => left.ordinal - right.ordinal); | |
| 746 | if (later.some((turn) => promptOf(turn) === undefined && !isWakeTurn(turn))) { | |
| 747 | return Effect.fail( | |
| 748 | new ProviderAdapter.ProviderAdapterProtocolError({ | |
| 749 | driver: OPENCODE_PROVIDER, | |
| 750 | detail: | |
| 751 | "This OpenCode conversation has turns from an earlier T3 Code version, so it can't be cut there.", | |
| 752 | }), | |
| 753 | ); | |
| 754 | } | |
| 755 | return Effect.succeed( | |
| 756 | later.map(promptOf).find((messageId) => messageId !== undefined && prompts.has(messageId)) ?? | |
| 757 | null, | |
| 758 | ); | |
| 759 | }; | |
| 760 | ||
| 761 | // Errors already in the adapter channel keep their tag; only lower-level ones are wrapped. | |
| 762 | const isProviderAdapterError = Schema.is(ProviderAdapter.ProviderAdapterV2Error); | |
| 763 | ||
| 764 | /** | |
| 765 | * The model OpenCode should run for a `provider/model` slug and its reasoning | |
| 766 | * variant, or undefined for any other slug: sending none would run OpenCode's | |
| 767 | * default while T3 records the requested model. | |
| 768 | */ | |
| 769 | const modelRef = (selection: ProviderAdapter.ProviderAdapterV2TurnInput["modelSelection"]) => { | |
| 770 | const parsed = parseOpenCodeModelSlug(selection.model); | |
| 771 | if (parsed === null) return undefined; | |
| 772 | const variant = getModelSelectionStringOptionValue(selection, "variant"); | |
| 773 | return Model.Ref.make({ | |
| 774 | providerID: Provider.ID.make(parsed.providerID), | |
| 775 | id: Model.ID.make(parsed.modelID), | |
| 776 | ...(variant === undefined ? {} : { variant: Model.VariantID.make(variant) }), | |
| 777 | }); | |
| 778 | }; | |
| 779 | const malformedModel = (model: string) => | |
| 780 | `OpenCode model '${model}' must use provider/model format`; | |
| 781 | const sameModel = (left: ModelRef, right: ModelRef | undefined) => | |
| 782 | left.providerID === right?.providerID && | |
| 783 | left.id === right?.id && | |
| 784 | (left.variant ?? "default") === (right?.variant ?? "default"); | |
| 785 | ||
| 786 | /** OpenCode's own agents for T3's interaction modes; plan mode is its read-only `plan` agent. */ | |
| 787 | const agentFor = (input: ProviderAdapter.ProviderAdapterV2TurnInput) => | |
| 788 | input.runtimePolicy.interactionMode === "plan" ? "plan" : "build"; | |
| 789 | ||
| 790 | /** `/name args` naming one of the workspace's commands, which OpenCode expands itself. */ | |
| 791 | const commandOf = (text: string) => { | |
| 792 | const match = /^\/([^\s/]+)(?:\s+([\s\S]*))?$/.exec(text.trim()); | |
| 793 | return match === null ? undefined : { name: match[1]!, text: match[2] ?? "" }; | |
| 794 | }; | |
| 795 | ||
| 796 | /** Whether a prompt names any skill at all, before the directory's skills are read. */ | |
| 797 | const SKILL_MENTION = new RegExp(SKILL_MENTION_PATTERN.source, "u"); | |
| 798 | ||
| 799 | /** The workspace skills a prompt names with the composer's `$skill` tokens. */ | |
| 800 | const skillsNamed = (text: string, known: ReadonlySet<string>) => [ | |
| 801 | ...new Set( | |
| 802 | [...text.matchAll(SKILL_MENTION_PATTERN)].flatMap((match) => | |
| 803 | known.has(match[2] ?? "") ? [match[2]!] : [], | |
| 804 | ), | |
| 805 | ), | |
| 806 | ]; | |
| 807 | ||
| 808 | /** | |
| 809 | * The turn's own tokens: steps add up, and the last step's input is the live | |
| 810 | * context size. A subagent's tokens are its own session's, never these. | |
| 811 | */ | |
| 812 | const turnTokenUsage = (turn: ActiveTurn, status: OrchestrationV2ProviderTurn["status"]) => | |
| 813 | turn.steps === 0 | |
| 814 | ? { | |
| 815 | usageScope: "main_agent" as const, | |
| 816 | usageStatus: "unavailable" as const, | |
| 817 | hasSubagents: turn.usedSubagents, | |
| 818 | } | |
| 819 | : { | |
| 820 | usageScope: "main_agent" as const, | |
| 821 | usageStatus: status === "completed" ? ("complete" as const) : ("partial" as const), | |
| 822 | inputTokens: turn.usage.input, | |
| 823 | cachedInputTokens: turn.usage.cached, | |
| 824 | cacheCreationTokens: turn.usage.cacheWrite, | |
| 825 | outputTokens: turn.usage.output, | |
| 826 | reasoningTokens: turn.usage.reasoning, | |
| 827 | hasSubagents: turn.usedSubagents, | |
| 828 | }; | |
| 829 | ||
| 830 | /** | |
| 831 | * The adapter for one provider instance. It talks to the instance's | |
| 832 | * {@link OpenCode2Server.OpenCode2Server}, which the driver builds from the instance's settings. | |
| 833 | */ | |
| 834 | export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: ProviderInstanceId) { | |
| 835 | const server = yield* OpenCode2Server.OpenCode2Server; | |
| 836 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | |
| 837 | const serverConfig = yield* ServerConfig.ServerConfig; | |
| 838 | const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests; | |
| 839 | const crypto = yield* Crypto.Crypto; | |
| 840 | const driver = OPENCODE_PROVIDER; | |
| 841 | const mcpServerNameFor = (threadId: string) => | |
| 842 | t3McpServerName(threadId).pipe(Effect.provideService(Crypto.Crypto, crypto)); | |
| 843 | ||
| 844 | /** | |
| 845 | * Lends the instance's server to a session until its scope closes. A spawned | |
| 846 | * server that died is started again on the next borrow. | |
| 847 | */ | |
| 848 | const borrow = Effect.gen(function* () { | |
| 849 | const lent = yield* Deferred.make<OpenCode2Server.OpenCode2Connection, OpenCodeRuntimeError>(); | |
| 850 | yield* server | |
| 851 | .withConnection((connection) => | |
| 852 | Deferred.succeed(lent, connection).pipe(Effect.andThen(Effect.never)), | |
| 853 | ) | |
| 854 | .pipe( | |
| 855 | Effect.catch((error) => Deferred.fail(lent, error)), | |
| 856 | Effect.forkScoped, | |
| 857 | ); | |
| 858 | return yield* Deferred.await(lent); | |
| 859 | }); | |
| 860 | ||
| 861 | const openSession = Effect.fn("OpenCode2Adapter.openSession")(function* ( | |
| 862 | input: Parameters<ProviderAdapter.ProviderAdapterV2Shape["openSession"]>[0], | |
| 863 | initial: { | |
| 864 | readonly connection: OpenCode2Server.OpenCode2Connection; | |
| 865 | readonly scope: Scope.Closeable; | |
| 866 | }, | |
| 867 | ) { | |
| 868 | let connection = initial.connection; | |
| 869 | // Replaced when the session reconnects to a restarted server. | |
| 870 | let client = connection.client; | |
| 871 | const sessionScope = yield* Effect.scope; | |
| 872 | // Context windows by directory, then `provider/model`: a project's own | |
| 873 | // OpenCode config can change a model's limits, and this one runtime serves | |
| 874 | // the instance's threads in every directory. | |
| 875 | const contextWindows = new Map<string, Map<string, number>>(); | |
| 876 | /** A thread without a worktree runs where T3 does, as its session is created. */ | |
| 877 | const directoryOf = (cwd: string | null | undefined) => cwd ?? serverConfig.cwd; | |
| 878 | const windowOf = (cwd: string | null | undefined, model: string) => | |
| 879 | contextWindows.get(directoryOf(cwd))?.get(model); | |
| 880 | const now = yield* DateTime.now; | |
| 881 | let session: OrchestrationV2ProviderSession = { | |
| 882 | id: input.providerSessionId, | |
| 883 | driver, | |
| 884 | providerInstanceId: instanceId, | |
| 885 | status: "ready", | |
| 886 | cwd: input.runtimePolicy.cwd ?? serverConfig.cwd, | |
| 887 | model: input.modelSelection.model, | |
| 888 | capabilities: OpenCode2ProviderCapabilities, | |
| 889 | createdAt: now, | |
| 890 | updatedAt: now, | |
| 891 | lastError: null, | |
| 892 | }; | |
| 893 | const events = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event, Cause.Done>(); | |
| 894 | // Thread sessions, and the sessions of their subagents. | |
| 895 | const threads = new Map<string, ThreadState>(); | |
| 896 | // A subagent's session, by its id, to the thread whose session started it. | |
| 897 | const childOwners = new Map<string, ThreadState>(); | |
| 898 | // What OpenCode announced about a subagent's session, until its call names it. | |
| 899 | const announced = new Map<string, EventOf<"session.created">["data"]>(); | |
| 900 | // Sessions of these threads with an execution running, seen on the stream. | |
| 901 | const busy = new Set<string>(); | |
| 902 | const pending = new Map<RuntimeRequestId, PendingRequest>(); | |
| 903 | // Events and a wake's replay into its turn are handled one at a time, in order. | |
| 904 | const lock = yield* Semaphore.make(1); | |
| 905 | // Sessions whose `revert.clear` has not had its empty execution yet. | |
| 906 | const clearing = new Map<string, Deferred.Deferred<void>>(); | |
| 907 | // Sessions a failed rollback may have left with a staged revert. | |
| 908 | const stagedReverts = new Set<string>(); | |
| 909 | // Starting a turn and cutting the history take turns on a session: each | |
| 910 | // checks that the other is not running before its own requests yield. | |
| 911 | const sessionGates = yield* KeyedLock.make<string>(); | |
| 912 | const exclusive = | |
| 913 | (providerThread: OrchestrationV2ProviderThread) => | |
| 914 | <A, E, R>(effect: Effect.Effect<A, E, R>) => { | |
| 915 | const sessionId = providerThread.nativeThreadRef?.nativeId; | |
| 916 | if (sessionId == null) return effect; | |
| 917 | return sessionGates.withLock(sessionId, effect); | |
| 918 | }; | |
| 919 | const emit = (event: ProviderAdapter.ProviderAdapterV2Event) => | |
| 920 | Queue.offer(events, event).pipe(Effect.asVoid); | |
| 921 | const ownerOf = (sessionId: string) => childOwners.get(sessionId) ?? threads.get(sessionId); | |
| 922 | ||
| 923 | const newThreadState = ( | |
| 924 | sessionId: string, | |
| 925 | providerThread: OrchestrationV2ProviderThread, | |
| 926 | directory: string, | |
| 927 | subagent: ThreadState["subagent"], | |
| 928 | ): ThreadState => ({ | |
| 929 | sessionId, | |
| 930 | providerThread, | |
| 931 | providerTurns: new Map(), | |
| 932 | active: undefined, | |
| 933 | model: undefined, | |
| 934 | unsettled: false, | |
| 935 | directory, | |
| 936 | agent: "build", | |
| 937 | rules: undefined, | |
| 938 | policy: input.runtimePolicy, | |
| 939 | grants: [], | |
| 940 | calls: new Map(), | |
| 941 | subagent, | |
| 942 | wakes: [], | |
| 943 | reports: new Map(), | |
| 944 | stoppedChildren: new Set(), | |
| 945 | strandedSteers: new Set(), | |
| 946 | mcp: undefined, | |
| 947 | instructions: undefined, | |
| 948 | }); | |
| 949 | ||
| 950 | /** The thread whose session started this one, through any nesting. */ | |
| 951 | const rootOf = (state: ThreadState): ThreadState => { | |
| 952 | let current = state; | |
| 953 | while (current.subagent !== undefined) current = current.subagent.call.state; | |
| 954 | return current; | |
| 955 | }; | |
| 956 | ||
| 957 | /** A thread's session and its subagents' sessions, through any nesting. */ | |
| 958 | const sessionsOf = (thread: ThreadState): ReadonlyArray<ThreadState> => [ | |
| 959 | thread, | |
| 960 | ...[...threads.values()].filter((state) => { | |
| 961 | for (let above = state.subagent?.call.state; above; above = above.subagent?.call.state) { | |
| 962 | if (above === thread) return true; | |
| 963 | } | |
| 964 | return false; | |
| 965 | }), | |
| 966 | ]; | |
| 967 | ||
| 968 | /** | |
| 969 | * A thread's `subagent` calls still running, its subagents' included: a | |
| 970 | * subagent's background call runs on after the call that made it ended. | |
| 971 | */ | |
| 972 | const runningCalls = (thread: ThreadState): ReadonlyArray<SubagentCall> => | |
| 973 | sessionsOf(thread).flatMap((state) => [...state.calls.values()]); | |
| 974 | ||
| 975 | /** The `subagent` calls that lead to a session, from its own up to the thread's. */ | |
| 976 | const callsAbove = (sessionId: string) => { | |
| 977 | const calls: Array<SubagentCall> = []; | |
| 978 | let current = threads.get(sessionId); | |
| 979 | while (current?.subagent !== undefined) { | |
| 980 | calls.push(current.subagent.call); | |
| 981 | current = current.subagent.call.state; | |
| 982 | } | |
| 983 | return calls; | |
| 984 | }; | |
| 985 | ||
| 986 | /** | |
| 987 | * The turn a request from `sessionId` is shown under. A background | |
| 988 | * subagent's goes to the turn that started it, whose run waits on the | |
| 989 | * subagent; anything else goes to the thread's running turn. | |
| 990 | */ | |
| 991 | const requestTurn = (sessionId: string) => { | |
| 992 | const state = ownerOf(sessionId); | |
| 993 | if (state === undefined) return undefined; | |
| 994 | const background = callsAbove(sessionId).findLast((call) => call.background); | |
| 995 | if (background !== undefined) { | |
| 996 | return isOrchestrationV2WorkActive(background.status) | |
| 997 | ? { state, turn: background.turn } | |
| 998 | : undefined; | |
| 999 | } | |
| 1000 | return state.active === undefined ? undefined : { state, turn: state.active }; | |
| 1001 | }; | |
| 1002 | ||
| 1003 | /** | |
| 1004 | * What a session still owes its thread once the thread's turn ended: | |
| 1005 | * background subagents, held executions, reports OpenCode queued for the | |
| 1006 | * follow-up it will start, and anything a subagent's session still runs, | |
| 1007 | * such as its own follow-up to such a report. | |
| 1008 | */ | |
| 1009 | const owesWork = (state: ThreadState) => | |
| 1010 | state.wakes.length > 0 || | |
| 1011 | state.reports.size > 0 || | |
| 1012 | (state.subagent !== undefined && busy.has(state.sessionId)) || | |
| 1013 | [...state.calls.values()].some((call) => call.background); | |
| 1014 | ||
| 1015 | /** Work that outlives the thread's turn, on its own session or its subagents'. */ | |
| 1016 | const hasBackground = (thread: ThreadState) => sessionsOf(thread).some(owesWork); | |
| 1017 | ||
| 1018 | const setSessionStatus = ( | |
| 1019 | status: OrchestrationV2ProviderSession["status"], | |
| 1020 | lastError: string | null, | |
| 1021 | ) => | |
| 1022 | Effect.gen(function* () { | |
| 1023 | session = { ...session, status, lastError, updatedAt: yield* DateTime.now }; | |
| 1024 | yield* emit({ type: "provider_session.updated", driver, providerSession: session }); | |
| 1025 | }); | |
| 1026 | ||
| 1027 | const ordinalOf = (turn: ActiveTurn, nativeId: string) => { | |
| 1028 | const known = turn.ordinals.get(nativeId); | |
| 1029 | if (known !== undefined) return known; | |
| 1030 | const next = turn.nextOrdinal++; | |
| 1031 | turn.ordinals.set(nativeId, next); | |
| 1032 | return next; | |
| 1033 | }; | |
| 1034 | ||
| 1035 | const itemBase = ( | |
| 1036 | state: ThreadState, | |
| 1037 | turn: ActiveTurn, | |
| 1038 | nativeId: string, | |
| 1039 | status: OrchestrationV2TurnItem["status"], | |
| 1040 | startedAt: DateTime.Utc, | |
| 1041 | completedAt: DateTime.Utc | null, | |
| 1042 | updatedAt: DateTime.Utc, | |
| 1043 | ) => ({ | |
| 1044 | id: idAllocator.derive.turnItemFromProviderItem({ driver, nativeItemId: nativeId }), | |
| 1045 | threadId: turn.input.threadId, | |
| 1046 | runId: turn.input.runId, | |
| 1047 | nodeId: idAllocator.derive.nodeFromProviderItem({ driver, nativeItemId: nativeId }), | |
| 1048 | providerThreadId: state.providerThread.id, | |
| 1049 | providerTurnId: turn.providerTurn.id, | |
| 1050 | nativeItemRef: ref(nativeId), | |
| 1051 | parentItemId: null, | |
| 1052 | ordinal: ordinalOf(turn, nativeId), | |
| 1053 | status, | |
| 1054 | title: null, | |
| 1055 | startedAt, | |
| 1056 | completedAt, | |
| 1057 | updatedAt, | |
| 1058 | }); | |
| 1059 | ||
| 1060 | const emitNode = ( | |
| 1061 | state: ThreadState, | |
| 1062 | turn: ActiveTurn, | |
| 1063 | nativeId: string, | |
| 1064 | kind: OrchestrationV2ExecutionNode["kind"], | |
| 1065 | status: OrchestrationV2ExecutionNode["status"], | |
| 1066 | startedAt: DateTime.Utc, | |
| 1067 | completedAt: DateTime.Utc | null, | |
| 1068 | ) => | |
| 1069 | emit({ | |
| 1070 | type: "node.updated", | |
| 1071 | driver, | |
| 1072 | node: { | |
| 1073 | id: idAllocator.derive.nodeFromProviderItem({ driver, nativeItemId: nativeId }), | |
| 1074 | threadId: turn.input.threadId, | |
| 1075 | runId: turn.input.runId, | |
| 1076 | parentNodeId: turn.input.rootNodeId, | |
| 1077 | rootNodeId: turn.input.rootNodeId, | |
| 1078 | kind, | |
| 1079 | status, | |
| 1080 | countsForRun: false, | |
| 1081 | providerThreadId: state.providerThread.id, | |
| 1082 | providerTurnId: turn.providerTurn.id, | |
| 1083 | nativeItemRef: ref(nativeId), | |
| 1084 | runtimeRequestId: null, | |
| 1085 | checkpointScopeId: null, | |
| 1086 | startedAt, | |
| 1087 | completedAt, | |
| 1088 | }, | |
| 1089 | }); | |
| 1090 | ||
| 1091 | /** One text or reasoning block, re-emitted with its accumulated text on every change. */ | |
| 1092 | const emitText = Effect.fnUntraced(function* ( | |
| 1093 | state: ThreadState, | |
| 1094 | turn: ActiveTurn, | |
| 1095 | data: { | |
| 1096 | readonly assistantMessageID: string; | |
| 1097 | readonly ordinal: number; | |
| 1098 | readonly text?: string; | |
| 1099 | }, | |
| 1100 | kind: "text" | "reasoning", | |
| 1101 | update: (current: string) => string, | |
| 1102 | completed = "text" in data, | |
| 1103 | ) { | |
| 1104 | const nativeId = `${data.assistantMessageID}:${kind}:${data.ordinal}`; | |
| 1105 | const updatedAt = yield* DateTime.now; | |
| 1106 | const entry = turn.texts.get(nativeId) ?? { | |
| 1107 | block: data, | |
| 1108 | kind, | |
| 1109 | startedAt: updatedAt, | |
| 1110 | text: "", | |
| 1111 | }; | |
| 1112 | entry.text = update(entry.text); | |
| 1113 | if (completed) turn.texts.delete(nativeId); | |
| 1114 | else turn.texts.set(nativeId, entry); | |
| 1115 | if (entry.text.length === 0) return; | |
| 1116 | const status = completed ? "completed" : "running"; | |
| 1117 | const completedAt = completed ? updatedAt : null; | |
| 1118 | const nodeKind = kind === "text" ? "assistant_message" : "reasoning"; | |
| 1119 | yield* emitNode(state, turn, nativeId, nodeKind, status, entry.startedAt, completedAt); | |
| 1120 | const base = itemBase(state, turn, nativeId, status, entry.startedAt, completedAt, updatedAt); | |
| 1121 | if (kind === "reasoning") { | |
| 1122 | yield* emit({ | |
| 1123 | type: "turn_item.updated", | |
| 1124 | driver, | |
| 1125 | turnItem: { ...base, type: "reasoning", text: entry.text, streaming: !completed }, | |
| 1126 | }); | |
| 1127 | return; | |
| 1128 | } | |
| 1129 | const messageId = idAllocator.derive.messageFromProviderItem({ | |
| 1130 | driver, | |
| 1131 | nativeItemId: nativeId, | |
| 1132 | }); | |
| 1133 | const message: OrchestrationV2ConversationMessage = { | |
| 1134 | createdBy: "agent", | |
| 1135 | creationSource: "provider", | |
| 1136 | id: messageId, | |
| 1137 | threadId: turn.input.threadId, | |
| 1138 | runId: turn.input.runId, | |
| 1139 | nodeId: base.nodeId, | |
| 1140 | role: "assistant", | |
| 1141 | text: entry.text, | |
| 1142 | attachments: [], | |
| 1143 | streaming: !completed, | |
| 1144 | createdAt: entry.startedAt, | |
| 1145 | updatedAt, | |
| 1146 | }; | |
| 1147 | yield* emit({ type: "message.updated", driver, message }); | |
| 1148 | yield* emit({ | |
| 1149 | type: "turn_item.updated", | |
| 1150 | driver, | |
| 1151 | turnItem: { | |
| 1152 | ...base, | |
| 1153 | type: "assistant_message", | |
| 1154 | messageId, | |
| 1155 | text: entry.text, | |
| 1156 | streaming: !completed, | |
| 1157 | }, | |
| 1158 | }); | |
| 1159 | }); | |
| 1160 | ||
| 1161 | const emitTool = Effect.fnUntraced(function* ( | |
| 1162 | state: ThreadState, | |
| 1163 | turn: ActiveTurn, | |
| 1164 | id: string, | |
| 1165 | status: "running" | "completed" | "failed" | "interrupted", | |
| 1166 | result?: { readonly output: string | undefined; readonly metadata: unknown }, | |
| 1167 | ) { | |
| 1168 | const tool = turn.tools.get(id); | |
| 1169 | if (tool === undefined) return; | |
| 1170 | const updatedAt = yield* DateTime.now; | |
| 1171 | const startedAt = turn.startedAt.get(id) ?? updatedAt; | |
| 1172 | const completedAt = status === "running" ? null : updatedAt; | |
| 1173 | const nativeId = `${turn.scope}${id}`; | |
| 1174 | yield* emitNode(state, turn, nativeId, "tool_call", status, startedAt, completedAt); | |
| 1175 | yield* emit({ | |
| 1176 | type: "turn_item.updated", | |
| 1177 | driver, | |
| 1178 | turnItem: openCodeToolTurnItem( | |
| 1179 | itemBase(state, turn, nativeId, status, startedAt, completedAt, updatedAt), | |
| 1180 | { | |
| 1181 | name: tool.name, | |
| 1182 | input: tool.input, | |
| 1183 | output: result?.output, | |
| 1184 | completedMetadata: status === "completed" ? result?.metadata : undefined, | |
| 1185 | }, | |
| 1186 | ), | |
| 1187 | }); | |
| 1188 | }); | |
| 1189 | ||
| 1190 | /** The turn's compaction item; its summary is the text OpenCode carries forward. */ | |
| 1191 | const emitCompaction = Effect.fnUntraced(function* ( | |
| 1192 | state: ThreadState, | |
| 1193 | turn: ActiveTurn, | |
| 1194 | status: "running" | "completed" | "failed" | "interrupted", | |
| 1195 | summary?: string, | |
| 1196 | ) { | |
| 1197 | const compaction = turn.compaction; | |
| 1198 | if (compaction === undefined) return; | |
| 1199 | const updatedAt = yield* DateTime.now; | |
| 1200 | const completedAt = status === "running" ? null : updatedAt; | |
| 1201 | yield* emit({ | |
| 1202 | type: "turn_item.updated", | |
| 1203 | driver, | |
| 1204 | turnItem: { | |
| 1205 | ...itemBase( | |
| 1206 | state, | |
| 1207 | turn, | |
| 1208 | compaction.nativeId, | |
| 1209 | status, | |
| 1210 | compaction.startedAt, | |
| 1211 | completedAt, | |
| 1212 | updatedAt, | |
| 1213 | ), | |
| 1214 | nodeId: turn.input.rootNodeId, | |
| 1215 | type: "compaction", | |
| 1216 | driver, | |
| 1217 | title: | |
| 1218 | status === "running" | |
| 1219 | ? "Compacting context" | |
| 1220 | : status === "completed" | |
| 1221 | ? "Context compacted" | |
| 1222 | : status === "failed" | |
| 1223 | ? "Compaction failed" | |
| 1224 | : "Compaction interrupted", | |
| 1225 | ...(summary === undefined || summary.length === 0 ? {} : { summary }), | |
| 1226 | }, | |
| 1227 | }); | |
| 1228 | if (status !== "running") turn.compaction = undefined; | |
| 1229 | }); | |
| 1230 | ||
| 1231 | const emitProviderTurn = ( | |
| 1232 | state: ThreadState, | |
| 1233 | turn: ActiveTurn, | |
| 1234 | providerTurn: OrchestrationV2ProviderTurn, | |
| 1235 | ) => { | |
| 1236 | state.providerTurns.set(String(providerTurn.id), providerTurn); | |
| 1237 | return emit({ | |
| 1238 | type: "provider_turn.updated", | |
| 1239 | driver, | |
| 1240 | threadId: turn.input.threadId, | |
| 1241 | providerTurn, | |
| 1242 | }); | |
| 1243 | }; | |
| 1244 | ||
| 1245 | const makeTurn = ( | |
| 1246 | owner: TurnOwner, | |
| 1247 | providerTurn: OrchestrationV2ProviderTurn, | |
| 1248 | options: { readonly scope: string; readonly awaitingStart: boolean }, | |
| 1249 | ): ActiveTurn => ({ | |
| 1250 | input: owner, | |
| 1251 | providerTurn, | |
| 1252 | texts: new Map(), | |
| 1253 | tools: new Map(), | |
| 1254 | startedAt: new Map(), | |
| 1255 | ordinals: new Map(), | |
| 1256 | nextOrdinal: providerTurn.ordinal * 100 + 1, | |
| 1257 | usage: { input: 0, cached: 0, cacheWrite: 0, output: 0, reasoning: 0 }, | |
| 1258 | steps: 0, | |
| 1259 | lastStep: undefined, | |
| 1260 | before: undefined, | |
| 1261 | compaction: undefined, | |
| 1262 | compactions: 0, | |
| 1263 | interrupted: false, | |
| 1264 | unsent: false, | |
| 1265 | steers: new Set(), | |
| 1266 | settledInbox: new Set(), | |
| 1267 | heldEnd: undefined, | |
| 1268 | awaitingStart: options.awaitingStart, | |
| 1269 | scope: options.scope, | |
| 1270 | usedSubagents: false, | |
| 1271 | }); | |
| 1272 | ||
| 1273 | /** A subagent call as its parent turn's item, node and subagent row. */ | |
| 1274 | const emitSubagent = Effect.fnUntraced(function* (call: SubagentCall) { | |
| 1275 | const updatedAt = yield* DateTime.now; | |
| 1276 | const { turn, state } = call; | |
| 1277 | const nodeId = idAllocator.derive.nodeFromProviderItem({ | |
| 1278 | driver, | |
| 1279 | nativeItemId: call.nativeId, | |
| 1280 | }); | |
| 1281 | const completedAt = isOrchestrationV2WorkActive(call.status) | |
| 1282 | ? null | |
| 1283 | : (call.completedAt ?? updatedAt); | |
| 1284 | const childProviderThreadId = call.child?.providerThread.id ?? null; | |
| 1285 | const childThreadId = call.child?.subagent?.appThread.id ?? null; | |
| 1286 | yield* emit({ | |
| 1287 | type: "node.updated", | |
| 1288 | driver, | |
| 1289 | node: { | |
| 1290 | id: nodeId, | |
| 1291 | threadId: turn.input.threadId, | |
| 1292 | runId: turn.input.runId, | |
| 1293 | parentNodeId: turn.input.rootNodeId, | |
| 1294 | rootNodeId: turn.input.rootNodeId, | |
| 1295 | kind: "subagent", | |
| 1296 | status: call.status, | |
| 1297 | countsForRun: false, | |
| 1298 | providerThreadId: childProviderThreadId ?? state.providerThread.id, | |
| 1299 | providerTurnId: turn.providerTurn.id, | |
| 1300 | nativeItemRef: ref(call.nativeId), | |
| 1301 | runtimeRequestId: null, | |
| 1302 | checkpointScopeId: null, | |
| 1303 | startedAt: call.startedAt, | |
| 1304 | completedAt, | |
| 1305 | }, | |
| 1306 | }); | |
| 1307 | yield* emit({ | |
| 1308 | type: "subagent.updated", | |
| 1309 | driver, | |
| 1310 | subagent: { | |
| 1311 | id: nodeId, | |
| 1312 | threadId: turn.input.threadId, | |
| 1313 | runId: turn.input.runId, | |
| 1314 | parentNodeId: turn.input.rootNodeId, | |
| 1315 | origin: "provider_native", | |
| 1316 | createdBy: "agent", | |
| 1317 | driver, | |
| 1318 | providerInstanceId: instanceId, | |
| 1319 | providerThreadId: childProviderThreadId, | |
| 1320 | childThreadId, | |
| 1321 | nativeTaskRef: ref(call.nativeId), | |
| 1322 | prompt: call.prompt, | |
| 1323 | title: call.title, | |
| 1324 | model: call.model, | |
| 1325 | status: call.status, | |
| 1326 | result: call.result, | |
| 1327 | startedAt: call.startedAt, | |
| 1328 | completedAt, | |
| 1329 | updatedAt, | |
| 1330 | }, | |
| 1331 | }); | |
| 1332 | yield* emit({ | |
| 1333 | type: "turn_item.updated", | |
| 1334 | driver, | |
| 1335 | turnItem: { | |
| 1336 | id: idAllocator.derive.turnItemFromProviderItem({ driver, nativeItemId: call.nativeId }), | |
| 1337 | threadId: turn.input.threadId, | |
| 1338 | runId: turn.input.runId, | |
| 1339 | nodeId, | |
| 1340 | providerThreadId: state.providerThread.id, | |
| 1341 | providerTurnId: turn.providerTurn.id, | |
| 1342 | nativeItemRef: ref(call.nativeId), | |
| 1343 | parentItemId: null, | |
| 1344 | ordinal: call.ordinal, | |
| 1345 | status: call.status, | |
| 1346 | title: call.title, | |
| 1347 | startedAt: call.startedAt, | |
| 1348 | completedAt, | |
| 1349 | updatedAt, | |
| 1350 | type: "subagent", | |
| 1351 | subagentId: nodeId, | |
| 1352 | origin: "provider_native", | |
| 1353 | driver, | |
| 1354 | providerInstanceId: instanceId, | |
| 1355 | childThreadId, | |
| 1356 | prompt: call.prompt, | |
| 1357 | result: call.result, | |
| 1358 | }, | |
| 1359 | }); | |
| 1360 | yield* syncRoster(rootOf(call.state)); | |
| 1361 | }); | |
| 1362 | ||
| 1363 | /** | |
| 1364 | * Lists a thread's background subagents on its provider thread, as typed | |
| 1365 | * `subagent` work: the thread shows it waits on them after its turn ends, | |
| 1366 | * and a Stop reaches them. Emitted only when the list changes. | |
| 1367 | */ | |
| 1368 | const syncRoster = Effect.fnUntraced(function* (state: ThreadState) { | |
| 1369 | const roster = runningCalls(state) | |
| 1370 | .filter((call) => call.background) | |
| 1371 | .map((call) => ({ | |
| 1372 | taskId: call.nativeId, | |
| 1373 | kind: "subagent" as const, | |
| 1374 | ...(call.title?.trim() ? { description: call.title.trim() } : {}), | |
| 1375 | ...(call.child?.subagent === undefined | |
| 1376 | ? {} | |
| 1377 | : { childThreadId: call.child.subagent.appThread.id }), | |
| 1378 | })); | |
| 1379 | const current = state.providerThread.pendingBackgroundTasks ?? []; | |
| 1380 | if ( | |
| 1381 | current.length === roster.length && | |
| 1382 | current.every( | |
| 1383 | (task, index) => | |
| 1384 | task.taskId === roster[index]?.taskId && | |
| 1385 | task.description === roster[index]?.description && | |
| 1386 | (task.kind === "subagent" ? task.childThreadId : undefined) === | |
| 1387 | roster[index]?.childThreadId, | |
| 1388 | ) | |
| 1389 | ) { | |
| 1390 | return; | |
| 1391 | } | |
| 1392 | state.providerThread = { | |
| 1393 | ...state.providerThread, | |
| 1394 | pendingBackgroundTasks: roster, | |
| 1395 | updatedAt: yield* DateTime.now, | |
| 1396 | }; | |
| 1397 | yield* emit({ | |
| 1398 | type: "provider_thread.updated", | |
| 1399 | driver, | |
| 1400 | providerThread: state.providerThread, | |
| 1401 | }); | |
| 1402 | }); | |
| 1403 | ||
| 1404 | const withReportedModel = ( | |
| 1405 | providerThread: OrchestrationV2ProviderThread, | |
| 1406 | model: ModelRef | undefined, | |
| 1407 | ): OrchestrationV2ProviderThread => { | |
| 1408 | if (model === undefined) return providerThread; | |
| 1409 | const modelSelection: ModelSelection = { | |
| 1410 | instanceId, | |
| 1411 | model: `${model.providerID}/${model.id}`, | |
| 1412 | options: model.variant === undefined ? [] : [{ id: "variant", value: model.variant }], | |
| 1413 | }; | |
| 1414 | if ( | |
| 1415 | providerThread.nativeMetadata?.modelSelection !== undefined && | |
| 1416 | modelSelectionsEqual(providerThread.nativeMetadata.modelSelection, modelSelection) | |
| 1417 | ) | |
| 1418 | return providerThread; | |
| 1419 | return { | |
| 1420 | ...providerThread, | |
| 1421 | nativeMetadata: { ...providerThread.nativeMetadata, modelSelection }, | |
| 1422 | }; | |
| 1423 | }; | |
| 1424 | ||
| 1425 | /** | |
| 1426 | * Gives a subagent call its session once both are known: OpenCode names | |
| 1427 | * the session on the call's progress, and announces it just before. The | |
| 1428 | * session becomes a child thread under the call's item. | |
| 1429 | */ | |
| 1430 | const attachChild = Effect.fnUntraced(function* (call: SubagentCall, childId: string) { | |
| 1431 | if (call.child !== undefined) return; | |
| 1432 | const info = announced.get(childId); | |
| 1433 | announced.delete(childId); | |
| 1434 | const now = yield* DateTime.now; | |
| 1435 | const nodeId = idAllocator.derive.nodeFromProviderItem({ | |
| 1436 | driver, | |
| 1437 | nativeItemId: call.nativeId, | |
| 1438 | }); | |
| 1439 | const childThreadId = idAllocator.derive.threadFromProviderThread({ | |
| 1440 | driver, | |
| 1441 | nativeThreadId: childId, | |
| 1442 | providerInstanceId: instanceId, | |
| 1443 | }); | |
| 1444 | const providerThreadId = idAllocator.derive.providerThread({ | |
| 1445 | driver, | |
| 1446 | nativeThreadId: childId, | |
| 1447 | providerInstanceId: instanceId, | |
| 1448 | }); | |
| 1449 | if (info?.model !== undefined) call.model = `${info.model.providerID}/${info.model.id}`; | |
| 1450 | call.title = call.title ?? info?.title ?? null; | |
| 1451 | const parentThread = call.turn.input.appThread; | |
| 1452 | const appThread = makeSubagentChildThread({ | |
| 1453 | parentThread, | |
| 1454 | childThreadId, | |
| 1455 | parentNodeId: nodeId, | |
| 1456 | activeProviderThreadId: providerThreadId, | |
| 1457 | providerInstanceId: instanceId, | |
| 1458 | modelSelection: { | |
| 1459 | instanceId, | |
| 1460 | model: call.model ?? call.turn.input.modelSelection.model, | |
| 1461 | }, | |
| 1462 | title: subagentThreadTitle({ | |
| 1463 | parentTitle: parentThread.title, | |
| 1464 | title: call.title, | |
| 1465 | prompt: call.prompt, | |
| 1466 | ordinal: call.ordinal, | |
| 1467 | }), | |
| 1468 | now, | |
| 1469 | createdBy: "agent", | |
| 1470 | creationSource: "provider", | |
| 1471 | }); | |
| 1472 | const providerThread: OrchestrationV2ProviderThread = { | |
| 1473 | id: providerThreadId, | |
| 1474 | driver, | |
| 1475 | providerInstanceId: instanceId, | |
| 1476 | providerSessionId: input.providerSessionId, | |
| 1477 | appThreadId: childThreadId, | |
| 1478 | ownerNodeId: nodeId, | |
| 1479 | nativeThreadRef: ref(childId), | |
| 1480 | nativeConversationHeadRef: null, | |
| 1481 | status: "active", | |
| 1482 | firstRunOrdinal: null, | |
| 1483 | lastRunOrdinal: null, | |
| 1484 | handoffIds: [], | |
| 1485 | forkedFrom: null, | |
| 1486 | createdAt: now, | |
| 1487 | updatedAt: now, | |
| 1488 | }; | |
| 1489 | // A subagent called again keeps its session's state: how many turns it | |
| 1490 | // ran (each one's native id is its own), its grants and rules, and the | |
| 1491 | // background work an earlier call left running, which Stop must reach. | |
| 1492 | const previous = threads.get(childId); | |
| 1493 | const subagent = { | |
| 1494 | call, | |
| 1495 | appThread, | |
| 1496 | turns: previous?.subagent?.turns ?? 0, | |
| 1497 | prompt: call.prompt, | |
| 1498 | queued: undefined, | |
| 1499 | }; | |
| 1500 | const child = | |
| 1501 | previous ?? newThreadState(childId, providerThread, call.state.directory, subagent); | |
| 1502 | child.subagent = subagent; | |
| 1503 | child.model = info?.model ?? child.model; | |
| 1504 | child.providerThread = withReportedModel(providerThread, child.model); | |
| 1505 | child.directory = call.state.directory; | |
| 1506 | child.agent = info?.agent ?? call.agent ?? child.agent; | |
| 1507 | // OpenCode gives a new session its parent's rules, which are the thread's. | |
| 1508 | child.rules = info?.permissions ?? child.rules; | |
| 1509 | threads.set(childId, child); | |
| 1510 | childOwners.set(childId, rootOf(call.state)); | |
| 1511 | call.child = child; | |
| 1512 | yield* emit({ type: "app_thread.created", driver, appThread }); | |
| 1513 | yield* emit({ | |
| 1514 | type: "provider_thread.updated", | |
| 1515 | driver, | |
| 1516 | providerThread: child.providerThread, | |
| 1517 | }); | |
| 1518 | yield* emitSubagent(call); | |
| 1519 | // A session called again was not made now, so it may hold the rules of | |
| 1520 | // a mode the thread has left. OpenCode applies a rules change to the | |
| 1521 | // asks after it, so it gets the thread's as soon as its call names it. | |
| 1522 | if (info === undefined) { | |
| 1523 | yield* writeRules(child, rootOf(call.state).policy).pipe( | |
| 1524 | Effect.timeout(REQUEST_REPLY_TIMEOUT), | |
| 1525 | Effect.ignore({ log: true }), | |
| 1526 | ); | |
| 1527 | } | |
| 1528 | }); | |
| 1529 | ||
| 1530 | /** | |
| 1531 | * Ends a subagent call and every call under it that is still running. A | |
| 1532 | * background one outlives the call that made it, as it outlives a turn, | |
| 1533 | * unless that call failed. | |
| 1534 | */ | |
| 1535 | const settleCall: ( | |
| 1536 | call: SubagentCall, | |
| 1537 | status: OrchestrationV2Subagent["status"], | |
| 1538 | result?: string, | |
| 1539 | ) => Effect.Effect<void> = Effect.fnUntraced(function* (call, status, result) { | |
| 1540 | if (!isOrchestrationV2WorkActive(call.status)) return; | |
| 1541 | call.status = status; | |
| 1542 | call.completedAt = yield* DateTime.now; | |
| 1543 | if (result !== undefined) call.result = result; | |
| 1544 | call.state.calls.delete(call.toolId); | |
| 1545 | const child = call.child; | |
| 1546 | if (child !== undefined) { | |
| 1547 | // A snapshot: settling a call removes it from the map. | |
| 1548 | for (const nested of Array.from(child.calls.values())) { | |
| 1549 | if (nested.background && status !== "failed") continue; | |
| 1550 | yield* settleCall(nested, status === "completed" ? "interrupted" : status); | |
| 1551 | } | |
| 1552 | if (child.active !== undefined && status !== "completed") { | |
| 1553 | yield* finishTurn(child, { status: "interrupted" }); | |
| 1554 | } | |
| 1555 | child.providerThread = { | |
| 1556 | ...child.providerThread, | |
| 1557 | status: "idle", | |
| 1558 | updatedAt: call.completedAt, | |
| 1559 | }; | |
| 1560 | yield* emit({ | |
| 1561 | type: "provider_thread.updated", | |
| 1562 | driver, | |
| 1563 | providerThread: child.providerThread, | |
| 1564 | }); | |
| 1565 | } | |
| 1566 | yield* emitSubagent(call); | |
| 1567 | }); | |
| 1568 | ||
| 1569 | /** | |
| 1570 | * Opens the runless turn a subagent's session runs each execution in, on | |
| 1571 | * its child thread, with the prompt it answers as the turn's user message. | |
| 1572 | */ | |
| 1573 | const startChildTurn = Effect.fnUntraced(function* (child: ThreadState) { | |
| 1574 | const subagent = child.subagent; | |
| 1575 | if (subagent === undefined || child.active !== undefined) return; | |
| 1576 | subagent.turns += 1; | |
| 1577 | const startedAt = yield* DateTime.now; | |
| 1578 | const nativeTurnId = `${child.sessionId}:turn:${subagent.turns}`; | |
| 1579 | const rootNodeId = idAllocator.derive.nodeFromProviderItem({ | |
| 1580 | driver, | |
| 1581 | nativeItemId: `${nativeTurnId}:root`, | |
| 1582 | }); | |
| 1583 | const providerTurn: OrchestrationV2ProviderTurn = { | |
| 1584 | id: idAllocator.derive.providerTurn({ driver, nativeTurnId }), | |
| 1585 | providerThreadId: child.providerThread.id, | |
| 1586 | nodeId: rootNodeId, | |
| 1587 | runAttemptId: null, | |
| 1588 | nativeTurnRef: ref(nativeTurnId, "weak"), | |
| 1589 | ordinal: subagent.turns, | |
| 1590 | status: "running", | |
| 1591 | startedAt, | |
| 1592 | completedAt: null, | |
| 1593 | }; | |
| 1594 | const turn = makeTurn( | |
| 1595 | { | |
| 1596 | appThread: subagent.appThread, | |
| 1597 | threadId: subagent.appThread.id, | |
| 1598 | runId: null, | |
| 1599 | runOrdinal: subagent.call.turn.input.runOrdinal, | |
| 1600 | rootNodeId, | |
| 1601 | modelSelection: subagent.appThread.modelSelection, | |
| 1602 | runtimePolicy: subagent.call.turn.input.runtimePolicy, | |
| 1603 | }, | |
| 1604 | providerTurn, | |
| 1605 | { scope: `${child.sessionId}:`, awaitingStart: false }, | |
| 1606 | ); | |
| 1607 | turn.before = subagent.queued; | |
| 1608 | subagent.queued = undefined; | |
| 1609 | child.active = turn; | |
| 1610 | yield* emit({ | |
| 1611 | type: "node.updated", | |
| 1612 | driver, | |
| 1613 | node: { | |
| 1614 | id: rootNodeId, | |
| 1615 | threadId: turn.input.threadId, | |
| 1616 | runId: null, | |
| 1617 | parentNodeId: null, | |
| 1618 | rootNodeId, | |
| 1619 | kind: "root_turn", | |
| 1620 | status: "running", | |
| 1621 | countsForRun: false, | |
| 1622 | providerThreadId: child.providerThread.id, | |
| 1623 | providerTurnId: providerTurn.id, | |
| 1624 | nativeItemRef: ref(nativeTurnId, "weak"), | |
| 1625 | runtimeRequestId: null, | |
| 1626 | checkpointScopeId: null, | |
| 1627 | startedAt, | |
| 1628 | completedAt: null, | |
| 1629 | }, | |
| 1630 | }); | |
| 1631 | yield* emitProviderTurn(child, turn, providerTurn); | |
| 1632 | const prompt = subagent.prompt; | |
| 1633 | subagent.prompt = undefined; | |
| 1634 | if (prompt !== undefined && prompt.length > 0) { | |
| 1635 | const nativeId = `${nativeTurnId}:prompt`; | |
| 1636 | const artifacts = makeSubagentConversationArtifacts({ | |
| 1637 | messageId: idAllocator.derive.messageFromProviderItem({ driver, nativeItemId: nativeId }), | |
| 1638 | senderThreadId: subagent.call.turn.input.threadId, | |
| 1639 | turnItemId: idAllocator.derive.turnItemFromProviderItem({ | |
| 1640 | driver, | |
| 1641 | nativeItemId: nativeId, | |
| 1642 | }), | |
| 1643 | threadId: turn.input.threadId, | |
| 1644 | rootNodeId, | |
| 1645 | providerThreadId: child.providerThread.id, | |
| 1646 | providerTurnId: providerTurn.id, | |
| 1647 | nativeItemRef: ref(nativeId, "weak"), | |
| 1648 | role: "user", | |
| 1649 | text: prompt, | |
| 1650 | ordinal: ordinalOf(turn, nativeId), | |
| 1651 | now: startedAt, | |
| 1652 | }); | |
| 1653 | yield* emit({ type: "message.updated", driver, message: artifacts.message }); | |
| 1654 | yield* emit({ type: "turn_item.updated", driver, turnItem: artifacts.turnItem }); | |
| 1655 | } | |
| 1656 | }); | |
| 1657 | ||
| 1658 | /** | |
| 1659 | * Asks the orchestrator for the continuation turn a held wake needs. It | |
| 1660 | * names the subagents whose end woke the thread. A wake a Stop or an | |
| 1661 | * earlier turn already took needs no turn, so its offer is dropped. | |
| 1662 | */ | |
| 1663 | const offerWake = Effect.fnUntraced(function* (state: ThreadState, wake: Wake) { | |
| 1664 | const route = state.providerThread.appThreadId; | |
| 1665 | if (route === null) return; | |
| 1666 | const notification = backgroundWorkNotification(wake.reports); | |
| 1667 | yield* continuationRequests.offer({ | |
| 1668 | threadId: route, | |
| 1669 | providerThreadId: state.providerThread.id, | |
| 1670 | driver, | |
| 1671 | detail: wake.detail, | |
| 1672 | ...(notification === null ? {} : { notification }), | |
| 1673 | dispatchIfCurrent: (dispatch) => | |
| 1674 | wake.dropped ? Effect.succeed(Option.none()) : Effect.map(dispatch, Option.some), | |
| 1675 | }); | |
| 1676 | }); | |
| 1677 | ||
| 1678 | const finishTurn = Effect.fnUntraced(function* ( | |
| 1679 | state: ThreadState, | |
| 1680 | terminal: TurnTerminal, | |
| 1681 | threadDisposition: "reusable" | "broken" = "reusable", | |
| 1682 | ) { | |
| 1683 | const turn = state.active; | |
| 1684 | if (turn === undefined) return; | |
| 1685 | state.active = undefined; | |
| 1686 | for (const inboxID of turn.steers) state.strandedSteers.add(inboxID); | |
| 1687 | // OpenCode drops a request when the asking session's execution ends. A | |
| 1688 | // subagent's request shown on this turn ends with it too, unless the | |
| 1689 | // subagent runs in the background past a turn that did not fail. | |
| 1690 | for (const entry of pending.values()) { | |
| 1691 | const background = callsAbove(entry.sessionId).some((call) => call.background); | |
| 1692 | if ( | |
| 1693 | entry.sessionId === state.sessionId || | |
| 1694 | (entry.turn === turn && !(background && terminal.status !== "failed")) | |
| 1695 | ) { | |
| 1696 | yield* settleRequest(entry, "cancelled"); | |
| 1697 | } | |
| 1698 | } | |
| 1699 | const completedAt = yield* DateTime.now; | |
| 1700 | // Blocks still open when the execution ends are final as they stand. | |
| 1701 | for (const open of turn.texts.values()) { | |
| 1702 | yield* emitText(state, turn, open.block, open.kind, (text) => text, true); | |
| 1703 | } | |
| 1704 | for (const id of turn.tools.keys()) { | |
| 1705 | yield* emitTool( | |
| 1706 | state, | |
| 1707 | turn, | |
| 1708 | id, | |
| 1709 | terminal.status === "completed" ? "completed" : "interrupted", | |
| 1710 | ); | |
| 1711 | } | |
| 1712 | // A foreground subagent ends with the turn that waits on it. A background | |
| 1713 | // one outlives a finished or interrupted turn (a user Stop has already | |
| 1714 | // stopped it), and a failed turn stops it. | |
| 1715 | // A snapshot: settling a call removes it from the map. | |
| 1716 | for (const call of Array.from(state.calls.values())) { | |
| 1717 | if (call.turn !== turn) continue; | |
| 1718 | if (call.background && terminal.status !== "failed") continue; | |
| 1719 | yield* settleCall(call, terminal.status === "completed" ? "completed" : terminal.status); | |
| 1720 | } | |
| 1721 | if (state.subagent !== undefined) { | |
| 1722 | yield* emit({ | |
| 1723 | type: "node.updated", | |
| 1724 | driver, | |
| 1725 | node: { | |
| 1726 | id: turn.input.rootNodeId, | |
| 1727 | threadId: turn.input.threadId, | |
| 1728 | runId: null, | |
| 1729 | parentNodeId: null, | |
| 1730 | rootNodeId: turn.input.rootNodeId, | |
| 1731 | kind: "root_turn", | |
| 1732 | status: terminal.status, | |
| 1733 | countsForRun: false, | |
| 1734 | providerThreadId: state.providerThread.id, | |
| 1735 | providerTurnId: turn.providerTurn.id, | |
| 1736 | nativeItemRef: turn.providerTurn.nativeTurnRef, | |
| 1737 | runtimeRequestId: null, | |
| 1738 | checkpointScopeId: null, | |
| 1739 | startedAt: turn.providerTurn.startedAt, | |
| 1740 | completedAt, | |
| 1741 | }, | |
| 1742 | }); | |
| 1743 | } | |
| 1744 | yield* emitCompaction(state, turn, terminal.status === "failed" ? "failed" : "interrupted"); | |
| 1745 | const window = windowOf(turn.input.runtimePolicy.cwd, turn.input.modelSelection.model); | |
| 1746 | const lastStep = turn.lastStep; | |
| 1747 | yield* emitProviderTurn(state, turn, { | |
| 1748 | ...turn.providerTurn, | |
| 1749 | status: terminal.status, | |
| 1750 | completedAt, | |
| 1751 | turnTokenUsage: turnTokenUsage(turn, terminal.status), | |
| 1752 | ...(lastStep === undefined | |
| 1753 | ? {} | |
| 1754 | : { | |
| 1755 | tokenUsage: { | |
| 1756 | usedTokens: | |
| 1757 | lastStep.input + lastStep.cache.read + lastStep.cache.write + lastStep.output, | |
| 1758 | maxTokens: window ?? null, | |
| 1759 | inputTokens: lastStep.input + lastStep.cache.read + lastStep.cache.write, | |
| 1760 | cachedInputTokens: lastStep.cache.read, | |
| 1761 | outputTokens: lastStep.output, | |
| 1762 | reasoningOutputTokens: lastStep.reasoning, | |
| 1763 | updatedAt: DateTime.formatIso(completedAt), | |
| 1764 | }, | |
| 1765 | }), | |
| 1766 | }); | |
| 1767 | // A subagent's session is its call's to settle: a turn there is runless, | |
| 1768 | // and T3 has no terminal to wait on. | |
| 1769 | if (state.subagent !== undefined) { | |
| 1770 | const call = state.subagent.call; | |
| 1771 | if (!call.background && terminal.status !== "completed") { | |
| 1772 | yield* settleCall(call, terminal.status); | |
| 1773 | } | |
| 1774 | return; | |
| 1775 | } | |
| 1776 | state.providerThread = { | |
| 1777 | ...state.providerThread, | |
| 1778 | status: threadDisposition === "broken" ? "error" : "idle", | |
| 1779 | updatedAt: completedAt, | |
| 1780 | }; | |
| 1781 | yield* emit({ | |
| 1782 | type: "provider_thread.updated", | |
| 1783 | driver, | |
| 1784 | providerThread: state.providerThread, | |
| 1785 | }); | |
| 1786 | const anyActive = [...threads.values()].some( | |
| 1787 | (candidate) => candidate.subagent === undefined && candidate.active !== undefined, | |
| 1788 | ); | |
| 1789 | yield* setSessionStatus(pending.size > 0 ? "waiting" : anyActive ? "running" : "ready", null); | |
| 1790 | const base = { | |
| 1791 | type: "turn.terminal" as const, | |
| 1792 | driver, | |
| 1793 | // The provider thread the turn started on. After a native fork the | |
| 1794 | // session's own thread has a fresh id, and the terminal must name the | |
| 1795 | // thread the start was recorded against. | |
| 1796 | providerThreadId: turn.providerTurn.providerThreadId, | |
| 1797 | providerTurnId: turn.providerTurn.id, | |
| 1798 | runOrdinal: turn.input.runOrdinal, | |
| 1799 | threadDisposition, | |
| 1800 | }; | |
| 1801 | yield* emit( | |
| 1802 | terminal.status === "failed" | |
| 1803 | ? { | |
| 1804 | ...base, | |
| 1805 | status: "failed", | |
| 1806 | failure: terminal.failure, | |
| 1807 | failureItemOrdinal: ordinalOf(turn, `terminal-failure:${turn.providerTurn.id}`), | |
| 1808 | } | |
| 1809 | : { ...base, status: terminal.status, failure: null }, | |
| 1810 | ); | |
| 1811 | // A spawned server lists its models lazily, so a window still unknown is | |
| 1812 | // read again for the next turn, off this stream so it never delays one. | |
| 1813 | if (window === undefined) { | |
| 1814 | yield* Effect.forkIn(readModels(directoryOf(turn.input.runtimePolicy.cwd)), sessionScope); | |
| 1815 | } | |
| 1816 | }); | |
| 1817 | ||
| 1818 | /** | |
| 1819 | * Ends the turn a request T3 could not answer would block, and stops the | |
| 1820 | * session: OpenCode waits on an unanswered request forever. The stopped | |
| 1821 | * run's end is its own, not the next turn's. | |
| 1822 | */ | |
| 1823 | const abandonRequest = Effect.fnUntraced(function* (state: ThreadState, reason: string) { | |
| 1824 | yield* Effect.logWarning("Could not answer an OpenCode request; ending its turn.", { | |
| 1825 | reason, | |
| 1826 | }); | |
| 1827 | if (state.active === undefined) return; | |
| 1828 | state.unsettled = true; | |
| 1829 | yield* finishTurn(state, { | |
| 1830 | status: "failed", | |
| 1831 | failure: makeProviderFailure({ | |
| 1832 | message: "OpenCode is waiting on a request T3 Code couldn't answer.", | |
| 1833 | class: "provider_error", | |
| 1834 | }), | |
| 1835 | }); | |
| 1836 | yield* client.session | |
| 1837 | .interrupt({ sessionID: Session.ID.make(state.sessionId) }) | |
| 1838 | .pipe(Effect.timeout("2 seconds"), Effect.ignore({ log: true })); | |
| 1839 | }); | |
| 1840 | ||
| 1841 | /** Stops tracking a request; the session is waiting only while any remain. */ | |
| 1842 | const forgetRequest = Effect.fnUntraced(function* (entry: PendingRequest) { | |
| 1843 | if (!pending.delete(entry.request.id)) return false; | |
| 1844 | if (pending.size === 0 && session.status === "waiting") { | |
| 1845 | yield* setSessionStatus("running", null); | |
| 1846 | } | |
| 1847 | return true; | |
| 1848 | }); | |
| 1849 | ||
| 1850 | /** | |
| 1851 | * Settles a request OpenCode answered or dropped on its own. T3's own | |
| 1852 | * answers are only forgotten: the orchestrator already recorded them. | |
| 1853 | */ | |
| 1854 | const settleRequest = Effect.fnUntraced(function* ( | |
| 1855 | entry: PendingRequest, | |
| 1856 | status: "resolved" | "cancelled", | |
| 1857 | ) { | |
| 1858 | if (!(yield* forgetRequest(entry)) || entry.answering) return; | |
| 1859 | const now = yield* DateTime.now; | |
| 1860 | const itemStatus = status === "resolved" ? "completed" : "cancelled"; | |
| 1861 | yield* emit({ | |
| 1862 | type: "runtime_request.updated", | |
| 1863 | driver, | |
| 1864 | threadId: entry.turn.input.threadId, | |
| 1865 | runtimeRequest: { ...entry.request, status, resolvedAt: now }, | |
| 1866 | }); | |
| 1867 | yield* emit({ | |
| 1868 | type: "node.updated", | |
| 1869 | driver, | |
| 1870 | node: { ...entry.node, status: itemStatus, completedAt: now }, | |
| 1871 | }); | |
| 1872 | yield* emit({ | |
| 1873 | type: "turn_item.updated", | |
| 1874 | driver, | |
| 1875 | turnItem: { ...entry.item, status: itemStatus, completedAt: now, updatedAt: now }, | |
| 1876 | }); | |
| 1877 | }); | |
| 1878 | ||
| 1879 | /** | |
| 1880 | * Stops a run no turn of T3's waits on (one a Stop left running, or one | |
| 1881 | * found asking after a reconnect): a reject without a message and a | |
| 1882 | * cancelled form both end OpenCode's execution. Its end is not a turn's. | |
| 1883 | */ | |
| 1884 | const stopStaleRequest = ( | |
| 1885 | sessionId: string, | |
| 1886 | native: { readonly type: "permission" | "form"; readonly id: string }, | |
| 1887 | ): Effect.Effect<void> => | |
| 1888 | native.type === "permission" | |
| 1889 | ? client.permission | |
| 1890 | .reply({ | |
| 1891 | sessionID: Session.ID.make(sessionId), | |
| 1892 | requestID: Permission.ID.make(native.id), | |
| 1893 | decision: "reject", | |
| 1894 | }) | |
| 1895 | .pipe( | |
| 1896 | Effect.catchTags(permissionGone), | |
| 1897 | Effect.timeout(REQUEST_REPLY_TIMEOUT), | |
| 1898 | Effect.ignore({ log: true }), | |
| 1899 | ) | |
| 1900 | : client.session.form | |
| 1901 | .cancel({ sessionID: sessionId, formID: Form.ID.make(native.id) }) | |
| 1902 | .pipe( | |
| 1903 | Effect.catchTags(formGone), | |
| 1904 | Effect.timeout(REQUEST_REPLY_TIMEOUT), | |
| 1905 | Effect.ignore({ log: true }), | |
| 1906 | ); | |
| 1907 | ||
| 1908 | /** | |
| 1909 | * Stops what a session still waits on when this runtime first loads it: | |
| 1910 | * T3 shows none of those requests (a restart or a closed session expired | |
| 1911 | * them), and OpenCode would wait on them forever. | |
| 1912 | */ | |
| 1913 | const stopLeftoverRequests = Effect.fnUntraced(function* (state: ThreadState) { | |
| 1914 | const sessionID = Session.ID.make(state.sessionId); | |
| 1915 | const listed = yield* Effect.all([ | |
| 1916 | client.permission.list({ sessionID }), | |
| 1917 | client.session.form.list({ sessionID }), | |
| 1918 | ]).pipe(Effect.timeout(ACTIVE_CHECK_TIMEOUT), Effect.option); | |
| 1919 | if (listed._tag === "None") { | |
| 1920 | return yield* Effect.logWarning("Could not list an OpenCode session's open requests."); | |
| 1921 | } | |
| 1922 | const [permissions, forms] = listed.value; | |
| 1923 | if (permissions.length === 0 && forms.length === 0) return; | |
| 1924 | // The stopped run's end is not the next turn's. | |
| 1925 | state.unsettled = true; | |
| 1926 | for (const request of permissions) { | |
| 1927 | yield* stopStaleRequest(state.sessionId, { type: "permission", id: request.id }); | |
| 1928 | } | |
| 1929 | for (const form of forms) { | |
| 1930 | yield* stopStaleRequest(state.sessionId, { type: "form", id: form.id }); | |
| 1931 | } | |
| 1932 | }); | |
| 1933 | ||
| 1934 | /** | |
| 1935 | * Shows a permission ask or question form on the thread whose session (or | |
| 1936 | * subagent session) asked, under that thread's running turn. A request no | |
| 1937 | * turn of T3's is waiting on is left for OpenCode's own clients. | |
| 1938 | */ | |
| 1939 | const showRequest = Effect.fnUntraced(function* ( | |
| 1940 | sessionId: string, | |
| 1941 | native: PendingRequest["native"], | |
| 1942 | body: | |
| 1943 | | { | |
| 1944 | readonly type: "approval_request"; | |
| 1945 | readonly requestKind: Extract< | |
| 1946 | OrchestrationV2TurnItem, | |
| 1947 | { type: "approval_request" } | |
| 1948 | >["requestKind"]; | |
| 1949 | readonly prompt: string; | |
| 1950 | readonly options: Extract< | |
| 1951 | OrchestrationV2TurnItem, | |
| 1952 | { type: "approval_request" } | |
| 1953 | >["options"]; | |
| 1954 | } | |
| 1955 | | { | |
| 1956 | readonly type: "user_input_request"; | |
| 1957 | readonly questions: ReadonlyArray<OrchestrationV2UserInputQuestion>; | |
| 1958 | }, | |
| 1959 | ) { | |
| 1960 | const target = requestTurn(sessionId); | |
| 1961 | if (target === undefined) return; | |
| 1962 | const { state, turn } = target; | |
| 1963 | if ([...pending.values()].some((entry) => entry.native.id === native.id)) return; | |
| 1964 | const now = yield* DateTime.now; | |
| 1965 | const requestId = yield* idAllocator.allocate.runtimeRequest({ | |
| 1966 | driver, | |
| 1967 | providerTurnId: turn.providerTurn.id, | |
| 1968 | nativeRequestId: native.id, | |
| 1969 | }); | |
| 1970 | const nodeId = idAllocator.derive.approvalNode({ requestId }); | |
| 1971 | const request: OrchestrationV2RuntimeRequest = { | |
| 1972 | id: requestId, | |
| 1973 | nodeId, | |
| 1974 | providerTurnId: turn.providerTurn.id, | |
| 1975 | nativeRequestRef: ref(native.id), | |
| 1976 | kind: body.type === "approval_request" ? body.requestKind : "user_input", | |
| 1977 | status: "pending", | |
| 1978 | responseCapability: { type: "live", providerSessionId: input.providerSessionId }, | |
| 1979 | createdAt: now, | |
| 1980 | resolvedAt: null, | |
| 1981 | }; | |
| 1982 | const node: OrchestrationV2ExecutionNode = { | |
| 1983 | id: nodeId, | |
| 1984 | threadId: turn.input.threadId, | |
| 1985 | runId: turn.input.runId, | |
| 1986 | parentNodeId: turn.input.rootNodeId, | |
| 1987 | rootNodeId: turn.input.rootNodeId, | |
| 1988 | kind: body.type, | |
| 1989 | status: "waiting", | |
| 1990 | countsForRun: false, | |
| 1991 | providerThreadId: state.providerThread.id, | |
| 1992 | providerTurnId: turn.providerTurn.id, | |
| 1993 | nativeItemRef: ref(native.id), | |
| 1994 | runtimeRequestId: requestId, | |
| 1995 | checkpointScopeId: null, | |
| 1996 | startedAt: now, | |
| 1997 | completedAt: null, | |
| 1998 | }; | |
| 1999 | const base = { | |
| 2000 | id: idAllocator.derive.approvalTurnItem({ requestId }), | |
| 2001 | threadId: turn.input.threadId, | |
| 2002 | runId: turn.input.runId, | |
| 2003 | nodeId, | |
| 2004 | providerThreadId: state.providerThread.id, | |
| 2005 | providerTurnId: turn.providerTurn.id, | |
| 2006 | nativeItemRef: ref(native.id), | |
| 2007 | parentItemId: null, | |
| 2008 | ordinal: ordinalOf(turn, native.id), | |
| 2009 | status: "waiting" as const, | |
| 2010 | startedAt: now, | |
| 2011 | completedAt: null, | |
| 2012 | updatedAt: now, | |
| 2013 | }; | |
| 2014 | const item: OrchestrationV2TurnItem = | |
| 2015 | body.type === "approval_request" | |
| 2016 | ? { | |
| 2017 | ...base, | |
| 2018 | title: native.type === "permission" ? native.action : null, | |
| 2019 | type: "approval_request", | |
| 2020 | requestId, | |
| 2021 | requestKind: body.requestKind, | |
| 2022 | prompt: body.prompt, | |
| 2023 | ...(body.options === undefined ? {} : { options: body.options }), | |
| 2024 | } | |
| 2025 | : { | |
| 2026 | ...base, | |
| 2027 | title: "User input", | |
| 2028 | type: "user_input_request", | |
| 2029 | requestId, | |
| 2030 | questions: body.questions, | |
| 2031 | }; | |
| 2032 | pending.set(requestId, { | |
| 2033 | request, | |
| 2034 | item, | |
| 2035 | node, | |
| 2036 | state, | |
| 2037 | turn, | |
| 2038 | sessionId, | |
| 2039 | answering: false, | |
| 2040 | native, | |
| 2041 | }); | |
| 2042 | yield* emit({ type: "node.updated", driver, node }); | |
| 2043 | yield* emit({ | |
| 2044 | type: "runtime_request.updated", | |
| 2045 | driver, | |
| 2046 | threadId: turn.input.threadId, | |
| 2047 | runtimeRequest: request, | |
| 2048 | }); | |
| 2049 | yield* emit({ type: "turn_item.updated", driver, turnItem: item }); | |
| 2050 | yield* setSessionStatus("waiting", null); | |
| 2051 | }); | |
| 2052 | ||
| 2053 | const onPermissionAsked = Effect.fnUntraced(function* (data: Permission.Request) { | |
| 2054 | // The asking session's own turn knows the tool: a subagent's session runs its own. | |
| 2055 | const turn = threads.get(data.sessionID)?.active; | |
| 2056 | const toolName = | |
| 2057 | data.source === undefined ? undefined : turn?.tools.get(data.source.id)?.name; | |
| 2058 | const save = data.save ?? []; | |
| 2059 | yield* showRequest( | |
| 2060 | data.sessionID, | |
| 2061 | { type: "permission", id: data.id, action: data.action, resources: data.resources, save }, | |
| 2062 | { | |
| 2063 | type: "approval_request", | |
| 2064 | requestKind: openCodePermissionRequestKind(data.action, toolName), | |
| 2065 | prompt: data.resources.length === 0 ? data.action : data.resources.join("\n"), | |
| 2066 | // "Always" in OpenCode saves a grant for the whole project, so the | |
| 2067 | // session-wide choice is T3's own rule on this session instead. | |
| 2068 | options: [ | |
| 2069 | { decision: "cancel", label: "Cancel" }, | |
| 2070 | { decision: "decline", label: "Decline" }, | |
| 2071 | ...(save.length > 0 | |
| 2072 | ? [ | |
| 2073 | { | |
| 2074 | decision: "acceptForSession" as const, | |
| 2075 | label: sessionGrantLabel(data.action, save), | |
| 2076 | }, | |
| 2077 | ] | |
| 2078 | : []), | |
| 2079 | { decision: "accept", label: "Approve" }, | |
| 2080 | ], | |
| 2081 | }, | |
| 2082 | ); | |
| 2083 | }); | |
| 2084 | ||
| 2085 | const onFormCreated = Effect.fnUntraced(function* (form: NativeForm) { | |
| 2086 | const target = requestTurn(form.sessionID); | |
| 2087 | if (target === undefined) return; | |
| 2088 | const { state } = target; | |
| 2089 | const mapped = formQuestions(form); | |
| 2090 | if ("questions" in mapped) { | |
| 2091 | return yield* showRequest( | |
| 2092 | form.sessionID, | |
| 2093 | { type: "form", id: form.id, form }, | |
| 2094 | { type: "user_input_request", questions: mapped.questions }, | |
| 2095 | ); | |
| 2096 | } | |
| 2097 | // Cancelling ends OpenCode's execution as a user stop, so the turn is | |
| 2098 | // failed here with the reason and that stop's end is skipped. | |
| 2099 | yield* Effect.logWarning("Declined an OpenCode form T3 Code cannot show.", { | |
| 2100 | reason: mapped.unsupported, | |
| 2101 | }); | |
| 2102 | const cancelled = yield* deliver( | |
| 2103 | client.session.form.cancel({ sessionID: form.sessionID, formID: form.id }), | |
| 2104 | ); | |
| 2105 | // The session that asked is the one blocked on the form and stopped by | |
| 2106 | // its cancel: a subagent's own, whose parent reads its failed call and | |
| 2107 | // goes on. A subagent's session has no Stop end to skip. | |
| 2108 | const asker = threads.get(form.sessionID) ?? state; | |
| 2109 | if (!cancelled) return yield* abandonRequest(asker, "form cancel failed"); | |
| 2110 | if (asker.subagent === undefined) asker.unsettled = true; | |
| 2111 | yield* finishTurn(asker, { | |
| 2112 | status: "failed", | |
| 2113 | failure: makeProviderFailure({ | |
| 2114 | message: `OpenCode asked for ${mapped.unsupported}, which T3 Code can't show. The question was declined.`, | |
| 2115 | class: "provider_error", | |
| 2116 | }), | |
| 2117 | }); | |
| 2118 | }); | |
| 2119 | ||
| 2120 | /** | |
| 2121 | * A permission ask or question form, from the stream or read back after a | |
| 2122 | * reconnect: shown under the turn it blocks, or stopped when it is the run | |
| 2123 | * a Stop left behind, which nothing answers. | |
| 2124 | */ | |
| 2125 | const onAsked = Effect.fnUntraced(function* ( | |
| 2126 | asked: | |
| 2127 | | { readonly type: "permission"; readonly request: Permission.Request } | |
| 2128 | | { readonly type: "form"; readonly form: NativeForm }, | |
| 2129 | ) { | |
| 2130 | const asking = asked.type === "permission" ? asked.request.sessionID : asked.form.sessionID; | |
| 2131 | const state = ownerOf(asking); | |
| 2132 | if (state === undefined) return; | |
| 2133 | const target = requestTurn(asking); | |
| 2134 | if (target !== undefined && !target.turn.awaitingStart) { | |
| 2135 | if (asked.type === "permission") return yield* onPermissionAsked(asked.request); | |
| 2136 | return yield* onFormCreated(asked.form); | |
| 2137 | } | |
| 2138 | if (state.unsettled || state.active?.awaitingStart === true) { | |
| 2139 | yield* stopStaleRequest( | |
| 2140 | asking, | |
| 2141 | asked.type === "permission" | |
| 2142 | ? { type: "permission", id: asked.request.id } | |
| 2143 | : { type: "form", id: asked.form.id }, | |
| 2144 | ); | |
| 2145 | } | |
| 2146 | }); | |
| 2147 | ||
| 2148 | const sessionOfEvent = (event: OpenCode2StreamEvent) => | |
| 2149 | event.type === "unreadable.execution.ended" || event.type === "unreadable.execution.started" | |
| 2150 | ? event.sessionID | |
| 2151 | : "sessionID" in event.data && typeof event.data.sessionID === "string" | |
| 2152 | ? event.data.sessionID | |
| 2153 | : undefined; | |
| 2154 | ||
| 2155 | const executionEnd = (type: string) => | |
| 2156 | type === "session.execution.succeeded" || | |
| 2157 | type === "session.execution.failed" || | |
| 2158 | type === "session.execution.interrupted"; | |
| 2159 | ||
| 2160 | /** A `subagent` call's item, made when the model starts it. */ | |
| 2161 | const startCall = Effect.fnUntraced(function* ( | |
| 2162 | state: ThreadState, | |
| 2163 | turn: ActiveTurn, | |
| 2164 | toolId: string, | |
| 2165 | ) { | |
| 2166 | const nativeId = `${turn.scope}${toolId}`; | |
| 2167 | const call: SubagentCall = { | |
| 2168 | toolId, | |
| 2169 | nativeId, | |
| 2170 | state, | |
| 2171 | turn, | |
| 2172 | ordinal: ordinalOf(turn, nativeId), | |
| 2173 | startedAt: yield* DateTime.now, | |
| 2174 | prompt: "", | |
| 2175 | title: null, | |
| 2176 | agent: undefined, | |
| 2177 | model: null, | |
| 2178 | background: false, | |
| 2179 | child: undefined, | |
| 2180 | status: "running", | |
| 2181 | result: null, | |
| 2182 | completedAt: null, | |
| 2183 | }; | |
| 2184 | turn.usedSubagents = true; | |
| 2185 | state.calls.set(toolId, call); | |
| 2186 | yield* emitSubagent(call); | |
| 2187 | }); | |
| 2188 | ||
| 2189 | /** | |
| 2190 | * One event for a session's running turn: its text, tools, steps and end. | |
| 2191 | * A `subagent` call is its item and child thread rather than a tool. | |
| 2192 | */ | |
| 2193 | const onTurnEvent = Effect.fnUntraced(function* ( | |
| 2194 | state: ThreadState, | |
| 2195 | turn: ActiveTurn, | |
| 2196 | event: OpenCode2StreamEvent, | |
| 2197 | ) { | |
| 2198 | switch (event.type) { | |
| 2199 | case "session.text.started": | |
| 2200 | case "session.reasoning.started": | |
| 2201 | case "session.text.delta": | |
| 2202 | case "session.reasoning.delta": | |
| 2203 | case "session.text.ended": | |
| 2204 | case "session.reasoning.ended": { | |
| 2205 | const data = event.data; | |
| 2206 | const kind = event.type.startsWith("session.text.") ? "text" : "reasoning"; | |
| 2207 | return yield* emitText(state, turn, data, kind, (text) => | |
| 2208 | "delta" in data ? text + data.delta : "text" in data ? data.text : text, | |
| 2209 | ); | |
| 2210 | } | |
| 2211 | case "session.tool.input.started": | |
| 2212 | // Its form is the item the user answers; the tool call would repeat it. | |
| 2213 | if (event.data.name === "question") return; | |
| 2214 | if (event.data.name === "subagent") return yield* startCall(state, turn, event.data.id); | |
| 2215 | turn.tools.set(event.data.id, { name: event.data.name, input: {} }); | |
| 2216 | turn.startedAt.set(event.data.id, yield* DateTime.now); | |
| 2217 | return yield* emitTool(state, turn, event.data.id, "running"); | |
| 2218 | case "session.tool.called": { | |
| 2219 | const call = state.calls.get(event.data.id); | |
| 2220 | if (call !== undefined) { | |
| 2221 | const { input } = event.data; | |
| 2222 | call.prompt = stringField(input, "prompt") ?? call.prompt; | |
| 2223 | call.title = stringField(input, "description") ?? call.title; | |
| 2224 | call.agent = stringField(input, "agent"); | |
| 2225 | call.model = stringField(input, "model") ?? call.model; | |
| 2226 | call.background = input["background"] === true; | |
| 2227 | return yield* emitSubagent(call); | |
| 2228 | } | |
| 2229 | const tool = turn.tools.get(event.data.id); | |
| 2230 | if (tool !== undefined) tool.input = event.data.input; | |
| 2231 | return yield* emitTool(state, turn, event.data.id, "running"); | |
| 2232 | } | |
| 2233 | case "session.tool.progress": { | |
| 2234 | const call = state.calls.get(event.data.id); | |
| 2235 | const childId = stringField(event.data.metadata, "sessionID"); | |
| 2236 | if (call !== undefined && childId !== undefined) yield* attachChild(call, childId); | |
| 2237 | return; | |
| 2238 | } | |
| 2239 | case "session.tool.success": { | |
| 2240 | const call = state.calls.get(event.data.id); | |
| 2241 | if (call !== undefined) { | |
| 2242 | const childId = stringField(event.data.metadata, "sessionID"); | |
| 2243 | if (childId !== undefined) yield* attachChild(call, childId); | |
| 2244 | // A background call returns at launch; its report settles it. | |
| 2245 | if (event.data.metadata?.["status"] === "running") return; | |
| 2246 | return yield* settleCall(call, "completed", subagentOutput(textOf(event.data.content))); | |
| 2247 | } | |
| 2248 | const output = textOf(event.data.content); | |
| 2249 | yield* emitTool(state, turn, event.data.id, "completed", { | |
| 2250 | output, | |
| 2251 | metadata: event.data.metadata, | |
| 2252 | }); | |
| 2253 | turn.tools.delete(event.data.id); | |
| 2254 | return; | |
| 2255 | } | |
| 2256 | case "session.tool.failed": { | |
| 2257 | const aborted = event.data.error.type === "aborted"; | |
| 2258 | const call = state.calls.get(event.data.id); | |
| 2259 | // A refused call (unknown agent, the nesting limit) is a failed subagent. | |
| 2260 | if (call !== undefined) { | |
| 2261 | return yield* settleCall( | |
| 2262 | call, | |
| 2263 | aborted ? "interrupted" : "failed", | |
| 2264 | event.data.error.message, | |
| 2265 | ); | |
| 2266 | } | |
| 2267 | yield* emitTool(state, turn, event.data.id, aborted ? "interrupted" : "failed", { | |
| 2268 | output: event.data.error.message, | |
| 2269 | metadata: event.data.metadata, | |
| 2270 | }); | |
| 2271 | turn.tools.delete(event.data.id); | |
| 2272 | return; | |
| 2273 | } | |
| 2274 | // `/compact` and OpenCode's own compaction when the context fills | |
| 2275 | // (`reason: "auto"`) both run inside a turn's execution. | |
| 2276 | case "session.compaction.started": | |
| 2277 | turn.compaction = { | |
| 2278 | nativeId: `${turn.providerTurn.id}:compaction:${turn.compactions++}`, | |
| 2279 | startedAt: yield* DateTime.now, | |
| 2280 | }; | |
| 2281 | return yield* emitCompaction(state, turn, "running"); | |
| 2282 | case "session.compaction.ended": | |
| 2283 | return yield* emitCompaction(state, turn, "completed", event.data.text); | |
| 2284 | case "session.compaction.failed": | |
| 2285 | return yield* emitCompaction(state, turn, "failed"); | |
| 2286 | case "session.step.ended": | |
| 2287 | case "session.step.failed": { | |
| 2288 | const tokens = event.data.tokens; | |
| 2289 | if (tokens === undefined) return; | |
| 2290 | turn.steps += 1; | |
| 2291 | turn.lastStep = tokens; | |
| 2292 | turn.usage.input += tokens.input + tokens.cache.read + tokens.cache.write; | |
| 2293 | turn.usage.cached += tokens.cache.read; | |
| 2294 | turn.usage.cacheWrite += tokens.cache.write; | |
| 2295 | turn.usage.output += tokens.output + tokens.reasoning; | |
| 2296 | turn.usage.reasoning += tokens.reasoning; | |
| 2297 | return; | |
| 2298 | } | |
| 2299 | case "session.execution.started": | |
| 2300 | // The execution that delivers steers the last one ended without. | |
| 2301 | turn.heldEnd = undefined; | |
| 2302 | return; | |
| 2303 | case "session.execution.succeeded": | |
| 2304 | return yield* endExecution(state, turn, { | |
| 2305 | status: turn.interrupted ? "interrupted" : "completed", | |
| 2306 | }); | |
| 2307 | case "session.execution.interrupted": | |
| 2308 | return yield* endExecution(state, turn, { status: "interrupted" }); | |
| 2309 | case "session.execution.failed": | |
| 2310 | return yield* endExecution(state, turn, { | |
| 2311 | status: "failed", | |
| 2312 | failure: makeProviderFailure({ | |
| 2313 | message: event.data.error.message, | |
| 2314 | code: event.data.error.type, | |
| 2315 | class: "provider_error", | |
| 2316 | }), | |
| 2317 | }); | |
| 2318 | default: | |
| 2319 | return; | |
| 2320 | } | |
| 2321 | }); | |
| 2322 | ||
| 2323 | /** | |
| 2324 | * An execution OpenCode started on a thread's session with no turn of T3's | |
| 2325 | * running: the parent's answer to a background subagent's report. It is | |
| 2326 | * held for the continuation turn it asks for, or stopped when it only | |
| 2327 | * reports subagents a Stop ended. | |
| 2328 | */ | |
| 2329 | /** | |
| 2330 | * Takes the reports an execution OpenCode started on its own delivers. When | |
| 2331 | * they all report subagents a Stop ended, that execution only answers the | |
| 2332 | * Stop, so it is stopped and `stopped` is true. | |
| 2333 | */ | |
| 2334 | const takeReports = Effect.fnUntraced(function* (state: ThreadState) { | |
| 2335 | const delivered = [...state.reports.values()]; | |
| 2336 | state.reports.clear(); | |
| 2337 | const stopped = | |
| 2338 | delivered.length > 0 && | |
| 2339 | delivered.every((entry) => state.stoppedChildren.has(entry.childId)); | |
| 2340 | for (const entry of delivered) state.stoppedChildren.delete(entry.childId); | |
| 2341 | if (stopped) { | |
| 2342 | state.unsettled = true; | |
| 2343 | yield* client.session | |
| 2344 | .interrupt({ sessionID: Session.ID.make(state.sessionId) }) | |
| 2345 | .pipe(Effect.timeout(INTERRUPT_TIMEOUT), Effect.ignore({ log: true })); | |
| 2346 | } | |
| 2347 | return { delivered, stopped }; | |
| 2348 | }); | |
| 2349 | ||
| 2350 | const onWake = Effect.fnUntraced(function* (state: ThreadState) { | |
| 2351 | const { delivered, stopped } = yield* takeReports(state); | |
| 2352 | if (stopped) return; | |
| 2353 | const wake: Wake = { | |
| 2354 | events: [], | |
| 2355 | running: true, | |
| 2356 | reports: delivered.map((entry) => entry.report), | |
| 2357 | detail: delivered.length === 0 ? null : delivered.map((entry) => entry.text).join("\n\n"), | |
| 2358 | dropped: false, | |
| 2359 | after: delivered.at(-1)?.inboxId, | |
| 2360 | first: delivered[0]?.inboxId, | |
| 2361 | }; | |
| 2362 | state.wakes.push(wake); | |
| 2363 | yield* Effect.logInfo("OpenCode started a turn on its own; asking for a continuation.", { | |
| 2364 | providerThreadId: state.providerThread.id, | |
| 2365 | }); | |
| 2366 | yield* offerWake(state, wake); | |
| 2367 | }); | |
| 2368 | ||
| 2369 | /** A background subagent's end, as OpenCode queues it for its parent. */ | |
| 2370 | const onReport = Effect.fnUntraced(function* ( | |
| 2371 | state: ThreadState, | |
| 2372 | inboxId: string, | |
| 2373 | payload: { | |
| 2374 | readonly text: string; | |
| 2375 | readonly metadata?: Readonly<Record<string, unknown>> | undefined; | |
| 2376 | }, | |
| 2377 | ) { | |
| 2378 | const childId = stringField(payload.metadata, "childID"); | |
| 2379 | if (stringField(payload.metadata, "source") !== "subagent" || childId === undefined) return; | |
| 2380 | const call = [...state.calls.values()].find( | |
| 2381 | (candidate) => candidate.child?.sessionId === childId, | |
| 2382 | ); | |
| 2383 | const outcome = reportOutcome(stringField(payload.metadata, "state")); | |
| 2384 | state.reports.set(inboxId, { | |
| 2385 | inboxId, | |
| 2386 | childId, | |
| 2387 | text: payload.text, | |
| 2388 | report: { | |
| 2389 | kind: "subagent", | |
| 2390 | label: call?.title ?? stringField(payload.metadata, "description"), | |
| 2391 | childThreadId: call?.child?.subagent?.appThread.id, | |
| 2392 | outcome, | |
| 2393 | }, | |
| 2394 | }); | |
| 2395 | if (call === undefined) return; | |
| 2396 | yield* settleCall( | |
| 2397 | call, | |
| 2398 | state.stoppedChildren.has(childId) | |
| 2399 | ? "interrupted" | |
| 2400 | : outcome === "failed" | |
| 2401 | ? "failed" | |
| 2402 | : outcome === "cancelled" | |
| 2403 | ? "cancelled" | |
| 2404 | : "completed", | |
| 2405 | subagentOutput(payload.text), | |
| 2406 | ); | |
| 2407 | }); | |
| 2408 | ||
| 2409 | /** | |
| 2410 | * The thread whose held execution an event belongs to: the thread's own | |
| 2411 | * session, or a subagent session its held execution started. | |
| 2412 | */ | |
| 2413 | const holderOf = (sessionId: string) => { | |
| 2414 | const own = threads.get(sessionId); | |
| 2415 | if (own !== undefined) return own.wakes.at(-1)?.running === true ? own : undefined; | |
| 2416 | // A subagent a held execution started is named only when that execution replays. | |
| 2417 | const parent = announced.get(sessionId)?.parentID; | |
| 2418 | const holder = parent === undefined ? undefined : threads.get(parent); | |
| 2419 | return holder !== undefined && holder.active === undefined && holder.wakes.length > 0 | |
| 2420 | ? holder | |
| 2421 | : undefined; | |
| 2422 | }; | |
| 2423 | ||
| 2424 | /** | |
| 2425 | * Ends the turn with its execution, unless a steer sent into it is still | |
| 2426 | * undelivered: OpenCode then starts another execution for it, which the | |
| 2427 | * turn owns too. A Stop leaves steers undelivered and starts nothing. | |
| 2428 | */ | |
| 2429 | const endExecution = Effect.fnUntraced(function* ( | |
| 2430 | state: ThreadState, | |
| 2431 | turn: ActiveTurn, | |
| 2432 | end: TurnTerminal, | |
| 2433 | ) { | |
| 2434 | if (end.status === "completed" && turn.steers.size > 0) { | |
| 2435 | turn.heldEnd = end; | |
| 2436 | return; | |
| 2437 | } | |
| 2438 | yield* finishTurn(state, end); | |
| 2439 | }); | |
| 2440 | ||
| 2441 | /** Ends a turn held for its steers once none is left to deliver. */ | |
| 2442 | const endIfSettled = (state: ThreadState, turn: ActiveTurn) => | |
| 2443 | turn.heldEnd !== undefined && turn.steers.size === 0 | |
| 2444 | ? finishTurn(state, turn.heldEnd) | |
| 2445 | : Effect.void; | |
| 2446 | ||
| 2447 | const handleEvent = Effect.fnUntraced(function* (event: OpenCode2StreamEvent) { | |
| 2448 | const sessionId = sessionOfEvent(event); | |
| 2449 | // `revert.clear` wakes the session into an empty execution of its own | |
| 2450 | // (2.0.18's `Session.revert.clear` ends with a wake). It is no run of | |
| 2451 | // T3's, and no follow-up to a subagent either. | |
| 2452 | const cleared = sessionId === undefined ? undefined : clearing.get(sessionId); | |
| 2453 | if (cleared !== undefined) { | |
| 2454 | if (event.type === "unreadable.execution.ended" || executionEnd(event.type)) { | |
| 2455 | return yield* Deferred.succeed(cleared, undefined); | |
| 2456 | } | |
| 2457 | if ( | |
| 2458 | event.type === "session.execution.started" || | |
| 2459 | event.type === "unreadable.execution.started" | |
| 2460 | ) { | |
| 2461 | return; | |
| 2462 | } | |
| 2463 | } | |
| 2464 | if (sessionId !== undefined) { | |
| 2465 | if ( | |
| 2466 | event.type === "session.execution.started" || | |
| 2467 | event.type === "unreadable.execution.started" | |
| 2468 | ) { | |
| 2469 | busy.add(sessionId); | |
| 2470 | } else if (event.type === "unreadable.execution.ended" || executionEnd(event.type)) { | |
| 2471 | busy.delete(sessionId); | |
| 2472 | } | |
| 2473 | } | |
| 2474 | // A subagent's session: named on its parent's `subagent` call just after. | |
| 2475 | if (event.type === "session.created" && event.data.parentID !== undefined) { | |
| 2476 | const owner = ownerOf(event.data.parentID); | |
| 2477 | if (owner !== undefined) { | |
| 2478 | childOwners.set(event.data.sessionID, owner); | |
| 2479 | announced.set(event.data.sessionID, event.data); | |
| 2480 | } | |
| 2481 | return; | |
| 2482 | } | |
| 2483 | // What a held execution does, and what the subagents it starts do, | |
| 2484 | // waits for the turn that takes it. | |
| 2485 | const holder = sessionId === undefined ? undefined : holderOf(sessionId); | |
| 2486 | const held = holder?.wakes.at(-1); | |
| 2487 | if (holder !== undefined && held !== undefined) { | |
| 2488 | held.events.push(event); | |
| 2489 | if ( | |
| 2490 | sessionId === holder.sessionId && | |
| 2491 | (event.type === "unreadable.execution.ended" || executionEnd(event.type)) | |
| 2492 | ) { | |
| 2493 | held.running = false; | |
| 2494 | } | |
| 2495 | return; | |
| 2496 | } | |
| 2497 | return yield* route(event, sessionId); | |
| 2498 | }); | |
| 2499 | ||
| 2500 | /** Handles one event for its session, as the turn running there sees it. */ | |
| 2501 | const route = Effect.fnUntraced(function* ( | |
| 2502 | event: OpenCode2StreamEvent, | |
| 2503 | sessionId: string | undefined, | |
| 2504 | ) { | |
| 2505 | // The end of the run a timed-out Stop left behind; no turn is its own. | |
| 2506 | const ended = | |
| 2507 | event.type === "unreadable.execution.ended" || executionEnd(event.type) | |
| 2508 | ? threads.get(sessionId ?? "") | |
| 2509 | : undefined; | |
| 2510 | if (ended?.unsettled === true) { | |
| 2511 | ended.unsettled = false; | |
| 2512 | return; | |
| 2513 | } | |
| 2514 | // Marks where a running turn's own execution begins; it never ends one. | |
| 2515 | // With no turn running it is a subagent's or a follow-up's start, below. | |
| 2516 | if (event.type === "unreadable.execution.started") { | |
| 2517 | const turn = threads.get(event.sessionID)?.active; | |
| 2518 | if (turn !== undefined) { | |
| 2519 | turn.awaitingStart = false; | |
| 2520 | turn.heldEnd = undefined; | |
| 2521 | return; | |
| 2522 | } | |
| 2523 | } | |
| 2524 | if (event.type === "unreadable.execution.ended") { | |
| 2525 | const state = threads.get(event.sessionID); | |
| 2526 | const turn = state?.active; | |
| 2527 | if (state === undefined || turn === undefined || turn.awaitingStart) return; | |
| 2528 | return yield* endExecution( | |
| 2529 | state, | |
| 2530 | turn, | |
| 2531 | event.executionType === "session.execution.succeeded" | |
| 2532 | ? { status: turn.interrupted ? "interrupted" : "completed" } | |
| 2533 | : event.executionType === "session.execution.interrupted" | |
| 2534 | ? { status: "interrupted" } | |
| 2535 | : { | |
| 2536 | status: "failed", | |
| 2537 | failure: makeProviderFailure({ | |
| 2538 | message: "OpenCode ended the turn with an error this version cannot read.", | |
| 2539 | class: "provider_error", | |
| 2540 | }), | |
| 2541 | }, | |
| 2542 | ); | |
| 2543 | } | |
| 2544 | if (event.type === "permission.asked") { | |
| 2545 | return yield* onAsked({ type: "permission", request: event.data }); | |
| 2546 | } | |
| 2547 | if (event.type === "form.created") | |
| 2548 | return yield* onAsked({ type: "form", form: event.data.form }); | |
| 2549 | // Answered in another OpenCode client, or dropped by OpenCode: a reject | |
| 2550 | // it sends on its own (a Stop, or another reject in the same session) | |
| 2551 | // cancels the request. T3's own answers are settled where they are sent. | |
| 2552 | if ( | |
| 2553 | event.type === "permission.replied" || | |
| 2554 | event.type === "form.replied" || | |
| 2555 | event.type === "form.cancelled" | |
| 2556 | ) { | |
| 2557 | const nativeId = event.type === "permission.replied" ? event.data.requestID : event.data.id; | |
| 2558 | const entry = [...pending.values()].find((candidate) => candidate.native.id === nativeId); | |
| 2559 | if (entry === undefined || entry.answering) return; | |
| 2560 | const answered = | |
| 2561 | event.type === "form.replied" || | |
| 2562 | (event.type === "permission.replied" && event.data.reply !== "reject"); | |
| 2563 | return yield* settleRequest(entry, answered ? "resolved" : "cancelled"); | |
| 2564 | } | |
| 2565 | if (sessionId === undefined) return; | |
| 2566 | const state = threads.get(sessionId); | |
| 2567 | if (state === undefined) return; | |
| 2568 | if (event.type === "session.model.selected" || event.type === "session.step.started") { | |
| 2569 | if (event.type === "session.model.selected") state.model = event.data.model; | |
| 2570 | const providerThread = withReportedModel(state.providerThread, event.data.model); | |
| 2571 | if (providerThread !== state.providerThread) { | |
| 2572 | state.providerThread = { ...providerThread, updatedAt: yield* DateTime.now }; | |
| 2573 | yield* emit({ | |
| 2574 | type: "provider_thread.updated", | |
| 2575 | driver, | |
| 2576 | providerThread: state.providerThread, | |
| 2577 | }); | |
| 2578 | } | |
| 2579 | } | |
| 2580 | if ( | |
| 2581 | event.type === "session.inbox.enqueued" && | |
| 2582 | state.subagent !== undefined && | |
| 2583 | state.active === undefined | |
| 2584 | ) { | |
| 2585 | state.subagent.queued ??= event.data.inboxID; | |
| 2586 | } | |
| 2587 | if (event.type === "session.inbox.enqueued" && event.data.item.type === "synthetic") { | |
| 2588 | return yield* onReport(state, event.data.inboxID, event.data.item.payload); | |
| 2589 | } | |
| 2590 | // Cancelled, or delivered into a turn already running: no wake reports it. | |
| 2591 | // A steer of the running turn's is settled the same way. | |
| 2592 | if (event.type === "session.inbox.cancelled" || event.type === "session.inbox.delivered") { | |
| 2593 | state.reports.delete(event.data.inboxID); | |
| 2594 | const turn = state.active; | |
| 2595 | if (turn !== undefined && turn.steers.delete(event.data.inboxID)) { | |
| 2596 | turn.settledInbox.add(event.data.inboxID); | |
| 2597 | // A cancelled steer may leave a held turn nothing to wait for. | |
| 2598 | if (event.type === "session.inbox.cancelled") yield* endIfSettled(state, turn); | |
| 2599 | } | |
| 2600 | return; | |
| 2601 | } | |
| 2602 | // A subagent's later prompts (a resumed subagent) open its next turn's message. | |
| 2603 | if ( | |
| 2604 | event.type === "session.inbox.enqueued" && | |
| 2605 | event.data.item.type === "user" && | |
| 2606 | state.subagent !== undefined && | |
| 2607 | state.subagent.prompt === undefined && | |
| 2608 | state.active === undefined | |
| 2609 | ) { | |
| 2610 | state.subagent.prompt = event.data.item.payload.text; | |
| 2611 | return; | |
| 2612 | } | |
| 2613 | const started = | |
| 2614 | event.type === "session.execution.started" || event.type === "unreadable.execution.started"; | |
| 2615 | // Each execution of a subagent's session is a turn on its child thread, | |
| 2616 | // unless it only answers the reports of nested subagents a Stop ended. | |
| 2617 | if (state.subagent !== undefined && state.active === undefined && started) { | |
| 2618 | if ((yield* takeReports(state)).stopped) { | |
| 2619 | state.subagent.queued = undefined; | |
| 2620 | return; | |
| 2621 | } | |
| 2622 | return yield* startChildTurn(state); | |
| 2623 | } | |
| 2624 | const turn = state.active; | |
| 2625 | if (turn === undefined) { | |
| 2626 | // OpenCode started the thread's session on its own. | |
| 2627 | if (started && state.subagent === undefined) return yield* onWake(state); | |
| 2628 | return; | |
| 2629 | } | |
| 2630 | // A session runs one execution at a time, and each opens with `started` | |
| 2631 | // on this ordered stream, so what comes before it is the stopped run's. | |
| 2632 | if (turn.awaitingStart) { | |
| 2633 | if (event.type === "session.execution.started") turn.awaitingStart = false; | |
| 2634 | return; | |
| 2635 | } | |
| 2636 | return yield* onTurnEvent(state, turn, event); | |
| 2637 | }); | |
| 2638 | ||
| 2639 | // The stream is the only terminal signal, and it is volatile: events sent | |
| 2640 | // while it is down are gone, and a restarted server never ends the | |
| 2641 | // execution it lost. So a lost stream reconnects, then reconciles each | |
| 2642 | // running turn from the server's own state (see `reconcile`). Only when | |
| 2643 | // reconnecting keeps failing are the turns failed and the session broken, | |
| 2644 | // so T3 reopens it. Set first, so a turn starting meanwhile waits or refuses. | |
| 2645 | let streamFailure: string | undefined; | |
| 2646 | let reconnected = yield* Deferred.make<void>(); | |
| 2647 | const failAll = Effect.fnUntraced(function* (message: string) { | |
| 2648 | streamFailure = message; | |
| 2649 | yield* Deferred.succeed(reconnected, undefined); | |
| 2650 | for (const state of threads.values()) { | |
| 2651 | const failure = makeProviderFailure({ message, class: "transport_error" }); | |
| 2652 | yield* finishTurn(state, { status: "failed", failure }, "broken"); | |
| 2653 | } | |
| 2654 | // Nothing can report a background subagent's end any more. | |
| 2655 | for (const state of threads.values()) { | |
| 2656 | for (const call of runningCalls(state)) yield* settleCall(call, "failed"); | |
| 2657 | for (const wake of state.wakes.splice(0)) wake.dropped = true; | |
| 2658 | state.reports.clear(); | |
| 2659 | } | |
| 2660 | yield* setSessionStatus("error", message); | |
| 2661 | yield* Queue.end(events); | |
| 2662 | }); | |
| 2663 | /** | |
| 2664 | * Emits what a running turn missed while the stream was down, from the | |
| 2665 | * session's history since the turn began: text, reasoning and tools, each | |
| 2666 | * under the same native id its live events would have used, so nothing | |
| 2667 | * already shown is duplicated. A turn begins at its prompt, whose id T3 | |
| 2668 | * chose (`nativeTurnRef`), or after `before` for a `/name` command or a | |
| 2669 | * continuation. | |
| 2670 | * Returns how that history says the turn's execution ended: the `idle` item | |
| 2671 | * OpenCode appends after each one, or undefined when there is none after | |
| 2672 | * the turn's start. | |
| 2673 | */ | |
| 2674 | const backfill = Effect.fnUntraced(function* (sessionId: string, state: ThreadState) { | |
| 2675 | const turn = state.active; | |
| 2676 | if (turn === undefined) return undefined; | |
| 2677 | const promptId = promptOf(turn.providerTurn); | |
| 2678 | const { before } = turn; | |
| 2679 | if (promptId === undefined && before === undefined) return undefined; | |
| 2680 | const start = before !== undefined ? before : promptId; | |
| 2681 | const read = yield* paginate( | |
| 2682 | { sessionID: Session.ID.make(sessionId), order: "desc" as const, limit: 50 }, | |
| 2683 | client.message.list, | |
| 2684 | ).pipe( | |
| 2685 | Stream.takeUntil((message) => message.id === start), | |
| 2686 | Stream.runCollect, | |
| 2687 | ); | |
| 2688 | // Without its start the history holds only earlier turns (the stream | |
| 2689 | // dropped before the prompt landed): none of it is this turn's. A null | |
| 2690 | // `before` is a command that started on an empty history. | |
| 2691 | const found = start === null || read.at(-1)?.id === start; | |
| 2692 | if (!found) return undefined; | |
| 2693 | const recent = before !== undefined && before !== null ? read.slice(0, -1) : read; | |
| 2694 | const idle = recent.find((message) => message.type === "idle"); | |
| 2695 | for (const message of recent.toReversed()) { | |
| 2696 | if (message.type !== "assistant" || state.active !== turn) continue; | |
| 2697 | const ordinals = { text: 0, reasoning: 0 }; | |
| 2698 | for (const part of message.content) { | |
| 2699 | if (part.type === "text" || part.type === "reasoning") { | |
| 2700 | const block = { assistantMessageID: message.id, ordinal: ordinals[part.type]++ }; | |
| 2701 | yield* emitText(state, turn, block, part.type, () => part.text, true); | |
| 2702 | continue; | |
| 2703 | } | |
| 2704 | if (part.type !== "tool") continue; | |
| 2705 | if (!turn.tools.has(part.id)) { | |
| 2706 | turn.tools.set(part.id, { name: part.name, input: {} }); | |
| 2707 | turn.startedAt.set(part.id, yield* DateTime.now); | |
| 2708 | } | |
| 2709 | const tool = turn.tools.get(part.id)!; | |
| 2710 | if (typeof part.state.input === "object" && part.state.input !== null) { | |
| 2711 | tool.input = part.state.input as Record<string, unknown>; | |
| 2712 | } | |
| 2713 | if (part.state.status === "completed") { | |
| 2714 | yield* emitTool(state, turn, part.id, "completed", { | |
| 2715 | output: textOf(part.state.content), | |
| 2716 | metadata: part.state.metadata, | |
| 2717 | }); | |
| 2718 | turn.tools.delete(part.id); | |
| 2719 | } else if (part.state.status === "error") { | |
| 2720 | yield* emitTool(state, turn, part.id, "failed", { | |
| 2721 | output: part.state.error.message, | |
| 2722 | metadata: part.state.metadata, | |
| 2723 | }); | |
| 2724 | turn.tools.delete(part.id); | |
| 2725 | } else { | |
| 2726 | yield* emitTool(state, turn, part.id, "running"); | |
| 2727 | } | |
| 2728 | } | |
| 2729 | } | |
| 2730 | return idle === undefined ? undefined : { outcome: idle.outcome }; | |
| 2731 | }); | |
| 2732 | ||
| 2733 | /** | |
| 2734 | * Settles each thread turn that was running when the stream dropped, from | |
| 2735 | * the server: a session still running keeps its turn (its next events | |
| 2736 | * arrive on the new stream); one that stopped ends the turn with the | |
| 2737 | * outcome of the `idle` item after the turn's start. A restarted server | |
| 2738 | * writes no such item for the execution it lost, and `session.outcome` is | |
| 2739 | * still the previous execution's, so no `idle` means the turn was | |
| 2740 | * interrupted. A continuation turn is settled the same way, from the report | |
| 2741 | * its execution answers. A turn still holding for an undelivered steer only | |
| 2742 | * ends once the session is idle. Permission asks and question forms are | |
| 2743 | * read back from every session still running, so a run that asked during | |
| 2744 | * the gap can be answered. | |
| 2745 | * | |
| 2746 | * What the stream carried outside a turn is not read back: a background | |
| 2747 | * subagent's report, a follow-up OpenCode started on its own, or one held | |
| 2748 | * for its continuation turn. So background work is settled, not resumed: a | |
| 2749 | * subagent whose session stopped ends as interrupted, and a thread's held | |
| 2750 | * follow-ups and queued reports are dropped, so nothing waits on an event | |
| 2751 | * that already went by. | |
| 2752 | */ | |
| 2753 | const reconcile = Effect.gen(function* () { | |
| 2754 | // A subagent's turn ends before the turn that waits on it, so the | |
| 2755 | // deepest sessions are read back first. | |
| 2756 | const running = [...threads] | |
| 2757 | .filter(([, state]) => state.active !== undefined) | |
| 2758 | .toSorted(([left], [right]) => callsAbove(right).length - callsAbove(left).length); | |
| 2759 | const background = [...threads.values()].filter( | |
| 2760 | (state) => state.subagent === undefined && hasBackground(state), | |
| 2761 | ); | |
| 2762 | if (running.length === 0 && background.length === 0 && busy.size === 0) return; | |
| 2763 | const active = yield* client.session.active(); | |
| 2764 | // An execution that ended while the stream was down never said so. | |
| 2765 | for (const sessionId of busy) if (!(sessionId in active)) busy.delete(sessionId); | |
| 2766 | for (const [sessionId, state] of running) { | |
| 2767 | const turn = state.active; | |
| 2768 | if (turn === undefined) continue; | |
| 2769 | const ended = yield* backfill(sessionId, state); | |
| 2770 | if (sessionId in active || state.active !== turn) continue; | |
| 2771 | yield* finishTurn( | |
| 2772 | state, | |
| 2773 | ended?.outcome === "succeeded" | |
| 2774 | ? { status: turn.interrupted ? "interrupted" : "completed" } | |
| 2775 | : ended?.outcome === "failed" | |
| 2776 | ? { | |
| 2777 | status: "failed", | |
| 2778 | failure: makeProviderFailure({ | |
| 2779 | message: | |
| 2780 | "OpenCode ended the turn with an error while T3 Code was reconnecting.", | |
| 2781 | class: "provider_error", | |
| 2782 | }), | |
| 2783 | } | |
| 2784 | : { status: "interrupted" }, | |
| 2785 | ); | |
| 2786 | } | |
| 2787 | for (const state of background) { | |
| 2788 | // A subagent still running keeps its call; its end arrives on the new stream. | |
| 2789 | for (const call of runningCalls(state)) { | |
| 2790 | const child = call.child?.sessionId; | |
| 2791 | if (child === undefined || !(child in active)) { | |
| 2792 | yield* settleCall(call, "interrupted", LOST_BACKGROUND); | |
| 2793 | } | |
| 2794 | } | |
| 2795 | // A subagent's session holds reports of its own background subagents. | |
| 2796 | for (const session of sessionsOf(state)) { | |
| 2797 | for (const wake of session.wakes.splice(0)) wake.dropped = true; | |
| 2798 | session.reports.clear(); | |
| 2799 | } | |
| 2800 | } | |
| 2801 | // A request asked while the stream was down was never shown, and the | |
| 2802 | // run waits on it; one T3 shows that OpenCode no longer lists was | |
| 2803 | // answered elsewhere or dropped with its execution. A session that | |
| 2804 | // stopped dropped its requests. | |
| 2805 | const listed = new Map<string, Set<string>>(); | |
| 2806 | for (const sessionId of Object.keys(active)) { | |
| 2807 | if (ownerOf(sessionId) === undefined) continue; | |
| 2808 | const sessionID = Session.ID.make(sessionId); | |
| 2809 | const [permissions, forms] = yield* Effect.all([ | |
| 2810 | client.permission.list({ sessionID }), | |
| 2811 | client.session.form.list({ sessionID }), | |
| 2812 | ]); | |
| 2813 | listed.set(sessionId, new Set([...permissions, ...forms].map((request) => request.id))); | |
| 2814 | const shown = new Set([...pending.values()].map((entry) => entry.native.id)); | |
| 2815 | for (const request of permissions) { | |
| 2816 | if (!shown.has(request.id)) yield* onAsked({ type: "permission", request }); | |
| 2817 | } | |
| 2818 | for (const form of forms) { | |
| 2819 | if (!shown.has(form.id)) yield* onAsked({ type: "form", form }); | |
| 2820 | } | |
| 2821 | } | |
| 2822 | // A snapshot: settling a request removes it from the map. | |
| 2823 | for (const entry of Array.from(pending.values())) { | |
| 2824 | if (listed.get(entry.sessionId)?.has(entry.native.id) !== true) { | |
| 2825 | yield* settleRequest(entry, "cancelled"); | |
| 2826 | } | |
| 2827 | } | |
| 2828 | }); | |
| 2829 | ||
| 2830 | /** | |
| 2831 | * Subscribes again, on a restarted server if the old one is gone, and | |
| 2832 | * reconciles. Retried a few times; the caller fails everything after that. | |
| 2833 | */ | |
| 2834 | const reconnect = Effect.gen(function* () { | |
| 2835 | // Returned on any failure, including the interrupt a closing session | |
| 2836 | // sends a reconnect still in flight, so a spawned server can idle-stop. | |
| 2837 | const scope = yield* Scope.make(); | |
| 2838 | const { next, stream } = yield* Effect.gen(function* () { | |
| 2839 | const next = yield* borrow.pipe(Effect.provideService(Scope.Scope, scope)); | |
| 2840 | return { next, stream: yield* next.events }; | |
| 2841 | }).pipe(Effect.onError(() => Scope.close(scope, Exit.void))); | |
| 2842 | const previous = currentScope; | |
| 2843 | connection = next; | |
| 2844 | client = next.client; | |
| 2845 | currentScope = scope; | |
| 2846 | yield* Scope.close(previous, Exit.void); | |
| 2847 | // A restarted server forgot T3's MCP servers; the next turn adds them again. | |
| 2848 | for (const state of threads.values()) state.mcp = undefined; | |
| 2849 | yield* lock.withPermit(reconcile).pipe(Effect.timeout(RECONCILE_TIMEOUT)); | |
| 2850 | return stream; | |
| 2851 | }).pipe( | |
| 2852 | Effect.retry({ times: RECONNECT_ATTEMPTS - 1, schedule: Schedule.spaced(RECONNECT_DELAY) }), | |
| 2853 | ); | |
| 2854 | ||
| 2855 | let currentScope = initial.scope; | |
| 2856 | // The borrow in use when the session closes is returned with it, so a | |
| 2857 | // spawned server can still reach its idle shutdown. | |
| 2858 | yield* Effect.addFinalizer(() => Scope.close(currentScope, Exit.void)); | |
| 2859 | const follow = (stream: Stream.Stream<OpenCode2StreamEvent, unknown>): Effect.Effect<void> => | |
| 2860 | stream.pipe( | |
| 2861 | Stream.runForEach((event) => lock.withPermit(handleEvent(event))), | |
| 2862 | Effect.exit, | |
| 2863 | Effect.flatMap(() => | |
| 2864 | Effect.gen(function* () { | |
| 2865 | streamFailure = "The OpenCode event stream was lost. Reconnecting."; | |
| 2866 | yield* Effect.logWarning("Lost the OpenCode event stream; reconnecting."); | |
| 2867 | // The last failure is kept for the log: it can carry the server's | |
| 2868 | // URL or response text, so only its tag is annotated and the full | |
| 2869 | // failure stays in the log's cause. | |
| 2870 | const next = yield* reconnect.pipe( | |
| 2871 | Effect.tapCause((cause) => | |
| 2872 | Effect.logWarning( | |
| 2873 | "Could not reconnect to the OpenCode event stream; failing the session.", | |
| 2874 | cause, | |
| 2875 | ).pipe(Effect.annotateLogs({ errorTag: causeErrorTag(cause) })), | |
| 2876 | ), | |
| 2877 | Effect.option, | |
| 2878 | ); | |
| 2879 | if (next._tag === "None") { | |
| 2880 | return yield* failAll("The OpenCode event stream was lost and could not reconnect."); | |
| 2881 | } | |
| 2882 | streamFailure = undefined; | |
| 2883 | const done = reconnected; | |
| 2884 | reconnected = yield* Deferred.make<void>(); | |
| 2885 | yield* Deferred.succeed(done, undefined); | |
| 2886 | return yield* follow(next.value); | |
| 2887 | }), | |
| 2888 | ), | |
| 2889 | ); | |
| 2890 | // Subscribed before any session or prompt call, so no event of theirs is missed. | |
| 2891 | yield* follow(yield* connection.events).pipe(Effect.forkScoped); | |
| 2892 | ||
| 2893 | // A server T3 did not start keeps running after T3 stops, so stop the turns | |
| 2894 | // it would otherwise finish unseen. A spawned server stops with its owner. | |
| 2895 | if (connection.external) { | |
| 2896 | yield* Effect.addFinalizer(() => | |
| 2897 | Effect.forEach( | |
| 2898 | [...threads].filter(([, state]) => state.active !== undefined), | |
| 2899 | ([sessionId]) => | |
| 2900 | client.session | |
| 2901 | .interrupt({ sessionID: Session.ID.make(sessionId) }) | |
| 2902 | .pipe(Effect.timeout("1 second"), Effect.ignore({ log: true })), | |
| 2903 | { concurrency: 8, discard: true }, | |
| 2904 | ), | |
| 2905 | ); | |
| 2906 | } | |
| 2907 | ||
| 2908 | // Context windows come from the server's model list for a directory, read | |
| 2909 | // when the session opens, before a thread first runs in another directory, | |
| 2910 | // and again after a turn whose model had none yet. A directory counts as | |
| 2911 | // known once its read starts, so a failed read is retried only after a turn. | |
| 2912 | const readModels = (directory: string) => | |
| 2913 | Effect.suspend(() => { | |
| 2914 | const windows = contextWindows.get(directory) ?? new Map<string, number>(); | |
| 2915 | contextWindows.set(directory, windows); | |
| 2916 | return client.model.list({ location: { directory } }).pipe( | |
| 2917 | Effect.timeout("5 seconds"), | |
| 2918 | Effect.tap((models) => | |
| 2919 | Effect.sync(() => { | |
| 2920 | for (const model of models.data) { | |
| 2921 | // A model's input limit, when it has one, is its real headroom. | |
| 2922 | windows.set( | |
| 2923 | `${model.providerID}/${model.id}`, | |
| 2924 | model.limit.input ?? model.limit.context, | |
| 2925 | ); | |
| 2926 | } | |
| 2927 | }), | |
| 2928 | ), | |
| 2929 | Effect.ignore({ log: true }), | |
| 2930 | ); | |
| 2931 | }); | |
| 2932 | const readModelsOnce = (cwd: string | null | undefined) => | |
| 2933 | contextWindows.has(directoryOf(cwd)) ? Effect.void : readModels(directoryOf(cwd)); | |
| 2934 | yield* readModels(session.cwd); | |
| 2935 | ||
| 2936 | // Each agent's own path allows, by the directory they were listed for. | |
| 2937 | const agentRules = new Map<string, ReadonlyMap<string, ReadonlyArray<Rule>>>(); | |
| 2938 | const pathsFor = Effect.fnUntraced(function* ( | |
| 2939 | directory: string, | |
| 2940 | agents: ReadonlyArray<string>, | |
| 2941 | ) { | |
| 2942 | let known = agentRules.get(directory); | |
| 2943 | if (known === undefined) { | |
| 2944 | // A fresh server lists no agents for its first moments, like its models. | |
| 2945 | const listed = yield* client.agent.list({ location: { directory } }).pipe( | |
| 2946 | Effect.repeat({ | |
| 2947 | until: (list) => list.data.length > 0, | |
| 2948 | schedule: Schedule.spaced("250 millis"), | |
| 2949 | }), | |
| 2950 | Effect.timeout("5 seconds"), | |
| 2951 | // The failure can carry the server's URL or response text, so only its | |
| 2952 | // tag is annotated; the full failure stays in the log's cause. | |
| 2953 | Effect.tapCause((cause) => | |
| 2954 | Effect.logWarning( | |
| 2955 | "Could not list OpenCode agents; their path rules are skipped.", | |
| 2956 | cause, | |
| 2957 | ).pipe(Effect.annotateLogs({ errorTag: causeErrorTag(cause) })), | |
| 2958 | ), | |
| 2959 | Effect.option, | |
| 2960 | ); | |
| 2961 | if (listed._tag === "None") return []; | |
| 2962 | known = new Map( | |
| 2963 | listed.value.data.map((agent) => [agent.id, agentPaths(agent.permissions)]), | |
| 2964 | ); | |
| 2965 | agentRules.set(directory, known); | |
| 2966 | } | |
| 2967 | const paths = new Map<string, Rule>(); | |
| 2968 | for (const agent of agents) { | |
| 2969 | for (const entry of known.get(agent) ?? []) { | |
| 2970 | paths.set(`${entry.action}\u0000${entry.resource}`, entry); | |
| 2971 | } | |
| 2972 | } | |
| 2973 | return [...paths.values()]; | |
| 2974 | }); | |
| 2975 | ||
| 2976 | /** | |
| 2977 | * The rules a thread's session runs `policy` with. Full access allows | |
| 2978 | * every path already, so the agent's own path rules are read only for the | |
| 2979 | * modes that narrow it. | |
| 2980 | */ | |
| 2981 | const rulesFor = Effect.fnUntraced(function* ( | |
| 2982 | thread: Pick<ThreadState, "directory" | "agent" | "grants">, | |
| 2983 | policy: RulesPolicy, | |
| 2984 | appThreadId: string | null, | |
| 2985 | ) { | |
| 2986 | const plan = policy.interactionMode === "plan"; | |
| 2987 | const paths = | |
| 2988 | policy.runtimeMode === "full-access" && !plan | |
| 2989 | ? [] | |
| 2990 | : yield* pathsFor(thread.directory, plan ? [thread.agent, "plan"] : [thread.agent]); | |
| 2991 | return sessionRules( | |
| 2992 | policy, | |
| 2993 | paths, | |
| 2994 | policy.runtimeMode === "full-access" ? [] : thread.grants, | |
| 2995 | appThreadId === null ? null : yield* mcpServerNameFor(appThreadId), | |
| 2996 | ); | |
| 2997 | }); | |
| 2998 | ||
| 2999 | /** Writes the session's rules for `policy` when they differ from what it has. */ | |
| 3000 | const writeRules = Effect.fnUntraced(function* (state: ThreadState, policy: RulesPolicy) { | |
| 3001 | // A subagent's session may use its thread's T3 server, the root's. | |
| 3002 | const rules = yield* rulesFor(state, policy, rootOf(state).providerThread.appThreadId); | |
| 3003 | if (!sameRules(state.rules, rules)) { | |
| 3004 | yield* client.session.update({ | |
| 3005 | sessionID: Session.ID.make(state.sessionId), | |
| 3006 | permissions: rules, | |
| 3007 | }); | |
| 3008 | state.rules = rules; | |
| 3009 | } | |
| 3010 | state.policy = policy; | |
| 3011 | }); | |
| 3012 | ||
| 3013 | const register = ( | |
| 3014 | providerThread: OrchestrationV2ProviderThread, | |
| 3015 | native: { | |
| 3016 | readonly id: string; | |
| 3017 | readonly model?: ModelRef | undefined; | |
| 3018 | readonly agent?: string | undefined; | |
| 3019 | readonly permissions?: ReadonlyArray<Rule> | undefined; | |
| 3020 | }, | |
| 3021 | directory: string, | |
| 3022 | ) => { | |
| 3023 | providerThread = withReportedModel(providerThread, native.model); | |
| 3024 | const existing = threads.get(native.id); | |
| 3025 | if (existing !== undefined) { | |
| 3026 | existing.providerThread = providerThread; | |
| 3027 | existing.model = native.model; | |
| 3028 | existing.directory = directory; | |
| 3029 | existing.agent = native.agent ?? existing.agent; | |
| 3030 | existing.rules = native.permissions; | |
| 3031 | return existing; | |
| 3032 | } | |
| 3033 | const state = newThreadState(native.id, providerThread, directory, undefined); | |
| 3034 | state.model = native.model; | |
| 3035 | state.agent = native.agent ?? state.agent; | |
| 3036 | state.rules = native.permissions; | |
| 3037 | threads.set(native.id, state); | |
| 3038 | return state; | |
| 3039 | }; | |
| 3040 | ||
| 3041 | /** | |
| 3042 | * Refuses to cut or copy a session's history while something writes to | |
| 3043 | * it: a turn of T3's, an execution seen running on the stream (a held | |
| 3044 | * follow-up), or a run on the server this runtime does not own: one a | |
| 3045 | * timed-out Stop or an unanswered request left behind, or any run on a | |
| 3046 | * session loaded after the server outlived T3. Only that last case asks | |
| 3047 | * the server. A rollback also waits for background subagents, whose | |
| 3048 | * reports wake the session into the history it would cut. | |
| 3049 | */ | |
| 3050 | const ensureQuiet = Effect.fnUntraced(function* ( | |
| 3051 | sessionId: string, | |
| 3052 | verb: "roll back" | "fork", | |
| 3053 | ) { | |
| 3054 | const state = threads.get(sessionId); | |
| 3055 | const busyHere = | |
| 3056 | state !== undefined && | |
| 3057 | (state.active !== undefined || | |
| 3058 | busy.has(sessionId) || | |
| 3059 | (verb === "roll back" && hasBackground(state))); | |
| 3060 | const busyThere = | |
| 3061 | !busyHere && | |
| 3062 | (state === undefined || state.unsettled) && | |
| 3063 | sessionId in (yield* client.session.active().pipe(Effect.timeout(ACTIVE_CHECK_TIMEOUT))); | |
| 3064 | if (busyHere || busyThere) { | |
| 3065 | return yield* new ProviderAdapter.ProviderAdapterProtocolError({ | |
| 3066 | driver: OPENCODE_PROVIDER, | |
| 3067 | detail: `Cannot ${verb} OpenCode session ${sessionId} while it is still working`, | |
| 3068 | }); | |
| 3069 | } | |
| 3070 | }); | |
| 3071 | ||
| 3072 | /** | |
| 3073 | * Takes back steers a finished turn left in OpenCode's inbox. One whose | |
| 3074 | * cancel fails may still be there, so it is tried again before the next prompt. | |
| 3075 | */ | |
| 3076 | const cancelStrandedSteers = Effect.fnUntraced(function* (state: ThreadState) { | |
| 3077 | const sessionID = Session.ID.make(state.sessionId); | |
| 3078 | // A snapshot: a cancelled steer leaves the set. | |
| 3079 | for (const inboxID of Array.from(state.strandedSteers)) { | |
| 3080 | const cancelled = yield* client.session.inbox | |
| 3081 | .cancel({ sessionID, inboxID: SessionMessage.ID.make(inboxID) }) | |
| 3082 | .pipe( | |
| 3083 | Effect.timeout(REQUEST_REPLY_TIMEOUT), | |
| 3084 | Effect.tapCause((cause) => | |
| 3085 | Effect.logWarning("Could not take back an OpenCode steer.", cause), | |
| 3086 | ), | |
| 3087 | Effect.exit, | |
| 3088 | Effect.map(Exit.isSuccess), | |
| 3089 | ); | |
| 3090 | if (cancelled) state.strandedSteers.delete(inboxID); | |
| 3091 | } | |
| 3092 | }); | |
| 3093 | ||
| 3094 | /** | |
| 3095 | * Clears a staged revert and waits out the empty execution `clear` runs, | |
| 3096 | * which is no turn of T3's. 2.0.18 wakes the session after every clear, | |
| 3097 | * with or without a stage (seen live), so that execution always comes. | |
| 3098 | */ | |
| 3099 | const clearRevert = Effect.fnUntraced(function* (sessionId: string) { | |
| 3100 | const settled = yield* Deferred.make<void>(); | |
| 3101 | clearing.set(sessionId, settled); | |
| 3102 | yield* client.session.revert | |
| 3103 | .clear({ sessionID: Session.ID.make(sessionId) }) | |
| 3104 | .pipe( | |
| 3105 | Effect.andThen(Deferred.await(settled)), | |
| 3106 | Effect.timeout(REQUEST_REPLY_TIMEOUT), | |
| 3107 | Effect.ensuring(Effect.sync(() => clearing.delete(sessionId))), | |
| 3108 | ); | |
| 3109 | stagedReverts.delete(sessionId); | |
| 3110 | }); | |
| 3111 | ||
| 3112 | /** | |
| 3113 | * Truncates the session's history before `boundary`: OpenCode stages the | |
| 3114 | * revert, then commits it. If either fails or is interrupted the stage is | |
| 3115 | * cleared, since OpenCode commits a staged revert on the next prompt; a | |
| 3116 | * clear that fails too is tried again before that prompt. Files stay where | |
| 3117 | * they are: T3 restores its own checkpoint when the user asked for files, | |
| 3118 | * and a rewind without files must not touch them. | |
| 3119 | */ | |
| 3120 | const rollBackTo = Effect.fnUntraced(function* ( | |
| 3121 | sessionId: string, | |
| 3122 | boundary: SessionMessage.ID, | |
| 3123 | ) { | |
| 3124 | const sessionID = Session.ID.make(sessionId); | |
| 3125 | // Each step is bounded: a server that never answers would otherwise | |
| 3126 | // hold the session's gate, and the next turn with it. | |
| 3127 | yield* client.session.revert.stage({ sessionID, messageID: boundary, files: false }).pipe( | |
| 3128 | Effect.timeout(REQUEST_REPLY_TIMEOUT), | |
| 3129 | Effect.andThen( | |
| 3130 | client.session.revert.commit({ sessionID }).pipe(Effect.timeout(REQUEST_REPLY_TIMEOUT)), | |
| 3131 | ), | |
| 3132 | Effect.onError(() => | |
| 3133 | clearRevert(sessionId).pipe( | |
| 3134 | Effect.tapError(() => Effect.sync(() => stagedReverts.add(sessionId))), | |
| 3135 | Effect.ignore({ log: true }), | |
| 3136 | ), | |
| 3137 | ), | |
| 3138 | ); | |
| 3139 | }); | |
| 3140 | ||
| 3141 | /** | |
| 3142 | * The ids fork and rollback can cut before: the session's prompts, and the | |
| 3143 | * reports continuations answer. The list takes one type, so it reads all. | |
| 3144 | */ | |
| 3145 | const userMessages = (sessionId: string) => | |
| 3146 | paginate({ sessionID: Session.ID.make(sessionId), limit: 100 }, client.message.list).pipe( | |
| 3147 | Stream.runCollect, | |
| 3148 | Effect.map( | |
| 3149 | (messages) => | |
| 3150 | new Set( | |
| 3151 | messages.flatMap((message) => | |
| 3152 | message.type === "user" || message.type === "synthetic" ? [String(message.id)] : [], | |
| 3153 | ), | |
| 3154 | ), | |
| 3155 | ), | |
| 3156 | ); | |
| 3157 | ||
| 3158 | /** The session's history as V2 messages, read after any change T3 made to it. */ | |
| 3159 | const snapshotOf = Effect.fnUntraced(function* ( | |
| 3160 | providerThread: OrchestrationV2ProviderThread, | |
| 3161 | sessionId: string, | |
| 3162 | ) { | |
| 3163 | const history = yield* paginate( | |
| 3164 | { sessionID: Session.ID.make(sessionId), order: "asc" as const, limit: 100 }, | |
| 3165 | client.message.list, | |
| 3166 | ).pipe(Stream.runCollect); | |
| 3167 | const snapshotAt = yield* DateTime.now; | |
| 3168 | const messages = history.flatMap((message): Array<OrchestrationV2ConversationMessage> => { | |
| 3169 | const text = | |
| 3170 | message.type === "user" | |
| 3171 | ? message.text | |
| 3172 | : message.type === "assistant" | |
| 3173 | ? textOf(message.content) | |
| 3174 | : ""; | |
| 3175 | if (text.length === 0 || (message.type !== "user" && message.type !== "assistant")) { | |
| 3176 | return []; | |
| 3177 | } | |
| 3178 | const createdAt = message.time.created; | |
| 3179 | return [ | |
| 3180 | { | |
| 3181 | createdBy: message.type === "user" ? "user" : "agent", | |
| 3182 | creationSource: "provider", | |
| 3183 | id: idAllocator.derive.messageFromProviderItem({ | |
| 3184 | driver, | |
| 3185 | nativeItemId: message.id, | |
| 3186 | }), | |
| 3187 | threadId: providerThread.appThreadId ?? input.threadId, | |
| 3188 | runId: null, | |
| 3189 | nodeId: null, | |
| 3190 | role: message.type, | |
| 3191 | text, | |
| 3192 | attachments: [], | |
| 3193 | streaming: false, | |
| 3194 | createdAt, | |
| 3195 | updatedAt: createdAt, | |
| 3196 | }, | |
| 3197 | ]; | |
| 3198 | }); | |
| 3199 | const lastUser = history.findLast((message) => message.type === "user")?.id; | |
| 3200 | const state = threads.get(sessionId); | |
| 3201 | return { | |
| 3202 | providerThread: { | |
| 3203 | ...providerThread, | |
| 3204 | providerSessionId: input.providerSessionId, | |
| 3205 | nativeConversationHeadRef: lastUser === undefined ? null : ref(lastUser, "weak"), | |
| 3206 | status: "idle" as const, | |
| 3207 | updatedAt: snapshotAt, | |
| 3208 | }, | |
| 3209 | providerTurns: state === undefined ? [] : [...state.providerTurns.values()], | |
| 3210 | messages, | |
| 3211 | runtimeRequests: [], | |
| 3212 | }; | |
| 3213 | }); | |
| 3214 | ||
| 3215 | const promptText = (turnInput: ProviderAdapter.ProviderAdapterV2TurnInput) => | |
| 3216 | providerMessageTextWithAttachmentPaths({ | |
| 3217 | text: turnInput.message.text, | |
| 3218 | attachments: turnInput.message.attachments, | |
| 3219 | resolveAttachmentPath: (attachment) => | |
| 3220 | resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }), | |
| 3221 | }).trim(); | |
| 3222 | ||
| 3223 | const removeMcp = (mcp: { readonly name: string; readonly directory: string }) => | |
| 3224 | Effect.suspend(() => | |
| 3225 | client.mcp.remove({ server: mcp.name, location: { directory: mcp.directory } }), | |
| 3226 | ).pipe(Effect.timeout("5 seconds"), Effect.ignore({ log: true })); | |
| 3227 | ||
| 3228 | // T3's MCP registrations outlive a session only on an external server; a | |
| 3229 | // spawned one forgets them when it stops. | |
| 3230 | yield* Effect.addFinalizer(() => | |
| 3231 | Effect.forEach( | |
| 3232 | [...threads.values()].flatMap((state) => (state.mcp === undefined ? [] : [state.mcp])), | |
| 3233 | removeMcp, | |
| 3234 | { concurrency: 8, discard: true }, | |
| 3235 | ), | |
| 3236 | ); | |
| 3237 | ||
| 3238 | /** | |
| 3239 | * T3's MCP server for this thread, and the per-turn instructions. The MCP | |
| 3240 | * server is registered for the session's directory under the thread's own | |
| 3241 | * name and credential (the session rules allow only it), and removed when | |
| 3242 | * the thread unloads or the session closes. OpenCode 2 has no per-prompt | |
| 3243 | * system field, so the instructions are a session instructions entry, | |
| 3244 | * which applies from the next step; it is only rewritten when it changes. | |
| 3245 | */ | |
| 3246 | const prepareTurn = Effect.fnUntraced(function* ( | |
| 3247 | sessionId: string, | |
| 3248 | state: ThreadState, | |
| 3249 | turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, | |
| 3250 | ) { | |
| 3251 | const mcpSession = McpProviderSession.readMcpProviderSession(turnInput.threadId); | |
| 3252 | const directory = turnInput.runtimePolicy.cwd ?? serverConfig.cwd; | |
| 3253 | const name = yield* mcpServerNameFor(turnInput.threadId); | |
| 3254 | // An external server may not reach T3's MCP endpoint, as with 1.x. | |
| 3255 | const wanted = | |
| 3256 | mcpSession === undefined || connection.external | |
| 3257 | ? undefined | |
| 3258 | : { name, directory, credential: mcpSession.authorizationHeader }; | |
| 3259 | if ( | |
| 3260 | state.mcp !== undefined && | |
| 3261 | (wanted === undefined || | |
| 3262 | state.mcp.directory !== wanted.directory || | |
| 3263 | state.mcp.credential !== wanted.credential) | |
| 3264 | ) { | |
| 3265 | yield* removeMcp(state.mcp); | |
| 3266 | state.mcp = undefined; | |
| 3267 | } | |
| 3268 | // T3's tools are an addition: a server that cannot add them still runs the turn. | |
| 3269 | if (wanted !== undefined && state.mcp === undefined) { | |
| 3270 | const added = yield* client.mcp | |
| 3271 | .add({ | |
| 3272 | server: name, | |
| 3273 | location: { directory }, | |
| 3274 | config: new Mcp.RemoteConfig({ | |
| 3275 | type: "remote", | |
| 3276 | url: mcpSession!.endpoint, | |
| 3277 | headers: { Authorization: wanted.credential }, | |
| 3278 | oauth: false, | |
| 3279 | }), | |
| 3280 | }) | |
| 3281 | .pipe( | |
| 3282 | Effect.timeout(INVENTORY_TIMEOUT), | |
| 3283 | Effect.as(true), | |
| 3284 | Effect.catchCause((cause) => | |
| 3285 | Effect.logWarning("Could not add T3 Code's MCP server to OpenCode.", cause).pipe( | |
| 3286 | Effect.as(false), | |
| 3287 | ), | |
| 3288 | ), | |
| 3289 | ); | |
| 3290 | if (added) state.mcp = wanted; | |
| 3291 | } | |
| 3292 | const instructions = [ | |
| 3293 | buildRuntimeInstructions({ harness: "OpenCode", model: turnInput.modelSelection.model }), | |
| 3294 | t3OrchestrationSystemPrompt(state.mcp !== undefined), | |
| 3295 | ] | |
| 3296 | .filter((part) => part !== undefined && part.length > 0) | |
| 3297 | .join("\n\n"); | |
| 3298 | if (instructions !== state.instructions) { | |
| 3299 | yield* client.session.instructions.entry.put({ | |
| 3300 | sessionID: Session.ID.make(sessionId), | |
| 3301 | key: INSTRUCTIONS_KEY, | |
| 3302 | value: instructions, | |
| 3303 | }); | |
| 3304 | state.instructions = instructions; | |
| 3305 | } | |
| 3306 | }); | |
| 3307 | ||
| 3308 | const markBefore = (sessionId: string, before: string | null) => | |
| 3309 | Effect.sync(() => { | |
| 3310 | const turn = threads.get(sessionId)?.active; | |
| 3311 | if (turn !== undefined) turn.before = before; | |
| 3312 | }); | |
| 3313 | ||
| 3314 | /** Installs a turn T3 started; every path after it ends the turn with a terminal. */ | |
| 3315 | const beginTurn = ( | |
| 3316 | state: ThreadState, | |
| 3317 | turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, | |
| 3318 | awaitingStart: boolean, | |
| 3319 | ) => | |
| 3320 | Effect.gen(function* () { | |
| 3321 | const startedAt = yield* DateTime.now; | |
| 3322 | const nativeTurnId = `${state.sessionId}:attempt:${turnInput.attemptId}`; | |
| 3323 | const providerTurn: OrchestrationV2ProviderTurn = { | |
| 3324 | id: idAllocator.derive.providerTurn({ driver, nativeTurnId }), | |
| 3325 | providerThreadId: turnInput.providerThread.id, | |
| 3326 | nodeId: turnInput.rootNodeId, | |
| 3327 | runAttemptId: turnInput.attemptId, | |
| 3328 | // The user message it prompts with, where fork and rollback cut. A | |
| 3329 | // continuation prompts nothing; it records the report it answers | |
| 3330 | // once it takes its execution. | |
| 3331 | nativeTurnRef: ref( | |
| 3332 | isContinuation(turnInput) | |
| 3333 | ? wakeTurnId(state.sessionId, turnInput.attemptId) | |
| 3334 | : turnPromptId(state.sessionId, turnInput.attemptId), | |
| 3335 | "weak", | |
| 3336 | ), | |
| 3337 | ordinal: turnInput.providerTurnOrdinal, | |
| 3338 | status: "running", | |
| 3339 | startedAt, | |
| 3340 | completedAt: null, | |
| 3341 | }; | |
| 3342 | const turn = makeTurn(turnInput, providerTurn, { scope: "", awaitingStart }); | |
| 3343 | // No stream is left to end this turn, so it must not start. | |
| 3344 | if (streamFailure !== undefined) { | |
| 3345 | return yield* new ProviderAdapter.ProviderAdapterEventStreamError({ | |
| 3346 | driver, | |
| 3347 | providerSessionId: input.providerSessionId, | |
| 3348 | cause: streamFailure, | |
| 3349 | }); | |
| 3350 | } | |
| 3351 | state.active = turn; | |
| 3352 | yield* emitProviderTurn(state, turn, providerTurn); | |
| 3353 | state.providerThread = { | |
| 3354 | ...state.providerThread, | |
| 3355 | status: "active", | |
| 3356 | firstRunOrdinal: state.providerThread.firstRunOrdinal ?? turnInput.runOrdinal, | |
| 3357 | lastRunOrdinal: turnInput.runOrdinal, | |
| 3358 | updatedAt: startedAt, | |
| 3359 | }; | |
| 3360 | yield* emit({ | |
| 3361 | type: "provider_thread.updated", | |
| 3362 | driver, | |
| 3363 | providerThread: state.providerThread, | |
| 3364 | }); | |
| 3365 | yield* setSessionStatus("running", null); | |
| 3366 | return turn; | |
| 3367 | }); | |
| 3368 | ||
| 3369 | /** Feeds a held execution's events to the turn now running on its session. */ | |
| 3370 | const replay = Effect.fnUntraced(function* (wake: Wake) { | |
| 3371 | wake.dropped = true; | |
| 3372 | for (const event of wake.events) yield* route(event, sessionOfEvent(event)); | |
| 3373 | }); | |
| 3374 | ||
| 3375 | /** | |
| 3376 | * A user turn that starts while OpenCode runs an execution on its own: the | |
| 3377 | * prompt joins that execution, so the turn takes it, and its continuation | |
| 3378 | * turn is no longer needed. Executions that already ended stay for theirs. | |
| 3379 | */ | |
| 3380 | const takeRunningWake = Effect.fnUntraced(function* (state: ThreadState) { | |
| 3381 | const wake = state.wakes.at(-1); | |
| 3382 | if (wake?.running !== true) return; | |
| 3383 | state.wakes.pop(); | |
| 3384 | yield* replay(wake); | |
| 3385 | }); | |
| 3386 | ||
| 3387 | /** | |
| 3388 | * The continuation turn for an execution OpenCode started on its own: it | |
| 3389 | * takes the oldest one held and ends with it. One already taken (a user | |
| 3390 | * turn joined it) leaves nothing to run, so the turn ends at once. | |
| 3391 | */ | |
| 3392 | const runWake = Effect.fnUntraced(function* ( | |
| 3393 | state: ThreadState, | |
| 3394 | turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, | |
| 3395 | ) { | |
| 3396 | yield* beginTurn(state, turnInput, false); | |
| 3397 | yield* lock.withPermit( | |
| 3398 | Effect.gen(function* () { | |
| 3399 | const wake = state.wakes.shift(); | |
| 3400 | if (wake === undefined) return yield* finishTurn(state, { status: "completed" }); | |
| 3401 | const turn = state.active; | |
| 3402 | if (turn !== undefined && wake.after !== undefined) turn.before = wake.after; | |
| 3403 | // Its first report is where its history begins, so fork and rollback cut there. | |
| 3404 | if (turn !== undefined && wake.first !== undefined) { | |
| 3405 | turn.providerTurn = { ...turn.providerTurn, nativeTurnRef: ref(wake.first, "weak") }; | |
| 3406 | } | |
| 3407 | yield* replay(wake); | |
| 3408 | }), | |
| 3409 | ); | |
| 3410 | }); | |
| 3411 | ||
| 3412 | /** | |
| 3413 | * Stops a thread's background subagents and any execution OpenCode is | |
| 3414 | * running on its own. Their sessions end as interrupted; the reports | |
| 3415 | * OpenCode then queues wake the parent into an execution that is stopped | |
| 3416 | * as well, so no turn takes it. A subagent the Stop did not reach runs on, | |
| 3417 | * so its call stays tracked: the session is not released under it, and | |
| 3418 | * the next Stop tries it again. | |
| 3419 | */ | |
| 3420 | const stopBackground = Effect.fnUntraced(function* (state: ThreadState) { | |
| 3421 | const calls = runningCalls(state).filter((call) => call.background); | |
| 3422 | // Every session under the thread, which a stopped subagent may report to. | |
| 3423 | const callers = sessionsOf(state); | |
| 3424 | // A subagent whose call ended runs again when OpenCode wakes it to | |
| 3425 | // answer a report of its own background subagent. | |
| 3426 | const followUps = callers.filter( | |
| 3427 | (caller) => | |
| 3428 | caller.subagent !== undefined && | |
| 3429 | busy.has(caller.sessionId) && | |
| 3430 | !isOrchestrationV2WorkActive(caller.subagent.call.status), | |
| 3431 | ); | |
| 3432 | // OpenCode announces a child's session before the call's progress names | |
| 3433 | // it, so a call without a child yet is stopped through its caller's | |
| 3434 | // announced children. | |
| 3435 | const announcedTo = (caller: ThreadState) => | |
| 3436 | [...announced.values()].flatMap((info) => | |
| 3437 | info.parentID === caller.sessionId ? [info.sessionID] : [], | |
| 3438 | ); | |
| 3439 | const childrenOf = (call: SubagentCall) => | |
| 3440 | call.child === undefined ? announcedTo(call.state) : [call.child.sessionId]; | |
| 3441 | // Each child reports to the session that called it, so its marker goes | |
| 3442 | // on that caller's state: the thread's own, or a subagent's for a nested one. | |
| 3443 | const children = new Map( | |
| 3444 | calls.flatMap((call) => childrenOf(call).map((childId) => [childId, call.state] as const)), | |
| 3445 | ); | |
| 3446 | const unreached = new Set<string>(); | |
| 3447 | for (const [childId, caller] of children) { | |
| 3448 | caller.stoppedChildren.add(childId); | |
| 3449 | const reached = yield* client.session | |
| 3450 | .interrupt({ sessionID: Session.ID.make(childId) }) | |
| 3451 | .pipe( | |
| 3452 | // A session that is gone runs nothing. | |
| 3453 | Effect.catchTags({ SessionNotFoundError: () => Effect.void }), | |
| 3454 | Effect.timeout(INTERRUPT_TIMEOUT), | |
| 3455 | Effect.tapCause((cause) => | |
| 3456 | Effect.logWarning("Could not stop an OpenCode subagent.", cause), | |
| 3457 | ), | |
| 3458 | Effect.exit, | |
| 3459 | Effect.map(Exit.isSuccess), | |
| 3460 | ); | |
| 3461 | if (!reached) unreached.add(childId); | |
| 3462 | } | |
| 3463 | // Its end closes its turn; one the Stop did not reach stays pending work. | |
| 3464 | for (const followUp of followUps) { | |
| 3465 | yield* client.session | |
| 3466 | .interrupt({ sessionID: Session.ID.make(followUp.sessionId) }) | |
| 3467 | .pipe( | |
| 3468 | Effect.catchTags({ SessionNotFoundError: () => Effect.void }), | |
| 3469 | Effect.timeout(INTERRUPT_TIMEOUT), | |
| 3470 | Effect.ignore({ log: true }), | |
| 3471 | ); | |
| 3472 | } | |
| 3473 | yield* lock.withPermit( | |
| 3474 | Effect.gen(function* () { | |
| 3475 | for (const call of calls) { | |
| 3476 | if (childrenOf(call).some((childId) => unreached.has(childId))) continue; | |
| 3477 | yield* settleCall(call, "interrupted"); | |
| 3478 | } | |
| 3479 | // A report already queued starts a follow-up the Stop must end too. | |
| 3480 | for (const caller of callers) { | |
| 3481 | for (const report of caller.reports.values()) { | |
| 3482 | caller.stoppedChildren.add(report.childId); | |
| 3483 | } | |
| 3484 | } | |
| 3485 | // A held wake no turn will take: its execution is stopped, not replayed. | |
| 3486 | const running = state.wakes.some((wake) => wake.running); | |
| 3487 | for (const wake of state.wakes.splice(0)) wake.dropped = true; | |
| 3488 | if (running) { | |
| 3489 | state.unsettled = true; | |
| 3490 | yield* client.session | |
| 3491 | .interrupt({ sessionID: Session.ID.make(state.sessionId) }) | |
| 3492 | .pipe(Effect.timeout(INTERRUPT_TIMEOUT), Effect.ignore({ log: true })); | |
| 3493 | } | |
| 3494 | }), | |
| 3495 | ); | |
| 3496 | }); | |
| 3497 | ||
| 3498 | /** | |
| 3499 | * What a turn sends: `/compact` compacts, `/name args` naming a workspace | |
| 3500 | * command runs it, and anything else is a prompt with the `$skill`s it | |
| 3501 | * names attached. Commands and skills are read from the session's | |
| 3502 | * directory only when the text could use them. A turn stopped meanwhile | |
| 3503 | * sends nothing. | |
| 3504 | */ | |
| 3505 | const submit = Effect.fnUntraced(function* ( | |
| 3506 | sessionId: string, | |
| 3507 | turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, | |
| 3508 | state: ThreadState, | |
| 3509 | turn: ActiveTurn, | |
| 3510 | ) { | |
| 3511 | const sessionID = Session.ID.make(sessionId); | |
| 3512 | /** Whether the turn still sends; past this point a Stop goes to the server. */ | |
| 3513 | const sending = () => { | |
| 3514 | if (state.active !== turn || turn.interrupted) return false; | |
| 3515 | turn.unsent = false; | |
| 3516 | return true; | |
| 3517 | }; | |
| 3518 | const text = turnInput.message.text.trim(); | |
| 3519 | const bare = turnInput.message.attachments.length === 0; | |
| 3520 | // The turn's own id, so fork and rollback cut before this turn's item. | |
| 3521 | const id = turnPromptId(sessionId, turnInput.attemptId); | |
| 3522 | if (bare && text === "/compact") { | |
| 3523 | if (!sending()) return; | |
| 3524 | return yield* client.session.compact({ sessionID, id }).pipe(Effect.asVoid); | |
| 3525 | } | |
| 3526 | const location = { directory: turnInput.runtimePolicy.cwd ?? serverConfig.cwd }; | |
| 3527 | const command = bare ? commandOf(text) : undefined; | |
| 3528 | if (command !== undefined) { | |
| 3529 | const commands = yield* client.command.list({ location }).pipe( | |
| 3530 | Effect.timeout(INVENTORY_TIMEOUT), | |
| 3531 | Effect.map((list) => list.data), | |
| 3532 | Effect.orElseSucceed(() => []), | |
| 3533 | ); | |
| 3534 | if (commands.some((entry) => entry.name === command.name)) { | |
| 3535 | // `session.command` takes no id of T3's and its answer carries none, | |
| 3536 | // so the turn remembers where the history stood before it. | |
| 3537 | const newest = yield* client.message.list({ sessionID, order: "desc", limit: 1 }); | |
| 3538 | yield* markBefore(sessionId, newest.data[0]?.id ?? null); | |
| 3539 | if (!sending()) return; | |
| 3540 | return yield* client.session.command({ sessionID, ...command }); | |
| 3541 | } | |
| 3542 | } | |
| 3543 | const skills = SKILL_MENTION.test(text) | |
| 3544 | ? skillsNamed( | |
| 3545 | text, | |
| 3546 | yield* client.skill.list({ location }).pipe( | |
| 3547 | Effect.timeout(INVENTORY_TIMEOUT), | |
| 3548 | Effect.map((list) => new Set(list.data.map((skill) => skill.id))), | |
| 3549 | Effect.orElseSucceed(() => new Set<string>()), | |
| 3550 | ), | |
| 3551 | ) | |
| 3552 | : []; | |
| 3553 | if (!sending()) return; | |
| 3554 | return yield* client.session | |
| 3555 | .prompt({ | |
| 3556 | sessionID, | |
| 3557 | id, | |
| 3558 | text: promptText(turnInput), | |
| 3559 | ...(skills.length === 0 | |
| 3560 | ? {} | |
| 3561 | : { skills: skills.map((id) => ({ id: Skill.ID.make(id) })) }), | |
| 3562 | }) | |
| 3563 | .pipe(Effect.asVoid); | |
| 3564 | }); | |
| 3565 | ||
| 3566 | const runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime = { | |
| 3567 | instanceId, | |
| 3568 | driver, | |
| 3569 | providerSessionId: input.providerSessionId, | |
| 3570 | get providerSession() { | |
| 3571 | return session; | |
| 3572 | }, | |
| 3573 | events: Stream.fromQueue(events), | |
| 3574 | // A background subagent keeps its session busy after its parent's turn, | |
| 3575 | // and a held wake still needs its turn: idle release must wait for both. | |
| 3576 | hasPendingBackgroundWork: Effect.sync(() => [...threads.values()].some(owesWork)), | |
| 3577 | hasPendingBackgroundWorkForThread: (providerThread) => | |
| 3578 | Effect.sync(() => { | |
| 3579 | const nativeId = providerThread.nativeThreadRef?.nativeId; | |
| 3580 | const state = nativeId == null ? undefined : threads.get(nativeId); | |
| 3581 | return state !== undefined && hasBackground(state); | |
| 3582 | }), | |
| 3583 | // A caller that names no directory gets the one this session opened in. | |
| 3584 | getModelContextWindow: (selection, cwd) => | |
| 3585 | selection.instanceId === instanceId | |
| 3586 | ? windowOf(cwd === undefined ? session.cwd : cwd, selection.model) | |
| 3587 | : undefined, | |
| 3588 | ensureThread: (threadInput) => | |
| 3589 | Effect.gen(function* () { | |
| 3590 | if (threadInput.existingProviderThread?.nativeThreadRef != null) { | |
| 3591 | return yield* runtime.resumeThread({ | |
| 3592 | providerThread: threadInput.existingProviderThread, | |
| 3593 | threadId: threadInput.threadId, | |
| 3594 | modelSelection: threadInput.modelSelection, | |
| 3595 | runtimePolicy: threadInput.runtimePolicy, | |
| 3596 | }); | |
| 3597 | } | |
| 3598 | yield* readModelsOnce(threadInput.runtimePolicy.cwd); | |
| 3599 | const model = modelRef(threadInput.modelSelection); | |
| 3600 | if (model === undefined) { | |
| 3601 | return yield* new ProviderAdapter.ProviderAdapterProtocolError({ | |
| 3602 | driver: OPENCODE_PROVIDER, | |
| 3603 | detail: malformedModel(threadInput.modelSelection.model), | |
| 3604 | }); | |
| 3605 | } | |
| 3606 | const directory = threadInput.runtimePolicy.cwd ?? serverConfig.cwd; | |
| 3607 | const policy = threadInput.runtimePolicy; | |
| 3608 | // A new session runs OpenCode's default agent. | |
| 3609 | const permissions = yield* rulesFor( | |
| 3610 | { directory, agent: "build", grants: [] }, | |
| 3611 | policy, | |
| 3612 | threadInput.threadId, | |
| 3613 | ); | |
| 3614 | const created = yield* client.session.create({ | |
| 3615 | location: Location.PublicRef.make({ directory: AbsolutePath.make(directory) }), | |
| 3616 | model, | |
| 3617 | permissions, | |
| 3618 | }); | |
| 3619 | const createdAt = yield* DateTime.now; | |
| 3620 | const providerThread: OrchestrationV2ProviderThread = { | |
| 3621 | ...(threadInput.existingProviderThread ?? { | |
| 3622 | id: idAllocator.derive.providerThread({ driver, nativeThreadId: created.id }), | |
| 3623 | driver, | |
| 3624 | providerInstanceId: instanceId, | |
| 3625 | appThreadId: threadInput.threadId, | |
| 3626 | ownerNodeId: null, | |
| 3627 | firstRunOrdinal: null, | |
| 3628 | lastRunOrdinal: null, | |
| 3629 | handoffIds: [], | |
| 3630 | forkedFrom: null, | |
| 3631 | createdAt, | |
| 3632 | }), | |
| 3633 | providerSessionId: input.providerSessionId, | |
| 3634 | nativeThreadRef: ref(created.id), | |
| 3635 | nativeConversationHeadRef: null, | |
| 3636 | status: "idle", | |
| 3637 | updatedAt: createdAt, | |
| 3638 | }; | |
| 3639 | const state = register( | |
| 3640 | providerThread, | |
| 3641 | { id: created.id, model: created.model, agent: created.agent, permissions }, | |
| 3642 | directory, | |
| 3643 | ); | |
| 3644 | state.policy = policy; | |
| 3645 | return state.providerThread; | |
| 3646 | }).pipe( | |
| 3647 | Effect.mapError((cause) => | |
| 3648 | isProviderAdapterError(cause) | |
| 3649 | ? cause | |
| 3650 | : new ProviderAdapter.ProviderAdapterEnsureThreadError({ | |
| 3651 | driver, | |
| 3652 | threadId: threadInput.threadId, | |
| 3653 | cause, | |
| 3654 | }), | |
| 3655 | ), | |
| 3656 | ), | |
| 3657 | resumeThread: (threadInput) => | |
| 3658 | Effect.gen(function* () { | |
| 3659 | const sessionId = yield* sessionIdOf(threadInput.providerThread); | |
| 3660 | if (threadInput.runtimePolicy !== undefined) { | |
| 3661 | yield* readModelsOnce(threadInput.runtimePolicy.cwd); | |
| 3662 | } | |
| 3663 | // 1.x session ids survive the upgrade; a server without this session | |
| 3664 | // fails the resume, so T3 recreates the thread with a handoff. | |
| 3665 | const native = yield* client.session.get({ sessionID: Session.ID.make(sessionId) }); | |
| 3666 | const providerThread: OrchestrationV2ProviderThread = { | |
| 3667 | ...threadInput.providerThread, | |
| 3668 | providerSessionId: input.providerSessionId, | |
| 3669 | status: "idle", | |
| 3670 | updatedAt: yield* DateTime.now, | |
| 3671 | }; | |
| 3672 | const cwd = threadInput.runtimePolicy?.cwd; | |
| 3673 | const loaded = threads.has(sessionId); | |
| 3674 | const state = register(providerThread, native, cwd ?? native.location.directory); | |
| 3675 | // OpenCode keeps no request across its own restart, but a server that | |
| 3676 | // outlived T3 may still wait on one T3 no longer shows. | |
| 3677 | if (!loaded) yield* stopLeftoverRequests(state); | |
| 3678 | // The session gets the rules for this thread's mode: it may have run | |
| 3679 | // another mode, or been made by 1.x or an earlier build. | |
| 3680 | yield* writeRules(state, threadInput.runtimePolicy ?? state.policy); | |
| 3681 | // A thread moved to another worktree takes its session with it. | |
| 3682 | if (cwd != null && native.location.directory !== cwd) { | |
| 3683 | yield* client.session.move({ | |
| 3684 | sessionID: Session.ID.make(sessionId), | |
| 3685 | directory: AbsolutePath.make(cwd), | |
| 3686 | }); | |
| 3687 | } | |
| 3688 | return state.providerThread; | |
| 3689 | }).pipe( | |
| 3690 | Effect.mapError((cause) => | |
| 3691 | isProviderAdapterError(cause) | |
| 3692 | ? cause | |
| 3693 | : new ProviderAdapter.ProviderAdapterResumeThreadError({ | |
| 3694 | driver, | |
| 3695 | providerSessionId: input.providerSessionId, | |
| 3696 | providerThreadId: threadInput.providerThread.id, | |
| 3697 | cause, | |
| 3698 | }), | |
| 3699 | ), | |
| 3700 | ), | |
| 3701 | // `/compact` is its own turn, which `startTurn` sends as `session.compact`. | |
| 3702 | compactThread: (turnInput) => | |
| 3703 | runtime.startTurn({ ...turnInput, message: { ...turnInput.message, text: "/compact" } }), | |
| 3704 | startTurn: (turnInput) => | |
| 3705 | Effect.gen(function* () { | |
| 3706 | // A lost stream is reconnecting: wait for it rather than prompt into | |
| 3707 | // a dead stream, and refuse the turn before any request if it gave up. | |
| 3708 | // Waited out before the session's gate, so a rollback or fork is not | |
| 3709 | // held behind a reconnect. | |
| 3710 | if (streamFailure !== undefined) { | |
| 3711 | yield* Deferred.await(reconnected).pipe(Effect.timeout(RECONNECT_WAIT), Effect.ignore); | |
| 3712 | } | |
| 3713 | if (streamFailure !== undefined) { | |
| 3714 | return yield* new ProviderAdapter.ProviderAdapterEventStreamError({ | |
| 3715 | driver, | |
| 3716 | providerSessionId: input.providerSessionId, | |
| 3717 | cause: streamFailure, | |
| 3718 | }); | |
| 3719 | } | |
| 3720 | // Its checks and setup take the session's gate, so a rollback or | |
| 3721 | // fork sees either no turn or this one installed; the prompt after | |
| 3722 | // it does not hold the gate. | |
| 3723 | const started = yield* exclusive(turnInput.providerThread)( | |
| 3724 | Effect.gen(function* () { | |
| 3725 | const sessionId = yield* sessionIdOf(turnInput.providerThread); | |
| 3726 | const state = threads.get(sessionId); | |
| 3727 | if (state === undefined) { | |
| 3728 | return yield* new ProviderAdapter.ProviderAdapterProtocolError({ | |
| 3729 | driver: OPENCODE_PROVIDER, | |
| 3730 | detail: `OpenCode session ${sessionId} is not registered`, | |
| 3731 | }); | |
| 3732 | } | |
| 3733 | if (state.active !== undefined) { | |
| 3734 | return yield* new ProviderAdapter.ProviderAdapterProtocolError({ | |
| 3735 | driver: OPENCODE_PROVIDER, | |
| 3736 | detail: `OpenCode session ${sessionId} already has an active turn`, | |
| 3737 | }); | |
| 3738 | } | |
| 3739 | // OpenCode already ran this turn on its own; it prompts nothing. | |
| 3740 | if (isContinuation(turnInput)) return yield* runWake(state, turnInput); | |
| 3741 | // After a timed-out Stop the server says whether that run is gone. A | |
| 3742 | // run still going is stopped again and this turn fails so it can be | |
| 3743 | // sent again; a run that is gone may still have its end on the | |
| 3744 | // stream, which the turn skips. | |
| 3745 | const afterUnsettled = state.unsettled; | |
| 3746 | let stillStopping = false; | |
| 3747 | if (state.unsettled) { | |
| 3748 | const active = yield* client.session | |
| 3749 | .active() | |
| 3750 | .pipe(Effect.timeout(ACTIVE_CHECK_TIMEOUT)); | |
| 3751 | stillStopping = sessionId in active; | |
| 3752 | if (stillStopping) { | |
| 3753 | yield* client.session | |
| 3754 | .interrupt({ sessionID: Session.ID.make(sessionId) }) | |
| 3755 | .pipe(Effect.timeout(INTERRUPT_TIMEOUT), Effect.ignore({ log: true })); | |
| 3756 | } else { | |
| 3757 | state.unsettled = false; | |
| 3758 | } | |
| 3759 | } | |
| 3760 | // Installs the turn; every path after it ends the turn with a terminal. | |
| 3761 | const begin = beginTurn(state, turnInput, afterUnsettled); | |
| 3762 | if (stillStopping) { | |
| 3763 | yield* begin; | |
| 3764 | return yield* finishTurn(state, { | |
| 3765 | status: "failed", | |
| 3766 | failure: makeProviderFailure({ | |
| 3767 | message: OPENCODE_2_STILL_STOPPING, | |
| 3768 | class: "provider_error", | |
| 3769 | }), | |
| 3770 | }); | |
| 3771 | } | |
| 3772 | // A turn T3 will not run still starts and fails, so the refusal is what | |
| 3773 | // the user reads. | |
| 3774 | const model = modelRef(turnInput.modelSelection); | |
| 3775 | if (model === undefined) { | |
| 3776 | yield* begin; | |
| 3777 | return yield* finishTurn(state, { | |
| 3778 | status: "failed", | |
| 3779 | failure: makeProviderFailure({ | |
| 3780 | message: malformedModel(turnInput.modelSelection.model), | |
| 3781 | class: "validation_error", | |
| 3782 | }), | |
| 3783 | }); | |
| 3784 | } | |
| 3785 | // Plan mode is OpenCode's `plan` agent, switched before the rules are | |
| 3786 | // written so they keep that agent's own path allows. Switching to or | |
| 3787 | // from `plan` queues OpenCode's own "Plan mode" reminder for the prompt. | |
| 3788 | const agent = agentFor(turnInput); | |
| 3789 | if (agent !== state.agent) { | |
| 3790 | yield* client.session.switchAgent({ | |
| 3791 | sessionID: Session.ID.make(sessionId), | |
| 3792 | agent: Agent.ID.make(agent), | |
| 3793 | }); | |
| 3794 | state.agent = agent; | |
| 3795 | } | |
| 3796 | // The thread's mode may have changed since the session was loaded, | |
| 3797 | // and its subagents still running hold the rules they started with. | |
| 3798 | // Those run on whether or not this turn starts, so theirs are best effort. | |
| 3799 | yield* writeRules(state, turnInput.runtimePolicy); | |
| 3800 | for (const call of runningCalls(state)) { | |
| 3801 | if (call.child === undefined) continue; | |
| 3802 | yield* writeRules(call.child, turnInput.runtimePolicy).pipe( | |
| 3803 | Effect.timeout(REQUEST_REPLY_TIMEOUT), | |
| 3804 | Effect.ignore({ log: true }), | |
| 3805 | ); | |
| 3806 | } | |
| 3807 | // A selection changed since the last turn applies now; OpenCode keeps | |
| 3808 | // the session's model otherwise. | |
| 3809 | if (!sameModel(model, state.model)) { | |
| 3810 | yield* client.session.switchModel({ sessionID: Session.ID.make(sessionId), model }); | |
| 3811 | state.model = model; | |
| 3812 | } | |
| 3813 | // Steers a stopped turn never delivered would reach the model first, | |
| 3814 | // and a revert a failed rollback left staged would be committed. | |
| 3815 | yield* cancelStrandedSteers(state); | |
| 3816 | if (stagedReverts.has(sessionId)) yield* clearRevert(sessionId); | |
| 3817 | yield* prepareTurn(sessionId, state, turnInput); | |
| 3818 | const turn = yield* begin; | |
| 3819 | turn.unsent = true; | |
| 3820 | // An execution OpenCode is running on its own takes this prompt at | |
| 3821 | // its next step, so this turn is that execution from here on. | |
| 3822 | yield* lock.withPermit(takeRunningWake(state)); | |
| 3823 | return { state, sessionId, turn }; | |
| 3824 | }), | |
| 3825 | ); | |
| 3826 | if (started === undefined) return; | |
| 3827 | const { state, sessionId, turn } = started; | |
| 3828 | yield* submit(sessionId, turnInput, state, turn).pipe( | |
| 3829 | // Deleted outside T3: the thread is broken, and forgetting it makes | |
| 3830 | // the next turn resume, fail, and recreate it with a handoff. | |
| 3831 | Effect.catchTags({ | |
| 3832 | SessionNotFoundError: () => | |
| 3833 | finishTurn( | |
| 3834 | state, | |
| 3835 | { | |
| 3836 | status: "failed", | |
| 3837 | failure: makeProviderFailure({ | |
| 3838 | message: | |
| 3839 | "The OpenCode session no longer exists. Send the message again to continue in a new session.", | |
| 3840 | class: "provider_error", | |
| 3841 | }), | |
| 3842 | }, | |
| 3843 | "broken", | |
| 3844 | ).pipe(Effect.andThen(Effect.sync(() => threads.delete(sessionId)))), | |
| 3845 | }), | |
| 3846 | Effect.tapError((cause) => | |
| 3847 | state.active === turn | |
| 3848 | ? Effect.gen(function* () { | |
| 3849 | // Without a clear rejection the server may have taken the | |
| 3850 | // prompt, so the next turn checks before it prompts again. | |
| 3851 | if (!CLEAR_PROMPT_REJECTIONS.has(cause._tag)) state.unsettled = true; | |
| 3852 | yield* finishTurn(state, { | |
| 3853 | status: "failed", | |
| 3854 | failure: makeProviderFailure({ cause, class: "provider_error" }), | |
| 3855 | }); | |
| 3856 | }) | |
| 3857 | : Effect.void, | |
| 3858 | ), | |
| 3859 | ); | |
| 3860 | }).pipe( | |
| 3861 | Effect.mapError((cause) => | |
| 3862 | isProviderAdapterError(cause) | |
| 3863 | ? cause | |
| 3864 | : new ProviderAdapter.ProviderAdapterTurnStartError({ | |
| 3865 | driver, | |
| 3866 | threadId: turnInput.threadId, | |
| 3867 | providerThreadId: turnInput.providerThread.id, | |
| 3868 | runId: turnInput.runId, | |
| 3869 | cause, | |
| 3870 | }), | |
| 3871 | ), | |
| 3872 | ), | |
| 3873 | steerTurn: (steerInput) => | |
| 3874 | Effect.gen(function* () { | |
| 3875 | const sessionId = yield* sessionIdOf(steerInput.providerThread); | |
| 3876 | const state = threads.get(sessionId); | |
| 3877 | const turn = state?.active; | |
| 3878 | if ( | |
| 3879 | state === undefined || | |
| 3880 | turn === undefined || | |
| 3881 | turn.providerTurn.id !== steerInput.providerTurnId || | |
| 3882 | turn.interrupted | |
| 3883 | ) { | |
| 3884 | return yield* new ProviderAdapter.ProviderAdapterProtocolError({ | |
| 3885 | driver: OPENCODE_PROVIDER, | |
| 3886 | detail: `OpenCode turn ${steerInput.providerTurnId} is not active`, | |
| 3887 | }); | |
| 3888 | } | |
| 3889 | const inboxID = steerPromptId(sessionId, steerInput.message.messageId); | |
| 3890 | // A retried steer that already reached the model is not sent again. | |
| 3891 | if (turn.settledInbox.has(inboxID)) return; | |
| 3892 | turn.steers.add(inboxID); | |
| 3893 | state.strandedSteers.delete(inboxID); | |
| 3894 | yield* client.session | |
| 3895 | .prompt({ | |
| 3896 | sessionID: Session.ID.make(sessionId), | |
| 3897 | id: inboxID, | |
| 3898 | text: providerMessageTextWithAttachmentPaths({ | |
| 3899 | text: steerInput.message.text, | |
| 3900 | attachments: steerInput.message.attachments, | |
| 3901 | resolveAttachmentPath: (attachment) => | |
| 3902 | resolveAttachmentPath({ | |
| 3903 | attachmentsDir: serverConfig.attachmentsDir, | |
| 3904 | attachment, | |
| 3905 | }), | |
| 3906 | }).trim(), | |
| 3907 | delivery: "steer", | |
| 3908 | }) | |
| 3909 | .pipe( | |
| 3910 | Effect.tapError((cause) => | |
| 3911 | lock.withPermit( | |
| 3912 | Effect.gen(function* () { | |
| 3913 | turn.steers.delete(inboxID); | |
| 3914 | // Without a clear refusal OpenCode may hold it; the next prompt takes it back. | |
| 3915 | if (!CLEAR_PROMPT_REJECTIONS.has(cause._tag)) state.strandedSteers.add(inboxID); | |
| 3916 | if (state.active === turn) yield* endIfSettled(state, turn); | |
| 3917 | }), | |
| 3918 | ), | |
| 3919 | ), | |
| 3920 | ); | |
| 3921 | }).pipe( | |
| 3922 | Effect.mapError((cause) => | |
| 3923 | isProviderAdapterError(cause) | |
| 3924 | ? cause | |
| 3925 | : new ProviderAdapter.ProviderAdapterSteerRunError({ | |
| 3926 | driver, | |
| 3927 | providerThreadId: steerInput.providerThread.id, | |
| 3928 | providerTurnId: steerInput.providerTurnId, | |
| 3929 | cause, | |
| 3930 | }), | |
| 3931 | ), | |
| 3932 | ), | |
| 3933 | interruptTurn: (interruptInput) => | |
| 3934 | Effect.gen(function* () { | |
| 3935 | const sessionId = yield* sessionIdOf(interruptInput.providerThread); | |
| 3936 | const state = threads.get(sessionId); | |
| 3937 | if (state === undefined) return; | |
| 3938 | const turn = state.active; | |
| 3939 | // OpenCode stops a foreground subagent with its parent, but not a | |
| 3940 | // background one: a user Stop (`requestRuntimeRestart`) stops those | |
| 3941 | // too, and the execution OpenCode starts to report them. A turn | |
| 3942 | // interrupted to restart it with new input leaves them running. | |
| 3943 | if (interruptInput.requestRuntimeRestart === true) yield* stopBackground(state); | |
| 3944 | if (turn === undefined || turn.providerTurn.id !== interruptInput.providerTurnId) { | |
| 3945 | return; | |
| 3946 | } | |
| 3947 | // The session answers with `session.execution.interrupted`, which ends | |
| 3948 | // the turn. A server that does not answer in time is stuck, so the turn | |
| 3949 | // ends here instead of waiting on it. | |
| 3950 | turn.interrupted = true; | |
| 3951 | const reply = yield* client.session | |
| 3952 | .interrupt({ sessionID: Session.ID.make(sessionId) }) | |
| 3953 | .pipe( | |
| 3954 | Effect.timeoutOption(INTERRUPT_TIMEOUT), | |
| 3955 | // A Stop that never reached the server stopped nothing. | |
| 3956 | Effect.tapError(() => | |
| 3957 | Effect.sync(() => { | |
| 3958 | turn.interrupted = false; | |
| 3959 | }), | |
| 3960 | ), | |
| 3961 | ); | |
| 3962 | if (reply._tag === "None") { | |
| 3963 | state.unsettled = true; | |
| 3964 | return yield* lock.withPermit(finishTurn(state, { status: "interrupted" })); | |
| 3965 | } | |
| 3966 | // Between the executions a late steer spans nothing runs yet. The | |
| 3967 | // steers are taken back, and a run they already started is skipped. | |
| 3968 | if (!reply.value.interrupted && state.active === turn && turn.heldEnd !== undefined) { | |
| 3969 | state.unsettled = true; | |
| 3970 | yield* lock.withPermit(finishTurn(state, { status: "interrupted" })); | |
| 3971 | return yield* cancelStrandedSteers(state); | |
| 3972 | } | |
| 3973 | // Nothing was running because the turn has not sent its request yet | |
| 3974 | // (it is reading the workspace's commands or skills): it ends here, | |
| 3975 | // and never sends it. | |
| 3976 | if (!reply.value.interrupted && state.active === turn && turn.unsent) { | |
| 3977 | return yield* lock.withPermit(finishTurn(state, { status: "interrupted" })); | |
| 3978 | } | |
| 3979 | // Nothing was running. Unless the execution already ended (its event | |
| 3980 | // is on the way), the turn is still open and nothing stopped. | |
| 3981 | if (!reply.value.interrupted && state.active === turn) { | |
| 3982 | turn.interrupted = false; | |
| 3983 | return yield* new ProviderAdapter.ProviderAdapterProtocolError({ | |
| 3984 | driver: OPENCODE_PROVIDER, | |
| 3985 | detail: `OpenCode session ${sessionId} had nothing running to stop`, | |
| 3986 | }); | |
| 3987 | } | |
| 3988 | }).pipe( | |
| 3989 | Effect.mapError((cause) => | |
| 3990 | isProviderAdapterError(cause) | |
| 3991 | ? cause | |
| 3992 | : new ProviderAdapter.ProviderAdapterInterruptError({ | |
| 3993 | driver, | |
| 3994 | providerThreadId: interruptInput.providerThread.id, | |
| 3995 | providerTurnId: interruptInput.providerTurnId, | |
| 3996 | cause, | |
| 3997 | }), | |
| 3998 | ), | |
| 3999 | ), | |
| 4000 | unloadThread: ({ providerThread }) => | |
| 4001 | Effect.gen(function* () { | |
| 4002 | const nativeId = providerThread.nativeThreadRef?.nativeId; | |
| 4003 | const state = nativeId == null ? undefined : threads.get(nativeId); | |
| 4004 | if ( | |
| 4005 | nativeId == null || | |
| 4006 | state === undefined || | |
| 4007 | state.active !== undefined || | |
| 4008 | hasBackground(state) | |
| 4009 | ) { | |
| 4010 | return; | |
| 4011 | } | |
| 4012 | threads.delete(nativeId); | |
| 4013 | for (const [child, owner] of childOwners) { | |
| 4014 | if (owner !== state) continue; | |
| 4015 | childOwners.delete(child); | |
| 4016 | threads.delete(child); | |
| 4017 | } | |
| 4018 | if (state.mcp !== undefined) yield* removeMcp(state.mcp); | |
| 4019 | }), | |
| 4020 | respondToRuntimeRequest: (requestInput) => | |
| 4021 | Effect.gen(function* () { | |
| 4022 | const entry = pending.get(requestInput.requestId); | |
| 4023 | if (entry === undefined || entry.answering) { | |
| 4024 | return yield* new ProviderAdapter.ProviderAdapterProtocolError({ | |
| 4025 | driver, | |
| 4026 | detail: `No pending OpenCode request ${requestInput.requestId}`, | |
| 4027 | }); | |
| 4028 | } | |
| 4029 | const { decision, answers } = requestInput; | |
| 4030 | const { native } = entry; | |
| 4031 | if (native.type === "permission" && decision === undefined) { | |
| 4032 | return yield* new ProviderAdapter.ProviderAdapterProtocolError({ | |
| 4033 | driver, | |
| 4034 | detail: `OpenCode approval request ${requestInput.requestId} requires a decision`, | |
| 4035 | }); | |
| 4036 | } | |
| 4037 | entry.answering = true; | |
| 4038 | // OpenCode's own "always" saves a grant for the whole project, so a | |
| 4039 | // session-wide answer is a rule on this session instead. The grant | |
| 4040 | // is best effort: this request is answered either way. | |
| 4041 | // The session that asked waits on the answer and holds the grant: a | |
| 4042 | // subagent's own, not its parent's. | |
| 4043 | const asker = threads.get(entry.sessionId) ?? entry.state; | |
| 4044 | if ( | |
| 4045 | native.type === "permission" && | |
| 4046 | (decision === "acceptForSession" || decision === "acceptAlways") | |
| 4047 | ) { | |
| 4048 | const state = asker; | |
| 4049 | for (const resource of native.save) { | |
| 4050 | if ( | |
| 4051 | !state.grants.some( | |
| 4052 | (grant) => grant.action === native.action && grant.resource === resource, | |
| 4053 | ) | |
| 4054 | ) { | |
| 4055 | state.grants.push({ action: native.action, resource, effect: "allow" }); | |
| 4056 | } | |
| 4057 | } | |
| 4058 | // A subagent runs under its thread's mode. | |
| 4059 | yield* writeRules(state, rootOf(state).policy).pipe(Effect.ignore({ log: true })); | |
| 4060 | } | |
| 4061 | const sessionID = Session.ID.make(entry.sessionId); | |
| 4062 | // Best effort: the decline stands without the note. | |
| 4063 | if (native.type === "permission" && decision === "decline") { | |
| 4064 | yield* client.session | |
| 4065 | .synthetic({ | |
| 4066 | sessionID, | |
| 4067 | text: declinedNote(native.action, native.resources), | |
| 4068 | delivery: "steer", | |
| 4069 | resume: false, | |
| 4070 | }) | |
| 4071 | .pipe(Effect.timeout(REQUEST_REPLY_TIMEOUT), Effect.ignore({ log: true })); | |
| 4072 | } | |
| 4073 | const delivered = yield* native.type === "permission" | |
| 4074 | ? deliver( | |
| 4075 | client.permission | |
| 4076 | .reply({ | |
| 4077 | sessionID, | |
| 4078 | requestID: Permission.ID.make(native.id), | |
| 4079 | decision: decision !== undefined && approves(decision) ? "once" : "reject", | |
| 4080 | // Without a message OpenCode ends the whole run, which is Cancel. | |
| 4081 | ...(decision === "decline" ? { message: DECLINED } : {}), | |
| 4082 | }) | |
| 4083 | .pipe(Effect.catchTags(permissionGone)), | |
| 4084 | ) | |
| 4085 | : deliver( | |
| 4086 | (answers === undefined || decision === "decline" || decision === "cancel" | |
| 4087 | ? client.session.form.cancel({ | |
| 4088 | sessionID: entry.sessionId, | |
| 4089 | formID: Form.ID.make(native.id), | |
| 4090 | }) | |
| 4091 | : client.session.form.reply({ | |
| 4092 | sessionID: entry.sessionId, | |
| 4093 | formID: Form.ID.make(native.id), | |
| 4094 | answer: formAnswer(native.form, answers), | |
| 4095 | }) | |
| 4096 | ).pipe(Effect.catchTags(formGone)), | |
| 4097 | ); | |
| 4098 | yield* forgetRequest(entry); | |
| 4099 | if (!delivered) yield* abandonRequest(asker, "answer not delivered"); | |
| 4100 | }).pipe( | |
| 4101 | Effect.mapError((cause) => | |
| 4102 | isProviderAdapterError(cause) | |
| 4103 | ? cause | |
| 4104 | : new ProviderAdapter.ProviderAdapterRuntimeRequestResponseError({ | |
| 4105 | driver, | |
| 4106 | requestId: requestInput.requestId, | |
| 4107 | cause, | |
| 4108 | }), | |
| 4109 | ), | |
| 4110 | ), | |
| 4111 | readThreadSnapshot: ({ providerThread }) => | |
| 4112 | Effect.gen(function* () { | |
| 4113 | const sessionId = yield* sessionIdOf(providerThread); | |
| 4114 | return yield* snapshotOf(providerThread, sessionId); | |
| 4115 | }).pipe( | |
| 4116 | Effect.mapError((cause) => | |
| 4117 | isProviderAdapterError(cause) | |
| 4118 | ? cause | |
| 4119 | : new ProviderAdapter.ProviderAdapterReadThreadSnapshotError({ | |
| 4120 | driver, | |
| 4121 | providerThreadId: providerThread.id, | |
| 4122 | cause, | |
| 4123 | }), | |
| 4124 | ), | |
| 4125 | ), | |
| 4126 | rollbackThread: (rollbackInput) => | |
| 4127 | Effect.gen(function* () { | |
| 4128 | const { providerThread, target } = rollbackInput; | |
| 4129 | const sessionId = yield* sessionIdOf(providerThread); | |
| 4130 | // A stopped run's end may still be on the stream, so `unsettled` | |
| 4131 | // stays for the next turn to skip it. | |
| 4132 | yield* ensureQuiet(sessionId, "roll back"); | |
| 4133 | // A session this runtime has not loaded yet is loaded the way a turn | |
| 4134 | // would load it, which stops anything it still waits on. Requests it | |
| 4135 | // stops end their run, so the server is asked again after that. | |
| 4136 | if (!threads.has(sessionId)) { | |
| 4137 | yield* runtime.resumeThread({ providerThread }); | |
| 4138 | yield* ensureQuiet(sessionId, "roll back"); | |
| 4139 | } | |
| 4140 | const boundary = yield* boundaryAfter( | |
| 4141 | rollbackInput.providerThreadTurns, | |
| 4142 | providerThread.id, | |
| 4143 | target.type === "provider_turn" ? target.providerTurn : undefined, | |
| 4144 | yield* userMessages(sessionId), | |
| 4145 | ); | |
| 4146 | if (boundary !== null) { | |
| 4147 | yield* rollBackTo(sessionId, SessionMessage.ID.make(boundary)); | |
| 4148 | } | |
| 4149 | // The turns after the target left the history with their messages. | |
| 4150 | // The snapshot lists the ones kept as T3 recorded them, which a | |
| 4151 | // runtime that loaded the session after they ran never saw. | |
| 4152 | const keptOrdinal = target.type === "provider_turn" ? target.providerTurn.ordinal : 0; | |
| 4153 | const loaded = threads.get(sessionId); | |
| 4154 | for (const [id, turn] of loaded?.providerTurns ?? []) { | |
| 4155 | if (turn.ordinal > keptOrdinal) loaded?.providerTurns.delete(id); | |
| 4156 | } | |
| 4157 | const snapshot = yield* snapshotOf(providerThread, sessionId); | |
| 4158 | const kept = new Map( | |
| 4159 | rollbackInput.providerThreadTurns | |
| 4160 | .filter( | |
| 4161 | (turn) => | |
| 4162 | turn.providerThreadId === providerThread.id && turn.ordinal <= keptOrdinal, | |
| 4163 | ) | |
| 4164 | .map((turn) => [turn.id, turn] as const), | |
| 4165 | ); | |
| 4166 | for (const turn of snapshot.providerTurns) kept.set(turn.id, turn); | |
| 4167 | // The snapshot's head is the last user message OpenCode kept. | |
| 4168 | return { ...snapshot, providerTurns: [...kept.values()] }; | |
| 4169 | }).pipe( | |
| 4170 | exclusive(rollbackInput.providerThread), | |
| 4171 | Effect.mapError((cause) => | |
| 4172 | isProviderAdapterError(cause) | |
| 4173 | ? cause | |
| 4174 | : new ProviderAdapter.ProviderAdapterRollbackThreadError({ | |
| 4175 | driver, | |
| 4176 | providerThreadId: rollbackInput.providerThread.id, | |
| 4177 | checkpointId: rollbackInput.target.checkpointId, | |
| 4178 | cause, | |
| 4179 | }), | |
| 4180 | ), | |
| 4181 | ), | |
| 4182 | forkThread: (forkInput) => | |
| 4183 | Effect.gen(function* () { | |
| 4184 | const source = forkInput.sourceProviderThread; | |
| 4185 | const sourceId = yield* sessionIdOf(source); | |
| 4186 | // OpenCode forks whatever history the source has, so a fork taken | |
| 4187 | // while anything writes to it would copy a run half done. | |
| 4188 | yield* ensureQuiet(sourceId, "fork"); | |
| 4189 | const selected = | |
| 4190 | forkInput.providerTurnId === undefined | |
| 4191 | ? undefined | |
| 4192 | : forkInput.sourceProviderTurns?.find((turn) => turn.id === forkInput.providerTurnId); | |
| 4193 | if (forkInput.providerTurnId !== undefined && selected === undefined) { | |
| 4194 | return yield* new ProviderAdapter.ProviderAdapterProtocolError({ | |
| 4195 | driver: OPENCODE_PROVIDER, | |
| 4196 | detail: `OpenCode fork boundary turn ${forkInput.providerTurnId} was not found`, | |
| 4197 | }); | |
| 4198 | } | |
| 4199 | const before = | |
| 4200 | selected === undefined | |
| 4201 | ? null | |
| 4202 | : yield* boundaryAfter( | |
| 4203 | forkInput.sourceProviderTurns ?? [], | |
| 4204 | source.id, | |
| 4205 | selected, | |
| 4206 | yield* userMessages(sourceId), | |
| 4207 | ); | |
| 4208 | // A fork copies the source's history, model, location and rules; its | |
| 4209 | // message ids are new, so it has no turns T3 could cut at yet. | |
| 4210 | const forked = yield* client.session.fork({ | |
| 4211 | sessionID: Session.ID.make(sourceId), | |
| 4212 | ...(before === null ? {} : { before: SessionMessage.ID.make(before) }), | |
| 4213 | }); | |
| 4214 | const createdAt = yield* DateTime.now; | |
| 4215 | const providerThread: OrchestrationV2ProviderThread = { | |
| 4216 | id: idAllocator.derive.providerThread({ driver, nativeThreadId: forked.id }), | |
| 4217 | driver, | |
| 4218 | providerInstanceId: instanceId, | |
| 4219 | providerSessionId: input.providerSessionId, | |
| 4220 | appThreadId: forkInput.targetThreadId, | |
| 4221 | ownerNodeId: forkInput.ownerNodeId ?? null, | |
| 4222 | nativeThreadRef: ref(forked.id), | |
| 4223 | nativeConversationHeadRef: null, | |
| 4224 | status: "idle", | |
| 4225 | firstRunOrdinal: null, | |
| 4226 | lastRunOrdinal: null, | |
| 4227 | handoffIds: [], | |
| 4228 | forkedFrom: { | |
| 4229 | providerThreadId: source.id, | |
| 4230 | ...(forkInput.providerTurnId === undefined | |
| 4231 | ? {} | |
| 4232 | : { providerTurnId: forkInput.providerTurnId }), | |
| 4233 | }, | |
| 4234 | createdAt, | |
| 4235 | updatedAt: createdAt, | |
| 4236 | }; | |
| 4237 | // A fork keeps the source's location. The target thread usually | |
| 4238 | // inherits that worktree too, but its first turn starts without a | |
| 4239 | // resume, so a target in another worktree takes the session there now. | |
| 4240 | const cwd = forkInput.runtimePolicy?.cwd; | |
| 4241 | const state = register(providerThread, forked, cwd ?? forked.location.directory); | |
| 4242 | // The fork runs the target thread's mode, not whatever the source ran. | |
| 4243 | yield* writeRules(state, forkInput.runtimePolicy ?? state.policy); | |
| 4244 | if (cwd != null && forked.location.directory !== cwd) { | |
| 4245 | yield* client.session.move({ | |
| 4246 | sessionID: Session.ID.make(forked.id), | |
| 4247 | directory: AbsolutePath.make(cwd), | |
| 4248 | }); | |
| 4249 | } | |
| 4250 | return state.providerThread; | |
| 4251 | }).pipe( | |
| 4252 | exclusive(forkInput.sourceProviderThread), | |
| 4253 | Effect.mapError((cause) => | |
| 4254 | isProviderAdapterError(cause) | |
| 4255 | ? cause | |
| 4256 | : new ProviderAdapter.ProviderAdapterForkThreadError({ | |
| 4257 | driver, | |
| 4258 | providerThreadId: forkInput.sourceProviderThread.id, | |
| 4259 | cause, | |
| 4260 | }), | |
| 4261 | ), | |
| 4262 | ), | |
| 4263 | }; | |
| 4264 | return runtime; | |
| 4265 | }); | |
| 4266 | ||
| 4267 | return ProviderAdapter.ProviderAdapterV2.of({ | |
| 4268 | instanceId, | |
| 4269 | driver, | |
| 4270 | getCapabilities: () => Effect.succeed(OpenCode2ProviderCapabilities), | |
| 4271 | planSelectionTransition: () => Effect.succeed(turnScopedSelectionTransition()), | |
| 4272 | // The session borrows the instance's server for as long as it is open, so a | |
| 4273 | // spawned server is not idle-stopped under a long tool call. | |
| 4274 | openSession: (input) => | |
| 4275 | Effect.gen(function* () { | |
| 4276 | const scope = yield* Scope.make(); | |
| 4277 | yield* Effect.addFinalizer((exit) => Scope.close(scope, exit)); | |
| 4278 | const connection = yield* borrow.pipe(Effect.provideService(Scope.Scope, scope)); | |
| 4279 | return yield* openSession(input, { connection, scope }); | |
| 4280 | }).pipe( | |
| 4281 | Effect.mapError( | |
| 4282 | (cause) => | |
| 4283 | new ProviderAdapter.ProviderAdapterOpenSessionError({ | |
| 4284 | driver, | |
| 4285 | providerSessionId: input.providerSessionId, | |
| 4286 | cause, | |
| 4287 | }), | |
| 4288 | ), | |
| 4289 | ), | |
| 4290 | }); | |
| 4291 | }); |
apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts deleted-2731| ... | ... | @@ -1,2731 +0,0 @@ |
| 1 | import { assert, describe, it } from "@effect/vitest"; | |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 3 | import type { OpencodeClient, ToolPart } from "@opencode-ai/sdk/v2"; | |
| 4 | import { | |
| 5 | CheckpointId, | |
| 6 | NodeId, | |
| 7 | OpenCodeSettings, | |
| 8 | ProjectId, | |
| 9 | ProviderInstanceId, | |
| 10 | ProviderSessionId, | |
| 11 | ProviderThreadId, | |
| 12 | ProviderTurnId, | |
| 13 | RunAttemptId, | |
| 14 | RunId, | |
| 15 | MessageId, | |
| 16 | ThreadId, | |
| 17 | type OrchestrationV2ProviderThread, | |
| 18 | type OrchestrationV2ProviderTurn, | |
| 19 | } from "@t3tools/contracts"; | |
| 20 | import * as Effect from "effect/Effect"; | |
| 21 | import * as Clock from "effect/Clock"; | |
| 22 | import * as DateTime from "effect/DateTime"; | |
| 23 | import * as Deferred from "effect/Deferred"; | |
| 24 | import * as Fiber from "effect/Fiber"; | |
| 25 | import * as Exit from "effect/Exit"; | |
| 26 | import * as Queue from "effect/Queue"; | |
| 27 | import * as Layer from "effect/Layer"; | |
| 28 | import * as Option from "effect/Option"; | |
| 29 | import * as Schema from "effect/Schema"; | |
| 30 | import * as Scope from "effect/Scope"; | |
| 31 | import * as Stream from "effect/Stream"; | |
| 32 | import * as TestClock from "effect/testing/TestClock"; | |
| 33 | ||
| 34 | import * as ServerConfig from "../../config.ts"; | |
| 35 | import type { EventNdjsonLogger } from "../../provider/EventNdjsonLogger.ts"; | |
| 36 | import type { OpenCodeRuntimeShape } from "../../provider/opencodeRuntime.ts"; | |
| 37 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; | |
| 38 | ||
| 39 | import { | |
| 40 | advanceOpenCodePromptAdmission, | |
| 41 | cancelOpenCodePromptAdmission, | |
| 42 | openCodeBoundaryAfterProviderTurn, | |
| 43 | openCodeChildPermissionRules, | |
| 44 | openCodePermissionRules, | |
| 45 | openCodePermissionRequestKind, | |
| 46 | openCodeToolProjectionKind, | |
| 47 | makeOpenCodeProtocolLogger, | |
| 48 | makeOpenCodeAdapterV2, | |
| 49 | OPENCODE_PROVIDER, | |
| 50 | reconcileOpenCodePromptAdmissionStatus, | |
| 51 | } from "./OpenCodeAdapterV2.ts"; | |
| 52 | import { ProviderAdapterV2RuntimePolicy } from "@t3tools/provider-core/server/ProviderAdapter"; | |
| 53 | ||
| 54 | const encodeUnknownJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); | |
| 55 | const OPEN_CODE_TEST_SETTINGS = Schema.decodeSync(OpenCodeSettings)({ | |
| 56 | serverUrl: "http://test.invalid", | |
| 57 | }); | |
| 58 | ||
| 59 | function promiseGate<A>() { | |
| 60 | let resolve!: (value: A) => void; | |
| 61 | const promise = new Promise<A>((done) => { | |
| 62 | resolve = done; | |
| 63 | }); | |
| 64 | return { promise, resolve }; | |
| 65 | } | |
| 66 | ||
| 67 | function asyncEventStream() { | |
| 68 | const values: Array<{ value: unknown; handled: ReturnType<typeof promiseGate<void>> }> = []; | |
| 69 | const waiters: Array<(value: IteratorResult<unknown>) => void> = []; | |
| 70 | let previousHandled: ReturnType<typeof promiseGate<void>> | undefined; | |
| 71 | let closed = false; | |
| 72 | return { | |
| 73 | close() { | |
| 74 | closed = true; | |
| 75 | for (const waiter of waiters.splice(0)) waiter({ done: true, value: undefined }); | |
| 76 | }, | |
| 77 | push(value: unknown) { | |
| 78 | const handled = promiseGate<void>(); | |
| 79 | const waiter = waiters.shift(); | |
| 80 | if (waiter) { | |
| 81 | previousHandled = handled; | |
| 82 | waiter({ done: false, value }); | |
| 83 | } else values.push({ value, handled }); | |
| 84 | return handled.promise; | |
| 85 | }, | |
| 86 | stream: { | |
| 87 | [Symbol.asyncIterator]() { | |
| 88 | return { | |
| 89 | next: () => { | |
| 90 | previousHandled?.resolve(); | |
| 91 | if (closed) return Promise.resolve({ done: true as const, value: undefined }); | |
| 92 | const entry = values.shift(); | |
| 93 | if (entry !== undefined) { | |
| 94 | previousHandled = entry.handled; | |
| 95 | return Promise.resolve({ done: false as const, value: entry.value }); | |
| 96 | } | |
| 97 | return new Promise<IteratorResult<unknown>>((resolve) => waiters.push(resolve)); | |
| 98 | }, | |
| 99 | }; | |
| 100 | }, | |
| 101 | }, | |
| 102 | }; | |
| 103 | } | |
| 104 | ||
| 105 | const OPENCODE_TEST_SETTINGS = Schema.decodeUnknownSync(OpenCodeSettings)({}); | |
| 106 | ||
| 107 | function runtimePolicy( | |
| 108 | runtimeMode: ProviderAdapterV2RuntimePolicy["runtimeMode"], | |
| 109 | override: Partial<ProviderAdapterV2RuntimePolicy> = {}, | |
| 110 | ): ProviderAdapterV2RuntimePolicy { | |
| 111 | return ProviderAdapterV2RuntimePolicy.make({ | |
| 112 | runtimeMode, | |
| 113 | interactionMode: "default", | |
| 114 | cwd: null, | |
| 115 | ...override, | |
| 116 | }); | |
| 117 | } | |
| 118 | ||
| 119 | function permissionAction(rules: ReturnType<typeof openCodePermissionRules>, permission: string) { | |
| 120 | return rules.findLast((rule) => rule.permission === "*" || rule.permission === permission) | |
| 121 | ?.action; | |
| 122 | } | |
| 123 | ||
| 124 | function providerTurn(input: { | |
| 125 | readonly id: string; | |
| 126 | readonly ordinal: number; | |
| 127 | readonly nativeId: string | null; | |
| 128 | }): OrchestrationV2ProviderTurn { | |
| 129 | return { | |
| 130 | id: ProviderTurnId.make(input.id), | |
| 131 | providerThreadId: ProviderThreadId.make("provider-thread:opencode-test"), | |
| 132 | nodeId: NodeId.make(`node:${input.id}`), | |
| 133 | runAttemptId: null, | |
| 134 | nativeTurnRef: | |
| 135 | input.nativeId === null | |
| 136 | ? null | |
| 137 | : { driver: OPENCODE_PROVIDER, nativeId: input.nativeId, strength: "weak" }, | |
| 138 | ordinal: input.ordinal, | |
| 139 | status: "completed", | |
| 140 | startedAt: null, | |
| 141 | completedAt: null, | |
| 142 | }; | |
| 143 | } | |
| 144 | ||
| 145 | const makeOpenCodeRuntimeHarness = Effect.fn("makeOpenCodeRuntimeHarness")(function* ( | |
| 146 | suffix: string, | |
| 147 | nativeSessionId: string, | |
| 148 | client: object, | |
| 149 | ) { | |
| 150 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | |
| 151 | const instanceId = ProviderInstanceId.make(`opencode-${suffix}`); | |
| 152 | const threadId = ThreadId.make(`thread-opencode-${suffix}`); | |
| 153 | const modelSelection = { | |
| 154 | instanceId, | |
| 155 | model: "anthropic/claude-sonnet", | |
| 156 | options: [], | |
| 157 | }; | |
| 158 | const policy = runtimePolicy("full-access", { cwd: "/workspace" }); | |
| 159 | const adapter = makeOpenCodeAdapterV2({ | |
| 160 | instanceId, | |
| 161 | settings: OPEN_CODE_TEST_SETTINGS, | |
| 162 | environment: {}, | |
| 163 | runtime: { | |
| 164 | connectToOpenCodeServer: () => Effect.succeed({ url: "http://test.invalid", external: true }), | |
| 165 | createOpenCodeSdkClient: () => client, | |
| 166 | } as unknown as OpenCodeRuntimeShape, | |
| 167 | idAllocator, | |
| 168 | serverConfig: { | |
| 169 | cwd: "/workspace", | |
| 170 | attachmentsDir: "/tmp/attachments", | |
| 171 | } as ServerConfig.ServerConfig["Service"], | |
| 172 | }); | |
| 173 | const runtime = yield* adapter.openSession({ | |
| 174 | threadId, | |
| 175 | providerSessionId: ProviderSessionId.make(`session-opencode-${suffix}`), | |
| 176 | modelSelection, | |
| 177 | runtimePolicy: policy, | |
| 178 | }); | |
| 179 | const providerThread = yield* runtime.ensureThread({ | |
| 180 | threadId, | |
| 181 | modelSelection, | |
| 182 | runtimePolicy: policy, | |
| 183 | }); | |
| 184 | const now = yield* DateTime.now; | |
| 185 | const startTurn = ( | |
| 186 | text = "hello", | |
| 187 | startProviderThread: OrchestrationV2ProviderThread = providerThread, | |
| 188 | ) => | |
| 189 | runtime.startTurn({ | |
| 190 | appThread: { | |
| 191 | id: threadId, | |
| 192 | projectId: ProjectId.make(`project-opencode-${suffix}`), | |
| 193 | title: suffix, | |
| 194 | providerInstanceId: modelSelection.instanceId, | |
| 195 | modelSelection, | |
| 196 | runtimeMode: "full-access", | |
| 197 | interactionMode: "default", | |
| 198 | branch: null, | |
| 199 | worktreePath: null, | |
| 200 | activeProviderThreadId: providerThread.id, | |
| 201 | lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, | |
| 202 | forkedFrom: null, | |
| 203 | createdBy: "user", | |
| 204 | creationSource: "web", | |
| 205 | createdAt: now, | |
| 206 | updatedAt: now, | |
| 207 | archivedAt: null, | |
| 208 | settledOverride: null, | |
| 209 | settledAt: null, | |
| 210 | lastVisitedAt: null, | |
| 211 | deletedAt: null, | |
| 212 | }, | |
| 213 | threadId, | |
| 214 | runId: RunId.make(`run-opencode-${suffix}`), | |
| 215 | runOrdinal: 1, | |
| 216 | providerTurnOrdinal: 1, | |
| 217 | attemptId: RunAttemptId.make(`attempt-opencode-${suffix}`), | |
| 218 | rootNodeId: NodeId.make(`node-opencode-${suffix}`), | |
| 219 | providerThread: startProviderThread, | |
| 220 | message: { | |
| 221 | createdBy: "user", | |
| 222 | creationSource: "web", | |
| 223 | messageId: MessageId.make(`message-opencode-${suffix}`), | |
| 224 | text, | |
| 225 | attachments: [], | |
| 226 | }, | |
| 227 | modelSelection, | |
| 228 | runtimePolicy: policy, | |
| 229 | }); | |
| 230 | return { | |
| 231 | nativeSessionId, | |
| 232 | now, | |
| 233 | policy, | |
| 234 | providerThread, | |
| 235 | runId: RunId.make(`run-opencode-${suffix}`), | |
| 236 | runtime, | |
| 237 | startTurn, | |
| 238 | threadId, | |
| 239 | }; | |
| 240 | }); | |
| 241 | ||
| 242 | describe("OpenCodeAdapterV2", () => { | |
| 243 | it.effect.each(["completed", "failed", "unresolved", "unavailable", "reconnect"] as const)( | |
| 244 | "normalizes OpenCode step usage for %s turns", | |
| 245 | (ending) => | |
| 246 | Effect.gen(function* () { | |
| 247 | const nativeEvents = asyncEventStream(); | |
| 248 | let promptId = ""; | |
| 249 | const harness = yield* makeOpenCodeRuntimeHarness(`usage-${ending}`, "root", { | |
| 250 | event: { subscribe: async () => ({ stream: nativeEvents.stream }) }, | |
| 251 | session: { | |
| 252 | create: async () => ({ data: { id: "root", time: { created: 1, updated: 1 } } }), | |
| 253 | promptAsync: async (input: { messageID: string }) => { | |
| 254 | promptId = input.messageID; | |
| 255 | return { data: true }; | |
| 256 | }, | |
| 257 | abort: async () => ({ data: true }), | |
| 258 | children: async () => ({ data: [] }), | |
| 259 | }, | |
| 260 | }); | |
| 261 | yield* harness.startTurn(); | |
| 262 | const received = yield* harness.runtime.events.pipe( | |
| 263 | Stream.takeUntil((event) => event.type === "turn.terminal"), | |
| 264 | Stream.runCollect, | |
| 265 | Effect.forkScoped, | |
| 266 | ); | |
| 267 | yield* Effect.promise(() => | |
| 268 | nativeEvents.push({ | |
| 269 | type: "message.updated", | |
| 270 | properties: { | |
| 271 | sessionID: "root", | |
| 272 | info: { id: promptId, role: "user", time: { created: 1 } }, | |
| 273 | }, | |
| 274 | }), | |
| 275 | ); | |
| 276 | const step = (id: string, messageID = "assistant") => ({ | |
| 277 | type: "message.part.updated", | |
| 278 | properties: { | |
| 279 | part: { | |
| 280 | type: "step-finish", | |
| 281 | id, | |
| 282 | sessionID: "root", | |
| 283 | messageID, | |
| 284 | reason: "stop", | |
| 285 | cost: 0.01, | |
| 286 | tokens: { input: 10, output: 5, reasoning: 2, cache: { read: 3, write: 4 } }, | |
| 287 | }, | |
| 288 | }, | |
| 289 | }); | |
| 290 | if (ending !== "unavailable") { | |
| 291 | yield* Effect.promise(() => nativeEvents.push(step("one"))); | |
| 292 | yield* Effect.promise(() => | |
| 293 | nativeEvents.push({ | |
| 294 | type: "message.updated", | |
| 295 | properties: { | |
| 296 | sessionID: "root", | |
| 297 | info: { | |
| 298 | id: "assistant", | |
| 299 | role: "assistant", | |
| 300 | time: { created: 1 }, | |
| 301 | parentID: promptId, | |
| 302 | }, | |
| 303 | }, | |
| 304 | }), | |
| 305 | ); | |
| 306 | yield* Effect.promise(() => nativeEvents.push(step("one"))); | |
| 307 | yield* Effect.promise(() => nativeEvents.push(step("two"))); | |
| 308 | yield* Effect.promise(() => | |
| 309 | nativeEvents.push({ | |
| 310 | type: "message.part.removed", | |
| 311 | properties: { sessionID: "root", messageID: "assistant", partID: "one" }, | |
| 312 | }), | |
| 313 | ); | |
| 314 | yield* Effect.promise(() => | |
| 315 | nativeEvents.push({ | |
| 316 | type: "message.updated", | |
| 317 | properties: { | |
| 318 | sessionID: "root", | |
| 319 | info: { | |
| 320 | id: "old-assistant", | |
| 321 | role: "assistant", | |
| 322 | time: { created: 1 }, | |
| 323 | parentID: "old-prompt", | |
| 324 | }, | |
| 325 | }, | |
| 326 | }), | |
| 327 | ); | |
| 328 | yield* Effect.promise(() => nativeEvents.push(step("old", "old-assistant"))); | |
| 329 | } | |
| 330 | if (ending === "unresolved") | |
| 331 | yield* Effect.promise(() => nativeEvents.push(step("unknown", "unknown-assistant"))); | |
| 332 | if (ending === "reconnect") { | |
| 333 | yield* Effect.promise(() => | |
| 334 | nativeEvents.push({ type: "server.connected", properties: {} }), | |
| 335 | ); | |
| 336 | yield* Effect.promise(() => | |
| 337 | nativeEvents.push({ type: "server.connected", properties: {} }), | |
| 338 | ); | |
| 339 | } | |
| 340 | if (ending === "failed") { | |
| 341 | yield* Effect.promise(() => | |
| 342 | nativeEvents.push({ | |
| 343 | type: "session.error", | |
| 344 | properties: { | |
| 345 | sessionID: "root", | |
| 346 | error: { name: "UnknownError", data: { message: "failed" } }, | |
| 347 | }, | |
| 348 | }), | |
| 349 | ); | |
| 350 | } else { | |
| 351 | yield* Effect.promise(() => | |
| 352 | nativeEvents.push({ | |
| 353 | type: "session.status", | |
| 354 | properties: { sessionID: "root", status: { type: "busy" } }, | |
| 355 | }), | |
| 356 | ); | |
| 357 | yield* Effect.promise(() => | |
| 358 | nativeEvents.push({ | |
| 359 | type: "session.status", | |
| 360 | properties: { sessionID: "root", status: { type: "idle" } }, | |
| 361 | }), | |
| 362 | ); | |
| 363 | } | |
| 364 | const events = yield* Fiber.join(received); | |
| 365 | const completed = events.findLast((event) => event.type === "provider_turn.updated"); | |
| 366 | assert.deepEqual( | |
| 367 | completed?.providerTurn.turnTokenUsage, | |
| 368 | ending === "unavailable" | |
| 369 | ? { usageStatus: "unavailable", usageScope: "main_agent", hasSubagents: false } | |
| 370 | : { | |
| 371 | usageStatus: ending === "completed" ? "complete" : "partial", | |
| 372 | usageScope: "main_agent", | |
| 373 | inputTokens: 34, | |
| 374 | cachedInputTokens: 6, | |
| 375 | cacheCreationTokens: 8, | |
| 376 | outputTokens: 14, | |
| 377 | reasoningTokens: 4, | |
| 378 | hasSubagents: false, | |
| 379 | }, | |
| 380 | ); | |
| 381 | }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), | |
| 382 | ); | |
| 383 | ||
| 384 | it.effect.each(["permission", "question"] as const)( | |
| 385 | "cancels an undelivered %s reply at its deadline", | |
| 386 | (kind) => | |
| 387 | Effect.gen(function* () { | |
| 388 | const nativeEvents = asyncEventStream(); | |
| 389 | const called = promiseGate<void>(); | |
| 390 | let signal: AbortSignal | undefined; | |
| 391 | let deliver = false; | |
| 392 | const harness = yield* makeOpenCodeRuntimeHarness(`reply-${kind}`, "root", { | |
| 393 | event: { subscribe: async () => ({ stream: nativeEvents.stream }) }, | |
| 394 | session: { | |
| 395 | create: async () => ({ data: { id: "root", time: { created: 1, updated: 1 } } }), | |
| 396 | promptAsync: async () => ({ data: true }), | |
| 397 | abort: async () => ({ data: true }), | |
| 398 | children: async () => ({ data: [] }), | |
| 399 | }, | |
| 400 | [kind]: { | |
| 401 | reply: async (_input: unknown, options: { signal: AbortSignal }) => { | |
| 402 | if (deliver) return { data: true }; | |
| 403 | signal = options.signal; | |
| 404 | called.resolve(); | |
| 405 | return new Promise(() => {}); | |
| 406 | }, | |
| 407 | }, | |
| 408 | }); | |
| 409 | yield* harness.startTurn(); | |
| 410 | const received = yield* harness.runtime.events.pipe( | |
| 411 | Stream.filter((event) => event.type === "runtime_request.updated"), | |
| 412 | Stream.take(1), | |
| 413 | Stream.runCollect, | |
| 414 | Effect.forkScoped, | |
| 415 | ); | |
| 416 | yield* Effect.promise(() => | |
| 417 | nativeEvents.push({ | |
| 418 | type: `${kind}.asked`, | |
| 419 | properties: | |
| 420 | kind === "permission" | |
| 421 | ? { | |
| 422 | id: "request", | |
| 423 | sessionID: "root", | |
| 424 | permission: "bash", | |
| 425 | patterns: ["*"], | |
| 426 | always: [], | |
| 427 | metadata: {}, | |
| 428 | } | |
| 429 | : { | |
| 430 | id: "request", | |
| 431 | sessionID: "root", | |
| 432 | questions: [ | |
| 433 | { | |
| 434 | header: "Choice", | |
| 435 | question: "Which?", | |
| 436 | options: [{ label: "Yes", description: "Proceed" }], | |
| 437 | }, | |
| 438 | ], | |
| 439 | }, | |
| 440 | }), | |
| 441 | ); | |
| 442 | const request = (yield* Fiber.join(received))[0]!.runtimeRequest; | |
| 443 | const response = { | |
| 444 | requestId: request.id, | |
| 445 | ...(kind === "permission" | |
| 446 | ? { decision: "accept" as const } | |
| 447 | : { answers: { Choice: "Yes" } }), | |
| 448 | }; | |
| 449 | const reply = yield* harness.runtime | |
| 450 | .respondToRuntimeRequest(response) | |
| 451 | .pipe(Effect.exit, Effect.forkScoped); | |
| 452 | yield* Effect.promise(() => called.promise); | |
| 453 | yield* TestClock.adjust("10 seconds"); | |
| 454 | assert.isTrue(Exit.isFailure(yield* Fiber.join(reply))); | |
| 455 | assert.isTrue(signal?.aborted); | |
| 456 | deliver = true; | |
| 457 | // Failed delivery leaves the request available for an explicit retry. | |
| 458 | yield* harness.runtime.respondToRuntimeRequest(response); | |
| 459 | }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), | |
| 460 | ); | |
| 461 | ||
| 462 | it.effect("aborts external root and descendants before closing the event stream", () => | |
| 463 | Effect.gen(function* () { | |
| 464 | const scope = yield* Scope.make(); | |
| 465 | const nativeEvents = asyncEventStream(); | |
| 466 | const calls: string[] = []; | |
| 467 | yield* makeOpenCodeRuntimeHarness("release", "root", { | |
| 468 | event: { | |
| 469 | subscribe: async (_input: unknown, options: { signal: AbortSignal }) => { | |
| 470 | options.signal.addEventListener("abort", () => { | |
| 471 | calls.push("stream.close"); | |
| 472 | nativeEvents.close(); | |
| 473 | }); | |
| 474 | return { stream: nativeEvents.stream }; | |
| 475 | }, | |
| 476 | }, | |
| 477 | session: { | |
| 478 | create: async () => ({ data: { id: "root", time: { created: 1, updated: 1 } } }), | |
| 479 | abort: async ({ sessionID }: { sessionID: string }) => { | |
| 480 | calls.push(`abort:${sessionID}`); | |
| 481 | return { data: true }; | |
| 482 | }, | |
| 483 | children: async ({ sessionID }: { sessionID: string }) => { | |
| 484 | calls.push(`children:${sessionID}`); | |
| 485 | return { data: sessionID === "root" ? [{ id: "child" }] : [] }; | |
| 486 | }, | |
| 487 | }, | |
| 488 | }).pipe(Effect.provideService(Scope.Scope, scope)); | |
| 489 | yield* Scope.close(scope, Exit.void); | |
| 490 | assert.deepEqual(calls, [ | |
| 491 | "abort:root", | |
| 492 | "children:root", | |
| 493 | "abort:child", | |
| 494 | "children:child", | |
| 495 | "stream.close", | |
| 496 | ]); | |
| 497 | }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), | |
| 498 | ); | |
| 499 | ||
| 500 | it.effect.each(["enumeration", "abort", "not-found", "timeout"] as const)( | |
| 501 | "reports descendant cleanup %s", | |
| 502 | (failure) => | |
| 503 | Effect.gen(function* () { | |
| 504 | const nativeEvents = asyncEventStream(); | |
| 505 | const called = promiseGate<void>(); | |
| 506 | let childSignal: AbortSignal | undefined; | |
| 507 | const harness = yield* makeOpenCodeRuntimeHarness(`cleanup-${failure}`, "root", { | |
| 508 | event: { subscribe: async () => ({ stream: nativeEvents.stream }) }, | |
| 509 | session: { | |
| 510 | create: async () => ({ data: { id: "root", time: { created: 1, updated: 1 } } }), | |
| 511 | promptAsync: async () => ({ data: true }), | |
| 512 | get: async () => ({ data: { id: "root", time: { created: 1, updated: 1 } } }), | |
| 513 | messages: async () => ({ data: [] }), | |
| 514 | children: async ({ sessionID }: { sessionID: string }) => { | |
| 515 | if (failure === "enumeration") throw new Error("cannot enumerate"); | |
| 516 | return { data: sessionID === "root" ? [{ id: "child" }] : [] }; | |
| 517 | }, | |
| 518 | abort: async ( | |
| 519 | { sessionID }: { sessionID: string }, | |
| 520 | options: { signal: AbortSignal }, | |
| 521 | ) => { | |
| 522 | if (sessionID === "root") return { data: true }; | |
| 523 | if (failure === "timeout" && childSignal?.aborted) return { data: true }; | |
| 524 | childSignal = options.signal; | |
| 525 | called.resolve(); | |
| 526 | if (failure === "timeout") { | |
| 527 | return new Promise(() => {}); | |
| 528 | } | |
| 529 | if (failure === "not-found") throw { status: 404 }; | |
| 530 | throw new Error("child abort failed"); | |
| 531 | }, | |
| 532 | }, | |
| 533 | }); | |
| 534 | yield* harness.startTurn(); | |
| 535 | const snapshot = yield* harness.runtime.readThreadSnapshot({ | |
| 536 | providerThread: harness.providerThread, | |
| 537 | }); | |
| 538 | const stop = yield* harness.runtime | |
| 539 | .interruptTurn({ | |
| 540 | providerThread: harness.providerThread, | |
| 541 | providerTurnId: snapshot.providerTurns.at(-1)!.id, | |
| 542 | }) | |
| 543 | .pipe(Effect.exit, Effect.forkScoped); | |
| 544 | if (failure === "timeout") { | |
| 545 | yield* Effect.promise(() => called.promise); | |
| 546 | yield* TestClock.adjust("15 seconds"); | |
| 547 | } | |
| 548 | const result = yield* Fiber.join(stop); | |
| 549 | assert.equal(Exit.isSuccess(result), failure === "not-found"); | |
| 550 | if (failure === "timeout") assert.isTrue(childSignal?.aborted); | |
| 551 | }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), | |
| 552 | ); | |
| 553 | ||
| 554 | it.effect( | |
| 555 | "preserves tool lifecycle, approval kinds, and late assistant text without cached tool payloads", | |
| 556 | () => | |
| 557 | Effect.gen(function* () { | |
| 558 | const nativeEvents = asyncEventStream(); | |
| 559 | const nativeSessionId = "native-opencode-tool-lifecycle"; | |
| 560 | const harness = yield* makeOpenCodeRuntimeHarness("tool-lifecycle", nativeSessionId, { | |
| 561 | event: { | |
| 562 | subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { | |
| 563 | options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); | |
| 564 | return { stream: nativeEvents.stream }; | |
| 565 | }, | |
| 566 | }, | |
| 567 | session: { | |
| 568 | create: async () => ({ | |
| 569 | data: { id: nativeSessionId, time: { created: 1, updated: 1 } }, | |
| 570 | }), | |
| 571 | promptAsync: async () => ({ data: true }), | |
| 572 | }, | |
| 573 | }); | |
| 574 | yield* harness.startTurn(); | |
| 575 | const received = yield* harness.runtime.events.pipe( | |
| 576 | Stream.takeUntil( | |
| 577 | (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction", | |
| 578 | ), | |
| 579 | Stream.runCollect, | |
| 580 | Effect.forkScoped, | |
| 581 | ); | |
| 582 | const states = [ | |
| 583 | { status: "pending", input: { command: "pwd" }, raw: "" }, | |
| 584 | { | |
| 585 | status: "running", | |
| 586 | input: { command: "pwd" }, | |
| 587 | title: "Working directory", | |
| 588 | time: { start: 1 }, | |
| 589 | }, | |
| 590 | { | |
| 591 | status: "completed", | |
| 592 | input: { command: "pwd" }, | |
| 593 | output: "/repo\n", | |
| 594 | title: "Working directory", | |
| 595 | metadata: {}, | |
| 596 | time: { start: 1, end: 2 }, | |
| 597 | }, | |
| 598 | { | |
| 599 | status: "error", | |
| 600 | input: { command: "pwd" }, | |
| 601 | error: "Command failed", | |
| 602 | time: { start: 3, end: 4 }, | |
| 603 | }, | |
| 604 | ] satisfies ReadonlyArray<ToolPart["state"]>; | |
| 605 | for (const state of states) { | |
| 606 | yield* Effect.promise(() => | |
| 607 | nativeEvents.push({ | |
| 608 | type: "message.part.updated", | |
| 609 | properties: { | |
| 610 | sessionID: nativeSessionId, | |
| 611 | part: { | |
| 612 | id: state.status === "error" ? "part-failed" : "part-working", | |
| 613 | sessionID: nativeSessionId, | |
| 614 | messageID: "late-assistant", | |
| 615 | type: "tool", | |
| 616 | callID: state.status === "error" ? "call-failed" : "call-working", | |
| 617 | tool: "bash", | |
| 618 | state, | |
| 619 | }, | |
| 620 | }, | |
| 621 | }), | |
| 622 | ); | |
| 623 | } | |
| 624 | yield* Effect.promise(() => | |
| 625 | nativeEvents.push({ | |
| 626 | type: "message.part.updated", | |
| 627 | properties: { | |
| 628 | sessionID: nativeSessionId, | |
| 629 | part: { | |
| 630 | id: "part-read", | |
| 631 | sessionID: nativeSessionId, | |
| 632 | messageID: "late-assistant", | |
| 633 | type: "tool", | |
| 634 | callID: "call-read", | |
| 635 | tool: "read", | |
| 636 | state: { status: "pending", input: { filePath: "/outside/file" }, raw: "" }, | |
| 637 | }, | |
| 638 | }, | |
| 639 | }), | |
| 640 | ); | |
| 641 | yield* Effect.promise(() => | |
| 642 | nativeEvents.push({ | |
| 643 | type: "permission.asked", | |
| 644 | properties: { | |
| 645 | id: "read-permission", | |
| 646 | sessionID: nativeSessionId, | |
| 647 | permission: "external_directory", | |
| 648 | patterns: ["/outside/*"], | |
| 649 | always: [], | |
| 650 | metadata: {}, | |
| 651 | tool: { messageID: "late-assistant", callID: "call-read" }, | |
| 652 | }, | |
| 653 | }), | |
| 654 | ); | |
| 655 | yield* Effect.promise(() => | |
| 656 | nativeEvents.push({ | |
| 657 | type: "message.part.updated", | |
| 658 | properties: { | |
| 659 | sessionID: nativeSessionId, | |
| 660 | part: { | |
| 661 | id: "part-text", | |
| 662 | sessionID: nativeSessionId, | |
| 663 | messageID: "late-assistant", | |
| 664 | type: "text", | |
| 665 | text: "Tool results received", | |
| 666 | time: { start: 4 }, | |
| 667 | }, | |
| 668 | }, | |
| 669 | }), | |
| 670 | ); | |
| 671 | yield* Effect.promise(() => | |
| 672 | nativeEvents.push({ | |
| 673 | type: "message.updated", | |
| 674 | properties: { | |
| 675 | sessionID: nativeSessionId, | |
| 676 | info: { | |
| 677 | id: "late-assistant", | |
| 678 | sessionID: nativeSessionId, | |
| 679 | role: "assistant", | |
| 680 | time: { created: 1, completed: 4 }, | |
| 681 | }, | |
| 682 | }, | |
| 683 | }), | |
| 684 | ); | |
| 685 | yield* Effect.promise(() => | |
| 686 | nativeEvents.push({ | |
| 687 | type: "session.compacted", | |
| 688 | properties: { sessionID: nativeSessionId }, | |
| 689 | }), | |
| 690 | ); | |
| 691 | const events = yield* Fiber.join(received); | |
| 692 | const items = events.flatMap((event) => | |
| 693 | event.type === "turn_item.updated" ? [event.turnItem] : [], | |
| 694 | ); | |
| 695 | const commands = items.filter((item) => item.type === "command_execution"); | |
| 696 | assert.deepEqual( | |
| 697 | commands.map((item) => item.status), | |
| 698 | ["pending", "running", "completed", "failed"], | |
| 699 | ); | |
| 700 | assert.deepEqual( | |
| 701 | commands.map((item) => item.input), | |
| 702 | ["pwd", "pwd", "pwd", "pwd"], | |
| 703 | ); | |
| 704 | assert.equal(commands[2]?.output, "/repo\n"); | |
| 705 | assert.equal(commands[3]?.output, "Command failed"); | |
| 706 | assert.isTrue( | |
| 707 | events.some( | |
| 708 | (event) => | |
| 709 | event.type === "runtime_request.updated" && event.runtimeRequest.kind === "file-read", | |
| 710 | ), | |
| 711 | ); | |
| 712 | const assistant = items.filter((item) => item.type === "assistant_message"); | |
| 713 | assert.equal(assistant.at(-1)?.text, "Tool results received"); | |
| 714 | assert.equal(assistant.at(-1)?.status, "completed"); | |
| 715 | }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), | |
| 716 | ); | |
| 717 | ||
| 718 | // Event order from a live OpenCode 1.18.32 run of a `task` call with | |
| 719 | // background=true: the task part completes at launch, the root session | |
| 720 | // settles, and the child session stays busy until its own work ends. | |
| 721 | it.effect("reports a background task child as pending work after the root turn settles", () => | |
| 722 | Effect.gen(function* () { | |
| 723 | const nativeEvents = asyncEventStream(); | |
| 724 | const push = (event: unknown) => Effect.promise(() => nativeEvents.push(event)); | |
| 725 | const root = "ses_root"; | |
| 726 | const child = "ses_child"; | |
| 727 | let promptId = ""; | |
| 728 | const harness = yield* makeOpenCodeRuntimeHarness("background-child", root, { | |
| 729 | event: { subscribe: async () => ({ stream: nativeEvents.stream }) }, | |
| 730 | session: { | |
| 731 | create: async () => ({ data: { id: root, time: { created: 1, updated: 1 } } }), | |
| 732 | get: async () => ({ | |
| 733 | data: { id: child, parentID: root, permission: [], time: { created: 2, updated: 2 } }, | |
| 734 | }), | |
| 735 | update: async () => ({ data: { id: child, parentID: root } }), | |
| 736 | promptAsync: async (input: { messageID: string }) => { | |
| 737 | promptId = input.messageID; | |
| 738 | return { data: true }; | |
| 739 | }, | |
| 740 | abort: async () => ({ data: true }), | |
| 741 | children: async () => ({ data: [] }), | |
| 742 | }, | |
| 743 | }); | |
| 744 | const hasPendingBackgroundWork = harness.runtime.hasPendingBackgroundWork; | |
| 745 | if (hasPendingBackgroundWork === undefined) { | |
| 746 | return yield* Effect.die("OpenCode runtime must expose hasPendingBackgroundWork."); | |
| 747 | } | |
| 748 | yield* harness.startTurn(); | |
| 749 | const terminal = yield* harness.runtime.events.pipe( | |
| 750 | Stream.filter((event) => event.type === "turn.terminal"), | |
| 751 | Stream.runHead, | |
| 752 | Effect.forkScoped, | |
| 753 | ); | |
| 754 | const taskPart = (status: "running" | "completed") => ({ | |
| 755 | type: "message.part.updated", | |
| 756 | properties: { | |
| 757 | sessionID: root, | |
| 758 | part: { | |
| 759 | id: "prt_task", | |
| 760 | sessionID: root, | |
| 761 | messageID: "msg_root_assistant", | |
| 762 | type: "tool", | |
| 763 | tool: "task", | |
| 764 | callID: "call_task", | |
| 765 | state: { | |
| 766 | status, | |
| 767 | input: { | |
| 768 | description: "Background sleep task", | |
| 769 | prompt: "sleep", | |
| 770 | subagent_type: "general", | |
| 771 | }, | |
| 772 | title: "Background sleep task", | |
| 773 | metadata: { parentSessionId: root, sessionId: child, background: true }, | |
| 774 | time: { start: 3, ...(status === "completed" ? { end: 3 } : {}) }, | |
| 775 | ...(status === "completed" ? { output: "Background task started" } : {}), | |
| 776 | }, | |
| 777 | }, | |
| 778 | }, | |
| 779 | }); | |
| 780 | const status = (sessionID: string, type: "busy" | "idle") => ({ | |
| 781 | type: "session.status", | |
| 782 | properties: { sessionID, status: { type } }, | |
| 783 | }); | |
| 784 | ||
| 785 | yield* push({ | |
| 786 | type: "message.updated", | |
| 787 | properties: { | |
| 788 | sessionID: root, | |
| 789 | info: { id: promptId, sessionID: root, role: "user", time: { created: 1 } }, | |
| 790 | }, | |
| 791 | }); | |
| 792 | yield* push(status(root, "busy")); | |
| 793 | yield* push(taskPart("running")); | |
| 794 | yield* push({ | |
| 795 | type: "session.created", | |
| 796 | properties: { | |
| 797 | sessionID: child, | |
| 798 | info: { id: child, parentID: root, time: { created: 2, updated: 2 } }, | |
| 799 | }, | |
| 800 | }); | |
| 801 | yield* push(status(child, "busy")); | |
| 802 | yield* push(taskPart("completed")); | |
| 803 | yield* push(status(root, "idle")); | |
| 804 | assert.equal(Option.getOrUndefined(yield* Fiber.join(terminal))?.status, "completed"); | |
| 805 | assert.isTrue(yield* hasPendingBackgroundWork, "the running child must pin idle release"); | |
| 806 | ||
| 807 | yield* push(status(child, "idle")); | |
| 808 | yield* push({ type: "session.idle", properties: { sessionID: child } }); | |
| 809 | assert.isFalse(yield* hasPendingBackgroundWork, "an idle child must not pin idle release"); | |
| 810 | }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), | |
| 811 | ); | |
| 812 | ||
| 813 | it.effect("titles OpenCode reads and searches from their input", () => | |
| 814 | Effect.gen(function* () { | |
| 815 | const nativeEvents = asyncEventStream(); | |
| 816 | const nativeSessionId = "native-opencode-search"; | |
| 817 | const harness = yield* makeOpenCodeRuntimeHarness("search-projection", nativeSessionId, { | |
| 818 | event: { | |
| 819 | subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { | |
| 820 | options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); | |
| 821 | return { stream: nativeEvents.stream }; | |
| 822 | }, | |
| 823 | }, | |
| 824 | session: { | |
| 825 | create: async () => ({ | |
| 826 | data: { id: nativeSessionId, time: { created: 1, updated: 1 } }, | |
| 827 | }), | |
| 828 | promptAsync: async () => ({ data: true }), | |
| 829 | }, | |
| 830 | }); | |
| 831 | yield* harness.startTurn(); | |
| 832 | const received = yield* harness.runtime.events.pipe( | |
| 833 | Stream.takeUntil( | |
| 834 | (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction", | |
| 835 | ), | |
| 836 | Stream.runCollect, | |
| 837 | Effect.forkScoped, | |
| 838 | ); | |
| 839 | for (const [tool, input, output] of [ | |
| 840 | ["read", { filePath: "src/env.ts" }, "---\nfile body"], | |
| 841 | ["grep", { pattern: "TODO", path: "apps/web" }, "---\nfile body"], | |
| 842 | ["websearch", { query: "OpenCode documentation" }, "---\nfile body"], | |
| 843 | ["glob", { pattern: "missing", path: "apps/web" }, ""], | |
| 844 | ["codesearch", {}, " \n\t"], | |
| 845 | ] as const) { | |
| 846 | yield* Effect.promise(() => | |
| 847 | nativeEvents.push({ | |
| 848 | type: "message.part.updated", | |
| 849 | properties: { | |
| 850 | sessionID: nativeSessionId, | |
| 851 | part: { | |
| 852 | id: `part-${tool}`, | |
| 853 | sessionID: nativeSessionId, | |
| 854 | messageID: "assistant-search", | |
| 855 | type: "tool", | |
| 856 | callID: `call-${tool}`, | |
| 857 | tool, | |
| 858 | state: { | |
| 859 | status: "completed", | |
| 860 | input, | |
| 861 | output, | |
| 862 | title: tool, | |
| 863 | metadata: {}, | |
| 864 | time: { start: 1, end: 2 }, | |
| 865 | }, | |
| 866 | }, | |
| 867 | }, | |
| 868 | }), | |
| 869 | ); | |
| 870 | } | |
| 871 | yield* Effect.promise(() => | |
| 872 | nativeEvents.push({ | |
| 873 | type: "session.compacted", | |
| 874 | properties: { sessionID: nativeSessionId }, | |
| 875 | }), | |
| 876 | ); | |
| 877 | const items = (yield* Fiber.join(received)).flatMap((event) => | |
| 878 | event.type === "turn_item.updated" ? [event.turnItem] : [], | |
| 879 | ); | |
| 880 | const read = items.find((item) => item.type === "dynamic_tool"); | |
| 881 | assert.equal(read?.title, "Read src/env.ts"); | |
| 882 | const grep = items.find((item) => item.type === "file_search"); | |
| 883 | assert.equal(grep?.title, "Searched TODO in web"); | |
| 884 | assert.equal(grep?.type === "file_search" ? grep.pattern : null, "TODO"); | |
| 885 | assert.deepEqual(grep?.type === "file_search" ? grep.results : null, [ | |
| 886 | { fileName: "apps/web", preview: "---\nfile body" }, | |
| 887 | ]); | |
| 888 | const webSearch = items.find((item) => item.type === "web_search"); | |
| 889 | assert.deepEqual(webSearch?.type === "web_search" ? webSearch.patterns : null, [ | |
| 890 | "OpenCode documentation", | |
| 891 | ]); | |
| 892 | assert.deepEqual(webSearch?.type === "web_search" ? webSearch.results : null, [ | |
| 893 | { snippet: "---\nfile body" }, | |
| 894 | ]); | |
| 895 | const emptyFileSearch = items.find( | |
| 896 | (item) => item.type === "file_search" && item.pattern === "missing", | |
| 897 | ); | |
| 898 | assert.ok(emptyFileSearch?.type === "file_search"); | |
| 899 | assert.equal(emptyFileSearch.results, undefined); | |
| 900 | const emptyWebSearch = items.find( | |
| 901 | (item) => item.type === "web_search" && item.patterns === undefined, | |
| 902 | ); | |
| 903 | assert.ok(emptyWebSearch?.type === "web_search"); | |
| 904 | assert.equal(emptyWebSearch.results, undefined); | |
| 905 | }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), | |
| 906 | ); | |
| 907 | ||
| 908 | it.effect("presents OpenCode MCP calls without treating remote tools as local edits", () => | |
| 909 | Effect.gen(function* () { | |
| 910 | const nativeEvents = asyncEventStream(); | |
| 911 | const nativeSessionId = "native-opencode-mcp"; | |
| 912 | let statusReads = 0; | |
| 913 | const harness = yield* makeOpenCodeRuntimeHarness("mcp-presentation", nativeSessionId, { | |
| 914 | event: { | |
| 915 | subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { | |
| 916 | options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); | |
| 917 | return { stream: nativeEvents.stream }; | |
| 918 | }, | |
| 919 | }, | |
| 920 | session: { | |
| 921 | create: async () => ({ data: { id: nativeSessionId, time: { created: 1, updated: 1 } } }), | |
| 922 | promptAsync: async () => ({ data: true }), | |
| 923 | }, | |
| 924 | mcp: { | |
| 925 | status: async () => { | |
| 926 | statusReads++; | |
| 927 | return { | |
| 928 | data: { | |
| 929 | "my.server_with_underscores": { status: "connected" }, | |
| 930 | ambiguous: { status: "connected" }, | |
| 931 | ambiguous_server: { status: "connected" }, | |
| 932 | code: { status: "connected" }, | |
| 933 | apply: { status: "connected" }, | |
| 934 | }, | |
| 935 | }; | |
| 936 | }, | |
| 937 | }, | |
| 938 | }); | |
| 939 | yield* harness.startTurn(); | |
| 940 | const received = yield* harness.runtime.events.pipe( | |
| 941 | Stream.takeUntil( | |
| 942 | (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction", | |
| 943 | ), | |
| 944 | Stream.runCollect, | |
| 945 | Effect.forkScoped, | |
| 946 | ); | |
| 947 | for (const status of ["running", "completed", "error"] as const) { | |
| 948 | yield* Effect.promise(() => | |
| 949 | nativeEvents.push({ | |
| 950 | type: "message.part.updated", | |
| 951 | properties: { | |
| 952 | sessionID: nativeSessionId, | |
| 953 | part: { | |
| 954 | id: "mcp-part", | |
| 955 | sessionID: nativeSessionId, | |
| 956 | messageID: "mcp-assistant", | |
| 957 | type: "tool", | |
| 958 | callID: "mcp-call", | |
| 959 | tool: "my_server_with_underscores_edit_document", | |
| 960 | state: { | |
| 961 | status, | |
| 962 | input: { document: "remote-doc" }, | |
| 963 | title: "Edit remote document", | |
| 964 | metadata: {}, | |
| 965 | output: "saved", | |
| 966 | error: "failed", | |
| 967 | time: { start: 1, end: 2 }, | |
| 968 | }, | |
| 969 | }, | |
| 970 | }, | |
| 971 | }), | |
| 972 | ); | |
| 973 | } | |
| 974 | yield* Effect.promise(() => | |
| 975 | nativeEvents.push({ | |
| 976 | type: "message.part.updated", | |
| 977 | properties: { | |
| 978 | sessionID: nativeSessionId, | |
| 979 | part: { | |
| 980 | id: "ambiguous-part", | |
| 981 | sessionID: nativeSessionId, | |
| 982 | messageID: "mcp-assistant", | |
| 983 | type: "tool", | |
| 984 | callID: "ambiguous-call", | |
| 985 | tool: "ambiguous_server_edit_document", | |
| 986 | state: { | |
| 987 | status: "completed", | |
| 988 | input: {}, | |
| 989 | title: "Ambiguous tool", | |
| 990 | metadata: {}, | |
| 991 | output: "", | |
| 992 | time: { start: 1, end: 2 }, | |
| 993 | }, | |
| 994 | }, | |
| 995 | }, | |
| 996 | }), | |
| 997 | ); | |
| 998 | for (const tool of ["code_search", "apply_patch"]) { | |
| 999 | yield* Effect.promise(() => | |
| 1000 | nativeEvents.push({ | |
| 1001 | type: "message.part.updated", | |
| 1002 | properties: { | |
| 1003 | sessionID: nativeSessionId, | |
| 1004 | part: { | |
| 1005 | id: `native-${tool}`, | |
| 1006 | sessionID: nativeSessionId, | |
| 1007 | messageID: "mcp-assistant", | |
| 1008 | type: "tool", | |
| 1009 | callID: `native-${tool}`, | |
| 1010 | tool, | |
| 1011 | state: { | |
| 1012 | status: "completed", | |
| 1013 | input: { query: "needle", filePath: "local.ts" }, | |
| 1014 | title: `Native ${tool}`, | |
| 1015 | metadata: {}, | |
| 1016 | output: "done", | |
| 1017 | time: { start: 1, end: 2 }, | |
| 1018 | }, | |
| 1019 | }, | |
| 1020 | }, | |
| 1021 | }), | |
| 1022 | ); | |
| 1023 | } | |
| 1024 | yield* Effect.promise(() => | |
| 1025 | nativeEvents.push({ | |
| 1026 | type: "session.compacted", | |
| 1027 | properties: { sessionID: nativeSessionId }, | |
| 1028 | }), | |
| 1029 | ); | |
| 1030 | const allItems = (yield* Fiber.join(received)).flatMap((event) => | |
| 1031 | event.type === "turn_item.updated" ? [event.turnItem] : [], | |
| 1032 | ); | |
| 1033 | assert.equal( | |
| 1034 | allItems.find((item) => item.title === "Native code_search")?.type, | |
| 1035 | "web_search", | |
| 1036 | ); | |
| 1037 | assert.equal( | |
| 1038 | allItems.find((item) => item.title === "Native apply_patch")?.type, | |
| 1039 | "file_change", | |
| 1040 | ); | |
| 1041 | const items = allItems.filter((item) => item.type === "dynamic_tool"); | |
| 1042 | assert.deepEqual( | |
| 1043 | items.slice(0, 3).map((item) => item.status), | |
| 1044 | ["running", "completed", "failed"], | |
| 1045 | ); | |
| 1046 | assert.deepEqual( | |
| 1047 | items.slice(0, 3).map((item) => item.title), | |
| 1048 | ["Edit remote document", "Edit remote document", "edit document"], | |
| 1049 | ); | |
| 1050 | for (const item of items.slice(0, 3)) { | |
| 1051 | assert.deepEqual(item.toolSource, { | |
| 1052 | key: "mcp:my.server_with_underscores", | |
| 1053 | name: "my.server with underscores", | |
| 1054 | kind: "integration", | |
| 1055 | }); | |
| 1056 | assert.deepEqual(item.input, { document: "remote-doc" }); | |
| 1057 | } | |
| 1058 | assert.equal(items[3]?.toolName, "ambiguous_server_edit_document"); | |
| 1059 | assert.isUndefined(items[3]?.toolSource); | |
| 1060 | assert.equal(statusReads, 1); | |
| 1061 | }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), | |
| 1062 | ); | |
| 1063 | ||
| 1064 | it.effect.each(["failure", "timeout"] as const)("retries MCP status after %s", (failure) => | |
| 1065 | Effect.gen(function* () { | |
| 1066 | const nativeEvents = asyncEventStream(); | |
| 1067 | const called = promiseGate<void>(); | |
| 1068 | const sessionId = `mcp-status-${failure}`; | |
| 1069 | let statusReads = 0; | |
| 1070 | let signal: AbortSignal | undefined; | |
| 1071 | const harness = yield* makeOpenCodeRuntimeHarness(sessionId, sessionId, { | |
| 1072 | event: { subscribe: async () => ({ stream: nativeEvents.stream }) }, | |
| 1073 | session: { | |
| 1074 | create: async () => ({ data: { id: sessionId, time: { created: 1, updated: 1 } } }), | |
| 1075 | promptAsync: async () => ({ data: true }), | |
| 1076 | }, | |
| 1077 | mcp: { | |
| 1078 | status: async (_input: unknown, options: { signal?: AbortSignal }) => { | |
| 1079 | statusReads++; | |
| 1080 | if (statusReads === 1) { | |
| 1081 | signal = options?.signal; | |
| 1082 | called.resolve(); | |
| 1083 | if (failure === "timeout") return new Promise(() => {}); | |
| 1084 | throw new Error("MCP status unavailable"); | |
| 1085 | } | |
| 1086 | return { data: { weather: { status: "connected" } } }; | |
| 1087 | }, | |
| 1088 | }, | |
| 1089 | }); | |
| 1090 | yield* harness.startTurn(); | |
| 1091 | const received = yield* harness.runtime.events.pipe( | |
| 1092 | Stream.takeUntil( | |
| 1093 | (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction", | |
| 1094 | ), | |
| 1095 | Stream.runCollect, | |
| 1096 | Effect.forkScoped, | |
| 1097 | ); | |
| 1098 | for (const status of ["running", "completed"] as const) { | |
| 1099 | const emitted = yield* Effect.promise(() => | |
| 1100 | nativeEvents.push({ | |
| 1101 | type: "message.part.updated", | |
| 1102 | properties: { | |
| 1103 | sessionID: sessionId, | |
| 1104 | part: { | |
| 1105 | id: "weather-part", | |
| 1106 | sessionID: sessionId, | |
| 1107 | messageID: "weather-message", | |
| 1108 | type: "tool", | |
| 1109 | callID: "weather-call", | |
| 1110 | tool: "weather_edit_document", | |
| 1111 | state: { | |
| 1112 | status, | |
| 1113 | input: { document: "remote-doc" }, | |
| 1114 | title: "Edit remote document", | |
| 1115 | metadata: {}, | |
| 1116 | output: "saved", | |
| 1117 | time: { start: 1, end: 2 }, | |
| 1118 | }, | |
| 1119 | }, | |
| 1120 | }, | |
| 1121 | }), | |
| 1122 | ).pipe(Effect.forkScoped); | |
| 1123 | if (status === "running" && failure === "timeout") { | |
| 1124 | yield* Effect.promise(() => called.promise); | |
| 1125 | yield* TestClock.adjust("1 second"); | |
| 1126 | } | |
| 1127 | yield* Fiber.join(emitted); | |
| 1128 | } | |
| 1129 | yield* Effect.promise(() => | |
| 1130 | nativeEvents.push({ type: "session.compacted", properties: { sessionID: sessionId } }), | |
| 1131 | ); | |
| 1132 | const completed = (yield* Fiber.join(received)).find( | |
| 1133 | (event) => | |
| 1134 | event.type === "turn_item.updated" && | |
| 1135 | event.turnItem.type === "dynamic_tool" && | |
| 1136 | event.turnItem.status === "completed", | |
| 1137 | ); | |
| 1138 | assert.equal(statusReads, 2); | |
| 1139 | assert.equal( | |
| 1140 | completed?.type === "turn_item.updated" && completed.turnItem.title, | |
| 1141 | "Edit remote document", | |
| 1142 | ); | |
| 1143 | assert.deepEqual(completed?.type === "turn_item.updated" && completed.turnItem.toolSource, { | |
| 1144 | key: "mcp:weather", | |
| 1145 | name: "weather", | |
| 1146 | kind: "integration", | |
| 1147 | }); | |
| 1148 | if (failure === "timeout") assert.isTrue(signal?.aborted); | |
| 1149 | }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), | |
| 1150 | ); | |
| 1151 | ||
| 1152 | it.effect("admits a native command on its user receipt before generation completes", () => | |
| 1153 | Effect.gen(function* () { | |
| 1154 | const nativeEvents = asyncEventStream(); | |
| 1155 | const release = promiseGate<void>(); | |
| 1156 | const calls = yield* Queue.unbounded<{ | |
| 1157 | messageID: string; | |
| 1158 | command: string; | |
| 1159 | arguments: string; | |
| 1160 | model: string; | |
| 1161 | }>(); | |
| 1162 | let promptCalls = 0; | |
| 1163 | const sessionId = "native-command"; | |
| 1164 | const harness = yield* makeOpenCodeRuntimeHarness("native-command", sessionId, { | |
| 1165 | event: { | |
| 1166 | subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { | |
| 1167 | options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); | |
| 1168 | return { stream: nativeEvents.stream }; | |
| 1169 | }, | |
| 1170 | }, | |
| 1171 | command: { list: async () => ({ data: [{ name: "review" }] }) }, | |
| 1172 | session: { | |
| 1173 | create: async () => ({ data: { id: sessionId, time: { created: 1, updated: 1 } } }), | |
| 1174 | command: async (input: { | |
| 1175 | messageID: string; | |
| 1176 | command: string; | |
| 1177 | arguments: string; | |
| 1178 | model: string; | |
| 1179 | }) => { | |
| 1180 | Queue.offerUnsafe(calls, input); | |
| 1181 | await release.promise; | |
| 1182 | return { data: true }; | |
| 1183 | }, | |
| 1184 | promptAsync: async () => { | |
| 1185 | promptCalls++; | |
| 1186 | return { data: true }; | |
| 1187 | }, | |
| 1188 | }, | |
| 1189 | }); | |
| 1190 | const start = yield* harness.startTurn("/review staged changes").pipe(Effect.forkScoped); | |
| 1191 | const call = yield* Queue.take(calls); | |
| 1192 | assert.equal(call.command, "review"); | |
| 1193 | assert.equal(call.arguments, "staged changes"); | |
| 1194 | assert.equal(call.model, "anthropic/claude-sonnet"); | |
| 1195 | yield* Effect.promise(() => | |
| 1196 | nativeEvents.push({ | |
| 1197 | type: "message.updated", | |
| 1198 | properties: { | |
| 1199 | sessionID: sessionId, | |
| 1200 | info: { | |
| 1201 | id: call.messageID, | |
| 1202 | sessionID: sessionId, | |
| 1203 | role: "user", | |
| 1204 | time: { created: DateTime.toEpochMillis(harness.now) }, | |
| 1205 | }, | |
| 1206 | }, | |
| 1207 | }), | |
| 1208 | ); | |
| 1209 | yield* Fiber.join(start); | |
| 1210 | assert.equal(promptCalls, 0); | |
| 1211 | release.resolve(); | |
| 1212 | }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), | |
| 1213 | ); | |
| 1214 | ||
| 1215 | it.effect("sends unadvertised slash commands as ordinary prompts", () => | |
| 1216 | Effect.gen(function* () { | |
| 1217 | const nativeEvents = asyncEventStream(); | |
| 1218 | const prompts: unknown[] = []; | |
| 1219 | const harness = yield* makeOpenCodeRuntimeHarness("unknown-command", "unknown-command", { | |
| 1220 | event: { | |
| 1221 | subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { | |
| 1222 | options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); | |
| 1223 | return { stream: nativeEvents.stream }; | |
| 1224 | }, | |
| 1225 | }, | |
| 1226 | command: { list: async () => ({ data: [{ name: "review" }] }) }, | |
| 1227 | session: { | |
| 1228 | create: async () => ({ | |
| 1229 | data: { id: "unknown-command", time: { created: 1, updated: 1 } }, | |
| 1230 | }), | |
| 1231 | promptAsync: async (input: { parts: unknown }) => { | |
| 1232 | prompts.push(input.parts); | |
| 1233 | return { data: true }; | |
| 1234 | }, | |
| 1235 | }, | |
| 1236 | }); | |
| 1237 | yield* harness.startTurn("/unknown words"); | |
| 1238 | assert.deepEqual(prompts, [[{ type: "text", text: "/unknown words" }]]); | |
| 1239 | }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), | |
| 1240 | ); | |
| 1241 | ||
| 1242 | it.effect("compacts with the native summarize API and emits a completed compaction", () => | |
| 1243 | Effect.gen(function* () { | |
| 1244 | const nativeEvents = asyncEventStream(); | |
| 1245 | const summarizeCalls: Array<unknown> = []; | |
| 1246 | let promptCalls = 0; | |
| 1247 | const harness = yield* makeOpenCodeRuntimeHarness("compact", "native-opencode-compact", { | |
| 1248 | event: { | |
| 1249 | subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { | |
| 1250 | options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); | |
| 1251 | return { stream: nativeEvents.stream }; | |
| 1252 | }, | |
| 1253 | }, | |
| 1254 | session: { | |
| 1255 | create: async () => ({ | |
| 1256 | data: { id: "native-opencode-compact", time: { created: 1, updated: 1 } }, | |
| 1257 | }), | |
| 1258 | summarize: async (input: unknown) => { | |
| 1259 | summarizeCalls.push(input); | |
| 1260 | return { data: true }; | |
| 1261 | }, | |
| 1262 | promptAsync: async () => { | |
| 1263 | promptCalls += 1; | |
| 1264 | return { data: true }; | |
| 1265 | }, | |
| 1266 | }, | |
| 1267 | }); | |
| 1268 | yield* harness.startTurn("/compact"); | |
| 1269 | const events = yield* harness.runtime.events.pipe( | |
| 1270 | Stream.takeUntil((event) => event.type === "turn.terminal"), | |
| 1271 | Stream.runCollect, | |
| 1272 | ); | |
| 1273 | assert.deepEqual(summarizeCalls, [ | |
| 1274 | { | |
| 1275 | sessionID: "native-opencode-compact", | |
| 1276 | providerID: "anthropic", | |
| 1277 | modelID: "claude-sonnet", | |
| 1278 | auto: false, | |
| 1279 | }, | |
| 1280 | ]); | |
| 1281 | assert.equal(promptCalls, 0); | |
| 1282 | assert.equal( | |
| 1283 | events.filter( | |
| 1284 | (event) => | |
| 1285 | event.type === "turn_item.updated" && | |
| 1286 | event.turnItem.type === "compaction" && | |
| 1287 | event.turnItem.status === "completed", | |
| 1288 | ).length, | |
| 1289 | 1, | |
| 1290 | ); | |
| 1291 | assert.isTrue( | |
| 1292 | events.some((event) => event.type === "turn.terminal" && event.status === "completed"), | |
| 1293 | ); | |
| 1294 | }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), | |
| 1295 | ); | |
| 1296 | ||
| 1297 | it.effect("ends a turn on the provider thread it started on", () => | |
| 1298 | Effect.gen(function* () { | |
| 1299 | const nativeEvents = asyncEventStream(); | |
| 1300 | const harness = yield* makeOpenCodeRuntimeHarness( | |
| 1301 | "forked-row", | |
| 1302 | "native-opencode-forked-row", | |
| 1303 | { | |
| 1304 | event: { | |
| 1305 | subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { | |
| 1306 | options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); | |
| 1307 | return { stream: nativeEvents.stream }; | |
| 1308 | }, | |
| 1309 | }, | |
| 1310 | session: { | |
| 1311 | create: async () => ({ | |
| 1312 | data: { id: "native-opencode-forked-row", time: { created: 1, updated: 1 } }, | |
| 1313 | }), | |
| 1314 | summarize: async () => ({ data: true }), | |
| 1315 | }, | |
| 1316 | }, | |
| 1317 | ); | |
| 1318 | // A forked run starts on its own row for the same native session, while | |
| 1319 | // the adapter tracks the session under the id it minted. | |
| 1320 | const forkedRow = { | |
| 1321 | ...harness.providerThread, | |
| 1322 | id: ProviderThreadId.make("provider-thread:opencode-test:forked-run-row"), | |
| 1323 | }; | |
| 1324 | yield* harness.startTurn("/compact", forkedRow); | |
| 1325 | const terminal = yield* harness.runtime.events.pipe( | |
| 1326 | Stream.filter( | |
| 1327 | (event): event is Extract<typeof event, { type: "turn.terminal" }> => | |
| 1328 | event.type === "turn.terminal", | |
| 1329 | ), | |
| 1330 | Stream.runHead, | |
| 1331 | ); | |
| 1332 | assert.equal(Option.getOrUndefined(terminal)?.providerThreadId, forkedRow.id); | |
| 1333 | }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), | |
| 1334 | ); | |
| 1335 | ||
| 1336 | it.effect("does not restore messages beyond OpenCode's persisted revert boundary", () => | |
| 1337 | Effect.gen(function* () { | |
| 1338 | const nativeEvents = asyncEventStream(); | |
| 1339 | const nativeSessionId = "native-opencode-reverted-snapshot"; | |
| 1340 | const messages = [ | |
| 1341 | { | |
| 1342 | info: { | |
| 1343 | id: "user-kept", | |
| 1344 | sessionID: nativeSessionId, | |
| 1345 | role: "user", | |
| 1346 | time: { created: 1 }, | |
| 1347 | }, | |
| 1348 | parts: [{ type: "text", text: "keep this prompt" }], | |
| 1349 | }, | |
| 1350 | { | |
| 1351 | info: { | |
| 1352 | id: "assistant-kept", | |
| 1353 | sessionID: nativeSessionId, | |
| 1354 | role: "assistant", | |
| 1355 | time: { created: 2, completed: 3 }, | |
| 1356 | }, | |
| 1357 | parts: [{ type: "text", text: "keep this answer" }], | |
| 1358 | }, | |
| 1359 | { | |
| 1360 | info: { | |
| 1361 | id: "user-reverted", | |
| 1362 | sessionID: nativeSessionId, | |
| 1363 | role: "user", | |
| 1364 | time: { created: 4 }, | |
| 1365 | }, | |
| 1366 | parts: [{ type: "text", text: "remove this prompt" }], | |
| 1367 | }, | |
| 1368 | { | |
| 1369 | info: { | |
| 1370 | id: "assistant-reverted", | |
| 1371 | sessionID: nativeSessionId, | |
| 1372 | role: "assistant", | |
| 1373 | time: { created: 5, completed: 6 }, | |
| 1374 | }, | |
| 1375 | parts: [{ type: "text", text: "remove this answer" }], | |
| 1376 | }, | |
| 1377 | ]; | |
| 1378 | const harness = yield* makeOpenCodeRuntimeHarness("reverted-snapshot", nativeSessionId, { | |
| 1379 | event: { | |
| 1380 | subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { | |
| 1381 | options.signal?.addEventListener("abort", () => nativeEvents.close(), { | |
| 1382 | once: true, | |
| 1383 | }); | |
| 1384 | return { stream: nativeEvents.stream }; | |
| 1385 | }, | |
| 1386 | }, | |
| 1387 | session: { | |
| 1388 | create: async () => ({ | |
| 1389 | data: { id: nativeSessionId, time: { created: 1, updated: 1 } }, | |
| 1390 | }), | |
| 1391 | get: async () => ({ | |
| 1392 | data: { | |
| 1393 | id: nativeSessionId, | |
| 1394 | time: { created: 1, updated: 6 }, | |
| 1395 | revert: { messageID: "user-reverted" }, | |
| 1396 | }, | |
| 1397 | }), | |
| 1398 | messages: async () => ({ data: messages }), | |
| 1399 | }, | |
| 1400 | }); | |
| 1401 | ||
| 1402 | const snapshot = yield* harness.runtime.readThreadSnapshot({ | |
| 1403 | providerThread: harness.providerThread, | |
| 1404 | }); | |
| 1405 | ||
| 1406 | assert.deepEqual( | |
| 1407 | snapshot.messages.map((message) => message.text), | |
| 1408 | ["keep this prompt", "keep this answer"], | |
| 1409 | ); | |
| 1410 | const providerPayload = snapshot.providerPayload as ReadonlyArray<{ | |
| 1411 | readonly info: { readonly id: string }; | |
| 1412 | }>; | |
| 1413 | assert.deepEqual( | |
| 1414 | providerPayload.map((entry) => entry.info.id), | |
| 1415 | ["user-kept", "assistant-kept"], | |
| 1416 | ); | |
| 1417 | assert.equal(snapshot.providerThread.nativeConversationHeadRef?.nativeId, "user-kept"); | |
| 1418 | }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), | |
| 1419 | ); | |
| 1420 | ||
| 1421 | it.effect("keeps a newly admitted prompt alive across stale idle and delayed busy evidence", () => | |
| 1422 | Effect.gen(function* () { | |
| 1423 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | |
| 1424 | const nativeEvents = asyncEventStream(); | |
| 1425 | const prompt = promiseGate<void>(); | |
| 1426 | const promptStarted = promiseGate<void>(); | |
| 1427 | const steerPrompt = promiseGate<void>(); | |
| 1428 | const steerPromptStarted = promiseGate<void>(); | |
| 1429 | const stopPromptStarted = promiseGate<void>(); | |
| 1430 | const statusCalled = promiseGate<void>(); | |
| 1431 | const steerStatusCalled = promiseGate<void>(); | |
| 1432 | const abortCalled = promiseGate<void>(); | |
| 1433 | let status: "idle" | "busy" = "busy"; | |
| 1434 | let promptCalls = 0; | |
| 1435 | let statusCalls = 0; | |
| 1436 | const promptInputs: Array<{ readonly messageID?: string }> = []; | |
| 1437 | const client = { | |
| 1438 | event: { | |
| 1439 | subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { | |
| 1440 | options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); | |
| 1441 | return { stream: nativeEvents.stream }; | |
| 1442 | }, | |
| 1443 | }, | |
| 1444 | session: { | |
| 1445 | create: async () => ({ | |
| 1446 | data: { id: "native-opencode-race", time: { created: 1, updated: 1 } }, | |
| 1447 | }), | |
| 1448 | get: async () => ({ | |
| 1449 | data: { id: "native-opencode-race", time: { created: 1, updated: 1 } }, | |
| 1450 | }), | |
| 1451 | promptAsync: async ( | |
| 1452 | input: { readonly messageID?: string }, | |
| 1453 | options?: { readonly signal?: AbortSignal }, | |
| 1454 | ) => { | |
| 1455 | promptInputs.push(input); | |
| 1456 | promptCalls += 1; | |
| 1457 | if (promptCalls === 1) { | |
| 1458 | promptStarted.resolve(); | |
| 1459 | await prompt.promise; | |
| 1460 | } else if (promptCalls === 2) { | |
| 1461 | steerPromptStarted.resolve(); | |
| 1462 | await steerPrompt.promise; | |
| 1463 | } else { | |
| 1464 | stopPromptStarted.resolve(); | |
| 1465 | await new Promise<void>((_resolve, reject) => | |
| 1466 | options?.signal?.addEventListener("abort", () => reject(options.signal!.reason), { | |
| 1467 | once: true, | |
| 1468 | }), | |
| 1469 | ); | |
| 1470 | } | |
| 1471 | return { data: true }; | |
| 1472 | }, | |
| 1473 | status: async () => ({ | |
| 1474 | data: (() => { | |
| 1475 | statusCalls += 1; | |
| 1476 | if (statusCalls === 1) statusCalled.resolve(); | |
| 1477 | else steerStatusCalled.resolve(); | |
| 1478 | return { "native-opencode-race": { type: status } }; | |
| 1479 | })(), | |
| 1480 | }), | |
| 1481 | messages: async () => ({ data: [] }), | |
| 1482 | children: async () => ({ data: [] }), | |
| 1483 | abort: async () => { | |
| 1484 | abortCalled.resolve(); | |
| 1485 | return { data: true }; | |
| 1486 | }, | |
| 1487 | }, | |
| 1488 | mcp: { add: async () => ({ data: true }) }, | |
| 1489 | }; | |
| 1490 | const adapter = makeOpenCodeAdapterV2({ | |
| 1491 | instanceId: ProviderInstanceId.make("opencode-test"), | |
| 1492 | settings: OPEN_CODE_TEST_SETTINGS, | |
| 1493 | environment: {}, | |
| 1494 | runtime: { | |
| 1495 | connectToOpenCodeServer: () => | |
| 1496 | Effect.succeed({ url: "http://test.invalid", external: true }), | |
| 1497 | createOpenCodeSdkClient: () => client, | |
| 1498 | } as unknown as OpenCodeRuntimeShape, | |
| 1499 | idAllocator, | |
| 1500 | serverConfig: { | |
| 1501 | cwd: "/workspace", | |
| 1502 | attachmentsDir: "/tmp/attachments", | |
| 1503 | } as ServerConfig.ServerConfig["Service"], | |
| 1504 | }); | |
| 1505 | const threadId = ThreadId.make("thread-opencode-admission-race"); | |
| 1506 | const providerSessionId = ProviderSessionId.make("session-opencode-admission-race"); | |
| 1507 | const modelSelection = { | |
| 1508 | instanceId: ProviderInstanceId.make("opencode-test"), | |
| 1509 | model: "anthropic/claude-sonnet", | |
| 1510 | options: [], | |
| 1511 | }; | |
| 1512 | const policy = runtimePolicy("full-access", { cwd: "/workspace" }); | |
| 1513 | const runtime = yield* adapter.openSession({ | |
| 1514 | threadId, | |
| 1515 | providerSessionId, | |
| 1516 | modelSelection, | |
| 1517 | runtimePolicy: policy, | |
| 1518 | }); | |
| 1519 | const providerThread = yield* runtime.ensureThread({ | |
| 1520 | threadId, | |
| 1521 | modelSelection, | |
| 1522 | runtimePolicy: policy, | |
| 1523 | }); | |
| 1524 | const now = yield* DateTime.now; | |
| 1525 | const attemptId = RunAttemptId.make("attempt-opencode-admission-race"); | |
| 1526 | const runId = RunId.make("run-opencode-admission-race"); | |
| 1527 | const start = yield* runtime | |
| 1528 | .startTurn({ | |
| 1529 | appThread: { | |
| 1530 | id: threadId, | |
| 1531 | projectId: ProjectId.make("project-opencode-admission-race"), | |
| 1532 | title: "race", | |
| 1533 | providerInstanceId: modelSelection.instanceId, | |
| 1534 | modelSelection, | |
| 1535 | runtimeMode: "full-access", | |
| 1536 | interactionMode: "default", | |
| 1537 | branch: null, | |
| 1538 | worktreePath: null, | |
| 1539 | activeProviderThreadId: providerThread.id, | |
| 1540 | lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, | |
| 1541 | forkedFrom: null, | |
| 1542 | createdBy: "user", | |
| 1543 | creationSource: "web", | |
| 1544 | createdAt: now, | |
| 1545 | updatedAt: now, | |
| 1546 | archivedAt: null, | |
| 1547 | settledOverride: null, | |
| 1548 | settledAt: null, | |
| 1549 | lastVisitedAt: null, | |
| 1550 | deletedAt: null, | |
| 1551 | }, | |
| 1552 | threadId, | |
| 1553 | runId, | |
| 1554 | runOrdinal: 1, | |
| 1555 | providerTurnOrdinal: 1, | |
| 1556 | attemptId, | |
| 1557 | rootNodeId: NodeId.make("node-opencode-admission-race"), | |
| 1558 | providerThread, | |
| 1559 | message: { | |
| 1560 | createdBy: "user", | |
| 1561 | creationSource: "web", | |
| 1562 | messageId: MessageId.make("message-opencode-admission-race"), | |
| 1563 | text: "hello", | |
| 1564 | attachments: [], | |
| 1565 | }, | |
| 1566 | modelSelection, | |
| 1567 | runtimePolicy: policy, | |
| 1568 | }) | |
| 1569 | .pipe(Effect.forkScoped); | |
| 1570 | ||
| 1571 | yield* Effect.promise(() => promptStarted.promise); | |
| 1572 | yield* Effect.promise(() => | |
| 1573 | nativeEvents.push({ | |
| 1574 | type: "session.status", | |
| 1575 | properties: { sessionID: "native-opencode-race", status: { type: "idle" } }, | |
| 1576 | }), | |
| 1577 | ); | |
| 1578 | prompt.resolve(); | |
| 1579 | yield* Fiber.join(start); | |
| 1580 | assert.match(promptInputs[0]?.messageID ?? "", /^msg_[0-9a-f]{12}[0-9A-Za-z]{28}$/); | |
| 1581 | yield* Effect.promise(() => | |
| 1582 | nativeEvents.push({ | |
| 1583 | type: "session.status", | |
| 1584 | properties: { sessionID: "native-opencode-race", status: { type: "busy" } }, | |
| 1585 | }), | |
| 1586 | ); | |
| 1587 | status = "busy"; | |
| 1588 | yield* Effect.promise(() => | |
| 1589 | nativeEvents.push({ | |
| 1590 | type: "message.updated", | |
| 1591 | properties: { | |
| 1592 | sessionID: "native-opencode-race", | |
| 1593 | info: { | |
| 1594 | id: "stale-user-message", | |
| 1595 | sessionID: "native-opencode-race", | |
| 1596 | role: "user", | |
| 1597 | time: { created: DateTime.toEpochMillis(now) }, | |
| 1598 | }, | |
| 1599 | }, | |
| 1600 | }), | |
| 1601 | ); | |
| 1602 | assert.equal(statusCalls, 0, "an unrelated user message must not release prompt admission"); | |
| 1603 | yield* Effect.promise(() => | |
| 1604 | nativeEvents.push({ | |
| 1605 | type: "session.status", | |
| 1606 | properties: { sessionID: "native-opencode-race", status: { type: "idle" } }, | |
| 1607 | }), | |
| 1608 | ); | |
| 1609 | yield* Effect.promise(() => | |
| 1610 | nativeEvents.push({ | |
| 1611 | type: "message.updated", | |
| 1612 | properties: { | |
| 1613 | sessionID: "native-opencode-race", | |
| 1614 | info: { | |
| 1615 | id: promptInputs[0]!.messageID!, | |
| 1616 | sessionID: "native-opencode-race", | |
| 1617 | role: "user", | |
| 1618 | time: { created: DateTime.toEpochMillis(now) }, | |
| 1619 | }, | |
| 1620 | }, | |
| 1621 | }), | |
| 1622 | ); | |
| 1623 | ||
| 1624 | yield* Effect.promise(() => statusCalled.promise); | |
| 1625 | ||
| 1626 | const snapshot = yield* runtime.readThreadSnapshot({ providerThread }); | |
| 1627 | assert.equal(snapshot.providerTurns.at(-1)?.status, "running"); | |
| 1628 | const activeTurn = snapshot.providerTurns.at(-1)!; | |
| 1629 | const steer = yield* runtime | |
| 1630 | .steerTurn({ | |
| 1631 | threadId, | |
| 1632 | runId, | |
| 1633 | providerThread, | |
| 1634 | providerTurnId: activeTurn.id, | |
| 1635 | message: { | |
| 1636 | messageId: MessageId.make("message-opencode-steer-race"), | |
| 1637 | text: "follow up", | |
| 1638 | attachments: [], | |
| 1639 | createdBy: "user", | |
| 1640 | creationSource: "web", | |
| 1641 | }, | |
| 1642 | }) | |
| 1643 | .pipe(Effect.forkScoped); | |
| 1644 | yield* Effect.promise(() => steerPromptStarted.promise); | |
| 1645 | assert.notEqual(promptInputs[1]?.messageID, promptInputs[0]?.messageID); | |
| 1646 | steerPrompt.resolve(); | |
| 1647 | yield* Fiber.join(steer); | |
| 1648 | yield* Effect.promise(() => | |
| 1649 | nativeEvents.push({ | |
| 1650 | type: "session.status", | |
| 1651 | properties: { sessionID: "native-opencode-race", status: { type: "busy" } }, | |
| 1652 | }), | |
| 1653 | ); | |
| 1654 | yield* Effect.promise(() => | |
| 1655 | nativeEvents.push({ | |
| 1656 | type: "message.updated", | |
| 1657 | properties: { | |
| 1658 | sessionID: "native-opencode-race", | |
| 1659 | info: { | |
| 1660 | id: promptInputs[0]!.messageID!, | |
| 1661 | sessionID: "native-opencode-race", | |
| 1662 | role: "user", | |
| 1663 | time: { created: DateTime.toEpochMillis(now) }, | |
| 1664 | }, | |
| 1665 | }, | |
| 1666 | }), | |
| 1667 | ); | |
| 1668 | yield* Effect.promise(() => | |
| 1669 | nativeEvents.push({ | |
| 1670 | type: "session.status", | |
| 1671 | properties: { sessionID: "native-opencode-race", status: { type: "idle" } }, | |
| 1672 | }), | |
| 1673 | ); | |
| 1674 | yield* Effect.promise(() => | |
| 1675 | nativeEvents.push({ | |
| 1676 | type: "message.updated", | |
| 1677 | properties: { | |
| 1678 | sessionID: "native-opencode-race", | |
| 1679 | info: { | |
| 1680 | id: promptInputs[1]!.messageID!, | |
| 1681 | sessionID: "native-opencode-race", | |
| 1682 | role: "user", | |
| 1683 | time: { created: DateTime.toEpochMillis(now) }, | |
| 1684 | }, | |
| 1685 | }, | |
| 1686 | }), | |
| 1687 | ); | |
| 1688 | yield* Effect.promise(() => steerStatusCalled.promise); | |
| 1689 | const pendingSteer = yield* runtime | |
| 1690 | .steerTurn({ | |
| 1691 | threadId, | |
| 1692 | runId, | |
| 1693 | providerThread, | |
| 1694 | providerTurnId: activeTurn.id, | |
| 1695 | message: { | |
| 1696 | messageId: MessageId.make("message-opencode-stop-pending"), | |
| 1697 | text: "pending when stopped", | |
| 1698 | attachments: [], | |
| 1699 | createdBy: "user", | |
| 1700 | creationSource: "web", | |
| 1701 | }, | |
| 1702 | }) | |
| 1703 | .pipe(Effect.exit, Effect.forkScoped); | |
| 1704 | yield* Effect.promise(() => stopPromptStarted.promise); | |
| 1705 | const interrupt = yield* runtime | |
| 1706 | .interruptTurn({ providerThread, providerTurnId: activeTurn.id }) | |
| 1707 | .pipe(Effect.forkScoped); | |
| 1708 | yield* Effect.promise(() => abortCalled.promise); | |
| 1709 | yield* Fiber.join(interrupt); | |
| 1710 | assert.isTrue(Exit.isFailure(yield* Fiber.join(pendingSteer))); | |
| 1711 | const promptWhileStopping = yield* Effect.exit( | |
| 1712 | runtime.steerTurn({ | |
| 1713 | threadId, | |
| 1714 | runId, | |
| 1715 | providerThread, | |
| 1716 | providerTurnId: activeTurn.id, | |
| 1717 | message: { | |
| 1718 | messageId: MessageId.make("message-opencode-after-stop"), | |
| 1719 | text: "must not pass the pending stop", | |
| 1720 | attachments: [], | |
| 1721 | createdBy: "user", | |
| 1722 | creationSource: "web", | |
| 1723 | }, | |
| 1724 | }), | |
| 1725 | ); | |
| 1726 | assert.isTrue(Exit.isFailure(promptWhileStopping)); | |
| 1727 | assert.equal(promptCalls, 3); | |
| 1728 | yield* Effect.promise(() => | |
| 1729 | nativeEvents.push({ | |
| 1730 | type: "session.status", | |
| 1731 | properties: { sessionID: "native-opencode-race", status: { type: "idle" } }, | |
| 1732 | }), | |
| 1733 | ); | |
| 1734 | const interrupted = yield* runtime.readThreadSnapshot({ providerThread }); | |
| 1735 | assert.equal(interrupted.providerTurns.at(-1)?.status, "interrupted"); | |
| 1736 | }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), | |
| 1737 | ); | |
| 1738 | ||
| 1739 | it.effect("interrupts an initial prompt while its SDK request is pending", () => | |
| 1740 | Effect.gen(function* () { | |
| 1741 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | |
| 1742 | const nativeEvents = asyncEventStream(); | |
| 1743 | const promptStarted = promiseGate<void>(); | |
| 1744 | const abortCalled = promiseGate<void>(); | |
| 1745 | let abortCalls = 0; | |
| 1746 | const client = { | |
| 1747 | event: { | |
| 1748 | subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { | |
| 1749 | options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); | |
| 1750 | return { stream: nativeEvents.stream }; | |
| 1751 | }, | |
| 1752 | }, | |
| 1753 | session: { | |
| 1754 | create: async () => ({ | |
| 1755 | data: { id: "native-opencode-initial-stop", time: { created: 1, updated: 1 } }, | |
| 1756 | }), | |
| 1757 | get: async () => ({ | |
| 1758 | data: { id: "native-opencode-initial-stop", time: { created: 1, updated: 1 } }, | |
| 1759 | }), | |
| 1760 | promptAsync: async (_input: unknown, options?: { readonly signal?: AbortSignal }) => { | |
| 1761 | promptStarted.resolve(); | |
| 1762 | await new Promise<void>((_resolve, reject) => { | |
| 1763 | const signal = options?.signal; | |
| 1764 | if (signal?.aborted) { | |
| 1765 | reject(signal.reason); | |
| 1766 | return; | |
| 1767 | } | |
| 1768 | signal?.addEventListener("abort", () => reject(signal.reason), { once: true }); | |
| 1769 | }); | |
| 1770 | return { data: true }; | |
| 1771 | }, | |
| 1772 | status: async () => ({ | |
| 1773 | data: { "native-opencode-initial-stop": { type: "idle" as const } }, | |
| 1774 | }), | |
| 1775 | messages: async () => ({ data: [] }), | |
| 1776 | children: async () => ({ data: [] }), | |
| 1777 | abort: async () => { | |
| 1778 | abortCalls += 1; | |
| 1779 | abortCalled.resolve(); | |
| 1780 | return { data: true }; | |
| 1781 | }, | |
| 1782 | }, | |
| 1783 | mcp: { add: async () => ({ data: true }) }, | |
| 1784 | }; | |
| 1785 | const adapter = makeOpenCodeAdapterV2({ | |
| 1786 | instanceId: ProviderInstanceId.make("opencode-initial-stop-test"), | |
| 1787 | settings: OPEN_CODE_TEST_SETTINGS, | |
| 1788 | environment: {}, | |
| 1789 | runtime: { | |
| 1790 | connectToOpenCodeServer: () => | |
| 1791 | Effect.succeed({ url: "http://test.invalid", external: true }), | |
| 1792 | createOpenCodeSdkClient: () => client, | |
| 1793 | } as unknown as OpenCodeRuntimeShape, | |
| 1794 | idAllocator, | |
| 1795 | serverConfig: { | |
| 1796 | cwd: "/workspace", | |
| 1797 | attachmentsDir: "/tmp/attachments", | |
| 1798 | } as ServerConfig.ServerConfig["Service"], | |
| 1799 | }); | |
| 1800 | const threadId = ThreadId.make("thread-opencode-initial-stop"); | |
| 1801 | const providerSessionId = ProviderSessionId.make("session-opencode-initial-stop"); | |
| 1802 | const modelSelection = { | |
| 1803 | instanceId: ProviderInstanceId.make("opencode-initial-stop-test"), | |
| 1804 | model: "anthropic/claude-sonnet", | |
| 1805 | options: [], | |
| 1806 | }; | |
| 1807 | const policy = runtimePolicy("full-access", { cwd: "/workspace" }); | |
| 1808 | const runtime = yield* adapter.openSession({ | |
| 1809 | threadId, | |
| 1810 | providerSessionId, | |
| 1811 | modelSelection, | |
| 1812 | runtimePolicy: policy, | |
| 1813 | }); | |
| 1814 | const providerThread = yield* runtime.ensureThread({ | |
| 1815 | threadId, | |
| 1816 | modelSelection, | |
| 1817 | runtimePolicy: policy, | |
| 1818 | }); | |
| 1819 | const terminalEvents = yield* runtime.events.pipe( | |
| 1820 | Stream.takeUntil((event) => event.type === "turn.terminal"), | |
| 1821 | Stream.runCollect, | |
| 1822 | Effect.forkScoped, | |
| 1823 | ); | |
| 1824 | const now = yield* DateTime.now; | |
| 1825 | const start = yield* runtime | |
| 1826 | .startTurn({ | |
| 1827 | appThread: { | |
| 1828 | id: threadId, | |
| 1829 | projectId: ProjectId.make("project-opencode-initial-stop"), | |
| 1830 | title: "initial stop", | |
| 1831 | providerInstanceId: modelSelection.instanceId, | |
| 1832 | modelSelection, | |
| 1833 | runtimeMode: "full-access", | |
| 1834 | interactionMode: "default", | |
| 1835 | branch: null, | |
| 1836 | worktreePath: null, | |
| 1837 | activeProviderThreadId: providerThread.id, | |
| 1838 | lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, | |
| 1839 | forkedFrom: null, | |
| 1840 | createdBy: "user", | |
| 1841 | creationSource: "web", | |
| 1842 | createdAt: now, | |
| 1843 | updatedAt: now, | |
| 1844 | archivedAt: null, | |
| 1845 | settledOverride: null, | |
| 1846 | settledAt: null, | |
| 1847 | lastVisitedAt: null, | |
| 1848 | deletedAt: null, | |
| 1849 | }, | |
| 1850 | threadId, | |
| 1851 | runId: RunId.make("run-opencode-initial-stop"), | |
| 1852 | runOrdinal: 1, | |
| 1853 | providerTurnOrdinal: 1, | |
| 1854 | attemptId: RunAttemptId.make("attempt-opencode-initial-stop"), | |
| 1855 | rootNodeId: NodeId.make("node-opencode-initial-stop"), | |
| 1856 | providerThread, | |
| 1857 | message: { | |
| 1858 | createdBy: "user", | |
| 1859 | creationSource: "web", | |
| 1860 | messageId: MessageId.make("message-opencode-initial-stop"), | |
| 1861 | text: "hello", | |
| 1862 | attachments: [], | |
| 1863 | }, | |
| 1864 | modelSelection, | |
| 1865 | runtimePolicy: policy, | |
| 1866 | }) | |
| 1867 | .pipe(Effect.forkScoped); | |
| 1868 | ||
| 1869 | yield* Effect.promise(() => promptStarted.promise); | |
| 1870 | const running = yield* runtime.readThreadSnapshot({ providerThread }); | |
| 1871 | const activeTurn = running.providerTurns.at(-1)!; | |
| 1872 | const interrupt = yield* runtime | |
| 1873 | .interruptTurn({ providerThread, providerTurnId: activeTurn.id }) | |
| 1874 | .pipe(Effect.forkScoped); | |
| 1875 | ||
| 1876 | yield* Effect.promise(() => abortCalled.promise); | |
| 1877 | yield* Fiber.join(start); | |
| 1878 | yield* Fiber.join(interrupt); | |
| 1879 | yield* Effect.promise(() => | |
| 1880 | nativeEvents.push({ | |
| 1881 | type: "session.status", | |
| 1882 | properties: { | |
| 1883 | sessionID: "native-opencode-initial-stop", | |
| 1884 | status: { type: "idle" }, | |
| 1885 | }, | |
| 1886 | }), | |
| 1887 | ); | |
| 1888 | ||
| 1889 | const events = Array.from(yield* Fiber.join(terminalEvents)); | |
| 1890 | const terminals = events.filter((event) => event.type === "turn.terminal"); | |
| 1891 | assert.equal(abortCalls, 1); | |
| 1892 | assert.lengthOf(terminals, 1); | |
| 1893 | assert.equal(terminals[0]?.status, "interrupted"); | |
| 1894 | assert.isNull(terminals[0]?.failure ?? null); | |
| 1895 | assert.isFalse( | |
| 1896 | events.some( | |
| 1897 | (event) => | |
| 1898 | (event.type === "turn.terminal" && event.status === "failed") || | |
| 1899 | (event.type === "provider_session.updated" && event.providerSession.status === "error"), | |
| 1900 | ), | |
| 1901 | ); | |
| 1902 | }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), | |
| 1903 | ); | |
| 1904 | ||
| 1905 | it.effect("fails an active turn when the OpenCode event stream ends cleanly", () => | |
| 1906 | Effect.gen(function* () { | |
| 1907 | const nativeEvents = asyncEventStream(); | |
| 1908 | const harness = yield* makeOpenCodeRuntimeHarness( | |
| 1909 | "clean-event-eof", | |
| 1910 | "native-opencode-clean-event-eof", | |
| 1911 | { | |
| 1912 | event: { | |
| 1913 | subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { | |
| 1914 | options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); | |
| 1915 | return { stream: nativeEvents.stream }; | |
| 1916 | }, | |
| 1917 | }, | |
| 1918 | session: { | |
| 1919 | create: async () => ({ | |
| 1920 | data: { id: "native-opencode-clean-event-eof", time: { created: 1, updated: 1 } }, | |
| 1921 | }), | |
| 1922 | promptAsync: async () => ({ data: true }), | |
| 1923 | }, | |
| 1924 | }, | |
| 1925 | ); | |
| 1926 | yield* harness.startTurn(); | |
| 1927 | const terminalEvents = yield* harness.runtime.events.pipe( | |
| 1928 | Stream.runCollect, | |
| 1929 | Effect.forkScoped, | |
| 1930 | ); | |
| 1931 | ||
| 1932 | nativeEvents.close(); | |
| 1933 | const received = Array.from(yield* Fiber.join(terminalEvents)); | |
| 1934 | assert.isTrue( | |
| 1935 | received.some( | |
| 1936 | (event) => | |
| 1937 | event.type === "provider_session.updated" && event.providerSession.status === "error", | |
| 1938 | ), | |
| 1939 | ); | |
| 1940 | const terminal = received.find((event) => event.type === "turn.terminal"); | |
| 1941 | assert.equal(terminal?.status, "failed"); | |
| 1942 | assert.equal(terminal?.failure?.class, "transport_error"); | |
| 1943 | assert.equal(terminal?.threadDisposition, "broken"); | |
| 1944 | assert.equal((yield* Effect.exit(harness.startTurn()))._tag, "Failure"); | |
| 1945 | }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), | |
| 1946 | ); | |
| 1947 | ||
| 1948 | it.effect("fails compaction when its response races stream termination", () => | |
| 1949 | Effect.gen(function* () { | |
| 1950 | const nativeEvents = asyncEventStream(); | |
| 1951 | const summarizeStarted = promiseGate<void>(); | |
| 1952 | const summarizeResult = promiseGate<{ data: boolean }>(); | |
| 1953 | const baseClock = yield* Clock.Clock; | |
| 1954 | const eofClockRead = yield* Deferred.make<void>(); | |
| 1955 | const releaseEofClockRead = yield* Deferred.make<void>(); | |
| 1956 | let blockNextClockRead = false; | |
| 1957 | const blockingClock: Clock.Clock = { | |
| 1958 | ...baseClock, | |
| 1959 | currentTimeMillis: Effect.suspend(() => { | |
| 1960 | if (!blockNextClockRead) return baseClock.currentTimeMillis; | |
| 1961 | blockNextClockRead = false; | |
| 1962 | return Deferred.succeed(eofClockRead, undefined).pipe( | |
| 1963 | Effect.andThen(Deferred.await(releaseEofClockRead)), | |
| 1964 | Effect.andThen(baseClock.currentTimeMillis), | |
| 1965 | ); | |
| 1966 | }), | |
| 1967 | }; | |
| 1968 | const harness = yield* makeOpenCodeRuntimeHarness( | |
| 1969 | "compaction-eof-race", | |
| 1970 | "native-opencode-compaction-eof-race", | |
| 1971 | { | |
| 1972 | event: { subscribe: async () => ({ stream: nativeEvents.stream }) }, | |
| 1973 | session: { | |
| 1974 | create: async () => ({ | |
| 1975 | data: { id: "native-opencode-compaction-eof-race", time: { created: 1, updated: 1 } }, | |
| 1976 | }), | |
| 1977 | summarize: () => { | |
| 1978 | summarizeStarted.resolve(); | |
| 1979 | return summarizeResult.promise; | |
| 1980 | }, | |
| 1981 | }, | |
| 1982 | }, | |
| 1983 | ).pipe(Effect.provideService(Clock.Clock, blockingClock)); | |
| 1984 | const events = yield* harness.runtime.events.pipe(Stream.runCollect, Effect.forkScoped); | |
| 1985 | const start = yield* harness.startTurn("/compact").pipe(Effect.forkScoped); | |
| 1986 | yield* Effect.promise(() => summarizeStarted.promise); | |
| 1987 | blockNextClockRead = true; | |
| 1988 | nativeEvents.close(); | |
| 1989 | yield* Deferred.await(eofClockRead); | |
| 1990 | summarizeResult.resolve({ data: true }); | |
| 1991 | yield* Fiber.join(start); | |
| 1992 | yield* Deferred.succeed(releaseEofClockRead, undefined); | |
| 1993 | const received = Array.from(yield* Fiber.join(events)); | |
| 1994 | const terminals = received.filter((event) => event.type === "turn.terminal"); | |
| 1995 | assert.lengthOf(terminals, 1); | |
| 1996 | assert.equal(terminals[0]?.status, "failed"); | |
| 1997 | assert.equal(terminals[0]?.failure?.class, "transport_error"); | |
| 1998 | assert.isFalse( | |
| 1999 | received.some( | |
| 2000 | (event) => | |
| 2001 | event.type === "turn_item.updated" && | |
| 2002 | event.turnItem.type === "compaction" && | |
| 2003 | event.turnItem.status === "completed", | |
| 2004 | ), | |
| 2005 | ); | |
| 2006 | }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), | |
| 2007 | ); | |
| 2008 | ||
| 2009 | it.effect("does not register a turn after the OpenCode event stream ends", () => | |
| 2010 | Effect.gen(function* () { | |
| 2011 | const nativeEvents = asyncEventStream(); | |
| 2012 | let promptCalls = 0; | |
| 2013 | const harness = yield* makeOpenCodeRuntimeHarness( | |
| 2014 | "event-eof-start-race", | |
| 2015 | "native-opencode-event-eof-start-race", | |
| 2016 | { | |
| 2017 | event: { | |
| 2018 | subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { | |
| 2019 | options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); | |
| 2020 | return { stream: nativeEvents.stream }; | |
| 2021 | }, | |
| 2022 | }, | |
| 2023 | session: { | |
| 2024 | create: async () => ({ | |
| 2025 | data: { | |
| 2026 | id: "native-opencode-event-eof-start-race", | |
| 2027 | time: { created: 1, updated: 1 }, | |
| 2028 | }, | |
| 2029 | }), | |
| 2030 | promptAsync: async () => { | |
| 2031 | promptCalls += 1; | |
| 2032 | return { data: true }; | |
| 2033 | }, | |
| 2034 | }, | |
| 2035 | }, | |
| 2036 | ); | |
| 2037 | const baseClock = yield* Clock.Clock; | |
| 2038 | const startClockRead = yield* Deferred.make<void>(); | |
| 2039 | const releaseStartClockRead = yield* Deferred.make<void>(); | |
| 2040 | let blockNextClockRead = true; | |
| 2041 | const blockingClock: Clock.Clock = { | |
| 2042 | ...baseClock, | |
| 2043 | currentTimeMillis: Effect.suspend(() => { | |
| 2044 | if (!blockNextClockRead) return baseClock.currentTimeMillis; | |
| 2045 | blockNextClockRead = false; | |
| 2046 | return Deferred.succeed(startClockRead, undefined).pipe( | |
| 2047 | Effect.andThen(Deferred.await(releaseStartClockRead)), | |
| 2048 | Effect.andThen(baseClock.currentTimeMillis), | |
| 2049 | ); | |
| 2050 | }), | |
| 2051 | }; | |
| 2052 | const start = yield* harness | |
| 2053 | .startTurn() | |
| 2054 | .pipe(Effect.provideService(Clock.Clock, blockingClock), Effect.exit, Effect.forkScoped); | |
| 2055 | yield* Deferred.await(startClockRead); | |
| 2056 | ||
| 2057 | const events = yield* harness.runtime.events.pipe(Stream.runCollect, Effect.forkScoped); | |
| 2058 | nativeEvents.close(); | |
| 2059 | yield* Fiber.join(events); | |
| 2060 | yield* Deferred.succeed(releaseStartClockRead, undefined); | |
| 2061 | ||
| 2062 | assert.isTrue(Exit.isFailure(yield* Fiber.join(start))); | |
| 2063 | assert.equal(promptCalls, 0); | |
| 2064 | }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), | |
| 2065 | ); | |
| 2066 | ||
| 2067 | it("holds stale idle through prompt admission until the new user message is observed", () => { | |
| 2068 | const admission = { | |
| 2069 | admissionPending: true, | |
| 2070 | admissionAccepted: false, | |
| 2071 | admissionMessageObserved: false, | |
| 2072 | idleDuringAdmission: false, | |
| 2073 | }; | |
| 2074 | ||
| 2075 | assert.equal(advanceOpenCodePromptAdmission(admission, "idle"), "hold"); | |
| 2076 | assert.equal(advanceOpenCodePromptAdmission(admission, "accepted"), "hold"); | |
| 2077 | assert.isTrue(admission.admissionPending); | |
| 2078 | assert.equal(advanceOpenCodePromptAdmission(admission, "user-message"), "reconcile-idle"); | |
| 2079 | assert.isTrue(admission.admissionPending); | |
| 2080 | }); | |
| 2081 | ||
| 2082 | it("releases admission only after prompt acceptance and new-turn evidence", () => { | |
| 2083 | const admission = { | |
| 2084 | admissionPending: true, | |
| 2085 | admissionAccepted: false, | |
| 2086 | admissionMessageObserved: false, | |
| 2087 | idleDuringAdmission: false, | |
| 2088 | }; | |
| 2089 | ||
| 2090 | assert.equal(advanceOpenCodePromptAdmission(admission, "busy"), "hold"); | |
| 2091 | assert.equal(advanceOpenCodePromptAdmission(admission, "accepted"), "release"); | |
| 2092 | assert.isFalse(admission.admissionPending); | |
| 2093 | }); | |
| 2094 | ||
| 2095 | it("releases admission when an assistant completes under a provider-assigned message id", () => { | |
| 2096 | const admission = { | |
| 2097 | admissionPending: true, | |
| 2098 | admissionAccepted: false, | |
| 2099 | admissionMessageObserved: false, | |
| 2100 | idleDuringAdmission: true, | |
| 2101 | }; | |
| 2102 | ||
| 2103 | assert.equal(advanceOpenCodePromptAdmission(admission, "assistant-completed"), "release"); | |
| 2104 | assert.isTrue(admission.admissionAccepted); | |
| 2105 | assert.isTrue(admission.admissionMessageObserved); | |
| 2106 | assert.isFalse(admission.admissionPending); | |
| 2107 | }); | |
| 2108 | ||
| 2109 | it("invalidates pending admission before aborting a turn", () => { | |
| 2110 | const admission = { | |
| 2111 | admissionGeneration: 4, | |
| 2112 | admissionPending: true, | |
| 2113 | admissionAccepted: false, | |
| 2114 | admissionMessageObserved: false, | |
| 2115 | idleDuringAdmission: false, | |
| 2116 | }; | |
| 2117 | ||
| 2118 | cancelOpenCodePromptAdmission(admission, 5); | |
| 2119 | ||
| 2120 | assert.equal(admission.admissionGeneration, 5); | |
| 2121 | assert.isFalse(admission.admissionPending); | |
| 2122 | assert.equal(advanceOpenCodePromptAdmission(admission, "accepted"), "release"); | |
| 2123 | }); | |
| 2124 | ||
| 2125 | it.effect("reconciles current idle and busy status replies after prompt admission", () => | |
| 2126 | Effect.gen(function* () { | |
| 2127 | for (const expected of ["idle", "busy"] as const) { | |
| 2128 | const admission = { admissionGeneration: 4, admissionPending: true }; | |
| 2129 | const status = yield* Deferred.make<"idle" | "busy" | "unknown">(); | |
| 2130 | const fiber = yield* reconcileOpenCodePromptAdmissionStatus( | |
| 2131 | admission, | |
| 2132 | 4, | |
| 2133 | Deferred.await(status), | |
| 2134 | ).pipe(Effect.forkChild({ startImmediately: true })); | |
| 2135 | ||
| 2136 | yield* Deferred.succeed(status, expected); | |
| 2137 | ||
| 2138 | assert.equal(yield* Fiber.join(fiber), expected); | |
| 2139 | assert.isFalse(admission.admissionPending); | |
| 2140 | } | |
| 2141 | }), | |
| 2142 | ); | |
| 2143 | ||
| 2144 | it.effect("keeps admission pending after a transient status failure", () => | |
| 2145 | Effect.gen(function* () { | |
| 2146 | const admission = { admissionGeneration: 4, admissionPending: true }; | |
| 2147 | assert.equal( | |
| 2148 | yield* reconcileOpenCodePromptAdmissionStatus(admission, 4, Effect.succeed("unknown")), | |
| 2149 | "unknown", | |
| 2150 | ); | |
| 2151 | assert.isTrue(admission.admissionPending); | |
| 2152 | assert.equal( | |
| 2153 | yield* reconcileOpenCodePromptAdmissionStatus(admission, 4, Effect.succeed("idle")), | |
| 2154 | "idle", | |
| 2155 | ); | |
| 2156 | assert.isFalse(admission.admissionPending); | |
| 2157 | }), | |
| 2158 | ); | |
| 2159 | ||
| 2160 | it.effect("retries a transient status failure without another idle event", () => | |
| 2161 | Effect.gen(function* () { | |
| 2162 | const nativeSessionId = "native-opencode-status-retry"; | |
| 2163 | const nativeEvents = asyncEventStream(); | |
| 2164 | const promptRelease = promiseGate<void>(); | |
| 2165 | const promptCalls = yield* Queue.unbounded<string>(); | |
| 2166 | const statusCalls = yield* Queue.unbounded<number>(); | |
| 2167 | let statusCallCount = 0; | |
| 2168 | const client = { | |
| 2169 | event: { | |
| 2170 | subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { | |
| 2171 | options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); | |
| 2172 | return { stream: nativeEvents.stream }; | |
| 2173 | }, | |
| 2174 | }, | |
| 2175 | session: { | |
| 2176 | create: async () => ({ | |
| 2177 | data: { id: nativeSessionId, time: { created: 1, updated: 1 } }, | |
| 2178 | }), | |
| 2179 | get: async () => ({ | |
| 2180 | data: { id: nativeSessionId, time: { created: 1, updated: 1 } }, | |
| 2181 | }), | |
| 2182 | promptAsync: async (input: { readonly messageID?: string }) => { | |
| 2183 | Queue.offerUnsafe(promptCalls, input.messageID!); | |
| 2184 | await promptRelease.promise; | |
| 2185 | return { data: true }; | |
| 2186 | }, | |
| 2187 | status: async () => { | |
| 2188 | statusCallCount += 1; | |
| 2189 | Queue.offerUnsafe(statusCalls, statusCallCount); | |
| 2190 | if (statusCallCount === 1) throw new Error("transient status failure"); | |
| 2191 | return { data: { [nativeSessionId]: { type: "idle" as const } } }; | |
| 2192 | }, | |
| 2193 | messages: async () => ({ data: [] }), | |
| 2194 | children: async () => ({ data: [] }), | |
| 2195 | abort: async () => ({ data: true }), | |
| 2196 | }, | |
| 2197 | mcp: { add: async () => ({ data: true }) }, | |
| 2198 | }; | |
| 2199 | const harness = yield* makeOpenCodeRuntimeHarness("status-retry", nativeSessionId, client); | |
| 2200 | const terminalEvents = yield* harness.runtime.events.pipe( | |
| 2201 | Stream.takeUntil((event) => event.type === "turn.terminal"), | |
| 2202 | Stream.runCollect, | |
| 2203 | Effect.forkScoped, | |
| 2204 | ); | |
| 2205 | const start = yield* harness.startTurn().pipe(Effect.forkScoped); | |
| 2206 | const admissionMessageId = yield* Queue.take(promptCalls); | |
| 2207 | ||
| 2208 | yield* Effect.promise(() => | |
| 2209 | nativeEvents.push({ | |
| 2210 | type: "session.status", | |
| 2211 | properties: { sessionID: nativeSessionId, status: { type: "idle" } }, | |
| 2212 | }), | |
| 2213 | ); | |
| 2214 | promptRelease.resolve(); | |
| 2215 | yield* Fiber.join(start); | |
| 2216 | const userMessage = { | |
| 2217 | type: "message.updated", | |
| 2218 | properties: { | |
| 2219 | sessionID: nativeSessionId, | |
| 2220 | info: { | |
| 2221 | id: admissionMessageId, | |
| 2222 | sessionID: nativeSessionId, | |
| 2223 | role: "user", | |
| 2224 | time: { created: DateTime.toEpochMillis(harness.now) }, | |
| 2225 | }, | |
| 2226 | }, | |
| 2227 | }; | |
| 2228 | yield* Effect.promise(() => nativeEvents.push(userMessage)); | |
| 2229 | assert.equal(yield* Queue.take(statusCalls), 1); | |
| 2230 | ||
| 2231 | yield* Effect.promise(() => nativeEvents.push(userMessage)); | |
| 2232 | assert.equal(statusCallCount, 1, "duplicate events must share the generation's retry worker"); | |
| 2233 | yield* TestClock.adjust("250 millis"); | |
| 2234 | assert.equal(yield* Queue.take(statusCalls), 2); | |
| 2235 | ||
| 2236 | const events = Array.from(yield* Fiber.join(terminalEvents)); | |
| 2237 | const terminals = events.filter((event) => event.type === "turn.terminal"); | |
| 2238 | assert.equal(statusCallCount, 2); | |
| 2239 | assert.lengthOf(terminals, 1); | |
| 2240 | assert.equal(terminals[0]?.status, "completed"); | |
| 2241 | assert.isNull(terminals[0]?.failure ?? null); | |
| 2242 | }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), | |
| 2243 | ); | |
| 2244 | ||
| 2245 | it.effect("does not let a queued status retry adopt a newer steer generation", () => | |
| 2246 | Effect.gen(function* () { | |
| 2247 | const nativeSessionId = "native-opencode-stale-status-retry"; | |
| 2248 | const nativeEvents = asyncEventStream(); | |
| 2249 | const firstPromptRelease = promiseGate<void>(); | |
| 2250 | const promptCalls = yield* Queue.unbounded<string>(); | |
| 2251 | const statusCalls = yield* Queue.unbounded<number>(); | |
| 2252 | let promptCallCount = 0; | |
| 2253 | let statusCallCount = 0; | |
| 2254 | const client = { | |
| 2255 | event: { | |
| 2256 | subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { | |
| 2257 | options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); | |
| 2258 | return { stream: nativeEvents.stream }; | |
| 2259 | }, | |
| 2260 | }, | |
| 2261 | session: { | |
| 2262 | create: async () => ({ | |
| 2263 | data: { id: nativeSessionId, time: { created: 1, updated: 1 } }, | |
| 2264 | }), | |
| 2265 | get: async () => ({ | |
| 2266 | data: { id: nativeSessionId, time: { created: 1, updated: 1 } }, | |
| 2267 | }), | |
| 2268 | promptAsync: async (input: { readonly messageID?: string }) => { | |
| 2269 | promptCallCount += 1; | |
| 2270 | Queue.offerUnsafe(promptCalls, input.messageID!); | |
| 2271 | if (promptCallCount === 1) { | |
| 2272 | await firstPromptRelease.promise; | |
| 2273 | } | |
| 2274 | return { data: true }; | |
| 2275 | }, | |
| 2276 | status: async () => { | |
| 2277 | statusCallCount += 1; | |
| 2278 | Queue.offerUnsafe(statusCalls, statusCallCount); | |
| 2279 | if (statusCallCount === 1) throw new Error("transient status failure"); | |
| 2280 | return { data: { [nativeSessionId]: { type: "idle" as const } } }; | |
| 2281 | }, | |
| 2282 | messages: async () => ({ data: [] }), | |
| 2283 | children: async () => ({ data: [] }), | |
| 2284 | abort: async () => ({ data: true }), | |
| 2285 | }, | |
| 2286 | mcp: { add: async () => ({ data: true }) }, | |
| 2287 | }; | |
| 2288 | const harness = yield* makeOpenCodeRuntimeHarness( | |
| 2289 | "stale-status-retry", | |
| 2290 | nativeSessionId, | |
| 2291 | client, | |
| 2292 | ); | |
| 2293 | const start = yield* harness.startTurn().pipe(Effect.forkScoped); | |
| 2294 | const firstAdmissionMessageId = yield* Queue.take(promptCalls); | |
| 2295 | ||
| 2296 | yield* Effect.promise(() => | |
| 2297 | nativeEvents.push({ | |
| 2298 | type: "session.status", | |
| 2299 | properties: { sessionID: nativeSessionId, status: { type: "idle" } }, | |
| 2300 | }), | |
| 2301 | ); | |
| 2302 | firstPromptRelease.resolve(); | |
| 2303 | yield* Fiber.join(start); | |
| 2304 | yield* Effect.promise(() => | |
| 2305 | nativeEvents.push({ | |
| 2306 | type: "message.updated", | |
| 2307 | properties: { | |
| 2308 | sessionID: nativeSessionId, | |
| 2309 | info: { | |
| 2310 | id: firstAdmissionMessageId, | |
| 2311 | sessionID: nativeSessionId, | |
| 2312 | role: "user", | |
| 2313 | time: { created: DateTime.toEpochMillis(harness.now) }, | |
| 2314 | }, | |
| 2315 | }, | |
| 2316 | }), | |
| 2317 | ); | |
| 2318 | assert.equal(yield* Queue.take(statusCalls), 1); | |
| 2319 | ||
| 2320 | const running = yield* harness.runtime.readThreadSnapshot({ | |
| 2321 | providerThread: harness.providerThread, | |
| 2322 | }); | |
| 2323 | const activeTurn = running.providerTurns.at(-1)!; | |
| 2324 | yield* harness.runtime.steerTurn({ | |
| 2325 | threadId: harness.threadId, | |
| 2326 | runId: harness.runId, | |
| 2327 | providerThread: harness.providerThread, | |
| 2328 | providerTurnId: activeTurn.id, | |
| 2329 | message: { | |
| 2330 | messageId: MessageId.make("message-opencode-stale-status-retry-steer"), | |
| 2331 | text: "new generation", | |
| 2332 | attachments: [], | |
| 2333 | createdBy: "user", | |
| 2334 | creationSource: "web", | |
| 2335 | }, | |
| 2336 | }); | |
| 2337 | yield* Queue.take(promptCalls); | |
| 2338 | yield* TestClock.adjust("250 millis"); | |
| 2339 | ||
| 2340 | const afterRetry = yield* harness.runtime.readThreadSnapshot({ | |
| 2341 | providerThread: harness.providerThread, | |
| 2342 | }); | |
| 2343 | assert.equal(statusCallCount, 1); | |
| 2344 | assert.equal(afterRetry.providerTurns.at(-1)?.status, "running"); | |
| 2345 | }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), | |
| 2346 | ); | |
| 2347 | ||
| 2348 | it.effect("ignores a delayed status reply after steering starts a newer admission", () => | |
| 2349 | Effect.gen(function* () { | |
| 2350 | const admission = { admissionGeneration: 4, admissionPending: true }; | |
| 2351 | const status = yield* Deferred.make<"idle" | "busy" | "unknown">(); | |
| 2352 | const fiber = yield* reconcileOpenCodePromptAdmissionStatus( | |
| 2353 | admission, | |
| 2354 | 4, | |
| 2355 | Deferred.await(status), | |
| 2356 | ).pipe(Effect.forkChild({ startImmediately: true })); | |
| 2357 | ||
| 2358 | admission.admissionGeneration = 5; | |
| 2359 | admission.admissionPending = true; | |
| 2360 | yield* Deferred.succeed(status, "idle"); | |
| 2361 | ||
| 2362 | assert.equal(yield* Fiber.join(fiber), "stale"); | |
| 2363 | assert.equal(admission.admissionGeneration, 5); | |
| 2364 | assert.isTrue(admission.admissionPending); | |
| 2365 | }), | |
| 2366 | ); | |
| 2367 | ||
| 2368 | it.effect("does not revive admission when abort wins a pending status lookup", () => | |
| 2369 | Effect.gen(function* () { | |
| 2370 | const admission = { admissionGeneration: 4, admissionPending: true }; | |
| 2371 | const status = yield* Deferred.make<"idle" | "busy" | "unknown">(); | |
| 2372 | const fiber = yield* reconcileOpenCodePromptAdmissionStatus( | |
| 2373 | admission, | |
| 2374 | 4, | |
| 2375 | Deferred.await(status), | |
| 2376 | ).pipe(Effect.forkChild({ startImmediately: true })); | |
| 2377 | ||
| 2378 | cancelOpenCodePromptAdmission(admission, 5); | |
| 2379 | yield* Deferred.succeed(status, "idle"); | |
| 2380 | ||
| 2381 | assert.equal(yield* Fiber.join(fiber), "stale"); | |
| 2382 | assert.isFalse(admission.admissionPending); | |
| 2383 | }), | |
| 2384 | ); | |
| 2385 | ||
| 2386 | it.effect("logs bounded structural protocol diagnostics without native payload values", () => | |
| 2387 | Effect.gen(function* () { | |
| 2388 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | |
| 2389 | const records: Array<unknown> = []; | |
| 2390 | const nativeEventLogger: EventNdjsonLogger = { | |
| 2391 | filePath: "/tmp/provider-native.ndjson", | |
| 2392 | write: (event) => Effect.sync(() => void records.push(event)), | |
| 2393 | close: () => Effect.void, | |
| 2394 | }; | |
| 2395 | const logProtocolEvent = makeOpenCodeProtocolLogger({ | |
| 2396 | nativeEventLogger, | |
| 2397 | idAllocator, | |
| 2398 | providerInstanceId: ProviderInstanceId.make("opencode-test"), | |
| 2399 | providerSessionId: ProviderSessionId.make("provider-session-opencode-test"), | |
| 2400 | threadId: ThreadId.make("thread-opencode-test"), | |
| 2401 | }); | |
| 2402 | const secret = "secret-opencode-prompt"; | |
| 2403 | ||
| 2404 | yield* logProtocolEvent({ | |
| 2405 | direction: "outgoing", | |
| 2406 | messageKind: "request", | |
| 2407 | method: "session.prompt", | |
| 2408 | payload: { prompt: secret, nested: { token: secret } }, | |
| 2409 | }); | |
| 2410 | ||
| 2411 | const serialized = encodeUnknownJson(records); | |
| 2412 | assert.notInclude(serialized, secret); | |
| 2413 | assert.include(serialized, '"protocol":"opencode-sdk.sse"'); | |
| 2414 | assert.include(serialized, '"method":"session.prompt"'); | |
| 2415 | assert.include(serialized, '"fieldCount":2'); | |
| 2416 | }).pipe(Effect.provide(IdAllocator.layer)), | |
| 2417 | ); | |
| 2418 | ||
| 2419 | it.effect("adopts the handed-over provider thread identity on session create", () => | |
| 2420 | Effect.gen(function* () { | |
| 2421 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | |
| 2422 | const serverConfig = yield* ServerConfig.ServerConfig; | |
| 2423 | let createCount = 0; | |
| 2424 | const createInputs: Array<unknown> = []; | |
| 2425 | const fakeClient = { | |
| 2426 | event: { | |
| 2427 | subscribe: async (_input?: unknown, options?: { readonly signal?: AbortSignal }) => ({ | |
| 2428 | // Emits nothing and ends when the pump's abort signal fires. | |
| 2429 | stream: { | |
| 2430 | [Symbol.asyncIterator]: () => ({ | |
| 2431 | next: () => | |
| 2432 | new Promise<IteratorResult<never>>((resolve) => { | |
| 2433 | const done = () => resolve({ done: true, value: undefined }); | |
| 2434 | if (options?.signal?.aborted) return done(); | |
| 2435 | options?.signal?.addEventListener("abort", done, { once: true }); | |
| 2436 | }), | |
| 2437 | }), | |
| 2438 | }, | |
| 2439 | }), | |
| 2440 | }, | |
| 2441 | session: { | |
| 2442 | create: async (input: unknown) => { | |
| 2443 | createCount += 1; | |
| 2444 | createInputs.push(input); | |
| 2445 | return { data: { id: `ses_native_${createCount}`, time: { created: 1, updated: 1 } } }; | |
| 2446 | }, | |
| 2447 | }, | |
| 2448 | } as unknown as OpencodeClient; | |
| 2449 | const unused = (operation: string) => () => Effect.die(`${operation} is not used`); | |
| 2450 | const runtime: OpenCodeRuntimeShape = { | |
| 2451 | startOpenCodeServerProcess: unused("startOpenCodeServerProcess"), | |
| 2452 | connectToOpenCodeServer: () => | |
| 2453 | Effect.succeed({ | |
| 2454 | url: "test://opencode", | |
| 2455 | version: "test", | |
| 2456 | exitCode: null, | |
| 2457 | external: true, | |
| 2458 | }), | |
| 2459 | runOpenCodeCommand: unused("runOpenCodeCommand"), | |
| 2460 | createOpenCodeSdkClient: () => fakeClient, | |
| 2461 | loadOpenCodeInventory: unused("loadOpenCodeInventory"), | |
| 2462 | loadInventoryFromCli: unused("loadInventoryFromCli"), | |
| 2463 | loadOpenCodeSkills: unused("loadOpenCodeSkills"), | |
| 2464 | loadSkillsFromCli: unused("loadSkillsFromCli"), | |
| 2465 | }; | |
| 2466 | const instanceId = ProviderInstanceId.make("opencode"); | |
| 2467 | const threadId = ThreadId.make("thread-opencode-adopt"); | |
| 2468 | const modelSelection = { instanceId, model: "default" }; | |
| 2469 | const adapter = makeOpenCodeAdapterV2({ | |
| 2470 | instanceId, | |
| 2471 | settings: OPENCODE_TEST_SETTINGS, | |
| 2472 | environment: {}, | |
| 2473 | runtime, | |
| 2474 | idAllocator, | |
| 2475 | serverConfig, | |
| 2476 | }); | |
| 2477 | const session = yield* adapter.openSession({ | |
| 2478 | threadId, | |
| 2479 | providerSessionId: ProviderSessionId.make("provider-session-opencode-adopt"), | |
| 2480 | modelSelection, | |
| 2481 | runtimePolicy: runtimePolicy("full-access"), | |
| 2482 | }); | |
| 2483 | const now = yield* DateTime.now; | |
| 2484 | // The placeholder row the orchestrator creates for a first run: no | |
| 2485 | // native identity yet. The adapter must bind the created session to | |
| 2486 | // this row instead of minting a second session-keyed row. | |
| 2487 | const placeholder: OrchestrationV2ProviderThread = { | |
| 2488 | id: ProviderThreadId.make("thread:provider:opencode:native-thread:pending:run:adopt:1"), | |
| 2489 | driver: OPENCODE_PROVIDER, | |
| 2490 | providerInstanceId: instanceId, | |
| 2491 | providerSessionId: null, | |
| 2492 | appThreadId: threadId, | |
| 2493 | ownerNodeId: null, | |
| 2494 | nativeThreadRef: null, | |
| 2495 | nativeConversationHeadRef: null, | |
| 2496 | status: "not_loaded", | |
| 2497 | firstRunOrdinal: 1, | |
| 2498 | lastRunOrdinal: 1, | |
| 2499 | handoffIds: [], | |
| 2500 | forkedFrom: null, | |
| 2501 | createdAt: now, | |
| 2502 | updatedAt: now, | |
| 2503 | }; | |
| 2504 | const adopted = yield* session.ensureThread({ | |
| 2505 | threadId, | |
| 2506 | modelSelection, | |
| 2507 | runtimePolicy: runtimePolicy("full-access"), | |
| 2508 | existingProviderThread: placeholder, | |
| 2509 | }); | |
| 2510 | assert.equal(adopted.id, placeholder.id); | |
| 2511 | assert.equal(adopted.nativeThreadRef?.nativeId, "ses_native_1"); | |
| 2512 | // Without a handed-over row the adapter still derives its own id. | |
| 2513 | const minted = yield* session.ensureThread({ | |
| 2514 | threadId, | |
| 2515 | modelSelection, | |
| 2516 | runtimePolicy: runtimePolicy("full-access"), | |
| 2517 | }); | |
| 2518 | assert.notEqual(minted.id, placeholder.id); | |
| 2519 | assert.equal(minted.nativeThreadRef?.nativeId, "ses_native_2"); | |
| 2520 | // OpenCode names a session from its first prompt only when create | |
| 2521 | // leaves the title unset, so the adapter never sends one. | |
| 2522 | for (const input of createInputs) { | |
| 2523 | assert.notProperty(input, "title"); | |
| 2524 | } | |
| 2525 | }).pipe( | |
| 2526 | Effect.scoped, | |
| 2527 | Effect.provide( | |
| 2528 | Layer.mergeAll( | |
| 2529 | IdAllocator.layer, | |
| 2530 | ServerConfig.layerTest(process.cwd(), { | |
| 2531 | prefix: "t3-opencode-v2-adapter-", | |
| 2532 | }).pipe(Layer.provide(NodeServices.layer)), | |
| 2533 | ), | |
| 2534 | ), | |
| 2535 | ), | |
| 2536 | ); | |
| 2537 | ||
| 2538 | it("maps native permission families to orchestration request kinds", () => { | |
| 2539 | assert.equal(openCodePermissionRequestKind("bash"), "command"); | |
| 2540 | assert.equal(openCodePermissionRequestKind("read"), "file-read"); | |
| 2541 | assert.equal(openCodePermissionRequestKind("grep"), "file-read"); | |
| 2542 | assert.equal(openCodePermissionRequestKind("external_directory"), "file-read"); | |
| 2543 | assert.equal(openCodePermissionRequestKind("external_directory", "edit"), "file-change"); | |
| 2544 | assert.equal(openCodePermissionRequestKind("edit"), "file-change"); | |
| 2545 | assert.equal(openCodePermissionRequestKind("apply_patch"), "file-change"); | |
| 2546 | assert.equal(openCodePermissionRequestKind("todowrite"), "command"); | |
| 2547 | assert.equal(openCodePermissionRequestKind("custom", "todowrite"), "command"); | |
| 2548 | }); | |
| 2549 | ||
| 2550 | it("maps OpenCode tools to semantic turn-item families", () => { | |
| 2551 | assert.equal(openCodeToolProjectionKind("bash"), "command_execution"); | |
| 2552 | assert.equal(openCodeToolProjectionKind("edit"), "file_change"); | |
| 2553 | assert.equal(openCodeToolProjectionKind("read"), "dynamic_tool"); | |
| 2554 | assert.equal(openCodeToolProjectionKind("lsp"), "file_search"); | |
| 2555 | assert.equal(openCodeToolProjectionKind("websearch"), "web_search"); | |
| 2556 | assert.equal(openCodeToolProjectionKind("codesearch"), "web_search"); | |
| 2557 | assert.equal(openCodeToolProjectionKind("todowrite"), "dynamic_tool"); | |
| 2558 | assert.equal(openCodeToolProjectionKind("custom_tool"), "dynamic_tool"); | |
| 2559 | }); | |
| 2560 | ||
| 2561 | it("maps runtime modes to safe OpenCode permission rules", () => { | |
| 2562 | const approvalRequired = openCodePermissionRules(runtimePolicy("approval-required")); | |
| 2563 | assert.equal(permissionAction(approvalRequired, "read"), "allow"); | |
| 2564 | assert.equal(permissionAction(approvalRequired, "edit"), "ask"); | |
| 2565 | assert.equal(permissionAction(approvalRequired, "bash"), "ask"); | |
| 2566 | assert.equal(permissionAction(approvalRequired, "doom_loop"), "ask"); | |
| 2567 | assert.equal(permissionAction(approvalRequired, "unknown_plugin_tool"), "ask"); | |
| 2568 | assert.equal(permissionAction(approvalRequired, "question"), "allow"); | |
| 2569 | ||
| 2570 | const autoAcceptEdits = openCodePermissionRules(runtimePolicy("auto-accept-edits")); | |
| 2571 | assert.equal(permissionAction(autoAcceptEdits, "edit"), "allow"); | |
| 2572 | assert.equal(permissionAction(autoAcceptEdits, "bash"), "ask"); | |
| 2573 | ||
| 2574 | const fullAccess = openCodePermissionRules(runtimePolicy("full-access")); | |
| 2575 | assert.equal(permissionAction(fullAccess, "bash"), "allow"); | |
| 2576 | assert.equal(permissionAction(fullAccess, "edit"), "allow"); | |
| 2577 | ||
| 2578 | const granularApproval = openCodePermissionRules( | |
| 2579 | runtimePolicy("full-access", { | |
| 2580 | approvalPolicy: { granular: { request_permissions: true } }, | |
| 2581 | }), | |
| 2582 | ); | |
| 2583 | assert.equal(permissionAction(granularApproval, "bash"), "ask"); | |
| 2584 | assert.equal(permissionAction(granularApproval, "read"), "allow"); | |
| 2585 | ||
| 2586 | const approvalRequiredWorkspaceWrite = openCodePermissionRules( | |
| 2587 | runtimePolicy("approval-required", { | |
| 2588 | sandboxPolicy: { | |
| 2589 | type: "workspaceWrite", | |
| 2590 | writableRoots: ["/tmp/opencode-workspace"], | |
| 2591 | networkAccess: false, | |
| 2592 | }, | |
| 2593 | }), | |
| 2594 | ); | |
| 2595 | assert.equal(permissionAction(approvalRequiredWorkspaceWrite, "edit"), "ask"); | |
| 2596 | }); | |
| 2597 | ||
| 2598 | it("enforces non-interactive sandbox policy through OpenCode permissions", () => { | |
| 2599 | const readOnly = openCodePermissionRules( | |
| 2600 | runtimePolicy("full-access", { | |
| 2601 | approvalPolicy: "never", | |
| 2602 | sandboxPolicy: { | |
| 2603 | type: "readOnly", | |
| 2604 | access: { type: "fullAccess" }, | |
| 2605 | networkAccess: false, | |
| 2606 | }, | |
| 2607 | }), | |
| 2608 | ); | |
| 2609 | assert.equal(permissionAction(readOnly, "read"), "allow"); | |
| 2610 | assert.equal(permissionAction(readOnly, "edit"), "deny"); | |
| 2611 | assert.equal(permissionAction(readOnly, "bash"), "deny"); | |
| 2612 | assert.equal(permissionAction(readOnly, "webfetch"), "deny"); | |
| 2613 | assert.equal(permissionAction(readOnly, "doom_loop"), "deny"); | |
| 2614 | assert.equal(permissionAction(readOnly, "unknown_plugin_tool"), "deny"); | |
| 2615 | assert.equal(permissionAction(readOnly, "external_directory"), "allow"); | |
| 2616 | ||
| 2617 | const workspaceWrite = openCodePermissionRules( | |
| 2618 | runtimePolicy("auto-accept-edits", { | |
| 2619 | approvalPolicy: "never", | |
| 2620 | sandboxPolicy: { | |
| 2621 | type: "workspaceWrite", | |
| 2622 | writableRoots: ["/tmp/opencode-workspace"], | |
| 2623 | networkAccess: true, | |
| 2624 | }, | |
| 2625 | }), | |
| 2626 | ); | |
| 2627 | assert.equal(permissionAction(workspaceWrite, "edit"), "allow"); | |
| 2628 | assert.equal(permissionAction(workspaceWrite, "bash"), "deny"); | |
| 2629 | assert.equal(permissionAction(workspaceWrite, "webfetch"), "allow"); | |
| 2630 | assert.deepInclude(workspaceWrite, { | |
| 2631 | permission: "external_directory", | |
| 2632 | pattern: "/tmp/opencode-workspace/*", | |
| 2633 | action: "allow", | |
| 2634 | }); | |
| 2635 | }); | |
| 2636 | ||
| 2637 | it("preserves OpenCode's recursion guard on task-created child sessions", () => { | |
| 2638 | const childRules = openCodeChildPermissionRules(runtimePolicy("full-access"), [ | |
| 2639 | { permission: "task", pattern: "*", action: "deny" }, | |
| 2640 | ]); | |
| 2641 | ||
| 2642 | assert.equal(permissionAction(childRules, "read"), "allow"); | |
| 2643 | assert.equal(permissionAction(childRules, "bash"), "allow"); | |
| 2644 | assert.equal(permissionAction(childRules, "task"), "deny"); | |
| 2645 | ||
| 2646 | const approvalRequiredPolicy = runtimePolicy("approval-required"); | |
| 2647 | const parentRules = openCodePermissionRules(approvalRequiredPolicy); | |
| 2648 | const childApprovalRules = openCodeChildPermissionRules(approvalRequiredPolicy, [ | |
| 2649 | ...parentRules.filter((rule) => rule.action === "deny"), | |
| 2650 | { permission: "task", pattern: "*", action: "deny" }, | |
| 2651 | ]); | |
| 2652 | assert.equal(permissionAction(childApprovalRules, "bash"), "ask"); | |
| 2653 | assert.equal(permissionAction(childApprovalRules, "task"), "deny"); | |
| 2654 | }); | |
| 2655 | ||
| 2656 | it("uses the next native user message as the exclusive fork and revert boundary", () => { | |
| 2657 | const first = providerTurn({ id: "turn:first", ordinal: 1, nativeId: "msg-user-1" }); | |
| 2658 | const synthetic = providerTurn({ id: "turn:synthetic", ordinal: 2, nativeId: null }); | |
| 2659 | const third = providerTurn({ id: "turn:third", ordinal: 3, nativeId: "msg-user-3" }); | |
| 2660 | ||
| 2661 | assert.equal( | |
| 2662 | openCodeBoundaryAfterProviderTurn([third, first, synthetic], first.id), | |
| 2663 | "msg-user-3", | |
| 2664 | ); | |
| 2665 | assert.isUndefined(openCodeBoundaryAfterProviderTurn([first, synthetic, third], third.id)); | |
| 2666 | }); | |
| 2667 | }); | |
| 2668 | ||
| 2669 | it.effect.each([false, true])( | |
| 2670 | "OpenCode rewind forks history and validates the retained boundary, invalid=%s", | |
| 2671 | (invalid) => | |
| 2672 | Effect.gen(function* () { | |
| 2673 | const events = asyncEventStream(); | |
| 2674 | const nativeSessionId = "rewind-source"; | |
| 2675 | const forkId = "rewind-fork"; | |
| 2676 | const calls: string[] = []; | |
| 2677 | const removed = { | |
| 2678 | info: { id: "first-user", sessionID: nativeSessionId, role: "user", time: { created: 1 } }, | |
| 2679 | parts: [], | |
| 2680 | }; | |
| 2681 | const harness = yield* makeOpenCodeRuntimeHarness("rewind-fork", nativeSessionId, { | |
| 2682 | event: { | |
| 2683 | subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { | |
| 2684 | options.signal?.addEventListener("abort", () => events.close(), { once: true }); | |
| 2685 | return { stream: events.stream }; | |
| 2686 | }, | |
| 2687 | }, | |
| 2688 | session: { | |
| 2689 | create: async () => ({ data: { id: nativeSessionId, time: { created: 1, updated: 1 } } }), | |
| 2690 | get: async ({ sessionID }: { sessionID: string }) => ({ | |
| 2691 | data: { id: sessionID, time: { created: 1, updated: 2 } }, | |
| 2692 | }), | |
| 2693 | messages: async ({ sessionID }: { sessionID: string }) => ({ | |
| 2694 | data: sessionID === nativeSessionId || invalid ? [removed] : [], | |
| 2695 | }), | |
| 2696 | fork: async ({ sessionID, messageID }: { sessionID: string; messageID: string }) => { | |
| 2697 | assert.equal(sessionID, nativeSessionId); | |
| 2698 | assert.equal(messageID, "first-user"); | |
| 2699 | calls.push("fork"); | |
| 2700 | return { data: { id: forkId, time: { created: 1, updated: 2 } } }; | |
| 2701 | }, | |
| 2702 | update: async () => { | |
| 2703 | calls.push("permissions"); | |
| 2704 | return { data: {} }; | |
| 2705 | }, | |
| 2706 | revert: async () => { | |
| 2707 | throw new Error("Native revert would change workspace files"); | |
| 2708 | }, | |
| 2709 | }, | |
| 2710 | }); | |
| 2711 | const effect = harness.runtime.rollbackThread({ | |
| 2712 | providerThread: harness.providerThread, | |
| 2713 | target: { | |
| 2714 | type: "thread_start", | |
| 2715 | checkpointId: CheckpointId.make("rewind-checkpoint"), | |
| 2716 | appRunOrdinal: 0, | |
| 2717 | }, | |
| 2718 | providerThreadTurns: [], | |
| 2719 | }); | |
| 2720 | if (invalid) { | |
| 2721 | const error = yield* effect.pipe(Effect.flip); | |
| 2722 | assert.equal(error._tag, "ProviderAdapterRollbackThreadError"); | |
| 2723 | assert.deepEqual(calls, ["fork"]); | |
| 2724 | } else { | |
| 2725 | const result = yield* effect; | |
| 2726 | assert.equal(result.providerThread.nativeThreadRef?.nativeId, forkId); | |
| 2727 | assert.equal(result.messages.length, 0); | |
| 2728 | assert.deepEqual(calls, ["fork", "permissions"]); | |
| 2729 | } | |
| 2730 | }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), | |
| 2731 | ); |
apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.testkit.ts+6-13| ... | ... | @@ -6,22 +6,19 @@ import * as Duration from "effect/Duration"; |
| 6 | 6 | import * as Layer from "effect/Layer"; |
| 7 | 7 | import * as Schema from "effect/Schema"; |
| 8 | 8 | |
| 9 | import * as ServerConfig from "../../config.ts"; | |
| 10 | import * as OpenCodeRuntime from "../../provider/opencodeRuntime.ts"; | |
| 9 | import { OpenCodeAdapterV2Driver } from "@t3tools/provider-opencode/server"; | |
| 10 | import * as OpenCodeRuntime from "@t3tools/provider-opencode/server/OpenCodeRuntime"; | |
| 11 | import { layerTestProviderHost } from "@t3tools/provider-testing/host"; | |
| 11 | 12 | import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts"; |
| 12 | 13 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; |
| 13 | 14 | import { ProviderAdapterDriverCreateError } from "@t3tools/provider-core/server/adapterDriver"; |
| 14 | 15 | import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts"; |
| 15 | import { | |
| 16 | makeReplayServerConfig, | |
| 17 | type OrchestratorV2ProviderReplayHarness, | |
| 18 | } from "../testkit/ProviderReplayHarness.ts"; | |
| 16 | import { type OrchestratorV2ProviderReplayHarness } from "../testkit/ProviderReplayHarness.ts"; | |
| 19 | 17 | import { |
| 20 | 18 | OPENCODE_DEFAULT_INSTANCE_ID, |
| 21 | 19 | OPENCODE_PROVIDER, |
| 22 | 20 | OPENCODE_SDK_PROTOCOL, |
| 23 | OpenCodeAdapterV2Driver, | |
| 24 | } from "./OpenCodeAdapterV2.ts"; | |
| 21 | } from "@t3tools/provider-opencode/testing"; | |
| 25 | 22 | |
| 26 | 23 | const OPENCODE_SDK_REPLAY_PROTOCOL = OPENCODE_SDK_PROTOCOL; |
| 27 | 24 | |
| ... | ... | @@ -457,10 +454,6 @@ function layerOpenCodeReplayRuntime(transcript: OpenCodeSdkReplayTranscript) { |
| 457 | 454 | } |
| 458 | 455 | |
| 459 | 456 | function layerOpenCodeProviderAdapterRegistryReplay(transcript: OpenCodeSdkReplayTranscript) { |
| 460 | const layerServerConfig = Layer.effect( | |
| 461 | ServerConfig.ServerConfig, | |
| 462 | makeReplayServerConfig(transcript.scenario).pipe(Effect.orDie), | |
| 463 | ).pipe(Layer.provide(NodeServices.layer)); | |
| 464 | 457 | return ProviderAdapterRegistry.layerFromDrivers({ |
| 465 | 458 | drivers: [OpenCodeAdapterV2Driver], |
| 466 | 459 | configMap: { |
| ... | ... | @@ -473,7 +466,7 @@ function layerOpenCodeProviderAdapterRegistryReplay(transcript: OpenCodeSdkRepla |
| 473 | 466 | Layer.provide( |
| 474 | 467 | Layer.mergeAll( |
| 475 | 468 | layerOpenCodeReplayRuntime(transcript), |
| 476 | layerServerConfig, | |
| 469 | layerTestProviderHost().pipe(Layer.provide(NodeServices.layer)), | |
| 477 | 470 | NodeServices.layer, |
| 478 | 471 | IdAllocator.layer, |
| 479 | 472 | Layer.succeed( |
apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts deleted-3807| ... | ... | @@ -1,3807 +0,0 @@ |
| 1 | import type { | |
| 2 | Event as OpenCodeEvent, | |
| 3 | Message as OpenCodeMessage, | |
| 4 | Part as OpenCodePart, | |
| 5 | PermissionRequest, | |
| 6 | PermissionRuleset, | |
| 7 | QuestionRequest, | |
| 8 | Session as OpenCodeSession, | |
| 9 | Todo as OpenCodeTodo, | |
| 10 | ToolPart, | |
| 11 | } from "@opencode-ai/sdk/v2"; | |
| 12 | import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; | |
| 13 | import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; | |
| 14 | import { causeErrorTag } from "@t3tools/shared/observability"; | |
| 15 | import { | |
| 16 | defaultInstanceIdForDriver, | |
| 17 | type ModelSelection, | |
| 18 | type OpenCodeSettings, | |
| 19 | type OrchestrationV2AppThread, | |
| 20 | type OrchestrationV2ConversationMessage, | |
| 21 | type OrchestrationV2ExecutionNode, | |
| 22 | type OrchestrationV2PlanStep, | |
| 23 | type OrchestrationV2ProviderCapabilities, | |
| 24 | type OrchestrationV2ProviderFailure, | |
| 25 | type OrchestrationV2ProviderRef, | |
| 26 | type OrchestrationV2ProviderSession, | |
| 27 | type OrchestrationV2ProviderThread, | |
| 28 | type OrchestrationV2ProviderTurn, | |
| 29 | type OrchestrationV2RuntimeRequest, | |
| 30 | type OrchestrationV2Subagent, | |
| 31 | type OrchestrationV2TurnItem, | |
| 32 | OpenCodeSettings as OpenCodeSettingsSchema, | |
| 33 | type PlanId, | |
| 34 | ProviderDriverKind, | |
| 35 | type ProviderInstanceId, | |
| 36 | type ProviderRequestKind, | |
| 37 | type ProviderSessionId, | |
| 38 | type RuntimeRequestId, | |
| 39 | type ThreadId, | |
| 40 | } from "@t3tools/contracts"; | |
| 41 | import * as Cause from "effect/Cause"; | |
| 42 | import * as DateTime from "effect/DateTime"; | |
| 43 | import * as Deferred from "effect/Deferred"; | |
| 44 | import * as Duration from "effect/Duration"; | |
| 45 | import * as Effect from "effect/Effect"; | |
| 46 | import * as Exit from "effect/Exit"; | |
| 47 | import * as Fiber from "effect/Fiber"; | |
| 48 | import * as Layer from "effect/Layer"; | |
| 49 | import * as Option from "effect/Option"; | |
| 50 | import * as Queue from "effect/Queue"; | |
| 51 | import * as Random from "effect/Random"; | |
| 52 | import * as Schema from "effect/Schema"; | |
| 53 | import * as Semaphore from "effect/Semaphore"; | |
| 54 | import * as Scope from "effect/Scope"; | |
| 55 | import * as Stream from "effect/Stream"; | |
| 56 | ||
| 57 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; | |
| 58 | import * as ServerConfig from "../../config.ts"; | |
| 59 | import { mcpToolPresentation } from "@t3tools/provider-core/server/mcpToolPresentation"; | |
| 60 | import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; | |
| 61 | import type { EventNdjsonLogger } from "../../provider/EventNdjsonLogger.ts"; | |
| 62 | import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts"; | |
| 63 | import { | |
| 64 | structuralProtocolMethod, | |
| 65 | summarizeNativeProtocolPayload, | |
| 66 | } from "../../provider/NativeProtocolLogging.ts"; | |
| 67 | import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; | |
| 68 | import { t3OrchestrationSystemPrompt } from "@t3tools/provider-core/server/orchestrationInstructions"; | |
| 69 | import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions"; | |
| 70 | import * as OpenCodeRuntime from "../../provider/opencodeRuntime.ts"; | |
| 71 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; | |
| 72 | import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; | |
| 73 | import { turnScopedSelectionTransition } from "@t3tools/provider-core/server/selectionTransition"; | |
| 74 | import { providerMessageTextWithAttachmentPaths } from "@t3tools/provider-core/server/attachmentPrompt"; | |
| 75 | import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; | |
| 76 | import { | |
| 77 | ProviderAdapterDriverCreateError, | |
| 78 | type ProviderAdapterDriver, | |
| 79 | type ProviderAdapterDriverCreateInput, | |
| 80 | } from "@t3tools/provider-core/server/adapterDriver"; | |
| 81 | import { makeSubagentChildThread, subagentThreadTitle } from "../SubagentProjection.ts"; | |
| 82 | import { openCodeToolTurnItem } from "./OpenCodeToolItems.ts"; | |
| 83 | ||
| 84 | export { openCodeToolProjectionKind } from "./OpenCodeToolItems.ts"; | |
| 85 | ||
| 86 | export const OPENCODE_PROVIDER = ProviderDriverKind.make("opencode"); | |
| 87 | export const OPENCODE_DEFAULT_INSTANCE_ID = defaultInstanceIdForDriver(OPENCODE_PROVIDER); | |
| 88 | export const OPENCODE_SDK_PROTOCOL = "opencode-sdk.sse" as const; | |
| 89 | const DEFAULT_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettingsSchema)({}); | |
| 90 | ||
| 91 | let openCodeMessageIdEpochMillis = -1; | |
| 92 | let openCodeMessageIdCounter = 0; | |
| 93 | ||
| 94 | const makeOpenCodeMessageId = Effect.fnUntraced(function* () { | |
| 95 | const epochMillis = DateTime.toEpochMillis(yield* DateTime.now); | |
| 96 | if (epochMillis !== openCodeMessageIdEpochMillis) { | |
| 97 | openCodeMessageIdEpochMillis = epochMillis; | |
| 98 | openCodeMessageIdCounter = 0; | |
| 99 | } | |
| 100 | openCodeMessageIdCounter += 1; | |
| 101 | const encodedTime = BigInt.asUintN( | |
| 102 | 48, | |
| 103 | BigInt(epochMillis) * 0x1000n + BigInt(openCodeMessageIdCounter), | |
| 104 | ) | |
| 105 | .toString(16) | |
| 106 | .padStart(12, "0"); | |
| 107 | const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; | |
| 108 | const random = (yield* Effect.forEach(Array.from({ length: 28 }), () => | |
| 109 | Random.nextIntBetween(0, alphabet.length - 1), | |
| 110 | )) | |
| 111 | .map((index) => alphabet[index]) | |
| 112 | .join(""); | |
| 113 | return `msg_${encodedTime}${random}`; | |
| 114 | }); | |
| 115 | ||
| 116 | /** | |
| 117 | * OpenCode's session, message, part, and interaction-request identifiers are | |
| 118 | * durable. It does not expose a first-class turn object: the initiating user | |
| 119 | * message is the best native turn correlation point, and session idle is the | |
| 120 | * authoritative terminal signal. | |
| 121 | */ | |
| 122 | const OpenCodeProviderCapabilitiesV2 = { | |
| 123 | sessions: { | |
| 124 | // The current adapter owns one directory-bound client/server per session. | |
| 125 | // Keep it isolated until its runtime is made safe for cross-thread pooling. | |
| 126 | supportsMultipleProviderThreadsPerSession: false, | |
| 127 | supportsModelSwitchInSession: true, | |
| 128 | supportsProviderSwitchingViaHandoff: true, | |
| 129 | supportsRuntimeModeSwitchInSession: false, | |
| 130 | pendingRequestsSurviveRestart: false, | |
| 131 | }, | |
| 132 | threads: { | |
| 133 | canCreateEmptyThread: true, | |
| 134 | canReadThreadSnapshot: true, | |
| 135 | canRollbackThread: true, | |
| 136 | canForkThread: true, | |
| 137 | canForkFromTurn: true, | |
| 138 | canForkFromSubagentThread: true, | |
| 139 | exposesNativeThreadId: true, | |
| 140 | }, | |
| 141 | turns: { | |
| 142 | exposesNativeTurnId: false, | |
| 143 | emitsTurnStarted: true, | |
| 144 | emitsTurnCompleted: true, | |
| 145 | supportsInterrupt: true, | |
| 146 | supportsActiveSteering: true, | |
| 147 | supportsSteeringByInterruptRestart: true, | |
| 148 | supportsQueuedMessages: true, | |
| 149 | terminalStatusQuality: "strong", | |
| 150 | }, | |
| 151 | streaming: { | |
| 152 | streamsAssistantText: true, | |
| 153 | streamsReasoning: true, | |
| 154 | streamsToolOutput: true, | |
| 155 | streamsPlanText: false, | |
| 156 | emitsMessageCompleted: true, | |
| 157 | }, | |
| 158 | tools: { | |
| 159 | exposesToolItemIds: true, | |
| 160 | emitsToolStarted: true, | |
| 161 | emitsToolCompleted: true, | |
| 162 | emitsToolOutput: true, | |
| 163 | supportsMcpTools: true, | |
| 164 | supportsDynamicToolCallbacks: false, | |
| 165 | }, | |
| 166 | approvals: { | |
| 167 | supportsCommandApproval: true, | |
| 168 | supportsFileReadApproval: true, | |
| 169 | supportsFileChangeApproval: true, | |
| 170 | supportsApplyPatchApproval: true, | |
| 171 | approvalsHaveNativeRequestIds: true, | |
| 172 | approvalCallbacksAreLiveOnly: true, | |
| 173 | approvalsCanOriginateFromSubagents: true, | |
| 174 | }, | |
| 175 | planning: { | |
| 176 | emitsPlanUpdated: true, | |
| 177 | emitsTodoList: true, | |
| 178 | emitsProposedPlan: false, | |
| 179 | supportsStructuredQuestions: true, | |
| 180 | planDeltasHaveItemIds: false, | |
| 181 | }, | |
| 182 | subagents: { | |
| 183 | supportsSubagents: true, | |
| 184 | exposesSubagentThreadIds: true, | |
| 185 | emitsSubagentLifecycle: true, | |
| 186 | canWaitForSubagents: true, | |
| 187 | canCloseSubagents: false, | |
| 188 | canForkSubagentThread: true, | |
| 189 | }, | |
| 190 | context: { | |
| 191 | acceptsSystemContext: false, | |
| 192 | acceptsDeveloperContext: false, | |
| 193 | acceptsSyntheticUserContext: true, | |
| 194 | canGenerateSummaries: true, | |
| 195 | canConsumeHandoffSummaries: true, | |
| 196 | supportsDeltaHandoff: true, | |
| 197 | supportsFullThreadHandoff: true, | |
| 198 | maxRecommendedHandoffChars: null, | |
| 199 | }, | |
| 200 | checkpointing: { | |
| 201 | appCanCheckpointFilesystem: true, | |
| 202 | supportsNestedCheckpointScopes: true, | |
| 203 | providerCanRollbackConversation: true, | |
| 204 | providerRollbackReturnsSnapshot: true, | |
| 205 | providerCanReadConversationSnapshot: true, | |
| 206 | }, | |
| 207 | identity: { | |
| 208 | nativeThreadIds: "strong", | |
| 209 | nativeTurnIds: "weak", | |
| 210 | nativeItemIds: "strong", | |
| 211 | nativeRequestIds: "strong", | |
| 212 | }, | |
| 213 | runtimePolicy: { | |
| 214 | enforcement: "native", | |
| 215 | }, | |
| 216 | } satisfies OrchestrationV2ProviderCapabilities; | |
| 217 | ||
| 218 | type TerminalTurnStatus = Extract< | |
| 219 | OrchestrationV2ProviderTurn["status"], | |
| 220 | "completed" | "interrupted" | "failed" | "cancelled" | |
| 221 | >; | |
| 222 | ||
| 223 | type OpenCodeStepUsage = Pick< | |
| 224 | Extract<OpenCodePart, { readonly type: "step-finish" }>, | |
| 225 | "id" | "tokens" | |
| 226 | >; | |
| 227 | ||
| 228 | interface OpenCodeTurnTokenUsageAccumulator { | |
| 229 | readonly partIds: Set<string>; | |
| 230 | readonly promptMessageIds: Set<string>; | |
| 231 | readonly assistantOwnershipByMessageId: Map<string, "owned" | "other" | "unknown">; | |
| 232 | // Native removal does not undo usage. Keep unresolved counts until this turn settles. | |
| 233 | readonly unresolvedStepsByMessageId: Map<string, Map<string, OpenCodeStepUsage>>; | |
| 234 | inputTokens: number; | |
| 235 | cachedInputTokens: number; | |
| 236 | cacheCreationTokens: number; | |
| 237 | outputTokens: number; | |
| 238 | reasoningTokens: number; | |
| 239 | hasSubagents: boolean; | |
| 240 | complete: boolean; | |
| 241 | } | |
| 242 | ||
| 243 | function makeOpenCodeTurnTokenUsageAccumulator(): OpenCodeTurnTokenUsageAccumulator { | |
| 244 | return { | |
| 245 | partIds: new Set(), | |
| 246 | promptMessageIds: new Set(), | |
| 247 | assistantOwnershipByMessageId: new Map(), | |
| 248 | unresolvedStepsByMessageId: new Map(), | |
| 249 | inputTokens: 0, | |
| 250 | cachedInputTokens: 0, | |
| 251 | cacheCreationTokens: 0, | |
| 252 | outputTokens: 0, | |
| 253 | reasoningTokens: 0, | |
| 254 | hasSubagents: false, | |
| 255 | complete: true, | |
| 256 | }; | |
| 257 | } | |
| 258 | ||
| 259 | function accumulateOpenCodeStepUsage( | |
| 260 | accumulator: OpenCodeTurnTokenUsageAccumulator, | |
| 261 | part: OpenCodeStepUsage, | |
| 262 | ): void { | |
| 263 | if (accumulator.partIds.has(part.id)) return; | |
| 264 | accumulator.partIds.add(part.id); | |
| 265 | accumulator.inputTokens += part.tokens.input + part.tokens.cache.read + part.tokens.cache.write; | |
| 266 | accumulator.cachedInputTokens += part.tokens.cache.read; | |
| 267 | accumulator.cacheCreationTokens += part.tokens.cache.write; | |
| 268 | accumulator.outputTokens += part.tokens.output + part.tokens.reasoning; | |
| 269 | accumulator.reasoningTokens += part.tokens.reasoning; | |
| 270 | } | |
| 271 | ||
| 272 | interface ActiveOpenCodeTurn { | |
| 273 | readonly usage: OpenCodeTurnTokenUsageAccumulator; | |
| 274 | readonly isRoot: boolean; | |
| 275 | readonly threadId: ThreadId; | |
| 276 | readonly runId: ProviderAdapter.ProviderAdapterV2TurnInput["runId"] | null; | |
| 277 | readonly rootNodeId: ProviderAdapter.ProviderAdapterV2TurnInput["rootNodeId"]; | |
| 278 | readonly appThread: OrchestrationV2AppThread; | |
| 279 | readonly modelSelection: ModelSelection; | |
| 280 | readonly runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy; | |
| 281 | readonly providerTurnId: OrchestrationV2ProviderTurn["id"]; | |
| 282 | /** The provider thread the turn started on, which its terminal names. */ | |
| 283 | readonly providerThreadId: OrchestrationV2ProviderTurn["providerThreadId"]; | |
| 284 | readonly providerTurnOrdinal: number; | |
| 285 | readonly runOrdinal: number; | |
| 286 | readonly runAttemptId: OrchestrationV2ProviderTurn["runAttemptId"]; | |
| 287 | readonly startedAt: DateTime.Utc; | |
| 288 | readonly itemOrdinals: Map<string, number>; | |
| 289 | readonly parts: Map<string, Exclude<OpenCodePart, ToolPart>>; | |
| 290 | readonly partIdsByMessage: Map<string, Set<string>>; | |
| 291 | readonly toolNamesByCallId: Map<string, string>; | |
| 292 | mcpServerNames?: ReadonlyArray<string>; | |
| 293 | readonly providerTurn: OrchestrationV2ProviderTurn; | |
| 294 | nextItemOrdinal: number; | |
| 295 | nativeUserMessageId: string | null; | |
| 296 | admissionMessageId: string | null; | |
| 297 | interrupted: boolean; | |
| 298 | finalized: boolean; | |
| 299 | planId: PlanId | null; | |
| 300 | admissionGeneration: number; | |
| 301 | admissionReconciliationGeneration: number | null; | |
| 302 | admissionPending: boolean; | |
| 303 | admissionAccepted: boolean; | |
| 304 | admissionMessageObserved: boolean; | |
| 305 | idleDuringAdmission: boolean; | |
| 306 | admissionSettled: Deferred.Deferred<void>; | |
| 307 | admissionAbortController: AbortController | null; | |
| 308 | } | |
| 309 | ||
| 310 | type OpenCodeAdmissionSignal = | |
| 311 | | "accepted" | |
| 312 | | "assistant-completed" | |
| 313 | | "busy" | |
| 314 | | "idle" | |
| 315 | | "user-message"; | |
| 316 | type OpenCodeAdmissionAction = "hold" | "reconcile-idle" | "release"; | |
| 317 | ||
| 318 | export function advanceOpenCodePromptAdmission( | |
| 319 | admission: Pick< | |
| 320 | ActiveOpenCodeTurn, | |
| 321 | "admissionAccepted" | "admissionMessageObserved" | "admissionPending" | "idleDuringAdmission" | |
| 322 | >, | |
| 323 | signal: OpenCodeAdmissionSignal, | |
| 324 | ): OpenCodeAdmissionAction { | |
| 325 | if (!admission.admissionPending) return "release"; | |
| 326 | if (signal === "assistant-completed") { | |
| 327 | admission.admissionAccepted = true; | |
| 328 | admission.admissionMessageObserved = true; | |
| 329 | admission.admissionPending = false; | |
| 330 | return "release"; | |
| 331 | } | |
| 332 | if (signal === "idle") { | |
| 333 | admission.idleDuringAdmission = true; | |
| 334 | return "hold"; | |
| 335 | } | |
| 336 | if (signal === "accepted") admission.admissionAccepted = true; | |
| 337 | if (signal === "busy" || signal === "user-message") admission.admissionMessageObserved = true; | |
| 338 | if (!admission.admissionAccepted || !admission.admissionMessageObserved) return "hold"; | |
| 339 | if (admission.idleDuringAdmission) return "reconcile-idle"; | |
| 340 | admission.admissionPending = false; | |
| 341 | return "release"; | |
| 342 | } | |
| 343 | ||
| 344 | export function cancelOpenCodePromptAdmission( | |
| 345 | admission: Pick<ActiveOpenCodeTurn, "admissionGeneration" | "admissionPending">, | |
| 346 | nextGeneration: number, | |
| 347 | ): void { | |
| 348 | admission.admissionGeneration = nextGeneration; | |
| 349 | admission.admissionPending = false; | |
| 350 | } | |
| 351 | ||
| 352 | export const reconcileOpenCodePromptAdmissionStatus = Effect.fn( | |
| 353 | "reconcileOpenCodePromptAdmissionStatus", | |
| 354 | )(function* ( | |
| 355 | admission: Pick<ActiveOpenCodeTurn, "admissionGeneration" | "admissionPending">, | |
| 356 | generation: number, | |
| 357 | readStatus: Effect.Effect<"busy" | "idle" | "unknown">, | |
| 358 | ) { | |
| 359 | if (admission.admissionGeneration !== generation || !admission.admissionPending) { | |
| 360 | return "stale" as const; | |
| 361 | } | |
| 362 | const status = yield* readStatus; | |
| 363 | if (admission.admissionGeneration !== generation || !admission.admissionPending) { | |
| 364 | return "stale" as const; | |
| 365 | } | |
| 366 | if (status !== "unknown") admission.admissionPending = false; | |
| 367 | return status; | |
| 368 | }); | |
| 369 | ||
| 370 | interface OpenCodeSubagentContext { | |
| 371 | readonly nativeItemId: string; | |
| 372 | readonly nodeId: OrchestrationV2Subagent["id"]; | |
| 373 | readonly parentState: OpenCodeThreadState; | |
| 374 | readonly parentTurn: ActiveOpenCodeTurn; | |
| 375 | readonly prompt: string; | |
| 376 | readonly title: string | null; | |
| 377 | readonly startedAt: DateTime.Utc; | |
| 378 | childSessionId: string | null; | |
| 379 | childThreadId: ThreadId | null; | |
| 380 | childProviderThreadId: OrchestrationV2ProviderThread["id"] | null; | |
| 381 | model: string | null; | |
| 382 | result: string | null; | |
| 383 | } | |
| 384 | ||
| 385 | interface OpenCodeThreadState { | |
| 386 | readonly nativeSessionId: string; | |
| 387 | providerThread: OrchestrationV2ProviderThread; | |
| 388 | appThread: OrchestrationV2AppThread | null; | |
| 389 | activeTurn: ActiveOpenCodeTurn | null; | |
| 390 | readonly providerTurns: Map<string, OrchestrationV2ProviderTurn>; | |
| 391 | readonly messages: Map<string, OrchestrationV2ConversationMessage>; | |
| 392 | readonly runtimeRequests: Map<string, OrchestrationV2RuntimeRequest>; | |
| 393 | readonly messageRoles: Map<string, "user" | "assistant">; | |
| 394 | readonly userMessageIds: Array<string>; | |
| 395 | parentSubagent: OpenCodeSubagentContext | null; | |
| 396 | nextChildTurnOrdinal: number; | |
| 397 | nextAdmissionGeneration: number; | |
| 398 | } | |
| 399 | ||
| 400 | interface OpenCodeRequestOwner { | |
| 401 | readonly state: OpenCodeThreadState; | |
| 402 | readonly turn: ActiveOpenCodeTurn; | |
| 403 | /** The subagent on the owner's thread that leads to the asking session. */ | |
| 404 | readonly subagent: OpenCodeSubagentContext | null; | |
| 405 | } | |
| 406 | ||
| 407 | /** | |
| 408 | * The top-level thread and active turn that own a session's requests, walking | |
| 409 | * up through (possibly nested) subagents. | |
| 410 | */ | |
| 411 | function topLevelRequestOwner(state: OpenCodeThreadState): OpenCodeRequestOwner | undefined { | |
| 412 | let owner = state; | |
| 413 | let subagent: OpenCodeSubagentContext | null = null; | |
| 414 | while (owner.parentSubagent !== null) { | |
| 415 | subagent = owner.parentSubagent; | |
| 416 | owner = subagent.parentState; | |
| 417 | } | |
| 418 | return owner.activeTurn === null ? undefined : { state: owner, turn: owner.activeTurn, subagent }; | |
| 419 | } | |
| 420 | ||
| 421 | interface PendingOpenCodeRequest { | |
| 422 | readonly requestId: RuntimeRequestId; | |
| 423 | readonly nativeRequestId: string; | |
| 424 | /** The session that asked, which may be a subagent's. */ | |
| 425 | readonly nativeSessionId: string; | |
| 426 | readonly turn: ActiveOpenCodeTurn; | |
| 427 | readonly state: OpenCodeThreadState; | |
| 428 | readonly nodeId: OrchestrationV2ExecutionNode["id"]; | |
| 429 | readonly parentNodeId: OrchestrationV2ExecutionNode["id"]; | |
| 430 | readonly turnItemId: OrchestrationV2TurnItem["id"]; | |
| 431 | readonly requestKind: OpenCodePermissionRequestKind | "user_input"; | |
| 432 | readonly createdAt: DateTime.Utc; | |
| 433 | readonly permission?: PermissionRequest; | |
| 434 | readonly question?: QuestionRequest; | |
| 435 | } | |
| 436 | ||
| 437 | export interface OpenCodeAdapterV2Options { | |
| 438 | readonly instanceId: ProviderInstanceId; | |
| 439 | readonly settings: OpenCodeSettings; | |
| 440 | readonly environment: NodeJS.ProcessEnv; | |
| 441 | readonly runtime: OpenCodeRuntime.OpenCodeRuntimeShape; | |
| 442 | readonly idAllocator: IdAllocator.IdAllocatorV2Shape; | |
| 443 | readonly serverConfig: ServerConfig.ServerConfig["Service"]; | |
| 444 | readonly nativeEventLogger?: EventNdjsonLogger; | |
| 445 | } | |
| 446 | ||
| 447 | export interface OpenCodeProtocolLogEvent { | |
| 448 | readonly direction: "incoming" | "outgoing"; | |
| 449 | readonly messageKind: "request" | "response" | "notification" | "error"; | |
| 450 | readonly method: string; | |
| 451 | readonly payload: unknown; | |
| 452 | } | |
| 453 | ||
| 454 | function formatOpenCodeProtocolLogPayload(event: OpenCodeProtocolLogEvent) { | |
| 455 | return { | |
| 456 | direction: event.direction, | |
| 457 | messageKind: event.messageKind, | |
| 458 | method: structuralProtocolMethod(event.method), | |
| 459 | payload: summarizeNativeProtocolPayload(event.payload), | |
| 460 | }; | |
| 461 | } | |
| 462 | ||
| 463 | export function makeOpenCodeProtocolLogger(input: { | |
| 464 | readonly nativeEventLogger: EventNdjsonLogger | undefined; | |
| 465 | readonly idAllocator: IdAllocator.IdAllocatorV2Shape; | |
| 466 | readonly providerInstanceId: ProviderInstanceId; | |
| 467 | readonly providerSessionId: ProviderSessionId; | |
| 468 | readonly threadId: ThreadId; | |
| 469 | }): (event: OpenCodeProtocolLogEvent) => Effect.Effect<void, never> { | |
| 470 | return (event) => | |
| 471 | Effect.gen(function* () { | |
| 472 | if (!input.nativeEventLogger) return; | |
| 473 | const observedAt = DateTime.formatIso(yield* DateTime.now); | |
| 474 | const method = structuralProtocolMethod(event.method); | |
| 475 | yield* input.nativeEventLogger.write( | |
| 476 | { | |
| 477 | observedAt, | |
| 478 | event: { | |
| 479 | id: yield* input.idAllocator.allocate.rawEvent({ | |
| 480 | providerSessionId: input.providerSessionId, | |
| 481 | method, | |
| 482 | }), | |
| 483 | kind: "protocol", | |
| 484 | protocol: OPENCODE_SDK_PROTOCOL, | |
| 485 | provider: OPENCODE_PROVIDER, | |
| 486 | providerInstanceId: input.providerInstanceId, | |
| 487 | providerSessionId: input.providerSessionId, | |
| 488 | createdAt: observedAt, | |
| 489 | threadId: input.threadId, | |
| 490 | payload: formatOpenCodeProtocolLogPayload(event), | |
| 491 | }, | |
| 492 | }, | |
| 493 | input.threadId, | |
| 494 | ); | |
| 495 | }).pipe( | |
| 496 | Effect.catchCause((cause) => | |
| 497 | Cause.hasInterrupts(cause) | |
| 498 | ? Effect.interrupt | |
| 499 | : Effect.logWarning("Failed to write native OpenCode event log.", { | |
| 500 | errorTag: causeErrorTag(cause), | |
| 501 | reasonCount: cause.reasons.length, | |
| 502 | provider: OPENCODE_PROVIDER, | |
| 503 | threadId: input.threadId, | |
| 504 | }), | |
| 505 | ), | |
| 506 | ); | |
| 507 | } | |
| 508 | ||
| 509 | function protocolError( | |
| 510 | detail: string, | |
| 511 | payload?: unknown, | |
| 512 | ): ProviderAdapter.ProviderAdapterProtocolError { | |
| 513 | return new ProviderAdapter.ProviderAdapterProtocolError({ | |
| 514 | driver: OPENCODE_PROVIDER, | |
| 515 | detail, | |
| 516 | ...(payload === undefined ? {} : { payload }), | |
| 517 | }); | |
| 518 | } | |
| 519 | ||
| 520 | function nativeThreadId(providerThread: OrchestrationV2ProviderThread): string { | |
| 521 | const nativeId = providerThread.nativeThreadRef?.nativeId; | |
| 522 | if (nativeId === null || nativeId === undefined) { | |
| 523 | throw protocolError(`Provider thread ${providerThread.id} has no OpenCode session id`); | |
| 524 | } | |
| 525 | return nativeId; | |
| 526 | } | |
| 527 | ||
| 528 | function dateTimeFromEpoch(value: number | undefined, fallback: DateTime.Utc): DateTime.Utc { | |
| 529 | if (value === undefined) return fallback; | |
| 530 | return Option.getOrElse(DateTime.make(value), () => fallback); | |
| 531 | } | |
| 532 | ||
| 533 | function nonEmptyString(value: unknown): string | undefined { | |
| 534 | return typeof value === "string" && value.trim().length > 0 ? value : undefined; | |
| 535 | } | |
| 536 | ||
| 537 | function recordValue(input: unknown, key: string): unknown { | |
| 538 | return typeof input === "object" && input !== null && key in input | |
| 539 | ? (input as Record<string, unknown>)[key] | |
| 540 | : undefined; | |
| 541 | } | |
| 542 | ||
| 543 | function recordString(input: unknown, ...keys: ReadonlyArray<string>): string | undefined { | |
| 544 | for (const key of keys) { | |
| 545 | const value = nonEmptyString(recordValue(input, key)); | |
| 546 | if (value !== undefined) return value; | |
| 547 | } | |
| 548 | return undefined; | |
| 549 | } | |
| 550 | ||
| 551 | function sdkResponseForRawLog(value: unknown): unknown { | |
| 552 | if (typeof value !== "object" || value === null) return value; | |
| 553 | if ("data" in value) return { data: (value as { readonly data?: unknown }).data ?? null }; | |
| 554 | if ("stream" in value) return { subscribed: true }; | |
| 555 | return value; | |
| 556 | } | |
| 557 | ||
| 558 | type OpenCodePermissionRequestKind = Extract< | |
| 559 | ProviderRequestKind, | |
| 560 | "command" | "file-read" | "file-change" | |
| 561 | >; | |
| 562 | ||
| 563 | export function openCodePermissionRequestKind( | |
| 564 | permission: string, | |
| 565 | toolName?: string, | |
| 566 | ): OpenCodePermissionRequestKind { | |
| 567 | const normalized = permission.toLowerCase(); | |
| 568 | const normalizedTool = toolName?.toLowerCase() ?? ""; | |
| 569 | if ( | |
| 570 | normalized === "edit" || | |
| 571 | normalized === "write" || | |
| 572 | normalized === "patch" || | |
| 573 | normalized === "apply_patch" || | |
| 574 | normalizedTool === "edit" || | |
| 575 | normalizedTool === "write" || | |
| 576 | normalizedTool === "patch" || | |
| 577 | normalizedTool === "apply_patch" | |
| 578 | ) { | |
| 579 | return "file-change"; | |
| 580 | } | |
| 581 | if ( | |
| 582 | normalized === "read" || | |
| 583 | normalized === "glob" || | |
| 584 | normalized === "grep" || | |
| 585 | normalized === "lsp" || | |
| 586 | normalized === "external_directory" || | |
| 587 | normalizedTool === "read" || | |
| 588 | normalizedTool.includes("glob") || | |
| 589 | normalizedTool.includes("grep") || | |
| 590 | normalizedTool.includes("search") | |
| 591 | ) { | |
| 592 | return "file-read"; | |
| 593 | } | |
| 594 | return "command"; | |
| 595 | } | |
| 596 | ||
| 597 | const OPENCODE_ALWAYS_ALLOWED_PERMISSIONS = [ | |
| 598 | "question", | |
| 599 | "read", | |
| 600 | "glob", | |
| 601 | "grep", | |
| 602 | "lsp", | |
| 603 | "todowrite", | |
| 604 | "task", | |
| 605 | "skill", | |
| 606 | ] as const; | |
| 607 | ||
| 608 | const OPENCODE_RESTRICTED_PERMISSIONS = [ | |
| 609 | "bash", | |
| 610 | "edit", | |
| 611 | "webfetch", | |
| 612 | "websearch", | |
| 613 | "codesearch", | |
| 614 | "external_directory", | |
| 615 | "doom_loop", | |
| 616 | ] as const; | |
| 617 | ||
| 618 | /** | |
| 619 | * OpenCode does not provide an OS sandbox, so permission rules are also the | |
| 620 | * enforcement boundary for non-interactive policies. Read/planning tools are | |
| 621 | * safe by default; edits are auto-approved only for workspace-write modes, | |
| 622 | * while shell/network/external access remains gated unless policy explicitly | |
| 623 | * allows it. | |
| 624 | */ | |
| 625 | export function openCodePermissionRules( | |
| 626 | runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy, | |
| 627 | ): PermissionRuleset { | |
| 628 | const sandboxPolicy = recordValue(runtimePolicy, "sandboxPolicy"); | |
| 629 | const sandboxType = recordString(sandboxPolicy, "type"); | |
| 630 | const rawApprovalPolicy = runtimePolicy.approvalPolicy; | |
| 631 | const approvalPolicy = nonEmptyString(rawApprovalPolicy); | |
| 632 | const requiresApproval = | |
| 633 | approvalPolicy === undefined | |
| 634 | ? (typeof rawApprovalPolicy === "object" && rawApprovalPolicy !== null) || | |
| 635 | runtimePolicy.runtimeMode !== "full-access" | |
| 636 | : approvalPolicy !== "never"; | |
| 637 | const externallySandboxed = sandboxType === "externalSandbox"; | |
| 638 | const dangerFullAccess = sandboxType === "dangerFullAccess"; | |
| 639 | const implicitFullAccess = | |
| 640 | sandboxType === undefined && runtimePolicy.runtimeMode === "full-access"; | |
| 641 | ||
| 642 | if (!requiresApproval && (externallySandboxed || dangerFullAccess || implicitFullAccess)) { | |
| 643 | return [{ permission: "*", pattern: "*", action: "allow" }]; | |
| 644 | } | |
| 645 | ||
| 646 | // Task sessions initially inherit only parent deny rules. Seed explicit | |
| 647 | // denies before the effective ask/allow overrides so a child is safe during | |
| 648 | // the short interval before emitSubagent installs its complete policy. | |
| 649 | const rules: PermissionRuleset = [ | |
| 650 | { permission: "*", pattern: "*", action: "deny" }, | |
| 651 | ...OPENCODE_RESTRICTED_PERMISSIONS.map((permission) => ({ | |
| 652 | permission, | |
| 653 | pattern: "*", | |
| 654 | action: "deny" as const, | |
| 655 | })), | |
| 656 | ]; | |
| 657 | ||
| 658 | if (requiresApproval) { | |
| 659 | rules.push({ permission: "*", pattern: "*", action: "ask" }); | |
| 660 | for (const permission of OPENCODE_RESTRICTED_PERMISSIONS) { | |
| 661 | rules.push({ permission, pattern: "*", action: "ask" }); | |
| 662 | } | |
| 663 | } | |
| 664 | ||
| 665 | rules.push( | |
| 666 | ...OPENCODE_ALWAYS_ALLOWED_PERMISSIONS.map((permission) => ({ | |
| 667 | permission, | |
| 668 | pattern: "*", | |
| 669 | action: "allow" as const, | |
| 670 | })), | |
| 671 | ); | |
| 672 | ||
| 673 | rules.push( | |
| 674 | { permission: "read", pattern: "*.env", action: requiresApproval ? "ask" : "deny" }, | |
| 675 | { permission: "read", pattern: "*.env.*", action: requiresApproval ? "ask" : "deny" }, | |
| 676 | { permission: "read", pattern: "*.env.example", action: "allow" }, | |
| 677 | ); | |
| 678 | ||
| 679 | if ( | |
| 680 | runtimePolicy.runtimeMode === "auto-accept-edits" || | |
| 681 | (!requiresApproval && sandboxType === "workspaceWrite") | |
| 682 | ) { | |
| 683 | rules.push({ permission: "edit", pattern: "*", action: "allow" }); | |
| 684 | } | |
| 685 | ||
| 686 | if (!requiresApproval && recordValue(sandboxPolicy, "networkAccess") === true) { | |
| 687 | for (const permission of ["webfetch", "websearch", "codesearch"] as const) { | |
| 688 | rules.push({ permission, pattern: "*", action: "allow" }); | |
| 689 | } | |
| 690 | } | |
| 691 | ||
| 692 | if (!requiresApproval && sandboxType === "readOnly") { | |
| 693 | const access = recordValue(sandboxPolicy, "access"); | |
| 694 | if (recordString(access, "type") === "fullAccess") { | |
| 695 | rules.push({ permission: "external_directory", pattern: "*", action: "allow" }); | |
| 696 | } | |
| 697 | } | |
| 698 | ||
| 699 | if (!requiresApproval && sandboxType === "workspaceWrite") { | |
| 700 | const writableRoots = recordValue(sandboxPolicy, "writableRoots"); | |
| 701 | if (Array.isArray(writableRoots)) { | |
| 702 | for (const root of writableRoots) { | |
| 703 | if (typeof root === "string" && root.trim().length > 0) { | |
| 704 | rules.push({ | |
| 705 | permission: "external_directory", | |
| 706 | pattern: `${root.replace(/\/$/, "")}/*`, | |
| 707 | action: "allow", | |
| 708 | }); | |
| 709 | } | |
| 710 | } | |
| 711 | } | |
| 712 | } | |
| 713 | ||
| 714 | return rules; | |
| 715 | } | |
| 716 | ||
| 717 | function permissionRuleEquals( | |
| 718 | left: PermissionRuleset[number], | |
| 719 | right: PermissionRuleset[number], | |
| 720 | ): boolean { | |
| 721 | return ( | |
| 722 | left.permission === right.permission && | |
| 723 | left.pattern === right.pattern && | |
| 724 | left.action === right.action | |
| 725 | ); | |
| 726 | } | |
| 727 | ||
| 728 | /** | |
| 729 | * OpenCode task sessions inherit only the parent's deny/external-directory | |
| 730 | * rules and then add agent-specific restrictions such as disabling nested | |
| 731 | * tasks. Install the complete parent policy while retaining only rules that | |
| 732 | * were added specifically for the selected child agent. | |
| 733 | */ | |
| 734 | export function openCodeChildPermissionRules( | |
| 735 | runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy, | |
| 736 | nativeChildRules: PermissionRuleset, | |
| 737 | ): PermissionRuleset { | |
| 738 | const parentRules = openCodePermissionRules(runtimePolicy); | |
| 739 | const inheritedRules = parentRules.filter( | |
| 740 | (rule) => rule.permission === "external_directory" || rule.action === "deny", | |
| 741 | ); | |
| 742 | const childSpecificRules = nativeChildRules.filter( | |
| 743 | (childRule) => | |
| 744 | !inheritedRules.some((inheritedRule) => permissionRuleEquals(childRule, inheritedRule)), | |
| 745 | ); | |
| 746 | return [...parentRules, ...childSpecificRules]; | |
| 747 | } | |
| 748 | ||
| 749 | /** | |
| 750 | * OpenCode's fork/revert boundary is exclusive. To retain the selected app | |
| 751 | * turn, address the next native user message; omitting a boundary retains the | |
| 752 | * current head when the selected turn is already last. | |
| 753 | */ | |
| 754 | export function openCodeBoundaryAfterProviderTurn( | |
| 755 | providerTurns: ReadonlyArray<OrchestrationV2ProviderTurn>, | |
| 756 | selectedProviderTurnId: OrchestrationV2ProviderTurn["id"], | |
| 757 | ): string | undefined { | |
| 758 | const selected = providerTurns.find((turn) => turn.id === selectedProviderTurnId); | |
| 759 | if (selected === undefined) return undefined; | |
| 760 | return providerTurns | |
| 761 | .filter((turn) => turn.ordinal > selected.ordinal) | |
| 762 | .toSorted((left, right) => left.ordinal - right.ordinal) | |
| 763 | .map((turn) => turn.nativeTurnRef?.nativeId) | |
| 764 | .find((nativeId): nativeId is string => nativeId !== null && nativeId !== undefined); | |
| 765 | } | |
| 766 | ||
| 767 | function toolStatus(part: ToolPart): { | |
| 768 | readonly node: OrchestrationV2ExecutionNode["status"]; | |
| 769 | readonly item: OrchestrationV2TurnItem["status"]; | |
| 770 | } { | |
| 771 | switch (part.state.status) { | |
| 772 | case "pending": | |
| 773 | return { node: "pending", item: "pending" }; | |
| 774 | case "running": | |
| 775 | return { node: "running", item: "running" }; | |
| 776 | case "completed": | |
| 777 | return { node: "completed", item: "completed" }; | |
| 778 | case "error": | |
| 779 | return { node: "failed", item: "failed" }; | |
| 780 | } | |
| 781 | } | |
| 782 | ||
| 783 | function toolInput(part: ToolPart): Record<string, unknown> { | |
| 784 | return part.state.input; | |
| 785 | } | |
| 786 | ||
| 787 | function toolOutput(part: ToolPart): string | undefined { | |
| 788 | if (part.state.status === "completed") return part.state.output; | |
| 789 | if (part.state.status === "error") return part.state.error; | |
| 790 | return undefined; | |
| 791 | } | |
| 792 | ||
| 793 | function toolStartedAt(part: ToolPart, now: DateTime.Utc): DateTime.Utc { | |
| 794 | return dateTimeFromEpoch( | |
| 795 | part.state.status === "pending" ? undefined : part.state.time.start, | |
| 796 | now, | |
| 797 | ); | |
| 798 | } | |
| 799 | ||
| 800 | function toolCompletedAt(part: ToolPart, now: DateTime.Utc): DateTime.Utc | null { | |
| 801 | return part.state.status === "completed" || part.state.status === "error" | |
| 802 | ? dateTimeFromEpoch(part.state.time.end, now) | |
| 803 | : null; | |
| 804 | } | |
| 805 | ||
| 806 | function toolTitle(part: ToolPart): string | null { | |
| 807 | return part.state.status === "running" || part.state.status === "completed" | |
| 808 | ? (part.state.title ?? null) | |
| 809 | : null; | |
| 810 | } | |
| 811 | ||
| 812 | function toolModel(part: ToolPart): string | null { | |
| 813 | const metadata = | |
| 814 | part.state.status === "running" || | |
| 815 | part.state.status === "completed" || | |
| 816 | part.state.status === "error" | |
| 817 | ? part.state.metadata | |
| 818 | : undefined; | |
| 819 | const model = recordValue(metadata, "model"); | |
| 820 | const providerId = recordString(model, "providerID", "providerId"); | |
| 821 | const modelId = recordString(model, "modelID", "modelId", "id"); | |
| 822 | return providerId !== undefined && modelId !== undefined ? `${providerId}/${modelId}` : null; | |
| 823 | } | |
| 824 | ||
| 825 | function taskSessionId(part: ToolPart): string | null { | |
| 826 | const metadata = | |
| 827 | part.state.status === "running" || | |
| 828 | part.state.status === "completed" || | |
| 829 | part.state.status === "error" | |
| 830 | ? part.state.metadata | |
| 831 | : undefined; | |
| 832 | return recordString(metadata, "sessionId", "sessionID") ?? null; | |
| 833 | } | |
| 834 | ||
| 835 | function makeProviderThread(input: { | |
| 836 | readonly idAllocator: IdAllocator.IdAllocatorV2Shape; | |
| 837 | readonly providerInstanceId: ProviderInstanceId; | |
| 838 | readonly providerSessionId: OrchestrationV2ProviderThread["providerSessionId"]; | |
| 839 | readonly appThreadId: OrchestrationV2ProviderThread["appThreadId"]; | |
| 840 | readonly ownerNodeId?: OrchestrationV2ProviderThread["ownerNodeId"]; | |
| 841 | readonly nativeSession: OpenCodeSession; | |
| 842 | readonly forkedFrom?: OrchestrationV2ProviderThread["forkedFrom"]; | |
| 843 | readonly now: DateTime.Utc; | |
| 844 | }): OrchestrationV2ProviderThread { | |
| 845 | const createdAt = dateTimeFromEpoch(input.nativeSession.time.created, input.now); | |
| 846 | return { | |
| 847 | id: input.idAllocator.derive.providerThread({ | |
| 848 | driver: OPENCODE_PROVIDER, | |
| 849 | nativeThreadId: input.nativeSession.id, | |
| 850 | }), | |
| 851 | driver: OPENCODE_PROVIDER, | |
| 852 | providerInstanceId: input.providerInstanceId, | |
| 853 | providerSessionId: input.providerSessionId, | |
| 854 | appThreadId: input.appThreadId, | |
| 855 | ownerNodeId: input.ownerNodeId ?? null, | |
| 856 | nativeThreadRef: { | |
| 857 | driver: OPENCODE_PROVIDER, | |
| 858 | nativeId: input.nativeSession.id, | |
| 859 | strength: "strong", | |
| 860 | }, | |
| 861 | nativeConversationHeadRef: null, | |
| 862 | status: "idle", | |
| 863 | firstRunOrdinal: null, | |
| 864 | lastRunOrdinal: null, | |
| 865 | handoffIds: [], | |
| 866 | forkedFrom: input.forkedFrom ?? null, | |
| 867 | createdAt, | |
| 868 | updatedAt: dateTimeFromEpoch(input.nativeSession.time.updated, input.now), | |
| 869 | }; | |
| 870 | } | |
| 871 | ||
| 872 | function providerRef(nativeId: string, strength: "strong" | "weak" = "strong") { | |
| 873 | return { | |
| 874 | driver: OPENCODE_PROVIDER, | |
| 875 | nativeId, | |
| 876 | strength, | |
| 877 | } satisfies OrchestrationV2ProviderRef; | |
| 878 | } | |
| 879 | ||
| 880 | function openCodeErrorMessage(event: Extract<OpenCodeEvent, { type: "session.error" }>): string { | |
| 881 | const error = event.properties.error; | |
| 882 | if (error === undefined) return "OpenCode session failed without an error payload."; | |
| 883 | return recordString(error.data, "message") ?? error.name; | |
| 884 | } | |
| 885 | ||
| 886 | function terminalStatusForError( | |
| 887 | event: Extract<OpenCodeEvent, { type: "session.error" }>, | |
| 888 | turn: ActiveOpenCodeTurn, | |
| 889 | ): TerminalTurnStatus { | |
| 890 | return turn.interrupted || isMessageAbortedError(event) ? "interrupted" : "failed"; | |
| 891 | } | |
| 892 | ||
| 893 | function isMessageAbortedError(event: Extract<OpenCodeEvent, { type: "session.error" }>): boolean { | |
| 894 | return event.properties.error?.name === "MessageAbortedError"; | |
| 895 | } | |
| 896 | ||
| 897 | function isOpenCodeNotFound(cause: unknown): boolean { | |
| 898 | const seen = new Set<unknown>(); | |
| 899 | const queue: Array<unknown> = [cause]; | |
| 900 | for (let steps = 0; queue.length > 0 && steps < 32; steps += 1) { | |
| 901 | const node = queue.shift(); | |
| 902 | if (node === null || typeof node !== "object" || seen.has(node)) { | |
| 903 | continue; | |
| 904 | } | |
| 905 | seen.add(node); | |
| 906 | const record = node as Record<string, unknown>; | |
| 907 | ||
| 908 | const response = record.response; | |
| 909 | const statuses = [ | |
| 910 | record.status, | |
| 911 | record.statusCode, | |
| 912 | response !== null && typeof response === "object" | |
| 913 | ? (response as { readonly status?: unknown }).status | |
| 914 | : undefined, | |
| 915 | ].filter((status): status is number => typeof status === "number"); | |
| 916 | if (statuses.includes(404)) { | |
| 917 | return true; | |
| 918 | } | |
| 919 | if (statuses.length > 0) { | |
| 920 | continue; | |
| 921 | } | |
| 922 | ||
| 923 | const name = record.name; | |
| 924 | if (typeof name === "string" && name.toLowerCase() === "notfounderror") { | |
| 925 | return true; | |
| 926 | } | |
| 927 | ||
| 928 | for (const key of ["cause", "body", "error", "data"] as const) { | |
| 929 | if (record[key] !== undefined) { | |
| 930 | queue.push(record[key]); | |
| 931 | } | |
| 932 | } | |
| 933 | } | |
| 934 | return false; | |
| 935 | } | |
| 936 | ||
| 937 | function unwrapData<A>(operation: string, result: { readonly data?: A }): NonNullable<A> { | |
| 938 | if (result.data === undefined) { | |
| 939 | throw new OpenCodeRuntime.OpenCodeRuntimeError({ | |
| 940 | operation, | |
| 941 | detail: `OpenCode ${operation} returned no response payload.`, | |
| 942 | }); | |
| 943 | } | |
| 944 | return result.data as NonNullable<A>; | |
| 945 | } | |
| 946 | ||
| 947 | export function makeOpenCodeAdapterV2( | |
| 948 | options: OpenCodeAdapterV2Options, | |
| 949 | ): ProviderAdapter.ProviderAdapterV2Shape { | |
| 950 | const { idAllocator, runtime, serverConfig } = options; | |
| 951 | ||
| 952 | return ProviderAdapter.ProviderAdapterV2.of({ | |
| 953 | instanceId: options.instanceId, | |
| 954 | driver: OPENCODE_PROVIDER, | |
| 955 | getCapabilities: () => Effect.succeed(OpenCodeProviderCapabilitiesV2), | |
| 956 | planSelectionTransition: () => Effect.succeed(turnScopedSelectionTransition()), | |
| 957 | openSession: Effect.fn("OpenCodeAdapterV2.openSession")( | |
| 958 | function* (input: ProviderAdapter.ProviderAdapterV2OpenSessionInput) { | |
| 959 | const scope = yield* Effect.scope; | |
| 960 | const cwd = input.runtimePolicy.cwd ?? serverConfig.cwd; | |
| 961 | const connection = yield* runtime.connectToOpenCodeServer({ | |
| 962 | binaryPath: options.settings.binaryPath, | |
| 963 | directory: cwd, | |
| 964 | serverUrl: options.settings.serverUrl, | |
| 965 | environment: options.environment, | |
| 966 | }); | |
| 967 | const client = runtime.createOpenCodeSdkClient({ | |
| 968 | baseUrl: connection.url, | |
| 969 | directory: cwd, | |
| 970 | ...(connection.external && options.settings.serverPassword | |
| 971 | ? { serverPassword: options.settings.serverPassword } | |
| 972 | : {}), | |
| 973 | }); | |
| 974 | ||
| 975 | const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); | |
| 976 | const hasT3Mcp = mcpSession !== undefined && !connection.external; | |
| 977 | const orchestrationSystemPrompt = t3OrchestrationSystemPrompt(hasT3Mcp); | |
| 978 | if (hasT3Mcp) { | |
| 979 | yield* OpenCodeRuntime.runOpenCodeSdk("mcp.add", () => | |
| 980 | client.mcp.add({ | |
| 981 | name: "t3-code", | |
| 982 | config: { | |
| 983 | type: "remote", | |
| 984 | url: mcpSession.endpoint, | |
| 985 | headers: { Authorization: mcpSession.authorizationHeader }, | |
| 986 | oauth: false, | |
| 987 | }, | |
| 988 | }), | |
| 989 | ); | |
| 990 | } | |
| 991 | ||
| 992 | const now = yield* DateTime.now; | |
| 993 | let sessionEntity: OrchestrationV2ProviderSession = { | |
| 994 | id: input.providerSessionId, | |
| 995 | driver: OPENCODE_PROVIDER, | |
| 996 | providerInstanceId: options.instanceId, | |
| 997 | status: "ready", | |
| 998 | cwd, | |
| 999 | model: input.modelSelection.model, | |
| 1000 | capabilities: OpenCodeProviderCapabilitiesV2, | |
| 1001 | createdAt: now, | |
| 1002 | updatedAt: now, | |
| 1003 | lastError: null, | |
| 1004 | }; | |
| 1005 | const events = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event, Cause.Done>(); | |
| 1006 | let nativeStreamFailure: OrchestrationV2ProviderFailure | null = null; | |
| 1007 | const threads = new Map<string, OpenCodeThreadState>(); | |
| 1008 | const commandReceipts = new Map<string, Deferred.Deferred<void>>(); | |
| 1009 | const commandControllers = new Map<string, Set<AbortController>>(); | |
| 1010 | const pendingRequests = new Map<string, PendingOpenCodeRequest>(); | |
| 1011 | const pendingRequestsByNativeId = new Map<string, PendingOpenCodeRequest>(); | |
| 1012 | const subagentsByNativeItemId = new Map<string, OpenCodeSubagentContext>(); | |
| 1013 | const subagentsByChildSessionId = new Map<string, OpenCodeSubagentContext>(); | |
| 1014 | // Permission and question requests can originate from child sessions | |
| 1015 | // (task subagents and their descendants). Related sessions map back | |
| 1016 | // to the root thread state whose active turn owns the request; asks | |
| 1017 | // arriving before the relation is known resolve it via session.get. | |
| 1018 | const relatedSessionOwners = new Map<string, OpenCodeThreadState>(); | |
| 1019 | // Sessions of this runtime that OpenCode reports busy. A background | |
| 1020 | // task child keeps running after its parent turn settles, so idle | |
| 1021 | // release must not close the server under it. | |
| 1022 | const busySessionIds = new Set<string>(); | |
| 1023 | // Requests settled before their session relation resolved: a late | |
| 1024 | // routing attempt must never resurrect them. Bounded because entries | |
| 1025 | // only matter for the seconds a routing retry can still be running. | |
| 1026 | const settledNativeRequestIds = new Set<string>(); | |
| 1027 | const pendingChildRequestRoutes = new Set<string>(); | |
| 1028 | const rememberSettledRequest = (nativeRequestId: string) => { | |
| 1029 | if (settledNativeRequestIds.size >= 2_048) settledNativeRequestIds.clear(); | |
| 1030 | settledNativeRequestIds.add(nativeRequestId); | |
| 1031 | }; | |
| 1032 | const abortController = new AbortController(); | |
| 1033 | let closing = false; | |
| 1034 | let hasConnected = false; | |
| 1035 | ||
| 1036 | const emitProviderEvent = (event: ProviderAdapter.ProviderAdapterV2Event) => | |
| 1037 | Queue.offer(events, event).pipe(Effect.asVoid); | |
| 1038 | ||
| 1039 | const logProtocolEvent = makeOpenCodeProtocolLogger({ | |
| 1040 | nativeEventLogger: options.nativeEventLogger, | |
| 1041 | idAllocator, | |
| 1042 | providerInstanceId: options.instanceId, | |
| 1043 | providerSessionId: input.providerSessionId, | |
| 1044 | threadId: input.threadId, | |
| 1045 | }); | |
| 1046 | ||
| 1047 | const sdkCall = <A>( | |
| 1048 | method: string, | |
| 1049 | payload: unknown, | |
| 1050 | call: (signal: AbortSignal) => Promise<A>, | |
| 1051 | ): Effect.Effect<A, OpenCodeRuntime.OpenCodeRuntimeError> => | |
| 1052 | logProtocolEvent({ | |
| 1053 | direction: "outgoing", | |
| 1054 | messageKind: "request", | |
| 1055 | method, | |
| 1056 | payload, | |
| 1057 | }).pipe( | |
| 1058 | Effect.andThen(OpenCodeRuntime.runOpenCodeSdk(method, call)), | |
| 1059 | Effect.tap((response) => | |
| 1060 | logProtocolEvent({ | |
| 1061 | direction: "incoming", | |
| 1062 | messageKind: "response", | |
| 1063 | method, | |
| 1064 | payload: sdkResponseForRawLog(response), | |
| 1065 | }), | |
| 1066 | ), | |
| 1067 | ); | |
| 1068 | ||
| 1069 | const abortDescendants = (rootId: string) => | |
| 1070 | Effect.gen(function* () { | |
| 1071 | const visited = new Set([rootId]); | |
| 1072 | const semaphore = Semaphore.makeUnsafe(8); | |
| 1073 | const visit = ( | |
| 1074 | sessionId: string, | |
| 1075 | abort: boolean, | |
| 1076 | ): Effect.Effect<OpenCodeRuntime.OpenCodeRuntimeError | undefined> => | |
| 1077 | Effect.gen(function* () { | |
| 1078 | const abortResult = abort | |
| 1079 | ? yield* sdkCall("session.abort", { sessionID: sessionId }, (signal) => | |
| 1080 | client.session.abort({ sessionID: sessionId }, { signal }), | |
| 1081 | ).pipe( | |
| 1082 | semaphore.withPermit, | |
| 1083 | Effect.catchIf(isOpenCodeNotFound, () => Effect.void), | |
| 1084 | Effect.result, | |
| 1085 | ) | |
| 1086 | : undefined; | |
| 1087 | const childrenResult = yield* sdkCall( | |
| 1088 | "session.children", | |
| 1089 | { sessionID: sessionId }, | |
| 1090 | (signal) => client.session.children({ sessionID: sessionId }, { signal }), | |
| 1091 | ).pipe( | |
| 1092 | semaphore.withPermit, | |
| 1093 | Effect.catchIf(isOpenCodeNotFound, () => Effect.void), | |
| 1094 | Effect.result, | |
| 1095 | ); | |
| 1096 | const firstFailure = | |
| 1097 | abortResult?._tag === "Failure" ? abortResult.failure : undefined; | |
| 1098 | if (childrenResult._tag === "Failure") | |
| 1099 | return firstFailure ?? childrenResult.failure; | |
| 1100 | const fresh = (childrenResult.success?.data ?? []).filter((child) => { | |
| 1101 | if (visited.has(child.id)) return false; | |
| 1102 | visited.add(child.id); | |
| 1103 | return true; | |
| 1104 | }); | |
| 1105 | const failures = yield* Effect.forEach(fresh, (child) => visit(child.id, true), { | |
| 1106 | concurrency: 8, | |
| 1107 | }); | |
| 1108 | return firstFailure ?? failures.find((failure) => failure !== undefined); | |
| 1109 | }); | |
| 1110 | const failure = yield* visit(rootId, false); | |
| 1111 | if (failure) return yield* Effect.fail(failure); | |
| 1112 | }).pipe(Effect.timeout("15 seconds")); | |
| 1113 | ||
| 1114 | const updateProviderSession = ( | |
| 1115 | status: OrchestrationV2ProviderSession["status"], | |
| 1116 | lastError: string | null = sessionEntity.lastError, | |
| 1117 | ) => | |
| 1118 | Effect.gen(function* () { | |
| 1119 | const updatedAt = yield* DateTime.now; | |
| 1120 | sessionEntity = { ...sessionEntity, status, lastError, updatedAt }; | |
| 1121 | yield* emitProviderEvent({ | |
| 1122 | type: "provider_session.updated", | |
| 1123 | driver: OPENCODE_PROVIDER, | |
| 1124 | providerSession: sessionEntity, | |
| 1125 | }); | |
| 1126 | }); | |
| 1127 | ||
| 1128 | const updateProviderThread = ( | |
| 1129 | state: OpenCodeThreadState, | |
| 1130 | patch: Partial<OrchestrationV2ProviderThread>, | |
| 1131 | ) => | |
| 1132 | Effect.gen(function* () { | |
| 1133 | const updatedAt = yield* DateTime.now; | |
| 1134 | state.providerThread = { ...state.providerThread, ...patch, updatedAt }; | |
| 1135 | yield* emitProviderEvent({ | |
| 1136 | type: "provider_thread.updated", | |
| 1137 | driver: OPENCODE_PROVIDER, | |
| 1138 | providerThread: state.providerThread, | |
| 1139 | }); | |
| 1140 | }); | |
| 1141 | ||
| 1142 | const itemOrdinal = (turn: ActiveOpenCodeTurn, nativeItemId: string): number => { | |
| 1143 | const existing = turn.itemOrdinals.get(nativeItemId); | |
| 1144 | if (existing !== undefined) return existing; | |
| 1145 | const ordinal = turn.nextItemOrdinal++; | |
| 1146 | turn.itemOrdinals.set(nativeItemId, ordinal); | |
| 1147 | return ordinal; | |
| 1148 | }; | |
| 1149 | ||
| 1150 | const emitProviderTurn = ( | |
| 1151 | state: OpenCodeThreadState, | |
| 1152 | turn: ActiveOpenCodeTurn, | |
| 1153 | status: OrchestrationV2ProviderTurn["status"], | |
| 1154 | completedAt: DateTime.Utc | null, | |
| 1155 | ) => { | |
| 1156 | const providerTurn: OrchestrationV2ProviderTurn = { | |
| 1157 | ...turn.providerTurn, | |
| 1158 | nativeTurnRef: | |
| 1159 | turn.nativeUserMessageId === null | |
| 1160 | ? turn.providerTurn.nativeTurnRef | |
| 1161 | : providerRef(turn.nativeUserMessageId, "weak"), | |
| 1162 | status, | |
| 1163 | completedAt, | |
| 1164 | ...(completedAt === null | |
| 1165 | ? {} | |
| 1166 | : { | |
| 1167 | turnTokenUsage: | |
| 1168 | turn.usage.partIds.size === 0 | |
| 1169 | ? { | |
| 1170 | usageScope: "main_agent" as const, | |
| 1171 | usageStatus: "unavailable" as const, | |
| 1172 | hasSubagents: turn.usage.hasSubagents, | |
| 1173 | } | |
| 1174 | : { | |
| 1175 | usageScope: "main_agent" as const, | |
| 1176 | usageStatus: | |
| 1177 | status === "completed" && | |
| 1178 | turn.usage.complete && | |
| 1179 | turn.usage.unresolvedStepsByMessageId.size === 0 | |
| 1180 | ? ("complete" as const) | |
| 1181 | : ("partial" as const), | |
| 1182 | inputTokens: turn.usage.inputTokens, | |
| 1183 | cachedInputTokens: turn.usage.cachedInputTokens, | |
| 1184 | cacheCreationTokens: turn.usage.cacheCreationTokens, | |
| 1185 | outputTokens: turn.usage.outputTokens, | |
| 1186 | reasoningTokens: turn.usage.reasoningTokens, | |
| 1187 | hasSubagents: turn.usage.hasSubagents, | |
| 1188 | }, | |
| 1189 | }), | |
| 1190 | }; | |
| 1191 | Object.assign(turn.providerTurn, providerTurn); | |
| 1192 | state.providerTurns.set(String(providerTurn.id), providerTurn); | |
| 1193 | return emitProviderEvent({ | |
| 1194 | type: "provider_turn.updated", | |
| 1195 | driver: OPENCODE_PROVIDER, | |
| 1196 | threadId: turn.threadId, | |
| 1197 | providerTurn, | |
| 1198 | }); | |
| 1199 | }; | |
| 1200 | ||
| 1201 | const emitTextPart = Effect.fnUntraced(function* ( | |
| 1202 | state: OpenCodeThreadState, | |
| 1203 | turn: ActiveOpenCodeTurn, | |
| 1204 | part: Extract<OpenCodePart, { type: "text" | "reasoning" }>, | |
| 1205 | forceCompleted = false, | |
| 1206 | ) { | |
| 1207 | if (part.type === "text" && (part.ignored === true || part.synthetic === true)) return; | |
| 1208 | if (part.text.length === 0) return; | |
| 1209 | const emittedAt = yield* DateTime.now; | |
| 1210 | const isCompleted = forceCompleted || part.time?.end !== undefined; | |
| 1211 | const startedAt = dateTimeFromEpoch(part.time?.start, emittedAt); | |
| 1212 | const completedAt = isCompleted ? dateTimeFromEpoch(part.time?.end, emittedAt) : null; | |
| 1213 | const nativeItemRef = providerRef(part.id); | |
| 1214 | const nodeId = idAllocator.derive.nodeFromProviderItem({ | |
| 1215 | driver: OPENCODE_PROVIDER, | |
| 1216 | nativeItemId: part.id, | |
| 1217 | }); | |
| 1218 | const turnItemId = idAllocator.derive.turnItemFromProviderItem({ | |
| 1219 | driver: OPENCODE_PROVIDER, | |
| 1220 | nativeItemId: part.id, | |
| 1221 | }); | |
| 1222 | const ordinal = itemOrdinal(turn, part.id); | |
| 1223 | yield* emitProviderEvent({ | |
| 1224 | type: "node.updated", | |
| 1225 | driver: OPENCODE_PROVIDER, | |
| 1226 | node: { | |
| 1227 | id: nodeId, | |
| 1228 | threadId: turn.threadId, | |
| 1229 | runId: turn.runId, | |
| 1230 | parentNodeId: turn.rootNodeId, | |
| 1231 | rootNodeId: turn.rootNodeId, | |
| 1232 | kind: part.type === "text" ? "assistant_message" : "reasoning", | |
| 1233 | status: isCompleted ? "completed" : "running", | |
| 1234 | countsForRun: false, | |
| 1235 | providerThreadId: state.providerThread.id, | |
| 1236 | providerTurnId: turn.providerTurnId, | |
| 1237 | nativeItemRef, | |
| 1238 | runtimeRequestId: null, | |
| 1239 | checkpointScopeId: null, | |
| 1240 | startedAt, | |
| 1241 | completedAt, | |
| 1242 | }, | |
| 1243 | }); | |
| 1244 | if (part.type === "text") { | |
| 1245 | const messageId = idAllocator.derive.messageFromProviderItem({ | |
| 1246 | driver: OPENCODE_PROVIDER, | |
| 1247 | nativeItemId: part.id, | |
| 1248 | }); | |
| 1249 | const message: OrchestrationV2ConversationMessage = { | |
| 1250 | createdBy: "agent", | |
| 1251 | creationSource: "provider", | |
| 1252 | id: messageId, | |
| 1253 | threadId: turn.threadId, | |
| 1254 | runId: turn.runId, | |
| 1255 | nodeId, | |
| 1256 | role: "assistant", | |
| 1257 | text: part.text, | |
| 1258 | attachments: [], | |
| 1259 | streaming: !isCompleted, | |
| 1260 | createdAt: startedAt, | |
| 1261 | updatedAt: emittedAt, | |
| 1262 | }; | |
| 1263 | state.messages.set(String(message.id), message); | |
| 1264 | yield* emitProviderEvent({ | |
| 1265 | type: "message.updated", | |
| 1266 | driver: OPENCODE_PROVIDER, | |
| 1267 | message, | |
| 1268 | }); | |
| 1269 | yield* emitProviderEvent({ | |
| 1270 | type: "turn_item.updated", | |
| 1271 | driver: OPENCODE_PROVIDER, | |
| 1272 | turnItem: { | |
| 1273 | id: turnItemId, | |
| 1274 | threadId: turn.threadId, | |
| 1275 | runId: turn.runId, | |
| 1276 | nodeId, | |
| 1277 | providerThreadId: state.providerThread.id, | |
| 1278 | providerTurnId: turn.providerTurnId, | |
| 1279 | nativeItemRef, | |
| 1280 | parentItemId: null, | |
| 1281 | ordinal, | |
| 1282 | status: isCompleted ? "completed" : "running", | |
| 1283 | title: null, | |
| 1284 | startedAt, | |
| 1285 | completedAt, | |
| 1286 | updatedAt: emittedAt, | |
| 1287 | type: "assistant_message", | |
| 1288 | messageId, | |
| 1289 | text: part.text, | |
| 1290 | streaming: !isCompleted, | |
| 1291 | }, | |
| 1292 | }); | |
| 1293 | return; | |
| 1294 | } | |
| 1295 | yield* emitProviderEvent({ | |
| 1296 | type: "turn_item.updated", | |
| 1297 | driver: OPENCODE_PROVIDER, | |
| 1298 | turnItem: { | |
| 1299 | id: turnItemId, | |
| 1300 | threadId: turn.threadId, | |
| 1301 | runId: turn.runId, | |
| 1302 | nodeId, | |
| 1303 | providerThreadId: state.providerThread.id, | |
| 1304 | providerTurnId: turn.providerTurnId, | |
| 1305 | nativeItemRef, | |
| 1306 | parentItemId: null, | |
| 1307 | ordinal, | |
| 1308 | status: isCompleted ? "completed" : "running", | |
| 1309 | title: null, | |
| 1310 | startedAt, | |
| 1311 | completedAt, | |
| 1312 | updatedAt: emittedAt, | |
| 1313 | type: "reasoning", | |
| 1314 | text: part.text, | |
| 1315 | streaming: !isCompleted, | |
| 1316 | }, | |
| 1317 | }); | |
| 1318 | }); | |
| 1319 | ||
| 1320 | const emitSubagent = Effect.fnUntraced(function* ( | |
| 1321 | state: OpenCodeThreadState, | |
| 1322 | turn: ActiveOpenCodeTurn, | |
| 1323 | part: ToolPart, | |
| 1324 | ) { | |
| 1325 | const now = yield* DateTime.now; | |
| 1326 | const nativeItemRef = providerRef(part.id); | |
| 1327 | const nodeId = idAllocator.derive.nodeFromProviderItem({ | |
| 1328 | driver: OPENCODE_PROVIDER, | |
| 1329 | nativeItemId: part.id, | |
| 1330 | }); | |
| 1331 | const turnItemId = idAllocator.derive.turnItemFromProviderItem({ | |
| 1332 | driver: OPENCODE_PROVIDER, | |
| 1333 | nativeItemId: part.id, | |
| 1334 | }); | |
| 1335 | turn.usage.hasSubagents = true; | |
| 1336 | const input = toolInput(part); | |
| 1337 | const prompt = recordString(input, "prompt") ?? ""; | |
| 1338 | const title = toolTitle(part) ?? recordString(input, "description") ?? null; | |
| 1339 | let context = subagentsByNativeItemId.get(part.id); | |
| 1340 | if (context === undefined) { | |
| 1341 | context = { | |
| 1342 | nativeItemId: part.id, | |
| 1343 | nodeId, | |
| 1344 | parentState: state, | |
| 1345 | parentTurn: turn, | |
| 1346 | prompt, | |
| 1347 | title, | |
| 1348 | startedAt: toolStartedAt(part, now), | |
| 1349 | childSessionId: null, | |
| 1350 | childThreadId: null, | |
| 1351 | childProviderThreadId: null, | |
| 1352 | model: null, | |
| 1353 | result: null, | |
| 1354 | }; | |
| 1355 | subagentsByNativeItemId.set(part.id, context); | |
| 1356 | } | |
| 1357 | context.model = toolModel(part) ?? context.model; | |
| 1358 | const childSessionId = taskSessionId(part); | |
| 1359 | if (childSessionId !== null && context.childSessionId === null) { | |
| 1360 | context.childSessionId = childSessionId; | |
| 1361 | context.childThreadId = idAllocator.derive.threadFromProviderThread({ | |
| 1362 | driver: OPENCODE_PROVIDER, | |
| 1363 | nativeThreadId: childSessionId, | |
| 1364 | }); | |
| 1365 | context.childProviderThreadId = idAllocator.derive.providerThread({ | |
| 1366 | driver: OPENCODE_PROVIDER, | |
| 1367 | nativeThreadId: childSessionId, | |
| 1368 | }); | |
| 1369 | subagentsByChildSessionId.set(childSessionId, context); | |
| 1370 | relatedSessionOwners.set(childSessionId, state); | |
| 1371 | const childModelSelection: ModelSelection = { | |
| 1372 | instanceId: options.instanceId, | |
| 1373 | model: context.model ?? turn.modelSelection.model, | |
| 1374 | }; | |
| 1375 | const childThread = makeSubagentChildThread({ | |
| 1376 | parentThread: turn.appThread, | |
| 1377 | childThreadId: context.childThreadId, | |
| 1378 | parentNodeId: nodeId, | |
| 1379 | activeProviderThreadId: context.childProviderThreadId, | |
| 1380 | providerInstanceId: options.instanceId, | |
| 1381 | modelSelection: childModelSelection, | |
| 1382 | title: subagentThreadTitle({ | |
| 1383 | parentTitle: turn.appThread.title, | |
| 1384 | title, | |
| 1385 | prompt, | |
| 1386 | ordinal: itemOrdinal(turn, part.id), | |
| 1387 | }), | |
| 1388 | now, | |
| 1389 | createdBy: "agent", | |
| 1390 | creationSource: "provider", | |
| 1391 | }); | |
| 1392 | const childProviderThread: OrchestrationV2ProviderThread = { | |
| 1393 | id: context.childProviderThreadId, | |
| 1394 | driver: OPENCODE_PROVIDER, | |
| 1395 | providerInstanceId: options.instanceId, | |
| 1396 | providerSessionId: inputProviderSessionId, | |
| 1397 | appThreadId: context.childThreadId, | |
| 1398 | ownerNodeId: nodeId, | |
| 1399 | nativeThreadRef: providerRef(childSessionId), | |
| 1400 | nativeConversationHeadRef: null, | |
| 1401 | status: "active", | |
| 1402 | firstRunOrdinal: null, | |
| 1403 | lastRunOrdinal: null, | |
| 1404 | handoffIds: [], | |
| 1405 | forkedFrom: null, | |
| 1406 | createdAt: now, | |
| 1407 | updatedAt: now, | |
| 1408 | }; | |
| 1409 | const childSessionResponse = yield* sdkCall( | |
| 1410 | "session.get", | |
| 1411 | { sessionID: childSessionId }, | |
| 1412 | () => client.session.get({ sessionID: childSessionId }), | |
| 1413 | ); | |
| 1414 | const nativeChildSession = unwrapData("session.get", childSessionResponse); | |
| 1415 | const childPermission = openCodeChildPermissionRules( | |
| 1416 | turn.runtimePolicy, | |
| 1417 | nativeChildSession.permission ?? [], | |
| 1418 | ); | |
| 1419 | yield* sdkCall( | |
| 1420 | "session.update", | |
| 1421 | { sessionID: childSessionId, permission: childPermission }, | |
| 1422 | () => | |
| 1423 | client.session.update({ | |
| 1424 | sessionID: childSessionId, | |
| 1425 | permission: childPermission, | |
| 1426 | }), | |
| 1427 | ); | |
| 1428 | threads.set(childSessionId, { | |
| 1429 | nativeSessionId: childSessionId, | |
| 1430 | providerThread: childProviderThread, | |
| 1431 | appThread: childThread, | |
| 1432 | activeTurn: null, | |
| 1433 | providerTurns: new Map(), | |
| 1434 | messages: new Map(), | |
| 1435 | runtimeRequests: new Map(), | |
| 1436 | messageRoles: new Map(), | |
| 1437 | userMessageIds: [], | |
| 1438 | parentSubagent: context, | |
| 1439 | nextChildTurnOrdinal: 1, | |
| 1440 | nextAdmissionGeneration: 1, | |
| 1441 | }); | |
| 1442 | yield* emitProviderEvent({ | |
| 1443 | type: "app_thread.created", | |
| 1444 | driver: OPENCODE_PROVIDER, | |
| 1445 | appThread: childThread, | |
| 1446 | }); | |
| 1447 | yield* emitProviderEvent({ | |
| 1448 | type: "provider_thread.updated", | |
| 1449 | driver: OPENCODE_PROVIDER, | |
| 1450 | providerThread: childProviderThread, | |
| 1451 | }); | |
| 1452 | } | |
| 1453 | const output = toolOutput(part); | |
| 1454 | if (part.state.status === "completed" && output !== undefined) context.result = output; | |
| 1455 | const status = toolStatus(part); | |
| 1456 | const completedAt = toolCompletedAt(part, now); | |
| 1457 | const subagentStatus: OrchestrationV2Subagent["status"] = | |
| 1458 | status.item === "failed" | |
| 1459 | ? "failed" | |
| 1460 | : status.item === "completed" | |
| 1461 | ? "completed" | |
| 1462 | : status.item === "pending" | |
| 1463 | ? "pending" | |
| 1464 | : "running"; | |
| 1465 | const subagent: OrchestrationV2Subagent = { | |
| 1466 | id: nodeId, | |
| 1467 | threadId: turn.threadId, | |
| 1468 | runId: turn.runId, | |
| 1469 | parentNodeId: turn.rootNodeId, | |
| 1470 | origin: "provider_native", | |
| 1471 | createdBy: "agent", | |
| 1472 | driver: OPENCODE_PROVIDER, | |
| 1473 | providerInstanceId: options.instanceId, | |
| 1474 | providerThreadId: context.childProviderThreadId, | |
| 1475 | childThreadId: context.childThreadId, | |
| 1476 | nativeTaskRef: nativeItemRef, | |
| 1477 | prompt, | |
| 1478 | title, | |
| 1479 | model: context.model, | |
| 1480 | status: subagentStatus, | |
| 1481 | result: context.result, | |
| 1482 | startedAt: context.startedAt, | |
| 1483 | completedAt, | |
| 1484 | updatedAt: now, | |
| 1485 | }; | |
| 1486 | yield* emitProviderEvent({ | |
| 1487 | type: "node.updated", | |
| 1488 | driver: OPENCODE_PROVIDER, | |
| 1489 | node: { | |
| 1490 | id: nodeId, | |
| 1491 | threadId: turn.threadId, | |
| 1492 | runId: turn.runId, | |
| 1493 | parentNodeId: turn.rootNodeId, | |
| 1494 | rootNodeId: turn.rootNodeId, | |
| 1495 | kind: "subagent", | |
| 1496 | status: status.node, | |
| 1497 | countsForRun: false, | |
| 1498 | providerThreadId: context.childProviderThreadId ?? state.providerThread.id, | |
| 1499 | providerTurnId: turn.providerTurnId, | |
| 1500 | nativeItemRef, | |
| 1501 | runtimeRequestId: null, | |
| 1502 | checkpointScopeId: null, | |
| 1503 | startedAt: context.startedAt, | |
| 1504 | completedAt, | |
| 1505 | }, | |
| 1506 | }); | |
| 1507 | yield* emitProviderEvent({ | |
| 1508 | type: "subagent.updated", | |
| 1509 | driver: OPENCODE_PROVIDER, | |
| 1510 | subagent, | |
| 1511 | }); | |
| 1512 | yield* emitProviderEvent({ | |
| 1513 | type: "turn_item.updated", | |
| 1514 | driver: OPENCODE_PROVIDER, | |
| 1515 | turnItem: { | |
| 1516 | id: turnItemId, | |
| 1517 | threadId: turn.threadId, | |
| 1518 | runId: turn.runId, | |
| 1519 | nodeId, | |
| 1520 | providerThreadId: state.providerThread.id, | |
| 1521 | providerTurnId: turn.providerTurnId, | |
| 1522 | nativeItemRef, | |
| 1523 | parentItemId: null, | |
| 1524 | ordinal: itemOrdinal(turn, part.id), | |
| 1525 | status: status.item, | |
| 1526 | title, | |
| 1527 | startedAt: context.startedAt, | |
| 1528 | completedAt, | |
| 1529 | updatedAt: now, | |
| 1530 | type: "subagent", | |
| 1531 | subagentId: nodeId, | |
| 1532 | origin: "provider_native", | |
| 1533 | driver: OPENCODE_PROVIDER, | |
| 1534 | providerInstanceId: options.instanceId, | |
| 1535 | childThreadId: context.childThreadId, | |
| 1536 | prompt, | |
| 1537 | result: context.result, | |
| 1538 | }, | |
| 1539 | }); | |
| 1540 | }); | |
| 1541 | ||
| 1542 | const emitToolPart = Effect.fnUntraced(function* ( | |
| 1543 | state: OpenCodeThreadState, | |
| 1544 | turn: ActiveOpenCodeTurn, | |
| 1545 | part: ToolPart, | |
| 1546 | ) { | |
| 1547 | const normalizedTool = part.tool.toLowerCase(); | |
| 1548 | if (normalizedTool === "task") { | |
| 1549 | yield* emitSubagent(state, turn, part); | |
| 1550 | return; | |
| 1551 | } | |
| 1552 | // question.asked carries the respondable semantic item. Projecting | |
| 1553 | // the implementation tool as well would duplicate it in the UI. | |
| 1554 | if (normalizedTool === "question") return; | |
| 1555 | const now = yield* DateTime.now; | |
| 1556 | const status = toolStatus(part); | |
| 1557 | const startedAt = toolStartedAt(part, now); | |
| 1558 | const completedAt = toolCompletedAt(part, now); | |
| 1559 | const nativeItemRef = providerRef(part.id); | |
| 1560 | const nodeId = idAllocator.derive.nodeFromProviderItem({ | |
| 1561 | driver: OPENCODE_PROVIDER, | |
| 1562 | nativeItemId: part.id, | |
| 1563 | }); | |
| 1564 | const turnItemId = idAllocator.derive.turnItemFromProviderItem({ | |
| 1565 | driver: OPENCODE_PROVIDER, | |
| 1566 | nativeItemId: part.id, | |
| 1567 | }); | |
| 1568 | const base = { | |
| 1569 | id: turnItemId, | |
| 1570 | threadId: turn.threadId, | |
| 1571 | runId: turn.runId, | |
| 1572 | nodeId, | |
| 1573 | providerThreadId: state.providerThread.id, | |
| 1574 | providerTurnId: turn.providerTurnId, | |
| 1575 | nativeItemRef, | |
| 1576 | parentItemId: null, | |
| 1577 | ordinal: itemOrdinal(turn, part.id), | |
| 1578 | status: status.item, | |
| 1579 | title: toolTitle(part), | |
| 1580 | startedAt, | |
| 1581 | completedAt, | |
| 1582 | updatedAt: now, | |
| 1583 | } satisfies Pick< | |
| 1584 | OrchestrationV2TurnItem, | |
| 1585 | | "id" | |
| 1586 | | "threadId" | |
| 1587 | | "runId" | |
| 1588 | | "nodeId" | |
| 1589 | | "providerThreadId" | |
| 1590 | | "providerTurnId" | |
| 1591 | | "nativeItemRef" | |
| 1592 | | "parentItemId" | |
| 1593 | | "ordinal" | |
| 1594 | | "status" | |
| 1595 | | "title" | |
| 1596 | | "startedAt" | |
| 1597 | | "completedAt" | |
| 1598 | | "updatedAt" | |
| 1599 | >; | |
| 1600 | const input = toolInput(part); | |
| 1601 | const output = toolOutput(part); | |
| 1602 | const isNativeTool = part.tool === "code_search" || part.tool === "apply_patch"; | |
| 1603 | if (!isNativeTool && part.tool.includes("_") && turn.mcpServerNames === undefined) { | |
| 1604 | const serverNames = yield* OpenCodeRuntime.runOpenCodeSdk("mcp.status", (signal) => | |
| 1605 | client.mcp.status(undefined, { signal, throwOnError: true }), | |
| 1606 | ).pipe( | |
| 1607 | Effect.timeout("1 second"), | |
| 1608 | Effect.map((response) => Object.keys(response.data ?? {})), | |
| 1609 | Effect.catch(() => Effect.succeed(undefined)), | |
| 1610 | ); | |
| 1611 | if (serverNames !== undefined) turn.mcpServerNames = serverNames; | |
| 1612 | } | |
| 1613 | const matchingServers = turn.mcpServerNames?.filter( | |
| 1614 | (name) => | |
| 1615 | !isNativeTool && part.tool.startsWith(`${name.replace(/[^a-zA-Z0-9_-]/g, "_")}_`), | |
| 1616 | ); | |
| 1617 | const serverName = matchingServers?.length === 1 ? matchingServers[0] : undefined; | |
| 1618 | const presentation = | |
| 1619 | serverName === undefined | |
| 1620 | ? {} | |
| 1621 | : mcpToolPresentation({ | |
| 1622 | serverName, | |
| 1623 | toolName: part.tool.slice(serverName.replace(/[^a-zA-Z0-9_-]/g, "_").length + 1), | |
| 1624 | title: toolTitle(part) === part.tool ? undefined : toolTitle(part), | |
| 1625 | }); | |
| 1626 | const turnItem: OrchestrationV2TurnItem = matchingServers?.length | |
| 1627 | ? { | |
| 1628 | ...base, | |
| 1629 | type: "dynamic_tool", | |
| 1630 | ...presentation, | |
| 1631 | toolName: part.tool, | |
| 1632 | input, | |
| 1633 | ...(output === undefined ? {} : { output }), | |
| 1634 | } | |
| 1635 | : openCodeToolTurnItem(base, { | |
| 1636 | name: part.tool, | |
| 1637 | input, | |
| 1638 | output, | |
| 1639 | completedMetadata: | |
| 1640 | part.state.status === "completed" ? part.state.metadata : undefined, | |
| 1641 | }); | |
| 1642 | yield* emitProviderEvent({ | |
| 1643 | type: "node.updated", | |
| 1644 | driver: OPENCODE_PROVIDER, | |
| 1645 | node: { | |
| 1646 | id: nodeId, | |
| 1647 | threadId: turn.threadId, | |
| 1648 | runId: turn.runId, | |
| 1649 | parentNodeId: turn.rootNodeId, | |
| 1650 | rootNodeId: turn.rootNodeId, | |
| 1651 | kind: "tool_call", | |
| 1652 | status: status.node, | |
| 1653 | countsForRun: false, | |
| 1654 | providerThreadId: state.providerThread.id, | |
| 1655 | providerTurnId: turn.providerTurnId, | |
| 1656 | nativeItemRef, | |
| 1657 | runtimeRequestId: null, | |
| 1658 | checkpointScopeId: null, | |
| 1659 | startedAt, | |
| 1660 | completedAt, | |
| 1661 | }, | |
| 1662 | }); | |
| 1663 | yield* emitProviderEvent({ | |
| 1664 | type: "turn_item.updated", | |
| 1665 | driver: OPENCODE_PROVIDER, | |
| 1666 | turnItem, | |
| 1667 | }); | |
| 1668 | }); | |
| 1669 | ||
| 1670 | const emitTodo = Effect.fnUntraced(function* ( | |
| 1671 | state: OpenCodeThreadState, | |
| 1672 | turn: ActiveOpenCodeTurn, | |
| 1673 | todos: ReadonlyArray<OpenCodeTodo>, | |
| 1674 | ) { | |
| 1675 | const now = yield* DateTime.now; | |
| 1676 | if (turn.planId === null) { | |
| 1677 | turn.planId = yield* idAllocator.allocate.plan({ | |
| 1678 | threadId: turn.threadId, | |
| 1679 | ...(turn.runId === null ? {} : { runId: turn.runId }), | |
| 1680 | driver: OPENCODE_PROVIDER, | |
| 1681 | }); | |
| 1682 | } | |
| 1683 | const planId = turn.planId; | |
| 1684 | const nativeItemId = `${state.nativeSessionId}:todo:${turn.providerTurnId}`; | |
| 1685 | const nodeId = idAllocator.derive.nodeFromProviderItem({ | |
| 1686 | driver: OPENCODE_PROVIDER, | |
| 1687 | nativeItemId, | |
| 1688 | }); | |
| 1689 | const turnItemId = idAllocator.derive.turnItemFromProviderItem({ | |
| 1690 | driver: OPENCODE_PROVIDER, | |
| 1691 | nativeItemId, | |
| 1692 | }); | |
| 1693 | const steps: Array<OrchestrationV2PlanStep> = todos.map((todo, index) => ({ | |
| 1694 | id: `${nativeItemId}:${index + 1}`, | |
| 1695 | text: todo.content.trim() || `Todo ${index + 1}`, | |
| 1696 | status: | |
| 1697 | todo.status === "completed" | |
| 1698 | ? "completed" | |
| 1699 | : todo.status === "in_progress" | |
| 1700 | ? "running" | |
| 1701 | : "pending", | |
| 1702 | })); | |
| 1703 | const completed = steps.length > 0 && steps.every((step) => step.status === "completed"); | |
| 1704 | yield* emitProviderEvent({ | |
| 1705 | type: "node.updated", | |
| 1706 | driver: OPENCODE_PROVIDER, | |
| 1707 | node: { | |
| 1708 | id: nodeId, | |
| 1709 | threadId: turn.threadId, | |
| 1710 | runId: turn.runId, | |
| 1711 | parentNodeId: turn.rootNodeId, | |
| 1712 | rootNodeId: turn.rootNodeId, | |
| 1713 | kind: "todo_list", | |
| 1714 | status: completed ? "completed" : "running", | |
| 1715 | countsForRun: false, | |
| 1716 | providerThreadId: state.providerThread.id, | |
| 1717 | providerTurnId: turn.providerTurnId, | |
| 1718 | nativeItemRef: providerRef(nativeItemId, "weak"), | |
| 1719 | runtimeRequestId: null, | |
| 1720 | checkpointScopeId: null, | |
| 1721 | startedAt: turn.startedAt, | |
| 1722 | completedAt: completed ? now : null, | |
| 1723 | }, | |
| 1724 | }); | |
| 1725 | yield* emitProviderEvent({ | |
| 1726 | type: "plan.updated", | |
| 1727 | driver: OPENCODE_PROVIDER, | |
| 1728 | plan: { | |
| 1729 | id: planId, | |
| 1730 | threadId: turn.threadId, | |
| 1731 | runId: turn.runId, | |
| 1732 | nodeId, | |
| 1733 | status: completed ? "completed" : "active", | |
| 1734 | kind: "todo_list", | |
| 1735 | steps, | |
| 1736 | }, | |
| 1737 | }); | |
| 1738 | yield* emitProviderEvent({ | |
| 1739 | type: "turn_item.updated", | |
| 1740 | driver: OPENCODE_PROVIDER, | |
| 1741 | turnItem: { | |
| 1742 | id: turnItemId, | |
| 1743 | threadId: turn.threadId, | |
| 1744 | runId: turn.runId, | |
| 1745 | nodeId, | |
| 1746 | providerThreadId: state.providerThread.id, | |
| 1747 | providerTurnId: turn.providerTurnId, | |
| 1748 | nativeItemRef: providerRef(nativeItemId, "weak"), | |
| 1749 | parentItemId: null, | |
| 1750 | ordinal: itemOrdinal(turn, nativeItemId), | |
| 1751 | status: completed ? "completed" : "running", | |
| 1752 | title: "Todo list", | |
| 1753 | startedAt: turn.startedAt, | |
| 1754 | completedAt: completed ? now : null, | |
| 1755 | updatedAt: now, | |
| 1756 | type: "todo_list", | |
| 1757 | planId, | |
| 1758 | steps, | |
| 1759 | }, | |
| 1760 | }); | |
| 1761 | }); | |
| 1762 | ||
| 1763 | const requestQuestions = (request: QuestionRequest) => | |
| 1764 | request.questions.map((question, index) => ({ | |
| 1765 | id: OpenCodeRuntime.openCodeQuestionId(index, question), | |
| 1766 | header: question.header.trim() || `Question ${index + 1}`, | |
| 1767 | question: question.question.trim() || question.header.trim() || `Question ${index + 1}`, | |
| 1768 | options: question.options.map((option) => ({ | |
| 1769 | label: option.label.trim() || "Option", | |
| 1770 | description: option.description.trim() || option.label.trim() || "Option", | |
| 1771 | })), | |
| 1772 | })); | |
| 1773 | ||
| 1774 | const runtimeRequestTurnItem = ( | |
| 1775 | pending: PendingOpenCodeRequest, | |
| 1776 | status: OrchestrationV2TurnItem["status"], | |
| 1777 | completedAt: DateTime.Utc | null, | |
| 1778 | updatedAt: DateTime.Utc, | |
| 1779 | ): OrchestrationV2TurnItem => { | |
| 1780 | const base = { | |
| 1781 | id: pending.turnItemId, | |
| 1782 | threadId: pending.turn.threadId, | |
| 1783 | runId: pending.turn.runId, | |
| 1784 | nodeId: pending.nodeId, | |
| 1785 | providerThreadId: pending.state.providerThread.id, | |
| 1786 | providerTurnId: pending.turn.providerTurnId, | |
| 1787 | nativeItemRef: providerRef(pending.nativeRequestId), | |
| 1788 | parentItemId: null, | |
| 1789 | ordinal: itemOrdinal(pending.turn, pending.nativeRequestId), | |
| 1790 | status, | |
| 1791 | startedAt: pending.createdAt, | |
| 1792 | completedAt, | |
| 1793 | updatedAt, | |
| 1794 | }; | |
| 1795 | if (pending.question !== undefined) { | |
| 1796 | return { | |
| 1797 | ...base, | |
| 1798 | title: "User input", | |
| 1799 | type: "user_input_request", | |
| 1800 | requestId: pending.requestId, | |
| 1801 | questions: requestQuestions(pending.question), | |
| 1802 | }; | |
| 1803 | } | |
| 1804 | const permission = pending.permission; | |
| 1805 | if (permission === undefined) { | |
| 1806 | throw protocolError(`OpenCode request ${pending.requestId} has no native payload`); | |
| 1807 | } | |
| 1808 | return { | |
| 1809 | ...base, | |
| 1810 | title: permission.permission, | |
| 1811 | type: "approval_request", | |
| 1812 | requestId: pending.requestId, | |
| 1813 | requestKind: pending.requestKind === "user_input" ? "command" : pending.requestKind, | |
| 1814 | prompt: | |
| 1815 | permission.patterns.length === 0 | |
| 1816 | ? permission.permission | |
| 1817 | : permission.patterns.join("\n"), | |
| 1818 | }; | |
| 1819 | }; | |
| 1820 | ||
| 1821 | const emitRuntimeRequest = Effect.fnUntraced(function* ( | |
| 1822 | owner: OpenCodeRequestOwner, | |
| 1823 | nativeRequestId: string, | |
| 1824 | request: | |
| 1825 | | { readonly type: "permission"; readonly value: PermissionRequest } | |
| 1826 | | { readonly type: "question"; readonly value: QuestionRequest }, | |
| 1827 | ) { | |
| 1828 | if (pendingRequestsByNativeId.has(nativeRequestId)) return; | |
| 1829 | const { state, turn, subagent } = owner; | |
| 1830 | const now = yield* DateTime.now; | |
| 1831 | const requestId = yield* idAllocator.allocate.runtimeRequest({ | |
| 1832 | driver: OPENCODE_PROVIDER, | |
| 1833 | providerTurnId: turn.providerTurnId, | |
| 1834 | nativeRequestId, | |
| 1835 | }); | |
| 1836 | const nodeId = idAllocator.derive.approvalNode({ requestId }); | |
| 1837 | const turnItemId = idAllocator.derive.approvalTurnItem({ requestId }); | |
| 1838 | // The tool call belongs to the asking session's turn, not the owner's. | |
| 1839 | const permissionToolName = | |
| 1840 | request.type === "permission" && request.value.tool !== undefined | |
| 1841 | ? threads | |
| 1842 | .get(request.value.sessionID) | |
| 1843 | ?.activeTurn?.toolNamesByCallId.get(request.value.tool.callID) | |
| 1844 | : undefined; | |
| 1845 | const permissionRequestKind = | |
| 1846 | request.type === "permission" | |
| 1847 | ? openCodePermissionRequestKind(request.value.permission, permissionToolName) | |
| 1848 | : undefined; | |
| 1849 | const requestKind: OrchestrationV2RuntimeRequest["kind"] = | |
| 1850 | permissionRequestKind ?? "user_input"; | |
| 1851 | const pending: PendingOpenCodeRequest = { | |
| 1852 | requestId, | |
| 1853 | nativeRequestId, | |
| 1854 | nativeSessionId: request.value.sessionID, | |
| 1855 | turn, | |
| 1856 | state, | |
| 1857 | nodeId, | |
| 1858 | parentNodeId: subagent?.nodeId ?? turn.rootNodeId, | |
| 1859 | turnItemId, | |
| 1860 | requestKind, | |
| 1861 | createdAt: now, | |
| 1862 | ...(request.type === "permission" | |
| 1863 | ? { permission: request.value } | |
| 1864 | : { question: request.value }), | |
| 1865 | }; | |
| 1866 | pendingRequests.set(String(requestId), pending); | |
| 1867 | pendingRequestsByNativeId.set(nativeRequestId, pending); | |
| 1868 | const runtimeRequest: OrchestrationV2RuntimeRequest = { | |
| 1869 | id: requestId, | |
| 1870 | nodeId, | |
| 1871 | providerTurnId: turn.providerTurnId, | |
| 1872 | nativeRequestRef: providerRef(nativeRequestId), | |
| 1873 | kind: requestKind, | |
| 1874 | status: "pending", | |
| 1875 | responseCapability: { | |
| 1876 | type: "live", | |
| 1877 | providerSessionId: inputProviderSessionId, | |
| 1878 | }, | |
| 1879 | createdAt: now, | |
| 1880 | resolvedAt: null, | |
| 1881 | }; | |
| 1882 | state.runtimeRequests.set(String(requestId), runtimeRequest); | |
| 1883 | yield* emitProviderEvent({ | |
| 1884 | type: "node.updated", | |
| 1885 | driver: OPENCODE_PROVIDER, | |
| 1886 | node: { | |
| 1887 | id: nodeId, | |
| 1888 | threadId: turn.threadId, | |
| 1889 | runId: turn.runId, | |
| 1890 | parentNodeId: pending.parentNodeId, | |
| 1891 | rootNodeId: turn.rootNodeId, | |
| 1892 | kind: request.type === "question" ? "user_input_request" : "approval_request", | |
| 1893 | status: "waiting", | |
| 1894 | countsForRun: false, | |
| 1895 | providerThreadId: state.providerThread.id, | |
| 1896 | providerTurnId: turn.providerTurnId, | |
| 1897 | nativeItemRef: providerRef(nativeRequestId), | |
| 1898 | runtimeRequestId: requestId, | |
| 1899 | checkpointScopeId: null, | |
| 1900 | startedAt: now, | |
| 1901 | completedAt: null, | |
| 1902 | }, | |
| 1903 | }); | |
| 1904 | yield* emitProviderEvent({ | |
| 1905 | type: "runtime_request.updated", | |
| 1906 | driver: OPENCODE_PROVIDER, | |
| 1907 | threadId: turn.threadId, | |
| 1908 | runtimeRequest, | |
| 1909 | }); | |
| 1910 | yield* emitProviderEvent({ | |
| 1911 | type: "turn_item.updated", | |
| 1912 | driver: OPENCODE_PROVIDER, | |
| 1913 | turnItem: runtimeRequestTurnItem(pending, "waiting", null, now), | |
| 1914 | }); | |
| 1915 | yield* updateProviderSession("waiting", null); | |
| 1916 | }); | |
| 1917 | ||
| 1918 | const resolveRuntimeRequest = Effect.fnUntraced(function* ( | |
| 1919 | nativeRequestId: string, | |
| 1920 | status: "resolved" | "cancelled", | |
| 1921 | ) { | |
| 1922 | const pending = pendingRequestsByNativeId.get(nativeRequestId); | |
| 1923 | if (pending === undefined) return; | |
| 1924 | const now = yield* DateTime.now; | |
| 1925 | const current = pending.state.runtimeRequests.get(String(pending.requestId)); | |
| 1926 | if (current !== undefined) { | |
| 1927 | const resolved: OrchestrationV2RuntimeRequest = { | |
| 1928 | ...current, | |
| 1929 | status, | |
| 1930 | resolvedAt: now, | |
| 1931 | }; | |
| 1932 | pending.state.runtimeRequests.set(String(pending.requestId), resolved); | |
| 1933 | yield* emitProviderEvent({ | |
| 1934 | type: "runtime_request.updated", | |
| 1935 | driver: OPENCODE_PROVIDER, | |
| 1936 | threadId: pending.turn.threadId, | |
| 1937 | runtimeRequest: resolved, | |
| 1938 | }); | |
| 1939 | } | |
| 1940 | yield* emitProviderEvent({ | |
| 1941 | type: "node.updated", | |
| 1942 | driver: OPENCODE_PROVIDER, | |
| 1943 | node: { | |
| 1944 | id: pending.nodeId, | |
| 1945 | threadId: pending.turn.threadId, | |
| 1946 | runId: pending.turn.runId, | |
| 1947 | parentNodeId: pending.parentNodeId, | |
| 1948 | rootNodeId: pending.turn.rootNodeId, | |
| 1949 | kind: pending.question === undefined ? "approval_request" : "user_input_request", | |
| 1950 | status: status === "resolved" ? "completed" : "cancelled", | |
| 1951 | countsForRun: false, | |
| 1952 | providerThreadId: pending.state.providerThread.id, | |
| 1953 | providerTurnId: pending.turn.providerTurnId, | |
| 1954 | nativeItemRef: providerRef(nativeRequestId), | |
| 1955 | runtimeRequestId: pending.requestId, | |
| 1956 | checkpointScopeId: null, | |
| 1957 | startedAt: pending.createdAt, | |
| 1958 | completedAt: now, | |
| 1959 | }, | |
| 1960 | }); | |
| 1961 | yield* emitProviderEvent({ | |
| 1962 | type: "turn_item.updated", | |
| 1963 | driver: OPENCODE_PROVIDER, | |
| 1964 | turnItem: runtimeRequestTurnItem( | |
| 1965 | pending, | |
| 1966 | status === "resolved" ? "completed" : "cancelled", | |
| 1967 | now, | |
| 1968 | now, | |
| 1969 | ), | |
| 1970 | }); | |
| 1971 | pendingRequests.delete(String(pending.requestId)); | |
| 1972 | pendingRequestsByNativeId.delete(nativeRequestId); | |
| 1973 | const hasOtherPending = Array.from(pendingRequests.values()).some( | |
| 1974 | (candidate) => candidate.turn.isRoot, | |
| 1975 | ); | |
| 1976 | if (!hasOtherPending) yield* updateProviderSession("running", null); | |
| 1977 | }); | |
| 1978 | ||
| 1979 | /** Resolve the thread state a session belongs to: its own, or for a | |
| 1980 | * child not registered yet, the nearest known ancestor found by walking | |
| 1981 | * the native parent chain. Registers every hop so later requests from | |
| 1982 | * the same child resolve without another lookup. */ | |
| 1983 | const resolveSessionOwner = Effect.fnUntraced(function* (sessionId: string) { | |
| 1984 | const known = threads.get(sessionId) ?? relatedSessionOwners.get(sessionId); | |
| 1985 | if (known !== undefined) return known; | |
| 1986 | let cursor = sessionId; | |
| 1987 | const hops: string[] = []; | |
| 1988 | for (let depth = 0; depth < 5; depth += 1) { | |
| 1989 | const response = yield* sdkCall("session.get", { sessionID: cursor }, () => | |
| 1990 | client.session.get({ sessionID: cursor }), | |
| 1991 | ).pipe(Effect.option); | |
| 1992 | const info = Option.getOrUndefined(response)?.data; | |
| 1993 | const parentId = info?.parentID; | |
| 1994 | if (parentId === undefined) return undefined; | |
| 1995 | hops.push(cursor); | |
| 1996 | const owner = threads.get(parentId) ?? relatedSessionOwners.get(parentId); | |
| 1997 | if (owner !== undefined) { | |
| 1998 | for (const hop of hops) relatedSessionOwners.set(hop, owner); | |
| 1999 | return owner; | |
| 2000 | } | |
| 2001 | cursor = parentId; | |
| 2002 | } | |
| 2003 | return undefined; | |
| 2004 | }); | |
| 2005 | ||
| 2006 | /** Every request is asked on the top-level thread and its active turn, | |
| 2007 | * under the subagent that leads to the asking session, because native | |
| 2008 | * subagent threads are hidden from the sidebar. A child's request can | |
| 2009 | * arrive before the task part or session.created event that reveals | |
| 2010 | * its relation to a thread. The first resolution attempt runs inline | |
| 2011 | * (the replayable path); if the relation or the owning turn is not | |
| 2012 | * established yet, a short forked backoff keeps trying instead of | |
| 2013 | * dropping the request. */ | |
| 2014 | const routeRuntimeRequest = Effect.fnUntraced(function* ( | |
| 2015 | nativeRequestId: string, | |
| 2016 | sessionId: string, | |
| 2017 | request: | |
| 2018 | | { readonly type: "permission"; readonly value: PermissionRequest } | |
| 2019 | | { readonly type: "question"; readonly value: QuestionRequest }, | |
| 2020 | ) { | |
| 2021 | if (pendingChildRequestRoutes.has(nativeRequestId)) return; | |
| 2022 | const attempt = Effect.gen(function* () { | |
| 2023 | if ( | |
| 2024 | settledNativeRequestIds.has(nativeRequestId) || | |
| 2025 | pendingRequestsByNativeId.has(nativeRequestId) | |
| 2026 | ) { | |
| 2027 | return true; | |
| 2028 | } | |
| 2029 | const state = yield* resolveSessionOwner(sessionId); | |
| 2030 | const owner = state === undefined ? undefined : topLevelRequestOwner(state); | |
| 2031 | if (owner === undefined) return false; | |
| 2032 | yield* emitRuntimeRequest(owner, nativeRequestId, request); | |
| 2033 | return true; | |
| 2034 | }); | |
| 2035 | if (yield* attempt) return; | |
| 2036 | pendingChildRequestRoutes.add(nativeRequestId); | |
| 2037 | yield* Effect.gen(function* () { | |
| 2038 | for (let retry = 0; retry < 5; retry += 1) { | |
| 2039 | yield* Effect.sleep(Duration.millis(Math.min(200 * 2 ** retry, 2_000))); | |
| 2040 | if (yield* attempt) return; | |
| 2041 | } | |
| 2042 | }).pipe( | |
| 2043 | Effect.ensuring(Effect.sync(() => pendingChildRequestRoutes.delete(nativeRequestId))), | |
| 2044 | Effect.forkIn(scope), | |
| 2045 | ); | |
| 2046 | }); | |
| 2047 | ||
| 2048 | const finalizeTurn = Effect.fnUntraced(function* ( | |
| 2049 | state: OpenCodeThreadState, | |
| 2050 | turn: ActiveOpenCodeTurn, | |
| 2051 | status: TerminalTurnStatus, | |
| 2052 | terminal?: { | |
| 2053 | readonly failure?: OrchestrationV2ProviderFailure; | |
| 2054 | readonly threadDisposition?: "reusable" | "broken"; | |
| 2055 | }, | |
| 2056 | ) { | |
| 2057 | if (turn.finalized) return; | |
| 2058 | if (nativeStreamFailure !== null) { | |
| 2059 | status = "failed"; | |
| 2060 | terminal = { failure: nativeStreamFailure, threadDisposition: "broken" }; | |
| 2061 | } | |
| 2062 | turn.finalized = true; | |
| 2063 | const completedAt = yield* DateTime.now; | |
| 2064 | for (const part of turn.parts.values()) { | |
| 2065 | if (part.type === "text" || part.type === "reasoning") { | |
| 2066 | yield* emitTextPart(state, turn, part, true); | |
| 2067 | } | |
| 2068 | } | |
| 2069 | for (const pending of Array.from(pendingRequests.values())) { | |
| 2070 | if ( | |
| 2071 | pending.turn.providerTurnId === turn.providerTurnId || | |
| 2072 | pending.nativeSessionId === state.nativeSessionId | |
| 2073 | ) { | |
| 2074 | yield* resolveRuntimeRequest(pending.nativeRequestId, "cancelled"); | |
| 2075 | } | |
| 2076 | } | |
| 2077 | yield* emitProviderTurn(state, turn, status, completedAt); | |
| 2078 | const threadDisposition = terminal?.threadDisposition ?? "reusable"; | |
| 2079 | yield* updateProviderThread(state, { | |
| 2080 | status: turn.isRoot ? "active" : threadDisposition === "broken" ? "error" : "idle", | |
| 2081 | nativeConversationHeadRef: | |
| 2082 | turn.nativeUserMessageId === null | |
| 2083 | ? state.providerThread.nativeConversationHeadRef | |
| 2084 | : providerRef(turn.nativeUserMessageId, "weak"), | |
| 2085 | }); | |
| 2086 | state.activeTurn = null; | |
| 2087 | if (!turn.isRoot) { | |
| 2088 | yield* emitProviderEvent({ | |
| 2089 | type: "node.updated", | |
| 2090 | driver: OPENCODE_PROVIDER, | |
| 2091 | node: { | |
| 2092 | id: turn.rootNodeId, | |
| 2093 | threadId: turn.threadId, | |
| 2094 | runId: null, | |
| 2095 | parentNodeId: null, | |
| 2096 | rootNodeId: turn.rootNodeId, | |
| 2097 | kind: "root_turn", | |
| 2098 | status, | |
| 2099 | countsForRun: false, | |
| 2100 | providerThreadId: state.providerThread.id, | |
| 2101 | providerTurnId: turn.providerTurnId, | |
| 2102 | nativeItemRef: providerRef(state.nativeSessionId), | |
| 2103 | runtimeRequestId: null, | |
| 2104 | checkpointScopeId: null, | |
| 2105 | startedAt: turn.startedAt, | |
| 2106 | completedAt, | |
| 2107 | }, | |
| 2108 | }); | |
| 2109 | return; | |
| 2110 | } | |
| 2111 | const anotherTurnIsActive = Array.from(threads.values()).some( | |
| 2112 | (candidate) => candidate.activeTurn?.isRoot === true, | |
| 2113 | ); | |
| 2114 | yield* updateProviderSession( | |
| 2115 | anotherTurnIsActive ? "running" : status === "failed" ? "error" : "ready", | |
| 2116 | status === "failed" ? sessionEntity.lastError : null, | |
| 2117 | ); | |
| 2118 | yield* emitProviderEvent( | |
| 2119 | status === "failed" | |
| 2120 | ? { | |
| 2121 | type: "turn.terminal", | |
| 2122 | driver: OPENCODE_PROVIDER, | |
| 2123 | providerThreadId: turn.providerThreadId, | |
| 2124 | providerTurnId: turn.providerTurnId, | |
| 2125 | runOrdinal: turn.runOrdinal, | |
| 2126 | failureItemOrdinal: itemOrdinal(turn, `terminal-failure:${turn.providerTurnId}`), | |
| 2127 | status, | |
| 2128 | failure: | |
| 2129 | terminal?.failure ?? | |
| 2130 | makeProviderFailure({ | |
| 2131 | message: sessionEntity.lastError ?? undefined, | |
| 2132 | class: "provider_error", | |
| 2133 | }), | |
| 2134 | threadDisposition, | |
| 2135 | } | |
| 2136 | : { | |
| 2137 | type: "turn.terminal", | |
| 2138 | driver: OPENCODE_PROVIDER, | |
| 2139 | providerThreadId: turn.providerThreadId, | |
| 2140 | providerTurnId: turn.providerTurnId, | |
| 2141 | runOrdinal: turn.runOrdinal, | |
| 2142 | status, | |
| 2143 | failure: null, | |
| 2144 | threadDisposition, | |
| 2145 | }, | |
| 2146 | ); | |
| 2147 | }); | |
| 2148 | ||
| 2149 | const promptAdmissionIsCurrent = ( | |
| 2150 | state: OpenCodeThreadState, | |
| 2151 | turn: ActiveOpenCodeTurn, | |
| 2152 | generation: number, | |
| 2153 | ) => | |
| 2154 | state.activeTurn === turn && | |
| 2155 | !turn.finalized && | |
| 2156 | !turn.interrupted && | |
| 2157 | turn.admissionPending && | |
| 2158 | turn.admissionGeneration === generation; | |
| 2159 | ||
| 2160 | const runPromptAdmissionReconciliation = Effect.fnUntraced(function* ( | |
| 2161 | state: OpenCodeThreadState, | |
| 2162 | turn: ActiveOpenCodeTurn, | |
| 2163 | generation: number, | |
| 2164 | ) { | |
| 2165 | while (promptAdmissionIsCurrent(state, turn, generation)) { | |
| 2166 | const status = yield* reconcileOpenCodePromptAdmissionStatus( | |
| 2167 | turn, | |
| 2168 | generation, | |
| 2169 | sdkCall("session.status", { sessionID: state.nativeSessionId, generation }, () => | |
| 2170 | client.session.status(), | |
| 2171 | ).pipe( | |
| 2172 | Effect.match({ | |
| 2173 | onFailure: () => "unknown" as const, | |
| 2174 | onSuccess: (response) => { | |
| 2175 | const statuses = unwrapData("session.status", response); | |
| 2176 | const sessionStatus = statuses[state.nativeSessionId]; | |
| 2177 | return sessionStatus === undefined || sessionStatus.type === "idle" | |
| 2178 | ? ("idle" as const) | |
| 2179 | : ("busy" as const); | |
| 2180 | }, | |
| 2181 | }), | |
| 2182 | ), | |
| 2183 | ); | |
| 2184 | if ( | |
| 2185 | state.activeTurn !== turn || | |
| 2186 | status === "stale" || | |
| 2187 | turn.finalized || | |
| 2188 | turn.interrupted || | |
| 2189 | turn.admissionGeneration !== generation | |
| 2190 | ) { | |
| 2191 | return; | |
| 2192 | } | |
| 2193 | if (status === "idle") { | |
| 2194 | yield* finalizeTurn(state, turn, "completed"); | |
| 2195 | return; | |
| 2196 | } | |
| 2197 | if (status === "busy") return; | |
| 2198 | yield* Effect.sleep("250 millis"); | |
| 2199 | } | |
| 2200 | }); | |
| 2201 | ||
| 2202 | const reconcilePromptAdmission = Effect.fnUntraced(function* ( | |
| 2203 | state: OpenCodeThreadState, | |
| 2204 | turn: ActiveOpenCodeTurn, | |
| 2205 | ) { | |
| 2206 | const generation = turn.admissionGeneration; | |
| 2207 | if ( | |
| 2208 | !promptAdmissionIsCurrent(state, turn, generation) || | |
| 2209 | turn.admissionReconciliationGeneration === generation | |
| 2210 | ) { | |
| 2211 | return; | |
| 2212 | } | |
| 2213 | turn.admissionReconciliationGeneration = generation; | |
| 2214 | yield* runPromptAdmissionReconciliation(state, turn, generation).pipe( | |
| 2215 | Effect.ensuring( | |
| 2216 | Effect.sync(() => { | |
| 2217 | if (turn.admissionReconciliationGeneration === generation) { | |
| 2218 | turn.admissionReconciliationGeneration = null; | |
| 2219 | } | |
| 2220 | }), | |
| 2221 | ), | |
| 2222 | Effect.forkIn(scope), | |
| 2223 | ); | |
| 2224 | }); | |
| 2225 | ||
| 2226 | const createChildTurn = Effect.fnUntraced(function* ( | |
| 2227 | state: OpenCodeThreadState, | |
| 2228 | message: Extract<OpenCodeMessage, { role: "user" }>, | |
| 2229 | ) { | |
| 2230 | if (state.appThread === null || state.parentSubagent === null) return null; | |
| 2231 | const now = yield* DateTime.now; | |
| 2232 | const startedAt = dateTimeFromEpoch(message.time.created, now); | |
| 2233 | const rootNodeId = idAllocator.derive.nodeFromProviderItem({ | |
| 2234 | driver: OPENCODE_PROVIDER, | |
| 2235 | nativeItemId: `${state.nativeSessionId}:root:${message.id}`, | |
| 2236 | }); | |
| 2237 | const providerTurnId = idAllocator.derive.providerTurn({ | |
| 2238 | driver: OPENCODE_PROVIDER, | |
| 2239 | nativeTurnId: message.id, | |
| 2240 | }); | |
| 2241 | const providerTurn: OrchestrationV2ProviderTurn = { | |
| 2242 | id: providerTurnId, | |
| 2243 | providerThreadId: state.providerThread.id, | |
| 2244 | nodeId: rootNodeId, | |
| 2245 | runAttemptId: null, | |
| 2246 | nativeTurnRef: providerRef(message.id, "weak"), | |
| 2247 | ordinal: state.nextChildTurnOrdinal++, | |
| 2248 | status: "running", | |
| 2249 | startedAt, | |
| 2250 | completedAt: null, | |
| 2251 | }; | |
| 2252 | const turn: ActiveOpenCodeTurn = { | |
| 2253 | isRoot: false, | |
| 2254 | threadId: state.appThread.id, | |
| 2255 | runId: null, | |
| 2256 | rootNodeId, | |
| 2257 | appThread: state.appThread, | |
| 2258 | modelSelection: state.appThread.modelSelection, | |
| 2259 | runtimePolicy: state.parentSubagent.parentTurn.runtimePolicy, | |
| 2260 | providerTurnId, | |
| 2261 | providerThreadId: providerTurn.providerThreadId, | |
| 2262 | providerTurnOrdinal: providerTurn.ordinal, | |
| 2263 | runOrdinal: state.parentSubagent.parentTurn.runOrdinal, | |
| 2264 | runAttemptId: null, | |
| 2265 | startedAt, | |
| 2266 | itemOrdinals: new Map(), | |
| 2267 | usage: makeOpenCodeTurnTokenUsageAccumulator(), | |
| 2268 | parts: new Map(), | |
| 2269 | partIdsByMessage: new Map(), | |
| 2270 | toolNamesByCallId: new Map(), | |
| 2271 | providerTurn, | |
| 2272 | nextItemOrdinal: 1, | |
| 2273 | nativeUserMessageId: message.id, | |
| 2274 | admissionMessageId: null, | |
| 2275 | interrupted: false, | |
| 2276 | finalized: false, | |
| 2277 | planId: null, | |
| 2278 | admissionGeneration: 0, | |
| 2279 | admissionReconciliationGeneration: null, | |
| 2280 | admissionPending: false, | |
| 2281 | admissionAccepted: true, | |
| 2282 | admissionMessageObserved: true, | |
| 2283 | idleDuringAdmission: false, | |
| 2284 | admissionSettled: Deferred.makeUnsafe<void>(), | |
| 2285 | admissionAbortController: null, | |
| 2286 | }; | |
| 2287 | state.activeTurn = turn; | |
| 2288 | state.providerTurns.set(String(providerTurnId), providerTurn); | |
| 2289 | yield* emitProviderEvent({ | |
| 2290 | type: "node.updated", | |
| 2291 | driver: OPENCODE_PROVIDER, | |
| 2292 | node: { | |
| 2293 | id: rootNodeId, | |
| 2294 | threadId: turn.threadId, | |
| 2295 | runId: null, | |
| 2296 | parentNodeId: null, | |
| 2297 | rootNodeId, | |
| 2298 | kind: "root_turn", | |
| 2299 | status: "running", | |
| 2300 | countsForRun: false, | |
| 2301 | providerThreadId: state.providerThread.id, | |
| 2302 | providerTurnId, | |
| 2303 | nativeItemRef: providerRef(message.id, "weak"), | |
| 2304 | runtimeRequestId: null, | |
| 2305 | checkpointScopeId: null, | |
| 2306 | startedAt, | |
| 2307 | completedAt: null, | |
| 2308 | }, | |
| 2309 | }); | |
| 2310 | yield* emitProviderTurn(state, turn, "running", null); | |
| 2311 | return turn; | |
| 2312 | }); | |
| 2313 | ||
| 2314 | const projectChildUserPart = Effect.fnUntraced(function* ( | |
| 2315 | state: OpenCodeThreadState, | |
| 2316 | turn: ActiveOpenCodeTurn, | |
| 2317 | part: Extract<OpenCodePart, { type: "text" }>, | |
| 2318 | ) { | |
| 2319 | const now = yield* DateTime.now; | |
| 2320 | const messageId = idAllocator.derive.messageFromProviderItem({ | |
| 2321 | driver: OPENCODE_PROVIDER, | |
| 2322 | nativeItemId: part.messageID, | |
| 2323 | }); | |
| 2324 | const turnItemId = idAllocator.derive.turnItemFromProviderItem({ | |
| 2325 | driver: OPENCODE_PROVIDER, | |
| 2326 | nativeItemId: part.messageID, | |
| 2327 | }); | |
| 2328 | const projected: OrchestrationV2ConversationMessage = { | |
| 2329 | createdBy: "agent", | |
| 2330 | creationSource: "provider", | |
| 2331 | id: messageId, | |
| 2332 | threadId: turn.threadId, | |
| 2333 | runId: null, | |
| 2334 | nodeId: turn.rootNodeId, | |
| 2335 | role: "user", | |
| 2336 | text: part.text, | |
| 2337 | attachments: [], | |
| 2338 | streaming: false, | |
| 2339 | createdAt: turn.startedAt, | |
| 2340 | updatedAt: now, | |
| 2341 | }; | |
| 2342 | state.messages.set(String(messageId), projected); | |
| 2343 | yield* emitProviderEvent({ | |
| 2344 | type: "message.updated", | |
| 2345 | driver: OPENCODE_PROVIDER, | |
| 2346 | message: projected, | |
| 2347 | }); | |
| 2348 | yield* emitProviderEvent({ | |
| 2349 | type: "turn_item.updated", | |
| 2350 | driver: OPENCODE_PROVIDER, | |
| 2351 | turnItem: { | |
| 2352 | createdBy: "agent", | |
| 2353 | creationSource: "provider", | |
| 2354 | id: turnItemId, | |
| 2355 | threadId: turn.threadId, | |
| 2356 | runId: null, | |
| 2357 | nodeId: turn.rootNodeId, | |
| 2358 | providerThreadId: state.providerThread.id, | |
| 2359 | providerTurnId: turn.providerTurnId, | |
| 2360 | nativeItemRef: providerRef(part.messageID), | |
| 2361 | parentItemId: null, | |
| 2362 | ordinal: itemOrdinal(turn, part.messageID), | |
| 2363 | status: "completed", | |
| 2364 | title: null, | |
| 2365 | startedAt: projected.createdAt, | |
| 2366 | completedAt: now, | |
| 2367 | updatedAt: now, | |
| 2368 | type: "user_message", | |
| 2369 | messageId, | |
| 2370 | inputIntent: "turn_start", | |
| 2371 | text: part.text, | |
| 2372 | attachments: [], | |
| 2373 | }, | |
| 2374 | }); | |
| 2375 | }); | |
| 2376 | ||
| 2377 | const emitCompactionItem = Effect.fn("OpenCodeAdapterV2.emitCompactionItem")(function* ( | |
| 2378 | state: OpenCodeThreadState, | |
| 2379 | turn: ActiveOpenCodeTurn, | |
| 2380 | ) { | |
| 2381 | const nativeItemId = `${turn.providerTurnId}:compaction`; | |
| 2382 | if (turn.itemOrdinals.has(nativeItemId)) return; | |
| 2383 | const now = yield* DateTime.now; | |
| 2384 | yield* emitProviderEvent({ | |
| 2385 | type: "turn_item.updated", | |
| 2386 | driver: OPENCODE_PROVIDER, | |
| 2387 | turnItem: { | |
| 2388 | id: idAllocator.derive.turnItemFromProviderItem({ | |
| 2389 | driver: OPENCODE_PROVIDER, | |
| 2390 | nativeItemId, | |
| 2391 | }), | |
| 2392 | threadId: turn.threadId, | |
| 2393 | runId: turn.runId, | |
| 2394 | nodeId: turn.rootNodeId, | |
| 2395 | providerThreadId: state.providerThread.id, | |
| 2396 | providerTurnId: turn.providerTurnId, | |
| 2397 | nativeItemRef: providerRef(nativeItemId, "weak"), | |
| 2398 | parentItemId: null, | |
| 2399 | ordinal: itemOrdinal(turn, nativeItemId), | |
| 2400 | type: "compaction", | |
| 2401 | driver: OPENCODE_PROVIDER, | |
| 2402 | status: "completed", | |
| 2403 | title: "Context compacted", | |
| 2404 | startedAt: turn.startedAt, | |
| 2405 | completedAt: now, | |
| 2406 | updatedAt: now, | |
| 2407 | }, | |
| 2408 | }); | |
| 2409 | }); | |
| 2410 | ||
| 2411 | const handleMessageUpdated = Effect.fnUntraced(function* ( | |
| 2412 | event: Extract<OpenCodeEvent, { type: "message.updated" }>, | |
| 2413 | ) { | |
| 2414 | const state = threads.get(event.properties.sessionID); | |
| 2415 | if (state === undefined) return; | |
| 2416 | const message = event.properties.info; | |
| 2417 | state.messageRoles.set(message.id, message.role); | |
| 2418 | if (message.role === "assistant") { | |
| 2419 | const usage = state.activeTurn?.usage; | |
| 2420 | if (usage === undefined) return; | |
| 2421 | const prior = usage.assistantOwnershipByMessageId.get(message.id); | |
| 2422 | const ownership = | |
| 2423 | prior !== undefined && prior !== "unknown" | |
| 2424 | ? prior | |
| 2425 | : !message.parentID | |
| 2426 | ? "unknown" | |
| 2427 | : usage.promptMessageIds.has(message.parentID) | |
| 2428 | ? "owned" | |
| 2429 | : "other"; | |
| 2430 | usage.assistantOwnershipByMessageId.set(message.id, ownership); | |
| 2431 | if (ownership !== "unknown") { | |
| 2432 | if (ownership === "owned") { | |
| 2433 | for (const step of usage.unresolvedStepsByMessageId.get(message.id)?.values() ?? | |
| 2434 | []) { | |
| 2435 | accumulateOpenCodeStepUsage(usage, step); | |
| 2436 | } | |
| 2437 | } | |
| 2438 | usage.unresolvedStepsByMessageId.delete(message.id); | |
| 2439 | } | |
| 2440 | return; | |
| 2441 | } | |
| 2442 | const commandReceipt = commandReceipts.get(message.id); | |
| 2443 | if (commandReceipt) yield* Deferred.succeed(commandReceipt, undefined); | |
| 2444 | const isNewUserMessage = !state.userMessageIds.includes(message.id); | |
| 2445 | if (isNewUserMessage) state.userMessageIds.push(message.id); | |
| 2446 | let turn = state.activeTurn; | |
| 2447 | if (turn === null && state.parentSubagent !== null && isNewUserMessage) { | |
| 2448 | turn = yield* createChildTurn(state, message); | |
| 2449 | } | |
| 2450 | const matchesAdmission = | |
| 2451 | turn !== null && | |
| 2452 | (turn.admissionMessageId === null || turn.admissionMessageId === message.id); | |
| 2453 | if (turn !== null && matchesAdmission) turn.usage.promptMessageIds.add(message.id); | |
| 2454 | if (turn !== null && matchesAdmission && turn.nativeUserMessageId === null) { | |
| 2455 | turn.nativeUserMessageId = message.id; | |
| 2456 | yield* emitProviderTurn(state, turn, "running", null); | |
| 2457 | } | |
| 2458 | if (turn !== null && matchesAdmission && turn.admissionPending) { | |
| 2459 | if (advanceOpenCodePromptAdmission(turn, "user-message") === "reconcile-idle") { | |
| 2460 | yield* reconcilePromptAdmission(state, turn); | |
| 2461 | } | |
| 2462 | } | |
| 2463 | }); | |
| 2464 | ||
| 2465 | const handlePartUpdated = Effect.fnUntraced(function* ( | |
| 2466 | event: Extract<OpenCodeEvent, { type: "message.part.updated" }>, | |
| 2467 | ) { | |
| 2468 | const part = event.properties.part; | |
| 2469 | const state = threads.get(part.sessionID); | |
| 2470 | const turn = state?.activeTurn; | |
| 2471 | if (state === undefined || turn === null || turn === undefined || turn.finalized) return; | |
| 2472 | if (part.type === "text" && state.messageRoles.get(part.messageID) === "user") { | |
| 2473 | if (!turn.isRoot) yield* projectChildUserPart(state, turn, part); | |
| 2474 | return; | |
| 2475 | } | |
| 2476 | if (part.type === "step-finish") { | |
| 2477 | const usage = turn.usage; | |
| 2478 | const ownership = usage.assistantOwnershipByMessageId.get(part.messageID); | |
| 2479 | if (ownership === "owned") accumulateOpenCodeStepUsage(usage, part); | |
| 2480 | else if ( | |
| 2481 | ownership === "unknown" || | |
| 2482 | (ownership === undefined && !state.messageRoles.has(part.messageID)) | |
| 2483 | ) { | |
| 2484 | const steps = | |
| 2485 | usage.unresolvedStepsByMessageId.get(part.messageID) ?? | |
| 2486 | new Map<string, OpenCodeStepUsage>(); | |
| 2487 | steps.set(part.id, { id: part.id, tokens: part.tokens }); | |
| 2488 | usage.unresolvedStepsByMessageId.set(part.messageID, steps); | |
| 2489 | } | |
| 2490 | return; | |
| 2491 | } | |
| 2492 | if (part.type === "tool") { | |
| 2493 | // Approval routing needs the tool name, without retaining its input and output. | |
| 2494 | turn.toolNamesByCallId.set(part.callID, part.tool); | |
| 2495 | } else { | |
| 2496 | turn.parts.set(part.id, part); | |
| 2497 | const ids = turn.partIdsByMessage.get(part.messageID) ?? new Set<string>(); | |
| 2498 | ids.add(part.id); | |
| 2499 | turn.partIdsByMessage.set(part.messageID, ids); | |
| 2500 | } | |
| 2501 | switch (part.type) { | |
| 2502 | case "text": | |
| 2503 | case "reasoning": | |
| 2504 | yield* emitTextPart(state, turn, part); | |
| 2505 | return; | |
| 2506 | case "tool": | |
| 2507 | yield* emitToolPart(state, turn, part); | |
| 2508 | return; | |
| 2509 | default: | |
| 2510 | return; | |
| 2511 | } | |
| 2512 | }); | |
| 2513 | ||
| 2514 | const handlePartDelta = Effect.fnUntraced(function* ( | |
| 2515 | event: Extract<OpenCodeEvent, { type: "message.part.delta" }>, | |
| 2516 | ) { | |
| 2517 | if (event.properties.field !== "text") return; | |
| 2518 | const state = threads.get(event.properties.sessionID); | |
| 2519 | const turn = state?.activeTurn; | |
| 2520 | const current = turn?.parts.get(event.properties.partID); | |
| 2521 | if ( | |
| 2522 | state === undefined || | |
| 2523 | turn === null || | |
| 2524 | turn === undefined || | |
| 2525 | current === undefined || | |
| 2526 | (current.type !== "text" && current.type !== "reasoning") | |
| 2527 | ) { | |
| 2528 | return; | |
| 2529 | } | |
| 2530 | const updated = { ...current, text: current.text + event.properties.delta }; | |
| 2531 | turn.parts.set(updated.id, updated); | |
| 2532 | yield* emitTextPart(state, turn, updated); | |
| 2533 | }); | |
| 2534 | ||
| 2535 | const handleAssistantCompleted = Effect.fnUntraced(function* ( | |
| 2536 | event: Extract<OpenCodeEvent, { type: "message.updated" }>, | |
| 2537 | ) { | |
| 2538 | const message = event.properties.info; | |
| 2539 | if (message.role !== "assistant" || message.time.completed === undefined) return; | |
| 2540 | const state = threads.get(message.sessionID); | |
| 2541 | const turn = state?.activeTurn; | |
| 2542 | if (state === undefined || turn === null || turn === undefined) return; | |
| 2543 | // Some OpenCode versions ignore the client-provided message ID. A | |
| 2544 | // completed assistant message is definitive admission evidence, so | |
| 2545 | // let the following idle event settle the turn without weakening | |
| 2546 | // the stale-user-message guard. | |
| 2547 | advanceOpenCodePromptAdmission(turn, "assistant-completed"); | |
| 2548 | for (const partId of turn.partIdsByMessage.get(message.id) ?? []) { | |
| 2549 | const part = turn.parts.get(partId); | |
| 2550 | if (part?.type === "text" || part?.type === "reasoning") { | |
| 2551 | yield* emitTextPart(state, turn, part, true); | |
| 2552 | } | |
| 2553 | } | |
| 2554 | }); | |
| 2555 | ||
| 2556 | const handleEvent = Effect.fnUntraced(function* (event: OpenCodeEvent) { | |
| 2557 | yield* logProtocolEvent({ | |
| 2558 | direction: "incoming", | |
| 2559 | messageKind: "notification", | |
| 2560 | method: event.type, | |
| 2561 | payload: event, | |
| 2562 | }); | |
| 2563 | switch (event.type) { | |
| 2564 | case "server.connected": | |
| 2565 | if (hasConnected) { | |
| 2566 | for (const state of threads.values()) { | |
| 2567 | if (state.activeTurn !== null) state.activeTurn.usage.complete = false; | |
| 2568 | } | |
| 2569 | } | |
| 2570 | hasConnected = true; | |
| 2571 | return; | |
| 2572 | case "message.updated": | |
| 2573 | yield* handleMessageUpdated(event); | |
| 2574 | yield* handleAssistantCompleted(event); | |
| 2575 | return; | |
| 2576 | case "session.compacted": { | |
| 2577 | const state = threads.get(event.properties.sessionID); | |
| 2578 | if (state?.activeTurn !== undefined && state.activeTurn !== null) { | |
| 2579 | yield* emitCompactionItem(state, state.activeTurn); | |
| 2580 | } | |
| 2581 | return; | |
| 2582 | } | |
| 2583 | case "message.part.updated": | |
| 2584 | yield* handlePartUpdated(event); | |
| 2585 | return; | |
| 2586 | case "message.part.delta": | |
| 2587 | yield* handlePartDelta(event); | |
| 2588 | return; | |
| 2589 | case "todo.updated": { | |
| 2590 | const state = threads.get(event.properties.sessionID); | |
| 2591 | if (state?.activeTurn !== null && state?.activeTurn !== undefined) { | |
| 2592 | yield* emitTodo(state, state.activeTurn, event.properties.todos); | |
| 2593 | } | |
| 2594 | return; | |
| 2595 | } | |
| 2596 | case "permission.asked": | |
| 2597 | yield* routeRuntimeRequest(event.properties.id, event.properties.sessionID, { | |
| 2598 | type: "permission", | |
| 2599 | value: event.properties, | |
| 2600 | }); | |
| 2601 | return; | |
| 2602 | case "question.asked": | |
| 2603 | yield* routeRuntimeRequest(event.properties.id, event.properties.sessionID, { | |
| 2604 | type: "question", | |
| 2605 | value: event.properties, | |
| 2606 | }); | |
| 2607 | return; | |
| 2608 | case "permission.replied": | |
| 2609 | case "question.replied": | |
| 2610 | rememberSettledRequest(event.properties.requestID); | |
| 2611 | yield* resolveRuntimeRequest(event.properties.requestID, "resolved"); | |
| 2612 | return; | |
| 2613 | case "question.rejected": | |
| 2614 | rememberSettledRequest(event.properties.requestID); | |
| 2615 | yield* resolveRuntimeRequest(event.properties.requestID, "cancelled"); | |
| 2616 | return; | |
| 2617 | case "session.created": | |
| 2618 | case "session.updated": { | |
| 2619 | const info = event.properties.info; | |
| 2620 | if (info.parentID !== undefined && !threads.has(info.id)) { | |
| 2621 | const owner = threads.get(info.parentID) ?? relatedSessionOwners.get(info.parentID); | |
| 2622 | if (owner !== undefined) { | |
| 2623 | relatedSessionOwners.set(info.id, owner); | |
| 2624 | } | |
| 2625 | } | |
| 2626 | return; | |
| 2627 | } | |
| 2628 | case "session.deleted": | |
| 2629 | relatedSessionOwners.delete(event.properties.info.id); | |
| 2630 | busySessionIds.delete(event.properties.info.id); | |
| 2631 | return; | |
| 2632 | case "session.status": { | |
| 2633 | const sessionId = event.properties.sessionID; | |
| 2634 | switch (event.properties.status.type) { | |
| 2635 | case "busy": | |
| 2636 | case "retry": | |
| 2637 | if (threads.has(sessionId) || relatedSessionOwners.has(sessionId)) { | |
| 2638 | busySessionIds.add(sessionId); | |
| 2639 | } | |
| 2640 | break; | |
| 2641 | case "idle": | |
| 2642 | busySessionIds.delete(sessionId); | |
| 2643 | break; | |
| 2644 | } | |
| 2645 | const state = threads.get(sessionId); | |
| 2646 | if (state === undefined) return; | |
| 2647 | if (event.properties.status.type === "busy") { | |
| 2648 | yield* updateProviderThread(state, { status: "active" }); | |
| 2649 | return; | |
| 2650 | } | |
| 2651 | if (event.properties.status.type === "idle" && state.activeTurn !== null) { | |
| 2652 | if (state.activeTurn.admissionPending) { | |
| 2653 | advanceOpenCodePromptAdmission(state.activeTurn, "idle"); | |
| 2654 | return; | |
| 2655 | } | |
| 2656 | yield* finalizeTurn( | |
| 2657 | state, | |
| 2658 | state.activeTurn, | |
| 2659 | state.activeTurn.interrupted ? "interrupted" : "completed", | |
| 2660 | ); | |
| 2661 | } | |
| 2662 | return; | |
| 2663 | } | |
| 2664 | case "session.idle": { | |
| 2665 | busySessionIds.delete(event.properties.sessionID); | |
| 2666 | const state = threads.get(event.properties.sessionID); | |
| 2667 | if (state?.activeTurn !== null && state?.activeTurn !== undefined) { | |
| 2668 | if (state.activeTurn.admissionPending) { | |
| 2669 | advanceOpenCodePromptAdmission(state.activeTurn, "idle"); | |
| 2670 | return; | |
| 2671 | } | |
| 2672 | yield* finalizeTurn( | |
| 2673 | state, | |
| 2674 | state.activeTurn, | |
| 2675 | state.activeTurn.interrupted ? "interrupted" : "completed", | |
| 2676 | ); | |
| 2677 | } | |
| 2678 | return; | |
| 2679 | } | |
| 2680 | case "session.error": { | |
| 2681 | const states = | |
| 2682 | event.properties.sessionID === undefined | |
| 2683 | ? Array.from(threads.values()).filter((state) => state.activeTurn !== null) | |
| 2684 | : [threads.get(event.properties.sessionID)].filter( | |
| 2685 | (state): state is OpenCodeThreadState => state !== undefined, | |
| 2686 | ); | |
| 2687 | const message = openCodeErrorMessage(event); | |
| 2688 | if ( | |
| 2689 | !isMessageAbortedError(event) && | |
| 2690 | (event.properties.sessionID === undefined || | |
| 2691 | states.some((state) => state.parentSubagent === null)) | |
| 2692 | ) { | |
| 2693 | yield* updateProviderSession("error", message); | |
| 2694 | } | |
| 2695 | for (const state of states) { | |
| 2696 | if (state.activeTurn !== null) { | |
| 2697 | yield* finalizeTurn( | |
| 2698 | state, | |
| 2699 | state.activeTurn, | |
| 2700 | terminalStatusForError(event, state.activeTurn), | |
| 2701 | { | |
| 2702 | failure: makeProviderFailure({ | |
| 2703 | message, | |
| 2704 | code: event.properties.error?.name ?? null, | |
| 2705 | class: "provider_error", | |
| 2706 | }), | |
| 2707 | threadDisposition: | |
| 2708 | event.properties.sessionID === undefined ? "broken" : "reusable", | |
| 2709 | }, | |
| 2710 | ); | |
| 2711 | } | |
| 2712 | } | |
| 2713 | return; | |
| 2714 | } | |
| 2715 | default: | |
| 2716 | return; | |
| 2717 | } | |
| 2718 | }); | |
| 2719 | ||
| 2720 | const inputProviderSessionId = input.providerSessionId; | |
| 2721 | ||
| 2722 | const subscription = yield* sdkCall("event.subscribe", {}, () => | |
| 2723 | client.event.subscribe(undefined, { signal: abortController.signal }), | |
| 2724 | ); | |
| 2725 | yield* Scope.addFinalizer( | |
| 2726 | scope, | |
| 2727 | Effect.sync(() => abortController.abort()), | |
| 2728 | ); | |
| 2729 | yield* Stream.fromAsyncIterable( | |
| 2730 | subscription.stream, | |
| 2731 | (cause) => | |
| 2732 | new OpenCodeRuntime.OpenCodeRuntimeError({ | |
| 2733 | operation: "event.subscribe", | |
| 2734 | detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause), | |
| 2735 | cause, | |
| 2736 | }), | |
| 2737 | ).pipe( | |
| 2738 | Stream.runForEach(handleEvent), | |
| 2739 | Effect.exit, | |
| 2740 | Effect.flatMap((exit) => | |
| 2741 | Effect.gen(function* () { | |
| 2742 | // No further status can clear a busy session once the stream ends. | |
| 2743 | busySessionIds.clear(); | |
| 2744 | if (closing || abortController.signal.aborted) return; | |
| 2745 | const detail = Exit.isSuccess(exit) | |
| 2746 | ? "OpenCode event stream ended unexpectedly." | |
| 2747 | : OpenCodeRuntime.openCodeRuntimeErrorDetail(Cause.squash(exit.cause)); | |
| 2748 | nativeStreamFailure = makeProviderFailure({ | |
| 2749 | message: detail, | |
| 2750 | class: "transport_error", | |
| 2751 | }); | |
| 2752 | yield* updateProviderSession("error", detail); | |
| 2753 | for (const state of threads.values()) { | |
| 2754 | if (state.activeTurn !== null) | |
| 2755 | yield* finalizeTurn(state, state.activeTurn, "failed", { | |
| 2756 | failure: makeProviderFailure({ message: detail, class: "transport_error" }), | |
| 2757 | threadDisposition: "broken", | |
| 2758 | }); | |
| 2759 | } | |
| 2760 | yield* Queue.end(events); | |
| 2761 | }), | |
| 2762 | ), | |
| 2763 | Effect.forkIn(scope), | |
| 2764 | ); | |
| 2765 | ||
| 2766 | if (!connection.external && connection.exitCode !== null) { | |
| 2767 | yield* connection.exitCode.pipe( | |
| 2768 | Effect.flatMap((code) => | |
| 2769 | abortController.signal.aborted | |
| 2770 | ? Effect.void | |
| 2771 | : Effect.gen(function* () { | |
| 2772 | const detail = `OpenCode server exited unexpectedly (${code}).`; | |
| 2773 | yield* updateProviderSession("error", detail); | |
| 2774 | for (const state of threads.values()) { | |
| 2775 | if (state.activeTurn !== null) { | |
| 2776 | yield* finalizeTurn(state, state.activeTurn, "failed", { | |
| 2777 | failure: makeProviderFailure({ | |
| 2778 | message: detail, | |
| 2779 | class: "transport_error", | |
| 2780 | }), | |
| 2781 | threadDisposition: "broken", | |
| 2782 | }); | |
| 2783 | } | |
| 2784 | } | |
| 2785 | }), | |
| 2786 | ), | |
| 2787 | Effect.forkIn(scope), | |
| 2788 | ); | |
| 2789 | } | |
| 2790 | ||
| 2791 | if (connection.external) { | |
| 2792 | yield* Scope.addFinalizer( | |
| 2793 | scope, | |
| 2794 | Effect.suspend(() => | |
| 2795 | Effect.forEach( | |
| 2796 | Array.from(threads.values()).filter((state) => state.parentSubagent === null), | |
| 2797 | (state) => | |
| 2798 | sdkCall("session.abort", { sessionID: state.nativeSessionId }, (signal) => | |
| 2799 | client.session.abort({ sessionID: state.nativeSessionId }, { signal }), | |
| 2800 | ).pipe( | |
| 2801 | Effect.timeout("1 second"), | |
| 2802 | Effect.ignore({ log: true }), | |
| 2803 | Effect.andThen( | |
| 2804 | abortDescendants(state.nativeSessionId).pipe( | |
| 2805 | Effect.timeout("1 second"), | |
| 2806 | Effect.ignore({ log: true }), | |
| 2807 | ), | |
| 2808 | ), | |
| 2809 | ), | |
| 2810 | { concurrency: 8, discard: true }, | |
| 2811 | ), | |
| 2812 | ), | |
| 2813 | ); | |
| 2814 | } | |
| 2815 | ||
| 2816 | yield* Scope.addFinalizer( | |
| 2817 | scope, | |
| 2818 | Effect.sync(() => { | |
| 2819 | closing = true; | |
| 2820 | }), | |
| 2821 | ); | |
| 2822 | ||
| 2823 | const registerThread = ( | |
| 2824 | nativeSession: OpenCodeSession, | |
| 2825 | providerThread: OrchestrationV2ProviderThread, | |
| 2826 | appThread: OrchestrationV2AppThread | null, | |
| 2827 | ): OpenCodeThreadState => { | |
| 2828 | const existing = threads.get(nativeSession.id); | |
| 2829 | if (existing !== undefined) { | |
| 2830 | existing.providerThread = providerThread; | |
| 2831 | if (appThread !== null) existing.appThread = appThread; | |
| 2832 | return existing; | |
| 2833 | } | |
| 2834 | const state: OpenCodeThreadState = { | |
| 2835 | nativeSessionId: nativeSession.id, | |
| 2836 | providerThread, | |
| 2837 | appThread, | |
| 2838 | activeTurn: null, | |
| 2839 | providerTurns: new Map(), | |
| 2840 | messages: new Map(), | |
| 2841 | runtimeRequests: new Map(), | |
| 2842 | messageRoles: new Map(), | |
| 2843 | userMessageIds: [], | |
| 2844 | parentSubagent: subagentsByChildSessionId.get(nativeSession.id) ?? null, | |
| 2845 | nextChildTurnOrdinal: 1, | |
| 2846 | nextAdmissionGeneration: 1, | |
| 2847 | }; | |
| 2848 | threads.set(nativeSession.id, state); | |
| 2849 | return state; | |
| 2850 | }; | |
| 2851 | ||
| 2852 | const resolvePromptParts = (turnInput: ProviderAdapter.ProviderAdapterV2TurnInput) => { | |
| 2853 | const text = providerMessageTextWithAttachmentPaths({ | |
| 2854 | text: turnInput.message.text, | |
| 2855 | attachments: turnInput.message.attachments, | |
| 2856 | resolveAttachmentPath: (attachment) => | |
| 2857 | resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }), | |
| 2858 | }).trim(); | |
| 2859 | const files = OpenCodeRuntime.toOpenCodeFileParts({ | |
| 2860 | attachments: turnInput.message.attachments, | |
| 2861 | resolveAttachmentPath: (attachment) => | |
| 2862 | resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }), | |
| 2863 | }); | |
| 2864 | if (text.length === 0 && files.length === 0) { | |
| 2865 | throw protocolError("OpenCode turns require text or at least one valid attachment"); | |
| 2866 | } | |
| 2867 | return [...(text.length === 0 ? [] : [{ type: "text" as const, text }]), ...files]; | |
| 2868 | }; | |
| 2869 | ||
| 2870 | const readSnapshot = Effect.fnUntraced(function* ( | |
| 2871 | providerThread: OrchestrationV2ProviderThread, | |
| 2872 | ) { | |
| 2873 | const sessionId = nativeThreadId(providerThread); | |
| 2874 | const sessionResponse = yield* sdkCall("session.get", { sessionID: sessionId }, () => | |
| 2875 | client.session.get({ sessionID: sessionId }), | |
| 2876 | ); | |
| 2877 | const nativeSession = unwrapData("session.get", sessionResponse); | |
| 2878 | const response = yield* sdkCall("session.messages", { sessionID: sessionId }, () => | |
| 2879 | client.session.messages({ sessionID: sessionId }), | |
| 2880 | ); | |
| 2881 | const nativeMessages = []; | |
| 2882 | for (const entry of unwrapData("session.messages", response)) { | |
| 2883 | // OpenCode retains reverted records in session.messages. Its | |
| 2884 | // revert marker identifies the first removed message, so stop | |
| 2885 | // before it or a restart resurrects rolled-back assistant work. | |
| 2886 | if (entry.info.id === nativeSession.revert?.messageID) break; | |
| 2887 | nativeMessages.push(entry); | |
| 2888 | } | |
| 2889 | const state = threads.get(sessionId); | |
| 2890 | const snapshotNow = yield* DateTime.now; | |
| 2891 | const messages: Array<OrchestrationV2ConversationMessage> = nativeMessages.flatMap( | |
| 2892 | ({ info, parts }) => { | |
| 2893 | const text = parts | |
| 2894 | .filter( | |
| 2895 | (part): part is Extract<OpenCodePart, { type: "text" }> => part.type === "text", | |
| 2896 | ) | |
| 2897 | .filter((part) => part.ignored !== true && part.synthetic !== true) | |
| 2898 | .map((part) => part.text) | |
| 2899 | .join("\n"); | |
| 2900 | if (text.length === 0) return []; | |
| 2901 | const createdAt = dateTimeFromEpoch(info.time.created, snapshotNow); | |
| 2902 | return [ | |
| 2903 | { | |
| 2904 | createdBy: info.role === "user" ? "user" : "agent", | |
| 2905 | creationSource: "provider", | |
| 2906 | id: idAllocator.derive.messageFromProviderItem({ | |
| 2907 | driver: OPENCODE_PROVIDER, | |
| 2908 | nativeItemId: info.id, | |
| 2909 | }), | |
| 2910 | threadId: providerThread.appThreadId ?? input.threadId, | |
| 2911 | runId: null, | |
| 2912 | nodeId: null, | |
| 2913 | role: info.role, | |
| 2914 | text, | |
| 2915 | attachments: [], | |
| 2916 | streaming: false, | |
| 2917 | createdAt, | |
| 2918 | updatedAt: | |
| 2919 | info.role === "assistant" | |
| 2920 | ? dateTimeFromEpoch(info.time.completed, createdAt) | |
| 2921 | : createdAt, | |
| 2922 | }, | |
| 2923 | ]; | |
| 2924 | }, | |
| 2925 | ); | |
| 2926 | const lastUser = nativeMessages.findLast(({ info }) => info.role === "user")?.info.id; | |
| 2927 | return { | |
| 2928 | providerThread: { | |
| 2929 | ...providerThread, | |
| 2930 | providerSessionId: input.providerSessionId, | |
| 2931 | nativeConversationHeadRef: | |
| 2932 | lastUser === undefined ? null : providerRef(lastUser, "weak"), | |
| 2933 | status: "idle" as const, | |
| 2934 | updatedAt: snapshotNow, | |
| 2935 | }, | |
| 2936 | providerTurns: state === undefined ? [] : [...state.providerTurns.values()], | |
| 2937 | messages, | |
| 2938 | runtimeRequests: state === undefined ? [] : [...state.runtimeRequests.values()], | |
| 2939 | providerPayload: nativeMessages, | |
| 2940 | }; | |
| 2941 | }); | |
| 2942 | ||
| 2943 | // Native commands wait for generation, unlike promptAsync. A user-message | |
| 2944 | // receipt admits the run while the scoped request continues in the background. | |
| 2945 | const submitPrompt = Effect.fn("OpenCode.submitPrompt")(function* ( | |
| 2946 | state: OpenCodeThreadState, | |
| 2947 | turn: ActiveOpenCodeTurn, | |
| 2948 | payload: Parameters<typeof client.session.promptAsync>[0] & { | |
| 2949 | messageID: string; | |
| 2950 | sessionID: string; | |
| 2951 | }, | |
| 2952 | abortController: AbortController, | |
| 2953 | ) { | |
| 2954 | const text = payload.parts | |
| 2955 | ?.filter((part) => part.type === "text") | |
| 2956 | .map((part) => part.text) | |
| 2957 | .join("\n") | |
| 2958 | .trim(); | |
| 2959 | const match = text?.match(/^\/([^\s/]+)(?:\s+([\s\S]*))?$/); | |
| 2960 | const command = match | |
| 2961 | ? (yield* OpenCodeRuntime.loadOpenCodeCommands(client).pipe( | |
| 2962 | Effect.timeout("10 seconds"), | |
| 2963 | Effect.orElseSucceed(() => []), | |
| 2964 | )).find((entry) => entry.name === match[1]) | |
| 2965 | : undefined; | |
| 2966 | if (!command) { | |
| 2967 | return yield* sdkCall("session.promptAsync", payload, (signal) => | |
| 2968 | client.session.promptAsync(payload, { | |
| 2969 | signal: AbortSignal.any([signal, abortController.signal]), | |
| 2970 | }), | |
| 2971 | ).pipe(Effect.asVoid); | |
| 2972 | } | |
| 2973 | const receipt = Deferred.makeUnsafe<void>(); | |
| 2974 | commandReceipts.set(payload.messageID, receipt); | |
| 2975 | const controllers = | |
| 2976 | commandControllers.get(payload.sessionID) ?? new Set<AbortController>(); | |
| 2977 | controllers.add(abortController); | |
| 2978 | commandControllers.set(payload.sessionID, controllers); | |
| 2979 | const generation = turn.admissionGeneration; | |
| 2980 | const commandPayload = { | |
| 2981 | sessionID: payload.sessionID, | |
| 2982 | messageID: payload.messageID, | |
| 2983 | command: command.name, | |
| 2984 | arguments: match?.[2] ?? "", | |
| 2985 | ...(payload.model | |
| 2986 | ? { model: `${payload.model.providerID}/${payload.model.modelID}` } | |
| 2987 | : {}), | |
| 2988 | ...(payload.agent ? { agent: payload.agent } : {}), | |
| 2989 | ...(payload.variant ? { variant: payload.variant } : {}), | |
| 2990 | parts: payload.parts?.filter((part) => part.type === "file") ?? [], | |
| 2991 | }; | |
| 2992 | const request = yield* sdkCall("session.command", commandPayload, (signal) => | |
| 2993 | client.session.command(commandPayload, { | |
| 2994 | signal: AbortSignal.any([signal, abortController.signal]), | |
| 2995 | }), | |
| 2996 | ).pipe( | |
| 2997 | Effect.asVoid, | |
| 2998 | Effect.tapError((cause) => | |
| 2999 | abortController.signal.aborted || | |
| 3000 | turn.finalized || | |
| 3001 | turn.admissionGeneration !== generation | |
| 3002 | ? Effect.void | |
| 3003 | : finalizeTurn(state, turn, "failed", { | |
| 3004 | failure: makeProviderFailure({ cause, class: "provider_error" }), | |
| 3005 | }), | |
| 3006 | ), | |
| 3007 | Effect.ensuring( | |
| 3008 | Effect.sync(() => { | |
| 3009 | commandReceipts.delete(payload.messageID); | |
| 3010 | controllers.delete(abortController); | |
| 3011 | if (controllers.size === 0) commandControllers.delete(payload.sessionID); | |
| 3012 | }), | |
| 3013 | ), | |
| 3014 | Effect.forkIn(scope), | |
| 3015 | ); | |
| 3016 | yield* Effect.raceFirst(Fiber.join(request), Deferred.await(receipt)).pipe( | |
| 3017 | Effect.timeout("10 seconds"), | |
| 3018 | Effect.catchTags({ | |
| 3019 | TimeoutError: (cause) => { | |
| 3020 | const error = new OpenCodeRuntime.OpenCodeRuntimeError({ | |
| 3021 | operation: "session.command", | |
| 3022 | detail: "OpenCode command admission did not complete within 10 seconds.", | |
| 3023 | cause, | |
| 3024 | }); | |
| 3025 | abortController.abort(); | |
| 3026 | return finalizeTurn(state, turn, "failed", { | |
| 3027 | failure: makeProviderFailure({ cause: error, class: "provider_error" }), | |
| 3028 | }).pipe(Effect.andThen(Effect.fail(error))); | |
| 3029 | }, | |
| 3030 | }), | |
| 3031 | ); | |
| 3032 | }); | |
| 3033 | ||
| 3034 | const runtimeSession: ProviderAdapter.ProviderAdapterV2SessionRuntime = { | |
| 3035 | instanceId: options.instanceId, | |
| 3036 | driver: OPENCODE_PROVIDER, | |
| 3037 | providerSessionId: input.providerSessionId, | |
| 3038 | providerSession: sessionEntity, | |
| 3039 | events: Stream.fromEffectRepeat(Queue.take(events)), | |
| 3040 | hasPendingBackgroundWork: Effect.sync(() => busySessionIds.size > 0), | |
| 3041 | ensureThread: (threadInput) => | |
| 3042 | Effect.gen(function* () { | |
| 3043 | // Only a row that already carries a native session can be | |
| 3044 | // resumed; a placeholder without one still needs session.create. | |
| 3045 | if (threadInput.existingProviderThread?.nativeThreadRef != null) { | |
| 3046 | return yield* runtimeSession.resumeThread({ | |
| 3047 | providerThread: threadInput.existingProviderThread, | |
| 3048 | }); | |
| 3049 | } | |
| 3050 | // No title: OpenCode generates one from the first prompt only when | |
| 3051 | // session.create leaves it unset (SessionPrompt.ensureTitle). | |
| 3052 | const response = yield* sdkCall( | |
| 3053 | "session.create", | |
| 3054 | { permission: openCodePermissionRules(threadInput.runtimePolicy) }, | |
| 3055 | () => | |
| 3056 | client.session.create({ | |
| 3057 | permission: openCodePermissionRules(threadInput.runtimePolicy), | |
| 3058 | }), | |
| 3059 | ); | |
| 3060 | const nativeSession = unwrapData("session.create", response); | |
| 3061 | const createdAt = yield* DateTime.now; | |
| 3062 | const created = makeProviderThread({ | |
| 3063 | idAllocator, | |
| 3064 | providerInstanceId: options.instanceId, | |
| 3065 | providerSessionId: input.providerSessionId, | |
| 3066 | appThreadId: threadInput.threadId, | |
| 3067 | nativeSession, | |
| 3068 | now: createdAt, | |
| 3069 | }); | |
| 3070 | const existing = threadInput.existingProviderThread; | |
| 3071 | // Bind the new native session to the caller's row when one was | |
| 3072 | // handed over: a second live row per app thread would make | |
| 3073 | // `activeProviderThreadId` flap between the two on every update. | |
| 3074 | const providerThread = | |
| 3075 | existing === undefined | |
| 3076 | ? created | |
| 3077 | : { | |
| 3078 | ...existing, | |
| 3079 | providerSessionId: input.providerSessionId, | |
| 3080 | nativeThreadRef: created.nativeThreadRef, | |
| 3081 | nativeConversationHeadRef: created.nativeConversationHeadRef, | |
| 3082 | status: created.status, | |
| 3083 | updatedAt: created.updatedAt, | |
| 3084 | }; | |
| 3085 | registerThread(nativeSession, providerThread, null); | |
| 3086 | return providerThread; | |
| 3087 | }).pipe( | |
| 3088 | Effect.mapError( | |
| 3089 | (cause) => | |
| 3090 | new ProviderAdapter.ProviderAdapterEnsureThreadError({ | |
| 3091 | driver: OPENCODE_PROVIDER, | |
| 3092 | threadId: threadInput.threadId, | |
| 3093 | cause, | |
| 3094 | }), | |
| 3095 | ), | |
| 3096 | ), | |
| 3097 | resumeThread: (threadInput) => | |
| 3098 | Effect.gen(function* () { | |
| 3099 | const sessionId = nativeThreadId(threadInput.providerThread); | |
| 3100 | const response = yield* sdkCall("session.get", { sessionID: sessionId }, () => | |
| 3101 | client.session.get({ sessionID: sessionId }), | |
| 3102 | ); | |
| 3103 | const nativeSession = unwrapData("session.get", response); | |
| 3104 | const resumedAt = yield* DateTime.now; | |
| 3105 | const providerThread = { | |
| 3106 | ...threadInput.providerThread, | |
| 3107 | providerSessionId: input.providerSessionId, | |
| 3108 | status: "idle" as const, | |
| 3109 | updatedAt: dateTimeFromEpoch(nativeSession.time.updated, resumedAt), | |
| 3110 | }; | |
| 3111 | registerThread(nativeSession, providerThread, null); | |
| 3112 | return providerThread; | |
| 3113 | }).pipe( | |
| 3114 | Effect.mapError( | |
| 3115 | (cause) => | |
| 3116 | new ProviderAdapter.ProviderAdapterResumeThreadError({ | |
| 3117 | driver: OPENCODE_PROVIDER, | |
| 3118 | providerSessionId: input.providerSessionId, | |
| 3119 | providerThreadId: threadInput.providerThread.id, | |
| 3120 | cause, | |
| 3121 | }), | |
| 3122 | ), | |
| 3123 | ), | |
| 3124 | compactThread: (turnInput) => | |
| 3125 | runtimeSession.startTurn({ | |
| 3126 | ...turnInput, | |
| 3127 | message: { ...turnInput.message, text: "/compact" }, | |
| 3128 | }), | |
| 3129 | startTurn: (turnInput) => | |
| 3130 | Effect.gen(function* () { | |
| 3131 | if (nativeStreamFailure !== null) { | |
| 3132 | return yield* protocolError( | |
| 3133 | "OpenCode event stream has ended; reconnect the provider session before starting another turn.", | |
| 3134 | ); | |
| 3135 | } | |
| 3136 | const sessionId = nativeThreadId(turnInput.providerThread); | |
| 3137 | const state = threads.get(sessionId); | |
| 3138 | if (state === undefined) { | |
| 3139 | return yield* protocolError(`OpenCode session ${sessionId} is not registered`); | |
| 3140 | } | |
| 3141 | if (state.activeTurn !== null) { | |
| 3142 | return yield* protocolError( | |
| 3143 | `OpenCode provider thread ${turnInput.providerThread.id} already has an active turn`, | |
| 3144 | ); | |
| 3145 | } | |
| 3146 | const parsedModel = OpenCodeRuntime.parseOpenCodeModelSlug( | |
| 3147 | turnInput.modelSelection.model, | |
| 3148 | ); | |
| 3149 | if (parsedModel === null) { | |
| 3150 | return yield* protocolError( | |
| 3151 | `OpenCode model '${turnInput.modelSelection.model}' must use provider/model format`, | |
| 3152 | ); | |
| 3153 | } | |
| 3154 | const isCompaction = | |
| 3155 | turnInput.message.text.trim() === "/compact" && | |
| 3156 | turnInput.message.attachments.length === 0; | |
| 3157 | const parts = isCompaction ? [] : resolvePromptParts(turnInput); | |
| 3158 | const startedAt = yield* DateTime.now; | |
| 3159 | const syntheticNativeTurnId = `${sessionId}:attempt:${turnInput.attemptId}`; | |
| 3160 | const providerTurnId = idAllocator.derive.providerTurn({ | |
| 3161 | driver: OPENCODE_PROVIDER, | |
| 3162 | nativeTurnId: syntheticNativeTurnId, | |
| 3163 | }); | |
| 3164 | const providerTurn: OrchestrationV2ProviderTurn = { | |
| 3165 | id: providerTurnId, | |
| 3166 | providerThreadId: turnInput.providerThread.id, | |
| 3167 | nodeId: turnInput.rootNodeId, | |
| 3168 | runAttemptId: turnInput.attemptId, | |
| 3169 | nativeTurnRef: providerRef(syntheticNativeTurnId, "weak"), | |
| 3170 | ordinal: turnInput.providerTurnOrdinal, | |
| 3171 | status: "running", | |
| 3172 | startedAt, | |
| 3173 | completedAt: null, | |
| 3174 | }; | |
| 3175 | const admissionMessageId = yield* makeOpenCodeMessageId(); | |
| 3176 | // No Effect may be yielded between this check and installing the | |
| 3177 | // turn. If the event stream ended while IDs were being prepared, | |
| 3178 | // registering afterward would leave a running turn that the EOF | |
| 3179 | // handler had already finished scanning. | |
| 3180 | if (nativeStreamFailure !== null) { | |
| 3181 | return yield* protocolError( | |
| 3182 | "OpenCode event stream has ended; reconnect the provider session before starting another turn.", | |
| 3183 | ); | |
| 3184 | } | |
| 3185 | if (state.activeTurn !== null) { | |
| 3186 | return yield* protocolError( | |
| 3187 | `OpenCode provider thread ${turnInput.providerThread.id} already has an active turn`, | |
| 3188 | ); | |
| 3189 | } | |
| 3190 | const turn: ActiveOpenCodeTurn = { | |
| 3191 | isRoot: true, | |
| 3192 | threadId: turnInput.threadId, | |
| 3193 | runId: turnInput.runId, | |
| 3194 | rootNodeId: turnInput.rootNodeId, | |
| 3195 | appThread: turnInput.appThread, | |
| 3196 | modelSelection: turnInput.modelSelection, | |
| 3197 | runtimePolicy: turnInput.runtimePolicy, | |
| 3198 | providerTurnId, | |
| 3199 | providerThreadId: turnInput.providerThread.id, | |
| 3200 | providerTurnOrdinal: turnInput.providerTurnOrdinal, | |
| 3201 | runOrdinal: turnInput.runOrdinal, | |
| 3202 | runAttemptId: turnInput.attemptId, | |
| 3203 | startedAt, | |
| 3204 | itemOrdinals: new Map(), | |
| 3205 | usage: makeOpenCodeTurnTokenUsageAccumulator(), | |
| 3206 | parts: new Map(), | |
| 3207 | partIdsByMessage: new Map(), | |
| 3208 | toolNamesByCallId: new Map(), | |
| 3209 | providerTurn, | |
| 3210 | nextItemOrdinal: turnInput.providerTurnOrdinal * 100 + 1, | |
| 3211 | nativeUserMessageId: null, | |
| 3212 | admissionMessageId, | |
| 3213 | interrupted: false, | |
| 3214 | finalized: false, | |
| 3215 | planId: null, | |
| 3216 | admissionGeneration: state.nextAdmissionGeneration++, | |
| 3217 | admissionReconciliationGeneration: null, | |
| 3218 | admissionPending: !isCompaction, | |
| 3219 | admissionAccepted: isCompaction, | |
| 3220 | admissionMessageObserved: false, | |
| 3221 | idleDuringAdmission: false, | |
| 3222 | admissionSettled: Deferred.makeUnsafe<void>(), | |
| 3223 | admissionAbortController: new AbortController(), | |
| 3224 | }; | |
| 3225 | if (turn.admissionMessageId !== null) | |
| 3226 | turn.usage.promptMessageIds.add(turn.admissionMessageId); | |
| 3227 | const admissionSettled = turn.admissionSettled; | |
| 3228 | const admissionAbortController = turn.admissionAbortController; | |
| 3229 | state.appThread = turnInput.appThread; | |
| 3230 | state.activeTurn = turn; | |
| 3231 | state.providerTurns.set(String(providerTurnId), providerTurn); | |
| 3232 | yield* emitProviderTurn(state, turn, "running", null); | |
| 3233 | yield* updateProviderThread(state, { | |
| 3234 | status: "active", | |
| 3235 | firstRunOrdinal: state.providerThread.firstRunOrdinal ?? turnInput.runOrdinal, | |
| 3236 | lastRunOrdinal: turnInput.runOrdinal, | |
| 3237 | }); | |
| 3238 | yield* updateProviderSession("running", null); | |
| 3239 | if (isCompaction) { | |
| 3240 | yield* sdkCall( | |
| 3241 | "session.summarize", | |
| 3242 | { sessionID: sessionId, ...parsedModel, auto: false }, | |
| 3243 | (signal) => | |
| 3244 | client.session.summarize( | |
| 3245 | { sessionID: sessionId, ...parsedModel, auto: false }, | |
| 3246 | { signal: AbortSignal.any([signal, admissionAbortController!.signal]) }, | |
| 3247 | ), | |
| 3248 | ).pipe( | |
| 3249 | Effect.tap(() => | |
| 3250 | turn.interrupted || turn.finalized || nativeStreamFailure !== null | |
| 3251 | ? Effect.void | |
| 3252 | : emitCompactionItem(state, turn), | |
| 3253 | ), | |
| 3254 | Effect.tap(() => | |
| 3255 | finalizeTurn(state, turn, turn.interrupted ? "interrupted" : "completed"), | |
| 3256 | ), | |
| 3257 | Effect.tapError((cause) => | |
| 3258 | finalizeTurn(state, turn, turn.interrupted ? "interrupted" : "failed", { | |
| 3259 | failure: makeProviderFailure({ cause, class: "provider_error" }), | |
| 3260 | }), | |
| 3261 | ), | |
| 3262 | Effect.ensuring(Deferred.succeed(admissionSettled, undefined)), | |
| 3263 | ); | |
| 3264 | return; | |
| 3265 | } | |
| 3266 | const systemPrompt = [ | |
| 3267 | orchestrationSystemPrompt, | |
| 3268 | buildRuntimeInstructions({ | |
| 3269 | harness: "OpenCode", | |
| 3270 | model: turnInput.modelSelection.model, | |
| 3271 | }), | |
| 3272 | ] | |
| 3273 | .filter(Boolean) | |
| 3274 | .join("\n\n"); | |
| 3275 | const agent = | |
| 3276 | getModelSelectionStringOptionValue(turnInput.modelSelection, "agent") ?? | |
| 3277 | (turnInput.runtimePolicy.interactionMode === "plan" ? "plan" : undefined); | |
| 3278 | const variant = getModelSelectionStringOptionValue( | |
| 3279 | turnInput.modelSelection, | |
| 3280 | "variant", | |
| 3281 | ); | |
| 3282 | yield* submitPrompt( | |
| 3283 | state, | |
| 3284 | turn, | |
| 3285 | { | |
| 3286 | sessionID: sessionId, | |
| 3287 | messageID: turn.admissionMessageId!, | |
| 3288 | model: parsedModel, | |
| 3289 | ...(agent === undefined ? {} : { agent }), | |
| 3290 | ...(variant === undefined ? {} : { variant }), | |
| 3291 | system: systemPrompt, | |
| 3292 | parts, | |
| 3293 | }, | |
| 3294 | admissionAbortController!, | |
| 3295 | ).pipe( | |
| 3296 | Effect.tapError((cause) => | |
| 3297 | admissionAbortController!.signal.aborted | |
| 3298 | ? Effect.void | |
| 3299 | : finalizeTurn(state, turn, "failed", { | |
| 3300 | failure: makeProviderFailure({ cause, class: "provider_error" }), | |
| 3301 | }), | |
| 3302 | ), | |
| 3303 | Effect.catch((cause) => | |
| 3304 | admissionAbortController!.signal.aborted ? Effect.void : Effect.fail(cause), | |
| 3305 | ), | |
| 3306 | Effect.ensuring( | |
| 3307 | Effect.all([ | |
| 3308 | Deferred.succeed(admissionSettled, undefined).pipe(Effect.ignore), | |
| 3309 | Effect.sync(() => { | |
| 3310 | if (turn.admissionAbortController === admissionAbortController) { | |
| 3311 | turn.admissionAbortController = null; | |
| 3312 | } | |
| 3313 | }), | |
| 3314 | ]).pipe(Effect.asVoid), | |
| 3315 | ), | |
| 3316 | ); | |
| 3317 | if (state.activeTurn === turn && !turn.finalized && !turn.interrupted) { | |
| 3318 | const admissionAction = advanceOpenCodePromptAdmission(turn, "accepted"); | |
| 3319 | if (admissionAction === "reconcile-idle") { | |
| 3320 | yield* reconcilePromptAdmission(state, turn); | |
| 3321 | } | |
| 3322 | } | |
| 3323 | }).pipe( | |
| 3324 | Effect.mapError( | |
| 3325 | (cause) => | |
| 3326 | new ProviderAdapter.ProviderAdapterTurnStartError({ | |
| 3327 | driver: OPENCODE_PROVIDER, | |
| 3328 | threadId: turnInput.threadId, | |
| 3329 | providerThreadId: turnInput.providerThread.id, | |
| 3330 | runId: turnInput.runId, | |
| 3331 | cause, | |
| 3332 | }), | |
| 3333 | ), | |
| 3334 | ), | |
| 3335 | steerTurn: (steerInput) => | |
| 3336 | Effect.gen(function* () { | |
| 3337 | const sessionId = nativeThreadId(steerInput.providerThread); | |
| 3338 | const state = threads.get(sessionId); | |
| 3339 | const turn = state?.activeTurn; | |
| 3340 | if ( | |
| 3341 | state === undefined || | |
| 3342 | turn === undefined || | |
| 3343 | turn === null || | |
| 3344 | turn.providerTurnId !== steerInput.providerTurnId || | |
| 3345 | turn.interrupted | |
| 3346 | ) { | |
| 3347 | return yield* protocolError( | |
| 3348 | `OpenCode turn ${steerInput.providerTurnId} is not active`, | |
| 3349 | ); | |
| 3350 | } | |
| 3351 | const parsedModel = OpenCodeRuntime.parseOpenCodeModelSlug(turn.modelSelection.model); | |
| 3352 | if (parsedModel === null) { | |
| 3353 | return yield* protocolError( | |
| 3354 | `OpenCode model '${turn.modelSelection.model}' must use provider/model format`, | |
| 3355 | ); | |
| 3356 | } | |
| 3357 | const text = providerMessageTextWithAttachmentPaths({ | |
| 3358 | text: steerInput.message.text, | |
| 3359 | attachments: steerInput.message.attachments, | |
| 3360 | resolveAttachmentPath: (attachment) => | |
| 3361 | resolveAttachmentPath({ | |
| 3362 | attachmentsDir: serverConfig.attachmentsDir, | |
| 3363 | attachment, | |
| 3364 | }), | |
| 3365 | }).trim(); | |
| 3366 | const files = OpenCodeRuntime.toOpenCodeFileParts({ | |
| 3367 | attachments: steerInput.message.attachments, | |
| 3368 | resolveAttachmentPath: (attachment) => | |
| 3369 | resolveAttachmentPath({ | |
| 3370 | attachmentsDir: serverConfig.attachmentsDir, | |
| 3371 | attachment, | |
| 3372 | }), | |
| 3373 | }); | |
| 3374 | if (text.length === 0 && files.length === 0) { | |
| 3375 | return yield* protocolError("OpenCode steering requires text or an attachment"); | |
| 3376 | } | |
| 3377 | const parts = [ | |
| 3378 | ...(text.length === 0 ? [] : [{ type: "text" as const, text }]), | |
| 3379 | ...files, | |
| 3380 | ]; | |
| 3381 | turn.admissionGeneration = state.nextAdmissionGeneration++; | |
| 3382 | turn.admissionMessageId = yield* makeOpenCodeMessageId(); | |
| 3383 | turn.admissionPending = true; | |
| 3384 | turn.admissionAccepted = false; | |
| 3385 | turn.admissionMessageObserved = false; | |
| 3386 | turn.idleDuringAdmission = false; | |
| 3387 | turn.admissionSettled = Deferred.makeUnsafe<void>(); | |
| 3388 | turn.admissionAbortController = new AbortController(); | |
| 3389 | if (turn.admissionMessageId !== null) | |
| 3390 | turn.usage.promptMessageIds.add(turn.admissionMessageId); | |
| 3391 | const admissionSettled = turn.admissionSettled; | |
| 3392 | const admissionAbortController = turn.admissionAbortController; | |
| 3393 | yield* submitPrompt( | |
| 3394 | state, | |
| 3395 | turn, | |
| 3396 | { | |
| 3397 | sessionID: sessionId, | |
| 3398 | messageID: turn.admissionMessageId, | |
| 3399 | model: parsedModel, | |
| 3400 | parts, | |
| 3401 | }, | |
| 3402 | admissionAbortController!, | |
| 3403 | ).pipe( | |
| 3404 | Effect.ensuring( | |
| 3405 | Effect.all([ | |
| 3406 | Deferred.succeed(admissionSettled, undefined).pipe(Effect.ignore), | |
| 3407 | Effect.sync(() => { | |
| 3408 | if (turn.admissionAbortController === admissionAbortController) { | |
| 3409 | turn.admissionAbortController = null; | |
| 3410 | } | |
| 3411 | }), | |
| 3412 | ]).pipe(Effect.asVoid), | |
| 3413 | ), | |
| 3414 | ); | |
| 3415 | if (state.activeTurn === turn && !turn.finalized) { | |
| 3416 | const admissionAction = advanceOpenCodePromptAdmission(turn, "accepted"); | |
| 3417 | if (admissionAction === "reconcile-idle") { | |
| 3418 | yield* reconcilePromptAdmission(state, turn); | |
| 3419 | } | |
| 3420 | } | |
| 3421 | }).pipe( | |
| 3422 | Effect.mapError( | |
| 3423 | (cause) => | |
| 3424 | new ProviderAdapter.ProviderAdapterSteerRunError({ | |
| 3425 | driver: OPENCODE_PROVIDER, | |
| 3426 | providerThreadId: steerInput.providerThread.id, | |
| 3427 | providerTurnId: steerInput.providerTurnId, | |
| 3428 | cause, | |
| 3429 | }), | |
| 3430 | ), | |
| 3431 | ), | |
| 3432 | interruptTurn: (interruptInput) => | |
| 3433 | Effect.gen(function* () { | |
| 3434 | const sessionId = nativeThreadId(interruptInput.providerThread); | |
| 3435 | const state = threads.get(sessionId); | |
| 3436 | const turn = state?.activeTurn; | |
| 3437 | // Stop on a settled turn: a background task child can outlive | |
| 3438 | // it (busySessionIds), and aborting the descendants ends it. | |
| 3439 | if ( | |
| 3440 | (turn === undefined || turn === null) && | |
| 3441 | interruptInput.requestRuntimeRestart === true | |
| 3442 | ) { | |
| 3443 | for (const controller of commandControllers.get(sessionId) ?? []) | |
| 3444 | controller.abort(); | |
| 3445 | yield* abortDescendants(sessionId); | |
| 3446 | return; | |
| 3447 | } | |
| 3448 | if ( | |
| 3449 | turn === undefined || | |
| 3450 | turn === null || | |
| 3451 | turn.providerTurnId !== interruptInput.providerTurnId | |
| 3452 | ) { | |
| 3453 | return yield* protocolError( | |
| 3454 | `OpenCode turn ${interruptInput.providerTurnId} is not active`, | |
| 3455 | ); | |
| 3456 | } | |
| 3457 | turn.interrupted = true; | |
| 3458 | for (const controller of commandControllers.get(sessionId) ?? []) controller.abort(); | |
| 3459 | const admissionWasPending = turn.admissionPending; | |
| 3460 | cancelOpenCodePromptAdmission(turn, state!.nextAdmissionGeneration++); | |
| 3461 | turn.admissionAbortController?.abort(); | |
| 3462 | if (admissionWasPending) { | |
| 3463 | // Settle the cancelled request before sending the session abort. | |
| 3464 | // A timed-out local request must not prevent the definitive | |
| 3465 | // server-side abort below from running. | |
| 3466 | yield* Deferred.await(turn.admissionSettled).pipe( | |
| 3467 | Effect.timeout("10 seconds"), | |
| 3468 | Effect.ignore, | |
| 3469 | ); | |
| 3470 | } | |
| 3471 | yield* sdkCall("session.abort", { sessionID: sessionId }, (signal) => | |
| 3472 | client.session.abort({ sessionID: sessionId }, { signal }), | |
| 3473 | ).pipe( | |
| 3474 | Effect.timeout("10 seconds"), | |
| 3475 | // The turn can settle while the abort is in flight, and | |
| 3476 | // aborting an already-idle session fails. That stop still | |
| 3477 | // succeeded; only surface failures for a turn that is | |
| 3478 | // genuinely still running. | |
| 3479 | Effect.catch((cause) => | |
| 3480 | turn.finalized | |
| 3481 | ? Effect.void | |
| 3482 | : Effect.suspend(() => { | |
| 3483 | turn.interrupted = false; | |
| 3484 | return Effect.fail(cause); | |
| 3485 | }), | |
| 3486 | ), | |
| 3487 | ); | |
| 3488 | // Root abort does not stop child sessions. Report incomplete cleanup | |
| 3489 | // even if the root has already emitted its terminal event. | |
| 3490 | yield* abortDescendants(sessionId); | |
| 3491 | }).pipe( | |
| 3492 | Effect.mapError( | |
| 3493 | (cause) => | |
| 3494 | new ProviderAdapter.ProviderAdapterInterruptError({ | |
| 3495 | driver: OPENCODE_PROVIDER, | |
| 3496 | providerThreadId: interruptInput.providerThread.id, | |
| 3497 | providerTurnId: interruptInput.providerTurnId, | |
| 3498 | cause, | |
| 3499 | }), | |
| 3500 | ), | |
| 3501 | ), | |
| 3502 | respondToRuntimeRequest: (requestInput) => | |
| 3503 | Effect.gen(function* () { | |
| 3504 | const pending = pendingRequests.get(String(requestInput.requestId)); | |
| 3505 | if (pending === undefined) { | |
| 3506 | return yield* protocolError( | |
| 3507 | `No pending OpenCode request ${requestInput.requestId}`, | |
| 3508 | ); | |
| 3509 | } | |
| 3510 | if (pending.question !== undefined) { | |
| 3511 | if (requestInput.answers === undefined) { | |
| 3512 | return yield* protocolError( | |
| 3513 | `OpenCode question request ${requestInput.requestId} requires answers`, | |
| 3514 | ); | |
| 3515 | } | |
| 3516 | const answers = OpenCodeRuntime.toOpenCodeQuestionAnswers( | |
| 3517 | pending.question, | |
| 3518 | requestInput.answers, | |
| 3519 | ); | |
| 3520 | yield* sdkCall( | |
| 3521 | "question.reply", | |
| 3522 | { requestID: pending.nativeRequestId, answers }, | |
| 3523 | (signal) => | |
| 3524 | client.question.reply( | |
| 3525 | { | |
| 3526 | requestID: pending.nativeRequestId, | |
| 3527 | answers, | |
| 3528 | }, | |
| 3529 | { signal }, | |
| 3530 | ), | |
| 3531 | ).pipe(Effect.timeout("10 seconds")); | |
| 3532 | return; | |
| 3533 | } | |
| 3534 | if (requestInput.decision === undefined) { | |
| 3535 | return yield* protocolError( | |
| 3536 | `OpenCode approval request ${requestInput.requestId} requires a decision`, | |
| 3537 | ); | |
| 3538 | } | |
| 3539 | const reply = OpenCodeRuntime.toOpenCodePermissionReply(requestInput.decision); | |
| 3540 | yield* sdkCall( | |
| 3541 | "permission.reply", | |
| 3542 | { requestID: pending.nativeRequestId, reply }, | |
| 3543 | (signal) => | |
| 3544 | client.permission.reply( | |
| 3545 | { | |
| 3546 | requestID: pending.nativeRequestId, | |
| 3547 | reply, | |
| 3548 | }, | |
| 3549 | { signal }, | |
| 3550 | ), | |
| 3551 | ).pipe(Effect.timeout("10 seconds")); | |
| 3552 | }).pipe( | |
| 3553 | Effect.mapError( | |
| 3554 | (cause) => | |
| 3555 | new ProviderAdapter.ProviderAdapterRuntimeRequestResponseError({ | |
| 3556 | driver: OPENCODE_PROVIDER, | |
| 3557 | requestId: requestInput.requestId, | |
| 3558 | cause, | |
| 3559 | }), | |
| 3560 | ), | |
| 3561 | ), | |
| 3562 | readThreadSnapshot: (snapshotInput) => | |
| 3563 | readSnapshot(snapshotInput.providerThread).pipe( | |
| 3564 | Effect.mapError( | |
| 3565 | (cause) => | |
| 3566 | new ProviderAdapter.ProviderAdapterReadThreadSnapshotError({ | |
| 3567 | driver: OPENCODE_PROVIDER, | |
| 3568 | providerThreadId: snapshotInput.providerThread.id, | |
| 3569 | cause, | |
| 3570 | }), | |
| 3571 | ), | |
| 3572 | ), | |
| 3573 | rollbackThread: (rollbackInput) => | |
| 3574 | Effect.gen(function* () { | |
| 3575 | const sessionId = nativeThreadId(rollbackInput.providerThread); | |
| 3576 | const state = threads.get(sessionId); | |
| 3577 | if (state?.activeTurn !== null && state?.activeTurn !== undefined) { | |
| 3578 | return yield* protocolError( | |
| 3579 | `Cannot roll back OpenCode thread ${rollbackInput.providerThread.id} while a turn is active`, | |
| 3580 | ); | |
| 3581 | } | |
| 3582 | const response = yield* sdkCall("session.messages", { sessionID: sessionId }, () => | |
| 3583 | client.session.messages({ sessionID: sessionId }), | |
| 3584 | ); | |
| 3585 | const messages = unwrapData("session.messages", response); | |
| 3586 | let boundaryMessageId: string | undefined; | |
| 3587 | if (rollbackInput.target.type === "thread_start") { | |
| 3588 | boundaryMessageId = messages.find(({ info }) => info.role === "user")?.info.id; | |
| 3589 | } else { | |
| 3590 | boundaryMessageId = openCodeBoundaryAfterProviderTurn( | |
| 3591 | rollbackInput.providerThreadTurns, | |
| 3592 | rollbackInput.target.providerTurn.id, | |
| 3593 | ); | |
| 3594 | } | |
| 3595 | let retainedThread = rollbackInput.providerThread; | |
| 3596 | if (boundaryMessageId !== undefined) { | |
| 3597 | const boundaryIndex = messages.findIndex( | |
| 3598 | ({ info }) => info.id === boundaryMessageId, | |
| 3599 | ); | |
| 3600 | if (boundaryIndex < 0) | |
| 3601 | return yield* protocolError( | |
| 3602 | "The OpenCode rewind boundary is no longer available.", | |
| 3603 | ); | |
| 3604 | const fork = unwrapData( | |
| 3605 | "session.fork", | |
| 3606 | yield* sdkCall( | |
| 3607 | "session.fork", | |
| 3608 | { sessionID: sessionId, messageID: boundaryMessageId }, | |
| 3609 | () => | |
| 3610 | client.session.fork({ sessionID: sessionId, messageID: boundaryMessageId }), | |
| 3611 | ), | |
| 3612 | ); | |
| 3613 | const retained = unwrapData( | |
| 3614 | "session.messages", | |
| 3615 | yield* sdkCall("session.messages", { sessionID: fork.id }, () => | |
| 3616 | client.session.messages({ sessionID: fork.id }), | |
| 3617 | ), | |
| 3618 | ); | |
| 3619 | if (retained.length !== boundaryIndex) | |
| 3620 | return yield* protocolError( | |
| 3621 | "OpenCode did not preserve the requested rewind boundary.", | |
| 3622 | ); | |
| 3623 | yield* sdkCall("session.update", { sessionID: fork.id }, () => | |
| 3624 | client.session.update({ | |
| 3625 | sessionID: fork.id, | |
| 3626 | permission: openCodePermissionRules(input.runtimePolicy), | |
| 3627 | }), | |
| 3628 | ); | |
| 3629 | retainedThread = { | |
| 3630 | ...rollbackInput.providerThread, | |
| 3631 | nativeThreadRef: providerRef(fork.id), | |
| 3632 | }; | |
| 3633 | registerThread(fork, retainedThread, state?.appThread ?? null); | |
| 3634 | } | |
| 3635 | const snapshot = yield* readSnapshot(retainedThread); | |
| 3636 | return { | |
| 3637 | ...snapshot, | |
| 3638 | providerThread: { | |
| 3639 | ...snapshot.providerThread, | |
| 3640 | nativeConversationHeadRef: | |
| 3641 | rollbackInput.target.type === "provider_turn" | |
| 3642 | ? rollbackInput.target.providerTurn.nativeTurnRef | |
| 3643 | : null, | |
| 3644 | }, | |
| 3645 | }; | |
| 3646 | }).pipe( | |
| 3647 | Effect.mapError( | |
| 3648 | (cause) => | |
| 3649 | new ProviderAdapter.ProviderAdapterRollbackThreadError({ | |
| 3650 | driver: OPENCODE_PROVIDER, | |
| 3651 | providerThreadId: rollbackInput.providerThread.id, | |
| 3652 | checkpointId: rollbackInput.target.checkpointId, | |
| 3653 | cause, | |
| 3654 | }), | |
| 3655 | ), | |
| 3656 | ), | |
| 3657 | forkThread: (forkInput) => | |
| 3658 | Effect.gen(function* () { | |
| 3659 | const sourceSessionId = nativeThreadId(forkInput.sourceProviderThread); | |
| 3660 | const sourceState = threads.get(sourceSessionId); | |
| 3661 | if (sourceState?.activeTurn !== null && sourceState?.activeTurn !== undefined) { | |
| 3662 | return yield* protocolError( | |
| 3663 | `Cannot fork OpenCode thread ${forkInput.sourceProviderThread.id} while a turn is active`, | |
| 3664 | ); | |
| 3665 | } | |
| 3666 | let boundaryMessageId: string | undefined; | |
| 3667 | if (forkInput.providerTurnId !== undefined) { | |
| 3668 | const sourceTurns = forkInput.sourceProviderTurns ?? []; | |
| 3669 | const selected = sourceTurns.find((turn) => turn.id === forkInput.providerTurnId); | |
| 3670 | if (selected === undefined) { | |
| 3671 | return yield* protocolError( | |
| 3672 | `OpenCode fork boundary turn ${forkInput.providerTurnId} was not found`, | |
| 3673 | ); | |
| 3674 | } | |
| 3675 | boundaryMessageId = openCodeBoundaryAfterProviderTurn(sourceTurns, selected.id); | |
| 3676 | } | |
| 3677 | const response = yield* sdkCall( | |
| 3678 | "session.fork", | |
| 3679 | { | |
| 3680 | sessionID: sourceSessionId, | |
| 3681 | ...(boundaryMessageId === undefined ? {} : { messageID: boundaryMessageId }), | |
| 3682 | }, | |
| 3683 | () => | |
| 3684 | client.session.fork({ | |
| 3685 | sessionID: sourceSessionId, | |
| 3686 | ...(boundaryMessageId === undefined ? {} : { messageID: boundaryMessageId }), | |
| 3687 | }), | |
| 3688 | ); | |
| 3689 | const nativeSession = unwrapData("session.fork", response); | |
| 3690 | const forkedAt = yield* DateTime.now; | |
| 3691 | const providerThread = makeProviderThread({ | |
| 3692 | idAllocator, | |
| 3693 | providerInstanceId: options.instanceId, | |
| 3694 | providerSessionId: input.providerSessionId, | |
| 3695 | appThreadId: forkInput.targetThreadId, | |
| 3696 | ...(forkInput.ownerNodeId === undefined | |
| 3697 | ? {} | |
| 3698 | : { ownerNodeId: forkInput.ownerNodeId }), | |
| 3699 | nativeSession, | |
| 3700 | forkedFrom: { | |
| 3701 | providerThreadId: forkInput.sourceProviderThread.id, | |
| 3702 | ...(forkInput.providerTurnId === undefined | |
| 3703 | ? {} | |
| 3704 | : { providerTurnId: forkInput.providerTurnId }), | |
| 3705 | }, | |
| 3706 | now: forkedAt, | |
| 3707 | }); | |
| 3708 | registerThread(nativeSession, providerThread, null); | |
| 3709 | return providerThread; | |
| 3710 | }).pipe( | |
| 3711 | Effect.mapError( | |
| 3712 | (cause) => | |
| 3713 | new ProviderAdapter.ProviderAdapterForkThreadError({ | |
| 3714 | driver: OPENCODE_PROVIDER, | |
| 3715 | providerThreadId: forkInput.sourceProviderThread.id, | |
| 3716 | cause, | |
| 3717 | }), | |
| 3718 | ), | |
| 3719 | ), | |
| 3720 | }; | |
| 3721 | ||
| 3722 | return runtimeSession; | |
| 3723 | }, | |
| 3724 | (effect, input) => | |
| 3725 | effect.pipe( | |
| 3726 | Effect.mapError( | |
| 3727 | (cause) => | |
| 3728 | new ProviderAdapter.ProviderAdapterOpenSessionError({ | |
| 3729 | driver: OPENCODE_PROVIDER, | |
| 3730 | providerSessionId: input.providerSessionId, | |
| 3731 | cause, | |
| 3732 | }), | |
| 3733 | ), | |
| 3734 | ), | |
| 3735 | ), | |
| 3736 | }); | |
| 3737 | } | |
| 3738 | ||
| 3739 | export type OpenCodeAdapterV2DriverEnv = | |
| 3740 | | OpenCodeRuntime.OpenCodeRuntime | |
| 3741 | | IdAllocator.IdAllocatorV2 | |
| 3742 | | ProviderEventLoggers.ProviderEventLoggers | |
| 3743 | | ServerConfig.ServerConfig; | |
| 3744 | ||
| 3745 | export const OpenCodeAdapterV2Driver: ProviderAdapterDriver< | |
| 3746 | OpenCodeSettings, | |
| 3747 | OpenCodeAdapterV2DriverEnv | |
| 3748 | > = { | |
| 3749 | driverKind: OPENCODE_PROVIDER, | |
| 3750 | configSchema: OpenCodeSettingsSchema, | |
| 3751 | defaultConfig: (): OpenCodeSettings => DEFAULT_OPENCODE_SETTINGS, | |
| 3752 | create: Effect.fn("OpenCodeAdapterV2Driver.create")( | |
| 3753 | function* (input: ProviderAdapterDriverCreateInput<OpenCodeSettings>) { | |
| 3754 | const hostEnvironment = yield* HostProcessEnvironment; | |
| 3755 | const openCodeRuntime = yield* OpenCodeRuntime.OpenCodeRuntime; | |
| 3756 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | |
| 3757 | const providerEventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers; | |
| 3758 | const serverConfig = yield* ServerConfig.ServerConfig; | |
| 3759 | return makeOpenCodeAdapterV2({ | |
| 3760 | instanceId: input.instanceId, | |
| 3761 | settings: { ...input.config, enabled: input.enabled }, | |
| 3762 | environment: mergeProviderInstanceEnvironment(input.environment, hostEnvironment), | |
| 3763 | runtime: openCodeRuntime, | |
| 3764 | idAllocator, | |
| 3765 | serverConfig, | |
| 3766 | ...(providerEventLoggers.native === undefined | |
| 3767 | ? {} | |
| 3768 | : { nativeEventLogger: providerEventLoggers.native }), | |
| 3769 | }); | |
| 3770 | }, | |
| 3771 | (effect, input) => | |
| 3772 | effect.pipe( | |
| 3773 | Effect.mapError( | |
| 3774 | (cause) => | |
| 3775 | new ProviderAdapterDriverCreateError({ | |
| 3776 | driver: OPENCODE_PROVIDER, | |
| 3777 | instanceId: input.instanceId, | |
| 3778 | detail: "Failed to create OpenCode v2 adapter.", | |
| 3779 | cause, | |
| 3780 | }), | |
| 3781 | ), | |
| 3782 | ), | |
| 3783 | ), | |
| 3784 | }; | |
| 3785 | ||
| 3786 | const layer: Layer.Layer<ProviderAdapter.ProviderAdapterV2, never, OpenCodeAdapterV2DriverEnv> = | |
| 3787 | Layer.effect( | |
| 3788 | ProviderAdapter.ProviderAdapterV2, | |
| 3789 | Effect.gen(function* () { | |
| 3790 | const hostEnvironment = yield* HostProcessEnvironment; | |
| 3791 | const openCodeRuntime = yield* OpenCodeRuntime.OpenCodeRuntime; | |
| 3792 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | |
| 3793 | const providerEventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers; | |
| 3794 | const serverConfig = yield* ServerConfig.ServerConfig; | |
| 3795 | return makeOpenCodeAdapterV2({ | |
| 3796 | instanceId: OPENCODE_DEFAULT_INSTANCE_ID, | |
| 3797 | settings: DEFAULT_OPENCODE_SETTINGS, | |
| 3798 | environment: hostEnvironment, | |
| 3799 | runtime: openCodeRuntime, | |
| 3800 | idAllocator, | |
| 3801 | serverConfig, | |
| 3802 | ...(providerEventLoggers.native === undefined | |
| 3803 | ? {} | |
| 3804 | : { nativeEventLogger: providerEventLoggers.native }), | |
| 3805 | }); | |
| 3806 | }), | |
| 3807 | ); |
apps/server/src/orchestration-v2/Adapters/OpenCodeToolItems.ts deleted-170| ... | ... | @@ -1,170 +0,0 @@ |
| 1 | /** | |
| 2 | * Turn-item bodies for OpenCode tool calls, shared by the 1.x and 2.x runtimes. | |
| 3 | * Both report a tool as a name, a JSON input, text output, and free-form | |
| 4 | * metadata; only how they deliver those differs. | |
| 5 | */ | |
| 6 | import type { OrchestrationV2TurnItem } from "@t3tools/contracts"; | |
| 7 | import { formatReadToolLabel, formatSearchToolLabel } from "@t3tools/shared/toolActivity"; | |
| 8 | ||
| 9 | // Search results stay on the timeline wire, so keep their text a preview. | |
| 10 | const SEARCH_PREVIEW_MAX_CHARS = 8_000; | |
| 11 | ||
| 12 | type ToolItemBase = Omit< | |
| 13 | Extract<OrchestrationV2TurnItem, { type: "dynamic_tool" }>, | |
| 14 | "type" | "toolName" | "input" | "output" | |
| 15 | >; | |
| 16 | ||
| 17 | export function openCodeToolProjectionKind( | |
| 18 | toolName: string, | |
| 19 | ): "command_execution" | "file_change" | "file_search" | "web_search" | "dynamic_tool" { | |
| 20 | const normalized = toolName.toLowerCase(); | |
| 21 | if (normalized === "todowrite") { | |
| 22 | return "dynamic_tool"; | |
| 23 | } | |
| 24 | if (normalized.includes("bash") || normalized.includes("shell")) { | |
| 25 | return "command_execution"; | |
| 26 | } | |
| 27 | if (normalized.includes("edit") || normalized.includes("write") || normalized.includes("patch")) { | |
| 28 | return "file_change"; | |
| 29 | } | |
| 30 | if (normalized.includes("web") || normalized === "codesearch" || normalized === "code_search") { | |
| 31 | return "web_search"; | |
| 32 | } | |
| 33 | if (normalized === "read") { | |
| 34 | return "dynamic_tool"; | |
| 35 | } | |
| 36 | if ( | |
| 37 | normalized.includes("glob") || | |
| 38 | normalized.includes("grep") || | |
| 39 | normalized.includes("search") || | |
| 40 | normalized.includes("lsp") | |
| 41 | ) { | |
| 42 | return "file_search"; | |
| 43 | } | |
| 44 | return "dynamic_tool"; | |
| 45 | } | |
| 46 | ||
| 47 | function recordString(input: unknown, ...keys: ReadonlyArray<string>): string | undefined { | |
| 48 | if (typeof input !== "object" || input === null) return undefined; | |
| 49 | for (const key of keys) { | |
| 50 | const value = (input as Record<string, unknown>)[key]; | |
| 51 | if (typeof value === "string" && value.trim().length > 0) return value; | |
| 52 | } | |
| 53 | return undefined; | |
| 54 | } | |
| 55 | ||
| 56 | function recordNumber(input: unknown, ...keys: ReadonlyArray<string>): number | undefined { | |
| 57 | if (typeof input !== "object" || input === null) return undefined; | |
| 58 | for (const key of keys) { | |
| 59 | const value = (input as Record<string, unknown>)[key]; | |
| 60 | if (typeof value === "number" && Number.isFinite(value)) return value; | |
| 61 | } | |
| 62 | return undefined; | |
| 63 | } | |
| 64 | ||
| 65 | function stableJson(value: unknown): string { | |
| 66 | if (typeof value === "string") return value; | |
| 67 | try { | |
| 68 | return JSON.stringify(value, null, 2); | |
| 69 | } catch { | |
| 70 | return String(value); | |
| 71 | } | |
| 72 | } | |
| 73 | ||
| 74 | /** | |
| 75 | * The turn item for one tool call. `completedMetadata` is the tool's metadata | |
| 76 | * once it succeeded: exit codes and diffs are only trusted from a finished call. | |
| 77 | */ | |
| 78 | export function openCodeToolTurnItem( | |
| 79 | base: ToolItemBase, | |
| 80 | tool: { | |
| 81 | readonly name: string; | |
| 82 | readonly input: Record<string, unknown>; | |
| 83 | readonly output: string | undefined; | |
| 84 | readonly completedMetadata: unknown; | |
| 85 | }, | |
| 86 | ): OrchestrationV2TurnItem { | |
| 87 | const { input, output } = tool; | |
| 88 | switch (openCodeToolProjectionKind(tool.name)) { | |
| 89 | case "command_execution": { | |
| 90 | const exitCode = recordNumber(tool.completedMetadata, "exit", "exitCode"); | |
| 91 | return { | |
| 92 | ...base, | |
| 93 | type: "command_execution", | |
| 94 | input: recordString(input, "command", "cmd") ?? stableJson(input), | |
| 95 | ...(output === undefined ? {} : { output }), | |
| 96 | ...(exitCode === undefined ? {} : { exitCode }), | |
| 97 | }; | |
| 98 | } | |
| 99 | case "file_change": { | |
| 100 | const oldStr = recordString(input, "oldString", "oldText"); | |
| 101 | const newStr = recordString(input, "newString", "content", "newText"); | |
| 102 | // A failed edit has no diff; keep its error where the diff would be. | |
| 103 | const diffStr = | |
| 104 | recordString(tool.completedMetadata, "diff", "patch") ?? | |
| 105 | (base.status === "failed" && output?.trim() ? output : undefined); | |
| 106 | return { | |
| 107 | ...base, | |
| 108 | type: "file_change", | |
| 109 | fileName: recordString(input, "filePath", "path", "file") ?? tool.name, | |
| 110 | ...(oldStr === undefined ? {} : { oldStr }), | |
| 111 | ...(newStr === undefined ? {} : { newStr }), | |
| 112 | ...(diffStr === undefined ? {} : { diffStr }), | |
| 113 | }; | |
| 114 | } | |
| 115 | case "file_search": { | |
| 116 | const pattern = recordString(input, "pattern", "query", "path", "filePath"); | |
| 117 | // OpenCode reports matches as plain text, so keep it as one result row | |
| 118 | // under the searched path, like the ACP search projection. | |
| 119 | const searchRoot = (recordString(input, "path", "filePath") ?? pattern)?.trim(); | |
| 120 | return { | |
| 121 | ...base, | |
| 122 | title: | |
| 123 | formatSearchToolLabel({ input, ...(pattern === undefined ? {} : { pattern }) }) ?? | |
| 124 | base.title, | |
| 125 | type: "file_search", | |
| 126 | ...(pattern === undefined ? {} : { pattern }), | |
| 127 | ...(!output?.trim() || searchRoot === undefined | |
| 128 | ? {} | |
| 129 | : { | |
| 130 | results: [ | |
| 131 | { fileName: searchRoot, preview: output.slice(0, SEARCH_PREVIEW_MAX_CHARS) }, | |
| 132 | ], | |
| 133 | }), | |
| 134 | }; | |
| 135 | } | |
| 136 | case "web_search": { | |
| 137 | const pattern = recordString(input, "query", "url", "pattern"); | |
| 138 | const url = recordString(input, "url")?.trim(); | |
| 139 | return { | |
| 140 | ...base, | |
| 141 | type: "web_search", | |
| 142 | ...(pattern === undefined ? {} : { patterns: [pattern] }), | |
| 143 | ...(!output?.trim() | |
| 144 | ? {} | |
| 145 | : { | |
| 146 | results: [ | |
| 147 | { | |
| 148 | ...(url === undefined ? {} : { url }), | |
| 149 | snippet: output.slice(0, SEARCH_PREVIEW_MAX_CHARS), | |
| 150 | }, | |
| 151 | ], | |
| 152 | }), | |
| 153 | }; | |
| 154 | } | |
| 155 | case "dynamic_tool": { | |
| 156 | const readPath = recordString(input, "filePath", "path", "file"); | |
| 157 | return { | |
| 158 | ...base, | |
| 159 | title: | |
| 160 | tool.name.toLowerCase() === "read" && readPath !== undefined | |
| 161 | ? formatReadToolLabel(readPath) | |
| 162 | : base.title, | |
| 163 | type: "dynamic_tool", | |
| 164 | toolName: tool.name, | |
| 165 | input, | |
| 166 | ...(output === undefined ? {} : { output }), | |
| 167 | }; | |
| 168 | } | |
| 169 | } | |
| 170 | } |
apps/server/src/orchestration-v2/CursorOrchestratorV2.live.test.ts+2-2| ... | ... | @@ -30,8 +30,8 @@ import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; |
| 30 | 30 | import * as ModelManifest from "../provider/ModelManifest.ts"; |
| 31 | 31 | import * as ProviderInstanceRegistryHydration from "../provider/ProviderInstanceRegistryHydration.ts"; |
| 32 | 32 | import * as ProviderEventLoggers from "../provider/ProviderEventLoggers.ts"; |
| 33 | import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts"; | |
| 34 | import * as OpenCodeServerLedger from "../provider/OpenCodeServerLedger.ts"; | |
| 33 | import * as OpenCodeRuntime from "@t3tools/provider-opencode/server/OpenCodeRuntime"; | |
| 34 | import * as OpenCodeServerLedger from "@t3tools/provider-opencode/server/OpenCodeServerLedger"; | |
| 35 | 35 | import * as ServerSettings from "../serverSettings.ts"; |
| 36 | 36 | import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; |
| 37 | 37 | import * as VcsProcess from "../vcs/VcsProcess.ts"; |
apps/server/src/orchestration-v2/GrokOrchestratorV2.live.test.ts+2-2| ... | ... | @@ -30,8 +30,8 @@ import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; |
| 30 | 30 | import * as ModelManifest from "../provider/ModelManifest.ts"; |
| 31 | 31 | import * as ProviderInstanceRegistryHydration from "../provider/ProviderInstanceRegistryHydration.ts"; |
| 32 | 32 | import * as ProviderEventLoggers from "../provider/ProviderEventLoggers.ts"; |
| 33 | import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts"; | |
| 34 | import * as OpenCodeServerLedger from "../provider/OpenCodeServerLedger.ts"; | |
| 33 | import * as OpenCodeRuntime from "@t3tools/provider-opencode/server/OpenCodeRuntime"; | |
| 34 | import * as OpenCodeServerLedger from "@t3tools/provider-opencode/server/OpenCodeServerLedger"; | |
| 35 | 35 | import * as ServerSettings from "../serverSettings.ts"; |
| 36 | 36 | import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; |
| 37 | 37 | import * as VcsProcess from "../vcs/VcsProcess.ts"; |
apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts+1-1| ... | ... | @@ -27,7 +27,7 @@ import { |
| 27 | 27 | OPENCODE2_HTTP_PROTOCOL, |
| 28 | 28 | OpenCode2OrchestratorReplayHarness, |
| 29 | 29 | } from "./Adapters/OpenCode2AdapterV2.testkit.ts"; |
| 30 | import { OPENCODE_PROVIDER } from "./Adapters/OpenCodeAdapterV2.ts"; | |
| 30 | import { OPENCODE_PROVIDER } from "@t3tools/provider-opencode/testing"; | |
| 31 | 31 | import { provideDeterministicTestRuntime } from "./testkit/DeterministicRuntime.ts"; |
| 32 | 32 | import type { OrchestratorV2ScenarioStep } from "./testkit/OrchestratorScenario.ts"; |
| 33 | 33 | import { runOrchestratorV2ProviderReplayScenario } from "./testkit/ProviderReplayHarness.ts"; |
apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts+3-3| ... | ... | @@ -55,9 +55,9 @@ import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; |
| 55 | 55 | import * as ModelManifest from "../provider/ModelManifest.ts"; |
| 56 | 56 | import * as ProviderInstanceRegistryHydration from "../provider/ProviderInstanceRegistryHydration.ts"; |
| 57 | 57 | import * as ProviderEventLoggers from "../provider/ProviderEventLoggers.ts"; |
| 58 | import * as OpenCode2Client from "../provider/opencode2/OpenCode2Client.ts"; | |
| 59 | import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts"; | |
| 60 | import * as OpenCodeServerLedger from "../provider/OpenCodeServerLedger.ts"; | |
| 58 | import * as OpenCode2Client from "@t3tools/provider-opencode/server/v2/OpenCode2Client"; | |
| 59 | import * as OpenCodeRuntime from "@t3tools/provider-opencode/server/OpenCodeRuntime"; | |
| 60 | import * as OpenCodeServerLedger from "@t3tools/provider-opencode/server/OpenCodeServerLedger"; | |
| 61 | 61 | import * as ServerSettings from "../serverSettings.ts"; |
| 62 | 62 | import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; |
| 63 | 63 | import * as VcsProcess from "../vcs/VcsProcess.ts"; |
apps/server/src/orchestration-v2/Orchestrator.ts+1-1| ... | ... | @@ -121,7 +121,7 @@ import { |
| 121 | 121 | subagentResultForRun, |
| 122 | 122 | delegatedTaskProgress, |
| 123 | 123 | subagentThreadTitle, |
| 124 | } from "./SubagentProjection.ts"; | |
| 124 | } from "@t3tools/provider-core/server/subagentProjection"; | |
| 125 | 125 | import { |
| 126 | 126 | forkableSourceRunStatusError, |
| 127 | 127 | isForkableSourceRunStatus, |
apps/server/src/orchestration-v2/SubagentProjection.test.ts+1-1| ... | ... | @@ -21,7 +21,7 @@ import { |
| 21 | 21 | delegatedTaskProgress, |
| 22 | 22 | subagentResultForRun, |
| 23 | 23 | makeSubagentConversationArtifacts, |
| 24 | } from "./SubagentProjection.ts"; | |
| 24 | } from "@t3tools/provider-core/server/subagentProjection"; | |
| 25 | 25 | |
| 26 | 26 | import { emptyProjection } from "./ProjectionStore.ts"; |
| 27 | 27 |
apps/server/src/orchestration-v2/SubagentProjection.ts deleted-259| ... | ... | @@ -1,259 +0,0 @@ |
| 1 | import type { | |
| 2 | MessageId, | |
| 3 | ModelSelection, | |
| 4 | NodeId, | |
| 5 | OrchestrationV2AppThread, | |
| 6 | OrchestrationV2Actor, | |
| 7 | OrchestrationV2ConversationMessage, | |
| 8 | OrchestrationV2CreationSource, | |
| 9 | OrchestrationV2ProviderRef, | |
| 10 | OrchestrationV2Run, | |
| 11 | OrchestrationV2ThreadProjection, | |
| 12 | OrchestrationV2TurnItem, | |
| 13 | ProviderInstanceId, | |
| 14 | ProviderThreadId, | |
| 15 | ProviderTurnId, | |
| 16 | ThreadId, | |
| 17 | TurnItemId, | |
| 18 | } from "@t3tools/contracts"; | |
| 19 | import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError"; | |
| 20 | import * as DateTime from "effect/DateTime"; | |
| 21 | import { isOrchestrationV2WorkActive } from "@t3tools/contracts"; | |
| 22 | ||
| 23 | function trimmed(value: string | null | undefined): string | undefined { | |
| 24 | const result = value?.trim(); | |
| 25 | return result && result.length > 0 ? result : undefined; | |
| 26 | } | |
| 27 | ||
| 28 | export function subagentThreadTitle(input: { | |
| 29 | readonly parentTitle: string; | |
| 30 | readonly title?: string | null; | |
| 31 | readonly prompt: string; | |
| 32 | readonly ordinal: number; | |
| 33 | }): string { | |
| 34 | const detail = trimmed(input.title) ?? trimmed(input.prompt); | |
| 35 | if (detail === undefined) { | |
| 36 | return `${input.parentTitle} subagent ${input.ordinal}`; | |
| 37 | } | |
| 38 | const clipped = detail.length > 72 ? `${detail.slice(0, 69)}...` : detail; | |
| 39 | return clipped; | |
| 40 | } | |
| 41 | ||
| 42 | export function makeSubagentChildThread(input: { | |
| 43 | readonly parentThread: OrchestrationV2AppThread; | |
| 44 | readonly childThreadId: ThreadId; | |
| 45 | readonly parentNodeId: NodeId; | |
| 46 | readonly activeProviderThreadId: ProviderThreadId | null; | |
| 47 | readonly providerInstanceId: ProviderInstanceId; | |
| 48 | readonly modelSelection: ModelSelection; | |
| 49 | readonly title: string; | |
| 50 | readonly now: DateTime.Utc; | |
| 51 | readonly createdBy: OrchestrationV2Actor; | |
| 52 | readonly creationSource: OrchestrationV2CreationSource; | |
| 53 | }): OrchestrationV2AppThread { | |
| 54 | return { | |
| 55 | ...input.parentThread, | |
| 56 | createdBy: input.createdBy, | |
| 57 | creationSource: input.creationSource, | |
| 58 | id: input.childThreadId, | |
| 59 | title: input.title, | |
| 60 | linkedPullRequest: null, | |
| 61 | pullRequests: [], | |
| 62 | historyOrigin: undefined, | |
| 63 | providerInstanceId: input.providerInstanceId, | |
| 64 | modelSelection: input.modelSelection, | |
| 65 | activeProviderThreadId: input.activeProviderThreadId, | |
| 66 | lineage: { | |
| 67 | parentThreadId: input.parentThread.id, | |
| 68 | relationshipToParent: "subagent", | |
| 69 | rootThreadId: input.parentThread.lineage.rootThreadId, | |
| 70 | }, | |
| 71 | forkedFrom: { | |
| 72 | type: "node", | |
| 73 | nodeId: input.parentNodeId, | |
| 74 | }, | |
| 75 | createdAt: input.now, | |
| 76 | updatedAt: input.now, | |
| 77 | archivedAt: null, | |
| 78 | settledOverride: null, | |
| 79 | settledAt: null, | |
| 80 | snoozedUntil: null, | |
| 81 | snoozedAt: null, | |
| 82 | lastVisitedAt: null, | |
| 83 | deletedAt: null, | |
| 84 | }; | |
| 85 | } | |
| 86 | ||
| 87 | export function makeSubagentConversationArtifacts(input: { | |
| 88 | readonly messageId: MessageId; | |
| 89 | readonly senderThreadId?: ThreadId; | |
| 90 | readonly turnItemId: TurnItemId; | |
| 91 | readonly threadId: ThreadId; | |
| 92 | readonly rootNodeId: NodeId; | |
| 93 | readonly providerThreadId: ProviderThreadId | null; | |
| 94 | readonly providerTurnId: ProviderTurnId | null; | |
| 95 | readonly nativeItemRef: OrchestrationV2ProviderRef | null; | |
| 96 | readonly role: "user" | "assistant"; | |
| 97 | readonly text: string; | |
| 98 | readonly ordinal: number; | |
| 99 | readonly now: DateTime.Utc; | |
| 100 | }): { | |
| 101 | readonly message: OrchestrationV2ConversationMessage; | |
| 102 | readonly turnItem: OrchestrationV2TurnItem; | |
| 103 | } { | |
| 104 | const message: OrchestrationV2ConversationMessage = { | |
| 105 | createdBy: "agent", | |
| 106 | creationSource: "provider", | |
| 107 | id: input.messageId, | |
| 108 | threadId: input.threadId, | |
| 109 | runId: null, | |
| 110 | nodeId: input.rootNodeId, | |
| 111 | role: input.role, | |
| 112 | ...(input.role === "user" && input.senderThreadId !== undefined | |
| 113 | ? { senderThreadId: input.senderThreadId } | |
| 114 | : {}), | |
| 115 | text: input.text, | |
| 116 | attachments: [], | |
| 117 | streaming: false, | |
| 118 | createdAt: input.now, | |
| 119 | updatedAt: input.now, | |
| 120 | }; | |
| 121 | const base = { | |
| 122 | id: input.turnItemId, | |
| 123 | threadId: input.threadId, | |
| 124 | runId: null, | |
| 125 | nodeId: input.rootNodeId, | |
| 126 | providerThreadId: input.providerThreadId, | |
| 127 | providerTurnId: input.providerTurnId, | |
| 128 | nativeItemRef: input.nativeItemRef, | |
| 129 | parentItemId: null, | |
| 130 | ordinal: input.ordinal, | |
| 131 | status: "completed" as const, | |
| 132 | title: null, | |
| 133 | startedAt: input.now, | |
| 134 | completedAt: input.now, | |
| 135 | updatedAt: input.now, | |
| 136 | messageId: input.messageId, | |
| 137 | text: input.text, | |
| 138 | }; | |
| 139 | const turnItem: OrchestrationV2TurnItem = | |
| 140 | input.role === "user" | |
| 141 | ? { | |
| 142 | ...base, | |
| 143 | createdBy: "agent", | |
| 144 | creationSource: "provider", | |
| 145 | type: "user_message", | |
| 146 | ...(input.senderThreadId === undefined ? {} : { senderThreadId: input.senderThreadId }), | |
| 147 | inputIntent: "turn_start", | |
| 148 | attachments: [], | |
| 149 | } | |
| 150 | : { | |
| 151 | ...base, | |
| 152 | type: "assistant_message", | |
| 153 | streaming: false, | |
| 154 | }; | |
| 155 | return { message, turnItem }; | |
| 156 | } | |
| 157 | ||
| 158 | export function subagentResultForRun( | |
| 159 | projection: Pick<OrchestrationV2ThreadProjection, "messages" | "turnItems">, | |
| 160 | run: OrchestrationV2Run, | |
| 161 | ): { | |
| 162 | readonly text: string; | |
| 163 | readonly messageId: OrchestrationV2ConversationMessage["id"] | null; | |
| 164 | readonly turnItemId: OrchestrationV2TurnItem["id"] | null; | |
| 165 | } { | |
| 166 | const message = | |
| 167 | projection.messages | |
| 168 | .filter( | |
| 169 | (candidate) => | |
| 170 | candidate.runId === run.id && | |
| 171 | candidate.role === "assistant" && | |
| 172 | candidate.text.trim().length > 0, | |
| 173 | ) | |
| 174 | .toSorted( | |
| 175 | (left, right) => | |
| 176 | DateTime.toEpochMillis(right.updatedAt) - DateTime.toEpochMillis(left.updatedAt), | |
| 177 | )[0] ?? null; | |
| 178 | const turnItem = | |
| 179 | projection.turnItems | |
| 180 | .filter( | |
| 181 | ( | |
| 182 | candidate, | |
| 183 | ): candidate is Extract<OrchestrationV2TurnItem, { readonly type: "assistant_message" }> => | |
| 184 | candidate.runId === run.id && | |
| 185 | candidate.type === "assistant_message" && | |
| 186 | candidate.text.trim().length > 0, | |
| 187 | ) | |
| 188 | .toSorted((left, right) => right.ordinal - left.ordinal)[0] ?? null; | |
| 189 | const failure = | |
| 190 | run.status === "failed" | |
| 191 | ? projection.turnItems | |
| 192 | .filter((item) => item.runId === run.id && item.type === "error") | |
| 193 | .toSorted((left, right) => right.ordinal - left.ordinal)[0] | |
| 194 | : undefined; | |
| 195 | const text = | |
| 196 | (failure?.type === "error" ? failure.failure.message : undefined) ?? | |
| 197 | message?.text ?? | |
| 198 | turnItem?.text ?? | |
| 199 | (run.status === "completed" | |
| 200 | ? "Child task completed without an assistant result." | |
| 201 | : `Child task ended with status ${run.status}.`); | |
| 202 | return { | |
| 203 | text, | |
| 204 | messageId: failure === undefined ? (message?.id ?? turnItem?.messageId ?? null) : null, | |
| 205 | turnItemId: failure?.id ?? turnItem?.id ?? null, | |
| 206 | }; | |
| 207 | } | |
| 208 | ||
| 209 | /** A finished turn can still own live children or queued completion follow-ups. */ | |
| 210 | export function delegatedTaskProgress(projection: { | |
| 211 | readonly runs: OrchestrationV2ThreadProjection["runs"]; | |
| 212 | readonly messages: ReadonlyArray< | |
| 213 | Pick<OrchestrationV2ConversationMessage, "runId" | "notification"> | |
| 214 | >; | |
| 215 | readonly subagents: ReadonlyArray< | |
| 216 | Pick<OrchestrationV2ThreadProjection["subagents"][number], "status" | "completionDelivery"> | |
| 217 | >; | |
| 218 | readonly providerThreads: ReadonlyArray< | |
| 219 | Pick<OrchestrationV2ThreadProjection["providerThreads"][number], "pendingBackgroundTasks"> | |
| 220 | >; | |
| 221 | }) { | |
| 222 | const terminal = (status: string) => | |
| 223 | ["completed", "failed", "cancelled", "interrupted", "rolled_back"].includes(status); | |
| 224 | const monitorRuns = new Set( | |
| 225 | projection.messages | |
| 226 | .filter((message) => message.notification?.source.kind === "monitor") | |
| 227 | .map((message) => message.runId), | |
| 228 | ); | |
| 229 | const workRuns = projection.runs.filter( | |
| 230 | (run) => !monitorRuns.has(run.id) && run.status !== "rolled_back", | |
| 231 | ); | |
| 232 | // A held queue waits for the user to resume it (after Stop, a restart, or a | |
| 233 | // provider failure), so its runs are not work the task still owes. | |
| 234 | const active = workRuns.some( | |
| 235 | (run) => !terminal(run.status) && !(run.status === "queued" && run.queueHeld === true), | |
| 236 | ); | |
| 237 | const children = | |
| 238 | projection.subagents.some( | |
| 239 | (task) => | |
| 240 | isOrchestrationV2WorkActive(task.status) || | |
| 241 | // Publishing a child's result precedes scheduling its parent's wake. | |
| 242 | // The parent still owes that follow-up even between those transactions. | |
| 243 | task.completionDelivery?.state === "pending" || | |
| 244 | task.completionDelivery?.state === "claimed", | |
| 245 | ) || | |
| 246 | projection.providerThreads.some((thread) => (thread.pendingBackgroundTasks?.length ?? 0) > 0); | |
| 247 | const resultRun = workRuns | |
| 248 | .filter((run) => terminal(run.status) && (run.startedAt !== null || run.ordinal === 1)) | |
| 249 | .toSorted((a, b) => (runRanAfter(a, b) ? -1 : runRanAfter(b, a) ? 1 : 0))[0]; | |
| 250 | return { | |
| 251 | state: | |
| 252 | active || resultRun === undefined | |
| 253 | ? ("working" as const) | |
| 254 | : children | |
| 255 | ? ("waiting_for_children" as const) | |
| 256 | : ("result_available" as const), | |
| 257 | resultRun, | |
| 258 | }; | |
| 259 | } |
apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts+1-1| ... | ... | @@ -17,7 +17,7 @@ import { GrokAdapterV2Driver, type GrokAdapterV2DriverEnv } from "./Adapters/Gro |
| 17 | 17 | import { |
| 18 | 18 | OpenCodeAdapterV2Driver, |
| 19 | 19 | type OpenCodeAdapterV2DriverEnv, |
| 20 | } from "./Adapters/OpenCodeAdapterV2.ts"; | |
| 20 | } from "@t3tools/provider-opencode/server"; | |
| 21 | 21 | import { PiAdapterV2Driver, type PiAdapterV2DriverEnv } from "@t3tools/provider-pi/server"; |
| 22 | 22 | import type { AnyProviderAdapterDriver } from "@t3tools/provider-core/server/adapterDriver"; |
| 23 | 23 |
apps/server/src/provider/Drivers/OpenCodeDriver.test.ts deleted-282| ... | ... | @@ -1,282 +0,0 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off | |
| 2 | import * as NodeFS from "node:fs"; | |
| 3 | import * as NodeOS from "node:os"; | |
| 4 | import * as NodePath from "node:path"; | |
| 5 | ||
| 6 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 7 | import { assert, it } from "@effect/vitest"; | |
| 8 | import { ProviderInstanceId, type OpenCodeSettings } from "@t3tools/contracts"; | |
| 9 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 10 | import * as Effect from "effect/Effect"; | |
| 11 | import * as Fiber from "effect/Fiber"; | |
| 12 | import * as Layer from "effect/Layer"; | |
| 13 | import * as TestClock from "effect/testing/TestClock"; | |
| 14 | import { HttpClient } from "effect/http"; | |
| 15 | import { ChildProcessSpawner } from "effect/process"; | |
| 16 | ||
| 17 | import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; | |
| 18 | import * as ServerConfig from "../../config.ts"; | |
| 19 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; | |
| 20 | import * as ServerSettings from "../../serverSettings.ts"; | |
| 21 | import * as ProviderEventLoggers from "../ProviderEventLoggers.ts"; | |
| 22 | import * as ProviderMaintenance from "@t3tools/provider-core/server/maintenanceResolver"; | |
| 23 | import * as OpenCodeRuntime from "../opencodeRuntime.ts"; | |
| 24 | import { | |
| 25 | OPENCODE_2_RESPONSES, | |
| 26 | OPENCODE_2_WORKSPACE_RESPONSES, | |
| 27 | replayOpenCodeServer, | |
| 28 | } from "../testFixtures/opencodeProbeResponses.ts"; | |
| 29 | import { OpenCodeDriver, openCodeUpdateFor } from "./OpenCodeDriver.ts"; | |
| 30 | import * as ProviderHostLive from "../ProviderHostLive.ts"; | |
| 31 | ||
| 32 | const serverStarts: Array<string> = []; | |
| 33 | const reachedServer = (operation: string) => | |
| 34 | Effect.sync(() => serverStarts.push(operation)).pipe( | |
| 35 | Effect.andThen( | |
| 36 | Effect.fail( | |
| 37 | new OpenCodeRuntime.OpenCodeRuntimeError({ | |
| 38 | operation, | |
| 39 | detail: "reached a 1.x server path", | |
| 40 | }), | |
| 41 | ), | |
| 42 | ), | |
| 43 | ); | |
| 44 | // Reports OpenCode 2 from `--version`; any attempt to reach a server is recorded and refused. | |
| 45 | const openCode2Runtime = { | |
| 46 | runOpenCodeCommand: () => Effect.succeed({ stdout: "opencode v2.0.18\n", stderr: "", code: 0 }), | |
| 47 | startOpenCodeServerProcess: () => reachedServer("start"), | |
| 48 | connectToOpenCodeServer: () => reachedServer("connect"), | |
| 49 | } as unknown as OpenCodeRuntime.OpenCodeRuntimeShape; | |
| 50 | ||
| 51 | const layerDeps = Layer.mergeAll( | |
| 52 | ServerConfig.layerTest(process.cwd(), { prefix: "t3-opencode-driver-" }), | |
| 53 | IdAllocator.layer, | |
| 54 | ServerSettings.layerTest(), | |
| 55 | Layer.mock(BackgroundPolicy.BackgroundPolicy)({}), | |
| 56 | Layer.succeed( | |
| 57 | ProviderEventLoggers.ProviderEventLoggers, | |
| 58 | ProviderEventLoggers.NoOpProviderEventLoggers, | |
| 59 | ), | |
| 60 | Layer.succeed(OpenCodeRuntime.OpenCodeRuntime, openCode2Runtime), | |
| 61 | ).pipe(Layer.provideMerge(NodeServices.layer)); | |
| 62 | const layer = ProviderHostLive.layer.pipe(Layer.provideMerge(layerDeps)); | |
| 63 | ||
| 64 | const create = (config: Partial<OpenCodeSettings>, http: HttpClient.HttpClient) => | |
| 65 | OpenCodeDriver.create({ | |
| 66 | instanceId: ProviderInstanceId.make("opencode-test"), | |
| 67 | displayName: undefined, | |
| 68 | environment: [], | |
| 69 | enabled: true, | |
| 70 | config: { ...OpenCodeDriver.defaultConfig(), ...config }, | |
| 71 | }).pipe(Effect.provideService(HttpClient.HttpClient, http)); | |
| 72 | ||
| 73 | const noHttp = HttpClient.make(() => Effect.die("A local binary must not be probed over HTTP")); | |
| 74 | ||
| 75 | it.layer(layer)("OpenCodeDriver runtime selection", (it) => { | |
| 76 | it.effect("never starts a 1.x server for an OpenCode 2 instance", () => | |
| 77 | Effect.gen(function* () { | |
| 78 | serverStarts.length = 0; | |
| 79 | const instance = yield* create({}, noHttp); | |
| 80 | // Text generation goes to the instance's 2.x server, which this test refuses to start. | |
| 81 | yield* Effect.flip( | |
| 82 | instance.textGeneration.generateThreadTitle({ | |
| 83 | cwd: process.cwd(), | |
| 84 | message: "hello", | |
| 85 | modelSelection: { instanceId: instance.instanceId, model: "opencode/big-pickle" }, | |
| 86 | }), | |
| 87 | ); | |
| 88 | assert.deepStrictEqual(serverStarts, ["start"]); | |
| 89 | }).pipe(Effect.scoped), | |
| 90 | ); | |
| 91 | ||
| 92 | it.effect("lists an OpenCode 2 workspace's own skills and commands from its server", () => | |
| 93 | Effect.gen(function* () { | |
| 94 | serverStarts.length = 0; | |
| 95 | const requested: Array<string> = []; | |
| 96 | const server = replayOpenCodeServer( | |
| 97 | { ...OPENCODE_2_RESPONSES, ...OPENCODE_2_WORKSPACE_RESPONSES }, | |
| 98 | "secret", | |
| 99 | requested, | |
| 100 | ); | |
| 101 | const instance = yield* create( | |
| 102 | { serverUrl: "http://127.0.0.1:4096", serverPassword: "secret" }, | |
| 103 | server, | |
| 104 | ); | |
| 105 | ||
| 106 | const workspace = yield* instance.snapshotForCwd!("/work"); | |
| 107 | assert.includeMembers( | |
| 108 | workspace.skills.map((skill) => skill.name), | |
| 109 | ["plum", "opencode"], | |
| 110 | ); | |
| 111 | assert.deepStrictEqual( | |
| 112 | workspace.slashCommands.map((command) => command.name), | |
| 113 | ["compact", "init", "review", "bee"], | |
| 114 | ); | |
| 115 | assert.include(requested, "/api/skill"); | |
| 116 | assert.deepStrictEqual(serverStarts, []); | |
| 117 | }).pipe(Effect.scoped), | |
| 118 | ); | |
| 119 | ||
| 120 | it.effect("answers capability reads without waiting on an unreachable server", () => | |
| 121 | Effect.gen(function* () { | |
| 122 | const hang = HttpClient.make(() => Effect.never); | |
| 123 | const instance = yield* create({ serverUrl: "http://127.0.0.1:9" }, hang); | |
| 124 | ||
| 125 | // No probe has succeeded yet, and the server never answers: the 1.x default applies. | |
| 126 | const capabilities = yield* instance.orchestrationAdapter | |
| 127 | .getCapabilities() | |
| 128 | .pipe(Effect.forkChild); | |
| 129 | yield* Effect.yieldNow; | |
| 130 | assert.isDefined(capabilities.pollUnsafe()); | |
| 131 | yield* Fiber.join(capabilities); | |
| 132 | }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), | |
| 133 | ); | |
| 134 | }); | |
| 135 | ||
| 136 | // npm links `<prefix>/bin/opencode` into the package that installed it: | |
| 137 | // `opencode-ai` for 1.x and `@opencode/cli` (whose bin is `bin/opencode.exe`) for 2.x. | |
| 138 | const npmGlobalInstall = (prefix: string, packagePath: ReadonlyArray<string>, bin: string) => { | |
| 139 | const target = NodePath.join(prefix, "lib", "node_modules", ...packagePath, "bin", bin); | |
| 140 | NodeFS.mkdirSync(NodePath.dirname(target), { recursive: true }); | |
| 141 | NodeFS.writeFileSync(target, "#!/bin/sh\n"); | |
| 142 | NodeFS.chmodSync(target, 0o755); | |
| 143 | const link = NodePath.join(prefix, "bin", "opencode"); | |
| 144 | NodeFS.mkdirSync(NodePath.dirname(link), { recursive: true }); | |
| 145 | NodeFS.symlinkSync(target, link); | |
| 146 | return link; | |
| 147 | }; | |
| 148 | const resolveUpdate = (generation: "v1" | "v2", binaryPath: string) => | |
| 149 | ProviderMaintenance.resolveProviderMaintenanceCapabilitiesEffect(openCodeUpdateFor(generation), { | |
| 150 | binaryPath, | |
| 151 | env: { PATH: "" }, | |
| 152 | }).pipe( | |
| 153 | Effect.provideService( | |
| 154 | ChildProcessSpawner.ChildProcessSpawner, | |
| 155 | ChildProcessSpawner.make(() => Effect.die("resolving an update must not spawn")), | |
| 156 | ), | |
| 157 | Effect.provide(NodeServices.layer), | |
| 158 | ); | |
| 159 | ||
| 160 | it.effect.skipIf(HostProcessPlatform.defaultValue() === "win32")( | |
| 161 | "updates an npm-global OpenCode 2 install as @opencode/cli and a 1.x one as opencode-ai", | |
| 162 | () => | |
| 163 | Effect.gen(function* () { | |
| 164 | const root = NodeFS.realpathSync( | |
| 165 | NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-opencode-update-")), | |
| 166 | ); | |
| 167 | const v2Prefix = NodePath.join(root, "v2"); | |
| 168 | const v1Prefix = NodePath.join(root, "v1"); | |
| 169 | const v2 = yield* resolveUpdate( | |
| 170 | "v2", | |
| 171 | npmGlobalInstall(v2Prefix, ["@opencode", "cli"], "opencode.exe"), | |
| 172 | ); | |
| 173 | assert.deepStrictEqual(v2.update?.args, [ | |
| 174 | "install", | |
| 175 | "-g", | |
| 176 | "--prefix", | |
| 177 | v2Prefix, | |
| 178 | "--allow-scripts=@opencode/cli", | |
| 179 | "@opencode/cli@latest", | |
| 180 | ]); | |
| 181 | const v1 = yield* resolveUpdate( | |
| 182 | "v1", | |
| 183 | npmGlobalInstall(v1Prefix, ["opencode-ai"], "opencode"), | |
| 184 | ); | |
| 185 | assert.strictEqual(v1.update?.args.at(-1), "opencode-ai@latest"); | |
| 186 | // A 1.x install is never offered 2.x's package, or the reverse. | |
| 187 | assert.isNull( | |
| 188 | (yield* resolveUpdate("v2", NodePath.join(v1Prefix, "bin", "opencode"))).update, | |
| 189 | ); | |
| 190 | assert.isNull( | |
| 191 | (yield* resolveUpdate("v1", NodePath.join(v2Prefix, "bin", "opencode"))).update, | |
| 192 | ); | |
| 193 | }), | |
| 194 | ); | |
| 195 | ||
| 196 | // The update the driver offers follows the version the binary reports now. | |
| 197 | const versionOutput: { current: string | undefined } = { current: undefined }; | |
| 198 | const versionProbes: Array<string> = []; | |
| 199 | const changingRuntime = { | |
| 200 | runOpenCodeCommand: ({ binaryPath }: { readonly binaryPath: string }) => | |
| 201 | Effect.sync(() => versionProbes.push(binaryPath)).pipe( | |
| 202 | Effect.andThen( | |
| 203 | versionOutput.current === undefined | |
| 204 | ? Effect.fail( | |
| 205 | new OpenCodeRuntime.OpenCodeRuntimeError({ | |
| 206 | operation: "version", | |
| 207 | detail: "no version", | |
| 208 | }), | |
| 209 | ) | |
| 210 | : Effect.succeed({ stdout: versionOutput.current, stderr: "", code: 0 }), | |
| 211 | ), | |
| 212 | ), | |
| 213 | startOpenCodeServerProcess: () => reachedServer("start"), | |
| 214 | connectToOpenCodeServer: () => reachedServer("connect"), | |
| 215 | } as unknown as OpenCodeRuntime.OpenCodeRuntimeShape; | |
| 216 | const layerUpdateDeps = Layer.mergeAll( | |
| 217 | ServerConfig.layerTest(process.cwd(), { prefix: "t3-opencode-driver-update-" }), | |
| 218 | IdAllocator.layer, | |
| 219 | ServerSettings.layerTest(), | |
| 220 | Layer.mock(BackgroundPolicy.BackgroundPolicy)({}), | |
| 221 | Layer.succeed( | |
| 222 | ProviderEventLoggers.ProviderEventLoggers, | |
| 223 | ProviderEventLoggers.NoOpProviderEventLoggers, | |
| 224 | ), | |
| 225 | Layer.succeed(OpenCodeRuntime.OpenCodeRuntime, changingRuntime), | |
| 226 | ).pipe(Layer.provideMerge(NodeServices.layer)); | |
| 227 | const layerUpdate = ProviderHostLive.layer.pipe(Layer.provideMerge(layerUpdateDeps)); | |
| 228 | ||
| 229 | it.layer(layerUpdate)("OpenCodeDriver updates", (it) => { | |
| 230 | it.effect("never runs the binary for a disabled instance's update check", () => | |
| 231 | Effect.gen(function* () { | |
| 232 | versionProbes.length = 0; | |
| 233 | versionOutput.current = "opencode v2.0.18\n"; | |
| 234 | const instance = yield* OpenCodeDriver.create({ | |
| 235 | instanceId: ProviderInstanceId.make("opencode-disabled"), | |
| 236 | displayName: undefined, | |
| 237 | environment: [], | |
| 238 | enabled: false, | |
| 239 | config: { ...OpenCodeDriver.defaultConfig(), binaryPath: "opencode" }, | |
| 240 | }).pipe(Effect.provideService(HttpClient.HttpClient, noHttp)); | |
| 241 | const maintenance = yield* instance.snapshot.resolveMaintenance(); | |
| 242 | yield* instance.snapshot.resolveMaintenance({ fresh: true }); | |
| 243 | assert.isNull(maintenance.update); | |
| 244 | assert.deepStrictEqual(versionProbes, []); | |
| 245 | }).pipe(Effect.scoped), | |
| 246 | ); | |
| 247 | ||
| 248 | it.effect.skipIf(HostProcessPlatform.defaultValue() === "win32")( | |
| 249 | "offers no package update for an unknown version and follows a changed one on a fresh read", | |
| 250 | () => | |
| 251 | Effect.gen(function* () { | |
| 252 | const root = NodeFS.realpathSync( | |
| 253 | NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-opencode-driver-update-")), | |
| 254 | ); | |
| 255 | const binaryPath = npmGlobalInstall(NodePath.join(root, "v1"), ["opencode-ai"], "opencode"); | |
| 256 | const instance = yield* OpenCodeDriver.create({ | |
| 257 | instanceId: ProviderInstanceId.make("opencode-update"), | |
| 258 | displayName: undefined, | |
| 259 | environment: [], | |
| 260 | enabled: true, | |
| 261 | config: { ...OpenCodeDriver.defaultConfig(), binaryPath }, | |
| 262 | }).pipe(Effect.provideService(HttpClient.HttpClient, noHttp)); | |
| 263 | ||
| 264 | // The version could not be read: which package owns the binary is unknown. | |
| 265 | versionOutput.current = undefined; | |
| 266 | const unknown = yield* instance.snapshot.resolveMaintenance({ fresh: true }); | |
| 267 | assert.isNull(unknown.update); | |
| 268 | assert.isNull(unknown.packageName); | |
| 269 | ||
| 270 | versionOutput.current = "1.18.32\n"; | |
| 271 | const v1 = yield* instance.snapshot.resolveMaintenance({ fresh: true }); | |
| 272 | assert.strictEqual(v1.packageName, "opencode-ai"); | |
| 273 | ||
| 274 | // The same path now reports 2.x (reinstalled in place): a fresh read re-probes | |
| 275 | // and never offers 1.x's package for it. | |
| 276 | versionOutput.current = "opencode v2.0.18\n"; | |
| 277 | const v2 = yield* instance.snapshot.resolveMaintenance({ fresh: true }); | |
| 278 | assert.strictEqual(v2.packageName, "@opencode/cli"); | |
| 279 | assert.isNull(v2.update); | |
| 280 | }).pipe(Effect.scoped), | |
| 281 | ); | |
| 282 | }); |
apps/server/src/provider/Drivers/OpenCodeDriver.ts deleted-542| ... | ... | @@ -1,542 +0,0 @@ |
| 1 | /** | |
| 2 | * OpenCodeDriver — `ProviderDriver` for the OpenCode runtime. | |
| 3 | * | |
| 4 | * Mirrors the Codex / Claude drivers: a plain value whose `create()` | |
| 5 | * bundles `snapshot` / `adapter` / `textGeneration` closures over the | |
| 6 | * per-instance `OpenCodeSettings`. | |
| 7 | * | |
| 8 | * Two instances with different `serverUrl`s therefore talk to independent | |
| 9 | * OpenCode servers; when no `serverUrl` is set, the adapter + text-generation | |
| 10 | * shares spin up their own scoped child processes, and those child | |
| 11 | * processes are released when the registry scope closes. | |
| 12 | * | |
| 13 | * @module provider/Drivers/OpenCodeDriver | |
| 14 | */ | |
| 15 | import { OpenCodeSettings, ProviderDriverKind } from "@t3tools/contracts"; | |
| 16 | import * as Crypto from "effect/Crypto"; | |
| 17 | import * as Deferred from "effect/Deferred"; | |
| 18 | import * as Effect from "effect/Effect"; | |
| 19 | import * as Option from "effect/Option"; | |
| 20 | import * as FileSystem from "effect/FileSystem"; | |
| 21 | import * as Path from "effect/Path"; | |
| 22 | import * as Schema from "effect/Schema"; | |
| 23 | import * as Stream from "effect/Stream"; | |
| 24 | import { HttpClient } from "effect/http"; | |
| 25 | import { ChildProcessSpawner } from "effect/process"; | |
| 26 | ||
| 27 | import * as OpenCode2TextGeneration from "../../textGeneration/OpenCode2TextGeneration.ts"; | |
| 28 | import { makeOpenCodeTextGeneration } from "../../textGeneration/OpenCodeTextGeneration.ts"; | |
| 29 | import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost"; | |
| 30 | import * as ServerConfig from "../../config.ts"; | |
| 31 | import * as OpenCodeAdapterV2 from "../../orchestration-v2/Adapters/OpenCodeAdapterV2.ts"; | |
| 32 | import * as OpenCode2AdapterV2 from "../../orchestration-v2/Adapters/OpenCode2AdapterV2.ts"; | |
| 33 | import type { ProviderAdapterV2Shape } from "@t3tools/provider-core/server/ProviderAdapter"; | |
| 34 | import type { TextGeneration } from "../../textGeneration/TextGeneration.ts"; | |
| 35 | import { ProviderDriverError } from "../Errors.ts"; | |
| 36 | import { readOpenCodeGoUsageLimits } from "../openCodeUsageLimits.ts"; | |
| 37 | import { | |
| 38 | checkOpenCodeProviderStatus, | |
| 39 | loadOpenCode2Workspace, | |
| 40 | makeOpenCode2ModelLoader, | |
| 41 | makePendingOpenCodeProvider, | |
| 42 | openCode2CommandsToServerProviderSlashCommands, | |
| 43 | openCode2SkillsToServerProviderSkills, | |
| 44 | openCodeSkillsToServerProviderSkills, | |
| 45 | openCodeCommandsToServerProviderSlashCommands, | |
| 46 | } from "../OpenCodeProvider.ts"; | |
| 47 | import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; | |
| 48 | import * as OpenCodeRuntime from "../opencodeRuntime.ts"; | |
| 49 | import { | |
| 50 | makeOpenCodeRuntimeProbe, | |
| 51 | probeOpenCodeRuntime, | |
| 52 | type ProbedOpenCode, | |
| 53 | } from "../opencodeVersionProbe.ts"; | |
| 54 | import * as OpenCodeServerOwner from "../OpenCodeServerOwner.ts"; | |
| 55 | import * as OpenCode2Client from "../opencode2/OpenCode2Client.ts"; | |
| 56 | import * as OpenCode2Server from "../opencode2/OpenCode2Server.ts"; | |
| 57 | import { | |
| 58 | defaultProviderContinuationIdentity, | |
| 59 | type ProviderDriver, | |
| 60 | type ProviderInstance, | |
| 61 | } from "@t3tools/provider-core/server/driver"; | |
| 62 | import { withInstanceIdentity } from "@t3tools/provider-core/server/instanceIdentity"; | |
| 63 | import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; | |
| 64 | import { | |
| 65 | enrichProviderSnapshotWithVersionAdvisory, | |
| 66 | makeCachedProviderMaintenanceResolution, | |
| 67 | makeManualOnlyProviderMaintenanceCapabilities, | |
| 68 | makePackageManagedProviderMaintenanceResolver, | |
| 69 | normalizeCommandPath, | |
| 70 | resolveProviderMaintenanceCapabilitiesEffect, | |
| 71 | } from "@t3tools/provider-core/server/maintenanceResolver"; | |
| 72 | import { | |
| 73 | haveProviderSnapshotSettingsChanged, | |
| 74 | makeProviderSnapshotSettingsSource, | |
| 75 | type ProviderSnapshotSettings, | |
| 76 | } from "@t3tools/provider-core/server/snapshotSettings"; | |
| 77 | const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings); | |
| 78 | ||
| 79 | const DRIVER_KIND = ProviderDriverKind.make("opencode"); | |
| 80 | ||
| 81 | function isOpenCodeNativeCommandPath(commandPath: string): boolean { | |
| 82 | const normalized = normalizeCommandPath(commandPath); | |
| 83 | return ( | |
| 84 | normalized.endsWith("/.opencode/bin/opencode") || | |
| 85 | normalized.endsWith("/.opencode/bin/opencode.exe") | |
| 86 | ); | |
| 87 | } | |
| 88 | ||
| 89 | /** | |
| 90 | * OpenCode 1.x ships as `opencode-ai` and 2.x as `@opencode/cli`. An install is | |
| 91 | * only ever updated within its own package: T3 never moves a 1.x install onto | |
| 92 | * 2.x or back, since 2.x converts the shared database in place. | |
| 93 | */ | |
| 94 | export const openCodeUpdateFor = (generation: ProbedOpenCode["generation"]) => | |
| 95 | makePackageManagedProviderMaintenanceResolver({ | |
| 96 | provider: DRIVER_KIND, | |
| 97 | npmPackageName: generation === "v2" ? "@opencode/cli" : "opencode-ai", | |
| 98 | nativeUpdate: { | |
| 99 | args: ["upgrade"], | |
| 100 | isCommandPath: isOpenCodeNativeCommandPath, | |
| 101 | }, | |
| 102 | }); | |
| 103 | ||
| 104 | type OpenCodeRuntimeProbe = Effect.Success< | |
| 105 | ReturnType<typeof makeOpenCodeRuntimeProbe<OpenCodeRuntime.OpenCodeRuntimeError>> | |
| 106 | >; | |
| 107 | ||
| 108 | /** Runs `v2` for a 2.x instance and `v1` otherwise; a failed probe keeps 1.x, whose checks report it. */ | |
| 109 | function byOpenCodeRuntime<A, E, R, PE>( | |
| 110 | probed: Effect.Effect<ProbedOpenCode | undefined, PE>, | |
| 111 | paths: { readonly v1: Effect.Effect<A, E, R>; readonly v2: Effect.Effect<A, E, R> }, | |
| 112 | ): Effect.Effect<A, E, R> { | |
| 113 | return probed.pipe( | |
| 114 | Effect.orElseSucceed(() => undefined), | |
| 115 | Effect.flatMap((result) => (result?.generation === "v2" ? paths.v2 : paths.v1)), | |
| 116 | ); | |
| 117 | } | |
| 118 | ||
| 119 | /** | |
| 120 | * Routes each adapter call to the runtime the instance's probe detected. Capability and selection | |
| 121 | * reads are hot, so they use the last successful probe (1.x before one lands) and never wait on a | |
| 122 | * slow server. Opening a session waits for a probe, so each server is spoken to in its own protocol. | |
| 123 | */ | |
| 124 | function selectOpenCodeRuntimeAdapter(input: { | |
| 125 | readonly probe: OpenCodeRuntimeProbe; | |
| 126 | readonly v1: ProviderAdapterV2Shape; | |
| 127 | readonly v2: ProviderAdapterV2Shape; | |
| 128 | }): ProviderAdapterV2Shape { | |
| 129 | const pick = <PE>(probed: Effect.Effect<ProbedOpenCode | undefined, PE>) => | |
| 130 | byOpenCodeRuntime(probed, { v1: Effect.succeed(input.v1), v2: Effect.succeed(input.v2) }); | |
| 131 | const hot = pick(Effect.map(input.probe.lastSuccess, Option.getOrUndefined)); | |
| 132 | return { | |
| 133 | instanceId: input.v1.instanceId, | |
| 134 | driver: DRIVER_KIND, | |
| 135 | getCapabilities: () => Effect.flatMap(hot, (adapter) => adapter.getCapabilities()), | |
| 136 | planSelectionTransition: (transition) => | |
| 137 | Effect.flatMap(hot, (adapter) => adapter.planSelectionTransition(transition)), | |
| 138 | openSession: (session) => | |
| 139 | Effect.flatMap(pick(input.probe.get), (adapter) => adapter.openSession(session)), | |
| 140 | }; | |
| 141 | } | |
| 142 | ||
| 143 | /** Text generation runs on the server the instance's probe detected, each in its own protocol. */ | |
| 144 | function selectOpenCodeRuntimeTextGeneration( | |
| 145 | probe: OpenCodeRuntimeProbe, | |
| 146 | v1: TextGeneration["Service"], | |
| 147 | v2: TextGeneration["Service"], | |
| 148 | ): TextGeneration["Service"] { | |
| 149 | return { | |
| 150 | generateCommitMessage: (input) => | |
| 151 | byOpenCodeRuntime(probe.get, { | |
| 152 | v1: v1.generateCommitMessage(input), | |
| 153 | v2: v2.generateCommitMessage(input), | |
| 154 | }), | |
| 155 | generatePrContent: (input) => | |
| 156 | byOpenCodeRuntime(probe.get, { | |
| 157 | v1: v1.generatePrContent(input), | |
| 158 | v2: v2.generatePrContent(input), | |
| 159 | }), | |
| 160 | generateBranchName: (input) => | |
| 161 | byOpenCodeRuntime(probe.get, { | |
| 162 | v1: v1.generateBranchName(input), | |
| 163 | v2: v2.generateBranchName(input), | |
| 164 | }), | |
| 165 | generateThreadTitle: (input) => | |
| 166 | byOpenCodeRuntime(probe.get, { | |
| 167 | v1: v1.generateThreadTitle(input), | |
| 168 | v2: v2.generateThreadTitle(input), | |
| 169 | }), | |
| 170 | }; | |
| 171 | } | |
| 172 | ||
| 173 | export type OpenCodeDriverEnv = | |
| 174 | | OpenCodeAdapterV2.OpenCodeAdapterV2DriverEnv | |
| 175 | | ProviderHost | |
| 176 | | ChildProcessSpawner.ChildProcessSpawner | |
| 177 | | Crypto.Crypto | |
| 178 | | FileSystem.FileSystem | |
| 179 | | HttpClient.HttpClient | |
| 180 | | OpenCodeRuntime.OpenCodeRuntime | |
| 181 | | Path.Path | |
| 182 | | ServerConfig.ServerConfig; | |
| 183 | ||
| 184 | export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv> = { | |
| 185 | driverKind: DRIVER_KIND, | |
| 186 | metadata: { | |
| 187 | displayName: "OpenCode", | |
| 188 | supportsMultipleInstances: true, | |
| 189 | }, | |
| 190 | configSchema: OpenCodeSettings, | |
| 191 | defaultConfig: (): OpenCodeSettings => decodeOpenCodeSettings({}), | |
| 192 | create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => | |
| 193 | Effect.gen(function* () { | |
| 194 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 195 | const fileSystem = yield* FileSystem.FileSystem; | |
| 196 | const pathService = yield* Path.Path; | |
| 197 | const openCodeRuntime = yield* OpenCodeRuntime.OpenCodeRuntime; | |
| 198 | const serverConfig = yield* ServerConfig.ServerConfig; | |
| 199 | const httpClient = yield* HttpClient.HttpClient; | |
| 200 | const crypto = yield* Crypto.Crypto; | |
| 201 | const host = yield* ProviderHost; | |
| 202 | const processEnv = mergeProviderInstanceEnvironment(environment); | |
| 203 | const continuationIdentity = defaultProviderContinuationIdentity({ | |
| 204 | driverKind: DRIVER_KIND, | |
| 205 | instanceId, | |
| 206 | }); | |
| 207 | const stampIdentity = withInstanceIdentity({ | |
| 208 | instanceId, | |
| 209 | driverKind: DRIVER_KIND, | |
| 210 | displayName, | |
| 211 | accentColor, | |
| 212 | continuationGroupKey: continuationIdentity.continuationKey, | |
| 213 | }); | |
| 214 | const effectiveConfig = { ...config, enabled } satisfies OpenCodeSettings; | |
| 215 | const runtimeProbe = yield* makeOpenCodeRuntimeProbe( | |
| 216 | probeOpenCodeRuntime(effectiveConfig, processEnv).pipe( | |
| 217 | Effect.provideService(HttpClient.HttpClient, httpClient), | |
| 218 | Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, openCodeRuntime), | |
| 219 | ), | |
| 220 | ); | |
| 221 | // Updates follow the installed package, which the version probe names. An | |
| 222 | // unknown version offers no package update, since a guess could move a | |
| 223 | // 2.x install onto 1.x's package or the reverse. A disabled instance never | |
| 224 | // runs its binary, so it has no version and offers no update. | |
| 225 | const noUpdate = makeManualOnlyProviderMaintenanceCapabilities({ | |
| 226 | provider: DRIVER_KIND, | |
| 227 | packageName: null, | |
| 228 | }); | |
| 229 | const maintenanceFor = (probed: typeof runtimeProbe.get) => | |
| 230 | probed.pipe( | |
| 231 | Effect.map((result) => result.generation), | |
| 232 | Effect.option, | |
| 233 | Effect.flatMap((generation) => | |
| 234 | Option.isNone(generation) | |
| 235 | ? Effect.succeed(noUpdate) | |
| 236 | : resolveProviderMaintenanceCapabilitiesEffect(openCodeUpdateFor(generation.value), { | |
| 237 | binaryPath: effectiveConfig.binaryPath, | |
| 238 | env: processEnv, | |
| 239 | }), | |
| 240 | ), | |
| 241 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | |
| 242 | Effect.provideService(FileSystem.FileSystem, fileSystem), | |
| 243 | Effect.provideService(Path.Path, pathService), | |
| 244 | ); | |
| 245 | const cachedMaintenance = yield* makeCachedProviderMaintenanceResolution( | |
| 246 | maintenanceFor(runtimeProbe.get), | |
| 247 | ); | |
| 248 | // A fresh read (an update about to run, or a manual refresh) re-probes, so | |
| 249 | // a binary replaced by the other major version gets its own package. | |
| 250 | const resolveMaintenance = (options?: { readonly fresh?: boolean }) => | |
| 251 | !effectiveConfig.enabled | |
| 252 | ? Effect.succeed(noUpdate) | |
| 253 | : options?.fresh === true | |
| 254 | ? runtimeProbe.refresh.pipe( | |
| 255 | Effect.ignore, | |
| 256 | Effect.andThen(cachedMaintenance({ fresh: true })), | |
| 257 | ) | |
| 258 | : cachedMaintenance(); | |
| 259 | const openCodeV1Adapter = yield* OpenCodeAdapterV2.OpenCodeAdapterV2Driver.create({ | |
| 260 | instanceId, | |
| 261 | displayName, | |
| 262 | accentColor, | |
| 263 | environment, | |
| 264 | enabled, | |
| 265 | config, | |
| 266 | }).pipe( | |
| 267 | Effect.mapError( | |
| 268 | (cause) => | |
| 269 | new ProviderDriverError({ | |
| 270 | driver: DRIVER_KIND, | |
| 271 | instanceId, | |
| 272 | detail: "Failed to build OpenCode orchestration adapter.", | |
| 273 | cause, | |
| 274 | }), | |
| 275 | ), | |
| 276 | ); | |
| 277 | // One OpenCode 2 server per instance, spawned on first use or reached at `serverUrl`. | |
| 278 | const openCode2Server = yield* OpenCode2Server.make({ | |
| 279 | binaryPath: effectiveConfig.binaryPath, | |
| 280 | serverUrl: effectiveConfig.serverUrl, | |
| 281 | serverPassword: effectiveConfig.serverPassword, | |
| 282 | directory: serverConfig.cwd, | |
| 283 | environment: processEnv, | |
| 284 | }).pipe( | |
| 285 | Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, openCodeRuntime), | |
| 286 | Effect.provideService( | |
| 287 | OpenCode2Client.OpenCode2Client, | |
| 288 | yield* OpenCode2Client.make.pipe( | |
| 289 | Effect.provideService(HttpClient.HttpClient, httpClient), | |
| 290 | ), | |
| 291 | ), | |
| 292 | ); | |
| 293 | const orchestrationAdapter = selectOpenCodeRuntimeAdapter({ | |
| 294 | probe: runtimeProbe, | |
| 295 | v1: openCodeV1Adapter, | |
| 296 | v2: yield* OpenCode2AdapterV2.make(instanceId).pipe( | |
| 297 | Effect.provideService(OpenCode2Server.OpenCode2Server, openCode2Server), | |
| 298 | ), | |
| 299 | }); | |
| 300 | const loadOpenCode2Models = yield* makeOpenCode2ModelLoader( | |
| 301 | openCode2Server.withConnection((connection) => | |
| 302 | connection.client.model.list({ location: { directory: serverConfig.cwd } }).pipe( | |
| 303 | Effect.map((models) => models.data), | |
| 304 | Effect.mapError( | |
| 305 | (cause) => | |
| 306 | new OpenCodeRuntime.OpenCodeRuntimeError({ | |
| 307 | operation: "model.list", | |
| 308 | detail: "The OpenCode server could not list its models.", | |
| 309 | cause, | |
| 310 | }), | |
| 311 | ), | |
| 312 | ), | |
| 313 | ), | |
| 314 | ); | |
| 315 | // A 2.x server lists skills and commands per directory, so one server | |
| 316 | // answers every workspace. Its event stream says when a directory it had | |
| 317 | // not served yet finished scanning. | |
| 318 | const listOpenCode2Workspace = (cwd: string) => | |
| 319 | openCode2Server.withConnection(({ client, events }) => | |
| 320 | Effect.gen(function* () { | |
| 321 | const location = { directory: cwd }; | |
| 322 | const scanned = yield* Deferred.make<void>(); | |
| 323 | const pending = new Set(["command.updated", "skill.updated"]); | |
| 324 | const stream = yield* events.pipe(Effect.option); | |
| 325 | if (stream._tag === "Some") { | |
| 326 | yield* stream.value.pipe( | |
| 327 | Stream.runForEach((event) => | |
| 328 | "location" in event && | |
| 329 | event.location?.directory === cwd && | |
| 330 | pending.delete(event.type) && | |
| 331 | pending.size === 0 | |
| 332 | ? Deferred.succeed(scanned, undefined) | |
| 333 | : Effect.void, | |
| 334 | ), | |
| 335 | Effect.ignore, | |
| 336 | Effect.forkScoped, | |
| 337 | ); | |
| 338 | } | |
| 339 | return yield* loadOpenCode2Workspace( | |
| 340 | Effect.all( | |
| 341 | { | |
| 342 | skills: client.skill.list({ location }).pipe(Effect.map((list) => list.data)), | |
| 343 | commands: client.command.list({ location }).pipe(Effect.map((list) => list.data)), | |
| 344 | }, | |
| 345 | { concurrency: "unbounded" }, | |
| 346 | ), | |
| 347 | Deferred.await(scanned), | |
| 348 | ); | |
| 349 | }).pipe(Effect.scoped), | |
| 350 | ); | |
| 351 | const serverOwner = yield* OpenCodeServerOwner.make({ | |
| 352 | binaryPath: effectiveConfig.binaryPath, | |
| 353 | directory: serverConfig.cwd, | |
| 354 | ...(effectiveConfig.serverPassword | |
| 355 | ? { serverPassword: effectiveConfig.serverPassword } | |
| 356 | : {}), | |
| 357 | environment: processEnv, | |
| 358 | }); | |
| 359 | const textGeneration = selectOpenCodeRuntimeTextGeneration( | |
| 360 | runtimeProbe, | |
| 361 | yield* makeOpenCodeTextGeneration(effectiveConfig).pipe( | |
| 362 | Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner), | |
| 363 | ), | |
| 364 | yield* OpenCode2TextGeneration.make().pipe( | |
| 365 | Effect.provideService(OpenCode2Server.OpenCode2Server, openCode2Server), | |
| 366 | ), | |
| 367 | ); | |
| 368 | ||
| 369 | const checkProvider = Effect.all( | |
| 370 | { | |
| 371 | provider: checkOpenCodeProviderStatus( | |
| 372 | effectiveConfig, | |
| 373 | serverConfig.cwd, | |
| 374 | runtimeProbe.refresh, | |
| 375 | loadOpenCode2Models, | |
| 376 | ), | |
| 377 | usageLimits: readOpenCodeGoUsageLimits({ | |
| 378 | enabled: effectiveConfig.enabled, | |
| 379 | serverUrl: effectiveConfig.serverUrl, | |
| 380 | environment: processEnv, | |
| 381 | }), | |
| 382 | }, | |
| 383 | { concurrency: "unbounded" }, | |
| 384 | ).pipe( | |
| 385 | Effect.map(({ provider, usageLimits }) => ({ ...provider, usageLimits })), | |
| 386 | Effect.map(stampIdentity), | |
| 387 | Effect.provideService(FileSystem.FileSystem, fileSystem), | |
| 388 | Effect.provideService(Path.Path, pathService), | |
| 389 | Effect.provideService(HttpClient.HttpClient, httpClient), | |
| 390 | Effect.provideService(Crypto.Crypto, crypto), | |
| 391 | Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner), | |
| 392 | Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, openCodeRuntime), | |
| 393 | ); | |
| 394 | // NOTE: the local branch intentionally uses the shared SDK server | |
| 395 | // instead of `opencode debug skill` (loadSkillsFromCli). The CLI writes | |
| 396 | // its full JSON inventory to stdout, but the Bun-compiled binary does | |
| 397 | // not flush more than one 64KB pipe buffer to a non-TTY stdout, so the | |
| 398 | // piped output arrives truncated and unparseable — which degrades to an | |
| 399 | // empty skill list and poisons the workspace snapshot the `$` picker | |
| 400 | // reads. The SDK `app.skills` endpoint honors the per-request directory | |
| 401 | // and returns complete results regardless of size. | |
| 402 | const loadWorkspaceInventory = ( | |
| 403 | client: Parameters<typeof OpenCodeRuntime.loadOpenCodeCommands>[0], | |
| 404 | ) => | |
| 405 | Effect.all( | |
| 406 | { | |
| 407 | skills: openCodeRuntime.loadOpenCodeSkills(client), | |
| 408 | commands: OpenCodeRuntime.loadOpenCodeCommands(client).pipe( | |
| 409 | Effect.timeout("10 seconds"), | |
| 410 | Effect.orElseSucceed(() => []), | |
| 411 | ), | |
| 412 | }, | |
| 413 | { concurrency: "unbounded" }, | |
| 414 | ); | |
| 415 | const loadWorkspaceForCwd = (cwd: string) => | |
| 416 | effectiveConfig.serverUrl.trim().length > 0 | |
| 417 | ? Effect.scoped( | |
| 418 | Effect.gen(function* () { | |
| 419 | const server = yield* openCodeRuntime.connectToOpenCodeServer({ | |
| 420 | binaryPath: effectiveConfig.binaryPath, | |
| 421 | directory: cwd, | |
| 422 | serverUrl: effectiveConfig.serverUrl, | |
| 423 | ...(effectiveConfig.serverPassword | |
| 424 | ? { serverPassword: effectiveConfig.serverPassword } | |
| 425 | : {}), | |
| 426 | environment: processEnv, | |
| 427 | }); | |
| 428 | const client = openCodeRuntime.createOpenCodeSdkClient({ | |
| 429 | baseUrl: server.url, | |
| 430 | directory: cwd, | |
| 431 | ...(effectiveConfig.serverPassword | |
| 432 | ? { serverPassword: effectiveConfig.serverPassword } | |
| 433 | : {}), | |
| 434 | }); | |
| 435 | return yield* loadWorkspaceInventory(client); | |
| 436 | }), | |
| 437 | ) | |
| 438 | : serverOwner.withServer((server) => | |
| 439 | loadWorkspaceInventory( | |
| 440 | openCodeRuntime.createOpenCodeSdkClient({ | |
| 441 | baseUrl: server.url, | |
| 442 | directory: cwd, | |
| 443 | ...(server.serverPassword !== undefined | |
| 444 | ? { serverPassword: server.serverPassword } | |
| 445 | : {}), | |
| 446 | }), | |
| 447 | ), | |
| 448 | ); | |
| 449 | ||
| 450 | const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, host.settings); | |
| 451 | const snapshot = yield* makeManagedServerProvider<ProviderSnapshotSettings<OpenCodeSettings>>( | |
| 452 | { | |
| 453 | resolveMaintenance, | |
| 454 | getSettings: snapshotSettings.getSettings, | |
| 455 | streamSettings: snapshotSettings.streamSettings, | |
| 456 | haveSettingsChanged: haveProviderSnapshotSettingsChanged, | |
| 457 | checkProviderOnSettingsChange: () => false, | |
| 458 | refreshOnInterval: false, | |
| 459 | initialSnapshot: (settings) => | |
| 460 | makePendingOpenCodeProvider(settings.provider).pipe(Effect.map(stampIdentity)), | |
| 461 | checkProvider, | |
| 462 | enrichSnapshot: ({ settings, snapshot, publishSnapshot }) => | |
| 463 | resolveMaintenance().pipe( | |
| 464 | Effect.flatMap((maintenanceCapabilities) => | |
| 465 | enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities, { | |
| 466 | enableProviderUpdateChecks: settings.enableProviderUpdateChecks, | |
| 467 | }), | |
| 468 | ), | |
| 469 | Effect.provideService(HttpClient.HttpClient, httpClient), | |
| 470 | Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), | |
| 471 | ), | |
| 472 | }, | |
| 473 | ).pipe( | |
| 474 | Effect.mapError( | |
| 475 | (cause) => | |
| 476 | new ProviderDriverError({ | |
| 477 | driver: DRIVER_KIND, | |
| 478 | instanceId, | |
| 479 | detail: `Failed to build OpenCode snapshot: ${cause.message ?? String(cause)}`, | |
| 480 | cause, | |
| 481 | }), | |
| 482 | ), | |
| 483 | ); | |
| 484 | ||
| 485 | return { | |
| 486 | instanceId, | |
| 487 | driverKind: DRIVER_KIND, | |
| 488 | continuationIdentity, | |
| 489 | displayName, | |
| 490 | accentColor, | |
| 491 | enabled, | |
| 492 | snapshot, | |
| 493 | snapshotForCwd: (cwd) => | |
| 494 | !effectiveConfig.enabled | |
| 495 | ? snapshot.getSnapshot | |
| 496 | : byOpenCodeRuntime(runtimeProbe.get, { | |
| 497 | v2: Effect.all([ | |
| 498 | snapshot.getSnapshot, | |
| 499 | listOpenCode2Workspace(cwd).pipe( | |
| 500 | Effect.timeout("20 seconds"), | |
| 501 | Effect.mapError( | |
| 502 | (cause) => | |
| 503 | new ProviderDriverError({ | |
| 504 | driver: DRIVER_KIND, | |
| 505 | instanceId, | |
| 506 | detail: `Failed to list OpenCode commands and skills for '${cwd}'`, | |
| 507 | cause, | |
| 508 | }), | |
| 509 | ), | |
| 510 | ), | |
| 511 | ]).pipe( | |
| 512 | Effect.map(([machineSnapshot, { skills, commands }]) => ({ | |
| 513 | ...machineSnapshot, | |
| 514 | skills: openCode2SkillsToServerProviderSkills(skills), | |
| 515 | slashCommands: openCode2CommandsToServerProviderSlashCommands(commands), | |
| 516 | })), | |
| 517 | ), | |
| 518 | v1: Effect.all([ | |
| 519 | snapshot.getSnapshot, | |
| 520 | loadWorkspaceForCwd(cwd).pipe(Effect.timeout("20 seconds")), | |
| 521 | ]).pipe( | |
| 522 | Effect.map(([machineSnapshot, { skills, commands }]) => ({ | |
| 523 | ...machineSnapshot, | |
| 524 | skills: openCodeSkillsToServerProviderSkills(skills), | |
| 525 | slashCommands: openCodeCommandsToServerProviderSlashCommands(commands), | |
| 526 | })), | |
| 527 | Effect.mapError( | |
| 528 | (cause) => | |
| 529 | new ProviderDriverError({ | |
| 530 | driver: DRIVER_KIND, | |
| 531 | instanceId, | |
| 532 | detail: `Failed to probe OpenCode commands and skills for '${cwd}'`, | |
| 533 | cause, | |
| 534 | }), | |
| 535 | ), | |
| 536 | ), | |
| 537 | }), | |
| 538 | orchestrationAdapter, | |
| 539 | textGeneration, | |
| 540 | } satisfies ProviderInstance; | |
| 541 | }), | |
| 542 | }; |
apps/server/src/provider/ModelManifest.ts+1-1| ... | ... | @@ -33,7 +33,7 @@ import * as Schema from "effect/Schema"; |
| 33 | 33 | import * as Semaphore from "effect/Semaphore"; |
| 34 | 34 | import { HttpClient, HttpClientResponse } from "effect/http"; |
| 35 | 35 | |
| 36 | import { writeFileStringAtomically } from "../atomicWrite.ts"; | |
| 36 | import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; | |
| 37 | 37 | import { ServerConfig } from "../config.ts"; |
| 38 | 38 | import * as ServerSettings from "../serverSettings.ts"; |
| 39 | 39 | import { hasValidClaudeManifestAdapters } from "./ClaudeModelManifest.ts"; |
apps/server/src/provider/NativeProtocolLogging.ts deleted-40| ... | ... | @@ -1,40 +0,0 @@ |
| 1 | import { errorTag } from "@t3tools/shared/observability"; | |
| 2 | ||
| 3 | export function structuralProtocolMethod(value: string): string { | |
| 4 | return value.length <= 128 && /^[A-Za-z][A-Za-z0-9._:/-]*$/.test(value) ? value : "unknown"; | |
| 5 | } | |
| 6 | ||
| 7 | /** | |
| 8 | * Retain only bounded structural metadata for native protocol diagnostics. | |
| 9 | * Provider payload values may contain prompts, credentials, or arbitrarily | |
| 10 | * large tool output and must never be copied into the observability stream. | |
| 11 | */ | |
| 12 | export function summarizeNativeProtocolPayload( | |
| 13 | payload: unknown, | |
| 14 | ): Readonly<Record<string, unknown>> { | |
| 15 | if (payload === null) return { valueType: "null" }; | |
| 16 | if (typeof payload === "string") { | |
| 17 | return { valueType: "string", byteLength: new TextEncoder().encode(payload).byteLength }; | |
| 18 | } | |
| 19 | if (payload instanceof Uint8Array) { | |
| 20 | return { valueType: "bytes", byteLength: payload.byteLength }; | |
| 21 | } | |
| 22 | if (Array.isArray(payload)) { | |
| 23 | return { valueType: "array", itemCount: payload.length }; | |
| 24 | } | |
| 25 | if (typeof payload !== "object") { | |
| 26 | return { valueType: typeof payload }; | |
| 27 | } | |
| 28 | ||
| 29 | try { | |
| 30 | const record = payload as Record<string, unknown>; | |
| 31 | return { | |
| 32 | valueType: "object", | |
| 33 | fieldCount: Object.keys(record).length, | |
| 34 | ...(typeof record._tag === "string" ? { messageTag: errorTag(record) } : {}), | |
| 35 | ...(typeof record.tag === "string" ? { method: structuralProtocolMethod(record.tag) } : {}), | |
| 36 | }; | |
| 37 | } catch { | |
| 38 | return { valueType: "object" }; | |
| 39 | } | |
| 40 | } |
apps/server/src/provider/OpenCode2Client.events.test.ts created+196| ... | ... | @@ -0,0 +1,196 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { assert, it } from "@effect/vitest"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Fiber from "effect/Fiber"; | |
| 5 | import * as FileSystem from "effect/FileSystem"; | |
| 6 | import * as Layer from "effect/Layer"; | |
| 7 | import * as Schema from "effect/Schema"; | |
| 8 | import * as Stream from "effect/Stream"; | |
| 9 | import { TestClock } from "effect/testing"; | |
| 10 | import { HttpClient, HttpClientResponse } from "effect/http"; | |
| 11 | import { describe } from "vite-plus/test"; | |
| 12 | ||
| 13 | import * as OpenCode2Client from "@t3tools/provider-opencode/server/v2/OpenCode2Client"; | |
| 14 | ||
| 15 | const RECORDING = new URL( | |
| 16 | "../orchestration-v2/testkit/fixtures/opencode2_simple/opencode_transcript.ndjson", | |
| 17 | import.meta.url, | |
| 18 | ); | |
| 19 | const Entry = Schema.Struct({ type: Schema.String, frame: Schema.optional(Schema.Unknown) }); | |
| 20 | const decodeEntry = Schema.decodeUnknownSync(Schema.fromJsonString(Entry)); | |
| 21 | const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); | |
| 22 | ||
| 23 | /** The recorded `simple` turn's events, as a newer server would send them. */ | |
| 24 | const newerServerStream = Effect.gen(function* () { | |
| 25 | const fs = yield* FileSystem.FileSystem; | |
| 26 | const recorded = (yield* fs.readFileString(RECORDING.pathname)) | |
| 27 | .split("\n") | |
| 28 | .filter((line) => line.length > 0) | |
| 29 | .map((line) => decodeEntry(line)) | |
| 30 | .flatMap((entry) => { | |
| 31 | const frame = entry.frame as { readonly type?: string; readonly event?: unknown } | undefined; | |
| 32 | return frame?.type === "sdk.event" ? [frame.event as Record<string, unknown>] : []; | |
| 33 | }); | |
| 34 | const executionStarted = recorded.findIndex( | |
| 35 | (event) => event.type === "session.execution.started", | |
| 36 | ); | |
| 37 | const [first] = recorded; | |
| 38 | return [ | |
| 39 | ...recorded.slice(0, executionStarted), | |
| 40 | // An event type this build has never heard of. | |
| 41 | { | |
| 42 | id: "evt_0000000000newtype", | |
| 43 | created: 1, | |
| 44 | type: "session.hologram.projected", | |
| 45 | data: { beams: 3 }, | |
| 46 | }, | |
| 47 | // A known event carrying a field added after 2.0.18. | |
| 48 | { | |
| 49 | ...recorded[executionStarted], | |
| 50 | data: { ...(recorded[executionStarted]!.data as object), lane: "fast" }, | |
| 51 | }, | |
| 52 | ...recorded.slice(executionStarted + 1), | |
| 53 | first, | |
| 54 | ]; | |
| 55 | }); | |
| 56 | ||
| 57 | const layerServing = (events: ReadonlyArray<unknown>) => | |
| 58 | Layer.succeed( | |
| 59 | HttpClient.HttpClient, | |
| 60 | HttpClient.make((request) => | |
| 61 | Effect.succeed( | |
| 62 | HttpClientResponse.fromWeb( | |
| 63 | request, | |
| 64 | new Response(events.map((event) => `data: ${encodeJson(event)}\n\n`).join(""), { | |
| 65 | headers: { "content-type": "text/event-stream" }, | |
| 66 | }), | |
| 67 | ), | |
| 68 | ), | |
| 69 | ), | |
| 70 | ); | |
| 71 | ||
| 72 | const connect = Effect.gen(function* () { | |
| 73 | const opencode = yield* OpenCode2Client.OpenCode2Client; | |
| 74 | return yield* opencode.connect({ baseUrl: "http://127.0.0.1:4096", password: "secret" }); | |
| 75 | }); | |
| 76 | ||
| 77 | /** Sends `events`, then keeps the connection open without another byte, like a frozen server. */ | |
| 78 | const layerServingThenSilent = (events: ReadonlyArray<unknown>) => | |
| 79 | Layer.succeed( | |
| 80 | HttpClient.HttpClient, | |
| 81 | HttpClient.make((request) => | |
| 82 | Effect.succeed( | |
| 83 | HttpClientResponse.fromWeb( | |
| 84 | request, | |
| 85 | new Response( | |
| 86 | new ReadableStream<Uint8Array>({ | |
| 87 | start(controller) { | |
| 88 | for (const event of events) { | |
| 89 | controller.enqueue(new TextEncoder().encode(`data: ${encodeJson(event)}\n\n`)); | |
| 90 | } | |
| 91 | }, | |
| 92 | }), | |
| 93 | { headers: { "content-type": "text/event-stream" } }, | |
| 94 | ), | |
| 95 | ), | |
| 96 | ), | |
| 97 | ), | |
| 98 | ); | |
| 99 | ||
| 100 | describe("OpenCode2Client events", () => { | |
| 101 | it.effect("fails a stream that stops sending anything, even heartbeats", () => | |
| 102 | Effect.gen(function* () { | |
| 103 | const { events } = yield* connect.pipe( | |
| 104 | Effect.provide( | |
| 105 | OpenCode2Client.layer.pipe( | |
| 106 | Layer.provide( | |
| 107 | layerServingThenSilent([{ id: "evt_1", type: "server.connected", data: {} }]), | |
| 108 | ), | |
| 109 | ), | |
| 110 | ), | |
| 111 | ); | |
| 112 | const drained = yield* (yield* events).pipe(Stream.runDrain, Effect.flip, Effect.forkChild); | |
| 113 | // Let the first frame arrive and the timer start before the clock moves. | |
| 114 | for (let i = 0; i < 20; i++) yield* Effect.yieldNow; | |
| 115 | yield* TestClock.adjust("46 seconds"); | |
| 116 | const failure = yield* Fiber.join(drained); | |
| 117 | assert.strictEqual(failure._tag, "OpenCode2SilentStreamError"); | |
| 118 | }).pipe(Effect.provide(TestClock.layer())), | |
| 119 | ); | |
| 120 | ||
| 121 | it.effect( | |
| 122 | "keeps an execution's end and start it cannot decode, and skips other unknown events", | |
| 123 | () => | |
| 124 | Effect.gen(function* () { | |
| 125 | const { events } = yield* connect.pipe( | |
| 126 | Effect.provide( | |
| 127 | OpenCode2Client.layer.pipe( | |
| 128 | Layer.provide( | |
| 129 | layerServing([ | |
| 130 | { | |
| 131 | id: "evt_1", | |
| 132 | created: 1, | |
| 133 | type: "session.hologram.projected", | |
| 134 | data: { sessionID: "ses_x" }, | |
| 135 | }, | |
| 136 | // A start is kept as a marker; it never ends anything. | |
| 137 | { | |
| 138 | id: "evt_0", | |
| 139 | created: 1, | |
| 140 | type: "session.execution.started", | |
| 141 | data: { sessionID: "ses_x", lane: 7 }, | |
| 142 | durable: "not-an-envelope", | |
| 143 | }, | |
| 144 | { | |
| 145 | id: "evt_2", | |
| 146 | created: 1, | |
| 147 | type: "session.execution.interrupted", | |
| 148 | data: { sessionID: "ses_x", reason: "budget" }, | |
| 149 | durable: { aggregateID: "ses_x", seq: 1, version: 1 }, | |
| 150 | }, | |
| 151 | ]), | |
| 152 | ), | |
| 153 | ), | |
| 154 | ), | |
| 155 | ); | |
| 156 | const received = yield* (yield* events).pipe(Stream.runCollect); | |
| 157 | assert.deepStrictEqual(received, [ | |
| 158 | { type: "unreadable.execution.started", sessionID: "ses_x" }, | |
| 159 | { | |
| 160 | type: "unreadable.execution.ended", | |
| 161 | executionType: "session.execution.interrupted", | |
| 162 | sessionID: "ses_x", | |
| 163 | }, | |
| 164 | ]); | |
| 165 | }), | |
| 166 | ); | |
| 167 | ||
| 168 | it.effect("skips events a newer server adds and still sees the turn end", () => | |
| 169 | Effect.gen(function* () { | |
| 170 | const events = yield* newerServerStream; | |
| 171 | const { events: subscribe } = yield* connect.pipe( | |
| 172 | Effect.provide(OpenCode2Client.layer.pipe(Layer.provide(layerServing(events)))), | |
| 173 | ); | |
| 174 | const types = yield* (yield* subscribe).pipe( | |
| 175 | Stream.map((event) => event.type), | |
| 176 | Stream.runCollect, | |
| 177 | ); | |
| 178 | ||
| 179 | assert.notInclude(types, "session.hologram.projected"); | |
| 180 | assert.include(types, "session.execution.started"); | |
| 181 | assert.include(types, "session.execution.succeeded"); | |
| 182 | assert.strictEqual(types.length, events.length - 1); | |
| 183 | }).pipe(Effect.provide(NodeServices.layer)), | |
| 184 | ); | |
| 185 | ||
| 186 | it.effect("the client's own subscription fails the whole stream on the same events", () => | |
| 187 | Effect.gen(function* () { | |
| 188 | const events = yield* newerServerStream; | |
| 189 | const { client } = yield* connect.pipe( | |
| 190 | Effect.provide(OpenCode2Client.layer.pipe(Layer.provide(layerServing(events)))), | |
| 191 | ); | |
| 192 | const failure = yield* client.event.subscribe().pipe(Stream.runDrain, Effect.flip); | |
| 193 | assert.strictEqual(failure._tag, "ClientError"); | |
| 194 | }).pipe(Effect.provide(NodeServices.layer)), | |
| 195 | ); | |
| 196 | }); |
apps/server/src/provider/OpenCodeProvider.test.ts deleted-873| ... | ... | @@ -1,873 +0,0 @@ |
| 1 | import * as NodeAssert from "node:assert/strict"; | |
| 2 | ||
| 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 4 | import { it } from "@effect/vitest"; | |
| 5 | import * as Deferred from "effect/Deferred"; | |
| 6 | import * as Effect from "effect/Effect"; | |
| 7 | import * as Fiber from "effect/Fiber"; | |
| 8 | import * as FileSystem from "effect/FileSystem"; | |
| 9 | import * as Layer from "effect/Layer"; | |
| 10 | import * as Schema from "effect/Schema"; | |
| 11 | import * as TestClock from "effect/testing/TestClock"; | |
| 12 | import { HttpClient, HttpClientResponse } from "effect/http"; | |
| 13 | import { beforeEach } from "vite-plus/test"; | |
| 14 | ||
| 15 | import { OpenCodeSettings } from "@t3tools/contracts"; | |
| 16 | import * as ServerConfig from "../config.ts"; | |
| 17 | import * as OpenCodeRuntime from "./opencodeRuntime.ts"; | |
| 18 | import * as OpenCodeServerOwner from "./OpenCodeServerOwner.ts"; | |
| 19 | import { | |
| 20 | checkOpenCodeProviderStatus, | |
| 21 | loadOpenCode2Workspace, | |
| 22 | makeOpenCode2ModelLoader, | |
| 23 | type OpenCode2Model, | |
| 24 | type OpenCode2Workspace, | |
| 25 | openCode2CommandsToServerProviderSlashCommands, | |
| 26 | openCode2SkillsToServerProviderSkills, | |
| 27 | openCodeCommandsToServerProviderSlashCommands, | |
| 28 | } from "./OpenCodeProvider.ts"; | |
| 29 | import { readOpenCodeGoUsageLimits } from "./openCodeUsageLimits.ts"; | |
| 30 | import { probeOpenCodeRuntime } from "./opencodeVersionProbe.ts"; | |
| 31 | import { | |
| 32 | OPENCODE_1_RESPONSES, | |
| 33 | OPENCODE_2_RESPONSES, | |
| 34 | replayOpenCodeServer, | |
| 35 | } from "./testFixtures/opencodeProbeResponses.ts"; | |
| 36 | const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings); | |
| 37 | ||
| 38 | const DEFAULT_VERSION_STDOUT = "opencode 1.14.19\n"; | |
| 39 | ||
| 40 | it.effect("reads Go limits with the instance's XDG credentials and preserves reset times", () => | |
| 41 | Effect.gen(function* () { | |
| 42 | const resetsAt = "2026-09-17T12:00:00.000Z"; | |
| 43 | const limits = yield* readOpenCodeGoUsageLimits({ | |
| 44 | enabled: true, | |
| 45 | serverUrl: "", | |
| 46 | environment: { XDG_DATA_HOME: "/instance/data", OPENCODE_API_KEY: "env-key" }, | |
| 47 | }).pipe( | |
| 48 | Effect.provideService( | |
| 49 | FileSystem.FileSystem, | |
| 50 | FileSystem.makeNoop({ | |
| 51 | readFileString: (path) => { | |
| 52 | NodeAssert.equal(path, "/instance/data/opencode/auth.json"); | |
| 53 | return Effect.succeed( | |
| 54 | JSON.stringify({ "opencode-go": { type: "api", key: "instance-key" } }), | |
| 55 | ); | |
| 56 | }, | |
| 57 | }), | |
| 58 | ), | |
| 59 | Effect.provideService( | |
| 60 | HttpClient.HttpClient, | |
| 61 | HttpClient.make((request) => { | |
| 62 | NodeAssert.equal(request.url, "https://opencode.ai/zen/go/v1/usage"); | |
| 63 | NodeAssert.equal(request.headers.authorization, "Bearer instance-key"); | |
| 64 | return Effect.succeed( | |
| 65 | HttpClientResponse.fromWeb( | |
| 66 | request, | |
| 67 | Response.json({ | |
| 68 | usage: { | |
| 69 | rolling: { percent: 0, resetsAt }, | |
| 70 | weekly: { percent: 10, resetsAt }, | |
| 71 | monthly: { percent: 125, resetsAt }, | |
| 72 | }, | |
| 73 | }), | |
| 74 | ), | |
| 75 | ); | |
| 76 | }), | |
| 77 | ), | |
| 78 | Effect.provide(NodeServices.layer), | |
| 79 | ); | |
| 80 | NodeAssert.equal(limits.unavailable, undefined); | |
| 81 | NodeAssert.equal( | |
| 82 | limits.credentialFingerprint, | |
| 83 | // SHA-256 of "opencode-go\0instance-key". | |
| 84 | "aba48e85c981a8edc1c9fb4575121accc7235fa55d0f8689f66f153de5566a37", | |
| 85 | ); | |
| 86 | NodeAssert.deepEqual( | |
| 87 | limits.windows.map(({ kind, usedPercent, resetsAt: reset }) => ({ | |
| 88 | kind, | |
| 89 | usedPercent, | |
| 90 | reset, | |
| 91 | })), | |
| 92 | [ | |
| 93 | { kind: "session", usedPercent: 0, reset: resetsAt }, | |
| 94 | { kind: "weekly", usedPercent: 10, reset: resetsAt }, | |
| 95 | { kind: "monthly", usedPercent: 100, reset: resetsAt }, | |
| 96 | ], | |
| 97 | ); | |
| 98 | }), | |
| 99 | ); | |
| 100 | ||
| 101 | it.effect("does not read local credentials for external or disabled OpenCode instances", () => | |
| 102 | Effect.gen(function* () { | |
| 103 | for (const settings of [ | |
| 104 | { enabled: true, serverUrl: "https://remote.example" }, | |
| 105 | { enabled: false, serverUrl: "" }, | |
| 106 | ]) { | |
| 107 | const limits = yield* readOpenCodeGoUsageLimits({ ...settings, environment: {} }).pipe( | |
| 108 | Effect.provideService( | |
| 109 | FileSystem.FileSystem, | |
| 110 | FileSystem.makeNoop({ | |
| 111 | readFileString: () => Effect.die("unexpected credential read"), | |
| 112 | }), | |
| 113 | ), | |
| 114 | Effect.provideService( | |
| 115 | HttpClient.HttpClient, | |
| 116 | HttpClient.make(() => Effect.die("unexpected usage request")), | |
| 117 | ), | |
| 118 | Effect.provide(NodeServices.layer), | |
| 119 | ); | |
| 120 | NodeAssert.equal(limits.unavailable?.reason, "unsupported"); | |
| 121 | } | |
| 122 | }), | |
| 123 | ); | |
| 124 | ||
| 125 | it.effect("keeps Go entitlement absence distinct from failed or malformed usage responses", () => | |
| 126 | Effect.gen(function* () { | |
| 127 | for (const [status, reason] of [ | |
| 128 | [403, "unsupported"], | |
| 129 | [401, "probeFailed"], | |
| 130 | [200, "probeFailed"], | |
| 131 | ] as const) { | |
| 132 | const limits = yield* readOpenCodeGoUsageLimits({ | |
| 133 | enabled: true, | |
| 134 | serverUrl: "", | |
| 135 | environment: { | |
| 136 | OPENCODE_AUTH_CONTENT: '{"opencode-go":{"type":"api","key":"inline-key"}}', | |
| 137 | }, | |
| 138 | }).pipe( | |
| 139 | Effect.provideService( | |
| 140 | FileSystem.FileSystem, | |
| 141 | FileSystem.makeNoop({ | |
| 142 | readFileString: () => Effect.die("inline credentials must bypass disk"), | |
| 143 | }), | |
| 144 | ), | |
| 145 | Effect.provideService( | |
| 146 | HttpClient.HttpClient, | |
| 147 | HttpClient.make((request) => | |
| 148 | Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({}, { status }))), | |
| 149 | ), | |
| 150 | ), | |
| 151 | Effect.provide(NodeServices.layer), | |
| 152 | ); | |
| 153 | NodeAssert.equal(limits.unavailable?.reason, reason); | |
| 154 | NodeAssert.equal(limits.credentialFingerprint, undefined); | |
| 155 | NodeAssert.deepEqual(limits.windows, []); | |
| 156 | } | |
| 157 | }), | |
| 158 | ); | |
| 159 | ||
| 160 | /** | |
| 161 | * The legacy `OpenCodeProviderLive` Layer + `OpenCodeProvider` service tag | |
| 162 | * are deleted. The snapshot-producing logic they wrapped now lives in the | |
| 163 | * standalone `checkOpenCodeProviderStatus(settings, cwd)` Effect, which | |
| 164 | * drivers call directly when building their per-instance snapshot | |
| 165 | * `ServerProviderShape`. Tests mirror that shape: build a settings payload, | |
| 166 | * invoke the check, assert on the returned snapshot. | |
| 167 | */ | |
| 168 | ||
| 169 | const runtimeMock = { | |
| 170 | state: { | |
| 171 | runVersionError: null as Error | null, | |
| 172 | runVersionPending: false, | |
| 173 | versionStdout: DEFAULT_VERSION_STDOUT, | |
| 174 | inventoryError: null as Error | null, | |
| 175 | connectionError: null as Error | null, | |
| 176 | inventoryCwd: null as string | null, | |
| 177 | closeCalls: 0, | |
| 178 | sdkClientInputs: [] as Array<{ | |
| 179 | baseUrl: string; | |
| 180 | directory: string; | |
| 181 | serverPassword?: string; | |
| 182 | }>, | |
| 183 | inventory: { | |
| 184 | providerList: { connected: [] as string[], all: [] as unknown[], default: {} }, | |
| 185 | agents: [] as unknown[], | |
| 186 | skills: [] as unknown[], | |
| 187 | } as unknown, | |
| 188 | }, | |
| 189 | reset() { | |
| 190 | this.state.runVersionError = null; | |
| 191 | this.state.runVersionPending = false; | |
| 192 | this.state.versionStdout = DEFAULT_VERSION_STDOUT; | |
| 193 | this.state.inventoryError = null; | |
| 194 | this.state.connectionError = null; | |
| 195 | this.state.inventoryCwd = null; | |
| 196 | this.state.closeCalls = 0; | |
| 197 | this.state.sdkClientInputs.length = 0; | |
| 198 | this.state.inventory = { | |
| 199 | providerList: { connected: [], all: [] as unknown[], default: {} }, | |
| 200 | agents: [] as unknown[], | |
| 201 | skills: [] as unknown[], | |
| 202 | }; | |
| 203 | }, | |
| 204 | }; | |
| 205 | ||
| 206 | const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = { | |
| 207 | startOpenCodeServerProcess: ({ serverPassword, environment }) => | |
| 208 | Effect.gen(function* () { | |
| 209 | yield* Effect.addFinalizer(() => | |
| 210 | Effect.sync(() => { | |
| 211 | runtimeMock.state.closeCalls += 1; | |
| 212 | }), | |
| 213 | ); | |
| 214 | const effectiveServerPassword = OpenCodeRuntime.resolveOpenCodeServerPassword({ | |
| 215 | external: false, | |
| 216 | ...(serverPassword !== undefined ? { serverPassword } : {}), | |
| 217 | ...(environment !== undefined ? { environment } : {}), | |
| 218 | }); | |
| 219 | return { | |
| 220 | url: "http://127.0.0.1:4301", | |
| 221 | ...(effectiveServerPassword !== undefined | |
| 222 | ? { serverPassword: effectiveServerPassword } | |
| 223 | : {}), | |
| 224 | version: "1.14.19", | |
| 225 | isRunning: Effect.succeed(true), | |
| 226 | exitCode: Effect.never, | |
| 227 | }; | |
| 228 | }), | |
| 229 | connectToOpenCodeServer: ({ serverUrl, serverPassword }) => | |
| 230 | Effect.gen(function* () { | |
| 231 | if (runtimeMock.state.connectionError) { | |
| 232 | return yield* new OpenCodeRuntime.OpenCodeRuntimeError({ | |
| 233 | operation: "global.health", | |
| 234 | detail: runtimeMock.state.connectionError.message, | |
| 235 | cause: runtimeMock.state.connectionError, | |
| 236 | }); | |
| 237 | } | |
| 238 | if (!serverUrl) { | |
| 239 | yield* Effect.addFinalizer(() => | |
| 240 | Effect.sync(() => { | |
| 241 | runtimeMock.state.closeCalls += 1; | |
| 242 | }), | |
| 243 | ); | |
| 244 | } | |
| 245 | return { | |
| 246 | url: serverUrl ?? "http://127.0.0.1:4301", | |
| 247 | ...(serverPassword ? { serverPassword } : {}), | |
| 248 | version: "1.14.19", | |
| 249 | exitCode: null, | |
| 250 | external: Boolean(serverUrl), | |
| 251 | }; | |
| 252 | }), | |
| 253 | runOpenCodeCommand: () => | |
| 254 | runtimeMock.state.runVersionPending | |
| 255 | ? Effect.never | |
| 256 | : runtimeMock.state.runVersionError | |
| 257 | ? Effect.fail( | |
| 258 | new OpenCodeRuntime.OpenCodeRuntimeError({ | |
| 259 | operation: "runOpenCodeCommand", | |
| 260 | detail: runtimeMock.state.runVersionError.message, | |
| 261 | cause: runtimeMock.state.runVersionError, | |
| 262 | }), | |
| 263 | ) | |
| 264 | : Effect.succeed({ stdout: runtimeMock.state.versionStdout, stderr: "", code: 0 }), | |
| 265 | createOpenCodeSdkClient: (input) => { | |
| 266 | runtimeMock.state.sdkClientInputs.push(input); | |
| 267 | return {} as unknown as ReturnType< | |
| 268 | OpenCodeRuntime.OpenCodeRuntimeShape["createOpenCodeSdkClient"] | |
| 269 | >; | |
| 270 | }, | |
| 271 | loadOpenCodeInventory: () => | |
| 272 | runtimeMock.state.inventoryError | |
| 273 | ? Effect.fail( | |
| 274 | new OpenCodeRuntime.OpenCodeRuntimeError({ | |
| 275 | operation: "loadOpenCodeInventory", | |
| 276 | detail: runtimeMock.state.inventoryError.message, | |
| 277 | cause: runtimeMock.state.inventoryError, | |
| 278 | }), | |
| 279 | ) | |
| 280 | : Effect.succeed(runtimeMock.state.inventory as OpenCodeRuntime.OpenCodeInventory), | |
| 281 | loadInventoryFromCli: ({ cwd }) => { | |
| 282 | runtimeMock.state.inventoryCwd = cwd; | |
| 283 | return runtimeMock.state.inventoryError | |
| 284 | ? Effect.fail( | |
| 285 | new OpenCodeRuntime.OpenCodeRuntimeError({ | |
| 286 | operation: "loadInventoryFromCli", | |
| 287 | detail: runtimeMock.state.inventoryError.message, | |
| 288 | cause: runtimeMock.state.inventoryError, | |
| 289 | }), | |
| 290 | ) | |
| 291 | : Effect.succeed(runtimeMock.state.inventory as OpenCodeRuntime.OpenCodeInventory); | |
| 292 | }, | |
| 293 | loadOpenCodeSkills: () => Effect.succeed([]), | |
| 294 | loadSkillsFromCli: () => Effect.succeed([]), | |
| 295 | }; | |
| 296 | ||
| 297 | beforeEach(() => { | |
| 298 | runtimeMock.reset(); | |
| 299 | }); | |
| 300 | ||
| 301 | it("keeps native and MCP commands while preserving compaction and separate skills", () => { | |
| 302 | NodeAssert.deepEqual( | |
| 303 | openCodeCommandsToServerProviderSlashCommands([ | |
| 304 | { name: "review", description: "Review changes", source: "command", hints: ["$ARGUMENTS"] }, | |
| 305 | { name: "review", source: "command", hints: [] }, | |
| 306 | { name: "compact", source: "command", hints: [] }, | |
| 307 | { name: "skill", source: "skill", hints: [] }, | |
| 308 | { name: "mcp:search", source: "mcp", hints: ["query"] }, | |
| 309 | ]).slice(1), | |
| 310 | [ | |
| 311 | { name: "review", description: "Review changes", input: { hint: "$ARGUMENTS" } }, | |
| 312 | { name: "mcp:search", input: { hint: "query" } }, | |
| 313 | ], | |
| 314 | ); | |
| 315 | }); | |
| 316 | ||
| 317 | const layerTest = Layer.succeed(OpenCodeRuntime.OpenCodeRuntime, OpenCodeRuntimeTestDouble).pipe( | |
| 318 | Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), | |
| 319 | Layer.provideMerge(NodeServices.layer), | |
| 320 | ); | |
| 321 | ||
| 322 | const makeOpenCodeSettings = (overrides?: Partial<OpenCodeSettings>): OpenCodeSettings => | |
| 323 | decodeOpenCodeSettings({ | |
| 324 | enabled: true, | |
| 325 | binaryPath: "opencode", | |
| 326 | serverUrl: "", | |
| 327 | serverPassword: "", | |
| 328 | customModels: [], | |
| 329 | ...overrides, | |
| 330 | }); | |
| 331 | ||
| 332 | const checkProvider = Effect.fn("checkProvider")(function* ( | |
| 333 | settings: OpenCodeSettings, | |
| 334 | cwd = process.cwd(), | |
| 335 | environment?: NodeJS.ProcessEnv, | |
| 336 | server = replayOpenCodeServer(OPENCODE_1_RESPONSES, settings.serverPassword), | |
| 337 | openCode2Models: Effect.Effect< | |
| 338 | ReadonlyArray<OpenCode2Model>, | |
| 339 | OpenCodeRuntime.OpenCodeRuntimeError | |
| 340 | > = Effect.succeed([]), | |
| 341 | ) { | |
| 342 | return yield* Effect.scoped( | |
| 343 | Effect.gen(function* () { | |
| 344 | const serverOwner = yield* OpenCodeServerOwner.make({ | |
| 345 | binaryPath: settings.binaryPath, | |
| 346 | directory: cwd, | |
| 347 | ...(settings.serverPassword ? { serverPassword: settings.serverPassword } : {}), | |
| 348 | ...(environment ? { environment } : {}), | |
| 349 | }); | |
| 350 | const probe = probeOpenCodeRuntime(settings, environment).pipe( | |
| 351 | Effect.provideService(HttpClient.HttpClient, server), | |
| 352 | Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, OpenCodeRuntimeTestDouble), | |
| 353 | ); | |
| 354 | return yield* checkOpenCodeProviderStatus(settings, cwd, probe, openCode2Models).pipe( | |
| 355 | Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner), | |
| 356 | ); | |
| 357 | }), | |
| 358 | ); | |
| 359 | }); | |
| 360 | ||
| 361 | it.layer(layerTest)("checkOpenCodeProviderStatus", (it) => { | |
| 362 | it.effect("shows a codex-style missing binary message", () => | |
| 363 | Effect.gen(function* () { | |
| 364 | runtimeMock.state.runVersionError = new Error("spawn opencode ENOENT"); | |
| 365 | const snapshot = yield* checkProvider(makeOpenCodeSettings()); | |
| 366 | ||
| 367 | NodeAssert.equal(snapshot.status, "error"); | |
| 368 | NodeAssert.equal(snapshot.installed, false); | |
| 369 | NodeAssert.equal( | |
| 370 | snapshot.message, | |
| 371 | "OpenCode CLI (`opencode`) is not installed or not on PATH.", | |
| 372 | ); | |
| 373 | }), | |
| 374 | ); | |
| 375 | ||
| 376 | it.effect("hides generic Effect.tryPromise text for local CLI probe failures", () => | |
| 377 | Effect.gen(function* () { | |
| 378 | runtimeMock.state.runVersionError = new Error("An error occurred in Effect.tryPromise"); | |
| 379 | const snapshot = yield* checkProvider(makeOpenCodeSettings()); | |
| 380 | ||
| 381 | NodeAssert.equal(snapshot.status, "error"); | |
| 382 | NodeAssert.equal(snapshot.installed, true); | |
| 383 | NodeAssert.equal(snapshot.message, "Failed to execute OpenCode CLI health check."); | |
| 384 | }), | |
| 385 | ); | |
| 386 | ||
| 387 | it.effect("times out a hanging local CLI version probe", () => | |
| 388 | Effect.gen(function* () { | |
| 389 | runtimeMock.state.runVersionPending = true; | |
| 390 | const probeFiber = yield* checkProvider(makeOpenCodeSettings()).pipe(Effect.forkChild); | |
| 391 | ||
| 392 | yield* Effect.yieldNow; | |
| 393 | yield* TestClock.adjust("4 seconds"); | |
| 394 | const snapshot = yield* Fiber.join(probeFiber); | |
| 395 | ||
| 396 | NodeAssert.equal(snapshot.status, "error"); | |
| 397 | NodeAssert.equal(snapshot.installed, true); | |
| 398 | NodeAssert.equal( | |
| 399 | snapshot.message, | |
| 400 | "Failed to execute OpenCode CLI health check: OpenCode CLI version probe timed out after 4 seconds.", | |
| 401 | ); | |
| 402 | }).pipe(Effect.provide(TestClock.layer())), | |
| 403 | ); | |
| 404 | ||
| 405 | it.effect("emits OpenCode variant defaults so trait picker can resolve a visible selection", () => | |
| 406 | Effect.gen(function* () { | |
| 407 | runtimeMock.state.inventory = { | |
| 408 | providerList: { | |
| 409 | connected: ["openai"], | |
| 410 | all: [ | |
| 411 | { | |
| 412 | id: "openai", | |
| 413 | name: "OpenAI", | |
| 414 | models: { | |
| 415 | "gpt-5.4": { | |
| 416 | id: "gpt-5.4", | |
| 417 | name: "GPT-5.4", | |
| 418 | variants: { | |
| 419 | none: {}, | |
| 420 | low: {}, | |
| 421 | medium: {}, | |
| 422 | high: {}, | |
| 423 | xhigh: {}, | |
| 424 | }, | |
| 425 | }, | |
| 426 | }, | |
| 427 | }, | |
| 428 | ], | |
| 429 | default: {}, | |
| 430 | }, | |
| 431 | agents: [ | |
| 432 | { name: "build", hidden: false, mode: "primary" }, | |
| 433 | { name: "plan", hidden: false, mode: "primary" }, | |
| 434 | ], | |
| 435 | }; | |
| 436 | ||
| 437 | const snapshot = yield* checkProvider(makeOpenCodeSettings()); | |
| 438 | const model = snapshot.models.find((entry) => entry.slug === "openai/gpt-5.4"); | |
| 439 | ||
| 440 | NodeAssert.ok(model); | |
| 441 | const variantDescriptor = model.capabilities?.optionDescriptors?.find( | |
| 442 | (descriptor) => descriptor.id === "variant" && descriptor.type === "select", | |
| 443 | ); | |
| 444 | NodeAssert.ok(variantDescriptor && variantDescriptor.type === "select"); | |
| 445 | NodeAssert.equal( | |
| 446 | variantDescriptor.options.find((option) => option.isDefault === true)?.id, | |
| 447 | "medium", | |
| 448 | ); | |
| 449 | const agentDescriptor = model.capabilities?.optionDescriptors?.find( | |
| 450 | (descriptor) => descriptor.id === "agent" && descriptor.type === "select", | |
| 451 | ); | |
| 452 | NodeAssert.ok(agentDescriptor && agentDescriptor.type === "select"); | |
| 453 | NodeAssert.equal( | |
| 454 | agentDescriptor.options.find((option) => option.isDefault === true)?.id, | |
| 455 | "build", | |
| 456 | ); | |
| 457 | }), | |
| 458 | ); | |
| 459 | ||
| 460 | it.effect("includes OpenCode skills in the provider snapshot", () => | |
| 461 | Effect.gen(function* () { | |
| 462 | runtimeMock.state.inventory = { | |
| 463 | providerList: { | |
| 464 | connected: ["openai"], | |
| 465 | all: [ | |
| 466 | { | |
| 467 | id: "openai", | |
| 468 | name: "OpenAI", | |
| 469 | models: { | |
| 470 | "gpt-5.4": { | |
| 471 | id: "gpt-5.4", | |
| 472 | name: "GPT-5.4", | |
| 473 | variants: {}, | |
| 474 | }, | |
| 475 | }, | |
| 476 | }, | |
| 477 | ], | |
| 478 | default: {}, | |
| 479 | }, | |
| 480 | agents: [], | |
| 481 | skills: [ | |
| 482 | { | |
| 483 | name: "openclaw-review", | |
| 484 | description: "Review OpenClaw workflow changes.", | |
| 485 | location: "/Users/test/.agents/skills/openclaw-review/SKILL.md", | |
| 486 | }, | |
| 487 | { | |
| 488 | name: "openclaw-triage", | |
| 489 | description: "Triage OpenClaw routing issues.", | |
| 490 | location: "/Users/test/.agents/skills/openclaw-triage/SKILL.md", | |
| 491 | }, | |
| 492 | { | |
| 493 | name: "missing-location", | |
| 494 | description: "This incomplete SDK row should be skipped.", | |
| 495 | location: "", | |
| 496 | }, | |
| 497 | ], | |
| 498 | }; | |
| 499 | ||
| 500 | const snapshot = yield* checkProvider(makeOpenCodeSettings()); | |
| 501 | ||
| 502 | NodeAssert.deepEqual( | |
| 503 | snapshot.skills.map((skill) => ({ | |
| 504 | name: skill.name, | |
| 505 | path: skill.path, | |
| 506 | enabled: skill.enabled, | |
| 507 | shortDescription: skill.shortDescription, | |
| 508 | })), | |
| 509 | [ | |
| 510 | { | |
| 511 | name: "openclaw-review", | |
| 512 | path: "/Users/test/.agents/skills/openclaw-review/SKILL.md", | |
| 513 | enabled: true, | |
| 514 | shortDescription: "Review OpenClaw workflow changes.", | |
| 515 | }, | |
| 516 | { | |
| 517 | name: "openclaw-triage", | |
| 518 | path: "/Users/test/.agents/skills/openclaw-triage/SKILL.md", | |
| 519 | enabled: true, | |
| 520 | shortDescription: "Triage OpenClaw routing issues.", | |
| 521 | }, | |
| 522 | ], | |
| 523 | ); | |
| 524 | }), | |
| 525 | ); | |
| 526 | ||
| 527 | it.effect("loads local inventory from a scoped OpenCode server", () => | |
| 528 | Effect.gen(function* () { | |
| 529 | yield* checkProvider(makeOpenCodeSettings({ serverPassword: "secret-password" })); | |
| 530 | ||
| 531 | NodeAssert.deepEqual(runtimeMock.state.sdkClientInputs, [ | |
| 532 | { | |
| 533 | baseUrl: "http://127.0.0.1:4301", | |
| 534 | directory: process.cwd(), | |
| 535 | serverPassword: "secret-password", | |
| 536 | }, | |
| 537 | ]); | |
| 538 | NodeAssert.equal(runtimeMock.state.closeCalls, 1); | |
| 539 | NodeAssert.equal(runtimeMock.state.inventoryCwd, null); | |
| 540 | }), | |
| 541 | ); | |
| 542 | ||
| 543 | it.effect("lists a local OpenCode 2 binary's models in Full access only, never via 1.x", () => | |
| 544 | Effect.gen(function* () { | |
| 545 | runtimeMock.state.versionStdout = "opencode v2.0.18\n"; | |
| 546 | const snapshot = yield* checkProvider( | |
| 547 | makeOpenCodeSettings(), | |
| 548 | process.cwd(), | |
| 549 | undefined, | |
| 550 | undefined, | |
| 551 | Effect.succeed([ | |
| 552 | { providerID: "opencode", id: "big-pickle", name: "Big Pickle", variants: [] }, | |
| 553 | { | |
| 554 | providerID: "opencode", | |
| 555 | id: "space-bunny-free", | |
| 556 | name: "Space Bunny Free", | |
| 557 | variants: [{ id: "low" }, { id: "medium" }, { id: "high" }], | |
| 558 | }, | |
| 559 | ]), | |
| 560 | ); | |
| 561 | ||
| 562 | NodeAssert.equal(snapshot.status, "ready"); | |
| 563 | NodeAssert.equal(snapshot.version, "2.0.18"); | |
| 564 | NodeAssert.deepEqual(snapshot.supportedRuntimeModes, [ | |
| 565 | "approval-required", | |
| 566 | "auto-accept-edits", | |
| 567 | "auto", | |
| 568 | "full-access", | |
| 569 | ]); | |
| 570 | // Plan mode runs as OpenCode's plan agent, and every session can compact. | |
| 571 | NodeAssert.equal(snapshot.showInteractionModeToggle, true); | |
| 572 | NodeAssert.deepEqual( | |
| 573 | snapshot.slashCommands.map((command) => command.name), | |
| 574 | ["compact"], | |
| 575 | ); | |
| 576 | NodeAssert.deepEqual( | |
| 577 | snapshot.models.map((model) => model.slug), | |
| 578 | ["opencode/big-pickle", "opencode/space-bunny-free"], | |
| 579 | ); | |
| 580 | const variant = snapshot.models.find((model) => model.slug === "opencode/space-bunny-free") | |
| 581 | ?.capabilities?.optionDescriptors?.[0]; | |
| 582 | NodeAssert.deepEqual( | |
| 583 | variant?.type === "select" ? variant.options.map((option) => option.id) : [], | |
| 584 | ["low", "medium", "high"], | |
| 585 | ); | |
| 586 | NodeAssert.equal(runtimeMock.state.sdkClientInputs.length, 0); | |
| 587 | }), | |
| 588 | ); | |
| 589 | ||
| 590 | it.effect("reports a failed OpenCode 2 model list without the server's response", () => | |
| 591 | Effect.gen(function* () { | |
| 592 | runtimeMock.state.versionStdout = "opencode v2.0.18\n"; | |
| 593 | const snapshot = yield* checkProvider( | |
| 594 | makeOpenCodeSettings(), | |
| 595 | process.cwd(), | |
| 596 | undefined, | |
| 597 | undefined, | |
| 598 | Effect.fail( | |
| 599 | new OpenCodeRuntime.OpenCodeRuntimeError({ | |
| 600 | operation: "model.list", | |
| 601 | detail: 'status=500 body={"token":"leaked-response-body"}', | |
| 602 | }), | |
| 603 | ), | |
| 604 | ); | |
| 605 | ||
| 606 | NodeAssert.equal(snapshot.status, "error"); | |
| 607 | NodeAssert.equal(snapshot.message, "OpenCode could not load its model list."); | |
| 608 | // A failed catalog read still leaves the session able to compact. | |
| 609 | NodeAssert.deepEqual( | |
| 610 | snapshot.slashCommands.map((command) => command.name), | |
| 611 | ["compact"], | |
| 612 | ); | |
| 613 | }), | |
| 614 | ); | |
| 615 | ||
| 616 | it.effect("uses an environment-only password for local inventory", () => | |
| 617 | Effect.gen(function* () { | |
| 618 | yield* checkProvider(makeOpenCodeSettings(), process.cwd(), { | |
| 619 | OPENCODE_SERVER_PASSWORD: "environment-password", | |
| 620 | }); | |
| 621 | ||
| 622 | NodeAssert.deepEqual(runtimeMock.state.sdkClientInputs, [ | |
| 623 | { | |
| 624 | baseUrl: "http://127.0.0.1:4301", | |
| 625 | directory: process.cwd(), | |
| 626 | serverPassword: "environment-password", | |
| 627 | }, | |
| 628 | ]); | |
| 629 | }), | |
| 630 | ); | |
| 631 | ||
| 632 | it.effect("uses the settings password when local environment auth differs", () => | |
| 633 | Effect.gen(function* () { | |
| 634 | yield* checkProvider( | |
| 635 | makeOpenCodeSettings({ serverPassword: "settings-password" }), | |
| 636 | process.cwd(), | |
| 637 | { OPENCODE_SERVER_PASSWORD: "environment-password" }, | |
| 638 | ); | |
| 639 | ||
| 640 | NodeAssert.equal(runtimeMock.state.sdkClientInputs[0]?.serverPassword, "settings-password"); | |
| 641 | }), | |
| 642 | ); | |
| 643 | ||
| 644 | it.effect("reports local model inventory failures without treating them as empty", () => | |
| 645 | Effect.gen(function* () { | |
| 646 | runtimeMock.state.inventoryError = new Error("opencode models failed"); | |
| 647 | const snapshot = yield* checkProvider(makeOpenCodeSettings()); | |
| 648 | ||
| 649 | NodeAssert.equal(snapshot.status, "error"); | |
| 650 | NodeAssert.equal(snapshot.installed, true); | |
| 651 | NodeAssert.equal(snapshot.models.length, 0); | |
| 652 | NodeAssert.equal( | |
| 653 | snapshot.message, | |
| 654 | "Failed to load OpenCode provider inventory: opencode models failed", | |
| 655 | ); | |
| 656 | }), | |
| 657 | ); | |
| 658 | }); | |
| 659 | ||
| 660 | it.layer(layerTest)("checkOpenCodeProviderStatus with configured server URL", (it) => { | |
| 661 | it.effect("does not send a local environment password to a configured server", () => | |
| 662 | Effect.gen(function* () { | |
| 663 | const snapshot = yield* checkProvider( | |
| 664 | makeOpenCodeSettings({ serverUrl: "http://127.0.0.1:9999" }), | |
| 665 | process.cwd(), | |
| 666 | { OPENCODE_SERVER_PASSWORD: "local-secret" }, | |
| 667 | ); | |
| 668 | ||
| 669 | NodeAssert.equal(snapshot.version, "1.14.19"); | |
| 670 | NodeAssert.deepEqual(runtimeMock.state.sdkClientInputs, [ | |
| 671 | { | |
| 672 | baseUrl: "http://127.0.0.1:9999", | |
| 673 | directory: process.cwd(), | |
| 674 | }, | |
| 675 | ]); | |
| 676 | }), | |
| 677 | ); | |
| 678 | ||
| 679 | it.effect("routes a configured OpenCode 2 server to the 2.x check, never via 1.x", () => | |
| 680 | Effect.gen(function* () { | |
| 681 | const settings = makeOpenCodeSettings({ | |
| 682 | serverUrl: "http://127.0.0.1:9999", | |
| 683 | serverPassword: "secret-password", | |
| 684 | }); | |
| 685 | const snapshot = yield* checkProvider( | |
| 686 | settings, | |
| 687 | process.cwd(), | |
| 688 | undefined, | |
| 689 | replayOpenCodeServer(OPENCODE_2_RESPONSES, "secret-password"), | |
| 690 | ); | |
| 691 | ||
| 692 | NodeAssert.equal(snapshot.status, "warning"); | |
| 693 | NodeAssert.equal(snapshot.version, "2.0.18"); | |
| 694 | NodeAssert.deepEqual(snapshot.supportedRuntimeModes, [ | |
| 695 | "approval-required", | |
| 696 | "auto-accept-edits", | |
| 697 | "auto", | |
| 698 | "full-access", | |
| 699 | ]); | |
| 700 | NodeAssert.equal(runtimeMock.state.sdkClientInputs.length, 0); | |
| 701 | }), | |
| 702 | ); | |
| 703 | ||
| 704 | it.effect("reports a rejected OpenCode 2 password as an auth error, not a version", () => | |
| 705 | Effect.gen(function* () { | |
| 706 | const snapshot = yield* checkProvider( | |
| 707 | makeOpenCodeSettings({ serverUrl: "http://127.0.0.1:9999", serverPassword: "wrong" }), | |
| 708 | process.cwd(), | |
| 709 | undefined, | |
| 710 | replayOpenCodeServer(OPENCODE_2_RESPONSES, "secret-password"), | |
| 711 | ); | |
| 712 | ||
| 713 | NodeAssert.equal(snapshot.status, "error"); | |
| 714 | NodeAssert.equal( | |
| 715 | snapshot.message, | |
| 716 | "OpenCode server rejected authentication. Check the server URL and password.", | |
| 717 | ); | |
| 718 | }), | |
| 719 | ); | |
| 720 | ||
| 721 | it.effect("rejects an unsupported server before loading inventory", () => | |
| 722 | Effect.gen(function* () { | |
| 723 | runtimeMock.state.connectionError = new Error( | |
| 724 | "OpenCode v1.14.18 is too old. Upgrade to v1.14.19 or newer.", | |
| 725 | ); | |
| 726 | const snapshot = yield* checkProvider( | |
| 727 | makeOpenCodeSettings({ serverUrl: "http://127.0.0.1:9999" }), | |
| 728 | ); | |
| 729 | ||
| 730 | NodeAssert.equal(snapshot.status, "error"); | |
| 731 | NodeAssert.equal(snapshot.models.length, 0); | |
| 732 | NodeAssert.match(snapshot.message ?? "", /v1\.14\.18 is too old/); | |
| 733 | NodeAssert.equal(runtimeMock.state.sdkClientInputs.length, 0); | |
| 734 | }), | |
| 735 | ); | |
| 736 | ||
| 737 | it.effect("surfaces a friendly auth error for configured servers", () => | |
| 738 | Effect.gen(function* () { | |
| 739 | runtimeMock.state.connectionError = new Error("401 Unauthorized"); | |
| 740 | const snapshot = yield* checkProvider( | |
| 741 | makeOpenCodeSettings({ | |
| 742 | serverUrl: "http://127.0.0.1:9999", | |
| 743 | serverPassword: "secret-password", | |
| 744 | }), | |
| 745 | ); | |
| 746 | ||
| 747 | NodeAssert.equal(snapshot.status, "error"); | |
| 748 | NodeAssert.equal(snapshot.installed, true); | |
| 749 | NodeAssert.equal( | |
| 750 | snapshot.message, | |
| 751 | "OpenCode server rejected authentication. Check the server URL and password.", | |
| 752 | ); | |
| 753 | }), | |
| 754 | ); | |
| 755 | ||
| 756 | it.effect("surfaces a friendly connection error for configured servers", () => | |
| 757 | Effect.gen(function* () { | |
| 758 | runtimeMock.state.connectionError = new Error( | |
| 759 | "fetch failed: connect ECONNREFUSED 127.0.0.1:9999", | |
| 760 | ); | |
| 761 | const snapshot = yield* checkProvider( | |
| 762 | makeOpenCodeSettings({ | |
| 763 | serverUrl: "http://127.0.0.1:9999", | |
| 764 | serverPassword: "secret-password", | |
| 765 | }), | |
| 766 | ); | |
| 767 | ||
| 768 | NodeAssert.equal(snapshot.status, "error"); | |
| 769 | NodeAssert.equal(snapshot.installed, true); | |
| 770 | NodeAssert.equal( | |
| 771 | snapshot.message, | |
| 772 | "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.", | |
| 773 | ); | |
| 774 | }), | |
| 775 | ); | |
| 776 | }); | |
| 777 | ||
| 778 | const bigPickle: OpenCode2Model = { | |
| 779 | providerID: "opencode", | |
| 780 | id: "big-pickle", | |
| 781 | name: "Big Pickle", | |
| 782 | variants: [], | |
| 783 | }; | |
| 784 | ||
| 785 | it.effect("waits for a fresh OpenCode 2 server to list its models", () => | |
| 786 | Effect.gen(function* () { | |
| 787 | // A fresh server lists nothing until its catalog loads. | |
| 788 | const replies: Array<ReadonlyArray<OpenCode2Model>> = [[], [], [bigPickle]]; | |
| 789 | const load = yield* makeOpenCode2ModelLoader(Effect.sync(() => replies.shift() ?? [bigPickle])); | |
| 790 | const fiber = yield* load.pipe(Effect.forkChild); | |
| 791 | yield* TestClock.adjust("1 second"); | |
| 792 | NodeAssert.deepEqual(yield* Fiber.join(fiber), [bigPickle]); | |
| 793 | }).pipe(Effect.provide(TestClock.layer())), | |
| 794 | ); | |
| 795 | ||
| 796 | it.effect("keeps the last OpenCode 2 model list while a fresh server's stays empty", () => | |
| 797 | Effect.gen(function* () { | |
| 798 | let listed: ReadonlyArray<OpenCode2Model> = [bigPickle]; | |
| 799 | const load = yield* makeOpenCode2ModelLoader(Effect.sync(() => listed)); | |
| 800 | NodeAssert.deepEqual(yield* load, [bigPickle]); | |
| 801 | listed = []; | |
| 802 | const fiber = yield* load.pipe(Effect.forkChild); | |
| 803 | yield* TestClock.adjust("6 seconds"); | |
| 804 | NodeAssert.deepEqual(yield* Fiber.join(fiber), [bigPickle]); | |
| 805 | }).pipe(Effect.provide(TestClock.layer())), | |
| 806 | ); | |
| 807 | ||
| 808 | // What 2.0.18 lists for a directory once it has scanned it (live, 2026-09-29). | |
| 809 | const builtinCommands = [ | |
| 810 | { name: "init", description: "guided AGENTS.md setup" }, | |
| 811 | { name: "review", description: "review changes [commit|branch|pr], defaults to uncommitted" }, | |
| 812 | ]; | |
| 813 | const builtinSkills = [ | |
| 814 | { id: "opencode", name: "OpenCode", path: "/builtin/opencode.md" }, | |
| 815 | { id: "report", name: "Report", path: "/builtin/report.md" }, | |
| 816 | ]; | |
| 817 | const scanned: OpenCode2Workspace = { | |
| 818 | commands: [...builtinCommands, { name: "hello", description: "Say hello to the workspace" }], | |
| 819 | skills: [ | |
| 820 | ...builtinSkills, | |
| 821 | { | |
| 822 | id: "greet", | |
| 823 | name: "greet", | |
| 824 | path: "/work/.opencode/skills/greet/SKILL.md", | |
| 825 | description: "Greets the user with the secret word MANGO.", | |
| 826 | }, | |
| 827 | ], | |
| 828 | }; | |
| 829 | ||
| 830 | it.effect("reads a fresh OpenCode 2 directory again once its scan has ended", () => | |
| 831 | Effect.gen(function* () { | |
| 832 | // A directory the server has not served yet lists no commands until its scan ends. | |
| 833 | const reads: Array<OpenCode2Workspace> = [{ commands: [], skills: [] }, scanned]; | |
| 834 | const scanEnded = yield* Deferred.make<void>(); | |
| 835 | const load = yield* loadOpenCode2Workspace( | |
| 836 | Effect.sync(() => reads.shift()!), | |
| 837 | Deferred.await(scanEnded), | |
| 838 | ).pipe(Effect.forkChild); | |
| 839 | yield* Effect.yieldNow; | |
| 840 | NodeAssert.equal(reads.length, 1); | |
| 841 | yield* Deferred.succeed(scanEnded, undefined); | |
| 842 | NodeAssert.deepEqual(yield* Fiber.join(load), scanned); | |
| 843 | }), | |
| 844 | ); | |
| 845 | ||
| 846 | it.effect("takes a served OpenCode 2 directory's first listing as is", () => | |
| 847 | Effect.gen(function* () { | |
| 848 | const workspace = yield* loadOpenCode2Workspace(Effect.succeed(scanned), Effect.never); | |
| 849 | NodeAssert.deepEqual(workspace, scanned); | |
| 850 | }), | |
| 851 | ); | |
| 852 | ||
| 853 | it("lists an OpenCode 2 directory's skills by id and its commands after /compact", () => { | |
| 854 | NodeAssert.deepEqual( | |
| 855 | openCode2SkillsToServerProviderSkills(scanned.skills).map((skill) => [ | |
| 856 | skill.name, | |
| 857 | skill.displayName, | |
| 858 | skill.path, | |
| 859 | ]), | |
| 860 | [ | |
| 861 | ["greet", undefined, "/work/.opencode/skills/greet/SKILL.md"], | |
| 862 | ["opencode", "OpenCode", "/builtin/opencode.md"], | |
| 863 | ["report", "Report", "/builtin/report.md"], | |
| 864 | ], | |
| 865 | ); | |
| 866 | NodeAssert.deepEqual( | |
| 867 | openCode2CommandsToServerProviderSlashCommands([ | |
| 868 | ...scanned.commands, | |
| 869 | { name: "compact", description: "a workspace command named like T3's own" }, | |
| 870 | ]).map((command) => command.name), | |
| 871 | ["compact", "init", "review", "hello"], | |
| 872 | ); | |
| 873 | }); |
apps/server/src/provider/OpenCodeProvider.ts deleted-747| ... | ... | @@ -1,747 +0,0 @@ |
| 1 | import { | |
| 2 | type ModelCapabilities, | |
| 3 | type OpenCodeSettings, | |
| 4 | type ServerProviderModel, | |
| 5 | type ServerProviderSkill, | |
| 6 | type ServerProviderSlashCommand, | |
| 7 | } from "@t3tools/contracts"; | |
| 8 | import * as Cause from "effect/Cause"; | |
| 9 | import * as Data from "effect/Data"; | |
| 10 | import * as DateTime from "effect/DateTime"; | |
| 11 | import * as Effect from "effect/Effect"; | |
| 12 | import * as Schedule from "effect/Schedule"; | |
| 13 | ||
| 14 | import { createModelCapabilities } from "@t3tools/shared/model"; | |
| 15 | import { compareSemverVersions } from "@t3tools/shared/semver"; | |
| 16 | import { | |
| 17 | buildServerProvider, | |
| 18 | COMPACT_SLASH_COMMAND, | |
| 19 | nonEmptyTrimmed, | |
| 20 | providerModelsFromSettings, | |
| 21 | type ServerProviderDraft, | |
| 22 | } from "@t3tools/provider-core/server/snapshotProbe"; | |
| 23 | import * as OpenCodeRuntime from "./opencodeRuntime.ts"; | |
| 24 | import type { ProbedOpenCode } from "./opencodeVersionProbe.ts"; | |
| 25 | import type { Agent, ProviderListResponse } from "@opencode-ai/sdk/v2"; | |
| 26 | import * as OpenCodeServerOwner from "./OpenCodeServerOwner.ts"; | |
| 27 | ||
| 28 | const OPENCODE_PRESENTATION = { | |
| 29 | displayName: "OpenCode", | |
| 30 | showInteractionModeToggle: false, | |
| 31 | } as const; | |
| 32 | ||
| 33 | class OpenCodeProbeError extends Data.TaggedError("OpenCodeProbeError")<{ | |
| 34 | readonly cause?: unknown; | |
| 35 | readonly detail: string; | |
| 36 | }> {} | |
| 37 | ||
| 38 | function normalizeProbeMessage(message: string): string | undefined { | |
| 39 | const trimmed = message.trim(); | |
| 40 | if (trimmed.length === 0) { | |
| 41 | return undefined; | |
| 42 | } | |
| 43 | if ( | |
| 44 | trimmed === "An error occurred in Effect.tryPromise" || | |
| 45 | trimmed === "An error occurred in Effect.try" | |
| 46 | ) { | |
| 47 | return undefined; | |
| 48 | } | |
| 49 | return trimmed; | |
| 50 | } | |
| 51 | ||
| 52 | function normalizedErrorMessage(cause: unknown): string | undefined { | |
| 53 | if (cause instanceof OpenCodeProbeError) { | |
| 54 | return normalizeProbeMessage(cause.detail); | |
| 55 | } | |
| 56 | ||
| 57 | if (!(cause instanceof Error)) { | |
| 58 | return undefined; | |
| 59 | } | |
| 60 | ||
| 61 | return normalizeProbeMessage(cause.message); | |
| 62 | } | |
| 63 | ||
| 64 | function formatOpenCodeProbeError(input: { | |
| 65 | readonly cause: unknown; | |
| 66 | readonly isExternalServer: boolean; | |
| 67 | readonly phase: "version" | "inventory"; | |
| 68 | readonly serverUrl: string; | |
| 69 | }): { readonly installed: boolean; readonly message: string } { | |
| 70 | const detail = normalizedErrorMessage(input.cause); | |
| 71 | const lower = detail?.toLowerCase() ?? ""; | |
| 72 | ||
| 73 | if (input.isExternalServer) { | |
| 74 | if ( | |
| 75 | lower.includes("401") || | |
| 76 | lower.includes("403") || | |
| 77 | lower.includes("unauthorized") || | |
| 78 | lower.includes("forbidden") | |
| 79 | ) { | |
| 80 | return { | |
| 81 | installed: true, | |
| 82 | message: "OpenCode server rejected authentication. Check the server URL and password.", | |
| 83 | }; | |
| 84 | } | |
| 85 | ||
| 86 | if ( | |
| 87 | lower.includes("econnrefused") || | |
| 88 | lower.includes("enotfound") || | |
| 89 | lower.includes("fetch failed") || | |
| 90 | lower.includes("networkerror") || | |
| 91 | lower.includes("timed out") || | |
| 92 | lower.includes("timeout") || | |
| 93 | lower.includes("socket hang up") | |
| 94 | ) { | |
| 95 | return { | |
| 96 | installed: true, | |
| 97 | message: `Couldn't reach the configured OpenCode server at ${input.serverUrl}. Check that the server is running and the URL is correct.`, | |
| 98 | }; | |
| 99 | } | |
| 100 | ||
| 101 | return { | |
| 102 | installed: true, | |
| 103 | message: detail ?? "Failed to connect to the configured OpenCode server.", | |
| 104 | }; | |
| 105 | } | |
| 106 | ||
| 107 | if (lower.includes("enoent") || lower.includes("notfound")) { | |
| 108 | return { | |
| 109 | installed: false, | |
| 110 | message: "OpenCode CLI (`opencode`) is not installed or not on PATH.", | |
| 111 | }; | |
| 112 | } | |
| 113 | ||
| 114 | if (lower.includes("quarantine")) { | |
| 115 | return { | |
| 116 | installed: true, | |
| 117 | message: | |
| 118 | "macOS is blocking the OpenCode binary (quarantine). Run `xattr -d com.apple.quarantine $(which opencode)` to fix this.", | |
| 119 | }; | |
| 120 | } | |
| 121 | ||
| 122 | if (lower.includes("invalid code signature") || lower.includes("corrupted")) { | |
| 123 | return { | |
| 124 | installed: true, | |
| 125 | message: | |
| 126 | "macOS killed the OpenCode process due to an invalid code signature. The binary may be corrupted — try reinstalling OpenCode.", | |
| 127 | }; | |
| 128 | } | |
| 129 | ||
| 130 | const failureLabel = | |
| 131 | input.phase === "inventory" | |
| 132 | ? "Failed to load OpenCode provider inventory" | |
| 133 | : "Failed to execute OpenCode CLI health check"; | |
| 134 | return { | |
| 135 | installed: true, | |
| 136 | message: detail ? `${failureLabel}: ${detail}` : `${failureLabel}.`, | |
| 137 | }; | |
| 138 | } | |
| 139 | ||
| 140 | function titleCaseSlug(value: string): string { | |
| 141 | const segments: Array<string> = []; | |
| 142 | for (const segment of value.split(/[-_/]+/)) { | |
| 143 | if (segment.length > 0) { | |
| 144 | segments.push(segment.charAt(0).toUpperCase() + segment.slice(1)); | |
| 145 | } | |
| 146 | } | |
| 147 | return segments.join(" "); | |
| 148 | } | |
| 149 | ||
| 150 | function inferDefaultVariant( | |
| 151 | providerID: string, | |
| 152 | variants: ReadonlyArray<string>, | |
| 153 | ): string | undefined { | |
| 154 | if (variants.length === 1) { | |
| 155 | return variants[0]; | |
| 156 | } | |
| 157 | if (providerID === "anthropic" || providerID.startsWith("google")) { | |
| 158 | return variants.includes("high") ? "high" : undefined; | |
| 159 | } | |
| 160 | if (providerID === "openai" || providerID === "opencode") { | |
| 161 | return variants.includes("medium") ? "medium" : variants.includes("high") ? "high" : undefined; | |
| 162 | } | |
| 163 | return undefined; | |
| 164 | } | |
| 165 | ||
| 166 | function inferDefaultAgent(agents: ReadonlyArray<Agent>): string | undefined { | |
| 167 | return agents.find((agent) => agent.name === "build")?.name ?? agents[0]?.name ?? undefined; | |
| 168 | } | |
| 169 | ||
| 170 | const DEFAULT_OPENCODE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ | |
| 171 | optionDescriptors: [ | |
| 172 | { | |
| 173 | id: "variant", | |
| 174 | label: "Reasoning", | |
| 175 | type: "select", | |
| 176 | options: [ | |
| 177 | { id: "low", label: "Low" }, | |
| 178 | { id: "medium", label: "Medium", isDefault: true }, | |
| 179 | { id: "high", label: "High" }, | |
| 180 | { id: "xhigh", label: "Extra High" }, | |
| 181 | ], | |
| 182 | currentValue: "medium", | |
| 183 | }, | |
| 184 | { | |
| 185 | id: "agent", | |
| 186 | label: "Agent", | |
| 187 | type: "select", | |
| 188 | options: [ | |
| 189 | { id: "build", label: "Build", isDefault: true }, | |
| 190 | { id: "plan", label: "Plan" }, | |
| 191 | ], | |
| 192 | currentValue: "build", | |
| 193 | }, | |
| 194 | ], | |
| 195 | }); | |
| 196 | ||
| 197 | function openCodeCapabilitiesForModel(input: { | |
| 198 | readonly providerID: string; | |
| 199 | readonly model: ProviderListResponse["all"][number]["models"][string]; | |
| 200 | readonly agents: ReadonlyArray<Agent>; | |
| 201 | }): ModelCapabilities { | |
| 202 | const rawVariantValues = Object.keys(input.model.variants ?? {}); | |
| 203 | // When a model advertises no variants, synthesize the standard reasoning | |
| 204 | // levels so the composer still offers a Reasoning selector (mirrors the | |
| 205 | // Codex/Grok experience where reasoning is always configurable). The set | |
| 206 | // covers the common OpenCode variant spectrum; `inferDefaultVariant` | |
| 207 | // picks the provider-appropriate default (e.g. medium for openai/opencode). | |
| 208 | const variantValues = | |
| 209 | rawVariantValues.length > 0 ? rawVariantValues : ["low", "medium", "high", "xhigh"]; | |
| 210 | const defaultVariant = inferDefaultVariant(input.providerID, variantValues); | |
| 211 | const variantOptions = variantValues.map((value) => | |
| 212 | defaultVariant === value | |
| 213 | ? { id: value, label: titleCaseSlug(value), isDefault: true as const } | |
| 214 | : { id: value, label: titleCaseSlug(value) }, | |
| 215 | ); | |
| 216 | const primaryAgents = input.agents.filter( | |
| 217 | (agent) => !agent.hidden && (agent.mode === "primary" || agent.mode === "all"), | |
| 218 | ); | |
| 219 | const defaultAgent = inferDefaultAgent(primaryAgents); | |
| 220 | const agentOptions = primaryAgents.map((agent) => | |
| 221 | defaultAgent === agent.name | |
| 222 | ? { id: agent.name, label: titleCaseSlug(agent.name), isDefault: true as const } | |
| 223 | : { id: agent.name, label: titleCaseSlug(agent.name) }, | |
| 224 | ); | |
| 225 | return createModelCapabilities({ | |
| 226 | optionDescriptors: [ | |
| 227 | ...(variantOptions.length > 0 | |
| 228 | ? [ | |
| 229 | { | |
| 230 | id: "variant", | |
| 231 | label: "Reasoning", | |
| 232 | type: "select" as const, | |
| 233 | options: variantOptions, | |
| 234 | ...(defaultVariant ? { currentValue: defaultVariant } : {}), | |
| 235 | }, | |
| 236 | ] | |
| 237 | : []), | |
| 238 | ...(agentOptions.length > 0 | |
| 239 | ? [ | |
| 240 | { | |
| 241 | id: "agent", | |
| 242 | label: "Agent", | |
| 243 | type: "select" as const, | |
| 244 | options: agentOptions, | |
| 245 | ...(defaultAgent ? { currentValue: defaultAgent } : {}), | |
| 246 | }, | |
| 247 | ] | |
| 248 | : []), | |
| 249 | ], | |
| 250 | }); | |
| 251 | } | |
| 252 | ||
| 253 | function flattenOpenCodeModels( | |
| 254 | input: OpenCodeRuntime.OpenCodeInventory, | |
| 255 | ): ReadonlyArray<ServerProviderModel> { | |
| 256 | const connected = new Set(input.providerList.connected); | |
| 257 | const models: Array<ServerProviderModel> = []; | |
| 258 | ||
| 259 | for (const provider of input.providerList.all) { | |
| 260 | if (!connected.has(provider.id)) { | |
| 261 | continue; | |
| 262 | } | |
| 263 | ||
| 264 | for (const model of Object.values(provider.models)) { | |
| 265 | const name = nonEmptyTrimmed(model.name); | |
| 266 | if (!name) { | |
| 267 | continue; | |
| 268 | } | |
| 269 | ||
| 270 | const subProvider = nonEmptyTrimmed(provider.name); | |
| 271 | models.push({ | |
| 272 | slug: `${provider.id}/${model.id}`, | |
| 273 | name, | |
| 274 | ...(subProvider ? { subProvider } : {}), | |
| 275 | isCustom: false, | |
| 276 | capabilities: openCodeCapabilitiesForModel({ | |
| 277 | providerID: provider.id, | |
| 278 | model, | |
| 279 | agents: input.agents, | |
| 280 | }), | |
| 281 | }); | |
| 282 | } | |
| 283 | } | |
| 284 | ||
| 285 | return models.toSorted((left, right) => left.name.localeCompare(right.name)); | |
| 286 | } | |
| 287 | ||
| 288 | function trimOptional(value: string | null | undefined): string | undefined { | |
| 289 | const trimmed = value?.trim(); | |
| 290 | return trimmed && trimmed.length > 0 ? trimmed : undefined; | |
| 291 | } | |
| 292 | ||
| 293 | export function openCodeSkillsToServerProviderSkills( | |
| 294 | input: OpenCodeRuntime.OpenCodeInventory["skills"] | undefined, | |
| 295 | ): ReadonlyArray<ServerProviderSkill> { | |
| 296 | const skills: ServerProviderSkill[] = []; | |
| 297 | for (const skill of input ?? []) { | |
| 298 | const name = trimOptional(skill.name); | |
| 299 | const path = trimOptional(skill.location); | |
| 300 | if (!name || !path) { | |
| 301 | continue; | |
| 302 | } | |
| 303 | ||
| 304 | const description = trimOptional(skill.description); | |
| 305 | skills.push({ | |
| 306 | name, | |
| 307 | path, | |
| 308 | enabled: true, | |
| 309 | ...(description ? { description, shortDescription: description } : {}), | |
| 310 | }); | |
| 311 | } | |
| 312 | ||
| 313 | return skills.toSorted((left, right) => left.name.localeCompare(right.name)); | |
| 314 | } | |
| 315 | ||
| 316 | export function openCodeCommandsToServerProviderSlashCommands( | |
| 317 | input: OpenCodeRuntime.OpenCodeInventory["commands"], | |
| 318 | ): ReadonlyArray<ServerProviderSlashCommand> { | |
| 319 | const commands: ServerProviderSlashCommand[] = [COMPACT_SLASH_COMMAND]; | |
| 320 | const names = new Set([COMPACT_SLASH_COMMAND.name]); | |
| 321 | for (const command of input ?? []) { | |
| 322 | const name = trimOptional(command.name); | |
| 323 | if (!name || names.has(name) || command.source === "skill") continue; | |
| 324 | names.add(name); | |
| 325 | const description = trimOptional(command.description); | |
| 326 | const hint = trimOptional(command.hints.join(" ")); | |
| 327 | commands.push({ | |
| 328 | name, | |
| 329 | ...(description ? { description } : {}), | |
| 330 | ...(hint ? { input: { hint } } : {}), | |
| 331 | }); | |
| 332 | } | |
| 333 | return commands; | |
| 334 | } | |
| 335 | ||
| 336 | export const makePendingOpenCodeProvider = ( | |
| 337 | openCodeSettings: OpenCodeSettings, | |
| 338 | ): Effect.Effect<ServerProviderDraft> => | |
| 339 | Effect.gen(function* () { | |
| 340 | const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); | |
| 341 | const models = providerModelsFromSettings( | |
| 342 | [], | |
| 343 | openCodeSettings.customModels, | |
| 344 | DEFAULT_OPENCODE_MODEL_CAPABILITIES, | |
| 345 | ); | |
| 346 | ||
| 347 | if (!openCodeSettings.enabled) { | |
| 348 | return buildServerProvider({ | |
| 349 | presentation: OPENCODE_PRESENTATION, | |
| 350 | enabled: false, | |
| 351 | checkedAt, | |
| 352 | models, | |
| 353 | probe: { | |
| 354 | installed: false, | |
| 355 | version: null, | |
| 356 | status: "warning", | |
| 357 | auth: { status: "unknown" }, | |
| 358 | message: | |
| 359 | openCodeSettings.serverUrl.trim().length > 0 | |
| 360 | ? "OpenCode is disabled in T3 Code settings. A server URL is configured." | |
| 361 | : "OpenCode is disabled in T3 Code settings.", | |
| 362 | }, | |
| 363 | }); | |
| 364 | } | |
| 365 | ||
| 366 | return buildServerProvider({ | |
| 367 | presentation: OPENCODE_PRESENTATION, | |
| 368 | enabled: true, | |
| 369 | checkedAt, | |
| 370 | models, | |
| 371 | probe: { | |
| 372 | installed: false, | |
| 373 | version: null, | |
| 374 | status: "warning", | |
| 375 | auth: { status: "unknown" }, | |
| 376 | message: "OpenCode provider status has not been checked in this session yet.", | |
| 377 | }, | |
| 378 | }); | |
| 379 | }); | |
| 380 | ||
| 381 | /** One model an OpenCode 2 server lists, as the status check reads it. */ | |
| 382 | export interface OpenCode2Model { | |
| 383 | readonly providerID: string; | |
| 384 | readonly id: string; | |
| 385 | readonly name: string; | |
| 386 | readonly variants: ReadonlyArray<{ readonly id: string }>; | |
| 387 | } | |
| 388 | ||
| 389 | /** | |
| 390 | * A server loads its catalog lazily and lists nothing for its first few | |
| 391 | * hundred milliseconds, and a local instance starts a fresh server for each | |
| 392 | * status check. So an empty list is read again for a while, and the last | |
| 393 | * non-empty list stands in if the catalog still has not loaded. | |
| 394 | */ | |
| 395 | export const makeOpenCode2ModelLoader = <E>( | |
| 396 | list: Effect.Effect<ReadonlyArray<OpenCode2Model>, E>, | |
| 397 | ) => | |
| 398 | Effect.sync(() => { | |
| 399 | let lastLoaded: ReadonlyArray<OpenCode2Model> = []; | |
| 400 | return list.pipe( | |
| 401 | Effect.repeat({ | |
| 402 | until: (models) => models.length > 0, | |
| 403 | schedule: Schedule.spaced("250 millis"), | |
| 404 | }), | |
| 405 | Effect.timeoutOption("5 seconds"), | |
| 406 | Effect.map((loaded) => { | |
| 407 | if (loaded._tag === "Some" && loaded.value.length > 0) lastLoaded = loaded.value; | |
| 408 | return lastLoaded; | |
| 409 | }), | |
| 410 | ); | |
| 411 | }); | |
| 412 | ||
| 413 | /** A workspace's skills and commands, as an OpenCode 2 server lists them for its directory. */ | |
| 414 | export interface OpenCode2Workspace { | |
| 415 | readonly skills: ReadonlyArray<{ | |
| 416 | readonly id: string; | |
| 417 | readonly name: string; | |
| 418 | readonly path: string; | |
| 419 | readonly description?: string | undefined; | |
| 420 | }>; | |
| 421 | readonly commands: ReadonlyArray<{ | |
| 422 | readonly name: string; | |
| 423 | readonly description?: string | undefined; | |
| 424 | }>; | |
| 425 | } | |
| 426 | ||
| 427 | /** | |
| 428 | * A server scans a directory it has not served yet in the background: its | |
| 429 | * first read lists no commands at all (not even the built-ins), and | |
| 430 | * `command.updated` plus `skill.updated` for the directory end the scan. A | |
| 431 | * directory it already serves lists everything at once. `scanned` must be | |
| 432 | * listening before `read` runs, since the read is what starts the scan. | |
| 433 | */ | |
| 434 | export const loadOpenCode2Workspace = <E>( | |
| 435 | read: Effect.Effect<OpenCode2Workspace, E>, | |
| 436 | scanned: Effect.Effect<void>, | |
| 437 | ) => | |
| 438 | Effect.gen(function* () { | |
| 439 | const first = yield* read; | |
| 440 | if (first.commands.length > 0) return first; | |
| 441 | yield* scanned.pipe(Effect.timeoutOption("10 seconds")); | |
| 442 | return yield* read; | |
| 443 | }); | |
| 444 | ||
| 445 | export function openCode2SkillsToServerProviderSkills( | |
| 446 | skills: OpenCode2Workspace["skills"], | |
| 447 | ): ReadonlyArray<ServerProviderSkill> { | |
| 448 | return skills | |
| 449 | .map((skill) => { | |
| 450 | const description = trimOptional(skill.description); | |
| 451 | return { | |
| 452 | name: skill.id, | |
| 453 | path: skill.path, | |
| 454 | enabled: true, | |
| 455 | ...(skill.name === skill.id ? {} : { displayName: skill.name }), | |
| 456 | ...(description ? { description, shortDescription: description } : {}), | |
| 457 | }; | |
| 458 | }) | |
| 459 | .toSorted((left, right) => left.name.localeCompare(right.name)); | |
| 460 | } | |
| 461 | ||
| 462 | export function openCode2CommandsToServerProviderSlashCommands( | |
| 463 | commands: OpenCode2Workspace["commands"], | |
| 464 | ): ReadonlyArray<ServerProviderSlashCommand> { | |
| 465 | const slashCommands: ServerProviderSlashCommand[] = [COMPACT_SLASH_COMMAND]; | |
| 466 | const names = new Set([COMPACT_SLASH_COMMAND.name]); | |
| 467 | for (const command of commands) { | |
| 468 | const name = trimOptional(command.name); | |
| 469 | if (!name || names.has(name)) continue; | |
| 470 | names.add(name); | |
| 471 | const description = trimOptional(command.description); | |
| 472 | slashCommands.push({ name, ...(description ? { description } : {}) }); | |
| 473 | } | |
| 474 | return slashCommands; | |
| 475 | } | |
| 476 | ||
| 477 | /** | |
| 478 | * Every mode maps onto OpenCode 2 session rules. Auto asks like Supervised: | |
| 479 | * OpenCode has no reviewer that approves routine actions. Plan mode is its | |
| 480 | * `plan` agent, so the composer's mode toggle drives it. | |
| 481 | */ | |
| 482 | const OPENCODE_2_PRESENTATION = { | |
| 483 | ...OPENCODE_PRESENTATION, | |
| 484 | showInteractionModeToggle: true, | |
| 485 | supportedRuntimeModes: ["approval-required", "auto-accept-edits", "auto", "full-access"], | |
| 486 | } as const; | |
| 487 | ||
| 488 | function openCode2ModelCapabilities(model: OpenCode2Model): ModelCapabilities { | |
| 489 | const variants = model.variants.map((variant) => variant.id); | |
| 490 | const defaultVariant = inferDefaultVariant(model.providerID, variants); | |
| 491 | return createModelCapabilities({ | |
| 492 | optionDescriptors: | |
| 493 | variants.length === 0 | |
| 494 | ? [] | |
| 495 | : [ | |
| 496 | { | |
| 497 | id: "variant", | |
| 498 | label: "Reasoning", | |
| 499 | type: "select", | |
| 500 | options: variants.map((id) => ({ | |
| 501 | id, | |
| 502 | label: titleCaseSlug(id), | |
| 503 | ...(id === defaultVariant ? { isDefault: true } : {}), | |
| 504 | })), | |
| 505 | ...(defaultVariant === undefined ? {} : { currentValue: defaultVariant }), | |
| 506 | }, | |
| 507 | ], | |
| 508 | }); | |
| 509 | } | |
| 510 | ||
| 511 | const checkOpenCode2 = Effect.fn("checkOpenCode2")(function* ( | |
| 512 | settings: OpenCodeSettings, | |
| 513 | version: string, | |
| 514 | checkedAt: string, | |
| 515 | loadModels: Effect.Effect<ReadonlyArray<OpenCode2Model>, OpenCodeRuntime.OpenCodeRuntimeError>, | |
| 516 | ) { | |
| 517 | const result = yield* Effect.exit(loadModels); | |
| 518 | const probe = (status: "ready" | "warning" | "error", message: string) => ({ | |
| 519 | installed: true, | |
| 520 | version, | |
| 521 | status, | |
| 522 | auth: { status: status === "ready" ? ("authenticated" as const) : ("unknown" as const) }, | |
| 523 | message, | |
| 524 | }); | |
| 525 | if (result._tag === "Failure") { | |
| 526 | // The detail can carry a response body; it stays in the log, not the status. | |
| 527 | yield* Effect.logWarning("OpenCode 2 model list failed", result.cause); | |
| 528 | return buildServerProvider({ | |
| 529 | presentation: OPENCODE_2_PRESENTATION, | |
| 530 | enabled: true, | |
| 531 | checkedAt, | |
| 532 | models: providerModelsFromSettings( | |
| 533 | [], | |
| 534 | settings.customModels, | |
| 535 | DEFAULT_OPENCODE_MODEL_CAPABILITIES, | |
| 536 | ), | |
| 537 | // Compaction does not depend on the model catalog. | |
| 538 | slashCommands: [COMPACT_SLASH_COMMAND], | |
| 539 | probe: probe("error", "OpenCode could not load its model list."), | |
| 540 | }); | |
| 541 | } | |
| 542 | const models = providerModelsFromSettings( | |
| 543 | result.value | |
| 544 | .map((model) => ({ | |
| 545 | slug: `${model.providerID}/${model.id}`, | |
| 546 | name: model.name, | |
| 547 | isCustom: false, | |
| 548 | capabilities: openCode2ModelCapabilities(model), | |
| 549 | })) | |
| 550 | .toSorted((left, right) => left.name.localeCompare(right.name)), | |
| 551 | settings.customModels, | |
| 552 | DEFAULT_OPENCODE_MODEL_CAPABILITIES, | |
| 553 | ); | |
| 554 | return buildServerProvider({ | |
| 555 | presentation: OPENCODE_2_PRESENTATION, | |
| 556 | enabled: true, | |
| 557 | checkedAt, | |
| 558 | models, | |
| 559 | // Every session can compact; a workspace snapshot adds that directory's commands. | |
| 560 | slashCommands: [COMPACT_SLASH_COMMAND], | |
| 561 | probe: | |
| 562 | result.value.length > 0 | |
| 563 | ? probe( | |
| 564 | "ready", | |
| 565 | `OpenCode ${version} lists ${result.value.length} model${result.value.length === 1 ? "" : "s"}.`, | |
| 566 | ) | |
| 567 | : probe("warning", "OpenCode 2 is running, but it did not list any models yet."), | |
| 568 | }); | |
| 569 | }); | |
| 570 | ||
| 571 | /** | |
| 572 | * `probeRuntime` is the driver's memoized version probe: `opencode --version` for a local binary, | |
| 573 | * the version endpoints for a configured server. The status check refreshes it, so an in-place | |
| 574 | * upgrade re-routes the instance. | |
| 575 | */ | |
| 576 | export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatus")(function* ( | |
| 577 | openCodeSettings: OpenCodeSettings, | |
| 578 | cwd: string, | |
| 579 | probeRuntime: Effect.Effect<ProbedOpenCode, OpenCodeRuntime.OpenCodeRuntimeError>, | |
| 580 | loadOpenCode2Models: Effect.Effect< | |
| 581 | ReadonlyArray<OpenCode2Model>, | |
| 582 | OpenCodeRuntime.OpenCodeRuntimeError | |
| 583 | >, | |
| 584 | ): Effect.fn.Return< | |
| 585 | ServerProviderDraft, | |
| 586 | never, | |
| 587 | OpenCodeRuntime.OpenCodeRuntime | OpenCodeServerOwner.OpenCodeServerOwner | |
| 588 | > { | |
| 589 | const openCodeRuntime = yield* OpenCodeRuntime.OpenCodeRuntime; | |
| 590 | const serverOwner = yield* OpenCodeServerOwner.OpenCodeServerOwner; | |
| 591 | const checkedAt = DateTime.formatIso(yield* DateTime.now); | |
| 592 | const customModels = openCodeSettings.customModels; | |
| 593 | const isExternalServer = openCodeSettings.serverUrl.trim().length > 0; | |
| 594 | ||
| 595 | const fallback = ( | |
| 596 | cause: unknown, | |
| 597 | version: string | null = null, | |
| 598 | phase: "version" | "inventory" = "version", | |
| 599 | ) => { | |
| 600 | const failure = formatOpenCodeProbeError({ | |
| 601 | cause, | |
| 602 | isExternalServer, | |
| 603 | phase, | |
| 604 | serverUrl: openCodeSettings.serverUrl, | |
| 605 | }); | |
| 606 | return buildServerProvider({ | |
| 607 | presentation: OPENCODE_PRESENTATION, | |
| 608 | enabled: openCodeSettings.enabled, | |
| 609 | checkedAt, | |
| 610 | models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES), | |
| 611 | probe: { | |
| 612 | installed: failure.installed, | |
| 613 | version, | |
| 614 | status: "error", | |
| 615 | auth: { status: "unknown" }, | |
| 616 | message: failure.message, | |
| 617 | }, | |
| 618 | }); | |
| 619 | }; | |
| 620 | ||
| 621 | if (!openCodeSettings.enabled) { | |
| 622 | return buildServerProvider({ | |
| 623 | presentation: OPENCODE_PRESENTATION, | |
| 624 | enabled: false, | |
| 625 | checkedAt, | |
| 626 | models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES), | |
| 627 | probe: { | |
| 628 | installed: false, | |
| 629 | version: null, | |
| 630 | status: "warning", | |
| 631 | auth: { status: "unknown" }, | |
| 632 | message: isExternalServer | |
| 633 | ? "OpenCode is disabled in T3 Code settings. A server URL is configured." | |
| 634 | : "OpenCode is disabled in T3 Code settings.", | |
| 635 | }, | |
| 636 | }); | |
| 637 | } | |
| 638 | ||
| 639 | const probedExit = yield* Effect.exit( | |
| 640 | probeRuntime.pipe( | |
| 641 | Effect.mapError( | |
| 642 | (cause) => | |
| 643 | new OpenCodeProbeError({ | |
| 644 | cause, | |
| 645 | detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause), | |
| 646 | }), | |
| 647 | ), | |
| 648 | ), | |
| 649 | ); | |
| 650 | if (probedExit._tag === "Failure") return fallback(Cause.squash(probedExit.cause)); | |
| 651 | const probed = probedExit.value; | |
| 652 | if (probed.generation === "v2") { | |
| 653 | return yield* checkOpenCode2(openCodeSettings, probed.version, checkedAt, loadOpenCode2Models); | |
| 654 | } | |
| 655 | let version: string | null = probed.version; | |
| 656 | if (compareSemverVersions(probed.version, OpenCodeRuntime.MINIMUM_OPENCODE_VERSION) < 0) { | |
| 657 | return buildServerProvider({ | |
| 658 | presentation: OPENCODE_PRESENTATION, | |
| 659 | enabled: openCodeSettings.enabled, | |
| 660 | checkedAt, | |
| 661 | models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES), | |
| 662 | probe: { | |
| 663 | installed: true, | |
| 664 | version: probed.version, | |
| 665 | status: "error", | |
| 666 | auth: { status: "unknown" }, | |
| 667 | message: `OpenCode v${probed.version} is too old. Upgrade to v${OpenCodeRuntime.MINIMUM_OPENCODE_VERSION} or newer.`, | |
| 668 | }, | |
| 669 | }); | |
| 670 | } | |
| 671 | ||
| 672 | const loadInventory = (server: { | |
| 673 | readonly url: string; | |
| 674 | readonly serverPassword?: string; | |
| 675 | readonly version: string; | |
| 676 | }) => | |
| 677 | openCodeRuntime | |
| 678 | .loadOpenCodeInventory( | |
| 679 | openCodeRuntime.createOpenCodeSdkClient({ | |
| 680 | baseUrl: server.url, | |
| 681 | directory: cwd, | |
| 682 | ...(server.serverPassword !== undefined ? { serverPassword: server.serverPassword } : {}), | |
| 683 | }), | |
| 684 | ) | |
| 685 | .pipe(Effect.map((inventory) => ({ inventory, version: server.version }))); | |
| 686 | const inventoryEffect = isExternalServer | |
| 687 | ? openCodeRuntime | |
| 688 | .connectToOpenCodeServer({ | |
| 689 | binaryPath: openCodeSettings.binaryPath, | |
| 690 | directory: cwd, | |
| 691 | serverUrl: openCodeSettings.serverUrl, | |
| 692 | ...(openCodeSettings.serverPassword | |
| 693 | ? { serverPassword: openCodeSettings.serverPassword } | |
| 694 | : {}), | |
| 695 | }) | |
| 696 | .pipe(Effect.flatMap(loadInventory), Effect.scoped) | |
| 697 | : serverOwner.withServer(loadInventory); | |
| 698 | const inventoryExit = yield* Effect.exit( | |
| 699 | inventoryEffect.pipe( | |
| 700 | Effect.mapError( | |
| 701 | (cause) => | |
| 702 | new OpenCodeProbeError({ | |
| 703 | cause, | |
| 704 | detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause), | |
| 705 | }), | |
| 706 | ), | |
| 707 | ), | |
| 708 | ); | |
| 709 | if (inventoryExit._tag === "Failure") { | |
| 710 | return fallback(Cause.squash(inventoryExit.cause), version, "inventory"); | |
| 711 | } | |
| 712 | ||
| 713 | version = inventoryExit.value.version; | |
| 714 | ||
| 715 | const models = providerModelsFromSettings( | |
| 716 | flattenOpenCodeModels(inventoryExit.value.inventory), | |
| 717 | customModels, | |
| 718 | DEFAULT_OPENCODE_MODEL_CAPABILITIES, | |
| 719 | ); | |
| 720 | const skills = openCodeSkillsToServerProviderSkills(inventoryExit.value.inventory.skills); | |
| 721 | const connectedCount = inventoryExit.value.inventory.providerList.connected.length; | |
| 722 | return buildServerProvider({ | |
| 723 | presentation: OPENCODE_PRESENTATION, | |
| 724 | enabled: true, | |
| 725 | checkedAt, | |
| 726 | models, | |
| 727 | skills, | |
| 728 | slashCommands: openCodeCommandsToServerProviderSlashCommands( | |
| 729 | inventoryExit.value.inventory.commands, | |
| 730 | ), | |
| 731 | probe: { | |
| 732 | installed: true, | |
| 733 | version, | |
| 734 | status: connectedCount > 0 ? "ready" : "warning", | |
| 735 | auth: { | |
| 736 | status: connectedCount > 0 ? "authenticated" : "unknown", | |
| 737 | type: "opencode", | |
| 738 | }, | |
| 739 | message: | |
| 740 | connectedCount > 0 | |
| 741 | ? `${connectedCount} upstream provider${connectedCount === 1 ? "" : "s"} connected through ${isExternalServer ? "the configured OpenCode server" : "OpenCode"}.` | |
| 742 | : isExternalServer | |
| 743 | ? "Connected to the configured OpenCode server, but it did not report any connected upstream providers." | |
| 744 | : "OpenCode is available, but it did not report any connected upstream providers.", | |
| 745 | }, | |
| 746 | }); | |
| 747 | }); |
apps/server/src/provider/OpenCodeServerLedger.test.ts deleted-244| ... | ... | @@ -1,244 +0,0 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off | |
| 2 | import * as NodeChildProcess from "node:child_process"; | |
| 3 | ||
| 4 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 5 | import { describe, expect, it } from "@effect/vitest"; | |
| 6 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 7 | import * as Deferred from "effect/Deferred"; | |
| 8 | import * as Effect from "effect/Effect"; | |
| 9 | import * as Exit from "effect/Exit"; | |
| 10 | import * as Fiber from "effect/Fiber"; | |
| 11 | import * as FileSystem from "effect/FileSystem"; | |
| 12 | import * as Layer from "effect/Layer"; | |
| 13 | import * as Path from "effect/Path"; | |
| 14 | import * as Scope from "effect/Scope"; | |
| 15 | import { ChildProcessSpawner } from "effect/process"; | |
| 16 | ||
| 17 | import * as OpenCodeRuntime from "./opencodeRuntime.ts"; | |
| 18 | import * as OpenCodeServerLedger from "./OpenCodeServerLedger.ts"; | |
| 19 | ||
| 20 | const SERVE_ARGS = ["serve", "--hostname=127.0.0.1", "--port=4096"]; | |
| 21 | ||
| 22 | const groupExists = (pgid: number) => { | |
| 23 | try { | |
| 24 | process.kill(-pgid, 0); | |
| 25 | return true; | |
| 26 | } catch { | |
| 27 | return false; | |
| 28 | } | |
| 29 | }; | |
| 30 | ||
| 31 | const killGroup = (pgid: number) => | |
| 32 | Effect.sync(() => { | |
| 33 | if (groupExists(pgid)) process.kill(-pgid, "SIGKILL"); | |
| 34 | }); | |
| 35 | ||
| 36 | /** | |
| 37 | * Spawns a throwaway process group shaped like `opencode serve`: by default a | |
| 38 | * shell leader whose argv ends in the serve arguments, plus a `sleep` member. | |
| 39 | */ | |
| 40 | const spawnGroup = (args: ReadonlyArray<string>, script = "sleep 600 & wait") => | |
| 41 | Effect.acquireRelease( | |
| 42 | Effect.gen(function* () { | |
| 43 | const child = NodeChildProcess.spawn("/bin/sh", ["-c", script, ...args], { | |
| 44 | detached: true, | |
| 45 | stdio: "ignore", | |
| 46 | }); | |
| 47 | const exited = yield* Deferred.make<NodeJS.Signals | null>(); | |
| 48 | child.once("exit", (_code, signal) => Deferred.doneUnsafe(exited, Effect.succeed(signal))); | |
| 49 | const pid = child.pid; | |
| 50 | if (pid === undefined) return yield* Effect.die("spawn failed"); | |
| 51 | return { pid, exited: Deferred.await(exited) }; | |
| 52 | }), | |
| 53 | ({ pid }) => killGroup(pid), | |
| 54 | ); | |
| 55 | ||
| 56 | /** A T3 server that recorded its OpenCode server and then died without cleanup. */ | |
| 57 | const recordFromDeadServer = (stateDir: string, server: { readonly pid: number }) => | |
| 58 | Effect.gen(function* () { | |
| 59 | const previousServer = yield* spawnGroup([]); | |
| 60 | const previousLedger = yield* OpenCodeServerLedger.make({ | |
| 61 | stateDir, | |
| 62 | ownerPid: previousServer.pid, | |
| 63 | }); | |
| 64 | // The previous server never gets to forget its entry. | |
| 65 | yield* Effect.asVoid(previousLedger.track({ pid: server.pid, port: 4096, args: SERVE_ARGS })); | |
| 66 | process.kill(-previousServer.pid, "SIGKILL"); | |
| 67 | yield* previousServer.exited; | |
| 68 | }); | |
| 69 | ||
| 70 | const hostPlatform = HostProcessPlatform.defaultValue(); | |
| 71 | // procps accepts the same `ps` flags as macOS, so Linux also covers the macOS path. | |
| 72 | const observedPlatforms: ReadonlyArray<NodeJS.Platform> = | |
| 73 | hostPlatform === "linux" ? ["linux", "darwin"] : hostPlatform === "darwin" ? ["darwin"] : []; | |
| 74 | ||
| 75 | describe.each(observedPlatforms)("OpenCodeServerLedger observing as %s", (platform) => { | |
| 76 | const provideHost = <A, E, R>(effect: Effect.Effect<A, E, R>) => | |
| 77 | effect.pipe( | |
| 78 | Effect.provideService(HostProcessPlatform, platform), | |
| 79 | Effect.provide(NodeServices.layer), | |
| 80 | ); | |
| 81 | ||
| 82 | it.live("stops an OpenCode server group left by a server that died", () => | |
| 83 | Effect.gen(function* () { | |
| 84 | const fs = yield* FileSystem.FileSystem; | |
| 85 | const path = yield* Path.Path; | |
| 86 | const stateDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-ledger-" }); | |
| 87 | const orphan = yield* spawnGroup(SERVE_ARGS); | |
| 88 | yield* recordFromDeadServer(stateDir, orphan); | |
| 89 | expect(yield* fs.readDirectory(path.join(stateDir, "opencode-servers"))).toHaveLength(1); | |
| 90 | ||
| 91 | const restarted = yield* OpenCodeServerLedger.make({ stateDir }); | |
| 92 | yield* restarted.reapOrphans; | |
| 93 | ||
| 94 | expect(yield* orphan.exited).toBe("SIGTERM"); | |
| 95 | expect(groupExists(orphan.pid)).toBe(false); | |
| 96 | expect(yield* fs.readDirectory(path.join(stateDir, "opencode-servers"))).toEqual([]); | |
| 97 | }).pipe(provideHost), | |
| 98 | ); | |
| 99 | ||
| 100 | it.live("records a group whose spawned wrapper already exited", () => | |
| 101 | Effect.gen(function* () { | |
| 102 | const fs = yield* FileSystem.FileSystem; | |
| 103 | const path = yield* Path.Path; | |
| 104 | const stateDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-ledger-" }); | |
| 105 | // The wrapper starts the serve process in its group and exits. | |
| 106 | const wrapper = yield* spawnGroup( | |
| 107 | SERVE_ARGS, | |
| 108 | '/bin/sh -c "sleep 600 & wait" "$0" "$@" & exit 0', | |
| 109 | ); | |
| 110 | yield* wrapper.exited; | |
| 111 | expect(groupExists(wrapper.pid)).toBe(true); | |
| 112 | ||
| 113 | yield* recordFromDeadServer(stateDir, wrapper); | |
| 114 | expect(yield* fs.readDirectory(path.join(stateDir, "opencode-servers"))).toHaveLength(1); | |
| 115 | ||
| 116 | const restarted = yield* OpenCodeServerLedger.make({ stateDir }); | |
| 117 | yield* restarted.reapOrphans; | |
| 118 | ||
| 119 | expect(groupExists(wrapper.pid)).toBe(false); | |
| 120 | expect(yield* fs.readDirectory(path.join(stateDir, "opencode-servers"))).toEqual([]); | |
| 121 | }).pipe(provideHost), | |
| 122 | ); | |
| 123 | ||
| 124 | it.live("leaves a recycled pid alone when its start time does not match", () => | |
| 125 | Effect.gen(function* () { | |
| 126 | const fs = yield* FileSystem.FileSystem; | |
| 127 | const path = yield* Path.Path; | |
| 128 | const stateDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-ledger-" }); | |
| 129 | const unrelated = yield* spawnGroup(SERVE_ARGS); | |
| 130 | yield* recordFromDeadServer(stateDir, unrelated); | |
| 131 | const entryPath = path.join(stateDir, "opencode-servers", `${unrelated.pid}.json`); | |
| 132 | // Same pid, a different process: what a recycled pid looks like. | |
| 133 | const entry = yield* fs.readFileString(entryPath); | |
| 134 | yield* fs.writeFileString(entryPath, entry.replace(/"startTime":"[^"]+"/, '"startTime":"1"')); | |
| 135 | ||
| 136 | const restarted = yield* OpenCodeServerLedger.make({ stateDir }); | |
| 137 | yield* restarted.reapOrphans; | |
| 138 | ||
| 139 | expect(groupExists(unrelated.pid)).toBe(true); | |
| 140 | expect(yield* fs.exists(entryPath)).toBe(false); | |
| 141 | }).pipe(provideHost), | |
| 142 | ); | |
| 143 | ||
| 144 | it.live("leaves a serve process on another port alone", () => | |
| 145 | Effect.gen(function* () { | |
| 146 | const fs = yield* FileSystem.FileSystem; | |
| 147 | const path = yield* Path.Path; | |
| 148 | const stateDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-ledger-" }); | |
| 149 | const unrelated = yield* spawnGroup(SERVE_ARGS); | |
| 150 | yield* recordFromDeadServer(stateDir, unrelated); | |
| 151 | const entryPath = path.join(stateDir, "opencode-servers", `${unrelated.pid}.json`); | |
| 152 | // Same pid and start second, but not the server T3 started. | |
| 153 | const entry = yield* fs.readFileString(entryPath); | |
| 154 | yield* fs.writeFileString(entryPath, entry.replace("--port=4096", "--port=4097")); | |
| 155 | ||
| 156 | const restarted = yield* OpenCodeServerLedger.make({ stateDir }); | |
| 157 | yield* restarted.reapOrphans; | |
| 158 | ||
| 159 | expect(groupExists(unrelated.pid)).toBe(true); | |
| 160 | expect(yield* fs.exists(entryPath)).toBe(false); | |
| 161 | }).pipe(provideHost), | |
| 162 | ); | |
| 163 | ||
| 164 | it.live("leaves the servers of a running T3 server alone", () => | |
| 165 | Effect.gen(function* () { | |
| 166 | const fs = yield* FileSystem.FileSystem; | |
| 167 | const path = yield* Path.Path; | |
| 168 | const stateDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-ledger-" }); | |
| 169 | const running = yield* OpenCodeServerLedger.make({ stateDir }); | |
| 170 | const server = yield* spawnGroup(SERVE_ARGS); | |
| 171 | const forget = yield* running.track({ pid: server.pid, port: 4096, args: SERVE_ARGS }); | |
| 172 | ||
| 173 | const other = yield* OpenCodeServerLedger.make({ stateDir }); | |
| 174 | yield* other.reapOrphans; | |
| 175 | expect(groupExists(server.pid)).toBe(true); | |
| 176 | ||
| 177 | yield* forget; | |
| 178 | expect(yield* fs.readDirectory(path.join(stateDir, "opencode-servers"))).toEqual([]); | |
| 179 | }).pipe(provideHost), | |
| 180 | ); | |
| 181 | }); | |
| 182 | ||
| 183 | describe.skipIf(observedPlatforms.length === 0)("OpenCode server startup", () => { | |
| 184 | it.live("stops the group when interrupted while the server is being recorded", () => | |
| 185 | Effect.gen(function* () { | |
| 186 | const fs = yield* FileSystem.FileSystem; | |
| 187 | const path = yield* Path.Path; | |
| 188 | const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-startup-" }); | |
| 189 | // A wrapper that leaves the serve process running in its group and exits 0, | |
| 190 | // which the spawner's own cleanup does not stop. | |
| 191 | const binaryPath = path.join(tempDir, "opencode"); | |
| 192 | yield* fs.writeFileString(binaryPath, "#!/bin/sh\nsleep 600 &\nexit 0\n"); | |
| 193 | yield* fs.chmod(binaryPath, 0o755); | |
| 194 | ||
| 195 | const spawned = yield* Deferred.make<ChildProcessSpawner.ChildProcessHandle>(); | |
| 196 | const recording = yield* Deferred.make<number>(); | |
| 197 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 198 | const layerRuntime = OpenCodeRuntime.layer.pipe( | |
| 199 | Layer.provide( | |
| 200 | Layer.succeed( | |
| 201 | ChildProcessSpawner.ChildProcessSpawner, | |
| 202 | ChildProcessSpawner.make((command) => | |
| 203 | spawner | |
| 204 | .spawn(command) | |
| 205 | .pipe(Effect.tap((handle) => Deferred.succeed(spawned, handle))), | |
| 206 | ), | |
| 207 | ), | |
| 208 | ), | |
| 209 | Layer.provide( | |
| 210 | Layer.succeed( | |
| 211 | OpenCodeServerLedger.OpenCodeServerLedger, | |
| 212 | OpenCodeServerLedger.OpenCodeServerLedger.of({ | |
| 213 | // Recording stalls until the scope is interrupted, after the wrapper exited. | |
| 214 | track: (server) => | |
| 215 | Effect.gen(function* () { | |
| 216 | yield* (yield* Deferred.await(spawned)).exitCode.pipe(Effect.ignore); | |
| 217 | yield* Deferred.succeed(recording, server.pid); | |
| 218 | return yield* Effect.never; | |
| 219 | }), | |
| 220 | }), | |
| 221 | ), | |
| 222 | ), | |
| 223 | ); | |
| 224 | ||
| 225 | const serverScope = yield* Scope.make(); | |
| 226 | const starting = yield* OpenCodeRuntime.OpenCodeRuntime.pipe( | |
| 227 | Effect.flatMap((runtime) => | |
| 228 | runtime.startOpenCodeServerProcess({ binaryPath, directory: tempDir, port: 4096 }), | |
| 229 | ), | |
| 230 | Effect.provideService(Scope.Scope, serverScope), | |
| 231 | Effect.provide(layerRuntime), | |
| 232 | Effect.forkChild, | |
| 233 | ); | |
| 234 | const pgid = yield* Deferred.await(recording); | |
| 235 | yield* Effect.addFinalizer(() => killGroup(pgid)); | |
| 236 | expect(groupExists(pgid)).toBe(true); | |
| 237 | ||
| 238 | yield* Fiber.interrupt(starting); | |
| 239 | yield* Scope.close(serverScope, Exit.void); | |
| 240 | ||
| 241 | expect(groupExists(pgid)).toBe(false); | |
| 242 | }).pipe(Effect.provide(NodeServices.layer)), | |
| 243 | ); | |
| 244 | }); |
apps/server/src/provider/OpenCodeServerLedger.ts deleted-339| ... | ... | @@ -1,339 +0,0 @@ |
| 1 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 2 | import * as Context from "effect/Context"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as FileSystem from "effect/FileSystem"; | |
| 5 | import * as Layer from "effect/Layer"; | |
| 6 | import * as Option from "effect/Option"; | |
| 7 | import * as Path from "effect/Path"; | |
| 8 | import * as Schema from "effect/Schema"; | |
| 9 | import { ChildProcess, ChildProcessSpawner } from "effect/process"; | |
| 10 | ||
| 11 | import { writeFileStringAtomically } from "../atomicWrite.ts"; | |
| 12 | import * as ServerConfig from "../config.ts"; | |
| 13 | import { signalProcessGroup } from "@t3tools/provider-core/server/processGroup"; | |
| 14 | ||
| 15 | const ProcessIdentity = Schema.Struct({ pid: Schema.Int, startTime: Schema.String }); | |
| 16 | type ProcessIdentity = typeof ProcessIdentity.Type; | |
| 17 | ||
| 18 | const OpenCodeServerEntry = Schema.Struct({ | |
| 19 | version: Schema.Literal(1), | |
| 20 | /** The spawned group. Its id is the pid T3 spawned, which also names the entry file. */ | |
| 21 | pgid: Schema.Int, | |
| 22 | /** | |
| 23 | * A group member seen at spawn: the spawned process, or a process it left in | |
| 24 | * the group if it had already exited. Its pid, start time, and command line | |
| 25 | * must all still match before the group is signalled. | |
| 26 | */ | |
| 27 | pid: Schema.Int, | |
| 28 | /** Linux `/proc/<pid>/stat` start ticks, or macOS `ps` lstart (whole seconds). */ | |
| 29 | startTime: Schema.String, | |
| 30 | command: Schema.String, | |
| 31 | port: Schema.Int, | |
| 32 | /** Entries copied along with a state directory are dropped, never acted on. */ | |
| 33 | stateDir: Schema.String, | |
| 34 | /** The T3 server that spawned it. Entries of a live owner are never touched. */ | |
| 35 | owner: ProcessIdentity, | |
| 36 | }); | |
| 37 | type OpenCodeServerEntry = typeof OpenCodeServerEntry.Type; | |
| 38 | const OpenCodeServerEntryJson = Schema.fromJsonString(OpenCodeServerEntry); | |
| 39 | const decodeEntry = Schema.decodeUnknownOption(OpenCodeServerEntryJson); | |
| 40 | const encodeEntry = Schema.encodeEffect(OpenCodeServerEntryJson); | |
| 41 | ||
| 42 | /** | |
| 43 | * Local `opencode serve` processes run in their own process group so T3 can | |
| 44 | * stop the whole group, which also means they outlive a T3 server that is | |
| 45 | * SIGKILLed or crashes. Each spawn is recorded under the state directory and | |
| 46 | * removed on a graceful stop; the next server start stops whatever a dead | |
| 47 | * server left behind. | |
| 48 | */ | |
| 49 | export class OpenCodeServerLedger extends Context.Service< | |
| 50 | OpenCodeServerLedger, | |
| 51 | { | |
| 52 | /** Records a spawned server group and returns the effect that forgets it after a graceful stop. */ | |
| 53 | readonly track: (server: { | |
| 54 | readonly pid: number; | |
| 55 | readonly port: number; | |
| 56 | /** The argv after the binary, e.g. `["serve", "--hostname=127.0.0.1", "--port=4096"]`. */ | |
| 57 | readonly args: ReadonlyArray<string>; | |
| 58 | }) => Effect.Effect<Effect.Effect<void>>; | |
| 59 | } | |
| 60 | >()("t3/provider/OpenCodeServerLedger") {} | |
| 61 | ||
| 62 | const ENTRY_DIRECTORY = "opencode-servers"; | |
| 63 | const ENTRY_FILE = /^\d+\.json$/; | |
| 64 | const STOP_POLL_INTERVAL = "50 millis"; | |
| 65 | const STOP_POLL_ATTEMPTS = 40; | |
| 66 | ||
| 67 | interface ObservedProcess { | |
| 68 | readonly pid: number; | |
| 69 | readonly pgid: number; | |
| 70 | readonly startTime: string; | |
| 71 | readonly command: string; | |
| 72 | readonly zombie: boolean; | |
| 73 | } | |
| 74 | ||
| 75 | // `ps -o lstart` prints e.g. `Sat Sep 6 20:51:57 2026`; C locale and UTC keep it stable. | |
| 76 | const DARWIN_PS_LINE = | |
| 77 | /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(\w{3} \w{3} +\d{1,2} \d{2}:\d{2}:\d{2} \d{4})\s+(.*)$/; | |
| 78 | ||
| 79 | const parseDarwinPs = (output: string): ReadonlyArray<ObservedProcess> => | |
| 80 | output.split("\n").flatMap((line) => { | |
| 81 | const match = DARWIN_PS_LINE.exec(line); | |
| 82 | if (match === null) return []; | |
| 83 | return [ | |
| 84 | { | |
| 85 | pid: Number(match[1]), | |
| 86 | pgid: Number(match[2]), | |
| 87 | zombie: match[3]!.startsWith("Z"), | |
| 88 | startTime: match[4]!, | |
| 89 | command: match[5]!.trimEnd(), | |
| 90 | }, | |
| 91 | ]; | |
| 92 | }); | |
| 93 | ||
| 94 | const signalGroup = (pgid: number, signal: NodeJS.Signals) => { | |
| 95 | try { | |
| 96 | signalProcessGroup(pgid, signal); | |
| 97 | } catch { | |
| 98 | // The group may already be gone. | |
| 99 | } | |
| 100 | }; | |
| 101 | ||
| 102 | const groupExists = (pgid: number) => { | |
| 103 | try { | |
| 104 | signalProcessGroup(pgid, 0); | |
| 105 | return true; | |
| 106 | } catch (cause) { | |
| 107 | return (cause as NodeJS.ErrnoException | undefined)?.code !== "ESRCH"; | |
| 108 | } | |
| 109 | }; | |
| 110 | ||
| 111 | /** | |
| 112 | * Builds a ledger for one state directory. `ownerPid` is the T3 server that | |
| 113 | * owns the servers it tracks; it defaults to this process. | |
| 114 | */ | |
| 115 | export const make = Effect.fn("OpenCodeServerLedger.make")(function* (input: { | |
| 116 | readonly stateDir: string; | |
| 117 | readonly ownerPid?: number; | |
| 118 | }) { | |
| 119 | const fs = yield* FileSystem.FileSystem; | |
| 120 | const path = yield* Path.Path; | |
| 121 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 122 | const platform = yield* HostProcessPlatform; | |
| 123 | const directory = path.join(input.stateDir, ENTRY_DIRECTORY); | |
| 124 | // Windows servers are not detached and are never recorded. | |
| 125 | const recordable = platform === "linux" || platform === "darwin"; | |
| 126 | ||
| 127 | /** Observes `pid`, or nothing when it is not in `group` (if given). */ | |
| 128 | const observeLinux = (pid: number, group?: number) => | |
| 129 | Effect.gen(function* () { | |
| 130 | const stat = yield* fs.readFileString(`/proc/${pid}/stat`); | |
| 131 | // After the parenthesized comm: state, ppid, pgrp, session, … starttime (index 19). | |
| 132 | const fields = stat | |
| 133 | .slice(stat.lastIndexOf(")") + 2) | |
| 134 | .trim() | |
| 135 | .split(/\s+/); | |
| 136 | const pgid = Number(fields[2]); | |
| 137 | const startTime = fields[19]; | |
| 138 | if (!Number.isSafeInteger(pgid) || startTime === undefined) return undefined; | |
| 139 | if (group !== undefined && pgid !== group) return undefined; | |
| 140 | const cmdline = yield* fs.readFileString(`/proc/${pid}/cmdline`); | |
| 141 | return { | |
| 142 | pid, | |
| 143 | pgid, | |
| 144 | startTime, | |
| 145 | command: cmdline.replace(/\0$/, "").replaceAll("\0", " "), | |
| 146 | zombie: fields[0] === "Z", | |
| 147 | } satisfies ObservedProcess; | |
| 148 | }); | |
| 149 | ||
| 150 | const psDarwin = (selection: ReadonlyArray<string>) => | |
| 151 | spawner | |
| 152 | .string( | |
| 153 | ChildProcess.make( | |
| 154 | "/bin/ps", | |
| 155 | ["-ww", "-o", "pid=,pgid=,stat=,lstart=,args=", ...selection], | |
| 156 | { | |
| 157 | env: { LC_ALL: "C", TZ: "UTC" }, | |
| 158 | extendEnv: true, | |
| 159 | stdin: "ignore", | |
| 160 | stderr: "ignore", | |
| 161 | }, | |
| 162 | ), | |
| 163 | ) | |
| 164 | .pipe(Effect.timeout("5 seconds"), Effect.map(parseDarwinPs)); | |
| 165 | ||
| 166 | const observe = (pid: number): Effect.Effect<ObservedProcess | undefined> => { | |
| 167 | if (platform === "linux") return observeLinux(pid).pipe(Effect.orElseSucceed(() => undefined)); | |
| 168 | if (platform === "darwin") { | |
| 169 | return psDarwin(["-p", String(pid)]).pipe( | |
| 170 | Effect.map((found) => found.find((candidate) => candidate.pid === pid)), | |
| 171 | Effect.orElseSucceed(() => undefined), | |
| 172 | ); | |
| 173 | } | |
| 174 | return Effect.succeed(undefined); | |
| 175 | }; | |
| 176 | ||
| 177 | // Scans every process, so it is only used when the spawned process is gone. | |
| 178 | const observeGroup = (pgid: number): Effect.Effect<ReadonlyArray<ObservedProcess>> => { | |
| 179 | if (platform === "linux") { | |
| 180 | return fs.readDirectory("/proc").pipe( | |
| 181 | Effect.flatMap((names) => | |
| 182 | Effect.forEach( | |
| 183 | names.filter((name) => /^\d+$/.test(name)), | |
| 184 | (name) => observeLinux(Number(name), pgid).pipe(Effect.orElseSucceed(() => undefined)), | |
| 185 | { concurrency: 16 }, | |
| 186 | ), | |
| 187 | ), | |
| 188 | Effect.map((found) => found.filter((candidate) => candidate !== undefined)), | |
| 189 | Effect.orElseSucceed(() => []), | |
| 190 | ); | |
| 191 | } | |
| 192 | if (platform === "darwin") { | |
| 193 | return psDarwin(["-A"]).pipe( | |
| 194 | Effect.map((found) => found.filter((candidate) => candidate.pgid === pgid)), | |
| 195 | Effect.orElseSucceed(() => []), | |
| 196 | ); | |
| 197 | } | |
| 198 | return Effect.succeed([]); | |
| 199 | }; | |
| 200 | ||
| 201 | const isRunning = (identity: ProcessIdentity) => | |
| 202 | observe(identity.pid).pipe( | |
| 203 | Effect.map( | |
| 204 | (observed) => | |
| 205 | observed !== undefined && !observed.zombie && observed.startTime === identity.startTime, | |
| 206 | ), | |
| 207 | ); | |
| 208 | ||
| 209 | const ownerPid = input.ownerPid ?? process.pid; | |
| 210 | const ownerProcess = yield* observe(ownerPid); | |
| 211 | const owner = | |
| 212 | ownerProcess === undefined ? undefined : { pid: ownerPid, startTime: ownerProcess.startTime }; | |
| 213 | ||
| 214 | const track: OpenCodeServerLedger["Service"]["track"] = (server) => | |
| 215 | Effect.gen(function* () { | |
| 216 | if (!recordable) return Effect.void; | |
| 217 | const pgid = server.pid; | |
| 218 | // The spawned process leads the group. If it already exited (a wrapper | |
| 219 | // that left the server running), any live member identifies the group: | |
| 220 | // a group id is not reused while the group has members. | |
| 221 | const leader = yield* observe(pgid); | |
| 222 | const members = | |
| 223 | leader !== undefined && !leader.zombie && leader.pgid === pgid | |
| 224 | ? [leader] | |
| 225 | : (yield* observeGroup(pgid)).filter((candidate) => !candidate.zombie); | |
| 226 | const member = | |
| 227 | members.find((candidate) => candidate.command.endsWith(` ${server.args.join(" ")}`)) ?? | |
| 228 | members[0]; | |
| 229 | if (owner === undefined || member === undefined) { | |
| 230 | if (groupExists(pgid)) { | |
| 231 | yield* Effect.logWarning( | |
| 232 | "Could not record an OpenCode server; it will keep running if this server crashes", | |
| 233 | { pid: pgid, port: server.port }, | |
| 234 | ); | |
| 235 | } | |
| 236 | return Effect.void; | |
| 237 | } | |
| 238 | const entryPath = path.join(directory, `${pgid}.json`); | |
| 239 | const entry: OpenCodeServerEntry = { | |
| 240 | version: 1, | |
| 241 | pgid, | |
| 242 | pid: member.pid, | |
| 243 | startTime: member.startTime, | |
| 244 | command: member.command, | |
| 245 | port: server.port, | |
| 246 | stateDir: input.stateDir, | |
| 247 | owner, | |
| 248 | }; | |
| 249 | yield* writeFileStringAtomically({ | |
| 250 | filePath: entryPath, | |
| 251 | contents: yield* encodeEntry(entry), | |
| 252 | }).pipe( | |
| 253 | Effect.provideService(FileSystem.FileSystem, fs), | |
| 254 | Effect.provideService(Path.Path, path), | |
| 255 | ); | |
| 256 | return fs | |
| 257 | .remove(entryPath, { force: true }) | |
| 258 | .pipe( | |
| 259 | Effect.catchCause((cause) => | |
| 260 | Effect.logWarning("Could not forget a stopped OpenCode server", { cause }), | |
| 261 | ), | |
| 262 | ); | |
| 263 | }).pipe( | |
| 264 | Effect.catchCause((cause) => | |
| 265 | Effect.logWarning("Could not record an OpenCode server process", { cause }).pipe( | |
| 266 | Effect.as(Effect.void), | |
| 267 | ), | |
| 268 | ), | |
| 269 | ); | |
| 270 | ||
| 271 | // Signals only a group whose recorded member still has the same pid, start | |
| 272 | // time, group, and full command line, so a recycled pid is never touched. | |
| 273 | const stopOrphan = (entry: OpenCodeServerEntry) => | |
| 274 | Effect.gen(function* () { | |
| 275 | const observed = yield* observe(entry.pid); | |
| 276 | if ( | |
| 277 | observed === undefined || | |
| 278 | observed.zombie || | |
| 279 | observed.startTime !== entry.startTime || | |
| 280 | observed.pgid !== entry.pgid || | |
| 281 | observed.command !== entry.command | |
| 282 | ) { | |
| 283 | return; | |
| 284 | } | |
| 285 | yield* Effect.logInfo("Stopping an OpenCode server left by a previous T3 Code server", { | |
| 286 | pid: entry.pgid, | |
| 287 | port: entry.port, | |
| 288 | }); | |
| 289 | signalGroup(entry.pgid, "SIGTERM"); | |
| 290 | for (let attempt = 0; attempt < STOP_POLL_ATTEMPTS && groupExists(entry.pgid); attempt++) { | |
| 291 | yield* Effect.sleep(STOP_POLL_INTERVAL); | |
| 292 | } | |
| 293 | // The group never emptied, so its pgid cannot have been reused. | |
| 294 | if (groupExists(entry.pgid)) signalGroup(entry.pgid, "SIGKILL"); | |
| 295 | }); | |
| 296 | ||
| 297 | const reapEntry = (entryPath: string) => | |
| 298 | Effect.gen(function* () { | |
| 299 | const entry = decodeEntry(yield* fs.readFileString(entryPath)); | |
| 300 | if (Option.isSome(entry) && entry.value.stateDir === input.stateDir) { | |
| 301 | if (yield* isRunning(entry.value.owner)) return; | |
| 302 | yield* stopOrphan(entry.value); | |
| 303 | } | |
| 304 | yield* fs.remove(entryPath, { force: true }); | |
| 305 | }).pipe( | |
| 306 | Effect.catchCause((cause) => | |
| 307 | Effect.logWarning("Could not clean up a recorded OpenCode server", { entryPath, cause }), | |
| 308 | ), | |
| 309 | ); | |
| 310 | ||
| 311 | /** Stops recorded servers whose owning T3 server is gone and drops stale entries. */ | |
| 312 | const reapOrphans = Effect.gen(function* () { | |
| 313 | const names = yield* fs.readDirectory(directory).pipe(Effect.orElseSucceed(() => [])); | |
| 314 | yield* Effect.forEach( | |
| 315 | names.filter((name) => ENTRY_FILE.test(name)), | |
| 316 | (name) => reapEntry(path.join(directory, name)), | |
| 317 | { concurrency: "unbounded", discard: true }, | |
| 318 | ); | |
| 319 | }); | |
| 320 | ||
| 321 | return { track, reapOrphans }; | |
| 322 | }); | |
| 323 | ||
| 324 | export const layer = Layer.effect( | |
| 325 | OpenCodeServerLedger, | |
| 326 | Effect.gen(function* () { | |
| 327 | const config = yield* ServerConfig.ServerConfig; | |
| 328 | const ledger = yield* make({ stateDir: config.stateDir }); | |
| 329 | // Reaping waits for orphans to exit, so it must not hold up startup. | |
| 330 | yield* ledger.reapOrphans.pipe(Effect.forkScoped); | |
| 331 | return OpenCodeServerLedger.of({ track: ledger.track }); | |
| 332 | }), | |
| 333 | ); | |
| 334 | ||
| 335 | /** Records nothing. For tests that start OpenCode servers without a state directory. */ | |
| 336 | export const layerTest = Layer.succeed( | |
| 337 | OpenCodeServerLedger, | |
| 338 | OpenCodeServerLedger.of({ track: () => Effect.succeed(Effect.void) }), | |
| 339 | ); |
apps/server/src/provider/OpenCodeServerOwner.test.ts deleted-289| ... | ... | @@ -1,289 +0,0 @@ |
| 1 | import { it } from "@effect/vitest"; | |
| 2 | import * as Deferred from "effect/Deferred"; | |
| 3 | import * as Duration from "effect/Duration"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as Fiber from "effect/Fiber"; | |
| 6 | import * as Ref from "effect/Ref"; | |
| 7 | import * as TestClock from "effect/testing/TestClock"; | |
| 8 | import { expect } from "vite-plus/test"; | |
| 9 | ||
| 10 | import * as OpenCodeRuntime from "./opencodeRuntime.ts"; | |
| 11 | import * as OpenCodeServerOwner from "./OpenCodeServerOwner.ts"; | |
| 12 | ||
| 13 | const unusedRuntimeMethod = () => | |
| 14 | Effect.fail( | |
| 15 | new OpenCodeRuntime.OpenCodeRuntimeError({ | |
| 16 | operation: "unused", | |
| 17 | detail: "unused test method", | |
| 18 | }), | |
| 19 | ); | |
| 20 | ||
| 21 | const makeRuntime = Effect.gen(function* () { | |
| 22 | const starts = yield* Ref.make(0); | |
| 23 | const closes = yield* Ref.make(0); | |
| 24 | const failNextStart = yield* Ref.make(false); | |
| 25 | const started = yield* Deferred.make<void>(); | |
| 26 | const closed = yield* Deferred.make<void>(); | |
| 27 | const runtime: OpenCodeRuntime.OpenCodeRuntimeShape = { | |
| 28 | startOpenCodeServerProcess: () => | |
| 29 | Effect.gen(function* () { | |
| 30 | if (yield* Ref.getAndSet(failNextStart, false)) { | |
| 31 | return yield* new OpenCodeRuntime.OpenCodeRuntimeError({ | |
| 32 | operation: "startOpenCodeServerProcess", | |
| 33 | detail: "start failed", | |
| 34 | }); | |
| 35 | } | |
| 36 | const index = yield* Ref.updateAndGet(starts, (count) => count + 1); | |
| 37 | yield* Deferred.succeed(started, undefined).pipe(Effect.ignore); | |
| 38 | yield* Effect.addFinalizer(() => | |
| 39 | Ref.update(closes, (count) => count + 1).pipe( | |
| 40 | Effect.andThen(Deferred.succeed(closed, undefined)), | |
| 41 | Effect.ignore, | |
| 42 | ), | |
| 43 | ); | |
| 44 | return { | |
| 45 | url: `http://127.0.0.1:${index}`, | |
| 46 | version: "1.14.19", | |
| 47 | isRunning: Effect.succeed(true), | |
| 48 | exitCode: Effect.never, | |
| 49 | }; | |
| 50 | }), | |
| 51 | connectToOpenCodeServer: unusedRuntimeMethod, | |
| 52 | runOpenCodeCommand: unusedRuntimeMethod, | |
| 53 | createOpenCodeSdkClient: () => ({}) as never, | |
| 54 | loadOpenCodeInventory: unusedRuntimeMethod, | |
| 55 | loadOpenCodeSkills: unusedRuntimeMethod, | |
| 56 | loadInventoryFromCli: unusedRuntimeMethod, | |
| 57 | loadSkillsFromCli: unusedRuntimeMethod, | |
| 58 | }; | |
| 59 | return { runtime, starts, closes, failNextStart, started, closed }; | |
| 60 | }); | |
| 61 | ||
| 62 | it.effect("shares concurrent borrowers and closes after the idle TTL", () => | |
| 63 | Effect.gen(function* () { | |
| 64 | const testRuntime = yield* makeRuntime; | |
| 65 | const release = yield* Deferred.make<void>(); | |
| 66 | yield* Effect.scoped( | |
| 67 | Effect.gen(function* () { | |
| 68 | const owner = yield* OpenCodeServerOwner.make({ | |
| 69 | binaryPath: "opencode", | |
| 70 | directory: "/project", | |
| 71 | }); | |
| 72 | const useServer = owner.withServer((server) => | |
| 73 | Deferred.await(release).pipe(Effect.as(server.url)), | |
| 74 | ); | |
| 75 | const fibers = yield* Effect.all([useServer, useServer], { | |
| 76 | concurrency: "unbounded", | |
| 77 | }).pipe(Effect.forkChild); | |
| 78 | yield* Deferred.await(testRuntime.started); | |
| 79 | expect(yield* Ref.get(testRuntime.starts)).toBe(1); | |
| 80 | yield* Deferred.succeed(release, undefined); | |
| 81 | expect(yield* Fiber.join(fibers)).toEqual(["http://127.0.0.1:1", "http://127.0.0.1:1"]); | |
| 82 | yield* TestClock.adjust(Duration.seconds(31)); | |
| 83 | yield* Deferred.await(testRuntime.closed); | |
| 84 | expect(yield* Ref.get(testRuntime.closes)).toBe(1); | |
| 85 | }), | |
| 86 | ).pipe(Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, testRuntime.runtime)); | |
| 87 | }).pipe(Effect.provide(TestClock.layer())), | |
| 88 | ); | |
| 89 | ||
| 90 | it.effect("retries a failed start and closes on owner scope shutdown", () => | |
| 91 | Effect.gen(function* () { | |
| 92 | const testRuntime = yield* makeRuntime; | |
| 93 | yield* Ref.set(testRuntime.failNextStart, true); | |
| 94 | yield* Effect.scoped( | |
| 95 | Effect.gen(function* () { | |
| 96 | const owner = yield* OpenCodeServerOwner.make({ | |
| 97 | binaryPath: "opencode", | |
| 98 | directory: "/project", | |
| 99 | }); | |
| 100 | expect( | |
| 101 | (yield* Effect.exit(owner.withServer((server) => Effect.succeed(server.url))))._tag, | |
| 102 | ).toBe("Failure"); | |
| 103 | expect(yield* owner.withServer((server) => Effect.succeed(server.url))).toBe( | |
| 104 | "http://127.0.0.1:1", | |
| 105 | ); | |
| 106 | }), | |
| 107 | ).pipe(Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, testRuntime.runtime)); | |
| 108 | expect(yield* Ref.get(testRuntime.starts)).toBe(1); | |
| 109 | expect(yield* Ref.get(testRuntime.closes)).toBe(1); | |
| 110 | }), | |
| 111 | ); | |
| 112 | ||
| 113 | it.effect("invalidates an exited process so the next borrower starts a new one", () => | |
| 114 | Effect.gen(function* () { | |
| 115 | const starts = yield* Ref.make(0); | |
| 116 | const processExits: Array<Deferred.Deferred<number>> = []; | |
| 117 | const processClosed = yield* Deferred.make<void>(); | |
| 118 | const runtime: OpenCodeRuntime.OpenCodeRuntimeShape = { | |
| 119 | startOpenCodeServerProcess: () => | |
| 120 | Effect.gen(function* () { | |
| 121 | const index = yield* Ref.updateAndGet(starts, (count) => count + 1); | |
| 122 | const exitCode = yield* Deferred.make<number>(); | |
| 123 | processExits.push(exitCode); | |
| 124 | yield* Effect.addFinalizer(() => | |
| 125 | Deferred.succeed(processClosed, undefined).pipe(Effect.ignore), | |
| 126 | ); | |
| 127 | return { | |
| 128 | url: `http://127.0.0.1:${index}`, | |
| 129 | version: "1.14.19", | |
| 130 | isRunning: Effect.succeed(true), | |
| 131 | exitCode: Deferred.await(exitCode), | |
| 132 | }; | |
| 133 | }), | |
| 134 | connectToOpenCodeServer: unusedRuntimeMethod, | |
| 135 | runOpenCodeCommand: unusedRuntimeMethod, | |
| 136 | createOpenCodeSdkClient: () => ({}) as never, | |
| 137 | loadOpenCodeInventory: unusedRuntimeMethod, | |
| 138 | loadOpenCodeSkills: unusedRuntimeMethod, | |
| 139 | loadInventoryFromCli: unusedRuntimeMethod, | |
| 140 | loadSkillsFromCli: unusedRuntimeMethod, | |
| 141 | }; | |
| 142 | ||
| 143 | yield* Effect.scoped( | |
| 144 | Effect.gen(function* () { | |
| 145 | const owner = yield* OpenCodeServerOwner.make({ | |
| 146 | binaryPath: "opencode", | |
| 147 | directory: "/project", | |
| 148 | }); | |
| 149 | expect(yield* owner.withServer((server) => Effect.succeed(server.url))).toBe( | |
| 150 | "http://127.0.0.1:1", | |
| 151 | ); | |
| 152 | yield* Deferred.succeed(processExits[0]!, 1); | |
| 153 | yield* Deferred.await(processClosed); | |
| 154 | expect(yield* owner.withServer((server) => Effect.succeed(server.url))).toBe( | |
| 155 | "http://127.0.0.1:2", | |
| 156 | ); | |
| 157 | }), | |
| 158 | ).pipe(Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, runtime)); | |
| 159 | expect(yield* Ref.get(starts)).toBe(2); | |
| 160 | }), | |
| 161 | ); | |
| 162 | ||
| 163 | it.effect("replaces a dead cached process before its exit watcher runs", () => | |
| 164 | Effect.gen(function* () { | |
| 165 | const starts = yield* Ref.make(0); | |
| 166 | const closes = yield* Ref.make(0); | |
| 167 | const processRunning: Array<Ref.Ref<boolean>> = []; | |
| 168 | const runtime: OpenCodeRuntime.OpenCodeRuntimeShape = { | |
| 169 | startOpenCodeServerProcess: () => | |
| 170 | Effect.gen(function* () { | |
| 171 | const index = yield* Ref.updateAndGet(starts, (count) => count + 1); | |
| 172 | const isRunning = yield* Ref.make(true); | |
| 173 | processRunning.push(isRunning); | |
| 174 | yield* Effect.addFinalizer(() => Ref.update(closes, (count) => count + 1)); | |
| 175 | return { | |
| 176 | url: `http://127.0.0.1:${index}`, | |
| 177 | version: "1.14.19", | |
| 178 | isRunning: Ref.get(isRunning), | |
| 179 | exitCode: Effect.never, | |
| 180 | }; | |
| 181 | }), | |
| 182 | connectToOpenCodeServer: unusedRuntimeMethod, | |
| 183 | runOpenCodeCommand: unusedRuntimeMethod, | |
| 184 | createOpenCodeSdkClient: () => ({}) as never, | |
| 185 | loadOpenCodeInventory: unusedRuntimeMethod, | |
| 186 | loadOpenCodeSkills: unusedRuntimeMethod, | |
| 187 | loadInventoryFromCli: unusedRuntimeMethod, | |
| 188 | loadSkillsFromCli: unusedRuntimeMethod, | |
| 189 | }; | |
| 190 | ||
| 191 | yield* Effect.scoped( | |
| 192 | Effect.gen(function* () { | |
| 193 | const owner = yield* OpenCodeServerOwner.make({ | |
| 194 | binaryPath: "opencode", | |
| 195 | directory: "/project", | |
| 196 | }); | |
| 197 | expect(yield* owner.withServer((server) => Effect.succeed(server.url))).toBe( | |
| 198 | "http://127.0.0.1:1", | |
| 199 | ); | |
| 200 | yield* Ref.set(processRunning[0]!, false); | |
| 201 | ||
| 202 | expect(yield* owner.withServer((server) => Effect.succeed(server.url))).toBe( | |
| 203 | "http://127.0.0.1:2", | |
| 204 | ); | |
| 205 | expect(yield* Ref.get(starts)).toBe(2); | |
| 206 | expect(yield* Ref.get(closes)).toBe(1); | |
| 207 | }), | |
| 208 | ).pipe(Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, runtime)); | |
| 209 | }), | |
| 210 | ); | |
| 211 | ||
| 212 | it.effect("cleans up an interrupted startup and allows a retry", () => | |
| 213 | Effect.gen(function* () { | |
| 214 | const starts = yield* Ref.make(0); | |
| 215 | const firstStartEntered = yield* Deferred.make<void>(); | |
| 216 | const firstStartClosed = yield* Deferred.make<void>(); | |
| 217 | const runtime: OpenCodeRuntime.OpenCodeRuntimeShape = { | |
| 218 | startOpenCodeServerProcess: () => | |
| 219 | Effect.gen(function* () { | |
| 220 | const index = yield* Ref.updateAndGet(starts, (count) => count + 1); | |
| 221 | yield* Effect.addFinalizer(() => | |
| 222 | index === 1 | |
| 223 | ? Deferred.succeed(firstStartClosed, undefined).pipe(Effect.ignore) | |
| 224 | : Effect.void, | |
| 225 | ); | |
| 226 | if (index === 1) { | |
| 227 | yield* Deferred.succeed(firstStartEntered, undefined); | |
| 228 | return yield* Effect.never; | |
| 229 | } | |
| 230 | return { | |
| 231 | url: `http://127.0.0.1:${index}`, | |
| 232 | version: "1.14.19", | |
| 233 | isRunning: Effect.succeed(true), | |
| 234 | exitCode: Effect.never, | |
| 235 | }; | |
| 236 | }), | |
| 237 | connectToOpenCodeServer: unusedRuntimeMethod, | |
| 238 | runOpenCodeCommand: unusedRuntimeMethod, | |
| 239 | createOpenCodeSdkClient: () => ({}) as never, | |
| 240 | loadOpenCodeInventory: unusedRuntimeMethod, | |
| 241 | loadOpenCodeSkills: unusedRuntimeMethod, | |
| 242 | loadInventoryFromCli: unusedRuntimeMethod, | |
| 243 | loadSkillsFromCli: unusedRuntimeMethod, | |
| 244 | }; | |
| 245 | ||
| 246 | yield* Effect.scoped( | |
| 247 | Effect.gen(function* () { | |
| 248 | const owner = yield* OpenCodeServerOwner.make({ | |
| 249 | binaryPath: "opencode", | |
| 250 | directory: "/project", | |
| 251 | }); | |
| 252 | const firstBorrower = yield* owner | |
| 253 | .withServer((server) => Effect.succeed(server.url)) | |
| 254 | .pipe(Effect.forkChild); | |
| 255 | yield* Deferred.await(firstStartEntered); | |
| 256 | yield* Fiber.interrupt(firstBorrower); | |
| 257 | yield* Deferred.await(firstStartClosed); | |
| 258 | expect(yield* owner.withServer((server) => Effect.succeed(server.url))).toBe( | |
| 259 | "http://127.0.0.1:2", | |
| 260 | ); | |
| 261 | }), | |
| 262 | ).pipe(Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, runtime)); | |
| 263 | }), | |
| 264 | ); | |
| 265 | ||
| 266 | it.effect("releases an interrupted borrower and closes after the idle TTL", () => | |
| 267 | Effect.gen(function* () { | |
| 268 | const testRuntime = yield* makeRuntime; | |
| 269 | const borrowerEntered = yield* Deferred.make<void>(); | |
| 270 | yield* Effect.scoped( | |
| 271 | Effect.gen(function* () { | |
| 272 | const owner = yield* OpenCodeServerOwner.make({ | |
| 273 | binaryPath: "opencode", | |
| 274 | directory: "/project", | |
| 275 | }); | |
| 276 | const borrower = yield* owner | |
| 277 | .withServer(() => | |
| 278 | Deferred.succeed(borrowerEntered, undefined).pipe(Effect.andThen(Effect.never)), | |
| 279 | ) | |
| 280 | .pipe(Effect.forkChild); | |
| 281 | yield* Deferred.await(borrowerEntered); | |
| 282 | yield* Fiber.interrupt(borrower); | |
| 283 | yield* TestClock.adjust(Duration.seconds(31)); | |
| 284 | yield* Deferred.await(testRuntime.closed); | |
| 285 | expect(yield* Ref.get(testRuntime.closes)).toBe(1); | |
| 286 | }), | |
| 287 | ).pipe(Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, testRuntime.runtime)); | |
| 288 | }).pipe(Effect.provide(TestClock.layer())), | |
| 289 | ); |
apps/server/src/provider/OpenCodeServerOwner.ts deleted-188| ... | ... | @@ -1,188 +0,0 @@ |
| 1 | import * as Context from "effect/Context"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as Exit from "effect/Exit"; | |
| 4 | import * as Fiber from "effect/Fiber"; | |
| 5 | import * as Layer from "effect/Layer"; | |
| 6 | import * as Scope from "effect/Scope"; | |
| 7 | import * as Semaphore from "effect/Semaphore"; | |
| 8 | ||
| 9 | import * as OpenCodeRuntime from "./opencodeRuntime.ts"; | |
| 10 | ||
| 11 | const OPENCODE_SERVER_IDLE_TTL = "30 seconds"; | |
| 12 | ||
| 13 | interface OpenCodeServerOwnerState { | |
| 14 | server: OpenCodeRuntime.OpenCodeServerProcess | null; | |
| 15 | serverScope: Scope.Closeable | null; | |
| 16 | borrowers: number; | |
| 17 | idleCloseFiber: Fiber.Fiber<void, never> | null; | |
| 18 | } | |
| 19 | ||
| 20 | export class OpenCodeServerOwner extends Context.Service< | |
| 21 | OpenCodeServerOwner, | |
| 22 | { | |
| 23 | readonly withServer: <A, E, R>( | |
| 24 | use: (server: OpenCodeRuntime.OpenCodeServerProcess) => Effect.Effect<A, E, R>, | |
| 25 | ) => Effect.Effect<A, E | OpenCodeRuntime.OpenCodeRuntimeError, R>; | |
| 26 | } | |
| 27 | >()("t3/provider/OpenCodeServerOwner") {} | |
| 28 | ||
| 29 | /** Owns the lazy local OpenCode server shared by one provider instance. */ | |
| 30 | export const make = Effect.fn("OpenCodeServerOwner.make")(function* (input: { | |
| 31 | readonly binaryPath: string; | |
| 32 | readonly directory: string; | |
| 33 | readonly serverPassword?: string; | |
| 34 | readonly environment?: NodeJS.ProcessEnv; | |
| 35 | readonly verify?: (url: string) => Effect.Effect<string, OpenCodeRuntime.OpenCodeRuntimeError>; | |
| 36 | }) { | |
| 37 | const runtime = yield* OpenCodeRuntime.OpenCodeRuntime; | |
| 38 | const ownerScope = yield* Effect.acquireRelease(Scope.make(), (scope) => | |
| 39 | Scope.close(scope, Exit.void), | |
| 40 | ); | |
| 41 | const mutex = yield* Semaphore.make(1); | |
| 42 | const state: OpenCodeServerOwnerState = { | |
| 43 | server: null, | |
| 44 | serverScope: null, | |
| 45 | borrowers: 0, | |
| 46 | idleCloseFiber: null, | |
| 47 | }; | |
| 48 | ||
| 49 | const cancelIdleClose = Effect.fn("OpenCodeServerOwner.cancelIdleClose")(function* () { | |
| 50 | const fiber = state.idleCloseFiber; | |
| 51 | state.idleCloseFiber = null; | |
| 52 | if (fiber !== null) { | |
| 53 | yield* Fiber.interrupt(fiber).pipe(Effect.ignore); | |
| 54 | } | |
| 55 | }); | |
| 56 | ||
| 57 | const closeServer = Effect.fn("OpenCodeServerOwner.closeServer")(function* ( | |
| 58 | expected?: OpenCodeRuntime.OpenCodeServerProcess, | |
| 59 | ) { | |
| 60 | if (expected !== undefined && state.server !== expected) { | |
| 61 | return; | |
| 62 | } | |
| 63 | const scope = state.serverScope; | |
| 64 | state.server = null; | |
| 65 | state.serverScope = null; | |
| 66 | if (scope !== null) { | |
| 67 | yield* Scope.close(scope, Exit.void).pipe(Effect.ignore); | |
| 68 | } | |
| 69 | }); | |
| 70 | ||
| 71 | const watchServerExit = Effect.fn("OpenCodeServerOwner.watchServerExit")(function* ( | |
| 72 | server: OpenCodeRuntime.OpenCodeServerProcess, | |
| 73 | ) { | |
| 74 | yield* server.exitCode; | |
| 75 | yield* mutex.withPermit( | |
| 76 | Effect.gen(function* () { | |
| 77 | if (state.server !== server) { | |
| 78 | return; | |
| 79 | } | |
| 80 | yield* cancelIdleClose(); | |
| 81 | yield* closeServer(server); | |
| 82 | }), | |
| 83 | ); | |
| 84 | }); | |
| 85 | ||
| 86 | const acquireServer = mutex.withPermit( | |
| 87 | Effect.gen(function* () { | |
| 88 | yield* cancelIdleClose(); | |
| 89 | if (state.server !== null) { | |
| 90 | if (yield* state.server.isRunning) { | |
| 91 | state.borrowers += 1; | |
| 92 | return state.server; | |
| 93 | } | |
| 94 | yield* closeServer(state.server); | |
| 95 | } | |
| 96 | ||
| 97 | return yield* Effect.uninterruptibleMask((restore) => | |
| 98 | Effect.gen(function* () { | |
| 99 | const serverScope = yield* Scope.make(); | |
| 100 | const started = yield* Effect.exit( | |
| 101 | restore( | |
| 102 | runtime | |
| 103 | .startOpenCodeServerProcess({ | |
| 104 | binaryPath: input.binaryPath, | |
| 105 | directory: input.directory, | |
| 106 | ...(input.serverPassword !== undefined | |
| 107 | ? { serverPassword: input.serverPassword } | |
| 108 | : {}), | |
| 109 | ...(input.environment ? { environment: input.environment } : {}), | |
| 110 | ...(input.verify ? { verify: input.verify } : {}), | |
| 111 | }) | |
| 112 | .pipe(Effect.provideService(Scope.Scope, serverScope)), | |
| 113 | ), | |
| 114 | ); | |
| 115 | if (Exit.isFailure(started)) { | |
| 116 | yield* Scope.close(serverScope, Exit.void).pipe(Effect.ignore); | |
| 117 | return yield* Effect.failCause(started.cause); | |
| 118 | } | |
| 119 | ||
| 120 | const server = started.value; | |
| 121 | state.server = server; | |
| 122 | state.serverScope = serverScope; | |
| 123 | state.borrowers = 1; | |
| 124 | yield* watchServerExit(server).pipe(Effect.forkIn(ownerScope)); | |
| 125 | return server; | |
| 126 | }), | |
| 127 | ); | |
| 128 | }), | |
| 129 | ); | |
| 130 | ||
| 131 | const releaseServer = (server: OpenCodeRuntime.OpenCodeServerProcess) => | |
| 132 | mutex.withPermit( | |
| 133 | Effect.gen(function* () { | |
| 134 | if (state.server !== server) { | |
| 135 | return; | |
| 136 | } | |
| 137 | state.borrowers = Math.max(0, state.borrowers - 1); | |
| 138 | if (state.borrowers > 0) { | |
| 139 | return; | |
| 140 | } | |
| 141 | yield* cancelIdleClose(); | |
| 142 | state.idleCloseFiber = yield* Effect.sleep(OPENCODE_SERVER_IDLE_TTL).pipe( | |
| 143 | Effect.andThen( | |
| 144 | mutex.withPermit( | |
| 145 | Effect.gen(function* () { | |
| 146 | if (state.server !== server || state.borrowers > 0) { | |
| 147 | return; | |
| 148 | } | |
| 149 | state.idleCloseFiber = null; | |
| 150 | yield* closeServer(server); | |
| 151 | }), | |
| 152 | ), | |
| 153 | ), | |
| 154 | Effect.forkIn(ownerScope), | |
| 155 | ); | |
| 156 | }), | |
| 157 | ); | |
| 158 | ||
| 159 | yield* Effect.addFinalizer(() => | |
| 160 | mutex.withPermit( | |
| 161 | Effect.gen(function* () { | |
| 162 | yield* cancelIdleClose(); | |
| 163 | state.borrowers = 0; | |
| 164 | yield* closeServer(); | |
| 165 | }), | |
| 166 | ), | |
| 167 | ); | |
| 168 | ||
| 169 | return OpenCodeServerOwner.of({ | |
| 170 | withServer: (use) => | |
| 171 | Effect.uninterruptibleMask((restore) => | |
| 172 | restore(acquireServer).pipe( | |
| 173 | Effect.flatMap((server) => | |
| 174 | restore(use(server)).pipe(Effect.ensuring(releaseServer(server))), | |
| 175 | ), | |
| 176 | ), | |
| 177 | ), | |
| 178 | }); | |
| 179 | }); | |
| 180 | ||
| 181 | /** @public Service construction is part of the canonical Effect module API. */ | |
| 182 | export const layer = (input: { | |
| 183 | readonly binaryPath: string; | |
| 184 | readonly directory: string; | |
| 185 | readonly serverPassword?: string; | |
| 186 | readonly environment?: NodeJS.ProcessEnv; | |
| 187 | readonly verify?: (url: string) => Effect.Effect<string, OpenCodeRuntime.OpenCodeRuntimeError>; | |
| 188 | }) => Layer.effect(OpenCodeServerOwner, make(input)); |
apps/server/src/provider/ProviderInstanceRegistry.test.ts+4-4| ... | ... | @@ -33,7 +33,6 @@ import { |
| 33 | 33 | type CodexSettings, |
| 34 | 34 | type CursorSettings, |
| 35 | 35 | type GrokSettings, |
| 36 | type OpenCodeSettings, | |
| 37 | 36 | ProviderDriverKind, |
| 38 | 37 | type ProviderInstanceConfigMap, |
| 39 | 38 | ProviderInstanceId, |
| ... | ... | @@ -56,10 +55,11 @@ import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; |
| 56 | 55 | import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; |
| 57 | 56 | import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; |
| 58 | 57 | import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts"; |
| 59 | import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts"; | |
| 58 | import { OpenCodeDriver, type OpenCodeDriverEnv } from "@t3tools/provider-opencode/server"; | |
| 59 | import type { OpenCodeSettings } from "@t3tools/provider-opencode/settings"; | |
| 60 | 60 | import * as ModelManifest from "./ModelManifest.ts"; |
| 61 | import * as OpenCodeRuntime from "./opencodeRuntime.ts"; | |
| 62 | import * as OpenCodeServerLedger from "./OpenCodeServerLedger.ts"; | |
| 61 | import * as OpenCodeRuntime from "@t3tools/provider-opencode/server/OpenCodeRuntime"; | |
| 62 | import * as OpenCodeServerLedger from "@t3tools/provider-opencode/server/OpenCodeServerLedger"; | |
| 63 | 63 | import * as ResetCreditCoordinator from "./resetCreditCoordinator.ts"; |
| 64 | 64 | import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; |
| 65 | 65 | import { makeProviderInstanceRegistry } from "./ProviderInstanceRegistry.ts"; |
apps/server/src/provider/ProviderRegistry.test.ts+2-2| ... | ... | @@ -45,8 +45,8 @@ import * as AntigravityInstallation from "./AntigravityInstallation.ts"; |
| 45 | 45 | import * as ModelManifest from "./ModelManifest.ts"; |
| 46 | 46 | import { applyProviderCompatibility } from "./providerCompatibility.ts"; |
| 47 | 47 | import * as ResetCreditCoordinator from "./resetCreditCoordinator.ts"; |
| 48 | import * as OpenCodeRuntime from "./opencodeRuntime.ts"; | |
| 49 | import * as OpenCodeServerLedger from "./OpenCodeServerLedger.ts"; | |
| 48 | import * as OpenCodeRuntime from "@t3tools/provider-opencode/server/OpenCodeRuntime"; | |
| 49 | import * as OpenCodeServerLedger from "@t3tools/provider-opencode/server/OpenCodeServerLedger"; | |
| 50 | 50 | import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; |
| 51 | 51 | import * as ProviderInstanceRegistryHydration from "./ProviderInstanceRegistryHydration.ts"; |
| 52 | 52 | import * as ServerConfig from "../config.ts"; |
apps/server/src/provider/acp/AcpNativeLogging.ts+1-1| ... | ... | @@ -10,7 +10,7 @@ import type { EventNdjsonLogger } from "../EventNdjsonLogger.ts"; |
| 10 | 10 | import { |
| 11 | 11 | structuralProtocolMethod, |
| 12 | 12 | summarizeNativeProtocolPayload, |
| 13 | } from "../NativeProtocolLogging.ts"; | |
| 13 | } from "@t3tools/provider-core/server/nativeProtocolLogging"; | |
| 14 | 14 | import type * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; |
| 15 | 15 | |
| 16 | 16 | const transientProtocolUpdates = new Set(["agent_message_chunk", "agent_thought_chunk"]); |
apps/server/src/provider/acp/AcpRegistryAuthenticationState.ts+1-1| ... | ... | @@ -12,7 +12,7 @@ import * as Ref from "effect/Ref"; |
| 12 | 12 | import * as Schema from "effect/Schema"; |
| 13 | 13 | import * as Semaphore from "effect/Semaphore"; |
| 14 | 14 | |
| 15 | import { writeFileStringAtomically } from "../../atomicWrite.ts"; | |
| 15 | import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; | |
| 16 | 16 | |
| 17 | 17 | const decodeState = Schema.decodeUnknownEffect( |
| 18 | 18 | Schema.fromJsonString(Schema.Struct({ binding: Schema.String, authenticated: Schema.Boolean })), |
apps/server/src/provider/builtInDrivers.ts+1-1| ... | ... | @@ -26,7 +26,7 @@ import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; |
| 26 | 26 | import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; |
| 27 | 27 | import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; |
| 28 | 28 | import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts"; |
| 29 | import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts"; | |
| 29 | import { OpenCodeDriver, type OpenCodeDriverEnv } from "@t3tools/provider-opencode/server"; | |
| 30 | 30 | import { MuseDriver, type MuseDriverEnv } from "@t3tools/provider-muse/server"; |
| 31 | 31 | import { PiDriver, type PiDriverEnv } from "@t3tools/provider-pi/server"; |
| 32 | 32 | import type { AnyProviderDriver } from "@t3tools/provider-core/server/driver"; |
apps/server/src/provider/openCodeUsageLimits.ts deleted-121| ... | ... | @@ -1,121 +0,0 @@ |
| 1 | import * as NodeOS from "node:os"; | |
| 2 | ||
| 3 | import type { ServerProviderUsageWindow } from "@t3tools/contracts"; | |
| 4 | import * as Crypto from "effect/Crypto"; | |
| 5 | import * as DateTime from "effect/DateTime"; | |
| 6 | import * as Effect from "effect/Effect"; | |
| 7 | import * as FileSystem from "effect/FileSystem"; | |
| 8 | import * as Option from "effect/Option"; | |
| 9 | import * as Path from "effect/Path"; | |
| 10 | import * as Schema from "effect/Schema"; | |
| 11 | import * as Hex from "effect/encoding/Hex"; | |
| 12 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; | |
| 13 | ||
| 14 | import { | |
| 15 | clampPercent, | |
| 16 | makeUnavailableUsageLimits, | |
| 17 | makeUsageLimits, | |
| 18 | } from "@t3tools/provider-core/server/usageLimits"; | |
| 19 | ||
| 20 | const AuthFile = Schema.Struct({ "opencode-go": Schema.optionalKey(Schema.Unknown) }); | |
| 21 | const ApiAuth = Schema.Struct({ type: Schema.Literal("api"), key: Schema.String }); | |
| 22 | const decodeAuthFile = Schema.decodeEffect(Schema.fromJsonString(AuthFile)); | |
| 23 | const decodeApiAuth = Schema.decodeUnknownOption(ApiAuth); | |
| 24 | const UsageWindow = Schema.Struct({ | |
| 25 | percent: Schema.Finite, | |
| 26 | resetsAt: Schema.DateTimeUtcFromString, | |
| 27 | }); | |
| 28 | const UsageResponse = Schema.Struct({ | |
| 29 | usage: Schema.Struct({ rolling: UsageWindow, weekly: UsageWindow, monthly: UsageWindow }), | |
| 30 | }); | |
| 31 | ||
| 32 | /** External OpenCode servers own their credentials; never read the host's account for them. */ | |
| 33 | export const readOpenCodeGoUsageLimits = Effect.fn("readOpenCodeGoUsageLimits")(function* (input: { | |
| 34 | readonly enabled: boolean; | |
| 35 | readonly serverUrl: string; | |
| 36 | readonly environment: NodeJS.ProcessEnv; | |
| 37 | }) { | |
| 38 | const checkedAt = DateTime.formatIso(yield* DateTime.now); | |
| 39 | const unsupported = makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); | |
| 40 | if (!input.enabled || input.serverUrl.trim()) return unsupported; | |
| 41 | ||
| 42 | return yield* Effect.gen(function* () { | |
| 43 | const fs = yield* FileSystem.FileSystem; | |
| 44 | const path = yield* Path.Path; | |
| 45 | const env = input.environment; | |
| 46 | const dataHome = | |
| 47 | env.XDG_DATA_HOME || | |
| 48 | path.join(env.HOME || env.USERPROFILE || NodeOS.homedir(), ".local", "share"); | |
| 49 | const authPath = path.join(dataHome, "opencode", "auth.json"); | |
| 50 | const contents = | |
| 51 | env.OPENCODE_AUTH_CONTENT || | |
| 52 | (yield* fs.readFileString(authPath).pipe( | |
| 53 | Effect.catchTags({ | |
| 54 | PlatformError: (error) => | |
| 55 | error.reason._tag === "NotFound" ? Effect.succeed("{}") : Effect.fail(error), | |
| 56 | }), | |
| 57 | )); | |
| 58 | const auth = yield* decodeAuthFile(contents); | |
| 59 | const apiAuth = decodeApiAuth(auth["opencode-go"]); | |
| 60 | // OpenCode overlays stored API credentials after environment credentials. | |
| 61 | const apiKey = (Option.isSome(apiAuth) ? apiAuth.value.key : env.OPENCODE_API_KEY)?.trim(); | |
| 62 | if (!apiKey) return unsupported; | |
| 63 | ||
| 64 | const client = yield* HttpClient.HttpClient; | |
| 65 | const response = yield* client.execute( | |
| 66 | HttpClientRequest.get("https://opencode.ai/zen/go/v1/usage").pipe( | |
| 67 | HttpClientRequest.bearerToken(apiKey), | |
| 68 | ), | |
| 69 | ); | |
| 70 | // A valid Zen key can exist without a Go subscription. | |
| 71 | if (response.status === 403) return unsupported; | |
| 72 | const body = yield* HttpClientResponse.filterStatusOk(response).pipe( | |
| 73 | Effect.flatMap(HttpClientResponse.schemaBodyJson(UsageResponse)), | |
| 74 | ); | |
| 75 | const windows: ServerProviderUsageWindow[] = [ | |
| 76 | { | |
| 77 | id: "go_rolling", | |
| 78 | kind: "session", | |
| 79 | label: "Go · Session", | |
| 80 | windowDurationMins: 5 * 60, | |
| 81 | usedPercent: clampPercent(body.usage.rolling.percent), | |
| 82 | resetsAt: DateTime.formatIso(body.usage.rolling.resetsAt), | |
| 83 | }, | |
| 84 | { | |
| 85 | id: "go_weekly", | |
| 86 | kind: "weekly", | |
| 87 | label: "Go · Weekly", | |
| 88 | windowDurationMins: 7 * 24 * 60, | |
| 89 | usedPercent: clampPercent(body.usage.weekly.percent), | |
| 90 | resetsAt: DateTime.formatIso(body.usage.weekly.resetsAt), | |
| 91 | }, | |
| 92 | { | |
| 93 | id: "go_monthly", | |
| 94 | kind: "monthly", | |
| 95 | label: "Go · Monthly", | |
| 96 | usedPercent: clampPercent(body.usage.monthly.percent), | |
| 97 | resetsAt: DateTime.formatIso(body.usage.monthly.resetsAt), | |
| 98 | }, | |
| 99 | ]; | |
| 100 | const crypto = yield* Crypto.Crypto; | |
| 101 | // Go's usage response has no account ID. An unkeyed hash matches across | |
| 102 | // environments without a shared secret. It permits offline guesses, but | |
| 103 | // Go keys are randomly generated. | |
| 104 | const credentialFingerprint = yield* crypto | |
| 105 | .digest("SHA-256", new TextEncoder().encode(`opencode-go\0${apiKey}`)) | |
| 106 | .pipe(Effect.map(Hex.encode), Effect.orDie); | |
| 107 | return { | |
| 108 | ...makeUsageLimits({ checkedAt, windows }), | |
| 109 | credentialFingerprint, | |
| 110 | }; | |
| 111 | }).pipe( | |
| 112 | Effect.timeout("5 seconds"), | |
| 113 | Effect.orElseSucceed(() => | |
| 114 | makeUnavailableUsageLimits({ | |
| 115 | checkedAt, | |
| 116 | reason: "probeFailed", | |
| 117 | message: "OpenCode Go could not read usage.", | |
| 118 | }), | |
| 119 | ), | |
| 120 | ); | |
| 121 | }); |
apps/server/src/provider/opencode2/OpenCode2Client.events.test.ts deleted-196| ... | ... | @@ -1,196 +0,0 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { assert, it } from "@effect/vitest"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Fiber from "effect/Fiber"; | |
| 5 | import * as FileSystem from "effect/FileSystem"; | |
| 6 | import * as Layer from "effect/Layer"; | |
| 7 | import * as Schema from "effect/Schema"; | |
| 8 | import * as Stream from "effect/Stream"; | |
| 9 | import { TestClock } from "effect/testing"; | |
| 10 | import { HttpClient, HttpClientResponse } from "effect/http"; | |
| 11 | import { describe } from "vite-plus/test"; | |
| 12 | ||
| 13 | import * as OpenCode2Client from "./OpenCode2Client.ts"; | |
| 14 | ||
| 15 | const RECORDING = new URL( | |
| 16 | "../../orchestration-v2/testkit/fixtures/opencode2_simple/opencode_transcript.ndjson", | |
| 17 | import.meta.url, | |
| 18 | ); | |
| 19 | const Entry = Schema.Struct({ type: Schema.String, frame: Schema.optional(Schema.Unknown) }); | |
| 20 | const decodeEntry = Schema.decodeUnknownSync(Schema.fromJsonString(Entry)); | |
| 21 | const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); | |
| 22 | ||
| 23 | /** The recorded `simple` turn's events, as a newer server would send them. */ | |
| 24 | const newerServerStream = Effect.gen(function* () { | |
| 25 | const fs = yield* FileSystem.FileSystem; | |
| 26 | const recorded = (yield* fs.readFileString(RECORDING.pathname)) | |
| 27 | .split("\n") | |
| 28 | .filter((line) => line.length > 0) | |
| 29 | .map((line) => decodeEntry(line)) | |
| 30 | .flatMap((entry) => { | |
| 31 | const frame = entry.frame as { readonly type?: string; readonly event?: unknown } | undefined; | |
| 32 | return frame?.type === "sdk.event" ? [frame.event as Record<string, unknown>] : []; | |
| 33 | }); | |
| 34 | const executionStarted = recorded.findIndex( | |
| 35 | (event) => event.type === "session.execution.started", | |
| 36 | ); | |
| 37 | const [first] = recorded; | |
| 38 | return [ | |
| 39 | ...recorded.slice(0, executionStarted), | |
| 40 | // An event type this build has never heard of. | |
| 41 | { | |
| 42 | id: "evt_0000000000newtype", | |
| 43 | created: 1, | |
| 44 | type: "session.hologram.projected", | |
| 45 | data: { beams: 3 }, | |
| 46 | }, | |
| 47 | // A known event carrying a field added after 2.0.18. | |
| 48 | { | |
| 49 | ...recorded[executionStarted], | |
| 50 | data: { ...(recorded[executionStarted]!.data as object), lane: "fast" }, | |
| 51 | }, | |
| 52 | ...recorded.slice(executionStarted + 1), | |
| 53 | first, | |
| 54 | ]; | |
| 55 | }); | |
| 56 | ||
| 57 | const layerServing = (events: ReadonlyArray<unknown>) => | |
| 58 | Layer.succeed( | |
| 59 | HttpClient.HttpClient, | |
| 60 | HttpClient.make((request) => | |
| 61 | Effect.succeed( | |
| 62 | HttpClientResponse.fromWeb( | |
| 63 | request, | |
| 64 | new Response(events.map((event) => `data: ${encodeJson(event)}\n\n`).join(""), { | |
| 65 | headers: { "content-type": "text/event-stream" }, | |
| 66 | }), | |
| 67 | ), | |
| 68 | ), | |
| 69 | ), | |
| 70 | ); | |
| 71 | ||
| 72 | const connect = Effect.gen(function* () { | |
| 73 | const opencode = yield* OpenCode2Client.OpenCode2Client; | |
| 74 | return yield* opencode.connect({ baseUrl: "http://127.0.0.1:4096", password: "secret" }); | |
| 75 | }); | |
| 76 | ||
| 77 | /** Sends `events`, then keeps the connection open without another byte, like a frozen server. */ | |
| 78 | const layerServingThenSilent = (events: ReadonlyArray<unknown>) => | |
| 79 | Layer.succeed( | |
| 80 | HttpClient.HttpClient, | |
| 81 | HttpClient.make((request) => | |
| 82 | Effect.succeed( | |
| 83 | HttpClientResponse.fromWeb( | |
| 84 | request, | |
| 85 | new Response( | |
| 86 | new ReadableStream<Uint8Array>({ | |
| 87 | start(controller) { | |
| 88 | for (const event of events) { | |
| 89 | controller.enqueue(new TextEncoder().encode(`data: ${encodeJson(event)}\n\n`)); | |
| 90 | } | |
| 91 | }, | |
| 92 | }), | |
| 93 | { headers: { "content-type": "text/event-stream" } }, | |
| 94 | ), | |
| 95 | ), | |
| 96 | ), | |
| 97 | ), | |
| 98 | ); | |
| 99 | ||
| 100 | describe("OpenCode2Client events", () => { | |
| 101 | it.effect("fails a stream that stops sending anything, even heartbeats", () => | |
| 102 | Effect.gen(function* () { | |
| 103 | const { events } = yield* connect.pipe( | |
| 104 | Effect.provide( | |
| 105 | OpenCode2Client.layer.pipe( | |
| 106 | Layer.provide( | |
| 107 | layerServingThenSilent([{ id: "evt_1", type: "server.connected", data: {} }]), | |
| 108 | ), | |
| 109 | ), | |
| 110 | ), | |
| 111 | ); | |
| 112 | const drained = yield* (yield* events).pipe(Stream.runDrain, Effect.flip, Effect.forkChild); | |
| 113 | // Let the first frame arrive and the timer start before the clock moves. | |
| 114 | for (let i = 0; i < 20; i++) yield* Effect.yieldNow; | |
| 115 | yield* TestClock.adjust("46 seconds"); | |
| 116 | const failure = yield* Fiber.join(drained); | |
| 117 | assert.strictEqual(failure._tag, "OpenCode2SilentStreamError"); | |
| 118 | }).pipe(Effect.provide(TestClock.layer())), | |
| 119 | ); | |
| 120 | ||
| 121 | it.effect( | |
| 122 | "keeps an execution's end and start it cannot decode, and skips other unknown events", | |
| 123 | () => | |
| 124 | Effect.gen(function* () { | |
| 125 | const { events } = yield* connect.pipe( | |
| 126 | Effect.provide( | |
| 127 | OpenCode2Client.layer.pipe( | |
| 128 | Layer.provide( | |
| 129 | layerServing([ | |
| 130 | { | |
| 131 | id: "evt_1", | |
| 132 | created: 1, | |
| 133 | type: "session.hologram.projected", | |
| 134 | data: { sessionID: "ses_x" }, | |
| 135 | }, | |
| 136 | // A start is kept as a marker; it never ends anything. | |
| 137 | { | |
| 138 | id: "evt_0", | |
| 139 | created: 1, | |
| 140 | type: "session.execution.started", | |
| 141 | data: { sessionID: "ses_x", lane: 7 }, | |
| 142 | durable: "not-an-envelope", | |
| 143 | }, | |
| 144 | { | |
| 145 | id: "evt_2", | |
| 146 | created: 1, | |
| 147 | type: "session.execution.interrupted", | |
| 148 | data: { sessionID: "ses_x", reason: "budget" }, | |
| 149 | durable: { aggregateID: "ses_x", seq: 1, version: 1 }, | |
| 150 | }, | |
| 151 | ]), | |
| 152 | ), | |
| 153 | ), | |
| 154 | ), | |
| 155 | ); | |
| 156 | const received = yield* (yield* events).pipe(Stream.runCollect); | |
| 157 | assert.deepStrictEqual(received, [ | |
| 158 | { type: "unreadable.execution.started", sessionID: "ses_x" }, | |
| 159 | { | |
| 160 | type: "unreadable.execution.ended", | |
| 161 | executionType: "session.execution.interrupted", | |
| 162 | sessionID: "ses_x", | |
| 163 | }, | |
| 164 | ]); | |
| 165 | }), | |
| 166 | ); | |
| 167 | ||
| 168 | it.effect("skips events a newer server adds and still sees the turn end", () => | |
| 169 | Effect.gen(function* () { | |
| 170 | const events = yield* newerServerStream; | |
| 171 | const { events: subscribe } = yield* connect.pipe( | |
| 172 | Effect.provide(OpenCode2Client.layer.pipe(Layer.provide(layerServing(events)))), | |
| 173 | ); | |
| 174 | const types = yield* (yield* subscribe).pipe( | |
| 175 | Stream.map((event) => event.type), | |
| 176 | Stream.runCollect, | |
| 177 | ); | |
| 178 | ||
| 179 | assert.notInclude(types, "session.hologram.projected"); | |
| 180 | assert.include(types, "session.execution.started"); | |
| 181 | assert.include(types, "session.execution.succeeded"); | |
| 182 | assert.strictEqual(types.length, events.length - 1); | |
| 183 | }).pipe(Effect.provide(NodeServices.layer)), | |
| 184 | ); | |
| 185 | ||
| 186 | it.effect("the client's own subscription fails the whole stream on the same events", () => | |
| 187 | Effect.gen(function* () { | |
| 188 | const events = yield* newerServerStream; | |
| 189 | const { client } = yield* connect.pipe( | |
| 190 | Effect.provide(OpenCode2Client.layer.pipe(Layer.provide(layerServing(events)))), | |
| 191 | ); | |
| 192 | const failure = yield* client.event.subscribe().pipe(Stream.runDrain, Effect.flip); | |
| 193 | assert.strictEqual(failure._tag, "ClientError"); | |
| 194 | }).pipe(Effect.provide(NodeServices.layer)), | |
| 195 | ); | |
| 196 | }); |
apps/server/src/provider/opencode2/OpenCode2Client.live.test.ts deleted-151| ... | ... | @@ -1,151 +0,0 @@ |
| 1 | /** | |
| 2 | * Live contract check for `@opencode/client` against a real OpenCode 2 server. | |
| 3 | * It re-proves that OpenCode's schemas decode under the workspace Effect | |
| 4 | * version, which the pnpm overrides force. Run it after bumping either side: | |
| 5 | * | |
| 6 | * OPENCODE2_BIN=/path/to/opencode vp test run src/provider/opencode2/OpenCode2Client.live.test.ts | |
| 7 | * | |
| 8 | * The turn uses the free `opencode/big-pickle` model. | |
| 9 | */ | |
| 10 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 11 | import { AbsolutePath, Location, Model, Provider } from "@opencode/client/effect"; | |
| 12 | import { assert, it } from "@effect/vitest"; | |
| 13 | import * as Crypto from "effect/Crypto"; | |
| 14 | import * as Effect from "effect/Effect"; | |
| 15 | import * as Base64Url from "effect/encoding/Base64Url"; | |
| 16 | import * as Fiber from "effect/Fiber"; | |
| 17 | import * as FileSystem from "effect/FileSystem"; | |
| 18 | import * as Filter from "effect/Filter"; | |
| 19 | import * as Layer from "effect/Layer"; | |
| 20 | import * as Option from "effect/Option"; | |
| 21 | import * as Path from "effect/Path"; | |
| 22 | import * as Stream from "effect/Stream"; | |
| 23 | import { FetchHttpClient } from "effect/http"; | |
| 24 | import { ChildProcess, ChildProcessSpawner } from "effect/process"; | |
| 25 | import { describe } from "vite-plus/test"; | |
| 26 | ||
| 27 | import * as OpenCode2Client from "./OpenCode2Client.ts"; | |
| 28 | ||
| 29 | const binaryPath = process.env.OPENCODE2_BIN; | |
| 30 | ||
| 31 | const startServer = Effect.fn("OpenCode2ClientLive.startServer")(function* (binary: string) { | |
| 32 | const fs = yield* FileSystem.FileSystem; | |
| 33 | const path = yield* Path.Path; | |
| 34 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 35 | const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode2-live-" }); | |
| 36 | const directory = path.join(root, "work"); | |
| 37 | yield* fs.makeDirectory(directory); | |
| 38 | // Non-ASCII on purpose: OpenCode decodes Basic credentials as UTF-8. | |
| 39 | const crypto = yield* Crypto.Crypto; | |
| 40 | const password = `${Base64Url.encode(yield* crypto.randomBytes(32))}-pässwörd€`; | |
| 41 | const child = yield* spawner.spawn( | |
| 42 | ChildProcess.make(binary, ["serve", "--hostname=127.0.0.1", "--port=0"], { | |
| 43 | cwd: directory, | |
| 44 | env: { | |
| 45 | PATH: process.env.PATH, | |
| 46 | HOME: root, | |
| 47 | XDG_CONFIG_HOME: path.join(root, "config"), | |
| 48 | XDG_DATA_HOME: path.join(root, "data"), | |
| 49 | XDG_STATE_HOME: path.join(root, "state"), | |
| 50 | XDG_CACHE_HOME: path.join(root, "cache"), | |
| 51 | OPENCODE_PASSWORD: password, | |
| 52 | }, | |
| 53 | }), | |
| 54 | ); | |
| 55 | // Stop by the PID captured at spawn, before the temp directory is removed. | |
| 56 | yield* Effect.addFinalizer(() => | |
| 57 | Effect.sync(() => { | |
| 58 | try { | |
| 59 | process.kill(Number(child.pid), "SIGTERM"); | |
| 60 | } catch { | |
| 61 | // Already exited. | |
| 62 | } | |
| 63 | }).pipe(Effect.andThen(child.exitCode), Effect.timeout("10 seconds"), Effect.ignore), | |
| 64 | ); | |
| 65 | const baseUrl = yield* child.stdout.pipe( | |
| 66 | Stream.decodeText(), | |
| 67 | Stream.splitLines, | |
| 68 | Stream.filterMap( | |
| 69 | Filter.fromPredicateOption((line: string) => | |
| 70 | Option.fromUndefinedOr(/server listening on\s+(https?:\/\/\S+)/i.exec(line)?.[1]), | |
| 71 | ), | |
| 72 | ), | |
| 73 | Stream.runHead, | |
| 74 | Effect.flatMap(Effect.fromOption), | |
| 75 | Effect.timeout("30 seconds"), | |
| 76 | ); | |
| 77 | return { baseUrl, password, directory, pid: Number(child.pid) }; | |
| 78 | }); | |
| 79 | ||
| 80 | describe.runIf(binaryPath !== undefined)("OpenCode2Client live", () => { | |
| 81 | it.live( | |
| 82 | "reads server info, runs one turn, and pages the history", | |
| 83 | () => | |
| 84 | Effect.gen(function* () { | |
| 85 | const server = yield* startServer(binaryPath!); | |
| 86 | const opencode = yield* OpenCode2Client.OpenCode2Client; | |
| 87 | const { client, events } = yield* opencode.connect({ | |
| 88 | baseUrl: server.baseUrl, | |
| 89 | password: server.password, | |
| 90 | }); | |
| 91 | ||
| 92 | const info = yield* client.server.info(); | |
| 93 | assert.strictEqual(info.version, "2.0.23"); | |
| 94 | assert.strictEqual(info.pid, server.pid); | |
| 95 | ||
| 96 | const session = yield* client.session.create({ | |
| 97 | title: "t3 client live check", | |
| 98 | location: Location.PublicRef.make({ directory: AbsolutePath.make(server.directory) }), | |
| 99 | model: Model.Ref.make({ | |
| 100 | providerID: Provider.ID.make("opencode"), | |
| 101 | id: Model.ID.make("big-pickle"), | |
| 102 | }), | |
| 103 | }); | |
| 104 | const finished = yield* (yield* events).pipe( | |
| 105 | Stream.filter( | |
| 106 | (event) => | |
| 107 | event.type !== "unreadable.execution.ended" && | |
| 108 | event.type !== "unreadable.execution.started" && | |
| 109 | (event.data as { readonly sessionID?: string }).sessionID === session.id && | |
| 110 | event.type.startsWith("session.execution.") && | |
| 111 | event.type !== "session.execution.started", | |
| 112 | ), | |
| 113 | Stream.runHead, | |
| 114 | Effect.forkChild, | |
| 115 | ); | |
| 116 | yield* client.session.prompt({ | |
| 117 | sessionID: session.id, | |
| 118 | text: "Reply with exactly: OPENCODE2_CLIENT_OK", | |
| 119 | }); | |
| 120 | const terminal = yield* Fiber.join(finished).pipe( | |
| 121 | Effect.flatMap(Effect.fromOption), | |
| 122 | Effect.timeout("120 seconds"), | |
| 123 | ); | |
| 124 | assert.strictEqual(terminal.type, "session.execution.succeeded"); | |
| 125 | ||
| 126 | // One message per page makes every request after the first send only a cursor. | |
| 127 | const history = yield* OpenCode2Client.paginate( | |
| 128 | { sessionID: session.id, order: "asc", limit: 1 }, | |
| 129 | client.message.list, | |
| 130 | ).pipe(Stream.runCollect); | |
| 131 | assert.deepStrictEqual( | |
| 132 | history.map((message) => message.type), | |
| 133 | ["user", "assistant", "idle"], | |
| 134 | ); | |
| 135 | const assistant = history[1]; | |
| 136 | assert(assistant?.type === "assistant"); | |
| 137 | const reply = assistant.content.flatMap((part) => | |
| 138 | part.type === "text" ? [part.text] : [], | |
| 139 | ); | |
| 140 | assert.include(reply.join(""), "OPENCODE2_CLIENT_OK"); | |
| 141 | }).pipe( | |
| 142 | Effect.scoped, | |
| 143 | Effect.provide( | |
| 144 | OpenCode2Client.layer.pipe( | |
| 145 | Layer.provideMerge(Layer.mergeAll(NodeServices.layer, FetchHttpClient.layer)), | |
| 146 | ), | |
| 147 | ), | |
| 148 | ), | |
| 149 | 180_000, | |
| 150 | ); | |
| 151 | }); |
apps/server/src/provider/opencode2/OpenCode2Client.test.ts deleted-48| ... | ... | @@ -1,48 +0,0 @@ |
| 1 | import { assert, it } from "@effect/vitest"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as Layer from "effect/Layer"; | |
| 4 | import * as Redacted from "effect/Redacted"; | |
| 5 | import { HttpClient, HttpClientResponse } from "effect/http"; | |
| 6 | import { describe } from "vite-plus/test"; | |
| 7 | ||
| 8 | import * as OpenCode2Client from "./OpenCode2Client.ts"; | |
| 9 | ||
| 10 | const INFO_BODY = '{"version":"2.0.18","pid":4242,"urls":[],"paths":{"tmp":"/tmp"}}'; | |
| 11 | ||
| 12 | /** Sends one request through the client and returns the Authorization header it carried. */ | |
| 13 | const authorizationFor = (password: string | Redacted.Redacted) => { | |
| 14 | const seen: Array<string | undefined> = []; | |
| 15 | const layerCapturing = Layer.succeed( | |
| 16 | HttpClient.HttpClient, | |
| 17 | HttpClient.make((request) => | |
| 18 | Effect.sync(() => { | |
| 19 | seen.push(request.headers.authorization); | |
| 20 | return HttpClientResponse.fromWeb( | |
| 21 | request, | |
| 22 | new Response(INFO_BODY, { headers: { "content-type": "application/json" } }), | |
| 23 | ); | |
| 24 | }), | |
| 25 | ), | |
| 26 | ); | |
| 27 | return Effect.gen(function* () { | |
| 28 | const opencode = yield* OpenCode2Client.OpenCode2Client; | |
| 29 | const { client } = yield* opencode.connect({ baseUrl: "http://127.0.0.1:4096", password }); | |
| 30 | yield* client.server.info(); | |
| 31 | return seen[0]; | |
| 32 | }).pipe(Effect.provide(OpenCode2Client.layer.pipe(Layer.provide(layerCapturing)))); | |
| 33 | }; | |
| 34 | ||
| 35 | const utf8Basic = (password: string) => | |
| 36 | `Basic ${Buffer.from(`opencode:${password}`, "utf8").toString("base64")}`; | |
| 37 | ||
| 38 | describe("OpenCode2Client authorization", () => { | |
| 39 | // OpenCode decodes Basic credentials as UTF-8. Latin-1 (`btoa`) encoding gets | |
| 40 | // `pässwörd` rejected with a 401 and throws on characters such as `€`. | |
| 41 | it.effect("encodes non-ASCII passwords as UTF-8", () => | |
| 42 | Effect.gen(function* () { | |
| 43 | assert.strictEqual(yield* authorizationFor("pässwörd"), utf8Basic("pässwörd")); | |
| 44 | assert.strictEqual(yield* authorizationFor("pass€word"), utf8Basic("pass€word")); | |
| 45 | assert.strictEqual(yield* authorizationFor(Redacted.make("pässwörd")), utf8Basic("pässwörd")); | |
| 46 | }), | |
| 47 | ); | |
| 48 | }); |
apps/server/src/provider/opencode2/OpenCode2Client.ts deleted-223| ... | ... | @@ -1,223 +0,0 @@ |
| 1 | import { OpenCode, type OpenCodeClient } from "@opencode/client/effect"; | |
| 2 | import { OpenCodeEvent } from "@opencode/protocol/groups/event"; | |
| 3 | import * as Context from "effect/Context"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as Layer from "effect/Layer"; | |
| 6 | import * as Option from "effect/Option"; | |
| 7 | import * as Redacted from "effect/Redacted"; | |
| 8 | import * as Result from "effect/Result"; | |
| 9 | import * as Schema from "effect/Schema"; | |
| 10 | import * as Stream from "effect/Stream"; | |
| 11 | import * as Sse from "effect/encoding/Sse"; | |
| 12 | import * as HttpClient from "effect/http/HttpClient"; | |
| 13 | import * as HttpClientError from "effect/http/HttpClientError"; | |
| 14 | import * as HttpClientRequest from "effect/http/HttpClientRequest"; | |
| 15 | import * as HttpClientResponse from "effect/http/HttpClientResponse"; | |
| 16 | ||
| 17 | /** OpenCode 2 accepts only HTTP Basic auth, always with this user name. */ | |
| 18 | const OPENCODE_USERNAME = "opencode"; | |
| 19 | ||
| 20 | /** | |
| 21 | * A session's execution ended, but this build could not decode the event (a | |
| 22 | * newer server added a field or a value). Only the type and session survive, | |
| 23 | * so the turn still ends. | |
| 24 | */ | |
| 25 | export interface OpenCode2UnreadableTerminal { | |
| 26 | readonly type: "unreadable.execution.ended"; | |
| 27 | readonly executionType: (typeof EXECUTION_ENDS)[number]; | |
| 28 | readonly sessionID: string; | |
| 29 | } | |
| 30 | ||
| 31 | /** | |
| 32 | * A session's execution started, but this build could not decode the event. | |
| 33 | * It marks where a new execution begins and never ends a turn. | |
| 34 | */ | |
| 35 | export interface OpenCode2UnreadableStart { | |
| 36 | readonly type: "unreadable.execution.started"; | |
| 37 | readonly sessionID: string; | |
| 38 | } | |
| 39 | ||
| 40 | /** The events that end an execution; any other undecodable event is skipped. */ | |
| 41 | const EXECUTION_ENDS = [ | |
| 42 | "session.execution.succeeded", | |
| 43 | "session.execution.failed", | |
| 44 | "session.execution.interrupted", | |
| 45 | ] as const; | |
| 46 | const isExecutionEnd = (type: string): type is (typeof EXECUTION_ENDS)[number] => | |
| 47 | (EXECUTION_ENDS as ReadonlyArray<string>).includes(type); | |
| 48 | ||
| 49 | /** The server sent nothing, not even a heartbeat, for `SILENT_STREAM_TIMEOUT`. */ | |
| 50 | export class OpenCode2SilentStreamError extends Schema.TaggedError<OpenCode2SilentStreamError>()( | |
| 51 | "OpenCode2SilentStreamError", | |
| 52 | {}, | |
| 53 | ) { | |
| 54 | override get message(): string { | |
| 55 | return "The OpenCode server stopped sending events."; | |
| 56 | } | |
| 57 | } | |
| 58 | ||
| 59 | export type OpenCode2StreamEvent = | |
| 60 | | OpenCodeEvent | |
| 61 | | OpenCode2UnreadableTerminal | |
| 62 | | OpenCode2UnreadableStart; | |
| 63 | ||
| 64 | /** | |
| 65 | * An OpenCode 2 client plus a forward-compatible `/api/event` stream. The | |
| 66 | * client's own `event.subscribe` fails the whole stream on the first event a | |
| 67 | * newer server adds, so `events` decodes each frame on its own and skips the | |
| 68 | * ones this build does not know, except an execution's end or start, which it | |
| 69 | * keeps as an {@link OpenCode2UnreadableTerminal} or {@link OpenCode2UnreadableStart}. | |
| 70 | * `events` succeeds once the server accepted the subscription: the stream is | |
| 71 | * volatile, so callers subscribe before they start work whose events they need. | |
| 72 | */ | |
| 73 | export interface OpenCode2Api { | |
| 74 | readonly client: OpenCodeClient; | |
| 75 | readonly events: Effect.Effect< | |
| 76 | Stream.Stream< | |
| 77 | OpenCode2StreamEvent, | |
| 78 | HttpClientError.HttpClientError | Sse.Retry | Sse.SseError | OpenCode2SilentStreamError | |
| 79 | >, | |
| 80 | HttpClientError.HttpClientError | |
| 81 | >; | |
| 82 | } | |
| 83 | ||
| 84 | /** Builds clients for OpenCode 2 servers, one per base URL and password. */ | |
| 85 | export class OpenCode2Client extends Context.Service< | |
| 86 | OpenCode2Client, | |
| 87 | { | |
| 88 | readonly connect: (input: { | |
| 89 | readonly baseUrl: string; | |
| 90 | readonly password: string | Redacted.Redacted; | |
| 91 | }) => Effect.Effect<OpenCode2Api>; | |
| 92 | } | |
| 93 | >()("t3/provider/opencode2/OpenCode2Client") {} | |
| 94 | ||
| 95 | /** | |
| 96 | * OpenCode writes a `: heartbeat` comment every 10 to 15 seconds, so this long | |
| 97 | * without a single byte means the server is stuck, not idle. | |
| 98 | */ | |
| 99 | const SILENT_STREAM_TIMEOUT = "45 seconds"; | |
| 100 | ||
| 101 | const decodeEvent = Schema.decodeUnknownResult(Schema.fromJsonString(OpenCodeEvent)); | |
| 102 | // Just enough of an event to route it, for frames the full schema rejects. | |
| 103 | const decodeEnvelope = Schema.decodeUnknownOption( | |
| 104 | Schema.fromJsonString( | |
| 105 | Schema.Struct({ | |
| 106 | type: Schema.String, | |
| 107 | data: Schema.optional(Schema.Struct({ sessionID: Schema.optional(Schema.String) })), | |
| 108 | }), | |
| 109 | ), | |
| 110 | ); | |
| 111 | ||
| 112 | const undecodable = (data: string) => | |
| 113 | Effect.gen(function* () { | |
| 114 | const envelope = decodeEnvelope(data); | |
| 115 | const type = envelope._tag === "Some" ? envelope.value.type.slice(0, 80) : "<unreadable>"; | |
| 116 | const sessionID = envelope._tag === "Some" ? envelope.value.data?.sessionID : undefined; | |
| 117 | if (isExecutionEnd(type) && sessionID !== undefined) { | |
| 118 | yield* Effect.logWarning( | |
| 119 | "Ended an OpenCode execution from an event this build cannot decode.", | |
| 120 | { | |
| 121 | type, | |
| 122 | }, | |
| 123 | ); | |
| 124 | return Result.succeed<OpenCode2StreamEvent>({ | |
| 125 | type: "unreadable.execution.ended", | |
| 126 | executionType: type, | |
| 127 | sessionID, | |
| 128 | }); | |
| 129 | } | |
| 130 | if (type === "session.execution.started" && sessionID !== undefined) { | |
| 131 | yield* Effect.logDebug("Read an OpenCode execution start this build cannot decode."); | |
| 132 | return Result.succeed<OpenCode2StreamEvent>({ | |
| 133 | type: "unreadable.execution.started", | |
| 134 | sessionID, | |
| 135 | }); | |
| 136 | } | |
| 137 | yield* Effect.logDebug("Skipped an OpenCode event this build cannot decode.", { type }); | |
| 138 | return Result.failVoid; | |
| 139 | }); | |
| 140 | ||
| 141 | /** Subscribes to `/api/event`, then streams every frame this build can route. */ | |
| 142 | const readEvents = (httpClient: HttpClient.HttpClient) => | |
| 143 | httpClient.get("/api/event", { headers: { accept: "text/event-stream" } }).pipe( | |
| 144 | Effect.flatMap(HttpClientResponse.filterStatusOk), | |
| 145 | Effect.map((response) => | |
| 146 | response.stream.pipe( | |
| 147 | Stream.timeoutOrElse({ | |
| 148 | duration: SILENT_STREAM_TIMEOUT, | |
| 149 | orElse: () => Stream.fail(new OpenCode2SilentStreamError()), | |
| 150 | }), | |
| 151 | Stream.decodeText, | |
| 152 | Stream.pipeThroughChannel(Sse.decode()), | |
| 153 | Stream.filterMapEffect((frame) => { | |
| 154 | const decoded = decodeEvent(frame.data); | |
| 155 | return Result.isSuccess(decoded) | |
| 156 | ? Effect.succeed(Result.succeed<OpenCode2StreamEvent>(decoded.success)) | |
| 157 | : undecodable(frame.data); | |
| 158 | }), | |
| 159 | ), | |
| 160 | ), | |
| 161 | ); | |
| 162 | ||
| 163 | /** | |
| 164 | * OpenCode decodes Basic credentials as UTF-8. `HttpClientRequest.basicAuth` | |
| 165 | * encodes them as Latin-1 (`btoa`), which gets non-ASCII passwords rejected. | |
| 166 | */ | |
| 167 | const basicAuthorization = (password: string | Redacted.Redacted) => { | |
| 168 | const plain = Redacted.isRedacted(password) ? Redacted.value(password) : password; | |
| 169 | return `Basic ${Buffer.from(`${OPENCODE_USERNAME}:${plain}`, "utf8").toString("base64")}`; | |
| 170 | }; | |
| 171 | ||
| 172 | /** @public Service construction is part of the canonical Effect module API. */ | |
| 173 | export const make = Effect.gen(function* () { | |
| 174 | const httpClient = yield* HttpClient.HttpClient; | |
| 175 | return OpenCode2Client.of({ | |
| 176 | connect: ({ baseUrl, password }) => { | |
| 177 | const authenticated = HttpClient.mapRequest( | |
| 178 | httpClient, | |
| 179 | HttpClientRequest.setHeader("Authorization", basicAuthorization(password)), | |
| 180 | ); | |
| 181 | return OpenCode.make({ baseUrl }).pipe( | |
| 182 | Effect.provideService(HttpClient.HttpClient, authenticated), | |
| 183 | Effect.map((client) => ({ | |
| 184 | client, | |
| 185 | events: readEvents( | |
| 186 | HttpClient.mapRequest(authenticated, HttpClientRequest.prependUrl(baseUrl)), | |
| 187 | ), | |
| 188 | })), | |
| 189 | ); | |
| 190 | }, | |
| 191 | }); | |
| 192 | }); | |
| 193 | ||
| 194 | export const layer = Layer.effect(OpenCode2Client, make); | |
| 195 | ||
| 196 | /** | |
| 197 | * Streams every item of a cursor-paged OpenCode 2 list. The first request | |
| 198 | * carries the caller's input (including `order`); later requests send only | |
| 199 | * the cursor, because OpenCode rejects a cursor combined with `order`. | |
| 200 | */ | |
| 201 | export const paginate = <Input extends { readonly cursor?: unknown }, Item, E, R>( | |
| 202 | input: Input, | |
| 203 | list: ( | |
| 204 | input: Input, | |
| 205 | ) => Effect.Effect< | |
| 206 | { readonly data: ReadonlyArray<Item>; readonly cursor: { readonly next?: Input["cursor"] } }, | |
| 207 | E, | |
| 208 | R | |
| 209 | >, | |
| 210 | ): Stream.Stream<Item, E, R> => | |
| 211 | Stream.paginate(input, (request) => | |
| 212 | list(request).pipe( | |
| 213 | Effect.map( | |
| 214 | (page) => | |
| 215 | [ | |
| 216 | page.data, | |
| 217 | page.data.length === 0 || page.cursor.next === undefined | |
| 218 | ? Option.none() | |
| 219 | : Option.some({ ...request, order: undefined, cursor: page.cursor.next }), | |
| 220 | ] as const, | |
| 221 | ), | |
| 222 | ), | |
| 223 | ); |
apps/server/src/provider/opencode2/OpenCode2Server.live.test.ts deleted-238| ... | ... | @@ -1,238 +0,0 @@ |
| 1 | /** | |
| 2 | * Live check of the OpenCode 2 server lifecycle against a real binary: | |
| 3 | * | |
| 4 | * OPENCODE2_BIN=/path/to/opencode vp test run src/provider/opencode2/OpenCode2Server.live.test.ts | |
| 5 | * | |
| 6 | * No model is called. The server runs with isolated HOME and XDG directories. | |
| 7 | */ | |
| 8 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 9 | import { AbsolutePath, Location } from "@opencode/client/effect"; | |
| 10 | import { assert, it } from "@effect/vitest"; | |
| 11 | import * as Context from "effect/Context"; | |
| 12 | import * as Effect from "effect/Effect"; | |
| 13 | import * as Exit from "effect/Exit"; | |
| 14 | import * as FileSystem from "effect/FileSystem"; | |
| 15 | import * as Filter from "effect/Filter"; | |
| 16 | import * as Layer from "effect/Layer"; | |
| 17 | import * as Option from "effect/Option"; | |
| 18 | import * as Path from "effect/Path"; | |
| 19 | import * as Redacted from "effect/Redacted"; | |
| 20 | import * as Scope from "effect/Scope"; | |
| 21 | import * as Stream from "effect/Stream"; | |
| 22 | import { FetchHttpClient } from "effect/http"; | |
| 23 | import { ChildProcess, ChildProcessSpawner } from "effect/process"; | |
| 24 | import { describe } from "vite-plus/test"; | |
| 25 | ||
| 26 | import * as OpenCodeRuntime from "../opencodeRuntime.ts"; | |
| 27 | import * as OpenCodeServerLedger from "../OpenCodeServerLedger.ts"; | |
| 28 | import * as OpenCode2Client from "./OpenCode2Client.ts"; | |
| 29 | import * as OpenCode2Server from "./OpenCode2Server.ts"; | |
| 30 | ||
| 31 | const binaryPath = process.env.OPENCODE2_BIN; | |
| 32 | ||
| 33 | // A negative pid probes the whole process group, which T3 stops as one unit. | |
| 34 | const isAlive = (pid: number) => { | |
| 35 | try { | |
| 36 | process.kill(pid, 0); | |
| 37 | return true; | |
| 38 | } catch { | |
| 39 | return false; | |
| 40 | } | |
| 41 | }; | |
| 42 | ||
| 43 | /** | |
| 44 | * Stops a server the test spawned itself: SIGTERM, then SIGKILL if it is still | |
| 45 | * alive after five seconds. Keyed on the PID captured at spawn, never on a | |
| 46 | * pattern. Servers T3 spawns are stopped by T3, through the instance scope. | |
| 47 | */ | |
| 48 | const stopByPid = (pid: number) => | |
| 49 | Effect.gen(function* () { | |
| 50 | const signal = (name: NodeJS.Signals) => { | |
| 51 | try { | |
| 52 | process.kill(pid, name); | |
| 53 | } catch { | |
| 54 | // Already exited. | |
| 55 | } | |
| 56 | }; | |
| 57 | signal("SIGTERM"); | |
| 58 | for (let attempt = 0; attempt < 50 && isAlive(pid); attempt++) { | |
| 59 | yield* Effect.sleep("100 millis"); | |
| 60 | } | |
| 61 | if (isAlive(pid)) signal("SIGKILL"); | |
| 62 | }); | |
| 63 | ||
| 64 | /** | |
| 65 | * Starts `opencode serve` the way a user would run it themselves. It is stopped | |
| 66 | * by its PID when the calling scope closes, whether or not the test passed. | |
| 67 | */ | |
| 68 | const startExternalServer = Effect.fn("OpenCode2ServerLive.startExternalServer")(function* (input: { | |
| 69 | readonly environment: NodeJS.ProcessEnv; | |
| 70 | readonly directory: string; | |
| 71 | }) { | |
| 72 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 73 | const child = yield* spawner.spawn( | |
| 74 | ChildProcess.make(binaryPath!, ["serve", "--hostname=127.0.0.1", "--port=0"], { | |
| 75 | cwd: input.directory, | |
| 76 | env: input.environment, | |
| 77 | }), | |
| 78 | ); | |
| 79 | const pid = Number(child.pid); | |
| 80 | yield* Effect.addFinalizer(() => stopByPid(pid)); | |
| 81 | const url = yield* child.stdout.pipe( | |
| 82 | Stream.decodeText(), | |
| 83 | Stream.splitLines, | |
| 84 | Stream.filterMap( | |
| 85 | Filter.fromPredicateOption((line: string) => | |
| 86 | Option.fromUndefinedOr(/server listening on\s+(https?:\/\/\S+)/i.exec(line)?.[1]), | |
| 87 | ), | |
| 88 | ), | |
| 89 | Stream.runHead, | |
| 90 | Effect.flatMap(Effect.fromOption), | |
| 91 | Effect.timeout("30 seconds"), | |
| 92 | ); | |
| 93 | return { url, pid }; | |
| 94 | }); | |
| 95 | ||
| 96 | describe.runIf(binaryPath !== undefined)("OpenCode2Server live", () => { | |
| 97 | it.live( | |
| 98 | "spawns one authenticated server per instance and stops it by PID", | |
| 99 | () => | |
| 100 | Effect.gen(function* () { | |
| 101 | const fs = yield* FileSystem.FileSystem; | |
| 102 | const path = yield* Path.Path; | |
| 103 | const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode2-server-live-" }); | |
| 104 | const first = path.join(root, "first"); | |
| 105 | const second = path.join(root, "second"); | |
| 106 | yield* fs.makeDirectory(first); | |
| 107 | yield* fs.makeDirectory(second); | |
| 108 | const environment = { | |
| 109 | PATH: process.env.PATH, | |
| 110 | HOME: root, | |
| 111 | XDG_CONFIG_HOME: path.join(root, "config"), | |
| 112 | XDG_DATA_HOME: path.join(root, "data"), | |
| 113 | XDG_STATE_HOME: path.join(root, "state"), | |
| 114 | XDG_CACHE_HOME: path.join(root, "cache"), | |
| 115 | // Ignored: the T3 password is the only one the server sees. | |
| 116 | OPENCODE_SERVER_PASSWORD: "ambient-password", | |
| 117 | }; | |
| 118 | ||
| 119 | // The instance owns its server: building the layer spawns nothing, and | |
| 120 | // closing its scope stops whatever it spawned. The scope is released | |
| 121 | // even when an assertion below fails; it is closed early on purpose. | |
| 122 | const instanceScope = yield* Effect.acquireRelease(Scope.make(), (scope) => | |
| 123 | Scope.close(scope, Exit.void), | |
| 124 | ); | |
| 125 | const server = Context.get( | |
| 126 | yield* Layer.buildWithScope( | |
| 127 | OpenCode2Server.layer({ | |
| 128 | binaryPath: binaryPath!, | |
| 129 | serverUrl: "", | |
| 130 | serverPassword: "", | |
| 131 | directory: first, | |
| 132 | environment, | |
| 133 | }), | |
| 134 | instanceScope, | |
| 135 | ), | |
| 136 | OpenCode2Server.OpenCode2Server, | |
| 137 | ); | |
| 138 | ||
| 139 | const spawned = yield* server.withConnection((connection) => | |
| 140 | Effect.gen(function* () { | |
| 141 | const info = yield* connection.client.server.info(); | |
| 142 | const session = yield* connection.client.session.create({ | |
| 143 | title: "first location", | |
| 144 | location: Location.PublicRef.make({ directory: AbsolutePath.make(first) }), | |
| 145 | }); | |
| 146 | return { ...connection, pid: info.pid, session }; | |
| 147 | }), | |
| 148 | ); | |
| 149 | assert.strictEqual(spawned.version, "2.0.18"); | |
| 150 | assert.isFalse(spawned.external); | |
| 151 | assert.isTrue(isAlive(spawned.pid)); | |
| 152 | ||
| 153 | // A second location is served by the same process. | |
| 154 | const secondLocation = yield* server.withConnection((connection) => | |
| 155 | Effect.gen(function* () { | |
| 156 | const info = yield* connection.client.server.info(); | |
| 157 | const session = yield* connection.client.session.create({ | |
| 158 | title: "second location", | |
| 159 | location: Location.PublicRef.make({ directory: AbsolutePath.make(second) }), | |
| 160 | }); | |
| 161 | return { url: connection.url, pid: info.pid, session }; | |
| 162 | }), | |
| 163 | ); | |
| 164 | assert.strictEqual(secondLocation.url, spawned.url); | |
| 165 | assert.strictEqual(secondLocation.pid, spawned.pid); | |
| 166 | assert.strictEqual(secondLocation.session.location.directory, second); | |
| 167 | assert.strictEqual(spawned.session.location.directory, first); | |
| 168 | ||
| 169 | // External URL: the configured password works, a wrong one is a 401. | |
| 170 | const external = yield* OpenCode2Server.make({ | |
| 171 | binaryPath: binaryPath!, | |
| 172 | serverUrl: spawned.url, | |
| 173 | serverPassword: "wrong-password", | |
| 174 | directory: first, | |
| 175 | environment, | |
| 176 | }); | |
| 177 | const rejected = yield* external.withConnection(() => Effect.void).pipe(Effect.flip); | |
| 178 | assert.include(rejected.detail, "rejected the server password"); | |
| 179 | const ambient = yield* OpenCode2Server.make({ | |
| 180 | binaryPath: binaryPath!, | |
| 181 | serverUrl: spawned.url, | |
| 182 | serverPassword: environment.OPENCODE_SERVER_PASSWORD, | |
| 183 | directory: first, | |
| 184 | environment, | |
| 185 | }); | |
| 186 | const ambientRejected = yield* ambient.withConnection(() => Effect.void).pipe(Effect.flip); | |
| 187 | assert.include(ambientRejected.detail, "rejected the server password"); | |
| 188 | ||
| 189 | // An external server whose password is not ASCII: OpenCode decodes Basic | |
| 190 | // credentials as UTF-8, so the configured password must be sent that way. | |
| 191 | const utf8Password = "pässwörd€"; | |
| 192 | const externalServer = yield* startExternalServer({ | |
| 193 | environment: OpenCode2Server.serverEnvironment(environment, Redacted.make(utf8Password)), | |
| 194 | directory: second, | |
| 195 | }); | |
| 196 | const utf8 = yield* OpenCode2Server.make({ | |
| 197 | binaryPath: binaryPath!, | |
| 198 | serverUrl: externalServer.url, | |
| 199 | serverPassword: utf8Password, | |
| 200 | directory: second, | |
| 201 | environment, | |
| 202 | }); | |
| 203 | const utf8Connection = yield* utf8.withConnection((connection) => | |
| 204 | Effect.succeed(connection), | |
| 205 | ); | |
| 206 | assert.strictEqual(utf8Connection.version, "2.0.18"); | |
| 207 | assert.isTrue(utf8Connection.external); | |
| 208 | const asciiOnly = yield* OpenCode2Server.make({ | |
| 209 | binaryPath: binaryPath!, | |
| 210 | serverUrl: externalServer.url, | |
| 211 | serverPassword: "passwrd", | |
| 212 | directory: second, | |
| 213 | environment, | |
| 214 | }); | |
| 215 | const asciiRejected = yield* asciiOnly.withConnection(() => Effect.void).pipe(Effect.flip); | |
| 216 | assert.include(asciiRejected.detail, "rejected the server password"); | |
| 217 | ||
| 218 | // Closing the instance stops the server it spawned. | |
| 219 | yield* Scope.close(instanceScope, Exit.void); | |
| 220 | for (let attempt = 0; attempt < 50 && isAlive(-spawned.pid); attempt++) { | |
| 221 | yield* Effect.sleep("100 millis"); | |
| 222 | } | |
| 223 | assert.isFalse(isAlive(spawned.pid), `opencode serve ${spawned.pid} outlived its instance`); | |
| 224 | assert.isFalse(isAlive(-spawned.pid), `a process in group ${spawned.pid} outlived it`); | |
| 225 | const unreachable = yield* external.withConnection(() => Effect.void).pipe(Effect.flip); | |
| 226 | assert.include(unreachable.detail, "Could not reach the OpenCode server"); | |
| 227 | }).pipe( | |
| 228 | Effect.scoped, | |
| 229 | Effect.provide( | |
| 230 | Layer.mergeAll( | |
| 231 | OpenCode2Client.layer, | |
| 232 | OpenCodeRuntime.layer.pipe(Layer.provide(OpenCodeServerLedger.layerTest)), | |
| 233 | ).pipe(Layer.provideMerge(Layer.mergeAll(NodeServices.layer, FetchHttpClient.layer))), | |
| 234 | ), | |
| 235 | ), | |
| 236 | 60_000, | |
| 237 | ); | |
| 238 | }); |
apps/server/src/provider/opencode2/OpenCode2Server.test.ts deleted-259| ... | ... | @@ -1,259 +0,0 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { assert, it } from "@effect/vitest"; | |
| 3 | import { | |
| 4 | HostProcessEnvironment, | |
| 5 | HostProcessExecutablePath, | |
| 6 | HostProcessPlatform, | |
| 7 | } from "@t3tools/shared/hostProcess"; | |
| 8 | import * as Effect from "effect/Effect"; | |
| 9 | import * as Fiber from "effect/Fiber"; | |
| 10 | import * as FileSystem from "effect/FileSystem"; | |
| 11 | import * as Layer from "effect/Layer"; | |
| 12 | import * as Path from "effect/Path"; | |
| 13 | import * as Redacted from "effect/Redacted"; | |
| 14 | import * as TestClock from "effect/testing/TestClock"; | |
| 15 | import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/http"; | |
| 16 | import { describe } from "vite-plus/test"; | |
| 17 | ||
| 18 | import * as OpenCodeRuntime from "../opencodeRuntime.ts"; | |
| 19 | import * as OpenCodeServerLedger from "../OpenCodeServerLedger.ts"; | |
| 20 | import * as OpenCode2Client from "./OpenCode2Client.ts"; | |
| 21 | import * as OpenCode2Server from "./OpenCode2Server.ts"; | |
| 22 | ||
| 23 | // Shapes and content types observed on `opencode serve` 2.0.18 (pid and port replaced). 1.x | |
| 24 | // answers /api/info with its web UI: 200 text/html. | |
| 25 | const INFO_BODY = | |
| 26 | '{"version":"2.0.18","pid":4242,"urls":["http://127.0.0.1:4096"],"paths":{"tmp":"/tmp/opencode"}}'; | |
| 27 | const UNAUTHORIZED_BODY = '{"_tag":"UnauthorizedError","message":"Authentication required"}'; | |
| 28 | const SPA_BODY = "<!doctype html><html><head><title>OpenCode</title></head></html>"; | |
| 29 | ||
| 30 | const layerServerReplying = (reply: { | |
| 31 | readonly status: number; | |
| 32 | readonly contentType?: string; | |
| 33 | readonly body: string; | |
| 34 | }) => | |
| 35 | Layer.succeed( | |
| 36 | HttpClient.HttpClient, | |
| 37 | HttpClient.make((request) => | |
| 38 | Effect.succeed( | |
| 39 | HttpClientResponse.fromWeb( | |
| 40 | request, | |
| 41 | new Response(reply.body, { | |
| 42 | status: reply.status, | |
| 43 | headers: reply.contentType === undefined ? {} : { "content-type": reply.contentType }, | |
| 44 | }), | |
| 45 | ), | |
| 46 | ), | |
| 47 | ), | |
| 48 | ); | |
| 49 | ||
| 50 | const verify = (httpClient: Layer.Layer<HttpClient.HttpClient>, url = "http://127.0.0.1:4096") => | |
| 51 | Effect.gen(function* () { | |
| 52 | const opencode = yield* OpenCode2Client.OpenCode2Client; | |
| 53 | const { client } = yield* opencode.connect({ baseUrl: url, password: "secret" }); | |
| 54 | return yield* OpenCode2Server.verifyServer(client); | |
| 55 | }).pipe(Effect.provide(OpenCode2Client.layer.pipe(Layer.provide(httpClient)))); | |
| 56 | ||
| 57 | describe("OpenCode2Server.verifyServer", () => { | |
| 58 | it.effect("returns the version of an authenticated OpenCode 2 server", () => | |
| 59 | Effect.gen(function* () { | |
| 60 | const version = yield* verify( | |
| 61 | layerServerReplying({ status: 200, contentType: "application/json", body: INFO_BODY }), | |
| 62 | ); | |
| 63 | assert.strictEqual(version, "2.0.18"); | |
| 64 | }), | |
| 65 | ); | |
| 66 | ||
| 67 | it.effect("reports a rejected password, not a wrong server", () => | |
| 68 | Effect.gen(function* () { | |
| 69 | const error = yield* verify( | |
| 70 | layerServerReplying({ | |
| 71 | status: 401, | |
| 72 | contentType: "application/json", | |
| 73 | body: UNAUTHORIZED_BODY, | |
| 74 | }), | |
| 75 | ).pipe(Effect.flip); | |
| 76 | assert.include(error.detail, "rejected the server password"); | |
| 77 | }), | |
| 78 | ); | |
| 79 | ||
| 80 | // 1.x rejects a wrong password with an empty 401, which the client cannot decode. | |
| 81 | it.effect("reports an empty-body 401 as a rejected password", () => | |
| 82 | Effect.gen(function* () { | |
| 83 | const error = yield* verify(layerServerReplying({ status: 401, body: "" })).pipe(Effect.flip); | |
| 84 | assert.include(error.detail, "rejected the server password"); | |
| 85 | }), | |
| 86 | ); | |
| 87 | ||
| 88 | it.effect("reports a server error as a server error, not as unreachable", () => | |
| 89 | Effect.gen(function* () { | |
| 90 | for (const status of [500, 502]) { | |
| 91 | const error = yield* verify( | |
| 92 | layerServerReplying({ status, contentType: "text/plain", body: "upstream failed" }), | |
| 93 | ).pipe(Effect.flip); | |
| 94 | assert.include(error.detail, `returned HTTP ${status}`); | |
| 95 | } | |
| 96 | }), | |
| 97 | ); | |
| 98 | ||
| 99 | // 1.x (and the 2.x web UI on unknown paths) answers /api/info with 200 HTML. | |
| 100 | it.effect("rejects a server that answers with the web UI's HTML", () => | |
| 101 | Effect.gen(function* () { | |
| 102 | const error = yield* verify( | |
| 103 | layerServerReplying({ status: 200, contentType: "text/html", body: SPA_BODY }), | |
| 104 | ).pipe(Effect.flip); | |
| 105 | assert.include(error.detail, "is not an OpenCode 2 server"); | |
| 106 | }), | |
| 107 | ); | |
| 108 | ||
| 109 | it.effect("reports an unreachable server as unreachable", () => | |
| 110 | Effect.gen(function* () { | |
| 111 | const error = yield* verify(FetchHttpClient.layer, "http://127.0.0.1:1").pipe(Effect.flip); | |
| 112 | assert.include(error.detail, "Could not reach the OpenCode server"); | |
| 113 | }), | |
| 114 | ); | |
| 115 | }); | |
| 116 | ||
| 117 | describe("OpenCode2Server error details", () => { | |
| 118 | // `detail` reaches clients through the provider status message, and a | |
| 119 | // `serverUrl` can carry credentials in its userinfo or query. | |
| 120 | const serverUrl = "http://user:url-secret@127.0.0.1:1/?token=query-secret"; | |
| 121 | const detailFor = (httpClient: Layer.Layer<HttpClient.HttpClient>) => | |
| 122 | Effect.gen(function* () { | |
| 123 | const server = yield* OpenCode2Server.make({ | |
| 124 | binaryPath: "opencode", | |
| 125 | serverUrl, | |
| 126 | serverPassword: "secret", | |
| 127 | directory: "/project", | |
| 128 | environment: {}, | |
| 129 | }); | |
| 130 | const error = yield* server.withConnection(() => Effect.void).pipe(Effect.flip); | |
| 131 | return error.detail; | |
| 132 | }).pipe( | |
| 133 | Effect.provide( | |
| 134 | Layer.mergeAll( | |
| 135 | OpenCode2Client.layer.pipe(Layer.provide(httpClient)), | |
| 136 | OpenCodeRuntime.layer.pipe(Layer.provide(OpenCodeServerLedger.layerTest)), | |
| 137 | ).pipe(Layer.provideMerge(NodeServices.layer)), | |
| 138 | ), | |
| 139 | ); | |
| 140 | ||
| 141 | it.effect("never include the server URL", () => | |
| 142 | Effect.gen(function* () { | |
| 143 | const layerHanging = Layer.succeed( | |
| 144 | HttpClient.HttpClient, | |
| 145 | HttpClient.make(() => Effect.never), | |
| 146 | ); | |
| 147 | const timedOut = yield* detailFor(layerHanging).pipe(Effect.forkChild); | |
| 148 | yield* TestClock.adjust("10 seconds"); | |
| 149 | const details = [ | |
| 150 | yield* detailFor(FetchHttpClient.layer), | |
| 151 | yield* detailFor(layerServerReplying({ status: 401, body: "" })), | |
| 152 | yield* detailFor(layerServerReplying({ status: 502, contentType: "text/plain", body: "" })), | |
| 153 | yield* detailFor( | |
| 154 | layerServerReplying({ status: 200, contentType: "text/html", body: SPA_BODY }), | |
| 155 | ), | |
| 156 | yield* Fiber.join(timedOut), | |
| 157 | ]; | |
| 158 | assert.deepStrictEqual(details, [ | |
| 159 | "Could not reach the OpenCode server.", | |
| 160 | "The OpenCode server rejected the server password.", | |
| 161 | "The OpenCode server returned HTTP 502.", | |
| 162 | "The server is not an OpenCode 2 server.", | |
| 163 | "Timed out waiting for the OpenCode server.", | |
| 164 | ]); | |
| 165 | for (const detail of details) { | |
| 166 | assert.notInclude(detail, "url-secret"); | |
| 167 | assert.notInclude(detail, "query-secret"); | |
| 168 | assert.notInclude(detail, "127.0.0.1"); | |
| 169 | } | |
| 170 | }).pipe(Effect.provide(TestClock.layer())), | |
| 171 | ); | |
| 172 | }); | |
| 173 | ||
| 174 | describe("OpenCode2Server passwords", () => { | |
| 175 | it.effect("generates a distinct 256-bit password per server", () => | |
| 176 | Effect.gen(function* () { | |
| 177 | const first = yield* OpenCode2Server.generatePassword; | |
| 178 | const second = yield* OpenCode2Server.generatePassword; | |
| 179 | assert.match(Redacted.value(first), /^[A-Za-z0-9_-]{43}$/); | |
| 180 | assert.notStrictEqual(Redacted.value(first), Redacted.value(second)); | |
| 181 | assert.notInclude(String(first), Redacted.value(first)); | |
| 182 | }).pipe(Effect.provide(NodeServices.layer)), | |
| 183 | ); | |
| 184 | ||
| 185 | it("hands the spawned server only the T3 password", () => { | |
| 186 | const password = Redacted.make("t3-generated"); | |
| 187 | const environment = OpenCode2Server.serverEnvironment( | |
| 188 | { PATH: "/bin", OPENCODE_SERVER_PASSWORD: "ambient", OPENCODE_PASSWORD: "ambient" }, | |
| 189 | password, | |
| 190 | ); | |
| 191 | assert.deepStrictEqual(environment, { PATH: "/bin", OPENCODE_PASSWORD: "t3-generated" }); | |
| 192 | }); | |
| 193 | }); | |
| 194 | ||
| 195 | // Serves /api/info only with the password from OPENCODE_PASSWORD, like 2.x, and | |
| 196 | // prints the 2.x banner (plus the generated-password line 2.x prints when no | |
| 197 | // password is set, which T3 must never need). | |
| 198 | const FAKE_SERVER = `import { createServer } from "node:http"; | |
| 199 | const expected = "Basic " + Buffer.from("opencode:" + process.env.OPENCODE_PASSWORD).toString("base64"); | |
| 200 | const server = createServer((request, response) => { | |
| 201 | if (request.headers.authorization !== expected) { | |
| 202 | response.writeHead(401, { "content-type": "application/json", "www-authenticate": "Basic" }); | |
| 203 | response.end(${JSON.stringify(UNAUTHORIZED_BODY)}); | |
| 204 | return; | |
| 205 | } | |
| 206 | response.writeHead(200, { "content-type": "application/json" }); | |
| 207 | response.end(JSON.stringify({ version: "2.0.18", pid: process.pid, urls: [], paths: { tmp: "/tmp" } })); | |
| 208 | }); | |
| 209 | server.listen(0, "127.0.0.1", () => { | |
| 210 | process.stdout.write("server listening on http://127.0.0.1:" + server.address().port + "\\n"); | |
| 211 | process.stdout.write("server password not-the-t3-password\\n"); | |
| 212 | }); | |
| 213 | `; | |
| 214 | ||
| 215 | describe("OpenCode2Server spawned server", () => { | |
| 216 | it.live( | |
| 217 | "is ready once /api/info accepts the generated password", | |
| 218 | () => | |
| 219 | Effect.gen(function* () { | |
| 220 | const fs = yield* FileSystem.FileSystem; | |
| 221 | const path = yield* Path.Path; | |
| 222 | const platform = yield* HostProcessPlatform; | |
| 223 | const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode2-fake-" }); | |
| 224 | const isWindows = platform === "win32"; | |
| 225 | const binaryPath = path.join(directory, isWindows ? "opencode.cmd" : "opencode"); | |
| 226 | const scriptPath = path.join(directory, "opencode.mjs"); | |
| 227 | yield* fs.writeFileString(scriptPath, FAKE_SERVER); | |
| 228 | yield* fs.writeFileString( | |
| 229 | binaryPath, | |
| 230 | isWindows | |
| 231 | ? `@echo off\r\n"${yield* HostProcessExecutablePath}" "${scriptPath}" %*\r\n` | |
| 232 | : `#!/bin/sh\nexec "${yield* HostProcessExecutablePath}" "${scriptPath}" "$@"\n`, | |
| 233 | ); | |
| 234 | if (!isWindows) yield* fs.chmod(binaryPath, 0o755); | |
| 235 | ||
| 236 | const server = yield* OpenCode2Server.make({ | |
| 237 | binaryPath, | |
| 238 | serverUrl: "", | |
| 239 | serverPassword: "", | |
| 240 | directory, | |
| 241 | environment: { ...(yield* HostProcessEnvironment), OPENCODE_SERVER_PASSWORD: "ambient" }, | |
| 242 | }); | |
| 243 | const first = yield* server.withConnection((connection) => Effect.succeed(connection)); | |
| 244 | const second = yield* server.withConnection((connection) => Effect.succeed(connection)); | |
| 245 | assert.strictEqual(first.version, "2.0.18"); | |
| 246 | assert.strictEqual(first.external, false); | |
| 247 | assert.strictEqual(second.client, first.client); | |
| 248 | }).pipe( | |
| 249 | Effect.scoped, | |
| 250 | Effect.provide( | |
| 251 | Layer.mergeAll( | |
| 252 | OpenCode2Client.layer, | |
| 253 | OpenCodeRuntime.layer.pipe(Layer.provide(OpenCodeServerLedger.layerTest)), | |
| 254 | ).pipe(Layer.provideMerge(Layer.mergeAll(NodeServices.layer, FetchHttpClient.layer))), | |
| 255 | ), | |
| 256 | ), | |
| 257 | 15_000, | |
| 258 | ); | |
| 259 | }); |
apps/server/src/provider/opencode2/OpenCode2Server.ts deleted-198| ... | ... | @@ -1,198 +0,0 @@ |
| 1 | /** | |
| 2 | * The OpenCode 2 server behind one provider instance. A 2.x server serves every | |
| 3 | * location from one process, so an instance shares one server across all of its | |
| 4 | * threads and directories. | |
| 5 | * | |
| 6 | * @module provider/opencode2/OpenCode2Server | |
| 7 | */ | |
| 8 | import type { OpenCodeClient } from "@opencode/client/effect"; | |
| 9 | import * as Context from "effect/Context"; | |
| 10 | import * as Crypto from "effect/Crypto"; | |
| 11 | import * as Effect from "effect/Effect"; | |
| 12 | import * as Base64Url from "effect/encoding/Base64Url"; | |
| 13 | import * as Layer from "effect/Layer"; | |
| 14 | import * as P from "effect/Predicate"; | |
| 15 | import * as Redacted from "effect/Redacted"; | |
| 16 | import * as HttpClientError from "effect/http/HttpClientError"; | |
| 17 | ||
| 18 | import { OpenCodeRuntimeError } from "../opencodeRuntime.ts"; | |
| 19 | import * as OpenCodeServerOwner from "../OpenCodeServerOwner.ts"; | |
| 20 | import * as OpenCode2Client from "./OpenCode2Client.ts"; | |
| 21 | ||
| 22 | const INFO_TIMEOUT = "5 seconds"; | |
| 23 | ||
| 24 | export interface OpenCode2Connection extends OpenCode2Client.OpenCode2Api { | |
| 25 | readonly url: string; | |
| 26 | readonly version: string; | |
| 27 | readonly external: boolean; | |
| 28 | } | |
| 29 | ||
| 30 | export class OpenCode2Server extends Context.Service< | |
| 31 | OpenCode2Server, | |
| 32 | { | |
| 33 | /** Runs `use` against the instance's server, spawning it first when T3 owns it. */ | |
| 34 | readonly withConnection: <A, E, R>( | |
| 35 | use: (connection: OpenCode2Connection) => Effect.Effect<A, E, R>, | |
| 36 | ) => Effect.Effect<A, E | OpenCodeRuntimeError, R>; | |
| 37 | } | |
| 38 | >()("t3/provider/opencode2/OpenCode2Server") {} | |
| 39 | ||
| 40 | /** | |
| 41 | * A fresh password for a spawned server. OpenCode 2 always requires one and | |
| 42 | * prints a generated one to stdout otherwise, so T3 supplies its own and keeps | |
| 43 | * it in memory. | |
| 44 | */ | |
| 45 | export const generatePassword = Effect.gen(function* () { | |
| 46 | const crypto = yield* Crypto.Crypto; | |
| 47 | const bytes = yield* crypto.randomBytes(32).pipe(Effect.orDie); | |
| 48 | return Redacted.make(Base64Url.encode(bytes), { label: "OPENCODE_PASSWORD" }); | |
| 49 | }); | |
| 50 | ||
| 51 | /** | |
| 52 | * The environment for a spawned 2.x server. `OPENCODE_PASSWORD` wins over | |
| 53 | * `OPENCODE_SERVER_PASSWORD` in OpenCode 2, so the inherited 1.x variable is | |
| 54 | * dropped to keep the T3 password the only one in play. | |
| 55 | */ | |
| 56 | export const serverEnvironment = ( | |
| 57 | environment: NodeJS.ProcessEnv, | |
| 58 | password: Redacted.Redacted, | |
| 59 | ): NodeJS.ProcessEnv => { | |
| 60 | const { OPENCODE_SERVER_PASSWORD: _inherited, ...rest } = environment; | |
| 61 | return { ...rest, OPENCODE_PASSWORD: Redacted.value(password) }; | |
| 62 | }; | |
| 63 | ||
| 64 | /** The client wraps HTTP failures in a `ClientError`; this unwraps either shape. */ | |
| 65 | const httpFailureOf = (cause: unknown): HttpClientError.HttpClientError | undefined => { | |
| 66 | if (HttpClientError.isHttpClientError(cause)) return cause; | |
| 67 | if ( | |
| 68 | P.isTagged(cause, "ClientError") && | |
| 69 | P.hasProperty(cause, "cause") && | |
| 70 | HttpClientError.isHttpClientError(cause.cause) | |
| 71 | ) { | |
| 72 | return cause.cause; | |
| 73 | } | |
| 74 | return undefined; | |
| 75 | }; | |
| 76 | ||
| 77 | /** | |
| 78 | * Describes a failed `/api/info` call. Only a transport failure means the | |
| 79 | * server is unreachable; a 401 without the 2.x error body is still a rejected | |
| 80 | * password (1.x sends it empty), and any other failed status is a server error. | |
| 81 | * A 2xx the client cannot decode is not OpenCode 2 (1.x answers with HTML). | |
| 82 | */ | |
| 83 | const describeInfoFailure = (cause: unknown) => { | |
| 84 | const failure = httpFailureOf(cause); | |
| 85 | if (failure?.reason._tag === "TransportError") { | |
| 86 | return "Could not reach the OpenCode server."; | |
| 87 | } | |
| 88 | const status = failure?.response?.status; | |
| 89 | if (status === 401) return "The OpenCode server rejected the server password."; | |
| 90 | if (status !== undefined && (status < 200 || status >= 300)) { | |
| 91 | return `The OpenCode server returned HTTP ${status}.`; | |
| 92 | } | |
| 93 | return "The server is not an OpenCode 2 server."; | |
| 94 | }; | |
| 95 | ||
| 96 | /** | |
| 97 | * Confirms a server is an authenticated OpenCode 2 server through `/api/info` | |
| 98 | * and returns its version. `/health` and friends answer 200 with the web UI's | |
| 99 | * HTML on every version, so only this endpoint proves readiness. Details are | |
| 100 | * fixed text because they reach clients and a `serverUrl` can carry | |
| 101 | * credentials; the underlying failure stays in `cause`. | |
| 102 | */ | |
| 103 | export const verifyServer = (client: OpenCodeClient) => | |
| 104 | client.server.info().pipe( | |
| 105 | Effect.timeoutOrElse({ | |
| 106 | duration: INFO_TIMEOUT, | |
| 107 | orElse: () => | |
| 108 | Effect.fail( | |
| 109 | new OpenCodeRuntimeError({ | |
| 110 | operation: "server.info", | |
| 111 | detail: "Timed out waiting for the OpenCode server.", | |
| 112 | }), | |
| 113 | ), | |
| 114 | }), | |
| 115 | Effect.catchTags({ | |
| 116 | UnauthorizedError: (cause) => | |
| 117 | Effect.fail( | |
| 118 | new OpenCodeRuntimeError({ | |
| 119 | operation: "server.info", | |
| 120 | detail: "The OpenCode server rejected the server password.", | |
| 121 | cause, | |
| 122 | }), | |
| 123 | ), | |
| 124 | }), | |
| 125 | Effect.mapError((cause) => | |
| 126 | OpenCodeRuntimeError.is(cause) | |
| 127 | ? cause | |
| 128 | : new OpenCodeRuntimeError({ | |
| 129 | operation: "server.info", | |
| 130 | detail: describeInfoFailure(cause), | |
| 131 | cause, | |
| 132 | }), | |
| 133 | ), | |
| 134 | Effect.map((info) => info.version), | |
| 135 | ); | |
| 136 | ||
| 137 | /** | |
| 138 | * One server per provider instance. With a `serverUrl` it connects to that | |
| 139 | * server with the configured password; otherwise it spawns `binaryPath serve` | |
| 140 | * with a generated password through {@link OpenCodeServerOwner}, which shares | |
| 141 | * the process between borrowers and stops it after an idle period. Clients are | |
| 142 | * built once per server; a failed check is not remembered. | |
| 143 | */ | |
| 144 | export const make = Effect.fn("OpenCode2Server.make")(function* (input: { | |
| 145 | readonly binaryPath: string; | |
| 146 | readonly serverUrl: string; | |
| 147 | readonly serverPassword: string; | |
| 148 | readonly directory: string; | |
| 149 | readonly environment: NodeJS.ProcessEnv; | |
| 150 | }) { | |
| 151 | const opencode = yield* OpenCode2Client.OpenCode2Client; | |
| 152 | const connectTo = (url: string, password: Redacted.Redacted, external: boolean) => | |
| 153 | Effect.gen(function* () { | |
| 154 | const api = yield* opencode.connect({ baseUrl: url, password }); | |
| 155 | const version = yield* verifyServer(api.client); | |
| 156 | return { ...api, url, version, external } satisfies OpenCode2Connection; | |
| 157 | }); | |
| 158 | let latest: OpenCode2Connection | undefined; | |
| 159 | const remember = (connection: OpenCode2Connection) => | |
| 160 | Effect.sync(() => { | |
| 161 | latest = connection; | |
| 162 | return connection.version; | |
| 163 | }); | |
| 164 | ||
| 165 | const serverUrl = input.serverUrl.trim(); | |
| 166 | if (serverUrl.length > 0) { | |
| 167 | const connect = connectTo(serverUrl, Redacted.make(input.serverPassword), true).pipe( | |
| 168 | Effect.tap(remember), | |
| 169 | ); | |
| 170 | return OpenCode2Server.of({ | |
| 171 | withConnection: (use) => | |
| 172 | Effect.suspend(() => (latest === undefined ? connect : Effect.succeed(latest))).pipe( | |
| 173 | Effect.flatMap(use), | |
| 174 | ), | |
| 175 | }); | |
| 176 | } | |
| 177 | ||
| 178 | const password = yield* generatePassword; | |
| 179 | const owner = yield* OpenCodeServerOwner.make({ | |
| 180 | binaryPath: input.binaryPath, | |
| 181 | directory: input.directory, | |
| 182 | environment: serverEnvironment(input.environment, password), | |
| 183 | verify: (url) => connectTo(url, password, false).pipe(Effect.flatMap(remember)), | |
| 184 | }); | |
| 185 | return OpenCode2Server.of({ | |
| 186 | withConnection: (use) => | |
| 187 | owner.withServer((server) => | |
| 188 | // The owner verifies every server it starts before lending it out. | |
| 189 | latest?.url === server.url | |
| 190 | ? use(latest) | |
| 191 | : Effect.die(new Error("OpenCode 2 server was lent before verification.")), | |
| 192 | ), | |
| 193 | }); | |
| 194 | }); | |
| 195 | ||
| 196 | /** Built once per provider instance from its settings; closing it stops a spawned server. */ | |
| 197 | export const layer = (input: Parameters<typeof make>[0]) => | |
| 198 | Layer.effect(OpenCode2Server, make(input)); |
apps/server/src/provider/opencodeRuntime.cliParsers.test.ts deleted-335| ... | ... | @@ -1,335 +0,0 @@ |
| 1 | import * as NodeAssert from "node:assert/strict"; | |
| 2 | ||
| 3 | import { describe, it } from "vite-plus/test"; | |
| 4 | ||
| 5 | import { | |
| 6 | parseAgentListCliOutput, | |
| 7 | parseModelsCliOutput, | |
| 8 | parseSkillsCliOutput, | |
| 9 | toOpenCodeFileParts, | |
| 10 | } from "./opencodeRuntime.ts"; | |
| 11 | ||
| 12 | describe("parseModelsCliOutput", () => { | |
| 13 | it("parses a single model from a single provider", () => { | |
| 14 | const stdout = [ | |
| 15 | "anthropic/claude-sonnet-4-5", | |
| 16 | JSON.stringify({ | |
| 17 | id: "claude-sonnet-4-5", | |
| 18 | providerID: "anthropic", | |
| 19 | name: "Claude Sonnet 4.5", | |
| 20 | capabilities: { temperature: true, reasoning: true, toolcall: true }, | |
| 21 | cost: { input: 3, output: 15 }, | |
| 22 | limit: { context: 200000, output: 8192 }, | |
| 23 | status: "active", | |
| 24 | options: {}, | |
| 25 | headers: {}, | |
| 26 | release_date: "2025-01-01", | |
| 27 | }), | |
| 28 | ].join("\n"); | |
| 29 | ||
| 30 | const result = parseModelsCliOutput(stdout); | |
| 31 | NodeAssert.equal(result.providers.size, 1); | |
| 32 | NodeAssert.equal(result.connected.length, 1); | |
| 33 | NodeAssert.equal(result.connected[0], "anthropic"); | |
| 34 | ||
| 35 | const provider = result.providers.get("anthropic")!; | |
| 36 | NodeAssert.ok(provider); | |
| 37 | NodeAssert.equal(provider.id, "anthropic"); | |
| 38 | NodeAssert.equal(provider.name, "anthropic"); | |
| 39 | NodeAssert.equal(Object.keys(provider.models).length, 1); | |
| 40 | ||
| 41 | const model = provider.models["claude-sonnet-4-5"]!; | |
| 42 | NodeAssert.ok(model); | |
| 43 | NodeAssert.equal(model.id, "claude-sonnet-4-5"); | |
| 44 | NodeAssert.equal(model.providerID, "anthropic"); | |
| 45 | NodeAssert.equal(model.name, "Claude Sonnet 4.5"); | |
| 46 | }); | |
| 47 | ||
| 48 | it("parses multiple models from multiple providers", () => { | |
| 49 | const stdout = [ | |
| 50 | "anthropic/claude-sonnet-4-5", | |
| 51 | JSON.stringify({ id: "claude-sonnet-4-5", providerID: "anthropic", name: "Sonnet 4.5" }), | |
| 52 | "anthropic/claude-haiku-4-5", | |
| 53 | JSON.stringify({ id: "claude-haiku-4-5", providerID: "anthropic", name: "Haiku 4.5" }), | |
| 54 | "openai/gpt-4o", | |
| 55 | JSON.stringify({ id: "gpt-4o", providerID: "openai", name: "GPT-4o" }), | |
| 56 | ].join("\n"); | |
| 57 | ||
| 58 | const result = parseModelsCliOutput(stdout); | |
| 59 | NodeAssert.equal(result.providers.size, 2); | |
| 60 | NodeAssert.equal(result.connected.length, 2); | |
| 61 | NodeAssert.equal([...result.connected].sort().join(","), "anthropic,openai"); | |
| 62 | NodeAssert.equal(Object.keys(result.providers.get("anthropic")!.models).length, 2); | |
| 63 | NodeAssert.equal(Object.keys(result.providers.get("openai")!.models).length, 1); | |
| 64 | }); | |
| 65 | ||
| 66 | it("handles empty input", () => { | |
| 67 | const result = parseModelsCliOutput(""); | |
| 68 | NodeAssert.equal(result.providers.size, 0); | |
| 69 | NodeAssert.equal(result.connected.length, 0); | |
| 70 | }); | |
| 71 | ||
| 72 | it("skips unparseable JSON blocks", () => { | |
| 73 | const stdout = [ | |
| 74 | "anthropic/claude-sonnet-4-5", | |
| 75 | "this is not valid json {{{", | |
| 76 | "anthropic/claude-haiku-4-5", | |
| 77 | JSON.stringify({ id: "claude-haiku-4-5", providerID: "anthropic", name: "Haiku 4.5" }), | |
| 78 | ].join("\n"); | |
| 79 | ||
| 80 | const result = parseModelsCliOutput(stdout); | |
| 81 | NodeAssert.equal(result.providers.size, 1); | |
| 82 | const provider = result.providers.get("anthropic")!; | |
| 83 | NodeAssert.equal(Object.keys(provider.models).length, 1); | |
| 84 | NodeAssert.ok(provider.models["claude-haiku-4-5"]); | |
| 85 | }); | |
| 86 | ||
| 87 | it("handles Windows-style CRLF line endings", () => { | |
| 88 | const stdout = | |
| 89 | "anthropic/claude-sonnet-4-5\r\n" + | |
| 90 | JSON.stringify({ id: "claude-sonnet-4-5", providerID: "anthropic", name: "Sonnet" }) + | |
| 91 | "\r\n"; | |
| 92 | ||
| 93 | const result = parseModelsCliOutput(stdout); | |
| 94 | NodeAssert.equal(result.providers.size, 1); | |
| 95 | NodeAssert.ok(result.providers.get("anthropic")!.models["claude-sonnet-4-5"]); | |
| 96 | }); | |
| 97 | ||
| 98 | it("handles model JSON with variants and nested fields", () => { | |
| 99 | const stdout = [ | |
| 100 | "opencode/gpt-5.4", | |
| 101 | JSON.stringify({ | |
| 102 | id: "gpt-5.4", | |
| 103 | providerID: "opencode", | |
| 104 | name: "GPT-5.4", | |
| 105 | family: "gpt", | |
| 106 | capabilities: { | |
| 107 | temperature: true, | |
| 108 | reasoning: true, | |
| 109 | attachment: false, | |
| 110 | toolcall: true, | |
| 111 | input: { text: true, audio: false, image: false, video: false, pdf: false }, | |
| 112 | output: { text: true, audio: false, image: false, video: false, pdf: false }, | |
| 113 | interleaved: false, | |
| 114 | }, | |
| 115 | cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, | |
| 116 | limit: { context: 200000, input: 160000, output: 32000 }, | |
| 117 | status: "active", | |
| 118 | options: {}, | |
| 119 | headers: {}, | |
| 120 | release_date: "2025-01-01", | |
| 121 | variants: { none: {}, low: {}, medium: {}, high: {} }, | |
| 122 | }), | |
| 123 | ].join("\n"); | |
| 124 | ||
| 125 | const result = parseModelsCliOutput(stdout); | |
| 126 | const model = result.providers.get("opencode")!.models["gpt-5.4"]!; | |
| 127 | NodeAssert.ok(model); | |
| 128 | NodeAssert.ok(model.capabilities); | |
| 129 | NodeAssert.equal(model.capabilities!.reasoning, true); | |
| 130 | NodeAssert.ok(model.variants); | |
| 131 | NodeAssert.equal(model.variants!["medium"] !== undefined, true); | |
| 132 | }); | |
| 133 | ||
| 134 | it("keeps a model whose JSON body has a slash and no interior whitespace", () => { | |
| 135 | // OpenRouter-style: the model id contains a `/` and no string value has a | |
| 136 | // space, so the JSON body line itself matches the slug regex. It must still | |
| 137 | // be treated as the body of the preceding slug, not a new slug. | |
| 138 | const stdout = [ | |
| 139 | "openrouter/qwen/qwen3-coder", | |
| 140 | JSON.stringify({ | |
| 141 | id: "qwen/qwen3-coder", | |
| 142 | providerID: "openrouter", | |
| 143 | name: "qwen3-coder", | |
| 144 | status: "active", | |
| 145 | }), | |
| 146 | ].join("\n"); | |
| 147 | ||
| 148 | const result = parseModelsCliOutput(stdout); | |
| 149 | NodeAssert.equal(result.providers.size, 1); | |
| 150 | NodeAssert.deepEqual([...result.connected], ["openrouter"]); | |
| 151 | const provider = result.providers.get("openrouter")!; | |
| 152 | NodeAssert.ok(provider); | |
| 153 | const model = provider.models["qwen/qwen3-coder"]!; | |
| 154 | NodeAssert.ok(model); | |
| 155 | NodeAssert.equal(model.id, "qwen/qwen3-coder"); | |
| 156 | NodeAssert.equal(model.providerID, "openrouter"); | |
| 157 | }); | |
| 158 | }); | |
| 159 | ||
| 160 | describe("parseAgentListCliOutput", () => { | |
| 161 | it("parses a single agent", () => { | |
| 162 | const stdout = [ | |
| 163 | "build (primary)", | |
| 164 | " " + JSON.stringify([{ permission: "*", action: "allow", pattern: "*" }]), | |
| 165 | ].join("\n"); | |
| 166 | ||
| 167 | const result = parseAgentListCliOutput(stdout); | |
| 168 | NodeAssert.equal(result.length, 1); | |
| 169 | NodeAssert.equal(result[0]!.name, "build"); | |
| 170 | NodeAssert.equal(result[0]!.mode, "primary"); | |
| 171 | NodeAssert.equal(result[0]!.permission.length, 1); | |
| 172 | }); | |
| 173 | ||
| 174 | it("parses multiple agents", () => { | |
| 175 | const stdout = [ | |
| 176 | "build (primary)", | |
| 177 | " " + JSON.stringify([{ permission: "*", action: "allow", pattern: "*" }]), | |
| 178 | "explore (subagent)", | |
| 179 | " " + JSON.stringify([{ permission: "read", action: "allow", pattern: "*" }]), | |
| 180 | "plan (primary)", | |
| 181 | " " + JSON.stringify([{ permission: "edit", action: "ask", pattern: "*.md" }]), | |
| 182 | ].join("\n"); | |
| 183 | ||
| 184 | const result = parseAgentListCliOutput(stdout); | |
| 185 | NodeAssert.equal(result.length, 3); | |
| 186 | NodeAssert.equal(result[0]!.name, "build"); | |
| 187 | NodeAssert.equal(result[0]!.mode, "primary"); | |
| 188 | NodeAssert.equal(result[1]!.name, "explore"); | |
| 189 | NodeAssert.equal(result[1]!.mode, "subagent"); | |
| 190 | NodeAssert.equal(result[2]!.name, "plan"); | |
| 191 | NodeAssert.equal(result[2]!.mode, "primary"); | |
| 192 | }); | |
| 193 | ||
| 194 | it("handles empty input", () => { | |
| 195 | const result = parseAgentListCliOutput(""); | |
| 196 | NodeAssert.equal(result.length, 0); | |
| 197 | }); | |
| 198 | ||
| 199 | it("skips agents with unparseable permission JSON", () => { | |
| 200 | const stdout = [ | |
| 201 | "build (primary)", | |
| 202 | " not valid json {", | |
| 203 | "explore (subagent)", | |
| 204 | " " + JSON.stringify([{ permission: "read", action: "allow", pattern: "*" }]), | |
| 205 | ].join("\n"); | |
| 206 | ||
| 207 | const result = parseAgentListCliOutput(stdout); | |
| 208 | NodeAssert.equal(result.length, 1); | |
| 209 | NodeAssert.equal(result[0]!.name, "explore"); | |
| 210 | }); | |
| 211 | ||
| 212 | it("handles real-world permission blocks with nested paths", () => { | |
| 213 | const permissions = [ | |
| 214 | { permission: "*", action: "allow", pattern: "*" }, | |
| 215 | { | |
| 216 | permission: "external_directory", | |
| 217 | pattern: "C:\\Users\\test\\.local\\*", | |
| 218 | action: "allow", | |
| 219 | }, | |
| 220 | { permission: "read", pattern: "*.env", action: "ask" }, | |
| 221 | ]; | |
| 222 | const stdout = ["build (primary)", " " + JSON.stringify(permissions)].join("\n"); | |
| 223 | ||
| 224 | const result = parseAgentListCliOutput(stdout); | |
| 225 | NodeAssert.equal(result.length, 1); | |
| 226 | NodeAssert.equal(result[0]!.permission.length, 3); | |
| 227 | NodeAssert.equal(result[0]!.permission[0]!.action, "allow"); | |
| 228 | NodeAssert.equal(result[0]!.permission[2]!.action, "ask"); | |
| 229 | }); | |
| 230 | ||
| 231 | it("handles agent names with spaces", () => { | |
| 232 | const stdout = [ | |
| 233 | "code reviewer (subagent)", | |
| 234 | " " + JSON.stringify([{ permission: "read", action: "allow", pattern: "*" }]), | |
| 235 | "my custom agent (primary)", | |
| 236 | " " + JSON.stringify([{ permission: "edit", action: "ask", pattern: "*.ts" }]), | |
| 237 | ].join("\n"); | |
| 238 | ||
| 239 | const result = parseAgentListCliOutput(stdout); | |
| 240 | NodeAssert.equal(result.length, 2); | |
| 241 | NodeAssert.equal(result[0]!.name, "code reviewer"); | |
| 242 | NodeAssert.equal(result[0]!.mode, "subagent"); | |
| 243 | NodeAssert.equal(result[1]!.name, "my custom agent"); | |
| 244 | NodeAssert.equal(result[1]!.mode, "primary"); | |
| 245 | }); | |
| 246 | ||
| 247 | it("marks known hidden agents", () => { | |
| 248 | const stdout = [ | |
| 249 | "compaction (primary)", | |
| 250 | " " + JSON.stringify([{ permission: "*", action: "allow", pattern: "*" }]), | |
| 251 | "build (primary)", | |
| 252 | " " + JSON.stringify([{ permission: "*", action: "allow", pattern: "*" }]), | |
| 253 | ].join("\n"); | |
| 254 | ||
| 255 | const result = parseAgentListCliOutput(stdout); | |
| 256 | NodeAssert.equal(result[0]!.hidden, true); | |
| 257 | NodeAssert.equal(result[1]!.hidden, false); | |
| 258 | }); | |
| 259 | }); | |
| 260 | ||
| 261 | describe("parseSkillsCliOutput", () => { | |
| 262 | it("parses only skill metadata from the CLI JSON output", () => { | |
| 263 | const result = parseSkillsCliOutput( | |
| 264 | JSON.stringify([ | |
| 265 | { | |
| 266 | name: "review-pr", | |
| 267 | description: "Review a pull request.", | |
| 268 | location: "/tmp/review-pr/SKILL.md", | |
| 269 | content: "---\nname: review-pr\n---\n", | |
| 270 | }, | |
| 271 | ]), | |
| 272 | ); | |
| 273 | ||
| 274 | NodeAssert.deepEqual(result, [ | |
| 275 | { | |
| 276 | name: "review-pr", | |
| 277 | description: "Review a pull request.", | |
| 278 | location: "/tmp/review-pr/SKILL.md", | |
| 279 | }, | |
| 280 | ]); | |
| 281 | }); | |
| 282 | ||
| 283 | it("degrades malformed output to an empty skill list", () => { | |
| 284 | NodeAssert.deepEqual(parseSkillsCliOutput("not json"), []); | |
| 285 | }); | |
| 286 | }); | |
| 287 | ||
| 288 | describe("toOpenCodeFileParts", () => { | |
| 289 | const attachment = (mimeType: string, sizeBytes = 12) => ({ | |
| 290 | type: "file" as const, | |
| 291 | id: "thread-1-00000000-0000-4000-8000-000000000001-bin", | |
| 292 | name: "attachment", | |
| 293 | mimeType, | |
| 294 | sizeBytes, | |
| 295 | }); | |
| 296 | ||
| 297 | it("sends supported images, text, and PDFs natively and skips what models reject", () => { | |
| 298 | const parts = toOpenCodeFileParts({ | |
| 299 | attachments: [ | |
| 300 | attachment("application/pdf"), | |
| 301 | attachment("text/markdown"), | |
| 302 | attachment("image/png"), | |
| 303 | // A ZIP file part makes OpenCode's Anthropic path throw before the | |
| 304 | // turn starts; it must ride only as the prompt's file path line. | |
| 305 | attachment("application/zip"), | |
| 306 | attachment("application/octet-stream"), | |
| 307 | // Image formats the model APIs reject stay on the fallback path too. | |
| 308 | attachment("image/bmp"), | |
| 309 | attachment("image/svg+xml"), | |
| 310 | // Over the direct-attachment limit: path fallback even for a PDF. | |
| 311 | attachment("application/pdf", 21 * 1024 * 1024), | |
| 312 | ], | |
| 313 | resolveAttachmentPath: () => "/tmp/attachment", | |
| 314 | }); | |
| 315 | ||
| 316 | NodeAssert.deepEqual( | |
| 317 | parts.map((part) => part.mime), | |
| 318 | ["application/pdf", "text/markdown", "image/png"], | |
| 319 | ); | |
| 320 | }); | |
| 321 | ||
| 322 | it("keeps folded clipboard text on the lazy path fallback", () => { | |
| 323 | const parts = toOpenCodeFileParts({ | |
| 324 | attachments: [ | |
| 325 | { | |
| 326 | ...attachment("text/plain"), | |
| 327 | source: { _tag: "pasted-text" as const }, | |
| 328 | }, | |
| 329 | ], | |
| 330 | resolveAttachmentPath: () => "/tmp/pasted-text.txt", | |
| 331 | }); | |
| 332 | ||
| 333 | NodeAssert.deepEqual(parts, []); | |
| 334 | }); | |
| 335 | }); |
apps/server/src/provider/opencodeRuntime.environment.test.ts deleted-239| ... | ... | @@ -1,239 +0,0 @@ |
| 1 | import type { OpencodeClient } from "@opencode-ai/sdk/v2"; | |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 3 | import { it as effectIt } from "@effect/vitest"; | |
| 4 | import { | |
| 5 | HostProcessEnvironment, | |
| 6 | HostProcessExecutablePath, | |
| 7 | HostProcessPlatform, | |
| 8 | } from "@t3tools/shared/hostProcess"; | |
| 9 | import * as Effect from "effect/Effect"; | |
| 10 | import * as Fiber from "effect/Fiber"; | |
| 11 | import * as FileSystem from "effect/FileSystem"; | |
| 12 | import * as Layer from "effect/Layer"; | |
| 13 | import * as Path from "effect/Path"; | |
| 14 | import * as TestClock from "effect/testing/TestClock"; | |
| 15 | import { FetchHttpClient, HttpClient } from "effect/http"; | |
| 16 | import { describe, expect, it } from "vite-plus/test"; | |
| 17 | ||
| 18 | import * as OpenCodeRuntime from "./opencodeRuntime.ts"; | |
| 19 | import * as OpenCodeServerLedger from "./OpenCodeServerLedger.ts"; | |
| 20 | ||
| 21 | describe("resolveOpenCodeConfigContent", () => { | |
| 22 | it("prefers the caller environment over the inherited environment", () => { | |
| 23 | expect( | |
| 24 | OpenCodeRuntime.resolveOpenCodeConfigContent( | |
| 25 | { OPENCODE_CONFIG_CONTENT: '{"source":"caller"}' }, | |
| 26 | { OPENCODE_CONFIG_CONTENT: '{"source":"process"}' }, | |
| 27 | ), | |
| 28 | ).toBe('{"source":"caller"}'); | |
| 29 | }); | |
| 30 | ||
| 31 | it("falls back to the inherited environment and then an empty config", () => { | |
| 32 | expect( | |
| 33 | OpenCodeRuntime.resolveOpenCodeConfigContent(undefined, { | |
| 34 | OPENCODE_CONFIG_CONTENT: '{"source":"process"}', | |
| 35 | }), | |
| 36 | ).toBe('{"source":"process"}'); | |
| 37 | expect(OpenCodeRuntime.resolveOpenCodeConfigContent(undefined, {})).toBe("{}"); | |
| 38 | }); | |
| 39 | }); | |
| 40 | ||
| 41 | describe("resolveOpenCodeServerPassword", () => { | |
| 42 | it("uses the local environment password when settings do not provide one", () => { | |
| 43 | expect( | |
| 44 | OpenCodeRuntime.resolveOpenCodeServerPassword( | |
| 45 | { external: false, environment: { OPENCODE_SERVER_PASSWORD: " env password " } }, | |
| 46 | {}, | |
| 47 | ), | |
| 48 | ).toBe(" env password "); | |
| 49 | }); | |
| 50 | ||
| 51 | it("uses the settings password for a local server", () => { | |
| 52 | expect( | |
| 53 | OpenCodeRuntime.resolveOpenCodeServerPassword( | |
| 54 | { external: false, serverPassword: " settings password " }, | |
| 55 | {}, | |
| 56 | ), | |
| 57 | ).toBe(" settings password "); | |
| 58 | }); | |
| 59 | ||
| 60 | it("uses the settings password when local settings and environment differ", () => { | |
| 61 | expect( | |
| 62 | OpenCodeRuntime.resolveOpenCodeServerPassword( | |
| 63 | { | |
| 64 | external: false, | |
| 65 | serverPassword: "settings-password", | |
| 66 | environment: { OPENCODE_SERVER_PASSWORD: "environment-password" }, | |
| 67 | }, | |
| 68 | {}, | |
| 69 | ), | |
| 70 | ).toBe("settings-password"); | |
| 71 | }); | |
| 72 | ||
| 73 | it("does not send an inherited local password to an external server", () => { | |
| 74 | expect( | |
| 75 | OpenCodeRuntime.resolveOpenCodeServerPassword( | |
| 76 | { external: true, environment: { OPENCODE_SERVER_PASSWORD: "local-secret" } }, | |
| 77 | { OPENCODE_SERVER_PASSWORD: "inherited-secret" }, | |
| 78 | ), | |
| 79 | ).toBeUndefined(); | |
| 80 | }); | |
| 81 | }); | |
| 82 | ||
| 83 | function makeHealthClient( | |
| 84 | result: (options?: { readonly signal?: AbortSignal }) => Promise<unknown>, | |
| 85 | ): OpencodeClient { | |
| 86 | return { | |
| 87 | global: { | |
| 88 | health: result, | |
| 89 | }, | |
| 90 | } as unknown as OpencodeClient; | |
| 91 | } | |
| 92 | ||
| 93 | describe("verifyOpenCodeServerVersion", () => { | |
| 94 | effectIt.effect("accepts a supported server version", () => | |
| 95 | Effect.gen(function* () { | |
| 96 | const version = yield* OpenCodeRuntime.verifyOpenCodeServerVersion( | |
| 97 | makeHealthClient(() => Promise.resolve({ data: { healthy: true, version: "1.14.19" } })), | |
| 98 | ); | |
| 99 | expect(version).toBe("1.14.19"); | |
| 100 | }), | |
| 101 | ); | |
| 102 | ||
| 103 | effectIt.effect("rejects a server below the supported version", () => | |
| 104 | Effect.gen(function* () { | |
| 105 | const error = yield* OpenCodeRuntime.verifyOpenCodeServerVersion( | |
| 106 | makeHealthClient(() => Promise.resolve({ data: { healthy: true, version: "1.14.18" } })), | |
| 107 | ).pipe(Effect.flip); | |
| 108 | expect(error).toBeInstanceOf(OpenCodeRuntime.OpenCodeRuntimeError); | |
| 109 | expect(error.detail).toContain("v1.14.18 is too old"); | |
| 110 | }), | |
| 111 | ); | |
| 112 | ||
| 113 | for (const data of [ | |
| 114 | { healthy: true }, | |
| 115 | { healthy: true, version: "not-a-version" }, | |
| 116 | { healthy: false, version: "1.14.19" }, | |
| 117 | ]) { | |
| 118 | effectIt.effect(`rejects an invalid health response: ${JSON.stringify(data)}`, () => | |
| 119 | Effect.gen(function* () { | |
| 120 | const error = yield* OpenCodeRuntime.verifyOpenCodeServerVersion( | |
| 121 | makeHealthClient(() => Promise.resolve({ data })), | |
| 122 | ).pipe(Effect.flip); | |
| 123 | expect(error).toBeInstanceOf(OpenCodeRuntime.OpenCodeRuntimeError); | |
| 124 | expect(error.detail).toContain("requires OpenCode v1.14.19 or newer"); | |
| 125 | }), | |
| 126 | ); | |
| 127 | } | |
| 128 | ||
| 129 | effectIt.effect("preserves an unauthorized health error", () => | |
| 130 | Effect.gen(function* () { | |
| 131 | const error = yield* OpenCodeRuntime.verifyOpenCodeServerVersion( | |
| 132 | makeHealthClient(() => | |
| 133 | Promise.reject({ response: { status: 401 }, error: { message: "Unauthorized" } }), | |
| 134 | ), | |
| 135 | ).pipe(Effect.flip); | |
| 136 | expect(error).toBeInstanceOf(OpenCodeRuntime.OpenCodeRuntimeError); | |
| 137 | expect(error.detail).toContain("status=401"); | |
| 138 | expect(error.detail).toContain("Unauthorized"); | |
| 139 | }), | |
| 140 | ); | |
| 141 | ||
| 142 | effectIt.effect("aborts a health request when the version check times out", () => | |
| 143 | Effect.gen(function* () { | |
| 144 | let requestSignal: AbortSignal | undefined; | |
| 145 | const checkFiber = yield* OpenCodeRuntime.verifyOpenCodeServerVersion( | |
| 146 | makeHealthClient((options) => { | |
| 147 | requestSignal = options?.signal; | |
| 148 | return new Promise(() => undefined); | |
| 149 | }), | |
| 150 | ).pipe(Effect.flip, Effect.forkChild); | |
| 151 | ||
| 152 | yield* Effect.yieldNow; | |
| 153 | expect(requestSignal).toBeDefined(); | |
| 154 | yield* TestClock.adjust("6 seconds"); | |
| 155 | ||
| 156 | const error = yield* Fiber.join(checkFiber); | |
| 157 | expect(error.detail).toBe("Timed out while checking the OpenCode server version."); | |
| 158 | expect(requestSignal?.aborted).toBe(true); | |
| 159 | }).pipe(Effect.provide(TestClock.layer())), | |
| 160 | ); | |
| 161 | }); | |
| 162 | ||
| 163 | describe("OpenCode server output", () => { | |
| 164 | effectIt.live( | |
| 165 | "drains stdout and stderr after startup so server requests can finish", | |
| 166 | () => | |
| 167 | Effect.gen(function* () { | |
| 168 | const fs = yield* FileSystem.FileSystem; | |
| 169 | const path = yield* Path.Path; | |
| 170 | const environment = yield* HostProcessEnvironment; | |
| 171 | const executablePath = yield* HostProcessExecutablePath; | |
| 172 | const platform = yield* HostProcessPlatform; | |
| 173 | const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-output-" }); | |
| 174 | const isWindows = platform === "win32"; | |
| 175 | const binaryPath = path.join(tempDir, isWindows ? "opencode.cmd" : "opencode"); | |
| 176 | const scriptPath = path.join(tempDir, "opencode.mjs"); | |
| 177 | ||
| 178 | yield* fs.writeFileString( | |
| 179 | scriptPath, | |
| 180 | `import { createServer } from "node:http"; | |
| 181 | const writeOutput = (stream) => new Promise((resolve, reject) => { | |
| 182 | stream.write("x".repeat(2 * 1024 * 1024), (error) => error ? reject(error) : resolve()); | |
| 183 | }); | |
| 184 | const server = createServer(async (request, response) => { | |
| 185 | if (request.url.startsWith("/global/health")) { | |
| 186 | response.setHeader("Content-Type", "application/json"); | |
| 187 | response.end(JSON.stringify({ healthy: true, version: "1.14.19" })); | |
| 188 | return; | |
| 189 | } | |
| 190 | await Promise.all([writeOutput(process.stdout), writeOutput(process.stderr)]); | |
| 191 | response.end("drained"); | |
| 192 | }); | |
| 193 | server.listen(0, "127.0.0.1", () => { | |
| 194 | process.stdout.write("opencode server listening on http://127.0.0.1:" + server.address().port + "\\n"); | |
| 195 | }); | |
| 196 | `, | |
| 197 | ); | |
| 198 | yield* fs.writeFileString( | |
| 199 | binaryPath, | |
| 200 | [ | |
| 201 | ...(isWindows ? ["@echo off"] : ["#!/bin/sh"]), | |
| 202 | isWindows | |
| 203 | ? '"%T3_TEST_NODE_BINARY%" "%T3_TEST_OPENCODE_SCRIPT%" %*' | |
| 204 | : 'exec "$T3_TEST_NODE_BINARY" "$T3_TEST_OPENCODE_SCRIPT" "$@"', | |
| 205 | "", | |
| 206 | ].join("\n"), | |
| 207 | ); | |
| 208 | if (!isWindows) { | |
| 209 | yield* fs.chmod(binaryPath, 0o755); | |
| 210 | } | |
| 211 | ||
| 212 | const runtime = yield* OpenCodeRuntime.OpenCodeRuntime; | |
| 213 | const server = yield* runtime.startOpenCodeServerProcess({ | |
| 214 | binaryPath, | |
| 215 | directory: tempDir, | |
| 216 | port: 0, | |
| 217 | environment: { | |
| 218 | ...environment, | |
| 219 | T3_TEST_NODE_BINARY: executablePath, | |
| 220 | T3_TEST_OPENCODE_SCRIPT: scriptPath, | |
| 221 | }, | |
| 222 | }); | |
| 223 | const response = yield* HttpClient.get(`${server.url}/output`); | |
| 224 | ||
| 225 | expect(yield* response.text).toBe("drained"); | |
| 226 | expect(yield* server.isRunning).toBe(true); | |
| 227 | }).pipe( | |
| 228 | Effect.scoped, | |
| 229 | Effect.provide([ | |
| 230 | OpenCodeRuntime.layer.pipe( | |
| 231 | Layer.provide(OpenCodeServerLedger.layerTest), | |
| 232 | Layer.provideMerge(NodeServices.layer), | |
| 233 | ), | |
| 234 | FetchHttpClient.layer, | |
| 235 | ]), | |
| 236 | ), | |
| 237 | 10_000, | |
| 238 | ); | |
| 239 | }); |
apps/server/src/provider/opencodeRuntime.inventory.test.ts deleted-290| ... | ... | @@ -1,290 +0,0 @@ |
| 1 | import * as NodeAssert from "node:assert/strict"; | |
| 2 | ||
| 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 4 | import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2"; | |
| 5 | import { it } from "@effect/vitest"; | |
| 6 | import * as Effect from "effect/Effect"; | |
| 7 | import * as Fiber from "effect/Fiber"; | |
| 8 | import * as FileSystem from "effect/FileSystem"; | |
| 9 | import * as Layer from "effect/Layer"; | |
| 10 | import * as Path from "effect/Path"; | |
| 11 | import * as Queue from "effect/Queue"; | |
| 12 | import { | |
| 13 | HostProcessEnvironment, | |
| 14 | HostProcessExecutablePath, | |
| 15 | HostProcessPlatform, | |
| 16 | } from "@t3tools/shared/hostProcess"; | |
| 17 | ||
| 18 | import * as OpenCodeRuntime from "./opencodeRuntime.ts"; | |
| 19 | import * as OpenCodeServerLedger from "./OpenCodeServerLedger.ts"; | |
| 20 | ||
| 21 | const layerTest = OpenCodeRuntime.layer.pipe( | |
| 22 | Layer.provide(OpenCodeServerLedger.layerTest), | |
| 23 | Layer.provideMerge(NodeServices.layer), | |
| 24 | ); | |
| 25 | ||
| 26 | it.layer(layerTest)("OpenCodeRuntime inventory", (it) => { | |
| 27 | it.effect("aborts pending SDK requests when inventory loading is interrupted", () => | |
| 28 | Effect.gen(function* () { | |
| 29 | const runtime = yield* OpenCodeRuntime.OpenCodeRuntime; | |
| 30 | const started = yield* Queue.make<void>(); | |
| 31 | const aborted = yield* Queue.make<string>(); | |
| 32 | const client = createOpencodeClient({ | |
| 33 | baseUrl: "http://opencode.test", | |
| 34 | fetch: Object.assign( | |
| 35 | (input: string | Request | URL) => { | |
| 36 | const request = input instanceof Request ? input : new Request(input.toString()); | |
| 37 | return new Promise<Response>((_resolve, reject) => { | |
| 38 | request.signal.addEventListener( | |
| 39 | "abort", | |
| 40 | () => { | |
| 41 | Queue.offerUnsafe(aborted, new URL(request.url).pathname); | |
| 42 | reject(request.signal.reason); | |
| 43 | }, | |
| 44 | { once: true }, | |
| 45 | ); | |
| 46 | Queue.offerUnsafe(started, undefined); | |
| 47 | }); | |
| 48 | }, | |
| 49 | { preconnect: () => undefined }, | |
| 50 | ), | |
| 51 | }); | |
| 52 | ||
| 53 | const inventoryFiber = yield* runtime.loadOpenCodeInventory(client).pipe(Effect.forkChild); | |
| 54 | yield* Queue.takeN(started, 4); | |
| 55 | yield* Fiber.interrupt(inventoryFiber); | |
| 56 | ||
| 57 | NodeAssert.deepEqual((yield* Queue.takeAll(aborted)).toSorted(), [ | |
| 58 | "/agent", | |
| 59 | "/command", | |
| 60 | "/provider", | |
| 61 | "/skill", | |
| 62 | ]); | |
| 63 | }), | |
| 64 | ); | |
| 65 | ||
| 66 | it.effect("discovers directory-scoped commands without retaining prompt templates", () => | |
| 67 | Effect.gen(function* () { | |
| 68 | const runtime = yield* OpenCodeRuntime.OpenCodeRuntime; | |
| 69 | const requests: Request[] = []; | |
| 70 | const client = createOpencodeClient({ | |
| 71 | baseUrl: "http://opencode.test", | |
| 72 | directory: "/workspace/project", | |
| 73 | fetch: Object.assign( | |
| 74 | async (input: string | Request | URL) => { | |
| 75 | const request = input instanceof Request ? input : new Request(input.toString()); | |
| 76 | requests.push(request); | |
| 77 | const route = new URL(request.url).pathname; | |
| 78 | return Response.json( | |
| 79 | route === "/provider" | |
| 80 | ? { connected: ["openai"], all: [], default: {} } | |
| 81 | : route === "/command" | |
| 82 | ? [ | |
| 83 | { | |
| 84 | name: "review", | |
| 85 | description: "Review changes", | |
| 86 | source: "command", | |
| 87 | hints: ["$ARGUMENTS"], | |
| 88 | template: "private native template", | |
| 89 | }, | |
| 90 | ] | |
| 91 | : [], | |
| 92 | ); | |
| 93 | }, | |
| 94 | { preconnect: () => undefined }, | |
| 95 | ), | |
| 96 | }); | |
| 97 | const inventory = yield* runtime.loadOpenCodeInventory(client); | |
| 98 | NodeAssert.deepEqual(inventory.commands, [ | |
| 99 | { | |
| 100 | name: "review", | |
| 101 | description: "Review changes", | |
| 102 | source: "command", | |
| 103 | hints: ["$ARGUMENTS"], | |
| 104 | }, | |
| 105 | ]); | |
| 106 | const commandRequest = requests.find( | |
| 107 | (request) => new URL(request.url).pathname === "/command", | |
| 108 | ); | |
| 109 | NodeAssert.ok(commandRequest); | |
| 110 | NodeAssert.equal( | |
| 111 | new URL(commandRequest.url).searchParams.get("directory") ?? | |
| 112 | decodeURIComponent(commandRequest.headers.get("x-opencode-directory") ?? ""), | |
| 113 | "/workspace/project", | |
| 114 | ); | |
| 115 | }), | |
| 116 | ); | |
| 117 | ||
| 118 | it.effect("keeps provider inventory when agent discovery fails", () => | |
| 119 | Effect.gen(function* () { | |
| 120 | const runtime = yield* OpenCodeRuntime.OpenCodeRuntime; | |
| 121 | const client = { | |
| 122 | provider: { | |
| 123 | list: () => | |
| 124 | Promise.resolve({ | |
| 125 | data: { | |
| 126 | connected: ["openai"], | |
| 127 | all: [], | |
| 128 | default: {}, | |
| 129 | }, | |
| 130 | }), | |
| 131 | }, | |
| 132 | app: { | |
| 133 | agents: () => Promise.reject(new Error("agents endpoint unavailable")), | |
| 134 | skills: () => Promise.resolve({ data: [] }), | |
| 135 | }, | |
| 136 | } as unknown as OpencodeClient; | |
| 137 | ||
| 138 | const inventory = yield* runtime.loadOpenCodeInventory(client); | |
| 139 | ||
| 140 | NodeAssert.deepEqual(inventory.providerList.connected, ["openai"]); | |
| 141 | NodeAssert.deepEqual(inventory.agents, []); | |
| 142 | NodeAssert.deepEqual(inventory.skills, []); | |
| 143 | }), | |
| 144 | ); | |
| 145 | ||
| 146 | it.effect("keeps provider inventory when skill discovery fails", () => | |
| 147 | Effect.gen(function* () { | |
| 148 | const runtime = yield* OpenCodeRuntime.OpenCodeRuntime; | |
| 149 | const client = { | |
| 150 | provider: { | |
| 151 | list: () => | |
| 152 | Promise.resolve({ | |
| 153 | data: { | |
| 154 | connected: ["openai"], | |
| 155 | all: [], | |
| 156 | default: {}, | |
| 157 | }, | |
| 158 | }), | |
| 159 | }, | |
| 160 | app: { | |
| 161 | agents: () => Promise.resolve({ data: [] }), | |
| 162 | skills: () => Promise.reject(new Error("skills endpoint unavailable")), | |
| 163 | }, | |
| 164 | } as unknown as OpencodeClient; | |
| 165 | ||
| 166 | const inventory = yield* runtime.loadOpenCodeInventory(client); | |
| 167 | ||
| 168 | NodeAssert.deepEqual(inventory.providerList.connected, ["openai"]); | |
| 169 | NodeAssert.deepEqual(inventory.agents, []); | |
| 170 | NodeAssert.deepEqual(inventory.skills, []); | |
| 171 | }), | |
| 172 | ); | |
| 173 | ||
| 174 | it.effect("keeps only SDK skill metadata in inventory", () => | |
| 175 | Effect.gen(function* () { | |
| 176 | const runtime = yield* OpenCodeRuntime.OpenCodeRuntime; | |
| 177 | const client = { | |
| 178 | provider: { | |
| 179 | list: () => | |
| 180 | Promise.resolve({ | |
| 181 | data: { | |
| 182 | connected: ["openai"], | |
| 183 | all: [], | |
| 184 | default: {}, | |
| 185 | }, | |
| 186 | }), | |
| 187 | }, | |
| 188 | app: { | |
| 189 | agents: () => Promise.resolve({ data: [] }), | |
| 190 | skills: () => | |
| 191 | Promise.resolve({ | |
| 192 | data: [ | |
| 193 | { | |
| 194 | name: "review", | |
| 195 | description: "Review code changes", | |
| 196 | location: "/skills/review/SKILL.md", | |
| 197 | content: "unused skill content", | |
| 198 | }, | |
| 199 | ], | |
| 200 | }), | |
| 201 | }, | |
| 202 | } as unknown as OpencodeClient; | |
| 203 | ||
| 204 | const inventory = yield* runtime.loadOpenCodeInventory(client); | |
| 205 | ||
| 206 | NodeAssert.deepEqual(inventory.skills, [ | |
| 207 | { | |
| 208 | name: "review", | |
| 209 | description: "Review code changes", | |
| 210 | location: "/skills/review/SKILL.md", | |
| 211 | }, | |
| 212 | ]); | |
| 213 | }), | |
| 214 | ); | |
| 215 | ||
| 216 | it.effect("drops oversized CLI skill output without losing the model inventory", () => | |
| 217 | Effect.gen(function* () { | |
| 218 | const fs = yield* FileSystem.FileSystem; | |
| 219 | const path = yield* Path.Path; | |
| 220 | const hostEnvironment = yield* HostProcessEnvironment; | |
| 221 | const executablePath = yield* HostProcessExecutablePath; | |
| 222 | const hostPlatform = yield* HostProcessPlatform; | |
| 223 | const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-inventory-" }); | |
| 224 | const isWindows = hostPlatform === "win32"; | |
| 225 | const binaryPath = path.join(tempDir, isWindows ? "opencode.cmd" : "opencode"); | |
| 226 | const scriptPath = path.join(tempDir, "opencode.mjs"); | |
| 227 | const oversizedContentBytes = 8 * 1024 * 1024 + 1; | |
| 228 | ||
| 229 | yield* fs.writeFileString( | |
| 230 | scriptPath, | |
| 231 | [ | |
| 232 | 'if (process.argv[2] === "models") {', | |
| 233 | ' process.stdout.write(`openai/gpt-test\\n{"id":"gpt-test","providerID":"openai","name":"GPT Test"}\\n`);', | |
| 234 | '} else if (process.argv[2] === "debug") {', | |
| 235 | ` const content = "x".repeat(${oversizedContentBytes});`, | |
| 236 | ' process.stdout.write(`[{"name":"oversized","content":"${content}"}]`);', | |
| 237 | "}", | |
| 238 | "", | |
| 239 | ].join("\n"), | |
| 240 | ); | |
| 241 | yield* fs.writeFileString( | |
| 242 | binaryPath, | |
| 243 | [ | |
| 244 | ...(isWindows ? ["@echo off"] : ["#!/bin/sh"]), | |
| 245 | isWindows | |
| 246 | ? '"%T3_TEST_NODE_BINARY%" "%T3_TEST_OPENCODE_SCRIPT%" %*' | |
| 247 | : 'exec "$T3_TEST_NODE_BINARY" "$T3_TEST_OPENCODE_SCRIPT" "$@"', | |
| 248 | "", | |
| 249 | ].join("\n"), | |
| 250 | ); | |
| 251 | if (!isWindows) { | |
| 252 | yield* fs.chmod(binaryPath, 0o755); | |
| 253 | } | |
| 254 | ||
| 255 | const runtime = yield* OpenCodeRuntime.OpenCodeRuntime; | |
| 256 | const inventory = yield* runtime.loadInventoryFromCli({ | |
| 257 | binaryPath, | |
| 258 | cwd: tempDir, | |
| 259 | environment: { | |
| 260 | ...hostEnvironment, | |
| 261 | T3_TEST_NODE_BINARY: executablePath, | |
| 262 | T3_TEST_OPENCODE_SCRIPT: scriptPath, | |
| 263 | }, | |
| 264 | }); | |
| 265 | ||
| 266 | NodeAssert.deepEqual(inventory.providerList.connected, ["openai"]); | |
| 267 | NodeAssert.equal(inventory.skills.length, 0); | |
| 268 | }), | |
| 269 | ); | |
| 270 | ||
| 271 | it.effect("caps and drains command stdout and stderr when requested", () => | |
| 272 | Effect.gen(function* () { | |
| 273 | const runtime = yield* OpenCodeRuntime.OpenCodeRuntime; | |
| 274 | const executablePath = yield* HostProcessExecutablePath; | |
| 275 | const outputBytes = 2 * 1024 * 1024; | |
| 276 | const result = yield* runtime.runOpenCodeCommand({ | |
| 277 | binaryPath: executablePath, | |
| 278 | args: [ | |
| 279 | "-e", | |
| 280 | `process.stdout.write("o".repeat(${outputBytes})); process.stderr.write("e".repeat(${outputBytes}));`, | |
| 281 | ], | |
| 282 | maxOutputBytes: 64, | |
| 283 | }); | |
| 284 | ||
| 285 | NodeAssert.equal(result.stdout, "o".repeat(64)); | |
| 286 | NodeAssert.equal(result.stderr, "e".repeat(64)); | |
| 287 | NodeAssert.equal(result.code, 0); | |
| 288 | }), | |
| 289 | ); | |
| 290 | }); |
apps/server/src/provider/opencodeRuntime.permissions.test.ts deleted-89| ... | ... | @@ -1,89 +0,0 @@ |
| 1 | import * as NodeAssert from "node:assert/strict"; | |
| 2 | ||
| 3 | import * as RegExpUtils from "effect/RegExp"; | |
| 4 | import { describe, it } from "vite-plus/test"; | |
| 5 | ||
| 6 | import { buildOpenCodePermissionRules, toOpenCodePermissionReply } from "./opencodeRuntime.ts"; | |
| 7 | ||
| 8 | function actionFor( | |
| 9 | runtimeMode: Parameters<typeof buildOpenCodePermissionRules>[0], | |
| 10 | permission: string, | |
| 11 | target = "*", | |
| 12 | ) { | |
| 13 | // OpenCode uses the last matching rule. Its wildcards match directory separators. | |
| 14 | return buildOpenCodePermissionRules(runtimeMode).findLast( | |
| 15 | (rule) => | |
| 16 | (rule.permission === "*" || rule.permission === permission) && | |
| 17 | new RegExp(`^${RegExpUtils.escape(rule.pattern).replaceAll("\\*", ".*")}$`, "s").test(target), | |
| 18 | )?.action; | |
| 19 | } | |
| 20 | ||
| 21 | describe("buildOpenCodePermissionRules", () => { | |
| 22 | it("pre-approves edits once the user has chosen to auto-accept them", () => { | |
| 23 | NodeAssert.equal(actionFor("auto-accept-edits", "edit"), "allow"); | |
| 24 | }); | |
| 25 | ||
| 26 | it("still asks before editing when approval is required", () => { | |
| 27 | NodeAssert.equal(actionFor("approval-required", "edit"), "ask"); | |
| 28 | }); | |
| 29 | ||
| 30 | // Documented in docs/user/permission-modes.md: providers without an AI | |
| 31 | // reviewer, OpenCode among them, fall back to Supervised for "auto". | |
| 32 | it("leaves auto asking, as the docs say it does without a reviewer", () => { | |
| 33 | NodeAssert.equal(actionFor("auto", "edit"), "ask"); | |
| 34 | }); | |
| 35 | ||
| 36 | it("allows workspace reads and task updates without asking in supervised modes", () => { | |
| 37 | for (const runtimeMode of ["approval-required", "auto-accept-edits", "auto"] as const) { | |
| 38 | for (const permission of ["read", "glob", "grep", "lsp", "skill", "todowrite"]) { | |
| 39 | NodeAssert.equal(actionFor(runtimeMode, permission, "src/index.ts"), "allow"); | |
| 40 | } | |
| 41 | } | |
| 42 | }); | |
| 43 | ||
| 44 | it("preserves OpenCode's environment-file approval rules", () => { | |
| 45 | for (const runtimeMode of ["approval-required", "auto-accept-edits", "auto"] as const) { | |
| 46 | for (const target of [ | |
| 47 | ".env", | |
| 48 | ".env.local", | |
| 49 | "config/service.env", | |
| 50 | "config/service.env.local", | |
| 51 | ]) { | |
| 52 | NodeAssert.equal(actionFor(runtimeMode, "read", target), "ask"); | |
| 53 | } | |
| 54 | for (const target of [".env.example", "config/service.env.example"]) { | |
| 55 | NodeAssert.equal(actionFor(runtimeMode, "read", target), "allow"); | |
| 56 | } | |
| 57 | } | |
| 58 | }); | |
| 59 | ||
| 60 | it("still asks before commands, network access, external directories and unknown tools", () => { | |
| 61 | for (const runtimeMode of ["approval-required", "auto-accept-edits", "auto"] as const) { | |
| 62 | NodeAssert.equal(actionFor(runtimeMode, "bash"), "ask"); | |
| 63 | NodeAssert.equal(actionFor(runtimeMode, "webfetch"), "ask"); | |
| 64 | NodeAssert.equal(actionFor(runtimeMode, "websearch"), "ask"); | |
| 65 | NodeAssert.equal(actionFor(runtimeMode, "external_directory"), "ask"); | |
| 66 | NodeAssert.equal(actionFor(runtimeMode, "doom_loop"), "ask"); | |
| 67 | NodeAssert.equal(actionFor(runtimeMode, "custom_tool"), "ask"); | |
| 68 | } | |
| 69 | }); | |
| 70 | ||
| 71 | it("allows everything only under full access", () => { | |
| 72 | NodeAssert.deepEqual(buildOpenCodePermissionRules("full-access"), [ | |
| 73 | { permission: "*", pattern: "*", action: "allow" }, | |
| 74 | { permission: "external_directory", pattern: "*", action: "allow" }, | |
| 75 | ]); | |
| 76 | }); | |
| 77 | }); | |
| 78 | ||
| 79 | describe("toOpenCodePermissionReply", () => { | |
| 80 | it.each([ | |
| 81 | ["accept", "once"], | |
| 82 | ["acceptForSession", "always"], | |
| 83 | ["acceptAlways", "always"], | |
| 84 | ["decline", "reject"], | |
| 85 | ["cancel", "reject"], | |
| 86 | ] as const)("maps %s to %s", (decision, reply) => { | |
| 87 | NodeAssert.equal(toOpenCodePermissionReply(decision), reply); | |
| 88 | }); | |
| 89 | }); |
apps/server/src/provider/opencodeRuntime.ts deleted-1111| ... | ... | @@ -1,1111 +0,0 @@ |
| 1 | import * as NodeURL from "node:url"; | |
| 2 | ||
| 3 | import type { ChatAttachment, ProviderApprovalDecision, RuntimeMode } from "@t3tools/contracts"; | |
| 4 | import { | |
| 5 | createOpencodeClient, | |
| 6 | type Agent, | |
| 7 | type Command, | |
| 8 | type FilePartInput, | |
| 9 | type Model, | |
| 10 | type OpencodeClient, | |
| 11 | type PermissionRuleset, | |
| 12 | type ProviderListResponse, | |
| 13 | type QuestionAnswer, | |
| 14 | type QuestionRequest, | |
| 15 | } from "@opencode-ai/sdk/v2"; | |
| 16 | import * as Cause from "effect/Cause"; | |
| 17 | import * as Context from "effect/Context"; | |
| 18 | import * as Data from "effect/Data"; | |
| 19 | import * as Deferred from "effect/Deferred"; | |
| 20 | import * as Effect from "effect/Effect"; | |
| 21 | import * as Exit from "effect/Exit"; | |
| 22 | import * as Fiber from "effect/Fiber"; | |
| 23 | import * as Layer from "effect/Layer"; | |
| 24 | import * as Option from "effect/Option"; | |
| 25 | import * as P from "effect/Predicate"; | |
| 26 | import * as Ref from "effect/Ref"; | |
| 27 | import * as Result from "effect/Result"; | |
| 28 | import * as Scope from "effect/Scope"; | |
| 29 | import * as Schema from "effect/Schema"; | |
| 30 | import * as Stream from "effect/Stream"; | |
| 31 | import { ChildProcess, ChildProcessSpawner } from "effect/process"; | |
| 32 | ||
| 33 | import { signalProcessGroup } from "@t3tools/provider-core/server/processGroup"; | |
| 34 | import { isWindowsCommandNotFound } from "../processRunner.ts"; | |
| 35 | import * as OpenCodeServerLedger from "./OpenCodeServerLedger.ts"; | |
| 36 | import { collectStreamAsString } from "@t3tools/provider-core/server/snapshotProbe"; | |
| 37 | import * as NetService from "@t3tools/shared/Net"; | |
| 38 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 39 | import { compareSemverVersions, parseSemver } from "@t3tools/shared/semver"; | |
| 40 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; | |
| 41 | const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown)); | |
| 42 | const OPENCODE_EMPTY_CONFIG_CONTENT = "{}"; | |
| 43 | ||
| 44 | export const MINIMUM_OPENCODE_VERSION = "1.14.19"; | |
| 45 | const OPENCODE_HEALTH_TIMEOUT = "5 seconds"; | |
| 46 | ||
| 47 | const OpenCodeHealthSchema = Schema.Struct({ | |
| 48 | healthy: Schema.Literal(true), | |
| 49 | version: Schema.String, | |
| 50 | }); | |
| 51 | const decodeOpenCodeHealth = Schema.decodeUnknownEffect(OpenCodeHealthSchema); | |
| 52 | ||
| 53 | export function resolveOpenCodeConfigContent( | |
| 54 | inputEnvironment: Readonly<Record<string, string | undefined>> | undefined, | |
| 55 | inheritedEnvironment: Readonly<Record<string, string | undefined>> = process.env, | |
| 56 | ): string { | |
| 57 | return ( | |
| 58 | inputEnvironment?.OPENCODE_CONFIG_CONTENT ?? | |
| 59 | inheritedEnvironment.OPENCODE_CONFIG_CONTENT ?? | |
| 60 | OPENCODE_EMPTY_CONFIG_CONTENT | |
| 61 | ); | |
| 62 | } | |
| 63 | ||
| 64 | export function resolveOpenCodeServerPassword( | |
| 65 | input: { | |
| 66 | readonly external: boolean; | |
| 67 | readonly serverPassword?: string; | |
| 68 | readonly environment?: Readonly<Record<string, string | undefined>>; | |
| 69 | }, | |
| 70 | inheritedEnvironment: Readonly<Record<string, string | undefined>> = process.env, | |
| 71 | ): string | undefined { | |
| 72 | if (input.serverPassword !== undefined) { | |
| 73 | return input.serverPassword; | |
| 74 | } | |
| 75 | if (input.external) { | |
| 76 | return undefined; | |
| 77 | } | |
| 78 | return input.environment === undefined | |
| 79 | ? inheritedEnvironment.OPENCODE_SERVER_PASSWORD | |
| 80 | : input.environment.OPENCODE_SERVER_PASSWORD; | |
| 81 | } | |
| 82 | ||
| 83 | const DEFAULT_OPENCODE_SERVER_TIMEOUT_MS = 30_000; | |
| 84 | const DEFAULT_HOSTNAME = "127.0.0.1"; | |
| 85 | const OPENCODE_SERVER_STARTUP_MAX_OUTPUT_CHARS = 64 * 1024; | |
| 86 | const OPENCODE_SKILL_DISCOVERY_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; | |
| 87 | export interface OpenCodeServerProcess { | |
| 88 | readonly url: string; | |
| 89 | readonly serverPassword?: string; | |
| 90 | readonly version: string; | |
| 91 | readonly isRunning: Effect.Effect<boolean>; | |
| 92 | readonly exitCode: Effect.Effect<number, never>; | |
| 93 | } | |
| 94 | ||
| 95 | export interface OpenCodeServerConnection { | |
| 96 | readonly url: string; | |
| 97 | readonly serverPassword?: string; | |
| 98 | readonly version: string; | |
| 99 | readonly exitCode: Effect.Effect<number, never> | null; | |
| 100 | readonly external: boolean; | |
| 101 | } | |
| 102 | ||
| 103 | const OPENCODE_RUNTIME_ERROR_TAG = "OpenCodeRuntimeError"; | |
| 104 | export class OpenCodeRuntimeError extends Data.TaggedError(OPENCODE_RUNTIME_ERROR_TAG)<{ | |
| 105 | readonly operation: string; | |
| 106 | readonly cause?: unknown; | |
| 107 | readonly detail: string; | |
| 108 | }> { | |
| 109 | static readonly is = (u: unknown): u is OpenCodeRuntimeError => | |
| 110 | P.isTagged(u, OPENCODE_RUNTIME_ERROR_TAG); | |
| 111 | } | |
| 112 | ||
| 113 | function encodeJsonStringForDiagnostics(input: unknown): string | undefined { | |
| 114 | const result = encodeUnknownJsonStringExit(input); | |
| 115 | return Exit.isSuccess(result) ? result.value : undefined; | |
| 116 | } | |
| 117 | ||
| 118 | export function openCodeRuntimeErrorDetail(cause: unknown): string { | |
| 119 | if (OpenCodeRuntimeError.is(cause)) return cause.detail; | |
| 120 | if (cause instanceof Error && cause.message.trim().length > 0) return cause.message.trim(); | |
| 121 | if (cause && typeof cause === "object") { | |
| 122 | // SDK v2 throws { response, request, error? } shapes — extract what's useful | |
| 123 | const anyCause = cause as Record<string, unknown>; | |
| 124 | const status = (anyCause.response as { status?: number } | undefined)?.status; | |
| 125 | const body = anyCause.error ?? anyCause.data ?? anyCause.body; | |
| 126 | const encodedBody = encodeJsonStringForDiagnostics(body ?? cause); | |
| 127 | if (encodedBody) { | |
| 128 | return `status=${status ?? "?"} body=${encodedBody}`; | |
| 129 | } | |
| 130 | } | |
| 131 | return String(cause); | |
| 132 | } | |
| 133 | ||
| 134 | export const runOpenCodeSdk = <A>( | |
| 135 | operation: string, | |
| 136 | fn: (signal: AbortSignal) => Promise<A>, | |
| 137 | ): Effect.Effect<A, OpenCodeRuntimeError> => | |
| 138 | Effect.tryPromise({ | |
| 139 | try: fn, | |
| 140 | catch: (cause) => | |
| 141 | new OpenCodeRuntimeError({ operation, detail: openCodeRuntimeErrorDetail(cause), cause }), | |
| 142 | }).pipe(Effect.withSpan(`opencode.${operation}`)); | |
| 143 | ||
| 144 | export const verifyOpenCodeServerVersion = Effect.fn("verifyOpenCodeServerVersion")(function* ( | |
| 145 | client: OpencodeClient, | |
| 146 | ) { | |
| 147 | const healthOption = yield* runOpenCodeSdk("global.health", (signal) => | |
| 148 | client.global.health({ signal }), | |
| 149 | ).pipe(Effect.timeoutOption(OPENCODE_HEALTH_TIMEOUT)); | |
| 150 | if (Option.isNone(healthOption)) { | |
| 151 | return yield* new OpenCodeRuntimeError({ | |
| 152 | operation: "global.health", | |
| 153 | detail: "Timed out while checking the OpenCode server version.", | |
| 154 | }); | |
| 155 | } | |
| 156 | ||
| 157 | const health = yield* decodeOpenCodeHealth(healthOption.value.data).pipe( | |
| 158 | Effect.mapError( | |
| 159 | (cause) => | |
| 160 | new OpenCodeRuntimeError({ | |
| 161 | operation: "global.health", | |
| 162 | detail: `OpenCode server returned an invalid health response. T3 Code requires OpenCode v${MINIMUM_OPENCODE_VERSION} or newer.`, | |
| 163 | cause, | |
| 164 | }), | |
| 165 | ), | |
| 166 | ); | |
| 167 | if (parseSemver(health.version) === null) { | |
| 168 | return yield* new OpenCodeRuntimeError({ | |
| 169 | operation: "global.health", | |
| 170 | detail: `OpenCode server returned an invalid version. T3 Code requires OpenCode v${MINIMUM_OPENCODE_VERSION} or newer.`, | |
| 171 | }); | |
| 172 | } | |
| 173 | if (compareSemverVersions(health.version, MINIMUM_OPENCODE_VERSION) < 0) { | |
| 174 | return yield* new OpenCodeRuntimeError({ | |
| 175 | operation: "global.health", | |
| 176 | detail: `OpenCode v${health.version} is too old. Upgrade to v${MINIMUM_OPENCODE_VERSION} or newer.`, | |
| 177 | }); | |
| 178 | } | |
| 179 | return health.version; | |
| 180 | }); | |
| 181 | ||
| 182 | export interface OpenCodeCommandResult { | |
| 183 | readonly stdout: string; | |
| 184 | readonly stderr: string; | |
| 185 | readonly code: number; | |
| 186 | } | |
| 187 | ||
| 188 | export interface OpenCodeInventory { | |
| 189 | readonly providerList: ProviderListResponse; | |
| 190 | readonly agents: ReadonlyArray<Agent>; | |
| 191 | readonly skills: ReadonlyArray<OpenCodeSkill>; | |
| 192 | readonly commands?: ReadonlyArray<OpenCodeSlashCommand>; | |
| 193 | } | |
| 194 | ||
| 195 | export type OpenCodeSlashCommand = Pick<Command, "name" | "description" | "source" | "hints">; | |
| 196 | ||
| 197 | /** Command templates stay in OpenCode, which expands arguments and runs MCP prompts. */ | |
| 198 | export const loadOpenCodeCommands = (client: OpencodeClient) => | |
| 199 | runOpenCodeSdk("command.list", (signal) => client.command.list(undefined, { signal })).pipe( | |
| 200 | Effect.map((result): ReadonlyArray<OpenCodeSlashCommand> => | |
| 201 | (result.data ?? []).map(({ name, description, source, hints }) => ({ | |
| 202 | name, | |
| 203 | ...(description === undefined ? {} : { description }), | |
| 204 | ...(source === undefined ? {} : { source }), | |
| 205 | hints, | |
| 206 | })), | |
| 207 | ), | |
| 208 | ); | |
| 209 | ||
| 210 | export interface ParsedOpenCodeModelSlug { | |
| 211 | readonly providerID: string; | |
| 212 | readonly modelID: string; | |
| 213 | } | |
| 214 | ||
| 215 | export interface OpenCodeSkill { | |
| 216 | readonly name?: string | null; | |
| 217 | readonly description?: string | null; | |
| 218 | readonly location?: string | null; | |
| 219 | } | |
| 220 | ||
| 221 | const OpenCodeSkillSchema = Schema.Struct({ | |
| 222 | name: Schema.optionalKey(Schema.NullOr(Schema.String)), | |
| 223 | description: Schema.optionalKey(Schema.NullOr(Schema.String)), | |
| 224 | location: Schema.optionalKey(Schema.NullOr(Schema.String)), | |
| 225 | }); | |
| 226 | const decodeOpenCodeSkillsCliOutputExit = Schema.decodeUnknownExit( | |
| 227 | Schema.fromJsonString(Schema.Array(OpenCodeSkillSchema)), | |
| 228 | ); | |
| 229 | ||
| 230 | export interface OpenCodeRuntimeShape { | |
| 231 | /** | |
| 232 | * Spawns a local OpenCode server process. Its lifetime is bound to the caller's | |
| 233 | * `Scope.Scope` — the child is killed automatically when that scope closes. | |
| 234 | * Consumers that want a long-lived server must create and hold a scope explicitly | |
| 235 | * (see {@link Scope.make}) and close it when done. | |
| 236 | */ | |
| 237 | readonly startOpenCodeServerProcess: (input: { | |
| 238 | readonly binaryPath: string; | |
| 239 | readonly directory: string; | |
| 240 | readonly serverPassword?: string; | |
| 241 | readonly environment?: NodeJS.ProcessEnv; | |
| 242 | readonly port?: number; | |
| 243 | readonly hostname?: string; | |
| 244 | readonly timeoutMs?: number; | |
| 245 | /** Checks the listening server and returns its version. Defaults to the 1.x health check. */ | |
| 246 | readonly verify?: (url: string) => Effect.Effect<string, OpenCodeRuntimeError>; | |
| 247 | }) => Effect.Effect<OpenCodeServerProcess, OpenCodeRuntimeError, Scope.Scope>; | |
| 248 | /** | |
| 249 | * Returns a handle to either an externally-managed OpenCode server (when | |
| 250 | * `serverUrl` is provided — no lifetime is attached to the caller's scope) or a | |
| 251 | * freshly spawned local server whose lifetime is bound to the caller's scope. | |
| 252 | */ | |
| 253 | readonly connectToOpenCodeServer: (input: { | |
| 254 | readonly binaryPath: string; | |
| 255 | readonly directory: string; | |
| 256 | readonly serverUrl?: string | null; | |
| 257 | readonly serverPassword?: string; | |
| 258 | readonly environment?: NodeJS.ProcessEnv; | |
| 259 | readonly port?: number; | |
| 260 | readonly hostname?: string; | |
| 261 | readonly timeoutMs?: number; | |
| 262 | }) => Effect.Effect<OpenCodeServerConnection, OpenCodeRuntimeError, Scope.Scope>; | |
| 263 | readonly runOpenCodeCommand: (input: { | |
| 264 | readonly binaryPath: string; | |
| 265 | readonly args: ReadonlyArray<string>; | |
| 266 | readonly environment?: NodeJS.ProcessEnv; | |
| 267 | readonly cwd?: string; | |
| 268 | readonly maxOutputBytes?: number; | |
| 269 | }) => Effect.Effect<OpenCodeCommandResult, OpenCodeRuntimeError>; | |
| 270 | readonly createOpenCodeSdkClient: (input: { | |
| 271 | readonly baseUrl: string; | |
| 272 | readonly directory: string; | |
| 273 | readonly serverPassword?: string; | |
| 274 | }) => OpencodeClient; | |
| 275 | readonly loadOpenCodeInventory: ( | |
| 276 | client: OpencodeClient, | |
| 277 | ) => Effect.Effect<OpenCodeInventory, OpenCodeRuntimeError>; | |
| 278 | readonly loadOpenCodeSkills: ( | |
| 279 | client: OpencodeClient, | |
| 280 | ) => Effect.Effect<ReadonlyArray<OpenCodeSkill>, OpenCodeRuntimeError>; | |
| 281 | readonly loadInventoryFromCli: (input: { | |
| 282 | readonly binaryPath: string; | |
| 283 | readonly cwd: string; | |
| 284 | readonly environment?: NodeJS.ProcessEnv; | |
| 285 | }) => Effect.Effect<OpenCodeInventory, OpenCodeRuntimeError>; | |
| 286 | readonly loadSkillsFromCli: (input: { | |
| 287 | readonly binaryPath: string; | |
| 288 | readonly cwd: string; | |
| 289 | readonly environment?: NodeJS.ProcessEnv; | |
| 290 | }) => Effect.Effect<ReadonlyArray<OpenCodeSkill>, OpenCodeRuntimeError>; | |
| 291 | } | |
| 292 | ||
| 293 | function parseServerUrlFromOutput(output: string): string | null { | |
| 294 | for (const line of output.split("\n")) { | |
| 295 | const match = line.match(/server listening on\s+(https?:\/\/[^\s]+)/i); | |
| 296 | if (match?.[1]) return match[1]; | |
| 297 | } | |
| 298 | return null; | |
| 299 | } | |
| 300 | ||
| 301 | const SLUG_LINE_RE = /^(\S+\/\S+)\s*$/; | |
| 302 | const AGENT_HEADER_RE = /^(.+)\s+\((\S+)\)\s*$/; | |
| 303 | ||
| 304 | // Agents that are always hidden in OpenCode but the CLI "agent list" command | |
| 305 | // does not expose the hidden flag. Keep in sync with OpenCode agent | |
| 306 | // definitions (in the OpenCode repo: packages/opencode/src/agent/agent.ts). | |
| 307 | const KNOWN_HIDDEN_AGENTS = new Set(["compaction", "summary", "title"]); | |
| 308 | ||
| 309 | /** @internal */ | |
| 310 | export function parseModelsCliOutput(stdout: string): { | |
| 311 | readonly providers: ReadonlyMap< | |
| 312 | string, | |
| 313 | { readonly id: string; readonly name: string; readonly models: { [key: string]: Model } } | |
| 314 | >; | |
| 315 | readonly connected: ReadonlyArray<string>; | |
| 316 | } { | |
| 317 | const providers = new Map< | |
| 318 | string, | |
| 319 | { id: string; name: string; models: { [key: string]: Model } } | |
| 320 | >(); | |
| 321 | const lines = stdout.split("\n"); | |
| 322 | let currentSlug: string | null = null; | |
| 323 | const jsonLines: Array<string> = []; | |
| 324 | ||
| 325 | const flushModel = () => { | |
| 326 | if (currentSlug !== null && jsonLines.length > 0) { | |
| 327 | const jsonStr = jsonLines.join("\n").trim(); | |
| 328 | if (jsonStr.length > 0) { | |
| 329 | try { | |
| 330 | const model = JSON.parse(jsonStr) as Model; | |
| 331 | const separator = currentSlug.indexOf("/"); | |
| 332 | if (separator > 0) { | |
| 333 | const providerID = currentSlug.slice(0, separator); | |
| 334 | const modelID = currentSlug.slice(separator + 1); | |
| 335 | let provider = providers.get(providerID); | |
| 336 | if (!provider) { | |
| 337 | provider = { id: providerID, name: providerID, models: {} }; | |
| 338 | providers.set(providerID, provider); | |
| 339 | } | |
| 340 | provider.models[modelID] = model; | |
| 341 | } | |
| 342 | } catch { | |
| 343 | // Skip unparseable model JSON | |
| 344 | } | |
| 345 | } | |
| 346 | } | |
| 347 | currentSlug = null; | |
| 348 | jsonLines.length = 0; | |
| 349 | }; | |
| 350 | ||
| 351 | for (const line of lines) { | |
| 352 | // A model's JSON body is a single `JSON.stringify` line starting with `{`, | |
| 353 | // while a provider/model slug is a bare `provider/model` header. Only the | |
| 354 | // latter can be a slug: without this guard a body line with no interior | |
| 355 | // whitespace and a `/` in one of its values (e.g. an OpenRouter model whose | |
| 356 | // `id` is `vendor/model`) matches SLUG_LINE_RE, so flushModel runs against | |
| 357 | // an empty body and the model is silently dropped. | |
| 358 | const slugMatch = line.trimStart().startsWith("{") ? null : SLUG_LINE_RE.exec(line); | |
| 359 | if (slugMatch) { | |
| 360 | flushModel(); | |
| 361 | currentSlug = slugMatch[1]!; | |
| 362 | } else if (currentSlug !== null) { | |
| 363 | jsonLines.push(line); | |
| 364 | } | |
| 365 | } | |
| 366 | flushModel(); | |
| 367 | ||
| 368 | return { providers, connected: [...providers.keys()] }; | |
| 369 | } | |
| 370 | ||
| 371 | /** @internal */ | |
| 372 | export function parseAgentListCliOutput(stdout: string): ReadonlyArray<Agent> { | |
| 373 | const agents: Array<Agent> = []; | |
| 374 | const lines = stdout.split("\n"); | |
| 375 | let currentHeader: { name: string; mode: string } | null = null; | |
| 376 | const blockLines: Array<string> = []; | |
| 377 | ||
| 378 | const flushAgent = () => { | |
| 379 | if (currentHeader !== null) { | |
| 380 | const jsonStr = blockLines.join("\n").trim(); | |
| 381 | if (jsonStr.length > 0) { | |
| 382 | try { | |
| 383 | const permission = JSON.parse(jsonStr); | |
| 384 | agents.push({ | |
| 385 | name: currentHeader.name, | |
| 386 | mode: currentHeader.mode as Agent["mode"], | |
| 387 | hidden: KNOWN_HIDDEN_AGENTS.has(currentHeader.name), | |
| 388 | permission, | |
| 389 | options: {}, | |
| 390 | }); | |
| 391 | } catch { | |
| 392 | // Skip unparseable agent | |
| 393 | } | |
| 394 | } | |
| 395 | } | |
| 396 | currentHeader = null; | |
| 397 | blockLines.length = 0; | |
| 398 | }; | |
| 399 | ||
| 400 | for (const line of lines) { | |
| 401 | const match = AGENT_HEADER_RE.exec(line); | |
| 402 | if (match) { | |
| 403 | flushAgent(); | |
| 404 | currentHeader = { name: match[1]!, mode: match[2]! }; | |
| 405 | } else if (currentHeader !== null) { | |
| 406 | blockLines.push(line); | |
| 407 | } | |
| 408 | } | |
| 409 | flushAgent(); | |
| 410 | ||
| 411 | return agents; | |
| 412 | } | |
| 413 | ||
| 414 | /** @internal */ | |
| 415 | export function parseSkillsCliOutput(stdout: string): ReadonlyArray<OpenCodeSkill> { | |
| 416 | const result = decodeOpenCodeSkillsCliOutputExit(stdout); | |
| 417 | return Exit.isSuccess(result) ? result.value : []; | |
| 418 | } | |
| 419 | ||
| 420 | export function parseOpenCodeModelSlug( | |
| 421 | slug: string | null | undefined, | |
| 422 | ): ParsedOpenCodeModelSlug | null { | |
| 423 | if (typeof slug !== "string") { | |
| 424 | return null; | |
| 425 | } | |
| 426 | ||
| 427 | const trimmed = slug.trim(); | |
| 428 | const separator = trimmed.indexOf("/"); | |
| 429 | if (separator <= 0 || separator === trimmed.length - 1) { | |
| 430 | return null; | |
| 431 | } | |
| 432 | ||
| 433 | return { | |
| 434 | providerID: trimmed.slice(0, separator), | |
| 435 | modelID: trimmed.slice(separator + 1), | |
| 436 | }; | |
| 437 | } | |
| 438 | ||
| 439 | export function openCodeQuestionId( | |
| 440 | index: number, | |
| 441 | question: QuestionRequest["questions"][number], | |
| 442 | ): string { | |
| 443 | const header = question.header | |
| 444 | .trim() | |
| 445 | .toLowerCase() | |
| 446 | .replace(/[^a-z0-9_-]+/g, "-"); | |
| 447 | return header.length > 0 ? `question-${index}-${header}` : `question-${index}`; | |
| 448 | } | |
| 449 | ||
| 450 | /** | |
| 451 | * Attachments OpenCode can hand to a model as a native file part. Anything | |
| 452 | * else (ZIP, binaries, image formats like BMP/AVIF/SVG that model APIs | |
| 453 | * reject, or files over the direct-attachment size limit) would make the turn | |
| 454 | * fail before it starts, so those ride only as the file path ProviderService | |
| 455 | * puts in the prompt. | |
| 456 | */ | |
| 457 | const OPENCODE_NATIVE_IMAGE_MIMES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]); | |
| 458 | const OPENCODE_NATIVE_FILE_PART_MAX_BYTES = 20 * 1024 * 1024; | |
| 459 | ||
| 460 | function isOpenCodeNativeFilePart(input: { | |
| 461 | readonly mimeType: string; | |
| 462 | readonly sizeBytes: number; | |
| 463 | }): boolean { | |
| 464 | if (input.sizeBytes > OPENCODE_NATIVE_FILE_PART_MAX_BYTES) { | |
| 465 | return false; | |
| 466 | } | |
| 467 | const normalized = input.mimeType.trim().toLowerCase(); | |
| 468 | return ( | |
| 469 | OPENCODE_NATIVE_IMAGE_MIMES.has(normalized) || | |
| 470 | normalized.startsWith("text/") || | |
| 471 | normalized === "application/pdf" | |
| 472 | ); | |
| 473 | } | |
| 474 | ||
| 475 | export function toOpenCodeFileParts(input: { | |
| 476 | readonly attachments: ReadonlyArray<ChatAttachment> | undefined; | |
| 477 | readonly resolveAttachmentPath: (attachment: ChatAttachment) => string | null; | |
| 478 | }): Array<FilePartInput> { | |
| 479 | const parts: Array<FilePartInput> = []; | |
| 480 | ||
| 481 | for (const attachment of input.attachments ?? []) { | |
| 482 | if ( | |
| 483 | attachment.type === "file" && | |
| 484 | "source" in attachment && | |
| 485 | attachment.source?._tag === "pasted-text" | |
| 486 | ) { | |
| 487 | continue; | |
| 488 | } | |
| 489 | if (!isOpenCodeNativeFilePart(attachment)) { | |
| 490 | continue; | |
| 491 | } | |
| 492 | const attachmentPath = input.resolveAttachmentPath(attachment); | |
| 493 | if (!attachmentPath) { | |
| 494 | continue; | |
| 495 | } | |
| 496 | ||
| 497 | parts.push({ | |
| 498 | type: "file", | |
| 499 | mime: attachment.mimeType, | |
| 500 | filename: attachment.name, | |
| 501 | url: NodeURL.pathToFileURL(attachmentPath).href, | |
| 502 | }); | |
| 503 | } | |
| 504 | ||
| 505 | return parts; | |
| 506 | } | |
| 507 | ||
| 508 | export function buildOpenCodePermissionRules(runtimeMode: RuntimeMode): PermissionRuleset { | |
| 509 | if (runtimeMode === "full-access") { | |
| 510 | return [ | |
| 511 | { permission: "*", pattern: "*", action: "allow" }, | |
| 512 | { permission: "external_directory", pattern: "*", action: "allow" }, | |
| 513 | ]; | |
| 514 | } | |
| 515 | ||
| 516 | // "Auto-accept edits" is documented as "auto-approve edits, ask before other | |
| 517 | // actions", so prompting for every edit ignores the mode the user picked. | |
| 518 | // "auto" is left asking on purpose: the docs say providers without an AI | |
| 519 | // reviewer, OpenCode among them, fall back to Supervised for that mode. | |
| 520 | const editAction = runtimeMode === "auto-accept-edits" ? "allow" : "ask"; | |
| 521 | ||
| 522 | // Session rules override OpenCode's agent defaults. Allow reads and task | |
| 523 | // updates, but keep its default approval rules for environment files. | |
| 524 | return [ | |
| 525 | { permission: "*", pattern: "*", action: "ask" }, | |
| 526 | { permission: "read", pattern: "*", action: "allow" }, | |
| 527 | { permission: "read", pattern: "*.env", action: "ask" }, | |
| 528 | { permission: "read", pattern: "*.env.*", action: "ask" }, | |
| 529 | { permission: "read", pattern: "*.env.example", action: "allow" }, | |
| 530 | { permission: "glob", pattern: "*", action: "allow" }, | |
| 531 | { permission: "grep", pattern: "*", action: "allow" }, | |
| 532 | { permission: "lsp", pattern: "*", action: "allow" }, | |
| 533 | { permission: "skill", pattern: "*", action: "allow" }, | |
| 534 | { permission: "todowrite", pattern: "*", action: "allow" }, | |
| 535 | { permission: "bash", pattern: "*", action: "ask" }, | |
| 536 | { permission: "edit", pattern: "*", action: editAction }, | |
| 537 | { permission: "webfetch", pattern: "*", action: "ask" }, | |
| 538 | { permission: "websearch", pattern: "*", action: "ask" }, | |
| 539 | { permission: "codesearch", pattern: "*", action: "ask" }, | |
| 540 | { permission: "external_directory", pattern: "*", action: "ask" }, | |
| 541 | { permission: "doom_loop", pattern: "*", action: "ask" }, | |
| 542 | { permission: "question", pattern: "*", action: "allow" }, | |
| 543 | ]; | |
| 544 | } | |
| 545 | ||
| 546 | export function toOpenCodePermissionReply( | |
| 547 | decision: ProviderApprovalDecision, | |
| 548 | ): "once" | "always" | "reject" { | |
| 549 | switch (decision) { | |
| 550 | case "accept": | |
| 551 | return "once"; | |
| 552 | case "acceptForSession": | |
| 553 | case "acceptAlways": | |
| 554 | return "always"; | |
| 555 | case "decline": | |
| 556 | case "cancel": | |
| 557 | default: | |
| 558 | return "reject"; | |
| 559 | } | |
| 560 | } | |
| 561 | ||
| 562 | export function toOpenCodeQuestionAnswers( | |
| 563 | request: QuestionRequest, | |
| 564 | answers: Record<string, unknown>, | |
| 565 | ): Array<QuestionAnswer> { | |
| 566 | return request.questions.map((question, index) => { | |
| 567 | const raw = | |
| 568 | answers[openCodeQuestionId(index, question)] ?? | |
| 569 | answers[question.header] ?? | |
| 570 | answers[question.question]; | |
| 571 | if (Array.isArray(raw)) { | |
| 572 | return raw.filter((value): value is string => typeof value === "string"); | |
| 573 | } | |
| 574 | if (typeof raw === "string") { | |
| 575 | return raw.trim().length > 0 ? [raw] : []; | |
| 576 | } | |
| 577 | return []; | |
| 578 | }); | |
| 579 | } | |
| 580 | ||
| 581 | function ensureRuntimeError( | |
| 582 | operation: OpenCodeRuntimeError["operation"], | |
| 583 | detail: string, | |
| 584 | cause: unknown, | |
| 585 | ): OpenCodeRuntimeError { | |
| 586 | return OpenCodeRuntimeError.is(cause) | |
| 587 | ? cause | |
| 588 | : new OpenCodeRuntimeError({ operation, detail, cause }); | |
| 589 | } | |
| 590 | ||
| 591 | const makeOpenCodeRuntime = Effect.gen(function* () { | |
| 592 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 593 | const netService = yield* NetService.NetService; | |
| 594 | const hostPlatform = yield* HostProcessPlatform; | |
| 595 | const serverLedger = yield* OpenCodeServerLedger.OpenCodeServerLedger; | |
| 596 | const resolveCommand = (command: string, args: ReadonlyArray<string>, env?: NodeJS.ProcessEnv) => | |
| 597 | resolveSpawnCommand(command, args, env ? { env } : {}); | |
| 598 | ||
| 599 | const runOpenCodeCommand: OpenCodeRuntimeShape["runOpenCodeCommand"] = (input) => | |
| 600 | Effect.gen(function* () { | |
| 601 | const spawnCommand = yield* resolveCommand(input.binaryPath, input.args, input.environment); | |
| 602 | const child = yield* spawner.spawn( | |
| 603 | ChildProcess.make(spawnCommand.command, spawnCommand.args, { | |
| 604 | detached: hostPlatform !== "win32", | |
| 605 | shell: spawnCommand.shell, | |
| 606 | ...(input.cwd ? { cwd: input.cwd } : {}), | |
| 607 | ...(input.environment ? { env: input.environment } : { extendEnv: true }), | |
| 608 | }), | |
| 609 | ); | |
| 610 | const terminateCommandGroup = | |
| 611 | hostPlatform === "win32" | |
| 612 | ? child.kill({ killSignal: "SIGKILL" }).pipe(Effect.asVoid) | |
| 613 | : Effect.sync(() => { | |
| 614 | try { | |
| 615 | signalProcessGroup(Number(child.pid), "SIGKILL"); | |
| 616 | } catch { | |
| 617 | // The command and its process group may already have exited. | |
| 618 | } | |
| 619 | }); | |
| 620 | yield* Effect.addFinalizer(() => terminateCommandGroup.pipe(Effect.ignore)); | |
| 621 | const collectOptions = | |
| 622 | input.maxOutputBytes === undefined ? undefined : { maxBytes: input.maxOutputBytes }; | |
| 623 | const [stdout, stderr, code] = yield* Effect.all( | |
| 624 | [ | |
| 625 | collectStreamAsString(child.stdout, collectOptions), | |
| 626 | collectStreamAsString(child.stderr, collectOptions), | |
| 627 | child.exitCode, | |
| 628 | ], | |
| 629 | { concurrency: "unbounded" }, | |
| 630 | ); | |
| 631 | const exitCode = Number(code); | |
| 632 | if (yield* isWindowsCommandNotFound(exitCode, stderr)) { | |
| 633 | return yield* new OpenCodeRuntimeError({ | |
| 634 | operation: "runOpenCodeCommand", | |
| 635 | detail: `spawn ${input.binaryPath} ENOENT`, | |
| 636 | }); | |
| 637 | } | |
| 638 | return { | |
| 639 | stdout, | |
| 640 | stderr, | |
| 641 | code: exitCode, | |
| 642 | } satisfies OpenCodeCommandResult; | |
| 643 | }).pipe( | |
| 644 | Effect.scoped, | |
| 645 | Effect.mapError((cause) => | |
| 646 | ensureRuntimeError( | |
| 647 | "runOpenCodeCommand", | |
| 648 | `Failed to execute '${input.binaryPath} ${input.args.join(" ")}': ${openCodeRuntimeErrorDetail(cause)}`, | |
| 649 | cause, | |
| 650 | ), | |
| 651 | ), | |
| 652 | ); | |
| 653 | ||
| 654 | const createOpenCodeSdkClient: OpenCodeRuntimeShape["createOpenCodeSdkClient"] = (input) => | |
| 655 | createOpencodeClient({ | |
| 656 | baseUrl: input.baseUrl, | |
| 657 | directory: input.directory, | |
| 658 | ...(input.serverPassword | |
| 659 | ? { | |
| 660 | headers: { | |
| 661 | Authorization: `Basic ${Buffer.from(`opencode:${input.serverPassword}`, "utf8").toString("base64")}`, | |
| 662 | }, | |
| 663 | } | |
| 664 | : {}), | |
| 665 | throwOnError: true, | |
| 666 | }); | |
| 667 | ||
| 668 | const startOpenCodeServerProcess: OpenCodeRuntimeShape["startOpenCodeServerProcess"] = (input) => | |
| 669 | Effect.gen(function* () { | |
| 670 | // Bind this server's lifetime to the caller's scope. When the caller's | |
| 671 | // scope closes, the spawned child is killed and all associated fibers | |
| 672 | // are interrupted automatically — no `close()` method needed. | |
| 673 | const runtimeScope = yield* Scope.Scope; | |
| 674 | ||
| 675 | const hostname = input.hostname ?? DEFAULT_HOSTNAME; | |
| 676 | const port = | |
| 677 | input.port ?? | |
| 678 | (yield* netService.findAvailablePort(0).pipe( | |
| 679 | Effect.mapError( | |
| 680 | (cause) => | |
| 681 | new OpenCodeRuntimeError({ | |
| 682 | operation: "startOpenCodeServerProcess", | |
| 683 | detail: `Failed to find available port: ${openCodeRuntimeErrorDetail(cause)}`, | |
| 684 | cause, | |
| 685 | }), | |
| 686 | ), | |
| 687 | )); | |
| 688 | const timeoutMs = input.timeoutMs ?? DEFAULT_OPENCODE_SERVER_TIMEOUT_MS; | |
| 689 | const args = ["serve", `--hostname=${hostname}`, `--port=${port}`]; | |
| 690 | const spawnCommand = yield* resolveCommand(input.binaryPath, args, input.environment); | |
| 691 | const serverPassword = resolveOpenCodeServerPassword({ | |
| 692 | external: false, | |
| 693 | ...(input.serverPassword !== undefined ? { serverPassword: input.serverPassword } : {}), | |
| 694 | ...(input.environment !== undefined ? { environment: input.environment } : {}), | |
| 695 | }); | |
| 696 | ||
| 697 | // Scopes close in reverse order. Forking this before the group kill is | |
| 698 | // registered forgets the ledger entry only once the group is stopped. | |
| 699 | const ledgerScope = yield* Scope.fork(runtimeScope); | |
| 700 | const child = yield* spawner | |
| 701 | .spawn( | |
| 702 | ChildProcess.make(spawnCommand.command, spawnCommand.args, { | |
| 703 | detached: hostPlatform !== "win32", | |
| 704 | shell: spawnCommand.shell, | |
| 705 | env: { | |
| 706 | ...input.environment, | |
| 707 | ...(serverPassword !== undefined ? { OPENCODE_SERVER_PASSWORD: serverPassword } : {}), | |
| 708 | // Respect an OPENCODE_CONFIG_CONTENT provided by the caller or | |
| 709 | // the inherited process environment, only falling back to the | |
| 710 | // empty config when neither is set. Setting it unconditionally | |
| 711 | // previously clobbered the user's opencode config, hiding their | |
| 712 | // providers/models. The value is set explicitly (rather than | |
| 713 | // relying on inheritance) because `extendEnv` is false whenever | |
| 714 | // `input.environment` is provided. | |
| 715 | OPENCODE_CONFIG_CONTENT: resolveOpenCodeConfigContent(input.environment), | |
| 716 | }, | |
| 717 | extendEnv: input.environment === undefined, | |
| 718 | }), | |
| 719 | ) | |
| 720 | .pipe( | |
| 721 | Effect.provideService(Scope.Scope, runtimeScope), | |
| 722 | Effect.mapError( | |
| 723 | (cause) => | |
| 724 | new OpenCodeRuntimeError({ | |
| 725 | operation: "startOpenCodeServerProcess", | |
| 726 | detail: `Failed to spawn OpenCode server process: ${openCodeRuntimeErrorDetail(cause)}`, | |
| 727 | cause, | |
| 728 | }), | |
| 729 | ), | |
| 730 | ); | |
| 731 | ||
| 732 | const killOpenCodeProcessGroup = (signal: NodeJS.Signals) => | |
| 733 | hostPlatform === "win32" | |
| 734 | ? child.kill({ killSignal: signal, forceKillAfter: "1 second" }).pipe(Effect.asVoid) | |
| 735 | : Effect.sync(() => { | |
| 736 | try { | |
| 737 | signalProcessGroup(Number(child.pid), signal); | |
| 738 | } catch { | |
| 739 | // The direct child may already have exited after starting the | |
| 740 | // server; the process group kill is best-effort cleanup for | |
| 741 | // any serve process left in that group. | |
| 742 | } | |
| 743 | }); | |
| 744 | const terminateChild = killOpenCodeProcessGroup("SIGTERM").pipe( | |
| 745 | Effect.andThen(Effect.sleep("1 second")), | |
| 746 | Effect.andThen(killOpenCodeProcessGroup("SIGKILL")), | |
| 747 | Effect.ignore, | |
| 748 | ); | |
| 749 | // Registered before recording, so an interrupt while the ledger writes | |
| 750 | // still stops the group. | |
| 751 | yield* Scope.addFinalizer(runtimeScope, terminateChild); | |
| 752 | const forgetServer = yield* serverLedger.track({ pid: Number(child.pid), port, args }); | |
| 753 | yield* Scope.addFinalizer(ledgerScope, forgetServer); | |
| 754 | ||
| 755 | const stdoutRef = yield* Ref.make<string | null>(""); | |
| 756 | const stderrRef = yield* Ref.make<string | null>(""); | |
| 757 | const readyDeferred = yield* Deferred.make<string, OpenCodeRuntimeError>(); | |
| 758 | ||
| 759 | const setReadyFromStdoutChunk = (chunk: string) => | |
| 760 | Ref.modify(stdoutRef, (stdout) => { | |
| 761 | if (stdout === null) { | |
| 762 | return [null, null] as const; | |
| 763 | } | |
| 764 | const nextStdout = `${stdout}${chunk}`; | |
| 765 | return [ | |
| 766 | parseServerUrlFromOutput(nextStdout), | |
| 767 | nextStdout.slice(-OPENCODE_SERVER_STARTUP_MAX_OUTPUT_CHARS), | |
| 768 | ] as const; | |
| 769 | }).pipe( | |
| 770 | Effect.flatMap((parsed) => | |
| 771 | parsed ? Deferred.succeed(readyDeferred, parsed).pipe(Effect.ignore) : Effect.void, | |
| 772 | ), | |
| 773 | ); | |
| 774 | ||
| 775 | const stdoutFiber = yield* child.stdout.pipe( | |
| 776 | Stream.decodeText(), | |
| 777 | Stream.runForEach(setReadyFromStdoutChunk), | |
| 778 | Effect.ignore, | |
| 779 | Effect.forkIn(runtimeScope), | |
| 780 | ); | |
| 781 | const stderrFiber = yield* child.stderr.pipe( | |
| 782 | Stream.decodeText(), | |
| 783 | Stream.runForEach((chunk) => | |
| 784 | Ref.update(stderrRef, (stderr) => | |
| 785 | stderr === null | |
| 786 | ? null | |
| 787 | : `${stderr}${chunk}`.slice(-OPENCODE_SERVER_STARTUP_MAX_OUTPUT_CHARS), | |
| 788 | ), | |
| 789 | ), | |
| 790 | Effect.ignore, | |
| 791 | Effect.forkIn(runtimeScope), | |
| 792 | ); | |
| 793 | ||
| 794 | const exitFiber = yield* child.exitCode.pipe( | |
| 795 | Effect.flatMap((code) => | |
| 796 | Effect.gen(function* () { | |
| 797 | const stdout = (yield* Ref.get(stdoutRef)) ?? ""; | |
| 798 | const stderr = (yield* Ref.get(stderrRef)) ?? ""; | |
| 799 | const exitCode = Number(code); | |
| 800 | yield* Deferred.fail( | |
| 801 | readyDeferred, | |
| 802 | new OpenCodeRuntimeError({ | |
| 803 | operation: "startOpenCodeServerProcess", | |
| 804 | detail: [ | |
| 805 | `OpenCode server exited before startup completed (code: ${String(exitCode)}).`, | |
| 806 | stdout.trim() ? `stdout:\n${stdout.trim()}` : null, | |
| 807 | stderr.trim() ? `stderr:\n${stderr.trim()}` : null, | |
| 808 | ] | |
| 809 | .filter(Boolean) | |
| 810 | .join("\n\n"), | |
| 811 | cause: { exitCode, stdout, stderr }, | |
| 812 | }), | |
| 813 | ).pipe(Effect.ignore); | |
| 814 | }), | |
| 815 | ), | |
| 816 | Effect.ignore, | |
| 817 | Effect.forkIn(runtimeScope), | |
| 818 | ); | |
| 819 | ||
| 820 | const readyExit = yield* Effect.exit( | |
| 821 | Deferred.await(readyDeferred).pipe(Effect.timeoutOption(timeoutMs)), | |
| 822 | ); | |
| 823 | ||
| 824 | if (Exit.isFailure(readyExit) || Option.isNone(readyExit.value)) { | |
| 825 | yield* Fiber.interruptAll([stdoutFiber, stderrFiber, exitFiber]).pipe(Effect.ignore); | |
| 826 | } | |
| 827 | ||
| 828 | if (Exit.isFailure(readyExit)) { | |
| 829 | const squashed = Cause.squash(readyExit.cause); | |
| 830 | return yield* ensureRuntimeError( | |
| 831 | "startOpenCodeServerProcess", | |
| 832 | `Failed while waiting for OpenCode server startup: ${openCodeRuntimeErrorDetail(squashed)}`, | |
| 833 | squashed, | |
| 834 | ); | |
| 835 | } | |
| 836 | ||
| 837 | const readyOption = readyExit.value; | |
| 838 | if (Option.isNone(readyOption)) { | |
| 839 | return yield* new OpenCodeRuntimeError({ | |
| 840 | operation: "startOpenCodeServerProcess", | |
| 841 | detail: `Timed out waiting for OpenCode server start after ${timeoutMs}ms.`, | |
| 842 | }); | |
| 843 | } | |
| 844 | ||
| 845 | // Keep draining both pipes until the process scope closes. Stopping the | |
| 846 | // readers can block OpenCode when its output buffers fill. Startup output | |
| 847 | // is no longer needed, so discard later output instead of retaining it. | |
| 848 | yield* Ref.set(stdoutRef, null); | |
| 849 | yield* Ref.set(stderrRef, null); | |
| 850 | ||
| 851 | const url = readyOption.value; | |
| 852 | const version = yield* ( | |
| 853 | input.verify?.(url) ?? | |
| 854 | verifyOpenCodeServerVersion( | |
| 855 | createOpenCodeSdkClient({ | |
| 856 | baseUrl: url, | |
| 857 | directory: input.directory, | |
| 858 | ...(serverPassword !== undefined ? { serverPassword } : {}), | |
| 859 | }), | |
| 860 | ) | |
| 861 | ); | |
| 862 | ||
| 863 | return { | |
| 864 | url, | |
| 865 | ...(serverPassword !== undefined ? { serverPassword } : {}), | |
| 866 | version, | |
| 867 | isRunning: child.isRunning.pipe(Effect.orElseSucceed(() => false)), | |
| 868 | exitCode: child.exitCode.pipe( | |
| 869 | Effect.map(Number), | |
| 870 | Effect.orElseSucceed(() => 0), | |
| 871 | ), | |
| 872 | } satisfies OpenCodeServerProcess; | |
| 873 | }); | |
| 874 | ||
| 875 | const connectToOpenCodeServer: OpenCodeRuntimeShape["connectToOpenCodeServer"] = (input) => { | |
| 876 | const serverUrl = input.serverUrl?.trim(); | |
| 877 | if (serverUrl) { | |
| 878 | const serverPassword = resolveOpenCodeServerPassword({ | |
| 879 | external: true, | |
| 880 | ...(input.serverPassword !== undefined ? { serverPassword: input.serverPassword } : {}), | |
| 881 | }); | |
| 882 | return verifyOpenCodeServerVersion( | |
| 883 | createOpenCodeSdkClient({ | |
| 884 | baseUrl: serverUrl, | |
| 885 | directory: input.directory, | |
| 886 | ...(serverPassword !== undefined ? { serverPassword } : {}), | |
| 887 | }), | |
| 888 | ).pipe( | |
| 889 | Effect.map((version) => ({ | |
| 890 | url: serverUrl, | |
| 891 | ...(serverPassword !== undefined ? { serverPassword } : {}), | |
| 892 | version, | |
| 893 | exitCode: null, | |
| 894 | external: true, | |
| 895 | })), | |
| 896 | ); | |
| 897 | } | |
| 898 | ||
| 899 | return startOpenCodeServerProcess({ | |
| 900 | binaryPath: input.binaryPath, | |
| 901 | directory: input.directory, | |
| 902 | ...(input.serverPassword !== undefined ? { serverPassword: input.serverPassword } : {}), | |
| 903 | ...(input.environment !== undefined ? { environment: input.environment } : {}), | |
| 904 | ...(input.port !== undefined ? { port: input.port } : {}), | |
| 905 | ...(input.hostname !== undefined ? { hostname: input.hostname } : {}), | |
| 906 | ...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}), | |
| 907 | }).pipe( | |
| 908 | Effect.map((server) => ({ | |
| 909 | url: server.url, | |
| 910 | ...(server.serverPassword !== undefined ? { serverPassword: server.serverPassword } : {}), | |
| 911 | version: server.version, | |
| 912 | exitCode: server.exitCode, | |
| 913 | external: false, | |
| 914 | })), | |
| 915 | ); | |
| 916 | }; | |
| 917 | ||
| 918 | const loadProviders = (client: OpencodeClient) => | |
| 919 | runOpenCodeSdk("provider.list", (signal) => client.provider.list(undefined, { signal })).pipe( | |
| 920 | Effect.filterMapOrFail( | |
| 921 | (list) => | |
| 922 | list.data | |
| 923 | ? Result.succeed(list.data) | |
| 924 | : Result.fail( | |
| 925 | new OpenCodeRuntimeError({ | |
| 926 | operation: "provider.list", | |
| 927 | detail: "OpenCode provider list was empty.", | |
| 928 | }), | |
| 929 | ), | |
| 930 | (result) => result, | |
| 931 | ), | |
| 932 | ); | |
| 933 | ||
| 934 | const loadAgents = (client: OpencodeClient) => | |
| 935 | runOpenCodeSdk("app.agents", (signal) => client.app.agents(undefined, { signal })).pipe( | |
| 936 | Effect.map((result) => result.data ?? []), | |
| 937 | Effect.orElseSucceed((): ReadonlyArray<Agent> => []), | |
| 938 | ); | |
| 939 | ||
| 940 | const loadOpenCodeSkills: OpenCodeRuntimeShape["loadOpenCodeSkills"] = (client) => | |
| 941 | runOpenCodeSdk("app.skills", (signal) => client.app.skills(undefined, { signal })).pipe( | |
| 942 | Effect.map((result) => | |
| 943 | (result.data ?? []).map((skill) => ({ | |
| 944 | name: skill.name, | |
| 945 | ...(skill.description === undefined ? {} : { description: skill.description }), | |
| 946 | location: skill.location, | |
| 947 | })), | |
| 948 | ), | |
| 949 | ); | |
| 950 | const loadSkills = (client: OpencodeClient) => | |
| 951 | loadOpenCodeSkills(client).pipe(Effect.orElseSucceed((): ReadonlyArray<OpenCodeSkill> => [])); | |
| 952 | ||
| 953 | const loadOpenCodeInventory: OpenCodeRuntimeShape["loadOpenCodeInventory"] = (client) => | |
| 954 | Effect.all( | |
| 955 | [ | |
| 956 | loadProviders(client), | |
| 957 | loadAgents(client), | |
| 958 | loadSkills(client), | |
| 959 | loadOpenCodeCommands(client).pipe(Effect.orElseSucceed(() => [])), | |
| 960 | ], | |
| 961 | { | |
| 962 | concurrency: "unbounded", | |
| 963 | }, | |
| 964 | ).pipe( | |
| 965 | Effect.map(([providerList, agents, skills, commands]) => ({ | |
| 966 | providerList, | |
| 967 | agents, | |
| 968 | skills, | |
| 969 | commands, | |
| 970 | })), | |
| 971 | ); | |
| 972 | ||
| 973 | const loadInventoryFromCli: OpenCodeRuntimeShape["loadInventoryFromCli"] = (input) => | |
| 974 | Effect.gen(function* () { | |
| 975 | const env = input.environment !== undefined ? { environment: input.environment } : ({} as {}); | |
| 976 | const commandContext = { cwd: input.cwd, ...env }; | |
| 977 | ||
| 978 | const runModelsCli = () => | |
| 979 | runOpenCodeCommand({ | |
| 980 | binaryPath: input.binaryPath, | |
| 981 | args: ["models", "--verbose"], | |
| 982 | ...commandContext, | |
| 983 | }).pipe(Effect.exit); | |
| 984 | const runAgentsCli = () => | |
| 985 | runOpenCodeCommand({ | |
| 986 | binaryPath: input.binaryPath, | |
| 987 | args: ["agent", "list"], | |
| 988 | ...commandContext, | |
| 989 | }).pipe(Effect.exit); | |
| 990 | const runSkillsCli = () => | |
| 991 | runOpenCodeCommand({ | |
| 992 | binaryPath: input.binaryPath, | |
| 993 | args: ["debug", "skill"], | |
| 994 | maxOutputBytes: OPENCODE_SKILL_DISCOVERY_MAX_OUTPUT_BYTES, | |
| 995 | ...commandContext, | |
| 996 | }).pipe(Effect.exit); | |
| 997 | ||
| 998 | // Every OpenCode CLI command opens the same shared SQLite database. Running them | |
| 999 | // concurrently causes "database is locked" failures, so run them one at a time. | |
| 1000 | const [initialModelsResult, initialAgentsResult, initialSkillsResult] = yield* Effect.all( | |
| 1001 | [runModelsCli(), runAgentsCli(), runSkillsCli()], | |
| 1002 | { concurrency: 1 }, | |
| 1003 | ); | |
| 1004 | let modelsResult = initialModelsResult; | |
| 1005 | let agentsResult = initialAgentsResult; | |
| 1006 | let skillsResult = initialSkillsResult; | |
| 1007 | ||
| 1008 | // Retry once after 1s on transient failures (e.g. SQLite "database is locked") | |
| 1009 | const needsModelsRetry = modelsResult._tag === "Failure" || modelsResult.value.code !== 0; | |
| 1010 | const needsAgentsRetry = agentsResult._tag === "Failure" || agentsResult.value.code !== 0; | |
| 1011 | const needsSkillsRetry = skillsResult._tag === "Failure" || skillsResult.value.code !== 0; | |
| 1012 | if (needsModelsRetry || needsAgentsRetry || needsSkillsRetry) { | |
| 1013 | yield* Effect.sleep("1 second"); | |
| 1014 | const [m2, a2, s2] = yield* Effect.all( | |
| 1015 | [ | |
| 1016 | needsModelsRetry ? runModelsCli() : Effect.succeed(modelsResult), | |
| 1017 | needsAgentsRetry ? runAgentsCli() : Effect.succeed(agentsResult), | |
| 1018 | needsSkillsRetry ? runSkillsCli() : Effect.succeed(skillsResult), | |
| 1019 | ], | |
| 1020 | { concurrency: 1 }, | |
| 1021 | ); | |
| 1022 | modelsResult = m2; | |
| 1023 | agentsResult = a2; | |
| 1024 | skillsResult = s2; | |
| 1025 | } | |
| 1026 | ||
| 1027 | if (modelsResult._tag === "Failure") { | |
| 1028 | const cause = Cause.squash(modelsResult.cause); | |
| 1029 | return yield* ensureRuntimeError( | |
| 1030 | "loadInventoryFromCli", | |
| 1031 | `Failed to load OpenCode models: ${openCodeRuntimeErrorDetail(cause)}`, | |
| 1032 | cause, | |
| 1033 | ); | |
| 1034 | } | |
| 1035 | if (modelsResult.value.code !== 0) { | |
| 1036 | return yield* new OpenCodeRuntimeError({ | |
| 1037 | operation: "loadInventoryFromCli", | |
| 1038 | detail: `OpenCode models command exited with code ${modelsResult.value.code}.`, | |
| 1039 | }); | |
| 1040 | } | |
| 1041 | ||
| 1042 | const parsed = parseModelsCliOutput(modelsResult.value.stdout); | |
| 1043 | const connected = [...parsed.connected]; | |
| 1044 | const allProviders: ProviderListResponse["all"] = [...parsed.providers.values()].map( | |
| 1045 | (provider) => ({ | |
| 1046 | id: provider.id, | |
| 1047 | name: provider.name, | |
| 1048 | source: "config" as const, | |
| 1049 | env: [], | |
| 1050 | options: {}, | |
| 1051 | models: provider.models, | |
| 1052 | }), | |
| 1053 | ); | |
| 1054 | ||
| 1055 | // Agent and skill metadata enrich the provider snapshot but are not required | |
| 1056 | // for an authoritative model inventory, so either may degrade to an empty list. | |
| 1057 | let agents: ReadonlyArray<Agent> = []; | |
| 1058 | if (agentsResult._tag === "Success" && agentsResult.value.code === 0) { | |
| 1059 | agents = parseAgentListCliOutput(agentsResult.value.stdout); | |
| 1060 | } | |
| 1061 | let skills: ReadonlyArray<OpenCodeSkill> = []; | |
| 1062 | if (skillsResult._tag === "Success" && skillsResult.value.code === 0) { | |
| 1063 | skills = parseSkillsCliOutput(skillsResult.value.stdout); | |
| 1064 | } | |
| 1065 | ||
| 1066 | return { | |
| 1067 | providerList: { all: allProviders, default: {}, connected }, | |
| 1068 | agents, | |
| 1069 | skills, | |
| 1070 | }; | |
| 1071 | }); | |
| 1072 | ||
| 1073 | const loadSkillsFromCli: OpenCodeRuntimeShape["loadSkillsFromCli"] = (input) => | |
| 1074 | runOpenCodeCommand({ | |
| 1075 | binaryPath: input.binaryPath, | |
| 1076 | args: ["debug", "skill"], | |
| 1077 | cwd: input.cwd, | |
| 1078 | maxOutputBytes: OPENCODE_SKILL_DISCOVERY_MAX_OUTPUT_BYTES, | |
| 1079 | ...(input.environment !== undefined ? { environment: input.environment } : {}), | |
| 1080 | }).pipe( | |
| 1081 | Effect.flatMap((result) => | |
| 1082 | result.code === 0 | |
| 1083 | ? Effect.succeed(parseSkillsCliOutput(result.stdout)) | |
| 1084 | : Effect.fail( | |
| 1085 | new OpenCodeRuntimeError({ | |
| 1086 | operation: "loadSkillsFromCli", | |
| 1087 | detail: `OpenCode skills command exited with code ${result.code}.`, | |
| 1088 | }), | |
| 1089 | ), | |
| 1090 | ), | |
| 1091 | ); | |
| 1092 | ||
| 1093 | return { | |
| 1094 | startOpenCodeServerProcess, | |
| 1095 | connectToOpenCodeServer, | |
| 1096 | runOpenCodeCommand, | |
| 1097 | createOpenCodeSdkClient, | |
| 1098 | loadOpenCodeInventory, | |
| 1099 | loadOpenCodeSkills, | |
| 1100 | loadInventoryFromCli, | |
| 1101 | loadSkillsFromCli, | |
| 1102 | } satisfies OpenCodeRuntimeShape; | |
| 1103 | }); | |
| 1104 | ||
| 1105 | export class OpenCodeRuntime extends Context.Service<OpenCodeRuntime, OpenCodeRuntimeShape>()( | |
| 1106 | "t3/provider/opencodeRuntime", | |
| 1107 | ) {} | |
| 1108 | ||
| 1109 | export const layer = Layer.effect(OpenCodeRuntime, makeOpenCodeRuntime).pipe( | |
| 1110 | Layer.provide(NetService.layer), | |
| 1111 | ); |
apps/server/src/provider/opencodeVersionProbe.test.ts deleted-197| ... | ... | @@ -1,197 +0,0 @@ |
| 1 | import { assert, describe, it } from "@effect/vitest"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import { HttpClient, HttpClientError, HttpClientRequest } from "effect/http"; | |
| 4 | ||
| 5 | import * as OpenCodeRuntime from "./opencodeRuntime.ts"; | |
| 6 | import { | |
| 7 | classifyOpenCodeCliVersion, | |
| 8 | makeOpenCodeRuntimeProbe, | |
| 9 | probeOpenCodeRuntime, | |
| 10 | } from "./opencodeVersionProbe.ts"; | |
| 11 | import { | |
| 12 | OPENCODE_1_RESPONSES, | |
| 13 | OPENCODE_2_RESPONSES, | |
| 14 | replayOpenCodeServer, | |
| 15 | } from "./testFixtures/opencodeProbeResponses.ts"; | |
| 16 | ||
| 17 | const noBinary = { | |
| 18 | runOpenCodeCommand: () => Effect.die("A configured server must not run the local binary"), | |
| 19 | } as unknown as OpenCodeRuntime.OpenCodeRuntimeShape; | |
| 20 | ||
| 21 | const probeServer = (serverUrl: string, serverPassword: string, http: HttpClient.HttpClient) => | |
| 22 | probeOpenCodeRuntime({ binaryPath: "opencode", serverUrl, serverPassword }).pipe( | |
| 23 | Effect.provideService(HttpClient.HttpClient, http), | |
| 24 | Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, noBinary), | |
| 25 | ); | |
| 26 | const SERVER_URL = "http://127.0.0.1:4096/"; | |
| 27 | ||
| 28 | describe("OpenCode version probe", () => { | |
| 29 | it("classifies the recorded `opencode --version` output of both versions", () => { | |
| 30 | assert.deepStrictEqual(classifyOpenCodeCliVersion("1.18.32\n"), { | |
| 31 | generation: "v1", | |
| 32 | version: "1.18.32", | |
| 33 | }); | |
| 34 | assert.deepStrictEqual(classifyOpenCodeCliVersion("opencode v2.0.18\n"), { | |
| 35 | generation: "v2", | |
| 36 | version: "2.0.18", | |
| 37 | }); | |
| 38 | assert.isUndefined(classifyOpenCodeCliVersion("opencode dev build\n")); | |
| 39 | }); | |
| 40 | ||
| 41 | it.effect("finds 2.x at /api/info without falling through to its HTML /global/health", () => | |
| 42 | Effect.gen(function* () { | |
| 43 | const paths: Array<string> = []; | |
| 44 | const probed = yield* probeServer( | |
| 45 | SERVER_URL, | |
| 46 | "pw", | |
| 47 | replayOpenCodeServer(OPENCODE_2_RESPONSES, "pw", paths), | |
| 48 | ); | |
| 49 | assert.deepStrictEqual(probed, { generation: "v2", version: "2.0.18" }); | |
| 50 | assert.deepStrictEqual(paths, ["/api/info"]); | |
| 51 | }), | |
| 52 | ); | |
| 53 | ||
| 54 | it.effect("skips the HTML 1.x serves at /api/info and finds it at /global/health", () => | |
| 55 | Effect.gen(function* () { | |
| 56 | const paths: Array<string> = []; | |
| 57 | const probed = yield* probeServer( | |
| 58 | SERVER_URL, | |
| 59 | "pw", | |
| 60 | replayOpenCodeServer(OPENCODE_1_RESPONSES, "pw", paths), | |
| 61 | ); | |
| 62 | assert.deepStrictEqual(probed, { generation: "v1", version: "1.18.32" }); | |
| 63 | assert.deepStrictEqual(paths, ["/api/info", "/global/health"]); | |
| 64 | }), | |
| 65 | ); | |
| 66 | ||
| 67 | it.effect("keeps a server URL's path prefix and query when probing", () => | |
| 68 | Effect.gen(function* () { | |
| 69 | const urls: Array<string> = []; | |
| 70 | const replay = replayOpenCodeServer(OPENCODE_1_RESPONSES, "pw"); | |
| 71 | // A reverse proxy mounting OpenCode under /opencode, reached with a routing query. | |
| 72 | const proxied = HttpClient.make((request, url) => { | |
| 73 | urls.push(url.href); | |
| 74 | return replay.execute( | |
| 75 | HttpClientRequest.setUrl( | |
| 76 | request, | |
| 77 | `http://127.0.0.1:4096${url.pathname.replace(/^\/opencode/, "")}`, | |
| 78 | ), | |
| 79 | ); | |
| 80 | }); | |
| 81 | const probed = yield* probeServer("http://proxy:8080/opencode/?route=oc", "pw", proxied); | |
| 82 | assert.strictEqual(probed.generation, "v1"); | |
| 83 | assert.deepStrictEqual(urls, [ | |
| 84 | "http://proxy:8080/opencode/api/info?route=oc", | |
| 85 | "http://proxy:8080/opencode/global/health?route=oc", | |
| 86 | ]); | |
| 87 | }), | |
| 88 | ); | |
| 89 | ||
| 90 | it.effect("reports a rejected password instead of guessing a version", () => | |
| 91 | Effect.gen(function* () { | |
| 92 | for (const responses of [OPENCODE_1_RESPONSES, OPENCODE_2_RESPONSES]) { | |
| 93 | const paths: Array<string> = []; | |
| 94 | const error = yield* Effect.flip( | |
| 95 | probeServer(SERVER_URL, "wrong", replayOpenCodeServer(responses, "pw", paths)), | |
| 96 | ); | |
| 97 | assert.match(error.detail, /401 Unauthorized/); | |
| 98 | assert.deepStrictEqual(paths, ["/api/info"]); | |
| 99 | } | |
| 100 | }), | |
| 101 | ); | |
| 102 | ||
| 103 | it.effect("sends a non-ASCII password as UTF-8, as both versions expect", () => | |
| 104 | Effect.gen(function* () { | |
| 105 | for (const [responses, password, generation] of [ | |
| 106 | [OPENCODE_1_RESPONSES, "pässwörd", "v1"], | |
| 107 | [OPENCODE_2_RESPONSES, "pass€word", "v2"], | |
| 108 | ] as const) { | |
| 109 | const probed = yield* probeServer( | |
| 110 | SERVER_URL, | |
| 111 | password, | |
| 112 | replayOpenCodeServer(responses, password), | |
| 113 | ); | |
| 114 | assert.strictEqual(probed.generation, generation); | |
| 115 | } | |
| 116 | }), | |
| 117 | ); | |
| 118 | ||
| 119 | it.effect("does not take other JSON at /api/info for OpenCode 2", () => | |
| 120 | Effect.gen(function* () { | |
| 121 | const impostor = { | |
| 122 | ...OPENCODE_1_RESPONSES, | |
| 123 | "/api/info": { status: 200, contentType: "application/json", body: '{"version":"3.4.1"}' }, | |
| 124 | "/global/health": OPENCODE_1_RESPONSES["/api/info"], | |
| 125 | }; | |
| 126 | const error = yield* Effect.flip( | |
| 127 | probeServer(SERVER_URL, "pw", replayOpenCodeServer(impostor, "pw")), | |
| 128 | ); | |
| 129 | assert.match(error.detail, /did not identify itself as OpenCode/); | |
| 130 | }), | |
| 131 | ); | |
| 132 | ||
| 133 | it.effect("never puts the configured URL or its credentials in an error detail", () => | |
| 134 | Effect.gen(function* () { | |
| 135 | const secret = "userinfo-secret"; | |
| 136 | const unreachable = HttpClient.make((request) => | |
| 137 | Effect.die(`should not be reached for ${request.url}`), | |
| 138 | ); | |
| 139 | // A fetch failure that echoes the full request URL, as Node's does. | |
| 140 | const refused = HttpClient.make((request) => | |
| 141 | Effect.fail( | |
| 142 | new HttpClientError.HttpClientError({ | |
| 143 | reason: new HttpClientError.TransportError({ | |
| 144 | request, | |
| 145 | description: `connect ECONNREFUSED ${request.url}`, | |
| 146 | }), | |
| 147 | }), | |
| 148 | ), | |
| 149 | ); | |
| 150 | for (const [serverUrl, http] of [ | |
| 151 | [`127.0.0.1:4096?token=${secret}`, unreachable], | |
| 152 | [`localhost:4096/${secret}`, unreachable], | |
| 153 | [`http://user:${secret}@10.0.0.1:4096/?token=${secret}`, refused], | |
| 154 | ] as const) { | |
| 155 | const error = yield* Effect.flip(probeServer(serverUrl, secret, http)); | |
| 156 | assert.notInclude(error.detail, secret, serverUrl); | |
| 157 | assert.notInclude(error.detail, "10.0.0.1", serverUrl); | |
| 158 | } | |
| 159 | }), | |
| 160 | ); | |
| 161 | ||
| 162 | it.effect("remembers a probed runtime but never a failure", () => | |
| 163 | Effect.gen(function* () { | |
| 164 | const outputs = ["", "opencode v2.0.18\n", "1.18.32\n"]; | |
| 165 | let calls = 0; | |
| 166 | const runtime = { | |
| 167 | runOpenCodeCommand: () => { | |
| 168 | const stdout = outputs[calls++]; | |
| 169 | return stdout | |
| 170 | ? Effect.succeed({ stdout, stderr: "", code: 0 }) | |
| 171 | : Effect.fail( | |
| 172 | new OpenCodeRuntime.OpenCodeRuntimeError({ operation: "spawn", detail: "ENOENT" }), | |
| 173 | ); | |
| 174 | }, | |
| 175 | } as unknown as OpenCodeRuntime.OpenCodeRuntimeShape; | |
| 176 | const probe = yield* makeOpenCodeRuntimeProbe( | |
| 177 | probeOpenCodeRuntime({ binaryPath: "opencode", serverUrl: "", serverPassword: "" }).pipe( | |
| 178 | Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, runtime), | |
| 179 | Effect.provideService( | |
| 180 | HttpClient.HttpClient, | |
| 181 | HttpClient.make(() => Effect.die("A local binary must not be probed over HTTP")), | |
| 182 | ), | |
| 183 | ), | |
| 184 | ); | |
| 185 | ||
| 186 | yield* Effect.flip(probe.get); | |
| 187 | assert.isTrue((yield* probe.lastSuccess)._tag === "None"); | |
| 188 | assert.strictEqual((yield* probe.get).generation, "v2"); | |
| 189 | assert.strictEqual((yield* probe.get).generation, "v2"); | |
| 190 | assert.strictEqual(calls, 2); | |
| 191 | // A status refresh re-probes, so an in-place downgrade or upgrade re-routes. | |
| 192 | assert.strictEqual((yield* probe.refresh).generation, "v1"); | |
| 193 | assert.strictEqual((yield* probe.get).generation, "v1"); | |
| 194 | assert.strictEqual(calls, 3); | |
| 195 | }), | |
| 196 | ); | |
| 197 | }); |
apps/server/src/provider/opencodeVersionProbe.ts deleted-198| ... | ... | @@ -1,198 +0,0 @@ |
| 1 | /** Detects whether an OpenCode instance runs 1.x or 2.x, so the driver can pick its runtime. */ | |
| 2 | import { parseSemver } from "@t3tools/shared/semver"; | |
| 3 | import * as Cache from "effect/Cache"; | |
| 4 | import * as Duration from "effect/Duration"; | |
| 5 | import * as Effect from "effect/Effect"; | |
| 6 | import * as Exit from "effect/Exit"; | |
| 7 | import * as Option from "effect/Option"; | |
| 8 | import * as Schema from "effect/Schema"; | |
| 9 | import { HttpClient, HttpClientRequest } from "effect/http"; | |
| 10 | ||
| 11 | import * as OpenCodeRuntime from "./opencodeRuntime.ts"; | |
| 12 | import { parseGenericCliVersion } from "@t3tools/provider-core/server/snapshotProbe"; | |
| 13 | ||
| 14 | export interface ProbedOpenCode { | |
| 15 | readonly generation: "v1" | "v2"; | |
| 16 | readonly version: string; | |
| 17 | } | |
| 18 | ||
| 19 | const OPENCODE_VERSION_PROBE_TIMEOUT = "4 seconds"; | |
| 20 | const OPENCODE_SERVER_PROBE_TIMEOUT = "5 seconds"; | |
| 21 | // 2.x's own CLI decodes `{version, pid}` from `/api/info`; requiring both keeps unrelated JSON out. | |
| 22 | const decodeApiInfo = Schema.decodeUnknownOption( | |
| 23 | Schema.fromJsonString(Schema.Struct({ version: Schema.String, pid: Schema.Int })), | |
| 24 | ); | |
| 25 | const decodeGlobalHealth = Schema.decodeUnknownOption( | |
| 26 | Schema.fromJsonString(Schema.Struct({ healthy: Schema.Literal(true), version: Schema.String })), | |
| 27 | ); | |
| 28 | ||
| 29 | function probed(version: string | null | undefined): ProbedOpenCode | undefined { | |
| 30 | const major = parseSemver(version ?? "")?.major; | |
| 31 | if (!version || major === undefined) return undefined; | |
| 32 | return { generation: major >= 2 ? "v2" : "v1", version }; | |
| 33 | } | |
| 34 | ||
| 35 | /** `opencode --version` prints `1.18.32` on 1.x and `opencode v2.0.18` on 2.x. */ | |
| 36 | export const classifyOpenCodeCliVersion = (output: string) => | |
| 37 | probed(parseGenericCliVersion(output)); | |
| 38 | ||
| 39 | /** | |
| 40 | * 2.x answers `/api/info` and 1.x answers `/global/health`. Each serves its web UI's HTML with a | |
| 41 | * 200 on the other's path, so only a JSON body counts. Both versions answer a wrong password with | |
| 42 | * a 401 on either path, so a 401 says nothing about the version. | |
| 43 | */ | |
| 44 | function classifyOpenCodeProbeResponse( | |
| 45 | path: "/api/info" | "/global/health", | |
| 46 | response: { | |
| 47 | readonly status: number; | |
| 48 | readonly contentType: string | undefined; | |
| 49 | readonly body: string; | |
| 50 | }, | |
| 51 | ): ProbedOpenCode | "unauthorized" | undefined { | |
| 52 | if (response.status === 401) return "unauthorized"; | |
| 53 | const mediaType = response.contentType?.split(";")[0]?.trim().toLowerCase(); | |
| 54 | if (response.status !== 200 || mediaType !== "application/json") return undefined; | |
| 55 | const version: Option.Option<string> = | |
| 56 | path === "/api/info" | |
| 57 | ? Option.map(decodeApiInfo(response.body), (info) => info.version) | |
| 58 | : Option.map(decodeGlobalHealth(response.body), (health) => health.version); | |
| 59 | return probed(Option.getOrUndefined(version)); | |
| 60 | } | |
| 61 | ||
| 62 | const probeOpenCodeBinary = Effect.fn("probeOpenCodeBinary")(function* ( | |
| 63 | binaryPath: string, | |
| 64 | environment: NodeJS.ProcessEnv | undefined, | |
| 65 | ) { | |
| 66 | const runtime = yield* OpenCodeRuntime.OpenCodeRuntime; | |
| 67 | const { stdout } = yield* runtime | |
| 68 | .runOpenCodeCommand({ | |
| 69 | binaryPath, | |
| 70 | args: ["--version"], | |
| 71 | ...(environment === undefined ? {} : { environment }), | |
| 72 | }) | |
| 73 | .pipe( | |
| 74 | Effect.timeoutOrElse({ | |
| 75 | duration: OPENCODE_VERSION_PROBE_TIMEOUT, | |
| 76 | orElse: () => | |
| 77 | Effect.fail( | |
| 78 | new OpenCodeRuntime.OpenCodeRuntimeError({ | |
| 79 | operation: "probeOpenCodeBinary", | |
| 80 | detail: `OpenCode CLI version probe timed out after ${OPENCODE_VERSION_PROBE_TIMEOUT}.`, | |
| 81 | }), | |
| 82 | ), | |
| 83 | }), | |
| 84 | ); | |
| 85 | const result = classifyOpenCodeCliVersion(stdout); | |
| 86 | if (result) return result; | |
| 87 | return yield* new OpenCodeRuntime.OpenCodeRuntimeError({ | |
| 88 | operation: "probeOpenCodeBinary", | |
| 89 | detail: `Unable to determine OpenCode version from \`opencode --version\` output. T3 Code requires OpenCode v${OpenCodeRuntime.MINIMUM_OPENCODE_VERSION} or newer.`, | |
| 90 | }); | |
| 91 | }); | |
| 92 | ||
| 93 | // Server failures reach clients through the provider status, so their details are fixed text: | |
| 94 | // the underlying error can carry the configured URL, its credentials, or the password header. | |
| 95 | const probeOpenCodeServer = Effect.fn("probeOpenCodeServer")(function* ( | |
| 96 | serverUrl: string, | |
| 97 | serverPassword: string, | |
| 98 | ) { | |
| 99 | const client = yield* HttpClient.HttpClient; | |
| 100 | const baseUrl = URL.parse(serverUrl.trim()); | |
| 101 | if (baseUrl?.protocol !== "http:" && baseUrl?.protocol !== "https:") { | |
| 102 | return yield* new OpenCodeRuntime.OpenCodeRuntimeError({ | |
| 103 | operation: "probeOpenCodeServer", | |
| 104 | detail: "The OpenCode server URL is not a valid http:// or https:// URL.", | |
| 105 | }); | |
| 106 | } | |
| 107 | // UTF-8, as the 1.x SDK client sends it; `HttpClientRequest.basicAuth` uses Latin-1 `btoa`. | |
| 108 | const authorization = serverPassword | |
| 109 | ? `Basic ${Buffer.from(`opencode:${serverPassword}`, "utf8").toString("base64")}` | |
| 110 | : undefined; | |
| 111 | for (const path of ["/api/info", "/global/health"] as const) { | |
| 112 | // A path prefix and query on the configured URL are kept: `/base/?x=1` → `/base/api/info?x=1`. | |
| 113 | const url = new URL(baseUrl); | |
| 114 | url.pathname = `${url.pathname.replace(/\/+$/, "")}${path}`; | |
| 115 | const request = HttpClientRequest.get(url.href); | |
| 116 | const result = yield* client | |
| 117 | .execute( | |
| 118 | authorization | |
| 119 | ? HttpClientRequest.setHeader(request, "authorization", authorization) | |
| 120 | : request, | |
| 121 | ) | |
| 122 | .pipe( | |
| 123 | Effect.flatMap((response) => | |
| 124 | Effect.map(response.text, (body) => | |
| 125 | classifyOpenCodeProbeResponse(path, { | |
| 126 | status: response.status, | |
| 127 | contentType: response.headers["content-type"], | |
| 128 | body, | |
| 129 | }), | |
| 130 | ), | |
| 131 | ), | |
| 132 | Effect.mapError( | |
| 133 | (cause) => | |
| 134 | new OpenCodeRuntime.OpenCodeRuntimeError({ | |
| 135 | operation: "probeOpenCodeServer", | |
| 136 | detail: "Couldn't reach the OpenCode server.", | |
| 137 | cause, | |
| 138 | }), | |
| 139 | ), | |
| 140 | Effect.timeoutOrElse({ | |
| 141 | duration: OPENCODE_SERVER_PROBE_TIMEOUT, | |
| 142 | orElse: () => | |
| 143 | Effect.fail( | |
| 144 | new OpenCodeRuntime.OpenCodeRuntimeError({ | |
| 145 | operation: "probeOpenCodeServer", | |
| 146 | detail: "Timed out while checking the OpenCode server version.", | |
| 147 | }), | |
| 148 | ), | |
| 149 | }), | |
| 150 | ); | |
| 151 | if (result === "unauthorized") { | |
| 152 | return yield* new OpenCodeRuntime.OpenCodeRuntimeError({ | |
| 153 | operation: "probeOpenCodeServer", | |
| 154 | detail: "401 Unauthorized: the OpenCode server rejected the password.", | |
| 155 | }); | |
| 156 | } | |
| 157 | if (result !== undefined) return result; | |
| 158 | } | |
| 159 | return yield* new OpenCodeRuntime.OpenCodeRuntimeError({ | |
| 160 | operation: "probeOpenCodeServer", | |
| 161 | detail: `The server did not identify itself as OpenCode. T3 Code requires OpenCode v${OpenCodeRuntime.MINIMUM_OPENCODE_VERSION} or newer.`, | |
| 162 | }); | |
| 163 | }); | |
| 164 | ||
| 165 | /** Probes a configured server when `serverUrl` is set, otherwise the local binary. */ | |
| 166 | export const probeOpenCodeRuntime = ( | |
| 167 | settings: { | |
| 168 | readonly binaryPath: string; | |
| 169 | readonly serverUrl: string; | |
| 170 | readonly serverPassword: string; | |
| 171 | }, | |
| 172 | environment?: NodeJS.ProcessEnv, | |
| 173 | ): Effect.Effect< | |
| 174 | ProbedOpenCode, | |
| 175 | OpenCodeRuntime.OpenCodeRuntimeError, | |
| 176 | HttpClient.HttpClient | OpenCodeRuntime.OpenCodeRuntime | |
| 177 | > => | |
| 178 | settings.serverUrl.trim().length > 0 | |
| 179 | ? probeOpenCodeServer(settings.serverUrl, settings.serverPassword) | |
| 180 | : probeOpenCodeBinary(settings.binaryPath, environment); | |
| 181 | ||
| 182 | /** | |
| 183 | * One instance's runtime, remembered after the first successful probe. Settings changes rebuild | |
| 184 | * the driver; `refresh` re-probes (status checks use it, so an in-place upgrade re-routes). A | |
| 185 | * failed probe is never remembered. `lastSuccess` never probes, for calls too hot to wait on one. | |
| 186 | */ | |
| 187 | export const makeOpenCodeRuntimeProbe = <E>(probe: Effect.Effect<ProbedOpenCode, E>) => | |
| 188 | Effect.map( | |
| 189 | Cache.makeWith(() => probe, { | |
| 190 | capacity: 1, | |
| 191 | timeToLive: (exit) => (Exit.isSuccess(exit) ? Duration.infinity : Duration.zero), | |
| 192 | }), | |
| 193 | (cache) => ({ | |
| 194 | get: Cache.get(cache, undefined), | |
| 195 | refresh: Cache.refresh(cache, undefined), | |
| 196 | lastSuccess: Cache.getSuccess(cache, undefined), | |
| 197 | }), | |
| 198 | ); |
apps/server/src/provider/providerStatusCache.ts+1-1| ... | ... | @@ -9,7 +9,7 @@ import * as FileSystem from "effect/FileSystem"; |
| 9 | 9 | import * as Path from "effect/Path"; |
| 10 | 10 | import * as Schema from "effect/Schema"; |
| 11 | 11 | |
| 12 | import { writeFileStringAtomically } from "../atomicWrite.ts"; | |
| 12 | import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; | |
| 13 | 13 | |
| 14 | 14 | const decodeProviderStatusCache = Schema.decodeUnknownEffect( |
| 15 | 15 | Schema.fromJsonString(ServerProviderSchema), |
apps/server/src/provider/testFixtures/opencodeProbeResponses.ts deleted-93| ... | ... | @@ -1,93 +0,0 @@ |
| 1 | /** | |
| 2 | * Responses recorded from `opencode serve` 1.18.32 and 2.0.18 on 2026-09-29, replayed by an | |
| 3 | * `HttpClient` so the version probe runs against real bytes. | |
| 4 | */ | |
| 5 | import * as Effect from "effect/Effect"; | |
| 6 | import { HttpClient, HttpClientResponse } from "effect/http"; | |
| 7 | ||
| 8 | interface RecordedResponse { | |
| 9 | readonly status: number; | |
| 10 | readonly contentType?: string; | |
| 11 | readonly body: string; | |
| 12 | } | |
| 13 | ||
| 14 | // Both versions serve their web UI's index page for unknown paths. Bodies are cut after the | |
| 15 | // first lines; the classifier rejects them on content type alone. | |
| 16 | const SPA_HTML = | |
| 17 | '<!doctype html>\n<html lang="en" style="background-color: var(--v2-background-bg-deep, #fafafa)">\n'; | |
| 18 | ||
| 19 | export const OPENCODE_2_RESPONSES = { | |
| 20 | "/api/info": { | |
| 21 | status: 200, | |
| 22 | contentType: "application/json", | |
| 23 | body: '{"version":"2.0.18","pid":2393556,"urls":["http://127.0.0.1:48771"],"paths":{"tmp":"/tmp/opencode"}}', | |
| 24 | }, | |
| 25 | "/global/health": { status: 200, contentType: "text/html", body: SPA_HTML }, | |
| 26 | unauthorized: { | |
| 27 | status: 401, | |
| 28 | contentType: "application/json", | |
| 29 | body: '{"_tag":"UnauthorizedError","message":"Authentication required"}', | |
| 30 | }, | |
| 31 | } satisfies Record<string, RecordedResponse>; | |
| 32 | ||
| 33 | /** | |
| 34 | * A 2.0.18 server's command and skill lists for a directory with one command | |
| 35 | * (`.opencode/command/bee.md`) and one skill (`.opencode/skills/plum`), live on | |
| 36 | * 2026-09-29. Skill contents are cut; machine-wide skills are dropped. | |
| 37 | */ | |
| 38 | export const OPENCODE_2_WORKSPACE_RESPONSES = { | |
| 39 | "/api/command": { | |
| 40 | status: 200, | |
| 41 | contentType: "application/json", | |
| 42 | 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"}]}', | |
| 43 | }, | |
| 44 | "/api/skill": { | |
| 45 | status: 200, | |
| 46 | contentType: "application/json", | |
| 47 | 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":"<!-- Built-in skill. -->"},{"id":"plum","name":"plum","description":"plum skill","path":"/work/.opencode/skills/plum/SKILL.md","content":"Say PLUM."}]}', | |
| 48 | }, | |
| 49 | } satisfies Record<string, RecordedResponse>; | |
| 50 | ||
| 51 | export const OPENCODE_1_RESPONSES = { | |
| 52 | "/api/info": { status: 200, contentType: "text/html", body: SPA_HTML }, | |
| 53 | "/global/health": { | |
| 54 | status: 200, | |
| 55 | contentType: "application/json", | |
| 56 | body: '{"healthy":true,"version":"1.18.32"}', | |
| 57 | }, | |
| 58 | // 1.x also answers a wrong password with an empty 401 on every path. | |
| 59 | unauthorized: { status: 401, body: "" }, | |
| 60 | } satisfies Record<string, RecordedResponse>; | |
| 61 | ||
| 62 | /** Replays a recorded server; credentials other than `password` (none when empty) get its 401. */ | |
| 63 | export function replayOpenCodeServer( | |
| 64 | responses: | |
| 65 | | typeof OPENCODE_1_RESPONSES | |
| 66 | | typeof OPENCODE_2_RESPONSES | |
| 67 | | (typeof OPENCODE_2_RESPONSES & typeof OPENCODE_2_WORKSPACE_RESPONSES), | |
| 68 | password: string, | |
| 69 | requestedPaths: Array<string> = [], | |
| 70 | ) { | |
| 71 | const answers: Record<string, RecordedResponse | undefined> = responses; | |
| 72 | // Both versions decode Basic credentials as UTF-8 (checked live with `pässwörd` and `pass€word`). | |
| 73 | const expected = password | |
| 74 | ? `Basic ${Buffer.from(`opencode:${password}`, "utf8").toString("base64")}` | |
| 75 | : undefined; | |
| 76 | return HttpClient.make((request) => { | |
| 77 | const path = new URL(request.url).pathname; | |
| 78 | requestedPaths.push(path); | |
| 79 | const recorded: RecordedResponse = | |
| 80 | request.headers.authorization === expected | |
| 81 | ? (answers[path] ?? responses.unauthorized) | |
| 82 | : responses.unauthorized; | |
| 83 | return Effect.succeed( | |
| 84 | HttpClientResponse.fromWeb( | |
| 85 | request, | |
| 86 | new Response(recorded.body, { | |
| 87 | status: recorded.status, | |
| 88 | headers: recorded.contentType ? { "content-type": recorded.contentType } : {}, | |
| 89 | }), | |
| 90 | ), | |
| 91 | ); | |
| 92 | }); | |
| 93 | } |
apps/server/src/server.ts+14-3| ... | ... | @@ -43,8 +43,8 @@ import * as ProviderEventIngestor from "./orchestration-v2/ProviderEventIngestor |
| 43 | 43 | import * as ModelManifest from "./provider/ModelManifest.ts"; |
| 44 | 44 | import * as ResetCreditCoordinator from "./provider/resetCreditCoordinator.ts"; |
| 45 | 45 | import * as ProviderEventLoggers from "./provider/ProviderEventLoggers.ts"; |
| 46 | import * as OpenCodeRuntime from "./provider/opencodeRuntime.ts"; | |
| 47 | import * as OpenCodeServerLedger from "./provider/OpenCodeServerLedger.ts"; | |
| 46 | import * as OpenCodeRuntime from "@t3tools/provider-opencode/server/OpenCodeRuntime"; | |
| 47 | import * as OpenCodeServerLedger from "@t3tools/provider-opencode/server/OpenCodeServerLedger"; | |
| 48 | 48 | import * as AcpRegistryCatalog from "./provider/AcpRegistryCatalog.ts"; |
| 49 | 49 | import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; |
| 50 | 50 | import * as CheckpointStore from "./checkpointing/CheckpointStore.ts"; |
| ... | ... | @@ -614,7 +614,18 @@ const layerRuntimeCoreDependencies = layerRuntimeCoreDependenciesBase.pipe( |
| 614 | 614 | // the rewritten registry reads snapshots off the instance registry and |
| 615 | 615 | // no longer transitively provides it. Exposing it at the runtime level |
| 616 | 616 | // keeps a single Live for all opencode consumers. |
| 617 | Layer.provideMerge(OpenCodeRuntime.layer.pipe(Layer.provide(OpenCodeServerLedger.layer))), | |
| 617 | Layer.provideMerge( | |
| 618 | OpenCodeRuntime.layer.pipe( | |
| 619 | Layer.provide( | |
| 620 | Layer.unwrap( | |
| 621 | Effect.gen(function* () { | |
| 622 | const config = yield* ServerConfig.ServerConfig; | |
| 623 | return OpenCodeServerLedger.layer({ stateDir: config.stateDir }); | |
| 624 | }), | |
| 625 | ), | |
| 626 | ), | |
| 627 | ), | |
| 628 | ), | |
| 618 | 629 | Layer.provideMerge(layerWorkspace), |
| 619 | 630 | Layer.provideMerge(ProjectEnrichmentService.layer), |
| 620 | 631 | Layer.provideMerge(Layer.mergeAll(NativeAppIconResolver.layer, layerProjectFaviconResolver)), |
apps/server/src/serverRuntimeState.ts+1-1| ... | ... | @@ -4,7 +4,7 @@ import * as FileSystem from "effect/FileSystem"; |
| 4 | 4 | import * as Option from "effect/Option"; |
| 5 | 5 | import * as Schema from "effect/Schema"; |
| 6 | 6 | |
| 7 | import { writeFileStringAtomically } from "./atomicWrite.ts"; | |
| 7 | import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; | |
| 8 | 8 | import type * as ServerConfig from "./config.ts"; |
| 9 | 9 | import { formatHostForUrl, isWildcardHost } from "./startupAccess.ts"; |
| 10 | 10 |
apps/server/src/serverSettings.test.ts+1-1| ... | ... | @@ -28,7 +28,7 @@ import * as SqlClient from "effect/sql/SqlClient"; |
| 28 | 28 | import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; |
| 29 | 29 | import * as ServerConfig from "./config.ts"; |
| 30 | 30 | import * as SqlitePersistence from "./persistence/Sqlite.ts"; |
| 31 | import { writeFileStringAtomically } from "./atomicWrite.ts"; | |
| 31 | import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; | |
| 32 | 32 | import * as ServerSettingsModule from "./serverSettings.ts"; |
| 33 | 33 | import { resolveProviderInstanceTerminalEnvironment } from "./terminal/Manager.ts"; |
| 34 | 34 |
apps/server/src/serverSettings.ts+1-1| ... | ... | @@ -53,7 +53,7 @@ import * as Semaphore from "effect/Semaphore"; |
| 53 | 53 | import * as Scope from "effect/Scope"; |
| 54 | 54 | import * as Stream from "effect/Stream"; |
| 55 | 55 | import * as SqlClient from "effect/sql/SqlClient"; |
| 56 | import { writeFileStringAtomically } from "./atomicWrite.ts"; | |
| 56 | import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; | |
| 57 | 57 | import { resolveSymlinkTarget } from "@t3tools/shared/symlink"; |
| 58 | 58 | import * as ServerConfig from "./config.ts"; |
| 59 | 59 | import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct"; |
apps/server/src/telemetry/Identify.ts+1-1| ... | ... | @@ -8,7 +8,7 @@ import * as Path from "effect/Path"; |
| 8 | 8 | import * as PlatformError from "effect/PlatformError"; |
| 9 | 9 | import * as Schema from "effect/Schema"; |
| 10 | 10 | |
| 11 | import { writeFileStringAtomically } from "../atomicWrite.ts"; | |
| 11 | import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; | |
| 12 | 12 | import * as ServerConfig from "../config.ts"; |
| 13 | 13 | |
| 14 | 14 | /** |
apps/server/src/textGeneration/OpenCode2TextGeneration.test.ts+4-6| ... | ... | @@ -4,15 +4,13 @@ import { ProviderInstanceId } from "@t3tools/contracts"; |
| 4 | 4 | import * as Effect from "effect/Effect"; |
| 5 | 5 | import * as Layer from "effect/Layer"; |
| 6 | 6 | |
| 7 | import * as ServerConfig from "../config.ts"; | |
| 7 | import { layerTestProviderHost } from "@t3tools/provider-testing/host"; | |
| 8 | 8 | import * as OpenCode2AdapterV2Testkit from "../orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts"; |
| 9 | import * as OpenCode2Server from "../provider/opencode2/OpenCode2Server.ts"; | |
| 10 | import * as OpenCode2TextGeneration from "./OpenCode2TextGeneration.ts"; | |
| 9 | import * as OpenCode2Server from "@t3tools/provider-opencode/server/v2/OpenCode2Server"; | |
| 10 | import * as OpenCode2TextGeneration from "@t3tools/provider-opencode/server/v2/textGeneration"; | |
| 11 | 11 | import { OPENCODE2_TITLE_GENERATION } from "./OpenCode2TextGeneration.fixture.ts"; |
| 12 | 12 | |
| 13 | const layer = ServerConfig.ServerConfig.layerTest(process.cwd(), { | |
| 14 | prefix: "t3code-opencode2-text-generation-test-", | |
| 15 | }).pipe(Layer.provideMerge(NodeServices.layer)); | |
| 13 | const layer = layerTestProviderHost().pipe(Layer.provideMerge(NodeServices.layer)); | |
| 16 | 14 | |
| 17 | 15 | it.layer(layer)("OpenCode2TextGeneration", (it) => { |
| 18 | 16 | it.effect("generates a title in a temporary session on the free tier and removes it", () => |
apps/server/src/textGeneration/OpenCode2TextGeneration.ts deleted-206| ... | ... | @@ -1,206 +0,0 @@ |
| 1 | /** | |
| 2 | * Text generation on an OpenCode 2 server. Both of its `generate` endpoints | |
| 3 | * answer 403 on OpenCode's free tier ("free tier can only be used from within | |
| 4 | * OpenCode"), so each request runs as a prompt in a temporary session, as 1.x | |
| 5 | * does, and the session is removed afterwards. | |
| 6 | * | |
| 7 | * @module textGeneration/OpenCode2TextGeneration | |
| 8 | */ | |
| 9 | import { AbsolutePath, Location, Model, Provider, Session } from "@opencode/client/effect"; | |
| 10 | import { TextGenerationError } from "@t3tools/contracts"; | |
| 11 | import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; | |
| 12 | import * as Deferred from "effect/Deferred"; | |
| 13 | import * as Effect from "effect/Effect"; | |
| 14 | import * as Schema from "effect/Schema"; | |
| 15 | import * as Stream from "effect/Stream"; | |
| 16 | ||
| 17 | import * as ServerConfig from "../config.ts"; | |
| 18 | import { resolveAttachmentPath } from "../attachmentStore.ts"; | |
| 19 | import type { OpenCode2Connection } from "../provider/opencode2/OpenCode2Server.ts"; | |
| 20 | import * as OpenCode2Server from "../provider/opencode2/OpenCode2Server.ts"; | |
| 21 | import { parseOpenCodeModelSlug } from "../provider/opencodeRuntime.ts"; | |
| 22 | import * as TextGenerationOperations from "@t3tools/provider-core/server/textGenerationOperations"; | |
| 23 | ||
| 24 | const isTextGenerationError = Schema.is(TextGenerationError); | |
| 25 | ||
| 26 | /** A generation that takes this long is stuck; its session is stopped and removed. */ | |
| 27 | const GENERATION_TIMEOUT = "3 minutes"; | |
| 28 | ||
| 29 | /** | |
| 30 | * Nothing in a text generation needs a tool, so every tool asks and T3 is not | |
| 31 | * there to answer. Denying `shell` or `read` outright gets the whole session | |
| 32 | * refused on OpenCode's free models, and an ask that is never answered would | |
| 33 | * hang, so asks are rejected as they arrive. | |
| 34 | */ | |
| 35 | const GENERATION_PERMISSIONS = [{ action: "*", resource: "*", effect: "ask" }] as const; | |
| 36 | ||
| 37 | /** How a generation ended. A failure's cause is the provider's own error, kept out of `detail`. */ | |
| 38 | type Outcome = | |
| 39 | | { readonly _tag: "text"; readonly text: string } | |
| 40 | | { readonly _tag: "failed"; readonly detail: string; readonly cause?: unknown }; | |
| 41 | ||
| 42 | const runOnServer = ( | |
| 43 | connection: OpenCode2Connection, | |
| 44 | input: TextGenerationOperations.Request<Schema.Top>, | |
| 45 | attachmentsDir: string, | |
| 46 | ) => | |
| 47 | Effect.gen(function* () { | |
| 48 | const { client } = connection; | |
| 49 | const parsed = parseOpenCodeModelSlug(input.modelSelection.model); | |
| 50 | if (parsed === null) { | |
| 51 | return yield* new TextGenerationError({ | |
| 52 | operation: input.operation, | |
| 53 | detail: "OpenCode model selection must use the 'provider/model' format.", | |
| 54 | }); | |
| 55 | } | |
| 56 | const variant = getModelSelectionStringOptionValue(input.modelSelection, "variant"); | |
| 57 | const outcome = yield* Deferred.make<Outcome>(); | |
| 58 | let sessionId: string | undefined; | |
| 59 | const texts = new Map<string, string>(); | |
| 60 | // Subscribed first: the stream is volatile, and the reply arrives on it. | |
| 61 | const events = yield* connection.events; | |
| 62 | yield* events.pipe( | |
| 63 | Stream.runForEach((event) => { | |
| 64 | // An execution end a newer server sent in a shape this build cannot | |
| 65 | // read still ends the generation, as it ends a turn in the adapter. | |
| 66 | if (event.type === "unreadable.execution.ended") { | |
| 67 | if (event.sessionID !== sessionId) return Effect.void; | |
| 68 | return Deferred.succeed( | |
| 69 | outcome, | |
| 70 | event.executionType === "session.execution.succeeded" | |
| 71 | ? { _tag: "text", text: [...texts.values()].join("\n").trim() } | |
| 72 | : { | |
| 73 | _tag: "failed", | |
| 74 | detail: "OpenCode ended the generation in a way this version cannot read.", | |
| 75 | cause: event, | |
| 76 | }, | |
| 77 | ); | |
| 78 | } | |
| 79 | if ( | |
| 80 | sessionId === undefined || | |
| 81 | !("data" in event) || | |
| 82 | !("sessionID" in event.data) || | |
| 83 | event.data.sessionID !== sessionId | |
| 84 | ) { | |
| 85 | return Effect.void; | |
| 86 | } | |
| 87 | switch (event.type) { | |
| 88 | case "session.text.ended": | |
| 89 | texts.set(`${event.data.assistantMessageID}:${event.data.ordinal}`, event.data.text); | |
| 90 | return Effect.void; | |
| 91 | case "permission.asked": | |
| 92 | return client.permission | |
| 93 | .reply({ | |
| 94 | sessionID: event.data.sessionID, | |
| 95 | requestID: event.data.id, | |
| 96 | decision: "reject", | |
| 97 | message: "Tools are not available while generating text.", | |
| 98 | }) | |
| 99 | .pipe(Effect.ignore({ log: true })); | |
| 100 | case "session.execution.succeeded": | |
| 101 | return Deferred.succeed(outcome, { | |
| 102 | _tag: "text", | |
| 103 | text: [...texts.values()].join("\n").trim(), | |
| 104 | }); | |
| 105 | case "session.execution.failed": | |
| 106 | return Deferred.succeed(outcome, { | |
| 107 | _tag: "failed", | |
| 108 | detail: "OpenCode could not generate the text.", | |
| 109 | cause: event.data.error, | |
| 110 | }); | |
| 111 | case "session.execution.interrupted": | |
| 112 | return Deferred.succeed(outcome, { | |
| 113 | _tag: "failed", | |
| 114 | detail: "OpenCode stopped the generation.", | |
| 115 | }); | |
| 116 | default: | |
| 117 | return Effect.void; | |
| 118 | } | |
| 119 | }), | |
| 120 | // The reply only arrives on this stream, so a lost stream ends the wait. | |
| 121 | Effect.exit, | |
| 122 | Effect.flatMap((exit) => | |
| 123 | Deferred.succeed(outcome, { | |
| 124 | _tag: "failed", | |
| 125 | detail: "The OpenCode event stream was lost.", | |
| 126 | cause: exit, | |
| 127 | }), | |
| 128 | ), | |
| 129 | Effect.forkScoped, | |
| 130 | ); | |
| 131 | const session = yield* client.session.create({ | |
| 132 | title: `T3 Code ${input.operation}`, | |
| 133 | location: Location.PublicRef.make({ directory: AbsolutePath.make(input.cwd) }), | |
| 134 | model: Model.Ref.make({ | |
| 135 | providerID: Provider.ID.make(parsed.providerID), | |
| 136 | id: Model.ID.make(parsed.modelID), | |
| 137 | ...(variant === undefined ? {} : { variant: Model.VariantID.make(variant) }), | |
| 138 | }), | |
| 139 | permissions: GENERATION_PERMISSIONS, | |
| 140 | }); | |
| 141 | sessionId = session.id; | |
| 142 | yield* Effect.addFinalizer(() => | |
| 143 | client.session | |
| 144 | .remove({ sessionID: session.id }) | |
| 145 | .pipe(Effect.timeout("5 seconds"), Effect.ignore({ log: true })), | |
| 146 | ); | |
| 147 | const images = (input.attachments ?? []).flatMap((attachment) => { | |
| 148 | if (attachment.type !== "image") return []; | |
| 149 | const path = resolveAttachmentPath({ attachmentsDir, attachment }); | |
| 150 | return path === null ? [] : [{ uri: `file://${path}`, name: attachment.name }]; | |
| 151 | }); | |
| 152 | yield* client.session.prompt({ | |
| 153 | sessionID: session.id, | |
| 154 | text: input.prompt, | |
| 155 | ...(images.length === 0 ? {} : { files: images }), | |
| 156 | }); | |
| 157 | const result = yield* Deferred.await(outcome).pipe( | |
| 158 | Effect.timeoutOrElse({ | |
| 159 | duration: GENERATION_TIMEOUT, | |
| 160 | orElse: () => | |
| 161 | client.session.interrupt({ sessionID: Session.ID.make(session.id) }).pipe( | |
| 162 | Effect.ignore, | |
| 163 | Effect.as<Outcome>({ | |
| 164 | _tag: "failed", | |
| 165 | detail: "OpenCode did not finish generating in time.", | |
| 166 | }), | |
| 167 | ), | |
| 168 | }), | |
| 169 | ); | |
| 170 | if (result._tag === "failed") { | |
| 171 | return yield* new TextGenerationError({ | |
| 172 | operation: input.operation, | |
| 173 | detail: result.detail, | |
| 174 | ...(result.cause === undefined ? {} : { cause: result.cause }), | |
| 175 | }); | |
| 176 | } | |
| 177 | if (result.text.length === 0) { | |
| 178 | return yield* new TextGenerationError({ | |
| 179 | operation: input.operation, | |
| 180 | detail: "OpenCode returned empty output.", | |
| 181 | }); | |
| 182 | } | |
| 183 | return result.text; | |
| 184 | }).pipe(Effect.scoped); | |
| 185 | ||
| 186 | /** Text generation for an instance whose server is OpenCode 2. */ | |
| 187 | export const make = Effect.fn("OpenCode2TextGeneration.make")(function* () { | |
| 188 | const server = yield* OpenCode2Server.OpenCode2Server; | |
| 189 | const { attachmentsDir } = yield* ServerConfig.ServerConfig; | |
| 190 | const run: TextGenerationOperations.Runner = (input) => | |
| 191 | server | |
| 192 | .withConnection((connection) => runOnServer(connection, input, attachmentsDir)) | |
| 193 | .pipe( | |
| 194 | Effect.mapError((cause) => | |
| 195 | isTextGenerationError(cause) | |
| 196 | ? cause | |
| 197 | : new TextGenerationError({ | |
| 198 | operation: input.operation, | |
| 199 | detail: "OpenCode text generation failed.", | |
| 200 | cause, | |
| 201 | }), | |
| 202 | ), | |
| 203 | Effect.flatMap((raw) => TextGenerationOperations.decodeJsonReply(input, "OpenCode", raw)), | |
| 204 | ); | |
| 205 | return TextGenerationOperations.fromRunner("OpenCode2TextGeneration", run); | |
| 206 | }); |
apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts deleted-616| ... | ... | @@ -1,616 +0,0 @@ |
| 1 | import { OpenCodeSettings, ProviderInstanceId, TextGenerationError } from "@t3tools/contracts"; | |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 3 | import { it } from "@effect/vitest"; | |
| 4 | import * as Duration from "effect/Duration"; | |
| 5 | import * as Effect from "effect/Effect"; | |
| 6 | import * as Layer from "effect/Layer"; | |
| 7 | import * as Schema from "effect/Schema"; | |
| 8 | import * as TestClock from "effect/testing/TestClock"; | |
| 9 | import * as NetService from "@t3tools/shared/Net"; | |
| 10 | import { beforeEach, expect } from "vite-plus/test"; | |
| 11 | ||
| 12 | import * as ServerConfig from "../config.ts"; | |
| 13 | import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts"; | |
| 14 | import * as OpenCodeServerOwner from "../provider/OpenCodeServerOwner.ts"; | |
| 15 | import * as OpenCodeTextGeneration from "./OpenCodeTextGeneration.ts"; | |
| 16 | import * as TextGeneration from "./TextGeneration.ts"; | |
| 17 | ||
| 18 | const runtimeMock = { | |
| 19 | state: { | |
| 20 | startCalls: [] as string[], | |
| 21 | promptUrls: [] as string[], | |
| 22 | promptParts: [] as ReadonlyArray<unknown>[], | |
| 23 | authHeaders: [] as Array<string | null>, | |
| 24 | closeCalls: [] as string[], | |
| 25 | sessionCreateCalls: 0, | |
| 26 | connectionError: undefined as Error | undefined, | |
| 27 | sessionCreateError: undefined as unknown, | |
| 28 | sessionResult: undefined as { data?: { id: string } } | undefined, | |
| 29 | promptRequestError: undefined as unknown, | |
| 30 | promptResult: undefined as | |
| 31 | | { data?: { info?: { error?: unknown }; parts?: Array<unknown> } } | |
| 32 | | undefined, | |
| 33 | }, | |
| 34 | reset() { | |
| 35 | this.state.startCalls.length = 0; | |
| 36 | this.state.promptUrls.length = 0; | |
| 37 | this.state.promptParts.length = 0; | |
| 38 | this.state.authHeaders.length = 0; | |
| 39 | this.state.closeCalls.length = 0; | |
| 40 | this.state.sessionCreateCalls = 0; | |
| 41 | this.state.connectionError = undefined; | |
| 42 | this.state.sessionCreateError = undefined; | |
| 43 | this.state.sessionResult = undefined; | |
| 44 | this.state.promptRequestError = undefined; | |
| 45 | this.state.promptResult = undefined; | |
| 46 | }, | |
| 47 | }; | |
| 48 | ||
| 49 | const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = { | |
| 50 | startOpenCodeServerProcess: ({ binaryPath, serverPassword, environment }) => | |
| 51 | Effect.gen(function* () { | |
| 52 | const index = runtimeMock.state.startCalls.length + 1; | |
| 53 | const url = `http://127.0.0.1:${4_300 + index}`; | |
| 54 | runtimeMock.state.startCalls.push(binaryPath); | |
| 55 | // The production runtime binds server lifetime to the caller's scope. | |
| 56 | // Mirror that here so the closeCalls probe observes scope close. | |
| 57 | yield* Effect.addFinalizer(() => | |
| 58 | Effect.sync(() => { | |
| 59 | runtimeMock.state.closeCalls.push(url); | |
| 60 | }), | |
| 61 | ); | |
| 62 | const effectiveServerPassword = OpenCodeRuntime.resolveOpenCodeServerPassword({ | |
| 63 | external: false, | |
| 64 | ...(serverPassword !== undefined ? { serverPassword } : {}), | |
| 65 | ...(environment !== undefined ? { environment } : {}), | |
| 66 | }); | |
| 67 | return { | |
| 68 | url, | |
| 69 | ...(effectiveServerPassword !== undefined | |
| 70 | ? { serverPassword: effectiveServerPassword } | |
| 71 | : {}), | |
| 72 | version: "1.14.19", | |
| 73 | isRunning: Effect.succeed(true), | |
| 74 | exitCode: Effect.never, | |
| 75 | }; | |
| 76 | }), | |
| 77 | connectToOpenCodeServer: ({ serverUrl, serverPassword }) => | |
| 78 | runtimeMock.state.connectionError | |
| 79 | ? Effect.fail( | |
| 80 | new OpenCodeRuntime.OpenCodeRuntimeError({ | |
| 81 | operation: "global.health", | |
| 82 | detail: runtimeMock.state.connectionError.message, | |
| 83 | cause: runtimeMock.state.connectionError, | |
| 84 | }), | |
| 85 | ) | |
| 86 | : Effect.succeed({ | |
| 87 | url: serverUrl ?? "http://127.0.0.1:4301", | |
| 88 | ...(serverPassword ? { serverPassword } : {}), | |
| 89 | version: "1.14.19", | |
| 90 | exitCode: null, | |
| 91 | external: Boolean(serverUrl), | |
| 92 | }), | |
| 93 | runOpenCodeCommand: () => Effect.succeed({ stdout: "", stderr: "", code: 0 }), | |
| 94 | createOpenCodeSdkClient: ({ baseUrl, serverPassword }) => | |
| 95 | ({ | |
| 96 | session: { | |
| 97 | create: async () => { | |
| 98 | runtimeMock.state.sessionCreateCalls += 1; | |
| 99 | if (runtimeMock.state.sessionCreateError !== undefined) { | |
| 100 | throw runtimeMock.state.sessionCreateError; | |
| 101 | } | |
| 102 | return runtimeMock.state.sessionResult ?? { data: { id: `${baseUrl}/session` } }; | |
| 103 | }, | |
| 104 | prompt: async (input: { readonly parts: ReadonlyArray<unknown> }) => { | |
| 105 | runtimeMock.state.promptUrls.push(baseUrl); | |
| 106 | runtimeMock.state.promptParts.push(input.parts); | |
| 107 | runtimeMock.state.authHeaders.push( | |
| 108 | serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null, | |
| 109 | ); | |
| 110 | if (runtimeMock.state.promptRequestError !== undefined) { | |
| 111 | throw runtimeMock.state.promptRequestError; | |
| 112 | } | |
| 113 | return ( | |
| 114 | runtimeMock.state.promptResult ?? { | |
| 115 | data: { | |
| 116 | parts: [ | |
| 117 | { | |
| 118 | type: "text", | |
| 119 | text: JSON.stringify({ | |
| 120 | subject: "Improve OpenCode reuse", | |
| 121 | body: "Reuse one server for the full action.", | |
| 122 | }), | |
| 123 | }, | |
| 124 | ], | |
| 125 | }, | |
| 126 | } | |
| 127 | ); | |
| 128 | }, | |
| 129 | }, | |
| 130 | }) as unknown as ReturnType<OpenCodeRuntime.OpenCodeRuntimeShape["createOpenCodeSdkClient"]>, | |
| 131 | loadOpenCodeInventory: () => | |
| 132 | Effect.fail( | |
| 133 | new OpenCodeRuntime.OpenCodeRuntimeError({ | |
| 134 | operation: "loadOpenCodeInventory", | |
| 135 | detail: "OpenCodeRuntimeTestDouble.loadOpenCodeInventory not used in this test", | |
| 136 | cause: null, | |
| 137 | }), | |
| 138 | ), | |
| 139 | loadOpenCodeSkills: () => Effect.succeed([]), | |
| 140 | loadInventoryFromCli: () => | |
| 141 | Effect.fail( | |
| 142 | new OpenCodeRuntime.OpenCodeRuntimeError({ | |
| 143 | operation: "loadInventoryFromCli", | |
| 144 | detail: "OpenCodeRuntimeTestDouble.loadInventoryFromCli not used in this test", | |
| 145 | cause: null, | |
| 146 | }), | |
| 147 | ), | |
| 148 | loadSkillsFromCli: () => Effect.succeed([]), | |
| 149 | }; | |
| 150 | ||
| 151 | const DEFAULT_TEST_MODEL_SELECTION = { | |
| 152 | instanceId: ProviderInstanceId.make("opencode"), | |
| 153 | model: "openai/gpt-5", | |
| 154 | }; | |
| 155 | const DEFAULT_COMMIT_MESSAGE_INPUT = { | |
| 156 | cwd: process.cwd(), | |
| 157 | branch: "feature/opencode-reuse", | |
| 158 | stagedSummary: "M README.md", | |
| 159 | stagedPatch: "diff --git a/README.md b/README.md", | |
| 160 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | |
| 161 | }; | |
| 162 | ||
| 163 | const OPENCODE_TEXT_GENERATION_IDLE_TTL_MS = 30_000; | |
| 164 | ||
| 165 | const layerOpenCodeTextGenerationTest = Layer.succeed( | |
| 166 | OpenCodeRuntime.OpenCodeRuntime, | |
| 167 | OpenCodeRuntimeTestDouble, | |
| 168 | ).pipe( | |
| 169 | Layer.provideMerge( | |
| 170 | ServerConfig.ServerConfig.layerTest(process.cwd(), { | |
| 171 | prefix: "t3code-opencode-text-generation-test-", | |
| 172 | }), | |
| 173 | ), | |
| 174 | Layer.provideMerge(NetService.layer), | |
| 175 | Layer.provideMerge(NodeServices.layer), | |
| 176 | ); | |
| 177 | ||
| 178 | const layerOpenCodeTextGenerationExistingServerTest = Layer.succeed( | |
| 179 | OpenCodeRuntime.OpenCodeRuntime, | |
| 180 | OpenCodeRuntimeTestDouble, | |
| 181 | ).pipe( | |
| 182 | Layer.provideMerge( | |
| 183 | ServerConfig.ServerConfig.layerTest(process.cwd(), { | |
| 184 | prefix: "t3code-opencode-text-generation-existing-server-test-", | |
| 185 | }), | |
| 186 | ), | |
| 187 | Layer.provideMerge(NetService.layer), | |
| 188 | Layer.provideMerge(NodeServices.layer), | |
| 189 | ); | |
| 190 | ||
| 191 | const DEFAULT_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({ | |
| 192 | binaryPath: "fake-opencode", | |
| 193 | }); | |
| 194 | const LOCAL_AUTH_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({ | |
| 195 | binaryPath: "fake-opencode", | |
| 196 | serverPassword: "secret-password", | |
| 197 | }); | |
| 198 | const EXISTING_SERVER_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({ | |
| 199 | binaryPath: "fake-opencode", | |
| 200 | serverUrl: "http://127.0.0.1:9999", | |
| 201 | serverPassword: "secret-password", | |
| 202 | }); | |
| 203 | const EXTERNAL_SERVER_WITHOUT_AUTH_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({ | |
| 204 | binaryPath: "fake-opencode", | |
| 205 | serverUrl: "http://127.0.0.1:9999", | |
| 206 | }); | |
| 207 | ||
| 208 | function withOpenCodeTextGeneration<A, E, R>( | |
| 209 | settings: OpenCodeSettings, | |
| 210 | effectFn: (textGeneration: TextGeneration.TextGeneration["Service"]) => Effect.Effect<A, E, R>, | |
| 211 | environment?: NodeJS.ProcessEnv, | |
| 212 | ) { | |
| 213 | return Effect.gen(function* () { | |
| 214 | const serverOwner = yield* OpenCodeServerOwner.make({ | |
| 215 | binaryPath: settings.binaryPath, | |
| 216 | directory: process.cwd(), | |
| 217 | ...(settings.serverPassword ? { serverPassword: settings.serverPassword } : {}), | |
| 218 | ...(environment ? { environment } : {}), | |
| 219 | }); | |
| 220 | const textGeneration = yield* OpenCodeTextGeneration.makeOpenCodeTextGeneration(settings).pipe( | |
| 221 | Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner), | |
| 222 | ); | |
| 223 | return yield* effectFn(textGeneration); | |
| 224 | }).pipe(Effect.scoped); | |
| 225 | } | |
| 226 | ||
| 227 | beforeEach(() => { | |
| 228 | runtimeMock.reset(); | |
| 229 | }); | |
| 230 | ||
| 231 | const advanceIdleClock = Effect.gen(function* () { | |
| 232 | yield* Effect.yieldNow; | |
| 233 | yield* TestClock.adjust(Duration.millis(OPENCODE_TEXT_GENERATION_IDLE_TTL_MS + 1)); | |
| 234 | yield* Effect.yieldNow; | |
| 235 | }); | |
| 236 | ||
| 237 | it.layer(layerOpenCodeTextGenerationTest)("OpenCodeTextGeneration", (it) => { | |
| 238 | it.effect("excludes generic files from thread title generation", () => | |
| 239 | withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => | |
| 240 | Effect.gen(function* () { | |
| 241 | runtimeMock.state.promptResult = { | |
| 242 | data: { | |
| 243 | parts: [{ type: "text", text: '{"title":"Review uploaded report"}' }], | |
| 244 | }, | |
| 245 | }; | |
| 246 | ||
| 247 | yield* textGeneration.generateThreadTitle({ | |
| 248 | cwd: process.cwd(), | |
| 249 | message: "Review these attachments.", | |
| 250 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | |
| 251 | attachments: [ | |
| 252 | { | |
| 253 | type: "image", | |
| 254 | id: "thread-image-attachment", | |
| 255 | name: "screenshot.png", | |
| 256 | mimeType: "image/png", | |
| 257 | sizeBytes: 3, | |
| 258 | }, | |
| 259 | { | |
| 260 | type: "file", | |
| 261 | id: "thread-report-attachment-pdf", | |
| 262 | name: "report.pdf", | |
| 263 | mimeType: "application/pdf", | |
| 264 | sizeBytes: 42, | |
| 265 | }, | |
| 266 | ], | |
| 267 | }); | |
| 268 | ||
| 269 | expect(runtimeMock.state.promptParts[0]).toEqual([ | |
| 270 | expect.objectContaining({ type: "text" }), | |
| 271 | expect.objectContaining({ type: "file", filename: "screenshot.png" }), | |
| 272 | ]); | |
| 273 | }), | |
| 274 | ), | |
| 275 | ); | |
| 276 | ||
| 277 | it.effect("passes configured authentication to a locally spawned server", () => | |
| 278 | withOpenCodeTextGeneration(LOCAL_AUTH_OPENCODE_SETTINGS, (textGeneration) => | |
| 279 | Effect.gen(function* () { | |
| 280 | yield* textGeneration.generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT); | |
| 281 | ||
| 282 | expect(runtimeMock.state.startCalls).toEqual(["fake-opencode"]); | |
| 283 | expect(runtimeMock.state.authHeaders).toEqual([ | |
| 284 | `Basic ${btoa("opencode:secret-password")}`, | |
| 285 | ]); | |
| 286 | }), | |
| 287 | ), | |
| 288 | ); | |
| 289 | ||
| 290 | it.effect("uses an environment-only password for a locally spawned server", () => | |
| 291 | withOpenCodeTextGeneration( | |
| 292 | DEFAULT_OPENCODE_SETTINGS, | |
| 293 | (textGeneration) => | |
| 294 | Effect.gen(function* () { | |
| 295 | yield* textGeneration.generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT); | |
| 296 | ||
| 297 | expect(runtimeMock.state.authHeaders).toEqual([ | |
| 298 | `Basic ${btoa("opencode:environment-password")}`, | |
| 299 | ]); | |
| 300 | }), | |
| 301 | { OPENCODE_SERVER_PASSWORD: "environment-password" }, | |
| 302 | ), | |
| 303 | ); | |
| 304 | ||
| 305 | it.effect("uses settings auth when the local environment password differs", () => | |
| 306 | withOpenCodeTextGeneration( | |
| 307 | LOCAL_AUTH_OPENCODE_SETTINGS, | |
| 308 | (textGeneration) => | |
| 309 | Effect.gen(function* () { | |
| 310 | yield* textGeneration.generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT); | |
| 311 | ||
| 312 | expect(runtimeMock.state.authHeaders).toEqual([ | |
| 313 | `Basic ${btoa("opencode:secret-password")}`, | |
| 314 | ]); | |
| 315 | }), | |
| 316 | { OPENCODE_SERVER_PASSWORD: "environment-password" }, | |
| 317 | ), | |
| 318 | ); | |
| 319 | ||
| 320 | it.effect("reuses a warm server across back-to-back requests and closes it after idling", () => | |
| 321 | withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => | |
| 322 | Effect.gen(function* () { | |
| 323 | yield* textGeneration.generateCommitMessage({ | |
| 324 | cwd: process.cwd(), | |
| 325 | branch: "feature/opencode-reuse", | |
| 326 | stagedSummary: "M README.md", | |
| 327 | stagedPatch: "diff --git a/README.md b/README.md", | |
| 328 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | |
| 329 | }); | |
| 330 | yield* textGeneration.generateCommitMessage({ | |
| 331 | cwd: process.cwd(), | |
| 332 | branch: "feature/opencode-reuse", | |
| 333 | stagedSummary: "M README.md", | |
| 334 | stagedPatch: "diff --git a/README.md b/README.md", | |
| 335 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | |
| 336 | }); | |
| 337 | ||
| 338 | expect(runtimeMock.state.startCalls).toEqual(["fake-opencode"]); | |
| 339 | expect(runtimeMock.state.promptUrls).toEqual([ | |
| 340 | "http://127.0.0.1:4301", | |
| 341 | "http://127.0.0.1:4301", | |
| 342 | ]); | |
| 343 | expect(runtimeMock.state.closeCalls).toEqual([]); | |
| 344 | ||
| 345 | yield* advanceIdleClock; | |
| 346 | ||
| 347 | expect(runtimeMock.state.closeCalls).toEqual(["http://127.0.0.1:4301"]); | |
| 348 | }), | |
| 349 | ).pipe(Effect.provide(TestClock.layer())), | |
| 350 | ); | |
| 351 | ||
| 352 | it.effect("starts a new server after the warm server idles out", () => | |
| 353 | withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => | |
| 354 | Effect.gen(function* () { | |
| 355 | yield* textGeneration.generateCommitMessage({ | |
| 356 | cwd: process.cwd(), | |
| 357 | branch: "feature/opencode-reuse", | |
| 358 | stagedSummary: "M README.md", | |
| 359 | stagedPatch: "diff --git a/README.md b/README.md", | |
| 360 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | |
| 361 | }); | |
| 362 | ||
| 363 | yield* advanceIdleClock; | |
| 364 | ||
| 365 | yield* textGeneration.generateCommitMessage({ | |
| 366 | cwd: process.cwd(), | |
| 367 | branch: "feature/opencode-reuse", | |
| 368 | stagedSummary: "M README.md", | |
| 369 | stagedPatch: "diff --git a/README.md b/README.md", | |
| 370 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | |
| 371 | }); | |
| 372 | ||
| 373 | expect(runtimeMock.state.startCalls).toEqual(["fake-opencode", "fake-opencode"]); | |
| 374 | expect(runtimeMock.state.promptUrls).toEqual([ | |
| 375 | "http://127.0.0.1:4301", | |
| 376 | "http://127.0.0.1:4302", | |
| 377 | ]); | |
| 378 | expect(runtimeMock.state.closeCalls).toEqual(["http://127.0.0.1:4301"]); | |
| 379 | }), | |
| 380 | ).pipe(Effect.provide(TestClock.layer())), | |
| 381 | ); | |
| 382 | ||
| 383 | it.effect("preserves the SDK cause when session creation fails", () => | |
| 384 | withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => | |
| 385 | Effect.gen(function* () { | |
| 386 | const sdkCause = new Error("session endpoint unavailable"); | |
| 387 | runtimeMock.state.sessionCreateError = sdkCause; | |
| 388 | ||
| 389 | const error = yield* textGeneration | |
| 390 | .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) | |
| 391 | .pipe(Effect.flip); | |
| 392 | ||
| 393 | expect(error).toBeInstanceOf(TextGenerationError); | |
| 394 | expect(error.message).toContain("OpenCode session.create request failed."); | |
| 395 | expect(error.cause).toMatchObject({ | |
| 396 | _tag: "OpenCodeTextGenerationSessionRequestError", | |
| 397 | operation: "generateCommitMessage", | |
| 398 | cwd: process.cwd(), | |
| 399 | cause: sdkCause, | |
| 400 | }); | |
| 401 | expect((error.cause as { cause: unknown }).cause).toBe(sdkCause); | |
| 402 | }), | |
| 403 | ), | |
| 404 | ); | |
| 405 | ||
| 406 | it.effect("reports a missing session payload without manufacturing a cause", () => | |
| 407 | withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => | |
| 408 | Effect.gen(function* () { | |
| 409 | runtimeMock.state.sessionResult = {}; | |
| 410 | ||
| 411 | const error = yield* textGeneration | |
| 412 | .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) | |
| 413 | .pipe(Effect.flip); | |
| 414 | ||
| 415 | expect(error.message).toContain("OpenCode session.create returned no session payload."); | |
| 416 | expect(error.cause).toMatchObject({ | |
| 417 | _tag: "OpenCodeTextGenerationSessionPayloadError", | |
| 418 | operation: "generateCommitMessage", | |
| 419 | cwd: process.cwd(), | |
| 420 | }); | |
| 421 | expect(error.cause).not.toHaveProperty("cause"); | |
| 422 | }), | |
| 423 | ), | |
| 424 | ); | |
| 425 | ||
| 426 | it.effect("preserves the SDK cause and request context when prompting fails", () => | |
| 427 | withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => | |
| 428 | Effect.gen(function* () { | |
| 429 | const sdkCause = new Error("prompt endpoint unavailable"); | |
| 430 | runtimeMock.state.promptRequestError = sdkCause; | |
| 431 | ||
| 432 | const error = yield* textGeneration | |
| 433 | .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) | |
| 434 | .pipe(Effect.flip); | |
| 435 | ||
| 436 | expect(error.message).toContain("OpenCode session.prompt request failed."); | |
| 437 | expect(error.cause).toMatchObject({ | |
| 438 | _tag: "OpenCodeTextGenerationPromptRequestError", | |
| 439 | operation: "generateCommitMessage", | |
| 440 | cwd: process.cwd(), | |
| 441 | sessionId: "http://127.0.0.1:4301/session", | |
| 442 | providerId: "openai", | |
| 443 | modelId: "gpt-5", | |
| 444 | cause: sdkCause, | |
| 445 | }); | |
| 446 | expect((error.cause as { cause: unknown }).cause).toBe(sdkCause); | |
| 447 | }), | |
| 448 | ), | |
| 449 | ); | |
| 450 | ||
| 451 | it.effect("returns a typed empty-output error for malformed and blank response parts", () => | |
| 452 | withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => | |
| 453 | Effect.gen(function* () { | |
| 454 | runtimeMock.state.promptResult = { | |
| 455 | data: { | |
| 456 | parts: [null, { type: "tool" }, { type: "text", text: " " }], | |
| 457 | }, | |
| 458 | }; | |
| 459 | ||
| 460 | const error = yield* textGeneration | |
| 461 | .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) | |
| 462 | .pipe(Effect.flip); | |
| 463 | ||
| 464 | expect(error.message).toContain("OpenCode returned empty output."); | |
| 465 | expect(error.cause).toMatchObject({ | |
| 466 | _tag: "OpenCodeTextGenerationEmptyOutputError", | |
| 467 | operation: "generateCommitMessage", | |
| 468 | cwd: process.cwd(), | |
| 469 | sessionId: "http://127.0.0.1:4301/session", | |
| 470 | providerId: "openai", | |
| 471 | modelId: "gpt-5", | |
| 472 | responsePartCount: 3, | |
| 473 | textPartCount: 1, | |
| 474 | }); | |
| 475 | expect(error.cause).not.toHaveProperty("cause"); | |
| 476 | }), | |
| 477 | ), | |
| 478 | ); | |
| 479 | ||
| 480 | it.effect("parses JSON returned as plain text output", () => | |
| 481 | withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => | |
| 482 | Effect.gen(function* () { | |
| 483 | runtimeMock.state.promptResult = { | |
| 484 | data: { | |
| 485 | parts: [ | |
| 486 | { | |
| 487 | type: "text", | |
| 488 | text: 'Here is the result:\n{"subject":"Tighten OpenCode parsing","body":"Handle JSON text output locally."}', | |
| 489 | }, | |
| 490 | ], | |
| 491 | }, | |
| 492 | }; | |
| 493 | ||
| 494 | const result = yield* textGeneration.generateCommitMessage({ | |
| 495 | cwd: process.cwd(), | |
| 496 | branch: "feature/opencode-reuse", | |
| 497 | stagedSummary: "M README.md", | |
| 498 | stagedPatch: "diff --git a/README.md b/README.md", | |
| 499 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | |
| 500 | }); | |
| 501 | ||
| 502 | expect(result).toEqual({ | |
| 503 | subject: "Tighten OpenCode parsing", | |
| 504 | body: "Handle JSON text output locally.", | |
| 505 | }); | |
| 506 | }), | |
| 507 | ), | |
| 508 | ); | |
| 509 | ||
| 510 | it.effect("surfaces the upstream OpenCode structured-output error message", () => | |
| 511 | withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => | |
| 512 | Effect.gen(function* () { | |
| 513 | runtimeMock.state.promptResult = { | |
| 514 | data: { | |
| 515 | info: { | |
| 516 | error: { | |
| 517 | name: "StructuredOutputError", | |
| 518 | data: { | |
| 519 | message: "Model did not produce structured output", | |
| 520 | retries: 2, | |
| 521 | }, | |
| 522 | }, | |
| 523 | }, | |
| 524 | }, | |
| 525 | }; | |
| 526 | ||
| 527 | const error = yield* textGeneration | |
| 528 | .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) | |
| 529 | .pipe(Effect.flip); | |
| 530 | ||
| 531 | expect(error.message).toContain("Model did not produce structured output"); | |
| 532 | expect(error.cause).toMatchObject({ | |
| 533 | _tag: "OpenCodeTextGenerationPromptResponseError", | |
| 534 | operation: "generateCommitMessage", | |
| 535 | cwd: process.cwd(), | |
| 536 | sessionId: "http://127.0.0.1:4301/session", | |
| 537 | providerId: "openai", | |
| 538 | modelId: "gpt-5", | |
| 539 | providerErrorName: "StructuredOutputError", | |
| 540 | providerMessage: "Model did not produce structured output", | |
| 541 | }); | |
| 542 | expect(error.cause).not.toHaveProperty("cause"); | |
| 543 | }), | |
| 544 | ), | |
| 545 | ); | |
| 546 | }); | |
| 547 | ||
| 548 | it.layer(layerOpenCodeTextGenerationExistingServerTest)( | |
| 549 | "OpenCodeTextGeneration with configured server URL", | |
| 550 | (it) => { | |
| 551 | it.effect("does not send a local environment password to a configured server", () => | |
| 552 | withOpenCodeTextGeneration( | |
| 553 | EXTERNAL_SERVER_WITHOUT_AUTH_OPENCODE_SETTINGS, | |
| 554 | (textGeneration) => | |
| 555 | Effect.gen(function* () { | |
| 556 | yield* textGeneration.generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT); | |
| 557 | expect(runtimeMock.state.authHeaders).toEqual([null]); | |
| 558 | }), | |
| 559 | { OPENCODE_SERVER_PASSWORD: "local-secret" }, | |
| 560 | ), | |
| 561 | ); | |
| 562 | ||
| 563 | it.effect("does not create a session when the server version is unsupported", () => | |
| 564 | withOpenCodeTextGeneration(EXISTING_SERVER_OPENCODE_SETTINGS, (textGeneration) => | |
| 565 | Effect.gen(function* () { | |
| 566 | runtimeMock.state.connectionError = new Error( | |
| 567 | "OpenCode v1.14.18 is too old. Upgrade to v1.14.19 or newer.", | |
| 568 | ); | |
| 569 | ||
| 570 | const error = yield* textGeneration | |
| 571 | .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) | |
| 572 | .pipe(Effect.flip); | |
| 573 | ||
| 574 | expect(error).toBeInstanceOf(TextGenerationError); | |
| 575 | expect(error.message).toContain("v1.14.18 is too old"); | |
| 576 | expect(runtimeMock.state.sessionCreateCalls).toBe(0); | |
| 577 | }), | |
| 578 | ), | |
| 579 | ); | |
| 580 | ||
| 581 | it.effect("reuses a configured OpenCode server URL without spawning or applying idle TTL", () => | |
| 582 | withOpenCodeTextGeneration(EXISTING_SERVER_OPENCODE_SETTINGS, (textGeneration) => | |
| 583 | Effect.gen(function* () { | |
| 584 | yield* textGeneration.generateCommitMessage({ | |
| 585 | cwd: process.cwd(), | |
| 586 | branch: "feature/opencode-reuse", | |
| 587 | stagedSummary: "M README.md", | |
| 588 | stagedPatch: "diff --git a/README.md b/README.md", | |
| 589 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | |
| 590 | }); | |
| 591 | yield* textGeneration.generateCommitMessage({ | |
| 592 | cwd: process.cwd(), | |
| 593 | branch: "feature/opencode-reuse", | |
| 594 | stagedSummary: "M README.md", | |
| 595 | stagedPatch: "diff --git a/README.md b/README.md", | |
| 596 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | |
| 597 | }); | |
| 598 | ||
| 599 | expect(runtimeMock.state.startCalls).toEqual([]); | |
| 600 | expect(runtimeMock.state.promptUrls).toEqual([ | |
| 601 | "http://127.0.0.1:9999", | |
| 602 | "http://127.0.0.1:9999", | |
| 603 | ]); | |
| 604 | expect(runtimeMock.state.authHeaders).toEqual([ | |
| 605 | `Basic ${btoa("opencode:secret-password")}`, | |
| 606 | `Basic ${btoa("opencode:secret-password")}`, | |
| 607 | ]); | |
| 608 | ||
| 609 | yield* advanceIdleClock; | |
| 610 | ||
| 611 | expect(runtimeMock.state.closeCalls).toEqual([]); | |
| 612 | }), | |
| 613 | ).pipe(Effect.provide(TestClock.layer())), | |
| 614 | ); | |
| 615 | }, | |
| 616 | ); |
apps/server/src/textGeneration/OpenCodeTextGeneration.ts deleted-325| ... | ... | @@ -1,325 +0,0 @@ |
| 1 | import * as Effect from "effect/Effect"; | |
| 2 | import * as Schema from "effect/Schema"; | |
| 3 | ||
| 4 | import { NonNegativeInt, TextGenerationError, type OpenCodeSettings } from "@t3tools/contracts"; | |
| 5 | import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; | |
| 6 | ||
| 7 | import * as ServerConfig from "../config.ts"; | |
| 8 | import { resolveAttachmentPath } from "../attachmentStore.ts"; | |
| 9 | import * as TextGenerationOperations from "@t3tools/provider-core/server/textGenerationOperations"; | |
| 10 | import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts"; | |
| 11 | import * as OpenCodeServerOwner from "../provider/OpenCodeServerOwner.ts"; | |
| 12 | ||
| 13 | const OpenCodeTextGenerationOperation = Schema.Literals([ | |
| 14 | "generateCommitMessage", | |
| 15 | "generatePrContent", | |
| 16 | "generateBranchName", | |
| 17 | "generateThreadTitle", | |
| 18 | ]); | |
| 19 | ||
| 20 | const openCodeTextGenerationErrorContext = { | |
| 21 | operation: OpenCodeTextGenerationOperation, | |
| 22 | cwd: Schema.String, | |
| 23 | }; | |
| 24 | ||
| 25 | export class OpenCodeTextGenerationSessionRequestError extends Schema.TaggedError<OpenCodeTextGenerationSessionRequestError>()( | |
| 26 | "OpenCodeTextGenerationSessionRequestError", | |
| 27 | { | |
| 28 | ...openCodeTextGenerationErrorContext, | |
| 29 | cause: Schema.Defect(), | |
| 30 | }, | |
| 31 | ) { | |
| 32 | override get message(): string { | |
| 33 | return `OpenCode session creation request failed for ${this.operation} in ${this.cwd}.`; | |
| 34 | } | |
| 35 | } | |
| 36 | ||
| 37 | export class OpenCodeTextGenerationSessionPayloadError extends Schema.TaggedError<OpenCodeTextGenerationSessionPayloadError>()( | |
| 38 | "OpenCodeTextGenerationSessionPayloadError", | |
| 39 | openCodeTextGenerationErrorContext, | |
| 40 | ) { | |
| 41 | override get message(): string { | |
| 42 | return `OpenCode session.create returned no session payload for ${this.operation} in ${this.cwd}.`; | |
| 43 | } | |
| 44 | } | |
| 45 | ||
| 46 | const openCodePromptErrorContext = { | |
| 47 | ...openCodeTextGenerationErrorContext, | |
| 48 | sessionId: Schema.String, | |
| 49 | providerId: Schema.String, | |
| 50 | modelId: Schema.String, | |
| 51 | }; | |
| 52 | ||
| 53 | export class OpenCodeTextGenerationPromptRequestError extends Schema.TaggedError<OpenCodeTextGenerationPromptRequestError>()( | |
| 54 | "OpenCodeTextGenerationPromptRequestError", | |
| 55 | { | |
| 56 | ...openCodePromptErrorContext, | |
| 57 | cause: Schema.Defect(), | |
| 58 | }, | |
| 59 | ) { | |
| 60 | override get message(): string { | |
| 61 | return `OpenCode prompt request failed for ${this.operation} in ${this.cwd} using ${this.providerId}/${this.modelId} (session ${this.sessionId}).`; | |
| 62 | } | |
| 63 | } | |
| 64 | ||
| 65 | export class OpenCodeTextGenerationPromptResponseError extends Schema.TaggedError<OpenCodeTextGenerationPromptResponseError>()( | |
| 66 | "OpenCodeTextGenerationPromptResponseError", | |
| 67 | { | |
| 68 | ...openCodePromptErrorContext, | |
| 69 | providerErrorName: Schema.optional(Schema.String), | |
| 70 | providerMessage: Schema.String, | |
| 71 | }, | |
| 72 | ) { | |
| 73 | override get message(): string { | |
| 74 | const providerError = this.providerErrorName ? ` ${this.providerErrorName}` : ""; | |
| 75 | return `OpenCode prompt${providerError} failed for ${this.operation} in ${this.cwd} using ${this.providerId}/${this.modelId} (session ${this.sessionId}): ${this.providerMessage}`; | |
| 76 | } | |
| 77 | } | |
| 78 | ||
| 79 | export class OpenCodeTextGenerationEmptyOutputError extends Schema.TaggedError<OpenCodeTextGenerationEmptyOutputError>()( | |
| 80 | "OpenCodeTextGenerationEmptyOutputError", | |
| 81 | { | |
| 82 | ...openCodePromptErrorContext, | |
| 83 | responsePartCount: NonNegativeInt, | |
| 84 | textPartCount: NonNegativeInt, | |
| 85 | }, | |
| 86 | ) { | |
| 87 | override get message(): string { | |
| 88 | 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).`; | |
| 89 | } | |
| 90 | } | |
| 91 | ||
| 92 | interface OpenCodePromptFailure { | |
| 93 | readonly name?: string; | |
| 94 | readonly message: string; | |
| 95 | } | |
| 96 | ||
| 97 | interface OpenCodeTextPart { | |
| 98 | readonly type: "text"; | |
| 99 | readonly text: string; | |
| 100 | } | |
| 101 | ||
| 102 | function getOpenCodePromptFailure(error: unknown): OpenCodePromptFailure | null { | |
| 103 | if (!error || typeof error !== "object") { | |
| 104 | return null; | |
| 105 | } | |
| 106 | ||
| 107 | const name = | |
| 108 | "name" in error && typeof error.name === "string" && error.name.trim().length > 0 | |
| 109 | ? error.name.trim() | |
| 110 | : undefined; | |
| 111 | const message = | |
| 112 | "data" in error && | |
| 113 | error.data && | |
| 114 | typeof error.data === "object" && | |
| 115 | "message" in error.data && | |
| 116 | typeof error.data.message === "string" | |
| 117 | ? error.data.message.trim() | |
| 118 | : ""; | |
| 119 | if (message.length > 0) { | |
| 120 | return { | |
| 121 | ...(name ? { name } : {}), | |
| 122 | message, | |
| 123 | }; | |
| 124 | } | |
| 125 | ||
| 126 | if (name) { | |
| 127 | return { name, message: name }; | |
| 128 | } | |
| 129 | ||
| 130 | return null; | |
| 131 | } | |
| 132 | ||
| 133 | function isOpenCodeTextPart(part: unknown): part is OpenCodeTextPart { | |
| 134 | return ( | |
| 135 | part !== null && | |
| 136 | typeof part === "object" && | |
| 137 | "type" in part && | |
| 138 | part.type === "text" && | |
| 139 | "text" in part && | |
| 140 | typeof part.text === "string" | |
| 141 | ); | |
| 142 | } | |
| 143 | ||
| 144 | function getOpenCodeTextResponse(parts: ReadonlyArray<unknown> | undefined): string { | |
| 145 | return (parts ?? []) | |
| 146 | .filter(isOpenCodeTextPart) | |
| 147 | .map((part) => part.text) | |
| 148 | .join("") | |
| 149 | .trim(); | |
| 150 | } | |
| 151 | ||
| 152 | export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration")(function* ( | |
| 153 | openCodeSettings: OpenCodeSettings, | |
| 154 | ) { | |
| 155 | const serverConfig = yield* ServerConfig.ServerConfig; | |
| 156 | const openCodeRuntime = yield* OpenCodeRuntime.OpenCodeRuntime; | |
| 157 | const serverOwner = yield* OpenCodeServerOwner.OpenCodeServerOwner; | |
| 158 | ||
| 159 | const runOpenCodeJson = Effect.fn("runOpenCodeJson")(function* <S extends Schema.Top>( | |
| 160 | input: TextGenerationOperations.Request<S>, | |
| 161 | ) { | |
| 162 | const parsedModel = OpenCodeRuntime.parseOpenCodeModelSlug(input.modelSelection.model); | |
| 163 | if (!parsedModel) { | |
| 164 | return yield* new TextGenerationError({ | |
| 165 | operation: input.operation, | |
| 166 | detail: "OpenCode model selection must use the 'provider/model' format.", | |
| 167 | }); | |
| 168 | } | |
| 169 | ||
| 170 | const fileParts = OpenCodeRuntime.toOpenCodeFileParts({ | |
| 171 | attachments: input.attachments?.filter((attachment) => attachment.type === "image"), | |
| 172 | resolveAttachmentPath: (attachment) => | |
| 173 | resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }), | |
| 174 | }); | |
| 175 | ||
| 176 | const runAgainstServer = Effect.fn("runOpenCodeJson.runAgainstServer")( | |
| 177 | function* ( | |
| 178 | server: Pick< | |
| 179 | OpenCodeRuntime.OpenCodeServerConnection, | |
| 180 | "url" | "serverPassword" | "version" | |
| 181 | >, | |
| 182 | ) { | |
| 183 | const client = openCodeRuntime.createOpenCodeSdkClient({ | |
| 184 | baseUrl: server.url, | |
| 185 | directory: input.cwd, | |
| 186 | ...(server.serverPassword !== undefined ? { serverPassword: server.serverPassword } : {}), | |
| 187 | }); | |
| 188 | const session = yield* Effect.tryPromise({ | |
| 189 | try: () => | |
| 190 | client.session.create({ | |
| 191 | title: `T3 Code ${input.operation}`, | |
| 192 | permission: [{ permission: "*", pattern: "*", action: "deny" }], | |
| 193 | }), | |
| 194 | catch: (cause) => | |
| 195 | new OpenCodeTextGenerationSessionRequestError({ | |
| 196 | operation: input.operation, | |
| 197 | cwd: input.cwd, | |
| 198 | cause, | |
| 199 | }), | |
| 200 | }); | |
| 201 | if (!session.data) { | |
| 202 | return yield* new OpenCodeTextGenerationSessionPayloadError({ | |
| 203 | operation: input.operation, | |
| 204 | cwd: input.cwd, | |
| 205 | }); | |
| 206 | } | |
| 207 | const selectedAgent = getModelSelectionStringOptionValue(input.modelSelection, "agent"); | |
| 208 | const selectedVariant = getModelSelectionStringOptionValue(input.modelSelection, "variant"); | |
| 209 | const promptContext = { | |
| 210 | operation: input.operation, | |
| 211 | cwd: input.cwd, | |
| 212 | sessionId: session.data.id, | |
| 213 | providerId: parsedModel.providerID, | |
| 214 | modelId: parsedModel.modelID, | |
| 215 | }; | |
| 216 | ||
| 217 | const result = yield* Effect.tryPromise({ | |
| 218 | try: () => | |
| 219 | client.session.prompt({ | |
| 220 | sessionID: session.data.id, | |
| 221 | model: parsedModel, | |
| 222 | ...(selectedAgent ? { agent: selectedAgent } : {}), | |
| 223 | ...(selectedVariant ? { variant: selectedVariant } : {}), | |
| 224 | parts: [{ type: "text", text: input.prompt }, ...fileParts], | |
| 225 | }), | |
| 226 | catch: (cause) => | |
| 227 | new OpenCodeTextGenerationPromptRequestError({ | |
| 228 | ...promptContext, | |
| 229 | cause, | |
| 230 | }), | |
| 231 | }); | |
| 232 | const promptFailure = getOpenCodePromptFailure(result.data?.info?.error); | |
| 233 | if (promptFailure) { | |
| 234 | return yield* new OpenCodeTextGenerationPromptResponseError({ | |
| 235 | ...promptContext, | |
| 236 | ...(promptFailure.name ? { providerErrorName: promptFailure.name } : {}), | |
| 237 | providerMessage: promptFailure.message, | |
| 238 | }); | |
| 239 | } | |
| 240 | const responseParts = result.data?.parts ?? []; | |
| 241 | const rawText = getOpenCodeTextResponse(responseParts); | |
| 242 | if (rawText.length === 0) { | |
| 243 | return yield* new OpenCodeTextGenerationEmptyOutputError({ | |
| 244 | ...promptContext, | |
| 245 | responsePartCount: responseParts.length, | |
| 246 | textPartCount: responseParts.filter(isOpenCodeTextPart).length, | |
| 247 | }); | |
| 248 | } | |
| 249 | return rawText; | |
| 250 | }, | |
| 251 | Effect.catchTags({ | |
| 252 | OpenCodeTextGenerationSessionRequestError: (cause) => | |
| 253 | Effect.fail( | |
| 254 | new TextGenerationError({ | |
| 255 | operation: cause.operation, | |
| 256 | detail: "OpenCode session.create request failed.", | |
| 257 | cause, | |
| 258 | }), | |
| 259 | ), | |
| 260 | OpenCodeTextGenerationSessionPayloadError: (cause) => | |
| 261 | Effect.fail( | |
| 262 | new TextGenerationError({ | |
| 263 | operation: cause.operation, | |
| 264 | detail: "OpenCode session.create returned no session payload.", | |
| 265 | cause, | |
| 266 | }), | |
| 267 | ), | |
| 268 | OpenCodeTextGenerationPromptRequestError: (cause) => | |
| 269 | Effect.fail( | |
| 270 | new TextGenerationError({ | |
| 271 | operation: cause.operation, | |
| 272 | detail: "OpenCode session.prompt request failed.", | |
| 273 | cause, | |
| 274 | }), | |
| 275 | ), | |
| 276 | OpenCodeTextGenerationPromptResponseError: (cause) => | |
| 277 | Effect.fail( | |
| 278 | new TextGenerationError({ | |
| 279 | operation: cause.operation, | |
| 280 | detail: cause.providerMessage, | |
| 281 | cause, | |
| 282 | }), | |
| 283 | ), | |
| 284 | OpenCodeTextGenerationEmptyOutputError: (cause) => | |
| 285 | Effect.fail( | |
| 286 | new TextGenerationError({ | |
| 287 | operation: cause.operation, | |
| 288 | detail: "OpenCode returned empty output.", | |
| 289 | cause, | |
| 290 | }), | |
| 291 | ), | |
| 292 | }), | |
| 293 | ); | |
| 294 | ||
| 295 | const serverOutput = | |
| 296 | openCodeSettings.serverUrl.length > 0 | |
| 297 | ? openCodeRuntime | |
| 298 | .connectToOpenCodeServer({ | |
| 299 | binaryPath: openCodeSettings.binaryPath, | |
| 300 | directory: input.cwd, | |
| 301 | serverUrl: openCodeSettings.serverUrl, | |
| 302 | ...(openCodeSettings.serverPassword | |
| 303 | ? { serverPassword: openCodeSettings.serverPassword } | |
| 304 | : {}), | |
| 305 | }) | |
| 306 | .pipe(Effect.flatMap(runAgainstServer), Effect.scoped) | |
| 307 | : serverOwner.withServer(runAgainstServer); | |
| 308 | const rawOutput = yield* serverOutput.pipe( | |
| 309 | Effect.catchTags({ | |
| 310 | OpenCodeRuntimeError: (cause) => | |
| 311 | Effect.fail( | |
| 312 | new TextGenerationError({ | |
| 313 | operation: input.operation, | |
| 314 | detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause), | |
| 315 | cause, | |
| 316 | }), | |
| 317 | ), | |
| 318 | }), | |
| 319 | ); | |
| 320 | ||
| 321 | return yield* TextGenerationOperations.decodeJsonReply(input, "OpenCode", rawOutput); | |
| 322 | }); | |
| 323 | ||
| 324 | return TextGenerationOperations.fromRunner("OpenCodeTextGeneration", runOpenCodeJson); | |
| 325 | }); |
apps/server/src/usage/UsageService.ts+1-1| ... | ... | @@ -52,7 +52,7 @@ import * as Schema from "effect/Schema"; |
| 52 | 52 | import * as Semaphore from "effect/Semaphore"; |
| 53 | 53 | import { HttpClient, HttpClientResponse } from "effect/http"; |
| 54 | 54 | |
| 55 | import { writeFileStringAtomically } from "../atomicWrite.ts"; | |
| 55 | import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; | |
| 56 | 56 | import * as ServerConfig from "../config.ts"; |
| 57 | 57 | import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; |
| 58 | 58 | import * as ServerSettings from "../serverSettings.ts"; |
apps/web/package.json+1| ... | ... | @@ -30,6 +30,7 @@ |
| 30 | 30 | "@t3tools/contracts": "workspace:*", |
| 31 | 31 | "@t3tools/provider-core": "workspace:*", |
| 32 | 32 | "@t3tools/provider-muse": "workspace:*", |
| 33 | "@t3tools/provider-opencode": "workspace:*", | |
| 33 | 34 | "@t3tools/provider-pi": "workspace:*", |
| 34 | 35 | "@t3tools/shared": "workspace:*", |
| 35 | 36 | "@tanstack/react-pacer": "^0.19.4", |
apps/web/src/components/Icons.tsx-26| ... | ... | @@ -681,32 +681,6 @@ export const AntigravityIcon: Icon = (props) => ( |
| 681 | 681 | </svg> |
| 682 | 682 | ); |
| 683 | 683 | |
| 684 | export const OpenCodeIcon: Icon = (props) => ( | |
| 685 | <svg {...props} viewBox="0 0 32 40" fill="none" xmlns="http://www.w3.org/2000/svg"> | |
| 686 | <g clipPath="url(#opencode__clip0_1311_94969)"> | |
| 687 | <path | |
| 688 | data-opencode-hole="true" | |
| 689 | className="dark:hidden" | |
| 690 | d="M24 32H8V16H24V32Z" | |
| 691 | fill="#CFCECD" | |
| 692 | /> | |
| 693 | <path className="dark:hidden" d="M24 8H8V32H24V8ZM32 40H0V0H32V40Z" fill="#211E1E" /> | |
| 694 | <path | |
| 695 | data-opencode-hole="true" | |
| 696 | className="hidden dark:block" | |
| 697 | d="M24 32H8V16H24V32Z" | |
| 698 | fill="#4B4646" | |
| 699 | /> | |
| 700 | <path className="hidden dark:block" d="M24 8H8V32H24V8ZM32 40H0V0H32V40Z" fill="#F1ECEC" /> | |
| 701 | </g> | |
| 702 | <defs> | |
| 703 | <clipPath id="opencode__clip0_1311_94969"> | |
| 704 | <rect width="32" height="40" fill="white" /> | |
| 705 | </clipPath> | |
| 706 | </defs> | |
| 707 | </svg> | |
| 708 | ); | |
| 709 | ||
| 710 | 684 | export const ACPRegistryIcon: Icon = ({ className, ...props }) => ( |
| 711 | 685 | <svg |
| 712 | 686 | {...props} |
apps/web/src/components/chat/ChatComposer.tsx+1-1| ... | ... | @@ -5572,7 +5572,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 5572 | 5572 | activeProviderIconClassName: cn( |
| 5573 | 5573 | composerProviderState.modelPickerIconClassName, |
| 5574 | 5574 | composerControlsCollapsed && |
| 5575 | "fill-muted-foreground/70! text-muted-foreground/70! [&_path]:fill-muted-foreground/70! [&_rect]:fill-muted-foreground/70! [&_[data-opencode-hole]]:fill-transparent!", | |
| 5575 | "fill-muted-foreground/70! text-muted-foreground/70! [&_path]:fill-muted-foreground/70! [&_rect]:fill-muted-foreground/70! [&_[data-icon-detail]]:fill-transparent!", | |
| 5576 | 5576 | ), |
| 5577 | 5577 | } |
| 5578 | 5578 | : {})} |
apps/web/src/components/chat/ProviderInstanceIcon.tsx+1-11| ... | ... | @@ -3,15 +3,7 @@ import { type CSSProperties, memo } from "react"; |
| 3 | 3 | import { providerInstanceInitials } from "@t3tools/client-runtime/state/provider-instance-display"; |
| 4 | 4 | |
| 5 | 5 | import { ProviderDriverKind } from "@t3tools/contracts"; |
| 6 | import { | |
| 7 | AntigravityIcon, | |
| 8 | ClaudeAI, | |
| 9 | CursorIcon, | |
| 10 | GrokIcon, | |
| 11 | Icon, | |
| 12 | OpenAI, | |
| 13 | OpenCodeIcon, | |
| 14 | } from "../Icons"; | |
| 6 | import { AntigravityIcon, ClaudeAI, CursorIcon, GrokIcon, Icon, OpenAI } from "../Icons"; | |
| 15 | 7 | |
| 16 | 8 | import { cn } from "~/lib/utils"; |
| 17 | 9 | import { providerClients } from "../settings/providerDriverMeta"; |
| ... | ... | @@ -25,7 +17,6 @@ import { |
| 25 | 17 | const PROVIDER_ICON_BY_PROVIDER: Partial<Record<ProviderDriverKind, Icon>> = { |
| 26 | 18 | [ProviderDriverKind.make("codex")]: OpenAI, |
| 27 | 19 | [ProviderDriverKind.make("claudeAgent")]: ClaudeAI, |
| 28 | [ProviderDriverKind.make("opencode")]: OpenCodeIcon, | |
| 29 | 20 | [ProviderDriverKind.make("cursor")]: CursorIcon, |
| 30 | 21 | [ProviderDriverKind.make("grok")]: GrokIcon, |
| 31 | 22 | [ProviderDriverKind.make("antigravity")]: AntigravityIcon, |
| ... | ... | @@ -36,7 +27,6 @@ const PROVIDER_TEXT_COLOR_BY_PROVIDER: Partial<Record<ProviderDriverKind, string |
| 36 | 27 | [ProviderDriverKind.make("claudeAgent")]: "text-[#d97757]", |
| 37 | 28 | [ProviderDriverKind.make("cursor")]: "text-[#26251E] dark:text-[#EDECEC]", |
| 38 | 29 | [ProviderDriverKind.make("grok")]: "text-[#0F0F0F] dark:text-[#F5F5F5]", |
| 39 | [ProviderDriverKind.make("opencode")]: "text-[#211E1E] dark:text-[#F1ECEC]", | |
| 40 | 30 | [ProviderDriverKind.make("antigravity")]: "text-[#5b87bf]", |
| 41 | 31 | }; |
| 42 | 32 |
apps/web/src/components/chat/ProviderPackageIcon.tsx+2| ... | ... | @@ -31,6 +31,8 @@ export function ProviderPackageIcon({ |
| 31 | 31 | key={path.d} |
| 32 | 32 | d={path.d} |
| 33 | 33 | fillRule={path.fillRule} |
| 34 | // Monochrome renderings (the collapsed composer) clear these detail paths. | |
| 35 | data-icon-detail={path.fill ? true : undefined} | |
| 34 | 36 | className={path.fill ? "fill-(--path-light) dark:fill-(--path-dark)" : undefined} |
| 35 | 37 | style={ |
| 36 | 38 | path.fill |
apps/web/src/components/settings/providerDriverMeta.ts+2-6| ... | ... | @@ -5,11 +5,11 @@ import { |
| 5 | 5 | CodexSettings, |
| 6 | 6 | CursorSettings, |
| 7 | 7 | GrokSettings, |
| 8 | OpenCodeSettings, | |
| 9 | 8 | ProviderDriverKind, |
| 10 | 9 | } from "@t3tools/contracts"; |
| 11 | 10 | import { makeProviderClientRegistry } from "@t3tools/provider-core/client"; |
| 12 | 11 | import { museClient } from "@t3tools/provider-muse/client"; |
| 12 | import { openCodeClient } from "@t3tools/provider-opencode/client"; | |
| 13 | 13 | import { piClient } from "@t3tools/provider-pi/client"; |
| 14 | 14 | |
| 15 | 15 | /** The provider client definitions this web build ships, in presentation order. */ |
| ... | ... | @@ -43,11 +43,7 @@ export const providerClients = makeProviderClientRegistry([ |
| 43 | 43 | label: "Grok", |
| 44 | 44 | settingsSchema: GrokSettings, |
| 45 | 45 | }, |
| 46 | { | |
| 47 | driverKind: ProviderDriverKind.make("opencode"), | |
| 48 | label: "OpenCode", | |
| 49 | settingsSchema: OpenCodeSettings, | |
| 50 | }, | |
| 46 | openCodeClient, | |
| 51 | 47 | { |
| 52 | 48 | driverKind: ProviderDriverKind.make("antigravity"), |
| 53 | 49 | label: "Antigravity", |
docs/internals/adding-a-provider.md+3-2| ... | ... | @@ -110,8 +110,9 @@ A provider lives in its own `packages/provider-<name>` package, with |
| 110 | 110 | - **Package exports.** `./settings` holds the instance settings schema, built with |
| 111 | 111 | `makeProviderSettingsSchema` from contracts. `./client` exports a `ProviderClientDefinition` |
| 112 | 112 | (label, settings schema, plain-data icon, badge) and must stay browser- and React Native-safe. |
| 113 | `./server` exports the `ProviderDriver` and its adapter driver. Settings live in | |
| 114 | `providerInstances`; new providers are off by default. | |
| 113 | `./server` exports the `ProviderDriver` and its adapter driver. `./testing` exposes internals | |
| 114 | the server's replay testkits drive; testkits that need the orchestrator stay in the server. | |
| 115 | Settings live in `providerInstances`; new providers are off by default. | |
| 115 | 116 | - **Package boundaries.** Server code imports only `@t3tools/provider-core/server/*`, contracts, |
| 116 | 117 | and shared. It reaches the server through `ProviderHost` (paths, settings, background demand, |
| 117 | 118 | attachments), never through `apps/server`. Tests use `@t3tools/provider-testing`. |
docs/internals/providers.md+4-4| ... | ... | @@ -17,12 +17,12 @@ directory must not share one T3 MCP entry. |
| 17 | 17 | |
| 18 | 18 | - **1.x** uses one T3-managed chat server per thread, so threads cannot replace each other's |
| 19 | 19 | connection. Catalog and text-generation work can share the |
| 20 | [instance-owned helper](../../apps/server/src/provider/OpenCodeServerOwner.ts), which closes | |
| 21 | after an idle period. See the [1.x adapter](../../apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts). | |
| 20 | [instance-owned helper](../../packages/provider-opencode/src/server/OpenCodeServerOwner.ts), which closes | |
| 21 | after an idle period. See the [1.x adapter](../../packages/provider-opencode/src/server/adapter.ts). | |
| 22 | 22 | - **2.x** serves every directory from one |
| 23 | [server per instance](../../apps/server/src/provider/opencode2/OpenCode2Server.ts). Each thread | |
| 23 | [server per instance](../../packages/provider-opencode/src/server/v2/OpenCode2Server.ts). Each thread | |
| 24 | 24 | registers its own `t3-code-<thread>` MCP entry, and session permission rules deny every other |
| 25 | thread's entry. See the [2.x adapter](../../apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts). | |
| 25 | thread's entry. See the [2.x adapter](../../packages/provider-opencode/src/server/v2/adapter.ts). | |
| 26 | 26 | |
| 27 | 27 | External OpenCode servers remain externally owned and can require an external restart to pick up |
| 28 | 28 | configuration changes. OpenCode stores "always" approval grants for the whole project. Automatic |
package.json+1-1| ... | ... | @@ -28,7 +28,7 @@ |
| 28 | 28 | "tc": "vp run -r --concurrency-limit 2 typecheck", |
| 29 | 29 | "lint": "vp lint --report-unused-disable-directives", |
| 30 | 30 | "knip": "knip --preprocessor ./scripts/knip-schemas.ts", |
| 31 | "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", | |
| 31 | "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", | |
| 32 | 32 | "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts", |
| 33 | 33 | "lint:mobile": "node scripts/mobile-native-static-check.ts", |
| 34 | 34 | "test": "vp run -r test", |
packages/contracts/src/settings.ts-52| ... | ... | @@ -907,58 +907,6 @@ export const AcpRegistrySettings = makeProviderSettingsSchema( |
| 907 | 907 | ); |
| 908 | 908 | export type AcpRegistrySettings = typeof AcpRegistrySettings.Type; |
| 909 | 909 | |
| 910 | export const OpenCodeSettings = makeProviderSettingsSchema( | |
| 911 | { | |
| 912 | // Off by default (like Cursor and Grok): the binding is not yet stable | |
| 913 | // enough to probe on every install. Users opt in from Settings. | |
| 914 | enabled: Schema.Boolean.pipe( | |
| 915 | Schema.withDecodingDefault(Effect.succeed(false)), | |
| 916 | Schema.annotateKey({ providerSettingsForm: { hidden: true } }), | |
| 917 | ), | |
| 918 | binaryPath: makeBinaryPathSetting("opencode").pipe( | |
| 919 | Schema.annotateKey({ | |
| 920 | title: "Binary path", | |
| 921 | description: "Path to the OpenCode binary.", | |
| 922 | providerSettingsForm: { | |
| 923 | placeholder: "opencode", | |
| 924 | clearWhenEmpty: "omit", | |
| 925 | }, | |
| 926 | }), | |
| 927 | ), | |
| 928 | serverUrl: TrimmedString.pipe( | |
| 929 | Schema.withDecodingDefault(Effect.succeed("")), | |
| 930 | Schema.annotateKey({ | |
| 931 | title: "Server URL", | |
| 932 | description: "Leave blank to let T3 Code spawn the server when needed.", | |
| 933 | providerSettingsForm: { | |
| 934 | placeholder: "http://127.0.0.1:4096", | |
| 935 | clearWhenEmpty: "omit", | |
| 936 | }, | |
| 937 | }), | |
| 938 | ), | |
| 939 | serverPassword: TrimmedString.pipe( | |
| 940 | Schema.withDecodingDefault(Effect.succeed("")), | |
| 941 | Schema.annotateKey({ | |
| 942 | title: "Server password", | |
| 943 | description: "Stored in plain text on disk.", | |
| 944 | providerSettingsForm: { | |
| 945 | control: "password", | |
| 946 | placeholder: "Optional", | |
| 947 | clearWhenEmpty: "omit", | |
| 948 | }, | |
| 949 | }), | |
| 950 | ), | |
| 951 | customModels: Schema.Array(CustomModelSetting).pipe( | |
| 952 | Schema.withDecodingDefault(Effect.succeed([])), | |
| 953 | Schema.annotateKey({ providerSettingsForm: { hidden: true } }), | |
| 954 | ), | |
| 955 | }, | |
| 956 | { | |
| 957 | order: ["binaryPath", "serverUrl", "serverPassword"], | |
| 958 | }, | |
| 959 | ); | |
| 960 | export type OpenCodeSettings = typeof OpenCodeSettings.Type; | |
| 961 | ||
| 962 | 910 | /** |
| 963 | 911 | * A read-only quota source outside this environment's provider CLIs. The |
| 964 | 912 | * only kind today is a CLIProxyAPI hub, whose management API reports the |
packages/provider-core/package.json+8| ... | ... | @@ -75,6 +75,10 @@ |
| 75 | 75 | "types": "./src/server/mcpToolPresentation.ts", |
| 76 | 76 | "import": "./src/server/mcpToolPresentation.ts" |
| 77 | 77 | }, |
| 78 | "./server/nativeProtocolLogging": { | |
| 79 | "types": "./src/server/nativeProtocolLogging.ts", | |
| 80 | "import": "./src/server/nativeProtocolLogging.ts" | |
| 81 | }, | |
| 78 | 82 | "./server/notification": { |
| 79 | 83 | "types": "./src/server/notification.ts", |
| 80 | 84 | "import": "./src/server/notification.ts" |
| ... | ... | @@ -127,6 +131,10 @@ |
| 127 | 131 | "types": "./src/server/snapshotSettings.ts", |
| 128 | 132 | "import": "./src/server/snapshotSettings.ts" |
| 129 | 133 | }, |
| 134 | "./server/subagentProjection": { | |
| 135 | "types": "./src/server/subagentProjection.ts", | |
| 136 | "import": "./src/server/subagentProjection.ts" | |
| 137 | }, | |
| 130 | 138 | "./server/textGeneration": { |
| 131 | 139 | "types": "./src/server/textGeneration.ts", |
| 132 | 140 | "import": "./src/server/textGeneration.ts" |
packages/provider-core/src/server/nativeProtocolLogging.ts created+40| ... | ... | @@ -0,0 +1,40 @@ |
| 1 | import { errorTag } from "@t3tools/shared/observability"; | |
| 2 | ||
| 3 | export function structuralProtocolMethod(value: string): string { | |
| 4 | return value.length <= 128 && /^[A-Za-z][A-Za-z0-9._:/-]*$/.test(value) ? value : "unknown"; | |
| 5 | } | |
| 6 | ||
| 7 | /** | |
| 8 | * Retain only bounded structural metadata for native protocol diagnostics. | |
| 9 | * Provider payload values may contain prompts, credentials, or arbitrarily | |
| 10 | * large tool output and must never be copied into the observability stream. | |
| 11 | */ | |
| 12 | export function summarizeNativeProtocolPayload( | |
| 13 | payload: unknown, | |
| 14 | ): Readonly<Record<string, unknown>> { | |
| 15 | if (payload === null) return { valueType: "null" }; | |
| 16 | if (typeof payload === "string") { | |
| 17 | return { valueType: "string", byteLength: new TextEncoder().encode(payload).byteLength }; | |
| 18 | } | |
| 19 | if (payload instanceof Uint8Array) { | |
| 20 | return { valueType: "bytes", byteLength: payload.byteLength }; | |
| 21 | } | |
| 22 | if (Array.isArray(payload)) { | |
| 23 | return { valueType: "array", itemCount: payload.length }; | |
| 24 | } | |
| 25 | if (typeof payload !== "object") { | |
| 26 | return { valueType: typeof payload }; | |
| 27 | } | |
| 28 | ||
| 29 | try { | |
| 30 | const record = payload as Record<string, unknown>; | |
| 31 | return { | |
| 32 | valueType: "object", | |
| 33 | fieldCount: Object.keys(record).length, | |
| 34 | ...(typeof record._tag === "string" ? { messageTag: errorTag(record) } : {}), | |
| 35 | ...(typeof record.tag === "string" ? { method: structuralProtocolMethod(record.tag) } : {}), | |
| 36 | }; | |
| 37 | } catch { | |
| 38 | return { valueType: "object" }; | |
| 39 | } | |
| 40 | } |
packages/provider-core/src/server/subagentProjection.ts created+259| ... | ... | @@ -0,0 +1,259 @@ |
| 1 | import type { | |
| 2 | MessageId, | |
| 3 | ModelSelection, | |
| 4 | NodeId, | |
| 5 | OrchestrationV2AppThread, | |
| 6 | OrchestrationV2Actor, | |
| 7 | OrchestrationV2ConversationMessage, | |
| 8 | OrchestrationV2CreationSource, | |
| 9 | OrchestrationV2ProviderRef, | |
| 10 | OrchestrationV2Run, | |
| 11 | OrchestrationV2ThreadProjection, | |
| 12 | OrchestrationV2TurnItem, | |
| 13 | ProviderInstanceId, | |
| 14 | ProviderThreadId, | |
| 15 | ProviderTurnId, | |
| 16 | ThreadId, | |
| 17 | TurnItemId, | |
| 18 | } from "@t3tools/contracts"; | |
| 19 | import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError"; | |
| 20 | import * as DateTime from "effect/DateTime"; | |
| 21 | import { isOrchestrationV2WorkActive } from "@t3tools/contracts"; | |
| 22 | ||
| 23 | function trimmed(value: string | null | undefined): string | undefined { | |
| 24 | const result = value?.trim(); | |
| 25 | return result && result.length > 0 ? result : undefined; | |
| 26 | } | |
| 27 | ||
| 28 | export function subagentThreadTitle(input: { | |
| 29 | readonly parentTitle: string; | |
| 30 | readonly title?: string | null; | |
| 31 | readonly prompt: string; | |
| 32 | readonly ordinal: number; | |
| 33 | }): string { | |
| 34 | const detail = trimmed(input.title) ?? trimmed(input.prompt); | |
| 35 | if (detail === undefined) { | |
| 36 | return `${input.parentTitle} subagent ${input.ordinal}`; | |
| 37 | } | |
| 38 | const clipped = detail.length > 72 ? `${detail.slice(0, 69)}...` : detail; | |
| 39 | return clipped; | |
| 40 | } | |
| 41 | ||
| 42 | export function makeSubagentChildThread(input: { | |
| 43 | readonly parentThread: OrchestrationV2AppThread; | |
| 44 | readonly childThreadId: ThreadId; | |
| 45 | readonly parentNodeId: NodeId; | |
| 46 | readonly activeProviderThreadId: ProviderThreadId | null; | |
| 47 | readonly providerInstanceId: ProviderInstanceId; | |
| 48 | readonly modelSelection: ModelSelection; | |
| 49 | readonly title: string; | |
| 50 | readonly now: DateTime.Utc; | |
| 51 | readonly createdBy: OrchestrationV2Actor; | |
| 52 | readonly creationSource: OrchestrationV2CreationSource; | |
| 53 | }): OrchestrationV2AppThread { | |
| 54 | return { | |
| 55 | ...input.parentThread, | |
| 56 | createdBy: input.createdBy, | |
| 57 | creationSource: input.creationSource, | |
| 58 | id: input.childThreadId, | |
| 59 | title: input.title, | |
| 60 | linkedPullRequest: null, | |
| 61 | pullRequests: [], | |
| 62 | historyOrigin: undefined, | |
| 63 | providerInstanceId: input.providerInstanceId, | |
| 64 | modelSelection: input.modelSelection, | |
| 65 | activeProviderThreadId: input.activeProviderThreadId, | |
| 66 | lineage: { | |
| 67 | parentThreadId: input.parentThread.id, | |
| 68 | relationshipToParent: "subagent", | |
| 69 | rootThreadId: input.parentThread.lineage.rootThreadId, | |
| 70 | }, | |
| 71 | forkedFrom: { | |
| 72 | type: "node", | |
| 73 | nodeId: input.parentNodeId, | |
| 74 | }, | |
| 75 | createdAt: input.now, | |
| 76 | updatedAt: input.now, | |
| 77 | archivedAt: null, | |
| 78 | settledOverride: null, | |
| 79 | settledAt: null, | |
| 80 | snoozedUntil: null, | |
| 81 | snoozedAt: null, | |
| 82 | lastVisitedAt: null, | |
| 83 | deletedAt: null, | |
| 84 | }; | |
| 85 | } | |
| 86 | ||
| 87 | export function makeSubagentConversationArtifacts(input: { | |
| 88 | readonly messageId: MessageId; | |
| 89 | readonly senderThreadId?: ThreadId; | |
| 90 | readonly turnItemId: TurnItemId; | |
| 91 | readonly threadId: ThreadId; | |
| 92 | readonly rootNodeId: NodeId; | |
| 93 | readonly providerThreadId: ProviderThreadId | null; | |
| 94 | readonly providerTurnId: ProviderTurnId | null; | |
| 95 | readonly nativeItemRef: OrchestrationV2ProviderRef | null; | |
| 96 | readonly role: "user" | "assistant"; | |
| 97 | readonly text: string; | |
| 98 | readonly ordinal: number; | |
| 99 | readonly now: DateTime.Utc; | |
| 100 | }): { | |
| 101 | readonly message: OrchestrationV2ConversationMessage; | |
| 102 | readonly turnItem: OrchestrationV2TurnItem; | |
| 103 | } { | |
| 104 | const message: OrchestrationV2ConversationMessage = { | |
| 105 | createdBy: "agent", | |
| 106 | creationSource: "provider", | |
| 107 | id: input.messageId, | |
| 108 | threadId: input.threadId, | |
| 109 | runId: null, | |
| 110 | nodeId: input.rootNodeId, | |
| 111 | role: input.role, | |
| 112 | ...(input.role === "user" && input.senderThreadId !== undefined | |
| 113 | ? { senderThreadId: input.senderThreadId } | |
| 114 | : {}), | |
| 115 | text: input.text, | |
| 116 | attachments: [], | |
| 117 | streaming: false, | |
| 118 | createdAt: input.now, | |
| 119 | updatedAt: input.now, | |
| 120 | }; | |
| 121 | const base = { | |
| 122 | id: input.turnItemId, | |
| 123 | threadId: input.threadId, | |
| 124 | runId: null, | |
| 125 | nodeId: input.rootNodeId, | |
| 126 | providerThreadId: input.providerThreadId, | |
| 127 | providerTurnId: input.providerTurnId, | |
| 128 | nativeItemRef: input.nativeItemRef, | |
| 129 | parentItemId: null, | |
| 130 | ordinal: input.ordinal, | |
| 131 | status: "completed" as const, | |
| 132 | title: null, | |
| 133 | startedAt: input.now, | |
| 134 | completedAt: input.now, | |
| 135 | updatedAt: input.now, | |
| 136 | messageId: input.messageId, | |
| 137 | text: input.text, | |
| 138 | }; | |
| 139 | const turnItem: OrchestrationV2TurnItem = | |
| 140 | input.role === "user" | |
| 141 | ? { | |
| 142 | ...base, | |
| 143 | createdBy: "agent", | |
| 144 | creationSource: "provider", | |
| 145 | type: "user_message", | |
| 146 | ...(input.senderThreadId === undefined ? {} : { senderThreadId: input.senderThreadId }), | |
| 147 | inputIntent: "turn_start", | |
| 148 | attachments: [], | |
| 149 | } | |
| 150 | : { | |
| 151 | ...base, | |
| 152 | type: "assistant_message", | |
| 153 | streaming: false, | |
| 154 | }; | |
| 155 | return { message, turnItem }; | |
| 156 | } | |
| 157 | ||
| 158 | export function subagentResultForRun( | |
| 159 | projection: Pick<OrchestrationV2ThreadProjection, "messages" | "turnItems">, | |
| 160 | run: OrchestrationV2Run, | |
| 161 | ): { | |
| 162 | readonly text: string; | |
| 163 | readonly messageId: OrchestrationV2ConversationMessage["id"] | null; | |
| 164 | readonly turnItemId: OrchestrationV2TurnItem["id"] | null; | |
| 165 | } { | |
| 166 | const message = | |
| 167 | projection.messages | |
| 168 | .filter( | |
| 169 | (candidate) => | |
| 170 | candidate.runId === run.id && | |
| 171 | candidate.role === "assistant" && | |
| 172 | candidate.text.trim().length > 0, | |
| 173 | ) | |
| 174 | .toSorted( | |
| 175 | (left, right) => | |
| 176 | DateTime.toEpochMillis(right.updatedAt) - DateTime.toEpochMillis(left.updatedAt), | |
| 177 | )[0] ?? null; | |
| 178 | const turnItem = | |
| 179 | projection.turnItems | |
| 180 | .filter( | |
| 181 | ( | |
| 182 | candidate, | |
| 183 | ): candidate is Extract<OrchestrationV2TurnItem, { readonly type: "assistant_message" }> => | |
| 184 | candidate.runId === run.id && | |
| 185 | candidate.type === "assistant_message" && | |
| 186 | candidate.text.trim().length > 0, | |
| 187 | ) | |
| 188 | .toSorted((left, right) => right.ordinal - left.ordinal)[0] ?? null; | |
| 189 | const failure = | |
| 190 | run.status === "failed" | |
| 191 | ? projection.turnItems | |
| 192 | .filter((item) => item.runId === run.id && item.type === "error") | |
| 193 | .toSorted((left, right) => right.ordinal - left.ordinal)[0] | |
| 194 | : undefined; | |
| 195 | const text = | |
| 196 | (failure?.type === "error" ? failure.failure.message : undefined) ?? | |
| 197 | message?.text ?? | |
| 198 | turnItem?.text ?? | |
| 199 | (run.status === "completed" | |
| 200 | ? "Child task completed without an assistant result." | |
| 201 | : `Child task ended with status ${run.status}.`); | |
| 202 | return { | |
| 203 | text, | |
| 204 | messageId: failure === undefined ? (message?.id ?? turnItem?.messageId ?? null) : null, | |
| 205 | turnItemId: failure?.id ?? turnItem?.id ?? null, | |
| 206 | }; | |
| 207 | } | |
| 208 | ||
| 209 | /** A finished turn can still own live children or queued completion follow-ups. */ | |
| 210 | export function delegatedTaskProgress(projection: { | |
| 211 | readonly runs: OrchestrationV2ThreadProjection["runs"]; | |
| 212 | readonly messages: ReadonlyArray< | |
| 213 | Pick<OrchestrationV2ConversationMessage, "runId" | "notification"> | |
| 214 | >; | |
| 215 | readonly subagents: ReadonlyArray< | |
| 216 | Pick<OrchestrationV2ThreadProjection["subagents"][number], "status" | "completionDelivery"> | |
| 217 | >; | |
| 218 | readonly providerThreads: ReadonlyArray< | |
| 219 | Pick<OrchestrationV2ThreadProjection["providerThreads"][number], "pendingBackgroundTasks"> | |
| 220 | >; | |
| 221 | }) { | |
| 222 | const terminal = (status: string) => | |
| 223 | ["completed", "failed", "cancelled", "interrupted", "rolled_back"].includes(status); | |
| 224 | const monitorRuns = new Set( | |
| 225 | projection.messages | |
| 226 | .filter((message) => message.notification?.source.kind === "monitor") | |
| 227 | .map((message) => message.runId), | |
| 228 | ); | |
| 229 | const workRuns = projection.runs.filter( | |
| 230 | (run) => !monitorRuns.has(run.id) && run.status !== "rolled_back", | |
| 231 | ); | |
| 232 | // A held queue waits for the user to resume it (after Stop, a restart, or a | |
| 233 | // provider failure), so its runs are not work the task still owes. | |
| 234 | const active = workRuns.some( | |
| 235 | (run) => !terminal(run.status) && !(run.status === "queued" && run.queueHeld === true), | |
| 236 | ); | |
| 237 | const children = | |
| 238 | projection.subagents.some( | |
| 239 | (task) => | |
| 240 | isOrchestrationV2WorkActive(task.status) || | |
| 241 | // Publishing a child's result precedes scheduling its parent's wake. | |
| 242 | // The parent still owes that follow-up even between those transactions. | |
| 243 | task.completionDelivery?.state === "pending" || | |
| 244 | task.completionDelivery?.state === "claimed", | |
| 245 | ) || | |
| 246 | projection.providerThreads.some((thread) => (thread.pendingBackgroundTasks?.length ?? 0) > 0); | |
| 247 | const resultRun = workRuns | |
| 248 | .filter((run) => terminal(run.status) && (run.startedAt !== null || run.ordinal === 1)) | |
| 249 | .toSorted((a, b) => (runRanAfter(a, b) ? -1 : runRanAfter(b, a) ? 1 : 0))[0]; | |
| 250 | return { | |
| 251 | state: | |
| 252 | active || resultRun === undefined | |
| 253 | ? ("working" as const) | |
| 254 | : children | |
| 255 | ? ("waiting_for_children" as const) | |
| 256 | : ("result_available" as const), | |
| 257 | resultRun, | |
| 258 | }; | |
| 259 | } |
packages/provider-opencode/package.json created+68| ... | ... | @@ -0,0 +1,68 @@ |
| 1 | { | |
| 2 | "name": "@t3tools/provider-opencode", | |
| 3 | "private": true, | |
| 4 | "type": "module", | |
| 5 | "exports": { | |
| 6 | "./client": { | |
| 7 | "types": "./src/client.ts", | |
| 8 | "import": "./src/client.ts" | |
| 9 | }, | |
| 10 | "./settings": { | |
| 11 | "types": "./src/settings.ts", | |
| 12 | "import": "./src/settings.ts" | |
| 13 | }, | |
| 14 | "./server": { | |
| 15 | "types": "./src/server.ts", | |
| 16 | "import": "./src/server.ts" | |
| 17 | }, | |
| 18 | "./testing": { | |
| 19 | "types": "./src/testing.ts", | |
| 20 | "import": "./src/testing.ts" | |
| 21 | }, | |
| 22 | "./server/OpenCodeRuntime": { | |
| 23 | "types": "./src/server/OpenCodeRuntime.ts", | |
| 24 | "import": "./src/server/OpenCodeRuntime.ts" | |
| 25 | }, | |
| 26 | "./server/OpenCodeServerLedger": { | |
| 27 | "types": "./src/server/OpenCodeServerLedger.ts", | |
| 28 | "import": "./src/server/OpenCodeServerLedger.ts" | |
| 29 | }, | |
| 30 | "./server/v2/adapter": { | |
| 31 | "types": "./src/server/v2/adapter.ts", | |
| 32 | "import": "./src/server/v2/adapter.ts" | |
| 33 | }, | |
| 34 | "./server/v2/OpenCode2Client": { | |
| 35 | "types": "./src/server/v2/OpenCode2Client.ts", | |
| 36 | "import": "./src/server/v2/OpenCode2Client.ts" | |
| 37 | }, | |
| 38 | "./server/v2/OpenCode2Server": { | |
| 39 | "types": "./src/server/v2/OpenCode2Server.ts", | |
| 40 | "import": "./src/server/v2/OpenCode2Server.ts" | |
| 41 | }, | |
| 42 | "./server/v2/textGeneration": { | |
| 43 | "types": "./src/server/v2/textGeneration.ts", | |
| 44 | "import": "./src/server/v2/textGeneration.ts" | |
| 45 | } | |
| 46 | }, | |
| 47 | "scripts": { | |
| 48 | "typecheck": "tsc --noEmit", | |
| 49 | "test": "vp test run --config ../../vite.config.ts --dir ." | |
| 50 | }, | |
| 51 | "dependencies": { | |
| 52 | "@opencode-ai/sdk": "^1.3.15", | |
| 53 | "@opencode/client": "2.0.23", | |
| 54 | "@opencode/protocol": "2.0.23", | |
| 55 | "@opencode/schema": "2.0.23", | |
| 56 | "@t3tools/contracts": "workspace:*", | |
| 57 | "@t3tools/provider-core": "workspace:*", | |
| 58 | "@t3tools/shared": "workspace:*", | |
| 59 | "effect": "catalog:" | |
| 60 | }, | |
| 61 | "devDependencies": { | |
| 62 | "@effect/platform-node": "catalog:", | |
| 63 | "@effect/vitest": "catalog:", | |
| 64 | "@t3tools/provider-testing": "workspace:*", | |
| 65 | "@types/node": "catalog:", | |
| 66 | "vite-plus": "catalog:" | |
| 67 | } | |
| 68 | } |
packages/provider-opencode/src/client.ts created+24| ... | ... | @@ -0,0 +1,24 @@ |
| 1 | /** | |
| 2 | * OpenCode's client definition: settings form, label, and glyph. | |
| 3 | * Browser- and React Native-safe. | |
| 4 | * | |
| 5 | * @module provider-opencode/client | |
| 6 | */ | |
| 7 | import { ProviderDriverKind } from "@t3tools/contracts"; | |
| 8 | import { defineProviderClient } from "@t3tools/provider-core/client"; | |
| 9 | ||
| 10 | import { OpenCodeSettings } from "./settings.ts"; | |
| 11 | ||
| 12 | export const openCodeClient = defineProviderClient({ | |
| 13 | driverKind: ProviderDriverKind.make("opencode"), | |
| 14 | label: "OpenCode", | |
| 15 | settingsSchema: OpenCodeSettings, | |
| 16 | icon: { | |
| 17 | viewBox: "0 0 32 40", | |
| 18 | fill: { light: "#211E1E", dark: "#F1ECEC" }, | |
| 19 | paths: [ | |
| 20 | { d: "M24 32H8V16H24V32Z", fill: { light: "#CFCECD", dark: "#4B4646" } }, | |
| 21 | { d: "M24 8H8V32H24V8ZM32 40H0V0H32V40Z" }, | |
| 22 | ], | |
| 23 | }, | |
| 24 | }); |
packages/provider-opencode/src/server.ts created+8| ... | ... | @@ -0,0 +1,8 @@ |
| 1 | /** | |
| 2 | * OpenCode's server entry: the driver the server registers, its adapter | |
| 3 | * driver, and the runtime layer the driver needs from the server. | |
| 4 | * | |
| 5 | * @module provider-opencode/server | |
| 6 | */ | |
| 7 | export { OpenCodeDriver, type OpenCodeDriverEnv } from "./server/driver.ts"; | |
| 8 | export { OpenCodeAdapterV2Driver, type OpenCodeAdapterV2DriverEnv } from "./server/adapter.ts"; |
packages/provider-opencode/src/server/OpenCodeRuntime.cliParsers.test.ts created+330| ... | ... | @@ -0,0 +1,330 @@ |
| 1 | import * as NodeAssert from "node:assert/strict"; | |
| 2 | ||
| 3 | import { describe, it } from "vite-plus/test"; | |
| 4 | ||
| 5 | import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; | |
| 6 | ||
| 7 | describe("parseModelsCliOutput", () => { | |
| 8 | it("parses a single model from a single provider", () => { | |
| 9 | const stdout = [ | |
| 10 | "anthropic/claude-sonnet-4-5", | |
| 11 | JSON.stringify({ | |
| 12 | id: "claude-sonnet-4-5", | |
| 13 | providerID: "anthropic", | |
| 14 | name: "Claude Sonnet 4.5", | |
| 15 | capabilities: { temperature: true, reasoning: true, toolcall: true }, | |
| 16 | cost: { input: 3, output: 15 }, | |
| 17 | limit: { context: 200000, output: 8192 }, | |
| 18 | status: "active", | |
| 19 | options: {}, | |
| 20 | headers: {}, | |
| 21 | release_date: "2025-01-01", | |
| 22 | }), | |
| 23 | ].join("\n"); | |
| 24 | ||
| 25 | const result = OpenCodeRuntime.parseModelsCliOutput(stdout); | |
| 26 | NodeAssert.equal(result.providers.size, 1); | |
| 27 | NodeAssert.equal(result.connected.length, 1); | |
| 28 | NodeAssert.equal(result.connected[0], "anthropic"); | |
| 29 | ||
| 30 | const provider = result.providers.get("anthropic")!; | |
| 31 | NodeAssert.ok(provider); | |
| 32 | NodeAssert.equal(provider.id, "anthropic"); | |
| 33 | NodeAssert.equal(provider.name, "anthropic"); | |
| 34 | NodeAssert.equal(Object.keys(provider.models).length, 1); | |
| 35 | ||
| 36 | const model = provider.models["claude-sonnet-4-5"]!; | |
| 37 | NodeAssert.ok(model); | |
| 38 | NodeAssert.equal(model.id, "claude-sonnet-4-5"); | |
| 39 | NodeAssert.equal(model.providerID, "anthropic"); | |
| 40 | NodeAssert.equal(model.name, "Claude Sonnet 4.5"); | |
| 41 | }); | |
| 42 | ||
| 43 | it("parses multiple models from multiple providers", () => { | |
| 44 | const stdout = [ | |
| 45 | "anthropic/claude-sonnet-4-5", | |
| 46 | JSON.stringify({ id: "claude-sonnet-4-5", providerID: "anthropic", name: "Sonnet 4.5" }), | |
| 47 | "anthropic/claude-haiku-4-5", | |
| 48 | JSON.stringify({ id: "claude-haiku-4-5", providerID: "anthropic", name: "Haiku 4.5" }), | |
| 49 | "openai/gpt-4o", | |
| 50 | JSON.stringify({ id: "gpt-4o", providerID: "openai", name: "GPT-4o" }), | |
| 51 | ].join("\n"); | |
| 52 | ||
| 53 | const result = OpenCodeRuntime.parseModelsCliOutput(stdout); | |
| 54 | NodeAssert.equal(result.providers.size, 2); | |
| 55 | NodeAssert.equal(result.connected.length, 2); | |
| 56 | NodeAssert.equal([...result.connected].sort().join(","), "anthropic,openai"); | |
| 57 | NodeAssert.equal(Object.keys(result.providers.get("anthropic")!.models).length, 2); | |
| 58 | NodeAssert.equal(Object.keys(result.providers.get("openai")!.models).length, 1); | |
| 59 | }); | |
| 60 | ||
| 61 | it("handles empty input", () => { | |
| 62 | const result = OpenCodeRuntime.parseModelsCliOutput(""); | |
| 63 | NodeAssert.equal(result.providers.size, 0); | |
| 64 | NodeAssert.equal(result.connected.length, 0); | |
| 65 | }); | |
| 66 | ||
| 67 | it("skips unparseable JSON blocks", () => { | |
| 68 | const stdout = [ | |
| 69 | "anthropic/claude-sonnet-4-5", | |
| 70 | "this is not valid json {{{", | |
| 71 | "anthropic/claude-haiku-4-5", | |
| 72 | JSON.stringify({ id: "claude-haiku-4-5", providerID: "anthropic", name: "Haiku 4.5" }), | |
| 73 | ].join("\n"); | |
| 74 | ||
| 75 | const result = OpenCodeRuntime.parseModelsCliOutput(stdout); | |
| 76 | NodeAssert.equal(result.providers.size, 1); | |
| 77 | const provider = result.providers.get("anthropic")!; | |
| 78 | NodeAssert.equal(Object.keys(provider.models).length, 1); | |
| 79 | NodeAssert.ok(provider.models["claude-haiku-4-5"]); | |
| 80 | }); | |
| 81 | ||
| 82 | it("handles Windows-style CRLF line endings", () => { | |
| 83 | const stdout = | |
| 84 | "anthropic/claude-sonnet-4-5\r\n" + | |
| 85 | JSON.stringify({ id: "claude-sonnet-4-5", providerID: "anthropic", name: "Sonnet" }) + | |
| 86 | "\r\n"; | |
| 87 | ||
| 88 | const result = OpenCodeRuntime.parseModelsCliOutput(stdout); | |
| 89 | NodeAssert.equal(result.providers.size, 1); | |
| 90 | NodeAssert.ok(result.providers.get("anthropic")!.models["claude-sonnet-4-5"]); | |
| 91 | }); | |
| 92 | ||
| 93 | it("handles model JSON with variants and nested fields", () => { | |
| 94 | const stdout = [ | |
| 95 | "opencode/gpt-5.4", | |
| 96 | JSON.stringify({ | |
| 97 | id: "gpt-5.4", | |
| 98 | providerID: "opencode", | |
| 99 | name: "GPT-5.4", | |
| 100 | family: "gpt", | |
| 101 | capabilities: { | |
| 102 | temperature: true, | |
| 103 | reasoning: true, | |
| 104 | attachment: false, | |
| 105 | toolcall: true, | |
| 106 | input: { text: true, audio: false, image: false, video: false, pdf: false }, | |
| 107 | output: { text: true, audio: false, image: false, video: false, pdf: false }, | |
| 108 | interleaved: false, | |
| 109 | }, | |
| 110 | cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, | |
| 111 | limit: { context: 200000, input: 160000, output: 32000 }, | |
| 112 | status: "active", | |
| 113 | options: {}, | |
| 114 | headers: {}, | |
| 115 | release_date: "2025-01-01", | |
| 116 | variants: { none: {}, low: {}, medium: {}, high: {} }, | |
| 117 | }), | |
| 118 | ].join("\n"); | |
| 119 | ||
| 120 | const result = OpenCodeRuntime.parseModelsCliOutput(stdout); | |
| 121 | const model = result.providers.get("opencode")!.models["gpt-5.4"]!; | |
| 122 | NodeAssert.ok(model); | |
| 123 | NodeAssert.ok(model.capabilities); | |
| 124 | NodeAssert.equal(model.capabilities!.reasoning, true); | |
| 125 | NodeAssert.ok(model.variants); | |
| 126 | NodeAssert.equal(model.variants!["medium"] !== undefined, true); | |
| 127 | }); | |
| 128 | ||
| 129 | it("keeps a model whose JSON body has a slash and no interior whitespace", () => { | |
| 130 | // OpenRouter-style: the model id contains a `/` and no string value has a | |
| 131 | // space, so the JSON body line itself matches the slug regex. It must still | |
| 132 | // be treated as the body of the preceding slug, not a new slug. | |
| 133 | const stdout = [ | |
| 134 | "openrouter/qwen/qwen3-coder", | |
| 135 | JSON.stringify({ | |
| 136 | id: "qwen/qwen3-coder", | |
| 137 | providerID: "openrouter", | |
| 138 | name: "qwen3-coder", | |
| 139 | status: "active", | |
| 140 | }), | |
| 141 | ].join("\n"); | |
| 142 | ||
| 143 | const result = OpenCodeRuntime.parseModelsCliOutput(stdout); | |
| 144 | NodeAssert.equal(result.providers.size, 1); | |
| 145 | NodeAssert.deepEqual([...result.connected], ["openrouter"]); | |
| 146 | const provider = result.providers.get("openrouter")!; | |
| 147 | NodeAssert.ok(provider); | |
| 148 | const model = provider.models["qwen/qwen3-coder"]!; | |
| 149 | NodeAssert.ok(model); | |
| 150 | NodeAssert.equal(model.id, "qwen/qwen3-coder"); | |
| 151 | NodeAssert.equal(model.providerID, "openrouter"); | |
| 152 | }); | |
| 153 | }); | |
| 154 | ||
| 155 | describe("parseAgentListCliOutput", () => { | |
| 156 | it("parses a single agent", () => { | |
| 157 | const stdout = [ | |
| 158 | "build (primary)", | |
| 159 | " " + JSON.stringify([{ permission: "*", action: "allow", pattern: "*" }]), | |
| 160 | ].join("\n"); | |
| 161 | ||
| 162 | const result = OpenCodeRuntime.parseAgentListCliOutput(stdout); | |
| 163 | NodeAssert.equal(result.length, 1); | |
| 164 | NodeAssert.equal(result[0]!.name, "build"); | |
| 165 | NodeAssert.equal(result[0]!.mode, "primary"); | |
| 166 | NodeAssert.equal(result[0]!.permission.length, 1); | |
| 167 | }); | |
| 168 | ||
| 169 | it("parses multiple agents", () => { | |
| 170 | const stdout = [ | |
| 171 | "build (primary)", | |
| 172 | " " + JSON.stringify([{ permission: "*", action: "allow", pattern: "*" }]), | |
| 173 | "explore (subagent)", | |
| 174 | " " + JSON.stringify([{ permission: "read", action: "allow", pattern: "*" }]), | |
| 175 | "plan (primary)", | |
| 176 | " " + JSON.stringify([{ permission: "edit", action: "ask", pattern: "*.md" }]), | |
| 177 | ].join("\n"); | |
| 178 | ||
| 179 | const result = OpenCodeRuntime.parseAgentListCliOutput(stdout); | |
| 180 | NodeAssert.equal(result.length, 3); | |
| 181 | NodeAssert.equal(result[0]!.name, "build"); | |
| 182 | NodeAssert.equal(result[0]!.mode, "primary"); | |
| 183 | NodeAssert.equal(result[1]!.name, "explore"); | |
| 184 | NodeAssert.equal(result[1]!.mode, "subagent"); | |
| 185 | NodeAssert.equal(result[2]!.name, "plan"); | |
| 186 | NodeAssert.equal(result[2]!.mode, "primary"); | |
| 187 | }); | |
| 188 | ||
| 189 | it("handles empty input", () => { | |
| 190 | const result = OpenCodeRuntime.parseAgentListCliOutput(""); | |
| 191 | NodeAssert.equal(result.length, 0); | |
| 192 | }); | |
| 193 | ||
| 194 | it("skips agents with unparseable permission JSON", () => { | |
| 195 | const stdout = [ | |
| 196 | "build (primary)", | |
| 197 | " not valid json {", | |
| 198 | "explore (subagent)", | |
| 199 | " " + JSON.stringify([{ permission: "read", action: "allow", pattern: "*" }]), | |
| 200 | ].join("\n"); | |
| 201 | ||
| 202 | const result = OpenCodeRuntime.parseAgentListCliOutput(stdout); | |
| 203 | NodeAssert.equal(result.length, 1); | |
| 204 | NodeAssert.equal(result[0]!.name, "explore"); | |
| 205 | }); | |
| 206 | ||
| 207 | it("handles real-world permission blocks with nested paths", () => { | |
| 208 | const permissions = [ | |
| 209 | { permission: "*", action: "allow", pattern: "*" }, | |
| 210 | { | |
| 211 | permission: "external_directory", | |
| 212 | pattern: "C:\\Users\\test\\.local\\*", | |
| 213 | action: "allow", | |
| 214 | }, | |
| 215 | { permission: "read", pattern: "*.env", action: "ask" }, | |
| 216 | ]; | |
| 217 | const stdout = ["build (primary)", " " + JSON.stringify(permissions)].join("\n"); | |
| 218 | ||
| 219 | const result = OpenCodeRuntime.parseAgentListCliOutput(stdout); | |
| 220 | NodeAssert.equal(result.length, 1); | |
| 221 | NodeAssert.equal(result[0]!.permission.length, 3); | |
| 222 | NodeAssert.equal(result[0]!.permission[0]!.action, "allow"); | |
| 223 | NodeAssert.equal(result[0]!.permission[2]!.action, "ask"); | |
| 224 | }); | |
| 225 | ||
| 226 | it("handles agent names with spaces", () => { | |
| 227 | const stdout = [ | |
| 228 | "code reviewer (subagent)", | |
| 229 | " " + JSON.stringify([{ permission: "read", action: "allow", pattern: "*" }]), | |
| 230 | "my custom agent (primary)", | |
| 231 | " " + JSON.stringify([{ permission: "edit", action: "ask", pattern: "*.ts" }]), | |
| 232 | ].join("\n"); | |
| 233 | ||
| 234 | const result = OpenCodeRuntime.parseAgentListCliOutput(stdout); | |
| 235 | NodeAssert.equal(result.length, 2); | |
| 236 | NodeAssert.equal(result[0]!.name, "code reviewer"); | |
| 237 | NodeAssert.equal(result[0]!.mode, "subagent"); | |
| 238 | NodeAssert.equal(result[1]!.name, "my custom agent"); | |
| 239 | NodeAssert.equal(result[1]!.mode, "primary"); | |
| 240 | }); | |
| 241 | ||
| 242 | it("marks known hidden agents", () => { | |
| 243 | const stdout = [ | |
| 244 | "compaction (primary)", | |
| 245 | " " + JSON.stringify([{ permission: "*", action: "allow", pattern: "*" }]), | |
| 246 | "build (primary)", | |
| 247 | " " + JSON.stringify([{ permission: "*", action: "allow", pattern: "*" }]), | |
| 248 | ].join("\n"); | |
| 249 | ||
| 250 | const result = OpenCodeRuntime.parseAgentListCliOutput(stdout); | |
| 251 | NodeAssert.equal(result[0]!.hidden, true); | |
| 252 | NodeAssert.equal(result[1]!.hidden, false); | |
| 253 | }); | |
| 254 | }); | |
| 255 | ||
| 256 | describe("parseSkillsCliOutput", () => { | |
| 257 | it("parses only skill metadata from the CLI JSON output", () => { | |
| 258 | const result = OpenCodeRuntime.parseSkillsCliOutput( | |
| 259 | JSON.stringify([ | |
| 260 | { | |
| 261 | name: "review-pr", | |
| 262 | description: "Review a pull request.", | |
| 263 | location: "/tmp/review-pr/SKILL.md", | |
| 264 | content: "---\nname: review-pr\n---\n", | |
| 265 | }, | |
| 266 | ]), | |
| 267 | ); | |
| 268 | ||
| 269 | NodeAssert.deepEqual(result, [ | |
| 270 | { | |
| 271 | name: "review-pr", | |
| 272 | description: "Review a pull request.", | |
| 273 | location: "/tmp/review-pr/SKILL.md", | |
| 274 | }, | |
| 275 | ]); | |
| 276 | }); | |
| 277 | ||
| 278 | it("degrades malformed output to an empty skill list", () => { | |
| 279 | NodeAssert.deepEqual(OpenCodeRuntime.parseSkillsCliOutput("not json"), []); | |
| 280 | }); | |
| 281 | }); | |
| 282 | ||
| 283 | describe("toOpenCodeFileParts", () => { | |
| 284 | const attachment = (mimeType: string, sizeBytes = 12) => ({ | |
| 285 | type: "file" as const, | |
| 286 | id: "thread-1-00000000-0000-4000-8000-000000000001-bin", | |
| 287 | name: "attachment", | |
| 288 | mimeType, | |
| 289 | sizeBytes, | |
| 290 | }); | |
| 291 | ||
| 292 | it("sends supported images, text, and PDFs natively and skips what models reject", () => { | |
| 293 | const parts = OpenCodeRuntime.toOpenCodeFileParts({ | |
| 294 | attachments: [ | |
| 295 | attachment("application/pdf"), | |
| 296 | attachment("text/markdown"), | |
| 297 | attachment("image/png"), | |
| 298 | // A ZIP file part makes OpenCode's Anthropic path throw before the | |
| 299 | // turn starts; it must ride only as the prompt's file path line. | |
| 300 | attachment("application/zip"), | |
| 301 | attachment("application/octet-stream"), | |
| 302 | // Image formats the model APIs reject stay on the fallback path too. | |
| 303 | attachment("image/bmp"), | |
| 304 | attachment("image/svg+xml"), | |
| 305 | // Over the direct-attachment limit: path fallback even for a PDF. | |
| 306 | attachment("application/pdf", 21 * 1024 * 1024), | |
| 307 | ], | |
| 308 | resolveAttachmentPath: () => "/tmp/attachment", | |
| 309 | }); | |
| 310 | ||
| 311 | NodeAssert.deepEqual( | |
| 312 | parts.map((part) => part.mime), | |
| 313 | ["application/pdf", "text/markdown", "image/png"], | |
| 314 | ); | |
| 315 | }); | |
| 316 | ||
| 317 | it("keeps folded clipboard text on the lazy path fallback", () => { | |
| 318 | const parts = OpenCodeRuntime.toOpenCodeFileParts({ | |
| 319 | attachments: [ | |
| 320 | { | |
| 321 | ...attachment("text/plain"), | |
| 322 | source: { _tag: "pasted-text" as const }, | |
| 323 | }, | |
| 324 | ], | |
| 325 | resolveAttachmentPath: () => "/tmp/pasted-text.txt", | |
| 326 | }); | |
| 327 | ||
| 328 | NodeAssert.deepEqual(parts, []); | |
| 329 | }); | |
| 330 | }); |
packages/provider-opencode/src/server/OpenCodeRuntime.environment.test.ts created+239| ... | ... | @@ -0,0 +1,239 @@ |
| 1 | import type { OpencodeClient } from "@opencode-ai/sdk/v2"; | |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 3 | import { it as effectIt } from "@effect/vitest"; | |
| 4 | import { | |
| 5 | HostProcessEnvironment, | |
| 6 | HostProcessExecutablePath, | |
| 7 | HostProcessPlatform, | |
| 8 | } from "@t3tools/shared/hostProcess"; | |
| 9 | import * as Effect from "effect/Effect"; | |
| 10 | import * as Fiber from "effect/Fiber"; | |
| 11 | import * as FileSystem from "effect/FileSystem"; | |
| 12 | import * as Layer from "effect/Layer"; | |
| 13 | import * as Path from "effect/Path"; | |
| 14 | import * as TestClock from "effect/testing/TestClock"; | |
| 15 | import { FetchHttpClient, HttpClient } from "effect/http"; | |
| 16 | import { describe, expect, it } from "vite-plus/test"; | |
| 17 | ||
| 18 | import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; | |
| 19 | import * as OpenCodeServerLedger from "./OpenCodeServerLedger.ts"; | |
| 20 | ||
| 21 | describe("resolveOpenCodeConfigContent", () => { | |
| 22 | it("prefers the caller environment over the inherited environment", () => { | |
| 23 | expect( | |
| 24 | OpenCodeRuntime.resolveOpenCodeConfigContent( | |
| 25 | { OPENCODE_CONFIG_CONTENT: '{"source":"caller"}' }, | |
| 26 | { OPENCODE_CONFIG_CONTENT: '{"source":"process"}' }, | |
| 27 | ), | |
| 28 | ).toBe('{"source":"caller"}'); | |
| 29 | }); | |
| 30 | ||
| 31 | it("falls back to the inherited environment and then an empty config", () => { | |
| 32 | expect( | |
| 33 | OpenCodeRuntime.resolveOpenCodeConfigContent(undefined, { | |
| 34 | OPENCODE_CONFIG_CONTENT: '{"source":"process"}', | |
| 35 | }), | |
| 36 | ).toBe('{"source":"process"}'); | |
| 37 | expect(OpenCodeRuntime.resolveOpenCodeConfigContent(undefined, {})).toBe("{}"); | |
| 38 | }); | |
| 39 | }); | |
| 40 | ||
| 41 | describe("resolveOpenCodeServerPassword", () => { | |
| 42 | it("uses the local environment password when settings do not provide one", () => { | |
| 43 | expect( | |
| 44 | OpenCodeRuntime.resolveOpenCodeServerPassword( | |
| 45 | { external: false, environment: { OPENCODE_SERVER_PASSWORD: " env password " } }, | |
| 46 | {}, | |
| 47 | ), | |
| 48 | ).toBe(" env password "); | |
| 49 | }); | |
| 50 | ||
| 51 | it("uses the settings password for a local server", () => { | |
| 52 | expect( | |
| 53 | OpenCodeRuntime.resolveOpenCodeServerPassword( | |
| 54 | { external: false, serverPassword: " settings password " }, | |
| 55 | {}, | |
| 56 | ), | |
| 57 | ).toBe(" settings password "); | |
| 58 | }); | |
| 59 | ||
| 60 | it("uses the settings password when local settings and environment differ", () => { | |
| 61 | expect( | |
| 62 | OpenCodeRuntime.resolveOpenCodeServerPassword( | |
| 63 | { | |
| 64 | external: false, | |
| 65 | serverPassword: "settings-password", | |
| 66 | environment: { OPENCODE_SERVER_PASSWORD: "environment-password" }, | |
| 67 | }, | |
| 68 | {}, | |
| 69 | ), | |
| 70 | ).toBe("settings-password"); | |
| 71 | }); | |
| 72 | ||
| 73 | it("does not send an inherited local password to an external server", () => { | |
| 74 | expect( | |
| 75 | OpenCodeRuntime.resolveOpenCodeServerPassword( | |
| 76 | { external: true, environment: { OPENCODE_SERVER_PASSWORD: "local-secret" } }, | |
| 77 | { OPENCODE_SERVER_PASSWORD: "inherited-secret" }, | |
| 78 | ), | |
| 79 | ).toBeUndefined(); | |
| 80 | }); | |
| 81 | }); | |
| 82 | ||
| 83 | function makeHealthClient( | |
| 84 | result: (options?: { readonly signal?: AbortSignal }) => Promise<unknown>, | |
| 85 | ): OpencodeClient { | |
| 86 | return { | |
| 87 | global: { | |
| 88 | health: result, | |
| 89 | }, | |
| 90 | } as unknown as OpencodeClient; | |
| 91 | } | |
| 92 | ||
| 93 | describe("verifyOpenCodeServerVersion", () => { | |
| 94 | effectIt.effect("accepts a supported server version", () => | |
| 95 | Effect.gen(function* () { | |
| 96 | const version = yield* OpenCodeRuntime.verifyOpenCodeServerVersion( | |
| 97 | makeHealthClient(() => Promise.resolve({ data: { healthy: true, version: "1.14.19" } })), | |
| 98 | ); | |
| 99 | expect(version).toBe("1.14.19"); | |
| 100 | }), | |
| 101 | ); | |
| 102 | ||
| 103 | effectIt.effect("rejects a server below the supported version", () => | |
| 104 | Effect.gen(function* () { | |
| 105 | const error = yield* OpenCodeRuntime.verifyOpenCodeServerVersion( | |
| 106 | makeHealthClient(() => Promise.resolve({ data: { healthy: true, version: "1.14.18" } })), | |
| 107 | ).pipe(Effect.flip); | |
| 108 | expect(error).toBeInstanceOf(OpenCodeRuntime.OpenCodeRuntimeError); | |
| 109 | expect(error.detail).toContain("v1.14.18 is too old"); | |
| 110 | }), | |
| 111 | ); | |
| 112 | ||
| 113 | for (const data of [ | |
| 114 | { healthy: true }, | |
| 115 | { healthy: true, version: "not-a-version" }, | |
| 116 | { healthy: false, version: "1.14.19" }, | |
| 117 | ]) { | |
| 118 | effectIt.effect(`rejects an invalid health response: ${JSON.stringify(data)}`, () => | |
| 119 | Effect.gen(function* () { | |
| 120 | const error = yield* OpenCodeRuntime.verifyOpenCodeServerVersion( | |
| 121 | makeHealthClient(() => Promise.resolve({ data })), | |
| 122 | ).pipe(Effect.flip); | |
| 123 | expect(error).toBeInstanceOf(OpenCodeRuntime.OpenCodeRuntimeError); | |
| 124 | expect(error.detail).toContain("requires OpenCode v1.14.19 or newer"); | |
| 125 | }), | |
| 126 | ); | |
| 127 | } | |
| 128 | ||
| 129 | effectIt.effect("preserves an unauthorized health error", () => | |
| 130 | Effect.gen(function* () { | |
| 131 | const error = yield* OpenCodeRuntime.verifyOpenCodeServerVersion( | |
| 132 | makeHealthClient(() => | |
| 133 | Promise.reject({ response: { status: 401 }, error: { message: "Unauthorized" } }), | |
| 134 | ), | |
| 135 | ).pipe(Effect.flip); | |
| 136 | expect(error).toBeInstanceOf(OpenCodeRuntime.OpenCodeRuntimeError); | |
| 137 | expect(error.detail).toContain("status=401"); | |
| 138 | expect(error.detail).toContain("Unauthorized"); | |
| 139 | }), | |
| 140 | ); | |
| 141 | ||
| 142 | effectIt.effect("aborts a health request when the version check times out", () => | |
| 143 | Effect.gen(function* () { | |
| 144 | let requestSignal: AbortSignal | undefined; | |
| 145 | const checkFiber = yield* OpenCodeRuntime.verifyOpenCodeServerVersion( | |
| 146 | makeHealthClient((options) => { | |
| 147 | requestSignal = options?.signal; | |
| 148 | return new Promise(() => undefined); | |
| 149 | }), | |
| 150 | ).pipe(Effect.flip, Effect.forkChild); | |
| 151 | ||
| 152 | yield* Effect.yieldNow; | |
| 153 | expect(requestSignal).toBeDefined(); | |
| 154 | yield* TestClock.adjust("6 seconds"); | |
| 155 | ||
| 156 | const error = yield* Fiber.join(checkFiber); | |
| 157 | expect(error.detail).toBe("Timed out while checking the OpenCode server version."); | |
| 158 | expect(requestSignal?.aborted).toBe(true); | |
| 159 | }).pipe(Effect.provide(TestClock.layer())), | |
| 160 | ); | |
| 161 | }); | |
| 162 | ||
| 163 | describe("OpenCode server output", () => { | |
| 164 | effectIt.live( | |
| 165 | "drains stdout and stderr after startup so server requests can finish", | |
| 166 | () => | |
| 167 | Effect.gen(function* () { | |
| 168 | const fs = yield* FileSystem.FileSystem; | |
| 169 | const path = yield* Path.Path; | |
| 170 | const environment = yield* HostProcessEnvironment; | |
| 171 | const executablePath = yield* HostProcessExecutablePath; | |
| 172 | const platform = yield* HostProcessPlatform; | |
| 173 | const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-output-" }); | |
| 174 | const isWindows = platform === "win32"; | |
| 175 | const binaryPath = path.join(tempDir, isWindows ? "opencode.cmd" : "opencode"); | |
| 176 | const scriptPath = path.join(tempDir, "opencode.mjs"); | |
| 177 | ||
| 178 | yield* fs.writeFileString( | |
| 179 | scriptPath, | |
| 180 | `import { createServer } from "node:http"; | |
| 181 | const writeOutput = (stream) => new Promise((resolve, reject) => { | |
| 182 | stream.write("x".repeat(2 * 1024 * 1024), (error) => error ? reject(error) : resolve()); | |
| 183 | }); | |
| 184 | const server = createServer(async (request, response) => { | |
| 185 | if (request.url.startsWith("/global/health")) { | |
| 186 | response.setHeader("Content-Type", "application/json"); | |
| 187 | response.end(JSON.stringify({ healthy: true, version: "1.14.19" })); | |
| 188 | return; | |
| 189 | } | |
| 190 | await Promise.all([writeOutput(process.stdout), writeOutput(process.stderr)]); | |
| 191 | response.end("drained"); | |
| 192 | }); | |
| 193 | server.listen(0, "127.0.0.1", () => { | |
| 194 | process.stdout.write("opencode server listening on http://127.0.0.1:" + server.address().port + "\\n"); | |
| 195 | }); | |
| 196 | `, | |
| 197 | ); | |
| 198 | yield* fs.writeFileString( | |
| 199 | binaryPath, | |
| 200 | [ | |
| 201 | ...(isWindows ? ["@echo off"] : ["#!/bin/sh"]), | |
| 202 | isWindows | |
| 203 | ? '"%T3_TEST_NODE_BINARY%" "%T3_TEST_OPENCODE_SCRIPT%" %*' | |
| 204 | : 'exec "$T3_TEST_NODE_BINARY" "$T3_TEST_OPENCODE_SCRIPT" "$@"', | |
| 205 | "", | |
| 206 | ].join("\n"), | |
| 207 | ); | |
| 208 | if (!isWindows) { | |
| 209 | yield* fs.chmod(binaryPath, 0o755); | |
| 210 | } | |
| 211 | ||
| 212 | const runtime = yield* OpenCodeRuntime.OpenCodeRuntime; | |
| 213 | const server = yield* runtime.startOpenCodeServerProcess({ | |
| 214 | binaryPath, | |
| 215 | directory: tempDir, | |
| 216 | port: 0, | |
| 217 | environment: { | |
| 218 | ...environment, | |
| 219 | T3_TEST_NODE_BINARY: executablePath, | |
| 220 | T3_TEST_OPENCODE_SCRIPT: scriptPath, | |
| 221 | }, | |
| 222 | }); | |
| 223 | const response = yield* HttpClient.get(`${server.url}/output`); | |
| 224 | ||
| 225 | expect(yield* response.text).toBe("drained"); | |
| 226 | expect(yield* server.isRunning).toBe(true); | |
| 227 | }).pipe( | |
| 228 | Effect.scoped, | |
| 229 | Effect.provide([ | |
| 230 | OpenCodeRuntime.layer.pipe( | |
| 231 | Layer.provide(OpenCodeServerLedger.layerTest), | |
| 232 | Layer.provideMerge(NodeServices.layer), | |
| 233 | ), | |
| 234 | FetchHttpClient.layer, | |
| 235 | ]), | |
| 236 | ), | |
| 237 | 10_000, | |
| 238 | ); | |
| 239 | }); |
packages/provider-opencode/src/server/OpenCodeRuntime.inventory.test.ts created+290| ... | ... | @@ -0,0 +1,290 @@ |
| 1 | import * as NodeAssert from "node:assert/strict"; | |
| 2 | ||
| 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 4 | import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2"; | |
| 5 | import { it } from "@effect/vitest"; | |
| 6 | import * as Effect from "effect/Effect"; | |
| 7 | import * as Fiber from "effect/Fiber"; | |
| 8 | import * as FileSystem from "effect/FileSystem"; | |
| 9 | import * as Layer from "effect/Layer"; | |
| 10 | import * as Path from "effect/Path"; | |
| 11 | import * as Queue from "effect/Queue"; | |
| 12 | import { | |
| 13 | HostProcessEnvironment, | |
| 14 | HostProcessExecutablePath, | |
| 15 | HostProcessPlatform, | |
| 16 | } from "@t3tools/shared/hostProcess"; | |
| 17 | ||
| 18 | import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; | |
| 19 | import * as OpenCodeServerLedger from "./OpenCodeServerLedger.ts"; | |
| 20 | ||
| 21 | const layerTest = OpenCodeRuntime.layer.pipe( | |
| 22 | Layer.provide(OpenCodeServerLedger.layerTest), | |
| 23 | Layer.provideMerge(NodeServices.layer), | |
| 24 | ); | |
| 25 | ||
| 26 | it.layer(layerTest)("OpenCodeRuntime inventory", (it) => { | |
| 27 | it.effect("aborts pending SDK requests when inventory loading is interrupted", () => | |
| 28 | Effect.gen(function* () { | |
| 29 | const runtime = yield* OpenCodeRuntime.OpenCodeRuntime; | |
| 30 | const started = yield* Queue.make<void>(); | |
| 31 | const aborted = yield* Queue.make<string>(); | |
| 32 | const client = createOpencodeClient({ | |
| 33 | baseUrl: "http://opencode.test", | |
| 34 | fetch: Object.assign( | |
| 35 | (input: string | Request | URL) => { | |
| 36 | const request = input instanceof Request ? input : new Request(input.toString()); | |
| 37 | return new Promise<Response>((_resolve, reject) => { | |
| 38 | request.signal.addEventListener( | |
| 39 | "abort", | |
| 40 | () => { | |
| 41 | Queue.offerUnsafe(aborted, new URL(request.url).pathname); | |
| 42 | reject(request.signal.reason); | |
| 43 | }, | |
| 44 | { once: true }, | |
| 45 | ); | |
| 46 | Queue.offerUnsafe(started, undefined); | |
| 47 | }); | |
| 48 | }, | |
| 49 | { preconnect: () => undefined }, | |
| 50 | ), | |
| 51 | }); | |
| 52 | ||
| 53 | const inventoryFiber = yield* runtime.loadOpenCodeInventory(client).pipe(Effect.forkChild); | |
| 54 | yield* Queue.takeN(started, 4); | |
| 55 | yield* Fiber.interrupt(inventoryFiber); | |
| 56 | ||
| 57 | NodeAssert.deepEqual((yield* Queue.takeAll(aborted)).toSorted(), [ | |
| 58 | "/agent", | |
| 59 | "/command", | |
| 60 | "/provider", | |
| 61 | "/skill", | |
| 62 | ]); | |
| 63 | }), | |
| 64 | ); | |
| 65 | ||
| 66 | it.effect("discovers directory-scoped commands without retaining prompt templates", () => | |
| 67 | Effect.gen(function* () { | |
| 68 | const runtime = yield* OpenCodeRuntime.OpenCodeRuntime; | |
| 69 | const requests: Request[] = []; | |
| 70 | const client = createOpencodeClient({ | |
| 71 | baseUrl: "http://opencode.test", | |
| 72 | directory: "/workspace/project", | |
| 73 | fetch: Object.assign( | |
| 74 | async (input: string | Request | URL) => { | |
| 75 | const request = input instanceof Request ? input : new Request(input.toString()); | |
| 76 | requests.push(request); | |
| 77 | const route = new URL(request.url).pathname; | |
| 78 | return Response.json( | |
| 79 | route === "/provider" | |
| 80 | ? { connected: ["openai"], all: [], default: {} } | |
| 81 | : route === "/command" | |
| 82 | ? [ | |
| 83 | { | |
| 84 | name: "review", | |
| 85 | description: "Review changes", | |
| 86 | source: "command", | |
| 87 | hints: ["$ARGUMENTS"], | |
| 88 | template: "private native template", | |
| 89 | }, | |
| 90 | ] | |
| 91 | : [], | |
| 92 | ); | |
| 93 | }, | |
| 94 | { preconnect: () => undefined }, | |
| 95 | ), | |
| 96 | }); | |
| 97 | const inventory = yield* runtime.loadOpenCodeInventory(client); | |
| 98 | NodeAssert.deepEqual(inventory.commands, [ | |
| 99 | { | |
| 100 | name: "review", | |
| 101 | description: "Review changes", | |
| 102 | source: "command", | |
| 103 | hints: ["$ARGUMENTS"], | |
| 104 | }, | |
| 105 | ]); | |
| 106 | const commandRequest = requests.find( | |
| 107 | (request) => new URL(request.url).pathname === "/command", | |
| 108 | ); | |
| 109 | NodeAssert.ok(commandRequest); | |
| 110 | NodeAssert.equal( | |
| 111 | new URL(commandRequest.url).searchParams.get("directory") ?? | |
| 112 | decodeURIComponent(commandRequest.headers.get("x-opencode-directory") ?? ""), | |
| 113 | "/workspace/project", | |
| 114 | ); | |
| 115 | }), | |
| 116 | ); | |
| 117 | ||
| 118 | it.effect("keeps provider inventory when agent discovery fails", () => | |
| 119 | Effect.gen(function* () { | |
| 120 | const runtime = yield* OpenCodeRuntime.OpenCodeRuntime; | |
| 121 | const client = { | |
| 122 | provider: { | |
| 123 | list: () => | |
| 124 | Promise.resolve({ | |
| 125 | data: { | |
| 126 | connected: ["openai"], | |
| 127 | all: [], | |
| 128 | default: {}, | |
| 129 | }, | |
| 130 | }), | |
| 131 | }, | |
| 132 | app: { | |
| 133 | agents: () => Promise.reject(new Error("agents endpoint unavailable")), | |
| 134 | skills: () => Promise.resolve({ data: [] }), | |
| 135 | }, | |
| 136 | } as unknown as OpencodeClient; | |
| 137 | ||
| 138 | const inventory = yield* runtime.loadOpenCodeInventory(client); | |
| 139 | ||
| 140 | NodeAssert.deepEqual(inventory.providerList.connected, ["openai"]); | |
| 141 | NodeAssert.deepEqual(inventory.agents, []); | |
| 142 | NodeAssert.deepEqual(inventory.skills, []); | |
| 143 | }), | |
| 144 | ); | |
| 145 | ||
| 146 | it.effect("keeps provider inventory when skill discovery fails", () => | |
| 147 | Effect.gen(function* () { | |
| 148 | const runtime = yield* OpenCodeRuntime.OpenCodeRuntime; | |
| 149 | const client = { | |
| 150 | provider: { | |
| 151 | list: () => | |
| 152 | Promise.resolve({ | |
| 153 | data: { | |
| 154 | connected: ["openai"], | |
| 155 | all: [], | |
| 156 | default: {}, | |
| 157 | }, | |
| 158 | }), | |
| 159 | }, | |
| 160 | app: { | |
| 161 | agents: () => Promise.resolve({ data: [] }), | |
| 162 | skills: () => Promise.reject(new Error("skills endpoint unavailable")), | |
| 163 | }, | |
| 164 | } as unknown as OpencodeClient; | |
| 165 | ||
| 166 | const inventory = yield* runtime.loadOpenCodeInventory(client); | |
| 167 | ||
| 168 | NodeAssert.deepEqual(inventory.providerList.connected, ["openai"]); | |
| 169 | NodeAssert.deepEqual(inventory.agents, []); | |
| 170 | NodeAssert.deepEqual(inventory.skills, []); | |
| 171 | }), | |
| 172 | ); | |
| 173 | ||
| 174 | it.effect("keeps only SDK skill metadata in inventory", () => | |
| 175 | Effect.gen(function* () { | |
| 176 | const runtime = yield* OpenCodeRuntime.OpenCodeRuntime; | |
| 177 | const client = { | |
| 178 | provider: { | |
| 179 | list: () => | |
| 180 | Promise.resolve({ | |
| 181 | data: { | |
| 182 | connected: ["openai"], | |
| 183 | all: [], | |
| 184 | default: {}, | |
| 185 | }, | |
| 186 | }), | |
| 187 | }, | |
| 188 | app: { | |
| 189 | agents: () => Promise.resolve({ data: [] }), | |
| 190 | skills: () => | |
| 191 | Promise.resolve({ | |
| 192 | data: [ | |
| 193 | { | |
| 194 | name: "review", | |
| 195 | description: "Review code changes", | |
| 196 | location: "/skills/review/SKILL.md", | |
| 197 | content: "unused skill content", | |
| 198 | }, | |
| 199 | ], | |
| 200 | }), | |
| 201 | }, | |
| 202 | } as unknown as OpencodeClient; | |
| 203 | ||
| 204 | const inventory = yield* runtime.loadOpenCodeInventory(client); | |
| 205 | ||
| 206 | NodeAssert.deepEqual(inventory.skills, [ | |
| 207 | { | |
| 208 | name: "review", | |
| 209 | description: "Review code changes", | |
| 210 | location: "/skills/review/SKILL.md", | |
| 211 | }, | |
| 212 | ]); | |
| 213 | }), | |
| 214 | ); | |
| 215 | ||
| 216 | it.effect("drops oversized CLI skill output without losing the model inventory", () => | |
| 217 | Effect.gen(function* () { | |
| 218 | const fs = yield* FileSystem.FileSystem; | |
| 219 | const path = yield* Path.Path; | |
| 220 | const hostEnvironment = yield* HostProcessEnvironment; | |
| 221 | const executablePath = yield* HostProcessExecutablePath; | |
| 222 | const hostPlatform = yield* HostProcessPlatform; | |
| 223 | const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-inventory-" }); | |
| 224 | const isWindows = hostPlatform === "win32"; | |
| 225 | const binaryPath = path.join(tempDir, isWindows ? "opencode.cmd" : "opencode"); | |
| 226 | const scriptPath = path.join(tempDir, "opencode.mjs"); | |
| 227 | const oversizedContentBytes = 8 * 1024 * 1024 + 1; | |
| 228 | ||
| 229 | yield* fs.writeFileString( | |
| 230 | scriptPath, | |
| 231 | [ | |
| 232 | 'if (process.argv[2] === "models") {', | |
| 233 | ' process.stdout.write(`openai/gpt-test\\n{"id":"gpt-test","providerID":"openai","name":"GPT Test"}\\n`);', | |
| 234 | '} else if (process.argv[2] === "debug") {', | |
| 235 | ` const content = "x".repeat(${oversizedContentBytes});`, | |
| 236 | ' process.stdout.write(`[{"name":"oversized","content":"${content}"}]`);', | |
| 237 | "}", | |
| 238 | "", | |
| 239 | ].join("\n"), | |
| 240 | ); | |
| 241 | yield* fs.writeFileString( | |
| 242 | binaryPath, | |
| 243 | [ | |
| 244 | ...(isWindows ? ["@echo off"] : ["#!/bin/sh"]), | |
| 245 | isWindows | |
| 246 | ? '"%T3_TEST_NODE_BINARY%" "%T3_TEST_OPENCODE_SCRIPT%" %*' | |
| 247 | : 'exec "$T3_TEST_NODE_BINARY" "$T3_TEST_OPENCODE_SCRIPT" "$@"', | |
| 248 | "", | |
| 249 | ].join("\n"), | |
| 250 | ); | |
| 251 | if (!isWindows) { | |
| 252 | yield* fs.chmod(binaryPath, 0o755); | |
| 253 | } | |
| 254 | ||
| 255 | const runtime = yield* OpenCodeRuntime.OpenCodeRuntime; | |
| 256 | const inventory = yield* runtime.loadInventoryFromCli({ | |
| 257 | binaryPath, | |
| 258 | cwd: tempDir, | |
| 259 | environment: { | |
| 260 | ...hostEnvironment, | |
| 261 | T3_TEST_NODE_BINARY: executablePath, | |
| 262 | T3_TEST_OPENCODE_SCRIPT: scriptPath, | |
| 263 | }, | |
| 264 | }); | |
| 265 | ||
| 266 | NodeAssert.deepEqual(inventory.providerList.connected, ["openai"]); | |
| 267 | NodeAssert.equal(inventory.skills.length, 0); | |
| 268 | }), | |
| 269 | ); | |
| 270 | ||
| 271 | it.effect("caps and drains command stdout and stderr when requested", () => | |
| 272 | Effect.gen(function* () { | |
| 273 | const runtime = yield* OpenCodeRuntime.OpenCodeRuntime; | |
| 274 | const executablePath = yield* HostProcessExecutablePath; | |
| 275 | const outputBytes = 2 * 1024 * 1024; | |
| 276 | const result = yield* runtime.runOpenCodeCommand({ | |
| 277 | binaryPath: executablePath, | |
| 278 | args: [ | |
| 279 | "-e", | |
| 280 | `process.stdout.write("o".repeat(${outputBytes})); process.stderr.write("e".repeat(${outputBytes}));`, | |
| 281 | ], | |
| 282 | maxOutputBytes: 64, | |
| 283 | }); | |
| 284 | ||
| 285 | NodeAssert.equal(result.stdout, "o".repeat(64)); | |
| 286 | NodeAssert.equal(result.stderr, "e".repeat(64)); | |
| 287 | NodeAssert.equal(result.code, 0); | |
| 288 | }), | |
| 289 | ); | |
| 290 | }); |
packages/provider-opencode/src/server/OpenCodeRuntime.permissions.test.ts created+89| ... | ... | @@ -0,0 +1,89 @@ |
| 1 | import * as NodeAssert from "node:assert/strict"; | |
| 2 | ||
| 3 | import * as RegExpUtils from "effect/RegExp"; | |
| 4 | import { describe, it } from "vite-plus/test"; | |
| 5 | ||
| 6 | import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; | |
| 7 | ||
| 8 | function actionFor( | |
| 9 | runtimeMode: Parameters<typeof OpenCodeRuntime.buildOpenCodePermissionRules>[0], | |
| 10 | permission: string, | |
| 11 | target = "*", | |
| 12 | ) { | |
| 13 | // OpenCode uses the last matching rule. Its wildcards match directory separators. | |
| 14 | return OpenCodeRuntime.buildOpenCodePermissionRules(runtimeMode).findLast( | |
| 15 | (rule) => | |
| 16 | (rule.permission === "*" || rule.permission === permission) && | |
| 17 | new RegExp(`^${RegExpUtils.escape(rule.pattern).replaceAll("\\*", ".*")}$`, "s").test(target), | |
| 18 | )?.action; | |
| 19 | } | |
| 20 | ||
| 21 | describe("buildOpenCodePermissionRules", () => { | |
| 22 | it("pre-approves edits once the user has chosen to auto-accept them", () => { | |
| 23 | NodeAssert.equal(actionFor("auto-accept-edits", "edit"), "allow"); | |
| 24 | }); | |
| 25 | ||
| 26 | it("still asks before editing when approval is required", () => { | |
| 27 | NodeAssert.equal(actionFor("approval-required", "edit"), "ask"); | |
| 28 | }); | |
| 29 | ||
| 30 | // Documented in docs/user/permission-modes.md: providers without an AI | |
| 31 | // reviewer, OpenCode among them, fall back to Supervised for "auto". | |
| 32 | it("leaves auto asking, as the docs say it does without a reviewer", () => { | |
| 33 | NodeAssert.equal(actionFor("auto", "edit"), "ask"); | |
| 34 | }); | |
| 35 | ||
| 36 | it("allows workspace reads and task updates without asking in supervised modes", () => { | |
| 37 | for (const runtimeMode of ["approval-required", "auto-accept-edits", "auto"] as const) { | |
| 38 | for (const permission of ["read", "glob", "grep", "lsp", "skill", "todowrite"]) { | |
| 39 | NodeAssert.equal(actionFor(runtimeMode, permission, "src/index.ts"), "allow"); | |
| 40 | } | |
| 41 | } | |
| 42 | }); | |
| 43 | ||
| 44 | it("preserves OpenCode's environment-file approval rules", () => { | |
| 45 | for (const runtimeMode of ["approval-required", "auto-accept-edits", "auto"] as const) { | |
| 46 | for (const target of [ | |
| 47 | ".env", | |
| 48 | ".env.local", | |
| 49 | "config/service.env", | |
| 50 | "config/service.env.local", | |
| 51 | ]) { | |
| 52 | NodeAssert.equal(actionFor(runtimeMode, "read", target), "ask"); | |
| 53 | } | |
| 54 | for (const target of [".env.example", "config/service.env.example"]) { | |
| 55 | NodeAssert.equal(actionFor(runtimeMode, "read", target), "allow"); | |
| 56 | } | |
| 57 | } | |
| 58 | }); | |
| 59 | ||
| 60 | it("still asks before commands, network access, external directories and unknown tools", () => { | |
| 61 | for (const runtimeMode of ["approval-required", "auto-accept-edits", "auto"] as const) { | |
| 62 | NodeAssert.equal(actionFor(runtimeMode, "bash"), "ask"); | |
| 63 | NodeAssert.equal(actionFor(runtimeMode, "webfetch"), "ask"); | |
| 64 | NodeAssert.equal(actionFor(runtimeMode, "websearch"), "ask"); | |
| 65 | NodeAssert.equal(actionFor(runtimeMode, "external_directory"), "ask"); | |
| 66 | NodeAssert.equal(actionFor(runtimeMode, "doom_loop"), "ask"); | |
| 67 | NodeAssert.equal(actionFor(runtimeMode, "custom_tool"), "ask"); | |
| 68 | } | |
| 69 | }); | |
| 70 | ||
| 71 | it("allows everything only under full access", () => { | |
| 72 | NodeAssert.deepEqual(OpenCodeRuntime.buildOpenCodePermissionRules("full-access"), [ | |
| 73 | { permission: "*", pattern: "*", action: "allow" }, | |
| 74 | { permission: "external_directory", pattern: "*", action: "allow" }, | |
| 75 | ]); | |
| 76 | }); | |
| 77 | }); | |
| 78 | ||
| 79 | describe("toOpenCodePermissionReply", () => { | |
| 80 | it.each([ | |
| 81 | ["accept", "once"], | |
| 82 | ["acceptForSession", "always"], | |
| 83 | ["acceptAlways", "always"], | |
| 84 | ["decline", "reject"], | |
| 85 | ["cancel", "reject"], | |
| 86 | ] as const)("maps %s to %s", (decision, reply) => { | |
| 87 | NodeAssert.equal(OpenCodeRuntime.toOpenCodePermissionReply(decision), reply); | |
| 88 | }); | |
| 89 | }); |
packages/provider-opencode/src/server/OpenCodeRuntime.ts created+1111| ... | ... | @@ -0,0 +1,1111 @@ |
| 1 | import * as NodeURL from "node:url"; | |
| 2 | ||
| 3 | import type { ChatAttachment, ProviderApprovalDecision, RuntimeMode } from "@t3tools/contracts"; | |
| 4 | import { | |
| 5 | createOpencodeClient, | |
| 6 | type Agent, | |
| 7 | type Command, | |
| 8 | type FilePartInput, | |
| 9 | type Model, | |
| 10 | type OpencodeClient, | |
| 11 | type PermissionRuleset, | |
| 12 | type ProviderListResponse, | |
| 13 | type QuestionAnswer, | |
| 14 | type QuestionRequest, | |
| 15 | } from "@opencode-ai/sdk/v2"; | |
| 16 | import * as Cause from "effect/Cause"; | |
| 17 | import * as Context from "effect/Context"; | |
| 18 | import * as Data from "effect/Data"; | |
| 19 | import * as Deferred from "effect/Deferred"; | |
| 20 | import * as Effect from "effect/Effect"; | |
| 21 | import * as Exit from "effect/Exit"; | |
| 22 | import * as Fiber from "effect/Fiber"; | |
| 23 | import * as Layer from "effect/Layer"; | |
| 24 | import * as Option from "effect/Option"; | |
| 25 | import * as P from "effect/Predicate"; | |
| 26 | import * as Ref from "effect/Ref"; | |
| 27 | import * as Result from "effect/Result"; | |
| 28 | import * as Scope from "effect/Scope"; | |
| 29 | import * as Schema from "effect/Schema"; | |
| 30 | import * as Stream from "effect/Stream"; | |
| 31 | import { ChildProcess, ChildProcessSpawner } from "effect/process"; | |
| 32 | ||
| 33 | import { signalProcessGroup } from "@t3tools/provider-core/server/processGroup"; | |
| 34 | import { isWindowsCommandNotFound } from "@t3tools/provider-core/server/snapshotProbe"; | |
| 35 | import * as OpenCodeServerLedger from "./OpenCodeServerLedger.ts"; | |
| 36 | import { collectStreamAsString } from "@t3tools/provider-core/server/snapshotProbe"; | |
| 37 | import * as NetService from "@t3tools/shared/Net"; | |
| 38 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 39 | import { compareSemverVersions, parseSemver } from "@t3tools/shared/semver"; | |
| 40 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; | |
| 41 | const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown)); | |
| 42 | const OPENCODE_EMPTY_CONFIG_CONTENT = "{}"; | |
| 43 | ||
| 44 | export const MINIMUM_OPENCODE_VERSION = "1.14.19"; | |
| 45 | const OPENCODE_HEALTH_TIMEOUT = "5 seconds"; | |
| 46 | ||
| 47 | const OpenCodeHealthSchema = Schema.Struct({ | |
| 48 | healthy: Schema.Literal(true), | |
| 49 | version: Schema.String, | |
| 50 | }); | |
| 51 | const decodeOpenCodeHealth = Schema.decodeUnknownEffect(OpenCodeHealthSchema); | |
| 52 | ||
| 53 | export function resolveOpenCodeConfigContent( | |
| 54 | inputEnvironment: Readonly<Record<string, string | undefined>> | undefined, | |
| 55 | inheritedEnvironment: Readonly<Record<string, string | undefined>> = process.env, | |
| 56 | ): string { | |
| 57 | return ( | |
| 58 | inputEnvironment?.OPENCODE_CONFIG_CONTENT ?? | |
| 59 | inheritedEnvironment.OPENCODE_CONFIG_CONTENT ?? | |
| 60 | OPENCODE_EMPTY_CONFIG_CONTENT | |
| 61 | ); | |
| 62 | } | |
| 63 | ||
| 64 | export function resolveOpenCodeServerPassword( | |
| 65 | input: { | |
| 66 | readonly external: boolean; | |
| 67 | readonly serverPassword?: string; | |
| 68 | readonly environment?: Readonly<Record<string, string | undefined>>; | |
| 69 | }, | |
| 70 | inheritedEnvironment: Readonly<Record<string, string | undefined>> = process.env, | |
| 71 | ): string | undefined { | |
| 72 | if (input.serverPassword !== undefined) { | |
| 73 | return input.serverPassword; | |
| 74 | } | |
| 75 | if (input.external) { | |
| 76 | return undefined; | |
| 77 | } | |
| 78 | return input.environment === undefined | |
| 79 | ? inheritedEnvironment.OPENCODE_SERVER_PASSWORD | |
| 80 | : input.environment.OPENCODE_SERVER_PASSWORD; | |
| 81 | } | |
| 82 | ||
| 83 | const DEFAULT_OPENCODE_SERVER_TIMEOUT_MS = 30_000; | |
| 84 | const DEFAULT_HOSTNAME = "127.0.0.1"; | |
| 85 | const OPENCODE_SERVER_STARTUP_MAX_OUTPUT_CHARS = 64 * 1024; | |
| 86 | const OPENCODE_SKILL_DISCOVERY_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; | |
| 87 | export interface OpenCodeServerProcess { | |
| 88 | readonly url: string; | |
| 89 | readonly serverPassword?: string; | |
| 90 | readonly version: string; | |
| 91 | readonly isRunning: Effect.Effect<boolean>; | |
| 92 | readonly exitCode: Effect.Effect<number, never>; | |
| 93 | } | |
| 94 | ||
| 95 | export interface OpenCodeServerConnection { | |
| 96 | readonly url: string; | |
| 97 | readonly serverPassword?: string; | |
| 98 | readonly version: string; | |
| 99 | readonly exitCode: Effect.Effect<number, never> | null; | |
| 100 | readonly external: boolean; | |
| 101 | } | |
| 102 | ||
| 103 | const OPENCODE_RUNTIME_ERROR_TAG = "OpenCodeRuntimeError"; | |
| 104 | export class OpenCodeRuntimeError extends Data.TaggedError(OPENCODE_RUNTIME_ERROR_TAG)<{ | |
| 105 | readonly operation: string; | |
| 106 | readonly cause?: unknown; | |
| 107 | readonly detail: string; | |
| 108 | }> { | |
| 109 | static readonly is = (u: unknown): u is OpenCodeRuntimeError => | |
| 110 | P.isTagged(u, OPENCODE_RUNTIME_ERROR_TAG); | |
| 111 | } | |
| 112 | ||
| 113 | function encodeJsonStringForDiagnostics(input: unknown): string | undefined { | |
| 114 | const result = encodeUnknownJsonStringExit(input); | |
| 115 | return Exit.isSuccess(result) ? result.value : undefined; | |
| 116 | } | |
| 117 | ||
| 118 | export function openCodeRuntimeErrorDetail(cause: unknown): string { | |
| 119 | if (OpenCodeRuntimeError.is(cause)) return cause.detail; | |
| 120 | if (cause instanceof Error && cause.message.trim().length > 0) return cause.message.trim(); | |
| 121 | if (cause && typeof cause === "object") { | |
| 122 | // SDK v2 throws { response, request, error? } shapes — extract what's useful | |
| 123 | const anyCause = cause as Record<string, unknown>; | |
| 124 | const status = (anyCause.response as { status?: number } | undefined)?.status; | |
| 125 | const body = anyCause.error ?? anyCause.data ?? anyCause.body; | |
| 126 | const encodedBody = encodeJsonStringForDiagnostics(body ?? cause); | |
| 127 | if (encodedBody) { | |
| 128 | return `status=${status ?? "?"} body=${encodedBody}`; | |
| 129 | } | |
| 130 | } | |
| 131 | return String(cause); | |
| 132 | } | |
| 133 | ||
| 134 | export const runOpenCodeSdk = <A>( | |
| 135 | operation: string, | |
| 136 | fn: (signal: AbortSignal) => Promise<A>, | |
| 137 | ): Effect.Effect<A, OpenCodeRuntimeError> => | |
| 138 | Effect.tryPromise({ | |
| 139 | try: fn, | |
| 140 | catch: (cause) => | |
| 141 | new OpenCodeRuntimeError({ operation, detail: openCodeRuntimeErrorDetail(cause), cause }), | |
| 142 | }).pipe(Effect.withSpan(`opencode.${operation}`)); | |
| 143 | ||
| 144 | export const verifyOpenCodeServerVersion = Effect.fn("verifyOpenCodeServerVersion")(function* ( | |
| 145 | client: OpencodeClient, | |
| 146 | ) { | |
| 147 | const healthOption = yield* runOpenCodeSdk("global.health", (signal) => | |
| 148 | client.global.health({ signal }), | |
| 149 | ).pipe(Effect.timeoutOption(OPENCODE_HEALTH_TIMEOUT)); | |
| 150 | if (Option.isNone(healthOption)) { | |
| 151 | return yield* new OpenCodeRuntimeError({ | |
| 152 | operation: "global.health", | |
| 153 | detail: "Timed out while checking the OpenCode server version.", | |
| 154 | }); | |
| 155 | } | |
| 156 | ||
| 157 | const health = yield* decodeOpenCodeHealth(healthOption.value.data).pipe( | |
| 158 | Effect.mapError( | |
| 159 | (cause) => | |
| 160 | new OpenCodeRuntimeError({ | |
| 161 | operation: "global.health", | |
| 162 | detail: `OpenCode server returned an invalid health response. T3 Code requires OpenCode v${MINIMUM_OPENCODE_VERSION} or newer.`, | |
| 163 | cause, | |
| 164 | }), | |
| 165 | ), | |
| 166 | ); | |
| 167 | if (parseSemver(health.version) === null) { | |
| 168 | return yield* new OpenCodeRuntimeError({ | |
| 169 | operation: "global.health", | |
| 170 | detail: `OpenCode server returned an invalid version. T3 Code requires OpenCode v${MINIMUM_OPENCODE_VERSION} or newer.`, | |
| 171 | }); | |
| 172 | } | |
| 173 | if (compareSemverVersions(health.version, MINIMUM_OPENCODE_VERSION) < 0) { | |
| 174 | return yield* new OpenCodeRuntimeError({ | |
| 175 | operation: "global.health", | |
| 176 | detail: `OpenCode v${health.version} is too old. Upgrade to v${MINIMUM_OPENCODE_VERSION} or newer.`, | |
| 177 | }); | |
| 178 | } | |
| 179 | return health.version; | |
| 180 | }); | |
| 181 | ||
| 182 | export interface OpenCodeCommandResult { | |
| 183 | readonly stdout: string; | |
| 184 | readonly stderr: string; | |
| 185 | readonly code: number; | |
| 186 | } | |
| 187 | ||
| 188 | export interface OpenCodeInventory { | |
| 189 | readonly providerList: ProviderListResponse; | |
| 190 | readonly agents: ReadonlyArray<Agent>; | |
| 191 | readonly skills: ReadonlyArray<OpenCodeSkill>; | |
| 192 | readonly commands?: ReadonlyArray<OpenCodeSlashCommand>; | |
| 193 | } | |
| 194 | ||
| 195 | export type OpenCodeSlashCommand = Pick<Command, "name" | "description" | "source" | "hints">; | |
| 196 | ||
| 197 | /** Command templates stay in OpenCode, which expands arguments and runs MCP prompts. */ | |
| 198 | export const loadOpenCodeCommands = (client: OpencodeClient) => | |
| 199 | runOpenCodeSdk("command.list", (signal) => client.command.list(undefined, { signal })).pipe( | |
| 200 | Effect.map((result): ReadonlyArray<OpenCodeSlashCommand> => | |
| 201 | (result.data ?? []).map(({ name, description, source, hints }) => ({ | |
| 202 | name, | |
| 203 | ...(description === undefined ? {} : { description }), | |
| 204 | ...(source === undefined ? {} : { source }), | |
| 205 | hints, | |
| 206 | })), | |
| 207 | ), | |
| 208 | ); | |
| 209 | ||
| 210 | export interface ParsedOpenCodeModelSlug { | |
| 211 | readonly providerID: string; | |
| 212 | readonly modelID: string; | |
| 213 | } | |
| 214 | ||
| 215 | export interface OpenCodeSkill { | |
| 216 | readonly name?: string | null; | |
| 217 | readonly description?: string | null; | |
| 218 | readonly location?: string | null; | |
| 219 | } | |
| 220 | ||
| 221 | const OpenCodeSkillSchema = Schema.Struct({ | |
| 222 | name: Schema.optionalKey(Schema.NullOr(Schema.String)), | |
| 223 | description: Schema.optionalKey(Schema.NullOr(Schema.String)), | |
| 224 | location: Schema.optionalKey(Schema.NullOr(Schema.String)), | |
| 225 | }); | |
| 226 | const decodeOpenCodeSkillsCliOutputExit = Schema.decodeUnknownExit( | |
| 227 | Schema.fromJsonString(Schema.Array(OpenCodeSkillSchema)), | |
| 228 | ); | |
| 229 | ||
| 230 | export interface OpenCodeRuntimeShape { | |
| 231 | /** | |
| 232 | * Spawns a local OpenCode server process. Its lifetime is bound to the caller's | |
| 233 | * `Scope.Scope` — the child is killed automatically when that scope closes. | |
| 234 | * Consumers that want a long-lived server must create and hold a scope explicitly | |
| 235 | * (see {@link Scope.make}) and close it when done. | |
| 236 | */ | |
| 237 | readonly startOpenCodeServerProcess: (input: { | |
| 238 | readonly binaryPath: string; | |
| 239 | readonly directory: string; | |
| 240 | readonly serverPassword?: string; | |
| 241 | readonly environment?: NodeJS.ProcessEnv; | |
| 242 | readonly port?: number; | |
| 243 | readonly hostname?: string; | |
| 244 | readonly timeoutMs?: number; | |
| 245 | /** Checks the listening server and returns its version. Defaults to the 1.x health check. */ | |
| 246 | readonly verify?: (url: string) => Effect.Effect<string, OpenCodeRuntimeError>; | |
| 247 | }) => Effect.Effect<OpenCodeServerProcess, OpenCodeRuntimeError, Scope.Scope>; | |
| 248 | /** | |
| 249 | * Returns a handle to either an externally-managed OpenCode server (when | |
| 250 | * `serverUrl` is provided — no lifetime is attached to the caller's scope) or a | |
| 251 | * freshly spawned local server whose lifetime is bound to the caller's scope. | |
| 252 | */ | |
| 253 | readonly connectToOpenCodeServer: (input: { | |
| 254 | readonly binaryPath: string; | |
| 255 | readonly directory: string; | |
| 256 | readonly serverUrl?: string | null; | |
| 257 | readonly serverPassword?: string; | |
| 258 | readonly environment?: NodeJS.ProcessEnv; | |
| 259 | readonly port?: number; | |
| 260 | readonly hostname?: string; | |
| 261 | readonly timeoutMs?: number; | |
| 262 | }) => Effect.Effect<OpenCodeServerConnection, OpenCodeRuntimeError, Scope.Scope>; | |
| 263 | readonly runOpenCodeCommand: (input: { | |
| 264 | readonly binaryPath: string; | |
| 265 | readonly args: ReadonlyArray<string>; | |
| 266 | readonly environment?: NodeJS.ProcessEnv; | |
| 267 | readonly cwd?: string; | |
| 268 | readonly maxOutputBytes?: number; | |
| 269 | }) => Effect.Effect<OpenCodeCommandResult, OpenCodeRuntimeError>; | |
| 270 | readonly createOpenCodeSdkClient: (input: { | |
| 271 | readonly baseUrl: string; | |
| 272 | readonly directory: string; | |
| 273 | readonly serverPassword?: string; | |
| 274 | }) => OpencodeClient; | |
| 275 | readonly loadOpenCodeInventory: ( | |
| 276 | client: OpencodeClient, | |
| 277 | ) => Effect.Effect<OpenCodeInventory, OpenCodeRuntimeError>; | |
| 278 | readonly loadOpenCodeSkills: ( | |
| 279 | client: OpencodeClient, | |
| 280 | ) => Effect.Effect<ReadonlyArray<OpenCodeSkill>, OpenCodeRuntimeError>; | |
| 281 | readonly loadInventoryFromCli: (input: { | |
| 282 | readonly binaryPath: string; | |
| 283 | readonly cwd: string; | |
| 284 | readonly environment?: NodeJS.ProcessEnv; | |
| 285 | }) => Effect.Effect<OpenCodeInventory, OpenCodeRuntimeError>; | |
| 286 | readonly loadSkillsFromCli: (input: { | |
| 287 | readonly binaryPath: string; | |
| 288 | readonly cwd: string; | |
| 289 | readonly environment?: NodeJS.ProcessEnv; | |
| 290 | }) => Effect.Effect<ReadonlyArray<OpenCodeSkill>, OpenCodeRuntimeError>; | |
| 291 | } | |
| 292 | ||
| 293 | function parseServerUrlFromOutput(output: string): string | null { | |
| 294 | for (const line of output.split("\n")) { | |
| 295 | const match = line.match(/server listening on\s+(https?:\/\/[^\s]+)/i); | |
| 296 | if (match?.[1]) return match[1]; | |
| 297 | } | |
| 298 | return null; | |
| 299 | } | |
| 300 | ||
| 301 | const SLUG_LINE_RE = /^(\S+\/\S+)\s*$/; | |
| 302 | const AGENT_HEADER_RE = /^(.+)\s+\((\S+)\)\s*$/; | |
| 303 | ||
| 304 | // Agents that are always hidden in OpenCode but the CLI "agent list" command | |
| 305 | // does not expose the hidden flag. Keep in sync with OpenCode agent | |
| 306 | // definitions (in the OpenCode repo: packages/opencode/src/agent/agent.ts). | |
| 307 | const KNOWN_HIDDEN_AGENTS = new Set(["compaction", "summary", "title"]); | |
| 308 | ||
| 309 | /** @internal */ | |
| 310 | export function parseModelsCliOutput(stdout: string): { | |
| 311 | readonly providers: ReadonlyMap< | |
| 312 | string, | |
| 313 | { readonly id: string; readonly name: string; readonly models: { [key: string]: Model } } | |
| 314 | >; | |
| 315 | readonly connected: ReadonlyArray<string>; | |
| 316 | } { | |
| 317 | const providers = new Map< | |
| 318 | string, | |
| 319 | { id: string; name: string; models: { [key: string]: Model } } | |
| 320 | >(); | |
| 321 | const lines = stdout.split("\n"); | |
| 322 | let currentSlug: string | null = null; | |
| 323 | const jsonLines: Array<string> = []; | |
| 324 | ||
| 325 | const flushModel = () => { | |
| 326 | if (currentSlug !== null && jsonLines.length > 0) { | |
| 327 | const jsonStr = jsonLines.join("\n").trim(); | |
| 328 | if (jsonStr.length > 0) { | |
| 329 | try { | |
| 330 | const model = JSON.parse(jsonStr) as Model; | |
| 331 | const separator = currentSlug.indexOf("/"); | |
| 332 | if (separator > 0) { | |
| 333 | const providerID = currentSlug.slice(0, separator); | |
| 334 | const modelID = currentSlug.slice(separator + 1); | |
| 335 | let provider = providers.get(providerID); | |
| 336 | if (!provider) { | |
| 337 | provider = { id: providerID, name: providerID, models: {} }; | |
| 338 | providers.set(providerID, provider); | |
| 339 | } | |
| 340 | provider.models[modelID] = model; | |
| 341 | } | |
| 342 | } catch { | |
| 343 | // Skip unparseable model JSON | |
| 344 | } | |
| 345 | } | |
| 346 | } | |
| 347 | currentSlug = null; | |
| 348 | jsonLines.length = 0; | |
| 349 | }; | |
| 350 | ||
| 351 | for (const line of lines) { | |
| 352 | // A model's JSON body is a single `JSON.stringify` line starting with `{`, | |
| 353 | // while a provider/model slug is a bare `provider/model` header. Only the | |
| 354 | // latter can be a slug: without this guard a body line with no interior | |
| 355 | // whitespace and a `/` in one of its values (e.g. an OpenRouter model whose | |
| 356 | // `id` is `vendor/model`) matches SLUG_LINE_RE, so flushModel runs against | |
| 357 | // an empty body and the model is silently dropped. | |
| 358 | const slugMatch = line.trimStart().startsWith("{") ? null : SLUG_LINE_RE.exec(line); | |
| 359 | if (slugMatch) { | |
| 360 | flushModel(); | |
| 361 | currentSlug = slugMatch[1]!; | |
| 362 | } else if (currentSlug !== null) { | |
| 363 | jsonLines.push(line); | |
| 364 | } | |
| 365 | } | |
| 366 | flushModel(); | |
| 367 | ||
| 368 | return { providers, connected: [...providers.keys()] }; | |
| 369 | } | |
| 370 | ||
| 371 | /** @internal */ | |
| 372 | export function parseAgentListCliOutput(stdout: string): ReadonlyArray<Agent> { | |
| 373 | const agents: Array<Agent> = []; | |
| 374 | const lines = stdout.split("\n"); | |
| 375 | let currentHeader: { name: string; mode: string } | null = null; | |
| 376 | const blockLines: Array<string> = []; | |
| 377 | ||
| 378 | const flushAgent = () => { | |
| 379 | if (currentHeader !== null) { | |
| 380 | const jsonStr = blockLines.join("\n").trim(); | |
| 381 | if (jsonStr.length > 0) { | |
| 382 | try { | |
| 383 | const permission = JSON.parse(jsonStr); | |
| 384 | agents.push({ | |
| 385 | name: currentHeader.name, | |
| 386 | mode: currentHeader.mode as Agent["mode"], | |
| 387 | hidden: KNOWN_HIDDEN_AGENTS.has(currentHeader.name), | |
| 388 | permission, | |
| 389 | options: {}, | |
| 390 | }); | |
| 391 | } catch { | |
| 392 | // Skip unparseable agent | |
| 393 | } | |
| 394 | } | |
| 395 | } | |
| 396 | currentHeader = null; | |
| 397 | blockLines.length = 0; | |
| 398 | }; | |
| 399 | ||
| 400 | for (const line of lines) { | |
| 401 | const match = AGENT_HEADER_RE.exec(line); | |
| 402 | if (match) { | |
| 403 | flushAgent(); | |
| 404 | currentHeader = { name: match[1]!, mode: match[2]! }; | |
| 405 | } else if (currentHeader !== null) { | |
| 406 | blockLines.push(line); | |
| 407 | } | |
| 408 | } | |
| 409 | flushAgent(); | |
| 410 | ||
| 411 | return agents; | |
| 412 | } | |
| 413 | ||
| 414 | /** @internal */ | |
| 415 | export function parseSkillsCliOutput(stdout: string): ReadonlyArray<OpenCodeSkill> { | |
| 416 | const result = decodeOpenCodeSkillsCliOutputExit(stdout); | |
| 417 | return Exit.isSuccess(result) ? result.value : []; | |
| 418 | } | |
| 419 | ||
| 420 | export function parseOpenCodeModelSlug( | |
| 421 | slug: string | null | undefined, | |
| 422 | ): ParsedOpenCodeModelSlug | null { | |
| 423 | if (typeof slug !== "string") { | |
| 424 | return null; | |
| 425 | } | |
| 426 | ||
| 427 | const trimmed = slug.trim(); | |
| 428 | const separator = trimmed.indexOf("/"); | |
| 429 | if (separator <= 0 || separator === trimmed.length - 1) { | |
| 430 | return null; | |
| 431 | } | |
| 432 | ||
| 433 | return { | |
| 434 | providerID: trimmed.slice(0, separator), | |
| 435 | modelID: trimmed.slice(separator + 1), | |
| 436 | }; | |
| 437 | } | |
| 438 | ||
| 439 | export function openCodeQuestionId( | |
| 440 | index: number, | |
| 441 | question: QuestionRequest["questions"][number], | |
| 442 | ): string { | |
| 443 | const header = question.header | |
| 444 | .trim() | |
| 445 | .toLowerCase() | |
| 446 | .replace(/[^a-z0-9_-]+/g, "-"); | |
| 447 | return header.length > 0 ? `question-${index}-${header}` : `question-${index}`; | |
| 448 | } | |
| 449 | ||
| 450 | /** | |
| 451 | * Attachments OpenCode can hand to a model as a native file part. Anything | |
| 452 | * else (ZIP, binaries, image formats like BMP/AVIF/SVG that model APIs | |
| 453 | * reject, or files over the direct-attachment size limit) would make the turn | |
| 454 | * fail before it starts, so those ride only as the file path ProviderService | |
| 455 | * puts in the prompt. | |
| 456 | */ | |
| 457 | const OPENCODE_NATIVE_IMAGE_MIMES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]); | |
| 458 | const OPENCODE_NATIVE_FILE_PART_MAX_BYTES = 20 * 1024 * 1024; | |
| 459 | ||
| 460 | function isOpenCodeNativeFilePart(input: { | |
| 461 | readonly mimeType: string; | |
| 462 | readonly sizeBytes: number; | |
| 463 | }): boolean { | |
| 464 | if (input.sizeBytes > OPENCODE_NATIVE_FILE_PART_MAX_BYTES) { | |
| 465 | return false; | |
| 466 | } | |
| 467 | const normalized = input.mimeType.trim().toLowerCase(); | |
| 468 | return ( | |
| 469 | OPENCODE_NATIVE_IMAGE_MIMES.has(normalized) || | |
| 470 | normalized.startsWith("text/") || | |
| 471 | normalized === "application/pdf" | |
| 472 | ); | |
| 473 | } | |
| 474 | ||
| 475 | export function toOpenCodeFileParts(input: { | |
| 476 | readonly attachments: ReadonlyArray<ChatAttachment> | undefined; | |
| 477 | readonly resolveAttachmentPath: (attachment: ChatAttachment) => string | null; | |
| 478 | }): Array<FilePartInput> { | |
| 479 | const parts: Array<FilePartInput> = []; | |
| 480 | ||
| 481 | for (const attachment of input.attachments ?? []) { | |
| 482 | if ( | |
| 483 | attachment.type === "file" && | |
| 484 | "source" in attachment && | |
| 485 | attachment.source?._tag === "pasted-text" | |
| 486 | ) { | |
| 487 | continue; | |
| 488 | } | |
| 489 | if (!isOpenCodeNativeFilePart(attachment)) { | |
| 490 | continue; | |
| 491 | } | |
| 492 | const attachmentPath = input.resolveAttachmentPath(attachment); | |
| 493 | if (!attachmentPath) { | |
| 494 | continue; | |
| 495 | } | |
| 496 | ||
| 497 | parts.push({ | |
| 498 | type: "file", | |
| 499 | mime: attachment.mimeType, | |
| 500 | filename: attachment.name, | |
| 501 | url: NodeURL.pathToFileURL(attachmentPath).href, | |
| 502 | }); | |
| 503 | } | |
| 504 | ||
| 505 | return parts; | |
| 506 | } | |
| 507 | ||
| 508 | export function buildOpenCodePermissionRules(runtimeMode: RuntimeMode): PermissionRuleset { | |
| 509 | if (runtimeMode === "full-access") { | |
| 510 | return [ | |
| 511 | { permission: "*", pattern: "*", action: "allow" }, | |
| 512 | { permission: "external_directory", pattern: "*", action: "allow" }, | |
| 513 | ]; | |
| 514 | } | |
| 515 | ||
| 516 | // "Auto-accept edits" is documented as "auto-approve edits, ask before other | |
| 517 | // actions", so prompting for every edit ignores the mode the user picked. | |
| 518 | // "auto" is left asking on purpose: the docs say providers without an AI | |
| 519 | // reviewer, OpenCode among them, fall back to Supervised for that mode. | |
| 520 | const editAction = runtimeMode === "auto-accept-edits" ? "allow" : "ask"; | |
| 521 | ||
| 522 | // Session rules override OpenCode's agent defaults. Allow reads and task | |
| 523 | // updates, but keep its default approval rules for environment files. | |
| 524 | return [ | |
| 525 | { permission: "*", pattern: "*", action: "ask" }, | |
| 526 | { permission: "read", pattern: "*", action: "allow" }, | |
| 527 | { permission: "read", pattern: "*.env", action: "ask" }, | |
| 528 | { permission: "read", pattern: "*.env.*", action: "ask" }, | |
| 529 | { permission: "read", pattern: "*.env.example", action: "allow" }, | |
| 530 | { permission: "glob", pattern: "*", action: "allow" }, | |
| 531 | { permission: "grep", pattern: "*", action: "allow" }, | |
| 532 | { permission: "lsp", pattern: "*", action: "allow" }, | |
| 533 | { permission: "skill", pattern: "*", action: "allow" }, | |
| 534 | { permission: "todowrite", pattern: "*", action: "allow" }, | |
| 535 | { permission: "bash", pattern: "*", action: "ask" }, | |
| 536 | { permission: "edit", pattern: "*", action: editAction }, | |
| 537 | { permission: "webfetch", pattern: "*", action: "ask" }, | |
| 538 | { permission: "websearch", pattern: "*", action: "ask" }, | |
| 539 | { permission: "codesearch", pattern: "*", action: "ask" }, | |
| 540 | { permission: "external_directory", pattern: "*", action: "ask" }, | |
| 541 | { permission: "doom_loop", pattern: "*", action: "ask" }, | |
| 542 | { permission: "question", pattern: "*", action: "allow" }, | |
| 543 | ]; | |
| 544 | } | |
| 545 | ||
| 546 | export function toOpenCodePermissionReply( | |
| 547 | decision: ProviderApprovalDecision, | |
| 548 | ): "once" | "always" | "reject" { | |
| 549 | switch (decision) { | |
| 550 | case "accept": | |
| 551 | return "once"; | |
| 552 | case "acceptForSession": | |
| 553 | case "acceptAlways": | |
| 554 | return "always"; | |
| 555 | case "decline": | |
| 556 | case "cancel": | |
| 557 | default: | |
| 558 | return "reject"; | |
| 559 | } | |
| 560 | } | |
| 561 | ||
| 562 | export function toOpenCodeQuestionAnswers( | |
| 563 | request: QuestionRequest, | |
| 564 | answers: Record<string, unknown>, | |
| 565 | ): Array<QuestionAnswer> { | |
| 566 | return request.questions.map((question, index) => { | |
| 567 | const raw = | |
| 568 | answers[openCodeQuestionId(index, question)] ?? | |
| 569 | answers[question.header] ?? | |
| 570 | answers[question.question]; | |
| 571 | if (Array.isArray(raw)) { | |
| 572 | return raw.filter((value): value is string => typeof value === "string"); | |
| 573 | } | |
| 574 | if (typeof raw === "string") { | |
| 575 | return raw.trim().length > 0 ? [raw] : []; | |
| 576 | } | |
| 577 | return []; | |
| 578 | }); | |
| 579 | } | |
| 580 | ||
| 581 | function ensureRuntimeError( | |
| 582 | operation: OpenCodeRuntimeError["operation"], | |
| 583 | detail: string, | |
| 584 | cause: unknown, | |
| 585 | ): OpenCodeRuntimeError { | |
| 586 | return OpenCodeRuntimeError.is(cause) | |
| 587 | ? cause | |
| 588 | : new OpenCodeRuntimeError({ operation, detail, cause }); | |
| 589 | } | |
| 590 | ||
| 591 | const makeOpenCodeRuntime = Effect.gen(function* () { | |
| 592 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 593 | const netService = yield* NetService.NetService; | |
| 594 | const hostPlatform = yield* HostProcessPlatform; | |
| 595 | const serverLedger = yield* OpenCodeServerLedger.OpenCodeServerLedger; | |
| 596 | const resolveCommand = (command: string, args: ReadonlyArray<string>, env?: NodeJS.ProcessEnv) => | |
| 597 | resolveSpawnCommand(command, args, env ? { env } : {}); | |
| 598 | ||
| 599 | const runOpenCodeCommand: OpenCodeRuntimeShape["runOpenCodeCommand"] = (input) => | |
| 600 | Effect.gen(function* () { | |
| 601 | const spawnCommand = yield* resolveCommand(input.binaryPath, input.args, input.environment); | |
| 602 | const child = yield* spawner.spawn( | |
| 603 | ChildProcess.make(spawnCommand.command, spawnCommand.args, { | |
| 604 | detached: hostPlatform !== "win32", | |
| 605 | shell: spawnCommand.shell, | |
| 606 | ...(input.cwd ? { cwd: input.cwd } : {}), | |
| 607 | ...(input.environment ? { env: input.environment } : { extendEnv: true }), | |
| 608 | }), | |
| 609 | ); | |
| 610 | const terminateCommandGroup = | |
| 611 | hostPlatform === "win32" | |
| 612 | ? child.kill({ killSignal: "SIGKILL" }).pipe(Effect.asVoid) | |
| 613 | : Effect.sync(() => { | |
| 614 | try { | |
| 615 | signalProcessGroup(Number(child.pid), "SIGKILL"); | |
| 616 | } catch { | |
| 617 | // The command and its process group may already have exited. | |
| 618 | } | |
| 619 | }); | |
| 620 | yield* Effect.addFinalizer(() => terminateCommandGroup.pipe(Effect.ignore)); | |
| 621 | const collectOptions = | |
| 622 | input.maxOutputBytes === undefined ? undefined : { maxBytes: input.maxOutputBytes }; | |
| 623 | const [stdout, stderr, code] = yield* Effect.all( | |
| 624 | [ | |
| 625 | collectStreamAsString(child.stdout, collectOptions), | |
| 626 | collectStreamAsString(child.stderr, collectOptions), | |
| 627 | child.exitCode, | |
| 628 | ], | |
| 629 | { concurrency: "unbounded" }, | |
| 630 | ); | |
| 631 | const exitCode = Number(code); | |
| 632 | if (yield* isWindowsCommandNotFound(exitCode, stderr)) { | |
| 633 | return yield* new OpenCodeRuntimeError({ | |
| 634 | operation: "runOpenCodeCommand", | |
| 635 | detail: `spawn ${input.binaryPath} ENOENT`, | |
| 636 | }); | |
| 637 | } | |
| 638 | return { | |
| 639 | stdout, | |
| 640 | stderr, | |
| 641 | code: exitCode, | |
| 642 | } satisfies OpenCodeCommandResult; | |
| 643 | }).pipe( | |
| 644 | Effect.scoped, | |
| 645 | Effect.mapError((cause) => | |
| 646 | ensureRuntimeError( | |
| 647 | "runOpenCodeCommand", | |
| 648 | `Failed to execute '${input.binaryPath} ${input.args.join(" ")}': ${openCodeRuntimeErrorDetail(cause)}`, | |
| 649 | cause, | |
| 650 | ), | |
| 651 | ), | |
| 652 | ); | |
| 653 | ||
| 654 | const createOpenCodeSdkClient: OpenCodeRuntimeShape["createOpenCodeSdkClient"] = (input) => | |
| 655 | createOpencodeClient({ | |
| 656 | baseUrl: input.baseUrl, | |
| 657 | directory: input.directory, | |
| 658 | ...(input.serverPassword | |
| 659 | ? { | |
| 660 | headers: { | |
| 661 | Authorization: `Basic ${Buffer.from(`opencode:${input.serverPassword}`, "utf8").toString("base64")}`, | |
| 662 | }, | |
| 663 | } | |
| 664 | : {}), | |
| 665 | throwOnError: true, | |
| 666 | }); | |
| 667 | ||
| 668 | const startOpenCodeServerProcess: OpenCodeRuntimeShape["startOpenCodeServerProcess"] = (input) => | |
| 669 | Effect.gen(function* () { | |
| 670 | // Bind this server's lifetime to the caller's scope. When the caller's | |
| 671 | // scope closes, the spawned child is killed and all associated fibers | |
| 672 | // are interrupted automatically — no `close()` method needed. | |
| 673 | const runtimeScope = yield* Scope.Scope; | |
| 674 | ||
| 675 | const hostname = input.hostname ?? DEFAULT_HOSTNAME; | |
| 676 | const port = | |
| 677 | input.port ?? | |
| 678 | (yield* netService.findAvailablePort(0).pipe( | |
| 679 | Effect.mapError( | |
| 680 | (cause) => | |
| 681 | new OpenCodeRuntimeError({ | |
| 682 | operation: "startOpenCodeServerProcess", | |
| 683 | detail: `Failed to find available port: ${openCodeRuntimeErrorDetail(cause)}`, | |
| 684 | cause, | |
| 685 | }), | |
| 686 | ), | |
| 687 | )); | |
| 688 | const timeoutMs = input.timeoutMs ?? DEFAULT_OPENCODE_SERVER_TIMEOUT_MS; | |
| 689 | const args = ["serve", `--hostname=${hostname}`, `--port=${port}`]; | |
| 690 | const spawnCommand = yield* resolveCommand(input.binaryPath, args, input.environment); | |
| 691 | const serverPassword = resolveOpenCodeServerPassword({ | |
| 692 | external: false, | |
| 693 | ...(input.serverPassword !== undefined ? { serverPassword: input.serverPassword } : {}), | |
| 694 | ...(input.environment !== undefined ? { environment: input.environment } : {}), | |
| 695 | }); | |
| 696 | ||
| 697 | // Scopes close in reverse order. Forking this before the group kill is | |
| 698 | // registered forgets the ledger entry only once the group is stopped. | |
| 699 | const ledgerScope = yield* Scope.fork(runtimeScope); | |
| 700 | const child = yield* spawner | |
| 701 | .spawn( | |
| 702 | ChildProcess.make(spawnCommand.command, spawnCommand.args, { | |
| 703 | detached: hostPlatform !== "win32", | |
| 704 | shell: spawnCommand.shell, | |
| 705 | env: { | |
| 706 | ...input.environment, | |
| 707 | ...(serverPassword !== undefined ? { OPENCODE_SERVER_PASSWORD: serverPassword } : {}), | |
| 708 | // Respect an OPENCODE_CONFIG_CONTENT provided by the caller or | |
| 709 | // the inherited process environment, only falling back to the | |
| 710 | // empty config when neither is set. Setting it unconditionally | |
| 711 | // previously clobbered the user's opencode config, hiding their | |
| 712 | // providers/models. The value is set explicitly (rather than | |
| 713 | // relying on inheritance) because `extendEnv` is false whenever | |
| 714 | // `input.environment` is provided. | |
| 715 | OPENCODE_CONFIG_CONTENT: resolveOpenCodeConfigContent(input.environment), | |
| 716 | }, | |
| 717 | extendEnv: input.environment === undefined, | |
| 718 | }), | |
| 719 | ) | |
| 720 | .pipe( | |
| 721 | Effect.provideService(Scope.Scope, runtimeScope), | |
| 722 | Effect.mapError( | |
| 723 | (cause) => | |
| 724 | new OpenCodeRuntimeError({ | |
| 725 | operation: "startOpenCodeServerProcess", | |
| 726 | detail: `Failed to spawn OpenCode server process: ${openCodeRuntimeErrorDetail(cause)}`, | |
| 727 | cause, | |
| 728 | }), | |
| 729 | ), | |
| 730 | ); | |
| 731 | ||
| 732 | const killOpenCodeProcessGroup = (signal: NodeJS.Signals) => | |
| 733 | hostPlatform === "win32" | |
| 734 | ? child.kill({ killSignal: signal, forceKillAfter: "1 second" }).pipe(Effect.asVoid) | |
| 735 | : Effect.sync(() => { | |
| 736 | try { | |
| 737 | signalProcessGroup(Number(child.pid), signal); | |
| 738 | } catch { | |
| 739 | // The direct child may already have exited after starting the | |
| 740 | // server; the process group kill is best-effort cleanup for | |
| 741 | // any serve process left in that group. | |
| 742 | } | |
| 743 | }); | |
| 744 | const terminateChild = killOpenCodeProcessGroup("SIGTERM").pipe( | |
| 745 | Effect.andThen(Effect.sleep("1 second")), | |
| 746 | Effect.andThen(killOpenCodeProcessGroup("SIGKILL")), | |
| 747 | Effect.ignore, | |
| 748 | ); | |
| 749 | // Registered before recording, so an interrupt while the ledger writes | |
| 750 | // still stops the group. | |
| 751 | yield* Scope.addFinalizer(runtimeScope, terminateChild); | |
| 752 | const forgetServer = yield* serverLedger.track({ pid: Number(child.pid), port, args }); | |
| 753 | yield* Scope.addFinalizer(ledgerScope, forgetServer); | |
| 754 | ||
| 755 | const stdoutRef = yield* Ref.make<string | null>(""); | |
| 756 | const stderrRef = yield* Ref.make<string | null>(""); | |
| 757 | const readyDeferred = yield* Deferred.make<string, OpenCodeRuntimeError>(); | |
| 758 | ||
| 759 | const setReadyFromStdoutChunk = (chunk: string) => | |
| 760 | Ref.modify(stdoutRef, (stdout) => { | |
| 761 | if (stdout === null) { | |
| 762 | return [null, null] as const; | |
| 763 | } | |
| 764 | const nextStdout = `${stdout}${chunk}`; | |
| 765 | return [ | |
| 766 | parseServerUrlFromOutput(nextStdout), | |
| 767 | nextStdout.slice(-OPENCODE_SERVER_STARTUP_MAX_OUTPUT_CHARS), | |
| 768 | ] as const; | |
| 769 | }).pipe( | |
| 770 | Effect.flatMap((parsed) => | |
| 771 | parsed ? Deferred.succeed(readyDeferred, parsed).pipe(Effect.ignore) : Effect.void, | |
| 772 | ), | |
| 773 | ); | |
| 774 | ||
| 775 | const stdoutFiber = yield* child.stdout.pipe( | |
| 776 | Stream.decodeText(), | |
| 777 | Stream.runForEach(setReadyFromStdoutChunk), | |
| 778 | Effect.ignore, | |
| 779 | Effect.forkIn(runtimeScope), | |
| 780 | ); | |
| 781 | const stderrFiber = yield* child.stderr.pipe( | |
| 782 | Stream.decodeText(), | |
| 783 | Stream.runForEach((chunk) => | |
| 784 | Ref.update(stderrRef, (stderr) => | |
| 785 | stderr === null | |
| 786 | ? null | |
| 787 | : `${stderr}${chunk}`.slice(-OPENCODE_SERVER_STARTUP_MAX_OUTPUT_CHARS), | |
| 788 | ), | |
| 789 | ), | |
| 790 | Effect.ignore, | |
| 791 | Effect.forkIn(runtimeScope), | |
| 792 | ); | |
| 793 | ||
| 794 | const exitFiber = yield* child.exitCode.pipe( | |
| 795 | Effect.flatMap((code) => | |
| 796 | Effect.gen(function* () { | |
| 797 | const stdout = (yield* Ref.get(stdoutRef)) ?? ""; | |
| 798 | const stderr = (yield* Ref.get(stderrRef)) ?? ""; | |
| 799 | const exitCode = Number(code); | |
| 800 | yield* Deferred.fail( | |
| 801 | readyDeferred, | |
| 802 | new OpenCodeRuntimeError({ | |
| 803 | operation: "startOpenCodeServerProcess", | |
| 804 | detail: [ | |
| 805 | `OpenCode server exited before startup completed (code: ${String(exitCode)}).`, | |
| 806 | stdout.trim() ? `stdout:\n${stdout.trim()}` : null, | |
| 807 | stderr.trim() ? `stderr:\n${stderr.trim()}` : null, | |
| 808 | ] | |
| 809 | .filter(Boolean) | |
| 810 | .join("\n\n"), | |
| 811 | cause: { exitCode, stdout, stderr }, | |
| 812 | }), | |
| 813 | ).pipe(Effect.ignore); | |
| 814 | }), | |
| 815 | ), | |
| 816 | Effect.ignore, | |
| 817 | Effect.forkIn(runtimeScope), | |
| 818 | ); | |
| 819 | ||
| 820 | const readyExit = yield* Effect.exit( | |
| 821 | Deferred.await(readyDeferred).pipe(Effect.timeoutOption(timeoutMs)), | |
| 822 | ); | |
| 823 | ||
| 824 | if (Exit.isFailure(readyExit) || Option.isNone(readyExit.value)) { | |
| 825 | yield* Fiber.interruptAll([stdoutFiber, stderrFiber, exitFiber]).pipe(Effect.ignore); | |
| 826 | } | |
| 827 | ||
| 828 | if (Exit.isFailure(readyExit)) { | |
| 829 | const squashed = Cause.squash(readyExit.cause); | |
| 830 | return yield* ensureRuntimeError( | |
| 831 | "startOpenCodeServerProcess", | |
| 832 | `Failed while waiting for OpenCode server startup: ${openCodeRuntimeErrorDetail(squashed)}`, | |
| 833 | squashed, | |
| 834 | ); | |
| 835 | } | |
| 836 | ||
| 837 | const readyOption = readyExit.value; | |
| 838 | if (Option.isNone(readyOption)) { | |
| 839 | return yield* new OpenCodeRuntimeError({ | |
| 840 | operation: "startOpenCodeServerProcess", | |
| 841 | detail: `Timed out waiting for OpenCode server start after ${timeoutMs}ms.`, | |
| 842 | }); | |
| 843 | } | |
| 844 | ||
| 845 | // Keep draining both pipes until the process scope closes. Stopping the | |
| 846 | // readers can block OpenCode when its output buffers fill. Startup output | |
| 847 | // is no longer needed, so discard later output instead of retaining it. | |
| 848 | yield* Ref.set(stdoutRef, null); | |
| 849 | yield* Ref.set(stderrRef, null); | |
| 850 | ||
| 851 | const url = readyOption.value; | |
| 852 | const version = yield* ( | |
| 853 | input.verify?.(url) ?? | |
| 854 | verifyOpenCodeServerVersion( | |
| 855 | createOpenCodeSdkClient({ | |
| 856 | baseUrl: url, | |
| 857 | directory: input.directory, | |
| 858 | ...(serverPassword !== undefined ? { serverPassword } : {}), | |
| 859 | }), | |
| 860 | ) | |
| 861 | ); | |
| 862 | ||
| 863 | return { | |
| 864 | url, | |
| 865 | ...(serverPassword !== undefined ? { serverPassword } : {}), | |
| 866 | version, | |
| 867 | isRunning: child.isRunning.pipe(Effect.orElseSucceed(() => false)), | |
| 868 | exitCode: child.exitCode.pipe( | |
| 869 | Effect.map(Number), | |
| 870 | Effect.orElseSucceed(() => 0), | |
| 871 | ), | |
| 872 | } satisfies OpenCodeServerProcess; | |
| 873 | }); | |
| 874 | ||
| 875 | const connectToOpenCodeServer: OpenCodeRuntimeShape["connectToOpenCodeServer"] = (input) => { | |
| 876 | const serverUrl = input.serverUrl?.trim(); | |
| 877 | if (serverUrl) { | |
| 878 | const serverPassword = resolveOpenCodeServerPassword({ | |
| 879 | external: true, | |
| 880 | ...(input.serverPassword !== undefined ? { serverPassword: input.serverPassword } : {}), | |
| 881 | }); | |
| 882 | return verifyOpenCodeServerVersion( | |
| 883 | createOpenCodeSdkClient({ | |
| 884 | baseUrl: serverUrl, | |
| 885 | directory: input.directory, | |
| 886 | ...(serverPassword !== undefined ? { serverPassword } : {}), | |
| 887 | }), | |
| 888 | ).pipe( | |
| 889 | Effect.map((version) => ({ | |
| 890 | url: serverUrl, | |
| 891 | ...(serverPassword !== undefined ? { serverPassword } : {}), | |
| 892 | version, | |
| 893 | exitCode: null, | |
| 894 | external: true, | |
| 895 | })), | |
| 896 | ); | |
| 897 | } | |
| 898 | ||
| 899 | return startOpenCodeServerProcess({ | |
| 900 | binaryPath: input.binaryPath, | |
| 901 | directory: input.directory, | |
| 902 | ...(input.serverPassword !== undefined ? { serverPassword: input.serverPassword } : {}), | |
| 903 | ...(input.environment !== undefined ? { environment: input.environment } : {}), | |
| 904 | ...(input.port !== undefined ? { port: input.port } : {}), | |
| 905 | ...(input.hostname !== undefined ? { hostname: input.hostname } : {}), | |
| 906 | ...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}), | |
| 907 | }).pipe( | |
| 908 | Effect.map((server) => ({ | |
| 909 | url: server.url, | |
| 910 | ...(server.serverPassword !== undefined ? { serverPassword: server.serverPassword } : {}), | |
| 911 | version: server.version, | |
| 912 | exitCode: server.exitCode, | |
| 913 | external: false, | |
| 914 | })), | |
| 915 | ); | |
| 916 | }; | |
| 917 | ||
| 918 | const loadProviders = (client: OpencodeClient) => | |
| 919 | runOpenCodeSdk("provider.list", (signal) => client.provider.list(undefined, { signal })).pipe( | |
| 920 | Effect.filterMapOrFail( | |
| 921 | (list) => | |
| 922 | list.data | |
| 923 | ? Result.succeed(list.data) | |
| 924 | : Result.fail( | |
| 925 | new OpenCodeRuntimeError({ | |
| 926 | operation: "provider.list", | |
| 927 | detail: "OpenCode provider list was empty.", | |
| 928 | }), | |
| 929 | ), | |
| 930 | (result) => result, | |
| 931 | ), | |
| 932 | ); | |
| 933 | ||
| 934 | const loadAgents = (client: OpencodeClient) => | |
| 935 | runOpenCodeSdk("app.agents", (signal) => client.app.agents(undefined, { signal })).pipe( | |
| 936 | Effect.map((result) => result.data ?? []), | |
| 937 | Effect.orElseSucceed((): ReadonlyArray<Agent> => []), | |
| 938 | ); | |
| 939 | ||
| 940 | const loadOpenCodeSkills: OpenCodeRuntimeShape["loadOpenCodeSkills"] = (client) => | |
| 941 | runOpenCodeSdk("app.skills", (signal) => client.app.skills(undefined, { signal })).pipe( | |
| 942 | Effect.map((result) => | |
| 943 | (result.data ?? []).map((skill) => ({ | |
| 944 | name: skill.name, | |
| 945 | ...(skill.description === undefined ? {} : { description: skill.description }), | |
| 946 | location: skill.location, | |
| 947 | })), | |
| 948 | ), | |
| 949 | ); | |
| 950 | const loadSkills = (client: OpencodeClient) => | |
| 951 | loadOpenCodeSkills(client).pipe(Effect.orElseSucceed((): ReadonlyArray<OpenCodeSkill> => [])); | |
| 952 | ||
| 953 | const loadOpenCodeInventory: OpenCodeRuntimeShape["loadOpenCodeInventory"] = (client) => | |
| 954 | Effect.all( | |
| 955 | [ | |
| 956 | loadProviders(client), | |
| 957 | loadAgents(client), | |
| 958 | loadSkills(client), | |
| 959 | loadOpenCodeCommands(client).pipe(Effect.orElseSucceed(() => [])), | |
| 960 | ], | |
| 961 | { | |
| 962 | concurrency: "unbounded", | |
| 963 | }, | |
| 964 | ).pipe( | |
| 965 | Effect.map(([providerList, agents, skills, commands]) => ({ | |
| 966 | providerList, | |
| 967 | agents, | |
| 968 | skills, | |
| 969 | commands, | |
| 970 | })), | |
| 971 | ); | |
| 972 | ||
| 973 | const loadInventoryFromCli: OpenCodeRuntimeShape["loadInventoryFromCli"] = (input) => | |
| 974 | Effect.gen(function* () { | |
| 975 | const env = input.environment !== undefined ? { environment: input.environment } : ({} as {}); | |
| 976 | const commandContext = { cwd: input.cwd, ...env }; | |
| 977 | ||
| 978 | const runModelsCli = () => | |
| 979 | runOpenCodeCommand({ | |
| 980 | binaryPath: input.binaryPath, | |
| 981 | args: ["models", "--verbose"], | |
| 982 | ...commandContext, | |
| 983 | }).pipe(Effect.exit); | |
| 984 | const runAgentsCli = () => | |
| 985 | runOpenCodeCommand({ | |
| 986 | binaryPath: input.binaryPath, | |
| 987 | args: ["agent", "list"], | |
| 988 | ...commandContext, | |
| 989 | }).pipe(Effect.exit); | |
| 990 | const runSkillsCli = () => | |
| 991 | runOpenCodeCommand({ | |
| 992 | binaryPath: input.binaryPath, | |
| 993 | args: ["debug", "skill"], | |
| 994 | maxOutputBytes: OPENCODE_SKILL_DISCOVERY_MAX_OUTPUT_BYTES, | |
| 995 | ...commandContext, | |
| 996 | }).pipe(Effect.exit); | |
| 997 | ||
| 998 | // Every OpenCode CLI command opens the same shared SQLite database. Running them | |
| 999 | // concurrently causes "database is locked" failures, so run them one at a time. | |
| 1000 | const [initialModelsResult, initialAgentsResult, initialSkillsResult] = yield* Effect.all( | |
| 1001 | [runModelsCli(), runAgentsCli(), runSkillsCli()], | |
| 1002 | { concurrency: 1 }, | |
| 1003 | ); | |
| 1004 | let modelsResult = initialModelsResult; | |
| 1005 | let agentsResult = initialAgentsResult; | |
| 1006 | let skillsResult = initialSkillsResult; | |
| 1007 | ||
| 1008 | // Retry once after 1s on transient failures (e.g. SQLite "database is locked") | |
| 1009 | const needsModelsRetry = modelsResult._tag === "Failure" || modelsResult.value.code !== 0; | |
| 1010 | const needsAgentsRetry = agentsResult._tag === "Failure" || agentsResult.value.code !== 0; | |
| 1011 | const needsSkillsRetry = skillsResult._tag === "Failure" || skillsResult.value.code !== 0; | |
| 1012 | if (needsModelsRetry || needsAgentsRetry || needsSkillsRetry) { | |
| 1013 | yield* Effect.sleep("1 second"); | |
| 1014 | const [m2, a2, s2] = yield* Effect.all( | |
| 1015 | [ | |
| 1016 | needsModelsRetry ? runModelsCli() : Effect.succeed(modelsResult), | |
| 1017 | needsAgentsRetry ? runAgentsCli() : Effect.succeed(agentsResult), | |
| 1018 | needsSkillsRetry ? runSkillsCli() : Effect.succeed(skillsResult), | |
| 1019 | ], | |
| 1020 | { concurrency: 1 }, | |
| 1021 | ); | |
| 1022 | modelsResult = m2; | |
| 1023 | agentsResult = a2; | |
| 1024 | skillsResult = s2; | |
| 1025 | } | |
| 1026 | ||
| 1027 | if (modelsResult._tag === "Failure") { | |
| 1028 | const cause = Cause.squash(modelsResult.cause); | |
| 1029 | return yield* ensureRuntimeError( | |
| 1030 | "loadInventoryFromCli", | |
| 1031 | `Failed to load OpenCode models: ${openCodeRuntimeErrorDetail(cause)}`, | |
| 1032 | cause, | |
| 1033 | ); | |
| 1034 | } | |
| 1035 | if (modelsResult.value.code !== 0) { | |
| 1036 | return yield* new OpenCodeRuntimeError({ | |
| 1037 | operation: "loadInventoryFromCli", | |
| 1038 | detail: `OpenCode models command exited with code ${modelsResult.value.code}.`, | |
| 1039 | }); | |
| 1040 | } | |
| 1041 | ||
| 1042 | const parsed = parseModelsCliOutput(modelsResult.value.stdout); | |
| 1043 | const connected = [...parsed.connected]; | |
| 1044 | const allProviders: ProviderListResponse["all"] = [...parsed.providers.values()].map( | |
| 1045 | (provider) => ({ | |
| 1046 | id: provider.id, | |
| 1047 | name: provider.name, | |
| 1048 | source: "config" as const, | |
| 1049 | env: [], | |
| 1050 | options: {}, | |
| 1051 | models: provider.models, | |
| 1052 | }), | |
| 1053 | ); | |
| 1054 | ||
| 1055 | // Agent and skill metadata enrich the provider snapshot but are not required | |
| 1056 | // for an authoritative model inventory, so either may degrade to an empty list. | |
| 1057 | let agents: ReadonlyArray<Agent> = []; | |
| 1058 | if (agentsResult._tag === "Success" && agentsResult.value.code === 0) { | |
| 1059 | agents = parseAgentListCliOutput(agentsResult.value.stdout); | |
| 1060 | } | |
| 1061 | let skills: ReadonlyArray<OpenCodeSkill> = []; | |
| 1062 | if (skillsResult._tag === "Success" && skillsResult.value.code === 0) { | |
| 1063 | skills = parseSkillsCliOutput(skillsResult.value.stdout); | |
| 1064 | } | |
| 1065 | ||
| 1066 | return { | |
| 1067 | providerList: { all: allProviders, default: {}, connected }, | |
| 1068 | agents, | |
| 1069 | skills, | |
| 1070 | }; | |
| 1071 | }); | |
| 1072 | ||
| 1073 | const loadSkillsFromCli: OpenCodeRuntimeShape["loadSkillsFromCli"] = (input) => | |
| 1074 | runOpenCodeCommand({ | |
| 1075 | binaryPath: input.binaryPath, | |
| 1076 | args: ["debug", "skill"], | |
| 1077 | cwd: input.cwd, | |
| 1078 | maxOutputBytes: OPENCODE_SKILL_DISCOVERY_MAX_OUTPUT_BYTES, | |
| 1079 | ...(input.environment !== undefined ? { environment: input.environment } : {}), | |
| 1080 | }).pipe( | |
| 1081 | Effect.flatMap((result) => | |
| 1082 | result.code === 0 | |
| 1083 | ? Effect.succeed(parseSkillsCliOutput(result.stdout)) | |
| 1084 | : Effect.fail( | |
| 1085 | new OpenCodeRuntimeError({ | |
| 1086 | operation: "loadSkillsFromCli", | |
| 1087 | detail: `OpenCode skills command exited with code ${result.code}.`, | |
| 1088 | }), | |
| 1089 | ), | |
| 1090 | ), | |
| 1091 | ); | |
| 1092 | ||
| 1093 | return { | |
| 1094 | startOpenCodeServerProcess, | |
| 1095 | connectToOpenCodeServer, | |
| 1096 | runOpenCodeCommand, | |
| 1097 | createOpenCodeSdkClient, | |
| 1098 | loadOpenCodeInventory, | |
| 1099 | loadOpenCodeSkills, | |
| 1100 | loadInventoryFromCli, | |
| 1101 | loadSkillsFromCli, | |
| 1102 | } satisfies OpenCodeRuntimeShape; | |
| 1103 | }); | |
| 1104 | ||
| 1105 | export class OpenCodeRuntime extends Context.Service<OpenCodeRuntime, OpenCodeRuntimeShape>()( | |
| 1106 | "@t3tools/provider-opencode/server/OpenCodeRuntime", | |
| 1107 | ) {} | |
| 1108 | ||
| 1109 | export const layer = Layer.effect(OpenCodeRuntime, makeOpenCodeRuntime).pipe( | |
| 1110 | Layer.provide(NetService.layer), | |
| 1111 | ); |
packages/provider-opencode/src/server/OpenCodeServerLedger.test.ts created+244| ... | ... | @@ -0,0 +1,244 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off | |
| 2 | import * as NodeChildProcess from "node:child_process"; | |
| 3 | ||
| 4 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 5 | import { describe, expect, it } from "@effect/vitest"; | |
| 6 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 7 | import * as Deferred from "effect/Deferred"; | |
| 8 | import * as Effect from "effect/Effect"; | |
| 9 | import * as Exit from "effect/Exit"; | |
| 10 | import * as Fiber from "effect/Fiber"; | |
| 11 | import * as FileSystem from "effect/FileSystem"; | |
| 12 | import * as Layer from "effect/Layer"; | |
| 13 | import * as Path from "effect/Path"; | |
| 14 | import * as Scope from "effect/Scope"; | |
| 15 | import { ChildProcessSpawner } from "effect/process"; | |
| 16 | ||
| 17 | import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; | |
| 18 | import * as OpenCodeServerLedger from "./OpenCodeServerLedger.ts"; | |
| 19 | ||
| 20 | const SERVE_ARGS = ["serve", "--hostname=127.0.0.1", "--port=4096"]; | |
| 21 | ||
| 22 | const groupExists = (pgid: number) => { | |
| 23 | try { | |
| 24 | process.kill(-pgid, 0); | |
| 25 | return true; | |
| 26 | } catch { | |
| 27 | return false; | |
| 28 | } | |
| 29 | }; | |
| 30 | ||
| 31 | const killGroup = (pgid: number) => | |
| 32 | Effect.sync(() => { | |
| 33 | if (groupExists(pgid)) process.kill(-pgid, "SIGKILL"); | |
| 34 | }); | |
| 35 | ||
| 36 | /** | |
| 37 | * Spawns a throwaway process group shaped like `opencode serve`: by default a | |
| 38 | * shell leader whose argv ends in the serve arguments, plus a `sleep` member. | |
| 39 | */ | |
| 40 | const spawnGroup = (args: ReadonlyArray<string>, script = "sleep 600 & wait") => | |
| 41 | Effect.acquireRelease( | |
| 42 | Effect.gen(function* () { | |
| 43 | const child = NodeChildProcess.spawn("/bin/sh", ["-c", script, ...args], { | |
| 44 | detached: true, | |
| 45 | stdio: "ignore", | |
| 46 | }); | |
| 47 | const exited = yield* Deferred.make<NodeJS.Signals | null>(); | |
| 48 | child.once("exit", (_code, signal) => Deferred.doneUnsafe(exited, Effect.succeed(signal))); | |
| 49 | const pid = child.pid; | |
| 50 | if (pid === undefined) return yield* Effect.die("spawn failed"); | |
| 51 | return { pid, exited: Deferred.await(exited) }; | |
| 52 | }), | |
| 53 | ({ pid }) => killGroup(pid), | |
| 54 | ); | |
| 55 | ||
| 56 | /** A T3 server that recorded its OpenCode server and then died without cleanup. */ | |
| 57 | const recordFromDeadServer = (stateDir: string, server: { readonly pid: number }) => | |
| 58 | Effect.gen(function* () { | |
| 59 | const previousServer = yield* spawnGroup([]); | |
| 60 | const previousLedger = yield* OpenCodeServerLedger.make({ | |
| 61 | stateDir, | |
| 62 | ownerPid: previousServer.pid, | |
| 63 | }); | |
| 64 | // The previous server never gets to forget its entry. | |
| 65 | yield* Effect.asVoid(previousLedger.track({ pid: server.pid, port: 4096, args: SERVE_ARGS })); | |
| 66 | process.kill(-previousServer.pid, "SIGKILL"); | |
| 67 | yield* previousServer.exited; | |
| 68 | }); | |
| 69 | ||
| 70 | const hostPlatform = HostProcessPlatform.defaultValue(); | |
| 71 | // procps accepts the same `ps` flags as macOS, so Linux also covers the macOS path. | |
| 72 | const observedPlatforms: ReadonlyArray<NodeJS.Platform> = | |
| 73 | hostPlatform === "linux" ? ["linux", "darwin"] : hostPlatform === "darwin" ? ["darwin"] : []; | |
| 74 | ||
| 75 | describe.each(observedPlatforms)("OpenCodeServerLedger observing as %s", (platform) => { | |
| 76 | const provideHost = <A, E, R>(effect: Effect.Effect<A, E, R>) => | |
| 77 | effect.pipe( | |
| 78 | Effect.provideService(HostProcessPlatform, platform), | |
| 79 | Effect.provide(NodeServices.layer), | |
| 80 | ); | |
| 81 | ||
| 82 | it.live("stops an OpenCode server group left by a server that died", () => | |
| 83 | Effect.gen(function* () { | |
| 84 | const fs = yield* FileSystem.FileSystem; | |
| 85 | const path = yield* Path.Path; | |
| 86 | const stateDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-ledger-" }); | |
| 87 | const orphan = yield* spawnGroup(SERVE_ARGS); | |
| 88 | yield* recordFromDeadServer(stateDir, orphan); | |
| 89 | expect(yield* fs.readDirectory(path.join(stateDir, "opencode-servers"))).toHaveLength(1); | |
| 90 | ||
| 91 | const restarted = yield* OpenCodeServerLedger.make({ stateDir }); | |
| 92 | yield* restarted.reapOrphans; | |
| 93 | ||
| 94 | expect(yield* orphan.exited).toBe("SIGTERM"); | |
| 95 | expect(groupExists(orphan.pid)).toBe(false); | |
| 96 | expect(yield* fs.readDirectory(path.join(stateDir, "opencode-servers"))).toEqual([]); | |
| 97 | }).pipe(provideHost), | |
| 98 | ); | |
| 99 | ||
| 100 | it.live("records a group whose spawned wrapper already exited", () => | |
| 101 | Effect.gen(function* () { | |
| 102 | const fs = yield* FileSystem.FileSystem; | |
| 103 | const path = yield* Path.Path; | |
| 104 | const stateDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-ledger-" }); | |
| 105 | // The wrapper starts the serve process in its group and exits. | |
| 106 | const wrapper = yield* spawnGroup( | |
| 107 | SERVE_ARGS, | |
| 108 | '/bin/sh -c "sleep 600 & wait" "$0" "$@" & exit 0', | |
| 109 | ); | |
| 110 | yield* wrapper.exited; | |
| 111 | expect(groupExists(wrapper.pid)).toBe(true); | |
| 112 | ||
| 113 | yield* recordFromDeadServer(stateDir, wrapper); | |
| 114 | expect(yield* fs.readDirectory(path.join(stateDir, "opencode-servers"))).toHaveLength(1); | |
| 115 | ||
| 116 | const restarted = yield* OpenCodeServerLedger.make({ stateDir }); | |
| 117 | yield* restarted.reapOrphans; | |
| 118 | ||
| 119 | expect(groupExists(wrapper.pid)).toBe(false); | |
| 120 | expect(yield* fs.readDirectory(path.join(stateDir, "opencode-servers"))).toEqual([]); | |
| 121 | }).pipe(provideHost), | |
| 122 | ); | |
| 123 | ||
| 124 | it.live("leaves a recycled pid alone when its start time does not match", () => | |
| 125 | Effect.gen(function* () { | |
| 126 | const fs = yield* FileSystem.FileSystem; | |
| 127 | const path = yield* Path.Path; | |
| 128 | const stateDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-ledger-" }); | |
| 129 | const unrelated = yield* spawnGroup(SERVE_ARGS); | |
| 130 | yield* recordFromDeadServer(stateDir, unrelated); | |
| 131 | const entryPath = path.join(stateDir, "opencode-servers", `${unrelated.pid}.json`); | |
| 132 | // Same pid, a different process: what a recycled pid looks like. | |
| 133 | const entry = yield* fs.readFileString(entryPath); | |
| 134 | yield* fs.writeFileString(entryPath, entry.replace(/"startTime":"[^"]+"/, '"startTime":"1"')); | |
| 135 | ||
| 136 | const restarted = yield* OpenCodeServerLedger.make({ stateDir }); | |
| 137 | yield* restarted.reapOrphans; | |
| 138 | ||
| 139 | expect(groupExists(unrelated.pid)).toBe(true); | |
| 140 | expect(yield* fs.exists(entryPath)).toBe(false); | |
| 141 | }).pipe(provideHost), | |
| 142 | ); | |
| 143 | ||
| 144 | it.live("leaves a serve process on another port alone", () => | |
| 145 | Effect.gen(function* () { | |
| 146 | const fs = yield* FileSystem.FileSystem; | |
| 147 | const path = yield* Path.Path; | |
| 148 | const stateDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-ledger-" }); | |
| 149 | const unrelated = yield* spawnGroup(SERVE_ARGS); | |
| 150 | yield* recordFromDeadServer(stateDir, unrelated); | |
| 151 | const entryPath = path.join(stateDir, "opencode-servers", `${unrelated.pid}.json`); | |
| 152 | // Same pid and start second, but not the server T3 started. | |
| 153 | const entry = yield* fs.readFileString(entryPath); | |
| 154 | yield* fs.writeFileString(entryPath, entry.replace("--port=4096", "--port=4097")); | |
| 155 | ||
| 156 | const restarted = yield* OpenCodeServerLedger.make({ stateDir }); | |
| 157 | yield* restarted.reapOrphans; | |
| 158 | ||
| 159 | expect(groupExists(unrelated.pid)).toBe(true); | |
| 160 | expect(yield* fs.exists(entryPath)).toBe(false); | |
| 161 | }).pipe(provideHost), | |
| 162 | ); | |
| 163 | ||
| 164 | it.live("leaves the servers of a running T3 server alone", () => | |
| 165 | Effect.gen(function* () { | |
| 166 | const fs = yield* FileSystem.FileSystem; | |
| 167 | const path = yield* Path.Path; | |
| 168 | const stateDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-ledger-" }); | |
| 169 | const running = yield* OpenCodeServerLedger.make({ stateDir }); | |
| 170 | const server = yield* spawnGroup(SERVE_ARGS); | |
| 171 | const forget = yield* running.track({ pid: server.pid, port: 4096, args: SERVE_ARGS }); | |
| 172 | ||
| 173 | const other = yield* OpenCodeServerLedger.make({ stateDir }); | |
| 174 | yield* other.reapOrphans; | |
| 175 | expect(groupExists(server.pid)).toBe(true); | |
| 176 | ||
| 177 | yield* forget; | |
| 178 | expect(yield* fs.readDirectory(path.join(stateDir, "opencode-servers"))).toEqual([]); | |
| 179 | }).pipe(provideHost), | |
| 180 | ); | |
| 181 | }); | |
| 182 | ||
| 183 | describe.skipIf(observedPlatforms.length === 0)("OpenCode server startup", () => { | |
| 184 | it.live("stops the group when interrupted while the server is being recorded", () => | |
| 185 | Effect.gen(function* () { | |
| 186 | const fs = yield* FileSystem.FileSystem; | |
| 187 | const path = yield* Path.Path; | |
| 188 | const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-startup-" }); | |
| 189 | // A wrapper that leaves the serve process running in its group and exits 0, | |
| 190 | // which the spawner's own cleanup does not stop. | |
| 191 | const binaryPath = path.join(tempDir, "opencode"); | |
| 192 | yield* fs.writeFileString(binaryPath, "#!/bin/sh\nsleep 600 &\nexit 0\n"); | |
| 193 | yield* fs.chmod(binaryPath, 0o755); | |
| 194 | ||
| 195 | const spawned = yield* Deferred.make<ChildProcessSpawner.ChildProcessHandle>(); | |
| 196 | const recording = yield* Deferred.make<number>(); | |
| 197 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 198 | const layerRuntime = OpenCodeRuntime.layer.pipe( | |
| 199 | Layer.provide( | |
| 200 | Layer.succeed( | |
| 201 | ChildProcessSpawner.ChildProcessSpawner, | |
| 202 | ChildProcessSpawner.make((command) => | |
| 203 | spawner | |
| 204 | .spawn(command) | |
| 205 | .pipe(Effect.tap((handle) => Deferred.succeed(spawned, handle))), | |
| 206 | ), | |
| 207 | ), | |
| 208 | ), | |
| 209 | Layer.provide( | |
| 210 | Layer.succeed( | |
| 211 | OpenCodeServerLedger.OpenCodeServerLedger, | |
| 212 | OpenCodeServerLedger.OpenCodeServerLedger.of({ | |
| 213 | // Recording stalls until the scope is interrupted, after the wrapper exited. | |
| 214 | track: (server) => | |
| 215 | Effect.gen(function* () { | |
| 216 | yield* (yield* Deferred.await(spawned)).exitCode.pipe(Effect.ignore); | |
| 217 | yield* Deferred.succeed(recording, server.pid); | |
| 218 | return yield* Effect.never; | |
| 219 | }), | |
| 220 | }), | |
| 221 | ), | |
| 222 | ), | |
| 223 | ); | |
| 224 | ||
| 225 | const serverScope = yield* Scope.make(); | |
| 226 | const starting = yield* OpenCodeRuntime.OpenCodeRuntime.pipe( | |
| 227 | Effect.flatMap((runtime) => | |
| 228 | runtime.startOpenCodeServerProcess({ binaryPath, directory: tempDir, port: 4096 }), | |
| 229 | ), | |
| 230 | Effect.provideService(Scope.Scope, serverScope), | |
| 231 | Effect.provide(layerRuntime), | |
| 232 | Effect.forkChild, | |
| 233 | ); | |
| 234 | const pgid = yield* Deferred.await(recording); | |
| 235 | yield* Effect.addFinalizer(() => killGroup(pgid)); | |
| 236 | expect(groupExists(pgid)).toBe(true); | |
| 237 | ||
| 238 | yield* Fiber.interrupt(starting); | |
| 239 | yield* Scope.close(serverScope, Exit.void); | |
| 240 | ||
| 241 | expect(groupExists(pgid)).toBe(false); | |
| 242 | }).pipe(Effect.provide(NodeServices.layer)), | |
| 243 | ); | |
| 244 | }); |
packages/provider-opencode/src/server/OpenCodeServerLedger.ts created+339| ... | ... | @@ -0,0 +1,339 @@ |
| 1 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 2 | import * as Context from "effect/Context"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as FileSystem from "effect/FileSystem"; | |
| 5 | import * as Layer from "effect/Layer"; | |
| 6 | import * as Option from "effect/Option"; | |
| 7 | import * as Path from "effect/Path"; | |
| 8 | import * as Schema from "effect/Schema"; | |
| 9 | import { ChildProcess, ChildProcessSpawner } from "effect/process"; | |
| 10 | ||
| 11 | import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; | |
| 12 | import { signalProcessGroup } from "@t3tools/provider-core/server/processGroup"; | |
| 13 | ||
| 14 | const ProcessIdentity = Schema.Struct({ pid: Schema.Int, startTime: Schema.String }); | |
| 15 | type ProcessIdentity = typeof ProcessIdentity.Type; | |
| 16 | ||
| 17 | const OpenCodeServerEntry = Schema.Struct({ | |
| 18 | version: Schema.Literal(1), | |
| 19 | /** The spawned group. Its id is the pid T3 spawned, which also names the entry file. */ | |
| 20 | pgid: Schema.Int, | |
| 21 | /** | |
| 22 | * A group member seen at spawn: the spawned process, or a process it left in | |
| 23 | * the group if it had already exited. Its pid, start time, and command line | |
| 24 | * must all still match before the group is signalled. | |
| 25 | */ | |
| 26 | pid: Schema.Int, | |
| 27 | /** Linux `/proc/<pid>/stat` start ticks, or macOS `ps` lstart (whole seconds). */ | |
| 28 | startTime: Schema.String, | |
| 29 | command: Schema.String, | |
| 30 | port: Schema.Int, | |
| 31 | /** Entries copied along with a state directory are dropped, never acted on. */ | |
| 32 | stateDir: Schema.String, | |
| 33 | /** The T3 server that spawned it. Entries of a live owner are never touched. */ | |
| 34 | owner: ProcessIdentity, | |
| 35 | }); | |
| 36 | type OpenCodeServerEntry = typeof OpenCodeServerEntry.Type; | |
| 37 | const OpenCodeServerEntryJson = Schema.fromJsonString(OpenCodeServerEntry); | |
| 38 | const decodeEntry = Schema.decodeUnknownOption(OpenCodeServerEntryJson); | |
| 39 | const encodeEntry = Schema.encodeEffect(OpenCodeServerEntryJson); | |
| 40 | ||
| 41 | /** | |
| 42 | * Local `opencode serve` processes run in their own process group so T3 can | |
| 43 | * stop the whole group, which also means they outlive a T3 server that is | |
| 44 | * SIGKILLed or crashes. Each spawn is recorded under the state directory and | |
| 45 | * removed on a graceful stop; the next server start stops whatever a dead | |
| 46 | * server left behind. | |
| 47 | */ | |
| 48 | export class OpenCodeServerLedger extends Context.Service< | |
| 49 | OpenCodeServerLedger, | |
| 50 | { | |
| 51 | /** Records a spawned server group and returns the effect that forgets it after a graceful stop. */ | |
| 52 | readonly track: (server: { | |
| 53 | readonly pid: number; | |
| 54 | readonly port: number; | |
| 55 | /** The argv after the binary, e.g. `["serve", "--hostname=127.0.0.1", "--port=4096"]`. */ | |
| 56 | readonly args: ReadonlyArray<string>; | |
| 57 | }) => Effect.Effect<Effect.Effect<void>>; | |
| 58 | } | |
| 59 | >()("@t3tools/provider-opencode/server/OpenCodeServerLedger") {} | |
| 60 | ||
| 61 | const ENTRY_DIRECTORY = "opencode-servers"; | |
| 62 | const ENTRY_FILE = /^\d+\.json$/; | |
| 63 | const STOP_POLL_INTERVAL = "50 millis"; | |
| 64 | const STOP_POLL_ATTEMPTS = 40; | |
| 65 | ||
| 66 | interface ObservedProcess { | |
| 67 | readonly pid: number; | |
| 68 | readonly pgid: number; | |
| 69 | readonly startTime: string; | |
| 70 | readonly command: string; | |
| 71 | readonly zombie: boolean; | |
| 72 | } | |
| 73 | ||
| 74 | // `ps -o lstart` prints e.g. `Sat Sep 6 20:51:57 2026`; C locale and UTC keep it stable. | |
| 75 | const DARWIN_PS_LINE = | |
| 76 | /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(\w{3} \w{3} +\d{1,2} \d{2}:\d{2}:\d{2} \d{4})\s+(.*)$/; | |
| 77 | ||
| 78 | const parseDarwinPs = (output: string): ReadonlyArray<ObservedProcess> => | |
| 79 | output.split("\n").flatMap((line) => { | |
| 80 | const match = DARWIN_PS_LINE.exec(line); | |
| 81 | if (match === null) return []; | |
| 82 | return [ | |
| 83 | { | |
| 84 | pid: Number(match[1]), | |
| 85 | pgid: Number(match[2]), | |
| 86 | zombie: match[3]!.startsWith("Z"), | |
| 87 | startTime: match[4]!, | |
| 88 | command: match[5]!.trimEnd(), | |
| 89 | }, | |
| 90 | ]; | |
| 91 | }); | |
| 92 | ||
| 93 | const signalGroup = (pgid: number, signal: NodeJS.Signals) => { | |
| 94 | try { | |
| 95 | signalProcessGroup(pgid, signal); | |
| 96 | } catch { | |
| 97 | // The group may already be gone. | |
| 98 | } | |
| 99 | }; | |
| 100 | ||
| 101 | const groupExists = (pgid: number) => { | |
| 102 | try { | |
| 103 | signalProcessGroup(pgid, 0); | |
| 104 | return true; | |
| 105 | } catch (cause) { | |
| 106 | return (cause as NodeJS.ErrnoException | undefined)?.code !== "ESRCH"; | |
| 107 | } | |
| 108 | }; | |
| 109 | ||
| 110 | /** | |
| 111 | * Builds a ledger for one state directory. `ownerPid` is the T3 server that | |
| 112 | * owns the servers it tracks; it defaults to this process. | |
| 113 | */ | |
| 114 | export const make = Effect.fn("OpenCodeServerLedger.make")(function* (input: { | |
| 115 | readonly stateDir: string; | |
| 116 | readonly ownerPid?: number; | |
| 117 | }) { | |
| 118 | const fs = yield* FileSystem.FileSystem; | |
| 119 | const path = yield* Path.Path; | |
| 120 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 121 | const platform = yield* HostProcessPlatform; | |
| 122 | const directory = path.join(input.stateDir, ENTRY_DIRECTORY); | |
| 123 | // Windows servers are not detached and are never recorded. | |
| 124 | const recordable = platform === "linux" || platform === "darwin"; | |
| 125 | ||
| 126 | /** Observes `pid`, or nothing when it is not in `group` (if given). */ | |
| 127 | const observeLinux = (pid: number, group?: number) => | |
| 128 | Effect.gen(function* () { | |
| 129 | const stat = yield* fs.readFileString(`/proc/${pid}/stat`); | |
| 130 | // After the parenthesized comm: state, ppid, pgrp, session, … starttime (index 19). | |
| 131 | const fields = stat | |
| 132 | .slice(stat.lastIndexOf(")") + 2) | |
| 133 | .trim() | |
| 134 | .split(/\s+/); | |
| 135 | const pgid = Number(fields[2]); | |
| 136 | const startTime = fields[19]; | |
| 137 | if (!Number.isSafeInteger(pgid) || startTime === undefined) return undefined; | |
| 138 | if (group !== undefined && pgid !== group) return undefined; | |
| 139 | const cmdline = yield* fs.readFileString(`/proc/${pid}/cmdline`); | |
| 140 | return { | |
| 141 | pid, | |
| 142 | pgid, | |
| 143 | startTime, | |
| 144 | command: cmdline.replace(/\0$/, "").replaceAll("\0", " "), | |
| 145 | zombie: fields[0] === "Z", | |
| 146 | } satisfies ObservedProcess; | |
| 147 | }); | |
| 148 | ||
| 149 | const psDarwin = (selection: ReadonlyArray<string>) => | |
| 150 | spawner | |
| 151 | .string( | |
| 152 | ChildProcess.make( | |
| 153 | "/bin/ps", | |
| 154 | ["-ww", "-o", "pid=,pgid=,stat=,lstart=,args=", ...selection], | |
| 155 | { | |
| 156 | env: { LC_ALL: "C", TZ: "UTC" }, | |
| 157 | extendEnv: true, | |
| 158 | stdin: "ignore", | |
| 159 | stderr: "ignore", | |
| 160 | }, | |
| 161 | ), | |
| 162 | ) | |
| 163 | .pipe(Effect.timeout("5 seconds"), Effect.map(parseDarwinPs)); | |
| 164 | ||
| 165 | const observe = (pid: number): Effect.Effect<ObservedProcess | undefined> => { | |
| 166 | if (platform === "linux") return observeLinux(pid).pipe(Effect.orElseSucceed(() => undefined)); | |
| 167 | if (platform === "darwin") { | |
| 168 | return psDarwin(["-p", String(pid)]).pipe( | |
| 169 | Effect.map((found) => found.find((candidate) => candidate.pid === pid)), | |
| 170 | Effect.orElseSucceed(() => undefined), | |
| 171 | ); | |
| 172 | } | |
| 173 | return Effect.succeed(undefined); | |
| 174 | }; | |
| 175 | ||
| 176 | // Scans every process, so it is only used when the spawned process is gone. | |
| 177 | const observeGroup = (pgid: number): Effect.Effect<ReadonlyArray<ObservedProcess>> => { | |
| 178 | if (platform === "linux") { | |
| 179 | return fs.readDirectory("/proc").pipe( | |
| 180 | Effect.flatMap((names) => | |
| 181 | Effect.forEach( | |
| 182 | names.filter((name) => /^\d+$/.test(name)), | |
| 183 | (name) => observeLinux(Number(name), pgid).pipe(Effect.orElseSucceed(() => undefined)), | |
| 184 | { concurrency: 16 }, | |
| 185 | ), | |
| 186 | ), | |
| 187 | Effect.map((found) => found.filter((candidate) => candidate !== undefined)), | |
| 188 | Effect.orElseSucceed(() => []), | |
| 189 | ); | |
| 190 | } | |
| 191 | if (platform === "darwin") { | |
| 192 | return psDarwin(["-A"]).pipe( | |
| 193 | Effect.map((found) => found.filter((candidate) => candidate.pgid === pgid)), | |
| 194 | Effect.orElseSucceed(() => []), | |
| 195 | ); | |
| 196 | } | |
| 197 | return Effect.succeed([]); | |
| 198 | }; | |
| 199 | ||
| 200 | const isRunning = (identity: ProcessIdentity) => | |
| 201 | observe(identity.pid).pipe( | |
| 202 | Effect.map( | |
| 203 | (observed) => | |
| 204 | observed !== undefined && !observed.zombie && observed.startTime === identity.startTime, | |
| 205 | ), | |
| 206 | ); | |
| 207 | ||
| 208 | const ownerPid = input.ownerPid ?? process.pid; | |
| 209 | const ownerProcess = yield* observe(ownerPid); | |
| 210 | const owner = | |
| 211 | ownerProcess === undefined ? undefined : { pid: ownerPid, startTime: ownerProcess.startTime }; | |
| 212 | ||
| 213 | const track: OpenCodeServerLedger["Service"]["track"] = (server) => | |
| 214 | Effect.gen(function* () { | |
| 215 | if (!recordable) return Effect.void; | |
| 216 | const pgid = server.pid; | |
| 217 | // The spawned process leads the group. If it already exited (a wrapper | |
| 218 | // that left the server running), any live member identifies the group: | |
| 219 | // a group id is not reused while the group has members. | |
| 220 | const leader = yield* observe(pgid); | |
| 221 | const members = | |
| 222 | leader !== undefined && !leader.zombie && leader.pgid === pgid | |
| 223 | ? [leader] | |
| 224 | : (yield* observeGroup(pgid)).filter((candidate) => !candidate.zombie); | |
| 225 | const member = | |
| 226 | members.find((candidate) => candidate.command.endsWith(` ${server.args.join(" ")}`)) ?? | |
| 227 | members[0]; | |
| 228 | if (owner === undefined || member === undefined) { | |
| 229 | if (groupExists(pgid)) { | |
| 230 | yield* Effect.logWarning( | |
| 231 | "Could not record an OpenCode server; it will keep running if this server crashes", | |
| 232 | { pid: pgid, port: server.port }, | |
| 233 | ); | |
| 234 | } | |
| 235 | return Effect.void; | |
| 236 | } | |
| 237 | const entryPath = path.join(directory, `${pgid}.json`); | |
| 238 | const entry: OpenCodeServerEntry = { | |
| 239 | version: 1, | |
| 240 | pgid, | |
| 241 | pid: member.pid, | |
| 242 | startTime: member.startTime, | |
| 243 | command: member.command, | |
| 244 | port: server.port, | |
| 245 | stateDir: input.stateDir, | |
| 246 | owner, | |
| 247 | }; | |
| 248 | yield* writeFileStringAtomically({ | |
| 249 | filePath: entryPath, | |
| 250 | contents: yield* encodeEntry(entry), | |
| 251 | }).pipe( | |
| 252 | Effect.provideService(FileSystem.FileSystem, fs), | |
| 253 | Effect.provideService(Path.Path, path), | |
| 254 | ); | |
| 255 | return fs | |
| 256 | .remove(entryPath, { force: true }) | |
| 257 | .pipe( | |
| 258 | Effect.catchCause((cause) => | |
| 259 | Effect.logWarning("Could not forget a stopped OpenCode server", { cause }), | |
| 260 | ), | |
| 261 | ); | |
| 262 | }).pipe( | |
| 263 | Effect.catchCause((cause) => | |
| 264 | Effect.logWarning("Could not record an OpenCode server process", { cause }).pipe( | |
| 265 | Effect.as(Effect.void), | |
| 266 | ), | |
| 267 | ), | |
| 268 | ); | |
| 269 | ||
| 270 | // Signals only a group whose recorded member still has the same pid, start | |
| 271 | // time, group, and full command line, so a recycled pid is never touched. | |
| 272 | const stopOrphan = (entry: OpenCodeServerEntry) => | |
| 273 | Effect.gen(function* () { | |
| 274 | const observed = yield* observe(entry.pid); | |
| 275 | if ( | |
| 276 | observed === undefined || | |
| 277 | observed.zombie || | |
| 278 | observed.startTime !== entry.startTime || | |
| 279 | observed.pgid !== entry.pgid || | |
| 280 | observed.command !== entry.command | |
| 281 | ) { | |
| 282 | return; | |
| 283 | } | |
| 284 | yield* Effect.logInfo("Stopping an OpenCode server left by a previous T3 Code server", { | |
| 285 | pid: entry.pgid, | |
| 286 | port: entry.port, | |
| 287 | }); | |
| 288 | signalGroup(entry.pgid, "SIGTERM"); | |
| 289 | for (let attempt = 0; attempt < STOP_POLL_ATTEMPTS && groupExists(entry.pgid); attempt++) { | |
| 290 | yield* Effect.sleep(STOP_POLL_INTERVAL); | |
| 291 | } | |
| 292 | // The group never emptied, so its pgid cannot have been reused. | |
| 293 | if (groupExists(entry.pgid)) signalGroup(entry.pgid, "SIGKILL"); | |
| 294 | }); | |
| 295 | ||
| 296 | const reapEntry = (entryPath: string) => | |
| 297 | Effect.gen(function* () { | |
| 298 | const entry = decodeEntry(yield* fs.readFileString(entryPath)); | |
| 299 | if (Option.isSome(entry) && entry.value.stateDir === input.stateDir) { | |
| 300 | if (yield* isRunning(entry.value.owner)) return; | |
| 301 | yield* stopOrphan(entry.value); | |
| 302 | } | |
| 303 | yield* fs.remove(entryPath, { force: true }); | |
| 304 | }).pipe( | |
| 305 | Effect.catchCause((cause) => | |
| 306 | Effect.logWarning("Could not clean up a recorded OpenCode server", { entryPath, cause }), | |
| 307 | ), | |
| 308 | ); | |
| 309 | ||
| 310 | /** Stops recorded servers whose owning T3 server is gone and drops stale entries. */ | |
| 311 | const reapOrphans = Effect.gen(function* () { | |
| 312 | const names = yield* fs.readDirectory(directory).pipe(Effect.orElseSucceed(() => [])); | |
| 313 | yield* Effect.forEach( | |
| 314 | names.filter((name) => ENTRY_FILE.test(name)), | |
| 315 | (name) => reapEntry(path.join(directory, name)), | |
| 316 | { concurrency: "unbounded", discard: true }, | |
| 317 | ); | |
| 318 | }); | |
| 319 | ||
| 320 | return { track, reapOrphans }; | |
| 321 | }); | |
| 322 | ||
| 323 | /** Tracks servers under `stateDir`, the server's own state directory. */ | |
| 324 | export const layer = (input: { readonly stateDir: string }) => | |
| 325 | Layer.effect( | |
| 326 | OpenCodeServerLedger, | |
| 327 | Effect.gen(function* () { | |
| 328 | const ledger = yield* make(input); | |
| 329 | // Reaping waits for orphans to exit, so it must not hold up startup. | |
| 330 | yield* ledger.reapOrphans.pipe(Effect.forkScoped); | |
| 331 | return OpenCodeServerLedger.of({ track: ledger.track }); | |
| 332 | }), | |
| 333 | ); | |
| 334 | ||
| 335 | /** Records nothing. For tests that start OpenCode servers without a state directory. */ | |
| 336 | export const layerTest = Layer.succeed( | |
| 337 | OpenCodeServerLedger, | |
| 338 | OpenCodeServerLedger.of({ track: () => Effect.succeed(Effect.void) }), | |
| 339 | ); |
packages/provider-opencode/src/server/OpenCodeServerOwner.test.ts created+289| ... | ... | @@ -0,0 +1,289 @@ |
| 1 | import { it } from "@effect/vitest"; | |
| 2 | import * as Deferred from "effect/Deferred"; | |
| 3 | import * as Duration from "effect/Duration"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as Fiber from "effect/Fiber"; | |
| 6 | import * as Ref from "effect/Ref"; | |
| 7 | import * as TestClock from "effect/testing/TestClock"; | |
| 8 | import { expect } from "vite-plus/test"; | |
| 9 | ||
| 10 | import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; | |
| 11 | import * as OpenCodeServerOwner from "./OpenCodeServerOwner.ts"; | |
| 12 | ||
| 13 | const unusedRuntimeMethod = () => | |
| 14 | Effect.fail( | |
| 15 | new OpenCodeRuntime.OpenCodeRuntimeError({ | |
| 16 | operation: "unused", | |
| 17 | detail: "unused test method", | |
| 18 | }), | |
| 19 | ); | |
| 20 | ||
| 21 | const makeRuntime = Effect.gen(function* () { | |
| 22 | const starts = yield* Ref.make(0); | |
| 23 | const closes = yield* Ref.make(0); | |
| 24 | const failNextStart = yield* Ref.make(false); | |
| 25 | const started = yield* Deferred.make<void>(); | |
| 26 | const closed = yield* Deferred.make<void>(); | |
| 27 | const runtime: OpenCodeRuntime.OpenCodeRuntimeShape = { | |
| 28 | startOpenCodeServerProcess: () => | |
| 29 | Effect.gen(function* () { | |
| 30 | if (yield* Ref.getAndSet(failNextStart, false)) { | |
| 31 | return yield* new OpenCodeRuntime.OpenCodeRuntimeError({ | |
| 32 | operation: "startOpenCodeServerProcess", | |
| 33 | detail: "start failed", | |
| 34 | }); | |
| 35 | } | |
| 36 | const index = yield* Ref.updateAndGet(starts, (count) => count + 1); | |
| 37 | yield* Deferred.succeed(started, undefined).pipe(Effect.ignore); | |
| 38 | yield* Effect.addFinalizer(() => | |
| 39 | Ref.update(closes, (count) => count + 1).pipe( | |
| 40 | Effect.andThen(Deferred.succeed(closed, undefined)), | |
| 41 | Effect.ignore, | |
| 42 | ), | |
| 43 | ); | |
| 44 | return { | |
| 45 | url: `http://127.0.0.1:${index}`, | |
| 46 | version: "1.14.19", | |
| 47 | isRunning: Effect.succeed(true), | |
| 48 | exitCode: Effect.never, | |
| 49 | }; | |
| 50 | }), | |
| 51 | connectToOpenCodeServer: unusedRuntimeMethod, | |
| 52 | runOpenCodeCommand: unusedRuntimeMethod, | |
| 53 | createOpenCodeSdkClient: () => ({}) as never, | |
| 54 | loadOpenCodeInventory: unusedRuntimeMethod, | |
| 55 | loadOpenCodeSkills: unusedRuntimeMethod, | |
| 56 | loadInventoryFromCli: unusedRuntimeMethod, | |
| 57 | loadSkillsFromCli: unusedRuntimeMethod, | |
| 58 | }; | |
| 59 | return { runtime, starts, closes, failNextStart, started, closed }; | |
| 60 | }); | |
| 61 | ||
| 62 | it.effect("shares concurrent borrowers and closes after the idle TTL", () => | |
| 63 | Effect.gen(function* () { | |
| 64 | const testRuntime = yield* makeRuntime; | |
| 65 | const release = yield* Deferred.make<void>(); | |
| 66 | yield* Effect.scoped( | |
| 67 | Effect.gen(function* () { | |
| 68 | const owner = yield* OpenCodeServerOwner.make({ | |
| 69 | binaryPath: "opencode", | |
| 70 | directory: "/project", | |
| 71 | }); | |
| 72 | const useServer = owner.withServer((server) => | |
| 73 | Deferred.await(release).pipe(Effect.as(server.url)), | |
| 74 | ); | |
| 75 | const fibers = yield* Effect.all([useServer, useServer], { | |
| 76 | concurrency: "unbounded", | |
| 77 | }).pipe(Effect.forkChild); | |
| 78 | yield* Deferred.await(testRuntime.started); | |
| 79 | expect(yield* Ref.get(testRuntime.starts)).toBe(1); | |
| 80 | yield* Deferred.succeed(release, undefined); | |
| 81 | expect(yield* Fiber.join(fibers)).toEqual(["http://127.0.0.1:1", "http://127.0.0.1:1"]); | |
| 82 | yield* TestClock.adjust(Duration.seconds(31)); | |
| 83 | yield* Deferred.await(testRuntime.closed); | |
| 84 | expect(yield* Ref.get(testRuntime.closes)).toBe(1); | |
| 85 | }), | |
| 86 | ).pipe(Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, testRuntime.runtime)); | |
| 87 | }).pipe(Effect.provide(TestClock.layer())), | |
| 88 | ); | |
| 89 | ||
| 90 | it.effect("retries a failed start and closes on owner scope shutdown", () => | |
| 91 | Effect.gen(function* () { | |
| 92 | const testRuntime = yield* makeRuntime; | |
| 93 | yield* Ref.set(testRuntime.failNextStart, true); | |
| 94 | yield* Effect.scoped( | |
| 95 | Effect.gen(function* () { | |
| 96 | const owner = yield* OpenCodeServerOwner.make({ | |
| 97 | binaryPath: "opencode", | |
| 98 | directory: "/project", | |
| 99 | }); | |
| 100 | expect( | |
| 101 | (yield* Effect.exit(owner.withServer((server) => Effect.succeed(server.url))))._tag, | |
| 102 | ).toBe("Failure"); | |
| 103 | expect(yield* owner.withServer((server) => Effect.succeed(server.url))).toBe( | |
| 104 | "http://127.0.0.1:1", | |
| 105 | ); | |
| 106 | }), | |
| 107 | ).pipe(Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, testRuntime.runtime)); | |
| 108 | expect(yield* Ref.get(testRuntime.starts)).toBe(1); | |
| 109 | expect(yield* Ref.get(testRuntime.closes)).toBe(1); | |
| 110 | }), | |
| 111 | ); | |
| 112 | ||
| 113 | it.effect("invalidates an exited process so the next borrower starts a new one", () => | |
| 114 | Effect.gen(function* () { | |
| 115 | const starts = yield* Ref.make(0); | |
| 116 | const processExits: Array<Deferred.Deferred<number>> = []; | |
| 117 | const processClosed = yield* Deferred.make<void>(); | |
| 118 | const runtime: OpenCodeRuntime.OpenCodeRuntimeShape = { | |
| 119 | startOpenCodeServerProcess: () => | |
| 120 | Effect.gen(function* () { | |
| 121 | const index = yield* Ref.updateAndGet(starts, (count) => count + 1); | |
| 122 | const exitCode = yield* Deferred.make<number>(); | |
| 123 | processExits.push(exitCode); | |
| 124 | yield* Effect.addFinalizer(() => | |
| 125 | Deferred.succeed(processClosed, undefined).pipe(Effect.ignore), | |
| 126 | ); | |
| 127 | return { | |
| 128 | url: `http://127.0.0.1:${index}`, | |
| 129 | version: "1.14.19", | |
| 130 | isRunning: Effect.succeed(true), | |
| 131 | exitCode: Deferred.await(exitCode), | |
| 132 | }; | |
| 133 | }), | |
| 134 | connectToOpenCodeServer: unusedRuntimeMethod, | |
| 135 | runOpenCodeCommand: unusedRuntimeMethod, | |
| 136 | createOpenCodeSdkClient: () => ({}) as never, | |
| 137 | loadOpenCodeInventory: unusedRuntimeMethod, | |
| 138 | loadOpenCodeSkills: unusedRuntimeMethod, | |
| 139 | loadInventoryFromCli: unusedRuntimeMethod, | |
| 140 | loadSkillsFromCli: unusedRuntimeMethod, | |
| 141 | }; | |
| 142 | ||
| 143 | yield* Effect.scoped( | |
| 144 | Effect.gen(function* () { | |
| 145 | const owner = yield* OpenCodeServerOwner.make({ | |
| 146 | binaryPath: "opencode", | |
| 147 | directory: "/project", | |
| 148 | }); | |
| 149 | expect(yield* owner.withServer((server) => Effect.succeed(server.url))).toBe( | |
| 150 | "http://127.0.0.1:1", | |
| 151 | ); | |
| 152 | yield* Deferred.succeed(processExits[0]!, 1); | |
| 153 | yield* Deferred.await(processClosed); | |
| 154 | expect(yield* owner.withServer((server) => Effect.succeed(server.url))).toBe( | |
| 155 | "http://127.0.0.1:2", | |
| 156 | ); | |
| 157 | }), | |
| 158 | ).pipe(Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, runtime)); | |
| 159 | expect(yield* Ref.get(starts)).toBe(2); | |
| 160 | }), | |
| 161 | ); | |
| 162 | ||
| 163 | it.effect("replaces a dead cached process before its exit watcher runs", () => | |
| 164 | Effect.gen(function* () { | |
| 165 | const starts = yield* Ref.make(0); | |
| 166 | const closes = yield* Ref.make(0); | |
| 167 | const processRunning: Array<Ref.Ref<boolean>> = []; | |
| 168 | const runtime: OpenCodeRuntime.OpenCodeRuntimeShape = { | |
| 169 | startOpenCodeServerProcess: () => | |
| 170 | Effect.gen(function* () { | |
| 171 | const index = yield* Ref.updateAndGet(starts, (count) => count + 1); | |
| 172 | const isRunning = yield* Ref.make(true); | |
| 173 | processRunning.push(isRunning); | |
| 174 | yield* Effect.addFinalizer(() => Ref.update(closes, (count) => count + 1)); | |
| 175 | return { | |
| 176 | url: `http://127.0.0.1:${index}`, | |
| 177 | version: "1.14.19", | |
| 178 | isRunning: Ref.get(isRunning), | |
| 179 | exitCode: Effect.never, | |
| 180 | }; | |
| 181 | }), | |
| 182 | connectToOpenCodeServer: unusedRuntimeMethod, | |
| 183 | runOpenCodeCommand: unusedRuntimeMethod, | |
| 184 | createOpenCodeSdkClient: () => ({}) as never, | |
| 185 | loadOpenCodeInventory: unusedRuntimeMethod, | |
| 186 | loadOpenCodeSkills: unusedRuntimeMethod, | |
| 187 | loadInventoryFromCli: unusedRuntimeMethod, | |
| 188 | loadSkillsFromCli: unusedRuntimeMethod, | |
| 189 | }; | |
| 190 | ||
| 191 | yield* Effect.scoped( | |
| 192 | Effect.gen(function* () { | |
| 193 | const owner = yield* OpenCodeServerOwner.make({ | |
| 194 | binaryPath: "opencode", | |
| 195 | directory: "/project", | |
| 196 | }); | |
| 197 | expect(yield* owner.withServer((server) => Effect.succeed(server.url))).toBe( | |
| 198 | "http://127.0.0.1:1", | |
| 199 | ); | |
| 200 | yield* Ref.set(processRunning[0]!, false); | |
| 201 | ||
| 202 | expect(yield* owner.withServer((server) => Effect.succeed(server.url))).toBe( | |
| 203 | "http://127.0.0.1:2", | |
| 204 | ); | |
| 205 | expect(yield* Ref.get(starts)).toBe(2); | |
| 206 | expect(yield* Ref.get(closes)).toBe(1); | |
| 207 | }), | |
| 208 | ).pipe(Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, runtime)); | |
| 209 | }), | |
| 210 | ); | |
| 211 | ||
| 212 | it.effect("cleans up an interrupted startup and allows a retry", () => | |
| 213 | Effect.gen(function* () { | |
| 214 | const starts = yield* Ref.make(0); | |
| 215 | const firstStartEntered = yield* Deferred.make<void>(); | |
| 216 | const firstStartClosed = yield* Deferred.make<void>(); | |
| 217 | const runtime: OpenCodeRuntime.OpenCodeRuntimeShape = { | |
| 218 | startOpenCodeServerProcess: () => | |
| 219 | Effect.gen(function* () { | |
| 220 | const index = yield* Ref.updateAndGet(starts, (count) => count + 1); | |
| 221 | yield* Effect.addFinalizer(() => | |
| 222 | index === 1 | |
| 223 | ? Deferred.succeed(firstStartClosed, undefined).pipe(Effect.ignore) | |
| 224 | : Effect.void, | |
| 225 | ); | |
| 226 | if (index === 1) { | |
| 227 | yield* Deferred.succeed(firstStartEntered, undefined); | |
| 228 | return yield* Effect.never; | |
| 229 | } | |
| 230 | return { | |
| 231 | url: `http://127.0.0.1:${index}`, | |
| 232 | version: "1.14.19", | |
| 233 | isRunning: Effect.succeed(true), | |
| 234 | exitCode: Effect.never, | |
| 235 | }; | |
| 236 | }), | |
| 237 | connectToOpenCodeServer: unusedRuntimeMethod, | |
| 238 | runOpenCodeCommand: unusedRuntimeMethod, | |
| 239 | createOpenCodeSdkClient: () => ({}) as never, | |
| 240 | loadOpenCodeInventory: unusedRuntimeMethod, | |
| 241 | loadOpenCodeSkills: unusedRuntimeMethod, | |
| 242 | loadInventoryFromCli: unusedRuntimeMethod, | |
| 243 | loadSkillsFromCli: unusedRuntimeMethod, | |
| 244 | }; | |
| 245 | ||
| 246 | yield* Effect.scoped( | |
| 247 | Effect.gen(function* () { | |
| 248 | const owner = yield* OpenCodeServerOwner.make({ | |
| 249 | binaryPath: "opencode", | |
| 250 | directory: "/project", | |
| 251 | }); | |
| 252 | const firstBorrower = yield* owner | |
| 253 | .withServer((server) => Effect.succeed(server.url)) | |
| 254 | .pipe(Effect.forkChild); | |
| 255 | yield* Deferred.await(firstStartEntered); | |
| 256 | yield* Fiber.interrupt(firstBorrower); | |
| 257 | yield* Deferred.await(firstStartClosed); | |
| 258 | expect(yield* owner.withServer((server) => Effect.succeed(server.url))).toBe( | |
| 259 | "http://127.0.0.1:2", | |
| 260 | ); | |
| 261 | }), | |
| 262 | ).pipe(Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, runtime)); | |
| 263 | }), | |
| 264 | ); | |
| 265 | ||
| 266 | it.effect("releases an interrupted borrower and closes after the idle TTL", () => | |
| 267 | Effect.gen(function* () { | |
| 268 | const testRuntime = yield* makeRuntime; | |
| 269 | const borrowerEntered = yield* Deferred.make<void>(); | |
| 270 | yield* Effect.scoped( | |
| 271 | Effect.gen(function* () { | |
| 272 | const owner = yield* OpenCodeServerOwner.make({ | |
| 273 | binaryPath: "opencode", | |
| 274 | directory: "/project", | |
| 275 | }); | |
| 276 | const borrower = yield* owner | |
| 277 | .withServer(() => | |
| 278 | Deferred.succeed(borrowerEntered, undefined).pipe(Effect.andThen(Effect.never)), | |
| 279 | ) | |
| 280 | .pipe(Effect.forkChild); | |
| 281 | yield* Deferred.await(borrowerEntered); | |
| 282 | yield* Fiber.interrupt(borrower); | |
| 283 | yield* TestClock.adjust(Duration.seconds(31)); | |
| 284 | yield* Deferred.await(testRuntime.closed); | |
| 285 | expect(yield* Ref.get(testRuntime.closes)).toBe(1); | |
| 286 | }), | |
| 287 | ).pipe(Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, testRuntime.runtime)); | |
| 288 | }).pipe(Effect.provide(TestClock.layer())), | |
| 289 | ); |
packages/provider-opencode/src/server/OpenCodeServerOwner.ts created+188| ... | ... | @@ -0,0 +1,188 @@ |
| 1 | import * as Context from "effect/Context"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as Exit from "effect/Exit"; | |
| 4 | import * as Fiber from "effect/Fiber"; | |
| 5 | import * as Layer from "effect/Layer"; | |
| 6 | import * as Scope from "effect/Scope"; | |
| 7 | import * as Semaphore from "effect/Semaphore"; | |
| 8 | ||
| 9 | import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; | |
| 10 | ||
| 11 | const OPENCODE_SERVER_IDLE_TTL = "30 seconds"; | |
| 12 | ||
| 13 | interface OpenCodeServerOwnerState { | |
| 14 | server: OpenCodeRuntime.OpenCodeServerProcess | null; | |
| 15 | serverScope: Scope.Closeable | null; | |
| 16 | borrowers: number; | |
| 17 | idleCloseFiber: Fiber.Fiber<void, never> | null; | |
| 18 | } | |
| 19 | ||
| 20 | export class OpenCodeServerOwner extends Context.Service< | |
| 21 | OpenCodeServerOwner, | |
| 22 | { | |
| 23 | readonly withServer: <A, E, R>( | |
| 24 | use: (server: OpenCodeRuntime.OpenCodeServerProcess) => Effect.Effect<A, E, R>, | |
| 25 | ) => Effect.Effect<A, E | OpenCodeRuntime.OpenCodeRuntimeError, R>; | |
| 26 | } | |
| 27 | >()("@t3tools/provider-opencode/server/OpenCodeServerOwner") {} | |
| 28 | ||
| 29 | /** Owns the lazy local OpenCode server shared by one provider instance. */ | |
| 30 | export const make = Effect.fn("OpenCodeServerOwner.make")(function* (input: { | |
| 31 | readonly binaryPath: string; | |
| 32 | readonly directory: string; | |
| 33 | readonly serverPassword?: string; | |
| 34 | readonly environment?: NodeJS.ProcessEnv; | |
| 35 | readonly verify?: (url: string) => Effect.Effect<string, OpenCodeRuntime.OpenCodeRuntimeError>; | |
| 36 | }) { | |
| 37 | const runtime = yield* OpenCodeRuntime.OpenCodeRuntime; | |
| 38 | const ownerScope = yield* Effect.acquireRelease(Scope.make(), (scope) => | |
| 39 | Scope.close(scope, Exit.void), | |
| 40 | ); | |
| 41 | const mutex = yield* Semaphore.make(1); | |
| 42 | const state: OpenCodeServerOwnerState = { | |
| 43 | server: null, | |
| 44 | serverScope: null, | |
| 45 | borrowers: 0, | |
| 46 | idleCloseFiber: null, | |
| 47 | }; | |
| 48 | ||
| 49 | const cancelIdleClose = Effect.fn("OpenCodeServerOwner.cancelIdleClose")(function* () { | |
| 50 | const fiber = state.idleCloseFiber; | |
| 51 | state.idleCloseFiber = null; | |
| 52 | if (fiber !== null) { | |
| 53 | yield* Fiber.interrupt(fiber).pipe(Effect.ignore); | |
| 54 | } | |
| 55 | }); | |
| 56 | ||
| 57 | const closeServer = Effect.fn("OpenCodeServerOwner.closeServer")(function* ( | |
| 58 | expected?: OpenCodeRuntime.OpenCodeServerProcess, | |
| 59 | ) { | |
| 60 | if (expected !== undefined && state.server !== expected) { | |
| 61 | return; | |
| 62 | } | |
| 63 | const scope = state.serverScope; | |
| 64 | state.server = null; | |
| 65 | state.serverScope = null; | |
| 66 | if (scope !== null) { | |
| 67 | yield* Scope.close(scope, Exit.void).pipe(Effect.ignore); | |
| 68 | } | |
| 69 | }); | |
| 70 | ||
| 71 | const watchServerExit = Effect.fn("OpenCodeServerOwner.watchServerExit")(function* ( | |
| 72 | server: OpenCodeRuntime.OpenCodeServerProcess, | |
| 73 | ) { | |
| 74 | yield* server.exitCode; | |
| 75 | yield* mutex.withPermit( | |
| 76 | Effect.gen(function* () { | |
| 77 | if (state.server !== server) { | |
| 78 | return; | |
| 79 | } | |
| 80 | yield* cancelIdleClose(); | |
| 81 | yield* closeServer(server); | |
| 82 | }), | |
| 83 | ); | |
| 84 | }); | |
| 85 | ||
| 86 | const acquireServer = mutex.withPermit( | |
| 87 | Effect.gen(function* () { | |
| 88 | yield* cancelIdleClose(); | |
| 89 | if (state.server !== null) { | |
| 90 | if (yield* state.server.isRunning) { | |
| 91 | state.borrowers += 1; | |
| 92 | return state.server; | |
| 93 | } | |
| 94 | yield* closeServer(state.server); | |
| 95 | } | |
| 96 | ||
| 97 | return yield* Effect.uninterruptibleMask((restore) => | |
| 98 | Effect.gen(function* () { | |
| 99 | const serverScope = yield* Scope.make(); | |
| 100 | const started = yield* Effect.exit( | |
| 101 | restore( | |
| 102 | runtime | |
| 103 | .startOpenCodeServerProcess({ | |
| 104 | binaryPath: input.binaryPath, | |
| 105 | directory: input.directory, | |
| 106 | ...(input.serverPassword !== undefined | |
| 107 | ? { serverPassword: input.serverPassword } | |
| 108 | : {}), | |
| 109 | ...(input.environment ? { environment: input.environment } : {}), | |
| 110 | ...(input.verify ? { verify: input.verify } : {}), | |
| 111 | }) | |
| 112 | .pipe(Effect.provideService(Scope.Scope, serverScope)), | |
| 113 | ), | |
| 114 | ); | |
| 115 | if (Exit.isFailure(started)) { | |
| 116 | yield* Scope.close(serverScope, Exit.void).pipe(Effect.ignore); | |
| 117 | return yield* Effect.failCause(started.cause); | |
| 118 | } | |
| 119 | ||
| 120 | const server = started.value; | |
| 121 | state.server = server; | |
| 122 | state.serverScope = serverScope; | |
| 123 | state.borrowers = 1; | |
| 124 | yield* watchServerExit(server).pipe(Effect.forkIn(ownerScope)); | |
| 125 | return server; | |
| 126 | }), | |
| 127 | ); | |
| 128 | }), | |
| 129 | ); | |
| 130 | ||
| 131 | const releaseServer = (server: OpenCodeRuntime.OpenCodeServerProcess) => | |
| 132 | mutex.withPermit( | |
| 133 | Effect.gen(function* () { | |
| 134 | if (state.server !== server) { | |
| 135 | return; | |
| 136 | } | |
| 137 | state.borrowers = Math.max(0, state.borrowers - 1); | |
| 138 | if (state.borrowers > 0) { | |
| 139 | return; | |
| 140 | } | |
| 141 | yield* cancelIdleClose(); | |
| 142 | state.idleCloseFiber = yield* Effect.sleep(OPENCODE_SERVER_IDLE_TTL).pipe( | |
| 143 | Effect.andThen( | |
| 144 | mutex.withPermit( | |
| 145 | Effect.gen(function* () { | |
| 146 | if (state.server !== server || state.borrowers > 0) { | |
| 147 | return; | |
| 148 | } | |
| 149 | state.idleCloseFiber = null; | |
| 150 | yield* closeServer(server); | |
| 151 | }), | |
| 152 | ), | |
| 153 | ), | |
| 154 | Effect.forkIn(ownerScope), | |
| 155 | ); | |
| 156 | }), | |
| 157 | ); | |
| 158 | ||
| 159 | yield* Effect.addFinalizer(() => | |
| 160 | mutex.withPermit( | |
| 161 | Effect.gen(function* () { | |
| 162 | yield* cancelIdleClose(); | |
| 163 | state.borrowers = 0; | |
| 164 | yield* closeServer(); | |
| 165 | }), | |
| 166 | ), | |
| 167 | ); | |
| 168 | ||
| 169 | return OpenCodeServerOwner.of({ | |
| 170 | withServer: (use) => | |
| 171 | Effect.uninterruptibleMask((restore) => | |
| 172 | restore(acquireServer).pipe( | |
| 173 | Effect.flatMap((server) => | |
| 174 | restore(use(server)).pipe(Effect.ensuring(releaseServer(server))), | |
| 175 | ), | |
| 176 | ), | |
| 177 | ), | |
| 178 | }); | |
| 179 | }); | |
| 180 | ||
| 181 | /** @public Service construction is part of the canonical Effect module API. */ | |
| 182 | export const layer = (input: { | |
| 183 | readonly binaryPath: string; | |
| 184 | readonly directory: string; | |
| 185 | readonly serverPassword?: string; | |
| 186 | readonly environment?: NodeJS.ProcessEnv; | |
| 187 | readonly verify?: (url: string) => Effect.Effect<string, OpenCodeRuntime.OpenCodeRuntimeError>; | |
| 188 | }) => Layer.effect(OpenCodeServerOwner, make(input)); |
packages/provider-opencode/src/server/adapter.test.ts created+2730| ... | ... | @@ -0,0 +1,2730 @@ |
| 1 | import { assert, describe, it } from "@effect/vitest"; | |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 3 | import type { OpencodeClient, ToolPart } from "@opencode-ai/sdk/v2"; | |
| 4 | import { | |
| 5 | CheckpointId, | |
| 6 | NodeId, | |
| 7 | ProjectId, | |
| 8 | ProviderInstanceId, | |
| 9 | ProviderSessionId, | |
| 10 | ProviderThreadId, | |
| 11 | ProviderTurnId, | |
| 12 | RunAttemptId, | |
| 13 | RunId, | |
| 14 | MessageId, | |
| 15 | ThreadId, | |
| 16 | type OrchestrationV2ProviderThread, | |
| 17 | type OrchestrationV2ProviderTurn, | |
| 18 | } from "@t3tools/contracts"; | |
| 19 | import { OpenCodeSettings } from "../settings.ts"; | |
| 20 | import * as Effect from "effect/Effect"; | |
| 21 | import * as Clock from "effect/Clock"; | |
| 22 | import * as DateTime from "effect/DateTime"; | |
| 23 | import * as Deferred from "effect/Deferred"; | |
| 24 | import * as Fiber from "effect/Fiber"; | |
| 25 | import * as Exit from "effect/Exit"; | |
| 26 | import * as Queue from "effect/Queue"; | |
| 27 | import * as Layer from "effect/Layer"; | |
| 28 | import * as Option from "effect/Option"; | |
| 29 | import * as Schema from "effect/Schema"; | |
| 30 | import * as Scope from "effect/Scope"; | |
| 31 | import * as Stream from "effect/Stream"; | |
| 32 | import * as TestClock from "effect/testing/TestClock"; | |
| 33 | ||
| 34 | import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; | |
| 35 | import { layerTestProviderHost } from "@t3tools/provider-testing/host"; | |
| 36 | import type * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; | |
| 37 | import type * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; | |
| 38 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; | |
| 39 | ||
| 40 | import { | |
| 41 | advanceOpenCodePromptAdmission, | |
| 42 | cancelOpenCodePromptAdmission, | |
| 43 | openCodeBoundaryAfterProviderTurn, | |
| 44 | openCodeChildPermissionRules, | |
| 45 | openCodePermissionRules, | |
| 46 | openCodePermissionRequestKind, | |
| 47 | openCodeToolProjectionKind, | |
| 48 | makeOpenCodeProtocolLogger, | |
| 49 | makeOpenCodeAdapterV2, | |
| 50 | OPENCODE_PROVIDER, | |
| 51 | reconcileOpenCodePromptAdmissionStatus, | |
| 52 | } from "./adapter.ts"; | |
| 53 | import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; | |
| 54 | ||
| 55 | const encodeUnknownJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); | |
| 56 | const OPEN_CODE_TEST_SETTINGS = Schema.decodeSync(OpenCodeSettings)({ | |
| 57 | serverUrl: "http://test.invalid", | |
| 58 | }); | |
| 59 | ||
| 60 | function promiseGate<A>() { | |
| 61 | let resolve!: (value: A) => void; | |
| 62 | const promise = new Promise<A>((done) => { | |
| 63 | resolve = done; | |
| 64 | }); | |
| 65 | return { promise, resolve }; | |
| 66 | } | |
| 67 | ||
| 68 | function asyncEventStream() { | |
| 69 | const values: Array<{ value: unknown; handled: ReturnType<typeof promiseGate<void>> }> = []; | |
| 70 | const waiters: Array<(value: IteratorResult<unknown>) => void> = []; | |
| 71 | let previousHandled: ReturnType<typeof promiseGate<void>> | undefined; | |
| 72 | let closed = false; | |
| 73 | return { | |
| 74 | close() { | |
| 75 | closed = true; | |
| 76 | for (const waiter of waiters.splice(0)) waiter({ done: true, value: undefined }); | |
| 77 | }, | |
| 78 | push(value: unknown) { | |
| 79 | const handled = promiseGate<void>(); | |
| 80 | const waiter = waiters.shift(); | |
| 81 | if (waiter) { | |
| 82 | previousHandled = handled; | |
| 83 | waiter({ done: false, value }); | |
| 84 | } else values.push({ value, handled }); | |
| 85 | return handled.promise; | |
| 86 | }, | |
| 87 | stream: { | |
| 88 | [Symbol.asyncIterator]() { | |
| 89 | return { | |
| 90 | next: () => { | |
| 91 | previousHandled?.resolve(); | |
| 92 | if (closed) return Promise.resolve({ done: true as const, value: undefined }); | |
| 93 | const entry = values.shift(); | |
| 94 | if (entry !== undefined) { | |
| 95 | previousHandled = entry.handled; | |
| 96 | return Promise.resolve({ done: false as const, value: entry.value }); | |
| 97 | } | |
| 98 | return new Promise<IteratorResult<unknown>>((resolve) => waiters.push(resolve)); | |
| 99 | }, | |
| 100 | }; | |
| 101 | }, | |
| 102 | }, | |
| 103 | }; | |
| 104 | } | |
| 105 | ||
| 106 | const OPENCODE_TEST_SETTINGS = Schema.decodeUnknownSync(OpenCodeSettings)({}); | |
| 107 | ||
| 108 | function runtimePolicy( | |
| 109 | runtimeMode: ProviderAdapter.ProviderAdapterV2RuntimePolicy["runtimeMode"], | |
| 110 | override: Partial<ProviderAdapter.ProviderAdapterV2RuntimePolicy> = {}, | |
| 111 | ): ProviderAdapter.ProviderAdapterV2RuntimePolicy { | |
| 112 | return ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ | |
| 113 | runtimeMode, | |
| 114 | interactionMode: "default", | |
| 115 | cwd: null, | |
| 116 | ...override, | |
| 117 | }); | |
| 118 | } | |
| 119 | ||
| 120 | function permissionAction(rules: ReturnType<typeof openCodePermissionRules>, permission: string) { | |
| 121 | return rules.findLast((rule) => rule.permission === "*" || rule.permission === permission) | |
| 122 | ?.action; | |
| 123 | } | |
| 124 | ||
| 125 | function providerTurn(input: { | |
| 126 | readonly id: string; | |
| 127 | readonly ordinal: number; | |
| 128 | readonly nativeId: string | null; | |
| 129 | }): OrchestrationV2ProviderTurn { | |
| 130 | return { | |
| 131 | id: ProviderTurnId.make(input.id), | |
| 132 | providerThreadId: ProviderThreadId.make("provider-thread:opencode-test"), | |
| 133 | nodeId: NodeId.make(`node:${input.id}`), | |
| 134 | runAttemptId: null, | |
| 135 | nativeTurnRef: | |
| 136 | input.nativeId === null | |
| 137 | ? null | |
| 138 | : { driver: OPENCODE_PROVIDER, nativeId: input.nativeId, strength: "weak" }, | |
| 139 | ordinal: input.ordinal, | |
| 140 | status: "completed", | |
| 141 | startedAt: null, | |
| 142 | completedAt: null, | |
| 143 | }; | |
| 144 | } | |
| 145 | ||
| 146 | const makeOpenCodeRuntimeHarness = Effect.fn("makeOpenCodeRuntimeHarness")(function* ( | |
| 147 | suffix: string, | |
| 148 | nativeSessionId: string, | |
| 149 | client: object, | |
| 150 | ) { | |
| 151 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | |
| 152 | const instanceId = ProviderInstanceId.make(`opencode-${suffix}`); | |
| 153 | const threadId = ThreadId.make(`thread-opencode-${suffix}`); | |
| 154 | const modelSelection = { | |
| 155 | instanceId, | |
| 156 | model: "anthropic/claude-sonnet", | |
| 157 | options: [], | |
| 158 | }; | |
| 159 | const policy = runtimePolicy("full-access", { cwd: "/workspace" }); | |
| 160 | const adapter = makeOpenCodeAdapterV2({ | |
| 161 | instanceId, | |
| 162 | settings: OPEN_CODE_TEST_SETTINGS, | |
| 163 | environment: {}, | |
| 164 | runtime: { | |
| 165 | connectToOpenCodeServer: () => Effect.succeed({ url: "http://test.invalid", external: true }), | |
| 166 | createOpenCodeSdkClient: () => client, | |
| 167 | } as unknown as OpenCodeRuntime.OpenCodeRuntimeShape, | |
| 168 | idAllocator, | |
| 169 | host: { | |
| 170 | paths: { cwd: "/workspace" }, | |
| 171 | resolveAttachmentPath: () => null, | |
| 172 | } as unknown as ProviderHost.ProviderHostShape, | |
| 173 | }); | |
| 174 | const runtime = yield* adapter.openSession({ | |
| 175 | threadId, | |
| 176 | providerSessionId: ProviderSessionId.make(`session-opencode-${suffix}`), | |
| 177 | modelSelection, | |
| 178 | runtimePolicy: policy, | |
| 179 | }); | |
| 180 | const providerThread = yield* runtime.ensureThread({ | |
| 181 | threadId, | |
| 182 | modelSelection, | |
| 183 | runtimePolicy: policy, | |
| 184 | }); | |
| 185 | const now = yield* DateTime.now; | |
| 186 | const startTurn = ( | |
| 187 | text = "hello", | |
| 188 | startProviderThread: OrchestrationV2ProviderThread = providerThread, | |
| 189 | ) => | |
| 190 | runtime.startTurn({ | |
| 191 | appThread: { | |
| 192 | id: threadId, | |
| 193 | projectId: ProjectId.make(`project-opencode-${suffix}`), | |
| 194 | title: suffix, | |
| 195 | providerInstanceId: modelSelection.instanceId, | |
| 196 | modelSelection, | |
| 197 | runtimeMode: "full-access", | |
| 198 | interactionMode: "default", | |
| 199 | branch: null, | |
| 200 | worktreePath: null, | |
| 201 | activeProviderThreadId: providerThread.id, | |
| 202 | lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, | |
| 203 | forkedFrom: null, | |
| 204 | createdBy: "user", | |
| 205 | creationSource: "web", | |
| 206 | createdAt: now, | |
| 207 | updatedAt: now, | |
| 208 | archivedAt: null, | |
| 209 | settledOverride: null, | |
| 210 | settledAt: null, | |
| 211 | lastVisitedAt: null, | |
| 212 | deletedAt: null, | |
| 213 | }, | |
| 214 | threadId, | |
| 215 | runId: RunId.make(`run-opencode-${suffix}`), | |
| 216 | runOrdinal: 1, | |
| 217 | providerTurnOrdinal: 1, | |
| 218 | attemptId: RunAttemptId.make(`attempt-opencode-${suffix}`), | |
| 219 | rootNodeId: NodeId.make(`node-opencode-${suffix}`), | |
| 220 | providerThread: startProviderThread, | |
| 221 | message: { | |
| 222 | createdBy: "user", | |
| 223 | creationSource: "web", | |
| 224 | messageId: MessageId.make(`message-opencode-${suffix}`), | |
| 225 | text, | |
| 226 | attachments: [], | |
| 227 | }, | |
| 228 | modelSelection, | |
| 229 | runtimePolicy: policy, | |
| 230 | }); | |
| 231 | return { | |
| 232 | nativeSessionId, | |
| 233 | now, | |
| 234 | policy, | |
| 235 | providerThread, | |
| 236 | runId: RunId.make(`run-opencode-${suffix}`), | |
| 237 | runtime, | |
| 238 | startTurn, | |
| 239 | threadId, | |
| 240 | }; | |
| 241 | }); | |
| 242 | ||
| 243 | describe("OpenCodeAdapterV2", () => { | |
| 244 | it.effect.each(["completed", "failed", "unresolved", "unavailable", "reconnect"] as const)( | |
| 245 | "normalizes OpenCode step usage for %s turns", | |
| 246 | (ending) => | |
| 247 | Effect.gen(function* () { | |
| 248 | const nativeEvents = asyncEventStream(); | |
| 249 | let promptId = ""; | |
| 250 | const harness = yield* makeOpenCodeRuntimeHarness(`usage-${ending}`, "root", { | |
| 251 | event: { subscribe: async () => ({ stream: nativeEvents.stream }) }, | |
| 252 | session: { | |
| 253 | create: async () => ({ data: { id: "root", time: { created: 1, updated: 1 } } }), | |
| 254 | promptAsync: async (input: { messageID: string }) => { | |
| 255 | promptId = input.messageID; | |
| 256 | return { data: true }; | |
| 257 | }, | |
| 258 | abort: async () => ({ data: true }), | |
| 259 | children: async () => ({ data: [] }), | |
| 260 | }, | |
| 261 | }); | |
| 262 | yield* harness.startTurn(); | |
| 263 | const received = yield* harness.runtime.events.pipe( | |
| 264 | Stream.takeUntil((event) => event.type === "turn.terminal"), | |
| 265 | Stream.runCollect, | |
| 266 | Effect.forkScoped, | |
| 267 | ); | |
| 268 | yield* Effect.promise(() => | |
| 269 | nativeEvents.push({ | |
| 270 | type: "message.updated", | |
| 271 | properties: { | |
| 272 | sessionID: "root", | |
| 273 | info: { id: promptId, role: "user", time: { created: 1 } }, | |
| 274 | }, | |
| 275 | }), | |
| 276 | ); | |
| 277 | const step = (id: string, messageID = "assistant") => ({ | |
| 278 | type: "message.part.updated", | |
| 279 | properties: { | |
| 280 | part: { | |
| 281 | type: "step-finish", | |
| 282 | id, | |
| 283 | sessionID: "root", | |
| 284 | messageID, | |
| 285 | reason: "stop", | |
| 286 | cost: 0.01, | |
| 287 | tokens: { input: 10, output: 5, reasoning: 2, cache: { read: 3, write: 4 } }, | |
| 288 | }, | |
| 289 | }, | |
| 290 | }); | |
| 291 | if (ending !== "unavailable") { | |
| 292 | yield* Effect.promise(() => nativeEvents.push(step("one"))); | |
| 293 | yield* Effect.promise(() => | |
| 294 | nativeEvents.push({ | |
| 295 | type: "message.updated", | |
| 296 | properties: { | |
| 297 | sessionID: "root", | |
| 298 | info: { | |
| 299 | id: "assistant", | |
| 300 | role: "assistant", | |
| 301 | time: { created: 1 }, | |
| 302 | parentID: promptId, | |
| 303 | }, | |
| 304 | }, | |
| 305 | }), | |
| 306 | ); | |
| 307 | yield* Effect.promise(() => nativeEvents.push(step("one"))); | |
| 308 | yield* Effect.promise(() => nativeEvents.push(step("two"))); | |
| 309 | yield* Effect.promise(() => | |
| 310 | nativeEvents.push({ | |
| 311 | type: "message.part.removed", | |
| 312 | properties: { sessionID: "root", messageID: "assistant", partID: "one" }, | |
| 313 | }), | |
| 314 | ); | |
| 315 | yield* Effect.promise(() => | |
| 316 | nativeEvents.push({ | |
| 317 | type: "message.updated", | |
| 318 | properties: { | |
| 319 | sessionID: "root", | |
| 320 | info: { | |
| 321 | id: "old-assistant", | |
| 322 | role: "assistant", | |
| 323 | time: { created: 1 }, | |
| 324 | parentID: "old-prompt", | |
| 325 | }, | |
| 326 | }, | |
| 327 | }), | |
| 328 | ); | |
| 329 | yield* Effect.promise(() => nativeEvents.push(step("old", "old-assistant"))); | |
| 330 | } | |
| 331 | if (ending === "unresolved") | |
| 332 | yield* Effect.promise(() => nativeEvents.push(step("unknown", "unknown-assistant"))); | |
| 333 | if (ending === "reconnect") { | |
| 334 | yield* Effect.promise(() => | |
| 335 | nativeEvents.push({ type: "server.connected", properties: {} }), | |
| 336 | ); | |
| 337 | yield* Effect.promise(() => | |
| 338 | nativeEvents.push({ type: "server.connected", properties: {} }), | |
| 339 | ); | |
| 340 | } | |
| 341 | if (ending === "failed") { | |
| 342 | yield* Effect.promise(() => | |
| 343 | nativeEvents.push({ | |
| 344 | type: "session.error", | |
| 345 | properties: { | |
| 346 | sessionID: "root", | |
| 347 | error: { name: "UnknownError", data: { message: "failed" } }, | |
| 348 | }, | |
| 349 | }), | |
| 350 | ); | |
| 351 | } else { | |
| 352 | yield* Effect.promise(() => | |
| 353 | nativeEvents.push({ | |
| 354 | type: "session.status", | |
| 355 | properties: { sessionID: "root", status: { type: "busy" } }, | |
| 356 | }), | |
| 357 | ); | |
| 358 | yield* Effect.promise(() => | |
| 359 | nativeEvents.push({ | |
| 360 | type: "session.status", | |
| 361 | properties: { sessionID: "root", status: { type: "idle" } }, | |
| 362 | }), | |
| 363 | ); | |
| 364 | } | |
| 365 | const events = yield* Fiber.join(received); | |
| 366 | const completed = events.findLast((event) => event.type === "provider_turn.updated"); | |
| 367 | assert.deepEqual( | |
| 368 | completed?.providerTurn.turnTokenUsage, | |
| 369 | ending === "unavailable" | |
| 370 | ? { usageStatus: "unavailable", usageScope: "main_agent", hasSubagents: false } | |
| 371 | : { | |
| 372 | usageStatus: ending === "completed" ? "complete" : "partial", | |
| 373 | usageScope: "main_agent", | |
| 374 | inputTokens: 34, | |
| 375 | cachedInputTokens: 6, | |
| 376 | cacheCreationTokens: 8, | |
| 377 | outputTokens: 14, | |
| 378 | reasoningTokens: 4, | |
| 379 | hasSubagents: false, | |
| 380 | }, | |
| 381 | ); | |
| 382 | }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), | |
| 383 | ); | |
| 384 | ||
| 385 | it.effect.each(["permission", "question"] as const)( | |
| 386 | "cancels an undelivered %s reply at its deadline", | |
| 387 | (kind) => | |
| 388 | Effect.gen(function* () { | |
| 389 | const nativeEvents = asyncEventStream(); | |
| 390 | const called = promiseGate<void>(); | |
| 391 | let signal: AbortSignal | undefined; | |
| 392 | let deliver = false; | |
| 393 | const harness = yield* makeOpenCodeRuntimeHarness(`reply-${kind}`, "root", { | |
| 394 | event: { subscribe: async () => ({ stream: nativeEvents.stream }) }, | |
| 395 | session: { | |
| 396 | create: async () => ({ data: { id: "root", time: { created: 1, updated: 1 } } }), | |
| 397 | promptAsync: async () => ({ data: true }), | |
| 398 | abort: async () => ({ data: true }), | |
| 399 | children: async () => ({ data: [] }), | |
| 400 | }, | |
| 401 | [kind]: { | |
| 402 | reply: async (_input: unknown, options: { signal: AbortSignal }) => { | |
| 403 | if (deliver) return { data: true }; | |
| 404 | signal = options.signal; | |
| 405 | called.resolve(); | |
| 406 | return new Promise(() => {}); | |
| 407 | }, | |
| 408 | }, | |
| 409 | }); | |
| 410 | yield* harness.startTurn(); | |
| 411 | const received = yield* harness.runtime.events.pipe( | |
| 412 | Stream.filter((event) => event.type === "runtime_request.updated"), | |
| 413 | Stream.take(1), | |
| 414 | Stream.runCollect, | |
| 415 | Effect.forkScoped, | |
| 416 | ); | |
| 417 | yield* Effect.promise(() => | |
| 418 | nativeEvents.push({ | |
| 419 | type: `${kind}.asked`, | |
| 420 | properties: | |
| 421 | kind === "permission" | |
| 422 | ? { | |
| 423 | id: "request", | |
| 424 | sessionID: "root", | |
| 425 | permission: "bash", | |
| 426 | patterns: ["*"], | |
| 427 | always: [], | |
| 428 | metadata: {}, | |
| 429 | } | |
| 430 | : { | |
| 431 | id: "request", | |
| 432 | sessionID: "root", | |
| 433 | questions: [ | |
| 434 | { | |
| 435 | header: "Choice", | |
| 436 | question: "Which?", | |
| 437 | options: [{ label: "Yes", description: "Proceed" }], | |
| 438 | }, | |
| 439 | ], | |
| 440 | }, | |
| 441 | }), | |
| 442 | ); | |
| 443 | const request = (yield* Fiber.join(received))[0]!.runtimeRequest; | |
| 444 | const response = { | |
| 445 | requestId: request.id, | |
| 446 | ...(kind === "permission" | |
| 447 | ? { decision: "accept" as const } | |
| 448 | : { answers: { Choice: "Yes" } }), | |
| 449 | }; | |
| 450 | const reply = yield* harness.runtime | |
| 451 | .respondToRuntimeRequest(response) | |
| 452 | .pipe(Effect.exit, Effect.forkScoped); | |
| 453 | yield* Effect.promise(() => called.promise); | |
| 454 | yield* TestClock.adjust("10 seconds"); | |
| 455 | assert.isTrue(Exit.isFailure(yield* Fiber.join(reply))); | |
| 456 | assert.isTrue(signal?.aborted); | |
| 457 | deliver = true; | |
| 458 | // Failed delivery leaves the request available for an explicit retry. | |
| 459 | yield* harness.runtime.respondToRuntimeRequest(response); | |
| 460 | }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), | |
| 461 | ); | |
| 462 | ||
| 463 | it.effect("aborts external root and descendants before closing the event stream", () => | |
| 464 | Effect.gen(function* () { | |
| 465 | const scope = yield* Scope.make(); | |
| 466 | const nativeEvents = asyncEventStream(); | |
| 467 | const calls: string[] = []; | |
| 468 | yield* makeOpenCodeRuntimeHarness("release", "root", { | |
| 469 | event: { | |
| 470 | subscribe: async (_input: unknown, options: { signal: AbortSignal }) => { | |
| 471 | options.signal.addEventListener("abort", () => { | |
| 472 | calls.push("stream.close"); | |
| 473 | nativeEvents.close(); | |
| 474 | }); | |
| 475 | return { stream: nativeEvents.stream }; | |
| 476 | }, | |
| 477 | }, | |
| 478 | session: { | |
| 479 | create: async () => ({ data: { id: "root", time: { created: 1, updated: 1 } } }), | |
| 480 | abort: async ({ sessionID }: { sessionID: string }) => { | |
| 481 | calls.push(`abort:${sessionID}`); | |
| 482 | return { data: true }; | |
| 483 | }, | |
| 484 | children: async ({ sessionID }: { sessionID: string }) => { | |
| 485 | calls.push(`children:${sessionID}`); | |
| 486 | return { data: sessionID === "root" ? [{ id: "child" }] : [] }; | |
| 487 | }, | |
| 488 | }, | |
| 489 | }).pipe(Effect.provideService(Scope.Scope, scope)); | |
| 490 | yield* Scope.close(scope, Exit.void); | |
| 491 | assert.deepEqual(calls, [ | |
| 492 | "abort:root", | |
| 493 | "children:root", | |
| 494 | "abort:child", | |
| 495 | "children:child", | |
| 496 | "stream.close", | |
| 497 | ]); | |
| 498 | }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), | |
| 499 | ); | |
| 500 | ||
| 501 | it.effect.each(["enumeration", "abort", "not-found", "timeout"] as const)( | |
| 502 | "reports descendant cleanup %s", | |
| 503 | (failure) => | |
| 504 | Effect.gen(function* () { | |
| 505 | const nativeEvents = asyncEventStream(); | |
| 506 | const called = promiseGate<void>(); | |
| 507 | let childSignal: AbortSignal | undefined; | |
| 508 | const harness = yield* makeOpenCodeRuntimeHarness(`cleanup-${failure}`, "root", { | |
| 509 | event: { subscribe: async () => ({ stream: nativeEvents.stream }) }, | |
| 510 | session: { | |
| 511 | create: async () => ({ data: { id: "root", time: { created: 1, updated: 1 } } }), | |
| 512 | promptAsync: async () => ({ data: true }), | |
| 513 | get: async () => ({ data: { id: "root", time: { created: 1, updated: 1 } } }), | |
| 514 | messages: async () => ({ data: [] }), | |
| 515 | children: async ({ sessionID }: { sessionID: string }) => { | |
| 516 | if (failure === "enumeration") throw new Error("cannot enumerate"); | |
| 517 | return { data: sessionID === "root" ? [{ id: "child" }] : [] }; | |
| 518 | }, | |
| 519 | abort: async ( | |
| 520 | { sessionID }: { sessionID: string }, | |
| 521 | options: { signal: AbortSignal }, | |
| 522 | ) => { | |
| 523 | if (sessionID === "root") return { data: true }; | |
| 524 | if (failure === "timeout" && childSignal?.aborted) return { data: true }; | |
| 525 | childSignal = options.signal; | |
| 526 | called.resolve(); | |
| 527 | if (failure === "timeout") { | |
| 528 | return new Promise(() => {}); | |
| 529 | } | |
| 530 | if (failure === "not-found") throw { status: 404 }; | |
| 531 | throw new Error("child abort failed"); | |
| 532 | }, | |
| 533 | }, | |
| 534 | }); | |
| 535 | yield* harness.startTurn(); | |
| 536 | const snapshot = yield* harness.runtime.readThreadSnapshot({ | |
| 537 | providerThread: harness.providerThread, | |
| 538 | }); | |
| 539 | const stop = yield* harness.runtime | |
| 540 | .interruptTurn({ | |
| 541 | providerThread: harness.providerThread, | |
| 542 | providerTurnId: snapshot.providerTurns.at(-1)!.id, | |
| 543 | }) | |
| 544 | .pipe(Effect.exit, Effect.forkScoped); | |
| 545 | if (failure === "timeout") { | |
| 546 | yield* Effect.promise(() => called.promise); | |
| 547 | yield* TestClock.adjust("15 seconds"); | |
| 548 | } | |
| 549 | const result = yield* Fiber.join(stop); | |
| 550 | assert.equal(Exit.isSuccess(result), failure === "not-found"); | |
| 551 | if (failure === "timeout") assert.isTrue(childSignal?.aborted); | |
| 552 | }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), | |
| 553 | ); | |
| 554 | ||
| 555 | it.effect( | |
| 556 | "preserves tool lifecycle, approval kinds, and late assistant text without cached tool payloads", | |
| 557 | () => | |
| 558 | Effect.gen(function* () { | |
| 559 | const nativeEvents = asyncEventStream(); | |
| 560 | const nativeSessionId = "native-opencode-tool-lifecycle"; | |
| 561 | const harness = yield* makeOpenCodeRuntimeHarness("tool-lifecycle", nativeSessionId, { | |
| 562 | event: { | |
| 563 | subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { | |
| 564 | options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); | |
| 565 | return { stream: nativeEvents.stream }; | |
| 566 | }, | |
| 567 | }, | |
| 568 | session: { | |
| 569 | create: async () => ({ | |
| 570 | data: { id: nativeSessionId, time: { created: 1, updated: 1 } }, | |
| 571 | }), | |
| 572 | promptAsync: async () => ({ data: true }), | |
| 573 | }, | |
| 574 | }); | |
| 575 | yield* harness.startTurn(); | |
| 576 | const received = yield* harness.runtime.events.pipe( | |
| 577 | Stream.takeUntil( | |
| 578 | (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction", | |
| 579 | ), | |
| 580 | Stream.runCollect, | |
| 581 | Effect.forkScoped, | |
| 582 | ); | |
| 583 | const states = [ | |
| 584 | { status: "pending", input: { command: "pwd" }, raw: "" }, | |
| 585 | { | |
| 586 | status: "running", | |
| 587 | input: { command: "pwd" }, | |
| 588 | title: "Working directory", | |
| 589 | time: { start: 1 }, | |
| 590 | }, | |
| 591 | { | |
| 592 | status: "completed", | |
| 593 | input: { command: "pwd" }, | |
| 594 | output: "/repo\n", | |
| 595 | title: "Working directory", | |
| 596 | metadata: {}, | |
| 597 | time: { start: 1, end: 2 }, | |
| 598 | }, | |
| 599 | { | |
| 600 | status: "error", | |
| 601 | input: { command: "pwd" }, | |
| 602 | error: "Command failed", | |
| 603 | time: { start: 3, end: 4 }, | |
| 604 | }, | |
| 605 | ] satisfies ReadonlyArray<ToolPart["state"]>; | |
| 606 | for (const state of states) { | |
| 607 | yield* Effect.promise(() => | |
| 608 | nativeEvents.push({ | |
| 609 | type: "message.part.updated", | |
| 610 | properties: { | |
| 611 | sessionID: nativeSessionId, | |
| 612 | part: { | |
| 613 | id: state.status === "error" ? "part-failed" : "part-working", | |
| 614 | sessionID: nativeSessionId, | |
| 615 | messageID: "late-assistant", | |
| 616 | type: "tool", | |
| 617 | callID: state.status === "error" ? "call-failed" : "call-working", | |
| 618 | tool: "bash", | |
| 619 | state, | |
| 620 | }, | |
| 621 | }, | |
| 622 | }), | |
| 623 | ); | |
| 624 | } | |
| 625 | yield* Effect.promise(() => | |
| 626 | nativeEvents.push({ | |
| 627 | type: "message.part.updated", | |
| 628 | properties: { | |
| 629 | sessionID: nativeSessionId, | |
| 630 | part: { | |
| 631 | id: "part-read", | |
| 632 | sessionID: nativeSessionId, | |
| 633 | messageID: "late-assistant", | |
| 634 | type: "tool", | |
| 635 | callID: "call-read", | |
| 636 | tool: "read", | |
| 637 | state: { status: "pending", input: { filePath: "/outside/file" }, raw: "" }, | |
| 638 | }, | |
| 639 | }, | |
| 640 | }), | |
| 641 | ); | |
| 642 | yield* Effect.promise(() => | |
| 643 | nativeEvents.push({ | |
| 644 | type: "permission.asked", | |
| 645 | properties: { | |
| 646 | id: "read-permission", | |
| 647 | sessionID: nativeSessionId, | |
| 648 | permission: "external_directory", | |
| 649 | patterns: ["/outside/*"], | |
| 650 | always: [], | |
| 651 | metadata: {}, | |
| 652 | tool: { messageID: "late-assistant", callID: "call-read" }, | |
| 653 | }, | |
| 654 | }), | |
| 655 | ); | |
| 656 | yield* Effect.promise(() => | |
| 657 | nativeEvents.push({ | |
| 658 | type: "message.part.updated", | |
| 659 | properties: { | |
| 660 | sessionID: nativeSessionId, | |
| 661 | part: { | |
| 662 | id: "part-text", | |
| 663 | sessionID: nativeSessionId, | |
| 664 | messageID: "late-assistant", | |
| 665 | type: "text", | |
| 666 | text: "Tool results received", | |
| 667 | time: { start: 4 }, | |
| 668 | }, | |
| 669 | }, | |
| 670 | }), | |
| 671 | ); | |
| 672 | yield* Effect.promise(() => | |
| 673 | nativeEvents.push({ | |
| 674 | type: "message.updated", | |
| 675 | properties: { | |
| 676 | sessionID: nativeSessionId, | |
| 677 | info: { | |
| 678 | id: "late-assistant", | |
| 679 | sessionID: nativeSessionId, | |
| 680 | role: "assistant", | |
| 681 | time: { created: 1, completed: 4 }, | |
| 682 | }, | |
| 683 | }, | |
| 684 | }), | |
| 685 | ); | |
| 686 | yield* Effect.promise(() => | |
| 687 | nativeEvents.push({ | |
| 688 | type: "session.compacted", | |
| 689 | properties: { sessionID: nativeSessionId }, | |
| 690 | }), | |
| 691 | ); | |
| 692 | const events = yield* Fiber.join(received); | |
| 693 | const items = events.flatMap((event) => | |
| 694 | event.type === "turn_item.updated" ? [event.turnItem] : [], | |
| 695 | ); | |
| 696 | const commands = items.filter((item) => item.type === "command_execution"); | |
| 697 | assert.deepEqual( | |
| 698 | commands.map((item) => item.status), | |
| 699 | ["pending", "running", "completed", "failed"], | |
| 700 | ); | |
| 701 | assert.deepEqual( | |
| 702 | commands.map((item) => item.input), | |
| 703 | ["pwd", "pwd", "pwd", "pwd"], | |
| 704 | ); | |
| 705 | assert.equal(commands[2]?.output, "/repo\n"); | |
| 706 | assert.equal(commands[3]?.output, "Command failed"); | |
| 707 | assert.isTrue( | |
| 708 | events.some( | |
| 709 | (event) => | |
| 710 | event.type === "runtime_request.updated" && event.runtimeRequest.kind === "file-read", | |
| 711 | ), | |
| 712 | ); | |
| 713 | const assistant = items.filter((item) => item.type === "assistant_message"); | |
| 714 | assert.equal(assistant.at(-1)?.text, "Tool results received"); | |
| 715 | assert.equal(assistant.at(-1)?.status, "completed"); | |
| 716 | }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), | |
| 717 | ); | |
| 718 | ||
| 719 | // Event order from a live OpenCode 1.18.32 run of a `task` call with | |
| 720 | // background=true: the task part completes at launch, the root session | |
| 721 | // settles, and the child session stays busy until its own work ends. | |
| 722 | it.effect("reports a background task child as pending work after the root turn settles", () => | |
| 723 | Effect.gen(function* () { | |
| 724 | const nativeEvents = asyncEventStream(); | |
| 725 | const push = (event: unknown) => Effect.promise(() => nativeEvents.push(event)); | |
| 726 | const root = "ses_root"; | |
| 727 | const child = "ses_child"; | |
| 728 | let promptId = ""; | |
| 729 | const harness = yield* makeOpenCodeRuntimeHarness("background-child", root, { | |
| 730 | event: { subscribe: async () => ({ stream: nativeEvents.stream }) }, | |
| 731 | session: { | |
| 732 | create: async () => ({ data: { id: root, time: { created: 1, updated: 1 } } }), | |
| 733 | get: async () => ({ | |
| 734 | data: { id: child, parentID: root, permission: [], time: { created: 2, updated: 2 } }, | |
| 735 | }), | |
| 736 | update: async () => ({ data: { id: child, parentID: root } }), | |
| 737 | promptAsync: async (input: { messageID: string }) => { | |
| 738 | promptId = input.messageID; | |
| 739 | return { data: true }; | |
| 740 | }, | |
| 741 | abort: async () => ({ data: true }), | |
| 742 | children: async () => ({ data: [] }), | |
| 743 | }, | |
| 744 | }); | |
| 745 | const hasPendingBackgroundWork = harness.runtime.hasPendingBackgroundWork; | |
| 746 | if (hasPendingBackgroundWork === undefined) { | |
| 747 | return yield* Effect.die("OpenCode runtime must expose hasPendingBackgroundWork."); | |
| 748 | } | |
| 749 | yield* harness.startTurn(); | |
| 750 | const terminal = yield* harness.runtime.events.pipe( | |
| 751 | Stream.filter((event) => event.type === "turn.terminal"), | |
| 752 | Stream.runHead, | |
| 753 | Effect.forkScoped, | |
| 754 | ); | |
| 755 | const taskPart = (status: "running" | "completed") => ({ | |
| 756 | type: "message.part.updated", | |
| 757 | properties: { | |
| 758 | sessionID: root, | |
| 759 | part: { | |
| 760 | id: "prt_task", | |
| 761 | sessionID: root, | |
| 762 | messageID: "msg_root_assistant", | |
| 763 | type: "tool", | |
| 764 | tool: "task", | |
| 765 | callID: "call_task", | |
| 766 | state: { | |
| 767 | status, | |
| 768 | input: { | |
| 769 | description: "Background sleep task", | |
| 770 | prompt: "sleep", | |
| 771 | subagent_type: "general", | |
| 772 | }, | |
| 773 | title: "Background sleep task", | |
| 774 | metadata: { parentSessionId: root, sessionId: child, background: true }, | |
| 775 | time: { start: 3, ...(status === "completed" ? { end: 3 } : {}) }, | |
| 776 | ...(status === "completed" ? { output: "Background task started" } : {}), | |
| 777 | }, | |
| 778 | }, | |
| 779 | }, | |
| 780 | }); | |
| 781 | const status = (sessionID: string, type: "busy" | "idle") => ({ | |
| 782 | type: "session.status", | |
| 783 | properties: { sessionID, status: { type } }, | |
| 784 | }); | |
| 785 | ||
| 786 | yield* push({ | |
| 787 | type: "message.updated", | |
| 788 | properties: { | |
| 789 | sessionID: root, | |
| 790 | info: { id: promptId, sessionID: root, role: "user", time: { created: 1 } }, | |
| 791 | }, | |
| 792 | }); | |
| 793 | yield* push(status(root, "busy")); | |
| 794 | yield* push(taskPart("running")); | |
| 795 | yield* push({ | |
| 796 | type: "session.created", | |
| 797 | properties: { | |
| 798 | sessionID: child, | |
| 799 | info: { id: child, parentID: root, time: { created: 2, updated: 2 } }, | |
| 800 | }, | |
| 801 | }); | |
| 802 | yield* push(status(child, "busy")); | |
| 803 | yield* push(taskPart("completed")); | |
| 804 | yield* push(status(root, "idle")); | |
| 805 | assert.equal(Option.getOrUndefined(yield* Fiber.join(terminal))?.status, "completed"); | |
| 806 | assert.isTrue(yield* hasPendingBackgroundWork, "the running child must pin idle release"); | |
| 807 | ||
| 808 | yield* push(status(child, "idle")); | |
| 809 | yield* push({ type: "session.idle", properties: { sessionID: child } }); | |
| 810 | assert.isFalse(yield* hasPendingBackgroundWork, "an idle child must not pin idle release"); | |
| 811 | }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), | |
| 812 | ); | |
| 813 | ||
| 814 | it.effect("titles OpenCode reads and searches from their input", () => | |
| 815 | Effect.gen(function* () { | |
| 816 | const nativeEvents = asyncEventStream(); | |
| 817 | const nativeSessionId = "native-opencode-search"; | |
| 818 | const harness = yield* makeOpenCodeRuntimeHarness("search-projection", nativeSessionId, { | |
| 819 | event: { | |
| 820 | subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { | |
| 821 | options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); | |
| 822 | return { stream: nativeEvents.stream }; | |
| 823 | }, | |
| 824 | }, | |
| 825 | session: { | |
| 826 | create: async () => ({ | |
| 827 | data: { id: nativeSessionId, time: { created: 1, updated: 1 } }, | |
| 828 | }), | |
| 829 | promptAsync: async () => ({ data: true }), | |
| 830 | }, | |
| 831 | }); | |
| 832 | yield* harness.startTurn(); | |
| 833 | const received = yield* harness.runtime.events.pipe( | |
| 834 | Stream.takeUntil( | |
| 835 | (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction", | |
| 836 | ), | |
| 837 | Stream.runCollect, | |
| 838 | Effect.forkScoped, | |
| 839 | ); | |
| 840 | for (const [tool, input, output] of [ | |
| 841 | ["read", { filePath: "src/env.ts" }, "---\nfile body"], | |
| 842 | ["grep", { pattern: "TODO", path: "apps/web" }, "---\nfile body"], | |
| 843 | ["websearch", { query: "OpenCode documentation" }, "---\nfile body"], | |
| 844 | ["glob", { pattern: "missing", path: "apps/web" }, ""], | |
| 845 | ["codesearch", {}, " \n\t"], | |
| 846 | ] as const) { | |
| 847 | yield* Effect.promise(() => | |
| 848 | nativeEvents.push({ | |
| 849 | type: "message.part.updated", | |
| 850 | properties: { | |
| 851 | sessionID: nativeSessionId, | |
| 852 | part: { | |
| 853 | id: `part-${tool}`, | |
| 854 | sessionID: nativeSessionId, | |
| 855 | messageID: "assistant-search", | |
| 856 | type: "tool", | |
| 857 | callID: `call-${tool}`, | |
| 858 | tool, | |
| 859 | state: { | |
| 860 | status: "completed", | |
| 861 | input, | |
| 862 | output, | |
| 863 | title: tool, | |
| 864 | metadata: {}, | |
| 865 | time: { start: 1, end: 2 }, | |
| 866 | }, | |
| 867 | }, | |
| 868 | }, | |
| 869 | }), | |
| 870 | ); | |
| 871 | } | |
| 872 | yield* Effect.promise(() => | |
| 873 | nativeEvents.push({ | |
| 874 | type: "session.compacted", | |
| 875 | properties: { sessionID: nativeSessionId }, | |
| 876 | }), | |
| 877 | ); | |
| 878 | const items = (yield* Fiber.join(received)).flatMap((event) => | |
| 879 | event.type === "turn_item.updated" ? [event.turnItem] : [], | |
| 880 | ); | |
| 881 | const read = items.find((item) => item.type === "dynamic_tool"); | |
| 882 | assert.equal(read?.title, "Read src/env.ts"); | |
| 883 | const grep = items.find((item) => item.type === "file_search"); | |
| 884 | assert.equal(grep?.title, "Searched TODO in web"); | |
| 885 | assert.equal(grep?.type === "file_search" ? grep.pattern : null, "TODO"); | |
| 886 | assert.deepEqual(grep?.type === "file_search" ? grep.results : null, [ | |
| 887 | { fileName: "apps/web", preview: "---\nfile body" }, | |
| 888 | ]); | |
| 889 | const webSearch = items.find((item) => item.type === "web_search"); | |
| 890 | assert.deepEqual(webSearch?.type === "web_search" ? webSearch.patterns : null, [ | |
| 891 | "OpenCode documentation", | |
| 892 | ]); | |
| 893 | assert.deepEqual(webSearch?.type === "web_search" ? webSearch.results : null, [ | |
| 894 | { snippet: "---\nfile body" }, | |
| 895 | ]); | |
| 896 | const emptyFileSearch = items.find( | |
| 897 | (item) => item.type === "file_search" && item.pattern === "missing", | |
| 898 | ); | |
| 899 | assert.ok(emptyFileSearch?.type === "file_search"); | |
| 900 | assert.equal(emptyFileSearch.results, undefined); | |
| 901 | const emptyWebSearch = items.find( | |
| 902 | (item) => item.type === "web_search" && item.patterns === undefined, | |
| 903 | ); | |
| 904 | assert.ok(emptyWebSearch?.type === "web_search"); | |
| 905 | assert.equal(emptyWebSearch.results, undefined); | |
| 906 | }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), | |
| 907 | ); | |
| 908 | ||
| 909 | it.effect("presents OpenCode MCP calls without treating remote tools as local edits", () => | |
| 910 | Effect.gen(function* () { | |
| 911 | const nativeEvents = asyncEventStream(); | |
| 912 | const nativeSessionId = "native-opencode-mcp"; | |
| 913 | let statusReads = 0; | |
| 914 | const harness = yield* makeOpenCodeRuntimeHarness("mcp-presentation", nativeSessionId, { | |
| 915 | event: { | |
| 916 | subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { | |
| 917 | options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); | |
| 918 | return { stream: nativeEvents.stream }; | |
| 919 | }, | |
| 920 | }, | |
| 921 | session: { | |
| 922 | create: async () => ({ data: { id: nativeSessionId, time: { created: 1, updated: 1 } } }), | |
| 923 | promptAsync: async () => ({ data: true }), | |
| 924 | }, | |
| 925 | mcp: { | |
| 926 | status: async () => { | |
| 927 | statusReads++; | |
| 928 | return { | |
| 929 | data: { | |
| 930 | "my.server_with_underscores": { status: "connected" }, | |
| 931 | ambiguous: { status: "connected" }, | |
| 932 | ambiguous_server: { status: "connected" }, | |
| 933 | code: { status: "connected" }, | |
| 934 | apply: { status: "connected" }, | |
| 935 | }, | |
| 936 | }; | |
| 937 | }, | |
| 938 | }, | |
| 939 | }); | |
| 940 | yield* harness.startTurn(); | |
| 941 | const received = yield* harness.runtime.events.pipe( | |
| 942 | Stream.takeUntil( | |
| 943 | (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction", | |
| 944 | ), | |
| 945 | Stream.runCollect, | |
| 946 | Effect.forkScoped, | |
| 947 | ); | |
| 948 | for (const status of ["running", "completed", "error"] as const) { | |
| 949 | yield* Effect.promise(() => | |
| 950 | nativeEvents.push({ | |
| 951 | type: "message.part.updated", | |
| 952 | properties: { | |
| 953 | sessionID: nativeSessionId, | |
| 954 | part: { | |
| 955 | id: "mcp-part", | |
| 956 | sessionID: nativeSessionId, | |
| 957 | messageID: "mcp-assistant", | |
| 958 | type: "tool", | |
| 959 | callID: "mcp-call", | |
| 960 | tool: "my_server_with_underscores_edit_document", | |
| 961 | state: { | |
| 962 | status, | |
| 963 | input: { document: "remote-doc" }, | |
| 964 | title: "Edit remote document", | |
| 965 | metadata: {}, | |
| 966 | output: "saved", | |
| 967 | error: "failed", | |
| 968 | time: { start: 1, end: 2 }, | |
| 969 | }, | |
| 970 | }, | |
| 971 | }, | |
| 972 | }), | |
| 973 | ); | |
| 974 | } | |
| 975 | yield* Effect.promise(() => | |
| 976 | nativeEvents.push({ | |
| 977 | type: "message.part.updated", | |
| 978 | properties: { | |
| 979 | sessionID: nativeSessionId, | |
| 980 | part: { | |
| 981 | id: "ambiguous-part", | |
| 982 | sessionID: nativeSessionId, | |
| 983 | messageID: "mcp-assistant", | |
| 984 | type: "tool", | |
| 985 | callID: "ambiguous-call", | |
| 986 | tool: "ambiguous_server_edit_document", | |
| 987 | state: { | |
| 988 | status: "completed", | |
| 989 | input: {}, | |
| 990 | title: "Ambiguous tool", | |
| 991 | metadata: {}, | |
| 992 | output: "", | |
| 993 | time: { start: 1, end: 2 }, | |
| 994 | }, | |
| 995 | }, | |
| 996 | }, | |
| 997 | }), | |
| 998 | ); | |
| 999 | for (const tool of ["code_search", "apply_patch"]) { | |
| 1000 | yield* Effect.promise(() => | |
| 1001 | nativeEvents.push({ | |
| 1002 | type: "message.part.updated", | |
| 1003 | properties: { | |
| 1004 | sessionID: nativeSessionId, | |
| 1005 | part: { | |
| 1006 | id: `native-${tool}`, | |
| 1007 | sessionID: nativeSessionId, | |
| 1008 | messageID: "mcp-assistant", | |
| 1009 | type: "tool", | |
| 1010 | callID: `native-${tool}`, | |
| 1011 | tool, | |
| 1012 | state: { | |
| 1013 | status: "completed", | |
| 1014 | input: { query: "needle", filePath: "local.ts" }, | |
| 1015 | title: `Native ${tool}`, | |
| 1016 | metadata: {}, | |
| 1017 | output: "done", | |
| 1018 | time: { start: 1, end: 2 }, | |
| 1019 | }, | |
| 1020 | }, | |
| 1021 | }, | |
| 1022 | }), | |
| 1023 | ); | |
| 1024 | } | |
| 1025 | yield* Effect.promise(() => | |
| 1026 | nativeEvents.push({ | |
| 1027 | type: "session.compacted", | |
| 1028 | properties: { sessionID: nativeSessionId }, | |
| 1029 | }), | |
| 1030 | ); | |
| 1031 | const allItems = (yield* Fiber.join(received)).flatMap((event) => | |
| 1032 | event.type === "turn_item.updated" ? [event.turnItem] : [], | |
| 1033 | ); | |
| 1034 | assert.equal( | |
| 1035 | allItems.find((item) => item.title === "Native code_search")?.type, | |
| 1036 | "web_search", | |
| 1037 | ); | |
| 1038 | assert.equal( | |
| 1039 | allItems.find((item) => item.title === "Native apply_patch")?.type, | |
| 1040 | "file_change", | |
| 1041 | ); | |
| 1042 | const items = allItems.filter((item) => item.type === "dynamic_tool"); | |
| 1043 | assert.deepEqual( | |
| 1044 | items.slice(0, 3).map((item) => item.status), | |
| 1045 | ["running", "completed", "failed"], | |
| 1046 | ); | |
| 1047 | assert.deepEqual( | |
| 1048 | items.slice(0, 3).map((item) => item.title), | |
| 1049 | ["Edit remote document", "Edit remote document", "edit document"], | |
| 1050 | ); | |
| 1051 | for (const item of items.slice(0, 3)) { | |
| 1052 | assert.deepEqual(item.toolSource, { | |
| 1053 | key: "mcp:my.server_with_underscores", | |
| 1054 | name: "my.server with underscores", | |
| 1055 | kind: "integration", | |
| 1056 | }); | |
| 1057 | assert.deepEqual(item.input, { document: "remote-doc" }); | |
| 1058 | } | |
| 1059 | assert.equal(items[3]?.toolName, "ambiguous_server_edit_document"); | |
| 1060 | assert.isUndefined(items[3]?.toolSource); | |
| 1061 | assert.equal(statusReads, 1); | |
| 1062 | }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), | |
| 1063 | ); | |
| 1064 | ||
| 1065 | it.effect.each(["failure", "timeout"] as const)("retries MCP status after %s", (failure) => | |
| 1066 | Effect.gen(function* () { | |
| 1067 | const nativeEvents = asyncEventStream(); | |
| 1068 | const called = promiseGate<void>(); | |
| 1069 | const sessionId = `mcp-status-${failure}`; | |
| 1070 | let statusReads = 0; | |
| 1071 | let signal: AbortSignal | undefined; | |
| 1072 | const harness = yield* makeOpenCodeRuntimeHarness(sessionId, sessionId, { | |
| 1073 | event: { subscribe: async () => ({ stream: nativeEvents.stream }) }, | |
| 1074 | session: { | |
| 1075 | create: async () => ({ data: { id: sessionId, time: { created: 1, updated: 1 } } }), | |
| 1076 | promptAsync: async () => ({ data: true }), | |
| 1077 | }, | |
| 1078 | mcp: { | |
| 1079 | status: async (_input: unknown, options: { signal?: AbortSignal }) => { | |
| 1080 | statusReads++; | |
| 1081 | if (statusReads === 1) { | |
| 1082 | signal = options?.signal; | |
| 1083 | called.resolve(); | |
| 1084 | if (failure === "timeout") return new Promise(() => {}); | |
| 1085 | throw new Error("MCP status unavailable"); | |
| 1086 | } | |
| 1087 | return { data: { weather: { status: "connected" } } }; | |
| 1088 | }, | |
| 1089 | }, | |
| 1090 | }); | |
| 1091 | yield* harness.startTurn(); | |
| 1092 | const received = yield* harness.runtime.events.pipe( | |
| 1093 | Stream.takeUntil( | |
| 1094 | (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction", | |
| 1095 | ), | |
| 1096 | Stream.runCollect, | |
| 1097 | Effect.forkScoped, | |
| 1098 | ); | |
| 1099 | for (const status of ["running", "completed"] as const) { | |
| 1100 | const emitted = yield* Effect.promise(() => | |
| 1101 | nativeEvents.push({ | |
| 1102 | type: "message.part.updated", | |
| 1103 | properties: { | |
| 1104 | sessionID: sessionId, | |
| 1105 | part: { | |
| 1106 | id: "weather-part", | |
| 1107 | sessionID: sessionId, | |
| 1108 | messageID: "weather-message", | |
| 1109 | type: "tool", | |
| 1110 | callID: "weather-call", | |
| 1111 | tool: "weather_edit_document", | |
| 1112 | state: { | |
| 1113 | status, | |
| 1114 | input: { document: "remote-doc" }, | |
| 1115 | title: "Edit remote document", | |
| 1116 | metadata: {}, | |
| 1117 | output: "saved", | |
| 1118 | time: { start: 1, end: 2 }, | |
| 1119 | }, | |
| 1120 | }, | |
| 1121 | }, | |
| 1122 | }), | |
| 1123 | ).pipe(Effect.forkScoped); | |
| 1124 | if (status === "running" && failure === "timeout") { | |
| 1125 | yield* Effect.promise(() => called.promise); | |
| 1126 | yield* TestClock.adjust("1 second"); | |
| 1127 | } | |
| 1128 | yield* Fiber.join(emitted); | |
| 1129 | } | |
| 1130 | yield* Effect.promise(() => | |
| 1131 | nativeEvents.push({ type: "session.compacted", properties: { sessionID: sessionId } }), | |
| 1132 | ); | |
| 1133 | const completed = (yield* Fiber.join(received)).find( | |
| 1134 | (event) => | |
| 1135 | event.type === "turn_item.updated" && | |
| 1136 | event.turnItem.type === "dynamic_tool" && | |
| 1137 | event.turnItem.status === "completed", | |
| 1138 | ); | |
| 1139 | assert.equal(statusReads, 2); | |
| 1140 | assert.equal( | |
| 1141 | completed?.type === "turn_item.updated" && completed.turnItem.title, | |
| 1142 | "Edit remote document", | |
| 1143 | ); | |
| 1144 | assert.deepEqual(completed?.type === "turn_item.updated" && completed.turnItem.toolSource, { | |
| 1145 | key: "mcp:weather", | |
| 1146 | name: "weather", | |
| 1147 | kind: "integration", | |
| 1148 | }); | |
| 1149 | if (failure === "timeout") assert.isTrue(signal?.aborted); | |
| 1150 | }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), | |
| 1151 | ); | |
| 1152 | ||
| 1153 | it.effect("admits a native command on its user receipt before generation completes", () => | |
| 1154 | Effect.gen(function* () { | |
| 1155 | const nativeEvents = asyncEventStream(); | |
| 1156 | const release = promiseGate<void>(); | |
| 1157 | const calls = yield* Queue.unbounded<{ | |
| 1158 | messageID: string; | |
| 1159 | command: string; | |
| 1160 | arguments: string; | |
| 1161 | model: string; | |
| 1162 | }>(); | |
| 1163 | let promptCalls = 0; | |
| 1164 | const sessionId = "native-command"; | |
| 1165 | const harness = yield* makeOpenCodeRuntimeHarness("native-command", sessionId, { | |
| 1166 | event: { | |
| 1167 | subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { | |
| 1168 | options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); | |
| 1169 | return { stream: nativeEvents.stream }; | |
| 1170 | }, | |
| 1171 | }, | |
| 1172 | command: { list: async () => ({ data: [{ name: "review" }] }) }, | |
| 1173 | session: { | |
| 1174 | create: async () => ({ data: { id: sessionId, time: { created: 1, updated: 1 } } }), | |
| 1175 | command: async (input: { | |
| 1176 | messageID: string; | |
| 1177 | command: string; | |
| 1178 | arguments: string; | |
| 1179 | model: string; | |
| 1180 | }) => { | |
| 1181 | Queue.offerUnsafe(calls, input); | |
| 1182 | await release.promise; | |
| 1183 | return { data: true }; | |
| 1184 | }, | |
| 1185 | promptAsync: async () => { | |
| 1186 | promptCalls++; | |
| 1187 | return { data: true }; | |
| 1188 | }, | |
| 1189 | }, | |
| 1190 | }); | |
| 1191 | const start = yield* harness.startTurn("/review staged changes").pipe(Effect.forkScoped); | |
| 1192 | const call = yield* Queue.take(calls); | |
| 1193 | assert.equal(call.command, "review"); | |
| 1194 | assert.equal(call.arguments, "staged changes"); | |
| 1195 | assert.equal(call.model, "anthropic/claude-sonnet"); | |
| 1196 | yield* Effect.promise(() => | |
| 1197 | nativeEvents.push({ | |
| 1198 | type: "message.updated", | |
| 1199 | properties: { | |
| 1200 | sessionID: sessionId, | |
| 1201 | info: { | |
| 1202 | id: call.messageID, | |
| 1203 | sessionID: sessionId, | |
| 1204 | role: "user", | |
| 1205 | time: { created: DateTime.toEpochMillis(harness.now) }, | |
| 1206 | }, | |
| 1207 | }, | |
| 1208 | }), | |
| 1209 | ); | |
| 1210 | yield* Fiber.join(start); | |
| 1211 | assert.equal(promptCalls, 0); | |
| 1212 | release.resolve(); | |
| 1213 | }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), | |
| 1214 | ); | |
| 1215 | ||
| 1216 | it.effect("sends unadvertised slash commands as ordinary prompts", () => | |
| 1217 | Effect.gen(function* () { | |
| 1218 | const nativeEvents = asyncEventStream(); | |
| 1219 | const prompts: unknown[] = []; | |
| 1220 | const harness = yield* makeOpenCodeRuntimeHarness("unknown-command", "unknown-command", { | |
| 1221 | event: { | |
| 1222 | subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { | |
| 1223 | options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); | |
| 1224 | return { stream: nativeEvents.stream }; | |
| 1225 | }, | |
| 1226 | }, | |
| 1227 | command: { list: async () => ({ data: [{ name: "review" }] }) }, | |
| 1228 | session: { | |
| 1229 | create: async () => ({ | |
| 1230 | data: { id: "unknown-command", time: { created: 1, updated: 1 } }, | |
| 1231 | }), | |
| 1232 | promptAsync: async (input: { parts: unknown }) => { | |
| 1233 | prompts.push(input.parts); | |
| 1234 | return { data: true }; | |
| 1235 | }, | |
| 1236 | }, | |
| 1237 | }); | |
| 1238 | yield* harness.startTurn("/unknown words"); | |
| 1239 | assert.deepEqual(prompts, [[{ type: "text", text: "/unknown words" }]]); | |
| 1240 | }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), | |
| 1241 | ); | |
| 1242 | ||
| 1243 | it.effect("compacts with the native summarize API and emits a completed compaction", () => | |
| 1244 | Effect.gen(function* () { | |
| 1245 | const nativeEvents = asyncEventStream(); | |
| 1246 | const summarizeCalls: Array<unknown> = []; | |
| 1247 | let promptCalls = 0; | |
| 1248 | const harness = yield* makeOpenCodeRuntimeHarness("compact", "native-opencode-compact", { | |
| 1249 | event: { | |
| 1250 | subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { | |
| 1251 | options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); | |
| 1252 | return { stream: nativeEvents.stream }; | |
| 1253 | }, | |
| 1254 | }, | |
| 1255 | session: { | |
| 1256 | create: async () => ({ | |
| 1257 | data: { id: "native-opencode-compact", time: { created: 1, updated: 1 } }, | |
| 1258 | }), | |
| 1259 | summarize: async (input: unknown) => { | |
| 1260 | summarizeCalls.push(input); | |
| 1261 | return { data: true }; | |
| 1262 | }, | |
| 1263 | promptAsync: async () => { | |
| 1264 | promptCalls += 1; | |
| 1265 | return { data: true }; | |
| 1266 | }, | |
| 1267 | }, | |
| 1268 | }); | |
| 1269 | yield* harness.startTurn("/compact"); | |
| 1270 | const events = yield* harness.runtime.events.pipe( | |
| 1271 | Stream.takeUntil((event) => event.type === "turn.terminal"), | |
| 1272 | Stream.runCollect, | |
| 1273 | ); | |
| 1274 | assert.deepEqual(summarizeCalls, [ | |
| 1275 | { | |
| 1276 | sessionID: "native-opencode-compact", | |
| 1277 | providerID: "anthropic", | |
| 1278 | modelID: "claude-sonnet", | |
| 1279 | auto: false, | |
| 1280 | }, | |
| 1281 | ]); | |
| 1282 | assert.equal(promptCalls, 0); | |
| 1283 | assert.equal( | |
| 1284 | events.filter( | |
| 1285 | (event) => | |
| 1286 | event.type === "turn_item.updated" && | |
| 1287 | event.turnItem.type === "compaction" && | |
| 1288 | event.turnItem.status === "completed", | |
| 1289 | ).length, | |
| 1290 | 1, | |
| 1291 | ); | |
| 1292 | assert.isTrue( | |
| 1293 | events.some((event) => event.type === "turn.terminal" && event.status === "completed"), | |
| 1294 | ); | |
| 1295 | }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), | |
| 1296 | ); | |
| 1297 | ||
| 1298 | it.effect("ends a turn on the provider thread it started on", () => | |
| 1299 | Effect.gen(function* () { | |
| 1300 | const nativeEvents = asyncEventStream(); | |
| 1301 | const harness = yield* makeOpenCodeRuntimeHarness( | |
| 1302 | "forked-row", | |
| 1303 | "native-opencode-forked-row", | |
| 1304 | { | |
| 1305 | event: { | |
| 1306 | subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { | |
| 1307 | options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); | |
| 1308 | return { stream: nativeEvents.stream }; | |
| 1309 | }, | |
| 1310 | }, | |
| 1311 | session: { | |
| 1312 | create: async () => ({ | |
| 1313 | data: { id: "native-opencode-forked-row", time: { created: 1, updated: 1 } }, | |
| 1314 | }), | |
| 1315 | summarize: async () => ({ data: true }), | |
| 1316 | }, | |
| 1317 | }, | |
| 1318 | ); | |
| 1319 | // A forked run starts on its own row for the same native session, while | |
| 1320 | // the adapter tracks the session under the id it minted. | |
| 1321 | const forkedRow = { | |
| 1322 | ...harness.providerThread, | |
| 1323 | id: ProviderThreadId.make("provider-thread:opencode-test:forked-run-row"), | |
| 1324 | }; | |
| 1325 | yield* harness.startTurn("/compact", forkedRow); | |
| 1326 | const terminal = yield* harness.runtime.events.pipe( | |
| 1327 | Stream.filter( | |
| 1328 | (event): event is Extract<typeof event, { type: "turn.terminal" }> => | |
| 1329 | event.type === "turn.terminal", | |
| 1330 | ), | |
| 1331 | Stream.runHead, | |
| 1332 | ); | |
| 1333 | assert.equal(Option.getOrUndefined(terminal)?.providerThreadId, forkedRow.id); | |
| 1334 | }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), | |
| 1335 | ); | |
| 1336 | ||
| 1337 | it.effect("does not restore messages beyond OpenCode's persisted revert boundary", () => | |
| 1338 | Effect.gen(function* () { | |
| 1339 | const nativeEvents = asyncEventStream(); | |
| 1340 | const nativeSessionId = "native-opencode-reverted-snapshot"; | |
| 1341 | const messages = [ | |
| 1342 | { | |
| 1343 | info: { | |
| 1344 | id: "user-kept", | |
| 1345 | sessionID: nativeSessionId, | |
| 1346 | role: "user", | |
| 1347 | time: { created: 1 }, | |
| 1348 | }, | |
| 1349 | parts: [{ type: "text", text: "keep this prompt" }], | |
| 1350 | }, | |
| 1351 | { | |
| 1352 | info: { | |
| 1353 | id: "assistant-kept", | |
| 1354 | sessionID: nativeSessionId, | |
| 1355 | role: "assistant", | |
| 1356 | time: { created: 2, completed: 3 }, | |
| 1357 | }, | |
| 1358 | parts: [{ type: "text", text: "keep this answer" }], | |
| 1359 | }, | |
| 1360 | { | |
| 1361 | info: { | |
| 1362 | id: "user-reverted", | |
| 1363 | sessionID: nativeSessionId, | |
| 1364 | role: "user", | |
| 1365 | time: { created: 4 }, | |
| 1366 | }, | |
| 1367 | parts: [{ type: "text", text: "remove this prompt" }], | |
| 1368 | }, | |
| 1369 | { | |
| 1370 | info: { | |
| 1371 | id: "assistant-reverted", | |
| 1372 | sessionID: nativeSessionId, | |
| 1373 | role: "assistant", | |
| 1374 | time: { created: 5, completed: 6 }, | |
| 1375 | }, | |
| 1376 | parts: [{ type: "text", text: "remove this answer" }], | |
| 1377 | }, | |
| 1378 | ]; | |
| 1379 | const harness = yield* makeOpenCodeRuntimeHarness("reverted-snapshot", nativeSessionId, { | |
| 1380 | event: { | |
| 1381 | subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { | |
| 1382 | options.signal?.addEventListener("abort", () => nativeEvents.close(), { | |
| 1383 | once: true, | |
| 1384 | }); | |
| 1385 | return { stream: nativeEvents.stream }; | |
| 1386 | }, | |
| 1387 | }, | |
| 1388 | session: { | |
| 1389 | create: async () => ({ | |
| 1390 | data: { id: nativeSessionId, time: { created: 1, updated: 1 } }, | |
| 1391 | }), | |
| 1392 | get: async () => ({ | |
| 1393 | data: { | |
| 1394 | id: nativeSessionId, | |
| 1395 | time: { created: 1, updated: 6 }, | |
| 1396 | revert: { messageID: "user-reverted" }, | |
| 1397 | }, | |
| 1398 | }), | |
| 1399 | messages: async () => ({ data: messages }), | |
| 1400 | }, | |
| 1401 | }); | |
| 1402 | ||
| 1403 | const snapshot = yield* harness.runtime.readThreadSnapshot({ | |
| 1404 | providerThread: harness.providerThread, | |
| 1405 | }); | |
| 1406 | ||
| 1407 | assert.deepEqual( | |
| 1408 | snapshot.messages.map((message) => message.text), | |
| 1409 | ["keep this prompt", "keep this answer"], | |
| 1410 | ); | |
| 1411 | const providerPayload = snapshot.providerPayload as ReadonlyArray<{ | |
| 1412 | readonly info: { readonly id: string }; | |
| 1413 | }>; | |
| 1414 | assert.deepEqual( | |
| 1415 | providerPayload.map((entry) => entry.info.id), | |
| 1416 | ["user-kept", "assistant-kept"], | |
| 1417 | ); | |
| 1418 | assert.equal(snapshot.providerThread.nativeConversationHeadRef?.nativeId, "user-kept"); | |
| 1419 | }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), | |
| 1420 | ); | |
| 1421 | ||
| 1422 | it.effect("keeps a newly admitted prompt alive across stale idle and delayed busy evidence", () => | |
| 1423 | Effect.gen(function* () { | |
| 1424 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | |
| 1425 | const nativeEvents = asyncEventStream(); | |
| 1426 | const prompt = promiseGate<void>(); | |
| 1427 | const promptStarted = promiseGate<void>(); | |
| 1428 | const steerPrompt = promiseGate<void>(); | |
| 1429 | const steerPromptStarted = promiseGate<void>(); | |
| 1430 | const stopPromptStarted = promiseGate<void>(); | |
| 1431 | const statusCalled = promiseGate<void>(); | |
| 1432 | const steerStatusCalled = promiseGate<void>(); | |
| 1433 | const abortCalled = promiseGate<void>(); | |
| 1434 | let status: "idle" | "busy" = "busy"; | |
| 1435 | let promptCalls = 0; | |
| 1436 | let statusCalls = 0; | |
| 1437 | const promptInputs: Array<{ readonly messageID?: string }> = []; | |
| 1438 | const client = { | |
| 1439 | event: { | |
| 1440 | subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { | |
| 1441 | options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); | |
| 1442 | return { stream: nativeEvents.stream }; | |
| 1443 | }, | |
| 1444 | }, | |
| 1445 | session: { | |
| 1446 | create: async () => ({ | |
| 1447 | data: { id: "native-opencode-race", time: { created: 1, updated: 1 } }, | |
| 1448 | }), | |
| 1449 | get: async () => ({ | |
| 1450 | data: { id: "native-opencode-race", time: { created: 1, updated: 1 } }, | |
| 1451 | }), | |
| 1452 | promptAsync: async ( | |
| 1453 | input: { readonly messageID?: string }, | |
| 1454 | options?: { readonly signal?: AbortSignal }, | |
| 1455 | ) => { | |
| 1456 | promptInputs.push(input); | |
| 1457 | promptCalls += 1; | |
| 1458 | if (promptCalls === 1) { | |
| 1459 | promptStarted.resolve(); | |
| 1460 | await prompt.promise; | |
| 1461 | } else if (promptCalls === 2) { | |
| 1462 | steerPromptStarted.resolve(); | |
| 1463 | await steerPrompt.promise; | |
| 1464 | } else { | |
| 1465 | stopPromptStarted.resolve(); | |
| 1466 | await new Promise<void>((_resolve, reject) => | |
| 1467 | options?.signal?.addEventListener("abort", () => reject(options.signal!.reason), { | |
| 1468 | once: true, | |
| 1469 | }), | |
| 1470 | ); | |
| 1471 | } | |
| 1472 | return { data: true }; | |
| 1473 | }, | |
| 1474 | status: async () => ({ | |
| 1475 | data: (() => { | |
| 1476 | statusCalls += 1; | |
| 1477 | if (statusCalls === 1) statusCalled.resolve(); | |
| 1478 | else steerStatusCalled.resolve(); | |
| 1479 | return { "native-opencode-race": { type: status } }; | |
| 1480 | })(), | |
| 1481 | }), | |
| 1482 | messages: async () => ({ data: [] }), | |
| 1483 | children: async () => ({ data: [] }), | |
| 1484 | abort: async () => { | |
| 1485 | abortCalled.resolve(); | |
| 1486 | return { data: true }; | |
| 1487 | }, | |
| 1488 | }, | |
| 1489 | mcp: { add: async () => ({ data: true }) }, | |
| 1490 | }; | |
| 1491 | const adapter = makeOpenCodeAdapterV2({ | |
| 1492 | instanceId: ProviderInstanceId.make("opencode-test"), | |
| 1493 | settings: OPEN_CODE_TEST_SETTINGS, | |
| 1494 | environment: {}, | |
| 1495 | runtime: { | |
| 1496 | connectToOpenCodeServer: () => | |
| 1497 | Effect.succeed({ url: "http://test.invalid", external: true }), | |
| 1498 | createOpenCodeSdkClient: () => client, | |
| 1499 | } as unknown as OpenCodeRuntime.OpenCodeRuntimeShape, | |
| 1500 | idAllocator, | |
| 1501 | host: { | |
| 1502 | paths: { cwd: "/workspace" }, | |
| 1503 | resolveAttachmentPath: () => null, | |
| 1504 | } as unknown as ProviderHost.ProviderHostShape, | |
| 1505 | }); | |
| 1506 | const threadId = ThreadId.make("thread-opencode-admission-race"); | |
| 1507 | const providerSessionId = ProviderSessionId.make("session-opencode-admission-race"); | |
| 1508 | const modelSelection = { | |
| 1509 | instanceId: ProviderInstanceId.make("opencode-test"), | |
| 1510 | model: "anthropic/claude-sonnet", | |
| 1511 | options: [], | |
| 1512 | }; | |
| 1513 | const policy = runtimePolicy("full-access", { cwd: "/workspace" }); | |
| 1514 | const runtime = yield* adapter.openSession({ | |
| 1515 | threadId, | |
| 1516 | providerSessionId, | |
| 1517 | modelSelection, | |
| 1518 | runtimePolicy: policy, | |
| 1519 | }); | |
| 1520 | const providerThread = yield* runtime.ensureThread({ | |
| 1521 | threadId, | |
| 1522 | modelSelection, | |
| 1523 | runtimePolicy: policy, | |
| 1524 | }); | |
| 1525 | const now = yield* DateTime.now; | |
| 1526 | const attemptId = RunAttemptId.make("attempt-opencode-admission-race"); | |
| 1527 | const runId = RunId.make("run-opencode-admission-race"); | |
| 1528 | const start = yield* runtime | |
| 1529 | .startTurn({ | |
| 1530 | appThread: { | |
| 1531 | id: threadId, | |
| 1532 | projectId: ProjectId.make("project-opencode-admission-race"), | |
| 1533 | title: "race", | |
| 1534 | providerInstanceId: modelSelection.instanceId, | |
| 1535 | modelSelection, | |
| 1536 | runtimeMode: "full-access", | |
| 1537 | interactionMode: "default", | |
| 1538 | branch: null, | |
| 1539 | worktreePath: null, | |
| 1540 | activeProviderThreadId: providerThread.id, | |
| 1541 | lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, | |
| 1542 | forkedFrom: null, | |
| 1543 | createdBy: "user", | |
| 1544 | creationSource: "web", | |
| 1545 | createdAt: now, | |
| 1546 | updatedAt: now, | |
| 1547 | archivedAt: null, | |
| 1548 | settledOverride: null, | |
| 1549 | settledAt: null, | |
| 1550 | lastVisitedAt: null, | |
| 1551 | deletedAt: null, | |
| 1552 | }, | |
| 1553 | threadId, | |
| 1554 | runId, | |
| 1555 | runOrdinal: 1, | |
| 1556 | providerTurnOrdinal: 1, | |
| 1557 | attemptId, | |
| 1558 | rootNodeId: NodeId.make("node-opencode-admission-race"), | |
| 1559 | providerThread, | |
| 1560 | message: { | |
| 1561 | createdBy: "user", | |
| 1562 | creationSource: "web", | |
| 1563 | messageId: MessageId.make("message-opencode-admission-race"), | |
| 1564 | text: "hello", | |
| 1565 | attachments: [], | |
| 1566 | }, | |
| 1567 | modelSelection, | |
| 1568 | runtimePolicy: policy, | |
| 1569 | }) | |
| 1570 | .pipe(Effect.forkScoped); | |
| 1571 | ||
| 1572 | yield* Effect.promise(() => promptStarted.promise); | |
| 1573 | yield* Effect.promise(() => | |
| 1574 | nativeEvents.push({ | |
| 1575 | type: "session.status", | |
| 1576 | properties: { sessionID: "native-opencode-race", status: { type: "idle" } }, | |
| 1577 | }), | |
| 1578 | ); | |
| 1579 | prompt.resolve(); | |
| 1580 | yield* Fiber.join(start); | |
| 1581 | assert.match(promptInputs[0]?.messageID ?? "", /^msg_[0-9a-f]{12}[0-9A-Za-z]{28}$/); | |
| 1582 | yield* Effect.promise(() => | |
| 1583 | nativeEvents.push({ | |
| 1584 | type: "session.status", | |
| 1585 | properties: { sessionID: "native-opencode-race", status: { type: "busy" } }, | |
| 1586 | }), | |
| 1587 | ); | |
| 1588 | status = "busy"; | |
| 1589 | yield* Effect.promise(() => | |
| 1590 | nativeEvents.push({ | |
| 1591 | type: "message.updated", | |
| 1592 | properties: { | |
| 1593 | sessionID: "native-opencode-race", | |
| 1594 | info: { | |
| 1595 | id: "stale-user-message", | |
| 1596 | sessionID: "native-opencode-race", | |
| 1597 | role: "user", | |
| 1598 | time: { created: DateTime.toEpochMillis(now) }, | |
| 1599 | }, | |
| 1600 | }, | |
| 1601 | }), | |
| 1602 | ); | |
| 1603 | assert.equal(statusCalls, 0, "an unrelated user message must not release prompt admission"); | |
| 1604 | yield* Effect.promise(() => | |
| 1605 | nativeEvents.push({ | |
| 1606 | type: "session.status", | |
| 1607 | properties: { sessionID: "native-opencode-race", status: { type: "idle" } }, | |
| 1608 | }), | |
| 1609 | ); | |
| 1610 | yield* Effect.promise(() => | |
| 1611 | nativeEvents.push({ | |
| 1612 | type: "message.updated", | |
| 1613 | properties: { | |
| 1614 | sessionID: "native-opencode-race", | |
| 1615 | info: { | |
| 1616 | id: promptInputs[0]!.messageID!, | |
| 1617 | sessionID: "native-opencode-race", | |
| 1618 | role: "user", | |
| 1619 | time: { created: DateTime.toEpochMillis(now) }, | |
| 1620 | }, | |
| 1621 | }, | |
| 1622 | }), | |
| 1623 | ); | |
| 1624 | ||
| 1625 | yield* Effect.promise(() => statusCalled.promise); | |
| 1626 | ||
| 1627 | const snapshot = yield* runtime.readThreadSnapshot({ providerThread }); | |
| 1628 | assert.equal(snapshot.providerTurns.at(-1)?.status, "running"); | |
| 1629 | const activeTurn = snapshot.providerTurns.at(-1)!; | |
| 1630 | const steer = yield* runtime | |
| 1631 | .steerTurn({ | |
| 1632 | threadId, | |
| 1633 | runId, | |
| 1634 | providerThread, | |
| 1635 | providerTurnId: activeTurn.id, | |
| 1636 | message: { | |
| 1637 | messageId: MessageId.make("message-opencode-steer-race"), | |
| 1638 | text: "follow up", | |
| 1639 | attachments: [], | |
| 1640 | createdBy: "user", | |
| 1641 | creationSource: "web", | |
| 1642 | }, | |
| 1643 | }) | |
| 1644 | .pipe(Effect.forkScoped); | |
| 1645 | yield* Effect.promise(() => steerPromptStarted.promise); | |
| 1646 | assert.notEqual(promptInputs[1]?.messageID, promptInputs[0]?.messageID); | |
| 1647 | steerPrompt.resolve(); | |
| 1648 | yield* Fiber.join(steer); | |
| 1649 | yield* Effect.promise(() => | |
| 1650 | nativeEvents.push({ | |
| 1651 | type: "session.status", | |
| 1652 | properties: { sessionID: "native-opencode-race", status: { type: "busy" } }, | |
| 1653 | }), | |
| 1654 | ); | |
| 1655 | yield* Effect.promise(() => | |
| 1656 | nativeEvents.push({ | |
| 1657 | type: "message.updated", | |
| 1658 | properties: { | |
| 1659 | sessionID: "native-opencode-race", | |
| 1660 | info: { | |
| 1661 | id: promptInputs[0]!.messageID!, | |
| 1662 | sessionID: "native-opencode-race", | |
| 1663 | role: "user", | |
| 1664 | time: { created: DateTime.toEpochMillis(now) }, | |
| 1665 | }, | |
| 1666 | }, | |
| 1667 | }), | |
| 1668 | ); | |
| 1669 | yield* Effect.promise(() => | |
| 1670 | nativeEvents.push({ | |
| 1671 | type: "session.status", | |
| 1672 | properties: { sessionID: "native-opencode-race", status: { type: "idle" } }, | |
| 1673 | }), | |
| 1674 | ); | |
| 1675 | yield* Effect.promise(() => | |
| 1676 | nativeEvents.push({ | |
| 1677 | type: "message.updated", | |
| 1678 | properties: { | |
| 1679 | sessionID: "native-opencode-race", | |
| 1680 | info: { | |
| 1681 | id: promptInputs[1]!.messageID!, | |
| 1682 | sessionID: "native-opencode-race", | |
| 1683 | role: "user", | |
| 1684 | time: { created: DateTime.toEpochMillis(now) }, | |
| 1685 | }, | |
| 1686 | }, | |
| 1687 | }), | |
| 1688 | ); | |
| 1689 | yield* Effect.promise(() => steerStatusCalled.promise); | |
| 1690 | const pendingSteer = yield* runtime | |
| 1691 | .steerTurn({ | |
| 1692 | threadId, | |
| 1693 | runId, | |
| 1694 | providerThread, | |
| 1695 | providerTurnId: activeTurn.id, | |
| 1696 | message: { | |
| 1697 | messageId: MessageId.make("message-opencode-stop-pending"), | |
| 1698 | text: "pending when stopped", | |
| 1699 | attachments: [], | |
| 1700 | createdBy: "user", | |
| 1701 | creationSource: "web", | |
| 1702 | }, | |
| 1703 | }) | |
| 1704 | .pipe(Effect.exit, Effect.forkScoped); | |
| 1705 | yield* Effect.promise(() => stopPromptStarted.promise); | |
| 1706 | const interrupt = yield* runtime | |
| 1707 | .interruptTurn({ providerThread, providerTurnId: activeTurn.id }) | |
| 1708 | .pipe(Effect.forkScoped); | |
| 1709 | yield* Effect.promise(() => abortCalled.promise); | |
| 1710 | yield* Fiber.join(interrupt); | |
| 1711 | assert.isTrue(Exit.isFailure(yield* Fiber.join(pendingSteer))); | |
| 1712 | const promptWhileStopping = yield* Effect.exit( | |
| 1713 | runtime.steerTurn({ | |
| 1714 | threadId, | |
| 1715 | runId, | |
| 1716 | providerThread, | |
| 1717 | providerTurnId: activeTurn.id, | |
| 1718 | message: { | |
| 1719 | messageId: MessageId.make("message-opencode-after-stop"), | |
| 1720 | text: "must not pass the pending stop", | |
| 1721 | attachments: [], | |
| 1722 | createdBy: "user", | |
| 1723 | creationSource: "web", | |
| 1724 | }, | |
| 1725 | }), | |
| 1726 | ); | |
| 1727 | assert.isTrue(Exit.isFailure(promptWhileStopping)); | |
| 1728 | assert.equal(promptCalls, 3); | |
| 1729 | yield* Effect.promise(() => | |
| 1730 | nativeEvents.push({ | |
| 1731 | type: "session.status", | |
| 1732 | properties: { sessionID: "native-opencode-race", status: { type: "idle" } }, | |
| 1733 | }), | |
| 1734 | ); | |
| 1735 | const interrupted = yield* runtime.readThreadSnapshot({ providerThread }); | |
| 1736 | assert.equal(interrupted.providerTurns.at(-1)?.status, "interrupted"); | |
| 1737 | }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), | |
| 1738 | ); | |
| 1739 | ||
| 1740 | it.effect("interrupts an initial prompt while its SDK request is pending", () => | |
| 1741 | Effect.gen(function* () { | |
| 1742 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | |
| 1743 | const nativeEvents = asyncEventStream(); | |
| 1744 | const promptStarted = promiseGate<void>(); | |
| 1745 | const abortCalled = promiseGate<void>(); | |
| 1746 | let abortCalls = 0; | |
| 1747 | const client = { | |
| 1748 | event: { | |
| 1749 | subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { | |
| 1750 | options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); | |
| 1751 | return { stream: nativeEvents.stream }; | |
| 1752 | }, | |
| 1753 | }, | |
| 1754 | session: { | |
| 1755 | create: async () => ({ | |
| 1756 | data: { id: "native-opencode-initial-stop", time: { created: 1, updated: 1 } }, | |
| 1757 | }), | |
| 1758 | get: async () => ({ | |
| 1759 | data: { id: "native-opencode-initial-stop", time: { created: 1, updated: 1 } }, | |
| 1760 | }), | |
| 1761 | promptAsync: async (_input: unknown, options?: { readonly signal?: AbortSignal }) => { | |
| 1762 | promptStarted.resolve(); | |
| 1763 | await new Promise<void>((_resolve, reject) => { | |
| 1764 | const signal = options?.signal; | |
| 1765 | if (signal?.aborted) { | |
| 1766 | reject(signal.reason); | |
| 1767 | return; | |
| 1768 | } | |
| 1769 | signal?.addEventListener("abort", () => reject(signal.reason), { once: true }); | |
| 1770 | }); | |
| 1771 | return { data: true }; | |
| 1772 | }, | |
| 1773 | status: async () => ({ | |
| 1774 | data: { "native-opencode-initial-stop": { type: "idle" as const } }, | |
| 1775 | }), | |
| 1776 | messages: async () => ({ data: [] }), | |
| 1777 | children: async () => ({ data: [] }), | |
| 1778 | abort: async () => { | |
| 1779 | abortCalls += 1; | |
| 1780 | abortCalled.resolve(); | |
| 1781 | return { data: true }; | |
| 1782 | }, | |
| 1783 | }, | |
| 1784 | mcp: { add: async () => ({ data: true }) }, | |
| 1785 | }; | |
| 1786 | const adapter = makeOpenCodeAdapterV2({ | |
| 1787 | instanceId: ProviderInstanceId.make("opencode-initial-stop-test"), | |
| 1788 | settings: OPEN_CODE_TEST_SETTINGS, | |
| 1789 | environment: {}, | |
| 1790 | runtime: { | |
| 1791 | connectToOpenCodeServer: () => | |
| 1792 | Effect.succeed({ url: "http://test.invalid", external: true }), | |
| 1793 | createOpenCodeSdkClient: () => client, | |
| 1794 | } as unknown as OpenCodeRuntime.OpenCodeRuntimeShape, | |
| 1795 | idAllocator, | |
| 1796 | host: { | |
| 1797 | paths: { cwd: "/workspace" }, | |
| 1798 | resolveAttachmentPath: () => null, | |
| 1799 | } as unknown as ProviderHost.ProviderHostShape, | |
| 1800 | }); | |
| 1801 | const threadId = ThreadId.make("thread-opencode-initial-stop"); | |
| 1802 | const providerSessionId = ProviderSessionId.make("session-opencode-initial-stop"); | |
| 1803 | const modelSelection = { | |
| 1804 | instanceId: ProviderInstanceId.make("opencode-initial-stop-test"), | |
| 1805 | model: "anthropic/claude-sonnet", | |
| 1806 | options: [], | |
| 1807 | }; | |
| 1808 | const policy = runtimePolicy("full-access", { cwd: "/workspace" }); | |
| 1809 | const runtime = yield* adapter.openSession({ | |
| 1810 | threadId, | |
| 1811 | providerSessionId, | |
| 1812 | modelSelection, | |
| 1813 | runtimePolicy: policy, | |
| 1814 | }); | |
| 1815 | const providerThread = yield* runtime.ensureThread({ | |
| 1816 | threadId, | |
| 1817 | modelSelection, | |
| 1818 | runtimePolicy: policy, | |
| 1819 | }); | |
| 1820 | const terminalEvents = yield* runtime.events.pipe( | |
| 1821 | Stream.takeUntil((event) => event.type === "turn.terminal"), | |
| 1822 | Stream.runCollect, | |
| 1823 | Effect.forkScoped, | |
| 1824 | ); | |
| 1825 | const now = yield* DateTime.now; | |
| 1826 | const start = yield* runtime | |
| 1827 | .startTurn({ | |
| 1828 | appThread: { | |
| 1829 | id: threadId, | |
| 1830 | projectId: ProjectId.make("project-opencode-initial-stop"), | |
| 1831 | title: "initial stop", | |
| 1832 | providerInstanceId: modelSelection.instanceId, | |
| 1833 | modelSelection, | |
| 1834 | runtimeMode: "full-access", | |
| 1835 | interactionMode: "default", | |
| 1836 | branch: null, | |
| 1837 | worktreePath: null, | |
| 1838 | activeProviderThreadId: providerThread.id, | |
| 1839 | lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, | |
| 1840 | forkedFrom: null, | |
| 1841 | createdBy: "user", | |
| 1842 | creationSource: "web", | |
| 1843 | createdAt: now, | |
| 1844 | updatedAt: now, | |
| 1845 | archivedAt: null, | |
| 1846 | settledOverride: null, | |
| 1847 | settledAt: null, | |
| 1848 | lastVisitedAt: null, | |
| 1849 | deletedAt: null, | |
| 1850 | }, | |
| 1851 | threadId, | |
| 1852 | runId: RunId.make("run-opencode-initial-stop"), | |
| 1853 | runOrdinal: 1, | |
| 1854 | providerTurnOrdinal: 1, | |
| 1855 | attemptId: RunAttemptId.make("attempt-opencode-initial-stop"), | |
| 1856 | rootNodeId: NodeId.make("node-opencode-initial-stop"), | |
| 1857 | providerThread, | |
| 1858 | message: { | |
| 1859 | createdBy: "user", | |
| 1860 | creationSource: "web", | |
| 1861 | messageId: MessageId.make("message-opencode-initial-stop"), | |
| 1862 | text: "hello", | |
| 1863 | attachments: [], | |
| 1864 | }, | |
| 1865 | modelSelection, | |
| 1866 | runtimePolicy: policy, | |
| 1867 | }) | |
| 1868 | .pipe(Effect.forkScoped); | |
| 1869 | ||
| 1870 | yield* Effect.promise(() => promptStarted.promise); | |
| 1871 | const running = yield* runtime.readThreadSnapshot({ providerThread }); | |
| 1872 | const activeTurn = running.providerTurns.at(-1)!; | |
| 1873 | const interrupt = yield* runtime | |
| 1874 | .interruptTurn({ providerThread, providerTurnId: activeTurn.id }) | |
| 1875 | .pipe(Effect.forkScoped); | |
| 1876 | ||
| 1877 | yield* Effect.promise(() => abortCalled.promise); | |
| 1878 | yield* Fiber.join(start); | |
| 1879 | yield* Fiber.join(interrupt); | |
| 1880 | yield* Effect.promise(() => | |
| 1881 | nativeEvents.push({ | |
| 1882 | type: "session.status", | |
| 1883 | properties: { | |
| 1884 | sessionID: "native-opencode-initial-stop", | |
| 1885 | status: { type: "idle" }, | |
| 1886 | }, | |
| 1887 | }), | |
| 1888 | ); | |
| 1889 | ||
| 1890 | const events = Array.from(yield* Fiber.join(terminalEvents)); | |
| 1891 | const terminals = events.filter((event) => event.type === "turn.terminal"); | |
| 1892 | assert.equal(abortCalls, 1); | |
| 1893 | assert.lengthOf(terminals, 1); | |
| 1894 | assert.equal(terminals[0]?.status, "interrupted"); | |
| 1895 | assert.isNull(terminals[0]?.failure ?? null); | |
| 1896 | assert.isFalse( | |
| 1897 | events.some( | |
| 1898 | (event) => | |
| 1899 | (event.type === "turn.terminal" && event.status === "failed") || | |
| 1900 | (event.type === "provider_session.updated" && event.providerSession.status === "error"), | |
| 1901 | ), | |
| 1902 | ); | |
| 1903 | }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), | |
| 1904 | ); | |
| 1905 | ||
| 1906 | it.effect("fails an active turn when the OpenCode event stream ends cleanly", () => | |
| 1907 | Effect.gen(function* () { | |
| 1908 | const nativeEvents = asyncEventStream(); | |
| 1909 | const harness = yield* makeOpenCodeRuntimeHarness( | |
| 1910 | "clean-event-eof", | |
| 1911 | "native-opencode-clean-event-eof", | |
| 1912 | { | |
| 1913 | event: { | |
| 1914 | subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { | |
| 1915 | options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); | |
| 1916 | return { stream: nativeEvents.stream }; | |
| 1917 | }, | |
| 1918 | }, | |
| 1919 | session: { | |
| 1920 | create: async () => ({ | |
| 1921 | data: { id: "native-opencode-clean-event-eof", time: { created: 1, updated: 1 } }, | |
| 1922 | }), | |
| 1923 | promptAsync: async () => ({ data: true }), | |
| 1924 | }, | |
| 1925 | }, | |
| 1926 | ); | |
| 1927 | yield* harness.startTurn(); | |
| 1928 | const terminalEvents = yield* harness.runtime.events.pipe( | |
| 1929 | Stream.runCollect, | |
| 1930 | Effect.forkScoped, | |
| 1931 | ); | |
| 1932 | ||
| 1933 | nativeEvents.close(); | |
| 1934 | const received = Array.from(yield* Fiber.join(terminalEvents)); | |
| 1935 | assert.isTrue( | |
| 1936 | received.some( | |
| 1937 | (event) => | |
| 1938 | event.type === "provider_session.updated" && event.providerSession.status === "error", | |
| 1939 | ), | |
| 1940 | ); | |
| 1941 | const terminal = received.find((event) => event.type === "turn.terminal"); | |
| 1942 | assert.equal(terminal?.status, "failed"); | |
| 1943 | assert.equal(terminal?.failure?.class, "transport_error"); | |
| 1944 | assert.equal(terminal?.threadDisposition, "broken"); | |
| 1945 | assert.equal((yield* Effect.exit(harness.startTurn()))._tag, "Failure"); | |
| 1946 | }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), | |
| 1947 | ); | |
| 1948 | ||
| 1949 | it.effect("fails compaction when its response races stream termination", () => | |
| 1950 | Effect.gen(function* () { | |
| 1951 | const nativeEvents = asyncEventStream(); | |
| 1952 | const summarizeStarted = promiseGate<void>(); | |
| 1953 | const summarizeResult = promiseGate<{ data: boolean }>(); | |
| 1954 | const baseClock = yield* Clock.Clock; | |
| 1955 | const eofClockRead = yield* Deferred.make<void>(); | |
| 1956 | const releaseEofClockRead = yield* Deferred.make<void>(); | |
| 1957 | let blockNextClockRead = false; | |
| 1958 | const blockingClock: Clock.Clock = { | |
| 1959 | ...baseClock, | |
| 1960 | currentTimeMillis: Effect.suspend(() => { | |
| 1961 | if (!blockNextClockRead) return baseClock.currentTimeMillis; | |
| 1962 | blockNextClockRead = false; | |
| 1963 | return Deferred.succeed(eofClockRead, undefined).pipe( | |
| 1964 | Effect.andThen(Deferred.await(releaseEofClockRead)), | |
| 1965 | Effect.andThen(baseClock.currentTimeMillis), | |
| 1966 | ); | |
| 1967 | }), | |
| 1968 | }; | |
| 1969 | const harness = yield* makeOpenCodeRuntimeHarness( | |
| 1970 | "compaction-eof-race", | |
| 1971 | "native-opencode-compaction-eof-race", | |
| 1972 | { | |
| 1973 | event: { subscribe: async () => ({ stream: nativeEvents.stream }) }, | |
| 1974 | session: { | |
| 1975 | create: async () => ({ | |
| 1976 | data: { id: "native-opencode-compaction-eof-race", time: { created: 1, updated: 1 } }, | |
| 1977 | }), | |
| 1978 | summarize: () => { | |
| 1979 | summarizeStarted.resolve(); | |
| 1980 | return summarizeResult.promise; | |
| 1981 | }, | |
| 1982 | }, | |
| 1983 | }, | |
| 1984 | ).pipe(Effect.provideService(Clock.Clock, blockingClock)); | |
| 1985 | const events = yield* harness.runtime.events.pipe(Stream.runCollect, Effect.forkScoped); | |
| 1986 | const start = yield* harness.startTurn("/compact").pipe(Effect.forkScoped); | |
| 1987 | yield* Effect.promise(() => summarizeStarted.promise); | |
| 1988 | blockNextClockRead = true; | |
| 1989 | nativeEvents.close(); | |
| 1990 | yield* Deferred.await(eofClockRead); | |
| 1991 | summarizeResult.resolve({ data: true }); | |
| 1992 | yield* Fiber.join(start); | |
| 1993 | yield* Deferred.succeed(releaseEofClockRead, undefined); | |
| 1994 | const received = Array.from(yield* Fiber.join(events)); | |
| 1995 | const terminals = received.filter((event) => event.type === "turn.terminal"); | |
| 1996 | assert.lengthOf(terminals, 1); | |
| 1997 | assert.equal(terminals[0]?.status, "failed"); | |
| 1998 | assert.equal(terminals[0]?.failure?.class, "transport_error"); | |
| 1999 | assert.isFalse( | |
| 2000 | received.some( | |
| 2001 | (event) => | |
| 2002 | event.type === "turn_item.updated" && | |
| 2003 | event.turnItem.type === "compaction" && | |
| 2004 | event.turnItem.status === "completed", | |
| 2005 | ), | |
| 2006 | ); | |
| 2007 | }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), | |
| 2008 | ); | |
| 2009 | ||
| 2010 | it.effect("does not register a turn after the OpenCode event stream ends", () => | |
| 2011 | Effect.gen(function* () { | |
| 2012 | const nativeEvents = asyncEventStream(); | |
| 2013 | let promptCalls = 0; | |
| 2014 | const harness = yield* makeOpenCodeRuntimeHarness( | |
| 2015 | "event-eof-start-race", | |
| 2016 | "native-opencode-event-eof-start-race", | |
| 2017 | { | |
| 2018 | event: { | |
| 2019 | subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { | |
| 2020 | options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); | |
| 2021 | return { stream: nativeEvents.stream }; | |
| 2022 | }, | |
| 2023 | }, | |
| 2024 | session: { | |
| 2025 | create: async () => ({ | |
| 2026 | data: { | |
| 2027 | id: "native-opencode-event-eof-start-race", | |
| 2028 | time: { created: 1, updated: 1 }, | |
| 2029 | }, | |
| 2030 | }), | |
| 2031 | promptAsync: async () => { | |
| 2032 | promptCalls += 1; | |
| 2033 | return { data: true }; | |
| 2034 | }, | |
| 2035 | }, | |
| 2036 | }, | |
| 2037 | ); | |
| 2038 | const baseClock = yield* Clock.Clock; | |
| 2039 | const startClockRead = yield* Deferred.make<void>(); | |
| 2040 | const releaseStartClockRead = yield* Deferred.make<void>(); | |
| 2041 | let blockNextClockRead = true; | |
| 2042 | const blockingClock: Clock.Clock = { | |
| 2043 | ...baseClock, | |
| 2044 | currentTimeMillis: Effect.suspend(() => { | |
| 2045 | if (!blockNextClockRead) return baseClock.currentTimeMillis; | |
| 2046 | blockNextClockRead = false; | |
| 2047 | return Deferred.succeed(startClockRead, undefined).pipe( | |
| 2048 | Effect.andThen(Deferred.await(releaseStartClockRead)), | |
| 2049 | Effect.andThen(baseClock.currentTimeMillis), | |
| 2050 | ); | |
| 2051 | }), | |
| 2052 | }; | |
| 2053 | const start = yield* harness | |
| 2054 | .startTurn() | |
| 2055 | .pipe(Effect.provideService(Clock.Clock, blockingClock), Effect.exit, Effect.forkScoped); | |
| 2056 | yield* Deferred.await(startClockRead); | |
| 2057 | ||
| 2058 | const events = yield* harness.runtime.events.pipe(Stream.runCollect, Effect.forkScoped); | |
| 2059 | nativeEvents.close(); | |
| 2060 | yield* Fiber.join(events); | |
| 2061 | yield* Deferred.succeed(releaseStartClockRead, undefined); | |
| 2062 | ||
| 2063 | assert.isTrue(Exit.isFailure(yield* Fiber.join(start))); | |
| 2064 | assert.equal(promptCalls, 0); | |
| 2065 | }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), | |
| 2066 | ); | |
| 2067 | ||
| 2068 | it("holds stale idle through prompt admission until the new user message is observed", () => { | |
| 2069 | const admission = { | |
| 2070 | admissionPending: true, | |
| 2071 | admissionAccepted: false, | |
| 2072 | admissionMessageObserved: false, | |
| 2073 | idleDuringAdmission: false, | |
| 2074 | }; | |
| 2075 | ||
| 2076 | assert.equal(advanceOpenCodePromptAdmission(admission, "idle"), "hold"); | |
| 2077 | assert.equal(advanceOpenCodePromptAdmission(admission, "accepted"), "hold"); | |
| 2078 | assert.isTrue(admission.admissionPending); | |
| 2079 | assert.equal(advanceOpenCodePromptAdmission(admission, "user-message"), "reconcile-idle"); | |
| 2080 | assert.isTrue(admission.admissionPending); | |
| 2081 | }); | |
| 2082 | ||
| 2083 | it("releases admission only after prompt acceptance and new-turn evidence", () => { | |
| 2084 | const admission = { | |
| 2085 | admissionPending: true, | |
| 2086 | admissionAccepted: false, | |
| 2087 | admissionMessageObserved: false, | |
| 2088 | idleDuringAdmission: false, | |
| 2089 | }; | |
| 2090 | ||
| 2091 | assert.equal(advanceOpenCodePromptAdmission(admission, "busy"), "hold"); | |
| 2092 | assert.equal(advanceOpenCodePromptAdmission(admission, "accepted"), "release"); | |
| 2093 | assert.isFalse(admission.admissionPending); | |
| 2094 | }); | |
| 2095 | ||
| 2096 | it("releases admission when an assistant completes under a provider-assigned message id", () => { | |
| 2097 | const admission = { | |
| 2098 | admissionPending: true, | |
| 2099 | admissionAccepted: false, | |
| 2100 | admissionMessageObserved: false, | |
| 2101 | idleDuringAdmission: true, | |
| 2102 | }; | |
| 2103 | ||
| 2104 | assert.equal(advanceOpenCodePromptAdmission(admission, "assistant-completed"), "release"); | |
| 2105 | assert.isTrue(admission.admissionAccepted); | |
| 2106 | assert.isTrue(admission.admissionMessageObserved); | |
| 2107 | assert.isFalse(admission.admissionPending); | |
| 2108 | }); | |
| 2109 | ||
| 2110 | it("invalidates pending admission before aborting a turn", () => { | |
| 2111 | const admission = { | |
| 2112 | admissionGeneration: 4, | |
| 2113 | admissionPending: true, | |
| 2114 | admissionAccepted: false, | |
| 2115 | admissionMessageObserved: false, | |
| 2116 | idleDuringAdmission: false, | |
| 2117 | }; | |
| 2118 | ||
| 2119 | cancelOpenCodePromptAdmission(admission, 5); | |
| 2120 | ||
| 2121 | assert.equal(admission.admissionGeneration, 5); | |
| 2122 | assert.isFalse(admission.admissionPending); | |
| 2123 | assert.equal(advanceOpenCodePromptAdmission(admission, "accepted"), "release"); | |
| 2124 | }); | |
| 2125 | ||
| 2126 | it.effect("reconciles current idle and busy status replies after prompt admission", () => | |
| 2127 | Effect.gen(function* () { | |
| 2128 | for (const expected of ["idle", "busy"] as const) { | |
| 2129 | const admission = { admissionGeneration: 4, admissionPending: true }; | |
| 2130 | const status = yield* Deferred.make<"idle" | "busy" | "unknown">(); | |
| 2131 | const fiber = yield* reconcileOpenCodePromptAdmissionStatus( | |
| 2132 | admission, | |
| 2133 | 4, | |
| 2134 | Deferred.await(status), | |
| 2135 | ).pipe(Effect.forkChild({ startImmediately: true })); | |
| 2136 | ||
| 2137 | yield* Deferred.succeed(status, expected); | |
| 2138 | ||
| 2139 | assert.equal(yield* Fiber.join(fiber), expected); | |
| 2140 | assert.isFalse(admission.admissionPending); | |
| 2141 | } | |
| 2142 | }), | |
| 2143 | ); | |
| 2144 | ||
| 2145 | it.effect("keeps admission pending after a transient status failure", () => | |
| 2146 | Effect.gen(function* () { | |
| 2147 | const admission = { admissionGeneration: 4, admissionPending: true }; | |
| 2148 | assert.equal( | |
| 2149 | yield* reconcileOpenCodePromptAdmissionStatus(admission, 4, Effect.succeed("unknown")), | |
| 2150 | "unknown", | |
| 2151 | ); | |
| 2152 | assert.isTrue(admission.admissionPending); | |
| 2153 | assert.equal( | |
| 2154 | yield* reconcileOpenCodePromptAdmissionStatus(admission, 4, Effect.succeed("idle")), | |
| 2155 | "idle", | |
| 2156 | ); | |
| 2157 | assert.isFalse(admission.admissionPending); | |
| 2158 | }), | |
| 2159 | ); | |
| 2160 | ||
| 2161 | it.effect("retries a transient status failure without another idle event", () => | |
| 2162 | Effect.gen(function* () { | |
| 2163 | const nativeSessionId = "native-opencode-status-retry"; | |
| 2164 | const nativeEvents = asyncEventStream(); | |
| 2165 | const promptRelease = promiseGate<void>(); | |
| 2166 | const promptCalls = yield* Queue.unbounded<string>(); | |
| 2167 | const statusCalls = yield* Queue.unbounded<number>(); | |
| 2168 | let statusCallCount = 0; | |
| 2169 | const client = { | |
| 2170 | event: { | |
| 2171 | subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { | |
| 2172 | options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); | |
| 2173 | return { stream: nativeEvents.stream }; | |
| 2174 | }, | |
| 2175 | }, | |
| 2176 | session: { | |
| 2177 | create: async () => ({ | |
| 2178 | data: { id: nativeSessionId, time: { created: 1, updated: 1 } }, | |
| 2179 | }), | |
| 2180 | get: async () => ({ | |
| 2181 | data: { id: nativeSessionId, time: { created: 1, updated: 1 } }, | |
| 2182 | }), | |
| 2183 | promptAsync: async (input: { readonly messageID?: string }) => { | |
| 2184 | Queue.offerUnsafe(promptCalls, input.messageID!); | |
| 2185 | await promptRelease.promise; | |
| 2186 | return { data: true }; | |
| 2187 | }, | |
| 2188 | status: async () => { | |
| 2189 | statusCallCount += 1; | |
| 2190 | Queue.offerUnsafe(statusCalls, statusCallCount); | |
| 2191 | if (statusCallCount === 1) throw new Error("transient status failure"); | |
| 2192 | return { data: { [nativeSessionId]: { type: "idle" as const } } }; | |
| 2193 | }, | |
| 2194 | messages: async () => ({ data: [] }), | |
| 2195 | children: async () => ({ data: [] }), | |
| 2196 | abort: async () => ({ data: true }), | |
| 2197 | }, | |
| 2198 | mcp: { add: async () => ({ data: true }) }, | |
| 2199 | }; | |
| 2200 | const harness = yield* makeOpenCodeRuntimeHarness("status-retry", nativeSessionId, client); | |
| 2201 | const terminalEvents = yield* harness.runtime.events.pipe( | |
| 2202 | Stream.takeUntil((event) => event.type === "turn.terminal"), | |
| 2203 | Stream.runCollect, | |
| 2204 | Effect.forkScoped, | |
| 2205 | ); | |
| 2206 | const start = yield* harness.startTurn().pipe(Effect.forkScoped); | |
| 2207 | const admissionMessageId = yield* Queue.take(promptCalls); | |
| 2208 | ||
| 2209 | yield* Effect.promise(() => | |
| 2210 | nativeEvents.push({ | |
| 2211 | type: "session.status", | |
| 2212 | properties: { sessionID: nativeSessionId, status: { type: "idle" } }, | |
| 2213 | }), | |
| 2214 | ); | |
| 2215 | promptRelease.resolve(); | |
| 2216 | yield* Fiber.join(start); | |
| 2217 | const userMessage = { | |
| 2218 | type: "message.updated", | |
| 2219 | properties: { | |
| 2220 | sessionID: nativeSessionId, | |
| 2221 | info: { | |
| 2222 | id: admissionMessageId, | |
| 2223 | sessionID: nativeSessionId, | |
| 2224 | role: "user", | |
| 2225 | time: { created: DateTime.toEpochMillis(harness.now) }, | |
| 2226 | }, | |
| 2227 | }, | |
| 2228 | }; | |
| 2229 | yield* Effect.promise(() => nativeEvents.push(userMessage)); | |
| 2230 | assert.equal(yield* Queue.take(statusCalls), 1); | |
| 2231 | ||
| 2232 | yield* Effect.promise(() => nativeEvents.push(userMessage)); | |
| 2233 | assert.equal(statusCallCount, 1, "duplicate events must share the generation's retry worker"); | |
| 2234 | yield* TestClock.adjust("250 millis"); | |
| 2235 | assert.equal(yield* Queue.take(statusCalls), 2); | |
| 2236 | ||
| 2237 | const events = Array.from(yield* Fiber.join(terminalEvents)); | |
| 2238 | const terminals = events.filter((event) => event.type === "turn.terminal"); | |
| 2239 | assert.equal(statusCallCount, 2); | |
| 2240 | assert.lengthOf(terminals, 1); | |
| 2241 | assert.equal(terminals[0]?.status, "completed"); | |
| 2242 | assert.isNull(terminals[0]?.failure ?? null); | |
| 2243 | }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), | |
| 2244 | ); | |
| 2245 | ||
| 2246 | it.effect("does not let a queued status retry adopt a newer steer generation", () => | |
| 2247 | Effect.gen(function* () { | |
| 2248 | const nativeSessionId = "native-opencode-stale-status-retry"; | |
| 2249 | const nativeEvents = asyncEventStream(); | |
| 2250 | const firstPromptRelease = promiseGate<void>(); | |
| 2251 | const promptCalls = yield* Queue.unbounded<string>(); | |
| 2252 | const statusCalls = yield* Queue.unbounded<number>(); | |
| 2253 | let promptCallCount = 0; | |
| 2254 | let statusCallCount = 0; | |
| 2255 | const client = { | |
| 2256 | event: { | |
| 2257 | subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { | |
| 2258 | options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); | |
| 2259 | return { stream: nativeEvents.stream }; | |
| 2260 | }, | |
| 2261 | }, | |
| 2262 | session: { | |
| 2263 | create: async () => ({ | |
| 2264 | data: { id: nativeSessionId, time: { created: 1, updated: 1 } }, | |
| 2265 | }), | |
| 2266 | get: async () => ({ | |
| 2267 | data: { id: nativeSessionId, time: { created: 1, updated: 1 } }, | |
| 2268 | }), | |
| 2269 | promptAsync: async (input: { readonly messageID?: string }) => { | |
| 2270 | promptCallCount += 1; | |
| 2271 | Queue.offerUnsafe(promptCalls, input.messageID!); | |
| 2272 | if (promptCallCount === 1) { | |
| 2273 | await firstPromptRelease.promise; | |
| 2274 | } | |
| 2275 | return { data: true }; | |
| 2276 | }, | |
| 2277 | status: async () => { | |
| 2278 | statusCallCount += 1; | |
| 2279 | Queue.offerUnsafe(statusCalls, statusCallCount); | |
| 2280 | if (statusCallCount === 1) throw new Error("transient status failure"); | |
| 2281 | return { data: { [nativeSessionId]: { type: "idle" as const } } }; | |
| 2282 | }, | |
| 2283 | messages: async () => ({ data: [] }), | |
| 2284 | children: async () => ({ data: [] }), | |
| 2285 | abort: async () => ({ data: true }), | |
| 2286 | }, | |
| 2287 | mcp: { add: async () => ({ data: true }) }, | |
| 2288 | }; | |
| 2289 | const harness = yield* makeOpenCodeRuntimeHarness( | |
| 2290 | "stale-status-retry", | |
| 2291 | nativeSessionId, | |
| 2292 | client, | |
| 2293 | ); | |
| 2294 | const start = yield* harness.startTurn().pipe(Effect.forkScoped); | |
| 2295 | const firstAdmissionMessageId = yield* Queue.take(promptCalls); | |
| 2296 | ||
| 2297 | yield* Effect.promise(() => | |
| 2298 | nativeEvents.push({ | |
| 2299 | type: "session.status", | |
| 2300 | properties: { sessionID: nativeSessionId, status: { type: "idle" } }, | |
| 2301 | }), | |
| 2302 | ); | |
| 2303 | firstPromptRelease.resolve(); | |
| 2304 | yield* Fiber.join(start); | |
| 2305 | yield* Effect.promise(() => | |
| 2306 | nativeEvents.push({ | |
| 2307 | type: "message.updated", | |
| 2308 | properties: { | |
| 2309 | sessionID: nativeSessionId, | |
| 2310 | info: { | |
| 2311 | id: firstAdmissionMessageId, | |
| 2312 | sessionID: nativeSessionId, | |
| 2313 | role: "user", | |
| 2314 | time: { created: DateTime.toEpochMillis(harness.now) }, | |
| 2315 | }, | |
| 2316 | }, | |
| 2317 | }), | |
| 2318 | ); | |
| 2319 | assert.equal(yield* Queue.take(statusCalls), 1); | |
| 2320 | ||
| 2321 | const running = yield* harness.runtime.readThreadSnapshot({ | |
| 2322 | providerThread: harness.providerThread, | |
| 2323 | }); | |
| 2324 | const activeTurn = running.providerTurns.at(-1)!; | |
| 2325 | yield* harness.runtime.steerTurn({ | |
| 2326 | threadId: harness.threadId, | |
| 2327 | runId: harness.runId, | |
| 2328 | providerThread: harness.providerThread, | |
| 2329 | providerTurnId: activeTurn.id, | |
| 2330 | message: { | |
| 2331 | messageId: MessageId.make("message-opencode-stale-status-retry-steer"), | |
| 2332 | text: "new generation", | |
| 2333 | attachments: [], | |
| 2334 | createdBy: "user", | |
| 2335 | creationSource: "web", | |
| 2336 | }, | |
| 2337 | }); | |
| 2338 | yield* Queue.take(promptCalls); | |
| 2339 | yield* TestClock.adjust("250 millis"); | |
| 2340 | ||
| 2341 | const afterRetry = yield* harness.runtime.readThreadSnapshot({ | |
| 2342 | providerThread: harness.providerThread, | |
| 2343 | }); | |
| 2344 | assert.equal(statusCallCount, 1); | |
| 2345 | assert.equal(afterRetry.providerTurns.at(-1)?.status, "running"); | |
| 2346 | }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), | |
| 2347 | ); | |
| 2348 | ||
| 2349 | it.effect("ignores a delayed status reply after steering starts a newer admission", () => | |
| 2350 | Effect.gen(function* () { | |
| 2351 | const admission = { admissionGeneration: 4, admissionPending: true }; | |
| 2352 | const status = yield* Deferred.make<"idle" | "busy" | "unknown">(); | |
| 2353 | const fiber = yield* reconcileOpenCodePromptAdmissionStatus( | |
| 2354 | admission, | |
| 2355 | 4, | |
| 2356 | Deferred.await(status), | |
| 2357 | ).pipe(Effect.forkChild({ startImmediately: true })); | |
| 2358 | ||
| 2359 | admission.admissionGeneration = 5; | |
| 2360 | admission.admissionPending = true; | |
| 2361 | yield* Deferred.succeed(status, "idle"); | |
| 2362 | ||
| 2363 | assert.equal(yield* Fiber.join(fiber), "stale"); | |
| 2364 | assert.equal(admission.admissionGeneration, 5); | |
| 2365 | assert.isTrue(admission.admissionPending); | |
| 2366 | }), | |
| 2367 | ); | |
| 2368 | ||
| 2369 | it.effect("does not revive admission when abort wins a pending status lookup", () => | |
| 2370 | Effect.gen(function* () { | |
| 2371 | const admission = { admissionGeneration: 4, admissionPending: true }; | |
| 2372 | const status = yield* Deferred.make<"idle" | "busy" | "unknown">(); | |
| 2373 | const fiber = yield* reconcileOpenCodePromptAdmissionStatus( | |
| 2374 | admission, | |
| 2375 | 4, | |
| 2376 | Deferred.await(status), | |
| 2377 | ).pipe(Effect.forkChild({ startImmediately: true })); | |
| 2378 | ||
| 2379 | cancelOpenCodePromptAdmission(admission, 5); | |
| 2380 | yield* Deferred.succeed(status, "idle"); | |
| 2381 | ||
| 2382 | assert.equal(yield* Fiber.join(fiber), "stale"); | |
| 2383 | assert.isFalse(admission.admissionPending); | |
| 2384 | }), | |
| 2385 | ); | |
| 2386 | ||
| 2387 | it.effect("logs bounded structural protocol diagnostics without native payload values", () => | |
| 2388 | Effect.gen(function* () { | |
| 2389 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | |
| 2390 | const records: Array<unknown> = []; | |
| 2391 | const nativeEventLogger: ProviderEventLoggers.EventNdjsonLogger = { | |
| 2392 | filePath: "/tmp/provider-native.ndjson", | |
| 2393 | write: (event) => Effect.sync(() => void records.push(event)), | |
| 2394 | close: () => Effect.void, | |
| 2395 | }; | |
| 2396 | const logProtocolEvent = makeOpenCodeProtocolLogger({ | |
| 2397 | nativeEventLogger, | |
| 2398 | idAllocator, | |
| 2399 | providerInstanceId: ProviderInstanceId.make("opencode-test"), | |
| 2400 | providerSessionId: ProviderSessionId.make("provider-session-opencode-test"), | |
| 2401 | threadId: ThreadId.make("thread-opencode-test"), | |
| 2402 | }); | |
| 2403 | const secret = "secret-opencode-prompt"; | |
| 2404 | ||
| 2405 | yield* logProtocolEvent({ | |
| 2406 | direction: "outgoing", | |
| 2407 | messageKind: "request", | |
| 2408 | method: "session.prompt", | |
| 2409 | payload: { prompt: secret, nested: { token: secret } }, | |
| 2410 | }); | |
| 2411 | ||
| 2412 | const serialized = encodeUnknownJson(records); | |
| 2413 | assert.notInclude(serialized, secret); | |
| 2414 | assert.include(serialized, '"protocol":"opencode-sdk.sse"'); | |
| 2415 | assert.include(serialized, '"method":"session.prompt"'); | |
| 2416 | assert.include(serialized, '"fieldCount":2'); | |
| 2417 | }).pipe(Effect.provide(IdAllocator.layer)), | |
| 2418 | ); | |
| 2419 | ||
| 2420 | it.effect("adopts the handed-over provider thread identity on session create", () => | |
| 2421 | Effect.gen(function* () { | |
| 2422 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | |
| 2423 | const host = yield* ProviderHost.ProviderHost; | |
| 2424 | let createCount = 0; | |
| 2425 | const createInputs: Array<unknown> = []; | |
| 2426 | const fakeClient = { | |
| 2427 | event: { | |
| 2428 | subscribe: async (_input?: unknown, options?: { readonly signal?: AbortSignal }) => ({ | |
| 2429 | // Emits nothing and ends when the pump's abort signal fires. | |
| 2430 | stream: { | |
| 2431 | [Symbol.asyncIterator]: () => ({ | |
| 2432 | next: () => | |
| 2433 | new Promise<IteratorResult<never>>((resolve) => { | |
| 2434 | const done = () => resolve({ done: true, value: undefined }); | |
| 2435 | if (options?.signal?.aborted) return done(); | |
| 2436 | options?.signal?.addEventListener("abort", done, { once: true }); | |
| 2437 | }), | |
| 2438 | }), | |
| 2439 | }, | |
| 2440 | }), | |
| 2441 | }, | |
| 2442 | session: { | |
| 2443 | create: async (input: unknown) => { | |
| 2444 | createCount += 1; | |
| 2445 | createInputs.push(input); | |
| 2446 | return { data: { id: `ses_native_${createCount}`, time: { created: 1, updated: 1 } } }; | |
| 2447 | }, | |
| 2448 | }, | |
| 2449 | } as unknown as OpencodeClient; | |
| 2450 | const unused = (operation: string) => () => Effect.die(`${operation} is not used`); | |
| 2451 | const runtime: OpenCodeRuntime.OpenCodeRuntimeShape = { | |
| 2452 | startOpenCodeServerProcess: unused("startOpenCodeServerProcess"), | |
| 2453 | connectToOpenCodeServer: () => | |
| 2454 | Effect.succeed({ | |
| 2455 | url: "test://opencode", | |
| 2456 | version: "test", | |
| 2457 | exitCode: null, | |
| 2458 | external: true, | |
| 2459 | }), | |
| 2460 | runOpenCodeCommand: unused("runOpenCodeCommand"), | |
| 2461 | createOpenCodeSdkClient: () => fakeClient, | |
| 2462 | loadOpenCodeInventory: unused("loadOpenCodeInventory"), | |
| 2463 | loadInventoryFromCli: unused("loadInventoryFromCli"), | |
| 2464 | loadOpenCodeSkills: unused("loadOpenCodeSkills"), | |
| 2465 | loadSkillsFromCli: unused("loadSkillsFromCli"), | |
| 2466 | }; | |
| 2467 | const instanceId = ProviderInstanceId.make("opencode"); | |
| 2468 | const threadId = ThreadId.make("thread-opencode-adopt"); | |
| 2469 | const modelSelection = { instanceId, model: "default" }; | |
| 2470 | const adapter = makeOpenCodeAdapterV2({ | |
| 2471 | instanceId, | |
| 2472 | settings: OPENCODE_TEST_SETTINGS, | |
| 2473 | environment: {}, | |
| 2474 | runtime, | |
| 2475 | idAllocator, | |
| 2476 | host, | |
| 2477 | }); | |
| 2478 | const session = yield* adapter.openSession({ | |
| 2479 | threadId, | |
| 2480 | providerSessionId: ProviderSessionId.make("provider-session-opencode-adopt"), | |
| 2481 | modelSelection, | |
| 2482 | runtimePolicy: runtimePolicy("full-access"), | |
| 2483 | }); | |
| 2484 | const now = yield* DateTime.now; | |
| 2485 | // The placeholder row the orchestrator creates for a first run: no | |
| 2486 | // native identity yet. The adapter must bind the created session to | |
| 2487 | // this row instead of minting a second session-keyed row. | |
| 2488 | const placeholder: OrchestrationV2ProviderThread = { | |
| 2489 | id: ProviderThreadId.make("thread:provider:opencode:native-thread:pending:run:adopt:1"), | |
| 2490 | driver: OPENCODE_PROVIDER, | |
| 2491 | providerInstanceId: instanceId, | |
| 2492 | providerSessionId: null, | |
| 2493 | appThreadId: threadId, | |
| 2494 | ownerNodeId: null, | |
| 2495 | nativeThreadRef: null, | |
| 2496 | nativeConversationHeadRef: null, | |
| 2497 | status: "not_loaded", | |
| 2498 | firstRunOrdinal: 1, | |
| 2499 | lastRunOrdinal: 1, | |
| 2500 | handoffIds: [], | |
| 2501 | forkedFrom: null, | |
| 2502 | createdAt: now, | |
| 2503 | updatedAt: now, | |
| 2504 | }; | |
| 2505 | const adopted = yield* session.ensureThread({ | |
| 2506 | threadId, | |
| 2507 | modelSelection, | |
| 2508 | runtimePolicy: runtimePolicy("full-access"), | |
| 2509 | existingProviderThread: placeholder, | |
| 2510 | }); | |
| 2511 | assert.equal(adopted.id, placeholder.id); | |
| 2512 | assert.equal(adopted.nativeThreadRef?.nativeId, "ses_native_1"); | |
| 2513 | // Without a handed-over row the adapter still derives its own id. | |
| 2514 | const minted = yield* session.ensureThread({ | |
| 2515 | threadId, | |
| 2516 | modelSelection, | |
| 2517 | runtimePolicy: runtimePolicy("full-access"), | |
| 2518 | }); | |
| 2519 | assert.notEqual(minted.id, placeholder.id); | |
| 2520 | assert.equal(minted.nativeThreadRef?.nativeId, "ses_native_2"); | |
| 2521 | // OpenCode names a session from its first prompt only when create | |
| 2522 | // leaves the title unset, so the adapter never sends one. | |
| 2523 | for (const input of createInputs) { | |
| 2524 | assert.notProperty(input, "title"); | |
| 2525 | } | |
| 2526 | }).pipe( | |
| 2527 | Effect.scoped, | |
| 2528 | Effect.provide( | |
| 2529 | Layer.mergeAll( | |
| 2530 | IdAllocator.layer, | |
| 2531 | layerTestProviderHost().pipe(Layer.provide(NodeServices.layer)), | |
| 2532 | ), | |
| 2533 | ), | |
| 2534 | ), | |
| 2535 | ); | |
| 2536 | ||
| 2537 | it("maps native permission families to orchestration request kinds", () => { | |
| 2538 | assert.equal(openCodePermissionRequestKind("bash"), "command"); | |
| 2539 | assert.equal(openCodePermissionRequestKind("read"), "file-read"); | |
| 2540 | assert.equal(openCodePermissionRequestKind("grep"), "file-read"); | |
| 2541 | assert.equal(openCodePermissionRequestKind("external_directory"), "file-read"); | |
| 2542 | assert.equal(openCodePermissionRequestKind("external_directory", "edit"), "file-change"); | |
| 2543 | assert.equal(openCodePermissionRequestKind("edit"), "file-change"); | |
| 2544 | assert.equal(openCodePermissionRequestKind("apply_patch"), "file-change"); | |
| 2545 | assert.equal(openCodePermissionRequestKind("todowrite"), "command"); | |
| 2546 | assert.equal(openCodePermissionRequestKind("custom", "todowrite"), "command"); | |
| 2547 | }); | |
| 2548 | ||
| 2549 | it("maps OpenCode tools to semantic turn-item families", () => { | |
| 2550 | assert.equal(openCodeToolProjectionKind("bash"), "command_execution"); | |
| 2551 | assert.equal(openCodeToolProjectionKind("edit"), "file_change"); | |
| 2552 | assert.equal(openCodeToolProjectionKind("read"), "dynamic_tool"); | |
| 2553 | assert.equal(openCodeToolProjectionKind("lsp"), "file_search"); | |
| 2554 | assert.equal(openCodeToolProjectionKind("websearch"), "web_search"); | |
| 2555 | assert.equal(openCodeToolProjectionKind("codesearch"), "web_search"); | |
| 2556 | assert.equal(openCodeToolProjectionKind("todowrite"), "dynamic_tool"); | |
| 2557 | assert.equal(openCodeToolProjectionKind("custom_tool"), "dynamic_tool"); | |
| 2558 | }); | |
| 2559 | ||
| 2560 | it("maps runtime modes to safe OpenCode permission rules", () => { | |
| 2561 | const approvalRequired = openCodePermissionRules(runtimePolicy("approval-required")); | |
| 2562 | assert.equal(permissionAction(approvalRequired, "read"), "allow"); | |
| 2563 | assert.equal(permissionAction(approvalRequired, "edit"), "ask"); | |
| 2564 | assert.equal(permissionAction(approvalRequired, "bash"), "ask"); | |
| 2565 | assert.equal(permissionAction(approvalRequired, "doom_loop"), "ask"); | |
| 2566 | assert.equal(permissionAction(approvalRequired, "unknown_plugin_tool"), "ask"); | |
| 2567 | assert.equal(permissionAction(approvalRequired, "question"), "allow"); | |
| 2568 | ||
| 2569 | const autoAcceptEdits = openCodePermissionRules(runtimePolicy("auto-accept-edits")); | |
| 2570 | assert.equal(permissionAction(autoAcceptEdits, "edit"), "allow"); | |
| 2571 | assert.equal(permissionAction(autoAcceptEdits, "bash"), "ask"); | |
| 2572 | ||
| 2573 | const fullAccess = openCodePermissionRules(runtimePolicy("full-access")); | |
| 2574 | assert.equal(permissionAction(fullAccess, "bash"), "allow"); | |
| 2575 | assert.equal(permissionAction(fullAccess, "edit"), "allow"); | |
| 2576 | ||
| 2577 | const granularApproval = openCodePermissionRules( | |
| 2578 | runtimePolicy("full-access", { | |
| 2579 | approvalPolicy: { granular: { request_permissions: true } }, | |
| 2580 | }), | |
| 2581 | ); | |
| 2582 | assert.equal(permissionAction(granularApproval, "bash"), "ask"); | |
| 2583 | assert.equal(permissionAction(granularApproval, "read"), "allow"); | |
| 2584 | ||
| 2585 | const approvalRequiredWorkspaceWrite = openCodePermissionRules( | |
| 2586 | runtimePolicy("approval-required", { | |
| 2587 | sandboxPolicy: { | |
| 2588 | type: "workspaceWrite", | |
| 2589 | writableRoots: ["/tmp/opencode-workspace"], | |
| 2590 | networkAccess: false, | |
| 2591 | }, | |
| 2592 | }), | |
| 2593 | ); | |
| 2594 | assert.equal(permissionAction(approvalRequiredWorkspaceWrite, "edit"), "ask"); | |
| 2595 | }); | |
| 2596 | ||
| 2597 | it("enforces non-interactive sandbox policy through OpenCode permissions", () => { | |
| 2598 | const readOnly = openCodePermissionRules( | |
| 2599 | runtimePolicy("full-access", { | |
| 2600 | approvalPolicy: "never", | |
| 2601 | sandboxPolicy: { | |
| 2602 | type: "readOnly", | |
| 2603 | access: { type: "fullAccess" }, | |
| 2604 | networkAccess: false, | |
| 2605 | }, | |
| 2606 | }), | |
| 2607 | ); | |
| 2608 | assert.equal(permissionAction(readOnly, "read"), "allow"); | |
| 2609 | assert.equal(permissionAction(readOnly, "edit"), "deny"); | |
| 2610 | assert.equal(permissionAction(readOnly, "bash"), "deny"); | |
| 2611 | assert.equal(permissionAction(readOnly, "webfetch"), "deny"); | |
| 2612 | assert.equal(permissionAction(readOnly, "doom_loop"), "deny"); | |
| 2613 | assert.equal(permissionAction(readOnly, "unknown_plugin_tool"), "deny"); | |
| 2614 | assert.equal(permissionAction(readOnly, "external_directory"), "allow"); | |
| 2615 | ||
| 2616 | const workspaceWrite = openCodePermissionRules( | |
| 2617 | runtimePolicy("auto-accept-edits", { | |
| 2618 | approvalPolicy: "never", | |
| 2619 | sandboxPolicy: { | |
| 2620 | type: "workspaceWrite", | |
| 2621 | writableRoots: ["/tmp/opencode-workspace"], | |
| 2622 | networkAccess: true, | |
| 2623 | }, | |
| 2624 | }), | |
| 2625 | ); | |
| 2626 | assert.equal(permissionAction(workspaceWrite, "edit"), "allow"); | |
| 2627 | assert.equal(permissionAction(workspaceWrite, "bash"), "deny"); | |
| 2628 | assert.equal(permissionAction(workspaceWrite, "webfetch"), "allow"); | |
| 2629 | assert.deepInclude(workspaceWrite, { | |
| 2630 | permission: "external_directory", | |
| 2631 | pattern: "/tmp/opencode-workspace/*", | |
| 2632 | action: "allow", | |
| 2633 | }); | |
| 2634 | }); | |
| 2635 | ||
| 2636 | it("preserves OpenCode's recursion guard on task-created child sessions", () => { | |
| 2637 | const childRules = openCodeChildPermissionRules(runtimePolicy("full-access"), [ | |
| 2638 | { permission: "task", pattern: "*", action: "deny" }, | |
| 2639 | ]); | |
| 2640 | ||
| 2641 | assert.equal(permissionAction(childRules, "read"), "allow"); | |
| 2642 | assert.equal(permissionAction(childRules, "bash"), "allow"); | |
| 2643 | assert.equal(permissionAction(childRules, "task"), "deny"); | |
| 2644 | ||
| 2645 | const approvalRequiredPolicy = runtimePolicy("approval-required"); | |
| 2646 | const parentRules = openCodePermissionRules(approvalRequiredPolicy); | |
| 2647 | const childApprovalRules = openCodeChildPermissionRules(approvalRequiredPolicy, [ | |
| 2648 | ...parentRules.filter((rule) => rule.action === "deny"), | |
| 2649 | { permission: "task", pattern: "*", action: "deny" }, | |
| 2650 | ]); | |
| 2651 | assert.equal(permissionAction(childApprovalRules, "bash"), "ask"); | |
| 2652 | assert.equal(permissionAction(childApprovalRules, "task"), "deny"); | |
| 2653 | }); | |
| 2654 | ||
| 2655 | it("uses the next native user message as the exclusive fork and revert boundary", () => { | |
| 2656 | const first = providerTurn({ id: "turn:first", ordinal: 1, nativeId: "msg-user-1" }); | |
| 2657 | const synthetic = providerTurn({ id: "turn:synthetic", ordinal: 2, nativeId: null }); | |
| 2658 | const third = providerTurn({ id: "turn:third", ordinal: 3, nativeId: "msg-user-3" }); | |
| 2659 | ||
| 2660 | assert.equal( | |
| 2661 | openCodeBoundaryAfterProviderTurn([third, first, synthetic], first.id), | |
| 2662 | "msg-user-3", | |
| 2663 | ); | |
| 2664 | assert.isUndefined(openCodeBoundaryAfterProviderTurn([first, synthetic, third], third.id)); | |
| 2665 | }); | |
| 2666 | }); | |
| 2667 | ||
| 2668 | it.effect.each([false, true])( | |
| 2669 | "OpenCode rewind forks history and validates the retained boundary, invalid=%s", | |
| 2670 | (invalid) => | |
| 2671 | Effect.gen(function* () { | |
| 2672 | const events = asyncEventStream(); | |
| 2673 | const nativeSessionId = "rewind-source"; | |
| 2674 | const forkId = "rewind-fork"; | |
| 2675 | const calls: string[] = []; | |
| 2676 | const removed = { | |
| 2677 | info: { id: "first-user", sessionID: nativeSessionId, role: "user", time: { created: 1 } }, | |
| 2678 | parts: [], | |
| 2679 | }; | |
| 2680 | const harness = yield* makeOpenCodeRuntimeHarness("rewind-fork", nativeSessionId, { | |
| 2681 | event: { | |
| 2682 | subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { | |
| 2683 | options.signal?.addEventListener("abort", () => events.close(), { once: true }); | |
| 2684 | return { stream: events.stream }; | |
| 2685 | }, | |
| 2686 | }, | |
| 2687 | session: { | |
| 2688 | create: async () => ({ data: { id: nativeSessionId, time: { created: 1, updated: 1 } } }), | |
| 2689 | get: async ({ sessionID }: { sessionID: string }) => ({ | |
| 2690 | data: { id: sessionID, time: { created: 1, updated: 2 } }, | |
| 2691 | }), | |
| 2692 | messages: async ({ sessionID }: { sessionID: string }) => ({ | |
| 2693 | data: sessionID === nativeSessionId || invalid ? [removed] : [], | |
| 2694 | }), | |
| 2695 | fork: async ({ sessionID, messageID }: { sessionID: string; messageID: string }) => { | |
| 2696 | assert.equal(sessionID, nativeSessionId); | |
| 2697 | assert.equal(messageID, "first-user"); | |
| 2698 | calls.push("fork"); | |
| 2699 | return { data: { id: forkId, time: { created: 1, updated: 2 } } }; | |
| 2700 | }, | |
| 2701 | update: async () => { | |
| 2702 | calls.push("permissions"); | |
| 2703 | return { data: {} }; | |
| 2704 | }, | |
| 2705 | revert: async () => { | |
| 2706 | throw new Error("Native revert would change workspace files"); | |
| 2707 | }, | |
| 2708 | }, | |
| 2709 | }); | |
| 2710 | const effect = harness.runtime.rollbackThread({ | |
| 2711 | providerThread: harness.providerThread, | |
| 2712 | target: { | |
| 2713 | type: "thread_start", | |
| 2714 | checkpointId: CheckpointId.make("rewind-checkpoint"), | |
| 2715 | appRunOrdinal: 0, | |
| 2716 | }, | |
| 2717 | providerThreadTurns: [], | |
| 2718 | }); | |
| 2719 | if (invalid) { | |
| 2720 | const error = yield* effect.pipe(Effect.flip); | |
| 2721 | assert.equal(error._tag, "ProviderAdapterRollbackThreadError"); | |
| 2722 | assert.deepEqual(calls, ["fork"]); | |
| 2723 | } else { | |
| 2724 | const result = yield* effect; | |
| 2725 | assert.equal(result.providerThread.nativeThreadRef?.nativeId, forkId); | |
| 2726 | assert.equal(result.messages.length, 0); | |
| 2727 | assert.deepEqual(calls, ["fork", "permissions"]); | |
| 2728 | } | |
| 2729 | }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), | |
| 2730 | ); |
packages/provider-opencode/src/server/adapter.ts created+3797| ... | ... | @@ -0,0 +1,3797 @@ |
| 1 | import type { | |
| 2 | Event as OpenCodeEvent, | |
| 3 | Message as OpenCodeMessage, | |
| 4 | Part as OpenCodePart, | |
| 5 | PermissionRequest, | |
| 6 | PermissionRuleset, | |
| 7 | QuestionRequest, | |
| 8 | Session as OpenCodeSession, | |
| 9 | Todo as OpenCodeTodo, | |
| 10 | ToolPart, | |
| 11 | } from "@opencode-ai/sdk/v2"; | |
| 12 | import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; | |
| 13 | import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; | |
| 14 | import { causeErrorTag } from "@t3tools/shared/observability"; | |
| 15 | import { | |
| 16 | defaultInstanceIdForDriver, | |
| 17 | type ModelSelection, | |
| 18 | type OrchestrationV2AppThread, | |
| 19 | type OrchestrationV2ConversationMessage, | |
| 20 | type OrchestrationV2ExecutionNode, | |
| 21 | type OrchestrationV2PlanStep, | |
| 22 | type OrchestrationV2ProviderCapabilities, | |
| 23 | type OrchestrationV2ProviderFailure, | |
| 24 | type OrchestrationV2ProviderRef, | |
| 25 | type OrchestrationV2ProviderSession, | |
| 26 | type OrchestrationV2ProviderThread, | |
| 27 | type OrchestrationV2ProviderTurn, | |
| 28 | type OrchestrationV2RuntimeRequest, | |
| 29 | type OrchestrationV2Subagent, | |
| 30 | type OrchestrationV2TurnItem, | |
| 31 | type PlanId, | |
| 32 | ProviderDriverKind, | |
| 33 | type ProviderInstanceId, | |
| 34 | type ProviderRequestKind, | |
| 35 | type ProviderSessionId, | |
| 36 | type RuntimeRequestId, | |
| 37 | type ThreadId, | |
| 38 | } from "@t3tools/contracts"; | |
| 39 | import { OpenCodeSettings } from "../settings.ts"; | |
| 40 | import * as Cause from "effect/Cause"; | |
| 41 | import * as DateTime from "effect/DateTime"; | |
| 42 | import * as Deferred from "effect/Deferred"; | |
| 43 | import * as Duration from "effect/Duration"; | |
| 44 | import * as Effect from "effect/Effect"; | |
| 45 | import * as Exit from "effect/Exit"; | |
| 46 | import * as Fiber from "effect/Fiber"; | |
| 47 | import * as Layer from "effect/Layer"; | |
| 48 | import * as Option from "effect/Option"; | |
| 49 | import * as Queue from "effect/Queue"; | |
| 50 | import * as Random from "effect/Random"; | |
| 51 | import * as Schema from "effect/Schema"; | |
| 52 | import * as Semaphore from "effect/Semaphore"; | |
| 53 | import * as Scope from "effect/Scope"; | |
| 54 | import * as Stream from "effect/Stream"; | |
| 55 | ||
| 56 | import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; | |
| 57 | import { mcpToolPresentation } from "@t3tools/provider-core/server/mcpToolPresentation"; | |
| 58 | import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; | |
| 59 | import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; | |
| 60 | import { | |
| 61 | structuralProtocolMethod, | |
| 62 | summarizeNativeProtocolPayload, | |
| 63 | } from "@t3tools/provider-core/server/nativeProtocolLogging"; | |
| 64 | import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; | |
| 65 | import { t3OrchestrationSystemPrompt } from "@t3tools/provider-core/server/orchestrationInstructions"; | |
| 66 | import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions"; | |
| 67 | import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; | |
| 68 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; | |
| 69 | import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; | |
| 70 | import { turnScopedSelectionTransition } from "@t3tools/provider-core/server/selectionTransition"; | |
| 71 | import { providerMessageTextWithAttachmentPaths } from "@t3tools/provider-core/server/attachmentPrompt"; | |
| 72 | import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; | |
| 73 | import { | |
| 74 | ProviderAdapterDriverCreateError, | |
| 75 | type ProviderAdapterDriver, | |
| 76 | type ProviderAdapterDriverCreateInput, | |
| 77 | } from "@t3tools/provider-core/server/adapterDriver"; | |
| 78 | import { | |
| 79 | makeSubagentChildThread, | |
| 80 | subagentThreadTitle, | |
| 81 | } from "@t3tools/provider-core/server/subagentProjection"; | |
| 82 | import { openCodeToolTurnItem } from "./toolItems.ts"; | |
| 83 | ||
| 84 | export { openCodeToolProjectionKind } from "./toolItems.ts"; | |
| 85 | ||
| 86 | export const OPENCODE_PROVIDER = ProviderDriverKind.make("opencode"); | |
| 87 | export const OPENCODE_DEFAULT_INSTANCE_ID = defaultInstanceIdForDriver(OPENCODE_PROVIDER); | |
| 88 | export const OPENCODE_SDK_PROTOCOL = "opencode-sdk.sse" as const; | |
| 89 | const DEFAULT_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({}); | |
| 90 | ||
| 91 | let openCodeMessageIdEpochMillis = -1; | |
| 92 | let openCodeMessageIdCounter = 0; | |
| 93 | ||
| 94 | const makeOpenCodeMessageId = Effect.fnUntraced(function* () { | |
| 95 | const epochMillis = DateTime.toEpochMillis(yield* DateTime.now); | |
| 96 | if (epochMillis !== openCodeMessageIdEpochMillis) { | |
| 97 | openCodeMessageIdEpochMillis = epochMillis; | |
| 98 | openCodeMessageIdCounter = 0; | |
| 99 | } | |
| 100 | openCodeMessageIdCounter += 1; | |
| 101 | const encodedTime = BigInt.asUintN( | |
| 102 | 48, | |
| 103 | BigInt(epochMillis) * 0x1000n + BigInt(openCodeMessageIdCounter), | |
| 104 | ) | |
| 105 | .toString(16) | |
| 106 | .padStart(12, "0"); | |
| 107 | const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; | |
| 108 | const random = (yield* Effect.forEach(Array.from({ length: 28 }), () => | |
| 109 | Random.nextIntBetween(0, alphabet.length - 1), | |
| 110 | )) | |
| 111 | .map((index) => alphabet[index]) | |
| 112 | .join(""); | |
| 113 | return `msg_${encodedTime}${random}`; | |
| 114 | }); | |
| 115 | ||
| 116 | /** | |
| 117 | * OpenCode's session, message, part, and interaction-request identifiers are | |
| 118 | * durable. It does not expose a first-class turn object: the initiating user | |
| 119 | * message is the best native turn correlation point, and session idle is the | |
| 120 | * authoritative terminal signal. | |
| 121 | */ | |
| 122 | const OpenCodeProviderCapabilitiesV2 = { | |
| 123 | sessions: { | |
| 124 | // The current adapter owns one directory-bound client/server per session. | |
| 125 | // Keep it isolated until its runtime is made safe for cross-thread pooling. | |
| 126 | supportsMultipleProviderThreadsPerSession: false, | |
| 127 | supportsModelSwitchInSession: true, | |
| 128 | supportsProviderSwitchingViaHandoff: true, | |
| 129 | supportsRuntimeModeSwitchInSession: false, | |
| 130 | pendingRequestsSurviveRestart: false, | |
| 131 | }, | |
| 132 | threads: { | |
| 133 | canCreateEmptyThread: true, | |
| 134 | canReadThreadSnapshot: true, | |
| 135 | canRollbackThread: true, | |
| 136 | canForkThread: true, | |
| 137 | canForkFromTurn: true, | |
| 138 | canForkFromSubagentThread: true, | |
| 139 | exposesNativeThreadId: true, | |
| 140 | }, | |
| 141 | turns: { | |
| 142 | exposesNativeTurnId: false, | |
| 143 | emitsTurnStarted: true, | |
| 144 | emitsTurnCompleted: true, | |
| 145 | supportsInterrupt: true, | |
| 146 | supportsActiveSteering: true, | |
| 147 | supportsSteeringByInterruptRestart: true, | |
| 148 | supportsQueuedMessages: true, | |
| 149 | terminalStatusQuality: "strong", | |
| 150 | }, | |
| 151 | streaming: { | |
| 152 | streamsAssistantText: true, | |
| 153 | streamsReasoning: true, | |
| 154 | streamsToolOutput: true, | |
| 155 | streamsPlanText: false, | |
| 156 | emitsMessageCompleted: true, | |
| 157 | }, | |
| 158 | tools: { | |
| 159 | exposesToolItemIds: true, | |
| 160 | emitsToolStarted: true, | |
| 161 | emitsToolCompleted: true, | |
| 162 | emitsToolOutput: true, | |
| 163 | supportsMcpTools: true, | |
| 164 | supportsDynamicToolCallbacks: false, | |
| 165 | }, | |
| 166 | approvals: { | |
| 167 | supportsCommandApproval: true, | |
| 168 | supportsFileReadApproval: true, | |
| 169 | supportsFileChangeApproval: true, | |
| 170 | supportsApplyPatchApproval: true, | |
| 171 | approvalsHaveNativeRequestIds: true, | |
| 172 | approvalCallbacksAreLiveOnly: true, | |
| 173 | approvalsCanOriginateFromSubagents: true, | |
| 174 | }, | |
| 175 | planning: { | |
| 176 | emitsPlanUpdated: true, | |
| 177 | emitsTodoList: true, | |
| 178 | emitsProposedPlan: false, | |
| 179 | supportsStructuredQuestions: true, | |
| 180 | planDeltasHaveItemIds: false, | |
| 181 | }, | |
| 182 | subagents: { | |
| 183 | supportsSubagents: true, | |
| 184 | exposesSubagentThreadIds: true, | |
| 185 | emitsSubagentLifecycle: true, | |
| 186 | canWaitForSubagents: true, | |
| 187 | canCloseSubagents: false, | |
| 188 | canForkSubagentThread: true, | |
| 189 | }, | |
| 190 | context: { | |
| 191 | acceptsSystemContext: false, | |
| 192 | acceptsDeveloperContext: false, | |
| 193 | acceptsSyntheticUserContext: true, | |
| 194 | canGenerateSummaries: true, | |
| 195 | canConsumeHandoffSummaries: true, | |
| 196 | supportsDeltaHandoff: true, | |
| 197 | supportsFullThreadHandoff: true, | |
| 198 | maxRecommendedHandoffChars: null, | |
| 199 | }, | |
| 200 | checkpointing: { | |
| 201 | appCanCheckpointFilesystem: true, | |
| 202 | supportsNestedCheckpointScopes: true, | |
| 203 | providerCanRollbackConversation: true, | |
| 204 | providerRollbackReturnsSnapshot: true, | |
| 205 | providerCanReadConversationSnapshot: true, | |
| 206 | }, | |
| 207 | identity: { | |
| 208 | nativeThreadIds: "strong", | |
| 209 | nativeTurnIds: "weak", | |
| 210 | nativeItemIds: "strong", | |
| 211 | nativeRequestIds: "strong", | |
| 212 | }, | |
| 213 | runtimePolicy: { | |
| 214 | enforcement: "native", | |
| 215 | }, | |
| 216 | } satisfies OrchestrationV2ProviderCapabilities; | |
| 217 | ||
| 218 | type TerminalTurnStatus = Extract< | |
| 219 | OrchestrationV2ProviderTurn["status"], | |
| 220 | "completed" | "interrupted" | "failed" | "cancelled" | |
| 221 | >; | |
| 222 | ||
| 223 | type OpenCodeStepUsage = Pick< | |
| 224 | Extract<OpenCodePart, { readonly type: "step-finish" }>, | |
| 225 | "id" | "tokens" | |
| 226 | >; | |
| 227 | ||
| 228 | interface OpenCodeTurnTokenUsageAccumulator { | |
| 229 | readonly partIds: Set<string>; | |
| 230 | readonly promptMessageIds: Set<string>; | |
| 231 | readonly assistantOwnershipByMessageId: Map<string, "owned" | "other" | "unknown">; | |
| 232 | // Native removal does not undo usage. Keep unresolved counts until this turn settles. | |
| 233 | readonly unresolvedStepsByMessageId: Map<string, Map<string, OpenCodeStepUsage>>; | |
| 234 | inputTokens: number; | |
| 235 | cachedInputTokens: number; | |
| 236 | cacheCreationTokens: number; | |
| 237 | outputTokens: number; | |
| 238 | reasoningTokens: number; | |
| 239 | hasSubagents: boolean; | |
| 240 | complete: boolean; | |
| 241 | } | |
| 242 | ||
| 243 | function makeOpenCodeTurnTokenUsageAccumulator(): OpenCodeTurnTokenUsageAccumulator { | |
| 244 | return { | |
| 245 | partIds: new Set(), | |
| 246 | promptMessageIds: new Set(), | |
| 247 | assistantOwnershipByMessageId: new Map(), | |
| 248 | unresolvedStepsByMessageId: new Map(), | |
| 249 | inputTokens: 0, | |
| 250 | cachedInputTokens: 0, | |
| 251 | cacheCreationTokens: 0, | |
| 252 | outputTokens: 0, | |
| 253 | reasoningTokens: 0, | |
| 254 | hasSubagents: false, | |
| 255 | complete: true, | |
| 256 | }; | |
| 257 | } | |
| 258 | ||
| 259 | function accumulateOpenCodeStepUsage( | |
| 260 | accumulator: OpenCodeTurnTokenUsageAccumulator, | |
| 261 | part: OpenCodeStepUsage, | |
| 262 | ): void { | |
| 263 | if (accumulator.partIds.has(part.id)) return; | |
| 264 | accumulator.partIds.add(part.id); | |
| 265 | accumulator.inputTokens += part.tokens.input + part.tokens.cache.read + part.tokens.cache.write; | |
| 266 | accumulator.cachedInputTokens += part.tokens.cache.read; | |
| 267 | accumulator.cacheCreationTokens += part.tokens.cache.write; | |
| 268 | accumulator.outputTokens += part.tokens.output + part.tokens.reasoning; | |
| 269 | accumulator.reasoningTokens += part.tokens.reasoning; | |
| 270 | } | |
| 271 | ||
| 272 | interface ActiveOpenCodeTurn { | |
| 273 | readonly usage: OpenCodeTurnTokenUsageAccumulator; | |
| 274 | readonly isRoot: boolean; | |
| 275 | readonly threadId: ThreadId; | |
| 276 | readonly runId: ProviderAdapter.ProviderAdapterV2TurnInput["runId"] | null; | |
| 277 | readonly rootNodeId: ProviderAdapter.ProviderAdapterV2TurnInput["rootNodeId"]; | |
| 278 | readonly appThread: OrchestrationV2AppThread; | |
| 279 | readonly modelSelection: ModelSelection; | |
| 280 | readonly runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy; | |
| 281 | readonly providerTurnId: OrchestrationV2ProviderTurn["id"]; | |
| 282 | /** The provider thread the turn started on, which its terminal names. */ | |
| 283 | readonly providerThreadId: OrchestrationV2ProviderTurn["providerThreadId"]; | |
| 284 | readonly providerTurnOrdinal: number; | |
| 285 | readonly runOrdinal: number; | |
| 286 | readonly runAttemptId: OrchestrationV2ProviderTurn["runAttemptId"]; | |
| 287 | readonly startedAt: DateTime.Utc; | |
| 288 | readonly itemOrdinals: Map<string, number>; | |
| 289 | readonly parts: Map<string, Exclude<OpenCodePart, ToolPart>>; | |
| 290 | readonly partIdsByMessage: Map<string, Set<string>>; | |
| 291 | readonly toolNamesByCallId: Map<string, string>; | |
| 292 | mcpServerNames?: ReadonlyArray<string>; | |
| 293 | readonly providerTurn: OrchestrationV2ProviderTurn; | |
| 294 | nextItemOrdinal: number; | |
| 295 | nativeUserMessageId: string | null; | |
| 296 | admissionMessageId: string | null; | |
| 297 | interrupted: boolean; | |
| 298 | finalized: boolean; | |
| 299 | planId: PlanId | null; | |
| 300 | admissionGeneration: number; | |
| 301 | admissionReconciliationGeneration: number | null; | |
| 302 | admissionPending: boolean; | |
| 303 | admissionAccepted: boolean; | |
| 304 | admissionMessageObserved: boolean; | |
| 305 | idleDuringAdmission: boolean; | |
| 306 | admissionSettled: Deferred.Deferred<void>; | |
| 307 | admissionAbortController: AbortController | null; | |
| 308 | } | |
| 309 | ||
| 310 | type OpenCodeAdmissionSignal = | |
| 311 | | "accepted" | |
| 312 | | "assistant-completed" | |
| 313 | | "busy" | |
| 314 | | "idle" | |
| 315 | | "user-message"; | |
| 316 | type OpenCodeAdmissionAction = "hold" | "reconcile-idle" | "release"; | |
| 317 | ||
| 318 | export function advanceOpenCodePromptAdmission( | |
| 319 | admission: Pick< | |
| 320 | ActiveOpenCodeTurn, | |
| 321 | "admissionAccepted" | "admissionMessageObserved" | "admissionPending" | "idleDuringAdmission" | |
| 322 | >, | |
| 323 | signal: OpenCodeAdmissionSignal, | |
| 324 | ): OpenCodeAdmissionAction { | |
| 325 | if (!admission.admissionPending) return "release"; | |
| 326 | if (signal === "assistant-completed") { | |
| 327 | admission.admissionAccepted = true; | |
| 328 | admission.admissionMessageObserved = true; | |
| 329 | admission.admissionPending = false; | |
| 330 | return "release"; | |
| 331 | } | |
| 332 | if (signal === "idle") { | |
| 333 | admission.idleDuringAdmission = true; | |
| 334 | return "hold"; | |
| 335 | } | |
| 336 | if (signal === "accepted") admission.admissionAccepted = true; | |
| 337 | if (signal === "busy" || signal === "user-message") admission.admissionMessageObserved = true; | |
| 338 | if (!admission.admissionAccepted || !admission.admissionMessageObserved) return "hold"; | |
| 339 | if (admission.idleDuringAdmission) return "reconcile-idle"; | |
| 340 | admission.admissionPending = false; | |
| 341 | return "release"; | |
| 342 | } | |
| 343 | ||
| 344 | export function cancelOpenCodePromptAdmission( | |
| 345 | admission: Pick<ActiveOpenCodeTurn, "admissionGeneration" | "admissionPending">, | |
| 346 | nextGeneration: number, | |
| 347 | ): void { | |
| 348 | admission.admissionGeneration = nextGeneration; | |
| 349 | admission.admissionPending = false; | |
| 350 | } | |
| 351 | ||
| 352 | export const reconcileOpenCodePromptAdmissionStatus = Effect.fn( | |
| 353 | "reconcileOpenCodePromptAdmissionStatus", | |
| 354 | )(function* ( | |
| 355 | admission: Pick<ActiveOpenCodeTurn, "admissionGeneration" | "admissionPending">, | |
| 356 | generation: number, | |
| 357 | readStatus: Effect.Effect<"busy" | "idle" | "unknown">, | |
| 358 | ) { | |
| 359 | if (admission.admissionGeneration !== generation || !admission.admissionPending) { | |
| 360 | return "stale" as const; | |
| 361 | } | |
| 362 | const status = yield* readStatus; | |
| 363 | if (admission.admissionGeneration !== generation || !admission.admissionPending) { | |
| 364 | return "stale" as const; | |
| 365 | } | |
| 366 | if (status !== "unknown") admission.admissionPending = false; | |
| 367 | return status; | |
| 368 | }); | |
| 369 | ||
| 370 | interface OpenCodeSubagentContext { | |
| 371 | readonly nativeItemId: string; | |
| 372 | readonly nodeId: OrchestrationV2Subagent["id"]; | |
| 373 | readonly parentState: OpenCodeThreadState; | |
| 374 | readonly parentTurn: ActiveOpenCodeTurn; | |
| 375 | readonly prompt: string; | |
| 376 | readonly title: string | null; | |
| 377 | readonly startedAt: DateTime.Utc; | |
| 378 | childSessionId: string | null; | |
| 379 | childThreadId: ThreadId | null; | |
| 380 | childProviderThreadId: OrchestrationV2ProviderThread["id"] | null; | |
| 381 | model: string | null; | |
| 382 | result: string | null; | |
| 383 | } | |
| 384 | ||
| 385 | interface OpenCodeThreadState { | |
| 386 | readonly nativeSessionId: string; | |
| 387 | providerThread: OrchestrationV2ProviderThread; | |
| 388 | appThread: OrchestrationV2AppThread | null; | |
| 389 | activeTurn: ActiveOpenCodeTurn | null; | |
| 390 | readonly providerTurns: Map<string, OrchestrationV2ProviderTurn>; | |
| 391 | readonly messages: Map<string, OrchestrationV2ConversationMessage>; | |
| 392 | readonly runtimeRequests: Map<string, OrchestrationV2RuntimeRequest>; | |
| 393 | readonly messageRoles: Map<string, "user" | "assistant">; | |
| 394 | readonly userMessageIds: Array<string>; | |
| 395 | parentSubagent: OpenCodeSubagentContext | null; | |
| 396 | nextChildTurnOrdinal: number; | |
| 397 | nextAdmissionGeneration: number; | |
| 398 | } | |
| 399 | ||
| 400 | interface OpenCodeRequestOwner { | |
| 401 | readonly state: OpenCodeThreadState; | |
| 402 | readonly turn: ActiveOpenCodeTurn; | |
| 403 | /** The subagent on the owner's thread that leads to the asking session. */ | |
| 404 | readonly subagent: OpenCodeSubagentContext | null; | |
| 405 | } | |
| 406 | ||
| 407 | /** | |
| 408 | * The top-level thread and active turn that own a session's requests, walking | |
| 409 | * up through (possibly nested) subagents. | |
| 410 | */ | |
| 411 | function topLevelRequestOwner(state: OpenCodeThreadState): OpenCodeRequestOwner | undefined { | |
| 412 | let owner = state; | |
| 413 | let subagent: OpenCodeSubagentContext | null = null; | |
| 414 | while (owner.parentSubagent !== null) { | |
| 415 | subagent = owner.parentSubagent; | |
| 416 | owner = subagent.parentState; | |
| 417 | } | |
| 418 | return owner.activeTurn === null ? undefined : { state: owner, turn: owner.activeTurn, subagent }; | |
| 419 | } | |
| 420 | ||
| 421 | interface PendingOpenCodeRequest { | |
| 422 | readonly requestId: RuntimeRequestId; | |
| 423 | readonly nativeRequestId: string; | |
| 424 | /** The session that asked, which may be a subagent's. */ | |
| 425 | readonly nativeSessionId: string; | |
| 426 | readonly turn: ActiveOpenCodeTurn; | |
| 427 | readonly state: OpenCodeThreadState; | |
| 428 | readonly nodeId: OrchestrationV2ExecutionNode["id"]; | |
| 429 | readonly parentNodeId: OrchestrationV2ExecutionNode["id"]; | |
| 430 | readonly turnItemId: OrchestrationV2TurnItem["id"]; | |
| 431 | readonly requestKind: OpenCodePermissionRequestKind | "user_input"; | |
| 432 | readonly createdAt: DateTime.Utc; | |
| 433 | readonly permission?: PermissionRequest; | |
| 434 | readonly question?: QuestionRequest; | |
| 435 | } | |
| 436 | ||
| 437 | export interface OpenCodeAdapterV2Options { | |
| 438 | readonly instanceId: ProviderInstanceId; | |
| 439 | readonly settings: OpenCodeSettings; | |
| 440 | readonly environment: NodeJS.ProcessEnv; | |
| 441 | readonly runtime: OpenCodeRuntime.OpenCodeRuntimeShape; | |
| 442 | readonly idAllocator: IdAllocator.IdAllocatorV2Shape; | |
| 443 | readonly host: ProviderHost.ProviderHostShape; | |
| 444 | readonly nativeEventLogger?: ProviderEventLoggers.EventNdjsonLogger; | |
| 445 | } | |
| 446 | ||
| 447 | export interface OpenCodeProtocolLogEvent { | |
| 448 | readonly direction: "incoming" | "outgoing"; | |
| 449 | readonly messageKind: "request" | "response" | "notification" | "error"; | |
| 450 | readonly method: string; | |
| 451 | readonly payload: unknown; | |
| 452 | } | |
| 453 | ||
| 454 | function formatOpenCodeProtocolLogPayload(event: OpenCodeProtocolLogEvent) { | |
| 455 | return { | |
| 456 | direction: event.direction, | |
| 457 | messageKind: event.messageKind, | |
| 458 | method: structuralProtocolMethod(event.method), | |
| 459 | payload: summarizeNativeProtocolPayload(event.payload), | |
| 460 | }; | |
| 461 | } | |
| 462 | ||
| 463 | export function makeOpenCodeProtocolLogger(input: { | |
| 464 | readonly nativeEventLogger: ProviderEventLoggers.EventNdjsonLogger | undefined; | |
| 465 | readonly idAllocator: IdAllocator.IdAllocatorV2Shape; | |
| 466 | readonly providerInstanceId: ProviderInstanceId; | |
| 467 | readonly providerSessionId: ProviderSessionId; | |
| 468 | readonly threadId: ThreadId; | |
| 469 | }): (event: OpenCodeProtocolLogEvent) => Effect.Effect<void, never> { | |
| 470 | return (event) => | |
| 471 | Effect.gen(function* () { | |
| 472 | if (!input.nativeEventLogger) return; | |
| 473 | const observedAt = DateTime.formatIso(yield* DateTime.now); | |
| 474 | const method = structuralProtocolMethod(event.method); | |
| 475 | yield* input.nativeEventLogger.write( | |
| 476 | { | |
| 477 | observedAt, | |
| 478 | event: { | |
| 479 | id: yield* input.idAllocator.allocate.rawEvent({ | |
| 480 | providerSessionId: input.providerSessionId, | |
| 481 | method, | |
| 482 | }), | |
| 483 | kind: "protocol", | |
| 484 | protocol: OPENCODE_SDK_PROTOCOL, | |
| 485 | provider: OPENCODE_PROVIDER, | |
| 486 | providerInstanceId: input.providerInstanceId, | |
| 487 | providerSessionId: input.providerSessionId, | |
| 488 | createdAt: observedAt, | |
| 489 | threadId: input.threadId, | |
| 490 | payload: formatOpenCodeProtocolLogPayload(event), | |
| 491 | }, | |
| 492 | }, | |
| 493 | input.threadId, | |
| 494 | ); | |
| 495 | }).pipe( | |
| 496 | Effect.catchCause((cause) => | |
| 497 | Cause.hasInterrupts(cause) | |
| 498 | ? Effect.interrupt | |
| 499 | : Effect.logWarning("Failed to write native OpenCode event log.", { | |
| 500 | errorTag: causeErrorTag(cause), | |
| 501 | reasonCount: cause.reasons.length, | |
| 502 | provider: OPENCODE_PROVIDER, | |
| 503 | threadId: input.threadId, | |
| 504 | }), | |
| 505 | ), | |
| 506 | ); | |
| 507 | } | |
| 508 | ||
| 509 | function protocolError( | |
| 510 | detail: string, | |
| 511 | payload?: unknown, | |
| 512 | ): ProviderAdapter.ProviderAdapterProtocolError { | |
| 513 | return new ProviderAdapter.ProviderAdapterProtocolError({ | |
| 514 | driver: OPENCODE_PROVIDER, | |
| 515 | detail, | |
| 516 | ...(payload === undefined ? {} : { payload }), | |
| 517 | }); | |
| 518 | } | |
| 519 | ||
| 520 | function nativeThreadId(providerThread: OrchestrationV2ProviderThread): string { | |
| 521 | const nativeId = providerThread.nativeThreadRef?.nativeId; | |
| 522 | if (nativeId === null || nativeId === undefined) { | |
| 523 | throw protocolError(`Provider thread ${providerThread.id} has no OpenCode session id`); | |
| 524 | } | |
| 525 | return nativeId; | |
| 526 | } | |
| 527 | ||
| 528 | function dateTimeFromEpoch(value: number | undefined, fallback: DateTime.Utc): DateTime.Utc { | |
| 529 | if (value === undefined) return fallback; | |
| 530 | return Option.getOrElse(DateTime.make(value), () => fallback); | |
| 531 | } | |
| 532 | ||
| 533 | function nonEmptyString(value: unknown): string | undefined { | |
| 534 | return typeof value === "string" && value.trim().length > 0 ? value : undefined; | |
| 535 | } | |
| 536 | ||
| 537 | function recordValue(input: unknown, key: string): unknown { | |
| 538 | return typeof input === "object" && input !== null && key in input | |
| 539 | ? (input as Record<string, unknown>)[key] | |
| 540 | : undefined; | |
| 541 | } | |
| 542 | ||
| 543 | function recordString(input: unknown, ...keys: ReadonlyArray<string>): string | undefined { | |
| 544 | for (const key of keys) { | |
| 545 | const value = nonEmptyString(recordValue(input, key)); | |
| 546 | if (value !== undefined) return value; | |
| 547 | } | |
| 548 | return undefined; | |
| 549 | } | |
| 550 | ||
| 551 | function sdkResponseForRawLog(value: unknown): unknown { | |
| 552 | if (typeof value !== "object" || value === null) return value; | |
| 553 | if ("data" in value) return { data: (value as { readonly data?: unknown }).data ?? null }; | |
| 554 | if ("stream" in value) return { subscribed: true }; | |
| 555 | return value; | |
| 556 | } | |
| 557 | ||
| 558 | type OpenCodePermissionRequestKind = Extract< | |
| 559 | ProviderRequestKind, | |
| 560 | "command" | "file-read" | "file-change" | |
| 561 | >; | |
| 562 | ||
| 563 | export function openCodePermissionRequestKind( | |
| 564 | permission: string, | |
| 565 | toolName?: string, | |
| 566 | ): OpenCodePermissionRequestKind { | |
| 567 | const normalized = permission.toLowerCase(); | |
| 568 | const normalizedTool = toolName?.toLowerCase() ?? ""; | |
| 569 | if ( | |
| 570 | normalized === "edit" || | |
| 571 | normalized === "write" || | |
| 572 | normalized === "patch" || | |
| 573 | normalized === "apply_patch" || | |
| 574 | normalizedTool === "edit" || | |
| 575 | normalizedTool === "write" || | |
| 576 | normalizedTool === "patch" || | |
| 577 | normalizedTool === "apply_patch" | |
| 578 | ) { | |
| 579 | return "file-change"; | |
| 580 | } | |
| 581 | if ( | |
| 582 | normalized === "read" || | |
| 583 | normalized === "glob" || | |
| 584 | normalized === "grep" || | |
| 585 | normalized === "lsp" || | |
| 586 | normalized === "external_directory" || | |
| 587 | normalizedTool === "read" || | |
| 588 | normalizedTool.includes("glob") || | |
| 589 | normalizedTool.includes("grep") || | |
| 590 | normalizedTool.includes("search") | |
| 591 | ) { | |
| 592 | return "file-read"; | |
| 593 | } | |
| 594 | return "command"; | |
| 595 | } | |
| 596 | ||
| 597 | const OPENCODE_ALWAYS_ALLOWED_PERMISSIONS = [ | |
| 598 | "question", | |
| 599 | "read", | |
| 600 | "glob", | |
| 601 | "grep", | |
| 602 | "lsp", | |
| 603 | "todowrite", | |
| 604 | "task", | |
| 605 | "skill", | |
| 606 | ] as const; | |
| 607 | ||
| 608 | const OPENCODE_RESTRICTED_PERMISSIONS = [ | |
| 609 | "bash", | |
| 610 | "edit", | |
| 611 | "webfetch", | |
| 612 | "websearch", | |
| 613 | "codesearch", | |
| 614 | "external_directory", | |
| 615 | "doom_loop", | |
| 616 | ] as const; | |
| 617 | ||
| 618 | /** | |
| 619 | * OpenCode does not provide an OS sandbox, so permission rules are also the | |
| 620 | * enforcement boundary for non-interactive policies. Read/planning tools are | |
| 621 | * safe by default; edits are auto-approved only for workspace-write modes, | |
| 622 | * while shell/network/external access remains gated unless policy explicitly | |
| 623 | * allows it. | |
| 624 | */ | |
| 625 | export function openCodePermissionRules( | |
| 626 | runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy, | |
| 627 | ): PermissionRuleset { | |
| 628 | const sandboxPolicy = recordValue(runtimePolicy, "sandboxPolicy"); | |
| 629 | const sandboxType = recordString(sandboxPolicy, "type"); | |
| 630 | const rawApprovalPolicy = runtimePolicy.approvalPolicy; | |
| 631 | const approvalPolicy = nonEmptyString(rawApprovalPolicy); | |
| 632 | const requiresApproval = | |
| 633 | approvalPolicy === undefined | |
| 634 | ? (typeof rawApprovalPolicy === "object" && rawApprovalPolicy !== null) || | |
| 635 | runtimePolicy.runtimeMode !== "full-access" | |
| 636 | : approvalPolicy !== "never"; | |
| 637 | const externallySandboxed = sandboxType === "externalSandbox"; | |
| 638 | const dangerFullAccess = sandboxType === "dangerFullAccess"; | |
| 639 | const implicitFullAccess = | |
| 640 | sandboxType === undefined && runtimePolicy.runtimeMode === "full-access"; | |
| 641 | ||
| 642 | if (!requiresApproval && (externallySandboxed || dangerFullAccess || implicitFullAccess)) { | |
| 643 | return [{ permission: "*", pattern: "*", action: "allow" }]; | |
| 644 | } | |
| 645 | ||
| 646 | // Task sessions initially inherit only parent deny rules. Seed explicit | |
| 647 | // denies before the effective ask/allow overrides so a child is safe during | |
| 648 | // the short interval before emitSubagent installs its complete policy. | |
| 649 | const rules: PermissionRuleset = [ | |
| 650 | { permission: "*", pattern: "*", action: "deny" }, | |
| 651 | ...OPENCODE_RESTRICTED_PERMISSIONS.map((permission) => ({ | |
| 652 | permission, | |
| 653 | pattern: "*", | |
| 654 | action: "deny" as const, | |
| 655 | })), | |
| 656 | ]; | |
| 657 | ||
| 658 | if (requiresApproval) { | |
| 659 | rules.push({ permission: "*", pattern: "*", action: "ask" }); | |
| 660 | for (const permission of OPENCODE_RESTRICTED_PERMISSIONS) { | |
| 661 | rules.push({ permission, pattern: "*", action: "ask" }); | |
| 662 | } | |
| 663 | } | |
| 664 | ||
| 665 | rules.push( | |
| 666 | ...OPENCODE_ALWAYS_ALLOWED_PERMISSIONS.map((permission) => ({ | |
| 667 | permission, | |
| 668 | pattern: "*", | |
| 669 | action: "allow" as const, | |
| 670 | })), | |
| 671 | ); | |
| 672 | ||
| 673 | rules.push( | |
| 674 | { permission: "read", pattern: "*.env", action: requiresApproval ? "ask" : "deny" }, | |
| 675 | { permission: "read", pattern: "*.env.*", action: requiresApproval ? "ask" : "deny" }, | |
| 676 | { permission: "read", pattern: "*.env.example", action: "allow" }, | |
| 677 | ); | |
| 678 | ||
| 679 | if ( | |
| 680 | runtimePolicy.runtimeMode === "auto-accept-edits" || | |
| 681 | (!requiresApproval && sandboxType === "workspaceWrite") | |
| 682 | ) { | |
| 683 | rules.push({ permission: "edit", pattern: "*", action: "allow" }); | |
| 684 | } | |
| 685 | ||
| 686 | if (!requiresApproval && recordValue(sandboxPolicy, "networkAccess") === true) { | |
| 687 | for (const permission of ["webfetch", "websearch", "codesearch"] as const) { | |
| 688 | rules.push({ permission, pattern: "*", action: "allow" }); | |
| 689 | } | |
| 690 | } | |
| 691 | ||
| 692 | if (!requiresApproval && sandboxType === "readOnly") { | |
| 693 | const access = recordValue(sandboxPolicy, "access"); | |
| 694 | if (recordString(access, "type") === "fullAccess") { | |
| 695 | rules.push({ permission: "external_directory", pattern: "*", action: "allow" }); | |
| 696 | } | |
| 697 | } | |
| 698 | ||
| 699 | if (!requiresApproval && sandboxType === "workspaceWrite") { | |
| 700 | const writableRoots = recordValue(sandboxPolicy, "writableRoots"); | |
| 701 | if (Array.isArray(writableRoots)) { | |
| 702 | for (const root of writableRoots) { | |
| 703 | if (typeof root === "string" && root.trim().length > 0) { | |
| 704 | rules.push({ | |
| 705 | permission: "external_directory", | |
| 706 | pattern: `${root.replace(/\/$/, "")}/*`, | |
| 707 | action: "allow", | |
| 708 | }); | |
| 709 | } | |
| 710 | } | |
| 711 | } | |
| 712 | } | |
| 713 | ||
| 714 | return rules; | |
| 715 | } | |
| 716 | ||
| 717 | function permissionRuleEquals( | |
| 718 | left: PermissionRuleset[number], | |
| 719 | right: PermissionRuleset[number], | |
| 720 | ): boolean { | |
| 721 | return ( | |
| 722 | left.permission === right.permission && | |
| 723 | left.pattern === right.pattern && | |
| 724 | left.action === right.action | |
| 725 | ); | |
| 726 | } | |
| 727 | ||
| 728 | /** | |
| 729 | * OpenCode task sessions inherit only the parent's deny/external-directory | |
| 730 | * rules and then add agent-specific restrictions such as disabling nested | |
| 731 | * tasks. Install the complete parent policy while retaining only rules that | |
| 732 | * were added specifically for the selected child agent. | |
| 733 | */ | |
| 734 | export function openCodeChildPermissionRules( | |
| 735 | runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy, | |
| 736 | nativeChildRules: PermissionRuleset, | |
| 737 | ): PermissionRuleset { | |
| 738 | const parentRules = openCodePermissionRules(runtimePolicy); | |
| 739 | const inheritedRules = parentRules.filter( | |
| 740 | (rule) => rule.permission === "external_directory" || rule.action === "deny", | |
| 741 | ); | |
| 742 | const childSpecificRules = nativeChildRules.filter( | |
| 743 | (childRule) => | |
| 744 | !inheritedRules.some((inheritedRule) => permissionRuleEquals(childRule, inheritedRule)), | |
| 745 | ); | |
| 746 | return [...parentRules, ...childSpecificRules]; | |
| 747 | } | |
| 748 | ||
| 749 | /** | |
| 750 | * OpenCode's fork/revert boundary is exclusive. To retain the selected app | |
| 751 | * turn, address the next native user message; omitting a boundary retains the | |
| 752 | * current head when the selected turn is already last. | |
| 753 | */ | |
| 754 | export function openCodeBoundaryAfterProviderTurn( | |
| 755 | providerTurns: ReadonlyArray<OrchestrationV2ProviderTurn>, | |
| 756 | selectedProviderTurnId: OrchestrationV2ProviderTurn["id"], | |
| 757 | ): string | undefined { | |
| 758 | const selected = providerTurns.find((turn) => turn.id === selectedProviderTurnId); | |
| 759 | if (selected === undefined) return undefined; | |
| 760 | return providerTurns | |
| 761 | .filter((turn) => turn.ordinal > selected.ordinal) | |
| 762 | .toSorted((left, right) => left.ordinal - right.ordinal) | |
| 763 | .map((turn) => turn.nativeTurnRef?.nativeId) | |
| 764 | .find((nativeId): nativeId is string => nativeId !== null && nativeId !== undefined); | |
| 765 | } | |
| 766 | ||
| 767 | function toolStatus(part: ToolPart): { | |
| 768 | readonly node: OrchestrationV2ExecutionNode["status"]; | |
| 769 | readonly item: OrchestrationV2TurnItem["status"]; | |
| 770 | } { | |
| 771 | switch (part.state.status) { | |
| 772 | case "pending": | |
| 773 | return { node: "pending", item: "pending" }; | |
| 774 | case "running": | |
| 775 | return { node: "running", item: "running" }; | |
| 776 | case "completed": | |
| 777 | return { node: "completed", item: "completed" }; | |
| 778 | case "error": | |
| 779 | return { node: "failed", item: "failed" }; | |
| 780 | } | |
| 781 | } | |
| 782 | ||
| 783 | function toolInput(part: ToolPart): Record<string, unknown> { | |
| 784 | return part.state.input; | |
| 785 | } | |
| 786 | ||
| 787 | function toolOutput(part: ToolPart): string | undefined { | |
| 788 | if (part.state.status === "completed") return part.state.output; | |
| 789 | if (part.state.status === "error") return part.state.error; | |
| 790 | return undefined; | |
| 791 | } | |
| 792 | ||
| 793 | function toolStartedAt(part: ToolPart, now: DateTime.Utc): DateTime.Utc { | |
| 794 | return dateTimeFromEpoch( | |
| 795 | part.state.status === "pending" ? undefined : part.state.time.start, | |
| 796 | now, | |
| 797 | ); | |
| 798 | } | |
| 799 | ||
| 800 | function toolCompletedAt(part: ToolPart, now: DateTime.Utc): DateTime.Utc | null { | |
| 801 | return part.state.status === "completed" || part.state.status === "error" | |
| 802 | ? dateTimeFromEpoch(part.state.time.end, now) | |
| 803 | : null; | |
| 804 | } | |
| 805 | ||
| 806 | function toolTitle(part: ToolPart): string | null { | |
| 807 | return part.state.status === "running" || part.state.status === "completed" | |
| 808 | ? (part.state.title ?? null) | |
| 809 | : null; | |
| 810 | } | |
| 811 | ||
| 812 | function toolModel(part: ToolPart): string | null { | |
| 813 | const metadata = | |
| 814 | part.state.status === "running" || | |
| 815 | part.state.status === "completed" || | |
| 816 | part.state.status === "error" | |
| 817 | ? part.state.metadata | |
| 818 | : undefined; | |
| 819 | const model = recordValue(metadata, "model"); | |
| 820 | const providerId = recordString(model, "providerID", "providerId"); | |
| 821 | const modelId = recordString(model, "modelID", "modelId", "id"); | |
| 822 | return providerId !== undefined && modelId !== undefined ? `${providerId}/${modelId}` : null; | |
| 823 | } | |
| 824 | ||
| 825 | function taskSessionId(part: ToolPart): string | null { | |
| 826 | const metadata = | |
| 827 | part.state.status === "running" || | |
| 828 | part.state.status === "completed" || | |
| 829 | part.state.status === "error" | |
| 830 | ? part.state.metadata | |
| 831 | : undefined; | |
| 832 | return recordString(metadata, "sessionId", "sessionID") ?? null; | |
| 833 | } | |
| 834 | ||
| 835 | function makeProviderThread(input: { | |
| 836 | readonly idAllocator: IdAllocator.IdAllocatorV2Shape; | |
| 837 | readonly providerInstanceId: ProviderInstanceId; | |
| 838 | readonly providerSessionId: OrchestrationV2ProviderThread["providerSessionId"]; | |
| 839 | readonly appThreadId: OrchestrationV2ProviderThread["appThreadId"]; | |
| 840 | readonly ownerNodeId?: OrchestrationV2ProviderThread["ownerNodeId"]; | |
| 841 | readonly nativeSession: OpenCodeSession; | |
| 842 | readonly forkedFrom?: OrchestrationV2ProviderThread["forkedFrom"]; | |
| 843 | readonly now: DateTime.Utc; | |
| 844 | }): OrchestrationV2ProviderThread { | |
| 845 | const createdAt = dateTimeFromEpoch(input.nativeSession.time.created, input.now); | |
| 846 | return { | |
| 847 | id: input.idAllocator.derive.providerThread({ | |
| 848 | driver: OPENCODE_PROVIDER, | |
| 849 | nativeThreadId: input.nativeSession.id, | |
| 850 | }), | |
| 851 | driver: OPENCODE_PROVIDER, | |
| 852 | providerInstanceId: input.providerInstanceId, | |
| 853 | providerSessionId: input.providerSessionId, | |
| 854 | appThreadId: input.appThreadId, | |
| 855 | ownerNodeId: input.ownerNodeId ?? null, | |
| 856 | nativeThreadRef: { | |
| 857 | driver: OPENCODE_PROVIDER, | |
| 858 | nativeId: input.nativeSession.id, | |
| 859 | strength: "strong", | |
| 860 | }, | |
| 861 | nativeConversationHeadRef: null, | |
| 862 | status: "idle", | |
| 863 | firstRunOrdinal: null, | |
| 864 | lastRunOrdinal: null, | |
| 865 | handoffIds: [], | |
| 866 | forkedFrom: input.forkedFrom ?? null, | |
| 867 | createdAt, | |
| 868 | updatedAt: dateTimeFromEpoch(input.nativeSession.time.updated, input.now), | |
| 869 | }; | |
| 870 | } | |
| 871 | ||
| 872 | function providerRef(nativeId: string, strength: "strong" | "weak" = "strong") { | |
| 873 | return { | |
| 874 | driver: OPENCODE_PROVIDER, | |
| 875 | nativeId, | |
| 876 | strength, | |
| 877 | } satisfies OrchestrationV2ProviderRef; | |
| 878 | } | |
| 879 | ||
| 880 | function openCodeErrorMessage(event: Extract<OpenCodeEvent, { type: "session.error" }>): string { | |
| 881 | const error = event.properties.error; | |
| 882 | if (error === undefined) return "OpenCode session failed without an error payload."; | |
| 883 | return recordString(error.data, "message") ?? error.name; | |
| 884 | } | |
| 885 | ||
| 886 | function terminalStatusForError( | |
| 887 | event: Extract<OpenCodeEvent, { type: "session.error" }>, | |
| 888 | turn: ActiveOpenCodeTurn, | |
| 889 | ): TerminalTurnStatus { | |
| 890 | return turn.interrupted || isMessageAbortedError(event) ? "interrupted" : "failed"; | |
| 891 | } | |
| 892 | ||
| 893 | function isMessageAbortedError(event: Extract<OpenCodeEvent, { type: "session.error" }>): boolean { | |
| 894 | return event.properties.error?.name === "MessageAbortedError"; | |
| 895 | } | |
| 896 | ||
| 897 | function isOpenCodeNotFound(cause: unknown): boolean { | |
| 898 | const seen = new Set<unknown>(); | |
| 899 | const queue: Array<unknown> = [cause]; | |
| 900 | for (let steps = 0; queue.length > 0 && steps < 32; steps += 1) { | |
| 901 | const node = queue.shift(); | |
| 902 | if (node === null || typeof node !== "object" || seen.has(node)) { | |
| 903 | continue; | |
| 904 | } | |
| 905 | seen.add(node); | |
| 906 | const record = node as Record<string, unknown>; | |
| 907 | ||
| 908 | const response = record.response; | |
| 909 | const statuses = [ | |
| 910 | record.status, | |
| 911 | record.statusCode, | |
| 912 | response !== null && typeof response === "object" | |
| 913 | ? (response as { readonly status?: unknown }).status | |
| 914 | : undefined, | |
| 915 | ].filter((status): status is number => typeof status === "number"); | |
| 916 | if (statuses.includes(404)) { | |
| 917 | return true; | |
| 918 | } | |
| 919 | if (statuses.length > 0) { | |
| 920 | continue; | |
| 921 | } | |
| 922 | ||
| 923 | const name = record.name; | |
| 924 | if (typeof name === "string" && name.toLowerCase() === "notfounderror") { | |
| 925 | return true; | |
| 926 | } | |
| 927 | ||
| 928 | for (const key of ["cause", "body", "error", "data"] as const) { | |
| 929 | if (record[key] !== undefined) { | |
| 930 | queue.push(record[key]); | |
| 931 | } | |
| 932 | } | |
| 933 | } | |
| 934 | return false; | |
| 935 | } | |
| 936 | ||
| 937 | function unwrapData<A>(operation: string, result: { readonly data?: A }): NonNullable<A> { | |
| 938 | if (result.data === undefined) { | |
| 939 | throw new OpenCodeRuntime.OpenCodeRuntimeError({ | |
| 940 | operation, | |
| 941 | detail: `OpenCode ${operation} returned no response payload.`, | |
| 942 | }); | |
| 943 | } | |
| 944 | return result.data as NonNullable<A>; | |
| 945 | } | |
| 946 | ||
| 947 | export function makeOpenCodeAdapterV2( | |
| 948 | options: OpenCodeAdapterV2Options, | |
| 949 | ): ProviderAdapter.ProviderAdapterV2Shape { | |
| 950 | const { idAllocator, runtime, host } = options; | |
| 951 | ||
| 952 | return ProviderAdapter.ProviderAdapterV2.of({ | |
| 953 | instanceId: options.instanceId, | |
| 954 | driver: OPENCODE_PROVIDER, | |
| 955 | getCapabilities: () => Effect.succeed(OpenCodeProviderCapabilitiesV2), | |
| 956 | planSelectionTransition: () => Effect.succeed(turnScopedSelectionTransition()), | |
| 957 | openSession: Effect.fn("OpenCodeAdapterV2.openSession")( | |
| 958 | function* (input: ProviderAdapter.ProviderAdapterV2OpenSessionInput) { | |
| 959 | const scope = yield* Effect.scope; | |
| 960 | const cwd = input.runtimePolicy.cwd ?? host.paths.cwd; | |
| 961 | const connection = yield* runtime.connectToOpenCodeServer({ | |
| 962 | binaryPath: options.settings.binaryPath, | |
| 963 | directory: cwd, | |
| 964 | serverUrl: options.settings.serverUrl, | |
| 965 | environment: options.environment, | |
| 966 | }); | |
| 967 | const client = runtime.createOpenCodeSdkClient({ | |
| 968 | baseUrl: connection.url, | |
| 969 | directory: cwd, | |
| 970 | ...(connection.external && options.settings.serverPassword | |
| 971 | ? { serverPassword: options.settings.serverPassword } | |
| 972 | : {}), | |
| 973 | }); | |
| 974 | ||
| 975 | const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); | |
| 976 | const hasT3Mcp = mcpSession !== undefined && !connection.external; | |
| 977 | const orchestrationSystemPrompt = t3OrchestrationSystemPrompt(hasT3Mcp); | |
| 978 | if (hasT3Mcp) { | |
| 979 | yield* OpenCodeRuntime.runOpenCodeSdk("mcp.add", () => | |
| 980 | client.mcp.add({ | |
| 981 | name: "t3-code", | |
| 982 | config: { | |
| 983 | type: "remote", | |
| 984 | url: mcpSession.endpoint, | |
| 985 | headers: { Authorization: mcpSession.authorizationHeader }, | |
| 986 | oauth: false, | |
| 987 | }, | |
| 988 | }), | |
| 989 | ); | |
| 990 | } | |
| 991 | ||
| 992 | const now = yield* DateTime.now; | |
| 993 | let sessionEntity: OrchestrationV2ProviderSession = { | |
| 994 | id: input.providerSessionId, | |
| 995 | driver: OPENCODE_PROVIDER, | |
| 996 | providerInstanceId: options.instanceId, | |
| 997 | status: "ready", | |
| 998 | cwd, | |
| 999 | model: input.modelSelection.model, | |
| 1000 | capabilities: OpenCodeProviderCapabilitiesV2, | |
| 1001 | createdAt: now, | |
| 1002 | updatedAt: now, | |
| 1003 | lastError: null, | |
| 1004 | }; | |
| 1005 | const events = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event, Cause.Done>(); | |
| 1006 | let nativeStreamFailure: OrchestrationV2ProviderFailure | null = null; | |
| 1007 | const threads = new Map<string, OpenCodeThreadState>(); | |
| 1008 | const commandReceipts = new Map<string, Deferred.Deferred<void>>(); | |
| 1009 | const commandControllers = new Map<string, Set<AbortController>>(); | |
| 1010 | const pendingRequests = new Map<string, PendingOpenCodeRequest>(); | |
| 1011 | const pendingRequestsByNativeId = new Map<string, PendingOpenCodeRequest>(); | |
| 1012 | const subagentsByNativeItemId = new Map<string, OpenCodeSubagentContext>(); | |
| 1013 | const subagentsByChildSessionId = new Map<string, OpenCodeSubagentContext>(); | |
| 1014 | // Permission and question requests can originate from child sessions | |
| 1015 | // (task subagents and their descendants). Related sessions map back | |
| 1016 | // to the root thread state whose active turn owns the request; asks | |
| 1017 | // arriving before the relation is known resolve it via session.get. | |
| 1018 | const relatedSessionOwners = new Map<string, OpenCodeThreadState>(); | |
| 1019 | // Sessions of this runtime that OpenCode reports busy. A background | |
| 1020 | // task child keeps running after its parent turn settles, so idle | |
| 1021 | // release must not close the server under it. | |
| 1022 | const busySessionIds = new Set<string>(); | |
| 1023 | // Requests settled before their session relation resolved: a late | |
| 1024 | // routing attempt must never resurrect them. Bounded because entries | |
| 1025 | // only matter for the seconds a routing retry can still be running. | |
| 1026 | const settledNativeRequestIds = new Set<string>(); | |
| 1027 | const pendingChildRequestRoutes = new Set<string>(); | |
| 1028 | const rememberSettledRequest = (nativeRequestId: string) => { | |
| 1029 | if (settledNativeRequestIds.size >= 2_048) settledNativeRequestIds.clear(); | |
| 1030 | settledNativeRequestIds.add(nativeRequestId); | |
| 1031 | }; | |
| 1032 | const abortController = new AbortController(); | |
| 1033 | let closing = false; | |
| 1034 | let hasConnected = false; | |
| 1035 | ||
| 1036 | const emitProviderEvent = (event: ProviderAdapter.ProviderAdapterV2Event) => | |
| 1037 | Queue.offer(events, event).pipe(Effect.asVoid); | |
| 1038 | ||
| 1039 | const logProtocolEvent = makeOpenCodeProtocolLogger({ | |
| 1040 | nativeEventLogger: options.nativeEventLogger, | |
| 1041 | idAllocator, | |
| 1042 | providerInstanceId: options.instanceId, | |
| 1043 | providerSessionId: input.providerSessionId, | |
| 1044 | threadId: input.threadId, | |
| 1045 | }); | |
| 1046 | ||
| 1047 | const sdkCall = <A>( | |
| 1048 | method: string, | |
| 1049 | payload: unknown, | |
| 1050 | call: (signal: AbortSignal) => Promise<A>, | |
| 1051 | ): Effect.Effect<A, OpenCodeRuntime.OpenCodeRuntimeError> => | |
| 1052 | logProtocolEvent({ | |
| 1053 | direction: "outgoing", | |
| 1054 | messageKind: "request", | |
| 1055 | method, | |
| 1056 | payload, | |
| 1057 | }).pipe( | |
| 1058 | Effect.andThen(OpenCodeRuntime.runOpenCodeSdk(method, call)), | |
| 1059 | Effect.tap((response) => | |
| 1060 | logProtocolEvent({ | |
| 1061 | direction: "incoming", | |
| 1062 | messageKind: "response", | |
| 1063 | method, | |
| 1064 | payload: sdkResponseForRawLog(response), | |
| 1065 | }), | |
| 1066 | ), | |
| 1067 | ); | |
| 1068 | ||
| 1069 | const abortDescendants = (rootId: string) => | |
| 1070 | Effect.gen(function* () { | |
| 1071 | const visited = new Set([rootId]); | |
| 1072 | const semaphore = Semaphore.makeUnsafe(8); | |
| 1073 | const visit = ( | |
| 1074 | sessionId: string, | |
| 1075 | abort: boolean, | |
| 1076 | ): Effect.Effect<OpenCodeRuntime.OpenCodeRuntimeError | undefined> => | |
| 1077 | Effect.gen(function* () { | |
| 1078 | const abortResult = abort | |
| 1079 | ? yield* sdkCall("session.abort", { sessionID: sessionId }, (signal) => | |
| 1080 | client.session.abort({ sessionID: sessionId }, { signal }), | |
| 1081 | ).pipe( | |
| 1082 | semaphore.withPermit, | |
| 1083 | Effect.catchIf(isOpenCodeNotFound, () => Effect.void), | |
| 1084 | Effect.result, | |
| 1085 | ) | |
| 1086 | : undefined; | |
| 1087 | const childrenResult = yield* sdkCall( | |
| 1088 | "session.children", | |
| 1089 | { sessionID: sessionId }, | |
| 1090 | (signal) => client.session.children({ sessionID: sessionId }, { signal }), | |
| 1091 | ).pipe( | |
| 1092 | semaphore.withPermit, | |
| 1093 | Effect.catchIf(isOpenCodeNotFound, () => Effect.void), | |
| 1094 | Effect.result, | |
| 1095 | ); | |
| 1096 | const firstFailure = | |
| 1097 | abortResult?._tag === "Failure" ? abortResult.failure : undefined; | |
| 1098 | if (childrenResult._tag === "Failure") | |
| 1099 | return firstFailure ?? childrenResult.failure; | |
| 1100 | const fresh = (childrenResult.success?.data ?? []).filter((child) => { | |
| 1101 | if (visited.has(child.id)) return false; | |
| 1102 | visited.add(child.id); | |
| 1103 | return true; | |
| 1104 | }); | |
| 1105 | const failures = yield* Effect.forEach(fresh, (child) => visit(child.id, true), { | |
| 1106 | concurrency: 8, | |
| 1107 | }); | |
| 1108 | return firstFailure ?? failures.find((failure) => failure !== undefined); | |
| 1109 | }); | |
| 1110 | const failure = yield* visit(rootId, false); | |
| 1111 | if (failure) return yield* Effect.fail(failure); | |
| 1112 | }).pipe(Effect.timeout("15 seconds")); | |
| 1113 | ||
| 1114 | const updateProviderSession = ( | |
| 1115 | status: OrchestrationV2ProviderSession["status"], | |
| 1116 | lastError: string | null = sessionEntity.lastError, | |
| 1117 | ) => | |
| 1118 | Effect.gen(function* () { | |
| 1119 | const updatedAt = yield* DateTime.now; | |
| 1120 | sessionEntity = { ...sessionEntity, status, lastError, updatedAt }; | |
| 1121 | yield* emitProviderEvent({ | |
| 1122 | type: "provider_session.updated", | |
| 1123 | driver: OPENCODE_PROVIDER, | |
| 1124 | providerSession: sessionEntity, | |
| 1125 | }); | |
| 1126 | }); | |
| 1127 | ||
| 1128 | const updateProviderThread = ( | |
| 1129 | state: OpenCodeThreadState, | |
| 1130 | patch: Partial<OrchestrationV2ProviderThread>, | |
| 1131 | ) => | |
| 1132 | Effect.gen(function* () { | |
| 1133 | const updatedAt = yield* DateTime.now; | |
| 1134 | state.providerThread = { ...state.providerThread, ...patch, updatedAt }; | |
| 1135 | yield* emitProviderEvent({ | |
| 1136 | type: "provider_thread.updated", | |
| 1137 | driver: OPENCODE_PROVIDER, | |
| 1138 | providerThread: state.providerThread, | |
| 1139 | }); | |
| 1140 | }); | |
| 1141 | ||
| 1142 | const itemOrdinal = (turn: ActiveOpenCodeTurn, nativeItemId: string): number => { | |
| 1143 | const existing = turn.itemOrdinals.get(nativeItemId); | |
| 1144 | if (existing !== undefined) return existing; | |
| 1145 | const ordinal = turn.nextItemOrdinal++; | |
| 1146 | turn.itemOrdinals.set(nativeItemId, ordinal); | |
| 1147 | return ordinal; | |
| 1148 | }; | |
| 1149 | ||
| 1150 | const emitProviderTurn = ( | |
| 1151 | state: OpenCodeThreadState, | |
| 1152 | turn: ActiveOpenCodeTurn, | |
| 1153 | status: OrchestrationV2ProviderTurn["status"], | |
| 1154 | completedAt: DateTime.Utc | null, | |
| 1155 | ) => { | |
| 1156 | const providerTurn: OrchestrationV2ProviderTurn = { | |
| 1157 | ...turn.providerTurn, | |
| 1158 | nativeTurnRef: | |
| 1159 | turn.nativeUserMessageId === null | |
| 1160 | ? turn.providerTurn.nativeTurnRef | |
| 1161 | : providerRef(turn.nativeUserMessageId, "weak"), | |
| 1162 | status, | |
| 1163 | completedAt, | |
| 1164 | ...(completedAt === null | |
| 1165 | ? {} | |
| 1166 | : { | |
| 1167 | turnTokenUsage: | |
| 1168 | turn.usage.partIds.size === 0 | |
| 1169 | ? { | |
| 1170 | usageScope: "main_agent" as const, | |
| 1171 | usageStatus: "unavailable" as const, | |
| 1172 | hasSubagents: turn.usage.hasSubagents, | |
| 1173 | } | |
| 1174 | : { | |
| 1175 | usageScope: "main_agent" as const, | |
| 1176 | usageStatus: | |
| 1177 | status === "completed" && | |
| 1178 | turn.usage.complete && | |
| 1179 | turn.usage.unresolvedStepsByMessageId.size === 0 | |
| 1180 | ? ("complete" as const) | |
| 1181 | : ("partial" as const), | |
| 1182 | inputTokens: turn.usage.inputTokens, | |
| 1183 | cachedInputTokens: turn.usage.cachedInputTokens, | |
| 1184 | cacheCreationTokens: turn.usage.cacheCreationTokens, | |
| 1185 | outputTokens: turn.usage.outputTokens, | |
| 1186 | reasoningTokens: turn.usage.reasoningTokens, | |
| 1187 | hasSubagents: turn.usage.hasSubagents, | |
| 1188 | }, | |
| 1189 | }), | |
| 1190 | }; | |
| 1191 | Object.assign(turn.providerTurn, providerTurn); | |
| 1192 | state.providerTurns.set(String(providerTurn.id), providerTurn); | |
| 1193 | return emitProviderEvent({ | |
| 1194 | type: "provider_turn.updated", | |
| 1195 | driver: OPENCODE_PROVIDER, | |
| 1196 | threadId: turn.threadId, | |
| 1197 | providerTurn, | |
| 1198 | }); | |
| 1199 | }; | |
| 1200 | ||
| 1201 | const emitTextPart = Effect.fnUntraced(function* ( | |
| 1202 | state: OpenCodeThreadState, | |
| 1203 | turn: ActiveOpenCodeTurn, | |
| 1204 | part: Extract<OpenCodePart, { type: "text" | "reasoning" }>, | |
| 1205 | forceCompleted = false, | |
| 1206 | ) { | |
| 1207 | if (part.type === "text" && (part.ignored === true || part.synthetic === true)) return; | |
| 1208 | if (part.text.length === 0) return; | |
| 1209 | const emittedAt = yield* DateTime.now; | |
| 1210 | const isCompleted = forceCompleted || part.time?.end !== undefined; | |
| 1211 | const startedAt = dateTimeFromEpoch(part.time?.start, emittedAt); | |
| 1212 | const completedAt = isCompleted ? dateTimeFromEpoch(part.time?.end, emittedAt) : null; | |
| 1213 | const nativeItemRef = providerRef(part.id); | |
| 1214 | const nodeId = idAllocator.derive.nodeFromProviderItem({ | |
| 1215 | driver: OPENCODE_PROVIDER, | |
| 1216 | nativeItemId: part.id, | |
| 1217 | }); | |
| 1218 | const turnItemId = idAllocator.derive.turnItemFromProviderItem({ | |
| 1219 | driver: OPENCODE_PROVIDER, | |
| 1220 | nativeItemId: part.id, | |
| 1221 | }); | |
| 1222 | const ordinal = itemOrdinal(turn, part.id); | |
| 1223 | yield* emitProviderEvent({ | |
| 1224 | type: "node.updated", | |
| 1225 | driver: OPENCODE_PROVIDER, | |
| 1226 | node: { | |
| 1227 | id: nodeId, | |
| 1228 | threadId: turn.threadId, | |
| 1229 | runId: turn.runId, | |
| 1230 | parentNodeId: turn.rootNodeId, | |
| 1231 | rootNodeId: turn.rootNodeId, | |
| 1232 | kind: part.type === "text" ? "assistant_message" : "reasoning", | |
| 1233 | status: isCompleted ? "completed" : "running", | |
| 1234 | countsForRun: false, | |
| 1235 | providerThreadId: state.providerThread.id, | |
| 1236 | providerTurnId: turn.providerTurnId, | |
| 1237 | nativeItemRef, | |
| 1238 | runtimeRequestId: null, | |
| 1239 | checkpointScopeId: null, | |
| 1240 | startedAt, | |
| 1241 | completedAt, | |
| 1242 | }, | |
| 1243 | }); | |
| 1244 | if (part.type === "text") { | |
| 1245 | const messageId = idAllocator.derive.messageFromProviderItem({ | |
| 1246 | driver: OPENCODE_PROVIDER, | |
| 1247 | nativeItemId: part.id, | |
| 1248 | }); | |
| 1249 | const message: OrchestrationV2ConversationMessage = { | |
| 1250 | createdBy: "agent", | |
| 1251 | creationSource: "provider", | |
| 1252 | id: messageId, | |
| 1253 | threadId: turn.threadId, | |
| 1254 | runId: turn.runId, | |
| 1255 | nodeId, | |
| 1256 | role: "assistant", | |
| 1257 | text: part.text, | |
| 1258 | attachments: [], | |
| 1259 | streaming: !isCompleted, | |
| 1260 | createdAt: startedAt, | |
| 1261 | updatedAt: emittedAt, | |
| 1262 | }; | |
| 1263 | state.messages.set(String(message.id), message); | |
| 1264 | yield* emitProviderEvent({ | |
| 1265 | type: "message.updated", | |
| 1266 | driver: OPENCODE_PROVIDER, | |
| 1267 | message, | |
| 1268 | }); | |
| 1269 | yield* emitProviderEvent({ | |
| 1270 | type: "turn_item.updated", | |
| 1271 | driver: OPENCODE_PROVIDER, | |
| 1272 | turnItem: { | |
| 1273 | id: turnItemId, | |
| 1274 | threadId: turn.threadId, | |
| 1275 | runId: turn.runId, | |
| 1276 | nodeId, | |
| 1277 | providerThreadId: state.providerThread.id, | |
| 1278 | providerTurnId: turn.providerTurnId, | |
| 1279 | nativeItemRef, | |
| 1280 | parentItemId: null, | |
| 1281 | ordinal, | |
| 1282 | status: isCompleted ? "completed" : "running", | |
| 1283 | title: null, | |
| 1284 | startedAt, | |
| 1285 | completedAt, | |
| 1286 | updatedAt: emittedAt, | |
| 1287 | type: "assistant_message", | |
| 1288 | messageId, | |
| 1289 | text: part.text, | |
| 1290 | streaming: !isCompleted, | |
| 1291 | }, | |
| 1292 | }); | |
| 1293 | return; | |
| 1294 | } | |
| 1295 | yield* emitProviderEvent({ | |
| 1296 | type: "turn_item.updated", | |
| 1297 | driver: OPENCODE_PROVIDER, | |
| 1298 | turnItem: { | |
| 1299 | id: turnItemId, | |
| 1300 | threadId: turn.threadId, | |
| 1301 | runId: turn.runId, | |
| 1302 | nodeId, | |
| 1303 | providerThreadId: state.providerThread.id, | |
| 1304 | providerTurnId: turn.providerTurnId, | |
| 1305 | nativeItemRef, | |
| 1306 | parentItemId: null, | |
| 1307 | ordinal, | |
| 1308 | status: isCompleted ? "completed" : "running", | |
| 1309 | title: null, | |
| 1310 | startedAt, | |
| 1311 | completedAt, | |
| 1312 | updatedAt: emittedAt, | |
| 1313 | type: "reasoning", | |
| 1314 | text: part.text, | |
| 1315 | streaming: !isCompleted, | |
| 1316 | }, | |
| 1317 | }); | |
| 1318 | }); | |
| 1319 | ||
| 1320 | const emitSubagent = Effect.fnUntraced(function* ( | |
| 1321 | state: OpenCodeThreadState, | |
| 1322 | turn: ActiveOpenCodeTurn, | |
| 1323 | part: ToolPart, | |
| 1324 | ) { | |
| 1325 | const now = yield* DateTime.now; | |
| 1326 | const nativeItemRef = providerRef(part.id); | |
| 1327 | const nodeId = idAllocator.derive.nodeFromProviderItem({ | |
| 1328 | driver: OPENCODE_PROVIDER, | |
| 1329 | nativeItemId: part.id, | |
| 1330 | }); | |
| 1331 | const turnItemId = idAllocator.derive.turnItemFromProviderItem({ | |
| 1332 | driver: OPENCODE_PROVIDER, | |
| 1333 | nativeItemId: part.id, | |
| 1334 | }); | |
| 1335 | turn.usage.hasSubagents = true; | |
| 1336 | const input = toolInput(part); | |
| 1337 | const prompt = recordString(input, "prompt") ?? ""; | |
| 1338 | const title = toolTitle(part) ?? recordString(input, "description") ?? null; | |
| 1339 | let context = subagentsByNativeItemId.get(part.id); | |
| 1340 | if (context === undefined) { | |
| 1341 | context = { | |
| 1342 | nativeItemId: part.id, | |
| 1343 | nodeId, | |
| 1344 | parentState: state, | |
| 1345 | parentTurn: turn, | |
| 1346 | prompt, | |
| 1347 | title, | |
| 1348 | startedAt: toolStartedAt(part, now), | |
| 1349 | childSessionId: null, | |
| 1350 | childThreadId: null, | |
| 1351 | childProviderThreadId: null, | |
| 1352 | model: null, | |
| 1353 | result: null, | |
| 1354 | }; | |
| 1355 | subagentsByNativeItemId.set(part.id, context); | |
| 1356 | } | |
| 1357 | context.model = toolModel(part) ?? context.model; | |
| 1358 | const childSessionId = taskSessionId(part); | |
| 1359 | if (childSessionId !== null && context.childSessionId === null) { | |
| 1360 | context.childSessionId = childSessionId; | |
| 1361 | context.childThreadId = idAllocator.derive.threadFromProviderThread({ | |
| 1362 | driver: OPENCODE_PROVIDER, | |
| 1363 | nativeThreadId: childSessionId, | |
| 1364 | }); | |
| 1365 | context.childProviderThreadId = idAllocator.derive.providerThread({ | |
| 1366 | driver: OPENCODE_PROVIDER, | |
| 1367 | nativeThreadId: childSessionId, | |
| 1368 | }); | |
| 1369 | subagentsByChildSessionId.set(childSessionId, context); | |
| 1370 | relatedSessionOwners.set(childSessionId, state); | |
| 1371 | const childModelSelection: ModelSelection = { | |
| 1372 | instanceId: options.instanceId, | |
| 1373 | model: context.model ?? turn.modelSelection.model, | |
| 1374 | }; | |
| 1375 | const childThread = makeSubagentChildThread({ | |
| 1376 | parentThread: turn.appThread, | |
| 1377 | childThreadId: context.childThreadId, | |
| 1378 | parentNodeId: nodeId, | |
| 1379 | activeProviderThreadId: context.childProviderThreadId, | |
| 1380 | providerInstanceId: options.instanceId, | |
| 1381 | modelSelection: childModelSelection, | |
| 1382 | title: subagentThreadTitle({ | |
| 1383 | parentTitle: turn.appThread.title, | |
| 1384 | title, | |
| 1385 | prompt, | |
| 1386 | ordinal: itemOrdinal(turn, part.id), | |
| 1387 | }), | |
| 1388 | now, | |
| 1389 | createdBy: "agent", | |
| 1390 | creationSource: "provider", | |
| 1391 | }); | |
| 1392 | const childProviderThread: OrchestrationV2ProviderThread = { | |
| 1393 | id: context.childProviderThreadId, | |
| 1394 | driver: OPENCODE_PROVIDER, | |
| 1395 | providerInstanceId: options.instanceId, | |
| 1396 | providerSessionId: inputProviderSessionId, | |
| 1397 | appThreadId: context.childThreadId, | |
| 1398 | ownerNodeId: nodeId, | |
| 1399 | nativeThreadRef: providerRef(childSessionId), | |
| 1400 | nativeConversationHeadRef: null, | |
| 1401 | status: "active", | |
| 1402 | firstRunOrdinal: null, | |
| 1403 | lastRunOrdinal: null, | |
| 1404 | handoffIds: [], | |
| 1405 | forkedFrom: null, | |
| 1406 | createdAt: now, | |
| 1407 | updatedAt: now, | |
| 1408 | }; | |
| 1409 | const childSessionResponse = yield* sdkCall( | |
| 1410 | "session.get", | |
| 1411 | { sessionID: childSessionId }, | |
| 1412 | () => client.session.get({ sessionID: childSessionId }), | |
| 1413 | ); | |
| 1414 | const nativeChildSession = unwrapData("session.get", childSessionResponse); | |
| 1415 | const childPermission = openCodeChildPermissionRules( | |
| 1416 | turn.runtimePolicy, | |
| 1417 | nativeChildSession.permission ?? [], | |
| 1418 | ); | |
| 1419 | yield* sdkCall( | |
| 1420 | "session.update", | |
| 1421 | { sessionID: childSessionId, permission: childPermission }, | |
| 1422 | () => | |
| 1423 | client.session.update({ | |
| 1424 | sessionID: childSessionId, | |
| 1425 | permission: childPermission, | |
| 1426 | }), | |
| 1427 | ); | |
| 1428 | threads.set(childSessionId, { | |
| 1429 | nativeSessionId: childSessionId, | |
| 1430 | providerThread: childProviderThread, | |
| 1431 | appThread: childThread, | |
| 1432 | activeTurn: null, | |
| 1433 | providerTurns: new Map(), | |
| 1434 | messages: new Map(), | |
| 1435 | runtimeRequests: new Map(), | |
| 1436 | messageRoles: new Map(), | |
| 1437 | userMessageIds: [], | |
| 1438 | parentSubagent: context, | |
| 1439 | nextChildTurnOrdinal: 1, | |
| 1440 | nextAdmissionGeneration: 1, | |
| 1441 | }); | |
| 1442 | yield* emitProviderEvent({ | |
| 1443 | type: "app_thread.created", | |
| 1444 | driver: OPENCODE_PROVIDER, | |
| 1445 | appThread: childThread, | |
| 1446 | }); | |
| 1447 | yield* emitProviderEvent({ | |
| 1448 | type: "provider_thread.updated", | |
| 1449 | driver: OPENCODE_PROVIDER, | |
| 1450 | providerThread: childProviderThread, | |
| 1451 | }); | |
| 1452 | } | |
| 1453 | const output = toolOutput(part); | |
| 1454 | if (part.state.status === "completed" && output !== undefined) context.result = output; | |
| 1455 | const status = toolStatus(part); | |
| 1456 | const completedAt = toolCompletedAt(part, now); | |
| 1457 | const subagentStatus: OrchestrationV2Subagent["status"] = | |
| 1458 | status.item === "failed" | |
| 1459 | ? "failed" | |
| 1460 | : status.item === "completed" | |
| 1461 | ? "completed" | |
| 1462 | : status.item === "pending" | |
| 1463 | ? "pending" | |
| 1464 | : "running"; | |
| 1465 | const subagent: OrchestrationV2Subagent = { | |
| 1466 | id: nodeId, | |
| 1467 | threadId: turn.threadId, | |
| 1468 | runId: turn.runId, | |
| 1469 | parentNodeId: turn.rootNodeId, | |
| 1470 | origin: "provider_native", | |
| 1471 | createdBy: "agent", | |
| 1472 | driver: OPENCODE_PROVIDER, | |
| 1473 | providerInstanceId: options.instanceId, | |
| 1474 | providerThreadId: context.childProviderThreadId, | |
| 1475 | childThreadId: context.childThreadId, | |
| 1476 | nativeTaskRef: nativeItemRef, | |
| 1477 | prompt, | |
| 1478 | title, | |
| 1479 | model: context.model, | |
| 1480 | status: subagentStatus, | |
| 1481 | result: context.result, | |
| 1482 | startedAt: context.startedAt, | |
| 1483 | completedAt, | |
| 1484 | updatedAt: now, | |
| 1485 | }; | |
| 1486 | yield* emitProviderEvent({ | |
| 1487 | type: "node.updated", | |
| 1488 | driver: OPENCODE_PROVIDER, | |
| 1489 | node: { | |
| 1490 | id: nodeId, | |
| 1491 | threadId: turn.threadId, | |
| 1492 | runId: turn.runId, | |
| 1493 | parentNodeId: turn.rootNodeId, | |
| 1494 | rootNodeId: turn.rootNodeId, | |
| 1495 | kind: "subagent", | |
| 1496 | status: status.node, | |
| 1497 | countsForRun: false, | |
| 1498 | providerThreadId: context.childProviderThreadId ?? state.providerThread.id, | |
| 1499 | providerTurnId: turn.providerTurnId, | |
| 1500 | nativeItemRef, | |
| 1501 | runtimeRequestId: null, | |
| 1502 | checkpointScopeId: null, | |
| 1503 | startedAt: context.startedAt, | |
| 1504 | completedAt, | |
| 1505 | }, | |
| 1506 | }); | |
| 1507 | yield* emitProviderEvent({ | |
| 1508 | type: "subagent.updated", | |
| 1509 | driver: OPENCODE_PROVIDER, | |
| 1510 | subagent, | |
| 1511 | }); | |
| 1512 | yield* emitProviderEvent({ | |
| 1513 | type: "turn_item.updated", | |
| 1514 | driver: OPENCODE_PROVIDER, | |
| 1515 | turnItem: { | |
| 1516 | id: turnItemId, | |
| 1517 | threadId: turn.threadId, | |
| 1518 | runId: turn.runId, | |
| 1519 | nodeId, | |
| 1520 | providerThreadId: state.providerThread.id, | |
| 1521 | providerTurnId: turn.providerTurnId, | |
| 1522 | nativeItemRef, | |
| 1523 | parentItemId: null, | |
| 1524 | ordinal: itemOrdinal(turn, part.id), | |
| 1525 | status: status.item, | |
| 1526 | title, | |
| 1527 | startedAt: context.startedAt, | |
| 1528 | completedAt, | |
| 1529 | updatedAt: now, | |
| 1530 | type: "subagent", | |
| 1531 | subagentId: nodeId, | |
| 1532 | origin: "provider_native", | |
| 1533 | driver: OPENCODE_PROVIDER, | |
| 1534 | providerInstanceId: options.instanceId, | |
| 1535 | childThreadId: context.childThreadId, | |
| 1536 | prompt, | |
| 1537 | result: context.result, | |
| 1538 | }, | |
| 1539 | }); | |
| 1540 | }); | |
| 1541 | ||
| 1542 | const emitToolPart = Effect.fnUntraced(function* ( | |
| 1543 | state: OpenCodeThreadState, | |
| 1544 | turn: ActiveOpenCodeTurn, | |
| 1545 | part: ToolPart, | |
| 1546 | ) { | |
| 1547 | const normalizedTool = part.tool.toLowerCase(); | |
| 1548 | if (normalizedTool === "task") { | |
| 1549 | yield* emitSubagent(state, turn, part); | |
| 1550 | return; | |
| 1551 | } | |
| 1552 | // question.asked carries the respondable semantic item. Projecting | |
| 1553 | // the implementation tool as well would duplicate it in the UI. | |
| 1554 | if (normalizedTool === "question") return; | |
| 1555 | const now = yield* DateTime.now; | |
| 1556 | const status = toolStatus(part); | |
| 1557 | const startedAt = toolStartedAt(part, now); | |
| 1558 | const completedAt = toolCompletedAt(part, now); | |
| 1559 | const nativeItemRef = providerRef(part.id); | |
| 1560 | const nodeId = idAllocator.derive.nodeFromProviderItem({ | |
| 1561 | driver: OPENCODE_PROVIDER, | |
| 1562 | nativeItemId: part.id, | |
| 1563 | }); | |
| 1564 | const turnItemId = idAllocator.derive.turnItemFromProviderItem({ | |
| 1565 | driver: OPENCODE_PROVIDER, | |
| 1566 | nativeItemId: part.id, | |
| 1567 | }); | |
| 1568 | const base = { | |
| 1569 | id: turnItemId, | |
| 1570 | threadId: turn.threadId, | |
| 1571 | runId: turn.runId, | |
| 1572 | nodeId, | |
| 1573 | providerThreadId: state.providerThread.id, | |
| 1574 | providerTurnId: turn.providerTurnId, | |
| 1575 | nativeItemRef, | |
| 1576 | parentItemId: null, | |
| 1577 | ordinal: itemOrdinal(turn, part.id), | |
| 1578 | status: status.item, | |
| 1579 | title: toolTitle(part), | |
| 1580 | startedAt, | |
| 1581 | completedAt, | |
| 1582 | updatedAt: now, | |
| 1583 | } satisfies Pick< | |
| 1584 | OrchestrationV2TurnItem, | |
| 1585 | | "id" | |
| 1586 | | "threadId" | |
| 1587 | | "runId" | |
| 1588 | | "nodeId" | |
| 1589 | | "providerThreadId" | |
| 1590 | | "providerTurnId" | |
| 1591 | | "nativeItemRef" | |
| 1592 | | "parentItemId" | |
| 1593 | | "ordinal" | |
| 1594 | | "status" | |
| 1595 | | "title" | |
| 1596 | | "startedAt" | |
| 1597 | | "completedAt" | |
| 1598 | | "updatedAt" | |
| 1599 | >; | |
| 1600 | const input = toolInput(part); | |
| 1601 | const output = toolOutput(part); | |
| 1602 | const isNativeTool = part.tool === "code_search" || part.tool === "apply_patch"; | |
| 1603 | if (!isNativeTool && part.tool.includes("_") && turn.mcpServerNames === undefined) { | |
| 1604 | const serverNames = yield* OpenCodeRuntime.runOpenCodeSdk("mcp.status", (signal) => | |
| 1605 | client.mcp.status(undefined, { signal, throwOnError: true }), | |
| 1606 | ).pipe( | |
| 1607 | Effect.timeout("1 second"), | |
| 1608 | Effect.map((response) => Object.keys(response.data ?? {})), | |
| 1609 | Effect.catch(() => Effect.succeed(undefined)), | |
| 1610 | ); | |
| 1611 | if (serverNames !== undefined) turn.mcpServerNames = serverNames; | |
| 1612 | } | |
| 1613 | const matchingServers = turn.mcpServerNames?.filter( | |
| 1614 | (name) => | |
| 1615 | !isNativeTool && part.tool.startsWith(`${name.replace(/[^a-zA-Z0-9_-]/g, "_")}_`), | |
| 1616 | ); | |
| 1617 | const serverName = matchingServers?.length === 1 ? matchingServers[0] : undefined; | |
| 1618 | const presentation = | |
| 1619 | serverName === undefined | |
| 1620 | ? {} | |
| 1621 | : mcpToolPresentation({ | |
| 1622 | serverName, | |
| 1623 | toolName: part.tool.slice(serverName.replace(/[^a-zA-Z0-9_-]/g, "_").length + 1), | |
| 1624 | title: toolTitle(part) === part.tool ? undefined : toolTitle(part), | |
| 1625 | }); | |
| 1626 | const turnItem: OrchestrationV2TurnItem = matchingServers?.length | |
| 1627 | ? { | |
| 1628 | ...base, | |
| 1629 | type: "dynamic_tool", | |
| 1630 | ...presentation, | |
| 1631 | toolName: part.tool, | |
| 1632 | input, | |
| 1633 | ...(output === undefined ? {} : { output }), | |
| 1634 | } | |
| 1635 | : openCodeToolTurnItem(base, { | |
| 1636 | name: part.tool, | |
| 1637 | input, | |
| 1638 | output, | |
| 1639 | completedMetadata: | |
| 1640 | part.state.status === "completed" ? part.state.metadata : undefined, | |
| 1641 | }); | |
| 1642 | yield* emitProviderEvent({ | |
| 1643 | type: "node.updated", | |
| 1644 | driver: OPENCODE_PROVIDER, | |
| 1645 | node: { | |
| 1646 | id: nodeId, | |
| 1647 | threadId: turn.threadId, | |
| 1648 | runId: turn.runId, | |
| 1649 | parentNodeId: turn.rootNodeId, | |
| 1650 | rootNodeId: turn.rootNodeId, | |
| 1651 | kind: "tool_call", | |
| 1652 | status: status.node, | |
| 1653 | countsForRun: false, | |
| 1654 | providerThreadId: state.providerThread.id, | |
| 1655 | providerTurnId: turn.providerTurnId, | |
| 1656 | nativeItemRef, | |
| 1657 | runtimeRequestId: null, | |
| 1658 | checkpointScopeId: null, | |
| 1659 | startedAt, | |
| 1660 | completedAt, | |
| 1661 | }, | |
| 1662 | }); | |
| 1663 | yield* emitProviderEvent({ | |
| 1664 | type: "turn_item.updated", | |
| 1665 | driver: OPENCODE_PROVIDER, | |
| 1666 | turnItem, | |
| 1667 | }); | |
| 1668 | }); | |
| 1669 | ||
| 1670 | const emitTodo = Effect.fnUntraced(function* ( | |
| 1671 | state: OpenCodeThreadState, | |
| 1672 | turn: ActiveOpenCodeTurn, | |
| 1673 | todos: ReadonlyArray<OpenCodeTodo>, | |
| 1674 | ) { | |
| 1675 | const now = yield* DateTime.now; | |
| 1676 | if (turn.planId === null) { | |
| 1677 | turn.planId = yield* idAllocator.allocate.plan({ | |
| 1678 | threadId: turn.threadId, | |
| 1679 | ...(turn.runId === null ? {} : { runId: turn.runId }), | |
| 1680 | driver: OPENCODE_PROVIDER, | |
| 1681 | }); | |
| 1682 | } | |
| 1683 | const planId = turn.planId; | |
| 1684 | const nativeItemId = `${state.nativeSessionId}:todo:${turn.providerTurnId}`; | |
| 1685 | const nodeId = idAllocator.derive.nodeFromProviderItem({ | |
| 1686 | driver: OPENCODE_PROVIDER, | |
| 1687 | nativeItemId, | |
| 1688 | }); | |
| 1689 | const turnItemId = idAllocator.derive.turnItemFromProviderItem({ | |
| 1690 | driver: OPENCODE_PROVIDER, | |
| 1691 | nativeItemId, | |
| 1692 | }); | |
| 1693 | const steps: Array<OrchestrationV2PlanStep> = todos.map((todo, index) => ({ | |
| 1694 | id: `${nativeItemId}:${index + 1}`, | |
| 1695 | text: todo.content.trim() || `Todo ${index + 1}`, | |
| 1696 | status: | |
| 1697 | todo.status === "completed" | |
| 1698 | ? "completed" | |
| 1699 | : todo.status === "in_progress" | |
| 1700 | ? "running" | |
| 1701 | : "pending", | |
| 1702 | })); | |
| 1703 | const completed = steps.length > 0 && steps.every((step) => step.status === "completed"); | |
| 1704 | yield* emitProviderEvent({ | |
| 1705 | type: "node.updated", | |
| 1706 | driver: OPENCODE_PROVIDER, | |
| 1707 | node: { | |
| 1708 | id: nodeId, | |
| 1709 | threadId: turn.threadId, | |
| 1710 | runId: turn.runId, | |
| 1711 | parentNodeId: turn.rootNodeId, | |
| 1712 | rootNodeId: turn.rootNodeId, | |
| 1713 | kind: "todo_list", | |
| 1714 | status: completed ? "completed" : "running", | |
| 1715 | countsForRun: false, | |
| 1716 | providerThreadId: state.providerThread.id, | |
| 1717 | providerTurnId: turn.providerTurnId, | |
| 1718 | nativeItemRef: providerRef(nativeItemId, "weak"), | |
| 1719 | runtimeRequestId: null, | |
| 1720 | checkpointScopeId: null, | |
| 1721 | startedAt: turn.startedAt, | |
| 1722 | completedAt: completed ? now : null, | |
| 1723 | }, | |
| 1724 | }); | |
| 1725 | yield* emitProviderEvent({ | |
| 1726 | type: "plan.updated", | |
| 1727 | driver: OPENCODE_PROVIDER, | |
| 1728 | plan: { | |
| 1729 | id: planId, | |
| 1730 | threadId: turn.threadId, | |
| 1731 | runId: turn.runId, | |
| 1732 | nodeId, | |
| 1733 | status: completed ? "completed" : "active", | |
| 1734 | kind: "todo_list", | |
| 1735 | steps, | |
| 1736 | }, | |
| 1737 | }); | |
| 1738 | yield* emitProviderEvent({ | |
| 1739 | type: "turn_item.updated", | |
| 1740 | driver: OPENCODE_PROVIDER, | |
| 1741 | turnItem: { | |
| 1742 | id: turnItemId, | |
| 1743 | threadId: turn.threadId, | |
| 1744 | runId: turn.runId, | |
| 1745 | nodeId, | |
| 1746 | providerThreadId: state.providerThread.id, | |
| 1747 | providerTurnId: turn.providerTurnId, | |
| 1748 | nativeItemRef: providerRef(nativeItemId, "weak"), | |
| 1749 | parentItemId: null, | |
| 1750 | ordinal: itemOrdinal(turn, nativeItemId), | |
| 1751 | status: completed ? "completed" : "running", | |
| 1752 | title: "Todo list", | |
| 1753 | startedAt: turn.startedAt, | |
| 1754 | completedAt: completed ? now : null, | |
| 1755 | updatedAt: now, | |
| 1756 | type: "todo_list", | |
| 1757 | planId, | |
| 1758 | steps, | |
| 1759 | }, | |
| 1760 | }); | |
| 1761 | }); | |
| 1762 | ||
| 1763 | const requestQuestions = (request: QuestionRequest) => | |
| 1764 | request.questions.map((question, index) => ({ | |
| 1765 | id: OpenCodeRuntime.openCodeQuestionId(index, question), | |
| 1766 | header: question.header.trim() || `Question ${index + 1}`, | |
| 1767 | question: question.question.trim() || question.header.trim() || `Question ${index + 1}`, | |
| 1768 | options: question.options.map((option) => ({ | |
| 1769 | label: option.label.trim() || "Option", | |
| 1770 | description: option.description.trim() || option.label.trim() || "Option", | |
| 1771 | })), | |
| 1772 | })); | |
| 1773 | ||
| 1774 | const runtimeRequestTurnItem = ( | |
| 1775 | pending: PendingOpenCodeRequest, | |
| 1776 | status: OrchestrationV2TurnItem["status"], | |
| 1777 | completedAt: DateTime.Utc | null, | |
| 1778 | updatedAt: DateTime.Utc, | |
| 1779 | ): OrchestrationV2TurnItem => { | |
| 1780 | const base = { | |
| 1781 | id: pending.turnItemId, | |
| 1782 | threadId: pending.turn.threadId, | |
| 1783 | runId: pending.turn.runId, | |
| 1784 | nodeId: pending.nodeId, | |
| 1785 | providerThreadId: pending.state.providerThread.id, | |
| 1786 | providerTurnId: pending.turn.providerTurnId, | |
| 1787 | nativeItemRef: providerRef(pending.nativeRequestId), | |
| 1788 | parentItemId: null, | |
| 1789 | ordinal: itemOrdinal(pending.turn, pending.nativeRequestId), | |
| 1790 | status, | |
| 1791 | startedAt: pending.createdAt, | |
| 1792 | completedAt, | |
| 1793 | updatedAt, | |
| 1794 | }; | |
| 1795 | if (pending.question !== undefined) { | |
| 1796 | return { | |
| 1797 | ...base, | |
| 1798 | title: "User input", | |
| 1799 | type: "user_input_request", | |
| 1800 | requestId: pending.requestId, | |
| 1801 | questions: requestQuestions(pending.question), | |
| 1802 | }; | |
| 1803 | } | |
| 1804 | const permission = pending.permission; | |
| 1805 | if (permission === undefined) { | |
| 1806 | throw protocolError(`OpenCode request ${pending.requestId} has no native payload`); | |
| 1807 | } | |
| 1808 | return { | |
| 1809 | ...base, | |
| 1810 | title: permission.permission, | |
| 1811 | type: "approval_request", | |
| 1812 | requestId: pending.requestId, | |
| 1813 | requestKind: pending.requestKind === "user_input" ? "command" : pending.requestKind, | |
| 1814 | prompt: | |
| 1815 | permission.patterns.length === 0 | |
| 1816 | ? permission.permission | |
| 1817 | : permission.patterns.join("\n"), | |
| 1818 | }; | |
| 1819 | }; | |
| 1820 | ||
| 1821 | const emitRuntimeRequest = Effect.fnUntraced(function* ( | |
| 1822 | owner: OpenCodeRequestOwner, | |
| 1823 | nativeRequestId: string, | |
| 1824 | request: | |
| 1825 | | { readonly type: "permission"; readonly value: PermissionRequest } | |
| 1826 | | { readonly type: "question"; readonly value: QuestionRequest }, | |
| 1827 | ) { | |
| 1828 | if (pendingRequestsByNativeId.has(nativeRequestId)) return; | |
| 1829 | const { state, turn, subagent } = owner; | |
| 1830 | const now = yield* DateTime.now; | |
| 1831 | const requestId = yield* idAllocator.allocate.runtimeRequest({ | |
| 1832 | driver: OPENCODE_PROVIDER, | |
| 1833 | providerTurnId: turn.providerTurnId, | |
| 1834 | nativeRequestId, | |
| 1835 | }); | |
| 1836 | const nodeId = idAllocator.derive.approvalNode({ requestId }); | |
| 1837 | const turnItemId = idAllocator.derive.approvalTurnItem({ requestId }); | |
| 1838 | // The tool call belongs to the asking session's turn, not the owner's. | |
| 1839 | const permissionToolName = | |
| 1840 | request.type === "permission" && request.value.tool !== undefined | |
| 1841 | ? threads | |
| 1842 | .get(request.value.sessionID) | |
| 1843 | ?.activeTurn?.toolNamesByCallId.get(request.value.tool.callID) | |
| 1844 | : undefined; | |
| 1845 | const permissionRequestKind = | |
| 1846 | request.type === "permission" | |
| 1847 | ? openCodePermissionRequestKind(request.value.permission, permissionToolName) | |
| 1848 | : undefined; | |
| 1849 | const requestKind: OrchestrationV2RuntimeRequest["kind"] = | |
| 1850 | permissionRequestKind ?? "user_input"; | |
| 1851 | const pending: PendingOpenCodeRequest = { | |
| 1852 | requestId, | |
| 1853 | nativeRequestId, | |
| 1854 | nativeSessionId: request.value.sessionID, | |
| 1855 | turn, | |
| 1856 | state, | |
| 1857 | nodeId, | |
| 1858 | parentNodeId: subagent?.nodeId ?? turn.rootNodeId, | |
| 1859 | turnItemId, | |
| 1860 | requestKind, | |
| 1861 | createdAt: now, | |
| 1862 | ...(request.type === "permission" | |
| 1863 | ? { permission: request.value } | |
| 1864 | : { question: request.value }), | |
| 1865 | }; | |
| 1866 | pendingRequests.set(String(requestId), pending); | |
| 1867 | pendingRequestsByNativeId.set(nativeRequestId, pending); | |
| 1868 | const runtimeRequest: OrchestrationV2RuntimeRequest = { | |
| 1869 | id: requestId, | |
| 1870 | nodeId, | |
| 1871 | providerTurnId: turn.providerTurnId, | |
| 1872 | nativeRequestRef: providerRef(nativeRequestId), | |
| 1873 | kind: requestKind, | |
| 1874 | status: "pending", | |
| 1875 | responseCapability: { | |
| 1876 | type: "live", | |
| 1877 | providerSessionId: inputProviderSessionId, | |
| 1878 | }, | |
| 1879 | createdAt: now, | |
| 1880 | resolvedAt: null, | |
| 1881 | }; | |
| 1882 | state.runtimeRequests.set(String(requestId), runtimeRequest); | |
| 1883 | yield* emitProviderEvent({ | |
| 1884 | type: "node.updated", | |
| 1885 | driver: OPENCODE_PROVIDER, | |
| 1886 | node: { | |
| 1887 | id: nodeId, | |
| 1888 | threadId: turn.threadId, | |
| 1889 | runId: turn.runId, | |
| 1890 | parentNodeId: pending.parentNodeId, | |
| 1891 | rootNodeId: turn.rootNodeId, | |
| 1892 | kind: request.type === "question" ? "user_input_request" : "approval_request", | |
| 1893 | status: "waiting", | |
| 1894 | countsForRun: false, | |
| 1895 | providerThreadId: state.providerThread.id, | |
| 1896 | providerTurnId: turn.providerTurnId, | |
| 1897 | nativeItemRef: providerRef(nativeRequestId), | |
| 1898 | runtimeRequestId: requestId, | |
| 1899 | checkpointScopeId: null, | |
| 1900 | startedAt: now, | |
| 1901 | completedAt: null, | |
| 1902 | }, | |
| 1903 | }); | |
| 1904 | yield* emitProviderEvent({ | |
| 1905 | type: "runtime_request.updated", | |
| 1906 | driver: OPENCODE_PROVIDER, | |
| 1907 | threadId: turn.threadId, | |
| 1908 | runtimeRequest, | |
| 1909 | }); | |
| 1910 | yield* emitProviderEvent({ | |
| 1911 | type: "turn_item.updated", | |
| 1912 | driver: OPENCODE_PROVIDER, | |
| 1913 | turnItem: runtimeRequestTurnItem(pending, "waiting", null, now), | |
| 1914 | }); | |
| 1915 | yield* updateProviderSession("waiting", null); | |
| 1916 | }); | |
| 1917 | ||
| 1918 | const resolveRuntimeRequest = Effect.fnUntraced(function* ( | |
| 1919 | nativeRequestId: string, | |
| 1920 | status: "resolved" | "cancelled", | |
| 1921 | ) { | |
| 1922 | const pending = pendingRequestsByNativeId.get(nativeRequestId); | |
| 1923 | if (pending === undefined) return; | |
| 1924 | const now = yield* DateTime.now; | |
| 1925 | const current = pending.state.runtimeRequests.get(String(pending.requestId)); | |
| 1926 | if (current !== undefined) { | |
| 1927 | const resolved: OrchestrationV2RuntimeRequest = { | |
| 1928 | ...current, | |
| 1929 | status, | |
| 1930 | resolvedAt: now, | |
| 1931 | }; | |
| 1932 | pending.state.runtimeRequests.set(String(pending.requestId), resolved); | |
| 1933 | yield* emitProviderEvent({ | |
| 1934 | type: "runtime_request.updated", | |
| 1935 | driver: OPENCODE_PROVIDER, | |
| 1936 | threadId: pending.turn.threadId, | |
| 1937 | runtimeRequest: resolved, | |
| 1938 | }); | |
| 1939 | } | |
| 1940 | yield* emitProviderEvent({ | |
| 1941 | type: "node.updated", | |
| 1942 | driver: OPENCODE_PROVIDER, | |
| 1943 | node: { | |
| 1944 | id: pending.nodeId, | |
| 1945 | threadId: pending.turn.threadId, | |
| 1946 | runId: pending.turn.runId, | |
| 1947 | parentNodeId: pending.parentNodeId, | |
| 1948 | rootNodeId: pending.turn.rootNodeId, | |
| 1949 | kind: pending.question === undefined ? "approval_request" : "user_input_request", | |
| 1950 | status: status === "resolved" ? "completed" : "cancelled", | |
| 1951 | countsForRun: false, | |
| 1952 | providerThreadId: pending.state.providerThread.id, | |
| 1953 | providerTurnId: pending.turn.providerTurnId, | |
| 1954 | nativeItemRef: providerRef(nativeRequestId), | |
| 1955 | runtimeRequestId: pending.requestId, | |
| 1956 | checkpointScopeId: null, | |
| 1957 | startedAt: pending.createdAt, | |
| 1958 | completedAt: now, | |
| 1959 | }, | |
| 1960 | }); | |
| 1961 | yield* emitProviderEvent({ | |
| 1962 | type: "turn_item.updated", | |
| 1963 | driver: OPENCODE_PROVIDER, | |
| 1964 | turnItem: runtimeRequestTurnItem( | |
| 1965 | pending, | |
| 1966 | status === "resolved" ? "completed" : "cancelled", | |
| 1967 | now, | |
| 1968 | now, | |
| 1969 | ), | |
| 1970 | }); | |
| 1971 | pendingRequests.delete(String(pending.requestId)); | |
| 1972 | pendingRequestsByNativeId.delete(nativeRequestId); | |
| 1973 | const hasOtherPending = Array.from(pendingRequests.values()).some( | |
| 1974 | (candidate) => candidate.turn.isRoot, | |
| 1975 | ); | |
| 1976 | if (!hasOtherPending) yield* updateProviderSession("running", null); | |
| 1977 | }); | |
| 1978 | ||
| 1979 | /** Resolve the thread state a session belongs to: its own, or for a | |
| 1980 | * child not registered yet, the nearest known ancestor found by walking | |
| 1981 | * the native parent chain. Registers every hop so later requests from | |
| 1982 | * the same child resolve without another lookup. */ | |
| 1983 | const resolveSessionOwner = Effect.fnUntraced(function* (sessionId: string) { | |
| 1984 | const known = threads.get(sessionId) ?? relatedSessionOwners.get(sessionId); | |
| 1985 | if (known !== undefined) return known; | |
| 1986 | let cursor = sessionId; | |
| 1987 | const hops: string[] = []; | |
| 1988 | for (let depth = 0; depth < 5; depth += 1) { | |
| 1989 | const response = yield* sdkCall("session.get", { sessionID: cursor }, () => | |
| 1990 | client.session.get({ sessionID: cursor }), | |
| 1991 | ).pipe(Effect.option); | |
| 1992 | const info = Option.getOrUndefined(response)?.data; | |
| 1993 | const parentId = info?.parentID; | |
| 1994 | if (parentId === undefined) return undefined; | |
| 1995 | hops.push(cursor); | |
| 1996 | const owner = threads.get(parentId) ?? relatedSessionOwners.get(parentId); | |
| 1997 | if (owner !== undefined) { | |
| 1998 | for (const hop of hops) relatedSessionOwners.set(hop, owner); | |
| 1999 | return owner; | |
| 2000 | } | |
| 2001 | cursor = parentId; | |
| 2002 | } | |
| 2003 | return undefined; | |
| 2004 | }); | |
| 2005 | ||
| 2006 | /** Every request is asked on the top-level thread and its active turn, | |
| 2007 | * under the subagent that leads to the asking session, because native | |
| 2008 | * subagent threads are hidden from the sidebar. A child's request can | |
| 2009 | * arrive before the task part or session.created event that reveals | |
| 2010 | * its relation to a thread. The first resolution attempt runs inline | |
| 2011 | * (the replayable path); if the relation or the owning turn is not | |
| 2012 | * established yet, a short forked backoff keeps trying instead of | |
| 2013 | * dropping the request. */ | |
| 2014 | const routeRuntimeRequest = Effect.fnUntraced(function* ( | |
| 2015 | nativeRequestId: string, | |
| 2016 | sessionId: string, | |
| 2017 | request: | |
| 2018 | | { readonly type: "permission"; readonly value: PermissionRequest } | |
| 2019 | | { readonly type: "question"; readonly value: QuestionRequest }, | |
| 2020 | ) { | |
| 2021 | if (pendingChildRequestRoutes.has(nativeRequestId)) return; | |
| 2022 | const attempt = Effect.gen(function* () { | |
| 2023 | if ( | |
| 2024 | settledNativeRequestIds.has(nativeRequestId) || | |
| 2025 | pendingRequestsByNativeId.has(nativeRequestId) | |
| 2026 | ) { | |
| 2027 | return true; | |
| 2028 | } | |
| 2029 | const state = yield* resolveSessionOwner(sessionId); | |
| 2030 | const owner = state === undefined ? undefined : topLevelRequestOwner(state); | |
| 2031 | if (owner === undefined) return false; | |
| 2032 | yield* emitRuntimeRequest(owner, nativeRequestId, request); | |
| 2033 | return true; | |
| 2034 | }); | |
| 2035 | if (yield* attempt) return; | |
| 2036 | pendingChildRequestRoutes.add(nativeRequestId); | |
| 2037 | yield* Effect.gen(function* () { | |
| 2038 | for (let retry = 0; retry < 5; retry += 1) { | |
| 2039 | yield* Effect.sleep(Duration.millis(Math.min(200 * 2 ** retry, 2_000))); | |
| 2040 | if (yield* attempt) return; | |
| 2041 | } | |
| 2042 | }).pipe( | |
| 2043 | Effect.ensuring(Effect.sync(() => pendingChildRequestRoutes.delete(nativeRequestId))), | |
| 2044 | Effect.forkIn(scope), | |
| 2045 | ); | |
| 2046 | }); | |
| 2047 | ||
| 2048 | const finalizeTurn = Effect.fnUntraced(function* ( | |
| 2049 | state: OpenCodeThreadState, | |
| 2050 | turn: ActiveOpenCodeTurn, | |
| 2051 | status: TerminalTurnStatus, | |
| 2052 | terminal?: { | |
| 2053 | readonly failure?: OrchestrationV2ProviderFailure; | |
| 2054 | readonly threadDisposition?: "reusable" | "broken"; | |
| 2055 | }, | |
| 2056 | ) { | |
| 2057 | if (turn.finalized) return; | |
| 2058 | if (nativeStreamFailure !== null) { | |
| 2059 | status = "failed"; | |
| 2060 | terminal = { failure: nativeStreamFailure, threadDisposition: "broken" }; | |
| 2061 | } | |
| 2062 | turn.finalized = true; | |
| 2063 | const completedAt = yield* DateTime.now; | |
| 2064 | for (const part of turn.parts.values()) { | |
| 2065 | if (part.type === "text" || part.type === "reasoning") { | |
| 2066 | yield* emitTextPart(state, turn, part, true); | |
| 2067 | } | |
| 2068 | } | |
| 2069 | for (const pending of Array.from(pendingRequests.values())) { | |
| 2070 | if ( | |
| 2071 | pending.turn.providerTurnId === turn.providerTurnId || | |
| 2072 | pending.nativeSessionId === state.nativeSessionId | |
| 2073 | ) { | |
| 2074 | yield* resolveRuntimeRequest(pending.nativeRequestId, "cancelled"); | |
| 2075 | } | |
| 2076 | } | |
| 2077 | yield* emitProviderTurn(state, turn, status, completedAt); | |
| 2078 | const threadDisposition = terminal?.threadDisposition ?? "reusable"; | |
| 2079 | yield* updateProviderThread(state, { | |
| 2080 | status: turn.isRoot ? "active" : threadDisposition === "broken" ? "error" : "idle", | |
| 2081 | nativeConversationHeadRef: | |
| 2082 | turn.nativeUserMessageId === null | |
| 2083 | ? state.providerThread.nativeConversationHeadRef | |
| 2084 | : providerRef(turn.nativeUserMessageId, "weak"), | |
| 2085 | }); | |
| 2086 | state.activeTurn = null; | |
| 2087 | if (!turn.isRoot) { | |
| 2088 | yield* emitProviderEvent({ | |
| 2089 | type: "node.updated", | |
| 2090 | driver: OPENCODE_PROVIDER, | |
| 2091 | node: { | |
| 2092 | id: turn.rootNodeId, | |
| 2093 | threadId: turn.threadId, | |
| 2094 | runId: null, | |
| 2095 | parentNodeId: null, | |
| 2096 | rootNodeId: turn.rootNodeId, | |
| 2097 | kind: "root_turn", | |
| 2098 | status, | |
| 2099 | countsForRun: false, | |
| 2100 | providerThreadId: state.providerThread.id, | |
| 2101 | providerTurnId: turn.providerTurnId, | |
| 2102 | nativeItemRef: providerRef(state.nativeSessionId), | |
| 2103 | runtimeRequestId: null, | |
| 2104 | checkpointScopeId: null, | |
| 2105 | startedAt: turn.startedAt, | |
| 2106 | completedAt, | |
| 2107 | }, | |
| 2108 | }); | |
| 2109 | return; | |
| 2110 | } | |
| 2111 | const anotherTurnIsActive = Array.from(threads.values()).some( | |
| 2112 | (candidate) => candidate.activeTurn?.isRoot === true, | |
| 2113 | ); | |
| 2114 | yield* updateProviderSession( | |
| 2115 | anotherTurnIsActive ? "running" : status === "failed" ? "error" : "ready", | |
| 2116 | status === "failed" ? sessionEntity.lastError : null, | |
| 2117 | ); | |
| 2118 | yield* emitProviderEvent( | |
| 2119 | status === "failed" | |
| 2120 | ? { | |
| 2121 | type: "turn.terminal", | |
| 2122 | driver: OPENCODE_PROVIDER, | |
| 2123 | providerThreadId: turn.providerThreadId, | |
| 2124 | providerTurnId: turn.providerTurnId, | |
| 2125 | runOrdinal: turn.runOrdinal, | |
| 2126 | failureItemOrdinal: itemOrdinal(turn, `terminal-failure:${turn.providerTurnId}`), | |
| 2127 | status, | |
| 2128 | failure: | |
| 2129 | terminal?.failure ?? | |
| 2130 | makeProviderFailure({ | |
| 2131 | message: sessionEntity.lastError ?? undefined, | |
| 2132 | class: "provider_error", | |
| 2133 | }), | |
| 2134 | threadDisposition, | |
| 2135 | } | |
| 2136 | : { | |
| 2137 | type: "turn.terminal", | |
| 2138 | driver: OPENCODE_PROVIDER, | |
| 2139 | providerThreadId: turn.providerThreadId, | |
| 2140 | providerTurnId: turn.providerTurnId, | |
| 2141 | runOrdinal: turn.runOrdinal, | |
| 2142 | status, | |
| 2143 | failure: null, | |
| 2144 | threadDisposition, | |
| 2145 | }, | |
| 2146 | ); | |
| 2147 | }); | |
| 2148 | ||
| 2149 | const promptAdmissionIsCurrent = ( | |
| 2150 | state: OpenCodeThreadState, | |
| 2151 | turn: ActiveOpenCodeTurn, | |
| 2152 | generation: number, | |
| 2153 | ) => | |
| 2154 | state.activeTurn === turn && | |
| 2155 | !turn.finalized && | |
| 2156 | !turn.interrupted && | |
| 2157 | turn.admissionPending && | |
| 2158 | turn.admissionGeneration === generation; | |
| 2159 | ||
| 2160 | const runPromptAdmissionReconciliation = Effect.fnUntraced(function* ( | |
| 2161 | state: OpenCodeThreadState, | |
| 2162 | turn: ActiveOpenCodeTurn, | |
| 2163 | generation: number, | |
| 2164 | ) { | |
| 2165 | while (promptAdmissionIsCurrent(state, turn, generation)) { | |
| 2166 | const status = yield* reconcileOpenCodePromptAdmissionStatus( | |
| 2167 | turn, | |
| 2168 | generation, | |
| 2169 | sdkCall("session.status", { sessionID: state.nativeSessionId, generation }, () => | |
| 2170 | client.session.status(), | |
| 2171 | ).pipe( | |
| 2172 | Effect.match({ | |
| 2173 | onFailure: () => "unknown" as const, | |
| 2174 | onSuccess: (response) => { | |
| 2175 | const statuses = unwrapData("session.status", response); | |
| 2176 | const sessionStatus = statuses[state.nativeSessionId]; | |
| 2177 | return sessionStatus === undefined || sessionStatus.type === "idle" | |
| 2178 | ? ("idle" as const) | |
| 2179 | : ("busy" as const); | |
| 2180 | }, | |
| 2181 | }), | |
| 2182 | ), | |
| 2183 | ); | |
| 2184 | if ( | |
| 2185 | state.activeTurn !== turn || | |
| 2186 | status === "stale" || | |
| 2187 | turn.finalized || | |
| 2188 | turn.interrupted || | |
| 2189 | turn.admissionGeneration !== generation | |
| 2190 | ) { | |
| 2191 | return; | |
| 2192 | } | |
| 2193 | if (status === "idle") { | |
| 2194 | yield* finalizeTurn(state, turn, "completed"); | |
| 2195 | return; | |
| 2196 | } | |
| 2197 | if (status === "busy") return; | |
| 2198 | yield* Effect.sleep("250 millis"); | |
| 2199 | } | |
| 2200 | }); | |
| 2201 | ||
| 2202 | const reconcilePromptAdmission = Effect.fnUntraced(function* ( | |
| 2203 | state: OpenCodeThreadState, | |
| 2204 | turn: ActiveOpenCodeTurn, | |
| 2205 | ) { | |
| 2206 | const generation = turn.admissionGeneration; | |
| 2207 | if ( | |
| 2208 | !promptAdmissionIsCurrent(state, turn, generation) || | |
| 2209 | turn.admissionReconciliationGeneration === generation | |
| 2210 | ) { | |
| 2211 | return; | |
| 2212 | } | |
| 2213 | turn.admissionReconciliationGeneration = generation; | |
| 2214 | yield* runPromptAdmissionReconciliation(state, turn, generation).pipe( | |
| 2215 | Effect.ensuring( | |
| 2216 | Effect.sync(() => { | |
| 2217 | if (turn.admissionReconciliationGeneration === generation) { | |
| 2218 | turn.admissionReconciliationGeneration = null; | |
| 2219 | } | |
| 2220 | }), | |
| 2221 | ), | |
| 2222 | Effect.forkIn(scope), | |
| 2223 | ); | |
| 2224 | }); | |
| 2225 | ||
| 2226 | const createChildTurn = Effect.fnUntraced(function* ( | |
| 2227 | state: OpenCodeThreadState, | |
| 2228 | message: Extract<OpenCodeMessage, { role: "user" }>, | |
| 2229 | ) { | |
| 2230 | if (state.appThread === null || state.parentSubagent === null) return null; | |
| 2231 | const now = yield* DateTime.now; | |
| 2232 | const startedAt = dateTimeFromEpoch(message.time.created, now); | |
| 2233 | const rootNodeId = idAllocator.derive.nodeFromProviderItem({ | |
| 2234 | driver: OPENCODE_PROVIDER, | |
| 2235 | nativeItemId: `${state.nativeSessionId}:root:${message.id}`, | |
| 2236 | }); | |
| 2237 | const providerTurnId = idAllocator.derive.providerTurn({ | |
| 2238 | driver: OPENCODE_PROVIDER, | |
| 2239 | nativeTurnId: message.id, | |
| 2240 | }); | |
| 2241 | const providerTurn: OrchestrationV2ProviderTurn = { | |
| 2242 | id: providerTurnId, | |
| 2243 | providerThreadId: state.providerThread.id, | |
| 2244 | nodeId: rootNodeId, | |
| 2245 | runAttemptId: null, | |
| 2246 | nativeTurnRef: providerRef(message.id, "weak"), | |
| 2247 | ordinal: state.nextChildTurnOrdinal++, | |
| 2248 | status: "running", | |
| 2249 | startedAt, | |
| 2250 | completedAt: null, | |
| 2251 | }; | |
| 2252 | const turn: ActiveOpenCodeTurn = { | |
| 2253 | isRoot: false, | |
| 2254 | threadId: state.appThread.id, | |
| 2255 | runId: null, | |
| 2256 | rootNodeId, | |
| 2257 | appThread: state.appThread, | |
| 2258 | modelSelection: state.appThread.modelSelection, | |
| 2259 | runtimePolicy: state.parentSubagent.parentTurn.runtimePolicy, | |
| 2260 | providerTurnId, | |
| 2261 | providerThreadId: providerTurn.providerThreadId, | |
| 2262 | providerTurnOrdinal: providerTurn.ordinal, | |
| 2263 | runOrdinal: state.parentSubagent.parentTurn.runOrdinal, | |
| 2264 | runAttemptId: null, | |
| 2265 | startedAt, | |
| 2266 | itemOrdinals: new Map(), | |
| 2267 | usage: makeOpenCodeTurnTokenUsageAccumulator(), | |
| 2268 | parts: new Map(), | |
| 2269 | partIdsByMessage: new Map(), | |
| 2270 | toolNamesByCallId: new Map(), | |
| 2271 | providerTurn, | |
| 2272 | nextItemOrdinal: 1, | |
| 2273 | nativeUserMessageId: message.id, | |
| 2274 | admissionMessageId: null, | |
| 2275 | interrupted: false, | |
| 2276 | finalized: false, | |
| 2277 | planId: null, | |
| 2278 | admissionGeneration: 0, | |
| 2279 | admissionReconciliationGeneration: null, | |
| 2280 | admissionPending: false, | |
| 2281 | admissionAccepted: true, | |
| 2282 | admissionMessageObserved: true, | |
| 2283 | idleDuringAdmission: false, | |
| 2284 | admissionSettled: Deferred.makeUnsafe<void>(), | |
| 2285 | admissionAbortController: null, | |
| 2286 | }; | |
| 2287 | state.activeTurn = turn; | |
| 2288 | state.providerTurns.set(String(providerTurnId), providerTurn); | |
| 2289 | yield* emitProviderEvent({ | |
| 2290 | type: "node.updated", | |
| 2291 | driver: OPENCODE_PROVIDER, | |
| 2292 | node: { | |
| 2293 | id: rootNodeId, | |
| 2294 | threadId: turn.threadId, | |
| 2295 | runId: null, | |
| 2296 | parentNodeId: null, | |
| 2297 | rootNodeId, | |
| 2298 | kind: "root_turn", | |
| 2299 | status: "running", | |
| 2300 | countsForRun: false, | |
| 2301 | providerThreadId: state.providerThread.id, | |
| 2302 | providerTurnId, | |
| 2303 | nativeItemRef: providerRef(message.id, "weak"), | |
| 2304 | runtimeRequestId: null, | |
| 2305 | checkpointScopeId: null, | |
| 2306 | startedAt, | |
| 2307 | completedAt: null, | |
| 2308 | }, | |
| 2309 | }); | |
| 2310 | yield* emitProviderTurn(state, turn, "running", null); | |
| 2311 | return turn; | |
| 2312 | }); | |
| 2313 | ||
| 2314 | const projectChildUserPart = Effect.fnUntraced(function* ( | |
| 2315 | state: OpenCodeThreadState, | |
| 2316 | turn: ActiveOpenCodeTurn, | |
| 2317 | part: Extract<OpenCodePart, { type: "text" }>, | |
| 2318 | ) { | |
| 2319 | const now = yield* DateTime.now; | |
| 2320 | const messageId = idAllocator.derive.messageFromProviderItem({ | |
| 2321 | driver: OPENCODE_PROVIDER, | |
| 2322 | nativeItemId: part.messageID, | |
| 2323 | }); | |
| 2324 | const turnItemId = idAllocator.derive.turnItemFromProviderItem({ | |
| 2325 | driver: OPENCODE_PROVIDER, | |
| 2326 | nativeItemId: part.messageID, | |
| 2327 | }); | |
| 2328 | const projected: OrchestrationV2ConversationMessage = { | |
| 2329 | createdBy: "agent", | |
| 2330 | creationSource: "provider", | |
| 2331 | id: messageId, | |
| 2332 | threadId: turn.threadId, | |
| 2333 | runId: null, | |
| 2334 | nodeId: turn.rootNodeId, | |
| 2335 | role: "user", | |
| 2336 | text: part.text, | |
| 2337 | attachments: [], | |
| 2338 | streaming: false, | |
| 2339 | createdAt: turn.startedAt, | |
| 2340 | updatedAt: now, | |
| 2341 | }; | |
| 2342 | state.messages.set(String(messageId), projected); | |
| 2343 | yield* emitProviderEvent({ | |
| 2344 | type: "message.updated", | |
| 2345 | driver: OPENCODE_PROVIDER, | |
| 2346 | message: projected, | |
| 2347 | }); | |
| 2348 | yield* emitProviderEvent({ | |
| 2349 | type: "turn_item.updated", | |
| 2350 | driver: OPENCODE_PROVIDER, | |
| 2351 | turnItem: { | |
| 2352 | createdBy: "agent", | |
| 2353 | creationSource: "provider", | |
| 2354 | id: turnItemId, | |
| 2355 | threadId: turn.threadId, | |
| 2356 | runId: null, | |
| 2357 | nodeId: turn.rootNodeId, | |
| 2358 | providerThreadId: state.providerThread.id, | |
| 2359 | providerTurnId: turn.providerTurnId, | |
| 2360 | nativeItemRef: providerRef(part.messageID), | |
| 2361 | parentItemId: null, | |
| 2362 | ordinal: itemOrdinal(turn, part.messageID), | |
| 2363 | status: "completed", | |
| 2364 | title: null, | |
| 2365 | startedAt: projected.createdAt, | |
| 2366 | completedAt: now, | |
| 2367 | updatedAt: now, | |
| 2368 | type: "user_message", | |
| 2369 | messageId, | |
| 2370 | inputIntent: "turn_start", | |
| 2371 | text: part.text, | |
| 2372 | attachments: [], | |
| 2373 | }, | |
| 2374 | }); | |
| 2375 | }); | |
| 2376 | ||
| 2377 | const emitCompactionItem = Effect.fn("OpenCodeAdapterV2.emitCompactionItem")(function* ( | |
| 2378 | state: OpenCodeThreadState, | |
| 2379 | turn: ActiveOpenCodeTurn, | |
| 2380 | ) { | |
| 2381 | const nativeItemId = `${turn.providerTurnId}:compaction`; | |
| 2382 | if (turn.itemOrdinals.has(nativeItemId)) return; | |
| 2383 | const now = yield* DateTime.now; | |
| 2384 | yield* emitProviderEvent({ | |
| 2385 | type: "turn_item.updated", | |
| 2386 | driver: OPENCODE_PROVIDER, | |
| 2387 | turnItem: { | |
| 2388 | id: idAllocator.derive.turnItemFromProviderItem({ | |
| 2389 | driver: OPENCODE_PROVIDER, | |
| 2390 | nativeItemId, | |
| 2391 | }), | |
| 2392 | threadId: turn.threadId, | |
| 2393 | runId: turn.runId, | |
| 2394 | nodeId: turn.rootNodeId, | |
| 2395 | providerThreadId: state.providerThread.id, | |
| 2396 | providerTurnId: turn.providerTurnId, | |
| 2397 | nativeItemRef: providerRef(nativeItemId, "weak"), | |
| 2398 | parentItemId: null, | |
| 2399 | ordinal: itemOrdinal(turn, nativeItemId), | |
| 2400 | type: "compaction", | |
| 2401 | driver: OPENCODE_PROVIDER, | |
| 2402 | status: "completed", | |
| 2403 | title: "Context compacted", | |
| 2404 | startedAt: turn.startedAt, | |
| 2405 | completedAt: now, | |
| 2406 | updatedAt: now, | |
| 2407 | }, | |
| 2408 | }); | |
| 2409 | }); | |
| 2410 | ||
| 2411 | const handleMessageUpdated = Effect.fnUntraced(function* ( | |
| 2412 | event: Extract<OpenCodeEvent, { type: "message.updated" }>, | |
| 2413 | ) { | |
| 2414 | const state = threads.get(event.properties.sessionID); | |
| 2415 | if (state === undefined) return; | |
| 2416 | const message = event.properties.info; | |
| 2417 | state.messageRoles.set(message.id, message.role); | |
| 2418 | if (message.role === "assistant") { | |
| 2419 | const usage = state.activeTurn?.usage; | |
| 2420 | if (usage === undefined) return; | |
| 2421 | const prior = usage.assistantOwnershipByMessageId.get(message.id); | |
| 2422 | const ownership = | |
| 2423 | prior !== undefined && prior !== "unknown" | |
| 2424 | ? prior | |
| 2425 | : !message.parentID | |
| 2426 | ? "unknown" | |
| 2427 | : usage.promptMessageIds.has(message.parentID) | |
| 2428 | ? "owned" | |
| 2429 | : "other"; | |
| 2430 | usage.assistantOwnershipByMessageId.set(message.id, ownership); | |
| 2431 | if (ownership !== "unknown") { | |
| 2432 | if (ownership === "owned") { | |
| 2433 | for (const step of usage.unresolvedStepsByMessageId.get(message.id)?.values() ?? | |
| 2434 | []) { | |
| 2435 | accumulateOpenCodeStepUsage(usage, step); | |
| 2436 | } | |
| 2437 | } | |
| 2438 | usage.unresolvedStepsByMessageId.delete(message.id); | |
| 2439 | } | |
| 2440 | return; | |
| 2441 | } | |
| 2442 | const commandReceipt = commandReceipts.get(message.id); | |
| 2443 | if (commandReceipt) yield* Deferred.succeed(commandReceipt, undefined); | |
| 2444 | const isNewUserMessage = !state.userMessageIds.includes(message.id); | |
| 2445 | if (isNewUserMessage) state.userMessageIds.push(message.id); | |
| 2446 | let turn = state.activeTurn; | |
| 2447 | if (turn === null && state.parentSubagent !== null && isNewUserMessage) { | |
| 2448 | turn = yield* createChildTurn(state, message); | |
| 2449 | } | |
| 2450 | const matchesAdmission = | |
| 2451 | turn !== null && | |
| 2452 | (turn.admissionMessageId === null || turn.admissionMessageId === message.id); | |
| 2453 | if (turn !== null && matchesAdmission) turn.usage.promptMessageIds.add(message.id); | |
| 2454 | if (turn !== null && matchesAdmission && turn.nativeUserMessageId === null) { | |
| 2455 | turn.nativeUserMessageId = message.id; | |
| 2456 | yield* emitProviderTurn(state, turn, "running", null); | |
| 2457 | } | |
| 2458 | if (turn !== null && matchesAdmission && turn.admissionPending) { | |
| 2459 | if (advanceOpenCodePromptAdmission(turn, "user-message") === "reconcile-idle") { | |
| 2460 | yield* reconcilePromptAdmission(state, turn); | |
| 2461 | } | |
| 2462 | } | |
| 2463 | }); | |
| 2464 | ||
| 2465 | const handlePartUpdated = Effect.fnUntraced(function* ( | |
| 2466 | event: Extract<OpenCodeEvent, { type: "message.part.updated" }>, | |
| 2467 | ) { | |
| 2468 | const part = event.properties.part; | |
| 2469 | const state = threads.get(part.sessionID); | |
| 2470 | const turn = state?.activeTurn; | |
| 2471 | if (state === undefined || turn === null || turn === undefined || turn.finalized) return; | |
| 2472 | if (part.type === "text" && state.messageRoles.get(part.messageID) === "user") { | |
| 2473 | if (!turn.isRoot) yield* projectChildUserPart(state, turn, part); | |
| 2474 | return; | |
| 2475 | } | |
| 2476 | if (part.type === "step-finish") { | |
| 2477 | const usage = turn.usage; | |
| 2478 | const ownership = usage.assistantOwnershipByMessageId.get(part.messageID); | |
| 2479 | if (ownership === "owned") accumulateOpenCodeStepUsage(usage, part); | |
| 2480 | else if ( | |
| 2481 | ownership === "unknown" || | |
| 2482 | (ownership === undefined && !state.messageRoles.has(part.messageID)) | |
| 2483 | ) { | |
| 2484 | const steps = | |
| 2485 | usage.unresolvedStepsByMessageId.get(part.messageID) ?? | |
| 2486 | new Map<string, OpenCodeStepUsage>(); | |
| 2487 | steps.set(part.id, { id: part.id, tokens: part.tokens }); | |
| 2488 | usage.unresolvedStepsByMessageId.set(part.messageID, steps); | |
| 2489 | } | |
| 2490 | return; | |
| 2491 | } | |
| 2492 | if (part.type === "tool") { | |
| 2493 | // Approval routing needs the tool name, without retaining its input and output. | |
| 2494 | turn.toolNamesByCallId.set(part.callID, part.tool); | |
| 2495 | } else { | |
| 2496 | turn.parts.set(part.id, part); | |
| 2497 | const ids = turn.partIdsByMessage.get(part.messageID) ?? new Set<string>(); | |
| 2498 | ids.add(part.id); | |
| 2499 | turn.partIdsByMessage.set(part.messageID, ids); | |
| 2500 | } | |
| 2501 | switch (part.type) { | |
| 2502 | case "text": | |
| 2503 | case "reasoning": | |
| 2504 | yield* emitTextPart(state, turn, part); | |
| 2505 | return; | |
| 2506 | case "tool": | |
| 2507 | yield* emitToolPart(state, turn, part); | |
| 2508 | return; | |
| 2509 | default: | |
| 2510 | return; | |
| 2511 | } | |
| 2512 | }); | |
| 2513 | ||
| 2514 | const handlePartDelta = Effect.fnUntraced(function* ( | |
| 2515 | event: Extract<OpenCodeEvent, { type: "message.part.delta" }>, | |
| 2516 | ) { | |
| 2517 | if (event.properties.field !== "text") return; | |
| 2518 | const state = threads.get(event.properties.sessionID); | |
| 2519 | const turn = state?.activeTurn; | |
| 2520 | const current = turn?.parts.get(event.properties.partID); | |
| 2521 | if ( | |
| 2522 | state === undefined || | |
| 2523 | turn === null || | |
| 2524 | turn === undefined || | |
| 2525 | current === undefined || | |
| 2526 | (current.type !== "text" && current.type !== "reasoning") | |
| 2527 | ) { | |
| 2528 | return; | |
| 2529 | } | |
| 2530 | const updated = { ...current, text: current.text + event.properties.delta }; | |
| 2531 | turn.parts.set(updated.id, updated); | |
| 2532 | yield* emitTextPart(state, turn, updated); | |
| 2533 | }); | |
| 2534 | ||
| 2535 | const handleAssistantCompleted = Effect.fnUntraced(function* ( | |
| 2536 | event: Extract<OpenCodeEvent, { type: "message.updated" }>, | |
| 2537 | ) { | |
| 2538 | const message = event.properties.info; | |
| 2539 | if (message.role !== "assistant" || message.time.completed === undefined) return; | |
| 2540 | const state = threads.get(message.sessionID); | |
| 2541 | const turn = state?.activeTurn; | |
| 2542 | if (state === undefined || turn === null || turn === undefined) return; | |
| 2543 | // Some OpenCode versions ignore the client-provided message ID. A | |
| 2544 | // completed assistant message is definitive admission evidence, so | |
| 2545 | // let the following idle event settle the turn without weakening | |
| 2546 | // the stale-user-message guard. | |
| 2547 | advanceOpenCodePromptAdmission(turn, "assistant-completed"); | |
| 2548 | for (const partId of turn.partIdsByMessage.get(message.id) ?? []) { | |
| 2549 | const part = turn.parts.get(partId); | |
| 2550 | if (part?.type === "text" || part?.type === "reasoning") { | |
| 2551 | yield* emitTextPart(state, turn, part, true); | |
| 2552 | } | |
| 2553 | } | |
| 2554 | }); | |
| 2555 | ||
| 2556 | const handleEvent = Effect.fnUntraced(function* (event: OpenCodeEvent) { | |
| 2557 | yield* logProtocolEvent({ | |
| 2558 | direction: "incoming", | |
| 2559 | messageKind: "notification", | |
| 2560 | method: event.type, | |
| 2561 | payload: event, | |
| 2562 | }); | |
| 2563 | switch (event.type) { | |
| 2564 | case "server.connected": | |
| 2565 | if (hasConnected) { | |
| 2566 | for (const state of threads.values()) { | |
| 2567 | if (state.activeTurn !== null) state.activeTurn.usage.complete = false; | |
| 2568 | } | |
| 2569 | } | |
| 2570 | hasConnected = true; | |
| 2571 | return; | |
| 2572 | case "message.updated": | |
| 2573 | yield* handleMessageUpdated(event); | |
| 2574 | yield* handleAssistantCompleted(event); | |
| 2575 | return; | |
| 2576 | case "session.compacted": { | |
| 2577 | const state = threads.get(event.properties.sessionID); | |
| 2578 | if (state?.activeTurn !== undefined && state.activeTurn !== null) { | |
| 2579 | yield* emitCompactionItem(state, state.activeTurn); | |
| 2580 | } | |
| 2581 | return; | |
| 2582 | } | |
| 2583 | case "message.part.updated": | |
| 2584 | yield* handlePartUpdated(event); | |
| 2585 | return; | |
| 2586 | case "message.part.delta": | |
| 2587 | yield* handlePartDelta(event); | |
| 2588 | return; | |
| 2589 | case "todo.updated": { | |
| 2590 | const state = threads.get(event.properties.sessionID); | |
| 2591 | if (state?.activeTurn !== null && state?.activeTurn !== undefined) { | |
| 2592 | yield* emitTodo(state, state.activeTurn, event.properties.todos); | |
| 2593 | } | |
| 2594 | return; | |
| 2595 | } | |
| 2596 | case "permission.asked": | |
| 2597 | yield* routeRuntimeRequest(event.properties.id, event.properties.sessionID, { | |
| 2598 | type: "permission", | |
| 2599 | value: event.properties, | |
| 2600 | }); | |
| 2601 | return; | |
| 2602 | case "question.asked": | |
| 2603 | yield* routeRuntimeRequest(event.properties.id, event.properties.sessionID, { | |
| 2604 | type: "question", | |
| 2605 | value: event.properties, | |
| 2606 | }); | |
| 2607 | return; | |
| 2608 | case "permission.replied": | |
| 2609 | case "question.replied": | |
| 2610 | rememberSettledRequest(event.properties.requestID); | |
| 2611 | yield* resolveRuntimeRequest(event.properties.requestID, "resolved"); | |
| 2612 | return; | |
| 2613 | case "question.rejected": | |
| 2614 | rememberSettledRequest(event.properties.requestID); | |
| 2615 | yield* resolveRuntimeRequest(event.properties.requestID, "cancelled"); | |
| 2616 | return; | |
| 2617 | case "session.created": | |
| 2618 | case "session.updated": { | |
| 2619 | const info = event.properties.info; | |
| 2620 | if (info.parentID !== undefined && !threads.has(info.id)) { | |
| 2621 | const owner = threads.get(info.parentID) ?? relatedSessionOwners.get(info.parentID); | |
| 2622 | if (owner !== undefined) { | |
| 2623 | relatedSessionOwners.set(info.id, owner); | |
| 2624 | } | |
| 2625 | } | |
| 2626 | return; | |
| 2627 | } | |
| 2628 | case "session.deleted": | |
| 2629 | relatedSessionOwners.delete(event.properties.info.id); | |
| 2630 | busySessionIds.delete(event.properties.info.id); | |
| 2631 | return; | |
| 2632 | case "session.status": { | |
| 2633 | const sessionId = event.properties.sessionID; | |
| 2634 | switch (event.properties.status.type) { | |
| 2635 | case "busy": | |
| 2636 | case "retry": | |
| 2637 | if (threads.has(sessionId) || relatedSessionOwners.has(sessionId)) { | |
| 2638 | busySessionIds.add(sessionId); | |
| 2639 | } | |
| 2640 | break; | |
| 2641 | case "idle": | |
| 2642 | busySessionIds.delete(sessionId); | |
| 2643 | break; | |
| 2644 | } | |
| 2645 | const state = threads.get(sessionId); | |
| 2646 | if (state === undefined) return; | |
| 2647 | if (event.properties.status.type === "busy") { | |
| 2648 | yield* updateProviderThread(state, { status: "active" }); | |
| 2649 | return; | |
| 2650 | } | |
| 2651 | if (event.properties.status.type === "idle" && state.activeTurn !== null) { | |
| 2652 | if (state.activeTurn.admissionPending) { | |
| 2653 | advanceOpenCodePromptAdmission(state.activeTurn, "idle"); | |
| 2654 | return; | |
| 2655 | } | |
| 2656 | yield* finalizeTurn( | |
| 2657 | state, | |
| 2658 | state.activeTurn, | |
| 2659 | state.activeTurn.interrupted ? "interrupted" : "completed", | |
| 2660 | ); | |
| 2661 | } | |
| 2662 | return; | |
| 2663 | } | |
| 2664 | case "session.idle": { | |
| 2665 | busySessionIds.delete(event.properties.sessionID); | |
| 2666 | const state = threads.get(event.properties.sessionID); | |
| 2667 | if (state?.activeTurn !== null && state?.activeTurn !== undefined) { | |
| 2668 | if (state.activeTurn.admissionPending) { | |
| 2669 | advanceOpenCodePromptAdmission(state.activeTurn, "idle"); | |
| 2670 | return; | |
| 2671 | } | |
| 2672 | yield* finalizeTurn( | |
| 2673 | state, | |
| 2674 | state.activeTurn, | |
| 2675 | state.activeTurn.interrupted ? "interrupted" : "completed", | |
| 2676 | ); | |
| 2677 | } | |
| 2678 | return; | |
| 2679 | } | |
| 2680 | case "session.error": { | |
| 2681 | const states = | |
| 2682 | event.properties.sessionID === undefined | |
| 2683 | ? Array.from(threads.values()).filter((state) => state.activeTurn !== null) | |
| 2684 | : [threads.get(event.properties.sessionID)].filter( | |
| 2685 | (state): state is OpenCodeThreadState => state !== undefined, | |
| 2686 | ); | |
| 2687 | const message = openCodeErrorMessage(event); | |
| 2688 | if ( | |
| 2689 | !isMessageAbortedError(event) && | |
| 2690 | (event.properties.sessionID === undefined || | |
| 2691 | states.some((state) => state.parentSubagent === null)) | |
| 2692 | ) { | |
| 2693 | yield* updateProviderSession("error", message); | |
| 2694 | } | |
| 2695 | for (const state of states) { | |
| 2696 | if (state.activeTurn !== null) { | |
| 2697 | yield* finalizeTurn( | |
| 2698 | state, | |
| 2699 | state.activeTurn, | |
| 2700 | terminalStatusForError(event, state.activeTurn), | |
| 2701 | { | |
| 2702 | failure: makeProviderFailure({ | |
| 2703 | message, | |
| 2704 | code: event.properties.error?.name ?? null, | |
| 2705 | class: "provider_error", | |
| 2706 | }), | |
| 2707 | threadDisposition: | |
| 2708 | event.properties.sessionID === undefined ? "broken" : "reusable", | |
| 2709 | }, | |
| 2710 | ); | |
| 2711 | } | |
| 2712 | } | |
| 2713 | return; | |
| 2714 | } | |
| 2715 | default: | |
| 2716 | return; | |
| 2717 | } | |
| 2718 | }); | |
| 2719 | ||
| 2720 | const inputProviderSessionId = input.providerSessionId; | |
| 2721 | ||
| 2722 | const subscription = yield* sdkCall("event.subscribe", {}, () => | |
| 2723 | client.event.subscribe(undefined, { signal: abortController.signal }), | |
| 2724 | ); | |
| 2725 | yield* Scope.addFinalizer( | |
| 2726 | scope, | |
| 2727 | Effect.sync(() => abortController.abort()), | |
| 2728 | ); | |
| 2729 | yield* Stream.fromAsyncIterable( | |
| 2730 | subscription.stream, | |
| 2731 | (cause) => | |
| 2732 | new OpenCodeRuntime.OpenCodeRuntimeError({ | |
| 2733 | operation: "event.subscribe", | |
| 2734 | detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause), | |
| 2735 | cause, | |
| 2736 | }), | |
| 2737 | ).pipe( | |
| 2738 | Stream.runForEach(handleEvent), | |
| 2739 | Effect.exit, | |
| 2740 | Effect.flatMap((exit) => | |
| 2741 | Effect.gen(function* () { | |
| 2742 | // No further status can clear a busy session once the stream ends. | |
| 2743 | busySessionIds.clear(); | |
| 2744 | if (closing || abortController.signal.aborted) return; | |
| 2745 | const detail = Exit.isSuccess(exit) | |
| 2746 | ? "OpenCode event stream ended unexpectedly." | |
| 2747 | : OpenCodeRuntime.openCodeRuntimeErrorDetail(Cause.squash(exit.cause)); | |
| 2748 | nativeStreamFailure = makeProviderFailure({ | |
| 2749 | message: detail, | |
| 2750 | class: "transport_error", | |
| 2751 | }); | |
| 2752 | yield* updateProviderSession("error", detail); | |
| 2753 | for (const state of threads.values()) { | |
| 2754 | if (state.activeTurn !== null) | |
| 2755 | yield* finalizeTurn(state, state.activeTurn, "failed", { | |
| 2756 | failure: makeProviderFailure({ message: detail, class: "transport_error" }), | |
| 2757 | threadDisposition: "broken", | |
| 2758 | }); | |
| 2759 | } | |
| 2760 | yield* Queue.end(events); | |
| 2761 | }), | |
| 2762 | ), | |
| 2763 | Effect.forkIn(scope), | |
| 2764 | ); | |
| 2765 | ||
| 2766 | if (!connection.external && connection.exitCode !== null) { | |
| 2767 | yield* connection.exitCode.pipe( | |
| 2768 | Effect.flatMap((code) => | |
| 2769 | abortController.signal.aborted | |
| 2770 | ? Effect.void | |
| 2771 | : Effect.gen(function* () { | |
| 2772 | const detail = `OpenCode server exited unexpectedly (${code}).`; | |
| 2773 | yield* updateProviderSession("error", detail); | |
| 2774 | for (const state of threads.values()) { | |
| 2775 | if (state.activeTurn !== null) { | |
| 2776 | yield* finalizeTurn(state, state.activeTurn, "failed", { | |
| 2777 | failure: makeProviderFailure({ | |
| 2778 | message: detail, | |
| 2779 | class: "transport_error", | |
| 2780 | }), | |
| 2781 | threadDisposition: "broken", | |
| 2782 | }); | |
| 2783 | } | |
| 2784 | } | |
| 2785 | }), | |
| 2786 | ), | |
| 2787 | Effect.forkIn(scope), | |
| 2788 | ); | |
| 2789 | } | |
| 2790 | ||
| 2791 | if (connection.external) { | |
| 2792 | yield* Scope.addFinalizer( | |
| 2793 | scope, | |
| 2794 | Effect.suspend(() => | |
| 2795 | Effect.forEach( | |
| 2796 | Array.from(threads.values()).filter((state) => state.parentSubagent === null), | |
| 2797 | (state) => | |
| 2798 | sdkCall("session.abort", { sessionID: state.nativeSessionId }, (signal) => | |
| 2799 | client.session.abort({ sessionID: state.nativeSessionId }, { signal }), | |
| 2800 | ).pipe( | |
| 2801 | Effect.timeout("1 second"), | |
| 2802 | Effect.ignore({ log: true }), | |
| 2803 | Effect.andThen( | |
| 2804 | abortDescendants(state.nativeSessionId).pipe( | |
| 2805 | Effect.timeout("1 second"), | |
| 2806 | Effect.ignore({ log: true }), | |
| 2807 | ), | |
| 2808 | ), | |
| 2809 | ), | |
| 2810 | { concurrency: 8, discard: true }, | |
| 2811 | ), | |
| 2812 | ), | |
| 2813 | ); | |
| 2814 | } | |
| 2815 | ||
| 2816 | yield* Scope.addFinalizer( | |
| 2817 | scope, | |
| 2818 | Effect.sync(() => { | |
| 2819 | closing = true; | |
| 2820 | }), | |
| 2821 | ); | |
| 2822 | ||
| 2823 | const registerThread = ( | |
| 2824 | nativeSession: OpenCodeSession, | |
| 2825 | providerThread: OrchestrationV2ProviderThread, | |
| 2826 | appThread: OrchestrationV2AppThread | null, | |
| 2827 | ): OpenCodeThreadState => { | |
| 2828 | const existing = threads.get(nativeSession.id); | |
| 2829 | if (existing !== undefined) { | |
| 2830 | existing.providerThread = providerThread; | |
| 2831 | if (appThread !== null) existing.appThread = appThread; | |
| 2832 | return existing; | |
| 2833 | } | |
| 2834 | const state: OpenCodeThreadState = { | |
| 2835 | nativeSessionId: nativeSession.id, | |
| 2836 | providerThread, | |
| 2837 | appThread, | |
| 2838 | activeTurn: null, | |
| 2839 | providerTurns: new Map(), | |
| 2840 | messages: new Map(), | |
| 2841 | runtimeRequests: new Map(), | |
| 2842 | messageRoles: new Map(), | |
| 2843 | userMessageIds: [], | |
| 2844 | parentSubagent: subagentsByChildSessionId.get(nativeSession.id) ?? null, | |
| 2845 | nextChildTurnOrdinal: 1, | |
| 2846 | nextAdmissionGeneration: 1, | |
| 2847 | }; | |
| 2848 | threads.set(nativeSession.id, state); | |
| 2849 | return state; | |
| 2850 | }; | |
| 2851 | ||
| 2852 | const resolvePromptParts = (turnInput: ProviderAdapter.ProviderAdapterV2TurnInput) => { | |
| 2853 | const text = providerMessageTextWithAttachmentPaths({ | |
| 2854 | text: turnInput.message.text, | |
| 2855 | attachments: turnInput.message.attachments, | |
| 2856 | resolveAttachmentPath: host.resolveAttachmentPath, | |
| 2857 | }).trim(); | |
| 2858 | const files = OpenCodeRuntime.toOpenCodeFileParts({ | |
| 2859 | attachments: turnInput.message.attachments, | |
| 2860 | resolveAttachmentPath: host.resolveAttachmentPath, | |
| 2861 | }); | |
| 2862 | if (text.length === 0 && files.length === 0) { | |
| 2863 | throw protocolError("OpenCode turns require text or at least one valid attachment"); | |
| 2864 | } | |
| 2865 | return [...(text.length === 0 ? [] : [{ type: "text" as const, text }]), ...files]; | |
| 2866 | }; | |
| 2867 | ||
| 2868 | const readSnapshot = Effect.fnUntraced(function* ( | |
| 2869 | providerThread: OrchestrationV2ProviderThread, | |
| 2870 | ) { | |
| 2871 | const sessionId = nativeThreadId(providerThread); | |
| 2872 | const sessionResponse = yield* sdkCall("session.get", { sessionID: sessionId }, () => | |
| 2873 | client.session.get({ sessionID: sessionId }), | |
| 2874 | ); | |
| 2875 | const nativeSession = unwrapData("session.get", sessionResponse); | |
| 2876 | const response = yield* sdkCall("session.messages", { sessionID: sessionId }, () => | |
| 2877 | client.session.messages({ sessionID: sessionId }), | |
| 2878 | ); | |
| 2879 | const nativeMessages = []; | |
| 2880 | for (const entry of unwrapData("session.messages", response)) { | |
| 2881 | // OpenCode retains reverted records in session.messages. Its | |
| 2882 | // revert marker identifies the first removed message, so stop | |
| 2883 | // before it or a restart resurrects rolled-back assistant work. | |
| 2884 | if (entry.info.id === nativeSession.revert?.messageID) break; | |
| 2885 | nativeMessages.push(entry); | |
| 2886 | } | |
| 2887 | const state = threads.get(sessionId); | |
| 2888 | const snapshotNow = yield* DateTime.now; | |
| 2889 | const messages: Array<OrchestrationV2ConversationMessage> = nativeMessages.flatMap( | |
| 2890 | ({ info, parts }) => { | |
| 2891 | const text = parts | |
| 2892 | .filter( | |
| 2893 | (part): part is Extract<OpenCodePart, { type: "text" }> => part.type === "text", | |
| 2894 | ) | |
| 2895 | .filter((part) => part.ignored !== true && part.synthetic !== true) | |
| 2896 | .map((part) => part.text) | |
| 2897 | .join("\n"); | |
| 2898 | if (text.length === 0) return []; | |
| 2899 | const createdAt = dateTimeFromEpoch(info.time.created, snapshotNow); | |
| 2900 | return [ | |
| 2901 | { | |
| 2902 | createdBy: info.role === "user" ? "user" : "agent", | |
| 2903 | creationSource: "provider", | |
| 2904 | id: idAllocator.derive.messageFromProviderItem({ | |
| 2905 | driver: OPENCODE_PROVIDER, | |
| 2906 | nativeItemId: info.id, | |
| 2907 | }), | |
| 2908 | threadId: providerThread.appThreadId ?? input.threadId, | |
| 2909 | runId: null, | |
| 2910 | nodeId: null, | |
| 2911 | role: info.role, | |
| 2912 | text, | |
| 2913 | attachments: [], | |
| 2914 | streaming: false, | |
| 2915 | createdAt, | |
| 2916 | updatedAt: | |
| 2917 | info.role === "assistant" | |
| 2918 | ? dateTimeFromEpoch(info.time.completed, createdAt) | |
| 2919 | : createdAt, | |
| 2920 | }, | |
| 2921 | ]; | |
| 2922 | }, | |
| 2923 | ); | |
| 2924 | const lastUser = nativeMessages.findLast(({ info }) => info.role === "user")?.info.id; | |
| 2925 | return { | |
| 2926 | providerThread: { | |
| 2927 | ...providerThread, | |
| 2928 | providerSessionId: input.providerSessionId, | |
| 2929 | nativeConversationHeadRef: | |
| 2930 | lastUser === undefined ? null : providerRef(lastUser, "weak"), | |
| 2931 | status: "idle" as const, | |
| 2932 | updatedAt: snapshotNow, | |
| 2933 | }, | |
| 2934 | providerTurns: state === undefined ? [] : [...state.providerTurns.values()], | |
| 2935 | messages, | |
| 2936 | runtimeRequests: state === undefined ? [] : [...state.runtimeRequests.values()], | |
| 2937 | providerPayload: nativeMessages, | |
| 2938 | }; | |
| 2939 | }); | |
| 2940 | ||
| 2941 | // Native commands wait for generation, unlike promptAsync. A user-message | |
| 2942 | // receipt admits the run while the scoped request continues in the background. | |
| 2943 | const submitPrompt = Effect.fn("OpenCode.submitPrompt")(function* ( | |
| 2944 | state: OpenCodeThreadState, | |
| 2945 | turn: ActiveOpenCodeTurn, | |
| 2946 | payload: Parameters<typeof client.session.promptAsync>[0] & { | |
| 2947 | messageID: string; | |
| 2948 | sessionID: string; | |
| 2949 | }, | |
| 2950 | abortController: AbortController, | |
| 2951 | ) { | |
| 2952 | const text = payload.parts | |
| 2953 | ?.filter((part) => part.type === "text") | |
| 2954 | .map((part) => part.text) | |
| 2955 | .join("\n") | |
| 2956 | .trim(); | |
| 2957 | const match = text?.match(/^\/([^\s/]+)(?:\s+([\s\S]*))?$/); | |
| 2958 | const command = match | |
| 2959 | ? (yield* OpenCodeRuntime.loadOpenCodeCommands(client).pipe( | |
| 2960 | Effect.timeout("10 seconds"), | |
| 2961 | Effect.orElseSucceed(() => []), | |
| 2962 | )).find((entry) => entry.name === match[1]) | |
| 2963 | : undefined; | |
| 2964 | if (!command) { | |
| 2965 | return yield* sdkCall("session.promptAsync", payload, (signal) => | |
| 2966 | client.session.promptAsync(payload, { | |
| 2967 | signal: AbortSignal.any([signal, abortController.signal]), | |
| 2968 | }), | |
| 2969 | ).pipe(Effect.asVoid); | |
| 2970 | } | |
| 2971 | const receipt = Deferred.makeUnsafe<void>(); | |
| 2972 | commandReceipts.set(payload.messageID, receipt); | |
| 2973 | const controllers = | |
| 2974 | commandControllers.get(payload.sessionID) ?? new Set<AbortController>(); | |
| 2975 | controllers.add(abortController); | |
| 2976 | commandControllers.set(payload.sessionID, controllers); | |
| 2977 | const generation = turn.admissionGeneration; | |
| 2978 | const commandPayload = { | |
| 2979 | sessionID: payload.sessionID, | |
| 2980 | messageID: payload.messageID, | |
| 2981 | command: command.name, | |
| 2982 | arguments: match?.[2] ?? "", | |
| 2983 | ...(payload.model | |
| 2984 | ? { model: `${payload.model.providerID}/${payload.model.modelID}` } | |
| 2985 | : {}), | |
| 2986 | ...(payload.agent ? { agent: payload.agent } : {}), | |
| 2987 | ...(payload.variant ? { variant: payload.variant } : {}), | |
| 2988 | parts: payload.parts?.filter((part) => part.type === "file") ?? [], | |
| 2989 | }; | |
| 2990 | const request = yield* sdkCall("session.command", commandPayload, (signal) => | |
| 2991 | client.session.command(commandPayload, { | |
| 2992 | signal: AbortSignal.any([signal, abortController.signal]), | |
| 2993 | }), | |
| 2994 | ).pipe( | |
| 2995 | Effect.asVoid, | |
| 2996 | Effect.tapError((cause) => | |
| 2997 | abortController.signal.aborted || | |
| 2998 | turn.finalized || | |
| 2999 | turn.admissionGeneration !== generation | |
| 3000 | ? Effect.void | |
| 3001 | : finalizeTurn(state, turn, "failed", { | |
| 3002 | failure: makeProviderFailure({ cause, class: "provider_error" }), | |
| 3003 | }), | |
| 3004 | ), | |
| 3005 | Effect.ensuring( | |
| 3006 | Effect.sync(() => { | |
| 3007 | commandReceipts.delete(payload.messageID); | |
| 3008 | controllers.delete(abortController); | |
| 3009 | if (controllers.size === 0) commandControllers.delete(payload.sessionID); | |
| 3010 | }), | |
| 3011 | ), | |
| 3012 | Effect.forkIn(scope), | |
| 3013 | ); | |
| 3014 | yield* Effect.raceFirst(Fiber.join(request), Deferred.await(receipt)).pipe( | |
| 3015 | Effect.timeout("10 seconds"), | |
| 3016 | Effect.catchTags({ | |
| 3017 | TimeoutError: (cause) => { | |
| 3018 | const error = new OpenCodeRuntime.OpenCodeRuntimeError({ | |
| 3019 | operation: "session.command", | |
| 3020 | detail: "OpenCode command admission did not complete within 10 seconds.", | |
| 3021 | cause, | |
| 3022 | }); | |
| 3023 | abortController.abort(); | |
| 3024 | return finalizeTurn(state, turn, "failed", { | |
| 3025 | failure: makeProviderFailure({ cause: error, class: "provider_error" }), | |
| 3026 | }).pipe(Effect.andThen(Effect.fail(error))); | |
| 3027 | }, | |
| 3028 | }), | |
| 3029 | ); | |
| 3030 | }); | |
| 3031 | ||
| 3032 | const runtimeSession: ProviderAdapter.ProviderAdapterV2SessionRuntime = { | |
| 3033 | instanceId: options.instanceId, | |
| 3034 | driver: OPENCODE_PROVIDER, | |
| 3035 | providerSessionId: input.providerSessionId, | |
| 3036 | providerSession: sessionEntity, | |
| 3037 | events: Stream.fromEffectRepeat(Queue.take(events)), | |
| 3038 | hasPendingBackgroundWork: Effect.sync(() => busySessionIds.size > 0), | |
| 3039 | ensureThread: (threadInput) => | |
| 3040 | Effect.gen(function* () { | |
| 3041 | // Only a row that already carries a native session can be | |
| 3042 | // resumed; a placeholder without one still needs session.create. | |
| 3043 | if (threadInput.existingProviderThread?.nativeThreadRef != null) { | |
| 3044 | return yield* runtimeSession.resumeThread({ | |
| 3045 | providerThread: threadInput.existingProviderThread, | |
| 3046 | }); | |
| 3047 | } | |
| 3048 | // No title: OpenCode generates one from the first prompt only when | |
| 3049 | // session.create leaves it unset (SessionPrompt.ensureTitle). | |
| 3050 | const response = yield* sdkCall( | |
| 3051 | "session.create", | |
| 3052 | { permission: openCodePermissionRules(threadInput.runtimePolicy) }, | |
| 3053 | () => | |
| 3054 | client.session.create({ | |
| 3055 | permission: openCodePermissionRules(threadInput.runtimePolicy), | |
| 3056 | }), | |
| 3057 | ); | |
| 3058 | const nativeSession = unwrapData("session.create", response); | |
| 3059 | const createdAt = yield* DateTime.now; | |
| 3060 | const created = makeProviderThread({ | |
| 3061 | idAllocator, | |
| 3062 | providerInstanceId: options.instanceId, | |
| 3063 | providerSessionId: input.providerSessionId, | |
| 3064 | appThreadId: threadInput.threadId, | |
| 3065 | nativeSession, | |
| 3066 | now: createdAt, | |
| 3067 | }); | |
| 3068 | const existing = threadInput.existingProviderThread; | |
| 3069 | // Bind the new native session to the caller's row when one was | |
| 3070 | // handed over: a second live row per app thread would make | |
| 3071 | // `activeProviderThreadId` flap between the two on every update. | |
| 3072 | const providerThread = | |
| 3073 | existing === undefined | |
| 3074 | ? created | |
| 3075 | : { | |
| 3076 | ...existing, | |
| 3077 | providerSessionId: input.providerSessionId, | |
| 3078 | nativeThreadRef: created.nativeThreadRef, | |
| 3079 | nativeConversationHeadRef: created.nativeConversationHeadRef, | |
| 3080 | status: created.status, | |
| 3081 | updatedAt: created.updatedAt, | |
| 3082 | }; | |
| 3083 | registerThread(nativeSession, providerThread, null); | |
| 3084 | return providerThread; | |
| 3085 | }).pipe( | |
| 3086 | Effect.mapError( | |
| 3087 | (cause) => | |
| 3088 | new ProviderAdapter.ProviderAdapterEnsureThreadError({ | |
| 3089 | driver: OPENCODE_PROVIDER, | |
| 3090 | threadId: threadInput.threadId, | |
| 3091 | cause, | |
| 3092 | }), | |
| 3093 | ), | |
| 3094 | ), | |
| 3095 | resumeThread: (threadInput) => | |
| 3096 | Effect.gen(function* () { | |
| 3097 | const sessionId = nativeThreadId(threadInput.providerThread); | |
| 3098 | const response = yield* sdkCall("session.get", { sessionID: sessionId }, () => | |
| 3099 | client.session.get({ sessionID: sessionId }), | |
| 3100 | ); | |
| 3101 | const nativeSession = unwrapData("session.get", response); | |
| 3102 | const resumedAt = yield* DateTime.now; | |
| 3103 | const providerThread = { | |
| 3104 | ...threadInput.providerThread, | |
| 3105 | providerSessionId: input.providerSessionId, | |
| 3106 | status: "idle" as const, | |
| 3107 | updatedAt: dateTimeFromEpoch(nativeSession.time.updated, resumedAt), | |
| 3108 | }; | |
| 3109 | registerThread(nativeSession, providerThread, null); | |
| 3110 | return providerThread; | |
| 3111 | }).pipe( | |
| 3112 | Effect.mapError( | |
| 3113 | (cause) => | |
| 3114 | new ProviderAdapter.ProviderAdapterResumeThreadError({ | |
| 3115 | driver: OPENCODE_PROVIDER, | |
| 3116 | providerSessionId: input.providerSessionId, | |
| 3117 | providerThreadId: threadInput.providerThread.id, | |
| 3118 | cause, | |
| 3119 | }), | |
| 3120 | ), | |
| 3121 | ), | |
| 3122 | compactThread: (turnInput) => | |
| 3123 | runtimeSession.startTurn({ | |
| 3124 | ...turnInput, | |
| 3125 | message: { ...turnInput.message, text: "/compact" }, | |
| 3126 | }), | |
| 3127 | startTurn: (turnInput) => | |
| 3128 | Effect.gen(function* () { | |
| 3129 | if (nativeStreamFailure !== null) { | |
| 3130 | return yield* protocolError( | |
| 3131 | "OpenCode event stream has ended; reconnect the provider session before starting another turn.", | |
| 3132 | ); | |
| 3133 | } | |
| 3134 | const sessionId = nativeThreadId(turnInput.providerThread); | |
| 3135 | const state = threads.get(sessionId); | |
| 3136 | if (state === undefined) { | |
| 3137 | return yield* protocolError(`OpenCode session ${sessionId} is not registered`); | |
| 3138 | } | |
| 3139 | if (state.activeTurn !== null) { | |
| 3140 | return yield* protocolError( | |
| 3141 | `OpenCode provider thread ${turnInput.providerThread.id} already has an active turn`, | |
| 3142 | ); | |
| 3143 | } | |
| 3144 | const parsedModel = OpenCodeRuntime.parseOpenCodeModelSlug( | |
| 3145 | turnInput.modelSelection.model, | |
| 3146 | ); | |
| 3147 | if (parsedModel === null) { | |
| 3148 | return yield* protocolError( | |
| 3149 | `OpenCode model '${turnInput.modelSelection.model}' must use provider/model format`, | |
| 3150 | ); | |
| 3151 | } | |
| 3152 | const isCompaction = | |
| 3153 | turnInput.message.text.trim() === "/compact" && | |
| 3154 | turnInput.message.attachments.length === 0; | |
| 3155 | const parts = isCompaction ? [] : resolvePromptParts(turnInput); | |
| 3156 | const startedAt = yield* DateTime.now; | |
| 3157 | const syntheticNativeTurnId = `${sessionId}:attempt:${turnInput.attemptId}`; | |
| 3158 | const providerTurnId = idAllocator.derive.providerTurn({ | |
| 3159 | driver: OPENCODE_PROVIDER, | |
| 3160 | nativeTurnId: syntheticNativeTurnId, | |
| 3161 | }); | |
| 3162 | const providerTurn: OrchestrationV2ProviderTurn = { | |
| 3163 | id: providerTurnId, | |
| 3164 | providerThreadId: turnInput.providerThread.id, | |
| 3165 | nodeId: turnInput.rootNodeId, | |
| 3166 | runAttemptId: turnInput.attemptId, | |
| 3167 | nativeTurnRef: providerRef(syntheticNativeTurnId, "weak"), | |
| 3168 | ordinal: turnInput.providerTurnOrdinal, | |
| 3169 | status: "running", | |
| 3170 | startedAt, | |
| 3171 | completedAt: null, | |
| 3172 | }; | |
| 3173 | const admissionMessageId = yield* makeOpenCodeMessageId(); | |
| 3174 | // No Effect may be yielded between this check and installing the | |
| 3175 | // turn. If the event stream ended while IDs were being prepared, | |
| 3176 | // registering afterward would leave a running turn that the EOF | |
| 3177 | // handler had already finished scanning. | |
| 3178 | if (nativeStreamFailure !== null) { | |
| 3179 | return yield* protocolError( | |
| 3180 | "OpenCode event stream has ended; reconnect the provider session before starting another turn.", | |
| 3181 | ); | |
| 3182 | } | |
| 3183 | if (state.activeTurn !== null) { | |
| 3184 | return yield* protocolError( | |
| 3185 | `OpenCode provider thread ${turnInput.providerThread.id} already has an active turn`, | |
| 3186 | ); | |
| 3187 | } | |
| 3188 | const turn: ActiveOpenCodeTurn = { | |
| 3189 | isRoot: true, | |
| 3190 | threadId: turnInput.threadId, | |
| 3191 | runId: turnInput.runId, | |
| 3192 | rootNodeId: turnInput.rootNodeId, | |
| 3193 | appThread: turnInput.appThread, | |
| 3194 | modelSelection: turnInput.modelSelection, | |
| 3195 | runtimePolicy: turnInput.runtimePolicy, | |
| 3196 | providerTurnId, | |
| 3197 | providerThreadId: turnInput.providerThread.id, | |
| 3198 | providerTurnOrdinal: turnInput.providerTurnOrdinal, | |
| 3199 | runOrdinal: turnInput.runOrdinal, | |
| 3200 | runAttemptId: turnInput.attemptId, | |
| 3201 | startedAt, | |
| 3202 | itemOrdinals: new Map(), | |
| 3203 | usage: makeOpenCodeTurnTokenUsageAccumulator(), | |
| 3204 | parts: new Map(), | |
| 3205 | partIdsByMessage: new Map(), | |
| 3206 | toolNamesByCallId: new Map(), | |
| 3207 | providerTurn, | |
| 3208 | nextItemOrdinal: turnInput.providerTurnOrdinal * 100 + 1, | |
| 3209 | nativeUserMessageId: null, | |
| 3210 | admissionMessageId, | |
| 3211 | interrupted: false, | |
| 3212 | finalized: false, | |
| 3213 | planId: null, | |
| 3214 | admissionGeneration: state.nextAdmissionGeneration++, | |
| 3215 | admissionReconciliationGeneration: null, | |
| 3216 | admissionPending: !isCompaction, | |
| 3217 | admissionAccepted: isCompaction, | |
| 3218 | admissionMessageObserved: false, | |
| 3219 | idleDuringAdmission: false, | |
| 3220 | admissionSettled: Deferred.makeUnsafe<void>(), | |
| 3221 | admissionAbortController: new AbortController(), | |
| 3222 | }; | |
| 3223 | if (turn.admissionMessageId !== null) | |
| 3224 | turn.usage.promptMessageIds.add(turn.admissionMessageId); | |
| 3225 | const admissionSettled = turn.admissionSettled; | |
| 3226 | const admissionAbortController = turn.admissionAbortController; | |
| 3227 | state.appThread = turnInput.appThread; | |
| 3228 | state.activeTurn = turn; | |
| 3229 | state.providerTurns.set(String(providerTurnId), providerTurn); | |
| 3230 | yield* emitProviderTurn(state, turn, "running", null); | |
| 3231 | yield* updateProviderThread(state, { | |
| 3232 | status: "active", | |
| 3233 | firstRunOrdinal: state.providerThread.firstRunOrdinal ?? turnInput.runOrdinal, | |
| 3234 | lastRunOrdinal: turnInput.runOrdinal, | |
| 3235 | }); | |
| 3236 | yield* updateProviderSession("running", null); | |
| 3237 | if (isCompaction) { | |
| 3238 | yield* sdkCall( | |
| 3239 | "session.summarize", | |
| 3240 | { sessionID: sessionId, ...parsedModel, auto: false }, | |
| 3241 | (signal) => | |
| 3242 | client.session.summarize( | |
| 3243 | { sessionID: sessionId, ...parsedModel, auto: false }, | |
| 3244 | { signal: AbortSignal.any([signal, admissionAbortController!.signal]) }, | |
| 3245 | ), | |
| 3246 | ).pipe( | |
| 3247 | Effect.tap(() => | |
| 3248 | turn.interrupted || turn.finalized || nativeStreamFailure !== null | |
| 3249 | ? Effect.void | |
| 3250 | : emitCompactionItem(state, turn), | |
| 3251 | ), | |
| 3252 | Effect.tap(() => | |
| 3253 | finalizeTurn(state, turn, turn.interrupted ? "interrupted" : "completed"), | |
| 3254 | ), | |
| 3255 | Effect.tapError((cause) => | |
| 3256 | finalizeTurn(state, turn, turn.interrupted ? "interrupted" : "failed", { | |
| 3257 | failure: makeProviderFailure({ cause, class: "provider_error" }), | |
| 3258 | }), | |
| 3259 | ), | |
| 3260 | Effect.ensuring(Deferred.succeed(admissionSettled, undefined)), | |
| 3261 | ); | |
| 3262 | return; | |
| 3263 | } | |
| 3264 | const systemPrompt = [ | |
| 3265 | orchestrationSystemPrompt, | |
| 3266 | buildRuntimeInstructions({ | |
| 3267 | harness: "OpenCode", | |
| 3268 | model: turnInput.modelSelection.model, | |
| 3269 | }), | |
| 3270 | ] | |
| 3271 | .filter(Boolean) | |
| 3272 | .join("\n\n"); | |
| 3273 | const agent = | |
| 3274 | getModelSelectionStringOptionValue(turnInput.modelSelection, "agent") ?? | |
| 3275 | (turnInput.runtimePolicy.interactionMode === "plan" ? "plan" : undefined); | |
| 3276 | const variant = getModelSelectionStringOptionValue( | |
| 3277 | turnInput.modelSelection, | |
| 3278 | "variant", | |
| 3279 | ); | |
| 3280 | yield* submitPrompt( | |
| 3281 | state, | |
| 3282 | turn, | |
| 3283 | { | |
| 3284 | sessionID: sessionId, | |
| 3285 | messageID: turn.admissionMessageId!, | |
| 3286 | model: parsedModel, | |
| 3287 | ...(agent === undefined ? {} : { agent }), | |
| 3288 | ...(variant === undefined ? {} : { variant }), | |
| 3289 | system: systemPrompt, | |
| 3290 | parts, | |
| 3291 | }, | |
| 3292 | admissionAbortController!, | |
| 3293 | ).pipe( | |
| 3294 | Effect.tapError((cause) => | |
| 3295 | admissionAbortController!.signal.aborted | |
| 3296 | ? Effect.void | |
| 3297 | : finalizeTurn(state, turn, "failed", { | |
| 3298 | failure: makeProviderFailure({ cause, class: "provider_error" }), | |
| 3299 | }), | |
| 3300 | ), | |
| 3301 | Effect.catch((cause) => | |
| 3302 | admissionAbortController!.signal.aborted ? Effect.void : Effect.fail(cause), | |
| 3303 | ), | |
| 3304 | Effect.ensuring( | |
| 3305 | Effect.all([ | |
| 3306 | Deferred.succeed(admissionSettled, undefined).pipe(Effect.ignore), | |
| 3307 | Effect.sync(() => { | |
| 3308 | if (turn.admissionAbortController === admissionAbortController) { | |
| 3309 | turn.admissionAbortController = null; | |
| 3310 | } | |
| 3311 | }), | |
| 3312 | ]).pipe(Effect.asVoid), | |
| 3313 | ), | |
| 3314 | ); | |
| 3315 | if (state.activeTurn === turn && !turn.finalized && !turn.interrupted) { | |
| 3316 | const admissionAction = advanceOpenCodePromptAdmission(turn, "accepted"); | |
| 3317 | if (admissionAction === "reconcile-idle") { | |
| 3318 | yield* reconcilePromptAdmission(state, turn); | |
| 3319 | } | |
| 3320 | } | |
| 3321 | }).pipe( | |
| 3322 | Effect.mapError( | |
| 3323 | (cause) => | |
| 3324 | new ProviderAdapter.ProviderAdapterTurnStartError({ | |
| 3325 | driver: OPENCODE_PROVIDER, | |
| 3326 | threadId: turnInput.threadId, | |
| 3327 | providerThreadId: turnInput.providerThread.id, | |
| 3328 | runId: turnInput.runId, | |
| 3329 | cause, | |
| 3330 | }), | |
| 3331 | ), | |
| 3332 | ), | |
| 3333 | steerTurn: (steerInput) => | |
| 3334 | Effect.gen(function* () { | |
| 3335 | const sessionId = nativeThreadId(steerInput.providerThread); | |
| 3336 | const state = threads.get(sessionId); | |
| 3337 | const turn = state?.activeTurn; | |
| 3338 | if ( | |
| 3339 | state === undefined || | |
| 3340 | turn === undefined || | |
| 3341 | turn === null || | |
| 3342 | turn.providerTurnId !== steerInput.providerTurnId || | |
| 3343 | turn.interrupted | |
| 3344 | ) { | |
| 3345 | return yield* protocolError( | |
| 3346 | `OpenCode turn ${steerInput.providerTurnId} is not active`, | |
| 3347 | ); | |
| 3348 | } | |
| 3349 | const parsedModel = OpenCodeRuntime.parseOpenCodeModelSlug(turn.modelSelection.model); | |
| 3350 | if (parsedModel === null) { | |
| 3351 | return yield* protocolError( | |
| 3352 | `OpenCode model '${turn.modelSelection.model}' must use provider/model format`, | |
| 3353 | ); | |
| 3354 | } | |
| 3355 | const text = providerMessageTextWithAttachmentPaths({ | |
| 3356 | text: steerInput.message.text, | |
| 3357 | attachments: steerInput.message.attachments, | |
| 3358 | resolveAttachmentPath: host.resolveAttachmentPath, | |
| 3359 | }).trim(); | |
| 3360 | const files = OpenCodeRuntime.toOpenCodeFileParts({ | |
| 3361 | attachments: steerInput.message.attachments, | |
| 3362 | resolveAttachmentPath: host.resolveAttachmentPath, | |
| 3363 | }); | |
| 3364 | if (text.length === 0 && files.length === 0) { | |
| 3365 | return yield* protocolError("OpenCode steering requires text or an attachment"); | |
| 3366 | } | |
| 3367 | const parts = [ | |
| 3368 | ...(text.length === 0 ? [] : [{ type: "text" as const, text }]), | |
| 3369 | ...files, | |
| 3370 | ]; | |
| 3371 | turn.admissionGeneration = state.nextAdmissionGeneration++; | |
| 3372 | turn.admissionMessageId = yield* makeOpenCodeMessageId(); | |
| 3373 | turn.admissionPending = true; | |
| 3374 | turn.admissionAccepted = false; | |
| 3375 | turn.admissionMessageObserved = false; | |
| 3376 | turn.idleDuringAdmission = false; | |
| 3377 | turn.admissionSettled = Deferred.makeUnsafe<void>(); | |
| 3378 | turn.admissionAbortController = new AbortController(); | |
| 3379 | if (turn.admissionMessageId !== null) | |
| 3380 | turn.usage.promptMessageIds.add(turn.admissionMessageId); | |
| 3381 | const admissionSettled = turn.admissionSettled; | |
| 3382 | const admissionAbortController = turn.admissionAbortController; | |
| 3383 | yield* submitPrompt( | |
| 3384 | state, | |
| 3385 | turn, | |
| 3386 | { | |
| 3387 | sessionID: sessionId, | |
| 3388 | messageID: turn.admissionMessageId, | |
| 3389 | model: parsedModel, | |
| 3390 | parts, | |
| 3391 | }, | |
| 3392 | admissionAbortController!, | |
| 3393 | ).pipe( | |
| 3394 | Effect.ensuring( | |
| 3395 | Effect.all([ | |
| 3396 | Deferred.succeed(admissionSettled, undefined).pipe(Effect.ignore), | |
| 3397 | Effect.sync(() => { | |
| 3398 | if (turn.admissionAbortController === admissionAbortController) { | |
| 3399 | turn.admissionAbortController = null; | |
| 3400 | } | |
| 3401 | }), | |
| 3402 | ]).pipe(Effect.asVoid), | |
| 3403 | ), | |
| 3404 | ); | |
| 3405 | if (state.activeTurn === turn && !turn.finalized) { | |
| 3406 | const admissionAction = advanceOpenCodePromptAdmission(turn, "accepted"); | |
| 3407 | if (admissionAction === "reconcile-idle") { | |
| 3408 | yield* reconcilePromptAdmission(state, turn); | |
| 3409 | } | |
| 3410 | } | |
| 3411 | }).pipe( | |
| 3412 | Effect.mapError( | |
| 3413 | (cause) => | |
| 3414 | new ProviderAdapter.ProviderAdapterSteerRunError({ | |
| 3415 | driver: OPENCODE_PROVIDER, | |
| 3416 | providerThreadId: steerInput.providerThread.id, | |
| 3417 | providerTurnId: steerInput.providerTurnId, | |
| 3418 | cause, | |
| 3419 | }), | |
| 3420 | ), | |
| 3421 | ), | |
| 3422 | interruptTurn: (interruptInput) => | |
| 3423 | Effect.gen(function* () { | |
| 3424 | const sessionId = nativeThreadId(interruptInput.providerThread); | |
| 3425 | const state = threads.get(sessionId); | |
| 3426 | const turn = state?.activeTurn; | |
| 3427 | // Stop on a settled turn: a background task child can outlive | |
| 3428 | // it (busySessionIds), and aborting the descendants ends it. | |
| 3429 | if ( | |
| 3430 | (turn === undefined || turn === null) && | |
| 3431 | interruptInput.requestRuntimeRestart === true | |
| 3432 | ) { | |
| 3433 | for (const controller of commandControllers.get(sessionId) ?? []) | |
| 3434 | controller.abort(); | |
| 3435 | yield* abortDescendants(sessionId); | |
| 3436 | return; | |
| 3437 | } | |
| 3438 | if ( | |
| 3439 | turn === undefined || | |
| 3440 | turn === null || | |
| 3441 | turn.providerTurnId !== interruptInput.providerTurnId | |
| 3442 | ) { | |
| 3443 | return yield* protocolError( | |
| 3444 | `OpenCode turn ${interruptInput.providerTurnId} is not active`, | |
| 3445 | ); | |
| 3446 | } | |
| 3447 | turn.interrupted = true; | |
| 3448 | for (const controller of commandControllers.get(sessionId) ?? []) controller.abort(); | |
| 3449 | const admissionWasPending = turn.admissionPending; | |
| 3450 | cancelOpenCodePromptAdmission(turn, state!.nextAdmissionGeneration++); | |
| 3451 | turn.admissionAbortController?.abort(); | |
| 3452 | if (admissionWasPending) { | |
| 3453 | // Settle the cancelled request before sending the session abort. | |
| 3454 | // A timed-out local request must not prevent the definitive | |
| 3455 | // server-side abort below from running. | |
| 3456 | yield* Deferred.await(turn.admissionSettled).pipe( | |
| 3457 | Effect.timeout("10 seconds"), | |
| 3458 | Effect.ignore, | |
| 3459 | ); | |
| 3460 | } | |
| 3461 | yield* sdkCall("session.abort", { sessionID: sessionId }, (signal) => | |
| 3462 | client.session.abort({ sessionID: sessionId }, { signal }), | |
| 3463 | ).pipe( | |
| 3464 | Effect.timeout("10 seconds"), | |
| 3465 | // The turn can settle while the abort is in flight, and | |
| 3466 | // aborting an already-idle session fails. That stop still | |
| 3467 | // succeeded; only surface failures for a turn that is | |
| 3468 | // genuinely still running. | |
| 3469 | Effect.catch((cause) => | |
| 3470 | turn.finalized | |
| 3471 | ? Effect.void | |
| 3472 | : Effect.suspend(() => { | |
| 3473 | turn.interrupted = false; | |
| 3474 | return Effect.fail(cause); | |
| 3475 | }), | |
| 3476 | ), | |
| 3477 | ); | |
| 3478 | // Root abort does not stop child sessions. Report incomplete cleanup | |
| 3479 | // even if the root has already emitted its terminal event. | |
| 3480 | yield* abortDescendants(sessionId); | |
| 3481 | }).pipe( | |
| 3482 | Effect.mapError( | |
| 3483 | (cause) => | |
| 3484 | new ProviderAdapter.ProviderAdapterInterruptError({ | |
| 3485 | driver: OPENCODE_PROVIDER, | |
| 3486 | providerThreadId: interruptInput.providerThread.id, | |
| 3487 | providerTurnId: interruptInput.providerTurnId, | |
| 3488 | cause, | |
| 3489 | }), | |
| 3490 | ), | |
| 3491 | ), | |
| 3492 | respondToRuntimeRequest: (requestInput) => | |
| 3493 | Effect.gen(function* () { | |
| 3494 | const pending = pendingRequests.get(String(requestInput.requestId)); | |
| 3495 | if (pending === undefined) { | |
| 3496 | return yield* protocolError( | |
| 3497 | `No pending OpenCode request ${requestInput.requestId}`, | |
| 3498 | ); | |
| 3499 | } | |
| 3500 | if (pending.question !== undefined) { | |
| 3501 | if (requestInput.answers === undefined) { | |
| 3502 | return yield* protocolError( | |
| 3503 | `OpenCode question request ${requestInput.requestId} requires answers`, | |
| 3504 | ); | |
| 3505 | } | |
| 3506 | const answers = OpenCodeRuntime.toOpenCodeQuestionAnswers( | |
| 3507 | pending.question, | |
| 3508 | requestInput.answers, | |
| 3509 | ); | |
| 3510 | yield* sdkCall( | |
| 3511 | "question.reply", | |
| 3512 | { requestID: pending.nativeRequestId, answers }, | |
| 3513 | (signal) => | |
| 3514 | client.question.reply( | |
| 3515 | { | |
| 3516 | requestID: pending.nativeRequestId, | |
| 3517 | answers, | |
| 3518 | }, | |
| 3519 | { signal }, | |
| 3520 | ), | |
| 3521 | ).pipe(Effect.timeout("10 seconds")); | |
| 3522 | return; | |
| 3523 | } | |
| 3524 | if (requestInput.decision === undefined) { | |
| 3525 | return yield* protocolError( | |
| 3526 | `OpenCode approval request ${requestInput.requestId} requires a decision`, | |
| 3527 | ); | |
| 3528 | } | |
| 3529 | const reply = OpenCodeRuntime.toOpenCodePermissionReply(requestInput.decision); | |
| 3530 | yield* sdkCall( | |
| 3531 | "permission.reply", | |
| 3532 | { requestID: pending.nativeRequestId, reply }, | |
| 3533 | (signal) => | |
| 3534 | client.permission.reply( | |
| 3535 | { | |
| 3536 | requestID: pending.nativeRequestId, | |
| 3537 | reply, | |
| 3538 | }, | |
| 3539 | { signal }, | |
| 3540 | ), | |
| 3541 | ).pipe(Effect.timeout("10 seconds")); | |
| 3542 | }).pipe( | |
| 3543 | Effect.mapError( | |
| 3544 | (cause) => | |
| 3545 | new ProviderAdapter.ProviderAdapterRuntimeRequestResponseError({ | |
| 3546 | driver: OPENCODE_PROVIDER, | |
| 3547 | requestId: requestInput.requestId, | |
| 3548 | cause, | |
| 3549 | }), | |
| 3550 | ), | |
| 3551 | ), | |
| 3552 | readThreadSnapshot: (snapshotInput) => | |
| 3553 | readSnapshot(snapshotInput.providerThread).pipe( | |
| 3554 | Effect.mapError( | |
| 3555 | (cause) => | |
| 3556 | new ProviderAdapter.ProviderAdapterReadThreadSnapshotError({ | |
| 3557 | driver: OPENCODE_PROVIDER, | |
| 3558 | providerThreadId: snapshotInput.providerThread.id, | |
| 3559 | cause, | |
| 3560 | }), | |
| 3561 | ), | |
| 3562 | ), | |
| 3563 | rollbackThread: (rollbackInput) => | |
| 3564 | Effect.gen(function* () { | |
| 3565 | const sessionId = nativeThreadId(rollbackInput.providerThread); | |
| 3566 | const state = threads.get(sessionId); | |
| 3567 | if (state?.activeTurn !== null && state?.activeTurn !== undefined) { | |
| 3568 | return yield* protocolError( | |
| 3569 | `Cannot roll back OpenCode thread ${rollbackInput.providerThread.id} while a turn is active`, | |
| 3570 | ); | |
| 3571 | } | |
| 3572 | const response = yield* sdkCall("session.messages", { sessionID: sessionId }, () => | |
| 3573 | client.session.messages({ sessionID: sessionId }), | |
| 3574 | ); | |
| 3575 | const messages = unwrapData("session.messages", response); | |
| 3576 | let boundaryMessageId: string | undefined; | |
| 3577 | if (rollbackInput.target.type === "thread_start") { | |
| 3578 | boundaryMessageId = messages.find(({ info }) => info.role === "user")?.info.id; | |
| 3579 | } else { | |
| 3580 | boundaryMessageId = openCodeBoundaryAfterProviderTurn( | |
| 3581 | rollbackInput.providerThreadTurns, | |
| 3582 | rollbackInput.target.providerTurn.id, | |
| 3583 | ); | |
| 3584 | } | |
| 3585 | let retainedThread = rollbackInput.providerThread; | |
| 3586 | if (boundaryMessageId !== undefined) { | |
| 3587 | const boundaryIndex = messages.findIndex( | |
| 3588 | ({ info }) => info.id === boundaryMessageId, | |
| 3589 | ); | |
| 3590 | if (boundaryIndex < 0) | |
| 3591 | return yield* protocolError( | |
| 3592 | "The OpenCode rewind boundary is no longer available.", | |
| 3593 | ); | |
| 3594 | const fork = unwrapData( | |
| 3595 | "session.fork", | |
| 3596 | yield* sdkCall( | |
| 3597 | "session.fork", | |
| 3598 | { sessionID: sessionId, messageID: boundaryMessageId }, | |
| 3599 | () => | |
| 3600 | client.session.fork({ sessionID: sessionId, messageID: boundaryMessageId }), | |
| 3601 | ), | |
| 3602 | ); | |
| 3603 | const retained = unwrapData( | |
| 3604 | "session.messages", | |
| 3605 | yield* sdkCall("session.messages", { sessionID: fork.id }, () => | |
| 3606 | client.session.messages({ sessionID: fork.id }), | |
| 3607 | ), | |
| 3608 | ); | |
| 3609 | if (retained.length !== boundaryIndex) | |
| 3610 | return yield* protocolError( | |
| 3611 | "OpenCode did not preserve the requested rewind boundary.", | |
| 3612 | ); | |
| 3613 | yield* sdkCall("session.update", { sessionID: fork.id }, () => | |
| 3614 | client.session.update({ | |
| 3615 | sessionID: fork.id, | |
| 3616 | permission: openCodePermissionRules(input.runtimePolicy), | |
| 3617 | }), | |
| 3618 | ); | |
| 3619 | retainedThread = { | |
| 3620 | ...rollbackInput.providerThread, | |
| 3621 | nativeThreadRef: providerRef(fork.id), | |
| 3622 | }; | |
| 3623 | registerThread(fork, retainedThread, state?.appThread ?? null); | |
| 3624 | } | |
| 3625 | const snapshot = yield* readSnapshot(retainedThread); | |
| 3626 | return { | |
| 3627 | ...snapshot, | |
| 3628 | providerThread: { | |
| 3629 | ...snapshot.providerThread, | |
| 3630 | nativeConversationHeadRef: | |
| 3631 | rollbackInput.target.type === "provider_turn" | |
| 3632 | ? rollbackInput.target.providerTurn.nativeTurnRef | |
| 3633 | : null, | |
| 3634 | }, | |
| 3635 | }; | |
| 3636 | }).pipe( | |
| 3637 | Effect.mapError( | |
| 3638 | (cause) => | |
| 3639 | new ProviderAdapter.ProviderAdapterRollbackThreadError({ | |
| 3640 | driver: OPENCODE_PROVIDER, | |
| 3641 | providerThreadId: rollbackInput.providerThread.id, | |
| 3642 | checkpointId: rollbackInput.target.checkpointId, | |
| 3643 | cause, | |
| 3644 | }), | |
| 3645 | ), | |
| 3646 | ), | |
| 3647 | forkThread: (forkInput) => | |
| 3648 | Effect.gen(function* () { | |
| 3649 | const sourceSessionId = nativeThreadId(forkInput.sourceProviderThread); | |
| 3650 | const sourceState = threads.get(sourceSessionId); | |
| 3651 | if (sourceState?.activeTurn !== null && sourceState?.activeTurn !== undefined) { | |
| 3652 | return yield* protocolError( | |
| 3653 | `Cannot fork OpenCode thread ${forkInput.sourceProviderThread.id} while a turn is active`, | |
| 3654 | ); | |
| 3655 | } | |
| 3656 | let boundaryMessageId: string | undefined; | |
| 3657 | if (forkInput.providerTurnId !== undefined) { | |
| 3658 | const sourceTurns = forkInput.sourceProviderTurns ?? []; | |
| 3659 | const selected = sourceTurns.find((turn) => turn.id === forkInput.providerTurnId); | |
| 3660 | if (selected === undefined) { | |
| 3661 | return yield* protocolError( | |
| 3662 | `OpenCode fork boundary turn ${forkInput.providerTurnId} was not found`, | |
| 3663 | ); | |
| 3664 | } | |
| 3665 | boundaryMessageId = openCodeBoundaryAfterProviderTurn(sourceTurns, selected.id); | |
| 3666 | } | |
| 3667 | const response = yield* sdkCall( | |
| 3668 | "session.fork", | |
| 3669 | { | |
| 3670 | sessionID: sourceSessionId, | |
| 3671 | ...(boundaryMessageId === undefined ? {} : { messageID: boundaryMessageId }), | |
| 3672 | }, | |
| 3673 | () => | |
| 3674 | client.session.fork({ | |
| 3675 | sessionID: sourceSessionId, | |
| 3676 | ...(boundaryMessageId === undefined ? {} : { messageID: boundaryMessageId }), | |
| 3677 | }), | |
| 3678 | ); | |
| 3679 | const nativeSession = unwrapData("session.fork", response); | |
| 3680 | const forkedAt = yield* DateTime.now; | |
| 3681 | const providerThread = makeProviderThread({ | |
| 3682 | idAllocator, | |
| 3683 | providerInstanceId: options.instanceId, | |
| 3684 | providerSessionId: input.providerSessionId, | |
| 3685 | appThreadId: forkInput.targetThreadId, | |
| 3686 | ...(forkInput.ownerNodeId === undefined | |
| 3687 | ? {} | |
| 3688 | : { ownerNodeId: forkInput.ownerNodeId }), | |
| 3689 | nativeSession, | |
| 3690 | forkedFrom: { | |
| 3691 | providerThreadId: forkInput.sourceProviderThread.id, | |
| 3692 | ...(forkInput.providerTurnId === undefined | |
| 3693 | ? {} | |
| 3694 | : { providerTurnId: forkInput.providerTurnId }), | |
| 3695 | }, | |
| 3696 | now: forkedAt, | |
| 3697 | }); | |
| 3698 | registerThread(nativeSession, providerThread, null); | |
| 3699 | return providerThread; | |
| 3700 | }).pipe( | |
| 3701 | Effect.mapError( | |
| 3702 | (cause) => | |
| 3703 | new ProviderAdapter.ProviderAdapterForkThreadError({ | |
| 3704 | driver: OPENCODE_PROVIDER, | |
| 3705 | providerThreadId: forkInput.sourceProviderThread.id, | |
| 3706 | cause, | |
| 3707 | }), | |
| 3708 | ), | |
| 3709 | ), | |
| 3710 | }; | |
| 3711 | ||
| 3712 | return runtimeSession; | |
| 3713 | }, | |
| 3714 | (effect, input) => | |
| 3715 | effect.pipe( | |
| 3716 | Effect.mapError( | |
| 3717 | (cause) => | |
| 3718 | new ProviderAdapter.ProviderAdapterOpenSessionError({ | |
| 3719 | driver: OPENCODE_PROVIDER, | |
| 3720 | providerSessionId: input.providerSessionId, | |
| 3721 | cause, | |
| 3722 | }), | |
| 3723 | ), | |
| 3724 | ), | |
| 3725 | ), | |
| 3726 | }); | |
| 3727 | } | |
| 3728 | ||
| 3729 | export type OpenCodeAdapterV2DriverEnv = | |
| 3730 | | OpenCodeRuntime.OpenCodeRuntime | |
| 3731 | | IdAllocator.IdAllocatorV2 | |
| 3732 | | ProviderEventLoggers.ProviderEventLoggers | |
| 3733 | | ProviderHost.ProviderHost; | |
| 3734 | ||
| 3735 | export const OpenCodeAdapterV2Driver: ProviderAdapterDriver< | |
| 3736 | OpenCodeSettings, | |
| 3737 | OpenCodeAdapterV2DriverEnv | |
| 3738 | > = { | |
| 3739 | driverKind: OPENCODE_PROVIDER, | |
| 3740 | configSchema: OpenCodeSettings, | |
| 3741 | defaultConfig: (): OpenCodeSettings => DEFAULT_OPENCODE_SETTINGS, | |
| 3742 | create: Effect.fn("OpenCodeAdapterV2Driver.create")( | |
| 3743 | function* (input: ProviderAdapterDriverCreateInput<OpenCodeSettings>) { | |
| 3744 | const hostEnvironment = yield* HostProcessEnvironment; | |
| 3745 | const openCodeRuntime = yield* OpenCodeRuntime.OpenCodeRuntime; | |
| 3746 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | |
| 3747 | const providerEventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers; | |
| 3748 | const host = yield* ProviderHost.ProviderHost; | |
| 3749 | return makeOpenCodeAdapterV2({ | |
| 3750 | instanceId: input.instanceId, | |
| 3751 | settings: { ...input.config, enabled: input.enabled }, | |
| 3752 | environment: mergeProviderInstanceEnvironment(input.environment, hostEnvironment), | |
| 3753 | runtime: openCodeRuntime, | |
| 3754 | idAllocator, | |
| 3755 | host, | |
| 3756 | ...(providerEventLoggers.native === undefined | |
| 3757 | ? {} | |
| 3758 | : { nativeEventLogger: providerEventLoggers.native }), | |
| 3759 | }); | |
| 3760 | }, | |
| 3761 | (effect, input) => | |
| 3762 | effect.pipe( | |
| 3763 | Effect.mapError( | |
| 3764 | (cause) => | |
| 3765 | new ProviderAdapterDriverCreateError({ | |
| 3766 | driver: OPENCODE_PROVIDER, | |
| 3767 | instanceId: input.instanceId, | |
| 3768 | detail: "Failed to create OpenCode v2 adapter.", | |
| 3769 | cause, | |
| 3770 | }), | |
| 3771 | ), | |
| 3772 | ), | |
| 3773 | ), | |
| 3774 | }; | |
| 3775 | ||
| 3776 | const layer: Layer.Layer<ProviderAdapter.ProviderAdapterV2, never, OpenCodeAdapterV2DriverEnv> = | |
| 3777 | Layer.effect( | |
| 3778 | ProviderAdapter.ProviderAdapterV2, | |
| 3779 | Effect.gen(function* () { | |
| 3780 | const hostEnvironment = yield* HostProcessEnvironment; | |
| 3781 | const openCodeRuntime = yield* OpenCodeRuntime.OpenCodeRuntime; | |
| 3782 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | |
| 3783 | const providerEventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers; | |
| 3784 | const host = yield* ProviderHost.ProviderHost; | |
| 3785 | return makeOpenCodeAdapterV2({ | |
| 3786 | instanceId: OPENCODE_DEFAULT_INSTANCE_ID, | |
| 3787 | settings: DEFAULT_OPENCODE_SETTINGS, | |
| 3788 | environment: hostEnvironment, | |
| 3789 | runtime: openCodeRuntime, | |
| 3790 | idAllocator, | |
| 3791 | host, | |
| 3792 | ...(providerEventLoggers.native === undefined | |
| 3793 | ? {} | |
| 3794 | : { nativeEventLogger: providerEventLoggers.native }), | |
| 3795 | }); | |
| 3796 | }), | |
| 3797 | ); |
packages/provider-opencode/src/server/driver.test.ts created+274| ... | ... | @@ -0,0 +1,274 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off | |
| 2 | import * as NodeFS from "node:fs"; | |
| 3 | import * as NodeOS from "node:os"; | |
| 4 | import * as NodePath from "node:path"; | |
| 5 | ||
| 6 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 7 | import { assert, it } from "@effect/vitest"; | |
| 8 | import { ProviderInstanceId } from "@t3tools/contracts"; | |
| 9 | import type { OpenCodeSettings } from "../settings.ts"; | |
| 10 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 11 | import * as Effect from "effect/Effect"; | |
| 12 | import * as Fiber from "effect/Fiber"; | |
| 13 | import * as Layer from "effect/Layer"; | |
| 14 | import * as TestClock from "effect/testing/TestClock"; | |
| 15 | import { HttpClient } from "effect/http"; | |
| 16 | import { ChildProcessSpawner } from "effect/process"; | |
| 17 | ||
| 18 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; | |
| 19 | import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; | |
| 20 | import * as ProviderMaintenance from "@t3tools/provider-core/server/maintenanceResolver"; | |
| 21 | import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; | |
| 22 | import { | |
| 23 | OPENCODE_2_RESPONSES, | |
| 24 | OPENCODE_2_WORKSPACE_RESPONSES, | |
| 25 | replayOpenCodeServer, | |
| 26 | } from "./probeResponses.fixture.ts"; | |
| 27 | import { OpenCodeDriver, openCodeUpdateFor } from "./driver.ts"; | |
| 28 | import { layerTestProviderHost } from "@t3tools/provider-testing/host"; | |
| 29 | ||
| 30 | const serverStarts: Array<string> = []; | |
| 31 | const reachedServer = (operation: string) => | |
| 32 | Effect.sync(() => serverStarts.push(operation)).pipe( | |
| 33 | Effect.andThen( | |
| 34 | Effect.fail( | |
| 35 | new OpenCodeRuntime.OpenCodeRuntimeError({ | |
| 36 | operation, | |
| 37 | detail: "reached a 1.x server path", | |
| 38 | }), | |
| 39 | ), | |
| 40 | ), | |
| 41 | ); | |
| 42 | // Reports OpenCode 2 from `--version`; any attempt to reach a server is recorded and refused. | |
| 43 | const openCode2Runtime = { | |
| 44 | runOpenCodeCommand: () => Effect.succeed({ stdout: "opencode v2.0.18\n", stderr: "", code: 0 }), | |
| 45 | startOpenCodeServerProcess: () => reachedServer("start"), | |
| 46 | connectToOpenCodeServer: () => reachedServer("connect"), | |
| 47 | } as unknown as OpenCodeRuntime.OpenCodeRuntimeShape; | |
| 48 | ||
| 49 | const layer = Layer.mergeAll( | |
| 50 | IdAllocator.layer, | |
| 51 | layerTestProviderHost(), | |
| 52 | Layer.succeed( | |
| 53 | ProviderEventLoggers.ProviderEventLoggers, | |
| 54 | ProviderEventLoggers.NoOpProviderEventLoggers, | |
| 55 | ), | |
| 56 | Layer.succeed(OpenCodeRuntime.OpenCodeRuntime, openCode2Runtime), | |
| 57 | ).pipe(Layer.provideMerge(NodeServices.layer)); | |
| 58 | ||
| 59 | const create = (config: Partial<OpenCodeSettings>, http: HttpClient.HttpClient) => | |
| 60 | OpenCodeDriver.create({ | |
| 61 | instanceId: ProviderInstanceId.make("opencode-test"), | |
| 62 | displayName: undefined, | |
| 63 | environment: [], | |
| 64 | enabled: true, | |
| 65 | config: { ...OpenCodeDriver.defaultConfig(), ...config }, | |
| 66 | }).pipe(Effect.provideService(HttpClient.HttpClient, http)); | |
| 67 | ||
| 68 | const noHttp = HttpClient.make(() => Effect.die("A local binary must not be probed over HTTP")); | |
| 69 | ||
| 70 | it.layer(layer)("OpenCodeDriver runtime selection", (it) => { | |
| 71 | it.effect("never starts a 1.x server for an OpenCode 2 instance", () => | |
| 72 | Effect.gen(function* () { | |
| 73 | serverStarts.length = 0; | |
| 74 | const instance = yield* create({}, noHttp); | |
| 75 | // Text generation goes to the instance's 2.x server, which this test refuses to start. | |
| 76 | yield* Effect.flip( | |
| 77 | instance.textGeneration.generateThreadTitle({ | |
| 78 | cwd: process.cwd(), | |
| 79 | message: "hello", | |
| 80 | modelSelection: { instanceId: instance.instanceId, model: "opencode/big-pickle" }, | |
| 81 | }), | |
| 82 | ); | |
| 83 | assert.deepStrictEqual(serverStarts, ["start"]); | |
| 84 | }).pipe(Effect.scoped), | |
| 85 | ); | |
| 86 | ||
| 87 | it.effect("lists an OpenCode 2 workspace's own skills and commands from its server", () => | |
| 88 | Effect.gen(function* () { | |
| 89 | serverStarts.length = 0; | |
| 90 | const requested: Array<string> = []; | |
| 91 | const server = replayOpenCodeServer( | |
| 92 | { ...OPENCODE_2_RESPONSES, ...OPENCODE_2_WORKSPACE_RESPONSES }, | |
| 93 | "secret", | |
| 94 | requested, | |
| 95 | ); | |
| 96 | const instance = yield* create( | |
| 97 | { serverUrl: "http://127.0.0.1:4096", serverPassword: "secret" }, | |
| 98 | server, | |
| 99 | ); | |
| 100 | ||
| 101 | const workspace = yield* instance.snapshotForCwd!("/work"); | |
| 102 | assert.includeMembers( | |
| 103 | workspace.skills.map((skill) => skill.name), | |
| 104 | ["plum", "opencode"], | |
| 105 | ); | |
| 106 | assert.deepStrictEqual( | |
| 107 | workspace.slashCommands.map((command) => command.name), | |
| 108 | ["compact", "init", "review", "bee"], | |
| 109 | ); | |
| 110 | assert.include(requested, "/api/skill"); | |
| 111 | assert.deepStrictEqual(serverStarts, []); | |
| 112 | }).pipe(Effect.scoped), | |
| 113 | ); | |
| 114 | ||
| 115 | it.effect("answers capability reads without waiting on an unreachable server", () => | |
| 116 | Effect.gen(function* () { | |
| 117 | const hang = HttpClient.make(() => Effect.never); | |
| 118 | const instance = yield* create({ serverUrl: "http://127.0.0.1:9" }, hang); | |
| 119 | ||
| 120 | // No probe has succeeded yet, and the server never answers: the 1.x default applies. | |
| 121 | const capabilities = yield* instance.orchestrationAdapter | |
| 122 | .getCapabilities() | |
| 123 | .pipe(Effect.forkChild); | |
| 124 | yield* Effect.yieldNow; | |
| 125 | assert.isDefined(capabilities.pollUnsafe()); | |
| 126 | yield* Fiber.join(capabilities); | |
| 127 | }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), | |
| 128 | ); | |
| 129 | }); | |
| 130 | ||
| 131 | // npm links `<prefix>/bin/opencode` into the package that installed it: | |
| 132 | // `opencode-ai` for 1.x and `@opencode/cli` (whose bin is `bin/opencode.exe`) for 2.x. | |
| 133 | const npmGlobalInstall = (prefix: string, packagePath: ReadonlyArray<string>, bin: string) => { | |
| 134 | const target = NodePath.join(prefix, "lib", "node_modules", ...packagePath, "bin", bin); | |
| 135 | NodeFS.mkdirSync(NodePath.dirname(target), { recursive: true }); | |
| 136 | NodeFS.writeFileSync(target, "#!/bin/sh\n"); | |
| 137 | NodeFS.chmodSync(target, 0o755); | |
| 138 | const link = NodePath.join(prefix, "bin", "opencode"); | |
| 139 | NodeFS.mkdirSync(NodePath.dirname(link), { recursive: true }); | |
| 140 | NodeFS.symlinkSync(target, link); | |
| 141 | return link; | |
| 142 | }; | |
| 143 | const resolveUpdate = (generation: "v1" | "v2", binaryPath: string) => | |
| 144 | ProviderMaintenance.resolveProviderMaintenanceCapabilitiesEffect(openCodeUpdateFor(generation), { | |
| 145 | binaryPath, | |
| 146 | env: { PATH: "" }, | |
| 147 | }).pipe( | |
| 148 | Effect.provideService( | |
| 149 | ChildProcessSpawner.ChildProcessSpawner, | |
| 150 | ChildProcessSpawner.make(() => Effect.die("resolving an update must not spawn")), | |
| 151 | ), | |
| 152 | Effect.provide(NodeServices.layer), | |
| 153 | ); | |
| 154 | ||
| 155 | it.effect.skipIf(HostProcessPlatform.defaultValue() === "win32")( | |
| 156 | "updates an npm-global OpenCode 2 install as @opencode/cli and a 1.x one as opencode-ai", | |
| 157 | () => | |
| 158 | Effect.gen(function* () { | |
| 159 | const root = NodeFS.realpathSync( | |
| 160 | NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-opencode-update-")), | |
| 161 | ); | |
| 162 | const v2Prefix = NodePath.join(root, "v2"); | |
| 163 | const v1Prefix = NodePath.join(root, "v1"); | |
| 164 | const v2 = yield* resolveUpdate( | |
| 165 | "v2", | |
| 166 | npmGlobalInstall(v2Prefix, ["@opencode", "cli"], "opencode.exe"), | |
| 167 | ); | |
| 168 | assert.deepStrictEqual(v2.update?.args, [ | |
| 169 | "install", | |
| 170 | "-g", | |
| 171 | "--prefix", | |
| 172 | v2Prefix, | |
| 173 | "--allow-scripts=@opencode/cli", | |
| 174 | "@opencode/cli@latest", | |
| 175 | ]); | |
| 176 | const v1 = yield* resolveUpdate( | |
| 177 | "v1", | |
| 178 | npmGlobalInstall(v1Prefix, ["opencode-ai"], "opencode"), | |
| 179 | ); | |
| 180 | assert.strictEqual(v1.update?.args.at(-1), "opencode-ai@latest"); | |
| 181 | // A 1.x install is never offered 2.x's package, or the reverse. | |
| 182 | assert.isNull( | |
| 183 | (yield* resolveUpdate("v2", NodePath.join(v1Prefix, "bin", "opencode"))).update, | |
| 184 | ); | |
| 185 | assert.isNull( | |
| 186 | (yield* resolveUpdate("v1", NodePath.join(v2Prefix, "bin", "opencode"))).update, | |
| 187 | ); | |
| 188 | }), | |
| 189 | ); | |
| 190 | ||
| 191 | // The update the driver offers follows the version the binary reports now. | |
| 192 | const versionOutput: { current: string | undefined } = { current: undefined }; | |
| 193 | const versionProbes: Array<string> = []; | |
| 194 | const changingRuntime = { | |
| 195 | runOpenCodeCommand: ({ binaryPath }: { readonly binaryPath: string }) => | |
| 196 | Effect.sync(() => versionProbes.push(binaryPath)).pipe( | |
| 197 | Effect.andThen( | |
| 198 | versionOutput.current === undefined | |
| 199 | ? Effect.fail( | |
| 200 | new OpenCodeRuntime.OpenCodeRuntimeError({ | |
| 201 | operation: "version", | |
| 202 | detail: "no version", | |
| 203 | }), | |
| 204 | ) | |
| 205 | : Effect.succeed({ stdout: versionOutput.current, stderr: "", code: 0 }), | |
| 206 | ), | |
| 207 | ), | |
| 208 | startOpenCodeServerProcess: () => reachedServer("start"), | |
| 209 | connectToOpenCodeServer: () => reachedServer("connect"), | |
| 210 | } as unknown as OpenCodeRuntime.OpenCodeRuntimeShape; | |
| 211 | const layerUpdate = Layer.mergeAll( | |
| 212 | IdAllocator.layer, | |
| 213 | layerTestProviderHost(), | |
| 214 | Layer.succeed( | |
| 215 | ProviderEventLoggers.ProviderEventLoggers, | |
| 216 | ProviderEventLoggers.NoOpProviderEventLoggers, | |
| 217 | ), | |
| 218 | Layer.succeed(OpenCodeRuntime.OpenCodeRuntime, changingRuntime), | |
| 219 | ).pipe(Layer.provideMerge(NodeServices.layer)); | |
| 220 | ||
| 221 | it.layer(layerUpdate)("OpenCodeDriver updates", (it) => { | |
| 222 | it.effect("never runs the binary for a disabled instance's update check", () => | |
| 223 | Effect.gen(function* () { | |
| 224 | versionProbes.length = 0; | |
| 225 | versionOutput.current = "opencode v2.0.18\n"; | |
| 226 | const instance = yield* OpenCodeDriver.create({ | |
| 227 | instanceId: ProviderInstanceId.make("opencode-disabled"), | |
| 228 | displayName: undefined, | |
| 229 | environment: [], | |
| 230 | enabled: false, | |
| 231 | config: { ...OpenCodeDriver.defaultConfig(), binaryPath: "opencode" }, | |
| 232 | }).pipe(Effect.provideService(HttpClient.HttpClient, noHttp)); | |
| 233 | const maintenance = yield* instance.snapshot.resolveMaintenance(); | |
| 234 | yield* instance.snapshot.resolveMaintenance({ fresh: true }); | |
| 235 | assert.isNull(maintenance.update); | |
| 236 | assert.deepStrictEqual(versionProbes, []); | |
| 237 | }).pipe(Effect.scoped), | |
| 238 | ); | |
| 239 | ||
| 240 | it.effect.skipIf(HostProcessPlatform.defaultValue() === "win32")( | |
| 241 | "offers no package update for an unknown version and follows a changed one on a fresh read", | |
| 242 | () => | |
| 243 | Effect.gen(function* () { | |
| 244 | const root = NodeFS.realpathSync( | |
| 245 | NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-opencode-driver-update-")), | |
| 246 | ); | |
| 247 | const binaryPath = npmGlobalInstall(NodePath.join(root, "v1"), ["opencode-ai"], "opencode"); | |
| 248 | const instance = yield* OpenCodeDriver.create({ | |
| 249 | instanceId: ProviderInstanceId.make("opencode-update"), | |
| 250 | displayName: undefined, | |
| 251 | environment: [], | |
| 252 | enabled: true, | |
| 253 | config: { ...OpenCodeDriver.defaultConfig(), binaryPath }, | |
| 254 | }).pipe(Effect.provideService(HttpClient.HttpClient, noHttp)); | |
| 255 | ||
| 256 | // The version could not be read: which package owns the binary is unknown. | |
| 257 | versionOutput.current = undefined; | |
| 258 | const unknown = yield* instance.snapshot.resolveMaintenance({ fresh: true }); | |
| 259 | assert.isNull(unknown.update); | |
| 260 | assert.isNull(unknown.packageName); | |
| 261 | ||
| 262 | versionOutput.current = "1.18.32\n"; | |
| 263 | const v1 = yield* instance.snapshot.resolveMaintenance({ fresh: true }); | |
| 264 | assert.strictEqual(v1.packageName, "opencode-ai"); | |
| 265 | ||
| 266 | // The same path now reports 2.x (reinstalled in place): a fresh read re-probes | |
| 267 | // and never offers 1.x's package for it. | |
| 268 | versionOutput.current = "opencode v2.0.18\n"; | |
| 269 | const v2 = yield* instance.snapshot.resolveMaintenance({ fresh: true }); | |
| 270 | assert.strictEqual(v2.packageName, "@opencode/cli"); | |
| 271 | assert.isNull(v2.update); | |
| 272 | }).pipe(Effect.scoped), | |
| 273 | ); | |
| 274 | }); |
packages/provider-opencode/src/server/driver.ts created+540| ... | ... | @@ -0,0 +1,540 @@ |
| 1 | /** | |
| 2 | * OpenCodeDriver — `ProviderDriver` for the OpenCode runtime. | |
| 3 | * | |
| 4 | * Mirrors the Codex / Claude drivers: a plain value whose `create()` | |
| 5 | * bundles `snapshot` / `adapter` / `textGeneration` closures over the | |
| 6 | * per-instance `OpenCodeSettings`. | |
| 7 | * | |
| 8 | * Two instances with different `serverUrl`s therefore talk to independent | |
| 9 | * OpenCode servers; when no `serverUrl` is set, the adapter + text-generation | |
| 10 | * shares spin up their own scoped child processes, and those child | |
| 11 | * processes are released when the registry scope closes. | |
| 12 | * | |
| 13 | * @module provider/Drivers/OpenCodeDriver | |
| 14 | */ | |
| 15 | import { ProviderDriverKind } from "@t3tools/contracts"; | |
| 16 | import { OpenCodeSettings } from "../settings.ts"; | |
| 17 | import * as Crypto from "effect/Crypto"; | |
| 18 | import * as Deferred from "effect/Deferred"; | |
| 19 | import * as Effect from "effect/Effect"; | |
| 20 | import * as Option from "effect/Option"; | |
| 21 | import * as FileSystem from "effect/FileSystem"; | |
| 22 | import * as Path from "effect/Path"; | |
| 23 | import * as Schema from "effect/Schema"; | |
| 24 | import * as Stream from "effect/Stream"; | |
| 25 | import { HttpClient } from "effect/http"; | |
| 26 | import { ChildProcessSpawner } from "effect/process"; | |
| 27 | ||
| 28 | import * as OpenCode2TextGeneration from "./v2/textGeneration.ts"; | |
| 29 | import { makeOpenCodeTextGeneration } from "./textGeneration.ts"; | |
| 30 | import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; | |
| 31 | import * as OpenCodeAdapterV2 from "./adapter.ts"; | |
| 32 | import * as OpenCode2AdapterV2 from "./v2/adapter.ts"; | |
| 33 | import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; | |
| 34 | import type { ProviderTextGeneration } from "@t3tools/provider-core/server/textGeneration"; | |
| 35 | import { ProviderDriverError } from "@t3tools/provider-core/server/errors"; | |
| 36 | import { readOpenCodeGoUsageLimits } from "./usageLimits.ts"; | |
| 37 | import { | |
| 38 | checkOpenCodeProviderStatus, | |
| 39 | loadOpenCode2Workspace, | |
| 40 | makeOpenCode2ModelLoader, | |
| 41 | makePendingOpenCodeProvider, | |
| 42 | openCode2CommandsToServerProviderSlashCommands, | |
| 43 | openCode2SkillsToServerProviderSkills, | |
| 44 | openCodeSkillsToServerProviderSkills, | |
| 45 | openCodeCommandsToServerProviderSlashCommands, | |
| 46 | } from "./status.ts"; | |
| 47 | import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; | |
| 48 | import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; | |
| 49 | import { | |
| 50 | makeOpenCodeRuntimeProbe, | |
| 51 | probeOpenCodeRuntime, | |
| 52 | type ProbedOpenCode, | |
| 53 | } from "./versionProbe.ts"; | |
| 54 | import * as OpenCodeServerOwner from "./OpenCodeServerOwner.ts"; | |
| 55 | import * as OpenCode2Client from "./v2/OpenCode2Client.ts"; | |
| 56 | import * as OpenCode2Server from "./v2/OpenCode2Server.ts"; | |
| 57 | import { | |
| 58 | defaultProviderContinuationIdentity, | |
| 59 | type ProviderDriver, | |
| 60 | type ProviderInstance, | |
| 61 | } from "@t3tools/provider-core/server/driver"; | |
| 62 | import { withInstanceIdentity } from "@t3tools/provider-core/server/instanceIdentity"; | |
| 63 | import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; | |
| 64 | import { | |
| 65 | enrichProviderSnapshotWithVersionAdvisory, | |
| 66 | makeCachedProviderMaintenanceResolution, | |
| 67 | makeManualOnlyProviderMaintenanceCapabilities, | |
| 68 | makePackageManagedProviderMaintenanceResolver, | |
| 69 | normalizeCommandPath, | |
| 70 | resolveProviderMaintenanceCapabilitiesEffect, | |
| 71 | } from "@t3tools/provider-core/server/maintenanceResolver"; | |
| 72 | import { | |
| 73 | haveProviderSnapshotSettingsChanged, | |
| 74 | makeProviderSnapshotSettingsSource, | |
| 75 | type ProviderSnapshotSettings, | |
| 76 | } from "@t3tools/provider-core/server/snapshotSettings"; | |
| 77 | const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings); | |
| 78 | ||
| 79 | const DRIVER_KIND = ProviderDriverKind.make("opencode"); | |
| 80 | ||
| 81 | function isOpenCodeNativeCommandPath(commandPath: string): boolean { | |
| 82 | const normalized = normalizeCommandPath(commandPath); | |
| 83 | return ( | |
| 84 | normalized.endsWith("/.opencode/bin/opencode") || | |
| 85 | normalized.endsWith("/.opencode/bin/opencode.exe") | |
| 86 | ); | |
| 87 | } | |
| 88 | ||
| 89 | /** | |
| 90 | * OpenCode 1.x ships as `opencode-ai` and 2.x as `@opencode/cli`. An install is | |
| 91 | * only ever updated within its own package: T3 never moves a 1.x install onto | |
| 92 | * 2.x or back, since 2.x converts the shared database in place. | |
| 93 | */ | |
| 94 | export const openCodeUpdateFor = (generation: ProbedOpenCode["generation"]) => | |
| 95 | makePackageManagedProviderMaintenanceResolver({ | |
| 96 | provider: DRIVER_KIND, | |
| 97 | npmPackageName: generation === "v2" ? "@opencode/cli" : "opencode-ai", | |
| 98 | nativeUpdate: { | |
| 99 | args: ["upgrade"], | |
| 100 | isCommandPath: isOpenCodeNativeCommandPath, | |
| 101 | }, | |
| 102 | }); | |
| 103 | ||
| 104 | type OpenCodeRuntimeProbe = Effect.Success< | |
| 105 | ReturnType<typeof makeOpenCodeRuntimeProbe<OpenCodeRuntime.OpenCodeRuntimeError>> | |
| 106 | >; | |
| 107 | ||
| 108 | /** Runs `v2` for a 2.x instance and `v1` otherwise; a failed probe keeps 1.x, whose checks report it. */ | |
| 109 | function byOpenCodeRuntime<A, E, R, PE>( | |
| 110 | probed: Effect.Effect<ProbedOpenCode | undefined, PE>, | |
| 111 | paths: { readonly v1: Effect.Effect<A, E, R>; readonly v2: Effect.Effect<A, E, R> }, | |
| 112 | ): Effect.Effect<A, E, R> { | |
| 113 | return probed.pipe( | |
| 114 | Effect.orElseSucceed(() => undefined), | |
| 115 | Effect.flatMap((result) => (result?.generation === "v2" ? paths.v2 : paths.v1)), | |
| 116 | ); | |
| 117 | } | |
| 118 | ||
| 119 | /** | |
| 120 | * Routes each adapter call to the runtime the instance's probe detected. Capability and selection | |
| 121 | * reads are hot, so they use the last successful probe (1.x before one lands) and never wait on a | |
| 122 | * slow server. Opening a session waits for a probe, so each server is spoken to in its own protocol. | |
| 123 | */ | |
| 124 | function selectOpenCodeRuntimeAdapter(input: { | |
| 125 | readonly probe: OpenCodeRuntimeProbe; | |
| 126 | readonly v1: ProviderAdapter.ProviderAdapterV2Shape; | |
| 127 | readonly v2: ProviderAdapter.ProviderAdapterV2Shape; | |
| 128 | }): ProviderAdapter.ProviderAdapterV2Shape { | |
| 129 | const pick = <PE>(probed: Effect.Effect<ProbedOpenCode | undefined, PE>) => | |
| 130 | byOpenCodeRuntime(probed, { v1: Effect.succeed(input.v1), v2: Effect.succeed(input.v2) }); | |
| 131 | const hot = pick(Effect.map(input.probe.lastSuccess, Option.getOrUndefined)); | |
| 132 | return { | |
| 133 | instanceId: input.v1.instanceId, | |
| 134 | driver: DRIVER_KIND, | |
| 135 | getCapabilities: () => Effect.flatMap(hot, (adapter) => adapter.getCapabilities()), | |
| 136 | planSelectionTransition: (transition) => | |
| 137 | Effect.flatMap(hot, (adapter) => adapter.planSelectionTransition(transition)), | |
| 138 | openSession: (session) => | |
| 139 | Effect.flatMap(pick(input.probe.get), (adapter) => adapter.openSession(session)), | |
| 140 | }; | |
| 141 | } | |
| 142 | ||
| 143 | /** Text generation runs on the server the instance's probe detected, each in its own protocol. */ | |
| 144 | function selectOpenCodeRuntimeTextGeneration( | |
| 145 | probe: OpenCodeRuntimeProbe, | |
| 146 | v1: ProviderTextGeneration, | |
| 147 | v2: ProviderTextGeneration, | |
| 148 | ): ProviderTextGeneration { | |
| 149 | return { | |
| 150 | generateCommitMessage: (input) => | |
| 151 | byOpenCodeRuntime(probe.get, { | |
| 152 | v1: v1.generateCommitMessage(input), | |
| 153 | v2: v2.generateCommitMessage(input), | |
| 154 | }), | |
| 155 | generatePrContent: (input) => | |
| 156 | byOpenCodeRuntime(probe.get, { | |
| 157 | v1: v1.generatePrContent(input), | |
| 158 | v2: v2.generatePrContent(input), | |
| 159 | }), | |
| 160 | generateBranchName: (input) => | |
| 161 | byOpenCodeRuntime(probe.get, { | |
| 162 | v1: v1.generateBranchName(input), | |
| 163 | v2: v2.generateBranchName(input), | |
| 164 | }), | |
| 165 | generateThreadTitle: (input) => | |
| 166 | byOpenCodeRuntime(probe.get, { | |
| 167 | v1: v1.generateThreadTitle(input), | |
| 168 | v2: v2.generateThreadTitle(input), | |
| 169 | }), | |
| 170 | }; | |
| 171 | } | |
| 172 | ||
| 173 | export type OpenCodeDriverEnv = | |
| 174 | | OpenCodeAdapterV2.OpenCodeAdapterV2DriverEnv | |
| 175 | | ProviderHost.ProviderHost | |
| 176 | | ChildProcessSpawner.ChildProcessSpawner | |
| 177 | | Crypto.Crypto | |
| 178 | | FileSystem.FileSystem | |
| 179 | | HttpClient.HttpClient | |
| 180 | | OpenCodeRuntime.OpenCodeRuntime | |
| 181 | | Path.Path; | |
| 182 | ||
| 183 | export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv> = { | |
| 184 | driverKind: DRIVER_KIND, | |
| 185 | metadata: { | |
| 186 | displayName: "OpenCode", | |
| 187 | supportsMultipleInstances: true, | |
| 188 | }, | |
| 189 | configSchema: OpenCodeSettings, | |
| 190 | defaultConfig: (): OpenCodeSettings => decodeOpenCodeSettings({}), | |
| 191 | create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => | |
| 192 | Effect.gen(function* () { | |
| 193 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 194 | const fileSystem = yield* FileSystem.FileSystem; | |
| 195 | const pathService = yield* Path.Path; | |
| 196 | const openCodeRuntime = yield* OpenCodeRuntime.OpenCodeRuntime; | |
| 197 | const httpClient = yield* HttpClient.HttpClient; | |
| 198 | const crypto = yield* Crypto.Crypto; | |
| 199 | const host = yield* ProviderHost.ProviderHost; | |
| 200 | const processEnv = mergeProviderInstanceEnvironment(environment); | |
| 201 | const continuationIdentity = defaultProviderContinuationIdentity({ | |
| 202 | driverKind: DRIVER_KIND, | |
| 203 | instanceId, | |
| 204 | }); | |
| 205 | const stampIdentity = withInstanceIdentity({ | |
| 206 | instanceId, | |
| 207 | driverKind: DRIVER_KIND, | |
| 208 | displayName, | |
| 209 | accentColor, | |
| 210 | continuationGroupKey: continuationIdentity.continuationKey, | |
| 211 | }); | |
| 212 | const effectiveConfig = { ...config, enabled } satisfies OpenCodeSettings; | |
| 213 | const runtimeProbe = yield* makeOpenCodeRuntimeProbe( | |
| 214 | probeOpenCodeRuntime(effectiveConfig, processEnv).pipe( | |
| 215 | Effect.provideService(HttpClient.HttpClient, httpClient), | |
| 216 | Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, openCodeRuntime), | |
| 217 | ), | |
| 218 | ); | |
| 219 | // Updates follow the installed package, which the version probe names. An | |
| 220 | // unknown version offers no package update, since a guess could move a | |
| 221 | // 2.x install onto 1.x's package or the reverse. A disabled instance never | |
| 222 | // runs its binary, so it has no version and offers no update. | |
| 223 | const noUpdate = makeManualOnlyProviderMaintenanceCapabilities({ | |
| 224 | provider: DRIVER_KIND, | |
| 225 | packageName: null, | |
| 226 | }); | |
| 227 | const maintenanceFor = (probed: typeof runtimeProbe.get) => | |
| 228 | probed.pipe( | |
| 229 | Effect.map((result) => result.generation), | |
| 230 | Effect.option, | |
| 231 | Effect.flatMap((generation) => | |
| 232 | Option.isNone(generation) | |
| 233 | ? Effect.succeed(noUpdate) | |
| 234 | : resolveProviderMaintenanceCapabilitiesEffect(openCodeUpdateFor(generation.value), { | |
| 235 | binaryPath: effectiveConfig.binaryPath, | |
| 236 | env: processEnv, | |
| 237 | }), | |
| 238 | ), | |
| 239 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | |
| 240 | Effect.provideService(FileSystem.FileSystem, fileSystem), | |
| 241 | Effect.provideService(Path.Path, pathService), | |
| 242 | ); | |
| 243 | const cachedMaintenance = yield* makeCachedProviderMaintenanceResolution( | |
| 244 | maintenanceFor(runtimeProbe.get), | |
| 245 | ); | |
| 246 | // A fresh read (an update about to run, or a manual refresh) re-probes, so | |
| 247 | // a binary replaced by the other major version gets its own package. | |
| 248 | const resolveMaintenance = (options?: { readonly fresh?: boolean }) => | |
| 249 | !effectiveConfig.enabled | |
| 250 | ? Effect.succeed(noUpdate) | |
| 251 | : options?.fresh === true | |
| 252 | ? runtimeProbe.refresh.pipe( | |
| 253 | Effect.ignore, | |
| 254 | Effect.andThen(cachedMaintenance({ fresh: true })), | |
| 255 | ) | |
| 256 | : cachedMaintenance(); | |
| 257 | const openCodeV1Adapter = yield* OpenCodeAdapterV2.OpenCodeAdapterV2Driver.create({ | |
| 258 | instanceId, | |
| 259 | displayName, | |
| 260 | accentColor, | |
| 261 | environment, | |
| 262 | enabled, | |
| 263 | config, | |
| 264 | }).pipe( | |
| 265 | Effect.mapError( | |
| 266 | (cause) => | |
| 267 | new ProviderDriverError({ | |
| 268 | driver: DRIVER_KIND, | |
| 269 | instanceId, | |
| 270 | detail: "Failed to build OpenCode orchestration adapter.", | |
| 271 | cause, | |
| 272 | }), | |
| 273 | ), | |
| 274 | ); | |
| 275 | // One OpenCode 2 server per instance, spawned on first use or reached at `serverUrl`. | |
| 276 | const openCode2Server = yield* OpenCode2Server.make({ | |
| 277 | binaryPath: effectiveConfig.binaryPath, | |
| 278 | serverUrl: effectiveConfig.serverUrl, | |
| 279 | serverPassword: effectiveConfig.serverPassword, | |
| 280 | directory: host.paths.cwd, | |
| 281 | environment: processEnv, | |
| 282 | }).pipe( | |
| 283 | Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, openCodeRuntime), | |
| 284 | Effect.provideService( | |
| 285 | OpenCode2Client.OpenCode2Client, | |
| 286 | yield* OpenCode2Client.make.pipe( | |
| 287 | Effect.provideService(HttpClient.HttpClient, httpClient), | |
| 288 | ), | |
| 289 | ), | |
| 290 | ); | |
| 291 | const orchestrationAdapter = selectOpenCodeRuntimeAdapter({ | |
| 292 | probe: runtimeProbe, | |
| 293 | v1: openCodeV1Adapter, | |
| 294 | v2: yield* OpenCode2AdapterV2.make(instanceId).pipe( | |
| 295 | Effect.provideService(OpenCode2Server.OpenCode2Server, openCode2Server), | |
| 296 | ), | |
| 297 | }); | |
| 298 | const loadOpenCode2Models = yield* makeOpenCode2ModelLoader( | |
| 299 | openCode2Server.withConnection((connection) => | |
| 300 | connection.client.model.list({ location: { directory: host.paths.cwd } }).pipe( | |
| 301 | Effect.map((models) => models.data), | |
| 302 | Effect.mapError( | |
| 303 | (cause) => | |
| 304 | new OpenCodeRuntime.OpenCodeRuntimeError({ | |
| 305 | operation: "model.list", | |
| 306 | detail: "The OpenCode server could not list its models.", | |
| 307 | cause, | |
| 308 | }), | |
| 309 | ), | |
| 310 | ), | |
| 311 | ), | |
| 312 | ); | |
| 313 | // A 2.x server lists skills and commands per directory, so one server | |
| 314 | // answers every workspace. Its event stream says when a directory it had | |
| 315 | // not served yet finished scanning. | |
| 316 | const listOpenCode2Workspace = (cwd: string) => | |
| 317 | openCode2Server.withConnection(({ client, events }) => | |
| 318 | Effect.gen(function* () { | |
| 319 | const location = { directory: cwd }; | |
| 320 | const scanned = yield* Deferred.make<void>(); | |
| 321 | const pending = new Set(["command.updated", "skill.updated"]); | |
| 322 | const stream = yield* events.pipe(Effect.option); | |
| 323 | if (stream._tag === "Some") { | |
| 324 | yield* stream.value.pipe( | |
| 325 | Stream.runForEach((event) => | |
| 326 | "location" in event && | |
| 327 | event.location?.directory === cwd && | |
| 328 | pending.delete(event.type) && | |
| 329 | pending.size === 0 | |
| 330 | ? Deferred.succeed(scanned, undefined) | |
| 331 | : Effect.void, | |
| 332 | ), | |
| 333 | Effect.ignore, | |
| 334 | Effect.forkScoped, | |
| 335 | ); | |
| 336 | } | |
| 337 | return yield* loadOpenCode2Workspace( | |
| 338 | Effect.all( | |
| 339 | { | |
| 340 | skills: client.skill.list({ location }).pipe(Effect.map((list) => list.data)), | |
| 341 | commands: client.command.list({ location }).pipe(Effect.map((list) => list.data)), | |
| 342 | }, | |
| 343 | { concurrency: "unbounded" }, | |
| 344 | ), | |
| 345 | Deferred.await(scanned), | |
| 346 | ); | |
| 347 | }).pipe(Effect.scoped), | |
| 348 | ); | |
| 349 | const serverOwner = yield* OpenCodeServerOwner.make({ | |
| 350 | binaryPath: effectiveConfig.binaryPath, | |
| 351 | directory: host.paths.cwd, | |
| 352 | ...(effectiveConfig.serverPassword | |
| 353 | ? { serverPassword: effectiveConfig.serverPassword } | |
| 354 | : {}), | |
| 355 | environment: processEnv, | |
| 356 | }); | |
| 357 | const textGeneration = selectOpenCodeRuntimeTextGeneration( | |
| 358 | runtimeProbe, | |
| 359 | yield* makeOpenCodeTextGeneration(effectiveConfig).pipe( | |
| 360 | Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner), | |
| 361 | ), | |
| 362 | yield* OpenCode2TextGeneration.make().pipe( | |
| 363 | Effect.provideService(OpenCode2Server.OpenCode2Server, openCode2Server), | |
| 364 | ), | |
| 365 | ); | |
| 366 | ||
| 367 | const checkProvider = Effect.all( | |
| 368 | { | |
| 369 | provider: checkOpenCodeProviderStatus( | |
| 370 | effectiveConfig, | |
| 371 | host.paths.cwd, | |
| 372 | runtimeProbe.refresh, | |
| 373 | loadOpenCode2Models, | |
| 374 | ), | |
| 375 | usageLimits: readOpenCodeGoUsageLimits({ | |
| 376 | enabled: effectiveConfig.enabled, | |
| 377 | serverUrl: effectiveConfig.serverUrl, | |
| 378 | environment: processEnv, | |
| 379 | }), | |
| 380 | }, | |
| 381 | { concurrency: "unbounded" }, | |
| 382 | ).pipe( | |
| 383 | Effect.map(({ provider, usageLimits }) => ({ ...provider, usageLimits })), | |
| 384 | Effect.map(stampIdentity), | |
| 385 | Effect.provideService(FileSystem.FileSystem, fileSystem), | |
| 386 | Effect.provideService(Path.Path, pathService), | |
| 387 | Effect.provideService(HttpClient.HttpClient, httpClient), | |
| 388 | Effect.provideService(Crypto.Crypto, crypto), | |
| 389 | Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner), | |
| 390 | Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, openCodeRuntime), | |
| 391 | ); | |
| 392 | // NOTE: the local branch intentionally uses the shared SDK server | |
| 393 | // instead of `opencode debug skill` (loadSkillsFromCli). The CLI writes | |
| 394 | // its full JSON inventory to stdout, but the Bun-compiled binary does | |
| 395 | // not flush more than one 64KB pipe buffer to a non-TTY stdout, so the | |
| 396 | // piped output arrives truncated and unparseable — which degrades to an | |
| 397 | // empty skill list and poisons the workspace snapshot the `$` picker | |
| 398 | // reads. The SDK `app.skills` endpoint honors the per-request directory | |
| 399 | // and returns complete results regardless of size. | |
| 400 | const loadWorkspaceInventory = ( | |
| 401 | client: Parameters<typeof OpenCodeRuntime.loadOpenCodeCommands>[0], | |
| 402 | ) => | |
| 403 | Effect.all( | |
| 404 | { | |
| 405 | skills: openCodeRuntime.loadOpenCodeSkills(client), | |
| 406 | commands: OpenCodeRuntime.loadOpenCodeCommands(client).pipe( | |
| 407 | Effect.timeout("10 seconds"), | |
| 408 | Effect.orElseSucceed(() => []), | |
| 409 | ), | |
| 410 | }, | |
| 411 | { concurrency: "unbounded" }, | |
| 412 | ); | |
| 413 | const loadWorkspaceForCwd = (cwd: string) => | |
| 414 | effectiveConfig.serverUrl.trim().length > 0 | |
| 415 | ? Effect.scoped( | |
| 416 | Effect.gen(function* () { | |
| 417 | const server = yield* openCodeRuntime.connectToOpenCodeServer({ | |
| 418 | binaryPath: effectiveConfig.binaryPath, | |
| 419 | directory: cwd, | |
| 420 | serverUrl: effectiveConfig.serverUrl, | |
| 421 | ...(effectiveConfig.serverPassword | |
| 422 | ? { serverPassword: effectiveConfig.serverPassword } | |
| 423 | : {}), | |
| 424 | environment: processEnv, | |
| 425 | }); | |
| 426 | const client = openCodeRuntime.createOpenCodeSdkClient({ | |
| 427 | baseUrl: server.url, | |
| 428 | directory: cwd, | |
| 429 | ...(effectiveConfig.serverPassword | |
| 430 | ? { serverPassword: effectiveConfig.serverPassword } | |
| 431 | : {}), | |
| 432 | }); | |
| 433 | return yield* loadWorkspaceInventory(client); | |
| 434 | }), | |
| 435 | ) | |
| 436 | : serverOwner.withServer((server) => | |
| 437 | loadWorkspaceInventory( | |
| 438 | openCodeRuntime.createOpenCodeSdkClient({ | |
| 439 | baseUrl: server.url, | |
| 440 | directory: cwd, | |
| 441 | ...(server.serverPassword !== undefined | |
| 442 | ? { serverPassword: server.serverPassword } | |
| 443 | : {}), | |
| 444 | }), | |
| 445 | ), | |
| 446 | ); | |
| 447 | ||
| 448 | const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, host.settings); | |
| 449 | const snapshot = yield* makeManagedServerProvider<ProviderSnapshotSettings<OpenCodeSettings>>( | |
| 450 | { | |
| 451 | resolveMaintenance, | |
| 452 | getSettings: snapshotSettings.getSettings, | |
| 453 | streamSettings: snapshotSettings.streamSettings, | |
| 454 | haveSettingsChanged: haveProviderSnapshotSettingsChanged, | |
| 455 | checkProviderOnSettingsChange: () => false, | |
| 456 | refreshOnInterval: false, | |
| 457 | initialSnapshot: (settings) => | |
| 458 | makePendingOpenCodeProvider(settings.provider).pipe(Effect.map(stampIdentity)), | |
| 459 | checkProvider, | |
| 460 | enrichSnapshot: ({ settings, snapshot, publishSnapshot }) => | |
| 461 | resolveMaintenance().pipe( | |
| 462 | Effect.flatMap((maintenanceCapabilities) => | |
| 463 | enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities, { | |
| 464 | enableProviderUpdateChecks: settings.enableProviderUpdateChecks, | |
| 465 | }), | |
| 466 | ), | |
| 467 | Effect.provideService(HttpClient.HttpClient, httpClient), | |
| 468 | Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), | |
| 469 | ), | |
| 470 | }, | |
| 471 | ).pipe( | |
| 472 | Effect.mapError( | |
| 473 | (cause) => | |
| 474 | new ProviderDriverError({ | |
| 475 | driver: DRIVER_KIND, | |
| 476 | instanceId, | |
| 477 | detail: `Failed to build OpenCode snapshot: ${cause.message ?? String(cause)}`, | |
| 478 | cause, | |
| 479 | }), | |
| 480 | ), | |
| 481 | ); | |
| 482 | ||
| 483 | return { | |
| 484 | instanceId, | |
| 485 | driverKind: DRIVER_KIND, | |
| 486 | continuationIdentity, | |
| 487 | displayName, | |
| 488 | accentColor, | |
| 489 | enabled, | |
| 490 | snapshot, | |
| 491 | snapshotForCwd: (cwd) => | |
| 492 | !effectiveConfig.enabled | |
| 493 | ? snapshot.getSnapshot | |
| 494 | : byOpenCodeRuntime(runtimeProbe.get, { | |
| 495 | v2: Effect.all([ | |
| 496 | snapshot.getSnapshot, | |
| 497 | listOpenCode2Workspace(cwd).pipe( | |
| 498 | Effect.timeout("20 seconds"), | |
| 499 | Effect.mapError( | |
| 500 | (cause) => | |
| 501 | new ProviderDriverError({ | |
| 502 | driver: DRIVER_KIND, | |
| 503 | instanceId, | |
| 504 | detail: `Failed to list OpenCode commands and skills for '${cwd}'`, | |
| 505 | cause, | |
| 506 | }), | |
| 507 | ), | |
| 508 | ), | |
| 509 | ]).pipe( | |
| 510 | Effect.map(([machineSnapshot, { skills, commands }]) => ({ | |
| 511 | ...machineSnapshot, | |
| 512 | skills: openCode2SkillsToServerProviderSkills(skills), | |
| 513 | slashCommands: openCode2CommandsToServerProviderSlashCommands(commands), | |
| 514 | })), | |
| 515 | ), | |
| 516 | v1: Effect.all([ | |
| 517 | snapshot.getSnapshot, | |
| 518 | loadWorkspaceForCwd(cwd).pipe(Effect.timeout("20 seconds")), | |
| 519 | ]).pipe( | |
| 520 | Effect.map(([machineSnapshot, { skills, commands }]) => ({ | |
| 521 | ...machineSnapshot, | |
| 522 | skills: openCodeSkillsToServerProviderSkills(skills), | |
| 523 | slashCommands: openCodeCommandsToServerProviderSlashCommands(commands), | |
| 524 | })), | |
| 525 | Effect.mapError( | |
| 526 | (cause) => | |
| 527 | new ProviderDriverError({ | |
| 528 | driver: DRIVER_KIND, | |
| 529 | instanceId, | |
| 530 | detail: `Failed to probe OpenCode commands and skills for '${cwd}'`, | |
| 531 | cause, | |
| 532 | }), | |
| 533 | ), | |
| 534 | ), | |
| 535 | }), | |
| 536 | orchestrationAdapter, | |
| 537 | textGeneration, | |
| 538 | } satisfies ProviderInstance; | |
| 539 | }), | |
| 540 | }; |
packages/provider-opencode/src/server/probeResponses.fixture.ts created+93| ... | ... | @@ -0,0 +1,93 @@ |
| 1 | /** | |
| 2 | * Responses recorded from `opencode serve` 1.18.32 and 2.0.18 on 2026-09-29, replayed by an | |
| 3 | * `HttpClient` so the version probe runs against real bytes. | |
| 4 | */ | |
| 5 | import * as Effect from "effect/Effect"; | |
| 6 | import { HttpClient, HttpClientResponse } from "effect/http"; | |
| 7 | ||
| 8 | interface RecordedResponse { | |
| 9 | readonly status: number; | |
| 10 | readonly contentType?: string; | |
| 11 | readonly body: string; | |
| 12 | } | |
| 13 | ||
| 14 | // Both versions serve their web UI's index page for unknown paths. Bodies are cut after the | |
| 15 | // first lines; the classifier rejects them on content type alone. | |
| 16 | const SPA_HTML = | |
| 17 | '<!doctype html>\n<html lang="en" style="background-color: var(--v2-background-bg-deep, #fafafa)">\n'; | |
| 18 | ||
| 19 | export const OPENCODE_2_RESPONSES = { | |
| 20 | "/api/info": { | |
| 21 | status: 200, | |
| 22 | contentType: "application/json", | |
| 23 | body: '{"version":"2.0.18","pid":2393556,"urls":["http://127.0.0.1:48771"],"paths":{"tmp":"/tmp/opencode"}}', | |
| 24 | }, | |
| 25 | "/global/health": { status: 200, contentType: "text/html", body: SPA_HTML }, | |
| 26 | unauthorized: { | |
| 27 | status: 401, | |
| 28 | contentType: "application/json", | |
| 29 | body: '{"_tag":"UnauthorizedError","message":"Authentication required"}', | |
| 30 | }, | |
| 31 | } satisfies Record<string, RecordedResponse>; | |
| 32 | ||
| 33 | /** | |
| 34 | * A 2.0.18 server's command and skill lists for a directory with one command | |
| 35 | * (`.opencode/command/bee.md`) and one skill (`.opencode/skills/plum`), live on | |
| 36 | * 2026-09-29. Skill contents are cut; machine-wide skills are dropped. | |
| 37 | */ | |
| 38 | export const OPENCODE_2_WORKSPACE_RESPONSES = { | |
| 39 | "/api/command": { | |
| 40 | status: 200, | |
| 41 | contentType: "application/json", | |
| 42 | 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"}]}', | |
| 43 | }, | |
| 44 | "/api/skill": { | |
| 45 | status: 200, | |
| 46 | contentType: "application/json", | |
| 47 | 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":"<!-- Built-in skill. -->"},{"id":"plum","name":"plum","description":"plum skill","path":"/work/.opencode/skills/plum/SKILL.md","content":"Say PLUM."}]}', | |
| 48 | }, | |
| 49 | } satisfies Record<string, RecordedResponse>; | |
| 50 | ||
| 51 | export const OPENCODE_1_RESPONSES = { | |
| 52 | "/api/info": { status: 200, contentType: "text/html", body: SPA_HTML }, | |
| 53 | "/global/health": { | |
| 54 | status: 200, | |
| 55 | contentType: "application/json", | |
| 56 | body: '{"healthy":true,"version":"1.18.32"}', | |
| 57 | }, | |
| 58 | // 1.x also answers a wrong password with an empty 401 on every path. | |
| 59 | unauthorized: { status: 401, body: "" }, | |
| 60 | } satisfies Record<string, RecordedResponse>; | |
| 61 | ||
| 62 | /** Replays a recorded server; credentials other than `password` (none when empty) get its 401. */ | |
| 63 | export function replayOpenCodeServer( | |
| 64 | responses: | |
| 65 | | typeof OPENCODE_1_RESPONSES | |
| 66 | | typeof OPENCODE_2_RESPONSES | |
| 67 | | (typeof OPENCODE_2_RESPONSES & typeof OPENCODE_2_WORKSPACE_RESPONSES), | |
| 68 | password: string, | |
| 69 | requestedPaths: Array<string> = [], | |
| 70 | ) { | |
| 71 | const answers: Record<string, RecordedResponse | undefined> = responses; | |
| 72 | // Both versions decode Basic credentials as UTF-8 (checked live with `pässwörd` and `pass€word`). | |
| 73 | const expected = password | |
| 74 | ? `Basic ${Buffer.from(`opencode:${password}`, "utf8").toString("base64")}` | |
| 75 | : undefined; | |
| 76 | return HttpClient.make((request) => { | |
| 77 | const path = new URL(request.url).pathname; | |
| 78 | requestedPaths.push(path); | |
| 79 | const recorded: RecordedResponse = | |
| 80 | request.headers.authorization === expected | |
| 81 | ? (answers[path] ?? responses.unauthorized) | |
| 82 | : responses.unauthorized; | |
| 83 | return Effect.succeed( | |
| 84 | HttpClientResponse.fromWeb( | |
| 85 | request, | |
| 86 | new Response(recorded.body, { | |
| 87 | status: recorded.status, | |
| 88 | headers: recorded.contentType ? { "content-type": recorded.contentType } : {}, | |
| 89 | }), | |
| 90 | ), | |
| 91 | ); | |
| 92 | }); | |
| 93 | } |
packages/provider-opencode/src/server/status.test.ts created+871| ... | ... | @@ -0,0 +1,871 @@ |
| 1 | import * as NodeAssert from "node:assert/strict"; | |
| 2 | ||
| 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 4 | import { it } from "@effect/vitest"; | |
| 5 | import * as Deferred from "effect/Deferred"; | |
| 6 | import * as Effect from "effect/Effect"; | |
| 7 | import * as Fiber from "effect/Fiber"; | |
| 8 | import * as FileSystem from "effect/FileSystem"; | |
| 9 | import * as Layer from "effect/Layer"; | |
| 10 | import * as Schema from "effect/Schema"; | |
| 11 | import * as TestClock from "effect/testing/TestClock"; | |
| 12 | import { HttpClient, HttpClientResponse } from "effect/http"; | |
| 13 | import { beforeEach } from "vite-plus/test"; | |
| 14 | ||
| 15 | import { OpenCodeSettings } from "../settings.ts"; | |
| 16 | import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; | |
| 17 | import * as OpenCodeServerOwner from "./OpenCodeServerOwner.ts"; | |
| 18 | import { | |
| 19 | checkOpenCodeProviderStatus, | |
| 20 | loadOpenCode2Workspace, | |
| 21 | makeOpenCode2ModelLoader, | |
| 22 | type OpenCode2Model, | |
| 23 | type OpenCode2Workspace, | |
| 24 | openCode2CommandsToServerProviderSlashCommands, | |
| 25 | openCode2SkillsToServerProviderSkills, | |
| 26 | openCodeCommandsToServerProviderSlashCommands, | |
| 27 | } from "./status.ts"; | |
| 28 | import { readOpenCodeGoUsageLimits } from "./usageLimits.ts"; | |
| 29 | import { probeOpenCodeRuntime } from "./versionProbe.ts"; | |
| 30 | import { | |
| 31 | OPENCODE_1_RESPONSES, | |
| 32 | OPENCODE_2_RESPONSES, | |
| 33 | replayOpenCodeServer, | |
| 34 | } from "./probeResponses.fixture.ts"; | |
| 35 | const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings); | |
| 36 | ||
| 37 | const DEFAULT_VERSION_STDOUT = "opencode 1.14.19\n"; | |
| 38 | ||
| 39 | it.effect("reads Go limits with the instance's XDG credentials and preserves reset times", () => | |
| 40 | Effect.gen(function* () { | |
| 41 | const resetsAt = "2026-09-17T12:00:00.000Z"; | |
| 42 | const limits = yield* readOpenCodeGoUsageLimits({ | |
| 43 | enabled: true, | |
| 44 | serverUrl: "", | |
| 45 | environment: { XDG_DATA_HOME: "/instance/data", OPENCODE_API_KEY: "env-key" }, | |
| 46 | }).pipe( | |
| 47 | Effect.provideService( | |
| 48 | FileSystem.FileSystem, | |
| 49 | FileSystem.makeNoop({ | |
| 50 | readFileString: (path) => { | |
| 51 | NodeAssert.equal(path, "/instance/data/opencode/auth.json"); | |
| 52 | return Effect.succeed( | |
| 53 | JSON.stringify({ "opencode-go": { type: "api", key: "instance-key" } }), | |
| 54 | ); | |
| 55 | }, | |
| 56 | }), | |
| 57 | ), | |
| 58 | Effect.provideService( | |
| 59 | HttpClient.HttpClient, | |
| 60 | HttpClient.make((request) => { | |
| 61 | NodeAssert.equal(request.url, "https://opencode.ai/zen/go/v1/usage"); | |
| 62 | NodeAssert.equal(request.headers.authorization, "Bearer instance-key"); | |
| 63 | return Effect.succeed( | |
| 64 | HttpClientResponse.fromWeb( | |
| 65 | request, | |
| 66 | Response.json({ | |
| 67 | usage: { | |
| 68 | rolling: { percent: 0, resetsAt }, | |
| 69 | weekly: { percent: 10, resetsAt }, | |
| 70 | monthly: { percent: 125, resetsAt }, | |
| 71 | }, | |
| 72 | }), | |
| 73 | ), | |
| 74 | ); | |
| 75 | }), | |
| 76 | ), | |
| 77 | Effect.provide(NodeServices.layer), | |
| 78 | ); | |
| 79 | NodeAssert.equal(limits.unavailable, undefined); | |
| 80 | NodeAssert.equal( | |
| 81 | limits.credentialFingerprint, | |
| 82 | // SHA-256 of "opencode-go\0instance-key". | |
| 83 | "aba48e85c981a8edc1c9fb4575121accc7235fa55d0f8689f66f153de5566a37", | |
| 84 | ); | |
| 85 | NodeAssert.deepEqual( | |
| 86 | limits.windows.map(({ kind, usedPercent, resetsAt: reset }) => ({ | |
| 87 | kind, | |
| 88 | usedPercent, | |
| 89 | reset, | |
| 90 | })), | |
| 91 | [ | |
| 92 | { kind: "session", usedPercent: 0, reset: resetsAt }, | |
| 93 | { kind: "weekly", usedPercent: 10, reset: resetsAt }, | |
| 94 | { kind: "monthly", usedPercent: 100, reset: resetsAt }, | |
| 95 | ], | |
| 96 | ); | |
| 97 | }), | |
| 98 | ); | |
| 99 | ||
| 100 | it.effect("does not read local credentials for external or disabled OpenCode instances", () => | |
| 101 | Effect.gen(function* () { | |
| 102 | for (const settings of [ | |
| 103 | { enabled: true, serverUrl: "https://remote.example" }, | |
| 104 | { enabled: false, serverUrl: "" }, | |
| 105 | ]) { | |
| 106 | const limits = yield* readOpenCodeGoUsageLimits({ ...settings, environment: {} }).pipe( | |
| 107 | Effect.provideService( | |
| 108 | FileSystem.FileSystem, | |
| 109 | FileSystem.makeNoop({ | |
| 110 | readFileString: () => Effect.die("unexpected credential read"), | |
| 111 | }), | |
| 112 | ), | |
| 113 | Effect.provideService( | |
| 114 | HttpClient.HttpClient, | |
| 115 | HttpClient.make(() => Effect.die("unexpected usage request")), | |
| 116 | ), | |
| 117 | Effect.provide(NodeServices.layer), | |
| 118 | ); | |
| 119 | NodeAssert.equal(limits.unavailable?.reason, "unsupported"); | |
| 120 | } | |
| 121 | }), | |
| 122 | ); | |
| 123 | ||
| 124 | it.effect("keeps Go entitlement absence distinct from failed or malformed usage responses", () => | |
| 125 | Effect.gen(function* () { | |
| 126 | for (const [status, reason] of [ | |
| 127 | [403, "unsupported"], | |
| 128 | [401, "probeFailed"], | |
| 129 | [200, "probeFailed"], | |
| 130 | ] as const) { | |
| 131 | const limits = yield* readOpenCodeGoUsageLimits({ | |
| 132 | enabled: true, | |
| 133 | serverUrl: "", | |
| 134 | environment: { | |
| 135 | OPENCODE_AUTH_CONTENT: '{"opencode-go":{"type":"api","key":"inline-key"}}', | |
| 136 | }, | |
| 137 | }).pipe( | |
| 138 | Effect.provideService( | |
| 139 | FileSystem.FileSystem, | |
| 140 | FileSystem.makeNoop({ | |
| 141 | readFileString: () => Effect.die("inline credentials must bypass disk"), | |
| 142 | }), | |
| 143 | ), | |
| 144 | Effect.provideService( | |
| 145 | HttpClient.HttpClient, | |
| 146 | HttpClient.make((request) => | |
| 147 | Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({}, { status }))), | |
| 148 | ), | |
| 149 | ), | |
| 150 | Effect.provide(NodeServices.layer), | |
| 151 | ); | |
| 152 | NodeAssert.equal(limits.unavailable?.reason, reason); | |
| 153 | NodeAssert.equal(limits.credentialFingerprint, undefined); | |
| 154 | NodeAssert.deepEqual(limits.windows, []); | |
| 155 | } | |
| 156 | }), | |
| 157 | ); | |
| 158 | ||
| 159 | /** | |
| 160 | * The legacy `OpenCodeProviderLive` Layer + `OpenCodeProvider` service tag | |
| 161 | * are deleted. The snapshot-producing logic they wrapped now lives in the | |
| 162 | * standalone `checkOpenCodeProviderStatus(settings, cwd)` Effect, which | |
| 163 | * drivers call directly when building their per-instance snapshot | |
| 164 | * `ServerProviderShape`. Tests mirror that shape: build a settings payload, | |
| 165 | * invoke the check, assert on the returned snapshot. | |
| 166 | */ | |
| 167 | ||
| 168 | const runtimeMock = { | |
| 169 | state: { | |
| 170 | runVersionError: null as Error | null, | |
| 171 | runVersionPending: false, | |
| 172 | versionStdout: DEFAULT_VERSION_STDOUT, | |
| 173 | inventoryError: null as Error | null, | |
| 174 | connectionError: null as Error | null, | |
| 175 | inventoryCwd: null as string | null, | |
| 176 | closeCalls: 0, | |
| 177 | sdkClientInputs: [] as Array<{ | |
| 178 | baseUrl: string; | |
| 179 | directory: string; | |
| 180 | serverPassword?: string; | |
| 181 | }>, | |
| 182 | inventory: { | |
| 183 | providerList: { connected: [] as string[], all: [] as unknown[], default: {} }, | |
| 184 | agents: [] as unknown[], | |
| 185 | skills: [] as unknown[], | |
| 186 | } as unknown, | |
| 187 | }, | |
| 188 | reset() { | |
| 189 | this.state.runVersionError = null; | |
| 190 | this.state.runVersionPending = false; | |
| 191 | this.state.versionStdout = DEFAULT_VERSION_STDOUT; | |
| 192 | this.state.inventoryError = null; | |
| 193 | this.state.connectionError = null; | |
| 194 | this.state.inventoryCwd = null; | |
| 195 | this.state.closeCalls = 0; | |
| 196 | this.state.sdkClientInputs.length = 0; | |
| 197 | this.state.inventory = { | |
| 198 | providerList: { connected: [], all: [] as unknown[], default: {} }, | |
| 199 | agents: [] as unknown[], | |
| 200 | skills: [] as unknown[], | |
| 201 | }; | |
| 202 | }, | |
| 203 | }; | |
| 204 | ||
| 205 | const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = { | |
| 206 | startOpenCodeServerProcess: ({ serverPassword, environment }) => | |
| 207 | Effect.gen(function* () { | |
| 208 | yield* Effect.addFinalizer(() => | |
| 209 | Effect.sync(() => { | |
| 210 | runtimeMock.state.closeCalls += 1; | |
| 211 | }), | |
| 212 | ); | |
| 213 | const effectiveServerPassword = OpenCodeRuntime.resolveOpenCodeServerPassword({ | |
| 214 | external: false, | |
| 215 | ...(serverPassword !== undefined ? { serverPassword } : {}), | |
| 216 | ...(environment !== undefined ? { environment } : {}), | |
| 217 | }); | |
| 218 | return { | |
| 219 | url: "http://127.0.0.1:4301", | |
| 220 | ...(effectiveServerPassword !== undefined | |
| 221 | ? { serverPassword: effectiveServerPassword } | |
| 222 | : {}), | |
| 223 | version: "1.14.19", | |
| 224 | isRunning: Effect.succeed(true), | |
| 225 | exitCode: Effect.never, | |
| 226 | }; | |
| 227 | }), | |
| 228 | connectToOpenCodeServer: ({ serverUrl, serverPassword }) => | |
| 229 | Effect.gen(function* () { | |
| 230 | if (runtimeMock.state.connectionError) { | |
| 231 | return yield* new OpenCodeRuntime.OpenCodeRuntimeError({ | |
| 232 | operation: "global.health", | |
| 233 | detail: runtimeMock.state.connectionError.message, | |
| 234 | cause: runtimeMock.state.connectionError, | |
| 235 | }); | |
| 236 | } | |
| 237 | if (!serverUrl) { | |
| 238 | yield* Effect.addFinalizer(() => | |
| 239 | Effect.sync(() => { | |
| 240 | runtimeMock.state.closeCalls += 1; | |
| 241 | }), | |
| 242 | ); | |
| 243 | } | |
| 244 | return { | |
| 245 | url: serverUrl ?? "http://127.0.0.1:4301", | |
| 246 | ...(serverPassword ? { serverPassword } : {}), | |
| 247 | version: "1.14.19", | |
| 248 | exitCode: null, | |
| 249 | external: Boolean(serverUrl), | |
| 250 | }; | |
| 251 | }), | |
| 252 | runOpenCodeCommand: () => | |
| 253 | runtimeMock.state.runVersionPending | |
| 254 | ? Effect.never | |
| 255 | : runtimeMock.state.runVersionError | |
| 256 | ? Effect.fail( | |
| 257 | new OpenCodeRuntime.OpenCodeRuntimeError({ | |
| 258 | operation: "runOpenCodeCommand", | |
| 259 | detail: runtimeMock.state.runVersionError.message, | |
| 260 | cause: runtimeMock.state.runVersionError, | |
| 261 | }), | |
| 262 | ) | |
| 263 | : Effect.succeed({ stdout: runtimeMock.state.versionStdout, stderr: "", code: 0 }), | |
| 264 | createOpenCodeSdkClient: (input) => { | |
| 265 | runtimeMock.state.sdkClientInputs.push(input); | |
| 266 | return {} as unknown as ReturnType< | |
| 267 | OpenCodeRuntime.OpenCodeRuntimeShape["createOpenCodeSdkClient"] | |
| 268 | >; | |
| 269 | }, | |
| 270 | loadOpenCodeInventory: () => | |
| 271 | runtimeMock.state.inventoryError | |
| 272 | ? Effect.fail( | |
| 273 | new OpenCodeRuntime.OpenCodeRuntimeError({ | |
| 274 | operation: "loadOpenCodeInventory", | |
| 275 | detail: runtimeMock.state.inventoryError.message, | |
| 276 | cause: runtimeMock.state.inventoryError, | |
| 277 | }), | |
| 278 | ) | |
| 279 | : Effect.succeed(runtimeMock.state.inventory as OpenCodeRuntime.OpenCodeInventory), | |
| 280 | loadInventoryFromCli: ({ cwd }) => { | |
| 281 | runtimeMock.state.inventoryCwd = cwd; | |
| 282 | return runtimeMock.state.inventoryError | |
| 283 | ? Effect.fail( | |
| 284 | new OpenCodeRuntime.OpenCodeRuntimeError({ | |
| 285 | operation: "loadInventoryFromCli", | |
| 286 | detail: runtimeMock.state.inventoryError.message, | |
| 287 | cause: runtimeMock.state.inventoryError, | |
| 288 | }), | |
| 289 | ) | |
| 290 | : Effect.succeed(runtimeMock.state.inventory as OpenCodeRuntime.OpenCodeInventory); | |
| 291 | }, | |
| 292 | loadOpenCodeSkills: () => Effect.succeed([]), | |
| 293 | loadSkillsFromCli: () => Effect.succeed([]), | |
| 294 | }; | |
| 295 | ||
| 296 | beforeEach(() => { | |
| 297 | runtimeMock.reset(); | |
| 298 | }); | |
| 299 | ||
| 300 | it("keeps native and MCP commands while preserving compaction and separate skills", () => { | |
| 301 | NodeAssert.deepEqual( | |
| 302 | openCodeCommandsToServerProviderSlashCommands([ | |
| 303 | { name: "review", description: "Review changes", source: "command", hints: ["$ARGUMENTS"] }, | |
| 304 | { name: "review", source: "command", hints: [] }, | |
| 305 | { name: "compact", source: "command", hints: [] }, | |
| 306 | { name: "skill", source: "skill", hints: [] }, | |
| 307 | { name: "mcp:search", source: "mcp", hints: ["query"] }, | |
| 308 | ]).slice(1), | |
| 309 | [ | |
| 310 | { name: "review", description: "Review changes", input: { hint: "$ARGUMENTS" } }, | |
| 311 | { name: "mcp:search", input: { hint: "query" } }, | |
| 312 | ], | |
| 313 | ); | |
| 314 | }); | |
| 315 | ||
| 316 | const layerTest = Layer.succeed(OpenCodeRuntime.OpenCodeRuntime, OpenCodeRuntimeTestDouble).pipe( | |
| 317 | Layer.provideMerge(NodeServices.layer), | |
| 318 | ); | |
| 319 | ||
| 320 | const makeOpenCodeSettings = (overrides?: Partial<OpenCodeSettings>): OpenCodeSettings => | |
| 321 | decodeOpenCodeSettings({ | |
| 322 | enabled: true, | |
| 323 | binaryPath: "opencode", | |
| 324 | serverUrl: "", | |
| 325 | serverPassword: "", | |
| 326 | customModels: [], | |
| 327 | ...overrides, | |
| 328 | }); | |
| 329 | ||
| 330 | const checkProvider = Effect.fn("checkProvider")(function* ( | |
| 331 | settings: OpenCodeSettings, | |
| 332 | cwd = process.cwd(), | |
| 333 | environment?: NodeJS.ProcessEnv, | |
| 334 | server = replayOpenCodeServer(OPENCODE_1_RESPONSES, settings.serverPassword), | |
| 335 | openCode2Models: Effect.Effect< | |
| 336 | ReadonlyArray<OpenCode2Model>, | |
| 337 | OpenCodeRuntime.OpenCodeRuntimeError | |
| 338 | > = Effect.succeed([]), | |
| 339 | ) { | |
| 340 | return yield* Effect.scoped( | |
| 341 | Effect.gen(function* () { | |
| 342 | const serverOwner = yield* OpenCodeServerOwner.make({ | |
| 343 | binaryPath: settings.binaryPath, | |
| 344 | directory: cwd, | |
| 345 | ...(settings.serverPassword ? { serverPassword: settings.serverPassword } : {}), | |
| 346 | ...(environment ? { environment } : {}), | |
| 347 | }); | |
| 348 | const probe = probeOpenCodeRuntime(settings, environment).pipe( | |
| 349 | Effect.provideService(HttpClient.HttpClient, server), | |
| 350 | Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, OpenCodeRuntimeTestDouble), | |
| 351 | ); | |
| 352 | return yield* checkOpenCodeProviderStatus(settings, cwd, probe, openCode2Models).pipe( | |
| 353 | Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner), | |
| 354 | ); | |
| 355 | }), | |
| 356 | ); | |
| 357 | }); | |
| 358 | ||
| 359 | it.layer(layerTest)("checkOpenCodeProviderStatus", (it) => { | |
| 360 | it.effect("shows a codex-style missing binary message", () => | |
| 361 | Effect.gen(function* () { | |
| 362 | runtimeMock.state.runVersionError = new Error("spawn opencode ENOENT"); | |
| 363 | const snapshot = yield* checkProvider(makeOpenCodeSettings()); | |
| 364 | ||
| 365 | NodeAssert.equal(snapshot.status, "error"); | |
| 366 | NodeAssert.equal(snapshot.installed, false); | |
| 367 | NodeAssert.equal( | |
| 368 | snapshot.message, | |
| 369 | "OpenCode CLI (`opencode`) is not installed or not on PATH.", | |
| 370 | ); | |
| 371 | }), | |
| 372 | ); | |
| 373 | ||
| 374 | it.effect("hides generic Effect.tryPromise text for local CLI probe failures", () => | |
| 375 | Effect.gen(function* () { | |
| 376 | runtimeMock.state.runVersionError = new Error("An error occurred in Effect.tryPromise"); | |
| 377 | const snapshot = yield* checkProvider(makeOpenCodeSettings()); | |
| 378 | ||
| 379 | NodeAssert.equal(snapshot.status, "error"); | |
| 380 | NodeAssert.equal(snapshot.installed, true); | |
| 381 | NodeAssert.equal(snapshot.message, "Failed to execute OpenCode CLI health check."); | |
| 382 | }), | |
| 383 | ); | |
| 384 | ||
| 385 | it.effect("times out a hanging local CLI version probe", () => | |
| 386 | Effect.gen(function* () { | |
| 387 | runtimeMock.state.runVersionPending = true; | |
| 388 | const probeFiber = yield* checkProvider(makeOpenCodeSettings()).pipe(Effect.forkChild); | |
| 389 | ||
| 390 | yield* Effect.yieldNow; | |
| 391 | yield* TestClock.adjust("4 seconds"); | |
| 392 | const snapshot = yield* Fiber.join(probeFiber); | |
| 393 | ||
| 394 | NodeAssert.equal(snapshot.status, "error"); | |
| 395 | NodeAssert.equal(snapshot.installed, true); | |
| 396 | NodeAssert.equal( | |
| 397 | snapshot.message, | |
| 398 | "Failed to execute OpenCode CLI health check: OpenCode CLI version probe timed out after 4 seconds.", | |
| 399 | ); | |
| 400 | }).pipe(Effect.provide(TestClock.layer())), | |
| 401 | ); | |
| 402 | ||
| 403 | it.effect("emits OpenCode variant defaults so trait picker can resolve a visible selection", () => | |
| 404 | Effect.gen(function* () { | |
| 405 | runtimeMock.state.inventory = { | |
| 406 | providerList: { | |
| 407 | connected: ["openai"], | |
| 408 | all: [ | |
| 409 | { | |
| 410 | id: "openai", | |
| 411 | name: "OpenAI", | |
| 412 | models: { | |
| 413 | "gpt-5.4": { | |
| 414 | id: "gpt-5.4", | |
| 415 | name: "GPT-5.4", | |
| 416 | variants: { | |
| 417 | none: {}, | |
| 418 | low: {}, | |
| 419 | medium: {}, | |
| 420 | high: {}, | |
| 421 | xhigh: {}, | |
| 422 | }, | |
| 423 | }, | |
| 424 | }, | |
| 425 | }, | |
| 426 | ], | |
| 427 | default: {}, | |
| 428 | }, | |
| 429 | agents: [ | |
| 430 | { name: "build", hidden: false, mode: "primary" }, | |
| 431 | { name: "plan", hidden: false, mode: "primary" }, | |
| 432 | ], | |
| 433 | }; | |
| 434 | ||
| 435 | const snapshot = yield* checkProvider(makeOpenCodeSettings()); | |
| 436 | const model = snapshot.models.find((entry) => entry.slug === "openai/gpt-5.4"); | |
| 437 | ||
| 438 | NodeAssert.ok(model); | |
| 439 | const variantDescriptor = model.capabilities?.optionDescriptors?.find( | |
| 440 | (descriptor) => descriptor.id === "variant" && descriptor.type === "select", | |
| 441 | ); | |
| 442 | NodeAssert.ok(variantDescriptor && variantDescriptor.type === "select"); | |
| 443 | NodeAssert.equal( | |
| 444 | variantDescriptor.options.find((option) => option.isDefault === true)?.id, | |
| 445 | "medium", | |
| 446 | ); | |
| 447 | const agentDescriptor = model.capabilities?.optionDescriptors?.find( | |
| 448 | (descriptor) => descriptor.id === "agent" && descriptor.type === "select", | |
| 449 | ); | |
| 450 | NodeAssert.ok(agentDescriptor && agentDescriptor.type === "select"); | |
| 451 | NodeAssert.equal( | |
| 452 | agentDescriptor.options.find((option) => option.isDefault === true)?.id, | |
| 453 | "build", | |
| 454 | ); | |
| 455 | }), | |
| 456 | ); | |
| 457 | ||
| 458 | it.effect("includes OpenCode skills in the provider snapshot", () => | |
| 459 | Effect.gen(function* () { | |
| 460 | runtimeMock.state.inventory = { | |
| 461 | providerList: { | |
| 462 | connected: ["openai"], | |
| 463 | all: [ | |
| 464 | { | |
| 465 | id: "openai", | |
| 466 | name: "OpenAI", | |
| 467 | models: { | |
| 468 | "gpt-5.4": { | |
| 469 | id: "gpt-5.4", | |
| 470 | name: "GPT-5.4", | |
| 471 | variants: {}, | |
| 472 | }, | |
| 473 | }, | |
| 474 | }, | |
| 475 | ], | |
| 476 | default: {}, | |
| 477 | }, | |
| 478 | agents: [], | |
| 479 | skills: [ | |
| 480 | { | |
| 481 | name: "openclaw-review", | |
| 482 | description: "Review OpenClaw workflow changes.", | |
| 483 | location: "/Users/test/.agents/skills/openclaw-review/SKILL.md", | |
| 484 | }, | |
| 485 | { | |
| 486 | name: "openclaw-triage", | |
| 487 | description: "Triage OpenClaw routing issues.", | |
| 488 | location: "/Users/test/.agents/skills/openclaw-triage/SKILL.md", | |
| 489 | }, | |
| 490 | { | |
| 491 | name: "missing-location", | |
| 492 | description: "This incomplete SDK row should be skipped.", | |
| 493 | location: "", | |
| 494 | }, | |
| 495 | ], | |
| 496 | }; | |
| 497 | ||
| 498 | const snapshot = yield* checkProvider(makeOpenCodeSettings()); | |
| 499 | ||
| 500 | NodeAssert.deepEqual( | |
| 501 | snapshot.skills.map((skill) => ({ | |
| 502 | name: skill.name, | |
| 503 | path: skill.path, | |
| 504 | enabled: skill.enabled, | |
| 505 | shortDescription: skill.shortDescription, | |
| 506 | })), | |
| 507 | [ | |
| 508 | { | |
| 509 | name: "openclaw-review", | |
| 510 | path: "/Users/test/.agents/skills/openclaw-review/SKILL.md", | |
| 511 | enabled: true, | |
| 512 | shortDescription: "Review OpenClaw workflow changes.", | |
| 513 | }, | |
| 514 | { | |
| 515 | name: "openclaw-triage", | |
| 516 | path: "/Users/test/.agents/skills/openclaw-triage/SKILL.md", | |
| 517 | enabled: true, | |
| 518 | shortDescription: "Triage OpenClaw routing issues.", | |
| 519 | }, | |
| 520 | ], | |
| 521 | ); | |
| 522 | }), | |
| 523 | ); | |
| 524 | ||
| 525 | it.effect("loads local inventory from a scoped OpenCode server", () => | |
| 526 | Effect.gen(function* () { | |
| 527 | yield* checkProvider(makeOpenCodeSettings({ serverPassword: "secret-password" })); | |
| 528 | ||
| 529 | NodeAssert.deepEqual(runtimeMock.state.sdkClientInputs, [ | |
| 530 | { | |
| 531 | baseUrl: "http://127.0.0.1:4301", | |
| 532 | directory: process.cwd(), | |
| 533 | serverPassword: "secret-password", | |
| 534 | }, | |
| 535 | ]); | |
| 536 | NodeAssert.equal(runtimeMock.state.closeCalls, 1); | |
| 537 | NodeAssert.equal(runtimeMock.state.inventoryCwd, null); | |
| 538 | }), | |
| 539 | ); | |
| 540 | ||
| 541 | it.effect("lists a local OpenCode 2 binary's models in Full access only, never via 1.x", () => | |
| 542 | Effect.gen(function* () { | |
| 543 | runtimeMock.state.versionStdout = "opencode v2.0.18\n"; | |
| 544 | const snapshot = yield* checkProvider( | |
| 545 | makeOpenCodeSettings(), | |
| 546 | process.cwd(), | |
| 547 | undefined, | |
| 548 | undefined, | |
| 549 | Effect.succeed([ | |
| 550 | { providerID: "opencode", id: "big-pickle", name: "Big Pickle", variants: [] }, | |
| 551 | { | |
| 552 | providerID: "opencode", | |
| 553 | id: "space-bunny-free", | |
| 554 | name: "Space Bunny Free", | |
| 555 | variants: [{ id: "low" }, { id: "medium" }, { id: "high" }], | |
| 556 | }, | |
| 557 | ]), | |
| 558 | ); | |
| 559 | ||
| 560 | NodeAssert.equal(snapshot.status, "ready"); | |
| 561 | NodeAssert.equal(snapshot.version, "2.0.18"); | |
| 562 | NodeAssert.deepEqual(snapshot.supportedRuntimeModes, [ | |
| 563 | "approval-required", | |
| 564 | "auto-accept-edits", | |
| 565 | "auto", | |
| 566 | "full-access", | |
| 567 | ]); | |
| 568 | // Plan mode runs as OpenCode's plan agent, and every session can compact. | |
| 569 | NodeAssert.equal(snapshot.showInteractionModeToggle, true); | |
| 570 | NodeAssert.deepEqual( | |
| 571 | snapshot.slashCommands.map((command) => command.name), | |
| 572 | ["compact"], | |
| 573 | ); | |
| 574 | NodeAssert.deepEqual( | |
| 575 | snapshot.models.map((model) => model.slug), | |
| 576 | ["opencode/big-pickle", "opencode/space-bunny-free"], | |
| 577 | ); | |
| 578 | const variant = snapshot.models.find((model) => model.slug === "opencode/space-bunny-free") | |
| 579 | ?.capabilities?.optionDescriptors?.[0]; | |
| 580 | NodeAssert.deepEqual( | |
| 581 | variant?.type === "select" ? variant.options.map((option) => option.id) : [], | |
| 582 | ["low", "medium", "high"], | |
| 583 | ); | |
| 584 | NodeAssert.equal(runtimeMock.state.sdkClientInputs.length, 0); | |
| 585 | }), | |
| 586 | ); | |
| 587 | ||
| 588 | it.effect("reports a failed OpenCode 2 model list without the server's response", () => | |
| 589 | Effect.gen(function* () { | |
| 590 | runtimeMock.state.versionStdout = "opencode v2.0.18\n"; | |
| 591 | const snapshot = yield* checkProvider( | |
| 592 | makeOpenCodeSettings(), | |
| 593 | process.cwd(), | |
| 594 | undefined, | |
| 595 | undefined, | |
| 596 | Effect.fail( | |
| 597 | new OpenCodeRuntime.OpenCodeRuntimeError({ | |
| 598 | operation: "model.list", | |
| 599 | detail: 'status=500 body={"token":"leaked-response-body"}', | |
| 600 | }), | |
| 601 | ), | |
| 602 | ); | |
| 603 | ||
| 604 | NodeAssert.equal(snapshot.status, "error"); | |
| 605 | NodeAssert.equal(snapshot.message, "OpenCode could not load its model list."); | |
| 606 | // A failed catalog read still leaves the session able to compact. | |
| 607 | NodeAssert.deepEqual( | |
| 608 | snapshot.slashCommands.map((command) => command.name), | |
| 609 | ["compact"], | |
| 610 | ); | |
| 611 | }), | |
| 612 | ); | |
| 613 | ||
| 614 | it.effect("uses an environment-only password for local inventory", () => | |
| 615 | Effect.gen(function* () { | |
| 616 | yield* checkProvider(makeOpenCodeSettings(), process.cwd(), { | |
| 617 | OPENCODE_SERVER_PASSWORD: "environment-password", | |
| 618 | }); | |
| 619 | ||
| 620 | NodeAssert.deepEqual(runtimeMock.state.sdkClientInputs, [ | |
| 621 | { | |
| 622 | baseUrl: "http://127.0.0.1:4301", | |
| 623 | directory: process.cwd(), | |
| 624 | serverPassword: "environment-password", | |
| 625 | }, | |
| 626 | ]); | |
| 627 | }), | |
| 628 | ); | |
| 629 | ||
| 630 | it.effect("uses the settings password when local environment auth differs", () => | |
| 631 | Effect.gen(function* () { | |
| 632 | yield* checkProvider( | |
| 633 | makeOpenCodeSettings({ serverPassword: "settings-password" }), | |
| 634 | process.cwd(), | |
| 635 | { OPENCODE_SERVER_PASSWORD: "environment-password" }, | |
| 636 | ); | |
| 637 | ||
| 638 | NodeAssert.equal(runtimeMock.state.sdkClientInputs[0]?.serverPassword, "settings-password"); | |
| 639 | }), | |
| 640 | ); | |
| 641 | ||
| 642 | it.effect("reports local model inventory failures without treating them as empty", () => | |
| 643 | Effect.gen(function* () { | |
| 644 | runtimeMock.state.inventoryError = new Error("opencode models failed"); | |
| 645 | const snapshot = yield* checkProvider(makeOpenCodeSettings()); | |
| 646 | ||
| 647 | NodeAssert.equal(snapshot.status, "error"); | |
| 648 | NodeAssert.equal(snapshot.installed, true); | |
| 649 | NodeAssert.equal(snapshot.models.length, 0); | |
| 650 | NodeAssert.equal( | |
| 651 | snapshot.message, | |
| 652 | "Failed to load OpenCode provider inventory: opencode models failed", | |
| 653 | ); | |
| 654 | }), | |
| 655 | ); | |
| 656 | }); | |
| 657 | ||
| 658 | it.layer(layerTest)("checkOpenCodeProviderStatus with configured server URL", (it) => { | |
| 659 | it.effect("does not send a local environment password to a configured server", () => | |
| 660 | Effect.gen(function* () { | |
| 661 | const snapshot = yield* checkProvider( | |
| 662 | makeOpenCodeSettings({ serverUrl: "http://127.0.0.1:9999" }), | |
| 663 | process.cwd(), | |
| 664 | { OPENCODE_SERVER_PASSWORD: "local-secret" }, | |
| 665 | ); | |
| 666 | ||
| 667 | NodeAssert.equal(snapshot.version, "1.14.19"); | |
| 668 | NodeAssert.deepEqual(runtimeMock.state.sdkClientInputs, [ | |
| 669 | { | |
| 670 | baseUrl: "http://127.0.0.1:9999", | |
| 671 | directory: process.cwd(), | |
| 672 | }, | |
| 673 | ]); | |
| 674 | }), | |
| 675 | ); | |
| 676 | ||
| 677 | it.effect("routes a configured OpenCode 2 server to the 2.x check, never via 1.x", () => | |
| 678 | Effect.gen(function* () { | |
| 679 | const settings = makeOpenCodeSettings({ | |
| 680 | serverUrl: "http://127.0.0.1:9999", | |
| 681 | serverPassword: "secret-password", | |
| 682 | }); | |
| 683 | const snapshot = yield* checkProvider( | |
| 684 | settings, | |
| 685 | process.cwd(), | |
| 686 | undefined, | |
| 687 | replayOpenCodeServer(OPENCODE_2_RESPONSES, "secret-password"), | |
| 688 | ); | |
| 689 | ||
| 690 | NodeAssert.equal(snapshot.status, "warning"); | |
| 691 | NodeAssert.equal(snapshot.version, "2.0.18"); | |
| 692 | NodeAssert.deepEqual(snapshot.supportedRuntimeModes, [ | |
| 693 | "approval-required", | |
| 694 | "auto-accept-edits", | |
| 695 | "auto", | |
| 696 | "full-access", | |
| 697 | ]); | |
| 698 | NodeAssert.equal(runtimeMock.state.sdkClientInputs.length, 0); | |
| 699 | }), | |
| 700 | ); | |
| 701 | ||
| 702 | it.effect("reports a rejected OpenCode 2 password as an auth error, not a version", () => | |
| 703 | Effect.gen(function* () { | |
| 704 | const snapshot = yield* checkProvider( | |
| 705 | makeOpenCodeSettings({ serverUrl: "http://127.0.0.1:9999", serverPassword: "wrong" }), | |
| 706 | process.cwd(), | |
| 707 | undefined, | |
| 708 | replayOpenCodeServer(OPENCODE_2_RESPONSES, "secret-password"), | |
| 709 | ); | |
| 710 | ||
| 711 | NodeAssert.equal(snapshot.status, "error"); | |
| 712 | NodeAssert.equal( | |
| 713 | snapshot.message, | |
| 714 | "OpenCode server rejected authentication. Check the server URL and password.", | |
| 715 | ); | |
| 716 | }), | |
| 717 | ); | |
| 718 | ||
| 719 | it.effect("rejects an unsupported server before loading inventory", () => | |
| 720 | Effect.gen(function* () { | |
| 721 | runtimeMock.state.connectionError = new Error( | |
| 722 | "OpenCode v1.14.18 is too old. Upgrade to v1.14.19 or newer.", | |
| 723 | ); | |
| 724 | const snapshot = yield* checkProvider( | |
| 725 | makeOpenCodeSettings({ serverUrl: "http://127.0.0.1:9999" }), | |
| 726 | ); | |
| 727 | ||
| 728 | NodeAssert.equal(snapshot.status, "error"); | |
| 729 | NodeAssert.equal(snapshot.models.length, 0); | |
| 730 | NodeAssert.match(snapshot.message ?? "", /v1\.14\.18 is too old/); | |
| 731 | NodeAssert.equal(runtimeMock.state.sdkClientInputs.length, 0); | |
| 732 | }), | |
| 733 | ); | |
| 734 | ||
| 735 | it.effect("surfaces a friendly auth error for configured servers", () => | |
| 736 | Effect.gen(function* () { | |
| 737 | runtimeMock.state.connectionError = new Error("401 Unauthorized"); | |
| 738 | const snapshot = yield* checkProvider( | |
| 739 | makeOpenCodeSettings({ | |
| 740 | serverUrl: "http://127.0.0.1:9999", | |
| 741 | serverPassword: "secret-password", | |
| 742 | }), | |
| 743 | ); | |
| 744 | ||
| 745 | NodeAssert.equal(snapshot.status, "error"); | |
| 746 | NodeAssert.equal(snapshot.installed, true); | |
| 747 | NodeAssert.equal( | |
| 748 | snapshot.message, | |
| 749 | "OpenCode server rejected authentication. Check the server URL and password.", | |
| 750 | ); | |
| 751 | }), | |
| 752 | ); | |
| 753 | ||
| 754 | it.effect("surfaces a friendly connection error for configured servers", () => | |
| 755 | Effect.gen(function* () { | |
| 756 | runtimeMock.state.connectionError = new Error( | |
| 757 | "fetch failed: connect ECONNREFUSED 127.0.0.1:9999", | |
| 758 | ); | |
| 759 | const snapshot = yield* checkProvider( | |
| 760 | makeOpenCodeSettings({ | |
| 761 | serverUrl: "http://127.0.0.1:9999", | |
| 762 | serverPassword: "secret-password", | |
| 763 | }), | |
| 764 | ); | |
| 765 | ||
| 766 | NodeAssert.equal(snapshot.status, "error"); | |
| 767 | NodeAssert.equal(snapshot.installed, true); | |
| 768 | NodeAssert.equal( | |
| 769 | snapshot.message, | |
| 770 | "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.", | |
| 771 | ); | |
| 772 | }), | |
| 773 | ); | |
| 774 | }); | |
| 775 | ||
| 776 | const bigPickle: OpenCode2Model = { | |
| 777 | providerID: "opencode", | |
| 778 | id: "big-pickle", | |
| 779 | name: "Big Pickle", | |
| 780 | variants: [], | |
| 781 | }; | |
| 782 | ||
| 783 | it.effect("waits for a fresh OpenCode 2 server to list its models", () => | |
| 784 | Effect.gen(function* () { | |
| 785 | // A fresh server lists nothing until its catalog loads. | |
| 786 | const replies: Array<ReadonlyArray<OpenCode2Model>> = [[], [], [bigPickle]]; | |
| 787 | const load = yield* makeOpenCode2ModelLoader(Effect.sync(() => replies.shift() ?? [bigPickle])); | |
| 788 | const fiber = yield* load.pipe(Effect.forkChild); | |
| 789 | yield* TestClock.adjust("1 second"); | |
| 790 | NodeAssert.deepEqual(yield* Fiber.join(fiber), [bigPickle]); | |
| 791 | }).pipe(Effect.provide(TestClock.layer())), | |
| 792 | ); | |
| 793 | ||
| 794 | it.effect("keeps the last OpenCode 2 model list while a fresh server's stays empty", () => | |
| 795 | Effect.gen(function* () { | |
| 796 | let listed: ReadonlyArray<OpenCode2Model> = [bigPickle]; | |
| 797 | const load = yield* makeOpenCode2ModelLoader(Effect.sync(() => listed)); | |
| 798 | NodeAssert.deepEqual(yield* load, [bigPickle]); | |
| 799 | listed = []; | |
| 800 | const fiber = yield* load.pipe(Effect.forkChild); | |
| 801 | yield* TestClock.adjust("6 seconds"); | |
| 802 | NodeAssert.deepEqual(yield* Fiber.join(fiber), [bigPickle]); | |
| 803 | }).pipe(Effect.provide(TestClock.layer())), | |
| 804 | ); | |
| 805 | ||
| 806 | // What 2.0.18 lists for a directory once it has scanned it (live, 2026-09-29). | |
| 807 | const builtinCommands = [ | |
| 808 | { name: "init", description: "guided AGENTS.md setup" }, | |
| 809 | { name: "review", description: "review changes [commit|branch|pr], defaults to uncommitted" }, | |
| 810 | ]; | |
| 811 | const builtinSkills = [ | |
| 812 | { id: "opencode", name: "OpenCode", path: "/builtin/opencode.md" }, | |
| 813 | { id: "report", name: "Report", path: "/builtin/report.md" }, | |
| 814 | ]; | |
| 815 | const scanned: OpenCode2Workspace = { | |
| 816 | commands: [...builtinCommands, { name: "hello", description: "Say hello to the workspace" }], | |
| 817 | skills: [ | |
| 818 | ...builtinSkills, | |
| 819 | { | |
| 820 | id: "greet", | |
| 821 | name: "greet", | |
| 822 | path: "/work/.opencode/skills/greet/SKILL.md", | |
| 823 | description: "Greets the user with the secret word MANGO.", | |
| 824 | }, | |
| 825 | ], | |
| 826 | }; | |
| 827 | ||
| 828 | it.effect("reads a fresh OpenCode 2 directory again once its scan has ended", () => | |
| 829 | Effect.gen(function* () { | |
| 830 | // A directory the server has not served yet lists no commands until its scan ends. | |
| 831 | const reads: Array<OpenCode2Workspace> = [{ commands: [], skills: [] }, scanned]; | |
| 832 | const scanEnded = yield* Deferred.make<void>(); | |
| 833 | const load = yield* loadOpenCode2Workspace( | |
| 834 | Effect.sync(() => reads.shift()!), | |
| 835 | Deferred.await(scanEnded), | |
| 836 | ).pipe(Effect.forkChild); | |
| 837 | yield* Effect.yieldNow; | |
| 838 | NodeAssert.equal(reads.length, 1); | |
| 839 | yield* Deferred.succeed(scanEnded, undefined); | |
| 840 | NodeAssert.deepEqual(yield* Fiber.join(load), scanned); | |
| 841 | }), | |
| 842 | ); | |
| 843 | ||
| 844 | it.effect("takes a served OpenCode 2 directory's first listing as is", () => | |
| 845 | Effect.gen(function* () { | |
| 846 | const workspace = yield* loadOpenCode2Workspace(Effect.succeed(scanned), Effect.never); | |
| 847 | NodeAssert.deepEqual(workspace, scanned); | |
| 848 | }), | |
| 849 | ); | |
| 850 | ||
| 851 | it("lists an OpenCode 2 directory's skills by id and its commands after /compact", () => { | |
| 852 | NodeAssert.deepEqual( | |
| 853 | openCode2SkillsToServerProviderSkills(scanned.skills).map((skill) => [ | |
| 854 | skill.name, | |
| 855 | skill.displayName, | |
| 856 | skill.path, | |
| 857 | ]), | |
| 858 | [ | |
| 859 | ["greet", undefined, "/work/.opencode/skills/greet/SKILL.md"], | |
| 860 | ["opencode", "OpenCode", "/builtin/opencode.md"], | |
| 861 | ["report", "Report", "/builtin/report.md"], | |
| 862 | ], | |
| 863 | ); | |
| 864 | NodeAssert.deepEqual( | |
| 865 | openCode2CommandsToServerProviderSlashCommands([ | |
| 866 | ...scanned.commands, | |
| 867 | { name: "compact", description: "a workspace command named like T3's own" }, | |
| 868 | ]).map((command) => command.name), | |
| 869 | ["compact", "init", "review", "hello"], | |
| 870 | ); | |
| 871 | }); |
packages/provider-opencode/src/server/status.ts created+747| ... | ... | @@ -0,0 +1,747 @@ |
| 1 | import { | |
| 2 | type ModelCapabilities, | |
| 3 | type ServerProviderModel, | |
| 4 | type ServerProviderSkill, | |
| 5 | type ServerProviderSlashCommand, | |
| 6 | } from "@t3tools/contracts"; | |
| 7 | import type { OpenCodeSettings } from "../settings.ts"; | |
| 8 | import * as Cause from "effect/Cause"; | |
| 9 | import * as Data from "effect/Data"; | |
| 10 | import * as DateTime from "effect/DateTime"; | |
| 11 | import * as Effect from "effect/Effect"; | |
| 12 | import * as Schedule from "effect/Schedule"; | |
| 13 | ||
| 14 | import { createModelCapabilities } from "@t3tools/shared/model"; | |
| 15 | import { compareSemverVersions } from "@t3tools/shared/semver"; | |
| 16 | import { | |
| 17 | buildServerProvider, | |
| 18 | COMPACT_SLASH_COMMAND, | |
| 19 | nonEmptyTrimmed, | |
| 20 | providerModelsFromSettings, | |
| 21 | type ServerProviderDraft, | |
| 22 | } from "@t3tools/provider-core/server/snapshotProbe"; | |
| 23 | import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; | |
| 24 | import type { ProbedOpenCode } from "./versionProbe.ts"; | |
| 25 | import type { Agent, ProviderListResponse } from "@opencode-ai/sdk/v2"; | |
| 26 | import * as OpenCodeServerOwner from "./OpenCodeServerOwner.ts"; | |
| 27 | ||
| 28 | const OPENCODE_PRESENTATION = { | |
| 29 | displayName: "OpenCode", | |
| 30 | showInteractionModeToggle: false, | |
| 31 | } as const; | |
| 32 | ||
| 33 | class OpenCodeProbeError extends Data.TaggedError("OpenCodeProbeError")<{ | |
| 34 | readonly cause?: unknown; | |
| 35 | readonly detail: string; | |
| 36 | }> {} | |
| 37 | ||
| 38 | function normalizeProbeMessage(message: string): string | undefined { | |
| 39 | const trimmed = message.trim(); | |
| 40 | if (trimmed.length === 0) { | |
| 41 | return undefined; | |
| 42 | } | |
| 43 | if ( | |
| 44 | trimmed === "An error occurred in Effect.tryPromise" || | |
| 45 | trimmed === "An error occurred in Effect.try" | |
| 46 | ) { | |
| 47 | return undefined; | |
| 48 | } | |
| 49 | return trimmed; | |
| 50 | } | |
| 51 | ||
| 52 | function normalizedErrorMessage(cause: unknown): string | undefined { | |
| 53 | if (cause instanceof OpenCodeProbeError) { | |
| 54 | return normalizeProbeMessage(cause.detail); | |
| 55 | } | |
| 56 | ||
| 57 | if (!(cause instanceof Error)) { | |
| 58 | return undefined; | |
| 59 | } | |
| 60 | ||
| 61 | return normalizeProbeMessage(cause.message); | |
| 62 | } | |
| 63 | ||
| 64 | function formatOpenCodeProbeError(input: { | |
| 65 | readonly cause: unknown; | |
| 66 | readonly isExternalServer: boolean; | |
| 67 | readonly phase: "version" | "inventory"; | |
| 68 | readonly serverUrl: string; | |
| 69 | }): { readonly installed: boolean; readonly message: string } { | |
| 70 | const detail = normalizedErrorMessage(input.cause); | |
| 71 | const lower = detail?.toLowerCase() ?? ""; | |
| 72 | ||
| 73 | if (input.isExternalServer) { | |
| 74 | if ( | |
| 75 | lower.includes("401") || | |
| 76 | lower.includes("403") || | |
| 77 | lower.includes("unauthorized") || | |
| 78 | lower.includes("forbidden") | |
| 79 | ) { | |
| 80 | return { | |
| 81 | installed: true, | |
| 82 | message: "OpenCode server rejected authentication. Check the server URL and password.", | |
| 83 | }; | |
| 84 | } | |
| 85 | ||
| 86 | if ( | |
| 87 | lower.includes("econnrefused") || | |
| 88 | lower.includes("enotfound") || | |
| 89 | lower.includes("fetch failed") || | |
| 90 | lower.includes("networkerror") || | |
| 91 | lower.includes("timed out") || | |
| 92 | lower.includes("timeout") || | |
| 93 | lower.includes("socket hang up") | |
| 94 | ) { | |
| 95 | return { | |
| 96 | installed: true, | |
| 97 | message: `Couldn't reach the configured OpenCode server at ${input.serverUrl}. Check that the server is running and the URL is correct.`, | |
| 98 | }; | |
| 99 | } | |
| 100 | ||
| 101 | return { | |
| 102 | installed: true, | |
| 103 | message: detail ?? "Failed to connect to the configured OpenCode server.", | |
| 104 | }; | |
| 105 | } | |
| 106 | ||
| 107 | if (lower.includes("enoent") || lower.includes("notfound")) { | |
| 108 | return { | |
| 109 | installed: false, | |
| 110 | message: "OpenCode CLI (`opencode`) is not installed or not on PATH.", | |
| 111 | }; | |
| 112 | } | |
| 113 | ||
| 114 | if (lower.includes("quarantine")) { | |
| 115 | return { | |
| 116 | installed: true, | |
| 117 | message: | |
| 118 | "macOS is blocking the OpenCode binary (quarantine). Run `xattr -d com.apple.quarantine $(which opencode)` to fix this.", | |
| 119 | }; | |
| 120 | } | |
| 121 | ||
| 122 | if (lower.includes("invalid code signature") || lower.includes("corrupted")) { | |
| 123 | return { | |
| 124 | installed: true, | |
| 125 | message: | |
| 126 | "macOS killed the OpenCode process due to an invalid code signature. The binary may be corrupted — try reinstalling OpenCode.", | |
| 127 | }; | |
| 128 | } | |
| 129 | ||
| 130 | const failureLabel = | |
| 131 | input.phase === "inventory" | |
| 132 | ? "Failed to load OpenCode provider inventory" | |
| 133 | : "Failed to execute OpenCode CLI health check"; | |
| 134 | return { | |
| 135 | installed: true, | |
| 136 | message: detail ? `${failureLabel}: ${detail}` : `${failureLabel}.`, | |
| 137 | }; | |
| 138 | } | |
| 139 | ||
| 140 | function titleCaseSlug(value: string): string { | |
| 141 | const segments: Array<string> = []; | |
| 142 | for (const segment of value.split(/[-_/]+/)) { | |
| 143 | if (segment.length > 0) { | |
| 144 | segments.push(segment.charAt(0).toUpperCase() + segment.slice(1)); | |
| 145 | } | |
| 146 | } | |
| 147 | return segments.join(" "); | |
| 148 | } | |
| 149 | ||
| 150 | function inferDefaultVariant( | |
| 151 | providerID: string, | |
| 152 | variants: ReadonlyArray<string>, | |
| 153 | ): string | undefined { | |
| 154 | if (variants.length === 1) { | |
| 155 | return variants[0]; | |
| 156 | } | |
| 157 | if (providerID === "anthropic" || providerID.startsWith("google")) { | |
| 158 | return variants.includes("high") ? "high" : undefined; | |
| 159 | } | |
| 160 | if (providerID === "openai" || providerID === "opencode") { | |
| 161 | return variants.includes("medium") ? "medium" : variants.includes("high") ? "high" : undefined; | |
| 162 | } | |
| 163 | return undefined; | |
| 164 | } | |
| 165 | ||
| 166 | function inferDefaultAgent(agents: ReadonlyArray<Agent>): string | undefined { | |
| 167 | return agents.find((agent) => agent.name === "build")?.name ?? agents[0]?.name ?? undefined; | |
| 168 | } | |
| 169 | ||
| 170 | const DEFAULT_OPENCODE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ | |
| 171 | optionDescriptors: [ | |
| 172 | { | |
| 173 | id: "variant", | |
| 174 | label: "Reasoning", | |
| 175 | type: "select", | |
| 176 | options: [ | |
| 177 | { id: "low", label: "Low" }, | |
| 178 | { id: "medium", label: "Medium", isDefault: true }, | |
| 179 | { id: "high", label: "High" }, | |
| 180 | { id: "xhigh", label: "Extra High" }, | |
| 181 | ], | |
| 182 | currentValue: "medium", | |
| 183 | }, | |
| 184 | { | |
| 185 | id: "agent", | |
| 186 | label: "Agent", | |
| 187 | type: "select", | |
| 188 | options: [ | |
| 189 | { id: "build", label: "Build", isDefault: true }, | |
| 190 | { id: "plan", label: "Plan" }, | |
| 191 | ], | |
| 192 | currentValue: "build", | |
| 193 | }, | |
| 194 | ], | |
| 195 | }); | |
| 196 | ||
| 197 | function openCodeCapabilitiesForModel(input: { | |
| 198 | readonly providerID: string; | |
| 199 | readonly model: ProviderListResponse["all"][number]["models"][string]; | |
| 200 | readonly agents: ReadonlyArray<Agent>; | |
| 201 | }): ModelCapabilities { | |
| 202 | const rawVariantValues = Object.keys(input.model.variants ?? {}); | |
| 203 | // When a model advertises no variants, synthesize the standard reasoning | |
| 204 | // levels so the composer still offers a Reasoning selector (mirrors the | |
| 205 | // Codex/Grok experience where reasoning is always configurable). The set | |
| 206 | // covers the common OpenCode variant spectrum; `inferDefaultVariant` | |
| 207 | // picks the provider-appropriate default (e.g. medium for openai/opencode). | |
| 208 | const variantValues = | |
| 209 | rawVariantValues.length > 0 ? rawVariantValues : ["low", "medium", "high", "xhigh"]; | |
| 210 | const defaultVariant = inferDefaultVariant(input.providerID, variantValues); | |
| 211 | const variantOptions = variantValues.map((value) => | |
| 212 | defaultVariant === value | |
| 213 | ? { id: value, label: titleCaseSlug(value), isDefault: true as const } | |
| 214 | : { id: value, label: titleCaseSlug(value) }, | |
| 215 | ); | |
| 216 | const primaryAgents = input.agents.filter( | |
| 217 | (agent) => !agent.hidden && (agent.mode === "primary" || agent.mode === "all"), | |
| 218 | ); | |
| 219 | const defaultAgent = inferDefaultAgent(primaryAgents); | |
| 220 | const agentOptions = primaryAgents.map((agent) => | |
| 221 | defaultAgent === agent.name | |
| 222 | ? { id: agent.name, label: titleCaseSlug(agent.name), isDefault: true as const } | |
| 223 | : { id: agent.name, label: titleCaseSlug(agent.name) }, | |
| 224 | ); | |
| 225 | return createModelCapabilities({ | |
| 226 | optionDescriptors: [ | |
| 227 | ...(variantOptions.length > 0 | |
| 228 | ? [ | |
| 229 | { | |
| 230 | id: "variant", | |
| 231 | label: "Reasoning", | |
| 232 | type: "select" as const, | |
| 233 | options: variantOptions, | |
| 234 | ...(defaultVariant ? { currentValue: defaultVariant } : {}), | |
| 235 | }, | |
| 236 | ] | |
| 237 | : []), | |
| 238 | ...(agentOptions.length > 0 | |
| 239 | ? [ | |
| 240 | { | |
| 241 | id: "agent", | |
| 242 | label: "Agent", | |
| 243 | type: "select" as const, | |
| 244 | options: agentOptions, | |
| 245 | ...(defaultAgent ? { currentValue: defaultAgent } : {}), | |
| 246 | }, | |
| 247 | ] | |
| 248 | : []), | |
| 249 | ], | |
| 250 | }); | |
| 251 | } | |
| 252 | ||
| 253 | function flattenOpenCodeModels( | |
| 254 | input: OpenCodeRuntime.OpenCodeInventory, | |
| 255 | ): ReadonlyArray<ServerProviderModel> { | |
| 256 | const connected = new Set(input.providerList.connected); | |
| 257 | const models: Array<ServerProviderModel> = []; | |
| 258 | ||
| 259 | for (const provider of input.providerList.all) { | |
| 260 | if (!connected.has(provider.id)) { | |
| 261 | continue; | |
| 262 | } | |
| 263 | ||
| 264 | for (const model of Object.values(provider.models)) { | |
| 265 | const name = nonEmptyTrimmed(model.name); | |
| 266 | if (!name) { | |
| 267 | continue; | |
| 268 | } | |
| 269 | ||
| 270 | const subProvider = nonEmptyTrimmed(provider.name); | |
| 271 | models.push({ | |
| 272 | slug: `${provider.id}/${model.id}`, | |
| 273 | name, | |
| 274 | ...(subProvider ? { subProvider } : {}), | |
| 275 | isCustom: false, | |
| 276 | capabilities: openCodeCapabilitiesForModel({ | |
| 277 | providerID: provider.id, | |
| 278 | model, | |
| 279 | agents: input.agents, | |
| 280 | }), | |
| 281 | }); | |
| 282 | } | |
| 283 | } | |
| 284 | ||
| 285 | return models.toSorted((left, right) => left.name.localeCompare(right.name)); | |
| 286 | } | |
| 287 | ||
| 288 | function trimOptional(value: string | null | undefined): string | undefined { | |
| 289 | const trimmed = value?.trim(); | |
| 290 | return trimmed && trimmed.length > 0 ? trimmed : undefined; | |
| 291 | } | |
| 292 | ||
| 293 | export function openCodeSkillsToServerProviderSkills( | |
| 294 | input: OpenCodeRuntime.OpenCodeInventory["skills"] | undefined, | |
| 295 | ): ReadonlyArray<ServerProviderSkill> { | |
| 296 | const skills: ServerProviderSkill[] = []; | |
| 297 | for (const skill of input ?? []) { | |
| 298 | const name = trimOptional(skill.name); | |
| 299 | const path = trimOptional(skill.location); | |
| 300 | if (!name || !path) { | |
| 301 | continue; | |
| 302 | } | |
| 303 | ||
| 304 | const description = trimOptional(skill.description); | |
| 305 | skills.push({ | |
| 306 | name, | |
| 307 | path, | |
| 308 | enabled: true, | |
| 309 | ...(description ? { description, shortDescription: description } : {}), | |
| 310 | }); | |
| 311 | } | |
| 312 | ||
| 313 | return skills.toSorted((left, right) => left.name.localeCompare(right.name)); | |
| 314 | } | |
| 315 | ||
| 316 | export function openCodeCommandsToServerProviderSlashCommands( | |
| 317 | input: OpenCodeRuntime.OpenCodeInventory["commands"], | |
| 318 | ): ReadonlyArray<ServerProviderSlashCommand> { | |
| 319 | const commands: ServerProviderSlashCommand[] = [COMPACT_SLASH_COMMAND]; | |
| 320 | const names = new Set([COMPACT_SLASH_COMMAND.name]); | |
| 321 | for (const command of input ?? []) { | |
| 322 | const name = trimOptional(command.name); | |
| 323 | if (!name || names.has(name) || command.source === "skill") continue; | |
| 324 | names.add(name); | |
| 325 | const description = trimOptional(command.description); | |
| 326 | const hint = trimOptional(command.hints.join(" ")); | |
| 327 | commands.push({ | |
| 328 | name, | |
| 329 | ...(description ? { description } : {}), | |
| 330 | ...(hint ? { input: { hint } } : {}), | |
| 331 | }); | |
| 332 | } | |
| 333 | return commands; | |
| 334 | } | |
| 335 | ||
| 336 | export const makePendingOpenCodeProvider = ( | |
| 337 | openCodeSettings: OpenCodeSettings, | |
| 338 | ): Effect.Effect<ServerProviderDraft> => | |
| 339 | Effect.gen(function* () { | |
| 340 | const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); | |
| 341 | const models = providerModelsFromSettings( | |
| 342 | [], | |
| 343 | openCodeSettings.customModels, | |
| 344 | DEFAULT_OPENCODE_MODEL_CAPABILITIES, | |
| 345 | ); | |
| 346 | ||
| 347 | if (!openCodeSettings.enabled) { | |
| 348 | return buildServerProvider({ | |
| 349 | presentation: OPENCODE_PRESENTATION, | |
| 350 | enabled: false, | |
| 351 | checkedAt, | |
| 352 | models, | |
| 353 | probe: { | |
| 354 | installed: false, | |
| 355 | version: null, | |
| 356 | status: "warning", | |
| 357 | auth: { status: "unknown" }, | |
| 358 | message: | |
| 359 | openCodeSettings.serverUrl.trim().length > 0 | |
| 360 | ? "OpenCode is disabled in T3 Code settings. A server URL is configured." | |
| 361 | : "OpenCode is disabled in T3 Code settings.", | |
| 362 | }, | |
| 363 | }); | |
| 364 | } | |
| 365 | ||
| 366 | return buildServerProvider({ | |
| 367 | presentation: OPENCODE_PRESENTATION, | |
| 368 | enabled: true, | |
| 369 | checkedAt, | |
| 370 | models, | |
| 371 | probe: { | |
| 372 | installed: false, | |
| 373 | version: null, | |
| 374 | status: "warning", | |
| 375 | auth: { status: "unknown" }, | |
| 376 | message: "OpenCode provider status has not been checked in this session yet.", | |
| 377 | }, | |
| 378 | }); | |
| 379 | }); | |
| 380 | ||
| 381 | /** One model an OpenCode 2 server lists, as the status check reads it. */ | |
| 382 | export interface OpenCode2Model { | |
| 383 | readonly providerID: string; | |
| 384 | readonly id: string; | |
| 385 | readonly name: string; | |
| 386 | readonly variants: ReadonlyArray<{ readonly id: string }>; | |
| 387 | } | |
| 388 | ||
| 389 | /** | |
| 390 | * A server loads its catalog lazily and lists nothing for its first few | |
| 391 | * hundred milliseconds, and a local instance starts a fresh server for each | |
| 392 | * status check. So an empty list is read again for a while, and the last | |
| 393 | * non-empty list stands in if the catalog still has not loaded. | |
| 394 | */ | |
| 395 | export const makeOpenCode2ModelLoader = <E>( | |
| 396 | list: Effect.Effect<ReadonlyArray<OpenCode2Model>, E>, | |
| 397 | ) => | |
| 398 | Effect.sync(() => { | |
| 399 | let lastLoaded: ReadonlyArray<OpenCode2Model> = []; | |
| 400 | return list.pipe( | |
| 401 | Effect.repeat({ | |
| 402 | until: (models) => models.length > 0, | |
| 403 | schedule: Schedule.spaced("250 millis"), | |
| 404 | }), | |
| 405 | Effect.timeoutOption("5 seconds"), | |
| 406 | Effect.map((loaded) => { | |
| 407 | if (loaded._tag === "Some" && loaded.value.length > 0) lastLoaded = loaded.value; | |
| 408 | return lastLoaded; | |
| 409 | }), | |
| 410 | ); | |
| 411 | }); | |
| 412 | ||
| 413 | /** A workspace's skills and commands, as an OpenCode 2 server lists them for its directory. */ | |
| 414 | export interface OpenCode2Workspace { | |
| 415 | readonly skills: ReadonlyArray<{ | |
| 416 | readonly id: string; | |
| 417 | readonly name: string; | |
| 418 | readonly path: string; | |
| 419 | readonly description?: string | undefined; | |
| 420 | }>; | |
| 421 | readonly commands: ReadonlyArray<{ | |
| 422 | readonly name: string; | |
| 423 | readonly description?: string | undefined; | |
| 424 | }>; | |
| 425 | } | |
| 426 | ||
| 427 | /** | |
| 428 | * A server scans a directory it has not served yet in the background: its | |
| 429 | * first read lists no commands at all (not even the built-ins), and | |
| 430 | * `command.updated` plus `skill.updated` for the directory end the scan. A | |
| 431 | * directory it already serves lists everything at once. `scanned` must be | |
| 432 | * listening before `read` runs, since the read is what starts the scan. | |
| 433 | */ | |
| 434 | export const loadOpenCode2Workspace = <E>( | |
| 435 | read: Effect.Effect<OpenCode2Workspace, E>, | |
| 436 | scanned: Effect.Effect<void>, | |
| 437 | ) => | |
| 438 | Effect.gen(function* () { | |
| 439 | const first = yield* read; | |
| 440 | if (first.commands.length > 0) return first; | |
| 441 | yield* scanned.pipe(Effect.timeoutOption("10 seconds")); | |
| 442 | return yield* read; | |
| 443 | }); | |
| 444 | ||
| 445 | export function openCode2SkillsToServerProviderSkills( | |
| 446 | skills: OpenCode2Workspace["skills"], | |
| 447 | ): ReadonlyArray<ServerProviderSkill> { | |
| 448 | return skills | |
| 449 | .map((skill) => { | |
| 450 | const description = trimOptional(skill.description); | |
| 451 | return { | |
| 452 | name: skill.id, | |
| 453 | path: skill.path, | |
| 454 | enabled: true, | |
| 455 | ...(skill.name === skill.id ? {} : { displayName: skill.name }), | |
| 456 | ...(description ? { description, shortDescription: description } : {}), | |
| 457 | }; | |
| 458 | }) | |
| 459 | .toSorted((left, right) => left.name.localeCompare(right.name)); | |
| 460 | } | |
| 461 | ||
| 462 | export function openCode2CommandsToServerProviderSlashCommands( | |
| 463 | commands: OpenCode2Workspace["commands"], | |
| 464 | ): ReadonlyArray<ServerProviderSlashCommand> { | |
| 465 | const slashCommands: ServerProviderSlashCommand[] = [COMPACT_SLASH_COMMAND]; | |
| 466 | const names = new Set([COMPACT_SLASH_COMMAND.name]); | |
| 467 | for (const command of commands) { | |
| 468 | const name = trimOptional(command.name); | |
| 469 | if (!name || names.has(name)) continue; | |
| 470 | names.add(name); | |
| 471 | const description = trimOptional(command.description); | |
| 472 | slashCommands.push({ name, ...(description ? { description } : {}) }); | |
| 473 | } | |
| 474 | return slashCommands; | |
| 475 | } | |
| 476 | ||
| 477 | /** | |
| 478 | * Every mode maps onto OpenCode 2 session rules. Auto asks like Supervised: | |
| 479 | * OpenCode has no reviewer that approves routine actions. Plan mode is its | |
| 480 | * `plan` agent, so the composer's mode toggle drives it. | |
| 481 | */ | |
| 482 | const OPENCODE_2_PRESENTATION = { | |
| 483 | ...OPENCODE_PRESENTATION, | |
| 484 | showInteractionModeToggle: true, | |
| 485 | supportedRuntimeModes: ["approval-required", "auto-accept-edits", "auto", "full-access"], | |
| 486 | } as const; | |
| 487 | ||
| 488 | function openCode2ModelCapabilities(model: OpenCode2Model): ModelCapabilities { | |
| 489 | const variants = model.variants.map((variant) => variant.id); | |
| 490 | const defaultVariant = inferDefaultVariant(model.providerID, variants); | |
| 491 | return createModelCapabilities({ | |
| 492 | optionDescriptors: | |
| 493 | variants.length === 0 | |
| 494 | ? [] | |
| 495 | : [ | |
| 496 | { | |
| 497 | id: "variant", | |
| 498 | label: "Reasoning", | |
| 499 | type: "select", | |
| 500 | options: variants.map((id) => ({ | |
| 501 | id, | |
| 502 | label: titleCaseSlug(id), | |
| 503 | ...(id === defaultVariant ? { isDefault: true } : {}), | |
| 504 | })), | |
| 505 | ...(defaultVariant === undefined ? {} : { currentValue: defaultVariant }), | |
| 506 | }, | |
| 507 | ], | |
| 508 | }); | |
| 509 | } | |
| 510 | ||
| 511 | const checkOpenCode2 = Effect.fn("checkOpenCode2")(function* ( | |
| 512 | settings: OpenCodeSettings, | |
| 513 | version: string, | |
| 514 | checkedAt: string, | |
| 515 | loadModels: Effect.Effect<ReadonlyArray<OpenCode2Model>, OpenCodeRuntime.OpenCodeRuntimeError>, | |
| 516 | ) { | |
| 517 | const result = yield* Effect.exit(loadModels); | |
| 518 | const probe = (status: "ready" | "warning" | "error", message: string) => ({ | |
| 519 | installed: true, | |
| 520 | version, | |
| 521 | status, | |
| 522 | auth: { status: status === "ready" ? ("authenticated" as const) : ("unknown" as const) }, | |
| 523 | message, | |
| 524 | }); | |
| 525 | if (result._tag === "Failure") { | |
| 526 | // The detail can carry a response body; it stays in the log, not the status. | |
| 527 | yield* Effect.logWarning("OpenCode 2 model list failed", result.cause); | |
| 528 | return buildServerProvider({ | |
| 529 | presentation: OPENCODE_2_PRESENTATION, | |
| 530 | enabled: true, | |
| 531 | checkedAt, | |
| 532 | models: providerModelsFromSettings( | |
| 533 | [], | |
| 534 | settings.customModels, | |
| 535 | DEFAULT_OPENCODE_MODEL_CAPABILITIES, | |
| 536 | ), | |
| 537 | // Compaction does not depend on the model catalog. | |
| 538 | slashCommands: [COMPACT_SLASH_COMMAND], | |
| 539 | probe: probe("error", "OpenCode could not load its model list."), | |
| 540 | }); | |
| 541 | } | |
| 542 | const models = providerModelsFromSettings( | |
| 543 | result.value | |
| 544 | .map((model) => ({ | |
| 545 | slug: `${model.providerID}/${model.id}`, | |
| 546 | name: model.name, | |
| 547 | isCustom: false, | |
| 548 | capabilities: openCode2ModelCapabilities(model), | |
| 549 | })) | |
| 550 | .toSorted((left, right) => left.name.localeCompare(right.name)), | |
| 551 | settings.customModels, | |
| 552 | DEFAULT_OPENCODE_MODEL_CAPABILITIES, | |
| 553 | ); | |
| 554 | return buildServerProvider({ | |
| 555 | presentation: OPENCODE_2_PRESENTATION, | |
| 556 | enabled: true, | |
| 557 | checkedAt, | |
| 558 | models, | |
| 559 | // Every session can compact; a workspace snapshot adds that directory's commands. | |
| 560 | slashCommands: [COMPACT_SLASH_COMMAND], | |
| 561 | probe: | |
| 562 | result.value.length > 0 | |
| 563 | ? probe( | |
| 564 | "ready", | |
| 565 | `OpenCode ${version} lists ${result.value.length} model${result.value.length === 1 ? "" : "s"}.`, | |
| 566 | ) | |
| 567 | : probe("warning", "OpenCode 2 is running, but it did not list any models yet."), | |
| 568 | }); | |
| 569 | }); | |
| 570 | ||
| 571 | /** | |
| 572 | * `probeRuntime` is the driver's memoized version probe: `opencode --version` for a local binary, | |
| 573 | * the version endpoints for a configured server. The status check refreshes it, so an in-place | |
| 574 | * upgrade re-routes the instance. | |
| 575 | */ | |
| 576 | export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatus")(function* ( | |
| 577 | openCodeSettings: OpenCodeSettings, | |
| 578 | cwd: string, | |
| 579 | probeRuntime: Effect.Effect<ProbedOpenCode, OpenCodeRuntime.OpenCodeRuntimeError>, | |
| 580 | loadOpenCode2Models: Effect.Effect< | |
| 581 | ReadonlyArray<OpenCode2Model>, | |
| 582 | OpenCodeRuntime.OpenCodeRuntimeError | |
| 583 | >, | |
| 584 | ): Effect.fn.Return< | |
| 585 | ServerProviderDraft, | |
| 586 | never, | |
| 587 | OpenCodeRuntime.OpenCodeRuntime | OpenCodeServerOwner.OpenCodeServerOwner | |
| 588 | > { | |
| 589 | const openCodeRuntime = yield* OpenCodeRuntime.OpenCodeRuntime; | |
| 590 | const serverOwner = yield* OpenCodeServerOwner.OpenCodeServerOwner; | |
| 591 | const checkedAt = DateTime.formatIso(yield* DateTime.now); | |
| 592 | const customModels = openCodeSettings.customModels; | |
| 593 | const isExternalServer = openCodeSettings.serverUrl.trim().length > 0; | |
| 594 | ||
| 595 | const fallback = ( | |
| 596 | cause: unknown, | |
| 597 | version: string | null = null, | |
| 598 | phase: "version" | "inventory" = "version", | |
| 599 | ) => { | |
| 600 | const failure = formatOpenCodeProbeError({ | |
| 601 | cause, | |
| 602 | isExternalServer, | |
| 603 | phase, | |
| 604 | serverUrl: openCodeSettings.serverUrl, | |
| 605 | }); | |
| 606 | return buildServerProvider({ | |
| 607 | presentation: OPENCODE_PRESENTATION, | |
| 608 | enabled: openCodeSettings.enabled, | |
| 609 | checkedAt, | |
| 610 | models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES), | |
| 611 | probe: { | |
| 612 | installed: failure.installed, | |
| 613 | version, | |
| 614 | status: "error", | |
| 615 | auth: { status: "unknown" }, | |
| 616 | message: failure.message, | |
| 617 | }, | |
| 618 | }); | |
| 619 | }; | |
| 620 | ||
| 621 | if (!openCodeSettings.enabled) { | |
| 622 | return buildServerProvider({ | |
| 623 | presentation: OPENCODE_PRESENTATION, | |
| 624 | enabled: false, | |
| 625 | checkedAt, | |
| 626 | models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES), | |
| 627 | probe: { | |
| 628 | installed: false, | |
| 629 | version: null, | |
| 630 | status: "warning", | |
| 631 | auth: { status: "unknown" }, | |
| 632 | message: isExternalServer | |
| 633 | ? "OpenCode is disabled in T3 Code settings. A server URL is configured." | |
| 634 | : "OpenCode is disabled in T3 Code settings.", | |
| 635 | }, | |
| 636 | }); | |
| 637 | } | |
| 638 | ||
| 639 | const probedExit = yield* Effect.exit( | |
| 640 | probeRuntime.pipe( | |
| 641 | Effect.mapError( | |
| 642 | (cause) => | |
| 643 | new OpenCodeProbeError({ | |
| 644 | cause, | |
| 645 | detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause), | |
| 646 | }), | |
| 647 | ), | |
| 648 | ), | |
| 649 | ); | |
| 650 | if (probedExit._tag === "Failure") return fallback(Cause.squash(probedExit.cause)); | |
| 651 | const probed = probedExit.value; | |
| 652 | if (probed.generation === "v2") { | |
| 653 | return yield* checkOpenCode2(openCodeSettings, probed.version, checkedAt, loadOpenCode2Models); | |
| 654 | } | |
| 655 | let version: string | null = probed.version; | |
| 656 | if (compareSemverVersions(probed.version, OpenCodeRuntime.MINIMUM_OPENCODE_VERSION) < 0) { | |
| 657 | return buildServerProvider({ | |
| 658 | presentation: OPENCODE_PRESENTATION, | |
| 659 | enabled: openCodeSettings.enabled, | |
| 660 | checkedAt, | |
| 661 | models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES), | |
| 662 | probe: { | |
| 663 | installed: true, | |
| 664 | version: probed.version, | |
| 665 | status: "error", | |
| 666 | auth: { status: "unknown" }, | |
| 667 | message: `OpenCode v${probed.version} is too old. Upgrade to v${OpenCodeRuntime.MINIMUM_OPENCODE_VERSION} or newer.`, | |
| 668 | }, | |
| 669 | }); | |
| 670 | } | |
| 671 | ||
| 672 | const loadInventory = (server: { | |
| 673 | readonly url: string; | |
| 674 | readonly serverPassword?: string; | |
| 675 | readonly version: string; | |
| 676 | }) => | |
| 677 | openCodeRuntime | |
| 678 | .loadOpenCodeInventory( | |
| 679 | openCodeRuntime.createOpenCodeSdkClient({ | |
| 680 | baseUrl: server.url, | |
| 681 | directory: cwd, | |
| 682 | ...(server.serverPassword !== undefined ? { serverPassword: server.serverPassword } : {}), | |
| 683 | }), | |
| 684 | ) | |
| 685 | .pipe(Effect.map((inventory) => ({ inventory, version: server.version }))); | |
| 686 | const inventoryEffect = isExternalServer | |
| 687 | ? openCodeRuntime | |
| 688 | .connectToOpenCodeServer({ | |
| 689 | binaryPath: openCodeSettings.binaryPath, | |
| 690 | directory: cwd, | |
| 691 | serverUrl: openCodeSettings.serverUrl, | |
| 692 | ...(openCodeSettings.serverPassword | |
| 693 | ? { serverPassword: openCodeSettings.serverPassword } | |
| 694 | : {}), | |
| 695 | }) | |
| 696 | .pipe(Effect.flatMap(loadInventory), Effect.scoped) | |
| 697 | : serverOwner.withServer(loadInventory); | |
| 698 | const inventoryExit = yield* Effect.exit( | |
| 699 | inventoryEffect.pipe( | |
| 700 | Effect.mapError( | |
| 701 | (cause) => | |
| 702 | new OpenCodeProbeError({ | |
| 703 | cause, | |
| 704 | detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause), | |
| 705 | }), | |
| 706 | ), | |
| 707 | ), | |
| 708 | ); | |
| 709 | if (inventoryExit._tag === "Failure") { | |
| 710 | return fallback(Cause.squash(inventoryExit.cause), version, "inventory"); | |
| 711 | } | |
| 712 | ||
| 713 | version = inventoryExit.value.version; | |
| 714 | ||
| 715 | const models = providerModelsFromSettings( | |
| 716 | flattenOpenCodeModels(inventoryExit.value.inventory), | |
| 717 | customModels, | |
| 718 | DEFAULT_OPENCODE_MODEL_CAPABILITIES, | |
| 719 | ); | |
| 720 | const skills = openCodeSkillsToServerProviderSkills(inventoryExit.value.inventory.skills); | |
| 721 | const connectedCount = inventoryExit.value.inventory.providerList.connected.length; | |
| 722 | return buildServerProvider({ | |
| 723 | presentation: OPENCODE_PRESENTATION, | |
| 724 | enabled: true, | |
| 725 | checkedAt, | |
| 726 | models, | |
| 727 | skills, | |
| 728 | slashCommands: openCodeCommandsToServerProviderSlashCommands( | |
| 729 | inventoryExit.value.inventory.commands, | |
| 730 | ), | |
| 731 | probe: { | |
| 732 | installed: true, | |
| 733 | version, | |
| 734 | status: connectedCount > 0 ? "ready" : "warning", | |
| 735 | auth: { | |
| 736 | status: connectedCount > 0 ? "authenticated" : "unknown", | |
| 737 | type: "opencode", | |
| 738 | }, | |
| 739 | message: | |
| 740 | connectedCount > 0 | |
| 741 | ? `${connectedCount} upstream provider${connectedCount === 1 ? "" : "s"} connected through ${isExternalServer ? "the configured OpenCode server" : "OpenCode"}.` | |
| 742 | : isExternalServer | |
| 743 | ? "Connected to the configured OpenCode server, but it did not report any connected upstream providers." | |
| 744 | : "OpenCode is available, but it did not report any connected upstream providers.", | |
| 745 | }, | |
| 746 | }); | |
| 747 | }); |
packages/provider-opencode/src/server/textGeneration.test.ts created+609| ... | ... | @@ -0,0 +1,609 @@ |
| 1 | import { ProviderInstanceId, TextGenerationError } from "@t3tools/contracts"; | |
| 2 | import { OpenCodeSettings } from "../settings.ts"; | |
| 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 4 | import { it } from "@effect/vitest"; | |
| 5 | import * as Duration from "effect/Duration"; | |
| 6 | import * as Effect from "effect/Effect"; | |
| 7 | import * as Layer from "effect/Layer"; | |
| 8 | import * as Schema from "effect/Schema"; | |
| 9 | import * as TestClock from "effect/testing/TestClock"; | |
| 10 | import * as NetService from "@t3tools/shared/Net"; | |
| 11 | import { beforeEach, expect } from "vite-plus/test"; | |
| 12 | ||
| 13 | import { layerTestProviderHost } from "@t3tools/provider-testing/host"; | |
| 14 | import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; | |
| 15 | import * as OpenCodeServerOwner from "./OpenCodeServerOwner.ts"; | |
| 16 | import * as OpenCodeTextGeneration from "./textGeneration.ts"; | |
| 17 | import type { ProviderTextGeneration } from "@t3tools/provider-core/server/textGeneration"; | |
| 18 | ||
| 19 | const runtimeMock = { | |
| 20 | state: { | |
| 21 | startCalls: [] as string[], | |
| 22 | promptUrls: [] as string[], | |
| 23 | promptParts: [] as ReadonlyArray<unknown>[], | |
| 24 | authHeaders: [] as Array<string | null>, | |
| 25 | closeCalls: [] as string[], | |
| 26 | sessionCreateCalls: 0, | |
| 27 | connectionError: undefined as Error | undefined, | |
| 28 | sessionCreateError: undefined as unknown, | |
| 29 | sessionResult: undefined as { data?: { id: string } } | undefined, | |
| 30 | promptRequestError: undefined as unknown, | |
| 31 | promptResult: undefined as | |
| 32 | | { data?: { info?: { error?: unknown }; parts?: Array<unknown> } } | |
| 33 | | undefined, | |
| 34 | }, | |
| 35 | reset() { | |
| 36 | this.state.startCalls.length = 0; | |
| 37 | this.state.promptUrls.length = 0; | |
| 38 | this.state.promptParts.length = 0; | |
| 39 | this.state.authHeaders.length = 0; | |
| 40 | this.state.closeCalls.length = 0; | |
| 41 | this.state.sessionCreateCalls = 0; | |
| 42 | this.state.connectionError = undefined; | |
| 43 | this.state.sessionCreateError = undefined; | |
| 44 | this.state.sessionResult = undefined; | |
| 45 | this.state.promptRequestError = undefined; | |
| 46 | this.state.promptResult = undefined; | |
| 47 | }, | |
| 48 | }; | |
| 49 | ||
| 50 | const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = { | |
| 51 | startOpenCodeServerProcess: ({ binaryPath, serverPassword, environment }) => | |
| 52 | Effect.gen(function* () { | |
| 53 | const index = runtimeMock.state.startCalls.length + 1; | |
| 54 | const url = `http://127.0.0.1:${4_300 + index}`; | |
| 55 | runtimeMock.state.startCalls.push(binaryPath); | |
| 56 | // The production runtime binds server lifetime to the caller's scope. | |
| 57 | // Mirror that here so the closeCalls probe observes scope close. | |
| 58 | yield* Effect.addFinalizer(() => | |
| 59 | Effect.sync(() => { | |
| 60 | runtimeMock.state.closeCalls.push(url); | |
| 61 | }), | |
| 62 | ); | |
| 63 | const effectiveServerPassword = OpenCodeRuntime.resolveOpenCodeServerPassword({ | |
| 64 | external: false, | |
| 65 | ...(serverPassword !== undefined ? { serverPassword } : {}), | |
| 66 | ...(environment !== undefined ? { environment } : {}), | |
| 67 | }); | |
| 68 | return { | |
| 69 | url, | |
| 70 | ...(effectiveServerPassword !== undefined | |
| 71 | ? { serverPassword: effectiveServerPassword } | |
| 72 | : {}), | |
| 73 | version: "1.14.19", | |
| 74 | isRunning: Effect.succeed(true), | |
| 75 | exitCode: Effect.never, | |
| 76 | }; | |
| 77 | }), | |
| 78 | connectToOpenCodeServer: ({ serverUrl, serverPassword }) => | |
| 79 | runtimeMock.state.connectionError | |
| 80 | ? Effect.fail( | |
| 81 | new OpenCodeRuntime.OpenCodeRuntimeError({ | |
| 82 | operation: "global.health", | |
| 83 | detail: runtimeMock.state.connectionError.message, | |
| 84 | cause: runtimeMock.state.connectionError, | |
| 85 | }), | |
| 86 | ) | |
| 87 | : Effect.succeed({ | |
| 88 | url: serverUrl ?? "http://127.0.0.1:4301", | |
| 89 | ...(serverPassword ? { serverPassword } : {}), | |
| 90 | version: "1.14.19", | |
| 91 | exitCode: null, | |
| 92 | external: Boolean(serverUrl), | |
| 93 | }), | |
| 94 | runOpenCodeCommand: () => Effect.succeed({ stdout: "", stderr: "", code: 0 }), | |
| 95 | createOpenCodeSdkClient: ({ baseUrl, serverPassword }) => | |
| 96 | ({ | |
| 97 | session: { | |
| 98 | create: async () => { | |
| 99 | runtimeMock.state.sessionCreateCalls += 1; | |
| 100 | if (runtimeMock.state.sessionCreateError !== undefined) { | |
| 101 | throw runtimeMock.state.sessionCreateError; | |
| 102 | } | |
| 103 | return runtimeMock.state.sessionResult ?? { data: { id: `${baseUrl}/session` } }; | |
| 104 | }, | |
| 105 | prompt: async (input: { readonly parts: ReadonlyArray<unknown> }) => { | |
| 106 | runtimeMock.state.promptUrls.push(baseUrl); | |
| 107 | runtimeMock.state.promptParts.push(input.parts); | |
| 108 | runtimeMock.state.authHeaders.push( | |
| 109 | serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null, | |
| 110 | ); | |
| 111 | if (runtimeMock.state.promptRequestError !== undefined) { | |
| 112 | throw runtimeMock.state.promptRequestError; | |
| 113 | } | |
| 114 | return ( | |
| 115 | runtimeMock.state.promptResult ?? { | |
| 116 | data: { | |
| 117 | parts: [ | |
| 118 | { | |
| 119 | type: "text", | |
| 120 | text: JSON.stringify({ | |
| 121 | subject: "Improve OpenCode reuse", | |
| 122 | body: "Reuse one server for the full action.", | |
| 123 | }), | |
| 124 | }, | |
| 125 | ], | |
| 126 | }, | |
| 127 | } | |
| 128 | ); | |
| 129 | }, | |
| 130 | }, | |
| 131 | }) as unknown as ReturnType<OpenCodeRuntime.OpenCodeRuntimeShape["createOpenCodeSdkClient"]>, | |
| 132 | loadOpenCodeInventory: () => | |
| 133 | Effect.fail( | |
| 134 | new OpenCodeRuntime.OpenCodeRuntimeError({ | |
| 135 | operation: "loadOpenCodeInventory", | |
| 136 | detail: "OpenCodeRuntimeTestDouble.loadOpenCodeInventory not used in this test", | |
| 137 | cause: null, | |
| 138 | }), | |
| 139 | ), | |
| 140 | loadOpenCodeSkills: () => Effect.succeed([]), | |
| 141 | loadInventoryFromCli: () => | |
| 142 | Effect.fail( | |
| 143 | new OpenCodeRuntime.OpenCodeRuntimeError({ | |
| 144 | operation: "loadInventoryFromCli", | |
| 145 | detail: "OpenCodeRuntimeTestDouble.loadInventoryFromCli not used in this test", | |
| 146 | cause: null, | |
| 147 | }), | |
| 148 | ), | |
| 149 | loadSkillsFromCli: () => Effect.succeed([]), | |
| 150 | }; | |
| 151 | ||
| 152 | const DEFAULT_TEST_MODEL_SELECTION = { | |
| 153 | instanceId: ProviderInstanceId.make("opencode"), | |
| 154 | model: "openai/gpt-5", | |
| 155 | }; | |
| 156 | const DEFAULT_COMMIT_MESSAGE_INPUT = { | |
| 157 | cwd: process.cwd(), | |
| 158 | branch: "feature/opencode-reuse", | |
| 159 | stagedSummary: "M README.md", | |
| 160 | stagedPatch: "diff --git a/README.md b/README.md", | |
| 161 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | |
| 162 | }; | |
| 163 | ||
| 164 | const OPENCODE_TEXT_GENERATION_IDLE_TTL_MS = 30_000; | |
| 165 | ||
| 166 | const layerOpenCodeTextGenerationTest = Layer.succeed( | |
| 167 | OpenCodeRuntime.OpenCodeRuntime, | |
| 168 | OpenCodeRuntimeTestDouble, | |
| 169 | ).pipe( | |
| 170 | Layer.provideMerge(layerTestProviderHost()), | |
| 171 | Layer.provideMerge(NetService.layer), | |
| 172 | Layer.provideMerge(NodeServices.layer), | |
| 173 | ); | |
| 174 | ||
| 175 | const layerOpenCodeTextGenerationExistingServerTest = Layer.succeed( | |
| 176 | OpenCodeRuntime.OpenCodeRuntime, | |
| 177 | OpenCodeRuntimeTestDouble, | |
| 178 | ).pipe( | |
| 179 | Layer.provideMerge(layerTestProviderHost()), | |
| 180 | Layer.provideMerge(NetService.layer), | |
| 181 | Layer.provideMerge(NodeServices.layer), | |
| 182 | ); | |
| 183 | ||
| 184 | const DEFAULT_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({ | |
| 185 | binaryPath: "fake-opencode", | |
| 186 | }); | |
| 187 | const LOCAL_AUTH_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({ | |
| 188 | binaryPath: "fake-opencode", | |
| 189 | serverPassword: "secret-password", | |
| 190 | }); | |
| 191 | const EXISTING_SERVER_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({ | |
| 192 | binaryPath: "fake-opencode", | |
| 193 | serverUrl: "http://127.0.0.1:9999", | |
| 194 | serverPassword: "secret-password", | |
| 195 | }); | |
| 196 | const EXTERNAL_SERVER_WITHOUT_AUTH_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({ | |
| 197 | binaryPath: "fake-opencode", | |
| 198 | serverUrl: "http://127.0.0.1:9999", | |
| 199 | }); | |
| 200 | ||
| 201 | function withOpenCodeTextGeneration<A, E, R>( | |
| 202 | settings: OpenCodeSettings, | |
| 203 | effectFn: (textGeneration: ProviderTextGeneration) => Effect.Effect<A, E, R>, | |
| 204 | environment?: NodeJS.ProcessEnv, | |
| 205 | ) { | |
| 206 | return Effect.gen(function* () { | |
| 207 | const serverOwner = yield* OpenCodeServerOwner.make({ | |
| 208 | binaryPath: settings.binaryPath, | |
| 209 | directory: process.cwd(), | |
| 210 | ...(settings.serverPassword ? { serverPassword: settings.serverPassword } : {}), | |
| 211 | ...(environment ? { environment } : {}), | |
| 212 | }); | |
| 213 | const textGeneration = yield* OpenCodeTextGeneration.makeOpenCodeTextGeneration(settings).pipe( | |
| 214 | Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner), | |
| 215 | ); | |
| 216 | return yield* effectFn(textGeneration); | |
| 217 | }).pipe(Effect.scoped); | |
| 218 | } | |
| 219 | ||
| 220 | beforeEach(() => { | |
| 221 | runtimeMock.reset(); | |
| 222 | }); | |
| 223 | ||
| 224 | const advanceIdleClock = Effect.gen(function* () { | |
| 225 | yield* Effect.yieldNow; | |
| 226 | yield* TestClock.adjust(Duration.millis(OPENCODE_TEXT_GENERATION_IDLE_TTL_MS + 1)); | |
| 227 | yield* Effect.yieldNow; | |
| 228 | }); | |
| 229 | ||
| 230 | it.layer(layerOpenCodeTextGenerationTest)("OpenCodeTextGeneration", (it) => { | |
| 231 | it.effect("excludes generic files from thread title generation", () => | |
| 232 | withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => | |
| 233 | Effect.gen(function* () { | |
| 234 | runtimeMock.state.promptResult = { | |
| 235 | data: { | |
| 236 | parts: [{ type: "text", text: '{"title":"Review uploaded report"}' }], | |
| 237 | }, | |
| 238 | }; | |
| 239 | ||
| 240 | yield* textGeneration.generateThreadTitle({ | |
| 241 | cwd: process.cwd(), | |
| 242 | message: "Review these attachments.", | |
| 243 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | |
| 244 | attachments: [ | |
| 245 | { | |
| 246 | type: "image", | |
| 247 | id: "thread-image-attachment", | |
| 248 | name: "screenshot.png", | |
| 249 | mimeType: "image/png", | |
| 250 | sizeBytes: 3, | |
| 251 | }, | |
| 252 | { | |
| 253 | type: "file", | |
| 254 | id: "thread-report-attachment-pdf", | |
| 255 | name: "report.pdf", | |
| 256 | mimeType: "application/pdf", | |
| 257 | sizeBytes: 42, | |
| 258 | }, | |
| 259 | ], | |
| 260 | }); | |
| 261 | ||
| 262 | expect(runtimeMock.state.promptParts[0]).toEqual([ | |
| 263 | expect.objectContaining({ type: "text" }), | |
| 264 | expect.objectContaining({ type: "file", filename: "screenshot.png" }), | |
| 265 | ]); | |
| 266 | }), | |
| 267 | ), | |
| 268 | ); | |
| 269 | ||
| 270 | it.effect("passes configured authentication to a locally spawned server", () => | |
| 271 | withOpenCodeTextGeneration(LOCAL_AUTH_OPENCODE_SETTINGS, (textGeneration) => | |
| 272 | Effect.gen(function* () { | |
| 273 | yield* textGeneration.generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT); | |
| 274 | ||
| 275 | expect(runtimeMock.state.startCalls).toEqual(["fake-opencode"]); | |
| 276 | expect(runtimeMock.state.authHeaders).toEqual([ | |
| 277 | `Basic ${btoa("opencode:secret-password")}`, | |
| 278 | ]); | |
| 279 | }), | |
| 280 | ), | |
| 281 | ); | |
| 282 | ||
| 283 | it.effect("uses an environment-only password for a locally spawned server", () => | |
| 284 | withOpenCodeTextGeneration( | |
| 285 | DEFAULT_OPENCODE_SETTINGS, | |
| 286 | (textGeneration) => | |
| 287 | Effect.gen(function* () { | |
| 288 | yield* textGeneration.generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT); | |
| 289 | ||
| 290 | expect(runtimeMock.state.authHeaders).toEqual([ | |
| 291 | `Basic ${btoa("opencode:environment-password")}`, | |
| 292 | ]); | |
| 293 | }), | |
| 294 | { OPENCODE_SERVER_PASSWORD: "environment-password" }, | |
| 295 | ), | |
| 296 | ); | |
| 297 | ||
| 298 | it.effect("uses settings auth when the local environment password differs", () => | |
| 299 | withOpenCodeTextGeneration( | |
| 300 | LOCAL_AUTH_OPENCODE_SETTINGS, | |
| 301 | (textGeneration) => | |
| 302 | Effect.gen(function* () { | |
| 303 | yield* textGeneration.generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT); | |
| 304 | ||
| 305 | expect(runtimeMock.state.authHeaders).toEqual([ | |
| 306 | `Basic ${btoa("opencode:secret-password")}`, | |
| 307 | ]); | |
| 308 | }), | |
| 309 | { OPENCODE_SERVER_PASSWORD: "environment-password" }, | |
| 310 | ), | |
| 311 | ); | |
| 312 | ||
| 313 | it.effect("reuses a warm server across back-to-back requests and closes it after idling", () => | |
| 314 | withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => | |
| 315 | Effect.gen(function* () { | |
| 316 | yield* textGeneration.generateCommitMessage({ | |
| 317 | cwd: process.cwd(), | |
| 318 | branch: "feature/opencode-reuse", | |
| 319 | stagedSummary: "M README.md", | |
| 320 | stagedPatch: "diff --git a/README.md b/README.md", | |
| 321 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | |
| 322 | }); | |
| 323 | yield* textGeneration.generateCommitMessage({ | |
| 324 | cwd: process.cwd(), | |
| 325 | branch: "feature/opencode-reuse", | |
| 326 | stagedSummary: "M README.md", | |
| 327 | stagedPatch: "diff --git a/README.md b/README.md", | |
| 328 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | |
| 329 | }); | |
| 330 | ||
| 331 | expect(runtimeMock.state.startCalls).toEqual(["fake-opencode"]); | |
| 332 | expect(runtimeMock.state.promptUrls).toEqual([ | |
| 333 | "http://127.0.0.1:4301", | |
| 334 | "http://127.0.0.1:4301", | |
| 335 | ]); | |
| 336 | expect(runtimeMock.state.closeCalls).toEqual([]); | |
| 337 | ||
| 338 | yield* advanceIdleClock; | |
| 339 | ||
| 340 | expect(runtimeMock.state.closeCalls).toEqual(["http://127.0.0.1:4301"]); | |
| 341 | }), | |
| 342 | ).pipe(Effect.provide(TestClock.layer())), | |
| 343 | ); | |
| 344 | ||
| 345 | it.effect("starts a new server after the warm server idles out", () => | |
| 346 | withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => | |
| 347 | Effect.gen(function* () { | |
| 348 | yield* textGeneration.generateCommitMessage({ | |
| 349 | cwd: process.cwd(), | |
| 350 | branch: "feature/opencode-reuse", | |
| 351 | stagedSummary: "M README.md", | |
| 352 | stagedPatch: "diff --git a/README.md b/README.md", | |
| 353 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | |
| 354 | }); | |
| 355 | ||
| 356 | yield* advanceIdleClock; | |
| 357 | ||
| 358 | yield* textGeneration.generateCommitMessage({ | |
| 359 | cwd: process.cwd(), | |
| 360 | branch: "feature/opencode-reuse", | |
| 361 | stagedSummary: "M README.md", | |
| 362 | stagedPatch: "diff --git a/README.md b/README.md", | |
| 363 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | |
| 364 | }); | |
| 365 | ||
| 366 | expect(runtimeMock.state.startCalls).toEqual(["fake-opencode", "fake-opencode"]); | |
| 367 | expect(runtimeMock.state.promptUrls).toEqual([ | |
| 368 | "http://127.0.0.1:4301", | |
| 369 | "http://127.0.0.1:4302", | |
| 370 | ]); | |
| 371 | expect(runtimeMock.state.closeCalls).toEqual(["http://127.0.0.1:4301"]); | |
| 372 | }), | |
| 373 | ).pipe(Effect.provide(TestClock.layer())), | |
| 374 | ); | |
| 375 | ||
| 376 | it.effect("preserves the SDK cause when session creation fails", () => | |
| 377 | withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => | |
| 378 | Effect.gen(function* () { | |
| 379 | const sdkCause = new Error("session endpoint unavailable"); | |
| 380 | runtimeMock.state.sessionCreateError = sdkCause; | |
| 381 | ||
| 382 | const error = yield* textGeneration | |
| 383 | .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) | |
| 384 | .pipe(Effect.flip); | |
| 385 | ||
| 386 | expect(error).toBeInstanceOf(TextGenerationError); | |
| 387 | expect(error.message).toContain("OpenCode session.create request failed."); | |
| 388 | expect(error.cause).toMatchObject({ | |
| 389 | _tag: "OpenCodeTextGenerationSessionRequestError", | |
| 390 | operation: "generateCommitMessage", | |
| 391 | cwd: process.cwd(), | |
| 392 | cause: sdkCause, | |
| 393 | }); | |
| 394 | expect((error.cause as { cause: unknown }).cause).toBe(sdkCause); | |
| 395 | }), | |
| 396 | ), | |
| 397 | ); | |
| 398 | ||
| 399 | it.effect("reports a missing session payload without manufacturing a cause", () => | |
| 400 | withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => | |
| 401 | Effect.gen(function* () { | |
| 402 | runtimeMock.state.sessionResult = {}; | |
| 403 | ||
| 404 | const error = yield* textGeneration | |
| 405 | .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) | |
| 406 | .pipe(Effect.flip); | |
| 407 | ||
| 408 | expect(error.message).toContain("OpenCode session.create returned no session payload."); | |
| 409 | expect(error.cause).toMatchObject({ | |
| 410 | _tag: "OpenCodeTextGenerationSessionPayloadError", | |
| 411 | operation: "generateCommitMessage", | |
| 412 | cwd: process.cwd(), | |
| 413 | }); | |
| 414 | expect(error.cause).not.toHaveProperty("cause"); | |
| 415 | }), | |
| 416 | ), | |
| 417 | ); | |
| 418 | ||
| 419 | it.effect("preserves the SDK cause and request context when prompting fails", () => | |
| 420 | withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => | |
| 421 | Effect.gen(function* () { | |
| 422 | const sdkCause = new Error("prompt endpoint unavailable"); | |
| 423 | runtimeMock.state.promptRequestError = sdkCause; | |
| 424 | ||
| 425 | const error = yield* textGeneration | |
| 426 | .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) | |
| 427 | .pipe(Effect.flip); | |
| 428 | ||
| 429 | expect(error.message).toContain("OpenCode session.prompt request failed."); | |
| 430 | expect(error.cause).toMatchObject({ | |
| 431 | _tag: "OpenCodeTextGenerationPromptRequestError", | |
| 432 | operation: "generateCommitMessage", | |
| 433 | cwd: process.cwd(), | |
| 434 | sessionId: "http://127.0.0.1:4301/session", | |
| 435 | providerId: "openai", | |
| 436 | modelId: "gpt-5", | |
| 437 | cause: sdkCause, | |
| 438 | }); | |
| 439 | expect((error.cause as { cause: unknown }).cause).toBe(sdkCause); | |
| 440 | }), | |
| 441 | ), | |
| 442 | ); | |
| 443 | ||
| 444 | it.effect("returns a typed empty-output error for malformed and blank response parts", () => | |
| 445 | withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => | |
| 446 | Effect.gen(function* () { | |
| 447 | runtimeMock.state.promptResult = { | |
| 448 | data: { | |
| 449 | parts: [null, { type: "tool" }, { type: "text", text: " " }], | |
| 450 | }, | |
| 451 | }; | |
| 452 | ||
| 453 | const error = yield* textGeneration | |
| 454 | .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) | |
| 455 | .pipe(Effect.flip); | |
| 456 | ||
| 457 | expect(error.message).toContain("OpenCode returned empty output."); | |
| 458 | expect(error.cause).toMatchObject({ | |
| 459 | _tag: "OpenCodeTextGenerationEmptyOutputError", | |
| 460 | operation: "generateCommitMessage", | |
| 461 | cwd: process.cwd(), | |
| 462 | sessionId: "http://127.0.0.1:4301/session", | |
| 463 | providerId: "openai", | |
| 464 | modelId: "gpt-5", | |
| 465 | responsePartCount: 3, | |
| 466 | textPartCount: 1, | |
| 467 | }); | |
| 468 | expect(error.cause).not.toHaveProperty("cause"); | |
| 469 | }), | |
| 470 | ), | |
| 471 | ); | |
| 472 | ||
| 473 | it.effect("parses JSON returned as plain text output", () => | |
| 474 | withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => | |
| 475 | Effect.gen(function* () { | |
| 476 | runtimeMock.state.promptResult = { | |
| 477 | data: { | |
| 478 | parts: [ | |
| 479 | { | |
| 480 | type: "text", | |
| 481 | text: 'Here is the result:\n{"subject":"Tighten OpenCode parsing","body":"Handle JSON text output locally."}', | |
| 482 | }, | |
| 483 | ], | |
| 484 | }, | |
| 485 | }; | |
| 486 | ||
| 487 | const result = yield* textGeneration.generateCommitMessage({ | |
| 488 | cwd: process.cwd(), | |
| 489 | branch: "feature/opencode-reuse", | |
| 490 | stagedSummary: "M README.md", | |
| 491 | stagedPatch: "diff --git a/README.md b/README.md", | |
| 492 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | |
| 493 | }); | |
| 494 | ||
| 495 | expect(result).toEqual({ | |
| 496 | subject: "Tighten OpenCode parsing", | |
| 497 | body: "Handle JSON text output locally.", | |
| 498 | }); | |
| 499 | }), | |
| 500 | ), | |
| 501 | ); | |
| 502 | ||
| 503 | it.effect("surfaces the upstream OpenCode structured-output error message", () => | |
| 504 | withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => | |
| 505 | Effect.gen(function* () { | |
| 506 | runtimeMock.state.promptResult = { | |
| 507 | data: { | |
| 508 | info: { | |
| 509 | error: { | |
| 510 | name: "StructuredOutputError", | |
| 511 | data: { | |
| 512 | message: "Model did not produce structured output", | |
| 513 | retries: 2, | |
| 514 | }, | |
| 515 | }, | |
| 516 | }, | |
| 517 | }, | |
| 518 | }; | |
| 519 | ||
| 520 | const error = yield* textGeneration | |
| 521 | .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) | |
| 522 | .pipe(Effect.flip); | |
| 523 | ||
| 524 | expect(error.message).toContain("Model did not produce structured output"); | |
| 525 | expect(error.cause).toMatchObject({ | |
| 526 | _tag: "OpenCodeTextGenerationPromptResponseError", | |
| 527 | operation: "generateCommitMessage", | |
| 528 | cwd: process.cwd(), | |
| 529 | sessionId: "http://127.0.0.1:4301/session", | |
| 530 | providerId: "openai", | |
| 531 | modelId: "gpt-5", | |
| 532 | providerErrorName: "StructuredOutputError", | |
| 533 | providerMessage: "Model did not produce structured output", | |
| 534 | }); | |
| 535 | expect(error.cause).not.toHaveProperty("cause"); | |
| 536 | }), | |
| 537 | ), | |
| 538 | ); | |
| 539 | }); | |
| 540 | ||
| 541 | it.layer(layerOpenCodeTextGenerationExistingServerTest)( | |
| 542 | "OpenCodeTextGeneration with configured server URL", | |
| 543 | (it) => { | |
| 544 | it.effect("does not send a local environment password to a configured server", () => | |
| 545 | withOpenCodeTextGeneration( | |
| 546 | EXTERNAL_SERVER_WITHOUT_AUTH_OPENCODE_SETTINGS, | |
| 547 | (textGeneration) => | |
| 548 | Effect.gen(function* () { | |
| 549 | yield* textGeneration.generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT); | |
| 550 | expect(runtimeMock.state.authHeaders).toEqual([null]); | |
| 551 | }), | |
| 552 | { OPENCODE_SERVER_PASSWORD: "local-secret" }, | |
| 553 | ), | |
| 554 | ); | |
| 555 | ||
| 556 | it.effect("does not create a session when the server version is unsupported", () => | |
| 557 | withOpenCodeTextGeneration(EXISTING_SERVER_OPENCODE_SETTINGS, (textGeneration) => | |
| 558 | Effect.gen(function* () { | |
| 559 | runtimeMock.state.connectionError = new Error( | |
| 560 | "OpenCode v1.14.18 is too old. Upgrade to v1.14.19 or newer.", | |
| 561 | ); | |
| 562 | ||
| 563 | const error = yield* textGeneration | |
| 564 | .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) | |
| 565 | .pipe(Effect.flip); | |
| 566 | ||
| 567 | expect(error).toBeInstanceOf(TextGenerationError); | |
| 568 | expect(error.message).toContain("v1.14.18 is too old"); | |
| 569 | expect(runtimeMock.state.sessionCreateCalls).toBe(0); | |
| 570 | }), | |
| 571 | ), | |
| 572 | ); | |
| 573 | ||
| 574 | it.effect("reuses a configured OpenCode server URL without spawning or applying idle TTL", () => | |
| 575 | withOpenCodeTextGeneration(EXISTING_SERVER_OPENCODE_SETTINGS, (textGeneration) => | |
| 576 | Effect.gen(function* () { | |
| 577 | yield* textGeneration.generateCommitMessage({ | |
| 578 | cwd: process.cwd(), | |
| 579 | branch: "feature/opencode-reuse", | |
| 580 | stagedSummary: "M README.md", | |
| 581 | stagedPatch: "diff --git a/README.md b/README.md", | |
| 582 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | |
| 583 | }); | |
| 584 | yield* textGeneration.generateCommitMessage({ | |
| 585 | cwd: process.cwd(), | |
| 586 | branch: "feature/opencode-reuse", | |
| 587 | stagedSummary: "M README.md", | |
| 588 | stagedPatch: "diff --git a/README.md b/README.md", | |
| 589 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | |
| 590 | }); | |
| 591 | ||
| 592 | expect(runtimeMock.state.startCalls).toEqual([]); | |
| 593 | expect(runtimeMock.state.promptUrls).toEqual([ | |
| 594 | "http://127.0.0.1:9999", | |
| 595 | "http://127.0.0.1:9999", | |
| 596 | ]); | |
| 597 | expect(runtimeMock.state.authHeaders).toEqual([ | |
| 598 | `Basic ${btoa("opencode:secret-password")}`, | |
| 599 | `Basic ${btoa("opencode:secret-password")}`, | |
| 600 | ]); | |
| 601 | ||
| 602 | yield* advanceIdleClock; | |
| 603 | ||
| 604 | expect(runtimeMock.state.closeCalls).toEqual([]); | |
| 605 | }), | |
| 606 | ).pipe(Effect.provide(TestClock.layer())), | |
| 607 | ); | |
| 608 | }, | |
| 609 | ); |
packages/provider-opencode/src/server/textGeneration.ts created+324| ... | ... | @@ -0,0 +1,324 @@ |
| 1 | import * as Effect from "effect/Effect"; | |
| 2 | import * as Schema from "effect/Schema"; | |
| 3 | ||
| 4 | import { NonNegativeInt, TextGenerationError } from "@t3tools/contracts"; | |
| 5 | import type { OpenCodeSettings } from "../settings.ts"; | |
| 6 | import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; | |
| 7 | ||
| 8 | import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; | |
| 9 | import * as TextGenerationOperations from "@t3tools/provider-core/server/textGenerationOperations"; | |
| 10 | import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; | |
| 11 | import * as OpenCodeServerOwner from "./OpenCodeServerOwner.ts"; | |
| 12 | ||
| 13 | const OpenCodeTextGenerationOperation = Schema.Literals([ | |
| 14 | "generateCommitMessage", | |
| 15 | "generatePrContent", | |
| 16 | "generateBranchName", | |
| 17 | "generateThreadTitle", | |
| 18 | ]); | |
| 19 | ||
| 20 | const openCodeTextGenerationErrorContext = { | |
| 21 | operation: OpenCodeTextGenerationOperation, | |
| 22 | cwd: Schema.String, | |
| 23 | }; | |
| 24 | ||
| 25 | export class OpenCodeTextGenerationSessionRequestError extends Schema.TaggedError<OpenCodeTextGenerationSessionRequestError>()( | |
| 26 | "OpenCodeTextGenerationSessionRequestError", | |
| 27 | { | |
| 28 | ...openCodeTextGenerationErrorContext, | |
| 29 | cause: Schema.Defect(), | |
| 30 | }, | |
| 31 | ) { | |
| 32 | override get message(): string { | |
| 33 | return `OpenCode session creation request failed for ${this.operation} in ${this.cwd}.`; | |
| 34 | } | |
| 35 | } | |
| 36 | ||
| 37 | export class OpenCodeTextGenerationSessionPayloadError extends Schema.TaggedError<OpenCodeTextGenerationSessionPayloadError>()( | |
| 38 | "OpenCodeTextGenerationSessionPayloadError", | |
| 39 | openCodeTextGenerationErrorContext, | |
| 40 | ) { | |
| 41 | override get message(): string { | |
| 42 | return `OpenCode session.create returned no session payload for ${this.operation} in ${this.cwd}.`; | |
| 43 | } | |
| 44 | } | |
| 45 | ||
| 46 | const openCodePromptErrorContext = { | |
| 47 | ...openCodeTextGenerationErrorContext, | |
| 48 | sessionId: Schema.String, | |
| 49 | providerId: Schema.String, | |
| 50 | modelId: Schema.String, | |
| 51 | }; | |
| 52 | ||
| 53 | export class OpenCodeTextGenerationPromptRequestError extends Schema.TaggedError<OpenCodeTextGenerationPromptRequestError>()( | |
| 54 | "OpenCodeTextGenerationPromptRequestError", | |
| 55 | { | |
| 56 | ...openCodePromptErrorContext, | |
| 57 | cause: Schema.Defect(), | |
| 58 | }, | |
| 59 | ) { | |
| 60 | override get message(): string { | |
| 61 | return `OpenCode prompt request failed for ${this.operation} in ${this.cwd} using ${this.providerId}/${this.modelId} (session ${this.sessionId}).`; | |
| 62 | } | |
| 63 | } | |
| 64 | ||
| 65 | export class OpenCodeTextGenerationPromptResponseError extends Schema.TaggedError<OpenCodeTextGenerationPromptResponseError>()( | |
| 66 | "OpenCodeTextGenerationPromptResponseError", | |
| 67 | { | |
| 68 | ...openCodePromptErrorContext, | |
| 69 | providerErrorName: Schema.optional(Schema.String), | |
| 70 | providerMessage: Schema.String, | |
| 71 | }, | |
| 72 | ) { | |
| 73 | override get message(): string { | |
| 74 | const providerError = this.providerErrorName ? ` ${this.providerErrorName}` : ""; | |
| 75 | return `OpenCode prompt${providerError} failed for ${this.operation} in ${this.cwd} using ${this.providerId}/${this.modelId} (session ${this.sessionId}): ${this.providerMessage}`; | |
| 76 | } | |
| 77 | } | |
| 78 | ||
| 79 | export class OpenCodeTextGenerationEmptyOutputError extends Schema.TaggedError<OpenCodeTextGenerationEmptyOutputError>()( | |
| 80 | "OpenCodeTextGenerationEmptyOutputError", | |
| 81 | { | |
| 82 | ...openCodePromptErrorContext, | |
| 83 | responsePartCount: NonNegativeInt, | |
| 84 | textPartCount: NonNegativeInt, | |
| 85 | }, | |
| 86 | ) { | |
| 87 | override get message(): string { | |
| 88 | 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).`; | |
| 89 | } | |
| 90 | } | |
| 91 | ||
| 92 | interface OpenCodePromptFailure { | |
| 93 | readonly name?: string; | |
| 94 | readonly message: string; | |
| 95 | } | |
| 96 | ||
| 97 | interface OpenCodeTextPart { | |
| 98 | readonly type: "text"; | |
| 99 | readonly text: string; | |
| 100 | } | |
| 101 | ||
| 102 | function getOpenCodePromptFailure(error: unknown): OpenCodePromptFailure | null { | |
| 103 | if (!error || typeof error !== "object") { | |
| 104 | return null; | |
| 105 | } | |
| 106 | ||
| 107 | const name = | |
| 108 | "name" in error && typeof error.name === "string" && error.name.trim().length > 0 | |
| 109 | ? error.name.trim() | |
| 110 | : undefined; | |
| 111 | const message = | |
| 112 | "data" in error && | |
| 113 | error.data && | |
| 114 | typeof error.data === "object" && | |
| 115 | "message" in error.data && | |
| 116 | typeof error.data.message === "string" | |
| 117 | ? error.data.message.trim() | |
| 118 | : ""; | |
| 119 | if (message.length > 0) { | |
| 120 | return { | |
| 121 | ...(name ? { name } : {}), | |
| 122 | message, | |
| 123 | }; | |
| 124 | } | |
| 125 | ||
| 126 | if (name) { | |
| 127 | return { name, message: name }; | |
| 128 | } | |
| 129 | ||
| 130 | return null; | |
| 131 | } | |
| 132 | ||
| 133 | function isOpenCodeTextPart(part: unknown): part is OpenCodeTextPart { | |
| 134 | return ( | |
| 135 | part !== null && | |
| 136 | typeof part === "object" && | |
| 137 | "type" in part && | |
| 138 | part.type === "text" && | |
| 139 | "text" in part && | |
| 140 | typeof part.text === "string" | |
| 141 | ); | |
| 142 | } | |
| 143 | ||
| 144 | function getOpenCodeTextResponse(parts: ReadonlyArray<unknown> | undefined): string { | |
| 145 | return (parts ?? []) | |
| 146 | .filter(isOpenCodeTextPart) | |
| 147 | .map((part) => part.text) | |
| 148 | .join("") | |
| 149 | .trim(); | |
| 150 | } | |
| 151 | ||
| 152 | export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration")(function* ( | |
| 153 | openCodeSettings: OpenCodeSettings, | |
| 154 | ) { | |
| 155 | const host = yield* ProviderHost.ProviderHost; | |
| 156 | const openCodeRuntime = yield* OpenCodeRuntime.OpenCodeRuntime; | |
| 157 | const serverOwner = yield* OpenCodeServerOwner.OpenCodeServerOwner; | |
| 158 | ||
| 159 | const runOpenCodeJson = Effect.fn("runOpenCodeJson")(function* <S extends Schema.Top>( | |
| 160 | input: TextGenerationOperations.Request<S>, | |
| 161 | ) { | |
| 162 | const parsedModel = OpenCodeRuntime.parseOpenCodeModelSlug(input.modelSelection.model); | |
| 163 | if (!parsedModel) { | |
| 164 | return yield* new TextGenerationError({ | |
| 165 | operation: input.operation, | |
| 166 | detail: "OpenCode model selection must use the 'provider/model' format.", | |
| 167 | }); | |
| 168 | } | |
| 169 | ||
| 170 | const fileParts = OpenCodeRuntime.toOpenCodeFileParts({ | |
| 171 | attachments: input.attachments?.filter((attachment) => attachment.type === "image"), | |
| 172 | resolveAttachmentPath: host.resolveAttachmentPath, | |
| 173 | }); | |
| 174 | ||
| 175 | const runAgainstServer = Effect.fn("runOpenCodeJson.runAgainstServer")( | |
| 176 | function* ( | |
| 177 | server: Pick< | |
| 178 | OpenCodeRuntime.OpenCodeServerConnection, | |
| 179 | "url" | "serverPassword" | "version" | |
| 180 | >, | |
| 181 | ) { | |
| 182 | const client = openCodeRuntime.createOpenCodeSdkClient({ | |
| 183 | baseUrl: server.url, | |
| 184 | directory: input.cwd, | |
| 185 | ...(server.serverPassword !== undefined ? { serverPassword: server.serverPassword } : {}), | |
| 186 | }); | |
| 187 | const session = yield* Effect.tryPromise({ | |
| 188 | try: () => | |
| 189 | client.session.create({ | |
| 190 | title: `T3 Code ${input.operation}`, | |
| 191 | permission: [{ permission: "*", pattern: "*", action: "deny" }], | |
| 192 | }), | |
| 193 | catch: (cause) => | |
| 194 | new OpenCodeTextGenerationSessionRequestError({ | |
| 195 | operation: input.operation, | |
| 196 | cwd: input.cwd, | |
| 197 | cause, | |
| 198 | }), | |
| 199 | }); | |
| 200 | if (!session.data) { | |
| 201 | return yield* new OpenCodeTextGenerationSessionPayloadError({ | |
| 202 | operation: input.operation, | |
| 203 | cwd: input.cwd, | |
| 204 | }); | |
| 205 | } | |
| 206 | const selectedAgent = getModelSelectionStringOptionValue(input.modelSelection, "agent"); | |
| 207 | const selectedVariant = getModelSelectionStringOptionValue(input.modelSelection, "variant"); | |
| 208 | const promptContext = { | |
| 209 | operation: input.operation, | |
| 210 | cwd: input.cwd, | |
| 211 | sessionId: session.data.id, | |
| 212 | providerId: parsedModel.providerID, | |
| 213 | modelId: parsedModel.modelID, | |
| 214 | }; | |
| 215 | ||
| 216 | const result = yield* Effect.tryPromise({ | |
| 217 | try: () => | |
| 218 | client.session.prompt({ | |
| 219 | sessionID: session.data.id, | |
| 220 | model: parsedModel, | |
| 221 | ...(selectedAgent ? { agent: selectedAgent } : {}), | |
| 222 | ...(selectedVariant ? { variant: selectedVariant } : {}), | |
| 223 | parts: [{ type: "text", text: input.prompt }, ...fileParts], | |
| 224 | }), | |
| 225 | catch: (cause) => | |
| 226 | new OpenCodeTextGenerationPromptRequestError({ | |
| 227 | ...promptContext, | |
| 228 | cause, | |
| 229 | }), | |
| 230 | }); | |
| 231 | const promptFailure = getOpenCodePromptFailure(result.data?.info?.error); | |
| 232 | if (promptFailure) { | |
| 233 | return yield* new OpenCodeTextGenerationPromptResponseError({ | |
| 234 | ...promptContext, | |
| 235 | ...(promptFailure.name ? { providerErrorName: promptFailure.name } : {}), | |
| 236 | providerMessage: promptFailure.message, | |
| 237 | }); | |
| 238 | } | |
| 239 | const responseParts = result.data?.parts ?? []; | |
| 240 | const rawText = getOpenCodeTextResponse(responseParts); | |
| 241 | if (rawText.length === 0) { | |
| 242 | return yield* new OpenCodeTextGenerationEmptyOutputError({ | |
| 243 | ...promptContext, | |
| 244 | responsePartCount: responseParts.length, | |
| 245 | textPartCount: responseParts.filter(isOpenCodeTextPart).length, | |
| 246 | }); | |
| 247 | } | |
| 248 | return rawText; | |
| 249 | }, | |
| 250 | Effect.catchTags({ | |
| 251 | OpenCodeTextGenerationSessionRequestError: (cause) => | |
| 252 | Effect.fail( | |
| 253 | new TextGenerationError({ | |
| 254 | operation: cause.operation, | |
| 255 | detail: "OpenCode session.create request failed.", | |
| 256 | cause, | |
| 257 | }), | |
| 258 | ), | |
| 259 | OpenCodeTextGenerationSessionPayloadError: (cause) => | |
| 260 | Effect.fail( | |
| 261 | new TextGenerationError({ | |
| 262 | operation: cause.operation, | |
| 263 | detail: "OpenCode session.create returned no session payload.", | |
| 264 | cause, | |
| 265 | }), | |
| 266 | ), | |
| 267 | OpenCodeTextGenerationPromptRequestError: (cause) => | |
| 268 | Effect.fail( | |
| 269 | new TextGenerationError({ | |
| 270 | operation: cause.operation, | |
| 271 | detail: "OpenCode session.prompt request failed.", | |
| 272 | cause, | |
| 273 | }), | |
| 274 | ), | |
| 275 | OpenCodeTextGenerationPromptResponseError: (cause) => | |
| 276 | Effect.fail( | |
| 277 | new TextGenerationError({ | |
| 278 | operation: cause.operation, | |
| 279 | detail: cause.providerMessage, | |
| 280 | cause, | |
| 281 | }), | |
| 282 | ), | |
| 283 | OpenCodeTextGenerationEmptyOutputError: (cause) => | |
| 284 | Effect.fail( | |
| 285 | new TextGenerationError({ | |
| 286 | operation: cause.operation, | |
| 287 | detail: "OpenCode returned empty output.", | |
| 288 | cause, | |
| 289 | }), | |
| 290 | ), | |
| 291 | }), | |
| 292 | ); | |
| 293 | ||
| 294 | const serverOutput = | |
| 295 | openCodeSettings.serverUrl.length > 0 | |
| 296 | ? openCodeRuntime | |
| 297 | .connectToOpenCodeServer({ | |
| 298 | binaryPath: openCodeSettings.binaryPath, | |
| 299 | directory: input.cwd, | |
| 300 | serverUrl: openCodeSettings.serverUrl, | |
| 301 | ...(openCodeSettings.serverPassword | |
| 302 | ? { serverPassword: openCodeSettings.serverPassword } | |
| 303 | : {}), | |
| 304 | }) | |
| 305 | .pipe(Effect.flatMap(runAgainstServer), Effect.scoped) | |
| 306 | : serverOwner.withServer(runAgainstServer); | |
| 307 | const rawOutput = yield* serverOutput.pipe( | |
| 308 | Effect.catchTags({ | |
| 309 | OpenCodeRuntimeError: (cause) => | |
| 310 | Effect.fail( | |
| 311 | new TextGenerationError({ | |
| 312 | operation: input.operation, | |
| 313 | detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause), | |
| 314 | cause, | |
| 315 | }), | |
| 316 | ), | |
| 317 | }), | |
| 318 | ); | |
| 319 | ||
| 320 | return yield* TextGenerationOperations.decodeJsonReply(input, "OpenCode", rawOutput); | |
| 321 | }); | |
| 322 | ||
| 323 | return TextGenerationOperations.fromRunner("OpenCodeTextGeneration", runOpenCodeJson); | |
| 324 | }); |
packages/provider-opencode/src/server/toolItems.ts created+170| ... | ... | @@ -0,0 +1,170 @@ |
| 1 | /** | |
| 2 | * Turn-item bodies for OpenCode tool calls, shared by the 1.x and 2.x runtimes. | |
| 3 | * Both report a tool as a name, a JSON input, text output, and free-form | |
| 4 | * metadata; only how they deliver those differs. | |
| 5 | */ | |
| 6 | import type { OrchestrationV2TurnItem } from "@t3tools/contracts"; | |
| 7 | import { formatReadToolLabel, formatSearchToolLabel } from "@t3tools/shared/toolActivity"; | |
| 8 | ||
| 9 | // Search results stay on the timeline wire, so keep their text a preview. | |
| 10 | const SEARCH_PREVIEW_MAX_CHARS = 8_000; | |
| 11 | ||
| 12 | type ToolItemBase = Omit< | |
| 13 | Extract<OrchestrationV2TurnItem, { type: "dynamic_tool" }>, | |
| 14 | "type" | "toolName" | "input" | "output" | |
| 15 | >; | |
| 16 | ||
| 17 | export function openCodeToolProjectionKind( | |
| 18 | toolName: string, | |
| 19 | ): "command_execution" | "file_change" | "file_search" | "web_search" | "dynamic_tool" { | |
| 20 | const normalized = toolName.toLowerCase(); | |
| 21 | if (normalized === "todowrite") { | |
| 22 | return "dynamic_tool"; | |
| 23 | } | |
| 24 | if (normalized.includes("bash") || normalized.includes("shell")) { | |
| 25 | return "command_execution"; | |
| 26 | } | |
| 27 | if (normalized.includes("edit") || normalized.includes("write") || normalized.includes("patch")) { | |
| 28 | return "file_change"; | |
| 29 | } | |
| 30 | if (normalized.includes("web") || normalized === "codesearch" || normalized === "code_search") { | |
| 31 | return "web_search"; | |
| 32 | } | |
| 33 | if (normalized === "read") { | |
| 34 | return "dynamic_tool"; | |
| 35 | } | |
| 36 | if ( | |
| 37 | normalized.includes("glob") || | |
| 38 | normalized.includes("grep") || | |
| 39 | normalized.includes("search") || | |
| 40 | normalized.includes("lsp") | |
| 41 | ) { | |
| 42 | return "file_search"; | |
| 43 | } | |
| 44 | return "dynamic_tool"; | |
| 45 | } | |
| 46 | ||
| 47 | function recordString(input: unknown, ...keys: ReadonlyArray<string>): string | undefined { | |
| 48 | if (typeof input !== "object" || input === null) return undefined; | |
| 49 | for (const key of keys) { | |
| 50 | const value = (input as Record<string, unknown>)[key]; | |
| 51 | if (typeof value === "string" && value.trim().length > 0) return value; | |
| 52 | } | |
| 53 | return undefined; | |
| 54 | } | |
| 55 | ||
| 56 | function recordNumber(input: unknown, ...keys: ReadonlyArray<string>): number | undefined { | |
| 57 | if (typeof input !== "object" || input === null) return undefined; | |
| 58 | for (const key of keys) { | |
| 59 | const value = (input as Record<string, unknown>)[key]; | |
| 60 | if (typeof value === "number" && Number.isFinite(value)) return value; | |
| 61 | } | |
| 62 | return undefined; | |
| 63 | } | |
| 64 | ||
| 65 | function stableJson(value: unknown): string { | |
| 66 | if (typeof value === "string") return value; | |
| 67 | try { | |
| 68 | return JSON.stringify(value, null, 2); | |
| 69 | } catch { | |
| 70 | return String(value); | |
| 71 | } | |
| 72 | } | |
| 73 | ||
| 74 | /** | |
| 75 | * The turn item for one tool call. `completedMetadata` is the tool's metadata | |
| 76 | * once it succeeded: exit codes and diffs are only trusted from a finished call. | |
| 77 | */ | |
| 78 | export function openCodeToolTurnItem( | |
| 79 | base: ToolItemBase, | |
| 80 | tool: { | |
| 81 | readonly name: string; | |
| 82 | readonly input: Record<string, unknown>; | |
| 83 | readonly output: string | undefined; | |
| 84 | readonly completedMetadata: unknown; | |
| 85 | }, | |
| 86 | ): OrchestrationV2TurnItem { | |
| 87 | const { input, output } = tool; | |
| 88 | switch (openCodeToolProjectionKind(tool.name)) { | |
| 89 | case "command_execution": { | |
| 90 | const exitCode = recordNumber(tool.completedMetadata, "exit", "exitCode"); | |
| 91 | return { | |
| 92 | ...base, | |
| 93 | type: "command_execution", | |
| 94 | input: recordString(input, "command", "cmd") ?? stableJson(input), | |
| 95 | ...(output === undefined ? {} : { output }), | |
| 96 | ...(exitCode === undefined ? {} : { exitCode }), | |
| 97 | }; | |
| 98 | } | |
| 99 | case "file_change": { | |
| 100 | const oldStr = recordString(input, "oldString", "oldText"); | |
| 101 | const newStr = recordString(input, "newString", "content", "newText"); | |
| 102 | // A failed edit has no diff; keep its error where the diff would be. | |
| 103 | const diffStr = | |
| 104 | recordString(tool.completedMetadata, "diff", "patch") ?? | |
| 105 | (base.status === "failed" && output?.trim() ? output : undefined); | |
| 106 | return { | |
| 107 | ...base, | |
| 108 | type: "file_change", | |
| 109 | fileName: recordString(input, "filePath", "path", "file") ?? tool.name, | |
| 110 | ...(oldStr === undefined ? {} : { oldStr }), | |
| 111 | ...(newStr === undefined ? {} : { newStr }), | |
| 112 | ...(diffStr === undefined ? {} : { diffStr }), | |
| 113 | }; | |
| 114 | } | |
| 115 | case "file_search": { | |
| 116 | const pattern = recordString(input, "pattern", "query", "path", "filePath"); | |
| 117 | // OpenCode reports matches as plain text, so keep it as one result row | |
| 118 | // under the searched path, like the ACP search projection. | |
| 119 | const searchRoot = (recordString(input, "path", "filePath") ?? pattern)?.trim(); | |
| 120 | return { | |
| 121 | ...base, | |
| 122 | title: | |
| 123 | formatSearchToolLabel({ input, ...(pattern === undefined ? {} : { pattern }) }) ?? | |
| 124 | base.title, | |
| 125 | type: "file_search", | |
| 126 | ...(pattern === undefined ? {} : { pattern }), | |
| 127 | ...(!output?.trim() || searchRoot === undefined | |
| 128 | ? {} | |
| 129 | : { | |
| 130 | results: [ | |
| 131 | { fileName: searchRoot, preview: output.slice(0, SEARCH_PREVIEW_MAX_CHARS) }, | |
| 132 | ], | |
| 133 | }), | |
| 134 | }; | |
| 135 | } | |
| 136 | case "web_search": { | |
| 137 | const pattern = recordString(input, "query", "url", "pattern"); | |
| 138 | const url = recordString(input, "url")?.trim(); | |
| 139 | return { | |
| 140 | ...base, | |
| 141 | type: "web_search", | |
| 142 | ...(pattern === undefined ? {} : { patterns: [pattern] }), | |
| 143 | ...(!output?.trim() | |
| 144 | ? {} | |
| 145 | : { | |
| 146 | results: [ | |
| 147 | { | |
| 148 | ...(url === undefined ? {} : { url }), | |
| 149 | snippet: output.slice(0, SEARCH_PREVIEW_MAX_CHARS), | |
| 150 | }, | |
| 151 | ], | |
| 152 | }), | |
| 153 | }; | |
| 154 | } | |
| 155 | case "dynamic_tool": { | |
| 156 | const readPath = recordString(input, "filePath", "path", "file"); | |
| 157 | return { | |
| 158 | ...base, | |
| 159 | title: | |
| 160 | tool.name.toLowerCase() === "read" && readPath !== undefined | |
| 161 | ? formatReadToolLabel(readPath) | |
| 162 | : base.title, | |
| 163 | type: "dynamic_tool", | |
| 164 | toolName: tool.name, | |
| 165 | input, | |
| 166 | ...(output === undefined ? {} : { output }), | |
| 167 | }; | |
| 168 | } | |
| 169 | } | |
| 170 | } |
packages/provider-opencode/src/server/usageLimits.ts created+121| ... | ... | @@ -0,0 +1,121 @@ |
| 1 | import * as NodeOS from "node:os"; | |
| 2 | ||
| 3 | import type { ServerProviderUsageWindow } from "@t3tools/contracts"; | |
| 4 | import * as Crypto from "effect/Crypto"; | |
| 5 | import * as DateTime from "effect/DateTime"; | |
| 6 | import * as Effect from "effect/Effect"; | |
| 7 | import * as FileSystem from "effect/FileSystem"; | |
| 8 | import * as Option from "effect/Option"; | |
| 9 | import * as Path from "effect/Path"; | |
| 10 | import * as Schema from "effect/Schema"; | |
| 11 | import * as Hex from "effect/encoding/Hex"; | |
| 12 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; | |
| 13 | ||
| 14 | import { | |
| 15 | clampPercent, | |
| 16 | makeUnavailableUsageLimits, | |
| 17 | makeUsageLimits, | |
| 18 | } from "@t3tools/provider-core/server/usageLimits"; | |
| 19 | ||
| 20 | const AuthFile = Schema.Struct({ "opencode-go": Schema.optionalKey(Schema.Unknown) }); | |
| 21 | const ApiAuth = Schema.Struct({ type: Schema.Literal("api"), key: Schema.String }); | |
| 22 | const decodeAuthFile = Schema.decodeEffect(Schema.fromJsonString(AuthFile)); | |
| 23 | const decodeApiAuth = Schema.decodeUnknownOption(ApiAuth); | |
| 24 | const UsageWindow = Schema.Struct({ | |
| 25 | percent: Schema.Finite, | |
| 26 | resetsAt: Schema.DateTimeUtcFromString, | |
| 27 | }); | |
| 28 | const UsageResponse = Schema.Struct({ | |
| 29 | usage: Schema.Struct({ rolling: UsageWindow, weekly: UsageWindow, monthly: UsageWindow }), | |
| 30 | }); | |
| 31 | ||
| 32 | /** External OpenCode servers own their credentials; never read the host's account for them. */ | |
| 33 | export const readOpenCodeGoUsageLimits = Effect.fn("readOpenCodeGoUsageLimits")(function* (input: { | |
| 34 | readonly enabled: boolean; | |
| 35 | readonly serverUrl: string; | |
| 36 | readonly environment: NodeJS.ProcessEnv; | |
| 37 | }) { | |
| 38 | const checkedAt = DateTime.formatIso(yield* DateTime.now); | |
| 39 | const unsupported = makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); | |
| 40 | if (!input.enabled || input.serverUrl.trim()) return unsupported; | |
| 41 | ||
| 42 | return yield* Effect.gen(function* () { | |
| 43 | const fs = yield* FileSystem.FileSystem; | |
| 44 | const path = yield* Path.Path; | |
| 45 | const env = input.environment; | |
| 46 | const dataHome = | |
| 47 | env.XDG_DATA_HOME || | |
| 48 | path.join(env.HOME || env.USERPROFILE || NodeOS.homedir(), ".local", "share"); | |
| 49 | const authPath = path.join(dataHome, "opencode", "auth.json"); | |
| 50 | const contents = | |
| 51 | env.OPENCODE_AUTH_CONTENT || | |
| 52 | (yield* fs.readFileString(authPath).pipe( | |
| 53 | Effect.catchTags({ | |
| 54 | PlatformError: (error) => | |
| 55 | error.reason._tag === "NotFound" ? Effect.succeed("{}") : Effect.fail(error), | |
| 56 | }), | |
| 57 | )); | |
| 58 | const auth = yield* decodeAuthFile(contents); | |
| 59 | const apiAuth = decodeApiAuth(auth["opencode-go"]); | |
| 60 | // OpenCode overlays stored API credentials after environment credentials. | |
| 61 | const apiKey = (Option.isSome(apiAuth) ? apiAuth.value.key : env.OPENCODE_API_KEY)?.trim(); | |
| 62 | if (!apiKey) return unsupported; | |
| 63 | ||
| 64 | const client = yield* HttpClient.HttpClient; | |
| 65 | const response = yield* client.execute( | |
| 66 | HttpClientRequest.get("https://opencode.ai/zen/go/v1/usage").pipe( | |
| 67 | HttpClientRequest.bearerToken(apiKey), | |
| 68 | ), | |
| 69 | ); | |
| 70 | // A valid Zen key can exist without a Go subscription. | |
| 71 | if (response.status === 403) return unsupported; | |
| 72 | const body = yield* HttpClientResponse.filterStatusOk(response).pipe( | |
| 73 | Effect.flatMap(HttpClientResponse.schemaBodyJson(UsageResponse)), | |
| 74 | ); | |
| 75 | const windows: ServerProviderUsageWindow[] = [ | |
| 76 | { | |
| 77 | id: "go_rolling", | |
| 78 | kind: "session", | |
| 79 | label: "Go · Session", | |
| 80 | windowDurationMins: 5 * 60, | |
| 81 | usedPercent: clampPercent(body.usage.rolling.percent), | |
| 82 | resetsAt: DateTime.formatIso(body.usage.rolling.resetsAt), | |
| 83 | }, | |
| 84 | { | |
| 85 | id: "go_weekly", | |
| 86 | kind: "weekly", | |
| 87 | label: "Go · Weekly", | |
| 88 | windowDurationMins: 7 * 24 * 60, | |
| 89 | usedPercent: clampPercent(body.usage.weekly.percent), | |
| 90 | resetsAt: DateTime.formatIso(body.usage.weekly.resetsAt), | |
| 91 | }, | |
| 92 | { | |
| 93 | id: "go_monthly", | |
| 94 | kind: "monthly", | |
| 95 | label: "Go · Monthly", | |
| 96 | usedPercent: clampPercent(body.usage.monthly.percent), | |
| 97 | resetsAt: DateTime.formatIso(body.usage.monthly.resetsAt), | |
| 98 | }, | |
| 99 | ]; | |
| 100 | const crypto = yield* Crypto.Crypto; | |
| 101 | // Go's usage response has no account ID. An unkeyed hash matches across | |
| 102 | // environments without a shared secret. It permits offline guesses, but | |
| 103 | // Go keys are randomly generated. | |
| 104 | const credentialFingerprint = yield* crypto | |
| 105 | .digest("SHA-256", new TextEncoder().encode(`opencode-go\0${apiKey}`)) | |
| 106 | .pipe(Effect.map(Hex.encode), Effect.orDie); | |
| 107 | return { | |
| 108 | ...makeUsageLimits({ checkedAt, windows }), | |
| 109 | credentialFingerprint, | |
| 110 | }; | |
| 111 | }).pipe( | |
| 112 | Effect.timeout("5 seconds"), | |
| 113 | Effect.orElseSucceed(() => | |
| 114 | makeUnavailableUsageLimits({ | |
| 115 | checkedAt, | |
| 116 | reason: "probeFailed", | |
| 117 | message: "OpenCode Go could not read usage.", | |
| 118 | }), | |
| 119 | ), | |
| 120 | ); | |
| 121 | }); |
packages/provider-opencode/src/server/v2/OpenCode2Client.live.test.ts created+151| ... | ... | @@ -0,0 +1,151 @@ |
| 1 | /** | |
| 2 | * Live contract check for `@opencode/client` against a real OpenCode 2 server. | |
| 3 | * It re-proves that OpenCode's schemas decode under the workspace Effect | |
| 4 | * version, which the pnpm overrides force. Run it after bumping either side: | |
| 5 | * | |
| 6 | * OPENCODE2_BIN=/path/to/opencode vp test run src/provider/opencode2/OpenCode2Client.live.test.ts | |
| 7 | * | |
| 8 | * The turn uses the free `opencode/big-pickle` model. | |
| 9 | */ | |
| 10 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 11 | import { AbsolutePath, Location, Model, Provider } from "@opencode/client/effect"; | |
| 12 | import { assert, it } from "@effect/vitest"; | |
| 13 | import * as Crypto from "effect/Crypto"; | |
| 14 | import * as Effect from "effect/Effect"; | |
| 15 | import * as Base64Url from "effect/encoding/Base64Url"; | |
| 16 | import * as Fiber from "effect/Fiber"; | |
| 17 | import * as FileSystem from "effect/FileSystem"; | |
| 18 | import * as Filter from "effect/Filter"; | |
| 19 | import * as Layer from "effect/Layer"; | |
| 20 | import * as Option from "effect/Option"; | |
| 21 | import * as Path from "effect/Path"; | |
| 22 | import * as Stream from "effect/Stream"; | |
| 23 | import { FetchHttpClient } from "effect/http"; | |
| 24 | import { ChildProcess, ChildProcessSpawner } from "effect/process"; | |
| 25 | import { describe } from "vite-plus/test"; | |
| 26 | ||
| 27 | import * as OpenCode2Client from "./OpenCode2Client.ts"; | |
| 28 | ||
| 29 | const binaryPath = process.env.OPENCODE2_BIN; | |
| 30 | ||
| 31 | const startServer = Effect.fn("OpenCode2ClientLive.startServer")(function* (binary: string) { | |
| 32 | const fs = yield* FileSystem.FileSystem; | |
| 33 | const path = yield* Path.Path; | |
| 34 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 35 | const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode2-live-" }); | |
| 36 | const directory = path.join(root, "work"); | |
| 37 | yield* fs.makeDirectory(directory); | |
| 38 | // Non-ASCII on purpose: OpenCode decodes Basic credentials as UTF-8. | |
| 39 | const crypto = yield* Crypto.Crypto; | |
| 40 | const password = `${Base64Url.encode(yield* crypto.randomBytes(32))}-pässwörd€`; | |
| 41 | const child = yield* spawner.spawn( | |
| 42 | ChildProcess.make(binary, ["serve", "--hostname=127.0.0.1", "--port=0"], { | |
| 43 | cwd: directory, | |
| 44 | env: { | |
| 45 | PATH: process.env.PATH, | |
| 46 | HOME: root, | |
| 47 | XDG_CONFIG_HOME: path.join(root, "config"), | |
| 48 | XDG_DATA_HOME: path.join(root, "data"), | |
| 49 | XDG_STATE_HOME: path.join(root, "state"), | |
| 50 | XDG_CACHE_HOME: path.join(root, "cache"), | |
| 51 | OPENCODE_PASSWORD: password, | |
| 52 | }, | |
| 53 | }), | |
| 54 | ); | |
| 55 | // Stop by the PID captured at spawn, before the temp directory is removed. | |
| 56 | yield* Effect.addFinalizer(() => | |
| 57 | Effect.sync(() => { | |
| 58 | try { | |
| 59 | process.kill(Number(child.pid), "SIGTERM"); | |
| 60 | } catch { | |
| 61 | // Already exited. | |
| 62 | } | |
| 63 | }).pipe(Effect.andThen(child.exitCode), Effect.timeout("10 seconds"), Effect.ignore), | |
| 64 | ); | |
| 65 | const baseUrl = yield* child.stdout.pipe( | |
| 66 | Stream.decodeText(), | |
| 67 | Stream.splitLines, | |
| 68 | Stream.filterMap( | |
| 69 | Filter.fromPredicateOption((line: string) => | |
| 70 | Option.fromUndefinedOr(/server listening on\s+(https?:\/\/\S+)/i.exec(line)?.[1]), | |
| 71 | ), | |
| 72 | ), | |
| 73 | Stream.runHead, | |
| 74 | Effect.flatMap(Effect.fromOption), | |
| 75 | Effect.timeout("30 seconds"), | |
| 76 | ); | |
| 77 | return { baseUrl, password, directory, pid: Number(child.pid) }; | |
| 78 | }); | |
| 79 | ||
| 80 | describe.runIf(binaryPath !== undefined)("OpenCode2Client live", () => { | |
| 81 | it.live( | |
| 82 | "reads server info, runs one turn, and pages the history", | |
| 83 | () => | |
| 84 | Effect.gen(function* () { | |
| 85 | const server = yield* startServer(binaryPath!); | |
| 86 | const opencode = yield* OpenCode2Client.OpenCode2Client; | |
| 87 | const { client, events } = yield* opencode.connect({ | |
| 88 | baseUrl: server.baseUrl, | |
| 89 | password: server.password, | |
| 90 | }); | |
| 91 | ||
| 92 | const info = yield* client.server.info(); | |
| 93 | assert.strictEqual(info.version, "2.0.23"); | |
| 94 | assert.strictEqual(info.pid, server.pid); | |
| 95 | ||
| 96 | const session = yield* client.session.create({ | |
| 97 | title: "t3 client live check", | |
| 98 | location: Location.PublicRef.make({ directory: AbsolutePath.make(server.directory) }), | |
| 99 | model: Model.Ref.make({ | |
| 100 | providerID: Provider.ID.make("opencode"), | |
| 101 | id: Model.ID.make("big-pickle"), | |
| 102 | }), | |
| 103 | }); | |
| 104 | const finished = yield* (yield* events).pipe( | |
| 105 | Stream.filter( | |
| 106 | (event) => | |
| 107 | event.type !== "unreadable.execution.ended" && | |
| 108 | event.type !== "unreadable.execution.started" && | |
| 109 | (event.data as { readonly sessionID?: string }).sessionID === session.id && | |
| 110 | event.type.startsWith("session.execution.") && | |
| 111 | event.type !== "session.execution.started", | |
| 112 | ), | |
| 113 | Stream.runHead, | |
| 114 | Effect.forkChild, | |
| 115 | ); | |
| 116 | yield* client.session.prompt({ | |
| 117 | sessionID: session.id, | |
| 118 | text: "Reply with exactly: OPENCODE2_CLIENT_OK", | |
| 119 | }); | |
| 120 | const terminal = yield* Fiber.join(finished).pipe( | |
| 121 | Effect.flatMap(Effect.fromOption), | |
| 122 | Effect.timeout("120 seconds"), | |
| 123 | ); | |
| 124 | assert.strictEqual(terminal.type, "session.execution.succeeded"); | |
| 125 | ||
| 126 | // One message per page makes every request after the first send only a cursor. | |
| 127 | const history = yield* OpenCode2Client.paginate( | |
| 128 | { sessionID: session.id, order: "asc", limit: 1 }, | |
| 129 | client.message.list, | |
| 130 | ).pipe(Stream.runCollect); | |
| 131 | assert.deepStrictEqual( | |
| 132 | history.map((message) => message.type), | |
| 133 | ["user", "assistant", "idle"], | |
| 134 | ); | |
| 135 | const assistant = history[1]; | |
| 136 | assert(assistant?.type === "assistant"); | |
| 137 | const reply = assistant.content.flatMap((part) => | |
| 138 | part.type === "text" ? [part.text] : [], | |
| 139 | ); | |
| 140 | assert.include(reply.join(""), "OPENCODE2_CLIENT_OK"); | |
| 141 | }).pipe( | |
| 142 | Effect.scoped, | |
| 143 | Effect.provide( | |
| 144 | OpenCode2Client.layer.pipe( | |
| 145 | Layer.provideMerge(Layer.mergeAll(NodeServices.layer, FetchHttpClient.layer)), | |
| 146 | ), | |
| 147 | ), | |
| 148 | ), | |
| 149 | 180_000, | |
| 150 | ); | |
| 151 | }); |
packages/provider-opencode/src/server/v2/OpenCode2Client.test.ts created+48| ... | ... | @@ -0,0 +1,48 @@ |
| 1 | import { assert, it } from "@effect/vitest"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as Layer from "effect/Layer"; | |
| 4 | import * as Redacted from "effect/Redacted"; | |
| 5 | import { HttpClient, HttpClientResponse } from "effect/http"; | |
| 6 | import { describe } from "vite-plus/test"; | |
| 7 | ||
| 8 | import * as OpenCode2Client from "./OpenCode2Client.ts"; | |
| 9 | ||
| 10 | const INFO_BODY = '{"version":"2.0.18","pid":4242,"urls":[],"paths":{"tmp":"/tmp"}}'; | |
| 11 | ||
| 12 | /** Sends one request through the client and returns the Authorization header it carried. */ | |
| 13 | const authorizationFor = (password: string | Redacted.Redacted) => { | |
| 14 | const seen: Array<string | undefined> = []; | |
| 15 | const layerCapturing = Layer.succeed( | |
| 16 | HttpClient.HttpClient, | |
| 17 | HttpClient.make((request) => | |
| 18 | Effect.sync(() => { | |
| 19 | seen.push(request.headers.authorization); | |
| 20 | return HttpClientResponse.fromWeb( | |
| 21 | request, | |
| 22 | new Response(INFO_BODY, { headers: { "content-type": "application/json" } }), | |
| 23 | ); | |
| 24 | }), | |
| 25 | ), | |
| 26 | ); | |
| 27 | return Effect.gen(function* () { | |
| 28 | const opencode = yield* OpenCode2Client.OpenCode2Client; | |
| 29 | const { client } = yield* opencode.connect({ baseUrl: "http://127.0.0.1:4096", password }); | |
| 30 | yield* client.server.info(); | |
| 31 | return seen[0]; | |
| 32 | }).pipe(Effect.provide(OpenCode2Client.layer.pipe(Layer.provide(layerCapturing)))); | |
| 33 | }; | |
| 34 | ||
| 35 | const utf8Basic = (password: string) => | |
| 36 | `Basic ${Buffer.from(`opencode:${password}`, "utf8").toString("base64")}`; | |
| 37 | ||
| 38 | describe("OpenCode2Client authorization", () => { | |
| 39 | // OpenCode decodes Basic credentials as UTF-8. Latin-1 (`btoa`) encoding gets | |
| 40 | // `pässwörd` rejected with a 401 and throws on characters such as `€`. | |
| 41 | it.effect("encodes non-ASCII passwords as UTF-8", () => | |
| 42 | Effect.gen(function* () { | |
| 43 | assert.strictEqual(yield* authorizationFor("pässwörd"), utf8Basic("pässwörd")); | |
| 44 | assert.strictEqual(yield* authorizationFor("pass€word"), utf8Basic("pass€word")); | |
| 45 | assert.strictEqual(yield* authorizationFor(Redacted.make("pässwörd")), utf8Basic("pässwörd")); | |
| 46 | }), | |
| 47 | ); | |
| 48 | }); |
packages/provider-opencode/src/server/v2/OpenCode2Client.ts created+223| ... | ... | @@ -0,0 +1,223 @@ |
| 1 | import { OpenCode, type OpenCodeClient } from "@opencode/client/effect"; | |
| 2 | import { OpenCodeEvent } from "@opencode/protocol/groups/event"; | |
| 3 | import * as Context from "effect/Context"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as Layer from "effect/Layer"; | |
| 6 | import * as Option from "effect/Option"; | |
| 7 | import * as Redacted from "effect/Redacted"; | |
| 8 | import * as Result from "effect/Result"; | |
| 9 | import * as Schema from "effect/Schema"; | |
| 10 | import * as Stream from "effect/Stream"; | |
| 11 | import * as Sse from "effect/encoding/Sse"; | |
| 12 | import * as HttpClient from "effect/http/HttpClient"; | |
| 13 | import * as HttpClientError from "effect/http/HttpClientError"; | |
| 14 | import * as HttpClientRequest from "effect/http/HttpClientRequest"; | |
| 15 | import * as HttpClientResponse from "effect/http/HttpClientResponse"; | |
| 16 | ||
| 17 | /** OpenCode 2 accepts only HTTP Basic auth, always with this user name. */ | |
| 18 | const OPENCODE_USERNAME = "opencode"; | |
| 19 | ||
| 20 | /** | |
| 21 | * A session's execution ended, but this build could not decode the event (a | |
| 22 | * newer server added a field or a value). Only the type and session survive, | |
| 23 | * so the turn still ends. | |
| 24 | */ | |
| 25 | export interface OpenCode2UnreadableTerminal { | |
| 26 | readonly type: "unreadable.execution.ended"; | |
| 27 | readonly executionType: (typeof EXECUTION_ENDS)[number]; | |
| 28 | readonly sessionID: string; | |
| 29 | } | |
| 30 | ||
| 31 | /** | |
| 32 | * A session's execution started, but this build could not decode the event. | |
| 33 | * It marks where a new execution begins and never ends a turn. | |
| 34 | */ | |
| 35 | export interface OpenCode2UnreadableStart { | |
| 36 | readonly type: "unreadable.execution.started"; | |
| 37 | readonly sessionID: string; | |
| 38 | } | |
| 39 | ||
| 40 | /** The events that end an execution; any other undecodable event is skipped. */ | |
| 41 | const EXECUTION_ENDS = [ | |
| 42 | "session.execution.succeeded", | |
| 43 | "session.execution.failed", | |
| 44 | "session.execution.interrupted", | |
| 45 | ] as const; | |
| 46 | const isExecutionEnd = (type: string): type is (typeof EXECUTION_ENDS)[number] => | |
| 47 | (EXECUTION_ENDS as ReadonlyArray<string>).includes(type); | |
| 48 | ||
| 49 | /** The server sent nothing, not even a heartbeat, for `SILENT_STREAM_TIMEOUT`. */ | |
| 50 | export class OpenCode2SilentStreamError extends Schema.TaggedError<OpenCode2SilentStreamError>()( | |
| 51 | "OpenCode2SilentStreamError", | |
| 52 | {}, | |
| 53 | ) { | |
| 54 | override get message(): string { | |
| 55 | return "The OpenCode server stopped sending events."; | |
| 56 | } | |
| 57 | } | |
| 58 | ||
| 59 | export type OpenCode2StreamEvent = | |
| 60 | | OpenCodeEvent | |
| 61 | | OpenCode2UnreadableTerminal | |
| 62 | | OpenCode2UnreadableStart; | |
| 63 | ||
| 64 | /** | |
| 65 | * An OpenCode 2 client plus a forward-compatible `/api/event` stream. The | |
| 66 | * client's own `event.subscribe` fails the whole stream on the first event a | |
| 67 | * newer server adds, so `events` decodes each frame on its own and skips the | |
| 68 | * ones this build does not know, except an execution's end or start, which it | |
| 69 | * keeps as an {@link OpenCode2UnreadableTerminal} or {@link OpenCode2UnreadableStart}. | |
| 70 | * `events` succeeds once the server accepted the subscription: the stream is | |
| 71 | * volatile, so callers subscribe before they start work whose events they need. | |
| 72 | */ | |
| 73 | export interface OpenCode2Api { | |
| 74 | readonly client: OpenCodeClient; | |
| 75 | readonly events: Effect.Effect< | |
| 76 | Stream.Stream< | |
| 77 | OpenCode2StreamEvent, | |
| 78 | HttpClientError.HttpClientError | Sse.Retry | Sse.SseError | OpenCode2SilentStreamError | |
| 79 | >, | |
| 80 | HttpClientError.HttpClientError | |
| 81 | >; | |
| 82 | } | |
| 83 | ||
| 84 | /** Builds clients for OpenCode 2 servers, one per base URL and password. */ | |
| 85 | export class OpenCode2Client extends Context.Service< | |
| 86 | OpenCode2Client, | |
| 87 | { | |
| 88 | readonly connect: (input: { | |
| 89 | readonly baseUrl: string; | |
| 90 | readonly password: string | Redacted.Redacted; | |
| 91 | }) => Effect.Effect<OpenCode2Api>; | |
| 92 | } | |
| 93 | >()("@t3tools/provider-opencode/server/v2/OpenCode2Client") {} | |
| 94 | ||
| 95 | /** | |
| 96 | * OpenCode writes a `: heartbeat` comment every 10 to 15 seconds, so this long | |
| 97 | * without a single byte means the server is stuck, not idle. | |
| 98 | */ | |
| 99 | const SILENT_STREAM_TIMEOUT = "45 seconds"; | |
| 100 | ||
| 101 | const decodeEvent = Schema.decodeUnknownResult(Schema.fromJsonString(OpenCodeEvent)); | |
| 102 | // Just enough of an event to route it, for frames the full schema rejects. | |
| 103 | const decodeEnvelope = Schema.decodeUnknownOption( | |
| 104 | Schema.fromJsonString( | |
| 105 | Schema.Struct({ | |
| 106 | type: Schema.String, | |
| 107 | data: Schema.optional(Schema.Struct({ sessionID: Schema.optional(Schema.String) })), | |
| 108 | }), | |
| 109 | ), | |
| 110 | ); | |
| 111 | ||
| 112 | const undecodable = (data: string) => | |
| 113 | Effect.gen(function* () { | |
| 114 | const envelope = decodeEnvelope(data); | |
| 115 | const type = envelope._tag === "Some" ? envelope.value.type.slice(0, 80) : "<unreadable>"; | |
| 116 | const sessionID = envelope._tag === "Some" ? envelope.value.data?.sessionID : undefined; | |
| 117 | if (isExecutionEnd(type) && sessionID !== undefined) { | |
| 118 | yield* Effect.logWarning( | |
| 119 | "Ended an OpenCode execution from an event this build cannot decode.", | |
| 120 | { | |
| 121 | type, | |
| 122 | }, | |
| 123 | ); | |
| 124 | return Result.succeed<OpenCode2StreamEvent>({ | |
| 125 | type: "unreadable.execution.ended", | |
| 126 | executionType: type, | |
| 127 | sessionID, | |
| 128 | }); | |
| 129 | } | |
| 130 | if (type === "session.execution.started" && sessionID !== undefined) { | |
| 131 | yield* Effect.logDebug("Read an OpenCode execution start this build cannot decode."); | |
| 132 | return Result.succeed<OpenCode2StreamEvent>({ | |
| 133 | type: "unreadable.execution.started", | |
| 134 | sessionID, | |
| 135 | }); | |
| 136 | } | |
| 137 | yield* Effect.logDebug("Skipped an OpenCode event this build cannot decode.", { type }); | |
| 138 | return Result.failVoid; | |
| 139 | }); | |
| 140 | ||
| 141 | /** Subscribes to `/api/event`, then streams every frame this build can route. */ | |
| 142 | const readEvents = (httpClient: HttpClient.HttpClient) => | |
| 143 | httpClient.get("/api/event", { headers: { accept: "text/event-stream" } }).pipe( | |
| 144 | Effect.flatMap(HttpClientResponse.filterStatusOk), | |
| 145 | Effect.map((response) => | |
| 146 | response.stream.pipe( | |
| 147 | Stream.timeoutOrElse({ | |
| 148 | duration: SILENT_STREAM_TIMEOUT, | |
| 149 | orElse: () => Stream.fail(new OpenCode2SilentStreamError()), | |
| 150 | }), | |
| 151 | Stream.decodeText, | |
| 152 | Stream.pipeThroughChannel(Sse.decode()), | |
| 153 | Stream.filterMapEffect((frame) => { | |
| 154 | const decoded = decodeEvent(frame.data); | |
| 155 | return Result.isSuccess(decoded) | |
| 156 | ? Effect.succeed(Result.succeed<OpenCode2StreamEvent>(decoded.success)) | |
| 157 | : undecodable(frame.data); | |
| 158 | }), | |
| 159 | ), | |
| 160 | ), | |
| 161 | ); | |
| 162 | ||
| 163 | /** | |
| 164 | * OpenCode decodes Basic credentials as UTF-8. `HttpClientRequest.basicAuth` | |
| 165 | * encodes them as Latin-1 (`btoa`), which gets non-ASCII passwords rejected. | |
| 166 | */ | |
| 167 | const basicAuthorization = (password: string | Redacted.Redacted) => { | |
| 168 | const plain = Redacted.isRedacted(password) ? Redacted.value(password) : password; | |
| 169 | return `Basic ${Buffer.from(`${OPENCODE_USERNAME}:${plain}`, "utf8").toString("base64")}`; | |
| 170 | }; | |
| 171 | ||
| 172 | /** @public Service construction is part of the canonical Effect module API. */ | |
| 173 | export const make = Effect.gen(function* () { | |
| 174 | const httpClient = yield* HttpClient.HttpClient; | |
| 175 | return OpenCode2Client.of({ | |
| 176 | connect: ({ baseUrl, password }) => { | |
| 177 | const authenticated = HttpClient.mapRequest( | |
| 178 | httpClient, | |
| 179 | HttpClientRequest.setHeader("Authorization", basicAuthorization(password)), | |
| 180 | ); | |
| 181 | return OpenCode.make({ baseUrl }).pipe( | |
| 182 | Effect.provideService(HttpClient.HttpClient, authenticated), | |
| 183 | Effect.map((client) => ({ | |
| 184 | client, | |
| 185 | events: readEvents( | |
| 186 | HttpClient.mapRequest(authenticated, HttpClientRequest.prependUrl(baseUrl)), | |
| 187 | ), | |
| 188 | })), | |
| 189 | ); | |
| 190 | }, | |
| 191 | }); | |
| 192 | }); | |
| 193 | ||
| 194 | export const layer = Layer.effect(OpenCode2Client, make); | |
| 195 | ||
| 196 | /** | |
| 197 | * Streams every item of a cursor-paged OpenCode 2 list. The first request | |
| 198 | * carries the caller's input (including `order`); later requests send only | |
| 199 | * the cursor, because OpenCode rejects a cursor combined with `order`. | |
| 200 | */ | |
| 201 | export const paginate = <Input extends { readonly cursor?: unknown }, Item, E, R>( | |
| 202 | input: Input, | |
| 203 | list: ( | |
| 204 | input: Input, | |
| 205 | ) => Effect.Effect< | |
| 206 | { readonly data: ReadonlyArray<Item>; readonly cursor: { readonly next?: Input["cursor"] } }, | |
| 207 | E, | |
| 208 | R | |
| 209 | >, | |
| 210 | ): Stream.Stream<Item, E, R> => | |
| 211 | Stream.paginate(input, (request) => | |
| 212 | list(request).pipe( | |
| 213 | Effect.map( | |
| 214 | (page) => | |
| 215 | [ | |
| 216 | page.data, | |
| 217 | page.data.length === 0 || page.cursor.next === undefined | |
| 218 | ? Option.none() | |
| 219 | : Option.some({ ...request, order: undefined, cursor: page.cursor.next }), | |
| 220 | ] as const, | |
| 221 | ), | |
| 222 | ), | |
| 223 | ); |
packages/provider-opencode/src/server/v2/OpenCode2Server.live.test.ts created+238| ... | ... | @@ -0,0 +1,238 @@ |
| 1 | /** | |
| 2 | * Live check of the OpenCode 2 server lifecycle against a real binary: | |
| 3 | * | |
| 4 | * OPENCODE2_BIN=/path/to/opencode vp test run src/provider/opencode2/OpenCode2Server.live.test.ts | |
| 5 | * | |
| 6 | * No model is called. The server runs with isolated HOME and XDG directories. | |
| 7 | */ | |
| 8 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 9 | import { AbsolutePath, Location } from "@opencode/client/effect"; | |
| 10 | import { assert, it } from "@effect/vitest"; | |
| 11 | import * as Context from "effect/Context"; | |
| 12 | import * as Effect from "effect/Effect"; | |
| 13 | import * as Exit from "effect/Exit"; | |
| 14 | import * as FileSystem from "effect/FileSystem"; | |
| 15 | import * as Filter from "effect/Filter"; | |
| 16 | import * as Layer from "effect/Layer"; | |
| 17 | import * as Option from "effect/Option"; | |
| 18 | import * as Path from "effect/Path"; | |
| 19 | import * as Redacted from "effect/Redacted"; | |
| 20 | import * as Scope from "effect/Scope"; | |
| 21 | import * as Stream from "effect/Stream"; | |
| 22 | import { FetchHttpClient } from "effect/http"; | |
| 23 | import { ChildProcess, ChildProcessSpawner } from "effect/process"; | |
| 24 | import { describe } from "vite-plus/test"; | |
| 25 | ||
| 26 | import * as OpenCodeRuntime from "../OpenCodeRuntime.ts"; | |
| 27 | import * as OpenCodeServerLedger from "../OpenCodeServerLedger.ts"; | |
| 28 | import * as OpenCode2Client from "./OpenCode2Client.ts"; | |
| 29 | import * as OpenCode2Server from "./OpenCode2Server.ts"; | |
| 30 | ||
| 31 | const binaryPath = process.env.OPENCODE2_BIN; | |
| 32 | ||
| 33 | // A negative pid probes the whole process group, which T3 stops as one unit. | |
| 34 | const isAlive = (pid: number) => { | |
| 35 | try { | |
| 36 | process.kill(pid, 0); | |
| 37 | return true; | |
| 38 | } catch { | |
| 39 | return false; | |
| 40 | } | |
| 41 | }; | |
| 42 | ||
| 43 | /** | |
| 44 | * Stops a server the test spawned itself: SIGTERM, then SIGKILL if it is still | |
| 45 | * alive after five seconds. Keyed on the PID captured at spawn, never on a | |
| 46 | * pattern. Servers T3 spawns are stopped by T3, through the instance scope. | |
| 47 | */ | |
| 48 | const stopByPid = (pid: number) => | |
| 49 | Effect.gen(function* () { | |
| 50 | const signal = (name: NodeJS.Signals) => { | |
| 51 | try { | |
| 52 | process.kill(pid, name); | |
| 53 | } catch { | |
| 54 | // Already exited. | |
| 55 | } | |
| 56 | }; | |
| 57 | signal("SIGTERM"); | |
| 58 | for (let attempt = 0; attempt < 50 && isAlive(pid); attempt++) { | |
| 59 | yield* Effect.sleep("100 millis"); | |
| 60 | } | |
| 61 | if (isAlive(pid)) signal("SIGKILL"); | |
| 62 | }); | |
| 63 | ||
| 64 | /** | |
| 65 | * Starts `opencode serve` the way a user would run it themselves. It is stopped | |
| 66 | * by its PID when the calling scope closes, whether or not the test passed. | |
| 67 | */ | |
| 68 | const startExternalServer = Effect.fn("OpenCode2ServerLive.startExternalServer")(function* (input: { | |
| 69 | readonly environment: NodeJS.ProcessEnv; | |
| 70 | readonly directory: string; | |
| 71 | }) { | |
| 72 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 73 | const child = yield* spawner.spawn( | |
| 74 | ChildProcess.make(binaryPath!, ["serve", "--hostname=127.0.0.1", "--port=0"], { | |
| 75 | cwd: input.directory, | |
| 76 | env: input.environment, | |
| 77 | }), | |
| 78 | ); | |
| 79 | const pid = Number(child.pid); | |
| 80 | yield* Effect.addFinalizer(() => stopByPid(pid)); | |
| 81 | const url = yield* child.stdout.pipe( | |
| 82 | Stream.decodeText(), | |
| 83 | Stream.splitLines, | |
| 84 | Stream.filterMap( | |
| 85 | Filter.fromPredicateOption((line: string) => | |
| 86 | Option.fromUndefinedOr(/server listening on\s+(https?:\/\/\S+)/i.exec(line)?.[1]), | |
| 87 | ), | |
| 88 | ), | |
| 89 | Stream.runHead, | |
| 90 | Effect.flatMap(Effect.fromOption), | |
| 91 | Effect.timeout("30 seconds"), | |
| 92 | ); | |
| 93 | return { url, pid }; | |
| 94 | }); | |
| 95 | ||
| 96 | describe.runIf(binaryPath !== undefined)("OpenCode2Server live", () => { | |
| 97 | it.live( | |
| 98 | "spawns one authenticated server per instance and stops it by PID", | |
| 99 | () => | |
| 100 | Effect.gen(function* () { | |
| 101 | const fs = yield* FileSystem.FileSystem; | |
| 102 | const path = yield* Path.Path; | |
| 103 | const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode2-server-live-" }); | |
| 104 | const first = path.join(root, "first"); | |
| 105 | const second = path.join(root, "second"); | |
| 106 | yield* fs.makeDirectory(first); | |
| 107 | yield* fs.makeDirectory(second); | |
| 108 | const environment = { | |
| 109 | PATH: process.env.PATH, | |
| 110 | HOME: root, | |
| 111 | XDG_CONFIG_HOME: path.join(root, "config"), | |
| 112 | XDG_DATA_HOME: path.join(root, "data"), | |
| 113 | XDG_STATE_HOME: path.join(root, "state"), | |
| 114 | XDG_CACHE_HOME: path.join(root, "cache"), | |
| 115 | // Ignored: the T3 password is the only one the server sees. | |
| 116 | OPENCODE_SERVER_PASSWORD: "ambient-password", | |
| 117 | }; | |
| 118 | ||
| 119 | // The instance owns its server: building the layer spawns nothing, and | |
| 120 | // closing its scope stops whatever it spawned. The scope is released | |
| 121 | // even when an assertion below fails; it is closed early on purpose. | |
| 122 | const instanceScope = yield* Effect.acquireRelease(Scope.make(), (scope) => | |
| 123 | Scope.close(scope, Exit.void), | |
| 124 | ); | |
| 125 | const server = Context.get( | |
| 126 | yield* Layer.buildWithScope( | |
| 127 | OpenCode2Server.layer({ | |
| 128 | binaryPath: binaryPath!, | |
| 129 | serverUrl: "", | |
| 130 | serverPassword: "", | |
| 131 | directory: first, | |
| 132 | environment, | |
| 133 | }), | |
| 134 | instanceScope, | |
| 135 | ), | |
| 136 | OpenCode2Server.OpenCode2Server, | |
| 137 | ); | |
| 138 | ||
| 139 | const spawned = yield* server.withConnection((connection) => | |
| 140 | Effect.gen(function* () { | |
| 141 | const info = yield* connection.client.server.info(); | |
| 142 | const session = yield* connection.client.session.create({ | |
| 143 | title: "first location", | |
| 144 | location: Location.PublicRef.make({ directory: AbsolutePath.make(first) }), | |
| 145 | }); | |
| 146 | return { ...connection, pid: info.pid, session }; | |
| 147 | }), | |
| 148 | ); | |
| 149 | assert.strictEqual(spawned.version, "2.0.18"); | |
| 150 | assert.isFalse(spawned.external); | |
| 151 | assert.isTrue(isAlive(spawned.pid)); | |
| 152 | ||
| 153 | // A second location is served by the same process. | |
| 154 | const secondLocation = yield* server.withConnection((connection) => | |
| 155 | Effect.gen(function* () { | |
| 156 | const info = yield* connection.client.server.info(); | |
| 157 | const session = yield* connection.client.session.create({ | |
| 158 | title: "second location", | |
| 159 | location: Location.PublicRef.make({ directory: AbsolutePath.make(second) }), | |
| 160 | }); | |
| 161 | return { url: connection.url, pid: info.pid, session }; | |
| 162 | }), | |
| 163 | ); | |
| 164 | assert.strictEqual(secondLocation.url, spawned.url); | |
| 165 | assert.strictEqual(secondLocation.pid, spawned.pid); | |
| 166 | assert.strictEqual(secondLocation.session.location.directory, second); | |
| 167 | assert.strictEqual(spawned.session.location.directory, first); | |
| 168 | ||
| 169 | // External URL: the configured password works, a wrong one is a 401. | |
| 170 | const external = yield* OpenCode2Server.make({ | |
| 171 | binaryPath: binaryPath!, | |
| 172 | serverUrl: spawned.url, | |
| 173 | serverPassword: "wrong-password", | |
| 174 | directory: first, | |
| 175 | environment, | |
| 176 | }); | |
| 177 | const rejected = yield* external.withConnection(() => Effect.void).pipe(Effect.flip); | |
| 178 | assert.include(rejected.detail, "rejected the server password"); | |
| 179 | const ambient = yield* OpenCode2Server.make({ | |
| 180 | binaryPath: binaryPath!, | |
| 181 | serverUrl: spawned.url, | |
| 182 | serverPassword: environment.OPENCODE_SERVER_PASSWORD, | |
| 183 | directory: first, | |
| 184 | environment, | |
| 185 | }); | |
| 186 | const ambientRejected = yield* ambient.withConnection(() => Effect.void).pipe(Effect.flip); | |
| 187 | assert.include(ambientRejected.detail, "rejected the server password"); | |
| 188 | ||
| 189 | // An external server whose password is not ASCII: OpenCode decodes Basic | |
| 190 | // credentials as UTF-8, so the configured password must be sent that way. | |
| 191 | const utf8Password = "pässwörd€"; | |
| 192 | const externalServer = yield* startExternalServer({ | |
| 193 | environment: OpenCode2Server.serverEnvironment(environment, Redacted.make(utf8Password)), | |
| 194 | directory: second, | |
| 195 | }); | |
| 196 | const utf8 = yield* OpenCode2Server.make({ | |
| 197 | binaryPath: binaryPath!, | |
| 198 | serverUrl: externalServer.url, | |
| 199 | serverPassword: utf8Password, | |
| 200 | directory: second, | |
| 201 | environment, | |
| 202 | }); | |
| 203 | const utf8Connection = yield* utf8.withConnection((connection) => | |
| 204 | Effect.succeed(connection), | |
| 205 | ); | |
| 206 | assert.strictEqual(utf8Connection.version, "2.0.18"); | |
| 207 | assert.isTrue(utf8Connection.external); | |
| 208 | const asciiOnly = yield* OpenCode2Server.make({ | |
| 209 | binaryPath: binaryPath!, | |
| 210 | serverUrl: externalServer.url, | |
| 211 | serverPassword: "passwrd", | |
| 212 | directory: second, | |
| 213 | environment, | |
| 214 | }); | |
| 215 | const asciiRejected = yield* asciiOnly.withConnection(() => Effect.void).pipe(Effect.flip); | |
| 216 | assert.include(asciiRejected.detail, "rejected the server password"); | |
| 217 | ||
| 218 | // Closing the instance stops the server it spawned. | |
| 219 | yield* Scope.close(instanceScope, Exit.void); | |
| 220 | for (let attempt = 0; attempt < 50 && isAlive(-spawned.pid); attempt++) { | |
| 221 | yield* Effect.sleep("100 millis"); | |
| 222 | } | |
| 223 | assert.isFalse(isAlive(spawned.pid), `opencode serve ${spawned.pid} outlived its instance`); | |
| 224 | assert.isFalse(isAlive(-spawned.pid), `a process in group ${spawned.pid} outlived it`); | |
| 225 | const unreachable = yield* external.withConnection(() => Effect.void).pipe(Effect.flip); | |
| 226 | assert.include(unreachable.detail, "Could not reach the OpenCode server"); | |
| 227 | }).pipe( | |
| 228 | Effect.scoped, | |
| 229 | Effect.provide( | |
| 230 | Layer.mergeAll( | |
| 231 | OpenCode2Client.layer, | |
| 232 | OpenCodeRuntime.layer.pipe(Layer.provide(OpenCodeServerLedger.layerTest)), | |
| 233 | ).pipe(Layer.provideMerge(Layer.mergeAll(NodeServices.layer, FetchHttpClient.layer))), | |
| 234 | ), | |
| 235 | ), | |
| 236 | 60_000, | |
| 237 | ); | |
| 238 | }); |
packages/provider-opencode/src/server/v2/OpenCode2Server.test.ts created+259| ... | ... | @@ -0,0 +1,259 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { assert, it } from "@effect/vitest"; | |
| 3 | import { | |
| 4 | HostProcessEnvironment, | |
| 5 | HostProcessExecutablePath, | |
| 6 | HostProcessPlatform, | |
| 7 | } from "@t3tools/shared/hostProcess"; | |
| 8 | import * as Effect from "effect/Effect"; | |
| 9 | import * as Fiber from "effect/Fiber"; | |
| 10 | import * as FileSystem from "effect/FileSystem"; | |
| 11 | import * as Layer from "effect/Layer"; | |
| 12 | import * as Path from "effect/Path"; | |
| 13 | import * as Redacted from "effect/Redacted"; | |
| 14 | import * as TestClock from "effect/testing/TestClock"; | |
| 15 | import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/http"; | |
| 16 | import { describe } from "vite-plus/test"; | |
| 17 | ||
| 18 | import * as OpenCodeRuntime from "../OpenCodeRuntime.ts"; | |
| 19 | import * as OpenCodeServerLedger from "../OpenCodeServerLedger.ts"; | |
| 20 | import * as OpenCode2Client from "./OpenCode2Client.ts"; | |
| 21 | import * as OpenCode2Server from "./OpenCode2Server.ts"; | |
| 22 | ||
| 23 | // Shapes and content types observed on `opencode serve` 2.0.18 (pid and port replaced). 1.x | |
| 24 | // answers /api/info with its web UI: 200 text/html. | |
| 25 | const INFO_BODY = | |
| 26 | '{"version":"2.0.18","pid":4242,"urls":["http://127.0.0.1:4096"],"paths":{"tmp":"/tmp/opencode"}}'; | |
| 27 | const UNAUTHORIZED_BODY = '{"_tag":"UnauthorizedError","message":"Authentication required"}'; | |
| 28 | const SPA_BODY = "<!doctype html><html><head><title>OpenCode</title></head></html>"; | |
| 29 | ||
| 30 | const layerServerReplying = (reply: { | |
| 31 | readonly status: number; | |
| 32 | readonly contentType?: string; | |
| 33 | readonly body: string; | |
| 34 | }) => | |
| 35 | Layer.succeed( | |
| 36 | HttpClient.HttpClient, | |
| 37 | HttpClient.make((request) => | |
| 38 | Effect.succeed( | |
| 39 | HttpClientResponse.fromWeb( | |
| 40 | request, | |
| 41 | new Response(reply.body, { | |
| 42 | status: reply.status, | |
| 43 | headers: reply.contentType === undefined ? {} : { "content-type": reply.contentType }, | |
| 44 | }), | |
| 45 | ), | |
| 46 | ), | |
| 47 | ), | |
| 48 | ); | |
| 49 | ||
| 50 | const verify = (httpClient: Layer.Layer<HttpClient.HttpClient>, url = "http://127.0.0.1:4096") => | |
| 51 | Effect.gen(function* () { | |
| 52 | const opencode = yield* OpenCode2Client.OpenCode2Client; | |
| 53 | const { client } = yield* opencode.connect({ baseUrl: url, password: "secret" }); | |
| 54 | return yield* OpenCode2Server.verifyServer(client); | |
| 55 | }).pipe(Effect.provide(OpenCode2Client.layer.pipe(Layer.provide(httpClient)))); | |
| 56 | ||
| 57 | describe("OpenCode2Server.verifyServer", () => { | |
| 58 | it.effect("returns the version of an authenticated OpenCode 2 server", () => | |
| 59 | Effect.gen(function* () { | |
| 60 | const version = yield* verify( | |
| 61 | layerServerReplying({ status: 200, contentType: "application/json", body: INFO_BODY }), | |
| 62 | ); | |
| 63 | assert.strictEqual(version, "2.0.18"); | |
| 64 | }), | |
| 65 | ); | |
| 66 | ||
| 67 | it.effect("reports a rejected password, not a wrong server", () => | |
| 68 | Effect.gen(function* () { | |
| 69 | const error = yield* verify( | |
| 70 | layerServerReplying({ | |
| 71 | status: 401, | |
| 72 | contentType: "application/json", | |
| 73 | body: UNAUTHORIZED_BODY, | |
| 74 | }), | |
| 75 | ).pipe(Effect.flip); | |
| 76 | assert.include(error.detail, "rejected the server password"); | |
| 77 | }), | |
| 78 | ); | |
| 79 | ||
| 80 | // 1.x rejects a wrong password with an empty 401, which the client cannot decode. | |
| 81 | it.effect("reports an empty-body 401 as a rejected password", () => | |
| 82 | Effect.gen(function* () { | |
| 83 | const error = yield* verify(layerServerReplying({ status: 401, body: "" })).pipe(Effect.flip); | |
| 84 | assert.include(error.detail, "rejected the server password"); | |
| 85 | }), | |
| 86 | ); | |
| 87 | ||
| 88 | it.effect("reports a server error as a server error, not as unreachable", () => | |
| 89 | Effect.gen(function* () { | |
| 90 | for (const status of [500, 502]) { | |
| 91 | const error = yield* verify( | |
| 92 | layerServerReplying({ status, contentType: "text/plain", body: "upstream failed" }), | |
| 93 | ).pipe(Effect.flip); | |
| 94 | assert.include(error.detail, `returned HTTP ${status}`); | |
| 95 | } | |
| 96 | }), | |
| 97 | ); | |
| 98 | ||
| 99 | // 1.x (and the 2.x web UI on unknown paths) answers /api/info with 200 HTML. | |
| 100 | it.effect("rejects a server that answers with the web UI's HTML", () => | |
| 101 | Effect.gen(function* () { | |
| 102 | const error = yield* verify( | |
| 103 | layerServerReplying({ status: 200, contentType: "text/html", body: SPA_BODY }), | |
| 104 | ).pipe(Effect.flip); | |
| 105 | assert.include(error.detail, "is not an OpenCode 2 server"); | |
| 106 | }), | |
| 107 | ); | |
| 108 | ||
| 109 | it.effect("reports an unreachable server as unreachable", () => | |
| 110 | Effect.gen(function* () { | |
| 111 | const error = yield* verify(FetchHttpClient.layer, "http://127.0.0.1:1").pipe(Effect.flip); | |
| 112 | assert.include(error.detail, "Could not reach the OpenCode server"); | |
| 113 | }), | |
| 114 | ); | |
| 115 | }); | |
| 116 | ||
| 117 | describe("OpenCode2Server error details", () => { | |
| 118 | // `detail` reaches clients through the provider status message, and a | |
| 119 | // `serverUrl` can carry credentials in its userinfo or query. | |
| 120 | const serverUrl = "http://user:url-secret@127.0.0.1:1/?token=query-secret"; | |
| 121 | const detailFor = (httpClient: Layer.Layer<HttpClient.HttpClient>) => | |
| 122 | Effect.gen(function* () { | |
| 123 | const server = yield* OpenCode2Server.make({ | |
| 124 | binaryPath: "opencode", | |
| 125 | serverUrl, | |
| 126 | serverPassword: "secret", | |
| 127 | directory: "/project", | |
| 128 | environment: {}, | |
| 129 | }); | |
| 130 | const error = yield* server.withConnection(() => Effect.void).pipe(Effect.flip); | |
| 131 | return error.detail; | |
| 132 | }).pipe( | |
| 133 | Effect.provide( | |
| 134 | Layer.mergeAll( | |
| 135 | OpenCode2Client.layer.pipe(Layer.provide(httpClient)), | |
| 136 | OpenCodeRuntime.layer.pipe(Layer.provide(OpenCodeServerLedger.layerTest)), | |
| 137 | ).pipe(Layer.provideMerge(NodeServices.layer)), | |
| 138 | ), | |
| 139 | ); | |
| 140 | ||
| 141 | it.effect("never include the server URL", () => | |
| 142 | Effect.gen(function* () { | |
| 143 | const layerHanging = Layer.succeed( | |
| 144 | HttpClient.HttpClient, | |
| 145 | HttpClient.make(() => Effect.never), | |
| 146 | ); | |
| 147 | const timedOut = yield* detailFor(layerHanging).pipe(Effect.forkChild); | |
| 148 | yield* TestClock.adjust("10 seconds"); | |
| 149 | const details = [ | |
| 150 | yield* detailFor(FetchHttpClient.layer), | |
| 151 | yield* detailFor(layerServerReplying({ status: 401, body: "" })), | |
| 152 | yield* detailFor(layerServerReplying({ status: 502, contentType: "text/plain", body: "" })), | |
| 153 | yield* detailFor( | |
| 154 | layerServerReplying({ status: 200, contentType: "text/html", body: SPA_BODY }), | |
| 155 | ), | |
| 156 | yield* Fiber.join(timedOut), | |
| 157 | ]; | |
| 158 | assert.deepStrictEqual(details, [ | |
| 159 | "Could not reach the OpenCode server.", | |
| 160 | "The OpenCode server rejected the server password.", | |
| 161 | "The OpenCode server returned HTTP 502.", | |
| 162 | "The server is not an OpenCode 2 server.", | |
| 163 | "Timed out waiting for the OpenCode server.", | |
| 164 | ]); | |
| 165 | for (const detail of details) { | |
| 166 | assert.notInclude(detail, "url-secret"); | |
| 167 | assert.notInclude(detail, "query-secret"); | |
| 168 | assert.notInclude(detail, "127.0.0.1"); | |
| 169 | } | |
| 170 | }).pipe(Effect.provide(TestClock.layer())), | |
| 171 | ); | |
| 172 | }); | |
| 173 | ||
| 174 | describe("OpenCode2Server passwords", () => { | |
| 175 | it.effect("generates a distinct 256-bit password per server", () => | |
| 176 | Effect.gen(function* () { | |
| 177 | const first = yield* OpenCode2Server.generatePassword; | |
| 178 | const second = yield* OpenCode2Server.generatePassword; | |
| 179 | assert.match(Redacted.value(first), /^[A-Za-z0-9_-]{43}$/); | |
| 180 | assert.notStrictEqual(Redacted.value(first), Redacted.value(second)); | |
| 181 | assert.notInclude(String(first), Redacted.value(first)); | |
| 182 | }).pipe(Effect.provide(NodeServices.layer)), | |
| 183 | ); | |
| 184 | ||
| 185 | it("hands the spawned server only the T3 password", () => { | |
| 186 | const password = Redacted.make("t3-generated"); | |
| 187 | const environment = OpenCode2Server.serverEnvironment( | |
| 188 | { PATH: "/bin", OPENCODE_SERVER_PASSWORD: "ambient", OPENCODE_PASSWORD: "ambient" }, | |
| 189 | password, | |
| 190 | ); | |
| 191 | assert.deepStrictEqual(environment, { PATH: "/bin", OPENCODE_PASSWORD: "t3-generated" }); | |
| 192 | }); | |
| 193 | }); | |
| 194 | ||
| 195 | // Serves /api/info only with the password from OPENCODE_PASSWORD, like 2.x, and | |
| 196 | // prints the 2.x banner (plus the generated-password line 2.x prints when no | |
| 197 | // password is set, which T3 must never need). | |
| 198 | const FAKE_SERVER = `import { createServer } from "node:http"; | |
| 199 | const expected = "Basic " + Buffer.from("opencode:" + process.env.OPENCODE_PASSWORD).toString("base64"); | |
| 200 | const server = createServer((request, response) => { | |
| 201 | if (request.headers.authorization !== expected) { | |
| 202 | response.writeHead(401, { "content-type": "application/json", "www-authenticate": "Basic" }); | |
| 203 | response.end(${JSON.stringify(UNAUTHORIZED_BODY)}); | |
| 204 | return; | |
| 205 | } | |
| 206 | response.writeHead(200, { "content-type": "application/json" }); | |
| 207 | response.end(JSON.stringify({ version: "2.0.18", pid: process.pid, urls: [], paths: { tmp: "/tmp" } })); | |
| 208 | }); | |
| 209 | server.listen(0, "127.0.0.1", () => { | |
| 210 | process.stdout.write("server listening on http://127.0.0.1:" + server.address().port + "\\n"); | |
| 211 | process.stdout.write("server password not-the-t3-password\\n"); | |
| 212 | }); | |
| 213 | `; | |
| 214 | ||
| 215 | describe("OpenCode2Server spawned server", () => { | |
| 216 | it.live( | |
| 217 | "is ready once /api/info accepts the generated password", | |
| 218 | () => | |
| 219 | Effect.gen(function* () { | |
| 220 | const fs = yield* FileSystem.FileSystem; | |
| 221 | const path = yield* Path.Path; | |
| 222 | const platform = yield* HostProcessPlatform; | |
| 223 | const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode2-fake-" }); | |
| 224 | const isWindows = platform === "win32"; | |
| 225 | const binaryPath = path.join(directory, isWindows ? "opencode.cmd" : "opencode"); | |
| 226 | const scriptPath = path.join(directory, "opencode.mjs"); | |
| 227 | yield* fs.writeFileString(scriptPath, FAKE_SERVER); | |
| 228 | yield* fs.writeFileString( | |
| 229 | binaryPath, | |
| 230 | isWindows | |
| 231 | ? `@echo off\r\n"${yield* HostProcessExecutablePath}" "${scriptPath}" %*\r\n` | |
| 232 | : `#!/bin/sh\nexec "${yield* HostProcessExecutablePath}" "${scriptPath}" "$@"\n`, | |
| 233 | ); | |
| 234 | if (!isWindows) yield* fs.chmod(binaryPath, 0o755); | |
| 235 | ||
| 236 | const server = yield* OpenCode2Server.make({ | |
| 237 | binaryPath, | |
| 238 | serverUrl: "", | |
| 239 | serverPassword: "", | |
| 240 | directory, | |
| 241 | environment: { ...(yield* HostProcessEnvironment), OPENCODE_SERVER_PASSWORD: "ambient" }, | |
| 242 | }); | |
| 243 | const first = yield* server.withConnection((connection) => Effect.succeed(connection)); | |
| 244 | const second = yield* server.withConnection((connection) => Effect.succeed(connection)); | |
| 245 | assert.strictEqual(first.version, "2.0.18"); | |
| 246 | assert.strictEqual(first.external, false); | |
| 247 | assert.strictEqual(second.client, first.client); | |
| 248 | }).pipe( | |
| 249 | Effect.scoped, | |
| 250 | Effect.provide( | |
| 251 | Layer.mergeAll( | |
| 252 | OpenCode2Client.layer, | |
| 253 | OpenCodeRuntime.layer.pipe(Layer.provide(OpenCodeServerLedger.layerTest)), | |
| 254 | ).pipe(Layer.provideMerge(Layer.mergeAll(NodeServices.layer, FetchHttpClient.layer))), | |
| 255 | ), | |
| 256 | ), | |
| 257 | 15_000, | |
| 258 | ); | |
| 259 | }); |
packages/provider-opencode/src/server/v2/OpenCode2Server.ts created+198| ... | ... | @@ -0,0 +1,198 @@ |
| 1 | /** | |
| 2 | * The OpenCode 2 server behind one provider instance. A 2.x server serves every | |
| 3 | * location from one process, so an instance shares one server across all of its | |
| 4 | * threads and directories. | |
| 5 | * | |
| 6 | * @module provider/opencode2/OpenCode2Server | |
| 7 | */ | |
| 8 | import type { OpenCodeClient } from "@opencode/client/effect"; | |
| 9 | import * as Context from "effect/Context"; | |
| 10 | import * as Crypto from "effect/Crypto"; | |
| 11 | import * as Effect from "effect/Effect"; | |
| 12 | import * as Base64Url from "effect/encoding/Base64Url"; | |
| 13 | import * as Layer from "effect/Layer"; | |
| 14 | import * as P from "effect/Predicate"; | |
| 15 | import * as Redacted from "effect/Redacted"; | |
| 16 | import * as HttpClientError from "effect/http/HttpClientError"; | |
| 17 | ||
| 18 | import * as OpenCodeRuntime from "../OpenCodeRuntime.ts"; | |
| 19 | import * as OpenCodeServerOwner from "../OpenCodeServerOwner.ts"; | |
| 20 | import * as OpenCode2Client from "./OpenCode2Client.ts"; | |
| 21 | ||
| 22 | const INFO_TIMEOUT = "5 seconds"; | |
| 23 | ||
| 24 | export interface OpenCode2Connection extends OpenCode2Client.OpenCode2Api { | |
| 25 | readonly url: string; | |
| 26 | readonly version: string; | |
| 27 | readonly external: boolean; | |
| 28 | } | |
| 29 | ||
| 30 | export class OpenCode2Server extends Context.Service< | |
| 31 | OpenCode2Server, | |
| 32 | { | |
| 33 | /** Runs `use` against the instance's server, spawning it first when T3 owns it. */ | |
| 34 | readonly withConnection: <A, E, R>( | |
| 35 | use: (connection: OpenCode2Connection) => Effect.Effect<A, E, R>, | |
| 36 | ) => Effect.Effect<A, E | OpenCodeRuntime.OpenCodeRuntimeError, R>; | |
| 37 | } | |
| 38 | >()("@t3tools/provider-opencode/server/v2/OpenCode2Server") {} | |
| 39 | ||
| 40 | /** | |
| 41 | * A fresh password for a spawned server. OpenCode 2 always requires one and | |
| 42 | * prints a generated one to stdout otherwise, so T3 supplies its own and keeps | |
| 43 | * it in memory. | |
| 44 | */ | |
| 45 | export const generatePassword = Effect.gen(function* () { | |
| 46 | const crypto = yield* Crypto.Crypto; | |
| 47 | const bytes = yield* crypto.randomBytes(32).pipe(Effect.orDie); | |
| 48 | return Redacted.make(Base64Url.encode(bytes), { label: "OPENCODE_PASSWORD" }); | |
| 49 | }); | |
| 50 | ||
| 51 | /** | |
| 52 | * The environment for a spawned 2.x server. `OPENCODE_PASSWORD` wins over | |
| 53 | * `OPENCODE_SERVER_PASSWORD` in OpenCode 2, so the inherited 1.x variable is | |
| 54 | * dropped to keep the T3 password the only one in play. | |
| 55 | */ | |
| 56 | export const serverEnvironment = ( | |
| 57 | environment: NodeJS.ProcessEnv, | |
| 58 | password: Redacted.Redacted, | |
| 59 | ): NodeJS.ProcessEnv => { | |
| 60 | const { OPENCODE_SERVER_PASSWORD: _inherited, ...rest } = environment; | |
| 61 | return { ...rest, OPENCODE_PASSWORD: Redacted.value(password) }; | |
| 62 | }; | |
| 63 | ||
| 64 | /** The client wraps HTTP failures in a `ClientError`; this unwraps either shape. */ | |
| 65 | const httpFailureOf = (cause: unknown): HttpClientError.HttpClientError | undefined => { | |
| 66 | if (HttpClientError.isHttpClientError(cause)) return cause; | |
| 67 | if ( | |
| 68 | P.isTagged(cause, "ClientError") && | |
| 69 | P.hasProperty(cause, "cause") && | |
| 70 | HttpClientError.isHttpClientError(cause.cause) | |
| 71 | ) { | |
| 72 | return cause.cause; | |
| 73 | } | |
| 74 | return undefined; | |
| 75 | }; | |
| 76 | ||
| 77 | /** | |
| 78 | * Describes a failed `/api/info` call. Only a transport failure means the | |
| 79 | * server is unreachable; a 401 without the 2.x error body is still a rejected | |
| 80 | * password (1.x sends it empty), and any other failed status is a server error. | |
| 81 | * A 2xx the client cannot decode is not OpenCode 2 (1.x answers with HTML). | |
| 82 | */ | |
| 83 | const describeInfoFailure = (cause: unknown) => { | |
| 84 | const failure = httpFailureOf(cause); | |
| 85 | if (failure?.reason._tag === "TransportError") { | |
| 86 | return "Could not reach the OpenCode server."; | |
| 87 | } | |
| 88 | const status = failure?.response?.status; | |
| 89 | if (status === 401) return "The OpenCode server rejected the server password."; | |
| 90 | if (status !== undefined && (status < 200 || status >= 300)) { | |
| 91 | return `The OpenCode server returned HTTP ${status}.`; | |
| 92 | } | |
| 93 | return "The server is not an OpenCode 2 server."; | |
| 94 | }; | |
| 95 | ||
| 96 | /** | |
| 97 | * Confirms a server is an authenticated OpenCode 2 server through `/api/info` | |
| 98 | * and returns its version. `/health` and friends answer 200 with the web UI's | |
| 99 | * HTML on every version, so only this endpoint proves readiness. Details are | |
| 100 | * fixed text because they reach clients and a `serverUrl` can carry | |
| 101 | * credentials; the underlying failure stays in `cause`. | |
| 102 | */ | |
| 103 | export const verifyServer = (client: OpenCodeClient) => | |
| 104 | client.server.info().pipe( | |
| 105 | Effect.timeoutOrElse({ | |
| 106 | duration: INFO_TIMEOUT, | |
| 107 | orElse: () => | |
| 108 | Effect.fail( | |
| 109 | new OpenCodeRuntime.OpenCodeRuntimeError({ | |
| 110 | operation: "server.info", | |
| 111 | detail: "Timed out waiting for the OpenCode server.", | |
| 112 | }), | |
| 113 | ), | |
| 114 | }), | |
| 115 | Effect.catchTags({ | |
| 116 | UnauthorizedError: (cause) => | |
| 117 | Effect.fail( | |
| 118 | new OpenCodeRuntime.OpenCodeRuntimeError({ | |
| 119 | operation: "server.info", | |
| 120 | detail: "The OpenCode server rejected the server password.", | |
| 121 | cause, | |
| 122 | }), | |
| 123 | ), | |
| 124 | }), | |
| 125 | Effect.mapError((cause) => | |
| 126 | OpenCodeRuntime.OpenCodeRuntimeError.is(cause) | |
| 127 | ? cause | |
| 128 | : new OpenCodeRuntime.OpenCodeRuntimeError({ | |
| 129 | operation: "server.info", | |
| 130 | detail: describeInfoFailure(cause), | |
| 131 | cause, | |
| 132 | }), | |
| 133 | ), | |
| 134 | Effect.map((info) => info.version), | |
| 135 | ); | |
| 136 | ||
| 137 | /** | |
| 138 | * One server per provider instance. With a `serverUrl` it connects to that | |
| 139 | * server with the configured password; otherwise it spawns `binaryPath serve` | |
| 140 | * with a generated password through {@link OpenCodeServerOwner}, which shares | |
| 141 | * the process between borrowers and stops it after an idle period. Clients are | |
| 142 | * built once per server; a failed check is not remembered. | |
| 143 | */ | |
| 144 | export const make = Effect.fn("OpenCode2Server.make")(function* (input: { | |
| 145 | readonly binaryPath: string; | |
| 146 | readonly serverUrl: string; | |
| 147 | readonly serverPassword: string; | |
| 148 | readonly directory: string; | |
| 149 | readonly environment: NodeJS.ProcessEnv; | |
| 150 | }) { | |
| 151 | const opencode = yield* OpenCode2Client.OpenCode2Client; | |
| 152 | const connectTo = (url: string, password: Redacted.Redacted, external: boolean) => | |
| 153 | Effect.gen(function* () { | |
| 154 | const api = yield* opencode.connect({ baseUrl: url, password }); | |
| 155 | const version = yield* verifyServer(api.client); | |
| 156 | return { ...api, url, version, external } satisfies OpenCode2Connection; | |
| 157 | }); | |
| 158 | let latest: OpenCode2Connection | undefined; | |
| 159 | const remember = (connection: OpenCode2Connection) => | |
| 160 | Effect.sync(() => { | |
| 161 | latest = connection; | |
| 162 | return connection.version; | |
| 163 | }); | |
| 164 | ||
| 165 | const serverUrl = input.serverUrl.trim(); | |
| 166 | if (serverUrl.length > 0) { | |
| 167 | const connect = connectTo(serverUrl, Redacted.make(input.serverPassword), true).pipe( | |
| 168 | Effect.tap(remember), | |
| 169 | ); | |
| 170 | return OpenCode2Server.of({ | |
| 171 | withConnection: (use) => | |
| 172 | Effect.suspend(() => (latest === undefined ? connect : Effect.succeed(latest))).pipe( | |
| 173 | Effect.flatMap(use), | |
| 174 | ), | |
| 175 | }); | |
| 176 | } | |
| 177 | ||
| 178 | const password = yield* generatePassword; | |
| 179 | const owner = yield* OpenCodeServerOwner.make({ | |
| 180 | binaryPath: input.binaryPath, | |
| 181 | directory: input.directory, | |
| 182 | environment: serverEnvironment(input.environment, password), | |
| 183 | verify: (url) => connectTo(url, password, false).pipe(Effect.flatMap(remember)), | |
| 184 | }); | |
| 185 | return OpenCode2Server.of({ | |
| 186 | withConnection: (use) => | |
| 187 | owner.withServer((server) => | |
| 188 | // The owner verifies every server it starts before lending it out. | |
| 189 | latest?.url === server.url | |
| 190 | ? use(latest) | |
| 191 | : Effect.die(new Error("OpenCode 2 server was lent before verification.")), | |
| 192 | ), | |
| 193 | }); | |
| 194 | }); | |
| 195 | ||
| 196 | /** Built once per provider instance from its settings; closing it stops a spawned server. */ | |
| 197 | export const layer = (input: Parameters<typeof make>[0]) => | |
| 198 | Layer.effect(OpenCode2Server, make(input)); |
packages/provider-opencode/src/server/v2/adapter.ts created+4290| ... | ... | @@ -0,0 +1,4290 @@ |
| 1 | /** | |
| 2 | * The OpenCode 2 runtime behind the `opencode` driver. It talks to the | |
| 3 | * instance's `opencode serve` process through the HTTP client and reads that | |
| 4 | * server's `/api/event` stream, routed here by session id. | |
| 5 | * | |
| 6 | * A turn is one `session.prompt` (or `session.command`, or `session.compact` | |
| 7 | * for `/compact`); the session's next `session.execution.*` terminal ends it. | |
| 8 | * A steer is another prompt with `delivery: "steer"`, which OpenCode reads at | |
| 9 | * the running execution's next step boundary, so it ends with the turn it | |
| 10 | * joined. Fork and rollback cut the native history before the user message a | |
| 11 | * turn prompted with (its `nativeTurnRef`). Each runtime mode is a set of | |
| 12 | * session permission rules, plan mode is also OpenCode's `plan` agent | |
| 13 | * (switched before the prompt like the model), and OpenCode's permission asks | |
| 14 | * and question forms become runtime requests on the asking session's thread, a | |
| 15 | * subagent's on its parent's. | |
| 16 | * | |
| 17 | * A `subagent` call runs in a child session shown as a subagent thread. A | |
| 18 | * background one outlives its turn; when it ends, OpenCode starts a parent | |
| 19 | * execution T3 did not ask for, which waits here for the continuation turn T3 | |
| 20 | * opens for it. | |
| 21 | * | |
| 22 | * @module orchestration-v2/Adapters/OpenCode2AdapterV2 | |
| 23 | */ | |
| 24 | ||
| 25 | import { | |
| 26 | AbsolutePath, | |
| 27 | Agent, | |
| 28 | Form, | |
| 29 | Location, | |
| 30 | Model, | |
| 31 | Permission, | |
| 32 | Provider, | |
| 33 | Session, | |
| 34 | SessionMessage, | |
| 35 | Skill, | |
| 36 | type OpenCodeEvent, | |
| 37 | } from "@opencode/client/effect"; | |
| 38 | import { Mcp } from "@opencode/schema/mcp"; | |
| 39 | import { | |
| 40 | isOrchestrationV2WorkActive, | |
| 41 | type OrchestrationV2AppThread, | |
| 42 | type OrchestrationV2ConversationMessage, | |
| 43 | type OrchestrationV2ExecutionNode, | |
| 44 | type OrchestrationV2ProviderCapabilities, | |
| 45 | type OrchestrationV2ProviderSession, | |
| 46 | type OrchestrationV2ProviderThread, | |
| 47 | type OrchestrationV2ProviderTurn, | |
| 48 | type OrchestrationV2RuntimeRequest, | |
| 49 | type OrchestrationV2Subagent, | |
| 50 | type OrchestrationV2TurnItem, | |
| 51 | type OrchestrationV2UserInputQuestion, | |
| 52 | type ModelSelection, | |
| 53 | type ProviderApprovalDecision, | |
| 54 | type ProviderInstanceId, | |
| 55 | type RunId, | |
| 56 | type RuntimeRequestId, | |
| 57 | } from "@t3tools/contracts"; | |
| 58 | import type * as Cause from "effect/Cause"; | |
| 59 | import * as DateTime from "effect/DateTime"; | |
| 60 | import * as Deferred from "effect/Deferred"; | |
| 61 | import * as Crypto from "effect/Crypto"; | |
| 62 | import * as Effect from "effect/Effect"; | |
| 63 | import * as Hex from "effect/encoding/Hex"; | |
| 64 | import * as Exit from "effect/Exit"; | |
| 65 | import * as Option from "effect/Option"; | |
| 66 | import * as Queue from "effect/Queue"; | |
| 67 | import * as Schedule from "effect/Schedule"; | |
| 68 | import * as Scope from "effect/Scope"; | |
| 69 | import * as Schema from "effect/Schema"; | |
| 70 | import * as Semaphore from "effect/Semaphore"; | |
| 71 | import * as Stream from "effect/Stream"; | |
| 72 | ||
| 73 | import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; | |
| 74 | import * as OpenCode2Client from "./OpenCode2Client.ts"; | |
| 75 | import * as OpenCode2Server from "./OpenCode2Server.ts"; | |
| 76 | import * as OpenCodeRuntime from "../OpenCodeRuntime.ts"; | |
| 77 | import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; | |
| 78 | import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions"; | |
| 79 | import { t3OrchestrationSystemPrompt } from "@t3tools/provider-core/server/orchestrationInstructions"; | |
| 80 | import { SKILL_MENTION_PATTERN } from "@t3tools/shared/composerInlineTokens"; | |
| 81 | import * as KeyedLock from "@t3tools/shared/KeyedLock"; | |
| 82 | import { getModelSelectionStringOptionValue, modelSelectionsEqual } from "@t3tools/shared/model"; | |
| 83 | import { causeErrorTag } from "@t3tools/shared/observability"; | |
| 84 | ||
| 85 | import { providerMessageTextWithAttachmentPaths } from "@t3tools/provider-core/server/attachmentPrompt"; | |
| 86 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; | |
| 87 | import { | |
| 88 | backgroundWorkNotification, | |
| 89 | type BackgroundWorkReport, | |
| 90 | } from "@t3tools/provider-core/server/notification"; | |
| 91 | import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; | |
| 92 | import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; | |
| 93 | import { | |
| 94 | makeSubagentChildThread, | |
| 95 | makeSubagentConversationArtifacts, | |
| 96 | subagentThreadTitle, | |
| 97 | } from "@t3tools/provider-core/server/subagentProjection"; | |
| 98 | import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; | |
| 99 | import { turnScopedSelectionTransition } from "@t3tools/provider-core/server/selectionTransition"; | |
| 100 | import { OPENCODE_PROVIDER, openCodePermissionRequestKind } from "../adapter.ts"; | |
| 101 | import { openCodeToolTurnItem } from "../toolItems.ts"; | |
| 102 | ||
| 103 | const OpenCode2ProviderCapabilities = { | |
| 104 | sessions: { | |
| 105 | // One server serves every location, so one session runtime owns them all. | |
| 106 | supportsMultipleProviderThreadsPerSession: true, | |
| 107 | supportsModelSwitchInSession: true, | |
| 108 | supportsProviderSwitchingViaHandoff: true, | |
| 109 | // A mode change rewrites the session's rules when its next turn resumes it. | |
| 110 | supportsRuntimeModeSwitchInSession: true, | |
| 111 | pendingRequestsSurviveRestart: false, | |
| 112 | }, | |
| 113 | threads: { | |
| 114 | canCreateEmptyThread: true, | |
| 115 | canReadThreadSnapshot: true, | |
| 116 | canRollbackThread: true, | |
| 117 | canForkThread: true, | |
| 118 | canForkFromTurn: true, | |
| 119 | canForkFromSubagentThread: false, | |
| 120 | exposesNativeThreadId: true, | |
| 121 | }, | |
| 122 | turns: { | |
| 123 | exposesNativeTurnId: false, | |
| 124 | emitsTurnStarted: true, | |
| 125 | emitsTurnCompleted: true, | |
| 126 | supportsInterrupt: true, | |
| 127 | supportsActiveSteering: true, | |
| 128 | supportsSteeringByInterruptRestart: true, | |
| 129 | // T3 holds queued messages and starts each as its own turn once the one | |
| 130 | // before it ends, so OpenCode's own `queue` delivery is never used. | |
| 131 | supportsQueuedMessages: true, | |
| 132 | terminalStatusQuality: "strong", | |
| 133 | }, | |
| 134 | streaming: { | |
| 135 | streamsAssistantText: true, | |
| 136 | streamsReasoning: true, | |
| 137 | streamsToolOutput: false, | |
| 138 | streamsPlanText: false, | |
| 139 | emitsMessageCompleted: true, | |
| 140 | }, | |
| 141 | tools: { | |
| 142 | exposesToolItemIds: true, | |
| 143 | emitsToolStarted: true, | |
| 144 | emitsToolCompleted: true, | |
| 145 | emitsToolOutput: true, | |
| 146 | supportsMcpTools: false, | |
| 147 | supportsDynamicToolCallbacks: false, | |
| 148 | }, | |
| 149 | approvals: { | |
| 150 | supportsCommandApproval: true, | |
| 151 | supportsFileReadApproval: true, | |
| 152 | supportsFileChangeApproval: true, | |
| 153 | supportsApplyPatchApproval: true, | |
| 154 | approvalsHaveNativeRequestIds: true, | |
| 155 | approvalCallbacksAreLiveOnly: true, | |
| 156 | approvalsCanOriginateFromSubagents: true, | |
| 157 | }, | |
| 158 | planning: { | |
| 159 | emitsPlanUpdated: false, | |
| 160 | emitsTodoList: false, | |
| 161 | emitsProposedPlan: false, | |
| 162 | supportsStructuredQuestions: true, | |
| 163 | planDeltasHaveItemIds: false, | |
| 164 | }, | |
| 165 | subagents: { | |
| 166 | supportsSubagents: true, | |
| 167 | exposesSubagentThreadIds: true, | |
| 168 | emitsSubagentLifecycle: true, | |
| 169 | canWaitForSubagents: true, | |
| 170 | canCloseSubagents: false, | |
| 171 | canForkSubagentThread: false, | |
| 172 | }, | |
| 173 | context: { | |
| 174 | acceptsSystemContext: false, | |
| 175 | acceptsDeveloperContext: false, | |
| 176 | acceptsSyntheticUserContext: true, | |
| 177 | canGenerateSummaries: false, | |
| 178 | canConsumeHandoffSummaries: true, | |
| 179 | supportsDeltaHandoff: true, | |
| 180 | supportsFullThreadHandoff: true, | |
| 181 | maxRecommendedHandoffChars: null, | |
| 182 | }, | |
| 183 | checkpointing: { | |
| 184 | appCanCheckpointFilesystem: true, | |
| 185 | supportsNestedCheckpointScopes: true, | |
| 186 | providerCanRollbackConversation: true, | |
| 187 | providerRollbackReturnsSnapshot: true, | |
| 188 | providerCanReadConversationSnapshot: true, | |
| 189 | }, | |
| 190 | identity: { | |
| 191 | nativeThreadIds: "strong", | |
| 192 | nativeTurnIds: "weak", | |
| 193 | nativeItemIds: "strong", | |
| 194 | nativeRequestIds: "strong", | |
| 195 | }, | |
| 196 | // OpenCode enforces each runtime mode through the session's permission rules. | |
| 197 | runtimePolicy: { enforcement: "native" }, | |
| 198 | } satisfies OrchestrationV2ProviderCapabilities; | |
| 199 | ||
| 200 | type EventOf<T extends OpenCodeEvent["type"]> = Extract<OpenCodeEvent, { readonly type: T }>; | |
| 201 | type Tokens = EventOf<"session.step.ended">["data"]["tokens"]; | |
| 202 | ||
| 203 | /** What a turn reports against: a run's own turn, or a subagent session's runless one. */ | |
| 204 | interface TurnOwner { | |
| 205 | readonly appThread: OrchestrationV2AppThread; | |
| 206 | readonly threadId: OrchestrationV2AppThread["id"]; | |
| 207 | readonly runId: RunId | null; | |
| 208 | readonly runOrdinal: number; | |
| 209 | readonly rootNodeId: ProviderAdapter.ProviderAdapterV2TurnInput["rootNodeId"]; | |
| 210 | readonly modelSelection: ModelSelection; | |
| 211 | /** Where the turn runs: a subagent runs in its parent turn's directory. */ | |
| 212 | readonly runtimePolicy: Pick<ProviderAdapter.ProviderAdapterV2RuntimePolicy, "cwd">; | |
| 213 | } | |
| 214 | ||
| 215 | interface ActiveTurn { | |
| 216 | readonly input: TurnOwner; | |
| 217 | providerTurn: OrchestrationV2ProviderTurn; | |
| 218 | /** Open text and reasoning blocks, keyed `<assistantMessageID>:<kind>:<ordinal>`. */ | |
| 219 | readonly texts: Map<string, OpenBlock>; | |
| 220 | readonly tools: Map<string, { readonly name: string; input: Record<string, unknown> }>; | |
| 221 | readonly startedAt: Map<string, DateTime.Utc>; | |
| 222 | readonly ordinals: Map<string, number>; | |
| 223 | nextOrdinal: number; | |
| 224 | /** Input includes cache reads and writes, as 1.x reports it; the parts are also kept apart. */ | |
| 225 | readonly usage: { | |
| 226 | input: number; | |
| 227 | cached: number; | |
| 228 | cacheWrite: number; | |
| 229 | output: number; | |
| 230 | reasoning: number; | |
| 231 | }; | |
| 232 | steps: number; | |
| 233 | lastStep: Tokens | undefined; | |
| 234 | /** | |
| 235 | * The history item this turn's execution follows, for a turn with no prompt | |
| 236 | * id of T3's: the session's newest item before a `/name` command (null when | |
| 237 | * the history was empty), since `session.command` takes no id and answers | |
| 238 | * without one; the report a continuation turn's execution answers; or, on a | |
| 239 | * subagent's session, the first item queued for its turn. A reconnect | |
| 240 | * backfills that turn from everything after this. Other turns backfill from | |
| 241 | * their own prompt's id (`nativeTurnRef`). | |
| 242 | */ | |
| 243 | before: string | null | undefined; | |
| 244 | /** The compaction running in this turn, `/compact` or OpenCode's own when the context fills. */ | |
| 245 | compaction: { readonly nativeId: string; readonly startedAt: DateTime.Utc } | undefined; | |
| 246 | compactions: number; | |
| 247 | interrupted: boolean; | |
| 248 | /** | |
| 249 | * Set until the turn's prompt, command or compaction is sent. A Stop before | |
| 250 | * then has nothing on the server to stop, so it ends the turn here and the | |
| 251 | * request is never sent. | |
| 252 | */ | |
| 253 | unsent: boolean; | |
| 254 | /** | |
| 255 | * Steers sent into this turn that OpenCode has not delivered yet, by inbox | |
| 256 | * id. Each one wakes the session, so an execution that ends before reading | |
| 257 | * one is followed by another that does, and the turn spans both. | |
| 258 | */ | |
| 259 | readonly steers: Set<string>; | |
| 260 | /** This turn's steers OpenCode delivered or cancelled: a retried one is not sent again. */ | |
| 261 | readonly settledInbox: Set<string>; | |
| 262 | /** How the last execution ended, while the turn waits for the one that delivers its steers. */ | |
| 263 | heldEnd: TurnTerminal | undefined; | |
| 264 | /** | |
| 265 | * Set on a turn started after a timed-out Stop's run left the server: that | |
| 266 | * run's tail can still be on the stream, and everything before this turn's | |
| 267 | * own `session.execution.started` belongs to it. | |
| 268 | */ | |
| 269 | awaitingStart: boolean; | |
| 270 | /** | |
| 271 | * Prefixes a subagent session's tool ids in native item ids: the model | |
| 272 | * names tool calls, so a child's could repeat its parent's. | |
| 273 | */ | |
| 274 | readonly scope: string; | |
| 275 | /** Set once the turn calls `subagent`: its usage then leaves out the subagents'. */ | |
| 276 | usedSubagents: boolean; | |
| 277 | } | |
| 278 | ||
| 279 | /** A `subagent` tool call. A background one outlives the turn that made it. */ | |
| 280 | interface SubagentCall { | |
| 281 | readonly toolId: string; | |
| 282 | readonly nativeId: string; | |
| 283 | /** The calling session and the turn whose item the call is. */ | |
| 284 | readonly state: ThreadState; | |
| 285 | readonly turn: ActiveTurn; | |
| 286 | readonly ordinal: number; | |
| 287 | readonly startedAt: DateTime.Utc; | |
| 288 | prompt: string; | |
| 289 | title: string | null; | |
| 290 | agent: string | undefined; | |
| 291 | model: string | null; | |
| 292 | /** The tool returned while the subagent runs on; the report OpenCode gives its parent settles it. */ | |
| 293 | background: boolean; | |
| 294 | child: ThreadState | undefined; | |
| 295 | status: OrchestrationV2Subagent["status"]; | |
| 296 | result: string | null; | |
| 297 | completedAt: DateTime.Utc | null; | |
| 298 | } | |
| 299 | ||
| 300 | /** | |
| 301 | * An execution OpenCode ran on a thread's session without T3 asking: the | |
| 302 | * parent's answer once a background subagent ended. Its events are held until | |
| 303 | * the continuation turn T3 opens for it takes them. | |
| 304 | */ | |
| 305 | interface Wake { | |
| 306 | readonly events: Array<OpenCode2Client.OpenCode2StreamEvent>; | |
| 307 | running: boolean; | |
| 308 | /** The background subagents whose end it answers, as its turn's notification names them. */ | |
| 309 | readonly reports: Array<BackgroundWorkReport>; | |
| 310 | /** What OpenCode told the model, the continuation's prompt text. */ | |
| 311 | readonly detail: string | null; | |
| 312 | /** Stopped, or taken by a user turn: the continuation it asked for is not needed. */ | |
| 313 | dropped: boolean; | |
| 314 | /** The last report it delivered (inbox ids are history ids): its execution follows it. */ | |
| 315 | readonly after: string | undefined; | |
| 316 | /** The first report it delivered, where fork and rollback cut before its turn. */ | |
| 317 | readonly first: string | undefined; | |
| 318 | } | |
| 319 | ||
| 320 | interface OpenBlock { | |
| 321 | readonly block: { readonly assistantMessageID: string; readonly ordinal: number }; | |
| 322 | readonly kind: "text" | "reasoning"; | |
| 323 | readonly startedAt: DateTime.Utc; | |
| 324 | text: string; | |
| 325 | } | |
| 326 | ||
| 327 | interface ThreadState { | |
| 328 | readonly sessionId: string; | |
| 329 | providerThread: OrchestrationV2ProviderThread; | |
| 330 | readonly providerTurns: Map<string, OrchestrationV2ProviderTurn>; | |
| 331 | active: ActiveTurn | undefined; | |
| 332 | /** What the native session runs now, so a changed selection is switched before prompting. */ | |
| 333 | model: ModelRef | undefined; | |
| 334 | /** | |
| 335 | * Set when a turn ended here while OpenCode may still be running it: a Stop | |
| 336 | * that timed out, a prompt whose request failed without a clear answer, or a | |
| 337 | * request T3 could not answer. Execution events carry only the session id, | |
| 338 | * so the next execution end belongs to that run; it clears this and ends no turn. | |
| 339 | */ | |
| 340 | unsettled: boolean; | |
| 341 | /** The session's location, where its agents' path rules are read. */ | |
| 342 | directory: string; | |
| 343 | /** | |
| 344 | * The agent the session runs (`build`, or `plan` in plan mode); its own path | |
| 345 | * rules stay in force, and a changed mode switches it before prompting. | |
| 346 | */ | |
| 347 | agent: string; | |
| 348 | /** The native session's rules as T3 last read or wrote them, and the policy they are for. */ | |
| 349 | rules: ReadonlyArray<Rule> | undefined; | |
| 350 | policy: RulesPolicy; | |
| 351 | /** "Allow … this session" answers, kept in the session's rules while T3 has it open. */ | |
| 352 | readonly grants: Array<Rule>; | |
| 353 | /** The session's `subagent` calls still running, by tool call id. */ | |
| 354 | readonly calls: Map<string, SubagentCall>; | |
| 355 | /** | |
| 356 | * Set on a subagent's session: the call that runs it and the thread it | |
| 357 | * shows in. Each of its executions is a runless turn there. | |
| 358 | */ | |
| 359 | subagent: | |
| 360 | | { | |
| 361 | call: SubagentCall; | |
| 362 | readonly appThread: OrchestrationV2AppThread; | |
| 363 | turns: number; | |
| 364 | /** The prompt its next execution answers, shown as that turn's user message. */ | |
| 365 | prompt: string | undefined; | |
| 366 | /** | |
| 367 | * The first item queued for the session since its last turn began (its | |
| 368 | * prompt, or a report it answers): where the next turn's history | |
| 369 | * begins, which a reconnect reads back. | |
| 370 | */ | |
| 371 | queued: string | undefined; | |
| 372 | } | |
| 373 | | undefined; | |
| 374 | /** Executions OpenCode started on its own, oldest first, each waiting for its turn. */ | |
| 375 | readonly wakes: Array<Wake>; | |
| 376 | /** | |
| 377 | * Background subagents whose end OpenCode queued for this session and has | |
| 378 | * not delivered yet, by inbox id. The wake that delivers them names them. | |
| 379 | */ | |
| 380 | readonly reports: Map< | |
| 381 | string, | |
| 382 | { | |
| 383 | readonly inboxId: string; | |
| 384 | readonly childId: string; | |
| 385 | readonly report: BackgroundWorkReport; | |
| 386 | readonly text: string; | |
| 387 | } | |
| 388 | >; | |
| 389 | /** | |
| 390 | * Background subagent sessions T3 stopped. OpenCode wakes the parent to | |
| 391 | * report them; a wake that reports only these is stopped as well. | |
| 392 | */ | |
| 393 | readonly stoppedChildren: Set<string>; | |
| 394 | /** | |
| 395 | * Steers a turn ended without (a Stop, a failure). They stay in OpenCode's | |
| 396 | * inbox, where the next prompt would deliver them first, so it cancels them. | |
| 397 | */ | |
| 398 | readonly strandedSteers: Set<string>; | |
| 399 | /** T3's MCP server as registered for this thread, and the instructions entry sent with it. */ | |
| 400 | mcp: | |
| 401 | | { readonly name: string; readonly directory: string; readonly credential: string } | |
| 402 | | undefined; | |
| 403 | instructions: string | undefined; | |
| 404 | } | |
| 405 | ||
| 406 | type TurnTerminal = | |
| 407 | | { readonly status: "completed" | "interrupted" } | |
| 408 | | { readonly status: "failed"; readonly failure: ReturnType<typeof makeProviderFailure> }; | |
| 409 | ||
| 410 | type Rule = Permission.Rule; | |
| 411 | type RulesPolicy = Pick< | |
| 412 | ProviderAdapter.ProviderAdapterV2RuntimePolicy, | |
| 413 | "runtimeMode" | "interactionMode" | |
| 414 | >; | |
| 415 | type NativeForm = EventOf<"form.created">["data"]["form"]; | |
| 416 | ||
| 417 | /** A permission ask or question form shown to the user and not answered yet. */ | |
| 418 | interface PendingRequest { | |
| 419 | readonly request: OrchestrationV2RuntimeRequest; | |
| 420 | readonly item: OrchestrationV2TurnItem; | |
| 421 | readonly node: OrchestrationV2ExecutionNode; | |
| 422 | /** The thread the request is shown on: the asking session's, or its parent's. */ | |
| 423 | readonly state: ThreadState; | |
| 424 | readonly turn: ActiveTurn; | |
| 425 | /** The session that asked: the thread's own, or one of its subagents'. */ | |
| 426 | readonly sessionId: string; | |
| 427 | /** Set once T3 sends its answer; the orchestrator has already recorded it. */ | |
| 428 | answering: boolean; | |
| 429 | readonly native: | |
| 430 | | { | |
| 431 | readonly type: "permission"; | |
| 432 | readonly id: string; | |
| 433 | readonly action: string; | |
| 434 | readonly resources: ReadonlyArray<string>; | |
| 435 | readonly save: ReadonlyArray<string>; | |
| 436 | } | |
| 437 | | { readonly type: "form"; readonly id: string; readonly form: NativeForm }; | |
| 438 | } | |
| 439 | ||
| 440 | const rule = (action: string, effect: Rule["effect"]): Rule => ({ action, resource: "*", effect }); | |
| 441 | ||
| 442 | /** | |
| 443 | * A session's permission rules. OpenCode checks the agent's rules and then | |
| 444 | * these, and the last rule that matches decides, so these override the | |
| 445 | * agent's. `paths` are the agent's own allows for its directories (saved tool | |
| 446 | * output, the plan agent's plan directory), which the blanket rules here would | |
| 447 | * otherwise override; `grants` are "Always allow this session" answers. | |
| 448 | */ | |
| 449 | /** | |
| 450 | * T3's MCP server is registered per directory, not per session, so each thread | |
| 451 | * gets its own `t3-code-<thread>` entry with its own credential. OpenCode names | |
| 452 | * an MCP tool's permission `<server>_<tool>` (non-alphanumerics become `_`). | |
| 453 | * OpenCode skips every tool of a server whose name is over 64 characters (its | |
| 454 | * tool namespace limit), and its router rejects adding one over 100, so a | |
| 455 | * thread id that does not fit is replaced by a digest of it. | |
| 456 | */ | |
| 457 | export const t3McpServerName = Effect.fn("t3McpServerName")(function* (threadId: string) { | |
| 458 | const name = `t3-code-${threadId.replaceAll(/[^a-zA-Z0-9_-]/g, "_")}`; | |
| 459 | if (name.length <= 64) return name; | |
| 460 | const crypto = yield* Crypto.Crypto; | |
| 461 | const digest = yield* crypto | |
| 462 | .digest("SHA-256", new TextEncoder().encode(threadId)) | |
| 463 | .pipe(Effect.orDie); | |
| 464 | return `t3-code-${Hex.encode(digest).slice(0, 16)}`; | |
| 465 | }); | |
| 466 | ||
| 467 | /** | |
| 468 | * The rules that keep T3's MCP servers to their own thread, after the mode's: | |
| 469 | * the last matching rule wins, so every thread's T3 server is denied and then | |
| 470 | * this thread's own is allowed again, in every mode. A subagent's session | |
| 471 | * inherits the thread's. | |
| 472 | */ | |
| 473 | const mcpRules = (mcpServerName: string | null): ReadonlyArray<Rule> => | |
| 474 | mcpServerName === null | |
| 475 | ? [] | |
| 476 | : [ | |
| 477 | { action: "t3-code-*", resource: "*", effect: "deny" }, | |
| 478 | { action: `${mcpServerName}_*`, resource: "*", effect: "allow" }, | |
| 479 | ]; | |
| 480 | ||
| 481 | const sessionRules = ( | |
| 482 | policy: RulesPolicy, | |
| 483 | paths: ReadonlyArray<Rule>, | |
| 484 | grants: ReadonlyArray<Rule>, | |
| 485 | mcpServerName: string | null, | |
| 486 | ): ReadonlyArray<Rule> => [ | |
| 487 | ...(policy.runtimeMode === "full-access" | |
| 488 | ? [rule("*", "allow")] | |
| 489 | : [ | |
| 490 | rule("shell", "ask"), | |
| 491 | rule("edit", policy.runtimeMode === "auto-accept-edits" ? "allow" : "ask"), | |
| 492 | rule("external_directory", "ask"), | |
| 493 | ]), | |
| 494 | ...grants, | |
| 495 | // Plan mode writes only its plan, which `paths` allows again. Shell and read | |
| 496 | // are never denied: the free tier refuses sessions whose rules deny them. | |
| 497 | ...(policy.interactionMode === "plan" ? [rule("edit", "deny")] : []), | |
| 498 | ...paths, | |
| 499 | ...mcpRules(mcpServerName), | |
| 500 | ]; | |
| 501 | ||
| 502 | const sameRules = (left: ReadonlyArray<Rule> | undefined, right: ReadonlyArray<Rule>) => | |
| 503 | left?.length === right.length && | |
| 504 | left.every( | |
| 505 | (entry, index) => | |
| 506 | entry.action === right[index]?.action && | |
| 507 | entry.resource === right[index]?.resource && | |
| 508 | entry.effect === right[index]?.effect, | |
| 509 | ); | |
| 510 | ||
| 511 | /** | |
| 512 | * Sent with a declined permission. A reject without a message is OpenCode's | |
| 513 | * "stop": it ends the whole execution, which is Cancel. | |
| 514 | */ | |
| 515 | const DECLINED = "The user declined this request."; | |
| 516 | ||
| 517 | /** | |
| 518 | * Steered into the session before a decline: a declined shell call reaches the | |
| 519 | * model only as "Unable to execute command", which it retries. | |
| 520 | */ | |
| 521 | const declinedNote = (action: string, resources: ReadonlyArray<string>) => | |
| 522 | `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.`; | |
| 523 | ||
| 524 | /** The session-wide choice for a request whose `save` patterns OpenCode would remember. */ | |
| 525 | const sessionGrantLabel = (action: string, save: ReadonlyArray<string>) => | |
| 526 | save.every((pattern) => pattern === "*") | |
| 527 | ? `Allow every ${action} request this session` | |
| 528 | : `Allow ${save.join(", ")} this session`; | |
| 529 | ||
| 530 | const text = (value: string | undefined, fallback: string) => value?.trim() || fallback; | |
| 531 | ||
| 532 | /** | |
| 533 | * A form's fields as T3 questions, or why T3 cannot ask them: a link to open, | |
| 534 | * a field shown only for another answer, a hidden field, or a number or yes/no | |
| 535 | * value. OpenCode's question tool only asks text and multi-select fields. | |
| 536 | */ | |
| 537 | const formQuestions = ( | |
| 538 | form: NativeForm, | |
| 539 | ): | |
| 540 | | { readonly questions: ReadonlyArray<OrchestrationV2UserInputQuestion> } | |
| 541 | | { readonly unsupported: string } => { | |
| 542 | const questions: Array<OrchestrationV2UserInputQuestion> = []; | |
| 543 | for (const [index, field] of form.fields.entries()) { | |
| 544 | if (field.type === "external") return { unsupported: "a link to open" }; | |
| 545 | if (field.type !== "string" && field.type !== "multiselect") { | |
| 546 | return { unsupported: `a ${field.type} value` }; | |
| 547 | } | |
| 548 | if (field.hidden === true) return { unsupported: "a hidden field" }; | |
| 549 | if ((field.when?.length ?? 0) > 0) return { unsupported: "a field that depends on another" }; | |
| 550 | const header = text(field.title, `Question ${index + 1}`); | |
| 551 | const options = (field.options ?? []).map((option) => { | |
| 552 | const label = text(option.label, text(option.value, "Option")); | |
| 553 | return { label, description: text(option.description, label), value: option.value }; | |
| 554 | }); | |
| 555 | questions.push({ | |
| 556 | id: field.key, | |
| 557 | header, | |
| 558 | question: text(field.description, header), | |
| 559 | options, | |
| 560 | multiSelect: field.type === "multiselect", | |
| 561 | allowCustomAnswer: field.custom === true || options.length === 0, | |
| 562 | }); | |
| 563 | } | |
| 564 | return { questions }; | |
| 565 | }; | |
| 566 | ||
| 567 | /** T3's answers in OpenCode's shape: a list for a multi-select, text otherwise. */ | |
| 568 | const formAnswer = (form: NativeForm, answers: Readonly<Record<string, unknown>>) => { | |
| 569 | const answer: Record<string, string | ReadonlyArray<string>> = {}; | |
| 570 | for (const field of form.fields) { | |
| 571 | const raw = answers[field.key]; | |
| 572 | const values = (Array.isArray(raw) ? raw : [raw]).filter( | |
| 573 | (value): value is string => typeof value === "string" && value.trim().length > 0, | |
| 574 | ); | |
| 575 | if (values.length > 0) { | |
| 576 | answer[field.key] = field.type === "multiselect" ? values : values.join(", "); | |
| 577 | } | |
| 578 | } | |
| 579 | return answer; | |
| 580 | }; | |
| 581 | ||
| 582 | const approves = (decision: ProviderApprovalDecision) => | |
| 583 | decision === "accept" || decision === "acceptForSession" || decision === "acceptAlways"; | |
| 584 | ||
| 585 | /** | |
| 586 | * An answer to a request OpenCode already dropped (its execution ended, or | |
| 587 | * its session is gone): nothing waits on it, so it counts as delivered. | |
| 588 | */ | |
| 589 | const permissionGone = { | |
| 590 | PermissionNotFoundError: () => Effect.void, | |
| 591 | SessionNotFoundError: () => Effect.void, | |
| 592 | }; | |
| 593 | const formGone = { | |
| 594 | FormNotFoundError: () => Effect.void, | |
| 595 | FormAlreadySettledError: () => Effect.void, | |
| 596 | SessionNotFoundError: () => Effect.void, | |
| 597 | }; | |
| 598 | ||
| 599 | /** The session rules an agent keeps for its own directories, which T3's blanket rules would override. */ | |
| 600 | const agentPaths = (rules: ReadonlyArray<Rule>) => | |
| 601 | rules.filter( | |
| 602 | (entry) => | |
| 603 | entry.effect === "allow" && | |
| 604 | entry.resource !== "*" && | |
| 605 | (entry.action === "edit" || entry.action === "external_directory"), | |
| 606 | ); | |
| 607 | ||
| 608 | const ref = (nativeId: string, strength: "strong" | "weak" = "strong") => ({ | |
| 609 | driver: OPENCODE_PROVIDER, | |
| 610 | nativeId, | |
| 611 | strength, | |
| 612 | }); | |
| 613 | ||
| 614 | const sessionIdOf = (providerThread: OrchestrationV2ProviderThread) => { | |
| 615 | const nativeId = providerThread.nativeThreadRef?.nativeId; | |
| 616 | return nativeId === undefined || nativeId === null | |
| 617 | ? Effect.fail( | |
| 618 | new ProviderAdapter.ProviderAdapterProtocolError({ | |
| 619 | driver: OPENCODE_PROVIDER, | |
| 620 | detail: `Provider thread ${providerThread.id} has no OpenCode session`, | |
| 621 | }), | |
| 622 | ) | |
| 623 | : Effect.succeed(nativeId); | |
| 624 | }; | |
| 625 | ||
| 626 | const textOf = (content: ReadonlyArray<{ readonly type: string; readonly text?: string }>) => | |
| 627 | content.flatMap((part) => (part.type === "text" && part.text ? [part.text] : [])).join("\n"); | |
| 628 | ||
| 629 | const stringField = (record: Readonly<Record<string, unknown>> | undefined, key: string) => { | |
| 630 | const value = record?.[key]; | |
| 631 | return typeof value === "string" && value.trim().length > 0 ? value : undefined; | |
| 632 | }; | |
| 633 | ||
| 634 | /** How a background subagent ended, from the `state` of OpenCode's report to its parent. */ | |
| 635 | const reportOutcome = (state: string | undefined) => | |
| 636 | state === "completed" | |
| 637 | ? ("completed" as const) | |
| 638 | : state === "error" | |
| 639 | ? ("failed" as const) | |
| 640 | : state === "cancelled" | |
| 641 | ? ("cancelled" as const) | |
| 642 | : ("unknown" as const); | |
| 643 | ||
| 644 | /** A subagent's answer, without the `<subagent …>` wrapper OpenCode gives the model. */ | |
| 645 | const subagentOutput = (text: string) => | |
| 646 | /^<subagent\b[^>]*>\n?([\s\S]*?)\n?<\/subagent>$/.exec(text.trim())?.[1] ?? text; | |
| 647 | ||
| 648 | const isContinuation = (turnInput: ProviderAdapter.ProviderAdapterV2TurnInput) => | |
| 649 | turnInput.message.createdBy === "agent" && turnInput.message.creationSource === "provider"; | |
| 650 | ||
| 651 | /** The native turn id of a continuation turn, which answers a wake and has no prompt. */ | |
| 652 | const wakeTurnId = (sessionId: string, attemptId: string) => `${sessionId}:wake:${attemptId}`; | |
| 653 | const isWakeTurn = (turn: OrchestrationV2ProviderTurn) => | |
| 654 | turn.nativeTurnRef?.nativeId?.includes(":wake:") === true; | |
| 655 | ||
| 656 | const INTERRUPT_TIMEOUT = "10 seconds"; | |
| 657 | /** The session instructions entry T3 writes its per-turn system prompt to. */ | |
| 658 | const INSTRUCTIONS_KEY = "t3-code"; | |
| 659 | /** A lost event stream is resubscribed this many times, this far apart, before the session breaks. */ | |
| 660 | const RECONNECT_ATTEMPTS = 5; | |
| 661 | const RECONNECT_DELAY = "2 seconds"; | |
| 662 | const RECONCILE_TIMEOUT = "15 seconds"; | |
| 663 | /** How long a new turn waits for a reconnect in progress. */ | |
| 664 | const RECONNECT_WAIT = "30 seconds"; | |
| 665 | /** A background subagent's result when its end was lost with the event stream. */ | |
| 666 | const LOST_BACKGROUND = | |
| 667 | "T3 Code lost its connection to OpenCode while this subagent ran, so its result is not shown."; | |
| 668 | /** How long a turn waits on the directory's commands or skills before sending the text as is. */ | |
| 669 | const INVENTORY_TIMEOUT = "5 seconds"; | |
| 670 | const ACTIVE_CHECK_TIMEOUT = "5 seconds"; | |
| 671 | /** Answers that mean the server refused a prompt; any other failure may have been accepted. */ | |
| 672 | const CLEAR_PROMPT_REJECTIONS: ReadonlySet<string> = new Set([ | |
| 673 | "InvalidRequestError", | |
| 674 | "ConflictError", | |
| 675 | "UnauthorizedError", | |
| 676 | "CommandNotFoundError", | |
| 677 | "SkillNotFoundError", | |
| 678 | ]); | |
| 679 | ||
| 680 | export const OPENCODE_2_STILL_STOPPING = | |
| 681 | "OpenCode is still stopping the previous turn. Send the message again in a moment."; | |
| 682 | const REQUEST_REPLY_TIMEOUT = "10 seconds"; | |
| 683 | ||
| 684 | /** Whether an answer to a paused request reached the server, trying twice. */ | |
| 685 | const deliver = <E>(answer: Effect.Effect<void, E>) => | |
| 686 | answer.pipe( | |
| 687 | Effect.retry({ times: 1 }), | |
| 688 | Effect.timeout(REQUEST_REPLY_TIMEOUT), | |
| 689 | Effect.exit, | |
| 690 | Effect.map(Exit.isSuccess), | |
| 691 | ); | |
| 692 | ||
| 693 | type ModelRef = ReturnType<typeof Model.Ref.make>; | |
| 694 | ||
| 695 | /** | |
| 696 | * The user message ids T3 prompts under. OpenCode takes a client id (it must | |
| 697 | * start with `msg_`) and answers a repeat of one in the same session with the | |
| 698 | * item it already has, so a retried request never queues a second message, | |
| 699 | * and a turn knows the message fork and rollback cut at before OpenCode | |
| 700 | * answers. The id is unique across the whole server, which refuses it in any | |
| 701 | * other session (409), so it names the session: another T3 database or | |
| 702 | * environment on the same server repeats thread ids and run ordinals, never | |
| 703 | * session ids. A turn keeps its id in `nativeTurnRef`. | |
| 704 | */ | |
| 705 | const turnPromptId = (sessionId: string, attemptId: string) => | |
| 706 | SessionMessage.ID.make(`msg_t3_turn_${sessionId}:${attemptId}`); | |
| 707 | const steerPromptId = (sessionId: string, messageId: string) => | |
| 708 | SessionMessage.ID.make(`msg_t3_steer_${sessionId}:${messageId}`); | |
| 709 | ||
| 710 | /** | |
| 711 | * The user message a turn prompted with. Turns from before T3 chose prompt ids | |
| 712 | * recorded `<session>:attempt:<id>`, which is no message. | |
| 713 | */ | |
| 714 | const promptOf = (turn: OrchestrationV2ProviderTurn) => { | |
| 715 | const nativeId = turn.nativeTurnRef?.nativeId; | |
| 716 | return nativeId?.startsWith("msg_") === true ? nativeId : undefined; | |
| 717 | }; | |
| 718 | ||
| 719 | /** | |
| 720 | * Where fork and rollback cut to keep the session's turns up to `kept` (all of | |
| 721 | * them before the first turn when undefined): before the message that starts | |
| 722 | * the next turn that reached OpenCode, which is in `prompts`. That is a turn's | |
| 723 | * prompt, or for a continuation the report it answers. A turn refused before | |
| 724 | * it prompted is not in the session and is passed over, and so is a | |
| 725 | * continuation that took no report. `null` means nothing follows, so there is | |
| 726 | * no cut; a later turn from before T3 chose prompt ids has no known message, | |
| 727 | * so no cut is safe. | |
| 728 | */ | |
| 729 | const boundaryAfter = ( | |
| 730 | turns: ReadonlyArray<OrchestrationV2ProviderTurn>, | |
| 731 | providerThreadId: OrchestrationV2ProviderThread["id"], | |
| 732 | kept: OrchestrationV2ProviderTurn | undefined, | |
| 733 | prompts: ReadonlySet<string>, | |
| 734 | ) => { | |
| 735 | const later = turns | |
| 736 | .filter( | |
| 737 | (turn) => | |
| 738 | turn.providerThreadId === providerThreadId && | |
| 739 | (kept === undefined || turn.ordinal > kept.ordinal), | |
| 740 | ) | |
| 741 | .toSorted((left, right) => left.ordinal - right.ordinal); | |
| 742 | if (later.some((turn) => promptOf(turn) === undefined && !isWakeTurn(turn))) { | |
| 743 | return Effect.fail( | |
| 744 | new ProviderAdapter.ProviderAdapterProtocolError({ | |
| 745 | driver: OPENCODE_PROVIDER, | |
| 746 | detail: | |
| 747 | "This OpenCode conversation has turns from an earlier T3 Code version, so it can't be cut there.", | |
| 748 | }), | |
| 749 | ); | |
| 750 | } | |
| 751 | return Effect.succeed( | |
| 752 | later.map(promptOf).find((messageId) => messageId !== undefined && prompts.has(messageId)) ?? | |
| 753 | null, | |
| 754 | ); | |
| 755 | }; | |
| 756 | ||
| 757 | // Errors already in the adapter channel keep their tag; only lower-level ones are wrapped. | |
| 758 | const isProviderAdapterError = Schema.is(ProviderAdapter.ProviderAdapterV2Error); | |
| 759 | ||
| 760 | /** | |
| 761 | * The model OpenCode should run for a `provider/model` slug and its reasoning | |
| 762 | * variant, or undefined for any other slug: sending none would run OpenCode's | |
| 763 | * default while T3 records the requested model. | |
| 764 | */ | |
| 765 | const modelRef = (selection: ProviderAdapter.ProviderAdapterV2TurnInput["modelSelection"]) => { | |
| 766 | const parsed = OpenCodeRuntime.parseOpenCodeModelSlug(selection.model); | |
| 767 | if (parsed === null) return undefined; | |
| 768 | const variant = getModelSelectionStringOptionValue(selection, "variant"); | |
| 769 | return Model.Ref.make({ | |
| 770 | providerID: Provider.ID.make(parsed.providerID), | |
| 771 | id: Model.ID.make(parsed.modelID), | |
| 772 | ...(variant === undefined ? {} : { variant: Model.VariantID.make(variant) }), | |
| 773 | }); | |
| 774 | }; | |
| 775 | const malformedModel = (model: string) => | |
| 776 | `OpenCode model '${model}' must use provider/model format`; | |
| 777 | const sameModel = (left: ModelRef, right: ModelRef | undefined) => | |
| 778 | left.providerID === right?.providerID && | |
| 779 | left.id === right?.id && | |
| 780 | (left.variant ?? "default") === (right?.variant ?? "default"); | |
| 781 | ||
| 782 | /** OpenCode's own agents for T3's interaction modes; plan mode is its read-only `plan` agent. */ | |
| 783 | const agentFor = (input: ProviderAdapter.ProviderAdapterV2TurnInput) => | |
| 784 | input.runtimePolicy.interactionMode === "plan" ? "plan" : "build"; | |
| 785 | ||
| 786 | /** `/name args` naming one of the workspace's commands, which OpenCode expands itself. */ | |
| 787 | const commandOf = (text: string) => { | |
| 788 | const match = /^\/([^\s/]+)(?:\s+([\s\S]*))?$/.exec(text.trim()); | |
| 789 | return match === null ? undefined : { name: match[1]!, text: match[2] ?? "" }; | |
| 790 | }; | |
| 791 | ||
| 792 | /** Whether a prompt names any skill at all, before the directory's skills are read. */ | |
| 793 | const SKILL_MENTION = new RegExp(SKILL_MENTION_PATTERN.source, "u"); | |
| 794 | ||
| 795 | /** The workspace skills a prompt names with the composer's `$skill` tokens. */ | |
| 796 | const skillsNamed = (text: string, known: ReadonlySet<string>) => [ | |
| 797 | ...new Set( | |
| 798 | [...text.matchAll(SKILL_MENTION_PATTERN)].flatMap((match) => | |
| 799 | known.has(match[2] ?? "") ? [match[2]!] : [], | |
| 800 | ), | |
| 801 | ), | |
| 802 | ]; | |
| 803 | ||
| 804 | /** | |
| 805 | * The turn's own tokens: steps add up, and the last step's input is the live | |
| 806 | * context size. A subagent's tokens are its own session's, never these. | |
| 807 | */ | |
| 808 | const turnTokenUsage = (turn: ActiveTurn, status: OrchestrationV2ProviderTurn["status"]) => | |
| 809 | turn.steps === 0 | |
| 810 | ? { | |
| 811 | usageScope: "main_agent" as const, | |
| 812 | usageStatus: "unavailable" as const, | |
| 813 | hasSubagents: turn.usedSubagents, | |
| 814 | } | |
| 815 | : { | |
| 816 | usageScope: "main_agent" as const, | |
| 817 | usageStatus: status === "completed" ? ("complete" as const) : ("partial" as const), | |
| 818 | inputTokens: turn.usage.input, | |
| 819 | cachedInputTokens: turn.usage.cached, | |
| 820 | cacheCreationTokens: turn.usage.cacheWrite, | |
| 821 | outputTokens: turn.usage.output, | |
| 822 | reasoningTokens: turn.usage.reasoning, | |
| 823 | hasSubagents: turn.usedSubagents, | |
| 824 | }; | |
| 825 | ||
| 826 | /** | |
| 827 | * The adapter for one provider instance. It talks to the instance's | |
| 828 | * {@link OpenCode2Server.OpenCode2Server}, which the driver builds from the instance's settings. | |
| 829 | */ | |
| 830 | export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: ProviderInstanceId) { | |
| 831 | const server = yield* OpenCode2Server.OpenCode2Server; | |
| 832 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | |
| 833 | const host = yield* ProviderHost.ProviderHost; | |
| 834 | const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests; | |
| 835 | const crypto = yield* Crypto.Crypto; | |
| 836 | const driver = OPENCODE_PROVIDER; | |
| 837 | const mcpServerNameFor = (threadId: string) => | |
| 838 | t3McpServerName(threadId).pipe(Effect.provideService(Crypto.Crypto, crypto)); | |
| 839 | ||
| 840 | /** | |
| 841 | * Lends the instance's server to a session until its scope closes. A spawned | |
| 842 | * server that died is started again on the next borrow. | |
| 843 | */ | |
| 844 | const borrow = Effect.gen(function* () { | |
| 845 | const lent = yield* Deferred.make< | |
| 846 | OpenCode2Server.OpenCode2Connection, | |
| 847 | OpenCodeRuntime.OpenCodeRuntimeError | |
| 848 | >(); | |
| 849 | yield* server | |
| 850 | .withConnection((connection) => | |
| 851 | Deferred.succeed(lent, connection).pipe(Effect.andThen(Effect.never)), | |
| 852 | ) | |
| 853 | .pipe( | |
| 854 | Effect.catch((error) => Deferred.fail(lent, error)), | |
| 855 | Effect.forkScoped, | |
| 856 | ); | |
| 857 | return yield* Deferred.await(lent); | |
| 858 | }); | |
| 859 | ||
| 860 | const openSession = Effect.fn("OpenCode2Adapter.openSession")(function* ( | |
| 861 | input: Parameters<ProviderAdapter.ProviderAdapterV2Shape["openSession"]>[0], | |
| 862 | initial: { | |
| 863 | readonly connection: OpenCode2Server.OpenCode2Connection; | |
| 864 | readonly scope: Scope.Closeable; | |
| 865 | }, | |
| 866 | ) { | |
| 867 | let connection = initial.connection; | |
| 868 | // Replaced when the session reconnects to a restarted server. | |
| 869 | let client = connection.client; | |
| 870 | const sessionScope = yield* Effect.scope; | |
| 871 | // Context windows by directory, then `provider/model`: a project's own | |
| 872 | // OpenCode config can change a model's limits, and this one runtime serves | |
| 873 | // the instance's threads in every directory. | |
| 874 | const contextWindows = new Map<string, Map<string, number>>(); | |
| 875 | /** A thread without a worktree runs where T3 does, as its session is created. */ | |
| 876 | const directoryOf = (cwd: string | null | undefined) => cwd ?? host.paths.cwd; | |
| 877 | const windowOf = (cwd: string | null | undefined, model: string) => | |
| 878 | contextWindows.get(directoryOf(cwd))?.get(model); | |
| 879 | const now = yield* DateTime.now; | |
| 880 | let session: OrchestrationV2ProviderSession = { | |
| 881 | id: input.providerSessionId, | |
| 882 | driver, | |
| 883 | providerInstanceId: instanceId, | |
| 884 | status: "ready", | |
| 885 | cwd: input.runtimePolicy.cwd ?? host.paths.cwd, | |
| 886 | model: input.modelSelection.model, | |
| 887 | capabilities: OpenCode2ProviderCapabilities, | |
| 888 | createdAt: now, | |
| 889 | updatedAt: now, | |
| 890 | lastError: null, | |
| 891 | }; | |
| 892 | const events = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event, Cause.Done>(); | |
| 893 | // Thread sessions, and the sessions of their subagents. | |
| 894 | const threads = new Map<string, ThreadState>(); | |
| 895 | // A subagent's session, by its id, to the thread whose session started it. | |
| 896 | const childOwners = new Map<string, ThreadState>(); | |
| 897 | // What OpenCode announced about a subagent's session, until its call names it. | |
| 898 | const announced = new Map<string, EventOf<"session.created">["data"]>(); | |
| 899 | // Sessions of these threads with an execution running, seen on the stream. | |
| 900 | const busy = new Set<string>(); | |
| 901 | const pending = new Map<RuntimeRequestId, PendingRequest>(); | |
| 902 | // Events and a wake's replay into its turn are handled one at a time, in order. | |
| 903 | const lock = yield* Semaphore.make(1); | |
| 904 | // Sessions whose `revert.clear` has not had its empty execution yet. | |
| 905 | const clearing = new Map<string, Deferred.Deferred<void>>(); | |
| 906 | // Sessions a failed rollback may have left with a staged revert. | |
| 907 | const stagedReverts = new Set<string>(); | |
| 908 | // Starting a turn and cutting the history take turns on a session: each | |
| 909 | // checks that the other is not running before its own requests yield. | |
| 910 | const sessionGates = yield* KeyedLock.make<string>(); | |
| 911 | const exclusive = | |
| 912 | (providerThread: OrchestrationV2ProviderThread) => | |
| 913 | <A, E, R>(effect: Effect.Effect<A, E, R>) => { | |
| 914 | const sessionId = providerThread.nativeThreadRef?.nativeId; | |
| 915 | if (sessionId == null) return effect; | |
| 916 | return sessionGates.withLock(sessionId, effect); | |
| 917 | }; | |
| 918 | const emit = (event: ProviderAdapter.ProviderAdapterV2Event) => | |
| 919 | Queue.offer(events, event).pipe(Effect.asVoid); | |
| 920 | const ownerOf = (sessionId: string) => childOwners.get(sessionId) ?? threads.get(sessionId); | |
| 921 | ||
| 922 | const newThreadState = ( | |
| 923 | sessionId: string, | |
| 924 | providerThread: OrchestrationV2ProviderThread, | |
| 925 | directory: string, | |
| 926 | subagent: ThreadState["subagent"], | |
| 927 | ): ThreadState => ({ | |
| 928 | sessionId, | |
| 929 | providerThread, | |
| 930 | providerTurns: new Map(), | |
| 931 | active: undefined, | |
| 932 | model: undefined, | |
| 933 | unsettled: false, | |
| 934 | directory, | |
| 935 | agent: "build", | |
| 936 | rules: undefined, | |
| 937 | policy: input.runtimePolicy, | |
| 938 | grants: [], | |
| 939 | calls: new Map(), | |
| 940 | subagent, | |
| 941 | wakes: [], | |
| 942 | reports: new Map(), | |
| 943 | stoppedChildren: new Set(), | |
| 944 | strandedSteers: new Set(), | |
| 945 | mcp: undefined, | |
| 946 | instructions: undefined, | |
| 947 | }); | |
| 948 | ||
| 949 | /** The thread whose session started this one, through any nesting. */ | |
| 950 | const rootOf = (state: ThreadState): ThreadState => { | |
| 951 | let current = state; | |
| 952 | while (current.subagent !== undefined) current = current.subagent.call.state; | |
| 953 | return current; | |
| 954 | }; | |
| 955 | ||
| 956 | /** A thread's session and its subagents' sessions, through any nesting. */ | |
| 957 | const sessionsOf = (thread: ThreadState): ReadonlyArray<ThreadState> => [ | |
| 958 | thread, | |
| 959 | ...[...threads.values()].filter((state) => { | |
| 960 | for (let above = state.subagent?.call.state; above; above = above.subagent?.call.state) { | |
| 961 | if (above === thread) return true; | |
| 962 | } | |
| 963 | return false; | |
| 964 | }), | |
| 965 | ]; | |
| 966 | ||
| 967 | /** | |
| 968 | * A thread's `subagent` calls still running, its subagents' included: a | |
| 969 | * subagent's background call runs on after the call that made it ended. | |
| 970 | */ | |
| 971 | const runningCalls = (thread: ThreadState): ReadonlyArray<SubagentCall> => | |
| 972 | sessionsOf(thread).flatMap((state) => [...state.calls.values()]); | |
| 973 | ||
| 974 | /** The `subagent` calls that lead to a session, from its own up to the thread's. */ | |
| 975 | const callsAbove = (sessionId: string) => { | |
| 976 | const calls: Array<SubagentCall> = []; | |
| 977 | let current = threads.get(sessionId); | |
| 978 | while (current?.subagent !== undefined) { | |
| 979 | calls.push(current.subagent.call); | |
| 980 | current = current.subagent.call.state; | |
| 981 | } | |
| 982 | return calls; | |
| 983 | }; | |
| 984 | ||
| 985 | /** | |
| 986 | * The turn a request from `sessionId` is shown under. A background | |
| 987 | * subagent's goes to the turn that started it, whose run waits on the | |
| 988 | * subagent; anything else goes to the thread's running turn. | |
| 989 | */ | |
| 990 | const requestTurn = (sessionId: string) => { | |
| 991 | const state = ownerOf(sessionId); | |
| 992 | if (state === undefined) return undefined; | |
| 993 | const background = callsAbove(sessionId).findLast((call) => call.background); | |
| 994 | if (background !== undefined) { | |
| 995 | return isOrchestrationV2WorkActive(background.status) | |
| 996 | ? { state, turn: background.turn } | |
| 997 | : undefined; | |
| 998 | } | |
| 999 | return state.active === undefined ? undefined : { state, turn: state.active }; | |
| 1000 | }; | |
| 1001 | ||
| 1002 | /** | |
| 1003 | * What a session still owes its thread once the thread's turn ended: | |
| 1004 | * background subagents, held executions, reports OpenCode queued for the | |
| 1005 | * follow-up it will start, and anything a subagent's session still runs, | |
| 1006 | * such as its own follow-up to such a report. | |
| 1007 | */ | |
| 1008 | const owesWork = (state: ThreadState) => | |
| 1009 | state.wakes.length > 0 || | |
| 1010 | state.reports.size > 0 || | |
| 1011 | (state.subagent !== undefined && busy.has(state.sessionId)) || | |
| 1012 | [...state.calls.values()].some((call) => call.background); | |
| 1013 | ||
| 1014 | /** Work that outlives the thread's turn, on its own session or its subagents'. */ | |
| 1015 | const hasBackground = (thread: ThreadState) => sessionsOf(thread).some(owesWork); | |
| 1016 | ||
| 1017 | const setSessionStatus = ( | |
| 1018 | status: OrchestrationV2ProviderSession["status"], | |
| 1019 | lastError: string | null, | |
| 1020 | ) => | |
| 1021 | Effect.gen(function* () { | |
| 1022 | session = { ...session, status, lastError, updatedAt: yield* DateTime.now }; | |
| 1023 | yield* emit({ type: "provider_session.updated", driver, providerSession: session }); | |
| 1024 | }); | |
| 1025 | ||
| 1026 | const ordinalOf = (turn: ActiveTurn, nativeId: string) => { | |
| 1027 | const known = turn.ordinals.get(nativeId); | |
| 1028 | if (known !== undefined) return known; | |
| 1029 | const next = turn.nextOrdinal++; | |
| 1030 | turn.ordinals.set(nativeId, next); | |
| 1031 | return next; | |
| 1032 | }; | |
| 1033 | ||
| 1034 | const itemBase = ( | |
| 1035 | state: ThreadState, | |
| 1036 | turn: ActiveTurn, | |
| 1037 | nativeId: string, | |
| 1038 | status: OrchestrationV2TurnItem["status"], | |
| 1039 | startedAt: DateTime.Utc, | |
| 1040 | completedAt: DateTime.Utc | null, | |
| 1041 | updatedAt: DateTime.Utc, | |
| 1042 | ) => ({ | |
| 1043 | id: idAllocator.derive.turnItemFromProviderItem({ driver, nativeItemId: nativeId }), | |
| 1044 | threadId: turn.input.threadId, | |
| 1045 | runId: turn.input.runId, | |
| 1046 | nodeId: idAllocator.derive.nodeFromProviderItem({ driver, nativeItemId: nativeId }), | |
| 1047 | providerThreadId: state.providerThread.id, | |
| 1048 | providerTurnId: turn.providerTurn.id, | |
| 1049 | nativeItemRef: ref(nativeId), | |
| 1050 | parentItemId: null, | |
| 1051 | ordinal: ordinalOf(turn, nativeId), | |
| 1052 | status, | |
| 1053 | title: null, | |
| 1054 | startedAt, | |
| 1055 | completedAt, | |
| 1056 | updatedAt, | |
| 1057 | }); | |
| 1058 | ||
| 1059 | const emitNode = ( | |
| 1060 | state: ThreadState, | |
| 1061 | turn: ActiveTurn, | |
| 1062 | nativeId: string, | |
| 1063 | kind: OrchestrationV2ExecutionNode["kind"], | |
| 1064 | status: OrchestrationV2ExecutionNode["status"], | |
| 1065 | startedAt: DateTime.Utc, | |
| 1066 | completedAt: DateTime.Utc | null, | |
| 1067 | ) => | |
| 1068 | emit({ | |
| 1069 | type: "node.updated", | |
| 1070 | driver, | |
| 1071 | node: { | |
| 1072 | id: idAllocator.derive.nodeFromProviderItem({ driver, nativeItemId: nativeId }), | |
| 1073 | threadId: turn.input.threadId, | |
| 1074 | runId: turn.input.runId, | |
| 1075 | parentNodeId: turn.input.rootNodeId, | |
| 1076 | rootNodeId: turn.input.rootNodeId, | |
| 1077 | kind, | |
| 1078 | status, | |
| 1079 | countsForRun: false, | |
| 1080 | providerThreadId: state.providerThread.id, | |
| 1081 | providerTurnId: turn.providerTurn.id, | |
| 1082 | nativeItemRef: ref(nativeId), | |
| 1083 | runtimeRequestId: null, | |
| 1084 | checkpointScopeId: null, | |
| 1085 | startedAt, | |
| 1086 | completedAt, | |
| 1087 | }, | |
| 1088 | }); | |
| 1089 | ||
| 1090 | /** One text or reasoning block, re-emitted with its accumulated text on every change. */ | |
| 1091 | const emitText = Effect.fnUntraced(function* ( | |
| 1092 | state: ThreadState, | |
| 1093 | turn: ActiveTurn, | |
| 1094 | data: { | |
| 1095 | readonly assistantMessageID: string; | |
| 1096 | readonly ordinal: number; | |
| 1097 | readonly text?: string; | |
| 1098 | }, | |
| 1099 | kind: "text" | "reasoning", | |
| 1100 | update: (current: string) => string, | |
| 1101 | completed = "text" in data, | |
| 1102 | ) { | |
| 1103 | const nativeId = `${data.assistantMessageID}:${kind}:${data.ordinal}`; | |
| 1104 | const updatedAt = yield* DateTime.now; | |
| 1105 | const entry = turn.texts.get(nativeId) ?? { | |
| 1106 | block: data, | |
| 1107 | kind, | |
| 1108 | startedAt: updatedAt, | |
| 1109 | text: "", | |
| 1110 | }; | |
| 1111 | entry.text = update(entry.text); | |
| 1112 | if (completed) turn.texts.delete(nativeId); | |
| 1113 | else turn.texts.set(nativeId, entry); | |
| 1114 | if (entry.text.length === 0) return; | |
| 1115 | const status = completed ? "completed" : "running"; | |
| 1116 | const completedAt = completed ? updatedAt : null; | |
| 1117 | const nodeKind = kind === "text" ? "assistant_message" : "reasoning"; | |
| 1118 | yield* emitNode(state, turn, nativeId, nodeKind, status, entry.startedAt, completedAt); | |
| 1119 | const base = itemBase(state, turn, nativeId, status, entry.startedAt, completedAt, updatedAt); | |
| 1120 | if (kind === "reasoning") { | |
| 1121 | yield* emit({ | |
| 1122 | type: "turn_item.updated", | |
| 1123 | driver, | |
| 1124 | turnItem: { ...base, type: "reasoning", text: entry.text, streaming: !completed }, | |
| 1125 | }); | |
| 1126 | return; | |
| 1127 | } | |
| 1128 | const messageId = idAllocator.derive.messageFromProviderItem({ | |
| 1129 | driver, | |
| 1130 | nativeItemId: nativeId, | |
| 1131 | }); | |
| 1132 | const message: OrchestrationV2ConversationMessage = { | |
| 1133 | createdBy: "agent", | |
| 1134 | creationSource: "provider", | |
| 1135 | id: messageId, | |
| 1136 | threadId: turn.input.threadId, | |
| 1137 | runId: turn.input.runId, | |
| 1138 | nodeId: base.nodeId, | |
| 1139 | role: "assistant", | |
| 1140 | text: entry.text, | |
| 1141 | attachments: [], | |
| 1142 | streaming: !completed, | |
| 1143 | createdAt: entry.startedAt, | |
| 1144 | updatedAt, | |
| 1145 | }; | |
| 1146 | yield* emit({ type: "message.updated", driver, message }); | |
| 1147 | yield* emit({ | |
| 1148 | type: "turn_item.updated", | |
| 1149 | driver, | |
| 1150 | turnItem: { | |
| 1151 | ...base, | |
| 1152 | type: "assistant_message", | |
| 1153 | messageId, | |
| 1154 | text: entry.text, | |
| 1155 | streaming: !completed, | |
| 1156 | }, | |
| 1157 | }); | |
| 1158 | }); | |
| 1159 | ||
| 1160 | const emitTool = Effect.fnUntraced(function* ( | |
| 1161 | state: ThreadState, | |
| 1162 | turn: ActiveTurn, | |
| 1163 | id: string, | |
| 1164 | status: "running" | "completed" | "failed" | "interrupted", | |
| 1165 | result?: { readonly output: string | undefined; readonly metadata: unknown }, | |
| 1166 | ) { | |
| 1167 | const tool = turn.tools.get(id); | |
| 1168 | if (tool === undefined) return; | |
| 1169 | const updatedAt = yield* DateTime.now; | |
| 1170 | const startedAt = turn.startedAt.get(id) ?? updatedAt; | |
| 1171 | const completedAt = status === "running" ? null : updatedAt; | |
| 1172 | const nativeId = `${turn.scope}${id}`; | |
| 1173 | yield* emitNode(state, turn, nativeId, "tool_call", status, startedAt, completedAt); | |
| 1174 | yield* emit({ | |
| 1175 | type: "turn_item.updated", | |
| 1176 | driver, | |
| 1177 | turnItem: openCodeToolTurnItem( | |
| 1178 | itemBase(state, turn, nativeId, status, startedAt, completedAt, updatedAt), | |
| 1179 | { | |
| 1180 | name: tool.name, | |
| 1181 | input: tool.input, | |
| 1182 | output: result?.output, | |
| 1183 | completedMetadata: status === "completed" ? result?.metadata : undefined, | |
| 1184 | }, | |
| 1185 | ), | |
| 1186 | }); | |
| 1187 | }); | |
| 1188 | ||
| 1189 | /** The turn's compaction item; its summary is the text OpenCode carries forward. */ | |
| 1190 | const emitCompaction = Effect.fnUntraced(function* ( | |
| 1191 | state: ThreadState, | |
| 1192 | turn: ActiveTurn, | |
| 1193 | status: "running" | "completed" | "failed" | "interrupted", | |
| 1194 | summary?: string, | |
| 1195 | ) { | |
| 1196 | const compaction = turn.compaction; | |
| 1197 | if (compaction === undefined) return; | |
| 1198 | const updatedAt = yield* DateTime.now; | |
| 1199 | const completedAt = status === "running" ? null : updatedAt; | |
| 1200 | yield* emit({ | |
| 1201 | type: "turn_item.updated", | |
| 1202 | driver, | |
| 1203 | turnItem: { | |
| 1204 | ...itemBase( | |
| 1205 | state, | |
| 1206 | turn, | |
| 1207 | compaction.nativeId, | |
| 1208 | status, | |
| 1209 | compaction.startedAt, | |
| 1210 | completedAt, | |
| 1211 | updatedAt, | |
| 1212 | ), | |
| 1213 | nodeId: turn.input.rootNodeId, | |
| 1214 | type: "compaction", | |
| 1215 | driver, | |
| 1216 | title: | |
| 1217 | status === "running" | |
| 1218 | ? "Compacting context" | |
| 1219 | : status === "completed" | |
| 1220 | ? "Context compacted" | |
| 1221 | : status === "failed" | |
| 1222 | ? "Compaction failed" | |
| 1223 | : "Compaction interrupted", | |
| 1224 | ...(summary === undefined || summary.length === 0 ? {} : { summary }), | |
| 1225 | }, | |
| 1226 | }); | |
| 1227 | if (status !== "running") turn.compaction = undefined; | |
| 1228 | }); | |
| 1229 | ||
| 1230 | const emitProviderTurn = ( | |
| 1231 | state: ThreadState, | |
| 1232 | turn: ActiveTurn, | |
| 1233 | providerTurn: OrchestrationV2ProviderTurn, | |
| 1234 | ) => { | |
| 1235 | state.providerTurns.set(String(providerTurn.id), providerTurn); | |
| 1236 | return emit({ | |
| 1237 | type: "provider_turn.updated", | |
| 1238 | driver, | |
| 1239 | threadId: turn.input.threadId, | |
| 1240 | providerTurn, | |
| 1241 | }); | |
| 1242 | }; | |
| 1243 | ||
| 1244 | const makeTurn = ( | |
| 1245 | owner: TurnOwner, | |
| 1246 | providerTurn: OrchestrationV2ProviderTurn, | |
| 1247 | options: { readonly scope: string; readonly awaitingStart: boolean }, | |
| 1248 | ): ActiveTurn => ({ | |
| 1249 | input: owner, | |
| 1250 | providerTurn, | |
| 1251 | texts: new Map(), | |
| 1252 | tools: new Map(), | |
| 1253 | startedAt: new Map(), | |
| 1254 | ordinals: new Map(), | |
| 1255 | nextOrdinal: providerTurn.ordinal * 100 + 1, | |
| 1256 | usage: { input: 0, cached: 0, cacheWrite: 0, output: 0, reasoning: 0 }, | |
| 1257 | steps: 0, | |
| 1258 | lastStep: undefined, | |
| 1259 | before: undefined, | |
| 1260 | compaction: undefined, | |
| 1261 | compactions: 0, | |
| 1262 | interrupted: false, | |
| 1263 | unsent: false, | |
| 1264 | steers: new Set(), | |
| 1265 | settledInbox: new Set(), | |
| 1266 | heldEnd: undefined, | |
| 1267 | awaitingStart: options.awaitingStart, | |
| 1268 | scope: options.scope, | |
| 1269 | usedSubagents: false, | |
| 1270 | }); | |
| 1271 | ||
| 1272 | /** A subagent call as its parent turn's item, node and subagent row. */ | |
| 1273 | const emitSubagent = Effect.fnUntraced(function* (call: SubagentCall) { | |
| 1274 | const updatedAt = yield* DateTime.now; | |
| 1275 | const { turn, state } = call; | |
| 1276 | const nodeId = idAllocator.derive.nodeFromProviderItem({ | |
| 1277 | driver, | |
| 1278 | nativeItemId: call.nativeId, | |
| 1279 | }); | |
| 1280 | const completedAt = isOrchestrationV2WorkActive(call.status) | |
| 1281 | ? null | |
| 1282 | : (call.completedAt ?? updatedAt); | |
| 1283 | const childProviderThreadId = call.child?.providerThread.id ?? null; | |
| 1284 | const childThreadId = call.child?.subagent?.appThread.id ?? null; | |
| 1285 | yield* emit({ | |
| 1286 | type: "node.updated", | |
| 1287 | driver, | |
| 1288 | node: { | |
| 1289 | id: nodeId, | |
| 1290 | threadId: turn.input.threadId, | |
| 1291 | runId: turn.input.runId, | |
| 1292 | parentNodeId: turn.input.rootNodeId, | |
| 1293 | rootNodeId: turn.input.rootNodeId, | |
| 1294 | kind: "subagent", | |
| 1295 | status: call.status, | |
| 1296 | countsForRun: false, | |
| 1297 | providerThreadId: childProviderThreadId ?? state.providerThread.id, | |
| 1298 | providerTurnId: turn.providerTurn.id, | |
| 1299 | nativeItemRef: ref(call.nativeId), | |
| 1300 | runtimeRequestId: null, | |
| 1301 | checkpointScopeId: null, | |
| 1302 | startedAt: call.startedAt, | |
| 1303 | completedAt, | |
| 1304 | }, | |
| 1305 | }); | |
| 1306 | yield* emit({ | |
| 1307 | type: "subagent.updated", | |
| 1308 | driver, | |
| 1309 | subagent: { | |
| 1310 | id: nodeId, | |
| 1311 | threadId: turn.input.threadId, | |
| 1312 | runId: turn.input.runId, | |
| 1313 | parentNodeId: turn.input.rootNodeId, | |
| 1314 | origin: "provider_native", | |
| 1315 | createdBy: "agent", | |
| 1316 | driver, | |
| 1317 | providerInstanceId: instanceId, | |
| 1318 | providerThreadId: childProviderThreadId, | |
| 1319 | childThreadId, | |
| 1320 | nativeTaskRef: ref(call.nativeId), | |
| 1321 | prompt: call.prompt, | |
| 1322 | title: call.title, | |
| 1323 | model: call.model, | |
| 1324 | status: call.status, | |
| 1325 | result: call.result, | |
| 1326 | startedAt: call.startedAt, | |
| 1327 | completedAt, | |
| 1328 | updatedAt, | |
| 1329 | }, | |
| 1330 | }); | |
| 1331 | yield* emit({ | |
| 1332 | type: "turn_item.updated", | |
| 1333 | driver, | |
| 1334 | turnItem: { | |
| 1335 | id: idAllocator.derive.turnItemFromProviderItem({ driver, nativeItemId: call.nativeId }), | |
| 1336 | threadId: turn.input.threadId, | |
| 1337 | runId: turn.input.runId, | |
| 1338 | nodeId, | |
| 1339 | providerThreadId: state.providerThread.id, | |
| 1340 | providerTurnId: turn.providerTurn.id, | |
| 1341 | nativeItemRef: ref(call.nativeId), | |
| 1342 | parentItemId: null, | |
| 1343 | ordinal: call.ordinal, | |
| 1344 | status: call.status, | |
| 1345 | title: call.title, | |
| 1346 | startedAt: call.startedAt, | |
| 1347 | completedAt, | |
| 1348 | updatedAt, | |
| 1349 | type: "subagent", | |
| 1350 | subagentId: nodeId, | |
| 1351 | origin: "provider_native", | |
| 1352 | driver, | |
| 1353 | providerInstanceId: instanceId, | |
| 1354 | childThreadId, | |
| 1355 | prompt: call.prompt, | |
| 1356 | result: call.result, | |
| 1357 | }, | |
| 1358 | }); | |
| 1359 | yield* syncRoster(rootOf(call.state)); | |
| 1360 | }); | |
| 1361 | ||
| 1362 | /** | |
| 1363 | * Lists a thread's background subagents on its provider thread, as typed | |
| 1364 | * `subagent` work: the thread shows it waits on them after its turn ends, | |
| 1365 | * and a Stop reaches them. Emitted only when the list changes. | |
| 1366 | */ | |
| 1367 | const syncRoster = Effect.fnUntraced(function* (state: ThreadState) { | |
| 1368 | const roster = runningCalls(state) | |
| 1369 | .filter((call) => call.background) | |
| 1370 | .map((call) => ({ | |
| 1371 | taskId: call.nativeId, | |
| 1372 | kind: "subagent" as const, | |
| 1373 | ...(call.title?.trim() ? { description: call.title.trim() } : {}), | |
| 1374 | ...(call.child?.subagent === undefined | |
| 1375 | ? {} | |
| 1376 | : { childThreadId: call.child.subagent.appThread.id }), | |
| 1377 | })); | |
| 1378 | const current = state.providerThread.pendingBackgroundTasks ?? []; | |
| 1379 | if ( | |
| 1380 | current.length === roster.length && | |
| 1381 | current.every( | |
| 1382 | (task, index) => | |
| 1383 | task.taskId === roster[index]?.taskId && | |
| 1384 | task.description === roster[index]?.description && | |
| 1385 | (task.kind === "subagent" ? task.childThreadId : undefined) === | |
| 1386 | roster[index]?.childThreadId, | |
| 1387 | ) | |
| 1388 | ) { | |
| 1389 | return; | |
| 1390 | } | |
| 1391 | state.providerThread = { | |
| 1392 | ...state.providerThread, | |
| 1393 | pendingBackgroundTasks: roster, | |
| 1394 | updatedAt: yield* DateTime.now, | |
| 1395 | }; | |
| 1396 | yield* emit({ | |
| 1397 | type: "provider_thread.updated", | |
| 1398 | driver, | |
| 1399 | providerThread: state.providerThread, | |
| 1400 | }); | |
| 1401 | }); | |
| 1402 | ||
| 1403 | const withReportedModel = ( | |
| 1404 | providerThread: OrchestrationV2ProviderThread, | |
| 1405 | model: ModelRef | undefined, | |
| 1406 | ): OrchestrationV2ProviderThread => { | |
| 1407 | if (model === undefined) return providerThread; | |
| 1408 | const modelSelection: ModelSelection = { | |
| 1409 | instanceId, | |
| 1410 | model: `${model.providerID}/${model.id}`, | |
| 1411 | options: model.variant === undefined ? [] : [{ id: "variant", value: model.variant }], | |
| 1412 | }; | |
| 1413 | if ( | |
| 1414 | providerThread.nativeMetadata?.modelSelection !== undefined && | |
| 1415 | modelSelectionsEqual(providerThread.nativeMetadata.modelSelection, modelSelection) | |
| 1416 | ) | |
| 1417 | return providerThread; | |
| 1418 | return { | |
| 1419 | ...providerThread, | |
| 1420 | nativeMetadata: { ...providerThread.nativeMetadata, modelSelection }, | |
| 1421 | }; | |
| 1422 | }; | |
| 1423 | ||
| 1424 | /** | |
| 1425 | * Gives a subagent call its session once both are known: OpenCode names | |
| 1426 | * the session on the call's progress, and announces it just before. The | |
| 1427 | * session becomes a child thread under the call's item. | |
| 1428 | */ | |
| 1429 | const attachChild = Effect.fnUntraced(function* (call: SubagentCall, childId: string) { | |
| 1430 | if (call.child !== undefined) return; | |
| 1431 | const info = announced.get(childId); | |
| 1432 | announced.delete(childId); | |
| 1433 | const now = yield* DateTime.now; | |
| 1434 | const nodeId = idAllocator.derive.nodeFromProviderItem({ | |
| 1435 | driver, | |
| 1436 | nativeItemId: call.nativeId, | |
| 1437 | }); | |
| 1438 | const childThreadId = idAllocator.derive.threadFromProviderThread({ | |
| 1439 | driver, | |
| 1440 | nativeThreadId: childId, | |
| 1441 | providerInstanceId: instanceId, | |
| 1442 | }); | |
| 1443 | const providerThreadId = idAllocator.derive.providerThread({ | |
| 1444 | driver, | |
| 1445 | nativeThreadId: childId, | |
| 1446 | providerInstanceId: instanceId, | |
| 1447 | }); | |
| 1448 | if (info?.model !== undefined) call.model = `${info.model.providerID}/${info.model.id}`; | |
| 1449 | call.title = call.title ?? info?.title ?? null; | |
| 1450 | const parentThread = call.turn.input.appThread; | |
| 1451 | const appThread = makeSubagentChildThread({ | |
| 1452 | parentThread, | |
| 1453 | childThreadId, | |
| 1454 | parentNodeId: nodeId, | |
| 1455 | activeProviderThreadId: providerThreadId, | |
| 1456 | providerInstanceId: instanceId, | |
| 1457 | modelSelection: { | |
| 1458 | instanceId, | |
| 1459 | model: call.model ?? call.turn.input.modelSelection.model, | |
| 1460 | }, | |
| 1461 | title: subagentThreadTitle({ | |
| 1462 | parentTitle: parentThread.title, | |
| 1463 | title: call.title, | |
| 1464 | prompt: call.prompt, | |
| 1465 | ordinal: call.ordinal, | |
| 1466 | }), | |
| 1467 | now, | |
| 1468 | createdBy: "agent", | |
| 1469 | creationSource: "provider", | |
| 1470 | }); | |
| 1471 | const providerThread: OrchestrationV2ProviderThread = { | |
| 1472 | id: providerThreadId, | |
| 1473 | driver, | |
| 1474 | providerInstanceId: instanceId, | |
| 1475 | providerSessionId: input.providerSessionId, | |
| 1476 | appThreadId: childThreadId, | |
| 1477 | ownerNodeId: nodeId, | |
| 1478 | nativeThreadRef: ref(childId), | |
| 1479 | nativeConversationHeadRef: null, | |
| 1480 | status: "active", | |
| 1481 | firstRunOrdinal: null, | |
| 1482 | lastRunOrdinal: null, | |
| 1483 | handoffIds: [], | |
| 1484 | forkedFrom: null, | |
| 1485 | createdAt: now, | |
| 1486 | updatedAt: now, | |
| 1487 | }; | |
| 1488 | // A subagent called again keeps its session's state: how many turns it | |
| 1489 | // ran (each one's native id is its own), its grants and rules, and the | |
| 1490 | // background work an earlier call left running, which Stop must reach. | |
| 1491 | const previous = threads.get(childId); | |
| 1492 | const subagent = { | |
| 1493 | call, | |
| 1494 | appThread, | |
| 1495 | turns: previous?.subagent?.turns ?? 0, | |
| 1496 | prompt: call.prompt, | |
| 1497 | queued: undefined, | |
| 1498 | }; | |
| 1499 | const child = | |
| 1500 | previous ?? newThreadState(childId, providerThread, call.state.directory, subagent); | |
| 1501 | child.subagent = subagent; | |
| 1502 | child.model = info?.model ?? child.model; | |
| 1503 | child.providerThread = withReportedModel(providerThread, child.model); | |
| 1504 | child.directory = call.state.directory; | |
| 1505 | child.agent = info?.agent ?? call.agent ?? child.agent; | |
| 1506 | // OpenCode gives a new session its parent's rules, which are the thread's. | |
| 1507 | child.rules = info?.permissions ?? child.rules; | |
| 1508 | threads.set(childId, child); | |
| 1509 | childOwners.set(childId, rootOf(call.state)); | |
| 1510 | call.child = child; | |
| 1511 | yield* emit({ type: "app_thread.created", driver, appThread }); | |
| 1512 | yield* emit({ | |
| 1513 | type: "provider_thread.updated", | |
| 1514 | driver, | |
| 1515 | providerThread: child.providerThread, | |
| 1516 | }); | |
| 1517 | yield* emitSubagent(call); | |
| 1518 | // A session called again was not made now, so it may hold the rules of | |
| 1519 | // a mode the thread has left. OpenCode applies a rules change to the | |
| 1520 | // asks after it, so it gets the thread's as soon as its call names it. | |
| 1521 | if (info === undefined) { | |
| 1522 | yield* writeRules(child, rootOf(call.state).policy).pipe( | |
| 1523 | Effect.timeout(REQUEST_REPLY_TIMEOUT), | |
| 1524 | Effect.ignore({ log: true }), | |
| 1525 | ); | |
| 1526 | } | |
| 1527 | }); | |
| 1528 | ||
| 1529 | /** | |
| 1530 | * Ends a subagent call and every call under it that is still running. A | |
| 1531 | * background one outlives the call that made it, as it outlives a turn, | |
| 1532 | * unless that call failed. | |
| 1533 | */ | |
| 1534 | const settleCall: ( | |
| 1535 | call: SubagentCall, | |
| 1536 | status: OrchestrationV2Subagent["status"], | |
| 1537 | result?: string, | |
| 1538 | ) => Effect.Effect<void> = Effect.fnUntraced(function* (call, status, result) { | |
| 1539 | if (!isOrchestrationV2WorkActive(call.status)) return; | |
| 1540 | call.status = status; | |
| 1541 | call.completedAt = yield* DateTime.now; | |
| 1542 | if (result !== undefined) call.result = result; | |
| 1543 | call.state.calls.delete(call.toolId); | |
| 1544 | const child = call.child; | |
| 1545 | if (child !== undefined) { | |
| 1546 | // A snapshot: settling a call removes it from the map. | |
| 1547 | for (const nested of Array.from(child.calls.values())) { | |
| 1548 | if (nested.background && status !== "failed") continue; | |
| 1549 | yield* settleCall(nested, status === "completed" ? "interrupted" : status); | |
| 1550 | } | |
| 1551 | if (child.active !== undefined && status !== "completed") { | |
| 1552 | yield* finishTurn(child, { status: "interrupted" }); | |
| 1553 | } | |
| 1554 | child.providerThread = { | |
| 1555 | ...child.providerThread, | |
| 1556 | status: "idle", | |
| 1557 | updatedAt: call.completedAt, | |
| 1558 | }; | |
| 1559 | yield* emit({ | |
| 1560 | type: "provider_thread.updated", | |
| 1561 | driver, | |
| 1562 | providerThread: child.providerThread, | |
| 1563 | }); | |
| 1564 | } | |
| 1565 | yield* emitSubagent(call); | |
| 1566 | }); | |
| 1567 | ||
| 1568 | /** | |
| 1569 | * Opens the runless turn a subagent's session runs each execution in, on | |
| 1570 | * its child thread, with the prompt it answers as the turn's user message. | |
| 1571 | */ | |
| 1572 | const startChildTurn = Effect.fnUntraced(function* (child: ThreadState) { | |
| 1573 | const subagent = child.subagent; | |
| 1574 | if (subagent === undefined || child.active !== undefined) return; | |
| 1575 | subagent.turns += 1; | |
| 1576 | const startedAt = yield* DateTime.now; | |
| 1577 | const nativeTurnId = `${child.sessionId}:turn:${subagent.turns}`; | |
| 1578 | const rootNodeId = idAllocator.derive.nodeFromProviderItem({ | |
| 1579 | driver, | |
| 1580 | nativeItemId: `${nativeTurnId}:root`, | |
| 1581 | }); | |
| 1582 | const providerTurn: OrchestrationV2ProviderTurn = { | |
| 1583 | id: idAllocator.derive.providerTurn({ driver, nativeTurnId }), | |
| 1584 | providerThreadId: child.providerThread.id, | |
| 1585 | nodeId: rootNodeId, | |
| 1586 | runAttemptId: null, | |
| 1587 | nativeTurnRef: ref(nativeTurnId, "weak"), | |
| 1588 | ordinal: subagent.turns, | |
| 1589 | status: "running", | |
| 1590 | startedAt, | |
| 1591 | completedAt: null, | |
| 1592 | }; | |
| 1593 | const turn = makeTurn( | |
| 1594 | { | |
| 1595 | appThread: subagent.appThread, | |
| 1596 | threadId: subagent.appThread.id, | |
| 1597 | runId: null, | |
| 1598 | runOrdinal: subagent.call.turn.input.runOrdinal, | |
| 1599 | rootNodeId, | |
| 1600 | modelSelection: subagent.appThread.modelSelection, | |
| 1601 | runtimePolicy: subagent.call.turn.input.runtimePolicy, | |
| 1602 | }, | |
| 1603 | providerTurn, | |
| 1604 | { scope: `${child.sessionId}:`, awaitingStart: false }, | |
| 1605 | ); | |
| 1606 | turn.before = subagent.queued; | |
| 1607 | subagent.queued = undefined; | |
| 1608 | child.active = turn; | |
| 1609 | yield* emit({ | |
| 1610 | type: "node.updated", | |
| 1611 | driver, | |
| 1612 | node: { | |
| 1613 | id: rootNodeId, | |
| 1614 | threadId: turn.input.threadId, | |
| 1615 | runId: null, | |
| 1616 | parentNodeId: null, | |
| 1617 | rootNodeId, | |
| 1618 | kind: "root_turn", | |
| 1619 | status: "running", | |
| 1620 | countsForRun: false, | |
| 1621 | providerThreadId: child.providerThread.id, | |
| 1622 | providerTurnId: providerTurn.id, | |
| 1623 | nativeItemRef: ref(nativeTurnId, "weak"), | |
| 1624 | runtimeRequestId: null, | |
| 1625 | checkpointScopeId: null, | |
| 1626 | startedAt, | |
| 1627 | completedAt: null, | |
| 1628 | }, | |
| 1629 | }); | |
| 1630 | yield* emitProviderTurn(child, turn, providerTurn); | |
| 1631 | const prompt = subagent.prompt; | |
| 1632 | subagent.prompt = undefined; | |
| 1633 | if (prompt !== undefined && prompt.length > 0) { | |
| 1634 | const nativeId = `${nativeTurnId}:prompt`; | |
| 1635 | const artifacts = makeSubagentConversationArtifacts({ | |
| 1636 | messageId: idAllocator.derive.messageFromProviderItem({ driver, nativeItemId: nativeId }), | |
| 1637 | senderThreadId: subagent.call.turn.input.threadId, | |
| 1638 | turnItemId: idAllocator.derive.turnItemFromProviderItem({ | |
| 1639 | driver, | |
| 1640 | nativeItemId: nativeId, | |
| 1641 | }), | |
| 1642 | threadId: turn.input.threadId, | |
| 1643 | rootNodeId, | |
| 1644 | providerThreadId: child.providerThread.id, | |
| 1645 | providerTurnId: providerTurn.id, | |
| 1646 | nativeItemRef: ref(nativeId, "weak"), | |
| 1647 | role: "user", | |
| 1648 | text: prompt, | |
| 1649 | ordinal: ordinalOf(turn, nativeId), | |
| 1650 | now: startedAt, | |
| 1651 | }); | |
| 1652 | yield* emit({ type: "message.updated", driver, message: artifacts.message }); | |
| 1653 | yield* emit({ type: "turn_item.updated", driver, turnItem: artifacts.turnItem }); | |
| 1654 | } | |
| 1655 | }); | |
| 1656 | ||
| 1657 | /** | |
| 1658 | * Asks the orchestrator for the continuation turn a held wake needs. It | |
| 1659 | * names the subagents whose end woke the thread. A wake a Stop or an | |
| 1660 | * earlier turn already took needs no turn, so its offer is dropped. | |
| 1661 | */ | |
| 1662 | const offerWake = Effect.fnUntraced(function* (state: ThreadState, wake: Wake) { | |
| 1663 | const route = state.providerThread.appThreadId; | |
| 1664 | if (route === null) return; | |
| 1665 | const notification = backgroundWorkNotification(wake.reports); | |
| 1666 | yield* continuationRequests.offer({ | |
| 1667 | threadId: route, | |
| 1668 | providerThreadId: state.providerThread.id, | |
| 1669 | driver, | |
| 1670 | detail: wake.detail, | |
| 1671 | ...(notification === null ? {} : { notification }), | |
| 1672 | dispatchIfCurrent: (dispatch) => | |
| 1673 | wake.dropped ? Effect.succeed(Option.none()) : Effect.map(dispatch, Option.some), | |
| 1674 | }); | |
| 1675 | }); | |
| 1676 | ||
| 1677 | const finishTurn = Effect.fnUntraced(function* ( | |
| 1678 | state: ThreadState, | |
| 1679 | terminal: TurnTerminal, | |
| 1680 | threadDisposition: "reusable" | "broken" = "reusable", | |
| 1681 | ) { | |
| 1682 | const turn = state.active; | |
| 1683 | if (turn === undefined) return; | |
| 1684 | state.active = undefined; | |
| 1685 | for (const inboxID of turn.steers) state.strandedSteers.add(inboxID); | |
| 1686 | // OpenCode drops a request when the asking session's execution ends. A | |
| 1687 | // subagent's request shown on this turn ends with it too, unless the | |
| 1688 | // subagent runs in the background past a turn that did not fail. | |
| 1689 | for (const entry of pending.values()) { | |
| 1690 | const background = callsAbove(entry.sessionId).some((call) => call.background); | |
| 1691 | if ( | |
| 1692 | entry.sessionId === state.sessionId || | |
| 1693 | (entry.turn === turn && !(background && terminal.status !== "failed")) | |
| 1694 | ) { | |
| 1695 | yield* settleRequest(entry, "cancelled"); | |
| 1696 | } | |
| 1697 | } | |
| 1698 | const completedAt = yield* DateTime.now; | |
| 1699 | // Blocks still open when the execution ends are final as they stand. | |
| 1700 | for (const open of turn.texts.values()) { | |
| 1701 | yield* emitText(state, turn, open.block, open.kind, (text) => text, true); | |
| 1702 | } | |
| 1703 | for (const id of turn.tools.keys()) { | |
| 1704 | yield* emitTool( | |
| 1705 | state, | |
| 1706 | turn, | |
| 1707 | id, | |
| 1708 | terminal.status === "completed" ? "completed" : "interrupted", | |
| 1709 | ); | |
| 1710 | } | |
| 1711 | // A foreground subagent ends with the turn that waits on it. A background | |
| 1712 | // one outlives a finished or interrupted turn (a user Stop has already | |
| 1713 | // stopped it), and a failed turn stops it. | |
| 1714 | // A snapshot: settling a call removes it from the map. | |
| 1715 | for (const call of Array.from(state.calls.values())) { | |
| 1716 | if (call.turn !== turn) continue; | |
| 1717 | if (call.background && terminal.status !== "failed") continue; | |
| 1718 | yield* settleCall(call, terminal.status === "completed" ? "completed" : terminal.status); | |
| 1719 | } | |
| 1720 | if (state.subagent !== undefined) { | |
| 1721 | yield* emit({ | |
| 1722 | type: "node.updated", | |
| 1723 | driver, | |
| 1724 | node: { | |
| 1725 | id: turn.input.rootNodeId, | |
| 1726 | threadId: turn.input.threadId, | |
| 1727 | runId: null, | |
| 1728 | parentNodeId: null, | |
| 1729 | rootNodeId: turn.input.rootNodeId, | |
| 1730 | kind: "root_turn", | |
| 1731 | status: terminal.status, | |
| 1732 | countsForRun: false, | |
| 1733 | providerThreadId: state.providerThread.id, | |
| 1734 | providerTurnId: turn.providerTurn.id, | |
| 1735 | nativeItemRef: turn.providerTurn.nativeTurnRef, | |
| 1736 | runtimeRequestId: null, | |
| 1737 | checkpointScopeId: null, | |
| 1738 | startedAt: turn.providerTurn.startedAt, | |
| 1739 | completedAt, | |
| 1740 | }, | |
| 1741 | }); | |
| 1742 | } | |
| 1743 | yield* emitCompaction(state, turn, terminal.status === "failed" ? "failed" : "interrupted"); | |
| 1744 | const window = windowOf(turn.input.runtimePolicy.cwd, turn.input.modelSelection.model); | |
| 1745 | const lastStep = turn.lastStep; | |
| 1746 | yield* emitProviderTurn(state, turn, { | |
| 1747 | ...turn.providerTurn, | |
| 1748 | status: terminal.status, | |
| 1749 | completedAt, | |
| 1750 | turnTokenUsage: turnTokenUsage(turn, terminal.status), | |
| 1751 | ...(lastStep === undefined | |
| 1752 | ? {} | |
| 1753 | : { | |
| 1754 | tokenUsage: { | |
| 1755 | usedTokens: | |
| 1756 | lastStep.input + lastStep.cache.read + lastStep.cache.write + lastStep.output, | |
| 1757 | maxTokens: window ?? null, | |
| 1758 | inputTokens: lastStep.input + lastStep.cache.read + lastStep.cache.write, | |
| 1759 | cachedInputTokens: lastStep.cache.read, | |
| 1760 | outputTokens: lastStep.output, | |
| 1761 | reasoningOutputTokens: lastStep.reasoning, | |
| 1762 | updatedAt: DateTime.formatIso(completedAt), | |
| 1763 | }, | |
| 1764 | }), | |
| 1765 | }); | |
| 1766 | // A subagent's session is its call's to settle: a turn there is runless, | |
| 1767 | // and T3 has no terminal to wait on. | |
| 1768 | if (state.subagent !== undefined) { | |
| 1769 | const call = state.subagent.call; | |
| 1770 | if (!call.background && terminal.status !== "completed") { | |
| 1771 | yield* settleCall(call, terminal.status); | |
| 1772 | } | |
| 1773 | return; | |
| 1774 | } | |
| 1775 | state.providerThread = { | |
| 1776 | ...state.providerThread, | |
| 1777 | status: threadDisposition === "broken" ? "error" : "idle", | |
| 1778 | updatedAt: completedAt, | |
| 1779 | }; | |
| 1780 | yield* emit({ | |
| 1781 | type: "provider_thread.updated", | |
| 1782 | driver, | |
| 1783 | providerThread: state.providerThread, | |
| 1784 | }); | |
| 1785 | const anyActive = [...threads.values()].some( | |
| 1786 | (candidate) => candidate.subagent === undefined && candidate.active !== undefined, | |
| 1787 | ); | |
| 1788 | yield* setSessionStatus(pending.size > 0 ? "waiting" : anyActive ? "running" : "ready", null); | |
| 1789 | const base = { | |
| 1790 | type: "turn.terminal" as const, | |
| 1791 | driver, | |
| 1792 | // The provider thread the turn started on. After a native fork the | |
| 1793 | // session's own thread has a fresh id, and the terminal must name the | |
| 1794 | // thread the start was recorded against. | |
| 1795 | providerThreadId: turn.providerTurn.providerThreadId, | |
| 1796 | providerTurnId: turn.providerTurn.id, | |
| 1797 | runOrdinal: turn.input.runOrdinal, | |
| 1798 | threadDisposition, | |
| 1799 | }; | |
| 1800 | yield* emit( | |
| 1801 | terminal.status === "failed" | |
| 1802 | ? { | |
| 1803 | ...base, | |
| 1804 | status: "failed", | |
| 1805 | failure: terminal.failure, | |
| 1806 | failureItemOrdinal: ordinalOf(turn, `terminal-failure:${turn.providerTurn.id}`), | |
| 1807 | } | |
| 1808 | : { ...base, status: terminal.status, failure: null }, | |
| 1809 | ); | |
| 1810 | // A spawned server lists its models lazily, so a window still unknown is | |
| 1811 | // read again for the next turn, off this stream so it never delays one. | |
| 1812 | if (window === undefined) { | |
| 1813 | yield* Effect.forkIn(readModels(directoryOf(turn.input.runtimePolicy.cwd)), sessionScope); | |
| 1814 | } | |
| 1815 | }); | |
| 1816 | ||
| 1817 | /** | |
| 1818 | * Ends the turn a request T3 could not answer would block, and stops the | |
| 1819 | * session: OpenCode waits on an unanswered request forever. The stopped | |
| 1820 | * run's end is its own, not the next turn's. | |
| 1821 | */ | |
| 1822 | const abandonRequest = Effect.fnUntraced(function* (state: ThreadState, reason: string) { | |
| 1823 | yield* Effect.logWarning("Could not answer an OpenCode request; ending its turn.", { | |
| 1824 | reason, | |
| 1825 | }); | |
| 1826 | if (state.active === undefined) return; | |
| 1827 | state.unsettled = true; | |
| 1828 | yield* finishTurn(state, { | |
| 1829 | status: "failed", | |
| 1830 | failure: makeProviderFailure({ | |
| 1831 | message: "OpenCode is waiting on a request T3 Code couldn't answer.", | |
| 1832 | class: "provider_error", | |
| 1833 | }), | |
| 1834 | }); | |
| 1835 | yield* client.session | |
| 1836 | .interrupt({ sessionID: Session.ID.make(state.sessionId) }) | |
| 1837 | .pipe(Effect.timeout("2 seconds"), Effect.ignore({ log: true })); | |
| 1838 | }); | |
| 1839 | ||
| 1840 | /** Stops tracking a request; the session is waiting only while any remain. */ | |
| 1841 | const forgetRequest = Effect.fnUntraced(function* (entry: PendingRequest) { | |
| 1842 | if (!pending.delete(entry.request.id)) return false; | |
| 1843 | if (pending.size === 0 && session.status === "waiting") { | |
| 1844 | yield* setSessionStatus("running", null); | |
| 1845 | } | |
| 1846 | return true; | |
| 1847 | }); | |
| 1848 | ||
| 1849 | /** | |
| 1850 | * Settles a request OpenCode answered or dropped on its own. T3's own | |
| 1851 | * answers are only forgotten: the orchestrator already recorded them. | |
| 1852 | */ | |
| 1853 | const settleRequest = Effect.fnUntraced(function* ( | |
| 1854 | entry: PendingRequest, | |
| 1855 | status: "resolved" | "cancelled", | |
| 1856 | ) { | |
| 1857 | if (!(yield* forgetRequest(entry)) || entry.answering) return; | |
| 1858 | const now = yield* DateTime.now; | |
| 1859 | const itemStatus = status === "resolved" ? "completed" : "cancelled"; | |
| 1860 | yield* emit({ | |
| 1861 | type: "runtime_request.updated", | |
| 1862 | driver, | |
| 1863 | threadId: entry.turn.input.threadId, | |
| 1864 | runtimeRequest: { ...entry.request, status, resolvedAt: now }, | |
| 1865 | }); | |
| 1866 | yield* emit({ | |
| 1867 | type: "node.updated", | |
| 1868 | driver, | |
| 1869 | node: { ...entry.node, status: itemStatus, completedAt: now }, | |
| 1870 | }); | |
| 1871 | yield* emit({ | |
| 1872 | type: "turn_item.updated", | |
| 1873 | driver, | |
| 1874 | turnItem: { ...entry.item, status: itemStatus, completedAt: now, updatedAt: now }, | |
| 1875 | }); | |
| 1876 | }); | |
| 1877 | ||
| 1878 | /** | |
| 1879 | * Stops a run no turn of T3's waits on (one a Stop left running, or one | |
| 1880 | * found asking after a reconnect): a reject without a message and a | |
| 1881 | * cancelled form both end OpenCode's execution. Its end is not a turn's. | |
| 1882 | */ | |
| 1883 | const stopStaleRequest = ( | |
| 1884 | sessionId: string, | |
| 1885 | native: { readonly type: "permission" | "form"; readonly id: string }, | |
| 1886 | ): Effect.Effect<void> => | |
| 1887 | native.type === "permission" | |
| 1888 | ? client.permission | |
| 1889 | .reply({ | |
| 1890 | sessionID: Session.ID.make(sessionId), | |
| 1891 | requestID: Permission.ID.make(native.id), | |
| 1892 | decision: "reject", | |
| 1893 | }) | |
| 1894 | .pipe( | |
| 1895 | Effect.catchTags(permissionGone), | |
| 1896 | Effect.timeout(REQUEST_REPLY_TIMEOUT), | |
| 1897 | Effect.ignore({ log: true }), | |
| 1898 | ) | |
| 1899 | : client.session.form | |
| 1900 | .cancel({ sessionID: sessionId, formID: Form.ID.make(native.id) }) | |
| 1901 | .pipe( | |
| 1902 | Effect.catchTags(formGone), | |
| 1903 | Effect.timeout(REQUEST_REPLY_TIMEOUT), | |
| 1904 | Effect.ignore({ log: true }), | |
| 1905 | ); | |
| 1906 | ||
| 1907 | /** | |
| 1908 | * Stops what a session still waits on when this runtime first loads it: | |
| 1909 | * T3 shows none of those requests (a restart or a closed session expired | |
| 1910 | * them), and OpenCode would wait on them forever. | |
| 1911 | */ | |
| 1912 | const stopLeftoverRequests = Effect.fnUntraced(function* (state: ThreadState) { | |
| 1913 | const sessionID = Session.ID.make(state.sessionId); | |
| 1914 | const listed = yield* Effect.all([ | |
| 1915 | client.permission.list({ sessionID }), | |
| 1916 | client.session.form.list({ sessionID }), | |
| 1917 | ]).pipe(Effect.timeout(ACTIVE_CHECK_TIMEOUT), Effect.option); | |
| 1918 | if (listed._tag === "None") { | |
| 1919 | return yield* Effect.logWarning("Could not list an OpenCode session's open requests."); | |
| 1920 | } | |
| 1921 | const [permissions, forms] = listed.value; | |
| 1922 | if (permissions.length === 0 && forms.length === 0) return; | |
| 1923 | // The stopped run's end is not the next turn's. | |
| 1924 | state.unsettled = true; | |
| 1925 | for (const request of permissions) { | |
| 1926 | yield* stopStaleRequest(state.sessionId, { type: "permission", id: request.id }); | |
| 1927 | } | |
| 1928 | for (const form of forms) { | |
| 1929 | yield* stopStaleRequest(state.sessionId, { type: "form", id: form.id }); | |
| 1930 | } | |
| 1931 | }); | |
| 1932 | ||
| 1933 | /** | |
| 1934 | * Shows a permission ask or question form on the thread whose session (or | |
| 1935 | * subagent session) asked, under that thread's running turn. A request no | |
| 1936 | * turn of T3's is waiting on is left for OpenCode's own clients. | |
| 1937 | */ | |
| 1938 | const showRequest = Effect.fnUntraced(function* ( | |
| 1939 | sessionId: string, | |
| 1940 | native: PendingRequest["native"], | |
| 1941 | body: | |
| 1942 | | { | |
| 1943 | readonly type: "approval_request"; | |
| 1944 | readonly requestKind: Extract< | |
| 1945 | OrchestrationV2TurnItem, | |
| 1946 | { type: "approval_request" } | |
| 1947 | >["requestKind"]; | |
| 1948 | readonly prompt: string; | |
| 1949 | readonly options: Extract< | |
| 1950 | OrchestrationV2TurnItem, | |
| 1951 | { type: "approval_request" } | |
| 1952 | >["options"]; | |
| 1953 | } | |
| 1954 | | { | |
| 1955 | readonly type: "user_input_request"; | |
| 1956 | readonly questions: ReadonlyArray<OrchestrationV2UserInputQuestion>; | |
| 1957 | }, | |
| 1958 | ) { | |
| 1959 | const target = requestTurn(sessionId); | |
| 1960 | if (target === undefined) return; | |
| 1961 | const { state, turn } = target; | |
| 1962 | if ([...pending.values()].some((entry) => entry.native.id === native.id)) return; | |
| 1963 | const now = yield* DateTime.now; | |
| 1964 | const requestId = yield* idAllocator.allocate.runtimeRequest({ | |
| 1965 | driver, | |
| 1966 | providerTurnId: turn.providerTurn.id, | |
| 1967 | nativeRequestId: native.id, | |
| 1968 | }); | |
| 1969 | const nodeId = idAllocator.derive.approvalNode({ requestId }); | |
| 1970 | const request: OrchestrationV2RuntimeRequest = { | |
| 1971 | id: requestId, | |
| 1972 | nodeId, | |
| 1973 | providerTurnId: turn.providerTurn.id, | |
| 1974 | nativeRequestRef: ref(native.id), | |
| 1975 | kind: body.type === "approval_request" ? body.requestKind : "user_input", | |
| 1976 | status: "pending", | |
| 1977 | responseCapability: { type: "live", providerSessionId: input.providerSessionId }, | |
| 1978 | createdAt: now, | |
| 1979 | resolvedAt: null, | |
| 1980 | }; | |
| 1981 | const node: OrchestrationV2ExecutionNode = { | |
| 1982 | id: nodeId, | |
| 1983 | threadId: turn.input.threadId, | |
| 1984 | runId: turn.input.runId, | |
| 1985 | parentNodeId: turn.input.rootNodeId, | |
| 1986 | rootNodeId: turn.input.rootNodeId, | |
| 1987 | kind: body.type, | |
| 1988 | status: "waiting", | |
| 1989 | countsForRun: false, | |
| 1990 | providerThreadId: state.providerThread.id, | |
| 1991 | providerTurnId: turn.providerTurn.id, | |
| 1992 | nativeItemRef: ref(native.id), | |
| 1993 | runtimeRequestId: requestId, | |
| 1994 | checkpointScopeId: null, | |
| 1995 | startedAt: now, | |
| 1996 | completedAt: null, | |
| 1997 | }; | |
| 1998 | const base = { | |
| 1999 | id: idAllocator.derive.approvalTurnItem({ requestId }), | |
| 2000 | threadId: turn.input.threadId, | |
| 2001 | runId: turn.input.runId, | |
| 2002 | nodeId, | |
| 2003 | providerThreadId: state.providerThread.id, | |
| 2004 | providerTurnId: turn.providerTurn.id, | |
| 2005 | nativeItemRef: ref(native.id), | |
| 2006 | parentItemId: null, | |
| 2007 | ordinal: ordinalOf(turn, native.id), | |
| 2008 | status: "waiting" as const, | |
| 2009 | startedAt: now, | |
| 2010 | completedAt: null, | |
| 2011 | updatedAt: now, | |
| 2012 | }; | |
| 2013 | const item: OrchestrationV2TurnItem = | |
| 2014 | body.type === "approval_request" | |
| 2015 | ? { | |
| 2016 | ...base, | |
| 2017 | title: native.type === "permission" ? native.action : null, | |
| 2018 | type: "approval_request", | |
| 2019 | requestId, | |
| 2020 | requestKind: body.requestKind, | |
| 2021 | prompt: body.prompt, | |
| 2022 | ...(body.options === undefined ? {} : { options: body.options }), | |
| 2023 | } | |
| 2024 | : { | |
| 2025 | ...base, | |
| 2026 | title: "User input", | |
| 2027 | type: "user_input_request", | |
| 2028 | requestId, | |
| 2029 | questions: body.questions, | |
| 2030 | }; | |
| 2031 | pending.set(requestId, { | |
| 2032 | request, | |
| 2033 | item, | |
| 2034 | node, | |
| 2035 | state, | |
| 2036 | turn, | |
| 2037 | sessionId, | |
| 2038 | answering: false, | |
| 2039 | native, | |
| 2040 | }); | |
| 2041 | yield* emit({ type: "node.updated", driver, node }); | |
| 2042 | yield* emit({ | |
| 2043 | type: "runtime_request.updated", | |
| 2044 | driver, | |
| 2045 | threadId: turn.input.threadId, | |
| 2046 | runtimeRequest: request, | |
| 2047 | }); | |
| 2048 | yield* emit({ type: "turn_item.updated", driver, turnItem: item }); | |
| 2049 | yield* setSessionStatus("waiting", null); | |
| 2050 | }); | |
| 2051 | ||
| 2052 | const onPermissionAsked = Effect.fnUntraced(function* (data: Permission.Request) { | |
| 2053 | // The asking session's own turn knows the tool: a subagent's session runs its own. | |
| 2054 | const turn = threads.get(data.sessionID)?.active; | |
| 2055 | const toolName = | |
| 2056 | data.source === undefined ? undefined : turn?.tools.get(data.source.id)?.name; | |
| 2057 | const save = data.save ?? []; | |
| 2058 | yield* showRequest( | |
| 2059 | data.sessionID, | |
| 2060 | { type: "permission", id: data.id, action: data.action, resources: data.resources, save }, | |
| 2061 | { | |
| 2062 | type: "approval_request", | |
| 2063 | requestKind: openCodePermissionRequestKind(data.action, toolName), | |
| 2064 | prompt: data.resources.length === 0 ? data.action : data.resources.join("\n"), | |
| 2065 | // "Always" in OpenCode saves a grant for the whole project, so the | |
| 2066 | // session-wide choice is T3's own rule on this session instead. | |
| 2067 | options: [ | |
| 2068 | { decision: "cancel", label: "Cancel" }, | |
| 2069 | { decision: "decline", label: "Decline" }, | |
| 2070 | ...(save.length > 0 | |
| 2071 | ? [ | |
| 2072 | { | |
| 2073 | decision: "acceptForSession" as const, | |
| 2074 | label: sessionGrantLabel(data.action, save), | |
| 2075 | }, | |
| 2076 | ] | |
| 2077 | : []), | |
| 2078 | { decision: "accept", label: "Approve" }, | |
| 2079 | ], | |
| 2080 | }, | |
| 2081 | ); | |
| 2082 | }); | |
| 2083 | ||
| 2084 | const onFormCreated = Effect.fnUntraced(function* (form: NativeForm) { | |
| 2085 | const target = requestTurn(form.sessionID); | |
| 2086 | if (target === undefined) return; | |
| 2087 | const { state } = target; | |
| 2088 | const mapped = formQuestions(form); | |
| 2089 | if ("questions" in mapped) { | |
| 2090 | return yield* showRequest( | |
| 2091 | form.sessionID, | |
| 2092 | { type: "form", id: form.id, form }, | |
| 2093 | { type: "user_input_request", questions: mapped.questions }, | |
| 2094 | ); | |
| 2095 | } | |
| 2096 | // Cancelling ends OpenCode's execution as a user stop, so the turn is | |
| 2097 | // failed here with the reason and that stop's end is skipped. | |
| 2098 | yield* Effect.logWarning("Declined an OpenCode form T3 Code cannot show.", { | |
| 2099 | reason: mapped.unsupported, | |
| 2100 | }); | |
| 2101 | const cancelled = yield* deliver( | |
| 2102 | client.session.form.cancel({ sessionID: form.sessionID, formID: form.id }), | |
| 2103 | ); | |
| 2104 | // The session that asked is the one blocked on the form and stopped by | |
| 2105 | // its cancel: a subagent's own, whose parent reads its failed call and | |
| 2106 | // goes on. A subagent's session has no Stop end to skip. | |
| 2107 | const asker = threads.get(form.sessionID) ?? state; | |
| 2108 | if (!cancelled) return yield* abandonRequest(asker, "form cancel failed"); | |
| 2109 | if (asker.subagent === undefined) asker.unsettled = true; | |
| 2110 | yield* finishTurn(asker, { | |
| 2111 | status: "failed", | |
| 2112 | failure: makeProviderFailure({ | |
| 2113 | message: `OpenCode asked for ${mapped.unsupported}, which T3 Code can't show. The question was declined.`, | |
| 2114 | class: "provider_error", | |
| 2115 | }), | |
| 2116 | }); | |
| 2117 | }); | |
| 2118 | ||
| 2119 | /** | |
| 2120 | * A permission ask or question form, from the stream or read back after a | |
| 2121 | * reconnect: shown under the turn it blocks, or stopped when it is the run | |
| 2122 | * a Stop left behind, which nothing answers. | |
| 2123 | */ | |
| 2124 | const onAsked = Effect.fnUntraced(function* ( | |
| 2125 | asked: | |
| 2126 | | { readonly type: "permission"; readonly request: Permission.Request } | |
| 2127 | | { readonly type: "form"; readonly form: NativeForm }, | |
| 2128 | ) { | |
| 2129 | const asking = asked.type === "permission" ? asked.request.sessionID : asked.form.sessionID; | |
| 2130 | const state = ownerOf(asking); | |
| 2131 | if (state === undefined) return; | |
| 2132 | const target = requestTurn(asking); | |
| 2133 | if (target !== undefined && !target.turn.awaitingStart) { | |
| 2134 | if (asked.type === "permission") return yield* onPermissionAsked(asked.request); | |
| 2135 | return yield* onFormCreated(asked.form); | |
| 2136 | } | |
| 2137 | if (state.unsettled || state.active?.awaitingStart === true) { | |
| 2138 | yield* stopStaleRequest( | |
| 2139 | asking, | |
| 2140 | asked.type === "permission" | |
| 2141 | ? { type: "permission", id: asked.request.id } | |
| 2142 | : { type: "form", id: asked.form.id }, | |
| 2143 | ); | |
| 2144 | } | |
| 2145 | }); | |
| 2146 | ||
| 2147 | const sessionOfEvent = (event: OpenCode2Client.OpenCode2StreamEvent) => | |
| 2148 | event.type === "unreadable.execution.ended" || event.type === "unreadable.execution.started" | |
| 2149 | ? event.sessionID | |
| 2150 | : "sessionID" in event.data && typeof event.data.sessionID === "string" | |
| 2151 | ? event.data.sessionID | |
| 2152 | : undefined; | |
| 2153 | ||
| 2154 | const executionEnd = (type: string) => | |
| 2155 | type === "session.execution.succeeded" || | |
| 2156 | type === "session.execution.failed" || | |
| 2157 | type === "session.execution.interrupted"; | |
| 2158 | ||
| 2159 | /** A `subagent` call's item, made when the model starts it. */ | |
| 2160 | const startCall = Effect.fnUntraced(function* ( | |
| 2161 | state: ThreadState, | |
| 2162 | turn: ActiveTurn, | |
| 2163 | toolId: string, | |
| 2164 | ) { | |
| 2165 | const nativeId = `${turn.scope}${toolId}`; | |
| 2166 | const call: SubagentCall = { | |
| 2167 | toolId, | |
| 2168 | nativeId, | |
| 2169 | state, | |
| 2170 | turn, | |
| 2171 | ordinal: ordinalOf(turn, nativeId), | |
| 2172 | startedAt: yield* DateTime.now, | |
| 2173 | prompt: "", | |
| 2174 | title: null, | |
| 2175 | agent: undefined, | |
| 2176 | model: null, | |
| 2177 | background: false, | |
| 2178 | child: undefined, | |
| 2179 | status: "running", | |
| 2180 | result: null, | |
| 2181 | completedAt: null, | |
| 2182 | }; | |
| 2183 | turn.usedSubagents = true; | |
| 2184 | state.calls.set(toolId, call); | |
| 2185 | yield* emitSubagent(call); | |
| 2186 | }); | |
| 2187 | ||
| 2188 | /** | |
| 2189 | * One event for a session's running turn: its text, tools, steps and end. | |
| 2190 | * A `subagent` call is its item and child thread rather than a tool. | |
| 2191 | */ | |
| 2192 | const onTurnEvent = Effect.fnUntraced(function* ( | |
| 2193 | state: ThreadState, | |
| 2194 | turn: ActiveTurn, | |
| 2195 | event: OpenCode2Client.OpenCode2StreamEvent, | |
| 2196 | ) { | |
| 2197 | switch (event.type) { | |
| 2198 | case "session.text.started": | |
| 2199 | case "session.reasoning.started": | |
| 2200 | case "session.text.delta": | |
| 2201 | case "session.reasoning.delta": | |
| 2202 | case "session.text.ended": | |
| 2203 | case "session.reasoning.ended": { | |
| 2204 | const data = event.data; | |
| 2205 | const kind = event.type.startsWith("session.text.") ? "text" : "reasoning"; | |
| 2206 | return yield* emitText(state, turn, data, kind, (text) => | |
| 2207 | "delta" in data ? text + data.delta : "text" in data ? data.text : text, | |
| 2208 | ); | |
| 2209 | } | |
| 2210 | case "session.tool.input.started": | |
| 2211 | // Its form is the item the user answers; the tool call would repeat it. | |
| 2212 | if (event.data.name === "question") return; | |
| 2213 | if (event.data.name === "subagent") return yield* startCall(state, turn, event.data.id); | |
| 2214 | turn.tools.set(event.data.id, { name: event.data.name, input: {} }); | |
| 2215 | turn.startedAt.set(event.data.id, yield* DateTime.now); | |
| 2216 | return yield* emitTool(state, turn, event.data.id, "running"); | |
| 2217 | case "session.tool.called": { | |
| 2218 | const call = state.calls.get(event.data.id); | |
| 2219 | if (call !== undefined) { | |
| 2220 | const { input } = event.data; | |
| 2221 | call.prompt = stringField(input, "prompt") ?? call.prompt; | |
| 2222 | call.title = stringField(input, "description") ?? call.title; | |
| 2223 | call.agent = stringField(input, "agent"); | |
| 2224 | call.model = stringField(input, "model") ?? call.model; | |
| 2225 | call.background = input["background"] === true; | |
| 2226 | return yield* emitSubagent(call); | |
| 2227 | } | |
| 2228 | const tool = turn.tools.get(event.data.id); | |
| 2229 | if (tool !== undefined) tool.input = event.data.input; | |
| 2230 | return yield* emitTool(state, turn, event.data.id, "running"); | |
| 2231 | } | |
| 2232 | case "session.tool.progress": { | |
| 2233 | const call = state.calls.get(event.data.id); | |
| 2234 | const childId = stringField(event.data.metadata, "sessionID"); | |
| 2235 | if (call !== undefined && childId !== undefined) yield* attachChild(call, childId); | |
| 2236 | return; | |
| 2237 | } | |
| 2238 | case "session.tool.success": { | |
| 2239 | const call = state.calls.get(event.data.id); | |
| 2240 | if (call !== undefined) { | |
| 2241 | const childId = stringField(event.data.metadata, "sessionID"); | |
| 2242 | if (childId !== undefined) yield* attachChild(call, childId); | |
| 2243 | // A background call returns at launch; its report settles it. | |
| 2244 | if (event.data.metadata?.["status"] === "running") return; | |
| 2245 | return yield* settleCall(call, "completed", subagentOutput(textOf(event.data.content))); | |
| 2246 | } | |
| 2247 | const output = textOf(event.data.content); | |
| 2248 | yield* emitTool(state, turn, event.data.id, "completed", { | |
| 2249 | output, | |
| 2250 | metadata: event.data.metadata, | |
| 2251 | }); | |
| 2252 | turn.tools.delete(event.data.id); | |
| 2253 | return; | |
| 2254 | } | |
| 2255 | case "session.tool.failed": { | |
| 2256 | const aborted = event.data.error.type === "aborted"; | |
| 2257 | const call = state.calls.get(event.data.id); | |
| 2258 | // A refused call (unknown agent, the nesting limit) is a failed subagent. | |
| 2259 | if (call !== undefined) { | |
| 2260 | return yield* settleCall( | |
| 2261 | call, | |
| 2262 | aborted ? "interrupted" : "failed", | |
| 2263 | event.data.error.message, | |
| 2264 | ); | |
| 2265 | } | |
| 2266 | yield* emitTool(state, turn, event.data.id, aborted ? "interrupted" : "failed", { | |
| 2267 | output: event.data.error.message, | |
| 2268 | metadata: event.data.metadata, | |
| 2269 | }); | |
| 2270 | turn.tools.delete(event.data.id); | |
| 2271 | return; | |
| 2272 | } | |
| 2273 | // `/compact` and OpenCode's own compaction when the context fills | |
| 2274 | // (`reason: "auto"`) both run inside a turn's execution. | |
| 2275 | case "session.compaction.started": | |
| 2276 | turn.compaction = { | |
| 2277 | nativeId: `${turn.providerTurn.id}:compaction:${turn.compactions++}`, | |
| 2278 | startedAt: yield* DateTime.now, | |
| 2279 | }; | |
| 2280 | return yield* emitCompaction(state, turn, "running"); | |
| 2281 | case "session.compaction.ended": | |
| 2282 | return yield* emitCompaction(state, turn, "completed", event.data.text); | |
| 2283 | case "session.compaction.failed": | |
| 2284 | return yield* emitCompaction(state, turn, "failed"); | |
| 2285 | case "session.step.ended": | |
| 2286 | case "session.step.failed": { | |
| 2287 | const tokens = event.data.tokens; | |
| 2288 | if (tokens === undefined) return; | |
| 2289 | turn.steps += 1; | |
| 2290 | turn.lastStep = tokens; | |
| 2291 | turn.usage.input += tokens.input + tokens.cache.read + tokens.cache.write; | |
| 2292 | turn.usage.cached += tokens.cache.read; | |
| 2293 | turn.usage.cacheWrite += tokens.cache.write; | |
| 2294 | turn.usage.output += tokens.output + tokens.reasoning; | |
| 2295 | turn.usage.reasoning += tokens.reasoning; | |
| 2296 | return; | |
| 2297 | } | |
| 2298 | case "session.execution.started": | |
| 2299 | // The execution that delivers steers the last one ended without. | |
| 2300 | turn.heldEnd = undefined; | |
| 2301 | return; | |
| 2302 | case "session.execution.succeeded": | |
| 2303 | return yield* endExecution(state, turn, { | |
| 2304 | status: turn.interrupted ? "interrupted" : "completed", | |
| 2305 | }); | |
| 2306 | case "session.execution.interrupted": | |
| 2307 | return yield* endExecution(state, turn, { status: "interrupted" }); | |
| 2308 | case "session.execution.failed": | |
| 2309 | return yield* endExecution(state, turn, { | |
| 2310 | status: "failed", | |
| 2311 | failure: makeProviderFailure({ | |
| 2312 | message: event.data.error.message, | |
| 2313 | code: event.data.error.type, | |
| 2314 | class: "provider_error", | |
| 2315 | }), | |
| 2316 | }); | |
| 2317 | default: | |
| 2318 | return; | |
| 2319 | } | |
| 2320 | }); | |
| 2321 | ||
| 2322 | /** | |
| 2323 | * An execution OpenCode started on a thread's session with no turn of T3's | |
| 2324 | * running: the parent's answer to a background subagent's report. It is | |
| 2325 | * held for the continuation turn it asks for, or stopped when it only | |
| 2326 | * reports subagents a Stop ended. | |
| 2327 | */ | |
| 2328 | /** | |
| 2329 | * Takes the reports an execution OpenCode started on its own delivers. When | |
| 2330 | * they all report subagents a Stop ended, that execution only answers the | |
| 2331 | * Stop, so it is stopped and `stopped` is true. | |
| 2332 | */ | |
| 2333 | const takeReports = Effect.fnUntraced(function* (state: ThreadState) { | |
| 2334 | const delivered = [...state.reports.values()]; | |
| 2335 | state.reports.clear(); | |
| 2336 | const stopped = | |
| 2337 | delivered.length > 0 && | |
| 2338 | delivered.every((entry) => state.stoppedChildren.has(entry.childId)); | |
| 2339 | for (const entry of delivered) state.stoppedChildren.delete(entry.childId); | |
| 2340 | if (stopped) { | |
| 2341 | state.unsettled = true; | |
| 2342 | yield* client.session | |
| 2343 | .interrupt({ sessionID: Session.ID.make(state.sessionId) }) | |
| 2344 | .pipe(Effect.timeout(INTERRUPT_TIMEOUT), Effect.ignore({ log: true })); | |
| 2345 | } | |
| 2346 | return { delivered, stopped }; | |
| 2347 | }); | |
| 2348 | ||
| 2349 | const onWake = Effect.fnUntraced(function* (state: ThreadState) { | |
| 2350 | const { delivered, stopped } = yield* takeReports(state); | |
| 2351 | if (stopped) return; | |
| 2352 | const wake: Wake = { | |
| 2353 | events: [], | |
| 2354 | running: true, | |
| 2355 | reports: delivered.map((entry) => entry.report), | |
| 2356 | detail: delivered.length === 0 ? null : delivered.map((entry) => entry.text).join("\n\n"), | |
| 2357 | dropped: false, | |
| 2358 | after: delivered.at(-1)?.inboxId, | |
| 2359 | first: delivered[0]?.inboxId, | |
| 2360 | }; | |
| 2361 | state.wakes.push(wake); | |
| 2362 | yield* Effect.logInfo("OpenCode started a turn on its own; asking for a continuation.", { | |
| 2363 | providerThreadId: state.providerThread.id, | |
| 2364 | }); | |
| 2365 | yield* offerWake(state, wake); | |
| 2366 | }); | |
| 2367 | ||
| 2368 | /** A background subagent's end, as OpenCode queues it for its parent. */ | |
| 2369 | const onReport = Effect.fnUntraced(function* ( | |
| 2370 | state: ThreadState, | |
| 2371 | inboxId: string, | |
| 2372 | payload: { | |
| 2373 | readonly text: string; | |
| 2374 | readonly metadata?: Readonly<Record<string, unknown>> | undefined; | |
| 2375 | }, | |
| 2376 | ) { | |
| 2377 | const childId = stringField(payload.metadata, "childID"); | |
| 2378 | if (stringField(payload.metadata, "source") !== "subagent" || childId === undefined) return; | |
| 2379 | const call = [...state.calls.values()].find( | |
| 2380 | (candidate) => candidate.child?.sessionId === childId, | |
| 2381 | ); | |
| 2382 | const outcome = reportOutcome(stringField(payload.metadata, "state")); | |
| 2383 | state.reports.set(inboxId, { | |
| 2384 | inboxId, | |
| 2385 | childId, | |
| 2386 | text: payload.text, | |
| 2387 | report: { | |
| 2388 | kind: "subagent", | |
| 2389 | label: call?.title ?? stringField(payload.metadata, "description"), | |
| 2390 | childThreadId: call?.child?.subagent?.appThread.id, | |
| 2391 | outcome, | |
| 2392 | }, | |
| 2393 | }); | |
| 2394 | if (call === undefined) return; | |
| 2395 | yield* settleCall( | |
| 2396 | call, | |
| 2397 | state.stoppedChildren.has(childId) | |
| 2398 | ? "interrupted" | |
| 2399 | : outcome === "failed" | |
| 2400 | ? "failed" | |
| 2401 | : outcome === "cancelled" | |
| 2402 | ? "cancelled" | |
| 2403 | : "completed", | |
| 2404 | subagentOutput(payload.text), | |
| 2405 | ); | |
| 2406 | }); | |
| 2407 | ||
| 2408 | /** | |
| 2409 | * The thread whose held execution an event belongs to: the thread's own | |
| 2410 | * session, or a subagent session its held execution started. | |
| 2411 | */ | |
| 2412 | const holderOf = (sessionId: string) => { | |
| 2413 | const own = threads.get(sessionId); | |
| 2414 | if (own !== undefined) return own.wakes.at(-1)?.running === true ? own : undefined; | |
| 2415 | // A subagent a held execution started is named only when that execution replays. | |
| 2416 | const parent = announced.get(sessionId)?.parentID; | |
| 2417 | const holder = parent === undefined ? undefined : threads.get(parent); | |
| 2418 | return holder !== undefined && holder.active === undefined && holder.wakes.length > 0 | |
| 2419 | ? holder | |
| 2420 | : undefined; | |
| 2421 | }; | |
| 2422 | ||
| 2423 | /** | |
| 2424 | * Ends the turn with its execution, unless a steer sent into it is still | |
| 2425 | * undelivered: OpenCode then starts another execution for it, which the | |
| 2426 | * turn owns too. A Stop leaves steers undelivered and starts nothing. | |
| 2427 | */ | |
| 2428 | const endExecution = Effect.fnUntraced(function* ( | |
| 2429 | state: ThreadState, | |
| 2430 | turn: ActiveTurn, | |
| 2431 | end: TurnTerminal, | |
| 2432 | ) { | |
| 2433 | if (end.status === "completed" && turn.steers.size > 0) { | |
| 2434 | turn.heldEnd = end; | |
| 2435 | return; | |
| 2436 | } | |
| 2437 | yield* finishTurn(state, end); | |
| 2438 | }); | |
| 2439 | ||
| 2440 | /** Ends a turn held for its steers once none is left to deliver. */ | |
| 2441 | const endIfSettled = (state: ThreadState, turn: ActiveTurn) => | |
| 2442 | turn.heldEnd !== undefined && turn.steers.size === 0 | |
| 2443 | ? finishTurn(state, turn.heldEnd) | |
| 2444 | : Effect.void; | |
| 2445 | ||
| 2446 | const handleEvent = Effect.fnUntraced(function* (event: OpenCode2Client.OpenCode2StreamEvent) { | |
| 2447 | const sessionId = sessionOfEvent(event); | |
| 2448 | // `revert.clear` wakes the session into an empty execution of its own | |
| 2449 | // (2.0.18's `Session.revert.clear` ends with a wake). It is no run of | |
| 2450 | // T3's, and no follow-up to a subagent either. | |
| 2451 | const cleared = sessionId === undefined ? undefined : clearing.get(sessionId); | |
| 2452 | if (cleared !== undefined) { | |
| 2453 | if (event.type === "unreadable.execution.ended" || executionEnd(event.type)) { | |
| 2454 | return yield* Deferred.succeed(cleared, undefined); | |
| 2455 | } | |
| 2456 | if ( | |
| 2457 | event.type === "session.execution.started" || | |
| 2458 | event.type === "unreadable.execution.started" | |
| 2459 | ) { | |
| 2460 | return; | |
| 2461 | } | |
| 2462 | } | |
| 2463 | if (sessionId !== undefined) { | |
| 2464 | if ( | |
| 2465 | event.type === "session.execution.started" || | |
| 2466 | event.type === "unreadable.execution.started" | |
| 2467 | ) { | |
| 2468 | busy.add(sessionId); | |
| 2469 | } else if (event.type === "unreadable.execution.ended" || executionEnd(event.type)) { | |
| 2470 | busy.delete(sessionId); | |
| 2471 | } | |
| 2472 | } | |
| 2473 | // A subagent's session: named on its parent's `subagent` call just after. | |
| 2474 | if (event.type === "session.created" && event.data.parentID !== undefined) { | |
| 2475 | const owner = ownerOf(event.data.parentID); | |
| 2476 | if (owner !== undefined) { | |
| 2477 | childOwners.set(event.data.sessionID, owner); | |
| 2478 | announced.set(event.data.sessionID, event.data); | |
| 2479 | } | |
| 2480 | return; | |
| 2481 | } | |
| 2482 | // What a held execution does, and what the subagents it starts do, | |
| 2483 | // waits for the turn that takes it. | |
| 2484 | const holder = sessionId === undefined ? undefined : holderOf(sessionId); | |
| 2485 | const held = holder?.wakes.at(-1); | |
| 2486 | if (holder !== undefined && held !== undefined) { | |
| 2487 | held.events.push(event); | |
| 2488 | if ( | |
| 2489 | sessionId === holder.sessionId && | |
| 2490 | (event.type === "unreadable.execution.ended" || executionEnd(event.type)) | |
| 2491 | ) { | |
| 2492 | held.running = false; | |
| 2493 | } | |
| 2494 | return; | |
| 2495 | } | |
| 2496 | return yield* route(event, sessionId); | |
| 2497 | }); | |
| 2498 | ||
| 2499 | /** Handles one event for its session, as the turn running there sees it. */ | |
| 2500 | const route = Effect.fnUntraced(function* ( | |
| 2501 | event: OpenCode2Client.OpenCode2StreamEvent, | |
| 2502 | sessionId: string | undefined, | |
| 2503 | ) { | |
| 2504 | // The end of the run a timed-out Stop left behind; no turn is its own. | |
| 2505 | const ended = | |
| 2506 | event.type === "unreadable.execution.ended" || executionEnd(event.type) | |
| 2507 | ? threads.get(sessionId ?? "") | |
| 2508 | : undefined; | |
| 2509 | if (ended?.unsettled === true) { | |
| 2510 | ended.unsettled = false; | |
| 2511 | return; | |
| 2512 | } | |
| 2513 | // Marks where a running turn's own execution begins; it never ends one. | |
| 2514 | // With no turn running it is a subagent's or a follow-up's start, below. | |
| 2515 | if (event.type === "unreadable.execution.started") { | |
| 2516 | const turn = threads.get(event.sessionID)?.active; | |
| 2517 | if (turn !== undefined) { | |
| 2518 | turn.awaitingStart = false; | |
| 2519 | turn.heldEnd = undefined; | |
| 2520 | return; | |
| 2521 | } | |
| 2522 | } | |
| 2523 | if (event.type === "unreadable.execution.ended") { | |
| 2524 | const state = threads.get(event.sessionID); | |
| 2525 | const turn = state?.active; | |
| 2526 | if (state === undefined || turn === undefined || turn.awaitingStart) return; | |
| 2527 | return yield* endExecution( | |
| 2528 | state, | |
| 2529 | turn, | |
| 2530 | event.executionType === "session.execution.succeeded" | |
| 2531 | ? { status: turn.interrupted ? "interrupted" : "completed" } | |
| 2532 | : event.executionType === "session.execution.interrupted" | |
| 2533 | ? { status: "interrupted" } | |
| 2534 | : { | |
| 2535 | status: "failed", | |
| 2536 | failure: makeProviderFailure({ | |
| 2537 | message: "OpenCode ended the turn with an error this version cannot read.", | |
| 2538 | class: "provider_error", | |
| 2539 | }), | |
| 2540 | }, | |
| 2541 | ); | |
| 2542 | } | |
| 2543 | if (event.type === "permission.asked") { | |
| 2544 | return yield* onAsked({ type: "permission", request: event.data }); | |
| 2545 | } | |
| 2546 | if (event.type === "form.created") | |
| 2547 | return yield* onAsked({ type: "form", form: event.data.form }); | |
| 2548 | // Answered in another OpenCode client, or dropped by OpenCode: a reject | |
| 2549 | // it sends on its own (a Stop, or another reject in the same session) | |
| 2550 | // cancels the request. T3's own answers are settled where they are sent. | |
| 2551 | if ( | |
| 2552 | event.type === "permission.replied" || | |
| 2553 | event.type === "form.replied" || | |
| 2554 | event.type === "form.cancelled" | |
| 2555 | ) { | |
| 2556 | const nativeId = event.type === "permission.replied" ? event.data.requestID : event.data.id; | |
| 2557 | const entry = [...pending.values()].find((candidate) => candidate.native.id === nativeId); | |
| 2558 | if (entry === undefined || entry.answering) return; | |
| 2559 | const answered = | |
| 2560 | event.type === "form.replied" || | |
| 2561 | (event.type === "permission.replied" && event.data.reply !== "reject"); | |
| 2562 | return yield* settleRequest(entry, answered ? "resolved" : "cancelled"); | |
| 2563 | } | |
| 2564 | if (sessionId === undefined) return; | |
| 2565 | const state = threads.get(sessionId); | |
| 2566 | if (state === undefined) return; | |
| 2567 | if (event.type === "session.model.selected" || event.type === "session.step.started") { | |
| 2568 | if (event.type === "session.model.selected") state.model = event.data.model; | |
| 2569 | const providerThread = withReportedModel(state.providerThread, event.data.model); | |
| 2570 | if (providerThread !== state.providerThread) { | |
| 2571 | state.providerThread = { ...providerThread, updatedAt: yield* DateTime.now }; | |
| 2572 | yield* emit({ | |
| 2573 | type: "provider_thread.updated", | |
| 2574 | driver, | |
| 2575 | providerThread: state.providerThread, | |
| 2576 | }); | |
| 2577 | } | |
| 2578 | } | |
| 2579 | if ( | |
| 2580 | event.type === "session.inbox.enqueued" && | |
| 2581 | state.subagent !== undefined && | |
| 2582 | state.active === undefined | |
| 2583 | ) { | |
| 2584 | state.subagent.queued ??= event.data.inboxID; | |
| 2585 | } | |
| 2586 | if (event.type === "session.inbox.enqueued" && event.data.item.type === "synthetic") { | |
| 2587 | return yield* onReport(state, event.data.inboxID, event.data.item.payload); | |
| 2588 | } | |
| 2589 | // Cancelled, or delivered into a turn already running: no wake reports it. | |
| 2590 | // A steer of the running turn's is settled the same way. | |
| 2591 | if (event.type === "session.inbox.cancelled" || event.type === "session.inbox.delivered") { | |
| 2592 | state.reports.delete(event.data.inboxID); | |
| 2593 | const turn = state.active; | |
| 2594 | if (turn !== undefined && turn.steers.delete(event.data.inboxID)) { | |
| 2595 | turn.settledInbox.add(event.data.inboxID); | |
| 2596 | // A cancelled steer may leave a held turn nothing to wait for. | |
| 2597 | if (event.type === "session.inbox.cancelled") yield* endIfSettled(state, turn); | |
| 2598 | } | |
| 2599 | return; | |
| 2600 | } | |
| 2601 | // A subagent's later prompts (a resumed subagent) open its next turn's message. | |
| 2602 | if ( | |
| 2603 | event.type === "session.inbox.enqueued" && | |
| 2604 | event.data.item.type === "user" && | |
| 2605 | state.subagent !== undefined && | |
| 2606 | state.subagent.prompt === undefined && | |
| 2607 | state.active === undefined | |
| 2608 | ) { | |
| 2609 | state.subagent.prompt = event.data.item.payload.text; | |
| 2610 | return; | |
| 2611 | } | |
| 2612 | const started = | |
| 2613 | event.type === "session.execution.started" || event.type === "unreadable.execution.started"; | |
| 2614 | // Each execution of a subagent's session is a turn on its child thread, | |
| 2615 | // unless it only answers the reports of nested subagents a Stop ended. | |
| 2616 | if (state.subagent !== undefined && state.active === undefined && started) { | |
| 2617 | if ((yield* takeReports(state)).stopped) { | |
| 2618 | state.subagent.queued = undefined; | |
| 2619 | return; | |
| 2620 | } | |
| 2621 | return yield* startChildTurn(state); | |
| 2622 | } | |
| 2623 | const turn = state.active; | |
| 2624 | if (turn === undefined) { | |
| 2625 | // OpenCode started the thread's session on its own. | |
| 2626 | if (started && state.subagent === undefined) return yield* onWake(state); | |
| 2627 | return; | |
| 2628 | } | |
| 2629 | // A session runs one execution at a time, and each opens with `started` | |
| 2630 | // on this ordered stream, so what comes before it is the stopped run's. | |
| 2631 | if (turn.awaitingStart) { | |
| 2632 | if (event.type === "session.execution.started") turn.awaitingStart = false; | |
| 2633 | return; | |
| 2634 | } | |
| 2635 | return yield* onTurnEvent(state, turn, event); | |
| 2636 | }); | |
| 2637 | ||
| 2638 | // The stream is the only terminal signal, and it is volatile: events sent | |
| 2639 | // while it is down are gone, and a restarted server never ends the | |
| 2640 | // execution it lost. So a lost stream reconnects, then reconciles each | |
| 2641 | // running turn from the server's own state (see `reconcile`). Only when | |
| 2642 | // reconnecting keeps failing are the turns failed and the session broken, | |
| 2643 | // so T3 reopens it. Set first, so a turn starting meanwhile waits or refuses. | |
| 2644 | let streamFailure: string | undefined; | |
| 2645 | let reconnected = yield* Deferred.make<void>(); | |
| 2646 | const failAll = Effect.fnUntraced(function* (message: string) { | |
| 2647 | streamFailure = message; | |
| 2648 | yield* Deferred.succeed(reconnected, undefined); | |
| 2649 | for (const state of threads.values()) { | |
| 2650 | const failure = makeProviderFailure({ message, class: "transport_error" }); | |
| 2651 | yield* finishTurn(state, { status: "failed", failure }, "broken"); | |
| 2652 | } | |
| 2653 | // Nothing can report a background subagent's end any more. | |
| 2654 | for (const state of threads.values()) { | |
| 2655 | for (const call of runningCalls(state)) yield* settleCall(call, "failed"); | |
| 2656 | for (const wake of state.wakes.splice(0)) wake.dropped = true; | |
| 2657 | state.reports.clear(); | |
| 2658 | } | |
| 2659 | yield* setSessionStatus("error", message); | |
| 2660 | yield* Queue.end(events); | |
| 2661 | }); | |
| 2662 | /** | |
| 2663 | * Emits what a running turn missed while the stream was down, from the | |
| 2664 | * session's history since the turn began: text, reasoning and tools, each | |
| 2665 | * under the same native id its live events would have used, so nothing | |
| 2666 | * already shown is duplicated. A turn begins at its prompt, whose id T3 | |
| 2667 | * chose (`nativeTurnRef`), or after `before` for a `/name` command or a | |
| 2668 | * continuation. | |
| 2669 | * Returns how that history says the turn's execution ended: the `idle` item | |
| 2670 | * OpenCode appends after each one, or undefined when there is none after | |
| 2671 | * the turn's start. | |
| 2672 | */ | |
| 2673 | const backfill = Effect.fnUntraced(function* (sessionId: string, state: ThreadState) { | |
| 2674 | const turn = state.active; | |
| 2675 | if (turn === undefined) return undefined; | |
| 2676 | const promptId = promptOf(turn.providerTurn); | |
| 2677 | const { before } = turn; | |
| 2678 | if (promptId === undefined && before === undefined) return undefined; | |
| 2679 | const start = before !== undefined ? before : promptId; | |
| 2680 | const read = yield* OpenCode2Client.paginate( | |
| 2681 | { sessionID: Session.ID.make(sessionId), order: "desc" as const, limit: 50 }, | |
| 2682 | client.message.list, | |
| 2683 | ).pipe( | |
| 2684 | Stream.takeUntil((message) => message.id === start), | |
| 2685 | Stream.runCollect, | |
| 2686 | ); | |
| 2687 | // Without its start the history holds only earlier turns (the stream | |
| 2688 | // dropped before the prompt landed): none of it is this turn's. A null | |
| 2689 | // `before` is a command that started on an empty history. | |
| 2690 | const found = start === null || read.at(-1)?.id === start; | |
| 2691 | if (!found) return undefined; | |
| 2692 | const recent = before !== undefined && before !== null ? read.slice(0, -1) : read; | |
| 2693 | const idle = recent.find((message) => message.type === "idle"); | |
| 2694 | for (const message of recent.toReversed()) { | |
| 2695 | if (message.type !== "assistant" || state.active !== turn) continue; | |
| 2696 | const ordinals = { text: 0, reasoning: 0 }; | |
| 2697 | for (const part of message.content) { | |
| 2698 | if (part.type === "text" || part.type === "reasoning") { | |
| 2699 | const block = { assistantMessageID: message.id, ordinal: ordinals[part.type]++ }; | |
| 2700 | yield* emitText(state, turn, block, part.type, () => part.text, true); | |
| 2701 | continue; | |
| 2702 | } | |
| 2703 | if (part.type !== "tool") continue; | |
| 2704 | if (!turn.tools.has(part.id)) { | |
| 2705 | turn.tools.set(part.id, { name: part.name, input: {} }); | |
| 2706 | turn.startedAt.set(part.id, yield* DateTime.now); | |
| 2707 | } | |
| 2708 | const tool = turn.tools.get(part.id)!; | |
| 2709 | if (typeof part.state.input === "object" && part.state.input !== null) { | |
| 2710 | tool.input = part.state.input as Record<string, unknown>; | |
| 2711 | } | |
| 2712 | if (part.state.status === "completed") { | |
| 2713 | yield* emitTool(state, turn, part.id, "completed", { | |
| 2714 | output: textOf(part.state.content), | |
| 2715 | metadata: part.state.metadata, | |
| 2716 | }); | |
| 2717 | turn.tools.delete(part.id); | |
| 2718 | } else if (part.state.status === "error") { | |
| 2719 | yield* emitTool(state, turn, part.id, "failed", { | |
| 2720 | output: part.state.error.message, | |
| 2721 | metadata: part.state.metadata, | |
| 2722 | }); | |
| 2723 | turn.tools.delete(part.id); | |
| 2724 | } else { | |
| 2725 | yield* emitTool(state, turn, part.id, "running"); | |
| 2726 | } | |
| 2727 | } | |
| 2728 | } | |
| 2729 | return idle === undefined ? undefined : { outcome: idle.outcome }; | |
| 2730 | }); | |
| 2731 | ||
| 2732 | /** | |
| 2733 | * Settles each thread turn that was running when the stream dropped, from | |
| 2734 | * the server: a session still running keeps its turn (its next events | |
| 2735 | * arrive on the new stream); one that stopped ends the turn with the | |
| 2736 | * outcome of the `idle` item after the turn's start. A restarted server | |
| 2737 | * writes no such item for the execution it lost, and `session.outcome` is | |
| 2738 | * still the previous execution's, so no `idle` means the turn was | |
| 2739 | * interrupted. A continuation turn is settled the same way, from the report | |
| 2740 | * its execution answers. A turn still holding for an undelivered steer only | |
| 2741 | * ends once the session is idle. Permission asks and question forms are | |
| 2742 | * read back from every session still running, so a run that asked during | |
| 2743 | * the gap can be answered. | |
| 2744 | * | |
| 2745 | * What the stream carried outside a turn is not read back: a background | |
| 2746 | * subagent's report, a follow-up OpenCode started on its own, or one held | |
| 2747 | * for its continuation turn. So background work is settled, not resumed: a | |
| 2748 | * subagent whose session stopped ends as interrupted, and a thread's held | |
| 2749 | * follow-ups and queued reports are dropped, so nothing waits on an event | |
| 2750 | * that already went by. | |
| 2751 | */ | |
| 2752 | const reconcile = Effect.gen(function* () { | |
| 2753 | // A subagent's turn ends before the turn that waits on it, so the | |
| 2754 | // deepest sessions are read back first. | |
| 2755 | const running = [...threads] | |
| 2756 | .filter(([, state]) => state.active !== undefined) | |
| 2757 | .toSorted(([left], [right]) => callsAbove(right).length - callsAbove(left).length); | |
| 2758 | const background = [...threads.values()].filter( | |
| 2759 | (state) => state.subagent === undefined && hasBackground(state), | |
| 2760 | ); | |
| 2761 | if (running.length === 0 && background.length === 0 && busy.size === 0) return; | |
| 2762 | const active = yield* client.session.active(); | |
| 2763 | // An execution that ended while the stream was down never said so. | |
| 2764 | for (const sessionId of busy) if (!(sessionId in active)) busy.delete(sessionId); | |
| 2765 | for (const [sessionId, state] of running) { | |
| 2766 | const turn = state.active; | |
| 2767 | if (turn === undefined) continue; | |
| 2768 | const ended = yield* backfill(sessionId, state); | |
| 2769 | if (sessionId in active || state.active !== turn) continue; | |
| 2770 | yield* finishTurn( | |
| 2771 | state, | |
| 2772 | ended?.outcome === "succeeded" | |
| 2773 | ? { status: turn.interrupted ? "interrupted" : "completed" } | |
| 2774 | : ended?.outcome === "failed" | |
| 2775 | ? { | |
| 2776 | status: "failed", | |
| 2777 | failure: makeProviderFailure({ | |
| 2778 | message: | |
| 2779 | "OpenCode ended the turn with an error while T3 Code was reconnecting.", | |
| 2780 | class: "provider_error", | |
| 2781 | }), | |
| 2782 | } | |
| 2783 | : { status: "interrupted" }, | |
| 2784 | ); | |
| 2785 | } | |
| 2786 | for (const state of background) { | |
| 2787 | // A subagent still running keeps its call; its end arrives on the new stream. | |
| 2788 | for (const call of runningCalls(state)) { | |
| 2789 | const child = call.child?.sessionId; | |
| 2790 | if (child === undefined || !(child in active)) { | |
| 2791 | yield* settleCall(call, "interrupted", LOST_BACKGROUND); | |
| 2792 | } | |
| 2793 | } | |
| 2794 | // A subagent's session holds reports of its own background subagents. | |
| 2795 | for (const session of sessionsOf(state)) { | |
| 2796 | for (const wake of session.wakes.splice(0)) wake.dropped = true; | |
| 2797 | session.reports.clear(); | |
| 2798 | } | |
| 2799 | } | |
| 2800 | // A request asked while the stream was down was never shown, and the | |
| 2801 | // run waits on it; one T3 shows that OpenCode no longer lists was | |
| 2802 | // answered elsewhere or dropped with its execution. A session that | |
| 2803 | // stopped dropped its requests. | |
| 2804 | const listed = new Map<string, Set<string>>(); | |
| 2805 | for (const sessionId of Object.keys(active)) { | |
| 2806 | if (ownerOf(sessionId) === undefined) continue; | |
| 2807 | const sessionID = Session.ID.make(sessionId); | |
| 2808 | const [permissions, forms] = yield* Effect.all([ | |
| 2809 | client.permission.list({ sessionID }), | |
| 2810 | client.session.form.list({ sessionID }), | |
| 2811 | ]); | |
| 2812 | listed.set(sessionId, new Set([...permissions, ...forms].map((request) => request.id))); | |
| 2813 | const shown = new Set([...pending.values()].map((entry) => entry.native.id)); | |
| 2814 | for (const request of permissions) { | |
| 2815 | if (!shown.has(request.id)) yield* onAsked({ type: "permission", request }); | |
| 2816 | } | |
| 2817 | for (const form of forms) { | |
| 2818 | if (!shown.has(form.id)) yield* onAsked({ type: "form", form }); | |
| 2819 | } | |
| 2820 | } | |
| 2821 | // A snapshot: settling a request removes it from the map. | |
| 2822 | for (const entry of Array.from(pending.values())) { | |
| 2823 | if (listed.get(entry.sessionId)?.has(entry.native.id) !== true) { | |
| 2824 | yield* settleRequest(entry, "cancelled"); | |
| 2825 | } | |
| 2826 | } | |
| 2827 | }); | |
| 2828 | ||
| 2829 | /** | |
| 2830 | * Subscribes again, on a restarted server if the old one is gone, and | |
| 2831 | * reconciles. Retried a few times; the caller fails everything after that. | |
| 2832 | */ | |
| 2833 | const reconnect = Effect.gen(function* () { | |
| 2834 | // Returned on any failure, including the interrupt a closing session | |
| 2835 | // sends a reconnect still in flight, so a spawned server can idle-stop. | |
| 2836 | const scope = yield* Scope.make(); | |
| 2837 | const { next, stream } = yield* Effect.gen(function* () { | |
| 2838 | const next = yield* borrow.pipe(Effect.provideService(Scope.Scope, scope)); | |
| 2839 | return { next, stream: yield* next.events }; | |
| 2840 | }).pipe(Effect.onError(() => Scope.close(scope, Exit.void))); | |
| 2841 | const previous = currentScope; | |
| 2842 | connection = next; | |
| 2843 | client = next.client; | |
| 2844 | currentScope = scope; | |
| 2845 | yield* Scope.close(previous, Exit.void); | |
| 2846 | // A restarted server forgot T3's MCP servers; the next turn adds them again. | |
| 2847 | for (const state of threads.values()) state.mcp = undefined; | |
| 2848 | yield* lock.withPermit(reconcile).pipe(Effect.timeout(RECONCILE_TIMEOUT)); | |
| 2849 | return stream; | |
| 2850 | }).pipe( | |
| 2851 | Effect.retry({ times: RECONNECT_ATTEMPTS - 1, schedule: Schedule.spaced(RECONNECT_DELAY) }), | |
| 2852 | ); | |
| 2853 | ||
| 2854 | let currentScope = initial.scope; | |
| 2855 | // The borrow in use when the session closes is returned with it, so a | |
| 2856 | // spawned server can still reach its idle shutdown. | |
| 2857 | yield* Effect.addFinalizer(() => Scope.close(currentScope, Exit.void)); | |
| 2858 | const follow = ( | |
| 2859 | stream: Stream.Stream<OpenCode2Client.OpenCode2StreamEvent, unknown>, | |
| 2860 | ): Effect.Effect<void> => | |
| 2861 | stream.pipe( | |
| 2862 | Stream.runForEach((event) => lock.withPermit(handleEvent(event))), | |
| 2863 | Effect.exit, | |
| 2864 | Effect.flatMap(() => | |
| 2865 | Effect.gen(function* () { | |
| 2866 | streamFailure = "The OpenCode event stream was lost. Reconnecting."; | |
| 2867 | yield* Effect.logWarning("Lost the OpenCode event stream; reconnecting."); | |
| 2868 | // The last failure is kept for the log: it can carry the server's | |
| 2869 | // URL or response text, so only its tag is annotated and the full | |
| 2870 | // failure stays in the log's cause. | |
| 2871 | const next = yield* reconnect.pipe( | |
| 2872 | Effect.tapCause((cause) => | |
| 2873 | Effect.logWarning( | |
| 2874 | "Could not reconnect to the OpenCode event stream; failing the session.", | |
| 2875 | cause, | |
| 2876 | ).pipe(Effect.annotateLogs({ errorTag: causeErrorTag(cause) })), | |
| 2877 | ), | |
| 2878 | Effect.option, | |
| 2879 | ); | |
| 2880 | if (next._tag === "None") { | |
| 2881 | return yield* failAll("The OpenCode event stream was lost and could not reconnect."); | |
| 2882 | } | |
| 2883 | streamFailure = undefined; | |
| 2884 | const done = reconnected; | |
| 2885 | reconnected = yield* Deferred.make<void>(); | |
| 2886 | yield* Deferred.succeed(done, undefined); | |
| 2887 | return yield* follow(next.value); | |
| 2888 | }), | |
| 2889 | ), | |
| 2890 | ); | |
| 2891 | // Subscribed before any session or prompt call, so no event of theirs is missed. | |
| 2892 | yield* follow(yield* connection.events).pipe(Effect.forkScoped); | |
| 2893 | ||
| 2894 | // A server T3 did not start keeps running after T3 stops, so stop the turns | |
| 2895 | // it would otherwise finish unseen. A spawned server stops with its owner. | |
| 2896 | if (connection.external) { | |
| 2897 | yield* Effect.addFinalizer(() => | |
| 2898 | Effect.forEach( | |
| 2899 | [...threads].filter(([, state]) => state.active !== undefined), | |
| 2900 | ([sessionId]) => | |
| 2901 | client.session | |
| 2902 | .interrupt({ sessionID: Session.ID.make(sessionId) }) | |
| 2903 | .pipe(Effect.timeout("1 second"), Effect.ignore({ log: true })), | |
| 2904 | { concurrency: 8, discard: true }, | |
| 2905 | ), | |
| 2906 | ); | |
| 2907 | } | |
| 2908 | ||
| 2909 | // Context windows come from the server's model list for a directory, read | |
| 2910 | // when the session opens, before a thread first runs in another directory, | |
| 2911 | // and again after a turn whose model had none yet. A directory counts as | |
| 2912 | // known once its read starts, so a failed read is retried only after a turn. | |
| 2913 | const readModels = (directory: string) => | |
| 2914 | Effect.suspend(() => { | |
| 2915 | const windows = contextWindows.get(directory) ?? new Map<string, number>(); | |
| 2916 | contextWindows.set(directory, windows); | |
| 2917 | return client.model.list({ location: { directory } }).pipe( | |
| 2918 | Effect.timeout("5 seconds"), | |
| 2919 | Effect.tap((models) => | |
| 2920 | Effect.sync(() => { | |
| 2921 | for (const model of models.data) { | |
| 2922 | // A model's input limit, when it has one, is its real headroom. | |
| 2923 | windows.set( | |
| 2924 | `${model.providerID}/${model.id}`, | |
| 2925 | model.limit.input ?? model.limit.context, | |
| 2926 | ); | |
| 2927 | } | |
| 2928 | }), | |
| 2929 | ), | |
| 2930 | Effect.ignore({ log: true }), | |
| 2931 | ); | |
| 2932 | }); | |
| 2933 | const readModelsOnce = (cwd: string | null | undefined) => | |
| 2934 | contextWindows.has(directoryOf(cwd)) ? Effect.void : readModels(directoryOf(cwd)); | |
| 2935 | yield* readModels(session.cwd); | |
| 2936 | ||
| 2937 | // Each agent's own path allows, by the directory they were listed for. | |
| 2938 | const agentRules = new Map<string, ReadonlyMap<string, ReadonlyArray<Rule>>>(); | |
| 2939 | const pathsFor = Effect.fnUntraced(function* ( | |
| 2940 | directory: string, | |
| 2941 | agents: ReadonlyArray<string>, | |
| 2942 | ) { | |
| 2943 | let known = agentRules.get(directory); | |
| 2944 | if (known === undefined) { | |
| 2945 | // A fresh server lists no agents for its first moments, like its models. | |
| 2946 | const listed = yield* client.agent.list({ location: { directory } }).pipe( | |
| 2947 | Effect.repeat({ | |
| 2948 | until: (list) => list.data.length > 0, | |
| 2949 | schedule: Schedule.spaced("250 millis"), | |
| 2950 | }), | |
| 2951 | Effect.timeout("5 seconds"), | |
| 2952 | // The failure can carry the server's URL or response text, so only its | |
| 2953 | // tag is annotated; the full failure stays in the log's cause. | |
| 2954 | Effect.tapCause((cause) => | |
| 2955 | Effect.logWarning( | |
| 2956 | "Could not list OpenCode agents; their path rules are skipped.", | |
| 2957 | cause, | |
| 2958 | ).pipe(Effect.annotateLogs({ errorTag: causeErrorTag(cause) })), | |
| 2959 | ), | |
| 2960 | Effect.option, | |
| 2961 | ); | |
| 2962 | if (listed._tag === "None") return []; | |
| 2963 | known = new Map( | |
| 2964 | listed.value.data.map((agent) => [agent.id, agentPaths(agent.permissions)]), | |
| 2965 | ); | |
| 2966 | agentRules.set(directory, known); | |
| 2967 | } | |
| 2968 | const paths = new Map<string, Rule>(); | |
| 2969 | for (const agent of agents) { | |
| 2970 | for (const entry of known.get(agent) ?? []) { | |
| 2971 | paths.set(`${entry.action}\u0000${entry.resource}`, entry); | |
| 2972 | } | |
| 2973 | } | |
| 2974 | return [...paths.values()]; | |
| 2975 | }); | |
| 2976 | ||
| 2977 | /** | |
| 2978 | * The rules a thread's session runs `policy` with. Full access allows | |
| 2979 | * every path already, so the agent's own path rules are read only for the | |
| 2980 | * modes that narrow it. | |
| 2981 | */ | |
| 2982 | const rulesFor = Effect.fnUntraced(function* ( | |
| 2983 | thread: Pick<ThreadState, "directory" | "agent" | "grants">, | |
| 2984 | policy: RulesPolicy, | |
| 2985 | appThreadId: string | null, | |
| 2986 | ) { | |
| 2987 | const plan = policy.interactionMode === "plan"; | |
| 2988 | const paths = | |
| 2989 | policy.runtimeMode === "full-access" && !plan | |
| 2990 | ? [] | |
| 2991 | : yield* pathsFor(thread.directory, plan ? [thread.agent, "plan"] : [thread.agent]); | |
| 2992 | return sessionRules( | |
| 2993 | policy, | |
| 2994 | paths, | |
| 2995 | policy.runtimeMode === "full-access" ? [] : thread.grants, | |
| 2996 | appThreadId === null ? null : yield* mcpServerNameFor(appThreadId), | |
| 2997 | ); | |
| 2998 | }); | |
| 2999 | ||
| 3000 | /** Writes the session's rules for `policy` when they differ from what it has. */ | |
| 3001 | const writeRules = Effect.fnUntraced(function* (state: ThreadState, policy: RulesPolicy) { | |
| 3002 | // A subagent's session may use its thread's T3 server, the root's. | |
| 3003 | const rules = yield* rulesFor(state, policy, rootOf(state).providerThread.appThreadId); | |
| 3004 | if (!sameRules(state.rules, rules)) { | |
| 3005 | yield* client.session.update({ | |
| 3006 | sessionID: Session.ID.make(state.sessionId), | |
| 3007 | permissions: rules, | |
| 3008 | }); | |
| 3009 | state.rules = rules; | |
| 3010 | } | |
| 3011 | state.policy = policy; | |
| 3012 | }); | |
| 3013 | ||
| 3014 | const register = ( | |
| 3015 | providerThread: OrchestrationV2ProviderThread, | |
| 3016 | native: { | |
| 3017 | readonly id: string; | |
| 3018 | readonly model?: ModelRef | undefined; | |
| 3019 | readonly agent?: string | undefined; | |
| 3020 | readonly permissions?: ReadonlyArray<Rule> | undefined; | |
| 3021 | }, | |
| 3022 | directory: string, | |
| 3023 | ) => { | |
| 3024 | providerThread = withReportedModel(providerThread, native.model); | |
| 3025 | const existing = threads.get(native.id); | |
| 3026 | if (existing !== undefined) { | |
| 3027 | existing.providerThread = providerThread; | |
| 3028 | existing.model = native.model; | |
| 3029 | existing.directory = directory; | |
| 3030 | existing.agent = native.agent ?? existing.agent; | |
| 3031 | existing.rules = native.permissions; | |
| 3032 | return existing; | |
| 3033 | } | |
| 3034 | const state = newThreadState(native.id, providerThread, directory, undefined); | |
| 3035 | state.model = native.model; | |
| 3036 | state.agent = native.agent ?? state.agent; | |
| 3037 | state.rules = native.permissions; | |
| 3038 | threads.set(native.id, state); | |
| 3039 | return state; | |
| 3040 | }; | |
| 3041 | ||
| 3042 | /** | |
| 3043 | * Refuses to cut or copy a session's history while something writes to | |
| 3044 | * it: a turn of T3's, an execution seen running on the stream (a held | |
| 3045 | * follow-up), or a run on the server this runtime does not own: one a | |
| 3046 | * timed-out Stop or an unanswered request left behind, or any run on a | |
| 3047 | * session loaded after the server outlived T3. Only that last case asks | |
| 3048 | * the server. A rollback also waits for background subagents, whose | |
| 3049 | * reports wake the session into the history it would cut. | |
| 3050 | */ | |
| 3051 | const ensureQuiet = Effect.fnUntraced(function* ( | |
| 3052 | sessionId: string, | |
| 3053 | verb: "roll back" | "fork", | |
| 3054 | ) { | |
| 3055 | const state = threads.get(sessionId); | |
| 3056 | const busyHere = | |
| 3057 | state !== undefined && | |
| 3058 | (state.active !== undefined || | |
| 3059 | busy.has(sessionId) || | |
| 3060 | (verb === "roll back" && hasBackground(state))); | |
| 3061 | const busyThere = | |
| 3062 | !busyHere && | |
| 3063 | (state === undefined || state.unsettled) && | |
| 3064 | sessionId in (yield* client.session.active().pipe(Effect.timeout(ACTIVE_CHECK_TIMEOUT))); | |
| 3065 | if (busyHere || busyThere) { | |
| 3066 | return yield* new ProviderAdapter.ProviderAdapterProtocolError({ | |
| 3067 | driver: OPENCODE_PROVIDER, | |
| 3068 | detail: `Cannot ${verb} OpenCode session ${sessionId} while it is still working`, | |
| 3069 | }); | |
| 3070 | } | |
| 3071 | }); | |
| 3072 | ||
| 3073 | /** | |
| 3074 | * Takes back steers a finished turn left in OpenCode's inbox. One whose | |
| 3075 | * cancel fails may still be there, so it is tried again before the next prompt. | |
| 3076 | */ | |
| 3077 | const cancelStrandedSteers = Effect.fnUntraced(function* (state: ThreadState) { | |
| 3078 | const sessionID = Session.ID.make(state.sessionId); | |
| 3079 | // A snapshot: a cancelled steer leaves the set. | |
| 3080 | for (const inboxID of Array.from(state.strandedSteers)) { | |
| 3081 | const cancelled = yield* client.session.inbox | |
| 3082 | .cancel({ sessionID, inboxID: SessionMessage.ID.make(inboxID) }) | |
| 3083 | .pipe( | |
| 3084 | Effect.timeout(REQUEST_REPLY_TIMEOUT), | |
| 3085 | Effect.tapCause((cause) => | |
| 3086 | Effect.logWarning("Could not take back an OpenCode steer.", cause), | |
| 3087 | ), | |
| 3088 | Effect.exit, | |
| 3089 | Effect.map(Exit.isSuccess), | |
| 3090 | ); | |
| 3091 | if (cancelled) state.strandedSteers.delete(inboxID); | |
| 3092 | } | |
| 3093 | }); | |
| 3094 | ||
| 3095 | /** | |
| 3096 | * Clears a staged revert and waits out the empty execution `clear` runs, | |
| 3097 | * which is no turn of T3's. 2.0.18 wakes the session after every clear, | |
| 3098 | * with or without a stage (seen live), so that execution always comes. | |
| 3099 | */ | |
| 3100 | const clearRevert = Effect.fnUntraced(function* (sessionId: string) { | |
| 3101 | const settled = yield* Deferred.make<void>(); | |
| 3102 | clearing.set(sessionId, settled); | |
| 3103 | yield* client.session.revert | |
| 3104 | .clear({ sessionID: Session.ID.make(sessionId) }) | |
| 3105 | .pipe( | |
| 3106 | Effect.andThen(Deferred.await(settled)), | |
| 3107 | Effect.timeout(REQUEST_REPLY_TIMEOUT), | |
| 3108 | Effect.ensuring(Effect.sync(() => clearing.delete(sessionId))), | |
| 3109 | ); | |
| 3110 | stagedReverts.delete(sessionId); | |
| 3111 | }); | |
| 3112 | ||
| 3113 | /** | |
| 3114 | * Truncates the session's history before `boundary`: OpenCode stages the | |
| 3115 | * revert, then commits it. If either fails or is interrupted the stage is | |
| 3116 | * cleared, since OpenCode commits a staged revert on the next prompt; a | |
| 3117 | * clear that fails too is tried again before that prompt. Files stay where | |
| 3118 | * they are: T3 restores its own checkpoint when the user asked for files, | |
| 3119 | * and a rewind without files must not touch them. | |
| 3120 | */ | |
| 3121 | const rollBackTo = Effect.fnUntraced(function* ( | |
| 3122 | sessionId: string, | |
| 3123 | boundary: SessionMessage.ID, | |
| 3124 | ) { | |
| 3125 | const sessionID = Session.ID.make(sessionId); | |
| 3126 | // Each step is bounded: a server that never answers would otherwise | |
| 3127 | // hold the session's gate, and the next turn with it. | |
| 3128 | yield* client.session.revert.stage({ sessionID, messageID: boundary, files: false }).pipe( | |
| 3129 | Effect.timeout(REQUEST_REPLY_TIMEOUT), | |
| 3130 | Effect.andThen( | |
| 3131 | client.session.revert.commit({ sessionID }).pipe(Effect.timeout(REQUEST_REPLY_TIMEOUT)), | |
| 3132 | ), | |
| 3133 | Effect.onError(() => | |
| 3134 | clearRevert(sessionId).pipe( | |
| 3135 | Effect.tapError(() => Effect.sync(() => stagedReverts.add(sessionId))), | |
| 3136 | Effect.ignore({ log: true }), | |
| 3137 | ), | |
| 3138 | ), | |
| 3139 | ); | |
| 3140 | }); | |
| 3141 | ||
| 3142 | /** | |
| 3143 | * The ids fork and rollback can cut before: the session's prompts, and the | |
| 3144 | * reports continuations answer. The list takes one type, so it reads all. | |
| 3145 | */ | |
| 3146 | const userMessages = (sessionId: string) => | |
| 3147 | OpenCode2Client.paginate( | |
| 3148 | { sessionID: Session.ID.make(sessionId), limit: 100 }, | |
| 3149 | client.message.list, | |
| 3150 | ).pipe( | |
| 3151 | Stream.runCollect, | |
| 3152 | Effect.map( | |
| 3153 | (messages) => | |
| 3154 | new Set( | |
| 3155 | messages.flatMap((message) => | |
| 3156 | message.type === "user" || message.type === "synthetic" ? [String(message.id)] : [], | |
| 3157 | ), | |
| 3158 | ), | |
| 3159 | ), | |
| 3160 | ); | |
| 3161 | ||
| 3162 | /** The session's history as V2 messages, read after any change T3 made to it. */ | |
| 3163 | const snapshotOf = Effect.fnUntraced(function* ( | |
| 3164 | providerThread: OrchestrationV2ProviderThread, | |
| 3165 | sessionId: string, | |
| 3166 | ) { | |
| 3167 | const history = yield* OpenCode2Client.paginate( | |
| 3168 | { sessionID: Session.ID.make(sessionId), order: "asc" as const, limit: 100 }, | |
| 3169 | client.message.list, | |
| 3170 | ).pipe(Stream.runCollect); | |
| 3171 | const snapshotAt = yield* DateTime.now; | |
| 3172 | const messages = history.flatMap((message): Array<OrchestrationV2ConversationMessage> => { | |
| 3173 | const text = | |
| 3174 | message.type === "user" | |
| 3175 | ? message.text | |
| 3176 | : message.type === "assistant" | |
| 3177 | ? textOf(message.content) | |
| 3178 | : ""; | |
| 3179 | if (text.length === 0 || (message.type !== "user" && message.type !== "assistant")) { | |
| 3180 | return []; | |
| 3181 | } | |
| 3182 | const createdAt = message.time.created; | |
| 3183 | return [ | |
| 3184 | { | |
| 3185 | createdBy: message.type === "user" ? "user" : "agent", | |
| 3186 | creationSource: "provider", | |
| 3187 | id: idAllocator.derive.messageFromProviderItem({ | |
| 3188 | driver, | |
| 3189 | nativeItemId: message.id, | |
| 3190 | }), | |
| 3191 | threadId: providerThread.appThreadId ?? input.threadId, | |
| 3192 | runId: null, | |
| 3193 | nodeId: null, | |
| 3194 | role: message.type, | |
| 3195 | text, | |
| 3196 | attachments: [], | |
| 3197 | streaming: false, | |
| 3198 | createdAt, | |
| 3199 | updatedAt: createdAt, | |
| 3200 | }, | |
| 3201 | ]; | |
| 3202 | }); | |
| 3203 | const lastUser = history.findLast((message) => message.type === "user")?.id; | |
| 3204 | const state = threads.get(sessionId); | |
| 3205 | return { | |
| 3206 | providerThread: { | |
| 3207 | ...providerThread, | |
| 3208 | providerSessionId: input.providerSessionId, | |
| 3209 | nativeConversationHeadRef: lastUser === undefined ? null : ref(lastUser, "weak"), | |
| 3210 | status: "idle" as const, | |
| 3211 | updatedAt: snapshotAt, | |
| 3212 | }, | |
| 3213 | providerTurns: state === undefined ? [] : [...state.providerTurns.values()], | |
| 3214 | messages, | |
| 3215 | runtimeRequests: [], | |
| 3216 | }; | |
| 3217 | }); | |
| 3218 | ||
| 3219 | const promptText = (turnInput: ProviderAdapter.ProviderAdapterV2TurnInput) => | |
| 3220 | providerMessageTextWithAttachmentPaths({ | |
| 3221 | text: turnInput.message.text, | |
| 3222 | attachments: turnInput.message.attachments, | |
| 3223 | resolveAttachmentPath: host.resolveAttachmentPath, | |
| 3224 | }).trim(); | |
| 3225 | ||
| 3226 | const removeMcp = (mcp: { readonly name: string; readonly directory: string }) => | |
| 3227 | Effect.suspend(() => | |
| 3228 | client.mcp.remove({ server: mcp.name, location: { directory: mcp.directory } }), | |
| 3229 | ).pipe(Effect.timeout("5 seconds"), Effect.ignore({ log: true })); | |
| 3230 | ||
| 3231 | // T3's MCP registrations outlive a session only on an external server; a | |
| 3232 | // spawned one forgets them when it stops. | |
| 3233 | yield* Effect.addFinalizer(() => | |
| 3234 | Effect.forEach( | |
| 3235 | [...threads.values()].flatMap((state) => (state.mcp === undefined ? [] : [state.mcp])), | |
| 3236 | removeMcp, | |
| 3237 | { concurrency: 8, discard: true }, | |
| 3238 | ), | |
| 3239 | ); | |
| 3240 | ||
| 3241 | /** | |
| 3242 | * T3's MCP server for this thread, and the per-turn instructions. The MCP | |
| 3243 | * server is registered for the session's directory under the thread's own | |
| 3244 | * name and credential (the session rules allow only it), and removed when | |
| 3245 | * the thread unloads or the session closes. OpenCode 2 has no per-prompt | |
| 3246 | * system field, so the instructions are a session instructions entry, | |
| 3247 | * which applies from the next step; it is only rewritten when it changes. | |
| 3248 | */ | |
| 3249 | const prepareTurn = Effect.fnUntraced(function* ( | |
| 3250 | sessionId: string, | |
| 3251 | state: ThreadState, | |
| 3252 | turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, | |
| 3253 | ) { | |
| 3254 | const mcpSession = McpProviderSession.readMcpProviderSession(turnInput.threadId); | |
| 3255 | const directory = turnInput.runtimePolicy.cwd ?? host.paths.cwd; | |
| 3256 | const name = yield* mcpServerNameFor(turnInput.threadId); | |
| 3257 | // An external server may not reach T3's MCP endpoint, as with 1.x. | |
| 3258 | const wanted = | |
| 3259 | mcpSession === undefined || connection.external | |
| 3260 | ? undefined | |
| 3261 | : { name, directory, credential: mcpSession.authorizationHeader }; | |
| 3262 | if ( | |
| 3263 | state.mcp !== undefined && | |
| 3264 | (wanted === undefined || | |
| 3265 | state.mcp.directory !== wanted.directory || | |
| 3266 | state.mcp.credential !== wanted.credential) | |
| 3267 | ) { | |
| 3268 | yield* removeMcp(state.mcp); | |
| 3269 | state.mcp = undefined; | |
| 3270 | } | |
| 3271 | // T3's tools are an addition: a server that cannot add them still runs the turn. | |
| 3272 | if (wanted !== undefined && state.mcp === undefined) { | |
| 3273 | const added = yield* client.mcp | |
| 3274 | .add({ | |
| 3275 | server: name, | |
| 3276 | location: { directory }, | |
| 3277 | config: new Mcp.RemoteConfig({ | |
| 3278 | type: "remote", | |
| 3279 | url: mcpSession!.endpoint, | |
| 3280 | headers: { Authorization: wanted.credential }, | |
| 3281 | oauth: false, | |
| 3282 | }), | |
| 3283 | }) | |
| 3284 | .pipe( | |
| 3285 | Effect.timeout(INVENTORY_TIMEOUT), | |
| 3286 | Effect.as(true), | |
| 3287 | Effect.catchCause((cause) => | |
| 3288 | Effect.logWarning("Could not add T3 Code's MCP server to OpenCode.", cause).pipe( | |
| 3289 | Effect.as(false), | |
| 3290 | ), | |
| 3291 | ), | |
| 3292 | ); | |
| 3293 | if (added) state.mcp = wanted; | |
| 3294 | } | |
| 3295 | const instructions = [ | |
| 3296 | buildRuntimeInstructions({ harness: "OpenCode", model: turnInput.modelSelection.model }), | |
| 3297 | t3OrchestrationSystemPrompt(state.mcp !== undefined), | |
| 3298 | ] | |
| 3299 | .filter((part) => part !== undefined && part.length > 0) | |
| 3300 | .join("\n\n"); | |
| 3301 | if (instructions !== state.instructions) { | |
| 3302 | yield* client.session.instructions.entry.put({ | |
| 3303 | sessionID: Session.ID.make(sessionId), | |
| 3304 | key: INSTRUCTIONS_KEY, | |
| 3305 | value: instructions, | |
| 3306 | }); | |
| 3307 | state.instructions = instructions; | |
| 3308 | } | |
| 3309 | }); | |
| 3310 | ||
| 3311 | const markBefore = (sessionId: string, before: string | null) => | |
| 3312 | Effect.sync(() => { | |
| 3313 | const turn = threads.get(sessionId)?.active; | |
| 3314 | if (turn !== undefined) turn.before = before; | |
| 3315 | }); | |
| 3316 | ||
| 3317 | /** Installs a turn T3 started; every path after it ends the turn with a terminal. */ | |
| 3318 | const beginTurn = ( | |
| 3319 | state: ThreadState, | |
| 3320 | turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, | |
| 3321 | awaitingStart: boolean, | |
| 3322 | ) => | |
| 3323 | Effect.gen(function* () { | |
| 3324 | const startedAt = yield* DateTime.now; | |
| 3325 | const nativeTurnId = `${state.sessionId}:attempt:${turnInput.attemptId}`; | |
| 3326 | const providerTurn: OrchestrationV2ProviderTurn = { | |
| 3327 | id: idAllocator.derive.providerTurn({ driver, nativeTurnId }), | |
| 3328 | providerThreadId: turnInput.providerThread.id, | |
| 3329 | nodeId: turnInput.rootNodeId, | |
| 3330 | runAttemptId: turnInput.attemptId, | |
| 3331 | // The user message it prompts with, where fork and rollback cut. A | |
| 3332 | // continuation prompts nothing; it records the report it answers | |
| 3333 | // once it takes its execution. | |
| 3334 | nativeTurnRef: ref( | |
| 3335 | isContinuation(turnInput) | |
| 3336 | ? wakeTurnId(state.sessionId, turnInput.attemptId) | |
| 3337 | : turnPromptId(state.sessionId, turnInput.attemptId), | |
| 3338 | "weak", | |
| 3339 | ), | |
| 3340 | ordinal: turnInput.providerTurnOrdinal, | |
| 3341 | status: "running", | |
| 3342 | startedAt, | |
| 3343 | completedAt: null, | |
| 3344 | }; | |
| 3345 | const turn = makeTurn(turnInput, providerTurn, { scope: "", awaitingStart }); | |
| 3346 | // No stream is left to end this turn, so it must not start. | |
| 3347 | if (streamFailure !== undefined) { | |
| 3348 | return yield* new ProviderAdapter.ProviderAdapterEventStreamError({ | |
| 3349 | driver, | |
| 3350 | providerSessionId: input.providerSessionId, | |
| 3351 | cause: streamFailure, | |
| 3352 | }); | |
| 3353 | } | |
| 3354 | state.active = turn; | |
| 3355 | yield* emitProviderTurn(state, turn, providerTurn); | |
| 3356 | state.providerThread = { | |
| 3357 | ...state.providerThread, | |
| 3358 | status: "active", | |
| 3359 | firstRunOrdinal: state.providerThread.firstRunOrdinal ?? turnInput.runOrdinal, | |
| 3360 | lastRunOrdinal: turnInput.runOrdinal, | |
| 3361 | updatedAt: startedAt, | |
| 3362 | }; | |
| 3363 | yield* emit({ | |
| 3364 | type: "provider_thread.updated", | |
| 3365 | driver, | |
| 3366 | providerThread: state.providerThread, | |
| 3367 | }); | |
| 3368 | yield* setSessionStatus("running", null); | |
| 3369 | return turn; | |
| 3370 | }); | |
| 3371 | ||
| 3372 | /** Feeds a held execution's events to the turn now running on its session. */ | |
| 3373 | const replay = Effect.fnUntraced(function* (wake: Wake) { | |
| 3374 | wake.dropped = true; | |
| 3375 | for (const event of wake.events) yield* route(event, sessionOfEvent(event)); | |
| 3376 | }); | |
| 3377 | ||
| 3378 | /** | |
| 3379 | * A user turn that starts while OpenCode runs an execution on its own: the | |
| 3380 | * prompt joins that execution, so the turn takes it, and its continuation | |
| 3381 | * turn is no longer needed. Executions that already ended stay for theirs. | |
| 3382 | */ | |
| 3383 | const takeRunningWake = Effect.fnUntraced(function* (state: ThreadState) { | |
| 3384 | const wake = state.wakes.at(-1); | |
| 3385 | if (wake?.running !== true) return; | |
| 3386 | state.wakes.pop(); | |
| 3387 | yield* replay(wake); | |
| 3388 | }); | |
| 3389 | ||
| 3390 | /** | |
| 3391 | * The continuation turn for an execution OpenCode started on its own: it | |
| 3392 | * takes the oldest one held and ends with it. One already taken (a user | |
| 3393 | * turn joined it) leaves nothing to run, so the turn ends at once. | |
| 3394 | */ | |
| 3395 | const runWake = Effect.fnUntraced(function* ( | |
| 3396 | state: ThreadState, | |
| 3397 | turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, | |
| 3398 | ) { | |
| 3399 | yield* beginTurn(state, turnInput, false); | |
| 3400 | yield* lock.withPermit( | |
| 3401 | Effect.gen(function* () { | |
| 3402 | const wake = state.wakes.shift(); | |
| 3403 | if (wake === undefined) return yield* finishTurn(state, { status: "completed" }); | |
| 3404 | const turn = state.active; | |
| 3405 | if (turn !== undefined && wake.after !== undefined) turn.before = wake.after; | |
| 3406 | // Its first report is where its history begins, so fork and rollback cut there. | |
| 3407 | if (turn !== undefined && wake.first !== undefined) { | |
| 3408 | turn.providerTurn = { ...turn.providerTurn, nativeTurnRef: ref(wake.first, "weak") }; | |
| 3409 | } | |
| 3410 | yield* replay(wake); | |
| 3411 | }), | |
| 3412 | ); | |
| 3413 | }); | |
| 3414 | ||
| 3415 | /** | |
| 3416 | * Stops a thread's background subagents and any execution OpenCode is | |
| 3417 | * running on its own. Their sessions end as interrupted; the reports | |
| 3418 | * OpenCode then queues wake the parent into an execution that is stopped | |
| 3419 | * as well, so no turn takes it. A subagent the Stop did not reach runs on, | |
| 3420 | * so its call stays tracked: the session is not released under it, and | |
| 3421 | * the next Stop tries it again. | |
| 3422 | */ | |
| 3423 | const stopBackground = Effect.fnUntraced(function* (state: ThreadState) { | |
| 3424 | const calls = runningCalls(state).filter((call) => call.background); | |
| 3425 | // Every session under the thread, which a stopped subagent may report to. | |
| 3426 | const callers = sessionsOf(state); | |
| 3427 | // A subagent whose call ended runs again when OpenCode wakes it to | |
| 3428 | // answer a report of its own background subagent. | |
| 3429 | const followUps = callers.filter( | |
| 3430 | (caller) => | |
| 3431 | caller.subagent !== undefined && | |
| 3432 | busy.has(caller.sessionId) && | |
| 3433 | !isOrchestrationV2WorkActive(caller.subagent.call.status), | |
| 3434 | ); | |
| 3435 | // OpenCode announces a child's session before the call's progress names | |
| 3436 | // it, so a call without a child yet is stopped through its caller's | |
| 3437 | // announced children. | |
| 3438 | const announcedTo = (caller: ThreadState) => | |
| 3439 | [...announced.values()].flatMap((info) => | |
| 3440 | info.parentID === caller.sessionId ? [info.sessionID] : [], | |
| 3441 | ); | |
| 3442 | const childrenOf = (call: SubagentCall) => | |
| 3443 | call.child === undefined ? announcedTo(call.state) : [call.child.sessionId]; | |
| 3444 | // Each child reports to the session that called it, so its marker goes | |
| 3445 | // on that caller's state: the thread's own, or a subagent's for a nested one. | |
| 3446 | const children = new Map( | |
| 3447 | calls.flatMap((call) => childrenOf(call).map((childId) => [childId, call.state] as const)), | |
| 3448 | ); | |
| 3449 | const unreached = new Set<string>(); | |
| 3450 | for (const [childId, caller] of children) { | |
| 3451 | caller.stoppedChildren.add(childId); | |
| 3452 | const reached = yield* client.session | |
| 3453 | .interrupt({ sessionID: Session.ID.make(childId) }) | |
| 3454 | .pipe( | |
| 3455 | // A session that is gone runs nothing. | |
| 3456 | Effect.catchTags({ SessionNotFoundError: () => Effect.void }), | |
| 3457 | Effect.timeout(INTERRUPT_TIMEOUT), | |
| 3458 | Effect.tapCause((cause) => | |
| 3459 | Effect.logWarning("Could not stop an OpenCode subagent.", cause), | |
| 3460 | ), | |
| 3461 | Effect.exit, | |
| 3462 | Effect.map(Exit.isSuccess), | |
| 3463 | ); | |
| 3464 | if (!reached) unreached.add(childId); | |
| 3465 | } | |
| 3466 | // Its end closes its turn; one the Stop did not reach stays pending work. | |
| 3467 | for (const followUp of followUps) { | |
| 3468 | yield* client.session | |
| 3469 | .interrupt({ sessionID: Session.ID.make(followUp.sessionId) }) | |
| 3470 | .pipe( | |
| 3471 | Effect.catchTags({ SessionNotFoundError: () => Effect.void }), | |
| 3472 | Effect.timeout(INTERRUPT_TIMEOUT), | |
| 3473 | Effect.ignore({ log: true }), | |
| 3474 | ); | |
| 3475 | } | |
| 3476 | yield* lock.withPermit( | |
| 3477 | Effect.gen(function* () { | |
| 3478 | for (const call of calls) { | |
| 3479 | if (childrenOf(call).some((childId) => unreached.has(childId))) continue; | |
| 3480 | yield* settleCall(call, "interrupted"); | |
| 3481 | } | |
| 3482 | // A report already queued starts a follow-up the Stop must end too. | |
| 3483 | for (const caller of callers) { | |
| 3484 | for (const report of caller.reports.values()) { | |
| 3485 | caller.stoppedChildren.add(report.childId); | |
| 3486 | } | |
| 3487 | } | |
| 3488 | // A held wake no turn will take: its execution is stopped, not replayed. | |
| 3489 | const running = state.wakes.some((wake) => wake.running); | |
| 3490 | for (const wake of state.wakes.splice(0)) wake.dropped = true; | |
| 3491 | if (running) { | |
| 3492 | state.unsettled = true; | |
| 3493 | yield* client.session | |
| 3494 | .interrupt({ sessionID: Session.ID.make(state.sessionId) }) | |
| 3495 | .pipe(Effect.timeout(INTERRUPT_TIMEOUT), Effect.ignore({ log: true })); | |
| 3496 | } | |
| 3497 | }), | |
| 3498 | ); | |
| 3499 | }); | |
| 3500 | ||
| 3501 | /** | |
| 3502 | * What a turn sends: `/compact` compacts, `/name args` naming a workspace | |
| 3503 | * command runs it, and anything else is a prompt with the `$skill`s it | |
| 3504 | * names attached. Commands and skills are read from the session's | |
| 3505 | * directory only when the text could use them. A turn stopped meanwhile | |
| 3506 | * sends nothing. | |
| 3507 | */ | |
| 3508 | const submit = Effect.fnUntraced(function* ( | |
| 3509 | sessionId: string, | |
| 3510 | turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, | |
| 3511 | state: ThreadState, | |
| 3512 | turn: ActiveTurn, | |
| 3513 | ) { | |
| 3514 | const sessionID = Session.ID.make(sessionId); | |
| 3515 | /** Whether the turn still sends; past this point a Stop goes to the server. */ | |
| 3516 | const sending = () => { | |
| 3517 | if (state.active !== turn || turn.interrupted) return false; | |
| 3518 | turn.unsent = false; | |
| 3519 | return true; | |
| 3520 | }; | |
| 3521 | const text = turnInput.message.text.trim(); | |
| 3522 | const bare = turnInput.message.attachments.length === 0; | |
| 3523 | // The turn's own id, so fork and rollback cut before this turn's item. | |
| 3524 | const id = turnPromptId(sessionId, turnInput.attemptId); | |
| 3525 | if (bare && text === "/compact") { | |
| 3526 | if (!sending()) return; | |
| 3527 | return yield* client.session.compact({ sessionID, id }).pipe(Effect.asVoid); | |
| 3528 | } | |
| 3529 | const location = { directory: turnInput.runtimePolicy.cwd ?? host.paths.cwd }; | |
| 3530 | const command = bare ? commandOf(text) : undefined; | |
| 3531 | if (command !== undefined) { | |
| 3532 | const commands = yield* client.command.list({ location }).pipe( | |
| 3533 | Effect.timeout(INVENTORY_TIMEOUT), | |
| 3534 | Effect.map((list) => list.data), | |
| 3535 | Effect.orElseSucceed(() => []), | |
| 3536 | ); | |
| 3537 | if (commands.some((entry) => entry.name === command.name)) { | |
| 3538 | // `session.command` takes no id of T3's and its answer carries none, | |
| 3539 | // so the turn remembers where the history stood before it. | |
| 3540 | const newest = yield* client.message.list({ sessionID, order: "desc", limit: 1 }); | |
| 3541 | yield* markBefore(sessionId, newest.data[0]?.id ?? null); | |
| 3542 | if (!sending()) return; | |
| 3543 | return yield* client.session.command({ sessionID, ...command }); | |
| 3544 | } | |
| 3545 | } | |
| 3546 | const skills = SKILL_MENTION.test(text) | |
| 3547 | ? skillsNamed( | |
| 3548 | text, | |
| 3549 | yield* client.skill.list({ location }).pipe( | |
| 3550 | Effect.timeout(INVENTORY_TIMEOUT), | |
| 3551 | Effect.map((list) => new Set(list.data.map((skill) => skill.id))), | |
| 3552 | Effect.orElseSucceed(() => new Set<string>()), | |
| 3553 | ), | |
| 3554 | ) | |
| 3555 | : []; | |
| 3556 | if (!sending()) return; | |
| 3557 | return yield* client.session | |
| 3558 | .prompt({ | |
| 3559 | sessionID, | |
| 3560 | id, | |
| 3561 | text: promptText(turnInput), | |
| 3562 | ...(skills.length === 0 | |
| 3563 | ? {} | |
| 3564 | : { skills: skills.map((id) => ({ id: Skill.ID.make(id) })) }), | |
| 3565 | }) | |
| 3566 | .pipe(Effect.asVoid); | |
| 3567 | }); | |
| 3568 | ||
| 3569 | const runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime = { | |
| 3570 | instanceId, | |
| 3571 | driver, | |
| 3572 | providerSessionId: input.providerSessionId, | |
| 3573 | get providerSession() { | |
| 3574 | return session; | |
| 3575 | }, | |
| 3576 | events: Stream.fromQueue(events), | |
| 3577 | // A background subagent keeps its session busy after its parent's turn, | |
| 3578 | // and a held wake still needs its turn: idle release must wait for both. | |
| 3579 | hasPendingBackgroundWork: Effect.sync(() => [...threads.values()].some(owesWork)), | |
| 3580 | hasPendingBackgroundWorkForThread: (providerThread) => | |
| 3581 | Effect.sync(() => { | |
| 3582 | const nativeId = providerThread.nativeThreadRef?.nativeId; | |
| 3583 | const state = nativeId == null ? undefined : threads.get(nativeId); | |
| 3584 | return state !== undefined && hasBackground(state); | |
| 3585 | }), | |
| 3586 | // A caller that names no directory gets the one this session opened in. | |
| 3587 | getModelContextWindow: (selection, cwd) => | |
| 3588 | selection.instanceId === instanceId | |
| 3589 | ? windowOf(cwd === undefined ? session.cwd : cwd, selection.model) | |
| 3590 | : undefined, | |
| 3591 | ensureThread: (threadInput) => | |
| 3592 | Effect.gen(function* () { | |
| 3593 | if (threadInput.existingProviderThread?.nativeThreadRef != null) { | |
| 3594 | return yield* runtime.resumeThread({ | |
| 3595 | providerThread: threadInput.existingProviderThread, | |
| 3596 | threadId: threadInput.threadId, | |
| 3597 | modelSelection: threadInput.modelSelection, | |
| 3598 | runtimePolicy: threadInput.runtimePolicy, | |
| 3599 | }); | |
| 3600 | } | |
| 3601 | yield* readModelsOnce(threadInput.runtimePolicy.cwd); | |
| 3602 | const model = modelRef(threadInput.modelSelection); | |
| 3603 | if (model === undefined) { | |
| 3604 | return yield* new ProviderAdapter.ProviderAdapterProtocolError({ | |
| 3605 | driver: OPENCODE_PROVIDER, | |
| 3606 | detail: malformedModel(threadInput.modelSelection.model), | |
| 3607 | }); | |
| 3608 | } | |
| 3609 | const directory = threadInput.runtimePolicy.cwd ?? host.paths.cwd; | |
| 3610 | const policy = threadInput.runtimePolicy; | |
| 3611 | // A new session runs OpenCode's default agent. | |
| 3612 | const permissions = yield* rulesFor( | |
| 3613 | { directory, agent: "build", grants: [] }, | |
| 3614 | policy, | |
| 3615 | threadInput.threadId, | |
| 3616 | ); | |
| 3617 | const created = yield* client.session.create({ | |
| 3618 | location: Location.PublicRef.make({ directory: AbsolutePath.make(directory) }), | |
| 3619 | model, | |
| 3620 | permissions, | |
| 3621 | }); | |
| 3622 | const createdAt = yield* DateTime.now; | |
| 3623 | const providerThread: OrchestrationV2ProviderThread = { | |
| 3624 | ...(threadInput.existingProviderThread ?? { | |
| 3625 | id: idAllocator.derive.providerThread({ driver, nativeThreadId: created.id }), | |
| 3626 | driver, | |
| 3627 | providerInstanceId: instanceId, | |
| 3628 | appThreadId: threadInput.threadId, | |
| 3629 | ownerNodeId: null, | |
| 3630 | firstRunOrdinal: null, | |
| 3631 | lastRunOrdinal: null, | |
| 3632 | handoffIds: [], | |
| 3633 | forkedFrom: null, | |
| 3634 | createdAt, | |
| 3635 | }), | |
| 3636 | providerSessionId: input.providerSessionId, | |
| 3637 | nativeThreadRef: ref(created.id), | |
| 3638 | nativeConversationHeadRef: null, | |
| 3639 | status: "idle", | |
| 3640 | updatedAt: createdAt, | |
| 3641 | }; | |
| 3642 | const state = register( | |
| 3643 | providerThread, | |
| 3644 | { id: created.id, model: created.model, agent: created.agent, permissions }, | |
| 3645 | directory, | |
| 3646 | ); | |
| 3647 | state.policy = policy; | |
| 3648 | return state.providerThread; | |
| 3649 | }).pipe( | |
| 3650 | Effect.mapError((cause) => | |
| 3651 | isProviderAdapterError(cause) | |
| 3652 | ? cause | |
| 3653 | : new ProviderAdapter.ProviderAdapterEnsureThreadError({ | |
| 3654 | driver, | |
| 3655 | threadId: threadInput.threadId, | |
| 3656 | cause, | |
| 3657 | }), | |
| 3658 | ), | |
| 3659 | ), | |
| 3660 | resumeThread: (threadInput) => | |
| 3661 | Effect.gen(function* () { | |
| 3662 | const sessionId = yield* sessionIdOf(threadInput.providerThread); | |
| 3663 | if (threadInput.runtimePolicy !== undefined) { | |
| 3664 | yield* readModelsOnce(threadInput.runtimePolicy.cwd); | |
| 3665 | } | |
| 3666 | // 1.x session ids survive the upgrade; a server without this session | |
| 3667 | // fails the resume, so T3 recreates the thread with a handoff. | |
| 3668 | const native = yield* client.session.get({ sessionID: Session.ID.make(sessionId) }); | |
| 3669 | const providerThread: OrchestrationV2ProviderThread = { | |
| 3670 | ...threadInput.providerThread, | |
| 3671 | providerSessionId: input.providerSessionId, | |
| 3672 | status: "idle", | |
| 3673 | updatedAt: yield* DateTime.now, | |
| 3674 | }; | |
| 3675 | const cwd = threadInput.runtimePolicy?.cwd; | |
| 3676 | const loaded = threads.has(sessionId); | |
| 3677 | const state = register(providerThread, native, cwd ?? native.location.directory); | |
| 3678 | // OpenCode keeps no request across its own restart, but a server that | |
| 3679 | // outlived T3 may still wait on one T3 no longer shows. | |
| 3680 | if (!loaded) yield* stopLeftoverRequests(state); | |
| 3681 | // The session gets the rules for this thread's mode: it may have run | |
| 3682 | // another mode, or been made by 1.x or an earlier build. | |
| 3683 | yield* writeRules(state, threadInput.runtimePolicy ?? state.policy); | |
| 3684 | // A thread moved to another worktree takes its session with it. | |
| 3685 | if (cwd != null && native.location.directory !== cwd) { | |
| 3686 | yield* client.session.move({ | |
| 3687 | sessionID: Session.ID.make(sessionId), | |
| 3688 | directory: AbsolutePath.make(cwd), | |
| 3689 | }); | |
| 3690 | } | |
| 3691 | return state.providerThread; | |
| 3692 | }).pipe( | |
| 3693 | Effect.mapError((cause) => | |
| 3694 | isProviderAdapterError(cause) | |
| 3695 | ? cause | |
| 3696 | : new ProviderAdapter.ProviderAdapterResumeThreadError({ | |
| 3697 | driver, | |
| 3698 | providerSessionId: input.providerSessionId, | |
| 3699 | providerThreadId: threadInput.providerThread.id, | |
| 3700 | cause, | |
| 3701 | }), | |
| 3702 | ), | |
| 3703 | ), | |
| 3704 | // `/compact` is its own turn, which `startTurn` sends as `session.compact`. | |
| 3705 | compactThread: (turnInput) => | |
| 3706 | runtime.startTurn({ ...turnInput, message: { ...turnInput.message, text: "/compact" } }), | |
| 3707 | startTurn: (turnInput) => | |
| 3708 | Effect.gen(function* () { | |
| 3709 | // A lost stream is reconnecting: wait for it rather than prompt into | |
| 3710 | // a dead stream, and refuse the turn before any request if it gave up. | |
| 3711 | // Waited out before the session's gate, so a rollback or fork is not | |
| 3712 | // held behind a reconnect. | |
| 3713 | if (streamFailure !== undefined) { | |
| 3714 | yield* Deferred.await(reconnected).pipe(Effect.timeout(RECONNECT_WAIT), Effect.ignore); | |
| 3715 | } | |
| 3716 | if (streamFailure !== undefined) { | |
| 3717 | return yield* new ProviderAdapter.ProviderAdapterEventStreamError({ | |
| 3718 | driver, | |
| 3719 | providerSessionId: input.providerSessionId, | |
| 3720 | cause: streamFailure, | |
| 3721 | }); | |
| 3722 | } | |
| 3723 | // Its checks and setup take the session's gate, so a rollback or | |
| 3724 | // fork sees either no turn or this one installed; the prompt after | |
| 3725 | // it does not hold the gate. | |
| 3726 | const started = yield* exclusive(turnInput.providerThread)( | |
| 3727 | Effect.gen(function* () { | |
| 3728 | const sessionId = yield* sessionIdOf(turnInput.providerThread); | |
| 3729 | const state = threads.get(sessionId); | |
| 3730 | if (state === undefined) { | |
| 3731 | return yield* new ProviderAdapter.ProviderAdapterProtocolError({ | |
| 3732 | driver: OPENCODE_PROVIDER, | |
| 3733 | detail: `OpenCode session ${sessionId} is not registered`, | |
| 3734 | }); | |
| 3735 | } | |
| 3736 | if (state.active !== undefined) { | |
| 3737 | return yield* new ProviderAdapter.ProviderAdapterProtocolError({ | |
| 3738 | driver: OPENCODE_PROVIDER, | |
| 3739 | detail: `OpenCode session ${sessionId} already has an active turn`, | |
| 3740 | }); | |
| 3741 | } | |
| 3742 | // OpenCode already ran this turn on its own; it prompts nothing. | |
| 3743 | if (isContinuation(turnInput)) return yield* runWake(state, turnInput); | |
| 3744 | // After a timed-out Stop the server says whether that run is gone. A | |
| 3745 | // run still going is stopped again and this turn fails so it can be | |
| 3746 | // sent again; a run that is gone may still have its end on the | |
| 3747 | // stream, which the turn skips. | |
| 3748 | const afterUnsettled = state.unsettled; | |
| 3749 | let stillStopping = false; | |
| 3750 | if (state.unsettled) { | |
| 3751 | const active = yield* client.session | |
| 3752 | .active() | |
| 3753 | .pipe(Effect.timeout(ACTIVE_CHECK_TIMEOUT)); | |
| 3754 | stillStopping = sessionId in active; | |
| 3755 | if (stillStopping) { | |
| 3756 | yield* client.session | |
| 3757 | .interrupt({ sessionID: Session.ID.make(sessionId) }) | |
| 3758 | .pipe(Effect.timeout(INTERRUPT_TIMEOUT), Effect.ignore({ log: true })); | |
| 3759 | } else { | |
| 3760 | state.unsettled = false; | |
| 3761 | } | |
| 3762 | } | |
| 3763 | // Installs the turn; every path after it ends the turn with a terminal. | |
| 3764 | const begin = beginTurn(state, turnInput, afterUnsettled); | |
| 3765 | if (stillStopping) { | |
| 3766 | yield* begin; | |
| 3767 | return yield* finishTurn(state, { | |
| 3768 | status: "failed", | |
| 3769 | failure: makeProviderFailure({ | |
| 3770 | message: OPENCODE_2_STILL_STOPPING, | |
| 3771 | class: "provider_error", | |
| 3772 | }), | |
| 3773 | }); | |
| 3774 | } | |
| 3775 | // A turn T3 will not run still starts and fails, so the refusal is what | |
| 3776 | // the user reads. | |
| 3777 | const model = modelRef(turnInput.modelSelection); | |
| 3778 | if (model === undefined) { | |
| 3779 | yield* begin; | |
| 3780 | return yield* finishTurn(state, { | |
| 3781 | status: "failed", | |
| 3782 | failure: makeProviderFailure({ | |
| 3783 | message: malformedModel(turnInput.modelSelection.model), | |
| 3784 | class: "validation_error", | |
| 3785 | }), | |
| 3786 | }); | |
| 3787 | } | |
| 3788 | // Plan mode is OpenCode's `plan` agent, switched before the rules are | |
| 3789 | // written so they keep that agent's own path allows. Switching to or | |
| 3790 | // from `plan` queues OpenCode's own "Plan mode" reminder for the prompt. | |
| 3791 | const agent = agentFor(turnInput); | |
| 3792 | if (agent !== state.agent) { | |
| 3793 | yield* client.session.switchAgent({ | |
| 3794 | sessionID: Session.ID.make(sessionId), | |
| 3795 | agent: Agent.ID.make(agent), | |
| 3796 | }); | |
| 3797 | state.agent = agent; | |
| 3798 | } | |
| 3799 | // The thread's mode may have changed since the session was loaded, | |
| 3800 | // and its subagents still running hold the rules they started with. | |
| 3801 | // Those run on whether or not this turn starts, so theirs are best effort. | |
| 3802 | yield* writeRules(state, turnInput.runtimePolicy); | |
| 3803 | for (const call of runningCalls(state)) { | |
| 3804 | if (call.child === undefined) continue; | |
| 3805 | yield* writeRules(call.child, turnInput.runtimePolicy).pipe( | |
| 3806 | Effect.timeout(REQUEST_REPLY_TIMEOUT), | |
| 3807 | Effect.ignore({ log: true }), | |
| 3808 | ); | |
| 3809 | } | |
| 3810 | // A selection changed since the last turn applies now; OpenCode keeps | |
| 3811 | // the session's model otherwise. | |
| 3812 | if (!sameModel(model, state.model)) { | |
| 3813 | yield* client.session.switchModel({ sessionID: Session.ID.make(sessionId), model }); | |
| 3814 | state.model = model; | |
| 3815 | } | |
| 3816 | // Steers a stopped turn never delivered would reach the model first, | |
| 3817 | // and a revert a failed rollback left staged would be committed. | |
| 3818 | yield* cancelStrandedSteers(state); | |
| 3819 | if (stagedReverts.has(sessionId)) yield* clearRevert(sessionId); | |
| 3820 | yield* prepareTurn(sessionId, state, turnInput); | |
| 3821 | const turn = yield* begin; | |
| 3822 | turn.unsent = true; | |
| 3823 | // An execution OpenCode is running on its own takes this prompt at | |
| 3824 | // its next step, so this turn is that execution from here on. | |
| 3825 | yield* lock.withPermit(takeRunningWake(state)); | |
| 3826 | return { state, sessionId, turn }; | |
| 3827 | }), | |
| 3828 | ); | |
| 3829 | if (started === undefined) return; | |
| 3830 | const { state, sessionId, turn } = started; | |
| 3831 | yield* submit(sessionId, turnInput, state, turn).pipe( | |
| 3832 | // Deleted outside T3: the thread is broken, and forgetting it makes | |
| 3833 | // the next turn resume, fail, and recreate it with a handoff. | |
| 3834 | Effect.catchTags({ | |
| 3835 | SessionNotFoundError: () => | |
| 3836 | finishTurn( | |
| 3837 | state, | |
| 3838 | { | |
| 3839 | status: "failed", | |
| 3840 | failure: makeProviderFailure({ | |
| 3841 | message: | |
| 3842 | "The OpenCode session no longer exists. Send the message again to continue in a new session.", | |
| 3843 | class: "provider_error", | |
| 3844 | }), | |
| 3845 | }, | |
| 3846 | "broken", | |
| 3847 | ).pipe(Effect.andThen(Effect.sync(() => threads.delete(sessionId)))), | |
| 3848 | }), | |
| 3849 | Effect.tapError((cause) => | |
| 3850 | state.active === turn | |
| 3851 | ? Effect.gen(function* () { | |
| 3852 | // Without a clear rejection the server may have taken the | |
| 3853 | // prompt, so the next turn checks before it prompts again. | |
| 3854 | if (!CLEAR_PROMPT_REJECTIONS.has(cause._tag)) state.unsettled = true; | |
| 3855 | yield* finishTurn(state, { | |
| 3856 | status: "failed", | |
| 3857 | failure: makeProviderFailure({ cause, class: "provider_error" }), | |
| 3858 | }); | |
| 3859 | }) | |
| 3860 | : Effect.void, | |
| 3861 | ), | |
| 3862 | ); | |
| 3863 | }).pipe( | |
| 3864 | Effect.mapError((cause) => | |
| 3865 | isProviderAdapterError(cause) | |
| 3866 | ? cause | |
| 3867 | : new ProviderAdapter.ProviderAdapterTurnStartError({ | |
| 3868 | driver, | |
| 3869 | threadId: turnInput.threadId, | |
| 3870 | providerThreadId: turnInput.providerThread.id, | |
| 3871 | runId: turnInput.runId, | |
| 3872 | cause, | |
| 3873 | }), | |
| 3874 | ), | |
| 3875 | ), | |
| 3876 | steerTurn: (steerInput) => | |
| 3877 | Effect.gen(function* () { | |
| 3878 | const sessionId = yield* sessionIdOf(steerInput.providerThread); | |
| 3879 | const state = threads.get(sessionId); | |
| 3880 | const turn = state?.active; | |
| 3881 | if ( | |
| 3882 | state === undefined || | |
| 3883 | turn === undefined || | |
| 3884 | turn.providerTurn.id !== steerInput.providerTurnId || | |
| 3885 | turn.interrupted | |
| 3886 | ) { | |
| 3887 | return yield* new ProviderAdapter.ProviderAdapterProtocolError({ | |
| 3888 | driver: OPENCODE_PROVIDER, | |
| 3889 | detail: `OpenCode turn ${steerInput.providerTurnId} is not active`, | |
| 3890 | }); | |
| 3891 | } | |
| 3892 | const inboxID = steerPromptId(sessionId, steerInput.message.messageId); | |
| 3893 | // A retried steer that already reached the model is not sent again. | |
| 3894 | if (turn.settledInbox.has(inboxID)) return; | |
| 3895 | turn.steers.add(inboxID); | |
| 3896 | state.strandedSteers.delete(inboxID); | |
| 3897 | yield* client.session | |
| 3898 | .prompt({ | |
| 3899 | sessionID: Session.ID.make(sessionId), | |
| 3900 | id: inboxID, | |
| 3901 | text: providerMessageTextWithAttachmentPaths({ | |
| 3902 | text: steerInput.message.text, | |
| 3903 | attachments: steerInput.message.attachments, | |
| 3904 | resolveAttachmentPath: host.resolveAttachmentPath, | |
| 3905 | }).trim(), | |
| 3906 | delivery: "steer", | |
| 3907 | }) | |
| 3908 | .pipe( | |
| 3909 | Effect.tapError((cause) => | |
| 3910 | lock.withPermit( | |
| 3911 | Effect.gen(function* () { | |
| 3912 | turn.steers.delete(inboxID); | |
| 3913 | // Without a clear refusal OpenCode may hold it; the next prompt takes it back. | |
| 3914 | if (!CLEAR_PROMPT_REJECTIONS.has(cause._tag)) state.strandedSteers.add(inboxID); | |
| 3915 | if (state.active === turn) yield* endIfSettled(state, turn); | |
| 3916 | }), | |
| 3917 | ), | |
| 3918 | ), | |
| 3919 | ); | |
| 3920 | }).pipe( | |
| 3921 | Effect.mapError((cause) => | |
| 3922 | isProviderAdapterError(cause) | |
| 3923 | ? cause | |
| 3924 | : new ProviderAdapter.ProviderAdapterSteerRunError({ | |
| 3925 | driver, | |
| 3926 | providerThreadId: steerInput.providerThread.id, | |
| 3927 | providerTurnId: steerInput.providerTurnId, | |
| 3928 | cause, | |
| 3929 | }), | |
| 3930 | ), | |
| 3931 | ), | |
| 3932 | interruptTurn: (interruptInput) => | |
| 3933 | Effect.gen(function* () { | |
| 3934 | const sessionId = yield* sessionIdOf(interruptInput.providerThread); | |
| 3935 | const state = threads.get(sessionId); | |
| 3936 | if (state === undefined) return; | |
| 3937 | const turn = state.active; | |
| 3938 | // OpenCode stops a foreground subagent with its parent, but not a | |
| 3939 | // background one: a user Stop (`requestRuntimeRestart`) stops those | |
| 3940 | // too, and the execution OpenCode starts to report them. A turn | |
| 3941 | // interrupted to restart it with new input leaves them running. | |
| 3942 | if (interruptInput.requestRuntimeRestart === true) yield* stopBackground(state); | |
| 3943 | if (turn === undefined || turn.providerTurn.id !== interruptInput.providerTurnId) { | |
| 3944 | return; | |
| 3945 | } | |
| 3946 | // The session answers with `session.execution.interrupted`, which ends | |
| 3947 | // the turn. A server that does not answer in time is stuck, so the turn | |
| 3948 | // ends here instead of waiting on it. | |
| 3949 | turn.interrupted = true; | |
| 3950 | const reply = yield* client.session | |
| 3951 | .interrupt({ sessionID: Session.ID.make(sessionId) }) | |
| 3952 | .pipe( | |
| 3953 | Effect.timeoutOption(INTERRUPT_TIMEOUT), | |
| 3954 | // A Stop that never reached the server stopped nothing. | |
| 3955 | Effect.tapError(() => | |
| 3956 | Effect.sync(() => { | |
| 3957 | turn.interrupted = false; | |
| 3958 | }), | |
| 3959 | ), | |
| 3960 | ); | |
| 3961 | if (reply._tag === "None") { | |
| 3962 | state.unsettled = true; | |
| 3963 | return yield* lock.withPermit(finishTurn(state, { status: "interrupted" })); | |
| 3964 | } | |
| 3965 | // Between the executions a late steer spans nothing runs yet. The | |
| 3966 | // steers are taken back, and a run they already started is skipped. | |
| 3967 | if (!reply.value.interrupted && state.active === turn && turn.heldEnd !== undefined) { | |
| 3968 | state.unsettled = true; | |
| 3969 | yield* lock.withPermit(finishTurn(state, { status: "interrupted" })); | |
| 3970 | return yield* cancelStrandedSteers(state); | |
| 3971 | } | |
| 3972 | // Nothing was running because the turn has not sent its request yet | |
| 3973 | // (it is reading the workspace's commands or skills): it ends here, | |
| 3974 | // and never sends it. | |
| 3975 | if (!reply.value.interrupted && state.active === turn && turn.unsent) { | |
| 3976 | return yield* lock.withPermit(finishTurn(state, { status: "interrupted" })); | |
| 3977 | } | |
| 3978 | // Nothing was running. Unless the execution already ended (its event | |
| 3979 | // is on the way), the turn is still open and nothing stopped. | |
| 3980 | if (!reply.value.interrupted && state.active === turn) { | |
| 3981 | turn.interrupted = false; | |
| 3982 | return yield* new ProviderAdapter.ProviderAdapterProtocolError({ | |
| 3983 | driver: OPENCODE_PROVIDER, | |
| 3984 | detail: `OpenCode session ${sessionId} had nothing running to stop`, | |
| 3985 | }); | |
| 3986 | } | |
| 3987 | }).pipe( | |
| 3988 | Effect.mapError((cause) => | |
| 3989 | isProviderAdapterError(cause) | |
| 3990 | ? cause | |
| 3991 | : new ProviderAdapter.ProviderAdapterInterruptError({ | |
| 3992 | driver, | |
| 3993 | providerThreadId: interruptInput.providerThread.id, | |
| 3994 | providerTurnId: interruptInput.providerTurnId, | |
| 3995 | cause, | |
| 3996 | }), | |
| 3997 | ), | |
| 3998 | ), | |
| 3999 | unloadThread: ({ providerThread }) => | |
| 4000 | Effect.gen(function* () { | |
| 4001 | const nativeId = providerThread.nativeThreadRef?.nativeId; | |
| 4002 | const state = nativeId == null ? undefined : threads.get(nativeId); | |
| 4003 | if ( | |
| 4004 | nativeId == null || | |
| 4005 | state === undefined || | |
| 4006 | state.active !== undefined || | |
| 4007 | hasBackground(state) | |
| 4008 | ) { | |
| 4009 | return; | |
| 4010 | } | |
| 4011 | threads.delete(nativeId); | |
| 4012 | for (const [child, owner] of childOwners) { | |
| 4013 | if (owner !== state) continue; | |
| 4014 | childOwners.delete(child); | |
| 4015 | threads.delete(child); | |
| 4016 | } | |
| 4017 | if (state.mcp !== undefined) yield* removeMcp(state.mcp); | |
| 4018 | }), | |
| 4019 | respondToRuntimeRequest: (requestInput) => | |
| 4020 | Effect.gen(function* () { | |
| 4021 | const entry = pending.get(requestInput.requestId); | |
| 4022 | if (entry === undefined || entry.answering) { | |
| 4023 | return yield* new ProviderAdapter.ProviderAdapterProtocolError({ | |
| 4024 | driver, | |
| 4025 | detail: `No pending OpenCode request ${requestInput.requestId}`, | |
| 4026 | }); | |
| 4027 | } | |
| 4028 | const { decision, answers } = requestInput; | |
| 4029 | const { native } = entry; | |
| 4030 | if (native.type === "permission" && decision === undefined) { | |
| 4031 | return yield* new ProviderAdapter.ProviderAdapterProtocolError({ | |
| 4032 | driver, | |
| 4033 | detail: `OpenCode approval request ${requestInput.requestId} requires a decision`, | |
| 4034 | }); | |
| 4035 | } | |
| 4036 | entry.answering = true; | |
| 4037 | // OpenCode's own "always" saves a grant for the whole project, so a | |
| 4038 | // session-wide answer is a rule on this session instead. The grant | |
| 4039 | // is best effort: this request is answered either way. | |
| 4040 | // The session that asked waits on the answer and holds the grant: a | |
| 4041 | // subagent's own, not its parent's. | |
| 4042 | const asker = threads.get(entry.sessionId) ?? entry.state; | |
| 4043 | if ( | |
| 4044 | native.type === "permission" && | |
| 4045 | (decision === "acceptForSession" || decision === "acceptAlways") | |
| 4046 | ) { | |
| 4047 | const state = asker; | |
| 4048 | for (const resource of native.save) { | |
| 4049 | if ( | |
| 4050 | !state.grants.some( | |
| 4051 | (grant) => grant.action === native.action && grant.resource === resource, | |
| 4052 | ) | |
| 4053 | ) { | |
| 4054 | state.grants.push({ action: native.action, resource, effect: "allow" }); | |
| 4055 | } | |
| 4056 | } | |
| 4057 | // A subagent runs under its thread's mode. | |
| 4058 | yield* writeRules(state, rootOf(state).policy).pipe(Effect.ignore({ log: true })); | |
| 4059 | } | |
| 4060 | const sessionID = Session.ID.make(entry.sessionId); | |
| 4061 | // Best effort: the decline stands without the note. | |
| 4062 | if (native.type === "permission" && decision === "decline") { | |
| 4063 | yield* client.session | |
| 4064 | .synthetic({ | |
| 4065 | sessionID, | |
| 4066 | text: declinedNote(native.action, native.resources), | |
| 4067 | delivery: "steer", | |
| 4068 | resume: false, | |
| 4069 | }) | |
| 4070 | .pipe(Effect.timeout(REQUEST_REPLY_TIMEOUT), Effect.ignore({ log: true })); | |
| 4071 | } | |
| 4072 | const delivered = yield* native.type === "permission" | |
| 4073 | ? deliver( | |
| 4074 | client.permission | |
| 4075 | .reply({ | |
| 4076 | sessionID, | |
| 4077 | requestID: Permission.ID.make(native.id), | |
| 4078 | decision: decision !== undefined && approves(decision) ? "once" : "reject", | |
| 4079 | // Without a message OpenCode ends the whole run, which is Cancel. | |
| 4080 | ...(decision === "decline" ? { message: DECLINED } : {}), | |
| 4081 | }) | |
| 4082 | .pipe(Effect.catchTags(permissionGone)), | |
| 4083 | ) | |
| 4084 | : deliver( | |
| 4085 | (answers === undefined || decision === "decline" || decision === "cancel" | |
| 4086 | ? client.session.form.cancel({ | |
| 4087 | sessionID: entry.sessionId, | |
| 4088 | formID: Form.ID.make(native.id), | |
| 4089 | }) | |
| 4090 | : client.session.form.reply({ | |
| 4091 | sessionID: entry.sessionId, | |
| 4092 | formID: Form.ID.make(native.id), | |
| 4093 | answer: formAnswer(native.form, answers), | |
| 4094 | }) | |
| 4095 | ).pipe(Effect.catchTags(formGone)), | |
| 4096 | ); | |
| 4097 | yield* forgetRequest(entry); | |
| 4098 | if (!delivered) yield* abandonRequest(asker, "answer not delivered"); | |
| 4099 | }).pipe( | |
| 4100 | Effect.mapError((cause) => | |
| 4101 | isProviderAdapterError(cause) | |
| 4102 | ? cause | |
| 4103 | : new ProviderAdapter.ProviderAdapterRuntimeRequestResponseError({ | |
| 4104 | driver, | |
| 4105 | requestId: requestInput.requestId, | |
| 4106 | cause, | |
| 4107 | }), | |
| 4108 | ), | |
| 4109 | ), | |
| 4110 | readThreadSnapshot: ({ providerThread }) => | |
| 4111 | Effect.gen(function* () { | |
| 4112 | const sessionId = yield* sessionIdOf(providerThread); | |
| 4113 | return yield* snapshotOf(providerThread, sessionId); | |
| 4114 | }).pipe( | |
| 4115 | Effect.mapError((cause) => | |
| 4116 | isProviderAdapterError(cause) | |
| 4117 | ? cause | |
| 4118 | : new ProviderAdapter.ProviderAdapterReadThreadSnapshotError({ | |
| 4119 | driver, | |
| 4120 | providerThreadId: providerThread.id, | |
| 4121 | cause, | |
| 4122 | }), | |
| 4123 | ), | |
| 4124 | ), | |
| 4125 | rollbackThread: (rollbackInput) => | |
| 4126 | Effect.gen(function* () { | |
| 4127 | const { providerThread, target } = rollbackInput; | |
| 4128 | const sessionId = yield* sessionIdOf(providerThread); | |
| 4129 | // A stopped run's end may still be on the stream, so `unsettled` | |
| 4130 | // stays for the next turn to skip it. | |
| 4131 | yield* ensureQuiet(sessionId, "roll back"); | |
| 4132 | // A session this runtime has not loaded yet is loaded the way a turn | |
| 4133 | // would load it, which stops anything it still waits on. Requests it | |
| 4134 | // stops end their run, so the server is asked again after that. | |
| 4135 | if (!threads.has(sessionId)) { | |
| 4136 | yield* runtime.resumeThread({ providerThread }); | |
| 4137 | yield* ensureQuiet(sessionId, "roll back"); | |
| 4138 | } | |
| 4139 | const boundary = yield* boundaryAfter( | |
| 4140 | rollbackInput.providerThreadTurns, | |
| 4141 | providerThread.id, | |
| 4142 | target.type === "provider_turn" ? target.providerTurn : undefined, | |
| 4143 | yield* userMessages(sessionId), | |
| 4144 | ); | |
| 4145 | if (boundary !== null) { | |
| 4146 | yield* rollBackTo(sessionId, SessionMessage.ID.make(boundary)); | |
| 4147 | } | |
| 4148 | // The turns after the target left the history with their messages. | |
| 4149 | // The snapshot lists the ones kept as T3 recorded them, which a | |
| 4150 | // runtime that loaded the session after they ran never saw. | |
| 4151 | const keptOrdinal = target.type === "provider_turn" ? target.providerTurn.ordinal : 0; | |
| 4152 | const loaded = threads.get(sessionId); | |
| 4153 | for (const [id, turn] of loaded?.providerTurns ?? []) { | |
| 4154 | if (turn.ordinal > keptOrdinal) loaded?.providerTurns.delete(id); | |
| 4155 | } | |
| 4156 | const snapshot = yield* snapshotOf(providerThread, sessionId); | |
| 4157 | const kept = new Map( | |
| 4158 | rollbackInput.providerThreadTurns | |
| 4159 | .filter( | |
| 4160 | (turn) => | |
| 4161 | turn.providerThreadId === providerThread.id && turn.ordinal <= keptOrdinal, | |
| 4162 | ) | |
| 4163 | .map((turn) => [turn.id, turn] as const), | |
| 4164 | ); | |
| 4165 | for (const turn of snapshot.providerTurns) kept.set(turn.id, turn); | |
| 4166 | // The snapshot's head is the last user message OpenCode kept. | |
| 4167 | return { ...snapshot, providerTurns: [...kept.values()] }; | |
| 4168 | }).pipe( | |
| 4169 | exclusive(rollbackInput.providerThread), | |
| 4170 | Effect.mapError((cause) => | |
| 4171 | isProviderAdapterError(cause) | |
| 4172 | ? cause | |
| 4173 | : new ProviderAdapter.ProviderAdapterRollbackThreadError({ | |
| 4174 | driver, | |
| 4175 | providerThreadId: rollbackInput.providerThread.id, | |
| 4176 | checkpointId: rollbackInput.target.checkpointId, | |
| 4177 | cause, | |
| 4178 | }), | |
| 4179 | ), | |
| 4180 | ), | |
| 4181 | forkThread: (forkInput) => | |
| 4182 | Effect.gen(function* () { | |
| 4183 | const source = forkInput.sourceProviderThread; | |
| 4184 | const sourceId = yield* sessionIdOf(source); | |
| 4185 | // OpenCode forks whatever history the source has, so a fork taken | |
| 4186 | // while anything writes to it would copy a run half done. | |
| 4187 | yield* ensureQuiet(sourceId, "fork"); | |
| 4188 | const selected = | |
| 4189 | forkInput.providerTurnId === undefined | |
| 4190 | ? undefined | |
| 4191 | : forkInput.sourceProviderTurns?.find((turn) => turn.id === forkInput.providerTurnId); | |
| 4192 | if (forkInput.providerTurnId !== undefined && selected === undefined) { | |
| 4193 | return yield* new ProviderAdapter.ProviderAdapterProtocolError({ | |
| 4194 | driver: OPENCODE_PROVIDER, | |
| 4195 | detail: `OpenCode fork boundary turn ${forkInput.providerTurnId} was not found`, | |
| 4196 | }); | |
| 4197 | } | |
| 4198 | const before = | |
| 4199 | selected === undefined | |
| 4200 | ? null | |
| 4201 | : yield* boundaryAfter( | |
| 4202 | forkInput.sourceProviderTurns ?? [], | |
| 4203 | source.id, | |
| 4204 | selected, | |
| 4205 | yield* userMessages(sourceId), | |
| 4206 | ); | |
| 4207 | // A fork copies the source's history, model, location and rules; its | |
| 4208 | // message ids are new, so it has no turns T3 could cut at yet. | |
| 4209 | const forked = yield* client.session.fork({ | |
| 4210 | sessionID: Session.ID.make(sourceId), | |
| 4211 | ...(before === null ? {} : { before: SessionMessage.ID.make(before) }), | |
| 4212 | }); | |
| 4213 | const createdAt = yield* DateTime.now; | |
| 4214 | const providerThread: OrchestrationV2ProviderThread = { | |
| 4215 | id: idAllocator.derive.providerThread({ driver, nativeThreadId: forked.id }), | |
| 4216 | driver, | |
| 4217 | providerInstanceId: instanceId, | |
| 4218 | providerSessionId: input.providerSessionId, | |
| 4219 | appThreadId: forkInput.targetThreadId, | |
| 4220 | ownerNodeId: forkInput.ownerNodeId ?? null, | |
| 4221 | nativeThreadRef: ref(forked.id), | |
| 4222 | nativeConversationHeadRef: null, | |
| 4223 | status: "idle", | |
| 4224 | firstRunOrdinal: null, | |
| 4225 | lastRunOrdinal: null, | |
| 4226 | handoffIds: [], | |
| 4227 | forkedFrom: { | |
| 4228 | providerThreadId: source.id, | |
| 4229 | ...(forkInput.providerTurnId === undefined | |
| 4230 | ? {} | |
| 4231 | : { providerTurnId: forkInput.providerTurnId }), | |
| 4232 | }, | |
| 4233 | createdAt, | |
| 4234 | updatedAt: createdAt, | |
| 4235 | }; | |
| 4236 | // A fork keeps the source's location. The target thread usually | |
| 4237 | // inherits that worktree too, but its first turn starts without a | |
| 4238 | // resume, so a target in another worktree takes the session there now. | |
| 4239 | const cwd = forkInput.runtimePolicy?.cwd; | |
| 4240 | const state = register(providerThread, forked, cwd ?? forked.location.directory); | |
| 4241 | // The fork runs the target thread's mode, not whatever the source ran. | |
| 4242 | yield* writeRules(state, forkInput.runtimePolicy ?? state.policy); | |
| 4243 | if (cwd != null && forked.location.directory !== cwd) { | |
| 4244 | yield* client.session.move({ | |
| 4245 | sessionID: Session.ID.make(forked.id), | |
| 4246 | directory: AbsolutePath.make(cwd), | |
| 4247 | }); | |
| 4248 | } | |
| 4249 | return state.providerThread; | |
| 4250 | }).pipe( | |
| 4251 | exclusive(forkInput.sourceProviderThread), | |
| 4252 | Effect.mapError((cause) => | |
| 4253 | isProviderAdapterError(cause) | |
| 4254 | ? cause | |
| 4255 | : new ProviderAdapter.ProviderAdapterForkThreadError({ | |
| 4256 | driver, | |
| 4257 | providerThreadId: forkInput.sourceProviderThread.id, | |
| 4258 | cause, | |
| 4259 | }), | |
| 4260 | ), | |
| 4261 | ), | |
| 4262 | }; | |
| 4263 | return runtime; | |
| 4264 | }); | |
| 4265 | ||
| 4266 | return ProviderAdapter.ProviderAdapterV2.of({ | |
| 4267 | instanceId, | |
| 4268 | driver, | |
| 4269 | getCapabilities: () => Effect.succeed(OpenCode2ProviderCapabilities), | |
| 4270 | planSelectionTransition: () => Effect.succeed(turnScopedSelectionTransition()), | |
| 4271 | // The session borrows the instance's server for as long as it is open, so a | |
| 4272 | // spawned server is not idle-stopped under a long tool call. | |
| 4273 | openSession: (input) => | |
| 4274 | Effect.gen(function* () { | |
| 4275 | const scope = yield* Scope.make(); | |
| 4276 | yield* Effect.addFinalizer((exit) => Scope.close(scope, exit)); | |
| 4277 | const connection = yield* borrow.pipe(Effect.provideService(Scope.Scope, scope)); | |
| 4278 | return yield* openSession(input, { connection, scope }); | |
| 4279 | }).pipe( | |
| 4280 | Effect.mapError( | |
| 4281 | (cause) => | |
| 4282 | new ProviderAdapter.ProviderAdapterOpenSessionError({ | |
| 4283 | driver, | |
| 4284 | providerSessionId: input.providerSessionId, | |
| 4285 | cause, | |
| 4286 | }), | |
| 4287 | ), | |
| 4288 | ), | |
| 4289 | }); | |
| 4290 | }); |
packages/provider-opencode/src/server/v2/textGeneration.ts created+204| ... | ... | @@ -0,0 +1,204 @@ |
| 1 | /** | |
| 2 | * Text generation on an OpenCode 2 server. Both of its `generate` endpoints | |
| 3 | * answer 403 on OpenCode's free tier ("free tier can only be used from within | |
| 4 | * OpenCode"), so each request runs as a prompt in a temporary session, as 1.x | |
| 5 | * does, and the session is removed afterwards. | |
| 6 | * | |
| 7 | * @module textGeneration/OpenCode2TextGeneration | |
| 8 | */ | |
| 9 | import { AbsolutePath, Location, Model, Provider, Session } from "@opencode/client/effect"; | |
| 10 | import { TextGenerationError } from "@t3tools/contracts"; | |
| 11 | import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; | |
| 12 | import * as Deferred from "effect/Deferred"; | |
| 13 | import * as Effect from "effect/Effect"; | |
| 14 | import * as Schema from "effect/Schema"; | |
| 15 | import * as Stream from "effect/Stream"; | |
| 16 | ||
| 17 | import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; | |
| 18 | import * as OpenCode2Server from "./OpenCode2Server.ts"; | |
| 19 | import * as OpenCodeRuntime from "../OpenCodeRuntime.ts"; | |
| 20 | import * as TextGenerationOperations from "@t3tools/provider-core/server/textGenerationOperations"; | |
| 21 | ||
| 22 | const isTextGenerationError = Schema.is(TextGenerationError); | |
| 23 | ||
| 24 | /** A generation that takes this long is stuck; its session is stopped and removed. */ | |
| 25 | const GENERATION_TIMEOUT = "3 minutes"; | |
| 26 | ||
| 27 | /** | |
| 28 | * Nothing in a text generation needs a tool, so every tool asks and T3 is not | |
| 29 | * there to answer. Denying `shell` or `read` outright gets the whole session | |
| 30 | * refused on OpenCode's free models, and an ask that is never answered would | |
| 31 | * hang, so asks are rejected as they arrive. | |
| 32 | */ | |
| 33 | const GENERATION_PERMISSIONS = [{ action: "*", resource: "*", effect: "ask" }] as const; | |
| 34 | ||
| 35 | /** How a generation ended. A failure's cause is the provider's own error, kept out of `detail`. */ | |
| 36 | type Outcome = | |
| 37 | | { readonly _tag: "text"; readonly text: string } | |
| 38 | | { readonly _tag: "failed"; readonly detail: string; readonly cause?: unknown }; | |
| 39 | ||
| 40 | const runOnServer = ( | |
| 41 | connection: OpenCode2Server.OpenCode2Connection, | |
| 42 | input: TextGenerationOperations.Request<Schema.Top>, | |
| 43 | resolveAttachmentPath: ProviderHost.ProviderHostShape["resolveAttachmentPath"], | |
| 44 | ) => | |
| 45 | Effect.gen(function* () { | |
| 46 | const { client } = connection; | |
| 47 | const parsed = OpenCodeRuntime.parseOpenCodeModelSlug(input.modelSelection.model); | |
| 48 | if (parsed === null) { | |
| 49 | return yield* new TextGenerationError({ | |
| 50 | operation: input.operation, | |
| 51 | detail: "OpenCode model selection must use the 'provider/model' format.", | |
| 52 | }); | |
| 53 | } | |
| 54 | const variant = getModelSelectionStringOptionValue(input.modelSelection, "variant"); | |
| 55 | const outcome = yield* Deferred.make<Outcome>(); | |
| 56 | let sessionId: string | undefined; | |
| 57 | const texts = new Map<string, string>(); | |
| 58 | // Subscribed first: the stream is volatile, and the reply arrives on it. | |
| 59 | const events = yield* connection.events; | |
| 60 | yield* events.pipe( | |
| 61 | Stream.runForEach((event) => { | |
| 62 | // An execution end a newer server sent in a shape this build cannot | |
| 63 | // read still ends the generation, as it ends a turn in the adapter. | |
| 64 | if (event.type === "unreadable.execution.ended") { | |
| 65 | if (event.sessionID !== sessionId) return Effect.void; | |
| 66 | return Deferred.succeed( | |
| 67 | outcome, | |
| 68 | event.executionType === "session.execution.succeeded" | |
| 69 | ? { _tag: "text", text: [...texts.values()].join("\n").trim() } | |
| 70 | : { | |
| 71 | _tag: "failed", | |
| 72 | detail: "OpenCode ended the generation in a way this version cannot read.", | |
| 73 | cause: event, | |
| 74 | }, | |
| 75 | ); | |
| 76 | } | |
| 77 | if ( | |
| 78 | sessionId === undefined || | |
| 79 | !("data" in event) || | |
| 80 | !("sessionID" in event.data) || | |
| 81 | event.data.sessionID !== sessionId | |
| 82 | ) { | |
| 83 | return Effect.void; | |
| 84 | } | |
| 85 | switch (event.type) { | |
| 86 | case "session.text.ended": | |
| 87 | texts.set(`${event.data.assistantMessageID}:${event.data.ordinal}`, event.data.text); | |
| 88 | return Effect.void; | |
| 89 | case "permission.asked": | |
| 90 | return client.permission | |
| 91 | .reply({ | |
| 92 | sessionID: event.data.sessionID, | |
| 93 | requestID: event.data.id, | |
| 94 | decision: "reject", | |
| 95 | message: "Tools are not available while generating text.", | |
| 96 | }) | |
| 97 | .pipe(Effect.ignore({ log: true })); | |
| 98 | case "session.execution.succeeded": | |
| 99 | return Deferred.succeed(outcome, { | |
| 100 | _tag: "text", | |
| 101 | text: [...texts.values()].join("\n").trim(), | |
| 102 | }); | |
| 103 | case "session.execution.failed": | |
| 104 | return Deferred.succeed(outcome, { | |
| 105 | _tag: "failed", | |
| 106 | detail: "OpenCode could not generate the text.", | |
| 107 | cause: event.data.error, | |
| 108 | }); | |
| 109 | case "session.execution.interrupted": | |
| 110 | return Deferred.succeed(outcome, { | |
| 111 | _tag: "failed", | |
| 112 | detail: "OpenCode stopped the generation.", | |
| 113 | }); | |
| 114 | default: | |
| 115 | return Effect.void; | |
| 116 | } | |
| 117 | }), | |
| 118 | // The reply only arrives on this stream, so a lost stream ends the wait. | |
| 119 | Effect.exit, | |
| 120 | Effect.flatMap((exit) => | |
| 121 | Deferred.succeed(outcome, { | |
| 122 | _tag: "failed", | |
| 123 | detail: "The OpenCode event stream was lost.", | |
| 124 | cause: exit, | |
| 125 | }), | |
| 126 | ), | |
| 127 | Effect.forkScoped, | |
| 128 | ); | |
| 129 | const session = yield* client.session.create({ | |
| 130 | title: `T3 Code ${input.operation}`, | |
| 131 | location: Location.PublicRef.make({ directory: AbsolutePath.make(input.cwd) }), | |
| 132 | model: Model.Ref.make({ | |
| 133 | providerID: Provider.ID.make(parsed.providerID), | |
| 134 | id: Model.ID.make(parsed.modelID), | |
| 135 | ...(variant === undefined ? {} : { variant: Model.VariantID.make(variant) }), | |
| 136 | }), | |
| 137 | permissions: GENERATION_PERMISSIONS, | |
| 138 | }); | |
| 139 | sessionId = session.id; | |
| 140 | yield* Effect.addFinalizer(() => | |
| 141 | client.session | |
| 142 | .remove({ sessionID: session.id }) | |
| 143 | .pipe(Effect.timeout("5 seconds"), Effect.ignore({ log: true })), | |
| 144 | ); | |
| 145 | const images = (input.attachments ?? []).flatMap((attachment) => { | |
| 146 | if (attachment.type !== "image") return []; | |
| 147 | const path = resolveAttachmentPath(attachment); | |
| 148 | return path === null ? [] : [{ uri: `file://${path}`, name: attachment.name }]; | |
| 149 | }); | |
| 150 | yield* client.session.prompt({ | |
| 151 | sessionID: session.id, | |
| 152 | text: input.prompt, | |
| 153 | ...(images.length === 0 ? {} : { files: images }), | |
| 154 | }); | |
| 155 | const result = yield* Deferred.await(outcome).pipe( | |
| 156 | Effect.timeoutOrElse({ | |
| 157 | duration: GENERATION_TIMEOUT, | |
| 158 | orElse: () => | |
| 159 | client.session.interrupt({ sessionID: Session.ID.make(session.id) }).pipe( | |
| 160 | Effect.ignore, | |
| 161 | Effect.as<Outcome>({ | |
| 162 | _tag: "failed", | |
| 163 | detail: "OpenCode did not finish generating in time.", | |
| 164 | }), | |
| 165 | ), | |
| 166 | }), | |
| 167 | ); | |
| 168 | if (result._tag === "failed") { | |
| 169 | return yield* new TextGenerationError({ | |
| 170 | operation: input.operation, | |
| 171 | detail: result.detail, | |
| 172 | ...(result.cause === undefined ? {} : { cause: result.cause }), | |
| 173 | }); | |
| 174 | } | |
| 175 | if (result.text.length === 0) { | |
| 176 | return yield* new TextGenerationError({ | |
| 177 | operation: input.operation, | |
| 178 | detail: "OpenCode returned empty output.", | |
| 179 | }); | |
| 180 | } | |
| 181 | return result.text; | |
| 182 | }).pipe(Effect.scoped); | |
| 183 | ||
| 184 | /** Text generation for an instance whose server is OpenCode 2. */ | |
| 185 | export const make = Effect.fn("OpenCode2TextGeneration.make")(function* () { | |
| 186 | const server = yield* OpenCode2Server.OpenCode2Server; | |
| 187 | const { resolveAttachmentPath } = yield* ProviderHost.ProviderHost; | |
| 188 | const run: TextGenerationOperations.Runner = (input) => | |
| 189 | server | |
| 190 | .withConnection((connection) => runOnServer(connection, input, resolveAttachmentPath)) | |
| 191 | .pipe( | |
| 192 | Effect.mapError((cause) => | |
| 193 | isTextGenerationError(cause) | |
| 194 | ? cause | |
| 195 | : new TextGenerationError({ | |
| 196 | operation: input.operation, | |
| 197 | detail: "OpenCode text generation failed.", | |
| 198 | cause, | |
| 199 | }), | |
| 200 | ), | |
| 201 | Effect.flatMap((raw) => TextGenerationOperations.decodeJsonReply(input, "OpenCode", raw)), | |
| 202 | ); | |
| 203 | return TextGenerationOperations.fromRunner("OpenCode2TextGeneration", run); | |
| 204 | }); |
packages/provider-opencode/src/server/versionProbe.test.ts created+197| ... | ... | @@ -0,0 +1,197 @@ |
| 1 | import { assert, describe, it } from "@effect/vitest"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import { HttpClient, HttpClientError, HttpClientRequest } from "effect/http"; | |
| 4 | ||
| 5 | import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; | |
| 6 | import { | |
| 7 | classifyOpenCodeCliVersion, | |
| 8 | makeOpenCodeRuntimeProbe, | |
| 9 | probeOpenCodeRuntime, | |
| 10 | } from "./versionProbe.ts"; | |
| 11 | import { | |
| 12 | OPENCODE_1_RESPONSES, | |
| 13 | OPENCODE_2_RESPONSES, | |
| 14 | replayOpenCodeServer, | |
| 15 | } from "./probeResponses.fixture.ts"; | |
| 16 | ||
| 17 | const noBinary = { | |
| 18 | runOpenCodeCommand: () => Effect.die("A configured server must not run the local binary"), | |
| 19 | } as unknown as OpenCodeRuntime.OpenCodeRuntimeShape; | |
| 20 | ||
| 21 | const probeServer = (serverUrl: string, serverPassword: string, http: HttpClient.HttpClient) => | |
| 22 | probeOpenCodeRuntime({ binaryPath: "opencode", serverUrl, serverPassword }).pipe( | |
| 23 | Effect.provideService(HttpClient.HttpClient, http), | |
| 24 | Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, noBinary), | |
| 25 | ); | |
| 26 | const SERVER_URL = "http://127.0.0.1:4096/"; | |
| 27 | ||
| 28 | describe("OpenCode version probe", () => { | |
| 29 | it("classifies the recorded `opencode --version` output of both versions", () => { | |
| 30 | assert.deepStrictEqual(classifyOpenCodeCliVersion("1.18.32\n"), { | |
| 31 | generation: "v1", | |
| 32 | version: "1.18.32", | |
| 33 | }); | |
| 34 | assert.deepStrictEqual(classifyOpenCodeCliVersion("opencode v2.0.18\n"), { | |
| 35 | generation: "v2", | |
| 36 | version: "2.0.18", | |
| 37 | }); | |
| 38 | assert.isUndefined(classifyOpenCodeCliVersion("opencode dev build\n")); | |
| 39 | }); | |
| 40 | ||
| 41 | it.effect("finds 2.x at /api/info without falling through to its HTML /global/health", () => | |
| 42 | Effect.gen(function* () { | |
| 43 | const paths: Array<string> = []; | |
| 44 | const probed = yield* probeServer( | |
| 45 | SERVER_URL, | |
| 46 | "pw", | |
| 47 | replayOpenCodeServer(OPENCODE_2_RESPONSES, "pw", paths), | |
| 48 | ); | |
| 49 | assert.deepStrictEqual(probed, { generation: "v2", version: "2.0.18" }); | |
| 50 | assert.deepStrictEqual(paths, ["/api/info"]); | |
| 51 | }), | |
| 52 | ); | |
| 53 | ||
| 54 | it.effect("skips the HTML 1.x serves at /api/info and finds it at /global/health", () => | |
| 55 | Effect.gen(function* () { | |
| 56 | const paths: Array<string> = []; | |
| 57 | const probed = yield* probeServer( | |
| 58 | SERVER_URL, | |
| 59 | "pw", | |
| 60 | replayOpenCodeServer(OPENCODE_1_RESPONSES, "pw", paths), | |
| 61 | ); | |
| 62 | assert.deepStrictEqual(probed, { generation: "v1", version: "1.18.32" }); | |
| 63 | assert.deepStrictEqual(paths, ["/api/info", "/global/health"]); | |
| 64 | }), | |
| 65 | ); | |
| 66 | ||
| 67 | it.effect("keeps a server URL's path prefix and query when probing", () => | |
| 68 | Effect.gen(function* () { | |
| 69 | const urls: Array<string> = []; | |
| 70 | const replay = replayOpenCodeServer(OPENCODE_1_RESPONSES, "pw"); | |
| 71 | // A reverse proxy mounting OpenCode under /opencode, reached with a routing query. | |
| 72 | const proxied = HttpClient.make((request, url) => { | |
| 73 | urls.push(url.href); | |
| 74 | return replay.execute( | |
| 75 | HttpClientRequest.setUrl( | |
| 76 | request, | |
| 77 | `http://127.0.0.1:4096${url.pathname.replace(/^\/opencode/, "")}`, | |
| 78 | ), | |
| 79 | ); | |
| 80 | }); | |
| 81 | const probed = yield* probeServer("http://proxy:8080/opencode/?route=oc", "pw", proxied); | |
| 82 | assert.strictEqual(probed.generation, "v1"); | |
| 83 | assert.deepStrictEqual(urls, [ | |
| 84 | "http://proxy:8080/opencode/api/info?route=oc", | |
| 85 | "http://proxy:8080/opencode/global/health?route=oc", | |
| 86 | ]); | |
| 87 | }), | |
| 88 | ); | |
| 89 | ||
| 90 | it.effect("reports a rejected password instead of guessing a version", () => | |
| 91 | Effect.gen(function* () { | |
| 92 | for (const responses of [OPENCODE_1_RESPONSES, OPENCODE_2_RESPONSES]) { | |
| 93 | const paths: Array<string> = []; | |
| 94 | const error = yield* Effect.flip( | |
| 95 | probeServer(SERVER_URL, "wrong", replayOpenCodeServer(responses, "pw", paths)), | |
| 96 | ); | |
| 97 | assert.match(error.detail, /401 Unauthorized/); | |
| 98 | assert.deepStrictEqual(paths, ["/api/info"]); | |
| 99 | } | |
| 100 | }), | |
| 101 | ); | |
| 102 | ||
| 103 | it.effect("sends a non-ASCII password as UTF-8, as both versions expect", () => | |
| 104 | Effect.gen(function* () { | |
| 105 | for (const [responses, password, generation] of [ | |
| 106 | [OPENCODE_1_RESPONSES, "pässwörd", "v1"], | |
| 107 | [OPENCODE_2_RESPONSES, "pass€word", "v2"], | |
| 108 | ] as const) { | |
| 109 | const probed = yield* probeServer( | |
| 110 | SERVER_URL, | |
| 111 | password, | |
| 112 | replayOpenCodeServer(responses, password), | |
| 113 | ); | |
| 114 | assert.strictEqual(probed.generation, generation); | |
| 115 | } | |
| 116 | }), | |
| 117 | ); | |
| 118 | ||
| 119 | it.effect("does not take other JSON at /api/info for OpenCode 2", () => | |
| 120 | Effect.gen(function* () { | |
| 121 | const impostor = { | |
| 122 | ...OPENCODE_1_RESPONSES, | |
| 123 | "/api/info": { status: 200, contentType: "application/json", body: '{"version":"3.4.1"}' }, | |
| 124 | "/global/health": OPENCODE_1_RESPONSES["/api/info"], | |
| 125 | }; | |
| 126 | const error = yield* Effect.flip( | |
| 127 | probeServer(SERVER_URL, "pw", replayOpenCodeServer(impostor, "pw")), | |
| 128 | ); | |
| 129 | assert.match(error.detail, /did not identify itself as OpenCode/); | |
| 130 | }), | |
| 131 | ); | |
| 132 | ||
| 133 | it.effect("never puts the configured URL or its credentials in an error detail", () => | |
| 134 | Effect.gen(function* () { | |
| 135 | const secret = "userinfo-secret"; | |
| 136 | const unreachable = HttpClient.make((request) => | |
| 137 | Effect.die(`should not be reached for ${request.url}`), | |
| 138 | ); | |
| 139 | // A fetch failure that echoes the full request URL, as Node's does. | |
| 140 | const refused = HttpClient.make((request) => | |
| 141 | Effect.fail( | |
| 142 | new HttpClientError.HttpClientError({ | |
| 143 | reason: new HttpClientError.TransportError({ | |
| 144 | request, | |
| 145 | description: `connect ECONNREFUSED ${request.url}`, | |
| 146 | }), | |
| 147 | }), | |
| 148 | ), | |
| 149 | ); | |
| 150 | for (const [serverUrl, http] of [ | |
| 151 | [`127.0.0.1:4096?token=${secret}`, unreachable], | |
| 152 | [`localhost:4096/${secret}`, unreachable], | |
| 153 | [`http://user:${secret}@10.0.0.1:4096/?token=${secret}`, refused], | |
| 154 | ] as const) { | |
| 155 | const error = yield* Effect.flip(probeServer(serverUrl, secret, http)); | |
| 156 | assert.notInclude(error.detail, secret, serverUrl); | |
| 157 | assert.notInclude(error.detail, "10.0.0.1", serverUrl); | |
| 158 | } | |
| 159 | }), | |
| 160 | ); | |
| 161 | ||
| 162 | it.effect("remembers a probed runtime but never a failure", () => | |
| 163 | Effect.gen(function* () { | |
| 164 | const outputs = ["", "opencode v2.0.18\n", "1.18.32\n"]; | |
| 165 | let calls = 0; | |
| 166 | const runtime = { | |
| 167 | runOpenCodeCommand: () => { | |
| 168 | const stdout = outputs[calls++]; | |
| 169 | return stdout | |
| 170 | ? Effect.succeed({ stdout, stderr: "", code: 0 }) | |
| 171 | : Effect.fail( | |
| 172 | new OpenCodeRuntime.OpenCodeRuntimeError({ operation: "spawn", detail: "ENOENT" }), | |
| 173 | ); | |
| 174 | }, | |
| 175 | } as unknown as OpenCodeRuntime.OpenCodeRuntimeShape; | |
| 176 | const probe = yield* makeOpenCodeRuntimeProbe( | |
| 177 | probeOpenCodeRuntime({ binaryPath: "opencode", serverUrl: "", serverPassword: "" }).pipe( | |
| 178 | Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, runtime), | |
| 179 | Effect.provideService( | |
| 180 | HttpClient.HttpClient, | |
| 181 | HttpClient.make(() => Effect.die("A local binary must not be probed over HTTP")), | |
| 182 | ), | |
| 183 | ), | |
| 184 | ); | |
| 185 | ||
| 186 | yield* Effect.flip(probe.get); | |
| 187 | assert.isTrue((yield* probe.lastSuccess)._tag === "None"); | |
| 188 | assert.strictEqual((yield* probe.get).generation, "v2"); | |
| 189 | assert.strictEqual((yield* probe.get).generation, "v2"); | |
| 190 | assert.strictEqual(calls, 2); | |
| 191 | // A status refresh re-probes, so an in-place downgrade or upgrade re-routes. | |
| 192 | assert.strictEqual((yield* probe.refresh).generation, "v1"); | |
| 193 | assert.strictEqual((yield* probe.get).generation, "v1"); | |
| 194 | assert.strictEqual(calls, 3); | |
| 195 | }), | |
| 196 | ); | |
| 197 | }); |
packages/provider-opencode/src/server/versionProbe.ts created+198| ... | ... | @@ -0,0 +1,198 @@ |
| 1 | /** Detects whether an OpenCode instance runs 1.x or 2.x, so the driver can pick its runtime. */ | |
| 2 | import { parseSemver } from "@t3tools/shared/semver"; | |
| 3 | import * as Cache from "effect/Cache"; | |
| 4 | import * as Duration from "effect/Duration"; | |
| 5 | import * as Effect from "effect/Effect"; | |
| 6 | import * as Exit from "effect/Exit"; | |
| 7 | import * as Option from "effect/Option"; | |
| 8 | import * as Schema from "effect/Schema"; | |
| 9 | import { HttpClient, HttpClientRequest } from "effect/http"; | |
| 10 | ||
| 11 | import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; | |
| 12 | import { parseGenericCliVersion } from "@t3tools/provider-core/server/snapshotProbe"; | |
| 13 | ||
| 14 | export interface ProbedOpenCode { | |
| 15 | readonly generation: "v1" | "v2"; | |
| 16 | readonly version: string; | |
| 17 | } | |
| 18 | ||
| 19 | const OPENCODE_VERSION_PROBE_TIMEOUT = "4 seconds"; | |
| 20 | const OPENCODE_SERVER_PROBE_TIMEOUT = "5 seconds"; | |
| 21 | // 2.x's own CLI decodes `{version, pid}` from `/api/info`; requiring both keeps unrelated JSON out. | |
| 22 | const decodeApiInfo = Schema.decodeUnknownOption( | |
| 23 | Schema.fromJsonString(Schema.Struct({ version: Schema.String, pid: Schema.Int })), | |
| 24 | ); | |
| 25 | const decodeGlobalHealth = Schema.decodeUnknownOption( | |
| 26 | Schema.fromJsonString(Schema.Struct({ healthy: Schema.Literal(true), version: Schema.String })), | |
| 27 | ); | |
| 28 | ||
| 29 | function probed(version: string | null | undefined): ProbedOpenCode | undefined { | |
| 30 | const major = parseSemver(version ?? "")?.major; | |
| 31 | if (!version || major === undefined) return undefined; | |
| 32 | return { generation: major >= 2 ? "v2" : "v1", version }; | |
| 33 | } | |
| 34 | ||
| 35 | /** `opencode --version` prints `1.18.32` on 1.x and `opencode v2.0.18` on 2.x. */ | |
| 36 | export const classifyOpenCodeCliVersion = (output: string) => | |
| 37 | probed(parseGenericCliVersion(output)); | |
| 38 | ||
| 39 | /** | |
| 40 | * 2.x answers `/api/info` and 1.x answers `/global/health`. Each serves its web UI's HTML with a | |
| 41 | * 200 on the other's path, so only a JSON body counts. Both versions answer a wrong password with | |
| 42 | * a 401 on either path, so a 401 says nothing about the version. | |
| 43 | */ | |
| 44 | function classifyOpenCodeProbeResponse( | |
| 45 | path: "/api/info" | "/global/health", | |
| 46 | response: { | |
| 47 | readonly status: number; | |
| 48 | readonly contentType: string | undefined; | |
| 49 | readonly body: string; | |
| 50 | }, | |
| 51 | ): ProbedOpenCode | "unauthorized" | undefined { | |
| 52 | if (response.status === 401) return "unauthorized"; | |
| 53 | const mediaType = response.contentType?.split(";")[0]?.trim().toLowerCase(); | |
| 54 | if (response.status !== 200 || mediaType !== "application/json") return undefined; | |
| 55 | const version: Option.Option<string> = | |
| 56 | path === "/api/info" | |
| 57 | ? Option.map(decodeApiInfo(response.body), (info) => info.version) | |
| 58 | : Option.map(decodeGlobalHealth(response.body), (health) => health.version); | |
| 59 | return probed(Option.getOrUndefined(version)); | |
| 60 | } | |
| 61 | ||
| 62 | const probeOpenCodeBinary = Effect.fn("probeOpenCodeBinary")(function* ( | |
| 63 | binaryPath: string, | |
| 64 | environment: NodeJS.ProcessEnv | undefined, | |
| 65 | ) { | |
| 66 | const runtime = yield* OpenCodeRuntime.OpenCodeRuntime; | |
| 67 | const { stdout } = yield* runtime | |
| 68 | .runOpenCodeCommand({ | |
| 69 | binaryPath, | |
| 70 | args: ["--version"], | |
| 71 | ...(environment === undefined ? {} : { environment }), | |
| 72 | }) | |
| 73 | .pipe( | |
| 74 | Effect.timeoutOrElse({ | |
| 75 | duration: OPENCODE_VERSION_PROBE_TIMEOUT, | |
| 76 | orElse: () => | |
| 77 | Effect.fail( | |
| 78 | new OpenCodeRuntime.OpenCodeRuntimeError({ | |
| 79 | operation: "probeOpenCodeBinary", | |
| 80 | detail: `OpenCode CLI version probe timed out after ${OPENCODE_VERSION_PROBE_TIMEOUT}.`, | |
| 81 | }), | |
| 82 | ), | |
| 83 | }), | |
| 84 | ); | |
| 85 | const result = classifyOpenCodeCliVersion(stdout); | |
| 86 | if (result) return result; | |
| 87 | return yield* new OpenCodeRuntime.OpenCodeRuntimeError({ | |
| 88 | operation: "probeOpenCodeBinary", | |
| 89 | detail: `Unable to determine OpenCode version from \`opencode --version\` output. T3 Code requires OpenCode v${OpenCodeRuntime.MINIMUM_OPENCODE_VERSION} or newer.`, | |
| 90 | }); | |
| 91 | }); | |
| 92 | ||
| 93 | // Server failures reach clients through the provider status, so their details are fixed text: | |
| 94 | // the underlying error can carry the configured URL, its credentials, or the password header. | |
| 95 | const probeOpenCodeServer = Effect.fn("probeOpenCodeServer")(function* ( | |
| 96 | serverUrl: string, | |
| 97 | serverPassword: string, | |
| 98 | ) { | |
| 99 | const client = yield* HttpClient.HttpClient; | |
| 100 | const baseUrl = URL.parse(serverUrl.trim()); | |
| 101 | if (baseUrl?.protocol !== "http:" && baseUrl?.protocol !== "https:") { | |
| 102 | return yield* new OpenCodeRuntime.OpenCodeRuntimeError({ | |
| 103 | operation: "probeOpenCodeServer", | |
| 104 | detail: "The OpenCode server URL is not a valid http:// or https:// URL.", | |
| 105 | }); | |
| 106 | } | |
| 107 | // UTF-8, as the 1.x SDK client sends it; `HttpClientRequest.basicAuth` uses Latin-1 `btoa`. | |
| 108 | const authorization = serverPassword | |
| 109 | ? `Basic ${Buffer.from(`opencode:${serverPassword}`, "utf8").toString("base64")}` | |
| 110 | : undefined; | |
| 111 | for (const path of ["/api/info", "/global/health"] as const) { | |
| 112 | // A path prefix and query on the configured URL are kept: `/base/?x=1` → `/base/api/info?x=1`. | |
| 113 | const url = new URL(baseUrl); | |
| 114 | url.pathname = `${url.pathname.replace(/\/+$/, "")}${path}`; | |
| 115 | const request = HttpClientRequest.get(url.href); | |
| 116 | const result = yield* client | |
| 117 | .execute( | |
| 118 | authorization | |
| 119 | ? HttpClientRequest.setHeader(request, "authorization", authorization) | |
| 120 | : request, | |
| 121 | ) | |
| 122 | .pipe( | |
| 123 | Effect.flatMap((response) => | |
| 124 | Effect.map(response.text, (body) => | |
| 125 | classifyOpenCodeProbeResponse(path, { | |
| 126 | status: response.status, | |
| 127 | contentType: response.headers["content-type"], | |
| 128 | body, | |
| 129 | }), | |
| 130 | ), | |
| 131 | ), | |
| 132 | Effect.mapError( | |
| 133 | (cause) => | |
| 134 | new OpenCodeRuntime.OpenCodeRuntimeError({ | |
| 135 | operation: "probeOpenCodeServer", | |
| 136 | detail: "Couldn't reach the OpenCode server.", | |
| 137 | cause, | |
| 138 | }), | |
| 139 | ), | |
| 140 | Effect.timeoutOrElse({ | |
| 141 | duration: OPENCODE_SERVER_PROBE_TIMEOUT, | |
| 142 | orElse: () => | |
| 143 | Effect.fail( | |
| 144 | new OpenCodeRuntime.OpenCodeRuntimeError({ | |
| 145 | operation: "probeOpenCodeServer", | |
| 146 | detail: "Timed out while checking the OpenCode server version.", | |
| 147 | }), | |
| 148 | ), | |
| 149 | }), | |
| 150 | ); | |
| 151 | if (result === "unauthorized") { | |
| 152 | return yield* new OpenCodeRuntime.OpenCodeRuntimeError({ | |
| 153 | operation: "probeOpenCodeServer", | |
| 154 | detail: "401 Unauthorized: the OpenCode server rejected the password.", | |
| 155 | }); | |
| 156 | } | |
| 157 | if (result !== undefined) return result; | |
| 158 | } | |
| 159 | return yield* new OpenCodeRuntime.OpenCodeRuntimeError({ | |
| 160 | operation: "probeOpenCodeServer", | |
| 161 | detail: `The server did not identify itself as OpenCode. T3 Code requires OpenCode v${OpenCodeRuntime.MINIMUM_OPENCODE_VERSION} or newer.`, | |
| 162 | }); | |
| 163 | }); | |
| 164 | ||
| 165 | /** Probes a configured server when `serverUrl` is set, otherwise the local binary. */ | |
| 166 | export const probeOpenCodeRuntime = ( | |
| 167 | settings: { | |
| 168 | readonly binaryPath: string; | |
| 169 | readonly serverUrl: string; | |
| 170 | readonly serverPassword: string; | |
| 171 | }, | |
| 172 | environment?: NodeJS.ProcessEnv, | |
| 173 | ): Effect.Effect< | |
| 174 | ProbedOpenCode, | |
| 175 | OpenCodeRuntime.OpenCodeRuntimeError, | |
| 176 | HttpClient.HttpClient | OpenCodeRuntime.OpenCodeRuntime | |
| 177 | > => | |
| 178 | settings.serverUrl.trim().length > 0 | |
| 179 | ? probeOpenCodeServer(settings.serverUrl, settings.serverPassword) | |
| 180 | : probeOpenCodeBinary(settings.binaryPath, environment); | |
| 181 | ||
| 182 | /** | |
| 183 | * One instance's runtime, remembered after the first successful probe. Settings changes rebuild | |
| 184 | * the driver; `refresh` re-probes (status checks use it, so an in-place upgrade re-routes). A | |
| 185 | * failed probe is never remembered. `lastSuccess` never probes, for calls too hot to wait on one. | |
| 186 | */ | |
| 187 | export const makeOpenCodeRuntimeProbe = <E>(probe: Effect.Effect<ProbedOpenCode, E>) => | |
| 188 | Effect.map( | |
| 189 | Cache.makeWith(() => probe, { | |
| 190 | capacity: 1, | |
| 191 | timeToLive: (exit) => (Exit.isSuccess(exit) ? Duration.infinity : Duration.zero), | |
| 192 | }), | |
| 193 | (cache) => ({ | |
| 194 | get: Cache.get(cache, undefined), | |
| 195 | refresh: Cache.refresh(cache, undefined), | |
| 196 | lastSuccess: Cache.getSuccess(cache, undefined), | |
| 197 | }), | |
| 198 | ); |
packages/provider-opencode/src/settings.ts created+66| ... | ... | @@ -0,0 +1,66 @@ |
| 1 | /** | |
| 2 | * OpenCode instance settings. Shared by the server driver and the client | |
| 3 | * settings form, so it holds only browser-safe schema code. | |
| 4 | * | |
| 5 | * @module provider-opencode/settings | |
| 6 | */ | |
| 7 | import { | |
| 8 | CustomModelSetting, | |
| 9 | makeBinaryPathSetting, | |
| 10 | makeProviderSettingsSchema, | |
| 11 | TrimmedString, | |
| 12 | } from "@t3tools/contracts"; | |
| 13 | import * as Effect from "effect/Effect"; | |
| 14 | import * as Schema from "effect/Schema"; | |
| 15 | ||
| 16 | export const OpenCodeSettings = makeProviderSettingsSchema( | |
| 17 | { | |
| 18 | // Off by default (like Cursor and Grok): the binding is not yet stable | |
| 19 | // enough to probe on every install. Users opt in from Settings. | |
| 20 | enabled: Schema.Boolean.pipe( | |
| 21 | Schema.withDecodingDefault(Effect.succeed(false)), | |
| 22 | Schema.annotateKey({ providerSettingsForm: { hidden: true } }), | |
| 23 | ), | |
| 24 | binaryPath: makeBinaryPathSetting("opencode").pipe( | |
| 25 | Schema.annotateKey({ | |
| 26 | title: "Binary path", | |
| 27 | description: "Path to the OpenCode binary.", | |
| 28 | providerSettingsForm: { | |
| 29 | placeholder: "opencode", | |
| 30 | clearWhenEmpty: "omit", | |
| 31 | }, | |
| 32 | }), | |
| 33 | ), | |
| 34 | serverUrl: TrimmedString.pipe( | |
| 35 | Schema.withDecodingDefault(Effect.succeed("")), | |
| 36 | Schema.annotateKey({ | |
| 37 | title: "Server URL", | |
| 38 | description: "Leave blank to let T3 Code spawn the server when needed.", | |
| 39 | providerSettingsForm: { | |
| 40 | placeholder: "http://127.0.0.1:4096", | |
| 41 | clearWhenEmpty: "omit", | |
| 42 | }, | |
| 43 | }), | |
| 44 | ), | |
| 45 | serverPassword: TrimmedString.pipe( | |
| 46 | Schema.withDecodingDefault(Effect.succeed("")), | |
| 47 | Schema.annotateKey({ | |
| 48 | title: "Server password", | |
| 49 | description: "Stored in plain text on disk.", | |
| 50 | providerSettingsForm: { | |
| 51 | control: "password", | |
| 52 | placeholder: "Optional", | |
| 53 | clearWhenEmpty: "omit", | |
| 54 | }, | |
| 55 | }), | |
| 56 | ), | |
| 57 | customModels: Schema.Array(CustomModelSetting).pipe( | |
| 58 | Schema.withDecodingDefault(Effect.succeed([])), | |
| 59 | Schema.annotateKey({ providerSettingsForm: { hidden: true } }), | |
| 60 | ), | |
| 61 | }, | |
| 62 | { | |
| 63 | order: ["binaryPath", "serverUrl", "serverPassword"], | |
| 64 | }, | |
| 65 | ); | |
| 66 | export type OpenCodeSettings = typeof OpenCodeSettings.Type; |
packages/provider-opencode/src/testing.ts created+13| ... | ... | @@ -0,0 +1,13 @@ |
| 1 | /** | |
| 2 | * OpenCode internals the server's replay testkits and live tests drive | |
| 3 | * directly: the 2.x client, server, adapter, and text generation, plus the | |
| 4 | * 1.x transcript identifiers. | |
| 5 | * | |
| 6 | * @module provider-opencode/testing | |
| 7 | */ | |
| 8 | export { | |
| 9 | OPENCODE_DEFAULT_INSTANCE_ID, | |
| 10 | OPENCODE_PROVIDER, | |
| 11 | OPENCODE_SDK_PROTOCOL, | |
| 12 | } from "./server/adapter.ts"; | |
| 13 | export { OPENCODE_2_STILL_STOPPING, t3McpServerName } from "./server/v2/adapter.ts"; |
packages/provider-opencode/tsconfig.json created+7| ... | ... | @@ -0,0 +1,7 @@ |
| 1 | { | |
| 2 | "extends": "../../tsconfig.base.json", | |
| 3 | "compilerOptions": { | |
| 4 | "types": ["node"] | |
| 5 | }, | |
| 6 | "include": ["src"] | |
| 7 | } |
packages/shared/package.json+4| ... | ... | @@ -462,6 +462,10 @@ |
| 462 | 462 | "./mcpApp": { |
| 463 | 463 | "types": "./src/mcpApp.ts", |
| 464 | 464 | "import": "./src/mcpApp.ts" |
| 465 | }, | |
| 466 | "./atomicWrite": { | |
| 467 | "types": "./src/atomicWrite.ts", | |
| 468 | "import": "./src/atomicWrite.ts" | |
| 465 | 469 | } |
| 466 | 470 | }, |
| 467 | 471 | "scripts": { |
packages/shared/src/atomicWrite.test.ts created+185| ... | ... | @@ -0,0 +1,185 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { assert, it } from "@effect/vitest"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Exit from "effect/Exit"; | |
| 5 | import * as FileSystem from "effect/FileSystem"; | |
| 6 | import * as Layer from "effect/Layer"; | |
| 7 | import * as Path from "effect/Path"; | |
| 8 | import * as PlatformError from "effect/PlatformError"; | |
| 9 | ||
| 10 | import { writeFileStringAtomically } from "./atomicWrite.ts"; | |
| 11 | ||
| 12 | it.layer(NodeServices.layer)("writeFileStringAtomically", (it) => { | |
| 13 | it.effect("keeps a symlinked file linked and rewrites its destination", () => | |
| 14 | Effect.gen(function* () { | |
| 15 | const fs = yield* FileSystem.FileSystem; | |
| 16 | const path = yield* Path.Path; | |
| 17 | const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-atomic-write-" }); | |
| 18 | const destination = path.join(root, "dotfiles", "settings.json"); | |
| 19 | const link = path.join(root, "home", "settings.json"); | |
| 20 | yield* fs.makeDirectory(path.dirname(destination), { recursive: true }); | |
| 21 | yield* fs.makeDirectory(path.dirname(link), { recursive: true }); | |
| 22 | yield* fs.writeFileString(destination, "before"); | |
| 23 | yield* fs.symlink(destination, link); | |
| 24 | ||
| 25 | yield* writeFileStringAtomically({ filePath: link, contents: "after" }); | |
| 26 | ||
| 27 | assert.strictEqual(yield* fs.readLink(link), destination); | |
| 28 | assert.strictEqual(yield* fs.readFileString(destination), "after"); | |
| 29 | }), | |
| 30 | ); | |
| 31 | ||
| 32 | it.effect("keeps a dangling symlink linked and creates its destination", () => | |
| 33 | Effect.gen(function* () { | |
| 34 | const fs = yield* FileSystem.FileSystem; | |
| 35 | const path = yield* Path.Path; | |
| 36 | const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-atomic-write-" }); | |
| 37 | const destination = path.join(root, "dotfiles", "settings.json"); | |
| 38 | const link = path.join(root, "home", "settings.json"); | |
| 39 | yield* fs.makeDirectory(path.dirname(link), { recursive: true }); | |
| 40 | yield* fs.symlink(destination, link); | |
| 41 | ||
| 42 | yield* writeFileStringAtomically({ filePath: link, contents: "fresh" }); | |
| 43 | ||
| 44 | assert.strictEqual(yield* fs.readLink(link), destination); | |
| 45 | assert.strictEqual(yield* fs.readFileString(destination), "fresh"); | |
| 46 | }), | |
| 47 | ); | |
| 48 | ||
| 49 | it.effect("fails on a symlink cycle without replacing either link", () => | |
| 50 | Effect.gen(function* () { | |
| 51 | const fs = yield* FileSystem.FileSystem; | |
| 52 | const path = yield* Path.Path; | |
| 53 | const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-atomic-write-" }); | |
| 54 | const first = path.join(root, "first.json"); | |
| 55 | const second = path.join(root, "second.json"); | |
| 56 | yield* fs.symlink(second, first); | |
| 57 | yield* fs.symlink(first, second); | |
| 58 | ||
| 59 | const result = yield* Effect.exit( | |
| 60 | writeFileStringAtomically({ filePath: first, contents: "after" }), | |
| 61 | ); | |
| 62 | ||
| 63 | assert.isTrue(Exit.isFailure(result)); | |
| 64 | assert.strictEqual(yield* fs.readLink(first), second); | |
| 65 | assert.strictEqual(yield* fs.readLink(second), first); | |
| 66 | }), | |
| 67 | ); | |
| 68 | ||
| 69 | it.effect("resolves a relative link through a symlinked parent directory", () => | |
| 70 | Effect.gen(function* () { | |
| 71 | const fs = yield* FileSystem.FileSystem; | |
| 72 | const path = yield* Path.Path; | |
| 73 | const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-atomic-write-" }); | |
| 74 | const destination = path.join(root, "dotfiles", "config", "settings.json"); | |
| 75 | const linkedState = path.join(root, "dotfiles", "state"); | |
| 76 | const home = path.join(root, "home"); | |
| 77 | const link = path.join(home, "state", "settings.json"); | |
| 78 | yield* fs.makeDirectory(path.dirname(destination), { recursive: true }); | |
| 79 | yield* fs.makeDirectory(linkedState, { recursive: true }); | |
| 80 | yield* fs.makeDirectory(home, { recursive: true }); | |
| 81 | yield* fs.symlink(linkedState, path.join(home, "state")); | |
| 82 | yield* fs.writeFileString(destination, "before"); | |
| 83 | yield* fs.symlink("../config/settings.json", link); | |
| 84 | ||
| 85 | yield* writeFileStringAtomically({ filePath: link, contents: "after" }); | |
| 86 | ||
| 87 | assert.strictEqual(yield* fs.readLink(link), "../config/settings.json"); | |
| 88 | assert.strictEqual(yield* fs.readFileString(destination), "after"); | |
| 89 | }), | |
| 90 | ); | |
| 91 | ||
| 92 | it.effect("creates a missing file and its directory", () => | |
| 93 | Effect.gen(function* () { | |
| 94 | const fs = yield* FileSystem.FileSystem; | |
| 95 | const path = yield* Path.Path; | |
| 96 | const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-atomic-write-" }); | |
| 97 | const filePath = path.join(root, "nested", "settings.json"); | |
| 98 | ||
| 99 | yield* writeFileStringAtomically({ filePath, contents: "fresh" }); | |
| 100 | ||
| 101 | assert.strictEqual(yield* fs.readFileString(filePath), "fresh"); | |
| 102 | }), | |
| 103 | ); | |
| 104 | }); | |
| 105 | ||
| 106 | it.effect("surfaces an unreadable link instead of writing over it", () => | |
| 107 | Effect.gen(function* () { | |
| 108 | const readLinkFailure = PlatformError.systemError({ | |
| 109 | _tag: "Unknown", | |
| 110 | module: "FileSystem", | |
| 111 | method: "readLink", | |
| 112 | pathOrDescriptor: "/home/settings.json", | |
| 113 | }); | |
| 114 | ||
| 115 | const result = yield* Effect.exit( | |
| 116 | writeFileStringAtomically({ filePath: "/home/settings.json", contents: "after" }), | |
| 117 | ); | |
| 118 | ||
| 119 | assert.deepStrictEqual(result, Exit.fail(readLinkFailure)); | |
| 120 | }).pipe( | |
| 121 | Effect.provide( | |
| 122 | Layer.mergeAll( | |
| 123 | Path.layer, | |
| 124 | FileSystem.layerNoop({ | |
| 125 | readLink: () => | |
| 126 | Effect.fail( | |
| 127 | PlatformError.systemError({ | |
| 128 | _tag: "Unknown", | |
| 129 | module: "FileSystem", | |
| 130 | method: "readLink", | |
| 131 | pathOrDescriptor: "/home/settings.json", | |
| 132 | }), | |
| 133 | ), | |
| 134 | rename: () => Effect.die("an unreadable link must not be replaced"), | |
| 135 | }), | |
| 136 | ), | |
| 137 | ), | |
| 138 | ), | |
| 139 | ); | |
| 140 | ||
| 141 | it.effect("succeeds when the write lands but its temp directory cannot be removed", () => | |
| 142 | Effect.gen(function* () { | |
| 143 | const renamed: Array<string> = []; | |
| 144 | const removed: Array<string> = []; | |
| 145 | const fileSystem = FileSystem.layerNoop({ | |
| 146 | // The target does not exist yet, so it is written in place. | |
| 147 | readLink: (path) => | |
| 148 | Effect.fail( | |
| 149 | PlatformError.systemError({ | |
| 150 | _tag: "NotFound", | |
| 151 | module: "FileSystem", | |
| 152 | method: "readLink", | |
| 153 | pathOrDescriptor: path, | |
| 154 | }), | |
| 155 | ), | |
| 156 | makeDirectory: () => Effect.void, | |
| 157 | makeTempDirectory: () => Effect.succeed("/home/settings.json.abc123"), | |
| 158 | writeFileString: () => Effect.void, | |
| 159 | rename: (_from, to) => Effect.sync(() => void renamed.push(to)), | |
| 160 | remove: (path) => | |
| 161 | Effect.sync(() => void removed.push(path)).pipe( | |
| 162 | Effect.andThen( | |
| 163 | Effect.fail( | |
| 164 | PlatformError.systemError({ | |
| 165 | _tag: "PermissionDenied", | |
| 166 | module: "FileSystem", | |
| 167 | method: "remove", | |
| 168 | pathOrDescriptor: path, | |
| 169 | }), | |
| 170 | ), | |
| 171 | ), | |
| 172 | ), | |
| 173 | }); | |
| 174 | ||
| 175 | const result = yield* Effect.exit( | |
| 176 | writeFileStringAtomically({ filePath: "/home/settings.json", contents: "after" }).pipe( | |
| 177 | Effect.provide(Layer.mergeAll(Path.layer, fileSystem)), | |
| 178 | ), | |
| 179 | ); | |
| 180 | ||
| 181 | assert.deepStrictEqual(result, Exit.void); | |
| 182 | assert.deepStrictEqual(renamed, ["/home/settings.json"]); | |
| 183 | assert.deepStrictEqual(removed, ["/home/settings.json.abc123"]); | |
| 184 | }), | |
| 185 | ); |
packages/shared/src/atomicWrite.ts created+38| ... | ... | @@ -0,0 +1,38 @@ |
| 1 | import * as Effect from "effect/Effect"; | |
| 2 | import * as FileSystem from "effect/FileSystem"; | |
| 3 | import * as Path from "effect/Path"; | |
| 4 | import { resolveSymlinkTarget } from "@t3tools/shared/symlink"; | |
| 5 | ||
| 6 | /** | |
| 7 | * Replaces a file's contents via a sibling temp file and rename. A symlinked | |
| 8 | * target is resolved first so the link survives and its destination is | |
| 9 | * rewritten, since renaming over the link itself would swap it for a regular file. | |
| 10 | */ | |
| 11 | export const writeFileStringAtomically = (input: { | |
| 12 | readonly filePath: string; | |
| 13 | readonly contents: string; | |
| 14 | }) => | |
| 15 | Effect.scoped( | |
| 16 | Effect.gen(function* () { | |
| 17 | const fs = yield* FileSystem.FileSystem; | |
| 18 | const path = yield* Path.Path; | |
| 19 | const targetPath = yield* resolveSymlinkTarget(input.filePath); | |
| 20 | const targetDirectory = path.dirname(targetPath); | |
| 21 | ||
| 22 | yield* fs.makeDirectory(targetDirectory, { recursive: true }); | |
| 23 | // The temp directory is cleanup, not part of the write: failing to remove | |
| 24 | // it (a virus scanner holding it on Windows) must not fail a write that | |
| 25 | // already landed. | |
| 26 | const tempDirectory = yield* Effect.acquireRelease( | |
| 27 | fs.makeTempDirectory({ | |
| 28 | directory: targetDirectory, | |
| 29 | prefix: `${path.basename(targetPath)}.`, | |
| 30 | }), | |
| 31 | (directory) => fs.remove(directory, { recursive: true }).pipe(Effect.ignore({ log: true })), | |
| 32 | ); | |
| 33 | const tempPath = path.join(tempDirectory, "contents.tmp"); | |
| 34 | ||
| 35 | yield* fs.writeFileString(tempPath, input.contents); | |
| 36 | yield* fs.rename(tempPath, targetPath); | |
| 37 | }), | |
| 38 | ); |
pnpm-lock.yaml+52-9| ... | ... | @@ -359,6 +359,9 @@ importers: |
| 359 | 359 | '@t3tools/provider-muse': |
| 360 | 360 | specifier: workspace:* |
| 361 | 361 | version: link:../../packages/provider-muse |
| 362 | '@t3tools/provider-opencode': | |
| 363 | specifier: workspace:* | |
| 364 | version: link:../../packages/provider-opencode | |
| 362 | 365 | '@t3tools/provider-pi': |
| 363 | 366 | specifier: workspace:* |
| 364 | 367 | version: link:../../packages/provider-pi |
| ... | ... | @@ -594,21 +597,15 @@ importers: |
| 594 | 597 | '@opencode-ai/sdk': |
| 595 | 598 | specifier: ^1.3.15 |
| 596 | 599 | version: 1.15.13 |
| 597 | '@opencode/client': | |
| 598 | specifier: 2.0.23 | |
| 599 | version: 2.0.23(patch_hash=f725d6e6d9af0d56dc6ed71b863518fbfc751af1773cff2311209d2c404cf4cd)(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f)) | |
| 600 | '@opencode/protocol': | |
| 601 | specifier: 2.0.23 | |
| 602 | version: 2.0.23(patch_hash=17a82f6c2c98fd0371e786c211a4fbdaaba403577a25733e5e99a0c25a16a685) | |
| 603 | '@opencode/schema': | |
| 604 | specifier: 2.0.23 | |
| 605 | version: 2.0.23(patch_hash=650f1b8602789407c010861284272dcb6bd6de01ec6a75bf3c0904a0e7596d59) | |
| 606 | 600 | '@t3tools/provider-core': |
| 607 | 601 | specifier: workspace:* |
| 608 | 602 | version: link:../../packages/provider-core |
| 609 | 603 | '@t3tools/provider-muse': |
| 610 | 604 | specifier: workspace:* |
| 611 | 605 | version: link:../../packages/provider-muse |
| 606 | '@t3tools/provider-opencode': | |
| 607 | specifier: workspace:* | |
| 608 | version: link:../../packages/provider-opencode | |
| 612 | 609 | '@t3tools/provider-pi': |
| 613 | 610 | specifier: workspace:* |
| 614 | 611 | version: link:../../packages/provider-pi |
| ... | ... | @@ -742,6 +739,9 @@ importers: |
| 742 | 739 | '@t3tools/provider-muse': |
| 743 | 740 | specifier: workspace:* |
| 744 | 741 | version: link:../../packages/provider-muse |
| 742 | '@t3tools/provider-opencode': | |
| 743 | specifier: workspace:* | |
| 744 | version: link:../../packages/provider-opencode | |
| 745 | 745 | '@t3tools/provider-pi': |
| 746 | 746 | specifier: workspace:* |
| 747 | 747 | version: link:../../packages/provider-pi |
| ... | ... | @@ -1142,6 +1142,49 @@ importers: |
| 1142 | 1142 | specifier: 1.0.0 |
| 1143 | 1143 | 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) |
| 1144 | 1144 | |
| 1145 | packages/provider-opencode: | |
| 1146 | dependencies: | |
| 1147 | '@opencode-ai/sdk': | |
| 1148 | specifier: ^1.3.15 | |
| 1149 | version: 1.15.13 | |
| 1150 | '@opencode/client': | |
| 1151 | specifier: 2.0.23 | |
| 1152 | version: 2.0.23(patch_hash=f725d6e6d9af0d56dc6ed71b863518fbfc751af1773cff2311209d2c404cf4cd)(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f)) | |
| 1153 | '@opencode/protocol': | |
| 1154 | specifier: 2.0.23 | |
| 1155 | version: 2.0.23(patch_hash=17a82f6c2c98fd0371e786c211a4fbdaaba403577a25733e5e99a0c25a16a685) | |
| 1156 | '@opencode/schema': | |
| 1157 | specifier: 2.0.23 | |
| 1158 | version: 2.0.23(patch_hash=650f1b8602789407c010861284272dcb6bd6de01ec6a75bf3c0904a0e7596d59) | |
| 1159 | '@t3tools/contracts': | |
| 1160 | specifier: workspace:* | |
| 1161 | version: link:../contracts | |
| 1162 | '@t3tools/provider-core': | |
| 1163 | specifier: workspace:* | |
| 1164 | version: link:../provider-core | |
| 1165 | '@t3tools/shared': | |
| 1166 | specifier: workspace:* | |
| 1167 | version: link:../shared | |
| 1168 | effect: | |
| 1169 | specifier: 4.0.1 | |
| 1170 | version: 4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f) | |
| 1171 | devDependencies: | |
| 1172 | '@effect/platform-node': | |
| 1173 | specifier: 4.0.1 | |
| 1174 | 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) | |
| 1175 | '@effect/vitest': | |
| 1176 | specifier: 4.0.1 | |
| 1177 | 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) | |
| 1178 | '@t3tools/provider-testing': | |
| 1179 | specifier: workspace:* | |
| 1180 | version: link:../provider-testing | |
| 1181 | '@types/node': | |
| 1182 | specifier: 24.12.4 | |
| 1183 | version: 24.12.4 | |
| 1184 | vite-plus: | |
| 1185 | specifier: 1.0.0 | |
| 1186 | 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) | |
| 1187 | ||
| 1145 | 1188 | packages/provider-pi: |
| 1146 | 1189 | dependencies: |
| 1147 | 1190 | '@t3tools/contracts': |
scripts/release-smoke.ts+1| ... | ... | @@ -32,6 +32,7 @@ const workspaceFiles = [ |
| 32 | 32 | "packages/provider-testing/package.json", |
| 33 | 33 | "packages/provider-pi/package.json", |
| 34 | 34 | "packages/provider-muse/package.json", |
| 35 | "packages/provider-opencode/package.json", | |
| 35 | 36 | "packages/effect-acp/package.json", |
| 36 | 37 | "packages/effect-codex-app-server/package.json", |
| 37 | 38 | "scripts/package.json", |