| author | |
| committer | |
| log | 58ba5594439c984b08d13b6d4efb5407adb06943 |
| tree | dfc2a08eb7a8457137a53af23a2e36e6e7f546e3 |
| parent | e9f50c3efcb02a199042364ead292e164274e716 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Simone <185146821+Lucenx9@users.noreply.github.com>5 files changed, 145 insertions(+), 11 deletions(-)
apps/server/src/provider/Layers/OpenCodeProvider.test.ts-3| ... | ... | @@ -239,19 +239,16 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { |
| 239 | 239 | name: "openclaw-review", |
| 240 | 240 | description: "Review OpenClaw workflow changes.", |
| 241 | 241 | location: "/Users/test/.agents/skills/openclaw-review/SKILL.md", |
| 242 | content: "---\nname: openclaw-review\n---\n", | |
| 243 | 242 | }, |
| 244 | 243 | { |
| 245 | 244 | name: "openclaw-triage", |
| 246 | 245 | description: "Triage OpenClaw routing issues.", |
| 247 | 246 | location: "/Users/test/.agents/skills/openclaw-triage/SKILL.md", |
| 248 | content: "---\nname: openclaw-triage\n---\n", | |
| 249 | 247 | }, |
| 250 | 248 | { |
| 251 | 249 | name: "missing-location", |
| 252 | 250 | description: "This incomplete SDK row should be skipped.", |
| 253 | 251 | location: "", |
| 254 | content: "---\nname: missing-location\n---\n", | |
| 255 | 252 | }, |
| 256 | 253 | ], |
| 257 | 254 | }; |
apps/server/src/provider/opencodeRuntime.cliParsers.test.ts+1-2| ... | ... | @@ -258,7 +258,7 @@ describe("parseAgentListCliOutput", () => { |
| 258 | 258 | }); |
| 259 | 259 | |
| 260 | 260 | describe("parseSkillsCliOutput", () => { |
| 261 | it("parses skill metadata from the CLI JSON output", () => { | |
| 261 | it("parses only skill metadata from the CLI JSON output", () => { | |
| 262 | 262 | const result = parseSkillsCliOutput( |
| 263 | 263 | JSON.stringify([ |
| 264 | 264 | { |
| ... | ... | @@ -275,7 +275,6 @@ describe("parseSkillsCliOutput", () => { |
| 275 | 275 | name: "review-pr", |
| 276 | 276 | description: "Review a pull request.", |
| 277 | 277 | location: "/tmp/review-pr/SKILL.md", |
| 278 | content: "---\nname: review-pr\n---\n", | |
| 279 | 278 | }, |
| 280 | 279 | ]); |
| 281 | 280 | }); |
apps/server/src/provider/opencodeRuntime.inventory.test.ts+125-1| ... | ... | @@ -4,13 +4,20 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 4 | 4 | import type { OpencodeClient } from "@opencode-ai/sdk/v2"; |
| 5 | 5 | import { it } from "@effect/vitest"; |
| 6 | 6 | import * as Effect from "effect/Effect"; |
| 7 | import * as FileSystem from "effect/FileSystem"; | |
| 7 | 8 | import * as Layer from "effect/Layer"; |
| 9 | import * as Path from "effect/Path"; | |
| 10 | import { | |
| 11 | HostProcessEnvironment, | |
| 12 | HostProcessExecutablePath, | |
| 13 | HostProcessPlatform, | |
| 14 | } from "@t3tools/shared/hostProcess"; | |
| 8 | 15 | |
| 9 | 16 | import { OpenCodeRuntime, OpenCodeRuntimeLive } from "./opencodeRuntime.ts"; |
| 10 | 17 | |
| 11 | 18 | const testLayer = OpenCodeRuntimeLive.pipe(Layer.provideMerge(NodeServices.layer)); |
| 12 | 19 | |
| 13 | it.layer(testLayer)("loadOpenCodeInventory", (it) => { | |
| 20 | it.layer(testLayer)("OpenCodeRuntime inventory", (it) => { | |
| 14 | 21 | it.effect("keeps provider inventory when skill discovery fails", () => |
| 15 | 22 | Effect.gen(function* () { |
| 16 | 23 | const runtime = yield* OpenCodeRuntime; |
| ... | ... | @@ -38,4 +45,121 @@ it.layer(testLayer)("loadOpenCodeInventory", (it) => { |
| 38 | 45 | NodeAssert.deepEqual(inventory.skills, []); |
| 39 | 46 | }), |
| 40 | 47 | ); |
| 48 | ||
| 49 | it.effect("keeps only SDK skill metadata in inventory", () => | |
| 50 | Effect.gen(function* () { | |
| 51 | const runtime = yield* OpenCodeRuntime; | |
| 52 | const client = { | |
| 53 | provider: { | |
| 54 | list: () => | |
| 55 | Promise.resolve({ | |
| 56 | data: { | |
| 57 | connected: ["openai"], | |
| 58 | all: [], | |
| 59 | default: {}, | |
| 60 | }, | |
| 61 | }), | |
| 62 | }, | |
| 63 | app: { | |
| 64 | agents: () => Promise.resolve({ data: [] }), | |
| 65 | skills: () => | |
| 66 | Promise.resolve({ | |
| 67 | data: [ | |
| 68 | { | |
| 69 | name: "review", | |
| 70 | description: "Review code changes", | |
| 71 | location: "/skills/review/SKILL.md", | |
| 72 | content: "unused skill content", | |
| 73 | }, | |
| 74 | ], | |
| 75 | }), | |
| 76 | }, | |
| 77 | } as unknown as OpencodeClient; | |
| 78 | ||
| 79 | const inventory = yield* runtime.loadOpenCodeInventory(client); | |
| 80 | ||
| 81 | NodeAssert.deepEqual(inventory.skills, [ | |
| 82 | { | |
| 83 | name: "review", | |
| 84 | description: "Review code changes", | |
| 85 | location: "/skills/review/SKILL.md", | |
| 86 | }, | |
| 87 | ]); | |
| 88 | }), | |
| 89 | ); | |
| 90 | ||
| 91 | it.effect("drops oversized CLI skill output without losing the model inventory", () => | |
| 92 | Effect.gen(function* () { | |
| 93 | const fs = yield* FileSystem.FileSystem; | |
| 94 | const path = yield* Path.Path; | |
| 95 | const hostEnvironment = yield* HostProcessEnvironment; | |
| 96 | const executablePath = yield* HostProcessExecutablePath; | |
| 97 | const hostPlatform = yield* HostProcessPlatform; | |
| 98 | const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-inventory-" }); | |
| 99 | const isWindows = hostPlatform === "win32"; | |
| 100 | const binaryPath = path.join(tempDir, isWindows ? "opencode.cmd" : "opencode"); | |
| 101 | const scriptPath = path.join(tempDir, "opencode.mjs"); | |
| 102 | const oversizedContentBytes = 8 * 1024 * 1024 + 1; | |
| 103 | ||
| 104 | yield* fs.writeFileString( | |
| 105 | scriptPath, | |
| 106 | [ | |
| 107 | 'if (process.argv[2] === "models") {', | |
| 108 | ' process.stdout.write(`openai/gpt-test\\n{"id":"gpt-test","providerID":"openai","name":"GPT Test"}\\n`);', | |
| 109 | '} else if (process.argv[2] === "debug") {', | |
| 110 | ` const content = "x".repeat(${oversizedContentBytes});`, | |
| 111 | ' process.stdout.write(`[{"name":"oversized","content":"${content}"}]`);', | |
| 112 | "}", | |
| 113 | "", | |
| 114 | ].join("\n"), | |
| 115 | ); | |
| 116 | yield* fs.writeFileString( | |
| 117 | binaryPath, | |
| 118 | [ | |
| 119 | ...(isWindows ? ["@echo off"] : ["#!/bin/sh"]), | |
| 120 | isWindows | |
| 121 | ? '"%T3_TEST_NODE_BINARY%" "%T3_TEST_OPENCODE_SCRIPT%" %*' | |
| 122 | : 'exec "$T3_TEST_NODE_BINARY" "$T3_TEST_OPENCODE_SCRIPT" "$@"', | |
| 123 | "", | |
| 124 | ].join("\n"), | |
| 125 | ); | |
| 126 | if (!isWindows) { | |
| 127 | yield* fs.chmod(binaryPath, 0o755); | |
| 128 | } | |
| 129 | ||
| 130 | const runtime = yield* OpenCodeRuntime; | |
| 131 | const inventory = yield* runtime.loadInventoryFromCli({ | |
| 132 | binaryPath, | |
| 133 | cwd: tempDir, | |
| 134 | environment: { | |
| 135 | ...hostEnvironment, | |
| 136 | T3_TEST_NODE_BINARY: executablePath, | |
| 137 | T3_TEST_OPENCODE_SCRIPT: scriptPath, | |
| 138 | }, | |
| 139 | }); | |
| 140 | ||
| 141 | NodeAssert.deepEqual(inventory.providerList.connected, ["openai"]); | |
| 142 | NodeAssert.equal(inventory.skills.length, 0); | |
| 143 | }), | |
| 144 | ); | |
| 145 | ||
| 146 | it.effect("caps and drains command stdout and stderr when requested", () => | |
| 147 | Effect.gen(function* () { | |
| 148 | const runtime = yield* OpenCodeRuntime; | |
| 149 | const executablePath = yield* HostProcessExecutablePath; | |
| 150 | const outputBytes = 2 * 1024 * 1024; | |
| 151 | const result = yield* runtime.runOpenCodeCommand({ | |
| 152 | binaryPath: executablePath, | |
| 153 | args: [ | |
| 154 | "-e", | |
| 155 | `process.stdout.write("o".repeat(${outputBytes})); process.stderr.write("e".repeat(${outputBytes}));`, | |
| 156 | ], | |
| 157 | maxOutputBytes: 64, | |
| 158 | }); | |
| 159 | ||
| 160 | NodeAssert.equal(result.stdout, "o".repeat(64)); | |
| 161 | NodeAssert.equal(result.stderr, "e".repeat(64)); | |
| 162 | NodeAssert.equal(result.code, 0); | |
| 163 | }), | |
| 164 | ); | |
| 41 | 165 | }); |
apps/server/src/provider/opencodeRuntime.ts+17-4| ... | ... | @@ -51,6 +51,7 @@ export function resolveOpenCodeConfigContent( |
| 51 | 51 | const OPENCODE_SERVER_READY_PREFIX = "opencode server listening"; |
| 52 | 52 | const DEFAULT_OPENCODE_SERVER_TIMEOUT_MS = 30_000; |
| 53 | 53 | const DEFAULT_HOSTNAME = "127.0.0.1"; |
| 54 | const OPENCODE_SKILL_DISCOVERY_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; | |
| 54 | 55 | export interface OpenCodeServerProcess { |
| 55 | 56 | readonly url: string; |
| 56 | 57 | readonly exitCode: Effect.Effect<number, never>; |
| ... | ... | @@ -124,14 +125,12 @@ export interface OpenCodeSkill { |
| 124 | 125 | readonly name?: string | null; |
| 125 | 126 | readonly description?: string | null; |
| 126 | 127 | readonly location?: string | null; |
| 127 | readonly content?: string | null; | |
| 128 | 128 | } |
| 129 | 129 | |
| 130 | 130 | const OpenCodeSkillSchema = Schema.Struct({ |
| 131 | 131 | name: Schema.optionalKey(Schema.NullOr(Schema.String)), |
| 132 | 132 | description: Schema.optionalKey(Schema.NullOr(Schema.String)), |
| 133 | 133 | location: Schema.optionalKey(Schema.NullOr(Schema.String)), |
| 134 | content: Schema.optionalKey(Schema.NullOr(Schema.String)), | |
| 135 | 134 | }); |
| 136 | 135 | const decodeOpenCodeSkillsCliOutputExit = Schema.decodeUnknownExit( |
| 137 | 136 | Schema.fromJsonString(Schema.Array(OpenCodeSkillSchema)), |
| ... | ... | @@ -169,6 +168,7 @@ export interface OpenCodeRuntimeShape { |
| 169 | 168 | readonly args: ReadonlyArray<string>; |
| 170 | 169 | readonly environment?: NodeJS.ProcessEnv; |
| 171 | 170 | readonly cwd?: string; |
| 171 | readonly maxOutputBytes?: number; | |
| 172 | 172 | }) => Effect.Effect<OpenCodeCommandResult, OpenCodeRuntimeError>; |
| 173 | 173 | readonly createOpenCodeSdkClient: (input: { |
| 174 | 174 | readonly baseUrl: string; |
| ... | ... | @@ -447,8 +447,14 @@ const makeOpenCodeRuntime = Effect.gen(function* () { |
| 447 | 447 | ...(input.environment ? { env: input.environment } : { extendEnv: true }), |
| 448 | 448 | }), |
| 449 | 449 | ); |
| 450 | const collectOptions = | |
| 451 | input.maxOutputBytes === undefined ? undefined : { maxBytes: input.maxOutputBytes }; | |
| 450 | 452 | const [stdout, stderr, code] = yield* Effect.all( |
| 451 | [collectStreamAsString(child.stdout), collectStreamAsString(child.stderr), child.exitCode], | |
| 453 | [ | |
| 454 | collectStreamAsString(child.stdout, collectOptions), | |
| 455 | collectStreamAsString(child.stderr, collectOptions), | |
| 456 | child.exitCode, | |
| 457 | ], | |
| 452 | 458 | { concurrency: "unbounded" }, |
| 453 | 459 | ); |
| 454 | 460 | const exitCode = Number(code); |
| ... | ... | @@ -702,7 +708,13 @@ const makeOpenCodeRuntime = Effect.gen(function* () { |
| 702 | 708 | |
| 703 | 709 | const loadSkills = (client: OpencodeClient) => |
| 704 | 710 | runOpenCodeSdk("app.skills", () => client.app.skills()).pipe( |
| 705 | Effect.map((result) => (result.data ?? []) as ReadonlyArray<OpenCodeSkill>), | |
| 711 | Effect.map((result) => | |
| 712 | (result.data ?? []).map((skill) => ({ | |
| 713 | name: skill.name, | |
| 714 | ...(skill.description === undefined ? {} : { description: skill.description }), | |
| 715 | location: skill.location, | |
| 716 | })), | |
| 717 | ), | |
| 706 | 718 | Effect.orElseSucceed((): ReadonlyArray<OpenCodeSkill> => []), |
| 707 | 719 | ); |
| 708 | 720 | |
| ... | ... | @@ -732,6 +744,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { |
| 732 | 744 | runOpenCodeCommand({ |
| 733 | 745 | binaryPath: input.binaryPath, |
| 734 | 746 | args: ["debug", "skill"], |
| 747 | maxOutputBytes: OPENCODE_SKILL_DISCOVERY_MAX_OUTPUT_BYTES, | |
| 735 | 748 | ...commandContext, |
| 736 | 749 | }).pipe(Effect.exit); |
| 737 | 750 |
apps/server/src/provider/providerSnapshot.ts+2-1| ... | ... | @@ -255,5 +255,6 @@ export function buildServerProvider(input: { |
| 255 | 255 | |
| 256 | 256 | export const collectStreamAsString = <E>( |
| 257 | 257 | stream: Stream.Stream<Uint8Array, E>, |
| 258 | options?: { readonly maxBytes?: number | undefined }, | |
| 258 | 259 | ): Effect.Effect<string, E> => |
| 259 | collectUint8StreamText({ stream }).pipe(Effect.map((collected) => collected.text)); | |
| 260 | collectUint8StreamText({ stream, ...options }).pipe(Effect.map((collected) => collected.text)); |