| 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,19 +239,16 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { |
| 239 | name: "openclaw-review", | 239 | name: "openclaw-review", |
| 240 | description: "Review OpenClaw workflow changes.", | 240 | description: "Review OpenClaw workflow changes.", |
| 241 | location: "/Users/test/.agents/skills/openclaw-review/SKILL.md", | 241 | location: "/Users/test/.agents/skills/openclaw-review/SKILL.md", |
| 242 | content: "---\nname: openclaw-review\n---\n", | ||
| 243 | }, | 242 | }, |
| 244 | { | 243 | { |
| 245 | name: "openclaw-triage", | 244 | name: "openclaw-triage", |
| 246 | description: "Triage OpenClaw routing issues.", | 245 | description: "Triage OpenClaw routing issues.", |
| 247 | location: "/Users/test/.agents/skills/openclaw-triage/SKILL.md", | 246 | location: "/Users/test/.agents/skills/openclaw-triage/SKILL.md", |
| 248 | content: "---\nname: openclaw-triage\n---\n", | ||
| 249 | }, | 247 | }, |
| 250 | { | 248 | { |
| 251 | name: "missing-location", | 249 | name: "missing-location", |
| 252 | description: "This incomplete SDK row should be skipped.", | 250 | description: "This incomplete SDK row should be skipped.", |
| 253 | location: "", | 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,7 +258,7 @@ describe("parseAgentListCliOutput", () => { |
| 258 | }); | 258 | }); |
| 259 | 259 | ||
| 260 | describe("parseSkillsCliOutput", () => { | 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 | const result = parseSkillsCliOutput( | 262 | const result = parseSkillsCliOutput( |
| 263 | JSON.stringify([ | 263 | JSON.stringify([ |
| 264 | { | 264 | { |
| ... | @@ -275,7 +275,6 @@ describe("parseSkillsCliOutput", () => { | ... | @@ -275,7 +275,6 @@ describe("parseSkillsCliOutput", () => { |
| 275 | name: "review-pr", | 275 | name: "review-pr", |
| 276 | description: "Review a pull request.", | 276 | description: "Review a pull request.", |
| 277 | location: "/tmp/review-pr/SKILL.md", | 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,13 +4,20 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 4 | import type { OpencodeClient } from "@opencode-ai/sdk/v2"; | 4 | import type { OpencodeClient } from "@opencode-ai/sdk/v2"; |
| 5 | import { it } from "@effect/vitest"; | 5 | import { it } from "@effect/vitest"; |
| 6 | import * as Effect from "effect/Effect"; | 6 | import * as Effect from "effect/Effect"; |
| 7 | import * as FileSystem from "effect/FileSystem"; | ||
| 7 | import * as Layer from "effect/Layer"; | 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 | import { OpenCodeRuntime, OpenCodeRuntimeLive } from "./opencodeRuntime.ts"; | 16 | import { OpenCodeRuntime, OpenCodeRuntimeLive } from "./opencodeRuntime.ts"; |
| 10 | 17 | ||
| 11 | const testLayer = OpenCodeRuntimeLive.pipe(Layer.provideMerge(NodeServices.layer)); | 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 | it.effect("keeps provider inventory when skill discovery fails", () => | 21 | it.effect("keeps provider inventory when skill discovery fails", () => |
| 15 | Effect.gen(function* () { | 22 | Effect.gen(function* () { |
| 16 | const runtime = yield* OpenCodeRuntime; | 23 | const runtime = yield* OpenCodeRuntime; |
| ... | @@ -38,4 +45,121 @@ it.layer(testLayer)("loadOpenCodeInventory", (it) => { | ... | @@ -38,4 +45,121 @@ it.layer(testLayer)("loadOpenCodeInventory", (it) => { |
| 38 | NodeAssert.deepEqual(inventory.skills, []); | 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,6 +51,7 @@ export function resolveOpenCodeConfigContent( |
| 51 | const OPENCODE_SERVER_READY_PREFIX = "opencode server listening"; | 51 | const OPENCODE_SERVER_READY_PREFIX = "opencode server listening"; |
| 52 | const DEFAULT_OPENCODE_SERVER_TIMEOUT_MS = 30_000; | 52 | const DEFAULT_OPENCODE_SERVER_TIMEOUT_MS = 30_000; |
| 53 | const DEFAULT_HOSTNAME = "127.0.0.1"; | 53 | const DEFAULT_HOSTNAME = "127.0.0.1"; |
| 54 | const OPENCODE_SKILL_DISCOVERY_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; | ||
| 54 | export interface OpenCodeServerProcess { | 55 | export interface OpenCodeServerProcess { |
| 55 | readonly url: string; | 56 | readonly url: string; |
| 56 | readonly exitCode: Effect.Effect<number, never>; | 57 | readonly exitCode: Effect.Effect<number, never>; |
| ... | @@ -124,14 +125,12 @@ export interface OpenCodeSkill { | ... | @@ -124,14 +125,12 @@ export interface OpenCodeSkill { |
| 124 | readonly name?: string | null; | 125 | readonly name?: string | null; |
| 125 | readonly description?: string | null; | 126 | readonly description?: string | null; |
| 126 | readonly location?: string | null; | 127 | readonly location?: string | null; |
| 127 | readonly content?: string | null; | ||
| 128 | } | 128 | } |
| 129 | 129 | ||
| 130 | const OpenCodeSkillSchema = Schema.Struct({ | 130 | const OpenCodeSkillSchema = Schema.Struct({ |
| 131 | name: Schema.optionalKey(Schema.NullOr(Schema.String)), | 131 | name: Schema.optionalKey(Schema.NullOr(Schema.String)), |
| 132 | description: Schema.optionalKey(Schema.NullOr(Schema.String)), | 132 | description: Schema.optionalKey(Schema.NullOr(Schema.String)), |
| 133 | location: Schema.optionalKey(Schema.NullOr(Schema.String)), | 133 | location: Schema.optionalKey(Schema.NullOr(Schema.String)), |
| 134 | content: Schema.optionalKey(Schema.NullOr(Schema.String)), | ||
| 135 | }); | 134 | }); |
| 136 | const decodeOpenCodeSkillsCliOutputExit = Schema.decodeUnknownExit( | 135 | const decodeOpenCodeSkillsCliOutputExit = Schema.decodeUnknownExit( |
| 137 | Schema.fromJsonString(Schema.Array(OpenCodeSkillSchema)), | 136 | Schema.fromJsonString(Schema.Array(OpenCodeSkillSchema)), |
| ... | @@ -169,6 +168,7 @@ export interface OpenCodeRuntimeShape { | ... | @@ -169,6 +168,7 @@ export interface OpenCodeRuntimeShape { |
| 169 | readonly args: ReadonlyArray<string>; | 168 | readonly args: ReadonlyArray<string>; |
| 170 | readonly environment?: NodeJS.ProcessEnv; | 169 | readonly environment?: NodeJS.ProcessEnv; |
| 171 | readonly cwd?: string; | 170 | readonly cwd?: string; |
| 171 | readonly maxOutputBytes?: number; | ||
| 172 | }) => Effect.Effect<OpenCodeCommandResult, OpenCodeRuntimeError>; | 172 | }) => Effect.Effect<OpenCodeCommandResult, OpenCodeRuntimeError>; |
| 173 | readonly createOpenCodeSdkClient: (input: { | 173 | readonly createOpenCodeSdkClient: (input: { |
| 174 | readonly baseUrl: string; | 174 | readonly baseUrl: string; |
| ... | @@ -447,8 +447,14 @@ const makeOpenCodeRuntime = Effect.gen(function* () { | ... | @@ -447,8 +447,14 @@ const makeOpenCodeRuntime = Effect.gen(function* () { |
| 447 | ...(input.environment ? { env: input.environment } : { extendEnv: true }), | 447 | ...(input.environment ? { env: input.environment } : { extendEnv: true }), |
| 448 | }), | 448 | }), |
| 449 | ); | 449 | ); |
| 450 | const collectOptions = | ||
| 451 | input.maxOutputBytes === undefined ? undefined : { maxBytes: input.maxOutputBytes }; | ||
| 450 | const [stdout, stderr, code] = yield* Effect.all( | 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 | { concurrency: "unbounded" }, | 458 | { concurrency: "unbounded" }, |
| 453 | ); | 459 | ); |
| 454 | const exitCode = Number(code); | 460 | const exitCode = Number(code); |
| ... | @@ -702,7 +708,13 @@ const makeOpenCodeRuntime = Effect.gen(function* () { | ... | @@ -702,7 +708,13 @@ const makeOpenCodeRuntime = Effect.gen(function* () { |
| 702 | 708 | ||
| 703 | const loadSkills = (client: OpencodeClient) => | 709 | const loadSkills = (client: OpencodeClient) => |
| 704 | runOpenCodeSdk("app.skills", () => client.app.skills()).pipe( | 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 | Effect.orElseSucceed((): ReadonlyArray<OpenCodeSkill> => []), | 718 | Effect.orElseSucceed((): ReadonlyArray<OpenCodeSkill> => []), |
| 707 | ); | 719 | ); |
| 708 | 720 | ||
| ... | @@ -732,6 +744,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { | ... | @@ -732,6 +744,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { |
| 732 | runOpenCodeCommand({ | 744 | runOpenCodeCommand({ |
| 733 | binaryPath: input.binaryPath, | 745 | binaryPath: input.binaryPath, |
| 734 | args: ["debug", "skill"], | 746 | args: ["debug", "skill"], |
| 747 | maxOutputBytes: OPENCODE_SKILL_DISCOVERY_MAX_OUTPUT_BYTES, | ||
| 735 | ...commandContext, | 748 | ...commandContext, |
| 736 | }).pipe(Effect.exit); | 749 | }).pipe(Effect.exit); |
| 737 | 750 |
apps/server/src/provider/providerSnapshot.ts+2-1| ... | @@ -255,5 +255,6 @@ export function buildServerProvider(input: { | ... | @@ -255,5 +255,6 @@ export function buildServerProvider(input: { |
| 255 | 255 | ||
| 256 | export const collectStreamAsString = <E>( | 256 | export const collectStreamAsString = <E>( |
| 257 | stream: Stream.Stream<Uint8Array, E>, | 257 | stream: Stream.Stream<Uint8Array, E>, |
| 258 | options?: { readonly maxBytes?: number | undefined }, | ||
| 258 | ): Effect.Effect<string, E> => | 259 | ): Effect.Effect<string, E> => |
| 259 | collectUint8StreamText({ stream }).pipe(Effect.map((collected) => collected.text)); | 260 | collectUint8StreamText({ stream, ...options }).pipe(Effect.map((collected) => collected.text)); |