| author | |
| committer | |
| log | 0ca3240691bf1773802b3ed70330515d68b0a6b8 |
| tree | 22d6f96d1c4819a3e33edff3b4311cd7c0b1d8da |
| parent | f4da4f3b4037260bbb0d8914acbebafd2206607a |
| signature | Signed by PGP key B5690EEEBB952194 |
6 files changed, 465 insertions(+), 20 deletions(-)
apps/server/src/provider/Layers/OpenCodeAdapter.test.ts+8| ... | @@ -221,6 +221,14 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { | ... | @@ -221,6 +221,14 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { |
| 221 | cause: null, | 221 | cause: null, |
| 222 | }), | 222 | }), |
| 223 | ), | 223 | ), |
| 224 | loadInventoryFromCli: () => | ||
| 225 | Effect.fail( | ||
| 226 | new OpenCodeRuntimeError({ | ||
| 227 | operation: "loadInventoryFromCli", | ||
| 228 | detail: "OpenCodeRuntimeTestDouble.loadInventoryFromCli not used in this test", | ||
| 229 | cause: null, | ||
| 230 | }), | ||
| 231 | ), | ||
| 224 | }; | 232 | }; |
| 225 | 233 | ||
| 226 | const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory, { | 234 | const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory, { |
apps/server/src/provider/Layers/OpenCodeProvider.test.ts+20-2| ... | @@ -95,6 +95,13 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { | ... | @@ -95,6 +95,13 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { |
| 95 | }), | 95 | }), |
| 96 | ) | 96 | ) |
| 97 | : Effect.succeed(runtimeMock.state.inventory as OpenCodeInventory), | 97 | : Effect.succeed(runtimeMock.state.inventory as OpenCodeInventory), |
| 98 | loadInventoryFromCli: () => | ||
| 99 | runtimeMock.state.inventoryError | ||
| 100 | ? Effect.succeed({ | ||
| 101 | providerList: { all: [], default: {}, connected: [] as string[] }, | ||
| 102 | agents: [], | ||
| 103 | } as OpenCodeInventory) | ||
| 104 | : Effect.succeed(runtimeMock.state.inventory as OpenCodeInventory), | ||
| 98 | }; | 105 | }; |
| 99 | 106 | ||
| 100 | beforeEach(() => { | 107 | beforeEach(() => { |
| ... | @@ -197,11 +204,22 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { | ... | @@ -197,11 +204,22 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { |
| 197 | }), | 204 | }), |
| 198 | ); | 205 | ); |
| 199 | 206 | ||
| 200 | it.effect("closes the local OpenCode server scope after provider refresh", () => | 207 | it.effect("does not spawn a local server for health check (uses CLI instead)", () => |
| 201 | Effect.gen(function* () { | 208 | Effect.gen(function* () { |
| 202 | yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); | 209 | yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); |
| 203 | 210 | ||
| 204 | NodeAssert.equal(runtimeMock.state.closeCalls, 1); | 211 | NodeAssert.equal(runtimeMock.state.closeCalls, 0); |
| 212 | }), | ||
| 213 | ); | ||
| 214 | |||
| 215 | it.effect("degrades gracefully on CLI failure for local installs", () => | ||
| 216 | Effect.gen(function* () { | ||
| 217 | runtimeMock.state.inventoryError = new Error("opencode models failed"); | ||
| 218 | const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); | ||
| 219 | |||
| 220 | NodeAssert.equal(snapshot.status, "warning"); | ||
| 221 | NodeAssert.equal(snapshot.installed, true); | ||
| 222 | NodeAssert.equal(snapshot.models.length, 0); | ||
| 205 | }), | 223 | }), |
| 206 | ); | 224 | ); |
| 207 | }); | 225 | }); |
apps/server/src/provider/Layers/OpenCodeProvider.ts+24-18| ... | @@ -409,26 +409,32 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu | ... | @@ -409,26 +409,32 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu |
| 409 | } | 409 | } |
| 410 | 410 | ||
| 411 | const inventoryExit = yield* Effect.exit( | 411 | const inventoryExit = yield* Effect.exit( |
| 412 | Effect.scoped( | 412 | (isExternalServer |
| 413 | Effect.gen(function* () { | 413 | ? Effect.scoped( |
| 414 | const server = yield* openCodeRuntime.connectToOpenCodeServer({ | 414 | Effect.gen(function* () { |
| 415 | const server = yield* openCodeRuntime.connectToOpenCodeServer({ | ||
| 416 | binaryPath: openCodeSettings.binaryPath, | ||
| 417 | serverUrl: openCodeSettings.serverUrl, | ||
| 418 | environment: resolvedEnvironment, | ||
| 419 | }); | ||
| 420 | return yield* openCodeRuntime.loadOpenCodeInventory( | ||
| 421 | openCodeRuntime.createOpenCodeSdkClient({ | ||
| 422 | baseUrl: server.url, | ||
| 423 | directory: cwd, | ||
| 424 | ...(openCodeSettings.serverPassword | ||
| 425 | ? { serverPassword: openCodeSettings.serverPassword } | ||
| 426 | : {}), | ||
| 427 | }), | ||
| 428 | ); | ||
| 429 | }), | ||
| 430 | ) | ||
| 431 | : openCodeRuntime.loadInventoryFromCli({ | ||
| 415 | binaryPath: openCodeSettings.binaryPath, | 432 | binaryPath: openCodeSettings.binaryPath, |
| 416 | serverUrl: openCodeSettings.serverUrl, | ||
| 417 | environment: resolvedEnvironment, | 433 | environment: resolvedEnvironment, |
| 418 | }); | 434 | }) |
| 419 | return yield* openCodeRuntime.loadOpenCodeInventory( | 435 | ).pipe( |
| 420 | openCodeRuntime.createOpenCodeSdkClient({ | 436 | Effect.mapError( |
| 421 | baseUrl: server.url, | 437 | (cause) => new OpenCodeProbeError({ cause, detail: openCodeRuntimeErrorDetail(cause) }), |
| 422 | directory: cwd, | ||
| 423 | ...(isExternalServer && openCodeSettings.serverPassword | ||
| 424 | ? { serverPassword: openCodeSettings.serverPassword } | ||
| 425 | : {}), | ||
| 426 | }), | ||
| 427 | ); | ||
| 428 | }).pipe( | ||
| 429 | Effect.mapError( | ||
| 430 | (cause) => new OpenCodeProbeError({ cause, detail: openCodeRuntimeErrorDetail(cause) }), | ||
| 431 | ), | ||
| 432 | ), | 438 | ), |
| 433 | ), | 439 | ), |
| 434 | ); | 440 | ); |
apps/server/src/provider/opencodeRuntime.cliParsers.test.ts created+229| ... | @@ -0,0 +1,229 @@ | ||
| 1 | import * as NodeAssert from "node:assert/strict"; | ||
| 2 | |||
| 3 | import { describe, it } from "vite-plus/test"; | ||
| 4 | |||
| 5 | import { parseModelsCliOutput, parseAgentListCliOutput } 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 = 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 = 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 = 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 = 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 = 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 = 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 | |||
| 130 | describe("parseAgentListCliOutput", () => { | ||
| 131 | it("parses a single agent", () => { | ||
| 132 | const stdout = [ | ||
| 133 | "build (primary)", | ||
| 134 | " " + JSON.stringify([{ permission: "*", action: "allow", pattern: "*" }]), | ||
| 135 | ].join("\n"); | ||
| 136 | |||
| 137 | const result = parseAgentListCliOutput(stdout); | ||
| 138 | NodeAssert.equal(result.length, 1); | ||
| 139 | NodeAssert.equal(result[0]!.name, "build"); | ||
| 140 | NodeAssert.equal(result[0]!.mode, "primary"); | ||
| 141 | NodeAssert.equal(result[0]!.permission.length, 1); | ||
| 142 | }); | ||
| 143 | |||
| 144 | it("parses multiple agents", () => { | ||
| 145 | const stdout = [ | ||
| 146 | "build (primary)", | ||
| 147 | " " + JSON.stringify([{ permission: "*", action: "allow", pattern: "*" }]), | ||
| 148 | "explore (subagent)", | ||
| 149 | " " + JSON.stringify([{ permission: "read", action: "allow", pattern: "*" }]), | ||
| 150 | "plan (primary)", | ||
| 151 | " " + JSON.stringify([{ permission: "edit", action: "ask", pattern: "*.md" }]), | ||
| 152 | ].join("\n"); | ||
| 153 | |||
| 154 | const result = parseAgentListCliOutput(stdout); | ||
| 155 | NodeAssert.equal(result.length, 3); | ||
| 156 | NodeAssert.equal(result[0]!.name, "build"); | ||
| 157 | NodeAssert.equal(result[0]!.mode, "primary"); | ||
| 158 | NodeAssert.equal(result[1]!.name, "explore"); | ||
| 159 | NodeAssert.equal(result[1]!.mode, "subagent"); | ||
| 160 | NodeAssert.equal(result[2]!.name, "plan"); | ||
| 161 | NodeAssert.equal(result[2]!.mode, "primary"); | ||
| 162 | }); | ||
| 163 | |||
| 164 | it("handles empty input", () => { | ||
| 165 | const result = parseAgentListCliOutput(""); | ||
| 166 | NodeAssert.equal(result.length, 0); | ||
| 167 | }); | ||
| 168 | |||
| 169 | it("skips agents with unparseable permission JSON", () => { | ||
| 170 | const stdout = [ | ||
| 171 | "build (primary)", | ||
| 172 | " not valid json {", | ||
| 173 | "explore (subagent)", | ||
| 174 | " " + JSON.stringify([{ permission: "read", action: "allow", pattern: "*" }]), | ||
| 175 | ].join("\n"); | ||
| 176 | |||
| 177 | const result = parseAgentListCliOutput(stdout); | ||
| 178 | NodeAssert.equal(result.length, 1); | ||
| 179 | NodeAssert.equal(result[0]!.name, "explore"); | ||
| 180 | }); | ||
| 181 | |||
| 182 | it("handles real-world permission blocks with nested paths", () => { | ||
| 183 | const permissions = [ | ||
| 184 | { permission: "*", action: "allow", pattern: "*" }, | ||
| 185 | { | ||
| 186 | permission: "external_directory", | ||
| 187 | pattern: "C:\\Users\\test\\.local\\*", | ||
| 188 | action: "allow", | ||
| 189 | }, | ||
| 190 | { permission: "read", pattern: "*.env", action: "ask" }, | ||
| 191 | ]; | ||
| 192 | const stdout = ["build (primary)", " " + JSON.stringify(permissions)].join("\n"); | ||
| 193 | |||
| 194 | const result = parseAgentListCliOutput(stdout); | ||
| 195 | NodeAssert.equal(result.length, 1); | ||
| 196 | NodeAssert.equal(result[0]!.permission.length, 3); | ||
| 197 | NodeAssert.equal(result[0]!.permission[0]!.action, "allow"); | ||
| 198 | NodeAssert.equal(result[0]!.permission[2]!.action, "ask"); | ||
| 199 | }); | ||
| 200 | |||
| 201 | it("handles agent names with spaces", () => { | ||
| 202 | const stdout = [ | ||
| 203 | "code reviewer (subagent)", | ||
| 204 | " " + JSON.stringify([{ permission: "read", action: "allow", pattern: "*" }]), | ||
| 205 | "my custom agent (primary)", | ||
| 206 | " " + JSON.stringify([{ permission: "edit", action: "ask", pattern: "*.ts" }]), | ||
| 207 | ].join("\n"); | ||
| 208 | |||
| 209 | const result = parseAgentListCliOutput(stdout); | ||
| 210 | NodeAssert.equal(result.length, 2); | ||
| 211 | NodeAssert.equal(result[0]!.name, "code reviewer"); | ||
| 212 | NodeAssert.equal(result[0]!.mode, "subagent"); | ||
| 213 | NodeAssert.equal(result[1]!.name, "my custom agent"); | ||
| 214 | NodeAssert.equal(result[1]!.mode, "primary"); | ||
| 215 | }); | ||
| 216 | |||
| 217 | it("marks known hidden agents", () => { | ||
| 218 | const stdout = [ | ||
| 219 | "compaction (primary)", | ||
| 220 | " " + JSON.stringify([{ permission: "*", action: "allow", pattern: "*" }]), | ||
| 221 | "build (primary)", | ||
| 222 | " " + JSON.stringify([{ permission: "*", action: "allow", pattern: "*" }]), | ||
| 223 | ].join("\n"); | ||
| 224 | |||
| 225 | const result = parseAgentListCliOutput(stdout); | ||
| 226 | NodeAssert.equal(result[0]!.hidden, true); | ||
| 227 | NodeAssert.equal(result[1]!.hidden, false); | ||
| 228 | }); | ||
| 229 | }); | ||
apps/server/src/provider/opencodeRuntime.ts+176| ... | @@ -5,6 +5,7 @@ import { | ... | @@ -5,6 +5,7 @@ import { |
| 5 | createOpencodeClient, | 5 | createOpencodeClient, |
| 6 | type Agent, | 6 | type Agent, |
| 7 | type FilePartInput, | 7 | type FilePartInput, |
| 8 | type Model, | ||
| 8 | type OpencodeClient, | 9 | type OpencodeClient, |
| 9 | type PermissionRuleset, | 10 | type PermissionRuleset, |
| 10 | type ProviderListResponse, | 11 | type ProviderListResponse, |
| ... | @@ -147,6 +148,10 @@ export interface OpenCodeRuntimeShape { | ... | @@ -147,6 +148,10 @@ export interface OpenCodeRuntimeShape { |
| 147 | readonly loadOpenCodeInventory: ( | 148 | readonly loadOpenCodeInventory: ( |
| 148 | client: OpencodeClient, | 149 | client: OpencodeClient, |
| 149 | ) => Effect.Effect<OpenCodeInventory, OpenCodeRuntimeError>; | 150 | ) => Effect.Effect<OpenCodeInventory, OpenCodeRuntimeError>; |
| 151 | readonly loadInventoryFromCli: (input: { | ||
| 152 | readonly binaryPath: string; | ||
| 153 | readonly environment?: NodeJS.ProcessEnv; | ||
| 154 | }) => Effect.Effect<OpenCodeInventory, OpenCodeRuntimeError>; | ||
| 150 | } | 155 | } |
| 151 | 156 | ||
| 152 | function parseServerUrlFromOutput(output: string): string | null { | 157 | function parseServerUrlFromOutput(output: string): string | null { |
| ... | @@ -160,6 +165,113 @@ function parseServerUrlFromOutput(output: string): string | null { | ... | @@ -160,6 +165,113 @@ function parseServerUrlFromOutput(output: string): string | null { |
| 160 | return null; | 165 | return null; |
| 161 | } | 166 | } |
| 162 | 167 | ||
| 168 | const SLUG_LINE_RE = /^(\S+\/\S+)\s*$/; | ||
| 169 | const AGENT_HEADER_RE = /^(.+)\s+\((\S+)\)\s*$/; | ||
| 170 | |||
| 171 | // Agents that are always hidden in OpenCode but the CLI "agent list" command | ||
| 172 | // does not expose the hidden flag. Keep in sync with OpenCode agent | ||
| 173 | // definitions (in the OpenCode repo: packages/opencode/src/agent/agent.ts). | ||
| 174 | const KNOWN_HIDDEN_AGENTS = new Set(["compaction", "summary", "title"]); | ||
| 175 | |||
| 176 | /** @internal */ | ||
| 177 | export function parseModelsCliOutput(stdout: string): { | ||
| 178 | readonly providers: ReadonlyMap< | ||
| 179 | string, | ||
| 180 | { readonly id: string; readonly name: string; readonly models: { [key: string]: Model } } | ||
| 181 | >; | ||
| 182 | readonly connected: ReadonlyArray<string>; | ||
| 183 | } { | ||
| 184 | const providers = new Map< | ||
| 185 | string, | ||
| 186 | { id: string; name: string; models: { [key: string]: Model } } | ||
| 187 | >(); | ||
| 188 | const lines = stdout.split("\n"); | ||
| 189 | let currentSlug: string | null = null; | ||
| 190 | const jsonLines: Array<string> = []; | ||
| 191 | |||
| 192 | const flushModel = () => { | ||
| 193 | if (currentSlug !== null && jsonLines.length > 0) { | ||
| 194 | const jsonStr = jsonLines.join("\n").trim(); | ||
| 195 | if (jsonStr.length > 0) { | ||
| 196 | try { | ||
| 197 | const model = JSON.parse(jsonStr) as Model; | ||
| 198 | const separator = currentSlug.indexOf("/"); | ||
| 199 | if (separator > 0) { | ||
| 200 | const providerID = currentSlug.slice(0, separator); | ||
| 201 | const modelID = currentSlug.slice(separator + 1); | ||
| 202 | let provider = providers.get(providerID); | ||
| 203 | if (!provider) { | ||
| 204 | provider = { id: providerID, name: providerID, models: {} }; | ||
| 205 | providers.set(providerID, provider); | ||
| 206 | } | ||
| 207 | provider.models[modelID] = model; | ||
| 208 | } | ||
| 209 | } catch { | ||
| 210 | // Skip unparseable model JSON | ||
| 211 | } | ||
| 212 | } | ||
| 213 | } | ||
| 214 | currentSlug = null; | ||
| 215 | jsonLines.length = 0; | ||
| 216 | }; | ||
| 217 | |||
| 218 | for (const line of lines) { | ||
| 219 | const slugMatch = SLUG_LINE_RE.exec(line); | ||
| 220 | if (slugMatch) { | ||
| 221 | flushModel(); | ||
| 222 | currentSlug = slugMatch[1]!; | ||
| 223 | } else if (currentSlug !== null) { | ||
| 224 | jsonLines.push(line); | ||
| 225 | } | ||
| 226 | } | ||
| 227 | flushModel(); | ||
| 228 | |||
| 229 | return { providers, connected: [...providers.keys()] }; | ||
| 230 | } | ||
| 231 | |||
| 232 | /** @internal */ | ||
| 233 | export function parseAgentListCliOutput(stdout: string): ReadonlyArray<Agent> { | ||
| 234 | const agents: Array<Agent> = []; | ||
| 235 | const lines = stdout.split("\n"); | ||
| 236 | let currentHeader: { name: string; mode: string } | null = null; | ||
| 237 | const blockLines: Array<string> = []; | ||
| 238 | |||
| 239 | const flushAgent = () => { | ||
| 240 | if (currentHeader !== null) { | ||
| 241 | const jsonStr = blockLines.join("\n").trim(); | ||
| 242 | if (jsonStr.length > 0) { | ||
| 243 | try { | ||
| 244 | const permission = JSON.parse(jsonStr); | ||
| 245 | agents.push({ | ||
| 246 | name: currentHeader.name, | ||
| 247 | mode: currentHeader.mode as Agent["mode"], | ||
| 248 | hidden: KNOWN_HIDDEN_AGENTS.has(currentHeader.name), | ||
| 249 | permission, | ||
| 250 | options: {}, | ||
| 251 | }); | ||
| 252 | } catch { | ||
| 253 | // Skip unparseable agent | ||
| 254 | } | ||
| 255 | } | ||
| 256 | } | ||
| 257 | currentHeader = null; | ||
| 258 | blockLines.length = 0; | ||
| 259 | }; | ||
| 260 | |||
| 261 | for (const line of lines) { | ||
| 262 | const match = AGENT_HEADER_RE.exec(line); | ||
| 263 | if (match) { | ||
| 264 | flushAgent(); | ||
| 265 | currentHeader = { name: match[1]!, mode: match[2]! }; | ||
| 266 | } else if (currentHeader !== null) { | ||
| 267 | blockLines.push(line); | ||
| 268 | } | ||
| 269 | } | ||
| 270 | flushAgent(); | ||
| 271 | |||
| 272 | return agents; | ||
| 273 | } | ||
| 274 | |||
| 163 | export function parseOpenCodeModelSlug( | 275 | export function parseOpenCodeModelSlug( |
| 164 | slug: string | null | undefined, | 276 | slug: string | null | undefined, |
| 165 | ): ParsedOpenCodeModelSlug | null { | 277 | ): ParsedOpenCodeModelSlug | null { |
| ... | @@ -542,12 +654,76 @@ const makeOpenCodeRuntime = Effect.gen(function* () { | ... | @@ -542,12 +654,76 @@ const makeOpenCodeRuntime = Effect.gen(function* () { |
| 542 | Effect.map(([providerList, agents]) => ({ providerList, agents })), | 654 | Effect.map(([providerList, agents]) => ({ providerList, agents })), |
| 543 | ); | 655 | ); |
| 544 | 656 | ||
| 657 | const loadInventoryFromCli: OpenCodeRuntimeShape["loadInventoryFromCli"] = (input) => | ||
| 658 | Effect.gen(function* () { | ||
| 659 | const env = input.environment !== undefined ? { environment: input.environment } : ({} as {}); | ||
| 660 | |||
| 661 | const runModelsCli = () => | ||
| 662 | runOpenCodeCommand({ | ||
| 663 | binaryPath: input.binaryPath, | ||
| 664 | args: ["models", "--verbose"], | ||
| 665 | ...env, | ||
| 666 | }).pipe(Effect.exit); | ||
| 667 | const runAgentsCli = () => | ||
| 668 | runOpenCodeCommand({ binaryPath: input.binaryPath, args: ["agent", "list"], ...env }).pipe( | ||
| 669 | Effect.exit, | ||
| 670 | ); | ||
| 671 | |||
| 672 | // First attempt — run both in parallel | ||
| 673 | let [modelsResult, agentsResult] = yield* Effect.all([runModelsCli(), runAgentsCli()], { | ||
| 674 | concurrency: "unbounded", | ||
| 675 | }); | ||
| 676 | |||
| 677 | // Retry once after 1s on transient failures (e.g. SQLite "database is locked") | ||
| 678 | const needsModelsRetry = modelsResult._tag === "Failure" || modelsResult.value.code !== 0; | ||
| 679 | const needsAgentsRetry = agentsResult._tag === "Failure" || agentsResult.value.code !== 0; | ||
| 680 | if (needsModelsRetry || needsAgentsRetry) { | ||
| 681 | yield* Effect.sleep("1 second"); | ||
| 682 | const [m2, a2] = yield* Effect.all( | ||
| 683 | [ | ||
| 684 | needsModelsRetry ? runModelsCli() : Effect.succeed(modelsResult), | ||
| 685 | needsAgentsRetry ? runAgentsCli() : Effect.succeed(agentsResult), | ||
| 686 | ], | ||
| 687 | { concurrency: "unbounded" }, | ||
| 688 | ); | ||
| 689 | modelsResult = m2; | ||
| 690 | agentsResult = a2; | ||
| 691 | } | ||
| 692 | |||
| 693 | // Degrade gracefully on failure — return empty inventory (warning status, not error) | ||
| 694 | let connected: string[] = []; | ||
| 695 | let allProviders: ProviderListResponse["all"] = []; | ||
| 696 | if (modelsResult._tag === "Success" && modelsResult.value.code === 0) { | ||
| 697 | const parsed = parseModelsCliOutput(modelsResult.value.stdout); | ||
| 698 | connected = [...parsed.connected]; | ||
| 699 | allProviders = [...parsed.providers.values()].map((p) => ({ | ||
| 700 | id: p.id, | ||
| 701 | name: p.name, | ||
| 702 | source: "config" as const, | ||
| 703 | env: [], | ||
| 704 | options: {}, | ||
| 705 | models: p.models, | ||
| 706 | })); | ||
| 707 | } | ||
| 708 | |||
| 709 | let agents: ReadonlyArray<Agent> = []; | ||
| 710 | if (agentsResult._tag === "Success" && agentsResult.value.code === 0) { | ||
| 711 | agents = parseAgentListCliOutput(agentsResult.value.stdout); | ||
| 712 | } | ||
| 713 | |||
| 714 | return { | ||
| 715 | providerList: { all: allProviders, default: {}, connected }, | ||
| 716 | agents, | ||
| 717 | }; | ||
| 718 | }); | ||
| 719 | |||
| 545 | return { | 720 | return { |
| 546 | startOpenCodeServerProcess, | 721 | startOpenCodeServerProcess, |
| 547 | connectToOpenCodeServer, | 722 | connectToOpenCodeServer, |
| 548 | runOpenCodeCommand, | 723 | runOpenCodeCommand, |
| 549 | createOpenCodeSdkClient, | 724 | createOpenCodeSdkClient, |
| 550 | loadOpenCodeInventory, | 725 | loadOpenCodeInventory, |
| 726 | loadInventoryFromCli, | ||
| 551 | } satisfies OpenCodeRuntimeShape; | 727 | } satisfies OpenCodeRuntimeShape; |
| 552 | }); | 728 | }); |
| 553 | 729 |
apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts+8| ... | @@ -107,6 +107,14 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = { | ... | @@ -107,6 +107,14 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = { |
| 107 | cause: null, | 107 | cause: null, |
| 108 | }), | 108 | }), |
| 109 | ), | 109 | ), |
| 110 | loadInventoryFromCli: () => | ||
| 111 | Effect.fail( | ||
| 112 | new OpenCodeRuntime.OpenCodeRuntimeError({ | ||
| 113 | operation: "loadInventoryFromCli", | ||
| 114 | detail: "OpenCodeRuntimeTestDouble.loadInventoryFromCli not used in this test", | ||
| 115 | cause: null, | ||
| 116 | }), | ||
| 117 | ), | ||
| 110 | }; | 118 | }; |
| 111 | 119 | ||
| 112 | const DEFAULT_TEST_MODEL_SELECTION = { | 120 | const DEFAULT_TEST_MODEL_SELECTION = { |