From 6364b635ceadc779132d2c22d4f848214bc9e9f7 Mon Sep 17 00:00:00 2001 From: THEKINGJEZE <132212229+THEKINGJEZE@users.noreply.github.com> Date: Fri, 24 Jul 2026 15:21:23 +0100 Subject: [PATCH] fix(codex): wait for plugin sync before loading skills --- .../src/provider/Layers/CodexProvider.test.ts | 49 +++++++- .../src/provider/Layers/CodexProvider.ts | 33 ++++- .../Layers/CodexSessionRuntime.test.ts | 117 +++++++++++++----- .../provider/Layers/CodexSessionRuntime.ts | 23 ++-- 4 files changed, 182 insertions(+), 40 deletions(-) diff --git a/apps/server/src/provider/Layers/CodexProvider.test.ts b/apps/server/src/provider/Layers/CodexProvider.test.ts index 2aeebdb2ccd..15bd9c85348 100644 --- a/apps/server/src/provider/Layers/CodexProvider.test.ts +++ b/apps/server/src/provider/Layers/CodexProvider.test.ts @@ -1,6 +1,53 @@ import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; -import { applyPreferredCodexDefaultModel, mapCodexModelCapabilities } from "./CodexProvider.ts"; +import { + applyPreferredCodexDefaultModel, + mapCodexModelCapabilities, + refreshCodexSkillsAfterPluginSync, +} from "./CodexProvider.ts"; + +it.effect("waits for plugin inventory before force-reloading skills", () => + Effect.gen(function* () { + const calls: Array<{ method: string; payload: unknown }> = []; + const skillsResponse = { + data: [{ cwd: "/tmp/project", errors: [], skills: [] }], + }; + const client = { + request: (method: string, payload: unknown) => { + calls.push({ method, payload }); + switch (method) { + case "plugin/installed": + return Effect.succeed({ marketplaces: [] }); + case "skills/list": + return Effect.succeed(skillsResponse); + default: + return Effect.die(new Error(`Unexpected request: ${method}`)); + } + }, + } as unknown as Parameters[0]["client"]; + + const response = yield* refreshCodexSkillsAfterPluginSync({ + client, + cwd: "/tmp/project", + }); + + assert.strictEqual(response, skillsResponse); + assert.deepStrictEqual(calls, [ + { + method: "plugin/installed", + payload: { cwds: ["/tmp/project"] }, + }, + { + method: "skills/list", + payload: { + cwds: ["/tmp/project"], + forceReload: true, + }, + }, + ]); + }), +); it("maps current Codex model capability fields", () => { const capabilities = mapCodexModelCapabilities({ diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 1ed9c750c18..fab7ec58b3d 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -50,6 +50,34 @@ export interface CodexAppServerProviderSnapshot { readonly skills: ReadonlyArray; } +export interface CodexSkillDiscoveryClient { + readonly request: { + ( + method: "plugin/installed", + payload: CodexSchema.V2PluginInstalledParams, + ): Effect.Effect; + ( + method: "skills/list", + payload: CodexSchema.V2SkillsListParams, + ): Effect.Effect; + }; +} + +export const refreshCodexSkillsAfterPluginSync = Effect.fn("refreshCodexSkillsAfterPluginSync")( + function* (input: { readonly client: CodexSkillDiscoveryClient; readonly cwd: string }) { + // Codex materializes remote plugins asynchronously after initialization. + // Reading the installed-plugin inventory is the readiness barrier for the + // subsequent forced disk scan; that transition emits no `skills/changed`. + yield* input.client.request("plugin/installed", { + cwds: [input.cwd], + }); + return yield* input.client.request("skills/list", { + cwds: [input.cwd], + forceReload: true, + }); + }, +); + const REASONING_EFFORT_LABELS: Readonly> = { none: "None", minimal: "Minimal", @@ -391,8 +419,9 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun const [skillsResponse, models] = yield* Effect.all( [ - client.request("skills/list", { - cwds: [input.cwd], + refreshCodexSkillsAfterPluginSync({ + client, + cwd: input.cwd, }), requestAllCodexModels(client), ], diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index d7346a0e0db..ec21a02f91d 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -393,27 +393,79 @@ describe("isRecoverableThreadResumeError", () => { }); describe("openCodexThread", () => { + it.effect("refreshes skills after plugin sync before starting a thread", () => + Effect.gen(function* () { + const calls: Array<{ method: string; payload: unknown }> = []; + const started = makeThreadOpenResponse("fresh-thread"); + const client = { + request: (method: string, payload: unknown) => { + calls.push({ method, payload }); + switch (method) { + case "plugin/installed": + return Effect.succeed({ marketplaces: [] }); + case "skills/list": + return Effect.succeed({ + data: [{ cwd: "/tmp/project", errors: [], skills: [] }], + }); + case "thread/start": + return Effect.succeed(started); + default: + return Effect.die(new Error(`Unexpected request: ${method}`)); + } + }, + } as unknown as Parameters[0]["client"]; + + const opened = yield* openCodexThread({ + client, + threadId: ThreadId.make("thread-1"), + runtimeMode: "full-access", + cwd: "/tmp/project", + requestedModel: "gpt-5.3-codex", + serviceTier: undefined, + resumeThreadId: undefined, + }); + + NodeAssert.equal(opened.thread.id, "fresh-thread"); + NodeAssert.deepStrictEqual( + calls.map((call) => call.method), + ["plugin/installed", "skills/list", "thread/start"], + ); + NodeAssert.deepStrictEqual(calls[0]?.payload, { cwds: ["/tmp/project"] }); + NodeAssert.deepStrictEqual(calls[1]?.payload, { + cwds: ["/tmp/project"], + forceReload: true, + }); + }), + ); + it.effect("falls back to thread/start when resume fails recoverably", () => Effect.gen(function* () { - const calls: Array<{ method: "thread/start" | "thread/resume"; payload: unknown }> = []; + const calls: Array<{ method: string; payload: unknown }> = []; const started = makeThreadOpenResponse("fresh-thread"); const client = { - request: ( - method: M, - payload: CodexRpc.ClientRequestParamsByMethod[M], - ) => { + request: (method: string, payload: unknown) => { calls.push({ method, payload }); - if (method === "thread/resume") { - return Effect.fail( - new CodexErrors.CodexAppServerRequestError({ - code: -32603, - errorMessage: "thread not found", - }), - ); + switch (method) { + case "plugin/installed": + return Effect.succeed({ marketplaces: [] }); + case "skills/list": + return Effect.succeed({ + data: [{ cwd: "/tmp/project", errors: [], skills: [] }], + }); + case "thread/resume": + return Effect.fail( + new CodexErrors.CodexAppServerRequestError({ + code: -32603, + errorMessage: "thread not found", + }), + ); + case "thread/start": + return Effect.succeed(started); + default: + return Effect.die(new Error(`Unexpected request: ${method}`)); } - return Effect.succeed(started as CodexRpc.ClientRequestResponsesByMethod[M]); }, - }; + } as unknown as Parameters[0]["client"]; const opened = yield* openCodexThread({ client, @@ -428,7 +480,7 @@ describe("openCodexThread", () => { NodeAssert.equal(opened.thread.id, "fresh-thread"); NodeAssert.deepStrictEqual( calls.map((call) => call.method), - ["thread/resume", "thread/start"], + ["plugin/installed", "skills/list", "thread/resume", "thread/start"], ); }), ); @@ -436,23 +488,28 @@ describe("openCodexThread", () => { it.effect("propagates non-recoverable resume failures", () => Effect.gen(function* () { const client = { - request: ( - method: M, - _payload: CodexRpc.ClientRequestParamsByMethod[M], - ) => { - if (method === "thread/resume") { - return Effect.fail( - new CodexErrors.CodexAppServerRequestError({ - code: -32603, - errorMessage: "timed out waiting for server", - }), - ); + request: (method: string) => { + switch (method) { + case "plugin/installed": + return Effect.succeed({ marketplaces: [] }); + case "skills/list": + return Effect.succeed({ + data: [{ cwd: "/tmp/project", errors: [], skills: [] }], + }); + case "thread/resume": + return Effect.fail( + new CodexErrors.CodexAppServerRequestError({ + code: -32603, + errorMessage: "timed out waiting for server", + }), + ); + case "thread/start": + return Effect.succeed(makeThreadOpenResponse("fresh-thread")); + default: + return Effect.die(new Error(`Unexpected request: ${method}`)); } - return Effect.succeed( - makeThreadOpenResponse("fresh-thread") as CodexRpc.ClientRequestResponsesByMethod[M], - ); }, - }; + } as unknown as Parameters[0]["client"]; const error = yield* openCodexThread({ client, diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 67108dd4dbb..063b4379f75 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -35,7 +35,11 @@ import * as CodexErrors from "effect-codex-app-server/errors"; import * as CodexRpc from "effect-codex-app-server/rpc"; import * as EffectCodexSchema from "effect-codex-app-server/schema"; -import { buildCodexInitializeParams } from "./CodexProvider.ts"; +import { + buildCodexInitializeParams, + refreshCodexSkillsAfterPluginSync, + type CodexSkillDiscoveryClient, +} from "./CodexProvider.ts"; import { codexSessionAppServerArgs } from "./codexLaunchArgs.ts"; import { expandHomePath } from "../../pathExpansion.ts"; import { buildCodexDeveloperInstructions } from "../CodexDeveloperInstructions.ts"; @@ -447,14 +451,14 @@ type CodexThreadOpenResponse = type CodexThreadOpenMethod = "thread/start" | "thread/resume"; -interface CodexThreadOpenClient { +interface CodexThreadOpenClient extends CodexSkillDiscoveryClient { readonly request: ( method: M, payload: CodexRpc.ClientRequestParamsByMethod[M], ) => Effect.Effect; } -export const openCodexThread = (input: { +export const openCodexThread = Effect.fn("openCodexThread")(function* (input: { readonly client: CodexThreadOpenClient; readonly threadId: ThreadId; readonly runtimeMode: RuntimeMode; @@ -462,7 +466,12 @@ export const openCodexThread = (input: { readonly requestedModel: string | undefined; readonly serviceTier: CodexServiceTier | undefined; readonly resumeThreadId: string | undefined; -}): Effect.Effect => { +}): Effect.fn.Return { + yield* refreshCodexSkillsAfterPluginSync({ + client: input.client, + cwd: input.cwd, + }); + const resumeThreadId = input.resumeThreadId; const startParams = buildThreadStartParams({ cwd: input.cwd, @@ -472,10 +481,10 @@ export const openCodexThread = (input: { }); if (resumeThreadId === undefined) { - return input.client.request("thread/start", startParams); + return yield* input.client.request("thread/start", startParams); } - return input.client + return yield* input.client .request("thread/resume", { threadId: resumeThreadId, ...startParams, @@ -491,7 +500,7 @@ export const openCodexThread = (input: { }).pipe(Effect.andThen(input.client.request("thread/start", startParams))), ), ); -}; +}); function readNotificationThreadId(notification: CodexServerNotification): string | undefined { switch (notification.method) {