| author | |
| committer | |
| log | cf251c3bd00ff0e0cc90a78b20c043cf58f330ad |
| tree | 1b7bd2b53b7594693dd3f5b73ad2c99a33d2e0a1 |
| parent | 4347f14b89ed66777ae16def278fd44575ab499b |
| signature | Signed by PGP key B5690EEEBB952194 |
5 files changed, 253 insertions(+), 19 deletions(-)
apps/server/src/provider/Layers/OpenCodeProvider.test.ts+80-3| ... | @@ -34,20 +34,24 @@ const runtimeMock = { | ... | @@ -34,20 +34,24 @@ const runtimeMock = { |
| 34 | runVersionError: null as Error | null, | 34 | runVersionError: null as Error | null, |
| 35 | versionStdout: DEFAULT_VERSION_STDOUT, | 35 | versionStdout: DEFAULT_VERSION_STDOUT, |
| 36 | inventoryError: null as Error | null, | 36 | inventoryError: null as Error | null, |
| 37 | inventoryCwd: null as string | null, | ||
| 37 | closeCalls: 0, | 38 | closeCalls: 0, |
| 38 | inventory: { | 39 | inventory: { |
| 39 | providerList: { connected: [] as string[], all: [] as unknown[], default: {} }, | 40 | providerList: { connected: [] as string[], all: [] as unknown[], default: {} }, |
| 40 | agents: [] as unknown[], | 41 | agents: [] as unknown[], |
| 42 | skills: [] as unknown[], | ||
| 41 | } as unknown, | 43 | } as unknown, |
| 42 | }, | 44 | }, |
| 43 | reset() { | 45 | reset() { |
| 44 | this.state.runVersionError = null; | 46 | this.state.runVersionError = null; |
| 45 | this.state.versionStdout = DEFAULT_VERSION_STDOUT; | 47 | this.state.versionStdout = DEFAULT_VERSION_STDOUT; |
| 46 | this.state.inventoryError = null; | 48 | this.state.inventoryError = null; |
| 49 | this.state.inventoryCwd = null; | ||
| 47 | this.state.closeCalls = 0; | 50 | this.state.closeCalls = 0; |
| 48 | this.state.inventory = { | 51 | this.state.inventory = { |
| 49 | providerList: { connected: [], all: [] as unknown[], default: {} }, | 52 | providerList: { connected: [], all: [] as unknown[], default: {} }, |
| 50 | agents: [] as unknown[], | 53 | agents: [] as unknown[], |
| 54 | skills: [] as unknown[], | ||
| 51 | }; | 55 | }; |
| 52 | }, | 56 | }, |
| 53 | }; | 57 | }; |
| ... | @@ -95,8 +99,9 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { | ... | @@ -95,8 +99,9 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { |
| 95 | }), | 99 | }), |
| 96 | ) | 100 | ) |
| 97 | : Effect.succeed(runtimeMock.state.inventory as OpenCodeInventory), | 101 | : Effect.succeed(runtimeMock.state.inventory as OpenCodeInventory), |
| 98 | loadInventoryFromCli: () => | 102 | loadInventoryFromCli: ({ cwd }) => { |
| 99 | runtimeMock.state.inventoryError | 103 | runtimeMock.state.inventoryCwd = cwd; |
| 104 | return runtimeMock.state.inventoryError | ||
| 100 | ? Effect.fail( | 105 | ? Effect.fail( |
| 101 | new OpenCodeRuntimeError({ | 106 | new OpenCodeRuntimeError({ |
| 102 | operation: "loadInventoryFromCli", | 107 | operation: "loadInventoryFromCli", |
| ... | @@ -104,7 +109,8 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { | ... | @@ -104,7 +109,8 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { |
| 104 | cause: runtimeMock.state.inventoryError, | 109 | cause: runtimeMock.state.inventoryError, |
| 105 | }), | 110 | }), |
| 106 | ) | 111 | ) |
| 107 | : Effect.succeed(runtimeMock.state.inventory as OpenCodeInventory), | 112 | : Effect.succeed(runtimeMock.state.inventory as OpenCodeInventory); |
| 113 | }, | ||
| 108 | }; | 114 | }; |
| 109 | 115 | ||
| 110 | beforeEach(() => { | 116 | beforeEach(() => { |
| ... | @@ -207,11 +213,82 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { | ... | @@ -207,11 +213,82 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { |
| 207 | }), | 213 | }), |
| 208 | ); | 214 | ); |
| 209 | 215 | ||
| 216 | it.effect("includes OpenCode skills in the provider snapshot", () => | ||
| 217 | Effect.gen(function* () { | ||
| 218 | runtimeMock.state.inventory = { | ||
| 219 | providerList: { | ||
| 220 | connected: ["openai"], | ||
| 221 | all: [ | ||
| 222 | { | ||
| 223 | id: "openai", | ||
| 224 | name: "OpenAI", | ||
| 225 | models: { | ||
| 226 | "gpt-5.4": { | ||
| 227 | id: "gpt-5.4", | ||
| 228 | name: "GPT-5.4", | ||
| 229 | variants: {}, | ||
| 230 | }, | ||
| 231 | }, | ||
| 232 | }, | ||
| 233 | ], | ||
| 234 | default: {}, | ||
| 235 | }, | ||
| 236 | agents: [], | ||
| 237 | skills: [ | ||
| 238 | { | ||
| 239 | name: "openclaw-review", | ||
| 240 | description: "Review OpenClaw workflow changes.", | ||
| 241 | location: "/Users/test/.agents/skills/openclaw-review/SKILL.md", | ||
| 242 | content: "---\nname: openclaw-review\n---\n", | ||
| 243 | }, | ||
| 244 | { | ||
| 245 | name: "openclaw-triage", | ||
| 246 | description: "Triage OpenClaw routing issues.", | ||
| 247 | location: "/Users/test/.agents/skills/openclaw-triage/SKILL.md", | ||
| 248 | content: "---\nname: openclaw-triage\n---\n", | ||
| 249 | }, | ||
| 250 | { | ||
| 251 | name: "missing-location", | ||
| 252 | description: "This incomplete SDK row should be skipped.", | ||
| 253 | location: "", | ||
| 254 | content: "---\nname: missing-location\n---\n", | ||
| 255 | }, | ||
| 256 | ], | ||
| 257 | }; | ||
| 258 | |||
| 259 | const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); | ||
| 260 | |||
| 261 | NodeAssert.deepEqual( | ||
| 262 | snapshot.skills.map((skill) => ({ | ||
| 263 | name: skill.name, | ||
| 264 | path: skill.path, | ||
| 265 | enabled: skill.enabled, | ||
| 266 | shortDescription: skill.shortDescription, | ||
| 267 | })), | ||
| 268 | [ | ||
| 269 | { | ||
| 270 | name: "openclaw-review", | ||
| 271 | path: "/Users/test/.agents/skills/openclaw-review/SKILL.md", | ||
| 272 | enabled: true, | ||
| 273 | shortDescription: "Review OpenClaw workflow changes.", | ||
| 274 | }, | ||
| 275 | { | ||
| 276 | name: "openclaw-triage", | ||
| 277 | path: "/Users/test/.agents/skills/openclaw-triage/SKILL.md", | ||
| 278 | enabled: true, | ||
| 279 | shortDescription: "Triage OpenClaw routing issues.", | ||
| 280 | }, | ||
| 281 | ], | ||
| 282 | ); | ||
| 283 | }), | ||
| 284 | ); | ||
| 285 | |||
| 210 | it.effect("does not spawn a local server for health check (uses CLI instead)", () => | 286 | it.effect("does not spawn a local server for health check (uses CLI instead)", () => |
| 211 | Effect.gen(function* () { | 287 | Effect.gen(function* () { |
| 212 | yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); | 288 | yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); |
| 213 | 289 | ||
| 214 | NodeAssert.equal(runtimeMock.state.closeCalls, 0); | 290 | NodeAssert.equal(runtimeMock.state.closeCalls, 0); |
| 291 | NodeAssert.equal(runtimeMock.state.inventoryCwd, process.cwd()); | ||
| 215 | }), | 292 | }), |
| 216 | ); | 293 | ); |
| 217 | 294 |
apps/server/src/provider/Layers/OpenCodeProvider.ts+30| ... | @@ -2,6 +2,7 @@ import { | ... | @@ -2,6 +2,7 @@ import { |
| 2 | type ModelCapabilities, | 2 | type ModelCapabilities, |
| 3 | type OpenCodeSettings, | 3 | type OpenCodeSettings, |
| 4 | type ServerProviderModel, | 4 | type ServerProviderModel, |
| 5 | type ServerProviderSkill, | ||
| 5 | } from "@t3tools/contracts"; | 6 | } from "@t3tools/contracts"; |
| 6 | import * as Cause from "effect/Cause"; | 7 | import * as Cause from "effect/Cause"; |
| 7 | import * as Data from "effect/Data"; | 8 | import * as Data from "effect/Data"; |
| ... | @@ -250,6 +251,32 @@ function flattenOpenCodeModels(input: OpenCodeInventory): ReadonlyArray<ServerPr | ... | @@ -250,6 +251,32 @@ function flattenOpenCodeModels(input: OpenCodeInventory): ReadonlyArray<ServerPr |
| 250 | return models.toSorted((left, right) => left.name.localeCompare(right.name)); | 251 | return models.toSorted((left, right) => left.name.localeCompare(right.name)); |
| 251 | } | 252 | } |
| 252 | 253 | ||
| 254 | function trimOptional(value: string | null | undefined): string | undefined { | ||
| 255 | const trimmed = value?.trim(); | ||
| 256 | return trimmed && trimmed.length > 0 ? trimmed : undefined; | ||
| 257 | } | ||
| 258 | |||
| 259 | function flattenOpenCodeSkills(input: OpenCodeInventory): ReadonlyArray<ServerProviderSkill> { | ||
| 260 | const skills: ServerProviderSkill[] = []; | ||
| 261 | for (const skill of input.skills ?? []) { | ||
| 262 | const name = trimOptional(skill.name); | ||
| 263 | const path = trimOptional(skill.location); | ||
| 264 | if (!name || !path) { | ||
| 265 | continue; | ||
| 266 | } | ||
| 267 | |||
| 268 | const description = trimOptional(skill.description); | ||
| 269 | skills.push({ | ||
| 270 | name, | ||
| 271 | path, | ||
| 272 | enabled: true, | ||
| 273 | ...(description ? { description, shortDescription: description } : {}), | ||
| 274 | }); | ||
| 275 | } | ||
| 276 | |||
| 277 | return skills.toSorted((left, right) => left.name.localeCompare(right.name)); | ||
| 278 | } | ||
| 279 | |||
| 253 | export const makePendingOpenCodeProvider = ( | 280 | export const makePendingOpenCodeProvider = ( |
| 254 | openCodeSettings: OpenCodeSettings, | 281 | openCodeSettings: OpenCodeSettings, |
| 255 | ): Effect.Effect<ServerProviderDraft> => | 282 | ): Effect.Effect<ServerProviderDraft> => |
| ... | @@ -412,6 +439,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu | ... | @@ -412,6 +439,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu |
| 412 | ) | 439 | ) |
| 413 | : openCodeRuntime.loadInventoryFromCli({ | 440 | : openCodeRuntime.loadInventoryFromCli({ |
| 414 | binaryPath: openCodeSettings.binaryPath, | 441 | binaryPath: openCodeSettings.binaryPath, |
| 442 | cwd, | ||
| 415 | environment: resolvedEnvironment, | 443 | environment: resolvedEnvironment, |
| 416 | }) | 444 | }) |
| 417 | ).pipe( | 445 | ).pipe( |
| ... | @@ -429,12 +457,14 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu | ... | @@ -429,12 +457,14 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu |
| 429 | customModels, | 457 | customModels, |
| 430 | DEFAULT_OPENCODE_MODEL_CAPABILITIES, | 458 | DEFAULT_OPENCODE_MODEL_CAPABILITIES, |
| 431 | ); | 459 | ); |
| 460 | const skills = flattenOpenCodeSkills(inventoryExit.value); | ||
| 432 | const connectedCount = inventoryExit.value.providerList.connected.length; | 461 | const connectedCount = inventoryExit.value.providerList.connected.length; |
| 433 | return buildServerProvider({ | 462 | return buildServerProvider({ |
| 434 | presentation: OPENCODE_PRESENTATION, | 463 | presentation: OPENCODE_PRESENTATION, |
| 435 | enabled: true, | 464 | enabled: true, |
| 436 | checkedAt, | 465 | checkedAt, |
| 437 | models, | 466 | models, |
| 467 | skills, | ||
| 438 | probe: { | 468 | probe: { |
| 439 | installed: true, | 469 | installed: true, |
| 440 | version, | 470 | version, |
apps/server/src/provider/opencodeRuntime.cliParsers.test.ts+33-1| ... | @@ -2,7 +2,11 @@ import * as NodeAssert from "node:assert/strict"; | ... | @@ -2,7 +2,11 @@ import * as NodeAssert from "node:assert/strict"; |
| 2 | 2 | ||
| 3 | import { describe, it } from "vite-plus/test"; | 3 | import { describe, it } from "vite-plus/test"; |
| 4 | 4 | ||
| 5 | import { parseModelsCliOutput, parseAgentListCliOutput } from "./opencodeRuntime.ts"; | 5 | import { |
| 6 | parseAgentListCliOutput, | ||
| 7 | parseModelsCliOutput, | ||
| 8 | parseSkillsCliOutput, | ||
| 9 | } from "./opencodeRuntime.ts"; | ||
| 6 | 10 | ||
| 7 | describe("parseModelsCliOutput", () => { | 11 | describe("parseModelsCliOutput", () => { |
| 8 | it("parses a single model from a single provider", () => { | 12 | it("parses a single model from a single provider", () => { |
| ... | @@ -252,3 +256,31 @@ describe("parseAgentListCliOutput", () => { | ... | @@ -252,3 +256,31 @@ describe("parseAgentListCliOutput", () => { |
| 252 | NodeAssert.equal(result[1]!.hidden, false); | 256 | NodeAssert.equal(result[1]!.hidden, false); |
| 253 | }); | 257 | }); |
| 254 | }); | 258 | }); |
| 259 | |||
| 260 | describe("parseSkillsCliOutput", () => { | ||
| 261 | it("parses skill metadata from the CLI JSON output", () => { | ||
| 262 | const result = parseSkillsCliOutput( | ||
| 263 | JSON.stringify([ | ||
| 264 | { | ||
| 265 | name: "review-pr", | ||
| 266 | description: "Review a pull request.", | ||
| 267 | location: "/tmp/review-pr/SKILL.md", | ||
| 268 | content: "---\nname: review-pr\n---\n", | ||
| 269 | }, | ||
| 270 | ]), | ||
| 271 | ); | ||
| 272 | |||
| 273 | NodeAssert.deepEqual(result, [ | ||
| 274 | { | ||
| 275 | name: "review-pr", | ||
| 276 | description: "Review a pull request.", | ||
| 277 | location: "/tmp/review-pr/SKILL.md", | ||
| 278 | content: "---\nname: review-pr\n---\n", | ||
| 279 | }, | ||
| 280 | ]); | ||
| 281 | }); | ||
| 282 | |||
| 283 | it("degrades malformed output to an empty skill list", () => { | ||
| 284 | NodeAssert.deepEqual(parseSkillsCliOutput("not json"), []); | ||
| 285 | }); | ||
| 286 | }); |
apps/server/src/provider/opencodeRuntime.inventory.test.ts created+41| ... | @@ -0,0 +1,41 @@ | ||
| 1 | import * as NodeAssert from "node:assert/strict"; | ||
| 2 | |||
| 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 4 | import type { OpencodeClient } from "@opencode-ai/sdk/v2"; | ||
| 5 | import { it } from "@effect/vitest"; | ||
| 6 | import * as Effect from "effect/Effect"; | ||
| 7 | import * as Layer from "effect/Layer"; | ||
| 8 | |||
| 9 | import { OpenCodeRuntime, OpenCodeRuntimeLive } from "./opencodeRuntime.ts"; | ||
| 10 | |||
| 11 | const testLayer = OpenCodeRuntimeLive.pipe(Layer.provideMerge(NodeServices.layer)); | ||
| 12 | |||
| 13 | it.layer(testLayer)("loadOpenCodeInventory", (it) => { | ||
| 14 | it.effect("keeps provider inventory when skill discovery fails", () => | ||
| 15 | Effect.gen(function* () { | ||
| 16 | const runtime = yield* OpenCodeRuntime; | ||
| 17 | const client = { | ||
| 18 | provider: { | ||
| 19 | list: () => | ||
| 20 | Promise.resolve({ | ||
| 21 | data: { | ||
| 22 | connected: ["openai"], | ||
| 23 | all: [], | ||
| 24 | default: {}, | ||
| 25 | }, | ||
| 26 | }), | ||
| 27 | }, | ||
| 28 | app: { | ||
| 29 | agents: () => Promise.resolve({ data: [] }), | ||
| 30 | skills: () => Promise.reject(new Error("skills endpoint unavailable")), | ||
| 31 | }, | ||
| 32 | } as unknown as OpencodeClient; | ||
| 33 | |||
| 34 | const inventory = yield* runtime.loadOpenCodeInventory(client); | ||
| 35 | |||
| 36 | NodeAssert.deepEqual(inventory.providerList.connected, ["openai"]); | ||
| 37 | NodeAssert.deepEqual(inventory.agents, []); | ||
| 38 | NodeAssert.deepEqual(inventory.skills, []); | ||
| 39 | }), | ||
| 40 | ); | ||
| 41 | }); | ||
apps/server/src/provider/opencodeRuntime.ts+69-15| ... | @@ -112,6 +112,7 @@ export interface OpenCodeCommandResult { | ... | @@ -112,6 +112,7 @@ export interface OpenCodeCommandResult { |
| 112 | export interface OpenCodeInventory { | 112 | export interface OpenCodeInventory { |
| 113 | readonly providerList: ProviderListResponse; | 113 | readonly providerList: ProviderListResponse; |
| 114 | readonly agents: ReadonlyArray<Agent>; | 114 | readonly agents: ReadonlyArray<Agent>; |
| 115 | readonly skills: ReadonlyArray<OpenCodeSkill>; | ||
| 115 | } | 116 | } |
| 116 | 117 | ||
| 117 | export interface ParsedOpenCodeModelSlug { | 118 | export interface ParsedOpenCodeModelSlug { |
| ... | @@ -119,6 +120,23 @@ export interface ParsedOpenCodeModelSlug { | ... | @@ -119,6 +120,23 @@ export interface ParsedOpenCodeModelSlug { |
| 119 | readonly modelID: string; | 120 | readonly modelID: string; |
| 120 | } | 121 | } |
| 121 | 122 | ||
| 123 | export interface OpenCodeSkill { | ||
| 124 | readonly name?: string | null; | ||
| 125 | readonly description?: string | null; | ||
| 126 | readonly location?: string | null; | ||
| 127 | readonly content?: string | null; | ||
| 128 | } | ||
| 129 | |||
| 130 | const OpenCodeSkillSchema = Schema.Struct({ | ||
| 131 | name: Schema.optionalKey(Schema.NullOr(Schema.String)), | ||
| 132 | description: Schema.optionalKey(Schema.NullOr(Schema.String)), | ||
| 133 | location: Schema.optionalKey(Schema.NullOr(Schema.String)), | ||
| 134 | content: Schema.optionalKey(Schema.NullOr(Schema.String)), | ||
| 135 | }); | ||
| 136 | const decodeOpenCodeSkillsCliOutputExit = Schema.decodeUnknownExit( | ||
| 137 | Schema.fromJsonString(Schema.Array(OpenCodeSkillSchema)), | ||
| 138 | ); | ||
| 139 | |||
| 122 | export interface OpenCodeRuntimeShape { | 140 | export interface OpenCodeRuntimeShape { |
| 123 | /** | 141 | /** |
| 124 | * Spawns a local OpenCode server process. Its lifetime is bound to the caller's | 142 | * Spawns a local OpenCode server process. Its lifetime is bound to the caller's |
| ... | @@ -150,6 +168,7 @@ export interface OpenCodeRuntimeShape { | ... | @@ -150,6 +168,7 @@ export interface OpenCodeRuntimeShape { |
| 150 | readonly binaryPath: string; | 168 | readonly binaryPath: string; |
| 151 | readonly args: ReadonlyArray<string>; | 169 | readonly args: ReadonlyArray<string>; |
| 152 | readonly environment?: NodeJS.ProcessEnv; | 170 | readonly environment?: NodeJS.ProcessEnv; |
| 171 | readonly cwd?: string; | ||
| 153 | }) => Effect.Effect<OpenCodeCommandResult, OpenCodeRuntimeError>; | 172 | }) => Effect.Effect<OpenCodeCommandResult, OpenCodeRuntimeError>; |
| 154 | readonly createOpenCodeSdkClient: (input: { | 173 | readonly createOpenCodeSdkClient: (input: { |
| 155 | readonly baseUrl: string; | 174 | readonly baseUrl: string; |
| ... | @@ -161,6 +180,7 @@ export interface OpenCodeRuntimeShape { | ... | @@ -161,6 +180,7 @@ export interface OpenCodeRuntimeShape { |
| 161 | ) => Effect.Effect<OpenCodeInventory, OpenCodeRuntimeError>; | 180 | ) => Effect.Effect<OpenCodeInventory, OpenCodeRuntimeError>; |
| 162 | readonly loadInventoryFromCli: (input: { | 181 | readonly loadInventoryFromCli: (input: { |
| 163 | readonly binaryPath: string; | 182 | readonly binaryPath: string; |
| 183 | readonly cwd: string; | ||
| 164 | readonly environment?: NodeJS.ProcessEnv; | 184 | readonly environment?: NodeJS.ProcessEnv; |
| 165 | }) => Effect.Effect<OpenCodeInventory, OpenCodeRuntimeError>; | 185 | }) => Effect.Effect<OpenCodeInventory, OpenCodeRuntimeError>; |
| 166 | } | 186 | } |
| ... | @@ -289,6 +309,12 @@ export function parseAgentListCliOutput(stdout: string): ReadonlyArray<Agent> { | ... | @@ -289,6 +309,12 @@ export function parseAgentListCliOutput(stdout: string): ReadonlyArray<Agent> { |
| 289 | return agents; | 309 | return agents; |
| 290 | } | 310 | } |
| 291 | 311 | ||
| 312 | /** @internal */ | ||
| 313 | export function parseSkillsCliOutput(stdout: string): ReadonlyArray<OpenCodeSkill> { | ||
| 314 | const result = decodeOpenCodeSkillsCliOutputExit(stdout); | ||
| 315 | return Exit.isSuccess(result) ? result.value : []; | ||
| 316 | } | ||
| 317 | |||
| 292 | export function parseOpenCodeModelSlug( | 318 | export function parseOpenCodeModelSlug( |
| 293 | slug: string | null | undefined, | 319 | slug: string | null | undefined, |
| 294 | ): ParsedOpenCodeModelSlug | null { | 320 | ): ParsedOpenCodeModelSlug | null { |
| ... | @@ -417,6 +443,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { | ... | @@ -417,6 +443,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { |
| 417 | const child = yield* spawner.spawn( | 443 | const child = yield* spawner.spawn( |
| 418 | ChildProcess.make(spawnCommand.command, spawnCommand.args, { | 444 | ChildProcess.make(spawnCommand.command, spawnCommand.args, { |
| 419 | shell: spawnCommand.shell, | 445 | shell: spawnCommand.shell, |
| 446 | ...(input.cwd ? { cwd: input.cwd } : {}), | ||
| 420 | ...(input.environment ? { env: input.environment } : { extendEnv: true }), | 447 | ...(input.environment ? { env: input.environment } : { extendEnv: true }), |
| 421 | }), | 448 | }), |
| 422 | ); | 449 | ); |
| ... | @@ -673,45 +700,67 @@ const makeOpenCodeRuntime = Effect.gen(function* () { | ... | @@ -673,45 +700,67 @@ const makeOpenCodeRuntime = Effect.gen(function* () { |
| 673 | Effect.map((result) => result.data ?? []), | 700 | Effect.map((result) => result.data ?? []), |
| 674 | ); | 701 | ); |
| 675 | 702 | ||
| 676 | const loadOpenCodeInventory: OpenCodeRuntimeShape["loadOpenCodeInventory"] = (client) => | 703 | const loadSkills = (client: OpencodeClient) => |
| 677 | Effect.all([loadProviders(client), loadAgents(client)], { concurrency: "unbounded" }).pipe( | 704 | runOpenCodeSdk("app.skills", () => client.app.skills()).pipe( |
| 678 | Effect.map(([providerList, agents]) => ({ providerList, agents })), | 705 | Effect.map((result) => (result.data ?? []) as ReadonlyArray<OpenCodeSkill>), |
| 706 | Effect.orElseSucceed((): ReadonlyArray<OpenCodeSkill> => []), | ||
| 679 | ); | 707 | ); |
| 680 | 708 | ||
| 709 | const loadOpenCodeInventory: OpenCodeRuntimeShape["loadOpenCodeInventory"] = (client) => | ||
| 710 | Effect.all([loadProviders(client), loadAgents(client), loadSkills(client)], { | ||
| 711 | concurrency: "unbounded", | ||
| 712 | }).pipe(Effect.map(([providerList, agents, skills]) => ({ providerList, agents, skills }))); | ||
| 713 | |||
| 681 | const loadInventoryFromCli: OpenCodeRuntimeShape["loadInventoryFromCli"] = (input) => | 714 | const loadInventoryFromCli: OpenCodeRuntimeShape["loadInventoryFromCli"] = (input) => |
| 682 | Effect.gen(function* () { | 715 | Effect.gen(function* () { |
| 683 | const env = input.environment !== undefined ? { environment: input.environment } : ({} as {}); | 716 | const env = input.environment !== undefined ? { environment: input.environment } : ({} as {}); |
| 717 | const commandContext = { cwd: input.cwd, ...env }; | ||
| 684 | 718 | ||
| 685 | const runModelsCli = () => | 719 | const runModelsCli = () => |
| 686 | runOpenCodeCommand({ | 720 | runOpenCodeCommand({ |
| 687 | binaryPath: input.binaryPath, | 721 | binaryPath: input.binaryPath, |
| 688 | args: ["models", "--verbose"], | 722 | args: ["models", "--verbose"], |
| 689 | ...env, | 723 | ...commandContext, |
| 690 | }).pipe(Effect.exit); | 724 | }).pipe(Effect.exit); |
| 691 | const runAgentsCli = () => | 725 | const runAgentsCli = () => |
| 692 | runOpenCodeCommand({ binaryPath: input.binaryPath, args: ["agent", "list"], ...env }).pipe( | 726 | runOpenCodeCommand({ |
| 693 | Effect.exit, | 727 | binaryPath: input.binaryPath, |
| 694 | ); | 728 | args: ["agent", "list"], |
| 729 | ...commandContext, | ||
| 730 | }).pipe(Effect.exit); | ||
| 731 | const runSkillsCli = () => | ||
| 732 | runOpenCodeCommand({ | ||
| 733 | binaryPath: input.binaryPath, | ||
| 734 | args: ["debug", "skill"], | ||
| 735 | ...commandContext, | ||
| 736 | }).pipe(Effect.exit); | ||
| 695 | 737 | ||
| 696 | // First attempt — run both in parallel | 738 | // First attempt — run all inventory commands in parallel. |
| 697 | let [modelsResult, agentsResult] = yield* Effect.all([runModelsCli(), runAgentsCli()], { | 739 | const [initialModelsResult, initialAgentsResult, initialSkillsResult] = yield* Effect.all( |
| 698 | concurrency: "unbounded", | 740 | [runModelsCli(), runAgentsCli(), runSkillsCli()], |
| 699 | }); | 741 | { concurrency: "unbounded" }, |
| 742 | ); | ||
| 743 | let modelsResult = initialModelsResult; | ||
| 744 | let agentsResult = initialAgentsResult; | ||
| 745 | let skillsResult = initialSkillsResult; | ||
| 700 | 746 | ||
| 701 | // Retry once after 1s on transient failures (e.g. SQLite "database is locked") | 747 | // Retry once after 1s on transient failures (e.g. SQLite "database is locked") |
| 702 | const needsModelsRetry = modelsResult._tag === "Failure" || modelsResult.value.code !== 0; | 748 | const needsModelsRetry = modelsResult._tag === "Failure" || modelsResult.value.code !== 0; |
| 703 | const needsAgentsRetry = agentsResult._tag === "Failure" || agentsResult.value.code !== 0; | 749 | const needsAgentsRetry = agentsResult._tag === "Failure" || agentsResult.value.code !== 0; |
| 704 | if (needsModelsRetry || needsAgentsRetry) { | 750 | const needsSkillsRetry = skillsResult._tag === "Failure" || skillsResult.value.code !== 0; |
| 751 | if (needsModelsRetry || needsAgentsRetry || needsSkillsRetry) { | ||
| 705 | yield* Effect.sleep("1 second"); | 752 | yield* Effect.sleep("1 second"); |
| 706 | const [m2, a2] = yield* Effect.all( | 753 | const [m2, a2, s2] = yield* Effect.all( |
| 707 | [ | 754 | [ |
| 708 | needsModelsRetry ? runModelsCli() : Effect.succeed(modelsResult), | 755 | needsModelsRetry ? runModelsCli() : Effect.succeed(modelsResult), |
| 709 | needsAgentsRetry ? runAgentsCli() : Effect.succeed(agentsResult), | 756 | needsAgentsRetry ? runAgentsCli() : Effect.succeed(agentsResult), |
| 757 | needsSkillsRetry ? runSkillsCli() : Effect.succeed(skillsResult), | ||
| 710 | ], | 758 | ], |
| 711 | { concurrency: "unbounded" }, | 759 | { concurrency: "unbounded" }, |
| 712 | ); | 760 | ); |
| 713 | modelsResult = m2; | 761 | modelsResult = m2; |
| 714 | agentsResult = a2; | 762 | agentsResult = a2; |
| 763 | skillsResult = s2; | ||
| 715 | } | 764 | } |
| 716 | 765 | ||
| 717 | if (modelsResult._tag === "Failure") { | 766 | if (modelsResult._tag === "Failure") { |
| ... | @@ -742,16 +791,21 @@ const makeOpenCodeRuntime = Effect.gen(function* () { | ... | @@ -742,16 +791,21 @@ const makeOpenCodeRuntime = Effect.gen(function* () { |
| 742 | }), | 791 | }), |
| 743 | ); | 792 | ); |
| 744 | 793 | ||
| 745 | // Agent metadata enriches model capabilities but is not required for an | 794 | // Agent and skill metadata enrich the provider snapshot but are not required |
| 746 | // authoritative model inventory, so it may still degrade to an empty list. | 795 | // for an authoritative model inventory, so either may degrade to an empty list. |
| 747 | let agents: ReadonlyArray<Agent> = []; | 796 | let agents: ReadonlyArray<Agent> = []; |
| 748 | if (agentsResult._tag === "Success" && agentsResult.value.code === 0) { | 797 | if (agentsResult._tag === "Success" && agentsResult.value.code === 0) { |
| 749 | agents = parseAgentListCliOutput(agentsResult.value.stdout); | 798 | agents = parseAgentListCliOutput(agentsResult.value.stdout); |
| 750 | } | 799 | } |
| 800 | let skills: ReadonlyArray<OpenCodeSkill> = []; | ||
| 801 | if (skillsResult._tag === "Success" && skillsResult.value.code === 0) { | ||
| 802 | skills = parseSkillsCliOutput(skillsResult.value.stdout); | ||
| 803 | } | ||
| 751 | 804 | ||
| 752 | return { | 805 | return { |
| 753 | providerList: { all: allProviders, default: {}, connected }, | 806 | providerList: { all: allProviders, default: {}, connected }, |
| 754 | agents, | 807 | agents, |
| 808 | skills, | ||
| 755 | }; | 809 | }; |
| 756 | }); | 810 | }); |
| 757 | 811 |