diff --git a/packages/provider-opencode/src/server/driver.test.ts b/packages/provider-opencode/src/server/driver.test.ts index 6d74dc8dd2542..ffb91b6a31994 100644 --- a/packages/provider-opencode/src/server/driver.test.ts +++ b/packages/provider-opencode/src/server/driver.test.ts @@ -537,6 +537,73 @@ 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" }, + }), + ), + ); + 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())), + ); + it.effect.each(["probe", "error", "empty"] as const)( "recovers discovery without a manual refresh after %s", (initialCatalog) => diff --git a/packages/provider-opencode/src/server/driver.ts b/packages/provider-opencode/src/server/driver.ts index 2686de885b3f0..a0d2f7905e4a5 100644 --- a/packages/provider-opencode/src/server/driver.ts +++ b/packages/provider-opencode/src/server/driver.ts @@ -438,6 +438,8 @@ export const OpenCodeDriver: ProviderDriver< Effect.timeout("2 seconds"), Effect.orElseSucceed(() => true), ), + // OpenCode answers provider.list for a location only once its skill scan has ended. + client.provider.list({ location }).pipe(Effect.timeout("10 seconds"), Effect.ignore), ); }).pipe(Effect.scoped), ); diff --git a/packages/provider-opencode/src/server/status.ts b/packages/provider-opencode/src/server/status.ts index 459904c27eb0e..0f24b1e22f479 100644 --- a/packages/provider-opencode/src/server/status.ts +++ b/packages/provider-opencode/src/server/status.ts @@ -578,16 +578,18 @@ export interface OpenCode2Workspace { /** * Reading a location the server has not loaded boots its scan, and only reads after the scan's * `command.updated` plus `skill.updated` are complete; `scanned` must listen before `read`. A - * loaded location listing no commands is still scanning. OpenCode exposes no readiness signal, - * so one another caller booted moments earlier can still read partial. + * loaded location listing no commands is still scanning. A location another caller booted + * moments earlier lists as loaded with only built-ins, so `settled` waits out its scan first. */ export const loadOpenCode2Workspace = Effect.fn("OpencodeProviderStatus.loadOpenCode2Workspace")( function* ( read: Effect.Effect, scanned: Effect.Effect, loaded: Effect.Effect, + settled: Effect.Effect = Effect.void, ) { const wasLoaded = yield* loaded; + yield* settled; const first = yield* read; if (wasLoaded && first.commands.length > 0) return first; yield* scanned.pipe(Effect.timeoutOption("10 seconds"));