diff --git a/.changeset/codex-fast-no-expiry.md b/.changeset/codex-fast-no-expiry.md new file mode 100644 index 0000000..187d2c8 --- /dev/null +++ b/.changeset/codex-fast-no-expiry.md @@ -0,0 +1,5 @@ +--- +"@eleith-pi/codex": patch +--- + +Keep saved fast-mode choices active without expiry across reloads, tree navigation, and model reselection. Check eligibility only when explicitly enabling fast mode, avoiding fast-mode authentication and catalog preparation on prompts while preserving scope checks and requested-versus-reported tier status. diff --git a/extensions/codex/README.md b/extensions/codex/README.md index ccae433..8e1772e 100644 --- a/extensions/codex/README.md +++ b/extensions/codex/README.md @@ -21,40 +21,11 @@ pi install npm:@eleith-pi/codex - `/eleith:codex context on` selects the verified local window when larger. - `/eleith:codex context off` returns to the catalog baseline, confirming reductions. - `/eleith:codex context toggle` switches the saved context preference. -- `/eleith:codex fast on` requests priority processing after confirmation of higher allowance use. +- `/eleith:codex fast on` checks catalog eligibility and requests priority processing after confirmation of higher allowance use. - `/eleith:codex fast off` requests standard processing. - `/eleith:codex fast toggle` switches the saved fast preference. - `/eleith:codex usage` fetches account allowance and reset information. in the terminal, the cancel key (Escape by default) aborts the request. -bare `context` and `fast` show the same dashboard as the root command, without account or catalog requests. -fast controls require a direct official legacy `openai-codex` selection with Pi OAuth and no provider override. -enabling priority also requires catalog eligibility. -priority is a request, not a scheduling guarantee; reported response tiers are shown separately. - -context and fast preferences persist per model on the active branch across reloads. the catalog baseline is not guessed: -missing baselines are reported, and models already at or above the verified window -get a no-extension notice rather than a smaller window. - -local context preference replay only runs while Pi is idle. after tree navigation it waits for -normal user input, before Pi checks compaction. custom-message-triggered turns use -the current window without replaying pending preferences; explicit context and fast -changes also require idle. - -### migrate - -when migrating from `@eleith-pi/context-codex`, run its old `/eleith:context-codex restore` -command with that extension loaded before removing it: - -```sh -pi remove npm:@eleith-pi/context-codex -pi install npm:@eleith-pi/codex -``` - -if migrating from the older `@eleith-pi/context-window` instead, first use its -`/eleith:context restore` command, then remove that package before installing codex. - -branch preferences are preserved. do not load the predecessor and codex together. - ## config set a command prefix in `~/.pi/agent/extensions/eleith.json`: diff --git a/extensions/codex/src/dashboard.test.ts b/extensions/codex/src/dashboard.test.ts index 41d8c86..0e8f60d 100644 --- a/extensions/codex/src/dashboard.test.ts +++ b/extensions/codex/src/dashboard.test.ts @@ -25,7 +25,6 @@ const context: ContextStatus = { }; const inherited: FastStatus = { preference: "inherit", - eligibility: "unknown", requestPolicy: "untouched", lastObserved: undefined, conflict: false, @@ -63,7 +62,6 @@ it("does not present requested priority as confirmed server processing", () => { const fast: FastStatus = { ...inherited, preference: "on", - eligibility: "supported", requestPolicy: "priority", lastObserved: "default", }; @@ -73,6 +71,21 @@ it("does not present requested priority as confirmed server processing", () => { expect(text).toContain("Context: 40k / 272k tokens (standard)"); }); +it("shows a saved on choice without needing an eligibility snapshot or a new response", () => { + const fast: FastStatus = { + ...inherited, + preference: "on", + requestPolicy: "priority", + }; + const original = structuredClone(fast); + const text = formatCodexDashboard(context, fast, "eleith:codex"); + expect(text).toContain("Fast: On (priority requested)\nNo response tier reported yet."); + expect(text).toContain("Priority requests use more allowance."); + expect(text).not.toContain("Paused"); + expect(text).not.toContain("Eligibility:"); + expect(fast).toEqual(original); +}); + it("keeps the effective context window distinct from the selected model window", () => { const snapshot: ContextStatus = { ...context, diff --git a/extensions/codex/src/fast-sdk.test.ts b/extensions/codex/src/fast-sdk.test.ts index 1ca6a92..3e0cfa2 100644 --- a/extensions/codex/src/fast-sdk.test.ts +++ b/extensions/codex/src/fast-sdk.test.ts @@ -36,6 +36,7 @@ type Transport = "sse" | "websocket"; beforeEach(blockUnexpectedNetwork); afterEach(() => { + vi.useRealTimers(); vi.restoreAllMocks(); vi.unstubAllGlobals(); }); @@ -50,10 +51,11 @@ it.each(["sse", "websocket"] as const)( const network = installResponseTransports(transport); const { runtime, model, accessToken, credentialWrites, resolveAuth, modelTurns } = await createOfflineModelRuntime(transport, network.fetch); - const { session, manager, confirm, notify, errors, closeSession } = + const { session, manager, confirm, notify, input, beforeStart, errors, closeSession } = await openRealExtensionSession(runtime, model); try { const branch = structuredClone(manager.getBranch()); + expect(resolveAuth).not.toHaveBeenCalled(); resolveAuth.mockClear(); let dashboard: string | undefined; for (const command of ["/eleith:codex", "/eleith:codex fast", "/eleith:codex context"]) { @@ -78,6 +80,8 @@ it.each(["sse", "websocket"] as const)( await session.prompt("/eleith:codex fast on"); expect(confirm).toHaveBeenCalledOnce(); + // Catalog discovery resolves OAuth once; there is no separate auth-scope lookup. + expect(resolveAuth).toHaveBeenCalledExactlyOnceWith("openai-codex"); expect(network.catalog).toHaveBeenCalledOnce(); expect(network.catalog.mock.calls[0][0]).toBe(CATALOG_URL); const headers = new Headers(network.catalog.mock.calls[0][1]?.headers); @@ -92,10 +96,24 @@ it.each(["sse", "websocket"] as const)( "info", ); + const enabledBranch = structuredClone(manager.getBranch()); resolveAuth.mockClear(); + // Already on is a local no-op, even if discovery has become unavailable. + network.catalog.mockRejectedValue(new Error("Catalog must not be consulted after enabling")); + await session.prompt("/eleith:codex fast on"); + expect(confirm).toHaveBeenCalledOnce(); + expect(network.catalog).toHaveBeenCalledOnce(); + expect(resolveAuth).not.toHaveBeenCalled(); + expect(manager.getBranch()).toEqual(enabledBranch); + await session.prompt("Reply with ok."); - // Ordinary input checks the cached catalog's auth scope through Pi resolution only. - expect(resolveAuth).toHaveBeenCalledWith("openai-codex"); + expect(input).toHaveBeenCalledExactlyOnceWith({ + text: "Reply with ok.", + streamingBehavior: undefined, + authCalls: 0, + }); + expect(beforeStart).toHaveBeenCalledExactlyOnceWith({ messageType: "user", authCalls: 0 }); + expectNativeAuthOnly(resolveAuth); expect(network.catalog).toHaveBeenCalledOnce(); expect(modelTurns).toHaveBeenCalledOnce(); expect(network.payloads).toEqual([ @@ -169,16 +187,19 @@ it("SDK replays the active fast branch alongside context controls, explicit off model, manager, ); + // A resumed on preference does not depend on successful catalog discovery. + network.catalog.mockRejectedValue(new Error("Resumed preferences must not fetch the catalog")); let extendedWindow: number | undefined; const outputLimit = session.model?.maxTokens; const thinking = session.thinkingLevel; try { expect(network.fetch).not.toHaveBeenCalled(); + expect(resolveAuth).not.toHaveBeenCalled(); const branch = structuredClone(manager.getBranch()); resolveAuth.mockClear(); await session.prompt("/eleith:codex fast"); expect(notify).toHaveBeenLastCalledWith( - expect.stringContaining("Fast: Paused — model eligibility needs checking"), + expect.stringContaining("Fast: On (priority requested)"), "info", ); expect(notify).toHaveBeenLastCalledWith(expect.stringContaining("Context:"), "info"); @@ -195,9 +216,11 @@ it("SDK replays the active fast branch alongside context controls, explicit off expect(session.thinkingLevel).toBe(thinking); expect(network.fetch).not.toHaveBeenCalled(); - // No explicit fast command fetched metadata in this resumed session. + // Saved on immediately requests priority, without an explicit fast command or discovery. + resolveAuth.mockClear(); await session.prompt("Reply with ok on the resumed branch."); - expect(network.catalog).toHaveBeenCalledOnce(); + expectNativeAuthOnly(resolveAuth); + expect(network.catalog).not.toHaveBeenCalled(); expect(network.payloads[0]).toMatchObject({ model: model.id, service_tier: "priority" }); expect(fastChoices(manager)).toEqual([saved]); expect(confirm).not.toHaveBeenCalled(); @@ -247,7 +270,7 @@ it("SDK replays the active fast branch alongside context controls, explicit off expect(network.payloads).toHaveLength(3); expect(legacy.session.getLastAssistantText()).toBe("ok"); expect(modelTurns).toHaveBeenCalledTimes(3); - expect(network.catalog).toHaveBeenCalledOnce(); + expect(network.catalog).not.toHaveBeenCalled(); expect(legacy.session.model?.contextWindow).toBe(extendedWindow); expect(legacy.session.model?.maxTokens).toBe(outputLimit); expect(legacy.session.thinkingLevel).toBe(thinking); @@ -259,6 +282,250 @@ it("SDK replays the active fast branch alongside context controls, explicit off } }); +it("SDK discovers eligibility only after confirmation for each explicit transition to on", async () => { + const network = installResponseTransports("sse"); + const { runtime, model, resolveAuth, modelTurns, credentialWrites } = + await createOfflineModelRuntime("sse", network.fetch); + const f = await openRealExtensionSession(runtime, model); + const confirming = deferred(); + const decision = deferred(); + let enabling: Promise | undefined; + try { + f.confirm.mockResolvedValueOnce(false); + await f.session.prompt("/eleith:codex fast on"); + expect(resolveAuth).not.toHaveBeenCalled(); + expect(network.fetch).not.toHaveBeenCalled(); + expect(fastChoices(f.manager)).toEqual([]); + + f.confirm.mockImplementationOnce(async () => { + confirming.resolve(); + await decision.promise; + return true; + }); + enabling = f.session.prompt("/eleith:codex fast on"); + await confirming.promise; + expect(resolveAuth).not.toHaveBeenCalled(); + expect(network.fetch).not.toHaveBeenCalled(); + expect(fastChoices(f.manager)).toEqual([]); + decision.resolve(); + await enabling; + expect(resolveAuth).toHaveBeenCalledExactlyOnceWith("openai-codex"); + expect(network.catalog).toHaveBeenCalledOnce(); + expect(fastChoices(f.manager)).toEqual([ + { provider: "openai-codex", modelId: model.id, mode: "on" }, + ]); + + resolveAuth.mockClear(); + await f.session.prompt("/eleith:codex fast off"); + expect(resolveAuth).not.toHaveBeenCalled(); + expect(network.catalog).toHaveBeenCalledOnce(); + await f.session.prompt("/eleith:codex fast toggle"); + expect(f.confirm).toHaveBeenCalledTimes(3); + expect(resolveAuth).toHaveBeenCalledExactlyOnceWith("openai-codex"); + expect(network.catalog).toHaveBeenCalledTimes(2); + expect(fastChoices(f.manager)).toEqual([ + { provider: "openai-codex", modelId: model.id, mode: "on" }, + { provider: "openai-codex", modelId: model.id, mode: "off" }, + { provider: "openai-codex", modelId: model.id, mode: "on" }, + ]); + expect(modelTurns).not.toHaveBeenCalled(); + expect(credentialWrites).not.toHaveBeenCalled(); + expect(f.errors).toEqual([]); + } finally { + decision.resolve(); + await enabling; + await f.closeSession(); + } +}); + +it.each(["sse", "websocket"] as const)( + "SDK saved priority survives expiry, reload, tree replay and model reselection on %s", + async (transport) => { + // Fake timeout clocks too: an expiry callback must not silently revoke the saved request. + // Microtasks and the provider's other scheduling primitives remain real. + vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] }); + const network = installResponseTransports(transport); + const { runtime, model, resolveAuth, modelTurns, credentialWrites } = + await createOfflineModelRuntime(transport, network.fetch); + const f = await openRealExtensionSession(runtime, model); + try { + await f.session.prompt("/eleith:codex fast on"); + expect(resolveAuth).toHaveBeenCalledExactlyOnceWith("openai-codex"); + const onLeaf = f.manager.getLeafId()!; + const saved = { provider: "openai-codex", modelId: model.id, mode: "on" }; + network.catalog.mockRejectedValue(new Error("Prompt/lifecycle discovery forbidden")); + resolveAuth.mockClear(); + + async function statusIsOn() { + resolveAuth.mockClear(); + await f.session.prompt("/eleith:codex fast"); + expect(f.notify).toHaveBeenLastCalledWith( + expect.stringContaining("Fast: On (priority requested)"), + "info", + ); + expect(resolveAuth).not.toHaveBeenCalled(); + expect(network.catalog).toHaveBeenCalledOnce(); + } + + async function priorityTurn(custom: boolean) { + resolveAuth.mockClear(); + f.input.mockClear(); + f.beforeStart.mockClear(); + const turns = modelTurns.mock.calls.length; + if (custom) { + await f.session.sendCustomMessage( + { customType: "fast-sdk", content: "Reply with ok.", display: false }, + { triggerTurn: true }, + ); + expect(f.input).not.toHaveBeenCalled(); + } else { + await f.session.prompt("Reply with ok."); + expect(f.input).toHaveBeenCalledExactlyOnceWith({ + text: "Reply with ok.", + streamingBehavior: undefined, + authCalls: 0, + }); + } + expect(f.beforeStart).toHaveBeenCalledExactlyOnceWith({ + messageType: custom ? "custom" : "user", + authCalls: 0, + }); + expectNativeAuthOnly(resolveAuth); + expect(network.catalog).toHaveBeenCalledOnce(); + expect(modelTurns).toHaveBeenCalledTimes(turns + 1); + expect(network.payloads.at(-1)).toMatchObject({ + model: model.id, + service_tier: "priority", + }); + expect(f.session.getLastAssistantText()).toBe("ok"); + } + + await vi.advanceTimersByTimeAsync(6 * 60_000); + await statusIsOn(); + await priorityTurn(true); // No idle input preparation can rescue an expired request policy. + + resolveAuth.mockClear(); + await f.session.reload(); + expect(resolveAuth).not.toHaveBeenCalled(); + expect(network.catalog).toHaveBeenCalledOnce(); + await statusIsOn(); + await priorityTurn(false); + + await f.session.prompt("/eleith:codex fast off"); + expect(fastChoices(f.manager)).toEqual([saved, { ...saved, mode: "off" }]); + resolveAuth.mockClear(); + expect(await f.session.navigateTree(onLeaf)).toMatchObject({ cancelled: false }); + expect(resolveAuth).not.toHaveBeenCalled(); + expect(fastChoices(f.manager)).toEqual([saved]); + await statusIsOn(); + await priorityTurn(true); // Tree restoration also works before any ordinary input. + + const other = runtime.getModel("openai-codex", "gpt-6.1-sol"); + if (!other) throw new Error("Second built-in Codex model missing"); + resolveAuth.mockClear(); + await f.session.setModel(other); + await f.session.setModel(model); + expect(f.modelSelect).toHaveBeenCalledTimes(2); // Exercise real invalidation, not same-ID suppression. + expect(resolveAuth).not.toHaveBeenCalled(); + await statusIsOn(); + await priorityTurn(true); + + // Also verify idempotence after replay/invalidation, but only AFTER testing the + // native requests so an on command cannot accidentally repair their policy. + resolveAuth.mockClear(); + const branch = structuredClone(f.manager.getBranch()); + await f.session.prompt("/eleith:codex fast on"); + expect(f.manager.getBranch()).toEqual(branch); + expect(resolveAuth).not.toHaveBeenCalled(); + expect(network.catalog).toHaveBeenCalledOnce(); + expect(network.payloads).toHaveLength(4); + expect(fastChoices(f.manager)).toEqual([saved]); + expect(f.confirm).toHaveBeenCalledOnce(); + expect(credentialWrites).not.toHaveBeenCalled(); + expect(f.errors).toEqual([]); + } finally { + await f.closeSession(); + } + }, +); + +it.each(["prompt", "queue"] as const)( + "SDK %s steering and follow-up input never adds auth or catalog work while fast is on", + async (submission) => { + const network = installResponseTransports("sse"); + const { runtime, model, resolveAuth, modelTurns, credentialWrites } = + await createOfflineModelRuntime("sse", network.fetch); + const f = await openRealExtensionSession(runtime, model); + const started = deferred(); + const blocked = deferred(); + let running: Promise | undefined; + try { + await f.session.prompt("/eleith:codex fast on"); + network.catalog.mockRejectedValue(new Error("Queued prompts must not discover eligibility")); + const respond = network.sse.getMockImplementation()!; + network.sse.mockImplementationOnce(async (...args) => { + started.resolve(); + await blocked.promise; + return respond(...args); + }); + resolveAuth.mockClear(); + running = f.session.prompt("Start with ok."); + await started.promise; + expect(f.session.isStreaming).toBe(true); + expectNativeAuthOnly(resolveAuth); + expect(f.input).toHaveBeenCalledExactlyOnceWith({ + text: "Start with ok.", + streamingBehavior: undefined, + authCalls: 0, + }); + resolveAuth.mockClear(); + f.input.mockClear(); + + if (submission === "prompt") { + await f.session.prompt("Steer with ok.", { streamingBehavior: "steer" }); + await f.session.prompt("Follow with ok.", { streamingBehavior: "followUp" }); + } else { + expect(await f.session.steer("Steer with ok.")).toBe("queued"); + expect(await f.session.followUp("Follow with ok.")).toBe("queued"); + } + expect(f.input.mock.calls).toEqual([ + [{ text: "Steer with ok.", streamingBehavior: "steer", authCalls: 0 }], + [{ text: "Follow with ok.", streamingBehavior: "followUp", authCalls: 0 }], + ]); + expect(resolveAuth).not.toHaveBeenCalled(); + expect(network.catalog).toHaveBeenCalledOnce(); + expect(modelTurns).toHaveBeenCalledOnce(); + + blocked.resolve(); + await running; + await f.session.waitForIdle(); + expectNativeAuthOnly(resolveAuth); + expect(modelTurns).toHaveBeenCalledTimes(3); + expect(network.catalog).toHaveBeenCalledOnce(); + expect(network.payloads).toEqual( + Array.from({ length: 3 }, () => + expect.objectContaining({ model: model.id, service_tier: "priority" }), + ), + ); + expect(f.confirm).toHaveBeenCalledOnce(); + expect(credentialWrites).not.toHaveBeenCalled(); + expect(f.errors).toEqual([]); + } finally { + blocked.resolve(); + await running; + await f.closeSession(); + } + }, +); + +function deferred() { + let resolve!: () => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + // Network policy: globals start closed; the supplied fetch accepts only these two fixed URLs. function blockUnexpectedNetwork() { vi.stubGlobal( @@ -410,6 +677,9 @@ async function openRealExtensionSession( retry: { enabled: false }, cacheWarming: "off", }); + const input = vi.fn(); + const beforeStart = vi.fn(); + const modelSelect = vi.fn(); const resourceLoader = new DefaultResourceLoader({ cwd: directory, agentDir: directory, @@ -419,7 +689,26 @@ async function openRealExtensionSession( noPromptTemplates: true, noThemes: true, noContextFiles: true, - extensionFactories: [codex], + extensionFactories: [ + codex, + (pi) => { + // These handlers run AFTER codex but BEFORE native request authentication. + pi.on("input", (event) => { + input({ + text: event.text, + streamingBehavior: event.streamingBehavior, + authCalls: vi.mocked(runtime.getAuth).mock.calls.length, + }); + }); + pi.on("before_agent_start", (event) => { + beforeStart({ + messageType: event.messageType, + authCalls: vi.mocked(runtime.getAuth).mock.calls.length, + }); + }); + pi.on("model_select", modelSelect); + }, + ], }); await resourceLoader.reload(); expect(resourceLoader.getExtensions().errors).toEqual([]); @@ -447,6 +736,9 @@ async function openRealExtensionSession( manager, confirm, notify, + input, + beforeStart, + modelSelect, errors, async closeSession() { await session.extensionRunner.emit({ type: "session_shutdown", reason: "quit" }); @@ -455,6 +747,16 @@ async function openRealExtensionSession( }; } +// Native provider resolution is essential and uses a model, not the extension's provider lookup. +function expectNativeAuthOnly( + resolveAuth: Awaited>["resolveAuth"], +) { + expect(resolveAuth).toHaveBeenCalled(); + for (const [selected] of resolveAuth.mock.calls) { + expect(selected).toEqual(expect.objectContaining({ provider: "openai-codex" })); + } +} + function fastChoices(manager: SessionManager) { return manager .getBranch() diff --git a/extensions/codex/src/fast-status.test.ts b/extensions/codex/src/fast-status.test.ts index 7e4c76c..b20547c 100644 --- a/extensions/codex/src/fast-status.test.ts +++ b/extensions/codex/src/fast-status.test.ts @@ -11,10 +11,9 @@ it("describes off as a standard request, not an observed processing tier", () => expect(formatFastStatus(fast)).toBe("Fast: Off (standard requested)"); }); -it("describes on as a priority request without claiming priority was delivered", () => { +it("describes a saved on choice as priority without eligibility state or a reported tier", () => { const fast = createFastStatusFixture({ preference: "on", - eligibility: "supported", requestPolicy: "priority", }); expect(formatFastStatus(fast).split("\n")).toEqual([ @@ -27,7 +26,6 @@ it("describes on as a priority request without claiming priority was delivered", it("keeps next-request priority separate from a reported standard tier", () => { const fast = createFastStatusFixture({ preference: "on", - eligibility: "supported", requestPolicy: "priority", lastObserved: "default", }); @@ -51,34 +49,16 @@ it.each<[ObservedTier, string]>([ ); }); -it("pauses a saved on choice when eligibility needs checking without claiming a current request", () => { +it("keeps a saved on choice intact outside scope and shows priority again when in scope", () => { const fast = createFastStatusFixture({ preference: "on", - requestPolicy: "suspended", - }); - expect(formatFastStatus(fast)).toBe("Fast: Paused — model eligibility needs checking"); -}); - -it("explains when priority is not available without erasing the saved preference", () => { - const fast = createFastStatusFixture({ - preference: "on", - eligibility: "unsupported", - requestPolicy: "suspended", - lastObserved: "default", - }); - expect(formatFastStatus(fast)).toBe( - "Fast: Paused — priority is not available for this model\nLast reported tier: Standard", - ); - expect(fast.preference).toBe("on"); -}); - -it("gives outside scope precedence over the saved on preference and supported eligibility", () => { - const fast = createFastStatusFixture({ - preference: "on", - eligibility: "supported", requestPolicy: "outside scope", }); expect(formatFastStatus(fast)).toBe("Fast: Unavailable for this model/provider"); + expect(fast.preference).toBe("on"); + expect(formatFastStatus({ ...fast, requestPolicy: "priority" })).toBe( + "Fast: On (priority requested)\nNo response tier reported yet.\nPriority requests use more allowance.", + ); }); it.each(["priority", "default"] as const)( @@ -86,7 +66,6 @@ it.each(["priority", "default"] as const)( (requestPolicy) => { const fast = createFastStatusFixture({ preference: requestPolicy === "priority" ? "on" : "off", - eligibility: "supported", requestPolicy, lastObserved: "flex", conflict: true, @@ -111,7 +90,6 @@ it.each(["priority", "default"] as const)( function createFastStatusFixture(overrides: Partial = {}): FastStatus { return { preference: "inherit", - eligibility: "unknown", requestPolicy: "untouched", lastObserved: undefined, conflict: false, diff --git a/extensions/codex/src/fast-status.ts b/extensions/codex/src/fast-status.ts index 51efdd4..3df4b7d 100644 --- a/extensions/codex/src/fast-status.ts +++ b/extensions/codex/src/fast-status.ts @@ -1,13 +1,11 @@ -import type { FastEligibility } from "./capabilities.ts"; import type { FastPreference } from "./fast-state.ts"; export type RequestedTier = "priority" | "default"; export type ObservedTier = RequestedTier | "auto" | "flex" | "fast"; -export type FastRequestPolicy = RequestedTier | "untouched" | "suspended" | "outside scope"; +export type FastRequestPolicy = RequestedTier | "untouched" | "outside scope"; export interface FastStatus { preference: FastPreference; - eligibility: FastEligibility; requestPolicy: FastRequestPolicy; lastObserved: ObservedTier | undefined; conflict: boolean; @@ -25,12 +23,6 @@ export function formatFastStatus(status: FastStatus): string { case "priority": headline = "Fast: On (priority requested)"; break; - case "suspended": - headline = - status.eligibility === "unsupported" - ? "Fast: Paused — priority is not available for this model" - : "Fast: Paused — model eligibility needs checking"; - break; case "outside scope": headline = "Fast: Unavailable for this model/provider"; break; diff --git a/extensions/codex/src/fast.test.ts b/extensions/codex/src/fast.test.ts index 62a7bc0..5946772 100644 --- a/extensions/codex/src/fast.test.ts +++ b/extensions/codex/src/fast.test.ts @@ -9,7 +9,6 @@ import { CodexAccountError, type CodexModelCatalog, type fetchCodexModelCatalog, - type readCodexAuthScope, } from "./client.ts"; import { FastController, type FastHost } from "./fast.ts"; import { FAST_ENTRY, type FastPreference } from "./fast-state.ts"; @@ -47,11 +46,9 @@ describe("commands and ownership", () => { ); expect(fast.controller.getStatus(fast.ctx)).toMatchObject({ preference: "on", - eligibility: "unknown", - requestPolicy: "suspended", + requestPolicy: "priority", }); expect(fast.ctx.ui.confirm).not.toHaveBeenCalled(); - expect(fast.readAuthScope).not.toHaveBeenCalled(); expect(fast.ctx.modelRegistry.getProviderAuth).not.toHaveBeenCalled(); expect(fast.fetchCatalog).not.toHaveBeenCalled(); expect(fast.appendEntry).not.toHaveBeenCalled(); @@ -67,7 +64,6 @@ describe("commands and ownership", () => { expect.stringContaining("use more of your Codex allowance"), { signal: expect.any(AbortSignal) }, ); - expect(fast.readAuthScope).not.toHaveBeenCalled(); expect(fast.fetchCatalog).not.toHaveBeenCalled(); expect(fast.appendEntry).not.toHaveBeenCalled(); expect(fast.controller.transform({ model: model.id }, fast.ctx)).toBeUndefined(); @@ -88,17 +84,11 @@ describe("commands and ownership", () => { model: model.id, service_tier: "default", }); - expect(fast.readAuthScope).not.toHaveBeenCalled(); expect(fast.fetchCatalog).not.toHaveBeenCalled(); }); - it("rejects invalid arguments and mutations during a run without prompting or IO", async () => { + it("rejects mutations during a run without prompting or IO", async () => { const fast = createFastControllerFixture(); - await fast.controller.handle("on extra", fast.ctx); - expect(fast.ctx.ui.notify).toHaveBeenLastCalledWith( - "Usage: /eleith:codex fast [on|off|toggle]", - "warning", - ); fast.ctx.isIdle.mockReturnValue(false); for (const action of ["on", "off", "toggle"]) await fast.controller.handle(action, fast.ctx); expect(fast.ctx.ui.notify).toHaveBeenLastCalledWith( @@ -106,7 +96,6 @@ describe("commands and ownership", () => { "warning", ); expect(fast.ctx.ui.confirm).not.toHaveBeenCalled(); - expect(fast.readAuthScope).not.toHaveBeenCalled(); expect(fast.fetchCatalog).not.toHaveBeenCalled(); expect(fast.appendEntry).not.toHaveBeenCalled(); }); @@ -117,7 +106,6 @@ describe("commands and ownership", () => { await fast.controller.handle("on", fast.ctx); expect(fast.ctx.ui.notify).toHaveBeenLastCalledWith("No model selected.", "warning"); expect(fast.ctx.ui.confirm).not.toHaveBeenCalled(); - expect(fast.readAuthScope).not.toHaveBeenCalled(); expect(fast.fetchCatalog).not.toHaveBeenCalled(); expect(fast.appendEntry).not.toHaveBeenCalled(); }); @@ -133,10 +121,8 @@ describe("commands and ownership", () => { return response.promise; }); const enabling = fast.controller.handle(" ON ", fast.ctx); - expect(fast.readAuthScope).not.toHaveBeenCalled(); expect(fast.fetchCatalog).not.toHaveBeenCalled(); expect(fast.appendEntry).not.toHaveBeenCalled(); - // A second mutating command must not compete with the confirmation. await fast.controller.handle("off", fast.ctx); expect(fast.ctx.ui.notify).toHaveBeenLastCalledWith( expect.stringContaining("Wait for the current run"), @@ -144,14 +130,12 @@ describe("commands and ownership", () => { ); confirmation.resolve(true); await started.promise; - expect(fast.readAuthScope).toHaveBeenCalledExactlyOnceWith( - fast.ctx.modelRegistry, - expect.any(AbortSignal), - ); expect(fast.fetchCatalog).toHaveBeenCalledExactlyOnceWith( fast.ctx.modelRegistry, expect.any(AbortSignal), ); + // No separate auth-scope preflight; the account client resolves auth for its one request. + expect(fast.ctx.modelRegistry.getProviderAuth).not.toHaveBeenCalled(); expect(fast.appendEntry).not.toHaveBeenCalled(); expect(fast.controller.transform({ model: model.id }, fast.ctx)).toBeUndefined(); response.resolve(catalog); @@ -172,24 +156,43 @@ describe("commands and ownership", () => { }); it.each([ - { models: [{ slug: otherModel.id, service_tiers: [{ id: "priority" }] }] }, - { models: [{ slug: model.id, service_tiers: [{ id: "default" }] }] }, - ])( - "does not enable without priority support for the selected catalog model: %j", - async (data) => { - const fast = createFastControllerFixture(); - fast.fetchCatalog.mockResolvedValue({ ...catalog, data }); - await fast.controller.handle("on", fast.ctx); - expect(fast.appendEntry).not.toHaveBeenCalled(); - expect(fast.controller.transform({ model: model.id }, fast.ctx)).toBeUndefined(); - expect(fast.ctx.ui.notify).toHaveBeenLastCalledWith( - expect.stringContaining("Preference unchanged"), - "warning", - ); + { + data: { models: [{ slug: otherModel.id, service_tiers: [{ id: "priority" }] }] }, + reason: "did not establish", }, - ); + { + data: { models: [{ slug: model.id, service_tiers: [{ id: "default" }] }] }, + reason: "does not advertise", + }, + ])("does not enable without established model support: %j", async ({ data, reason }) => { + const fast = createFastControllerFixture(); + fast.fetchCatalog.mockResolvedValue({ ...catalog, data }); + await fast.controller.handle("on", fast.ctx); + expect(fast.appendEntry).not.toHaveBeenCalled(); + expect(fast.controller.transform({ model: model.id }, fast.ctx)).toBeUndefined(); + expect(fast.ctx.ui.notify).toHaveBeenLastCalledWith(expect.stringContaining(reason), "warning"); + expect(fast.ctx.ui.notify).toHaveBeenLastCalledWith( + expect.stringContaining("Preference unchanged"), + "warning", + ); + }); - it("copies a frozen payload for explicit default, preserves fields, then restores a legacy inherit entry", async () => { + it("treats an already saved on as idempotent without confirmation or discovery", async () => { + const fast = createFastControllerFixture(); + fast.ctx.sessionManager.getBranch.mockReturnValue([choice("on")]); + fast.controller.restore(fast.ctx); + fast.ctx.hasUI = false; + await fast.controller.handle("on", fast.ctx); + expect(fast.ctx.ui.notify).toHaveBeenLastCalledWith( + expect.stringContaining("Fast: On (priority requested)"), + "info", + ); + expect(fast.fetchCatalog).not.toHaveBeenCalled(); + expect(fast.ctx.ui.confirm).not.toHaveBeenCalled(); + expect(fast.appendEntry).not.toHaveBeenCalled(); + }); + + it("copies a frozen payload for explicit default and restores a legacy inherit entry", async () => { const fast = createFastControllerFixture(); const input = Object.freeze([{ role: "user", content: "hello" }]); const payload = Object.freeze({ @@ -222,29 +225,8 @@ describe("commands and ownership", () => { preference: "inherit", requestPolicy: "untouched", }); - await fast.controller.prepare(fast.ctx); - fast.controller.restore(fast.ctx); - expect(fast.appendEntry).toHaveBeenCalledOnce(); - expect(fast.ctx.ui.confirm).not.toHaveBeenCalled(); - expect(fast.readAuthScope).not.toHaveBeenCalled(); expect(fast.fetchCatalog).not.toHaveBeenCalled(); - }); - - it("restores a legacy inherit entry after the selection leaves direct scope without writing", async () => { - const fast = createFastControllerFixture(); - await fast.controller.handle("off", fast.ctx); - fast.ctx.modelRegistry.isUsingOAuth.mockReturnValue(false); - fast.ctx.sessionManager.getBranch.mockReturnValue([choice("off"), choice("inherit")]); - fast.controller.restore(fast.ctx); - expect(fast.controller.getStatus(fast.ctx)).toMatchObject({ - preference: "inherit", - requestPolicy: "outside scope", - }); - expect(fast.controller.transform({ model: model.id }, fast.ctx)).toBeUndefined(); - expect(fast.appendEntry).toHaveBeenCalledOnce(); expect(fast.ctx.ui.confirm).not.toHaveBeenCalled(); - expect(fast.readAuthScope).not.toHaveBeenCalled(); - expect(fast.fetchCatalog).not.toHaveBeenCalled(); }); it.each(["on", "off"])( @@ -264,17 +246,13 @@ describe("commands and ownership", () => { model: model.id, service_tier: tier, }); - expect(fast.controller.getStatus(fast.ctx)).toMatchObject({ - conflict: false, - requestPolicy: tier, - }); + expect(fast.controller.getStatus(fast.ctx)).toMatchObject({ conflict: false }); }, ); - it("toggle confirms only transitions to on and leaves off intact if the next confirmation is declined", async () => { + it("toggle confirms only transitions to on and preserves off if confirmation is declined", async () => { const fast = createFastControllerFixture(); await fast.controller.handle("toggle", fast.ctx); - expect(fast.ctx.ui.confirm).toHaveBeenCalledOnce(); expect(fast.controller.transform({ model: model.id }, fast.ctx)?.service_tier).toBe("priority"); await fast.controller.handle("toggle", fast.ctx); expect(fast.ctx.ui.confirm).toHaveBeenCalledOnce(); @@ -289,25 +267,56 @@ describe("commands and ownership", () => { expect(fast.controller.transform({ model: model.id }, fast.ctx)?.service_tier).toBe("default"); expect(fast.fetchCatalog).toHaveBeenCalledOnce(); }); + + it("honors server Retry-After only for explicit enable attempts, without automatic retries", async () => { + const fast = createFastControllerFixture(); + fast.fetchCatalog.mockRejectedValueOnce( + new CodexAccountError("http", "Codex account service is rate limited.", 60_000), + ); + await fast.controller.handle("on", fast.ctx); + expect(fast.ctx.ui.notify).toHaveBeenLastCalledWith( + "Codex account service is rate limited. Preference unchanged.", + "warning", + ); + await fast.controller.handle("on", fast.ctx); + expect(fast.fetchCatalog).toHaveBeenCalledOnce(); + expect(fast.ctx.ui.confirm).toHaveBeenCalledOnce(); + expect(fast.ctx.ui.notify).toHaveBeenLastCalledWith( + expect.stringContaining("Try fast on again later"), + "warning", + ); + fast.clock.now += 60_000; + expect(fast.controller.transform({ model: model.id }, fast.ctx)).toBeUndefined(); + expect(fast.fetchCatalog).toHaveBeenCalledOnce(); + await fast.controller.handle("on", fast.ctx); + expect(fast.fetchCatalog).toHaveBeenCalledTimes(2); + expect(fast.controller.transform({ model: model.id }, fast.ctx)?.service_tier).toBe("priority"); + }); + + it("does not expose unexpected discovery errors or change an existing off preference", async () => { + const fast = createFastControllerFixture(); + await fast.controller.handle("off", fast.ctx); + fast.fetchCatalog.mockRejectedValueOnce(new Error("private failure details must not appear")); + await fast.controller.handle("on", fast.ctx); + expect(fast.ctx.ui.notify).toHaveBeenLastCalledWith( + "Could not enable Codex fast mode. Preference unchanged.", + "warning", + ); + expect(fast.controller.transform({ model: model.id }, fast.ctx)?.service_tier).toBe("default"); + expect(fast.appendEntry).toHaveBeenCalledOnce(); + }); }); -describe("branch replay and preparation", () => { - it("replays only the current branch, separates model choices, and ignores context choices", async () => { +describe("durable branch preferences", () => { + it("replays the active branch immediately without discovery, keeping model and context choices separate", () => { const fast = createFastControllerFixture(); fast.ctx.sessionManager.getBranch.mockReturnValue([ choice("off"), choice("on"), choice("off", otherModel), - { - ...choice("off"), - customType: CONTEXT_ENTRY, - data: { provider: model.provider, modelId: model.id, extended: true, mode: "off" }, - }, + { ...choice("off"), customType: CONTEXT_ENTRY }, ]); fast.controller.restore(fast.ctx); - expect(fast.readAuthScope).not.toHaveBeenCalled(); - expect(fast.controller.transform({ model: model.id }, fast.ctx)).toBeUndefined(); - await fast.controller.prepare(fast.ctx); expect(fast.controller.transform({ model: model.id }, fast.ctx)?.service_tier).toBe("priority"); fast.ctx.model = otherModel; expect(fast.controller.transform({ model: otherModel.id }, fast.ctx)?.service_tier).toBe( @@ -318,171 +327,60 @@ describe("branch replay and preparation", () => { expect(fast.controller.transform({ model: otherModel.id }, fast.ctx)).toBeUndefined(); fast.ctx.model = model; expect(fast.controller.transform({ model: model.id }, fast.ctx)).toBeUndefined(); - await fast.controller.prepare(fast.ctx); - expect(fast.readAuthScope).toHaveBeenCalledOnce(); - expect(fast.fetchCatalog).toHaveBeenCalledOnce(); + expect(fast.fetchCatalog).not.toHaveBeenCalled(); + expect(fast.ctx.modelRegistry.getProviderAuth).not.toHaveBeenCalled(); expect(fast.appendEntry).not.toHaveBeenCalled(); expect(fast.ctx.ui.confirm).not.toHaveBeenCalled(); }); - it("lets fresh on expire at the TTL without hook IO or erasing the suspended preference", async () => { + it("keeps priority after five minutes, days, invalidation and reload without any preflight", async () => { const fast = createFastControllerFixture(); await fast.controller.handle("on", fast.ctx); - fast.clock.now += 5 * 60_000 - 1; - expect(fast.controller.transform({ model: model.id }, fast.ctx)?.service_tier).toBe("priority"); - fast.clock.now += 1; - expect(fast.controller.transform({ model: model.id }, fast.ctx)).toBeUndefined(); - fast.controller.observe( - stream({ type: "response.completed", response: { service_tier: "default" } }), - fast.ctx, - ); - expect(fast.controller.getStatus(fast.ctx)).toMatchObject({ - preference: "on", - eligibility: "unknown", - requestPolicy: "suspended", - }); - expect(fast.readAuthScope).toHaveBeenCalledOnce(); - expect(fast.fetchCatalog).toHaveBeenCalledOnce(); - expect(fast.appendEntry).toHaveBeenCalledOnce(); - await fast.controller.prepare(fast.ctx); - expect(fast.fetchCatalog).toHaveBeenCalledTimes(2); - expect(fast.controller.transform({ model: model.id }, fast.ctx)?.service_tier).toBe("priority"); - expect(fast.appendEntry).toHaveBeenCalledOnce(); - expect(fast.ctx.ui.confirm).toHaveBeenCalledOnce(); - }); - - it("prepares only idle saved-on input, checking auth even when the catalog cache is fresh", async () => { - const fast = createFastControllerFixture(); - for (const branch of [[], [choice("off")], [choice("inherit")]]) { - fast.ctx.sessionManager.getBranch.mockReturnValue(branch); - fast.controller.restore(fast.ctx); - await fast.controller.prepare(fast.ctx); - } - expect(fast.readAuthScope).not.toHaveBeenCalled(); - fast.ctx.sessionManager.getBranch.mockReturnValue([choice("on")]); - fast.controller.restore(fast.ctx); + fast.fetchCatalog.mockRejectedValue(new Error("Catalog must not be needed again")); + fast.ctx.modelRegistry.getProviderAuth.mockRejectedValue(new Error("No auth preflight")); fast.ctx.isIdle.mockReturnValue(false); - await fast.controller.prepare(fast.ctx); - expect(fast.readAuthScope).not.toHaveBeenCalled(); - fast.ctx.isIdle.mockReturnValue(true); - await fast.controller.prepare(fast.ctx); - await fast.controller.prepare(fast.ctx); - expect(fast.readAuthScope).toHaveBeenCalledTimes(2); - expect(fast.fetchCatalog).toHaveBeenCalledOnce(); - expect(fast.controller.transform({ model: model.id }, fast.ctx)?.service_tier).toBe("priority"); - expect(fast.ctx.ui.confirm).not.toHaveBeenCalled(); - expect(fast.appendEntry).not.toHaveBeenCalled(); - }); - - it("invalidates a fresh catalog on auth scope change and recatalogs before resuming", async () => { - const fast = createFastControllerFixture(); - fast.ctx.sessionManager.getBranch.mockReturnValue([choice("on")]); - fast.controller.restore(fast.ctx); - await fast.controller.prepare(fast.ctx); - fast.readAuthScope.mockResolvedValue("scope-b"); - fast.fetchCatalog.mockResolvedValue({ - authScope: "scope-b", - data: { models: [{ slug: model.id, service_tiers: [{ id: "default" }] }] }, - }); - await fast.controller.prepare(fast.ctx); - expect(fast.fetchCatalog).toHaveBeenCalledTimes(2); - expect(fast.controller.transform({ model: model.id }, fast.ctx)).toBeUndefined(); - expect(fast.controller.getStatus(fast.ctx)).toMatchObject({ - preference: "on", - eligibility: "unsupported", - requestPolicy: "suspended", - }); - fast.readAuthScope.mockResolvedValue("scope-c"); - fast.fetchCatalog.mockResolvedValue({ ...catalog, authScope: "scope-c" }); - await fast.controller.prepare(fast.ctx); - expect(fast.fetchCatalog).toHaveBeenCalledTimes(3); - expect(fast.controller.transform({ model: model.id }, fast.ctx)?.service_tier).toBe("priority"); - expect(fast.appendEntry).not.toHaveBeenCalled(); - }); - - it("rejects a catalog fetched under a different auth scope, preserving the preference for a retry", async () => { - const fast = createFastControllerFixture(); - fast.ctx.sessionManager.getBranch.mockReturnValue([choice("on")]); - fast.controller.restore(fast.ctx); - fast.fetchCatalog.mockResolvedValueOnce({ ...catalog, authScope: "scope-b" }); - await fast.controller.prepare(fast.ctx); - expect(fast.ctx.ui.notify).toHaveBeenLastCalledWith( - expect.stringContaining("authentication changed during discovery"), - "warning", - ); - expect(fast.controller.transform({ model: model.id }, fast.ctx)).toBeUndefined(); - await fast.controller.prepare(fast.ctx); - expect(fast.fetchCatalog).toHaveBeenCalledTimes(2); - expect(fast.controller.transform({ model: model.id }, fast.ctx)?.service_tier).toBe("priority"); - expect(fast.appendEntry).not.toHaveBeenCalled(); - }); - - it("honors 429 backoff without timers or hook retries and resumes only on later input", async () => { - const fast = createFastControllerFixture(); - fast.ctx.sessionManager.getBranch.mockReturnValue([choice("on")]); - fast.controller.restore(fast.ctx); - fast.fetchCatalog.mockRejectedValueOnce( - new CodexAccountError("http", "Codex account service is rate limited.", 60_000), - ); - await fast.controller.prepare(fast.ctx); - expect(fast.ctx.ui.notify).toHaveBeenLastCalledWith( - expect.stringContaining("rate limited. Preference unchanged."), - "warning", - ); - await fast.controller.prepare(fast.ctx); - expect(fast.fetchCatalog).toHaveBeenCalledOnce(); - expect(fast.ctx.ui.notify).toHaveBeenLastCalledWith( - expect.stringContaining("Try again later"), - "warning", - ); - fast.clock.now += 60_000; - expect(fast.controller.transform({ model: model.id }, fast.ctx)).toBeUndefined(); - fast.controller.observe( - stream({ type: "response.completed", response: { service_tier: "priority" } }), - fast.ctx, - ); - expect(fast.controller.getStatus(fast.ctx)).toMatchObject({ - preference: "on", - requestPolicy: "suspended", - lastObserved: "priority", - }); - expect(fast.fetchCatalog).toHaveBeenCalledOnce(); - expect(fast.readAuthScope).toHaveBeenCalledTimes(2); - await fast.controller.prepare(fast.ctx); - expect(fast.fetchCatalog).toHaveBeenCalledTimes(2); - expect(fast.controller.transform({ model: model.id }, fast.ctx)?.service_tier).toBe("priority"); - expect(fast.appendEntry).not.toHaveBeenCalled(); - }); - - it.each(["auth", "catalog"])( - "does not display unexpected %s errors or clear a saved-on preference", - async (source) => { - const fast = createFastControllerFixture(); - fast.ctx.sessionManager.getBranch.mockReturnValue([choice("on")]); - fast.controller.restore(fast.ctx); - await fast.controller.prepare(fast.ctx); - const error = new Error("private failure details must not appear"); - if (source === "auth") fast.readAuthScope.mockRejectedValueOnce(error); - else { - fast.clock.now += 5 * 60_000; - fast.fetchCatalog.mockRejectedValueOnce(error); - } - await fast.controller.prepare(fast.ctx); - expect(fast.ctx.ui.notify).toHaveBeenLastCalledWith( - "Fast mode suspended: Could not prepare Codex fast mode. Preference unchanged.", - "warning", + for (const elapsed of [5 * 60_000, 24 * 60 * 60_000]) { + fast.clock.now += elapsed; + expect(fast.controller.transform({ model: model.id }, fast.ctx)?.service_tier).toBe( + "priority", ); - expect(fast.controller.transform({ model: model.id }, fast.ctx)).toBeUndefined(); - expect(fast.controller.getStatus(fast.ctx)).toMatchObject({ - preference: "on", - requestPolicy: "suspended", - }); - expect(fast.appendEntry).not.toHaveBeenCalled(); - }, - ); + expect(fast.controller.getStatus(fast.ctx)).toMatchObject({ requestPolicy: "priority" }); + } + fast.controller.invalidate(); + expect(fast.controller.transform({ model: model.id }, fast.ctx)?.service_tier).toBe("priority"); + fast.ctx.sessionManager.getBranch.mockReturnValue([choice("on")]); + const reloaded = new FastController( + { appendEntry: fast.appendEntry }, + "eleith:codex", + fast.fetchCatalog, + ); + reloaded.restore(fast.ctx); + expect(reloaded.transform({ model: model.id }, fast.ctx)?.service_tier).toBe("priority"); + expect(fast.fetchCatalog).toHaveBeenCalledOnce(); + expect(fast.ctx.modelRegistry.getProviderAuth).not.toHaveBeenCalled(); + expect(fast.ctx.ui.confirm).toHaveBeenCalledOnce(); + expect(fast.ctx.ui.notify).toHaveBeenCalledOnce(); + }); + + it("keeps saved on while outside scope and resumes it when the direct selection returns", () => { + const fast = createFastControllerFixture(); + fast.ctx.sessionManager.getBranch.mockReturnValue([choice("on")]); + fast.controller.restore(fast.ctx); + fast.ctx.modelRegistry.isUsingOAuth.mockReturnValue(false); + expect(fast.controller.getStatus(fast.ctx)).toMatchObject({ + preference: "on", + requestPolicy: "outside scope", + }); + expect(fast.controller.transform({ model: model.id }, fast.ctx)).toBeUndefined(); + fast.ctx.modelRegistry.isUsingOAuth.mockReturnValue(true); + fast.controller.invalidate(); + expect(fast.controller.transform({ model: model.id }, fast.ctx)?.service_tier).toBe("priority"); + expect(fast.fetchCatalog).not.toHaveBeenCalled(); + expect(fast.appendEntry).not.toHaveBeenCalled(); + }); }); -describe("asynchronous ownership", () => { +describe("asynchronous enable ownership", () => { it.each(["model", "session", "leaf"])( "discards confirmation after the %s changes", async (changed) => { @@ -490,12 +388,9 @@ describe("asynchronous ownership", () => { const confirmation = deferred(); fast.ctx.ui.confirm.mockReturnValue(confirmation.promise); const enabling = fast.controller.handle("on", fast.ctx); - if (changed === "model") fast.ctx.model = otherModel; - if (changed === "session") fast.ctx.sessionManager.getSessionId.mockReturnValue("session-b"); - if (changed === "leaf") fast.ctx.sessionManager.getLeafId.mockReturnValue("leaf-b"); + changeSelection(fast, changed); confirmation.resolve(true); await enabling; - expect(fast.readAuthScope).not.toHaveBeenCalled(); expect(fast.fetchCatalog).not.toHaveBeenCalled(); expect(fast.appendEntry).not.toHaveBeenCalled(); expect(fast.ctx.ui.notify).not.toHaveBeenCalled(); @@ -514,9 +409,7 @@ describe("asynchronous ownership", () => { }); const enabling = fast.controller.handle("on", fast.ctx); await started.promise; - if (changed === "model") fast.ctx.model = otherModel; - if (changed === "session") fast.ctx.sessionManager.getSessionId.mockReturnValue("session-b"); - if (changed === "leaf") fast.ctx.sessionManager.getLeafId.mockReturnValue("leaf-b"); + changeSelection(fast, changed); response.resolve(catalog); await enabling; expect(fast.appendEntry).not.toHaveBeenCalled(); @@ -559,113 +452,62 @@ describe("asynchronous ownership", () => { }); }); - it.each(["resolve", "reject"])( - "shutdown ignores late confirmation %s before touching expired host getters", - async (outcome) => { - const fast = createFastControllerFixture(); - const confirmation = deferred(); - fast.ctx.ui.confirm.mockReturnValue(confirmation.promise); - const enabling = fast.controller.handle("on", fast.ctx); - const signal = fast.ctx.ui.confirm.mock.calls[0][2]!.signal!; - const notify = fast.ctx.ui.notify; - fast.controller.dispose(); - fast.controller.dispose(); - for (const key of ["model", "modelRegistry", "isIdle", "sessionManager", "ui", "hasUI"]) { - Object.defineProperty(fast.ctx, key, { - get: () => { - throw new Error(`Expired ${key}`); - }, + it.each(["confirmation", "catalog"])( + "shutdown ignores late %s results before touching expired host getters", + async (stage) => { + for (const outcome of ["resolve", "reject"]) { + const fast = createFastControllerFixture(); + const confirmation = deferred(); + const response = deferred(); + const started = deferred(); + if (stage === "confirmation") fast.ctx.ui.confirm.mockReturnValue(confirmation.promise); + fast.fetchCatalog.mockImplementationOnce(() => { + started.resolve(); + return response.promise; }); + const enabling = fast.controller.handle("on", fast.ctx); + if (stage === "catalog") await started.promise; + const signal = + stage === "confirmation" + ? fast.ctx.ui.confirm.mock.calls[0][2]!.signal! + : fast.fetchCatalog.mock.calls[0][1]!; + const notify = fast.ctx.ui.notify; + fast.controller.dispose(); + fast.controller.dispose(); + for (const key of ["model", "modelRegistry", "isIdle", "sessionManager", "ui", "hasUI"]) { + Object.defineProperty(fast.ctx, key, { + get: () => { + throw new Error(`Expired ${key}`); + }, + }); + } + if (stage === "confirmation") { + if (outcome === "resolve") confirmation.resolve(true); + else confirmation.reject(new Error("private late failure")); + } else { + if (outcome === "resolve") response.resolve(catalog); + else response.reject(new CodexAccountError("http", "private late failure", 60_000)); + } + await enabling; + expect(signal.aborted).toBe(true); + expect(notify).not.toHaveBeenCalled(); + expect(fast.appendEntry).not.toHaveBeenCalled(); + expect(fast.controller.getStatus(fast.ctx)).toBeUndefined(); + await fast.controller.handle("on", fast.ctx); + fast.controller.restore(fast.ctx); + expect(fast.controller.transform({ model: model.id }, fast.ctx)).toBeUndefined(); + fast.controller.observe(stream({ type: "response.completed" }), fast.ctx); } - if (outcome === "resolve") confirmation.resolve(true); - else confirmation.reject(new Error("private late failure")); - await enabling; - expect(signal.aborted).toBe(true); - expect(notify).not.toHaveBeenCalled(); - expect(fast.readAuthScope).not.toHaveBeenCalled(); - expect(fast.fetchCatalog).not.toHaveBeenCalled(); - expect(fast.appendEntry).not.toHaveBeenCalled(); - expect(fast.controller.getStatus(fast.ctx)).toBeUndefined(); - await fast.controller.handle("on", fast.ctx); - await fast.controller.prepare(fast.ctx); - fast.controller.restore(fast.ctx); - expect(fast.controller.transform({ model: model.id }, fast.ctx)).toBeUndefined(); - fast.controller.observe(stream({ type: "response.completed" }), fast.ctx); }, ); - - it.each(["resolve", "reject"])( - "shutdown ignores late catalog %s without accessing the expired host", - async (outcome) => { - const fast = createFastControllerFixture(); - const response = deferred(); - const started = deferred(); - fast.fetchCatalog.mockImplementationOnce(() => { - started.resolve(); - return response.promise; - }); - const enabling = fast.controller.handle("on", fast.ctx); - await started.promise; - const signal = fast.fetchCatalog.mock.calls[0][1]!; - const notify = fast.ctx.ui.notify; - fast.controller.dispose(); - for (const key of ["model", "modelRegistry", "isIdle", "sessionManager", "ui"]) { - Object.defineProperty(fast.ctx, key, { - get: () => { - throw new Error(`Expired ${key}`); - }, - }); - } - if (outcome === "resolve") response.resolve(catalog); - else - response.reject( - new CodexAccountError("http", "Codex account service is rate limited.", 60_000), - ); - await enabling; - expect(signal.aborted).toBe(true); - expect(notify).not.toHaveBeenCalled(); - expect(fast.appendEntry).not.toHaveBeenCalled(); - }, - ); - - it("shutdown stops a saved-on auth check before any late catalog IO or host access", async () => { - const fast = createFastControllerFixture(); - fast.ctx.sessionManager.getBranch.mockReturnValue([choice("on")]); - fast.controller.restore(fast.ctx); - const scope = deferred(); - fast.readAuthScope.mockReturnValueOnce(scope.promise); - const preparing = fast.controller.prepare(fast.ctx); - const signal = fast.readAuthScope.mock.calls[0][1]!; - const notify = fast.ctx.ui.notify; - fast.controller.dispose(); - for (const key of ["model", "modelRegistry", "isIdle", "sessionManager", "ui"]) { - Object.defineProperty(fast.ctx, key, { - get: () => { - throw new Error(`Expired ${key}`); - }, - }); - } - scope.resolve("scope-a"); - await preparing; - expect(signal.aborted).toBe(true); - expect(fast.fetchCatalog).not.toHaveBeenCalled(); - expect(notify).not.toHaveBeenCalled(); - expect(fast.appendEntry).not.toHaveBeenCalled(); - }); }); describe("provider stream observations", () => { - it("reports an observed default independently of the priority payload and next-request policy", async () => { + it("reports observed default independently of the priority request without disabling future requests", async () => { const fast = createFastControllerFixture(); await fast.controller.handle("on", fast.ctx); const payload = Object.freeze({ model: model.id, input: [], service_tier: "priority" }); - const transformed = fast.controller.transform(payload, fast.ctx); - expect(transformed).toBeUndefined(); - expect(transformed ?? payload).toEqual({ - model: model.id, - input: [], - service_tier: "priority", - }); + expect(fast.controller.transform(payload, fast.ctx)).toBeUndefined(); fast.controller.observe( stream({ type: "response.completed", response: { service_tier: "default" } }), fast.ctx, @@ -677,7 +519,6 @@ describe("provider stream observations", () => { }); expect(fast.controller.transform({ model: model.id }, fast.ctx)?.service_tier).toBe("priority"); expect(fast.fetchCatalog).toHaveBeenCalledOnce(); - expect(fast.readAuthScope).toHaveBeenCalledOnce(); }); it.each([ @@ -686,10 +527,7 @@ describe("provider stream observations", () => { ])("reports missing or unrecognized terminal tier metadata as unknown: %j", async (data) => { const fast = createFastControllerFixture(); await fast.controller.handle("off", fast.ctx); - expect(fast.controller.transform({ model: model.id }, fast.ctx)).toEqual({ - model: model.id, - service_tier: "default", - }); + fast.controller.transform({ model: model.id }, fast.ctx); fast.controller.observe( stream({ type: "response.completed", response: { service_tier: "default" } }), fast.ctx, @@ -699,17 +537,13 @@ describe("provider stream observations", () => { requestPolicy: "default", lastObserved: undefined, }); - expect(fast.readAuthScope).not.toHaveBeenCalled(); expect(fast.fetchCatalog).not.toHaveBeenCalled(); }); - it("ignores wrong-model and nonterminal events, and does not show delivery from another session", async () => { + it("ignores wrong-model and nonterminal events, and delivery from another session", async () => { const fast = createFastControllerFixture(); await fast.controller.handle("off", fast.ctx); - expect(fast.controller.transform({ model: model.id }, fast.ctx)).toEqual({ - model: model.id, - service_tier: "default", - }); + fast.controller.transform({ model: model.id }, fast.ctx); fast.controller.observe( stream({ type: "response.completed", response: { service_tier: "default" } }), fast.ctx, @@ -795,15 +629,19 @@ function createFastControllerFixture() { } satisfies FastHost; const appendEntry = vi.fn(); const fetchCatalog = vi.fn(async () => catalog); - const readAuthScope = vi.fn(async () => "scope-a"); const controller = new FastController( { appendEntry }, "eleith:codex", fetchCatalog, - readAuthScope, () => clock.now, ); - return { controller, ctx, appendEntry, fetchCatalog, readAuthScope, clock }; + return { controller, ctx, appendEntry, fetchCatalog, clock }; +} + +function changeSelection(fast: ReturnType, changed: string) { + if (changed === "model") fast.ctx.model = otherModel; + if (changed === "session") fast.ctx.sessionManager.getSessionId.mockReturnValue("session-b"); + if (changed === "leaf") fast.ctx.sessionManager.getLeafId.mockReturnValue("leaf-b"); } function stream(data: unknown): ProviderStreamEvent { diff --git a/extensions/codex/src/fast.ts b/extensions/codex/src/fast.ts index 3de9d71..22d877a 100644 --- a/extensions/codex/src/fast.ts +++ b/extensions/codex/src/fast.ts @@ -3,13 +3,8 @@ import type { ExtensionContext, ProviderStreamEvent, } from "@earendil-works/pi-coding-agent"; -import { - CodexAccountError, - fetchCodexModelCatalog, - readCodexAuthScope, - type CodexAccountRegistry, -} from "./client.ts"; -import { codexFastEligibility, type FastEligibility } from "./capabilities.ts"; +import { CodexAccountError, fetchCodexModelCatalog, type CodexAccountRegistry } from "./client.ts"; +import { codexFastEligibility } from "./capabilities.ts"; import { FAST_ENTRY, readFastPreferences, @@ -29,8 +24,6 @@ import { type DirectCodexHost, } from "./request-scope.ts"; -const CAPABILITY_TTL_MS = 5 * 60_000; - export interface FastHost extends DirectCodexHost { readonly modelRegistry: CodexAccountRegistry & Pick< @@ -55,13 +48,6 @@ interface EligibilityPreparation { baseUrl: string; } -interface CachedEligibility { - modelKey: string; - authScope: string; - eligibility: FastEligibility; - expiresAt: number; -} - interface ModelTierActivity { modelKey: string; sessionId: string; @@ -71,7 +57,6 @@ interface ModelTierActivity { export class FastController { private preferences = new Map(); - private cachedEligibility: CachedEligibility | undefined; private pendingPreparation: EligibilityPreparation | undefined; private modelTierActivity: ModelTierActivity | undefined; // Honor endpoint Retry-After across model/branch changes; account switching is not supported. @@ -82,7 +67,6 @@ export class FastController { private readonly pi: Pick, private readonly commandName: string, private readonly fetchCatalog: typeof fetchCodexModelCatalog = fetchCodexModelCatalog, - private readonly readAuthScope: typeof readCodexAuthScope = readCodexAuthScope, private readonly now: () => number = Date.now, ) {} @@ -106,11 +90,8 @@ export class FastController { activity.sessionId !== ctx.sessionManager.getSessionId() ) activity = undefined; - let eligibility: FastEligibility = "unknown"; - if (direct) eligibility = this.currentEligibility(selectedModelKey); return { preference: this.preferenceFor(selectedModelKey), - eligibility, requestPolicy: this.requestPolicy(ctx), lastObserved: activity?.observed, conflict: activity?.conflict ?? false, @@ -150,6 +131,10 @@ export class FastController { this.report(ctx); return; } + if (this.preferenceFor(modelKey(model)) === "on") { + this.report(ctx); + return; + } if (!ctx.hasUI) { ctx.ui.notify( "Enabling fast mode requires interactive confirmation of higher allowance consumption.", @@ -160,22 +145,6 @@ export class FastController { await this.enable(ctx); } - // Only ordinary idle input prepares saved on choices. Never fetch from request/stream hooks. - async prepare(ctx: FastHost): Promise { - if (this.disposed || this.pendingPreparation || !ctx.isIdle()) return; - const model = ctx.model; - if (!model || this.preferenceFor(modelKey(model)) !== "on" || !isDirectCodexSelection(ctx)) - return; - const preparation = this.startPreparation(ctx); - try { - await this.refreshEligibility(ctx, preparation, false); - } catch (error) { - this.reportPreparationFailure(error, ctx, preparation); - } finally { - if (this.pendingPreparation === preparation) this.pendingPreparation = undefined; - } - } - transform(payload: unknown, ctx: FastHost): Record | undefined { if (this.disposed) return; if (!matchesDirectCodexSelection(payload, ctx) || !ctx.model) { @@ -224,7 +193,6 @@ export class FastController { invalidate(): void { this.pendingPreparation?.abortController.abort(); this.pendingPreparation = undefined; - this.cachedEligibility = undefined; this.modelTierActivity = undefined; } @@ -236,6 +204,10 @@ export class FastController { } private async enable(ctx: FastHost): Promise { + if (this.now() < this.retryAt) { + ctx.ui.notify("The catalog service is rate limited. Try fast on again later.", "warning"); + return; + } const preparation = this.startPreparation(ctx); try { const confirmed = await ctx.ui.confirm( @@ -244,8 +216,8 @@ export class FastController { { signal: preparation.abortController.signal }, ); if (!this.canApplyPreparation(ctx, preparation) || !confirmed) return; - const eligibility = await this.refreshEligibility(ctx, preparation, true); - if (!this.canApplyPreparation(ctx, preparation) || eligibility !== "supported") return; + const supported = await this.checkEligibility(ctx, preparation); + if (!this.canApplyPreparation(ctx, preparation) || !supported) return; this.rememberChoice(ctx, "on"); this.pendingPreparation = undefined; this.report(ctx); @@ -256,57 +228,21 @@ export class FastController { } } - private async refreshEligibility( + // Discovery belongs only to the explicit enable command, never to prompt submission or replay. + private async checkEligibility( ctx: FastHost, preparation: EligibilityPreparation, - force: boolean, - ): Promise { - const signal = preparation.abortController.signal; - const authScope = await this.readAuthScope(ctx.modelRegistry, signal); - if (!this.canApplyPreparation(ctx, preparation)) return; - const cached = this.cachedEligibility; - if ( - !force && - cached?.modelKey === preparation.modelKey && - cached.authScope === authScope && - cached.expiresAt > this.now() - ) - return cached.eligibility; - - this.cachedEligibility = undefined; - this.modelTierActivity = undefined; - if (this.now() < this.retryAt) { - ctx.ui.notify( - "Fast mode suspended: the catalog service is rate limited. Try again later.", - "warning", - ); - return; - } - const catalog = await this.fetchCatalog(ctx.modelRegistry, signal); - if (!this.canApplyPreparation(ctx, preparation)) return; - if (catalog.authScope !== authScope) { - ctx.ui.notify( - "Fast mode suspended: authentication changed during discovery. Try fast on again.", - "warning", - ); - return; - } - const model = ctx.model; - if (!model) return; - const eligibility = codexFastEligibility(catalog.data, model.id); - this.cachedEligibility = { - modelKey: preparation.modelKey, - authScope, - eligibility, - expiresAt: this.now() + CAPABILITY_TTL_MS, - }; - if (eligibility !== "supported") { - ctx.ui.notify( - `Fast mode suspended: model eligibility is ${eligibility}. Preference unchanged.`, - "warning", - ); - } - return eligibility; + ): Promise { + const catalog = await this.fetchCatalog(ctx.modelRegistry, preparation.abortController.signal); + if (!this.canApplyPreparation(ctx, preparation) || !ctx.model) return false; + const eligibility = codexFastEligibility(catalog.data, ctx.model.id); + if (eligibility === "supported") return true; + const reason = + eligibility === "unsupported" + ? "the model does not advertise priority processing" + : "the catalog did not establish this model's priority support"; + ctx.ui.notify(`Could not enable fast mode: ${reason}. Preference unchanged.`, "warning"); + return false; } private startPreparation(ctx: FastHost): EligibilityPreparation { @@ -351,14 +287,12 @@ export class FastController { preparation: EligibilityPreparation, ): void { if (!this.canApplyPreparation(ctx, preparation)) return; - this.cachedEligibility = undefined; - this.modelTierActivity = undefined; if (error instanceof CodexAccountError && error.retryAfterMs !== undefined) { this.retryAt = this.now() + error.retryAfterMs; } - let message = "Could not prepare Codex fast mode."; + let message = "Could not enable Codex fast mode."; if (error instanceof CodexAccountError) message = error.message; - ctx.ui.notify(`Fast mode suspended: ${message} Preference unchanged.`, "warning"); + ctx.ui.notify(`${message} Preference unchanged.`, "warning"); } private preferenceFor(selectedModelKey: string): FastPreference { @@ -377,21 +311,13 @@ export class FastController { this.modelTierActivity = undefined; } - private currentEligibility(selectedModelKey: string): FastEligibility { - const cached = this.cachedEligibility; - if (!cached || cached.modelKey !== selectedModelKey || cached.expiresAt <= this.now()) - return "unknown"; - return cached.eligibility; - } - private requestPolicy(ctx: FastHost): FastRequestPolicy { if (!ctx.model || !isDirectCodexSelection(ctx)) return "outside scope"; const selectedModelKey = modelKey(ctx.model); const preference = this.preferenceFor(selectedModelKey); if (preference === "inherit") return "untouched"; if (preference === "off") return "default"; - if (this.pendingPreparation || this.currentEligibility(selectedModelKey) !== "supported") - return "suspended"; + // A saved, confirmed choice is durable intent, not an expiring capability lease. return "priority"; } diff --git a/extensions/codex/src/index.ts b/extensions/codex/src/index.ts index f71c77a..887b2dd 100644 --- a/extensions/codex/src/index.ts +++ b/extensions/codex/src/index.ts @@ -63,7 +63,6 @@ export default async function codex(pi: ExtensionAPI): Promise { pi.on("input", async (event, ctx) => { if (event.streamingBehavior === undefined && ctx.isIdle()) { await controller.reconcile(ctx); - await fast.prepare(ctx); } }); pi.on("before_provider_request", (event, ctx) => fast.transform(event.payload, ctx));