diff --git a/packages/provider-opencode/src/server/driver.test.ts b/packages/provider-opencode/src/server/driver.test.ts index b3c628e28fc63..9e85273809a5d 100644 --- a/packages/provider-opencode/src/server/driver.test.ts +++ b/packages/provider-opencode/src/server/driver.test.ts @@ -537,71 +537,78 @@ it.layer(layer)("OpenCodeDriver runtime selection", (it) => { }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), ); - it.effect("caches a cold OpenCode 2 directory's skills only after its scan ends", () => - Effect.gen(function* () { - // OpenCode 2.2.7: touching a location lists it as loaded with built-in commands and skills - // at once, while provider.list waits for the scan that adds the project's skills. - const scanEnded = yield* Deferred.make(); - const builtinSkills = JSON.stringify({ - location: { directory: "/work" }, - data: ( - JSON.parse(OPENCODE_2_WORKSPACE_RESPONSES["/api/skill"].body).data as Array<{ - id: string; - }> - ).filter((skill) => skill.id !== "plum"), - }); - const http = HttpClient.make((request) => { - const path = new URL(request.url).pathname; - const json = (body: string) => - Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(JSON.parse(body)))); - const afterScan = (body: string) => - Deferred.await(scanEnded).pipe(Effect.andThen(json(body))); - switch (path) { - case "/api/info": - return json( - JSON.stringify({ - ...JSON.parse(OPENCODE_2_RESPONSES["/api/info"].body), - version: "2.2.7", - }), - ); - case "/api/event": - return Effect.succeed( - HttpClientResponse.fromWeb( - request, - new Response(new ReadableStream(), { - headers: { "content-type": "text/event-stream" }, + it.effect.each(["ends", "hangs"] as const)( + "caches a cold OpenCode 2 directory's skills only after its scan ends (scan %s)", + (scan) => + Effect.gen(function* () { + // OpenCode 2.2.7: touching a location lists it as loaded with built-in commands and skills + // at once, while provider.list waits for the scan that adds the project's skills. + const scanEnded = yield* Deferred.make(); + const builtinSkills = JSON.stringify({ + location: { directory: "/work" }, + data: ( + JSON.parse(OPENCODE_2_WORKSPACE_RESPONSES["/api/skill"].body).data as Array<{ + id: string; + }> + ).filter((skill) => skill.id !== "plum"), + }); + const http = HttpClient.make((request) => { + const path = new URL(request.url).pathname; + const json = (body: string) => + Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(JSON.parse(body)))); + const afterScan = (body: string) => + Deferred.await(scanEnded).pipe(Effect.andThen(json(body))); + switch (path) { + case "/api/info": + return json( + JSON.stringify({ + ...JSON.parse(OPENCODE_2_RESPONSES["/api/info"].body), + version: "2.2.7", }), - ), - ); - case "/api/debug/location": - return json('[{"directory":"/work"}]'); - case "/api/provider": - case "/api/model": - return afterScan('{"location":{"directory":"/work"},"data":[]}'); - case "/api/command": - return json(OPENCODE_2_WORKSPACE_RESPONSES["/api/command"].body); - case "/api/skill": - return Deferred.isDone(scanEnded).pipe( - Effect.flatMap((done) => - json(done ? OPENCODE_2_WORKSPACE_RESPONSES["/api/skill"].body : builtinSkills), - ), - ); - default: - return Effect.die(`Unexpected request: ${path}`); - } - }); - const instance = yield* create({ serverUrl: "http://127.0.0.1:4096" }, http); + ); + case "/api/event": + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(new ReadableStream(), { + headers: { "content-type": "text/event-stream" }, + }), + ), + ); + case "/api/debug/location": + return json('[{"directory":"/work"}]'); + case "/api/provider": + case "/api/model": + return afterScan('{"location":{"directory":"/work"},"data":[]}'); + case "/api/command": + return json(OPENCODE_2_WORKSPACE_RESPONSES["/api/command"].body); + case "/api/skill": + return Deferred.isDone(scanEnded).pipe( + Effect.flatMap((done) => + json(done ? OPENCODE_2_WORKSPACE_RESPONSES["/api/skill"].body : builtinSkills), + ), + ); + default: + return Effect.die(`Unexpected request: ${path}`); + } + }); + const instance = yield* create({ serverUrl: "http://127.0.0.1:4096" }, http); - const snapshot = yield* instance.snapshotForCwd!("/work").pipe(Effect.forkChild); - yield* TestClock.adjust("1 second"); - yield* Deferred.succeed(scanEnded, undefined); - yield* TestClock.adjust("30 seconds"); - const { skills } = yield* Fiber.join(snapshot); - assert.include( - skills.map((skill) => skill.name), - "plum", - ); - }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), + const snapshot = yield* instance.snapshotForCwd!("/work").pipe(Effect.forkChild); + yield* TestClock.adjust("1 second"); + if (scan === "ends") yield* Deferred.succeed(scanEnded, undefined); + yield* TestClock.adjust("30 seconds"); + if (scan === "hangs") { + // Built-ins alone must not be cached as the directory's skills. + yield* Effect.flip(Fiber.join(snapshot)); + return; + } + const { skills } = yield* Fiber.join(snapshot); + assert.include( + skills.map((skill) => skill.name), + "plum", + ); + }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), ); it.effect.each(["probe", "error", "empty"] as const)( diff --git a/packages/provider-opencode/src/server/driver.ts b/packages/provider-opencode/src/server/driver.ts index 9b39fed2546b5..d8b4a29968c8c 100644 --- a/packages/provider-opencode/src/server/driver.ts +++ b/packages/provider-opencode/src/server/driver.ts @@ -402,11 +402,23 @@ export const OpenCodeDriver: ProviderDriver< processEnv, ); const listOpenCode2Workspace = (cwd: string) => - openCode2Server.withConnection(({ client, events }) => + openCode2Server.withConnection(({ client, events, version }) => Effect.gen(function* () { // OpenCode names a location by its resolved path (no trailing slash or `..`). const directory = pathService.resolve(cwd); const location = { directory }; + const read = Effect.all( + { + skills: client.skill.list({ location }).pipe(Effect.map((list) => list.data)), + commands: client.command.list({ location }).pipe(Effect.map((list) => list.data)), + }, + { concurrency: "unbounded" }, + ); + if (compareSemverVersions(version, "2.2.7") >= 0) { + // provider.list awaits plugin activation; loaded locations and update events do not. + yield* client.provider.list({ location }).pipe(Effect.timeout("10 seconds")); + return yield* read; + } const scanned = yield* Deferred.make(); const pending = new Set(["command.updated", "skill.updated"]); const stream = yield* events.pipe(Effect.option); @@ -425,13 +437,7 @@ export const OpenCodeDriver: ProviderDriver< ); } return yield* loadOpenCode2Workspace( - Effect.all( - { - skills: client.skill.list({ location }).pipe(Effect.map((list) => list.data)), - commands: client.command.list({ location }).pipe(Effect.map((list) => list.data)), - }, - { concurrency: "unbounded" }, - ), + read, Deferred.await(scanned), // Without events no scan can be awaited, so trust the first read as before. stream._tag === "None"