From 4b19b7d6eef31465a6569727debd94095c3d409e Mon Sep 17 00:00:00 2001 From: eleith Date: Sun, 1 Nov 2026 11:02:24 -0800 Subject: [PATCH 1/2] fix(codex): make controls and usage easier to navigate --- .changeset/codex-ux.md | 5 + extensions/codex/src/commands-editor.test.ts | 166 ++++++ extensions/codex/src/commands.test.ts | 100 ++++ extensions/codex/src/commands.ts | 49 ++ extensions/codex/src/controller.test.ts | 16 +- extensions/codex/src/controller.ts | 52 +- extensions/codex/src/dashboard.test.ts | 68 +++ extensions/codex/src/dashboard.ts | 24 + extensions/codex/src/fast-sdk.test.ts | 109 +++- extensions/codex/src/fast-status.test.ts | 169 ++++++- extensions/codex/src/fast-status.ts | 40 ++ extensions/codex/src/fast.test.ts | 98 +++- extensions/codex/src/fast.ts | 65 ++- extensions/codex/src/index.test.ts | 87 +++- extensions/codex/src/index.ts | 27 +- extensions/codex/src/status.test.ts | 475 ++++++++++++------ extensions/codex/src/status.ts | 135 +++-- extensions/codex/src/usage-controller.test.ts | 429 +++++++++++----- extensions/codex/src/usage-controller.ts | 105 +++- extensions/codex/src/usage-sdk.test.ts | 15 +- extensions/codex/src/usage.test.ts | 239 +++++++-- extensions/codex/src/usage.ts | 90 ++-- packages/internal/src/commands.test.ts | 41 +- packages/internal/src/commands.ts | 4 +- 24 files changed, 2031 insertions(+), 577 deletions(-) create mode 100644 .changeset/codex-ux.md create mode 100644 extensions/codex/src/commands-editor.test.ts create mode 100644 extensions/codex/src/commands.test.ts create mode 100644 extensions/codex/src/commands.ts create mode 100644 extensions/codex/src/dashboard.test.ts create mode 100644 extensions/codex/src/dashboard.ts diff --git a/.changeset/codex-ux.md b/.changeset/codex-ux.md new file mode 100644 index 0000000..88b258d --- /dev/null +++ b/.changeset/codex-ux.md @@ -0,0 +1,5 @@ +--- +"@eleith-pi/codex": patch +--- + +Make Codex controls easier to use with a combined context and fast-mode dashboard, concise fast status, descriptive subcommand completion, clearer allowance reports, and temporary loading feedback for account usage requests. diff --git a/extensions/codex/src/commands-editor.test.ts b/extensions/codex/src/commands-editor.test.ts new file mode 100644 index 0000000..99f874e --- /dev/null +++ b/extensions/codex/src/commands-editor.test.ts @@ -0,0 +1,166 @@ +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + CombinedAutocompleteProvider, + Editor, + type EditorTheme, + type Terminal, + TuiMainScreen, +} from "@earendil-works/pi-tui"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { completeCodexArguments } from "./commands.ts"; + +const identity = (text: string) => text; +const theme: EditorTheme = { + borderColor: identity, + selectList: { + selectedPrefix: identity, + selectedText: identity, + description: identity, + scrollInfo: identity, + noMatch: identity, + }, +}; + +let emptyFileDirectory: string; + +beforeEach(() => { + // File completion must see a real, empty directory, not the user's cwd/home. + const cache = fileURLToPath(new URL("../../../.cache/", import.meta.url)); + mkdirSync(cache, { recursive: true }); + emptyFileDirectory = mkdtempSync(join(cache, "codex-editor-empty-")); + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); + vi.restoreAllMocks(); + rmSync(emptyFileDirectory, { recursive: true, force: true }); +}); + +it("reopens fast controls on a typed argument prefix and replaces the whole argument", async () => { + const { editor, getArgumentCompletions, getSuggestions } = createEditor(); + await acceptCodexCommand(editor); + + typeText(editor, "fast"); + await vi.waitFor( + () => { + expect(getArgumentCompletions).toHaveBeenLastCalledWith("fast"); + expect(editor.isShowingAutocomplete()).toBe(true); + }, + { timeout: 1_000 }, + ); + editor.handleInput("\t"); + expect(editor.getText()).toBe("/eleith:codex fast"); + expect(editor.isShowingAutocomplete()).toBe(false); + + const lookupsBeforeSpace = getSuggestions.mock.calls.length; + editor.handleInput(" "); + await vi.advanceTimersByTimeAsync(200); + expect(editor.getText()).toBe("/eleith:codex fast "); + expect(editor.isShowingAutocomplete()).toBe(false); + expect(getSuggestions).toHaveBeenCalledTimes(lookupsBeforeSpace); + + editor.handleInput("o"); + await vi.waitFor( + () => { + expect(getArgumentCompletions).toHaveBeenLastCalledWith("fast o"); + expect(editor.isShowingAutocomplete()).toBe(true); + }, + { timeout: 1_000 }, + ); + + // Editor exposes menu visibility, not its items. Observe the real provider's + // public result and the rendered list instead of inspecting editor internals. + const lookup = getSuggestions.mock.results.at(-1); + if (lookup?.type !== "return") throw new Error("Native suggestion lookup did not return"); + const suggestions = await lookup.value; + expect(suggestions?.prefix).toBe("fast o"); + expect(suggestions?.items.map(({ value, label }) => ({ value, label }))).toEqual([ + { value: "fast on", label: "on" }, + { value: "fast off", label: "off" }, + ]); + const rendered = editor.render(120).join("\n"); + expect(rendered).toContain("Request priority processing"); + expect(rendered).toContain("Request standard processing"); + + editor.handleInput("\t"); + expect(editor.getText()).toBe("/eleith:codex fast on"); + expect(editor.isShowingAutocomplete()).toBe(false); +}); + +it("routes empty-prefix Tab through the native file gate, not Codex arguments", async () => { + const { editor, getArgumentCompletions, getSuggestions, fileCompletionGate } = createEditor(); + await acceptCodexCommand(editor); + expect(getArgumentCompletions).not.toHaveBeenCalled(); + getSuggestions.mockClear(); + fileCompletionGate.mockClear(); + + // Tab takes the forced-file path. Its native gate rejects this empty argument + // before a file lookup or the command's argument completer can run. + editor.handleInput("\t"); + expect(fileCompletionGate).toHaveBeenCalledExactlyOnceWith( + ["/eleith:codex "], + 0, + "/eleith:codex ".length, + ); + expect(fileCompletionGate).toHaveReturnedWith(false); + expect(getSuggestions).not.toHaveBeenCalled(); + expect(getArgumentCompletions).not.toHaveBeenCalled(); + expect(editor.isShowingAutocomplete()).toBe(false); + expect(editor.getText()).toBe("/eleith:codex "); +}); + +function createEditor() { + // Real TUI without terminal lifecycle, stdin, output, or scheduled rendering. + const terminal: Terminal = { + columns: 120, + rows: 40, + kittyProtocolActive: false, + start: () => { + throw new Error("This UI-only test must not start a terminal"); + }, + stop: vi.fn(), + drainInput: async () => {}, + write: vi.fn(), + moveBy: vi.fn(), + hideCursor: vi.fn(), + showCursor: vi.fn(), + clearLine: vi.fn(), + clearFromCursor: vi.fn(), + clearScreen: vi.fn(), + setTitle: vi.fn(), + setProgress: vi.fn(), + setProgramStatus: vi.fn(), + }; + const tui = new TuiMainScreen(terminal); + vi.spyOn(tui, "requestRender").mockImplementation(() => {}); + const getArgumentCompletions = vi.fn(completeCodexArguments); + const provider = new CombinedAutocompleteProvider( + [{ name: "eleith:codex", getArgumentCompletions }], + emptyFileDirectory, + ); + // Call-through spy: suggestion routing and replacement remain native Pi logic. + const getSuggestions = vi.spyOn(provider, "getSuggestions"); + const fileCompletionGate = vi.spyOn(provider, "shouldTriggerFileCompletion"); + const editor = new Editor(tui, theme); + editor.setAutocompleteProvider(provider); + return { editor, getArgumentCompletions, getSuggestions, fileCompletionGate }; +} + +function typeText(editor: Editor, text: string) { + for (const character of text) editor.handleInput(character); +} + +async function acceptCodexCommand(editor: Editor) { + typeText(editor, "/eleith:cod"); + await vi.waitFor(() => expect(editor.isShowingAutocomplete()).toBe(true), { + timeout: 1_000, + }); + editor.handleInput("\t"); + // Native command-name completion already appends a space and closes the menu. + expect(editor.getText()).toBe("/eleith:codex "); + expect(editor.isShowingAutocomplete()).toBe(false); +} diff --git a/extensions/codex/src/commands.test.ts b/extensions/codex/src/commands.test.ts new file mode 100644 index 0000000..9bf9ac1 --- /dev/null +++ b/extensions/codex/src/commands.test.ts @@ -0,0 +1,100 @@ +import { expect, it } from "vitest"; +import { CODEX_ACTIONS, completeCodexArguments } from "./commands.ts"; + +it("offers the four root actions with context, usage, and fast help", async () => { + expect(CODEX_ACTIONS).toEqual(["extend", "restore", "usage", "fast"]); + expect(await completeCodexArguments("")).toEqual([ + { + value: "extend", + label: "extend", + description: "Extend the context window for the current supported Codex model", + }, + { + value: "restore", + label: "restore", + description: "Restore the standard context window", + }, + { + value: "usage", + label: "usage", + description: "Show account usage and allowance limits", + }, + { + value: "fast", + label: "fast", + description: "Show fast mode and its controls", + }, + ]); +}); + +it.each([ + [" EX", ["extend"]], + ["\tRe", ["restore"]], + ["US", ["usage"]], + ["Fa", ["fast"]], + ["fast", ["fast"]], + [" \t ", ["extend", "restore", "usage", "fast"]], +])("completes normalized root prefix %j", async (prefix, values) => { + expect((await completeCodexArguments(prefix))?.map(({ value }) => value)).toEqual(values); +}); + +it("offers fast controls with full replacement values and helpful descriptions", async () => { + expect(await completeCodexArguments("fast ")).toEqual([ + { + value: "fast on", + label: "on", + description: "Request priority processing (uses more allowance)", + }, + { + value: "fast off", + label: "off", + description: "Request standard processing instead of priority", + }, + { + value: "fast toggle", + label: "toggle", + description: "Switch fast mode on or off", + }, + { + value: "fast status", + label: "status", + description: "Show detailed fast-mode diagnostics", + }, + { + value: "fast reset", + label: "reset", + description: "Return to the provider's default for this model", + }, + ]); + expect(await completeCodexArguments(" \tFAST\t ")).toEqual( + await completeCodexArguments("fast "), + ); +}); + +it.each([ + ["fast o", ["fast on", "fast off"]], + [" FAST\tOf", ["fast off"]], + ["fast t", ["fast toggle"]], + ["fast S", ["fast status"]], + ["fast re", ["fast reset"]], + ["fast on", ["fast on"]], +])("completes only the matching fast argument for %j", async (prefix, values) => { + expect((await completeCodexArguments(prefix))?.map(({ value }) => value)).toEqual(values); +}); + +it.each([ + "unknown", + "fast unknown", + "fa on", + "extend ", + "restore o", + "usage fast", + "fast on ", + "fast off\t", + "fast toggle extra", + "fast status ", + "fast reset on", + "fast o ", +])("does not invent further arguments for %j", async (prefix) => { + expect(await completeCodexArguments(prefix)).toBe(null); +}); diff --git a/extensions/codex/src/commands.ts b/extensions/codex/src/commands.ts new file mode 100644 index 0000000..61ea96d --- /dev/null +++ b/extensions/codex/src/commands.ts @@ -0,0 +1,49 @@ +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; + +type NativeCommand = Parameters[1]; + +export const CODEX_ACTIONS = ["extend", "restore", "usage", "fast"] as const; + +const ACTION_HELP: Record<(typeof CODEX_ACTIONS)[number], string> = { + extend: "Extend the context window for the current supported Codex model", + restore: "Restore the standard context window", + usage: "Show account usage and allowance limits", + fast: "Show fast mode and its controls", +}; + +const FAST_HELP = [ + { action: "on", description: "Request priority processing (uses more allowance)" }, + { action: "off", description: "Request standard processing instead of priority" }, + { action: "toggle", description: "Switch fast mode on or off" }, + { action: "status", description: "Show detailed fast-mode diagnostics" }, + { action: "reset", description: "Return to the provider's default for this model" }, +] as const; + +export const completeCodexArguments: NonNullable = ( + prefix, +) => { + const input = prefix.trimStart().toLowerCase(); + const split = input.search(/\s/); + if (split < 0) { + const matches = CODEX_ACTIONS.filter((action) => action.startsWith(input)); + return matches.length + ? matches.map((action) => ({ + value: action, + label: action, + description: ACTION_HELP[action], + })) + : null; + } + + if (input.slice(0, split) !== "fast") return null; + const argument = input.slice(split).trimStart(); + if (/\s/.test(argument)) return null; + const matches = FAST_HELP.filter(({ action }) => action.startsWith(argument)); + return matches.length + ? matches.map(({ action, description }) => ({ + value: `fast ${action}`, + label: action, + description, + })) + : null; +}; diff --git a/extensions/codex/src/controller.test.ts b/extensions/codex/src/controller.test.ts index c08fa1c..76e71b0 100644 --- a/extensions/codex/src/controller.test.ts +++ b/extensions/codex/src/controller.test.ts @@ -841,11 +841,9 @@ describe("ContextController", () => { `No verified extended context limit for openai-codex/${id}. Only supported Codex models can be changed.`, "warning", ); - await f.controller.handle("", f.ctx); - expect(f.notify).toHaveBeenLastCalledWith( - expect.stringMatching(/Extended +not verified/), - "info", - ); + const notifications = f.notify.mock.calls.length; + expect(f.controller.getStatus(f.ctx)?.extendedWindow).toBeUndefined(); + expect(f.notify).toHaveBeenCalledTimes(notifications); expect(f.state.current).toBe(f.catalog); expect(f.catalog).toEqual(original); expect(f.api.setModel).not.toHaveBeenCalled(); @@ -886,7 +884,11 @@ describe("ContextController", () => { expect.stringContaining("baseline is unknown"), "warning", ); - await f.controller.handle("", f.ctx); - expect(f.notify).toHaveBeenLastCalledWith(expect.stringMatching(/Standard +unknown/), "info"); + const notifications = f.notify.mock.calls.length; + expect(f.controller.getStatus(f.ctx)).toMatchObject({ + standardWindow: undefined, + extendedWindow: undefined, + }); + expect(f.notify).toHaveBeenCalledTimes(notifications); }); }); diff --git a/extensions/codex/src/controller.ts b/extensions/codex/src/controller.ts index ff6347f..e19d46a 100644 --- a/extensions/codex/src/controller.ts +++ b/extensions/codex/src/controller.ts @@ -2,7 +2,7 @@ import type { Api, Model } from "@earendil-works/pi-ai"; import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { contextProfile, modelKey } from "./profiles.ts"; import { CONTEXT_ENTRY, readPreferences, type ContextPreference } from "./state.ts"; -import { formatContextStatus } from "./status.ts"; +import type { ContextStatus } from "./status.ts"; // Small public ports: no provider calls, catalog refresh, or settings-file reads. export type ContextAPI = Pick< @@ -92,10 +92,6 @@ export class ContextController { async handle(args: string, ctx: ContextHost): Promise { if (this.disposed) return; const action = args.trim().toLowerCase(); - if (!action) { - this.status(ctx); - return; - } if (action !== "extend" && action !== "restore") { ctx.ui.notify(`Usage: /${this.commandName} [extend|restore]`, "info"); return; @@ -115,6 +111,27 @@ export class ContextController { this.preferences.clear(); } + getStatus(ctx: ContextHost): ContextStatus | undefined { + if (this.disposed) return undefined; + const model = ctx.model; + if (!model) return undefined; + const profile = contextProfile(model); + const standardWindow = baseline(ctx, model); + let extendedWindow: number | undefined; + if (profile && standardWindow !== undefined) { + extendedWindow = Math.max(standardWindow, profile.extendedWindow); + } + return { + model, + usage: ctx.getContextUsage(), + standardWindow, + extendedWindow, + desiredExtended: this.preferences.get(modelKey(model)), + replayPaused: this.replayPaused, + branch: ctx.sessionManager.getBranch(), + }; + } + private async change(ctx: ContextHost, extended: boolean, command: boolean): Promise { const current = ctx.model; const profile = contextProfile(current); @@ -189,31 +206,6 @@ export class ContextController { this.preferences.set(key, extended); } - private status(ctx: ContextHost): void { - const model = ctx.model; - if (!model) { - ctx.ui.notify("No model selected.", "info"); - return; - } - const profile = contextProfile(model); - const standardWindow = baseline(ctx, model); - ctx.ui.notify( - formatContextStatus({ - model, - usage: ctx.getContextUsage(), - standardWindow, - extendedWindow: - profile && standardWindow !== undefined - ? Math.max(standardWindow, profile.extendedWindow) - : undefined, - desiredExtended: this.preferences.get(modelKey(model)), - replayPaused: this.replayPaused, - branch: ctx.sessionManager.getBranch(), - }), - "info", - ); - } - private usageAllowsReduction(ctx: ContextHost, target: number): boolean { const usage = ctx.getContextUsage()?.tokens; if (typeof usage === "number" && Number.isFinite(usage) && usage > target) { diff --git a/extensions/codex/src/dashboard.test.ts b/extensions/codex/src/dashboard.test.ts new file mode 100644 index 0000000..f91d2bd --- /dev/null +++ b/extensions/codex/src/dashboard.test.ts @@ -0,0 +1,68 @@ +import type { Api, Model } from "@earendil-works/pi-ai"; +import { expect, it } from "vitest"; +import { formatCodexDashboard } from "./dashboard.ts"; +import type { ContextStatus } from "./status.ts"; +import type { FastStatus } from "./fast-status.ts"; + +const model: Model = { + id: "gpt-6-sol", + name: "Sol", + provider: "openai-codex", + api: "openai-codex-responses", + baseUrl: "https://chatgpt.com/backend-api", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 272_000, + maxTokens: 128_000, +}; +const context: ContextStatus = { + model, + usage: { tokens: 40_000, contextWindow: 272_000, percent: 14.7 }, + standardWindow: 272_000, + extendedWindow: 922_000, + branch: [], +}; +const inherited: FastStatus = { + preference: "inherit", + eligibility: "unknown", + requestPolicy: "untouched", + lastRequested: undefined, + lastObserved: undefined, + conflict: false, +}; + +it("puts fast and context state together, with discoverable configured commands", () => { + const original = structuredClone({ context, inherited }); + const text = formatCodexDashboard(context, inherited, "personal:codex"); + expect(text).toContain("Codex · openai-codex/gpt-6-sol"); + expect(text).toContain("Fast: Provider default (no override)"); + expect(text).toContain("Context: 40k / 272k tokens (standard)"); + expect(text).toContain("/personal:codex usage — account allowance"); + expect(text).toContain( + '/personal:codex fast — priority controls (type "fast o" for on/off suggestions)', + ); + expect(text).not.toContain("/eleith:codex"); + expect(text).not.toContain("Eligibility:"); + expect(text).not.toContain("Tab for options"); + expect({ context, inherited }).toEqual(original); +}); + +it("does not present requested priority as confirmed server processing", () => { + const fast: FastStatus = { + ...inherited, + preference: "on", + eligibility: "supported", + requestPolicy: "priority", + lastRequested: "priority", + lastObserved: "default", + }; + const text = formatCodexDashboard(context, fast, "eleith:codex"); + expect(text).toContain("Fast: On (priority requested)\nLast reported tier: Standard"); + expect(text).not.toContain("Last reported tier: Priority"); + expect(text).toContain("Context: 40k / 272k tokens (standard)"); +}); + +it("does not invent a model or context window when no model is selected", () => { + expect(formatCodexDashboard(undefined, undefined, "eleith:codex")).toBe("No model selected."); +}); diff --git a/extensions/codex/src/dashboard.ts b/extensions/codex/src/dashboard.ts new file mode 100644 index 0000000..4991011 --- /dev/null +++ b/extensions/codex/src/dashboard.ts @@ -0,0 +1,24 @@ +import { formatContextSummary, type ContextStatus } from "./status.ts"; +import { formatFastStatus, type FastStatus } from "./fast-status.ts"; +import { modelKey } from "./profiles.ts"; + +/** Local snapshots only: opening the dashboard never fetches account data. */ +export function formatCodexDashboard( + context: ContextStatus | undefined, + fast: FastStatus | undefined, + commandName: string, + now = Date.now(), +): string { + if (!context) return "No model selected."; + let fastSummary = "Fast: unavailable"; + if (fast) fastSummary = formatFastStatus(fast); + return [ + `Codex · ${modelKey(context.model)}`, + fastSummary, + formatContextSummary(context, now), + "", + `/${commandName} usage — account allowance`, + `/${commandName} extend|restore — context window`, + `/${commandName} fast — priority controls (type "fast o" for on/off suggestions)`, + ].join("\n"); +} diff --git a/extensions/codex/src/fast-sdk.test.ts b/extensions/codex/src/fast-sdk.test.ts index d5b97c2..462c3d2 100644 --- a/extensions/codex/src/fast-sdk.test.ts +++ b/extensions/codex/src/fast-sdk.test.ts @@ -54,9 +54,33 @@ it.each(["sse", "websocket"] as const)( await openRealExtensionSession(runtime, model); try { const branch = structuredClone(manager.getBranch()); + resolveAuth.mockClear(); + await session.prompt("/eleith:codex"); + expect(notify).toHaveBeenLastCalledWith(expect.stringContaining("Context:"), "info"); + expect(notify).toHaveBeenLastCalledWith( + expect.stringContaining("Fast: Provider default (no override)"), + "info", + ); await session.prompt("/eleith:codex fast"); - await session.prompt("/eleith:codex fast status"); + expect(notify).toHaveBeenLastCalledWith( + expect.stringContaining("Fast: Provider default (no override)"), + "info", + ); expect(network.fetch).not.toHaveBeenCalled(); + expect(resolveAuth).not.toHaveBeenCalled(); + expect(credentialWrites).not.toHaveBeenCalled(); + expect(manager.getBranch()).toEqual(branch); + expect(modelTurns).not.toHaveBeenCalled(); + await session.prompt("/eleith:codex fast status"); + expect(notify).toHaveBeenLastCalledWith( + expect.stringContaining( + "Preference: inherit\nEligibility: unknown\nNext request policy: untouched", + ), + "info", + ); + expect(network.fetch).not.toHaveBeenCalled(); + expect(resolveAuth).not.toHaveBeenCalled(); + expect(credentialWrites).not.toHaveBeenCalled(); expect(manager.getBranch()).toEqual(branch); expect(modelTurns).not.toHaveBeenCalled(); @@ -71,6 +95,10 @@ it.each(["sse", "websocket"] as const)( { provider: "openai-codex", modelId: model.id, mode: "on" }, ]); expect(modelTurns).not.toHaveBeenCalled(); + expect(notify).toHaveBeenLastCalledWith( + expect.stringContaining("Fast: On (priority requested)\nNo response tier reported yet."), + "info", + ); resolveAuth.mockClear(); await session.prompt("Reply with ok."); @@ -92,18 +120,37 @@ it.each(["sse", "websocket"] as const)( const requests = network.fetch.mock.calls.length; resolveAuth.mockClear(); const afterTurn = structuredClone(manager.getBranch()); - for (const command of ["/eleith:codex fast status", "/eleith:codex fast"]) { - await session.prompt(command); - expect(notify).toHaveBeenLastCalledWith(expect.stringContaining("Preference: on"), "info"); - expect(notify).toHaveBeenLastCalledWith( - expect.stringContaining("Last requested tier: priority"), - "info", - ); - expect(notify).toHaveBeenLastCalledWith( - expect.stringContaining("Last observed tier: default"), - "info", - ); - } + await session.prompt("/eleith:codex"); + expect(notify).toHaveBeenLastCalledWith( + expect.stringContaining("Fast: On (priority requested)\nLast reported tier: Standard"), + "info", + ); + expect(notify).toHaveBeenLastCalledWith(expect.stringContaining("Context:"), "info"); + expect(network.fetch).toHaveBeenCalledTimes(requests); + expect(resolveAuth).not.toHaveBeenCalled(); + expect(manager.getBranch()).toEqual(afterTurn); + await session.prompt("/eleith:codex fast status"); + expect(notify).toHaveBeenLastCalledWith(expect.stringContaining("Preference: on"), "info"); + expect(notify).toHaveBeenLastCalledWith( + expect.stringContaining("Last requested tier: priority"), + "info", + ); + expect(notify).toHaveBeenLastCalledWith( + expect.stringContaining("Last observed tier: default"), + "info", + ); + expect(network.fetch).toHaveBeenCalledTimes(requests); + expect(resolveAuth).not.toHaveBeenCalled(); + expect(manager.getBranch()).toEqual(afterTurn); + expect(modelTurns).toHaveBeenCalledOnce(); + expect(credentialWrites).not.toHaveBeenCalled(); + + await session.prompt("/eleith:codex fast"); + expect(notify).toHaveBeenLastCalledWith( + expect.stringContaining("Fast: On (priority requested)\nLast reported tier: Standard"), + "info", + ); + expect(notify).toHaveBeenLastCalledWith(expect.not.stringContaining("Preference:"), "info"); expect(network.fetch).toHaveBeenCalledTimes(requests); expect(resolveAuth).not.toHaveBeenCalled(); expect(manager.getBranch()).toEqual(afterTurn); @@ -118,10 +165,8 @@ it.each(["sse", "websocket"] as const)( it("SDK replays the active fast branch alongside context controls, then off and reset change the wire", async () => { const network = installResponseTransports("sse"); - const { runtime, model, credentialWrites, modelTurns } = await createOfflineModelRuntime( - "sse", - network.fetch, - ); + const { runtime, model, credentialWrites, resolveAuth, modelTurns } = + await createOfflineModelRuntime("sse", network.fetch); const manager = SessionManager.inMemory(directory); const saved = { provider: "openai-codex", modelId: model.id, mode: "on" }; const onLeaf = manager.appendCustomEntry(FAST_ENTRY, saved); @@ -136,11 +181,29 @@ it("SDK replays the active fast branch alongside context controls, then off and try { expect(network.fetch).not.toHaveBeenCalled(); const branch = structuredClone(manager.getBranch()); + resolveAuth.mockClear(); await session.prompt("/eleith:codex fast"); - await session.prompt("/eleith:codex fast status"); - expect(notify).toHaveBeenLastCalledWith(expect.stringContaining("Preference: on"), "info"); + expect(notify).toHaveBeenLastCalledWith( + expect.stringContaining("Fast: Paused — model eligibility needs checking"), + "info", + ); expect(network.fetch).not.toHaveBeenCalled(); + expect(resolveAuth).not.toHaveBeenCalled(); + expect(credentialWrites).not.toHaveBeenCalled(); expect(manager.getBranch()).toEqual(branch); + expect(modelTurns).not.toHaveBeenCalled(); + await session.prompt("/eleith:codex fast status"); + expect(notify).toHaveBeenLastCalledWith( + expect.stringContaining( + "Preference: on\nEligibility: unknown\nNext request policy: suspended", + ), + "info", + ); + expect(network.fetch).not.toHaveBeenCalled(); + expect(resolveAuth).not.toHaveBeenCalled(); + expect(credentialWrites).not.toHaveBeenCalled(); + expect(manager.getBranch()).toEqual(branch); + expect(modelTurns).not.toHaveBeenCalled(); const outputLimit = session.model?.maxTokens; const thinking = session.thinkingLevel; @@ -159,11 +222,19 @@ it("SDK replays the active fast branch alongside context controls, then off and expect(confirm).not.toHaveBeenCalled(); await session.prompt("/eleith:codex fast off"); + expect(notify).toHaveBeenLastCalledWith( + expect.stringContaining("Fast: Off (standard requested)"), + "info", + ); expect(fastChoices(manager)).toEqual([saved, { ...saved, mode: "off" }]); await session.prompt("Reply with ok without priority."); expect(network.payloads[1]).toMatchObject({ model: model.id, service_tier: "default" }); await session.prompt("/eleith:codex fast reset"); + expect(notify).toHaveBeenLastCalledWith( + expect.stringContaining("Fast: Provider default (no override)"), + "info", + ); expect(fastChoices(manager)).toEqual([ saved, { ...saved, mode: "off" }, diff --git a/extensions/codex/src/fast-status.test.ts b/extensions/codex/src/fast-status.test.ts index a50f9f1..229d0ea 100644 --- a/extensions/codex/src/fast-status.test.ts +++ b/extensions/codex/src/fast-status.test.ts @@ -1,18 +1,125 @@ import { expect, it } from "vitest"; -import { formatFastStatus, type FastStatus } from "./fast-status.ts"; +import { + formatFastDiagnostics, + formatFastStatus, + type FastStatus, + type ObservedTier, +} from "./fast-status.ts"; -const suspended: FastStatus = { - preference: "on", - eligibility: "unknown", - requestPolicy: "suspended", - lastRequested: undefined, - lastObserved: undefined, - conflict: false, -}; +it("leaves provider default brief without unknown fields, commands or cost warnings", () => { + const fast = createFastStatusFixture(); + expect(formatFastStatus(fast)).toBe("Fast: Provider default (no override)"); +}); -it("labels unknown delivery and explains selected scope, allowance consumption and cost-estimate limits", () => { - const text = formatFastStatus(suspended); - expect(text.split("\n")).toEqual([ +it("describes off as a standard request, not an observed processing tier", () => { + const fast = createFastStatusFixture({ preference: "off", requestPolicy: "default" }); + expect(formatFastStatus(fast)).toBe("Fast: Off (standard requested)"); +}); + +it("describes on as a priority request without claiming priority was delivered", () => { + const fast = createFastStatusFixture({ + preference: "on", + eligibility: "supported", + requestPolicy: "priority", + lastRequested: "priority", + }); + expect(formatFastStatus(fast).split("\n")).toEqual([ + "Fast: On (priority requested)", + "No response tier reported yet.", + "Priority requests use more allowance.", + ]); +}); + +it("keeps requested priority separate from a reported standard tier", () => { + const fast = createFastStatusFixture({ + preference: "on", + eligibility: "supported", + requestPolicy: "priority", + lastRequested: "priority", + lastObserved: "default", + }); + expect(formatFastStatus(fast).split("\n")).toEqual([ + "Fast: On (priority requested)", + "Last reported tier: Standard", + "Priority requests use more allowance.", + ]); +}); + +it.each<[ObservedTier, string]>([ + ["default", "Standard"], + ["priority", "Priority"], + ["flex", "Flex"], + ["auto", "Automatic"], + ["fast", "Fast"], +])("labels the known reported tier %s as %s even without an override", (tier, label) => { + const fast = createFastStatusFixture({ lastObserved: tier }); + expect(formatFastStatus(fast)).toBe( + `Fast: Provider default (no override)\nLast reported tier: ${label}`, + ); +}); + +it("pauses a saved on choice when eligibility needs checking without claiming a current request", () => { + const fast = createFastStatusFixture({ + preference: "on", + requestPolicy: "suspended", + lastRequested: "priority", + }); + 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 old requested tier", () => { + const fast = createFastStatusFixture({ + preference: "on", + eligibility: "supported", + requestPolicy: "outside scope", + lastRequested: "priority", + }); + expect(formatFastStatus(fast)).toBe("Fast: Unavailable for this model/provider"); +}); + +it.each(["priority", "default"] as const)( + "reports a past conflict separately without suspending the next %s request", + (requestPolicy) => { + const fast = createFastStatusFixture({ + preference: requestPolicy === "priority" ? "on" : "off", + eligibility: "supported", + requestPolicy, + lastObserved: "flex", + conflict: true, + }); + const headline = + requestPolicy === "priority" + ? "Fast: On (priority requested)" + : "Fast: Off (standard requested)"; + const rows = [ + headline, + "Last reported tier: Flex", + "Last request kept another tier setting; not overridden.", + ]; + if (requestPolicy === "priority") rows.push("Priority requests use more allowance."); + expect(formatFastStatus(fast).split("\n")).toEqual(rows); + expect(formatFastStatus({ ...fast, conflict: false })).not.toContain( + "Last request kept another tier setting", + ); + }, +); + +it("preserves unknown delivery and the full scope, allowance and cost-estimate diagnostics", () => { + const fast = createFastStatusFixture({ preference: "on", requestPolicy: "suspended" }); + expect(formatFastDiagnostics(fast).split("\n")).toEqual([ "Codex fast mode (selected model)", "Preference: on", "Eligibility: unknown", @@ -25,18 +132,38 @@ it("labels unknown delivery and explains selected scope, allowance consumption a ]); }); -it("keeps requested and observed tiers distinct and adds the conflict note only when needed", () => { - const status: FastStatus = { - ...suspended, +it("preserves distinct requested and observed diagnostics and the original optional conflict note", () => { + const fast = createFastStatusFixture({ + preference: "on", eligibility: "supported", requestPolicy: "priority", lastRequested: "priority", lastObserved: "default", - }; - const text = formatFastStatus(status); - expect(text).toContain("Last requested tier: priority\nLast observed tier: default"); - expect(text).not.toContain("Tier conflict:"); - expect(formatFastStatus({ ...status, conflict: true })).toContain( + conflict: true, + }); + expect(formatFastDiagnostics(fast).split("\n")).toEqual([ + "Codex fast mode (selected model)", + "Preference: on", + "Eligibility: supported", + "Next request policy: priority", + "Last requested tier: priority", + "Last observed tier: default", "Tier conflict: an existing non-owned tier was left untouched.", - ); + "Scope: direct legacy Codex; may include retries, compaction and cache warming. Selected scope is not physical-request identity.", + "Priority uses more allowance. Pi's cost estimate may miss priority pricing when response tier metadata is absent or normalized.", + "Observed tier is model-scoped, not a receipt for the last requested tier.", + ]); + expect(formatFastDiagnostics({ ...fast, conflict: false })).not.toContain("Tier conflict:"); }); + +function createFastStatusFixture(overrides: Partial = {}): FastStatus { + return { + preference: "inherit", + eligibility: "unknown", + requestPolicy: "untouched", + lastRequested: undefined, + lastObserved: undefined, + conflict: false, + ...overrides, + }; +} diff --git a/extensions/codex/src/fast-status.ts b/extensions/codex/src/fast-status.ts index f893d43..2ac4a79 100644 --- a/extensions/codex/src/fast-status.ts +++ b/extensions/codex/src/fast-status.ts @@ -15,6 +15,46 @@ export interface FastStatus { } export function formatFastStatus(status: FastStatus): string { + let headline: string; + switch (status.requestPolicy) { + case "untouched": + headline = "Fast: Provider default (no override)"; + break; + case "default": + headline = "Fast: Off (standard requested)"; + break; + 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; + } + const rows = [headline]; + if (status.lastObserved !== undefined) { + const labels: Record = { + default: "Standard", + priority: "Priority", + flex: "Flex", + auto: "Automatic", + fast: "Fast", + }; + rows.push(`Last reported tier: ${labels[status.lastObserved]}`); + } else if (status.requestPolicy === "priority") { + rows.push("No response tier reported yet."); + } + if (status.conflict) rows.push("Last request kept another tier setting; not overridden."); + if (status.requestPolicy === "priority") rows.push("Priority requests use more allowance."); + return rows.join("\n"); +} + +export function formatFastDiagnostics(status: FastStatus): string { const rows = [ "Codex fast mode (selected model)", `Preference: ${status.preference}`, diff --git a/extensions/codex/src/fast.test.ts b/extensions/codex/src/fast.test.ts index f23a387..2f0385b 100644 --- a/extensions/codex/src/fast.test.ts +++ b/extensions/codex/src/fast.test.ts @@ -34,26 +34,43 @@ const catalog: CodexModelCatalog = { }; describe("commands and ownership", () => { - it.each(["", " STATUS "])( - "reports %j without confirmation, auth IO, catalog IO or writes", - async (args) => { - const fast = createFastControllerFixture(); - fast.ctx.sessionManager.getBranch.mockReturnValue([choice("on")]); - fast.controller.restore(fast.ctx); - await fast.controller.handle(args, fast.ctx); - expect(fast.ctx.ui.notify).toHaveBeenCalledExactlyOnceWith( - expect.stringContaining( - "Preference: on\nEligibility: unknown\nNext request policy: suspended", - ), - "info", - ); - 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(); - }, - ); + it("reports bare fast briefly without confirmation, auth IO, catalog IO or writes", async () => { + const fast = createFastControllerFixture(); + fast.ctx.sessionManager.getBranch.mockReturnValue([choice("on")]); + fast.controller.restore(fast.ctx); + await fast.controller.handle("", fast.ctx); + expect(fast.ctx.ui.notify).toHaveBeenCalledExactlyOnceWith( + expect.stringContaining("Fast: Paused — model eligibility needs checking"), + "info", + ); + expect(fast.ctx.ui.notify).not.toHaveBeenCalledWith( + expect.stringContaining("Preference:"), + expect.anything(), + ); + 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(); + }); + + it("reports explicit status technically without confirmation, auth IO, catalog IO or writes", async () => { + const fast = createFastControllerFixture(); + fast.ctx.sessionManager.getBranch.mockReturnValue([choice("on")]); + fast.controller.restore(fast.ctx); + await fast.controller.handle(" STATUS ", fast.ctx); + expect(fast.ctx.ui.notify).toHaveBeenCalledExactlyOnceWith( + expect.stringContaining( + "Preference: on\nEligibility: unknown\nNext request policy: suspended", + ), + "info", + ); + 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(); + }); it("does not discover or persist anything when the cost confirmation is declined", async () => { const fast = createFastControllerFixture(); @@ -164,7 +181,7 @@ describe("commands and ownership", () => { service_tier: "priority", }); expect(fast.ctx.ui.notify).toHaveBeenLastCalledWith( - expect.stringContaining("Next request policy: priority"), + expect.stringContaining("Fast: On (priority requested)"), "info", ); }); @@ -198,6 +215,10 @@ describe("commands and ownership", () => { max_output_tokens: 42, }); await fast.controller.handle("off", fast.ctx); + expect(fast.ctx.ui.notify).toHaveBeenLastCalledWith( + expect.stringContaining("Fast: Off (standard requested)"), + "info", + ); const transformed = fast.controller.transform(payload, fast.ctx); expect(transformed).toEqual({ ...payload, service_tier: "default" }); expect(transformed).not.toBe(payload); @@ -217,9 +238,7 @@ describe("commands and ownership", () => { }); expect(fast.controller.transform(payload, fast.ctx)).toBeUndefined(); expect(fast.ctx.ui.notify).toHaveBeenLastCalledWith( - expect.stringContaining( - "Preference: inherit\nEligibility: unknown\nNext request policy: untouched", - ), + expect.stringContaining("Fast: Provider default (no override)"), "info", ); await fast.controller.handle("reset", fast.ctx); @@ -240,7 +259,7 @@ describe("commands and ownership", () => { mode: "inherit", }); expect(fast.ctx.ui.notify).toHaveBeenLastCalledWith( - expect.stringContaining("Next request policy: outside scope"), + expect.stringContaining("Fast: Unavailable for this model/provider"), "info", ); }); @@ -267,6 +286,17 @@ describe("commands and ownership", () => { expect.stringContaining(`Next request policy: ${tier}`), "info", ); + await fast.controller.handle("", fast.ctx); + const headline = + mode === "on" ? "Fast: On (priority requested)" : "Fast: Off (standard requested)"; + expect(fast.ctx.ui.notify).toHaveBeenLastCalledWith( + expect.stringContaining(headline), + "info", + ); + expect(fast.ctx.ui.notify).toHaveBeenLastCalledWith( + expect.stringContaining("Last request kept another tier setting; not overridden."), + "info", + ); expect(fast.controller.transform({ model: model.id }, fast.ctx)).toEqual({ model: model.id, service_tier: tier, @@ -276,6 +306,15 @@ describe("commands and ownership", () => { expect.not.stringContaining("Tier conflict:"), "info", ); + await fast.controller.handle("", fast.ctx); + expect(fast.ctx.ui.notify).toHaveBeenLastCalledWith( + expect.stringContaining(headline), + "info", + ); + expect(fast.ctx.ui.notify).toHaveBeenLastCalledWith( + expect.not.stringContaining("Last request kept another tier setting"), + "info", + ); }, ); @@ -679,6 +718,15 @@ describe("provider stream observations", () => { ), "info", ); + await fast.controller.handle("", fast.ctx); + expect(fast.ctx.ui.notify).toHaveBeenLastCalledWith( + expect.stringContaining("Fast: On (priority requested)\nLast reported tier: Standard"), + "info", + ); + expect(fast.ctx.ui.notify).toHaveBeenLastCalledWith( + expect.not.stringContaining("No response tier reported yet."), + "info", + ); expect(fast.controller.transform({ model: model.id }, fast.ctx)?.service_tier).toBe("priority"); expect(fast.fetchCatalog).toHaveBeenCalledOnce(); expect(fast.readAuthScope).toHaveBeenCalledOnce(); diff --git a/extensions/codex/src/fast.ts b/extensions/codex/src/fast.ts index 2d74b6d..c60e129 100644 --- a/extensions/codex/src/fast.ts +++ b/extensions/codex/src/fast.ts @@ -18,6 +18,8 @@ import { } from "./fast-state.ts"; import { formatFastStatus, + formatFastDiagnostics, + type FastStatus, type FastRequestPolicy, type ObservedTier, type RequestedTier, @@ -93,13 +95,46 @@ export class FastController { this.preferences = readFastPreferences(ctx.sessionManager.getBranch()); } + getStatus(ctx: FastHost): FastStatus | undefined { + if (this.disposed) return undefined; + const model = ctx.model; + if (!model) return undefined; + const selectedModelKey = modelKey(model); + const direct = isDirectCodexSelection(ctx); + let activity = this.modelTierActivity; + if ( + !activity || + !direct || + activity.modelKey !== selectedModelKey || + 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), + lastRequested: activity?.requested, + lastObserved: activity?.observed, + conflict: activity?.conflict ?? false, + }; + } + async handle(args: string, ctx: FastHost): Promise { if (this.disposed) return; const action = args.trim().toLowerCase(); - if (!action || action === "status") { + if (!action) { this.report(ctx); return; } + if (action === "status") { + const status = this.getStatus(ctx); + let message = "No model selected."; + if (status) message = formatFastDiagnostics(status); + ctx.ui.notify(message, "info"); + return; + } if (!["on", "off", "toggle", "reset"].includes(action)) { ctx.ui.notify(`Usage: /${this.commandName} fast [on|off|toggle|status|reset]`, "warning"); return; @@ -385,32 +420,14 @@ export class FastController { } private report(ctx: FastHost): void { - const model = ctx.model; - if (!model) { + const status = this.getStatus(ctx); + if (!status) { ctx.ui.notify("No model selected.", "info"); return; } - const selectedModelKey = modelKey(model); - const direct = isDirectCodexSelection(ctx); - let activity = this.modelTierActivity; - if ( - !activity || - !direct || - activity.modelKey !== selectedModelKey || - activity.sessionId !== ctx.sessionManager.getSessionId() - ) - activity = undefined; - let eligibility: FastEligibility = "unknown"; - if (direct) eligibility = this.currentEligibility(selectedModelKey); - const status = { - preference: this.preferenceFor(selectedModelKey), - eligibility, - requestPolicy: this.requestPolicy(ctx), - lastRequested: activity?.requested, - lastObserved: activity?.observed, - conflict: activity?.conflict ?? false, - }; - ctx.ui.notify(formatFastStatus(status), "info"); + const summary = formatFastStatus(status); + const hint = `/${this.commandName} fast on|off|toggle|reset · status for details`; + ctx.ui.notify(`${summary}\n\n${hint}`, "info"); } } diff --git a/extensions/codex/src/index.test.ts b/extensions/codex/src/index.test.ts index 31a02c9..59ce3b3 100644 --- a/extensions/codex/src/index.test.ts +++ b/extensions/codex/src/index.test.ts @@ -1,6 +1,7 @@ import { mkdirSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent"; +import type { Api, Model } from "@earendil-works/pi-ai"; import { afterAll, expect, it, vi } from "vitest"; import codex from "./index.ts"; import { fetchCodexModelCatalog, fetchCodexUsage, readCodexAuthScope } from "./client.ts"; @@ -61,7 +62,7 @@ async function load(prefix = "eleith") { isIdle: () => true, getContextUsage: () => undefined, modelRegistry: {}, - ui: { notify, confirm: vi.fn(async () => false) }, + ui: { notify, confirm: vi.fn(async () => false), setStatus: vi.fn() }, sessionManager: { getBranch: () => [], getSessionId: () => "session", getLeafId: () => null }, } as unknown as ExtensionCommandContext; await codex(pi as unknown as ExtensionAPI); @@ -89,13 +90,19 @@ it("registers only the configured native command once after binding", async () = expect(context.pi.registerCommand).toHaveBeenCalledTimes(1); expect(context.pi.registerCommand.mock.calls[0]?.[0]).toBe("personal:codex"); expect(await context.command().getArgumentCompletions?.("e")).toEqual([ - { value: "extend", label: "extend" }, + expect.objectContaining({ value: "extend", label: "extend", description: expect.any(String) }), ]); - expect(await context.command().getArgumentCompletions?.("")).toEqual([ - { value: "extend", label: "extend" }, - { value: "restore", label: "restore" }, - { value: "usage", label: "usage" }, - { value: "fast", label: "fast" }, + const rootChoices = await context.command().getArgumentCompletions?.(""); + expect(rootChoices?.map(({ value }) => value)).toEqual(["extend", "restore", "usage", "fast"]); + expect(await context.command().getArgumentCompletions?.("fast o")).toEqual([ + expect.objectContaining({ + value: "fast on", + description: expect.stringContaining("allowance"), + }), + expect.objectContaining({ + value: "fast off", + description: expect.stringContaining("standard"), + }), ]); }); @@ -164,10 +171,9 @@ it("routes usage through the configured command without requiring an idle Codex context.ctx.modelRegistry, expect.any(AbortSignal), ); - expect(context.notify).toHaveBeenCalledExactlyOnceWith( - expect.stringContaining("Plan: plus"), - "info", - ); + expect(context.notify).toHaveBeenNthCalledWith(1, "Fetching Codex usage…", "info"); + expect(context.notify).toHaveBeenLastCalledWith(expect.stringContaining("Plan: plus"), "info"); + expect(context.notify).toHaveBeenCalledTimes(2); expect(context.pi.setModel).not.toHaveBeenCalled(); expect(context.pi.appendEntry).not.toHaveBeenCalled(); }); @@ -189,10 +195,67 @@ it.each(["model_select", "session_tree", "session_shutdown"])( expect(signal.aborted).toBe(true); finish({ plan_type: "plus" }); await request; - expect(context.notify).not.toHaveBeenCalled(); + expect(context.notify).toHaveBeenCalledExactlyOnceWith("Fetching Codex usage…", "info"); }, ); +it("combines context and fast mode in a read-only dashboard with the configured command hints", async () => { + const context = await load("personal"); + await context.emit("session_start"); + const model: Model = { + id: "gpt-6-sol", + name: "Sol", + provider: "openai-codex", + api: "openai-codex-responses", + baseUrl: "https://chatgpt.com/backend-api", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 272_000, + maxTokens: 128_000, + }; + const getProviderAuth = vi.fn(async () => { + throw new Error("Auth lookup forbidden"); + }); + const ctx = { + ...context.ctx, + model, + modelRegistry: { + find: () => model, + getProvider: () => ({ baseUrl: model.baseUrl }), + isUsingOAuth: () => true, + getRegisteredProviderConfig: () => undefined, + getRegisteredNativeProvider: () => undefined, + getProviderAuth, + }, + } as unknown as ExtensionCommandContext; + await context.command().handler("", ctx); + expect(context.notify).toHaveBeenCalledExactlyOnceWith( + expect.stringContaining("Fast: Provider default (no override)"), + "info", + ); + expect(context.notify).toHaveBeenLastCalledWith(expect.stringContaining("Context:"), "info"); + expect(context.notify).toHaveBeenLastCalledWith( + expect.stringContaining("/personal:codex fast"), + "info", + ); + expect(getProviderAuth).not.toHaveBeenCalled(); + expect(fetchCodexUsage).not.toHaveBeenCalled(); + expect(fetchCodexModelCatalog).not.toHaveBeenCalled(); + expect(readCodexAuthScope).not.toHaveBeenCalled(); + expect(context.pi.appendEntry).not.toHaveBeenCalled(); + expect(context.pi.setModel).not.toHaveBeenCalled(); + await context.emit("session_shutdown"); + const notify = context.notify; + Object.defineProperty(ctx, "ui", { + get: () => { + throw new Error("Expired UI"); + }, + }); + await context.command().handler("", ctx); + expect(notify).toHaveBeenCalledOnce(); +}); + it("fails invalid prefix settings before registering any capabilities", async () => { writeFileSync(configPath, '{"commandPrefix":"Invalid Prefix"}'); const on = vi.fn(), diff --git a/extensions/codex/src/index.ts b/extensions/codex/src/index.ts index 5930ddf..d497ee7 100644 --- a/extensions/codex/src/index.ts +++ b/extensions/codex/src/index.ts @@ -4,6 +4,8 @@ import { getSettings } from "@eleith/pi-internal/config"; import { ContextController } from "./controller.ts"; import { UsageController } from "./usage-controller.ts"; import { FastController } from "./fast.ts"; +import { CODEX_ACTIONS, completeCodexArguments } from "./commands.ts"; +import { formatCodexDashboard } from "./dashboard.ts"; export default async function codex(pi: ExtensionAPI): Promise { const settings = await getSettings(); @@ -11,14 +13,23 @@ export default async function codex(pi: ExtensionAPI): Promise { const controller = new ContextController(pi, commandName); const usage = new UsageController(); const fast = new FastController(pi, commandName); + let disposed = false; registerCommand(pi, { name: commandName, - description: "Codex context, account usage and fast controls", - actions: ["extend", "restore", "usage", "fast"], + description: "Context, fast requests and account allowance", + actions: CODEX_ACTIONS, + getArgumentCompletions: completeCodexArguments, argumentActions: ["fast"], defaultAction: "", handler(action, ctx, argument) { + if (disposed) return; + if (!action) { + const context = controller.getStatus(ctx); + const fastStatus = fast.getStatus(ctx); + ctx.ui.notify(formatCodexDashboard(context, fastStatus, commandName), "info"); + return; + } if (action === "usage") return usage.report(ctx); if (action === "fast") return fast.handle(argument ?? "", ctx); return controller.handle(action, ctx); @@ -30,19 +41,23 @@ export default async function codex(pi: ExtensionAPI): Promise { return controller.restore(ctx); }); pi.on("session_tree", (_event, ctx) => { - usage.cancel(); + usage.cancel(ctx); fast.restore(ctx); return controller.restore(ctx); }); pi.on("model_select", (_event, ctx) => { - usage.cancel(); + usage.cancel(ctx); fast.invalidate(); return controller.modelSelected(ctx); }); pi.on("session_shutdown", (_event, ctx) => { - usage.dispose(); + disposed = true; fast.dispose(); - controller.shutdown(ctx); + try { + usage.dispose(ctx); + } finally { + controller.shutdown(ctx); + } }); pi.on("input", async (event, ctx) => { if (event.streamingBehavior === undefined && ctx.isIdle()) { diff --git a/extensions/codex/src/status.test.ts b/extensions/codex/src/status.test.ts index 5eb24e0..4e1dbf2 100644 --- a/extensions/codex/src/status.test.ts +++ b/extensions/codex/src/status.test.ts @@ -1,7 +1,7 @@ import type { Api, AssistantMessage, Model } from "@earendil-works/pi-ai"; -import type { SessionEntry } from "@earendil-works/pi-coding-agent"; +import type { ContextUsage, SessionEntry } from "@earendil-works/pi-coding-agent"; import { describe, expect, it } from "vitest"; -import { formatContextStatus } from "./status.ts"; +import { type ContextStatus, formatContextSummary } from "./status.ts"; const NOW = Date.parse("2026-01-10T12:00:00Z"); const model: Model = { @@ -16,7 +16,7 @@ const model: Model = { contextWindow: 272_000, maxTokens: 128_000, }; -const status: Parameters[0] = { +const status: ContextStatus = { model, usage: undefined, standardWindow: 272_000, @@ -74,81 +74,258 @@ function compaction(id: string, timestamp: string, parentId: string | null = nul }; } -describe("context status windows", () => { - it("reports the selected model and local extended metadata without changing it", () => { - const current = { +describe("context summary occupancy", () => { + it("uses the physical 128k window for a routed model advertising an extended 1m window", () => { + const report = formatContextSummary( + { + ...status, + model: { ...model, contextWindow: 1_000_000 }, + extendedWindow: 1_000_000, + desiredExtended: true, + usage: { tokens: 120_000, contextWindow: 128_000, percent: 93.75 }, + }, + NOW, + ); + expect(report.split("\n")[0]).toBe( + "Context: 120k / 128k tokens (effective) · Selected context window: 1m tokens (extended)", + ); + expect(report).not.toContain("pending"); + }); + + it.each([undefined, 0])( + "keeps known effective occupancy when the selected window is %s", + (contextWindow) => { + // A routed or persisted model may lack today's required window metadata. + const selected = { ...model, contextWindow } as unknown as Model; + const report = formatContextSummary( + { + ...status, + model: selected, + usage: { tokens: 120_000, contextWindow: 128_000, percent: 93.75 }, + }, + NOW, + ); + expect(report.split("\n")[0]).toBe( + "Context: 120k / 128k tokens (effective) · Selected context window: unknown tokens (window unknown)", + ); + }, + ); + + it("provides three read-only local lines without a model header or fast hint", () => { + const current: ContextStatus = { ...status, - extendedWindow: 922_000, - usage: { tokens: 12_000, contextWindow: 922_000, percent: 1.3 }, + usage: { tokens: 40_000, contextWindow: 272_000, percent: 14.7 }, + branch: [ + compaction("first", "2026-01-10T11:50:00Z"), + compaction("last", "2026-01-10T11:55:00Z", "first"), + message(assistant({ input: 10_000, cacheRead: 20_000 })), + ], }; const original = structuredClone(current); - const report = formatContextStatus(current, NOW); - expect(report).toContain("codex · openai-codex/gpt-6-sol"); - expect(report).toMatch(/^ Usage +12k \/ 922k$/m); - expect(report).toMatch(/^ Standard +272k$/m); - expect(report).toMatch(/^ Extended +922k \(local window\)$/m); + expect(formatContextSummary(current, NOW)).toBe( + "Context: 40k / 272k tokens (standard)\n" + + "Compaction: 2 on this branch · last 5 minutes ago\n" + + "Cached input: 20k / 30k (last assistant request)", + ); expect(current).toEqual(original); }); - it("shows unknown usage with the model window when usage is unavailable", () => { - const report = formatContextStatus(status, NOW); - expect(report).toMatch(/^ Usage +unknown \/ 272k$/m); - expect(report).toMatch(/^ Extended +not verified$/m); - }); - - it.each([null, -1, NaN, Infinity])("does not turn invalid token usage %s into zero", (tokens) => { - const report = formatContextStatus( - { ...status, usage: { tokens, contextWindow: 922_000, percent: null } }, + it.each([ + [272_000, "standard"], + [922_000, "extended"], + [500_000, "custom"], + ] as const)("keeps the selected %d window's %s label separate", (contextWindow, label) => { + const report = formatContextSummary( + { + ...status, + model: { ...model, contextWindow }, + extendedWindow: 922_000, + usage: { tokens: 12_000, contextWindow: 128_000, percent: 9.375 }, + }, NOW, ); - expect(report).toMatch(/^ Usage +unknown \/ 922k$/m); - }); - - it("preserves genuinely zero context usage", () => { - const report = formatContextStatus( - { ...status, usage: { tokens: 0, contextWindow: 272_000, percent: 0 } }, - NOW, + expect(report.split("\n")[0]).toBe( + `Context: 12k / 128k tokens (effective) · Selected context window: ${contextWindow / 1000}k tokens (${label})`, ); - expect(report).toMatch(/^ Usage +0 \/ 272k$/m); - }); -}); - -describe("catalog baseline and desired state", () => { - it("reports unknown baseline honestly without inventing an extended target", () => { - const report = formatContextStatus({ ...status, standardWindow: undefined }, NOW); - expect(report).toMatch(/^ Standard +unknown$/m); - expect(report).toContain("unknown; reselect a catalog model"); }); - it("explains an already extended-sized catalog baseline", () => { - const report = formatContextStatus( - { ...status, standardWindow: 1_000_000, extendedWindow: 1_000_000 }, - NOW, - ); - expect(report).toContain("catalog already provides this window; no extension needed"); - }); - - it.each([true, false])( - "keeps desired preference %s separate from effective usage and replay pause", - (desiredExtended) => { - const report = formatContextStatus({ ...status, desiredExtended, replayPaused: true }, NOW); - expect(report).toContain( - `${desiredExtended ? "extended" : "standard"} (desired, not a receipt)`, + it.each([undefined, null, 0, -1, NaN, Infinity])( + "falls back to the selected window when the effective window is %s", + (contextWindow) => { + const usage = { tokens: 12_000, contextWindow, percent: null } as unknown as ContextUsage; + expect(formatContextSummary({ ...status, usage }, NOW).split("\n")[0]).toBe( + "Context: 12k / 272k tokens (standard)", ); - expect(report).toMatch(/^ Usage +unknown \/ 272k$/m); - expect(report).toContain("paused; explicitly extend or restore"); }, ); -}); -describe("context status compactions", () => { - it("reports no compaction on an empty branch", () => { - const report = formatContextStatus(status, NOW); - expect(report).toMatch(/^ Compactions +0 on this branch$/m); - expect(report).toMatch(/^ Last compacted +never on this branch$/m); + it.each([null, -1, NaN, Infinity])("keeps invalid token usage %s unknown", (tokens) => { + const report = formatContextSummary( + { ...status, usage: { tokens, contextWindow: 272_000, percent: null } }, + NOW, + ); + expect(report.split("\n")[0]).toBe("Context: 272k tokens (standard) · usage not reported"); }); - it("counts only supplied branch compactions and uses the last in branch order", () => { + it("retains the effective window even when token usage is unknown", () => { + const report = formatContextSummary( + { ...status, usage: { tokens: null, contextWindow: 128_000, percent: null } }, + NOW, + ); + expect(report.split("\n")[0]).toBe( + "Context: 128k tokens (effective) · usage not reported · Selected context window: 272k tokens (standard)", + ); + }); + + it("does not fabricate a zero window when neither window is known", () => { + const report = formatContextSummary( + { + ...status, + model: { ...model, contextWindow: 0 }, + usage: { tokens: 12_000, contextWindow: 0, percent: null }, + }, + NOW, + ); + expect(report.split("\n")[0]).toBe("Context: 12k / unknown tokens (window unknown)"); + }); + + it("preserves measured zero usage and keeps an empty branch compact", () => { + expect( + formatContextSummary( + { ...status, usage: { tokens: 0, contextWindow: 272_000, percent: 0 } }, + NOW, + ), + ).toBe( + "Context: 0 / 272k tokens (standard)\n" + + "Compaction: 0 on this branch\n" + + "Cached input: not reported", + ); + }); +}); + +describe("context summary baselines and desired state", () => { + it("does not infer a baseline from the model name or a verified extended window", () => { + const report = formatContextSummary( + { + ...status, + model: { ...model, contextWindow: 922_000 }, + standardWindow: undefined, + extendedWindow: 922_000, + }, + NOW, + ); + expect(report.split("\n")[0]).toBe( + "Context: 922k tokens (baseline unknown) · usage not reported", + ); + }); + + it("labels a catalog already at the extended target standard with no pending replay", () => { + const report = formatContextSummary( + { + ...status, + model: { ...model, contextWindow: 1_000_000 }, + standardWindow: 1_000_000, + extendedWindow: 1_000_000, + desiredExtended: true, + }, + NOW, + ); + expect(report.split("\n")[0]).toBe("Context: 1m tokens (standard) · usage not reported"); + }); + + it("does not call a larger custom window extended without verified metadata", () => { + const report = formatContextSummary( + { ...status, model: { ...model, contextWindow: 922_000 } }, + NOW, + ); + expect(report.split("\n")[0]).toBe("Context: 922k tokens (custom) · usage not reported"); + }); + + it.each([ + [true, 272_000, "standard", "extended"], + [false, 922_000, "extended", "standard"], + ] as const)( + "does not present desired extended=%s as active", + (desiredExtended, contextWindow, active, desired) => { + const current = { + ...status, + model: { ...model, contextWindow }, + extendedWindow: 922_000, + desiredExtended, + }; + expect(formatContextSummary(current, NOW).split("\n")[0]).toBe( + `Context: ${contextWindow / 1000}k tokens (${active}) · usage not reported · ${desired} pending normal prompt`, + ); + const paused = formatContextSummary({ ...current, replayPaused: true }, NOW); + expect(paused.split("\n")[0]).toBe( + `Context: ${contextWindow / 1000}k tokens (${active}) · usage not reported · ${desired} pending · replay paused`, + ); + expect(paused).not.toContain("normal prompt"); + }, + ); + + it("keeps pending replay tied to the selected window, not a matching physical window", () => { + const current: ContextStatus = { + ...status, + model: { ...model, contextWindow: 922_000 }, + desiredExtended: false, + usage: { tokens: 120_000, contextWindow: 128_000, percent: 93.75 }, + standardWindow: 128_000, + extendedWindow: 922_000, + }; + expect(formatContextSummary(current, NOW).split("\n")[0]).toBe( + "Context: 120k / 128k tokens (effective) · Selected context window: 922k tokens (extended) · standard pending normal prompt", + ); + expect(formatContextSummary({ ...current, replayPaused: true }, NOW).split("\n")[0]).toBe( + "Context: 120k / 128k tokens (effective) · Selected context window: 922k tokens (extended) · standard pending · replay paused", + ); + }); + + it.each([ + [false, 272_000, "standard"], + [true, 922_000, "extended"], + ] as const)( + "omits pending when desired extended=%s matches the selected window", + (desiredExtended, contextWindow, label) => { + const report = formatContextSummary( + { + ...status, + model: { ...model, contextWindow }, + extendedWindow: 922_000, + desiredExtended, + }, + NOW, + ); + expect(report.split("\n")[0]).toBe( + `Context: ${contextWindow / 1000}k tokens (${label}) · usage not reported`, + ); + }, + ); + + it.each([true, false])( + "does not promise replay with an unknown target (%s)", + (desiredExtended) => { + const report = formatContextSummary( + { ...status, standardWindow: undefined, desiredExtended, replayPaused: true }, + NOW, + ); + expect(report.split("\n")[0]).toBe( + "Context: 272k tokens (baseline unknown) · usage not reported · " + + `${desiredExtended ? "extended" : "standard"} desired (target unknown) · replay paused`, + ); + expect(report).not.toContain("normal prompt"); + }, + ); + + it("retains the replay pause even without a saved preference", () => { + expect(formatContextSummary({ ...status, replayPaused: true }, NOW).split("\n")[0]).toBe( + "Context: 272k tokens (standard) · usage not reported · replay paused", + ); + }); +}); + +describe("context summary branch compactions", () => { + it("counts only the supplied active branch and uses branch order rather than timestamps", () => { const shared = compaction("shared", "2026-01-10T11:59:59Z"); const abandoned = compaction("abandoned", "2026-01-10T11:00:00Z", "shared"); const active = compaction("active", "2026-01-08T12:00:00Z", "shared"); @@ -160,12 +337,12 @@ describe("context status compactions", () => { fromId: "abandoned", summary: "Branch summary", }; - expect(formatContextStatus({ ...status, branch: [shared, abandoned] }, NOW)).toMatch( - /^ Last compacted +1 hour ago$/m, - ); - const report = formatContextStatus({ ...status, branch: [shared, active, summary] }, NOW); - expect(report).toMatch(/^ Compactions +2 on this branch$/m); - expect(report).toMatch(/^ Last compacted +2 days ago$/m); + expect( + formatContextSummary({ ...status, branch: [shared, abandoned] }, NOW).split("\n")[1], + ).toBe("Compaction: 2 on this branch · last 1 hour ago"); + expect( + formatContextSummary({ ...status, branch: [shared, active, summary] }, NOW).split("\n")[1], + ).toBe("Compaction: 2 on this branch · last 2 days ago"); }); it.each([ @@ -179,35 +356,33 @@ describe("context status compactions", () => { [86_399_999, "23 hours ago"], [86_400_000, "1 day ago"], [172_800_000, "2 days ago"], - ])("formats elapsed time at %d ms", (age, expected) => { - const branch = [compaction("compact", new Date(NOW - age).toISOString())]; - expect(formatContextStatus({ ...status, branch }, NOW)).toMatch( - new RegExp(`^ Last compacted +${expected}$`, "m"), + ])("reports the final branch compaction's age at %d ms", (age, expected) => { + const branch = [compaction("last", new Date(NOW - age).toISOString())]; + expect(formatContextSummary({ ...status, branch }, NOW).split("\n")[1]).toBe( + `Compaction: 1 on this branch · last ${expected}`, ); }); it.each(["not-a-date", new Date(NOW + 1000).toISOString()])( - "reports unknown elapsed time for %s", + "does not invent the last compaction time for %s", (timestamp) => { - const report = formatContextStatus( - { ...status, branch: [compaction("compact", timestamp)] }, - NOW, + const branch = [compaction("last", timestamp)]; + expect(formatContextSummary({ ...status, branch }, NOW).split("\n")[1]).toBe( + "Compaction: 1 on this branch · last time unknown", ); - expect(report).toMatch(/^ Compactions +1 on this branch$/m); - expect(report).toMatch(/^ Last compacted +unknown$/m); }, ); }); -describe("context status cache reports", () => { +describe("context summary last-request cache", () => { it("does not invent cache metrics without an assistant request", () => { const branch = [message({ role: "user", content: "Hello", timestamp: NOW })]; - const report = formatContextStatus({ ...status, branch }, NOW); - expect(report).toMatch(/^ Cached input +not reported$/m); - expect(report).not.toMatch(/^ Cache writes /m); + expect(formatContextSummary({ ...status, branch }, NOW).split("\n")[2]).toBe( + "Cached input: not reported", + ); }); - it("adds disjoint uncached, cache-read and cache-write counts, excluding output", () => { + it("includes disjoint cache writes in the input total but excludes output", () => { const branch = [ message( assistant({ @@ -219,40 +394,40 @@ describe("context status cache reports", () => { }), ), ]; - const report = formatContextStatus({ ...status, branch }, NOW); - expect(report).toMatch(/^ Cached input +6k \/ 9k input tokens$/m); - expect(report).toMatch(/^ Cache writes +1k tokens$/m); - expect(report).not.toMatch(/^ Model /m); + const report = formatContextSummary({ ...status, branch }, NOW); + expect(report.split("\n")).toHaveLength(3); + expect(report.split("\n")[2]).toBe("Cached input: 6k / 9k · wrote 1k (last assistant request)"); }); it("allows cache reads to account for all input", () => { const branch = [message(assistant({ cacheRead: 1000 }))]; - expect(formatContextStatus({ ...status, branch }, NOW)).toMatch( - /^ Cached input +1k \/ 1k input tokens$/m, + expect(formatContextSummary({ ...status, branch }, NOW).split("\n")[2]).toBe( + "Cached input: 1k / 1k (last assistant request)", ); }); - it.each([0, 1000])("calls zero cache reads none reported with %d cache writes", (cacheWrite) => { - const branch = [message(assistant({ input: 2000, cacheWrite }))]; - const report = formatContextStatus({ ...status, branch }, NOW); - expect(report).toMatch( - new RegExp( - `^ Cached input +none reported \\(${cacheWrite ? "3k" : "2k"} input tokens\\)$`, - "m", - ), - ); - if (cacheWrite) expect(report).toMatch(/^ Cache writes +1k tokens$/m); - else expect(report).not.toMatch(/^ Cache writes /m); - }); + it.each([ + { cacheWrite: 0, expected: "none reported · 2k input tokens" }, + { cacheWrite: 1000, expected: "none reported · 3k input tokens · wrote 1k" }, + ])( + "does not call zero cache reads a measured miss with $cacheWrite writes", + ({ cacheWrite, expected }) => { + const branch = [message(assistant({ input: 2000, cacheWrite }))]; + expect(formatContextSummary({ ...status, branch }, NOW).split("\n")[2]).toBe( + `Cached input: ${expected} (last assistant request)`, + ); + }, + ); - it("treats an all-zero failed request as unreported rather than a cache miss", () => { - const branch = [ - message(assistant({}, { stopReason: "error", errorMessage: "Request failed" })), - ]; - const report = formatContextStatus({ ...status, branch }, NOW); - expect(report).toMatch(/^ Cached input +not reported$/m); - expect(report).not.toContain("none reported"); - }); + it.each(["stop", "error"] as const)( + "keeps an all-zero %s request unreported rather than claiming a cache miss", + (stopReason) => { + const branch = [message(assistant({}, { stopReason }))]; + expect(formatContextSummary({ ...status, branch }, NOW).split("\n")[2]).toBe( + "Cached input: not reported (last assistant request)", + ); + }, + ); it.each([ { input: -1 }, @@ -266,49 +441,61 @@ describe("context status cache reports", () => { const branch = [ message(assistant({ input: 2000, cacheRead: 6000, cacheWrite: 1000, ...usage })), ]; - const report = formatContextStatus({ ...status, branch }, NOW); - expect(report).toMatch(/^ Cached input +not reported$/m); - expect(report).not.toMatch(/^ Cache writes /m); + expect(formatContextSummary({ ...status, branch }, NOW).split("\n")[2]).toBe( + "Cached input: not reported (last assistant request)", + ); }); it.each([undefined, { input: 2000, cacheWrite: 0 }])( "tolerates missing persisted usage or counters: %j", (usage) => { - // Persisted legacy/malformed messages can lack fields required by today's type. + // Legacy/malformed messages can lack fields required by today's type. const persisted = { ...assistant(), usage } as unknown as AssistantMessage; - const report = formatContextStatus({ ...status, branch: [message(persisted)] }, NOW); - expect(report).toMatch(/^ Cached input +not reported$/m); + expect( + formatContextSummary({ ...status, branch: [message(persisted)] }, NOW).split("\n")[2], + ).toBe("Cached input: not reported (last assistant request)"); }, ); - it("uses the last assistant even after a model switch and trailing non-assistant entries", () => { - const branch: SessionEntry[] = [ - message(assistant({ input: 1000, cacheRead: 9000 }), "earlier"), - message( - assistant({ input: 2000, cacheRead: 1000 }, { provider: "other", model: "prior-model" }), - "last", - ), - { - type: "model_change", - id: "switch", - parentId: "last", - timestamp: new Date(NOW).toISOString(), - provider: model.provider, - modelId: model.id, - }, - message({ role: "user", content: "Next request", timestamp: NOW }, "user"), - compaction("compact", new Date(NOW).toISOString()), - ]; - const report = formatContextStatus({ ...status, branch }, NOW); - expect(report).toMatch(/^ Model +other\/prior-model$/m); - expect(report).toMatch(/^ Cached input +1k \/ 3k input tokens$/m); - expect(report).not.toContain("9k / 10k"); - }); - - it("still identifies the prior request model when its usage was not reported", () => { - const branch = [message(assistant({}, { provider: "other", model: "prior-model" }))]; - const report = formatContextStatus({ ...status, branch }, NOW); - expect(report).toMatch(/^ Model +other\/prior-model$/m); - expect(report).toMatch(/^ Cached input +not reported$/m); - }); + it.each([ + { + scenario: "reported usage", + usage: { input: 2000, cacheRead: 1000 }, + stopReason: "stop", + expected: "1k / 3k", + }, + { + scenario: "a failed placeholder", + usage: {}, + stopReason: "error", + expected: "not reported", + }, + ] as const)( + "scopes the latest request's $scenario to its prior model after a switch", + ({ usage, stopReason, expected }) => { + const branch: SessionEntry[] = [ + message(assistant({ input: 1000, cacheRead: 9000 }), "earlier"), + message( + assistant(usage, { provider: "other", model: "prior-model", stopReason }), + "latest", + ), + { + type: "model_change", + id: "switch", + parentId: "latest", + timestamp: new Date(NOW).toISOString(), + provider: model.provider, + modelId: model.id, + }, + message({ role: "user", content: "Next request", timestamp: NOW }, "user"), + compaction("last", new Date(NOW).toISOString()), + ]; + const report = formatContextSummary({ ...status, branch }, NOW); + expect(report.split("\n")[2]).toBe( + `Cached input: ${expected} (last assistant request · other/prior-model)`, + ); + expect(report).not.toContain("9k / 10k"); + expect(report).not.toContain("none reported"); + }, + ); }); diff --git a/extensions/codex/src/status.ts b/extensions/codex/src/status.ts index c5c6264..f58b996 100644 --- a/extensions/codex/src/status.ts +++ b/extensions/codex/src/status.ts @@ -2,7 +2,7 @@ import type { Api, AssistantMessage, Model } from "@earendil-works/pi-ai"; import type { ContextUsage, SessionEntry } from "@earendil-works/pi-coding-agent"; import { modelKey } from "./profiles.ts"; -interface ContextStatus { +export interface ContextStatus { readonly model: Model; readonly usage: ContextUsage | undefined; readonly standardWindow: number | undefined; @@ -21,12 +21,12 @@ function validCount(value: number | null | undefined): value is number { return typeof value === "number" && Number.isFinite(value) && value >= 0; } -function tokens(value: number | null | undefined): string { - return validCount(value) ? TOKEN_FORMAT.format(value).toLowerCase() : "unknown"; +function validWindow(value: number | null | undefined): value is number { + return validCount(value) && value > 0; } -function row(label: string, value: string): string { - return ` ${label.padEnd(18)}${value}`; +function tokens(value: number | null | undefined): string { + return validCount(value) ? TOKEN_FORMAT.format(value).toLowerCase() : "unknown"; } function elapsed(timestamp: string, now: number): string { @@ -50,87 +50,78 @@ function lastAssistant(branch: readonly SessionEntry[]): AssistantMessage | unde return undefined; } -function cacheRows(message: AssistantMessage | undefined, currentModel: Model): string[] { - if (!message) return [row("Cached input", "not reported")]; +function cacheSummary(message: AssistantMessage | undefined, currentModel: Model): string { + if (!message) return "Cached input: not reported"; - const rows: string[] = []; const requestModel = modelKey({ provider: message.provider, id: message.model }); - if (requestModel !== modelKey(currentModel)) rows.push(row("Model", requestModel)); - + const scope = + requestModel === modelKey(currentModel) + ? "last assistant request" + : `last assistant request · ${requestModel}`; const usage = message.usage; - // Pi normalizes input into disjoint uncached, cache-read, and cache-write counts. - // An all-zero placeholder (e.g. a failed request) is not evidence of a cache miss. + let value = "not reported"; if ( - !usage || - !validCount(usage.input) || - !validCount(usage.cacheRead) || - !validCount(usage.cacheWrite) + usage && + validCount(usage.input) && + validCount(usage.cacheRead) && + validCount(usage.cacheWrite) ) { - return [...rows, row("Cached input", "not reported")]; + // Pi's uncached, cache-read and cache-write counts are disjoint; output is excluded. + const input = usage.input + usage.cacheRead + usage.cacheWrite; + if (Number.isFinite(input) && input > 0) { + // Zero cache counters may be adapter defaults, not a measured cache miss. + value = + usage.cacheRead > 0 + ? `${tokens(usage.cacheRead)} / ${tokens(input)}` + : `none reported · ${tokens(input)} input tokens`; + if (usage.cacheWrite > 0) value += ` · wrote ${tokens(usage.cacheWrite)}`; + } } - const input = usage.input + usage.cacheRead + usage.cacheWrite; - if (!Number.isFinite(input) || input === 0) return [...rows, row("Cached input", "not reported")]; - - // Some adapters also normalize absent cache counters to zero. Don't claim we - // know whether that means a measured miss or a provider without cache metrics. - rows.push( - row( - "Cached input", - usage.cacheRead > 0 - ? `${tokens(usage.cacheRead)} / ${tokens(input)} input tokens` - : `none reported (${tokens(input)} input tokens)`, - ), - ); - if (usage.cacheWrite > 0) rows.push(row("Cache writes", `${tokens(usage.cacheWrite)} tokens`)); - return rows; + return `Cached input: ${value} (${scope})`; } -/** Read-only report: no settings-file guesses, provider calls, or preference changes. */ -export function formatContextStatus(status: ContextStatus, now = Date.now()): string { - const { model, usage, standardWindow, extendedWindow, branch, desiredExtended, replayPaused } = +function windowLabel({ model, standardWindow, extendedWindow }: ContextStatus): string { + if (!validWindow(model.contextWindow)) return "window unknown"; + if (!validWindow(standardWindow)) return "baseline unknown"; + if (model.contextWindow === standardWindow) return "standard"; + if (validWindow(extendedWindow) && model.contextWindow === extendedWindow) return "extended"; + return "custom"; +} + +/** Read-only local snapshot; the caller supplies the model header and fast-mode hint. */ +export function formatContextSummary(status: ContextStatus, now = Date.now()): string { + const { model, usage, standardWindow, extendedWindow, desiredExtended, replayPaused, branch } = status; - const lines = [ - `codex · ${modelKey(model)}`, - "", - " window", - row( - "Usage", - `${tokens(usage?.tokens)} / ${tokens(usage?.contextWindow ?? model.contextWindow)}`, - ), - row("Standard", tokens(standardWindow)), - ]; + const selectedWindow = validWindow(model.contextWindow) ? model.contextWindow : undefined; + // Routed/virtual models may advertise a different window from the one used for compaction. + const effectiveWindow = validWindow(usage?.contextWindow) ? usage.contextWindow : selectedWindow; + const separateWindows = effectiveWindow !== selectedWindow; + const label = separateWindows ? "effective" : windowLabel(status); + const window = `${tokens(effectiveWindow)} tokens (${label})`; + let context = validCount(usage?.tokens) + ? `Context: ${tokens(usage.tokens)} / ${window}` + : `Context: ${window} · usage not reported`; - if (standardWindow === undefined) { - lines.push(row("Baseline", "unknown; reselect a catalog model")); - } else if (extendedWindow === standardWindow) { - lines.push(row("Baseline", "catalog already provides this window; no extension needed")); + if (separateWindows) { + context += ` · Selected context window: ${tokens(selectedWindow)} tokens (${windowLabel(status)})`; } - if (desiredExtended !== undefined) { - const preference = desiredExtended ? "extended" : "standard"; - lines.push(row("Branch preference", `${preference} (desired, not a receipt)`)); + const desired = desiredExtended ? "extended" : "standard"; + const target = desiredExtended ? extendedWindow : standardWindow; + if (!validWindow(target)) { + context += ` · ${desired} desired (target unknown)`; + } else if (selectedWindow !== target) { + context += ` · ${desired} pending${replayPaused ? "" : " normal prompt"}`; + } } - if (replayPaused) { - lines.push(row("Automatic replay", "paused; explicitly extend or restore")); - } - - const extendedLabel = - extendedWindow === undefined ? "not verified" : `${tokens(extendedWindow)} (local window)`; - lines.push(row("Extended", extendedLabel)); + if (replayPaused) context += " · replay paused"; const compactions = branch.filter((entry) => entry.type === "compaction"); const lastCompaction = compactions.at(-1); - const lastCompacted = lastCompaction - ? elapsed(lastCompaction.timestamp, now) - : "never on this branch"; - lines.push( - "", - " compaction", - row("Compactions", `${compactions.length} on this branch`), - row("Last compacted", lastCompacted), - "", - " cache · last assistant request", - ...cacheRows(lastAssistant(branch), model), - ); - return lines.join("\n"); + let compaction = `Compaction: ${compactions.length} on this branch`; + if (lastCompaction) { + const age = elapsed(lastCompaction.timestamp, now); + compaction += age === "unknown" ? " · last time unknown" : ` · last ${age}`; + } + return [context, compaction, cacheSummary(lastAssistant(branch), model)].join("\n"); } diff --git a/extensions/codex/src/usage-controller.test.ts b/extensions/codex/src/usage-controller.test.ts index 1663d16..25f9cb2 100644 --- a/extensions/codex/src/usage-controller.test.ts +++ b/extensions/codex/src/usage-controller.test.ts @@ -2,6 +2,295 @@ import { expect, it, vi } from "vitest"; import { CodexAccountError, type CodexAccountRegistry, type fetchCodexUsage } from "./client.ts"; import { UsageController, type UsageHost } from "./usage-controller.ts"; +it("shows temporary status immediately, then clears it before reporting the account", async () => { + const f = fixture(); + const response = deferred(); + f.fetchUsage.mockImplementation(() => { + expect(f.ctx.ui.setStatus).toHaveBeenCalledExactlyOnceWith( + "eleith-codex-usage", + "Codex: fetching usage…", + ); + return response.promise; + }); + const request = f.controller.report(f.ctx); + expect(f.ctx.ui.setStatus).toHaveBeenCalledExactlyOnceWith( + "eleith-codex-usage", + "Codex: fetching usage…", + ); + expect(f.ctx.ui.notify).not.toHaveBeenCalled(); + expect(f.fetchUsage).toHaveBeenCalledExactlyOnceWith( + f.ctx.modelRegistry, + expect.any(AbortSignal), + ); + + f.ctx.ui.notify.mockImplementation(() => { + expect(f.ctx.ui.setStatus).toHaveBeenLastCalledWith("eleith-codex-usage", undefined); + expire(f.ctx); + }); + const notify = f.ctx.ui.notify; + const setStatus = f.ctx.ui.setStatus; + response.resolve({ plan_type: "plus" }); + await request; + expect(notify).toHaveBeenCalledExactlyOnceWith( + expect.stringContaining("codex account usage · openai-codex"), + "info", + ); + expect(setStatus).toHaveBeenCalledTimes(2); +}); + +it("supersedes using the new context and never lets a late reply clear the new marker", async () => { + const f = fixture(); + const first = deferred(); + const second = deferred(); + f.fetchUsage.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise); + const oldRequest = f.controller.report(f.ctx); + const oldSignal = f.fetchUsage.mock.calls[0][1]!; + const oldUI = f.ctx.ui; + const fresh = host(); + expire(f.ctx); + const newRequest = f.controller.report(fresh); + expect(oldSignal.aborted).toBe(true); + expect(oldUI.setStatus).toHaveBeenCalledTimes(1); + expect(fresh.ui.setStatus.mock.calls).toEqual([ + ["eleith-codex-usage", undefined], + ["eleith-codex-usage", "Codex: fetching usage…"], + ]); + + first.resolve({ plan_type: "old-plan" }); + await oldRequest; + expect(oldUI.notify).not.toHaveBeenCalled(); + expect(fresh.ui.setStatus).toHaveBeenCalledTimes(2); + expect(fresh.ui.notify).not.toHaveBeenCalled(); + second.resolve({ plan_type: "new-plan" }); + await newRequest; + expect(fresh.ui.setStatus).toHaveBeenLastCalledWith("eleith-codex-usage", undefined); + expect(fresh.ui.setStatus).toHaveBeenCalledTimes(3); + expect(fresh.ui.notify).toHaveBeenCalledExactlyOnceWith( + expect.stringContaining("Plan: new-plan"), + "info", + ); +}); + +it.each(["cancel", "dispose"] as const)( + "%s clears with fresh context before expiry and ignores late success and error", + async (action) => { + for (const fails of [false, true]) { + const f = fixture(); + const response = deferred(); + f.fetchUsage.mockReturnValue(response.promise); + const request = f.controller.report(f.ctx); + const signal = f.fetchUsage.mock.calls[0][1]!; + const oldUI = f.ctx.ui; + const fresh = host(); + const freshUI = fresh.ui; + expire(f.ctx); + f.controller[action](fresh); + expect(signal.aborted).toBe(true); + expect(freshUI.setStatus).toHaveBeenCalledExactlyOnceWith("eleith-codex-usage", undefined); + f.controller[action](fresh); + expect(freshUI.setStatus).toHaveBeenCalledTimes(1); + expire(fresh); + if (fails) response.reject(new Error("private late failure")); + else response.resolve({ plan_type: "plus" }); + await request; + expect(oldUI.setStatus).toHaveBeenCalledTimes(1); + expect(oldUI.notify).not.toHaveBeenCalled(); + expect(freshUI.setStatus).toHaveBeenCalledTimes(1); + expect(freshUI.notify).not.toHaveBeenCalled(); + if (action === "dispose") { + await f.controller.report(f.ctx); + expect(f.fetchUsage).toHaveBeenCalledOnce(); + } + } + }, +); + +it("can start another explicit request after cancellation without waiting for the old request", async () => { + const f = fixture(); + const response = deferred(); + f.fetchUsage.mockReturnValueOnce(response.promise); + const oldRequest = f.controller.report(f.ctx); + f.controller.cancel(f.ctx); + await f.controller.report(f.ctx); + expect(f.ctx.ui.notify).toHaveBeenCalledExactlyOnceWith( + expect.stringContaining("Plan: plus"), + "info", + ); + expect(f.ctx.ui.setStatus).toHaveBeenCalledTimes(4); + response.reject(new CodexAccountError("http", "Late rate limit", 60_000)); + await oldRequest; + await f.controller.report(f.ctx); + expect(f.fetchUsage).toHaveBeenCalledTimes(3); + expect(f.ctx.ui.setStatus).toHaveBeenCalledTimes(6); +}); + +it("clears a completed request without reporting to a replacement session", async () => { + const f = fixture(); + const response = deferred(); + f.fetchUsage.mockReturnValue(response.promise); + const request = f.controller.report(f.ctx); + f.ctx.sessionManager.getSessionId.mockReturnValue("different-session"); + response.resolve({ plan_type: "plus" }); + await request; + expect(f.ctx.ui.notify).not.toHaveBeenCalled(); + expect(f.ctx.ui.setStatus).toHaveBeenLastCalledWith("eleith-codex-usage", undefined); + expect(f.ctx.ui.setStatus).toHaveBeenCalledTimes(2); + f.controller.cancel(f.ctx); + expect(f.ctx.ui.setStatus).toHaveBeenCalledTimes(2); +}); + +it.each([ + [ + "unrecognized response", + { private_account_data: "do-not-display" }, + undefined, + "Codex account service returned an unrecognized usage response.", + ], + [ + "unexpected error", + undefined, + new Error("private failure details"), + "Could not read Codex account usage.", + ], + [ + "account error", + undefined, + new CodexAccountError("timeout", "Codex account request timed out."), + "Codex account request timed out.", + ], +] as const)("clears status before reporting an %s", async (_name, data, error, message) => { + const f = fixture(); + const response = deferred(); + f.fetchUsage.mockReturnValue(response.promise); + const request = f.controller.report(f.ctx); + expect(f.ctx.ui.notify).not.toHaveBeenCalled(); + f.ctx.ui.notify.mockImplementation(() => { + expect(f.ctx.ui.setStatus).toHaveBeenLastCalledWith("eleith-codex-usage", undefined); + }); + if (error) response.reject(error); + else response.resolve(data); + await request; + expect(f.ctx.ui.notify).toHaveBeenCalledExactlyOnceWith(message, "warning"); + expect(f.ctx.ui.setStatus).toHaveBeenCalledTimes(2); +}); + +it("honors retry-after without polling or showing a fetching marker for a blocked request", async () => { + const f = fixture(); + f.fetchUsage.mockRejectedValueOnce( + new CodexAccountError("http", "Codex account service is rate limited.", 60_000), + ); + await f.controller.report(f.ctx); + expect(f.ctx.ui.setStatus).toHaveBeenCalledTimes(2); + await f.controller.report(f.ctx); + expect(f.fetchUsage).toHaveBeenCalledOnce(); + expect(f.ctx.ui.setStatus).toHaveBeenCalledTimes(2); + expect(f.ctx.ui.notify).toHaveBeenLastCalledWith( + "Codex account service is rate limited. Try again in 60s.", + "warning", + ); + f.clock.now += 60_000; + expect(f.fetchUsage).toHaveBeenCalledOnce(); + await f.controller.report(f.ctx); + expect(f.fetchUsage).toHaveBeenCalledTimes(2); + expect(f.ctx.ui.setStatus).toHaveBeenCalledTimes(4); + expect(f.ctx.ui.notify).toHaveBeenLastCalledWith(expect.stringContaining("Plan: plus"), "info"); +}); + +it.each([ + { + outcome: "success", + fetch: async () => ({ plan_type: "plus" }), + message: expect.stringContaining("Plan: plus"), + level: "info", + }, + { + outcome: "failure", + fetch: async () => { + throw new Error("private failure"); + }, + message: "Could not read Codex account usage.", + level: "warning", + }, +] as const)( + "reports $outcome after plain progress without UI", + async ({ fetch, message, level }) => { + const f = fixture(false); + f.fetchUsage.mockImplementationOnce(fetch); + const request = f.controller.report(f.ctx); + expect(f.ctx.ui.notify).toHaveBeenCalledExactlyOnceWith("Fetching Codex usage…", "info"); + expect(f.ctx.ui.setStatus).not.toHaveBeenCalled(); + + await request; + expect(f.ctx.ui.notify).toHaveBeenCalledTimes(2); + expect(f.ctx.ui.notify).toHaveBeenLastCalledWith(message, level); + expect(f.ctx.ui.setStatus).not.toHaveBeenCalled(); + }, +); + +it.each(["cancel", "dispose"] as const)( + "%s suppresses completion after plain progress without UI", + async (action) => { + const f = fixture(false); + const response = deferred(); + f.fetchUsage.mockReturnValueOnce(response.promise); + const request = f.controller.report(f.ctx); + expect(f.ctx.ui.notify).toHaveBeenCalledExactlyOnceWith("Fetching Codex usage…", "info"); + expect(f.ctx.ui.setStatus).not.toHaveBeenCalled(); + + f.controller[action](f.ctx); + expect(f.fetchUsage.mock.calls[0][1]!.aborted).toBe(true); + response.resolve({ plan_type: "old-plan" }); + await request; + expect(f.ctx.ui.notify).toHaveBeenCalledExactlyOnceWith("Fetching Codex usage…", "info"); + expect(f.ctx.ui.setStatus).not.toHaveBeenCalled(); + }, +); + +it("replaces a request without UI and ignores the old completion", async () => { + const f = fixture(false); + const response = deferred(); + f.fetchUsage.mockReturnValueOnce(response.promise); + const oldRequest = f.controller.report(f.ctx); + const oldSignal = f.fetchUsage.mock.calls[0][1]!; + expect(f.ctx.ui.notify).toHaveBeenCalledExactlyOnceWith("Fetching Codex usage…", "info"); + + await f.controller.report(f.ctx); + expect(oldSignal.aborted).toBe(true); + expect(f.fetchUsage).toHaveBeenCalledTimes(2); + expect(f.ctx.ui.notify.mock.calls).toEqual([ + ["Fetching Codex usage…", "info"], + ["Fetching Codex usage…", "info"], + [expect.stringContaining("Plan: plus"), "info"], + ]); + + response.resolve({ plan_type: "old-plan" }); + await oldRequest; + expect(f.ctx.ui.notify).toHaveBeenCalledTimes(3); + expect(f.ctx.ui.setStatus).not.toHaveBeenCalled(); +}); + +it("does not hide a final notification failure or try to clear after it invalidates context", async () => { + const f = fixture(); + const setStatus = f.ctx.ui.setStatus; + f.ctx.ui.notify.mockImplementation(() => { + expire(f.ctx); + throw new Error("UI failure"); + }); + await expect(f.controller.report(f.ctx)).rejects.toThrow("UI failure"); + expect(setStatus).toHaveBeenCalledTimes(2); + f.controller.cancel(host()); + expect(setStatus).toHaveBeenCalledTimes(2); +}); + +it("does not swallow a status cleanup failure", async () => { + const f = fixture(); + f.ctx.ui.setStatus.mockImplementation((_key, text) => { + if (text === undefined) throw new Error("Status cleanup failure"); + }); + await expect(f.controller.report(f.ctx)).rejects.toThrow("Status cleanup failure"); + expect(f.ctx.ui.notify).not.toHaveBeenCalled(); +}); + function deferred() { let resolve!: (value: T) => void; let reject!: (error: unknown) => void; @@ -12,135 +301,33 @@ function deferred() { return { promise, resolve, reject }; } -function fixture() { - const clock = { now: Date.parse("2026-01-10T12:00:00Z") }; - const fetchUsage = vi.fn(async () => ({ plan_type: "plus" })); - const ctx = { +function host(hasUI = true) { + return { + hasUI, modelRegistry: {} as CodexAccountRegistry, sessionManager: { getSessionId: vi.fn(() => "session") }, - ui: { notify: vi.fn() }, + ui: { + notify: vi.fn(), + setStatus: vi.fn(), + }, } satisfies UsageHost; +} + +function fixture(hasUI = true) { + const clock = { now: Date.parse("2026-01-10T12:00:00Z") }; + const fetchUsage = vi.fn(async () => ({ plan_type: "plus" })); + const ctx = host(hasUI); const controller = new UsageController(fetchUsage, () => clock.now); return { controller, ctx, fetchUsage, clock }; } -it("reports the configured Codex account independently of the selected model", async () => { - const f = fixture(); - await f.controller.report(f.ctx); - expect(f.fetchUsage).toHaveBeenCalledExactlyOnceWith( - f.ctx.modelRegistry, - expect.any(AbortSignal), - ); - expect(f.ctx.ui.notify).toHaveBeenCalledExactlyOnceWith( - expect.stringContaining("codex account usage · openai-codex"), - "info", - ); -}); - -it("supersedes an earlier request and ignores its late reply", async () => { - const f = fixture(); - const first = deferred(); - f.fetchUsage.mockReturnValueOnce(first.promise).mockResolvedValueOnce({ plan_type: "new-plan" }); - const oldRequest = f.controller.report(f.ctx); - const oldSignal = f.fetchUsage.mock.calls[0][1]!; - await f.controller.report(f.ctx); - expect(oldSignal.aborted).toBe(true); - first.resolve({ plan_type: "old-plan" }); - await oldRequest; - expect(f.ctx.ui.notify).toHaveBeenCalledExactlyOnceWith( - expect.stringContaining("Plan: new-plan"), - "info", - ); -}); - -it.each(["cancel", "dispose"] as const)( - "%s discards a late reply before accessing expired context", - async (action) => { - const f = fixture(); - const response = deferred(); - f.fetchUsage.mockReturnValue(response.promise); - const request = f.controller.report(f.ctx); - const signal = f.fetchUsage.mock.calls[0][1]!; - const notify = f.ctx.ui.notify; - f.controller[action](); - Object.defineProperty(f.ctx, "sessionManager", { +function expire(ctx: UsageHost) { + for (const key of ["hasUI", "sessionManager", "modelRegistry", "ui"]) { + Object.defineProperty(ctx, key, { get: () => { throw new Error("Expired context"); }, + configurable: true, }); - Object.defineProperty(f.ctx, "ui", { - get: () => { - throw new Error("Expired context"); - }, - }); - response.resolve({ plan_type: "plus" }); - await request; - expect(signal.aborted).toBe(true); - expect(notify).not.toHaveBeenCalled(); - if (action === "dispose") await f.controller.report(f.ctx); - }, -); - -it("disposal suppresses a late error and is idempotent", async () => { - const f = fixture(); - const response = deferred(); - f.fetchUsage.mockReturnValue(response.promise); - const request = f.controller.report(f.ctx); - f.controller.dispose(); - f.controller.dispose(); - response.reject(new Error("private late failure")); - await request; - await f.controller.report(f.ctx); - expect(f.ctx.ui.notify).not.toHaveBeenCalled(); - expect(f.fetchUsage).toHaveBeenCalledOnce(); -}); - -it("does not deliver a reply to a replacement session", async () => { - const f = fixture(); - const response = deferred(); - f.fetchUsage.mockReturnValue(response.promise); - const request = f.controller.report(f.ctx); - f.ctx.sessionManager.getSessionId.mockReturnValue("different-session"); - response.resolve({ plan_type: "plus" }); - await request; - expect(f.ctx.ui.notify).not.toHaveBeenCalled(); -}); - -it("reports an unrecognized schema without dumping account data", async () => { - const f = fixture(); - f.fetchUsage.mockResolvedValue({ private_account_data: "do-not-display" }); - await f.controller.report(f.ctx); - expect(f.ctx.ui.notify).toHaveBeenCalledExactlyOnceWith( - "Codex account service returned an unrecognized usage response.", - "warning", - ); -}); - -it("does not expose unexpected error details", async () => { - const f = fixture(); - f.fetchUsage.mockRejectedValue(new Error("private failure details")); - await f.controller.report(f.ctx); - expect(f.ctx.ui.notify).toHaveBeenCalledExactlyOnceWith( - "Could not read Codex account usage.", - "warning", - ); -}); - -it("honors retry-after without polling or making another request until explicitly asked", async () => { - const f = fixture(); - f.fetchUsage.mockRejectedValueOnce( - new CodexAccountError("http", "Codex account service is rate limited.", 60_000), - ); - await f.controller.report(f.ctx); - await f.controller.report(f.ctx); - expect(f.fetchUsage).toHaveBeenCalledOnce(); - expect(f.ctx.ui.notify).toHaveBeenLastCalledWith( - "Codex account service is rate limited. Try again in 60s.", - "warning", - ); - f.clock.now += 60_000; - expect(f.fetchUsage).toHaveBeenCalledOnce(); - await f.controller.report(f.ctx); - expect(f.fetchUsage).toHaveBeenCalledTimes(2); - expect(f.ctx.ui.notify).toHaveBeenLastCalledWith(expect.stringContaining("Plan: plus"), "info"); -}); + } +} diff --git a/extensions/codex/src/usage-controller.ts b/extensions/codex/src/usage-controller.ts index 02a55de..5d6cf8e 100644 --- a/extensions/codex/src/usage-controller.ts +++ b/extensions/codex/src/usage-controller.ts @@ -2,14 +2,23 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; import { CodexAccountError, fetchCodexUsage, type CodexAccountRegistry } from "./client.ts"; import { formatCodexUsage } from "./usage.ts"; +const STATUS_KEY = "eleith-codex-usage"; + export interface UsageHost { + readonly hasUI: boolean; readonly modelRegistry: CodexAccountRegistry; readonly sessionManager: Pick; - readonly ui: Pick; + readonly ui: Pick; +} + +interface PendingUsage { + readonly controller: AbortController; + readonly sessionId: string; + indicatorVisible: boolean; } export class UsageController { - private pending: AbortController | undefined; + private pending: PendingUsage | undefined; private retryAt = 0; private disposed = false; @@ -20,50 +29,94 @@ export class UsageController { async report(ctx: UsageHost): Promise { if (this.disposed) return; - this.cancel(); + this.cancel(ctx); if (this.now() < this.retryAt) { const seconds = Math.ceil((this.retryAt - this.now()) / 1000); ctx.ui.notify(`Codex account service is rate limited. Try again in ${seconds}s.`, "warning"); return; } - const sessionId = ctx.sessionManager.getSessionId(); - const operation = new AbortController(); + const operation: PendingUsage = { + controller: new AbortController(), + sessionId: ctx.sessionManager.getSessionId(), + indicatorVisible: ctx.hasUI, + }; this.pending = operation; + if (operation.indicatorVisible) { + ctx.ui.setStatus(STATUS_KEY, "Codex: fetching usage…"); + } else { + ctx.ui.notify("Fetching Codex usage…", "info"); + } + if (!this.canDeliverReport(operation, ctx)) { + this.finish(operation, ctx); + return; + } + + let result: { data: unknown } | { error: unknown }; try { - const data = await this.fetchUsage(ctx.modelRegistry, operation.signal); - if (!this.canDeliverReport(operation, ctx, sessionId)) return; - const report = formatCodexUsage(data, this.now()); - if (report === undefined) { - ctx.ui.notify("Codex account service returned an unrecognized usage response.", "warning"); - } else { - ctx.ui.notify(report, "info"); - } + result = { data: await this.fetchUsage(ctx.modelRegistry, operation.controller.signal) }; } catch (error) { - if (!this.canDeliverReport(operation, ctx, sessionId)) return; - const message = + result = { error }; + } + if (!this.canDeliverReport(operation, ctx)) { + this.finish(operation, ctx); + return; + } + + let message: string; + let level: "info" | "warning" = "warning"; + if ("error" in result) { + const error = result.error; + message = error instanceof CodexAccountError ? error.message : "Could not read Codex account usage."; if (error instanceof CodexAccountError && error.retryAfterMs !== undefined) { this.retryAt = this.now() + error.retryAfterMs; } - ctx.ui.notify(message, "warning"); - } finally { - if (this.pending === operation) this.pending = undefined; + } else { + const report = formatCodexUsage(result.data, this.now()); + message = report ?? "Codex account service returned an unrecognized usage response."; + if (report !== undefined) level = "info"; } - } - cancel(): void { - this.pending?.abort(); + // Clear before notifying: the notification itself may reload the extension. + this.clearIndicator(operation, ctx); + if (!this.canDeliverReport(operation, ctx)) { + this.finish(operation, ctx); + return; + } this.pending = undefined; + ctx.ui.notify(message, level); } - dispose(): void { + cancel(ctx: UsageHost): void { + const operation = this.pending; + this.pending = undefined; + if (!operation) return; + operation.controller.abort(); + this.clearIndicator(operation, ctx); + } + + dispose(ctx: UsageHost): void { if (this.disposed) return; this.disposed = true; - this.cancel(); + this.cancel(ctx); } - private canDeliverReport(operation: AbortController, ctx: UsageHost, sessionId: string): boolean { - if (this.disposed || this.pending !== operation || operation.signal.aborted) return false; - return ctx.sessionManager.getSessionId() === sessionId; + private clearIndicator(operation: PendingUsage, ctx: UsageHost): void { + if (!operation.indicatorVisible) return; + operation.indicatorVisible = false; + if (ctx.hasUI) ctx.ui.setStatus(STATUS_KEY, undefined); + } + + private finish(operation: PendingUsage, ctx: UsageHost): void { + if (this.pending !== operation) return; + this.pending = undefined; + this.clearIndicator(operation, ctx); + } + + private canDeliverReport(operation: PendingUsage, ctx: UsageHost): boolean { + // Invalidation must win before touching any potentially expired context getter. + if (this.disposed || this.pending !== operation || operation.controller.signal.aborted) + return false; + return ctx.sessionManager.getSessionId() === operation.sessionId; } } diff --git a/extensions/codex/src/usage-sdk.test.ts b/extensions/codex/src/usage-sdk.test.ts index 63c1427..35b1797 100644 --- a/extensions/codex/src/usage-sdk.test.ts +++ b/extensions/codex/src/usage-sdk.test.ts @@ -52,7 +52,7 @@ it("SDK usage reuses Pi's OAuth without a model turn or credential write", async vi.stubGlobal("fetch", accountRequest); const { runtime, model, accessToken, credentialWrites, modelTurns } = await createOfflineCodexRuntime(); - const { session, sessionManager, notify, extensionErrors, closeSession } = + const { session, sessionManager, notify, setStatus, extensionErrors, closeSession } = await openCodexExtensionSession(runtime, model); try { const branch = structuredClone(sessionManager.getBranch()); @@ -61,10 +61,17 @@ it("SDK usage reuses Pi's OAuth without a model turn or credential write", async await session.prompt("/eleith:codex usage"); expect(notify).toHaveBeenLastCalledWith( - expect.stringContaining("Primary (5h): 25% used"), + expect.stringContaining("5h window: 75% remaining (25% used)"), "info", ); expect(accountRequest).toHaveBeenCalledOnce(); + expect(setStatus.mock.calls).toEqual([ + ["eleith-codex-usage", "Codex: fetching usage…"], + ["eleith-codex-usage", undefined], + ]); + expect(setStatus.mock.invocationCallOrder[1]).toBeLessThan( + notify.mock.invocationCallOrder.at(-1)!, + ); const headers = new Headers(accountRequest.mock.calls[0][1]?.headers); expect(headers.get("authorization")).toBe(`Bearer ${accessToken}`); expect(headers.get("chatgpt-account-id")).toBe("sdk-test-account"); @@ -150,16 +157,18 @@ async function openCodexExtensionSession(runtime: ModelRuntime, model: Model extensionErrors.push(error), }); return { session, sessionManager, notify, + setStatus, extensionErrors, async closeSession() { await session.extensionRunner.emit({ type: "session_shutdown", reason: "quit" }); diff --git a/extensions/codex/src/usage.test.ts b/extensions/codex/src/usage.test.ts index 2283ad1..4e4ea63 100644 --- a/extensions/codex/src/usage.test.ts +++ b/extensions/codex/src/usage.test.ts @@ -1,4 +1,4 @@ -import { expect, it } from "vitest"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { formatCodexUsage } from "./usage.ts"; const NOW = Date.parse("2026-01-10T12:00:00Z"); @@ -7,8 +7,19 @@ const primary = { limit_window_seconds: 1800, reset_at: NOW / 1000 + 3600, }; +const LocalDateTimeFormat = Intl.DateTimeFormat; +let timeZone = "UTC"; -it("formats an exact account report without mutating the payload", () => { +beforeEach(() => { + timeZone = "UTC"; + // Pin only the test's local zone; production must use the user's local zone. + vi.spyOn(Intl, "DateTimeFormat").mockImplementation(function (locales, options) { + return new LocalDateTimeFormat(locales, { ...options, timeZone }); + }); +}); +afterEach(() => vi.restoreAllMocks()); + +it("formats a remaining-first account report without mutating the payload", () => { const payload = { plan_type: "plus", account_id: "do-not-display-account-id", @@ -20,17 +31,16 @@ it("formats an exact account report without mutating the payload", () => { const report = formatCodexUsage(payload, NOW)!; expect(report).toBe(`codex account usage · openai-codex Plan: plus +Credits: 12.5 balance (available) Codex - Primary (30m): 25% used · 75% remaining - Resets: 2026-01-10 13:00:00 UTC (in 1h) - Secondary: not reported - -Credits: 12.5 balance (available)`); + 30m window: 75% remaining (25% used) + Resets: Jan 10, 2026, 13:00:00 UTC (in 1h) + Secondary window: not reported`); expect(payload).toEqual(original); }); -it("does not invent windows or unlimited credits for plans with missing quota data", () => { +it("summarizes absent data without inventing windows or unlimited credits", () => { const report = formatCodexUsage( { plan_type: "pro", @@ -40,18 +50,31 @@ it("does not invent windows or unlimited credits for plans with missing quota da }, NOW, )!; - expect(report).toContain("Codex\n Not reported"); - expect(report).toContain("Code review\n Not reported"); - expect(report).toContain("Credits: not reported"); + expect(report).toBe(`codex account usage · openai-codex +Plan: pro + +Not reported: Codex quota, Code review quota, credits.`); expect(report).not.toMatch(/0%|5h|7d|unlimited/); }); +it("does not add an unadvertised review quota or default windows to a plan-only report", () => { + expect(formatCodexUsage({ plan_type: "plus" }, NOW)).toBe( + "codex account usage · openai-codex\nPlan: plus\n\nNot reported: Codex quota, credits.", + ); +}); + it.each([ - [0, "0% used · 100% remaining"], - [-1, "usage unknown"], - [101, "usage unknown"], - [NaN, "usage unknown"], - ["25", "usage unknown"], + [0, "100% remaining (0% used)"], + [25, "75% remaining (25% used)"], + [100, "0% remaining (100% used)"], + [12.5, "87.5% remaining (12.5% used)"], + [-1, "remaining not reported"], + [101, "remaining not reported"], + [NaN, "remaining not reported"], + [Infinity, "remaining not reported"], + ["25", "remaining not reported"], + [null, "remaining not reported"], + [undefined, "remaining not reported"], ] as const)("keeps invalid percentages distinct from genuine zero: %s", (used, expected) => { const report = formatCodexUsage( { @@ -59,7 +82,43 @@ it.each([ }, NOW, )!; - expect(report).toContain(expected); + expect(report).toContain(`30m window: ${expected}`); + if (expected === "remaining not reported") expect(report).not.toContain("%"); +}); + +it.each([ + [1800, "30m window"], + [18_000, "5h window"], + [604_800, "7d window"], + [90_061, "1d 1h 1m 1s window"], + [0, "Primary window (duration not reported)"], + [-1, "Primary window (duration not reported)"], + [1.5, "Primary window (duration not reported)"], + [Number.MAX_SAFE_INTEGER + 1, "Primary window (duration not reported)"], + ["18000", "Primary window (duration not reported)"], + [undefined, "Primary window (duration not reported)"], +] as const)("labels only the actual advertised duration: %s", (seconds, expected) => { + const report = formatCodexUsage( + { rate_limit: { primary_window: { ...primary, limit_window_seconds: seconds } } }, + NOW, + )!; + expect(report).toContain(`${expected}: 75% remaining (25% used)`); +}); + +it("groups actual primary and secondary durations under their quota", () => { + const report = formatCodexUsage( + { + rate_limit: { + primary_window: { ...primary, limit_window_seconds: 18_000 }, + secondary_window: { ...primary, used_percent: 10, limit_window_seconds: 604_800 }, + }, + }, + NOW, + )!; + expect(report).toContain("Codex\n 5h window: 75% remaining (25% used)"); + expect(report).toContain(" 7d window: 90% remaining (10% used)"); + expect(report).not.toContain("Primary"); + expect(report).not.toContain("Secondary"); }); it("uses supplied relative resets without guessing a window duration", () => { @@ -75,11 +134,35 @@ it("uses supplied relative resets without guessing a window duration", () => { }, NOW, )!; - expect(report).toContain("Primary (duration unknown): 40% used · 60% remaining"); - expect(report).toContain("2026-01-10 12:01:30 UTC (in 1m 30s)"); + expect(report).toContain("Primary window (duration not reported): 60% remaining (40% used)"); + expect(report).toContain("Jan 10, 2026, 12:01:30 UTC (in 1m 30s)"); }); -it("reports unknown invalid reset times and never resets a measured balance locally", () => { +it.each([ + ["2026-01-10T12:00:00Z", "Jan 10, 2026, 08:00:00 EST"], + ["2026-07-10T12:00:00Z", "Jul 10, 2026, 09:00:00 EDT"], + ["2026-01-10T00:00:00Z", "Jan 9, 2026, 20:00:00 EST"], +])("uses local reset date, zone and relative time: %s", (now, expected) => { + timeZone = "America/New_York"; + const report = formatCodexUsage( + { rate_limit: { primary_window: { reset_after_seconds: 3600 } } }, + Date.parse(now), + )!; + expect(report).toContain(`Resets: ${expected} (in 1h)`); + expect(report).toContain("remaining not reported"); + expect(report).not.toContain("UTC"); +}); + +it("prefers an absolute reset to a conflicting relative reset", () => { + const report = formatCodexUsage( + { rate_limit: { primary_window: { ...primary, reset_after_seconds: 90 } } }, + NOW, + )!; + expect(report).toContain("Jan 10, 2026, 13:00:00 UTC (in 1h)"); + expect(report).not.toContain("in 1m 30s"); +}); + +it("reports invalid resets as unknown and never renews a measured balance locally", () => { const report = formatCodexUsage( { rate_limit: { @@ -90,10 +173,32 @@ it("reports unknown invalid reset times and never resets a measured balance loca }, NOW, )!; - expect(report).toContain("100% used · 0% remaining"); - expect(report).toContain("reset time passed; refresh usage"); + expect(report).toContain("0% remaining (100% used)"); + expect(report).toContain("Jan 10, 2026, 11:59:59 UTC (reset time passed; refresh usage)"); expect(report).toContain("Resets: unknown"); - expect(report).toContain("Limit reached"); + expect(report.indexOf("Codex: limit reached")).toBeLessThan(report.indexOf("\nCodex\n")); + expect(report).not.toContain("100% remaining"); +}); + +it.each([ + {}, + { reset_at: "1768050000" }, + { reset_at: 1e20, reset_after_seconds: 60 }, + { reset_after_seconds: -1 }, + { reset_after_seconds: "60" }, +])("does not fabricate a reset from missing or invalid values: %j", (reset) => { + expect( + formatCodexUsage({ rate_limit: { primary_window: { used_percent: 25, ...reset } } }, NOW), + ).toContain("Resets: unknown"); +}); + +it("treats a reset exactly at now as expired without claiming renewal", () => { + const report = formatCodexUsage( + { rate_limit: { primary_window: { used_percent: 100, reset_after_seconds: 0 } } }, + NOW, + )!; + expect(report).toContain("0% remaining (100% used)"); + expect(report).toContain("reset time passed; refresh usage"); }); it.each([ @@ -102,14 +207,25 @@ it.each([ [{ has_credits: false, balance: "0" }, "0 balance (unavailable)"], [{ has_credits: true }, "available; balance not reported"], [{ has_credits: false, balance: "0x10" }, "unavailable; balance not reported"], + [{ balance: 0 }, "0 balance (availability unknown)"], + [{ balance: -1 }, "availability unknown; balance not reported"], + [{ balance: Infinity }, "availability unknown; balance not reported"], + [{ balance: "1e3" }, "availability unknown; balance not reported"], + [{ balance: " 12.50 " }, "12.5 balance (availability unknown)"], + [{ unlimited: "true", has_credits: "true" }, "availability unknown; balance not reported"], ] as const)("reports only supplied credit facts: %j", (credits, expected) => { expect(formatCodexUsage({ credits }, NOW)).toContain(`Credits: ${expected}`); }); -it("includes review and additional buckets plus explicit account restrictions", () => { +it("puts spending, account and all bucket restrictions before healthy quotas", () => { const report = formatCodexUsage( { - code_review_rate_limit: { primary_window: { ...primary, limit_window_seconds: 604_800 } }, + rate_limit: { primary_window: primary }, + code_review_rate_limit: { + limit_reached: true, + allowed: false, + primary_window: { ...primary, limit_window_seconds: 604_800 }, + }, additional_rate_limits: [ { metered_feature: "codex_other", @@ -117,16 +233,60 @@ it("includes review and additional buckets plus explicit account restrictions", rate_limit: { allowed: false, primary_window: primary }, }, ], + credits: { has_credits: false, balance: "0" }, spend_control: { reached: true }, rate_limit_reached_type: { type: "workspace_owner_credits_depleted" }, }, NOW, )!; - expect(report).toContain("Code review\n Primary (7d)"); - expect(report).toContain("Other model pool\n Primary (30m)"); - expect(report).toContain("Requests not currently allowed"); - expect(report).toContain("Workspace spending limit: reached"); - expect(report).toContain("Account limit: workspace owner credits depleted"); + expect(report.startsWith("codex account usage · openai-codex\n")).toBe(true); + for (const warning of [ + "Workspace spending limit: reached", + "Account limit: workspace owner credits depleted", + "Code review: limit reached; requests not currently allowed", + "Other model pool: requests not currently allowed", + "Credits: 0 balance (unavailable)", + ]) { + expect(report).toContain(warning); + expect(report.indexOf(warning)).toBeLessThan(report.indexOf("\nCodex\n")); + } + expect(report.indexOf("Workspace spending limit")).toBeLessThan(report.indexOf("Code review:")); + expect(report).toContain("Code review\n 7d window: 75% remaining (25% used)"); + expect(report).toContain("Other model pool\n 30m window: 75% remaining (25% used)"); +}); + +it("keeps access restrictions visible even when all quota windows are absent", () => { + const report = formatCodexUsage( + { + rate_limit: { allowed: false }, + code_review_rate_limit: { limit_reached: true }, + additional_rate_limits: [{ metered_feature: "Extra", rate_limit: { allowed: false } }], + }, + NOW, + )!; + expect(report).toBe(`codex account usage · openai-codex +Plan: unknown + +Codex: requests not currently allowed +Code review: limit reached +Extra: requests not currently allowed + +Not reported: Codex quota, Code review quota, Extra quota, credits.`); +}); + +it("does not interpret truthy restriction values as explicit restrictions", () => { + const report = formatCodexUsage( + { + rate_limit: { allowed: "false", limit_reached: "true" }, + spend_control: { reached: "true" }, + rate_limit_reached_type: { type: 123 }, + }, + NOW, + )!; + expect(report).not.toMatch( + /requests not currently allowed|limit reached|spending limit|Account limit/, + ); + expect(report).toContain("Not reported: Codex quota, credits."); }); it("bounds and sanitizes backend labels and additional-bucket output", () => { @@ -146,6 +306,25 @@ it("bounds and sanitizes backend labels and additional-bucket output", () => { expect(report.length).toBeLessThan(5000); }); +it("sanitizes restriction labels and retains additional-bucket name fallbacks", () => { + const report = formatCodexUsage( + { + rate_limit_reached_type: { type: "owner\r\ncredits\u001bdepleted" }, + additional_rate_limits: [ + { limit_name: "\n", metered_feature: "Fallback\tpool", rate_limit: { allowed: false } }, + null, + { limit_name: 42, metered_feature: false }, + ], + }, + NOW, + )!; + expect(report).toContain("Account limit: owner credits depleted"); + expect(report).toContain("Fallback pool: requests not currently allowed"); + expect(report).toContain( + "Not reported: Codex quota, Fallback pool quota, Additional 2 quota, Additional 3 quota, credits.", + ); +}); + it.each([null, [], {}, { unrelated: 10 }, { rate_limit: "wrong shape" }])( "rejects unrecognized response shapes instead of fabricating healthy usage: %j", (payload) => { diff --git a/extensions/codex/src/usage.ts b/extensions/codex/src/usage.ts index 0ae2b28..6bcee00 100644 --- a/extensions/codex/src/usage.ts +++ b/extensions/codex/src/usage.ts @@ -20,20 +20,40 @@ export function formatCodexUsage(value: unknown, now = Date.now()): string | und const sections = readUsageSections(value); if (!sections) return undefined; - const rows = [ - "codex account usage · openai-codex", - `Plan: ${sections.plan ?? "unknown"}`, - "", - ...bucketRows("Codex", sections.codex, now), - ]; + const buckets = [{ name: "Codex", bucket: sections.codex }]; if (sections.includeCodeReview) { - rows.push("", ...bucketRows("Code review", sections.codeReview, now)); + buckets.push({ name: "Code review", bucket: sections.codeReview }); } - rows.push(...additionalBucketRows(sections.additional, now)); - rows.push("", `Credits: ${creditStatus(sections.credits)}`); - if (sections.spendingLimitReached) rows.push("Workspace spending limit: reached"); + buckets.push(...additionalBuckets(sections.additional)); + + const rows = ["codex account usage · openai-codex", `Plan: ${sections.plan ?? "unknown"}`]; + const warnings: string[] = []; + if (sections.spendingLimitReached) warnings.push("Workspace spending limit: reached"); if (sections.accountLimit) - rows.push(`Account limit: ${sections.accountLimit.replaceAll("_", " ")}`); + warnings.push(`Account limit: ${sections.accountLimit.replaceAll("_", " ")}`); + for (const { name, bucket } of buckets) { + const restrictions: string[] = []; + if (bucket?.limit_reached === true) restrictions.push("limit reached"); + if (bucket?.allowed === false) restrictions.push("requests not currently allowed"); + if (restrictions.length) warnings.push(`${name}: ${restrictions.join("; ")}`); + } + if (warnings.length) rows.push("", ...warnings); + if (sections.credits) rows.push(`Credits: ${creditStatus(sections.credits)}`); + + const notReported: string[] = []; + for (const { name, bucket } of buckets) { + const quotaRows = bucketRows(name, bucket, now); + if (quotaRows.length) rows.push("", ...quotaRows); + else notReported.push(`${name} quota`); + } + if (!sections.credits) notReported.push("credits"); + if (notReported.length) rows.push("", `Not reported: ${notReported.join(", ")}.`); + if (sections.additional && sections.additional.length > MAX_ADDITIONAL_BUCKETS) { + rows.push( + "", + `${sections.additional.length - MAX_ADDITIONAL_BUCKETS} additional entries omitted.`, + ); + } return rows.join("\n"); } @@ -77,49 +97,42 @@ function readUsageSections(value: unknown): UsageSections | undefined { } function bucketRows(name: string, bucket: JsonObject | undefined, now: number): string[] { - if (!bucket) return [name, " Not reported"]; - const rows = [ + if (!bucket || (!readObject(bucket.primary_window) && !readObject(bucket.secondary_window))) + return []; + return [ name, ...windowRows("Primary", bucket.primary_window, now), ...windowRows("Secondary", bucket.secondary_window, now), ]; - if (bucket.limit_reached === true) rows.push(" Limit reached"); - else if (bucket.allowed === false) rows.push(" Requests not currently allowed"); - return rows; } function windowRows(name: string, value: unknown, now: number): string[] { const window = readObject(value); - if (!window) return [` ${name}: not reported`]; + if (!window) return [` ${name} window: not reported`]; const seconds = finiteNonnegativeNumber(window.limit_window_seconds); - let interval = "duration unknown"; + let label = `${name} window (duration not reported)`; if (seconds !== undefined && Number.isSafeInteger(seconds) && seconds > 0) - interval = duration(seconds); + label = `${duration(seconds)} window`; const used = finiteNonnegativeNumber(window.used_percent); - let usage = "usage unknown"; + let usage = "remaining not reported"; if (used !== undefined && used <= 100) { - usage = `${NUMBERS.format(used)}% used · ${NUMBERS.format(100 - used)}% remaining`; + usage = `${NUMBERS.format(100 - used)}% remaining (${NUMBERS.format(used)}% used)`; } - return [` ${name} (${interval}): ${usage}`, ` Resets: ${resetTime(window, now)}`]; + return [` ${label}: ${usage}`, ` Resets: ${resetTime(window, now)}`]; } -function additionalBucketRows(additional: unknown[] | undefined, now: number): string[] { - if (!additional) return []; - const rows: string[] = []; - for (const [index, value] of additional.slice(0, MAX_ADDITIONAL_BUCKETS).entries()) { +function additionalBuckets( + additional: unknown[] | undefined, +): { name: string; bucket: JsonObject | undefined }[] { + return (additional ?? []).slice(0, MAX_ADDITIONAL_BUCKETS).map((value, index) => { const entry = readObject(value); const name = sanitizeDisplayLabel(entry?.limit_name) ?? sanitizeDisplayLabel(entry?.metered_feature) ?? `Additional ${index + 1}`; - const quota = readObject(entry?.rate_limit); - rows.push("", ...bucketRows(name, quota, now)); - } - if (additional.length > MAX_ADDITIONAL_BUCKETS) { - rows.push("", `${additional.length - MAX_ADDITIONAL_BUCKETS} additional entries omitted.`); - } - return rows; + return { name, bucket: readObject(entry?.rate_limit) }; + }); } function creditStatus(credits: JsonObject | undefined): string { @@ -151,7 +164,16 @@ function resetTime(window: JsonObject, now: number): string { else if (relative !== undefined) timestamp = now + relative * 1000; const date = new Date(timestamp); if (!Number.isFinite(date.getTime())) return "unknown"; - const time = date.toISOString().replace("T", " ").replace(".000Z", " UTC"); + const time = new Intl.DateTimeFormat("en-US", { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hourCycle: "h23", + timeZoneName: "short", + }).format(date); const remaining = Math.ceil((timestamp - now) / 1000); return remaining > 0 ? `${time} (in ${duration(remaining)})` diff --git a/packages/internal/src/commands.test.ts b/packages/internal/src/commands.test.ts index 046d263..e27ca0b 100644 --- a/packages/internal/src/commands.test.ts +++ b/packages/internal/src/commands.test.ts @@ -6,9 +6,13 @@ import type { import { expect, it, vi } from "vitest"; import { registerCommand } from "./commands.ts"; -function setup(argumentActions?: readonly string[]) { +type Command = Parameters[1]; + +function setup( + argumentActions?: readonly string[], + getArgumentCompletions?: Command["getArgumentCompletions"], +) { type Start = (event: unknown, ctx: ExtensionContext) => void | Promise; - type Command = Parameters[1]; let start: Start | undefined; let command: Command | undefined; const handler = vi.fn(); @@ -29,6 +33,7 @@ function setup(argumentActions?: readonly string[]) { actions: ["on", "off", "test", "toggle"], defaultAction: "toggle", argumentActions, + getArgumentCompletions, handler, }); if (!start) throw new Error("Missing session_start handler"); @@ -69,3 +74,35 @@ it("does not allow arguments for other actions or invent URL completions", async expect(f.command.getArgumentCompletions?.("test https://")).toBe(null); expect(f.command.getArgumentCompletions?.("TE")).toEqual([{ value: "test", label: "test" }]); }); + +it("keeps the default action completions when no override is supplied", () => { + const { command } = setup(); + expect(command.getArgumentCompletions?.(" O")).toEqual([ + { value: "on", label: "on" }, + { value: "off", label: "off" }, + ]); + expect(command.getArgumentCompletions?.("unknown")).toBe(null); + expect(command.getArgumentCompletions?.("on ")).toBe(null); +}); + +it("passes through a native completer without normalizing its input or replacing null", () => { + const complete = vi.fn(() => null); + const { command } = setup(undefined, complete); + expect(command.getArgumentCompletions).toBe(complete); + expect(command.getArgumentCompletions?.(" ON ")).toBe(null); + expect(complete).toHaveBeenCalledWith(" ON "); +}); + +it("supports asynchronous native completion items without changing strict routing", async () => { + const items = [{ value: "test extra", label: "extra", description: "Fixture argument" }]; + const complete = vi.fn(async () => items); + const f = setup(["test"], complete); + expect(await f.command.getArgumentCompletions?.("test e")).toBe(items); + await f.command.handler("test extra", f.ctx); + expect(f.handler).toHaveBeenLastCalledWith("test", f.ctx, "extra"); + f.handler.mockClear(); + await f.command.handler("on extra", f.ctx); + await f.command.handler("unknown", f.ctx); + expect(f.handler).not.toHaveBeenCalled(); + expect(f.notify).toHaveBeenCalledTimes(2); +}); diff --git a/packages/internal/src/commands.ts b/packages/internal/src/commands.ts index 81bea2a..f46c9a6 100644 --- a/packages/internal/src/commands.ts +++ b/packages/internal/src/commands.ts @@ -9,6 +9,7 @@ interface CommandOptions { defaultAction: string; /** Only listed actions accept trailing input; preserve it verbatim (for URLs, etc.). */ argumentActions?: readonly string[]; + getArgumentCompletions?: NativeCommand["getArgumentCompletions"]; handler(action: string, ctx: ExtensionCommandContext, argument?: string): void | Promise; } @@ -33,7 +34,8 @@ export function registerCommand(pi: ExtensionAPI, options: CommandOptions): void function createCommand(options: CommandOptions): NativeCommand { return { description: `${options.description} — ${commandUsage(options)}`, - getArgumentCompletions: (prefix) => completeActions(options.actions, prefix), + getArgumentCompletions: + options.getArgumentCompletions ?? ((prefix) => completeActions(options.actions, prefix)), handler: (args, ctx) => invokeCommand(options, args, ctx), }; } -- 2.54.0 From 3f059ea887e6c531ec367e8c89f151c8d4ecc8d4 Mon Sep 17 00:00:00 2001 From: eleith Date: Sun, 1 Nov 2026 18:18:23 -0800 Subject: [PATCH 2/2] fix(codex): simplify controls and make usage loading visible Mirror context and fast on/off/toggle controls and share a local dashboard across all bare forms. Replace footer progress with an owned native cancellable loader, preserving non-TUI notifications and historical branch replay. Add shared-UI ownership and early-completion regressions, remove orphaned diagnostics tracking, and keep context scenarios ahead of fixture plumbing. --- .changeset/codex-ux.md | 2 +- extensions/codex/src/commands-editor.test.ts | 96 +- extensions/codex/src/commands.test.ts | 92 +- extensions/codex/src/commands.ts | 36 +- extensions/codex/src/controller.test.ts | 1561 +++++++++-------- extensions/codex/src/controller.ts | 16 +- .../src/custom-interaction.test-helper.ts | 127 ++ extensions/codex/src/dashboard.test.ts | 35 +- extensions/codex/src/dashboard.ts | 4 +- extensions/codex/src/fast-sdk.test.ts | 166 +- extensions/codex/src/fast-status.test.ts | 55 +- extensions/codex/src/fast-status.ts | 19 - extensions/codex/src/fast.test.ts | 267 ++- extensions/codex/src/fast.ts | 34 +- extensions/codex/src/index.test.ts | 65 +- extensions/codex/src/index.ts | 13 +- extensions/codex/src/sdk.test.ts | 18 +- extensions/codex/src/usage-controller.test.ts | 476 +++-- extensions/codex/src/usage-controller.ts | 144 +- extensions/codex/src/usage-sdk.test.ts | 46 +- 20 files changed, 1824 insertions(+), 1448 deletions(-) create mode 100644 extensions/codex/src/custom-interaction.test-helper.ts diff --git a/.changeset/codex-ux.md b/.changeset/codex-ux.md index 88b258d..f55f38d 100644 --- a/.changeset/codex-ux.md +++ b/.changeset/codex-ux.md @@ -2,4 +2,4 @@ "@eleith-pi/codex": patch --- -Make Codex controls easier to use with a combined context and fast-mode dashboard, concise fast status, descriptive subcommand completion, clearer allowance reports, and temporary loading feedback for account usage requests. +Make Codex controls easier to use with matching context and fast on/off/toggle commands, a shared local dashboard, descriptive subcommand completion, clearer allowance reports, and a native cancellable loader for account usage requests. diff --git a/extensions/codex/src/commands-editor.test.ts b/extensions/codex/src/commands-editor.test.ts index 99f874e..1964c42 100644 --- a/extensions/codex/src/commands-editor.test.ts +++ b/extensions/codex/src/commands-editor.test.ts @@ -40,56 +40,62 @@ afterEach(() => { rmSync(emptyFileDirectory, { recursive: true, force: true }); }); -it("reopens fast controls on a typed argument prefix and replaces the whole argument", async () => { - const { editor, getArgumentCompletions, getSuggestions } = createEditor(); - await acceptCodexCommand(editor); +it.each([ + ["fast", "Request priority processing", "Request standard processing"], + ["context", "Use the larger local context window", "Use the catalog baseline context window"], +])( + "reopens %s controls on a typed prefix and replaces the whole argument", + async (group, onHelp, offHelp) => { + const { editor, getArgumentCompletions, getSuggestions } = createEditor(); + await acceptCodexCommand(editor); - typeText(editor, "fast"); - await vi.waitFor( - () => { - expect(getArgumentCompletions).toHaveBeenLastCalledWith("fast"); - expect(editor.isShowingAutocomplete()).toBe(true); - }, - { timeout: 1_000 }, - ); - editor.handleInput("\t"); - expect(editor.getText()).toBe("/eleith:codex fast"); - expect(editor.isShowingAutocomplete()).toBe(false); + typeText(editor, group); + await vi.waitFor( + () => { + expect(getArgumentCompletions).toHaveBeenLastCalledWith(group); + expect(editor.isShowingAutocomplete()).toBe(true); + }, + { timeout: 1_000 }, + ); + editor.handleInput("\t"); + expect(editor.getText()).toBe(`/eleith:codex ${group}`); + expect(editor.isShowingAutocomplete()).toBe(false); - const lookupsBeforeSpace = getSuggestions.mock.calls.length; - editor.handleInput(" "); - await vi.advanceTimersByTimeAsync(200); - expect(editor.getText()).toBe("/eleith:codex fast "); - expect(editor.isShowingAutocomplete()).toBe(false); - expect(getSuggestions).toHaveBeenCalledTimes(lookupsBeforeSpace); + const lookupsBeforeSpace = getSuggestions.mock.calls.length; + editor.handleInput(" "); + await vi.advanceTimersByTimeAsync(200); + expect(editor.getText()).toBe(`/eleith:codex ${group} `); + expect(editor.isShowingAutocomplete()).toBe(false); + expect(getSuggestions).toHaveBeenCalledTimes(lookupsBeforeSpace); - editor.handleInput("o"); - await vi.waitFor( - () => { - expect(getArgumentCompletions).toHaveBeenLastCalledWith("fast o"); - expect(editor.isShowingAutocomplete()).toBe(true); - }, - { timeout: 1_000 }, - ); + editor.handleInput("o"); + await vi.waitFor( + () => { + expect(getArgumentCompletions).toHaveBeenLastCalledWith(`${group} o`); + expect(editor.isShowingAutocomplete()).toBe(true); + }, + { timeout: 1_000 }, + ); - // Editor exposes menu visibility, not its items. Observe the real provider's - // public result and the rendered list instead of inspecting editor internals. - const lookup = getSuggestions.mock.results.at(-1); - if (lookup?.type !== "return") throw new Error("Native suggestion lookup did not return"); - const suggestions = await lookup.value; - expect(suggestions?.prefix).toBe("fast o"); - expect(suggestions?.items.map(({ value, label }) => ({ value, label }))).toEqual([ - { value: "fast on", label: "on" }, - { value: "fast off", label: "off" }, - ]); - const rendered = editor.render(120).join("\n"); - expect(rendered).toContain("Request priority processing"); - expect(rendered).toContain("Request standard processing"); + // Editor exposes menu visibility, not its items. Observe the real provider's + // public result and the rendered list instead of inspecting editor internals. + const lookup = getSuggestions.mock.results.at(-1); + if (lookup?.type !== "return") throw new Error("Native suggestion lookup did not return"); + const suggestions = await lookup.value; + expect(suggestions?.prefix).toBe(`${group} o`); + expect(suggestions?.items.map(({ value, label }) => ({ value, label }))).toEqual([ + { value: `${group} on`, label: "on" }, + { value: `${group} off`, label: "off" }, + ]); + const rendered = editor.render(120).join("\n"); + expect(rendered).toContain(onHelp); + expect(rendered).toContain(offHelp); - editor.handleInput("\t"); - expect(editor.getText()).toBe("/eleith:codex fast on"); - expect(editor.isShowingAutocomplete()).toBe(false); -}); + editor.handleInput("\t"); + expect(editor.getText()).toBe(`/eleith:codex ${group} on`); + expect(editor.isShowingAutocomplete()).toBe(false); + }, +); it("routes empty-prefix Tab through the native file gate, not Codex arguments", async () => { const { editor, getArgumentCompletions, getSuggestions, fileCompletionGate } = createEditor(); diff --git a/extensions/codex/src/commands.test.ts b/extensions/codex/src/commands.test.ts index 9bf9ac1..308cbb7 100644 --- a/extensions/codex/src/commands.test.ts +++ b/extensions/codex/src/commands.test.ts @@ -1,43 +1,61 @@ import { expect, it } from "vitest"; import { CODEX_ACTIONS, completeCodexArguments } from "./commands.ts"; -it("offers the four root actions with context, usage, and fast help", async () => { - expect(CODEX_ACTIONS).toEqual(["extend", "restore", "usage", "fast"]); +it("offers context, fast, and usage at the root", async () => { + expect(CODEX_ACTIONS).toEqual(["context", "fast", "usage"]); expect(await completeCodexArguments("")).toEqual([ { - value: "extend", - label: "extend", - description: "Extend the context window for the current supported Codex model", + value: "context", + label: "context", + description: "Control the local context window", }, { - value: "restore", - label: "restore", - description: "Restore the standard context window", + value: "fast", + label: "fast", + description: "Control priority processing and allowance use", }, { value: "usage", label: "usage", description: "Show account usage and allowance limits", }, - { - value: "fast", - label: "fast", - description: "Show fast mode and its controls", - }, ]); }); it.each([ - [" EX", ["extend"]], - ["\tRe", ["restore"]], + [" CO", ["context"]], + ["\tFa", ["fast"]], ["US", ["usage"]], - ["Fa", ["fast"]], + ["context", ["context"]], ["fast", ["fast"]], - [" \t ", ["extend", "restore", "usage", "fast"]], + [" \t ", ["context", "fast", "usage"]], ])("completes normalized root prefix %j", async (prefix, values) => { expect((await completeCodexArguments(prefix))?.map(({ value }) => value)).toEqual(values); }); +it("offers local context controls with full replacement values and plain descriptions", async () => { + expect(await completeCodexArguments("context ")).toEqual([ + { + value: "context on", + label: "on", + description: "Use the larger local context window", + }, + { + value: "context off", + label: "off", + description: "Use the catalog baseline context window", + }, + { + value: "context toggle", + label: "toggle", + description: "Switch between the larger local window and catalog baseline", + }, + ]); + expect(await completeCodexArguments(" \tCONTEXT\t ")).toEqual( + await completeCodexArguments("context "), + ); +}); + it("offers fast controls with full replacement values and helpful descriptions", async () => { expect(await completeCodexArguments("fast ")).toEqual([ { @@ -55,16 +73,6 @@ it("offers fast controls with full replacement values and helpful descriptions", label: "toggle", description: "Switch fast mode on or off", }, - { - value: "fast status", - label: "status", - description: "Show detailed fast-mode diagnostics", - }, - { - value: "fast reset", - label: "reset", - description: "Return to the provider's default for this model", - }, ]); expect(await completeCodexArguments(" \tFAST\t ")).toEqual( await completeCodexArguments("fast "), @@ -72,29 +80,47 @@ it("offers fast controls with full replacement values and helpful descriptions", }); it.each([ + ["context o", ["context on", "context off"]], + [" CONTEXT\tOf", ["context off"]], + ["context t", ["context toggle"]], + ["context on", ["context on"]], ["fast o", ["fast on", "fast off"]], [" FAST\tOf", ["fast off"]], ["fast t", ["fast toggle"]], - ["fast S", ["fast status"]], - ["fast re", ["fast reset"]], ["fast on", ["fast on"]], -])("completes only the matching fast argument for %j", async (prefix, values) => { +])("completes only the matching control for %j", async (prefix, values) => { expect((await completeCodexArguments(prefix))?.map(({ value }) => value)).toEqual(values); }); it.each([ "unknown", + "extend", + "restore", + "status", + "reset", + "ex", + "re", "fast unknown", + "context unknown", "fa on", + "con on", "extend ", "restore o", "usage fast", "fast on ", "fast off\t", "fast toggle extra", - "fast status ", - "fast reset on", + "fast status", + "fast reset", "fast o ", -])("does not invent further arguments for %j", async (prefix) => { + "context on ", + "context off\t", + "context toggle extra", + "context status", + "context reset", + "context extend", + "context restore", + "context o ", +])("does not offer aliases or invent further arguments for %j", async (prefix) => { expect(await completeCodexArguments(prefix)).toBe(null); }); diff --git a/extensions/codex/src/commands.ts b/extensions/codex/src/commands.ts index 61ea96d..30cd814 100644 --- a/extensions/codex/src/commands.ts +++ b/extensions/codex/src/commands.ts @@ -2,22 +2,29 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; type NativeCommand = Parameters[1]; -export const CODEX_ACTIONS = ["extend", "restore", "usage", "fast"] as const; +export const CODEX_ACTIONS = ["context", "fast", "usage"] as const; const ACTION_HELP: Record<(typeof CODEX_ACTIONS)[number], string> = { - extend: "Extend the context window for the current supported Codex model", - restore: "Restore the standard context window", + context: "Control the local context window", + fast: "Control priority processing and allowance use", usage: "Show account usage and allowance limits", - fast: "Show fast mode and its controls", }; -const FAST_HELP = [ - { action: "on", description: "Request priority processing (uses more allowance)" }, - { action: "off", description: "Request standard processing instead of priority" }, - { action: "toggle", description: "Switch fast mode on or off" }, - { action: "status", description: "Show detailed fast-mode diagnostics" }, - { action: "reset", description: "Return to the provider's default for this model" }, -] as const; +const CONTROL_HELP = { + context: [ + { action: "on", description: "Use the larger local context window" }, + { action: "off", description: "Use the catalog baseline context window" }, + { + action: "toggle", + description: "Switch between the larger local window and catalog baseline", + }, + ], + fast: [ + { action: "on", description: "Request priority processing (uses more allowance)" }, + { action: "off", description: "Request standard processing instead of priority" }, + { action: "toggle", description: "Switch fast mode on or off" }, + ], +} as const; export const completeCodexArguments: NonNullable = ( prefix, @@ -35,13 +42,14 @@ export const completeCodexArguments: NonNullable action.startsWith(argument)); + const matches = CONTROL_HELP[group].filter(({ action }) => action.startsWith(argument)); return matches.length ? matches.map(({ action, description }) => ({ - value: `fast ${action}`, + value: `${group} ${action}`, label: action, description, })) diff --git a/extensions/codex/src/controller.test.ts b/extensions/codex/src/controller.test.ts index 76e71b0..f0e1c93 100644 --- a/extensions/codex/src/controller.test.ts +++ b/extensions/codex/src/controller.test.ts @@ -4,6 +4,842 @@ import { describe, expect, it, vi } from "vitest"; import { ContextController, type ContextAPI, type ContextHost } from "./controller.ts"; import { CONTEXT_ENTRY, type ContextPreference } from "./state.ts"; +describe("ContextController", () => { + it.each( + [undefined, false, true].flatMap((saved) => + [false, true].map((selectedExtended) => ({ saved, selectedExtended })), + ), + )( + "toggle flips saved preference $saved with selectedExtended=$selectedExtended", + async ({ saved, selectedExtended }) => { + const f = fixture(); + f.state.branch = saved === undefined ? [] : [entry(preference(saved))]; + f.state.current = selectedExtended ? { ...f.catalog, contextWindow: 922_000 } : f.catalog; + // Load the active branch without replay aligning metadata to its preference. + f.state.idle = false; + await f.controller.restore(f.ctx); + f.state.idle = true; + + await f.controller.handle(" TOGGLE ", f.ctx); + + const extended = saved !== true; + expect(f.state.current?.contextWindow).toBe(extended ? 922_000 : 272_000); + expect(f.controller.getStatus(f.ctx)?.desiredExtended).toBe(extended); + expect(f.api.appendEntry).toHaveBeenCalledExactlyOnceWith( + CONTEXT_ENTRY, + preference(extended), + ); + expect(f.api.setModel).toHaveBeenCalledTimes(extended === selectedExtended ? 0 : 1); + expect(f.confirm).toHaveBeenCalledTimes(!extended && selectedExtended ? 1 : 0); + expect(f.catalog.contextWindow).toBe(272_000); + expect(f.state.thinking).toBe("medium"); + }, + ); + + it("declined toggle reduction keeps the saved preference for the next toggle", async () => { + const f = fixture(); + await f.controller.handle("on", f.ctx); + const extended = f.state.current; + f.confirm.mockResolvedValueOnce(false); + + await f.controller.handle("toggle", f.ctx); + + expect(f.state.current).toBe(extended); + expect(f.controller.getStatus(f.ctx)?.desiredExtended).toBe(true); + expect(f.api.appendEntry).toHaveBeenCalledTimes(1); + expect(f.api.setModel).toHaveBeenCalledTimes(1); + + await f.controller.handle("toggle", f.ctx); + + expect(f.confirm).toHaveBeenCalledTimes(2); + expect(f.state.current?.contextWindow).toBe(272_000); + expect(f.controller.getStatus(f.ctx)?.desiredExtended).toBe(false); + expect(f.api.appendEntry).toHaveBeenCalledTimes(2); + expect(f.api.appendEntry).toHaveBeenLastCalledWith(CONTEXT_ENTRY, preference(false)); + }); + + it.each(["confirmation", "selection"] as const)( + "guards concurrent commands while a toggle awaits %s", + async (pending) => { + const f = fixture(); + const finish = deferred(); + if (pending === "confirmation") { + await f.controller.handle("on", f.ctx); + f.confirm.mockReturnValueOnce(finish.promise); + } else { + f.api.setModel.mockImplementationOnce(async (copy) => { + await finish.promise; + await f.assign(copy); + return true; + }); + } + const records = f.api.appendEntry.mock.calls.length; + const request = f.controller.handle("toggle", f.ctx); + + for (const action of ["toggle", "on", "off"]) { + await f.controller.handle(action, f.ctx); + expect(f.notify).toHaveBeenLastCalledWith( + "Wait for the current run or context change to finish.", + "warning", + ); + } + expect(f.api.setModel).toHaveBeenCalledTimes(1); + expect(f.confirm).toHaveBeenCalledTimes(pending === "confirmation" ? 1 : 0); + expect(f.api.appendEntry).toHaveBeenCalledTimes(records); + expect(f.controller.getStatus(f.ctx)?.desiredExtended).toBe( + pending === "confirmation" ? true : undefined, + ); + + finish.resolve(true); + await request; + + const extended = pending === "selection"; + expect(f.state.current?.contextWindow).toBe(extended ? 922_000 : 272_000); + expect(f.controller.getStatus(f.ctx)?.desiredExtended).toBe(extended); + expect(f.api.appendEntry).toHaveBeenCalledTimes(records + 1); + expect(f.api.appendEntry).toHaveBeenLastCalledWith(CONTEXT_ENTRY, preference(extended)); + }, + ); + + it.each(["extend", "restore", " EXTEND ", "", "context on", "on off", "usage", "fast"])( + "rejects legacy or invalid controller action %j without changing context", + async (action) => { + const f = fixture(); + await f.controller.handle(action, f.ctx); + expect(f.notify).toHaveBeenCalledExactlyOnceWith( + "Usage: /personal:codex context [on|off|toggle]", + "warning", + ); + expect(f.state.current).toBe(f.catalog); + expect(f.controller.getStatus(f.ctx)?.desiredExtended).toBeUndefined(); + expect(f.api.setModel).not.toHaveBeenCalled(); + expect(f.api.appendEntry).not.toHaveBeenCalled(); + expect(f.confirm).not.toHaveBeenCalled(); + }, + ); + + it.each([false, true])( + "extends with retained/cloned outcome %s, preserving metadata and thinking", + async (clone) => { + const f = fixture(); + f.state.cloneSelection = clone; + const original = structuredClone(f.catalog); + await f.controller.handle(" ON ", f.ctx); + + expect(f.state.current).not.toBe(f.catalog); + expect(f.state.current).toEqual({ ...original, contextWindow: 922_000 }); + expect(f.catalog).toEqual(original); + expect(f.state.thinking).toBe("medium"); + expect(f.api.setThinkingLevel).toHaveBeenCalledWith("medium"); + expect(f.api.appendEntry).toHaveBeenCalledExactlyOnceWith(CONTEXT_ENTRY, preference(true)); + expect(f.notify).toHaveBeenLastCalledWith(expect.stringContaining("922,000 tokens"), "info"); + + await f.controller.handle("on", f.ctx); + expect(f.api.setModel).toHaveBeenCalledTimes(1); + expect(f.api.appendEntry).toHaveBeenCalledTimes(1); + }, + ); + + it.each([null, "original"])( + "allows setModel metadata to advance leaf %s", + async (previousLeaf) => { + const f = fixture(); + f.state.leafId = previousLeaf; + f.state.branch = previousLeaf === null ? [] : [{ ...entry(null), id: previousLeaf }]; + f.api.setModel.mockImplementationOnce(async (copy) => { + await f.assign(copy); + const timestamp = "2026-01-01T00:00:00Z"; + f.state.branch.push( + { + type: "model_change", + id: "model-change", + parentId: previousLeaf, + timestamp, + provider: copy.provider, + modelId: copy.id, + }, + { + type: "thinking_level_change", + id: "thinking-change", + parentId: "model-change", + timestamp, + thinkingLevel: "off", + }, + ); + f.state.leafId = "thinking-change"; + return true; + }); + await f.controller.handle("on", f.ctx); + expect(f.state.current?.contextWindow).toBe(922_000); + expect(f.catalog.contextWindow).toBe(272_000); + expect(f.state.thinking).toBe("medium"); + expect(f.api.appendEntry).toHaveBeenCalledExactlyOnceWith(CONTEXT_ENTRY, preference(true)); + }, + ); + + it("confirms restore without mutating any previous copy", async () => { + const f = fixture(); + await f.controller.handle("on", f.ctx); + const extended = f.state.current!; + f.state.thinking = "high"; + await f.controller.handle("off", f.ctx); + + expect(f.confirm).toHaveBeenCalledWith( + "Use standard context?", + expect.stringContaining("922,000 → 272,000"), + { signal: expect.any(AbortSignal) }, + ); + expect(f.state.current).not.toBe(extended); + expect(f.state.current?.contextWindow).toBe(272_000); + expect(extended.contextWindow).toBe(922_000); + expect(f.catalog.contextWindow).toBe(272_000); + expect(f.state.thinking).toBe("high"); + expect(f.api.appendEntry).toHaveBeenLastCalledWith(CONTEXT_ENTRY, preference(false)); + expect(f.api.setModel).toHaveBeenCalledTimes(2); + + f.controller.shutdown(); + expect(f.api.setModel).toHaveBeenCalledTimes(2); + expect(f.api.appendEntry).toHaveBeenCalledTimes(2); + }); + + it("shutdown leaves selected metadata untouched with no API or transcript writes", async () => { + const f = fixture(); + await f.controller.handle("on", f.ctx); + const owned = f.state.current!; + f.api.setModel.mockClear(); + f.api.appendEntry.mockClear(); + f.api.setThinkingLevel.mockClear(); + f.expire(); + f.controller.shutdown(); + f.controller.shutdown(); + await f.controller.restore(f.ctx); + await f.controller.modelSelected(f.ctx); + await f.controller.reconcile(f.ctx); + await f.controller.handle("on", f.ctx); + + expect(owned.contextWindow).toBe(922_000); + expect(f.catalog.contextWindow).toBe(272_000); + expect(f.api.setModel).not.toHaveBeenCalled(); + expect(f.api.appendEntry).not.toHaveBeenCalled(); + expect(f.api.setThinkingLevel).not.toHaveBeenCalled(); + expect(f.state.staleReads).toBe(0); + }); + + it("authentication refusal leaves the model and branch preference untouched", async () => { + const f = fixture(); + f.state.accepted = false; + await f.controller.handle("on", f.ctx); + expect(f.state.current).toBe(f.catalog); + expect(f.catalog.contextWindow).toBe(272_000); + expect(f.api.appendEntry).not.toHaveBeenCalled(); + expect(f.api.setThinkingLevel).not.toHaveBeenCalled(); + expect(f.notify).toHaveBeenCalledWith( + "Could not change context: provider authentication is unavailable. Automatic replay is paused; explicitly use /personal:codex context on or /personal:codex context off after checking authentication.", + "warning", + ); + + f.state.accepted = true; + await f.controller.handle("on", f.ctx); + expect(f.state.current?.contextWindow).toBe(922_000); + expect(f.api.appendEntry).toHaveBeenCalledTimes(1); + }); + + it("authentication refusal during replay does not retry on later idle hooks", async () => { + const f = fixture(); + f.state.branch = [entry(preference(true))]; + f.state.accepted = false; + await f.controller.restore(f.ctx); + f.state.accepted = true; + await f.controller.reconcile(f.ctx); + await f.controller.modelSelected(f.ctx); + expect(f.api.setModel).toHaveBeenCalledTimes(1); + expect(f.api.appendEntry).not.toHaveBeenCalled(); + await f.controller.handle("on", f.ctx); + expect(f.state.current?.contextWindow).toBe(922_000); + expect(f.state.thinking).toBe("medium"); + expect(f.api.setModel).toHaveBeenCalledTimes(2); + // The saved desired preference was already true; no redundant record. + expect(f.api.appendEntry).not.toHaveBeenCalled(); + }); + + it("busy runs reject changes without opening a dialog or writing preferences", async () => { + const f = fixture(); + f.state.idle = false; + await f.controller.handle("on", f.ctx); + await f.controller.handle("off", f.ctx); + await f.controller.handle("toggle", f.ctx); + expect(f.api.setModel).not.toHaveBeenCalled(); + expect(f.confirm).not.toHaveBeenCalled(); + expect(f.api.appendEntry).not.toHaveBeenCalled(); + expect(f.notify).toHaveBeenLastCalledWith( + expect.stringContaining("Wait for the current run"), + "warning", + ); + }); + + it("cancelled reduction leaves the extended copy and preference intact", async () => { + const f = fixture(); + await f.controller.handle("on", f.ctx); + const owned = f.state.current; + f.confirm.mockResolvedValueOnce(false); + await f.controller.handle("off", f.ctx); + expect(f.state.current).toBe(owned); + expect(owned?.contextWindow).toBe(922_000); + expect(f.api.setModel).toHaveBeenCalledTimes(1); + expect(f.api.appendEntry).toHaveBeenCalledTimes(1); + + await f.controller.handle("off", f.ctx); + expect(f.state.current?.contextWindow).toBe(272_000); + }); + + it("usage above the target requires compaction before a reduction dialog", async () => { + const f = fixture(); + await f.controller.handle("on", f.ctx); + f.state.usage = { tokens: 272_001, contextWindow: 922_000, percent: null }; + await f.controller.handle("off", f.ctx); + expect(f.confirm).not.toHaveBeenCalled(); + expect(f.api.setModel).toHaveBeenCalledTimes(1); + expect(f.api.appendEntry).toHaveBeenCalledTimes(1); + expect(f.state.current?.contextWindow).toBe(922_000); + expect(f.notify).toHaveBeenLastCalledWith(expect.stringContaining("Run /compact"), "warning"); + }); + + it("no UI blocks reduction, even when usage is unknown", async () => { + const f = fixture(); + await f.controller.handle("on", f.ctx); + f.state.hasUI = false; + f.state.usage = undefined; + await f.controller.handle("off", f.ctx); + expect(f.confirm).not.toHaveBeenCalled(); + expect(f.api.setModel).toHaveBeenCalledTimes(1); + expect(f.api.appendEntry).toHaveBeenCalledTimes(1); + expect(f.state.current?.contextWindow).toBe(922_000); + expect(f.notify).toHaveBeenLastCalledWith( + expect.stringContaining("interactive confirmation"), + "warning", + ); + }); + + it("restores only valid preferences on the active branch for the selected model", async () => { + const f = fixture(); + // This abandoned choice must not survive restore of the active branch. + f.state.branch = [entry(preference(true))]; + f.state.idle = false; + await f.controller.restore(f.ctx); + f.state.branch = [ + entry(preference(false)), + entry({ ...preference(true), extended: "true" }), + entry(preference(true), "another-extension"), + entry(preference(true, "gpt-6-astra")), + ]; + f.state.idle = true; + await f.controller.restore(f.ctx); + expect(f.state.current).toBe(f.catalog); + expect(f.api.setModel).not.toHaveBeenCalled(); + expect(f.api.appendEntry).not.toHaveBeenCalled(); + + f.state.branch.push(entry(preference(true)), entry(null)); + await f.controller.restore(f.ctx); + expect(f.state.current?.contextWindow).toBe(922_000); + expect(f.api.appendEntry).not.toHaveBeenCalled(); + expect(f.catalog.contextWindow).toBe(272_000); + }); + + it("defers standard-branch restoration until idle and confirms the reduction", async () => { + const f = fixture(); + await f.controller.handle("on", f.ctx); + f.state.branch = [entry(preference(false))]; + f.state.idle = false; + await f.controller.restore(f.ctx); + await f.controller.reconcile(f.ctx); + expect(f.confirm).not.toHaveBeenCalled(); + expect(f.state.current?.contextWindow).toBe(922_000); + + const answer = deferred(); + f.confirm.mockReturnValueOnce(answer.promise); + f.state.idle = true; + const restoration = f.controller.reconcile(f.ctx); + await f.controller.handle("on", f.ctx); + expect(f.api.setModel).toHaveBeenCalledTimes(1); + expect(f.notify).toHaveBeenLastCalledWith( + expect.stringContaining("Wait for the current run"), + "warning", + ); + answer.resolve(true); + await restoration; + expect(f.state.current?.contextWindow).toBe(272_000); + // Reconciliation applies a saved choice, not a new transcript preference. + expect(f.api.appendEntry).toHaveBeenCalledTimes(1); + }); + + it("declining branch reconciliation remembers extended on that branch", async () => { + const f = fixture(); + await f.controller.handle("on", f.ctx); + f.state.branch = []; + f.confirm.mockResolvedValueOnce(false); + await f.controller.restore(f.ctx); + expect(f.api.appendEntry).toHaveBeenCalledTimes(2); + expect(f.api.appendEntry).toHaveBeenLastCalledWith(CONTEXT_ENTRY, preference(true)); + expect(f.notify).toHaveBeenLastCalledWith("Keeping extended context on this branch.", "info"); + await f.controller.reconcile(f.ctx); + expect(f.confirm).toHaveBeenCalledTimes(1); + expect(f.state.current?.contextWindow).toBe(922_000); + }); + + it("shutdown aborts the confirmation signal without accessing the expired root", async () => { + const f = fixture(); + await f.controller.handle("on", f.ctx); + const answer = deferred(); + f.confirm.mockReturnValueOnce(answer.promise); + const request = f.controller.handle("off", f.ctx); + const signal = f.confirm.mock.calls[0][2]?.signal; + expect(signal).toBeInstanceOf(AbortSignal); + expect(signal?.aborted).toBe(false); + f.controller.shutdown(); + f.expire(); + f.notify.mockClear(); + answer.resolve(true); + await request; + expect(signal?.aborted).toBe(true); + expect(f.state.staleReads).toBe(0); + expect(f.notify).not.toHaveBeenCalled(); + expect(f.state.current?.contextWindow).toBe(922_000); + expect(f.api.setModel).toHaveBeenCalledTimes(1); + expect(f.api.appendEntry).toHaveBeenCalledTimes(1); + }); + + it.each(["model", "tree", "session", "shutdown"] as const)( + "%s invalidates a dialog without expired getters or fabricated decline preferences", + async (event) => { + const f = fixture(); + await f.controller.handle("on", f.ctx); + const extended = f.state.current!; + f.state.branch = []; + const answer = deferred(); + f.confirm.mockReturnValueOnce(answer.promise); + const request = f.controller.restore(f.ctx); + const signal = f.confirm.mock.calls[0][2]?.signal; + const live = f.replaceRoot(); + if (event === "model") { + f.state.current = model("other", "other"); + await f.controller.modelSelected(live); + } else if (event === "shutdown") f.controller.shutdown(live); + else { + if (event === "session") f.state.sessionId = "session-b"; + await f.controller.restore(live); + } + expect(signal?.aborted).toBe(true); + expect(f.notify).toHaveBeenLastCalledWith( + expect.stringContaining("Automatic replay is paused"), + "warning", + ); + f.notify.mockClear(); + answer.resolve(false); + await request; + expect(f.state.staleReads).toBe(0); + expect(f.notify).not.toHaveBeenCalled(); + expect(f.api.appendEntry).toHaveBeenCalledTimes(1); + expect(extended.contextWindow).toBe(922_000); + await f.controller.reconcile(f.ctx); + expect(f.confirm).toHaveBeenCalledTimes(1); + if (event !== "shutdown") { + f.state.current = extended; + await f.controller.handle("off", f.ctx); + expect(f.state.current?.contextWindow).toBe(272_000); + } + }, + ); + + it.each(["sessionId", "leafId", "model", "idle"] as const)( + "rejects even a declined confirmation after %s changes", + async (key) => { + const f = fixture(); + await f.controller.handle("on", f.ctx); + f.state.branch = []; + const answer = deferred(); + f.confirm.mockReturnValueOnce(answer.promise); + const request = f.controller.restore(f.ctx); + if (key === "model") f.state.current = { ...f.state.current!, maxTokens: 100 }; + else if (key === "idle") f.state.idle = false; + else f.state[key] = "changed"; + answer.resolve(false); + await request; + expect(f.api.setModel).toHaveBeenCalledTimes(1); + expect(f.api.appendEntry).toHaveBeenCalledTimes(1); + expect(f.notify).toHaveBeenLastCalledWith(expect.stringContaining("uncertain"), "warning"); + }, + ); + + it.each([undefined, { tokens: null, contextWindow: 922_000, percent: null }])( + "confirms reduction with unknown usage %j without inventing zero", + async (usage) => { + const f = fixture(); + await f.controller.handle("on", f.ctx); + f.state.usage = usage; + await f.controller.handle("off", f.ctx); + expect(f.confirm).toHaveBeenCalledWith( + "Use standard context?", + expect.stringContaining("usage: unknown"), + expect.anything(), + ); + expect(f.state.current?.contextWindow).toBe(272_000); + }, + ); + + it("accepts a frozen submitted copy and leaves shared nested catalog metadata untouched", async () => { + const f = fixture(); + const original = structuredClone(f.catalog); + Object.freeze(f.catalog.cost); + Object.freeze(f.catalog.input); + Object.freeze(f.catalog); + f.api.setModel.mockImplementation(async (copy) => { + Object.freeze(copy); + await f.assign(copy); + return true; + }); + await f.controller.handle("on", f.ctx); + const extended = f.state.current!; + expect(extended.cost).toBe(f.catalog.cost); + await f.controller.handle("off", f.ctx); + expect(f.state.current?.contextWindow).toBe(272_000); + expect(extended.contextWindow).toBe(922_000); + expect(f.catalog).toEqual(original); + expect(f.api.appendEntry).toHaveBeenCalledTimes(2); + f.controller.shutdown(); + expect(extended.contextWindow).toBe(922_000); + }); + + it("compares captured values even when another writer mutates a retained submitted object", async () => { + const f = fixture(); + f.api.setModel.mockImplementationOnce(async (copy) => { + await f.assign(copy); + // Simulate a different writer: retaining the input is not an ownership contract. + copy.contextWindow = 800_000; + return true; + }); + await f.controller.handle("on", f.ctx); + expect(f.state.current?.contextWindow).toBe(800_000); + expect(f.api.appendEntry).not.toHaveBeenCalled(); + expect(f.api.setThinkingLevel).not.toHaveBeenCalled(); + expect(f.notify).toHaveBeenLastCalledWith(expect.stringContaining("uncertain"), "warning"); + }); + + it("an expired dialog's finally cannot clear a newer explicit request", async () => { + const f = fixture(); + await f.controller.handle("on", f.ctx); + const oldAnswer = deferred(); + const newAnswer = deferred(); + f.confirm.mockReturnValueOnce(oldAnswer.promise).mockReturnValueOnce(newAnswer.promise); + const oldRequest = f.controller.handle("off", f.ctx); + await f.controller.restore(f.replaceRoot()); + const newRequest = f.controller.handle("off", f.ctx); + oldAnswer.resolve(false); + await oldRequest; + await f.controller.handle("on", f.ctx); + expect(f.notify).toHaveBeenLastCalledWith( + expect.stringContaining("Wait for the current run"), + "warning", + ); + expect(f.api.setModel).toHaveBeenCalledTimes(1); + newAnswer.resolve(true); + await newRequest; + expect(f.state.staleReads).toBe(0); + expect(f.state.current?.contextWindow).toBe(272_000); + expect(f.api.appendEntry).toHaveBeenLastCalledWith(CONTEXT_ENTRY, preference(false)); + }); + + it("rechecks usage after confirmation", async () => { + const f = fixture(); + await f.controller.handle("on", f.ctx); + f.confirm.mockImplementationOnce(async () => { + f.state.usage = { tokens: 300_000, contextWindow: 922_000, percent: null }; + return true; + }); + await f.controller.handle("off", f.ctx); + expect(f.api.setModel).toHaveBeenCalledTimes(1); + expect(f.api.appendEntry).toHaveBeenCalledTimes(1); + expect(f.notify).toHaveBeenLastCalledWith(expect.stringContaining("Run /compact"), "warning"); + }); + + it.each(["usage", "noUI"] as const)( + "blocked replay %s cannot remember keep-extended", + async (reason) => { + const f = fixture(); + await f.controller.handle("on", f.ctx); + f.state.branch = []; + if (reason === "usage") + f.state.usage = { tokens: 300_000, contextWindow: 922_000, percent: null }; + else f.state.hasUI = false; + await f.controller.restore(f.ctx); + expect(f.confirm).not.toHaveBeenCalled(); + expect(f.api.appendEntry).toHaveBeenCalledTimes(1); + }, + ); + + it.each(["sessionId", "leafId", "metadata", "cloneMismatch"] as const)( + "reports a completed setModel with %s interference without rollback or replay", + async (key) => { + const f = fixture(); + const assigned = deferred(); + const finish = deferred(); + f.api.setModel.mockImplementationOnce(async (copy) => { + await f.assign(copy); + assigned.resolve(); + return finish.promise; + }); + const request = f.controller.handle("on", f.ctx); + await assigned.promise; + if (key === "metadata" || key === "cloneMismatch") + f.state.current = { + ...f.state.current!, + [key === "metadata" ? "baseUrl" : "maxTokens"]: + key === "metadata" ? "https://other.invalid" : 100, + }; + else f.state[key] = "changed"; + finish.resolve(true); + await request; + expect(f.api.setModel.mock.calls[0][0].contextWindow).toBe(922_000); + expect(f.catalog.contextWindow).toBe(272_000); + expect(f.state.thinking).toBe("off"); + expect(f.api.setThinkingLevel).not.toHaveBeenCalled(); + expect(f.api.appendEntry).not.toHaveBeenCalled(); + expect(f.notify).toHaveBeenLastCalledWith(expect.stringContaining("uncertain"), "warning"); + f.state.current = f.catalog; + await f.controller.reconcile(f.ctx); + await f.controller.modelSelected(f.ctx); + await f.controller.restore(f.ctx); + expect(f.api.setModel).toHaveBeenCalledTimes(1); + await f.controller.handle("on", f.ctx); + expect(f.state.current?.contextWindow).toBe(922_000); + expect(f.api.appendEntry).toHaveBeenCalledTimes(1); + }, + ); + + it.each(["shutdown", "restore"] as const)( + "invalidates pending setModel on %s without reading stale getters", + async (event) => { + const f = fixture(); + const started = deferred>(); + const finish = deferred(); + f.api.setModel.mockImplementationOnce(async (copy) => { + started.resolve(copy); + await finish.promise; + // An SDK selection can still assign after local cleanup. + f.state.current = copy; + f.state.thinking = "off"; + return true; + }); + const request = f.controller.handle("on", f.ctx); + const copy = await started.promise; + const original = structuredClone(copy); + const live = f.replaceRoot(); + if (event === "shutdown") f.controller.shutdown(live); + else await f.controller.restore(live); + expect(f.notify).toHaveBeenLastCalledWith( + expect.stringContaining("cannot be cancelled"), + "warning", + ); + f.notify.mockClear(); + finish.resolve(); + await request; + expect(copy).toEqual(original); + expect(f.state.staleReads).toBe(0); + expect(f.state.current?.contextWindow).toBe(922_000); + expect(f.api.appendEntry).not.toHaveBeenCalled(); + expect(f.api.setThinkingLevel).not.toHaveBeenCalled(); + expect(f.notify).not.toHaveBeenCalled(); + }, + ); + + it.each(["on", "off"])("does not repair a throw-after-assignment on %s", async (action) => { + const f = fixture(); + if (action === "off") await f.controller.handle("on", f.ctx); + const previous = f.state.current!; + const original = structuredClone(previous); + const records = f.api.appendEntry.mock.calls.length; + f.api.setThinkingLevel.mockClear(); + f.api.setModel.mockImplementationOnce(async (copy) => { + await f.assign(copy); + throw new Error("model hook failed after assignment"); + }); + await f.controller.handle(action, f.ctx); + expect(f.state.current?.contextWindow).toBe(action === "on" ? 922_000 : 272_000); + expect(previous).toEqual(original); + expect(f.state.thinking).toBe("off"); + expect(f.api.setThinkingLevel).not.toHaveBeenCalled(); + expect(f.api.appendEntry).toHaveBeenCalledTimes(records); + expect(f.notify).toHaveBeenLastCalledWith(expect.stringContaining("uncertain"), "warning"); + await f.controller.reconcile(f.ctx); + f.controller.shutdown(); + expect(previous).toEqual(original); + }); + + it("does not rewrite a pending copy or external selection during authentication", async () => { + const f = fixture(); + await f.controller.handle("on", f.ctx); + const oldCopy = f.state.current!; + const started = deferred>(); + const finish = deferred(); + f.api.setModel.mockImplementationOnce(async (copy) => { + started.resolve(copy); + await finish.promise; + await f.assign(copy); + return true; + }); + const request = f.controller.handle("off", f.ctx); + const pending = await started.promise; + const originalPending = structuredClone(pending); + const external = model("other", "another-provider"); + const originalExternal = structuredClone(external); + await f.assign(external); + expect(pending).toEqual(originalPending); + finish.resolve(); + await request; + expect(f.state.current).toEqual(originalPending); + expect(external).toEqual(originalExternal); + expect(oldCopy.contextWindow).toBe(922_000); + expect(f.state.thinking).toBe("off"); + expect(f.api.appendEntry).toHaveBeenCalledTimes(1); + expect(f.notify).toHaveBeenCalledWith( + expect.stringContaining("cannot be cancelled"), + "warning", + ); + f.state.current = f.catalog; + await f.controller.reconcile(f.ctx); + expect(f.api.setModel).toHaveBeenCalledTimes(2); + }); + + it("replays the selected model's ordinary branch preference on a provider/ID event", async () => { + const f = fixture(); + f.state.branch = [entry(preference(true, "gpt-6-astra"))]; + await f.controller.restore(f.ctx); + const astra = model("gpt-6-astra"); + f.state.catalog = astra; + await f.assign(astra); + expect(f.state.current?.id).toBe("gpt-6-astra"); + expect(f.state.current?.contextWindow).toBe(922_000); + expect(astra.contextWindow).toBe(272_000); + expect(f.api.setModel).toHaveBeenCalledTimes(1); + expect(f.api.appendEntry).not.toHaveBeenCalled(); + expect(f.state.thinking).toBe("off"); + }); + + it("replays a saved choice after same-ID catalog selection via the next idle hook", async () => { + const f = fixture(); + await f.controller.handle("on", f.ctx); + const oldCopy = f.state.current!; + await f.assign(f.catalog); // no model_select + expect(f.state.current?.contextWindow).toBe(272_000); + await f.controller.reconcile(f.ctx); + expect(f.state.current?.contextWindow).toBe(922_000); + expect(oldCopy.contextWindow).toBe(922_000); + expect(f.catalog.contextWindow).toBe(272_000); + expect(f.api.appendEntry).toHaveBeenCalledTimes(1); + }); + + it("restores an extended selection with a fresh controller using catalog baseline", async () => { + const f = fixture(); + await f.controller.handle("on", f.ctx); + f.controller.shutdown(); + const controller = new ContextController(f.api, "personal:codex"); + await controller.restore(f.ctx); + expect(f.state.current?.contextWindow).toBe(922_000); + await controller.handle("off", f.ctx); + expect(f.api.setModel.mock.lastCall?.[0].contextWindow).toBe(272_000); + expect(f.state.current?.contextWindow).toBe(272_000); + expect(f.api.appendEntry).toHaveBeenLastCalledWith(CONTEXT_ENTRY, preference(false)); + }); + + it.each([300_000, 922_000, 1_000_000])( + "honors catalog override %s rather than a guessed default", + async (window) => { + const f = fixture({ ...model(), contextWindow: window }); + await f.controller.handle("on", f.ctx); + expect(f.state.current?.contextWindow).toBe(Math.max(window, 922_000)); + if (window >= 922_000) { + expect(f.api.setModel).not.toHaveBeenCalled(); + expect(f.notify).toHaveBeenLastCalledWith( + `Using catalog context: ${window.toLocaleString("en-US")} tokens. No extension is needed.`, + "info", + ); + } + await f.controller.handle("off", f.ctx); + expect(f.state.current?.contextWindow).toBe(window); + if (window >= 922_000) { + expect(f.confirm).not.toHaveBeenCalled(); + expect(f.notify).toHaveBeenLastCalledWith( + expect.stringContaining("no smaller default is known"), + "info", + ); + } + }, + ); + + it.each(["gpt-6.2-sol", "gpt-6.1-astra", "gpt-7-sol"])( + "does not guess an extended limit or replay saved preferences for %s", + async (id) => { + const f = fixture(model(id)); + const original = structuredClone(f.catalog); + f.state.branch = [entry(preference(true, id))]; + await f.controller.restore(f.ctx); + await f.controller.handle("on", f.ctx); + await f.controller.handle("off", f.ctx); + expect(f.notify).toHaveBeenLastCalledWith( + `No verified extended context limit for openai-codex/${id}. Only supported Codex models can be changed.`, + "warning", + ); + const notifications = f.notify.mock.calls.length; + expect(f.controller.getStatus(f.ctx)?.extendedWindow).toBeUndefined(); + expect(f.notify).toHaveBeenCalledTimes(notifications); + expect(f.state.current).toBe(f.catalog); + expect(f.catalog).toEqual(original); + expect(f.api.setModel).not.toHaveBeenCalled(); + expect(f.api.appendEntry).not.toHaveBeenCalled(); + expect(f.confirm).not.toHaveBeenCalled(); + }, + ); + + it("does not extend a known ID through a non-Codex API", async () => { + const f = fixture({ ...model(), api: "openai-responses" }); + await f.controller.handle("on", f.ctx); + expect(f.api.setModel).not.toHaveBeenCalled(); + expect(f.api.appendEntry).not.toHaveBeenCalled(); + expect(f.notify).toHaveBeenLastCalledWith( + expect.stringContaining("No verified extended context limit"), + "warning", + ); + }); + + it.each([ + undefined, + { ...model(), id: "other" }, + { ...model(), provider: "other" }, + { ...model(), api: "openai-responses" }, + { ...model(), baseUrl: "https://other.invalid" }, + ...[0, -1, NaN, Infinity].map((contextWindow) => ({ ...model(), contextWindow })), + ])("refuses missing/incompatible/invalid catalog baseline %j", async (catalog) => { + const f = fixture(); + f.state.current = { ...f.catalog, contextWindow: 922_000 }; + f.state.catalog = catalog; + await f.controller.handle("off", f.ctx); + await f.controller.handle("on", f.ctx); + await f.controller.reconcile(f.ctx); + expect(f.api.setModel).not.toHaveBeenCalled(); + expect(f.api.appendEntry).not.toHaveBeenCalled(); + expect(f.confirm).not.toHaveBeenCalled(); + expect(f.notify).toHaveBeenLastCalledWith( + expect.stringContaining("baseline is unknown"), + "warning", + ); + const notifications = f.notify.mock.calls.length; + expect(f.controller.getStatus(f.ctx)).toMatchObject({ + standardWindow: undefined, + extendedWindow: undefined, + }); + expect(f.notify).toHaveBeenCalledTimes(notifications); + }); +}); + function model(id = "gpt-6-sol", provider = "openai-codex"): Model { return { id, @@ -167,728 +1003,3 @@ function fixture(catalog = model()) { }, }; } - -describe("ContextController", () => { - it.each([false, true])( - "extends with retained/cloned outcome %s, preserving metadata and thinking", - async (clone) => { - const f = fixture(); - f.state.cloneSelection = clone; - const original = structuredClone(f.catalog); - await f.controller.handle(" EXTEND ", f.ctx); - - expect(f.state.current).not.toBe(f.catalog); - expect(f.state.current).toEqual({ ...original, contextWindow: 922_000 }); - expect(f.catalog).toEqual(original); - expect(f.state.thinking).toBe("medium"); - expect(f.api.setThinkingLevel).toHaveBeenCalledWith("medium"); - expect(f.api.appendEntry).toHaveBeenCalledExactlyOnceWith(CONTEXT_ENTRY, preference(true)); - expect(f.notify).toHaveBeenLastCalledWith(expect.stringContaining("922,000 tokens"), "info"); - - await f.controller.handle("extend", f.ctx); - expect(f.api.setModel).toHaveBeenCalledTimes(1); - expect(f.api.appendEntry).toHaveBeenCalledTimes(1); - }, - ); - - it.each([null, "original"])( - "allows setModel metadata to advance leaf %s", - async (previousLeaf) => { - const f = fixture(); - f.state.leafId = previousLeaf; - f.state.branch = previousLeaf === null ? [] : [{ ...entry(null), id: previousLeaf }]; - f.api.setModel.mockImplementationOnce(async (copy) => { - await f.assign(copy); - const timestamp = "2026-01-01T00:00:00Z"; - f.state.branch.push( - { - type: "model_change", - id: "model-change", - parentId: previousLeaf, - timestamp, - provider: copy.provider, - modelId: copy.id, - }, - { - type: "thinking_level_change", - id: "thinking-change", - parentId: "model-change", - timestamp, - thinkingLevel: "off", - }, - ); - f.state.leafId = "thinking-change"; - return true; - }); - await f.controller.handle("extend", f.ctx); - expect(f.state.current?.contextWindow).toBe(922_000); - expect(f.catalog.contextWindow).toBe(272_000); - expect(f.state.thinking).toBe("medium"); - expect(f.api.appendEntry).toHaveBeenCalledExactlyOnceWith(CONTEXT_ENTRY, preference(true)); - }, - ); - - it("confirms restore without mutating any previous copy", async () => { - const f = fixture(); - await f.controller.handle("extend", f.ctx); - const extended = f.state.current!; - f.state.thinking = "high"; - await f.controller.handle("restore", f.ctx); - - expect(f.confirm).toHaveBeenCalledWith( - "Use standard context?", - expect.stringContaining("922,000 → 272,000"), - { signal: expect.any(AbortSignal) }, - ); - expect(f.state.current).not.toBe(extended); - expect(f.state.current?.contextWindow).toBe(272_000); - expect(extended.contextWindow).toBe(922_000); - expect(f.catalog.contextWindow).toBe(272_000); - expect(f.state.thinking).toBe("high"); - expect(f.api.appendEntry).toHaveBeenLastCalledWith(CONTEXT_ENTRY, preference(false)); - expect(f.api.setModel).toHaveBeenCalledTimes(2); - - f.controller.shutdown(); - expect(f.api.setModel).toHaveBeenCalledTimes(2); - expect(f.api.appendEntry).toHaveBeenCalledTimes(2); - }); - - it("shutdown leaves selected metadata untouched with no API or transcript writes", async () => { - const f = fixture(); - await f.controller.handle("extend", f.ctx); - const owned = f.state.current!; - f.api.setModel.mockClear(); - f.api.appendEntry.mockClear(); - f.api.setThinkingLevel.mockClear(); - f.expire(); - f.controller.shutdown(); - f.controller.shutdown(); - await f.controller.restore(f.ctx); - await f.controller.modelSelected(f.ctx); - await f.controller.reconcile(f.ctx); - await f.controller.handle("extend", f.ctx); - - expect(owned.contextWindow).toBe(922_000); - expect(f.catalog.contextWindow).toBe(272_000); - expect(f.api.setModel).not.toHaveBeenCalled(); - expect(f.api.appendEntry).not.toHaveBeenCalled(); - expect(f.api.setThinkingLevel).not.toHaveBeenCalled(); - expect(f.state.staleReads).toBe(0); - }); - - it("authentication refusal leaves the model and branch preference untouched", async () => { - const f = fixture(); - f.state.accepted = false; - await f.controller.handle("extend", f.ctx); - expect(f.state.current).toBe(f.catalog); - expect(f.catalog.contextWindow).toBe(272_000); - expect(f.api.appendEntry).not.toHaveBeenCalled(); - expect(f.api.setThinkingLevel).not.toHaveBeenCalled(); - expect(f.notify).toHaveBeenCalledWith( - expect.stringContaining("authentication is unavailable"), - "warning", - ); - - f.state.accepted = true; - await f.controller.handle("extend", f.ctx); - expect(f.state.current?.contextWindow).toBe(922_000); - expect(f.api.appendEntry).toHaveBeenCalledTimes(1); - }); - - it("authentication refusal during replay does not retry on later idle hooks", async () => { - const f = fixture(); - f.state.branch = [entry(preference(true))]; - f.state.accepted = false; - await f.controller.restore(f.ctx); - f.state.accepted = true; - await f.controller.reconcile(f.ctx); - await f.controller.modelSelected(f.ctx); - expect(f.api.setModel).toHaveBeenCalledTimes(1); - expect(f.api.appendEntry).not.toHaveBeenCalled(); - await f.controller.handle("extend", f.ctx); - expect(f.state.current?.contextWindow).toBe(922_000); - expect(f.state.thinking).toBe("medium"); - expect(f.api.setModel).toHaveBeenCalledTimes(2); - // The saved desired preference was already true; no redundant record. - expect(f.api.appendEntry).not.toHaveBeenCalled(); - }); - - it("busy runs reject changes without opening a dialog or writing preferences", async () => { - const f = fixture(); - f.state.idle = false; - await f.controller.handle("extend", f.ctx); - await f.controller.handle("restore", f.ctx); - expect(f.api.setModel).not.toHaveBeenCalled(); - expect(f.confirm).not.toHaveBeenCalled(); - expect(f.api.appendEntry).not.toHaveBeenCalled(); - expect(f.notify).toHaveBeenLastCalledWith( - expect.stringContaining("Wait for the current run"), - "warning", - ); - }); - - it("cancelled reduction leaves the extended copy and preference intact", async () => { - const f = fixture(); - await f.controller.handle("extend", f.ctx); - const owned = f.state.current; - f.confirm.mockResolvedValueOnce(false); - await f.controller.handle("restore", f.ctx); - expect(f.state.current).toBe(owned); - expect(owned?.contextWindow).toBe(922_000); - expect(f.api.setModel).toHaveBeenCalledTimes(1); - expect(f.api.appendEntry).toHaveBeenCalledTimes(1); - - await f.controller.handle("restore", f.ctx); - expect(f.state.current?.contextWindow).toBe(272_000); - }); - - it("usage above the target requires compaction before a reduction dialog", async () => { - const f = fixture(); - await f.controller.handle("extend", f.ctx); - f.state.usage = { tokens: 272_001, contextWindow: 922_000, percent: null }; - await f.controller.handle("restore", f.ctx); - expect(f.confirm).not.toHaveBeenCalled(); - expect(f.api.setModel).toHaveBeenCalledTimes(1); - expect(f.api.appendEntry).toHaveBeenCalledTimes(1); - expect(f.state.current?.contextWindow).toBe(922_000); - expect(f.notify).toHaveBeenLastCalledWith(expect.stringContaining("Run /compact"), "warning"); - }); - - it("no UI blocks reduction, even when usage is unknown", async () => { - const f = fixture(); - await f.controller.handle("extend", f.ctx); - f.state.hasUI = false; - f.state.usage = undefined; - await f.controller.handle("restore", f.ctx); - expect(f.confirm).not.toHaveBeenCalled(); - expect(f.api.setModel).toHaveBeenCalledTimes(1); - expect(f.api.appendEntry).toHaveBeenCalledTimes(1); - expect(f.state.current?.contextWindow).toBe(922_000); - expect(f.notify).toHaveBeenLastCalledWith( - expect.stringContaining("interactive confirmation"), - "warning", - ); - }); - - it("restores only valid preferences on the active branch for the selected model", async () => { - const f = fixture(); - // This abandoned choice must not survive restore of the active branch. - f.state.branch = [entry(preference(true))]; - f.state.idle = false; - await f.controller.restore(f.ctx); - f.state.branch = [ - entry(preference(false)), - entry({ ...preference(true), extended: "true" }), - entry(preference(true), "another-extension"), - entry(preference(true, "gpt-6-astra")), - ]; - f.state.idle = true; - await f.controller.restore(f.ctx); - expect(f.state.current).toBe(f.catalog); - expect(f.api.setModel).not.toHaveBeenCalled(); - expect(f.api.appendEntry).not.toHaveBeenCalled(); - - f.state.branch.push(entry(preference(true)), entry(null)); - await f.controller.restore(f.ctx); - expect(f.state.current?.contextWindow).toBe(922_000); - expect(f.api.appendEntry).not.toHaveBeenCalled(); - expect(f.catalog.contextWindow).toBe(272_000); - }); - - it("defers standard-branch restoration until idle and confirms the reduction", async () => { - const f = fixture(); - await f.controller.handle("extend", f.ctx); - f.state.branch = [entry(preference(false))]; - f.state.idle = false; - await f.controller.restore(f.ctx); - await f.controller.reconcile(f.ctx); - expect(f.confirm).not.toHaveBeenCalled(); - expect(f.state.current?.contextWindow).toBe(922_000); - - const answer = deferred(); - f.confirm.mockReturnValueOnce(answer.promise); - f.state.idle = true; - const restoration = f.controller.reconcile(f.ctx); - await f.controller.handle("extend", f.ctx); - expect(f.api.setModel).toHaveBeenCalledTimes(1); - expect(f.notify).toHaveBeenLastCalledWith( - expect.stringContaining("Wait for the current run"), - "warning", - ); - answer.resolve(true); - await restoration; - expect(f.state.current?.contextWindow).toBe(272_000); - // Reconciliation applies a saved choice, not a new transcript preference. - expect(f.api.appendEntry).toHaveBeenCalledTimes(1); - }); - - it("declining branch reconciliation remembers extended on that branch", async () => { - const f = fixture(); - await f.controller.handle("extend", f.ctx); - f.state.branch = []; - f.confirm.mockResolvedValueOnce(false); - await f.controller.restore(f.ctx); - expect(f.api.appendEntry).toHaveBeenCalledTimes(2); - expect(f.api.appendEntry).toHaveBeenLastCalledWith(CONTEXT_ENTRY, preference(true)); - expect(f.notify).toHaveBeenLastCalledWith("Keeping extended context on this branch.", "info"); - await f.controller.reconcile(f.ctx); - expect(f.confirm).toHaveBeenCalledTimes(1); - expect(f.state.current?.contextWindow).toBe(922_000); - }); - - it("shutdown aborts the confirmation signal without accessing the expired root", async () => { - const f = fixture(); - await f.controller.handle("extend", f.ctx); - const answer = deferred(); - f.confirm.mockReturnValueOnce(answer.promise); - const request = f.controller.handle("restore", f.ctx); - const signal = f.confirm.mock.calls[0][2]?.signal; - expect(signal).toBeInstanceOf(AbortSignal); - expect(signal?.aborted).toBe(false); - f.controller.shutdown(); - f.expire(); - f.notify.mockClear(); - answer.resolve(true); - await request; - expect(signal?.aborted).toBe(true); - expect(f.state.staleReads).toBe(0); - expect(f.notify).not.toHaveBeenCalled(); - expect(f.state.current?.contextWindow).toBe(922_000); - expect(f.api.setModel).toHaveBeenCalledTimes(1); - expect(f.api.appendEntry).toHaveBeenCalledTimes(1); - }); - - it.each(["model", "tree", "session", "shutdown"] as const)( - "%s invalidates a dialog without expired getters or fabricated decline preferences", - async (event) => { - const f = fixture(); - await f.controller.handle("extend", f.ctx); - const extended = f.state.current!; - f.state.branch = []; - const answer = deferred(); - f.confirm.mockReturnValueOnce(answer.promise); - const request = f.controller.restore(f.ctx); - const signal = f.confirm.mock.calls[0][2]?.signal; - const live = f.replaceRoot(); - if (event === "model") { - f.state.current = model("other", "other"); - await f.controller.modelSelected(live); - } else if (event === "shutdown") f.controller.shutdown(live); - else { - if (event === "session") f.state.sessionId = "session-b"; - await f.controller.restore(live); - } - expect(signal?.aborted).toBe(true); - expect(f.notify).toHaveBeenLastCalledWith( - expect.stringContaining("Automatic replay is paused"), - "warning", - ); - f.notify.mockClear(); - answer.resolve(false); - await request; - expect(f.state.staleReads).toBe(0); - expect(f.notify).not.toHaveBeenCalled(); - expect(f.api.appendEntry).toHaveBeenCalledTimes(1); - expect(extended.contextWindow).toBe(922_000); - await f.controller.reconcile(f.ctx); - expect(f.confirm).toHaveBeenCalledTimes(1); - if (event !== "shutdown") { - f.state.current = extended; - await f.controller.handle("restore", f.ctx); - expect(f.state.current?.contextWindow).toBe(272_000); - } - }, - ); - - it.each(["sessionId", "leafId", "model", "idle"] as const)( - "rejects even a declined confirmation after %s changes", - async (key) => { - const f = fixture(); - await f.controller.handle("extend", f.ctx); - f.state.branch = []; - const answer = deferred(); - f.confirm.mockReturnValueOnce(answer.promise); - const request = f.controller.restore(f.ctx); - if (key === "model") f.state.current = { ...f.state.current!, maxTokens: 100 }; - else if (key === "idle") f.state.idle = false; - else f.state[key] = "changed"; - answer.resolve(false); - await request; - expect(f.api.setModel).toHaveBeenCalledTimes(1); - expect(f.api.appendEntry).toHaveBeenCalledTimes(1); - expect(f.notify).toHaveBeenLastCalledWith(expect.stringContaining("uncertain"), "warning"); - }, - ); - - it.each([undefined, { tokens: null, contextWindow: 922_000, percent: null }])( - "confirms reduction with unknown usage %j without inventing zero", - async (usage) => { - const f = fixture(); - await f.controller.handle("extend", f.ctx); - f.state.usage = usage; - await f.controller.handle("restore", f.ctx); - expect(f.confirm).toHaveBeenCalledWith( - "Use standard context?", - expect.stringContaining("usage: unknown"), - expect.anything(), - ); - expect(f.state.current?.contextWindow).toBe(272_000); - }, - ); - - it("accepts a frozen submitted copy and leaves shared nested catalog metadata untouched", async () => { - const f = fixture(); - const original = structuredClone(f.catalog); - Object.freeze(f.catalog.cost); - Object.freeze(f.catalog.input); - Object.freeze(f.catalog); - f.api.setModel.mockImplementation(async (copy) => { - Object.freeze(copy); - await f.assign(copy); - return true; - }); - await f.controller.handle("extend", f.ctx); - const extended = f.state.current!; - expect(extended.cost).toBe(f.catalog.cost); - await f.controller.handle("restore", f.ctx); - expect(f.state.current?.contextWindow).toBe(272_000); - expect(extended.contextWindow).toBe(922_000); - expect(f.catalog).toEqual(original); - expect(f.api.appendEntry).toHaveBeenCalledTimes(2); - f.controller.shutdown(); - expect(extended.contextWindow).toBe(922_000); - }); - - it("compares captured values even when another writer mutates a retained submitted object", async () => { - const f = fixture(); - f.api.setModel.mockImplementationOnce(async (copy) => { - await f.assign(copy); - // Simulate a different writer: retaining the input is not an ownership contract. - copy.contextWindow = 800_000; - return true; - }); - await f.controller.handle("extend", f.ctx); - expect(f.state.current?.contextWindow).toBe(800_000); - expect(f.api.appendEntry).not.toHaveBeenCalled(); - expect(f.api.setThinkingLevel).not.toHaveBeenCalled(); - expect(f.notify).toHaveBeenLastCalledWith(expect.stringContaining("uncertain"), "warning"); - }); - - it("an expired dialog's finally cannot clear a newer explicit request", async () => { - const f = fixture(); - await f.controller.handle("extend", f.ctx); - const oldAnswer = deferred(); - const newAnswer = deferred(); - f.confirm.mockReturnValueOnce(oldAnswer.promise).mockReturnValueOnce(newAnswer.promise); - const oldRequest = f.controller.handle("restore", f.ctx); - await f.controller.restore(f.replaceRoot()); - const newRequest = f.controller.handle("restore", f.ctx); - oldAnswer.resolve(false); - await oldRequest; - await f.controller.handle("extend", f.ctx); - expect(f.notify).toHaveBeenLastCalledWith( - expect.stringContaining("Wait for the current run"), - "warning", - ); - expect(f.api.setModel).toHaveBeenCalledTimes(1); - newAnswer.resolve(true); - await newRequest; - expect(f.state.staleReads).toBe(0); - expect(f.state.current?.contextWindow).toBe(272_000); - expect(f.api.appendEntry).toHaveBeenLastCalledWith(CONTEXT_ENTRY, preference(false)); - }); - - it("rechecks usage after confirmation", async () => { - const f = fixture(); - await f.controller.handle("extend", f.ctx); - f.confirm.mockImplementationOnce(async () => { - f.state.usage = { tokens: 300_000, contextWindow: 922_000, percent: null }; - return true; - }); - await f.controller.handle("restore", f.ctx); - expect(f.api.setModel).toHaveBeenCalledTimes(1); - expect(f.api.appendEntry).toHaveBeenCalledTimes(1); - expect(f.notify).toHaveBeenLastCalledWith(expect.stringContaining("Run /compact"), "warning"); - }); - - it.each(["usage", "noUI"] as const)( - "blocked replay %s cannot remember keep-extended", - async (reason) => { - const f = fixture(); - await f.controller.handle("extend", f.ctx); - f.state.branch = []; - if (reason === "usage") - f.state.usage = { tokens: 300_000, contextWindow: 922_000, percent: null }; - else f.state.hasUI = false; - await f.controller.restore(f.ctx); - expect(f.confirm).not.toHaveBeenCalled(); - expect(f.api.appendEntry).toHaveBeenCalledTimes(1); - }, - ); - - it.each(["sessionId", "leafId", "metadata", "cloneMismatch"] as const)( - "reports a completed setModel with %s interference without rollback or replay", - async (key) => { - const f = fixture(); - const assigned = deferred(); - const finish = deferred(); - f.api.setModel.mockImplementationOnce(async (copy) => { - await f.assign(copy); - assigned.resolve(); - return finish.promise; - }); - const request = f.controller.handle("extend", f.ctx); - await assigned.promise; - if (key === "metadata" || key === "cloneMismatch") - f.state.current = { - ...f.state.current!, - [key === "metadata" ? "baseUrl" : "maxTokens"]: - key === "metadata" ? "https://other.invalid" : 100, - }; - else f.state[key] = "changed"; - finish.resolve(true); - await request; - expect(f.api.setModel.mock.calls[0][0].contextWindow).toBe(922_000); - expect(f.catalog.contextWindow).toBe(272_000); - expect(f.state.thinking).toBe("off"); - expect(f.api.setThinkingLevel).not.toHaveBeenCalled(); - expect(f.api.appendEntry).not.toHaveBeenCalled(); - expect(f.notify).toHaveBeenLastCalledWith(expect.stringContaining("uncertain"), "warning"); - f.state.current = f.catalog; - await f.controller.reconcile(f.ctx); - await f.controller.modelSelected(f.ctx); - await f.controller.restore(f.ctx); - expect(f.api.setModel).toHaveBeenCalledTimes(1); - await f.controller.handle("extend", f.ctx); - expect(f.state.current?.contextWindow).toBe(922_000); - expect(f.api.appendEntry).toHaveBeenCalledTimes(1); - }, - ); - - it.each(["shutdown", "restore"] as const)( - "invalidates pending setModel on %s without reading stale getters", - async (event) => { - const f = fixture(); - const started = deferred>(); - const finish = deferred(); - f.api.setModel.mockImplementationOnce(async (copy) => { - started.resolve(copy); - await finish.promise; - // An SDK selection can still assign after local cleanup. - f.state.current = copy; - f.state.thinking = "off"; - return true; - }); - const request = f.controller.handle("extend", f.ctx); - const copy = await started.promise; - const original = structuredClone(copy); - const live = f.replaceRoot(); - if (event === "shutdown") f.controller.shutdown(live); - else await f.controller.restore(live); - expect(f.notify).toHaveBeenLastCalledWith( - expect.stringContaining("cannot be cancelled"), - "warning", - ); - f.notify.mockClear(); - finish.resolve(); - await request; - expect(copy).toEqual(original); - expect(f.state.staleReads).toBe(0); - expect(f.state.current?.contextWindow).toBe(922_000); - expect(f.api.appendEntry).not.toHaveBeenCalled(); - expect(f.api.setThinkingLevel).not.toHaveBeenCalled(); - expect(f.notify).not.toHaveBeenCalled(); - }, - ); - - it.each(["extend", "restore"])( - "does not repair a throw-after-assignment on %s", - async (action) => { - const f = fixture(); - if (action === "restore") await f.controller.handle("extend", f.ctx); - const previous = f.state.current!; - const original = structuredClone(previous); - const records = f.api.appendEntry.mock.calls.length; - f.api.setThinkingLevel.mockClear(); - f.api.setModel.mockImplementationOnce(async (copy) => { - await f.assign(copy); - throw new Error("model hook failed after assignment"); - }); - await f.controller.handle(action, f.ctx); - expect(f.state.current?.contextWindow).toBe(action === "extend" ? 922_000 : 272_000); - expect(previous).toEqual(original); - expect(f.state.thinking).toBe("off"); - expect(f.api.setThinkingLevel).not.toHaveBeenCalled(); - expect(f.api.appendEntry).toHaveBeenCalledTimes(records); - expect(f.notify).toHaveBeenLastCalledWith(expect.stringContaining("uncertain"), "warning"); - await f.controller.reconcile(f.ctx); - f.controller.shutdown(); - expect(previous).toEqual(original); - }, - ); - - it("does not rewrite a pending copy or external selection during authentication", async () => { - const f = fixture(); - await f.controller.handle("extend", f.ctx); - const oldCopy = f.state.current!; - const started = deferred>(); - const finish = deferred(); - f.api.setModel.mockImplementationOnce(async (copy) => { - started.resolve(copy); - await finish.promise; - await f.assign(copy); - return true; - }); - const request = f.controller.handle("restore", f.ctx); - const pending = await started.promise; - const originalPending = structuredClone(pending); - const external = model("other", "another-provider"); - const originalExternal = structuredClone(external); - await f.assign(external); - expect(pending).toEqual(originalPending); - finish.resolve(); - await request; - expect(f.state.current).toEqual(originalPending); - expect(external).toEqual(originalExternal); - expect(oldCopy.contextWindow).toBe(922_000); - expect(f.state.thinking).toBe("off"); - expect(f.api.appendEntry).toHaveBeenCalledTimes(1); - expect(f.notify).toHaveBeenCalledWith( - expect.stringContaining("cannot be cancelled"), - "warning", - ); - f.state.current = f.catalog; - await f.controller.reconcile(f.ctx); - expect(f.api.setModel).toHaveBeenCalledTimes(2); - }); - - it("replays the selected model's ordinary branch preference on a provider/ID event", async () => { - const f = fixture(); - f.state.branch = [entry(preference(true, "gpt-6-astra"))]; - await f.controller.restore(f.ctx); - const astra = model("gpt-6-astra"); - f.state.catalog = astra; - await f.assign(astra); - expect(f.state.current?.id).toBe("gpt-6-astra"); - expect(f.state.current?.contextWindow).toBe(922_000); - expect(astra.contextWindow).toBe(272_000); - expect(f.api.setModel).toHaveBeenCalledTimes(1); - expect(f.api.appendEntry).not.toHaveBeenCalled(); - expect(f.state.thinking).toBe("off"); - }); - - it("replays a saved choice after same-ID catalog selection via the next idle hook", async () => { - const f = fixture(); - await f.controller.handle("extend", f.ctx); - const oldCopy = f.state.current!; - await f.assign(f.catalog); // no model_select - expect(f.state.current?.contextWindow).toBe(272_000); - await f.controller.reconcile(f.ctx); - expect(f.state.current?.contextWindow).toBe(922_000); - expect(oldCopy.contextWindow).toBe(922_000); - expect(f.catalog.contextWindow).toBe(272_000); - expect(f.api.appendEntry).toHaveBeenCalledTimes(1); - }); - - it("restores an extended selection with a fresh controller using catalog baseline", async () => { - const f = fixture(); - await f.controller.handle("extend", f.ctx); - f.controller.shutdown(); - const controller = new ContextController(f.api, "personal:codex"); - await controller.restore(f.ctx); - expect(f.state.current?.contextWindow).toBe(922_000); - await controller.handle("restore", f.ctx); - expect(f.api.setModel.mock.lastCall?.[0].contextWindow).toBe(272_000); - expect(f.state.current?.contextWindow).toBe(272_000); - expect(f.api.appendEntry).toHaveBeenLastCalledWith(CONTEXT_ENTRY, preference(false)); - }); - - it.each([300_000, 922_000, 1_000_000])( - "honors catalog override %s rather than a guessed default", - async (window) => { - const f = fixture({ ...model(), contextWindow: window }); - await f.controller.handle("extend", f.ctx); - expect(f.state.current?.contextWindow).toBe(Math.max(window, 922_000)); - if (window >= 922_000) { - expect(f.api.setModel).not.toHaveBeenCalled(); - expect(f.notify).toHaveBeenLastCalledWith( - `Using catalog context: ${window.toLocaleString("en-US")} tokens. No extension is needed.`, - "info", - ); - } - await f.controller.handle("restore", f.ctx); - expect(f.state.current?.contextWindow).toBe(window); - if (window >= 922_000) { - expect(f.confirm).not.toHaveBeenCalled(); - expect(f.notify).toHaveBeenLastCalledWith( - expect.stringContaining("no smaller default is known"), - "info", - ); - } - }, - ); - - it.each(["gpt-6.2-sol", "gpt-6.1-astra", "gpt-7-sol"])( - "does not guess an extended limit or replay saved preferences for %s", - async (id) => { - const f = fixture(model(id)); - const original = structuredClone(f.catalog); - f.state.branch = [entry(preference(true, id))]; - await f.controller.restore(f.ctx); - await f.controller.handle("extend", f.ctx); - await f.controller.handle("restore", f.ctx); - expect(f.notify).toHaveBeenLastCalledWith( - `No verified extended context limit for openai-codex/${id}. Only supported Codex models can be changed.`, - "warning", - ); - const notifications = f.notify.mock.calls.length; - expect(f.controller.getStatus(f.ctx)?.extendedWindow).toBeUndefined(); - expect(f.notify).toHaveBeenCalledTimes(notifications); - expect(f.state.current).toBe(f.catalog); - expect(f.catalog).toEqual(original); - expect(f.api.setModel).not.toHaveBeenCalled(); - expect(f.api.appendEntry).not.toHaveBeenCalled(); - expect(f.confirm).not.toHaveBeenCalled(); - }, - ); - - it("does not extend a known ID through a non-Codex API", async () => { - const f = fixture({ ...model(), api: "openai-responses" }); - await f.controller.handle("extend", f.ctx); - expect(f.api.setModel).not.toHaveBeenCalled(); - expect(f.api.appendEntry).not.toHaveBeenCalled(); - expect(f.notify).toHaveBeenLastCalledWith( - expect.stringContaining("No verified extended context limit"), - "warning", - ); - }); - - it.each([ - undefined, - { ...model(), id: "other" }, - { ...model(), provider: "other" }, - { ...model(), api: "openai-responses" }, - { ...model(), baseUrl: "https://other.invalid" }, - ...[0, -1, NaN, Infinity].map((contextWindow) => ({ ...model(), contextWindow })), - ])("refuses missing/incompatible/invalid catalog baseline %j", async (catalog) => { - const f = fixture(); - f.state.current = { ...f.catalog, contextWindow: 922_000 }; - f.state.catalog = catalog; - await f.controller.handle("restore", f.ctx); - await f.controller.handle("extend", f.ctx); - await f.controller.reconcile(f.ctx); - expect(f.api.setModel).not.toHaveBeenCalled(); - expect(f.api.appendEntry).not.toHaveBeenCalled(); - expect(f.confirm).not.toHaveBeenCalled(); - expect(f.notify).toHaveBeenLastCalledWith( - expect.stringContaining("baseline is unknown"), - "warning", - ); - const notifications = f.notify.mock.calls.length; - expect(f.controller.getStatus(f.ctx)).toMatchObject({ - standardWindow: undefined, - extendedWindow: undefined, - }); - expect(f.notify).toHaveBeenCalledTimes(notifications); - }); -}); diff --git a/extensions/codex/src/controller.ts b/extensions/codex/src/controller.ts index e19d46a..38c1480 100644 --- a/extensions/codex/src/controller.ts +++ b/extensions/codex/src/controller.ts @@ -92,16 +92,20 @@ export class ContextController { async handle(args: string, ctx: ContextHost): Promise { if (this.disposed) return; const action = args.trim().toLowerCase(); - if (action !== "extend" && action !== "restore") { - ctx.ui.notify(`Usage: /${this.commandName} [extend|restore]`, "info"); + if (action !== "on" && action !== "off" && action !== "toggle") { + ctx.ui.notify(`Usage: /${this.commandName} context [on|off|toggle]`, "warning"); return; } if (this.changing || this.selecting || !ctx.isIdle()) { ctx.ui.notify("Wait for the current run or context change to finish.", "warning"); return; } + const model = ctx.model; + const extended = + action === "on" || + (action === "toggle" && (!model || this.preferences.get(modelKey(model)) !== true)); this.replayPaused = false; - await this.change(ctx, action === "extend", true); + await this.change(ctx, extended, true); } shutdown(ctx?: ContextHost): void { @@ -148,7 +152,7 @@ export class ContextController { const standardWindow = baseline(ctx, current); if (standardWindow === undefined) { ctx.ui.notify( - "Catalog baseline is unknown or incompatible. Reselect a catalog model and inspect context status before changing the window.", + `Catalog baseline is unknown or incompatible. Reselect a catalog model and inspect /${this.commandName} before changing the window.`, "warning", ); return; @@ -280,7 +284,7 @@ export class ContextController { if (!accepted) { this.replayPaused = true; ctx.ui.notify( - "Could not change context: provider authentication is unavailable. Automatic replay is paused; explicitly extend or restore after checking authentication.", + `Could not change context: provider authentication is unavailable. Automatic replay is paused; explicitly use /${this.commandName} context on or /${this.commandName} context off after checking authentication.`, "warning", ); return false; @@ -325,7 +329,7 @@ export class ContextController { private uncertain(ctx: ContextHost): void { this.replayPaused = true; ctx.ui.notify( - `Context change interrupted or uncertain. In-flight selection cannot be cancelled and may replace your intended model. Inspect /${this.commandName}, reselect the intended model if needed, then explicitly extend or restore. Automatic replay is paused; try /${this.commandName} again when ready.`, + `Context change interrupted or uncertain. In-flight selection cannot be cancelled and may replace your intended model. Inspect /${this.commandName}, reselect the intended model if needed, then explicitly use /${this.commandName} context on or /${this.commandName} context off. Automatic replay is paused.`, "warning", ); } diff --git a/extensions/codex/src/custom-interaction.test-helper.ts b/extensions/codex/src/custom-interaction.test-helper.ts new file mode 100644 index 0000000..bc9f21d --- /dev/null +++ b/extensions/codex/src/custom-interaction.test-helper.ts @@ -0,0 +1,127 @@ +import { vi } from "vitest"; +import type { + BorderedLoader, + ExtensionUIContext, + KeybindingsManager, + Theme, +} from "@earendil-works/pi-coding-agent"; + +type Tui = ConstructorParameters[0]; +type UIComponent = Awaited[0]>>; +type CustomFactory = ( + tui: Tui, + theme: Theme, + kb: KeybindingsManager, + done: (result: T) => void, +) => UIComponent | Promise; + +// The public custom-interaction lifecycle, without starting a terminal. Model Pi 1.3's +// shared editor restoration (not 1.4's dialog stack), so unsafe non-overlay closes +// really can steal focus from a newer interaction. Overlay removal owns only its entry. +export function customInteractionUI() { + const mainEditor: UIComponent = { render: () => ["editor"], invalidate() {} }; + let editorComponent = mainEditor; + let focusedComponent = mainEditor; + const overlays: { component: UIComponent; preFocus: UIComponent }[] = []; + const requestRender = vi.fn(); + const modals: { + component?: UIComponent; + lines: string[]; + closed: boolean; + adopted: boolean; + close: ReturnType void>>; + requestRender: ReturnType void>>; + }[] = []; + + const restoreEditor = () => { + editorComponent = mainEditor; + focusedComponent = mainEditor; + requestRender(); + }; + const removeOwnedOverlay = (overlay: (typeof overlays)[number] | undefined) => { + if (!overlay) return; + const index = overlays.indexOf(overlay); + if (index === -1) return; + for (const entry of overlays) { + if (entry.preFocus === overlay.component) entry.preFocus = overlay.preFocus; + } + overlays.splice(index, 1); + if (focusedComponent === overlay.component) { + focusedComponent = overlays.at(-1)?.component ?? overlay.preFocus; + } + requestRender(); + }; + const custom = vi.fn( + (factory: CustomFactory, options?: Parameters[1]) => + new Promise((resolve, reject) => { + let overlay: (typeof overlays)[number] | undefined; + const modal = { + component: undefined as UIComponent | undefined, + lines: [] as string[], + closed: false, + adopted: false, + close: vi.fn(), + // Attribute animation requests to the factory while sharing the same UI surface. + requestRender: vi.fn(() => requestRender()), + }; + modal.close.mockImplementation((result: T) => { + if (modal.closed) return; + modal.closed = true; + if (options?.overlay) removeOwnedOverlay(overlay); + else restoreEditor(); + resolve(result); + if (modal.adopted) modal.component?.dispose?.(); + }); + modals.push(modal); + Promise.resolve( + factory( + { requestRender: modal.requestRender } as unknown as Tui, + { fg: (_color: string, text: string) => text } as Theme, + {} as KeybindingsManager, + modal.close, + ), + ) + .then((component) => { + // Keep the result observable even on early done, but never adopt or dispose it: + // native Pi leaves early-done cleanup to the factory's owner. + modal.component = component; + if (modal.closed) return; + modal.adopted = true; + if (options?.overlay) { + overlay = { component, preFocus: focusedComponent }; + overlays.push(overlay); + } else { + editorComponent = component; + } + focusedComponent = component; + modal.lines = component.render(80); + requestRender(); + }) + .catch((error: unknown) => { + if (modal.closed) return; + if (!options?.overlay) restoreEditor(); + reject(error); + }); + }), + ); + + return { + // Vitest erases the generic call signature; the implementation preserves it. + custom: custom as ExtensionUIContext["custom"] & typeof custom, + modals, + requestRender, + mainEditor, + get editorComponent() { + return editorComponent; + }, + get focusedComponent() { + return focusedComponent; + }, + get components() { + return [editorComponent, ...overlays.map((entry) => entry.component)]; + }, + dispose() { + for (const modal of modals) modal.component?.dispose?.(); + }, + }; +} diff --git a/extensions/codex/src/dashboard.test.ts b/extensions/codex/src/dashboard.test.ts index f91d2bd..41d8c86 100644 --- a/extensions/codex/src/dashboard.test.ts +++ b/extensions/codex/src/dashboard.test.ts @@ -27,7 +27,6 @@ const inherited: FastStatus = { preference: "inherit", eligibility: "unknown", requestPolicy: "untouched", - lastRequested: undefined, lastObserved: undefined, conflict: false, }; @@ -40,8 +39,20 @@ it("puts fast and context state together, with discoverable configured commands" expect(text).toContain("Context: 40k / 272k tokens (standard)"); expect(text).toContain("/personal:codex usage — account allowance"); expect(text).toContain( - '/personal:codex fast — priority controls (type "fast o" for on/off suggestions)', + '/personal:codex context on|off|toggle — local context window (type "context o" for on/off suggestions)', ); + expect(text).toContain( + '/personal:codex fast on|off|toggle — priority controls (type "fast o" for on/off suggestions)', + ); + for (const removed of [ + "extend|restore", + " fast status", + " fast reset", + " context status", + " context reset", + ]) { + expect(text).not.toContain(removed); + } expect(text).not.toContain("/eleith:codex"); expect(text).not.toContain("Eligibility:"); expect(text).not.toContain("Tab for options"); @@ -54,7 +65,6 @@ it("does not present requested priority as confirmed server processing", () => { preference: "on", eligibility: "supported", requestPolicy: "priority", - lastRequested: "priority", lastObserved: "default", }; const text = formatCodexDashboard(context, fast, "eleith:codex"); @@ -63,6 +73,25 @@ it("does not present requested priority as confirmed server processing", () => { expect(text).toContain("Context: 40k / 272k tokens (standard)"); }); +it("keeps the effective context window distinct from the selected model window", () => { + const snapshot: ContextStatus = { + ...context, + model: { ...model, contextWindow: 922_000 }, + }; + const original = structuredClone(snapshot); + const text = formatCodexDashboard(snapshot, inherited, "eleith:codex"); + expect(text).toContain( + "Fast: Provider default (no override)\nContext: 40k / 272k tokens (effective) · Selected context window: 922k tokens (extended)", + ); + expect(text).not.toContain("Context: 40k / 922k"); + expect(snapshot).toEqual(original); +}); + +it("keeps the context snapshot visible when fast state is unavailable", () => { + const text = formatCodexDashboard(context, undefined, "eleith:codex"); + expect(text).toContain("Fast: unavailable\nContext: 40k / 272k tokens (standard)"); +}); + it("does not invent a model or context window when no model is selected", () => { expect(formatCodexDashboard(undefined, undefined, "eleith:codex")).toBe("No model selected."); }); diff --git a/extensions/codex/src/dashboard.ts b/extensions/codex/src/dashboard.ts index 4991011..d512b45 100644 --- a/extensions/codex/src/dashboard.ts +++ b/extensions/codex/src/dashboard.ts @@ -18,7 +18,7 @@ export function formatCodexDashboard( formatContextSummary(context, now), "", `/${commandName} usage — account allowance`, - `/${commandName} extend|restore — context window`, - `/${commandName} fast — priority controls (type "fast o" for on/off suggestions)`, + `/${commandName} context on|off|toggle — local context window (type "context o" for on/off suggestions)`, + `/${commandName} fast on|off|toggle — priority controls (type "fast o" for on/off suggestions)`, ].join("\n"); } diff --git a/extensions/codex/src/fast-sdk.test.ts b/extensions/codex/src/fast-sdk.test.ts index 462c3d2..1ca6a92 100644 --- a/extensions/codex/src/fast-sdk.test.ts +++ b/extensions/codex/src/fast-sdk.test.ts @@ -55,34 +55,26 @@ it.each(["sse", "websocket"] as const)( try { const branch = structuredClone(manager.getBranch()); resolveAuth.mockClear(); - await session.prompt("/eleith:codex"); - expect(notify).toHaveBeenLastCalledWith(expect.stringContaining("Context:"), "info"); - expect(notify).toHaveBeenLastCalledWith( - expect.stringContaining("Fast: Provider default (no override)"), - "info", - ); - await session.prompt("/eleith:codex fast"); - expect(notify).toHaveBeenLastCalledWith( - expect.stringContaining("Fast: Provider default (no override)"), - "info", - ); - expect(network.fetch).not.toHaveBeenCalled(); - expect(resolveAuth).not.toHaveBeenCalled(); - expect(credentialWrites).not.toHaveBeenCalled(); - expect(manager.getBranch()).toEqual(branch); - expect(modelTurns).not.toHaveBeenCalled(); - await session.prompt("/eleith:codex fast status"); - expect(notify).toHaveBeenLastCalledWith( - expect.stringContaining( - "Preference: inherit\nEligibility: unknown\nNext request policy: untouched", - ), - "info", - ); - expect(network.fetch).not.toHaveBeenCalled(); - expect(resolveAuth).not.toHaveBeenCalled(); - expect(credentialWrites).not.toHaveBeenCalled(); - expect(manager.getBranch()).toEqual(branch); - expect(modelTurns).not.toHaveBeenCalled(); + let dashboard: string | undefined; + for (const command of ["/eleith:codex", "/eleith:codex fast", "/eleith:codex context"]) { + const notifications = notify.mock.calls.length; + await session.prompt(command); + expect(notify).toHaveBeenCalledTimes(notifications + 1); + expect(notify).toHaveBeenLastCalledWith(expect.stringContaining("Context:"), "info"); + expect(notify).toHaveBeenLastCalledWith( + expect.stringContaining("Fast: Provider default (no override)"), + "info", + ); + const message = notify.mock.calls.at(-1)![0] as string; + if (dashboard === undefined) dashboard = message; + else expect(message).toBe(dashboard); + expect(network.fetch).not.toHaveBeenCalled(); + expect(resolveAuth).not.toHaveBeenCalled(); + expect(credentialWrites).not.toHaveBeenCalled(); + expect(manager.getBranch()).toEqual(branch); + expect(modelTurns).not.toHaveBeenCalled(); + expect(confirm).not.toHaveBeenCalled(); + } await session.prompt("/eleith:codex fast on"); expect(confirm).toHaveBeenCalledOnce(); @@ -120,40 +112,39 @@ it.each(["sse", "websocket"] as const)( const requests = network.fetch.mock.calls.length; resolveAuth.mockClear(); const afterTurn = structuredClone(manager.getBranch()); - await session.prompt("/eleith:codex"); - expect(notify).toHaveBeenLastCalledWith( - expect.stringContaining("Fast: On (priority requested)\nLast reported tier: Standard"), - "info", - ); - expect(notify).toHaveBeenLastCalledWith(expect.stringContaining("Context:"), "info"); - expect(network.fetch).toHaveBeenCalledTimes(requests); - expect(resolveAuth).not.toHaveBeenCalled(); - expect(manager.getBranch()).toEqual(afterTurn); - await session.prompt("/eleith:codex fast status"); - expect(notify).toHaveBeenLastCalledWith(expect.stringContaining("Preference: on"), "info"); - expect(notify).toHaveBeenLastCalledWith( - expect.stringContaining("Last requested tier: priority"), - "info", - ); - expect(notify).toHaveBeenLastCalledWith( - expect.stringContaining("Last observed tier: default"), - "info", - ); - expect(network.fetch).toHaveBeenCalledTimes(requests); - expect(resolveAuth).not.toHaveBeenCalled(); - expect(manager.getBranch()).toEqual(afterTurn); - expect(modelTurns).toHaveBeenCalledOnce(); - expect(credentialWrites).not.toHaveBeenCalled(); + dashboard = undefined; + for (const command of ["/eleith:codex", "/eleith:codex fast", "/eleith:codex context"]) { + const notifications = notify.mock.calls.length; + await session.prompt(command); + expect(notify).toHaveBeenCalledTimes(notifications + 1); + expect(notify).toHaveBeenLastCalledWith( + expect.stringContaining("Fast: On (priority requested)\nLast reported tier: Standard"), + "info", + ); + expect(notify).toHaveBeenLastCalledWith(expect.stringContaining("Context:"), "info"); + const message = notify.mock.calls.at(-1)![0] as string; + if (dashboard === undefined) dashboard = message; + else expect(message).toBe(dashboard); + expect(network.fetch).toHaveBeenCalledTimes(requests); + expect(resolveAuth).not.toHaveBeenCalled(); + expect(manager.getBranch()).toEqual(afterTurn); + expect(modelTurns).toHaveBeenCalledOnce(); + expect(credentialWrites).not.toHaveBeenCalled(); + expect(confirm).toHaveBeenCalledOnce(); + } - await session.prompt("/eleith:codex fast"); + await session.prompt("/eleith:codex fast toggle"); expect(notify).toHaveBeenLastCalledWith( - expect.stringContaining("Fast: On (priority requested)\nLast reported tier: Standard"), + expect.stringContaining("Fast: Off (standard requested)"), "info", ); - expect(notify).toHaveBeenLastCalledWith(expect.not.stringContaining("Preference:"), "info"); + expect(fastChoices(manager)).toEqual([ + { provider: "openai-codex", modelId: model.id, mode: "on" }, + { provider: "openai-codex", modelId: model.id, mode: "off" }, + ]); + expect(confirm).toHaveBeenCalledOnce(); expect(network.fetch).toHaveBeenCalledTimes(requests); expect(resolveAuth).not.toHaveBeenCalled(); - expect(manager.getBranch()).toEqual(afterTurn); expect(modelTurns).toHaveBeenCalledOnce(); expect(credentialWrites).not.toHaveBeenCalled(); expect(errors).toEqual([]); @@ -163,7 +154,7 @@ it.each(["sse", "websocket"] as const)( }, ); -it("SDK replays the active fast branch alongside context controls, then off and reset change the wire", async () => { +it("SDK replays the active fast branch alongside context controls, explicit off and legacy inherit", async () => { const network = installResponseTransports("sse"); const { runtime, model, credentialWrites, resolveAuth, modelTurns } = await createOfflineModelRuntime("sse", network.fetch); @@ -178,6 +169,9 @@ it("SDK replays the active fast branch alongside context controls, then off and model, manager, ); + let extendedWindow: number | undefined; + const outputLimit = session.model?.maxTokens; + const thinking = session.thinkingLevel; try { expect(network.fetch).not.toHaveBeenCalled(); const branch = structuredClone(manager.getBranch()); @@ -187,28 +181,15 @@ it("SDK replays the active fast branch alongside context controls, then off and expect.stringContaining("Fast: Paused — model eligibility needs checking"), "info", ); - expect(network.fetch).not.toHaveBeenCalled(); - expect(resolveAuth).not.toHaveBeenCalled(); - expect(credentialWrites).not.toHaveBeenCalled(); - expect(manager.getBranch()).toEqual(branch); - expect(modelTurns).not.toHaveBeenCalled(); - await session.prompt("/eleith:codex fast status"); - expect(notify).toHaveBeenLastCalledWith( - expect.stringContaining( - "Preference: on\nEligibility: unknown\nNext request policy: suspended", - ), - "info", - ); + expect(notify).toHaveBeenLastCalledWith(expect.stringContaining("Context:"), "info"); expect(network.fetch).not.toHaveBeenCalled(); expect(resolveAuth).not.toHaveBeenCalled(); expect(credentialWrites).not.toHaveBeenCalled(); expect(manager.getBranch()).toEqual(branch); expect(modelTurns).not.toHaveBeenCalled(); - const outputLimit = session.model?.maxTokens; - const thinking = session.thinkingLevel; - await session.prompt("/eleith:codex extend"); - const extendedWindow = session.model?.contextWindow; + await session.prompt("/eleith:codex context on"); + extendedWindow = session.model?.contextWindow; expect(extendedWindow).toBeGreaterThanOrEqual(model.contextWindow); expect(session.model?.maxTokens).toBe(outputLimit); expect(session.thinkingLevel).toBe(thinking); @@ -229,9 +210,26 @@ it("SDK replays the active fast branch alongside context controls, then off and expect(fastChoices(manager)).toEqual([saved, { ...saved, mode: "off" }]); await session.prompt("Reply with ok without priority."); expect(network.payloads[1]).toMatchObject({ model: model.id, service_tier: "default" }); + expect(session.model?.contextWindow).toBe(extendedWindow); + expect(session.model?.maxTokens).toBe(outputLimit); + expect(session.thinkingLevel).toBe(thinking); + expect(credentialWrites).not.toHaveBeenCalled(); + expect(errors).toEqual([]); + } finally { + await closeSession(); + } - await session.prompt("/eleith:codex fast reset"); - expect(notify).toHaveBeenLastCalledWith( + // Old sessions may contain inherit entries. Restore that schema, not a reset command. + const inheritLeaf = manager.appendCustomEntry(FAST_ENTRY, { ...saved, mode: "inherit" }); + manager.appendCustomEntry(FAST_ENTRY, saved); + manager.branch(inheritLeaf); + const legacy = await openRealExtensionSession(runtime, model, manager); + try { + const branch = structuredClone(manager.getBranch()); + const requests = network.fetch.mock.calls.length; + resolveAuth.mockClear(); + await legacy.session.prompt("/eleith:codex fast"); + expect(legacy.notify).toHaveBeenLastCalledWith( expect.stringContaining("Fast: Provider default (no override)"), "info", ); @@ -240,20 +238,24 @@ it("SDK replays the active fast branch alongside context controls, then off and { ...saved, mode: "off" }, { ...saved, mode: "inherit" }, ]); - await session.prompt("Reply with ok using the provider default."); + expect(manager.getBranch()).toEqual(branch); + expect(network.fetch).toHaveBeenCalledTimes(requests); + expect(resolveAuth).not.toHaveBeenCalled(); + await legacy.session.prompt("Reply with ok using the provider default."); expect(network.payloads[2]).toHaveProperty("model", model.id); expect(network.payloads[2]).not.toHaveProperty("service_tier"); expect(network.payloads).toHaveLength(3); - expect(session.getLastAssistantText()).toBe("ok"); + expect(legacy.session.getLastAssistantText()).toBe("ok"); expect(modelTurns).toHaveBeenCalledTimes(3); expect(network.catalog).toHaveBeenCalledOnce(); - expect(session.model?.contextWindow).toBe(extendedWindow); - expect(session.model?.maxTokens).toBe(outputLimit); - expect(session.thinkingLevel).toBe(thinking); + expect(legacy.session.model?.contextWindow).toBe(extendedWindow); + expect(legacy.session.model?.maxTokens).toBe(outputLimit); + expect(legacy.session.thinkingLevel).toBe(thinking); + expect(legacy.confirm).not.toHaveBeenCalled(); expect(credentialWrites).not.toHaveBeenCalled(); - expect(errors).toEqual([]); + expect(legacy.errors).toEqual([]); } finally { - await closeSession(); + await legacy.closeSession(); } }); diff --git a/extensions/codex/src/fast-status.test.ts b/extensions/codex/src/fast-status.test.ts index 229d0ea..7e4c76c 100644 --- a/extensions/codex/src/fast-status.test.ts +++ b/extensions/codex/src/fast-status.test.ts @@ -1,10 +1,5 @@ import { expect, it } from "vitest"; -import { - formatFastDiagnostics, - formatFastStatus, - type FastStatus, - type ObservedTier, -} from "./fast-status.ts"; +import { formatFastStatus, type FastStatus, type ObservedTier } from "./fast-status.ts"; it("leaves provider default brief without unknown fields, commands or cost warnings", () => { const fast = createFastStatusFixture(); @@ -21,7 +16,6 @@ it("describes on as a priority request without claiming priority was delivered", preference: "on", eligibility: "supported", requestPolicy: "priority", - lastRequested: "priority", }); expect(formatFastStatus(fast).split("\n")).toEqual([ "Fast: On (priority requested)", @@ -30,12 +24,11 @@ it("describes on as a priority request without claiming priority was delivered", ]); }); -it("keeps requested priority separate from a reported standard tier", () => { +it("keeps next-request priority separate from a reported standard tier", () => { const fast = createFastStatusFixture({ preference: "on", eligibility: "supported", requestPolicy: "priority", - lastRequested: "priority", lastObserved: "default", }); expect(formatFastStatus(fast).split("\n")).toEqual([ @@ -62,7 +55,6 @@ it("pauses a saved on choice when eligibility needs checking without claiming a const fast = createFastStatusFixture({ preference: "on", requestPolicy: "suspended", - lastRequested: "priority", }); expect(formatFastStatus(fast)).toBe("Fast: Paused — model eligibility needs checking"); }); @@ -80,12 +72,11 @@ it("explains when priority is not available without erasing the saved preference expect(fast.preference).toBe("on"); }); -it("gives outside scope precedence over the saved on preference and old requested tier", () => { +it("gives outside scope precedence over the saved on preference and supported eligibility", () => { const fast = createFastStatusFixture({ preference: "on", eligibility: "supported", requestPolicy: "outside scope", - lastRequested: "priority", }); expect(formatFastStatus(fast)).toBe("Fast: Unavailable for this model/provider"); }); @@ -117,51 +108,11 @@ it.each(["priority", "default"] as const)( }, ); -it("preserves unknown delivery and the full scope, allowance and cost-estimate diagnostics", () => { - const fast = createFastStatusFixture({ preference: "on", requestPolicy: "suspended" }); - expect(formatFastDiagnostics(fast).split("\n")).toEqual([ - "Codex fast mode (selected model)", - "Preference: on", - "Eligibility: unknown", - "Next request policy: suspended", - "Last requested tier: unknown", - "Last observed tier: unknown", - "Scope: direct legacy Codex; may include retries, compaction and cache warming. Selected scope is not physical-request identity.", - "Priority uses more allowance. Pi's cost estimate may miss priority pricing when response tier metadata is absent or normalized.", - "Observed tier is model-scoped, not a receipt for the last requested tier.", - ]); -}); - -it("preserves distinct requested and observed diagnostics and the original optional conflict note", () => { - const fast = createFastStatusFixture({ - preference: "on", - eligibility: "supported", - requestPolicy: "priority", - lastRequested: "priority", - lastObserved: "default", - conflict: true, - }); - expect(formatFastDiagnostics(fast).split("\n")).toEqual([ - "Codex fast mode (selected model)", - "Preference: on", - "Eligibility: supported", - "Next request policy: priority", - "Last requested tier: priority", - "Last observed tier: default", - "Tier conflict: an existing non-owned tier was left untouched.", - "Scope: direct legacy Codex; may include retries, compaction and cache warming. Selected scope is not physical-request identity.", - "Priority uses more allowance. Pi's cost estimate may miss priority pricing when response tier metadata is absent or normalized.", - "Observed tier is model-scoped, not a receipt for the last requested tier.", - ]); - expect(formatFastDiagnostics({ ...fast, conflict: false })).not.toContain("Tier conflict:"); -}); - function createFastStatusFixture(overrides: Partial = {}): FastStatus { return { preference: "inherit", eligibility: "unknown", requestPolicy: "untouched", - lastRequested: undefined, lastObserved: undefined, conflict: false, ...overrides, diff --git a/extensions/codex/src/fast-status.ts b/extensions/codex/src/fast-status.ts index 2ac4a79..51efdd4 100644 --- a/extensions/codex/src/fast-status.ts +++ b/extensions/codex/src/fast-status.ts @@ -9,7 +9,6 @@ export interface FastStatus { preference: FastPreference; eligibility: FastEligibility; requestPolicy: FastRequestPolicy; - lastRequested: RequestedTier | undefined; lastObserved: ObservedTier | undefined; conflict: boolean; } @@ -53,21 +52,3 @@ export function formatFastStatus(status: FastStatus): string { if (status.requestPolicy === "priority") rows.push("Priority requests use more allowance."); return rows.join("\n"); } - -export function formatFastDiagnostics(status: FastStatus): string { - const rows = [ - "Codex fast mode (selected model)", - `Preference: ${status.preference}`, - `Eligibility: ${status.eligibility}`, - `Next request policy: ${status.requestPolicy}`, - `Last requested tier: ${status.lastRequested ?? "unknown"}`, - `Last observed tier: ${status.lastObserved ?? "unknown"}`, - ]; - if (status.conflict) rows.push("Tier conflict: an existing non-owned tier was left untouched."); - rows.push( - "Scope: direct legacy Codex; may include retries, compaction and cache warming. Selected scope is not physical-request identity.", - "Priority uses more allowance. Pi's cost estimate may miss priority pricing when response tier metadata is absent or normalized.", - "Observed tier is model-scoped, not a receipt for the last requested tier.", - ); - return rows.join("\n"); -} diff --git a/extensions/codex/src/fast.test.ts b/extensions/codex/src/fast.test.ts index 2f0385b..62a7bc0 100644 --- a/extensions/codex/src/fast.test.ts +++ b/extensions/codex/src/fast.test.ts @@ -34,43 +34,29 @@ const catalog: CodexModelCatalog = { }; describe("commands and ownership", () => { - it("reports bare fast briefly without confirmation, auth IO, catalog IO or writes", async () => { - const fast = createFastControllerFixture(); - fast.ctx.sessionManager.getBranch.mockReturnValue([choice("on")]); - fast.controller.restore(fast.ctx); - await fast.controller.handle("", fast.ctx); - expect(fast.ctx.ui.notify).toHaveBeenCalledExactlyOnceWith( - expect.stringContaining("Fast: Paused — model eligibility needs checking"), - "info", - ); - expect(fast.ctx.ui.notify).not.toHaveBeenCalledWith( - expect.stringContaining("Preference:"), - expect.anything(), - ); - 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(); - }); - - it("reports explicit status technically without confirmation, auth IO, catalog IO or writes", async () => { - const fast = createFastControllerFixture(); - fast.ctx.sessionManager.getBranch.mockReturnValue([choice("on")]); - fast.controller.restore(fast.ctx); - await fast.controller.handle(" STATUS ", fast.ctx); - expect(fast.ctx.ui.notify).toHaveBeenCalledExactlyOnceWith( - expect.stringContaining( - "Preference: on\nEligibility: unknown\nNext request policy: suspended", - ), - "info", - ); - 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(); - }); + it.each(["", " ", "status", " STATUS ", "reset", "enable", "disable", "on extra"])( + "rejects the unsupported action %j without confirmation, IO or writes", + async (action) => { + const fast = createFastControllerFixture(); + fast.ctx.sessionManager.getBranch.mockReturnValue([choice("on")]); + fast.controller.restore(fast.ctx); + await fast.controller.handle(action, fast.ctx); + expect(fast.ctx.ui.notify).toHaveBeenCalledExactlyOnceWith( + "Usage: /eleith:codex fast [on|off|toggle]", + "warning", + ); + expect(fast.controller.getStatus(fast.ctx)).toMatchObject({ + preference: "on", + eligibility: "unknown", + requestPolicy: "suspended", + }); + 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(); + }, + ); it("does not discover or persist anything when the cost confirmation is declined", async () => { const fast = createFastControllerFixture(); @@ -110,12 +96,11 @@ describe("commands and ownership", () => { const fast = createFastControllerFixture(); await fast.controller.handle("on extra", fast.ctx); expect(fast.ctx.ui.notify).toHaveBeenLastCalledWith( - "Usage: /eleith:codex fast [on|off|toggle|status|reset]", + "Usage: /eleith:codex fast [on|off|toggle]", "warning", ); fast.ctx.isIdle.mockReturnValue(false); - for (const action of ["on", "off", "toggle", "reset"]) - await fast.controller.handle(action, fast.ctx); + for (const action of ["on", "off", "toggle"]) await fast.controller.handle(action, fast.ctx); expect(fast.ctx.ui.notify).toHaveBeenLastCalledWith( expect.stringContaining("Wait for the current run"), "warning", @@ -204,7 +189,7 @@ describe("commands and ownership", () => { }, ); - it("copies a frozen payload for explicit default, preserves fields, then resets with an inherit tombstone", async () => { + it("copies a frozen payload for explicit default, preserves fields, then restores a legacy inherit entry", async () => { const fast = createFastControllerFixture(); const input = Object.freeze([{ role: "user", content: "hello" }]); const payload = Object.freeze({ @@ -216,7 +201,7 @@ describe("commands and ownership", () => { }); await fast.controller.handle("off", fast.ctx); expect(fast.ctx.ui.notify).toHaveBeenLastCalledWith( - expect.stringContaining("Fast: Off (standard requested)"), + "Fast: Off (standard requested)\n\n/eleith:codex fast on|off|toggle", "info", ); const transformed = fast.controller.transform(payload, fast.ctx); @@ -230,38 +215,36 @@ describe("commands and ownership", () => { ).toBeUndefined(); await fast.controller.handle("off", fast.ctx); expect(fast.appendEntry).toHaveBeenCalledOnce(); - await fast.controller.handle("reset", fast.ctx); - expect(fast.appendEntry).toHaveBeenLastCalledWith(FAST_ENTRY, { - provider: model.provider, - modelId: model.id, - mode: "inherit", - }); + fast.ctx.sessionManager.getBranch.mockReturnValue([choice("off"), choice("inherit")]); + fast.controller.restore(fast.ctx); expect(fast.controller.transform(payload, fast.ctx)).toBeUndefined(); - expect(fast.ctx.ui.notify).toHaveBeenLastCalledWith( - expect.stringContaining("Fast: Provider default (no override)"), - "info", - ); - await fast.controller.handle("reset", fast.ctx); - expect(fast.appendEntry).toHaveBeenCalledTimes(2); + expect(fast.controller.getStatus(fast.ctx)).toMatchObject({ + 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("can remove its saved choice after the selection leaves direct scope", async () => { + 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); - await fast.controller.handle("reset", fast.ctx); - expect(fast.appendEntry).toHaveBeenLastCalledWith(FAST_ENTRY, { - provider: model.provider, - modelId: model.id, - mode: "inherit", + 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.ctx.ui.notify).toHaveBeenLastCalledWith( - expect.stringContaining("Fast: Unavailable for this model/provider"), - "info", - ); + 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"])( @@ -273,48 +256,18 @@ describe("commands and ownership", () => { const payload = Object.freeze({ model: model.id, service_tier: "flex", input: [] }); expect(fast.controller.transform(payload, fast.ctx)).toBeUndefined(); expect(payload.service_tier).toBe("flex"); - await fast.controller.handle("status", fast.ctx); - expect(fast.ctx.ui.notify).toHaveBeenLastCalledWith( - expect.stringContaining("Tier conflict: an existing non-owned tier was left untouched."), - "info", - ); - expect(fast.ctx.ui.notify).toHaveBeenLastCalledWith( - expect.stringContaining("Last requested tier: unknown"), - "info", - ); - expect(fast.ctx.ui.notify).toHaveBeenLastCalledWith( - expect.stringContaining(`Next request policy: ${tier}`), - "info", - ); - await fast.controller.handle("", fast.ctx); - const headline = - mode === "on" ? "Fast: On (priority requested)" : "Fast: Off (standard requested)"; - expect(fast.ctx.ui.notify).toHaveBeenLastCalledWith( - expect.stringContaining(headline), - "info", - ); - expect(fast.ctx.ui.notify).toHaveBeenLastCalledWith( - expect.stringContaining("Last request kept another tier setting; not overridden."), - "info", - ); + expect(fast.controller.getStatus(fast.ctx)).toMatchObject({ + conflict: true, + requestPolicy: tier, + }); expect(fast.controller.transform({ model: model.id }, fast.ctx)).toEqual({ model: model.id, service_tier: tier, }); - await fast.controller.handle("status", fast.ctx); - expect(fast.ctx.ui.notify).toHaveBeenLastCalledWith( - expect.not.stringContaining("Tier conflict:"), - "info", - ); - await fast.controller.handle("", fast.ctx); - expect(fast.ctx.ui.notify).toHaveBeenLastCalledWith( - expect.stringContaining(headline), - "info", - ); - expect(fast.ctx.ui.notify).toHaveBeenLastCalledWith( - expect.not.stringContaining("Last request kept another tier setting"), - "info", - ); + expect(fast.controller.getStatus(fast.ctx)).toMatchObject({ + conflict: false, + requestPolicy: tier, + }); }, ); @@ -383,13 +336,11 @@ describe("branch replay and preparation", () => { stream({ type: "response.completed", response: { service_tier: "default" } }), fast.ctx, ); - await fast.controller.handle("status", fast.ctx); - expect(fast.ctx.ui.notify).toHaveBeenLastCalledWith( - expect.stringContaining( - "Preference: on\nEligibility: unknown\nNext request policy: suspended", - ), - "info", - ); + 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(); @@ -436,13 +387,11 @@ describe("branch replay and preparation", () => { await fast.controller.prepare(fast.ctx); expect(fast.fetchCatalog).toHaveBeenCalledTimes(2); expect(fast.controller.transform({ model: model.id }, fast.ctx)).toBeUndefined(); - await fast.controller.handle("status", fast.ctx); - expect(fast.ctx.ui.notify).toHaveBeenLastCalledWith( - expect.stringContaining( - "Preference: on\nEligibility: unsupported\nNext request policy: suspended", - ), - "info", - ); + 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); @@ -492,7 +441,11 @@ describe("branch replay and preparation", () => { stream({ type: "response.completed", response: { service_tier: "priority" } }), fast.ctx, ); - await fast.controller.handle("status", 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); @@ -520,11 +473,10 @@ describe("branch replay and preparation", () => { "warning", ); expect(fast.controller.transform({ model: model.id }, fast.ctx)).toBeUndefined(); - await fast.controller.handle("status", fast.ctx); - expect(fast.ctx.ui.notify).toHaveBeenLastCalledWith( - expect.stringContaining("Preference: on"), - "info", - ); + expect(fast.controller.getStatus(fast.ctx)).toMatchObject({ + preference: "on", + requestPolicy: "suspended", + }); expect(fast.appendEntry).not.toHaveBeenCalled(); }, ); @@ -633,7 +585,8 @@ describe("asynchronous ownership", () => { expect(fast.readAuthScope).not.toHaveBeenCalled(); expect(fast.fetchCatalog).not.toHaveBeenCalled(); expect(fast.appendEntry).not.toHaveBeenCalled(); - await fast.controller.handle("status", fast.ctx); + 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(); @@ -702,31 +655,26 @@ describe("asynchronous ownership", () => { }); describe("provider stream observations", () => { - it("reports an observed default independently of requested priority, without changing the next preference", async () => { + it("reports an observed default independently of the priority payload and next-request policy", async () => { const fast = createFastControllerFixture(); await fast.controller.handle("on", fast.ctx); const payload = Object.freeze({ model: model.id, input: [], service_tier: "priority" }); - expect(fast.controller.transform(payload, fast.ctx)).toBeUndefined(); + const transformed = fast.controller.transform(payload, fast.ctx); + expect(transformed).toBeUndefined(); + expect(transformed ?? payload).toEqual({ + model: model.id, + input: [], + service_tier: "priority", + }); fast.controller.observe( stream({ type: "response.completed", response: { service_tier: "default" } }), fast.ctx, ); - await fast.controller.handle("status", fast.ctx); - expect(fast.ctx.ui.notify).toHaveBeenLastCalledWith( - expect.stringContaining( - "Next request policy: priority\nLast requested tier: priority\nLast observed tier: default", - ), - "info", - ); - await fast.controller.handle("", fast.ctx); - expect(fast.ctx.ui.notify).toHaveBeenLastCalledWith( - expect.stringContaining("Fast: On (priority requested)\nLast reported tier: Standard"), - "info", - ); - expect(fast.ctx.ui.notify).toHaveBeenLastCalledWith( - expect.not.stringContaining("No response tier reported yet."), - "info", - ); + expect(fast.controller.getStatus(fast.ctx)).toMatchObject({ + preference: "on", + requestPolicy: "priority", + lastObserved: "default", + }); expect(fast.controller.transform({ model: model.id }, fast.ctx)?.service_tier).toBe("priority"); expect(fast.fetchCatalog).toHaveBeenCalledOnce(); expect(fast.readAuthScope).toHaveBeenCalledOnce(); @@ -738,17 +686,19 @@ 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); - fast.controller.transform({ model: model.id }, fast.ctx); + expect(fast.controller.transform({ model: model.id }, fast.ctx)).toEqual({ + model: model.id, + service_tier: "default", + }); fast.controller.observe( stream({ type: "response.completed", response: { service_tier: "default" } }), fast.ctx, ); fast.controller.observe(stream(data), fast.ctx); - await fast.controller.handle("status", fast.ctx); - expect(fast.ctx.ui.notify).toHaveBeenLastCalledWith( - expect.stringContaining("Last requested tier: default\nLast observed tier: unknown"), - "info", - ); + expect(fast.controller.getStatus(fast.ctx)).toMatchObject({ + requestPolicy: "default", + lastObserved: undefined, + }); expect(fast.readAuthScope).not.toHaveBeenCalled(); expect(fast.fetchCatalog).not.toHaveBeenCalled(); }); @@ -756,7 +706,10 @@ describe("provider stream observations", () => { it("ignores wrong-model and nonterminal events, and does not show delivery from another session", async () => { const fast = createFastControllerFixture(); await fast.controller.handle("off", fast.ctx); - fast.controller.transform({ model: model.id }, fast.ctx); + expect(fast.controller.transform({ model: model.id }, fast.ctx)).toEqual({ + model: model.id, + service_tier: "default", + }); fast.controller.observe( stream({ type: "response.completed", response: { service_tier: "default" } }), fast.ctx, @@ -772,21 +725,19 @@ describe("provider stream observations", () => { stream({ type: "response.created", response: { service_tier: "priority" } }), fast.ctx, ); - await fast.controller.handle("status", fast.ctx); - expect(fast.ctx.ui.notify).toHaveBeenLastCalledWith( - expect.stringContaining("Last observed tier: default"), - "info", - ); + expect(fast.controller.getStatus(fast.ctx)).toMatchObject({ + requestPolicy: "default", + lastObserved: "default", + }); fast.ctx.sessionManager.getSessionId.mockReturnValue("session-b"); fast.controller.observe( stream({ type: "response.completed", response: { service_tier: "priority" } }), fast.ctx, ); - await fast.controller.handle("status", fast.ctx); - expect(fast.ctx.ui.notify).toHaveBeenLastCalledWith( - expect.stringContaining("Last requested tier: unknown\nLast observed tier: unknown"), - "info", - ); + expect(fast.controller.getStatus(fast.ctx)).toMatchObject({ + requestPolicy: "default", + lastObserved: undefined, + }); }); }); diff --git a/extensions/codex/src/fast.ts b/extensions/codex/src/fast.ts index c60e129..3de9d71 100644 --- a/extensions/codex/src/fast.ts +++ b/extensions/codex/src/fast.ts @@ -18,11 +18,9 @@ import { } from "./fast-state.ts"; import { formatFastStatus, - formatFastDiagnostics, type FastStatus, type FastRequestPolicy, type ObservedTier, - type RequestedTier, } from "./fast-status.ts"; import { modelKey } from "./profiles.ts"; import { @@ -67,7 +65,6 @@ interface CachedEligibility { interface ModelTierActivity { modelKey: string; sessionId: string; - requested: RequestedTier | undefined; observed: ObservedTier | undefined; conflict: boolean; } @@ -115,7 +112,6 @@ export class FastController { preference: this.preferenceFor(selectedModelKey), eligibility, requestPolicy: this.requestPolicy(ctx), - lastRequested: activity?.requested, lastObserved: activity?.observed, conflict: activity?.conflict ?? false, }; @@ -124,19 +120,8 @@ export class FastController { async handle(args: string, ctx: FastHost): Promise { if (this.disposed) return; const action = args.trim().toLowerCase(); - if (!action) { - this.report(ctx); - return; - } - if (action === "status") { - const status = this.getStatus(ctx); - let message = "No model selected."; - if (status) message = formatFastDiagnostics(status); - ctx.ui.notify(message, "info"); - return; - } - if (!["on", "off", "toggle", "reset"].includes(action)) { - ctx.ui.notify(`Usage: /${this.commandName} fast [on|off|toggle|status|reset]`, "warning"); + if (!["on", "off", "toggle"].includes(action)) { + ctx.ui.notify(`Usage: /${this.commandName} fast [on|off|toggle]`, "warning"); return; } if (this.pendingPreparation || !ctx.isIdle()) { @@ -148,18 +133,11 @@ export class FastController { ctx.ui.notify("No model selected.", "warning"); return; } - let mode: FastPreference; - if (action === "reset") mode = "inherit"; - else if (action === "off") mode = "off"; + let mode: "on" | "off"; + if (action === "off") mode = "off"; else if (action === "toggle" && this.preferenceFor(modelKey(model)) === "on") mode = "off"; else mode = "on"; - // Reset only removes our choice; it is safe even after the selection leaves supported scope. - if (mode === "inherit") { - this.rememberChoice(ctx, mode); - this.report(ctx); - return; - } if (!isDirectCodexSelection(ctx)) { ctx.ui.notify( "Fast controls require a direct official legacy Codex model with Pi OAuth and no provider override.", @@ -207,7 +185,6 @@ export class FastController { const activity: ModelTierActivity = { modelKey: modelKey(ctx.model), sessionId: ctx.sessionManager.getSessionId(), - requested: undefined, observed: undefined, conflict: false, }; @@ -218,7 +195,6 @@ export class FastController { activity.conflict = true; return; } - activity.requested = policy; if (payload.service_tier === policy) return; return { ...payload, service_tier: policy }; } @@ -426,7 +402,7 @@ export class FastController { return; } const summary = formatFastStatus(status); - const hint = `/${this.commandName} fast on|off|toggle|reset · status for details`; + const hint = `/${this.commandName} fast on|off|toggle`; ctx.ui.notify(`${summary}\n\n${hint}`, "info"); } } diff --git a/extensions/codex/src/index.test.ts b/extensions/codex/src/index.test.ts index 59ce3b3..cef7cd4 100644 --- a/extensions/codex/src/index.test.ts +++ b/extensions/codex/src/index.test.ts @@ -62,7 +62,7 @@ async function load(prefix = "eleith") { isIdle: () => true, getContextUsage: () => undefined, modelRegistry: {}, - ui: { notify, confirm: vi.fn(async () => false), setStatus: vi.fn() }, + ui: { notify, confirm: vi.fn(async () => false) }, sessionManager: { getBranch: () => [], getSessionId: () => "session", getLeafId: () => null }, } as unknown as ExtensionCommandContext; await codex(pi as unknown as ExtensionAPI); @@ -89,11 +89,15 @@ it("registers only the configured native command once after binding", async () = await context.emit("session_start"); expect(context.pi.registerCommand).toHaveBeenCalledTimes(1); expect(context.pi.registerCommand.mock.calls[0]?.[0]).toBe("personal:codex"); - expect(await context.command().getArgumentCompletions?.("e")).toEqual([ - expect.objectContaining({ value: "extend", label: "extend", description: expect.any(String) }), + expect(await context.command().getArgumentCompletions?.("c")).toEqual([ + expect.objectContaining({ + value: "context", + label: "context", + description: expect.any(String), + }), ]); const rootChoices = await context.command().getArgumentCompletions?.(""); - expect(rootChoices?.map(({ value }) => value)).toEqual(["extend", "restore", "usage", "fast"]); + expect(rootChoices?.map(({ value }) => value)).toEqual(["context", "fast", "usage"]); expect(await context.command().getArgumentCompletions?.("fast o")).toEqual([ expect.objectContaining({ value: "fast on", @@ -109,12 +113,14 @@ it("registers only the configured native command once after binding", async () = it("handles no argument as status rather than an invalid default action", async () => { const context = await load(); await context.emit("session_start"); - await context.command().handler("", context.ctx); - expect(context.notify).toHaveBeenLastCalledWith("No model selected.", "info"); + for (const input of ["", "context", "fast"]) { + await context.command().handler(input, context.ctx); + expect(context.notify).toHaveBeenLastCalledWith("No model selected.", "info"); + } expect(context.pi.setModel).not.toHaveBeenCalled(); await context.command().handler("extend restore", context.ctx); expect(context.notify).toHaveBeenLastCalledWith( - "Usage: /eleith:codex [extend|restore|usage|fast]", + "Usage: /eleith:codex [context|fast|usage]", "warning", ); }); @@ -137,21 +143,24 @@ it("wires restore/reconcile hooks without provider calls for an unsupported mode expect(readCodexAuthScope).not.toHaveBeenCalled(); }); -it("routes fast subcommands without allowing trailing arguments on other actions", async () => { +it("rejects removed commands and additional control arguments without side effects", async () => { const context = await load("personal"); await context.emit("session_start"); - await context.command().handler("fast status", context.ctx); - expect(context.notify).toHaveBeenLastCalledWith("No model selected.", "info"); - await context.command().handler("fast off extra", context.ctx); - expect(context.notify).toHaveBeenLastCalledWith( - "Usage: /personal:codex fast [on|off|toggle|status|reset]", - "warning", - ); - await context.command().handler("restore extra", context.ctx); - expect(context.notify).toHaveBeenLastCalledWith( - "Usage: /personal:codex [extend|restore|usage|fast]", - "warning", - ); + const rootUsage = "Usage: /personal:codex [context|fast|usage]"; + const cases = [ + ["extend", rootUsage], + ["restore", rootUsage], + ["usage extra", rootUsage], + ["fast status", "Usage: /personal:codex fast [on|off|toggle]"], + ["fast reset", "Usage: /personal:codex fast [on|off|toggle]"], + ["fast off extra", "Usage: /personal:codex fast [on|off|toggle]"], + ["context status", "Usage: /personal:codex context [on|off|toggle]"], + ["context on extra", "Usage: /personal:codex context [on|off|toggle]"], + ] as const; + for (const [input, usage] of cases) { + await context.command().handler(input, context.ctx); + expect(context.notify).toHaveBeenLastCalledWith(usage, "warning"); + } expect(fetchCodexModelCatalog).not.toHaveBeenCalled(); expect(readCodexAuthScope).not.toHaveBeenCalled(); expect(fetchCodexUsage).not.toHaveBeenCalled(); @@ -245,6 +254,18 @@ it("combines context and fast mode in a read-only dashboard with the configured expect(readCodexAuthScope).not.toHaveBeenCalled(); expect(context.pi.appendEntry).not.toHaveBeenCalled(); expect(context.pi.setModel).not.toHaveBeenCalled(); + const dashboard = context.notify.mock.calls[0][0]; + for (const input of ["context", "fast"]) { + await context.command().handler(input, ctx); + expect(context.notify).toHaveBeenLastCalledWith(dashboard, "info"); + } + expect(context.notify).toHaveBeenCalledTimes(3); + expect(getProviderAuth).not.toHaveBeenCalled(); + expect(fetchCodexUsage).not.toHaveBeenCalled(); + expect(fetchCodexModelCatalog).not.toHaveBeenCalled(); + expect(readCodexAuthScope).not.toHaveBeenCalled(); + expect(context.pi.appendEntry).not.toHaveBeenCalled(); + expect(context.pi.setModel).not.toHaveBeenCalled(); await context.emit("session_shutdown"); const notify = context.notify; Object.defineProperty(ctx, "ui", { @@ -252,8 +273,8 @@ it("combines context and fast mode in a read-only dashboard with the configured throw new Error("Expired UI"); }, }); - await context.command().handler("", ctx); - expect(notify).toHaveBeenCalledOnce(); + for (const input of ["", "context", "fast"]) await context.command().handler(input, ctx); + expect(notify).toHaveBeenCalledTimes(3); }); it("fails invalid prefix settings before registering any capabilities", async () => { diff --git a/extensions/codex/src/index.ts b/extensions/codex/src/index.ts index d497ee7..f71c77a 100644 --- a/extensions/codex/src/index.ts +++ b/extensions/codex/src/index.ts @@ -20,19 +20,20 @@ export default async function codex(pi: ExtensionAPI): Promise { description: "Context, fast requests and account allowance", actions: CODEX_ACTIONS, getArgumentCompletions: completeCodexArguments, - argumentActions: ["fast"], + argumentActions: ["context", "fast"], defaultAction: "", handler(action, ctx, argument) { if (disposed) return; - if (!action) { + const showStatus = !action || ((action === "context" || action === "fast") && !argument); + if (showStatus) { const context = controller.getStatus(ctx); const fastStatus = fast.getStatus(ctx); ctx.ui.notify(formatCodexDashboard(context, fastStatus, commandName), "info"); return; } if (action === "usage") return usage.report(ctx); + if (action === "context") return controller.handle(argument ?? "", ctx); if (action === "fast") return fast.handle(argument ?? "", ctx); - return controller.handle(action, ctx); }, }); @@ -41,12 +42,12 @@ export default async function codex(pi: ExtensionAPI): Promise { return controller.restore(ctx); }); pi.on("session_tree", (_event, ctx) => { - usage.cancel(ctx); + usage.cancel(); fast.restore(ctx); return controller.restore(ctx); }); pi.on("model_select", (_event, ctx) => { - usage.cancel(ctx); + usage.cancel(); fast.invalidate(); return controller.modelSelected(ctx); }); @@ -54,7 +55,7 @@ export default async function codex(pi: ExtensionAPI): Promise { disposed = true; fast.dispose(); try { - usage.dispose(ctx); + usage.dispose(); } finally { controller.shutdown(ctx); } diff --git a/extensions/codex/src/sdk.test.ts b/extensions/codex/src/sdk.test.ts index ccb38e2..bf6649c 100644 --- a/extensions/codex/src/sdk.test.ts +++ b/extensions/codex/src/sdk.test.ts @@ -180,7 +180,7 @@ it.each([ const original = structuredClone(f.catalog); try { const leaf = f.manager.getLeafId(); - await f.session.prompt("/eleith:codex extend"); + await f.session.prompt("/eleith:codex context on"); const extended = f.session.model!; expect(extended.contextWindow).toBe(Math.max(window, 922_000)); if (window < 922_000) { @@ -194,7 +194,7 @@ it.each([ await f.session.reload(); expect(f.session.model?.contextWindow).toBe(Math.max(window, 922_000)); expect(f.registry.find("openai-codex", modelId)?.contextWindow).toBe(window); - await f.session.prompt("/eleith:codex restore"); + await f.session.prompt("/eleith:codex context off"); expect(f.session.model?.contextWindow).toBe(window); expect(extended.contextWindow).toBe(Math.max(window, 922_000)); expect(f.catalog).toEqual(original); @@ -225,7 +225,7 @@ it("SDK replays a saved context preference after the command rename", async () = expect(f.session.model?.contextWindow).toBe(922_000); expect(f.preferences()).toEqual([saved]); - await f.session.prompt("/eleith:codex restore"); + await f.session.prompt("/eleith:codex context off"); expect(f.session.model?.contextWindow).toBe(272_000); expect(f.preferences()).toEqual([saved, { ...saved, extended: false }]); } finally { @@ -257,7 +257,7 @@ it.each(["rename", "external"])( }); const selection = vi.spyOn(f.session, "setModel"); try { - const request = f.session.prompt("/eleith:codex extend"); + const request = f.session.prompt("/eleith:codex context on"); await started; const submitted = selection.mock.calls[0][0]; const originalSubmitted = structuredClone(submitted); @@ -284,7 +284,7 @@ it.each(["rename", "external"])( expect(thinkingWrites).not.toHaveBeenCalled(); expect(f.session.thinkingLevel).toBe(thinking); // Only an explicit context-changing command resumes work. - await f.session.prompt("/eleith:codex restore"); + await f.session.prompt("/eleith:codex context off"); expect(f.session.model?.contextWindow).toBe(272_000); expect(f.preferences()).toEqual([ { provider: "openai-codex", modelId: "gpt-6-sol", extended: false }, @@ -304,14 +304,14 @@ it.each(["missing", "endpoint"])( async (reason) => { const f = await fixture(); try { - await f.session.prompt("/eleith:codex extend"); + await f.session.prompt("/eleith:codex context on"); const lookup = vi .spyOn(f.registry, "find") .mockReturnValue( reason === "missing" ? undefined : { ...f.catalog, baseUrl: "https://different.invalid" }, ); const select = vi.spyOn(f.session, "setModel"); - await f.session.prompt("/eleith:codex restore"); + await f.session.prompt("/eleith:codex context off"); expect(select).not.toHaveBeenCalled(); expect(f.session.model?.contextWindow).toBe(922_000); expect(f.confirm).not.toHaveBeenCalled(); @@ -359,9 +359,9 @@ it.each(["user", "custom"] as const)( return stream; }); try { - await f.session.prompt("/eleith:codex extend"); + await f.session.prompt("/eleith:codex context on"); const extendedLeaf = f.manager.getLeafId()!; - await f.session.prompt("/eleith:codex restore"); + await f.session.prompt("/eleith:codex context off"); expect(f.session.model?.contextWindow).toBe(272_000); expect(f.confirm).toHaveBeenCalledTimes(1); diff --git a/extensions/codex/src/usage-controller.test.ts b/extensions/codex/src/usage-controller.test.ts index 25f9cb2..52a42ec 100644 --- a/extensions/codex/src/usage-controller.test.ts +++ b/extensions/codex/src/usage-controller.test.ts @@ -1,102 +1,148 @@ -import { expect, it, vi } from "vitest"; +import { rmSync } from "node:fs"; +import { afterAll, afterEach, expect, it, vi } from "vitest"; +import { BorderedLoader, initTheme } from "@earendil-works/pi-coding-agent"; import { CodexAccountError, type CodexAccountRegistry, type fetchCodexUsage } from "./client.ts"; import { UsageController, type UsageHost } from "./usage-controller.ts"; +import { customInteractionUI } from "./custom-interaction.test-helper.ts"; -it("shows temporary status immediately, then clears it before reporting the account", async () => { +const directory = await vi.hoisted(async () => { + const { mkdtempSync } = await import("node:fs"); + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + const directory = mkdtempSync(join(tmpdir(), "codex-usage-controller-test-")); + vi.stubEnv("PI_CODING_AGENT_DIR", directory); + return directory; +}); +initTheme("dark", false); +const cleanup: (() => void)[] = []; +afterEach(() => { + for (const close of cleanup.splice(0)) close(); + vi.useRealTimers(); +}); +afterAll(() => { + vi.unstubAllEnvs(); + rmSync(directory, { recursive: true, force: true }); +}); + +it("renders and animates the native bordered loader, then closes before notifying", async () => { + vi.useFakeTimers(); const f = fixture(); const response = deferred(); - f.fetchUsage.mockImplementation(() => { - expect(f.ctx.ui.setStatus).toHaveBeenCalledExactlyOnceWith( - "eleith-codex-usage", - "Codex: fetching usage…", - ); - return response.promise; - }); + f.fetchUsage.mockReturnValueOnce(response.promise); const request = f.controller.report(f.ctx); - expect(f.ctx.ui.setStatus).toHaveBeenCalledExactlyOnceWith( - "eleith-codex-usage", - "Codex: fetching usage…", - ); + await Promise.resolve(); + const modal = f.ctx.modals[0]; + const loader = modal.component!; + expect(loader).toBeInstanceOf(BorderedLoader); + const lines = loader.render(60); + expect(lines[0]).toBe("─".repeat(60)); + expect(lines.at(-1)).toBe("─".repeat(60)); + expect(lines.join("\n")).toContain("Fetching Codex usage…"); + expect(lines.join("\n")).toMatch(/esc.*cancel/i); + const renderCount = f.ctx.requestRender.mock.calls.length; + vi.advanceTimersByTime(160); + expect(f.ctx.requestRender.mock.calls.length).toBeGreaterThan(renderCount); + expect(loader.render(60)).not.toEqual(lines); expect(f.ctx.ui.notify).not.toHaveBeenCalled(); expect(f.fetchUsage).toHaveBeenCalledExactlyOnceWith( f.ctx.modelRegistry, expect.any(AbortSignal), ); - - f.ctx.ui.notify.mockImplementation(() => { - expect(f.ctx.ui.setStatus).toHaveBeenLastCalledWith("eleith-codex-usage", undefined); + const signal = f.fetchUsage.mock.calls[0][1]!; + const removeAbort = vi.spyOn(signal, "removeEventListener"); + const removeLoaderAbort = vi.spyOn((loader as BorderedLoader).signal, "removeEventListener"); + const notify = f.ctx.ui.notify; + notify.mockImplementation(() => { + expect(modal.closed).toBe(true); + expect(modal.close).toHaveBeenCalledOnce(); + expect(signal.aborted).toBe(false); expire(f.ctx); }); - const notify = f.ctx.ui.notify; - const setStatus = f.ctx.ui.setStatus; + response.resolve({ plan_type: "plus" }); await request; - expect(notify).toHaveBeenCalledExactlyOnceWith( - expect.stringContaining("codex account usage · openai-codex"), - "info", - ); - expect(setStatus).toHaveBeenCalledTimes(2); + expect(notify).toHaveBeenCalledExactlyOnceWith(expect.stringContaining("Plan: plus"), "info"); + expect(removeAbort).toHaveBeenCalledWith("abort", expect.any(Function)); + expect(removeLoaderAbort).toHaveBeenCalledWith("abort", expect.any(Function)); + const stoppedCount = f.ctx.requestRender.mock.calls.length; + vi.advanceTimersByTime(160); + expect(f.ctx.requestRender).toHaveBeenCalledTimes(stoppedCount); }); -it("supersedes using the new context and never lets a late reply clear the new marker", async () => { - const f = fixture(); - const first = deferred(); - const second = deferred(); - f.fetchUsage.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise); - const oldRequest = f.controller.report(f.ctx); - const oldSignal = f.fetchUsage.mock.calls[0][1]!; - const oldUI = f.ctx.ui; - const fresh = host(); - expire(f.ctx); - const newRequest = f.controller.report(fresh); - expect(oldSignal.aborted).toBe(true); - expect(oldUI.setStatus).toHaveBeenCalledTimes(1); - expect(fresh.ui.setStatus.mock.calls).toEqual([ - ["eleith-codex-usage", undefined], - ["eleith-codex-usage", "Codex: fetching usage…"], - ]); +it.each(["success", "error"] as const)( + "Escape aborts and closes immediately even when fetch ignores abort, ignoring late %s", + async (outcome) => { + const f = fixture(); + const response = deferred(); + f.fetchUsage.mockReturnValueOnce(response.promise); + const request = f.controller.report(f.ctx); + await Promise.resolve(); + const modal = f.ctx.modals[0]; + const signal = f.fetchUsage.mock.calls[0][1]!; + const loader = modal.component as BorderedLoader; + const removeAbort = vi.spyOn(signal, "removeEventListener"); + const removeLoaderAbort = vi.spyOn(loader.signal, "removeEventListener"); + loader.handleInput("\u001b"); + expect(loader.signal.aborted).toBe(true); + expect(signal.aborted).toBe(true); + expect(modal.closed).toBe(true); + expect(modal.close).toHaveBeenCalledOnce(); + await request; // No need to settle the uncooperative fetch. + expect(removeAbort).toHaveBeenCalledWith("abort", expect.any(Function)); + expect(removeLoaderAbort).toHaveBeenCalledWith("abort", expect.any(Function)); + const notify = f.ctx.ui.notify; + expire(f.ctx); + if (outcome === "error") response.reject(new Error("private late failure")); + else response.resolve({ plan_type: "late-plan" }); + await response.promise.catch(() => {}); + expect(notify).not.toHaveBeenCalled(); + expect(modal.close).toHaveBeenCalledOnce(); + }, +); - first.resolve({ plan_type: "old-plan" }); - await oldRequest; - expect(oldUI.notify).not.toHaveBeenCalled(); - expect(fresh.ui.setStatus).toHaveBeenCalledTimes(2); - expect(fresh.ui.notify).not.toHaveBeenCalled(); - second.resolve({ plan_type: "new-plan" }); - await newRequest; - expect(fresh.ui.setStatus).toHaveBeenLastCalledWith("eleith-codex-usage", undefined); - expect(fresh.ui.setStatus).toHaveBeenCalledTimes(3); - expect(fresh.ui.notify).toHaveBeenCalledExactlyOnceWith( - expect.stringContaining("Plan: new-plan"), - "info", - ); +it("treats external component disposal as cancellation, not just an animation stop", async () => { + const f = fixture(); + const response = deferred(); + f.fetchUsage.mockReturnValueOnce(response.promise); + const request = f.controller.report(f.ctx); + await Promise.resolve(); + const modal = f.ctx.modals[0]; + const loader = modal.component as BorderedLoader; + loader.dispose(); + expect(loader.signal.aborted).toBe(false); // Native disposal itself does not abort. + expect(f.fetchUsage.mock.calls[0][1]!.aborted).toBe(true); + expect(modal.closed).toBe(true); + await request; + expect(f.ctx.ui.notify).not.toHaveBeenCalled(); + response.resolve({ plan_type: "late" }); + await response.promise; + expect(modal.close).toHaveBeenCalledOnce(); }); it.each(["cancel", "dispose"] as const)( - "%s clears with fresh context before expiry and ignores late success and error", + "%s closes only the owned modal with no expired context access or late delivery", async (action) => { for (const fails of [false, true]) { const f = fixture(); const response = deferred(); - f.fetchUsage.mockReturnValue(response.promise); + f.fetchUsage.mockReturnValueOnce(response.promise); const request = f.controller.report(f.ctx); + await Promise.resolve(); + const modal = f.ctx.modals[0]; const signal = f.fetchUsage.mock.calls[0][1]!; - const oldUI = f.ctx.ui; - const fresh = host(); - const freshUI = fresh.ui; + const notify = f.ctx.ui.notify; expire(f.ctx); - f.controller[action](fresh); + f.controller[action](); expect(signal.aborted).toBe(true); - expect(freshUI.setStatus).toHaveBeenCalledExactlyOnceWith("eleith-codex-usage", undefined); - f.controller[action](fresh); - expect(freshUI.setStatus).toHaveBeenCalledTimes(1); - expire(fresh); - if (fails) response.reject(new Error("private late failure")); - else response.resolve({ plan_type: "plus" }); + expect(modal.closed).toBe(true); + expect(modal.close).toHaveBeenCalledOnce(); + f.controller[action](); await request; - expect(oldUI.setStatus).toHaveBeenCalledTimes(1); - expect(oldUI.notify).not.toHaveBeenCalled(); - expect(freshUI.setStatus).toHaveBeenCalledTimes(1); - expect(freshUI.notify).not.toHaveBeenCalled(); + if (fails) response.reject(new Error("private late failure")); + else response.resolve({ plan_type: "late" }); + await response.promise.catch(() => {}); + expect(notify).not.toHaveBeenCalled(); + expect(modal.close).toHaveBeenCalledOnce(); if (action === "dispose") { await f.controller.report(f.ctx); expect(f.fetchUsage).toHaveBeenCalledOnce(); @@ -105,38 +151,125 @@ it.each(["cancel", "dispose"] as const)( }, ); -it("can start another explicit request after cancellation without waiting for the old request", async () => { +it("supersedes before adoption and stops the old animation before any manual disposal", async () => { + vi.useFakeTimers(); + const f = fixture(); + const first = deferred(); + const second = deferred(); + f.fetchUsage.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise); + const oldRequest = f.controller.report(f.ctx); + const oldModal = f.ctx.modals[0]; + const oldSignal = f.fetchUsage.mock.calls[0][1]!; + const oldNotify = f.ctx.ui.notify; + expire(f.ctx); + const fresh = host("tui", f.ctx.surface); + const newRequest = f.controller.report(fresh); + expect(oldSignal.aborted).toBe(true); + expect(oldModal.closed).toBe(true); + await oldRequest; + await Promise.resolve(); + const newModal = fresh.modals[1]; + expect(oldModal.adopted).toBe(false); + expect(newModal.adopted).toBe(true); + expect(newModal.closed).toBe(false); + expect(f.ctx.surface.focusedComponent).toBe(newModal.component); + expect(f.ctx.surface.components).toEqual([f.ctx.surface.mainEditor, newModal.component]); + const oldRenderCount = oldModal.requestRender.mock.calls.length; + const newRenderCount = newModal.requestRender.mock.calls.length; + vi.advanceTimersByTime(320); + // This must precede manual disposal/fixture cleanup: early done is not adopted by Pi. + expect(oldModal.requestRender).toHaveBeenCalledTimes(oldRenderCount); + expect(newModal.requestRender.mock.calls.length).toBeGreaterThan(newRenderCount); + oldModal.component!.dispose?.(); + (oldModal.component as BorderedLoader).handleInput("\u001b"); + first.reject(new CodexAccountError("http", "Late rate limit", 60_000)); + await first.promise.catch(() => {}); + expect(newModal.closed).toBe(false); + expect(newModal.close).not.toHaveBeenCalled(); + expect(oldNotify).not.toHaveBeenCalled(); + second.resolve({ plan_type: "new-plan" }); + await newRequest; + expect(newModal.close).toHaveBeenCalledOnce(); + expect(fresh.ui.notify).toHaveBeenCalledExactlyOnceWith( + expect.stringContaining("Plan: new-plan"), + "info", + ); + await f.controller.report(fresh); + expect(f.fetchUsage).toHaveBeenCalledTimes(3); // The stale rate limit did not apply. +}); + +it.each([ + ["complete", false], + ["complete", true], + ["cancel", false], + ["cancel", true], +] as const)( + "old usage %s preserves a newer interaction (overlay: %s) on the same UI surface", + async (action, overlay) => { + const f = fixture(); + const response = deferred(); + f.fetchUsage.mockReturnValueOnce(response.promise); + const request = f.controller.report(f.ctx); + await Promise.resolve(); + const usageModal = f.ctx.modals[0]; + const surface = f.ctx.surface; + expect(f.ctx.ui.custom).toHaveBeenCalledWith(expect.any(Function), { overlay: true }); + expect(surface.focusedComponent).toBe(usageModal.component); + expect(surface.components).toEqual([surface.mainEditor, usageModal.component]); + + const newerComponent = { + render: () => ["newer interaction"], + invalidate() {}, + dispose: vi.fn(), + }; + const newerRequest = f.ctx.ui.custom(() => newerComponent, { overlay }); + await Promise.resolve(); + const newerModal = f.ctx.modals[1]; + expect(surface.focusedComponent).toBe(newerComponent); + expect(surface.editorComponent).toBe(overlay ? surface.mainEditor : newerComponent); + expect(surface.components).toContain(usageModal.component); + expect(surface.components).toContain(newerComponent); + + if (action === "cancel") f.controller.cancel(); + else response.resolve({ plan_type: "plus" }); + await request; + expect(usageModal.closed).toBe(true); + expect(newerModal.closed).toBe(false); + expect(newerModal.close).not.toHaveBeenCalled(); + expect(newerComponent.dispose).not.toHaveBeenCalled(); + expect(surface.focusedComponent).toBe(newerComponent); + expect(surface.editorComponent).toBe(overlay ? surface.mainEditor : newerComponent); + expect(surface.components).toEqual( + overlay ? [surface.mainEditor, newerComponent] : [newerComponent], + ); + expect(newerModal.lines).toEqual(["newer interaction"]); + + // The newer interaction still owns its completion and can return focus normally. + newerModal.close(); + await newerRequest; + expect(newerComponent.dispose).toHaveBeenCalledOnce(); + expect(surface.focusedComponent).toBe(surface.mainEditor); + expect(surface.components).toEqual([surface.mainEditor]); + if (action === "cancel") { + response.resolve({ plan_type: "late" }); + await response.promise; + expect(f.ctx.ui.notify).not.toHaveBeenCalled(); + } + }, +); + +it("suppresses a report when the session changes without an explicit cancellation", async () => { const f = fixture(); const response = deferred(); f.fetchUsage.mockReturnValueOnce(response.promise); - const oldRequest = f.controller.report(f.ctx); - f.controller.cancel(f.ctx); - await f.controller.report(f.ctx); - expect(f.ctx.ui.notify).toHaveBeenCalledExactlyOnceWith( - expect.stringContaining("Plan: plus"), - "info", - ); - expect(f.ctx.ui.setStatus).toHaveBeenCalledTimes(4); - response.reject(new CodexAccountError("http", "Late rate limit", 60_000)); - await oldRequest; - await f.controller.report(f.ctx); - expect(f.fetchUsage).toHaveBeenCalledTimes(3); - expect(f.ctx.ui.setStatus).toHaveBeenCalledTimes(6); -}); - -it("clears a completed request without reporting to a replacement session", async () => { - const f = fixture(); - const response = deferred(); - f.fetchUsage.mockReturnValue(response.promise); const request = f.controller.report(f.ctx); - f.ctx.sessionManager.getSessionId.mockReturnValue("different-session"); + f.ctx.sessionManager.getSessionId.mockReturnValue("replacement-session"); response.resolve({ plan_type: "plus" }); await request; + expect(f.ctx.modals[0].closed).toBe(true); expect(f.ctx.ui.notify).not.toHaveBeenCalled(); - expect(f.ctx.ui.setStatus).toHaveBeenLastCalledWith("eleith-codex-usage", undefined); - expect(f.ctx.ui.setStatus).toHaveBeenCalledTimes(2); - f.controller.cancel(f.ctx); - expect(f.ctx.ui.setStatus).toHaveBeenCalledTimes(2); + f.controller.cancel(); + expect(f.ctx.modals[0].close).toHaveBeenCalledOnce(); }); it.each([ @@ -158,137 +291,93 @@ it.each([ new CodexAccountError("timeout", "Codex account request timed out."), "Codex account request timed out.", ], -] as const)("clears status before reporting an %s", async (_name, data, error, message) => { +] as const)("closes before reporting an %s", async (_name, data, error, message) => { const f = fixture(); const response = deferred(); - f.fetchUsage.mockReturnValue(response.promise); + f.fetchUsage.mockReturnValueOnce(response.promise); const request = f.controller.report(f.ctx); - expect(f.ctx.ui.notify).not.toHaveBeenCalled(); f.ctx.ui.notify.mockImplementation(() => { - expect(f.ctx.ui.setStatus).toHaveBeenLastCalledWith("eleith-codex-usage", undefined); + expect(f.ctx.modals[0].closed).toBe(true); + expect(f.ctx.modals[0].close).toHaveBeenCalledOnce(); }); if (error) response.reject(error); else response.resolve(data); await request; expect(f.ctx.ui.notify).toHaveBeenCalledExactlyOnceWith(message, "warning"); - expect(f.ctx.ui.setStatus).toHaveBeenCalledTimes(2); }); -it("honors retry-after without polling or showing a fetching marker for a blocked request", async () => { +it("honors retry-after without polling or opening a loader for a blocked request", async () => { const f = fixture(); f.fetchUsage.mockRejectedValueOnce( new CodexAccountError("http", "Codex account service is rate limited.", 60_000), ); await f.controller.report(f.ctx); - expect(f.ctx.ui.setStatus).toHaveBeenCalledTimes(2); await f.controller.report(f.ctx); expect(f.fetchUsage).toHaveBeenCalledOnce(); - expect(f.ctx.ui.setStatus).toHaveBeenCalledTimes(2); + expect(f.ctx.ui.custom).toHaveBeenCalledOnce(); expect(f.ctx.ui.notify).toHaveBeenLastCalledWith( "Codex account service is rate limited. Try again in 60s.", "warning", ); f.clock.now += 60_000; - expect(f.fetchUsage).toHaveBeenCalledOnce(); await f.controller.report(f.ctx); expect(f.fetchUsage).toHaveBeenCalledTimes(2); - expect(f.ctx.ui.setStatus).toHaveBeenCalledTimes(4); + expect(f.ctx.ui.custom).toHaveBeenCalledTimes(2); expect(f.ctx.ui.notify).toHaveBeenLastCalledWith(expect.stringContaining("Plan: plus"), "info"); }); -it.each([ - { - outcome: "success", - fetch: async () => ({ plan_type: "plus" }), - message: expect.stringContaining("Plan: plus"), - level: "info", - }, - { - outcome: "failure", - fetch: async () => { - throw new Error("private failure"); - }, - message: "Could not read Codex account usage.", - level: "warning", - }, -] as const)( - "reports $outcome after plain progress without UI", - async ({ fetch, message, level }) => { - const f = fixture(false); - f.fetchUsage.mockImplementationOnce(fetch); - const request = f.controller.report(f.ctx); - expect(f.ctx.ui.notify).toHaveBeenCalledExactlyOnceWith("Fetching Codex usage…", "info"); - expect(f.ctx.ui.setStatus).not.toHaveBeenCalled(); - - await request; - expect(f.ctx.ui.notify).toHaveBeenCalledTimes(2); - expect(f.ctx.ui.notify).toHaveBeenLastCalledWith(message, level); - expect(f.ctx.ui.setStatus).not.toHaveBeenCalled(); +it.each(["rpc", "print", "json"] as const)( + "%s uses only progress notifications, never custom terminal UI", + async (mode) => { + for (const fails of [false, true]) { + const f = fixture(mode); + if (fails) f.fetchUsage.mockRejectedValueOnce(new Error("private failure")); + const request = f.controller.report(f.ctx); + expect(f.ctx.ui.notify).toHaveBeenCalledExactlyOnceWith("Fetching Codex usage…", "info"); + await request; + expect(f.ctx.ui.custom).not.toHaveBeenCalled(); + expect(f.ctx.ui.notify).toHaveBeenCalledTimes(2); + expect(f.ctx.ui.notify).toHaveBeenLastCalledWith( + fails ? "Could not read Codex account usage." : expect.stringContaining("Plan: plus"), + fails ? "warning" : "info", + ); + } }, ); -it.each(["cancel", "dispose"] as const)( - "%s suppresses completion after plain progress without UI", - async (action) => { - const f = fixture(false); +it.each(["rpc", "print"] as const)( + "%s cancellation settles without waiting, and another request can start", + async (mode) => { + const f = fixture(mode); const response = deferred(); f.fetchUsage.mockReturnValueOnce(response.promise); - const request = f.controller.report(f.ctx); - expect(f.ctx.ui.notify).toHaveBeenCalledExactlyOnceWith("Fetching Codex usage…", "info"); - expect(f.ctx.ui.setStatus).not.toHaveBeenCalled(); - - f.controller[action](f.ctx); + const oldRequest = f.controller.report(f.ctx); + f.controller.cancel(); + await oldRequest; expect(f.fetchUsage.mock.calls[0][1]!.aborted).toBe(true); - response.resolve({ plan_type: "old-plan" }); - await request; - expect(f.ctx.ui.notify).toHaveBeenCalledExactlyOnceWith("Fetching Codex usage…", "info"); - expect(f.ctx.ui.setStatus).not.toHaveBeenCalled(); + await f.controller.report(f.ctx); + expect(f.ctx.ui.notify.mock.calls).toEqual([ + ["Fetching Codex usage…", "info"], + ["Fetching Codex usage…", "info"], + [expect.stringContaining("Plan: plus"), "info"], + ]); + response.reject(new Error("late failure")); + await response.promise.catch(() => {}); + expect(f.ctx.ui.notify).toHaveBeenCalledTimes(3); + expect(f.ctx.ui.custom).not.toHaveBeenCalled(); }, ); -it("replaces a request without UI and ignores the old completion", async () => { - const f = fixture(false); - const response = deferred(); - f.fetchUsage.mockReturnValueOnce(response.promise); - const oldRequest = f.controller.report(f.ctx); - const oldSignal = f.fetchUsage.mock.calls[0][1]!; - expect(f.ctx.ui.notify).toHaveBeenCalledExactlyOnceWith("Fetching Codex usage…", "info"); - - await f.controller.report(f.ctx); - expect(oldSignal.aborted).toBe(true); - expect(f.fetchUsage).toHaveBeenCalledTimes(2); - expect(f.ctx.ui.notify.mock.calls).toEqual([ - ["Fetching Codex usage…", "info"], - ["Fetching Codex usage…", "info"], - [expect.stringContaining("Plan: plus"), "info"], - ]); - - response.resolve({ plan_type: "old-plan" }); - await oldRequest; - expect(f.ctx.ui.notify).toHaveBeenCalledTimes(3); - expect(f.ctx.ui.setStatus).not.toHaveBeenCalled(); -}); - -it("does not hide a final notification failure or try to clear after it invalidates context", async () => { +it("propagates a final notification failure without touching the invalidated context again", async () => { const f = fixture(); - const setStatus = f.ctx.ui.setStatus; f.ctx.ui.notify.mockImplementation(() => { expire(f.ctx); throw new Error("UI failure"); }); await expect(f.controller.report(f.ctx)).rejects.toThrow("UI failure"); - expect(setStatus).toHaveBeenCalledTimes(2); - f.controller.cancel(host()); - expect(setStatus).toHaveBeenCalledTimes(2); -}); - -it("does not swallow a status cleanup failure", async () => { - const f = fixture(); - f.ctx.ui.setStatus.mockImplementation((_key, text) => { - if (text === undefined) throw new Error("Status cleanup failure"); - }); - await expect(f.controller.report(f.ctx)).rejects.toThrow("Status cleanup failure"); - expect(f.ctx.ui.notify).not.toHaveBeenCalled(); + expect(f.ctx.modals[0].closed).toBe(true); + f.controller.cancel(); + expect(f.ctx.modals[0].close).toHaveBeenCalledOnce(); }); function deferred() { @@ -301,28 +390,37 @@ function deferred() { return { promise, resolve, reject }; } -function host(hasUI = true) { +function host(mode: UsageHost["mode"] = "tui", surface = customInteractionUI()) { + cleanup.push(() => surface.dispose()); return { - hasUI, + mode, modelRegistry: {} as CodexAccountRegistry, sessionManager: { getSessionId: vi.fn(() => "session") }, ui: { notify: vi.fn(), - setStatus: vi.fn(), + custom: surface.custom, }, - } satisfies UsageHost; + modals: surface.modals, + requestRender: surface.requestRender, + surface, + } satisfies UsageHost & { + modals: typeof surface.modals; + requestRender: typeof surface.requestRender; + surface: typeof surface; + }; } -function fixture(hasUI = true) { +function fixture(mode: UsageHost["mode"] = "tui") { const clock = { now: Date.parse("2026-01-10T12:00:00Z") }; const fetchUsage = vi.fn(async () => ({ plan_type: "plus" })); - const ctx = host(hasUI); + const ctx = host(mode); const controller = new UsageController(fetchUsage, () => clock.now); + cleanup.push(() => controller.dispose()); return { controller, ctx, fetchUsage, clock }; } function expire(ctx: UsageHost) { - for (const key of ["hasUI", "sessionManager", "modelRegistry", "ui"]) { + for (const key of ["mode", "sessionManager", "modelRegistry", "ui"]) { Object.defineProperty(ctx, key, { get: () => { throw new Error("Expired context"); diff --git a/extensions/codex/src/usage-controller.ts b/extensions/codex/src/usage-controller.ts index 5d6cf8e..f3ded72 100644 --- a/extensions/codex/src/usage-controller.ts +++ b/extensions/codex/src/usage-controller.ts @@ -1,20 +1,21 @@ -import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; +import { BorderedLoader, type ExtensionContext } from "@earendil-works/pi-coding-agent"; import { CodexAccountError, fetchCodexUsage, type CodexAccountRegistry } from "./client.ts"; import { formatCodexUsage } from "./usage.ts"; -const STATUS_KEY = "eleith-codex-usage"; - export interface UsageHost { - readonly hasUI: boolean; + readonly mode: ExtensionContext["mode"]; readonly modelRegistry: CodexAccountRegistry; readonly sessionManager: Pick; - readonly ui: Pick; + readonly ui: Pick; } +type UsageResult = { data: unknown } | { error: unknown }; + interface PendingUsage { readonly controller: AbortController; readonly sessionId: string; - indicatorVisible: boolean; + // Owns only this interaction's loader and completion callback, never its context. + closeLoader?: () => void; } export class UsageController { @@ -29,7 +30,7 @@ export class UsageController { async report(ctx: UsageHost): Promise { if (this.disposed) return; - this.cancel(ctx); + this.cancel(); if (this.now() < this.retryAt) { const seconds = Math.ceil((this.retryAt - this.now()) / 1000); ctx.ui.notify(`Codex account service is rate limited. Try again in ${seconds}s.`, "warning"); @@ -38,27 +39,26 @@ export class UsageController { const operation: PendingUsage = { controller: new AbortController(), sessionId: ctx.sessionManager.getSessionId(), - indicatorVisible: ctx.hasUI, }; this.pending = operation; - if (operation.indicatorVisible) { - ctx.ui.setStatus(STATUS_KEY, "Codex: fetching usage…"); - } else { - ctx.ui.notify("Fetching Codex usage…", "info"); - } - if (!this.canDeliverReport(operation, ctx)) { - this.finish(operation, ctx); - return; - } - let result: { data: unknown } | { error: unknown }; + let result: UsageResult | undefined; try { - result = { data: await this.fetchUsage(ctx.modelRegistry, operation.controller.signal) }; + const registry = ctx.modelRegistry; + if (ctx.mode === "tui") { + result = await this.readUsageWithLoader(operation, registry, ctx.ui); + } else { + // RPC has UI, but cannot host terminal components. Print/JSON use this too. + ctx.ui.notify("Fetching Codex usage…", "info"); + result = await this.readUsageUntilCancelled(operation, registry); + } } catch (error) { - result = { error }; + this.cancelOperation(operation); + throw error; } - if (!this.canDeliverReport(operation, ctx)) { - this.finish(operation, ctx); + // The custom interaction has closed before any final notification or context access. + if (!this.canDeliverReport(operation, ctx) || result === undefined) { + this.cancelOperation(operation); return; } @@ -77,40 +77,98 @@ export class UsageController { if (report !== undefined) level = "info"; } - // Clear before notifying: the notification itself may reload the extension. - this.clearIndicator(operation, ctx); - if (!this.canDeliverReport(operation, ctx)) { - this.finish(operation, ctx); - return; - } + // A notification may reload the extension. Nothing may use ctx after this call. this.pending = undefined; ctx.ui.notify(message, level); } - cancel(ctx: UsageHost): void { + cancel(): void { const operation = this.pending; - this.pending = undefined; - if (!operation) return; - operation.controller.abort(); - this.clearIndicator(operation, ctx); + if (operation) this.cancelOperation(operation); } - dispose(ctx: UsageHost): void { + dispose(): void { if (this.disposed) return; this.disposed = true; - this.cancel(ctx); + this.cancel(); } - private clearIndicator(operation: PendingUsage, ctx: UsageHost): void { - if (!operation.indicatorVisible) return; - operation.indicatorVisible = false; - if (ctx.hasUI) ctx.ui.setStatus(STATUS_KEY, undefined); + private readUsageWithLoader( + operation: PendingUsage, + registry: CodexAccountRegistry, + ui: UsageHost["ui"], + ): Promise { + // An owned overlay closes only itself; Pi 1.3 non-overlay completion resets the + // editor/focus even when a newer interaction has taken over the shared surface. + return ui.custom( + (tui, theme, _kb, done) => { + const loader = new BorderedLoader(tui, theme, "Fetching Codex usage…"); + let closed = false; + const onLoaderAbort = () => this.cancelOperation(operation); + const closeLoader = (result?: UsageResult) => { + if (closed) return; + closed = true; + operation.closeLoader = undefined; + loader.signal.removeEventListener("abort", onLoaderAbort); + // Dispose explicitly too: Pi may receive done before adopting the factory result. + loader.dispose(); + done(result); + }; + operation.closeLoader = () => closeLoader(); + loader.signal.addEventListener("abort", onLoaderAbort, { once: true }); + + // Native dispose stops animation, but does not abort its signal. External disposal + // must cancel our HTTP wait; disposal during successful close must not cancel delivery. + const disposeLoader = loader.dispose.bind(loader); + loader.dispose = () => { + disposeLoader(); + if (!closed) this.cancelOperation(operation); + }; + + if (this.pending !== operation || operation.controller.signal.aborted) closeLoader(); + else void this.readUsageUntilCancelled(operation, registry).then(closeLoader); + return loader; + }, + { overlay: true }, + ); } - private finish(operation: PendingUsage, ctx: UsageHost): void { - if (this.pending !== operation) return; - this.pending = undefined; - this.clearIndicator(operation, ctx); + private readUsageUntilCancelled( + operation: PendingUsage, + registry: CodexAccountRegistry, + ): Promise { + const { signal } = operation.controller; + // Do not depend on an injected fetch implementation observing AbortSignal. Its late + // fulfillment/rejection is observed, but can neither retain ctx nor reopen/close UI. + return new Promise((resolve) => { + let settled = false; + const finishRead = (result?: UsageResult) => { + if (settled) return; + settled = true; + signal.removeEventListener("abort", onReadAbort); + resolve(result); + }; + const onReadAbort = () => finishRead(); + signal.addEventListener("abort", onReadAbort, { once: true }); + if (signal.aborted) { + finishRead(); + return; + } + try { + void this.fetchUsage(registry, signal).then( + (data) => finishRead({ data }), + (error: unknown) => finishRead({ error }), + ); + } catch (error) { + finishRead({ error }); + } + }); + } + + private cancelOperation(operation: PendingUsage): void { + if (this.pending === operation) this.pending = undefined; + operation.controller.abort(); + operation.closeLoader?.(); } private canDeliverReport(operation: PendingUsage, ctx: UsageHost): boolean { diff --git a/extensions/codex/src/usage-sdk.test.ts b/extensions/codex/src/usage-sdk.test.ts index 35b1797..76bc0f6 100644 --- a/extensions/codex/src/usage-sdk.test.ts +++ b/extensions/codex/src/usage-sdk.test.ts @@ -8,14 +8,17 @@ import { type OAuthCredential, } from "@earendil-works/pi-ai"; import { + BorderedLoader, createAgentSession, DefaultResourceLoader, + initTheme, ModelRuntime, SessionManager, SettingsManager, type ExtensionUIContext, } from "@earendil-works/pi-coding-agent"; import codex from "./index.ts"; +import { customInteractionUI } from "./custom-interaction.test-helper.ts"; const directory = await vi.hoisted(async () => { const { mkdtempSync } = await import("node:fs"); @@ -52,24 +55,38 @@ it("SDK usage reuses Pi's OAuth without a model turn or credential write", async vi.stubGlobal("fetch", accountRequest); const { runtime, model, accessToken, credentialWrites, modelTurns } = await createOfflineCodexRuntime(); - const { session, sessionManager, notify, setStatus, extensionErrors, closeSession } = - await openCodexExtensionSession(runtime, model); + const { + session, + sessionManager, + notify, + setStatus, + custom, + modals, + extensionErrors, + closeSession, + } = await openCodexExtensionSession(runtime, model); try { const branch = structuredClone(sessionManager.getBranch()); await session.prompt("/eleith:codex"); expect(accountRequest).not.toHaveBeenCalled(); + notify.mockImplementation(() => { + expect(modals[0].closed).toBe(true); + expect(modals[0].close).toHaveBeenCalledOnce(); + }); await session.prompt("/eleith:codex usage"); + expect(custom).toHaveBeenCalledOnce(); + expect(custom).toHaveBeenCalledWith(expect.any(Function), { overlay: true }); + expect(modals[0].component).toBeInstanceOf(BorderedLoader); + expect(modals[0].lines.join("\n")).toContain("Fetching Codex usage…"); + expect(modals[0].lines.join("\n")).toMatch(/esc.*cancel/i); expect(notify).toHaveBeenLastCalledWith( expect.stringContaining("5h window: 75% remaining (25% used)"), "info", ); expect(accountRequest).toHaveBeenCalledOnce(); - expect(setStatus.mock.calls).toEqual([ - ["eleith-codex-usage", "Codex: fetching usage…"], - ["eleith-codex-usage", undefined], - ]); - expect(setStatus.mock.invocationCallOrder[1]).toBeLessThan( + expect(setStatus).not.toHaveBeenCalled(); + expect(modals[0].close.mock.invocationCallOrder[0]).toBeLessThan( notify.mock.invocationCallOrder.at(-1)!, ); const headers = new Headers(accountRequest.mock.calls[0][1]?.headers); @@ -125,6 +142,7 @@ async function createOfflineCodexRuntime() { } async function openCodexExtensionSession(runtime: ModelRuntime, model: Model) { + initTheme("dark", false); const settingsManager = SettingsManager.inMemory({ packages: [], enableInstallTelemetry: false, @@ -158,10 +176,12 @@ async function openCodexExtensionSession(runtime: ModelRuntime, model: Model extensionErrors.push(error), }); return { @@ -169,10 +189,16 @@ async function openCodexExtensionSession(runtime: ModelRuntime, model: Model