From 3f7bbb7b1e988a3927757e82093fc2404f409308 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 16:41:52 +0000 Subject: [PATCH 1/2] test(opencode): a cold OpenCode 2 directory caches its skills after the scan Reproduces #35283: OpenCode 2.2.7 lists a just-touched location as loaded with built-in commands and skills while its skill scan is still running, so the workspace snapshot caches only the built-in skills. AI-assisted; verified with the tests and tools listed above. --- .../src/server/driver.test.ts | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) 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) => From f168e66fd09b6468ee26fa68b7e8b55aee55ee5b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 16:48:24 +0000 Subject: [PATCH 2/2] fix(opencode): a project opened mid-scan lists its OpenCode skills OpenCode 2.2.7 lists a location another request just touched as loaded, with only its built-in commands and skills, while the skill scan still runs. The workspace snapshot trusted that first read and cached it. Wait for provider.list on the location, which OpenCode answers only once the scan has ended, before reading skills and commands. Fixes pingdotgg/t3code#35283 AI-assisted; verified with the tests and tools listed above. --- packages/provider-opencode/src/server/driver.ts | 2 ++ packages/provider-opencode/src/server/status.ts | 6 ++++-- 2 files changed, 6 insertions(+), 2 deletions(-) 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"));